Skip to main content

concinnity_core/spawn/
template.rs

1//! Runtime entity spawn: instantiate a copy of an existing placement at a new
2//! transform and give it the components and (optionally) the Lifetime the copy
3//! needs to live in the world.
4//!
5//! What a copy's draw slot is comes from a host, through the `clone_slot` /
6//! `acquire_slot` seams: a slot index is a renderer's to allocate, and it is
7//! the one thing here that is. Everything else -- which components a copy
8//! carries, when a Spawner is due, when a Lifetime is up -- is the world's, so
9//! it lives with the world.
10
11use concinnity_memory::InlineVec;
12
13use crate::components::{
14    BodyDynamics, Collider, GlobalTransform, Lifetime, MeshRenderer, ModelRenderer, Pickup,
15    PropInstance, RenderHandle, SkeletonPose, Spawner, Transform,
16};
17use crate::ecs::asset_id::AssetId;
18use crate::ecs::{Entity, EntityByName, FrameVec, PipelineContext};
19
20/// Instantiate a runtime copy of `template`'s renderable: clone each of its
21/// backend draw slots at `transform` through `clone_slot`, then build a new
22/// entity carrying the cloned slots, a copy of the template's renderer, the
23/// placement, and an optional Lifetime.
24///
25/// `clone_slot(src_draw_idx, model)` returns the new backend slot index (a
26/// vacated slot reused, or a freshly appended one); it is the seam a host wires
27/// to its own allocator. When `name` is Some the new entity is registered under
28/// it so it can later be addressed by name like an authored placement;
29/// transient spawns (a Spawner's churn) pass None to avoid interning a name per
30/// spawn. Returns the new entity, or None when the template has no draw slots to
31/// copy or a clone fails.
32pub fn spawn_from_template(
33    ctx: &mut PipelineContext,
34    template: Entity,
35    name: Option<AssetId>,
36    transform: Transform,
37    lifetime: Option<f32>,
38    mut clone_slot: impl FnMut(usize, [[f32; 4]; 4]) -> Option<usize>,
39) -> Option<Entity> {
40    let src_slots: InlineVec<u32> = ctx.get::<RenderHandle>(template).map(|h| h.draws.clone())?;
41    if src_slots.is_empty() {
42        return None;
43    }
44    let model = transform.model_matrix();
45    let mut draws = InlineVec::new();
46    for src in src_slots {
47        let new_slot = clone_slot(src as usize, model)?;
48        draws.push(new_slot as u32);
49    }
50
51    // Copy whichever renderer the template carries so the new entity is a
52    // first-class renderable for every system that joins on it, plus its
53    // physics components so the physics system builds it a body.
54    let mesh_renderer = ctx.get::<MeshRenderer>(template).cloned();
55    let model_renderer = ctx.get::<ModelRenderer>(template).cloned();
56    let collider = ctx.get::<Collider>(template).cloned();
57    let body_dynamics = ctx.get::<BodyDynamics>(template).copied();
58    let pickup = ctx.get::<Pickup>(template).is_some();
59    let prop_instance = ctx.get::<PropInstance>(template).is_some();
60
61    let entity = ctx.components.spawn();
62    ctx.insert(entity, transform);
63    ctx.insert(entity, GlobalTransform(model));
64    ctx.insert(entity, RenderHandle { draws });
65    if let Some(renderer) = mesh_renderer {
66        ctx.insert(entity, renderer);
67    } else if let Some(renderer) = model_renderer {
68        ctx.insert(entity, renderer);
69    }
70    if let Some(collider) = collider {
71        ctx.insert(entity, collider);
72    }
73    if let Some(body_dynamics) = body_dynamics {
74        ctx.insert(entity, body_dynamics);
75    }
76    if pickup {
77        ctx.insert(entity, Pickup);
78    }
79    if prop_instance {
80        ctx.insert(entity, PropInstance);
81    }
82    if let Some(secs) = lifetime {
83        ctx.insert(entity, Lifetime { remaining: secs });
84    }
85    if let Some(name) = name
86        && let Some(by_name) = ctx.resource_mut::<EntityByName>()
87    {
88        by_name.0.insert(name, entity);
89    }
90    Some(entity)
91}
92
93/// Instantiate a runtime copy of a skinned `template` (a SkinnedMesh's
94/// SkeletonPose entity) at `transform`.
95///
96/// Unlike the static path, a skinned instance is not a cloned draw slot: it
97/// claims one of the template's pre-reserved hidden bind-pose copies through
98/// `acquire_slot`, which reveals it and returns its skinned index. The new
99/// entity carries its own SkeletonPose (so an animation system drives it, keyed
100/// on the shared mesh id, in lockstep with the template), a Transform (so the
101/// per-frame model push can move it), and an optional Lifetime. When `name` is
102/// Some the instance is registered so it can be addressed (e.g. despawned) by
103/// name. Returns the new entity, or None when the template is not skinned or
104/// its instance pool is exhausted.
105pub fn spawn_skinned_from_template(
106    ctx: &mut PipelineContext,
107    template: Entity,
108    name: Option<AssetId>,
109    transform: Transform,
110    lifetime: Option<f32>,
111    mut acquire_slot: impl FnMut(usize, [[f32; 4]; 4]) -> Option<usize>,
112) -> Option<Entity> {
113    let template_pose = ctx.get::<SkeletonPose>(template)?;
114    let model = transform.model_matrix();
115    let skinned_index = acquire_slot(template_pose.skinned_index, model)?;
116    let pose = template_pose.clone_for_slot(skinned_index);
117
118    let prop_instance = ctx.get::<PropInstance>(template).is_some();
119
120    let entity = ctx.components.spawn();
121    ctx.insert(entity, transform);
122    ctx.insert(entity, pose);
123    if prop_instance {
124        ctx.insert(entity, PropInstance);
125    }
126    if let Some(secs) = lifetime {
127        ctx.insert(entity, Lifetime { remaining: secs });
128    }
129    if let Some(name) = name
130        && let Some(by_name) = ctx.resource_mut::<EntityByName>()
131    {
132        by_name.0.insert(name, entity);
133    }
134    Some(entity)
135}
136
137/// One spawn a Spawner is due to emit this step: the template to copy, where to
138/// place it, and how long the copy should live.
139///
140/// Returned by [`tick_spawners`] for the caller to route through
141/// [`spawn_from_template`] with the live backend, the same way
142/// [`tick_lifetimes`] returns expiries for the caller to despawn.
143#[derive(Clone, Copy)]
144pub struct DueSpawn {
145    /// The placement to copy.
146    pub template: AssetId,
147    /// Where the copy is placed.
148    pub transform: Transform,
149    /// Seconds the copy lives for, or `None` when it is not auto-removed.
150    pub lifetime: Option<f32>,
151}
152
153/// Advance every Spawner's clock by `dt` and return the spawns now due, in
154/// frame scratch.
155///
156/// A spawner emits one copy per whole `interval` elapsed (so a long frame that
157/// crosses several intervals catches up), at the spawner entity's own
158/// Transform. A non-positive interval is inert (never spawns). A zero
159/// `lifetime` means the copy is not auto-removed; otherwise it carries that
160/// countdown.
161pub fn tick_spawners<'a>(ctx: &mut PipelineContext<'a>, dt: f32) -> FrameVec<'a, DueSpawn> {
162    let frame = ctx.frame;
163    // Advance every spawner's clock in place, recording only the ones that
164    // crossed at least one interval this step (template, lifetime, and how many
165    // copies are due). Every spawner could fire, so the reservation is exact.
166    let mut fired = frame.vec::<(Entity, AssetId, f32, u32)>(ctx.query::<Spawner>().len());
167    for (entity, spawner) in ctx.query_mut_with_entity::<Spawner>() {
168        if spawner.interval <= 0.0 {
169            continue;
170        }
171        spawner.elapsed += dt;
172        let mut count = 0;
173        while spawner.elapsed >= spawner.interval {
174            spawner.elapsed -= spawner.interval;
175            spawner.count += 1;
176            count += 1;
177        }
178        if count > 0 {
179            fired.push((entity, spawner.template, spawner.lifetime, count));
180        }
181    }
182    // Resolve each fired spawner's placement (its Transform) now that the mutable
183    // Spawner borrow is released, and expand to one DueSpawn per copy.
184    let mut due = frame.vec::<DueSpawn>(
185        fired
186            .iter()
187            .map(|&(.., count)| count as usize)
188            .sum::<usize>(),
189    );
190    for &(entity, template, lifetime, count) in fired.iter() {
191        let transform = ctx.get::<Transform>(entity).copied().unwrap_or_default();
192        for _ in 0..count {
193            due.push(DueSpawn {
194                template,
195                transform,
196                lifetime: (lifetime > 0.0).then_some(lifetime),
197            });
198        }
199    }
200    due
201}
202
203/// Decrement every Lifetime by `dt` and return the entities whose countdown
204/// reached zero this step, in frame scratch, for the caller to despawn.
205///
206/// Entities still alive keep their decremented remaining. Returning the expired
207/// list (rather than despawning inline, which would mutate storage
208/// mid-iteration) lets the caller route each expiry through the same despawn
209/// cascade a DespawnRequest uses.
210pub fn tick_lifetimes<'a>(ctx: &mut PipelineContext<'a>, dt: f32) -> FrameVec<'a, Entity> {
211    let mut expired = ctx.frame.vec::<Entity>(ctx.query::<Lifetime>().len());
212    for (entity, life) in ctx.query_mut_with_entity::<Lifetime>() {
213        life.remaining -= dt;
214        if life.remaining <= 0.0 {
215            expired.push(entity);
216        }
217    }
218    expired
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use alloc::collections::BTreeMap;
225    use alloc::vec;
226    use alloc::vec::Vec;
227
228    use crate::ecs::{
229        Arena, ComponentStorage, FrameContext, NoPayloads, Resources, SkinnedMeshHandle,
230    };
231    use crate::gfx::profile::FrameProfile;
232    use crate::gfx::skeleton::Skeleton;
233
234    // Build an isolated PipelineContext over fresh storage, mirroring the
235    // despawn tests, so the spawn/despawn loop can run without a backend.
236    fn run<R>(body: impl FnOnce(&mut PipelineContext) -> R) -> R {
237        let mut components = ComponentStorage::default();
238        let mut blob = NoPayloads;
239        let mut profile = FrameProfile::default();
240        let mut resources = Resources::new();
241        let scratch = Arena::with_capacity(64 * 1024);
242        let mut ctx = PipelineContext {
243            components: &mut components,
244            blob: &mut blob,
245            profile: &mut profile,
246            resources: &mut resources,
247            frame: FrameContext::new(&scratch),
248        };
249        body(&mut ctx)
250    }
251
252    // A `clone_slot` seam standing in for a renderer's draw-slot allocator: it
253    // pops a vacated slot before growing, which is the property the recycle
254    // tests are about.
255    #[derive(Default)]
256    struct Slots {
257        free: Vec<usize>,
258        len: usize,
259    }
260
261    impl Slots {
262        fn with_len(len: usize) -> Slots {
263            Slots {
264                free: Vec::new(),
265                len,
266            }
267        }
268
269        fn allocate(&mut self) -> usize {
270            match self.free.pop() {
271                Some(slot) => slot,
272                None => {
273                    self.len += 1;
274                    self.len - 1
275                }
276            }
277        }
278
279        fn free(&mut self, slot: usize) {
280            self.free.push(slot);
281        }
282    }
283
284    // The `acquire_slot` seam's stand-in: the pre-reserved hidden copies a
285    // skinned template owns, and which of them are currently free.
286    #[derive(Default)]
287    struct Pool {
288        free: BTreeMap<usize, Vec<usize>>,
289        owner: BTreeMap<usize, usize>,
290    }
291
292    impl Pool {
293        fn reserve(&mut self, template: usize, instance: usize) {
294            self.owner.insert(instance, template);
295            self.free.entry(template).or_default().push(instance);
296        }
297
298        fn acquire(&mut self, template: usize) -> Option<usize> {
299            self.free.get_mut(&template).and_then(|slots| slots.pop())
300        }
301
302        fn release(&mut self, instance: usize) {
303            if let Some(&template) = self.owner.get(&instance) {
304                self.free.entry(template).or_default().push(instance);
305            }
306        }
307    }
308
309    #[test]
310    fn spawned_copy_carries_the_template_physics_components() {
311        run(|ctx| {
312            ctx.insert_resource(EntityByName::default());
313
314            let template = ctx.components.spawn();
315            ctx.insert(template, Transform::default());
316            ctx.insert(
317                template,
318                MeshRenderer {
319                    mesh: None,
320                    material: None,
321                    texture: None,
322                    cull_distance: 0.0,
323                },
324            );
325            ctx.insert(template, RenderHandle { draws: [0].into() });
326            ctx.insert(
327                template,
328                Collider(crate::components::PropCollider {
329                    radius: 0.4,
330                    ..Default::default()
331                }),
332            );
333            ctx.insert(
334                template,
335                BodyDynamics {
336                    mass: 2.5,
337                    ..Default::default()
338                },
339            );
340            ctx.insert(template, Pickup);
341
342            let mut alloc = Slots::with_len(1);
343            let spawned = spawn_from_template(
344                ctx,
345                template,
346                None,
347                Transform::default(),
348                None,
349                |_src, _model| Some(alloc.allocate()),
350            )
351            .expect("spawn");
352
353            assert_eq!(
354                ctx.get::<Collider>(spawned).map(|c| c.0.radius),
355                Some(0.4),
356                "the collider is copied"
357            );
358            assert_eq!(
359                ctx.get::<BodyDynamics>(spawned).map(|b| b.mass),
360                Some(2.5),
361                "the dynamic-body parameters are copied"
362            );
363            assert!(ctx.get::<Pickup>(spawned).is_some(), "the tag is copied");
364        });
365    }
366
367    #[test]
368    fn freed_draw_slot_is_reused_by_the_next_spawn() {
369        run(|ctx| {
370            ctx.insert_resource(EntityByName::default());
371
372            // A template placement occupying draw slot 0.
373            let template = ctx.components.spawn();
374            ctx.insert(template, Transform::default());
375            ctx.insert(
376                template,
377                MeshRenderer {
378                    mesh: None,
379                    material: None,
380                    texture: None,
381                    cull_distance: 0.0,
382                },
383            );
384            ctx.insert(template, RenderHandle { draws: [0].into() });
385
386            // The backend starts with one live slot (the template's).
387            let mut alloc = Slots::with_len(1);
388
389            // First spawn appends a fresh slot past the template's.
390            let first = spawn_from_template(
391                ctx,
392                template,
393                Some(AssetId(1)),
394                Transform::default(),
395                Some(0.5),
396                |_src, _model| Some(alloc.allocate()),
397            )
398            .expect("first spawn");
399            let first_slot = ctx.get::<RenderHandle>(first).unwrap().draws.clone();
400            assert_eq!(first_slot, vec![1], "first spawn appended slot 1");
401
402            // Its Lifetime expires; the expiry frees the slot like a despawn's
403            // retire -> free does, then despawns the entity.
404            let expired = tick_lifetimes(ctx, 1.0);
405            assert_eq!(&*expired, &[first], "the short-lived spawn expired");
406            let freed: Vec<u32> = ctx.get::<RenderHandle>(first).unwrap().draws.to_vec();
407            for slot in &freed {
408                alloc.free(*slot as usize);
409            }
410            ctx.despawn(first);
411            assert!(ctx.get::<RenderHandle>(first).is_none(), "first despawned");
412
413            // The next spawn reuses the freed slot instead of growing the vec.
414            let second = spawn_from_template(
415                ctx,
416                template,
417                Some(AssetId(2)),
418                Transform::default(),
419                None,
420                |_src, _model| Some(alloc.allocate()),
421            )
422            .expect("second spawn");
423            let second_slot = ctx.get::<RenderHandle>(second).unwrap().draws.clone();
424            assert_eq!(
425                second_slot, freed,
426                "the freed draw slot must be recycled by the next spawn"
427            );
428        });
429    }
430
431    #[test]
432    fn skinned_spawn_claims_and_recycles_a_pooled_slot() {
433        run(|ctx| {
434            ctx.insert_resource(EntityByName::default());
435
436            // A skinned template at draw slot 0 with two pre-reserved hidden
437            // copies (slots 1 and 2) in the pool.
438            let template = ctx.components.spawn();
439            ctx.insert(
440                template,
441                SkeletonPose::new(SkinnedMeshHandle(10), 0, Skeleton::new(Vec::new())),
442            );
443            let mut pool = Pool::default();
444            pool.reserve(0, 1);
445            pool.reserve(0, 2);
446
447            // The spawn claims a pooled copy and the new entity points at it.
448            let first = spawn_skinned_from_template(
449                ctx,
450                template,
451                Some(AssetId(11)),
452                Transform::default(),
453                Some(0.5),
454                |template_idx, _model| pool.acquire(template_idx),
455            )
456            .expect("first skinned spawn");
457            let first_slot = ctx.get::<SkeletonPose>(first).unwrap().skinned_index;
458            assert_eq!(
459                ctx.get::<SkeletonPose>(first).unwrap().mesh_id,
460                SkinnedMeshHandle(10),
461                "the instance shares the template's mesh id so it animates with it"
462            );
463
464            // Its Lifetime expires; the expiry releases the slot to the pool like
465            // a despawn's retire does, then despawns the entity.
466            let expired = tick_lifetimes(ctx, 1.0);
467            assert_eq!(&*expired, &[first]);
468            pool.release(first_slot);
469            ctx.despawn(first);
470
471            // The next spawn recycles the freed slot instead of a fresh one.
472            let second = spawn_skinned_from_template(
473                ctx,
474                template,
475                None,
476                Transform::default(),
477                None,
478                |template_idx, _model| pool.acquire(template_idx),
479            )
480            .expect("second skinned spawn");
481            assert_eq!(
482                ctx.get::<SkeletonPose>(second).unwrap().skinned_index,
483                first_slot,
484                "the freed skinned slot must be recycled by the next spawn"
485            );
486        });
487    }
488
489    #[test]
490    fn skinned_spawn_with_exhausted_pool_returns_none() {
491        run(|ctx| {
492            let template = ctx.components.spawn();
493            ctx.insert(
494                template,
495                SkeletonPose::new(SkinnedMeshHandle(10), 0, Skeleton::new(Vec::new())),
496            );
497            // A template that reserved no instances has nothing to claim.
498            let mut pool = Pool::default();
499            let spawned = spawn_skinned_from_template(
500                ctx,
501                template,
502                None,
503                Transform::default(),
504                None,
505                |template_idx, _model| pool.acquire(template_idx),
506            );
507            assert!(spawned.is_none(), "an exhausted pool drops the spawn");
508        });
509    }
510
511    #[test]
512    fn spawn_registers_the_instance_by_name() {
513        run(|ctx| {
514            ctx.insert_resource(EntityByName::default());
515            let template = ctx.components.spawn();
516            ctx.insert(template, Transform::default());
517            ctx.insert(template, RenderHandle { draws: [0].into() });
518            let mut alloc = Slots::with_len(1);
519
520            let spawned = spawn_from_template(
521                ctx,
522                template,
523                Some(AssetId(42)),
524                Transform::default(),
525                None,
526                |_src, _model| Some(alloc.allocate()),
527            )
528            .expect("spawn");
529
530            let by_name = ctx.resource::<EntityByName>().unwrap();
531            assert_eq!(by_name.get(AssetId(42)), Some(spawned));
532        });
533    }
534
535    #[test]
536    fn spawner_emits_one_copy_per_interval_elapsed() {
537        run(|ctx| {
538            let spawner = ctx.components.spawn();
539            ctx.insert(
540                spawner,
541                Transform {
542                    position: [1.0, 2.0, 3.0],
543                    ..Transform::default()
544                },
545            );
546            ctx.insert(
547                spawner,
548                Spawner {
549                    template: AssetId(7),
550                    interval: 1.0,
551                    lifetime: 2.0,
552                    elapsed: 0.0,
553                    count: 0,
554                },
555            );
556
557            // Below the interval: nothing due, but the clock advances.
558            assert!(tick_spawners(ctx, 0.5).is_empty());
559            // Crossing the interval emits one, carrying the lifetime + template
560            // and the spawner's own position.
561            let due = tick_spawners(ctx, 0.6);
562            assert_eq!(due.len(), 1);
563            assert_eq!(due[0].template, AssetId(7));
564            assert_eq!(due[0].lifetime, Some(2.0));
565            assert_eq!(due[0].transform.position, [1.0, 2.0, 3.0]);
566            // A long frame crossing several intervals catches up.
567            assert_eq!(tick_spawners(ctx, 2.5).len(), 2);
568            assert_eq!(ctx.get::<Spawner>(spawner).unwrap().count, 3);
569        });
570    }
571
572    #[test]
573    fn spawner_with_nonpositive_interval_is_inert() {
574        run(|ctx| {
575            let spawner = ctx.components.spawn();
576            ctx.insert(spawner, Transform::default());
577            ctx.insert(
578                spawner,
579                Spawner {
580                    template: AssetId(7),
581                    interval: 0.0,
582                    lifetime: 0.0,
583                    elapsed: 0.0,
584                    count: 0,
585                },
586            );
587            assert!(tick_spawners(ctx, 100.0).is_empty());
588        });
589    }
590
591    #[test]
592    fn tick_only_expires_elapsed_lifetimes() {
593        run(|ctx| {
594            let short = ctx.components.spawn();
595            ctx.insert(short, Lifetime { remaining: 0.1 });
596            let long = ctx.components.spawn();
597            ctx.insert(long, Lifetime { remaining: 5.0 });
598
599            let expired = tick_lifetimes(ctx, 0.2);
600            assert_eq!(&*expired, &[short], "only the short lifetime expired");
601            // The survivor's clock advanced but it is still alive.
602            assert_eq!(ctx.get::<Lifetime>(long).unwrap().remaining, 4.8);
603        });
604    }
605}