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`,
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)
```
- Triangles (non dégénérés) triés par aire croissante ; on retire le plus petit jusqu'à la
cible — **sans carte de topologie** (« Règle A ») : tout sous-ensemble de faces d'un mesh valide
est un mesh valide (pas de trou à boucher, pas de flip). La cible est donc toujours atteignable
(clamp `[1, T]`).
- Sortie : re-indexation (weld par position exacte), **normales lisses recalculées** sur les faces
- Weld préalable (tolérance relative 1e-6 — grille + 27 voisins : les seams trigonométriques
diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas), triangles dégénérés jetés, puis
**quadric edge collapse** : file de priorité des arêtes classées par coût (erreur quadrique de
l'arête + longueur d'arête) ; on replie la plus bon marché jusqu'à la cible. Un repli **interne**
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 =
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).
- Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**).
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
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).
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`
/// triangles (clamp `[1, T]`) — les plus petits partent en premier (faible impact visuel),
/// normales lisses recalculées. Déterministe.
/// Quadric edge collapse (D10, Garland–Heckbert) : renvoie un `Geometry` indexé avec
/// ~`target_triangles` triangles (best effort ±1–2, clamp `[1, T]`) — les arêtes au coût
/// quadrique le plus faible partent en premier, normales lisses recalculées. Déterministe.
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
```