LOD GPU
This commit is contained in:
+25
-6
@@ -11,7 +11,11 @@
|
||||
//! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets,
|
||||
//! * a **directional** light (the shadow caster) + a **point** light + a **spot** light,
|
||||
//! so the shadow of the cube and the colored light halos are all visible,
|
||||
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly.
|
||||
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly,
|
||||
//! * **LOD** (Step 19): the rounded primitives are created with three levels each
|
||||
//! (`create_mesh_with_lod`, auto-decimated by halving targets); the CPU picks each entity's
|
||||
//! level from its projected screen size (with hysteresis) — zoom in/out with the wheel and
|
||||
//! the sphere/cylinder/cone/torus visibly lose detail as they shrink on screen.
|
||||
//!
|
||||
//! Doc (this header) follows the English convention used for examples; internal comments stay
|
||||
//! concise and French where helpful. Run with:
|
||||
@@ -118,23 +122,38 @@ impl AppHandler for Demo {
|
||||
app.scene.add_entity("ground", "ground_mesh").unwrap();
|
||||
|
||||
// 4. One mesh per primitive, each assigned to a textured (or stripe) material.
|
||||
// The cube + ground stay single-level (tiny meshes — LOD would buy nothing); the
|
||||
// rounded primitives get three LOD levels each (Step 19): level 0 is the full mesh,
|
||||
// levels 1.. are auto-generated by greedy decimation at halving targets (D10), all
|
||||
// packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
|
||||
// switches levels on the fly (asymmetric hysteresis, D4).
|
||||
app.scene
|
||||
.create_mesh("cube_mesh", cube(0.8), Some("solid_mat"))
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh("sphere_mesh", uv_sphere(0.55, 32, 20), Some("stripes_mat"))
|
||||
.create_mesh_with_lod(
|
||||
"sphere_mesh",
|
||||
uv_sphere(0.55, 32, 20),
|
||||
Some("stripes_mat"),
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh("ico_mesh", icosphere(0.5, 2), Some("solid_mat"))
|
||||
.create_mesh_with_lod("ico_mesh", icosphere(0.5, 2), Some("solid_mat"), 3)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"))
|
||||
.create_mesh_with_lod("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"), 3)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"))
|
||||
.create_mesh_with_lod("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"), 3)
|
||||
.unwrap();
|
||||
app.scene
|
||||
.create_mesh("torus_mesh", torus(0.42, 0.16, 24, 16), Some("solid_mat"))
|
||||
.create_mesh_with_lod(
|
||||
"torus_mesh",
|
||||
torus(0.42, 0.16, 24, 16),
|
||||
Some("solid_mat"),
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
place("cube_e", "cube_mesh", app, 0);
|
||||
|
||||
+2
-2
@@ -7,11 +7,11 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| **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`) |
|
||||
| **resources** | Data types: Vertex (CPU-side), Mesh (GPU geometry + bounding box, multi-level LOD via packed vertex/index buffers), Material (appearance descriptor), Texture, Lights, Camera + CameraController, and the uniform slot types (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`/`LodRow`/`LodTable`) |
|
||||
| **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache |
|
||||
| **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) |
|
||||
| **math** | Transform, Geometry (per-attribute mesh data + AABB, greedy decimation `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) 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 |
|
||||
|
||||
+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`
|
||||
|
||||
@@ -326,6 +326,210 @@ impl Geometry {
|
||||
self.validate()?;
|
||||
Ok(self.to_vertices())
|
||||
}
|
||||
|
||||
/// Number of triangles: index count / 3, or vertex count / 3 for non-indexed geometry.
|
||||
pub fn num_triangles(&self) -> usize {
|
||||
match &self.indices {
|
||||
Some(i) => i.len() / 3,
|
||||
None => self.positions.len() / 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// The source triangles (explicit indices, or implicit for non-indexed input), with
|
||||
/// degenerate (zero-area) triangles discarded. Returns `None` when the input is
|
||||
/// malformed (index/vertex count not a multiple of 3) — callers fall back to a clone.
|
||||
fn non_degenerate_triangles(&self) -> Option<Vec<[u32; 3]>> {
|
||||
let vcount = self.positions.len() as u32;
|
||||
let tris: Vec<[u32; 3]> = if let Some(indices) = &self.indices {
|
||||
if indices.len() % 3 != 0 {
|
||||
return None;
|
||||
}
|
||||
indices
|
||||
.chunks_exact(3)
|
||||
.map(|c| [c[0] as u32, c[1] as u32, c[2] as u32])
|
||||
.collect()
|
||||
} else if vcount % 3 != 0 {
|
||||
return None;
|
||||
} else {
|
||||
(0..vcount).step_by(3).map(|i| [i, i + 1, i + 2]).collect()
|
||||
};
|
||||
Some(
|
||||
tris.into_iter()
|
||||
.filter(|t| Self::triangle_area(&self.positions, t) > 1e-8)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Area of a triangle given its three vertex indices (half the cross-product magnitude).
|
||||
fn triangle_area(positions: &[[f32; 3]], t: &[u32; 3]) -> f32 {
|
||||
let a = positions[t[0] as usize];
|
||||
let b = positions[t[1] as usize];
|
||||
let c = positions[t[2] as usize];
|
||||
let ux = b[0] - a[0];
|
||||
let uy = b[1] - a[1];
|
||||
let uz = b[2] - a[2];
|
||||
let vx = c[0] - a[0];
|
||||
let vy = c[1] - a[1];
|
||||
let vz = c[2] - a[2];
|
||||
let cx = uy * vz - uz * vy;
|
||||
let cy = uz * vx - ux * vz;
|
||||
let cz = ux * vy - uy * vx;
|
||||
(cx * cx + cy * cy + cz * cz).sqrt() * 0.5
|
||||
}
|
||||
|
||||
/// Greedy decimation (Step 19, D10 — pure function, no GPU): returns an **indexed**
|
||||
/// `Geometry` with ≈ `target_triangles` triangles, preserving the silhouette.
|
||||
///
|
||||
/// Algorithm: degenerate (zero-area) triangles are dropped, then the triangles are
|
||||
/// sorted by **ascending area** (stable, tie-broken by original index — deterministic)
|
||||
/// and the `T - target` smallest are removed. Any subset of a mesh's faces is a valid
|
||||
/// (possibly open) mesh — a removed triangle just leaves boundary edges, so no topology
|
||||
/// repair is needed (full manifold preservation would require edge collapse — out of
|
||||
/// scope, see DRAFT Step 19 "Out of scope"). The kept triangles are rebuilt in original
|
||||
/// triangle order:
|
||||
/// - vertices are **welded by exact position equality** (dedup; non-indexed input is
|
||||
/// welded first), and the output is always indexed;
|
||||
/// - smooth normals are **recomputed** over the kept faces (only when the source had
|
||||
/// normals);
|
||||
/// - UVs / colors take the value of the **first vertex of each welded group** encountered
|
||||
/// during the rebuild (documented trade-off: LOD sacrifices UV precision for the
|
||||
/// silhouette).
|
||||
///
|
||||
/// Fallback (never corrupts): target ≥ triangle count, target = 0, or malformed input
|
||||
/// (empty, or counts not multiples of 3) → `self.clone()`.
|
||||
pub fn decimated(&self, target_triangles: u32) -> Geometry {
|
||||
let Some(tris) = self.non_degenerate_triangles() else {
|
||||
return self.clone();
|
||||
};
|
||||
let t = tris.len() as u32;
|
||||
if t == 0 || target_triangles == 0 || target_triangles >= t {
|
||||
return self.clone();
|
||||
}
|
||||
|
||||
// Ascending area (stable; tie-broken by original index) → drop the smallest first.
|
||||
let mut order: Vec<(f32, u32)> = tris
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tri)| (Self::triangle_area(&self.positions, tri), i as u32))
|
||||
.collect();
|
||||
order.sort_by(|a, b| {
|
||||
a.0.partial_cmp(&b.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.1.cmp(&b.1))
|
||||
});
|
||||
|
||||
let needed = t - target_triangles;
|
||||
let mut removed = vec![false; tris.len()];
|
||||
for i in 0..needed as usize {
|
||||
removed[order[i].1 as usize] = true;
|
||||
}
|
||||
let kept: Vec<[u32; 3]> = tris
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !removed[*i])
|
||||
.map(|(_, tri)| tri)
|
||||
.collect();
|
||||
|
||||
// Rebuild: weld by exact position, remap indices in original triangle order.
|
||||
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(self.positions.len());
|
||||
let mut new_uvs: Option<Vec<[f32; 2]>> = self.uvs.is_some().then(Vec::new);
|
||||
let mut new_colors: Option<Vec<[f32; 4]>> = self.colors.is_some().then(Vec::new);
|
||||
// Key = exact float bits (f32 is not Hash/Eq; `to_bits` preserves exact equality).
|
||||
let mut map: std::collections::HashMap<(u32, u32, u32), u32> =
|
||||
std::collections::HashMap::new();
|
||||
let mut new_indices: Vec<u16> = Vec::with_capacity(kept.len() * 3);
|
||||
|
||||
for tri in &kept {
|
||||
for (_k, &vi) in tri.iter().enumerate() {
|
||||
let pos = self.positions[vi as usize];
|
||||
let key = (pos[0].to_bits(), pos[1].to_bits(), pos[2].to_bits());
|
||||
let ni = if let Some(&ni) = map.get(&key) {
|
||||
ni
|
||||
} else {
|
||||
let ni = new_positions.len() as u32;
|
||||
new_positions.push(pos);
|
||||
if let Some(uvs) = &mut new_uvs {
|
||||
uvs.push(self.uvs.as_ref().unwrap()[vi as usize]);
|
||||
}
|
||||
if let Some(colors) = &mut new_colors {
|
||||
colors.push(self.colors.as_ref().unwrap()[vi as usize]);
|
||||
}
|
||||
map.insert(key, ni);
|
||||
ni
|
||||
};
|
||||
new_indices.push(ni as u16);
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute smooth normals over the kept faces (only when the source had normals).
|
||||
let normals = self.normals.as_ref().map(|_| {
|
||||
let mut acc = vec![[0.0f32; 3]; new_positions.len()];
|
||||
// Accumulate with the REMAPPED (post-weld) indices: `kept` holds original indices,
|
||||
// which may not exist in `new_positions` once welding has merged duplicates.
|
||||
for i in 0..kept.len() {
|
||||
let (ia, ib, ic) = (
|
||||
new_indices[i * 3] as usize,
|
||||
new_indices[i * 3 + 1] as usize,
|
||||
new_indices[i * 3 + 2] as usize,
|
||||
);
|
||||
let a = new_positions[ia];
|
||||
let b = new_positions[ib];
|
||||
let c = new_positions[ic];
|
||||
let n = [
|
||||
(b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]),
|
||||
(b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]),
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]),
|
||||
];
|
||||
for vi in [ia, ib, ic] {
|
||||
let v = &mut acc[vi];
|
||||
v[0] += n[0];
|
||||
v[1] += n[1];
|
||||
v[2] += n[2];
|
||||
}
|
||||
}
|
||||
acc.iter()
|
||||
.map(|v| {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len < 1e-12 {
|
||||
[0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[v[0] / len, v[1] / len, v[2] / len]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Geometry {
|
||||
positions: new_positions,
|
||||
normals,
|
||||
uvs: new_uvs,
|
||||
colors: new_colors,
|
||||
indices: Some(new_indices),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates `levels` (clamped to `1..=MAX_LOD_LEVELS`) LOD geometries from `self`
|
||||
/// (Step 19, D10): level 0 is `self` (**byte-exact**), level k = `decimated(T × 0.5^k)`
|
||||
/// with the target clamped to `[1, T]` (a mesh never decimates below one triangle).
|
||||
/// Cost is setup-only (a few ms for thousands of triangles), never per frame.
|
||||
pub fn generate_lod_levels(&self, levels: u8) -> Vec<Geometry> {
|
||||
let levels = levels.clamp(1, crate::utils::conf::MAX_LOD_LEVELS as u8);
|
||||
let mut out = vec![self.clone()];
|
||||
let Some(tris) = self.non_degenerate_triangles() else {
|
||||
return out;
|
||||
};
|
||||
let t = tris.len() as u32;
|
||||
if t == 0 {
|
||||
return out;
|
||||
}
|
||||
for k in 1..levels {
|
||||
let target = ((t as f32) * 0.5f32.powi(k as i32))
|
||||
.round()
|
||||
.clamp(1.0, t as f32) as u32;
|
||||
out.push(self.decimated(target));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -465,4 +669,224 @@ mod tests {
|
||||
let geo = quad();
|
||||
assert_eq!(geo.indices(), Some(&[0, 1, 2, 0, 2, 3][..]));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// decimated / generate_lod_levels (Step 19, D10)
|
||||
// ========================================================================
|
||||
|
||||
/// Fan of three triangles sharing vertex 0, with distinct areas 0.5 / 4 / 16.
|
||||
fn tri_fan() -> Geometry {
|
||||
Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0], // 0 (shared)
|
||||
[1.0, 0.0, 0.0], // 1
|
||||
[0.0, 1.0, 0.0], // 2
|
||||
[2.0, 0.0, 0.0], // 3
|
||||
[0.0, 4.0, 0.0], // 4
|
||||
[4.0, 0.0, 0.0], // 5
|
||||
[0.0, 8.0, 0.0], // 6
|
||||
])
|
||||
.with_normals(vec![[0.0, 0.0, 1.0]; 7])
|
||||
.with_indices(vec![0, 1, 2, 0, 3, 4, 0, 5, 6])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_removes_smallest_triangles_first() {
|
||||
// Areas 0.5 / 4 / 16 → target 2 keeps the two largest.
|
||||
let out = tri_fan().decimated(2);
|
||||
assert_eq!(out.num_triangles(), 2);
|
||||
// The smallest triangle's unique vertices (1, 2) are gone.
|
||||
assert!(!out.positions.contains(&[1.0, 0.0, 0.0]));
|
||||
assert!(!out.positions.contains(&[0.0, 1.0, 0.0]));
|
||||
assert!(out.positions.contains(&[2.0, 0.0, 0.0]));
|
||||
assert!(out.positions.contains(&[0.0, 4.0, 0.0]));
|
||||
out.validate().expect("decimated output validates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_icosahedron_20_to_10() {
|
||||
use crate::math::primitives;
|
||||
let ico = primitives::icosphere(1.0, 0); // 12 vertices / 20 faces, equal area
|
||||
assert_eq!(ico.num_triangles(), 20);
|
||||
let out = ico.decimated(10);
|
||||
assert_eq!(out.num_triangles(), 10);
|
||||
out.validate().expect("output validates");
|
||||
// All output positions come from the input; the bbox stays inside the original.
|
||||
for p in &out.positions {
|
||||
assert!(ico.positions.contains(p));
|
||||
}
|
||||
let in_bb = ico.bbox().expect("input bbox");
|
||||
let out_bb = out.bbox().expect("output bbox");
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(out_bb.min[i] - in_bb.min[i]).abs() < 1e-5
|
||||
&& out_bb.min[i] >= in_bb.min[i] - 1e-5
|
||||
&& out_bb.max[i] <= in_bb.max[i] + 1e-5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_welds_non_indexed_input() {
|
||||
// Four triangles stored NON-indexed (duplicated corners). A and B share the
|
||||
// (0,0,0)-(4,0,0) edge; C and D are smaller and get removed at target 2.
|
||||
let geo = Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[4.0, 0.0, 0.0],
|
||||
[0.0, 4.0, 0.0], // tri A (big)
|
||||
[0.0, 0.0, 0.0], // tri B (big; shared edge duplicated)
|
||||
[4.0, 0.0, 0.0],
|
||||
[0.0, -4.0, 0.0],
|
||||
[0.0, 4.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[0.0, -4.0, 0.0], // tri C (small, removed)
|
||||
[1.0, 1.0, 0.0],
|
||||
[2.0, -1.0, 0.0],
|
||||
[0.0, -4.0, 0.0], // tri D (smallest, removed)
|
||||
]);
|
||||
assert!(geo.indices().is_none());
|
||||
// Target 2 keeps the two big triangles; welding merges A and B's duplicated edge corners.
|
||||
let out = geo.decimated(2);
|
||||
assert!(out.indices().is_some(), "output is always indexed");
|
||||
assert_eq!(out.num_triangles(), 2);
|
||||
assert_eq!(out.positions.len(), 4, "12 input vertices weld to 4");
|
||||
assert_eq!(out.indices().unwrap(), &[0, 1, 2, 0, 1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_is_deterministic() {
|
||||
use crate::math::primitives;
|
||||
let ico = primitives::icosphere(1.0, 1); // 80 equal-area faces
|
||||
let a = ico.decimated(30);
|
||||
let b = ico.decimated(30);
|
||||
assert_eq!(a.indices(), b.indices());
|
||||
assert_eq!(a.positions, b.positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_fallback_target_at_or_above_count() {
|
||||
let geo = tri_fan(); // 3 triangles
|
||||
assert_eq!(
|
||||
geo.decimated(3).indices(),
|
||||
geo.indices(),
|
||||
"target == T → clone"
|
||||
);
|
||||
assert_eq!(
|
||||
geo.decimated(99).indices(),
|
||||
geo.indices(),
|
||||
"target > T → clone"
|
||||
);
|
||||
assert_eq!(
|
||||
geo.decimated(0).indices(),
|
||||
geo.indices(),
|
||||
"target 0 → clone"
|
||||
);
|
||||
// Malformed non-indexed input (vertex count not a multiple of 3) → clone.
|
||||
let bad = Geometry::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
|
||||
assert_eq!(bad.decimated(1).indices(), bad.indices());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_drops_degenerate_triangles() {
|
||||
// 2 real triangles + 1 degenerate (collinear) one.
|
||||
let geo = Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[0.0, 2.0, 0.0], // tri 0: area 2
|
||||
[0.0, 2.0, 0.0], // tri 1: area 2
|
||||
[2.0, 2.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[3.0, 0.0, 0.0], // tri 2: degenerate (collinear)
|
||||
[4.0, 0.0, 0.0],
|
||||
[5.0, 0.0, 0.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
// T (non-degenerate) = 2; target 1 → one real triangle kept, degenerate dropped.
|
||||
let out = geo.decimated(1);
|
||||
assert_eq!(out.num_triangles(), 1);
|
||||
assert!(
|
||||
!out.positions.contains(&[3.0, 0.0, 0.0]),
|
||||
"degenerate triangle dropped"
|
||||
);
|
||||
out.validate().expect("output validates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_recomputes_unit_normals() {
|
||||
// Garbage (unnormalized) input normals; output normals must be unit length.
|
||||
let geo = tri_fan().with_normals(vec![[9.0, 9.0, 9.0]; 7]);
|
||||
let out = geo.decimated(2);
|
||||
let normals = out.normals.expect("normals recomputed");
|
||||
for n in &normals {
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_uv_takes_first_encountered() {
|
||||
// Big triangle (area 2, good UVs) + small one (area 0.5, seam UVs) → the small
|
||||
// one is removed; welded UVs come from the kept triangle's vertices.
|
||||
// A and B are the two big triangles (kept); B reuses v0's position as v4 with a
|
||||
// DIFFERENT (seam) UV. C and D are small and removed at target 2. Welding v4 into v0
|
||||
// must keep the FIRST encountered UV (0,0) and drop the seam (9,9).
|
||||
let geo = Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[3.0, 0.0, 0.0],
|
||||
[3.0, 3.0, 0.0],
|
||||
[0.0, 3.0, 0.0],
|
||||
[0.0, 0.0, 0.0], // seam copy of v0
|
||||
[1.5, 1.5, 0.0],
|
||||
])
|
||||
.with_uvs(vec![
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
[0.0, 1.0],
|
||||
[9.0, 9.0], // seam UVs on the duplicate corner
|
||||
[8.0, 8.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 4, 2, 3, 3, 4, 5, 5, 1, 2]);
|
||||
let out = geo.decimated(2); // keeps the two big triangles
|
||||
assert_eq!(out.num_triangles(), 2);
|
||||
assert_eq!(
|
||||
out.positions.len(),
|
||||
4,
|
||||
"seam corner welds into its first occurrence"
|
||||
);
|
||||
assert_eq!(
|
||||
out.uvs.as_deref(),
|
||||
Some(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_lod_levels_halving_ratios() {
|
||||
use crate::math::primitives;
|
||||
let geo = primitives::icosphere(1.0, 1); // 80 triangles
|
||||
let levels = geo.generate_lod_levels(3);
|
||||
assert_eq!(levels.len(), 3);
|
||||
assert_eq!(levels[0].num_triangles(), 80, "L0 exact");
|
||||
assert_eq!(levels[0].indices(), geo.indices(), "L0 byte-exact");
|
||||
assert_eq!(levels[1].num_triangles(), 40);
|
||||
assert_eq!(levels[2].num_triangles(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_lod_levels_one_level() {
|
||||
let geo = tri_fan();
|
||||
let levels = geo.generate_lod_levels(1);
|
||||
assert_eq!(levels.len(), 1);
|
||||
assert_eq!(levels[0].indices(), geo.indices());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_lod_levels_tiny_mesh_clamps_to_one() {
|
||||
let geo = quad(); // 2 triangles, equal area
|
||||
let levels = geo.generate_lod_levels(4);
|
||||
assert_eq!(levels.len(), 4);
|
||||
assert_eq!(levels[0].num_triangles(), 2);
|
||||
assert_eq!(levels[1].num_triangles(), 1, "2 × 0.5");
|
||||
assert_eq!(levels[2].num_triangles(), 1, "2 × 0.25 → clamped to 1");
|
||||
assert_eq!(levels[3].num_triangles(), 1, "2 × 0.125 → clamped to 1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! # LOD — Per-frame Level Selection (pure, testable without a GPU)
|
||||
//!
|
||||
//! The pure functions behind the LOD feature (Step 19, D1/D4/D8). Each frame the **CPU**
|
||||
//! decides which detail level every entity draws; these functions do that math:
|
||||
//!
|
||||
//! - [`projected_radius_px`]: the entity's *perceived size* — its bounding-sphere radius in
|
||||
//! screen pixels (the **same sphere** the GPU frustum culling uses, D8);
|
||||
//! - [`lod_level`]: the level decision with **asymmetric hysteresis** (D4) — the core
|
||||
//! anti-flicker mechanism.
|
||||
//!
|
||||
//! Both are pure (no GPU, no state beyond the caller-supplied `last` level) → unit-testable.
|
||||
//! The Renderer calls them per slot each frame and uploads the resulting levels to the GPU,
|
||||
//! which only maps level → draw args (the packed-buffer offsets live in the per-mesh LOD
|
||||
//! table — see `resources::uniform::LodTable`).
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
/// Projected radius (in **pixels**) of a bounding sphere, given the camera's view/projection.
|
||||
///
|
||||
/// The sphere center (world space) is transformed into view space; a sphere at depth `d` with
|
||||
/// radius `r` subtends `r / d` in view space, which the projection's vertical scale
|
||||
/// (`proj.y.y = 1 / tan(fov / 2)`) maps to NDC — multiplied by `height_px / 2` (half the
|
||||
/// viewport height in pixels) gives pixels.
|
||||
///
|
||||
/// A sphere whose center is inside/behind the near plane (`depth <= 1e-4`) returns
|
||||
/// `f32::INFINITY` — the entity dominates the screen, so the finest level (0) is chosen.
|
||||
pub fn projected_radius_px(
|
||||
center_world: Vec3,
|
||||
radius: f32,
|
||||
view: Mat4,
|
||||
proj: Mat4,
|
||||
height_px: f32,
|
||||
) -> f32 {
|
||||
let v = view * Vec4::new(center_world.x, center_world.y, center_world.z, 1.0);
|
||||
let depth = -v.z; // view space: the camera looks along -Z (glam `look_at_mat4`)
|
||||
if depth <= 1e-4 {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
(radius / depth) * proj.y_axis.y * (height_px * 0.5)
|
||||
}
|
||||
|
||||
/// Level decision with **asymmetric hysteresis** (Step 19, D4).
|
||||
///
|
||||
/// `thresholds` is a **descending** pixel radius: `thresholds[k]` is the radius *above which*
|
||||
/// level k+1 is required (i.e. level k is sufficient up to that bound; level 0 has no bound).
|
||||
/// Levels beyond the threshold count share the last bound (clamped) — e.g. with `[48, 12]`
|
||||
/// only the first three levels are distinct.
|
||||
///
|
||||
/// Hysteresis (dead band):
|
||||
/// - to a **finer** level: immediate, as soon as `radius_px` exceeds the current level's bound;
|
||||
/// - to a **coarser** level: only if `radius_px <= bound(k) * 0.8` (20 % dead band), stepped
|
||||
/// incrementally (each intermediate bound × 0.8 must hold).
|
||||
///
|
||||
/// The "detail loss" pop (going coarser) is therefore delayed; the "detail regain" pop (going
|
||||
/// finer) is immediate — standard engine practice. `f32::INFINITY` (object at the camera)
|
||||
/// always returns 0. The result is always within `0..=max_level`.
|
||||
pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) -> u32 {
|
||||
if radius_px.is_infinite() || max_level == 0 || thresholds.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Bound for level k+1: the k-th threshold, clamped for levels beyond the threshold count.
|
||||
let bound = |k: u32| thresholds[(k as usize).min(thresholds.len() - 1)];
|
||||
let last = (last as usize).min(max_level as usize) as u32;
|
||||
|
||||
// Target without hysteresis: the coarsest level whose bound is still satisfied.
|
||||
let mut target = 0u32;
|
||||
let mut k = 0u32;
|
||||
while k < max_level {
|
||||
if radius_px <= bound(k) {
|
||||
target = k + 1;
|
||||
k += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if target <= last {
|
||||
// Finer or equal: immediate (no dead band on the way to more detail).
|
||||
target
|
||||
} else {
|
||||
// Coarser: 20 % dead band per step, incremental.
|
||||
let mut lvl = last;
|
||||
while lvl < target {
|
||||
if radius_px <= bound(lvl) * 0.8 {
|
||||
lvl += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
lvl
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::Mat4;
|
||||
use glam::Vec3;
|
||||
|
||||
/// A camera at `(0, 0, dist)` looking at the origin, up `+Y`, with vertical `fov`.
|
||||
fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
|
||||
let view =
|
||||
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y);
|
||||
let proj = glam::Mat4::perspective_rh_gl(fov, 1.0, 0.1, 100.0);
|
||||
(view, proj)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// projected_radius_px
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn projected_radius_analytic() {
|
||||
// Sphere of radius 1 at the origin; camera 5 units away; fov = 90°
|
||||
// (proj vertical scale = 1/tan(45°) = 1); viewport 1000 px tall.
|
||||
// Expected: (1 / 5) * 1 * 500 = 100 px.
|
||||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let r = projected_radius_px(Vec3::ZERO, 1.0, view, proj, 1000.0);
|
||||
assert!((r - 100.0).abs() < 1e-3, "expected 100 px, got {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_scale_invariance() {
|
||||
// 10x bigger object 10x further away → same projected radius (similarity).
|
||||
let (view1, proj1) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let (view2, proj2) = camera(50.0, std::f32::consts::PI / 2.0);
|
||||
let r1 = projected_radius_px(Vec3::ZERO, 1.0, view1, proj1, 1000.0);
|
||||
let r2 = projected_radius_px(Vec3::ZERO, 10.0, view2, proj2, 1000.0);
|
||||
assert!((r1 - r2).abs() < 1e-2, "expected equal, got {r1} vs {r2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_at_camera_is_infinite() {
|
||||
// Center at the camera position → depth 0 → INFINITY (finest level).
|
||||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let r = projected_radius_px(Vec3::new(0.0, 0.0, 5.0), 1.0, view, proj, 1000.0);
|
||||
assert!(r.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_behind_camera_is_infinite() {
|
||||
// Center behind the camera → negative depth → INFINITY.
|
||||
let (view, proj) = camera(5.0, std::f32::consts::PI / 2.0);
|
||||
let r = projected_radius_px(Vec3::new(0.0, 0.0, 20.0), 1.0, view, proj, 1000.0);
|
||||
assert!(r.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_radius_narrower_fov_larger_pixels() {
|
||||
// Narrower FOV (zoomed in) → LARGER vertical projection scale (1/tan(fov/2)) →
|
||||
// more pixels for the same sphere at the same distance.
|
||||
let fov_narrow = std::f32::consts::PI / 3.0; // 60°
|
||||
let fov_wide = std::f32::consts::PI / 2.0; // 90°
|
||||
let (v1, p1) = camera(5.0, fov_narrow);
|
||||
let (v2, p2) = camera(5.0, fov_wide);
|
||||
let r1 = projected_radius_px(Vec3::ZERO, 1.0, v1, p1, 1000.0);
|
||||
let r2 = projected_radius_px(Vec3::ZERO, 1.0, v2, p2, 1000.0);
|
||||
assert!(
|
||||
r1 > r2,
|
||||
"narrower FOV should give more pixels: {r1} vs {r2}"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// lod_level
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn lod_level_simple_thresholds() {
|
||||
let t = [48.0f32, 12.0];
|
||||
// r > 48 → level 0 (too big for any coarser level).
|
||||
assert_eq!(lod_level(100.0, 0, 2, &t), 0);
|
||||
assert_eq!(lod_level(52.0, 0, 2, &t), 0);
|
||||
// 48 >= r > 38.4 (0.8·48): target is L1, but the dead band holds it at L0.
|
||||
assert_eq!(lod_level(44.0, 0, 2, &t), 0);
|
||||
// r <= 38.4 → L1.
|
||||
assert_eq!(lod_level(38.4, 0, 2, &t), 1);
|
||||
assert_eq!(lod_level(30.0, 0, 2, &t), 1);
|
||||
// 12 > r > 9.6 (0.8·12): target L2, dead band holds at L1.
|
||||
assert_eq!(lod_level(10.0, 0, 2, &t), 1);
|
||||
// r <= 9.6 → L2 (both steps pass the band).
|
||||
assert_eq!(lod_level(9.6, 0, 2, &t), 2);
|
||||
assert_eq!(lod_level(9.0, 0, 2, &t), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_finer_is_immediate() {
|
||||
let t = [48.0f32, 12.0];
|
||||
// Already coarse (L2); radius grows past 48 → immediately back to L0.
|
||||
assert_eq!(lod_level(100.0, 2, 2, &t), 0);
|
||||
// L2, radius between the bounds → immediately to L1.
|
||||
assert_eq!(lod_level(30.0, 2, 2, &t), 1);
|
||||
// L1, radius past 48 → immediately to L0.
|
||||
assert_eq!(lod_level(52.0, 1, 2, &t), 0);
|
||||
// L1, radius below 12 → target L2 but dead band (10 > 9.6) holds at L1.
|
||||
assert_eq!(lod_level(10.0, 1, 2, &t), 1);
|
||||
// L1, radius below 9.6 → L2.
|
||||
assert_eq!(lod_level(9.0, 1, 2, &t), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_oscillation_is_stable() {
|
||||
// Anti-flicker (D4): a radius oscillating ±10 % around threshold 48 (43.2..52.8)
|
||||
// must not make the level flip back and forth.
|
||||
let t = [48.0f32];
|
||||
let mut level = 0u32;
|
||||
for _ in 0..100 {
|
||||
for r in [43.2f32, 52.8, 43.2, 52.8] {
|
||||
level = lod_level(r, level, 2, &t);
|
||||
}
|
||||
}
|
||||
// Whatever level it settled on, it must not have changed on the last pass.
|
||||
let before = level;
|
||||
for r in [43.2f32, 52.8, 43.2, 52.8] {
|
||||
level = lod_level(r, level, 2, &t);
|
||||
}
|
||||
assert_eq!(before, level, "level flickered around the threshold");
|
||||
// From L0 the oscillation never leaves L0 (coarser needs r ≤ 38.4).
|
||||
assert_eq!(lod_level(43.2, 0, 2, &t), 0);
|
||||
assert_eq!(lod_level(52.8, 0, 2, &t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_clamped_thresholds_for_extra_levels() {
|
||||
// 4 levels but only 2 thresholds: levels 2 and 3 share the last bound (12).
|
||||
let t = [48.0f32, 12.0];
|
||||
// r = 9 passes both bands (38.4, 9.6) AND the clamped third bound (0.8·12) → L3.
|
||||
assert_eq!(lod_level(9.0, 0, 3, &t), 3);
|
||||
// r = 10 passes the first two targets but the clamped band holds at L2.
|
||||
assert_eq!(lod_level(10.0, 0, 3, &t), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_infinite_returns_zero() {
|
||||
let t = [48.0f32, 12.0];
|
||||
assert_eq!(lod_level(f32::INFINITY, 2, 2, &t), 0);
|
||||
assert_eq!(lod_level(f32::INFINITY, 0, 2, &t), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_level_degenerate_inputs() {
|
||||
let t = [48.0f32];
|
||||
assert_eq!(lod_level(1.0, 5, 0, &t), 0); // max_level 0
|
||||
assert_eq!(lod_level(1.0, 0, 2, &[]), 0); // no thresholds
|
||||
// Stale `last` beyond max_level is clamped, not a panic.
|
||||
assert_eq!(lod_level(100.0, 9, 2, &t), 0);
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,17 @@
|
||||
//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form
|
||||
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
|
||||
//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry`
|
||||
//! - `lod.rs`: Pure LOD level-selection functions (projected radius + hysteresis, Step 19)
|
||||
|
||||
pub mod frustum;
|
||||
pub mod geometry;
|
||||
pub mod lod;
|
||||
pub mod primitives;
|
||||
pub mod transform;
|
||||
|
||||
// Re-exports
|
||||
pub use frustum::Frustum;
|
||||
pub use geometry::{BBox, Geometry, GeometryError};
|
||||
pub use lod::{lod_level, projected_radius_px};
|
||||
pub use primitives::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
|
||||
pub use transform::Transform;
|
||||
|
||||
+307
-44
@@ -14,84 +14,209 @@
|
||||
//! - **CPU+GPU retention (DRAFT Step 8, D5)**: `geometry` (CPU) and the vertex/index buffers (GPU) coexist.
|
||||
//! The GPU buffers are uploaded once at creation; the `Arc<Geometry>` is kept for CPU-side computations
|
||||
//! without re-uploading per frame.
|
||||
//! - **LOD packing (Step 19, D7)**: a multi-level mesh packs ALL its levels into **one** vertex buffer and
|
||||
//! **one** index buffer (level k lives at a byte offset), because WebGPU forbids dynamic offsets on
|
||||
//! vertex/index bindings — only the draw ARGS move per frame. The per-level offsets live in the
|
||||
//! `LodRow`s (uploaded to the GPU LOD table); the shadow/main passes always bind level 0.
|
||||
//!
|
||||
//! ## Construction (DRAFT Step 8, D4)
|
||||
//! The single canonical constructor is [`Mesh::from_geometry`]. The former `Mesh::new`/`Mesh::with_material`
|
||||
//! ## Construction (DRAFT Step 8, D4; Step 19, D6/D7)
|
||||
//! The single canonical constructor is [`Mesh::from_geometry_lod`] (levels + mode); [`Mesh::from_geometry`]
|
||||
//! is its one-level convenience wrapper. The former `Mesh::new`/`Mesh::with_material`
|
||||
//! (which took raw `&[Vertex]`) were removed in Step 8: the `Scene` declares meshes from a `Geometry`, and
|
||||
//! `Mesh` derives its interleaved vertices internally via `Geometry::to_vertices()`.
|
||||
|
||||
use crate::math::Geometry;
|
||||
use crate::resources::Material;
|
||||
use crate::resources::Vertex;
|
||||
use crate::resources::uniform::LodRow;
|
||||
use std::sync::Arc;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// How a mesh's LOD levels were produced (Step 19, D6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LodMode {
|
||||
/// No LOD: a single level (the plain `create_mesh` path).
|
||||
#[default]
|
||||
Off,
|
||||
/// Levels 1.. were auto-generated by greedy decimation (`Geometry::decimated`, D10).
|
||||
Auto,
|
||||
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
|
||||
Explicit,
|
||||
}
|
||||
|
||||
/// Persistent GPU geometry: vertex positions, optional indices, draw call counters, and the retained
|
||||
/// CPU `Geometry` (Step 8). Created once via `Mesh::from_geometry()` during scene setup; referenced by
|
||||
/// Renderer for every frame. A Mesh optionally references the `Material` used to render it (`Option<Arc<Material>>`).
|
||||
/// When `material()` is `None`, the `Scene` supplies its default material at draw time (DRAFT Step 7.3.5).
|
||||
///
|
||||
/// Since Step 19 a Mesh may carry several LOD levels: they are packed into the single vertex/index
|
||||
/// buffers (D7) and described by `lod_rows` (uploaded per mesh as a [`crate::resources::uniform::LodTable`]).
|
||||
pub struct Mesh {
|
||||
/// Shared CPU geometry this mesh was built from (Step 8, D5). Retained for CPU-side computation
|
||||
/// Shared CPU geometry of level 0 (the full mesh, Step 8, D5). Retained for CPU-side computation
|
||||
/// (bounding boxes, UV access, normal queries) and shared across meshes with identical geometry.
|
||||
geometry: Arc<Geometry>,
|
||||
/// GPU buffer containing vertex attribute data (position, UV, color).
|
||||
/// GPU buffer containing the packed vertex data of ALL levels (one `Vertex` per position, levels
|
||||
/// concatenated in level order; level 0 first).
|
||||
pub vertex_buffer: wgpu::Buffer,
|
||||
/// Optional GPU buffer for indexed drawing. Present when the mesh uses index-based rendering instead of simple vertex iteration.
|
||||
/// Optional GPU buffer with the packed indices of all indexed levels (rebased onto the packed
|
||||
/// vertex layout). `None` when no level is indexed.
|
||||
pub index_buffer: Option<wgpu::Buffer>,
|
||||
/// Number of vertices in the mesh. Used as `0..num_vertices` for non-indexed draws.
|
||||
/// Number of vertices of level 0. Used as `0..num_vertices` for non-indexed draws.
|
||||
pub num_vertices: u32,
|
||||
/// Number of indices in the index buffer. Used as `0..num_indices` for indexed draws.
|
||||
/// Number of indices of level 0. Used as `0..num_indices` for indexed draws.
|
||||
pub num_indices: u32,
|
||||
/// The Material used to render this mesh. `None` until assigned; the Renderer falls back to the
|
||||
/// Scene's default material when absent (DRAFT Step 7.3.5).
|
||||
material: Option<Arc<Material>>,
|
||||
/// How the levels were produced (Step 19, D6).
|
||||
lod_mode: LodMode,
|
||||
/// The CPU geometry of every level, level 0 first (all retained; `geometry` is `lod_levels[0]`).
|
||||
lod_levels: Vec<Arc<Geometry>>,
|
||||
/// The packed-buffer offsets per level (mirrors the uploaded per-mesh LOD table).
|
||||
lod_rows: Vec<LodRow>,
|
||||
}
|
||||
|
||||
/// Error returned by [`pack_levels`] when the packed vertex total exceeds the u16 index range.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PackError {
|
||||
/// Total packed vertices (all levels) — must be < 65536 for u16 rebased indices.
|
||||
pub total_vertices: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PackError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"packed LOD vertex total {} exceeds the 65535 u16 index limit",
|
||||
self.total_vertices
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PackError {}
|
||||
|
||||
/// Pure packing of LOD levels (Step 19, D7) — no GPU, unit-testable.
|
||||
///
|
||||
/// Concatenates every level's interleaved vertices into one flat slice (level 0 first) and rebases
|
||||
/// every indexed level's indices onto the packed vertex layout. Returns the packed vertices, the
|
||||
/// packed indices (`None` when no level is indexed), and one [`LodRow`] per level carrying its
|
||||
/// **element** offsets + counts. Offsets are in ELEMENT units (vertex indices / index elements),
|
||||
/// not bytes: the GPU cull pass copies them straight into the WebGPU indirect draw args, whose
|
||||
/// `first_vertex`/`first_index` fields are element indices — the packed buffers are bound in full
|
||||
/// (offset 0) and only the args' first_* fields move per level.
|
||||
///
|
||||
/// Fails with [`PackError`] when the **total** packed vertex count reaches 65536 (u16 rebased
|
||||
/// indices cannot address it). Callers (the Scene APIs) validate the total beforehand and report
|
||||
/// the error to the user; a single-level mesh can never fail (its indices are already u16).
|
||||
pub(crate) fn pack_levels(
|
||||
levels: &[Arc<Geometry>],
|
||||
) -> Result<(Vec<Vertex>, Option<Vec<u16>>, Vec<LodRow>), PackError> {
|
||||
let total: u32 = levels.iter().map(|l| l.positions.len() as u32).sum();
|
||||
if total >= 65536 {
|
||||
return Err(PackError {
|
||||
total_vertices: total,
|
||||
});
|
||||
}
|
||||
|
||||
let mut vertices: Vec<Vertex> = Vec::new();
|
||||
let mut indices: Vec<u16> = Vec::new();
|
||||
let mut any_indexed = false;
|
||||
let mut rows: Vec<LodRow> = Vec::with_capacity(levels.len());
|
||||
|
||||
let mut vertex_base = 0u32; // element (vertex index) base of the level in the packed buffer
|
||||
let mut index_base = 0u32; // element (index element) base of the level in the packed buffer
|
||||
for level in levels {
|
||||
let level_vertices = level.to_vertices();
|
||||
// Element units (NOT bytes): the row's offsets feed the WebGPU indirect draw args
|
||||
// (first_vertex = vertex index, first_index = index element) — see the doc above.
|
||||
let level_vertex_offset = vertex_base;
|
||||
let level_vertex_count = level_vertices.len() as u32;
|
||||
|
||||
let (level_index_offset, level_index_count) = match level.indices() {
|
||||
Some(data) => {
|
||||
any_indexed = true;
|
||||
for &idx in data {
|
||||
// Safe: the total-vertex check above guarantees no u16 overflow.
|
||||
indices.push(idx as u32 as u16 + vertex_base as u16);
|
||||
}
|
||||
(index_base, data.len() as u32)
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
rows.push(LodRow::new(
|
||||
level_vertex_offset,
|
||||
level_vertex_count,
|
||||
level_index_offset,
|
||||
level_index_count,
|
||||
));
|
||||
vertex_base += level_vertex_count;
|
||||
index_base += level_index_count;
|
||||
vertices.extend(level_vertices);
|
||||
}
|
||||
|
||||
Ok((vertices, any_indexed.then_some(indices), rows))
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Canonical constructor (Step 8, D4): builds GPU buffers from a shared CPU `Geometry`.
|
||||
/// Canonical constructor (Step 8, D4, now D6/D7): builds the packed GPU buffers from a list of
|
||||
/// LOD levels (level 0 = the full mesh, always present).
|
||||
///
|
||||
/// Inputs: device (GPU command source for buffer creation), geometry (shared CPU vertex data to
|
||||
/// upload), material (optional appearance; `None` falls back to the Scene default at draw time).
|
||||
///
|
||||
/// Internal steps: 1) derive interleaved `Vertex` array via `geometry.to_vertices()`; 2) create the
|
||||
/// vertex buffer (one `Vertex` per position); 3) if the geometry has indices, create the index buffer
|
||||
/// and set `num_indices`, else leave it `None`.
|
||||
///
|
||||
/// The provided `geometry` is retained on the mesh (`geometry` accessor) alongside the uploaded GPU
|
||||
/// buffers, so the CPU data remains readable for later phases without re-uploading each frame (DRAFT Step 8, D5).
|
||||
/// All levels are packed into ONE vertex buffer and ONE index buffer (D7 — WebGPU forbids dynamic
|
||||
/// offsets on vertex/index bindings; only the draw args move). `num_vertices`/`num_indices`
|
||||
/// describe **level 0** (the shadow and main passes always bind level 0); the per-level draw
|
||||
/// arguments are emitted by the GPU cull pass from the uploaded LOD table.
|
||||
pub fn from_geometry_lod(
|
||||
device: &wgpu::Device,
|
||||
levels: Vec<Arc<Geometry>>,
|
||||
material: Option<Arc<Material>>,
|
||||
mode: LodMode,
|
||||
) -> Self {
|
||||
assert!(!levels.is_empty(), "a mesh needs at least level 0");
|
||||
// Packing can only fail when the packed vertex total reaches 65536; the Scene APIs
|
||||
// validate that beforehand. A single level (from_geometry) can never fail.
|
||||
let (vertices, indices, rows) =
|
||||
pack_levels(&levels).expect("packed LOD vertex total exceeds the u16 limit");
|
||||
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Vertex Buffer (packed LOD)"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
|
||||
let index_buffer = indices.map(|data| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Index Buffer (packed LOD)"),
|
||||
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_SRC,
|
||||
contents: bytemuck::cast_slice(&data),
|
||||
})
|
||||
});
|
||||
|
||||
let l0 = &levels[0];
|
||||
Self {
|
||||
geometry: Arc::clone(l0),
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
num_vertices: l0.positions.len() as u32,
|
||||
num_indices: l0.indices().map(|i| i.len() as u32).unwrap_or(0),
|
||||
material,
|
||||
lod_mode: mode,
|
||||
lod_levels: levels,
|
||||
lod_rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-level convenience constructor (Step 8, D4): builds GPU buffers from a shared CPU
|
||||
/// `Geometry` (no LOD — `LodMode::Off`, `lod_rows` has a single row).
|
||||
pub fn from_geometry(
|
||||
device: &wgpu::Device,
|
||||
geometry: Arc<Geometry>,
|
||||
material: Option<Arc<Material>>,
|
||||
) -> Self {
|
||||
let vertices = geometry.to_vertices();
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Vertex Buffer"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
|
||||
let (index_buffer, num_indices) = if let Some(data) = geometry.indices() {
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Mesh Index Buffer"),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
contents: bytemuck::cast_slice(data),
|
||||
});
|
||||
(Some(buffer), data.len() as u32)
|
||||
} else {
|
||||
(None, 0)
|
||||
};
|
||||
|
||||
Self {
|
||||
geometry,
|
||||
vertex_buffer,
|
||||
index_buffer,
|
||||
num_vertices: vertices.len() as u32,
|
||||
num_indices,
|
||||
material,
|
||||
}
|
||||
Self::from_geometry_lod(device, vec![geometry], material, LodMode::Off)
|
||||
}
|
||||
|
||||
/// Returns a reference to the shared CPU geometry this mesh was built from (Step 8, D5).
|
||||
/// Returns a reference to the level-0 CPU geometry this mesh was built from (Step 8, D5).
|
||||
/// Read-only accessor for CPU-side queries (bounding boxes, UVs, normals).
|
||||
pub fn geometry(&self) -> &Arc<Geometry> {
|
||||
&self.geometry
|
||||
@@ -108,4 +233,142 @@ impl Mesh {
|
||||
pub fn set_material(&mut self, material: Arc<Material>) {
|
||||
self.material = Some(material);
|
||||
}
|
||||
|
||||
/// How this mesh's LOD levels were produced (Step 19, D6).
|
||||
pub fn lod_mode(&self) -> LodMode {
|
||||
self.lod_mode
|
||||
}
|
||||
|
||||
/// Number of LOD levels (1 for a plain mesh).
|
||||
pub fn num_lod_levels(&self) -> usize {
|
||||
self.lod_rows.len()
|
||||
}
|
||||
|
||||
/// The per-level packed-buffer rows (level 0 first).
|
||||
pub fn lod_rows(&self) -> &[LodRow] {
|
||||
&self.lod_rows
|
||||
}
|
||||
|
||||
/// The CPU geometry of level k (0 = full mesh). Used by `Scene::add_mesh_lod` when
|
||||
/// reconstructing a mesh with a modified level list.
|
||||
pub fn lod_levels_arc(&self, k: usize) -> Arc<Geometry> {
|
||||
Arc::clone(&self.lod_levels[k])
|
||||
}
|
||||
|
||||
/// The per-mesh GPU LOD table (uploaded once by the Renderer; read by the GPU cull pass).
|
||||
pub fn lod_table(&self) -> crate::resources::uniform::LodTable {
|
||||
crate::resources::uniform::LodTable::from_rows(&self.lod_rows)
|
||||
}
|
||||
|
||||
/// Whether **level 0** is indexed (the shadow/main passes always bind level 0).
|
||||
pub fn l0_indexed(&self) -> bool {
|
||||
self.lod_rows
|
||||
.first()
|
||||
.map(|r| r.index_count > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::primitives;
|
||||
|
||||
#[test]
|
||||
fn pack_levels_offsets_and_rebasing() {
|
||||
// Two levels: L0 = icosahedron (12 verts / 60 indices), L1 = decimated to 10 (welded).
|
||||
let l0 = Arc::new(primitives::icosphere(1.0, 0));
|
||||
let l1 = Arc::new(l0.decimated(10));
|
||||
let (vertices, indices, rows) =
|
||||
pack_levels(&[Arc::clone(&l0), Arc::clone(&l1)]).expect("small mesh packs");
|
||||
|
||||
// Packed vertices = L0 + L1 concatenated.
|
||||
assert_eq!(vertices.len(), l0.positions.len() + l1.positions.len());
|
||||
// Packed indices = 60 + L1's (rebased by L0's vertex count).
|
||||
let packed_indices = indices.expect("both levels indexed");
|
||||
assert_eq!(packed_indices.len(), 60 + l1.indices().unwrap().len());
|
||||
// L0 indices unchanged (rebase base 0); L1 indices rebased by 12.
|
||||
for (i, idx) in l0.indices().unwrap().iter().enumerate() {
|
||||
assert_eq!(packed_indices[i], *idx);
|
||||
}
|
||||
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
|
||||
assert_eq!(packed_indices[60 + j], *idx + 12);
|
||||
}
|
||||
|
||||
// Rows carry the ELEMENT offsets (vertex indices / index elements, not bytes).
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].vertex_offset, 0);
|
||||
assert_eq!(rows[0].vertex_count, 12);
|
||||
assert_eq!(rows[0].index_offset, 0);
|
||||
assert_eq!(rows[0].index_count, 60);
|
||||
assert_eq!(rows[1].vertex_offset, 12);
|
||||
assert_eq!(rows[1].vertex_count, l1.positions.len() as u32);
|
||||
assert_eq!(rows[1].index_offset, 60);
|
||||
assert_eq!(rows[1].index_count, l1.indices().unwrap().len() as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_levels_mixed_indexedness() {
|
||||
// Non-indexed L0 (6 verts) + indexed L1 (welded) — the Auto-mode case from DRAFT D7.
|
||||
let l0 = Arc::new(
|
||||
Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[0.0, 2.0, 0.0],
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[0.0, -2.0, 0.0],
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 3, 4, 5]),
|
||||
);
|
||||
// Force L0 non-indexed: strip the indices.
|
||||
let l0_nonidx = Arc::new(Geometry::new(l0.positions.clone()));
|
||||
let l1 = Arc::new(l0.decimated(1)); // welded + indexed
|
||||
let (vertices, indices, rows) =
|
||||
pack_levels(&[Arc::clone(&l0_nonidx), Arc::clone(&l1)]).expect("small mesh packs");
|
||||
assert_eq!(vertices.len(), 6 + l1.positions.len());
|
||||
let packed = indices.expect("an indexed level exists");
|
||||
// L0 contributes no indices; L1's are rebased by 6.
|
||||
assert_eq!(packed.len(), l1.indices().unwrap().len());
|
||||
assert_eq!(rows[0].index_count, 0, "non-indexed L0 row");
|
||||
assert_eq!(rows[1].index_offset, 0, "L1 is the first indexed level");
|
||||
assert_eq!(rows[1].vertex_offset, 6, "element units, not bytes");
|
||||
for (j, idx) in l1.indices().unwrap().iter().enumerate() {
|
||||
assert_eq!(packed[j], *idx + 6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_levels_all_non_indexed() {
|
||||
let l0 = Arc::new(Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]));
|
||||
let (vertices, indices, rows) = pack_levels(&[l0.clone()]).expect("small mesh packs");
|
||||
assert_eq!(vertices.len(), 3);
|
||||
assert!(indices.is_none());
|
||||
assert_eq!(rows[0].index_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_table_from_rows() {
|
||||
let l0 = Arc::new(primitives::icosphere(1.0, 0));
|
||||
let l1 = Arc::new(l0.decimated(10));
|
||||
let (_, _, rows) = pack_levels(&[l0, l1]).expect("small mesh packs");
|
||||
let table = crate::resources::uniform::LodTable::from_rows(&rows);
|
||||
assert_eq!(table.count, 2);
|
||||
assert_eq!(table.rows[0].index_count, 60);
|
||||
assert_eq!(table.rows[1].vertex_count, rows[1].vertex_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_levels_rejects_u16_overflow() {
|
||||
// A level with 70k vertices (indexed) cannot be packed with u16 rebased indices.
|
||||
let big = Arc::new(
|
||||
Geometry::new(vec![[0.0, 0.0, 0.0]; 70_000]).with_indices(vec![0u16; 70_000 / 3 * 3]),
|
||||
);
|
||||
let err = pack_levels(&[big]).unwrap_err();
|
||||
assert_eq!(err.total_vertices, 70_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ pub mod vertex;
|
||||
pub use camera::{Camera, CameraController, PITCH_LIMIT};
|
||||
pub use lights::Lights;
|
||||
pub use material::Material;
|
||||
pub use mesh::Mesh;
|
||||
pub use mesh::{LodMode, Mesh, PackError};
|
||||
pub use texture::{Texture, TextureError};
|
||||
pub use uniform::{
|
||||
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,
|
||||
FRAME_UNIFORMS_SIZE, FrameUniforms, LOD_ROW_SIZE, LOD_TABLE_SIZE, Light, LightType, LodRow,
|
||||
LodTable, MAT_SLOT_SIZE, MAX_LIGHTS, MatSlot, OBJECT_UNIFORM_SIZE, ObjectUniform,
|
||||
SHADOW_UNIFORM_SIZE, ShadowUniform, TRANSFORM_SLOT_SIZE, TransformSlot,
|
||||
};
|
||||
pub use vertex::Vertex;
|
||||
|
||||
|
||||
@@ -383,6 +383,79 @@ impl CullUniforms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Size in bytes of one [`LodRow`] (16 B = 4 u32), matching WGSL `LodRow`.
|
||||
pub const LOD_ROW_SIZE: u64 = 16;
|
||||
|
||||
/// Size in bytes of one [`LodTable`] (80 B = 5 × 16 B), matching WGSL `LodTable`.
|
||||
pub const LOD_TABLE_SIZE: u64 = 80;
|
||||
|
||||
/// One LOD level of a mesh's **packed** vertex/index buffers (Step 19, D7) — the per-level
|
||||
/// draw offsets the GPU cull pass needs to emit the level's indirect draw args.
|
||||
/// Mirrors the WGSL `LodRow` (16 bytes: `vec4<u32>`).
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
|
||||
pub struct LodRow {
|
||||
/// **Element** offset of this level's vertices within the mesh's packed vertex buffer
|
||||
/// (a vertex index, not a byte offset — the WebGPU `drawIndirectNonIndexed` first_vertex
|
||||
/// is a vertex index; it goes straight into the indirect args' `first_vertex`).
|
||||
pub vertex_offset: u32,
|
||||
/// Number of vertices of this level.
|
||||
pub vertex_count: u32,
|
||||
/// **Element** offset of this level's indices within the mesh's packed index buffer
|
||||
/// (an index element, not a byte offset — the `drawIndirectIndexed` first_index is an
|
||||
/// index element; 0 when the level is non-indexed or no earlier level has indices).
|
||||
pub index_offset: u32,
|
||||
/// Number of indices of this level (0 when non-indexed); the draw count.
|
||||
pub index_count: u32,
|
||||
}
|
||||
|
||||
impl LodRow {
|
||||
/// Builds a row from the packed-buffer offsets the packer computed.
|
||||
/// `index_offset`/`index_count` are 0 for a non-indexed level.
|
||||
pub fn new(vertex_offset: u32, vertex_count: u32, index_offset: u32, index_count: u32) -> Self {
|
||||
Self {
|
||||
vertex_offset,
|
||||
vertex_count,
|
||||
index_offset,
|
||||
index_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-mesh LOD table (Step 19, D7): one [`LodRow`] per level, uploaded once per mesh and
|
||||
/// read by the GPU cull pass to map a CPU-decided level to indirect draw args. Level 0
|
||||
/// **always** exists and is byte-exact with the full mesh. Mirrors the WGSL `LodTable`
|
||||
/// (80 bytes = count @0 + 4 × 16-byte rows @16..80, each row a `vec4<u32>`).
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
|
||||
pub struct LodTable {
|
||||
/// Number of valid levels (1 = no LOD, single level).
|
||||
pub count: u32,
|
||||
/// Padding so the rows start at byte 16 (WGSL `LodTable`: `count : u32` @0, a 12-byte pad,
|
||||
/// then the `array<LodRow, 4>` @16 — the WGSL pad is an `array<u32, 3>` (alignment 4, NOT
|
||||
/// `vec3<u32>` which would align to 16 and grow the struct to 96 bytes).
|
||||
pub _pad: [u32; 3],
|
||||
/// One 16-byte row per level (`LOD_ROW_SIZE`-spaced), zeroed beyond `count`.
|
||||
pub rows: [LodRow; crate::utils::conf::MAX_LOD_LEVELS as usize],
|
||||
}
|
||||
|
||||
impl LodTable {
|
||||
/// Builds a table from up to `MAX_LOD_LEVELS` rows (row 0 = level 0 = full mesh).
|
||||
pub fn from_rows(rows: &[LodRow]) -> Self {
|
||||
let mut table = Self::default();
|
||||
table._pad = [0; 3];
|
||||
table.count = rows.len() as u32;
|
||||
for (i, row) in rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(crate::utils::conf::MAX_LOD_LEVELS as usize)
|
||||
{
|
||||
table.rows[i] = *row;
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -479,4 +552,39 @@ mod tests {
|
||||
assert_eq!(offset_of!(CullUniforms, num_slots), 96);
|
||||
assert_eq!(offset_of!(CullUniforms, culling), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_layouts_match_wgsl() {
|
||||
// LodRow: four u32 -> 16 (a vec4<u32> in WGSL).
|
||||
assert_eq!(size_of::<LodRow>(), 16);
|
||||
assert_eq!(LOD_ROW_SIZE, 16);
|
||||
assert_eq!(offset_of!(LodRow, vertex_offset), 0);
|
||||
assert_eq!(offset_of!(LodRow, vertex_count), 4);
|
||||
assert_eq!(offset_of!(LodRow, index_offset), 8);
|
||||
assert_eq!(offset_of!(LodRow, index_count), 12);
|
||||
|
||||
// LodTable: count @0 + 4 rows @16 -> 80.
|
||||
assert_eq!(size_of::<LodTable>(), 80);
|
||||
assert_eq!(LOD_TABLE_SIZE, 80);
|
||||
assert_eq!(offset_of!(LodTable, count), 0);
|
||||
assert_eq!(offset_of!(LodTable, rows), 16);
|
||||
|
||||
// from_rows: count + rows filled, rest zeroed.
|
||||
let table = LodTable::from_rows(&[LodRow {
|
||||
vertex_offset: 0,
|
||||
vertex_count: 100,
|
||||
index_offset: 0,
|
||||
index_count: 300,
|
||||
}]);
|
||||
assert_eq!(table.count, 1);
|
||||
assert_eq!(table.rows[0].vertex_count, 100);
|
||||
assert_eq!(table.rows[1], LodRow::default());
|
||||
|
||||
// MAX_LOD_LEVELS rows fit exactly.
|
||||
let table = LodTable::from_rows(&vec![
|
||||
LodRow::default();
|
||||
crate::utils::conf::MAX_LOD_LEVELS as usize
|
||||
]);
|
||||
assert_eq!(table.count, crate::utils::conf::MAX_LOD_LEVELS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +277,148 @@ impl Scene {
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one
|
||||
/// declarative call (Step 19, D6/D7). Level 0 is `geometry` (byte-exact); levels 1.. are
|
||||
/// auto-generated by greedy decimation (`Geometry::generate_lod_levels`, D10) at halving
|
||||
/// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the
|
||||
/// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
|
||||
///
|
||||
/// Inputs: id (unique mesh id), geometry (level 0 — the full mesh), material (optional
|
||||
/// material id, same rule as [`create_mesh`]), levels (2..=MAX_LOD_LEVELS).
|
||||
/// Returns `Err` if the id exists, the material is unknown, `levels` is out of range, or the
|
||||
/// packed vertex total exceeds the u16 index limit (65535).
|
||||
pub fn create_mesh_with_lod(
|
||||
&mut self,
|
||||
id: &str,
|
||||
geometry: Geometry,
|
||||
material: Option<&str>,
|
||||
levels: u8,
|
||||
) -> Result<String, String> {
|
||||
use crate::utils::conf::MAX_LOD_LEVELS;
|
||||
if self.meshes.contains_key(id) {
|
||||
return Err(format!("Mesh ID '{}' already exists.", id));
|
||||
}
|
||||
if levels < 2 || u32::from(levels) > MAX_LOD_LEVELS {
|
||||
return Err(format!(
|
||||
"LOD levels must be in 2..={MAX_LOD_LEVELS} (got {levels})."
|
||||
));
|
||||
}
|
||||
let lod_levels: Vec<Arc<Geometry>> = geometry
|
||||
.generate_lod_levels(levels)
|
||||
.into_iter()
|
||||
.map(Arc::new)
|
||||
.collect();
|
||||
let total: u32 = lod_levels.iter().map(|l| l.positions.len() as u32).sum();
|
||||
if total >= 65536 {
|
||||
return Err(format!(
|
||||
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit; use fewer levels or a smaller mesh."
|
||||
));
|
||||
}
|
||||
let mut mesh = Mesh::from_geometry_lod(
|
||||
self.device(),
|
||||
lod_levels,
|
||||
None,
|
||||
crate::resources::LodMode::Auto,
|
||||
);
|
||||
if let Some(name) = material {
|
||||
let mat = self
|
||||
.materials
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
|
||||
.clone();
|
||||
mesh.set_material(mat);
|
||||
}
|
||||
self.meshes.insert(id.to_string(), Arc::new(mesh));
|
||||
self.mesh_order.push(id.to_string());
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Adds (or replaces) an **explicitly provided** LOD level on an existing mesh (Step 19, D6).
|
||||
/// The level is packed into the mesh's buffers alongside the others (D7) and the per-mesh LOD
|
||||
/// table is updated (it is re-uploaded every frame, so the change takes effect next frame).
|
||||
///
|
||||
/// Inputs: id (existing mesh id), level (index ≥ 1; must be ≤ the current level count —
|
||||
/// append at the end or replace in place), geometry (the level's geometry).
|
||||
/// Validation: the level must validate, carry the **same attribute set and indexedness** as
|
||||
/// level 0, keep the packed vertex total under 65536, and stay within `MAX_LOD_LEVELS`.
|
||||
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String> {
|
||||
use crate::utils::conf::MAX_LOD_LEVELS;
|
||||
let current = self
|
||||
.meshes
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("Mesh '{}' does not exist.", id))?;
|
||||
let levels_count = current.num_lod_levels();
|
||||
if level < 1 || u32::from(level) > MAX_LOD_LEVELS {
|
||||
return Err(format!(
|
||||
"LOD level must be in 1..={MAX_LOD_LEVELS} (got {level})."
|
||||
));
|
||||
}
|
||||
if level as usize > levels_count {
|
||||
return Err(format!(
|
||||
"Mesh '{}' has {levels_count} level(s); level {level} does not exist and the next free level is {levels_count}.",
|
||||
id
|
||||
));
|
||||
}
|
||||
let l0 = current.geometry();
|
||||
let attr = |g: &Geometry| (g.normals.is_some(), g.uvs.is_some(), g.colors.is_some());
|
||||
if attr(&geometry) != attr(l0) {
|
||||
return Err(format!(
|
||||
"LOD level {level} of mesh '{}' must have the same attribute set (normals/UVs/colors) as level 0.",
|
||||
id
|
||||
));
|
||||
}
|
||||
if geometry.indices().is_some() != l0.indices().is_some() {
|
||||
return Err(format!(
|
||||
"LOD level {level} of mesh '{}' must have the same indexedness as level 0.",
|
||||
id
|
||||
));
|
||||
}
|
||||
geometry
|
||||
.validate()
|
||||
.map_err(|e| format!("LOD level {level} of mesh '{}': {e}", id))?;
|
||||
|
||||
let mut new_levels: Vec<Arc<Geometry>> = (0..levels_count)
|
||||
.map(|i| current.lod_levels_arc(i))
|
||||
.collect();
|
||||
if level as usize == levels_count {
|
||||
if levels_count >= MAX_LOD_LEVELS as usize {
|
||||
return Err(format!(
|
||||
"Mesh '{}' already has the maximum of {MAX_LOD_LEVELS} LOD levels.",
|
||||
id
|
||||
));
|
||||
}
|
||||
new_levels.push(Arc::new(geometry)); // append the next level (L_k at vec index k)
|
||||
} else {
|
||||
new_levels[level as usize] = Arc::new(geometry); // replace L_k in place (vec index k)
|
||||
}
|
||||
let total: u32 = new_levels.iter().map(|l| l.positions.len() as u32).sum();
|
||||
if total >= 65536 {
|
||||
return Err(format!(
|
||||
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit."
|
||||
));
|
||||
}
|
||||
|
||||
let material = current.material().cloned();
|
||||
let mesh = Mesh::from_geometry_lod(
|
||||
self.device(),
|
||||
new_levels,
|
||||
material,
|
||||
crate::resources::LodMode::Explicit,
|
||||
);
|
||||
// Entities reference the mesh by (stable) index, not by Arc — swapping the Arc is safe.
|
||||
self.meshes.insert(id.to_string(), Arc::new(mesh));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The per-mesh LOD tables in `mesh_order` order (one 80-byte table per mesh, level 0 first)
|
||||
/// — the payload of the GPU `lod_tables` buffer, uploaded every frame (Step 19, D7).
|
||||
pub fn mesh_lod_tables(&self) -> Vec<crate::resources::LodTable> {
|
||||
self.mesh_order
|
||||
.iter()
|
||||
.map(|name| self.meshes[name].lod_table())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the Scene's default material: the `standard` shader pipeline, built lazily on first
|
||||
/// call and cached afterwards. Used by `Renderer::render_scene` for meshes that carry no material.
|
||||
/// Note: the flat (unlit) look is *not* a property of this material — it is driven by the
|
||||
|
||||
@@ -9,11 +9,17 @@
|
||||
// = no-op) instead of a CPU-side per-entity loop.
|
||||
//
|
||||
// All buffers are fixed-capacity (MAX_ENTITIES = 256, see ARCHI_CPU_GPU.md D12) and are
|
||||
// allocated once. Each frame the CPU rewrites the transform slots and cull uniforms;
|
||||
// everything else is GPU-driven.
|
||||
// allocated once. Each frame the CPU rewrites the transform slots, cull uniforms, LOD levels
|
||||
// and LOD tables; 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).
|
||||
// TransformSlot (64B), MatSlot (256B), BBoxSlot (32B), DrawSlot (80B), CullUniforms (112B),
|
||||
// LodRow (16B), LodTable (80B).
|
||||
//
|
||||
// Step 19 (LOD): the CPU decides each entity's level (screen-space size + hysteresis, D8);
|
||||
// the cull pass maps it to the packed level's draw args through the per-mesh LOD table
|
||||
// (binding 4) and per-slot level array (binding 3). With LOD disabled the CPU writes level 0
|
||||
// everywhere and the args are byte-identical to the pre-LOD behavior.
|
||||
//
|
||||
// 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
|
||||
@@ -63,6 +69,22 @@ struct CullUniforms {
|
||||
_pad : vec2u,
|
||||
};
|
||||
|
||||
// 16 bytes: one LOD level's draw offsets (Step 19, D7). ELEMENT units, not bytes: x is a
|
||||
// vertex index (drawIndirectNonIndexed first_vertex) and z an index element (drawIndirectIndexed
|
||||
// first_index) — the packed vertex/index buffers are bound in full (offset 0), only these
|
||||
// first_* values move per level. w = 0 marks a non-indexed level.
|
||||
struct LodRow {
|
||||
o : vec4u, // x = first_vertex, y = vertex_count, z = first_index, w = index_count
|
||||
};
|
||||
|
||||
// 80 bytes: the per-mesh LOD table (Step 19, D7). count @0, 12-byte pad (an array<u32,3> —
|
||||
// NOT vec3u, which would align to 16 and grow the struct to 96 B), rows @16..80.
|
||||
struct LodTable {
|
||||
count : u32, // number of valid levels (1 = no LOD)
|
||||
_pad : array<u32, 3>,
|
||||
rows : array<LodRow, 4>, // MAX_LOD_LEVELS = 4; zeroed beyond count
|
||||
};
|
||||
|
||||
// ---- 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.
|
||||
@@ -71,6 +93,10 @@ struct CullUniforms {
|
||||
@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>;
|
||||
// Step 19: per-slot CPU-decided LOD level (one u32 per entity slot) and the per-mesh LOD tables
|
||||
// (one 80-byte LodTable per mesh, mesh_order order — same indexing as `bboxes`).
|
||||
@group(2) @binding(3) var<storage, read> lod_levels : array<u32>;
|
||||
@group(2) @binding(4) var<storage, read> lod_tables : array<LodTable>;
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
@@ -119,10 +145,23 @@ fn identity_mat() -> mat4x4f {
|
||||
|
||||
// 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.
|
||||
// (A level-0 draw produced by `write_level_args` is byte-identical to this: first_* = 0.)
|
||||
fn set_draw_count(i : u32, count : u32) {
|
||||
draw_args[i].a = vec4u(count, 1u, 0u, 0u);
|
||||
}
|
||||
|
||||
// Fills the slot's indirect args with the draw command of LOD level `row` (Step 19, D7):
|
||||
// indexed levels use the 5-field layout (index_count, instances, first_index, base_vertex,
|
||||
// base_instance) and non-indexed levels the 4-field layout (vertex_count, instances,
|
||||
// first_vertex, base_instance). The element-unit offsets in the row become the first_* fields.
|
||||
fn write_level_args(i : u32, row : LodRow) {
|
||||
if (row.o.w > 0u) {
|
||||
draw_args[i].a = vec4u(row.o.w, 1u, row.o.z, 0u);
|
||||
} else {
|
||||
draw_args[i].a = vec4u(row.o.y, 1u, row.o.x, 0u);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pass 1: derive world matrices (Step 15.5) ----
|
||||
// Dispatched for MAX_ENTITIES; inactive slots get the identity matrix (a harmless stale read).
|
||||
@compute
|
||||
@@ -137,9 +176,11 @@ fn compute_matrices(@builtin(global_invocation_id) gid : vec3u) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pass 2: cull + fill indirect draw args (Step 15.6) ----
|
||||
// ---- Pass 2: LOD level → draw args + cull (Steps 15.6 + 19) ----
|
||||
// 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.
|
||||
// the indirect render passes skip them. For visible slots the CPU-decided LOD level (binding 3)
|
||||
// is mapped through the mesh's LOD table (binding 4) to the level's draw command; culling
|
||||
// (when enabled) zeroes it against the frustum planes.
|
||||
@compute
|
||||
@workgroup_size(64)
|
||||
fn cull(@builtin(global_invocation_id) gid : vec3u) {
|
||||
@@ -158,32 +199,41 @@ fn cull(@builtin(global_invocation_id) gid : vec3u) {
|
||||
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;
|
||||
// LOD (Step 19, D8): the CPU decides the level per slot (projected size + hysteresis);
|
||||
// the GPU only maps it to the packed level's draw args. Clamped to the mesh's level count
|
||||
// (the CPU clamps too — defense in depth against a stale level after an add_mesh_lod).
|
||||
let table = lod_tables[u32(t.flags.x)];
|
||||
var level = lod_levels[i];
|
||||
if (level >= table.count) {
|
||||
level = table.count - 1u;
|
||||
}
|
||||
let row = table.rows[level].o;
|
||||
|
||||
// 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));
|
||||
|
||||
// Culling: test the entity's world bounding sphere against the frustum planes.
|
||||
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;
|
||||
if (cull_u.culling == 1u) {
|
||||
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 it).
|
||||
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));
|
||||
|
||||
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);
|
||||
if (!visible) {
|
||||
set_draw_count(i, 0u);
|
||||
return;
|
||||
}
|
||||
|
||||
// Visible: the chosen level's draw command (level 0 is byte-identical to the pre-LOD args).
|
||||
write_level_args(i, LodRow(row));
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@ pub const GPU_DRIVEN_SHADER: &str = include_str!("../shaders/gpu_driven.wgsl");
|
||||
/// 256 is a multiple of the 64-wide workgroup size, giving a whole number of workgroups.
|
||||
pub const MAX_ENTITIES: u32 = 256;
|
||||
|
||||
/// Maximum number of LOD levels per mesh (Étape 19, D8). The per-mesh LOD table
|
||||
/// (`LodTable`, 80 bytes) carries one 16-byte row per level — 4 rows + the count header.
|
||||
pub const MAX_LOD_LEVELS: u32 = 4;
|
||||
|
||||
/// Default LOD thresholds in **pixels** of projected bounding-sphere radius (Étape 19, D4/D8):
|
||||
/// `thresholds[k]` is the radius *above which* level k+1 is required (descending). With these
|
||||
/// values: `r > 48` → L0, `12 < r ≤ 48` → L1, `r ≤ 12` → L2+ (clamped). Constants in v1
|
||||
/// (per-scene/mesh configurability is a follow-up); the hysteresis dead band (×0.8 to go
|
||||
/// coarser) lives in `math::lod::lod_level`.
|
||||
pub const LOD_THRESHOLDS: [f32; 2] = [48.0, 12.0];
|
||||
|
||||
/// 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;
|
||||
|
||||
Reference in New Issue
Block a user