From 92c6a5cc752701f1182bb7c8ed997f29155523bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20B=C3=B6l=C3=B6ny?= Date: Sat, 8 Nov 2025 12:31:35 +0100 Subject: [PATCH 1/4] simplify: Attempt to clarify error behavior a little bit --- src/simplify.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simplify.rs b/src/simplify.rs index 9202d47..60c9e39 100644 --- a/src/simplify.rs +++ b/src/simplify.rs @@ -1268,7 +1268,7 @@ bitflags! { /// # Arguments /// /// * `destination`: must contain enough space for the target index buffer, worst case is `indices.len()` elements (**not** `target_index_count`)! -/// * `target_error`: represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation +/// * `target_error`: represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation; value range [0..1] /// * `result_error`: can be None; when it's not None, it will contain the resulting (relative) error after simplification pub fn simplify( destination: &mut [u32], @@ -1472,7 +1472,7 @@ where /// # Arguments /// /// * `destination`: must contain enough space for the target index buffer, worst case is `indices.len()` elements (**not** `target_index_count`)! -/// * `target_error`: represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation +/// * `target_error`: represents the error relative to mesh extents that can be tolerated, e.g. 0.01 = 1% deformation; value range [0..1] /// * `result_error`: can be None; when it's not None, it will contain the resulting (relative) error after simplification #[cfg(feature = "experimental")] pub fn simplify_sloppy( From 3a3d3c1b8a5a40f1923b2f25a5ba87304f4d285a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20B=C3=B6l=C3=B6ny?= Date: Sat, 8 Nov 2025 13:18:40 +0100 Subject: [PATCH 2/4] clusterizer: Improve selection critera of first triangle of new meshlet --- examples/demo.rs | 2 +- src/cluster.rs | 182 +++++++++++++++++++++++++++++--------------- src/vertex/cache.rs | 24 +++--- 3 files changed, 133 insertions(+), 75 deletions(-) diff --git a/examples/demo.rs b/examples/demo.rs index 277859b..320e41f 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -1318,7 +1318,7 @@ fn process(mesh: &Mesh) { } fn process_dev(mesh: &Mesh) { - tessellation_adjacency(mesh); + meshlets(mesh, false); } fn main() -> Result<(), Box> { diff --git a/src/cluster.rs b/src/cluster.rs index 939ac47..52504fa 100644 --- a/src/cluster.rs +++ b/src/cluster.rs @@ -549,6 +549,86 @@ pub fn build_meshlets_scan( meshlet_offset } +#[allow(clippy::too_many_arguments)] +fn get_neighbor_triangle( + meshlet: &Meshlet, + meshlet_cone: Option<&Cone>, + meshlet_vertices: &mut [u32], + indices: &[u32], + adjacency: &TriangleAdjacency, + triangles: &[Cone], + live_triangles: &[u32], + used: &[u8], + meshlet_expected_radius: f32, + cone_weight: f32, +) -> (Option, usize) { + let mut best_triangle = None; + let mut best_extra = 5; + let mut best_score = f32::MAX; + + for i in 0..meshlet.vertex_count { + let index = meshlet_vertices[meshlet.vertex_offset as usize + i as usize] as usize; + + let neighbors_size = adjacency.counts[index] as usize; + let neighbouts_offset = adjacency.offsets[index] as usize; + let neighbors = &adjacency.data[neighbouts_offset..neighbouts_offset + neighbors_size]; + + for triangle in neighbors { + let triangle = *triangle as usize; + + let [a, b, c] = &indices[triangle * 3..triangle * 3 + 3].try_into().unwrap(); + + let a = *a as usize; + let b = *b as usize; + let c = *c as usize; + + let mut extra = [used[a], used[b], used[c]].iter().filter(|v| **v == UNUSED).count(); + + // triangles that don't add new vertices to meshlets are max. priority + if extra != 0 { + // artificially increase the priority of dangling triangles as they're expensive to add to new meshlets + if live_triangles[a] == 1 || live_triangles[b] == 1 || live_triangles[c] == 1 { + extra = 0; + } + + extra += 1; + } + + // since topology-based priority is always more important than the score, we can skip scoring in some cases + if extra > best_extra { + continue; + } + + // caller selects one of two scoring functions: geometrical (based on meshlet cone) or topological (based on remaining triangles) + let score = if let Some(meshlet_cone) = meshlet_cone { + let tri_cone = &triangles[triangle]; + + let distance2 = (tri_cone.px - meshlet_cone.px) * (tri_cone.px - meshlet_cone.px) + + (tri_cone.py - meshlet_cone.py) * (tri_cone.py - meshlet_cone.py) + + (tri_cone.pz - meshlet_cone.pz) * (tri_cone.pz - meshlet_cone.pz); + + let spread = + tri_cone.nx * meshlet_cone.nx + tri_cone.ny * meshlet_cone.ny + tri_cone.nz * meshlet_cone.nz; + + get_meshlet_score(distance2, spread, cone_weight, meshlet_expected_radius) + } else { + // each live_triangles entry is >= 1 since it includes the current triangle we're processing + (live_triangles[a] + live_triangles[b] + live_triangles[c] - 3) as f32 + }; + + // note that topology-based priority is always more important than the score + // this helps maintain reasonable effectiveness of meshlet data and reduces scoring cost + if extra < best_extra || score < best_score { + best_triangle = Some(triangle); + best_extra = extra; + best_score = score; + } + } + } + + (best_triangle, best_extra) +} + /// Splits the mesh into a set of meshlets where each meshlet has a micro index buffer indexing into meshlet vertices that refer to the original vertex buffer. /// /// The resulting data can be used to render meshes using NVidia programmable mesh shading pipeline, or in other cluster-based renderers. @@ -581,6 +661,8 @@ where assert!((1..=MESHLET_MAX_TRIANGLES).contains(&max_triangles)); assert!(max_triangles.is_multiple_of(4)); // ensures the caller will compute output space properly as index data is 4b aligned + assert!((0.0..=1.0).contains(&cone_weight)); + let mut adjacency = TriangleAdjacency::default(); build_triangle_adjacency(&mut adjacency, indices, vertices.len()); @@ -617,66 +699,42 @@ where let mut meshlet_cone_acc = Cone::default(); loop { - let mut best_triangle = None; - let mut best_extra = 5; - let mut best_score = f32::MAX; - let meshlet_cone = get_meshlet_cone(&meshlet_cone_acc, meshlet.triangle_count); - for i in 0..meshlet.vertex_count { - let index = meshlet_vertices[meshlet.vertex_offset as usize + i as usize] as usize; - - let neighbours_size = adjacency.counts[index] as usize; - let neighbouts_offset = adjacency.offsets[index] as usize; - let neighbours = &adjacency.data[neighbouts_offset..neighbouts_offset + neighbours_size]; - - for triangle in neighbours { - let triangle = *triangle as usize; - assert!(!emitted_flags[triangle]); - - let a = indices[triangle * 3 + 0] as usize; - let b = indices[triangle * 3 + 1] as usize; - let c = indices[triangle * 3 + 2] as usize; - assert!(a < vertices.len() && b < vertices.len() && c < vertices.len()); - - let mut extra = [used[a], used[b], used[c]].iter().filter(|v| **v == UNUSED).count(); - - // triangles that don't add new vertices to meshlets are max. priority - if extra != 0 { - // artificially increase the priority of dangling triangles as they're expensive to add to new meshlets - if live_triangles[a] == 1 || live_triangles[b] == 1 || live_triangles[c] == 1 { - extra = 0; - } - - extra += 1; - } - - // since topology-based priority is always more important than the score, we can skip scoring in some cases - if extra > best_extra { - continue; - } - - let tri_cone = &triangles[triangle]; - - let distance2 = (tri_cone.px - meshlet_cone.px) * (tri_cone.px - meshlet_cone.px) - + (tri_cone.py - meshlet_cone.py) * (tri_cone.py - meshlet_cone.py) - + (tri_cone.pz - meshlet_cone.pz) * (tri_cone.pz - meshlet_cone.pz); - - let spread = - tri_cone.nx * meshlet_cone.nx + tri_cone.ny * meshlet_cone.ny + tri_cone.nz * meshlet_cone.nz; - - let score = get_meshlet_score(distance2, spread, cone_weight, meshlet_expected_radius); - - // note that topology-based priority is always more important than the score - // this helps maintain reasonable effectiveness of meshlet data and reduces scoring cost - if extra < best_extra || score < best_score { - best_triangle = Some(triangle); - best_extra = extra; - best_score = score; - } - } + let (mut best_triangle, best_extra) = get_neighbor_triangle( + &meshlet, + Some(&meshlet_cone), + meshlet_vertices, + indices, + &adjacency, + &triangles, + &live_triangles, + &used, + meshlet_expected_radius, + cone_weight, + ); + + // if the best triangle doesn't fit into current meshlet, the spatial scoring we've used is not very meaningful, so we re-select using topological scoring + if best_triangle.is_some() + && ((meshlet.vertex_count + best_extra as u32) as usize > max_vertices + || meshlet.triangle_count as usize >= max_triangles) + { + best_triangle = get_neighbor_triangle( + &meshlet, + None, + meshlet_vertices, + indices, + &adjacency, + &triangles, + &live_triangles, + &used, + meshlet_expected_radius, + 0.0, + ) + .0; } + // when we run out of neighboring triangles we need to switch to spatial search; we currently just pick the closest triangle irrespective of connectivity if best_triangle.is_none() { let position = [meshlet_cone.px, meshlet_cone.py, meshlet_cone.pz]; let mut index = !0u32; @@ -719,15 +777,15 @@ where for index in abc { let index = index as usize; - let neighbours_offset = adjacency.offsets[index] as usize; - let neighbours_size = adjacency.counts[index] as usize; - let neighbours = &mut adjacency.data[neighbours_offset..neighbours_offset + neighbours_size]; + let neighbors_offset = adjacency.offsets[index] as usize; + let neighbors_size = adjacency.counts[index] as usize; + let neighbors = &mut adjacency.data[neighbors_offset..neighbors_offset + neighbors_size]; - for i in 0..neighbours_size { - let tri = neighbours[i] as usize; + for i in 0..neighbors_size { + let tri = neighbors[i] as usize; if tri == best_triangle { - neighbours[i] = neighbours[neighbours_size - 1]; + neighbors[i] = neighbors[neighbors_size - 1]; adjacency.counts[index] -= 1; break; } diff --git a/src/vertex/cache.rs b/src/vertex/cache.rs index 2040169..d64cba8 100644 --- a/src/vertex/cache.rs +++ b/src/vertex/cache.rs @@ -150,7 +150,7 @@ fn get_next_vertex_dead_end( u32::MAX } -fn get_next_vertex_neighbour( +fn get_next_vertex_neighbor( next_candidates: &[u32], live_triangles: &[u32], cache_timestamps: &[u32], @@ -302,14 +302,14 @@ fn optimize_vertex_cache_table( for index in abc { let index = *index as usize; - let neighbours = &mut adjacency.data[adjacency.offsets[index] as usize..]; - let neighbours_size = adjacency.counts[index] as usize; + let neighbors = &mut adjacency.data[adjacency.offsets[index] as usize..]; + let neighbors_size = adjacency.counts[index] as usize; - for i in 0..neighbours_size { - let tri = neighbours[i]; + for i in 0..neighbors_size { + let tri = neighbors[i]; if tri == current_triangle { - neighbours[i] = neighbours[neighbours_size - 1]; + neighbors[i] = neighbors[neighbors_size - 1]; adjacency.counts[index] -= 1; break; } @@ -331,9 +331,9 @@ fn optimize_vertex_cache_table( // update scores of vertex triangles let off = adjacency.offsets[index] as usize; - let neighbours = &adjacency.data[off..off + adjacency.counts[index] as usize]; + let neighbors = &adjacency.data[off..off + adjacency.counts[index] as usize]; - for tri in neighbours { + for tri in neighbors { assert!(!emitted_flags[*tri as usize]); let tri_score = triangle_scores[*tri as usize] + score_diff; @@ -430,12 +430,12 @@ pub fn optimize_vertex_cache_fifo(destination: &mut [u32], indices: &[u32], vert while current_vertex != INVALID_INDEX { let next_candidates_begin = dead_end_top; - // emit all vertex neighbours + // emit all vertex neighbors let o = adjacency.offsets[current_vertex as usize] as usize; let c = adjacency.counts[current_vertex as usize] as usize; - let neighbours = &adjacency.data[o..o + c]; + let neighbors = &adjacency.data[o..o + c]; - for triangle in neighbours { + for triangle in neighbors { let triangle = *triangle as usize; if !emitted_flags[triangle] { @@ -472,7 +472,7 @@ pub fn optimize_vertex_cache_fifo(destination: &mut [u32], indices: &[u32], vert let next_candidates = &dead_end[next_candidates_begin..dead_end_top]; // get next vertex - current_vertex = get_next_vertex_neighbour( + current_vertex = get_next_vertex_neighbor( next_candidates, &live_triangles, &cache_timestamps, From 776edb679b304b218d522b3787f2d6b8469826e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20B=C3=B6l=C3=B6ny?= Date: Sat, 8 Nov 2025 13:19:35 +0100 Subject: [PATCH 3/4] Add a reference for apex-less formula derivation --- src/cluster.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cluster.rs b/src/cluster.rs index 52504fa..c2e16be 100644 --- a/src/cluster.rs +++ b/src/cluster.rs @@ -840,7 +840,8 @@ where /// ``` /// /// The formula that uses the apex is slightly more accurate but needs the apex; if you are already using bounding sphere -/// to do frustum/occlusion culling, the formula that doesn't use the apex may be preferable. +/// to do frustum/occlusion culling, the formula that doesn't use the apex may be preferable (for derivation see +/// Real-Time Rendering 4th Edition, section 19.3). /// /// # Arguments /// From 8e87d2bf8d53b144cb202d0b6e16d3ecf82f8f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20B=C3=B6l=C3=B6ny?= Date: Sat, 8 Nov 2025 14:17:13 +0100 Subject: [PATCH 4/4] Improve and optimize exponential filter encoder --- src/vertex/filter.rs | 150 +++++++++++++++++++++++++++++++++---------- 1 file changed, 115 insertions(+), 35 deletions(-) diff --git a/src/vertex/filter.rs b/src/vertex/filter.rs index 51031d3..9b2daf5 100644 --- a/src/vertex/filter.rs +++ b/src/vertex/filter.rs @@ -216,50 +216,88 @@ pub fn encode_filter_quat<'a>( } } -fn frexp(s: f32) -> (f32, i32) { - if 0.0 == s { - (s, 0) - } else { - let lg = s.abs().log2(); - let x = (lg.fract() - 1.).exp2(); - let exp = lg.floor() + 1.0; - (s.signum() * x, exp as i32) - } +// optimized variant of frexp +fn optlog2(v: f32) -> i32 { + let u = v.to_bits(); + // +1 accounts for implicit 1. in mantissa; denormalized numbers will end up clamped to min_exp by calling code + if u == 0 { 0 } else { ((u >> 23) & 0xff) as i32 - 127 + 1 } +} + +// optimized variant of ldexp +fn optexp2(e: i32) -> f32 { + let u = ((e + 127) as u32) << 23; + f32::from_bits(u) } -fn ldexp(a: f32, exp: i32) -> f32 { - let f = exp as f32; - a * f.exp2() +#[derive(Debug, PartialEq, Eq)] +pub enum EncodeExpMode { + /// When encoding exponents, use separate values for each component (maximum quality) + Separate, + /// When encoding exponents, use shared value for all components of each vector (better compression) + SharedVector, + /// When encoding exponents, use shared value for each component of all vectors (best compression) + SharedComponent, } /// Encodes arbitrary (finite) floating-point data with 8-bit exponent and K-bit integer mantissa (1 <= K <= 24). /// -/// Mantissa is shared between all components of a given vector as defined by `N`. -/// When individual (scalar) encoding is desired, simply pass N=1 and adjust iterators accordingly ([core::array::from_ref], [core::array::from_mut]). +/// Exponent can be shared between all components of a given vector as defined by `N` or all values of a given component. pub fn encode_filter_exp<'a, const N: usize>( destination: impl Iterator, bits: u32, - data: impl Iterator, + data: impl Iterator + Clone, + mode: EncodeExpMode, ) { assert!((1..=24).contains(&bits)); + assert!((1..=64).contains(&N)); + + const MIN_EXP: i32 = -100; + let mut component_exp = [MIN_EXP; N]; + + if mode == EncodeExpMode::SharedComponent { + for v in data.clone() { + // use maximum exponent to encode values; this guarantees that mantissa is [-1, 1] + for (c, ce) in v.iter().zip(component_exp.iter_mut()) { + let e = optlog2(*c); + *ce = (*ce).max(e); + } + } + } for (v, d) in data.zip(destination) { - // use maximum exponent to encode values; this guarantees that mantissa is [-1, 1] - let mut exp = -100; - - for c in v { - let (_, e) = frexp(*c); - exp = exp.max(e); + let mut vector_exp = MIN_EXP; + + match mode { + EncodeExpMode::SharedVector => { + // use maximum exponent to encode values; this guarantees that mantissa is [-1, 1] + for c in v { + let e = optlog2(*c); + vector_exp = vector_exp.max(e); + } + } + EncodeExpMode::Separate => { + for (c, ce) in v.iter().zip(component_exp.iter_mut()) { + let e = optlog2(*c); + *ce = MIN_EXP.max(e); + } + } + _ => {} } - // note that we additionally scale the mantissa to make it a K-bit signed integer (K-1 bits for magnitude) - exp -= (bits - 1) as i32; + for ((vc, dc), ce) in v.iter().zip(d.iter_mut()).zip(component_exp) { + let mut exp = if mode == EncodeExpMode::SharedVector { + vector_exp + } else { + ce + }; + + // note that we additionally scale the mantissa to make it a K-bit signed integer (K-1 bits for magnitude) + exp -= bits as i32 - 1; - // compute renormalized rounded mantissa for each component - let mmask = (1 << 24) - 1; + // compute renormalized rounded mantissa for each component + let mmask = (1 << 24) - 1; - for (vc, dc) in v.iter().zip(d.iter_mut()) { - let m = (ldexp(*vc, -exp) + if *vc >= 0.0 { 0.5 } else { -0.5 }) as i32; + let m = (vc * optexp2(-exp) + if *vc >= 0.0 { 0.5 } else { -0.5 }) as i32; *dc = ((m & mmask) as u32) | ((exp as u32) << 24); } @@ -469,19 +507,61 @@ mod test { #[test] fn test_encode_filter_exp() { - const DATA: [[f32; 3]; 1] = [[1.0, -23.4, -0.1]]; - const EXPECTED: [[u32; 3]; 1] = [[0xf7000200, 0xf7ffd133, 0xf7ffffcd]]; + const DATA: [[f32; 2]; 2] = [[1.0, -23.4], [-0.1, 11.0]]; + + // separate exponents: each component gets its own value + const EXPECTED1: [[u32; 2]; 2] = [[0xf3002000, 0xf7ffd133], [0xefffcccd, 0xf6002c00]]; + + // shared exponents (vector): all components of each vector get the same value + const EXPECTED2: [[u32; 2]; 2] = [[0xf7000200, 0xf7ffd133], [0xf6ffff9a, 0xf6002c00]]; + + // shared exponents (component): each component gets the same value across all vectors + const EXPECTED3: [[u32; 2]; 2] = [[0xf3002000, 0xf7ffd133], [0xf3fffccd, 0xf7001600]]; + + let mut encoded1 = [[0u32; 2]; 2]; + encode_filter_exp::<2>(encoded1.iter_mut(), 15, DATA.iter(), EncodeExpMode::Separate); + assert_eq!(encoded1, EXPECTED1); - let mut encoded = [[0u32; 3]; 1]; - encode_filter_exp::<3>(encoded.iter_mut(), 15, DATA.iter()); + let mut encoded2 = [[0u32; 2]; 2]; + encode_filter_exp::<2>(encoded2.iter_mut(), 15, DATA.iter(), EncodeExpMode::SharedVector); + assert_eq!(encoded2, EXPECTED2); + + let mut encoded3 = [[0u32; 2]; 2]; + encode_filter_exp::<2>(encoded3.iter_mut(), 15, DATA.iter(), EncodeExpMode::SharedComponent); + assert_eq!(encoded3, EXPECTED3); + + let mut decoded1 = encoded1.clone(); + decode_filter_exp(decoded1.iter_mut().flatten()); + + let mut decoded2 = encoded2.clone(); + decode_filter_exp(decoded2.iter_mut().flatten()); + + let mut decoded3 = encoded3.clone(); + decode_filter_exp(decoded3.iter_mut().flatten()); + + for (i, data) in DATA[0].iter().enumerate() { + assert!((f32::from_bits(decoded1[0][i]) - *data).abs() < 1e-3); + assert!((f32::from_bits(decoded2[0][i]) - *data).abs() < 1e-3); + assert!((f32::from_bits(decoded3[0][i]) - *data).abs() < 1e-3); + } + } + + #[test] + fn test_encode_filter_exp_zero() { + const DATA: [[f32; 1]; 1] = [[0.0]]; + const EXPECTED: [[u32; 1]; 1] = [[0xf2000000]]; + + let mut encoded = [[0u32; 1]; 1]; + encode_filter_exp::<1>(encoded.iter_mut(), 15, DATA.iter(), EncodeExpMode::Separate); assert_eq!(encoded, EXPECTED); - let mut decoded = encoded.clone(); + let mut decoded = encoded; decode_filter_exp(decoded.iter_mut().flatten()); - for (dat, dec) in DATA.iter().flatten().zip(decoded.iter().flatten()) { - assert!((f32::from_bits(*dec) - *dat).abs() < 1e-3); - } + assert_eq!( + decoded.iter().flatten().map(|d| f32::from_bits(*d)).collect::>(), + DATA.iter().flatten().copied().collect::>(), + ); } }