Я недавно выпустил обновление для Blackshift, добавив в игру новые типы песчаных тайлов:

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

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

Чтобы корректно накладывать тени, фрагментный шейдер анализирует карту соседства. Это 8-битное целое число (integer), где каждый бит соответствует одному из направлений.

Как и остальные элементы игры, тайлы рендерятся через GPU-инстансинг: вся группа объектов рисуется за один вызов. GPU получает массив трансформаций для каждого экземпляра, используя одну и ту же базовую модель. Данные о соседстве передаются вместе с матрицей трансформации3.
Так как bgfx поддерживает для данных инстансов только float, я преобразовываю 8-битный integer в число с плавающей запятой перед отправкой в буфер.
Вершинный шейдер считывает этот float и передает его фрагментному, который конвертирует значение обратно в integer, чтобы выделить необходимые биты. Погрешности float для диапазона 0–255 обычно не критичны, поэтому я был уверен в надежности этого метода: если CPU пишет 238.0f, шейдер должен получить те же 238.0f4.
Но где же тогда прятался баг?
* * *
Первым делом я заподозрил Z-конфликты (z-fighting), но это было лишено смысла: поверхность едина. Отключение Z-буферизации лишь подтвердило мои подозрения — проблема была в другом.
Затем я обратил внимание на предпросмотр уровней. В GUI Blackshift миниатюры рендерятся иначе, и, как выяснилось, на них артефактов не было. Это была важная зацепка.

Главное различие в том, что для миниатюр инстансинг отключен. Однако тщательная проверка кода инстансинга показала, что это ложный след — даже при его отключении ошибка сохранялась.
Вернувшись к работе с float, я осознал: когда значение передается из вершинного шейдера во фрагментный, GPU выполняет интерполяцию. Поскольку все вершины треугольника имеют одинаковый «соседский» float, я полагал, что результат будет стабильным. И на моем железе он был таковым.

Проблема кроется в перспективной коррекции интерполяции5. На некоторых GPU вычисления с плавающей запятой приводят к микроскопическим отклонениям. В итоге значение 238.0f может превратиться в 237.999…, что при обратной конвертации в integer дает 237 вместо 238, полностью меняя паттерн теней.
Решение оказалось простым: при записи в буфер я добавил смещение:
(float)adjacency + 0.5f
Это нивелирует погрешности, гарантируя попадание в нужный диапазон целого числа6.
Почему же проблема не возникала на миниатюрах? Ответ прост.

Ответ
Миниатюры рендерятся с использованием ортогональной проекции, которая не требует перспективной коррекции интерполяции.
Заключение
Важный урок: в графическом конвейере передача одинаковых значений в вершины не гарантирует идентичности данных для каждого фрагмента треугольника. Особенности работы GPU с перспективной проекцией могут вносить непредсказуемые искажения на разном оборудовании.
Сноски
1. Это своего рода «псевдо-AO». По сути, вся компьютерная графика — это набор компромиссов и иллюзий.
2. Четыре полосы по краям отвечают за стыковку с соседними тайлами. Если сосед отсутствует, шейдер убирает их из кадра.
3. Хранение соседства в текстуре было бы альтернативой, но это усложнило бы архитектуру при одновременной отрисовке нескольких уровней в списке.
4. Использование float обусловлено ограничениями API bgfx и поддержкой старого оборудования.
5. Подробнее об этом можно почитать здесь.
6. Я не мог воспользоваться квалификатором flat, так как вынужден поддерживать устаревшие версии OpenGL.


