LOD: interpoler UVs/couleurs des vertices déplacés par le repli

Le vertex-cible d'un repli se déplace au point optimal de l'arête mais
conserveait l'UV du weld — désaccord position/UV croissant en cascade :
la texture 'fuit' et les motifs (rayures) disparaissent aux niveaux
lointains, avec un changement radical entre deux LOD.

- Collapse porte désormais les tables uvs/colors (clonées au weld).
- collapse_edge interpole les UVs de la cible : uv_t ← (1−λ)·uv_s + λ·uv_t,
  avec le même λ que le déplacement (cost_and_point renvoie désormais λ).
- Garde-fou seam : si |Δu| > 0.5 ou |Δv| > 0.5 (saut de texture), la cible
  garde son UV — l'interpolation ne traverse jamais une seam.
- Couleurs : toujours interpolées (espace colorimétrique continu).
- Compaction : la sortie lit les tables mises à jour (c.uvs/c.colors), pas
  les tables d'origine du weld.
- Docs : DRAFT.md (Sortie), gpu-driven.md (décimination), ARCHI_CPU_GPU (LOD).
This commit is contained in:
Jérôme Bousquié
2026-09-23 12:21:32 +02:00
parent a3a7ff4a6b
commit eac266dd86
4 changed files with 138 additions and 30 deletions
+6 -3
View File
@@ -134,9 +134,12 @@ Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU)
est clampée à `[1, T]` et atteinte au mieux (best effort : la granularité −2/−1 peut s'en 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). é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 - 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 = 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 vertex **déplacé** par un repli reçoit l'**interpolation** des UVs/couleurs de la paire
le LOD sacrifie la précision UV au profit de la silhouette). (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).
- Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**). - Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**).
Triangles dégénérés jetés. Triangles dégénérés jetés.
- **Déterministe** (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) → - **Déterministe** (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) →
+2 -1
View File
@@ -43,7 +43,8 @@ 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 > 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 > 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 > 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, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en > 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
> unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 : > 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 > 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` > bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
+4 -1
View File
@@ -98,7 +98,10 @@ CPU):
cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the 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 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 "books"), a boundary collapse removes one face; duplicate corners are welded (relative
tolerance 1e-6), and the levels are **packed into the mesh's single 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
levels are **packed into the mesh's single
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry. 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 2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen
pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going
+126 -25
View File
@@ -478,9 +478,12 @@ impl Geometry {
/// topological guarantee that simple triangle removal can never give. Collapses that /// 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 create a non-manifold ("book") or a duplicate face are skipped; faces that
/// would degenerate are dropped. Smooth normals are **recomputed** over the surviving /// would degenerate are dropped. Smooth normals are **recomputed** over the surviving
/// faces (only when the source had normals); a welded group's UV/color is the value of /// faces (only when the source had normals). A survivor **moved** by a collapse gets
/// the **first vertex encountered** (documented trade-off: LOD sacrifices UV precision /// its UV/color **re-interpolated** between the two collapsed endpoints at the same λ
/// at seams for the silhouette). /// 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).
/// ///
/// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a /// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a
/// result that would not fit u16 indices (≥ 65536 vertices) → `self.clone()`. Best /// result that would not fit u16 indices (≥ 65536 vertices) → `self.clone()`. Best
@@ -504,7 +507,7 @@ impl Geometry {
} }
// Collapse down to `target_triangles` faces (or as close as the topology allows). // Collapse down to `target_triangles` faces (or as close as the topology allows).
let mut c = Collapse::new(wpos, wfaces); let mut c = Collapse::new(wpos, wuvs, wcolors, wfaces);
let mut pq = c.rebuild_pq(); let mut pq = c.rebuild_pq();
let mut rebuilds = 0u32; let mut rebuilds = 0u32;
while c.face_count > target_triangles { while c.face_count > target_triangles {
@@ -532,7 +535,7 @@ impl Geometry {
continue; continue;
} }
// Cost drifted (quadrics/positions changed since the push) → refresh, retry. // Cost drifted (quadrics/positions changed since the push) → refresh, retry.
let (true_cost, _) = c.cost_and_point(a, b); let (true_cost, _, _) = c.cost_and_point(a, b);
if (true_cost - cost).abs() > 1e-9 + 1e-6 * true_cost.abs() { if (true_cost - cost).abs() > 1e-9 + 1e-6 * true_cost.abs() {
pq.push(EdgeCost(true_cost, a, b)); pq.push(EdgeCost(true_cost, a, b));
continue; continue;
@@ -553,17 +556,20 @@ impl Geometry {
} }
let mut new_id = vec![u32::MAX; n]; let mut new_id = vec![u32::MAX; n];
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n); let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n);
let mut new_uvs = wuvs.is_some().then(Vec::new); // The collapses moved survivors to optimal points and re-interpolated their
let mut new_colors = wcolors.is_some().then(Vec::new); // UVs/colors — these tables hold the post-collapse values.
let (cuvs, ccolors) = (c.uvs.take(), c.colors.take());
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 { for i in 0..n {
if c.active[i] && referenced[i] { if c.active[i] && referenced[i] {
new_id[i] = new_positions.len() as u32; new_id[i] = new_positions.len() as u32;
new_positions.push(c.pos[i]); new_positions.push(c.pos[i]);
if let Some(uvs) = &mut new_uvs { if let Some(uvs) = &mut new_uvs {
uvs.push(wuvs.as_ref().unwrap()[i]); uvs.push(cuvs.as_ref().unwrap()[i]);
} }
if let Some(colors) = &mut new_colors { if let Some(colors) = &mut new_colors {
colors.push(wcolors.as_ref().unwrap()[i]); colors.push(ccolors.as_ref().unwrap()[i]);
} }
} }
} }
@@ -663,6 +669,13 @@ impl Geometry {
struct Collapse { struct Collapse {
/// Mutable positions: a collapse target moves to its optimal point. /// Mutable positions: a collapse target moves to its optimal point.
pos: Vec<[f32; 3]>, pos: 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
/// no UVs.
uvs: Option<Vec<[f32; 2]>>,
/// Colors, same treatment as UVs but with no seam guard (color space is continuous).
colors: Option<Vec<[f32; 4]>>,
/// Vertices not yet merged away. /// Vertices not yet merged away.
active: Vec<bool>, active: Vec<bool>,
/// Living faces by (ever-allocated) id; `None` = collapsed away or degenerated. /// Living faces by (ever-allocated) id; `None` = collapsed away or degenerated.
@@ -739,10 +752,17 @@ fn tri_sorted(mut t: [u32; 3]) -> [u32; 3] {
} }
impl Collapse { impl Collapse {
fn new(pos: Vec<[f32; 3]>, faces: Vec<[u32; 3]>) -> Self { fn new(
pos: Vec<[f32; 3]>,
uvs: Option<Vec<[f32; 2]>>,
colors: Option<Vec<[f32; 4]>>,
faces: Vec<[u32; 3]>,
) -> Self {
let n = pos.len(); let n = pos.len();
let mut c = Self { let mut c = Self {
pos, pos,
uvs,
colors,
active: vec![true; n], active: vec![true; n],
faces: faces.iter().map(|f| Some(*f)).collect(), faces: faces.iter().map(|f| Some(*f)).collect(),
vfaces: vec![Vec::new(); n], vfaces: vec![Vec::new(); n],
@@ -814,12 +834,13 @@ impl Collapse {
) )
} }
/// Collapse cost of edge (a, b) and the **optimal point** (where the target moves): /// Collapse cost of edge (a, b), the **optimal point** (where the target moves) and
/// quadric error at the optimum + edge length (the length term orders flat regions /// the **λ of that point on the segment**: quadric error at the optimum + edge length
/// deterministically, shortest edge first). The unconstrained optimum can lie far off /// (the length term orders flat regions deterministically, shortest edge first). The
/// the edge on curved surfaces, so it is **clamped to the segment** — the result then /// unconstrained optimum can lie far off the edge on curved surfaces, so it is
/// stays inside the input's bounding box. /// **clamped to the segment** (λ ∈ [0,1]) — the result then stays inside the input's
fn cost_and_point(&self, a: u32, b: u32) -> (f32, [f32; 3]) { /// bounding box, and λ is exactly the weight of `b` in `pa + λ(pb − pa)`.
fn cost_and_point(&self, a: u32, b: u32) -> (f32, [f32; 3], f32) {
let q = self.quad[a as usize] + self.quad[b as usize]; let q = self.quad[a as usize] + self.quad[b as usize];
let pa = self.pos[a as usize]; let pa = self.pos[a as usize];
let pb = self.pos[b as usize]; let pb = self.pos[b as usize];
@@ -849,7 +870,7 @@ impl Collapse {
let pt = [pa[0] + s * e[0], pa[1] + s * e[1], pa[2] + s * e[2]]; let pt = [pa[0] + s * e[0], pa[1] + s * e[1], pa[2] + s * e[2]];
let qv = Vec4::new(pt[0], pt[1], pt[2], 1.0); let qv = Vec4::new(pt[0], pt[1], pt[2], 1.0);
let err = (q * qv).dot(qv); let err = (q * qv).dot(qv);
(err + elen2.sqrt(), pt) (err + elen2.sqrt(), pt, s)
} }
/// Full priority-queue rebuild: every edge whose endpoints are both active and that /// Full priority-queue rebuild: every edge whose endpoints are both active and that
@@ -869,7 +890,7 @@ impl Collapse {
if !self.active[a as usize] || !self.active[b as usize] { if !self.active[a as usize] || !self.active[b as usize] {
continue; continue;
} }
let (cost, _) = self.cost_and_point(a, b); let (cost, _, _) = self.cost_and_point(a, b);
pq.push(EdgeCost(cost, a, b)); pq.push(EdgeCost(cost, a, b));
} }
pq pq
@@ -972,8 +993,28 @@ impl Collapse {
// degenerate when s merges into t and are simply REMOVED (no new face is created: // degenerate when s merges into t and are simply REMOVED (no new face is created:
// the region folds and the neighbouring faces sweep over it). This keeps the mesh // the region folds and the neighbouring faces sweep over it). This keeps the mesh
// a closed 2-manifold — the property that makes the decimated result hole-free. // a closed 2-manifold — the property that makes the decimated result hole-free.
let (_cost, pt) = self.cost_and_point(s, t); let (_cost, pt, lam) = self.cost_and_point(s, t);
self.pos[t as usize] = pt; 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.
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])];
}
}
if let Some(colors) = &mut self.colors {
let cs = colors[s as usize];
let ct = colors[t as usize];
for k in 0..4 {
colors[t as usize][k] = cs[k] + lam * (ct[k] - cs[k]);
}
}
let qs = self.quad[s as usize]; let qs = self.quad[s as usize];
self.quad[t as usize] += qs; self.quad[t as usize] += qs;
self.active[s as usize] = false; self.active[s as usize] = false;
@@ -1024,7 +1065,7 @@ impl Collapse {
for j in (i + 1)..3 { for j in (i + 1)..3 {
let k = Self::edge_key(tri[i], tri[j]); let k = Self::edge_key(tri[i], tri[j]);
if pushed.insert(k) { if pushed.insert(k) {
let (cost, _) = self.cost_and_point(tri[i], tri[j]); let (cost, _, _) = self.cost_and_point(tri[i], tri[j]);
pq.push(EdgeCost(cost, tri[i], tri[j])); pq.push(EdgeCost(cost, tri[i], tri[j]));
} }
} }
@@ -1424,10 +1465,20 @@ mod tests {
} }
#[test] #[test]
fn decimated_uv_target_vertex_keeps_its_own() { fn decimated_moved_vertex_uv_is_interpolated() {
// A collapse target keeps its own UV; the source's UV is dropped. Flat quadric // Flat quadric → singular → midpoint (λ = 0.5). Edge (0,1) collapses (s = 1,
// (z = 0 plane through the origin) → singular → the optimal point is the midpoint. // t = 0) and t's UV becomes the λ-blend of the pair — (0,0) and (0.2,0) →
let geo = quad(); // 4 vertices, 2 faces // (0.1, 0) — so the texture stays attached to the moved position instead of
// jumping. (UVs are deliberately small-span: a full [0,1] wrap across an edge
// is indistinguishable from a seam and is intentionally NOT blended.)
let geo = Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
])
.with_uvs(vec![[0.0, 0.0], [0.2, 0.0], [0.2, 0.3], [0.0, 0.3]])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let out = geo.decimated(1); let out = geo.decimated(1);
assert_eq!(out.num_triangles(), 1); assert_eq!(out.num_triangles(), 1);
assert_eq!(out.positions.len(), 3); assert_eq!(out.positions.len(), 3);
@@ -1438,7 +1489,57 @@ mod tests {
); );
assert_eq!( assert_eq!(
out.uvs.as_deref(), out.uvs.as_deref(),
Some(&[[0.0, 0.0], [1.0, 1.0], [0.0, 1.0]][..]) Some(&[[0.1, 0.0], [0.2, 0.3], [0.0, 0.3]][..])
);
}
#[test]
fn decimated_moved_vertex_color_is_interpolated() {
// Color space has no seams: the survivor's color is always the λ-blend.
let geo = Geometry::new(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
])
.with_colors(vec![
[1.0, 0.0, 0.0, 1.0],
[0.0, 0.0, 0.0, 1.0],
[0.0, 0.0, 0.0, 1.0],
[0.0, 0.0, 0.0, 1.0],
])
.with_indices(vec![0, 1, 2, 0, 2, 3]);
let out = geo.decimated(1);
// t = 0 moves to the midpoint (λ = 0.5) → color = 0.5·black + 0.5·red.
assert_eq!(out.colors.as_deref().unwrap()[0], [0.5, 0.0, 0.0, 1.0]);
}
#[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![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 0.5, 0.0],
[0.5, 2.0, 0.0],
])
.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.
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"
); );
} }