Files
wsg/lib/src/resources/mesh.rs
T
Jérôme Bousquié a3a7ff4a6b LOD: quadric edge collapse (Garland-Heckbert) replaces Rule A
Rule A (area-sorted triangle removal) left holes and open boundaries
on closed meshes (visible artifacts when zoomed far out). The decimator
is now a proper quadric edge collapse:

- Collapse: welded u32 topology, per-vertex quadrics (accumulated
  incident face planes), edge cost = quadric error at the optimal
  point (clamped to the segment) + edge length, BinaryHeap with a
  custom Ord (f32 is not Ord; inverted compare, NaN-safe).
- Standard GH semantics WITHOUT the new face: both incident pair faces
  degenerate and are removed; neighbouring faces remap and sweep over
  the region. Preserves the Euler characteristic and closedness (no
  holes, no books, no duplicate faces); interior collapse = -2 faces,
  boundary = -1. Guards: non-manifold edge (>2 faces) or a fold
  (duplicate sorted triple) rejects the collapse.
- welded(): fuzzy welding (1e-6 relative tolerance, grid + 27-
  neighbour broad phase, exact verify) - trig-generated seams differ
  by ~1e-16, exact-bit welding missed them.
- Root causes fixed along the way: dead face slots are never reused
  (stale edge/vface entries), vfaces updated on remap, degenerate
  faces dropped, best-effort target (granularity -2/-1 can land 1-2
  off; soft cap, deterministic).
- Docs: DRAFT D10, ROADMAP 4.3, ARCHI_CPU_GPU, gpu-driven, lib
  README, mesh/scene/demo comments - 'greedy decimation / Rule A'
  replaced by 'quadric edge collapse'.
- lod.rs test: deprecated glam perspective alias -> explicit
  glam::camera::rh::proj::opengl::perspective.

cargo test --workspace: 100 passed (94 lib + 3 wgsl + 3 integration),
0 failed; demo runs clean with LOD on.
2026-09-23 11:46:12 +02:00

375 lines
17 KiB
Rust

//! # Mesh Module — Persistent GPU Geometry Container
//!
//! Defines `Mesh`, a persistent GPU geometry container. Mesh data is uploaded to the GPU once at creation time
//! and remains valid across all frames until dropped. Since Step 8 (DRAFT Step 8.3), a Mesh also retains the
//! CPU geometry it was built from (`geometry: Arc<Geometry>`), giving meshes a shared, readable source of truth
//! for phases such as bounding-box culling and UV access. Since Step 7, a Mesh may also hold a reference to the
//! `Material` that draws it — the appearance lives on the Mesh rather than on the `Entity`.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **Identifiants**: Each Mesh is registered in Scene by string identifier, enabling dynamic access
//! during the render loop without borrow checker issues. The identifier serves as the `Handle<T>` key.
//! - **Declaration Phase**: Meshes are instantiated once in the declarative phase before the render loop begins.
//! - **Performance**: Multiple entities can reference the same Mesh, reducing memory footprint for repeated geometry.
//! - **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; 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 quadric edge collapse (`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 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 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 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 of level 0. Used as `0..num_vertices` for non-indexed draws.
pub num_vertices: u32,
/// 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, now D6/D7): builds the packed GPU buffers from a list of
/// LOD levels (level 0 = the full mesh, always present).
///
/// 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 {
Self::from_geometry_lod(device, vec![geometry], material, LodMode::Off)
}
/// 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
}
/// Returns a reference to the Material attached to this mesh, if any.
/// When `None`, the Renderer falls back to the Scene's default material at draw time.
pub fn material(&self) -> Option<&Arc<Material>> {
self.material.as_ref()
}
/// Attaches (or replaces) the Material used to render this mesh.
/// Called by `Scene::create_mesh` during scene setup or by advanced users linking geometry manually.
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);
}
}