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.
This commit is contained in:
Jérôme Bousquié
2026-09-23 11:46:12 +02:00
parent f15e920109
commit a3a7ff4a6b
10 changed files with 805 additions and 173 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ impl AppHandler for Demo {
// 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
// levels 1.. are auto-generated by quadric edge collapse 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
+1 -1
View File
@@ -11,7 +11,7 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi
| **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, 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) |
| **math** | Transform, Geometry (per-attribute mesh data + AABB, quadric edge collapse `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 |
+773 -152
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -101,7 +101,7 @@ mod tests {
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);
let proj = glam::camera::rh::proj::opengl::perspective(fov, 1.0, 0.1, 100.0);
(view, proj)
}
+1 -1
View File
@@ -38,7 +38,7 @@ 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).
/// Levels 1.. were auto-generated by quadric edge collapse (`Geometry::decimated`, D10).
Auto,
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
Explicit,
+1 -1
View File
@@ -279,7 +279,7 @@ impl Scene {
/// 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
/// auto-generated by quadric edge collapse (`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.
///