Skip to main content

concinnity_core/gfx/
ik.rs

1//! Analytic two-bone inverse kinematics: bend a root-mid-end joint chain (a
2//! leg or an arm) so the end joint lands on a target, with a pole vector
3//! picking the bend side. Operates on the sampled local pose matrices after
4//! blending and before `skinning_matrices`, so the solve composes with any
5//! animation. Pure math, no ECS or backend types.
6
7use crate::math::vec3::{add, cross, dot, length, scale, sub};
8use crate::math::{acos, atan2, sin_cos};
9use alloc::vec::Vec;
10
11use crate::gfx::skeleton::Skeleton;
12use crate::gfx::transform::{Mat4, mat4_affine_inverse, mat4_mul};
13
14type Vec3 = [f32; 3];
15type Mat3 = [[f32; 3]; 3];
16
17const EPS: f32 = 1.0e-5;
18
19fn normalize(v: Vec3) -> Option<Vec3> {
20    let len = length(v);
21    (len > EPS).then(|| scale(v, 1.0 / len))
22}
23
24// Rodrigues rotation: column-major 3x3 rotating about the unit `axis` by
25// `angle` radians.
26fn rotate_about(axis: Vec3, angle: f32) -> Mat3 {
27    let (s, c) = sin_cos(angle);
28    let t = 1.0 - c;
29    let [x, y, z] = axis;
30    [
31        [t * x * x + c, t * x * y + s * z, t * x * z - s * y],
32        [t * x * y - s * z, t * y * y + c, t * y * z + s * x],
33        [t * x * z + s * y, t * y * z - s * x, t * z * z + c],
34    ]
35}
36
37const MAT3_IDENTITY: Mat3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
38
39// Shortest-arc rotation taking direction `a` onto direction `b` (inputs need
40// not be unit length). Identity when either is degenerate or they already
41// align; a half-turn about any perpendicular when they oppose.
42fn from_to(a: Vec3, b: Vec3) -> Mat3 {
43    let (Some(a), Some(b)) = (normalize(a), normalize(b)) else {
44        return MAT3_IDENTITY;
45    };
46    let c = cross(a, b);
47    let angle = atan2(length(c), dot(a, b));
48    match normalize(c) {
49        Some(axis) => rotate_about(axis, angle),
50        // Parallel or anti-parallel: no rotation, or a half-turn about any
51        // axis perpendicular to `a`.
52        None if dot(a, b) > 0.0 => MAT3_IDENTITY,
53        None => rotate_about(any_perpendicular(a), core::f32::consts::PI),
54    }
55}
56
57// Some unit vector perpendicular to unit `v`.
58fn any_perpendicular(v: Vec3) -> Vec3 {
59    let candidate = if v[0].abs() < 0.9 {
60        cross(v, [1.0, 0.0, 0.0])
61    } else {
62        cross(v, [0.0, 1.0, 0.0])
63    };
64    normalize(candidate).unwrap_or([0.0, 0.0, 1.0])
65}
66
67fn mat3_apply(m: Mat3, v: Vec3) -> Vec3 {
68    [
69        m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2],
70        m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2],
71        m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2],
72    ]
73}
74
75fn mat3_mul(a: Mat3, b: Mat3) -> Mat3 {
76    let mut out = [[0.0f32; 3]; 3];
77    for col in 0..3 {
78        for row in 0..3 {
79            for k in 0..3 {
80                out[col][row] += a[k][row] * b[col][k];
81            }
82        }
83    }
84    out
85}
86
87// Rotate a full affine matrix about a world-space pivot point: the linear
88// part is premultiplied by `r`, the translation orbits the pivot.
89fn rotate_mat4_about(r: Mat3, pivot: Vec3, m: Mat4) -> Mat4 {
90    let mut out = m;
91    for col in 0..3 {
92        let rotated = mat3_apply(r, [m[col][0], m[col][1], m[col][2]]);
93        out[col][0] = rotated[0];
94        out[col][1] = rotated[1];
95        out[col][2] = rotated[2];
96    }
97    let p = mat3_apply(r, sub([m[3][0], m[3][1], m[3][2]], pivot));
98    out[3][0] = pivot[0] + p[0];
99    out[3][1] = pivot[1] + p[1];
100    out[3][2] = pivot[2] + p[2];
101    out
102}
103
104/// One two-bone chain resolved to joint indices. `mid` must be the direct
105/// child of `root` and `end` the direct child of `mid` (the local-pose
106/// write-back assumes it); `pole` is the bend direction in mesh space.
107#[derive(Debug, Clone)]
108pub struct TwoBoneChain {
109    /// Index of the chain's root joint.
110    pub root: usize,
111    /// Index of the middle joint, a direct child of `root`.
112    pub mid: usize,
113    /// Index of the end joint, a direct child of `mid`.
114    pub end: usize,
115    /// Bend direction in mesh space.
116    pub pole: Vec3,
117}
118
119// Solve the chain analytically: given the current joint positions and a
120// target for the end joint, return the delta rotations to apply about the
121// root and mid joint origins (in the same space as the positions). The
122// target is reach-clamped, so an out-of-range target straightens the chain
123// toward it. `None` when the chain is degenerate (zero-length bones or a
124// target on top of the root).
125pub(crate) fn solve_two_bone(
126    root: Vec3,
127    mid: Vec3,
128    end: Vec3,
129    target: Vec3,
130    pole: Vec3,
131) -> Option<(Mat3, Mat3)> {
132    let upper = sub(mid, root);
133    let lower = sub(end, mid);
134    let a = length(upper);
135    let b = length(lower);
136    if a <= EPS || b <= EPS {
137        return None;
138    }
139    let to_target = sub(target, root);
140    let dist = length(to_target);
141    if dist <= EPS {
142        return None;
143    }
144    let t_dir = scale(to_target, 1.0 / dist);
145    let t = dist.clamp((a - b).abs() + 1.0e-4, a + b - 1.0e-4);
146
147    let u = normalize(sub(root, mid)).expect("a > EPS");
148    let v = normalize(lower).expect("b > EPS");
149
150    // Bend axis: the current bend plane's normal. A straight chain has no
151    // bend plane; fall back to an axis perpendicular to the bone line and
152    // as close to the pole plane as possible (the signed-angle math below
153    // requires the axis to be perpendicular to both bones, and for a
154    // straight chain u = -v).
155    let axis = normalize(cross(upper, lower))
156        .or_else(|| normalize(cross(v, pole)))
157        .unwrap_or_else(|| any_perpendicular(v));
158
159    // Interior knee angle from the law of cosines, then rotate the lower
160    // bone so the root-to-end distance becomes exactly `t`. The signed
161    // current angle keeps the existing bend side; the pole twist below
162    // picks the final plane, so an ambiguous side here cannot stick.
163    let desired = acos(((a * a + b * b - t * t) / (2.0 * a * b)).clamp(-1.0, 1.0));
164    let current_cos = dot(u, v).clamp(-1.0, 1.0);
165    let current_sin = dot(cross(u, v), axis);
166    let current = atan2(current_sin, current_cos);
167    let signed_desired = if current >= 0.0 { desired } else { -desired };
168    let r_mid = rotate_about(axis, signed_desired - current);
169
170    // Aim the whole (now correctly-shortened) chain from the root onto the
171    // target direction.
172    let end_bent = add(mid, mat3_apply(r_mid, lower));
173    let r_aim = from_to(sub(end_bent, root), to_target);
174
175    // Twist about the root-to-target axis so the mid joint lies on the pole
176    // side of the chain.
177    let mid_aimed = mat3_apply(r_aim, upper);
178    let bend_current = sub(mid_aimed, scale(t_dir, dot(mid_aimed, t_dir)));
179    let bend_pole = sub(pole, scale(t_dir, dot(pole, t_dir)));
180    let r_root = match (normalize(bend_current), normalize(bend_pole)) {
181        (Some(c), Some(p)) => {
182            let twist = atan2(dot(cross(c, p), t_dir), dot(c, p));
183            mat3_mul(rotate_about(t_dir, twist), r_aim)
184        }
185        _ => r_aim,
186    };
187    Some((r_root, r_mid))
188}
189
190/// Apply one chain to a sampled local pose in place. `target` is the desired
191/// end-joint position in mesh space; `weight` in `[0, 1]` fades the solve by
192/// pulling the effective target from the animated end position toward
193/// `target`. Locals shorter than the chain's joints grow from the bind pose
194/// first, so a partial sample still solves correctly. `world` is a reusable
195/// buffer the hierarchy composes through, so a steady-state solve allocates
196/// nothing.
197pub fn apply_two_bone_ik(
198    skeleton: &Skeleton,
199    locals: &mut Vec<Mat4>,
200    chain: &TwoBoneChain,
201    target: Vec3,
202    weight: f32,
203    world: &mut Vec<Mat4>,
204) {
205    let weight = weight.clamp(0.0, 1.0);
206    let n = skeleton.len();
207    if weight <= 0.0 || chain.root >= n || chain.mid >= n || chain.end >= n {
208        return;
209    }
210    while locals.len() < n {
211        let i = locals.len();
212        locals.push(skeleton.joints()[i].bind.to_matrix());
213    }
214    skeleton.world_matrices_into(locals, world);
215    let pos = |m: &Mat4| [m[3][0], m[3][1], m[3][2]];
216    let p_root = pos(&world[chain.root]);
217    let p_mid = pos(&world[chain.mid]);
218    let p_end = pos(&world[chain.end]);
219    let effective = add(p_end, scale(sub(target, p_end), weight));
220    let Some((r_root, r_mid)) = solve_two_bone(p_root, p_mid, p_end, effective, chain.pole) else {
221        return;
222    };
223
224    // New world transforms: the root turns about its own origin; the mid
225    // inherits that and additionally bends about its (pre-solve) origin.
226    let root_world = rotate_mat4_about(r_root, p_root, world[chain.root]);
227    let mid_world = rotate_mat4_about(
228        r_root,
229        p_root,
230        rotate_mat4_about(r_mid, p_mid, world[chain.mid]),
231    );
232
233    // Back to locals. The root's parent world is untouched by the solve; the
234    // mid's parent is the root (direct parentage, validated at resolution).
235    locals[chain.root] = match skeleton.joints()[chain.root].parent {
236        Some(p) => mat4_mul(mat4_affine_inverse(world[p]), root_world),
237        None => root_world,
238    };
239    locals[chain.mid] = mat4_mul(mat4_affine_inverse(root_world), mid_world);
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::gfx::skeleton::{Joint, JointPose};
246    use alloc::string::ToString;
247    use alloc::vec;
248
249    // A three-joint leg hanging straight down: hip at y=2, knee at y=1,
250    // foot at y=0, all along -Y.
251    fn leg() -> Skeleton {
252        let joint = |name: &str, parent: Option<usize>, ty: f32| Joint {
253            name: name.to_string(),
254            parent,
255            bind: JointPose {
256                translation: [0.0, ty, 0.0],
257                ..JointPose::default()
258            },
259        };
260        Skeleton::new(vec![
261            joint("hip", None, 2.0),
262            joint("knee", Some(0), -1.0),
263            joint("foot", Some(1), -1.0),
264        ])
265    }
266
267    fn bind_locals(skeleton: &Skeleton) -> Vec<Mat4> {
268        skeleton
269            .joints()
270            .iter()
271            .map(|j| j.bind.to_matrix())
272            .collect()
273    }
274
275    fn joint_pos(skeleton: &Skeleton, locals: &[Mat4], i: usize) -> [f32; 3] {
276        let mut worlds = Vec::new();
277        skeleton.world_matrices_into(locals, &mut worlds);
278        let w = worlds[i];
279        [w[3][0], w[3][1], w[3][2]]
280    }
281
282    fn solve(
283        skeleton: &Skeleton,
284        locals: &mut Vec<Mat4>,
285        chain: &TwoBoneChain,
286        target: [f32; 3],
287        weight: f32,
288    ) {
289        apply_two_bone_ik(skeleton, locals, chain, target, weight, &mut Vec::new());
290    }
291
292    fn assert_close(a: [f32; 3], b: [f32; 3], tol: f32) {
293        for k in 0..3 {
294            assert!((a[k] - b[k]).abs() < tol, "{a:?} vs {b:?}");
295        }
296    }
297
298    const CHAIN: TwoBoneChain = TwoBoneChain {
299        root: 0,
300        mid: 1,
301        end: 2,
302        pole: [0.0, 0.0, 1.0],
303    };
304
305    #[test]
306    fn reachable_target_lands_the_foot_and_bends_toward_the_pole() {
307        let skeleton = leg();
308        let mut locals = bind_locals(&skeleton);
309        // Pull the foot up half a unit: the leg must bend.
310        solve(&skeleton, &mut locals, &CHAIN, [0.0, 0.5, 0.0], 1.0);
311        assert_close(joint_pos(&skeleton, &locals, 2), [0.0, 0.5, 0.0], 1e-3);
312        // The knee moved out on the pole side (+Z) and the hip stayed put.
313        let knee = joint_pos(&skeleton, &locals, 1);
314        assert!(knee[2] > 0.1, "knee bends toward the pole: {knee:?}");
315        assert_close(joint_pos(&skeleton, &locals, 0), [0.0, 2.0, 0.0], 1e-4);
316        // Bone lengths are preserved by the solve.
317        let hip = joint_pos(&skeleton, &locals, 0);
318        let foot = joint_pos(&skeleton, &locals, 2);
319        let len = |a: [f32; 3], b: [f32; 3]| length(sub(a, b));
320        assert!((len(hip, knee) - 1.0).abs() < 1e-3);
321        assert!((len(knee, foot) - 1.0).abs() < 1e-3);
322    }
323
324    #[test]
325    fn out_of_reach_target_straightens_toward_it() {
326        let skeleton = leg();
327        let mut locals = bind_locals(&skeleton);
328        solve(&skeleton, &mut locals, &CHAIN, [3.0, 2.0, 0.0], 1.0);
329        // Max reach is ~2 along +X from the hip at (0,2,0).
330        let foot = joint_pos(&skeleton, &locals, 2);
331        assert_close(foot, [2.0, 2.0, 0.0], 2e-2);
332    }
333
334    #[test]
335    fn sideways_target_respects_the_pole_plane() {
336        let skeleton = leg();
337        let mut locals = bind_locals(&skeleton);
338        solve(&skeleton, &mut locals, &CHAIN, [1.0, 0.8, 0.0], 1.0);
339        assert_close(joint_pos(&skeleton, &locals, 2), [1.0, 0.8, 0.0], 1e-3);
340        let knee = joint_pos(&skeleton, &locals, 1);
341        assert!(knee[2] > 0.05, "knee stays on the +Z pole side: {knee:?}");
342    }
343
344    #[test]
345    fn weight_blends_between_animated_and_solved() {
346        let skeleton = leg();
347        let mut half = bind_locals(&skeleton);
348        solve(&skeleton, &mut half, &CHAIN, [0.0, 0.5, 0.0], 0.5);
349        // Half weight pins the foot halfway between animated (y=0) and the
350        // target (y=0.5).
351        assert_close(joint_pos(&skeleton, &half, 2), [0.0, 0.25, 0.0], 1e-3);
352
353        let mut off = bind_locals(&skeleton);
354        solve(&skeleton, &mut off, &CHAIN, [0.0, 0.5, 0.0], 0.0);
355        assert_close(joint_pos(&skeleton, &off, 2), [0.0, 0.0, 0.0], 1e-6);
356    }
357
358    #[test]
359    fn degenerate_targets_leave_the_pose_untouched() {
360        let skeleton = leg();
361        let mut locals = bind_locals(&skeleton);
362        let before = locals.clone();
363        // EntityTarget exactly on the root: no solvable direction.
364        solve(&skeleton, &mut locals, &CHAIN, [0.0, 2.0, 0.0], 1.0);
365        assert_eq!(locals, before);
366        // Out-of-range chain indices are ignored.
367        let bad = TwoBoneChain {
368            root: 0,
369            mid: 9,
370            end: 2,
371            pole: [0.0, 0.0, 1.0],
372        };
373        solve(&skeleton, &mut locals, &bad, [0.0, 0.5, 0.0], 1.0);
374        assert_eq!(locals, before);
375    }
376
377    #[test]
378    fn solve_composes_with_an_animated_pose() {
379        // Rotate the hip 45 degrees about Z first (the leg swings toward +X),
380        // then pin the foot back under the hip: the solve must land on target
381        // from the animated (not bind) configuration.
382        let skeleton = leg();
383        let mut locals = bind_locals(&skeleton);
384        locals[0] = JointPose {
385            translation: [0.0, 2.0, 0.0],
386            rotation_deg: [0.0, 0.0, 45.0],
387            ..JointPose::default()
388        }
389        .to_matrix();
390        solve(&skeleton, &mut locals, &CHAIN, [0.0, 0.2, 0.0], 1.0);
391        assert_close(joint_pos(&skeleton, &locals, 2), [0.0, 0.2, 0.0], 1e-3);
392    }
393
394    // The shortest-arc rotation has three degenerate inputs, and each has to
395    // resolve to something usable rather than a NaN basis: a zero-length
396    // direction, two that already align, and two that oppose.
397    #[test]
398    fn the_shortest_arc_handles_every_degenerate_pair() {
399        let x: Vec3 = [1.0, 0.0, 0.0];
400
401        assert_eq!(from_to([0.0; 3], x), MAT3_IDENTITY, "no direction to turn");
402        assert_eq!(from_to(x, [0.0; 3]), MAT3_IDENTITY, "nowhere to turn to");
403        assert_eq!(
404            from_to(x, [2.0, 0.0, 0.0]),
405            MAT3_IDENTITY,
406            "already aligned"
407        );
408
409        // Opposed: a half-turn about some perpendicular, which lands `x` on
410        // its own negation whichever axis was picked.
411        let flipped = mat3_apply(from_to(x, [-1.0, 0.0, 0.0]), x);
412        assert_close(flipped, [-1.0, 0.0, 0.0], 1e-5);
413    }
414
415    // The perpendicular is picked against whichever world axis the input is
416    // least aligned with, so both arms have to produce a unit vector at right
417    // angles to it.
418    #[test]
419    fn the_perpendicular_is_perpendicular_on_either_arm() {
420        for v in [
421            [0.0, 1.0, 0.0],
422            [0.0, 0.0, 1.0],
423            [1.0, 0.0, 0.0],
424            [-1.0, 0.0, 0.0],
425        ] {
426            let p = any_perpendicular(v);
427            assert!(
428                dot(v, p).abs() < 1e-5,
429                "{v:?} vs {p:?} are not perpendicular"
430            );
431            assert!((length(p) - 1.0).abs() < 1e-5, "{p:?} is not unit length");
432        }
433    }
434
435    // A chain with no length, or one whose target sits on its own root, has no
436    // aim direction to solve for and is left alone rather than snapping.
437    #[test]
438    fn a_degenerate_chain_or_target_does_not_solve() {
439        let origin = [0.0, 0.0, 0.0];
440        let pole = [0.0, 0.0, 1.0];
441        assert!(
442            solve_two_bone(origin, origin, [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], pole).is_none(),
443            "the upper bone has no length"
444        );
445        assert!(
446            solve_two_bone(
447                origin,
448                [0.0, -1.0, 0.0],
449                [0.0, -1.0, 0.0],
450                [1.0, 0.0, 0.0],
451                pole
452            )
453            .is_none(),
454            "the lower bone has no length"
455        );
456        assert!(
457            solve_two_bone(origin, [0.0, -1.0, 0.0], [0.0, -2.0, 0.0], origin, pole).is_none(),
458            "the target sits on the root"
459        );
460    }
461
462    // A pole on the chain's own aim axis names no bend plane, so the solve
463    // keeps the aim and applies no twist rather than rotating about nothing.
464    #[test]
465    fn a_pole_on_the_aim_axis_leaves_the_twist_alone() {
466        let solved = solve_two_bone(
467            [0.0, 2.0, 0.0],
468            [0.0, 1.0, 0.0],
469            [0.0, 0.0, 0.0],
470            [0.0, 0.5, 0.0],
471            [0.0, 1.0, 0.0],
472        );
473        assert!(
474            solved.is_some(),
475            "the chain still solves without a bend plane"
476        );
477    }
478
479    // The caller may hand in a locals buffer shorter than the skeleton (an
480    // empty one on the first frame); the missing entries are filled from the
481    // bind pose rather than indexed past the end.
482    #[test]
483    fn a_short_locals_buffer_is_filled_from_the_bind_pose() {
484        let skeleton = leg();
485        let mut locals: Vec<Mat4> = Vec::new();
486        solve(&skeleton, &mut locals, &CHAIN, [0.5, 0.5, 0.0], 1.0);
487        assert_eq!(locals.len(), skeleton.joints().len());
488    }
489}