material batching

This commit is contained in:
Jérôme Bousquié
2026-09-22 17:07:52 +02:00
parent 3a424afe8c
commit 531c43a457
9 changed files with 313 additions and 407 deletions
+123 -26
View File
@@ -15,7 +15,8 @@
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Execution Phase**: Renderer executes per-frame render loops. During this phase it iterates Scene entities
//! and draws each one by binding the appropriate Material+Mesh pair.
//! - **Performance**: Entity sorting within the render loop minimizes pipeline switches (batching by material).
//! - **Performance**: the main pass batches draws by material (Étape 18), so the pipeline +
//! texture state changes happen once per distinct material, not once per entity.
//! - **Low-Level Access**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths.
use crate::core::Context;
@@ -40,6 +41,9 @@ use crate::utils::conf::{
};
use glam::{Mat4, Vec3, Vec4};
use std::cell::Cell;
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
/// The Executor layer of the architecture. Holds shared references to Device and Queue from Context,
/// plus the surface texture format. Executes WGPU rendering commands by binding Materials and Meshes
@@ -118,6 +122,11 @@ pub struct Renderer {
/// Interior-mutable so `set_culling` can toggle it from an immutable `&Renderer` (matching the
/// Renderer's all-`&self` API). Read each frame by `render_scene` when building the cull uniforms.
cull_enabled: Cell<bool>,
/// Number of `set_pipeline` calls in the LAST `render_scene` main pass. Since Étape 18 the
/// main pass batches by material, so this equals the number of DISTINCT materials drawn that
/// frame. Interior-mutable (all-`&self` API); exposed through `debug_dump` for the
/// state-change A/B measurement (Étape 18 verification).
debug_pipeline_switches: Cell<u32>,
}
impl Renderer {
@@ -460,6 +469,7 @@ impl Renderer {
cull_bundle_bg,
matrix_object_bg,
cull_enabled: Cell::new(false),
debug_pipeline_switches: Cell::new(0),
};
// Seed the shared frame buffer with an identity camera + current unlit flag so the low-level
// `render` path (which has no window/camera) sees coherent values before `render_scene` runs.
@@ -755,8 +765,10 @@ impl Renderer {
// it reads the same matrix + draw-args buffers. No-op when shadows are off.
self.render_shadow_map(&mut encoder, scene);
// 7. Main render pass: one indirect draw per active slot. The matrix + draw-args are read via
// per-slot offsets; a culled/inactive slot's args are zero, so its draw is a no-op.
// 7. Main render pass: one indirect draw per active slot, batched by material (Étape 18).
// The matrix + draw-args are read via per-slot offsets; a culled/inactive slot's args
// are zero, so its draw is a no-op. State changes (pipeline + texture bind group @2)
// are hoisted out of the slot loop: one per DISTINCT material, not one per entity.
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"),
@@ -782,34 +794,54 @@ impl Renderer {
..Default::default()
});
for slot in scene.iter_slot_draws() {
if !slot.active {
continue; // tombstone — the GPU already zeroed this slot's draw args.
}
// The Material is resolved from the Mesh itself, falling back to the Scene's default
// material when the mesh carries none.
let material = slot
.mesh
.material()
.cloned()
.unwrap_or_else(|| scene.default_material());
let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32;
let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE;
// Batching by material (Étape 18): the material's Arc pointer is the group key — the
// same Material shares one pipeline AND one texture bind group (@2), so both state
// changes are frozen within a group. Groups appear in order of first appearance in
// stable slot order (D2), so the draw order stays deterministic frame to frame. All
// pipelines are opaque (BlendState::REPLACE), so reordering draws is visually neutral
// (D4 — if transparent blending is ever added, see the constraint in the user docs).
let slots: Vec<_> = scene.iter_slot_draws().filter(|s| s.active).collect();
// Materialize the Material Arcs first: the pointer keys below must stay valid for the
// whole pass, and `default_material()` returns a clone that would otherwise be dropped
// at the end of the closure (D1).
let materials: Vec<Arc<Material>> = slots
.iter()
.map(|s| {
s.mesh
.material()
.cloned()
.unwrap_or_else(|| scene.default_material())
})
.collect();
let keys: Vec<*const Material> = materials.iter().map(Arc::as_ptr).collect();
let groups = batch_slots(&keys);
self.debug_pipeline_switches.set(groups.len() as u32);
for group in &groups {
// The first slot of a group carries the group's material (all slots in the group
// share the same Arc pointer).
let material = &materials[group[0]];
render_pass.set_pipeline(&material.pipeline);
render_pass.set_bind_group(0, &self.frame_bind_group, &[]);
// Group 1 (dynamic): the 64-byte matrix slice for this slot.
render_pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]);
render_pass.set_bind_group(2, &material.texture_bind_group, &[]);
render_pass.set_bind_group(3, &self.shadow_bind_group, &[]);
render_pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
if let Some(index_buffer) = &slot.mesh.index_buffer {
render_pass
.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
for &pos in group {
let slot = &slots[pos];
let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32;
let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE;
// Group 1 (dynamic): the 64-byte matrix slice for this slot.
render_pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]);
render_pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
if let Some(index_buffer) = &slot.mesh.index_buffer {
render_pass.set_index_buffer(
index_buffer.slice(..),
wgpu::IndexFormat::Uint16,
);
}
render_pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset);
} else {
render_pass.draw_indirect(&self.draw_args_buffer, indirect_offset);
}
render_pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset);
} else {
render_pass.draw_indirect(&self.draw_args_buffer, indirect_offset);
}
}
}
@@ -958,6 +990,10 @@ impl Renderer {
for (i, p) in c.planes.iter().enumerate() {
eprintln!("[dbg] plane[{i}] = {p:?}");
}
eprintln!(
"[dbg] pipeline switches (this frame's main pass) = {}",
self.debug_pipeline_switches.get()
);
eprintln!("[dbg] done");
}
}
@@ -1157,3 +1193,64 @@ fn draw_entity(
pass.draw(0..mesh.num_vertices, 0..1);
}
}
/// Groups the positions of a key slice for material batching (Étape 18, D2/D5). Groups appear in
/// order of first key occurrence; indices within a group keep input order; every input index
/// appears exactly once. Pure and GPU-free, so it is unit-testable with integer keys. The `Clone`
/// bound only serves to keep an owned copy of each group's key (the real keys are `*const T`,
/// i.e. `Copy`).
fn batch_slots<K: Eq + Hash + Clone>(keys: &[K]) -> Vec<Vec<usize>> {
let mut groups: Vec<(K, Vec<usize>)> = Vec::new();
let mut index: HashMap<&K, usize> = HashMap::new();
for (i, k) in keys.iter().enumerate() {
let g = *index.entry(k).or_insert_with(|| {
groups.push((k.clone(), Vec::new()));
groups.len() - 1
});
groups[g].1.push(i);
}
groups.into_iter().map(|(_, idxs)| idxs).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_slots_groups_by_first_occurrence() {
assert_eq!(
batch_slots(&[1u32, 2, 1, 3, 2]),
vec![vec![0, 2], vec![1, 4], vec![3]]
);
}
#[test]
fn batch_slots_single_group() {
assert_eq!(batch_slots(&[7u32, 7, 7]), vec![vec![0, 1, 2]]);
}
#[test]
fn batch_slots_all_distinct() {
assert_eq!(batch_slots(&[1u32, 2, 3]), vec![vec![0], vec![1], vec![2]]);
}
#[test]
fn batch_slots_empty() {
assert_eq!(batch_slots::<u32>(&[]), Vec::<Vec<usize>>::new());
}
#[test]
fn batch_slots_each_index_exactly_once() {
let keys: Vec<u32> = (0..50).map(|i| i % 4).collect();
let groups = batch_slots(&keys);
let mut all: Vec<usize> = groups.iter().flatten().copied().collect();
all.sort();
assert_eq!(all, (0..50).collect::<Vec<_>>());
}
#[test]
fn batch_slots_deterministic_repeated() {
let keys: Vec<u32> = vec![2, 0, 1, 2, 0, 1, 3];
assert_eq!(batch_slots(&keys), batch_slots(&keys));
}
}
+3 -2
View File
@@ -8,8 +8,9 @@
// The main and shadow render passes are then 100% indirect: they read the draw slots (zero count
// = no-op) instead of a CPU-side per-entity loop.
//
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see DRAFT D12) and allocated once. Per frame the CPU
// rewrites only the transform slots and the cull uniforms; everything else is GPU-driven.
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see ARCHI_CPU_GPU.md D12) and are
// allocated once. Each frame the CPU rewrites the transform slots and cull uniforms;
// everything else is GPU-driven.
//
// GPU buffer layouts mirror the bytemuck structs in `resources::uniform` (byte-for-byte):
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B).