From 4d6f48a8a68e5ee708ebc0351743c81506b70404 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 11:03:58 +0000 Subject: [PATCH] fix(model): share decoded textures and skin matrices, bound texture pixels, index OBJ materials Texture::decode expanded any image to float RGBA without a size check. Repeated mtllib lines reparsed the library and decoded its textures again; glTF textures with a non-default sampler each copied their image's pixels; every instance of a skin built its own joint matrices. OBJ usemtl and faces scanned the growing material and primitive lists, and contour grouping kept every ancestor of every contour. --- crates/model/src/extrude.rs | 43 ++++++++++++++++------ crates/model/src/gltf.rs | 52 ++++++++++++++++++--------- crates/model/src/lib.rs | 53 +++++++++++++++++++++------ crates/model/src/obj.rs | 57 +++++++++++++++++++---------- crates/model/src/tests.rs | 72 +++++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 55 deletions(-) diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 114606b95..9018eb186 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -96,10 +96,14 @@ fn point_in(p: P, c: &[P]) -> bool { inside } +/// Most contours grouped into outlines; containment is tested pairwise, so the rest are dropped. +pub const MAX_CONTOURS: usize = 10_000; + /// Group closed contours into outlines by nesting depth (even depth = outer, odd = hole of the -/// innermost containing outer contour). Degenerate contours are dropped. +/// innermost containing outer contour). Degenerate contours are dropped, as are those past +/// [`MAX_CONTOURS`]. pub fn group_contours(contours: Vec>) -> Vec { - let cs: Vec> = contours + let mut cs: Vec> = contours .into_iter() .map(|mut c| { c.dedup(); @@ -110,25 +114,44 @@ pub fn group_contours(contours: Vec>) -> Vec { }) .filter(|c| c.len() >= 3 && signed_area(c).abs() > 1e-9) .collect(); + if cs.len() > MAX_CONTOURS { + log::warn!("model: {} contours, grouping the first {MAX_CONTOURS}", cs.len()); + cs.truncate(MAX_CONTOURS); + } let n = cs.len(); let area: Vec = cs.iter().map(|c| signed_area(c).abs()).collect(); - // Containers of each contour (by a vertex inside; nested contours do not cross). - let parents: Vec> = (0..n) + let bounds: Vec<[P; 2]> = cs + .iter() + .map(|c| c.iter().fold([[f64::MAX; 2], [f64::MIN; 2]], |[lo, hi], p| [[lo[0].min(p[0]), lo[1].min(p[1])], [hi[0].max(p[0]), hi[1].max(p[1])]])) + .collect(); + // The innermost container of each contour: the smallest larger contour holding a vertex of + // it (nested contours do not cross); its bounding box must hold the vertex first. + let parent: Vec> = (0..n) .map(|i| { - let probe = cs[i][0]; - (0..n).filter(|&j| j != i && area[j] > area[i] && point_in(probe, &cs[j])).collect() + let p = cs[i][0]; + (0..n) + .filter(|&j| j != i && area[j] > area[i]) + .filter(|&j| (bounds[j][0][0]..=bounds[j][1][0]).contains(&p[0]) && (bounds[j][0][1]..=bounds[j][1][1]).contains(&p[1])) + .filter(|&j| point_in(p, &cs[j])) + .min_by(|&a, &b| area[a].total_cmp(&area[b])) }) .collect(); + // Nesting depth, containers (the larger contours) first. + let mut by_area: Vec = (0..n).collect(); + by_area.sort_by(|&a, &b| area[b].total_cmp(&area[a])); + let mut depth = vec![0usize; n]; + for &i in &by_area { + depth[i] = parent[i].map_or(0, |p| depth[p] + 1); + } let mut outlines: Vec = vec![]; let mut slot = vec![usize::MAX; n]; let mut order: Vec = (0..n).collect(); - order.sort_by_key(|&i| parents[i].len()); + order.sort_by_key(|&i| depth[i]); for i in order { - let depth = parents[i].len(); - if depth.is_multiple_of(2) { + if depth[i].is_multiple_of(2) { slot[i] = outlines.len(); outlines.push(Outline { outer: cs[i].clone(), holes: vec![] }); - } else if let Some(&p) = parents[i].iter().filter(|&&j| parents[j].len() == depth - 1).min_by(|&&a, &&b| area[a].total_cmp(&area[b])) + } else if let Some(p) = parent[i] && slot[p] != usize::MAX { outlines[slot[p]].holes.push(cs[i].clone()); diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index c0b736359..c614ae7f8 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -11,12 +11,14 @@ //! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets //! are ignored. +use std::collections::HashMap; use std::{cell::Cell, sync::Arc}; use serde_json::Value; use crate::{ - AlphaMode, Animation, Channel, ChannelPath, Interp, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, TexRef, Texture, Wrap, perr, + AlphaMode, Animation, Channel, ChannelPath, Interp, MAX_IMPORT_TEXTURE_PIXELS, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, + TexRef, Texture, Wrap, perr, }; const GLB_MAGIC: u32 = 0x4654_6C67; @@ -352,19 +354,32 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>> = vec![]; + // Images naming one source decode once, and all of them within one pixel budget. + let mut image_of: HashMap>> = HashMap::new(); + let mut pixels_left = MAX_IMPORT_TEXTURE_PIXELS; for im in arr(&j, "images") { - let data = if let Some(u) = im.get("uri").and_then(Value::as_str) { - load_uri(u, resolve).ok() - } else if let Some(bv) = idx(im, "bufferView") { - arr(&j, "bufferViews").get(bv).and_then(|v| { - let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; - let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) - }) - } else { - None + let (uri, view) = (im.get("uri").and_then(Value::as_str), idx(im, "bufferView")); + let key = match (uri, view) { + (Some(u), _) => format!("uri:{u}"), + (None, Some(bv)) => format!("bufferView:{bv}"), + (None, None) => { + images.push(None); + continue; + } }; - images.push(data.and_then(|b| Texture::decode(&b).ok()).map(Arc::new)); + let image = image_of.entry(key).or_insert_with(|| { + let data = if let Some(u) = uri { + load_uri(u, resolve).ok() + } else { + arr(&j, "bufferViews").get(view.unwrap_or(usize::MAX)).and_then(|v| { + let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; + let o = idx(v, "byteOffset").unwrap_or(0); + b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) + }) + }; + data.and_then(|b| Texture::decode_within(&b, &mut pixels_left).ok()).map(Arc::new) + }); + images.push(image.clone()); } let wrap = |v: u64| match v { 33071 => Wrap::Clamp, @@ -373,8 +388,11 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option> = vec![]; let mut tex_map: Vec> = vec![]; + // One texture per image and wrapping: repeated references share it instead of copying + // the pixels. + let mut texture_of: HashMap<(usize, [Wrap; 2]), usize> = HashMap::new(); for t in arr(&j, "textures") { - let Some(img) = idx(t, "source").and_then(|s| images.get(s).cloned().flatten()) else { + let Some((src, img)) = idx(t, "source").and_then(|s| Some((s, images.get(s).cloned().flatten()?))) else { tex_map.push(None); continue; }; @@ -382,9 +400,11 @@ pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option| -> Option { let v = v?; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 19a0d37f3..9e8420b59 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -46,7 +46,7 @@ pub(crate) fn perr(s: impl Into) -> ModelError { } /// Texture coordinate wrapping (glTF sampler wrap modes). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Wrap { #[default] Repeat, @@ -64,14 +64,35 @@ pub struct Texture { pub wrap: [Wrap; 2], } +/// Most pixels one texture may hold (8192 × 8192: 1 GiB as float RGBA). +pub const MAX_TEXTURE_PIXELS: u64 = 1 << 26; +/// Most pixels the textures of one import may hold together (2 GiB as float RGBA). +pub const MAX_IMPORT_TEXTURE_PIXELS: u64 = 1 << 27; + impl Texture { pub fn solid(c: [f32; 4]) -> Texture { Texture { width: 1, height: 1, data: vec![c], wrap: [Wrap::Repeat; 2] } } - /// Decode a PNG or JPEG. + /// Decode a PNG or JPEG of at most [`MAX_TEXTURE_PIXELS`]. pub fn decode(bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| perr(format!("image: {e}")))?.to_rgba32f(); + Self::decode_within(bytes, &mut MAX_IMPORT_TEXTURE_PIXELS.clone()) + } + /// [`decode`](Self::decode) taking the pixels from `pixels_left`, an import's remaining + /// texture budget, before the image is decoded. + pub fn decode_within(bytes: &[u8], pixels_left: &mut u64) -> Result { + let err = |e: image::ImageError| perr(format!("image: {e}")); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| perr(format!("image: {e}")))?; + let (w, h) = reader.into_dimensions().map_err(err)?; + let pixels = w as u64 * h as u64; + if pixels > MAX_TEXTURE_PIXELS { + return Err(ModelError::Unsupported(format!("a {w}×{h} texture (at most {MAX_TEXTURE_PIXELS} pixels)"))); + } + if pixels > *pixels_left { + return Err(ModelError::Unsupported(format!("a {w}×{h} texture: the model's textures would hold more than {MAX_IMPORT_TEXTURE_PIXELS} pixels"))); + } + let img = image::load_from_memory(bytes).map_err(err)?.to_rgba32f(); let (w, h) = (img.width(), img.height()); + *pixels_left -= pixels; let data = img.pixels().map(|p| p.0).collect(); Ok(Texture { width: w, height: h, data, wrap: [Wrap::Repeat; 2] }) } @@ -570,8 +591,9 @@ pub struct Instance<'m> { pub mesh: &'m Mesh, /// Node → model space. pub world: Mat4, - /// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`). - pub joints: Option>, + /// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`), shared by + /// the instances of one skin. + pub joints: Option>>, } impl Model { @@ -622,6 +644,8 @@ impl Model { let mut seen = vec![false; self.nodes.len()]; let mut stack: Vec = self.roots.clone(); let mut out = vec![]; + // The pose is one for the whole scene, so every instance of a skin shares its matrices. + let mut joints_of: std::collections::HashMap>> = std::collections::HashMap::new(); while let Some(i) = stack.pop() { if i >= self.nodes.len() || seen[i] { continue; @@ -629,12 +653,19 @@ impl Model { seen[i] = true; let n = &self.nodes[i]; if let Some(m) = n.mesh.and_then(|m| self.meshes.get(m)) { - let joints = n.skin.and_then(|s| self.skins.get(s)).map(|s| { - s.joints - .iter() - .enumerate() - .map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY)) - .collect() + let joints = n.skin.and_then(|si| self.skins.get(si).map(|s| (si, s))).map(|(si, s)| { + joints_of + .entry(si) + .or_insert_with(|| { + Arc::new( + s.joints + .iter() + .enumerate() + .map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY)) + .collect(), + ) + }) + .clone() }); // Skinned vertices are placed by their joints alone (glTF: the node transform of // a skinned mesh is ignored). diff --git a/crates/model/src/obj.rs b/crates/model/src/obj.rs index 22d9877a2..32b3242a0 100644 --- a/crates/model/src/obj.rs +++ b/crates/model/src/obj.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; +use crate::{AlphaMode, MAX_IMPORT_TEXTURE_PIXELS, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; /// The last component of a referenced path, treating `\` as a separator too: a path from a /// machine of another kind (`C:\textures\checker.png`) still finds `checker.png` next to the @@ -39,10 +39,23 @@ fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> O None } -/// Parse MTL text into named materials (textures appended to `textures`). -pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>) -> Vec { +/// The textures an OBJ import has decoded, by reference, and the pixels it may still decode. +pub struct TextureCache { + loaded: HashMap>, + pixels_left: u64, +} + +impl Default for TextureCache { + fn default() -> Self { + TextureCache { loaded: HashMap::new(), pixels_left: MAX_IMPORT_TEXTURE_PIXELS } + } +} + +/// Parse MTL text into named materials (textures appended to `textures`, decoded once per +/// reference through `cache`). +pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>, cache: &mut TextureCache) -> Vec { let mut out: Vec = vec![]; - let mut loaded: HashMap> = HashMap::new(); + let TextureCache { loaded, pixels_left } = cache; let mut tex = |name: &str, textures: &mut Vec>| -> Option { let key = name.trim().to_string(); let t = *loaded.entry(key.clone()).or_insert_with(|| { @@ -50,7 +63,7 @@ pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures log::warn!("model: texture `{key}` was not found next to the model"); return None; }; - match Texture::decode(&b) { + match Texture::decode_within(&b, pixels_left) { Ok(t) => { textures.push(Arc::new(t)); Some(textures.len() - 1) @@ -151,9 +164,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut vt: Vec<[f32; 2]> = vec![]; let mut vn: Vec<[f32; 3]> = vec![]; let mut materials: Vec = vec![]; + let mut material_of: HashMap = HashMap::new(); let mut textures: Vec> = vec![]; + let mut cache = TextureCache::default(); + let mut libraries: Vec = vec![]; // One primitive per material, in order of first use. let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut prim_of: HashMap, usize> = HashMap::new(); let mut current: Option = None; let mut smooth = 0u32; let mut flat_id = 1u32 << 31; @@ -181,11 +198,19 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "mtllib" => { let name = line["mtllib".len()..].trim(); + // A library named again is already loaded. + if libraries.iter().any(|l| l == name) { + continue; + } + libraries.push(name.to_string()); let mut found = false; for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) { if let Some(b) = resolve(file) { - let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures); - materials.append(&mut ms); + let ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures, &mut cache); + for m in ms { + material_of.entry(m.name.clone()).or_insert(materials.len()); + materials.push(m); + } found = true; break; } @@ -196,12 +221,11 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "usemtl" => { let name = line["usemtl".len()..].trim(); - current = materials.iter().position(|m| m.name == name); - if current.is_none() { + current = Some(*material_of.entry(name.to_string()).or_insert_with(|| { // Unknown material: a named grey default. materials.push(Material { name: name.to_string(), metallic: 0.0, roughness: 0.5, base_color: [0.8, 0.8, 0.8, 1.0], ..Material::default() }); - current = Some(materials.len() - 1); - } + materials.len() - 1 + })); } "f" => { if rest.len() < 3 { @@ -234,13 +258,10 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } else { smooth }; - let slot = match prims.iter().position(|p| p.0 == current) { - Some(i) => i, - None => { - prims.push((current, Primitive { material: current, ..Default::default() }, HashMap::new(), vec![])); - prims.len() - 1 - } - }; + let slot = *prim_of.entry(current).or_insert_with(|| { + prims.push((current, Primitive { material: current, ..Default::default() }, HashMap::new(), vec![])); + prims.len() - 1 + }); let (_, prim, map, need) = &mut prims[slot]; let mut ids = vec![]; for (vi, ti, ni) in refs { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 5ee6a6950..843a9db9e 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -731,3 +731,75 @@ fn gltf_strip_expansion_is_charged_to_the_read_budget() { let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 5000).err().unwrap_or_else(|| panic!("imported")); assert!(e.to_string().contains("more than 5000 components"), "{e}"); } + +#[test] +fn texture_decoding_is_charged_to_the_import_budget() { + let png = { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let mut left = 4; + assert_eq!(Texture::decode_within(&png, &mut left).unwrap().data.len(), 4); + assert_eq!(left, 0); + assert!(matches!(Texture::decode_within(&png, &mut left), Err(ModelError::Unsupported(_)))); +} + +#[test] +fn obj_repeated_material_libraries_and_textures_load_once() { + let png = { + let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let mtl = b"newmtl A\nmap_Kd t.png\nnewmtl B\nmap_Kd t.png\n".to_vec(); + let src = b"mtllib m.mtl\nmtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl A\nf 1 2 3\nusemtl B\nf 1 2 3\nusemtl A\nf 1 2 3\n"; + let m = obj::parse(src, &|name| match name { + "m.mtl" => Some(mtl.clone()), + "t.png" => Some(png.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(m.materials.iter().map(|m| m.name.as_str()).collect::>(), ["A", "B"]); + assert_eq!(m.textures.len(), 1); + assert_eq!(m.meshes[0].primitives.iter().map(|p| (p.material, p.triangle_count())).collect::>(), [(Some(0), 2), (Some(1), 1)]); +} + +#[test] +fn gltf_textures_naming_one_image_share_it() { + let doc = String::from_utf8(fixture("quad.gltf")).unwrap(); + // Three textures of one image: the fixture's sampler, the same sampler again, no sampler. + let doc = doc.replace( + "\"textures\": [\n {\n \"source\": 0,\n \"sampler\": 0\n }\n ]", + "\"textures\": [{\"source\": 0, \"sampler\": 0}, {\"source\": 0, \"sampler\": 0}, {\"source\": 0}]", + ); + let m = gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.textures.len(), 2); + assert_eq!(m.textures[0].wrap, [Wrap::Clamp, Wrap::Repeat]); + assert_eq!(m.textures[1].wrap, [Wrap::Repeat, Wrap::Repeat]); +} + +#[test] +fn instances_of_one_skin_share_its_joint_matrices() { + let p = mesh(PrimitiveKind::Plane, &PrimParams { size: [2.0, 2.0, 0.0], ..Default::default() }); + let mut m = Model::single(Mesh { name: "s".into(), primitives: vec![p] }, vec![Material::default()]); + m.nodes[0].skin = Some(0); + m.nodes.push(Node { mesh: Some(0), skin: Some(0), ..Node::default() }); + m.roots = vec![0, 1]; + m.skins.push(Skin { joints: vec![0], inverse_bind: vec![Mat4::IDENTITY] }); + let inst = m.instances(None, 0.0); + assert_eq!(inst.len(), 2); + assert!(Arc::ptr_eq(inst[0].joints.as_ref().unwrap(), inst[1].joints.as_ref().unwrap())); +} + +#[test] +fn contour_grouping_is_bounded() { + let sq = |x: f64, y: f64, s: f64| vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]; + let n = extrude::MAX_CONTOURS + 1; + let cs: Vec> = (0..n).map(|i| sq(i as f64 * 2.0, 0.0, 1.0)).collect(); + let g = group_contours(cs); + assert_eq!(g.len(), extrude::MAX_CONTOURS); + assert!(g.iter().all(|o| o.holes.is_empty())); +}