Skip to main content

concinnity_core/gfx/
proportions.rs

1//! Per-joint proportion changes applied to a posed skeleton: a uniform scale
2//! on a joint's local matrix and a length offset pushing its children along
3//! the bone. The bind pose and inverse bind matrices stay as authored, so the
4//! change rides under every clip sampled on the skeleton.
5
6use alloc::vec::Vec;
7
8use crate::components::JointProportion;
9
10use crate::gfx::skeleton::Skeleton;
11use crate::gfx::transform::Mat4;
12
13// One child pushed along its bind direction by the parent's `length`.
14#[derive(Debug, Clone, PartialEq)]
15struct ChildOffset {
16    child: usize,
17    offset: [f32; 3],
18}
19
20#[derive(Debug, Clone, PartialEq)]
21struct Entry {
22    joint: usize,
23    scale: f32,
24    offsets: Vec<ChildOffset>,
25}
26
27/// Proportion entries resolved against one skeleton, ready to apply to a
28/// local-pose buffer every frame without further lookups.
29#[derive(Debug, Clone, Default, PartialEq)]
30pub struct ProportionLayer {
31    entries: Vec<Entry>,
32}
33
34impl ProportionLayer {
35    /// Resolve `proportions` against `skeleton` by joint name. Entries naming
36    /// an unknown joint are skipped (the caller reports them); entries that
37    /// change nothing are dropped.
38    pub fn resolve(skeleton: &Skeleton, proportions: &[JointProportion]) -> Self {
39        let joints = skeleton.joints();
40        let entries = proportions
41            .iter()
42            .filter(|p| p.scale != 1.0 || p.length != 0.0)
43            .filter_map(|p| {
44                let joint = skeleton.joint_index(&p.joint)?;
45                let offsets = joints
46                    .iter()
47                    .enumerate()
48                    .filter(|(_, j)| j.parent == Some(joint))
49                    .filter_map(|(child, j)| {
50                        let dir = normalize(j.bind.translation)?;
51                        Some(ChildOffset {
52                            child,
53                            offset: crate::math::vec3::scale(dir, p.length),
54                        })
55                    })
56                    .collect();
57                Some(Entry {
58                    joint,
59                    scale: p.scale,
60                    offsets,
61                })
62            })
63            .collect();
64        Self { entries }
65    }
66
67    /// Whether no joint is changed.
68    pub fn is_empty(&self) -> bool {
69        self.entries.is_empty()
70    }
71
72    /// Apply the layer to `locals` (one local matrix per joint, as sampled
73    /// from a clip or copied from the bind pose). A joint's scale multiplies
74    /// its local basis; its children's local translations gain the length
75    /// offset. Entries past the end of `locals` are ignored.
76    pub fn apply(&self, locals: &mut [Mat4]) {
77        for e in &self.entries {
78            if let Some(m) = locals.get_mut(e.joint) {
79                for col in m.iter_mut().take(3) {
80                    col[0] *= e.scale;
81                    col[1] *= e.scale;
82                    col[2] *= e.scale;
83                }
84            }
85            for o in &e.offsets {
86                if let Some(m) = locals.get_mut(o.child) {
87                    m[3][0] += o.offset[0];
88                    m[3][1] += o.offset[1];
89                    m[3][2] += o.offset[2];
90                }
91            }
92        }
93    }
94
95    /// Uniform scale of the skeleton's first root joint, or `1` when no
96    /// entry changes it.
97    pub fn root_scale(&self, skeleton: &Skeleton) -> f32 {
98        let root = skeleton.joints().iter().position(|j| j.parent.is_none());
99        self.entries
100            .iter()
101            .find(|e| Some(e.joint) == root)
102            .map_or(1.0, |e| e.scale)
103    }
104
105    /// Ratio of the proportioned skeleton's height to the bind height, both
106    /// measured as the highest joint position (model Y) in the rest pose.
107    /// `1` when the bind skeleton has no height.
108    pub fn height_ratio(&self, skeleton: &Skeleton) -> f32 {
109        let mut world = Vec::new();
110        skeleton.world_matrices_into(skeleton.bind_locals(), &mut world);
111        let bind_top = top(&world);
112        let mut locals: Vec<Mat4> = skeleton.bind_locals().to_vec();
113        self.apply(&mut locals);
114        skeleton.world_matrices_into(&locals, &mut world);
115        if bind_top <= 1e-6 {
116            1.0
117        } else {
118            top(&world) / bind_top
119        }
120    }
121}
122
123fn top(world: &[Mat4]) -> f32 {
124    world.iter().map(|m| m[3][1]).fold(0.0, f32::max)
125}
126
127fn normalize(v: [f32; 3]) -> Option<[f32; 3]> {
128    let len = crate::math::vec3::length(v);
129    (len > 1e-6).then(|| crate::math::vec3::scale(v, 1.0 / len))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::gfx::skeleton::{Joint, JointPose};
136    use alloc::string::String;
137    use alloc::vec;
138
139    // root -> mid -> tip, each 1 unit up the Y axis.
140    fn chain() -> Skeleton {
141        let joint = |name: &str, parent: Option<usize>, y: f32| Joint {
142            name: String::from(name),
143            parent,
144            bind: JointPose {
145                translation: [0.0, y, 0.0],
146                ..Default::default()
147            },
148        };
149        Skeleton::new(vec![
150            joint("root", None, 0.0),
151            joint("mid", Some(0), 1.0),
152            joint("tip", Some(1), 1.0),
153        ])
154    }
155
156    fn proportion(joint: &str, scale: f32, length: f32) -> JointProportion {
157        JointProportion {
158            joint: String::from(joint),
159            scale,
160            length,
161        }
162    }
163
164    fn positions(skeleton: &Skeleton, layer: &ProportionLayer) -> Vec<[f32; 3]> {
165        let mut locals = skeleton.bind_locals().to_vec();
166        layer.apply(&mut locals);
167        let mut world = Vec::new();
168        skeleton.world_matrices_into(&locals, &mut world);
169        world.iter().map(|m| [m[3][0], m[3][1], m[3][2]]).collect()
170    }
171
172    fn close(a: [f32; 3], b: [f32; 3]) -> bool {
173        (0..3).all(|i| (a[i] - b[i]).abs() < 1e-5)
174    }
175
176    #[test]
177    fn length_pushes_only_the_children_along_the_bone() {
178        let s = chain();
179        let layer = ProportionLayer::resolve(&s, &[proportion("mid", 1.0, 0.5)]);
180        let p = positions(&s, &layer);
181        // mid itself stays; tip moves 0.5 further up and nothing stretches
182        // beyond that.
183        assert!(close(p[1], [0.0, 1.0, 0.0]), "{p:?}");
184        assert!(close(p[2], [0.0, 2.5, 0.0]), "{p:?}");
185        assert!((layer.height_ratio(&s) - 1.25).abs() < 1e-5);
186    }
187
188    #[test]
189    fn scale_propagates_to_descendants() {
190        let s = chain();
191        let layer = ProportionLayer::resolve(&s, &[proportion("mid", 2.0, 0.0)]);
192        let p = positions(&s, &layer);
193        // The mid joint's frame is doubled, so the tip's 1-unit offset
194        // becomes 2 units.
195        assert!(close(p[2], [0.0, 3.0, 0.0]), "{p:?}");
196        // Scaling the root doubles the whole chain and reports as root scale.
197        let layer = ProportionLayer::resolve(&s, &[proportion("root", 2.0, 0.0)]);
198        let p = positions(&s, &layer);
199        assert!(close(p[2], [0.0, 4.0, 0.0]), "{p:?}");
200        assert_eq!(layer.root_scale(&s), 2.0);
201        assert!((layer.height_ratio(&s) - 2.0).abs() < 1e-5);
202    }
203
204    #[test]
205    fn unknown_and_identity_entries_are_dropped() {
206        let s = chain();
207        let layer = ProportionLayer::resolve(
208            &s,
209            &[proportion("tail", 2.0, 1.0), proportion("mid", 1.0, 0.0)],
210        );
211        assert!(layer.is_empty());
212        assert_eq!(layer.root_scale(&s), 1.0);
213        assert_eq!(layer.height_ratio(&s), 1.0);
214        let mut locals = s.bind_locals().to_vec();
215        layer.apply(&mut locals);
216        assert_eq!(locals, s.bind_locals());
217    }
218
219    #[test]
220    fn a_short_local_buffer_is_applied_in_range() {
221        let s = chain();
222        let layer = ProportionLayer::resolve(&s, &[proportion("mid", 2.0, 0.5)]);
223        let mut locals = s.bind_locals()[..2].to_vec();
224        layer.apply(&mut locals);
225        assert_eq!(locals[1][0][0], 2.0);
226    }
227}