← Модуль 8/Рендер-конвейер и culling
EN
Модуль 8 · Технический deep-dive

Рендер-конвейер и culling

GPU — это параллельный брандспойт, который превращает 3D-сцену в пиксели через фиксированные стадии. 60 fps или тормоза решает не математика шейдеров (это проекция и PBR), а логистика: не слать работу, которую выбросят (culling), не красить пиксели дважды (overdraw), не гнать по одной детали на грузовик (draw calls).
~18 мин🛠 инженерия + 🔬 GPU
Суть за 30 секунд
Рендер-конвейер: vertex-шейдер (трансформирует вершины) → растеризатор (треугольники → фрагменты) → fragment-шейдер (красит пиксели) → merger (depth-тест, блендинг). GPU — массивно-параллельный брандспойт: варпы по 32 потока в lockstep, высокая латентность памяти прячется occupancy (много варпов в полёте), а дивергенция и разбросанная память её убивают. Производительность — это не слать лишнюю работу: culling (frustum/backface/occlusion — не обрабатывать невидимое), overdraw (не шейдить пиксели, которые перезапишутся — depth-prepass, сортировка front-to-back), draw calls (каждый вызов = CPU-оверхед; инстансинг/батчинг). Свет масштабируют forward O(N·L) vs deferred O(N+L). Explicit-API (Vulkan/DX12) убрали драйвер-бутылочное горло, дав строить командные буферы на N потоках. Сквозная мысль: подавай только видимое, держи параллельную машину сытой, минимизируй оверхед на элемент.

Механизм: конвейер и его логистика

Стадии конвейера

GPU прогоняет геометрию через конвейер (упрощённо):

вершины vertexтрансформ растер→ фрагменты fragmentцвет пикселя mergerdepth/blend программируемо фикс. программируемо
Программируемые стадии (vertex/fragment/compute) чередуются с фиксированными (растеризация, depth-тест). Математика vertex — проекция, математика fragment — PBR; здесь — как весь конвейер кормить.

GPU — брандспойт с высокой латентностью

GPU не быстрый на одном потоке — он массивно-параллельный: тысячи потоков, сгруппированных в варпы по 32, исполняющихся в lockstep. Доступ к памяти долгий (сотни циклов), но латентность прячется occupancy — пока один варп ждёт память, GPU переключается на другой. Два убийцы пропускной способности: дивергенция (потоки варпа взяли разные ветки → варп исполняет обе) и разбросанный доступ (соседние потоки читают несоседние адреса → много транзакций вместо коалесценции). Вывод: конвейер хочет когерентную, пакетную, предсказуемую работу — и вся оптимизация про это.

Culling — не обрабатывать невидимое

Самый большой рычаг — не отправлять в конвейер то, что не увидят, и как можно раньше:

Каждый уровень режет работу перед дорогими стадиями. В открытом мире это разница между «миллион треугольников» и «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:

tCPU= ndraw·cdraw

10 000 объектов по одному вызову × ~0.05 мс = 500 мс — кадр невозможен (CPU-bound). Лечат инстансингом (один вызов рисует один меш N раз — трава, толпа) и батчингом (слить меши с одним материалом): 100 вызовов × 0.05 = 5 мс — влезает. А explicit-API (Vulkan/DX12) убрали драйвер как единственное узкое место: N потоков строят N командных буферов и сабмитят в конце кадра (в GL/D3D11 драйвер был однопоточным горлом).

Масштабирование света: forward vs deferred

Наивный forward освещает каждый объект каждым источником → O(N·L) (100 источников × 1000 объектов = 100k проходов). Deferred сначала пишет геометрию (нормаль/альбедо/глубину) в G-буфер, потом считает свет по пикселям из него → O(N+L). Цена deferred — память (G-буфер = несколько полноэкранных картинок) и плохая прозрачность. Современный компромисс — Forward+ (тайловый): экран на плитки 16×16, для каждой считаем список влияющих источников — прозрачность forward + эффективность deferred.

🕹 Что включить — и что заметить

Логистика рендера видна через debug-виды движков: overdraw, wireframe, счётчик draw calls.

Overdraw-визуализатор UE / Unity / RenderDoc

Почти в любом движке есть режим «overdraw»: чем краснее, тем больше раз пиксель перекрашен. Частицы, дым, листва, UI-слои светятся красным — вот где утекает fragment-бюджет.

🎮 Сделай: в UE/Unity включи overdraw-вью и посмотри на сцену с дымом/частицами — увидишь красные зоны многократной перерисовки. Сравни с непрозрачной геометрией (почти без overdraw благодаря early-z). Это и есть «не крась пиксель дважды» глазами.

Frustum/occlusion culling открытый мир · pop-in

В любой открытой игре повернись резко на 180° — заметишь, как объекты появляются, входя в пирамиду видимости (frustum culling), и как то, что за стеной, не рисуется (occlusion). Pop-in LOD — обратная сторона той же экономии.

🎮 Сделай: в открытом мире быстро крути камеру у края видимости — поймай момент «дорисовки». Зайди за большую стену/здание и мысленно спроси: рисует ли движок то, что за ней? (Не должен — occlusion culling.) Это работа, которую не делают.

Счётчик draw calls профайлер движка

Статистика рендера (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 меняет класс сложности света с O(N·L) на O(N+L), но платит памятью G-буфера и ломает прозрачность (только один слой глубины). Forward+ бьёт экран на тайлы, compute-проходом считает для каждого тайла список влияющих источников (light culling), затем forward-шейдит объект только релевантными источниками. Получаем и прозрачность (forward), и почти-deferred-эффективность. Это пример смены класса сложности реструктуризацией, а не микрооптимизацией.

Аналогия
GPU — это гигантский сборочный конвейер с тысячами параллельных рабочих, а производительность рендера — это логистика. Не вези на завод детали, которые не пойдут в изделие (culling). Не крась стену, которую сейчас снесёшь (overdraw). Не отправляй по одной детали на отдельном грузовике, когда можно палетой (draw calls / батчинг). Сама обработка детали — её обточка (проекция) и покраска (PBR) — это другие уроки; здесь речь о том, как кормить конвейер, чтобы он не простаивал и не делал брак в мусор.
Почему это важно
Разница между 60 fps и слайд-шоу обычно не в математике шейдеров, а в том, шлёшь ли ты GPU только нужную работу. Culling, борьба с overdraw и батчинг draw calls — три главных рычага, а понимание GPU как латентно-скрывающего параллельного брандспойта (варпы/occupancy) объясняет, почему побеждает когерентная, пакетная, видимая-только работа. Это чистая systems/performance-инженерия — и она обобщается на любую параллельную машину, которую ты кормишь.
🔁 За пределами игр — куда это переносится
Урок — это кормление массивно-параллельной машины и вырезание лишней работы: culling, батчинг, occupancy, смена класса сложности.

ML / AI (твой домен): это дословно утилизация GPU в трейнинге/инференсе. Варпы/occupancy ⇄ загрузка тензор-ядер и batch size (мал батч → GPU голодает, как низкий occupancy); коалесценция памяти ⇄ contiguous/coalesced доступ к тензорам (и почему раскладка решает); дивергенция ⇄ почему dense-операции бьют sparse на GPU. «Не слать работу, которую выбросят» = pruning / early-exit / MoE-роутинг / sparsity; «батчить, чтобы амортизировать оверхед на вызов» = батчинг инференс-запросов и амортизация kernel-launch. Deferred (смена O(NL)→O(N+L) реструктуризацией) ⇄ алгоритмическая смена класса сложности (кэш/мемоизация/линейный attention). А explicit-API многопоточная подача команд ⇄ host→device как бутылочное горло: часто голодает не GPU, а data loader / CPU-подача (та же болезнь, что однопоточный драйвер).

Системы / данные: предикатное проталкивание в БД = culling (не читай строки, которые отфильтруешь); батчинг запросов; конвейеры со стадиями и backpressure; латентность прячут параллелизмом (как occupancy).

Перформанс-инженерия в целом: самый дешёвый способ ускорить — не делать работу (culling), потом делать её пакетно (батчинг), потом менять алгоритм (класс сложности), и только потом микрооптимизировать.

Принцип: оптимизируй в порядке рычага — не делай лишнего, делай пакетно, меняй сложность, кормь параллельную машину когерентно. Микрооптимизация шейдера/ядра — последнее, а не первое.

🔧 Запусти и поковыряй — на домашнем компе
Во что играть — выше (🕹). Здесь — залезть в кадр профайлером.
🔧 Поковырять (RenderDoc / движок) ~40 мин
Захвати кадр в RenderDoc (или профайлер UE/Unity) и посмотри: сколько draw calls, где overdraw, что закуллено. Включи/выключи static-batching или инстансинг и замерь дельту draw calls + CPU-время. Найди самый дорогой проход и спроси: это culling, overdraw или draw calls?
🧪 Потестить (порядок рычагов) ~15 мин
Дана тормозящая сцена. Расставь рычаги по силе для этой проблемы: culling? снижение overdraw (сортировка/prepass)? батчинг draw calls? forward→deferred? И только потом — микрооптимизация шейдера. Обоснуй порядок бюджетом (CPU-bound vs GPU-bound vs fill-bound).
Чеклист: захватил кадр в RenderDoc; нашёл draw calls/overdraw/culling; замерил эффект батчинга; определил bound (CPU/GPU/fill); расставил рычаги оптимизации по силе.
Связи
основа
3D-конвейер — математика vertex-стадии (проекция, z-буфер); здесь — как весь конвейер вокруг неё кормить эффективно.
основа
PBR — математика fragment-стадии (материалы); overdraw = впустую потраченный PBR-шейдинг.
смежное
ECS и data-oriented — плотная раскладка данных = когерентный доступ, который любит и GPU (коалесценция).
дальше
Mesh shaders и Nanite — GPU сам решает, что рисовать: culling и LOD переезжают на GPU, ломая per-vertex-модель.
Вопросы пытливого ума
Почему culling важнее оптимизации самого шейдера?
Потому что не сделать работу дешевле, чем сделать её быстро. Оптимизация шейдера ускоряет обработку каждого элемента на проценты; culling убирает целые элементы из конвейера — часто 80–95% сцены невидимо (за камерой, за стенами, спиной треугольника). Сначала вырезаешь невидимое (порядок величины), потом борешься с overdraw, потом батчишь, и лишь затем точишь шейдер (проценты). Классическая ошибка джуна — микрооптимизировать пиксельный шейдер объекта, который вообще не должен был рисоваться. Самый быстрый код — тот, что не выполняется.
Что такое overdraw и почему он «тихий» убийца?
Overdraw — это когда один пиксель экрана закрашивается несколько раз за кадр (нарисовали дальний объект, потом ближний поверх). Работа fragment-шейдера по перезаписанному пикселю полностью выброшена. «Тихий» — потому что картинка выглядит правильно (виден только верхний слой), и в профайлере это не отдельная строка, а размазанная трата fill-rate. Особенно жрут прозрачные слои (дым, частицы, UI): их нельзя early-z-отбросить, и они складываются друг на друга. Лечат сортировкой front-to-back (early-z режет закрытое) и depth-prepass. Невидимая на глаз, но убивает fill-bound сцены.
Почему draw calls — это стоимость CPU, а не GPU?
Потому что draw call — это команда от CPU к GPU: сменить состояние (шейдер, текстуры, буферы), провалидировать в драйвере, положить в командный буфер. Сама отрисовка на GPU дёшева; дорого — подготовить и отправить тысячи команд с CPU за 16 мс. Поэтому 10 000 уникальных объектов делают сцену CPU-bound при простаивающем GPU. Инстансинг/батчинг сокращают число команд (одна команда рисует много), а explicit-API распараллеливают их подготовку. Диагностика: если FPS не растёт от упрощения шейдеров, но растёт от батчинга — ты упёрся в draw calls, то есть в CPU.
Forward или deferred — когда что?
Зависит от числа источников света и роли прозрачности. Deferred выигрывает при много источников (сложность O(N+L) вместо O(N·L)), но ест память под G-буфер и плохо дружит с прозрачностью (только один слой глубины) и с MSAA. Forward проще, дружит с прозрачностью и MSAA, но не тянет много динамических источников. Forward+ (тайловый) — практический дефолт: light-culling по плиткам даёт много источников и прозрачность. Мобилки часто forward/forward+ (память и bandwidth дороги), консоли/PC с толпой источников — deferred/Forward+. Выбор — про профиль сцены, не про «что круче».
GPU «высоко-латентный, но высокопропускной» — как это одновременно?
Латентность и пропускная способность — разные вещи. Один доступ к памяти GPU долгий (сотни циклов — высокая латентность), но GPU не ждёт его простаивая: он переключается на другой варп, у которого данные готовы, и так держит вычислители занятыми (высокая пропускная способность). Это как повар, который, поставив кастрюлю кипятиться (долгая операция), не стоит над ней, а режет овощи для другого блюда. Условие — чтобы «других блюд» (варпов/occupancy) хватало: если их мало или регистров/shared-памяти на поток слишком много, латентность обнажается и GPU простаивает. Отсюда цель — держать много независимой работы в полёте.
Что почитать