Skip to main content

concinnity_core/gfx/
morph_targets.rs

1//! Sparse morph-target storage for skinned payloads.
2//!
3//! A morph target moves a small region of the mesh and is zero elsewhere, so
4//! the payload stores only the non-zero deltas. The storage is vertex-major
5//! (compressed sparse row): `offsets[v]..offsets[v + 1]` is the run of
6//! [`MorphEntry`]s touching vertex `v`, each naming its target. That is the
7//! order the deform kernels want: one vertex reads its own run and skips every
8//! target that does not move it.
9//!
10//! The GPU consumes both tables through one buffer, see [`PayloadMorphs::packed_words`].
11
12use alloc::format;
13use alloc::string::String;
14use alloc::string::ToString;
15use alloc::vec;
16use alloc::vec::Vec;
17
18/// One morph-target vertex delta in dense form: position and normal offsets
19/// added to the bind pose before skinning, scaled by the target's weight.
20#[derive(Copy, Clone, Debug, Default, PartialEq)]
21pub struct MorphDelta {
22    /// Bind-space position offset.
23    pub position: [f32; 3],
24    /// Normal offset; the deformed normal is re-normalised.
25    pub normal: [f32; 3],
26}
27
28/// One sparse morph entry as the GPU consumes it: the target it belongs to
29/// plus the position and normal offsets. Plain tightly packed 4-byte fields;
30/// the shader-side struct uses packed types so the 28-byte stride matches.
31#[derive(Copy, Clone, Debug, Default, PartialEq, bytemuck::NoUninit)]
32#[repr(C)]
33pub struct MorphEntry {
34    /// Morph target this delta belongs to.
35    pub target: u32,
36    /// Bind-space position offset.
37    pub position: [f32; 3],
38    /// Normal offset.
39    pub normal: [f32; 3],
40}
41
42/// Deltas whose every component is at or below this magnitude are dropped
43/// when a dense target is sparsified: a micron of position or a 1e-6 normal
44/// tilt is invisible, and imported targets carry that much float noise.
45pub const MORPH_DELTA_EPSILON: f32 = 1e-6;
46
47/// Morph-target block of a skinned payload: target names plus the sparse
48/// vertex-major entries.
49#[derive(Clone, Debug, Default, PartialEq)]
50pub struct PayloadMorphs {
51    /// Morph-target names, in target order.
52    pub names: Vec<String>,
53    /// `vertex_count + 1` entry offsets; vertex `v` owns
54    /// `entries[offsets[v]..offsets[v + 1]]`. Empty when there are no targets.
55    pub offsets: Vec<u32>,
56    /// Sparse entries, grouped by vertex, targets ascending within a vertex.
57    pub entries: Vec<MorphEntry>,
58}
59
60impl PayloadMorphs {
61    /// Whether the mesh declares no morph targets.
62    pub fn is_empty(&self) -> bool {
63        self.names.is_empty()
64    }
65
66    /// Morph targets on the mesh.
67    pub fn target_count(&self) -> usize {
68        self.names.len()
69    }
70
71    /// Vertices the offsets table covers (0 without targets).
72    pub fn vertex_count(&self) -> usize {
73        self.offsets.len().saturating_sub(1)
74    }
75
76    /// Build the sparse block from dense target-major deltas
77    /// (`deltas[t * vertex_count + v]`), keeping every delta with a component
78    /// above [`MORPH_DELTA_EPSILON`].
79    pub fn from_dense(
80        names: Vec<String>,
81        vertex_count: usize,
82        deltas: &[MorphDelta],
83    ) -> Result<Self, String> {
84        if deltas.len() != names.len() * vertex_count {
85            return Err(format!(
86                "morph_deltas has {} entries; {} target(s) x {} vertices requires {}",
87                deltas.len(),
88                names.len(),
89                vertex_count,
90                names.len() * vertex_count,
91            ));
92        }
93        if names.is_empty() {
94            return Ok(Self::default());
95        }
96        let mut offsets = Vec::with_capacity(vertex_count + 1);
97        let mut entries = Vec::new();
98        offsets.push(0u32);
99        for v in 0..vertex_count {
100            for (t, name_block) in deltas.chunks_exact(vertex_count).enumerate() {
101                let d = name_block[v];
102                if is_significant(&d) {
103                    entries.push(MorphEntry {
104                        target: t as u32,
105                        position: d.position,
106                        normal: d.normal,
107                    });
108                }
109            }
110            offsets.push(entries.len() as u32);
111        }
112        Ok(Self {
113            names,
114            offsets,
115            entries,
116        })
117    }
118
119    /// Expand back to dense target-major deltas (`[t * vertex_count + v]`),
120    /// with zeros wherever no entry exists.
121    pub fn to_dense(&self) -> Vec<MorphDelta> {
122        let n = self.vertex_count();
123        let mut out = vec![MorphDelta::default(); self.target_count() * n];
124        for (v, e) in self.vertex_entries() {
125            out[e.target as usize * n + v] = MorphDelta {
126                position: e.position,
127                normal: e.normal,
128            };
129        }
130        out
131    }
132
133    /// Every entry paired with the vertex it belongs to.
134    pub(crate) fn vertex_entries(&self) -> impl Iterator<Item = (usize, &MorphEntry)> {
135        self.offsets.windows(2).enumerate().flat_map(move |(v, w)| {
136            self.entries[w[0] as usize..w[1] as usize]
137                .iter()
138                .map(move |e| (v, e))
139        })
140    }
141
142    /// Check the tables agree: offsets start at 0, never decrease, end at the
143    /// entry count, and every entry names a declared target.
144    pub fn validate(&self) -> Result<(), String> {
145        if self.is_empty() {
146            if !self.offsets.is_empty() || !self.entries.is_empty() {
147                return Err("morph block has entries but no targets".to_string());
148            }
149            return Ok(());
150        }
151        if self.offsets.first() != Some(&0) {
152            return Err("morph offsets must start at 0".to_string());
153        }
154        if self.offsets.windows(2).any(|w| w[1] < w[0]) {
155            return Err("morph offsets must not decrease".to_string());
156        }
157        if self.offsets.last().copied().unwrap_or(0) as usize != self.entries.len() {
158            return Err(format!(
159                "morph offsets end at {} but there are {} entries",
160                self.offsets.last().copied().unwrap_or(0),
161                self.entries.len()
162            ));
163        }
164        let targets = self.target_count() as u32;
165        if let Some(e) = self.entries.iter().find(|e| e.target >= targets) {
166            return Err(format!(
167                "morph entry names target {} of {targets}",
168                e.target
169            ));
170        }
171        Ok(())
172    }
173
174    /// The single GPU buffer the deform kernels read: the offsets table, then
175    /// the entries, which start at the first 16-byte-aligned word past it.
176    /// Each entry is seven 4-byte words laid out as [`MorphEntry`].
177    pub fn packed_words(&self) -> Vec<u32> {
178        if self.is_empty() {
179            return Vec::new();
180        }
181        let base = entry_word_base(self.vertex_count());
182        let mut words = Vec::with_capacity(base + self.entries.len() * MORPH_ENTRY_WORDS);
183        words.extend_from_slice(&self.offsets);
184        words.resize(base, 0);
185        words.extend_from_slice(bytemuck::cast_slice::<MorphEntry, u32>(&self.entries));
186        words
187    }
188}
189
190/// Words per [`MorphEntry`] in the packed buffer.
191pub(crate) const MORPH_ENTRY_WORDS: usize = 7;
192
193/// Word index where the entries begin in [`PayloadMorphs::packed_words`]: the
194/// `vertex_count + 1` offsets rounded up to a 16-byte boundary. The shaders
195/// compute the same value from their `vertex_count` parameter.
196pub(crate) fn entry_word_base(vertex_count: usize) -> usize {
197    (vertex_count + 1 + 3) & !3
198}
199
200fn is_significant(d: &MorphDelta) -> bool {
201    d.position
202        .iter()
203        .chain(d.normal.iter())
204        .any(|x| x.abs() > MORPH_DELTA_EPSILON)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn delta(p: f32) -> MorphDelta {
212        MorphDelta {
213            position: [p, 0.0, 0.0],
214            normal: [0.0, 0.0, 0.0],
215        }
216    }
217
218    fn sample() -> PayloadMorphs {
219        // 2 targets x 3 vertices: target 0 moves v0 and v2, target 1 moves v2.
220        let dense = vec![
221            delta(1.0),
222            delta(0.0),
223            delta(2.0),
224            delta(0.0),
225            delta(0.0),
226            MorphDelta {
227                position: [0.0; 3],
228                normal: [0.0, 0.5, 0.0],
229            },
230        ];
231        PayloadMorphs::from_dense(vec!["a".into(), "b".into()], 3, &dense).expect("dense")
232    }
233
234    #[test]
235    fn sparsifies_and_expands_to_the_same_dense_block() {
236        let m = sample();
237        assert_eq!(m.offsets, vec![0, 1, 1, 3]);
238        assert_eq!(m.entries.len(), 3);
239        assert_eq!(m.entries[1].target, 0);
240        assert_eq!(m.entries[2].target, 1);
241        assert_eq!(m.entries[2].normal, [0.0, 0.5, 0.0]);
242        m.validate().expect("valid");
243        let dense = m.to_dense();
244        assert_eq!(dense.len(), 6);
245        assert_eq!(dense[2], delta(2.0));
246        assert_eq!(dense[5].normal, [0.0, 0.5, 0.0]);
247        assert_eq!(dense[1], MorphDelta::default());
248        let again = PayloadMorphs::from_dense(m.names.clone(), 3, &dense).expect("dense");
249        assert_eq!(again, m);
250    }
251
252    #[test]
253    fn deltas_at_the_epsilon_are_dropped_but_any_component_above_it_is_kept() {
254        let dense = vec![
255            MorphDelta {
256                position: [MORPH_DELTA_EPSILON; 3],
257                normal: [0.0; 3],
258            },
259            MorphDelta {
260                position: [0.0; 3],
261                normal: [0.0, 0.0, -MORPH_DELTA_EPSILON * 2.0],
262            },
263        ];
264        let m = PayloadMorphs::from_dense(vec!["t".into()], 2, &dense).expect("dense");
265        assert_eq!(m.entries.len(), 1);
266        assert_eq!(m.offsets, vec![0, 0, 1]);
267    }
268
269    #[test]
270    fn no_targets_is_the_empty_block() {
271        let m = PayloadMorphs::from_dense(Vec::new(), 5, &[]).expect("dense");
272        assert!(m.is_empty());
273        assert_eq!(m, PayloadMorphs::default());
274        assert!(m.to_dense().is_empty());
275        assert!(m.packed_words().is_empty());
276    }
277
278    #[test]
279    fn a_dense_block_of_the_wrong_length_is_refused() {
280        let err = PayloadMorphs::from_dense(vec!["t".into()], 3, &[delta(1.0)]).unwrap_err();
281        assert!(err.contains("1 target(s) x 3 vertices requires 3"), "{err}");
282    }
283
284    #[test]
285    fn validate_catches_every_table_disagreement() {
286        let mut m = sample();
287        m.offsets[0] = 1;
288        assert!(m.validate().unwrap_err().contains("start at 0"));
289        let mut m = sample();
290        m.offsets[2] = 0;
291        assert!(m.validate().unwrap_err().contains("not decrease"));
292        let mut m = sample();
293        m.offsets[3] = 2;
294        assert!(m.validate().unwrap_err().contains("end at 2"));
295        let mut m = sample();
296        m.entries[0].target = 2;
297        assert!(m.validate().unwrap_err().contains("target 2 of 2"));
298        let mut m = sample();
299        m.names.clear();
300        assert!(m.validate().unwrap_err().contains("no targets"));
301    }
302
303    #[test]
304    fn packed_words_place_entries_at_the_aligned_base() {
305        let m = sample();
306        // 4 offsets round up to 4 words; 3 entries x 7 words follow.
307        assert_eq!(entry_word_base(3), 4);
308        assert_eq!(entry_word_base(4), 8);
309        assert_eq!(entry_word_base(0), 4);
310        let words = m.packed_words();
311        assert_eq!(words.len(), 4 + 3 * MORPH_ENTRY_WORDS);
312        assert_eq!(&words[..4], &[0, 1, 1, 3]);
313        assert_eq!(words[4], 0, "entry 0 target");
314        assert_eq!(f32::from_bits(words[5]), 1.0, "entry 0 position.x");
315        assert_eq!(words[4 + 2 * MORPH_ENTRY_WORDS], 1, "entry 2 target");
316        assert_eq!(
317            f32::from_bits(words[4 + 2 * MORPH_ENTRY_WORDS + 5]),
318            0.5,
319            "entry 2 normal.y"
320        );
321    }
322
323    #[test]
324    fn morph_entry_layout_matches_shaders() {
325        // `MorphEntry` is read through a raw pointer by the deform kernel
326        // (by byte offset in rt_skin.slang): uint target at 0, two packed
327        // float3s at 4 and 16, 28-byte stride.
328        use core::mem::{offset_of, size_of};
329        assert_eq!(size_of::<MorphEntry>(), MORPH_ENTRY_WORDS * 4);
330        assert_eq!(offset_of!(MorphEntry, target), 0);
331        assert_eq!(offset_of!(MorphEntry, position), 4);
332        assert_eq!(offset_of!(MorphEntry, normal), 16);
333    }
334}