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 full animation system (`animsprite` + `seq[]` +
16//! `frmval[]` interpolation) is **not** ported here — the host
17//! drives `kfaval[]` directly each frame, which is enough for
18//! procedural animation. Adding `animsprite` later would let
19//! `.kfa` files with baked frame curves play back unchanged.
20//!
21//! No oracle pose exercises KFA, so this module's correctness
22//! gate is "looks right + tests verify the bone math". We can
23//! tighten validation when a real `.kfa` asset lands.
24
25#![allow(
26 clippy::cast_possible_truncation,
27 clippy::cast_possible_wrap,
28 clippy::cast_sign_loss,
29 clippy::cast_precision_loss,
30 clippy::similar_names,
31 clippy::too_many_arguments,
32 clippy::doc_markdown,
33 clippy::many_single_char_names,
34 clippy::missing_panics_doc,
35 clippy::float_cmp,
36 clippy::useless_vec
37)]
38
39use roxlap_formats::kfa::{Hinge, KfaSprite, Point3};
40use roxlap_formats::sprite::Sprite;
41
42use crate::camera_math::CameraState;
43use crate::opticast::OpticastSettings;
44use crate::sprite::{draw_sprite, mat2, DrawTarget, SpriteLighting};
45
46/// Voxlap's `genperp` — given a non-zero axis vector `a`, build
47/// two orthonormal vectors `b`, `c` such that `(a, b, c)` form a
48/// right-handed orthonormal basis with `a` along its primary axis.
49/// Mirror of voxlap5.c:9546-9561.
50///
51/// If `a` is zero, returns `([0; 3], [0; 3])` (matches voxlap's
52/// degenerate-input zeroing).
53fn genperp(a: [f32; 3]) -> ([f32; 3], [f32; 3]) {
54 if a == [0.0, 0.0, 0.0] {
55 return ([0.0; 3], [0.0; 3]);
56 }
57 // Pick the smallest-magnitude axis to zero out in `b`, so the
58 // remaining two components dominate and the cross-product
59 // `c = a × b` stays well-conditioned.
60 let ax = a[0].abs();
61 let ay = a[1].abs();
62 let az = a[2].abs();
63 let b = if ax < ay && ax < az {
64 let t = 1.0 / (a[1] * a[1] + a[2] * a[2]).sqrt();
65 [0.0, a[2] * t, -a[1] * t]
66 } else if ay < az {
67 let t = 1.0 / (a[0] * a[0] + a[2] * a[2]).sqrt();
68 [-a[2] * t, 0.0, a[0] * t]
69 } else {
70 let t = 1.0 / (a[0] * a[0] + a[1] * a[1]).sqrt();
71 [a[1] * t, -a[0] * t, 0.0]
72 };
73 let c = [
74 a[1] * b[2] - a[2] * b[1],
75 a[2] * b[0] - a[0] * b[2],
76 a[0] * b[1] - a[1] * b[0],
77 ];
78 (b, c)
79}
80
81/// Voxlap's `mat0` (`voxlap5.c:9568`) — given `B` and `C` such
82/// that `A * B = C`, find `A`. Returns `(a_s, a_h, a_f, a_o)`.
83///
84/// Used by `setlimb` to find the rotation matrix that maps the
85/// parent's hinge frame to the child's hinge frame.
86fn mat0(
87 b_s: [f32; 3],
88 b_h: [f32; 3],
89 b_f: [f32; 3],
90 b_o: [f32; 3],
91 c_s: [f32; 3],
92 c_h: [f32; 3],
93 c_f: [f32; 3],
94 c_o: [f32; 3],
95) -> ([f32; 3], [f32; 3], [f32; 3], [f32; 3]) {
96 // A's columns = C's columns expressed in B's frame.
97 // Voxlap evaluates as `bs.row * c.col + bh.row * c.col + bg.row * c.col`
98 // for each (row, output-col) pair.
99 let ts = [
100 b_s[0] * c_s[0] + b_h[0] * c_h[0] + b_f[0] * c_f[0],
101 b_s[0] * c_s[1] + b_h[0] * c_h[1] + b_f[0] * c_f[1],
102 b_s[0] * c_s[2] + b_h[0] * c_h[2] + b_f[0] * c_f[2],
103 ];
104 let th = [
105 b_s[1] * c_s[0] + b_h[1] * c_h[0] + b_f[1] * c_f[0],
106 b_s[1] * c_s[1] + b_h[1] * c_h[1] + b_f[1] * c_f[1],
107 b_s[1] * c_s[2] + b_h[1] * c_h[2] + b_f[1] * c_f[2],
108 ];
109 let tf = [
110 b_s[2] * c_s[0] + b_h[2] * c_h[0] + b_f[2] * c_f[0],
111 b_s[2] * c_s[1] + b_h[2] * c_h[1] + b_f[2] * c_f[1],
112 b_s[2] * c_s[2] + b_h[2] * c_h[2] + b_f[2] * c_f[2],
113 ];
114 let to = [
115 c_o[0] - b_o[0] * ts[0] - b_o[1] * th[0] - b_o[2] * tf[0],
116 c_o[1] - b_o[0] * ts[1] - b_o[1] * th[1] - b_o[2] * tf[1],
117 c_o[2] - b_o[0] * ts[2] - b_o[1] * th[2] - b_o[2] * tf[2],
118 ];
119 (ts, th, tf, to)
120}
121
122#[inline]
123fn pt(p: Point3) -> [f32; 3] {
124 [p.x, p.y, p.z]
125}
126
127/// Convert voxlap's i16 hinge angle (full circle = 65536) to
128/// `(cos, sin)` floats. Voxlap C uses `ucossin(((int32_t)val)<<16,
129/// ...)`, a precomputed 256-entry table. Our port uses libm
130/// `cos/sin` — no oracle pose exercises this code so the small
131/// precision difference vs voxlap C's table is acceptable.
132fn cossin_q15(val: i16) -> [f32; 2] {
133 let ang = (i32::from(val) as f32) * (std::f32::consts::PI * 2.0 / 65536.0);
134 [ang.cos(), ang.sin()]
135}
136
137/// Voxlap's `setlimb` (`voxlap5.c:9643`) — compute child limb
138/// `i`'s world transform from parent limb `p`'s world transform
139/// via the hinge connecting them.
140///
141/// Math:
142/// 1. Build the child-side velcro frame from the hinge: `qp =
143/// hinge.p[0]`, `(qs, qh, qf) = (hinge.v[0], genperp(hinge.v[0]))`.
144/// 2. Apply the hinge transform — for `htype == 0` (rotate around
145/// `qs` by `val` angle), rotate `(qh, qf)` in their plane.
146/// 3. Build the parent-side velcro frame: `pp = hinge.p[1]`,
147/// `(ps, ph, pf) = (hinge.v[1], genperp(hinge.v[1]))`.
148/// 4. `mat0`: find `R` such that `R * (ps, ph, pf, pp) = (qs, qh,
149/// qf, qp)` — `R` is the limb's hinge rotation in parent
150/// coords.
151/// 5. `mat2`: `child_world = parent_world * R`. The limb's `(s,
152/// h, f, p)` transform updates in place.
153fn setlimb(limbs: &mut [Sprite], hinges: &[Hinge], i: usize, parent: usize, htype: u8, val: i16) {
154 let hinge = &hinges[i];
155
156 // Step 1: child-side velcro frame.
157 let qp = pt(hinge.p[0]);
158 let mut qs = pt(hinge.v[0]);
159 let (mut qh, mut qf) = genperp(qs);
160
161 // Step 2: apply hinge transform.
162 if htype == 0 {
163 // Rotate (qh, qf) around qs by `val` (Q15 angle).
164 let r = cossin_q15(val);
165 let ph = qh;
166 let pf = qf;
167 qh = [
168 ph[0] * r[0] - pf[0] * r[1],
169 ph[1] * r[0] - pf[1] * r[1],
170 ph[2] * r[0] - pf[2] * r[1],
171 ];
172 qf = [
173 ph[0] * r[1] + pf[0] * r[0],
174 ph[1] * r[1] + pf[1] * r[0],
175 ph[2] * r[1] + pf[2] * r[0],
176 ];
177 } else {
178 // Voxlap's only documented htype is 0; non-0 hits a MSVC
179 // `__assume(0)`. Treat as no rotation.
180 // (Suppress the unused `qs` warning if we add types later.)
181 let _ = (&mut qs, &mut qh, &mut qf);
182 }
183
184 // Step 3: parent-side velcro frame.
185 let pp = pt(hinge.p[1]);
186 let ps = pt(hinge.v[1]);
187 let (ph, pf) = genperp(ps);
188
189 // Step 4: mat0 — find R such that R * (ps,ph,pf,pp) = (qs,qh,qf,qp).
190 // R reuses the (qs, qh, qf, qp) variables in voxlap's C code.
191 let (rs, rh, rf, ro) = mat0(ps, ph, pf, pp, qs, qh, qf, qp);
192
193 // Step 5: mat2 — child_world = parent_world * R.
194 let parent_s = limbs[parent].s;
195 let parent_h = limbs[parent].h;
196 let parent_f = limbs[parent].f;
197 let parent_p = limbs[parent].p;
198 let (cs, ch, cf, co) = mat2(parent_s, parent_h, parent_f, parent_p, rs, rh, rf, ro);
199 let child = &mut limbs[i];
200 child.s = cs;
201 child.h = ch;
202 child.f = cf;
203 child.p = co;
204}
205
206/// Render an animated KFA sprite — voxlap's `kfadraw`
207/// (voxlap5.c:9759). Walks the bone tree in topological order
208/// (parents first), computes each limb's world transform from
209/// the parent's via the per-limb `setlimb` walk, then dispatches
210/// [`crate::sprite::draw_sprite`] per limb to rasterise its kv6.
211///
212/// Returns the total number of pixels written across all limbs.
213pub fn draw_kfa_sprite(
214 target: &mut DrawTarget<'_>,
215 cam: &CameraState,
216 settings: &OpticastSettings,
217 lighting: &SpriteLighting<'_>,
218 kfa: &mut KfaSprite,
219) -> u32 {
220 // Voxlap iterates `for i = numhin-1; i >= 0; i--`; sort_hinges
221 // puts parents at high indices, so descending iteration walks
222 // parents first.
223 let n = kfa.hinge_sort.len();
224 let mut total: u32 = 0;
225 for k in (0..n).rev() {
226 let j = kfa.hinge_sort[k];
227 let parent = kfa.hinges[j].parent;
228 if parent >= 0 {
229 // Child bone: derive transform from parent.
230 let htype = kfa.hinges[j].htype;
231 let val = kfa.kfaval[j];
232 setlimb(&mut kfa.limbs, &kfa.hinges, j, parent as usize, htype, val);
233 } else {
234 // Root bone: copy world basis from KfaSprite + apply
235 // hinge.p[0] as the velcro offset (voxlap5.c:9772-9782).
236 let s = kfa.s;
237 let h = kfa.h;
238 let f = kfa.f;
239 let p_world = kfa.p;
240 let tp = pt(kfa.hinges[j].p[0]);
241 let limb = &mut kfa.limbs[j];
242 limb.s = s;
243 limb.h = h;
244 limb.f = f;
245 limb.p = [
246 p_world[0] - tp[0] * s[0] - tp[1] * h[0] - tp[2] * f[0],
247 p_world[1] - tp[0] * s[1] - tp[1] * h[1] - tp[2] * f[1],
248 p_world[2] - tp[0] * s[2] - tp[1] * h[2] - tp[2] * f[2],
249 ];
250 }
251 // Render the limb's kv6 mesh.
252 total += draw_sprite(target, cam, settings, lighting, &kfa.limbs[j]);
253 }
254 total
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 /// genperp produces an orthonormal basis with the input axis
262 /// as the dominant direction.
263 #[test]
264 fn genperp_orthonormal() {
265 let a = [1.0_f32, 0.0, 0.0];
266 let (b, c) = genperp(a);
267 // b · a = 0
268 assert!((a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).abs() < 1e-6);
269 // c · a = 0
270 assert!((a[0] * c[0] + a[1] * c[1] + a[2] * c[2]).abs() < 1e-6);
271 // b · c = 0
272 assert!((b[0] * c[0] + b[1] * c[1] + b[2] * c[2]).abs() < 1e-6);
273 // |b| ≈ 1
274 let lb = b[0] * b[0] + b[1] * b[1] + b[2] * b[2];
275 assert!((lb - 1.0).abs() < 1e-5, "|b|² = {lb}");
276 // |c| ≈ 1
277 let lc = c[0] * c[0] + c[1] * c[1] + c[2] * c[2];
278 assert!((lc - 1.0).abs() < 1e-5, "|c|² = {lc}");
279 }
280
281 /// genperp of zero vector returns zero vectors.
282 #[test]
283 fn genperp_zero() {
284 let (b, c) = genperp([0.0, 0.0, 0.0]);
285 assert_eq!(b, [0.0, 0.0, 0.0]);
286 assert_eq!(c, [0.0, 0.0, 0.0]);
287 }
288}