GPU culling

This commit is contained in:
Jérôme Bousquié
2026-09-22 15:48:15 +02:00
parent 3dd372410f
commit 3a424afe8c
25 changed files with 2155 additions and 177 deletions
+532 -89
View File
@@ -20,27 +20,30 @@
use crate::core::Context;
use crate::core::Frame;
use crate::math::Transform;
use crate::math::Frustum;
use crate::pipeline::{
DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout,
create_shadow_uniform_layout, create_uniform_bind_group_layouts,
};
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE};
use crate::resources::uniform::{
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE,
MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE,
TRANSFORM_SLOT_SIZE, TransformSlot,
};
use crate::resources::{
Camera, FrameUniforms, Lights, MAX_LIGHTS, Material, Mesh, ObjectUniform, ShadowUniform,
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
};
use crate::scene::Scene;
use crate::utils::conf::{
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, MAX_ENTITIES, SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE,
SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
};
use glam::{Mat4, Vec3, Vec4};
use std::cell::RefCell;
use std::collections::HashMap;
use std::cell::Cell;
/// 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
/// into RenderPasses during each frame. Does not own raw hardware resources (they are Arc-cloned from Context).
///
/// Since Step 3 every pipeline declares the two uniform bind groups (frame @0 + object @1), the
/// Renderer owns the matching GPU buffers and `BindGroup`s and binds them around every draw call.
pub struct Renderer {
@@ -58,8 +61,6 @@ pub struct Renderer {
/// Depth attachment view used by both render passes (`render`, `render_scene`). Format
/// `DEPTH_FORMAT` (Depth32Float) — matches every pipeline's `DepthStencilState` (D1).
depth_view: wgpu::TextureView,
/// Bind group layout for the per-object uniforms (group 1) — must match every pipeline layout.
object_layout: wgpu::BindGroupLayout,
/// Shared per-frame uniform buffer handle — kept so the camera matrices can be rewritten each
/// frame (`render_scene`) and shipped to the GPU before the frame bind group is used.
frame_buffer: wgpu::Buffer,
@@ -67,10 +68,6 @@ pub struct Renderer {
frame_bind_group: wgpu::BindGroup,
/// Shared per-object bind group (identity model) used by the low-level `render` path.
shared_object_bind_group: wgpu::BindGroup,
/// Per-entity object uniform buffers + bind groups, lazily created on first encounter and keyed by
/// entity label. Needed because `render_scene(&self, &Scene)` is immutable; the model matrix is
/// rewritten each frame for every entity.
object_cache: RefCell<HashMap<String, (wgpu::Buffer, wgpu::BindGroup)>>,
/// Flat (unlit) rendering flag, exposed via [`Renderer::set_unlit`]. When true, `options.x` of the
/// `FrameUniforms` is set to 1 so the `standard` shader returns vertex colors as-is — flat 2D
/// rendering is thus a special case of the 3D lit path (DRAFT Step 5). Defaults to `false` (lit).
@@ -91,6 +88,36 @@ pub struct Renderer {
shadow_uniform_bind_group: wgpu::BindGroup,
/// Depth-only pipeline rendering the scene from the shadow light's point of view (D4).
shadow_pipeline: wgpu::RenderPipeline,
// ---- Phase 3 (Step 15) — GPU-driven rendering: compute pipelines + slot buffers + bind groups ----
/// Compute pipeline deriving per-entity world matrices from the transform buffer (Step 15.5).
compute_matrices_pipeline: wgpu::ComputePipeline,
/// Compute pipeline culling entities + filling the indirect draw args (Step 15.6).
cull_pipeline: wgpu::ComputePipeline,
/// GPU world-matrix slots (`STORAGE | COPY_DST`), written by `compute_matrices`, bound per-slot
/// via `matrix_object_bg`.
matrix_buffer: wgpu::Buffer,
/// GPU transform slots (`STORAGE | COPY_DST`), read by both compute passes; rewritten by the CPU each frame.
transform_buffer: wgpu::Buffer,
/// GPU local-space bounding boxes (`STORAGE | COPY_DST`), read by `cull`; uploaded once per mesh set.
bbox_buffer: wgpu::Buffer,
/// GPU indirect draw args (`STORAGE | INDIRECT`), written by `cull`, read by the indirect renders.
draw_args_buffer: wgpu::Buffer,
/// GPU cull uniforms (`UNIFORM | COPY_DST`): frustum planes + control flags; rewritten each frame.
cull_uniform_buffer: wgpu::Buffer,
/// `compute_matrices`/`cull` group 0 (transform buffer, storage read) — shared by both compute passes.
transform_bg: wgpu::BindGroup,
/// `compute_matrices` group 1 (matrix buffer, storage read_write).
matrices_bg: wgpu::BindGroup,
/// `cull` group 2 (cull uniforms + bboxes + draw args).
cull_bundle_bg: wgpu::BindGroup,
/// Shared object bind group (group 1, dynamic) binding ONE 64-byte matrix slice of the GPU matrix
/// buffer — every entity's render/shadow draw binds this with a per-slot dynamic offset
/// (`slot_index * MAT_SLOT_SIZE`, 256-aligned) to select its slot.
matrix_object_bg: wgpu::BindGroup,
/// Whether GPU frustum culling is enabled (off by default until validated, Step 15.6).
/// 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>,
}
impl Renderer {
@@ -204,17 +231,216 @@ impl Renderer {
});
let shadow_pipeline = build_shadow_pipeline(&device, &object_layout);
// Phase 3 (Step 15) — GPU-driven rendering. One compute shader module with two entry points
// (`compute_matrices`, `cull`); a single explicit 3-group pipeline layout is shared by both
// pipelines so they bind the same transforms / matrices / cull buffers (DRAFT Step 15.5).
let gpu_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-driven compute shader"),
source: wgpu::ShaderSource::Wgsl(GPU_DRIVEN_SHADER.into()),
});
// Group 0: transform slots (storage read). Group 1: world matrices (storage read_write).
let gpu_transforms_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu transforms layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let gpu_matrices_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu matrices layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
// Group 2: cull uniforms (uniform) + bounding boxes (storage read) + draw args (storage rw).
let gpu_cull_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu cull layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let gpu_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu pipeline layout"),
bind_group_layouts: &[
Some(&gpu_transforms_layout),
Some(&gpu_matrices_layout),
Some(&gpu_cull_layout),
],
immediate_size: 0,
});
let compute_matrices_pipeline =
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("compute_matrices pipeline"),
layout: Some(&gpu_pipeline_layout),
module: &gpu_shader,
entry_point: Some("compute_matrices"),
compilation_options: Default::default(),
cache: None,
});
let cull_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("cull pipeline"),
layout: Some(&gpu_pipeline_layout),
module: &gpu_shader,
entry_point: Some("cull"),
compilation_options: Default::default(),
cache: None,
});
// Fixed-capacity slot buffers (allocated once). Transform + cull-uniform buffers are rewritten
// by the CPU each frame; matrix + draw-args buffers are GPU-written; the bbox buffer is uploaded
// once per mesh set.
let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU transform slots"),
size: MAX_ENTITIES as u64 * TRANSFORM_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the slots back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let matrix_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU world matrices"),
size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::UNIFORM
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU bounding boxes"),
size: MAX_ENTITIES as u64 * BBOX_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the boxes back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let draw_args_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU indirect draw args"),
size: MAX_ENTITIES as u64 * DRAW_SLOT_SIZE,
// COPY_SRC: lets `debug_dump` read the GPU-written args back via copy + map.
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::INDIRECT
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let cull_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU cull uniforms"),
size: CULL_UNIFORMS_SIZE,
usage: wgpu::BufferUsages::UNIFORM
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
// Bind groups against the explicit layouts. `transform_bg` is shared by both compute passes
// (group 0); `matrices_bg` by `compute_matrices` (group 1); `cull_bundle_bg` by `cull`
// (group 2). `matrix_object_bg` uses the render pipeline's dynamic object layout (group 1) and
// binds a single 64-byte slice of the matrix buffer — per-entity draws select the slice's 256-byte
// slot via a dynamic offset (a whole-buffer binding would cap the dynamic offset at 0).
let transform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu transforms bind group"),
layout: &gpu_transforms_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: transform_buffer.as_entire_binding(),
}],
});
let matrices_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu matrices bind group"),
layout: &gpu_matrices_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: matrix_buffer.as_entire_binding(),
}],
});
let cull_bundle_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu cull bundle bind group"),
layout: &gpu_cull_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: cull_uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: bbox_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: draw_args_buffer.as_entire_binding(),
},
],
});
let matrix_object_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("GPU matrix object bind group"),
layout: &object_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
// Bind ONE 64-byte matrix (not the whole buffer) so the per-slot dynamic offset can
// slide across the 256-byte slots. The offset is `slot_index * MAT_SLOT_SIZE` (256-aligned).
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &matrix_buffer,
offset: 0,
// One 64-byte matrix (OBJECT_UNIFORM_SIZE is a non-zero const, so the unwrap is safe).
size: Some(std::num::NonZeroU64::new(OBJECT_UNIFORM_SIZE).unwrap()),
}),
}],
});
let renderer = Self {
queue,
device,
format,
_depth_texture: depth_texture,
depth_view,
object_layout,
frame_buffer,
frame_bind_group,
shared_object_bind_group,
object_cache: RefCell::new(HashMap::new()),
unlit: false,
_shadow_texture: shadow_texture,
shadow_view,
@@ -222,6 +448,18 @@ impl Renderer {
shadow_uniform_buffer,
shadow_uniform_bind_group,
shadow_pipeline,
compute_matrices_pipeline,
cull_pipeline,
matrix_buffer,
transform_buffer,
bbox_buffer,
draw_args_buffer,
cull_uniform_buffer,
transform_bg,
matrices_bg,
cull_bundle_bg,
matrix_object_bg,
cull_enabled: Cell::new(false),
};
// 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.
@@ -352,7 +590,7 @@ impl Renderer {
-light.position_dir.y,
-light.position_dir.z,
),
crate::resources::LightType::Spot { .. } => {
crate::resources::LightType::Spot => {
Vec3::new(light.dir_angle.x, light.dir_angle.y, light.dir_angle.z)
}
crate::resources::LightType::Point => return None,
@@ -423,23 +661,22 @@ impl Renderer {
material,
&self.frame_bind_group,
&self.shared_object_bind_group,
0, // object offset 0 — the identity object buffer (low-level path, no slot).
&self.shadow_bind_group,
);
}
self.queue.submit(std::iter::once(encoder.finish()));
}
/// Renders every entity in `scene` into the given color view within a single batched render pass.
/// This avoids allocating a separate encoder and render pass per entity (which the low-level
/// `render` does), minimizing GPU submissions. Called automatically each frame by the default
/// `AppHandler::render` through `App::render_scene`.
/// Renders every entity in `scene` into the given color view, fully GPU-driven (Phase 3, Step 15).
/// Per frame the CPU rewrites only the transform slots + cull uniforms; the GPU then derives the
/// world matrices (`compute_matrices`), culls + fills the indirect draw args (`cull`), and the
/// main + shadow render passes are 100% indirect (a culled/inactive slot's args are zero → a no-op
/// draw). This removes the CPU-side per-entity loop from the render hot path.
/// Inputs: view — the frame's texture view color attachment; scene — the scene whose entities are
/// drawn; aspect — the viewport aspect ratio (width/height), used to build the camera's perspective
/// projection.
///
/// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU
/// receives the active camera's view/projection matrices and position for this frame (Step 4.3).
/// drawn; aspect — the viewport aspect ratio (width/height) for the camera's perspective projection.
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
// 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags).
self.write_frame_uniforms(
scene.camera(),
scene.lights(),
@@ -448,17 +685,78 @@ impl Renderer {
scene.shadow_caster(),
);
// 2. Rewrite the GPU transform slots — the single source of truth for world matrices, and the
// only per-entity CPU→GPU copy each frame (packed TRS, 64 B per slot).
let transform_slots = scene.packed_transform_slots();
self.queue.write_buffer(
&self.transform_buffer,
0,
bytemuck::cast_slice(&transform_slots),
);
// 3. Upload the local-space bounding boxes (small; the mesh set is static in practice, but
// re-uploading each frame keeps the mesh-index → bbox mapping correct if meshes are added).
let bboxes = scene.mesh_bboxes();
self.queue
.write_buffer(&self.bbox_buffer, 0, bytemuck::cast_slice(&bboxes));
// 4. Compute the view frustum from the camera's view-projection and write the cull uniforms
// (six unit planes + the num_slots / culling control flags).
let camera = scene.camera();
let view_proj = camera.projection_matrix(aspect) * camera.view_matrix();
let frustum = Frustum::from_view_proj(&view_proj);
let cull_uniforms =
CullUniforms::from_frustum(&frustum, scene.num_slots() as u32, self.cull_enabled.get());
self.queue.write_buffer(
&self.cull_uniform_buffer,
0,
bytemuck::bytes_of(&cull_uniforms),
);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("scene encoder"),
});
// Step 14 (DRAFT 3.2): run the depth-only shadow pass first when a light is configured to
// cast shadows (D4). It populates `shadow_view` on the shared encoder; the main pass below
// then samples it via `shadow_bind_group`. `render_shadow_map` no-ops when shadows are off.
// 5. Dispatch the two compute passes (Step 15.5): `compute_matrices` derives each entity's
// world matrix into the matrix buffer, then `cull` fills the indirect draw args (a zero
// count for a culled/inactive slot). Both read the transform buffer written above.
let workgroups = MAX_ENTITIES.div_ceil(GPU_WORKGROUP_SIZE);
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("compute_matrices pass"),
timestamp_writes: None,
});
// Bind ALL THREE groups: the shared layout has a non-null bind group at every index,
// so WebGPU requires each to be set even when the entry point doesn't read it. The
// cull bundle (group 2) is unused by this entry point but must still be bound.
pass.set_pipeline(&self.compute_matrices_pipeline);
pass.set_bind_group(0, &self.transform_bg, &[]);
pass.set_bind_group(1, &self.matrices_bg, &[]);
pass.set_bind_group(2, &self.cull_bundle_bg, &[]);
pass.dispatch_workgroups(workgroups, 1, 1);
}
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("cull pass"),
timestamp_writes: None,
});
// Same: bind all three. Group 1 (matrices) is unused by the cull entry point (it
// computes the world-space centre from the transform directly) but must be bound.
pass.set_pipeline(&self.cull_pipeline);
pass.set_bind_group(0, &self.transform_bg, &[]);
pass.set_bind_group(1, &self.matrices_bg, &[]);
pass.set_bind_group(2, &self.cull_bundle_bg, &[]);
pass.dispatch_workgroups(workgroups, 1, 1);
}
// 6. Run the depth-only shadow pass (indirect) first when a light casts shadows (DRAFT 3.2/D4);
// 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.
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"),
@@ -484,42 +782,201 @@ impl Renderer {
..Default::default()
});
// Step 7 (DRAFT 7.3.4): the Material is resolved from the Mesh itself, falling back
// to the Scene's default material when the mesh carries none.
for (label, mesh, transform) in scene.iter_entities() {
let material = mesh
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_bind_group = self.object_bind_group_for(label, transform);
draw_entity(
&mut render_pass,
mesh,
&material,
&self.frame_bind_group,
&object_bind_group,
&self.shadow_bind_group,
);
let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32;
let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE;
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);
}
render_pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset);
} else {
render_pass.draw_indirect(&self.draw_args_buffer, indirect_offset);
}
}
}
self.queue.submit(std::iter::once(encoder.finish()));
}
/// Debug helper (Phase 3 black-window investigation): reads back the first `n` slots of the
/// transform / matrix / indirect-draw-args / bbox buffers (plus the cull uniforms) and prints
/// them to stderr. Call it from a render callback *after* `render_scene` so the compute
/// passes of the current frame have been submitted.
///
/// **Synchronous on purpose** (blocks the calling thread until every staging buffer is read
/// back and unmapped). Two wgpu-core rules make this necessary (both cost a frozen render
/// loop if violated — observed empirically with this very tool):
///
/// 1. `Queue::write_buffer` on a buffer with a **pending map** fails with
/// `TransferError::BufferNotAvailable` (and `render_scene` writes the transform / bbox /
/// cull buffers every frame). A detached-thread readback that leaves its maps pending
/// when the next frame starts corrupts/skips that frame (demo froze after 3 async dumps).
/// 2. Map callbacks are only fired by the queue `maintain`, which runs **inside**
/// `Queue::submit` — a thread blocked waiting on its own callbacks can never trigger it
/// (demo froze on the first purely-synchronous dump).
///
/// This implementation closes both loops itself: it submits the copies, requests the maps,
/// then pumps the queue with empty submits (each one runs a `maintain` and fires whatever
/// callbacks are due) until every map has completed, and only then unmaps. At the point it
/// returns, all staging buffers are `Idle` again, so the next frame's `write_buffer` calls
/// are safe. Each dump briefly stalls the render loop (a few ms).
#[doc(hidden)]
pub fn debug_dump(&self, n: u32) {
let device = self.device.clone();
let queue = self.queue.clone();
let matrix_buf = self.matrix_buffer.clone();
let draw_args_buf = self.draw_args_buffer.clone();
let cull_buf = self.cull_uniform_buffer.clone();
let transform_buf = self.transform_buffer.clone();
let bbox_buf = self.bbox_buffer.clone();
let n = n.min(MAX_ENTITIES as u32).max(1);
{
// Creates a MAP_READ staging buffer and records a copy of `size` bytes from `src`
// into the caller's encoder. NOTE: the map must only be requested AFTER the submit —
// wgpu-core rejects a submission that references a buffer in a non-idle map state.
fn readback(
device: &wgpu::Device,
src: &wgpu::Buffer,
size: u64,
enc: &mut wgpu::CommandEncoder,
) -> wgpu::Buffer {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dbg readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
enc.copy_buffer_to_buffer(src, 0, &read, 0, size);
read
}
let specs: [(&wgpu::Buffer, u64); 5] = [
(&matrix_buf, n as u64 * MAT_SLOT_SIZE),
(&draw_args_buf, n as u64 * DRAW_SLOT_SIZE),
(&transform_buf, n as u64 * TRANSFORM_SLOT_SIZE),
(&bbox_buf, n as u64 * BBOX_SLOT_SIZE),
(&cull_buf, CULL_UNIFORMS_SIZE),
];
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut reads = Vec::with_capacity(specs.len());
{
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("dbg readback encoder"),
});
for (src, size) in &specs {
reads.push(readback(&device, src, *size, &mut enc));
}
queue.submit([enc.finish()]);
}
// Only now request the maps (wgpu 30 has no async `map` future — one message each).
for read in &reads {
let size = read.size();
let tx = tx.clone();
read.map_async(wgpu::MapMode::Read, 0..size, move |_| {
let _ = tx.send(());
});
}
// Pump the queue until every map callback has fired. The callbacks are delivered by
// the `maintain` that runs inside `Queue::submit` — and this thread is the only one
// that will submit from now on (the caller is blocked here), so it must pump itself.
// Empty submits are cheap: each one polls the GPU and fires whatever is due.
loop {
let mut done = 0;
while rx.try_recv().is_ok() {
done += 1;
}
if done == specs.len() {
break;
}
queue.submit([]);
std::thread::sleep(std::time::Duration::from_millis(2));
}
let data: Vec<Vec<u8>> = reads
.iter()
.map(|b| {
b.slice(..)
.get_mapped_range()
.expect("dbg mapped range")
.to_vec()
})
.collect();
for b in &reads {
b.unmap();
}
let (mat_data, args_data, tr_data, bb_data, cull_data) =
(&data[0], &data[1], &data[2], &data[3], &data[4]);
for i in 0..n {
let off = (i as u64 * TRANSFORM_SLOT_SIZE) as usize;
let t: TransformSlot =
bytemuck::pod_read_unaligned(&tr_data[off..off + TRANSFORM_SLOT_SIZE as usize]);
eprintln!(
"[dbg] transform[{i}] translation={:?} flags={:?} rotation={:?} scale={:?}",
t.translation, t.flags, t.rotation, t.scale
);
}
for i in 0..n {
let off = (i as u64 * BBOX_SLOT_SIZE) as usize;
let b: BBoxSlot =
bytemuck::pod_read_unaligned(&bb_data[off..off + BBOX_SLOT_SIZE as usize]);
eprintln!("[dbg] bbox[{i}] min={:?} max={:?}", b.min, b.max);
}
for i in 0..n {
let off = (i as u64 * MAT_SLOT_SIZE) as usize;
let m: MatSlot =
bytemuck::pod_read_unaligned(&mat_data[off..off + MAT_SLOT_SIZE as usize]);
eprintln!("[dbg] matrix[{i}] m = {:?}", m.m);
}
for i in 0..n {
let off = (i as u64 * DRAW_SLOT_SIZE) as usize;
let d: DrawSlot =
bytemuck::pod_read_unaligned(&args_data[off..off + DRAW_SLOT_SIZE as usize]);
eprintln!("[dbg] draw_args[{i}].a = {:?}", d.a);
}
let c: CullUniforms = bytemuck::pod_read_unaligned(&cull_data[..]);
eprintln!(
"[dbg] cull: num_slots={} culling={}",
c.num_slots, c.culling
);
for (i, p) in c.planes.iter().enumerate() {
eprintln!("[dbg] plane[{i}] = {p:?}");
}
eprintln!("[dbg] done");
}
}
/// Renders every entity of `scene` from the shadow-casting light's point of view into the
/// shadow depth map (Step 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at
/// the start of `render_scene`. No-ops (produces no GPU work) when `scene.shadow_caster()` is
/// `None`. The shadow light's `view_proj` is written to `shadow_uniform_buffer`, and the shadow
/// pass writes depth into `shadow_view` (clear 1.0, store). The per-entity model bind groups are
/// reused from `object_bind_group_for`, so transforms match the main pass exactly.
/// Inputs: encoder (the shared command encoder for the frame), scene (entities to cast).
/// `None`. Phase 3 (Step 15): like the main pass, the shadow pass is 100% indirect — it reads the
/// GPU world matrices (group 1, dynamic offset) and the GPU indirect draw args, so transforms and
/// culling match the main pass exactly. Inputs: encoder (the shared command encoder for the frame,
/// with the compute passes already dispatched), scene (entities to cast).
fn render_shadow_map(&self, encoder: &mut wgpu::CommandEncoder, scene: &Scene) {
let _caster = match scene.shadow_caster() {
let caster = match scene.shadow_caster() {
Some(c) => c,
None => return,
};
// Recompute the light's view_proj and write it into the shadow uniform buffer so the
// depth-only vertex shader transforms vertices into light-clip space (D4).
let (light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(_caster)) {
let (_light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(caster)) {
Some(pair) => pair,
None => return,
};
@@ -548,20 +1005,26 @@ impl Renderer {
pass.set_pipeline(&self.shadow_pipeline);
// Group 0: the shadow light view_proj (D4) — the shadow pipeline's only uniform group.
pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]);
for (label, mesh, transform) in scene.iter_entities() {
let object_bind_group = self.object_bind_group_for(label, transform);
// Group 1: per-entity model. The shadow pipeline has no texture/sampler groups.
pass.set_bind_group(1, &object_bind_group, &[]);
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..mesh.num_indices, 0, 0..1);
// Phase 3: one indirect draw per active slot; group 1 (dynamic) selects the matrix slice and
// the draw-args offset. The shadow pipeline has no texture/sampler groups.
for slot in scene.iter_slot_draws() {
if !slot.active {
continue;
}
let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32;
let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE;
pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]);
pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
if let Some(index_buffer) = &slot.mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
}
pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset);
} else {
pass.draw(0..mesh.num_vertices, 0..1);
pass.draw_indirect(&self.draw_args_buffer, indirect_offset);
}
}
drop(pass);
let _ = light_index; // (index retained for future per-light shadow options)
}
/// Presents the rendered frame by submitting the acquired surface texture to the GPU queue.
@@ -583,37 +1046,13 @@ impl Renderer {
self.format
}
/// Returns the per-entity object bind group for `label`, creating its uniform buffer on first
/// encounter and rewriting the model matrix each call. Since `render_scene(&self)` is immutable,
/// the lazily-populated cache is interior-mutable (`RefCell`). Step 4.2.
/// Inputs: label (entity identifier used as cache key), transform (world placement to upload).
/// Returns an owned (cheaply Arc-cloned) reference handle to the object bind group (group 1).
fn object_bind_group_for(&self, label: &str, transform: &Transform) -> wgpu::BindGroup {
let mut cache = self.object_cache.borrow_mut();
let (buffer, bind_group) = cache.entry(label.to_string()).or_insert_with(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("object uniform buffer"),
size: OBJECT_UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("object bind group"),
layout: &self.object_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, bind_group)
});
// Rewrite the model matrix every frame so entity transforms can update (e.g. rotation).
let object_uniforms = ObjectUniform {
model: transform.to_matrix(),
};
self.queue
.write_buffer(buffer, 0, bytemuck::bytes_of(&object_uniforms));
bind_group.clone()
/// Enables or disables GPU frustum culling (Phase 3, Step 15.6). Culling is off by default: with
/// it disabled, the `cull` pass copies every active slot's draw count (nothing is culled), so the
/// scene renders identically to the pre-Phase-3 CPU loop. Enabling it makes `cull` test each
/// slot's world-space bounding box against the camera frustum and zero the draw args of culled
/// slots (so their indirect draws become no-ops). Inputs: enabled (true = cull, false = draw all).
pub fn set_culling(&self, enabled: bool) {
self.cull_enabled.set(enabled);
}
}
@@ -691,6 +1130,7 @@ fn draw_entity(
material: &Material,
frame_bind_group: &wgpu::BindGroup,
object_bind_group: &wgpu::BindGroup,
object_offset: u32,
shadow_bind_group: &wgpu::BindGroup,
) {
if mesh.num_vertices == 0 {
@@ -699,7 +1139,10 @@ fn draw_entity(
}
pass.set_pipeline(&material.pipeline);
pass.set_bind_group(0, frame_bind_group, &[]);
pass.set_bind_group(1, object_bind_group, &[]);
// Phase 3 (D12): the object (model) binding is dynamic — `object_offset` selects the 64-byte
// slice. The low-level path passes 0 (the shared identity buffer); the GPU-driven path uses its
// own inline `set_bind_group` calls (not this helper) with a per-slot `slot_index * MAT_SLOT_SIZE`.
pass.set_bind_group(1, object_bind_group, &[object_offset]);
// Step 10 (DRAFT 10.4): texture group — the Material owns its bind group (placeholder
// white if it has no texture, D1/D2). Always bindable since it is attached to every pipeline.
pass.set_bind_group(2, &material.texture_bind_group, &[]);