Skip to main content

galeon_engine/
render_event.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3//! One-shot event bridge for audio/VFX triggers across the render boundary.
4//!
5//! Games implement [`RenderEvent`] on their ECS event types to declare how
6//! each event serialises into a [`FrameEvent`]. They then register those
7//! event types with [`RenderEventRegistry`]. The extraction pass drains all
8//! readable events (from the previous tick) and appends them to the
9//! `FramePacket::events` buffer.
10//!
11//! # Example
12//!
13//! ```rust
14//! # use galeon_engine::render_event::{FrameEvent, RenderEvent};
15//! struct ImpactEvent {
16//!     entity_index: u32,
17//!     position: [f32; 3],
18//!     force: f32,
19//! }
20//!
21//! impl RenderEvent for ImpactEvent {
22//!     const KIND: u32 = 1;
23//!     fn entity(&self) -> u32 { self.entity_index }
24//!     fn position(&self) -> [f32; 3] { self.position }
25//!     fn intensity(&self) -> f32 { self.force }
26//!     // data() defaults to [0.0; 4] — override for extra payload
27//! }
28//! ```
29
30use std::cell::RefCell;
31use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
32
33use crate::event::Events;
34use crate::world::World;
35
36// =============================================================================
37// FrameEvent — fixed-schema one-shot event for audio/VFX triggers
38// =============================================================================
39
40/// A single one-shot event extracted from the ECS for TS consumption.
41///
42/// Fixed schema optimised for audio/VFX triggers: event type, source entity,
43/// world-space position (for spatial audio), intensity (for volume/scale),
44/// and a 4-float payload for arbitrary extra data (color, direction, variant).
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct FrameEvent {
47    /// Stable event-type identifier. Each distinct event type returns a unique
48    /// constant via [`RenderEvent::KIND`].
49    pub kind: u32,
50    /// Source entity index, or 0 if not entity-specific.
51    pub entity: u32,
52    /// World-space position for spatial audio. `[0.0, 0.0, 0.0]` if not spatial.
53    pub position: [f32; 3],
54    /// Intensity/magnitude for volume or scale. `1.0` is the default.
55    pub intensity: f32,
56    /// Extra payload for event-specific data (color, direction, variant ID, etc.).
57    /// `[0.0; 4]` when unused.
58    pub data: [f32; 4],
59}
60
61/// Number of f32-equivalent values per [`FrameEvent`] in the struct-of-arrays
62/// WASM transport (kinds + entities + positions + intensities + data).
63pub const FRAME_EVENT_STRIDE: usize = 10;
64
65// =============================================================================
66// RenderEvent trait
67// =============================================================================
68
69/// Implemented by ECS event types that should be extracted as [`FrameEvent`]s
70/// for the TS audio/VFX layer.
71///
72/// Each implementor must provide a unique [`KIND`](RenderEvent::KIND) constant
73/// and methods to extract entity, position, and intensity.
74pub trait RenderEvent: 'static + Send + Sync {
75    /// Unique event-type identifier. Must be stable across frames.
76    const KIND: u32;
77
78    /// Source entity index, or 0 if not entity-specific.
79    fn entity(&self) -> u32;
80
81    /// World-space position for spatial audio.
82    fn position(&self) -> [f32; 3];
83
84    /// Intensity/magnitude for volume scaling. Defaults to `1.0`.
85    fn intensity(&self) -> f32 {
86        1.0
87    }
88
89    /// Extra payload for event-specific data (e.g. color `[r,g,b,a]`,
90    /// direction `[dx,dy,dz,0]`, variant ID `[id,0,0,0]`).
91    /// Defaults to `[0.0; 4]`.
92    fn data(&self) -> [f32; 4] {
93        [0.0; 4]
94    }
95}
96
97// =============================================================================
98// RenderEventRegistry
99// =============================================================================
100
101/// Type-erased extraction closure that reads current-tick events from the world.
102type EventExtractFn = Box<dyn Fn(&World) -> Vec<FrameEvent> + Send + Sync>;
103
104/// Resource that holds all registered render-event extractors and an
105/// accumulation buffer that survives across multiple ticks per frame.
106///
107/// # Data flow
108///
109/// ```text
110/// Each tick:  Schedule::run() → systems write EventWriter<T>
111///                             → world.flush_render_events()
112///                               reads Events<T>::current, appends to `pending`
113///                             → update_events() swaps buffers (safe — we already captured)
114///
115/// Each frame: extract_frame() → registry.drain() → moves `pending` into FramePacket
116/// ```
117///
118/// This avoids the double-buffer latency AND prevents multi-tick event loss:
119/// every tick's events accumulate until the next extraction drains them.
120///
121/// Register event types at startup (inside a [`Plugin`](crate::engine::Plugin)):
122///
123/// ```rust,no_run
124/// # use galeon_engine::render_event::RenderEventRegistry;
125/// let mut registry = RenderEventRegistry::new();
126/// // registry.register::<ImpactEvent>();
127/// ```
128pub struct RenderEventRegistry {
129    extractors: Vec<EventExtractFn>,
130    /// Accumulation buffer: events captured across ticks via [`accumulate`],
131    /// drained once per frame by [`drain`]. Uses `RefCell` because drain is
132    /// called from `&World` (shared access) during extraction.
133    pending: RefCell<Vec<FrameEvent>>,
134}
135
136impl RenderEventRegistry {
137    /// Create an empty registry.
138    pub fn new() -> Self {
139        Self {
140            extractors: Vec::new(),
141            pending: RefCell::new(Vec::new()),
142        }
143    }
144
145    /// Register an ECS event type for render extraction.
146    ///
147    /// Each extractor tracks an offset into `Events<T>::current` so that
148    /// multiple flushes per tick (pre-swap for deadlines, post-swap for
149    /// systems) only capture newly added events. When `current` shrinks
150    /// (after `update_events` swaps and clears it), the offset resets.
151    pub fn register<T: RenderEvent>(&mut self) {
152        let offset = AtomicUsize::new(0);
153        let last_epoch = AtomicU64::new(0);
154        self.extractors.push(Box::new(move |world: &World| {
155            let Some(events) = world.try_resource::<Events<T>>() else {
156                return Vec::new();
157            };
158            // Detect buffer swap via epoch counter — reset offset.
159            let epoch = world.event_swap_epoch();
160            if last_epoch.load(Ordering::Relaxed) != epoch {
161                offset.store(0, Ordering::Relaxed);
162                last_epoch.store(epoch, Ordering::Relaxed);
163            }
164            let skip = offset.load(Ordering::Relaxed);
165            let result: Vec<FrameEvent> = events
166                .read_current()
167                .skip(skip)
168                .map(|e| FrameEvent {
169                    kind: T::KIND,
170                    entity: e.entity(),
171                    position: e.position(),
172                    intensity: e.intensity(),
173                    data: e.data(),
174                })
175                .collect();
176            offset.store(events.current_len(), Ordering::Relaxed);
177            result
178        }));
179    }
180
181    /// Capture this tick's events into the accumulation buffer.
182    ///
183    /// Called by [`World::flush_render_events`] at the end of each
184    /// `Schedule::run()`, **before** `update_events()` swaps the buffers
185    /// on the next tick. This ensures every tick's events are preserved
186    /// even when multiple ticks run per render frame (same effect as
187    /// Bevy's deferred buffer swap, without changing core event semantics).
188    pub fn accumulate(&self, world: &World) {
189        let mut pending = self.pending.borrow_mut();
190        for extractor in &self.extractors {
191            pending.extend(extractor(world));
192        }
193    }
194
195    /// Take all accumulated events since the last drain.
196    ///
197    /// Called by the extraction pass to move events into the `FramePacket`.
198    /// Uses `RefCell` interior mutability so extraction can drain from `&World`.
199    pub fn drain(&self) -> Vec<FrameEvent> {
200        std::mem::take(&mut *self.pending.borrow_mut())
201    }
202
203    /// Number of registered event types.
204    pub fn len(&self) -> usize {
205        self.extractors.len()
206    }
207
208    /// Returns `true` when no event types are registered.
209    pub fn is_empty(&self) -> bool {
210        self.extractors.is_empty()
211    }
212}
213
214impl Default for RenderEventRegistry {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220// =============================================================================
221// Tests
222// =============================================================================
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::world::World;
228
229    // -- Test event types ----------------------------------------------------
230
231    #[derive(Debug, PartialEq)]
232    struct ImpactEvent {
233        entity_index: u32,
234        pos: [f32; 3],
235        force: f32,
236    }
237
238    impl RenderEvent for ImpactEvent {
239        const KIND: u32 = 1;
240        fn entity(&self) -> u32 {
241            self.entity_index
242        }
243        fn position(&self) -> [f32; 3] {
244            self.pos
245        }
246        fn intensity(&self) -> f32 {
247            self.force
248        }
249    }
250
251    #[derive(Debug, PartialEq)]
252    struct ExplosionEvent {
253        pos: [f32; 3],
254        radius: f32,
255    }
256
257    impl RenderEvent for ExplosionEvent {
258        const KIND: u32 = 2;
259        fn entity(&self) -> u32 {
260            0
261        }
262        fn position(&self) -> [f32; 3] {
263            self.pos
264        }
265        fn intensity(&self) -> f32 {
266            self.radius
267        }
268    }
269
270    // -- Tests ---------------------------------------------------------------
271
272    #[test]
273    fn empty_registry() {
274        let registry = RenderEventRegistry::new();
275        assert!(registry.is_empty());
276        assert_eq!(registry.len(), 0);
277    }
278
279    #[test]
280    fn register_increments_len() {
281        let mut registry = RenderEventRegistry::new();
282        registry.register::<ImpactEvent>();
283        assert_eq!(registry.len(), 1);
284        registry.register::<ExplosionEvent>();
285        assert_eq!(registry.len(), 2);
286    }
287
288    // Helper: accumulate + drain simulates the schedule → extraction flow.
289    fn flush_and_drain(registry: &RenderEventRegistry, world: &World) -> Vec<FrameEvent> {
290        registry.accumulate(world);
291        registry.drain()
292    }
293
294    #[test]
295    fn drain_empty_when_no_events() {
296        let mut world = World::new();
297        world.add_event::<ImpactEvent>();
298
299        let mut registry = RenderEventRegistry::new();
300        registry.register::<ImpactEvent>();
301
302        let events = flush_and_drain(&registry, &world);
303        assert!(events.is_empty());
304    }
305
306    #[test]
307    fn drain_empty_when_events_resource_missing() {
308        let world = World::new();
309
310        let mut registry = RenderEventRegistry::new();
311        registry.register::<ImpactEvent>();
312
313        let events = flush_and_drain(&registry, &world);
314        assert!(events.is_empty());
315    }
316
317    #[test]
318    fn accumulate_captures_current_buffer() {
319        let mut world = World::new();
320        world.add_event::<ImpactEvent>();
321
322        world
323            .resource_mut::<Events<ImpactEvent>>()
324            .send(ImpactEvent {
325                entity_index: 42,
326                pos: [1.0, 2.0, 3.0],
327                force: 0.75,
328            });
329
330        let mut registry = RenderEventRegistry::new();
331        registry.register::<ImpactEvent>();
332
333        let events = flush_and_drain(&registry, &world);
334        assert_eq!(events.len(), 1);
335        assert_eq!(events[0].kind, 1);
336        assert_eq!(events[0].entity, 42);
337        assert_eq!(events[0].position, [1.0, 2.0, 3.0]);
338        assert!((events[0].intensity - 0.75).abs() < f32::EPSILON);
339        assert_eq!(events[0].data, [0.0; 4]);
340    }
341
342    #[test]
343    fn accumulate_multiple_events_same_type() {
344        let mut world = World::new();
345        world.add_event::<ImpactEvent>();
346
347        let events_res = world.resource_mut::<Events<ImpactEvent>>();
348        events_res.send(ImpactEvent {
349            entity_index: 1,
350            pos: [0.0; 3],
351            force: 1.0,
352        });
353        events_res.send(ImpactEvent {
354            entity_index: 2,
355            pos: [5.0; 3],
356            force: 0.5,
357        });
358
359        let mut registry = RenderEventRegistry::new();
360        registry.register::<ImpactEvent>();
361
362        let events = flush_and_drain(&registry, &world);
363        assert_eq!(events.len(), 2);
364        assert_eq!(events[0].entity, 1);
365        assert_eq!(events[1].entity, 2);
366    }
367
368    #[test]
369    fn accumulate_multiple_event_types() {
370        let mut world = World::new();
371        world.add_event::<ImpactEvent>();
372        world.add_event::<ExplosionEvent>();
373
374        world
375            .resource_mut::<Events<ImpactEvent>>()
376            .send(ImpactEvent {
377                entity_index: 10,
378                pos: [1.0, 0.0, 0.0],
379                force: 0.9,
380            });
381        world
382            .resource_mut::<Events<ExplosionEvent>>()
383            .send(ExplosionEvent {
384                pos: [5.0, 5.0, 5.0],
385                radius: 3.0,
386            });
387
388        let mut registry = RenderEventRegistry::new();
389        registry.register::<ImpactEvent>();
390        registry.register::<ExplosionEvent>();
391
392        let events = flush_and_drain(&registry, &world);
393        assert_eq!(events.len(), 2);
394
395        let impact = events.iter().find(|e| e.kind == 1).unwrap();
396        assert_eq!(impact.entity, 10);
397
398        let explosion = events.iter().find(|e| e.kind == 2).unwrap();
399        assert_eq!(explosion.entity, 0);
400        assert!((explosion.intensity - 3.0).abs() < f32::EPSILON);
401    }
402
403    #[test]
404    fn multi_tick_accumulation_no_event_loss() {
405        let mut world = World::new();
406        world.add_event::<ImpactEvent>();
407
408        let mut registry = RenderEventRegistry::new();
409        registry.register::<ImpactEvent>();
410
411        // Tick 1: send, accumulate, swap.
412        world
413            .resource_mut::<Events<ImpactEvent>>()
414            .send(ImpactEvent {
415                entity_index: 1,
416                pos: [0.0; 3],
417                force: 1.0,
418            });
419        registry.accumulate(&world);
420        world.update_events(); // swap clears current — but we already captured
421
422        // Tick 2: send, accumulate, swap.
423        world
424            .resource_mut::<Events<ImpactEvent>>()
425            .send(ImpactEvent {
426                entity_index: 2,
427                pos: [5.0; 3],
428                force: 0.5,
429            });
430        registry.accumulate(&world);
431        world.update_events();
432
433        // Drain: both ticks' events present, no loss.
434        let events = registry.drain();
435        assert_eq!(events.len(), 2);
436        assert_eq!(events[0].entity, 1);
437        assert_eq!(events[1].entity, 2);
438    }
439
440    #[test]
441    fn drain_clears_pending() {
442        let mut world = World::new();
443        world.add_event::<ImpactEvent>();
444
445        let mut registry = RenderEventRegistry::new();
446        registry.register::<ImpactEvent>();
447
448        world
449            .resource_mut::<Events<ImpactEvent>>()
450            .send(ImpactEvent {
451                entity_index: 1,
452                pos: [0.0; 3],
453                force: 1.0,
454            });
455        registry.accumulate(&world);
456
457        assert_eq!(registry.drain().len(), 1);
458        // Second drain returns empty — no double delivery.
459        assert!(registry.drain().is_empty());
460    }
461
462    #[test]
463    fn no_double_delivery_across_frames() {
464        let mut world = World::new();
465        world.add_event::<ImpactEvent>();
466
467        let mut registry = RenderEventRegistry::new();
468        registry.register::<ImpactEvent>();
469
470        // Frame 1: send, accumulate, drain.
471        world
472            .resource_mut::<Events<ImpactEvent>>()
473            .send(ImpactEvent {
474                entity_index: 1,
475                pos: [0.0; 3],
476                force: 1.0,
477            });
478        registry.accumulate(&world);
479        let frame1 = registry.drain();
480        assert_eq!(frame1.len(), 1);
481
482        // Swap (next tick start).
483        world.update_events();
484
485        // Frame 2: no new events, accumulate reads empty current.
486        registry.accumulate(&world);
487        let frame2 = registry.drain();
488        assert!(frame2.is_empty()); // NOT re-delivered from previous
489    }
490
491    #[test]
492    fn default_intensity_is_one() {
493        #[derive(Debug)]
494        struct MinimalEvent;
495
496        impl RenderEvent for MinimalEvent {
497            const KIND: u32 = 99;
498            fn entity(&self) -> u32 {
499                0
500            }
501            fn position(&self) -> [f32; 3] {
502                [0.0; 3]
503            }
504        }
505
506        let mut world = World::new();
507        world.add_event::<MinimalEvent>();
508        world
509            .resource_mut::<Events<MinimalEvent>>()
510            .send(MinimalEvent);
511
512        let mut registry = RenderEventRegistry::new();
513        registry.register::<MinimalEvent>();
514
515        let events = flush_and_drain(&registry, &world);
516        assert_eq!(events.len(), 1);
517        assert!((events[0].intensity - 1.0).abs() < f32::EPSILON);
518    }
519
520    #[test]
521    fn custom_data_payload() {
522        #[derive(Debug)]
523        struct HitFlash {
524            color: [f32; 4],
525        }
526
527        impl RenderEvent for HitFlash {
528            const KIND: u32 = 50;
529            fn entity(&self) -> u32 {
530                0
531            }
532            fn position(&self) -> [f32; 3] {
533                [0.0; 3]
534            }
535            fn data(&self) -> [f32; 4] {
536                self.color
537            }
538        }
539
540        let mut world = World::new();
541        world.add_event::<HitFlash>();
542        world.resource_mut::<Events<HitFlash>>().send(HitFlash {
543            color: [1.0, 0.0, 0.0, 0.8],
544        });
545
546        let mut registry = RenderEventRegistry::new();
547        registry.register::<HitFlash>();
548
549        let events = flush_and_drain(&registry, &world);
550        assert_eq!(events.len(), 1);
551        assert_eq!(events[0].kind, 50);
552        assert_eq!(events[0].data, [1.0, 0.0, 0.0, 0.8]);
553    }
554
555    #[test]
556    fn drain_with_empty_registry_returns_empty() {
557        let registry = RenderEventRegistry::new();
558        assert!(registry.drain().is_empty());
559    }
560}