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
+17 -10
View File
@@ -118,22 +118,29 @@ ou **à chaud** (toggle de debug au runtime). Aucun changement de signature exis
L'utilisateur déclare sa géométrie comme aujourd'hui (`positions`/`indices`/`normals`/`uvs`, L'utilisateur déclare sa géométrie comme aujourd'hui (`positions`/`indices`/`normals`/`uvs`,
y compris générée procéduralement par lui) et la bibliothèque **calcule les niveaux LOD sous le y compris générée procéduralement par lui) et la bibliothèque **calcule les niveaux LOD sous le
capot**. Mécanisme : décimation gloutonne du maillage indexé — capot**. Mécanisme : **quadric edge collapse** (Garland–Heckbert) sur le maillage indexé —
``` ```
Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU) Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
``` ```
- Triangles (non dégénérés) triés par aire croissante ; on retire le plus petit jusqu'à la - Weld préalable (tolérance relative 1e-6 — grille + 27 voisins : les seams trigonométriques
cible — **sans carte de topologie** (« Règle A ») : tout sous-ensemble de faces d'un mesh valide diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas), triangles dégénérés jetés, puis
est un mesh valide (pas de trou à boucher, pas de flip). La cible est donc toujours atteignable **quadric edge collapse** : file de priorité des arêtes classées par coût (erreur quadrique de
(clamp `[1, T]`). l'arête + longueur d'arête) ; on replie la plus bon marché jusqu'à la cible. Un repli **interne**
- Sortie : re-indexation (weld par position exacte), **normales lisses recalculées** sur les faces fusionne les 2 triangles incidents (ils dégénèrent — −2 faces) et re-mappe les voisins :
**pas de nouvelle face** — caractéristique d'Euler et **clôture préservées** (un mesh fermé
reste fermé : pas de trous, pas de « books ») ; un repli de **bordure** retire 1 face.
Garde-fous : arête non-manifold (≥ 3 faces) ou face en double (pli) → repli rejeté. La cible
est clampée à `[1, T]` et atteinte au mieux (best effort : la granularité −2/−1 peut s'en
écarter d'un ou deux triangles — jamais de géométrie corrompue).
- Sortie : re-indexation (le weld ci-dessus), **normales lisses recalculées** sur les faces
survivantes (normales de faces accumulées par coin soudé, puis normalisées), UVs/couleurs = survivantes (normales de faces accumulées par coin soudé, puis normalisées), UVs/couleurs =
valeur du premier vertex de chaque groupe soudé (documenté : évite le bleeding entre seams UV ; valeur du premier vertex de chaque groupe soudé (documenté : évite le bleeding entre seams UV ;
le LOD sacrifie la précision UV au profit de la silhouette). le LOD sacrifie la précision UV au profit de la silhouette).
- Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**). - Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**).
Triangles dégénérés jetés. Triangles dégénérés jetés.
- **Déterministe** (tri stable, tie-break par index d'origine) → counts reproductibles en tests. - **Déterministe** (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) →
counts reproductibles en tests.
- Fallback : la cible est clampée à `[1, T]` — un mesh n'est jamais décimé sous un triangle, et - Fallback : la cible est clampée à `[1, T]` — un mesh n'est jamais décimé sous un triangle, et
le count **réel** va dans le tableau LOD (`validate()` OK, jamais de géométrie corrompue). le count **réel** va dans le tableau LOD (`validate()` OK, jamais de géométrie corrompue).
@@ -251,9 +258,9 @@ pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32])
/// `depth <= eps` (objet dans la caméra) → f32::INFINITY (niveau 0). /// `depth <= eps` (objet dans la caméra) → f32::INFINITY (niveau 0).
pub fn projected_radius_px(center_world: Vec3, radius: f32, view: Mat4, proj: Mat4, height_px: f32) -> f32; pub fn projected_radius_px(center_world: Vec3, radius: f32, view: Mat4, proj: Mat4, height_px: f32) -> f32;
/// Décimation gloutonne (D10, Règle A) : renvoie un `Geometry` indexé avec `target_triangles` /// Quadric edge collapse (D10, Garland–Heckbert) : renvoie un `Geometry` indexé avec
/// triangles (clamp `[1, T]`) — les plus petits partent en premier (faible impact visuel), /// ~`target_triangles` triangles (best effort ±1–2, clamp `[1, T]`) — les arêtes au coût
/// normales lisses recalculées. Déterministe. /// quadrique le plus faible partent en premier, normales lisses recalculées. Déterministe.
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
``` ```
+1 -1
View File
@@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
### 4.3 Optimisations ### 4.3 Optimisations
- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé) - [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé)
- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par décimation gloutonne au setup (`Geometry::decimated`/`generate_lod_levels`, Règle A : retrait des plus petits triangles, weld par position exacte, rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame) - [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated`/`generate_lod_levels` : arêtes classées par coût quadrique, repli interne −2 faces / bordure −1, un mesh fermé reste fermé, weld tolérance 1e-6, rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame)
- [ ] HDR + Tone Mapping (optionnel) - [ ] HDR + Tone Mapping (optionnel)
### 4.4 Gestion du Resize (cycle de vie Surface + Depth) ### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
+3 -2
View File
@@ -41,8 +41,9 @@ Ce document sert de spécification technique et de trame d'implémentation pour
> du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU** > du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU**
> (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ; > (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ;
> le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont > le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont
> générés par décimation gloutonne au setup (`Geometry::decimated` : suppression des plus petits triangles, > générés par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les
> soudure par position exacte, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en > arêtes au coût quadrique minimal sont repliées en premier, un mesh fermé reste fermé — pas de
> trous, pas de « books » ; soudure tolérance 1e-6, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en
> unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 : > unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 :
> 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu > 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md` > bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
+6 -3
View File
@@ -93,9 +93,12 @@ LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity deci
CPU): CPU):
1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated 1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated
automatically from level 0 by greedy decimation (`Geometry::generate_lod_levels`): the smallest automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
triangles are removed first (no edge map, no crease handling — a subset of the faces of a valid `Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed
mesh is valid), duplicate corners are welded, and the levels are **packed into the mesh's single cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the
neighbours — no new face, so a **closed mesh stays closed** (no holes, no non-manifold
"books"), a boundary collapse removes one face; duplicate corners are welded (relative
tolerance 1e-6), and the levels are **packed into the mesh's single
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry. vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry.
2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen 2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen
pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going
+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. // 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 // 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, // 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 // packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel
// switches levels on the fly (asymmetric hysteresis, D4). // switches levels on the fly (asymmetric hysteresis, D4).
app.scene 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 | | **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` | | **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) | | **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 | | **utils** | Configuration constants and WsgError type |
| **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation | | **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 | | **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) { fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) {
let view = let view =
glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y); 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) (view, proj)
} }
+1 -1
View File
@@ -38,7 +38,7 @@ pub enum LodMode {
/// No LOD: a single level (the plain `create_mesh` path). /// No LOD: a single level (the plain `create_mesh` path).
#[default] #[default]
Off, 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, Auto,
/// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`). /// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`).
Explicit, 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 /// 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 /// 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 /// 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. /// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
/// ///