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}