This commit is contained in:
Jérôme Bousquié
2026-09-22 20:47:36 +02:00
parent 531c43a457
commit f15e920109
16 changed files with 1981 additions and 264 deletions
+230 -13
View File
@@ -22,25 +22,26 @@
use crate::core::Context;
use crate::core::Frame;
use crate::math::Frustum;
use crate::math::lod::{lod_level, projected_radius_px};
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::{
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,
LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE,
SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot,
};
use crate::resources::{
Camera, CullUniforms, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform,
};
use crate::scene::Scene;
use crate::utils::conf::{
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, MAX_ENTITIES, SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE,
SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS,
SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS,
};
use glam::{Mat4, Vec3, Vec4};
use std::cell::Cell;
use glam::{Mat4, Quat, Vec3, Vec4};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
@@ -108,6 +109,12 @@ pub struct Renderer {
draw_args_buffer: wgpu::Buffer,
/// GPU cull uniforms (`UNIFORM | COPY_DST`): frustum planes + control flags; rewritten each frame.
cull_uniform_buffer: wgpu::Buffer,
/// GPU per-slot LOD levels (`STORAGE | COPY_DST`): one u32 per entity slot, the CPU's per-frame
/// level decision (Step 19, D8); read by `cull` (group 2, binding 3).
lod_levels_buffer: wgpu::Buffer,
/// GPU per-mesh LOD tables (`STORAGE | COPY_DST`): one 80-byte [`LodTable`] per mesh in
/// `mesh_order` order; read by `cull` (group 2, binding 4) to map a level to its draw args.
lod_tables_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).
@@ -127,6 +134,17 @@ pub struct Renderer {
/// frame. Interior-mutable (all-`&self` API); exposed through `debug_dump` for the
/// state-change A/B measurement (Étape 18 verification).
debug_pipeline_switches: Cell<u32>,
/// Whether LOD is enabled (Step 19, D8). When `false` the CPU writes level 0 for every slot
/// each frame, and the GPU indirect args are byte-identical to the pre-LOD behavior
/// (level-0 rows carry the full-mesh counts). Interior-mutable (all-`&self` API).
lod_enabled: Cell<bool>,
/// Per-slot level of the PREVIOUS frame — the hysteresis state of [`lod_level`] (Step 19, D4):
/// going coarser requires a 20 % dead band measured against this value. Interior-mutable
/// (all-`&self` API); resized when the slot count grows (entity append).
last_lod_levels: RefCell<Vec<u32>>,
/// Viewport height in pixels (Step 19, D9): the unit of the LOD projected-size test. Set from
/// the initial surface size in `new` and refreshed by `resize_depth` on window resize.
viewport_height: u32,
}
impl Renderer {
@@ -310,6 +328,27 @@ impl Renderer {
},
count: None,
},
// Step 19 (LOD): per-slot levels (storage read) + per-mesh tables (storage read).
wgpu::BindGroupLayoutEntry {
binding: 3,
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: 4,
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_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -386,6 +425,25 @@ impl Renderer {
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
// Step 19 (LOD): per-slot levels (one u32 per entity slot, CPU-written each frame) and
// per-mesh tables (one 80-byte LodTable per mesh, mesh_order order). `COPY_SRC` lets
// `debug_dump` read them back. The cull pass maps each slot's level to its draw args.
let lod_levels_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU LOD levels"),
size: MAX_ENTITIES as u64 * 4,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let lod_tables_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("GPU LOD tables"),
size: MAX_ENTITIES as u64 * LOD_TABLE_SIZE,
usage: wgpu::BufferUsages::STORAGE
| 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
@@ -423,6 +481,15 @@ impl Renderer {
binding: 2,
resource: draw_args_buffer.as_entire_binding(),
},
// Step 19 (LOD): the level + table buffers (whole-buffer bindings).
wgpu::BindGroupEntry {
binding: 3,
resource: lod_levels_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: lod_tables_buffer.as_entire_binding(),
},
],
});
let matrix_object_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -464,12 +531,17 @@ impl Renderer {
bbox_buffer,
draw_args_buffer,
cull_uniform_buffer,
lod_levels_buffer,
lod_tables_buffer,
transform_bg,
matrices_bg,
cull_bundle_bg,
matrix_object_bg,
cull_enabled: Cell::new(false),
debug_pipeline_switches: Cell::new(0),
lod_enabled: Cell::new(true),
last_lod_levels: RefCell::new(Vec::new()),
viewport_height: height,
};
// 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.
@@ -511,6 +583,8 @@ impl Renderer {
let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height);
self._depth_texture = depth_texture;
self.depth_view = depth_view;
// Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test.
self.viewport_height = height;
}
/// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4).
@@ -704,16 +778,40 @@ impl Renderer {
bytemuck::cast_slice(&transform_slots),
);
// 2b. LOD (Step 19, D8): the CPU picks each slot's detail level from the projected
// bounding-sphere radius (asymmetric hysteresis in `math::lod`), then uploads the
// per-slot levels + per-mesh tables the `cull` pass maps level → indirect args.
// LOD disabled ⇒ every level is 0, and level-0 rows carry the full-mesh counts, so
// the GPU args are byte-identical to the pre-LOD behavior (D8 compatibility).
let camera = scene.camera();
let cam_view = camera.view_matrix();
let cam_proj = camera.projection_matrix(aspect);
let bboxes = scene.mesh_bboxes();
let lod_levels: Vec<u32> = if self.lod_enabled.get() {
self.compute_lod_levels(scene, &cam_view, &cam_proj, &transform_slots, &bboxes)
} else {
vec![0u32; transform_slots.len()]
};
self.queue.write_buffer(
&self.lod_levels_buffer,
0,
bytemuck::cast_slice(&lod_levels),
);
let lod_tables = scene.mesh_lod_tables();
self.queue.write_buffer(
&self.lod_tables_buffer,
0,
bytemuck::cast_slice(&lod_tables),
);
// 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 view_proj = cam_proj * cam_view;
let frustum = Frustum::from_view_proj(&view_proj);
let cull_uniforms =
CullUniforms::from_frustum(&frustum, scene.num_slots() as u32, self.cull_enabled.get());
@@ -831,7 +929,16 @@ impl Renderer {
// Group 1 (dynamic): the 64-byte matrix slice for this slot.
render_pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]);
render_pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..));
if slot.has_index {
// Step 19: the draw command follows the CHOSEN level's indexedness, not L0's —
// an Auto-mode mesh may mix indexed levels (e.g. L0 indexed, L1+ non-indexed).
// Level 0 (LOD off, or a single-level mesh) reproduces the pre-LOD command.
let row = slot
.mesh
.lod_rows()
.get(lod_levels[slot.slot_index] as usize)
.copied()
.unwrap_or_default();
if row.index_count > 0 {
if let Some(index_buffer) = &slot.mesh.index_buffer {
render_pass.set_index_buffer(
index_buffer.slice(..),
@@ -879,6 +986,8 @@ impl Renderer {
let cull_buf = self.cull_uniform_buffer.clone();
let transform_buf = self.transform_buffer.clone();
let bbox_buf = self.bbox_buffer.clone();
let lod_levels_buf = self.lod_levels_buffer.clone();
let lod_tables_buf = self.lod_tables_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`
@@ -900,12 +1009,14 @@ impl Renderer {
read
}
let specs: [(&wgpu::Buffer, u64); 5] = [
let specs: [(&wgpu::Buffer, u64); 7] = [
(&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),
(&lod_levels_buf, n as u64 * 4),
(&lod_tables_buf, n as u64 * LOD_TABLE_SIZE),
];
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut reads = Vec::with_capacity(specs.len());
@@ -953,8 +1064,17 @@ impl Renderer {
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]);
let (
mat_data,
args_data,
tr_data,
bb_data,
cull_data,
lod_levels_data,
lod_tables_data,
) = (
&data[0], &data[1], &data[2], &data[3], &data[4], &data[5], &data[6],
);
for i in 0..n {
let off = (i as u64 * TRANSFORM_SLOT_SIZE) as usize;
let t: TransformSlot =
@@ -990,6 +1110,28 @@ impl Renderer {
for (i, p) in c.planes.iter().enumerate() {
eprintln!("[dbg] plane[{i}] = {p:?}");
}
// Step 19 (LOD): the CPU-decided per-slot levels and the per-mesh tables the GPU
// maps level → indirect args from.
let levels: Vec<u32> = (0..n)
.map(|i| {
bytemuck::pod_read_unaligned(
&lod_levels_data[i as usize * 4..i as usize * 4 + 4],
)
})
.collect();
eprintln!(
"[dbg] lod: enabled={} viewport_height={} levels={:?}",
self.lod_enabled.get(),
self.viewport_height,
levels
);
for i in 0..n {
let off = (i as u64 * LOD_TABLE_SIZE) as usize;
let t: LodTable = bytemuck::pod_read_unaligned(
&lod_tables_data[off..off + LOD_TABLE_SIZE as usize],
);
eprintln!("[dbg] lod_table[{}] count={} rows={:?}", i, t.count, t.rows);
}
eprintln!(
"[dbg] pipeline switches (this frame's main pass) = {}",
self.debug_pipeline_switches.get()
@@ -1090,6 +1232,81 @@ impl Renderer {
pub fn set_culling(&self, enabled: bool) {
self.cull_enabled.set(enabled);
}
/// Enables or disables LOD (Step 19, D8). When disabled the CPU writes level 0 for every slot
/// each frame; level-0 rows carry the full-mesh draw counts, so the GPU indirect args are
/// byte-identical to the pre-LOD behavior (the scene renders exactly as before). When enabled
/// the CPU picks each slot's level from the projected bounding-sphere radius (with the
/// asymmetric hysteresis of `math::lod::lod_level`) and the `cull` pass maps level → args.
/// Enabled by default. Inputs: enabled (true = LOD on, false = always level 0).
pub fn set_lod_enabled(&self, enabled: bool) {
self.lod_enabled.set(enabled);
}
/// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the
/// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox
/// center + max half-extent × max scale component, rotated by the entity's quaternion) — is
/// projected to screen pixels ([`projected_radius_px`]), and [`lod_level`] turns that radius
/// into a level with asymmetric hysteresis (D4: the `last` level is the previous frame's
/// choice, kept in `self.last_lod_levels`).
///
/// Inactive (tombstoned) slots and single-level meshes get level 0 (and their hysteresis state
/// resets, so a re-added entity starts fresh). The result has one entry per transform slot.
fn compute_lod_levels(
&self,
scene: &Scene,
view: &Mat4,
proj: &Mat4,
transform_slots: &[TransformSlot],
bboxes: &[BBoxSlot],
) -> Vec<u32> {
let height = self.viewport_height.max(1) as f32;
let mut last = self.last_lod_levels.borrow_mut();
if last.len() != transform_slots.len() {
// Entity slots are append-only, but a resize keeps the old levels for the surviving
// slots (their hysteresis is meaningful) and zero-fills the new ones.
let keep = last.len().min(transform_slots.len());
let tail = last.split_off(keep);
last.extend(std::iter::repeat(0).take(transform_slots.len() - keep));
drop(tail);
}
let mut levels = vec![0u32; transform_slots.len()];
for (i, t) in transform_slots.iter().enumerate() {
if !t.is_active() {
last[i] = 0;
continue; // level 0 (zeroed vec); reset hysteresis for the tombstone
}
let mesh_idx = t.flags[0] as usize;
let mesh = scene.mesh_by_index(mesh_idx as u32);
let max_level = (mesh.num_lod_levels() as u32)
.saturating_sub(1)
.min(MAX_LOD_LEVELS - 1);
if max_level == 0 {
last[i] = 0;
continue; // single-level mesh: always L0
}
let b = &bboxes[mesh_idx];
let center = Vec3::new(
(b.min[0] + b.max[0]) * 0.5,
(b.min[1] + b.max[1]) * 0.5,
(b.min[2] + b.max[2]) * 0.5,
);
let half = Vec3::new(
(b.max[0] - b.min[0]) * 0.5,
(b.max[1] - b.min[1]) * 0.5,
(b.max[2] - b.min[2]) * 0.5,
);
// Mirror the WGSL cull pass exactly (D8): radius = |half-extents| × max(scale).
let radius = half.length() * t.scale[0].max(t.scale[1].max(t.scale[2]));
let center_world =
Vec3::from_array(t.translation) + Quat::from_array(t.rotation) * center;
let r_px = projected_radius_px(center_world, radius, *view, *proj, height);
let lvl = lod_level(r_px, last[i], max_level, &LOD_THRESHOLDS);
last[i] = lvl;
levels[i] = lvl;
}
levels
}
}
/// Allocates the depth texture + view backing the render passes' `depth_stencil_attachment`