BlueGit

golden-age paperdoll-renderer

Merge feature/anim-fidelity: animation interpolation fidelity + channel validation (#10)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
9c70a7917399d05b60aca49ce23d11a266d67ab4
Pipeline
Passed
4 files changed +390 −61
README.md +4 −0
@@ -85,6 +85,10 @@ cargo run --example equipment_fixture --locked
85 85
86 86 Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
87 87
88 ## Animaties
89
90 Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.
91
88 92 ## Productie-output
89 93
90 94 ### Render alles
@@ -85,6 +85,10 @@ cargo run --example equipment_fixture --locked
85
85
86Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
86Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
87
87
88## Animaties
89
90Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.
91
88## Productie-output
92## Productie-output
89
93
90### Render alles
94### Render alles
src/fbx_scene.rs +3 −0
@@ -818,6 +818,7 @@ fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewCl
818 818 name,
819 819 duration,
820 820 channels,
821 skipped: Vec::new(),
821 822 });
822 823 }
823 824 Ok(clips)
@@ -848,6 +849,7 @@ fn push_vec3_channel(
848 849 property,
849 850 times,
850 851 values,
852 interpolation: crate::preview::Interpolation::Linear,
851 853 });
852 854 Ok(())
853 855 }
@@ -881,6 +883,7 @@ fn push_quat_channel(
881 883 property: ChannelProperty::Rotation,
882 884 times,
883 885 values,
886 interpolation: crate::preview::Interpolation::Linear,
884 887 });
885 888 Ok(())
886 889 }
@@ -818,6 +818,7 @@ fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewCl
818 name,
818 name,
819 duration,
819 duration,
820 channels,
820 channels,
821 skipped: Vec::new(),
821 });
822 });
822 }
823 }
823 Ok(clips)
824 Ok(clips)
@@ -848,6 +849,7 @@ fn push_vec3_channel(
848 property,
849 property,
849 times,
850 times,
850 values,
851 values,
852 interpolation: crate::preview::Interpolation::Linear,
851 });
853 });
852 Ok(())
854 Ok(())
853}
855}
@@ -881,6 +883,7 @@ fn push_quat_channel(
881 property: ChannelProperty::Rotation,
883 property: ChannelProperty::Rotation,
882 times,
884 times,
883 values,
885 values,
886 interpolation: crate::preview::Interpolation::Linear,
884 });
887 });
885 Ok(())
888 Ok(())
886}
889}
src/preview.rs +371 −61
@@ -158,6 +158,18 @@ pub struct PreviewClip {
158 158 pub name: String,
159 159 pub duration: f32,
160 160 pub channels: Vec<AnimChannel>,
161 /// Source channels not applied, with the reason (e.g. morph weights).
162 pub skipped: Vec<String>,
163 }
164
165 /// Keyframe interpolation as stored in the source (glTF sampler).
166 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167 pub enum Interpolation {
168 #[default]
169 Linear,
170 Step,
171 /// Hermite spline; `values` holds (in-tangent, value, out-tangent) per key.
172 CubicSpline,
161 173 }
162 174
163 175 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -173,6 +185,110 @@ pub struct AnimChannel {
173 185 pub property: ChannelProperty,
174 186 pub times: Vec<f32>,
175 187 pub values: Vec<f32>,
188 pub interpolation: Interpolation,
189 }
190
191 impl ChannelProperty {
192 pub fn width(self) -> usize {
193 match self {
194 Self::Rotation => 4,
195 _ => 3,
196 }
197 }
198 }
199
200 impl AnimChannel {
201 /// Times finite and non-decreasing; one value (or tangent triple) per key, finite.
202 pub fn validate(&self) -> Result<(), String> {
203 if self.times.is_empty() {
204 return Err("kanaal zonder keyframes".into());
205 }
206 if self.times.iter().any(|t| !t.is_finite() || *t < 0.)
207 || self.times.windows(2).any(|w| w[1] < w[0])
208 {
209 return Err("keyframetijden niet eindig of niet oplopend".into());
210 }
211 let per_key = self.property.width()
212 * if self.interpolation == Interpolation::CubicSpline {
213 3
214 } else {
215 1
216 };
217 if self.values.len() != self.times.len() * per_key {
218 return Err(format!(
219 "{} waarden voor {} keyframes (verwacht {per_key} per key)",
220 self.values.len(),
221 self.times.len()
222 ));
223 }
224 if self.values.iter().any(|v| !v.is_finite()) {
225 return Err("niet-eindige keyframewaarde".into());
226 }
227 Ok(())
228 }
229
230 fn value(&self, key: usize, part: usize) -> &[f32] {
231 let w = self.property.width();
232 let index = if self.interpolation == Interpolation::CubicSpline {
233 key * 3 + part
234 } else {
235 key
236 };
237 &self.values[index * w..index * w + w]
238 }
239
240 /// Sampled value at `time` (clamped to the first/last key).
241 pub fn sample(&self, time: f32) -> Vec<f32> {
242 let (left, right, blend) = key_blend(&self.times, time);
243 let w = self.property.width();
244 let a = self.value(left, 1);
245 if left == right || self.interpolation == Interpolation::Step {
246 return a.to_vec();
247 }
248 let b = self.value(right, 1);
249 match self.interpolation {
250 Interpolation::Step => a.to_vec(),
251 Interpolation::Linear => {
252 if self.property == ChannelProperty::Rotation {
253 let q = slerp([a[0], a[1], a[2], a[3]], [b[0], b[1], b[2], b[3]], blend);
254 q.to_vec()
255 } else {
256 (0..w).map(|i| a[i] + (b[i] - a[i]) * blend).collect()
257 }
258 }
259 Interpolation::CubicSpline => {
260 let dt = self.times[right] - self.times[left];
261 let out_a = self.value(left, 2);
262 let in_b = self.value(right, 0);
263 let t = blend;
264 let (t2, t3) = (t * t, t * t * t);
265 let h00 = 2. * t3 - 3. * t2 + 1.;
266 let h10 = t3 - 2. * t2 + t;
267 let h01 = -2. * t3 + 3. * t2;
268 let h11 = t3 - t2;
269 let v: Vec<f32> = (0..w)
270 .map(|i| h00 * a[i] + h10 * dt * out_a[i] + h01 * b[i] + h11 * dt * in_b[i])
271 .collect();
272 if self.property == ChannelProperty::Rotation {
273 normalize4([v[0], v[1], v[2], v[3]]).to_vec()
274 } else {
275 v
276 }
277 }
278 }
279 }
280 }
281
282 /// Clip for a requested name: exact match first, then the first clip whose
283 /// name contains it (case-insensitive), so "walk" never picks "walk_fast"
284 /// when "walk" exists.
285 pub fn find_clip<'a>(clips: &'a [PreviewClip], name: &str) -> Option<&'a PreviewClip> {
286 clips.iter().find(|c| c.name == name).or_else(|| {
287 let lower = name.to_lowercase();
288 clips
289 .iter()
290 .find(|c| c.name.to_lowercase().contains(&lower))
291 })
176 292 }
177 293
178 294 /// Whether a body model path is drawable by the live viewport and production batch.
@@ -456,17 +572,22 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
456 572 let mut clips = Vec::new();
457 573 for animation in document.animations() {
458 574 let mut channels = Vec::new();
575 let mut skipped = Vec::new();
459 576 let mut duration = 0.0_f32;
460 577 for channel in animation.channels() {
578 let target = channel.target().node().index();
579 let interpolation = match channel.sampler().interpolation() {
580 gltf::animation::Interpolation::Linear => Interpolation::Linear,
581 gltf::animation::Interpolation::Step => Interpolation::Step,
582 gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
583 };
461 584 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
462 585 let times: Vec<f32> = reader
463 586 .read_inputs()
464 587 .map(|iter| iter.collect())
465 588 .unwrap_or_default();
466 if let Some(last) = times.last() {
467 duration = duration.max(*last);
468 }
469 589 let Some(outputs) = reader.read_outputs() else {
590 skipped.push(format!("node {target}: geen uitvoerwaarden"));
470 591 continue;
471 592 };
472 593 let (property, values) = match outputs {
@@ -480,19 +601,40 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
480 601 ChannelProperty::Rotation,
481 602 values.into_f32().flatten().collect(),
482 603 ),
483 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
604 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => {
605 skipped.push(format!(
606 "node {target}: morph-target-gewichten (niet ondersteund)"
607 ));
608 continue;
609 }
484 610 };
485 channels.push(AnimChannel {
486 node: channel.target().node().index(),
611 let channel = AnimChannel {
612 node: target,
487 613 property,
488 614 times,
489 615 values,
490 });
616 interpolation,
617 };
618 if let Err(reason) = channel.validate() {
619 return Err(format!(
620 "animatie '{}': ongeldig kanaal op node {target}: {reason}",
621 animation.name().unwrap_or("naamloos")
622 ));
623 }
624 if let Some(last) = channel.times.last() {
625 duration = duration.max(*last);
626 }
627 channels.push(channel);
491 628 }
492 629 clips.push(PreviewClip {
493 name: animation.name().unwrap_or("naamloos").to_string(),
630 name: animation
631 .name()
632 .filter(|n| !n.trim().is_empty())
633 .map(str::to_string)
634 .unwrap_or_else(|| format!("animatie {}", animation.index())),
494 635 duration,
495 636 channels,
637 skipped,
496 638 });
497 639 }
498 640 Ok(PreviewCharacter {
@@ -887,37 +1029,17 @@ fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f3
887 1029 .collect();
888 1030 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
889 1031 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
890 if let Some(clip) = character.clips.iter().find(|clip| {
891 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
892 }) {
1032 if let Some(clip) = find_clip(&character.clips, clip_name) {
893 1033 let time = wrap_time(time, clip.duration);
894 1034 for channel in &clip.channels {
895 if channel.node >= character.nodes.len() || channel.times.is_empty() {
1035 if channel.node >= character.nodes.len() || channel.validate().is_err() {
896 1036 continue;
897 1037 }
898 let (left, right, blend) = key_blend(&channel.times, time);
1038 let v = channel.sample(time);
899 1039 match channel.property {
900 ChannelProperty::Translation => {
901 translation[channel.node] = lerp3(
902 key3(&channel.values, left),
903 key3(&channel.values, right),
904 blend,
905 );
906 }
907 ChannelProperty::Scale => {
908 scale[channel.node] = lerp3(
909 key3(&channel.values, left),
910 key3(&channel.values, right),
911 blend,
912 );
913 }
914 ChannelProperty::Rotation => {
915 rotation[channel.node] = slerp(
916 key4(&channel.values, left),
917 key4(&channel.values, right),
918 blend,
919 );
920 }
1040 ChannelProperty::Translation => translation[channel.node] = [v[0], v[1], v[2]],
1041 ChannelProperty::Scale => scale[channel.node] = [v[0], v[1], v[2]],
1042 ChannelProperty::Rotation => rotation[channel.node] = [v[0], v[1], v[2], v[3]],
921 1043 }
922 1044 }
923 1045 }
@@ -980,33 +1102,6 @@ fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
980 1102 (index, index + 1, blend)
981 1103 }
982 1104
983 fn key3(values: &[f32], index: usize) -> [f32; 3] {
984 let start = index * 3;
985 [
986 values.get(start).copied().unwrap_or(0.0),
987 values.get(start + 1).copied().unwrap_or(0.0),
988 values.get(start + 2).copied().unwrap_or(0.0),
989 ]
990 }
991
992 fn key4(values: &[f32], index: usize) -> [f32; 4] {
993 let start = index * 4;
994 [
995 values.get(start).copied().unwrap_or(0.0),
996 values.get(start + 1).copied().unwrap_or(0.0),
997 values.get(start + 2).copied().unwrap_or(0.0),
998 values.get(start + 3).copied().unwrap_or(1.0),
999 ]
1000 }
1001
1002 fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
1003 [
1004 a[0] + (b[0] - a[0]) * t,
1005 a[1] + (b[1] - a[1]) * t,
1006 a[2] + (b[2] - a[2]) * t,
1007 ]
1008 }
1009
1010 1105 fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1011 1106 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1012 1107 if dot < 0.0 {
@@ -1222,6 +1317,221 @@ fn normalize3(value: [f32; 3]) -> [f32; 3] {
1222 1317 mod tests {
1223 1318 use super::*;
1224 1319
1320 fn chan(
1321 property: ChannelProperty,
1322 interpolation: Interpolation,
1323 times: &[f32],
1324 values: &[f32],
1325 ) -> AnimChannel {
1326 AnimChannel {
1327 node: 0,
1328 property,
1329 times: times.to_vec(),
1330 values: values.to_vec(),
1331 interpolation,
1332 }
1333 }
1334
1335 #[test]
1336 fn step_linear_and_cubic_interpolation_follow_gltf() {
1337 let lin = chan(
1338 ChannelProperty::Translation,
1339 Interpolation::Linear,
1340 &[0., 1.],
1341 &[0., 0., 0., 2., 4., 6.],
1342 );
1343 assert_eq!(lin.sample(0.25), vec![0.5, 1., 1.5]);
1344 assert_eq!(lin.sample(-1.), vec![0., 0., 0.]);
1345 assert_eq!(lin.sample(5.), vec![2., 4., 6.]);
1346 let step = Interpolation::Step;
1347 let st = chan(
1348 ChannelProperty::Translation,
1349 step,
1350 &[0., 1.],
1351 &[0., 0., 0., 2., 4., 6.],
1352 );
1353 assert_eq!(st.sample(0.99), vec![0., 0., 0.]);
1354 assert_eq!(st.sample(1.), vec![2., 4., 6.]);
1355 // Cubic: (in, value, out) per key; zero tangents give smoothstep.
1356 let cubic = chan(
1357 ChannelProperty::Translation,
1358 Interpolation::CubicSpline,
1359 &[0., 2.],
1360 &[
1361 9., 9., 9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 10., 10., 10., 9., 9., 9.,
1362 ],
1363 );
1364 cubic.validate().unwrap();
1365 let v = cubic.sample(0.5); // t = 0.25 -> h01 = 0.15625
1366 assert!((v[0] - 1.5625).abs() < 1e-5, "{v:?}");
1367 // Tangents are scaled by the key interval: out-tangent 1/s over 2 s.
1368 let sloped = chan(
1369 ChannelProperty::Translation,
1370 Interpolation::CubicSpline,
1371 &[0., 2.],
1372 &[
1373 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 2., 0., 0., 0., 0., 0.,
1374 ],
1375 );
1376 let v = sloped.sample(1.); // t=.5: h01*2 + h10*2*1 = 1 + 0.25
1377 assert!((v[0] - 1.25).abs() < 1e-5, "{v:?}");
1378 // Cubic rotation stays unit length.
1379 let q = chan(
1380 ChannelProperty::Rotation,
1381 Interpolation::CubicSpline,
1382 &[0., 1.],
1383 &[
1384 0.,
1385 0.,
1386 0.,
1387 0.,
1388 0.,
1389 0.,
1390 0.,
1391 1.,
1392 0.,
1393 0.,
1394 0.,
1395 0.,
1396 0.,
1397 0.,
1398 0.,
1399 0.,
1400 0.,
1401 0.,
1402 std::f32::consts::FRAC_1_SQRT_2,
1403 std::f32::consts::FRAC_1_SQRT_2,
1404 0.,
1405 0.,
1406 0.,
1407 0.,
1408 ],
1409 );
1410 let r = q.sample(0.5);
1411 let len: f32 = r.iter().map(|x| x * x).sum::<f32>().sqrt();
1412 assert!((len - 1.).abs() < 1e-4);
1413 // Validation rejects malformed channels.
1414 assert!(chan(
1415 ChannelProperty::Translation,
1416 Interpolation::CubicSpline,
1417 &[0., 1.],
1418 &[0.; 6]
1419 )
1420 .validate()
1421 .is_err());
1422 assert!(chan(
1423 ChannelProperty::Translation,
1424 Interpolation::Linear,
1425 &[1., 0.],
1426 &[0.; 6]
1427 )
1428 .validate()
1429 .is_err());
1430 assert!(chan(
1431 ChannelProperty::Translation,
1432 Interpolation::Linear,
1433 &[0., f32::NAN],
1434 &[0.; 6]
1435 )
1436 .validate()
1437 .is_err());
1438 assert!(chan(
1439 ChannelProperty::Scale,
1440 Interpolation::Linear,
1441 &[0.],
1442 &[f32::INFINITY, 0., 0.]
1443 )
1444 .validate()
1445 .is_err());
1446 }
1447
1448 #[test]
1449 fn clip_lookup_prefers_exact_name() {
1450 let clip = |name: &str| PreviewClip {
1451 name: name.into(),
1452 duration: 1.,
1453 channels: Vec::new(),
1454 skipped: Vec::new(),
1455 };
1456 let clips = vec![clip("walk_fast"), clip("walk"), clip("Attack")];
1457 assert_eq!(find_clip(&clips, "walk").unwrap().name, "walk");
1458 assert_eq!(find_clip(&clips, "attack").unwrap().name, "Attack");
1459 assert!(find_clip(&clips, "run").is_none());
1460 }
1461
1462 fn glb_with(edit: impl Fn(&mut serde_json::Value)) -> std::path::PathBuf {
1463 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1464 .join("tests/fixtures/bodies/test-body-male.glb");
1465 let bytes = std::fs::read(src).unwrap();
1466 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
1467 let mut doc: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap();
1468 edit(&mut doc);
1469 let mut json = serde_json::to_vec(&doc).unwrap();
1470 while !json.len().is_multiple_of(4) {
1471 json.push(b' ');
1472 }
1473 let rest = &bytes[20 + json_len..];
1474 let mut out = Vec::new();
1475 out.extend_from_slice(&bytes[0..8]);
1476 out.extend_from_slice(&((12 + 8 + json.len() + rest.len()) as u32).to_le_bytes());
1477 out.extend_from_slice(&(json.len() as u32).to_le_bytes());
1478 out.extend_from_slice(b"JSON");
1479 out.extend_from_slice(&json);
1480 out.extend_from_slice(rest);
1481 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1482 let path = std::env::temp_dir().join(format!(
1483 "ppd-anim-{}-{}.glb",
1484 std::process::id(),
1485 N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1486 ));
1487 std::fs::write(&path, out).unwrap();
1488 path
1489 }
1490
1491 #[test]
1492 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1493 let base = load_gltf_file(
1494 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1495 .join("tests/fixtures/bodies/test-body-male.glb"),
1496 )
1497 .unwrap();
1498 assert!(base.clips.iter().all(|c| c
1499 .channels
1500 .iter()
1501 .all(|ch| ch.interpolation == Interpolation::Linear)));
1502 let step = glb_with(|d| {
1503 for a in d["animations"].as_array_mut().unwrap() {
1504 for s in a["samplers"].as_array_mut().unwrap() {
1505 s["interpolation"] = "STEP".into();
1506 }
1507 a.as_object_mut().unwrap().remove("name");
1508 }
1509 });
1510 let loaded = load_gltf_file(&step).unwrap();
1511 assert!(loaded.clips.iter().all(|c| c
1512 .channels
1513 .iter()
1514 .all(|ch| ch.interpolation == Interpolation::Step)));
1515 assert_eq!(loaded.clips[0].name, "animatie 0");
1516 assert_ne!(loaded.clips[0].name, loaded.clips[1].name);
1517 // Between keys a STEP channel holds the left key.
1518 let ch = loaded.clips[0]
1519 .channels
1520 .iter()
1521 .find(|c| c.times.len() > 2)
1522 .unwrap();
1523 let mid = (ch.times[0] + ch.times[1]) / 2.;
1524 assert_eq!(ch.sample(mid), ch.sample(ch.times[0]));
1525 // CUBICSPLINE without tangent triples is rejected explicitly.
1526 let cubic = glb_with(|d| {
1527 d["animations"][0]["samplers"][0]["interpolation"] = "CUBICSPLINE".into();
1528 });
1529 let err = load_gltf_file(&cubic).err().unwrap();
1530 assert!(err.contains("ongeldig kanaal"), "{err}");
1531 let _ = std::fs::remove_file(step);
1532 let _ = std::fs::remove_file(cubic);
1533 }
1534
1225 1535 fn male() -> PreviewCharacter {
1226 1536 load_gltf_file(
1227 1537 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
@@ -158,6 +158,18 @@ pub struct PreviewClip {
158 pub name: String,
158 pub name: String,
159 pub duration: f32,
159 pub duration: f32,
160 pub channels: Vec<AnimChannel>,
160 pub channels: Vec<AnimChannel>,
161 /// Source channels not applied, with the reason (e.g. morph weights).
162 pub skipped: Vec<String>,
163}
164
165/// Keyframe interpolation as stored in the source (glTF sampler).
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub enum Interpolation {
168 #[default]
169 Linear,
170 Step,
171 /// Hermite spline; `values` holds (in-tangent, value, out-tangent) per key.
172 CubicSpline,
161}
173}
162
174
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -173,6 +185,110 @@ pub struct AnimChannel {
173 pub property: ChannelProperty,
185 pub property: ChannelProperty,
174 pub times: Vec<f32>,
186 pub times: Vec<f32>,
175 pub values: Vec<f32>,
187 pub values: Vec<f32>,
188 pub interpolation: Interpolation,
189}
190
191impl ChannelProperty {
192 pub fn width(self) -> usize {
193 match self {
194 Self::Rotation => 4,
195 _ => 3,
196 }
197 }
198}
199
200impl AnimChannel {
201 /// Times finite and non-decreasing; one value (or tangent triple) per key, finite.
202 pub fn validate(&self) -> Result<(), String> {
203 if self.times.is_empty() {
204 return Err("kanaal zonder keyframes".into());
205 }
206 if self.times.iter().any(|t| !t.is_finite() || *t < 0.)
207 || self.times.windows(2).any(|w| w[1] < w[0])
208 {
209 return Err("keyframetijden niet eindig of niet oplopend".into());
210 }
211 let per_key = self.property.width()
212 * if self.interpolation == Interpolation::CubicSpline {
213 3
214 } else {
215 1
216 };
217 if self.values.len() != self.times.len() * per_key {
218 return Err(format!(
219 "{} waarden voor {} keyframes (verwacht {per_key} per key)",
220 self.values.len(),
221 self.times.len()
222 ));
223 }
224 if self.values.iter().any(|v| !v.is_finite()) {
225 return Err("niet-eindige keyframewaarde".into());
226 }
227 Ok(())
228 }
229
230 fn value(&self, key: usize, part: usize) -> &[f32] {
231 let w = self.property.width();
232 let index = if self.interpolation == Interpolation::CubicSpline {
233 key * 3 + part
234 } else {
235 key
236 };
237 &self.values[index * w..index * w + w]
238 }
239
240 /// Sampled value at `time` (clamped to the first/last key).
241 pub fn sample(&self, time: f32) -> Vec<f32> {
242 let (left, right, blend) = key_blend(&self.times, time);
243 let w = self.property.width();
244 let a = self.value(left, 1);
245 if left == right || self.interpolation == Interpolation::Step {
246 return a.to_vec();
247 }
248 let b = self.value(right, 1);
249 match self.interpolation {
250 Interpolation::Step => a.to_vec(),
251 Interpolation::Linear => {
252 if self.property == ChannelProperty::Rotation {
253 let q = slerp([a[0], a[1], a[2], a[3]], [b[0], b[1], b[2], b[3]], blend);
254 q.to_vec()
255 } else {
256 (0..w).map(|i| a[i] + (b[i] - a[i]) * blend).collect()
257 }
258 }
259 Interpolation::CubicSpline => {
260 let dt = self.times[right] - self.times[left];
261 let out_a = self.value(left, 2);
262 let in_b = self.value(right, 0);
263 let t = blend;
264 let (t2, t3) = (t * t, t * t * t);
265 let h00 = 2. * t3 - 3. * t2 + 1.;
266 let h10 = t3 - 2. * t2 + t;
267 let h01 = -2. * t3 + 3. * t2;
268 let h11 = t3 - t2;
269 let v: Vec<f32> = (0..w)
270 .map(|i| h00 * a[i] + h10 * dt * out_a[i] + h01 * b[i] + h11 * dt * in_b[i])
271 .collect();
272 if self.property == ChannelProperty::Rotation {
273 normalize4([v[0], v[1], v[2], v[3]]).to_vec()
274 } else {
275 v
276 }
277 }
278 }
279 }
280}
281
282/// Clip for a requested name: exact match first, then the first clip whose
283/// name contains it (case-insensitive), so "walk" never picks "walk_fast"
284/// when "walk" exists.
285pub fn find_clip<'a>(clips: &'a [PreviewClip], name: &str) -> Option<&'a PreviewClip> {
286 clips.iter().find(|c| c.name == name).or_else(|| {
287 let lower = name.to_lowercase();
288 clips
289 .iter()
290 .find(|c| c.name.to_lowercase().contains(&lower))
291 })
176}
292}
177
293
178/// Whether a body model path is drawable by the live viewport and production batch.
294/// Whether a body model path is drawable by the live viewport and production batch.
@@ -456,17 +572,22 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
456 let mut clips = Vec::new();
572 let mut clips = Vec::new();
457 for animation in document.animations() {
573 for animation in document.animations() {
458 let mut channels = Vec::new();
574 let mut channels = Vec::new();
575 let mut skipped = Vec::new();
459 let mut duration = 0.0_f32;
576 let mut duration = 0.0_f32;
460 for channel in animation.channels() {
577 for channel in animation.channels() {
578 let target = channel.target().node().index();
579 let interpolation = match channel.sampler().interpolation() {
580 gltf::animation::Interpolation::Linear => Interpolation::Linear,
581 gltf::animation::Interpolation::Step => Interpolation::Step,
582 gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
583 };
461 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
584 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
462 let times: Vec<f32> = reader
585 let times: Vec<f32> = reader
463 .read_inputs()
586 .read_inputs()
464 .map(|iter| iter.collect())
587 .map(|iter| iter.collect())
465 .unwrap_or_default();
588 .unwrap_or_default();
466 if let Some(last) = times.last() {
467 duration = duration.max(*last);
468 }
469 let Some(outputs) = reader.read_outputs() else {
589 let Some(outputs) = reader.read_outputs() else {
590 skipped.push(format!("node {target}: geen uitvoerwaarden"));
470 continue;
591 continue;
471 };
592 };
472 let (property, values) = match outputs {
593 let (property, values) = match outputs {
@@ -480,19 +601,40 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
480 ChannelProperty::Rotation,
601 ChannelProperty::Rotation,
481 values.into_f32().flatten().collect(),
602 values.into_f32().flatten().collect(),
482 ),
603 ),
483 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
604 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => {
605 skipped.push(format!(
606 "node {target}: morph-target-gewichten (niet ondersteund)"
607 ));
608 continue;
609 }
484 };
610 };
485 channels.push(AnimChannel {
611 let channel = AnimChannel {
486 node: channel.target().node().index(),
612 node: target,
487 property,
613 property,
488 times,
614 times,
489 values,
615 values,
490 });
616 interpolation,
617 };
618 if let Err(reason) = channel.validate() {
619 return Err(format!(
620 "animatie '{}': ongeldig kanaal op node {target}: {reason}",
621 animation.name().unwrap_or("naamloos")
622 ));
623 }
624 if let Some(last) = channel.times.last() {
625 duration = duration.max(*last);
626 }
627 channels.push(channel);
491 }
628 }
492 clips.push(PreviewClip {
629 clips.push(PreviewClip {
493 name: animation.name().unwrap_or("naamloos").to_string(),
630 name: animation
631 .name()
632 .filter(|n| !n.trim().is_empty())
633 .map(str::to_string)
634 .unwrap_or_else(|| format!("animatie {}", animation.index())),
494 duration,
635 duration,
495 channels,
636 channels,
637 skipped,
496 });
638 });
497 }
639 }
498 Ok(PreviewCharacter {
640 Ok(PreviewCharacter {
@@ -887,37 +1029,17 @@ fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f3
887 .collect();
1029 .collect();
888 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
1030 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
889 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
1031 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
890 if let Some(clip) = character.clips.iter().find(|clip| {
1032 if let Some(clip) = find_clip(&character.clips, clip_name) {
891 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
892 }) {
893 let time = wrap_time(time, clip.duration);
1033 let time = wrap_time(time, clip.duration);
894 for channel in &clip.channels {
1034 for channel in &clip.channels {
895 if channel.node >= character.nodes.len() || channel.times.is_empty() {
1035 if channel.node >= character.nodes.len() || channel.validate().is_err() {
896 continue;
1036 continue;
897 }
1037 }
898 let (left, right, blend) = key_blend(&channel.times, time);
1038 let v = channel.sample(time);
899 match channel.property {
1039 match channel.property {
900 ChannelProperty::Translation => {
1040 ChannelProperty::Translation => translation[channel.node] = [v[0], v[1], v[2]],
901 translation[channel.node] = lerp3(
1041 ChannelProperty::Scale => scale[channel.node] = [v[0], v[1], v[2]],
902 key3(&channel.values, left),
1042 ChannelProperty::Rotation => rotation[channel.node] = [v[0], v[1], v[2], v[3]],
903 key3(&channel.values, right),
904 blend,
905 );
906 }
907 ChannelProperty::Scale => {
908 scale[channel.node] = lerp3(
909 key3(&channel.values, left),
910 key3(&channel.values, right),
911 blend,
912 );
913 }
914 ChannelProperty::Rotation => {
915 rotation[channel.node] = slerp(
916 key4(&channel.values, left),
917 key4(&channel.values, right),
918 blend,
919 );
920 }
921 }
1043 }
922 }
1044 }
923 }
1045 }
@@ -980,33 +1102,6 @@ fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
980 (index, index + 1, blend)
1102 (index, index + 1, blend)
981}
1103}
982
1104
983fn key3(values: &[f32], index: usize) -> [f32; 3] {
984 let start = index * 3;
985 [
986 values.get(start).copied().unwrap_or(0.0),
987 values.get(start + 1).copied().unwrap_or(0.0),
988 values.get(start + 2).copied().unwrap_or(0.0),
989 ]
990}
991
992fn key4(values: &[f32], index: usize) -> [f32; 4] {
993 let start = index * 4;
994 [
995 values.get(start).copied().unwrap_or(0.0),
996 values.get(start + 1).copied().unwrap_or(0.0),
997 values.get(start + 2).copied().unwrap_or(0.0),
998 values.get(start + 3).copied().unwrap_or(1.0),
999 ]
1000}
1001
1002fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
1003 [
1004 a[0] + (b[0] - a[0]) * t,
1005 a[1] + (b[1] - a[1]) * t,
1006 a[2] + (b[2] - a[2]) * t,
1007 ]
1008}
1009
1010fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1105fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1011 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1106 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1012 if dot < 0.0 {
1107 if dot < 0.0 {
@@ -1222,6 +1317,221 @@ fn normalize3(value: [f32; 3]) -> [f32; 3] {
1222mod tests {
1317mod tests {
1223 use super::*;
1318 use super::*;
1224
1319
1320 fn chan(
1321 property: ChannelProperty,
1322 interpolation: Interpolation,
1323 times: &[f32],
1324 values: &[f32],
1325 ) -> AnimChannel {
1326 AnimChannel {
1327 node: 0,
1328 property,
1329 times: times.to_vec(),
1330 values: values.to_vec(),
1331 interpolation,
1332 }
1333 }
1334
1335 #[test]
1336 fn step_linear_and_cubic_interpolation_follow_gltf() {
1337 let lin = chan(
1338 ChannelProperty::Translation,
1339 Interpolation::Linear,
1340 &[0., 1.],
1341 &[0., 0., 0., 2., 4., 6.],
1342 );
1343 assert_eq!(lin.sample(0.25), vec![0.5, 1., 1.5]);
1344 assert_eq!(lin.sample(-1.), vec![0., 0., 0.]);
1345 assert_eq!(lin.sample(5.), vec![2., 4., 6.]);
1346 let step = Interpolation::Step;
1347 let st = chan(
1348 ChannelProperty::Translation,
1349 step,
1350 &[0., 1.],
1351 &[0., 0., 0., 2., 4., 6.],
1352 );
1353 assert_eq!(st.sample(0.99), vec![0., 0., 0.]);
1354 assert_eq!(st.sample(1.), vec![2., 4., 6.]);
1355 // Cubic: (in, value, out) per key; zero tangents give smoothstep.
1356 let cubic = chan(
1357 ChannelProperty::Translation,
1358 Interpolation::CubicSpline,
1359 &[0., 2.],
1360 &[
1361 9., 9., 9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 10., 10., 10., 9., 9., 9.,
1362 ],
1363 );
1364 cubic.validate().unwrap();
1365 let v = cubic.sample(0.5); // t = 0.25 -> h01 = 0.15625
1366 assert!((v[0] - 1.5625).abs() < 1e-5, "{v:?}");
1367 // Tangents are scaled by the key interval: out-tangent 1/s over 2 s.
1368 let sloped = chan(
1369 ChannelProperty::Translation,
1370 Interpolation::CubicSpline,
1371 &[0., 2.],
1372 &[
1373 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 2., 0., 0., 0., 0., 0.,
1374 ],
1375 );
1376 let v = sloped.sample(1.); // t=.5: h01*2 + h10*2*1 = 1 + 0.25
1377 assert!((v[0] - 1.25).abs() < 1e-5, "{v:?}");
1378 // Cubic rotation stays unit length.
1379 let q = chan(
1380 ChannelProperty::Rotation,
1381 Interpolation::CubicSpline,
1382 &[0., 1.],
1383 &[
1384 0.,
1385 0.,
1386 0.,
1387 0.,
1388 0.,
1389 0.,
1390 0.,
1391 1.,
1392 0.,
1393 0.,
1394 0.,
1395 0.,
1396 0.,
1397 0.,
1398 0.,
1399 0.,
1400 0.,
1401 0.,
1402 std::f32::consts::FRAC_1_SQRT_2,
1403 std::f32::consts::FRAC_1_SQRT_2,
1404 0.,
1405 0.,
1406 0.,
1407 0.,
1408 ],
1409 );
1410 let r = q.sample(0.5);
1411 let len: f32 = r.iter().map(|x| x * x).sum::<f32>().sqrt();
1412 assert!((len - 1.).abs() < 1e-4);
1413 // Validation rejects malformed channels.
1414 assert!(chan(
1415 ChannelProperty::Translation,
1416 Interpolation::CubicSpline,
1417 &[0., 1.],
1418 &[0.; 6]
1419 )
1420 .validate()
1421 .is_err());
1422 assert!(chan(
1423 ChannelProperty::Translation,
1424 Interpolation::Linear,
1425 &[1., 0.],
1426 &[0.; 6]
1427 )
1428 .validate()
1429 .is_err());
1430 assert!(chan(
1431 ChannelProperty::Translation,
1432 Interpolation::Linear,
1433 &[0., f32::NAN],
1434 &[0.; 6]
1435 )
1436 .validate()
1437 .is_err());
1438 assert!(chan(
1439 ChannelProperty::Scale,
1440 Interpolation::Linear,
1441 &[0.],
1442 &[f32::INFINITY, 0., 0.]
1443 )
1444 .validate()
1445 .is_err());
1446 }
1447
1448 #[test]
1449 fn clip_lookup_prefers_exact_name() {
1450 let clip = |name: &str| PreviewClip {
1451 name: name.into(),
1452 duration: 1.,
1453 channels: Vec::new(),
1454 skipped: Vec::new(),
1455 };
1456 let clips = vec![clip("walk_fast"), clip("walk"), clip("Attack")];
1457 assert_eq!(find_clip(&clips, "walk").unwrap().name, "walk");
1458 assert_eq!(find_clip(&clips, "attack").unwrap().name, "Attack");
1459 assert!(find_clip(&clips, "run").is_none());
1460 }
1461
1462 fn glb_with(edit: impl Fn(&mut serde_json::Value)) -> std::path::PathBuf {
1463 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1464 .join("tests/fixtures/bodies/test-body-male.glb");
1465 let bytes = std::fs::read(src).unwrap();
1466 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
1467 let mut doc: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap();
1468 edit(&mut doc);
1469 let mut json = serde_json::to_vec(&doc).unwrap();
1470 while !json.len().is_multiple_of(4) {
1471 json.push(b' ');
1472 }
1473 let rest = &bytes[20 + json_len..];
1474 let mut out = Vec::new();
1475 out.extend_from_slice(&bytes[0..8]);
1476 out.extend_from_slice(&((12 + 8 + json.len() + rest.len()) as u32).to_le_bytes());
1477 out.extend_from_slice(&(json.len() as u32).to_le_bytes());
1478 out.extend_from_slice(b"JSON");
1479 out.extend_from_slice(&json);
1480 out.extend_from_slice(rest);
1481 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1482 let path = std::env::temp_dir().join(format!(
1483 "ppd-anim-{}-{}.glb",
1484 std::process::id(),
1485 N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1486 ));
1487 std::fs::write(&path, out).unwrap();
1488 path
1489 }
1490
1491 #[test]
1492 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1493 let base = load_gltf_file(
1494 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1495 .join("tests/fixtures/bodies/test-body-male.glb"),
1496 )
1497 .unwrap();
1498 assert!(base.clips.iter().all(|c| c
1499 .channels
1500 .iter()
1501 .all(|ch| ch.interpolation == Interpolation::Linear)));
1502 let step = glb_with(|d| {
1503 for a in d["animations"].as_array_mut().unwrap() {
1504 for s in a["samplers"].as_array_mut().unwrap() {
1505 s["interpolation"] = "STEP".into();
1506 }
1507 a.as_object_mut().unwrap().remove("name");
1508 }
1509 });
1510 let loaded = load_gltf_file(&step).unwrap();
1511 assert!(loaded.clips.iter().all(|c| c
1512 .channels
1513 .iter()
1514 .all(|ch| ch.interpolation == Interpolation::Step)));
1515 assert_eq!(loaded.clips[0].name, "animatie 0");
1516 assert_ne!(loaded.clips[0].name, loaded.clips[1].name);
1517 // Between keys a STEP channel holds the left key.
1518 let ch = loaded.clips[0]
1519 .channels
1520 .iter()
1521 .find(|c| c.times.len() > 2)
1522 .unwrap();
1523 let mid = (ch.times[0] + ch.times[1]) / 2.;
1524 assert_eq!(ch.sample(mid), ch.sample(ch.times[0]));
1525 // CUBICSPLINE without tangent triples is rejected explicitly.
1526 let cubic = glb_with(|d| {
1527 d["animations"][0]["samplers"][0]["interpolation"] = "CUBICSPLINE".into();
1528 });
1529 let err = load_gltf_file(&cubic).err().unwrap();
1530 assert!(err.contains("ongeldig kanaal"), "{err}");
1531 let _ = std::fs::remove_file(step);
1532 let _ = std::fs::remove_file(cubic);
1533 }
1534
1225 fn male() -> PreviewCharacter {
1535 fn male() -> PreviewCharacter {
1226 load_gltf_file(
1536 load_gltf_file(
1227 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1537 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
src/production.rs +12 −0
@@ -1589,6 +1589,18 @@ fn execute_target(
1589 1589 )?
1590 1590 }
1591 1591 };
1592 for clip in &job.character.clips {
1593 for reason in &clip.skipped {
1594 emit(RenderEvent::Step {
1595 index: 0,
1596 total: plan.passes,
1597 label: format!(
1598 "Let op: clip '{}': kanaal niet toegepast: {reason}",
1599 clip.name
1600 ),
1601 });
1602 }
1603 }
1592 1604 let root = session.root.clone();
1593 1605 // Internal unique names select every source clip, even duplicate display names.
1594 1606 for (i, c) in job.character.clips.iter_mut().enumerate() {
@@ -1589,6 +1589,18 @@ fn execute_target(
1589 )?
1589 )?
1590 }
1590 }
1591 };
1591 };
1592 for clip in &job.character.clips {
1593 for reason in &clip.skipped {
1594 emit(RenderEvent::Step {
1595 index: 0,
1596 total: plan.passes,
1597 label: format!(
1598 "Let op: clip '{}': kanaal niet toegepast: {reason}",
1599 clip.name
1600 ),
1601 });
1602 }
1603 }
1592 let root = session.root.clone();
1604 let root = session.root.clone();
1593 // Internal unique names select every source clip, even duplicate display names.
1605 // Internal unique names select every source clip, even duplicate display names.
1594 for (i, c) in job.character.clips.iter_mut().enumerate() {
1606 for (i, c) in job.character.clips.iter_mut().enumerate() {