Как повернуть время вспять и выиграть Assembly с DOS-демкой в 2025-м году. Статья с картинками.

Привет, меня зовут bitl и я из тех, кто в детстве не наигрался с кодингом под DOS, и занимаюсь этим в 2020-х... Впрочем, я об этом уже рассказывал в прошлой статье. Тогда мы разбирались с таким явлением, как "сайзкодинг" в рамках демосцены, и как напрочь откомпилировать себе мозги. Вне зависимости - получилось это у вас, или нет, - сейчас я предлагаю почитать про другие способы.

Если сайзкодинг - это про размер, и оптимизация скорости исполняемого кода там отходит на второй план, то в данной статье речь пойдет как раз про скорость, ухищрения, трюки и просто наглый обман незадачливого зрителя с одной лишь целью: показать на древнем железе красочные и динамичные видеоэффекты под музыку и с невсратым FPS. Ну и, конечно, дизайн, олдскульный вайб, фанк, грув - всё это тоже важные составляющие, но говорить мы будем в основном про кодерскую эстетику (30-летней давности).

Маленькое пояснение. Наверняка о "демках" знают большинство читателей Хабра, но часто встречается мнение, что демки - это вот те "крошечные программки". Тут показания могут разниться: кто-то помнит какую-нибудь fr-08 (64Кб), кто-то elevated (4kb), или что-то ещё более миниатюрное вроде memories (256 байт), а кто-то обязательно заговорит про игру .kkrieger (96Кб). И да, это все "демосцена". Но маленький размер - это лишь вопрос номинации. Если речь о номинации "мегадемо", то размер уже не является основным критерием, хотя в разные годы и существовали определенные рамки (4 Мб, 16Мб, и т.д.), сейчас бывают демки и больше 1 гигабайта (и не всеми сценерами это приветствуется). Прежде чем возмущаться, вспомните, доставляли ли вам те же 3DMark'и 2000, 2001 и т.д.? Это ведь тоже "демо", хоть и коммерческие. А они занимали отнюдь не 64кб :) А классика на все времена Second Reality занимает 2.5 Мб! И никто в 93-м году не бухтел (да не, бухтели, конечно, но это ни на что не повлияло).
В общем, я хочу сказать, что бывают и "большие демки", где просто авторам немножко развязывают руки и позволяют хранить музыку, текстуры и меши в файлах, главное чтобы результат радовал и восхищал. Всё, я оправдался!

Оглавление:

Начало
Tech data & Requirements
1. Кубики в прозрачном кубе
2. Гибкий куб в воде
3. We are back!
4. Тоннель из кружков
5. Деформация картинок
6. Bump-эффект
7. Прыгающие шары
8. Плазма
9. Полет над полем шестиугольников
10. Зеркальный додекаэдр
11. Shadebobs
12. Куб с частицами
Пара слов про оптимизацию
FAQ
Полезные ссылки

Начало

В августе этого (2025-го) года я и мой коллега по цеху @Manwe_SandSзавершили масштабный долгострой, находившийся в производстве почитай два года (ну, мы уже старые пердуны, все делаем медленно, кумпешка же уже не варит) — ретро‑демку «Demoded» для MS‑DOS, ориентированную на IBM PC 486DX2-66MHz (или лучше).
Признаюсь, это был самый трудоёмкий и масштабный демо‑проект, который я когда‑либо делал. И даже для такого ветерана демосцены, как Manwe, наверняка, этот опыт тоже стал незабываемым, особенно учитывая моё «продюсирование» (почитать его статью о работе над музыкальным треком к демке можно тут: https://t.me/manwe_live/61)

К счастью, демка оправдала вложенные в нее силы и выиграла конкурс «Oldskool demo» на Assembly 2025. А в этом году конкуренция была неожиданно жёсткой, было много сильных демо в данной номинации, которые заставили поволноваться. Но все обошлось. В общем, сбылась мечта идиота детства, пусть и спустя 30 лет.

Так что, перед тем как продолжить, предлагаю посмотреть демо, поскольку дальше мы будем разбирать эффекты и сцены именно из неё:

Ой, это всего-лишь картинка, ссылки на видео ниже
Ой, это всего-лишь картинка, ссылки на видео ниже

Скачать саму демку (а запустить можно, например, из DOSBox) можно тут:
Scene.org: https://files.scene.org/view/demos/groups/7dump/demoded.zip
Pouet.net: https://www.pouet.net/prod.php?which=104607
Demozoo: https://demozoo.org/productions/375937/

Ну а кому лень, посмотрите капчур (сделан с Pentium 75 MHz)
YT: https://www.youtube.com/watch?v=C2GWvm4uMrM
VK: https://vk.com/video-214185861_456239831
MP4: http://chiptown.ru/asm2025/demoded1080p_max.mp4

Если вы посмотрели и недоумеваете: что тут "кодить"? Какие два года? Да я за вечер это на OpenGL напишу - тогда предлагаю сообщить об этом в комментариях, нам очень важно об этом узнать!

Ну а кому интересно - как это сделано: продолжаем.

Tech data & Requirements

Немного вводных технических данных. Демо написано в Turbo Pascal 7.0 (ну... олдскул же, и всё такое), работает в «реальном режиме» (real mode), то есть 16-битный режим адресации памяти («640 кб хватит всем»). Однако используется и XMS‑память (с помощью himem.sys) в которую на старте загружаются все сохраненные в data‑файле ресурсы (текстуры, таблицы, шрифты и т. п.), чтобы потом быстро подгружаться в базовую память по мере необходимости и не кряхтеть винчестером. Сами видеоэффекты в процессе работы используют только dos‑память, поскольку XMS‑память в реальном режиме полноценно использовать не выйдет, разве что как «файл подкачки». В качестве музыкального трекерного плеера используется библиотека MIDAS 0.4 от Sahara Surfers (1995 года). Саундтрек демки написан в трекерном формате s3m (Scream Tracker 3).

Ну и суммарно, для работы демо нужно: 80386-совместимый процессор, 387-й сопроцессор, быстрая VGA‑совместимая видеокарта (желательно VLB или PCI),
570 Кб базовой памяти, 2 Мб XMS памяти, звуковая карта Gravis Ultrasound с 1 Мб памяти, либо Sound Blaster‑совместимая карта и 1 Мб EMS‑памяти (emm386.exe — помните такой?). В общем, обычные требования для той поры, 1993–1995 годов.

1. Кубики в прозрачном кубе

Glenz-куб с сотней кубиков
Glenz-куб с сотней кубиков

Первая сцена, и, наверное, самая затейливая и нагруженная "демо-технологиями" :)
С чего бы начать... Давайте с очевидного. Для начала решалась задача - написать алгоритм коллизии частицы с гранями куба. Для геймдева видимо довольно тривиальная задача. Я сначала было начал описывать алгоритм и формулы... Но потом подумал... Это же скука смертная. Я не собираюсь тут делать вид, что хорошо знаю алгебру, тригонометрию и не забыл деление в столбик. Давайте я просто опишу основные фичи и разоблачу некоторое мошенничество.

1. На самом деле в данной сцене физика столкновений упрощена до минимума. Кубики не взаимодействуют друг с другом. Они даже не отсортированы по удалённости от наблюдателя. А коллизия и отскок рассчитываются относительно центров кубиков, как если бы это были просто частицы.

2. В большом кубе скачет 100 маленьких. Но все они только след одного, первого, точнее, как бы его "лог" (или история). Можно сказать, что это спрайты, которые создаются в реальном времени (1 прошедший кадр, 1 новый спрайт). Только хранятся они не в виде битмапов, а в виде координат отрезков, закрашивающих грани.

Здесь надо понимать, что мы также движемся по буферу, как по кинопленке
Здесь надо понимать, что мы также движемся по буферу, как по кинопленке

Таким образом поворот, проекция, границы закраски - вычисляются только для одного кубика, остальные рисуются уже на всём готовом, ну с поправкой на положение на экране. В процессе мы движемся по "логу" вперёд, поэтому все кубики вращаются, повторяя вращения "первого" с некоторым отставанием. Можно это представить так: у нас есть закольцованная киноплёнка, протянутая через несколько кинопроекторов, а также через одну кинокамеру, которая снимает кубик, обновляя старый кадр (такая вот волшебная многоразовая плёнка). И да, конечно их вращение никак не зависит от столкновений или чего-то еще.
Зачем эти выкрутасы? Затем, что честно обсчитать даже поворот вершин сотни отдельных кубиков (800 вершин) для 486-го будет не очень лёгкой задачей, не говоря уже о честной физике столкновений. А ведь ещё проекция (с отсечением нелицевых граней и перспективным искажением), растеризация.
Но скажите, если вы смотрели демо до того, как прочитали данный абзац - вы заметили подвох?

3. Ещё один трюк связан с имитацией полупрозрачности граней большого куба.
Напомню, мы используем 8-битный VGA‑режим, 256 цветов. В отличие от современных графических режимов, здесь пиксель представлен 1 байтом, значение которого означает номер (индекс) цвета в предустановленной палитре. Так что мы не можем просто так оперировать RGB‑компонентами как в TrueColor. К счастью мы можем модифицировать палитру для каждого кадра. И мы это делаем. В данной сцене грани большого куба меняют своё «освещение» и «степень прозрачности» в зависимости от угла. Каждая грань куба в разные моменты сцены окрашивается в разные оттенки, сохраняя при этом корректное наложение цветов на внутренние разноцветные кубики. Как это работает? Для начала, цвета (номера цветов) маленьких кубиков и цвета лицевых граней большого куба подобраны так, чтобы при их сложении в буфере формировались отдельные диапазоны значений: под первой гранью — цвета 1..30 (5 видов кубов, включая большой куб, * 6 граней), под второй гранью 31..61 и так далее для всех 6 граней (ок, на самом деле там структура несколько иная, но суть не меняется).

VGA-палитра двух разных кадров
VGA-палитра двух разных кадров

Далее мы «на лету» устанавливаем VGA‑палитру с учётом текущей ситуации: как какая грань сейчас освещена, насколько она прозрачна. Установка палитры (это делается через порты ввода/вывода) довольно прожорливый процесс, так что мы устанавливаем палитру только для тех граней, которые в текущий момент повёрнуты к нам. А чтобы ещё больше облегчить компьютеру задачу — все модификации палитры делаются по заранее обсчитанной таблице.

4. И финальный фокус‑покус: всё это (включая коллизии, отскоки, закраску, палитру и прочее) написано на ассемблере, и с использованием только целочисленных вычислений (fixed point) и очень тщательно заоптимизировано с выкраиванием тактов и отвоевыванием каждого FPS'a. Этот пункт справедлив для всех частей демо.

Да, выше написано, что демо написано в Turbo Pascal, но вы же не думали, что демки для DOS пишутся совсем без ассемблера (пусть даже встроенного)?

2. Гибкий куб в воде

Надеюсь вы еще читаете, и предыдущая часть у вас не отбила желание продолжать погружаться в хитросплетения демо-кодинга. Я постараюсь писать лаконичнее. Честно!

Водоплавающий куб
Водоплавающий куб

Итак, вторая сцена - это классический для демок 90-х "резиновый куб" (rubber cube) плюхающийся в двухмерную симуляцию воды. С кубиком все просто: как и в первой сцене мы строим "лог" (историю) вращающегося кубика, а при отрисовке берём каждую следующую строку от предыдущих "кадров", как бы растягивая куб по времени. Отдельная задача: хранить "историю" куба в компактном формате (обычно нам нужно хранить количество "кадров" равных количеству строк в кубе. У меня кубик по высоте равен 100 пикселям, то есть 100 строк. И, конечно, как и в случае с первой сценой, я сохраняю в буфер истории куба только x1,x2 каждой строки, каждой грани: (2 байта * 100 + 1 цвет грани) * 3 грани (которые мы максимум можем видеть одномоментно), и умножить на 100 кадров истории, укладываемся в 60300 байт, то есть достаточно одного сегмента (в реальном режиме вся память делится на сегменты по 64кб). Я также добавил некоторую функцию, чтобы выборка строк из "истории" была не совсем линейна, а более "динамичной", а также некоторые украшательства при отрисовке, благодаря чему куб получается закрашен симпатичным нелинейным градиентом.

Далее. Симуляция воды. Многие приняли это за физику. А вот и фигушки. Это просто синусы. Несколько наложенных друг на друга синусов разной длины волны, разной амплитуды и скорости. Когда куб падает ниже определенной точки, запускается сложная цепочка событий, которые управляют параметрами в функции. Таким образом, это результат долгого подбора параметров и коэффициентов для частного случая. А физики, как таковой, тут нет. Извините.
Ну, зато есть некоторая физика частиц, которые разлетаются в воде, имитируя пузырьки. 2048 частиц, каждая со своим вектором. Также они колышатся вместе с поверхностью (но с меньшей амплитудой) и присутствует эффект параллакса... В общем, как говорится, задротство внимание к деталям :)
Ну и отдельно я бы мог похвастаться очень быстрой рутиной отрисовки волнистого градиента, но не буду (я же обещал быть лаконичнее).

3. We are back!

Эх, если бы я не прогуливал алгебру...
Эх, если бы я не прогуливал алгебру...

Следом у нас идёт сценка с икосаэдром и текстом "We are back!" спроецированными на грани вращающегося куба (скорее параллелепипеда, но не важно). Здесь особо рассказывать нечего. Просто немножко линейной алгебры и плоскозакрашенные полигоны. Ага, тут нет текстур, это только flat-полигоны.

4. Тоннель из кружков

Тонель как тонель. Видели и лучше.
Тонель как тонель. Видели и лучше.

Снова сэкономлю ваше время. Это просто олдскульный туннель. Обычно их рисовали точками, а я решил поэкспериментировать с окружностями. Было интересно написать максимально быструю рутину закраски окружности. Пришел к тривиальному способу: прекалькуляция координат границ окружности для всех необходимых диаметров в виде двух значений для каждой строки (смещение, длина). Потом просто рисуем отрезки по готовым данным. Тупо, но быстро.

5. Деформация картинок

Мне нравится этот стоп-кадр
Мне нравится этот стоп-кадр

Часть с эффектом деформации битмапа. Что тут примечательного и как работает? Это самый обычный "мэппинг". У нас есть статичная двухмерная UV-карта и текстура. И мы скроллим текстуру относительно UV-карты.
То есть: у нас есть массив размером с экран, где каждый элемент сопоставлен пикселю на экране и содержит координаты пикселя текстуры, который здесь надо вставить. У меня UV-карты сгенерены в том же Паскале, никакие дополнительные инструменты не использовались. Текстуру используем 256х256 (8 бит на пиксель, соответственно), что максимально удобно: в 16-битном адресе пикселя верхние 8 бит - это номер строки, нижние 8 бит - номер пикселя в строке, а все 16 бит вместе сразу же образуют смещение.

Для большей динамичности я также в процессе отрисовки немного играюсь начальными смещениями в момент начала рисования новой строки.
Главная особенность: очень быстрый алгоритм отрисовки. Ничего сверхсложного, но понадобилось немало экспериментов и тестирований на реальном целевом железе, чтобы получить желаемые стабильные 60 FPS, при этом не жертвуя разрешением.

Пример генерации uv-карты:

Скрытый текст
{Линзо-подобная деформация}
Procedure GenMap(x0,y0: word; buf1,buf2:word);
var
x,y:integer;
r:single;
u,v:byte;
begin
    for y:=0 to 199 do
     for x:=0 to 319 do
       begin
          r:=sqrt((y-y0)*(y-y0)+(x-x0)*(x-x0));
          v:= ((X-x0)*64) div round(192-r);
          u:= ((Y-y0)*64) div round(192-r);{}
          if y<100 then memw[buf1:word((x+y*320) shl 1)]:=v+u*256
          else memw[buf2:word((x+(y-100)*320) shl 1)]:=v+u*256;
       end;
end;
{Чтобы охватить весь экран нам потребуется целых два буфера
по 64000 байт, на 1 пиксель приходится адрес в два байта: x,y
Напомню, в реальном режиме мы ограничены сегментами по 64кб }

6. Bump-эффект

Довольно популярный олдскульный эффект, вариации которого можно увидеть в сотнях демо на любых платформах. Принцип тоже довольно простой и известный. Я, пожалуй, не буду умножать энтропию и просто дам пару ссылок с объяснением и примерами:
https://pascal.sources.ru/demo/bumpmap.htm
http://www.algolist.ru/graphics/bumpmap.html
Остаётся только сделать его очень-очень быстрым. В раз 20 быстрее, чем код по ссылкам, иначе на 486-м будет милое слайдшоу. Также, вместо простого радиального градиента в качестве EnvironmentMap я сделал некоторую цветную кляксу с радиально убывающей яркостью, и получил "металлические" переливы. Плюс еще парочку трюков с палитрой, прорисовка букв... Приключений на 20 минут.

7. Прыгающие шары

Что ж, наконец менее казуальный эффект. По крайней мере я не припомню DOS-демок с чем-то подобным (Может вы помните? Пишите в комментариях). Хотя я встречал нечто похожее на Амиге, но не факт, что методы реализации у нас совпадают. Если кто-то не понял: это не полигональные объекты, и не рейтрейсинг, и вообще не 3D - лишь иммитация.

Разбор: https://youtu.be/7Lf4Z5YYbtU

Я придумал этот эффект, когда экспериментировал с олдскульным эффектом "линзы", который был очень популярен в начале 90-х, возможно вы его помните по Second Reality, а может по скринсэйверу Windows. Делается совершенно также, как описанный выше эффект "деформации" (рисуем текстуру по UV-карте), только для маленького фрагмента экрана.

Я заметил, что если скроллить текстуру не в сторону, противоположную движению "линзы" (как в оригинальном эффекте линзы), а в ту же, то это похоже на катящийся шар, особенно если не рисовать фон. Ок. Но ведь шар вращается не только по X/Y осям, но может крутится и по оси Z. Без этого выглядит не очень естественно.

И я добавил 2D-вращение. Стало больше похоже на шар, не правда ли? Остается добавить lightmap, подобрать текстуру, палитру. Ну и заставить это работать быстро на нашем любимом ПК 1992-го года. Описывать кодерские выкрутасы в процессе борьбы за кадро-секунды вряд ли имеет смысл, так как это потянет на отдельную статью (а кому и зачем это вообще нужно знать?). Есть некоторые забавные моменты: например, рендер шара разбит на клеточки 16х16, чтобы лучше попадать в кэш процессора. Текстура конвертируется в специальный формат, где пиксели расположены не линейно, палитра также формируется особым порядком. Все ради оптимизации вычислений во внутреннем цикле рендера. И уверен, через некоторое время я и сам не смогу вспомнить и понять: что там творится. Но я отношусь к демокодингу как к джазовой импровизации: сыграл один раз, и все, пытаться повторять это я никогда не стану :)

Для любознательных: пример кода генерации uv-карты:

Скрытый текст
{UV-карта для шарика 128х128 пикселя}
Procedure MapGen(map :word);
var
xc,yc,r:integer;
n,dx,dy,x,y,M:word;
Z,Xt,Yt:byte;
begin
V:=3; {степень выпуклости}
n:=0; xc:=64; yc:=64; r:=64;
for y:=0 to 127 do begin
    dy:=word(yc-y);
    dy:=dy*dy;
    for x:=0 to 127 do begin
        dx:= word(xc-x);
        M:=(dx*dx + dy);
        if M < R*R then begin
           Z:=byte(255-M shr 4);
           Z:=Z div V;
           Xt:=byte(((x-r)*R) div (Z+R));
           Yt:=byte(((y-r)*R) div (Z+R));
           memw[map:n]:=word( (Xt+r) + (Yt+r)*256 );
        end;
        n:=n+2;
    end;
  end;
end;
{Обычно для вычисления Z используют квадратный корень, но 
я в данном случае обхожусь без него. Выпуклость получается даже 
более естественной}

8. Плазма

Куда же без неё? Наверное, самая популярная категория эффектов. Собственно, плазмой в демомэйкинге называется любая цветная каша, которая колбасится с прослеживаемой или неочень логикой. Можно выделить несколько самых популярных и часто встречающихся видов: синусная плазма, фрактальная и интерференционная. Фрактальную мы не будем разбирать, обычно это довольно статичное унылое зрелище с цикличной палитрой. К тому же в демке ее нет. А двумя другими мы немного разберемся.

Разбор: https://youtu.be/X6BpW2N_Sf0

После шариков у нас идёт сценка с классической синусной плазмой.
Вкратце, это сложение синуса X-координаты пикселя и синуса Y-координаты, с умножениями на коэфициенты по вкусу и добавлением переменной t (время, или номер кадра, или т.п.).
Функцию можно записать так: color = SIN(x*f1​ +t) + SIN(y*f2 ​+t)
Синусы, конечно, прекалсятся в таблицы. На практике синусов (волн) обычно больше, чтобы придать плазме разнообразия. Можно добавить ещё синус из квадратного корня суммы квадратов X и Y, и т.д. Все зависит от вашей фантазии. Главное, чтобы все хорошо укладывалось в таблички, чтобы не вычислять это в реальном времени, иначе и Пентиум II надорвет пупок. Ну и, конечно, нужно подобрать правильную палитру.

В эстетических целях я здесь также добавил dither-эффект: просто сложением матрицы Байера с результирующим цветом пикселя.

Следом идёт три вариации «интерференционной плазмы». На самом деле это я её так называю, на демосцене похоже нет однозначного названия этого рода эффекта. Но есть эффект «интерференция», который имеет схожий принцип, но визуально на плазму не похож. С другой стороны, это тоже сложение волн, а значит, это похоже и на синусную плазму... Но к коллайдеру!

Одна текстура
Одна текстура

Что мы делаем? Мы берем текстуру с неким радиально-волновым градиентом и двигаем ее по какой-то произвольной траектории.

Две текстуры
Две текстуры

Затем берем ту же текстуру и складываем ее (да, просто арифметическим сложением складываем байты) с первой, но двигаем ее немного иначе. Выглядит как физический эффект интерференции волн, правда же?

Всё ещё не похоже на то, что вы видели в демке? Ну, во-первых, я складываю четыре раза, с разными позициями, во-вторых форма радиальных волн, палитра и траектории движения очень тщательно подбирались, чтобы получить интересные результаты и скрыть очевидность принципа эффекта. Форму градиента можно сильно варьировать, получая необычные результаты:

Предсказать - что получится, почти не возможно. Поле для экспериментов
Предсказать - что получится, почти не возможно. Поле для экспериментов

Для тех, кому любопытно - как генерируются эти текстуры:

Скрытый текст
{Радиальная (для зелёной плазмы)}
Procedure Ifer_Table1(size, x0,y0,y_start:word; c0:byte; buf:word);
var
n,p:word;
x,y,c:integer;
color:byte;
begin
  n:=0; x:=0; y:=y_start;
  for p:=0 to size do begin
    c:=round(cos(sqrt((y-y0)*(y-y0)+(x/2-x0)*(x/2-x0))/9)*30+30)+c0;
    if (c>255) then color := abs(255-c) else color := c;
    mem[buf:n]:=color;
    x:=x+1;
    if (x=512) then begin x:=0; y:=y+1; end;
    n:=n+1;
  end;
end;
{Спиралевидная плазма}
Procedure Ifer_Table2(size, x0,y0,y_start:word; c0:byte; buf:word);
var
n,p:word;
x,y,c:integer;
color:byte;
a:single;
begin
  n:=0; x:=0; y:=y_start;
  for p:=0 to size do begin
    if (x/2-x0>0) and (y-y0>=0) then a:=arctan((y-y0) / (x/2-x0));
    if (x/2-x0=0) and (y-y0>=0) then a:=1.57;
    if (x/2-x0>0) and (y-y0<0)  then a:=arctan((y-y0) / (x/2-x0))+2*pi;
    if (x/2-x0=0) and (y-y0<0)  then a:=-1.57+2*pi;
    if (x/2-x0<0) then a:=arctan((y-y0) / (x/2-x0))+pi;
    a:=a / (2*pi);
    c:=integer(round(a*256));
    c:=c+round(sin(sqrt((y-y0)*(y-y0)+(x/2-x0)*(x/2-x0))/16)*38+38);
    if (c>255) then color := abs(255-c) else color := c;
    mem[buf:n]:=color;
    x:=x+1;
    if (x=512) then begin x:=0; y:=y+1; end;
    n:=n+1;
  end;
end;

Здесь, конечно, об оптимизации речи не идет, поскольку все это генерируется заранее. В демке на самом деле эти текстуры сохранены в data-файле, чтобы не нагружать программный код.

Эффект получился очень шустрым, а все потому, что в рендер-цикле нам практически ничего не нужно вычислять, только читать байты по определенному смещению и сумировать их в регистре. К тому же мы это можем делать сразу по 4 пикселя за одну операцию (используя 32-битные инструкции) и делать 4 сложения за один проход, не используя буфер.

9. Полет над полем шестиугольников

Эффект похож на воксели, но строго говоря ими не является. До того как выпустить демо, я уже эксплуатировал эту идею в своих 256-байтных интро "Hell maze" и "Mercury". Но здесь мне предстояло добиться на порядок лучшей производительности (потом, слезами и немалым количеством пангалактического грызлодёра кофе).
Однако, как это работает? Изначально мы делаем эффект полета камеры над бесконечно-затайленой текстурой (256x256). Давайте я приведу базовый алгоритм, поскольку он довольно простой (на Паскале, уже простите):

Скрытый текст
Tcos := round(cos(t)*128);
Tsin := round(sin(t)*128);

for x:=0 to 319 do begin
    sx:=Tcos*(x-160);
    cx:=Tsin*(x-160);

    for y:=0 to 199 do begin

        xtr:= sx - Tsin*(y-100);
        ytr:= cx + Tcos*(y-100);
        z:=y * 4 + 1;
        xt:=xtr div z +t;
        yt:=ytr div z +t;

        col:=mem[TEXTURE:word(xt+yt*256)];
        mem[VGA:word(x+y*320)]:=col;
  end;
end;

Для простоты я привожу код без всяких оптимизаций, максимально тупо и в лоб. В релизе, конечно, никаких умножений и делений во внутреннем цикле нет, это было бы слишком медленно.

Можно заметить, что отрисовка кадра происходит не строками, а столбцами. Зачем? Ну, как минимум потому, что мы хотим сделать дальше. Создаем текстуру с участками нулевого цвета, которые мы будем считать дырками. Далее, в цикле отрисовки (а мы двигаемся сверху вниз, по столбцу экрана) добавляем условие: если наткнулись на нулевой пиксель текстуры, то вместо него начинаем рисовать предыдущий пиксель, интерполируя его значение, пока не наткнемся снова на "ненулевой" пиксель текстуры. Не знаю, поняли вы что-нибудь, из моих неуклюжих объяснений, в общем, представьте себе что мы просто "смазываем" вниз края:

Вот примерно так, только на ассемблере и 60 раз в секунду :)
Вот примерно так, только на ассемблере и 60 раз в секунду :)

10. Зеркальный додекаэдр

Я люблю Платоновы тела, вы наверное заметили. А додекаэдр особенно! Он крутой.
В данной сцене мы снова прибегнем к шулерским приемам. Конечно никаких отражений не вычисляется, и даже текстурирования как такового здесь нет. Каждая грань закрашивается совершенно плоским куском текстуры, без какого бы то ни было искажения, как если бы это была просто 2D-картинка на экране обрезанная границами грани. Однако для каждой грани текстура имеет разное смещение по X, Y. Это смещение привязано к нормали грани. С этим знанием просто посмотрите эту сцену еще раз. Впрочем, вероятно вы и сразу все поняли.

Как изменяется палитра
Как изменяется палитра

Освещение граней делается через модификацию VGA-палитры: 5 градиентов (а больше мы одновременно граней и не видим) меняют свою яркость в зависимости от угла наклона соответствующей грани.

11. Shadebobs

Я не знаю, как назвать этот эффект. Может, «разноцветные круглые штуки летающие по экрану»? В любом случае, я думаю, это очень классный эффект. Я вообще люблю всякие имитации hi‑color'a на стандартном VGA‑адаптере. И в частности dither‑эффекты (люблю интры «Technicolor» и «Mainstream»). И всегда хотел сделать что‑то такое. Но как? На самом деле я не знаю — как именно это сделано у тех парней и всегда изобретаю свой велосипед, так интереснее.

Чтобы решить проблему смешивания цветов (напоминаю, у нас 256-цветный режим), я начал с конвертации трехкомпонентного цвета RGB в 8-битный индексированный цвет (как это делает любой конвертор картинок). Только мне нужно это делать очень быстро, и на 486-м. Я наткнулся на такую вещь как Web‑safe colors. Ок, это работает, установив такую универсальную палитру, мы можем вычислять ближайший оттенок 24-битного RGB‑цвета по формуле:

color = (R*6 / 256)*36 + (G*6 / 256)*6 + (B*6 / 256)

Это работает. Но это очень неудобно. Если деления на 256 мы можем легко заменить битовыми сдвигами, то что делать с умножениями на 6 и 36? А умножения на каждый пиксель - это зло. Ну, то есть их, конечно, можно заменить сдвигами и сложениями, но все равно слишком много операций.

Что ж, в итоге я пришел к немного другому квантованию палитры:

{Установка VGA-палитры}
for R:=0 to 7 do
  for G:=0 to 7 do
    for B:=0 to 3 do begin
        SetVGAPalette(n, R*9,G*9,B*21);
         n:=n+1;
     end;

Для которой поиск цвета происходит по формуле:

color = (r*8 / 64)*32 + (g*8 / 64)*4 + (b*4 / 64) 
;
// здесь деление на 64, потому что мне было достаточно 
// диапазона 0..63 для каждого RGB-компонента, то есть 18-битный Hi-color.

Чудно, все умножения и деления с числами степени двойки и прекрасно заменяются на битовые операции. Немного потыкав в кодерский калькулятор, я придумал такой код на ассемблере, выполняющий эквивалентное вычисление (хороший пример: зачем нужен ассемблер, компилятор не придумает такого решения):

; На входе: AH = Red, AL = Green, BL = Blue
shl ah, 2
shr al, 1
shr bl, 4
and ax, 0e0fch
add al, ah
add al, bl  ; AL = color (номер цвета в 256-цветной палитре)

Остается подмешать сюда паттерн order-дизеринга (я это сделал сложением с каждым RGB-байтом перед конверсией). Это накладывает кое-какие ограничения, но не критично.

Далее в этом эффекте у нас еще размытие и масштабирование, все это для трех буферов, в которых отдельно хранятся цветовые профили. Но не стоит трогать это даже длинной палкой, иначе мы так и до Пасхи не закончим. Впрочем, открою еще один секрет, в этом эффекте на каждый кадр на экран отрисовывается лишь четверть пикселей, иначе на 486-м вменяемый FPS не получить. И что забавно, это вообще не портит эффект и почти нет никакой визуальной разницы с "полнопиксельным" вариантом.

12. Куб с частицами

Неужели мы наконец приближаемся к концу? Последняя сцена - куб с насыпанными в него частицами. Это мы уже видели в первой сцене, только здесь вместо кубиков - частицы. 1100 штук (ну вот так). Фокусов тут особо никаких нет, кроме всяких кодерских трюком с ассемблером. Но я не думаю, что их стоит разбирать в рамках этой статьи. А чисто математически это происходит так:

У каждой частицы, естественно, есть координата и вектор скорости.
Каждую грань считаем плоскостью в 3D-пространстве заданную тремя вершинами.

1. Считаем нормаль плоскости.
2. Проверяем лежит ли точка (частица) с внешней стороны плоскости относительно центра куба.
3. Если да, то проводим прямую через центр куба и координаты частицы, и вычисляем точку пересечения прямой с плоскостью. Возвращаем точку в эти координаты (это не совсем правильно, но в нашем случае годится).
4. Вычисляем новый вектор частицы после отскока, по формуле:
NewV = V - 2 * dot(N, V) * N
где V - вектор частицы, N - нормаль плоскости (перпендикуляр).

Ну и, потом там еще коэфициенты гравитации, затухания энергии... По вкусу.
На самом деле, хотелось бы придумать какой-нибудь более остроумный и фэйковый способ для подобного эффекта, который бы позволил визуализировать 5 тысяч или 10 тысяч частиц на том же "железе". Может у вас есть какие-то идеи? Пишите в комментариях :)

Assembly Summer 2025 (Финляндия, Хельсинки)
Assembly Summer 2025 (Финляндия, Хельсинки)

Пара слов про оптимизацию

Дисклеймер: в данной главе мы говорим о демокодинге под DOS и компьютеры на базе процессоров 80386/80486. И написанное не относится к нормальному размеренному и практичному программированию.

Какие есть способы сделать код на ассемблере быстрее? Пробежимся по нескольким концепциям:

- Делать процедуры как можно более самодостаточными и монолитными. Мы не должны скакать в цикле по разным внешним процедурам и функциям.

- Писать узкоспециализированный и немасштабируемый код. Хардкодинг, ад и Израиль.
Чем менее гибкий и конфигурируемый код вы сделаете, тем лучше его получится заоптимизировать под конкретный частный случай. Похоже на вредный совет, но я серьезно. Если, конечно, вы не пишите "демо-движок" с целью поставить создание демок на конвеер.

- Сводить к минимуму количество обращений к памяти, во вложенных циклах для промежуточных вычислений стараться использовать только регистры процессора.
Не стесняться использовать все доступные регистры, как вам удобно, не взирая на их "как бы специализацию".

- Стараться делать так, чтобы ваш алгоритм читал память линейно, проектировать структуры данных с учетом этого нюанса. Так вы эффективнее используете кэш процессора (а это может повышать скорость в несколько раз).

- Искать способы сократить вычисления во внутреннем цикле, думать - что можно вынести на уровень выше. Всё, что можно вычислить вне цикла, должно быть вынесено из цикла!

- Использовать частичную "развертку циклов". Так вы экономите такты и на переходах, и на количестве операций с регистрами-счетчиками, а заодно более эффективно эксплуатируете code prefetching (предвыборка инструкций). Да, получается длинная портянка кода, но наша задача выжать максимум скорости, а не сделать код маленьким.

- Сводить к минимуму ветвление внутри вложенных циклов, переходы сбрасывают кэш кода в процессоре.

- Читать и записывать в память не отдельными байтами, а "словами" (16-бит) или "двойными словами" (32 бит). Стараться использовать 16/32-разрядные инструкции для операций с байтами (например если вам надо прочитать из памяти 4 байта подряд, или записать, то намного быстрее это сделать одной 32-битной инструкцией). Всеми правдами и неправдами выдумывать способы, чтобы и на входе и на выходе у вас был не отдельный байт, а сразу четыре, правильно упорядоченные в 32-битном регистре (в этом вам помогут инструкции rol, ror, shr, shl, and, shld, shrd и т.д.), но сохранить при этом положительный КПД. Запись 32-битного значения в память (или в видеопамять) намного быстрее, чем четыре побайтовых записи.

- Сводить к минимуму количество умножений и делений. Использовать вместо них трюки с битовыми операциями. Если изучать вопрос глубже - есть целые книги на эту тему, вроде "Hacker's Delight" (но осторожно, это тяжёлые вещества!)

- Использовать таблицы с предварительно рассчитанными значениями.

- Использовать целочисленную арифметику, fixed point. Что такое fixed point-арифметика? Простыми словами: вместо того чтобы работать с дробными числами вы умножаете вводные значения на 100 (или на 1000) производите нужные вычисления, а потом результат делите на 100. Только в случае с компьютерами это 100h, то есть 256, потому что так удобнее (и упомянутые умножения/деления заменяются побитовыми сдвигами).

- Применять самомодифицирующийся код. Например: если вам внутри вложенного цикла нужно некое значение, и оно постоянно для этого вложенного цикла, а задается на уровне цикла выше, что вы делаете? В идеале у нас есть свободный регистр и мы записываем в него это значение перед входом в цикл и далее используем этот регистр. Но что если свободных регистров у нас уже нет, все под что-то задействованы? Читать внутри цикла значение из памяти/стэка? Медленно. К тому же у нас могут быть заняты своими делами и все сегментные регистры и все указатели. Выход: "на лету" модифицировать код перед входом в цикл, подставив значение прямо в нужный опкод (как численный параметр), перезаписав исполняемый код прямо в памяти (вот уж чего компилятор точно не станет делать). В демке "Demoded" я так делаю почти во всех процедурах ответственных за рендер или вычисление больших массивов данных.

Ну, а в остальном: использовать справочник инструкций, смекалку и помнить принцип: всегда можно сделать еще быстрее. Даже если кажется, что быстрее уже нельзя.

FAQ:

На основе реальных вопросов от людей, посмотревших "Demoded".

Q: Где посмотреть исходники демки
A: Я планирую их опубликовать вместе с финальной версией чуть позже
(постоянный адрес релиза: https://www.pouet.net/prod.php?which=104607 )

Q: А на Shadertoy можно найти шейдеры из демки?
A: Я не писал никаких прототипов на шейдерах, а принципы большинства эффектов в этой демо не очень ложатся в концепцию шейдеров, так что нет. Разве что кто-то сделает шейдерный ремейк :)

Q: Некоторые эффекты похоже используют какие-то предварительно отрендеренные данные?
A: Используются предварительно вычисленные таблицы (синусов, делений, рандомов и т.п.) и UV-карты, как и в абсолютно любой DOS-демке. Если речь о предварительно отрендеренной анимации, то нет, такого не используется.

Q: Какие библиотеки использованы?
A: Кроме музыкального плеера MIDAS - никаких. Весь остальной код написан с нуля, специально для этой демки.

Q: Сколько заплатили за победу на Assembly?
A: Ох... пишу эту статью лёжа под пальмой на пляже Рио-де-Жанейро, пью джус, оранжад... Извините, ко мне тут Челентано подошел...

Полезные ссылки:

Статьи схожей тематики на «Хабре»:
HAL в 4000 байт
Разработка демо для NES — HEOHdemo
Создание демки специально для HABR
Программирование под БК 0010 в 2019-ом году
Графика древности: палитры, часть 1 и (часть 2-я)

Демосценерские ресурсы (международные):
https://www.pouet.net/ - новые демо-релизы и основная тусовка западной сцены
https://www.demoparty.net/ (календарь мировых демопати)
https://demozoo.org/ - новости (жиденькие) и каталог всех релизов мировой сцены
https://www.youtube.com/@psenough/videos - еженедельные обзоры жизни демосцены

Демосцена в России:
https://retroscene.org/
https://www.demoscene.ru/
https://chaosconstructions.ru/ - фестиваль Chaos Constructions (Питер)
http://www.dihalt.org.ru/ - демопати DiHalt (Н.Новгород)
https://multimatograf.ru/ - фестиваль «Мультиматограф» (Вологда)

Старый добрый DEMO.DESIGN FAQ (материалы из FidoNet и прочий стаф из 90-х):
https://www.enlight.ru/demo/faq/
https://www.enlight.ru/faq3d/content.htm

Некоторая документация по x86 и прочее:
80x86 Integer Instruction Set (8088 - Pentium) (содержит инфу о тактах на инструкцию)
https://shell-storm.org/online/Online-Assembler-and-Disassembler/

Комментарии (2)


  1. NutsUnderline
    21.08.2025 14:50

    Q: Сколько заплатили за победу на Assembly?

    забанили по географическому признаку?


  1. Inskin
    21.08.2025 14:50

    На CC не подавались? Уже в эти выходные )