Merge feature/anim-fidelity: animation interpolation fidelity + channel validation (#10)
@@ -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 --locked85
85
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.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
## Animaties89
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
## Productie-output92
## Productie-output89
93
90
### Render alles94
### Render alles@@ -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<PreviewCl818
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
}@@ -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)]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
#[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
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
3214
} else {215
1216
};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 + part234
} else {235
key236
};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
v276
}277
}278
}279
}280
}281
282
/// Clip for a requested name: exact match first, then the first clip whose283
/// 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
clips289
.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> = reader585
let times: Vec<f32> = reader463
.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: animation631
.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: f3887
.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
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
fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {1105
fn 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] {1222
mod tests {1317
mod 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.156251366
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.251377
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
path1489
}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| c1499
.channels1500
.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| c1512
.channels1513
.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
.channels1520
.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"),@@ -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.name1600
),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() {