craft-mesh: preserve glTF tangents

* gltf: test preserving authored tangents

* gltf: expose authored tangent attributes
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 06:26:09 -05:00
1 parent e3c6ec4a23
commit ceb78f63af
4 files changed
+51 -8

No files matched your search

+19 -7
View File
@@ -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<const N: usize>(v: &[f64]) -> impl Iterator<Item = [f64; N]> + '_ {
/// 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<String, ([u32; 3], usize)>;
type Shared = std::collections::HashMap<String, ([u32; 4], usize)>;
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)) {
+11 -1
View File
@@ -170,6 +170,8 @@ pub struct Part {
pub material: Option<usize>,
/// 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<u32>,
/// 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<Vec<[f32; 2]>>,
/// 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())
+3
View File
@@ -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);
+18
View File
@@ -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();