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}