diff --git a/crates/mjcf-rs/src/lib.rs b/crates/mjcf-rs/src/lib.rs index 3d1cf9d94..61788b792 100644 --- a/crates/mjcf-rs/src/lib.rs +++ b/crates/mjcf-rs/src/lib.rs @@ -45,6 +45,7 @@ mod error; pub mod extras; mod loader; pub mod model; +pub mod normals; pub mod types; #[cfg(feature = "msh")] diff --git a/crates/mjcf-rs/src/normals.rs b/crates/mjcf-rs/src/normals.rs new file mode 100644 index 000000000..9b5feed30 --- /dev/null +++ b/crates/mjcf-rs/src/normals.rs @@ -0,0 +1,188 @@ +//! Smooth, crease-aware per-vertex normal generation for visual meshes. +//! +//! Faceted mesh formats (notably STL, which most MuJoCo Menagerie robots +//! ship) store one normal per *triangle* with fully unshared vertices, so +//! the file normals — and any naive per-face recompute — can only ever +//! produce flat shading. [`smooth_vertex_normals`] regenerates normals the +//! way MuJoCo's compiler does: weld coincident vertices to recover the +//! shared-edge topology, then blend each corner from the area-weighted +//! normals of the incident faces, leaving sharp edges creased. + +use std::collections::HashMap; + +/// Dot threshold below which two adjacent faces form a crease and are *not* +/// blended into a shared normal: `cos(45°) = 1/√2`. Matches the crease angle +/// MuJoCo uses when it generates mesh normals. +const CREASE_COS: f64 = std::f64::consts::FRAC_1_SQRT_2; +/// Position quantization grid (1 µm) used to weld coincident vertices. STL +/// duplicates shared corners with bit-identical coordinates, so this is +/// exact in practice while also collapsing any near-coincident vertices. +const INV_EPS: f64 = 1.0e6; + +/// Compute per-vertex normals for a visual trimesh the way MuJoCo does: +/// weld coincident vertices to recover the shared-edge topology a faceted +/// source (STL) throws away, then blend each corner's normal from the +/// area-weighted normals of the incident faces. Adjacent faces whose +/// normals differ by more than the crease angle (45°) are *not* +/// blended, so box edges stay crisp while curved surfaces read as smooth. +/// When `smooth` is set (MJCF `smoothnormal="true"`) the crease threshold +/// is disabled and every shared face is blended. +/// +/// `vertices` is the per-corner vertex buffer (`[x, y, z]`) and `indices` +/// the triangle list into it. The returned buffer is parallel to +/// `vertices`, so a renderer can use it verbatim without merging vertices +/// (which would otherwise break per-vertex UV alignment). +pub fn smooth_vertex_normals( + vertices: &[[f64; 3]], + indices: &[[u32; 3]], + smooth: bool, +) -> Vec<[f32; 3]> { + // original vertex index -> welded vertex id. + let mut welded: HashMap<[i64; 3], usize> = HashMap::new(); + let mut rep = vec![0usize; vertices.len()]; + for (i, p) in vertices.iter().enumerate() { + let key = [ + (p[0] * INV_EPS).round() as i64, + (p[1] * INV_EPS).round() as i64, + (p[2] * INV_EPS).round() as i64, + ]; + let next = welded.len(); + rep[i] = *welded.entry(key).or_insert(next); + } + + // Area-weighted face normals + per-welded-vertex incident faces. + let mut face_n: Vec<[f64; 3]> = Vec::with_capacity(indices.len()); + let mut incident: Vec> = vec![Vec::new(); welded.len()]; + for (fi, t) in indices.iter().enumerate() { + let a = vertices[t[0] as usize]; + let b = vertices[t[1] as usize]; + let c = vertices[t[2] as usize]; + // Un-normalized cross product = 2·area·unit_normal → area weighting. + face_n.push(cross(sub(b, a), sub(c, a))); + for &corner in t { + incident[rep[corner as usize]].push(fi); + } + } + + // Blend each corner from the incident faces within the crease angle. + let mut out = vec![[0.0f32; 3]; vertices.len()]; + for (fi, t) in indices.iter().enumerate() { + let nf = normalize_or(face_n[fi], [0.0, 0.0, 1.0]); + for &corner in t { + let mut acc = [0.0f64; 3]; + for &gf in &incident[rep[corner as usize]] { + if smooth || dot(nf, normalize_or(face_n[gf], nf)) >= CREASE_COS { + acc = add(acc, face_n[gf]); + } + } + let n = normalize_or(acc, nf); + out[corner as usize] = [n[0] as f32, n[1] as f32, n[2] as f32]; + } + } + out +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn normalize_or(v: [f64; 3], fallback: [f64; 3]) -> [f64; 3] { + let len = dot(v, v).sqrt(); + if len > 1.0e-12 { + [v[0] / len, v[1] / len, v[2] / len] + } else { + fallback + } +} + +#[cfg(test)] +mod tests { + use super::smooth_vertex_normals; + + fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + } + + /// Flatten triangles STL-style: every triangle gets its own three + /// vertices (no sharing), so the welding inside `smooth_vertex_normals` + /// is what must recover the shared edges — exactly the faceted-input + /// case that motivated the function. + fn flatten(tris: &[[[f64; 3]; 3]]) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { + let mut verts = Vec::new(); + let mut idx = Vec::new(); + for t in tris { + let base = verts.len() as u32; + verts.extend_from_slice(t); + idx.push([base, base + 1, base + 2]); + } + (verts, idx) + } + + /// Two triangles hinged along the shared edge (0,0,0)-(1,0,0) with the + /// angle `beta` (radians) between their face normals. Face 1's normal is + /// +Z; vertices are ordered so the shared corners land at indices 0 + /// (face 1) and 4 (face 2). + fn hinge(beta: f64) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { + let e0 = [0.0, 0.0, 0.0]; + let e1 = [1.0, 0.0, 0.0]; + let w1 = [0.5, 1.0, 0.0]; // face 1 lies in z=0 → normal +Z + let w2 = [0.5, -beta.cos(), beta.sin()]; // face 2 normal = (0, sinβ, cosβ) + flatten(&[[e0, e1, w1], [e1, e0, w2]]) + } + + #[test] + fn sharp_edge_stays_creased() { + // 90° between faces > the 45° crease threshold: the two copies of + // the shared corner must keep their own face normal (no blending). + let (v, i) = hinge(std::f64::consts::FRAC_PI_2); + let n = smooth_vertex_normals(&v, &i, /*smooth=*/ false); + assert_eq!(n.len(), v.len()); + // Corner of face 1 at the shared edge → still +Z. + assert!(dot(n[0], [0.0, 0.0, 1.0]) > 0.999, "{:?}", n[0]); + // Same position, corner of face 2 → its own +Y normal: a crease. + assert!(dot(n[4], [0.0, 1.0, 0.0]) > 0.999, "{:?}", n[4]); + } + + #[test] + fn shallow_edge_gets_smoothed() { + // 20° between faces < the 45° threshold: the shared corners blend + // to the average of the two face normals — the curved-surface case + // that makes STL robots look faceted without this. + let beta = 20.0_f64.to_radians(); + let (v, i) = hinge(beta); + let n = smooth_vertex_normals(&v, &i, /*smooth=*/ false); + // Blended normal bisects the two faces → cos(β/2) onto +Z, and is + // *not* equal to either face normal (proving smoothing happened). + let onto_z = dot(n[0], [0.0, 0.0, 1.0]); + let expected = (beta / 2.0).cos() as f32; + assert!((onto_z - expected).abs() < 1.0e-3, "{onto_z} vs {expected}"); + assert!(onto_z < 0.999, "should be blended, not flat: {onto_z}"); + // Both copies of the shared corner agree (fully smooth across edge). + assert!(dot(n[0], n[4]) > 0.999, "{:?} vs {:?}", n[0], n[4]); + } + + #[test] + fn smoothnormal_forces_blend_across_sharp_edge() { + // With `smooth=true` (MJCF smoothnormal) even the 90° edge blends. + let (v, i) = hinge(std::f64::consts::FRAC_PI_2); + let n = smooth_vertex_normals(&v, &i, /*smooth=*/ true); + assert!(dot(n[0], n[4]) > 0.999, "{:?} vs {:?}", n[0], n[4]); + assert!( + dot(n[0], [0.0, 0.0, 1.0]) < 0.999, + "should blend: {:?}", + n[0] + ); + } +} diff --git a/crates/rapier3d-mjcf/src/loader/geom.rs b/crates/rapier3d-mjcf/src/loader/geom.rs index 61268a034..0f13b547e 100644 --- a/crates/rapier3d-mjcf/src/loader/geom.rs +++ b/crates/rapier3d-mjcf/src/loader/geom.rs @@ -245,11 +245,39 @@ impl<'a> Conversion<'a> { } else { None }; + // Recompute smooth, crease-aware vertex normals from the + // geometry the way MuJoCo does, rather than trusting the file's. + // Most menagerie robots (e.g. unitree_g1) ship STL, a faceted + // format: it stores one normal per *triangle* with fully + // unshared vertices, so neither the raw file normals nor a + // naive per-face recompute can ever produce anything but flat + // shading. Welding coincident vertices to recover shared edges + // and blending the adjacent face normals (honoring the asset's + // `smoothnormal` crease threshold) yields the smooth look the + // source models intend. Visual path only — the collision/mass + // paths have no use for normals. + let normals = if force_trimesh { + m.shape.as_trimesh().map(|tm| { + let verts: Vec<[f64; 3]> = tm + .vertices() + .iter() + .map(|p| [p.x as f64, p.y as f64, p.z as f64]) + .collect(); + mjcf_rs::normals::smooth_vertex_normals( + &verts, + tm.indices(), + mesh.smoothnormal > 0.5, + ) + }) + } else { + None + }; let diffuse_texture = m.material.texture.diffuse.clone(); return Some(LoadedMesh { shape: m.shape, pose: m.pose, uvs, + normals, diffuse_texture, }); } @@ -471,30 +499,32 @@ impl<'a> Conversion<'a> { // Mesh assets get the specialized path that also harvests UVs // and the MTL diffuse texture. Primitives go through the // standard analytic shape path (no UVs, no MTL). - let (shape, local_pose, uvs, mtl_texture) = if matches!(g.type_, mb::GeomType::Mesh) { - let loaded = self.load_visual_mesh_asset(g)?; - // `loaded.pose` is the asset-level intrinsic offset (the - // converter's adjustment — identity for trimeshes). The - // geom's own `pos`/`quat` haven't been applied yet; multiply - // by the body-frame pose so the mesh lands where the MJCF - // places it within its parent body. Without this every - // mesh visual sits at the body's origin and only the - // collider path (which composes via `build_geom_shape`) - // looks correct. - let body_frame_pose = self.geom_body_frame_pose(g); - ( - loaded.shape, - body_frame_pose * loaded.pose, - loaded.uvs, - loaded.diffuse_texture, - ) - } else { - // `build_geom_shape_with` already composes `body_frame_pose * extra`, - // so the returned `local_pose` is fully placed in the body's frame. - let (shape, local_pose) = - self.build_geom_shape_with(g, /*force_trimesh=*/ true)?; - (shape, local_pose, None, None) - }; + let (shape, local_pose, uvs, normals, mtl_texture) = + if matches!(g.type_, mb::GeomType::Mesh) { + let loaded = self.load_visual_mesh_asset(g)?; + // `loaded.pose` is the asset-level intrinsic offset (the + // converter's adjustment — identity for trimeshes). The + // geom's own `pos`/`quat` haven't been applied yet; multiply + // by the body-frame pose so the mesh lands where the MJCF + // places it within its parent body. Without this every + // mesh visual sits at the body's origin and only the + // collider path (which composes via `build_geom_shape`) + // looks correct. + let body_frame_pose = self.geom_body_frame_pose(g); + ( + loaded.shape, + body_frame_pose * loaded.pose, + loaded.uvs, + loaded.normals, + loaded.diffuse_texture, + ) + } else { + // `build_geom_shape_with` already composes `body_frame_pose * extra`, + // so the returned `local_pose` is fully placed in the body's frame. + let (shape, local_pose) = + self.build_geom_shape_with(g, /*force_trimesh=*/ true)?; + (shape, local_pose, None, None, None) + }; let (material_rgba, material_texture) = self.resolve_geom_material(g); let rgba = g @@ -508,6 +538,7 @@ impl<'a> Conversion<'a> { local_pose, rgba, uvs, + normals, texture, }) } @@ -559,6 +590,7 @@ pub(super) struct LoadedMesh { pub(super) shape: SharedShape, pub(super) pose: Pose, pub(super) uvs: Option>, + pub(super) normals: Option>, pub(super) diffuse_texture: Option, } diff --git a/crates/rapier3d-mjcf/src/loader/types.rs b/crates/rapier3d-mjcf/src/loader/types.rs index d63426b70..552498e3c 100644 --- a/crates/rapier3d-mjcf/src/loader/types.rs +++ b/crates/rapier3d-mjcf/src/loader/types.rs @@ -75,6 +75,15 @@ pub struct MjcfVisualMesh { /// carried UV data; `None` for primitives and for meshes whose /// source didn't include texture coordinates. pub uvs: Option>, + /// Per-vertex normals parallel to the shape's vertex buffer. Computed + /// from the geometry (crease-aware smoothing, honoring the asset's + /// `smoothnormal`) the way MuJoCo generates mesh normals — faceted + /// sources like STL carry no usable shared-vertex normals of their own. + /// `Some` for mesh geoms; `None` for primitives. When present, a + /// renderer should use these directly instead of recomputing flat + /// per-face normals, so meshes render smooth-shaded like MuJoCo's + /// viewer rather than faceted. + pub normals: Option>, /// Resolved filesystem path to a 2D color texture. Pulled from the /// MJCF `` when set, otherwise from the OBJ /// MTL's `map_Kd`. `None` for geoms that aren't textured. diff --git a/crates/rapier_testbed2d-f64/Cargo.toml b/crates/rapier_testbed2d-f64/Cargo.toml index 89ab8edc4..fd9966334 100644 --- a/crates/rapier_testbed2d-f64/Cargo.toml +++ b/crates/rapier_testbed2d-f64/Cargo.toml @@ -60,7 +60,7 @@ puffin_egui = { version = "0.29", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" indexmap = { version = "2", features = ["serde"] } -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } log = "0.4" env_logger = "0.11" diff --git a/crates/rapier_testbed2d/Cargo.toml b/crates/rapier_testbed2d/Cargo.toml index c38a43b63..d93c7ab89 100644 --- a/crates/rapier_testbed2d/Cargo.toml +++ b/crates/rapier_testbed2d/Cargo.toml @@ -60,7 +60,7 @@ puffin_egui = { version = "0.29", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" indexmap = { version = "2", features = ["serde"] } -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } log = "0.4" env_logger = "0.11" diff --git a/crates/rapier_testbed3d-f64/Cargo.toml b/crates/rapier_testbed3d-f64/Cargo.toml index 5d498523d..21981b3a5 100644 --- a/crates/rapier_testbed3d-f64/Cargo.toml +++ b/crates/rapier_testbed3d-f64/Cargo.toml @@ -61,7 +61,7 @@ serde_json = "1" profiling = "1.0" puffin_egui = { version = "0.29", optional = true } indexmap = { version = "2", features = ["serde"] } -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } log = "0.4" env_logger = "0.11" diff --git a/crates/rapier_testbed3d/Cargo.toml b/crates/rapier_testbed3d/Cargo.toml index 3c2cfaa24..f708f8571 100644 --- a/crates/rapier_testbed3d/Cargo.toml +++ b/crates/rapier_testbed3d/Cargo.toml @@ -62,7 +62,7 @@ egui = "0.34" profiling = "1.0" puffin_egui = { version = "0.29", optional = true } indexmap = { version = "2", features = ["serde"] } -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } log = "0.4" env_logger = "0.11" diff --git a/examples2d/Cargo.toml b/examples2d/Cargo.toml index 21a41f67a..ed66c155d 100644 --- a/examples2d/Cargo.toml +++ b/examples2d/Cargo.toml @@ -16,7 +16,7 @@ enhanced-determinism = ["rapier2d/enhanced-determinism"] rand = "0.10" lyon = "0.17" dot_vox = "5" -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] usvg = "0.14" diff --git a/examples3d-f64/Cargo.toml b/examples3d-f64/Cargo.toml index ea7dfb2eb..fb0976038 100644 --- a/examples3d-f64/Cargo.toml +++ b/examples3d-f64/Cargo.toml @@ -18,7 +18,7 @@ wasm-bindgen = "0.2" obj-rs = { version = "0.7", default-features = false } bincode = "1" serde = "1" -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } [dependencies.rapier_testbed3d-f64] path = "../crates/rapier_testbed3d-f64" diff --git a/examples3d/Cargo.toml b/examples3d/Cargo.toml index 3f647dc71..53760c4aa 100644 --- a/examples3d/Cargo.toml +++ b/examples3d/Cargo.toml @@ -22,7 +22,7 @@ bincode = "1" serde_json = "1" dot_vox = "5" glam = { version = "0.33", features = ["fast-math"] } -kiss3d = { version = "0.42.0", features = ["egui", "serde"] } +kiss3d = { version = "0.43.0", features = ["egui", "serde"] } [dependencies.rapier_testbed3d] path = "../crates/rapier_testbed3d" diff --git a/examples3d/mujoco_menagerie3.rs b/examples3d/mujoco_menagerie3.rs index 7cd86b2ce..30c046ece 100644 --- a/examples3d/mujoco_menagerie3.rs +++ b/examples3d/mujoco_menagerie3.rs @@ -259,6 +259,7 @@ fn register_visual_meshes( vm.local_pose, color, vm.uvs.as_deref(), + vm.normals.as_deref(), vm.texture.as_deref(), ); } diff --git a/src_testbed/graphics.rs b/src_testbed/graphics.rs index a3aeddc9c..1d9bc3d07 100644 --- a/src_testbed/graphics.rs +++ b/src_testbed/graphics.rs @@ -209,48 +209,68 @@ impl GraphicsManager { local_pose: rapier::math::Pose, color: Color, uvs: Option<&[[f32; 2]]>, + normals: Option<&[[f32; 3]]>, texture: Option<&Path>, ) { // Plumbing per-vertex UVs into a custom kiss3d mesh is only // supported in 3D; 2D always uses the standard node path. #[cfg(feature = "dim3")] let mut node = { - let textured = uvs.is_some() && texture.is_some(); - if textured && matches!(shape.shape_type(), ShapeType::TriMesh) { - // Build the kiss3d mesh ourselves so we can plumb UVs in; - // the standard `create_individual_node` path doesn't carry - // them through. - // - // OBJ stores UV `v=0` at the *bottom* of the image while - // wgpu samples textures from the top — flip `v` so the - // texture lands the right way up. + if matches!(shape.shape_type(), ShapeType::TriMesh) { + // Build the kiss3d mesh ourselves so we can plumb the + // authored UVs *and* normals straight through. The standard + // `create_individual_node` path carries neither and always + // recomputes flat per-face normals — for a visual mesh + // that replaces the default collider render we instead want + // to match the source asset exactly. let trimesh = shape.as_trimesh().unwrap(); - let uvs_vec: Vec = uvs - .unwrap() - .iter() - .map(|uv| Vec2::new(uv[0], 1.0 - uv[1])) - .collect(); - // If the UV count doesn't match the vertex count we drop - // the UVs — `replicate_vertices` would otherwise panic. - let uvs_opt = if uvs_vec.len() == trimesh.vertices().len() { - Some(uvs_vec) - } else { - None - }; let vtx: Vec = trimesh .vertices() .iter() .map(|pt| Vec3::new(pt.x as f32, pt.y as f32, pt.z as f32)) .collect(); let idx = trimesh.indices().to_vec(); + + // UVs are only meaningful with a texture. OBJ stores UV + // `v=0` at the *bottom* of the image while wgpu samples + // textures from the top — flip `v` so the texture lands + // the right way up. A UV count that doesn't match the + // vertex buffer is dropped (`replicate_vertices` would + // otherwise panic). + let uvs_opt = if texture.is_some() { + uvs.filter(|uvs| uvs.len() == trimesh.vertices().len()) + .map(|uvs| uvs.iter().map(|uv| Vec2::new(uv[0], 1.0 - uv[1])).collect()) + } else { + None + }; + + // Use the authored normals verbatim when they line up with + // the vertex buffer — this preserves smooth shading where + // the artist made it smooth instead of faceting everything. + let normals_opt: Option> = normals + .filter(|n| n.len() == trimesh.vertices().len()) + .map(|n| { + n.iter() + .map(|nrm| Vec3::new(nrm[0], nrm[1], nrm[2])) + .collect() + }); + + let have_normals = normals_opt.is_some(); let mut mesh = kiss3d::procedural::RenderMesh::new( vtx, - None, + normals_opt, uvs_opt, Some(kiss3d::procedural::IndexBuffer::Unified(idx)), ); - mesh.replicate_vertices(); - mesh.recompute_normals(); + if !have_normals { + // No usable authored normals (e.g. an STL/OBJ that + // carried none): fall back to flat shading so the mesh + // still lights. This splits shared vertices, so it must + // run *only* when we didn't supply normals above — + // never on the smooth path. + mesh.replicate_vertices(); + mesh.recompute_normals(); + } Some(self.scene.add_render_mesh(mesh, Vec3::ONE)) } else { Self::create_individual_node(&mut self.scene, &**shape, color, false) @@ -258,13 +278,19 @@ impl GraphicsManager { }; #[cfg(feature = "dim2")] let mut node = { - // Custom UV plumbing is unsupported in 2D; ignore `uvs`. - let _ = uvs; + // Custom UV/normal plumbing is unsupported in 2D; ignore them. + let _ = (uvs, normals); Self::create_individual_node(&mut self.scene, &**shape, color, false) }; if let Some(n) = node.as_mut() { n.set_color(color); + // The custom trimesh path above builds the node directly rather + // than through `create_individual_node`, so disable backface + // culling here too — visual meshes are routinely viewed from + // both sides and authored winding isn't guaranteed. + #[cfg(feature = "dim3")] + n.enable_backface_culling_recursive(false); if let Some(tex_path) = texture { // The cache key uses the absolute path string so the // same texture file is uploaded only once across the diff --git a/src_testbed/testbed/app.rs b/src_testbed/testbed/app.rs index 283ac2929..2a513ad1a 100644 --- a/src_testbed/testbed/app.rs +++ b/src_testbed/testbed/app.rs @@ -210,6 +210,7 @@ impl TestbedApp { let mut window = Window::new_with_size(title, 1280, 720).await; window.set_background_color(Color::new(245.0 / 255.0, 245.0 / 255.0, 236.0 / 255.0, 1.0)); + window.set_ambient(0.1); let mut debug_render = DebugRenderPipelineResource::default(); let mut camera = Camera::default(); diff --git a/src_testbed/testbed/graphics_context.rs b/src_testbed/testbed/graphics_context.rs index 0d78c9276..e83a10971 100644 --- a/src_testbed/testbed/graphics_context.rs +++ b/src_testbed/testbed/graphics_context.rs @@ -54,8 +54,11 @@ impl<'a> TestbedGraphics<'a> { /// pose offset by `local_pose` and does not participate in physics. /// /// `uvs` is an optional per-vertex UV buffer (used only when the - /// shape is a `TriMesh` *and* `texture` is also set). `texture` is - /// an optional path to a 2D color image to apply. + /// shape is a `TriMesh` *and* `texture` is also set). `normals` is an + /// optional per-vertex normal buffer parallel to the shape's vertices; + /// when supplied the renderer uses it verbatim instead of recomputing + /// flat per-face normals. `texture` is an optional path to a 2D color + /// image to apply. pub fn add_body_render_mesh( &mut self, body: RigidBodyHandle, @@ -63,6 +66,7 @@ impl<'a> TestbedGraphics<'a> { local_pose: rapier::math::Pose, color: Color, uvs: Option<&[[f32; 2]]>, + normals: Option<&[[f32; 3]]>, texture: Option<&std::path::Path>, ) { self.graphics.add_body_render_mesh( @@ -72,6 +76,7 @@ impl<'a> TestbedGraphics<'a> { local_pose, color, uvs, + normals, texture, ); } diff --git a/src_testbed/testbed/testbed.rs b/src_testbed/testbed/testbed.rs index e9db3dd1f..065eda5ca 100644 --- a/src_testbed/testbed/testbed.rs +++ b/src_testbed/testbed/testbed.rs @@ -294,8 +294,11 @@ impl Testbed<'_> { /// inserting them as colliders. /// /// `uvs` is an optional per-vertex UV buffer (used only when the - /// shape is a `TriMesh` *and* `texture` is also set). `texture` is - /// an optional path to a 2D color image to apply. + /// shape is a `TriMesh` *and* `texture` is also set). `normals` is an + /// optional per-vertex normal buffer parallel to the shape's vertices; + /// when supplied the renderer uses it verbatim (preserving authored + /// smooth shading) instead of recomputing flat per-face normals. + /// `texture` is an optional path to a 2D color image to apply. pub fn add_body_render_mesh( &mut self, body: RigidBodyHandle, @@ -303,6 +306,7 @@ impl Testbed<'_> { local_pose: rapier::math::Pose, color: Color, uvs: Option<&[[f32; 2]]>, + normals: Option<&[[f32; 3]]>, texture: Option<&std::path::Path>, ) { if let Some(graphics) = &mut self.graphics { @@ -313,6 +317,7 @@ impl Testbed<'_> { local_pose, color, uvs, + normals, texture, ); }