Автор разбирает проблему оптимизации отрисовки 2D‑игры со спрайтами и мягкими (fuzzy) краями, где нельзя полагаться на z‑buffer, а нужен классический painter’s algorithm — рисование объектов сзади наперёд.
Исходная постановка
В игре порядка 500 сущностей. Каждая сущность — спрайт с двумя дополнительными эффектами: «подсветка снизу» (underglow) и «подсветка сверху» (overglow). Для корректного наложения эффектов (без «просвечивания» огней через другие объекты) каждый объект рисуется в три прохода: underglow → спрайт → overglow. Наивная реализация даёт 3 draw call на объект, то есть 1500 draw call только на базовый слой.
Проблема усугубляется тем, что поверх могут быть дополнительные слои и эффекты, ещё увеличивающие количество draw call.
Использование сетки мира
Мир разбит на сетку. Объекты, как правило, находятся в пределах одной ячейки и не «прыгают» далеко (из верхнего левого в нижний правый угол). Это позволяет делать допущения для оптимизации.
Идея: батчить отрисовку по ячейкам и типу слоя. Вместо того чтобы рисовать каждый объект полностью по очереди, можно, например, сначала нарисовать все underglow для «первого» объекта в каждой ячейке одним draw call, затем все спрайты, затем все overglow, а потом повторить цикл для «второго» объекта в каждой ячейке (второй Z‑слой внутри ячейки).
При 8 ячейках и 2 объектах в каждой, наивный метод даёт 48 draw call (8 ячеек × 2 объекта × 3 слоя), а батчинг по слоям и ячейкам — всего 6 (3 слоя × 2 «прохода» по объектам). Выигрыш существенный.
Ограничения и сложности
Реальный случай сложнее:
- В одной ячейке может быть от 1 до 16 сущностей.
- Некоторые объекты частично заходят в соседние ячейки, что создаёт риск неправильного порядка отрисовки (артефакты и «z‑fight» на уровне painter’s algorithm).
- Нужно избежать тяжёлой сортировки каждый кадр.
Метаинформация и Z‑структура
Для каждого объекта автор использует два ключевых параметра:
- Индекс ячейки сетки (grid square).
- Z‑значение, попадающее в один из 16 Z‑диапазонов (bands) на весь мир.
Это позволяет гарантировать, что до 16 сущностей в ячейке могут быть корректно отрисованы в разных Z‑слоях (по сути, 16 отдельных draw call для них, если нужно).
Чтобы минимизировать сортировку в рантайме, список сущностей изначально формируется в уже отсортированном по Z виде. Пример структуры: A1, B1, C1, D1, E1, F1, G1, H1, A2, C2, F2, где чёрный индекс — Z‑уровень, а синий — ячейка сетки. Этот список становится «дефолтным» порядком painter’s algorithm.
Двухслойный рендер-пайплайн
Для генерализации решения автор предлагает двухуровневую архитектуру рендеринга:
- Слой 1 (логический): игровой код просто эмитирует «сырые» draw call с метаданными (текстура, режим смешивания, параметры объекта и т.п.) в наивном порядке, без попытки оптимизации.
- Слой 2 (оптимизатор): собирает все запросы слоя 1 за кадр, анализирует их, вычисляет bounding box каждого вызова, определяет пересечения и возможные батчи, и формирует оптимизированную последовательность draw call для DirectX с минимальным числом смен состояний и вызовов.
Далее оптимизированный пакет может отправляться в отдельный поток, который «стримит» команды в DirectX, пока основной поток подготавливает следующий кадр.
Плюс архитектуры — её универсальность: один и тот же механизм может обслуживать как статичные сущности, так и любые другие объекты и эффекты. Также можно оставить возможность отключать второй слой и рендерить «как есть» для сравнения производительности (например, по горячей клавише).
Выводы
- Наивный painter’s algorithm с несколькими эффектами на спрайт быстро взрывает количество draw call.
- Разбиение мира на сетку и батчинг по ячейкам и слоям даёт кратное снижение числа вызовов рендера.
- Хранение сущностей в заранее отсортированном по Z списке уменьшает потребность в сортировке каждый кадр.
- Двухслойный рендер-пайплайн (сырые команды → оптимизатор) позволяет централизованно решать задачи батчинга и порядка отрисовки.
- Такой подход масштабируется и подходит для сложных 2D‑игр со спрайтами и мягкими краями, где z‑buffer использовать нельзя.