Рендер-конвейер и culling
Механизм: конвейер и его логистика
Стадии конвейера
GPU прогоняет геометрию через конвейер (упрощённо):
GPU — брандспойт с высокой латентностью
GPU не быстрый на одном потоке — он массивно-параллельный: тысячи потоков, сгруппированных в варпы по 32, исполняющихся в lockstep. Доступ к памяти долгий (сотни циклов), но латентность прячется occupancy — пока один варп ждёт память, GPU переключается на другой. Два убийцы пропускной способности: дивергенция (потоки варпа взяли разные ветки → варп исполняет обе) и разбросанный доступ (соседние потоки читают несоседние адреса → много транзакций вместо коалесценции). Вывод: конвейер хочет когерентную, пакетную, предсказуемую работу — и вся оптимизация про это.
Culling — не обрабатывать невидимое
Самый большой рычаг — не отправлять в конвейер то, что не увидят, и как можно раньше:
- Frustum culling: объект вне пирамиды видимости камеры → пропускаем целиком (на CPU/через bounding-объёмы).
- Backface culling: треугольник повёрнут «спиной» (знак экранной площади) → не растеризуем ~половину треугольников.
- Occlusion culling: объект спрятан за другой геометрией → пропускаем (hi-Z, occlusion queries; предок — PVS в Quake).
Каждый уровень режет работу перед дорогими стадиями. В открытом мире это разница между «миллион треугольников» и «100 тысяч видимых».
Overdraw — не красить пиксель дважды
Если рисовать дальнее-потом-ближнее, ближнее перезаписывает дальнее — и вся работа fragment-шейдера по дальнему пикселю выброшена. Это overdraw, тихий убийца производительности (особенно прозрачность, частицы, дым — их нельзя early-z-отбросить). Лечат сортировкой front-to-back (ранний depth-тест отбрасывает закрытые фрагменты до шейдинга) и depth-prepass (сначала рендерим только глубину, потом шейдим лишь реально видимое). Сцена с 3× overdraw шейдит втрое больше пикселей, чем показывает.
Draw calls — не гнать по детали на грузовик
Каждый draw call — команда CPU→GPU с оверхедом (смена состояния, валидация драйвером). Это CPU-стоимость, не GPU. Бюджет кадра 60 fps — 16.67 мс; стоимость CPU:
10 000 объектов по одному вызову × ~0.05 мс = 500 мс — кадр невозможен (CPU-bound). Лечат инстансингом (один вызов рисует один меш N раз — трава, толпа) и батчингом (слить меши с одним материалом): 100 вызовов × 0.05 = 5 мс — влезает. А explicit-API (Vulkan/DX12) убрали драйвер как единственное узкое место: N потоков строят N командных буферов и сабмитят в конце кадра (в GL/D3D11 драйвер был однопоточным горлом).
Масштабирование света: forward vs deferred
Наивный forward освещает каждый объект каждым источником → (100 источников × 1000 объектов = 100k проходов). Deferred сначала пишет геометрию (нормаль/альбедо/глубину) в G-буфер, потом считает свет по пикселям из него → . Цена deferred — память (G-буфер = несколько полноэкранных картинок) и плохая прозрачность. Современный компромисс — Forward+ (тайловый): экран на плитки 16×16, для каждой считаем список влияющих источников — прозрачность forward + эффективность deferred.
🕹 Что включить — и что заметить
Логистика рендера видна через debug-виды движков: overdraw, wireframe, счётчик draw calls.
Почти в любом движке есть режим «overdraw»: чем краснее, тем больше раз пиксель перекрашен. Частицы, дым, листва, UI-слои светятся красным — вот где утекает fragment-бюджет.
🎮 Сделай: в UE/Unity включи overdraw-вью и посмотри на сцену с дымом/частицами — увидишь красные зоны многократной перерисовки. Сравни с непрозрачной геометрией (почти без overdraw благодаря early-z). Это и есть «не крась пиксель дважды» глазами.
В любой открытой игре повернись резко на 180° — заметишь, как объекты появляются, входя в пирамиду видимости (frustum culling), и как то, что за стеной, не рисуется (occlusion). Pop-in LOD — обратная сторона той же экономии.
🎮 Сделай: в открытом мире быстро крути камеру у края видимости — поймай момент «дорисовки». Зайди за большую стену/здание и мысленно спроси: рисует ли движок то, что за ней? (Не должен — occlusion culling.) Это работа, которую не делают.
Статистика рендера (Unity Stats, UE `stat rhi`, RenderDoc) показывает число draw calls и батчей. Сцена с тысячами уникальных объектов CPU-bound; та же сцена с инстансингом — десятки вызовов.
🎮 Сделай: в редакторе движка глянь draw calls до и после включения инстансинга/статик-батчинга на поле травы или толпе. Заметь, как число вызовов падает на порядки, а FPS растёт — при той же картинке. Оверхед был на CPU, не на GPU.
Хардкор · GPU-архитектура: варпы, occupancy, дивергенция, коалесценцияможно пропустить
Оптимизация рендера — это работа с архитектурой GPU, а не против неё.
Варпы и дивергенция
Потоки исполняются группами по 32 (варп, NVIDIA) в lockstep — одна инструкция на все 32. Если внутри варпа if разводит потоки по веткам, GPU исполняет обе ветки последовательно, маскируя неактивные — эффективная загрузка падает вдвое (или хуже при вложенных ветвлениях). Отсюда правило: ветвись по данным так, чтобы соседние потоки шли одной веткой (coherent branching).
Occupancy — как прячется латентность
Доступ к глобальной памяти — сотни циклов. GPU прячет их не кэшем, а переключением варпов: пока один ждёт память, считается другой. Чем больше активных варпов (occupancy), тем полнее спрятана латентность. Слишком много регистров/shared-памяти на поток снижает occupancy (меньше варпов влезает). Трассировка лучей роняет occupancy (длинные стойлы на обход BVH) — нужно много потоков в полёте.
Коалесценция памяти
Если поток 0 читает адрес X, поток 1 — X+1, … — GPU сливает это в одну транзакцию (coalesced). Разбросанный доступ (strided/random) → десятки транзакций на тот же объём. Поэтому data-oriented раскладка (SoA, плотные массивы) так важна для GPU-кода: она делает доступ когерентным. Shared-память (per-block кэш) — ручной способ переиспользовать данные без похода в глобальную.
Хардкор · инженерия: explicit-API, командные буферы и forward+ по тайламможно пропустить
Почему explicit-API
В OpenGL/D3D11 ты ставил состояние, а драйвер догадывался, как оптимизировать — и делал это в одном потоке, становясь горлом на многоядерных CPU. Vulkan/DX12/Metal/WebGPU сделали всё явным: ты сам собираешь командные буферы, управляешь памятью, дескрипторами и синхронизацией. Выигрыш — многопоточная запись команд (N потоков → N буферов → сабмит в конце кадра) и предсказуемость; цена — больше кода и способов ошибиться. Паттерн (command buffers / descriptor sets / barriers) переносится между API — выучил один, остальные это другой словарь.
Forward+ по тайлам
Deferred меняет класс сложности света с на , но платит памятью G-буфера и ломает прозрачность (только один слой глубины). Forward+ бьёт экран на тайлы, compute-проходом считает для каждого тайла список влияющих источников (light culling), затем forward-шейдит объект только релевантными источниками. Получаем и прозрачность (forward), и почти-deferred-эффективность. Это пример смены класса сложности реструктуризацией, а не микрооптимизацией.
ML / AI (твой домен): это дословно утилизация GPU в трейнинге/инференсе. Варпы/occupancy ⇄ загрузка тензор-ядер и batch size (мал батч → GPU голодает, как низкий occupancy); коалесценция памяти ⇄ contiguous/coalesced доступ к тензорам (и почему раскладка решает); дивергенция ⇄ почему dense-операции бьют sparse на GPU. «Не слать работу, которую выбросят» = pruning / early-exit / MoE-роутинг / sparsity; «батчить, чтобы амортизировать оверхед на вызов» = батчинг инференс-запросов и амортизация kernel-launch. Deferred (смена реструктуризацией) ⇄ алгоритмическая смена класса сложности (кэш/мемоизация/линейный attention). А explicit-API многопоточная подача команд ⇄ host→device как бутылочное горло: часто голодает не GPU, а data loader / CPU-подача (та же болезнь, что однопоточный драйвер).
Системы / данные: предикатное проталкивание в БД = culling (не читай строки, которые отфильтруешь); батчинг запросов; конвейеры со стадиями и backpressure; латентность прячут параллелизмом (как occupancy).
Перформанс-инженерия в целом: самый дешёвый способ ускорить — не делать работу (culling), потом делать её пакетно (батчинг), потом менять алгоритм (класс сложности), и только потом микрооптимизировать.
Принцип: оптимизируй в порядке рычага — не делай лишнего, делай пакетно, меняй сложность, кормь параллельную машину когерентно. Микрооптимизация шейдера/ядра — последнее, а не первое.
Почему culling важнее оптимизации самого шейдера?
Что такое overdraw и почему он «тихий» убийца?
Почему draw calls — это стоимость CPU, а не GPU?
Forward или deferred — когда что?
GPU «высоко-латентный, но высокопропускной» — как это одновременно?
- «Real-Time Rendering» (Akenine-Möller и др.) — главы про конвейер, culling, forward/deferred.
- «GPU Gems» / «GPU Pro» — практические техники culling, overdraw, оптимизации.
- RenderDoc + docs — разбор реального кадра (draw calls, overdraw, состояния).
- vkguide.dev / Vulkan Tutorial — как explicit-API строит командные буферы.
- Модуль 8, «Rendering Pipeline» → «Forward vs Deferred», «GPU Architecture Basics», «Modern explicit graphics APIs» (
08-technical-deep-dives.md).