Как повернуть время вспять и выиграть 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. Кубики в прозрачном кубе

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

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

Далее мы «на лету» устанавливаем 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 - лишь иммитация.

Я придумал этот эффект, когда экспериментировал с олдскульным эффектом "линзы", который был очень популярен в начале 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. Плазма
Куда же без неё? Наверное, самая популярная категория эффектов. Собственно, плазмой в демомэйкинге называется любая цветная каша, которая колбасится с прослеживаемой или неочень логикой. Можно выделить несколько самых популярных и часто встречающихся видов: синусная плазма, фрактальная и интерференционная. Фрактальную мы не будем разбирать, обычно это довольно статичное унылое зрелище с цикличной палитрой. К тому же в демке ее нет. А двумя другими мы немного разберемся.

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

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 тысяч частиц на том же "железе". Может у вас есть какие-то идеи? Пишите в комментариях :)

Пара слов про оптимизацию
Дисклеймер: в данной главе мы говорим о демокодинге под 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/
NutsUnderline
забанили по географическому признаку?