LOD: fix inverted normals on decimated meshes + rim protection

Root cause of the user-reported artifacts (stripes disappearing on the
far LOD): normals were RECOMPUTED from the surviving faces after the
collapse. uv_sphere is wound inward, so the recomputed normals pointed
inward — the far LOD was back-face-lit (measured deviation 2.0 vs 0.0
on L0).

Fix — normals are INHERITED, never recomputed:
- welded() now also welds normals (first-encountered per cluster)
- Collapse owns the normal table; collapse_edge λ-blends + renormalizes
  at the same λ as the position (no seam guard: the attribute-aware
  weld kept hard-edge vertices separate, so no edge crosses a shading
  break)
- compaction reads the post-collapse table instead of recomputing
- the outward reorientation added earlier is removed: the source
  winding + normals are preserved as-is, so every LOD level is
  shading-compatible with L0 whatever the source orientation

Rim protection (attribute-aware weld leaves UV-seam slits / pole fans
as boundary rims): a face touching such a rim is never removed while
interior collapses remain — strict PQ mode (edges whose incident faces
are fully interior, re-validated at pop) with a best-effort fallback
when the interior alone cannot reach the target. Seam-free meshes
(icosahedron) stay topologically closed; sewn meshes stay geometrically
complete (no hole at the slit) — tests now assert seam-column survival.

Docs: gpu-driven.md §LOD, ARCHI_CPU_GPU LOD note, ROADMAP 4.3 updated
with the attribute-aware weld + rim protection + inherited normals.

Gate: fmt ✓, check 0 warnings ✓, 104 tests ✓, demo runs ✓.
Measured (uv_sphere 32×20): normal max deviation 0.0000 on L0–L2
(was 2.0000), UV max error 0.0084 vs analytical (was 0.5000).
This commit is contained in:
Jérôme Bousquié
2026-09-23 15:01:56 +02:00
parent eac266dd86
commit 40fac63590
4 changed files with 433 additions and 130 deletions
+6 -3
View File
@@ -42,9 +42,12 @@ Ce document sert de spécification technique et de trame d'implémentation pour
> (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
> générés par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les
> 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, UVs/couleurs interpolés au repli — jamais
> à travers une seam UV —, 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 ; soudure **consciente des attributs**
> — un doublon ne fusionne que si UV proches (≤ ½ tuile) ET normales proches (dot > 0.9) — ; un mesh
> sans couture reste fermé, sur un mesh couturé les lèvres de couture sont protégées (pas de trous,
> pas de « books ») ; UVs/couleurs/normales interpolés au repli, normales **héritées** de la source
> (jamais recalculées — l'éclairage reste identique au niveau 0 quelle que soit l'orientation source),
> jamais à travers une seam UV ; 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 :
> 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`