Skip to main content

khora_agents/skybox_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 SkyboxAgent — owns the environment-background `LaneKind::Render`
16//! pass.
17
18use std::sync::Arc;
19use std::time::Duration;
20
21use khora_core::agent::{
22    Agent, AgentAccess, AgentDependency, AgentImportance, DependencyKind, ExecutionPhase,
23    ExecutionTiming,
24};
25use khora_core::control::gorna::{
26    measured_frame_time_ms, AgentFrameStatusMap, AgentId, AgentStatus, NegotiationRequest,
27    NegotiationResponse, ResourceBudget, StrategyId, StrategyOption,
28};
29use khora_core::lane::{ColorTarget, DepthTarget, LaneContext, LaneRegistry, Slot};
30use khora_core::renderer::api::core::FrameContext;
31use khora_core::renderer::GraphicsDevice;
32use khora_core::EngineContext;
33use khora_data::render::{
34    PassContribution, PassDescriptor, RenderWorld, ResourceId, SkyboxPassSlot,
35};
36
37/// The agent responsible for the skybox / environment-background pass.
38///
39/// Owns a single-lane [`LaneRegistry`] (the [`SkyboxLane`]). Structurally it
40/// mirrors `OverlayAgent` — `Isolated`, buffers a pass into a deck slot — but
41/// it runs its lane unconditionally (no host opt-in) and carries a higher
42/// importance so the background sky survives budget pressure.
43///
44/// [`SkyboxLane`]: khora_lanes::render_lane::SkyboxLane
45pub struct SkyboxAgent {
46    /// The single skybox lane, held in a registry for parity with the other
47    /// render agents.
48    lanes: LaneRegistry,
49    /// Time budget assigned by GORNA via `apply_budget`.
50    time_budget: Duration,
51    /// Current GORNA strategy ID applied via `apply_budget`.
52    current_strategy: StrategyId,
53    /// Shared, scheduler-written per-agent frame metrics, read in
54    /// `report_status`; the agent holds no per-frame counters.
55    frame_status: Option<AgentFrameStatusMap>,
56}
57
58impl Agent for SkyboxAgent {
59    fn id(&self) -> AgentId {
60        AgentId::Skybox
61    }
62
63    /// Reads only the `LaneBus` + shared read-only resources (the IBL bake's
64    /// bindings), records into its own encoder, and buffers its pass into its
65    /// `OutputDeck` — no `World`, no shared mutable resource.
66    fn access(&self) -> AgentAccess {
67        AgentAccess::Isolated
68    }
69
70    /// Buffers its pass into the [`SkyboxPassSlot`] deck slot.
71    fn deck_writes(&self) -> Vec<std::any::TypeId> {
72        vec![std::any::TypeId::of::<SkyboxPassSlot>()]
73    }
74
75    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
76        // A single fixed background pass — quote a small flat overhead and
77        // accept the balanced strategy (there is nothing to trade off).
78        let strategies = vec![StrategyOption {
79            id: StrategyId::Balanced,
80            estimated_time: Duration::from_micros(300),
81            estimated_vram: 0,
82        }];
83        NegotiationResponse {
84            strategies,
85            timing_adjustment: None,
86        }
87    }
88
89    fn apply_budget(&mut self, budget: ResourceBudget) {
90        self.time_budget = budget.time_limit;
91        self.current_strategy = budget.strategy_id;
92    }
93
94    fn on_initialize(&mut self, context: &mut EngineContext<'_>) {
95        self.frame_status = context
96            .runtime
97            .resources
98            .get::<AgentFrameStatusMap>()
99            .cloned();
100
101        let Some(device_arc) = context
102            .runtime
103            .backends
104            .get::<Arc<dyn GraphicsDevice>>()
105            .cloned()
106        else {
107            log::warn!("SkyboxAgent: graphics device unavailable in on_initialize");
108            return;
109        };
110        let pipeline_system = context
111            .runtime
112            .resources
113            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
114            .cloned();
115
116        let mut init_ctx = LaneContext::new();
117        init_ctx.insert(device_arc);
118        if let Some(ps) = pipeline_system {
119            init_ctx.insert(ps);
120        }
121        for lane in self.lanes.all() {
122            if let Err(e) = lane.on_initialize(&mut init_ctx) {
123                log::error!(
124                    "SkyboxAgent: failed to initialize lane {}: {}",
125                    lane.strategy_name(),
126                    e
127                );
128            }
129        }
130    }
131
132    fn execute(&mut self, context: &mut EngineContext<'_>) {
133        let Some(device_arc) = context.runtime.backends.get::<Arc<dyn GraphicsDevice>>() else {
134            return;
135        };
136        let device: Arc<dyn GraphicsDevice> = (*device_arc).clone();
137
138        // The env cube published by the IBL bake. Absent until the bake has run
139        // (first frames) ⇒ no background yet; the scene's clear color shows.
140        let ibl = context
141            .runtime
142            .resources
143            .get::<khora_data::IblBaker>()
144            .and_then(|baker| baker.bindings());
145        let Some(ibl) = ibl else {
146            return;
147        };
148
149        let render_world: Option<&RenderWorld> = context.bus.get();
150
151        // Frame targets — the skybox draws into the same color target as the
152        // main render and depth-tests against the existing depth buffer.
153        let Some(fctx) = context.runtime.resources.get::<Arc<FrameContext>>() else {
154            log::warn!("SkyboxAgent: no FrameContext in services");
155            return;
156        };
157        let Some(color_target) = fctx.get::<ColorTarget>().map(|a| *a) else {
158            return;
159        };
160        let Some(depth_target) = fctx.get::<DepthTarget>().map(|a| *a) else {
161            return;
162        };
163
164        let mut encoder = device.create_command_encoder(Some("Khora Skybox Encoder"));
165        {
166            let mut ctx = LaneContext::new();
167            ctx.insert(device.clone());
168            // SAFETY: encoder is alive for the whole block; ctx is dropped
169            // before encoder.finish() consumes it.
170            let encoder_slot = Slot::new(encoder.as_mut());
171            ctx.insert(unsafe {
172                std::mem::transmute::<
173                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
174                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
175                >(encoder_slot)
176            });
177            if let Some(rw) = render_world {
178                ctx.insert(khora_core::lane::Ref::new(rw));
179            }
180            ctx.insert(color_target);
181            ctx.insert(depth_target);
182            // The env-cube bindings the SkyboxLane samples. LaneContext does not
183            // reach `runtime.resources`, so the agent forwards them explicitly.
184            ctx.insert(ibl);
185
186            for lane in self.lanes.all() {
187                if let Err(e) = lane.execute(&mut ctx) {
188                    log::error!("SkyboxAgent: lane {} failed: {}", lane.strategy_name(), e);
189                }
190            }
191        }
192
193        if let Some(cmd_buf) = encoder.finish() {
194            let descriptor = PassDescriptor::new("SkyboxPass")
195                .writes(ResourceId::Color)
196                .reads(ResourceId::Depth);
197            context.deck.slot::<SkyboxPassSlot>().0 = Some(PassContribution {
198                descriptor,
199                command_buffer: cmd_buf,
200            });
201        }
202    }
203
204    fn report_status(&self) -> AgentStatus {
205        let measured_time_ms = measured_frame_time_ms(&self.frame_status, self.id());
206        let health_score = if self.time_budget.is_zero() || measured_time_ms <= 0.0 {
207            1.0
208        } else {
209            (self.time_budget.as_secs_f32() * 1000.0 / measured_time_ms).min(1.0)
210        };
211        AgentStatus {
212            agent_id: self.id(),
213            health_score,
214            current_strategy: self.current_strategy,
215            is_stalled: false,
216            message: format!("skybox frame_time={measured_time_ms:.2}ms"),
217        }
218    }
219
220    fn execution_timing(&self) -> ExecutionTiming {
221        ExecutionTiming {
222            allowed_phases: vec![ExecutionPhase::OUTPUT],
223            default_phase: ExecutionPhase::OUTPUT,
224            // Lower than `Renderer` (1.0) so the scene draws first; higher than
225            // the optional overlays (0.5) since the sky is a core background.
226            priority: 0.6,
227            // Not debug viz — keep the background under budget pressure.
228            importance: AgentImportance::Important,
229            // The SkyboxPass loads the scene's color + depth, so the Renderer
230            // must have submitted first.
231            dependencies: vec![AgentDependency {
232                target: AgentId::Renderer,
233                kind: DependencyKind::Hard,
234                condition: None,
235            }],
236            fixed_timestep: None,
237        }
238    }
239
240    fn as_any(&self) -> &dyn std::any::Any {
241        self
242    }
243
244    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
245        self
246    }
247}
248
249impl Default for SkyboxAgent {
250    fn default() -> Self {
251        let mut lanes = LaneRegistry::new();
252        lanes.register(Box::new(khora_lanes::render_lane::SkyboxLane::default()));
253        Self {
254            lanes,
255            time_budget: Duration::ZERO,
256            current_strategy: StrategyId::Balanced,
257            frame_status: None,
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn skybox_agent_registers_its_lane() {
268        let agent = SkyboxAgent::default();
269        assert_eq!(agent.lanes.len(), 1);
270        assert!(agent.lanes.get("Skybox").is_some());
271    }
272
273    #[test]
274    fn skybox_agent_reports_skybox_id() {
275        let agent = SkyboxAgent::default();
276        assert_eq!(agent.id(), AgentId::Skybox);
277    }
278
279    #[test]
280    fn skybox_agent_runs_in_output_phase_after_renderer() {
281        let agent = SkyboxAgent::default();
282        let timing = agent.execution_timing();
283        assert_eq!(timing.default_phase, ExecutionPhase::OUTPUT);
284        assert_eq!(timing.importance, AgentImportance::Important);
285        assert_eq!(timing.dependencies.len(), 1);
286        assert_eq!(timing.dependencies[0].target, AgentId::Renderer);
287    }
288}