Skip to main content

khora_data/ecs/
soa.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//! Field-split (Structure-of-Arrays) component storage — the physical layout
16//! the AGDF compute kernels want.
17//!
18//! A normal CRPECS column is `Vec<T>` (Array-of-Structures *within* the
19//! component): one struct after another. For a compute-heavy hot loop that
20//! sweeps a few fields over many entities, that wastes the CPU's SIMD slots and
21//! cache. A [`FieldSoaColumn`] instead stores **one contiguous `f32` array per
22//! field**, so each field streams without gather and tiles cleanly into
23//! `f32x8` (the resident layout the bench showed reaches ~4×).
24//!
25//! This is the data-layer realisation of "adapt the HOW, not the WHAT": the
26//! component's *semantics* are unchanged, only its column's physical mapping.
27//! It is **opt-in per component** (`#[component(layout = "soa")]`) and lives
28//! behind the same `Box<dyn AnyVec>` as any column, so the rest of CRPECS —
29//! pages, domains, queries on other components, serialization — is untouched.
30//! A field-SoA component can't yield `&T` (its bytes aren't a contiguous `T`),
31//! so it is read by value via the `Soa<T>` query / `World::clone_component`, or
32//! in bulk via the per-field arrays for SIMD.
33//!
34//! The generic column needs only a tiny amount of per-type knowledge — how to
35//! scatter a value into the field arrays and gather it back — supplied by
36//! [`SoaLayout`], which `#[derive(Component)]` generates for `layout = "soa"`
37//! structs whose fields are all `f32`.
38//!
39//! (Note: "field array" here is the per-component-field `Vec<f32>`; it is
40//! unrelated to the engine's [`Lane`](crate::ecs) negotiation concept.)
41
42use std::any::Any;
43use std::marker::PhantomData;
44
45use crate::ecs::component::Component;
46use crate::ecs::page::{AnyVec, SetFromBytesError, MAX_COLUMN_PAYLOAD_BYTES};
47
48/// Per-type knowledge the generic [`FieldSoaColumn`] needs to scatter a
49/// component into its `f32` field arrays and gather it back.
50///
51/// Generated by `#[derive(Component)]` + `#[component(layout = "soa")]` for
52/// structs whose fields are all `f32`. The array order matches field
53/// declaration order.
54pub trait SoaLayout: Component {
55    /// Number of `f32` fields this component decomposes into.
56    const FIELD_COUNT: usize;
57    /// Field names in declaration order (for the glass-box / debugging).
58    const FIELD_NAMES: &'static [&'static str];
59
60    /// Appends each of `self`'s fields onto the matching field array (one new
61    /// row).
62    ///
63    /// `fields.len() == FIELD_COUNT` is guaranteed by the caller.
64    fn scatter_push(&self, fields: &mut [Vec<f32>]);
65
66    /// Overwrites row `row` of every field array with `self`'s fields.
67    ///
68    /// `fields.len() == FIELD_COUNT` and `row` in bounds are guaranteed.
69    fn scatter_set(&self, fields: &mut [Vec<f32>], row: usize);
70
71    /// Reconstructs a value from row `row` across the field arrays.
72    fn gather(fields: &[Vec<f32>], row: usize) -> Self;
73}
74
75/// A field-split component column: one contiguous `Vec<f32>` per component
76/// field.
77///
78/// Stored behind `Box<dyn AnyVec>` exactly like an AoS `Vec<T>` column, so it
79/// participates in the normal page lifecycle (spawn / despawn / migration /
80/// serialization). Access is by value ([`get`](Self::get)) or in bulk through
81/// the per-field [`field`](Self::field) slices for SIMD kernels.
82#[derive(Debug)]
83pub struct FieldSoaColumn<T: SoaLayout> {
84    /// `FIELD_COUNT` field arrays, each holding one `f32` per row. All arrays
85    /// have the same length (the row count).
86    fields: Vec<Vec<f32>>,
87    _marker: PhantomData<fn() -> T>,
88}
89
90impl<T: SoaLayout> Default for FieldSoaColumn<T> {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl<T: SoaLayout> FieldSoaColumn<T> {
97    /// Creates an empty column with one field array per component field.
98    pub fn new() -> Self {
99        Self {
100            fields: (0..T::FIELD_COUNT).map(|_| Vec::new()).collect(),
101            _marker: PhantomData,
102        }
103    }
104
105    /// The number of rows (entities) stored.
106    pub fn len(&self) -> usize {
107        self.fields.first().map_or(0, Vec::len)
108    }
109
110    /// Whether the column holds no rows.
111    pub fn is_empty(&self) -> bool {
112        self.len() == 0
113    }
114
115    /// Appends a value as a new row.
116    pub fn push(&mut self, value: T) {
117        value.scatter_push(&mut self.fields);
118    }
119
120    /// Reconstructs the value stored at `row`.
121    ///
122    /// # Panics
123    /// Panics if `row >= len()`.
124    pub fn get(&self, row: usize) -> T {
125        T::gather(&self.fields, row)
126    }
127
128    /// Overwrites the value at `row`.
129    ///
130    /// # Panics
131    /// Panics if `row >= len()`.
132    pub fn set(&mut self, row: usize, value: T) {
133        value.scatter_set(&mut self.fields, row);
134    }
135
136    /// Read-only access to one field's array (`field < FIELD_COUNT`) — the
137    /// contiguous `f32` stream a SIMD kernel tiles over.
138    pub fn field(&self, field: usize) -> &[f32] {
139        &self.fields[field]
140    }
141
142    /// Mutable access to one field's array.
143    pub fn field_mut(&mut self, field: usize) -> &mut [f32] {
144        &mut self.fields[field]
145    }
146
147    /// All field arrays (read-only).
148    pub fn fields(&self) -> &[Vec<f32>] {
149        &self.fields
150    }
151
152    /// All field arrays (mutable) — for a kernel that writes several fields.
153    pub fn fields_mut(&mut self) -> &mut [Vec<f32>] {
154        &mut self.fields
155    }
156}
157
158impl<T: SoaLayout> AnyVec for FieldSoaColumn<T> {
159    fn as_any(&self) -> &dyn Any {
160        self
161    }
162
163    fn as_any_mut(&mut self) -> &mut dyn Any {
164        self
165    }
166
167    fn swap_remove_any(&mut self, index: usize) {
168        for field in &mut self.fields {
169            field.swap_remove(index);
170        }
171    }
172
173    fn to_bytes(&self) -> Vec<u8> {
174        // Field-major little-endian: field 0's f32s, then field 1's, … This is
175        // the column's own self-consistent format; `set_from_bytes` reverses it.
176        let mut out = Vec::with_capacity(self.len() * T::FIELD_COUNT * 4);
177        for field in &self.fields {
178            for &v in field {
179                out.extend_from_slice(&v.to_le_bytes());
180            }
181        }
182        out
183    }
184
185    unsafe fn set_from_bytes(&mut self, bytes: &[u8]) -> Result<(), SetFromBytesError> {
186        let field_count = T::FIELD_COUNT;
187        // One row occupies `field_count` little-endian f32s. A zero-field layout
188        // has no payload, mirroring the ZST handling in the `Vec<T>` column.
189        let row_size = field_count.saturating_mul(4);
190        if row_size == 0 {
191            return Ok(());
192        }
193
194        // Validate the untrusted length before reserving any rows: it must be an
195        // exact multiple of a full row and stay under the payload ceiling.
196        if !bytes.len().is_multiple_of(row_size) {
197            return Err(SetFromBytesError::MisalignedLength {
198                len: bytes.len(),
199                elem_size: row_size,
200            });
201        }
202        if bytes.len() > MAX_COLUMN_PAYLOAD_BYTES {
203            return Err(SetFromBytesError::PayloadTooLarge {
204                len: bytes.len(),
205                max: MAX_COLUMN_PAYLOAD_BYTES,
206            });
207        }
208
209        let rows = bytes.len() / row_size;
210        let mut idx = 0usize;
211        for field in &mut self.fields {
212            field.clear();
213            field.reserve(rows);
214            for _ in 0..rows {
215                let b = [
216                    bytes[idx * 4],
217                    bytes[idx * 4 + 1],
218                    bytes[idx * 4 + 2],
219                    bytes[idx * 4 + 3],
220                ];
221                field.push(f32::from_le_bytes(b));
222                idx += 1;
223            }
224        }
225        Ok(())
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    // A hand-written SoaLayout to test the generic column without the macro.
234    #[derive(Debug, Clone, Copy, PartialEq)]
235    struct P {
236        x: f32,
237        y: f32,
238        z: f32,
239    }
240    impl Component for P {}
241    impl SoaLayout for P {
242        const FIELD_COUNT: usize = 3;
243        const FIELD_NAMES: &'static [&'static str] = &["x", "y", "z"];
244        fn scatter_push(&self, fields: &mut [Vec<f32>]) {
245            fields[0].push(self.x);
246            fields[1].push(self.y);
247            fields[2].push(self.z);
248        }
249        fn scatter_set(&self, fields: &mut [Vec<f32>], row: usize) {
250            fields[0][row] = self.x;
251            fields[1][row] = self.y;
252            fields[2][row] = self.z;
253        }
254        fn gather(fields: &[Vec<f32>], row: usize) -> Self {
255            P {
256                x: fields[0][row],
257                y: fields[1][row],
258                z: fields[2][row],
259            }
260        }
261    }
262
263    #[test]
264    fn push_get_set_roundtrip() {
265        let mut col = FieldSoaColumn::<P>::new();
266        col.push(P {
267            x: 1.0,
268            y: 2.0,
269            z: 3.0,
270        });
271        col.push(P {
272            x: 4.0,
273            y: 5.0,
274            z: 6.0,
275        });
276        assert_eq!(col.len(), 2);
277        assert_eq!(
278            col.get(0),
279            P {
280                x: 1.0,
281                y: 2.0,
282                z: 3.0
283            }
284        );
285        assert_eq!(
286            col.get(1),
287            P {
288                x: 4.0,
289                y: 5.0,
290                z: 6.0
291            }
292        );
293        col.set(
294            0,
295            P {
296                x: 7.0,
297                y: 8.0,
298                z: 9.0,
299            },
300        );
301        assert_eq!(
302            col.get(0),
303            P {
304                x: 7.0,
305                y: 8.0,
306                z: 9.0
307            }
308        );
309        // Each field array is contiguous (the SIMD-friendly property).
310        assert_eq!(col.field(0), &[7.0, 4.0]);
311        assert_eq!(col.field(2), &[9.0, 6.0]);
312    }
313
314    #[test]
315    fn swap_remove_keeps_fields_aligned() {
316        let mut col = FieldSoaColumn::<P>::new();
317        for i in 0..3 {
318            let f = i as f32;
319            col.push(P {
320                x: f,
321                y: f + 10.0,
322                z: f + 20.0,
323            });
324        }
325        col.swap_remove_any(0); // row 0 replaced by last row (index 2)
326        assert_eq!(col.len(), 2);
327        assert_eq!(
328            col.get(0),
329            P {
330                x: 2.0,
331                y: 12.0,
332                z: 22.0
333            }
334        );
335        assert_eq!(
336            col.get(1),
337            P {
338                x: 1.0,
339                y: 11.0,
340                z: 21.0
341            }
342        );
343    }
344
345    #[test]
346    fn bytes_roundtrip() {
347        let mut col = FieldSoaColumn::<P>::new();
348        col.push(P {
349            x: 1.5,
350            y: -2.5,
351            z: 3.25,
352        });
353        col.push(P {
354            x: 4.0,
355            y: 5.0,
356            z: 6.0,
357        });
358        let bytes = col.to_bytes();
359        let mut restored = FieldSoaColumn::<P>::new();
360        // SAFETY: `bytes` came from `to_bytes` on the same `FieldSoaColumn<P>`
361        // type, so the field-major f32 layout matches exactly.
362        unsafe { restored.set_from_bytes(&bytes) }.expect("valid bytes must round-trip");
363        assert_eq!(restored.len(), 2);
364        assert_eq!(
365            restored.get(0),
366            P {
367                x: 1.5,
368                y: -2.5,
369                z: 3.25
370            }
371        );
372        assert_eq!(
373            restored.get(1),
374            P {
375                x: 4.0,
376                y: 5.0,
377                z: 6.0
378            }
379        );
380    }
381}
382
383/// End-to-end test of a real `#[component(layout = "soa")]` component flowing
384/// through the full CRPECS lifecycle: spawn, by-value query, bulk SIMD-style
385/// mutation, by-value get/set, and despawn — proving the field-SoA column is a
386/// first-class CRPECS citizen, not a parallel store.
387#[cfg(test)]
388mod world_integration {
389    use crate::ecs::{SemanticDomain, Soa, World};
390    use khora_macros::Component;
391
392    #[derive(Debug, Clone, Copy, PartialEq, Default, Component)]
393    #[component(no_serializable, layout = "soa")]
394    struct Velocity {
395        x: f32,
396        y: f32,
397        z: f32,
398    }
399
400    fn sorted_by_x(world: &World) -> Vec<Velocity> {
401        let mut v: Vec<Velocity> = world.query::<Soa<Velocity>>().collect();
402        v.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
403        v
404    }
405
406    #[test]
407    fn soa_component_full_lifecycle() {
408        let mut world = World::default();
409        world.register_component::<Velocity>(SemanticDomain::Physics);
410
411        let e0 = world.spawn(Velocity {
412            x: 1.0,
413            y: 2.0,
414            z: 3.0,
415        });
416        let e1 = world.spawn(Velocity {
417            x: 4.0,
418            y: 5.0,
419            z: 6.0,
420        });
421
422        // By-value query reconstructs each value from the field arrays.
423        assert_eq!(
424            sorted_by_x(&world),
425            vec![
426                Velocity {
427                    x: 1.0,
428                    y: 2.0,
429                    z: 3.0
430                },
431                Velocity {
432                    x: 4.0,
433                    y: 5.0,
434                    z: 6.0
435                },
436            ]
437        );
438
439        // Bulk SIMD-style access: the `x` field array is contiguous — double it.
440        world.for_each_soa_column_mut::<Velocity>(|col| {
441            for x in col.field_mut(0) {
442                *x *= 2.0;
443            }
444        });
445        assert_eq!(world.clone_component::<Velocity>(e0).unwrap().x, 2.0);
446        assert_eq!(world.clone_component::<Velocity>(e1).unwrap().x, 8.0);
447
448        // By-value write.
449        assert!(world.set_component(
450            e0,
451            Velocity {
452                x: 9.0,
453                y: 9.0,
454                z: 9.0
455            }
456        ));
457        assert_eq!(
458            world.clone_component::<Velocity>(e0),
459            Some(Velocity {
460                x: 9.0,
461                y: 9.0,
462                z: 9.0
463            })
464        );
465
466        // Despawn one; the other's field arrays stay aligned and correct.
467        world.despawn(e0);
468        assert_eq!(
469            world.clone_component::<Velocity>(e1),
470            Some(Velocity {
471                x: 8.0,
472                y: 5.0,
473                z: 6.0
474            })
475        );
476        assert_eq!(world.query::<Soa<Velocity>>().count(), 1);
477    }
478}