diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs index e2b9e1bd..8a3fd9f4 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; const MAX_DEPTH: usize = 64; /// Largest decoded array accepted (bytes). const MAX_ARRAY_BYTES: usize = 1 << 30; +/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the +/// wire type) plus the meshes built from them (#1234). +const MAX_IMPORT_BYTES: usize = 1 << 31; + +/// What is left of an import's memory budget. +struct Budget(std::cell::Cell); + +impl Budget { + fn new(bytes: usize) -> Budget { + Budget(std::cell::Cell::new(bytes)) + } + /// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before + /// anything that large is allocated. + fn take(&self, bytes: Option, what: &str) -> Result<()> { + let left = self.0.get(); + match bytes.filter(|&b| b <= left) { + Some(b) => { + self.0.set(left - b); + Ok(()) + } + None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))), + } + } +} + +/// Bytes a mesh keeps. +fn mesh_bytes(m: &Mesh) -> Option { + m.primitives.iter().try_fold(0usize, |acc, p| { + let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?; + let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?; + acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?) + }) +} + +/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as +/// one `Prop` each and are parsed before they can be counted (the text itself bounds them). +fn value_bytes(nodes: &[FbxNode]) -> Option { + nodes.iter().try_fold(0usize, |acc, n| { + let own = n.props.iter().try_fold(0usize, |a, p| { + let extra = match p { + Prop::Ints(v) => v.len().checked_mul(8)?, + Prop::Floats(v) => v.len().checked_mul(8)?, + Prop::Str(v) => v.len(), + Prop::Bytes(v) => v.len(), + Prop::Int(_) | Prop::Float(_) => 0, + }; + a.checked_add(std::mem::size_of::())?.checked_add(extra) + })?; + acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) + }) +} /// One property value of an FBX node. #[derive(Clone, Debug, PartialEq)] @@ -126,7 +177,17 @@ impl FbxNode { /// Read an FBX file (binary or ASCII) into its top-level nodes and the file version /// (e.g. 7400). pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { - if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } + read_within(bytes, &Budget::new(MAX_IMPORT_BYTES)) +} + +fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { + read_binary(bytes, budget) + } else { + let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?; + budget.take(value_bytes(&nodes), "its values")?; + Ok((version, nodes)) + } } // --------------------------------------------------------------------------------------------- @@ -136,6 +197,7 @@ struct Bin<'a> { d: &'a [u8], /// 64-bit record offsets (version 7500 and later). wide: bool, + budget: &'a Budget, } impl Bin<'_> { @@ -207,7 +269,7 @@ impl Bin<'_> { } fn prop(&self, pos: &mut usize, pend: usize) -> Result { - let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget }; let t = sub.arr::<1>(pos)?[0]; Ok(match t { b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), @@ -234,6 +296,9 @@ impl Bin<'_> { _ => 1, }; let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; + // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget + // is charged for what is kept, not for the wire bytes. + sub.budget.take(len.checked_mul(8), "an array")?; let data = sub.take(pos, clen)?; let raw = match enc { 0 => data.to_vec(), @@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String { } } -fn read_binary(bytes: &[u8]) -> Result<(u32, Vec)> { +fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { let mut pos = BINARY_MAGIC.len(); - let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; - let bin = Bin { d: bytes, wide: version >= 7500 }; + let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500, budget }; let mut nodes = vec![]; while pos < bytes.len() { match bin.record(&mut pos, 0) { @@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str { /// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - let (version, doc) = read(bytes)?; + parse_within(bytes, resolve, MAX_IMPORT_BYTES) +} + +/// [`parse`] keeping at most `limit` bytes of arrays and meshes. +fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: usize) -> Result { + let budget = Budget::new(limit); + let (version, doc) = read_within(bytes, &budget)?; if version != 0 && version < 7000 { return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); } @@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< Some(materials.len() - 1) }; - // One mesh per Model with Mesh geometry (material slots are per Model). + // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the + // same geometry with the same materials share one mesh rather than each building a copy. let mut meshes: Vec = vec![]; + let mut mesh_of: HashMap<(i64, Vec), usize> = HashMap::new(); for &id in &model_ids { let Some(&ni) = node_of.get(&id) else { continue }; let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; let Some(geom) = by_id.get(&gid) else { continue }; let mats: Vec = links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); - let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); - let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; - meshes.push(mesh); - let mesh_index = meshes.len() - 1; + let mesh_index = match mesh_of.get(&(gid, mats.clone())) { + Some(&i) => i, + None => { + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; + meshes.push(mesh); + mesh_of.insert((gid, mats), meshes.len() - 1); + meshes.len() - 1 + } + }; let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); match geo { Some(matrix) => { diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs index 3cd8ba5a..1ba1978b 100644 --- a/crates/model/src/fbx_tests.rs +++ b/crates/model/src/fbx_tests.rs @@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() { assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); } + +/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type +/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. +fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { + let mut props = vec![]; + for &(t, len) in arrays { + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + props.push(t); + props.extend((len as u32).to_le_bytes()); + props.extend(0u32.to_le_bytes()); + props.extend(((len * size) as u32).to_le_bytes()); + props.extend(std::iter::repeat_n(1u8, len * size)); + } + let mut out = BINARY_MAGIC.to_vec(); + out.extend(7400u32.to_le_bytes()); + let end = out.len() + 13 + 1 + props.len(); + out.extend((end as u32).to_le_bytes()); + out.extend((arrays.len() as u32).to_le_bytes()); + out.extend((props.len() as u32).to_le_bytes()); + out.push(1); + out.push(b'A'); + out.extend(props); + out +} + +#[test] +fn fbx_arrays_are_charged_for_the_memory_they_keep() { + // A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`: + // the budget counts the eight (#1234). + let n = 10_000; + let bin = binary_arrays(&[(b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 8)).is_ok()); + let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err(); + assert!(e.to_string().contains("memory limit"), "{e}"); + // 32-bit floats widen to f64 the same way. + assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err()); + // Several arrays each within the budget but not together. + let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 24)).is_ok()); + assert!(read_within(&bin, &Budget::new(n * 20)).is_err()); + // ASCII values count too. + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(values > 45 * std::mem::size_of::(), "{values}"); + assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit")); +} + +/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, +/// `Red only` with just the first. +fn instanced_scene() -> String { + ASCII + .replace( + "\tMaterial: 400,", + "\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,", + ) + .replace( + "\tC: \"OO\",500,300\n", + "\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n", + ) +} + +#[test] +fn fbx_models_instancing_one_geometry_share_its_mesh() { + let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap(); + let mesh_of = |name: &str| { + let n = m.nodes.iter().position(|n| n.name == name).unwrap(); + // The mesh sits on the node, or on its geometric-offset child. + m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap() + }; + assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); + assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); + assert_eq!(m.meshes.len(), 2); + // A budget with room for one copy of the geometry's mesh, but not three, still imports. + let one = parse(ASCII.as_bytes(), &|_| None).unwrap(); + let mesh = mesh_bytes(&one.meshes[0]).unwrap(); + let (_, doc) = read(instanced_scene().as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok()); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err()); +}