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
+1 -1
View File
@@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
### 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] 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)
- [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, weld tolérance 1e-6 **conscient des attributs** (UV ≤ ½ tuile + normales dot > 0.9 — jamais à travers une seam/côte dure), mesh sans couture reste fermé, lèvres de couture protégées sinon (surface géométriquement complète), UVs/couleurs/normales interpolés au repli (normales héritées, jamais recalculées), 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)
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
+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`
+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
+413 -119
View File
@@ -378,19 +378,55 @@ impl Geometry {
(cx * cx + cy * cy + cz * cz).sqrt() * 0.5
}
/// Welds the given triangles into a single vertex table (positions + optional
/// per-vertex attributes) and u32 face indices — the form [`Collapse`] operates on.
/// Welding uses a small **relative** tolerance (1e-6 of the bounding box, at least
/// 1e-6 world units): positions that agree within it are the same vertex. This
/// absorbs the near-duplicates produced by trig generation (`sin(2π) ≠ 0` — the seam
/// of a torus or UV sphere) so they weld cleanly, while never merging distinct
/// vertices of a well-formed mesh. A welded group's UV/color is the value of the
/// **first vertex encountered** (deterministic; the first triangle's seam UV wins).
/// Attribute-aware weld (used by [`Self::decimated`]): positions within a small
/// relative tolerance are clustered, but a duplicate is merged into a cluster only
/// when it also shares the same UV tile region (within half a tile — a full-tile
/// jump is a texture seam) and nearly the same normal (dot > 0.9, ≈25° — a larger
/// angle is a hard edge). Seam and hard-edge duplicates therefore stay separate, so
/// an edge collapse can never cross a texture chart or a hard edge: the decimated
/// level keeps the source's per-chart texture and shading layout.
fn welded(
&self,
tris: &[[u32; 3]],
) -> (
Vec<[f32; 3]>,
Option<Vec<[f32; 3]>>,
Option<Vec<[f32; 2]>>,
Option<Vec<[f32; 4]>>,
Vec<[u32; 3]>,
) {
self.welded_inner(tris, true)
}
/// Position-only weld (attribute-blind) — used by [`is_closed`]: it welds the trig
/// seams even when the duplicated vertices carry different charts, so topological
/// closedness is measured on the geometric surface, not on the attribute layout.
#[cfg(test)]
fn welded_by_position(
&self,
tris: &[[u32; 3]],
) -> (
Vec<[f32; 3]>,
Option<Vec<[f32; 3]>>,
Option<Vec<[f32; 2]>>,
Option<Vec<[f32; 4]>>,
Vec<[u32; 3]>,
) {
self.welded_inner(tris, false)
}
/// Shared weld (see the two wrappers): a grid of **relative-tolerance** position
/// clusters (1e-6 of the bounding box, at least 1e-6 world units — absorbs the
/// near-duplicates produced by trig generation, `sin(2π) ≠ 0`), plus the attribute
/// test in attribute-aware mode. A welded group's attributes (normal/UV/color) are
/// the value of its **first vertex encountered** (deterministic).
fn welded_inner(
&self,
tris: &[[u32; 3]],
attrs_aware: bool,
) -> (
Vec<[f32; 3]>,
Option<Vec<[f32; 3]>>,
Option<Vec<[f32; 2]>>,
Option<Vec<[f32; 4]>>,
Vec<[u32; 3]>,
@@ -409,10 +445,16 @@ impl Geometry {
let cell = |p: &[f32; 3]| [0, 1, 2].map(|k| (p[k] * inv).round() as i64);
let mut positions: Vec<[f32; 3]> = Vec::with_capacity(self.positions.len());
let mut normals: Option<Vec<[f32; 3]>> = self.normals.is_some().then(Vec::new);
let mut uvs: Option<Vec<[f32; 2]>> = self.uvs.is_some().then(Vec::new);
let mut colors: Option<Vec<[f32; 4]>> = self.colors.is_some().then(Vec::new);
// Grid cell → welded id (broad phase; candidates are verified by distance).
let mut grid: std::collections::HashMap<[i64; 3], u32> = std::collections::HashMap::new();
// Grid cell → welded ids (broad phase; candidates are verified by distance, and
// by attribute compatibility in attribute-aware mode). A cell holds several ids
// when attribute-duplicated vertices (seam/hard edge) live at one position.
let mut grid: std::collections::HashMap<[i64; 3], Vec<u32>> =
std::collections::HashMap::new();
// Welded id → source vertex id of the group's first encountered member.
let mut rep: Vec<usize> = Vec::with_capacity(self.positions.len());
let mut new_indices: Vec<u32> = Vec::with_capacity(tris.len() * 3);
for tri in tris {
for &vi in tri.iter() {
@@ -425,14 +467,22 @@ impl Geometry {
for dy in -1i64..=1 {
for dz in -1i64..=1 {
let key = [c[0] + dx, c[1] + dy, c[2] + dz];
if let Some(&candidate) = grid.get(&key) {
let cp = &positions[candidate as usize];
let d2 = (0..3)
.map(|k| (pos[k] - cp[k]) * (pos[k] - cp[k]))
.sum::<f32>();
if d2 <= 3.0 * eps * eps {
ni = Some(candidate);
break 'search;
if let Some(cands) = grid.get(&key) {
for &candidate in cands {
let cp = &positions[candidate as usize];
let d2 = (0..3)
.map(|k| (pos[k] - cp[k]) * (pos[k] - cp[k]))
.sum::<f32>();
if d2 <= 3.0 * eps * eps
&& (!attrs_aware
|| self.attrs_compatible(
vi as usize,
rep[candidate as usize],
))
{
ni = Some(candidate);
break 'search;
}
}
}
}
@@ -443,13 +493,17 @@ impl Geometry {
None => {
let ni = positions.len() as u32;
positions.push(*pos);
rep.push(vi as usize);
if let Some(normals) = &mut normals {
normals.push(self.normals.as_ref().unwrap()[vi as usize]);
}
if let Some(uvs) = &mut uvs {
uvs.push(self.uvs.as_ref().unwrap()[vi as usize]);
}
if let Some(colors) = &mut colors {
colors.push(self.colors.as_ref().unwrap()[vi as usize]);
}
grid.entry(c).or_insert(ni);
grid.entry(c).or_default().push(ni);
ni
}
};
@@ -460,7 +514,27 @@ impl Geometry {
.chunks_exact(3)
.map(|c| [c[0], c[1], c[2]])
.collect();
(positions, uvs, colors, faces)
(positions, normals, uvs, colors, faces)
}
/// Whether two source vertices (known to be within the weld tolerance) belong to the
/// same texture chart / shading region: same UV tile region (within half a tile) and
/// nearly the same normal (dot > 0.9, ≈25°). Missing attribute tables are skipped
/// (position-only weld). Assumes normalized normals.
fn attrs_compatible(&self, i: usize, j: usize) -> bool {
if let Some(uvs) = &self.uvs {
let (a, b) = (uvs[i], uvs[j]);
if (a[0] - b[0]).abs() > 0.5 || (a[1] - b[1]).abs() > 0.5 {
return false;
}
}
if let Some(normals) = &self.normals {
let (a, b) = (normals[i], normals[j]);
if a[0] * b[0] + a[1] * b[1] + a[2] * b[2] <= 0.9 {
return false;
}
}
true
}
/// Quadric edge-collapse decimation (Step 19, D10 — pure function, no GPU): returns an
@@ -477,13 +551,12 @@ impl Geometry {
/// mesh stays closed — no holes — and an open mesh keeps its boundary: this is the
/// topological guarantee that simple triangle removal can never give. Collapses that
/// would create a non-manifold ("book") or a duplicate face are skipped; faces that
/// would degenerate are dropped. Smooth normals are **recomputed** over the surviving
/// faces (only when the source had normals). A survivor **moved** by a collapse gets
/// its UV/color **re-interpolated** between the two collapsed endpoints at the same λ
/// as its new position, so the texture stays attached to the surface and coarsens
/// smoothly across levels; a UV seam (endpoints more than half a tile apart) is never
/// blended across — the survivor keeps its own value there — and a welded group's
/// UV/color is the value of the **first vertex encountered** (deterministic).
/// would degenerate are dropped. A survivor **moved** by a collapse gets its
/// UV/color/normal **re-interpolated** between the two collapsed endpoints at the same
/// λ as its new position, so the texture and shading stay attached to the surface and
/// coarsen smoothly across levels; a UV seam (endpoints more than half a tile apart)
/// is never blended across — the survivor keeps its own value there — and a welded
/// group's attributes are the value of the **first vertex encountered** (deterministic).
///
/// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a
/// result that would not fit u16 indices (≥ 65536 vertices) → `self.clone()`. Best
@@ -491,6 +564,20 @@ impl Geometry {
/// and 1 on a boundary edge, so the target is reached exactly only when the topology
/// allows it — otherwise the result is off by a triangle or two (never more: the loop
/// stops as soon as it goes at or under the target).
///
/// The **orientation is preserved** from the source: the output faces keep the
/// source winding, and the normals are inherited (λ-blended) from the source vertex
/// normals — never recomputed from the (possibly inward-wound) faces. Every LOD
/// level is therefore shading-compatible with L0, whatever the source's winding
/// (`uv_sphere` is wound inward but its normals point outward — both are kept as-is;
/// recomputing normals from the faces would have inverted them at the LOD switch).
///
/// **Rim protection**: the attribute-aware weld leaves the UV-seam slit and pole
/// fans as *boundary loops* (topologically open rims on a geometrically closed
/// surface). A face touching such a rim is never removed while interior collapses
/// remain — killing it would open a real hole where the cut's other side used to be.
/// Only when the interior alone cannot reach the target (best effort) do boundary
/// collapses run: the rim then loses slivers, the standard open-mesh behavior.
pub fn decimated(&self, target_triangles: u32) -> Geometry {
let Some(tris) = self.non_degenerate_triangles() else {
return self.clone();
@@ -501,22 +588,31 @@ impl Geometry {
}
// Welded form (one vertex table + u32 faces) — what edge collapse operates on.
let (wpos, wuvs, wcolors, wfaces) = self.welded(&tris);
let (wpos, wnormals, wuvs, wcolors, wfaces) = self.welded(&tris);
if wfaces.is_empty() {
return self.clone();
}
// Collapse down to `target_triangles` faces (or as close as the topology allows).
let mut c = Collapse::new(wpos, wuvs, wcolors, wfaces);
let mut pq = c.rebuild_pq();
// The orientation is preserved as-is (source winding + source normals, inherited
// through the collapse) — see the doc: the LOD levels are shading-compatible
// with L0 whatever the source's winding.
let mut c = Collapse::new(wpos, wnormals, wuvs, wcolors, wfaces);
// Strict mode (default): only edges whose faces are fully interior collapse —
// see the **Rim protection** doc. A dry strict queue drops to the best-effort
// fallback (boundary collapses allowed); a dry fallback queue ends the loop.
let mut strict = true;
let mut pq = c.rebuild_pq(strict);
let mut rebuilds = 0u32;
while c.face_count > target_triangles {
let Some(EdgeCost(cost, a, b)) = pq.pop() else {
// Heap exhausted (fresh edges are not in it yet) → full rebuild.
let rebuilt = c.rebuild_pq();
// Heap exhausted (fresh edges are not in it yet) → full rebuild; a dry
// strict queue de-escalates to the fallback mode (see above).
let rebuilt = c.rebuild_pq(!strict);
if rebuilt.is_empty() {
break; // best effort: nothing left that can collapse
}
strict = false;
rebuilds += 1;
if rebuilds > 4 * t {
// Safety valve (pathological mesh: every remaining edge is rejected by
@@ -540,8 +636,8 @@ impl Geometry {
pq.push(EdgeCost(true_cost, a, b));
continue;
}
if !c.collapse_edge(a, b, &mut pq) {
continue; // invalid (book/duplicate) → skipped; re-enters at a rebuild
if !c.collapse_edge(a, b, strict, &mut pq) {
continue; // invalid (book/duplicate/strict-rim) → skipped; re-enters at a rebuild
}
}
@@ -557,14 +653,18 @@ impl Geometry {
let mut new_id = vec![u32::MAX; n];
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n);
// The collapses moved survivors to optimal points and re-interpolated their
// UVs/colors — these tables hold the post-collapse values.
let (cuvs, ccolors) = (c.uvs.take(), c.colors.take());
// normals/UVs/colors — these tables hold the post-collapse values.
let (cnormals, cuvs, ccolors) = (c.normals.take(), c.uvs.take(), c.colors.take());
let mut new_normals = cnormals.is_some().then(Vec::new);
let mut new_uvs = cuvs.is_some().then(Vec::new);
let mut new_colors = ccolors.is_some().then(Vec::new);
for i in 0..n {
if c.active[i] && referenced[i] {
new_id[i] = new_positions.len() as u32;
new_positions.push(c.pos[i]);
if let Some(normals) = &mut new_normals {
normals.push(cnormals.as_ref().unwrap()[i]);
}
if let Some(uvs) = &mut new_uvs {
uvs.push(cuvs.as_ref().unwrap()[i]);
}
@@ -584,42 +684,9 @@ impl Geometry {
}
}
// Recompute smooth normals over the surviving faces (only when the source had
// normals).
let normals = self.normals.as_ref().map(|_| {
let mut acc = vec![[0.0f32; 3]; new_positions.len()];
for tri in new_indices.chunks_exact(3) {
let (ia, ib, ic) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
let pa = new_positions[ia];
let pb = new_positions[ib];
let pc = new_positions[ic];
let nrm = [
(pb[1] - pa[1]) * (pc[2] - pa[2]) - (pb[2] - pa[2]) * (pc[1] - pa[1]),
(pb[2] - pa[2]) * (pc[0] - pa[0]) - (pb[0] - pa[0]) * (pc[2] - pa[2]),
(pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0]),
];
for vi in [ia, ib, ic] {
let v = &mut acc[vi];
v[0] += nrm[0];
v[1] += nrm[1];
v[2] += nrm[2];
}
}
acc.iter()
.map(|v| {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len < 1e-12 {
[0.0, 0.0, 1.0]
} else {
[v[0] / len, v[1] / len, v[2] / len]
}
})
.collect()
});
Geometry {
positions: new_positions,
normals,
normals: new_normals,
uvs: new_uvs,
colors: new_colors,
indices: Some(new_indices),
@@ -669,6 +736,11 @@ impl Geometry {
struct Collapse {
/// Mutable positions: a collapse target moves to its optimal point.
pos: Vec<[f32; 3]>,
/// Normals, λ-blended on a collapse like the UVs but with no seam guard (normals are
/// continuous on a smooth chart, and the attribute-aware weld kept hard-edge
/// duplicates separate). Inherited from the source, never recomputed (see
/// `Geometry::decimated`). `None` when the source had no normals.
normals: Option<Vec<[f32; 3]>>,
/// UVs, moved in with the positions: a collapse target's UV is re-interpolated
/// between the two collapsed endpoints (at the same λ as its new position) so the
/// texture stays attached to the surface across levels. `None` when the source had
@@ -751,9 +823,35 @@ fn tri_sorted(mut t: [u32; 3]) -> [u32; 3] {
t
}
/// True when the mesh is **closed** topologically: after a position-only weld, every
/// edge lies in exactly two faces (no boundary edges → no holes). The position-only weld
/// makes this robust to UV seams (the same position duplicated with different charts) and
/// to attribute-duplicated pole vertices. Used by the decimation tests to check that
/// seam-free inputs (icosahedron) stay topologically closed after the collapse.
#[cfg(test)]
fn is_closed(geo: &Geometry) -> bool {
let Some(tris) = geo.non_degenerate_triangles() else {
return false;
};
let (_pos, _normals, _uvs, _colors, faces) = geo.welded_by_position(&tris);
let mut edges: std::collections::HashMap<[u32; 2], u32> = std::collections::HashMap::new();
for tri in &faces {
if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] {
continue; // degenerate after welding (pole/seam duplicate) — no edges to count
}
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
let (a, b) = (tri[i], tri[j]);
let key = if a < b { [a, b] } else { [b, a] };
*edges.entry(key).or_insert(0) += 1;
}
}
!edges.is_empty() && edges.values().all(|&c| c == 2)
}
impl Collapse {
fn new(
pos: Vec<[f32; 3]>,
normals: Option<Vec<[f32; 3]>>,
uvs: Option<Vec<[f32; 2]>>,
colors: Option<Vec<[f32; 4]>>,
faces: Vec<[u32; 3]>,
@@ -761,6 +859,7 @@ impl Collapse {
let n = pos.len();
let mut c = Self {
pos,
normals,
uvs,
colors,
active: vec![true; n],
@@ -875,14 +974,20 @@ impl Collapse {
/// Full priority-queue rebuild: every edge whose endpoints are both active and that
/// has 1–2 living faces, ordered by (cost, a, b) — deterministic.
fn rebuild_pq(&self) -> std::collections::BinaryHeap<EdgeCost> {
///
/// `strict`: only edges whose incident faces are **fully interior** (every edge in
/// exactly two living faces) are queued — see `decimated`'s **Rim protection**.
/// `!strict` (best-effort fallback) queues every 1–2 face edge, the standard
/// behavior where a boundary collapse may remove a face.
fn rebuild_pq(&self, strict: bool) -> std::collections::BinaryHeap<EdgeCost> {
let mut pq = std::collections::BinaryHeap::new();
for (&key, faces) in &self.edge_faces {
let live = faces
let live: Vec<u32> = faces
.iter()
.filter(|&&f| self.faces[f as usize].is_some())
.count();
if live == 0 || live > 2 {
.copied()
.filter(|&f| self.faces[f as usize].is_some())
.collect();
if live.is_empty() || live.len() > 2 {
continue;
}
let a = (key >> 32) as u32;
@@ -890,12 +995,34 @@ impl Collapse {
if !self.active[a as usize] || !self.active[b as usize] {
continue;
}
if strict && !live.iter().all(|&f| self.face_fully_interior(f)) {
continue;
}
let (cost, _, _) = self.cost_and_point(a, b);
pq.push(EdgeCost(cost, a, b));
}
pq
}
/// True when every edge of face `f` lies in exactly two living faces (the face
/// touches no boundary). Only such a face may die in a strict collapse: each of its
/// edges has a live neighbour on the far side that the remap extends over the freed
/// area. A face with a boundary edge has an uncovered side — removing it opens a
/// hole (at a UV-seam slit, the far side is the cut's other rim, not a face).
fn face_fully_interior(&self, f: u32) -> bool {
let Some(tri) = self.faces[f as usize] else {
return false;
};
for i in 0..3 {
for j in (i + 1)..3 {
if self.edge_face_count(tri[i], tri[j]) != 2 {
return false;
}
}
}
true
}
/// Merges `s` into `t` and rewrites the edge's incident faces, or returns `false`
/// when the collapse would create a non-manifold vertex or a duplicate face (the
/// "book" test). Merge direction: the endpoint with the **smaller** face degree
@@ -907,6 +1034,7 @@ impl Collapse {
&mut self,
a: u32,
b: u32,
strict: bool,
pq: &mut std::collections::BinaryHeap<EdgeCost>,
) -> bool {
let live_deg = |v: u32| {
@@ -939,6 +1067,12 @@ impl Collapse {
return false;
}
// Strict path: the pair faces must STILL be fully interior — a neighbour may
// have died since the edge was queued, opening a boundary edge on one of them.
if strict && !pair.iter().all(|&f| self.face_fully_interior(f)) {
return false;
}
// Duplicate-face test (fold): every remapped face of `s` must stay unique among
// the living faces of `t` and the other remapped faces (sorted triples). A
// duplicate is a fold — the surface would double-cover a region — and is rejected.
@@ -997,10 +1131,12 @@ impl Collapse {
self.pos[t as usize] = pt;
// Keep the survivor's texture attributes consistent with its NEW position: the
// optimal point is clamped to the segment, so it is (1−λ)·p_s + λ·p_t — blend the
// UVs/colors with the same λ and the texture stays attached to the surface (it
// coarsens smoothly across levels instead of jumping). A UV **seam** (endpoints
// more than half a tile apart) is never blended across — that would smear the
// texture across the seam — so the survivor keeps its own value there.
// UVs/colors/normals with the same λ and the texture/shading stay attached to the
// surface (they coarsen smoothly across levels instead of jumping). A UV **seam**
// (endpoints more than half a tile apart) is never blended across — that would
// smear the texture across the seam — so the survivor keeps its own value there.
// Normals are always blended: the weld kept hard-edge vertices separate, so no
// edge crosses a shading discontinuity.
if let Some(uvs) = &mut self.uvs {
let us = uvs[s as usize];
let ut = uvs[t as usize];
@@ -1015,6 +1151,22 @@ impl Collapse {
colors[t as usize][k] = cs[k] + lam * (ct[k] - cs[k]);
}
}
if let Some(normals) = &mut self.normals {
let ns = normals[s as usize];
let nt = normals[t as usize];
let mut m = [0.0f32; 3];
for k in 0..3 {
m[k] = ns[k] + lam * (nt[k] - ns[k]);
}
// Re-normalize: a blend of two unit normals has a slightly sub-unit length.
let len = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
if len > 1e-12 {
for k in 0..3 {
m[k] /= len;
}
normals[t as usize] = m;
}
}
let qs = self.quad[s as usize];
self.quad[t as usize] += qs;
self.active[s as usize] = false;
@@ -1249,28 +1401,6 @@ mod tests {
.with_indices(vec![0, 1, 2, 0, 3, 4, 0, 5, 6])
}
/// True when the mesh is **closed** topologically: after welding by position, every
/// edge lies in exactly two faces (no boundary edges → no holes). Welding makes this
/// robust to UV seams (the same position duplicated with different UVs).
fn is_closed(geo: &Geometry) -> bool {
let Some(tris) = geo.non_degenerate_triangles() else {
return false;
};
let (_pos, _uvs, _colors, faces) = geo.welded(&tris);
let mut edges: std::collections::HashMap<[u32; 2], u32> = std::collections::HashMap::new();
for tri in &faces {
if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] {
continue; // degenerate after welding (pole/seam duplicate) — no edges to count
}
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
let (a, b) = (tri[i], tri[j]);
let key = if a < b { [a, b] } else { [b, a] };
*edges.entry(key).or_insert(0) += 1;
}
}
!edges.is_empty() && edges.values().all(|&c| c == 2)
}
/// The output bounding box stays inside the input's: optimal points are clamped to
/// edge segments (convex combinations of input positions).
fn bbox_inside(input: &Geometry, out: &Geometry) -> bool {
@@ -1325,7 +1455,21 @@ mod tests {
assert!(is_closed(&sph), "input is closed");
let out = sph.decimated(t / 2);
assert!(out.validate().is_ok());
assert!(is_closed(&out), "sphere stays closed");
// The attribute-aware weld slits the seam/poles (topological rims); rim
// protection keeps the rim faces alive, so the surface stays geometrically
// complete — no hole. Check: the seam column (u = 1) vertices all survive.
let seam = |g: &Geometry| {
g.positions
.iter()
.zip(g.uvs.as_ref().unwrap().iter())
.filter(|(_, uv)| uv[0] > 0.99)
.count()
};
let (si, so) = (seam(&sph), seam(&out));
assert!(
so >= si - 2,
"seam column survives (no hole at the slit): in={si}, out={so}"
);
assert!(bbox_inside(&sph, &out));
let in_z = sph
.positions
@@ -1351,7 +1495,20 @@ mod tests {
assert!(is_closed(&torus), "input is closed");
let out = torus.decimated(t / 2);
assert!(out.validate().is_ok());
assert!(is_closed(&out), "torus stays closed");
// As for the sphere: the welded seam is a rim; rim protection keeps the seam
// column alive → the surface stays geometrically complete (no hole at the slit).
let seam = |g: &Geometry| {
g.positions
.iter()
.zip(g.uvs.as_ref().unwrap().iter())
.filter(|(_, uv)| uv[0] > 0.99)
.count()
};
let (si, so) = (seam(&torus), seam(&out));
assert!(
so >= si - 2,
"seam column survives (no hole at the slit): in={si}, out={so}"
);
assert!(bbox_inside(&torus, &out));
assert!(
out.num_triangles() as u32 <= t / 2,
@@ -1453,14 +1610,75 @@ mod tests {
}
#[test]
fn decimated_recomputes_unit_normals() {
// Garbage (unnormalized) input normals; output normals must be unit length.
let geo = tri_fan().with_normals(vec![[9.0, 9.0, 9.0]; 7]);
fn decimated_inherits_source_normals() {
// Normals are INHERITED from the source, never recomputed from the faces (the
// source's winding may be inward — recomputation would flip the lighting at the
// LOD switch). A collapse blends the pair's normals at the same λ and
// re-normalizes — with identical input normals the output is exactly the source
// value, so every output normal must equal [0,0,1].
let geo = tri_fan().with_normals(vec![[0.0, 0.0, 1.0]; 7]);
let out = geo.decimated(2);
let normals = out.normals.expect("normals recomputed");
let normals = out.normals.expect("normals inherited");
for n in &normals {
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {n:?}");
assert_eq!(*n, [0.0, 0.0, 1.0], "normal must equal the source's: {n:?}");
}
}
/// Regression (user-reported artifact): the decimated levels of a UV sphere must
/// keep their UVs close to the analytical parameterization AND their normals pointing
/// outward (inherited from the source — before the fix, normals were recomputed from
/// the inward-wound faces and pointed INWARD, deviation ≈ 2.0, far LOD looked flat).
#[test]
fn decimated_sphere_uv_and_normal_error() {
use crate::math::primitives;
let r = 0.55;
let geo = primitives::uv_sphere(r, 32, 20);
let levels = geo.generate_lod_levels(3);
let analytical = |p: [f32; 3]| -> [f32; 2] {
let mut theta = p[2].atan2(p[0]);
if theta < 0.0 {
theta += std::f32::consts::TAU;
}
let phi = (p[1] / r).clamp(-1.0, 1.0).acos();
[theta / std::f32::consts::TAU, phi / std::f32::consts::PI]
};
let udist = |a: f32, b: f32| {
let d = (a - b).abs();
d.min(1.0 - d)
};
// L0 is the untouched source (its seam keeps u = 0 by design) — check L1/L2 only.
for (i, lvl) in levels.iter().enumerate().filter(|(i, _)| *i > 0) {
let uvs = lvl.uvs.as_ref().expect("uvs");
let (mut max_e, mut sum_e) = (0.0f32, 0.0f32);
for (p, uv) in lvl.positions.iter().zip(uvs.iter()) {
let a = analytical(*p);
let e = if (p[1] / r).abs() > 0.999 {
// Pole: a single point, the parameterization degenerates there — u is
// arbitrary (even `atan2` flips on the ±0.0 of a pole vertex), check v only.
(uv[1] - a[1]).abs()
} else {
udist(uv[0], a[0]).max((uv[1] - a[1]).abs())
};
max_e = max_e.max(e);
sum_e += e;
}
let mean_e = sum_e / uvs.len() as f32;
assert!(
max_e < 0.25,
"L{i}: max UV error {max_e:.4} vs the analytical parameterization"
);
assert!(mean_e < 0.05, "L{i}: mean UV error {mean_e:.4}");
let norms = lvl.normals.as_ref().expect("normals");
let mut max_n = 0.0f32;
for (p, nrm) in lvl.positions.iter().zip(norms.iter()) {
let rl = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
let dot = ((p[0] * nrm[0] + p[1] * nrm[1] + p[2] * nrm[2]) / rl).clamp(-1.0, 1.0);
max_n = max_n.max((1.0 - dot).max(0.0));
}
assert!(
max_n < 0.1,
"L{i}: normals must point outward (max deviation {max_n:.4})"
);
}
}
@@ -1543,10 +1761,53 @@ mod tests {
);
}
/// A cylinder has two UV charts at the same ring positions (side band: v on the 0/1
/// lines; caps: a disc around (0.5, 0.5)). The attribute-aware weld keeps the
/// duplicates separate, so decimating must never blend the charts together.
#[test]
fn welded_uv_first_encountered_wins() {
// Seam: the same position with different UVs — the first-encountered UV wins.
let geo = Geometry::new(vec![
fn decimated_cylinder_keeps_charts() {
use crate::math::primitives;
let cyl = primitives::cylinder(0.4, 0.9, 8);
let out = cyl.decimated(16);
assert!(out.validate().is_ok());
let uvs = out.uvs.as_ref().expect("uvs");
let mut cap_chart = false;
let mut side_chart = false;
for (p, uv) in out.positions.iter().zip(uvs.iter()) {
if p[1].abs() < 0.3 {
continue; // mid-height side vertices — not part of either ring chart
}
// A collapse blends UVs only along an edge, and no edge ever spans two
// charts (the weld kept them in separate vertex groups) — so a ring vertex's
// UV must stay inside one chart's region: the cap DISC (interior included —
// a blend of two ring points lies inside the disc) or the side v = 0/1 lines.
let d2 = (uv[0] - 0.5) * (uv[0] - 0.5) + (uv[1] - 0.5) * (uv[1] - 0.5);
let on_vline = uv[1] < 0.05 || uv[1] > 0.95;
if d2 <= 0.31 {
cap_chart = true; // inside the cap disc (ring or blended interior)
} else if on_vline {
side_chart = true; // on the v = 0/1 band lines
} else {
panic!(
"chimera vertex at {:?}: uv {:?} belongs to neither chart",
p, uv
);
}
}
assert!(
side_chart,
"the side chart ring survived (v on the 0/1 lines)"
);
assert!(cap_chart, "the cap chart ring survived (disc UVs)");
}
/// The weld is attribute-aware: it merges duplicates only when the UV and normal
/// charts agree — a seam (Δuv > ½ tile) or a hard edge (dot ≤ 0.9) stays separate,
/// so a collapse can never cross a chart boundary.
#[test]
fn welded_attribute_aware() {
// Seam: same position, different UVs (Δu = 9 > 0.5) → NOT welded.
let seam = Geometry::new(vec![
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 2.0, 0.0],
@@ -1561,15 +1822,48 @@ mod tests {
[9.0, 9.0],
])
.with_indices(vec![0, 1, 2, 3, 4, 2]);
let tris = geo.non_degenerate_triangles().unwrap();
let (positions, uvs, _colors, faces) = geo.welded(&tris);
assert_eq!(positions.len(), 4, "the seam duplicate welds into vertex 0");
let tris = seam.non_degenerate_triangles().unwrap();
let (positions, _normals, uvs, _colors, faces) = seam.welded(&tris);
assert_eq!(positions.len(), 5, "the seam duplicate stays separate");
assert_eq!(uvs.expect("uvs")[4], [9.0, 9.0], "its chart is preserved");
assert_eq!(faces, [[0, 1, 2], [3, 4, 2]]);
// Same position, same UV, same normal → welded (first-encountered id wins).
let same = Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 0.0], // duplicate of vertex 0, identical attributes
])
.with_uvs(vec![[0.0, 0.0], [0.2, 0.0], [0.0, 0.2], [0.0, 0.0]])
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
.with_indices(vec![0, 1, 2, 3, 1, 2]);
let tris = same.non_degenerate_triangles().unwrap();
let (positions, _normals, _uvs, _colors, _faces) = same.welded(&tris);
assert_eq!(
uvs.expect("uvs")[0],
[0.0, 0.0],
"first-encountered UV wins"
positions.len(),
3,
"the identical duplicate welds into vertex 0"
);
assert_eq!(faces, [[0, 1, 2], [3, 0, 2]]);
// Same position + UV, different normal (hard edge, dot = 0 ≤ 0.9) → NOT welded.
let hard = Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 0.0], // duplicate of vertex 0, perpendicular normal
])
.with_uvs(vec![[0.0, 0.0], [0.2, 0.0], [0.0, 0.2], [0.0, 0.0]])
.with_normals(vec![
[0.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
[1.0, 0.0, 0.0],
])
.with_indices(vec![0, 1, 2, 3, 1, 2]);
let tris = hard.non_degenerate_triangles().unwrap();
let (positions, _normals, _uvs, _colors, _faces) = hard.welded(&tris);
assert_eq!(positions.len(), 4, "the hard-edge duplicate stays separate");
}
#[test]