Instancing
Что уже должно быть понятно:
- камера, view/projection матрицы
- depth buffer, bind groups
- вершинные и индексные буферы
Что появится в этой главе:
- instance buffer с model-матрицами
VertexStepMode::Instance— данные на экземпляр, а не на вершину- один
draw_indexedдля сотни объектов - разделение uniform:
view_projобщий,model— на экземпляр
Итог: сетка 5×5×5 (125) кубов, нарисованных одним вызовом draw
В главе про depth buffer мы рисовали три куба тремя bind groups и тремя draw_indexed. Каждый куб имел свой uniform-буфер с MVP-матрицей. Это работает, но при сотнях или тысячах объектов приводит к двум проблемам: много bind groups (каждая — аллокация на GPU) и много вызовов отрисовки (каждый — накладные расходы на CPU).
Instancing решает обе проблемы: один bind group, один draw_indexed, а данные для каждого экземпляра передаются через отдельный буфер — instance buffer.
Идея: разделяем MVP
До сих пор мы передавали одну матрицу mvp = projection × view × model. При instancing view и projection общие для всех экземпляров, а model-матрица у каждого своя. Разделим их:
- Uniform-буфер:
view_proj = projection × view— одни на весь кадр - Instance buffer:
model— на каждый экземпляр
Шейдер домножает:
output.position = uniforms.view_proj * model * vec4<f32>(input.position, 1.0);Instance buffer
Instance buffer похож на вершинный — те же данные, другое значение step_mode:
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct InstanceData {
model: [[f32; 4]; 4],
}[[f32; 4]; 4] — это 4×4 матрица в column-major порядке. Mat4::to_cols_array_2d() из glam возвращает именно его.
Почему не Mat4 напрямую? InstanceData используется с bytemuck::cast_slice для записи в GPU-буфер. Для этого нужен трейт Pod (plain old data), а Mat4 из glam не реализует Pod. [[f32; 4]; 4] — массив простых типов, Pod реализован автоматически. Для uniform-буферов, где Pod не нужен, используется encase::ShaderType — и там Mat4 работает напрямую.
Описание буфера для pipeline:
fn desc() -> VertexBufferLayout<'static> {
VertexBufferLayout {
array_stride: size_of::<InstanceData>() as BufferAddress,
step_mode: VertexStepMode::Instance,
attributes: &Self::ATTRIBUTES,
}
}VertexStepMode::Instance — GPU считывает один элемент instance buffer на каждый экземпляр, а не на каждую вершину. Для 125 экземпляров будет считано 125 элементов, каждый из которых используется для всех 36 вершин куба.
Данные экземпляров
Генерируем сетку 5×5×5 из 125 кубов:
const GRID_SIZE: usize = 5;
const NUM_INSTANCES: usize = GRID_SIZE * GRID_SIZE * GRID_SIZE;
fn generate_instances() -> Vec<InstanceData> {
let mut instances = Vec::with_capacity(NUM_INSTANCES);
let offset = GRID_SIZE as f32 * 0.5;
for x in 0..GRID_SIZE {
for y in 0..GRID_SIZE {
for z in 0..GRID_SIZE {
let index = x * GRID_SIZE * GRID_SIZE + y * GRID_SIZE + z;
let pos = Vec3::new(
x as f32 - offset + 0.5,
y as f32 - offset + 0.5,
z as f32 - offset + 0.5,
);
let rotation = Mat4::from_rotation_y(index as f32 * 0.5);
let scale = 0.8 + 0.4 * ((index as f32) % 5.0) / 5.0;
let scale_mat = Mat4::from_scale(Vec3::splat(scale));
let model = Mat4::from_translation(pos) * rotation * scale_mat;
instances.push(InstanceData {
model: model.to_cols_array_2d(),
});
}
}
}
instances
}Сдвиг x - offset + 0.5 центрирует сетку вокруг начала координат. Каждый экземпляр получает:
rotation— поворот вокруг Y, угол зависит от линейного индекса (index * 0.5радиан), чтобы кубы были повёрнуты по-разномуscale— размер от 0.8 до 1.2, чередующийся по шаблону:0.8 + 0.4 * (index % 5) / 5scale_mat— матрица масштаба из скаляра черезVec3::splat
Итоговая model-матрица: translation * rotation * scale — порядок стандартный (масштаб → поворот → сдвиг при чтении справа налево).
Буфер создаётся с флагом COPY_DST, чтобы обновлять данные экземпляров каждый кадр:
let instance_buffer = ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Instance Buffer"),
contents: bytemuck::cast_slice(&instances),
usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
});Uniform с view_proj
Вместо MVP храним только view_proj:
#[derive(ShaderType)]
struct ShaderUniforms {
view_proj: Mat4,
}Uniform-буфер обновляется каждый кадр — view_proj зависит от позиции камеры и соотношения сторон:
let view_proj = projection * view_mat;
let mut uniform_data = encase::UniformBuffer::new(Vec::new());
uniform_data.write(&ShaderUniforms { view_proj }).unwrap();
ctx.queue
.write_buffer(&self.uniform_buffer, 0, &uniform_data.into_inner());Шейдер
Вершинный шейдер принимает данные экземпляра как дополнительные атрибуты со location(2) по location(5):
struct InstanceInput {
@location(2) model_col0: vec4<f32>,
@location(3) model_col1: vec4<f32>,
@location(4) model_col2: vec4<f32>,
@location(5) model_col3: vec4<f32>,
};WGSL не позволяет передать mat4x4 через атрибуты напрямую, поэтому передаём по столбцам и собираем:
@vertex
fn vs_main(input: VertexInput, instance: InstanceInput) -> VertexOutput {
let model = mat4x4<f32>(
instance.model_col0,
instance.model_col1,
instance.model_col2,
instance.model_col3,
);
var output: VertexOutput;
output.position = uniforms.view_proj * model * vec4<f32>(input.position, 1.0);
output.uv = input.uv;
return output;
}Два параметра у vs_main: input — данные вершины (шаг Vertex), instance — данные экземпляра (шаг Instance). GPU автоматически комбинирует их.
Pipeline: два vertex buffer layout
Pipeline принимает два описания буферов:
vertex: VertexState {
module: &shader_module,
entry_point: Some("vs_main"),
buffers: &[Vertex::desc(), InstanceData::desc()],
compilation_options: PipelineCompilationOptions::default(),
},Vertex::desc() — slot 0 (вершины), InstanceData::desc() — slot 1 (экземпляры). Slot определяется порядком в массиве, а не полем shader_location — shader_location указывает на @location(N) в шейдере.
Отрисовка
Один bind group, один вызов:
rpass.set_pipeline(&self.pipeline);
rpass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
rpass.set_vertex_buffer(1, self.instance_buffer.slice(..));
rpass.set_index_buffer(self.index_buffer.slice(..), IndexFormat::Uint16);
rpass.set_bind_group(0, &self.bind_group, &[]);
rpass.draw_indexed(0..36, 0, 0..NUM_INSTANCES as u32);Третий параметр draw_indexed — количество экземпляров. 0..36 — индексы одного куба, 0..NUM_INSTANCES — 125 экземпляров. GPU нарисует 36 × 125 = 4500 треугольников за один вызов.
Что получилось
Типичные ошибки
step_mode: VertexStepMode::Instanceзабыли — GPU будет читать instance buffer как вершинный, данные сместятсяdraw_indexed(0..36, 0, 0..0)— третий параметр 0 = 0 экземпляров = ничего не нарисуется[[f32; 4]; 4]в column-major —to_cols_array_2d()даёт правильный порядок,to_rows_array_2d()— нет
125 кубов в виде сетки 5×5×5. Камера свободно перемещается между ними — WASD, мышь (правая кнопка), Space/Shift. Все кубы нарисованы одним draw_indexed.
Попробуем
- Увеличить
GRID_SIZEдо 10 — 1000 кубов, по-прежнему один draw call - Добавить поворот в model-матрицу:
Mat4::from_translation(pos) * Mat4::from_rotation_y(angle) - Изменить масштаб части экземпляров:
Mat4::from_scale(Vec3::splat(0.5))