Я только что выпустил обновление моей игры Blackshift, в котором, среди прочего, были добавлены эти тайлы песка:

Всё было хорошо, пока не начали поступать отчёты о багах вот с такими скриншотами:

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

Для того, чтобы знать, где размещать тени, фрагментный шейдер считывает карту соседства. Это 8-битный integer для каждого тайла, по одному биту на каждое из соседних направлений.

Как и всё остальное в Blackshift, тайлы песка отрисовываются при помощи инстансинга GPU: все тайлы песка на экране отрисовываются одной группой. GPU получает матрицу преобразований для каждого экземпляра и знает, что для всех них нужно использовать один меш. Так как каждый экземпляр имеет собственные данные о соседстве, значение соседства тоже передается в данных каждого экземпляра вместе с матрицей преобразований3.
Значение соседства — это 8-битный integer, но поскольку bgfx в качестве данных экземпляров поддерживает только float, перед записью в буфер экземпляров этот integer преобразуется во float.
Вершинный шейдер считывает его и передаёт фрагментному шейдеру, который затем преобразует его обратно int и проверяет отдельные биты, чтобы знать, где располагать тени.
Разумеется, из-за проблем с точностью при работе с float нужно быть аккуратным, но для хранения любого integer от 0 до 255 у них достаточно точности, так что никаких сложностей здесь нет. Если CPU решает, что integer соседства равен 238, то он запишет 238.0f и шейдер считывает 238.0f, преобразует значение обратно в 238 и считывает биты4.
Вот так и рендерятся тайлы песка. Так где же баг?
* * *
Первым делом я подумал, что это похоже на Z-конфликты, но это не имело никакого смысла. Поверхность песка — это одна поверхность, ей не с чем конфликтовать. Чтобы убедиться в этом, я отключил z-буферизацию, и да, артефакты не пропали. Дело было не в Z-конфликтах.
Затем я проверил миниатюры списка уровней. В GUI списка уровней Blackshift рендерит миниатюры загружаемых игроками уровней, позволяющие выбирать, в какой из них играть. Эти миниатюры рендерятся немного иначе, чем кадры геймплея, поэтому иногда они могут быть хорошим тестовым случаем; если баг возникает на обычном рендере, но не на рендере миниатюр, то разница в техниках рендеринга может подсказать источник проблем.

Я задал вопрос игрокам, у которых возникал этот баг, и оказалось, что в этом случае он не проявлялся на миниатюрах, только в самой игре.
Это стало отличной подсказкой. Самая важная разница между рендерами миниатюр и игры заключается в том, что для миниатюр вообще не используется инстансинг GPU; всё это отключено. В нём выполняется примерно по одному вызову отрисовки на объект и на материал, а всё, что обычно передаётся как данные экземпляров, отправляется в виде uniform.
Я потратил кучу времени на дотошное исследование кода инстансинга и проверил всё дважды, но в конечном итоге выяснилось, что это ложная улика. После отключения инстансинга в основном рендере баг сохранился. И это значило, что... разумеется, оставалась единственная причина. В конце концов, между рендерами миниатюр и игры было только ещё одно различие.
Вернёмся к нашим float. CPU решает, что integer соседства равен 238, и записывает в буфер данных экземпляров 238.0f; вершинный шейдер извлекает это значение и записывает его в varying, а фрагментный шейдер считывает его оттуда, интерпретирует и отрисовывает тени.
Дело в том, что когда 238.0f добирается из вершинного во фрагментный шейдер, как и любая другая переменная varying, она интерполируется GPU по поверхности отрисовываемого треугольника. А поскольку значения всех трёх углов каждого треугольника одинаковы, я думал, что получающиеся интерполированные значения в каждой точке треугольника должны быть одинаковыми. И они действительно были... на моей машине.

Но когда эту интерполяцию выполняют GPU, они делают это с перспективной коррекцией, при которой выполняется деление на глубину каждого фрагмента с последующим умножением в конце5; если немного упростить, то можно сказать, что это может вызывать появление численной неточности. Наверно, мой GPU достаточно умён, чтобы заметить, что все три точки треугольника имеют одинаковое значение, и пропустить все эти вычисления, но GPU некоторых игроков этого не делают. Они выполняют все эти перспективные деления и умножения, получая немного отличающиеся значения float.
Это объясняет наблюдаемые артефакты; когда после интерполяции значение соседства пикселя оказывается меньше (даже на бесконечно малое значение), чем нужно, оно интерпретируется, как integer на единицу ниже, что соответствует совершенно иной карте соседства, а значит, и другому паттерну теней.
В конечном итоге я исправил это на стороне CPU; строку
(float)adjacency
в буфере экземпляров я заменил на
(float)adjacency+0.5f
Любые колебания теперь безопасно оказываются в пределах одного и того же integer, а артефакты пропадают6.
Так почему же артефакты не появлялись на рендерах миниатюр? Присмотритесь. Видите ответ?

Ответ
Миниатюры рендерятся ортогональной камерой, не требующей перспективной коррекции.
Заключение
Главный вывод здесь таков: в мире GPU запись одного и того же значения во все три вершины не гарантирует, что все фрагменты в треугольнике получат это значение. У некоторых фрагментов значение может слегка отличаться, но только на отдельном оборудовании и только если использовать перспективную проекцию, однако может также существовать оборудование, которое делает то же без перспективной проекции.
Сноски
Я воспринимаю это как фальшивое ambient occlusion. На самом деле, всё ambient occlusion фальшивое, как и вся остальная компьютерная графика.
Четыре четырёхугольных полосы по краям становятся видимыми гранями тайлов без песка, соседних с тайлом песка. На изображении они представлены в виде вертикальных боков серых тайлов. Если тайла нет, вершинный шейдер просто перемещает их все из вида.
Почему бы просто не хранить карту соседства для всего уровня в виде текстуры? Даже если бы я сделал так, шейдеру всё равно нужно было знать, на какие координаты в текстуре смотреть, поэтому мне всё равно пришлось бы отправлять координаты отрисовываемой ячейки в качестве данных экземпляров. То есть если данные экземпляров в любом случае необходимы, можно просто отправлять само значение, а не какие-то координаты для поиска значения в текстуре. Кроме того, bgfx ограничивает нас двадцатью float на экземпляр, а у меня был только один запасной. Думаю, можно было бы воссоздавать координаты ячейки, заглядывая в матрицу преобразований, уже занимающую бóльшую часть этих 20 floats; думаю, это бы сработало. Но в то время я об этом не думал. Разумеется, если бы я использовал текстуру, то нужно было бы поддерживать её актуальность, и мне бы понадобилось множество текстур, потому что иногда Blackshift отрисовывает множество уровней одновременно (например, когда игрок скроллит список уровней и смотрит миниатюры уровней других пользователей; они рендерятся конкурентно на стороне клиента). Это просто было бы сложнее.
Зачем вообще использовать float? Потому что bgfx поддерживает только float. Зачем преобразовывать их значения вместо выполнения bitcasting? Потому что я поддерживаю старый OpenGL, не умеющий выполнять bitcasting.
Есть хорошее объяснение интерполяции с перспективной коррекцией.
Почему просто не пометить переменную varying, как flat? Повторюсь, я поддерживаю старый OpenGL, где flat отсутствует.
Комментарии (6)

uxgen
15.04.2026 15:18Все решается проще через flat квалификатор. Была бы там поддержка интов, без него вообще бы шейдер не скомпилировался.

GCU
15.04.2026 15:18Не понимаю зачем для тайла из пары треугольников вообще использовать инстансинг и что делать со щелями на границах тайлов в худшем случае.

zakker
15.04.2026 15:18(float)adjacency+0.5finline long int fround(float x) { return _mm_cvtss_si32(_mm_load_ss(&x)); }upd: у вас другая задача: сделать такой float, чтобы при отсечении дробной части всегда получался исходный int. Но да ладно, может кому пригодится функция

Jijiki
15.04.2026 15:18//структура для наглядности #[repr(C)] #[derive(Copy, Clone, Debug)] pub struct Vertex { pub pos: [f32; 3], pub norm: [f32; 3], pub uv: [f32; 2], pub v_id: f32, } //fragment shader if (int(v_ID + 0.5) == level.isSelected) {Или в шейдере сразу можно, правда для этого придется передавать его, а выбирать трассировочным лучом бвх или бсп - обычно спуск по дереву, выбор по айди, если это выбор. Или не выбирать как у автора.
На всякий случай: такая ситуация с деревом(бсп-браш или бвх оба по центровкам уи обьектов могут работать) хорошо вписывается в структуру мира где есть 2 дерева для уи и для обьектов, мир может быть любым, потомучто камера может работать в двух режимах орто и перспектива и их можно смешивать.
Milliard
Почему он сразу так не сделал? Очевидно, что при преобразовании int во float и обратно, обязательно наступит момент, когда у float появится отрицательный эпсилон.
asurkis
Совсем не очевидно, если не задумываться о том, как выполняется интерполяция. В том же JavaScript ведь никто не добавляет 0.5 к длине массива при итерации, а там все числа вещественные, только 64-битные