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
+126 -25
View File
@@ -478,9 +478,12 @@ impl Geometry {
/// 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 welded group's UV/color is the value of
/// the **first vertex encountered** (documented trade-off: LOD sacrifices UV precision
/// at seams for the silhouette).
/// 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).
///
/// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a
/// 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).
let mut c = Collapse::new(wpos, wfaces);
let mut c = Collapse::new(wpos, wuvs, wcolors, wfaces);
let mut pq = c.rebuild_pq();
let mut rebuilds = 0u32;
while c.face_count > target_triangles {
@@ -532,7 +535,7 @@ impl Geometry {
continue;
}
// 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() {
pq.push(EdgeCost(true_cost, a, b));
continue;
@@ -553,17 +556,20 @@ impl Geometry {
}
let mut new_id = vec![u32::MAX; n];
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n);
let mut new_uvs = wuvs.is_some().then(Vec::new);
let mut new_colors = wcolors.is_some().then(Vec::new);
// 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());
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(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 {
colors.push(wcolors.as_ref().unwrap()[i]);
colors.push(ccolors.as_ref().unwrap()[i]);
}
}
}
@@ -663,6 +669,13 @@ impl Geometry {
struct Collapse {
/// Mutable positions: a collapse target moves to its optimal point.
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.
active: Vec<bool>,
/// 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 {
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 mut c = Self {
pos,
uvs,
colors,
active: vec![true; n],
faces: faces.iter().map(|f| Some(*f)).collect(),
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):
/// quadric error at the optimum + edge length (the length term orders flat regions
/// deterministically, shortest edge first). The unconstrained optimum can lie far off
/// the edge on curved surfaces, so it is **clamped to the segment** — the result then
/// stays inside the input's bounding box.
fn cost_and_point(&self, a: u32, b: u32) -> (f32, [f32; 3]) {
/// Collapse cost of edge (a, b), the **optimal point** (where the target moves) and
/// the **λ of that point on the segment**: quadric error at the optimum + edge length
/// (the length term orders flat regions deterministically, shortest edge first). The
/// unconstrained optimum can lie far off the edge on curved surfaces, so it is
/// **clamped to the segment** (λ ∈ [0,1]) — the result then stays inside the input's
/// 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 pa = self.pos[a 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 qv = Vec4::new(pt[0], pt[1], pt[2], 1.0);
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
@@ -869,7 +890,7 @@ impl Collapse {
if !self.active[a as usize] || !self.active[b as usize] {
continue;
}
let (cost, _) = self.cost_and_point(a, b);
let (cost, _, _) = self.cost_and_point(a, b);
pq.push(EdgeCost(cost, a, b));
}
pq
@@ -972,8 +993,28 @@ impl Collapse {
// 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
// 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;
// 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];
self.quad[t as usize] += qs;
self.active[s as usize] = false;
@@ -1024,7 +1065,7 @@ impl Collapse {
for j in (i + 1)..3 {
let k = Self::edge_key(tri[i], tri[j]);
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]));
}
}
@@ -1424,10 +1465,20 @@ mod tests {
}
#[test]
fn decimated_uv_target_vertex_keeps_its_own() {
// A collapse target keeps its own UV; the source's UV is dropped. Flat quadric
// (z = 0 plane through the origin) → singular → the optimal point is the midpoint.
let geo = quad(); // 4 vertices, 2 faces
fn decimated_moved_vertex_uv_is_interpolated() {
// Flat quadric → singular → midpoint (λ = 0.5). Edge (0,1) collapses (s = 1,
// t = 0) and t's UV becomes the λ-blend of the pair — (0,0) and (0.2,0) →
// (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);
assert_eq!(out.num_triangles(), 1);
assert_eq!(out.positions.len(), 3);
@@ -1438,7 +1489,57 @@ mod tests {
);
assert_eq!(
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"
);
}