Спидран первого эпизода Quake, отрендеренный с помощью описанной в статье системы

Введение


Прошло уже двадцать пять лет с тех пор, как Quake установил новую планку реализма игровых движков. Это была одна из первых коммерческих игр с полностью текстурированными 3D-сценами и рендерингом в реальном времени с заранее вычисленными картами освещения, добавляющими атмосферности.

Тем не менее, необходимость работы игры в реальном времени на слабом «железе» 1996 года сильно ограничивала реализм графики. В этой статье я расскажу, как можно улучшить внешний вид игры на современном оборудовании и с рендерингом не в реальном времени.

Я расскажу о том, как написал скрипт для преобразования файлов демозаписей Quake в сцены Blender. Blender — это свободное и opensource-приложение для 3D-моделирования и рендеринга. Его рендерер Cycles — это трассировщик путей, способный создавать фотореалистичные изображения, он поддерживает такие функции, как motion blur, depth of field, сложную систему шейдеров и многое другое. Экспортировав проект в Blender, мы сможем использовать все эти функции бесплатно, без необходимости писать новый рендерер. Я буду стремиться максимально использовать оригинальные ресурсы игры и использовать точную симуляцию освещения Blender для повышения реализма.

В посте я буду ссылаться на исходный код, являющийся частью моего репозитория pyquake. Если вы хотите импортировать собственные демо в Blender, то я написал инструкции, но, вероятно, не буду отвечать на тикеты или PR. Если вам интересно поддерживать этот проект, то рекомендую создать его форк.

Сравнение скриншотов

Quake, отрендеренный в Blender


Скриншот из Quakespasm


Quake, отрендеренный в Blender


Скриншот из Quakespasm


Quake, отрендеренный в Blender


Скриншот из Quakespasm

e1m1 render from Blender

Quake, отрендеренный в Blender

e1m1 Quakespasm screenshot

Скриншот из Quakespasm

e1m5 render from Blender

Quake, отрендеренный в Blender

e1m5 Quakespasm screenshot

Скриншот из Quakespasm

e1m5 render from Blender

Quake, отрендеренный в Blender

e1m5 Quakespasm screenshot

Скриншот из Quakespasm

e1m5 render from Blender

Quake, отрендеренный в Blender

e1m5 Quakespasm screenshot

Скриншот из Quakespasm

e1m5 render from Blender

Quake, отрендеренный в Blender

e1m5 Quakespasm screenshot

Скриншот из Quakespasm

e1m6 render from Blender

Quake, отрендеренный в Blender

e1m6 Quakespasm screenshot

Скриншот из Quakespasm

e1m6 render from Blender

Quake, отрендеренный в Blender

e1m6 Quakespasm screenshot

Скриншот из Quakespasm

e1m7 render from Blender

Quake, отрендеренный в Blender

e1m7 Quakespasm screenshot

Скриншот из Quakespasm

Скриншоты сделаны в Quakespasm — порте исходного кода, сохраняющего внешний вид игры.

Парсинг демозаписей


Файл демо — это компактная запись игры в Quake, которой можно делиться с другими игроками или просматривать самому. Quake — многопользовательская игра, поэтому она разделена на клиент и сервер. По сути, демо записывает трафик, передаваемый от сервера к клиенту во время игры. Имея демо и установленный Quake, можно в точности воссоздать то, что видел игрок на момент записи:


Иллюстрация записи демо. Клиент (справа) общается с сервером (слева). Получаемый клиентом трафик записывается в файл демо.

Стоит заметить, что в даже однопользовательском режиме в коде сохраняются компоненты клиента и сервера, только сетевой слой заменён простой передачей в памяти, то есть демо можно записывать и в этом режиме.

Так как формат файлов демо тесно связан с протоколом Quake, его можно понять, читая соответствующий слой сетевого кода Quake. Поэтому довольно просто написать парсер формата файлов демо на Python.

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

ServerMessagePrint(string='\x02\nVERSION 1.09 SERVER (5336 CRC)')
ServerMessageServerInfo(
    protocol=Protocol(version=<ProtocolVersion.NETQUAKE: 15>,
                      flags=<ProtocolFlags.0: 0>),
    max_clients=1, game_type=0, level_name='the Slipgate Complex',
    models=['maps/e1m1.bsp', '*1', '*2', '*3', '*4', '*5', '*6', '*7', '*8',
            '*9', '*10', '*11', '*12', '*13', '*14', '*15', '*16', '*17', '*18',
            '*19', '*20', '*21', '*22', '*23', '*24', '*25', '*26', '*27',
            '*28', '*29', '*30', '*31', '*32', '*33', '*34', '*35', '*36',
            '*37', '*38', '*39', '*40', '*41', '*42', '*43', '*44', '*45',
            '*46', '*47', '*48', '*49', '*50', '*51', '*52', '*53', '*54',
            '*55', '*56', '*57', 'progs/player.mdl', 'progs/eyes.mdl',
            'progs/h_player.mdl', 'progs/gib1.mdl', 'progs/gib2.mdl',
            'progs/gib3.mdl', 'progs/s_bubble.spr', 'progs/s_explod.spr',
            'progs/v_axe.mdl', 'progs/v_shot.mdl', 'progs/v_nail.mdl',
            'progs/v_rock.mdl', 'progs/v_shot2.mdl', 'progs/v_nail2.mdl',
            'progs/v_rock2.mdl', 'progs/bolt.mdl', 'progs/bolt2.mdl',
            'progs/bolt3.mdl', 'progs/lavaball.mdl', 'progs/missile.mdl',
            'progs/grenade.mdl', 'progs/spike.mdl', 'progs/s_spike.mdl',
            'progs/backpack.mdl', 'progs/zom_gib.mdl', 'progs/v_light.mdl',
            'progs/armor.mdl', 'progs/g_nail.mdl', 'progs/soldier.mdl',
            'progs/h_guard.mdl', 'maps/b_nail0.bsp', 'progs/quaddama.mdl',
            'maps/b_bh100.bsp', 'maps/b_shell0.bsp', 'maps/b_bh10.bsp',
            'maps/b_bh25.bsp', 'maps/b_nail1.bsp', 'progs/h_dog.mdl',
            'progs/dog.mdl', 'progs/suit.mdl', 'progs/g_shot.mdl',
            'maps/b_explob.bsp'], sounds=['weapons/r_exp3.wav',
            'weapons/rocket1i.wav', 'weapons/sgun1.wav', 'weapons/guncock.wav',
            'weapons/ric1.wav', 'weapons/ric2.wav', 'weapons/ric3.wav',
            'weapons/spike2.wav', 'weapons/tink1.wav', 'weapons/grenade.wav',
            'weapons/bounce.wav', 'weapons/shotgn2.wav', 'items/damage2.wav',
            'demon/dland2.wav', 'misc/h2ohit1.wav', 'items/itembk2.wav',
            'player/plyrjmp8.wav', 'player/land.wav', 'player/land2.wav',
            'player/drown1.wav', 'player/drown2.wav', 'player/gasp1.wav',
            'player/gasp2.wav', 'player/h2odeath.wav', 'misc/talk.wav',
            'player/teledth1.wav', 'misc/r_tele1.wav', 'misc/r_tele2.wav',
            'misc/r_tele3.wav', 'misc/r_tele4.wav', 'misc/r_tele5.wav',
            'weapons/lock4.wav', 'weapons/pkup.wav', 'items/armor1.wav',
            'weapons/lhit.wav', 'weapons/lstart.wav', 'items/damage3.wav',
            'misc/power.wav', 'player/gib.wav', 'player/udeath.wav',
            'player/tornoff2.wav', 'player/pain1.wav', 'player/pain2.wav',
            'player/pain3.wav', 'player/pain4.wav', 'player/pain5.wav',
            'player/pain6.wav', 'player/death1.wav', 'player/death2.wav',
            'player/death3.wav', 'player/death4.wav', 'player/death5.wav',
            'weapons/ax1.wav', 'player/axhit1.wav', 'player/axhit2.wav',
            'player/h2ojump.wav', 'player/slimbrn2.wav', 'player/inh2o.wav',
            'player/inlava.wav', 'misc/outwater.wav', 'player/lburn1.wav',
            'player/lburn2.wav', 'misc/water1.wav', 'misc/water2.wav',
            'ambience/buzz1.wav', 'doors/basetry.wav', 'doors/baseuse.wav',
            'doors/hydro1.wav', 'doors/hydro2.wav', 'misc/null.wav',
            'ambience/fl_hum1.wav', 'buttons/switch21.wav', 'plats/plat1.wav',
            'plats/plat2.wav', 'doors/stndr1.wav', 'doors/stndr2.wav',
            'doors/basesec1.wav', 'doors/basesec2.wav', 'misc/trigger1.wav',
            'soldier/death1.wav', 'soldier/idle.wav', 'soldier/pain1.wav',
            'soldier/pain2.wav', 'soldier/sattck1.wav', 'soldier/sight1.wav',
            'items/damage.wav', 'buttons/airbut1.wav', 'ambience/hum1.wav',
            'items/r_item2.wav', 'items/r_item1.wav', 'items/health1.wav',
            'doors/drclos4.wav', 'doors/doormv1.wav', 'dog/dattack1.wav',
            'dog/ddeath.wav', 'dog/dpain1.wav', 'dog/dsight.wav',
            'dog/idle.wav', 'items/suit.wav', 'items/suit2.wav',
            'misc/secret.wav', 'ambience/comp1.wav', 'ambience/drone6.wav'])
ServerMessageCdTrack(track=6, loop=6)
ServerMessageSetView(viewentity=1)

Затем идёт серия базовых команд, задающих набор сущностей, каждая из которых связана с одной из вышеупомянутых моделей, подобно списку исполнителей в пьесе. Сущности задают все объекты в игре. Сущностью может быть монстр, игрок, аптечка, лифт, кнопка. Всё, кроме статичных частей уровня:

ServerMessageSpawnBaseline(entity_num=0, model_num=1, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=1, model_num=59, frame=0, colormap=1, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=8, model_num=2, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=9, model_num=3, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=11, model_num=85, frame=0, colormap=0, skin=0,
                           origin=(688.0, 480.0, 80.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=12, model_num=4, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=13, model_num=5, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=14, model_num=6, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=15, model_num=7, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=22, model_num=8, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -152.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=24, model_num=9, frame=0, colormap=0, skin=0,
                           origin=(0.0, -240.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=25, model_num=10, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=31, model_num=86, frame=0, colormap=0, skin=0,
                           origin=(112.0, 2352.0, 16.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=32, model_num=11, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=34, model_num=87, frame=0, colormap=0, skin=0,
                           origin=(248.0, 2392.0, 40.0), angles=(0.0, 3.141592653589793, 0.0))
ServerMessageSpawnBaseline(entity_num=35, model_num=89, frame=0, colormap=0, skin=0,
                           origin=(272.0, 2352.0, 64.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=41, model_num=14, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=42, model_num=15, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=43, model_num=16, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -66.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=45, model_num=18, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=47, model_num=90, frame=0, colormap=0, skin=0,
                           origin=(544.0, 2480.0, -87.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=48, model_num=20, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=50, model_num=22, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=51, model_num=23, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -400.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=53, model_num=24, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=55, model_num=91, frame=0, colormap=0, skin=0,
                           origin=(944.0, 1008.0, -271.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=57, model_num=92, frame=0, colormap=0, skin=0,
                           origin=(296.0, 2136.0, -191.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=58, model_num=93, frame=0, colormap=0, skin=0,
                           origin=(1424.0, 904.0, -431.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=59, model_num=94, frame=0, colormap=0, skin=0,
                           origin=(1376.0, 808.0, -431.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=60, model_num=94, frame=0, colormap=0, skin=0,
                           origin=(1176.0, 936.0, -431.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=61, model_num=26, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=62, model_num=27, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=63, model_num=28, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=65, model_num=30, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=70, model_num=35, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -16.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=71, model_num=36, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -16.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=72, model_num=37, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -16.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=73, model_num=38, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, -16.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=75, model_num=95, frame=0, colormap=0, skin=0,
                           origin=(1376.0, 1024.0, -279.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=76, model_num=94, frame=0, colormap=0, skin=0,

ServerMessageSpawnBaseline(entity_num=78, model_num=93, frame=0, colormap=0, skin=0,
                           origin=(1256.0, 1704.0, -431.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=83, model_num=39, frame=0, colormap=0, skin=0,
                           origin=(0.0, 0.0, 0.0), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=85, model_num=94, frame=0, colormap=0, skin=0,
                           origin=(328.0, 848.0, -223.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=86, model_num=94, frame=0, colormap=0, skin=0,
                           origin=(344.0, 920.0, -223.875), angles=(0.0, 0.0, 0.0))
ServerMessageSpawnBaseline(entity_num=87, model_num=93, frame=0, colormap=0, skin=0,
                           origin=(-16.0, 2064.0, -207.875), angles=(0.0, 0.0, 0.0))

...

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

ServerMessageTime(time=1.4415020942687988)
ServerMessageClientData(
	view_height=22, ideal_pitch=0, punch_angles=(0, 0, 0), m_velocity=(0, 0, -32),
	items=<ItemFlags.SIGIL4|AXE|SHELLS|SHOTGUN: 2147488001>, on_ground=False,
	in_water=False, weapon_frame=0, armor=0, weapon_model_index=68, health=100,
    ammo=25, shells=25, nails=0, rockets=0, cells=0, active_weapon=<ItemFlags.SHOTGUN: 1>)
ServerMessageUpdate(
    entity_num=1, model_num=None, frame=13, colormap=None,
    skin=None, effects=None, origin=(480.0, -352.0, 88.0), angle=(None, None, None),
    step=False)
ServerMessageUpdate(
    entity_num=101, model_num=None, frame=None, colormap=None, skin=None, effects=None,
    origin=(None, None, None), angle=(None, None, None), step=False)

Описанная выше последовательность повторяется для каждого кадра демо, и каждый кадр содержит команду ServerMessageUpdate для каждой сущности.

Мой код на Python для парсинга файлов демо можно найти здесь.

Парсинг ресурсов


Сам уровень описывается в файле .bsp. В нём указана информация о геометрии и текстурах уровня, а также некоторые другие структуры данных, к которым мы вернёмся позже. Формат файлов хорошо задокументирован и его достаточно легко спарсить в классы Python. Модели, описывающие такие элементы, как монстры, модели оружия и так далее, хранятся в файлах .mdl, тоже имеющих хорошую документацию. Звуки в моём проекте не обрабатываются (в видео в начале игры я просто наложил аудиозапись из игры), поэтому парсить звуковые ресурсы не нужно.

Мой код на Python для парсинга файлов .bsp и .mdl можно найти здесь (.bsp) и здесь (.mdl).


Бегущий Quakeguy. Геометрия, текстуры и данные анимации хранятся в файле .mdl. Файлы .bsp тоже содержат всё необходимое для уровня.

Загрузка в Blender


Blender имеет богатый интерфейс для написания скриптов на Python. Интерфейс Blender работает благодаря взаимодействию с Python API, поэтому всё, что можно сделать через UI, также можно реализовать скриптами. При помощи этого интерфейса Python я создал скрипт для импорта файлов .bsp и моделей в Blender. У большинства концепций из ресурсов Quake существуют прямые аналоги в Blender:

  • Модели и геометрию карт Quake можно представить в виде мешей Blender.
  • Позы моделей в моделях Quake можно представить как shape keys Blender.
  • Данные текстур Quake можно представить в виде изображений.
  • Координаты текстур можно закодировать в виде UV-карт Blender.
  • Такие эффекты, как волны на воде, анимированные текстуры, а также скайбоксы можно реализовать как шейдеры.

Мой код для импорта распарсенных файлов .mdl и .bsp в Blender находится здесь (.mdl) и здесь (.bsp).
Импорт распарсенного демо заключается в считывании вводного раздела, сообщающего нам, какие игровые ресурсы (модели и карту) нужно преобразовать в ресурсы Blender, с последующим их анимированием согласно данным базовых позиций и обновлений. Для этого я использую поддержку анимации Blender. В частности, я вставляю ключевые кадры позиций, ориентации и shape key для каждой команды ServerMessageUpdate из демо. Также добавил ключевые кадры для анимированных шейдеров, например, для волн на воде, чтобы они двигались в соответствии с текущим временем в игре. Ниже показана запись, показывающая результат импорта демо из первого уровня игры. Мой код импорта файлов демо в Blender можно найти здесь.


Просматриваем демо, импортированное в Blender

Освещение


Итак, мы уже загрузили и анимировали геометрию, а также наложили часть текстур. Однако у нас пока нет источников освещения. Когда проектировались уровни оригинального Quake, источники освещения задавались как расставленные по уровню точечные источники. Процесс компиляции преобразовывал эти точечные источники света в карты освещения. Этот процесс заключается в сопоставлении текстуры низкого разрешения с каждой поверхностью уровня и вычислением того, насколько каждый тексел напрямую освещён точечными источниками света. Так как этот процесс измеряет только прямое освещение, дизайнеры уровней добавляли вторичные источники света, чтобы имитировать эффекты отражённого освещения.

Так как исходники оригинальных карт доступны, я мог бы использовать эти источники света для освещения сцены. Однако поскольку Blender сам умеет выполнять точное освещение с многократными отражениями, то при добавлении оригинальных источников света Quake количество отражённого света удваивалось бы, из-за чего сцена оказывалась бы слишком освещённой.

Вместо этого я буду освещать сцену непосредственно из информации текстур. Все текстуры Quake имеют цвета из 256-цветной палитры:


Последние 32 цвета этой палитры особенны тем, что всегда отображаются с полной яркостью, то есть даже в тени они выглядят полностью освещёнными:


В своей системе я обрабатываю эти яркие цвета как испускающие свет, чтобы они могли освещать пространство вокруг, а также казались яркими в камере:


Кроме того, я обрабатываю как испускающие свет источники некоторые модели, например, модели факелов.

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



Ничего себе! Для рендеринга этого кадра на моей GeForce RTX 2060 потребовалось около 20 секунд, и он всё равно невероятно зернистый! Даже применение встроенного шумоподавителя Blender не позволило восстановить чёткое изображение. Обычно Blender без проблем справляется со сценами такого уровня сложности, так что же происходит?

Выборка по значимости


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

Проиллюстрируем эту мысль: допустим, в сцене есть 100 источников света, каждый из которых находится в квадрате показанной ниже сетки размером 10x10. Позиция камеры обозначена зелёным крестиком. Пока не обращайте внимание на красный квадрат (мы вернёмся к нему позже). Для отдельной точки, которую видит камера, влияние каждого источника задаётся яркостью квадрата. Из-за перекрытия геометрией уровня на освещённость точки влияют только пятнадцать источников.


Влияние 100 источников света в сцене.

Мы могли бы просто взять среднее значение всех этих 100 источников, чтобы получить точный ответ, но у такого решения есть две проблемы:

  • Сэмплирование всех источников может занять очень много времени.
  • В реальности каждый отдельный источник не равномерен по своему влиянию: яркость может зависеть от того, в какой точке источника мы выполняем сэмплирование. Представьте длинную флуоресцентную лампу, частично перекрытую стеной, или текстуру источника света, неравномерно испускающую свет. В таком случае всего 100 сэмплов будет недостаточно.

Чтобы избежать этих проблем, мы используем стохастический подход под названием интегрирование Монте-Карло, который применяет случайное сэмплирование. Допустим, мы берём 32 сэмпла, чтобы измерить влияние на рассматриваемую точку. Мы сэмплируем 32 раз 100 источников, заменяя их каждый раз, и усредняем получившееся значение, чтобы получить среднее сэмплирования. Получаемый нами ответ случаен, потому что он зависит от сэмплируемых значений. Вот график, демонстрирующий распределение этих средних значений сэмплирования после получения 100 000 сэмплов:


Распределение средних значений сэмплирования после получения сэмплов от всех источников освещения. Среднее средних значений сэмплов обозначено красной линией.

Как видите, здесь есть дисперсия: среднее сэмплирования может иметь значения в интервале от 0 до 0,3. Такая дисперсия нежелательна, она проявляется на готовом изображении в виде шума.

Есть ли способ снижения дисперсии среднего значения сэмплирования с сохранением того же среднего (средних значений сэмплирования)? Оказывается, здесь можно использовать простую схему типа выборки по значимости (importance sampling). Если мы заранее знаем, что ни один из источников снаружи красного квадрата не влияет на готовое изображение, то просто можем взять нужные 32 сэмпла из квадрата 5x5. Так как теперь мы сэмплируем из 25 значений, а не из 100, то стоит ожидать, что среднее значения сэмплирования будет в четыре раза больше, чем при сэмплировании из полного множества источников. Поэтому мы корректируем среднее значение сэмплирования, разделив его на четыре. Вот распределение скорректированных средних при использовании новой схемы:


Распределение скорректированных средних значений сэмплирования после получения значений от подмножества источников света.

Как видите, дисперсия снизилась, однако среднее осталось тем же, поэтому мы получаем менее шумные, но всё равно правильные (в матожидании) изображения.

Это упрощённое описание того, как Blender сэмплирует источники освещения, но суть остаётся той же: шум можно снизить, если мы не будем сэмплировать источники света, не влияющие на готовое изображение. В Blender есть встроенный способ исключения из сэмплирования источников, называемый в UI Multiple Importance Sampling, а в коде — sample_as_light. Этот флаг можно анимировать при помощи ключевых кадров, поэтому мы можем вносить изменения в зависимости от текущей позиции игрока и угла поворота камеры.

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

Данные видимости BSP


К счастью, в файлах .bsp есть встроенные структуры данных для решения похожей задачи. Одной из замечательных инноваций, позволявших Quake эффективно работать на «железе» 1996 года, было определение видимых поверхностей, то есть расчёт того, какие части уровня видны из каждой позиции игрока.

Для решения этой задачи файл .bsp разделяет уровень на множество разделённых объёмов, называемых листьями. Каждый лист содержит несколько граней.

Информация о видимости в BSP — это большая (но сжатая) заранее вычисленная двухмерная битовая карта, сообщающая нам, какие из листьев потенциально могут видеть друг друга. Множество листьев, которое потенциально может видеть конкретный лист, называется его потенциально видимым множеством (potentially visible set, PVS):


Поверхности, которые в игре находятся вне PVS камеры, не отрисовываются. Наша задача немного отличается — нам нужно сэмплировать и те источники света, которые невидимы для камеры, но видимы хотя бы из одной точки, видимой для камеры.

Давайте рассмотрим пример, в котором свет влияет на изображение. Вот внешний скриншот первого коридора игры, где источник освещения обозначен оранжевой звёздочкой, а игрок находится за углом и его камера обозначена оранжевой пирамидой. [Если вам известен этот уровень, то вы знаете, что в обычном состоянии этот коридор перекрыт дверью. В этой статье предполагаем, что дверь открыта.]


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


Листья PVS источника света обозначены жёлтым, а листья в PVS камеры — синим. Листья, находящиеся в обоих PVS, обозначены зелёным.

Такая система работает хорошо и немного улучшает ситуацию. Однако мы можем сделать намного больше. Проблема в том, что PVS слишком консервативен, то есть существует множество полностью перекрытых листьев, которые можно отбросить, но которые всё равно остаются в PVS. Аналогично, есть точки в PVS источника света, которые из-за ограниченной мощности источника и закона обратной квадратичной зависимости имеют очень мало освещения. Из-за этого мы всё равно сэмплируем множество источников, не влияющих на сцену.

Усечение по пирамиде видимости


Чтобы улучшить ситуацию, я снова воспользовался примером вычислений видимости в Quake и применил механизм под названием frustum culling (усечение по пирамиде видимости). Мы можем связать с камерой пирамиду видимости, то есть объём, грани которого соответствуют краям экрана, спроецированным из начальной точки камеры. Все точки, находящиеся за пределами пирамиды видимости, будут невидимыми для камеры, поэтому мы можем исключить эти листья из PVS. Благодаря этому скрываются листья, находящиеся за спиной игрока и в целом за границами обзора камеры.

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

Получившаяся система основана на проверке того, пересекаются ли два уменьшенных объёма PVS:


В данном случае они пересекаются, однако можно представить, что если бы игрок повернулся в противоположном направлении, то пересечение бы отсутствовало, и источник света (совершенно правильно) не сэмплировался бы.

Эта система лучше, чем система без ограничений, и как вы видите, намного лучше, чем система, просто сэмплирующая все источники:


После применения системы шумоподавления Blender к этому результату мы получаем ещё более чёткое изображение:


detailed view

Эта схема выбора источников для сэмплирования имеет недостаток — она игнорирует возможность влияния множественных отражений света: яркий источник в конце извилистого коридора может освещать другой конец благодаря многочисленным отражениям, однако в такой методике этот источник не будет сэмплироваться. Хотя в теории такая проблема существует, на практике её не заметно.

Последние штрихи


Для создания окончательного результата требуется ещё несколько аспектов:

  • Чтобы разные текстуры источников света выглядели правильно, они должны иметь разные яркости. Например, текстура прожектора должна быть намного ярче, чем вывеска с задней подсветкой. Эта информация не закодирована в игре, поэтому моя система считывает файл конфигурации, который, среди прочего, содержит красиво выглядящие значения яркости для каждой текстуры.
  • Я обрабатываю текстуру неба как испускающую свет, используя источник солнечного света Blender.
  • Хотя в оригинальной игре источники свет не зависят от текстур, художники обычно располагали источники рядом с моделью или текстурой, которые должны испускать свет, чтобы визуально освещение выглядело логичным. Несмотря на это, в оригинальной игре всё равно есть некоторые области с нелогичными источниками, которые в моём рендеринге выглядят слишком тёмными. Чтобы учесть это, я добавил несколько источников из исходников оригинальных карт для освещения этих областей. Расположение и яркость этих источников записаны в файл конфигурации, о котором я говорил выше.
  • В оригинальной игре была система частиц, используемая для взрывов, эффектов телепорта и так далее. Для их отображения я использую собственную систему частиц Blender.

Есть и другие вещи, которые можно добавить:

  • Отображение оружия игрока. В оригинальной игре текущее выбранное оружие отображается внизу экрана.
  • Эффекты молний. Они видны на lightning gun и в последнем уровне первого эпизода.
  • Спрайты. В игре есть несколько спрайтов, используемых для таких вещей, как эффект взрыва (отдельно от частиц) и пузыри.
  • Анимированные источники света. Карты освещения оригинальной игры могли быть анимированными, что полезно для реализации мерцающих лампочек и т.п., а также для включения и отключения ламп внутриигровыми переключателями.

Заключение


Я создал систему, импортирующую в Blender файлы демозаписей Quake и все связанные с ними ресурсы. Для этого потребовалось воссоздать приличную часть клиента Quake на Python, заменить отвечающий за рендеринг код кодом, дублирующим состояние игры в Blender. Это заняло много времени, поэтому, вероятно, было бы эффективнее использовать часть кода Quake и добавить мой код на более низком уровне. С другой стороны, я создал библиотеку кода для взаимодействия с несколькими форматами файлов Quake, а также вспомогательный код (например, симплексный солвер). Это может оказаться полезным для других проектов сообщества Quake.

Кстати, о других способах применения: созданную в Blender сцену можно использовать для анализа игрока. Выигрыш или проигрыш спидранов игры часто определяется долями секунды. Если можно будет загрузить нескольких демо в одну сцену и отследить конкретно, кто находится впереди на конкретном этапе, то мы могли бы понять, где можно выиграть время.

При разработке этого проекта я постоянно вспоминал сравнение с рендерерами реального времени, в частности, с проектом Quake II RTX (Q2RTX). Даже при всех моих оптимизациях рендеринг всё равно занимает по несколько секунд на кадр, а Q2RTX выполняется в реальном времени. Blender тоже поддерживает аппаратный raytracing, почему же он всё равно настолько медленнее? Думаю, вероятные причины в следующем:

  • Q2RTX использует временную фильтрацию для сглаживания изображений. Это означает, что сэмплы из предыдущих кадров вносят вклад в текущий кадр. Blender пока не полностью поддерживает временную фильтрацию, поэтому мне нужно было рендерить каждый кадр по отдельности.
  • Моей системе необходимо определять сэмплируемые источники света в каждом кадре. Система на основе RTX может выбирать сэмплируемые источники на основе пути. Для каждой точки поверхности, у которой интегрируется каждый падающий свет, необходимо выполнять только вычисления прямых PVS (с нулевым количеством отражений). Естественно, это приводит к тому, что на каждый пиксель нужно сэмплировать меньшее количество источников. Из прочитанной мной информации я не могу с уверенностью сказать, что это справедливо для Q2RTX, но такое кажется вероятным.
  • Трассировщик путей Blender под названием Cycles очень обобщён, то есть способен работать с множеством разных типов сцен — с геометрией разной сложности, разным количеством источников света, и так далее. В отличие от него, Q2RTX — крайне специализированный рендерер, поэтому его можно оптимизировать под конкретную задачу — рендеринг уровней Quake.