Skip to main content

khora_agents/overlay_agent/
agent.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//! Defines the OverlayAgent — owns overlay / debug `LaneKind::Render` lanes.
16//!
17//! Per CLAD an Agent owns exactly one domain. OverlayAgent's domain is
18//! "post-main-render overlay passes". Lanes here render into the same
19//! color target as `RenderAgent`, but with `depth-read-only` semantics
20//! and alpha blending — they overlay, they don't replace.
21//!
22//! Unlike `RenderAgent`, OverlayAgent does **not** pick one strategy
23//! per frame. Each registered lane is independent and runs every frame
24//! it has work to do — `GizmoLane` skips if no gizmos were published,
25//! `WireframeLane` runs when the debug flag is on, etc.
26
27use std::sync::Arc;
28use std::time::Duration;
29
30use khora_core::agent::{
31    Agent, AgentAccess, AgentDependency, AgentImportance, DependencyKind, ExecutionPhase,
32    ExecutionTiming,
33};
34use khora_core::control::gorna::{
35    measured_frame_time_ms, AgentFrameStatusMap, AgentId, AgentStatus, NegotiationRequest,
36    NegotiationResponse, ResourceBudget, StrategyId, StrategyOption,
37};
38use khora_core::lane::{ClearColor, ColorTarget, DepthTarget, LaneContext, LaneRegistry, Slot};
39use khora_core::renderer::api::core::FrameContext;
40use khora_core::renderer::api::scene::GpuMesh;
41use khora_core::renderer::GraphicsDevice;
42use khora_core::EngineContext;
43use khora_data::assets::Assets;
44use khora_data::render::{
45    OverlayPassSlot, PassContribution, PassDescriptor, RenderWorld, ResourceId,
46};
47use khora_data::AssetStore;
48use std::sync::RwLock;
49
50/// The agent responsible for overlay / debug rendering passes.
51///
52/// Owns a [`LaneRegistry`] of overlay lanes (gizmo, wireframe, emissive,
53/// …). Each lane is asked to run every frame; lanes that have nothing
54/// to do early-out internally rather than being gated at the agent
55/// level. This keeps the agent generic — it never knows what each lane
56/// needs to consult to decide.
57pub struct OverlayAgent {
58    /// Registered overlay lanes.
59    lanes: LaneRegistry,
60    /// Time budget assigned by GORNA via `apply_budget`.
61    time_budget: Duration,
62    /// Current GORNA strategy ID applied via `apply_budget`.
63    current_strategy: StrategyId,
64    /// Shared, scheduler-written per-agent frame metrics, read in
65    /// `report_status`; the agent holds no per-frame counters.
66    frame_status: Option<AgentFrameStatusMap>,
67}
68
69impl Agent for OverlayAgent {
70    fn id(&self) -> AgentId {
71        AgentId::Overlay
72    }
73
74    /// Reads only the `LaneBus` and shared read-only resources, records into its
75    /// own encoder, and buffers its pass into its `OutputDeck` — no `World`, no
76    /// shared mutable resource. Eligible to run concurrently with any wave.
77    fn access(&self) -> AgentAccess {
78        AgentAccess::Isolated
79    }
80
81    /// Buffers its pass into the [`OverlayPassSlot`] deck slot.
82    fn deck_writes(&self) -> Vec<std::any::TypeId> {
83        vec![std::any::TypeId::of::<OverlayPassSlot>()]
84    }
85
86    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
87        // Overlay passes are bounded by host application activity (editor
88        // gizmos, debug flags). They don't compete for the main render
89        // budget — quote a small flat overhead and accept any of the
90        // standard strategy IDs.
91        let strategies = vec![StrategyOption {
92            id: StrategyId::Balanced,
93            estimated_time: Duration::from_micros(500),
94            estimated_vram: 1024 * 1024,
95        }];
96        NegotiationResponse {
97            strategies,
98            timing_adjustment: None,
99        }
100    }
101
102    fn apply_budget(&mut self, budget: ResourceBudget) {
103        self.time_budget = budget.time_limit;
104        self.current_strategy = budget.strategy_id;
105    }
106
107    fn on_initialize(&mut self, context: &mut EngineContext<'_>) {
108        self.frame_status = context
109            .runtime
110            .resources
111            .get::<AgentFrameStatusMap>()
112            .cloned();
113
114        let Some(device_arc) = context
115            .runtime
116            .backends
117            .get::<Arc<dyn GraphicsDevice>>()
118            .cloned()
119        else {
120            log::warn!("OverlayAgent: graphics device unavailable in on_initialize");
121            return;
122        };
123        let pipeline_system = context
124            .runtime
125            .resources
126            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
127            .cloned();
128
129        let mut init_ctx = LaneContext::new();
130        init_ctx.insert(device_arc);
131        if let Some(ps) = pipeline_system {
132            init_ctx.insert(ps);
133        }
134        for lane in self.lanes.all() {
135            if let Err(e) = lane.on_initialize(&mut init_ctx) {
136                log::error!(
137                    "OverlayAgent: failed to initialize lane {}: {}",
138                    lane.strategy_name(),
139                    e
140                );
141            }
142        }
143    }
144
145    fn execute(&mut self, context: &mut EngineContext<'_>) {
146        if self.lanes.is_empty() {
147            // Nothing to do — no overlay lanes registered. Common when
148            // the host application doesn't need debug viz.
149            return;
150        }
151
152        let Some(device_arc) = context.runtime.backends.get::<Arc<dyn GraphicsDevice>>() else {
153            return;
154        };
155        let device: Arc<dyn GraphicsDevice> = (*device_arc).clone();
156
157        let Some(asset_store) = context.runtime.resources.get::<AssetStore>() else {
158            return;
159        };
160        let gpu_meshes: Arc<RwLock<Assets<GpuMesh>>> = asset_store.store::<GpuMesh>();
161
162        let render_world: Option<&RenderWorld> = context.bus.get();
163
164        // Read frame targets — overlay lanes draw into the same color
165        // target as the main render and use the existing depth buffer
166        // read-only.
167        let Some(fctx) = context.runtime.resources.get::<Arc<FrameContext>>() else {
168            log::warn!("OverlayAgent: no FrameContext in services");
169            return;
170        };
171        let Some(color_target) = fctx.get::<ColorTarget>().map(|a| *a) else {
172            return;
173        };
174        let depth_target = fctx.get::<DepthTarget>().map(|a| *a);
175        let clear_color = fctx
176            .get::<ClearColor>()
177            .map(|a| *a)
178            .unwrap_or_else(|| ClearColor(khora_core::math::LinearRgba::new(0.0, 0.0, 0.0, 0.0)));
179
180        // Encode every overlay lane's commands into a single command
181        // buffer, attached to the FrameGraph as a single OverlayPass.
182        let mut encoder = device.create_command_encoder(Some("Khora Overlay Encoder"));
183        {
184            let mut ctx = LaneContext::new();
185            ctx.insert(device.clone());
186            ctx.insert(gpu_meshes.clone());
187            // SAFETY: encoder is alive for the whole block; ctx is dropped
188            // before encoder.finish() consumes it.
189            let encoder_slot = Slot::new(encoder.as_mut());
190            ctx.insert(unsafe {
191                std::mem::transmute::<
192                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
193                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
194                >(encoder_slot)
195            });
196            // RenderWorld carries the primary view GizmoLane needs for
197            // its camera matrix.
198            if let Some(rw) = render_world {
199                ctx.insert(khora_core::lane::Ref::new(rw));
200            }
201            ctx.insert(Slot::new(&mut *context.deck));
202            ctx.insert(color_target);
203            if let Some(dt) = depth_target {
204                ctx.insert(dt);
205            }
206            ctx.insert(clear_color);
207
208            // The host application (editor, debug tooling) publishes
209            // gizmo lines into this shared frame. The engine only owns
210            // the mechanism — it never produces the data itself, so the
211            // editor stays a pure consumer of engine APIs (no
212            // engine-internal `EditorAgent`).
213            if let Some(gizmos) = context
214                .runtime
215                .resources
216                .get::<khora_lanes::render_lane::SharedGizmoFrame>()
217                .cloned()
218            {
219                ctx.insert(gizmos);
220            }
221            // Editor-grid opt-in — same mechanism: the host app enables
222            // it, `GridLane` consumes it; absent ⇒ no grid.
223            if let Some(grid_cfg) = context
224                .runtime
225                .resources
226                .get::<khora_lanes::render_lane::SharedGridConfig>()
227                .cloned()
228            {
229                ctx.insert(grid_cfg);
230            }
231            // Wireframe debug overlay — same opt-in mechanism.
232            if let Some(wireframe_cfg) = context
233                .runtime
234                .resources
235                .get::<khora_lanes::render_lane::SharedWireframeConfig>()
236                .cloned()
237            {
238                ctx.insert(wireframe_cfg);
239            }
240
241            for lane in self.lanes.all() {
242                if let Err(e) = lane.execute(&mut ctx) {
243                    log::error!("OverlayAgent: lane {} failed: {}", lane.strategy_name(), e);
244                }
245            }
246        }
247
248        if let Some(cmd_buf) = encoder.finish() {
249            let descriptor = PassDescriptor::new("OverlayPass")
250                .writes(ResourceId::Color)
251                .reads(ResourceId::Depth);
252            // Buffer into the deck; the engine folds it into the FrameGraph after
253            // the wave. With no shared-graph lock and no other shared mutable
254            // write, OverlayAgent is `AgentAccess::Isolated`.
255            context.deck.slot::<OverlayPassSlot>().0 = Some(PassContribution {
256                descriptor,
257                command_buffer: cmd_buf,
258            });
259        }
260    }
261
262    fn report_status(&self) -> AgentStatus {
263        let measured_time_ms = measured_frame_time_ms(&self.frame_status, self.id());
264        let health_score = if self.time_budget.is_zero() || measured_time_ms <= 0.0 {
265            1.0
266        } else {
267            (self.time_budget.as_secs_f32() * 1000.0 / measured_time_ms).min(1.0)
268        };
269        AgentStatus {
270            agent_id: self.id(),
271            health_score,
272            current_strategy: self.current_strategy,
273            is_stalled: false,
274            message: format!(
275                "overlay_lanes={} frame_time={measured_time_ms:.2}ms",
276                self.lanes.len(),
277            ),
278        }
279    }
280
281    fn execution_timing(&self) -> ExecutionTiming {
282        ExecutionTiming {
283            allowed_phases: vec![ExecutionPhase::OUTPUT],
284            default_phase: ExecutionPhase::OUTPUT,
285            // Lower priority than `Renderer` (1.0) so the scheduler
286            // dispatches the main render before the overlay.
287            priority: 0.5,
288            importance: AgentImportance::Optional,
289            // The OverlayPass draws on top of the main render's color
290            // target, so we need the Renderer to have submitted first.
291            dependencies: vec![AgentDependency {
292                target: AgentId::Renderer,
293                kind: DependencyKind::Hard,
294                condition: None,
295            }],
296            fixed_timestep: None,
297        }
298    }
299
300    fn as_any(&self) -> &dyn std::any::Any {
301        self
302    }
303
304    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
305        self
306    }
307}
308
309impl Default for OverlayAgent {
310    fn default() -> Self {
311        let mut lanes = LaneRegistry::new();
312        // Order matters — overlays composite in registration order:
313        // grid is the backdrop, wireframe is debug viz, gizmo is the editor
314        // handles on top.
315        lanes.register(Box::new(khora_lanes::render_lane::GridLane::default()));
316        lanes.register(Box::new(khora_lanes::render_lane::WireframeLane::default()));
317        lanes.register(Box::new(khora_lanes::render_lane::GizmoLane::default()));
318
319        Self {
320            lanes,
321            time_budget: Duration::ZERO,
322            current_strategy: StrategyId::Balanced,
323            frame_status: None,
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn overlay_agent_registers_overlay_lanes() {
334        let agent = OverlayAgent::default();
335        assert_eq!(agent.lanes.len(), 3);
336        assert!(agent.lanes.get("Grid").is_some());
337        assert!(agent.lanes.get("Wireframe").is_some());
338        assert!(agent.lanes.get("Gizmo").is_some());
339    }
340
341    #[test]
342    fn overlay_agent_reports_overlay_id() {
343        let agent = OverlayAgent::default();
344        assert_eq!(agent.id(), AgentId::Overlay);
345    }
346
347    #[test]
348    fn overlay_agent_runs_in_output_phase_after_renderer() {
349        let agent = OverlayAgent::default();
350        let timing = agent.execution_timing();
351        assert_eq!(timing.default_phase, ExecutionPhase::OUTPUT);
352        assert_eq!(timing.dependencies.len(), 1);
353        assert_eq!(timing.dependencies[0].target, AgentId::Renderer);
354    }
355}