Uniform и bind groups
Что уже должно быть понятно:
- вершинные и индексные буферы
VertexBufferLayout, атрибуты вершин- шейдеры WGSL: структуры,
@location,@builtin
Что появится в этой главе:
- uniform-буфер: передача данных из CPU в шейдер
- bind group layout, bind group, pipeline layout
- крейт
encaseи выравнивание uniform-структур - обновление данных каждый кадр
Итог: прямоугольник с пульсирующими цветами — оттенки плавно меняются во времени
До сих пор все данные, которые получали шейдеры, были статичными. Вершинные буферы мы создавали один раз в init и больше не трогали. Но что если нужно передать в шейдер значение, которое меняется каждый кадр — например, текущее время, позицию камеры или цвет, зависящий от действий пользователя?
Вершинный буфер не подходит: он привязан к конкретным вершинам, а нам нужно значение, общее для всего вызова отрисовки. Для этого в WebGPU существует uniform-буфер — специальный GPU-буфер, доступный шейдерам на протяжении всего вызова отрисовки. Каждый кадр мы записываем в него новые данные, и шейдер тут же их видит.
Но просто создать буфер недостаточно — нужно ещё объяснить и GPU, и шейдеру, что этот буфер существует и как к нему обращаться. Эту роль выполняют bind groups — механизм привязки ресурсов в WebGPU.
Наш графический конвейер обрастает ещё одним источником данных — uniform-буфер подключается к фрагментному шейдеру через bind group:
В отличие от вершинных и индексных буферов, которые «втекают» в конвейер последовательно, uniform-буфер подключается к шейдеру напрямую — фрагментный шейдер может читать из него в любой момент своей работы.
Общая картина
В передаче данных в шейдер участвуют несколько сущностей. Вот как они связаны:
Четыре новых сущности, и каждая решает свою задачу:
| Сущность | Зачем нужна |
|---|---|
| Uniform Buffer | Хранит данные на GPU, обновляется каждый кадр |
| Bind Group Layout | Описывает форму привязки: «binding 0 — uniform-буфер, видимый во фрагментном шейдере» |
| Bind Group | Привязывает конкретный буфер к описанной форме |
| Pipeline Layout | Связывает bind group layouts с render pipeline |
Зачем четыре сущности вместо одной? Bind group layout — это «контракт», описывающий формат, но не конкретные данные. Bind group — «экземпляр контракта» с реальным буфером. Это разделение позволяет менять данные (создавать новый bind group), не пересоздавая pipeline.
Uniform-буфер и выравнивание
Добавим крейт encase в зависимости (Cargo.toml) — он автоматизирует выравнивание данных для uniform-буферов:
[dependencies]
framework = { path = "../../../framework" }
wgpu.workspace = true
winit.workspace = true
bytemuck.workspace = true
encase.workspace = trueНачнём с Rust-структуры, данные которой будем передавать в шейдер:
use encase::ShaderType;
#[derive(ShaderType)]
struct ShaderUniforms {
time: f32,
}Единственное поле — time: f32, количество секунд с момента запуска. Передавать будем каждый кадр.
Крейт encase решает проблему выравнивания. WGSL требует, чтобы структуры в адресном пространстве uniform были выровнены по 16 байт. Наша структура занимает 4 байта, но GPU ожидает 16 — остальные 12 байт должны быть заполнены паддингом. encase делает это автоматически: ShaderUniforms::min_size() возвращает 16, а при записи в буфер write() добавляет нужное количество нулей.
WGSL видит эту память как структуру Uniforms { time: f32 } — паддинг прозрачен для шейдера, но обязателен для корректного чтения GPU. Если бы мы попытались передать только 4 байта без выравнивания, результат был бы непредсказуемым.
let uniform_buffer = ctx.device.create_buffer(&BufferDescriptor {
label: Some("Uniform Buffer"),
size: ShaderUniforms::min_size().into(),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});Два флага usage:
UNIFORM— буфер доступен шейдерам через адресное пространствоuniformCOPY_DST— разрешает запись в буфер со стороны CPU черезqueue.write_buffer()
Почему бы не использовать bytemuck, как для вершин?
Для вершинных буферов мы используем bytemuck::cast_slice, потому что VertexBufferLayout позволяет явно задать смещение и размер каждого атрибута. Мы сами контролируем, как данные расположены в памяти.
Uniform-буферы — другое дело. WGSL требует 16-байтового выравнивания структур в адресном пространстве uniform, и правила неочевидны: vec3<f32> занимает 12 байт, но выравнивается на 16; матрицы выравниваются поколоночно; массивы дополняются до кратного 16. Считать паддинг вручную для сложных структур мучительно и чревато ошибками.
encase через derive-макрос ShaderType автоматически вычисляет правильное выравнивание и размеры, генерируя тот же layout, который ожидает WGSL. В workspace уже есть glam с фичей encase — векторные и матричные типы реализуют ShaderType из коробки.
В этом руководстве мы используем:
- bytemuck для вершинных данных (где layout контролируется вручную)
- encase для uniform-буферов (где WGSL диктует правила выравнивания)
Размер uniform-буфера ограничен
Uniform-буферы не подходят для хранения больших объёмов данных. По умолчанию максимальный размер привязки — 16 КБ (max_uniform_buffer_binding_size), хотя адаптер может поддерживать до 256 МБ. Для массивов текстур, больших наборов матриц или целых сцен нужны другие типы ресурсов: storage-буферы (var<storage>) — они не имеют таких жёстких ограничений и доступны для чтения и записи из шейдера. Мы познакомимся с ними в одной из следующих глав.
Bind Group Layout — контракт привязки
Bind group layout описывает, какие ресурсы доступны шейдерам, но не привязывает конкретные буферы:
let bind_group_layout =
ctx.device
.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("Bind Group Layout"),
entries: &[BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: Some(ShaderUniforms::min_size()),
},
count: None,
}],
});Разберём поля BindGroupLayoutEntry:
binding: 0— индекс привязки внутри группы. В шейдере это@binding(0)visibility: ShaderStages::FRAGMENT— ресурс доступен только фрагментному шейдеру. Если бы нужно было использовать uniform и в вершинном шейдере (например, для матрицы проекции), указали быShaderStages::VERTEX | ShaderStages::FRAGMENTty: BindingType::Buffer { ty: Uniform, ... }— тип ресурса: uniform-буферhas_dynamic_offset: false— не используем динамические смещения (продвинутая возможность, понадобится позже)count: None— один буфер, а не массив
Bind Group — конкретные данные
Bind group связывает layout с реальным буфером:
let bind_group = ctx.device.create_bind_group(&BindGroupDescriptor {
label: Some("Bind Group"),
layout: &bind_group_layout,
entries: &[BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});as_entire_binding() привязывает весь буфер целиком — от начала до конца. Если бы мы хотели привязать только часть буфера, использовали бы slice(..) с явным диапазоном.
Pipeline Layout
До этого мы передавали layout: None при создании pipeline, и wgpu автоматически генерировал пустой layout. Теперь, когда у нас есть bind group layout, нужно указать его явно:
let pipeline_layout = ctx
.device
.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("Pipeline Layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});bind_group_layouts — массив описаний групп привязки. Первый элемент соответствует @group(0) в шейдере, второй — @group(1) и так далее. Пока у нас одна группа с одной привязкой.
immediate_size: 0 — размер области для immediate-данных (замена push constants из Vulkan; продвинутая тема, нам пока не нужна).
При создании render pipeline теперь указываем явный layout:
let pipeline = ctx.device.create_render_pipeline(&RenderPipelineDescriptor {
// ...
layout: None,
layout: Some(&pipeline_layout),
// ...
});Сторона WGSL
В шейдере объявляем структуру и привязываем её к группе и индексу привязки:
struct Uniforms {
time: f32,
};
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;@group(0)— соответствует индексу вbind_group_layouts(первый элемент = 0)@binding(0)— соответствуетbinding: 0вBindGroupLayoutEntryvar<uniform>— переменная в uniform-адресном пространстве
WGSL определяет несколько адресных пространств для переменных:
| Адресное пространство | Доступ | Назначение |
|---|---|---|
uniform | только чтение | Данные из uniform-буфера, ограничение ~16 КБ на привязку |
storage | чтение и/или запись | Произвольные данные, без жёсткого ограничения размера |
private | чтение/запись | Локальные переменные шейдера (аналог стека) |
workgroup | чтение/запись | Разделяемая память между потоками compute shader |
Мы будем использовать uniform для всех глав до раздела про particles, где появится var<storage> для хранения массива частиц, и compute passes, где появятся storage-текстуры.
Фрагментный шейдер использует time для создания пульсирующего эффекта:
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(input.color, 1.0);
let t = uniforms.time;
let r = input.color.r * (0.5 + 0.5 * sin(t));
let g = input.color.g * (0.5 + 0.5 * sin(t + 2.094));
let b = input.color.b * (0.5 + 0.5 * sin(t + 4.189));
return vec4<f32>(r, g, b, 1.0);
}Каждый цветовой канал модулируется синусоидой с фазовым сдвигом 2π/3 (≈ 2.094 радиана) между каналами. Выражение 0.5 + 0.5 * sin(t) даёт значения в диапазоне [0, 1], поэтому цвет никогда не уходит в отрицательные значения или пересыщение. Результат — плавная «волна» цвета, пробегающая по прямоугольнику.
Обновляем данные каждый кадр
Осталось записывать новое значение time в uniform-буфер на каждом кадре:
struct AnimatedQuad {
// ...
uniform_buffer: Buffer,
bind_group: BindGroup,
start_time: Instant,
}
impl Example for AnimatedQuad {
fn render(&mut self, ctx: &GpuContext, view: &TextureView, encoder: &mut CommandEncoder) {
let time = self.start_time.elapsed().as_secs_f32();
let mut uniform_data = encase::UniformBuffer::new(Vec::new());
uniform_data.write(&ShaderUniforms { time }).unwrap();
ctx.queue.write_buffer(&self.uniform_buffer, 0, &uniform_data.into_inner());
// ... render pass ...
}
}Три шага каждый кадр:
- Вычисляем
timeчерезInstant::elapsed() - Сериализуем структуру через
encase::UniformBuffer::write()— получаемVec<u8>с правильным выравниванием - Записываем байты в GPU-буфер через
queue.write_buffer()
queue.write_buffer() — удобный метод для небольших обновлений: он копирует данные в staging-буфер и отправляет команду копирования в GPU-очередь. Данные становятся доступны шейдерам после queue.submit() — это происходит в framework сразу после вызова render().
write_buffer не пишет в GPU-буфер напрямую — данные сначала попадают во временный staging-буфер в памяти, доступной и CPU, и GPU. При queue.submit() команда копирования из staging-буфера в настоящий uniform-буфер ставится в очередь вместе с остальными командами рендера.
StagingBelt
Каждый вызов write_buffer создаёт новый staging-буфер. Для одного-двух uniform-буферов это незаметно, но при сотнях обновлений за кадр аллокации становятся накладными. В wgpu есть queue::Queue::write_buffer_with и утилита StagingBelt из wgpu::util — она переиспользует один staging-буфер вместо создания новых.
В render pass добавляется одна новая строка — привязка bind group:
rpass.set_pipeline(&self.pipeline);
rpass.set_bind_group(0, &self.bind_group, &[]);
rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
rpass.set_index_buffer(self.index_buffer.slice(..), IndexFormat::Uint16);
rpass.draw_indexed(0..6, 0, 0..1);set_bind_group(0, ...) привязывает bind group к индексу 0 — тот же индекс, что @group(0) в шейдере. Пустой срез &[] — массив динамических смещений (не используем).
Порядок bind groups имеет значение
Самому wgpu всё равно, в каком порядке расположены bind groups — нумерация @group(0), @group(1) и т.д. выбирается нами произвольно. Но нижележащие API (Vulkan, DirectX, Metal) оптимизируют переключение ресурсов, и для этого группы рекомендуется располагать по частоте обновления — от редких к частым:
| Индекс | Частота изменения | Пример содержимого |
|---|---|---|
| 0 | Один раз / редко | Статические текстуры, данные сцены |
| 1 | На кадр | Матрица проекции, параметры камеры |
| 2 | На материал | Текстура материала, параметры света |
| 3 | На объект | Модельная матрица, цвет объекта |
Что получилось
Типичные ошибки
- 16 KB лимит на uniform-буфер (
max_uniform_buffer_binding_size) — превысите, получите ошибку sizeбуфера должен быть не меньшеShaderUniforms::min_size()— иначе GPU прочитает мусор- Забыть
COPY_DSTв usage —write_bufferвернёт ошибку: буфер не принимает запись visibilityне включает нужный шейдер — переменная в WGSL будет недоступна, ошибка компиляции шейдера
Тот же прямоугольник из прошлой главы, но теперь его цвета плавно пульсируют: красный канал нарастает и спадает, затем зелёный, затем синий — создавая эффект «цветовой волны». Это первый пример данных, которые меняются каждый кадр под управлением CPU.
Этот паттерн — uniform-буфер, обновляемый каждый кадр — ляжет в основу всего последующего: матриц камеры, параметров освещения, времени для анимаций. Bind groups будут становиться сложнее, но принцип останется тем же.
Попробуем
- Изменим формулу в шейдере: заменим
sin(t)наcos(t * 2.0)— как изменится характер анимации? - Добавим в
ShaderUniformsвторое полеspeed: f32и будем передавать его из Rust. В шейдере умножимtнаspeed— так можно управлять скоростью анимации из кода