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.
This commit is contained in:
Dylan M. Taylor
2026-11-03 11:03:58 +00:00
parent 7870b5916a
commit 4d6f48a8a6
5 changed files with 222 additions and 55 deletions
+33 -10
View File
@@ -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<P>>) -> Vec<Outline> {
let cs: Vec<Vec<P>> = contours
let mut cs: Vec<Vec<P>> = contours
.into_iter()
.map(|mut c| {
c.dedup();
@@ -110,25 +114,44 @@ pub fn group_contours(contours: Vec<Vec<P>>) -> Vec<Outline> {
})
.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<f64> = 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<Vec<usize>> = (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<Option<usize>> = (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<usize> = (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<Outline> = vec![];
let mut slot = vec![usize::MAX; n];
let mut order: Vec<usize> = (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());
+36 -16
View File
@@ -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<V
// Images → textures (decoded once per image; samplers set the wrap modes).
let mut images: Vec<Option<Arc<Texture>>> = vec![];
// Images naming one source decode once, and all of them within one pixel budget.
let mut image_of: HashMap<String, Option<Arc<Texture>>> = 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<V
};
let mut textures: Vec<Arc<Texture>> = vec![];
let mut tex_map: Vec<Option<usize>> = 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<V
let ws = samp.and_then(|s| s.get("wrapS")).and_then(Value::as_u64).unwrap_or(10497);
let wt = samp.and_then(|s| s.get("wrapT")).and_then(Value::as_u64).unwrap_or(10497);
let w = [wrap(ws), wrap(wt)];
let tex = if img.wrap == w { img } else { Arc::new(Texture { wrap: w, ..(*img).clone() }) };
tex_map.push(Some(textures.len()));
textures.push(tex);
let i = *texture_of.entry((src, w)).or_insert_with(|| {
textures.push(if img.wrap == w { img } else { Arc::new(Texture { wrap: w, ..(*img).clone() }) });
textures.len() - 1
});
tex_map.push(Some(i));
}
let tref = |v: Option<&Value>| -> Option<TexRef> {
let v = v?;
+42 -11
View File
@@ -46,7 +46,7 @@ pub(crate) fn perr(s: impl Into<String>) -> 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<Texture> {
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<Texture> {
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<Vec<Mat4>>,
/// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`), shared by
/// the instances of one skin.
pub joints: Option<Arc<Vec<Mat4>>>,
}
impl Model {
@@ -622,6 +644,8 @@ impl Model {
let mut seen = vec![false; self.nodes.len()];
let mut stack: Vec<usize> = 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<usize, Arc<Vec<Mat4>>> = 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).
+39 -18
View File
@@ -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<Vec<u8>>, 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<Vec<u8>>, textures: &mut Vec<Arc<Texture>>) -> Vec<Material> {
/// The textures an OBJ import has decoded, by reference, and the pixels it may still decode.
pub struct TextureCache {
loaded: HashMap<String, Option<usize>>,
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<Vec<u8>>, textures: &mut Vec<Arc<Texture>>, cache: &mut TextureCache) -> Vec<Material> {
let mut out: Vec<Material> = vec![];
let mut loaded: HashMap<String, Option<usize>> = HashMap::new();
let TextureCache { loaded, pixels_left } = cache;
let mut tex = |name: &str, textures: &mut Vec<Arc<Texture>>| -> Option<TexRef> {
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<Vec<u8>>, 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<Vec<u8>>) -> Result<
let mut vt: Vec<[f32; 2]> = vec![];
let mut vn: Vec<[f32; 3]> = vec![];
let mut materials: Vec<Material> = vec![];
let mut material_of: HashMap<String, usize> = HashMap::new();
let mut textures: Vec<Arc<Texture>> = vec![];
let mut cache = TextureCache::default();
let mut libraries: Vec<String> = vec![];
// One primitive per material, in order of first use.
let mut prims: Vec<(Option<usize>, Primitive, HashMap<Key, u32>, Vec<bool>)> = vec![];
let mut prim_of: HashMap<Option<usize>, usize> = HashMap::new();
let mut current: Option<usize> = 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<Vec<u8>>) -> 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<Vec<u8>>) -> 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<Vec<u8>>) -> 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 {
+72
View File
@@ -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::<Vec<_>>(), ["A", "B"]);
assert_eq!(m.textures.len(), 1);
assert_eq!(m.meshes[0].primitives.iter().map(|p| (p.material, p.triangle_count())).collect::<Vec<_>>(), [(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<Vec<[f64; 2]>> = (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()));
}