From ceb78f63afd20395eb054d6ec460bade494d2cd8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 06:26:09 -0500 Subject: [PATCH] craft-mesh: preserve glTF tangents * gltf: test preserving authored tangents * gltf: expose authored tangent attributes --- crates/mesh/src/gltf.rs | 26 +++++++++++++++++++------- crates/mesh/src/lib.rs | 12 +++++++++++- crates/mesh/src/tests.rs | 3 +++ crates/mesh/src/tests/gltf.rs | 18 ++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/mesh/src/gltf.rs b/crates/mesh/src/gltf.rs index 90e7b0840..44477ddfb 100644 --- a/crates/mesh/src/gltf.rs +++ b/crates/mesh/src/gltf.rs @@ -3,7 +3,7 @@ //! //! Read: the default scene's node hierarchy (matrix or translation/rotation/scale), triangle //! primitives (`TRIANGLES`, `TRIANGLE_STRIP`, `TRIANGLE_FAN`, indexed or not) with POSITION, -//! NORMAL, every TEXCOORD_n, COLOR_0, JOINTS_0 and WEIGHTS_0 of any component type the +//! NORMAL, TANGENT, every TEXCOORD_n, COLOR_0, JOINTS_0 and WEIGHTS_0 of any component type the //! specification or `KHR_mesh_quantization` allows, strided buffer views, sparse accessors, //! metallic-roughness materials (factors, the five texture slots with their UV set and wrap //! modes, alpha modes, double-sided, `KHR_materials_emissive_strength`, `KHR_materials_unlit`; @@ -15,7 +15,7 @@ //! same accessors), and each primitive a [`Part`]. //! //! Not read: required extensions that change how geometry is stored (Draco, meshopt) are -//! [`Error::Unsupported`]; points, lines, morph targets, tangents and +//! [`Error::Unsupported`]; points, lines, morph targets and //! other extensions are passed over (skipped primitives are counted in a warning). //! //! Buffers are needed to read anything, so an external buffer is asked of the resolver (by its @@ -353,7 +353,7 @@ fn rows_of(v: &[f64]) -> impl Iterator + '_ { /// Append the primitive's vertex attributes to the mesh's pools; the bases its corners count /// from (`NONE` for a pool the primitive does not fill). -fn attributes(d: &Doc, attrs: &Value, pos: &[f64], mesh: &mut Mesh) -> Result<[u32; 3]> { +fn attributes(d: &Doc, attrs: &Value, pos: &[f64], mesh: &mut Mesh) -> Result<[u32; 4]> { let nv = pos.len() / 3; let base_of = |len: usize| { u32::try_from(len) @@ -367,6 +367,18 @@ fn attributes(d: &Doc, attrs: &Value, pos: &[f64], mesh: &mut Mesh) -> Result<[u normal_base = base_of(mesh.normals.len())?; mesh.normals.extend(rows_of::<3>(&v)); } + let mut tangent_base = NONE; + match d.attribute(attrs, "TANGENT", nv, &[4])? { + Some((v, _)) => { + if mesh.tangents.is_empty() { + mesh.tangents.resize(mesh.positions.len(), [0.0; 4]); + } + tangent_base = base_of(mesh.tangents.len())?; + mesh.tangents.extend(rows_of::<4>(&v)); + } + None if !mesh.tangents.is_empty() => mesh.tangents.resize(mesh.positions.len() + nv, [0.0; 4]), + None => {} + } let mut sets = Vec::new(); while sets.len() < MAX_UV_SETS { let Some((v, _)) = d.attribute(attrs, &format!("TEXCOORD_{}", sets.len()), nv, &[2])? else { break }; @@ -411,13 +423,13 @@ fn attributes(d: &Doc, attrs: &Value, pos: &[f64], mesh: &mut Mesh) -> Result<[u _ => {} } mesh.positions.extend(rows_of::<3>(pos)); - Ok([base, normal_base, uv_base]) + Ok([base, normal_base, uv_base, tangent_base]) } /// Where the vertices of one set of attribute accessors start in the mesh's pools (positions, /// normals, UVs), and how many there are. Primitives of a mesh that name the same accessors /// (one vertex buffer, several materials) share the vertices. -type Shared = std::collections::HashMap; +type Shared = std::collections::HashMap; fn primitive(d: &Doc, p: &Value, mesh: &mut Mesh, shared: &mut Shared, materials: usize, skipped: &mut Skipped) -> Result<()> { let mode = idx(p, "mode").unwrap_or(4); @@ -428,7 +440,7 @@ fn primitive(d: &Doc, p: &Value, mesh: &mut Mesh, shared: &mut Shared, materials }; charge(&d.parts_left, 1, "primitives")?; let key = attrs.to_string(); - let ([base, normal_base, uv_base], nv) = match shared.get(&key) { + let ([base, normal_base, uv_base, tangent_base], nv) = match shared.get(&key) { Some(known) => *known, None => { let (pos, n) = d.rows(pa, d.vertices_left.get(), "vertices")?; @@ -464,7 +476,7 @@ fn primitive(d: &Doc, p: &Value, mesh: &mut Mesh, shared: &mut Shared, materials }; let finite = |i: u32| (i as usize) < nv && mesh.positions.get(base as usize + i as usize).is_some_and(|p| p.iter().all(|x| x.is_finite())); let offset = |b: u32, i: u32| if b == NONE { NONE } else { b + i }; - let mut part = Part { material: idx(p, "material").filter(|m| *m < materials), ..Part::default() }; + let mut part = Part { material: idx(p, "material").filter(|m| *m < materials), has_tangents: tangent_base != NONE, ..Part::default() }; let count = if mode == 4 { raw.len() / 3 } else { raw.len().saturating_sub(2) }; for t in (0..count).filter_map(triangle) { if t.iter().all(|i| finite(*i)) { diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs index 06d6c526e..c4bf90661 100644 --- a/crates/mesh/src/lib.rs +++ b/crates/mesh/src/lib.rs @@ -170,6 +170,8 @@ pub struct Part { pub material: Option, /// OBJ smoothing group; 0 is off. pub smoothing: u32, + /// This glTF primitive supplies tangents for its positions. + pub has_tangents: bool, /// Corners of each face, in order. Empty means every face is a triangle. pub face_sizes: Vec, /// The corners of all faces, one face after another. @@ -214,6 +216,9 @@ pub struct Mesh { pub name: String, pub positions: Vec<[f64; 3]>, pub normals: Vec<[f64; 3]>, + /// Authored glTF tangents `[x, y, z, handedness]`, aligned with `positions`; empty when no + /// primitive supplies tangents. A part's [`Part::has_tangents`] distinguishes absent values. + pub tangents: Vec<[f64; 4]>, /// UV sets; every set has the same length and is indexed by [`Corner::uv`]. pub uv_sets: Vec>, /// RGBA per position (same length as `positions`), or empty. @@ -255,11 +260,16 @@ impl Mesh { if self.uv_sets.iter().any(|s| s.len() != uvs) { return bad("UV sets differ in length"); } - for (what, len) in [("colours", self.colors.len()), ("joints", self.joints.len()), ("weights", self.weights.len())] { + for (what, len) in + [("colours", self.colors.len()), ("tangents", self.tangents.len()), ("joints", self.joints.len()), ("weights", self.weights.len())] + { if len != 0 && len != self.positions.len() { return bad(&format!("{what} do not match the positions")); } } + if self.parts.iter().any(|p| p.has_tangents) && self.tangents.len() != self.positions.len() { + return bad("a part names tangents but the tangent array does not match the positions"); + } for p in &self.parts { let total = if p.face_sizes.is_empty() { (p.corners.len() % 3 == 0).then_some(p.corners.len()) diff --git a/crates/mesh/src/tests.rs b/crates/mesh/src/tests.rs index bd1ea1aad..587ef4db1 100644 --- a/crates/mesh/src/tests.rs +++ b/crates/mesh/src/tests.rs @@ -72,6 +72,9 @@ fn validate_catches_what_writers_cannot_take() { let mut m = tri_mesh(); m.colors = vec![[1.0; 4]]; assert!(m.validate().is_err()); + let mut m = tri_mesh(); + m.parts[0].has_tangents = true; + assert!(m.validate().is_err(), "a part names tangents without a per-position array"); let mut s = Scene::from_meshes(vec![tri_mesh()], vec![]); s.validate().unwrap(); s.meshes[0].parts[0].material = Some(0); diff --git a/crates/mesh/src/tests/gltf.rs b/crates/mesh/src/tests/gltf.rs index c4498b3e2..011f4d75e 100644 --- a/crates/mesh/src/tests/gltf.rs +++ b/crates/mesh/src/tests/gltf.rs @@ -106,6 +106,24 @@ fn glb_quad_with_a_node_tree_and_a_material() { assert!((p[0] - -9.0).abs() < 1e-9 && (p[1] - 2.0).abs() < 1e-9 && p[2].abs() < 1e-9, "{p:?}"); } +#[test] +fn gltf_preserves_authored_tangents_on_the_mesh_positions() { + let mut b = B::default(); + let p = b.f32s(&QUAD[..9], "VEC3", 3); + let tangent = [1.0, 0.0, 0.0, -1.0].repeat(3); + let t = b.f32s(&tangent, "VEC4", 4); + let i = b.u16s(&[0, 1, 2]); + let scene = parse(&b.glb(json!({ + "meshes": [{ "primitives": [{ "attributes": { "POSITION": p, "TANGENT": t }, "indices": i }] }] + }))) + .unwrap(); + + scene.validate().unwrap(); + let mesh = scene.meshes.first().unwrap(); + assert_eq!(mesh.tangents, vec![[1.0, 0.0, 0.0, -1.0]; 3]); + assert!(mesh.parts.first().unwrap().has_tangents); +} + fn base64(data: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::new();