khora_sdk/engine.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The Khora Engine core — generic engine runtime with injection points.
16//!
17//! This module contains `EngineCore`, the winit-agnostic engine core.
18//! Windowing and event-loop integration lives in `winit_adapters.rs`.
19//!
20//! The engine owns: DCC, scheduler, telemetry, runtime containers, frame loop.
21//! The app owns: window, renderer, agents, phases, game logic.
22
23use khora_control::{substrate, DccConfig, DccService, EngineMode};
24use khora_core::lane::{ClearColor, ColorTarget, DepthTarget};
25use khora_core::renderer::traits::RenderSystem;
26use khora_core::renderer::GraphicsDevice;
27use khora_core::Runtime;
28use khora_data::ecs::TickPhase;
29use khora_data::render::{submit_frame_graph, FrameGraph, SharedFrameGraph};
30use khora_telemetry::TelemetryService;
31use std::collections::VecDeque;
32use std::sync::{Arc, Mutex, RwLock};
33use std::time::Duration;
34
35use crate::traits::EngineApp;
36use crate::GameWorld;
37use crate::InputEvent;
38
39/// Well-known viewport handle for the primary 3D viewport.
40pub const PRIMARY_VIEWPORT: khora_core::ui::editor::viewport_texture::ViewportTextureHandle =
41 khora_core::ui::editor::viewport_texture::ViewportTextureHandle(0);
42
43// ─────────────────────────────────────────────────────────────────────
44// EngineCore — winit-agnostic engine runtime
45// ─────────────────────────────────────────────────────────────────────
46
47/// The core engine state, independent of any windowing backend.
48///
49/// Created by the windowing driver (e.g. `WinitAppRunner`), then
50/// driven frame-by-frame via [`EngineCore::tick`].
51pub struct EngineCore<A: EngineApp> {
52 app: Option<A>,
53 game_world: Option<GameWorld>,
54 telemetry: Option<TelemetryService>,
55 dcc: Option<DccService>,
56 scheduler: Option<khora_control::ExecutionScheduler>,
57 context: Arc<RwLock<khora_control::Context>>,
58 runtime: Arc<Runtime>,
59 input_events: VecDeque<InputEvent>,
60 simulation_started: bool,
61}
62
63impl<A: EngineApp> EngineCore<A> {
64 /// Creates a new, uninitialized engine core.
65 pub fn new() -> Self {
66 Self {
67 app: None,
68 game_world: None,
69 telemetry: None,
70 dcc: None,
71 scheduler: None,
72 context: Arc::new(RwLock::new(khora_control::Context {
73 hardware: khora_control::HardwareState::default(),
74 mode: EngineMode::Playing,
75 global_budget_multiplier: 1.0,
76 memory_pressure: 0.0,
77 })),
78 runtime: Arc::new(Runtime::new()),
79 input_events: VecDeque::new(),
80 simulation_started: false,
81 }
82 }
83
84 /// Bootstraps the engine: creates DCC, telemetry, scheduler,
85 /// registers agents, calls `app.setup()`, and initializes agents.
86 ///
87 /// This method takes ownership of the `runtime` populated
88 /// by the windowing driver's bootstrap closure. It wraps the runtime
89 /// in an `Arc` once all built-in entries have been inserted.
90 pub fn bootstrap(&mut self, mut app: A, mut runtime: Runtime) {
91 // Create DCC + telemetry
92 let (mut dcc, dcc_rx) = DccService::new(DccConfig::default());
93 let telemetry =
94 TelemetryService::new(Duration::from_secs(1)).with_dcc_sender(dcc.event_sender());
95
96 // Register the system-RAM monitor so the tracking allocator's live
97 // stats flow through telemetry into the DCC, where they drive memory
98 // pressure + allocation-churn signals (it is no longer write-only).
99 telemetry.monitor_registry().register(std::sync::Arc::new(
100 khora_infra::telemetry::memory_monitor::MemoryMonitor::new("System_RAM".to_string()),
101 ));
102
103 // ── Expose observable handles via Resources ─────────────────────
104 // Apps (e.g. the editor) read live engine state (monitors, agent
105 // list, DCC context) through these handles. They're cheap clones of
106 // internal Arc-shared structures, so doing so before `app.setup` is
107 // safe.
108 runtime
109 .resources
110 .insert(telemetry.monitor_registry().clone());
111 runtime.resources.insert(dcc.agent_registry().clone());
112 // Live DCC context: shared `Arc<RwLock<Context>>` updated by the
113 // DCC cold thread, read by observers each frame.
114 runtime.resources.insert(dcc.context_handle());
115
116 // ── Overlay channels (gizmo + grid) ──────────────────────────────────
117 // Shared resources the host application (editor, debug tooling)
118 // writes into each frame; the `OverlayAgent` lanes read them in
119 // the OUTPUT phase. The engine only provides the slots — it never
120 // produces the data itself, keeping the editor a pure consumer
121 // of engine APIs (no engine-internal `EditorAgent`).
122 //
123 // Inserted BEFORE `app.setup` (like the observable handles above)
124 // so an app can configure them during setup — e.g. the editor
125 // enables the grid via `GridConfig` there. They are standalone
126 // `Arc<Mutex<Default>>` with no dependency on later-created
127 // resources, so exposing them this early is safe.
128 let gizmo_frame: khora_lanes::render_lane::SharedGizmoFrame =
129 Arc::new(Mutex::new(khora_data::render::GizmoFrame::default()));
130 runtime.resources.insert(gizmo_frame);
131 // Editor grid — disabled by default; the editor opts in.
132 let grid_config: khora_lanes::render_lane::SharedGridConfig =
133 Arc::new(Mutex::new(khora_data::render::GridConfig::default()));
134 runtime.resources.insert(grid_config);
135 // Wireframe debug overlay — disabled by default; the editor opts in.
136 let wireframe_config: khora_lanes::render_lane::SharedWireframeConfig =
137 Arc::new(Mutex::new(khora_data::render::WireframeConfig::default()));
138 runtime.resources.insert(wireframe_config);
139
140 // ── Data-layer GPU resources ─────────────────────────────────────
141 // AssetStore: the single engine-wide store of projected GPU assets
142 // (Assets<GpuMesh> / GpuMaterial / CpuTexture sub-stores, created on
143 // demand). The SDK/app fills the CpuTexture sub-store (it owns the
144 // AssetService); the data-layer projection (ProjectionRegistry, runs
145 // sync_all/sync_materials once per frame in PreExtract) only uploads
146 // from it (khora-data must not depend on khora-io).
147 //
148 // Inserted BEFORE `app.setup` so an app can register assets (e.g.
149 // decoded textures) into the store during setup. Neither needs a
150 // graphics device at construction, so this is safe this early.
151 let asset_store = khora_data::AssetStore::new();
152 let proj_registry = khora_data::ProjectionRegistry::new(asset_store.clone());
153 runtime.resources.insert(asset_store);
154 runtime.resources.insert(proj_registry);
155
156 // IBL bake service — projects the scene environment (procedural sky
157 // for now) into the GPU cubes/LUT image-based lighting samples. Baked
158 // once by the `ibl_bake` DataSystem on the first frame a device is
159 // available; the lit lanes read the result at group 3.
160 runtime.resources.insert(khora_data::IblBaker::new());
161
162 // InputMap — engine-wide action / binding map. Inserted BEFORE
163 // `app.setup` so apps can bind actions and cache the handle during
164 // setup (e.g. the sandbox's PlayerController). The engine ticks it
165 // each frame from `drain_inputs`.
166 runtime
167 .resources
168 .insert(Arc::new(Mutex::new(khora_core::platform::InputMap::new())));
169
170 // Frame graph — per-frame collection of render passes recorded by
171 // agents during OUTPUT; `tick_with_services()` drains + submits it.
172 let frame_graph: SharedFrameGraph = Arc::new(Mutex::new(FrameGraph::new()));
173 runtime.resources.insert(frame_graph);
174
175 // Shader/pipeline composition is owned by the `PipelineSystem` backend
176 // (`WgpuPipelineSystem`), injected into `runtime.resources` by the app
177 // bootstrap; render lanes resolve their layouts + pipelines through it.
178
179 // EcsMaintenance — fetched + ticked each frame by the `ecs_maintenance`
180 // DataSystem (Maintenance phase).
181 runtime
182 .resources
183 .insert(Arc::new(Mutex::new(khora_data::ecs::EcsMaintenance::new())));
184
185 // AssetEviction — fetched + ticked each frame by the `asset_eviction`
186 // DataSystem (Maintenance phase). Reclaims orphaned GPU meshes/materials
187 // (from despawns and inline material edits) that the insert-only
188 // projection would otherwise leak, freeing their wgpu resources.
189 runtime
190 .resources
191 .insert(Arc::new(Mutex::new(khora_data::AssetEviction::new())));
192
193 // Time — the engine's per-frame clock. The scheduler publishes the
194 // real frame delta + fixed step + render-interpolation alpha into it
195 // each frame; Flows and game `update` read it (replacing hardcoded
196 // deltas). Shared behind RwLock so the scheduler (holding Arc<Runtime>)
197 // writes while readers borrow `&Runtime`. Inserted before `app.setup`
198 // so apps can read it during setup.
199 let time: khora_core::time::SharedTime =
200 Arc::new(std::sync::RwLock::new(khora_core::time::Time::default()));
201 runtime.resources.insert(time);
202
203 // TransformInterpolation — engine-owned per-entity "previous pose" store
204 // the capture pass fills and the render projection blends by the
205 // interpolation alpha. A resource, not an ECS component, so it never
206 // appears in the editor or scene files (interpolation is render-only).
207 let transform_interpolation: khora_core::interpolation::SharedTransformInterpolation =
208 Arc::new(std::sync::RwLock::new(
209 khora_core::interpolation::TransformInterpolation::new(),
210 ));
211 runtime.resources.insert(transform_interpolation);
212
213 // UiImageAtlas — `AssetUUID → AtlasRect` mapping for UI images; the GPU
214 // atlas itself is allocated lazily by `UiAgent::on_initialize`.
215 runtime
216 .resources
217 .insert(Arc::new(khora_data::ui::UiImageAtlas::new()));
218
219 // AgentFrameStatus map — per-agent execution metrics measured and
220 // written by the scheduler each frame; agents read their own slot in
221 // `report_status` instead of holding per-frame counters.
222 let agent_frame_status: khora_core::control::gorna::AgentFrameStatusMap =
223 Arc::new(std::sync::RwLock::new(std::collections::HashMap::new()));
224 runtime.resources.insert(agent_frame_status);
225
226 // Create the game world
227 let mut game_world = GameWorld::new();
228
229 // Call app setup. The runtime is now fully populated, so the app can
230 // read any engine resource here. Mutation goes through `register_agents`.
231 app.setup(&mut game_world, &runtime);
232
233 // Register agents via the app's AgentProvider trait.
234 app.register_agents(&dcc, &mut runtime);
235
236 let runtime_arc = Arc::new(runtime);
237
238 // Register built-in agents (always present). Agents implement only the
239 // `Agent` trait + `Default`, so construction goes through `Default::default()`.
240 dcc.register_agent(
241 Arc::new(Mutex::new(
242 khora_agents::render_agent::RenderAgent::default(),
243 )),
244 1.0,
245 );
246 dcc.register_agent(
247 Arc::new(Mutex::new(
248 khora_agents::shadow_agent::ShadowAgent::default(),
249 )),
250 1.0,
251 );
252 dcc.register_agent(
253 Arc::new(Mutex::new(
254 khora_agents::overlay_agent::OverlayAgent::default(),
255 )),
256 1.0,
257 );
258 dcc.register_agent(
259 Arc::new(Mutex::new(
260 khora_agents::skybox_agent::SkyboxAgent::default(),
261 )),
262 1.0,
263 );
264 dcc.register_agent(
265 Arc::new(Mutex::new(
266 khora_agents::physics_agent::PhysicsAgent::default(),
267 )),
268 1.0,
269 );
270 dcc.register_agent(
271 Arc::new(Mutex::new(khora_agents::ui_agent::UiAgent::default())),
272 1.0,
273 );
274 dcc.register_agent(
275 Arc::new(Mutex::new(khora_agents::audio_agent::AudioAgent::default())),
276 1.0,
277 );
278
279 // Initialize agents with the full runtime so on_initialize() can
280 // find Arc<dyn GraphicsDevice>, Arc<Mutex<Box<dyn RenderSystem>>>,
281 // GpuCache, etc. via the typed runtime containers.
282 {
283 let init_bus = khora_core::lane::LaneBus::new();
284 let mut init_deck = khora_core::lane::OutputDeck::new();
285 let mut init_ctx = khora_core::EngineContext {
286 world: khora_core::WorldAccess::None,
287 runtime: Arc::clone(&runtime_arc),
288 bus: &init_bus,
289 deck: &mut init_deck,
290 };
291 dcc.initialize_agents(&mut init_ctx);
292 }
293 // Start the DCC background thread AFTER agents are initialized
294 // so GORNA does not run health-checks before agents are ready.
295 dcc.start(dcc_rx);
296
297 // Build scheduler
298 let agent_ids = vec![
299 khora_core::control::gorna::AgentId::Renderer,
300 khora_core::control::gorna::AgentId::ShadowRenderer,
301 khora_core::control::gorna::AgentId::Overlay,
302 khora_core::control::gorna::AgentId::Skybox,
303 khora_core::control::gorna::AgentId::Physics,
304 khora_core::control::gorna::AgentId::Ui,
305 khora_core::control::gorna::AgentId::Audio,
306 ];
307
308 let registry = dcc.agent_registry().clone();
309 let mut scheduler =
310 khora_control::ExecutionScheduler::new(registry, self.context.clone(), &agent_ids);
311
312 // Connect the read-only observation tunnel: the scheduler publishes
313 // per-agent cost samples + per-component access snapshots the DCC's
314 // cost model and layout advisor consume.
315 scheduler.set_telemetry_sender(dcc.event_sender());
316
317 // Run phases through the parallel wave executor: agents declaring
318 // `AgentAccess::Isolated` / `SharedWorld` (no `&mut World`, disjoint
319 // outputs) run concurrently within a phase, the rest serially. With
320 // the current agents this makes `[Ui, Overlay]` a concurrent wave;
321 // pass submission stays deterministic (fixed scene→ui→overlay fold).
322 scheduler.set_parallel_execution(true);
323
324 // Inject custom phases from the app
325 let custom_phases = app.custom_phases();
326 for phase in custom_phases {
327 scheduler.insert_after(khora_core::agent::ExecutionPhase::OUTPUT, phase);
328 }
329
330 let budget_channel = scheduler.budget_channel().clone();
331 dcc.connect_budget_channel(budget_channel);
332
333 let _ = dcc
334 .event_sender()
335 .send(khora_core::telemetry::TelemetryEvent::PhaseChange(
336 "boot".to_string(),
337 ));
338
339 // Store everything
340 self.app = Some(app);
341 self.game_world = Some(game_world);
342 self.telemetry = Some(telemetry);
343 self.dcc = Some(dcc);
344 self.scheduler = Some(scheduler);
345 self.runtime = runtime_arc;
346 }
347
348 /// Queues an input event to be processed on the next tick.
349 pub fn feed_input(&mut self, event: InputEvent) {
350 self.input_events.push_back(event);
351 }
352
353 /// Executes one frame: app update, ECS maintenance, scheduler.
354 ///
355 /// This method is called by the windowing driver (e.g. winit)
356 /// each time a redraw is requested.
357 pub fn tick(&mut self) {
358 // Default tick path: reuse the engine-level Runtime as-is. The
359 // winit driver injects per-frame resources (`FrameContext`,
360 // viewport handle) by calling `tick_with_runtime` directly.
361 let frame_runtime_arc = Arc::clone(&self.runtime);
362 self.tick_with_runtime(frame_runtime_arc);
363 }
364
365 /// Executes one frame with a custom per-frame [`Runtime`] overlay.
366 ///
367 /// Used by the winit runner to inject a `FrameContext` into the
368 /// per-frame Resources for cross-agent synchronization.
369 ///
370 /// This is a convenience wrapper that calls the staged methods in order
371 /// without invoking any [`EngineApp`] lifecycle hooks. Drivers that need
372 /// to interleave hooks (e.g., the editor's overlay/shell) should call the
373 /// staged methods directly via the winit runner.
374 pub fn tick_with_runtime(&mut self, frame_runtime_arc: Arc<Runtime>) {
375 let inputs = self.drain_inputs();
376 self.run_app_update(&inputs);
377 let presents = self.begin_render_frame(&frame_runtime_arc);
378 self.run_scheduler(&frame_runtime_arc);
379 self.end_render_frame(presents);
380 self.run_maintenance();
381 }
382
383 /// Stage 6 — runs the end-of-tick `Maintenance`-phase `DataSystem`s
384 /// against the scheduler's per-frame [`OutputDeck`].
385 ///
386 /// The engine is subsystem-agnostic here: it threads the deck through
387 /// the substrate dispatcher and lets each `DataSystem` drain whatever
388 /// typed slot belongs to its domain (audio writeback, physics
389 /// writeback, future subsystems …). No subsystem-specific code lives
390 /// in this method.
391 pub fn run_maintenance(&mut self) {
392 let Some(gw) = self.game_world.as_mut() else {
393 return;
394 };
395 let world = gw.inner_world_mut();
396
397 if let Some(scheduler) = self.scheduler.as_mut() {
398 substrate::run_data_systems(
399 world,
400 &self.runtime,
401 scheduler.deck_mut(),
402 TickPhase::Maintenance,
403 );
404 } else {
405 // No scheduler installed — pass a transient empty deck so
406 // `DataSystem`s that read it harmlessly observe an empty slot.
407 let mut deck = khora_core::lane::OutputDeck::new();
408 substrate::run_data_systems(world, &self.runtime, &mut deck, TickPhase::Maintenance);
409 }
410 }
411
412 /// Stage 1 — drain queued input events. Also marks simulation started
413 /// (emits the `"simulation"` phase change on the first call), ticks
414 /// the telemetry service, and feeds the events into the engine-wide
415 /// [`khora_core::platform::InputMap`] so app code can query actions
416 /// (`is_pressed`, `just_pressed`) from the same frame's inputs.
417 pub fn drain_inputs(&mut self) -> Vec<InputEvent> {
418 if !self.simulation_started {
419 if let Some(dcc) = &self.dcc {
420 let _ =
421 dcc.event_sender()
422 .send(khora_core::telemetry::TelemetryEvent::PhaseChange(
423 "simulation".to_string(),
424 ));
425 }
426 self.simulation_started = true;
427 }
428 if let Some(telemetry) = self.telemetry.as_mut() {
429 let _ = telemetry.tick();
430 }
431 let drained: Vec<InputEvent> = self.input_events.drain(..).collect();
432
433 // Tick the InputMap with this frame's events. Lock is short — only
434 // held for the duration of `update`. App code that needs the map
435 // (e.g. `runtime.resources.get::<Arc<Mutex<InputMap>>>()`) sees the
436 // updated state on its next lock.
437 if let Some(map_arc) = self
438 .runtime
439 .resources
440 .get::<Arc<Mutex<khora_core::platform::InputMap>>>()
441 {
442 if let Ok(mut map) = map_arc.lock() {
443 map.update(&drained);
444 }
445 }
446
447 drained
448 }
449
450 /// Stage 2 — run `app.update`, ECS maintenance, mesh sync, and scene/UI
451 /// extractions. Called between [`drain_inputs`](Self::drain_inputs) and
452 /// [`begin_render_frame`](Self::begin_render_frame).
453 pub fn run_app_update(&mut self, inputs: &[InputEvent]) {
454 let (Some(app), Some(gw)) = (self.app.as_mut(), self.game_world.as_mut()) else {
455 return;
456 };
457 let runtime = &self.runtime;
458
459 // Pre-scheduler phases run before the per-frame `OutputDeck` is
460 // built. We pass a transient empty deck so `DataSystem`s that
461 // happen to read it observe an empty slot — they simply no-op.
462 let mut deck = khora_core::lane::OutputDeck::new();
463
464 // Substrate Pass — pre-simulation invariants (input-driven mutations,
465 // scene events that must be visible to agents).
466 substrate::run_data_systems(
467 gw.inner_world_mut(),
468 runtime,
469 &mut deck,
470 TickPhase::PreSimulation,
471 );
472
473 app.update(gw, inputs);
474
475 // Substrate Pass — post-simulation invariants (hierarchy fix-ups
476 // such as transform_propagation, run after app.update mutates Transforms
477 // but before extraction reads GlobalTransform).
478 substrate::run_data_systems(
479 gw.inner_world_mut(),
480 runtime,
481 &mut deck,
482 TickPhase::PostSimulation,
483 );
484
485 // Substrate Pass — pre-extract. Runs:
486 // - `gpu_mesh_sync` (CPU→GPU mesh upload, replaces former proj.sync_all)
487 // - any other PreExtract DataSystem registered by users.
488 // RenderFlow + UiFlow then run inside the scheduler's Substrate Pass
489 // and publish their views into the LaneBus.
490 substrate::run_data_systems(
491 gw.inner_world_mut(),
492 &self.runtime,
493 &mut deck,
494 TickPhase::PreExtract,
495 );
496 }
497
498 /// Stage 3 — acquire the swapchain via `RenderSystem::begin_frame` and
499 /// populate the per-frame [`FrameContext`] with `ColorTarget`,
500 /// `DepthTarget`, and `ClearColor`.
501 ///
502 /// Returns `true` when a renderer is present and `begin_frame` succeeded
503 /// (driver should later call [`present_frame`](Self::present_frame)).
504 /// Returns `false` only when no renderer is registered or the swapchain
505 /// could not be acquired.
506 pub fn begin_render_frame(&mut self, frame_runtime_arc: &Arc<Runtime>) -> bool {
507 let render_system = self
508 .runtime
509 .backends
510 .get::<Arc<Mutex<Box<dyn RenderSystem>>>>()
511 .map(|arc| (*arc).clone());
512 let fctx = frame_runtime_arc
513 .resources
514 .get::<Arc<khora_core::renderer::api::core::FrameContext>>()
515 .map(|arc| (*arc).clone());
516
517 let Some(rs) = &render_system else {
518 return false;
519 };
520 let Ok(mut guard) = rs.lock() else {
521 return false;
522 };
523 match guard.begin_frame() {
524 Ok(targets) => {
525 if let Some(fctx) = &fctx {
526 fctx.insert(ColorTarget(targets.color));
527 if let Some(d) = targets.depth {
528 fctx.insert(DepthTarget(d));
529 }
530 fctx.insert(ClearColor(khora_core::math::LinearRgba::new(
531 0.1, 0.1, 0.15, 1.0,
532 )));
533 }
534 true
535 }
536 Err(e) => {
537 // Fatal device errors (lost / out-of-memory) are surfaced loudly
538 // so the host can decide to tear down; transient acquisition
539 // skips (minimized window, surface reconfigure, timeout) are
540 // expected and logged at debug to avoid per-frame spam.
541 if e.is_fatal() {
542 log::error!("EngineCore: begin_frame fatal error: {}", e);
543 } else {
544 log::debug!("EngineCore: begin_frame skipped this frame: {}", e);
545 }
546 false
547 }
548 }
549 }
550
551 /// Stage 4 — dispatch the scheduler so all registered agents execute
552 /// their phases for this frame.
553 pub fn run_scheduler(&mut self, frame_runtime_arc: &Arc<Runtime>) {
554 let Some(gw) = self.game_world.as_mut() else {
555 return;
556 };
557 if let Some(s) = self.scheduler.as_mut() {
558 s.run_frame(gw.inner_world_mut(), frame_runtime_arc.clone());
559 }
560 }
561
562 /// Stage 5a — submit recorded passes from the [`FrameGraph`] to the GPU.
563 /// When `presents` is `false` (render-to-viewport mode), the frame graph
564 /// is discarded instead.
565 pub fn submit_passes(&mut self, presents: bool) {
566 let device = self
567 .runtime
568 .backends
569 .get::<Arc<dyn GraphicsDevice>>()
570 .map(|arc| (*arc).clone());
571 let frame_graph = self
572 .runtime
573 .resources
574 .get::<SharedFrameGraph>()
575 .map(|arc| (*arc).clone());
576
577 // Fold the agents' recorded passes (buffered in the scheduler's deck by
578 // Render/Skybox/Ui/Overlay instead of locking the shared graph) into the
579 // FrameGraph in a fixed layer order — scene, then the skybox background,
580 // then UI, then overlay compositing. The skybox sits right after the
581 // scene (it loads the scene's depth to reject geometry pixels) and
582 // before the debug overlays. The topological sort refines this via the
583 // declared read/write edges; insertion order is the tie-breaker.
584 if let (Some(graph), Some(scheduler)) = (&frame_graph, self.scheduler.as_mut()) {
585 use khora_data::render::{OverlayPassSlot, ScenePassSlot, SkyboxPassSlot, UiPassSlot};
586 let deck = scheduler.deck_mut();
587 let scene = deck.take::<ScenePassSlot>().0;
588 let skybox = deck.take::<SkyboxPassSlot>().0;
589 let ui = deck.take::<UiPassSlot>().0;
590 let overlay = deck.take::<OverlayPassSlot>().0;
591 if scene.is_some() || skybox.is_some() || ui.is_some() || overlay.is_some() {
592 if let Ok(mut fg) = graph.lock() {
593 for pass in [scene, skybox, ui, overlay].into_iter().flatten() {
594 fg.add_pass(pass.descriptor, pass.command_buffer);
595 }
596 } else {
597 log::error!("submit_passes: FrameGraph mutex poisoned, dropping frame passes");
598 }
599 }
600 }
601
602 if presents {
603 if let (Some(graph), Some(device)) = (&frame_graph, &device) {
604 submit_frame_graph(graph, device.as_ref());
605 }
606 } else if let Some(graph) = &frame_graph {
607 graph.lock().expect("FrameGraph mutex poisoned").clear();
608 }
609 }
610
611 /// Stage 5b — call `RenderSystem::end_frame` to present the swapchain.
612 /// No-op when `presents` is `false`.
613 pub fn present_frame(&mut self, presents: bool) {
614 if !presents {
615 return;
616 }
617 let render_system = self
618 .runtime
619 .backends
620 .get::<Arc<Mutex<Box<dyn RenderSystem>>>>()
621 .map(|arc| (*arc).clone());
622 if let Some(rs) = &render_system {
623 if let Ok(mut guard) = rs.lock() {
624 if let Err(e) = guard.end_frame() {
625 if e.is_fatal() {
626 log::error!("EngineCore: end_frame fatal error: {}", e);
627 } else {
628 log::debug!("EngineCore: end_frame skipped this frame: {}", e);
629 }
630 }
631 }
632 }
633 }
634
635 /// Convenience: runs both [`submit_passes`](Self::submit_passes) and
636 /// [`present_frame`](Self::present_frame) in order. Used by the default
637 /// `tick` path; drivers that need to interleave hooks should call the
638 /// staged methods directly.
639 pub fn end_render_frame(&mut self, presents: bool) {
640 self.submit_passes(presents);
641 self.present_frame(presents);
642 }
643
644 /// Mutable accessor for the application instance. Used by the winit
645 /// runner to invoke [`EngineApp`] lifecycle hooks between staged frame
646 /// methods.
647 pub fn app_mut(&mut self) -> Option<&mut A> {
648 self.app.as_mut()
649 }
650
651 /// Invokes a closure with mutable access to BOTH the application and the
652 /// game world simultaneously. Used by the winit runner to call lifecycle
653 /// hooks that need to read/write components (e.g., gizmo collection)
654 /// without re-borrowing `EngineCore` twice.
655 ///
656 /// The closure is skipped silently if either is uninitialized.
657 pub fn with_app_and_world<F>(&mut self, f: F)
658 where
659 F: FnOnce(&mut A, &mut GameWorld),
660 {
661 if let (Some(app), Some(world)) = (self.app.as_mut(), self.game_world.as_mut()) {
662 f(app, world);
663 }
664 }
665
666 /// Stores the runtime Arc. Used by the winit runner after bootstrap.
667 pub fn set_runtime(&mut self, runtime: Arc<Runtime>) {
668 self.runtime = runtime;
669 }
670
671 /// Returns a reference to the engine-level runtime.
672 pub fn runtime(&self) -> &Arc<Runtime> {
673 &self.runtime
674 }
675
676 /// Returns a mutable reference to the game world, if initialized.
677 pub fn game_world_mut(&mut self) -> Option<&mut GameWorld> {
678 self.game_world.as_mut()
679 }
680
681 /// Returns the DCC service, if initialized.
682 pub fn dcc(&self) -> Option<&DccService> {
683 self.dcc.as_ref()
684 }
685
686 /// Applies a developer [`EngineHint`](khora_core::control::gorna::EngineHint)
687 /// biasing GORNA arbitration (`Cap` a per-frame budget, `Prioritize` an
688 /// agent) without changing game semantics. No-op if the DCC isn't running.
689 /// Thread-safe; takes effect on the next arbitration tick.
690 pub fn set_engine_hint(&self, hint: khora_core::control::gorna::EngineHint) {
691 if let Some(dcc) = &self.dcc {
692 dcc.set_hint(hint);
693 }
694 }
695
696 /// Clears all developer hints for an agent, restoring engine defaults.
697 pub fn clear_agent_hints(&self, agent_id: khora_core::control::gorna::AgentId) {
698 if let Some(dcc) = &self.dcc {
699 dcc.clear_agent_hints(agent_id);
700 }
701 }
702
703 /// Read-only snapshot of the accumulated per-agent hints (glass-box), or an
704 /// empty map if the DCC isn't running.
705 pub fn engine_hints(
706 &self,
707 ) -> std::collections::HashMap<
708 khora_core::control::gorna::AgentId,
709 khora_core::control::gorna::AgentHints,
710 > {
711 self.dcc.as_ref().map(|d| d.hints()).unwrap_or_default()
712 }
713
714 /// Shuts down the engine, calling `app.on_shutdown()`.
715 ///
716 /// Note: renderer shutdown is the responsibility of the application,
717 /// since the renderer was created and registered by the app's bootstrap closure.
718 pub fn shutdown(&mut self) {
719 if let Some(app) = self.app.as_mut() {
720 app.on_shutdown();
721 }
722 log::info!("Engine shutdown complete.");
723 }
724}
725
726impl<A: EngineApp> Default for EngineCore<A> {
727 fn default() -> Self {
728 Self::new()
729 }
730}