Первый день закончился 16 октября в 18:35: два закрытых плана, версия 0.3.0, пещера с подгрузкой, по которой можно летать и копать, и три невыполненных бюджета. Этот пост — о пяти днях после него, до 21 октября, 11:31: версия 0.24.0, двадцать пять закрытых планов, двадцать одно архитектурное решение, а у движка появились коллизии, пространственный звук с реверберацией под размер комнаты, в которой стоишь, журнал операций, хост WebAssembly для логики игры, префабы, HUD, геймпад, обрушение, который крушит то, сквозь что падает, новый формат кирпича на стороне CPU, а зеркало свою половину ещё ждёт, вид на мир снаружи, сквозь породу, и поверхности, которые больше не похожи на кубики. Из трёх невыполненных бюджетов один выполнен, один выполнен после того, как я поднял планку, а один не выполнен до сих пор, зато теперь известно, почему.

Движок делается вместе с первой игрой, о которой в этом посте речи не будет. Каждое число ниже снято на моём ноутбуке, который в файле бюджетов назван машиной разработки: RTX 3080 Laptop GPU и Ryzen 9. Два слова из первого дня, на которые опирается всё остальное: кирпич — это 8 × 8 × 8 вокселей, а чанк — 8 × 8 × 8 кирпичей.

Вечер 16-го

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

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

Ошибка нормали внутри вырезанной сферы по режимам поверхности

Средний угол между затеняющей нормалью и настоящей нормалью сферы, градусы, при 320 × 180 на программном драйвере.

Грани 40,75°
Грани с детализацией 46,36°
Трилинейно, 8 соседей 18,62°
Градиент, 27 соседей 6,95°
Контурные плоскости 7,41°
Контуры с детализацией 28,4°

docs/plans/done/0005-surface-look-spike.md, таблица фазы 5. Ошибка силуэта столба — 172 пикселя во всех режимах, кроме контуров, где 32: нормаль не может сдвинуть попадание, плоскость может.

Показать числа
градусов
Грани 40,75°
Грани с детализацией 46,36°
Трилинейно, 8 соседей 18,62°
Градиент, 27 соседей 6,95°
Контурные плоскости 7,41°
Контуры с детализацией 28,4°

Проход лучей при 1080p по режимам поверхности

Миллисекунды на ограниченном уровне, по три замера, на RTX 3080.

Грани 1,877 мс
Грани с детализацией 1,994 мс
Трилинейно, 8 соседей 2,878 мс
Градиент, 27 соседей 4,568 мс
Контурные плоскости 1,944 мс
Контуры с детализацией 2,069 мс

Та же таблица. Точность градиента стоит 2,7 мс на кадр; точность контуров — от 0,05 до 0,07.

Показать числа
мс
Грани 1,877 мс
Грани с детализацией 1,994 мс
Трилинейно, 8 соседей 2,878 мс
Градиент, 27 соседей 4,568 мс
Контурные плоскости 1,944 мс
Контуры с детализацией 2,069 мс

Последняя фаза была моей: пролететь уровень в каждом режиме и выбрать. В логе осталось, что я сказал. «Больше всего мне нравится gradient27, но тени выглядят неправильно; контуры странные, много артефактов — чёрные линии»; «контуры без теней выглядят лучше всего (если нажать L)»; про детализацию: «похоже на что-то среднее между шумом и тенями, наверное, лучше поменьше». Я назвал двух финалистов, а потом: «Я ошибался, контуры — лучший режим». К тому времени чёрные линии уже были посчитаны. Подсчёт одиночных тёмных пикселей на программном драйвере дал для контуров 646 с тенями и 301 без, и 372 пикселя, которые становились пятнами только с включённой тенью, то есть теневой луч стартовал внутри плоскости, в которую только что попал; у граней таких 174. ADR-0011 в тот же вечер зафиксировал решение: каждый воксель полного кирпича несёт 16-битный атрибут, октаэдральную нормаль в двенадцати битах и смещение плоскости в четырёх, в страницах атрибутов внутри того же пула кирпичей, так что шейдер ничего нового не привязывает. С собственной оговоркой: «Решение нельзя выпускать при радиусе по умолчанию, пока доработка не уменьшит память под атрибуты». Со страницами атрибутов пещера с подгрузкой дошла до 3,3 ГБ живых данных и 4,1 ГБ выделенных на карте с 8 ГБ и умерла с ошибкой нехватки памяти. Доработка стала планом 0013, одобренным в 21:27.

Ещё одна строка из лога того плана, потому что она объясняет пост про первый день: «RTX 3080 Laptop GPU отвалилась от шины PCIe 16 октября в 17:51», с записью ядра «GPU lost from the bus». Первые две фазы пробы сняли на встроенной Radeon, грани за 17,2 мс и градиент за 41,3, и кадры в посте про первый день тоже отрисованы на Radeon, и тот пост её называет. После перезагрузки карта вернулась; фазу 3 снимали уже на ней.

Один и тот же проход в двух видах

Отрисовано headless в 1280 × 720 сборкой версии 0.24.0, ограниченный уровень, сид 1, камера как у заглавной картинки README, налобный фонарь с теневым лучом.

Проход в пещере под налобным фонарём, нарисованный нормалями граней: каждый воксель — видимый кубик, стены ступенчатые
Нормали граней, вид первого дня
Тот же проход, нарисованный контурными плоскостями с детализацией породы: гладкие округлые стены с мелкой фактурой, кубиков нет
Контурные плоскости с детализацией породы силой 0,1, режим по умолчанию с пятого дня

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

Ходьба. План 0004 дал движку контроллер персонажа: вертикальный цилиндр, плоский с обоих концов, который проводят сквозь воксели целочисленным conservative advancement, с зондом подъёма на уступ. ADR-0005 объясняет, почему цилиндр, а не капсула: «полусферическая стопа не может стоять на краю ступени, пока её ось не зайдёт за край… Каждый выпущенный контроллер обходит это плоским зондом стопы». Моя первая ходьба в окне тем вечером показала, что тело «замирало на ровном полу и у стен или углов, ни на один уступ нельзя было подняться, и только G освобождал его». Четыре причины исправили за следующие два часа, и две из них — ошибки, которые бывают только в целочисленной геометрии. У воксельного бокса меньше чем в единице от тела, на смещении 768,89 единицы от ребра по логу, квадрат расстояния округлился вниз до нуля, и он «останавливал каждое следующее движение, включая падение и шаг прочь». А корень из длины шага округляли вниз, так что остаток, до единицы длиннее самого шага, проходился целиком и заканчивался внутри бокса. В 22:04 я прошёлся снова: «всё хорошо, ощущается естественно. подстроим потом, если понадобится». Теперь это закреплено двумя тестами: 115 248 проводок, из которых ни одна не заканчивается внутри бокса, и 800 случайных прогулок на 56 135 тиков, которые ни разу не замирают.

Уши. План 0006 принёс пространственный звук: передаточную функцию головы из базы SADIE II, объект D2, манекен KEMAR, упакованную в формат движка и свёрнутую во временной области, 128 отсчётов на ухо, в микшере, который работает в колбэке устройства вывода и никогда не выделяет память и не берёт блокировок. Целиком голова весила 9 083 684 байта, втрое больше репозитория, так что оставили каждый третий азимут: 2 950 направлений, 3 044 420 байт. Окклюзия пускает от источника семь зондирующих лучей и считает на них отрезки породы; главный тест — тон 100 Гц в десяти метрах за пятью метрами материала с пропусканием 0,5 и поглощением 0,3 на метр, который должен звучать на уровне 0,0814 от открытого и звучит на 0,081379. В тот вечер я надел наушники и доложил две вещи: левое и правое перепутаны, и щёлкает, когда поворачиваешься или идёшь. Первое было в наушниках: «speaker-test сыграл „Front Left“ в правое ухо», это была системная карта каналов, движок тут ни при чём. У щелчков было три причины, каждая измерена: линейный кроссфейд между направлениями, четвёртая разность которого была в 227 раз больше, чем в покое, заменён на приподнятый косинус; звуковой крейт собирался без оптимизаций в отладочных сборках, 1,25 мс на блок в 256 кадров против 5,33 мс, которые блок длится; и окна упакованной головы по направлениям начинались с разбросом до 23 отсчётов. Я на слух сравнил в движке пакет с единым окном и старый: блоков со скачком фазы больше 0,3 радиана стало 14 из 3 611 вместо 378 из 2 722, «и щелчки на слух пропали».

Хук. В 18:35 появился новый хук, тот, который упомянут в посте про первый день: агенту нельзя просить композитор закрыть окно, потому что дважды за день тот закрывал вместо этого мой редактор. Через час случилось зависание при выходе, которое я не смог воспроизвести: мой полёт в окне не отреагировал на закрытие, и его пришлось убить, а все headless-варианты выходили сразу. Оболочка теперь пишет в лог каждую стадию завершения в фиксированном порядке, так что в следующий раз будет видно, на какой стадии зависло.

Ночь 17-го

Я оставил планы одобренными, с одним указанием на все случаи: «действуй по своим рекомендациям». Оркестрирующая сессия гнала их параллельно в отдельных worktree, и между 00:24 и 06:09 закрылись девять:

ВремяПланВерсия
00:240003, стоимость правки: строки-отрезки и таблицы цели0.4.1
00:270004, коллизии и контроллер персонажа0.5.0
01:120012, два гейта репозитория из Ritmolux—
02:060008, импорт MagicaVoxel и штамповка префабов0.6.0
03:160014, стоимость правки, второй заход0.7.0
03:430009, запрос опоры и операции обрушения0.8.0
03:530010, HUD со статистикой0.9.0
04:040007, журнал операций, байтовый канал, клиенты0.10.0
06:090011, хост WebAssembly для логики0.11.0

Шесть фаз за ночь остановились на своём условии готовности, том условии, которое фаза должна выполнить, чтобы считаться сделанной, и были поправлены на месте, и у каждой поправки в логе стоит «применено оркестратором… владелец может отменить». Клавиша штамповки V совпала с переключателем детализации, так что штамповка теперь P. Журнал операций не мог отличить операцию, которую сделала логика, от операции, поданной напрямую в том же тике, а отвергнутая операция забирала порядковый номер, которого не записывал ни один журнал; поправка — пограничная запись в 13 байт. План по стоимости правки остановил его собственный профиль: работа с отрезками, которую он собирался срезать ниже пяти процентов, занимала тринадцать, и поправка говорит прямо: «Цель nfr.md §4 в 2 мс этим планом не достигается». А хост WebAssembly обнаружил, что после любого прерывания, по топливу, по памяти или по панике, «wasmtime 49.0.3 отказывается входить в экземпляр компонента снова», так что сорвавшийся скрипт оставался остановленным.

В 07:11 сессия написала мне файл decisions-pending.md, а в 09:12 я на него ответил. Решения: базовая карта остаётся GTX 1660, а портативный профиль класса Switch 2 — профиль качества, а не гейт, и «до Switch 1 ray marcher такой формы не дотянется»; полупрозрачные твёрдые тела — воксели, а газ — отдельный объём, потом; прямой свет через ресемплирование по важности, а непрямой — через зонды; сцены под открытым небом — в рамках проекта; а вопрос о стоимости правки переформулирован «вокруг стабильности времени кадра на сложных сценах». Три ночных решения я отменил: контроллер получает выталкивание, тело, которое засыпала правка, поднимают наружу; сорвавшийся скрипт перезапускается со своего сида, а не остаётся остановленным, до восьми раз; а план про гейты получает патч-версию. Планы с 0016 по 0019 были написаны и одобрены к 10:02, план 0020 к 10:36, и я запушил main.

Что построила ночь

Журнал операций. Сервер владеет миром и журналом из (tick, seq, op); клиент либо общий, один мир за Arc, либо собственный, со своим миром, который проигрывает байты и поднимает событие рассинхронизации при первом расхождении. Выкапывание сферой — 47 байт в канале, заполнение коробкой 65, капсула 75. Проиграть 10 000 выкапываний по метру — 279 мс. Фраза из ADR-0007: «Журнал операций — единственный источник порядка. В каком бы порядке потоки ни вызывали submit, воспроизведение идёт в порядке seq журнала». Стенд получил --save и --replay, и хеш мира сохранённой сессии при воспроизведении сходится.

Хост WebAssembly. ADR-0010 в первый день сказал, что логика игры — это один компонент за трейтом, который разрабатывается нативно и поставляется как WebAssembly. План 0011 это доказал: одни и те же 100 входов через сервер с нативной эхо-логикой и со скомпилированным компонентом, «закодированные байты двух журналов операций равны, хеши двух миров равны». Собственная логика копания стенда переехала в отдельный крейт и даёт тот же хеш сессии в обоих вариантах. Каждый механизм детерминизма проверен гостем, написанным специально, чтобы его сломать: канонические биты NaN, часы, которые читают тик, источник случайности, засеянный тиком, никакой файловой системы, гость, который сжигает топливо, гость, который съедает лимит памяти, гость, который пишет по 64 КиБ в stderr на каждый вызов. Число, которое предложение назвало неверно, записано в итоге ADR: «Вызов хоста стоит около 1,4 мкс, а не десятки наносекунд». Бюджет поставили в 2 мкс; тихий замер на отсечке — 1,56. Через три дня план 0019 заставил хост брать плату за работу: за каждый байт списка операций, за каждый воксель в границах правки, за каждый шаг луча, за каждую ячейку, в которую заглянул запрос опоры, из топлива тика, так что подсчёт по кубу в 1 024 вокселя на сторону роняет тик по топливу вместо того, чтобы выполниться.

Префабы, HUD, обрушение. Файл MagicaVoxel импортируется в префаб с картой материалов, а префаб штампуется в мир в любом из 24 поворотов, при генерации или на ходу; свод 16 × 16 × 16 с оболочкой в один воксель, заштампованный в породу, меняет ровно 4 096 вокселей, а тот же свод, поставленный при генерации, даёт мир с тем же хешем. Ревью нашло файл в 70 байт, который выделил бы 3,1 ГиБ, и загрузчик теперь ограничивает модель 256 вокселями по стороне. HUD — это растровый шрифт 8 × 8 из 95 глифов в одном проходе поверх кадра, 0,038 мс на кадр, потому что из библиотеки интерфейса, рендерера текста и собственного шрифта я выбрал вариант без зависимостей; ADR-0009: «неверный бит — это неверный пиксель, и свалить его не на какую библиотеку». Появилось и обрушение, в первом варианте: запрос опоры, заливка по грубой сетке (ячейка на кирпич, та же, по которой считают коллизии и звук) от изменённой ячейки по несущим, с лимитом на размер, и две операции, жёсткое вертикальное падение области на минимальный зазор под ней и удаление, которое оставляет часть щебнем. «Область больше max_region не падает никогда: свод большой каверны „держится“ своим размером».

Вечер 17-го и 18-е

Прослушивание 17-го в 10:00, уже на влитом звуке, прошло: стук с правильной стороны, гул глуше за породой, никаких щелчков при повороте. Одна находка осталась открытой, щелчки при движении слушателя, которые пропадали с выключенными отражениями, значит, дело было в том, как переключался отвод отражения; её передали плану 0020. Вечером я запустил проверку версии 0.12.0 в окне и нашёл две вещи, которых ночные закрытия не нашли. Удалённая тонкая область печатала «removed: {rock: 201}, 0 rubble», потому что четверть каждого столбца округлялась в ноль. А колёсико мыши не меняло радиус копания: я «прокрутил раз сорок, и каждая строка читалась dig radius 0.5 m», дробная часть каждого кадра тоже округлялась в ноль. Третья находка не была ошибкой ни одного из планов: обрушение в пещере почти не случалось, потому что «ячейка 0,8 м с 64 сплошными вокселями из 512 всё ещё несёт нагрузку, так что тонкая оставшаяся перемычка держит навес», а я хотел навесов, которые падают от пролёта и веса, а не только когда их отрезало от породы. Это ушло в веху про обрушение в дорожной карте и стало решением 20-го, о котором ниже. План 0016, исправление всего этого и трёх решений, которые я отменил, прошёл 18-го и закрылся в 22:09 версией 0.13.0; в моей перепроверке колёсико печатает «dig radius 0.4, 0.5, 0.3, 0.2, 0.3 m», а кольцо, прорезанное в тонкой стене, оставляет 134 вокселя щебня из 598, то есть четверть.

Планка правки и кирпич, который её сдвинул

Первый день не уложился в бюджет правки: 100 выкапываний по метру за 4,92 мс против 2. План 0003 довёл до 3,56 на тихой машине, решая отрезки аналитически на каждую строку кирпича. План 0014 сначала профилировал и опроверг собственную арифметику плана: развёртывание кирпича, которое он собирался убрать, занимало пять процентов времени, а запись строк — 54. Он выпустил пул массивов кирпичей и более дешёвую строку капсулы и закрылся на 3,03 мс на прогретой машине с таким расчётом при закрытии: «Убрать решение строки, поиск отрезков и Brick::expand целиком — останется около 2,4 мс. Внутри представления из ADR-0002 до 2 мс не доходит ничто». Ревьюер рекомендовал пересмотреть бюджет. Тем утром я пошёл другим путём: новое представление.

ADR-0012, предложен и принят 17-го: кирпич — это 512-битная маска занятости, палитра на кирпич и столько битовых плоскостей, сколько нужно палитре, одни и те же байты в ОЗУ и в зеркале на GPU. «Время кадра — это пропускная способность памяти, а сегодняшний обход тратит на шаг пять зависимых чтений». Порядок работ в нём такой: сначала иерархия, потом формат. Формат для CPU пришёл 20-го планом 0021, и его первой фазой была перепись настоящих кирпичей: на уровне 383 608 полных кирпичей, из них 275 466 держат один материал и 107 325 два, так что объём падает с 392,8 МБ до 34,6; в пещере с подгрузкой при радиусах по умолчанию — с 3 053 МБ до 411. Бенчмарк ядра, по которому я решал 20-го в 09:12: 505,3 нс на правку кирпича сегодня, 116,8 на плоскостях, отношение 0,231. План закрылся в 13:37 с бенчмарком правки на 1,49 мс на прогретой машине в том же окне, где старый код показывал 2,86.

Планка правки и её замеры по планам

100 выкапываний сферой радиусом в метр, миллисекунды CPU; на прогретой машине там, где план записал такой замер.

  • линия бюджета
  • измерено
План 0001, первый день 2 мс 4,92 мс
План 0003, строки-отрезки 2 мс 3,56 мс
План 0014, пул и строка капсулы 2 мс 3,03 мс
План 0021, упакованный кирпич 2 мс 1,49 мс
Тихий повторный замер, 20 октября 2,2 мс 1,79 мс

docs/nfr.md §4 и замеры при закрытии планов. Собственный бенчмарк стенда показал 20-го от 2,06 до 2,09 мс при неизменном крейте мира, «так что это состояние машины, а не код», и я поднял планку до 2,2 мс на машине разработки, с ещё одним заходом на оптимизацию в плане.

Показать числа
линия бюджетаизмерено
План 0001, первый день 2 мс4,92 мс
План 0003, строки-отрезки 2 мс3,56 мс
План 0014, пул и строка капсулы 2 мс3,03 мс
План 0021, упакованный кирпич 2 мс1,49 мс
Тихий повторный замер, 20 октября 2,2 мс1,79 мс

Для памяти кирпич сделал больше, чем для правок. В файле бюджетов первого дня было сказано: «по ОЗУ запас самый маленький». Первая загрузка уровня в GPU упала с примерно 585 мс до примерно 190, а сброшенный на диск чанк из 512 кирпичей с двумя материалами занимает 37 416 байт вместо 524 840.

Память до и после упакованного кирпича

Мегабайты при радиусах подгрузки по умолчанию, как их записывают перепись и замеры при закрытии плана 0021.

  • до
  • после
Пик процесса, как его считает ядро системы 3 886 МБ 966 МБ
Резидентные чанки в ОЗУ 3 242 МБ 442 МБ
Объём кирпичей, пещера с подгрузкой 3 053 МБ 411 МБ
Объём кирпичей, ограниченный уровень 393 МБ 35 МБ

docs/plans/done/0021-packed-cpu-brick-census-kernel-and-readers.md и docs/nfr.md §3. Байты зеркала не сдвинулись: его половина формата — тот план, который ADR-0012 ещё должен.

Показать числа
допосле
Пик процесса, как его считает ядро системы 3 886 МБ966 МБ
Резидентные чанки в ОЗУ 3 242 МБ442 МБ
Объём кирпичей, пещера с подгрузкой 3 053 МБ411 МБ
Объём кирпичей, ограниченный уровень 393 МБ35 МБ

Проход лучей и иерархия

Второй невыполненный бюджет — первичные лучи в пещере с подгрузкой: 6,69 мс против 3, тогда как ограниченный уровень тот же шейдер проходил за 1,95. Первая фаза плана 0015 добавила в стенд вид по шагам и показала, в чём дело: «У точки появления в пещере с подгрузкой в среднем 15,55 шага на луч против 10,48 на уровне, в 1,5 раза, а время прохода — в 3,7 раза… Большая часть разницы — это цена шага: пять зависимых чтений в зеркало размером около 1,7 ГБ». Так что план построил иерархию битов занятости, по одному чтению на уровень: 512 битов кирпичей в начале каждой записи чанка, бит в таблице чанков для чанка из сплошного воздуха, 512-битная маска непрозрачности на кирпич вместо 8-байтовой, и обход, который держит свой чанк, биты, запись кирпича и маску от шага к шагу и читает пул только в точке попадания.

Проход лучей до и после иерархии

Миллисекунды при 1920 × 1080, медиана последних 30 кадров, три прогона, затенение с теневым лучом.

  • до
  • после
Пещера с подгрузкой, точка появления 6,81 мс 1,95 мс
Ограниченный уровень, точка появления 1,82 мс 1,45 мс

docs/plans/done/0015-traversal-hierarchy-chunk-bits-brick-masks-and-translucent-class.md, замеры при закрытии; планка — 3,0 мс. Среднее число шагов на луч в точке появления пещеры упало с 15,55 до 10,98, p99 — с 84 до 53.

Показать числа
допосле
Пещера с подгрузкой, точка появления 6,81 мс1,95 мс
Ограниченный уровень, точка появления 1,82 мс1,45 мс

Остановилась та фаза, которая меняла выход пропуска из ячейки. В посте про первый день была история про 17 одиночных пикселей, сдвинувшихся с приходом масок 2 × 2 × 2; пропуск 4 × 4 × 4 сдвигал пиксели в каждом эталоне, в каждой из картинок, с которыми CI сравнивает каждую сборку, а правило плана было «бит в бит». Меня спросили, и я решил: переснять эталоны один раз, «у разового переснятия нет порога по пикселям»; каждый эталон перегенерировали единожды, отличающиеся пиксели посчитали, от 58 до 141 на картинку, а координаты выхода у обхода теперь идут в порядке плоскостей, так что «картинка не зависит ни от одного пропуска». План закрылся 19-го в 10:32 с точкой появления пещеры на 1,93–1,98 мс по шести прогонам, «планка на машине разработки выполнена», и с классом полупрозрачных материалов в придачу: второй короткий обход от глаза до непрозрачного попадания, который накладывает перед ним до четырёх полупрозрачных вокселей. Тест плана говорит, что непрозрачному обходу он ничего не стоит; замер в точке появления вырос примерно на 0,1 мс, когда он лёг.

Контуры по умолчанию

У плана 0013 было три задачи: начало теневого луча, память под атрибуты и сила детализации. Начало сдвинули на полвокселя вдоль нормали плоскости, и число одиночных тёмных пикселей упало со 174 у граней до 52. Силу детализации выбирал я, 18-го, из 0,1, 0,15, 0,2 и 0,3: 0,1. Память оказалась самым трудным и закончился вариантом, который план называет (в). 17-го я выбрал как временную меру меньший радиус полной детализации, 14 чанков вместо 16. Тем временем план 0017 заставил зеркало резервировать свои 3 ГБ один раз при старте и сообщать давление, долю занятого, с правилом, которое снимает чанк радиуса полной детализации, когда давление переходит 0,9, и возвращает его ниже 0,7. 20-го, уже с этим, фаза остановилась: при 14 давление показало 0,911, когда всё загрузилось, и правило всё равно уменьшило радиус. Вариант (в) отзывает временную меру и не задаёт нового радиуса: вид по умолчанию укладывается в 3 ГБ одним только правилом давления. Два полёта автопилота установились на 12–13 чанках, от 76,8 до 83,2 м полной детализации, против «около 100 м» из дорожной карты; простой вид держит 16, 102 м. В файле бюджетов это записано как наблюдение, а не как невыполнение: живые байты на пике ниже планки. План, который удержит 16 с контурами, хранение атрибутов только для поверхности, — последний шаг ADR-0012, и он пока не написан. 20-го я его облетел, и в логе стоит: «вид по умолчанию принят; --level — любимый вид владельца, и он плавный».

Ревью при закрытии того плана нашло «настоящую регрессию»: p99 прохода атрибутов на потоке рендера, то есть кадр медленнее 99 из 100, был 167 мс, а вид по умолчанию пролетал на пятую часть меньше кадров, чем простой. Из этого вырос план 0023.

Поток рендера

Гипотеза ADR-0017, подтверждённая профилем 20-го: когда камера пересекает границу чанка, разом выгружаются около 2 000 чанков кольца пониженной детализации, и каждый запускает проверку 488 пограничных кирпичей, под миллион поисков в хеш-таблице за один кадр; бенчмарк насчитал 956 968 посещений кирпичей оболочки на одно пересечение. Три исправления, каждое измерено. Проверка оболочки теперь заходит только в пограничные кирпичи полных соседей, и случай выгрузки превращается в 50 986 поисков соседей и ноль посещений кирпичей, со 170,89 мс до 1,399 на кадре пересечения в бенчмарке. Проход ограничен 8 192 кирпичами на кадр с очередью переноса, а кирпич, который ждёт, получает запись очистки, пишущую «нет плоскости», так что он «показывает нормали граней, а не устаревшие плоскости». А очередь подгрузки, обход резидентности и сдвиг окна стали инкрементальными: худший кадр обновления ушёл с 37,8 мс до 4,75. Фазу, которая перенесла бы сужение палитр при загрузке на рабочие потоки подгрузки, я пропустил 21-го, посмотрев на её входной профиль.

CPU потока рендера до и после плана 0023

Миллисекунды на кадр в виде по умолчанию в пещере с подгрузкой, тихие повторные замеры 20 и 21 октября.

  • до
  • после
Проход атрибутов, p99 180 мс 6 мс
Проход атрибутов, максимум 390 мс 14,6 мс
Обновление подгрузки, максимум 37,8 мс 4,75 мс

docs/nfr.md §2 и замеры фазы 6 плана. Планка прохода атрибутов — p99 ниже 4 мс; 5,5–6,0 записаны как невыполнение. Планка подгрузки в 1,0 мс на кадр по-прежнему не выполнена в простом виде, p50 от 4,9 до 5,0; вид по умолчанию на установившемся радиусе показывает 0,006.

Показать числа
допосле
Проход атрибутов, p99 180 мс6 мс
Проход атрибутов, максимум 390 мс14,6 мс
Обновление подгрузки, максимум 37,8 мс4,75 мс

Результат я облетел утром 21-го: «Рывок: „почти исчез“, ни одной ощутимой остановки; частота кадров падала примерно до 40 fps на быстрых проходах… Грани вместо контуров на свежеподгруженной породе: „не бывает“, видимой задержки нет. Выкапывания: контуры в том же кадре». План закрылся в 11:21 версией 0.23.0, с планкой подгрузки, которая «по-прежнему записана как невыполнение», и с причиной, у которой впервые есть число: 41,8 процента отсчётов синхронизации — это копирование в буфер GPU, а сужение — примерно 38. Следующий шаг — кольцо промежуточных буферов, которое ADR отверг как свой вариант Б, так что взять его «можно только новым ADR, который отменит это отклонение». Я поставил его после плана по освещению.

Обрушение, который крушит

20-го я провёл в окне две проверки, вида по умолчанию и плана, который проводит штамповку и падение через сервер, и вторая нашла главную ошибку этих дней. Каждая область, которую запрос опоры называл неподдержанной, печатала «приземлилась через 0 тиков, упала на 0 вокселей». Запрос считает, что ячейка с менее чем 64 сплошными вокселями из 512 ничего не держит; падение же останавливается на любом сплошном вокселе снизу. Область на тонкой прослойке была одновременно неподдержанной и заклиненной, и никогда ничего не падало. Headless-воспроизведение на уровне: первая область, которую отделяют выкапывания по сиду, четыре ячейки и 501 воксель, лежит на ячейках, в которых от 31 до 38 сплошных вокселей, и падает на ноль.

Я выбрал падение, которое крушит то, сквозь что идёт, и ADR-0016 это записал: «Крушить, а не уносить. Раскрошенный объём становится щебнем, ссыпанным в конце падения, через сервер и журнал». Сплошной воксель вне области поддаётся, если в его ячейке сплошных меньше порога, а материал не неразрушимый: поиск зазора проходит сквозь него как сквозь воздух, и падение его крушит. Та область падает по восемь вокселей за тик, девять раз, 72 вокселя, круша по дороге 141, и садится на ячейку, которая держит нагрузку; операция ссыпания кладёт эти 141 обратно поверх неё щебнем. Полный прогон уровня по 40 000 выкапываний по сиду теперь находит одну область там, где заклинивающий код находил шесть, потому что первая падает, а следующие выкапывания, которые отделяли бы её снова, не находят ничего. Два новых вида операций, пятый и шестой, чтобы воспроизведение падало так же; хеш сценария обрушения сдвинулся и закреплён заново, хеш сессии не сдвинулся.

Приземлившийся кусок

Область, которую отделяет тест, после падения на 7,2 м сквозь прослойки: раскрошенные воксели ссыпаны обратно на неё щебнем. Отрисовано headless из собственного мира теста обрушения, лампа приглушена в восемь раз из-за расстояния.

Глыба воксельной породы, вид сверху и сбоку под тусклым налобным фонарём, её верх неровный, на нём насыпаны отдельные воксели, вокруг тёмная пещера
Четыре ячейки, 501 воксель, 141 раскрошен и ссыпан

Моя проверка в 22:23: 17 неподдержанных областей, 17 приземлений, ни одного отказа и ни одного заклинивания; «приземлилась через 4 тика, упала на 26 вокселей, раскрошила 159 вокселей, 159 щебня»; «приземлилась через 16 тиков, упала на 121 воксель, раскрошила 17 вокселей, 17 щебня»; 14 из 17 что-то раскрошили, и каждая кучка равна раскрошенному. «всё работает как задумано». Бенчмарки: плита сквозь прослойку 92–95 мкс против предложенных 100, поддающийся куб 7,4–10,6 мс против 20, ссыпание 35–38 мкс против 200.

Пещера, которую слышно

У плана 0020 три фазы с пометкой human, больше, чем у любого другого, потому что проверять их можно только ушами. Прямой звук, окклюзия и ранние отражения были у движка с 16-го. Этот план добавил поздний хвост: веер из 128 лучей от слушателя измеряет объём и площадь стен комнаты, которую он видит, формула Эйринга превращает их во время затухания, зажатое между 50 мс и 8 с, и одна общая сеть обратной связи из шестнадцати линий задержки воспроизводит хвост такой длины, с длинами линий в простых числах около средней длины свободного пробега. Ранние эхо воспроизводятся со стороны своей стены через шестнадцать общих шин направлений, каждая со своим откликом головы, и при движении слушателя скользят, а не переключаются, и это те самые щелчки, которые прослушивание 17-го оставило открытыми. Отражающая грань сохраняет корень пятой степени из единицы минус её поглощение, правило глубины проникновения. А --mic открывает микрофон как голос с заглушённым прямым путём, «чтобы игрок слышал, как его собственный голос возвращается от пещеры». Куб в 12 м, измеренный из центра, даёт затухание 2,249 с.

Две фазы остановились на условиях готовности, которые нельзя выполнить так, как они записаны: тест ходьбы просил меньше полдецибела изменения на блок, а идеальный рендер той же ходьбы без единого щелчка меняется на 5,24; тест шага между комнатами измерял гребень хвоста вместо щелчков. Обе поправили. Потом, 20-го в 17:55, я слушал. Из шести проверок прошли две. «С включённым хвостом теряется направление. С выключенным Y направление стука слышно; с включённым — нет». «Баланс неверный, эхо слишком тихое, а хвост слишком громкий». Комната в 4 м была «короткой и тесной» и прошла; комната в 36 м «не читается как большой зал: владелец ждёт, что голос вернётся поздно и почти в полную силу, как когда кричишь в настоящей пещере». От движения слушателя получалось что-то «вроде Доплера, но без сдвига высоты». А вылет за пределы мира и возвращение в зал давали «громкий хлопок».

Исправление, пять фаз, вошло между 18:40 и 19:44. Хлопок давал веер, пущенный из толщи породы: он сообщал комнату вообще без стен; теперь такой веер отбрасывается, а веер без единого попадания даёт ноль. Смена комнаты передаёт хвост между двумя сетями, когда средняя длина свободного пробега меняется больше чем на пятнадцать процентов, с кроссфейдом; измерено как +2,62 дБ над установившимся уровнем зала против +19,11 при скольжении и +34,10 при старом переключении. Эхо скользят со скоростью самой ходьбы и дотягиваются до комнаты в 100 м. Прямой путь получил необязательную задержку распространения, выключенную по умолчанию. А линии хвоста были ограничены 4 800 кадрами, и комната в 100 м это вскрыла: пятнадцать из шестнадцати линий упёрлись в предел, и хвост показывал −29,9 дБ против −22,6 у комнаты; предел теперь 28 000, а замер −22,56.

Я слушал снова в 22:27, в наушниках, которые названы в логе, и подбирал уровни руками по комнатам: от +30 дБ эха в комнате 4 м до +6 в комнате 100 м, хвост от −15 до 0, а в комнате 100 м с микрофоном «голос возвращается поздно и сильно, потом тянется, „вполне естественно“». Направление стука держалось во всех трёх комнатах на этих настройках. Значит, уровень — функция комнаты, и фаза 13 сделала его функцией: кусочно-линейной кривой по логарифму средней длины свободного пробега с узлами в четырёх комнатах, которые я настроил. ADR-0021 записывает цену: «Уровни больше не физические. В кубе 4 м при настройках по умолчанию ранняя энергия примерно на 34 дБ выше энергии прямого пути». Подтверждающее прослушивание утром 21-го: комната 4 м «хороша и естественна как есть», комната 12 м лучше с эхо на 3 дБ тише, и это уходит архитектору на пересчёт того узла, прогулка по пещере без единого скачка уровня в любом зале, а дрожание при ходьбе «почти отсутствует».

Уровни эха и хвоста по размеру комнаты, узлы кривой

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

  • ранние эхо
  • поздний хвост
Куб 4 м, 2,67 м 30 дБ -15 дБ
Куб 12 м, 8,0 м 15 дБ 0 дБ
Куб 36 м, 24,0 м 12 дБ 0 дБ
Комната 100 м, 69,0 м 6 дБ 0 дБ

docs/adrs/0021-echo-and-tail-levels-by-room-size-curves-and-a-tail-handed-between-two-networks.md. Средняя длина свободного пробега — среднее расстояние, которое луч проходит между стенами. Между узлами кривая линейна по его логарифму, за ними держит крайнее значение; у хвоста два узла, и дальше 8 м он равен 0.

Показать числа
ранние эхопоздний хвост
Куб 4 м, 2,67 м 30 дБ-15 дБ
Куб 12 м, 8,0 м 15 дБ0 дБ
Куб 36 м, 24,0 м 12 дБ0 дБ
Комната 100 м, 69,0 м 6 дБ0 дБ

Последнее решение из этого плана — правило для бенчмарков. Парный бенчмарк микшера в фазе 13 показал новый код на +5,80 процента к родительскому при том, что в измеряемом блоке не прибавилось никакой работы. После пересборки с выравниванием циклов на 64 байта «оба дерева показывают одно и то же… Разница в том, куда компоновщик положил цикл». ADR-0020: парные границы бенчмарков снимаются с выровненными циклами, и повторный замер дал +0,08. Абсолютные предложения для микшера всё равно не выполнены, 0,69 мс в покое против 0,6 и 1,14 при повороте против 1,0, на нагруженной машине; я принял нагруженные замеры, чтобы закрыть план 21-го, а тихий замер и оптимизация, если он останется выше, стали доработкой. Версия 0.24.0.

Снаружи мира

Вылет за границы уровня показывал чёрный экран, а хотелось вида, как когда вылетаешь через noclip за пределы карты в Source: уровень как прозрачный кукольный дом из освещённых комнат. Правило плана 0025 — одно предложение в шейдере: луч, который начинается в ячейке не из воздуха, игнорирует породу, пока не войдёт в пустую ячейку резидентного чанка, а потом попадает в следующую поверхность как обычно; чанк вне окна — не воздух, так что порода, за которой нет каверны, — промах. Попадание затеняется фиксированным полусферическим светом, без лампы и теневого луча, а промах — ровное небо. Камеру эталона я отодвинул с 20 м снаружи на 40, потому что с 20 неба было шесть процентов, а с 40 — двадцать четыре. Клавиша X, флаг --cutaway, и HUD показывает режим.

Уровень с 40 м снаружи его западной грани

Сид 1, камера эталона разреза в 1280 × 720 на RTX 3080: дальняя стена каждой каверны видна сквозь оболочку под полусферическим светом; там, где за породой нет каверны, — небо.

Разрез воксельного уровня снаружи: полоса серой породы, сквозь которую видны освещённые дальние стены множества каверн, и серо-голубое небо сверху, снизу и в просветах
Вид в разрезе

Луч снаружи проходит всю оболочку: в среднем 49,5 шага на луч и p99 170 против 0,91 и 1 для той же камеры без разреза. В файле бюджетов это диагностика, без планки.

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

Бюджеты на отсечке

Планки кадра и CPU против замеров, 21 октября

Миллисекунды на машине разработки. Базовой карты нет; её числа — оценки.

  • линия бюджета
  • измерено
Первичные лучи, пещера с подгрузкой 3 мс 1,98 мс
Первичные лучи, ограниченный уровень 3 мс 1,46 мс
CPU подгрузки на кадр, простой вид, p50 1 мс 5,02 мс
Проход атрибутов, поток рендера, p99 4 мс 6,02 мс
100 выкапываний по 1 м 2,2 мс 1,79 мс
Блок микшера, 32 голоса, в покое 0,6 мс 0,69 мс
Блок микшера, при повороте 1 мс 1,14 мс
Обновление акустики, 32 источника 1,1 мс 0,74 мс

docs/nfr.md §2, §4 и §9 на версии 0.24.0. У планок освещения, шумоподавления и апскейла, 10,5 мс из 16,6, замеров пока нет: они принадлежат плану 0024.

Показать числа
линия бюджетаизмерено
Первичные лучи, пещера с подгрузкой 3 мс1,98 мс
Первичные лучи, ограниченный уровень 3 мс1,46 мс
CPU подгрузки на кадр, простой вид, p50 1 мс5,02 мс
Проход атрибутов, поток рендера, p99 4 мс6,02 мс
100 выкапываний по 1 м 2,2 мс1,79 мс
Блок микшера, 32 голоса, в покое 0,6 мс0,69 мс
Блок микшера, при повороте 1 мс1,14 мс
Обновление акустики, 32 источника 1,1 мс0,74 мс

Планки памяти против замеров

Гигабайты, пик за полёты автопилота при радиусах по умолчанию, как их записывает файл бюджетов.

  • линия бюджета
  • измерено
Видеопамять: зеркало мира, живые байты против зарезервированного бюджета 3 ГБ 2,86 ГБ
ОЗУ: пик процесса после упакованного кирпича 4 ГБ 0,97 ГБ

docs/nfr.md §3. В первый день было 2,43 и 3,87. Планка видеопамяти выполнена правилом давления, которое держит полную детализацию на 12–13 чанках с контурами вместо 16.

Показать числа
линия бюджетаизмерено
Видеопамять: зеркало мира, живые байты против зарезервированного бюджета 3 ГБ2,86 ГБ
ОЗУ: пик процесса после упакованного кирпича 4 ГБ0,97 ГБ

Четыре планки за эти дни поменял я: планку правки на 2,2 мс, предложение в 5 мкс для проводки на её замер 26,3 мкс, предложение в 0,5 мс для обновления акустики на её замер 1,0 мс, и нагруженные замеры микшера, принятые как невыполнение. Каждая так и записана в файле, с датой.

Что дальше и что открыто

Следующий план — тот, который ADR-0001 назвал самым большим риском: освещение. План 0024 одобрен, и в нём не начато ничего.

flowchart TB
hit["проход попаданий: по записи на пиксель (позиция, нормаль, материал, плоскость)"] --> light["проход света: один образец на пиксель, фонарь или светящийся воксель, выбранный ресемплированием; один теневой луч"]
mirror["загрузка в зеркало извлекает светящиеся воксели в буфер образцов света"] --> light
light --> temporal["временной проход: перепроецировать результат прошлого кадра; стереть историю внутри бокса каждой правки"]
temporal --> atrous["проход a-trous: фильтр по дисперсии"]
atrous --> comp["сборка: вернуть альбедо, добавить свечение, экспозиция, тональная кривая"]

Довод ADR-0018 — арифметика базовой карты: «Планка освещения в §2, 6,0 мс, покупает на базовой карте примерно два луча на пиксель в полном разрешении или восемь в половинном. Любое семейство, которому нужно больше одного-двух теневых лучей на пиксель за кадр в полном разрешении, в базовую карту не укладывается». Четыре механизма остаются переключаемыми, пока я их не увижу: обход теневого луча, выбор образца света, стирание истории и то, считать ли свет в половинном разрешении. Два из его условий готовности: светящийся куб 4 × 4 × 4 «держит ровно 56 образцов света после синхронизации», пол под светящимся потолком — в пределах пяти процентов от освещённости 1,741. Последняя фаза переснимает заглавную картинку README со светящейся жилой.

Кроме него открыто: кольцо промежуточных буферов для копирования при подгрузке, которому нужен свой ADR; план хранения атрибутов, который удержит 16 чанков контуров в 3 ГБ; доработки звука, тихий замер микшера, дрожание, узел 12 м; три хеша, которые ждут подтверждения от Windows-ветки CI; DualSense на диване; и базовая карта, «которую нужно купить или одолжить до закрытия M2». README отстаёт от движка на три плана: он не знает X, --cutaway, --pad, --room и --mic и по-прежнему пишет, что реверберация «в работе».

Как были организованы пять дней

Харнесс тот же, что в первый день: ADR на настоящий компромисс, план с фазами и условием готовности на каждую, dev строит, ревью при закрытии в свежей сессии, моё «go». За пять дней прибавился масштаб: 20-го одновременно шли четыре агента в четырёх worktree, поэтому почти каждый замер того дня помечен «не тихо» со средней нагрузкой за минуту от 8 до 29, и поэтому тихие повторные замеры существуют как отдельные коммиты. Одиннадцать планов закрылись 19-го, 20-го и 21-го. Между двумя из них прошло обновление зависимости, потому что инструмент проверки уязвимостей упал на двух предупреждениях про wasmtime; патч-версия закрыла оба. А каждую фазу с пометкой human делал я, за клавиатурой или в наушниках, кроме геймпада на диване.