use super::decode_obj_vertices_for_async;
use super::error::AssetError;
use gizmo_animation::skeletal::{AnimationClip, Keyframe, SkeletonHierarchy, SkeletonJoint, Track};
use crate::components::{Material, Mesh};
use crate::renderer::Vertex;
use gizmo_math::{Quat, Vec3};
use std::sync::Arc;
use wgpu::util::DeviceExt;
pub struct GltfNodeData {
pub index: usize,
pub name: Option<String>,
pub skin_index: Option<usize>,
pub translation: [f32; 3],
pub rotation: [f32; 4],
pub scale: [f32; 3],
pub primitives: Vec<(Mesh, Option<Material>)>,
pub children: Vec<GltfNodeData>,
}
pub struct GltfSceneAsset {
pub roots: Vec<GltfNodeData>,
pub animations: Vec<AnimationClip>,
pub skeletons: Vec<SkeletonHierarchy>,
}
impl super::AssetManager {
pub fn install_obj_mesh(
&mut self,
device: &wgpu::Device,
file_path: &str,
vertices: Vec<Vertex>,
_aabb: gizmo_math::Aabb,
) -> Mesh {
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("OBJ VBuf: {file_path}")),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let mesh = Mesh::new(
device,
Arc::new(vbuf),
&vertices,
Vec3::ZERO,
format!("obj:{file_path}"),
);
self.mesh_cache.insert(file_path.to_string(), mesh.clone());
mesh
}
pub fn load_obj(&mut self, device: &wgpu::Device, file_path_or_uuid: &str) -> Mesh {
let file_path = match self.resolve_path_from_meta_source(file_path_or_uuid) {
Ok(p) => p,
Err(e) => {
tracing::error!("[AssetManager] ERROR: {e}");
return self.loading_placeholder_mesh(device);
}
};
let cache_key = self
.get_uuid(&file_path)
.map(|id| id.to_string())
.unwrap_or_else(|| file_path.clone());
if let Some(cached) = self.mesh_cache.get(&cache_key) {
return cached.clone();
}
let (vertices, aabb) = match decode_obj_vertices_for_async(&file_path) {
Ok(v) => v,
Err(e) => {
tracing::error!("[AssetManager] OBJ load failed: {file_path} — {e}");
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Fallback VBuf (not found)"),
contents: &[],
usage: wgpu::BufferUsages::VERTEX,
});
return Mesh::empty(Arc::new(vbuf), format!("obj:missing_{file_path}"));
}
};
self.install_obj_mesh(device, &cache_key, vertices, aabb)
}
pub fn load_gltf_scene(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
texture_bind_group_layout: &wgpu::BindGroupLayout,
default_tbind: Arc<wgpu::BindGroup>,
path_or_uuid: &str,
) -> Result<GltfSceneAsset, AssetError> {
let file_path = self.resolve_path_from_meta_source(path_or_uuid)?;
let cache_key = self
.get_uuid(&file_path)
.map(|id| id.to_string())
.unwrap_or_else(|| file_path.clone());
let import_result = if let Some(data) = self.embedded_assets.get(&file_path) {
gltf::import_slice(data.as_ref())
} else {
gltf::import(&file_path)
};
let (document, buffers, images) =
import_result.map_err(|source| AssetError::GltfImport {
path: std::path::PathBuf::from(&file_path),
source,
})?;
self.load_gltf_from_import(
device,
queue,
texture_bind_group_layout,
default_tbind,
&cache_key,
document,
buffers,
images,
)
}
pub fn load_gltf_from_import(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
texture_bind_group_layout: &wgpu::BindGroupLayout,
default_tbind: Arc<wgpu::BindGroup>,
file_path: &str,
document: gltf::Document,
buffers: Vec<gltf::buffer::Data>,
images: Vec<gltf::image::Data>,
) -> Result<GltfSceneAsset, AssetError> {
self.ensure_material_defaults(device, queue);
let srgb_flags = classify_gltf_image_srgb(&document, images.len());
let gpu_images = upload_gltf_images(device, queue, file_path, &images, &srgb_flags);
let defaults = self
.material_defaults()
.expect("material defaults ensured above");
let gltf_materials = build_gltf_materials(
device,
texture_bind_group_layout,
&document,
&gpu_images,
defaults,
&default_tbind,
);
let mut roots = Vec::new();
for scene in document.scenes() {
for node in scene.nodes() {
roots.push(self.parse_gltf_node(
device,
&node,
&buffers,
&gltf_materials,
file_path,
));
}
}
let animations = parse_animations(&document, &buffers);
let node_parents: std::collections::HashMap<usize, usize> = document
.nodes()
.flat_map(|parent| {
parent
.children()
.map(move |child| (child.index(), parent.index()))
})
.collect();
let nodes_by_index: Vec<gltf::Node> = document.nodes().collect();
let skeletons = parse_skeletons(&document, &buffers, &node_parents, &nodes_by_index);
Ok(GltfSceneAsset {
roots,
animations,
skeletons,
})
}
fn parse_gltf_node(
&mut self,
device: &wgpu::Device,
node: &gltf::Node,
buffers: &[gltf::buffer::Data],
materials: &[Material],
file_name: &str,
) -> GltfNodeData {
let (translation, rotation, scale) = node.transform().decomposed();
let mut primitives = Vec::new();
if let Some(mesh) = node.mesh() {
for (prim_i, primitive) in mesh.primitives().enumerate() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
tracing::error!(
"[GLTF WARN] Skipping non-triangle primitive (mode={:?}) on node '{}'",
primitive.mode(),
node.name().unwrap_or("<unnamed>"),
);
continue;
}
let reader = primitive.reader(|buf| Some(&buffers[buf.index()]));
let positions: Vec<[f32; 3]> = reader
.read_positions()
.map(|it| it.collect())
.unwrap_or_default();
if positions.is_empty() {
continue; }
let supplied_normals: Option<Vec<[f32; 3]>> =
reader.read_normals().map(|it| it.collect());
let supplied_tangents: Option<Vec<[f32; 4]>> =
reader.read_tangents().map(|it| it.collect());
let tex_coords: Vec<[f32; 2]> = reader
.read_tex_coords(0)
.map(|it| it.into_f32().collect())
.unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
let joints: Option<Vec<[u16; 4]>> =
reader.read_joints(0).map(|it| it.into_u16().collect());
let weights: Option<Vec<[f32; 4]>> =
reader.read_weights(0).map(|it| it.into_f32().collect());
let mut all_vertices: Vec<Vertex> = Vec::new();
let mut aabb = gizmo_math::Aabb::empty();
let make_vertex = |idx: usize| -> Vertex {
let pos = positions[idx];
let normal = supplied_normals
.as_ref()
.and_then(|n| n.get(idx).copied())
.unwrap_or([0.0, 1.0, 0.0]);
let uv = tex_coords.get(idx).copied().unwrap_or([0.0, 0.0]);
let j = joints
.as_ref()
.and_then(|js| js.get(idx))
.map(|&[a, b, c, d]| [a as u32, b as u32, c as u32, d as u32])
.unwrap_or([0; 4]);
let w = normalize_skin_weights(
weights
.as_ref()
.and_then(|ws| ws.get(idx))
.copied()
.unwrap_or([0.0; 4]),
);
let tangent = if let Some(ref tangents) = supplied_tangents {
tangents.get(idx).copied().unwrap_or([1.0, 0.0, 0.0, 1.0])
} else {
let n = gizmo_math::Vec3::from(normal);
let t = if n.x.abs() > 0.9 {
gizmo_math::Vec3::new(0.0, 1.0, 0.0).cross(n).normalize()
} else {
gizmo_math::Vec3::new(1.0, 0.0, 0.0).cross(n).normalize()
};
[t.x, t.y, t.z, 1.0]
};
Vertex {
position: pos,
normal,
tex_coords: uv,
color: [1.0, 1.0, 1.0],
joint_indices: j,
joint_weights: w,
tangent,
}
};
if let Some(indices) = reader.read_indices() {
let idx: Vec<u32> = indices.into_u32().collect();
for tri in idx.chunks_exact(3) {
if tri.iter().any(|&t| (t as usize) >= positions.len()) {
continue;
}
for &t in tri {
let i = t as usize;
let pos = positions[i];
aabb.extend(Vec3::new(pos[0], pos[1], pos[2]));
all_vertices.push(make_vertex(i));
}
}
} else {
for (i, pos) in positions.iter().enumerate() {
aabb.extend(Vec3::new(pos[0], pos[1], pos[2]));
all_vertices.push(make_vertex(i));
}
}
if supplied_normals.is_none() {
compute_flat_normals(&mut all_vertices);
}
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("GLTF VBuf: {file_name}_prim{prim_i}")),
contents: bytemuck::cast_slice(&all_vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let mesh_source = format!(
"gltf_mesh_{file_name}_{}_p{prim_i}",
node.name().unwrap_or("<unnamed>")
);
let mesh_comp = Mesh::new(
device,
Arc::new(vbuf),
&all_vertices,
Vec3::ZERO,
mesh_source.clone(),
);
self.mesh_cache.insert(mesh_source, mesh_comp.clone());
let mat_opt = primitive
.material()
.index()
.and_then(|idx| materials.get(idx).cloned());
primitives.push((mesh_comp, mat_opt));
}
}
let children = node
.children()
.map(|child| self.parse_gltf_node(device, &child, buffers, materials, file_name))
.collect();
GltfNodeData {
index: node.index(),
name: node.name().map(str::to_owned),
skin_index: node.skin().map(|s| s.index()),
translation,
rotation,
scale,
primitives,
children,
}
}
}
fn convert_image_to_rgba8(image: &gltf::image::Data, idx: usize, file_path: &str) -> Vec<u8> {
let (w, h) = (image.width as usize, image.height as usize);
let pixel_count = w * h;
match image.format {
gltf::image::Format::R8G8B8A8 => {
let expected = pixel_count * 4;
if image.pixels.len() >= expected {
image.pixels[..expected].to_vec()
} else {
let mut out = image.pixels.clone();
out.resize(expected, 255);
out
}
}
gltf::image::Format::R8G8B8 => {
let mut out = Vec::with_capacity(pixel_count * 4);
for chunk in image.pixels.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
out.resize(pixel_count * 4, 255);
out
}
gltf::image::Format::R8G8 => {
let mut out = Vec::with_capacity(pixel_count * 4);
for chunk in image.pixels.chunks_exact(2) {
out.extend_from_slice(&[chunk[0], chunk[1], 0, 255]);
}
out.resize(pixel_count * 4, 255);
out
}
gltf::image::Format::R8 => {
let mut out = Vec::with_capacity(pixel_count * 4);
for &lum in &image.pixels {
out.extend_from_slice(&[lum, lum, lum, 255]);
}
out.resize(pixel_count * 4, 255);
out
}
unknown => {
tracing::error!(
"[GLTF WARN] Unknown pixel format {unknown:?} on image {idx} in '{file_path}'. \
Falling back to RGBA8 with clamped copy."
);
let expected = pixel_count * 4;
let mut out = vec![0u8; expected];
for px in 0..pixel_count {
out[px * 4 + 3] = 255;
}
let copy_len = image.pixels.len().min(expected);
out[..copy_len].copy_from_slice(&image.pixels[..copy_len]);
out
}
}
}
fn normalize_skin_weights(w: [f32; 4]) -> [f32; 4] {
let sum = w[0] + w[1] + w[2] + w[3];
if sum > 1e-5 {
[w[0] / sum, w[1] / sum, w[2] / sum, w[3] / sum]
} else {
w
}
}
fn compute_flat_normals(vertices: &mut [Vertex]) {
for tri in vertices.chunks_exact_mut(3) {
let v0 = Vec3::from(tri[0].position);
let v1 = Vec3::from(tri[1].position);
let v2 = Vec3::from(tri[2].position);
let edge1 = v1 - v0;
let edge2 = v2 - v0;
let cross = edge1.cross(edge2);
let normal = if cross.length_squared() > 1e-10 {
cross.normalize()
} else {
Vec3::Y };
let n = [normal.x, normal.y, normal.z];
tri[0].normal = n;
tri[1].normal = n;
tri[2].normal = n;
}
}
struct GpuImage {
#[allow(dead_code)]
texture: wgpu::Texture,
view: wgpu::TextureView,
}
fn classify_gltf_image_srgb(document: &gltf::Document, num_images: usize) -> Vec<bool> {
let mut is_srgb = vec![false; num_images];
let mut mark = |idx: usize| {
if idx < is_srgb.len() {
is_srgb[idx] = true;
}
};
for material in document.materials() {
let pbr = material.pbr_metallic_roughness();
if let Some(ti) = pbr.base_color_texture() {
mark(ti.texture().source().index());
}
if let Some(ti) = material.emissive_texture() {
mark(ti.texture().source().index());
}
}
is_srgb
}
fn upload_gltf_images(
device: &wgpu::Device,
queue: &wgpu::Queue,
file_path: &str,
images: &[gltf::image::Data],
srgb_flags: &[bool],
) -> Vec<GpuImage> {
let mut out = Vec::with_capacity(images.len());
for (i, image) in images.iter().enumerate() {
let (width, height) = (image.width, image.height);
let rgba: Vec<u8> = convert_image_to_rgba8(image, i, file_path);
let texture_size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
let format = if srgb_flags.get(i).copied().unwrap_or(true) {
wgpu::TextureFormat::Rgba8UnormSrgb
} else {
wgpu::TextureFormat::Rgba8Unorm
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
size: texture_size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
label: Some(&format!("{file_path}_tex_{i}")),
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * width),
rows_per_image: Some(height),
},
texture_size,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
out.push(GpuImage { texture, view });
}
out
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct SamplerKey {
wrap_u: wgpu::AddressMode,
wrap_v: wgpu::AddressMode,
mag: wgpu::FilterMode,
min: wgpu::FilterMode,
}
impl SamplerKey {
const DEFAULT: SamplerKey = SamplerKey {
wrap_u: wgpu::AddressMode::Repeat,
wrap_v: wgpu::AddressMode::Repeat,
mag: wgpu::FilterMode::Linear,
min: wgpu::FilterMode::Linear,
};
fn from_gltf(s: &gltf::texture::Sampler) -> SamplerKey {
SamplerKey {
wrap_u: wrap_to_wgpu(s.wrap_s()),
wrap_v: wrap_to_wgpu(s.wrap_t()),
mag: mag_to_wgpu(s.mag_filter()),
min: min_to_wgpu(s.min_filter()),
}
}
}
fn wrap_to_wgpu(m: gltf::texture::WrappingMode) -> wgpu::AddressMode {
use gltf::texture::WrappingMode;
match m {
WrappingMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
WrappingMode::MirroredRepeat => wgpu::AddressMode::MirrorRepeat,
WrappingMode::Repeat => wgpu::AddressMode::Repeat,
}
}
fn mag_to_wgpu(f: Option<gltf::texture::MagFilter>) -> wgpu::FilterMode {
match f {
Some(gltf::texture::MagFilter::Nearest) => wgpu::FilterMode::Nearest,
_ => wgpu::FilterMode::Linear,
}
}
fn min_to_wgpu(f: Option<gltf::texture::MinFilter>) -> wgpu::FilterMode {
use gltf::texture::MinFilter;
match f {
Some(MinFilter::Nearest)
| Some(MinFilter::NearestMipmapNearest)
| Some(MinFilter::NearestMipmapLinear) => wgpu::FilterMode::Nearest,
_ => wgpu::FilterMode::Linear,
}
}
fn create_gltf_sampler(device: &wgpu::Device, key: SamplerKey) -> wgpu::Sampler {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("gltf_material_sampler"),
address_mode_u: key.wrap_u,
address_mode_v: key.wrap_v,
address_mode_w: wgpu::AddressMode::Repeat,
mag_filter: key.mag,
min_filter: key.min,
mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default()
})
}
fn material_sampler_key(material: &gltf::Material) -> SamplerKey {
let pbr = material.pbr_metallic_roughness();
let tex = pbr
.base_color_texture()
.map(|t| t.texture())
.or_else(|| material.normal_texture().map(|t| t.texture()))
.or_else(|| pbr.metallic_roughness_texture().map(|t| t.texture()))
.or_else(|| material.emissive_texture().map(|t| t.texture()))
.or_else(|| material.occlusion_texture().map(|t| t.texture()));
match tex {
Some(t) => SamplerKey::from_gltf(&t.sampler()),
None => SamplerKey::DEFAULT,
}
}
fn emissive_with_strength(factor: [f32; 3], strength: Option<f32>) -> [f32; 3] {
let s = strength.unwrap_or(1.0);
[factor[0] * s, factor[1] * s, factor[2] * s]
}
fn material_uv_transform(material: &gltf::Material) -> crate::gpu_types::UvTransform {
match material
.pbr_metallic_roughness()
.base_color_texture()
.and_then(|ti| ti.texture_transform())
{
Some(tt) => crate::gpu_types::UvTransform {
offset: tt.offset(),
rotation: tt.rotation(),
scale: tt.scale(),
},
None => crate::gpu_types::UvTransform::default(),
}
}
fn build_gltf_materials(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
document: &gltf::Document,
gpu_images: &[GpuImage],
defaults: &crate::asset::MaterialDefaults,
default_tbind: &Arc<wgpu::BindGroup>,
) -> Vec<Material> {
let mut sampler_cache: std::collections::HashMap<SamplerKey, wgpu::Sampler> =
std::collections::HashMap::new();
for material in document.materials() {
let key = material_sampler_key(&material);
sampler_cache
.entry(key)
.or_insert_with(|| create_gltf_sampler(device, key));
}
sampler_cache
.entry(SamplerKey::DEFAULT)
.or_insert_with(|| create_gltf_sampler(device, SamplerKey::DEFAULT));
document
.materials()
.map(|material| {
let pbr = material.pbr_metallic_roughness();
let base_color = pbr.base_color_factor();
let mat_sampler = &sampler_cache[&material_sampler_key(&material)];
let base_view = pbr
.base_color_texture()
.and_then(|ti| gpu_images.get(ti.texture().source().index()))
.map(|img| &img.view)
.unwrap_or(&defaults.white_view);
let normal_view = material
.normal_texture()
.and_then(|nt| gpu_images.get(nt.texture().source().index()))
.map(|img| &img.view)
.unwrap_or(&defaults.flat_normal_view);
let mr_view = pbr
.metallic_roughness_texture()
.and_then(|ti| gpu_images.get(ti.texture().source().index()))
.map(|img| &img.view)
.unwrap_or(&defaults.white_view);
let emissive_view = material
.emissive_texture()
.and_then(|ti| gpu_images.get(ti.texture().source().index()))
.map(|img| &img.view)
.unwrap_or(&defaults.white_view);
let ao_view = material
.occlusion_texture()
.and_then(|ot| gpu_images.get(ot.texture().source().index()))
.map(|img| &img.view)
.unwrap_or(&defaults.white_view);
let has_base = pbr.base_color_texture().is_some();
let has_any_map = has_base
|| material.normal_texture().is_some()
|| pbr.metallic_roughness_texture().is_some()
|| material.emissive_texture().is_some()
|| material.occlusion_texture().is_some();
let emissive =
emissive_with_strength(material.emissive_factor(), material.emissive_strength());
let normal_scale = material.normal_texture().map(|nt| nt.scale()).unwrap_or(1.0);
let occlusion_strength = material
.occlusion_texture()
.map(|ot| ot.strength())
.unwrap_or(1.0);
let uv_transform = material_uv_transform(&material);
let params = crate::gpu_types::MaterialParams::new(
emissive,
normal_scale,
occlusion_strength,
uv_transform,
);
let is_default_params = emissive == [0.0, 0.0, 0.0]
&& normal_scale == 1.0
&& occlusion_strength == 1.0
&& uv_transform.is_identity();
let bind_group = if !has_any_map && is_default_params {
default_tbind.clone()
} else {
let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!(
"gltf_material_params_{}",
material.index().unwrap_or(usize::MAX)
)),
contents: bytemuck::cast_slice(&[params]),
usage: wgpu::BufferUsages::UNIFORM,
});
super::AssetManager::assemble_material_bind_group(
device,
layout,
base_view,
mat_sampler,
normal_view,
mr_view,
emissive_view,
ao_view,
¶ms_buffer,
&format!("gltf_material_{}", material.index().unwrap_or(usize::MAX)),
)
};
let mut mat = Material::new(bind_group);
if has_base {
mat.texture_source = Some(format!(
"gltf_tex_base_{}",
material.index().unwrap_or(usize::MAX)
));
}
let mat_name = material.name().unwrap_or("").to_lowercase();
let is_glass = mat_name.contains("glass");
let alpha = if is_glass {
0.25 } else if material.alpha_mode() == gltf::material::AlphaMode::Opaque {
1.0
} else {
base_color[3]
};
tracing::debug!("GLTF LOAD MAT: name={:?}, alpha_mode={:?}, alpha_factor={}, base_color={:?}, double_sided={}",
material.name(), material.alpha_mode(), alpha, base_color, material.double_sided());
mat.albedo = gizmo_math::Vec4::new(base_color[0], base_color[1], base_color[2], alpha);
mat.metallic = pbr.metallic_factor();
mat.roughness = pbr.roughness_factor();
mat.is_transparent = material.alpha_mode() != gltf::material::AlphaMode::Opaque || alpha < 0.99 || is_glass;
mat.is_double_sided = material.double_sided();
mat
})
.collect()
}
fn build_keyframes<V, T>(
times: &[f32],
vals: &[V],
cubic: bool,
conv: impl Fn(&V) -> T,
) -> Vec<Keyframe<T>> {
let (stride, off) = if cubic { (3usize, 1usize) } else { (1usize, 0usize) };
times
.iter()
.enumerate()
.filter_map(|(i, &t)| {
let base = i * stride;
let value = conv(vals.get(base + off)?);
if cubic {
match (vals.get(base), vals.get(base + 2)) {
(Some(a), Some(b)) => {
Some(Keyframe::with_tangents(t, value, conv(a), conv(b)))
}
_ => Some(Keyframe::new(t, value)),
}
} else {
Some(Keyframe::new(t, value))
}
})
.collect()
}
fn parse_animations(
document: &gltf::Document,
buffers: &[gltf::buffer::Data],
) -> Vec<AnimationClip> {
document
.animations()
.map(|anim| {
let mut translations = Vec::new();
let mut rotations = Vec::new();
let mut scales = Vec::new();
for channel in anim.channels() {
let target_node = channel.target().node().index();
let target_node_name = channel.target().node().name().map(str::to_owned);
let reader = channel.reader(|b| Some(&buffers[b.index()]));
let times: Vec<f32> = match reader.read_inputs() {
Some(it) => it.collect(),
None => continue,
};
let interp = match channel.sampler().interpolation() {
gltf::animation::Interpolation::Step => {
gizmo_animation::skeletal::InterpolationMode::Step
}
gltf::animation::Interpolation::CubicSpline => {
gizmo_animation::skeletal::InterpolationMode::CubicSpline
}
_ => gizmo_animation::skeletal::InterpolationMode::Linear,
};
let outputs = match reader.read_outputs() {
Some(o) => o,
None => continue,
};
match outputs {
gltf::animation::util::ReadOutputs::Translations(tr) => {
let vals: Vec<[f32; 3]> = tr.collect();
let cubic = matches!(interp, gizmo_animation::skeletal::InterpolationMode::CubicSpline);
let keyframes = build_keyframes(×, &vals, cubic, |v| Vec3::new(v[0], v[1], v[2]));
translations.push(Track {
target_node,
target_node_name: target_node_name.clone(),
interpolation: interp,
keyframes,
});
}
gltf::animation::util::ReadOutputs::Rotations(rt) => {
let vals: Vec<[f32; 4]> = rt.into_f32().collect();
let cubic = matches!(interp, gizmo_animation::skeletal::InterpolationMode::CubicSpline);
let keyframes = build_keyframes(×, &vals, cubic, |v| Quat::from_xyzw(v[0], v[1], v[2], v[3]));
rotations.push(Track {
target_node,
target_node_name: target_node_name.clone(),
interpolation: interp,
keyframes,
});
}
gltf::animation::util::ReadOutputs::Scales(sc) => {
let vals: Vec<[f32; 3]> = sc.collect();
let cubic = matches!(interp, gizmo_animation::skeletal::InterpolationMode::CubicSpline);
let keyframes = build_keyframes(×, &vals, cubic, |v| Vec3::new(v[0], v[1], v[2]));
scales.push(Track {
target_node,
target_node_name,
interpolation: interp,
keyframes,
});
}
_ => {} }
}
let d_tr = translations
.iter()
.filter_map(|t| t.keyframes.last().map(|k| k.time))
.fold(0.0f32, f32::max);
let d_rot = rotations
.iter()
.filter_map(|t| t.keyframes.last().map(|k| k.time))
.fold(0.0f32, f32::max);
let d_scl = scales
.iter()
.filter_map(|t| t.keyframes.last().map(|k| k.time))
.fold(0.0f32, f32::max);
let duration = d_tr.max(d_rot).max(d_scl);
AnimationClip {
name: anim.name().unwrap_or("unnamed").to_string(),
duration,
translations,
rotations,
scales,
}
})
.collect()
}
fn parse_skeletons(
document: &gltf::Document,
buffers: &[gltf::buffer::Data],
node_parents: &std::collections::HashMap<usize, usize>,
nodes_by_index: &[gltf::Node],
) -> Vec<SkeletonHierarchy> {
document
.skins()
.map(|skin| {
let reader = skin.reader(|b| Some(&buffers[b.index()]));
let identity_mat = [
[1.0, 0., 0., 0.],
[0., 1., 0., 0.],
[0., 0., 1., 0.],
[0., 0., 0., 1.],
];
let ibm: Vec<[[f32; 4]; 4]> = reader
.read_inverse_bind_matrices()
.map(|v| v.collect())
.unwrap_or_else(|| vec![identity_mat; skin.joints().count()]);
let node_to_bone: std::collections::HashMap<usize, usize> = skin
.joints()
.enumerate()
.map(|(bone_idx, node)| (node.index(), bone_idx))
.collect();
let joints: Vec<SkeletonJoint> = skin
.joints()
.enumerate()
.map(|(bone_idx, joint_node)| {
let inverse_bind_matrix = ibm
.get(bone_idx)
.map(gizmo_math::Mat4::from_cols_array_2d)
.unwrap_or(gizmo_math::Mat4::IDENTITY);
let parent_index = node_parents
.get(&joint_node.index())
.and_then(|p| node_to_bone.get(p).copied());
let (t, r, s) = joint_node.transform().decomposed();
let bind_translation = Vec3::new(t[0], t[1], t[2]);
let bind_rotation = Quat::from_array(r);
let bind_scale = Vec3::new(s[0], s[1], s[2]);
let local_bind_transform = gizmo_math::Mat4::from_translation(bind_translation)
* gizmo_math::Mat4::from_quat(bind_rotation)
* gizmo_math::Mat4::from_scale(bind_scale);
SkeletonJoint {
name: joint_node.name().unwrap_or("bone").to_string(),
node_index: joint_node.index(),
inverse_bind_matrix,
parent_index,
local_bind_transform,
bind_translation,
bind_rotation,
bind_scale,
}
})
.collect();
let root_transform =
compute_armature_root_transform(&skin, node_parents, &node_to_bone, nodes_by_index);
SkeletonHierarchy {
joints,
root_transform,
}
})
.collect()
}
fn compute_armature_root_transform(
skin: &gltf::Skin,
node_parents: &std::collections::HashMap<usize, usize>,
node_to_bone: &std::collections::HashMap<usize, usize>,
nodes_by_index: &[gltf::Node],
) -> gizmo_math::Mat4 {
let mut root_transform = gizmo_math::Mat4::IDENTITY;
let first_joint = match skin.joints().next() {
Some(j) => j,
None => return root_transform,
};
let mut current_idx = first_joint.index();
let mut ancestor_transforms: Vec<gizmo_math::Mat4> = Vec::new();
while let Some(&parent_idx) = node_parents.get(¤t_idx) {
if node_to_bone.contains_key(&parent_idx) {
break;
}
if let Some(parent_node) = nodes_by_index.get(parent_idx) {
let (t, r, s) = parent_node.transform().decomposed();
let mat = gizmo_math::Mat4::from_translation(Vec3::new(t[0], t[1], t[2]))
* gizmo_math::Mat4::from_quat(Quat::from_array(r))
* gizmo_math::Mat4::from_scale(Vec3::new(s[0], s[1], s[2]));
ancestor_transforms.push(mat);
}
current_idx = parent_idx;
}
for mat in ancestor_transforms.into_iter().rev() {
root_transform *= mat;
}
root_transform
}
#[cfg(test)]
mod tests {
use super::*;
fn wsum(w: [f32; 4]) -> f32 {
w[0] + w[1] + w[2] + w[3]
}
#[test]
fn skin_weights_normalize_to_unity() {
let w = normalize_skin_weights([0.25, 0.25, 0.0, 0.0]);
assert!((wsum(w) - 1.0).abs() < 1e-6, "sum={}", wsum(w));
assert!((w[0] - 0.5).abs() < 1e-6 && (w[1] - 0.5).abs() < 1e-6);
let w = normalize_skin_weights([0.3, 0.3, 0.3, 0.3]);
assert!((wsum(w) - 1.0).abs() < 1e-6);
for c in w {
assert!((c - 0.25).abs() < 1e-6);
}
let w = normalize_skin_weights([0.5, 0.5, 0.0, 0.0]);
assert!((wsum(w) - 1.0).abs() < 1e-6);
}
#[test]
fn skin_weights_all_zero_preserved() {
assert_eq!(normalize_skin_weights([0.0; 4]), [0.0; 4]);
}
#[test]
fn skin_weights_arbitrary_sum_to_one() {
for w in [[0.1, 0.2, 0.3, 0.05], [0.9, 0.05, 0.02, 0.0], [2.0, 1.0, 0.5, 0.5]] {
let n = normalize_skin_weights(w);
assert!((wsum(n) - 1.0).abs() < 1e-5, "input {w:?} → {n:?} sum {}", wsum(n));
}
}
#[test]
fn emissive_strength_scales_factor() {
assert_eq!(emissive_with_strength([1.0, 0.5, 0.0], None), [1.0, 0.5, 0.0]);
assert_eq!(emissive_with_strength([1.0, 0.5, 0.25], Some(4.0)), [4.0, 2.0, 1.0]);
assert_eq!(emissive_with_strength([1.0, 1.0, 1.0], Some(0.0)), [0.0, 0.0, 0.0]);
}
#[test]
fn sampler_filter_and_wrap_converters() {
use gltf::texture::{MagFilter, MinFilter, WrappingMode};
assert_eq!(wrap_to_wgpu(WrappingMode::ClampToEdge), wgpu::AddressMode::ClampToEdge);
assert_eq!(wrap_to_wgpu(WrappingMode::MirroredRepeat), wgpu::AddressMode::MirrorRepeat);
assert_eq!(wrap_to_wgpu(WrappingMode::Repeat), wgpu::AddressMode::Repeat);
assert_eq!(mag_to_wgpu(Some(MagFilter::Nearest)), wgpu::FilterMode::Nearest);
assert_eq!(mag_to_wgpu(Some(MagFilter::Linear)), wgpu::FilterMode::Linear);
assert_eq!(mag_to_wgpu(None), wgpu::FilterMode::Linear);
assert_eq!(min_to_wgpu(Some(MinFilter::Nearest)), wgpu::FilterMode::Nearest);
assert_eq!(min_to_wgpu(Some(MinFilter::NearestMipmapLinear)), wgpu::FilterMode::Nearest);
assert_eq!(min_to_wgpu(Some(MinFilter::Linear)), wgpu::FilterMode::Linear);
assert_eq!(min_to_wgpu(Some(MinFilter::LinearMipmapLinear)), wgpu::FilterMode::Linear);
assert_eq!(min_to_wgpu(None), wgpu::FilterMode::Linear);
}
#[test]
fn gltf_material_sampler_and_emissive_strength_parsed() {
let json = r#"{
"asset": { "version": "2.0" },
"extensionsUsed": ["KHR_materials_emissive_strength"],
"samplers": [
{ "wrapS": 33071, "wrapT": 10497, "magFilter": 9728, "minFilter": 9729 }
],
"images": [ { "uri": "dummy.png" } ],
"textures": [ { "sampler": 0, "source": 0 } ],
"materials": [
{
"pbrMetallicRoughness": { "baseColorTexture": { "index": 0 } },
"emissiveFactor": [1.0, 0.5, 0.25],
"extensions": { "KHR_materials_emissive_strength": { "emissiveStrength": 4.0 } }
}
]
}"#;
let doc = gltf::Gltf::from_slice(json.as_bytes()).expect("parse minimal glTF");
let material = doc.materials().next().expect("one material");
let key = material_sampler_key(&material);
assert_eq!(key.wrap_u, wgpu::AddressMode::ClampToEdge);
assert_eq!(key.wrap_v, wgpu::AddressMode::Repeat);
assert_eq!(key.mag, wgpu::FilterMode::Nearest);
assert_eq!(key.min, wgpu::FilterMode::Linear);
assert_eq!(material.emissive_strength(), Some(4.0));
let emissive =
emissive_with_strength(material.emissive_factor(), material.emissive_strength());
assert_eq!(emissive, [4.0, 2.0, 1.0]);
}
#[test]
fn material_without_textures_uses_default_sampler_key() {
let json = r#"{
"asset": { "version": "2.0" },
"materials": [ { "emissiveFactor": [0.0, 0.0, 0.0] } ]
}"#;
let doc = gltf::Gltf::from_slice(json.as_bytes()).expect("parse");
let material = doc.materials().next().expect("one material");
assert_eq!(material_sampler_key(&material), SamplerKey::DEFAULT);
assert_eq!(material.emissive_strength(), None);
}
#[test]
fn gltf_texture_transform_parsed_and_packed() {
let json = r#"{
"asset": { "version": "2.0" },
"extensionsUsed": ["KHR_texture_transform"],
"images": [ { "uri": "dummy.png" } ],
"samplers": [ {} ],
"textures": [ { "sampler": 0, "source": 0 } ],
"materials": [
{
"pbrMetallicRoughness": {
"baseColorTexture": {
"index": 0,
"extensions": {
"KHR_texture_transform": {
"offset": [0.1, 0.2],
"rotation": 1.5,
"scale": [2.0, 3.0]
}
}
}
}
}
]
}"#;
let doc = gltf::Gltf::from_slice(json.as_bytes()).expect("parse");
let material = doc.materials().next().expect("one material");
let uv = material_uv_transform(&material);
assert_eq!(uv.offset, [0.1, 0.2]);
assert!((uv.rotation - 1.5).abs() < 1e-6);
assert_eq!(uv.scale, [2.0, 3.0]);
assert!(!uv.is_identity());
let params = crate::gpu_types::MaterialParams::new([0.0; 3], 1.0, 1.0, uv);
assert_eq!(params.occlusion_uv_rot_offset, [1.0, 1.5, 0.1, 0.2]);
assert_eq!(params.uv_scale, [2.0, 3.0, 0.0, 0.0]);
}
#[test]
fn material_without_texture_transform_is_identity() {
let json = r#"{
"asset": { "version": "2.0" },
"images": [ { "uri": "dummy.png" } ],
"samplers": [ {} ],
"textures": [ { "sampler": 0, "source": 0 } ],
"materials": [ { "pbrMetallicRoughness": { "baseColorTexture": { "index": 0 } } } ]
}"#;
let doc = gltf::Gltf::from_slice(json.as_bytes()).expect("parse");
let material = doc.materials().next().expect("one material");
assert!(material_uv_transform(&material).is_identity());
let d = crate::gpu_types::MaterialParams::default();
assert_eq!(d.uv_scale, [1.0, 1.0, 0.0, 0.0]);
assert_eq!(d.occlusion_uv_rot_offset, [1.0, 0.0, 0.0, 0.0]);
}
}