libs/render (Renderer, SceneDraws, preview play) plus the Arcade sim/math it still draws through. Gameplay crates stay out.
483 lines
19 KiB
Rust
483 lines
19 KiB
Rust
//! Writer→parser round-trip gate for the character chain.
|
|
//!
|
|
//! `makepad_gltf::write_glb_mesh_skinned` is the chain's animated-GLB
|
|
//! emitter; `skin.rs` is the engine's parser and THE acceptance contract
|
|
//! (character-chain campaign): whatever the writer emits must load, sample
|
|
//! and skin here with exact expected transforms. If either side drifts,
|
|
//! this breaks before a box ever renders a wrong character.
|
|
|
|
use makepad_render::skin::{SkinnedModel, PoseBuffer, SKIN_VERTEX_FLOATS};
|
|
use makepad_gltf::{
|
|
write_glb_mesh_skinned, GlbAnimChannel, GlbAnimClip, GlbAnimPath, GlbJoint, GlbSkinnedMesh,
|
|
};
|
|
|
|
const SIN45: f32 = 0.70710678;
|
|
|
|
/// Two-joint chain: root at origin, tip 1m up. Four single-weight vertices,
|
|
/// "walk" rotates the tip 90° about Z while the root bobs up, "idle" is a
|
|
/// static scale channel. Every expected position below is hand-derivable.
|
|
fn test_rig() -> Vec<u8> {
|
|
let positions = [
|
|
[0.0f32, 0.0, 0.0], // root-bound
|
|
[1.0, 0.0, 0.0], // root-bound
|
|
[0.0, 1.0, 0.0], // tip-bound (at the tip joint)
|
|
[0.0, 2.0, 0.0], // tip-bound (1m past the tip)
|
|
];
|
|
let indices = [0u32, 1, 2, 1, 3, 2];
|
|
let joints_0 = [[0u16, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0]];
|
|
let weights_0 = [[1.0f32, 0.0, 0.0, 0.0]; 4];
|
|
let joints = [
|
|
GlbJoint::at("root", None, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
|
|
GlbJoint::at("tip", Some(0), [0.0, 1.0, 0.0], [0.0, 1.0, 0.0]),
|
|
];
|
|
let clips = [
|
|
GlbAnimClip {
|
|
name: "walk".into(),
|
|
channels: vec![
|
|
GlbAnimChannel {
|
|
joint: 1,
|
|
path: GlbAnimPath::Rotation,
|
|
times: vec![0.0, 1.0, 2.0],
|
|
values: vec![
|
|
0.0, 0.0, 0.0, 1.0, // identity
|
|
0.0, 0.0, SIN45, SIN45, // 90° about Z
|
|
0.0, 0.0, 0.0, 1.0,
|
|
],
|
|
},
|
|
GlbAnimChannel {
|
|
joint: 0,
|
|
path: GlbAnimPath::Translation,
|
|
times: vec![0.0, 1.0, 2.0],
|
|
values: vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0],
|
|
},
|
|
],
|
|
},
|
|
GlbAnimClip {
|
|
name: "idle".into(),
|
|
channels: vec![GlbAnimChannel {
|
|
joint: 1,
|
|
path: GlbAnimPath::Scale,
|
|
times: vec![0.0, 2.0],
|
|
values: vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
|
}],
|
|
},
|
|
];
|
|
write_glb_mesh_skinned(&GlbSkinnedMesh {
|
|
positions: &positions,
|
|
normals: None,
|
|
uvs: Some(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]),
|
|
indices: &indices,
|
|
joints_0: &joints_0,
|
|
weights_0: &weights_0,
|
|
joints: &joints,
|
|
clips: &clips,
|
|
base_color_png: None,
|
|
})
|
|
}
|
|
|
|
/// Skinned positions for `clip` at `t` through the full engine path
|
|
/// (sample → palette → CPU skin), positions extracted from the packed lanes.
|
|
fn skinned_positions(model: &SkinnedModel, clip: usize, t: f32) -> Vec<[f32; 3]> {
|
|
let mut pose = PoseBuffer::new();
|
|
model.sample_clip(clip, t, &mut pose);
|
|
let mut palette = Vec::new();
|
|
model.palette(&pose, &mut palette);
|
|
let mut packed = Vec::new();
|
|
model.skin_to_packed(&palette, &mut packed);
|
|
packed
|
|
.chunks_exact(SKIN_VERTEX_FLOATS)
|
|
.map(|v| [v[0], v[1], v[2]])
|
|
.collect()
|
|
}
|
|
|
|
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
|
|
for d in 0..3 {
|
|
assert!(
|
|
(got[d] - want[d]).abs() < 1.0e-5,
|
|
"{what}: got {got:?}, want {want:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn writer_output_parses_and_names_clips() {
|
|
let model = SkinnedModel::parse_glb(&test_rig()).expect("engine parses writer output");
|
|
assert_eq!(model.joint_count(), 2);
|
|
assert_eq!(model.vertex_count(), 4);
|
|
assert_eq!(model.indices(), &[0, 1, 2, 1, 3, 2]);
|
|
assert_eq!(model.skipped_unskinned, 0);
|
|
assert_eq!(model.clips.len(), 2);
|
|
assert_eq!(model.clips[0].name, "walk");
|
|
assert_eq!(model.clips[1].name, "idle");
|
|
assert!((model.clips[0].duration - 2.0).abs() < 1.0e-6);
|
|
assert!((model.clips[1].duration - 2.0).abs() < 1.0e-6);
|
|
// The locomotion resolver finds the gait pair by name substring — the
|
|
// motion domain's clip-name contract (idle/walk/jump) rides on this.
|
|
assert_eq!(model.clip_index("walk"), Some(0));
|
|
assert_eq!(model.clip_index_any(&["nope", "idle"]), Some(1));
|
|
assert_eq!(model.gait_clips(), Some((1, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn writer_output_skins_to_exact_positions() {
|
|
let model = SkinnedModel::parse_glb(&test_rig()).expect("engine parses writer output");
|
|
|
|
// t=0: rest pose everywhere — identity palette.
|
|
let rest = skinned_positions(&model, 0, 0.0);
|
|
assert_close(rest[0], [0.0, 0.0, 0.0], "rest v0");
|
|
assert_close(rest[1], [1.0, 0.0, 0.0], "rest v1");
|
|
assert_close(rest[2], [0.0, 1.0, 0.0], "rest v2");
|
|
assert_close(rest[3], [0.0, 2.0, 0.0], "rest v3");
|
|
|
|
// t=1 (exact key): root bobbed +0.5Y, tip rotated 90° about Z.
|
|
// v3 sits 1m along the tip bone: rotates to -X, rides the bob.
|
|
let mid = skinned_positions(&model, 0, 1.0);
|
|
assert_close(mid[0], [0.0, 0.5, 0.0], "walk@1 v0");
|
|
assert_close(mid[1], [1.0, 0.5, 0.0], "walk@1 v1");
|
|
assert_close(mid[2], [0.0, 1.5, 0.0], "walk@1 v2");
|
|
assert_close(mid[3], [-1.0, 1.5, 0.0], "walk@1 v3");
|
|
|
|
// t=0.5 (between keys): translation lerps to +0.25Y; rotation nlerps —
|
|
// for unit quats at f=0.5 that's the exact 45° halfway rotation.
|
|
let quarter = skinned_positions(&model, 0, 0.5);
|
|
assert_close(quarter[0], [0.0, 0.25, 0.0], "walk@.5 v0");
|
|
assert_close(quarter[3], [-SIN45, 1.25 + SIN45, 0.0], "walk@.5 v3");
|
|
|
|
// The idle clip leaves the mesh at rest.
|
|
let idle = skinned_positions(&model, 1, 0.7);
|
|
assert_close(idle[3], [0.0, 2.0, 0.0], "idle v3");
|
|
|
|
// Bounds stay finite and cover the posed mesh.
|
|
let mut pose = PoseBuffer::new();
|
|
model.sample_clip(0, 1.0, &mut pose);
|
|
let mut palette = Vec::new();
|
|
model.palette(&pose, &mut palette);
|
|
let (mn, mx) = model.posed_bounds(&palette).expect("bounds");
|
|
assert!(mn.x <= -1.0 && mx.y >= 1.5, "posed bounds cover the swing");
|
|
}
|
|
|
|
#[test]
|
|
fn prune_weights_removes_cross_limb_stragglers() {
|
|
// A "webbing" vertex: 0.9 tip / 0.1 root — the auto-skinning artifact
|
|
// class (faint cross-limb influence that sags when limbs separate).
|
|
let positions = [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
|
|
let joints = [
|
|
GlbJoint::at("root", None, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
|
|
GlbJoint::at("tip", Some(0), [0.0, 1.0, 0.0], [0.0, 1.0, 0.0]),
|
|
];
|
|
// Times span 2s: sample_clip WRAPS t modulo duration, so a clip ending
|
|
// exactly at the probe time would sample the rest pose instead.
|
|
let clips = [GlbAnimClip {
|
|
name: "walk".into(),
|
|
channels: vec![
|
|
GlbAnimChannel {
|
|
joint: 1,
|
|
path: GlbAnimPath::Rotation,
|
|
times: vec![0.0, 1.0, 2.0],
|
|
values: vec![
|
|
0.0, 0.0, 0.0, 1.0, //
|
|
0.0, 0.0, SIN45, SIN45, //
|
|
0.0, 0.0, 0.0, 1.0,
|
|
],
|
|
},
|
|
GlbAnimChannel {
|
|
joint: 0,
|
|
path: GlbAnimPath::Translation,
|
|
times: vec![0.0, 1.0, 2.0],
|
|
values: vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0],
|
|
},
|
|
],
|
|
}];
|
|
let glb = write_glb_mesh_skinned(&GlbSkinnedMesh {
|
|
positions: &positions,
|
|
normals: None,
|
|
uvs: None,
|
|
indices: &[0, 1, 2],
|
|
joints_0: &[[0u16, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 0]],
|
|
weights_0: &[
|
|
[1.0f32, 0.0, 0.0, 0.0],
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.9, 0.1, 0.0, 0.0], // v2: joints [tip, root] via joints_0? see below
|
|
],
|
|
joints: &joints,
|
|
clips: &clips,
|
|
base_color_png: None,
|
|
});
|
|
// NOTE: v2's joints_0 lane is [1,0,0,0] with weights [0.9, 0.1] — i.e.
|
|
// 0.9 on the tip and a 0.1 straggler on the root (slot 1 = joint 0).
|
|
let mut model = SkinnedModel::parse_glb(&glb).expect("parse");
|
|
// Pre-prune: the straggler pulls v2 off the pure tip transform.
|
|
let before = skinned_positions(&model, 0, 1.0);
|
|
assert_close(before[2], [0.05, 1.95, 0.0], "webbed v2");
|
|
// Prune the 0.1 straggler (below threshold): v2 rides the tip exactly.
|
|
let culled = model.prune_weights(0.15, 0.99);
|
|
assert_eq!(culled, 1);
|
|
let after = skinned_positions(&model, 0, 1.0);
|
|
assert_close(after[0], [0.0, 0.5, 0.0], "root vert untouched");
|
|
assert_close(after[2], [0.0, 2.0, 0.0], "hardened v2");
|
|
// Idempotent on clean weights.
|
|
assert_eq!(model.prune_weights(0.15, 0.99), 0);
|
|
// Hardening: a 0.6/0.4 vertex is untouched at harden_at 0.85 but snaps
|
|
// to its dominant joint at harden_at 0.5.
|
|
let glb2 = write_glb_mesh_skinned(&GlbSkinnedMesh {
|
|
positions: &positions,
|
|
normals: None,
|
|
uvs: None,
|
|
indices: &[0, 1, 2],
|
|
joints_0: &[[0u16, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 0]],
|
|
weights_0: &[
|
|
[1.0f32, 0.0, 0.0, 0.0],
|
|
[1.0, 0.0, 0.0, 0.0],
|
|
[0.6, 0.4, 0.0, 0.0],
|
|
],
|
|
joints: &joints,
|
|
clips: &clips,
|
|
base_color_png: None,
|
|
});
|
|
let mut soft = SkinnedModel::parse_glb(&glb2).expect("parse");
|
|
assert_eq!(soft.prune_weights(0.05, 0.85), 0, "0.6 is no dominant at 0.85");
|
|
assert_eq!(soft.prune_weights(0.05, 0.5), 1, "0.6 dominates at 0.5");
|
|
let hard = skinned_positions(&soft, 0, 1.0);
|
|
assert_close(hard[2], [0.0, 2.0, 0.0], "hardened at 0.5");
|
|
}
|
|
|
|
#[test]
|
|
fn cull_stretched_triangles_removes_cross_limb_webbing() {
|
|
// Two "limbs" (independent root joints); a bridge triangle spans them.
|
|
// The "spread" clip translates limb B 2m away — the bridge shears to
|
|
// ~5x its rest edge while the intra-limb triangle rides rigidly.
|
|
let positions = [
|
|
[0.0f32, 0.0, 0.0], // A
|
|
[0.2, 0.0, 0.0], // A
|
|
[0.1, 0.2, 0.0], // A — intra-limb triangle 0,1,2
|
|
[0.3, 0.0, 0.0], // B — bridge triangle 1,3,2 spans A/B
|
|
];
|
|
let joints = [
|
|
GlbJoint::at("limb_a", None, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
|
|
GlbJoint::at("limb_b", None, [0.3, 0.0, 0.0], [0.3, 0.0, 0.0]),
|
|
];
|
|
let clips = [GlbAnimClip {
|
|
name: "spread".into(),
|
|
channels: vec![GlbAnimChannel {
|
|
joint: 1,
|
|
path: GlbAnimPath::Translation,
|
|
times: vec![0.0, 1.0, 2.0],
|
|
values: vec![0.3, 0.0, 0.0, 2.3, 0.0, 0.0, 0.3, 0.0, 0.0],
|
|
}],
|
|
}];
|
|
let glb = write_glb_mesh_skinned(&GlbSkinnedMesh {
|
|
positions: &positions,
|
|
normals: None,
|
|
uvs: None,
|
|
indices: &[0, 1, 2, 1, 3, 2],
|
|
joints_0: &[[0u16; 4], [0; 4], [0; 4], [1, 0, 0, 0]],
|
|
weights_0: &[[1.0f32, 0.0, 0.0, 0.0]; 4],
|
|
joints: &joints,
|
|
clips: &clips,
|
|
base_color_png: None,
|
|
});
|
|
let mut model = SkinnedModel::parse_glb(&glb).expect("parse");
|
|
assert_eq!(model.indices().len(), 6);
|
|
let culled = model.cull_stretched_triangles(&[0], 6, 3.0);
|
|
assert_eq!(culled, 1, "the bridge triangle goes");
|
|
assert_eq!(model.indices(), &[0, 1, 2], "the intra-limb triangle stays");
|
|
}
|
|
|
|
#[test]
|
|
fn deformation_audit_reports_without_mutating_topology() {
|
|
let positions = [
|
|
[0.0f32, 0.0, 0.0],
|
|
[0.2, 0.0, 0.0],
|
|
[0.1, 0.2, 0.0],
|
|
[0.3, 0.0, 0.0],
|
|
];
|
|
let joints = [
|
|
GlbJoint::at("limb_a", None, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
|
|
GlbJoint::at("limb_b", None, [0.3, 0.0, 0.0], [0.3, 0.0, 0.0]),
|
|
];
|
|
let clips = [GlbAnimClip {
|
|
name: "spread".into(),
|
|
channels: vec![GlbAnimChannel {
|
|
joint: 1,
|
|
path: GlbAnimPath::Translation,
|
|
times: vec![0.0, 1.0, 2.0],
|
|
values: vec![0.3, 0.0, 0.0, 2.3, 0.0, 0.0, 0.3, 0.0, 0.0],
|
|
}],
|
|
}];
|
|
let glb = write_glb_mesh_skinned(&GlbSkinnedMesh {
|
|
positions: &positions,
|
|
normals: None,
|
|
uvs: None,
|
|
indices: &[0, 1, 2, 1, 3, 2],
|
|
joints_0: &[[0u16; 4], [0; 4], [0; 4], [1, 0, 0, 0]],
|
|
weights_0: &[[1.0f32, 0.0, 0.0, 0.0]; 4],
|
|
joints: &joints,
|
|
clips: &clips,
|
|
base_color_png: None,
|
|
});
|
|
let model = SkinnedModel::parse_glb(&glb).unwrap();
|
|
let indices_before = model.indices().to_vec();
|
|
let audit = model.audit_deformation(&[0], 6);
|
|
|
|
assert_eq!(audit.triangles, indices_before.len() / 3);
|
|
assert_eq!(audit.samples, 6);
|
|
assert!(audit.over_3x > 0, "fixture should expose pathological stretch");
|
|
assert!(audit.max_stretch > 3.0);
|
|
assert_eq!(model.indices(), indices_before);
|
|
}
|
|
|
|
#[test]
|
|
fn audit_external_generated_character_if_requested() {
|
|
let Some(path) = std::env::var_os("MAKEPAD_SKIN_AUDIT_GLB") else {
|
|
eprintln!("MAKEPAD_SKIN_AUDIT_GLB unset; skipping external audit");
|
|
return;
|
|
};
|
|
let path = std::path::PathBuf::from(path);
|
|
let bytes = std::fs::read(&path).unwrap_or_else(|error| {
|
|
panic!("cannot read deformation-audit input {}: {error}", path.display())
|
|
});
|
|
let model = SkinnedModel::parse_glb(&bytes).unwrap_or_else(|error| {
|
|
panic!("cannot parse deformation-audit input {}: {error}", path.display())
|
|
});
|
|
assert!(!model.clips.is_empty(), "audit input has no animation clips");
|
|
for (clip, animation) in model.clips.iter().enumerate() {
|
|
let audit = model.audit_deformation(&[clip], 12);
|
|
let temporal = model.audit_temporal_motion(clip, 30.0);
|
|
let runtime_loop = model.audit_loop_blended_motion(clip, 30.0, 0.2, 2);
|
|
eprintln!(
|
|
"skin deformation audit: file={} clip={} triangles={} samples={} over_2x={} over_3x={} p95={:.4} p99={:.4} max={:.4}",
|
|
path.display(),
|
|
animation.name,
|
|
audit.triangles,
|
|
audit.samples,
|
|
audit.over_2x,
|
|
audit.over_3x,
|
|
audit.p95_stretch,
|
|
audit.p99_stretch,
|
|
audit.max_stretch,
|
|
);
|
|
eprintln!(
|
|
"skin temporal audit: file={} clip={} frames={} pairs={} joint_max_deg={:.4}@node{}/frame{} joint_p99_deg={:.4} vertex_max={:.6}@v{}/frame{} vertex_p99={:.6} seam_joint_deg={:.4} seam_vertex={:.6}",
|
|
path.display(),
|
|
animation.name,
|
|
temporal.frames,
|
|
temporal.frame_pairs,
|
|
temporal.max_joint_angle_degrees,
|
|
temporal.max_joint_node,
|
|
temporal.max_joint_frame,
|
|
temporal.p99_joint_angle_degrees,
|
|
temporal.max_vertex_delta,
|
|
temporal.max_vertex,
|
|
temporal.max_vertex_frame,
|
|
temporal.p99_vertex_delta,
|
|
temporal.seam_joint_angle_degrees,
|
|
temporal.seam_vertex_delta,
|
|
);
|
|
eprintln!(
|
|
"skin runtime loop audit: file={} clip={} cycles={} frames={} pairs={} wraps={} joint_max_deg={:.4}@node{}/frame{} vertex_max={:.6}@v{}/frame{} wrap_joint_deg={:.4} wrap_vertex={:.6}",
|
|
path.display(),
|
|
animation.name,
|
|
runtime_loop.cycles,
|
|
runtime_loop.frames,
|
|
runtime_loop.frame_pairs,
|
|
runtime_loop.wraps,
|
|
runtime_loop.max_joint_angle_degrees,
|
|
runtime_loop.max_joint_node,
|
|
runtime_loop.max_joint_frame,
|
|
runtime_loop.max_vertex_delta,
|
|
runtime_loop.max_vertex,
|
|
runtime_loop.max_vertex_frame,
|
|
runtime_loop.wrap_joint_angle_degrees,
|
|
runtime_loop.wrap_vertex_delta,
|
|
);
|
|
let outliers = model.temporal_joint_outliers(clip, 30.0);
|
|
let ranked: Vec<_> = outliers
|
|
.iter()
|
|
.take(8)
|
|
.map(|outlier| {
|
|
(
|
|
outlier.node,
|
|
model.node_name(outlier.node).unwrap_or("?"),
|
|
outlier.frame,
|
|
outlier.angle_degrees,
|
|
)
|
|
})
|
|
.collect();
|
|
eprintln!(
|
|
"skin temporal joint ranking: file={} clip={} top={ranked:?}",
|
|
path.display(),
|
|
animation.name,
|
|
);
|
|
if std::env::var_os("MAKEPAD_SKIN_AUDIT_DETAIL").is_some() {
|
|
let mut previous = PoseBuffer::new();
|
|
let mut current = PoseBuffer::new();
|
|
for frame in 0..temporal.frames {
|
|
let duration = animation.duration;
|
|
let before_wrap = duration - (1.0 / 30.0f32).min(duration) * 1.0e-4;
|
|
let time = ((frame + 1) as f32 / 30.0).min(before_wrap);
|
|
model.sample_clip(clip, time, &mut current);
|
|
if frame > 0 {
|
|
let mut ranked: Vec<_> = current
|
|
.iter()
|
|
.zip(&previous)
|
|
.enumerate()
|
|
.map(|(node, (right, left))| {
|
|
let dot = (left.r.x * right.r.x
|
|
+ left.r.y * right.r.y
|
|
+ left.r.z * right.r.z
|
|
+ left.r.w * right.r.w)
|
|
.abs()
|
|
.clamp(-1.0, 1.0);
|
|
(2.0 * dot.acos() * 180.0 / std::f32::consts::PI, node)
|
|
})
|
|
.collect();
|
|
ranked.sort_by(|left, right| right.0.total_cmp(&left.0));
|
|
if ranked[0].0 > 20.0 {
|
|
eprintln!(
|
|
"skin temporal detail: clip={} frame={} time={:.6} top={:?}",
|
|
animation.name,
|
|
frame,
|
|
time,
|
|
&ranked[..ranked.len().min(6)]
|
|
);
|
|
}
|
|
}
|
|
std::mem::swap(&mut previous, &mut current);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn writer_u16_joint_lane_survives_large_rigs() {
|
|
// 300 joints forces the writer off the u8 JOINTS_0 lane; the engine
|
|
// parser reads u16 fine (its 256 limit applies to the GPU packer only).
|
|
let joints: Vec<GlbJoint> = (0..300)
|
|
.map(|i| {
|
|
GlbJoint::at(
|
|
&format!("j{i}"),
|
|
if i == 0 { None } else { Some(i - 1) },
|
|
[0.0, if i == 0 { 0.0 } else { 0.01 }, 0.0],
|
|
[0.0, 0.01 * i as f32, 0.0],
|
|
)
|
|
})
|
|
.collect();
|
|
let positions = [[0.0f32, 3.0, 0.0], [1.0, 3.0, 0.0], [0.0, 4.0, 0.0]];
|
|
let glb = write_glb_mesh_skinned(&GlbSkinnedMesh {
|
|
positions: &positions,
|
|
normals: None,
|
|
uvs: None,
|
|
indices: &[0, 1, 2],
|
|
joints_0: &[[299u16, 0, 0, 0]; 3],
|
|
weights_0: &[[1.0f32, 0.0, 0.0, 0.0]; 3],
|
|
joints: &joints,
|
|
clips: &[],
|
|
base_color_png: None,
|
|
});
|
|
let model = SkinnedModel::parse_glb(&glb).expect("u16 joints parse");
|
|
assert_eq!(model.joint_count(), 300);
|
|
// Rest pose = bind pose: vertices come back where they were authored.
|
|
let rest = skinned_positions(&model, 0, 0.0); // no clips: rest sample
|
|
assert_close(rest[0], [0.0, 3.0, 0.0], "u16 rest v0");
|
|
assert_close(rest[2], [0.0, 4.0, 0.0], "u16 rest v2");
|
|
}
|