Skip to main content

roxlap_core/
kfa_draw.rs

1//! KFA (animated kv6) sprite renderer — voxlap's `kfadraw`
2//! (`voxlap5.c:9759`) plus the bone-transform helpers
3//! `genperp` (voxlap5.c:9546), `mat0` (voxlap5.c:9568), and
4//! `setlimb` (voxlap5.c:9643).
5//!
6//! A KFA sprite is a hierarchy of bones; each bone carries an
7//! optional [`Sprite`] (= one kv6 limb) plus a hinge tying it to
8//! its parent. Per frame, the user updates `kfaval[i]` (a 16-bit
9//! angle) for each animated bone; this module walks the hinge
10//! tree in topological order and computes each limb's world
11//! transform from the parent's, then dispatches the existing
12//! [`crate::sprite::draw_sprite`] per limb to rasterise the kv6
13//! data.
14//!
15//! Voxlap's animation-curve playback (`animsprite` + `seq[]` +
16//! `frmval[]` interpolation) lives in
17//! [`roxlap_formats::kfa::KfaSprite::animsprite`] — call it to
18//! advance `kfaval[]` from a baked curve, or poke `kfaval[]`
19//! directly for procedural animation, then render here. The bone
20//! posing this module computes is also exposed standalone as
21//! [`solve_kfa_limbs`] for non-CPU backends (the GPU sprite pass).
22//!
23//! No oracle pose exercises KFA, so this module's correctness
24//! gate is "looks right + tests verify the bone math". We can
25//! tighten validation when a real `.kfa` asset lands.
26
27#![allow(
28    clippy::cast_possible_truncation,
29    clippy::cast_possible_wrap,
30    clippy::cast_sign_loss,
31    clippy::cast_precision_loss,
32    clippy::similar_names,
33    clippy::too_many_arguments,
34    clippy::doc_markdown,
35    clippy::many_single_char_names,
36    clippy::missing_panics_doc,
37    clippy::float_cmp,
38    clippy::useless_vec
39)]
40
41use roxlap_formats::kfa::{Hinge, KfaSprite, Point3};
42use roxlap_formats::sprite::Sprite;
43use roxlap_formats::xform::BoneXform;
44
45use crate::camera_math::CameraState;
46use crate::opticast::OpticastSettings;
47use crate::sprite::{draw_sprite, mat2, DrawTarget, SpriteLighting};
48
49/// Voxlap's `genperp` — given a non-zero axis vector `a`, build
50/// two orthonormal vectors `b`, `c` such that `(a, b, c)` form a
51/// right-handed orthonormal basis with `a` along its primary axis.
52/// Mirror of voxlap5.c:9546-9561.
53///
54/// If `a` is zero, returns `([0; 3], [0; 3])` (matches voxlap's
55/// degenerate-input zeroing).
56fn genperp(a: [f32; 3]) -> ([f32; 3], [f32; 3]) {
57    if a == [0.0, 0.0, 0.0] {
58        return ([0.0; 3], [0.0; 3]);
59    }
60    // Pick the smallest-magnitude axis to zero out in `b`, so the
61    // remaining two components dominate and the cross-product
62    // `c = a × b` stays well-conditioned.
63    let ax = a[0].abs();
64    let ay = a[1].abs();
65    let az = a[2].abs();
66    let b = if ax < ay && ax < az {
67        let t = 1.0 / (a[1] * a[1] + a[2] * a[2]).sqrt();
68        [0.0, a[2] * t, -a[1] * t]
69    } else if ay < az {
70        let t = 1.0 / (a[0] * a[0] + a[2] * a[2]).sqrt();
71        [-a[2] * t, 0.0, a[0] * t]
72    } else {
73        let t = 1.0 / (a[0] * a[0] + a[1] * a[1]).sqrt();
74        [a[1] * t, -a[0] * t, 0.0]
75    };
76    let c = [
77        a[1] * b[2] - a[2] * b[1],
78        a[2] * b[0] - a[0] * b[2],
79        a[0] * b[1] - a[1] * b[0],
80    ];
81    (b, c)
82}
83
84/// Voxlap's `mat0` (`voxlap5.c:9568`) — given `B` and `C` such
85/// that `A * B = C`, find `A`. Returns `(a_s, a_h, a_f, a_o)`.
86///
87/// Used by `setlimb` to find the rotation matrix that maps the
88/// parent's hinge frame to the child's hinge frame.
89fn mat0(
90    b_s: [f32; 3],
91    b_h: [f32; 3],
92    b_f: [f32; 3],
93    b_o: [f32; 3],
94    c_s: [f32; 3],
95    c_h: [f32; 3],
96    c_f: [f32; 3],
97    c_o: [f32; 3],
98) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
99    // A's columns = C's columns expressed in B's frame.
100    // Voxlap evaluates as `bs.row * c.col + bh.row * c.col + bg.row * c.col`
101    // for each (row, output-col) pair.
102    let ts = [
103        b_s[0] * c_s[0] + b_h[0] * c_h[0] + b_f[0] * c_f[0],
104        b_s[0] * c_s[1] + b_h[0] * c_h[1] + b_f[0] * c_f[1],
105        b_s[0] * c_s[2] + b_h[0] * c_h[2] + b_f[0] * c_f[2],
106    ];
107    let th = [
108        b_s[1] * c_s[0] + b_h[1] * c_h[0] + b_f[1] * c_f[0],
109        b_s[1] * c_s[1] + b_h[1] * c_h[1] + b_f[1] * c_f[1],
110        b_s[1] * c_s[2] + b_h[1] * c_h[2] + b_f[1] * c_f[2],
111    ];
112    let tf = [
113        b_s[2] * c_s[0] + b_h[2] * c_h[0] + b_f[2] * c_f[0],
114        b_s[2] * c_s[1] + b_h[2] * c_h[1] + b_f[2] * c_f[1],
115        b_s[2] * c_s[2] + b_h[2] * c_h[2] + b_f[2] * c_f[2],
116    ];
117    let to = [
118        c_o[0] - b_o[0] * ts[0] - b_o[1] * th[0] - b_o[2] * tf[0],
119        c_o[1] - b_o[0] * ts[1] - b_o[1] * th[1] - b_o[2] * tf[1],
120        c_o[2] - b_o[0] * ts[2] - b_o[1] * th[2] - b_o[2] * tf[2],
121    ];
122    (ts, th, tf, to)
123}
124
125#[inline]
126fn pt(p: Point3) -> [f32; 3] {
127    [p.x, p.y, p.z]
128}
129
130/// Voxlap's `setlimb` (`voxlap5.c:9643`) — compute child limb
131/// `i`'s world transform from parent limb `p`'s world transform
132/// via the hinge connecting them.
133///
134/// Math:
135/// 1. Build the child-side velcro frame from the hinge: `qp =
136///    hinge.p[0]`, `(qs, qh, qf) = (hinge.v[0], genperp(hinge.v[0]))`.
137/// 2. Apply the hinge transform — for `htype == 0` (rotate around
138///    `qs` by `val` angle), rotate `(qh, qf)` in their plane.
139/// 3. Build the parent-side velcro frame: `pp = hinge.p[1]`,
140///    `(ps, ph, pf) = (hinge.v[1], genperp(hinge.v[1]))`.
141/// 4. `mat0`: find `R` such that `R * (ps, ph, pf, pp) = (qs, qh,
142///    qf, qp)` — `R` is the limb's hinge rotation in parent
143///    coords.
144/// 5. `mat2`: `child_world = parent_world * R`. The limb's `(s,
145///    h, f, p)` transform updates in place.
146fn setlimb(limbs: &mut [Sprite], hinges: &[Hinge], i: usize, parent: usize, xform: &BoneXform) {
147    let p = &limbs[parent];
148    let (cs, ch, cf, co) = limb_xform((p.s, p.h, p.f, p.p), &hinges[i], xform);
149    let child = &mut limbs[i];
150    child.s = cs;
151    child.h = ch;
152    child.f = cf;
153    child.p = co;
154}
155
156/// The pure `setlimb` math: a child bone's world `(s, h, f, p)` from its
157/// parent's world transform, the connecting `hinge`, and the bone's local
158/// [`BoneXform`]. Split out from [`setlimb`] (which writes into the
159/// `Sprite` list) so it can be reasoned about / tested in isolation.
160fn limb_xform(
161    parent: ([f32; 3], [f32; 3], [f32; 3], [f32; 3]),
162    hinge: &Hinge,
163    xform: &BoneXform,
164) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
165    let (parent_s, parent_h, parent_f, parent_p) = parent;
166
167    // Step 1: child-side velcro frame, with the animated translation added to
168    // the child anchor (velcro-local).
169    let qp0 = pt(hinge.p[0]);
170    let qp = [
171        qp0[0] + xform.t[0],
172        qp0[1] + xform.t[1],
173        qp0[2] + xform.t[2],
174    ];
175    let qs0 = pt(hinge.v[0]);
176    let (qh0, qf0) = genperp(qs0);
177
178    // Step 2: apply the hinge rotation by rotating the whole velcro frame by
179    // the quaternion. For a rotation about the hinge axis this leaves `qs`
180    // fixed and spins `(qh, qf)` in their plane — voxlap's legacy 1-DOF case
181    // (see `BoneXform::from_hinge_angle`); a free quaternion gives full 3-DOF.
182    let qs = xform.r.rotate(qs0);
183    let qh = xform.r.rotate(qh0);
184    let qf = xform.r.rotate(qf0);
185
186    // Step 3: parent-side velcro frame.
187    let pp = pt(hinge.p[1]);
188    let ps = pt(hinge.v[1]);
189    let (ph, pf) = genperp(ps);
190
191    // Step 4: mat0 — find R such that R * (ps,ph,pf,pp) = (qs,qh,qf,qp).
192    let (rs, rh, rf, ro) = mat0(ps, ph, pf, pp, qs, qh, qf, qp);
193
194    // Step 5: mat2 — child_world = parent_world * R.
195    let (cs, ch, cf, co) = mat2(parent_s, parent_h, parent_f, parent_p, rs, rh, rf, ro);
196
197    // Step 6: non-uniform scale along the bone's local axes — the Sprite basis
198    // vectors' length scales the kv6 (children inherit it via `mat2`).
199    (
200        [cs[0] * xform.s[0], cs[1] * xform.s[0], cs[2] * xform.s[0]],
201        [ch[0] * xform.s[1], ch[1] * xform.s[1], ch[2] * xform.s[1]],
202        [cf[0] * xform.s[2], cf[1] * xform.s[2], cf[2] * xform.s[2]],
203        co,
204    )
205}
206
207/// Render an animated KFA sprite — voxlap's `kfadraw`
208/// (voxlap5.c:9759). Walks the bone tree in topological order
209/// (parents first), computes each limb's world transform from
210/// the parent's via the per-limb `setlimb` walk, then dispatches
211/// [`crate::sprite::draw_sprite`] per limb to rasterise its kv6.
212///
213/// Returns the total number of pixels written across all limbs.
214pub fn draw_kfa_sprite(
215    target: &mut DrawTarget<'_>,
216    cam: &CameraState,
217    settings: &OpticastSettings,
218    lighting: &SpriteLighting<'_>,
219    kfa: &mut KfaSprite,
220) -> u32 {
221    // Pose first, then rasterise. Voxlap interleaves the two in one
222    // descending loop, but a limb's transform depends only on its
223    // (already-posed) parent — never on drawing — so a full solve pass
224    // followed by a full draw pass is identical, and lets non-CPU
225    // backends reuse `solve_kfa_limbs` verbatim.
226    solve_kfa_limbs(kfa);
227    let n = kfa.hinge_sort.len();
228    let mut total: u32 = 0;
229    for k in (0..n).rev() {
230        let j = kfa.hinge_sort[k];
231        total += draw_sprite(target, cam, settings, lighting, &kfa.limbs[j]);
232    }
233    total
234}
235
236/// Pose every limb of a KFA sprite — the bone-transform half of
237/// [`draw_kfa_sprite`], without rasterising. Walks the hinge tree in
238/// topological order (parents first) and writes each limb's world
239/// `(s, h, f, p)` from its parent's via the per-limb `setlimb` math,
240/// reading the current [`KfaSprite::kfaval`] angles. Mirror of the
241/// transform portion of voxlap's `kfadraw` (voxlap5.c:9759).
242///
243/// Split out so non-CPU backends (e.g. the GPU instanced-sprite pass)
244/// can run the exact same posing and then consume `kfa.limbs[*]`
245/// transforms however they need. The host typically calls
246/// [`KfaSprite::animsprite`](roxlap_formats::kfa::KfaSprite::animsprite)
247/// to advance `kfaval[]` first, then this to resolve world transforms.
248pub fn solve_kfa_limbs(kfa: &mut KfaSprite) {
249    // Voxlap iterates `for i = numhin-1; i >= 0; i--`; sort_hinges
250    // puts parents at high indices, so descending iteration walks
251    // parents first.
252    let n = kfa.hinge_sort.len();
253    for k in (0..n).rev() {
254        let j = kfa.hinge_sort[k];
255        let parent = kfa.hinges[j].parent;
256        if parent >= 0 {
257            // Child bone: derive transform from parent using the bone's
258            // resolved local TRS (`kfaval`). A non-zero `htype` means "no
259            // rotation/transform", matching voxlap.
260            let htype = kfa.hinges[j].htype;
261            let xform = if htype == 0 {
262                kfa.kfaval[j]
263            } else {
264                BoneXform::IDENTITY
265            };
266            setlimb(&mut kfa.limbs, &kfa.hinges, j, parent as usize, &xform);
267        } else {
268            // Root bone: copy world basis from KfaSprite + apply
269            // hinge.p[0] as the velcro offset (voxlap5.c:9772-9782).
270            let s = kfa.s;
271            let h = kfa.h;
272            let f = kfa.f;
273            let p_world = kfa.p;
274            let tp = pt(kfa.hinges[j].p[0]);
275            let limb = &mut kfa.limbs[j];
276            limb.s = s;
277            limb.h = h;
278            limb.f = f;
279            limb.p = [
280                p_world[0] - tp[0] * s[0] - tp[1] * h[0] - tp[2] * f[0],
281                p_world[1] - tp[0] * s[1] - tp[1] * h[1] - tp[2] * f[1],
282                p_world[2] - tp[0] * s[2] - tp[1] * h[2] - tp[2] * f[2],
283            ];
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    /// genperp produces an orthonormal basis with the input axis
293    /// as the dominant direction.
294    #[test]
295    fn genperp_orthonormal() {
296        let a = [1.0_f32, 0.0, 0.0];
297        let (b, c) = genperp(a);
298        // b · a = 0
299        assert!((a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).abs() < 1e-6);
300        // c · a = 0
301        assert!((a[0] * c[0] + a[1] * c[1] + a[2] * c[2]).abs() < 1e-6);
302        // b · c = 0
303        assert!((b[0] * c[0] + b[1] * c[1] + b[2] * c[2]).abs() < 1e-6);
304        // |b| ≈ 1
305        let lb = b[0] * b[0] + b[1] * b[1] + b[2] * b[2];
306        assert!((lb - 1.0).abs() < 1e-5, "|b|² = {lb}");
307        // |c| ≈ 1
308        let lc = c[0] * c[0] + c[1] * c[1] + c[2] * c[2];
309        assert!((lc - 1.0).abs() < 1e-5, "|c|² = {lc}");
310    }
311
312    /// genperp of zero vector returns zero vectors.
313    #[test]
314    fn genperp_zero() {
315        let (b, c) = genperp([0.0, 0.0, 0.0]);
316        assert_eq!(b, [0.0, 0.0, 0.0]);
317        assert_eq!(c, [0.0, 0.0, 0.0]);
318    }
319
320    /// The original voxlap `setlimb` rotation math (rotate `(qh, qf)` about
321    /// `qs` by the Q15 angle, no translation / scale), kept as the spec the
322    /// new quaternion path must reproduce.
323    fn legacy_limb_xform(
324        parent: ([f32; 3], [f32; 3], [f32; 3], [f32; 3]),
325        hinge: &Hinge,
326        val: i16,
327    ) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
328        let (ps0, ph0, pf0, pp0) = parent;
329        let qp = pt(hinge.p[0]);
330        let qs = pt(hinge.v[0]);
331        let (mut qh, mut qf) = genperp(qs);
332        let ang = (i32::from(val) as f32) * (std::f32::consts::PI * 2.0 / 65536.0);
333        let (c, s) = (ang.cos(), ang.sin());
334        let (ih, jf) = (qh, qf);
335        qh = [
336            ih[0] * c - jf[0] * s,
337            ih[1] * c - jf[1] * s,
338            ih[2] * c - jf[2] * s,
339        ];
340        qf = [
341            ih[0] * s + jf[0] * c,
342            ih[1] * s + jf[1] * c,
343            ih[2] * s + jf[2] * c,
344        ];
345        let pp = pt(hinge.p[1]);
346        let ps = pt(hinge.v[1]);
347        let (ph, pf) = genperp(ps);
348        let (rs, rh, rf, ro) = mat0(ps, ph, pf, pp, qs, qh, qf, qp);
349        mat2(ps0, ph0, pf0, pp0, rs, rh, rf, ro)
350    }
351
352    /// The new TRS solver, fed a rotation-only `BoneXform` from a hinge angle,
353    /// reproduces the legacy single-axis `setlimb` to f32 epsilon — so the
354    /// quaternion rewrite is behaviour-preserving for existing rigs.
355    #[test]
356    fn trs_solver_matches_the_legacy_hinge_rotation() {
357        let axis = Point3 {
358            x: 0.0,
359            y: 0.0,
360            z: 1.0,
361        };
362        let hinge = Hinge {
363            parent: 0,
364            p: [
365                Point3 {
366                    x: 0.0,
367                    y: 0.0,
368                    z: 0.0,
369                },
370                Point3 {
371                    x: 6.0,
372                    y: 0.0,
373                    z: 0.0,
374                },
375            ],
376            v: [axis, axis],
377            vmin: i16::MIN,
378            vmax: i16::MAX,
379            htype: 0,
380            filler: [0; 7],
381        };
382        // An identity (axis-aligned, origin) parent world transform.
383        let parent = (
384            [1.0, 0.0, 0.0],
385            [0.0, 1.0, 0.0],
386            [0.0, 0.0, 1.0],
387            [0.0, 0.0, 0.0],
388        );
389        let close = |a: [f32; 3], b: [f32; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-4);
390        for val in [0i16, 8000, 16384, -16384, 30000, i16::MIN] {
391            let want = legacy_limb_xform(parent, &hinge, val);
392            let got = limb_xform(parent, &hinge, &BoneXform::from_hinge_angle(pt(axis), val));
393            assert!(
394                close(got.0, want.0),
395                "s mismatch at {val}: {:?} vs {:?}",
396                got.0,
397                want.0
398            );
399            assert!(close(got.1, want.1), "h mismatch at {val}");
400            assert!(close(got.2, want.2), "f mismatch at {val}");
401            assert!(close(got.3, want.3), "p mismatch at {val}");
402        }
403    }
404}