TLDR: В теории — да, на практике — нет.
Всем привет! Я студент 2 курса магистратуры Университета ИТМО факультета «Школа разработки видеоигр». В своей выпускной работе «Анализ и разработка алгоритма Shadow Mapping направленных источников света для систем с несколькими GPU» я перенёс вычисление Cascaded Shadow Maps на вторую видеокарту и получил 40% прироста к производительности.
Дисклеймер
Сразу оговорюсь, что я пока только начинаю свой путь в компьютерной графике, поэтому буду рад любым замечаниям и обсуждению моих выводов в комментариях.
Немного про актуальность
Тут должно быть про то, что тени — это круто и необходимо в играх, в приложениях, но это не НИР, так что пропустим.
Можно заметить одну интересную тенденцию: если взглянуть на статистику видеокарт в Steam Survey, то среди лидеров по приросту — видеокарты ноутбуков. Это говорит о том, что игровые ноутбуки с двумя видеокартами становятся всё более распространенными.

Новая линейка десктопных процессоров от AMD серии 7XXX и 9XXX оснащена встроенной графикой. Так почему бы не задействовать мощность по максимуму?
Конечно, multi‑GPU активно используются для обучения ИИ, но там обычно задействовано гораздо больше двух видеокарт, что является редкостью для геймерских систем. Тем не менее, потенциал использования даже двух GPU в играх кажется весьма интересным.
SLI же мертв
Старым подходом к объединению мощности нескольких видеокарт от Nvidia был SLI, который в 2018 эволюционировал в NVLink Bridge. Аналогичное решение от AMD — CrossFireX. Эти технологии обеспечивали связь между видеокартами, позволяя им обмениваться данными и синхронизировать вычисления. NVLink значительно увеличил пропускную способность и эффективность работы в multi‑GPU системах, находя применение как в профессиональных (Quadro, Tesla), так и в некоторых игровых (GeForce) видеокартах.
Однако у этих подходов были существенные недостатки:
отсутствие возможности использования нескольких видеокарт от разных вендоров
отсутствие явного использования ресурсов видеокарт
Для решения вышеперечисленных проблем появилась программная поддержка multi‑GPU в графических API, таких как DirectX 12 и Vulkan. В 2015 году Microsoft активно продвигала Explicit Multi‑GPU как «киллер фичу» DirectX 12. Подробнее об этом мы поговорим далее.
Имеет ли смысл multi-GPU?
В процессе поиска литературы для анализа предметной области я наткнулся на работу близкую к моей теме: «Techniques for the utilization of heterogenous multi‑GPU configurations in realtime rendering». В ней анализируются алгоритмы multi‑GPU рендеринга для приложений реального времени. Автор рассматривал производительность методов Alternate Frame Rendering, Split Frame Rendering и разделение проходов рендеринга для отрисовки Shadow Map для точечных источников света на несколько GPU.
Главный недостаток первых двух методов — игнорирование сложности кадра. При использовании гетерогенных видеокарт с разной производительностью это может привести к задержкам из‑за ожидания более быстрой видеокарты.
Разделение рендер‑проходов выглядит многообещающе, однако с некоторыми выводами я не согласен:
Ожидание одной видеокартой завершения работы другой приводит к снижению производительности multi‑GPU рендеринга.
Да, это безусловно верно. Но сегодня рендеринг одного кадра включает множество последовательных и параллельных проходов. Поэтому проблему ожидания, например, дискретной видеокартой интегрированной, следует рассматривать в контексте общего времени рендеринга кадра.
Ключевой метрикой эффективности алгоритма является время его выполнения в миллисекундах. Значение, на которое ориентируются разработчики компьютерных игр, равняется примерно 16.6 миллисекунд.
На приведенном ниже рисунке показано распределение времени между GPU при расчете карт теней для 4 точечных источников света. Общее время рендеринга кадра около 18 миллисекунд. Это говорит о том, что при оптимальном распределении задач multi‑GPU рендеринг может повысить производительность приложения.
Автор также исследовал асинхронное взаимодействие видеокарт. Однако на представленных им графиках всё равно наблюдается время ожидания одной видеокарты. Это кажется странным, ведь при асинхронной работе ожидания по определению быть не должно.

Проектирование
Перед проектированием алгоритма рассмотрим стратегии распределения ресурсов и вычислений между несколькими видеокартами.
Данными подходами я вдохновился у o3de движка.
Стратегии управления общими ресурсами
Можно выделить 2 стратегии:
Стратегия «Один Device к одному ресурсу» (1:1) используется в большинстве рендер‑движков. Однако в контексте multi‑GPU потребуется модификация высокоуровневого кода, чтобы каждое устройство создавало собственные ресурсы для рендеринга мешей, материалов и других объектов
Стратегия «Несколько Device»ов к одному ресурсу» (1:N) предполагает, что ресурс становится контейнером и включает в себя подресурсы для каждой видеокарты. В этом случае потребуется изменить API и низкоуровневый код существующих рендер‑движков
В разрабатываемом прототипе будет реализован второй подход.
Стратегии распределения вычислений
Проанализировав существующие подходы, можно выделить следующие стратегии распределения вычислений:
Стратегия «RenderGraph на каждое устройство» подразумевает, что устройства не взаимодействуют напрямую, но могут использовать общие неизменяемые ресурсы. Эта стратегия подходит для отрисовки сцен в разные viewport. В таком случае управление ресурсами может осуществляться по стратегии 1:N
Стратегия «распределение RenderPass»ов между устройствами» включает синхронное или асинхронное взаимодействие. Каждое устройство выполняет свой RenderPass, и в зависимости от RenderGraph выстраиваются необходимые синхронизации и дополнительные проходы для копирования ресурсов. Для управления ресурсами в данном случае необходимы подходы 1:1 и 1:N
«Разделение одного RenderPass»а между устройствами» — техника Split Frame Rendering, которая рассматривалась ранее
Наиболее подходящей стратегией является распределение RenderPass»ов между устройствами.
Как в итоге параллелить задачи?
Рассмотрим применимость рендеринга с использованием нескольких GPU в контексте двух основных техник шейдинга — forward и deferred, как в синхронном, так и в асинхронном режимах выполнения.
Multi-GPU Shadow Mapping алгоритм в Forward Rendering
В контексте Forward Rendering разработаны алгоритмы, в которых вычисление Cascaded Shadow Maps (CSM) выполняется на дополнительной видеокарте, а рендеринг финального изображения — на основной.
На диаграммах термин «Cross Resource» обозначает общий ресурс, используемый в качестве буфера для передачи данных между несколькими видеокартами:

Forward Rendering в синхронном варианте

Forward Rendering в асинхронном варианте
Небольшие выводы:
Синхронный вариант показывает, что техника Forward Rendering не обеспечивает эффективного масштабирования в multi‑GPU системах. Основная проблема заключается в невозможности распараллелить ресурсоемкий этап Forward Pass, включающий все вычисления освещения и затенения от всех источников света. Поскольку этот этап требует полной информации о сцене, основная видеокарта вынуждена ожидать завершения предварительных вычислений на дополнительной видеокарте
Асинхронный вариант применим для Forward Rendering, однако не является универсальным решением для всех конфигураций multi‑GPU. Даже при равной вычислительной мощности обеих видеокарт такая схема неизбежно вводит задержку как минимум в один кадр из‑за необходимости последовательного получения промежуточных результатов
Таким образом, использование multi‑GPU для Shadow Mapping в Forward Rendering является малоперспективным как в синхронном, так и в асинхронном режимах из‑за сложности эффективного распределения вычислительной нагрузки.
Multi-GPU Shadow Mapping алгоритм в Deferred Rendering
Учитывая особенности Deferred Rendering, разработаны алгоритмы, в которых вычисление каскадных теневых карт выполняется на дополнительной видеокарте. В то же время основная видеокарта последовательно выполняет проходы geometry pass, light pass (исключая обработку направленного источника света) и дополнительные этапы, например, SSAO:

Deferred Rendering в синхронном варианте

Deferred Rendering в асинхронном варианте
В отличие от предыдущих подходов данный алгоритм позволяет основной видеокарте выполнять полезные вычисления одновременно с формированием CSM на дополнительной видеокарте. Такой подход обеспечивает более рациональное использование вычислительных ресурсов обеих видеокарт.
Сочетание синхронного и асинхронного режимов работы обеспечивает оптимальное распределение вычислительной нагрузки в различных multi‑GPU конфигурациях, учитывая особенности их архитектуры и производительность.
Дополнительно: multi-GPU Shadow Mapping with Shadow Mask
Существенным недостатком рассмотренных ранее алгоритмов является большой объем данных, передаваемых между видеокартами. Например, для четырех каскадов с разрешением 4096×4096 и 32-битной разрядностью требуется 256 мегабайт видеопамяти, что является значительным объемом для передачи. В современных ноутбуках интерфейс PCI‑e 4.0 с 16 линиями обеспечивает пропускную способность до 32 мегабайт в миллисекунду, что может являться узким местом при передаче больших объемов данных.
Поэтому был разработан алгоритм, уменьшающий объем передаваемой информации между видеокартами, при этом также используется метод каскадных теней:

алгоритма
После вычисления текстуры Shadow Mask основная видеокарта на этапе расчёта освещения от направленного источника света сопоставляет каждый пиксель финального изображения с соответствующим пикселем из Shadow Mask, чтобы определить степень его затенения.
Данный алгоритм имеет следующие преимущества:
Снижение объема передаваемых данных
Абстрагирование от конкретной реализации алгоритма построения теней. Получаемая Shadow Mask представляет собой интерфейс взаимодействия между основной и дополнительной видеокартами. Это означает, что основная видеокарта не зависит от конкретного алгоритма построения теней и всегда использует текстуру Shadow Mask. Как следствие, разработчики получают возможность динамически управлять нагрузкой на дополнительную видеокарту
К недостаткам алгоритма можно отнести следующее:
Данный подход можно использовать только в синхронном варианте
Увеличение потребления видеопамяти на дополнительной видеокарте вследствие добавления двух дополнительных этапов
Возрастание вычислительной нагрузки на дополнительный графический процессор из‑за введения двух дополнительных этапов
Все алгоритмы и эффекты, связанные с тенями (например, percentage‑closer filtering или мягкие тени), могут быть вычислены только на дополнительной видеокарте
В своей работе я остановился на асинхронном алгоритме multi‑GPU Shadow Mapping в Deferred Rendering, так как он подходит для различных конфигураций multi‑GPU систем.
К коду
При реализации своего прототипа я вдохновлялся следующими проектами:
Прежде чем перейти к описанию кода, я хотел бы рассказать о мотивации выбора использованных инструментов.
DirectX 12 или Vulkan
Как уже упоминалось, для работы с multi‑GPU системами сегодня доступны два основных графических API: Vulkan и DirectX 12. Оба поддерживают linked и unlinked режимы взаимодействия с несколькими видеокартами. Linked режим, основанный на SLI или CrossFire, не подходит для конфигурации с интегрированной и дискретной видеокартами.
В своей работе я сосредоточился на unlinked режиме. При выборе графической библиотеки ключевым фактором стала поддержка функций, облегчающих работу с multi‑GPU системами в этом режиме.
В Vulkan API для unlinked режима требуется самостоятельно синхронизировать и передавать данные через CPU. Возможно существуют какие‑нибудь расширения, но мне кажется, что они поддерживаются малым количеством видеокарт. Таким образом, Vulkan не предоставляет достаточной встроенной функциональности для удобной реализации multi‑GPU конфигураций.
Для реализации алгоритма я выбрал DirectX 12. В отличие от Vulkan DirectX 12 предлагает встроенную поддержку создания и использования общих ресурсов и общих примитивов синхронизации:

Rust
Выбор Rust был довольно простым:
Я владею им лучше, чем C++
Мне просто нравится на нём писать
Основная сложность заключалась в отсутствии подходящего крейта для использования DirectX 12 в Rust. Конечно, существует крейт windows‑rs, но мне не понравился API методов, и хотелось использовать Builder‑паттерн для инициализации структур. Поэтому в прошлом году весной и летом я разработал небольшую обёртку oxidx для DirectX 12, параллельно изучая книгу Фрэнка Луны «Introduction to 3D Game Programming with DirectX 12».
Архитектура
Стандартная архитектура большинства рендер‑движков состоит из 2 модулей: Render Hardware Interface (RHI) и High‑Level Rendering.

Для обеспечения совместного использования вычислительных ресурсов между несколькими графическими процессорами в архитектуру системы был интегрирован вспомогательный промежуточный модуль — Mid‑Level Rendering. Этот модуль является дополнительной абстракцией над RHI, в которой доступ к ресурсам GPU осуществляется через Handle
:
pub struct Handle<T> {
pub(super) index: u32,
pub(super) cookie: NonZero<u32>,
_marker: PhantomData<T>,
}

Структуры данных
Мы крутые программисты, поэтому перед написанием рендера нужно реализовать структуры данных. В этом разделе я опишу ключевые структуры, которые легли в основу моей реализации.
HandleAllocator
Предназначен для создания новых Handle
'ов. Аллокатор линейно «выделяет» хендлы, а также хранит в себе список свободных индексов:
handle.rs
#[derive(Debug)]
pub struct HandleAllocator<T> {
gens: Vec<u32>,
free_list: Vec<u32>,
_marker: PhantomData<T>,
}
impl<T> HandleAllocator<T> {
#[inline]
pub fn new() -> Self {
Self {
gens: Vec::new(),
free_list: Vec::new(),
_marker: PhantomData,
}
}
#[inline]
pub fn allocate(&mut self) -> Handle<T> {
if let Some(idx) = self.free_list.pop() {
Handle::new(idx, self.gens[idx as usize])
} else {
let idx = self.gens.len();
#[derive(Debug)]
pub struct HandleAllocator<T> {
gens: Vec<u32>,
free_list: Vec<u32>,
_marker: PhantomData<T>,
}
impl<T> HandleAllocator<T> {
#[inline]
pub fn new() -> Self {
Self {
gens: Vec::new(),
free_list: Vec::new(),
_marker: PhantomData,
}
}
#[inline]
pub fn allocate(&mut self) -> Handle<T> {
if let Some(idx) = self.free_list.pop() {
Handle::new(idx, self.gens[idx as usize])
} else {
let idx = self.gens.len();
self.gens.push(1);
Handle::new(idx as u32, 1)
}
}
#[inline]
pub fn is_valid(&self, handle: Handle<T>) -> bool {
self.gens
.get(handle.index as usize)
.is_some_and(|h| *h == handle.cookie.get())
}
#[inline]
pub fn free(&mut self, handle: Handle<T>) {
if let Some(cookie) = self.gens.get_mut(handle.index as usize) {
*cookie += 1;
self.free_list.push(handle.index);
}
}
}
self.gens.push(1);
Handle::new(idx as u32, 1)
}
}
#[inline]
pub fn is_valid(&self, handle: Handle<T>) -> bool {
self.gens
#[derive(Debug)]
pub struct HandleAllocator<T> {
gens: Vec<u32>,
free_list: Vec<u32>,
_marker: PhantomData<T>,
}
impl<T> HandleAllocator<T> {
#[inline]
pub fn new() -> Self {
Self {
gens: Vec::new(),
free_list: Vec::new(),
_marker: PhantomData,
}
}
#[inline]
pub fn allocate(&mut self) -> Handle<T> {
if let Some(idx) = self.free_list.pop() {
Handle::new(idx, self.gens[idx as usize])
} else {
let idx = self.gens.len();
#[derive(Debug)]
pub struct HandleAllocator<T> {
gens: Vec<u32>,
free_list: Vec<u32>,
_marker: PhantomData<T>,
}
impl<T> HandleAllocator<T> {
#[inline]
pub fn new() -> Self {
Self {
gens: Vec::new(),
free_list: Vec::new(),
_marker: PhantomData,
}
}
#[inline]
pub fn allocate(&mut self) -> Handle<T> {
if let Some(idx) = self.free_list.pop() {
Handle::new(idx, self.gens[idx as usize])
} else {
let idx = self.gens.len();
self.gens.push(1);
Handle::new(idx as u32, 1)
}
}
#[inline]
pub fn is_valid(&self, handle: Handle<T>) -> bool {
self.gens
.get(handle.index as usize)
.is_some_and(|h| *h == handle.cookie.get())
}
#[inline]
pub fn free(&mut self, handle: Handle<T>) {
if let Some(cookie) = self.gens.get_mut(handle.index as usize) {
*cookie += 1;
self.free_list.push(handle.index);
}
}
}
self.gens.push(1);
Handle::new(idx as u32, 1)
}
}
#[inline]
pub fn is_valid(&self, handle: Handle<T>) -> bool {
self.gens
.get(handle.index as usize)
.is_some_and(|h| *h == handle.cookie.get())
}
#[inline]
pub fn free(&mut self, handle: Handle<T>) {
if let Some(cookie) = self.gens.get_mut(handle.index as usize) {
*cookie += 1;
self.free_list.push(handle.index);
}
}
}
.get(handle.index as usize)
.is_some_and(|h| *h == handle.cookie.get())
}
#[inline]
pub fn free(&mut self, handle: Handle<T>) {
if let Some(cookie) = self.gens.get_mut(handle.index as usize) {
*cookie += 1;
self.free_list.push(handle.index);
}
}
}
RangeAllocator
Тут должна была быть реализация аллокатора диапазонов, который используется для управления дескрипторами ресурсов в DescriptorHeap
. Однако в момент написания системы для работы с дескрипторами мне было лень реализовывать аллокатор, поэтому:
// Cargo.toml
range-alloc = "0.1.4"
SparseMap
SparseMap — это ассоциативный массив, в котором ключами выступают числовые значения. В моем случае данная структура данных необходима для маппинга Handle
в Resource
:
sparse_map.rs
#[derive(Clone, Copy, Debug)]
struct SparseEntry {
dense_index: usize,
dense_cookie: NonZero<u32>,
}
#[derive(Debug)]
pub struct SparseMap<U, W> {
sparse: Vec<Option<SparseEntry>>,
dense: Vec<MaybeUninit<W>>,
dense_to_sparse: Vec<usize>,
_marker: PhantomData<U>,
}
impl<U, W> Default for SparseMap<U, W> {
fn default() -> Self {
Self::new(128)
}
}
impl<U, W> SparseMap<U, W> {
pub fn new(capacity: usize) -> Self {
Self {
sparse: vec![None; capacity],
dense: Vec::new(),
dense_to_sparse: Vec::new(),
_marker: PhantomData,
}
}
pub fn contains(&self, handle: Handle<U>) -> bool {
self.sparse
.get(handle.index as usize)
.is_some_and(|h| h.is_some_and(|h| h.dense_cookie == handle.cookie))
}
pub fn set(&mut self, handle: Handle<U>, value: W) -> Option<W> {
if self.sparse.len() <= handle.index as usize {
self.sparse.resize((handle.index + 1) as usize, None);
}
if let Some(ref mut h) = self.sparse[handle.index as usize] {
let value = std::mem::replace(
&mut self.dense[h.dense_index as usize],
MaybeUninit::new(value),
);
h.dense_cookie = handle.cookie;
unsafe { Some(value.assume_init()) }
} else {
let pos = self.dense.len();
self.dense.push(MaybeUninit::new(value));
self.dense_to_sparse.push(handle.index as usize);
self.sparse[handle.index as usize] = Some(SparseEntry {
dense_index: pos,
dense_cookie: handle.cookie,
});
None
}
}
pub fn get(&self, handle: Handle<U>) -> Option<&W> {
self.sparse.get(handle.index as usize).and_then(|h| {
if let Some(h) = h {
if h.dense_cookie == handle.cookie {
unsafe { Some(self.dense[h.dense_index as usize].assume_init_ref()) }
} else {
None
}
} else {
None
}
})
}
pub fn get_mut(&mut self, handle: Handle<U>) -> Option<&mut W> {
self.sparse.get(handle.index as usize).and_then(|h| {
if let Some(h) = h {
if h.dense_cookie == handle.cookie {
unsafe { Some(self.dense[h.dense_index as usize].assume_init_mut()) }
} else {
None
}
} else {
None
}
})
}
pub fn remove(&mut self, handle: Handle<U>) -> Option<W> {
let Some(Some(SparseEntry {
dense_index,
dense_cookie,
})) = self.sparse.get(handle.index as usize).cloned()
else {
return None;
};
if dense_cookie != handle.cookie {
return None;
}
let value = std::mem::replace(&mut self.dense[dense_index], MaybeUninit::uninit());
let value = unsafe { value.assume_init() };
self.dense.swap_remove(dense_index);
self.dense_to_sparse.swap_remove(dense_index);
self.sparse[handle.index as usize] = None;
let Some(Some(handle)) = self
.dense_to_sparse
.get(dense_index)
.and_then(|idx| self.sparse.get_mut(*idx))
else {
return Some(value);
};
handle.dense_index = dense_index;
Some(value)
}
}
impl<U, W> Drop for SparseMap<U, W> {
fn drop(&mut self) {
for handle in self.sparse.iter_mut() {
if let Some(handle) = handle.take() {
unsafe {
self.dense[handle.dense_index as usize].assume_init_drop();
}
}
}
}
}
Да, тут есть немного unsafe'а. Можно было обойтись без него, разбавив код с помощью Option и unwrap'ов. Для вас небольшой challenge — найти UB.
ReadWriteCopyRingBuffer
Это название описывает модифицированный кольцевой буфер, предназначенный для хранения ресурсов. Сами ресурсы в буфере являются неизменяемыми, но для каждого ресурса хранится изменяемое состояние, отражающее его готовность к использованию на различных этапах рендеринга:
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RwcState {
#[default]
WaitForWrite,
WaitForCopy(u64),
WaitForRead(u64),
}
Числа в перечислении является временем на GPU‑timeline, когда к ресурсу можно будет безопасно обратиться.
rwc_ring_buffer.rs
#[derive(Debug)]
pub struct RwcRingBuffer<T, const N: usize> {
buffer: SmallVec<[T; N]>,
size: usize,
states: SmallVec<[RwcState; N]>,
pub head: usize,
pub tail: usize,
}
impl<T, const N: usize> RwcRingBuffer<T, N> {
pub fn new(buffer: SmallVec<[T; N]>) -> Self {
let size = buffer.len();
Self {
buffer,
head: 0,
tail: 0,
size,
states: (0..size).map(|_| Default::default()).collect(),
}
}
#[inline]
pub fn head_state(&self) -> RwcState {
self.states[self.head]
}
#[inline]
pub fn tail_state(&self) -> RwcState {
self.states[self.tail]
}
#[inline]
pub fn advance_head(&mut self) {
self.head = (self.head + 1) % self.size;
}
#[inline]
pub fn advance_tail(&mut self) {
self.tail = (self.tail + 1) % self.size;
}
#[inline]
pub fn update_head_state(&mut self, state: RwcState) {
self.states[self.head] = state;
}
#[inline]
pub fn update_tail_state(&mut self, state: RwcState) {
self.states[self.tail] = state;
}
#[inline]
pub fn head_data(&self) -> &T {
&self.buffer[self.head]
}
#[inline]
pub fn tail_data(&self) -> &T {
&self.buffer[self.tail]
}
#[inline]
pub fn tip_index(&self) -> usize {
if self.tail == 0 {
self.size - 1
} else {
self.tail - 1
}
}
#[inline]
pub fn tip_data(&self) -> &T {
&self.buffer[self.tip_index()]
}
}
В контексте разрабатываемого алгоритма состояния интерпретируются следующим образом:
WaitForWrite: начальное состояние всех общих ресурсов. Означает, что данный ресурс может быть использован для рендеринга Cascaded Shadow Maps
WaitForCopy: состояние содержит значение
fence
'а, сигнализирующее о завершении отрисовки на интегрированной видеокарте. Перед записью команд на копирование необходимо проверить это значение с текущим значением fence очереди интегрированной GPU (fence.GetCompletedValue()
)WaitForRead: аналогично
WaitForCopy
, но относится к очереди копирования дискретной GPU. Состояние содержит значениеfence
, сигнализирующее о завершении копирования на дискретную видеокарту
Как именно реализована работа с кольцевым буфером, я покажу в процессе разбора кода, отвечающего за рендеринг кадра.
RHI
Another try, another fail...
Из‑за недостатка опыта в компьютерной графике в N‑раз получился своеобразный слой абстракции над графическим API.
Я пытался совместить гибкость, статический полиморфизм и «чистый код». В итоге получился вот такой франкенштейн:
pub trait RenderDevice:
RenderResourceDevice
+ RenderCommandDevice<
CommandQueue: for<'a> RenderCommandQueue<
CommandBuffer: RenderCommandBuffer<
Device = Self,
RenderEncoder<'a>: RenderEncoder<
Buffer = Self::Buffer,
Texture = Self::Texture,
ShaderArgument = Self::ShaderArgument,
RasterPipeline = Self::RasterPipeline,
>,
TransferEncoder<'a>: TransferEncoder<Texture = Self::Texture>,
>,
Event = Self::Event,
>,
> + RenderShaderDevice
+ RenderSwapchainDevice<Swapchain: Surface<Texture = Self::Texture>, Queue = Self::CommandQueue>
+ Send
+ Sync
{
}
Это просто вспомогательный трейт для Mid‑Level Rendering модуля, чтобы во всех местах не прописывать эти ограничения.
В какой‑то момент стремление к излишней гибкости привело к таким конструкциям, как эта структура:
#[derive(Clone, Debug)]
pub struct ShaderArgumentDesc<
'a,
D: RenderResourceDevice,
V: IntoIterator<Item = ShaderEntry<'a, D>>,
S: IntoIterator<Item = &'a D::Sampler>,
> {
pub views: V,
pub samplers: S,
pub dynamic_buffer: Option<&'a D::Buffer>,
}
Спасибо за гибкость и спасибо за увеличение времени компиляции (хотя эта структура всё время использовалась с одними и теми же D, V, S).
В голове не раз промелькала мысль: «Надо было использовать dyn Trait
и &[T]
...».
Далее я опишу наиболее интересные моменты реализации RHI.
Запись команд
CommandQueue
является «фабрикой» создания CommandBuffer
. Она управляет жизненным циклом созданных буферов, возвращая их для повторного использования после того, как GPU завершит их выполнение, или создавая новые буферы при необходимости. Реализованы следующие операции над CommandBuffer
'ами:
Enqueue помещает командный буфер во временную очередь. При создании нового командного буфера
CommandQueue
сначала пытается получить буфер из этой очередиCommit отправляет командные буферы в промежуточный буфер. Эти буферы позже будут отправлены одной командой на выполнение на GPU
Каждому CommandBuffer
'у создается TimestampQueryHeap
, чтобы считать время выполнения команд на GPU. Благодаря использованию Encoder
'ов для записи команд в буфер процесс замера времени различных рендер‑проходов становится автоматизированным. Метод begin при этом возвращает Option<Timings>
.
Текстуры
В моей реализации отсутствуют явные TextureView
. При создании текстуры определяется её тип (RenderTarget
, DepthStencil
или ShaderResource
) и в зависимости от этого создается TextureView
или нет.
let descriptor = match view.view_ty {
TextureViewType::RenderTarget => {
Some(self.descriptors.allocate(dx::DescriptorHeapType::Rtv, 1))
}
TextureViewType::DepthStencil => {
Some(self.descriptors.allocate(dx::DescriptorHeapType::Dsv, 1))
}
_ => None,
};
Для создания другого View
для существующей текстуры используется метод fn create_texture_view(&self, texture: &Self::Texture, desc: TextureViewDesc) -> Self::Texture;
, который возвращает «новую» текстуру. На самом деле это ненастоящая текстура, содержащая ссылку или хендл на оригинальную текстуру и переопределенные параметры View
.
Теперь вернемся к основной теме статьи. А как же реализованы общие текстуры?
Для этого используется следующее перечисление:
#[derive(Debug)]
pub enum TextureFlavor {
Local,
CrossAdapter {
heap: dx::Heap,
},
Binded {
heap: dx::Heap,
cross: dx::Resource,
cross_state: Mutex<dx::ResourceStates>,
},
}
Здесь проявляется аппаратная особенность интегрированных видеокарт Intel: текстуры, созданные с флагом RenderTarget
, могут быть размещены в общей памяти и напрямую использоваться для рендеринга. Это позволяет избежать этапа копирования текстуры из локальной памяти в общую на карточке от Intel.
Для определения возможности использования текстур из общей памяти в рендеринге необходимо проверить поддержку соответствующей DirectX 12 Features:
let mut feature = OptionsFeature::default();
device
.check_feature_support(&mut feature)
.expect("failed to check options");
if feature.cross_adapter_row_major_texture_supported() {
// Поддерживается
} else {
// Не поддерживается
}
Исходя из этой информации, значения перечисления TextureFlavor
имеют следующее значение:
Local
: обычная текстура, размещенная в локальной памяти GPUCrossAdapter
: текстура, находящаяся в общей памяти и доступная для рендеринга обоими GPU.dx::Heap
— это shared‑куча, где выделена память под текстуру.Binded
: текстура должна передавать между GPU, но она не создана в общей памяти. Для решения этой проблемы создается дополнительная текстура, с помощью которой передается информация с одной видеокарты на другую
Когда одна видеокарта создает общую текстуру, другая должна ее «открыть» для использования:
fn open_texture(
&self,
texture: &Self::Texture,
other_gpu: &Self,
overrided_view: Option<TextureViewDesc>,
) -> Self::Texture {
let heap = match &texture.flavor {
TextureFlavor::Local => panic!("Texture is local, can not open handle"),
TextureFlavor::CrossAdapter { heap } => heap,
TextureFlavor::Binded { heap, .. } => heap,
};
let handle = other_gpu
.gpu
.create_shared_handle(heap, None)
.expect("Failed to open handle");
let open_heap: dx::Heap = self
.gpu
.open_shared_handle(handle)
.expect("Failed to open heap");
handle.close().expect("Failed to close handle");
self.create_shared_texture(
texture
.desc
.clone()
.with_name(std::borrow::Cow::Owned(format!(
"{} Opened",
texture
.desc
.name
.as_ref()
.unwrap_or(&std::borrow::Cow::Borrowed("Unnamed"))
))),
open_heap,
overrided_view,
)
}
Для передачи данных между текстурами (в случае TextureFlavor::Binded
) реализованы вспомогательные методы для TransferEncoder
:
impl<'a> TransferEncoder for DxTransferEncoder<'a> {
type Texture = DxTexture;
fn pull_texture(&self, texture: &Self::Texture) {
match &texture.flavor {
TextureFlavor::Binded { cross, .. } => {
self.cmd.list.copy_resource(&texture.raw, cross);
}
_ => { /* NOOP */ }
}
}
fn push_texture(&self, texture: &Self::Texture) {
match &texture.flavor {
TextureFlavor::Binded { cross, .. } => {
self.cmd.list.copy_resource(cross, &texture.raw);
}
_ => { /* NOOP */ }
}
}
}
Resource Binding
Ресурсы шейдера в моей системе являются персистентными, то есть создаются на этапе инициализации, а не во время рендеринга каждого кадра.
#[derive(Debug)]
pub struct DxShaderArgument {
pub(super) views: Option<Descriptor>,
pub(super) samplers: Option<Descriptor>,
pub(super) dynamic_address: Option<u64>,
}
Структура ShaderArgument
содержит:
views
: опциональный дескриптор для текстурsamplers
: опциональный дескриптор для семплеровdynamic_address
: адрес буфера констант (uniform buffer) в памяти GPU
Все uniform‑данные передаются через «динамический» буфер. Во время привязки ShaderArgument
передается аргумент смещения относительно начала адреса динамического буфера. Это позволяет абстрагировать данные, которые передаются с CPU, от концепции «frames in flight».
Одновременно может быть привязано до четырех ShaderArgument
'ов, что позволяет использовать до четырех uniform‑буферов, чего вполне достаточно для моих текущих целей.
Вот пример объявления ресурсов шейдера в HLSL:
SamplerState linear_clamp_s : register(s0);
cbuffer GlobalBuffer : register(b0, space0) {
Globals g_data;
}
Texture2D diffuse_t : register(t0, space1);
Texture2D normal_t : register(t1, space1);
cbuffer MaterialBuffer : register(b0, space1) {
Material material_data;
}
cbuffer ObjectTransform : register(b0, space2)
{
matrix transform;
}
А вот как эти ресурсы описываются в Rust:
let gpass_layout_desc = PipelineLayoutDesc {
sets: &[
BindingSet {
entries: &[],
use_dynamic_buffer: true,
},
BindingSet {
entries: &[BindingEntry::new(BindingType::Srv, 2)],
use_dynamic_buffer: true,
},
BindingSet {
entries: &[],
use_dynamic_buffer: true,
},
],
static_samplers: &[StaticSampler {
ty: SamplerType::Sample(Filter::Linear),
address_mode: AddressMode::Wrap,
}],
};
Mid-Level Rendering
Главной точкой входа является RenderSystem
, которая создает запрошенные бекенды графического API и управляет хендлами:
#[derive(Debug)]
pub struct RenderSystem {
pub(super) dx_backend: Option<Arc<Backend<DxBackend>>>,
pub handles: HandleContainer,
}
impl RenderSystem {
pub fn new(backend_settings: &[RenderBackendSettings]) -> Self {
let dx_backend = backend_settings
.iter()
.find(|b| b.api == RenderBackend::Dx12)
.and_then(|settings| Some(Arc::new(Backend::new(DxBackend::new(settings.debug)))));
Self {
dx_backend,
handles: HandleContainer::new(),
}
}
#[inline]
pub fn dx_backend(&self) -> Option<Arc<Backend<DxBackend>>> {
self.dx_backend.clone()
}
/*Тут API для работы с хендлами (create, free для различных ресурсов)*/
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderBackendSettings {
pub api: RenderBackend,
pub debug: DebugFlags,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderBackend {
Dx12,
}
Вспомогательная структура HandleContainer
выглядит так:
#[derive(Debug)]
pub struct HandleContainer {
pub(super) buffers: Mutex<HandleAllocator<Buffer>>,
pub(super) textures: Mutex<HandleAllocator<Texture>>,
pub(super) samplers: Mutex<HandleAllocator<Sampler>>,
pub(super) pipeline_layouts: Mutex<HandleAllocator<PipelineLayout>>,
pub(super) shader_arguments: Mutex<HandleAllocator<ShaderArgument>>,
pub(super) raster_pipeline: Mutex<HandleAllocator<RasterPipeline>>,
}
Мы уже рассматривали ранее HandleAllocator
, который в данном случае параметризован unit‑типами, указывающими на тип ресурса (Buffer, Texture и т. д.).
Для упрощения взаимодействия с объектами RHI на уровне MLR используется обертка Context
. Она содержит устройство, различные очереди команд, загрузчик ресурсов и самое главное — маппер Handle
'ов:
pub struct Context<D: RenderDevice> {
pub(super) gpu: D,
pub(super) graphics_queue: CommandQueue<D>,
pub(super) compute_queue: CommandQueue<D>,
pub(super) transfer_queue: CommandQueue<D>,
pub(super) uploader: D::ResourceUploader,
pub(super) mapper: Arc<ResourceMapper<D>>,
}
ResourceMapper
выполняет функцию связывания Handle с реальными ресурсами, созданными на уровне RHI. Он похож на HandleContainer
, но вместо HandleAllocator
хранит SparseMap
для быстрого поиска ресурса по его Handle
:
pub(super) struct ResourceMapper<D: RenderDevice> {
pub(super) buffers: RwLock<SparseMap<Buffer, D::Buffer>>,
pub(super) textures: RwLock<SparseMap<Texture, D::Texture>>,
pub(super) samplers: RwLock<SparseMap<Sampler, D::Sampler>>,
pub(super) pipeline_layouts: RwLock<SparseMap<PipelineLayout, D::PipelineLayout>>,
pub(super) shader_arguments: RwLock<SparseMap<ShaderArgument, D::ShaderArgument>>,
pub(super) raster_pipelines: RwLock<SparseMap<RasterPipeline, D::RasterPipeline>>,
}
Для упрощения работы с двумя графическими контекстами (в случае multi‑GPU) была реализована вспомогательная структура ContextDual
, которая хранит два экземпляра Context
и предоставляет удобные методы для выполнения одного и того же кода на обоих устройствах:
pub struct ContextDual<D: RenderDevice> {
pub primary: Arc<Context<D>>,
pub secondary: Arc<Context<D>>,
}
impl<D: RenderDevice> ContextDual<D> {
pub fn new(primary: Arc<Context<D>>, secondary: Arc<Context<D>>) -> Self {
Self { primary, secondary }
}
pub fn call(&self, func: impl Fn(&Context<D>)) {
func(&self.primary);
func(&self.secondary);
}
pub fn parallel(&self, func: impl Fn(&Context<D>) + Sync) {
std::thread::scope(|s| {
s.spawn(|| func(&self.primary));
s.spawn(|| func(&self.secondary));
});
}
pub fn call_primary(&self, mut func: impl FnMut(&Context<D>)) {
func(&self.primary);
}
pub fn call_secondary(&self, mut func: impl FnMut(&Context<D>)) {
func(&self.secondary);
}
}
Давайте посмотрим, как выглядит интерфейс для привязки хендла и ресурса:
pub trait RenderResourceContext {
fn bind_buffer(&self, handle: Handle<Buffer>, desc: BufferDesc, init_data: Option<&[u8]>);
fn unbind_buffer(&self, handle: Handle<Buffer>);
fn update_buffer<T: Clone>(&self, handle: Handle<Buffer>, offset: usize, data: &[T]);
fn bind_texture(&self, handle: Handle<Texture>, desc: TextureDesc, init_data: Option<&[u8]>);
fn unbind_texture(&self, handle: Handle<Texture>);
fn bind_texture_view(
&self,
handle: Handle<Texture>,
texture: Handle<Texture>,
desc: TextureViewDesc,
);
fn open_texture_handle(
&self,
handle: Handle<Texture>,
other: &Self,
overrided_view: Option<TextureViewDesc>,
);
fn bind_sampler(&self, handle: Handle<Sampler>, desc: SamplerDesc);
fn unbind_sampler(&self, handle: Handle<Sampler>);
}
Обратите внимание, что в методах этого интерфейса нет явного упоминания типов ресурсов из RenderDevice
. Это связано с тем, что сами методы создают необходимые объекты RHI и связывают их с переданным Handle.
Наверно, стоило добавить отдельный интерфейс, в котором методы просто привязывают ресурсы к хендлу. Это помогло бы избежать небольшого костыля в Swapchain
.
Такой подход к управлению ресурсами через Handle был реализован для упрощения работы с общими ресурсами в multi‑GPU системах. Приятным бонусом оказалось то, что при изменении разрешения экрана рендер‑проходам, зависящим от ресурсов других проходов, не требовалось обновлять ссылки на пересозданные ресурсы, так как связь осуществлялась через Handle
.
В моей реализации отсутствует классический рендер‑граф. Вместо этого используется набор структур, описывающих отдельные рендер‑проходы, их входные и выходные данные, а также зависимости между ними.
Renderer
Рассмотрим применение Mid‑Level Rendering в коде рендеринга.
Дублирующиеся ресурсы, которые должны быть доступны по одному и тому же хендлу:
// Преобразуем сцену из GLTF-формата
let prepared = scene.prepare(rs);
// Параллельно загружаем на каждое устройство данные
group.parallel(|ctx| {
ctx.bind_buffer(
prepared.positions, // Это хендл
BufferDesc {
name: Some("Position Vertex Buffer".into()),
size: size_of_val(&scene.positions[..]),
stride: size_of::<[f32; 3]>(),
usage: BufferUsages::Vertex,
memory_location: MemoryLocation::GpuToGpu,
},
Some(bytemuck::cast_slice(&scene.positions)),
);
ctx.bind_buffer(
prepared.indices, // Это хендл
BufferDesc {
name: Some("Index Buffer".into()),
size: size_of_val(&scene.indices[..]),
stride: size_of::<u32>(),
usage: BufferUsages::Index,
memory_location: MemoryLocation::GpuToGpu,
},
Some(bytemuck::cast_slice(&scene.indices)),
);
for (buffer, argument) in prepared.submeshes.iter() {
let data = (0..settings.frames_in_flight)
.map(|_| GpuTransform {
mat: glam::Mat4::from_scale(vec3(
settings.scene_scale,
settings.scene_scale,
settings.scene_scale,
)),
})
.collect::<Vec<_>>();
ctx.bind_buffer(
*buffer, // Это хендл
BufferDesc {
name: Some("Object Position".into()),
size: settings.frames_in_flight * size_of::<GpuTransform>(),
stride: 0,
usage: BufferUsages::Uniform,
memory_location: MemoryLocation::CpuToGpu,
},
None,
);
ctx.update_buffer(*buffer, 0, &data);
ctx.bind_shader_argument(
*argument, // Это хендл
ShaderArgumentDesc {
views: &[],
samplers: &[],
dynamic_buffer: Some(*buffer),
},
);
}
});
Общие ресурсы:
// Создаем просто хендлы текстур
let shared = (0..texture_count)
.map(|_| rs.create_texture_handle())
.collect::<SmallVec<_>>();
// Создаем текстуру для Cascaded Shadow Maps, в которую будет происходить
// рендеринг на второй видеокарте
group.call_secondary(|ctx| {
shared.iter().enumerate().for_each(|(i, t)| {
ctx.bind_texture(
*t,
TextureDesc::new_2d(
[2 * settings.cascade_size, 2 * settings.cascade_size],
Format::R32,
TextureUsages::RenderTarget
| TextureUsages::Resource
| TextureUsages::Shared,
)
.with_name(format!("Shared Cascaded Shadow Maps {i}").into())
.with_color(ClearColor::Color([1.0, 1.0, 1.0, 1.0])),
None,
);
});
});
// "Открываем" текстуру на основной видеокарте, которая будет использоваться
// как Shader Resource
group.call_primary(|ctx| {
shared.iter().enumerate().for_each(|(_, t)| {
ctx.open_texture_handle(
*t,
&group.secondary,
Some(
TextureViewDesc::default()
.with_view_type(TextureViewType::ShaderResource)
.with_format(Format::R32),
),
);
});
});
Внимательный читатель с опытом в компьютерной графике мог заметить, что размер текстуры каскадов умножается на 2, что формат текстуры R32, а не D32, что стоит флаг TextureUsages::RenderTarget
, а не TextureUsages::DepthStencil
.
Да, это было сделано специально как раз для того, чтобы избавиться от дополнительного копирования на карточках Intel. В общей памяти нельзя создавать TextureArray
и текстуры с флагом DepthStencil
, поэтому в моем рендере используется атлас для каскадов и формат R32 с флагом RenderTarget
.

Так как же используется ReadWriteCopyRingBuffer?
Talk is cheap. Show me the code.
pub fn render(
&mut self,
world: &World,
globals: Handle<ShaderArgument>,
swapchain_view: Handle<Texture>,
camera: &Camera,
light_dir: glam::Vec3,
frame_idx: usize,
) {
// Проверяем готова ли вторая видеокарта для рендеринга
// И проверяем, что head-указатель имеет состояния "готовности для записи/рендера"
if self.ctx.secondary.is_ready(CommandType::Graphics)
&& self.csm.shared.head_state() == RwcState::WaitForWrite
{
// Обновляем матрицы каскадов
self.csm.update(camera, light_dir);
// Записываем команды на вторую видеокарту
self.ctx.call_secondary(|ctx| {
let mut cmd = ctx.create_encoder(CommandType::Graphics);
let timings = cmd.begin(ctx);
// Отправляем по каналу информацию о таймингах, если есть
if let Some(sdr) = &mut self.sender {
if let Some(timings) = timings {
sdr.send(TimingsInfo::SecondaryMultiGpu(timings))
.expect("failed to send");
}
} else {
info!("Secondary Timings: {:?}", timings);
}
// Стешим CommandBuffer/CommandEncoder
ctx.enqueue(cmd);
// Выполняем рендер-проход Cascaded Shadow Maps
self.csm.render(world);
// Копируем Cascaded Shadow Maps из локальной памяти в общую
// На Intel карточках ничего не будет
let mut cmd = ctx.create_encoder(CommandType::Graphics);
cmd.set_barriers(&[
Barrier::Texture(
*self.csm.shared.head_data(), // Это хендл
ResourceState::CopySrc,
Subresource::Local(None),
),
Barrier::Texture(
*self.csm.shared.head_data(), // Это хендл
ResourceState::CopyDst,
Subresource::Shared,
),
]);
{
let encoder = cmd.transfer("Push CSM".into());
encoder.push_texture(*self.csm.shared.head_data());
}
// Пушим CommandBuffer/CommandEncoder
ctx.commit(cmd);
// Меняем состояние head-указателя на "готовность для копирования"
self.csm
.shared
.update_head_state(RwcState::WaitForCopy(ctx.submit(CommandType::Graphics)));
});
}
// Проверяем, что tail-указатель имеет состояние WaitForCopy
// Проверяем, что очередь копирования на основной видеокарте готово для копирования
// Проверяем, что вторая видеокарта закончила рендер и копирование каскадов
if let RwcState::WaitForCopy(v) = self.csm.shared.tail_state() {
if self.ctx.primary.is_ready(CommandType::Transfer)
&& self.ctx.secondary.is_ready_for(CommandType::Graphics, v)
{
// Передвигаем head-указатель на следующее состояние
self.csm.shared.advance_head();
// Записываем команды копирования на основной видеокарте
self.ctx.call_primary(|ctx| {
let mut cmd = ctx.create_encoder(CommandType::Transfer);
let timings = cmd.begin(ctx);
if let Some(sdr) = &mut self.sender {
if let Some(timings) = timings {
sdr.send(TimingsInfo::PrimaryCopyMultiGpu(timings))
.expect("failed to send");
}
} else {
info!("Copy Timings: {:?}", timings);
}
cmd.set_barriers(&[
Barrier::Texture(
*self.csm.shared.tail_data(), // Это хендл
ResourceState::CopyDst,
Subresource::Local(None),
),
Barrier::Texture(
*self.csm.shared.tail_data(), // Это хендл
ResourceState::CopySrc,
Subresource::Shared,
),
]);
{
let encoder = cmd.transfer("Pull CSM".into());
encoder.pull_texture(*self.csm.shared.tail_data());
}
ctx.commit(cmd);
// Обновляем состояние tail-указателя на "готово для чтения"
self.csm.shared.update_tail_state(RwcState::WaitForRead(
ctx.submit(CommandType::Transfer),
));
});
}
}
// Z-prepass
self.zpass.render(globals, frame_idx, world);
// G-pass
self.gpass.render(globals, frame_idx, world);
// Проверяем, что tail-указатель имеет состояние WaitForRead
// Проверяем, что очередь копирования скопировала текстуру из общей памяти в локальную
let copy_texture = if let RwcState::WaitForRead(v) = self.csm.shared.tail_state() {
if self.ctx.primary.is_ready_for(CommandType::Transfer, v) {
// Обновляем состояние tail-указателя на WaitForWrite
self.csm.shared.update_tail_state(RwcState::WaitForWrite);
let csm = *self.csm.shared.tail_data();
Some(csm)
} else {
None
}
} else {
None
};
// Проверяем Cascaded Shadow Maps взята из tail-указателя или нет
let (csm, idx) = match copy_texture {
// Cascaded Shadow Maps взята из tail-указателя
Some(texture) => {
let idx = self.csm.shared.tail;
// Передвигаем tail-указатель на следующее состояние
self.csm.shared.advance_tail();
(texture, idx)
}
None => {
// Берем Cascaded Shadow Maps из предыдущего состояния tail-указателя
let idx = self.csm.shared.tip_index();
let texture = *self.csm.shared.tip_data();
(texture, idx)
}
};
// Directional Light Pass
self.dir_pass
.render(globals, csm, self.csm.argument[idx], frame_idx, idx);
// Gamma Correction Pass and Swapchain Output
self.final_pass.render(swapchain_view);
}
Вот так рендерится весь кадр. Стоит упомянуть, что мой рендер очень простой и не использует никаких фишек. Тут нет frustrum culling'а, indirect draw, bindless textures. Рендер использует только draw indexed.
А недостатки?
Естественно, разработанный алгоритм, как и любое другое техническое решение, не лишен недостатков:
Большой объем передаваемой памяти
Артефакты при резком движении камеры. Cascaded Shadow Maps рендерятся относительно View‑Space. При резком повороте камеры на системах с недостаточной производительностью может быть заметно запаздывание в обновлении теней. Это происходит из‑за того, что для отрисовки используются результаты CSM с предыдущих кадров, которые уже не соответствуют текущему положению камеры

Несмотря на эти недостатки, разработанный подход демонстрирует потенциальню возможность использования multi‑GPU для повышения производительности в отдельных задачах рендеринга, таких как расчет теней.
Тестирование и анализ
Ради увеличения выборки тестирования я обновил свой компьютер и приобрел AMD Ryzen 5 7600 со встроенной графикой.
Тестирование и анализ разработанного алгоритма проводились на следующих аппаратных конфигурациях с двумя графическими процессорами:
NVIDIA GeForce RTX 3060 + AMD Radeon™ Graphics; — Стационарный ПК
NVIDIA GeForce GTX 1050 + Intel® UHD Graphics 630; — Ноутбук
NVIDIA GeForce GTX 1650 Ti + Intel® UHD Graphics; — Ноутбук
NVIDIA GeForce RTX 4070 Laptop GPU + Intel® Iris® Xe Graphics. — Ноутбук
Для оценки эффективности алгоритма в условиях различной сложности сцен были использованы следующие тестовые сцены:
Pica Pica содержит 76 274 треугольников
The town on capital isle содержит 193 485 треугольников
Bistro содержит 2 829 238 треугольников
Моей ошибкой было то, что две из выбранных сцен не сильно отличались по количеству треугольников от сцены Bistro, но я всё равно учитываю полученные результаты.
Перед основным тестированием был проведён анализ производительности отдельных этапов рендеринга при использовании одной видеокарты:

Как показано на рисунке, наибольшее время затрачивается на отрисовку теней с использованием алгоритма Cascaded Shadow Maps. Перенос этого этапа на вторую видеокарту потенциально увеличивает общую производительность рендеринга.
При тестировании производительности разработанного алгоритма на сцене «Bistro» с размерами каскадов 2048×2048 и количеством каскадов, равным 4, было зафиксировано увеличение производительности основной видеокарты на 69%:

Аналогичные тесты на сценах «The Town on Capital Isle» и «Pica Pica» показали прирост производительности основной видеокарты на 34% и 27% соответственно. Эти результаты свидетельствуют о том, что наибольший прирост производительности достигается при высокой геометрической сложности сцены. Что и логично, так как время рендеринга Cascaded Shadow Maps зависит напрямую от сложности сцены:


Рисунок ниже демонстрирует зависимость времени передачи данных между видеокартами от размера теневой карты каскадов. Как упоминалось выше, копирование на видеокартах от Intel отсутствует:

Анализ графиков показывает, что время копирования линейно зависит от размера каскадов. По моему мненинию, оптимальным вариантом является размер каскадов 2048×2048 пикселей. При увеличении размера до 4096×4096 пикселей время копирования текстуры составляет почти половину времени рендеринга Cascaded Shadow Maps.
Напоследок покажу зависимости времени выполнения рендеринга Cascaded Shadow Maps от сцены на второй видеокарте. Интересно, что наилучшие результаты показала AMD Radeon™ Graphics. Кроме того, при тестировании алгоритма не наблюдались проблемы с артефактами, описанные во втором пункте недостатков:

Небольшой фейл при сборе данных. Я изначально написал программу‑бенчер, которая запускала рендер с определенной сценой и с конфигурацией и получала данные по TCP. Всё бы ничего, но я сохранял результаты в JSON формате. Получив результаты бенчмарка со своих устройств и устройств друзей, я приступил к анализу. Я понял, что формат JSON нельзя нормально закинуть в Excel и проанализировать. Я бы мог переделать формат сохранения, но не хотел лишний раз дергать друзей, поэтому написал конвертер из JSON в CSV.
Выводы
Средний прирост производительности при использовании разработанного алгоритма составил 40%
Видеокарты, отличающиеся производительностью в несколько раз, должны взаимодействовать асинхронно
Копирование ресурса из общей памяти в локальную память необходимо делать асинхронно с помощью очереди копирования
Screen‑space техники требуют синхронного взаимодействия нескольких видеокарт для минимизации визуальных артефактов
В настоящий момент не существует инструментария для отладки и профилирования multi‑GPU рендеринга, а также CPU и GPU валидация DirectX 12 не всегда корректно обрабатывает неправильное использование API для нескольких устройств
Отличный потенциал показала встроенная видеокарта в процессоре для настольного ПК
Найти Issue, кинуть Pull Request, форкнуть, поставить звёздочку вы можете здесь.
Комментарии (16)
malyazin_2010
11.05.2025 11:28У меня есть пк с тремя дискретными вилеокартами + встроенная графика. В моих задачах 3 видеокарты работают в три раза быстрее, чем одна. Подключение к работе еще и встроенной графики особо результат не улучшает, поскольку встроенеая графика слишком слабая по сравнению с видеокартами. Мой конфиг тут: https://habr.com/ru/articles/896454/
rutexd
11.05.2025 11:28Тут другой вопрос - а зачем? Что бы все еще быстрее летало?
Современные карточки очень мощные, могут намного больше чем "много" операций производить за условный такт. Даже средне бюджетные или встроенные уже далеко не самые плохие, как может показаться.
Решать надо не проблему мощностей а проблему оптимизации, которой никто не хочет заморачиваться. Учить дизайнеров эффективно рисовать \ моделировать и программистов писать эффективный код. Вместо этого, у нас (псевдо)2д стратегии которые на минималках выдают 10-15 фпс - потому что дизайнер решил что проще обмазаться шейдерами чем потратить условно неделю-месяц на нечто более оптимальное - или накидал 500 текстурок, моделек и еще кучу эффектов в один файл облака лишь бы облако выглядело естественно, когда какой нибудь средний шутер вполне себе уверенно работает на средних на том самом встроенном железе и по графике практически не отличим от передовых "ыыыы" шыдэвров.
В качестве эксперимента - интересная статья, в качестве весьма интересного потенциала использования даже двух GPU - боже упаси. Когда нибудь вопрос 2 карточек может быть и станет актуальным но сейчас точно не то время.
з.ы. речь про геймгев.
Jijiki
11.05.2025 11:28могу больше сказать на ГЛ1(thecplusplusguy если интересно ) ) стартовый плохой не оптимизированый с кое какой анимацией (из obj) в отрисовке по list(он пока наибыстрейший на нвидии покрайней мере на низах 10 серии) на лоу карте летит по рендеру, тоесть если добавить 1 кусковые планарки(не лист, а Buffer отрисовка с только нужными шейдерами) в нужных масштабах на местность можно пока еще даже на такой железке рисовать индексируемый мир. (там придётся только понять как ускорить скелетную анимацию)(и оффлайн расчет невидимых анимационых движимых-говорящих обьектов, чтобы при входе в их зону взаимодействия с ними видеть обновленные состояния, тоесть движимые обьекты принадлежат каким-то квадрантам а квадранты - чанки статичных моделей)
Pavel_Agafonov Автор
11.05.2025 11:28Тут другой вопрос - а зачем? Что бы все еще быстрее летало?
Дополнительные миллисекунды для бюджета кадра лишними не бывают.
Решать надо не проблему мощностей а проблему оптимизации, которой никто не хочет заморачиваться. Учить дизайнеров эффективно рисовать \ моделировать и программистов писать эффективный код. Вместо этого, у нас (псевдо)2д стратегии которые на минималках выдают 10-15 фпс - потому что дизайнер решил что проще обмазаться шейдерами чем потратить условно неделю-месяц на нечто более оптимальное - или накидал 500 текстурок, моделек и еще кучу эффектов в один файл облака лишь бы облако выглядело естественно, когда какой нибудь средний шутер вполне себе уверенно работает на средних на том самом встроенном железе и по графике практически не отличим от передовых "ыыыы" шыдэвров.
Согласен. Сейчас современные игры без DLSS не вывозят стабильный и высокий фреймрейт.
rutexd
11.05.2025 11:28Согласны то вы согласны - только абзацем до вы свое согласие сводите на нет, рассказами о том что 2 гпу это решение.... Если сейчас все начнут требовать 2 гпу для очередной даже не ыыыы подделки, это будет мягко говоря победа мракобесия. Миллисекунды надо искать не посредством 2 карточки а посредством оптимизаций.
Pavel_Agafonov Автор
11.05.2025 11:28что 2 гпу это решение....
Я нигде не утверждал, что это решение всех проблем. У меня был гипотеза "Перенос рендера Cascaded Shadow Maps на вторую видеокарту может увеличить производительность". Я реализовал прототип и подтвердил свою гипотезу.
Если сейчас все начнут требовать 2 гпу для очередной даже не ыыыы подделки, это будет мягко говоря победа мракобесия.
2 гпу - это не требование, а опция. Условная галочка в настройках "задействовать вторую видеокарту" для систем, где несколько видях.
Миллисекунды надо искать не посредством 2 карточки а посредством оптимизаций.
Оптимизации могут быть разные. В данном случае это перенос рендера теней на вторую видеокарту. Если это не оптимизация, то, по вашему мнению, использование нескольких ядер ЦПУ не является оптимизацией?
Jijiki
11.05.2025 11:28тут есть нюанс игра не сцена 1, а несколько, основная нагрузка будет не на тенях а на анимациях и движимых обьектах тоесть физике, тоесть буквально как падает дождь(он же не будет падать на сквозь здания), и как происходит взаимодействие через действие, которое постоянно оппонируется зацикленной анимацией
анимация пока самая ходовая нагрузка, конечно если без анимаций, только сцена, то тени для сцены можно ограничить радиусом, в конечном счете тени можно отрубить оставив AO
в итоге нагрузка будет на физических бросках + анимация + частички
и скопление евентов
ну и чутка тумана, ну а если все технологии разворачивать то не знаю
пак мобов 40 штук с анимациями да если еще бегут, в дефолте двигаются
Alex-Freeman
11.05.2025 11:28Потому, что к примеру 2*4090 смогли бы вывезти Alan wake 2, Wukoong и тд, даже если прибавка была только 30-40%, которые 5090 не вывозит без ухудшайзеров. При этом стоили бы меньше.
Loco2k
11.05.2025 11:28Возможно ли мульти ГПУ для VR? Рендерим на каждой карте отдельный глаз. Сцена и движение камеры почти идентичны ведь.
Pavel_Agafonov Автор
11.05.2025 11:28Да, возможно. Я встречал работы, которые как раз этому посвящены. Только для VR понадобятся видеокарты с одинаковой производительностью.
arheops
11.05.2025 11:28Количество людей, что купят себе вторую видиокарту - минимально.
Потому оно не стоит того
Pavel_Agafonov Автор
11.05.2025 11:28Связка дискретная + интегрированная видеокарта встречается не мало. Наибольший прирост популярности и самая большая доля использования среди GPU за последний месяц у NVIDIA GeForce RTX 4060 Laptop GPU. https://store.steampowered.com/hwsurvey/directx/?sort=chg
arheops
11.05.2025 11:28Тут сложно. Смотрите. Очень немого компьютеров в внешней видокартой расчитаны по питанию на ОДНОВРЕМЕННОЕ использование обоих.
Многие конфигурации расчитываются с минимальным зазором. Тоесть ваша игра может ломать системы, сжигать PSU
И да, в ноутах с 4060 тоже не расчитано.
Loco2k
11.05.2025 11:28Мульти ГПУ для рендеринга у Нвидиа есть для проф использования. Например две rtx8000 с мостом nvlink тащат сцену на 96гб памяти. Только вот потом nvlink для таких карт убрали, а разрабы предлагают гонять данные по pcie.
Не проверял, но думаю что поддержка в каком-то виде таки осталась. Я именно про рендеринг в реальном времени.
Javian
11.05.2025 11:28А как изменится производительность, если подсунуть этой игре DXVK, чтобы вместо DX12 работал Vulkan ? Некоторые игры у меня не выносили такой подмены, например GTA V или Feed and Grow.
Jijiki
ну это по-сути 2 киловатта(или полтора или сколько ест 3060 к примеру а их 2) поправьте меня, ради того чтобы без идеальных 16.6 не увидеть даже 6000 на вулкане, так же поидее изза подхода на 16.6 вы включали новую поддержку отрисовски на Вулкане например mailbox? при одном из них(FIFO или mailbox) будет прирост до той точки какое может дать железо
так же есть технология наниты и глобал иллюм и меш шейдинг, и интересно как ваш подход влияет на разгрузку 3д анимаций при вулкане мы можем клеить все обьекты видемые в 1 чанк(условно в 1 кусок памяти статичный)
и еще такие моменты как интрисинки получается процессор нужен всё таки хотябы выше нижнегосреднего
тоесть можно тестировать на кубиках это быстрее по развертыванию, но реализация кубического мира сама по себе основана на кусках или 1 куске тоесть на 1 карте тестовый мир из кубиков на С++ будет летать в этом и прикол при сравнении с планарным подходом, значит можно приблизиться к чанкованной отрисовке( тоесть отрисовка индексируемых планарных квадрантов с статичными обьектами относительно позиции камеры )