GPU culling
This commit is contained in:
+30
-1
@@ -61,6 +61,8 @@ fn stripes_rgba() -> Vec<u8> {
|
||||
struct Demo {
|
||||
camera: CameraController,
|
||||
angle: f32,
|
||||
/// Phase 3 black-window investigation: number of debug_dump calls already made.
|
||||
dbg: u32,
|
||||
}
|
||||
|
||||
/// Horizontal radius at which the primitives sit around the origin.
|
||||
@@ -222,13 +224,40 @@ impl AppHandler for Demo {
|
||||
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.4);
|
||||
app.scene.set_entity_transform("cube_e", tf);
|
||||
}
|
||||
|
||||
fn render(&mut self, app: &mut wsg_lib::App, frame: &wsg_lib::core::Frame) {
|
||||
app.render_scene(frame.view());
|
||||
// Opt-in GPU readback (black-window investigation tooling): WSG_DEBUG_DUMP=N dumps the
|
||||
// first 8 slots of the transform/matrix/draw-args/bbox buffers for N frames (unset = silent,
|
||||
// non-numeric value = 3 frames). Note: orbiting/zooming this camera
|
||||
// can never cull the entity ring — the camera always looks at the origin, so each
|
||||
// entity's angular offset from the view axis is bounded by atan(1.7/6.1) ≈ 15.5°, under
|
||||
// the ~22° vertical half-FOV (verified 2026-09-22: 600 frames swept, GPU==CPU on all
|
||||
// 6000 cull verdicts, zero flips on the ring). Counts only flip to 0 for entities far
|
||||
// off-axis (e.g. behind the near plane) — see docs/user/gpu-driven.md.
|
||||
// Unset → 0 (the showcase stays silent); set but non-numeric (e.g. `WSG_DEBUG_DUMP=on`) → 3.
|
||||
let frames = match std::env::var("WSG_DEBUG_DUMP") {
|
||||
Ok(v) => v.parse::<u32>().ok().filter(|&n| n > 0).unwrap_or(3),
|
||||
Err(_) => 0,
|
||||
};
|
||||
if self.dbg < frames {
|
||||
self.dbg += 1;
|
||||
app.renderer().debug_dump(8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pollster::main]
|
||||
async fn main() -> Result<(), WsgError> {
|
||||
let app = AppBuilder::new().title("WSG Demo").build().await?;
|
||||
// Culling enabled here (Step 15, D8) to exercise the GPU path; it is OFF by default elsewhere.
|
||||
let app = AppBuilder::new()
|
||||
.title("WSG Demo")
|
||||
.with_culling(true)
|
||||
.build()
|
||||
.await?;
|
||||
app.run(Demo {
|
||||
camera: CameraController::default(),
|
||||
angle: 0.0,
|
||||
dbg: 0,
|
||||
})
|
||||
}
|
||||
|
||||
+6
-5
@@ -2,15 +2,16 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules:
|
||||
This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://github.com/gfx-rs/wgpu) for simple 3D drawing operations. The crate follows a layered architecture organized into eight modules (plus the `shaders/` asset directory):
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration; also `InputState` (unified keyboard/mouse input, Step 15.B) |
|
||||
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry), Material (appearance descriptor), Texture, Lights, Camera + CameraController |
|
||||
| **core** | Manager (Context) + Executor (Renderer) layers — GPU lifecycle and draw call orchestration (incl. the GPU-driven compute passes + opt-in frustum culling); also `InputState` (unified keyboard/mouse input, Step 15.B) |
|
||||
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry + bounding box), Material (appearance descriptor), Texture, Lights, Camera + CameraController, and the uniform slot types (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`) |
|
||||
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
|
||||
| **scene** | Scene — resource depot and entity graph for declarative rendering setup |
|
||||
| **math** | Transform, Geometry (per-attribute mesh data) and `primitives` (procedural mesh generators) |
|
||||
| **shaders** | Embedded WGSL sources (`standard`, `shadow`, `gpu_driven`) loaded via the `include_str!` fallback in `utils::conf` |
|
||||
| **scene** | Scene — resource depot and slot-based entity graph for declarative rendering setup (Step 17) |
|
||||
| **math** | Transform, Geometry (per-attribute mesh data + AABB), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z) and `primitives` (procedural mesh generators) |
|
||||
| **utils** | Configuration constants and WsgError type |
|
||||
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation |
|
||||
| **handler** | AppHandler trait — user-defined game logic interface injected into the render loop |
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct App {
|
||||
pub(crate) width: u32,
|
||||
/// Window height, read by the runner when the window is created in `resumed`.
|
||||
pub(crate) height: u32,
|
||||
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
|
||||
pub(crate) culling: bool,
|
||||
/// Winit event loop for window management. Set to None after run() consumes it.
|
||||
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
|
||||
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
|
||||
@@ -118,6 +120,7 @@ impl App {
|
||||
title: self.title.clone(),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
culling: self.culling,
|
||||
handler,
|
||||
app: None,
|
||||
};
|
||||
@@ -173,6 +176,8 @@ pub struct AppBuilder {
|
||||
width: u32,
|
||||
/// Window height in pixels.
|
||||
height: u32,
|
||||
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
|
||||
culling: bool,
|
||||
}
|
||||
|
||||
impl AppBuilder {
|
||||
@@ -183,6 +188,7 @@ impl AppBuilder {
|
||||
title: APP_DEFAULT_TITLE.to_string(),
|
||||
width: APP_DEFAULT_WIDTH,
|
||||
height: APP_DEFAULT_HEIGHT,
|
||||
culling: false,
|
||||
}
|
||||
}
|
||||
/// Sets the window title to display in the OS taskbar/window decorations.
|
||||
@@ -198,6 +204,14 @@ impl AppBuilder {
|
||||
self.height = height;
|
||||
self
|
||||
}
|
||||
/// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
|
||||
/// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
|
||||
/// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
|
||||
/// every active entity visible, so the rendered image is identical to culling-off.
|
||||
pub fn with_culling(mut self, enabled: bool) -> Self {
|
||||
self.culling = enabled;
|
||||
self
|
||||
}
|
||||
/// Builds the configured `App` instance: creates the event loop and stores the window
|
||||
/// configuration. The GPU context, window and renderer are created later, when the event loop
|
||||
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
|
||||
@@ -211,6 +225,7 @@ impl AppBuilder {
|
||||
title: self.title,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
culling: self.culling,
|
||||
event_loop: Some(event_loop),
|
||||
context: None,
|
||||
renderer: None,
|
||||
@@ -229,6 +244,8 @@ struct AppRunner<H: AppHandler> {
|
||||
width: u32,
|
||||
/// Window height in pixels, applied when the window is created in `resumed`.
|
||||
height: u32,
|
||||
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
|
||||
culling: bool,
|
||||
/// The user-provided game logic.
|
||||
handler: H,
|
||||
/// The fully-built App facade, populated on the first `resumed` event.
|
||||
@@ -262,6 +279,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
.expect("surface configuration failed");
|
||||
let device = Arc::new(context.device.clone());
|
||||
let renderer = Renderer::new(&context, format, self.width, self.height);
|
||||
// Step 15, D8: apply the culling flag (off by default — non-regression).
|
||||
renderer.set_culling(self.culling);
|
||||
|
||||
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context
|
||||
// (device + queue + format + cache) into the Scene before setup so it can build materials/meshes.
|
||||
@@ -274,6 +293,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
||||
title: self.title.clone(),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
culling: self.culling,
|
||||
event_loop: None,
|
||||
context: Some(context),
|
||||
renderer: Some(renderer),
|
||||
|
||||
+532
-89
@@ -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, &[]);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! # Frustum Module
|
||||
//!
|
||||
//! View-projection frustum representation and plane extraction, for frustum culling (Phase 3,
|
||||
//! Step 15.6). Planes follow the Gribb-Hartmann convention, adapted to WebGPU's `[0, 1]` clip-space
|
||||
//! z range (the `directx` projection produced by [`crate::resources::Camera::projection_matrix`]).
|
||||
//!
|
||||
//! Each plane is a `[f32; 4]` `(normal, d)` such that a world point `p` is **inside** the frustum
|
||||
//! iff `dot(p, normal) + d >= 0` for every plane. The six planes are extracted from the rows of the
|
||||
//! view-projection matrix `M` (world to clip space), whose NDC conventions are x, y in `[-1, 1]` and
|
||||
//! z in `[0, 1]`:
|
||||
//!
|
||||
//! | plane | clip-space inequality | row combination |
|
||||
//! |--------|-----------------------|-----------------|
|
||||
//! | left | cx + cw >= 0 | w + x |
|
||||
//! | right | -cx + cw >= 0 | w - x |
|
||||
//! | bottom | cy + cw >= 0 | w + y |
|
||||
//! | top | -cy + cw >= 0 | w - y |
|
||||
//! | near | cz >= 0 | z |
|
||||
//! | far | -cz + cw >= 0 | w - z |
|
||||
//!
|
||||
//! (For the `[0, 1]` z range the near plane is the z row alone — `cz >= 0` — whereas the classic
|
||||
//! `[-1, 1]` Gribb-Hartmann uses `w + z`. The far plane `w - z` is the same in both.)
|
||||
//! Each plane is normalized to a unit normal so the signed-distance test is scale-invariant.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
/// A view-projection frustum represented by its six bounding planes.
|
||||
///
|
||||
/// Each plane is a `[f32; 4]` `(normal, d)`: a world point `p` is inside when
|
||||
/// `dot(p, normal) + d >= 0`. Built from a view-projection matrix via
|
||||
/// [`Frustum::from_view_proj`] and uploaded to the GPU culling compute shader (Phase 3).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Frustum {
|
||||
/// The six frustum planes, order: `[left, right, bottom, top, near, far]`.
|
||||
pub planes: [[f32; 4]; 6],
|
||||
}
|
||||
|
||||
impl Frustum {
|
||||
/// Extracts the six frustum planes from a view-projection matrix (Gribb-Hartmann, adapted to
|
||||
/// WebGPU's `[0, 1]` clip-space z). Inputs: m — the `projection * view` matrix (world to clip
|
||||
/// space). Returns the frustum with unit-length plane normals.
|
||||
pub fn from_view_proj(m: &Mat4) -> Self {
|
||||
// glam stores Mat4 by column; transpose so `.x_axis`/`.y_axis`/... are the rows of M,
|
||||
// i.e. the clip-space basis vectors the Gribb-Hartmann method combines.
|
||||
let mt = m.transpose();
|
||||
let r0 = mt.x_axis; // row 0 of M -> clip x
|
||||
let r1 = mt.y_axis; // row 1 of M -> clip y
|
||||
let r2 = mt.z_axis; // row 2 of M -> clip z
|
||||
let r3 = mt.w_axis; // row 3 of M -> clip w
|
||||
let raw: [Vec4; 6] = [
|
||||
r3 + r0, // left
|
||||
r3 - r0, // right
|
||||
r3 + r1, // bottom
|
||||
r3 - r1, // top
|
||||
r2, // near
|
||||
r3 - r2, // far
|
||||
];
|
||||
let planes = raw.map(|p| {
|
||||
let n = Vec3::new(p.x, p.y, p.z);
|
||||
let len = n.length();
|
||||
if len > 1e-8 {
|
||||
let nn = n / len;
|
||||
[nn.x, nn.y, nn.z, p.w / len]
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
}
|
||||
});
|
||||
Self { planes }
|
||||
}
|
||||
|
||||
/// Tests whether a world-space point lies inside the frustum (inside every plane).
|
||||
/// Inputs: p — a world-space point. Returns true if it satisfies all six plane inequalities.
|
||||
pub fn contains_point(&self, p: Vec3) -> bool {
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane[0] * p.x + plane[1] * p.y + plane[2] * p.z + plane[3] >= 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resources::camera::Camera;
|
||||
|
||||
/// Builds the view-projection matrix for a camera at `(0,0,d)` looking at the origin (45 deg fov,
|
||||
/// near 0.1, far 100), matching the `directx` (WebGPU `[0,1]`) projection used by the renderer.
|
||||
fn vp(d: f32) -> Mat4 {
|
||||
let cam = Camera::new(Vec3::new(0.0, 0.0, d), Vec3::ZERO, Vec3::Y).with_perspective(
|
||||
45.0_f32.to_radians(),
|
||||
0.1,
|
||||
100.0,
|
||||
);
|
||||
cam.projection_matrix(1.0) * cam.view_matrix()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_inside_when_camera_looks_at_it() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
assert!(
|
||||
fr.contains_point(Vec3::new(0.0, 0.0, 0.0)),
|
||||
"the look-at target must be inside the frustum"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_camera_is_culled() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
// Camera at z = 10 looks toward -z; a point at z = 50 is behind it.
|
||||
assert!(
|
||||
!fr.contains_point(Vec3::new(0.0, 0.0, 50.0)),
|
||||
"a point behind the camera must be culled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_to_the_side_is_culled() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
// Far off to the side, well outside the 45-degree field of view.
|
||||
assert!(
|
||||
!fr.contains_point(Vec3::new(1000.0, 0.0, 0.0)),
|
||||
"a point far to the side must be culled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beyond_far_plane_is_culled() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
// z = -500 is 510 units in front of the camera (at z = 10), beyond far = 100.
|
||||
assert!(
|
||||
!fr.contains_point(Vec3::new(0.0, 0.0, -500.0)),
|
||||
"a point beyond the far plane must be culled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planes_are_unit_length() {
|
||||
let fr = Frustum::from_view_proj(&vp(10.0));
|
||||
for plane in fr.planes {
|
||||
let len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
||||
assert!(
|
||||
(len - 1.0).abs() < 1e-3,
|
||||
"plane normal must be unit length, got {len}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reproduces the `demo` example's exact camera + entity layout and confirms the GPU cull
|
||||
/// pass would NOT cull any of them (they sit at radius 1.7 around the origin, in front of the
|
||||
/// orbital camera). If this fails, the demo's black window is a frustum-culling bug.
|
||||
#[test]
|
||||
fn demo_camera_sees_all_primitives() {
|
||||
use crate::resources::CameraController;
|
||||
let mut ctrl = CameraController::default();
|
||||
ctrl.yaw = 0.6;
|
||||
ctrl.pitch = 0.35;
|
||||
ctrl.distance = 6.5;
|
||||
ctrl.target = Vec3::ZERO;
|
||||
let mut cam = Camera::default();
|
||||
ctrl.apply_to(&mut cam);
|
||||
let vp = cam.projection_matrix(1.0) * cam.view_matrix();
|
||||
let fr = Frustum::from_view_proj(&vp);
|
||||
// Ground plane center (origin).
|
||||
assert!(
|
||||
fr.contains_point(Vec3::ZERO),
|
||||
"origin (ground center) must be inside"
|
||||
);
|
||||
// The six primitives, placed by demo::place at radius 1.7, y = 0.5.
|
||||
for i in 0..6 {
|
||||
let a = i as f32 / 6.0 * std::f32::consts::TAU;
|
||||
let p = Vec3::new(a.cos() * 1.7, 0.5, a.sin() * 1.7);
|
||||
assert!(
|
||||
fr.contains_point(p),
|
||||
"primitive {i} at {} must be inside the frustum",
|
||||
p
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,57 @@
|
||||
|
||||
use crate::resources::Vertex;
|
||||
|
||||
/// An axis-aligned bounding box in object (local) space: the min/max corners of a geometry's
|
||||
/// positions. Used for conservative sphere culling (Phase 3): the culling radius is the box's
|
||||
/// circumradius and the culling center is its center, both computed once per mesh and uploaded
|
||||
/// to the GPU culling buffer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
#[repr(C)]
|
||||
pub struct BBox {
|
||||
/// Minimum corner (local space).
|
||||
pub min: [f32; 3],
|
||||
/// Maximum corner (local space).
|
||||
pub max: [f32; 3],
|
||||
}
|
||||
|
||||
impl BBox {
|
||||
/// Builds a bounding box from a list of local-space positions. Returns `None` when the input
|
||||
/// is empty (a geometry must have at least one position to have a bounding box).
|
||||
pub fn from_positions(positions: &[[f32; 3]]) -> Option<Self> {
|
||||
if positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut min = [f32::INFINITY; 3];
|
||||
let mut max = [f32::NEG_INFINITY; 3];
|
||||
for p in positions {
|
||||
for i in 0..3 {
|
||||
min[i] = min[i].min(p[i]);
|
||||
max[i] = max[i].max(p[i]);
|
||||
}
|
||||
}
|
||||
Some(Self { min, max })
|
||||
}
|
||||
|
||||
/// The center of the box (local space), i.e. the midpoint of the min and max corners.
|
||||
pub fn center(&self) -> [f32; 3] {
|
||||
[
|
||||
(self.min[0] + self.max[0]) * 0.5,
|
||||
(self.min[1] + self.max[1]) * 0.5,
|
||||
(self.min[2] + self.max[2]) * 0.5,
|
||||
]
|
||||
}
|
||||
|
||||
/// The circumradius: distance from the center to a corner (all corners are equidistant for an
|
||||
/// axis-aligned box). This is the conservative culling radius — the sphere of this radius
|
||||
/// around the center fully contains the box.
|
||||
pub fn circumradius(&self) -> f32 {
|
||||
let dx = (self.max[0] - self.min[0]) * 0.5;
|
||||
let dy = (self.max[1] - self.min[1]) * 0.5;
|
||||
let dz = (self.max[2] - self.min[2]) * 0.5;
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation error produced when a `Geometry` is inconsistent, i.e. its optional
|
||||
/// per-vertex arrays (`normals`, `uvs`, `colors`) have a length different from
|
||||
/// `positions`, or an index is out of bounds.
|
||||
@@ -223,6 +274,13 @@ impl Geometry {
|
||||
self.indices.as_deref()
|
||||
}
|
||||
|
||||
/// Computes the axis-aligned bounding box of this geometry's positions (local space).
|
||||
/// Returns `None` when the geometry has no positions. Used by `Mesh`/`Scene` to build the
|
||||
/// per-mesh culling sphere (Phase 3).
|
||||
pub fn bbox(&self) -> Option<BBox> {
|
||||
BBox::from_positions(&self.positions)
|
||||
}
|
||||
|
||||
/// Returns the CPU vertices in interleaved `resources::Vertex` layout, suitable
|
||||
/// for GPU upload. Assumes the geometry is valid; missing per-vertex attributes
|
||||
/// are filled with defaults:
|
||||
@@ -307,6 +365,26 @@ mod tests {
|
||||
assert_eq!(geo.validate(), Err(GeometryError::EmptyPositions));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbox_of_quad() {
|
||||
let bb = quad().bbox().expect("quad has positions");
|
||||
assert_eq!(bb.min, [0.0, 0.0, 0.0]);
|
||||
assert_eq!(bb.max, [1.0, 1.0, 0.0]);
|
||||
assert_eq!(bb.center(), [0.5, 0.5, 0.0]);
|
||||
// Circumradius of a unit square: half-diagonal = sqrt(0.5^2 + 0.5^2) = sqrt(0.5).
|
||||
let expected = (0.5_f32 * 0.5 + 0.5_f32 * 0.5).sqrt();
|
||||
assert!(
|
||||
(bb.circumradius() - expected).abs() < 1e-6,
|
||||
"got {}",
|
||||
bb.circumradius()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbox_of_empty_is_none() {
|
||||
assert!(Geometry::new(Vec::new()).bbox().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_attribute_count_mismatch() {
|
||||
let geo = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
|
||||
|
||||
+3
-1
@@ -16,11 +16,13 @@
|
||||
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
|
||||
//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry`
|
||||
|
||||
pub mod frustum;
|
||||
pub mod geometry;
|
||||
pub mod primitives;
|
||||
pub mod transform;
|
||||
|
||||
// Re-exports
|
||||
pub use geometry::{Geometry, GeometryError};
|
||||
pub use frustum::Frustum;
|
||||
pub use geometry::{BBox, Geometry, GeometryError};
|
||||
pub use primitives::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
|
||||
pub use transform::Transform;
|
||||
|
||||
@@ -22,12 +22,15 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Creates the two bind group layouts shared by **every** pipeline (Step 3 — decision ratified
|
||||
/// "a single layout for all"). Both buffers are `Uniform`, 16-byte aligned, no dynamic offset.
|
||||
/// Matching CPU types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
|
||||
/// "a single layout for all"). Both buffers are `Uniform` and 16-byte aligned. Matching CPU
|
||||
/// types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
|
||||
/// Returns `[frame_layout, object_layout]` in renderer binding order.
|
||||
///
|
||||
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages.
|
||||
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only.
|
||||
/// - `index 0`: per-frame uniforms (view/proj/light/options), visible in both shader stages. Static
|
||||
/// (one shared `FrameUniforms` buffer per frame, no dynamic offset).
|
||||
/// - `index 1`: per-object uniforms (model matrix), visible in the vertex stage only. **Dynamic**
|
||||
/// (Phase 3, D12): the offset selects a 64-byte slice of the single GPU-written matrix buffer,
|
||||
/// so every entity shares one buffer. The low-level `render` path passes offset 0.
|
||||
pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGroupLayout; 2] {
|
||||
[
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -50,7 +53,10 @@ pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGr
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
// Phase 3 (D12): dynamic offset so every entity shares the single GPU-written
|
||||
// matrix buffer (one 64-byte slice per slot), instead of a per-entity buffer.
|
||||
// The low-level `render` path passes offset 0 (its identity object buffer).
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
|
||||
@@ -27,8 +27,10 @@ pub use material::Material;
|
||||
pub use mesh::Mesh;
|
||||
pub use texture::{Texture, TextureError};
|
||||
pub use uniform::{
|
||||
FRAME_UNIFORMS_SIZE, FrameUniforms, Light, LightType, MAX_LIGHTS, OBJECT_UNIFORM_SIZE,
|
||||
ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform,
|
||||
BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, CullUniforms, DRAW_SLOT_SIZE, DrawSlot,
|
||||
FRAME_UNIFORMS_SIZE, FrameUniforms, Light, LightType, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot,
|
||||
OBJECT_UNIFORM_SIZE, ObjectUniform, SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE,
|
||||
TransformSlot,
|
||||
};
|
||||
pub use vertex::Vertex;
|
||||
|
||||
|
||||
@@ -178,6 +178,211 @@ pub struct ShadowUniform {
|
||||
pub view_proj: Mat4,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase 3 — GPU-driven entity slot buffers (Step 15)
|
||||
// ============================================================================
|
||||
//
|
||||
// CPU-side `Pod` mirrors of the GPU buffer element types declared in `gpu_driven.wgsl` (see that
|
||||
// file's "GPU buffer layouts" note). Their byte layout must match the WGSL structs **exactly** —
|
||||
// including the 16-byte alignment of `vec3` (std140), which is why `vec3` fields below carry an
|
||||
// explicit `_pad` so the Rust offsets line up with the WGSL ones.
|
||||
//
|
||||
// All buffers are fixed-capacity (`MAX_ENTITIES`), allocated once. Per frame the CPU rewrites only
|
||||
// the transform slots + cull uniforms; the matrices and indirect draw args are written by the GPU.
|
||||
|
||||
/// Byte size of one GPU entity transform slot (`TransformSlot`).
|
||||
pub const TRANSFORM_SLOT_SIZE: u64 = std::mem::size_of::<TransformSlot>() as u64;
|
||||
/// Byte size of one GPU world-matrix slot (`MatSlot`).
|
||||
pub const MAT_SLOT_SIZE: u64 = std::mem::size_of::<MatSlot>() as u64;
|
||||
/// Byte size of one GPU local-space bounding box (`BBoxSlot`).
|
||||
pub const BBOX_SLOT_SIZE: u64 = std::mem::size_of::<BBoxSlot>() as u64;
|
||||
/// Byte size of one GPU indirect draw-args slot (`DrawSlot`).
|
||||
pub const DRAW_SLOT_SIZE: u64 = std::mem::size_of::<DrawSlot>() as u64;
|
||||
/// Byte size of the GPU cull/uniform block (`CullUniforms`).
|
||||
pub const CULL_UNIFORMS_SIZE: u64 = std::mem::size_of::<CullUniforms>() as u64;
|
||||
|
||||
/// A packed entity transform slot (64 bytes) — the single CPU→GPU source of truth for world
|
||||
/// matrices (Step 15, D13). Mirrors the WGSL `TransformSlot`.
|
||||
///
|
||||
/// Layout (std140, 16-byte aligned): translation (vec3 @0) + pad, flags (vec4 @16), rotation
|
||||
/// (vec4 @32), scale (vec3 @48) + pad → 64 bytes.
|
||||
///
|
||||
/// `flags` packing: x = mesh index (stable index into the mesh list), y = active (0/1),
|
||||
/// z = draw count (vertex or index count for this mesh), w = has_index (0/1).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct TransformSlot {
|
||||
/// World translation (xyz). Offset 0.
|
||||
pub translation: [f32; 3],
|
||||
_pad0: [f32; 1],
|
||||
/// Packed flags: x = mesh index, y = active, z = draw count, w = has_index. Offset 16.
|
||||
pub flags: [f32; 4],
|
||||
/// Rotation quaternion (x, y, z, w). Offset 32.
|
||||
pub rotation: [f32; 4],
|
||||
/// Non-uniform scale (xyz). Offset 48.
|
||||
pub scale: [f32; 3],
|
||||
_pad1: [f32; 1],
|
||||
}
|
||||
|
||||
impl TransformSlot {
|
||||
/// Builds an active transform slot from a CPU [`crate::math::Transform`] + the mesh's draw
|
||||
/// metadata. `mesh_index` / `draw_count` are packed into `flags`; `active` is 1.
|
||||
pub fn from_transform(
|
||||
t: &crate::math::Transform,
|
||||
mesh_index: u32,
|
||||
draw_count: u32,
|
||||
has_index: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
translation: t.translation.to_array(),
|
||||
_pad0: [0.0; 1],
|
||||
flags: [
|
||||
mesh_index as f32,
|
||||
1.0,
|
||||
draw_count as f32,
|
||||
if has_index { 1.0 } else { 0.0 },
|
||||
],
|
||||
rotation: [t.rotation.x, t.rotation.y, t.rotation.z, t.rotation.w],
|
||||
scale: t.scale.to_array(),
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an inactive (tombstone) slot: `active` = 0; the GPU writes identity and skips the draw.
|
||||
pub fn inactive() -> Self {
|
||||
Self {
|
||||
translation: [0.0; 3],
|
||||
_pad0: [0.0; 1],
|
||||
flags: [0.0, 0.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
scale: [0.0; 3],
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable index of the entity's mesh (from `flags.x`).
|
||||
pub fn mesh_index(&self) -> u32 {
|
||||
self.flags[0] as u32
|
||||
}
|
||||
|
||||
/// Draw count (vertex or index count) packed in `flags.z`.
|
||||
pub fn draw_count(&self) -> u32 {
|
||||
self.flags[2] as u32
|
||||
}
|
||||
|
||||
/// Whether the entity's mesh is indexed (from `flags.w`).
|
||||
pub fn has_index(&self) -> bool {
|
||||
self.flags[3] >= 0.5
|
||||
}
|
||||
|
||||
/// Whether the slot is active (from `flags.y`).
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.flags[1] >= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// A 4x4 world matrix (64 B) followed by 192 B of padding = **256 B** total. The padding is
|
||||
/// REQUIRED: the render pipelines read this slot through the `uniform` object group with a
|
||||
/// per-slot dynamic offset, and WebGPU demands that offset be a multiple of
|
||||
/// `min_uniform_buffer_offset_alignment` (256 B). A bare 64-byte matrix can never be individually
|
||||
/// addressable via a uniform offset, so each slot is padded to a 256-byte boundary (capacity is
|
||||
/// capped at 256 = 64 KB / 256 B). Derived on the GPU by `compute_matrices` (Step 15). Mirrors the
|
||||
/// WGSL `MatSlot`. (No `Default`: the matrices are GPU-written, so the CPU never constructs a
|
||||
/// `MatSlot` — this type exists only to fix the buffer's slot size. `pad` is `[f32; 48]`, beyond
|
||||
/// the `N ≤ 32` bound of the array `Default` impl, so `Default` cannot be derived.)
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct MatSlot {
|
||||
/// The world matrix (object → world), column-major. Offset 0.
|
||||
pub m: Mat4,
|
||||
/// 192 bytes of padding (alignment only, never read). Offset 64.
|
||||
pub pad: [f32; 48],
|
||||
}
|
||||
|
||||
/// A local-space axis-aligned bounding box (32 bytes), uploaded once per mesh (Step 15).
|
||||
/// Mirrors the WGSL `BBoxSlot` (min vec3 @0 + pad, max vec3 @16 + pad).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct BBoxSlot {
|
||||
/// Bounding box minimum corner (xyz). Offset 0.
|
||||
pub min: [f32; 3],
|
||||
_pad0: [f32; 1],
|
||||
/// Bounding box maximum corner (xyz). Offset 16.
|
||||
pub max: [f32; 3],
|
||||
_pad1: [f32; 1],
|
||||
}
|
||||
|
||||
impl BBoxSlot {
|
||||
/// Builds a slot from a CPU [`crate::math::BBox`] (padding zeroed).
|
||||
pub fn from_bbox(b: &crate::math::BBox) -> Self {
|
||||
Self {
|
||||
min: b.min,
|
||||
max: b.max,
|
||||
_pad0: [0.0; 1],
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate (all-zero) box, used as the placeholder for a mesh with no bounding box.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
min: [0.0; 3],
|
||||
max: [0.0; 3],
|
||||
_pad0: [0.0; 1],
|
||||
_pad1: [0.0; 1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indirect draw arguments for one entity (80 bytes = five u32 vec4s). The shader writes only `.a`;
|
||||
/// the rest stays zero (the constant instance count of 1 lives in `.a.y`). Mirrors the WGSL `DrawSlot`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Default)]
|
||||
pub struct DrawSlot {
|
||||
/// `.a` = (count, instance_count, first_vertex/first_index, base_vertex); `.b..e` = 0.
|
||||
pub a: [u32; 4],
|
||||
/// Reserved (first_instance for the indexed block). Kept zero.
|
||||
pub b: [u32; 4],
|
||||
/// Reserved padding; kept zero (part of the 80-byte slot).
|
||||
pub c: [u32; 4],
|
||||
/// Reserved padding; kept zero (part of the 80-byte slot).
|
||||
pub d: [u32; 4],
|
||||
/// Reserved padding; kept zero (part of the 80-byte slot).
|
||||
pub e: [u32; 4],
|
||||
}
|
||||
|
||||
/// Per-frame GPU cull/uniform block (112 bytes), rewritten by the CPU each frame (Step 15).
|
||||
/// Mirrors the WGSL `CullUniforms`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
pub struct CullUniforms {
|
||||
/// Six frustum planes, unit (normal, d); inside the frustum iff dot(p, normal) + d >= 0.
|
||||
pub planes: [[f32; 4]; 6],
|
||||
/// Number of live entity slots (slots at/ beyond this are no-ops).
|
||||
pub num_slots: u32,
|
||||
/// 0 = culling disabled (every active entity draws), 1 = enabled (sphere test).
|
||||
pub culling: u32,
|
||||
_pad: [u32; 2],
|
||||
}
|
||||
|
||||
impl CullUniforms {
|
||||
/// Builds the cull block from six frustum planes (each a unit `[normal; d]` `[f32; 4]`) + the
|
||||
/// control flags. `num_slots` = number of live entity slots.
|
||||
pub fn new(planes: [[f32; 4]; 6], num_slots: u32, culling: bool) -> Self {
|
||||
Self {
|
||||
planes,
|
||||
num_slots,
|
||||
culling: if culling { 1 } else { 0 },
|
||||
_pad: [0; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the cull block directly from a [`crate::math::Frustum`] (its six unit planes).
|
||||
pub fn from_frustum(f: &crate::math::Frustum, num_slots: u32, culling: bool) -> Self {
|
||||
Self::new(f.planes, num_slots, culling)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,4 +448,35 @@ mod tests {
|
||||
assert_eq!(align_of::<ObjectUniform>(), 16);
|
||||
assert_eq!(offset_of!(ObjectUniform, model), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_slot_layouts_match_wgsl() {
|
||||
// TransformSlot: translation @0 (vec3+pad), flags @16, rotation @32, scale @48 (vec3+pad) -> 64.
|
||||
assert_eq!(size_of::<TransformSlot>(), 64);
|
||||
assert_eq!(offset_of!(TransformSlot, translation), 0);
|
||||
assert_eq!(offset_of!(TransformSlot, flags), 16);
|
||||
assert_eq!(offset_of!(TransformSlot, rotation), 32);
|
||||
assert_eq!(offset_of!(TransformSlot, scale), 48);
|
||||
|
||||
// MatSlot: one 4x4 column-major matrix (64 B) + 192 B padding -> 256 (padded to the
|
||||
// uniform offset alignment; see the struct doc). m @0, pad @64.
|
||||
assert_eq!(size_of::<MatSlot>(), 256);
|
||||
assert_eq!(align_of::<MatSlot>(), 16);
|
||||
assert_eq!(offset_of!(MatSlot, m), 0);
|
||||
assert_eq!(offset_of!(MatSlot, pad), 64);
|
||||
|
||||
// BBoxSlot: min @0 (vec3+pad), max @16 (vec3+pad) -> 32.
|
||||
assert_eq!(size_of::<BBoxSlot>(), 32);
|
||||
assert_eq!(offset_of!(BBoxSlot, min), 0);
|
||||
assert_eq!(offset_of!(BBoxSlot, max), 16);
|
||||
|
||||
// DrawSlot: five u32 vec4s -> 80 (a multiple of both 16 and 20, per the WebGPU indirect rule).
|
||||
assert_eq!(size_of::<DrawSlot>(), 80);
|
||||
|
||||
// CullUniforms: 6 planes (96) + num_slots @96 + culling @100 + pad -> 112.
|
||||
assert_eq!(size_of::<CullUniforms>(), 112);
|
||||
assert_eq!(offset_of!(CullUniforms, planes), 0);
|
||||
assert_eq!(offset_of!(CullUniforms, num_slots), 96);
|
||||
assert_eq!(offset_of!(CullUniforms, culling), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ pub mod scene;
|
||||
|
||||
// Re-export
|
||||
pub use entity::Entity;
|
||||
pub use scene::Scene;
|
||||
pub use scene::{Scene, SlotDraw};
|
||||
|
||||
+208
-1
@@ -17,7 +17,7 @@
|
||||
|
||||
use crate::math::{Geometry, Transform};
|
||||
use crate::pipeline::PipelineCache;
|
||||
use crate::resources::{Camera, Lights, Material, Mesh, Texture};
|
||||
use crate::resources::{BBoxSlot, Camera, Lights, Material, Mesh, Texture, TransformSlot};
|
||||
use crate::scene::Entity;
|
||||
use glam::Vec3;
|
||||
use std::cell::RefCell;
|
||||
@@ -37,6 +37,42 @@ struct SceneGpu {
|
||||
cache: RefCell<PipelineCache>,
|
||||
}
|
||||
|
||||
/// A stable, append-only slot for an entity in the GPU-driven slot buffers (Phase 3, Step 15).
|
||||
/// Slots are **never freed**: removing an entity leaves a tombstone (its label drops out of the
|
||||
/// `entities` map) so that slot indices stay stable across frames and the fixed-capacity GPU buffers
|
||||
/// can be indexed by a constant slot index. The transform itself is read from the `entities` map
|
||||
/// (by `label`) at pack time; the slot only carries the mesh identity + draw metadata, which are
|
||||
/// stable once the entity is (re-)added.
|
||||
#[derive(Clone)]
|
||||
struct EntitySlot {
|
||||
/// Entity label (key into the `entities` map; the transform is read from there each frame).
|
||||
label: String,
|
||||
/// Stable index of the entity's mesh (position in `mesh_order`) — indexes the GPU bbox buffer.
|
||||
mesh_index: u32,
|
||||
/// Draw count for the entity's mesh (vertex count, or index count when indexed) → packed into
|
||||
/// the transform slot's `flags.z`.
|
||||
draw_count: u32,
|
||||
/// Whether the entity's mesh is indexed → packed into the transform slot's `flags.w`.
|
||||
has_index: bool,
|
||||
}
|
||||
|
||||
/// Per-slot draw descriptor for the GPU-driven render loop (Phase 3). Carries everything the
|
||||
/// renderer needs to issue one indirect draw: the slot index (→ indirect-args + matrix buffer
|
||||
/// offset), whether the slot is active (tombstones are skipped on the CPU), the mesh, and whether
|
||||
/// it is indexed. The world matrix is **not** carried here — it is derived on the GPU (Step 15.5)
|
||||
/// and read from the matrix buffer by the render pipeline.
|
||||
#[derive(Clone)]
|
||||
pub struct SlotDraw {
|
||||
/// Stable slot index (offset into the indirect-args and matrix buffers, in slot units).
|
||||
pub slot_index: usize,
|
||||
/// Whether the slot is active (false = tombstone; the CPU skips it, the GPU zeros its draw args).
|
||||
pub active: bool,
|
||||
/// The entity's mesh (vertex/index buffers + material).
|
||||
pub mesh: Arc<Mesh>,
|
||||
/// Whether the mesh is indexed (`draw_indexed_indirect` vs `draw_indirect`).
|
||||
pub has_index: bool,
|
||||
}
|
||||
|
||||
/// Resource depot and entity graph. Stores Meshes and Materials keyed by identifier strings,
|
||||
/// maps entity labels to their associated `Entity` (mesh + transform) for rendering iteration,
|
||||
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Step 4.3).
|
||||
@@ -69,6 +105,13 @@ pub struct Scene {
|
||||
/// `None` = shadows off (default, non-regression). Read each frame by `Renderer::render_scene`
|
||||
/// to compute the light `view_proj` and enable shadow sampling.
|
||||
shadow_caster: Option<usize>,
|
||||
/// Stable, append-only entity slots for the GPU-driven buffers (Phase 3). Grows only; removed
|
||||
/// entities leave tombstones so slot indices stay stable.
|
||||
entity_slots: Vec<EntitySlot>,
|
||||
/// Map of entity label to slot index (O(1) lookup so a re-added label reuses its slot).
|
||||
slot_of_label: HashMap<String, usize>,
|
||||
/// Ordered mesh identifiers (index = the stable `mesh_index` used by slots and the bbox buffer).
|
||||
mesh_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
@@ -88,6 +131,9 @@ impl Scene {
|
||||
lights: Lights::new(),
|
||||
ambient: [1.0, 1.0, 1.0],
|
||||
shadow_caster: None,
|
||||
entity_slots: Vec::new(),
|
||||
slot_of_label: HashMap::new(),
|
||||
mesh_order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +273,7 @@ impl Scene {
|
||||
mesh.set_material(mat);
|
||||
}
|
||||
self.meshes.insert(id.to_string(), Arc::new(mesh));
|
||||
self.mesh_order.push(id.to_string());
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
@@ -399,6 +446,7 @@ impl Scene {
|
||||
return Err(format!("Mesh ID '{}' already exists.", id));
|
||||
}
|
||||
self.meshes.insert(id.to_string(), mesh);
|
||||
self.mesh_order.push(id.to_string());
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
@@ -440,6 +488,40 @@ impl Scene {
|
||||
}
|
||||
self.entities
|
||||
.insert(label.to_string(), Entity::new(mesh_id, transform));
|
||||
// Keep the stable slot in sync (Phase 3): a re-added label reuses its slot (stable index);
|
||||
// a new label appends a slot. The mesh index + draw metadata are read from the mesh.
|
||||
let mesh_index = self
|
||||
.mesh_order
|
||||
.iter()
|
||||
.position(|id| id == mesh_id)
|
||||
.expect("mesh validated above") as u32;
|
||||
let mesh = &self.meshes[mesh_id];
|
||||
let has_index = mesh.index_buffer.is_some();
|
||||
let draw_count = if has_index {
|
||||
mesh.num_indices
|
||||
} else {
|
||||
mesh.num_vertices
|
||||
};
|
||||
let slot_index = match self.slot_of_label.get(label) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = self.entity_slots.len();
|
||||
self.slot_of_label.insert(label.to_string(), i);
|
||||
self.entity_slots.push(EntitySlot {
|
||||
label: label.to_string(),
|
||||
mesh_index: 0,
|
||||
draw_count: 0,
|
||||
has_index: false,
|
||||
});
|
||||
i
|
||||
}
|
||||
};
|
||||
self.entity_slots[slot_index] = EntitySlot {
|
||||
label: label.to_string(),
|
||||
mesh_index,
|
||||
draw_count,
|
||||
has_index,
|
||||
};
|
||||
Ok(label.to_string())
|
||||
}
|
||||
|
||||
@@ -497,6 +579,91 @@ impl Scene {
|
||||
pub fn entity_count(&self) -> usize {
|
||||
self.entities.len()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Phase 3 — GPU-driven entity slot accessors (Step 15)
|
||||
// ========================================================================
|
||||
// These expose the stable slot system to the Renderer: the packed transform slots (uploaded
|
||||
// to the GPU each frame), the per-slot draw descriptors (for the indirect render loop), the
|
||||
// per-mesh bounding boxes (uploaded once), and the slot/mesh counts. The world matrices are
|
||||
// derived on the GPU; these methods only feed the CPU→GPU inputs and the draw-loop metadata.
|
||||
|
||||
/// Packs the stable entity slots into GPU [`TransformSlot`]s (one per slot; tombstones →
|
||||
/// inactive). The renderer uploads this to the transform buffer each frame (Phase 3, Step 15).
|
||||
pub fn packed_transform_slots(&self) -> Vec<TransformSlot> {
|
||||
self.entity_slots
|
||||
.iter()
|
||||
.map(|slot| match self.entities.get(&slot.label) {
|
||||
Some(entity) => TransformSlot::from_transform(
|
||||
entity.transform(),
|
||||
slot.mesh_index,
|
||||
slot.draw_count,
|
||||
slot.has_index,
|
||||
),
|
||||
None => TransformSlot::inactive(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Iterates the stable entity slots as per-slot draw descriptors (Phase 3). Each item carries
|
||||
/// the slot index (→ indirect-args/matrix buffer offset), whether the slot is active (tombstones
|
||||
/// are skipped on the CPU), the mesh, and whether it is indexed. Used by the indirect render loop.
|
||||
pub fn iter_slot_draws(&self) -> impl Iterator<Item = SlotDraw> + '_ {
|
||||
self.entity_slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, slot)| SlotDraw {
|
||||
slot_index: i,
|
||||
active: self.entities.contains_key(&slot.label),
|
||||
mesh: self.meshes[&self.mesh_order[slot.mesh_index as usize]].clone(),
|
||||
has_index: slot.has_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// The local-space bounding boxes for all registered meshes, in `mesh_index` order (one per
|
||||
/// mesh). Uploaded once to the GPU bbox buffer (Phase 3). Meshes without a bounding box get a
|
||||
/// degenerate (all-zero) box, which the cull pass treats as a zero-radius sphere.
|
||||
pub fn mesh_bboxes(&self) -> Vec<BBoxSlot> {
|
||||
self.mesh_order
|
||||
.iter()
|
||||
.map(|id| {
|
||||
self.meshes[id]
|
||||
.geometry()
|
||||
.bbox()
|
||||
.map(|b| BBoxSlot::from_bbox(&b))
|
||||
.unwrap_or_else(BBoxSlot::empty)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stable index of `mesh_id` in the ordered mesh list (its slot in the GPU bbox buffer), if
|
||||
/// the mesh is registered.
|
||||
pub fn mesh_index_of(&self, mesh_id: &str) -> Option<u32> {
|
||||
self.mesh_order
|
||||
.iter()
|
||||
.position(|id| id == mesh_id)
|
||||
.map(|p| p as u32)
|
||||
}
|
||||
|
||||
/// The registered mesh at a stable `mesh_index` (panics if the index is out of range; in
|
||||
/// practice it is always valid, being derived from `mesh_order` positions).
|
||||
pub fn mesh_by_index(&self, index: u32) -> &Arc<Mesh> {
|
||||
&self.meshes[&self.mesh_order[index as usize]]
|
||||
}
|
||||
|
||||
/// Number of entity slots (tombstones included) — the `num_slots` written to the cull uniforms
|
||||
/// (slots at/beyond this are no-ops on the GPU).
|
||||
pub fn num_slots(&self) -> usize {
|
||||
self.entity_slots.len()
|
||||
}
|
||||
|
||||
/// Number of *active* entity slots (tombstones excluded) — the live entity count.
|
||||
pub fn num_active_slots(&self) -> usize {
|
||||
self.entity_slots
|
||||
.iter()
|
||||
.filter(|s| self.entities.contains_key(&s.label))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -586,4 +753,44 @@ mod tests {
|
||||
assert!(!scene.set_entity_transform("missing", Transform::identity()));
|
||||
assert!(!scene.remove_entity("missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_driven_slot_bookkeeping_is_empty_when_no_entities() {
|
||||
// The GPU-driven slot system starts empty; the full slot/mesh interplay requires a
|
||||
// wgpu device (to build meshes) and is validated by the examples.
|
||||
let scene = Scene::new();
|
||||
assert_eq!(scene.num_slots(), 0);
|
||||
assert_eq!(scene.num_active_slots(), 0);
|
||||
assert!(scene.packed_transform_slots().is_empty());
|
||||
assert!(scene.mesh_bboxes().is_empty());
|
||||
assert!(scene.iter_slot_draws().next().is_none());
|
||||
assert!(scene.mesh_index_of("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_slot_roundtrips_transform_and_is_inactive_when_tombstoned() {
|
||||
// Exercises the CPU→GPU packing (GPU-independent): an active slot packs the transform +
|
||||
// mesh index + draw count + index flag; a tombstoned slot packs to `inactive()`.
|
||||
let t = Transform {
|
||||
translation: glam::Vec3::new(1.0, 2.0, 3.0),
|
||||
..Transform::identity()
|
||||
};
|
||||
let slot = TransformSlot::from_transform(&t, 7, 36, true);
|
||||
assert_eq!(slot.mesh_index(), 7, "mesh index packed in flags.x");
|
||||
assert!(slot.is_active(), "active = 1");
|
||||
assert_eq!(slot.draw_count(), 36, "draw count packed in flags.z");
|
||||
assert!(slot.has_index(), "indexed flag packed in flags.w");
|
||||
assert!(
|
||||
slot.translation
|
||||
.iter()
|
||||
.zip([1.0, 2.0, 3.0].iter())
|
||||
.all(|(a, b)| (a - b).abs() < 1e-5)
|
||||
);
|
||||
|
||||
let inactive = TransformSlot::inactive();
|
||||
assert!(!inactive.is_active(), "inactive active-flag = 0");
|
||||
assert_eq!(inactive.mesh_index(), 0);
|
||||
assert_eq!(inactive.draw_count(), 0);
|
||||
assert!(!inactive.has_index());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// # GPU-driven rendering compute shader (Phase 3, Step 15)
|
||||
//
|
||||
// Two compute entry points run sequentially in a single command encoder, before the render passes:
|
||||
// 1. `compute_matrices` derives each entity's world matrix on the GPU from its transform slot.
|
||||
// 2. `cull` decides per-entity visibility (bounding sphere vs frustum) and fills the indirect
|
||||
// draw arguments (the vertex/index count, zeroed when the entity is culled or inactive).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// GPU buffer layouts mirror the bytemuck structs in `resources::uniform` (byte-for-byte):
|
||||
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B).
|
||||
//
|
||||
// GOTCHA — WGSL `select` argument order: `select(reject, accept, cond)` returns the SECOND
|
||||
// argument when `cond` is true and the FIRST when false (the reverse of HLSL's
|
||||
// `select(trueVal, falseVal, cond)`). The original cull pass wrote
|
||||
// `select(u32(t.flags.z), 0u, visible)`, which zeroed the count of every VISIBLE entity (and
|
||||
// would have drawn the culled ones) — the source of the black-window bug.
|
||||
|
||||
// 64 bytes: packed TRS, the single source of truth for world matrices.
|
||||
struct TransformSlot {
|
||||
translation : vec3f,
|
||||
flags : vec4f, // x = mesh index, y = active, z = draw count, w = has_index
|
||||
rotation : vec4f,
|
||||
scale : vec3f,
|
||||
};
|
||||
|
||||
// 256 bytes: a 64-byte world matrix followed by 192 bytes of padding. The padding is REQUIRED —
|
||||
// the render pipelines read this slot through the `uniform` object group with a per-slot dynamic
|
||||
// offset, and WebGPU demands that offset be a multiple of `min_uniform_buffer_offset_alignment`
|
||||
// (256 bytes). A bare 64-byte matrix can never be individually addressable via a uniform offset,
|
||||
// so each slot is padded to a 256-byte boundary (capacity is capped at 256 = 64 KB / 256 B).
|
||||
struct MatSlot {
|
||||
m : mat4x4f,
|
||||
pad : array<vec4f, 12>, // 192 bytes, alignment only — never read
|
||||
};
|
||||
|
||||
// 32 bytes: local-space axis-aligned bounding box (uploaded once per mesh).
|
||||
struct BBoxSlot {
|
||||
min : vec3f,
|
||||
max : vec3f,
|
||||
};
|
||||
|
||||
// 80 bytes: indirect draw arguments for one entity (a 4-u32 non-indexed / 5-u32 indexed block).
|
||||
// Only `.a` is written by the shader; the rest stays zero (instance count is the constant 1 in `.a.y`).
|
||||
struct DrawSlot {
|
||||
a : vec4u,
|
||||
b : vec4u,
|
||||
c : vec4u,
|
||||
d : vec4u,
|
||||
e : vec4u,
|
||||
};
|
||||
|
||||
// 112 bytes: frustum planes + control flags, rewritten by the CPU each frame.
|
||||
struct CullUniforms {
|
||||
planes : array<vec4f, 6>, // unit (normal, d); inside iff dot(p, normal) + d >= 0
|
||||
num_slots : u32, // number of live entity slots
|
||||
culling : u32, // 0 = culling disabled, 1 = enabled
|
||||
_pad : vec2u,
|
||||
};
|
||||
|
||||
// ---- Bind groups (Step 15.5) ----
|
||||
// Group 0 (transforms) is shared by both entry points; group 1 (matrices) by `compute_matrices`;
|
||||
// group 2 (cull uniforms + bboxes + draw args) by `cull`. Each pipeline infers the subset it uses.
|
||||
@group(0) @binding(0) var<storage, read> transforms : array<TransformSlot>;
|
||||
@group(1) @binding(0) var<storage, read_write> matrices : array<MatSlot>;
|
||||
@group(2) @binding(0) var<uniform> cull_u : CullUniforms;
|
||||
@group(2) @binding(1) var<storage, read> bboxes : array<BBoxSlot>;
|
||||
@group(2) @binding(2) var<storage, read_write> draw_args : array<DrawSlot>;
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
// Builds a rotation mat4x4f from a quaternion (x, y, z, w) in column-major form.
|
||||
fn quat_to_mat4(q : vec4f) -> mat4x4f {
|
||||
let x = q.x;
|
||||
let y = q.y;
|
||||
let z = q.z;
|
||||
let w = q.w;
|
||||
return mat4x4f(
|
||||
vec4f(1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z), 2.0 * (x * z - w * y), 0.0),
|
||||
vec4f(2.0 * (x * y - w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + w * x), 0.0),
|
||||
vec4f(2.0 * (x * z + w * y), 2.0 * (y * z - w * x), 1.0 - 2.0 * (x * x + y * y), 0.0),
|
||||
vec4f(0.0, 0.0, 0.0, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
// Rotates a local-space vector by a quaternion (via the rotation matrix).
|
||||
fn rotate_by_quat(v : vec3f, q : vec4f) -> vec3f {
|
||||
let m = quat_to_mat4(q);
|
||||
return (m * vec4f(v, 0.0)).xyz;
|
||||
}
|
||||
|
||||
// World matrix = T * R * S, column-major (matches the CPU `Transform::to_matrix`, D13).
|
||||
// Returns just the 4x4 matrix; the caller stores it in `matrices[i].m` (the slot's 192-byte pad
|
||||
// is left at its zero-initialised value).
|
||||
fn world_matrix(t : TransformSlot) -> mat4x4f {
|
||||
let r = quat_to_mat4(t.rotation);
|
||||
return mat4x4f(
|
||||
r[0] * t.scale.x,
|
||||
r[1] * t.scale.y,
|
||||
r[2] * t.scale.z,
|
||||
vec4f(t.translation.x, t.translation.y, t.translation.z, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
// The identity world matrix (used for inactive slots so any stale read is harmless).
|
||||
fn identity_mat() -> mat4x4f {
|
||||
return mat4x4f(
|
||||
vec4f(1.0, 0.0, 0.0, 0.0),
|
||||
vec4f(0.0, 1.0, 0.0, 0.0),
|
||||
vec4f(0.0, 0.0, 1.0, 0.0),
|
||||
vec4f(0.0, 0.0, 0.0, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
// Fills a draw slot with a non-zero (visible) count of `count`, or zero (culled / inactive).
|
||||
// The count lands in `.a.x`; `.a.y` (instance count) is the constant 1; the rest stays zero.
|
||||
fn set_draw_count(i : u32, count : u32) {
|
||||
draw_args[i].a = vec4u(count, 1u, 0u, 0u);
|
||||
}
|
||||
|
||||
// ---- Pass 1: derive world matrices (Step 15.5) ----
|
||||
// Dispatched for MAX_ENTITIES; inactive slots get the identity matrix (a harmless stale read).
|
||||
@compute
|
||||
@workgroup_size(64)
|
||||
fn compute_matrices(@builtin(global_invocation_id) gid : vec3u) {
|
||||
let i = gid.x;
|
||||
let t = transforms[i];
|
||||
if (t.flags.y < 0.5) {
|
||||
matrices[i].m = identity_mat();
|
||||
} else {
|
||||
matrices[i].m = world_matrix(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pass 2: cull + fill indirect draw args (Step 15.6) ----
|
||||
// Dispatched for MAX_ENTITIES. Slots at or beyond `num_slots` (and inactive slots) are zeroed so
|
||||
// the indirect render passes skip them; visible slots keep their packed count.
|
||||
@compute
|
||||
@workgroup_size(64)
|
||||
fn cull(@builtin(global_invocation_id) gid : vec3u) {
|
||||
let i = gid.x;
|
||||
let t = transforms[i];
|
||||
|
||||
// Beyond the live slots: zero the count so the indirect draw is a no-op.
|
||||
if (i >= cull_u.num_slots) {
|
||||
set_draw_count(i, 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inactive slot (tombstone): no draw.
|
||||
if (t.flags.y < 0.5) {
|
||||
set_draw_count(i, 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
// Culling disabled: every active entity is visible, with its packed count.
|
||||
if (cull_u.culling == 0u) {
|
||||
set_draw_count(i, u32(t.flags.z));
|
||||
return;
|
||||
}
|
||||
|
||||
// Culling enabled: test the entity's world bounding sphere against the frustum planes.
|
||||
let b = bboxes[u32(t.flags.x)];
|
||||
let center_local = (b.min + b.max) * 0.5;
|
||||
// World center = translation + rotation * local center (no scale; the radius carries the scale).
|
||||
let center_world = t.translation + rotate_by_quat(center_local, t.rotation);
|
||||
let half_extents = (b.max - b.min) * 0.5;
|
||||
let radius = length(half_extents) * max(t.scale.x, max(t.scale.y, t.scale.z));
|
||||
|
||||
var visible = true;
|
||||
for (var p = 0u; p < 6u; p = p + 1u) {
|
||||
let plane = cull_u.planes[p];
|
||||
let dist = dot(plane.xyz, center_world) + plane.w;
|
||||
if (dist < -radius) {
|
||||
visible = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: WGSL `select(reject, accept, cond)` — the accept value is the SECOND argument.
|
||||
// visible => full count; culled => 0.
|
||||
let count = select(0u, u32(t.flags.z), visible);
|
||||
set_draw_count(i, count);
|
||||
}
|
||||
@@ -35,6 +35,32 @@ pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl";
|
||||
/// (Step 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read.
|
||||
pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl");
|
||||
|
||||
/// The GPU-driven rendering compute shader source (Phase 3, Step 15), embedded at compile time.
|
||||
/// It carries two compute entry points — `compute_matrices` (derive world matrices) and `cull`
|
||||
/// (per-entity visibility + indirect draw args) — compiled directly by the renderer (internal to
|
||||
/// the library; no external file is read).
|
||||
pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
|
||||
|
||||
/// Fixed capacity of the GPU-driven entity slot buffers (Phase 3). The transform, matrix, bbox and
|
||||
/// indirect-draw-args buffers are all sized to this capacity and allocated once; per frame the CPU
|
||||
/// rewrites only the transform slots and the cull uniforms.
|
||||
///
|
||||
/// **Why 256 (and why the matrix slot is padded to 256 B):** the matrix buffer is bound to the
|
||||
/// render pipeline's `uniform` object slot (group 1), which imposes TWO limits:
|
||||
/// (1) a single uniform binding is capped at `max_uniform_buffer_binding_size` (64 KB on most
|
||||
/// backends), and (2) a uniform offset must be a multiple of `min_uniform_buffer_offset_alignment`
|
||||
/// (256 B). A 64-byte matrix can therefore never be individually addressable via a per-slot
|
||||
/// dynamic offset, so each matrix slot is padded to 256 B (see `MatSlot`); with 256-byte slots,
|
||||
/// 256 slots × 256 B is exactly 64 KB — the largest the single-buffer / dynamic-offset design
|
||||
/// can address. (The transform / bbox / draw-args buffers are `storage` bindings with a 128 MB
|
||||
/// limit and no 256-B offset rule, so they keep their natural 64 / 32 / 80 B slot sizes.)
|
||||
/// 256 is a multiple of the 64-wide workgroup size, giving a whole number of workgroups.
|
||||
pub const MAX_ENTITIES: u32 = 256;
|
||||
|
||||
/// Workgroup size of the GPU-driven compute shaders (matches the `@workgroup_size` in
|
||||
/// `gpu_driven.wgsl`). The compute dispatch is `MAX_ENTITIES / WORKGROUP_SIZE` workgroups.
|
||||
pub const GPU_WORKGROUP_SIZE: u32 = 64;
|
||||
|
||||
/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good
|
||||
/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes.
|
||||
pub const SHADOW_MAP_SIZE: u32 = 1024;
|
||||
|
||||
@@ -54,3 +54,34 @@ fn shadow_shader_is_valid_wgsl() {
|
||||
.collect();
|
||||
assert_eq!(entry_names, vec!["vs_main"], "only vs_main expected");
|
||||
}
|
||||
|
||||
/// Parses and fully validates the embedded `gpu_driven.wgsl` compute shader (Phase 3, Step 15)
|
||||
/// via naga. The renderer compiles it directly into two `ComputePipeline`s (one per entry point),
|
||||
/// so this offline validation is the guarantee of its validity. The contract expects exactly the
|
||||
/// two compute entry points: `compute_matrices` and `cull`.
|
||||
#[test]
|
||||
fn gpu_driven_shader_is_valid_wgsl() {
|
||||
let src = include_str!("../src/shaders/gpu_driven.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("gpu_driven.wgsl: parsing error: {e:?}"));
|
||||
|
||||
let mut validator = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
);
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("gpu_driven.wgsl: validation failed: {e:?}"));
|
||||
|
||||
let mut entry_names: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
.map(|ep| ep.name.as_str())
|
||||
.collect();
|
||||
entry_names.sort();
|
||||
assert_eq!(
|
||||
entry_names,
|
||||
vec!["compute_matrices", "cull"],
|
||||
"the two compute entry points are expected"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user