Skip to main content

khora_core/math/
simd.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Explicit-SIMD batch kernels for transform math — the AGDF *compute-heavy hot
16//! loop* lever.
17//!
18//! A component column is stored as an array of whole structs (AoS-within-the-
19//! component), which is fine for random access but leaves SIMD lanes empty on a
20//! batched math sweep: a `Vec3`/`Quaternion` only fills part of a vector
21//! register. The lever is to feed the heavy loops *field-split* data — every
22//! field its own contiguous `f32` stream ([`TrsBatchSoa`]) — and process eight
23//! entities at a time with [`wide::f32x8`]. `f32x8` maps 1:1 to an 8-wide AoSoA
24//! tile, so the index split is trivial and the compiler emits one aligned vector
25//! op per field instead of eight scalar ones.
26//!
27//! Why *explicit* SIMD rather than trusting auto-vectorisation: the heavy parts
28//! of this math are a quaternion-to-matrix expansion and a `1/√(x²+y²+z²+w²)`
29//! normalisation. The normalisation's horizontal sum is a floating-point
30//! reduction, and FP addition is not associative — the compiler is not allowed
31//! to reorder it into independent lanes, so it serialises and the loop stays
32//! scalar. Writing the lanes by hand recovers the throughput the auto-vectoriser
33//! leaves on the table.
34//!
35//! The kernels are layout-agnostic in the LLAMA sense: callers fill a
36//! [`TrsBatchSoa`] from whatever their storage is, and the kernel owns the
37//! physical f32x8 tiling underneath. Each kernel has a scalar twin (used for the
38//! ragged tail and as the equivalence oracle in tests) so the SIMD path is
39//! guaranteed to match the scalar result to the last representable bit of the
40//! same operations.
41
42use wide::{f32x8, CmpGt};
43
44use super::{Mat4, Vec4};
45
46/// SIMD lane width. Eight `f32` lanes = one 256-bit register (AVX) and the
47/// AoSoA tile width the [`TrsBatchSoa`] kernels stride by.
48pub const LANES: usize = 8;
49
50/// A field-split (struct-of-arrays) batch of translate/rotate/scale inputs.
51///
52/// Each field is its own contiguous `f32` stream so a kernel can load eight
53/// entities of one field into a single [`f32x8`]. Fill it from any source
54/// (e.g. an ECS `Transform` column) with [`push`](Self::push); the engine's
55/// component types stay in the data crate, this buffer speaks only `f32`.
56#[derive(Debug, Default, Clone)]
57pub struct TrsBatchSoa {
58    /// Translation X / Y / Z, one entry per entity.
59    pub tx: Vec<f32>,
60    /// Translation Y.
61    pub ty: Vec<f32>,
62    /// Translation Z.
63    pub tz: Vec<f32>,
64    /// Rotation quaternion X / Y / Z / W, one entry per entity.
65    pub qx: Vec<f32>,
66    /// Rotation quaternion Y.
67    pub qy: Vec<f32>,
68    /// Rotation quaternion Z.
69    pub qz: Vec<f32>,
70    /// Rotation quaternion W.
71    pub qw: Vec<f32>,
72    /// Scale X / Y / Z, one entry per entity.
73    pub sx: Vec<f32>,
74    /// Scale Y.
75    pub sy: Vec<f32>,
76    /// Scale Z.
77    pub sz: Vec<f32>,
78}
79
80impl TrsBatchSoa {
81    /// Creates an empty batch with room for `n` entities reserved in every field.
82    pub fn with_capacity(n: usize) -> Self {
83        let v = || Vec::with_capacity(n);
84        Self {
85            tx: v(),
86            ty: v(),
87            tz: v(),
88            qx: v(),
89            qy: v(),
90            qz: v(),
91            qw: v(),
92            sx: v(),
93            sy: v(),
94            sz: v(),
95        }
96    }
97
98    /// The number of entities in the batch.
99    pub fn len(&self) -> usize {
100        self.tx.len()
101    }
102
103    /// Whether the batch holds no entities.
104    pub fn is_empty(&self) -> bool {
105        self.tx.is_empty()
106    }
107
108    /// Drops every entity, keeping the allocated capacity for reuse next frame.
109    pub fn clear(&mut self) {
110        self.tx.clear();
111        self.ty.clear();
112        self.tz.clear();
113        self.qx.clear();
114        self.qy.clear();
115        self.qz.clear();
116        self.qw.clear();
117        self.sx.clear();
118        self.sy.clear();
119        self.sz.clear();
120    }
121
122    /// Appends one entity's TRS, given translation `[x, y, z]`, rotation
123    /// quaternion `[x, y, z, w]`, and scale `[x, y, z]`.
124    pub fn push(&mut self, translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) {
125        self.tx.push(translation[0]);
126        self.ty.push(translation[1]);
127        self.tz.push(translation[2]);
128        self.qx.push(rotation[0]);
129        self.qy.push(rotation[1]);
130        self.qz.push(rotation[2]);
131        self.qw.push(rotation[3]);
132        self.sx.push(scale[0]);
133        self.sy.push(scale[1]);
134        self.sz.push(scale[2]);
135    }
136}
137
138/// Composes `out[i] = T · R · S` for every entity in `batch`, the same
139/// column-major affine matrix [`Mat4::from_translation`]`(t) *
140/// `[`Mat4::from_quat`]`(q) * `[`Mat4::from_scale`]`(s)` produces — eight
141/// entities per [`f32x8`] tile, scalar for the ragged tail.
142///
143/// The quaternion is taken as-is (not renormalised), matching
144/// [`Mat4::from_quat`]. The last matrix row is written as the exact affine
145/// constant `[0, 0, 0, 1]`.
146///
147/// **Performance caveat (measured):** because the output is *array-of-structs*
148/// `Mat4`, each lane is transposed back out individually, and that scatter
149/// dominates the relatively cheap quaternion expansion — so for AoS `Mat4`
150/// output this kernel is *slower* than [`compose_trs_to_mat4_scalar`]. The
151/// explicit-SIMD win materialises only when the data stays field-SoA *resident*
152/// across the loop (see [`normalize_quat_batch`], which is in-place SoA and does
153/// win). Use this kernel only inside a pipeline whose result also stays SoA, not
154/// for a one-off SoA→AoS transpose.
155///
156/// # Panics
157/// Panics if `out.len() != batch.len()`.
158pub fn compose_trs_to_mat4(batch: &TrsBatchSoa, out: &mut [Mat4]) {
159    let n = batch.len();
160    assert_eq!(
161        out.len(),
162        n,
163        "output slice length ({}) must match batch length ({n})",
164        out.len()
165    );
166
167    let one = f32x8::splat(1.0);
168    let full = (n / LANES) * LANES;
169    let mut i = 0;
170    while i < full {
171        let qx = load8(&batch.qx, i);
172        let qy = load8(&batch.qy, i);
173        let qz = load8(&batch.qz, i);
174        let qw = load8(&batch.qw, i);
175        let sx = load8(&batch.sx, i);
176        let sy = load8(&batch.sy, i);
177        let sz = load8(&batch.sz, i);
178
179        // Quaternion → rotation columns, mirroring `Mat4::from_quat` exactly.
180        let x2 = qx + qx;
181        let y2 = qy + qy;
182        let z2 = qz + qz;
183        let xx = qx * x2;
184        let xy = qx * y2;
185        let xz = qx * z2;
186        let yy = qy * y2;
187        let yz = qy * z2;
188        let zz = qz * z2;
189        let wx = qw * x2;
190        let wy = qw * y2;
191        let wz = qw * z2;
192
193        // `T·R·S` upper-left = rotation columns scaled per-axis by S.
194        let c0x = (one - (yy + zz)) * sx;
195        let c0y = (xy + wz) * sx;
196        let c0z = (xz - wy) * sx;
197        let c1x = (xy - wz) * sy;
198        let c1y = (one - (xx + zz)) * sy;
199        let c1z = (yz + wx) * sy;
200        let c2x = (xz + wy) * sz;
201        let c2y = (yz - wx) * sz;
202        let c2z = (one - (xx + yy)) * sz;
203
204        let tx = load8(&batch.tx, i).to_array();
205        let ty = load8(&batch.ty, i).to_array();
206        let tz = load8(&batch.tz, i).to_array();
207        let (c0x, c0y, c0z) = (c0x.to_array(), c0y.to_array(), c0z.to_array());
208        let (c1x, c1y, c1z) = (c1x.to_array(), c1y.to_array(), c1z.to_array());
209        let (c2x, c2y, c2z) = (c2x.to_array(), c2y.to_array(), c2z.to_array());
210
211        for lane in 0..LANES {
212            out[i + lane] = Mat4::from_cols(
213                Vec4::new(c0x[lane], c0y[lane], c0z[lane], 0.0),
214                Vec4::new(c1x[lane], c1y[lane], c1z[lane], 0.0),
215                Vec4::new(c2x[lane], c2y[lane], c2z[lane], 0.0),
216                Vec4::new(tx[lane], ty[lane], tz[lane], 1.0),
217            );
218        }
219        i += LANES;
220    }
221
222    // Ragged tail (n not a multiple of LANES) — scalar twin of the kernel above.
223    while i < n {
224        out[i] = compose_one(
225            [batch.tx[i], batch.ty[i], batch.tz[i]],
226            [batch.qx[i], batch.qy[i], batch.qz[i], batch.qw[i]],
227            [batch.sx[i], batch.sy[i], batch.sz[i]],
228        );
229        i += 1;
230    }
231}
232
233/// Scalar twin of [`compose_trs_to_mat4`] — identical result, one entity at a
234/// time, no SIMD. This is the fallback a caller should prefer for small batches
235/// (where the field-split transpose costs more than the vector op saves) and the
236/// equivalence oracle the SIMD path is tested against.
237///
238/// # Panics
239/// Panics if `out.len() != batch.len()`.
240pub fn compose_trs_to_mat4_scalar(batch: &TrsBatchSoa, out: &mut [Mat4]) {
241    assert_eq!(
242        out.len(),
243        batch.len(),
244        "output slice length ({}) must match batch length ({})",
245        out.len(),
246        batch.len()
247    );
248    for (i, slot) in out.iter_mut().enumerate() {
249        *slot = compose_one(
250            [batch.tx[i], batch.ty[i], batch.tz[i]],
251            [batch.qx[i], batch.qy[i], batch.qz[i], batch.qw[i]],
252            [batch.sx[i], batch.sy[i], batch.sz[i]],
253        );
254    }
255}
256
257/// Scalar `T · R · S` for one entity — the equivalence oracle for
258/// [`compose_trs_to_mat4`] and the kernel used for its ragged tail.
259#[inline]
260fn compose_one(t: [f32; 3], q: [f32; 4], s: [f32; 3]) -> Mat4 {
261    let [qx, qy, qz, qw] = q;
262    let x2 = qx + qx;
263    let y2 = qy + qy;
264    let z2 = qz + qz;
265    let xx = qx * x2;
266    let xy = qx * y2;
267    let xz = qx * z2;
268    let yy = qy * y2;
269    let yz = qy * z2;
270    let zz = qz * z2;
271    let wx = qw * x2;
272    let wy = qw * y2;
273    let wz = qw * z2;
274    Mat4::from_cols(
275        Vec4::new(
276            (1.0 - (yy + zz)) * s[0],
277            (xy + wz) * s[0],
278            (xz - wy) * s[0],
279            0.0,
280        ),
281        Vec4::new(
282            (xy - wz) * s[1],
283            (1.0 - (xx + zz)) * s[1],
284            (yz + wx) * s[1],
285            0.0,
286        ),
287        Vec4::new(
288            (xz + wy) * s[2],
289            (yz - wx) * s[2],
290            (1.0 - (xx + yy)) * s[2],
291            0.0,
292        ),
293        Vec4::new(t[0], t[1], t[2], 1.0),
294    )
295}
296
297/// Normalises a batch of quaternions in place to unit length, eight at a time.
298///
299/// This is the kernel whose reduction (`x²+y²+z²+w²`) and `1/√` the
300/// auto-vectoriser refuses to lane because FP addition is not associative;
301/// doing it explicitly is the point of the SIMD path. All four slices must be
302/// the same length; a near-zero quaternion is left unchanged (no divide-by-zero).
303///
304/// # Panics
305/// Panics if the four slices do not all have the same length.
306pub fn normalize_quat_batch(qx: &mut [f32], qy: &mut [f32], qz: &mut [f32], qw: &mut [f32]) {
307    let n = qx.len();
308    assert!(
309        qy.len() == n && qz.len() == n && qw.len() == n,
310        "all four quaternion-component slices must have the same length"
311    );
312
313    let full = (n / LANES) * LANES;
314    let mut i = 0;
315    while i < full {
316        let x = load8(qx, i);
317        let y = load8(qy, i);
318        let z = load8(qz, i);
319        let w = load8(qw, i);
320        let len_sq = x * x + y * y + z * z + w * w;
321        // 1/√len² with the near-zero lanes forced to a unit scale (no NaN/Inf).
322        let safe = len_sq.cmp_gt(f32x8::splat(f32::MIN_POSITIVE));
323        let inv_len = safe.blend(f32x8::splat(1.0) / len_sq.sqrt(), f32x8::splat(1.0));
324        store8(qx, i, x * inv_len);
325        store8(qy, i, y * inv_len);
326        store8(qz, i, z * inv_len);
327        store8(qw, i, w * inv_len);
328        i += LANES;
329    }
330
331    while i < n {
332        let len_sq = qx[i] * qx[i] + qy[i] * qy[i] + qz[i] * qz[i] + qw[i] * qw[i];
333        if len_sq > f32::MIN_POSITIVE {
334            let inv = 1.0 / len_sq.sqrt();
335            qx[i] *= inv;
336            qy[i] *= inv;
337            qz[i] *= inv;
338            qw[i] *= inv;
339        }
340        i += 1;
341    }
342}
343
344/// Loads eight contiguous `f32`s starting at `i` into a vector lane.
345///
346/// Goes through a contiguous slice→array conversion (one 32-byte move the
347/// backend lowers to a single vector load) rather than eight indexed reads —
348/// the latter keeps per-element bounds checks and defeats the vectorisation.
349#[inline]
350fn load8(s: &[f32], i: usize) -> f32x8 {
351    let chunk: [f32; LANES] = s[i..i + LANES].try_into().unwrap();
352    f32x8::new(chunk)
353}
354
355/// Stores a vector lane back into eight contiguous `f32`s starting at `i`.
356#[inline]
357fn store8(s: &mut [f32], i: usize, v: f32x8) {
358    let a = v.to_array();
359    s[i..i + LANES].copy_from_slice(&a);
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::math::{Quaternion, Vec3};
366
367    /// A spread of TRS inputs whose count is deliberately not a multiple of
368    /// `LANES`, so both the vector body and the scalar tail are exercised.
369    fn sample_batch(n: usize) -> TrsBatchSoa {
370        let mut b = TrsBatchSoa::with_capacity(n);
371        for k in 0..n {
372            let f = k as f32;
373            let axis = Vec3::new(0.3 + f, 1.0 - 0.2 * f, 0.5 + 0.1 * f).normalize();
374            let q = Quaternion::from_axis_angle(axis, 0.17 * f + 0.4);
375            b.push(
376                [f, -2.0 * f, 0.5 * f + 1.0],
377                [q.x, q.y, q.z, q.w],
378                [1.0 + 0.1 * f, 2.0 - 0.05 * f, 0.75 + 0.02 * f],
379            );
380        }
381        b
382    }
383
384    #[test]
385    fn compose_matches_scalar_oracle() {
386        let n = 21; // 2 full tiles + 5 tail
387        let batch = sample_batch(n);
388        let mut out = vec![Mat4::IDENTITY; n];
389        compose_trs_to_mat4(&batch, &mut out);
390
391        for (k, mat) in out.iter().enumerate() {
392            let expected = compose_one(
393                [batch.tx[k], batch.ty[k], batch.tz[k]],
394                [batch.qx[k], batch.qy[k], batch.qz[k], batch.qw[k]],
395                [batch.sx[k], batch.sy[k], batch.sz[k]],
396            );
397            for col in 0..4 {
398                for row in 0..4 {
399                    assert!(
400                        crate::math::approx_eq(mat.cols[col][row], expected.cols[col][row]),
401                        "entity {k} mismatch at col {col} row {row}"
402                    );
403                }
404            }
405            // Affine last row must be the exact constant the AffineTransform
406            // conversion asserts on.
407            assert_eq!(mat.get_row(3), Vec4::new(0.0, 0.0, 0.0, 1.0));
408        }
409    }
410
411    #[test]
412    fn normalize_matches_scalar_and_yields_unit_length() {
413        let n = 19; // 2 full tiles + 3 tail
414        let mut qx = Vec::new();
415        let mut qy = Vec::new();
416        let mut qz = Vec::new();
417        let mut qw = Vec::new();
418        for k in 0..n {
419            let f = k as f32;
420            qx.push(0.5 + f);
421            qy.push(1.0 - 0.1 * f);
422            qz.push(0.25 * f);
423            qw.push(2.0 + 0.3 * f);
424        }
425        let (rx, ry, rz, rw) = (qx.clone(), qy.clone(), qz.clone(), qw.clone());
426
427        normalize_quat_batch(&mut qx, &mut qy, &mut qz, &mut qw);
428
429        for k in 0..n {
430            let inv = 1.0 / (rx[k] * rx[k] + ry[k] * ry[k] + rz[k] * rz[k] + rw[k] * rw[k]).sqrt();
431            assert!(crate::math::approx_eq(qx[k], rx[k] * inv));
432            assert!(crate::math::approx_eq(qy[k], ry[k] * inv));
433            assert!(crate::math::approx_eq(qz[k], rz[k] * inv));
434            assert!(crate::math::approx_eq(qw[k], rw[k] * inv));
435            let len = (qx[k] * qx[k] + qy[k] * qy[k] + qz[k] * qz[k] + qw[k] * qw[k]).sqrt();
436            assert!(
437                crate::math::approx_eq(len, 1.0),
438                "entity {k} not unit length"
439            );
440        }
441    }
442
443    #[test]
444    fn near_zero_quaternion_is_left_unchanged() {
445        let mut qx = vec![0.0f32; LANES];
446        let mut qy = vec![0.0f32; LANES];
447        let mut qz = vec![0.0f32; LANES];
448        let mut qw = vec![0.0f32; LANES];
449        normalize_quat_batch(&mut qx, &mut qy, &mut qz, &mut qw);
450        assert!(qx.iter().all(|&v| v == 0.0));
451        assert!(qw.iter().all(|&v| v == 0.0));
452    }
453}