Когда я был студентом, мне очень хотелось сделать что-нибудь похожее на Doom 2.
Тогда даже простая трёхмерная графика казалась настоящей магией. Нарисовать вращающийся куб с видимыми гранями — уже было круто. Наложить текстуру на стену — почти сказка. Сделать лабиринт, по которому можно ходить, — проект, которым можно было гордиться.

Хотелось настоящего Doom.
Настоящий уровень, а не несколько стен, расставленных вручную. Коридоры, комнаты, перепады высот, текстуры, пол и потолок. Чтобы на экране был не учебный «3D engine», а Doom 2.
Проблема была простой.
У меня есть Turbo Pascal, обычный DOS-компьютер — и примерно на этом список доступных технологий заканчивается.
А Doom написан на C.
Неужели для того, чтобы сделать Doom, обязательно сначала учить C со всей его адресной арифметикой? Или всё-таки можно на Pascal?
Тогда такой проект был слишком большим, чтобы просто взять и проверить эту идею. Сейчас всё изменилось.
Исходники Doom открыты, а механическую работу по переносу можно сильно ускорить современными инструментами и ИИ. Поэтому старый вопрос наконец стало возможно превратить из студенческой мечты в практический эксперимент.
В моём случае от начала переноса до работающего рендерера понадобилось примерно 45 итераций и половина дня.
И на диске появился файл с тем самым, когда-то почти сказочным названием:
DOOM2.PAS
Системные требования Doom даже немного подбадривали:
386. VGA. 4 мегабайта памяти.
Допустим, всё это у нашего бедного студента есть.
Есть и Pascal, который умеет работать с DOS, видеопамятью и кучей, поддерживает указатели и позволяет вставлять ассемблер.
На первый взгляд, этого должно быть достаточно.
Но прежде чем писать рендерер, нужно проверить самое очевидное ограничение DOS-программы.
А сколько памяти нужно уровню?
Самое очевидное препятствие — знаменитые 64 КБ на сегмент.
Doom — большая игра. Карта, BSP, текстуры, таблицы.
Может быть, настоящий уровень просто невозможно нормально разместить в памяти обычной программы реального режима?
Основные структуры карты Doom довольно компактны:
Структура |
Размер одной записи |
|---|---|
THING |
10 байт |
VERTEX |
4 байта |
LINEDEF |
14 байт |
SIDEDEF |
30 байт |
SECTOR |
26 байт |
SEG |
12 байт |
SSECTOR |
4 байта |
NODE |
28 байт |
VERTEX, например, — это всего две 16-битные координаты:
X — 2 байта;
Y — 2 байта.
Итого четыре байта.
Теперь можно пройти по уровням Doom 2 и посмотреть максимальные размеры основных массивов:
Данные |
Максимум записей |
Карта |
Размер |
|---|---|---|---|
VERTEXES |
1601 |
MAP15 |
6404 байта |
LINEDEFS |
1690 |
MAP15 |
23660 байт |
SIDEDEFS |
2587 |
MAP14 |
77610 байт |
SECTORS |
348 |
MAP14 |
9048 байт |
SEGS |
2815 |
MAP14 |
33780 байт |
SSECTORS |
875 |
MAP15 |
3500 байт |
NODES |
874 |
MAP15 |
24472 байта |
И тут выясняется довольно приятная вещь. Почти всё помещается. Конечно, весь уровень целиком в один 64-КБ сегмент не входит. Но этого и не требуется.
Можно выделить отдельные блоки:
VERTEXES;
LINEDEFS;
SECTORS;
SEGS;
SSECTORS;
NODES.
Каждый логический массив хранится отдельно.
SEGS в самом большом случае занимают меньше 34 КБ. NODES — около 24 КБ. LINEDEFS — около 23 КБ.
Сами данные BSP не требуют плоского многомегабайтного адресного пространства.
Есть только одно исключение.
SIDEDEFS и 64 КБ
Один SIDEDEF занимает 30 байт.
На MAP14:
2587 × 30 = 77610 байт
На MAP15:
2361 × 30 = 70830 байт
Вот один логический массив действительно пересёк границу сегмента.
Но и это не тупик.
SIDEDEFS можно разбить на страницы.
Для рендерера по-прежнему существует обычный логический индекс:
SideDef[0] SideDef[1] SideDef[2] ...
Физически записи хранятся в нескольких блоках:
SIDEDEFS page 0 SIDEDEFS page 1 ...
Для доступа к записи определяется страница и индекс внутри неё:
SIDEDEF index ↓ page ↓ index in page ↓ far pointer
Появляется дополнительный слой адресации — прямое следствие сегментированной памяти. Но принципиальной преграды здесь нет.
Почему MAP15?
Для демонстрации можно было взять небольшую карту и почти не сталкиваться с ограничениями.
Но интереснее проверить рендерер на серьёзном настоящем уровне.
У MAP15:
1601 vertices;
1690 linedefs;
2361 sidedefs;
301 sectors;
2647 segs;
875 subsectors;
874 nodes.
Основные данные уровня занимают примерно:
Данные |
Размер |
|---|---|
VERTEXES |
6404 байта |
LINEDEFS |
23660 байт |
SIDEDEFS |
70830 байт |
SECTORS |
7826 байт |
SEGS |
31764 байта |
SSECTORS |
3500 байт |
NODES |
24472 байта |
Всего — около 168 КБ.
В один сегмент они не помещаются. По нескольким сегментам — вполне.
Добавляем необходимые ресурсы и текстуры, и обычной памяти в реальном режиме всё ещё достаточно.
Первый подозреваемый отпадает: большой настоящий уровень можно загрузить и обработать, а SIDEDEFS — разнести по нескольким блокам.
У нас есть 386, VGA и Pascal. Пора писать рендерер.
Doom 2 на Pascal заработал
Перенос самого рендерера принципиальных препятствий уже не вызвал.
Все структуры Doom вполне нормально выражаются на Pascal.
Структуры превращаются в record, указатели уже есть, а fixed-point величины можно хранить в LongInt.
И в конце MAP15 действительно появился на экране.
Для проверки результата я считал хеш framebuffer.
В окончательной тестовой сцене правильный кадр даёт:
screen_nonzero=53713 screen_hash=F311402C
Это гораздо надёжнее проверки глазами. Одна неправильная колонка может быть почти незаметна, а контрольная сумма уже изменится.
Первый ответ получен: выразительных возможностей Pascal для Doom хватает. Кадр правильный — но рисуется он очень медленно.
Можно посмотреть, как Doom рисует кадр

Сначала появляются стены. Затем остальные стены. Потом пол. Потом потолок.
Для учебной демонстрации это даже интересно. На экране видно, в каком порядке работает алгоритм.
Для игры — не очень.
Первоначальная цель состояла в том, чтобы запустить рендерер, поэтому отдельный экранный буфер мы делать не собирались. Кадр выводился непосредственно в VGA по мере построения.
Всё работало правильно, но медленно.
Возник естественный вопрос: что именно занимает столько времени?
Doom постоянно использует 32-битные fixed-point величины, углы, координаты, масштабы и промежуточные произведения.
У Borland Pascal 7 есть тип LongInt, но сам компилятор остаётся 16-битным.
Процессор при этом уже умеет EAX, EBX, ECX, EDX, ESI и EDI, полноценные 32-битные умножения, сдвиги и адресную арифметику.
Следующим шагом стал 386 ассемблер.
Переход к ассемблеру
Можно было ограничиться несколькими короткими вставками в самых очевидных местах.
Но тогда мы проверяли бы только пользу отдельных локальных оптимизаций.
Рендерер Doom — это не набор независимых процедур. Это связанный горячий путь:
→ проекция → отсечение → геометрия → настройка текстур → растеризация
Если после каждого шага возвращать все промежуточные значения в Pascal, сохранять их в память, снова подготавливать параметры и вызывать следующий небольшой ASM-блок, значительная часть потенциального выигрыша теряется на границах.
Поэтому несколькими вставками было не обойтись: чтобы проверить идею честно, пришлось переносить на ассемблер весь горячий путь.
Неделя с 386 ASM
На создание цельного оптимизированного под 386 графического ядра ушла примерно неделя.
Горячие стадии постепенно собирались в один ASM-модуль, который компоновался с Borland Pascal как обычный объектный файл.
Pascal остался оболочкой программы: загрузчиком WAD, владельцем структур данных, системой управления памятью и кодом проверки результата. Горячую часть рендерера взяло на себя ядро, использующее возможности 386 напрямую.
Внутри него координаты, дробные шаги, адреса, указатели на текстуры и счётчики не обязаны после каждого действия возвращаться в 16-битные временные переменные.
Их можно удерживать в регистрах и передавать дальше по цепочке вычислений.
Одновременно пришлось привести в порядок сам эксперимент.
Из измеряемого участка были убраны:
подробная диагностическая печать;
проверки, выполняемые для каждой стены и колонки;
служебные обращения к таймеру внутри горячих циклов;
ленивая загрузка ресурсов.
Особенно важной оказалась загрузка текстур.
Первоначально для теста использовалась MAP15 — большая карта, хорошо подходившая для проверки ограничений памяти и корректности работы с настоящими данными Doom.
Но первый замер показал около 246 сотых секунды, и сначала это выглядело как время работы рендерера.
При оптимизации выяснилось, что значительная часть этого результата вообще не относилась к построению кадра.
Во время первого обращения движок лениво находил, загружал и распаковывал необходимые текстуры.
Внутри замера незаметно выполнялся кэширование семи ресурсов:
46, 263, 189, 372, 281, 375, 374
Получалось, что тест измерял сразу поиск ресурсов в WAD, загрузку и подготовку текстур и только затем построение кадра. Такое число ничего не говорило о скорости самого рендерера.
Можно было заранее прогреть все текстуры MAP15, но для воспроизводимого сравнения это создавало другую проблему: большой уровень использует сложный набор ресурсов, а любое изменение положения камеры могло затронуть новые стены и вызвать дополнительную ленивую загрузку.
Поэтому роли тестовых карт были разделены.
MAP15 осталась доказательством того, что Borland Pascal способен загрузить и обработать большой настоящий уровень Doom 2, несмотря на 64-КБ сегменты.
А для измерения производительности была выбрана MAP01 с фиксированной камерой и заранее известным набором текстур.
Перед запуском таймера все необходимые ресурсы принудительно прогревались. Их загрузка измерялась отдельно и больше не влияла на время кадра.
С этого момента бенчмарк измерял именно рендерер:
+ фиксированная карта + фиксированная камера + фиксированный набор текстур + одинаковый объём геометрии
Роли карт разделились. MAP15 отвечала на вопрос, можно ли загрузить и отрисовать большой настоящий уровень. MAP01 — сколько времени занимает один и тот же заранее подготовленный кадр. Только после этого реализации стало возможно сравнивать напрямую.
После завершения графического ядра и очистки измеряемого участка оптимизированная версия Borland Pascal реального режима построила кадр за:
render_time_cs=66
При этом контрольные значения полностью сохранились:
draw_columns=814 draw_pixels=37866 plane_spans=567 plane_pixels=15894 screen_nonzero=53713 screen_hash=F311402C
Чтобы понять, что именно дала неделя ассемблера, нужна была контрольная версия.
Что считать чистой Pascal-версией?
Сравнивать законченное под 386 процессор ядро с самым первым прототипом бессмысленно.
В первоначальной версии были ленивая загрузка текстур, тяжёлая диагностика и другие накладные расходы, которые не относятся к качеству кода рендерера.
Поэтому была собрана контрольная версия Borland Pascal.
Основной рендерер в ней остаётся компиляторным Pascal-кодом, но условия эксперимента очищены:
Нормализована критическая fixed-point арифметика MUL/DIV.
Вывод текстур не делает заведомо лишнюю работу.
Те же семь текстур прогреваются до начала замера.
Диагностика находится за пределами измеряемого участка.
Сохраняются тот же контрольный кадр и тот же объём работы.
Это не намеренно замедленная версия, а разумно подготовленный компиляторный контроль.
Он строит тот же кадр за:
render_time_cs=137
Сравнение получается прямым:
137 / 66 = 2,08
То есть цельное, вручную оптимизированное ассемблерное ядро ускорило рендер кадра примерно в 2,1 раза.
А что если взять Free Pascal и ничего не переписывать?
После этого возникает естественный вопрос: что будет, если вообще не оптимизировать рендерер вручную, а почти тот же Pascal-код собрать Free Pascal под 32-битный DOS?
Под капотом это уже другой мир: защищённый режим через DOS extender, 32-битные регистры и указатели, плоское адресное пространство и естественная для компилятора 32-битная арифметика. При этом сам рендерер почти не нужно переписывать.
Получается хороший контрольный опыт:
Что произойдёт, если дать почти тому же Pascal-коду естественную 32-битную среду?
Серебряная пуля найдена!

Тот же рендерер, практически тот же Pascal-код — но теперь собранный Free Pascal под DOS 32.
Оптимизированная версия Borland Pascal тратила на кадр около:
66 cs
Free Pascal показывал:
6–11 cs
Почти десятикратная разница. В Borland Pascal было видно, как кадр строится по частям, а в Free Pascal готовое изображение возникало сразу.
Объяснение казалось очевидным: плоская память, защищённый режим и компилятор, для которого 32-битные значения — обычный рабочий материал. Почти тот же код без ручной оптимизации оказался почти в десять раз быстрее.
На этом этапе результат выглядел убедительно.
Откуда же взялась такая разница?
Чтобы понять причину ускорения, мы начали сравнивать ассемблерные листинги.
С одной стороны находился код, сгенерированный 32-битным Free Pascal.
С другой — наш, вручную оптимизированный на ассемблере движок для Borland Pascal.
И здесь возникла серьёзная несостыковка. Ручной код не выглядел в десять раз хуже компиляторного: в критических местах он был сопоставим, а местами даже лучше. У Free Pascal оставались преимущества 32-битного ABI и плоской памяти, но такого разрыва листинги не объясняли.
Обе программы в конечном счёте исполняли машинные инструкции на одном и том же процессоре. Откуда тогда взялось почти десятикратное ускорение?
При изучении листингов обнаружилась ещё одна важная деталь.
Free Pascal не выводил каждый построенный пиксель непосредственно в VGA.
Он использовал промежуточный экранный буфер в обычной памяти, а затем переносил готовое изображение в видеопамять.
Двойная буферизация появилась сама — как часть реализации Free Pascal.
Именно поэтому FPC-версия не только показывала маленькое время, но и визуально выглядела так, будто кадр возникает мгновенно.
Borland Pascal всё ещё рисовал непосредственно в VGA. На экране был виден сам процесс построения изображения.
Получалось, что вместе с режимами исполнения сравнивались ещё и два способа вывода: одна версия показывала каждый промежуточный этап, другая — только готовый кадр. Визуальную разницу это объясняло, временную — ещё нет. Но условия всё равно следовало выровнять.
Framebuffer, который мы не собирались делать
Изначально отдельный framebuffer не входил в план: для проверки корректности проще и нагляднее было писать прямо в VGA. Но для сравнения с FPC обе программы должны были выводить изображение одинаково.
Поэтому такой же промежуточный буфер был добавлен в Borland Pascal.
Рендерер сначала строил изображение в отдельном 64000-байтовом блоке памяти, а затем готовый кадр переносился в VGA.
На нашей шкале время копирования оказалось меньше одной сотой секунды:
framebuffer_copy_cs=0
Это не означает, что копирование не стоит ничего. Только то, что его длительность меньше разрешения используемого таймера.
Визуально поведение изменилось полностью: теперь и Borland Pascal показывал готовый кадр сразу. Значит, ощущение мгновенного вывода создавал промежуточный буфер. Но разницу между 66 и 6–11 сотыми секунды он не объяснял.
Так значит, дело не в коде?
Алгоритм, машинный код и способ вывода уже не объясняли разрыв. Оставалось проверить среду, в которой выполнялись программы.
DOSBox-X был настроен так:
cycles=auto
Здесь и нашлась недостающая деталь.
Для программы реального режима Borland Pascal эмулятор фактически сохранял производительность примерно на уровне 3000 cycles.
Но при запуске программы через GO32v2 переходил в значительно более производительный режим, близкий к AUTO/MAX.
Free Pascal действительно выполнялся почти в десять раз быстрее — только на другом виртуальном процессоре. Framebuffer скрывал построение кадра, а cycles=auto давал программе через GO32v2 значительно больше вычислительных ресурсов. Мы думали, что сравниваем реальный и защищённый режимы, а сравнивали ещё и разные скорости виртуального компьютера.
Чтобы продолжить эксперимент, условия пришлось зафиксировать:
core=normal cputype=386 cycles=fixed 3000
Кроме того, обе версии должны были иметь одинаковые:
промежуточный framebuffer;
предварительную загрузку текстур;
границы измеряемого участка;
отключённую внутри таймера диагностику;
контрольный кадр;
объём выполненной работы.
После этого Free Pascal показал:
render_time_cs=71
Оптимизированный движок реального режима на Borland Pascal:
render_time_cs=66
Почти десятикратное преимущество исчезло, а результат наконец согласовался с ассемблерными листингами. Защищённый режим не стал серебряной пулей для производительности; преимуществом Free Pascal оказалось другое — почти тот же код без недели ручной работы получил естественную 32-битную среду и двойную буферизацию.
Три версии, один кадр
Главный экспериментальный результат выглядит так:
Версия |
Время |
|---|---|
Borland Pascal, без ассемблера |
137 cs |
Borland Pascal, 386 ASM |
66 cs |
Free Pascal GO32v2 |
71 cs |
Все три версии строят один и тот же контрольный кадр:
screen_hash=F311402C
И выполняют тот же объём работы:
draw_columns=814 draw_pixels=37866 plane_spans=567 plane_pixels=15894
При сопоставимых условиях оптимизированный движок реального режима оказался немного быстрее: 66 против 71 сотой секунды, но такая разница не позволяет объявлять один режим принципиально лучше другого. Зато ручная работа над горячим кодом дала воспроизводимые 2,1×:
137 cs → 66 cs
Итоговый Pascal-тест
Финальные версии были запущены в DOSBox-X на фиксированном профиле 486DX2:
cycles=fixed 23880
Все три программы строили один и тот же кадр и давали одинаковые контрольные значения:
screen_nonzero=53713 screen_hash=F311402C openings_hash=D3261B0D draw_columns=814 draw_pixels=37866 plane_spans=567 plane_pixels=15894
Итоговые результаты:
Версия |
Время |
|---|---|
Borland Pascal 7 |
16 cs |
Free Pascal GO32v2 |
11 cs |
Borland Pascal, 386 ASM |
6 cs |
Ассемблерный код продолжал развиваться и в конечном счёте вырос примерно до десяти тысяч строк и нескольких сотен итераций.
Он оказался быстрее компиляторного Borland Pascal примерно в 2,7 раза.
Free Pascal — примерно в 1,8 раза.
Ручная работа дала большой результат, но одновременно показала его цену. Речь шла не о нескольких вставках imul или shl: фактически пришлось выполнять работу компилятора — распределять регистры, связывать стадии рендерера, управлять временными значениями, адресами, циклами и переходами.
Для современного эксперимента, где значительную часть механической работы помогал выполнять ИИ, это оказалось возможным.
Для квалифицированного разработчика девяностых такой объём мог означать месяцы работы.
А как бы справился оригинальный код?
Pascal построил правильный кадр, ручной ассемблер ускорил его почти втрое, а Free Pascal получил близкую производительность без десяти тысяч строк ASM. Оставалось проверить C — язык, на котором был написан сам Doom.
Хотелось проверить не переписанную вручную отдельную функцию и не искусственный тест, а настоящий Doom рендерер, собранный профессиональным DOS-компилятором той эпохи.
Так я нашёл PCDoom — реконструкцию DOS-версии Doom для Open Watcom C.
В исходном виде порт оказался нерабочим.
Игра запускалась, показывала меню и загружала уровень, но видеобэкенд смешивал содержимое разных VGA-страниц. Внутри рамки игрового окна оставалась заставка, а элементы меню и нового кадра мигали поверх друг друга.
Сам рендерер работал; проблема находилась в выводе готового изображения.
Сложный рендерер с использованием VGA страниц пришлось заменить простым линейным framebuffer. После этого порт заработал, и его удалось привести к условиям нашего теста: та же MAP01, та же камера, тот же размер окна, без монстров, оружия, status bar, музыки и движения.
Watcom построил попиксельно ту же геометрию: стены, границы и плоскости оказались на тех же местах.
Но оригинальный движок Doom дополнительно применял свои таблицы освещения. Поэтому значения цветов и итоговый хеш отличались от упрощённого Pascal-варианта.
И даже с этим затенением результат составил:
Open Watcom C — 4,46 cs
Для сравнения:
Borland Pascal 7 — 16 cs Free Pascal — 11 cs ручной ASM — 6 cs Open Watcom C — 4,46 cs
В оригинальном PCDoom использовалось около пятисот строк ассемблера для нескольких самых горячих циклов рендерера.
Это уже не ручная замена компилятора, а локальная оптимизация нескольких горячих циклов. Даже с отладкой речь скорее о неделях или месяце работы, а не о многих месяцах. При этом Watcom вывел полноценный кадр Doom с оригинальным затенением быстрее нашего ассемблерного ядра.
Так обязательно ли было учить C?
Теперь можно ответить на исходный вопрос. Для одной камеры и одного набора стен и плоскостей результаты получились такими:
Borland Pascal 7 — 16 cs Free Pascal GO32v2 — 11 cs Borland Pascal, 386 ASM — 6 cs Open Watcom C — 4,46 cs
Для Borland Pascal потребовалось постепенно заменить почти весь критический путь собственным ассемблером. В итоге набралось около десяти тысяч строк и сотни итераций.
В оригинальном DOS-подходе достаточно было примерно пятисот строк ассемблера для нескольких самых горячих циклов. Возможно, месяц работы вместе с отладкой.
Pascal всё-таки смог: кадр появился, геометрия и контрольные значения совпали, а ассемблерное ядро обогнало оба Pascal-компилятора. Технического запрета не существовало.
Но рядом был инструмент с плоской памятью, 32-битным кодом, DOS extender и компилятором, который сам выполнял большую часть работы, сделанной для Borland Pascal вручную.
В 1998 году я этого не знал и задавался вопросом: можно ли на этом написать Doom?
Теперь я знаю ответ на этот вопрос.
А стоило ли?
P. S. Полные исходники я не публикую. Различных портов Doom и так достаточно. Если они будут вам интересны, я готов их предоставить.
pewpew
Мсье знает толк!
В конце 1990-х - начале 2000-х как раз занимался чем-то подобным в старших классах. Смотрел с восхищением на труды Кена Сильвермана и конечно же на исходники Doom, тогда ещё совершенно свежие. Подглядывал в исходники, не весть откуда скачанные чьих-то клонов Doom. На тот момент Turbo Pascal 7 + 16-битный x86 assembler в режиме реального времени казался потолком, Язык C казался какой-то магией.
И вот вы, с Dos Navigator и этим приветом 20-летней давности.
Шикарно!