meridian_engine_core/lib.rs
1//! Runtime: frame scheduler, event system, subsystem manager; ties every other crate into the main loop.
2//!
3//! [`SubsystemManager`] is the one place in the workspace allowed to know
4//! about every `*-core` at once (see docs/dependency-rules.md rule 7) — it
5//! owns real instances of the driver-independent subsystems that exist
6//! today: an `ecs-core` [`World`], `physics-core`'s body list and
7//! pipeline, and `audio-core`'s listener/mixer. `graphics-core` isn't
8//! wired into [`Runtime::tick`] yet: `graphics-driver` has a real
9//! windowed `wgpu` device now (see docs/roadmap.md's `winit`/windowing
10//! entry), but `graphics-core` itself has no scene/material vocabulary or
11//! GPU-submission bridge yet to turn its `RenderGraph` into actual draw
12//! calls, so there's no frame in the render sense for `Runtime::tick` to
13//! schedule. This crate deliberately doesn't depend on `graphics-driver`
14//! directly (see docs/dependency-rules.md: `engine-core` depends on
15//! `graphics-core`, not drivers) — a windowed app composes
16//! [`platform_core::run_windowed_app`](meridian_platform_core::run_windowed_app)
17//! with its own `graphics-driver::Device`/`Surface` and reuses
18//! [`Runtime::tick`]'s [`Time`] for animation/physics timing (see the
19//! `spinning_cube` example), rather than `Runtime` gaining rendering
20//! awareness before `graphics-core` has anything to submit. Real audio
21//! *output* follows the same composition pattern: the app owns an
22//! `audio-core::AudioOutput` (the core→own-driver bridge over
23//! `audio-driver`) and feeds it `Mixer::render_interleaved` blocks each
24//! tick — see the `audible_scene` example; `Runtime` itself stays
25//! driver-free on the audio side too.
26//!
27//! [`Runtime::tick`] advances physics, then recomputes audio gains from
28//! the physics-updated emitter frames, in that order — not through
29//! [`FrameScheduler`]/`task-core`'s `JobGraph`, deliberately: physics and
30//! audio are the only two real per-frame systems today, and they have a
31//! genuine sequential data dependency (audio reads positions physics just
32//! wrote), not two independent branches. Wrapping a strictly sequential
33//! two-step in a job graph would be decorative, not functional — the same
34//! reason `compute-runtime`'s `task-core` dependency isn't wired in yet
35//! (see that crate's module doc). [`FrameScheduler`] is real and tested on
36//! its own terms; it becomes load-bearing once a second real per-frame
37//! system exists that's genuinely independent of physics (animation,
38//! particles, ...) to run alongside it.
39
40use std::any::{Any, TypeId};
41use std::collections::HashMap;
42
43use meridian_audio_core::{Emitter, Listener, Mixer};
44use meridian_ecs_core::World;
45use meridian_physics_core::{BroadPhase, ConstraintSolver, Integrator, NarrowPhase, RigidBody};
46use meridian_platform_core::{Clock, CpuCapabilities, Time};
47use meridian_task_core::{JobGraph, Scheduler};
48
49/// Workspace-wide event bus: a frame-scoped mailbox, not a persistent log.
50/// [`publish`](Self::publish) queues an event by its concrete type;
51/// [`drain`](Self::drain) removes and returns every event of that type
52/// published since the last drain. This is what lets subsystems
53/// communicate without depending on each other directly — e.g. a future
54/// `physics-core` contact could be published here and consumed by
55/// `audio-core` for an impact sound, without either crate knowing the
56/// other exists (see docs/dependency-rules.md rule 7: only `engine-core`
57/// is allowed to know about both).
58#[derive(Default)]
59pub struct EventSystem {
60 queues: HashMap<TypeId, Vec<Box<dyn Any>>>,
61}
62
63impl std::fmt::Debug for EventSystem {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("EventSystem")
66 .field("queued_event_types", &self.queues.len())
67 .finish()
68 }
69}
70
71impl EventSystem {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 /// Queues `event`, keyed by its concrete type `E`. Multiple event
77 /// types don't collide — each gets its own queue.
78 pub fn publish<E: 'static>(&mut self, event: E) {
79 self.queues
80 .entry(TypeId::of::<E>())
81 .or_default()
82 .push(Box::new(event));
83 }
84
85 /// Removes and returns every queued event of type `E`, in publish
86 /// order. A second call before any new `publish::<E>` returns empty —
87 /// this is a drain, not a peek.
88 pub fn drain<E: 'static>(&mut self) -> Vec<E> {
89 let Some(boxed) = self.queues.remove(&TypeId::of::<E>()) else {
90 return Vec::new();
91 };
92 boxed
93 .into_iter()
94 .map(|b| *b.downcast::<E>().expect("queue keyed by TypeId::of::<E>()"))
95 .collect()
96 }
97}
98
99/// Runs one frame's [`JobGraph`] across worker threads — the engine-layer
100/// application of `task-core`'s generic scheduler (see
101/// docs/threading-model.md). Sized by [`FrameScheduler::default`] to the
102/// real detected CPU thread count via `platform-core`, not a hardcoded
103/// guess.
104#[derive(Debug)]
105pub struct FrameScheduler {
106 scheduler: Scheduler,
107}
108
109impl Default for FrameScheduler {
110 fn default() -> Self {
111 Self::new(CpuCapabilities::detect().threads)
112 }
113}
114
115impl FrameScheduler {
116 pub fn new(worker_count: usize) -> Self {
117 Self {
118 scheduler: Scheduler::new(worker_count),
119 }
120 }
121
122 /// Runs `graph` to completion, blocking until every job has finished.
123 pub fn run(&self, graph: JobGraph) {
124 self.scheduler.run(graph);
125 }
126}
127
128/// Registry of active subsystems for the current [`Runtime`] — real owned
129/// instances, not stubs: an `ecs-core` [`World`] (available for
130/// application-level entity/`Transform` use; not synced with `bodies`
131/// below — no such mapping is defined anywhere in the workspace yet, and
132/// inventing one here would be new, undocumented design, not wiring
133/// together what already exists), `physics-core`'s body list plus its
134/// broad/narrow-phase and solver/integrator, and `audio-core`'s listener,
135/// emitters and mixer. The only place in the workspace allowed to know
136/// about every `*-core` at once — see docs/dependency-rules.md rule 7.
137pub struct SubsystemManager {
138 pub world: World,
139
140 pub bodies: Vec<RigidBody>,
141 pub broad_phase: BroadPhase,
142 pub narrow_phase: NarrowPhase,
143 pub solver: ConstraintSolver,
144 pub integrator: Integrator,
145
146 pub listener: Listener,
147 pub emitters: Vec<(Emitter, f32)>,
148 pub mixer: Mixer,
149}
150
151impl std::fmt::Debug for SubsystemManager {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 // `World` doesn't derive `Debug` (it holds type-erased archetype
154 // storage — see meridian-ecs-core), so this summarizes it rather
155 // than deriving through it.
156 f.debug_struct("SubsystemManager")
157 .field("bodies", &self.bodies.len())
158 .field("emitters", &self.emitters.len())
159 .field("listener", &self.listener)
160 .finish_non_exhaustive()
161 }
162}
163
164impl SubsystemManager {
165 pub fn new(mixer: Mixer) -> Self {
166 Self {
167 world: World::new(),
168 bodies: Vec::new(),
169 broad_phase: BroadPhase::new(),
170 narrow_phase: NarrowPhase::new(),
171 solver: ConstraintSolver::default(),
172 integrator: Integrator::default(),
173 listener: Listener::default(),
174 emitters: Vec::new(),
175 mixer,
176 }
177 }
178
179 /// Advances every body by `dt`: integrate, find broad-phase candidate
180 /// pairs, generate exact contacts, resolve them. The same pipeline
181 /// `physics-core`'s own tests exercise by hand
182 /// (`full_step_ball_settles_on_static_floor_without_sinking_through`),
183 /// centralized here as the one real per-frame physics step.
184 pub fn step_physics(&mut self, dt: f32) {
185 self.integrator.step(&mut self.bodies, dt);
186 let pairs = self.broad_phase.find_candidate_pairs(&self.bodies).to_vec();
187 for contact in self.narrow_phase.generate_contacts(&self.bodies, &pairs) {
188 self.solver.resolve(&mut self.bodies, &contact);
189 }
190 }
191
192 /// Per-channel gains for every emitter against the current listener,
193 /// via `audio-core`'s `Mixer` — reads whatever `emitters`' frames are
194 /// *right now*, so calling this after [`step_physics`](Self::step_physics)
195 /// reflects physics-updated positions.
196 pub fn mix_audio(&self) -> Vec<(meridian_audio_core::Channel, f32)> {
197 self.mixer.mix(&self.listener, &self.emitters)
198 }
199}
200
201/// Published by [`Runtime::tick`] after every frame — the one concrete
202/// event type this crate defines itself; application code can publish its
203/// own event types through the same [`EventSystem`].
204#[derive(Debug, Clone, Copy, PartialEq)]
205pub struct FrameCompleted {
206 pub frame_index: u64,
207 pub delta_seconds: f64,
208}
209
210/// Owns subsystem instances and drives the frame loop. Construct once with
211/// [`Runtime::new`], then call [`Runtime::tick`] once per frame.
212#[derive(Debug)]
213pub struct Runtime {
214 pub subsystems: SubsystemManager,
215 pub events: EventSystem,
216 pub frame_scheduler: FrameScheduler,
217 clock: Clock,
218 frame_index: u64,
219}
220
221impl Runtime {
222 pub fn new(subsystems: SubsystemManager) -> Self {
223 Self {
224 subsystems,
225 events: EventSystem::new(),
226 frame_scheduler: FrameScheduler::default(),
227 clock: Clock::new(),
228 frame_index: 0,
229 }
230 }
231
232 /// Advances the simulation by one frame: ticks the clock, steps
233 /// physics, recomputes audio gains from the result, publishes a
234 /// [`FrameCompleted`] event, and returns the frame's [`Time`]. See the
235 /// module doc for why this is a direct sequential call rather than a
236 /// `FrameScheduler`-run job graph.
237 pub fn tick(&mut self) -> Time {
238 let time = self.clock.tick();
239 self.subsystems.step_physics(time.delta_seconds as f32);
240 self.events.publish(FrameCompleted {
241 frame_index: self.frame_index,
242 delta_seconds: time.delta_seconds,
243 });
244 self.frame_index += 1;
245 time
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use meridian_audio_core::{AttenuationModel, Channel, SpeakerLayout};
253 use meridian_gac_core::{Motor3, Vec3};
254 use meridian_physics_core::ColliderShape;
255
256 #[test]
257 fn event_system_round_trips_by_type() {
258 let mut events = EventSystem::new();
259 events.publish(1i32);
260 events.publish(2i32);
261 events.publish("hello");
262
263 assert_eq!(events.drain::<i32>(), vec![1, 2]);
264 assert_eq!(events.drain::<&str>(), vec!["hello"]);
265 }
266
267 #[test]
268 fn event_system_drain_empties_the_queue() {
269 let mut events = EventSystem::new();
270 events.publish(42i32);
271 assert_eq!(events.drain::<i32>(), vec![42]);
272 assert_eq!(events.drain::<i32>(), Vec::<i32>::new());
273 }
274
275 #[test]
276 fn event_system_drain_of_unpublished_type_is_empty() {
277 let mut events = EventSystem::new();
278 assert_eq!(events.drain::<f32>(), Vec::<f32>::new());
279 }
280
281 #[test]
282 fn frame_scheduler_runs_a_job_graph() {
283 use std::sync::Arc;
284 use std::sync::atomic::{AtomicUsize, Ordering};
285
286 let ran = Arc::new(AtomicUsize::new(0));
287 let mut graph = JobGraph::new();
288 let ran2 = ran.clone();
289 graph.add_job("job", &[], move || {
290 ran2.fetch_add(1, Ordering::SeqCst);
291 });
292
293 FrameScheduler::new(2).run(graph);
294 assert_eq!(ran.load(Ordering::SeqCst), 1);
295 }
296
297 fn falling_body() -> RigidBody {
298 RigidBody {
299 frame: Motor3::translation(Vec3::new(0.0, 10.0, 0.0)),
300 mass: 1.0,
301 shape: ColliderShape::Sphere { radius: 0.5 },
302 ..Default::default()
303 }
304 }
305
306 #[test]
307 fn runtime_tick_advances_physics_under_gravity() {
308 let mut subsystems = SubsystemManager::new(Mixer::new(SpeakerLayout::mono()));
309 subsystems.bodies.push(falling_body());
310 let mut runtime = Runtime::new(subsystems);
311
312 // Checking velocity, not position: position starts at y=10.0, and
313 // a real wall-clock dt (microseconds, since Clock isn't a fixed
314 // step) produces a position delta many orders of magnitude below
315 // f32's precision at that magnitude — it would round away to
316 // nothing even though physics genuinely ran. Velocity starts at
317 // exactly 0.0, so any nonzero gravity contribution is visible
318 // regardless of how small the real elapsed dt was.
319 for _ in 0..5 {
320 runtime.tick();
321 }
322 assert!(
323 runtime.subsystems.bodies[0].velocity.y < 0.0,
324 "gravity must have been applied across ticks"
325 );
326 }
327
328 #[test]
329 fn runtime_tick_publishes_frame_completed_with_increasing_index() {
330 let subsystems = SubsystemManager::new(Mixer::new(SpeakerLayout::mono()));
331 let mut runtime = Runtime::new(subsystems);
332
333 runtime.tick();
334 runtime.tick();
335 runtime.tick();
336
337 let completed = runtime.events.drain::<FrameCompleted>();
338 assert_eq!(completed.len(), 3);
339 assert_eq!(
340 completed.iter().map(|e| e.frame_index).collect::<Vec<_>>(),
341 vec![0, 1, 2]
342 );
343 }
344
345 #[test]
346 fn subsystem_manager_mixes_audio_from_current_emitter_positions() {
347 let mut subsystems = SubsystemManager::new(
348 Mixer::new(SpeakerLayout::stereo_headphones()).with_attenuation(AttenuationModel {
349 reference_distance: 1000.0,
350 rolloff: 1.0,
351 max_distance: 1000.0,
352 }),
353 );
354 subsystems.listener = Listener {
355 frame: Motor3::identity(),
356 };
357 // Local +Z is "right" per audio-core's listener convention.
358 subsystems.emitters.push((
359 Emitter {
360 frame: Motor3::translation(Vec3::new(0.0, 0.0, 5.0)),
361 },
362 1.0,
363 ));
364
365 let gains = subsystems.mix_audio();
366 let gain_of = |channel: Channel| {
367 gains
368 .iter()
369 .find(|(c, _)| *c == channel)
370 .map(|(_, g)| *g)
371 .unwrap_or(0.0)
372 };
373 assert!(gain_of(Channel::Right) > 0.99);
374 assert!(gain_of(Channel::Left) < 1e-3);
375 }
376
377 #[test]
378 fn subsystem_manager_step_physics_resolves_a_resting_contact() {
379 let mut subsystems = SubsystemManager::new(Mixer::new(SpeakerLayout::mono()));
380 subsystems.bodies.push(RigidBody {
381 frame: Motor3::translation(Vec3::new(0.0, -50.0, 0.0)),
382 mass: 0.0, // static floor
383 shape: ColliderShape::Sphere { radius: 50.0 },
384 ..Default::default()
385 });
386 subsystems.bodies.push(falling_body());
387
388 for _ in 0..600 {
389 subsystems.step_physics(1.0 / 60.0);
390 }
391
392 let resting_height = subsystems.bodies[1].position().y;
393 assert!(
394 (resting_height - 0.5).abs() < 0.5,
395 "ball should settle near the floor surface, got y={resting_height}"
396 );
397 }
398}