LOD: fige les jumeaux de fente (slit twins) + blend UV linéaire (le fold est supprimé)
- weld: Δ UV = 0.5 exact n'est plus soudé (ambigu: fente à sa plus large vs saut légitime — la colonne u=1 du cône vs le chart disque du cap tombait exactement dessus et mélangeait les charts) - collapse: les UV se blendent linéairement. Le fold par coordonnée (Δ entier → 0) figeait l'UV du sommet sur les vertices de base (bande de rayures, signalée par l'utilisateur) — il était inutile: les jumeaux de fente sont gelés, aucun repli ne traverse la fente - welded renvoie un struct Welded (clippy) - test cône: dual-chart (fan latéral bilinéaire + disque du cap), surface latérale r = λ; test seam/span réécrit selon la sémantique finale - doc: gpu-driven.md, ARCHI_CPU_GPU.md (box LOD), DRAFT.md (D10) alignés sur la sémantique finale (gel des jumeaux, blend linéaire, seuil strict) - AGENTS.md: gotcha 'ne jamais folder un saut entier de tuile' 102 tests passent, demo lance et rend sans erreur.
This commit is contained in:
@@ -44,6 +44,7 @@ WGPU doesn't have a native "Context" object — this type groups them together f
|
||||
- No feature flags, no dev-dependencies, no tests yet. Adding any requires updating both `Cargo.toml` files if the dependency spans crates.
|
||||
- The workspace has no `[workspace.dependencies]` section. Dependencies are declared per-crate rather than centrally.
|
||||
- **WGSL `select` argument order** (cost us a day): `select(reject, accept, cond)` returns the **second** arg when `cond` is true — the reverse of HLSL's `select(trueVal, falseVal, cond)`. In `shaders/gpu_driven.wgsl` the cull pass must stay `select(0u, u32(flags.z), visible)` (visible ⇒ full count, culled ⇒ 0). Swapped args silently zero the counts of every visible entity → black window. See the GOTCHA comment at the top of that shader.
|
||||
- **LOD UV blending: never fold integer-tile jumps, freeze seam twins instead** (cost us a day, 2026-09-23): a UV *seam* is two copies of the same 3-D point on integer-apart UVs (u=0/u=1 columns) — it is NOT a mesh edge, so the decimation must record the weld's refused pairs and **freeze** those twins (any edge touching one is excluded from the PQ). A co-facial edge spanning a whole tile (cone apex v=1 ↔ base v=0) is a *legit* chart span — the chart is bilinear, so the UVs **blend linearly** (fold the integer jump to zero and the apex UV smears down the cone side). And the attribute-aware weld refuses a Δ of *exactly* 0.5 (ambiguous: seam at its widest vs legit half-tile jump — the cone's u=1 column vs the cap-disc chart sits exactly there). See the comments in `geometry.rs` (`welded`, `Collapse::collapse_edge`) and the cone/seam regression tests.
|
||||
|
||||
<!-- lean-ctx -->
|
||||
## lean-ctx
|
||||
|
||||
+17
-10
@@ -123,8 +123,11 @@ capot**. Mécanisme : **quadric edge collapse** (Garland–Heckbert) sur le mail
|
||||
```
|
||||
Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
|
||||
```
|
||||
- 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
|
||||
- Weld préalable **conscient des attributs** (tolérance relative 1e-6 — grille + 27 voisins : les
|
||||
seams trigonométriques diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas ; un doublon ne
|
||||
fusionne que si UV strictement < ½ tuile par coordonnée — un Δ = ½ exact est ambigu, fente à sa
|
||||
plus large vs saut légitime — ET normales à ~25° ; les paires refusées à UV écart d'entier sont
|
||||
**enregistrées** comme jumeaux de fente), 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 :
|
||||
@@ -133,13 +136,16 @@ Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
|
||||
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 :
|
||||
le vertex **déplacé** par un repli reçoit l'**interpolation** des UVs/couleurs de la paire
|
||||
(même λ que son nouveau point optimal) — la texture reste attachée à la surface et se
|
||||
grossit doucement d'un niveau à l'autre (pas de saut radical) ; **jamais d'interpolation à
|
||||
travers une seam UV** (extrémités à plus de 0.5 de tuile d'écart → la cible garde son UV) ;
|
||||
un groupe soudé garde l'UV du premier vertex rencontré (déterministe).
|
||||
- Sortie : re-indexation (le weld ci-dessus). Normales : **héritées de la source, jamais
|
||||
recalculées** (elles passent telles quelles et sont interpolées par le repli — l'éclairage
|
||||
reste identique au niveau 0 quelle que soit l'orientation de la source). UVs/couleurs :
|
||||
le vertex **déplacé** par un repli reçoit le **blend linéaire** de la paire (même λ que son
|
||||
nouveau point optimal) — le chart est bilinéaire, donc le blend est la valeur exacte du chart
|
||||
au nouveau point : la texture reste attachée à la surface et se grossit doucement d'un niveau
|
||||
à l'autre (pas de saut radical) ; **jamais de blend à travers une seam UV** — les jumeaux de
|
||||
fente (même position, UV écart d'entier) sont **gelés** : toute arête qui y touche est exclue
|
||||
de la file, donc aucun repli ne traverse la fente (c'est le gel qui protège, pas un rejet de
|
||||
blend) ; un groupe soudé garde l'UV du premier vertex rencontré (déterministe).
|
||||
- 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** (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) →
|
||||
@@ -263,7 +269,8 @@ pub fn projected_radius_px(center_world: Vec3, radius: f32, view: Mat4, proj: Ma
|
||||
|
||||
/// 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.
|
||||
/// quadrique le plus faible partent en premier, normales **héritées** de la source (jamais
|
||||
/// recalculées), UVs/couleurs blendés linéairement, jumeaux de fente gelés. Déterministe.
|
||||
pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry
|
||||
```
|
||||
|
||||
|
||||
@@ -43,11 +43,16 @@ Ce document sert de spécification technique et de trame d'implémentation pour
|
||||
> 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 ; 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
|
||||
> — un doublon ne fusionne que si UV strictement < ½ tuile par coordonnée (un Δ = ½ exact est
|
||||
> ambigu : fente à sa plus large vs saut légitime) ET normales proches (dot > 0.9) ; les paires
|
||||
> refusées à UV écart d'entier sont **enregistrées** (jumeaux de fente) — ; un mesh sans couture
|
||||
> reste fermé (pas de trous, pas de « books »), et sur un mesh couturé les jumeaux de fente sont
|
||||
> **gelés** (toute arête qui y touche est exclue de la file — la fente zéro-largeur reste fermée à
|
||||
> tous les niveaux) ; UVs/couleurs/normales **blendés linéairement** au repli (le chart est
|
||||
> bilinéaire → exact au nouveau point — jamais de fold de tuile, qui figeait l'UV d'un sommet sur
|
||||
> les vertices de base), normales **héritées** de la source (jamais recalculées — l'éclairage reste
|
||||
> identique au niveau 0 quelle que soit l'orientation source) ; 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`
|
||||
|
||||
+14
-9
@@ -98,15 +98,20 @@ CPU):
|
||||
cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps 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
|
||||
their attributes** (relative position tolerance 1e-6, merged only when the UVs are strictly
|
||||
less than half a tile apart on both coordinates — an offset of exactly ½ is ambiguous: a wrap
|
||||
seam at its widest or a legitimate half-tile jump — and the normals within ~25°; a seam or a
|
||||
hard edge therefore stays a separate corner, and the weld *records* the integer-apart pairs it
|
||||
refused); on a mesh with a UV seam those **seam twins are frozen** — every edge touching one
|
||||
is excluded from the collapse queue — so the zero-width slit stays closed at every level, and
|
||||
the rim protection (no boundary collapse while any interior edge remains) keeps the surface
|
||||
geometrically complete; a survivor **moved** by a collapse gets its UV/color/normal
|
||||
**blended linearly** between the collapsed endpoints (same λ as its new position — the chart
|
||||
is bilinear, so the blend is the exact chart value at the new point: texture and shading stay
|
||||
attached to the surface and coarsen smoothly across levels, and a seam is never crossed because
|
||||
its twins are frozen, not because a blend is rejected; 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
|
||||
|
||||
+286
-90
@@ -380,21 +380,18 @@ impl Geometry {
|
||||
|
||||
/// 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]>,
|
||||
) {
|
||||
/// when it also shares the same UV tile region (strictly less than half a tile apart
|
||||
/// on both coordinates — a Δ of exactly 0.5 is ambiguous: a wrap seam at its widest
|
||||
/// *or* a legit half-tile jump, and the cone's u = 1 side column vs the cap-disc
|
||||
/// chart sits exactly there, so it is never welded) 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.
|
||||
/// Returns a [`Welded`]: the welded attribute arrays plus the **seam twins** — the
|
||||
/// pairs of welded ids the weld refused to merge (same position, incompatible
|
||||
/// attributes) — the slit bookkeeping the decimation uses to keep UV seams closed.
|
||||
fn welded(&self, tris: &[[u32; 3]]) -> Welded {
|
||||
self.welded_inner(tris, true)
|
||||
}
|
||||
|
||||
@@ -402,16 +399,7 @@ impl Geometry {
|
||||
/// 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]>,
|
||||
) {
|
||||
fn welded_by_position(&self, tris: &[[u32; 3]]) -> Welded {
|
||||
self.welded_inner(tris, false)
|
||||
}
|
||||
|
||||
@@ -420,17 +408,7 @@ impl Geometry {
|
||||
/// 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]>,
|
||||
) {
|
||||
fn welded_inner(&self, tris: &[[u32; 3]], attrs_aware: bool) -> Welded {
|
||||
// Relative tolerance from the bounding box (at least 1e-6 world units).
|
||||
let (mut mn, mut mx) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
for p in &self.positions {
|
||||
@@ -456,6 +434,8 @@ impl Geometry {
|
||||
// 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);
|
||||
// Seam twins the weld refused to merge (slit bookkeeping for the decimation).
|
||||
let mut seam_pairs: Vec<(u32, u32)> = Vec::new();
|
||||
for tri in tris {
|
||||
for &vi in tri.iter() {
|
||||
let pos = &self.positions[vi as usize];
|
||||
@@ -463,6 +443,7 @@ impl Geometry {
|
||||
// Search this cell and its 26 neighbours: rounding can straddle a cell
|
||||
// boundary, so an in-tolerance twin may sit in an adjacent cell.
|
||||
let mut ni: Option<u32> = None;
|
||||
let mut refused: Option<u32> = None;
|
||||
'search: for dx in -1i64..=1 {
|
||||
for dy in -1i64..=1 {
|
||||
for dz in -1i64..=1 {
|
||||
@@ -473,16 +454,20 @@ impl Geometry {
|
||||
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;
|
||||
if d2 > 3.0 * eps * eps {
|
||||
continue;
|
||||
}
|
||||
if attrs_aware
|
||||
&& !self
|
||||
.attrs_compatible(vi as usize, rep[candidate as usize])
|
||||
{
|
||||
// Same point, different chart: the weld refuses
|
||||
// to merge — remember the twin for the decimation.
|
||||
refused = refused.or(Some(candidate));
|
||||
continue;
|
||||
}
|
||||
ni = Some(candidate);
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,6 +489,9 @@ impl Geometry {
|
||||
colors.push(self.colors.as_ref().unwrap()[vi as usize]);
|
||||
}
|
||||
grid.entry(c).or_default().push(ni);
|
||||
if let (true, Some(r)) = (attrs_aware, refused) {
|
||||
seam_pairs.push((ni, r));
|
||||
}
|
||||
ni
|
||||
}
|
||||
};
|
||||
@@ -514,7 +502,14 @@ impl Geometry {
|
||||
.chunks_exact(3)
|
||||
.map(|c| [c[0], c[1], c[2]])
|
||||
.collect();
|
||||
(positions, normals, uvs, colors, faces)
|
||||
Welded {
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
colors,
|
||||
faces,
|
||||
seam_twins: seam_pairs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two source vertices (known to be within the weld tolerance) belong to the
|
||||
@@ -524,7 +519,11 @@ impl Geometry {
|
||||
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 {
|
||||
// Strictly less than half a tile: a Δ of *exactly* 0.5 is ambiguous (a wrap
|
||||
// seam at its widest vs. a legit half-tile attribute jump — the cone's
|
||||
// u = 1 side column vs the cap-disc chart (1.0, 0.5) sits exactly there)
|
||||
// → never weld across it (keeping the duplicates is the safe direction).
|
||||
if (a[0] - b[0]).abs() >= 0.5 || (a[1] - b[1]).abs() >= 0.5 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -554,9 +553,14 @@ impl Geometry {
|
||||
/// 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).
|
||||
/// coarsen smoothly across levels. UVs blend **linearly** — a UV **seam** (two copies
|
||||
/// of the same point on integer-apart UVs) is recorded by the weld and its twins are
|
||||
/// **frozen**: every edge touching one is excluded, so no collapse ever crosses the
|
||||
/// slit and the seam UVs are never blended; a co-facial edge spanning a whole tile
|
||||
/// (the cone's apex v = 1 ↔ base v = 0) is a *legit* chart span and blends straight —
|
||||
/// the chart is bilinear, so the linear blend is the exact chart value at the new
|
||||
/// position. 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
|
||||
@@ -578,6 +582,13 @@ impl Geometry {
|
||||
/// 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.
|
||||
///
|
||||
/// **Slit freezing**: the slit's two rim columns (the u = 0 / u = 1 copies of the
|
||||
/// same points) are *frozen* in both modes — no edge touching a slit-twin vertex is
|
||||
/// ever collapsed, so the zero-width cut can never open into a visible crack when
|
||||
/// the fallback lets boundary collapses run. Their faces still die through the
|
||||
/// other edges, so the target is still reached; chart-boundary duplicates (side vs
|
||||
/// cap, non-integer UV offset) are NOT frozen and decimate normally.
|
||||
pub fn decimated(&self, target_triangles: u32) -> Geometry {
|
||||
let Some(tris) = self.non_degenerate_triangles() else {
|
||||
return self.clone();
|
||||
@@ -588,8 +599,8 @@ impl Geometry {
|
||||
}
|
||||
|
||||
// Welded form (one vertex table + u32 faces) — what edge collapse operates on.
|
||||
let (wpos, wnormals, wuvs, wcolors, wfaces) = self.welded(&tris);
|
||||
if wfaces.is_empty() {
|
||||
let w = self.welded(&tris);
|
||||
if w.faces.is_empty() {
|
||||
return self.clone();
|
||||
}
|
||||
|
||||
@@ -597,7 +608,14 @@ impl Geometry {
|
||||
// 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);
|
||||
let mut c = Collapse::new(
|
||||
w.positions,
|
||||
w.normals,
|
||||
w.uvs,
|
||||
w.colors,
|
||||
w.faces,
|
||||
&w.seam_twins,
|
||||
);
|
||||
// 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.
|
||||
@@ -626,6 +644,9 @@ impl Geometry {
|
||||
if !c.active[a as usize] || !c.active[b as usize] {
|
||||
continue;
|
||||
}
|
||||
if c.twin[a as usize].is_some() || c.twin[b as usize].is_some() {
|
||||
continue; // slit twin: frozen (re-pushed entries bypass rebuild_pq)
|
||||
}
|
||||
let live = c.edge_face_count(a, b);
|
||||
if live == 0 || live > 2 {
|
||||
continue;
|
||||
@@ -733,6 +754,20 @@ impl Geometry {
|
||||
/// with fresh costs after each collapse. Quadrics are maintained incrementally
|
||||
/// (`Q_t += Q_s + plane(new face)`), so a full rebuild only happens when the heap
|
||||
/// runs dry.
|
||||
/// Weld output (see [`Geometry::welded`]): the welded attribute arrays plus the
|
||||
/// **seam twins** — the pairs of welded ids the weld refused to merge (same position,
|
||||
/// incompatible attributes). The decimation freezes the integer-apart ones (true UV
|
||||
/// slits) so no collapse ever crosses the slit.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Welded {
|
||||
positions: Vec<[f32; 3]>,
|
||||
normals: Option<Vec<[f32; 3]>>,
|
||||
uvs: Option<Vec<[f32; 2]>>,
|
||||
colors: Option<Vec<[f32; 4]>>,
|
||||
faces: Vec<[u32; 3]>,
|
||||
seam_twins: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
struct Collapse {
|
||||
/// Mutable positions: a collapse target moves to its optimal point.
|
||||
pos: Vec<[f32; 3]>,
|
||||
@@ -760,6 +795,12 @@ struct Collapse {
|
||||
quad: Vec<Mat4>,
|
||||
/// Number of living faces.
|
||||
face_count: u32,
|
||||
/// Slit twins: welded seam copies (same point, integer-apart UVs — a wrap seam, e.g.
|
||||
/// the u = 0 / u = 1 columns). A twin vertex must never move or die: collapsing one
|
||||
/// of its edges would tear the zero-width UV-seam slit open into a visible crack.
|
||||
/// Every edge touching a twin is filtered out of the queues (their faces can still
|
||||
/// die through the *other* edges). `None` = not a slit twin.
|
||||
twin: Vec<Option<u32>>,
|
||||
}
|
||||
|
||||
/// Priority-queue entry `(cost, endpoint a, endpoint b)`. `BinaryHeap` is a max-heap,
|
||||
@@ -833,7 +874,7 @@ 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 Welded { 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] {
|
||||
@@ -848,6 +889,27 @@ fn is_closed(geo: &Geometry) -> bool {
|
||||
!edges.is_empty() && edges.values().all(|&c| c == 2)
|
||||
}
|
||||
|
||||
/// Whether the UV offset between two vertices is the signature of a wrap seam: every
|
||||
/// coordinate is (near) integer apart AND at least one spans more than half a tile
|
||||
/// (u = 0 vs u = 1). Two *different* charts (side band vs cap disc) have a non-integer
|
||||
/// offset on at least one coordinate and are NOT seams — their duplicates may still
|
||||
/// decimate independently (the rim is not frozen).
|
||||
fn uv_offset_is_seam(uvs: Option<&[[f32; 2]]>, a: u32, b: u32) -> bool {
|
||||
let Some(uvs) = uvs else { return false };
|
||||
let (ua, ub) = (uvs[a as usize], uvs[b as usize]);
|
||||
let mut seam = false;
|
||||
for k in 0..2 {
|
||||
let d = (ua[k] - ub[k]).abs();
|
||||
if d > 0.5 {
|
||||
seam = true;
|
||||
}
|
||||
if (d - d.round()).abs() > 0.05 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
seam
|
||||
}
|
||||
|
||||
impl Collapse {
|
||||
fn new(
|
||||
pos: Vec<[f32; 3]>,
|
||||
@@ -855,8 +917,18 @@ impl Collapse {
|
||||
uvs: Option<Vec<[f32; 2]>>,
|
||||
colors: Option<Vec<[f32; 4]>>,
|
||||
faces: Vec<[u32; 3]>,
|
||||
seam_pairs: &[(u32, u32)],
|
||||
) -> Self {
|
||||
let n = pos.len();
|
||||
// Slit twins: among the weld's refused pairs, keep the true wrap seams (see
|
||||
// `uv_offset_is_seam`) — those vertices are frozen for the whole decimation.
|
||||
let mut twin: Vec<Option<u32>> = vec![None; n];
|
||||
for &(a, b) in seam_pairs {
|
||||
if uv_offset_is_seam(uvs.as_deref(), a, b) {
|
||||
twin[a as usize] = Some(b);
|
||||
twin[b as usize] = Some(a);
|
||||
}
|
||||
}
|
||||
let mut c = Self {
|
||||
pos,
|
||||
normals,
|
||||
@@ -868,6 +940,7 @@ impl Collapse {
|
||||
edge_faces: std::collections::HashMap::new(),
|
||||
quad: vec![Mat4::ZERO; n],
|
||||
face_count: faces.len() as u32,
|
||||
twin,
|
||||
};
|
||||
for (i, f) in c.faces.iter().enumerate() {
|
||||
let f = f.unwrap();
|
||||
@@ -995,6 +1068,9 @@ impl Collapse {
|
||||
if !self.active[a as usize] || !self.active[b as usize] {
|
||||
continue;
|
||||
}
|
||||
if self.twin[a as usize].is_some() || self.twin[b as usize].is_some() {
|
||||
continue; // slit twin: frozen — never moves, never dies
|
||||
}
|
||||
if strict && !live.iter().all(|&f| self.face_fully_interior(f)) {
|
||||
continue;
|
||||
}
|
||||
@@ -1083,10 +1159,10 @@ impl Collapse {
|
||||
if pair.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
if let Some(tri) = self.faces[f as usize] {
|
||||
if !seen.insert(tri_sorted(tri)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(tri) = self.faces[f as usize]
|
||||
&& !seen.insert(tri_sorted(tri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for &f in &self.vfaces[s as usize] {
|
||||
@@ -1097,9 +1173,9 @@ impl Collapse {
|
||||
continue; // degenerate (contains s AND t) → dropped, allowed
|
||||
}
|
||||
let mut r = tri;
|
||||
for i in 0..3 {
|
||||
if r[i] == s {
|
||||
r[i] = t;
|
||||
for ri in r.iter_mut() {
|
||||
if *ri == s {
|
||||
*ri = t;
|
||||
}
|
||||
}
|
||||
if r[0] == r[1] || r[1] == r[2] || r[0] == r[2] {
|
||||
@@ -1132,18 +1208,24 @@ impl Collapse {
|
||||
// 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/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.
|
||||
// surface (they coarsen smoothly across levels instead of jumping).
|
||||
//
|
||||
// UVs are blended **linearly** — no fold, no wrap: a UV *seam* (two copies of the
|
||||
// same point on integer-apart UVs) can never be collapsed, because seam twins are
|
||||
// frozen (see `twin`) — so no collapse ever crosses a seam, and the fold that used
|
||||
// to guard it was never needed. What the fold DID break: a co-facial edge spanning
|
||||
// a full tile (the cone's apex v = 1 ↔ base v = 0 edges) is a *legit* chart span —
|
||||
// the chart is bilinear, so the linear blend is exactly the chart value at the
|
||||
// new position; folding the integer jump to zero would have kept the apex UV on a
|
||||
// base vertex (the visible stripe band, user-reported 2026-09-23).
|
||||
if let Some(uvs) = &mut self.uvs {
|
||||
let us = uvs[s as usize];
|
||||
let ut = uvs[t as usize];
|
||||
if (us[0] - ut[0]).abs() <= 0.5 && (us[1] - ut[1]).abs() <= 0.5 {
|
||||
uvs[t as usize] = [us[0] + lam * (ut[0] - us[0]), us[1] + lam * (ut[1] - us[1])];
|
||||
let (us, ut) = (uvs[s as usize], uvs[t as usize]);
|
||||
for c in 0..2 {
|
||||
uvs[t as usize][c] = us[c] + lam * (ut[c] - us[c]);
|
||||
}
|
||||
}
|
||||
// Normals are always blended: the weld kept hard-edge vertices separate, so no
|
||||
// edge crosses a shading discontinuity.
|
||||
if let Some(colors) = &mut self.colors {
|
||||
let cs = colors[s as usize];
|
||||
let ct = colors[t as usize];
|
||||
@@ -1161,8 +1243,8 @@ impl Collapse {
|
||||
// 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;
|
||||
for mk in m.iter_mut() {
|
||||
*mk /= len;
|
||||
}
|
||||
normals[t as usize] = m;
|
||||
}
|
||||
@@ -1178,9 +1260,9 @@ impl Collapse {
|
||||
// Remap `s`'s other faces: s→t, edge bookkeeping updated, area-guarded.
|
||||
for (f, old, old_area) in remaps {
|
||||
let mut tri = old;
|
||||
for i in 0..3 {
|
||||
if tri[i] == s {
|
||||
tri[i] = t;
|
||||
for ti in tri.iter_mut() {
|
||||
if *ti == s {
|
||||
*ti = t;
|
||||
}
|
||||
}
|
||||
if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] {
|
||||
@@ -1682,6 +1764,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_cone_uv_follows_its_chart() {
|
||||
// Regression (user-reported artifact, 2026-09-23): the old all-or-nothing UV
|
||||
// guard rejected a blend whenever EITHER coordinate spanned more than half the
|
||||
// tile — the cone's apex→base edges span the full v range (v=1 → v=0), so every
|
||||
// moved survivor kept its stale UV and a vertical band of the wrong stripe showed
|
||||
// on the cone's side. UVs now blend linearly (the fold was removed — it was
|
||||
// freezing the apex UV onto base vertices); the cone has TWO charts (side fan +
|
||||
// cap disc) and two slit columns at u = 0 / u = 1, so each decimated vertex is
|
||||
// checked against BOTH analytical maps and must follow at least one.
|
||||
use crate::math::primitives;
|
||||
let cone = primitives::cone(1.0, 1.0, 16);
|
||||
let levels = cone.generate_lod_levels(3);
|
||||
let udist = |a: f32, b: f32| {
|
||||
let d = (a - b).abs();
|
||||
d.min(1.0 - d)
|
||||
};
|
||||
// L0 is the untouched source — check L1..L3. The cone is centered on the
|
||||
// origin: apex (0, +0.5, 0), base ring y = −0.5, radius 1.
|
||||
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, mut n) = (0.0f32, 0.0f32, 0u32);
|
||||
for (p, uv) in lvl.positions.iter().zip(uvs.iter()) {
|
||||
let r = (p[0] * p[0] + p[2] * p[2]).sqrt();
|
||||
let lam = (0.5 - p[1]) / 1.0; // 0 at the apex, 1 at the base
|
||||
let mut err: Option<f32> = None;
|
||||
// Side chart: the fan is bilinear between the apex (u = 0.5, v = 1) and
|
||||
// the base ring (u = θ/2π, v = 0); the side surface is r = λ, so a
|
||||
// vertex is on it iff |λ_y − r| is small (0.1: a collapsed base vertex
|
||||
// can drift a few % inward). The decimated UV must follow the SAME
|
||||
// bilinear map L0 follows.
|
||||
if (lam - r).abs() <= 0.1 {
|
||||
let theta = p[2].atan2(p[0]).rem_euclid(std::f32::consts::TAU);
|
||||
let u_a = (1.0 - lam) * 0.5 + lam * (theta / std::f32::consts::TAU);
|
||||
let v_a = 1.0 - lam;
|
||||
err = Some(udist(uv[0], u_a).max((uv[1] - v_a).abs()));
|
||||
}
|
||||
// Cap disc chart: bilinear 0.5 + 0.5·(x, z) on the y = −0.5 plane.
|
||||
// Cap vertices (and side base-ring vertices) live there; both filters
|
||||
// are generous and the vertex is checked against BOTH (min of errors).
|
||||
if (p[1] + 0.5).abs() <= 0.1 && r <= 1.1 {
|
||||
let e = udist(uv[0], 0.5 + 0.5 * p[0]).max((uv[1] - (0.5 + 0.5 * p[2])).abs());
|
||||
err = Some(match err {
|
||||
Some(x) => x.min(e),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(e) = err {
|
||||
max_e = max_e.max(e);
|
||||
sum_e += e;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
assert!(n > 0, "L{i}: no chart vertices to check");
|
||||
let mean_e = sum_e / n as f32;
|
||||
assert!(
|
||||
max_e < 0.12,
|
||||
"L{i}: max UV error {max_e:.4} vs both charts (stale-UV band?)"
|
||||
);
|
||||
assert!(mean_e < 0.04, "L{i}: mean UV error {mean_e:.4}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_moved_vertex_uv_is_interpolated() {
|
||||
// Flat quadric → singular → midpoint (λ = 0.5). Edge (0,1) collapses (s = 1,
|
||||
@@ -1733,11 +1878,56 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_no_uv_interpolation_across_seam() {
|
||||
// Flat fan, 3 faces. The radial edge (0,1) is the cheapest (shortest) and its
|
||||
// endpoints are more than half a UV tile apart (a seam) → the survivor's UV must
|
||||
// stay its OWN value: blending across a seam would smear the texture.
|
||||
let geo = Geometry::new(vec![
|
||||
fn decimated_uv_seam_kept_legit_span_blended() {
|
||||
// Two different rules, one integer-tile fold:
|
||||
// (a) a UV **seam** — two copies of the SAME point on integer-apart UVs (u = 0
|
||||
// vs u = 1) — folds to a zero blend: the twin vertices are *frozen* (they
|
||||
// never move or die, or the zero-width slit would open into a visible
|
||||
// crack) and their UV stays untouched;
|
||||
// (b) a **legit** edge spanning more than half the tile between two different
|
||||
// points still blends along the shortest (wrapped) path — the old
|
||||
// all-or-nothing guard rejected (b) and left stale UVs (the visible stripe
|
||||
// band on the cone, user-reported 2026-09-23).
|
||||
// (a) true seam: integer offset, exactly the same point. A unit square whose
|
||||
// bottom-left corner is split into the slit twins a (u = 0) and b (u = 1):
|
||||
// the interior edge (c, d) collapses (−2 faces → 1 left) while every edge
|
||||
// touching a or b is excluded — the twins must stay frozen and their UVs
|
||||
// untouched.
|
||||
let seam = Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0], // a: slit twin, u = 0
|
||||
[0.0, 0.0, 0.0], // b: slit twin (same point), u = 1
|
||||
[1.0, 0.0, 0.0], // c
|
||||
[1.0, 1.0, 0.0], // d
|
||||
[0.0, 1.0, 0.0], // e
|
||||
])
|
||||
.with_uvs(vec![
|
||||
[0.0, 0.5],
|
||||
[1.0, 0.5],
|
||||
[0.5, 0.0],
|
||||
[1.0, 1.0],
|
||||
[0.0, 1.0],
|
||||
])
|
||||
.with_indices(vec![0, 2, 3, 0, 3, 4, 1, 2, 3]);
|
||||
let out = seam.decimated(1);
|
||||
assert_eq!(
|
||||
out.num_triangles(),
|
||||
1,
|
||||
"the interior edge still collapses (the target is reached)"
|
||||
);
|
||||
let uvs = out.uvs.as_deref().expect("uvs");
|
||||
// The twin a is referenced by the surviving face → present, frozen:
|
||||
// exact position, exact UV (never blended, never moved).
|
||||
let a = out
|
||||
.positions
|
||||
.iter()
|
||||
.position(|p| p[0].abs() < 1e-9 && p[1].abs() < 1e-9)
|
||||
.expect("twin a");
|
||||
assert_eq!(uvs[a], [0.0, 0.5], "seam twin UV must stay untouched");
|
||||
|
||||
// (b) legit span: Δu = 0.6 between two different points → blend linearly. A
|
||||
// co-facial full-tile span (the cone's apex v = 1 ↔ base v = 0) is legit — the
|
||||
// chart is bilinear, so the linear blend is the exact chart value.
|
||||
let span = Geometry::new(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[2.0, 0.5, 0.0],
|
||||
@@ -1745,19 +1935,20 @@ mod tests {
|
||||
])
|
||||
.with_uvs(vec![[0.6, 0.5], [0.0, 0.0], [0.1, 0.0], [0.0, 0.1]])
|
||||
.with_indices(vec![0, 1, 2, 0, 2, 3, 0, 3, 1]);
|
||||
let out = geo.decimated(2);
|
||||
// Interior edge (0,1) collapses (−2 faces → 1 left): t = 0 moves to the midpoint,
|
||||
// but Δu = 0.6 > 0.5 → its UV is untouched.
|
||||
let out = span.decimated(2);
|
||||
assert_eq!(out.num_triangles(), 1);
|
||||
let moved = out
|
||||
.positions
|
||||
.iter()
|
||||
.position(|p| (p[0] - 0.5).abs() < 1e-6 && p[1].abs() < 1e-6)
|
||||
.expect("the survivor moved to the midpoint");
|
||||
assert_eq!(
|
||||
out.uvs.as_deref().unwrap()[moved],
|
||||
[0.6, 0.5],
|
||||
"no blending across a UV seam"
|
||||
let uv = out.uvs.as_deref().unwrap()[moved];
|
||||
// s = 1 (0, 0) dies, t = 0 (0.6, 0.5) survives, λ = 0.5: linear blend
|
||||
// u = 0 + 0.5·(0.6 − 0) = 0.3 — blended, not stale.
|
||||
assert!(
|
||||
(uv[0] - 0.3).abs() < 1e-4,
|
||||
"legit span must blend linearly (got {:?})",
|
||||
uv
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1823,7 +2014,12 @@ mod tests {
|
||||
])
|
||||
.with_indices(vec![0, 1, 2, 3, 4, 2]);
|
||||
let tris = seam.non_degenerate_triangles().unwrap();
|
||||
let (positions, _normals, uvs, _colors, faces) = seam.welded(&tris);
|
||||
let Welded {
|
||||
positions,
|
||||
uvs,
|
||||
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]]);
|
||||
@@ -1839,7 +2035,7 @@ mod tests {
|
||||
.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);
|
||||
let Welded { positions, .. } = same.welded(&tris);
|
||||
assert_eq!(
|
||||
positions.len(),
|
||||
3,
|
||||
@@ -1862,7 +2058,7 @@ mod tests {
|
||||
])
|
||||
.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);
|
||||
let Welded { positions, .. } = hard.welded(&tris);
|
||||
assert_eq!(positions.len(), 4, "the hard-edge duplicate stays separate");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user