1use crate::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
20pub 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 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
93pub 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#[derive(Clone, Copy)]
144pub struct DueSpawn {
145 pub template: AssetId,
147 pub transform: Transform,
149 pub lifetime: Option<f32>,
151}
152
153pub fn tick_spawners<'a>(ctx: &mut PipelineContext<'a>, dt: f32) -> FrameVec<'a, DueSpawn> {
162 let frame = ctx.frame;
163 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 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
203pub 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 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 #[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 #[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 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 let mut alloc = Slots::with_len(1);
388
389 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 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 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 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 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 let expired = tick_lifetimes(ctx, 1.0);
467 assert_eq!(&*expired, &[first]);
468 pool.release(first_slot);
469 ctx.despawn(first);
470
471 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 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 assert!(tick_spawners(ctx, 0.5).is_empty());
559 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 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 assert_eq!(ctx.get::<Lifetime>(long).unwrap().remaining, 4.8);
603 });
604 }
605}