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
+13 -7
View File
@@ -96,11 +96,17 @@ CPU):
automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
`Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed
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); a survivor **moved** by a collapse gets its UV/color **interpolated**
between the collapsed endpoints (same λ as its new position — the texture stays attached
to the surface and coarsens smoothly across levels, never across a UV seam), and the
neighbours — no new face, so a **seam-free mesh stays closed** (no holes, no non-manifold
"books") and a boundary collapse removes one face; duplicate corners are welded **aware of
their attributes** (relative position tolerance 1e-6, merged only when the UVs are within half
a tile and the normals within ~25° — a seam or a hard edge stays a separate corner, so a
collapse can never cross a texture chart or a shading break; on a mesh with a UV seam the
seam's lips are then *protected* — their faces never collapse while interior ones remain — so
the surface stays geometrically complete); a survivor **moved** by a collapse gets its
UV/color/normal **interpolated** between the collapsed endpoints (same λ as its new position —
texture and shading stay attached to the surface and coarsen smoothly across levels, never
across a UV seam; normals are **inherited from the source, never recomputed**, so the lighting
is identical to level 0 whatever the source's winding), and the
levels are **packed into the mesh's single
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
@@ -219,8 +225,8 @@ and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
- **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all
levels at 65 535 vertices, and there are at most 4 levels. The decimation (smallest-triangle
removal + welding) is a setup-time cost only (a few ms for thousands of triangles); the
levels at 65 535 vertices, and there are at most 4 levels. The decimation (quadric edge collapse
+ attribute-aware welding) is a setup-time cost only (a few ms for thousands of triangles); the
per-frame cost is one sphere projection per entity on the CPU.
- **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is