diff --git a/docs/DRAFT.md b/docs/DRAFT.md index e31502f..9c23ec7 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -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 é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 = - valeur du premier vertex de chaque groupe soudé (documenté : évite le bleeding entre seams UV ; - le LOD sacrifie la précision UV au profit de la silhouette). + 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). - 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) → diff --git a/docs/tech/ARCHI_CPU_GPU.md b/docs/tech/ARCHI_CPU_GPU.md index 01c3708..9979c3c 100644 --- a/docs/tech/ARCHI_CPU_GPU.md +++ b/docs/tech/ARCHI_CPU_GPU.md @@ -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 > 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, 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 : > 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` diff --git a/docs/user/gpu-driven.md b/docs/user/gpu-driven.md index 5498c0b..a686c96 100644 --- a/docs/user/gpu-driven.md +++ b/docs/user/gpu-driven.md @@ -98,7 +98,10 @@ CPU): 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), 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. 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 diff --git a/lib/src/math/geometry.rs b/lib/src/math/geometry.rs index 95d1742..deb5ad6 100644 --- a/lib/src/math/geometry.rs +++ b/lib/src/math/geometry.rs @@ -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>, + /// Colors, same treatment as UVs but with no seam guard (color space is continuous). + colors: Option>, /// Vertices not yet merged away. active: Vec, /// 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>, + colors: Option>, + 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" ); }