material batching
This commit is contained in:
+123
-26
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user