Skip to main content

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}