Skip to main content

oxihuman_morph/
incremental.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Incremental morph update system.
5//!
6//! Tracks which morph targets have changed since the last mesh build and caches
7//! per-target position contributions so that only dirty targets need recomputation.
8
9use std::collections::{HashMap, HashSet};
10
11// ---------------------------------------------------------------------------
12// DirtyTracker
13// ---------------------------------------------------------------------------
14
15/// Tracks which morph targets have been modified and need recomputation.
16#[derive(Debug, Clone)]
17pub struct DirtyTracker {
18    dirty: HashSet<String>,
19}
20
21impl Default for DirtyTracker {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl DirtyTracker {
28    /// Create a new tracker with no dirty targets.
29    pub fn new() -> Self {
30        Self {
31            dirty: HashSet::new(),
32        }
33    }
34
35    /// Mark a single target as dirty (needs recomputation).
36    pub fn mark_dirty(&mut self, name: &str) {
37        self.dirty.insert(name.to_string());
38    }
39
40    /// Mark every known target as dirty. This accepts the set of all target
41    /// names so the tracker does not need to maintain its own registry.
42    pub fn mark_all_dirty(&mut self, all_names: &[&str]) {
43        for name in all_names {
44            self.dirty.insert((*name).to_string());
45        }
46    }
47
48    /// Clear all dirty flags (call after a successful rebuild).
49    pub fn clear(&mut self) {
50        self.dirty.clear();
51    }
52
53    /// Returns `true` if the given target is dirty.
54    pub fn is_dirty(&self, name: &str) -> bool {
55        self.dirty.contains(name)
56    }
57
58    /// Number of targets currently marked dirty.
59    pub fn dirty_count(&self) -> usize {
60        self.dirty.len()
61    }
62
63    /// Snapshot of all dirty target names (unordered).
64    pub fn dirty_targets(&self) -> Vec<&str> {
65        self.dirty.iter().map(|s| s.as_str()).collect()
66    }
67
68    /// Returns `true` when there are no dirty targets.
69    pub fn is_clean(&self) -> bool {
70        self.dirty.is_empty()
71    }
72}
73
74// ---------------------------------------------------------------------------
75// IncrementalMorphCache
76// ---------------------------------------------------------------------------
77
78/// Per-target weighted delta buffer stored as a flat `Vec<f32>` of length
79/// `vertex_count * 3`. Entry `[i*3], [i*3+1], [i*3+2]` stores the xyz
80/// displacement contributed by this target at vertex `i`.
81#[derive(Debug, Clone)]
82struct TargetContribution {
83    /// Flat xyz contribution buffer (length = vertex_count * 3).
84    buf: Vec<f32>,
85}
86
87/// Caches the weighted contribution of each morph target so that a full
88/// summation is only needed once; subsequent updates recompute only the
89/// targets that changed.
90#[derive(Debug, Clone)]
91pub struct IncrementalMorphCache {
92    contributions: HashMap<String, TargetContribution>,
93    vertex_count: usize,
94}
95
96impl IncrementalMorphCache {
97    /// Create a new empty cache for a mesh with `vertex_count` vertices.
98    pub fn new(vertex_count: usize) -> Self {
99        Self {
100            contributions: HashMap::new(),
101            vertex_count,
102        }
103    }
104
105    /// Return the vertex count this cache was created for.
106    pub fn vertex_count(&self) -> usize {
107        self.vertex_count
108    }
109
110    /// Number of cached target contributions.
111    pub fn target_count(&self) -> usize {
112        self.contributions.len()
113    }
114
115    /// Update (or insert) the cached contribution for target `name`.
116    ///
117    /// `deltas` is a slice of `(vertex_id, dx, dy, dz)` sparse deltas and
118    /// `weight` is the scalar weight to multiply them by.  The contribution
119    /// buffer is zeroed first, then the weighted deltas are scattered in.
120    pub fn update_target(
121        &mut self,
122        name: &str,
123        deltas: &[(u32, f32, f32, f32)],
124        weight: f32,
125        vertex_count: usize,
126    ) {
127        let len = vertex_count * 3;
128        let entry = self
129            .contributions
130            .entry(name.to_string())
131            .or_insert_with(|| TargetContribution {
132                buf: vec![0.0; len],
133            });
134
135        // Resize if needed (defensive).
136        if entry.buf.len() != len {
137            entry.buf.resize(len, 0.0);
138        }
139
140        // Zero out old contribution.
141        for v in entry.buf.iter_mut() {
142            *v = 0.0;
143        }
144
145        // Scatter weighted deltas.
146        for &(vid, dx, dy, dz) in deltas {
147            let idx = vid as usize * 3;
148            if idx + 2 < len {
149                entry.buf[idx] += weight * dx;
150                entry.buf[idx + 1] += weight * dy;
151                entry.buf[idx + 2] += weight * dz;
152            }
153        }
154    }
155
156    /// Remove a target's contribution from the cache.
157    pub fn remove_target(&mut self, name: &str) {
158        self.contributions.remove(name);
159    }
160
161    /// Full rebuild: `base_positions + sum(all contributions)`.
162    ///
163    /// `base_positions` is a flat `[x0, y0, z0, x1, y1, z1, ...]` array of
164    /// length `vertex_count * 3`.  Returns a new flat position buffer of the
165    /// same layout.
166    pub fn rebuild_mesh(&self, base_positions: &[f32]) -> Vec<f32> {
167        let len = base_positions.len();
168        let mut out = base_positions.to_vec();
169        for contrib in self.contributions.values() {
170            let n = contrib.buf.len().min(len);
171            for (out_val, &src_val) in out[..n].iter_mut().zip(contrib.buf[..n].iter()) {
172                *out_val += src_val;
173            }
174        }
175        out
176    }
177
178    /// Incremental rebuild: only recompute dirty targets.
179    ///
180    /// `current` is the mesh position buffer from the previous frame (mutated
181    /// in-place).  For each dirty target the old contribution is subtracted
182    /// and the new one (already stored via `update_target`) is added.
183    ///
184    /// `old_contributions` maps target name -> the *previous* contribution
185    /// buffer that was already baked into `current`.  After this call,
186    /// `current` reflects the latest cached contributions.
187    ///
188    /// Targets present in the dirty set but absent from `old_contributions`
189    /// are treated as newly added (old contribution is zero).
190    pub fn rebuild_incremental(
191        &self,
192        current: &mut [f32],
193        dirty: &DirtyTracker,
194        old_contributions: &HashMap<String, Vec<f32>>,
195    ) {
196        let len = current.len();
197
198        for dirty_name in dirty.dirty_targets() {
199            // Subtract old contribution if it existed.
200            if let Some(old_buf) = old_contributions.get(dirty_name) {
201                let n = old_buf.len().min(len);
202                for i in 0..n {
203                    current[i] -= old_buf[i];
204                }
205            }
206
207            // Add new contribution.
208            if let Some(new_contrib) = self.contributions.get(dirty_name) {
209                let n = new_contrib.buf.len().min(len);
210                for (cur_val, &src_val) in current[..n].iter_mut().zip(new_contrib.buf[..n].iter())
211                {
212                    *cur_val += src_val;
213                }
214            }
215        }
216    }
217
218    /// Snapshot the current contribution buffer for a target (for use as
219    /// `old_contributions` in the next incremental rebuild).
220    pub fn snapshot_contribution(&self, name: &str) -> Option<Vec<f32>> {
221        self.contributions.get(name).map(|c| c.buf.clone())
222    }
223
224    /// Snapshot all contributions (cheap clone of the inner buffers).
225    pub fn snapshot_all(&self) -> HashMap<String, Vec<f32>> {
226        self.contributions
227            .iter()
228            .map(|(k, v)| (k.clone(), v.buf.clone()))
229            .collect()
230    }
231
232    /// Clear all cached contributions.
233    pub fn clear(&mut self) {
234        self.contributions.clear();
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Tests
240// ---------------------------------------------------------------------------
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    // -- DirtyTracker tests --
247
248    #[test]
249    fn tracker_starts_clean() {
250        let tracker = DirtyTracker::new();
251        assert!(tracker.is_clean());
252        assert_eq!(tracker.dirty_count(), 0);
253        assert!(!tracker.is_dirty("foo"));
254    }
255
256    #[test]
257    fn mark_and_query() {
258        let mut tracker = DirtyTracker::new();
259        tracker.mark_dirty("height");
260        tracker.mark_dirty("weight");
261
262        assert!(tracker.is_dirty("height"));
263        assert!(tracker.is_dirty("weight"));
264        assert!(!tracker.is_dirty("age"));
265        assert_eq!(tracker.dirty_count(), 2);
266    }
267
268    #[test]
269    fn mark_all_dirty() {
270        let mut tracker = DirtyTracker::new();
271        let names = vec!["a", "b", "c"];
272        tracker.mark_all_dirty(&names);
273        assert_eq!(tracker.dirty_count(), 3);
274        for name in &names {
275            assert!(tracker.is_dirty(name));
276        }
277    }
278
279    #[test]
280    fn clear_resets() {
281        let mut tracker = DirtyTracker::new();
282        tracker.mark_dirty("x");
283        tracker.clear();
284        assert!(tracker.is_clean());
285        assert_eq!(tracker.dirty_count(), 0);
286    }
287
288    #[test]
289    fn dirty_targets_returns_all_marked() {
290        let mut tracker = DirtyTracker::new();
291        tracker.mark_dirty("alpha");
292        tracker.mark_dirty("beta");
293        let mut targets = tracker.dirty_targets();
294        targets.sort();
295        assert_eq!(targets, vec!["alpha", "beta"]);
296    }
297
298    #[test]
299    fn duplicate_mark_is_idempotent() {
300        let mut tracker = DirtyTracker::new();
301        tracker.mark_dirty("x");
302        tracker.mark_dirty("x");
303        assert_eq!(tracker.dirty_count(), 1);
304    }
305
306    // -- IncrementalMorphCache tests --
307
308    fn base_3v() -> Vec<f32> {
309        // 3 vertices: (0,0,0), (1,0,0), (0,1,0)
310        vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
311    }
312
313    #[test]
314    fn empty_cache_rebuild_equals_base() {
315        let cache = IncrementalMorphCache::new(3);
316        let base = base_3v();
317        let result = cache.rebuild_mesh(&base);
318        assert_eq!(result, base);
319    }
320
321    #[test]
322    fn update_target_and_rebuild() {
323        let mut cache = IncrementalMorphCache::new(3);
324        // Target moves vertex 1 by (0.5, 0, 0) with weight 1.0
325        let deltas = vec![(1u32, 0.5f32, 0.0f32, 0.0f32)];
326        cache.update_target("height", &deltas, 1.0, 3);
327
328        let base = base_3v();
329        let result = cache.rebuild_mesh(&base);
330
331        // vertex 1 x should be 1.0 + 0.5 = 1.5
332        assert!((result[3] - 1.5).abs() < 1e-6);
333        // other vertices unchanged
334        assert!((result[0] - 0.0).abs() < 1e-6);
335        assert!((result[6] - 0.0).abs() < 1e-6);
336    }
337
338    #[test]
339    fn update_target_with_weight() {
340        let mut cache = IncrementalMorphCache::new(3);
341        let deltas = vec![(0u32, 2.0f32, 0.0f32, 0.0f32)];
342        cache.update_target("stretch", &deltas, 0.5, 3);
343
344        let base = base_3v();
345        let result = cache.rebuild_mesh(&base);
346
347        // vertex 0 x: 0.0 + 2.0 * 0.5 = 1.0
348        assert!((result[0] - 1.0).abs() < 1e-6);
349    }
350
351    #[test]
352    fn remove_target_excludes_contribution() {
353        let mut cache = IncrementalMorphCache::new(3);
354        let deltas = vec![(0u32, 10.0f32, 0.0f32, 0.0f32)];
355        cache.update_target("big", &deltas, 1.0, 3);
356        cache.remove_target("big");
357
358        let base = base_3v();
359        let result = cache.rebuild_mesh(&base);
360        assert!((result[0] - 0.0).abs() < 1e-6);
361    }
362
363    #[test]
364    fn multiple_targets_sum() {
365        let mut cache = IncrementalMorphCache::new(3);
366        cache.update_target("a", &[(0u32, 1.0f32, 0.0f32, 0.0f32)], 1.0, 3);
367        cache.update_target("b", &[(0u32, 0.0f32, 2.0f32, 0.0f32)], 1.0, 3);
368
369        let base = base_3v();
370        let result = cache.rebuild_mesh(&base);
371        // vertex 0: (0+1, 0+2, 0) = (1, 2, 0)
372        assert!((result[0] - 1.0).abs() < 1e-6);
373        assert!((result[1] - 2.0).abs() < 1e-6);
374        assert!((result[2] - 0.0).abs() < 1e-6);
375    }
376
377    #[test]
378    fn incremental_rebuild_matches_full() {
379        let base = base_3v();
380        let mut cache = IncrementalMorphCache::new(3);
381
382        // Initial state: target "h" with weight 0.5
383        let deltas = vec![(1u32, 2.0f32, 0.0f32, 0.0f32)];
384        cache.update_target("h", &deltas, 0.5, 3);
385        let old_snap = cache.snapshot_all();
386        let mut current = cache.rebuild_mesh(&base);
387
388        // Change weight to 0.8
389        cache.update_target("h", &deltas, 0.8, 3);
390        let mut dirty = DirtyTracker::new();
391        dirty.mark_dirty("h");
392
393        cache.rebuild_incremental(&mut current, &dirty, &old_snap);
394
395        // Full rebuild for comparison
396        let full = cache.rebuild_mesh(&base);
397
398        for (i, (a, b)) in current.iter().zip(full.iter()).enumerate() {
399            assert!(
400                (a - b).abs() < 1e-5,
401                "mismatch at index {}: incremental={}, full={}",
402                i,
403                a,
404                b
405            );
406        }
407    }
408
409    #[test]
410    fn incremental_new_target_matches_full() {
411        let base = base_3v();
412        let mut cache = IncrementalMorphCache::new(3);
413
414        // Build with one target
415        cache.update_target("a", &[(0u32, 1.0f32, 0.0f32, 0.0f32)], 1.0, 3);
416        let old_snap = cache.snapshot_all();
417        let mut current = cache.rebuild_mesh(&base);
418
419        // Add a second target
420        cache.update_target("b", &[(2u32, 0.0f32, 0.0f32, 3.0f32)], 1.0, 3);
421        let mut dirty = DirtyTracker::new();
422        dirty.mark_dirty("b");
423
424        cache.rebuild_incremental(&mut current, &dirty, &old_snap);
425
426        let full = cache.rebuild_mesh(&base);
427        for (i, (a, b)) in current.iter().zip(full.iter()).enumerate() {
428            assert!(
429                (a - b).abs() < 1e-5,
430                "mismatch at index {}: incremental={}, full={}",
431                i,
432                a,
433                b
434            );
435        }
436    }
437
438    #[test]
439    fn incremental_remove_target_matches_full() {
440        let base = base_3v();
441        let mut cache = IncrementalMorphCache::new(3);
442
443        cache.update_target("a", &[(0u32, 5.0f32, 0.0f32, 0.0f32)], 1.0, 3);
444        cache.update_target("b", &[(1u32, 0.0f32, 3.0f32, 0.0f32)], 1.0, 3);
445        let old_snap = cache.snapshot_all();
446        let mut current = cache.rebuild_mesh(&base);
447
448        // Remove target "a"
449        cache.remove_target("a");
450        let mut dirty = DirtyTracker::new();
451        dirty.mark_dirty("a");
452
453        cache.rebuild_incremental(&mut current, &dirty, &old_snap);
454
455        let full = cache.rebuild_mesh(&base);
456        for (i, (a, b)) in current.iter().zip(full.iter()).enumerate() {
457            assert!(
458                (a - b).abs() < 1e-5,
459                "mismatch at index {}: incremental={}, full={}",
460                i,
461                a,
462                b
463            );
464        }
465    }
466
467    #[test]
468    fn incremental_no_dirty_is_noop() {
469        let base = base_3v();
470        let mut cache = IncrementalMorphCache::new(3);
471        cache.update_target("x", &[(0u32, 1.0f32, 2.0f32, 3.0f32)], 1.0, 3);
472        let old_snap = cache.snapshot_all();
473        let mut current = cache.rebuild_mesh(&base);
474        let before = current.clone();
475
476        let dirty = DirtyTracker::new(); // nothing dirty
477        cache.rebuild_incremental(&mut current, &dirty, &old_snap);
478
479        assert_eq!(current, before);
480    }
481
482    #[test]
483    fn snapshot_contribution_round_trip() {
484        let mut cache = IncrementalMorphCache::new(3);
485        let deltas = vec![(0u32, 1.0f32, 2.0f32, 3.0f32)];
486        cache.update_target("t", &deltas, 0.5, 3);
487
488        let snap = cache.snapshot_contribution("t");
489        assert!(snap.is_some());
490        let snap = snap.expect("snapshot should exist");
491        // vertex 0: (0.5, 1.0, 1.5)
492        assert!((snap[0] - 0.5).abs() < 1e-6);
493        assert!((snap[1] - 1.0).abs() < 1e-6);
494        assert!((snap[2] - 1.5).abs() < 1e-6);
495    }
496
497    #[test]
498    fn out_of_bounds_vertex_id_is_ignored() {
499        let mut cache = IncrementalMorphCache::new(2);
500        // vertex_count=2 so valid indices are 0..5. vid=10 is out of bounds.
501        let deltas = vec![(10u32, 1.0f32, 1.0f32, 1.0f32)];
502        cache.update_target("oob", &deltas, 1.0, 2);
503
504        let base = vec![0.0f32; 6];
505        let result = cache.rebuild_mesh(&base);
506        // Nothing should have changed.
507        assert_eq!(result, base);
508    }
509
510    #[test]
511    fn clear_empties_cache() {
512        let mut cache = IncrementalMorphCache::new(3);
513        cache.update_target("a", &[(0u32, 1.0, 0.0, 0.0)], 1.0, 3);
514        assert_eq!(cache.target_count(), 1);
515        cache.clear();
516        assert_eq!(cache.target_count(), 0);
517    }
518
519    /// End-to-end: simulate a multi-frame scenario where weights change each
520    /// frame and verify incremental always matches full rebuild.
521    #[test]
522    fn multi_frame_incremental_consistency() {
523        let base = base_3v();
524        let deltas_h = vec![
525            (0u32, 1.0f32, 0.0f32, 0.0f32),
526            (1u32, 0.0f32, 0.5f32, 0.0f32),
527        ];
528        let deltas_w = vec![
529            (1u32, 0.0f32, 0.0f32, 1.0f32),
530            (2u32, 0.3f32, 0.0f32, 0.0f32),
531        ];
532
533        let weight_sequences: &[(f32, f32)] = &[
534            (0.0, 0.0),
535            (0.5, 0.2),
536            (0.8, 0.6),
537            (1.0, 1.0),
538            (0.3, 0.9),
539            (0.0, 0.0),
540        ];
541
542        let mut cache = IncrementalMorphCache::new(3);
543        let mut current = base.clone();
544        let mut old_snap: HashMap<String, Vec<f32>> = HashMap::new();
545
546        for &(wh, ww) in weight_sequences {
547            // Update targets with new weights
548            cache.update_target("h", &deltas_h, wh, 3);
549            cache.update_target("w", &deltas_w, ww, 3);
550
551            let mut dirty = DirtyTracker::new();
552            dirty.mark_dirty("h");
553            dirty.mark_dirty("w");
554
555            cache.rebuild_incremental(&mut current, &dirty, &old_snap);
556
557            let full = cache.rebuild_mesh(&base);
558
559            for (i, (a, b)) in current.iter().zip(full.iter()).enumerate() {
560                assert!(
561                    (a - b).abs() < 1e-4,
562                    "frame wh={}, ww={}: mismatch at {}: inc={}, full={}",
563                    wh,
564                    ww,
565                    i,
566                    a,
567                    b
568                );
569            }
570
571            old_snap = cache.snapshot_all();
572        }
573    }
574}