Skip to main content

appcore_render/
scene.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: scene.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/14 16:59:22 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/15 10:24:40 by dnettoRaw
8//      ###########      S: 0.1.0-alpha.1
9// =============================================================================
10
11use crate::{
12    scene_storage::{reserved, Archetype, Location, Slot, SnapshotPermit},
13    EntityId, MovingColumns, RenderBinding, RenderError, SceneInstance, SceneSnapshot, Transform,
14    Velocity,
15};
16use std::sync::{
17    atomic::{AtomicU32, AtomicU64, Ordering},
18    Arc,
19};
20static NEXT_WORLD: AtomicU64 = AtomicU64::new(1);
21/// CPU ownership budgets. The byte ceiling accounts columns, slots, free indices, snapshots and step scratch,
22/// excluding allocator overhead and independently owned mesh/material assets.
23#[derive(Debug, Clone, Copy)]
24pub struct SceneLimits {
25    /// Maximum live/retired identity slots and capacity of each maintained archetype.
26    pub max_entities: u32,
27    /// Maximum instances copied into one frame snapshot.
28    pub max_snapshot_instances: u32,
29    /// Maximum simultaneously owned snapshots, in 1..8.
30    pub max_snapshots: u32,
31    /// Accounted CPU reservation ceiling, at most 512 MiB.
32    pub managed_cpu_bytes: u64,
33}
34impl Default for SceneLimits {
35    fn default() -> Self {
36        Self {
37            max_entities: 8192,
38            max_snapshot_instances: 8192,
39            max_snapshots: 2,
40            managed_cpu_bytes: 32 * 1024 * 1024,
41        }
42    }
43}
44impl SceneLimits {
45    /// Validate capacity and compute the conservative reservation before allocating columns.
46    pub fn reservation_bytes(self) -> Result<u64, RenderError> {
47        if self.max_entities == 0
48            || self.max_entities > 65536
49            || self.max_snapshot_instances == 0
50            || self.max_snapshot_instances > self.max_entities
51            || !(1..=8).contains(&self.max_snapshots)
52            || self.managed_cpu_bytes > 512 * 1024 * 1024
53        {
54            return Err(RenderError::InvalidLimits);
55        }
56        let row = std::mem::size_of::<EntityId>()
57            + std::mem::size_of::<Transform>()
58            + std::mem::size_of::<RenderBinding>();
59        let per_entity = 2 * row
60            + std::mem::size_of::<Velocity>()
61            + std::mem::size_of::<Slot>()
62            + std::mem::size_of::<u32>()
63            + std::mem::size_of::<Transform>();
64        let bytes = u64::from(self.max_entities) * per_entity as u64
65            + u64::from(self.max_snapshots)
66                * u64::from(self.max_snapshot_instances)
67                * std::mem::size_of::<SceneInstance>() as u64;
68        if bytes > self.managed_cpu_bytes {
69            return Err(RenderError::BudgetExceeded);
70        }
71        Ok(bytes)
72    }
73}
74/// Native two-archetype ECS: render-only and render+velocity. No external ECS or locks.
75/// IDs index a location table; row compaction always repairs the moved entity's location.
76pub struct SceneWorld {
77    owner: u64,
78    limits: SceneLimits,
79    slots: Vec<Slot>,
80    free_slots: Vec<u32>,
81    archetypes: [Archetype; 2],
82    live: usize,
83    snapshots: Arc<AtomicU32>,
84}
85impl SceneWorld {
86    /// Reserve bounded columns after validating the CPU budget.
87    pub fn new(limits: SceneLimits) -> Result<Self, RenderError> {
88        limits.reservation_bytes()?;
89        let owner = NEXT_WORLD
90            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_add(1))
91            .map_err(|_| RenderError::IdentityExhausted)?;
92        let capacity = limits.max_entities as usize;
93        Ok(Self {
94            owner,
95            limits,
96            slots: reserved(capacity)?,
97            free_slots: reserved(capacity)?,
98            archetypes: [
99                Archetype::new(capacity, false)?,
100                Archetype::new(capacity, true)?,
101            ],
102            live: 0,
103            snapshots: Arc::new(AtomicU32::new(0)),
104        })
105    }
106    /// Live entities, bounded by the identity slot capacity.
107    pub fn len(&self) -> usize {
108        self.live
109    }
110    /// Whether no entities remain.
111    pub fn is_empty(&self) -> bool {
112        self.live == 0
113    }
114    /// Spawn a validated instance in the matching archetype. Failure makes no structural change.
115    /// Slot admission is constant-time through a bounded free list; reuse order is unspecified.
116    pub fn spawn(
117        &mut self,
118        transform: Transform,
119        binding: RenderBinding,
120        velocity: Option<Velocity>,
121    ) -> Result<EntityId, RenderError> {
122        let binding = binding.validate()?;
123        let index = if let Some(index) = self.free_slots.pop() {
124            index as usize
125        } else {
126            if self.slots.len() >= self.limits.max_entities as usize {
127                return Err(RenderError::BudgetExceeded);
128            }
129            self.slots.push(Slot {
130                generation: 0,
131                location: None,
132            });
133            self.slots.len() - 1
134        };
135        let slot = &mut self.slots[index];
136        slot.generation += 1;
137        let id = EntityId {
138            owner: self.owner,
139            index: index as u32,
140            generation: slot.generation,
141        };
142        let archetype = usize::from(velocity.is_some());
143        let row = self.archetypes[archetype].push(id, transform, binding, velocity);
144        self.slots[index].location = Some(Location { archetype, row });
145        self.live += 1;
146        Ok(id)
147    }
148    fn locate(&self, id: EntityId) -> Result<Location, RenderError> {
149        if id.owner != self.owner {
150            return Err(RenderError::InvalidResource);
151        }
152        self.slots
153            .get(id.index as usize)
154            .filter(|s| s.generation == id.generation)
155            .and_then(|s| s.location)
156            .ok_or(RenderError::InvalidResource)
157    }
158    fn remove_row(&mut self, location: Location) -> (Transform, RenderBinding, Option<Velocity>) {
159        let table = &mut self.archetypes[location.archetype];
160        let values = table.remove(location.row);
161        if let Some(moved) = table.entities.get(location.row) {
162            self.slots[moved.index as usize].location = Some(location);
163        }
164        values
165    }
166    /// Remove without invalidating other identities; stale handles never alias a later spawn.
167    pub fn despawn(&mut self, id: EntityId) -> Result<(), RenderError> {
168        let location = self.locate(id)?;
169        self.remove_row(location);
170        let slot = &mut self.slots[id.index as usize];
171        slot.location = None;
172        if slot.generation < u64::MAX {
173            self.free_slots.push(id.index);
174        }
175        self.live -= 1;
176        Ok(())
177    }
178    /// Read the current validated transform by stable identity.
179    pub fn transform(&self, id: EntityId) -> Result<Transform, RenderError> {
180        let l = self.locate(id)?;
181        Ok(self.archetypes[l.archetype].transforms[l.row])
182    }
183    /// Replace a validated transform without moving its row or binding.
184    pub fn set_transform(&mut self, id: EntityId, value: Transform) -> Result<(), RenderError> {
185        let l = self.locate(id)?;
186        self.archetypes[l.archetype].transforms[l.row] = value;
187        Ok(())
188    }
189    /// Add/remove velocity through explicit archetype migration, preserving entity identity.
190    pub fn set_velocity(
191        &mut self,
192        id: EntityId,
193        velocity: Option<Velocity>,
194    ) -> Result<(), RenderError> {
195        let l = self.locate(id)?;
196        let target = usize::from(velocity.is_some());
197        if l.archetype == target {
198            if let (Some(column), Some(value)) = (&mut self.archetypes[target].velocities, velocity)
199            {
200                column[l.row] = value;
201            }
202            return Ok(());
203        }
204        let (transform, binding, _) = self.remove_row(l);
205        let row = self.archetypes[target].push(id, transform, binding, velocity);
206        self.slots[id.index as usize].location = Some(Location {
207            archetype: target,
208            row,
209        });
210        Ok(())
211    }
212    /// Borrow disjoint aligned transform/velocity columns. Structural mutation requires releasing them.
213    pub fn moving_columns(&mut self) -> MovingColumns<'_> {
214        let table = &mut self.archetypes[1];
215        MovingColumns {
216            entities: &table.entities,
217            transforms: &mut table.transforms,
218            velocities: table
219                .velocities
220                .as_mut()
221                .map_or(&mut [], |v| v.as_mut_slice()),
222        }
223    }
224    /// Integrate local velocity with a bounded timestep; validate every candidate before committing any.
225    pub fn step_velocity(&mut self, dt: f32) -> Result<(), RenderError> {
226        if !dt.is_finite() || !(0.0..=0.05).contains(&dt) {
227            return Err(RenderError::InvalidLimits);
228        }
229        let table = &mut self.archetypes[1];
230        let velocities = table
231            .velocities
232            .as_ref()
233            .ok_or(RenderError::InvalidLimits)?;
234        let mut next = reserved(table.transforms.len())?;
235        for (transform, velocity) in table.transforms.iter().zip(velocities) {
236            next.push(transform.with_position(std::array::from_fn(|i| {
237                transform.position()[i] + velocity.value()[i] * dt
238            }))?);
239        }
240        table.transforms.copy_from_slice(&next);
241        Ok(())
242    }
243    /// Extract owned instances; rejects excess before allocating the snapshot.
244    /// A renderer must resolve asset identities before uploading or submitting a draw.
245    pub fn snapshot(&self) -> Result<SceneSnapshot, RenderError> {
246        if self.live > self.limits.max_snapshot_instances as usize {
247            return Err(RenderError::BudgetExceeded);
248        }
249        self.snapshots
250            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
251                (n < self.limits.max_snapshots).then_some(n + 1)
252            })
253            .map_err(|_| RenderError::SnapshotsPending)?;
254        let permit = SnapshotPermit(self.snapshots.clone());
255        let mut instances = reserved(self.live)?;
256        for table in &self.archetypes {
257            for i in 0..table.entities.len() {
258                instances.push(SceneInstance {
259                    entity: table.entities[i],
260                    transform: table.transforms[i],
261                    binding: table.bindings[i],
262                });
263            }
264        }
265        Ok(SceneSnapshot {
266            _permit: permit,
267            instances,
268        })
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    #[test]
276    fn exhausted_generation_is_retired_without_reentering_the_free_list() -> Result<(), RenderError>
277    {
278        let limits = SceneLimits {
279            max_entities: 1,
280            max_snapshot_instances: 1,
281            ..Default::default()
282        };
283        let mut world = SceneWorld::new(limits)?;
284        let mut resources = crate::ResourceArena::new(crate::RenderLimits::default())?;
285        let binding = RenderBinding {
286            mesh: resources.insert((), 1)?,
287            material: resources.insert((), 1)?,
288            tint: [1.; 4],
289            animation: 0,
290            morph: 0,
291        };
292        let initial = world.spawn(Transform::default(), binding, None)?;
293        let exhausted = EntityId {
294            generation: u64::MAX,
295            ..initial
296        };
297        world.slots[0].generation = u64::MAX;
298        world.archetypes[0].entities[0] = exhausted;
299        world.despawn(exhausted)?;
300        assert!(world.free_slots.is_empty());
301        assert_eq!(
302            world.spawn(Transform::default(), binding, None),
303            Err(RenderError::BudgetExceeded)
304        );
305        assert_eq!(world.transform(initial), Err(RenderError::InvalidResource));
306        assert_eq!(
307            world.transform(exhausted),
308            Err(RenderError::InvalidResource)
309        );
310        Ok(())
311    }
312}