Я только что выпустил обновление моей игры 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 запись одного и того же значения во все три вершины не гарантирует, что все фрагменты в треугольнике получат это значение. У некоторых фрагментов значение может слегка отличаться, но только на отдельном оборудовании и только если использовать перспективную проекцию, однако может также существовать оборудование, которое делает то же без перспективной проекции.
Сноски
1. Я воспринимаю это как фальшивое ambient occlusion. На самом деле, всё ambient occlusion фальшивое, как и вся остальная компьютерная графика.
2. Четыре четырёхугольных полосы по краям становятся видимыми гранями тайлов без песка, соседних с тайлом песка. На изображении они представлены в виде вертикальных боков серых тайлов. Если тайла нет, вершинный шейдер просто перемещает их все из вида.
3. Почему бы просто не хранить карту соседства для всего уровня в виде текстуры? Даже если бы я сделал так, шейдеру всё равно нужно было знать, на какие координаты в текстуре смотреть, поэтому мне всё равно пришлось бы отправлять координаты отрисовываемой ячейки в качестве данных экземпляров. То есть если данные экземпляров в любом случае необходимы, можно просто отправлять само значение, а не какие-то координаты для поиска значения в текстуре. Кроме того, bgfx ограничивает нас двадцатью float на экземпляр, а у меня был только один запасной. Думаю, можно было бы воссоздавать координаты ячейки, заглядывая в матрицу преобразований, уже занимающую бóльшую часть этих 20 floats; думаю, это бы сработало. Но в то время я об этом не думал. Разумеется, если бы я использовал текстуру, то нужно было бы поддерживать её актуальность, и мне бы понадобилось множество текстур, потому что иногда Blackshift отрисовывает множество уровней одновременно (например, когда игрок скроллит список уровней и смотрит миниатюры уровней других пользователей; они рендерятся конкурентно на стороне клиента). Это просто было бы сложнее.
4. Зачем вообще использовать float? Потому что bgfx поддерживает только float. Зачем преобразовывать их значения вместо выполнения bitcasting? Потому что я поддерживаю старый OpenGL, не умеющий выполнять bitcasting.
5. Есть хорошее объяснение интерполяции с перспективной коррекцией.
6. Почему просто не пометить переменную varying, как flat? Повторюсь, я поддерживаю старый OpenGL, где flat отсутствует.
ссылка на оригинал статьи https://habr.com/ru/articles/1023806/