Всем привет! Меня зовут Григорий Дядиченко, я уже что-то разрабатываю на Unity десять лет. В прошлой статье была база — графический конвейер, но в разы полезнее понимать, а как графика вообще работает. Понимание работы GPU позволяет понимать суть оптимизаций и почему они именно так работают, а не охотится на ведьм. Если интересуетесь темой — добро пожаловать под кат!

Немного истории

Современные графические процессоры (GPU) прошли сложный путь. От 2D-ускорителей 1980-х до современных параллельных процессоров. Эволюция прошла этапы фиксированного конвейера (3dfx Voodoo), программируемых шейдеров (GeForce 3), унифицированной архитектуры (G80) и рейтрейсинга (RTX). Современные GPU — это мощные вычислительные системы с тысячами ядер, поддерживающие сложный рендеринг, компьют шейдеры и ИИ-технологии и многое другое.

NVIDIA начала с архитектуры NV1 (1995) с фиксированным конвейером. В 1999 году GeForce 256 (NV10) представила аппаратный T&L. В 2001-м GeForce 3 (NV20) добавила программируемые шейдеры. Революционная Tesla (G80, 2006) ввела унифицированные шейдерные блоки и CUDA. Fermi (GF100, 2010) улучшила вычисления, а Kepler (GK110, 2012) повысила энергоэффективность. Maxwell (GM200, 2014) оптимизировала потребление, Pascal (GP100, 2016) добавила HBM2 и 16 нм. Volta (GV100, 2017) принесла Tensor Cores, Turing (TU102, 2018) — RT-ядра и DLSS. Ampere (GA102, 2020) увеличила RT-производительность, а Ada Lovelace (AD102, 2022) представила DLSS 3 и новые SM-блоки.

Фиксированный конвейер (Fixed-Function Pipeline) — это ранний подход к графическому рендерингу, при котором GPU выполнял обработку геометрии и пикселей по жестко заданным, неизменяемым этапам. В отличие от современных программируемых шейдеров, здесь разработчики могли лишь настраивать параметры (например, освещение или текстурирование) через API (Direct3D, OpenGL), но не изменять сам алгоритм обработки.

Мобильные процессоры пошли подобный путь от простых 2D-ускорителей (PowerVR MBX, 2003) до мощных процессоров с поддержкой рейтрейсинга и ИИ. Ключевые этапы: фиксированный конвейер (2000-е), переход к шейдерам (PowerVR SGX, Adreno 200), унифицированные архитектуры (Mali-T600, 2012) и современные решения (Adreno 750, Apple GPU, Xclipse) с ИИ-ускорением и аппаратным рейтрейсингом. Сегодня мобильная графика приближается к PC-уровню.

Фиксированный конвейер нас уже не интересует. Это именно что история. А вот то, как устроены параллельные вычисления на современных GPU давайте разберём.

Процесс работы GPU

Сперва нам нужно передать данные на видеокарту. Данный процесс состоит из нескольких шагов. Но тут нам пригодится понятие шины GPU (PCIe).

Шина это электронный интерфейс для передачи данных между компонентами системы, объединяющий устройства общими линиями связи, протоколами и стандартами синхронизации.

Они бывают последовательные, параллельные. Они связывают между собой GPU, CPU, периферию, память. В мобильных устройствах используется тоже самое, только урезанное и более энергоэффективное.

Инициализация и передача данных

Первый шаг для графики на уровне железа это инициализация и передача данных. Сначала CPU вызывает API ((Direct3D/Vulkan/OpenGL/Metal). Дальше драйвер GPU конвертирует API вызовы в машинные команды и формирует Command Buffer. После происходит копирование данных из системной памяти в VRAM с помощью шины PCIe.

Из самого интересного для понимания передаются там следующие данные:

  1. Графические буферы

    1. Вершинный буфер - по своей сути вершины со всеми вертексными параметрами

    2. Индексный буфер - буффер индексов для формирования треугольников

    3. Uniform/Constants - параметры шейдеров

  2. Текстуры

    1. 2D/3D текстуры

    2. Mipmap уровни - предварительно рассчитанные уменьшенные копии текстур

    3. Кубические карты (Cubemaps)

  3. Вычислительные данные

    1. Structured Buffers - произвольные структуры для шейдеров

    2. Raw Buffers - "Сырые" данные (например, для ray tracing-акселерации):

Есть ещё ряд данных вроде командных данных, метаданных и другого, но в контексте обсуждения работы в движке Unity, а не разработки своего движка, не особо интересные. Что из этого интересует нас и при чём тут шина?

А как часто копируются данные? Возьмем для примера вертексный буффер. Есть 4 основных типа с этой точки зрения:

  1. Статическая геометрия - копируется на загрузке (не передается каждый кадр рендера в VRAM) (вот зачем в том числе нужна эта галочка Static в Unity)

  2. Динамическая геометрия - каждый кадр/при изменении

  3. Скининговая геометрия - каждый кадр

  4. Процедурная геометрия - по требованию

И тут возникает интересный момент с точки зрения понимания оптимизации. Средняя пропускная способность PCIe в мобильных устройствах 4ГБ/c. Вертексный буффер - это все параметры которые мы используем в вертексах. В стандарте это позиция, uv, normal. Но может быть что-то дополнительно вроде цвета. Но стандартно оптимизированный вертекс для мобильной игры весит 14 байт. И дальше не сложно посчитать что если мы хотим, чтобы игра работала в 60 кадров в секунду. И нам надо 4 294 967 296 байт поделить на 14 байт и на 60 кадров, что бы понять сколько теоретически PCIe может скушать. Получится 5 113 056 вершин. Если бы PCIe занималась только этим. Таким образом начинаешь понимать зачем везде где надо расставлять пометку, что это статик геометрия и убираешь ненужные вертексные параметры, вроде битангенса если он не используется.

Текстурные данные скажем грузятся редко. Каждый кадр шину грузит только RenderTexture, поэтому они важны по производительности только с точки зрения хранения в системной памяти и в VRAM. Чтож, мы всё проинициализировали. Пора считать.

Графический конвейер

Про сам конвейер я написал всё что хотел в прошлой статье. А тут хочется поговорить про нюансы параллельных вычислений. Но по сути перед отправкой результата расчётов происходит именно этот шаг, так что назовём его так.

Вообще я рекомендую посмотреть эту серию лекций по параллельным вычислениям от Стэнфорда. Там много полезной информации. Мы пройдемся по основам.

Параллельные вычисления на GPU — это массовая обработка данных за счёт тысяч ядер, работающих одновременно.

Ключевые принципы

  1. SIMD-архитектура — одна инструкция выполняется для множества данных (например, один шейдер обрабатывает все пиксели сразу

  2. Warp/Wavefront — группы потоков выполняют одну команду синхронно

SIMD (Single Instruction, Multiple Data) — архитектура параллельных вычислений, где одна инструкция применяется к множеству данных одновременно. Это фундаментальный принцип работы современных GPU, отличающий их от CPU где доминирует SISD (Single Instruction, Single Data) или MIMD( Multiple Instruction, Multiple Data). По сути CPU выполняет операции по тактам последовательно. А гпу выполняет кучу операций параллельно. Но это и без умных слов все знают.

И вот тут у нас важные определения варп (Warp) и волновой фронт (Wavefront). Потому что когда я изучал эту тему было куча непонятных ответов на форумах, где эти понятия подразумеваются как само собой разумеющееся. По сути это синонимы. Warp у Nvidia, Wavefront у AMD, а что это такое?

Warp (NVIDIA) и Wavefront (AMD) — это группы потоков (threads), которые выполняются одновременно на одном вычислительном блоке GPU. Они являются фундаментальной единицей планирования в архитектурах NVIDIA CUDA и AMD GCN/RDNA.

И всё это нужно для понимания злополучного if-else в шейдерах. И ключевой проблеме Branch Divergence.

Branch Divergence

У нас есть один Warp/Wavefront внутри которого у нас есть 32/64 потока исполнения. Если у нас эти потоки начинают выполнять разные ветки if-else, то возникает Branch Divergence. Это называется сериализация и все возможные ветки начинают выполняться последовательно, а не параллельно.

Если объяснять на пальцах в контексте видеокарт Nvidia, то как это работает. У нас 32 потока обрабатывают 32 пикселя. В 8 пикселях мы пошли в одну ветку if, а в 24 в другую. Сначала у нас выполнится для первых 8 пикселей (а остальные потоки будут ждать), а потом для 24 оставшихся. это может сильно снижать производительность.

И причина этого в архитектуре GPU. GPU оптимизирован для SIMT (Single Instruction, Multiple Threads) — одна инструкция применяется ко всем потокам warp/wavefront одновременно. Невозможно выполнить разные инструкции для разных потоков в рамках одного warp.

Примеры возникновения

Явные условные операторы

// Плохо: вызывает divergence
if (threadId.x % 2 == 0) {
    a = b + c;
} else {
    a = b - c;
}

Циклы с условиями

// Потоки могут выходить из цикла в разное время
while (x < 100) {
    x += step; // Разные step → divergence
}

Неоднородные данные

if (uv.x < 0 || uv.x > 1) discard; // Крайние пиксели → divergence

Способы устранения

Замена ветвлений на предикаты

// Вместо if-else
float condition = step(0.5, uv.x); // 1.0 если uv.x >= 0.5, иначе 0.0
color = mix(textureA, textureB, condition);

Перегруппировка данных

// Группируем потоки с одинаковыми условиями
if (threadId.x < 16) { ... }
else { ... }

Пример оптимизации

// Было (плохо):
if (normal.y > 0.5) {
    color = textureA.Sample(sampler, uv);
} else {
    color = textureB.Sample(sampler, uv);
}

// Стало (хорошо):
float lerpFactor = saturate((normal.y - 0.5) * 1000.0); // Резкий переход
color = lerp(textureB, textureA, lerpFactor);

Но существуют и обратные примеры. Если благодаря пляски с бубном математика для обхода if становится слишком сложной, то дешевле по производительности будет сделать просто if.

Когда Branch Divergence выгоднее сложной математики

Рассмотрим задачу условного ветвления в шейдере, где:

  • Условие выполняется очень редко (например, в 1% потоков warp).

  • Альтернатива — дорогая математическая операция (например, sinlog, деление), которую пришлось бы вычислять во всех потоках.

Вариант с Branch Divergence

// Условие: только 1 поток из 32 (3.125%) требует сложных вычислений
if (shouldComputeExpensiveValue) { // Ложь в 31 потоке из 32
    result = exp(value) / (1.0 + log(value)); // Дорогая операция
} else {
    result = value * 2.0; // Дешевая операция
}
  • GPU выполняет обе ветки последовательно, но:

    • Ветка else (31 поток) занимает 1 такт.

    • Ветка if (1 поток) занимает 10 тактов (условно для exp/log).

  • Общее время: ~11 тактов.

Вариант без Branch Divergence (всегда вычисляем)

// Заменяем ветвление на "слепое" вычисление для всех потоков
result = value * 2.0 + 
         shouldComputeExpensiveValue * (exp(value) / (1.0 + log(value)) - value * 2.0);

Что происходит:

  • Все 32 потока обязаны выполнить:

    • exp(value) (10 тактов),

    • log(value) (10 тактов),

    • Деление (5 тактов),

    • Лерпинг (3 такта).

  • Общее время: ~28 тактов.

То есть Branch Divergence может быть оптимальным, если дорогие вычисления требуются редко. Или когда на вычисление правильного результата без условных операторов требуется слишком много математики.

О Самплировании Текстур

И последнее о чём хочется поговорить это Texture Sampling. Texture Sampling (сэмплирование текстуры) — это процесс выборки и интерпретации данных из текстуры с учетом координат, фильтрации и других параметров. Из важных моментов в контексте движков и разработки нужно понимать, что это дорого. Операция самплирования может занимать 100 тактов. Поэтому лучше всегда использовать текстурные атласы.

Процесс сэмплирования: пошагово

Определение положения

  1. Шейдер вычисляет UV-координаты

  2. Координаты преобразуются в текстурное пространство:

Выборка текселей

В зависимости от типа фильтрации:

Тип фильтрации

Количество текселей

Формула

Nearest (ближайший)

1

texel = fetch(round(texX), round(texY))

Bilinear (билинейная)

4

Интерполяция между 4 соседними текселями

Trilinear (трилинейная)

8

Bilinear + интерполяция между mip-уровнями

Anisotropic (анизотропная)

8-32

Множество выборок вдоль направления перспективы

Mipmapping

  • Иерархия предварительно уменьшенных копий текстуры

  • Автоматический выбор уровня на основе ddx/ddy

Фильтрация

Билинейная формула:

color = lerp(
    lerp(texel00, texel10, fracX),
    lerp(texel01, texel11, fracX),
    fracY
)

Анизотропная:

  • До 16 выборок вдоль главной оси перспективы

  • Учитывает соотношение сторон проекции

Существует много интересных техник оптимизаций семплинга, которые мы разберем в статье по оптимизациям, если серия статей будет интересна.

Заключение

Прошлись немного по GPU и основным моментам его работы. По крайней мере той части, которая интересна в контексте понимания работы игровых движков.

Если вам интересны новости Unity разработки и в целом тема Unity - подписывайтесь на мой блог в телеграм. Я публикую там интересные новости и обзоры на них, свои мысли про бизнес, про фриланс и про разработку. Плюс лучший показатель того, что надо тема интересна - надо писать. Спасибо за внимание!

Комментарии (3)


  1. SIISII
    02.08.2025 11:48

    Вертексный буффер

    А всегда писать нормальным русским языком -- "вершинный буфер" -- никак нельзя? Вообще, грамотность речи где-то в районе двойки, да и с технической точки зрения определённые недостатки есть. Например:

    Сначала CPU вызывает API ((Direct3D/Vulkan/OpenGL/Metal). Дальше драйвер GPU конвертирует API вызовы в машинные команды и формирует Command Buffer. После происходит копирование данных из системной памяти в VRAM с помощью шины PCIe.

    Во-первых, графический API вызывает не "CPU", т. е. центральный процессор, а программа, желающая что-либо вывести посредством этого API. Вот выполняется она на центральном процессоре, это да -- но вызывает она, а не процессор, он просто выполняет команду за командой, и вызов функций API для него ничем не отличается от любой другой работы.

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

    Наконец, копирование из системной памяти в видеопамять имеет место не всегда (иногда видеопамяти не хватает, и в неё загружаются лишь наиболее критические вещи, а остальное по мере необходимости берётся прямо из системной памяти, а иногда -- например, на ноутбуках со слабой графикой -- видеопамяти как таковой просто нет), да и к типу шины оно ни разу не привязано. В частности, графический процессор, расположенный на одном кристалле с центральными процессорами архитектуры ARM, почти наверняка будет связан шиной AXI -- но это играет роль лишь для разработчиков железа и, в какой-то мере, для разработчиков операционных систем и драйверов (какие механизмы PnP используются и т.д.), но не для собственно "графических" программистов.


    1. DyadichenkoGA Автор
      02.08.2025 11:48

      Спасибо, только непонятно в чём претензия. Вершинный или вертексный нормальный рабочий сленг. Я понимаю что кому-то нравится больше русские определения - дело вкуса.

      Тоже самое про CPU. Это форма речи в которой вполне понятно что имеется ввиду. Уж тогда давайте говорить не "выполняет команду за командой", а кладёт байтики в регистры и посредством их обработки формирует машинные команды для исполнения...

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

      А теперь к сути. Статья в общем о механизмах для понимания почему что-то делается так, оптимизируется так и какие ограничения. От того, что я назову вертексные атрибуты вершинными параметрами суть не поменяется. Поменялась бы если бы на данную тематику была бы тонна рускоязычных материалов с устоявшейся терминологией. Но их как бы нет

      Частности можно разбирать до бесконечности. И я за то чтобы про это писать в комментариях. Если эта информация бесполезна, то прошу объяснить почему или нерелевантна. Так как когда я делал промышленную симуляцию проведения физического процесса в разных условиях атмосферной среды, ну мне пригодилось понимание пропускной способности шины и как она нагружается.

      Я всегда за критику, только по существу. За дополнение или опровержение информации так сказать. А уж в каком формате как что называть и каким сленгом пользоваться - моё право как автора. Так же как и ваше право как читателя считать, что это плохо.


  1. SIISII
    02.08.2025 11:48

    Вершинный или вертексный нормальный рабочий сленг.

    Сленг годится для устного общения между коллегами, в статьях же лучше придерживаться нормального литературного языка. Впрочем, Ваше право как автора выбирать те выражения, какие Вам нравятся. Но тогда непонятно, почему в одном месте Вы пишете "вершинный буфер", а в другом -- "вертексный буффер".

    Ну а ошибки в русском языке в любом случае имеют место быть, и они никакой текст не красят.