Search
Write a publication
Pull to refresh

О графике в Unity: видеокарты и их работа

Level of difficultyEasy
Reading time8 min
Views915

Всем привет! Меня зовут Григорий Дядиченко, я уже что-то разрабатываю на 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 - подписывайтесь на мой блог в телеграм. Я публикую там интересные новости и обзоры на них, свои мысли про бизнес, про фриланс и про разработку. Плюс лучший показатель того, что надо тема интересна - надо писать. Спасибо за внимание!

Tags:
Hubs:
+1
Comments4

Articles