Когда я был студентом, мне очень хотелось сделать что-нибудь похожее на 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-кодом, но условия эксперимента очищены:

  1. Нормализована критическая fixed-point арифметика MUL/DIV.

  2. Вывод текстур не делает заведомо лишнюю работу.

  3. Те же семь текстур прогреваются до начала замера.

  4. Диагностика находится за пределами измеряемого участка.

  5. Сохраняются тот же контрольный кадр и тот же объём работы.

Это не намеренно замедленная версия, а разумно подготовленный компиляторный контроль.

Он строит тот же кадр за:

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

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


  1. pewpew
    05.08.2026 08:33

    Мсье знает толк!
    В конце 1990-х - начале 2000-х как раз занимался чем-то подобным в старших классах. Смотрел с восхищением на труды Кена Сильвермана и конечно же на исходники Doom, тогда ещё совершенно свежие. Подглядывал в исходники, не весть откуда скачанные чьих-то клонов Doom. На тот момент Turbo Pascal 7 + 16-битный x86 assembler в режиме реального времени казался потолком, Язык C казался какой-то магией.
    И вот вы, с Dos Navigator и этим приветом 20-летней давности.
    Шикарно!