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:
+126
-25
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user