use std::collections::{HashMap, HashSet};
use gltf::accessor::{DataType, Dimensions};
use gltf::animation::util::ReadOutputs;
use gltf::animation::{Channel, Property};
use gltf::material::AlphaMode;
use gltf::{Accessor, Gltf, Mesh, Node, Primitive, Semantic, Skin};
use crate::assets::{
Item, NamedAnimation, NamedMesh, NamedModel, NamedSlot, Readable, ReliefData, ShadingData,
TextureData, texture,
};
use crate::material::CUTOUT;
use crate::math::{Mat3, Mat4, Quat, UVec2, Vec2, Vec3, Vec4};
use crate::mesh::{Animation, Joint, Keys, Local, Moves, Placed, Rig, Track, Vertex, Weighted};
use crate::{Color, Error, Material};
const HEADER_BYTES: u32 = 12;
const FLOATS: &[DataType] = &[DataType::F32];
const CORNERS: &[DataType] = &[DataType::U8, DataType::U16, DataType::U32];
const SCALED: &[DataType] = &[DataType::U8, DataType::U16, DataType::F32];
const NUMBERED: &[DataType] = &[DataType::U8, DataType::U16];
const TURNED: &[DataType] = &[
DataType::I8,
DataType::U8,
DataType::I16,
DataType::U16,
DataType::F32,
];
pub(crate) fn decode(stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
let _ = stem;
let gltf = container(bytes)?;
let data = binary_chunk(&gltf)?;
readable(&gltf, data.len())?;
let images = gltf
.images()
.map(|image| decode_image(image.source(), data))
.collect::<Result<Vec<_>, _>>()?;
let mut items = Vec::with_capacity(gltf.meshes().len() + images.len());
for mesh in gltf.meshes() {
let Some(name) = mesh.name() else {
log::warn!("an asset source holds a mesh with no name, which nothing can pull");
continue;
};
items.push((
name.to_owned(),
Item::Mesh(decode_mesh(&mesh, data, &images)?),
));
}
for root in roots(&gltf) {
let Some(name) = root.name() else {
log::warn!("an asset source holds a root node with no name, which nothing can pull");
continue;
};
items.push((
name.to_owned(),
Item::Model(decode_model(&gltf, &root, data, &images)?),
));
}
for (image, pixels) in gltf.images().zip(images) {
if let Some(name) = image.name() {
items.push((name.to_owned(), Item::Texture(pixels)));
}
}
Ok(items)
}
fn roots(gltf: &Gltf) -> Vec<Node<'_>> {
let mut read = HashSet::new();
gltf.scenes()
.flat_map(|scene| scene.nodes())
.filter(|node| read.insert(node.index()))
.collect()
}
fn container(bytes: &[u8]) -> Result<Gltf, Error> {
if declared_length(bytes).is_some_and(|length| length < HEADER_BYTES) {
return Err(Error::msg(
"is truncated: it declares less than it starts with",
));
}
let read = Gltf::from_slice_without_validation(bytes).map_err(decoding)?;
let mut json = read.document.into_json();
for extension in json.extensions_required.drain(..) {
log::debug!(
"an asset source requires `{extension}`, which Mirage does not implement; it is \
loaded without it"
);
}
Ok(Gltf {
document: gltf::Document::from_json(json).map_err(decoding)?,
blob: read.blob,
})
}
fn decoding(error: gltf::Error) -> Error {
Error::msg(format!("did not decode: {error}"))
}
fn declared_length(bytes: &[u8]) -> Option<u32> {
if !bytes.starts_with(b"glTF") {
return None;
}
let length: [u8; 4] = bytes.get(8..12)?.try_into().ok()?;
Some(u32::from_le_bytes(length))
}
fn readable(gltf: &Gltf, chunk: usize) -> Result<(), Error> {
for image in &gltf.as_json().images {
match (&image.buffer_view, &image.mime_type, &image.uri) {
(Some(_), None, _) => {
return Err(Error::msg("holds an image and never says what kind"));
}
(None, _, None) => return Err(Error::msg("holds an image with nothing in it")),
_ => {}
}
}
for mesh in gltf.meshes() {
for primitive in mesh.primitives() {
attributes(&primitive, mesh.name().unwrap_or_default(), chunk)?;
}
}
for skin in gltf.skins() {
let Some(binds) = skin.inverse_bind_matrices() else {
continue;
};
let name = skin.name().unwrap_or_default();
shaped(
&binds,
Dimensions::Mat4,
FLOATS,
chunk,
&format!("`{name}`'s binds"),
)?;
}
for animation in gltf.animations() {
let name = animation.name().unwrap_or_default();
for channel in animation.channels() {
keyed(&channel, name, chunk)?;
}
}
Ok(())
}
fn keyed(channel: &Channel<'_>, animation: &str, chunk: usize) -> Result<(), Error> {
let (dimensions, types) = match channel.target().property() {
Property::Translation | Property::Scale => (Dimensions::Vec3, FLOATS),
Property::Rotation => (Dimensions::Vec4, TURNED),
Property::MorphTargetWeights => return Ok(()),
};
let sampler = channel.sampler();
shaped(
&sampler.input(),
Dimensions::Scalar,
FLOATS,
chunk,
&format!("`{animation}`'s key times"),
)?;
shaped(
&sampler.output(),
dimensions,
types,
chunk,
&format!("`{animation}`'s keys"),
)
}
fn attributes(primitive: &Primitive<'_>, mesh: &str, chunk: usize) -> Result<(), Error> {
let attribute = |semantic, dimensions, types, what: &str| match primitive.get(&semantic) {
Some(accessor) => shaped(
&accessor,
dimensions,
types,
chunk,
&format!("`{mesh}`'s {what}"),
),
None => Ok(()),
};
attribute(Semantic::Positions, Dimensions::Vec3, FLOATS, "positions")?;
attribute(Semantic::Normals, Dimensions::Vec3, FLOATS, "normals")?;
attribute(Semantic::TexCoords(0), Dimensions::Vec2, SCALED, "uvs")?;
attribute(Semantic::Joints(0), Dimensions::Vec4, NUMBERED, "joints")?;
attribute(Semantic::Weights(0), Dimensions::Vec4, SCALED, "weights")?;
match primitive.indices() {
Some(corners) => shaped(
&corners,
Dimensions::Scalar,
CORNERS,
chunk,
&format!("`{mesh}`'s corners"),
),
None => Ok(()),
}
}
fn shaped(
accessor: &Accessor<'_>,
dimensions: Dimensions,
types: &[DataType],
chunk: usize,
what: &str,
) -> Result<(), Error> {
let read = accessor.dimensions() == dimensions
&& types.contains(&accessor.data_type())
&& accessor.sparse().is_none()
&& within(accessor, chunk);
match read {
true => Ok(()),
false => Err(Error::msg(format!(
"writes {what} in a form Mirage cannot read"
))),
}
}
fn within(accessor: &Accessor<'_>, chunk: usize) -> bool {
let Some(view) = accessor.view() else {
return false;
};
let size = accessor.size();
let stride = view.stride().unwrap_or(size);
let last = accessor
.count()
.checked_sub(1)
.and_then(|before| before.checked_mul(stride));
let end = last
.and_then(|last| accessor.offset().checked_add(last))
.and_then(|last| last.checked_add(size));
stride >= size
&& end.is_some_and(|end| end <= view.length())
&& view
.offset()
.checked_add(view.length())
.is_some_and(|end| end <= chunk)
}
fn binary_chunk(gltf: &Gltf) -> Result<&[u8], Error> {
let data = gltf.blob.as_deref().unwrap_or_default();
for buffer in gltf.buffers() {
if !matches!(buffer.source(), gltf::buffer::Source::Bin) {
return Err(Error::msg(
"keeps data in a separate file; export it self-contained",
));
}
if buffer.length() > data.len() {
return Err(Error::msg("is truncated: its binary chunk is short"));
}
}
Ok(data)
}
fn decode_image(source: gltf::image::Source<'_>, data: &[u8]) -> Result<TextureData, Error> {
let gltf::image::Source::View { view, .. } = source else {
return Err(Error::msg(
"keeps an image in a separate file; export it self-contained",
));
};
let bytes = view
.offset()
.checked_add(view.length())
.and_then(|end| data.get(view.offset()..end))
.ok_or_else(|| Error::msg("is truncated: an image runs past its binary chunk"))?;
texture::decode(bytes).map_err(|error| Error::msg(format!("holds an image that {error}")))
}
fn decode_mesh(mesh: &Mesh<'_>, data: &[u8], images: &[TextureData]) -> Result<NamedMesh, Error> {
let name = mesh.name().unwrap_or_default();
let mut built = Built::default();
built.add(mesh, name, data, images, &Joined::Plain)?;
if built.slots.is_empty() {
return Err(Error::msg(format!("gives `{name}` no geometry")));
}
Ok(NamedMesh::new(built.vertices, built.indices, built.slots))
}
fn decode_model(
gltf: &Gltf,
root: &Node<'_>,
data: &[u8],
images: &[TextureData],
) -> Result<NamedModel, Error> {
let name = root.name().unwrap_or_default();
let subtree = Subtree::of(root)?;
let joints = Joints::of(gltf, &subtree, data, name)?;
let mut built = Built::default();
for held in subtree.held() {
let Some(mesh) = held.node.mesh() else {
continue;
};
built.add(&mesh, name, data, images, &joints.joined(&held.node))?;
}
let animations = gltf
.animations()
.map(|animation| moved_by(&animation, &joints, data))
.collect::<Result<Vec<NamedAnimation>, Error>>()?;
Ok(NamedModel::new(
NamedMesh::new(built.vertices, built.indices, built.slots),
Rig::new(joints.joints, built.weights),
animations,
))
}
#[derive(Default)]
struct Built {
vertices: Vec<Vertex>,
indices: Vec<u32>,
slots: Vec<NamedSlot>,
weights: Vec<Weighted>,
}
impl Built {
fn add(
&mut self,
mesh: &Mesh<'_>,
asset: &str,
data: &[u8],
images: &[TextureData],
joined: &Joined,
) -> Result<(), Error> {
for primitive in mesh.primitives() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
return Err(Error::msg(format!(
"draws `{asset}` out of something other than triangles"
)));
}
let reader = primitive.reader(|_| Some(data));
let Some(positions) = reader.read_positions() else {
return Err(Error::msg(format!("gives `{asset}` no positions")));
};
let positions: Vec<Vec3> = positions.map(Vec3::from).collect();
let uvs: Vec<Vec2> = reader
.read_tex_coords(0)
.map(|uvs| uvs.into_f32().map(Vec2::from).collect())
.unwrap_or_default();
let corners: Vec<u32> = match reader.read_indices() {
Some(read) => read.into_u32().collect(),
None => (0..positions.len() as u32).collect(),
};
if corners.iter().any(|&at| at as usize >= positions.len()) {
return Err(Error::msg(format!(
"indexes `{asset}` past its own corners"
)));
}
if primitive.get(&Semantic::Joints(1)).is_some() {
log::debug!(
"`{asset}` states a second set of joints per vertex, which Mirage does not \
read; it is posed by the first four"
);
}
let skinned = Skinned {
joints: reader
.read_joints(0)
.map(|read| read.into_u16().collect())
.unwrap_or_default(),
weights: reader
.read_weights(0)
.map(|read| read.into_f32().collect())
.unwrap_or_default(),
};
let uv = |at: usize| uvs.get(at).copied().unwrap_or_default();
let first_index = self.indices.len() as u32;
let first_vertex = self.vertices.len() as u32;
match reader.read_normals() {
Some(normals) => {
let normals: Vec<Vec3> = normals.map(Vec3::from).collect();
if normals.len() != positions.len() {
return Err(Error::msg(format!(
"gives `{asset}` normals its positions do not match"
)));
}
for (at, (&position, &normal)) in positions.iter().zip(&normals).enumerate() {
let vertex = Vertex::new(position, normal, uv(at));
self.push(joined, &skinned, at, vertex, asset)?;
}
self.indices
.extend(corners.iter().map(|&corner| first_vertex + corner));
}
None => {
for triangle in corners.chunks_exact(3) {
let corner = |at: usize| positions[triangle[at] as usize];
let facing = (corner(1) - corner(0))
.cross(corner(2) - corner(0))
.normalize_or_zero();
for &at in triangle {
let at = at as usize;
let vertex = Vertex::new(positions[at], facing, uv(at));
self.push(joined, &skinned, at, vertex, asset)?;
}
}
self.indices
.extend(first_vertex..self.vertices.len() as u32);
}
}
let material = primitive.material();
self.slots.push(NamedSlot {
name: material.name().unwrap_or_default().to_owned(),
index_count: self.indices.len() as u32 - first_index,
material: tint(&primitive),
texture: base_color(&primitive, images),
relief: relief(&primitive, images),
shading: shading(&primitive, images),
emissive: emissive(&primitive, images),
});
}
Ok(())
}
fn push(
&mut self,
joined: &Joined,
skinned: &Skinned,
at: usize,
vertex: Vertex,
asset: &str,
) -> Result<(), Error> {
self.vertices.push(Vertex::new(
joined.position(vertex.position),
joined.normal(vertex.normal),
vertex.uv,
));
self.weights.extend(joined.taken(skinned, at, asset)?);
Ok(())
}
}
#[derive(Default)]
struct Skinned {
joints: Vec<[u16; 4]>,
weights: Vec<[f32; 4]>,
}
impl Skinned {
fn at(&self, at: usize) -> ([u16; 4], [f32; 4]) {
(
self.joints.get(at).copied().unwrap_or_default(),
self.weights
.get(at)
.copied()
.unwrap_or([1.0, 0.0, 0.0, 0.0]),
)
}
}
enum Joined {
Plain,
Skinned(Vec<u32>),
Whole { rest: Mat4, joint: u32 },
}
impl Joined {
fn position(&self, position: Vec3) -> Vec3 {
match self {
Self::Whole { rest, .. } => rest.transform_point3(position),
Self::Plain | Self::Skinned(_) => position,
}
}
fn normal(&self, normal: Vec3) -> Vec3 {
match self {
Self::Whole { rest, .. } => (cofactor(*rest) * normal).normalize_or_zero(),
Self::Plain | Self::Skinned(_) => normal,
}
}
fn taken(&self, skinned: &Skinned, at: usize, asset: &str) -> Result<Option<Weighted>, Error> {
let Self::Skinned(joints) = self else {
return Ok(match self {
Self::Whole { joint, .. } => Some(Weighted::whole(*joint)),
Self::Plain | Self::Skinned(_) => None,
});
};
let (takes, weights) = skinned.at(at);
let mut taken = [0; 4];
for (joint, &take) in taken.iter_mut().zip(&takes) {
*joint = *joints.get(take as usize).ok_or_else(|| {
Error::msg(format!("skins `{asset}` to a joint its skin does not hold"))
})?;
}
Ok(Some(Weighted::scaled(taken, weights)))
}
}
fn cofactor(model: Mat4) -> Mat3 {
let [x, y, z] = [model.x_axis, model.y_axis, model.z_axis].map(Vec4::truncate);
Mat3::from_cols(y.cross(z), z.cross(x), x.cross(y))
}
struct Subtree<'a> {
held: Vec<Held<'a>>,
at: HashMap<usize, usize>,
}
struct Held<'a> {
node: Node<'a>,
parent: Option<usize>,
rest: Local,
}
impl<'a> Subtree<'a> {
fn of(root: &Node<'a>) -> Result<Self, Error> {
let mut subtree = Self {
held: Vec::new(),
at: HashMap::new(),
};
let mut down = vec![(root.clone(), None)];
while let Some((node, parent)) = down.pop() {
if subtree.at.contains_key(&node.index()) {
continue;
}
let rest = rest_of(&node)?;
let at = subtree.held.len();
subtree.at.insert(node.index(), at);
let children: Vec<Node<'a>> = node.children().collect();
subtree.held.push(Held { node, parent, rest });
down.extend(children.into_iter().rev().map(|child| (child, Some(at))));
}
Ok(subtree)
}
fn held(&self) -> impl Iterator<Item = &Held<'a>> {
self.held.iter()
}
fn holds(&self, node: &Node<'_>) -> bool {
self.at.contains_key(&node.index())
}
fn placed(&self, held: &Held<'a>, at: &HashMap<usize, (u32, Mat4)>) -> (Placed, Mat4) {
let mut above = Mat4::IDENTITY;
let mut parent = held.parent;
while let Some(up) = parent {
let Some(held) = self.held.get(up) else {
break;
};
if let Some(&(joint, rest)) = at.get(&held.node.index()) {
return (Placed::Under(joint), rest);
}
above = held.rest.matrix() * above;
parent = held.parent;
}
(Placed::Within(above), above)
}
}
fn rest_of(node: &Node<'_>) -> Result<Local, Error> {
match node.transform() {
gltf::scene::Transform::Decomposed {
translation,
rotation,
scale,
} => Ok(Local::new(
Vec3::from(translation),
Quat::from_array(rotation),
Vec3::from(scale),
)),
gltf::scene::Transform::Matrix { .. } => Err(Error::msg(format!(
"places `{}` by a matrix, which Mirage does not read; export it as a position, a \
turn and a scale",
node.name().unwrap_or_default()
))),
}
}
struct Joints {
joints: Vec<Joint>,
at: HashMap<usize, (u32, Mat4)>,
}
impl Joints {
fn of(gltf: &Gltf, subtree: &Subtree<'_>, data: &[u8], asset: &str) -> Result<Self, Error> {
let moved = moved(gltf);
let bound = bound(subtree, data, asset)?;
let mut joints = Self {
joints: Vec::new(),
at: HashMap::new(),
};
for held in subtree.held() {
let node = &held.node;
let whole = node.mesh().is_some() && node.skin().is_none();
if !(whole || moved.contains(&node.index()) || bound.contains_key(&node.index())) {
continue;
}
let (placed, above) = subtree.placed(held, &joints.at);
let rest = held.rest.matrix();
let model = above * rest;
let bind = bound
.get(&node.index())
.copied()
.unwrap_or_else(|| model.inverse());
joints
.at
.insert(node.index(), (joints.joints.len() as u32, model));
joints.joints.push(Joint {
placed,
rest: held.rest,
bind,
});
}
Ok(joints)
}
fn moves(&self, node: &Node<'_>) -> Option<u32> {
self.at.get(&node.index()).map(|&(joint, _)| joint)
}
fn joined(&self, node: &Node<'_>) -> Joined {
let Some(skin) = node.skin() else {
let (joint, rest) = self
.at
.get(&node.index())
.copied()
.unwrap_or((0, Mat4::IDENTITY));
return Joined::Whole { rest, joint };
};
Joined::Skinned(
skin.joints()
.map(|joint| self.moves(&joint).unwrap_or_default())
.collect(),
)
}
}
fn moved(gltf: &Gltf) -> HashSet<usize> {
gltf.animations()
.flat_map(|animation| animation.channels())
.filter(|channel| !matches!(channel.target().property(), Property::MorphTargetWeights))
.map(|channel| channel.target().node().index())
.collect()
}
fn bound(subtree: &Subtree<'_>, data: &[u8], asset: &str) -> Result<HashMap<usize, Mat4>, Error> {
let mut bound = HashMap::new();
let skins = subtree.held().filter_map(|held| held.node.skin());
for skin in skins {
let binds = read_binds(&skin, data);
for (position, joint) in skin.joints().enumerate() {
if !subtree.holds(&joint) {
return Err(Error::msg(format!(
"skins `{asset}` to a joint that lies outside it"
)));
}
bound
.entry(joint.index())
.or_insert_with(|| binds.get(position).copied().unwrap_or(Mat4::IDENTITY));
}
}
Ok(bound)
}
fn read_binds(skin: &Skin<'_>, data: &[u8]) -> Vec<Mat4> {
skin.reader(|_| Some(data))
.read_inverse_bind_matrices()
.map(|binds| binds.map(|bind| Mat4::from_cols_array_2d(&bind)).collect())
.unwrap_or_default()
}
fn moved_by(
animation: &gltf::Animation<'_>,
joints: &Joints,
data: &[u8],
) -> Result<NamedAnimation, Error> {
let name = animation.name().unwrap_or_default().to_owned();
let mut tracks: Vec<Track> = Vec::new();
let mut repeated: Option<String> = None;
for channel in animation.channels() {
let node = channel.target().node();
let Some(joint) = joints.moves(&node) else {
continue;
};
let Some(track) = track(&channel, joint, data, &name)? else {
continue;
};
let differs = tracks
.iter()
.find(|kept| kept.same_path(&track))
.map(|kept| !kept.same_curve(&track));
match differs {
None => tracks.push(track),
Some(false) => {}
Some(true) => {
let node = node.name().unwrap_or_default().to_owned();
log::debug!(
"the animation `{name}` moves the node `{node}` along two curves at once; no \
clip can read it"
);
repeated.get_or_insert(node);
}
}
}
Ok(NamedAnimation {
name,
moves: match repeated {
Some(node) => Readable::Repeated { node },
None => Readable::Tracks(Animation::new(tracks)),
},
})
}
fn track(
channel: &Channel<'_>,
joint: u32,
data: &[u8],
animation: &str,
) -> Result<Option<Track>, Error> {
let reader = channel.reader(|_| Some(data));
let (Some(times), Some(values)) = (reader.read_inputs(), reader.read_outputs()) else {
return Ok(None);
};
let times: Vec<f32> = times.collect();
let between = channel.sampler().interpolation();
let moves = match values {
ReadOutputs::Translations(values) => keys(
between,
×,
&values.map(Vec3::from).collect::<Vec<Vec3>>(),
)
.map(Moves::Position),
ReadOutputs::Rotations(values) => keys(
between,
×,
&values
.into_f32()
.map(Quat::from_array)
.collect::<Vec<Quat>>(),
)
.map(Moves::Turn),
ReadOutputs::Scales(values) => keys(
between,
×,
&values.map(Vec3::from).collect::<Vec<Vec3>>(),
)
.map(Moves::Scale),
ReadOutputs::MorphTargetWeights(_) => {
log::debug!("an animation moves a mesh's own shape, which Mirage does not read");
return Ok(None);
}
};
match moves {
Some(moves) => Ok(Some(Track { joint, moves })),
None => Err(Error::msg(format!(
"gives `{animation}` another count of keys than of key times"
))),
}
}
fn keys<T: Copy>(
between: gltf::animation::Interpolation,
times: &[f32],
values: &[T],
) -> Option<Keys<T>> {
match between {
gltf::animation::Interpolation::Step => paired(times, values).map(Keys::Step),
gltf::animation::Interpolation::Linear => paired(times, values).map(Keys::Linear),
gltf::animation::Interpolation::CubicSpline => tripled(times, values).map(Keys::Cubic),
}
}
fn paired<T: Copy>(times: &[f32], values: &[T]) -> Option<Vec<(f32, T)>> {
(times.len() == values.len())
.then(|| times.iter().copied().zip(values.iter().copied()).collect())
}
fn tripled<T: Copy>(times: &[f32], values: &[T]) -> Option<Vec<(f32, [T; 3])>> {
(values.len() == 3 * times.len()).then(|| {
let keys = values
.chunks_exact(3)
.filter_map(|key| <[T; 3]>::try_from(key).ok());
times.iter().copied().zip(keys).collect()
})
}
fn tint(primitive: &Primitive<'_>) -> Material {
let material = primitive.material();
let pbr = material.pbr_metallic_roughness();
let [red, green, blue, alpha] = pbr.base_color_factor();
let [emissive_red, emissive_green, emissive_blue] = material.emissive_factor();
let mode = material.alpha_mode();
let shaded = Material::lit(Color::rgba(red, green, blue, opacity(mode, alpha)))
.roughness(pbr.roughness_factor())
.metallic(pbr.metallic_factor())
.emissive(Color::rgb(emissive_red, emissive_green, emissive_blue));
match mode {
AlphaMode::Mask => cut_out(shaded, material.alpha_cutoff()),
AlphaMode::Blend | AlphaMode::Opaque => shaded,
}
}
fn opacity(mode: AlphaMode, alpha: f32) -> f32 {
match mode {
AlphaMode::Blend => alpha,
AlphaMode::Opaque | AlphaMode::Mask => 1.0,
}
}
fn cut_out(material: Material, cutoff: Option<f32>) -> Material {
if cutoff.is_some_and(|declared| declared != CUTOUT) {
log::debug!(
"a material is masked at an alpha Mirage does not keep; it is drawn at {CUTOUT}"
);
}
material.cutout()
}
fn base_color(primitive: &Primitive<'_>, images: &[TextureData]) -> Option<TextureData> {
let base_color = primitive
.material()
.pbr_metallic_roughness()
.base_color_texture()?;
one_uv_set("a base color", base_color.tex_coord());
images.get(base_color.texture().source().index()).cloned()
}
fn emissive(primitive: &Primitive<'_>, images: &[TextureData]) -> Option<TextureData> {
let emissive = primitive.material().emissive_texture()?;
one_uv_set("an emissive", emissive.tex_coord());
images.get(emissive.texture().source().index()).cloned()
}
fn relief(primitive: &Primitive<'_>, images: &[TextureData]) -> Option<ReliefData> {
let normal = primitive.material().normal_texture()?;
one_uv_set("a normal", normal.tex_coord());
if normal.scale() != 1.0 {
log::debug!("a material scales its normal texture; Mirage reads it at 1.0");
}
let image = images.get(normal.texture().source().index())?;
Some(ReliefData::normals(image.size(), image.pixels().to_vec()))
}
fn shading(primitive: &Primitive<'_>, images: &[TextureData]) -> Option<ShadingData> {
let material = primitive.material();
let occlusion = material.occlusion_texture();
let strength = occlusion.as_ref().map_or(1.0, |texture| texture.strength());
let occluding = occlusion.and_then(|texture| {
one_uv_set("an occlusion", texture.tex_coord());
images.get(texture.texture().source().index())
});
let rough = material
.pbr_metallic_roughness()
.metallic_roughness_texture()
.and_then(|texture| {
one_uv_set("a metallic-roughness", texture.tex_coord());
images.get(texture.texture().source().index())
});
if occluding.is_none() && rough.is_none() {
return None;
}
Some(merged(occluding, rough, strength))
}
fn one_uv_set(what: &str, set: u32) {
if set != 0 {
log::warn!("{what} texture reads a UV set Mirage does not keep; it will look wrong");
}
}
fn merged(
occluding: Option<&TextureData>,
rough: Option<&TextureData>,
strength: f32,
) -> ShadingData {
let size = extent(occluding).max(extent(rough));
let pixels = (0..size.y)
.flat_map(|down| (0..size.x).map(move |across| UVec2::new(across, down)))
.flat_map(|at| {
[
occluded(sampled(occluding, size, at, 0), strength),
sampled(rough, size, at, 1),
sampled(rough, size, at, 2),
u8::MAX,
]
})
.collect();
ShadingData::rgba8(size, pixels)
}
fn extent(texture: Option<&TextureData>) -> UVec2 {
texture.map_or(UVec2::ZERO, TextureData::size)
}
fn sampled(texture: Option<&TextureData>, size: UVec2, at: UVec2, channel: usize) -> u8 {
let Some(texture) = texture else {
return u8::MAX;
};
let across = texture.size().x;
let scaled = at * texture.size() / size;
texture
.pixels()
.get((4 * (scaled.y * across + scaled.x)) as usize + channel)
.copied()
.unwrap_or(u8::MAX)
}
fn occluded(texel: u8, strength: f32) -> u8 {
let whole = f32::from(u8::MAX);
(whole - strength * (whole - f32::from(texel))).round() as u8
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
use crate::assets::testing::{error, geometry};
use crate::assets::{Assets, BEACON, file};
use crate::math::UVec2;
use crate::mesh::{Clip, Geometry, NoClips, NoParts, Part};
const OCCLUDING: [u8; 4] = [40, 0, 0, u8::MAX];
const ROUGH: [u8; 4] = [0, 120, 200, u8::MAX];
const MERGED: [u8; 4] = [40, 120, 200, u8::MAX];
const TURNED: [u8; 4] = [128, 218, 218, u8::MAX];
const BUMPY: &str = r#"{"name":"Panel","normalTexture":{"index":0}}"#;
const SCALED: &str = r#"{"name":"Panel","normalTexture":{"index":0,"scale":0.5}}"#;
const VIEWS: &str = r#"{"buffer":0,"byteOffset":0,"byteLength":36},
{"buffer":0,"byteOffset":36,"byteLength":64}"#;
const POSITIONS: &str = r#"{"bufferView":0,"componentType":5126,"count":3,
"type":"VEC3","min":[0,0,0],"max":[1,1,0]}"#;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum Face {
Panels,
Caps,
}
impl Part for Face {
fn from_name(name: &str) -> Option<Self> {
match name {
"Beacon Panels" => Some(Self::Panels),
"Beacon Caps" => Some(Self::Caps),
_ => None,
}
}
fn all() -> Vec<Self> {
vec![Self::Panels, Self::Caps]
}
fn index(&self) -> u32 {
match self {
Self::Panels => 0,
Self::Caps => 1,
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct Panels;
impl Part for Panels {
fn from_name(name: &str) -> Option<Self> {
(name == "Beacon Panels").then_some(Self)
}
fn all() -> Vec<Self> {
vec![Self]
}
fn index(&self) -> u32 {
0
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct Both;
impl Part for Both {
fn from_name(name: &str) -> Option<Self> {
name.starts_with("Beacon ").then_some(Self)
}
fn all() -> Vec<Self> {
vec![Self]
}
fn index(&self) -> u32 {
0
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum Renamed {
Panels,
Wing,
}
impl Part for Renamed {
fn from_name(name: &str) -> Option<Self> {
match name {
"Beacon Panels" => Some(Self::Panels),
"Wing" => Some(Self::Wing),
_ => None,
}
}
fn all() -> Vec<Self> {
vec![Self::Panels, Self::Wing]
}
fn index(&self) -> u32 {
match self {
Self::Panels => 0,
Self::Wing => 1,
}
}
}
fn loaded() -> Assets {
Assets::load([file("hello.glb", BEACON)]).expect("the example's model decodes")
}
#[test]
fn a_source_decodes_to_slots_carrying_their_own_key_tint_and_texture() {
let assets = loaded();
let beacon = assets.mesh::<Face>("beacon");
assert_eq!(beacon.vertices().len(), 24, "six faces of four corners");
assert_eq!(beacon.indices().len(), 36);
let [panels, caps] = beacon.slots() else {
panic!("the two primitives became two slots");
};
assert_eq!(
(panels.part(), panels.index_count()),
(Some(Face::Panels.index()), 24)
);
assert_eq!(
(caps.part(), caps.index_count()),
(Some(Face::Caps.index()), 12)
);
let built = geometry(beacon.clone());
assert_eq!(built.part_indices(0), 0..24, "the four sides come first");
assert_eq!(built.part_indices(1), 24..36);
assert_eq!(
caps.material(),
Material::lit(Color::rgba(0.9, 0.45, 0.1, 1.0)).roughness(0.5),
"the base color factor becomes the slot's tint"
);
assert_eq!(
panels.material(),
Material::lit(Color::WHITE).roughness(0.5),
"and a textured material that declares none tints with white"
);
let pixels = panels
.texture()
.expect("the sides sample the embedded image");
assert_eq!(pixels.size(), UVec2::splat(16));
assert_eq!(pixels.pixels().len(), 16 * 16 * 4);
assert!(
caps.texture().is_none(),
"the caps sample the built-in white"
);
}
#[test]
fn a_named_image_is_pullable_on_its_own() {
let assets = loaded();
let mut panels = assets.texture("beacon_panels");
assert_eq!(panels.size(), UVec2::splat(16));
assert!(
panels.take_unresolved().is_empty(),
"the mesh's name and the image's both resolved"
);
}
#[test]
fn a_material_the_vocabulary_does_not_name_stays_exactly_as_authored() {
let assets = loaded();
let beacon = assets.mesh::<Panels>("beacon");
let [panels, caps] = beacon.slots() else {
panic!("both primitives are still drawn");
};
assert_eq!(panels.part(), Some(0), "the named half is addressed");
assert_eq!(caps.part(), None, "the other half is anonymous");
assert_eq!(
caps.material(),
Material::lit(Color::rgba(0.9, 0.45, 0.1, 1.0)).roughness(0.5),
"and keeps what the file gave it"
);
assert_eq!(
geometry(beacon).part_count(),
2,
"a material the game never names is the artist's business"
);
}
#[test]
fn a_mesh_with_no_parts_loads_a_multi_material_model_with_every_slot_anonymous() {
let assets = loaded();
let beacon = assets.mesh::<NoParts>("beacon");
assert_eq!(beacon.slots().len(), 2);
assert!(
beacon.slots().iter().all(|slot| slot.part().is_none()),
"no part names any slot of it"
);
assert!(
beacon.slots()[0].texture().is_some(),
"the sides keep their skin"
);
assert_eq!(geometry(beacon).part_count(), 2);
}
#[test]
fn two_materials_resolving_to_one_part_join_the_startup_error() {
let assets = loaded();
let beacon = assets.mesh::<Both>("beacon");
assert_eq!(
beacon.slots().len(),
0,
"a part is one slot, and a draw could not say which it meant"
);
assert_eq!(
error(beacon),
"the asset `beacon` has two materials that resolve to the part Both: \
`Beacon Panels` and `Beacon Caps`"
);
}
#[test]
fn a_part_no_material_resolves_to_joins_the_startup_error() {
let assets = loaded();
let beacon = assets.mesh::<Renamed>("beacon");
assert_eq!(
beacon.slots().len(),
0,
"a draw of it could name a part it does not have"
);
assert_eq!(
error(beacon),
"the asset `beacon` has no material that resolves to the part Wing"
);
}
fn container(json: &str, binary: &[u8]) -> Vec<u8> {
let mut json = json.as_bytes().to_vec();
json.resize(json.len().next_multiple_of(4), b' ');
let mut binary = binary.to_vec();
binary.resize(binary.len().next_multiple_of(4), 0);
let length = (28 + json.len() + binary.len()) as u32;
let mut glb = Vec::with_capacity(length as usize);
glb.extend(b"glTF");
glb.extend(2u32.to_le_bytes());
glb.extend(length.to_le_bytes());
glb.extend((json.len() as u32).to_le_bytes());
glb.extend(b"JSON");
glb.extend(&json);
glb.extend((binary.len() as u32).to_le_bytes());
glb.extend(b"BIN\0");
glb.extend(&binary);
glb
}
#[test]
fn a_primitive_with_no_normals_is_shaded_flat_as_the_format_says() {
let corners = [Vec3::ZERO, Vec3::X, Vec3::Y];
let source = container(
r#"{"asset":{"version":"2.0"},
"buffers":[{"byteLength":36}],
"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":36}],
"accessors":[{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0]}],
"meshes":[{"name":"wedge","primitives":[{"attributes":{"POSITION":0},"mode":4}]}]}"#,
bytemuck::cast_slice(&corners),
);
let assets = Assets::load([file("wedge.glb", &source)])
.expect("geometry without normals still decodes");
let wedge = assets.mesh::<NoParts>("wedge");
assert_eq!(
wedge.vertices().len(),
3,
"flat shading needs a vertex of its own per corner"
);
assert!(
wedge
.vertices()
.iter()
.all(|corner| corner.normal == Vec3::Z),
"each carrying the triangle's own facing, not a guess"
);
assert_eq!(wedge.indices(), [0, 1, 2]);
}
#[test]
fn a_primitive_with_a_normal_missing_does_not_decode() {
let mut buffer = vec![Vec3::ZERO, Vec3::X, Vec3::Y];
buffer.extend([Vec3::Z, Vec3::Z]);
let source = container(
r#"{"asset":{"version":"2.0"},
"buffers":[{"byteLength":60}],
"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":36},
{"buffer":0,"byteOffset":36,"byteLength":24}],
"accessors":[{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3",
"min":[0,0,0],"max":[1,1,1]},
{"bufferView":1,"componentType":5126,"count":2,"type":"VEC3"}],
"meshes":[{"name":"wedge","primitives":[
{"attributes":{"POSITION":0,"NORMAL":1},"mode":4}]}]}"#,
bytemuck::cast_slice(&buffer),
);
let Err(error) = Assets::load([file("wedge.glb", &source)]) else {
panic!("one corner is shaded by nothing");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: the asset source `wedge.glb` gives `wedge` \
normals its positions do not match"
);
}
#[test]
fn only_a_blended_material_keeps_the_alpha_its_base_color_declares_and_a_masked_one_cuts_out() {
let corners = [Vec3::ZERO, Vec3::X, Vec3::Y];
let pane = |material: u32| {
format!(r#"{{"attributes":{{"POSITION":0}},"mode":4,"material":{material}}}"#)
};
let source = container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"buffers":[{{"byteLength":36}}],
"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":36}}],
"accessors":[{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0]}}],
"materials":[
{{"name":"Glass","alphaMode":"BLEND",
"pbrMetallicRoughness":{{"baseColorFactor":[1,1,1,0.25]}}}},
{{"name":"Wall","alphaMode":"OPAQUE",
"pbrMetallicRoughness":{{"baseColorFactor":[1,1,1,0.25]}}}},
{{"name":"Leaf","alphaMode":"MASK","alphaCutoff":0.25,
"pbrMetallicRoughness":{{"baseColorFactor":[1,1,1,0.25]}}}}],
"meshes":[{{"name":"panes","primitives":[{},{},{}]}}]}}"#,
pane(0),
pane(1),
pane(2),
),
bytemuck::cast_slice(&corners),
);
let assets = Assets::load([file("panes.glb", &source)])
.expect("three materials over one triangle each");
let panes = assets.mesh::<NoParts>("panes");
let materials: Vec<Material> = panes.slots().iter().map(|slot| slot.material()).collect();
let alpha: Vec<f32> = materials.iter().map(|kept| kept.tint().alpha).collect();
assert_eq!(
alpha,
[0.25, 1.0, 1.0],
"blended keeps it, opaque and masked are drawn whole"
);
assert_eq!(
materials.iter().map(Material::cuts).collect::<Vec<bool>>(),
[false, false, true],
"and only the masked one drops the texels its alpha leaves out, \
at the one alpha Mirage keeps rather than the declared 0.25"
);
}
#[test]
fn a_material_carries_its_roughness_its_metallic_and_its_emissive_factors_over() {
let corners = [Vec3::ZERO, Vec3::X, Vec3::Y];
let pane = |material: u32| {
format!(r#"{{"attributes":{{"POSITION":0}},"mode":4,"material":{material}}}"#)
};
let source = container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"buffers":[{{"byteLength":36}}],
"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":36}}],
"accessors":[{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0]}}],
"materials":[
{{"name":"Gold","emissiveFactor":[0.25,0.5,0.75],
"pbrMetallicRoughness":{{"roughnessFactor":0.2,"metallicFactor":0.9}}}},
{{"name":"Plain"}}],
"meshes":[{{"name":"panes","primitives":[{},{}]}}]}}"#,
pane(0),
pane(1),
),
bytemuck::cast_slice(&corners),
);
let assets = Assets::load([file("panes.glb", &source)])
.expect("two materials over one triangle each");
let panes = assets.mesh::<NoParts>("panes");
let [gold, plain] = panes.slots() else {
panic!("the two primitives became two slots");
};
assert_eq!(
gold.material(),
Material::lit(Color::WHITE)
.roughness(0.2)
.metallic(0.9)
.emissive(Color::rgb(0.25, 0.5, 0.75)),
"each factor lands in the lane of its own name"
);
assert_eq!(
plain.material(),
Material::lit(Color::WHITE).roughness(1.0).metallic(1.0),
"and a material declaring none of them is the fully rough metal glTF states"
);
}
fn png(texel: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(1, 1, image::Rgba(texel))
.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
.expect("the fixture encodes");
out
}
fn mapped(material: &str, texels: &[[u8; 4]]) -> Vec<u8> {
let corners = [Vec3::ZERO, Vec3::X, Vec3::Y];
let mut binary: Vec<u8> = bytemuck::cast_slice(&corners).to_vec();
let mut views = vec![r#"{"buffer":0,"byteOffset":0,"byteLength":36}"#.to_owned()];
for image in texels.iter().map(|&texel| png(texel)) {
binary.resize(binary.len().next_multiple_of(4), 0);
views.push(format!(
r#"{{"buffer":0,"byteOffset":{},"byteLength":{}}}"#,
binary.len(),
image.len()
));
binary.extend(image);
}
let images = (1..views.len())
.map(|view| format!(r#"{{"bufferView":{view},"mimeType":"image/png"}}"#))
.collect::<Vec<String>>();
let textures = (0..images.len())
.map(|source| format!(r#"{{"source":{source}}}"#))
.collect::<Vec<String>>();
container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"buffers":[{{"byteLength":{}}}],
"bufferViews":[{}],
"accessors":[{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0]}}],
"images":[{}],
"textures":[{}],
"materials":[{material}],
"meshes":[{{"name":"panel","primitives":[
{{"attributes":{{"POSITION":0}},"mode":4,"material":0}}]}}]}}"#,
binary.len(),
views.join(","),
images.join(","),
textures.join(","),
),
&binary,
)
}
fn panel(material: &str, texels: &[[u8; 4]]) -> Geometry {
let assets = Assets::load([file("panel.glb", &mapped(material, texels))])
.expect("one triangle over its own images decodes");
geometry(assets.mesh::<NoParts>("panel"))
}
fn merged(texel: [u8; 4]) -> ShadingData {
ShadingData::rgba8(UVec2::ONE, texel.to_vec())
}
#[test]
fn an_occlusion_and_a_metallic_roughness_texture_merge_into_one_shading_map() {
let spread = panel(
r#"{"name":"Panel","occlusionTexture":{"index":0},
"pbrMetallicRoughness":{"metallicRoughnessTexture":{"index":1}}}"#,
&[OCCLUDING, ROUGH],
);
let shared = panel(
r#"{"name":"Panel","occlusionTexture":{"index":0},
"pbrMetallicRoughness":{"metallicRoughnessTexture":{"index":0}}}"#,
&[MERGED],
);
assert_eq!(
spread.part_shading(0),
Some(&merged(MERGED)),
"each channel comes out of the texture that declared it"
);
assert_eq!(
shared.part_shading(0),
Some(&merged(MERGED)),
"and a source spreading them over one texture reads the same map"
);
}
#[test]
fn a_declared_occlusion_strength_is_scaled_into_the_merged_map() {
let half = panel(
r#"{"name":"Panel","occlusionTexture":{"index":0,"strength":0.5}}"#,
&[OCCLUDING],
);
let whole = panel(
r#"{"name":"Panel","occlusionTexture":{"index":0}}"#,
&[OCCLUDING],
);
assert_eq!(
half.part_shading(0),
Some(&merged([148, u8::MAX, u8::MAX, u8::MAX])),
"half the strength takes the texel half the way to the whole sky"
);
assert_eq!(
whole.part_shading(0),
Some(&merged([40, u8::MAX, u8::MAX, u8::MAX])),
"and a source declaring none keeps the texel and scales no other lane"
);
}
#[test]
fn an_emissive_texture_becomes_the_slots_emissive_map() {
let glowing = panel(
r#"{"name":"Panel","emissiveTexture":{"index":0}}"#,
&[ROUGH],
);
let plain = panel(r#"{"name":"Panel"}"#, &[ROUGH]);
assert_eq!(
glowing.part_emissive(0).map(TextureData::pixels),
Some(&ROUGH[..]),
"the light the surface casts is read per texel"
);
assert!(
plain.part_emissive(0).is_none() && plain.part_shading(0).is_none(),
"and a material declaring no map carries none, which draws as \
glTF's own defaults state"
);
}
#[test]
fn a_normal_texture_becomes_a_relief_that_holds_no_depth() {
let bumpy = panel(BUMPY, &[TURNED]);
let plain = panel(r#"{"name":"Panel"}"#, &[TURNED]);
let read = bumpy.part_relief(0).expect("the normal texture is read");
assert_eq!(
read,
&ReliefData::normals(UVec2::ONE, TURNED.to_vec()),
"the texels are read as the source holds them"
);
assert!(
!read.deep(),
"as normals alone, whatever the alpha byte beside them holds"
);
assert!(
plain.part_relief(0).is_none(),
"and a material declaring no normal texture carries no relief"
);
}
#[test]
fn a_declared_normal_scale_is_read_as_one() {
let scaled = panel(SCALED, &[TURNED]);
let whole = panel(BUMPY, &[TURNED]);
assert_eq!(
scaled.part_relief(0),
whole.part_relief(0),
"a scale Mirage does not keep leaves the relief the source's own"
);
}
#[test]
fn an_image_whose_view_reaches_past_what_a_number_holds_is_read_as_truncated() {
let source = container(
r#"{"asset":{"version":"2.0"},
"buffers":[{"byteLength":4}],
"bufferViews":[{"buffer":0,"byteOffset":18446744073709551615,"byteLength":4}],
"images":[{"name":"skin","bufferView":0,"mimeType":"image/png"}]}"#,
&[0; 4],
);
let Err(error) = Assets::load([file("skin.glb", &source)]) else {
panic!("no image starts where that view says");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: the asset source `skin.glb` is truncated: an \
image runs past its binary chunk"
);
}
fn refused(bytes: &[u8]) -> String {
decode("source", bytes)
.err()
.map_or_else(|| "decodes".to_owned(), |error| error.to_string())
}
fn wedge(views: &str, accessors: &str, primitive: &str) -> Vec<u8> {
let mut buffer: Vec<u8> = bytemuck::cast_slice(&[Vec3::ZERO, Vec3::X, Vec3::Y]).to_vec();
buffer.extend([0; 64]);
container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"buffers":[{{"byteLength":100}}],
"bufferViews":[{views}],
"accessors":[{accessors}],
"meshes":[{{"name":"wedge","primitives":[{primitive}]}}]}}"#
),
&buffer,
)
}
#[test]
fn a_source_declaring_less_than_it_starts_with_does_not_decode() {
let mut source = container(r#"{"asset":{"version":"2.0"}}"#, &[0; 8]);
source[8..12].copy_from_slice(&3u32.to_le_bytes());
let Err(error) = Assets::load([file("short.glb", &source)]) else {
panic!("nothing is that short");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: the asset source `short.glb` is truncated: it \
declares less than it starts with"
);
}
#[test]
fn a_source_cut_off_anywhere_fails_rather_than_reading_past_its_own_end() {
for at in 0..BEACON.len() {
assert_ne!(refused(&BEACON[..at]), "decodes", "a cut at {at}");
}
}
#[test]
fn a_mesh_written_out_of_numbers_no_reader_takes_does_not_decode() {
let bent = |attribute: &str, declared: &str| {
let source = wedge(
VIEWS,
&format!(r#"{POSITIONS},{{"bufferView":1,{declared},"count":3}}"#),
&format!(r#"{{"attributes":{{"POSITION":0,"{attribute}":1}},"mode":4}}"#),
);
refused(&source)
};
assert_eq!(
bent("NORMAL", r#""componentType":5120,"type":"VEC3""#),
"writes `wedge`'s normals in a form Mirage cannot read"
);
assert_eq!(
bent("TEXCOORD_0", r#""componentType":5120,"type":"VEC2""#),
"writes `wedge`'s uvs in a form Mirage cannot read"
);
let indexed = wedge(
VIEWS,
&format!(
r#"{POSITIONS},{{"bufferView":1,"componentType":5126,"type":"SCALAR","count":3}}"#
),
r#"{"attributes":{"POSITION":0},"indices":1,"mode":4}"#,
);
assert_eq!(
refused(&indexed),
"writes `wedge`'s corners in a form Mirage cannot read",
"corners are whole numbers"
);
}
#[test]
fn an_accessor_naming_bytes_its_source_does_not_hold_does_not_decode() {
const HELD: &str = r#"{"buffer":0,"byteOffset":0,"byteLength":36}"#;
let primitive = r#"{"attributes":{"POSITION":0},"mode":4}"#;
let past = |views: &str, accessors: &str| refused(&wedge(views, accessors, primitive));
let says = "writes `wedge`'s positions in a form Mirage cannot read";
for accessor in [
r#"{"bufferView":0,"componentType":5126,"count":0,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0]}"#,
r#"{"bufferView":0,"componentType":5126,"count":1073741824,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0]}"#,
r#"{"bufferView":0,"byteOffset":18446744073709551615,"componentType":5126,
"count":3,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}"#,
r#"{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3",
"min":[0,0,0],"max":[1,1,0],
"sparse":{"count":0,
"indices":{"bufferView":0,"byteOffset":0,"componentType":5125},
"values":{"bufferView":0,"byteOffset":0}}}"#,
] {
assert_eq!(past(HELD, accessor), says, "over {accessor}");
}
assert_eq!(
past(
r#"{"buffer":0,"byteOffset":18446744073709551615,"byteLength":36}"#,
POSITIONS
),
says,
"a view starting past what a number holds"
);
assert_eq!(
past(
r#"{"buffer":0,"byteOffset":0,"byteLength":36,"byteStride":4}"#,
POSITIONS
),
says,
"and one whose steps are shorter than what it steps over"
);
}
#[test]
fn an_image_a_source_does_not_hold_whole_does_not_decode() {
let named = |image: &str| {
let source = container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"buffers":[{{"byteLength":4}}],
"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":4}}],
"images":[{image}]}}"#
),
&[0; 4],
);
refused(&source)
};
assert_eq!(
named(r#"{"name":"skin","bufferView":0}"#),
"holds an image and never says what kind"
);
assert_eq!(
named(r#"{"name":"skin"}"#),
"holds an image with nothing in it"
);
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct Turn;
impl Clip for Turn {
fn from_name(name: &str) -> Option<Self> {
(name == "turn").then_some(Self)
}
fn all() -> Vec<Self> {
vec![Self]
}
fn index(&self) -> u32 {
0
}
}
fn rack(channels: &str, samplers: &str) -> Vec<u8> {
let mut buffer: Vec<f32> = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
buffer.extend([0.0, 1.0]);
buffer.extend([0.0, 0.0, 0.0, 0.0, 2.0, 0.0]);
buffer.extend([0.0, 0.0, 0.0, 0.0, 0.0, 3.0]);
container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"extensionsRequired":["KHR_materials_unlit"],
"extensionsUsed":["KHR_materials_unlit"],
"buffers":[{{"byteLength":92}}],
"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":36}},
{{"buffer":0,"byteOffset":36,"byteLength":8}},
{{"buffer":0,"byteOffset":44,"byteLength":24}},
{{"buffer":0,"byteOffset":68,"byteLength":24}}],
"accessors":[{POSITIONS},
{{"bufferView":1,"componentType":5126,"count":2,"type":"SCALAR"}},
{{"bufferView":2,"componentType":5126,"count":2,"type":"VEC3"}},
{{"bufferView":3,"componentType":5126,"count":2,"type":"VEC3"}}],
"meshes":[{{"name":"BoxMesh","primitives":[
{{"attributes":{{"POSITION":0}},"mode":4}}]}}],
"nodes":[{{"name":"Box","mesh":0}},
{{"name":"Pivot","children":[0]}},
{{"name":"Rack","children":[1]}}],
"scenes":[{{"nodes":[2]}}],
"animations":[{{"name":"turn",
"channels":[{{"sampler":0,"target":{{"node":1,"path":"translation"}}}}
{channels}],
"samplers":[{{"input":1,"output":2,"interpolation":"LINEAR"}}
{samplers}]}}]}}"#
),
bytemuck::cast_slice(&buffer),
)
}
fn racked(channels: &str, samplers: &str) -> Assets {
Assets::load([file("rack.glb", &rack(channels, samplers))])
.expect("a source that requires an extension Mirage lacks still decodes")
}
#[test]
fn a_node_an_animation_moves_is_a_joint_whatever_it_holds() {
let assets = racked("", "");
let rack = geometry(assets.model::<NoParts, Turn>("Rack"));
let joints = rack.rig().joints();
assert_eq!(
joints.len(),
2,
"the node the clip moves, and the mesh node"
);
assert!(
matches!(joints[0].placed, Placed::Within(_)),
"the moved node hangs under no joint of its own"
);
assert_eq!(joints[1].placed, Placed::Under(0), "and the mesh under it");
assert_eq!(rack.clips()[0].tracks().len(), 1);
}
#[test]
fn two_curves_on_one_node_stop_startup_only_where_a_clip_names_the_animation() {
const REPEATED: &str = r#",{"sampler":1,"target":{"node":1,"path":"translation"}}"#;
const OTHER: &str = r#",{"input":1,"output":3,"interpolation":"LINEAR"}"#;
let read = racked(REPEATED, OTHER);
assert!(
geometry(read.model::<NoParts, NoClips>("Rack"))
.clips()
.is_empty(),
"a game that names no clip of it draws it as it always did"
);
let named = racked(REPEATED, OTHER);
let rack = named.model::<NoParts, Turn>("Rack");
assert_eq!(rack.slots().len(), 0);
assert_eq!(
error(rack),
"the asset `Rack` has the animation `turn` for the clip Turn, which moves the \
node `Pivot` along two curves at once"
);
}
#[test]
fn a_channel_stating_another_count_of_keys_than_of_times_does_not_decode() {
const MISMATCHED: &str = r#",{"sampler":1,"target":{"node":0,"path":"translation"}}"#;
const OVER: &str = r#",{"input":1,"output":0,"interpolation":"LINEAR"}"#;
let Err(error) = Assets::load([file("rack.glb", &rack(MISMATCHED, OVER))]) else {
panic!("three values at two times is no curve, and never a trim");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: the asset source `rack.glb` gives `turn` \
another count of keys than of key times"
);
}
fn skinned(takes: u16) -> Vec<u8> {
let corners = [Vec3::ZERO, Vec3::X, Vec3::Y];
let mut binary: Vec<u8> = bytemuck::cast_slice(&corners).to_vec();
binary.extend(bytemuck::cast_slice(&[takes, 0, 0, 0].repeat(3)));
binary.extend(bytemuck::cast_slice(&[1.0f32, 0.0, 0.0, 0.0].repeat(3)));
container(
&format!(
r#"{{"asset":{{"version":"2.0"}},
"buffers":[{{"byteLength":108}}],
"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":36}},
{{"buffer":0,"byteOffset":36,"byteLength":24}},
{{"buffer":0,"byteOffset":60,"byteLength":48}}],
"accessors":[{POSITIONS},
{{"bufferView":1,"componentType":5123,"count":3,"type":"VEC4"}},
{{"bufferView":2,"componentType":5126,"count":3,"type":"VEC4"}}],
"meshes":[{{"name":"BodyMesh","primitives":[
{{"attributes":{{"POSITION":0,"JOINTS_0":1,"WEIGHTS_0":2}},"mode":4}}]}}],
"nodes":[{{"name":"Bone"}},
{{"name":"Body","mesh":0,"skin":0}},
{{"name":"Rig","children":[0,1]}}],
"skins":[{{"joints":[0]}}],
"scenes":[{{"nodes":[2]}}]}}"#
),
&binary,
)
}
#[test]
fn a_vertex_taking_a_joint_its_skin_does_not_hold_does_not_decode() {
let assets = Assets::load([file("held.glb", &skinned(0))])
.expect("the one joint the skin holds is the one its corners take");
assert_eq!(
geometry(assets.model::<NoParts, NoClips>("Rig"))
.rig()
.joints()
.len(),
1
);
let Err(error) = Assets::load([file("past.glb", &skinned(1))]) else {
panic!("the second joint of a skin that holds one");
};
assert_eq!(
error.to_string(),
"the game's asset sources did not load: the asset source `past.glb` skins `Rig` to a \
joint its skin does not hold"
);
}
#[test]
fn a_source_that_is_not_a_container_fails_to_decode() {
let Err(error) = Assets::load([file("junk.glb", b"not a container at all")]) else {
panic!("nothing decodes that");
};
assert!(
error.to_string().starts_with(
"the game's asset sources did not load: the asset source `junk.glb` did not \
decode"
),
"got {error}"
);
}
}