LOD GPU
This commit is contained in:
+230
-13
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user