Deltadev-math.ruподписаться
// GPU

Путь одного draw call: почему лоу-поли сцена тормозит

Дорога команды рисования от вызова в коде до пикселя: командный буфер и цена draw call, пропускная способность памяти, варпы и дивергенция ветвлений, SetPass и батчинг. Пять интерактивов в браузере.

10 июля 2026·30 мин чтения·draw callbandwidthSIMTбатчинг
Дейв разгребает очередь команд перед видеокартой, Delta подсвечивает ровный конвейер

Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.

Чтож, эта статья — про дорогу, которую проходит одна команда рисования: от строчки в вашем коде до пикселей на экране. Разберём чем вы платите за картинку:

  • команды — что такое draw call, почему его цена не зависит от треугольников;
  • байты — почему довезти данные до вычислений дороже, чем вычислить;
  • когерентность — как GPU гонит тысячи потоков строем;
  • смены состояния — почему счётчик Batches падает, а кадр не легчает, и что на самом деле меряет SetPass.

Часть 1. Команда

Итак, начнём с главного действующего лица. Draw call — это команда «нарисуй вот этот меш вот этим материалом», которую CPU через графический драйвер отправляет видеокарте. Обратите внимание: процессор при этом не рисует ни одного пикселя — он раздаёт приказы. Рисует действительно GPU, но каждую порцию работы ему кто-то должен выписать, оформить и доставить.

И вот это «оформить» — не бесплатно. Перед каждым вызовом идёт бумажная работа: собрать состояние (какой шейдер, какие текстуры, какие буферы, как смешивать цвета), драйвер всё это проверит, переведёт на язык конкретной видеокарты и положит в командный буфер — очередь, которую GPU разгребает в своём темпе. Пока видеокарта рисует кадр N, процессор уже готовит команды для кадра N+1: стороны работают асинхронно.

Классика по этой теме — доклад Маттиаса Влоки «Batch, Batch, Batch: What Does It Really Mean?» с GDC 2003: уже тогда показывали, что при мелких батчах производительность ограничена процессором, а видеокарта простаивает. Железо с 2003 года изменилось до неузнаваемости, а вывод жив до сих пор.

Арифметика на пальцах (цифры модельные)Осторожно! Математика!

Возьмём модельную цену вызова — 30 микросекунд CPU-времени на подготовку и валидацию (порядок величины, не бенчмарк). Тогда:

  • 200 объектов → 6 мс на подачу команд. В бюджет 60 fps (16.7 мс) влезает.
  • 2000 объектов → 60 мс. Уже 16 fps — без единого нарисованного пикселя.

А теперь тот же миллион треугольников десятком крупных мешей: 10 вызовов → 0.3 мс на CPU, и вся тяжесть уезжает на GPU, которому нарисовать миллион треугольников — рутинная работа.

[ DEMO 01 ]//

Очередь команд: настоящая деревня из заборов

Кстати, самый показательный порядок действий — обратный: сначала сбатчить, а уже потом включить тяжёлую геометрию. В сбатченном режиме треугольники наконец-то начинают стоить заметно — потому что кадр теперь действительно рисует, а не выписывает приказы. Тумблер «сбатчить» здесь — настоящий GPU-инстансинг: все копии одного меша уходят одной командой. Про арсенал таких склеек подробно поговорим в части 4.

Кто кого ждёт: читаем профайлер

В Unity этот диагноз виден прямо по именам сэмплов в профайлере — они устроены как ответ на вопрос «кто кого ждёт» (расшифровка всех маркеров — в мануале, страница Common Profiler markers):

  • Gfx.WaitForRenderThread в главном потоке (при multithreaded rendering) — рендер-поток не успевает проталкивать очередь команд в драйвер;
  • Gfx.WaitForGfxCommandsFromMainThread в рендер-потоке — обратная ситуация: команды до него ещё не донесли;
  • Gfx.WaitForPresentOnGfxThread — противоположный диагноз: процессор всё сдал и ждёт видеокарту, кадр упёрся в GPU.

Ну и оговорка про современность, чтобы не застрять в 2003 году: DX12, Vulkan и Metal заметно удешевили отдельный вызов — часть работы драйвера переехала на этап создания пайплайна и на само приложение (подробнее в части 4, когда доберёмся до PSO). Но экономику накладных расходов это не отменяет: дорога у команды осталась той же, просто пошлины на ней ниже. Тысяча вызовов всё ещё дороже десяти.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

Часть 2. Байты

Команда доехала до видеокарты — теперь ей нужны данные: вершины, текстуры, буферы. Вы взяли ту же геометрию, тот же свет, те же материалы — просто заменили текстуры с 1K на 4K, чтобы покрасивее. Ни одной новой строки в шейдере, арифметики на пиксель ровно столько же. А кадр просел, и на телефоне куда сильнее, чем на десктопе.

Дело в том, что у видеокарты две очень разные способности, и они не равны. Считать — умножать, складывать, гонять шейдерную арифметику — она умеет чудовищно быстро, тысячами потоков разом. А доставать данные из памяти — заметно медленнее: байты лежат в видеопамяти далеко от вычислительных ядер, и их надо провезти по шине через кэши к тому месту, где над ними наконец сделают пару операций. Bandwidth (пропускная способность) — это сколько байт в секунду успевает проехать по этой дороге.

Насколько «далеко» — это не метафора, а энергетика кристалла. По оценкам Билла Дэлли (NVIDIA), одна операция с плавающей точкой стоит порядка 20 пикоджоулей, а доставка операндов из памяти — от ~1000 пДж и выше; соотношение растёт с каждым техпроцессом, от ~160× на 45 нм до ~1000× на 7 нм. Его фраза — «compute is free, data is priceless». Довезти байт до вычисления дороже, чем вычисление сделать.

Два узких горлышка кадра

По сути, у кадра два независимых узких горлышка. Один — сколько GPU может посчитать: упёрлись в него — кадр Ограничен вычислениями: ALU заняты, данные подвозятся вовремя.. Другой — сколько данных он может провезти: упёрлись — кадр Ограничен памятью: ядра простаивают в ожидании байтов с шины.. Диагноз определяет лечение: в memory-bound кадре можно удвоить скорость арифметики и не выиграть ни миллисекунды — ядра и так простаивают, ждут данных.

Теперь арифметика нашего симптома: 4K-текстура — это вчетверо больше текселей по каждой стороне, то есть (4096/1024)² = 16 раз больше байт, которые тащатся по той же шине через те же кэши.

И дело не только в текстурах. К примеру вершины едут по той же шине, и каждый атрибут — цвет вершины, нормали, тангенты — это байты, умноженные на число вершин. В 2018-м я загружал в дополненную реальность для недвижимости детализированные здания на миллионы полигонов — на мобильные телефоны, со светом, запечённым в текстуры. Вершина хранила только позицию и UV: нормали и тангенты были убраны из моделей, потому что весь свет предрассчитан заранее и тогда они ни к чему. Без этого те миллионы полигонов на телефоне не завелись бы.

[ DEMO 02 ]//

Дорога данных: два узких горлышка кадра

Заметьте главное свойство демки: строка «ядра» не реагирует на тумблеры вовсе. Все рычаги этой части — про дорогу, не про вычисления.

Мипмапы — это про шину, а не только про алиасинг

Обычно мипмапы вспоминают как средство от ряби на дальних объектах. Но у них есть и вторая работа, про которую забывают: локальность. Далёкий забор занимает на экране тридцать пикселей; если сэмплить его полноразмерную 4K-текстуру, соседние пиксели экрана бьют по далёким друг от друга текселям — каждое чтение промахивается мимо кэша и гоняет по шине целую кэш-строку ради одного текселя. Мип нужного уровня лежит в памяти компактно, попадает в кэш — и трафик падает в разы. Сглаживание тут приятный побочный эффект той же локальности.

Тот же механизм с другой стороны — дешёвый на вид полноэкранный эффект. Блюр или bloom почти ничего не считают на пиксель, но читают и пишут миллионы пикселей за проход. Считать нечего — а шина забита под завязку. Поэтому пост-эффекты душат разрешением и числом проходов, а не упрощением математики.

Аптечка против memory-boundОсторожно! Математика!
  • Сжатие текстур (BCn на десктопе, ASTC на мобилках): в разы меньше байт через шину, распаковка аппаратная — встроена в текстурный блок.
  • Мипмапы — см. выше; отключёнными они бывают только по недосмотру.
  • Атласы и локальность доступа — то, что читается вместе, должно лежать рядом.
  • Downsample для пост-эффектов — блюр в полразрешения читает вчетверо меньше.
  • Аккуратность с форматами — не 32 бита на канал там, где хватает 8.

Общий принцип один: держать данные компактными и близко к ядрам.

Остался вопрос, который эта часть аккуратно обошла: если чтение из памяти стоит сотни тактов ожидания, почему видеокарта вообще работает, а не стоит колом? Ответ — в том, как GPU гоняет потоки, и он же объяснит, почему ваш if в шейдере ничего не сэкономил. Это часть 3.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

Часть 3. Тысячи медленных потоков

Симптом номер три. Вы нашли в шейдере тяжёлый кусок — дорогой цикл, ветку с кучей математики — и обернули его в if: мол, для половины пикселей эту ветку пропустим и сэкономим. Пропустили. А кадр не разгрузился.

Чтобы понять почему, сначала закроем долг из части 2: выборка из памяти стоит сотни тактов — почему видеокарта не стоит колом? Ответ — толпа. GPU держит в полёте тысячи потоков: один встал на выборке — планировщик тут же пускает считать другой, у которого данные уже приехали. Переключение по сути бесплатное: всё состояние потока живёт в регистровом файле, это не тяжёлые потоки операционной системы.

Задержка — сколько едет один запрос; её толпа прячет: пока байты одного потока в пути, считают другие. Пропускная способность — сколько байтов приезжает за кадр всего; если шина не вывозит, потока «с уже приехавшими данными» просто нет — и ядра простаивают, сколько потоков ни держи.

Но у этой конструкции есть цена. Одно «ядро» GPU — это не маленький процессор, а простая и небыстрая линия: ни предсказания ветвлений, ни внеочередного исполнения, один поток на ней ползёт медленнее, чем на CPU. Сила — в количестве. И линии эти не независимы: они идут строем, группами — у NVIDIA группа называется варп и состоит из 32 потоков, у AMD — wavefront по 64 (на архитектуре GCN; RDNA — нативно по 32). Вся группа исполняет одну и ту же инструкцию разом, каждая линия — над своими данными. Модель называется SIMT: single instruction, multiple threads. Представьте не тысячу гонцов, у каждого свой маршрут, а колонну, марширующую в ногу.

Дивергенция: развилка, на которой строй расходится

Теперь разгадка вашего if. Что делает колонна, когда половина хочет налево, а половина направо? Документация CUDA отвечает:

If threads of a warp diverge via a data-dependent conditional branch, the warp executes each branch path taken, disabling threads that are not on that path.

То есть варп прогоняет обе ветки по очереди, на каждой отключая маской те линии, которых в ней нет. Это дивергенция ветвлений (branch divergence). Группа идёт так же долго, как все пути её потоков вместе взятые: один поток свернул в тяжёлую ветку — и остальные тридцать один стоят под маской и ждут его, будто и не пропускали ничего. Ваш «пропустить дорогой код» экономит, только если его пропускает вся колонна разом.

В коде эта ловушка выглядит невинно. Классика с if — как-то так:

float4 frag (v2f i) : SV_Target
{
    float heavy = _Mask.Sample(sampler_Mask, i.uv).r; // у каждого пикселя своё
    if (heavy > 0.5)
        return HeavyLighting(i);   // ~200 инструкций
    return CheapLighting(i);       // ~20 инструкций
}

Ожидание: половина пикселей пойдёт дешёвым путём, сэкономим почти половину работы. Реальность: стоит в варпе оказаться хотя бы одному пикселю с каждой стороны порога — и варп исполняет обе ветки, все ~220 инструкций, для всех тридцати двух линий. Экономия случается только в тех варпах, где порог не разрезал соседей.

Вторая форма той же ловушки — цикл, у которого число итераций зависит от данных:

// Радиус размытия приходит из маски — у каждого пикселя свой.
int steps = (int)(_BlurMask.Sample(sampler_BlurMask, i.uv).r * 16); // 0..16
float4 acc = 0;
for (int k = -steps; k <= steps; k++)   // у соседей по варпу разный steps
    acc += _Tex.Sample(sampler_Tex, i.uv + float2(k, 0) * _TexelSize);
return acc / (2 * steps + 1);

break и выход по условию линию не освобождают: пиксель со steps = 0 доделал свою итерацию и стоит под маской, пока сосед крутит все шестнадцать. Время варпа — это max по его линиям, а не среднее.

Проверьте на демке. Доля тяжёлых пикселей одна и та же — а цена разная, в зависимости от того, как они разложены по варпам:

[ DEMO 03 ]//

Варп: строй из 32 потоков

Раскладка «тайлами» — это когерентность: соседние потоки заняты одним и тем же, каждый варп целиком уходит в одну ветку, и вторая сторона для него просто не исполняется. Такая ветка почти бесплатна. «Шахматка» — худший случай: в каждом варпе есть и те и другие, и каждый варп платит за обе ветки. Качественные ориентиры такие: разошедшийся if/else стоит примерно как обе ветки подряд — то есть примерно вдвое дороже идеала; теоретический предел — 32 линии по 32 разным путям, до 32× медленнее.

Где это кусается в реальных шейдерах

Übershader со switch по режиму. Пока режим — uniform-параметр материала, всё дёшево: условие одинаково для всего варпа, колонна идёт одной веткой, остальные ветки не исполняются вовсе. Но стоит выбирать режим попиксельно — material-ID из G-буфера, слои терреина, маска — и на стыках материалов соседние пиксели одного варпа уходят в разные ветки: варп прогоняет все режимы по очереди. Код тот же, switch тот же; всё решает, одинаковый ли режим у соседей по варпу. Лечение — классификация: сортировать работу так, чтобы в один варп попадал один материал.

Raymarching и SDF. Цикл марша — дивергенция на каждой итерации: часть лучей упирается в поверхность за пару шагов, часть марширует до предела, и весь варп топчется в цикле, пока не закончит самый глубокий луч. Ранний выход по одному лучу не даёт ничего — время группы держит самый медленный поток. Мы натыкались на это в статье про SDF, теперь у эффекта есть имя. NVIDIA ради таких случаев сделала Shader Execution Reordering — аппаратную перегруппировку вызовов, чтобы собирать похожие пути в один варп.

Uniform-ветвление: if без дивергенции

Дивергенция требует, чтобы условие различалось внутри варпа. Если условие для всех линий одно — значение из материала, констант-буфера, глобальный тогл качества, — расходиться нечему: варп целиком уходит в одну ветку, а невзятая сторона не исполняется вовсе. Такое ветвление называют uniform (ещё говорят scalar branch), и оно почти бесплатно:

cbuffer Material
{
    int _Mode;   // режим материала: один на весь draw call
};

float4 frag (v2f i) : SV_Target
{
    if (_Mode == MODE_TOON)      // одинаково для всех пикселей вызова
        return ToonLighting(i);  // весь варп идёт сюда...
    return PbrLighting(i);       // ...а эта ветка не исполняется вовсе
}

Тот же if, но с попиксельным источником условия — уже дивергенция:

int mode = (int)_ModeTex.Sample(sampler_ModeTex, i.uv).r; // у соседей разный
if (mode == MODE_TOON)   // на стыках режимов варп платит за обе ветки
    ...

Разница не в синтаксисе, а в источнике данных: _Mode одинаков для всего вызова, _ModeTex меняется от пикселя к пикселю. Отсюда практическое правило: прежде чем бояться if, спросите себя, откуда приходит условие — из констант или из данных пикселя.

У uniform-ветвления есть и статический родственник — варианты шейдера (shader_feature / multi_compile в Unity): там ветка выбрасывается ещё на компиляции. Быстрее нуля не бывает, но цена своя: каждый keyword плодит варианты, а разные варианты — это разный стейт со всеми последствиями из части 4, плюс фризы на первой компиляции пайплайна. Динамический uniform-if держит один вариант и для тоглов качества обычно достаточен. Оговорка: скомпилированы при этом всё равно обе ветки, и регистры резервируются под худшую из них — совсем жирные развилки-übershader'ы этим занижают occupancy, поэтому их всё же дробят на варианты.

И мелким шрифтом для дотошных: начиная с архитектуры Volta у NVIDIA каждый поток имеет собственный счётчик команд (Independent Thread Scheduling), так что жёсткий «такт-в-такт» lock-step программной моделью больше не гарантируется. На экономику это не влияет: две разные инструкции варп по-прежнему не исполняет одновременно, и дивергенция стоит темпа исполнения ровно так же.

Итого у кадра появилась третья валюта: когерентность — насколько дружно ваши потоки делают одно и то же. Осталась четвёртая, самая коварная: она прячется не в вызовах и не в шейдере, а между вызовами.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

Часть 4. Стейт

Последний симптом — самый обидный, потому что настигает уже в процессе оптимизации. Вы гонитесь за числом draw calls: объединяете меши, чистите объекты — и счётчик Batches в статистике ползёт вниз. А кадр не легчает, особенно на мобилке. Присмотритесь: draw calls упали, а вторая цифра рядом, SetPass calls, — нет.

Дело в том, что это две разные вещи. Batch (draw call) — просто команда «нарисуй» на текущем состоянии конвейера. А SetPass случается, только когда состояние надо сменить: другой шейдер, другая текстура, другой блендинг, другой Z-тест. Сто вызовов на одном материале — это один SetPass и куча дешёвых команд следом. А смена материала между вызовами — новый SetPass: перенастройка конвейера, валидация в драйвере, и в тяжёлом случае видеокарта дожидается завершения всего, что уже успела запустить, прежде чем переключиться, — это называют флашем конвейера (pipeline flush).

Собственно, тезис части: цену кадру набивает не сколько вы рисуете, а сколько раз между вызовами меняете стейт. Поэтому один материал со статик-батчингом летит — сотни мешей идут одним SetPass'ом; а десяток материалов вперемешку кладёт кадр, хотя вы не добавили ни полигона. Вы добавили переключений.

[ DEMO 04 ]//

Batches против SetPass: ворота переналадки

Обратите внимание на разделение труда: сортировка не убирает ни одного вызова — только выстраивает одинаковый стейт подряд, и «ворота» переналадки схлопываются. Батчинг идёт дальше и склеивает сами вызовы. Это разные лекарства от разных статей расходов, и работают они лучше всего вместе.

Где цена на самом деле

Тут стоит быть аккуратным, потому что «флаш на каждой смене стейта» — перебор, и опытный графический программист вас за это по голове не погладит. Расходы на смену состояния — это две разные статьи:

  • CPU-статья (главная и повседневная). Каждая смена стейта — валидация и подготовка в драйвере. Конвейер GPU при этом чаще всего не встаёт — дорого именно процессору. Отсюда и лечение из части 1: меньше вызовов, меньше настроек.
  • GPU-статья (реже, но жёстче). Настоящие флаши сосредоточены вокруг смены render target и части переключений шейдеров. На десктопе это неприятно; на мобильном тайловом GPU — больно по-настоящему: смена render target посреди кадра заставляет выгрузить (resolve) весь текущий тайловый буфер во внешнюю память и потом загрузить обратно. Помните экономию шины из части 2? Вот её ровно в этом месте и теряют.
Почему батч молча не собрался: чеклистОсторожно! Математика!

Отдельная засада — когда лекарство прописано, а эффекта нет: статик-батч просто не сложился. Типичные ломатели (проверяются через Frame Debugger):

  • renderer.material в коде: молча инстанцирует персональную копию материала на объект — и в рантайме у вас уже не 3 материала, а 300. Лечение — renderer.sharedMaterial, а различия на объект — через MaterialPropertyBlock или instanced-свойства.
  • Разные keyword'ы шейдера = разные варианты = разный стейт. Материал «тот же», а ветка компиляции другая (карта нормалей есть/нет — уже два варианта).
  • Объекты на разных тайлах лайтмапы вместе не батчатся.
  • Объект не помечен static; тег DisableBatching у шейдера; отрицательный масштаб.
  • И полу-ломатель: комбинированный буфер вмещает до 64 000 вершин — при переполнении Unity молча заводит следующий батч, и вызовов оказывается больше, чем вы ждали.

Кто что режет: карта инструментов

Инструментов несколько, и путаются в них постоянно, потому что делают они разное:

ИнструментDraw callsСмены стейтаЦена
Static batchingрежетрежетпамять: геометрия дублируется в мировых координатах; лимит 64k вершин на батч
GPU instancingрежет радикально (N копий = 1 вызов)косвенно: материал и так одинодин меш + один материал; различия — через instanced-свойства
SRP Batcher (URP/HDRP)не режетудешевляет каждуютребует совместимых шейдеров
Dynamic batchingрежет мелочькосвеннопер-вершинная работа на CPU каждый кадр; в современном Unity — deprecated

Про инстансинг важно снять страх: MaterialPropertyBlock — это ровно задуманный способ варьировать копии (цвет, фаза анимации), инстансинг он не ломает. Ловушка обратного знака: MPB отключает быстрый путь SRP Batcher — у разных батчеров разные правила, и это нормально. Прикладной кейс на десятки тысяч объектов малым числом вызовов мы разбирали в статье про толпу на GPU — там инстансинг несёт на себе вообще всю сцену.

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

Чтож, четыре валюты собраны: команды, байты, когерентность, смены состояния. Осталось пройти дорогу целиком и научиться по симптому определять, на какой из четырёх касс кадр переплачивает.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

Часть 5. Весь путь целиком

Давайте теперь проведём одну команду по всей дороге, от начала до конца.

  1. Ваш код решает: «нарисовать забор №481 материалом „дерево“» — и зовёт движок.
  2. Драйвер собирает стейт, валидирует его (если материал сменился — это тот самый SetPass из части 4) и кладёт готовую команду в командный буфер.
  3. GPU в своём темпе достаёт команду из буфера. Вершинная стадия читает геометрию из памяти — байты едут по шине из части 2 — и прогоняет вершинный шейдер.
  4. Растеризация нарезает треугольники на пиксели и раздаёт их варпам.
  5. Фрагментная стадия маршем из части 3 считает цвет: текстуры снова едут по шине, ветки либо идут строем, либо расходятся.
  6. Запись складывает результат в кадровый буфер — на тайловой мобилке через он-чип память, с расплатой за каждую смену render target.

Кадр готов, когда через конвейер прошла последняя команда. И держит его та стадия, которая работает медленнее всех, — конвейер не бывает быстрее своего узкого места. Отсюда главный практический навык этой статьи: оптимизировать стадию, которая не ботлнек, бесполезно. Совсем. Вы можете вдвое ускорить шейдеры в кадре, который упёрся в подачу команд, — и не выиграть ни миллисекунды.

Финальный интерактив собирает все четыре части. Стартовая конфигурация — испорчено всё: вызов на объект, сырые 4K-текстуры, дивергентные ветки, очередь вперемешку. Включайте лекарства по одному и следите за ржавой подсветкой — узкое место не исчезает, оно переезжает:

[ DEMO 05 ]//

Весь путь: от вызова до пикселя

Порядок включения, кстати, повторяет реальный процесс оптимизации: чините самое узкое, меряете заново, чините новое узкое. Никакой другой порядок не работает — потому и нельзя оптимизировать «по чеклисту сверху вниз», не глядя в профайлер.

Диагностика: на какой кассе переплачиваем

Соберём симптомы всех частей в одну таблицу-шпаргалку:

СимптомВалютаКуда смотреть
GPU простаивает, кадр съел CPU, батчи в тысячахкомандыGfx.WaitForRenderThread, счётчик Batches
Заменили текстуры/подняли разрешение — просело, арифметика не меняласьбайтыразмер и сжатие текстур, мипы, пост-проходы
Обернули тяжёлый код в if — не помоглокогерентностьдивергенция: как условие раскладывается по соседним пикселям
Batches падает, кадр стоит на местесмены состоянияSetPass calls, Frame Debugger, число материалов
На десктопе терпимо, на мобилке слайд-шоубайты и/или render targetширина шины, тайловый resolve/restore

Это, разумеется, упрощённая карта — в настоящем профайлере деталей больше, и они любят накладываться друг на друга.

Выводы

Итак, дорога пройдена. Что стоит унести с собой:

  1. Draw call — это команда, а не рисование. Её цена — накладные расходы CPU и драйвера, и от числа треугольников она почти не зависит. Лоу-поли не спасает от тысячи вызовов (часть 1).
  2. Довезти байт дороже, чем посчитать. У кадра два потолка — вычисления и подвоз; в memory-bound кадре оптимизация арифметики не даёт ничего. Мипмапы и сжатие — это про шину, а не только про красоту (часть 2).
  3. GPU идёт строем. Тысячи потоков прячут задержки памяти, но платят когерентностью: if экономит, только когда в него сворачивает весь варп (часть 3).
  4. Дорого не рисовать, а переключаться. Batches и SetPass — разные цифры; кадр чаще держит вторая. Меньше материалов, сортировка по стейту, батчинг и инстансинг — всё это способы держать стейт на месте (часть 4).
  5. Конвейер не быстрее узкого места. Сначала диагноз, потом лечение; после каждого лечения — новый диагноз, потому что узкое место переезжает (часть 5).

Что осталось за рамками

Конечно же, дорога в статье спрямлена. Мы не тронули compute-пайплайны и GPU-driven rendering, где команды рождаются на самой видеокарте; bindless-ресурсы; глубины тайловых архитектур; вендорские профилировщики, в которых виден каждый такт. Да и модель «кадр = max(CPU, GPU)» — учебная: в настоящем конвейере стадии перекрываются хитрее, и профайлер вам об этом обязательно расскажет.

Главный же тезис прежний: «один объект — один draw call» — отличное упрощение, пока объектов десятки. Профессионализм не в том, чтобы отказаться от упрощений, а в том, чтобы знать, где именно они перестают работать — и какой из четырёх валют вы в этот момент начали переплачивать.

Надеюсь, статья была полезна. Такие разборы — с математикой, интерактивами и профайлером наперевес — я регулярно публикую в телеграм-канале, заходите:

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться