Skip to main content

khora_agents/shadow_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 ShadowAgent — owns `LaneKind::Shadow` lanes only.
16//!
17//! Per CLAD an Agent owns exactly one `LaneKind` and stores **only** its
18//! own GORNA / strategy state. The agent registers all available shadow
19//! strategies as separate lanes; per-frame it selects **one** lane based
20//! on the budget GORNA assigned via `apply_budget`.
21//!
22//! Strategies:
23//!
24//! - [`StandardShadowsLane`] — full quality, 2048² 2D atlas + 512² cube atlas.
25//! - [`MediumShadowsLane`] — same algorithm, half resolution (1024² + 256²),
26//!   the `Balanced` middle rung.
27//! - [`LowResShadowsLane`] — same algorithm, quarter resolution (512² + 128²)
28//!   for tight time / VRAM budgets.
29//!
30//! All produce the same `ShadowGpuBindings` + `ShadowEntries` contract;
31//! lit consumer lanes are agnostic about which one ran.
32
33use std::sync::Arc;
34use std::time::Duration;
35
36use khora_core::agent::{Agent, AgentImportance, ExecutionPhase, ExecutionTiming};
37use khora_core::control::gorna::{
38    measured_frame_time_ms, AgentFrameStatusMap, AgentId, AgentStatus, NegotiationRequest,
39    NegotiationResponse, ResourceBudget, StrategyId, StrategyOption,
40};
41use khora_core::lane::{LaneContext, LaneRegistry, Ref, Slot};
42use khora_core::renderer::api::core::FrameContext;
43use khora_core::renderer::api::scene::GpuMesh;
44use khora_core::renderer::GraphicsDevice;
45use khora_core::EngineContext;
46use khora_data::render::RenderWorld;
47use khora_data::AssetStore;
48use khora_lanes::render_lane::shadows_lane::{
49    LOW_RES_STRATEGY_NAME, MEDIUM_STRATEGY_NAME, STANDARD_STRATEGY_NAME,
50};
51use khora_lanes::render_lane::{LowResShadowsLane, MediumShadowsLane, StandardShadowsLane};
52
53const COST_TO_MS_SCALE: f32 = 5.0;
54
55/// Strategy slot mirroring the `Lane` family registered on the agent.
56///
57/// One value = one fully-featured shadow pipeline. The agent stores the
58/// currently-selected variant so `execute()` knows which lane to invoke
59/// (the registry holds them both, but only one runs per frame).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ShadowStrategy {
62    /// Full-quality pipeline: 2048² × 4-layer 2D atlas + 512² × 4-cube
63    /// cube atlas. Maps to [`StandardShadowsLane`].
64    Standard,
65    /// Half resolution (1024² × 4-layer + 256² × 4-cube) — the `Balanced`
66    /// middle rung. Maps to [`MediumShadowsLane`].
67    Medium,
68    /// Quarter resolution (512² × 4-layer + 128² × 4-cube).
69    /// Maps to [`LowResShadowsLane`].
70    LowRes,
71}
72
73impl ShadowStrategy {
74    /// Returns the stable strategy name advertised by the matching lane.
75    pub fn lane_name(self) -> &'static str {
76        match self {
77            ShadowStrategy::Standard => STANDARD_STRATEGY_NAME,
78            ShadowStrategy::Medium => MEDIUM_STRATEGY_NAME,
79            ShadowStrategy::LowRes => LOW_RES_STRATEGY_NAME,
80        }
81    }
82
83    /// Maps a GORNA-issued [`StrategyId`] onto a concrete shadow strategy.
84    /// Each tier gets a genuinely different pipeline so budget changes are
85    /// observable in quality and cost.
86    fn from_strategy_id(id: StrategyId) -> Self {
87        match id {
88            StrategyId::HighPerformance => ShadowStrategy::Standard,
89            StrategyId::Balanced => ShadowStrategy::Medium,
90            StrategyId::LowPower => ShadowStrategy::LowRes,
91            StrategyId::Custom(_) => ShadowStrategy::Standard,
92        }
93    }
94}
95
96/// The agent responsible for shadow map rendering (`LaneKind::Shadow`).
97///
98/// Holds **only** its own strategy state — every other dependency
99/// (`GraphicsDevice`, `GpuCache`, `RenderWorld`, `FrameContext`) is
100/// fetched from `EngineContext::services` per frame.
101pub struct ShadowAgent {
102    /// Registered strategies — one lane per [`ShadowStrategy`] value.
103    lanes: LaneRegistry,
104    /// Strategy currently selected by GORNA.
105    strategy: ShadowStrategy,
106    /// Time budget assigned by GORNA via `apply_budget`.
107    time_budget: Duration,
108    /// Current GORNA strategy ID applied via `apply_budget`.
109    current_strategy: StrategyId,
110    /// Shared, scheduler-written per-agent frame metrics, read in
111    /// `report_status`; the agent holds no per-frame counters.
112    frame_status: Option<AgentFrameStatusMap>,
113}
114
115impl Agent for ShadowAgent {
116    fn id(&self) -> AgentId {
117        AgentId::ShadowRenderer
118    }
119
120    fn negotiate(&mut self, request: NegotiationRequest) -> NegotiationResponse {
121        // Estimate cost from a stub LaneContext. The real RenderWorld lives
122        // in the LaneBus at execute time; negotiation runs on the DCC thread
123        // without access to the live scene, so we feed the cost estimator a
124        // borrowed empty stub.
125        let stub_world = RenderWorld::new();
126        let mut ctx = LaneContext::new();
127        ctx.insert(Ref::new(&stub_world));
128
129        let lane_time = |name: &str, default_cost: f32| {
130            let cost = self
131                .lanes
132                .get(name)
133                .map(|lane| lane.estimate_cost(&ctx))
134                .unwrap_or(default_cost);
135            Duration::from_secs_f32((cost * COST_TO_MS_SCALE).max(0.1) / 1000.0)
136        };
137
138        // Per-tier VRAM at Depth32Float: 2D atlas (res² × 4 layers × 4B) +
139        // cube atlas (face² × 24 layers × 4B).
140        //   Standard: 64 + 24 MiB · Medium: 16 + 6 MiB · LowRes: 4 + 1.5 MiB
141        let std_vram = (64 + 24) * 1024 * 1024_u64;
142        let med_vram = (16 + 6) * 1024 * 1024_u64;
143        let low_vram = 4 * 1024 * 1024 + 3 * 512 * 1024_u64;
144
145        let fits = |vram: u64| {
146            request
147                .constraints
148                .max_vram_bytes
149                .map(|max| vram <= max)
150                .unwrap_or(true)
151        };
152
153        let mut strategies = Vec::new();
154        if fits(std_vram) {
155            strategies.push(StrategyOption {
156                id: StrategyId::HighPerformance,
157                estimated_time: lane_time(STANDARD_STRATEGY_NAME, 1.0),
158                estimated_vram: std_vram,
159            });
160        }
161        if fits(med_vram) {
162            strategies.push(StrategyOption {
163                id: StrategyId::Balanced,
164                estimated_time: lane_time(MEDIUM_STRATEGY_NAME, 0.75),
165                estimated_vram: med_vram,
166            });
167        }
168
169        // LowRes is always offered so the agent never returns an empty
170        // strategy set — it is the floor GORNA can fall back to.
171        strategies.push(StrategyOption {
172            id: StrategyId::LowPower,
173            estimated_time: lane_time(LOW_RES_STRATEGY_NAME, 0.5),
174            estimated_vram: low_vram,
175        });
176
177        NegotiationResponse {
178            strategies,
179            timing_adjustment: None,
180        }
181    }
182
183    fn apply_budget(&mut self, budget: ResourceBudget) {
184        self.time_budget = budget.time_limit;
185        self.current_strategy = budget.strategy_id;
186        self.strategy = ShadowStrategy::from_strategy_id(budget.strategy_id);
187    }
188
189    fn on_initialize(&mut self, context: &mut EngineContext<'_>) {
190        self.frame_status = context
191            .runtime
192            .resources
193            .get::<AgentFrameStatusMap>()
194            .cloned();
195
196        // One-shot lane GPU initialization for every registered strategy.
197        // Strategies are cheap to keep idle — only the selected lane
198        // executes per frame, but each one needs its own resources ready
199        // when the agent eventually picks it.
200        let Some(device_arc) = context
201            .runtime
202            .backends
203            .get::<Arc<dyn GraphicsDevice>>()
204            .cloned()
205        else {
206            log::warn!("ShadowAgent: graphics device unavailable in on_initialize");
207            return;
208        };
209        let pipeline_system = context
210            .runtime
211            .resources
212            .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
213            .cloned();
214
215        let mut init_ctx = LaneContext::new();
216        init_ctx.insert(device_arc);
217        if let Some(ps) = pipeline_system {
218            init_ctx.insert(ps);
219        }
220        for lane in self.lanes.all() {
221            if let Err(e) = lane.on_initialize(&mut init_ctx) {
222                log::error!(
223                    "ShadowAgent: failed to initialize lane {}: {}",
224                    lane.strategy_name(),
225                    e
226                );
227            }
228        }
229    }
230
231    fn execute(&mut self, context: &mut EngineContext<'_>) {
232        // Look up everything from services — the agent owns none of it.
233        let Some(device_arc) = context.runtime.backends.get::<Arc<dyn GraphicsDevice>>() else {
234            return;
235        };
236        let device: Arc<dyn GraphicsDevice> = (*device_arc).clone();
237
238        let Some(asset_store) = context.runtime.resources.get::<AssetStore>() else {
239            return;
240        };
241        let gpu_meshes = asset_store.store::<GpuMesh>();
242
243        let Some(render_world): Option<&RenderWorld> = context.bus.get() else {
244            log::warn!("ShadowAgent: no RenderWorld in LaneBus (RenderFlow not run?)");
245            return;
246        };
247
248        let frame_ctx = context
249            .runtime
250            .resources
251            .get::<Arc<FrameContext>>()
252            .cloned();
253
254        // Encode shadow passes into a standalone command buffer.
255        let mut encoder = device.create_command_encoder(Some("Shadow Command Encoder"));
256        {
257            let mut ctx = LaneContext::new();
258            ctx.insert(device.clone());
259            ctx.insert(gpu_meshes);
260            // SAFETY: encoder lives for the entire scope of this block; ctx
261            // is dropped before encoder.finish().
262            let encoder_slot = Slot::new(encoder.as_mut());
263            ctx.insert(unsafe {
264                std::mem::transmute::<
265                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
266                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
267                >(encoder_slot)
268            });
269            ctx.insert(Ref::new(render_world));
270            if let Some(shadow_view) = context.bus.get::<khora_data::flow::ShadowView>() {
271                ctx.insert(Ref::new(shadow_view));
272            }
273            // SAFETY: deck is borrowed from EngineContext for the duration
274            // of this agent.execute() call; the lane runs synchronously
275            // before the slot is dropped.
276            ctx.insert(Slot::new(&mut *context.deck));
277
278            // Pick exactly one lane (the strategy GORNA selected) and run
279            // it. The unselected lanes stay idle for this frame — their
280            // resources remain allocated but nothing renders into them.
281            let lane_name = self.strategy.lane_name();
282            if let Some(lane) = self.lanes.get(lane_name) {
283                if let Err(e) = lane.execute(&mut ctx) {
284                    log::error!(
285                        "ShadowAgent: shadow lane {} failed: {}",
286                        lane.strategy_name(),
287                        e
288                    );
289                }
290            } else {
291                log::error!(
292                    "ShadowAgent: selected strategy {:?} has no registered lane",
293                    self.strategy
294                );
295            }
296
297            // No `FrameContext` hoist — the lane has already published
298            // a `ShadowFrame` slot into the per-frame `OutputDeck`.
299            // Consumer lit lanes read `deck.slot::<ShadowFrame>()`
300            // directly. This is the only cross-agent channel for
301            // shadow data and complies with CLAD (input via Bus, output
302            // via Deck, no side-channels).
303        }
304        let _ = frame_ctx; // kept for any future per-frame resource use
305        if let Some(cmd_buf) = encoder.finish() {
306            device.submit_command_buffer(cmd_buf);
307        } else {
308            log::error!("ShadowAgent: encoder.finish() returned None — skipping shadow submission");
309        }
310    }
311
312    fn report_status(&self) -> AgentStatus {
313        let measured_time_ms = measured_frame_time_ms(&self.frame_status, self.id());
314        let health_score = if self.time_budget.is_zero() || measured_time_ms <= 0.0 {
315            1.0
316        } else {
317            (self.time_budget.as_secs_f32() * 1000.0 / measured_time_ms).min(1.0)
318        };
319
320        AgentStatus {
321            agent_id: self.id(),
322            health_score,
323            current_strategy: self.current_strategy,
324            is_stalled: false,
325            message: format!(
326                "shadow_strategy={:?} time={measured_time_ms:.2}ms",
327                self.strategy
328            ),
329        }
330    }
331
332    fn execution_timing(&self) -> ExecutionTiming {
333        ExecutionTiming {
334            allowed_phases: vec![ExecutionPhase::OBSERVE],
335            default_phase: ExecutionPhase::OBSERVE,
336            priority: 1.0,
337            importance: AgentImportance::Important,
338            dependencies: vec![],
339            fixed_timestep: None,
340        }
341    }
342
343    fn as_any(&self) -> &dyn std::any::Any {
344        self
345    }
346
347    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
348        self
349    }
350}
351
352impl Default for ShadowAgent {
353    fn default() -> Self {
354        let mut lanes = LaneRegistry::new();
355        lanes.register(Box::new(StandardShadowsLane::default()));
356        lanes.register(Box::new(MediumShadowsLane::default()));
357        lanes.register(Box::new(LowResShadowsLane::default()));
358
359        Self {
360            lanes,
361            // Default to full quality; GORNA can step down to Budget on
362            // pressure via `apply_budget`.
363            strategy: ShadowStrategy::Standard,
364            time_budget: Duration::ZERO,
365            current_strategy: StrategyId::HighPerformance,
366            frame_status: None,
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use khora_core::control::gorna::ResourceBudget;
375    use std::collections::HashMap;
376
377    fn budget(strategy_id: StrategyId) -> ResourceBudget {
378        ResourceBudget {
379            strategy_id,
380            time_limit: Duration::from_millis(8),
381            memory_limit: None,
382            extra_params: HashMap::new(),
383        }
384    }
385
386    #[test]
387    fn from_strategy_id_maps_low_power_to_low_res() {
388        assert_eq!(
389            ShadowStrategy::from_strategy_id(StrategyId::LowPower),
390            ShadowStrategy::LowRes
391        );
392    }
393
394    #[test]
395    fn from_strategy_id_maps_each_tier_to_a_distinct_strategy() {
396        assert_eq!(
397            ShadowStrategy::from_strategy_id(StrategyId::HighPerformance),
398            ShadowStrategy::Standard
399        );
400        assert_eq!(
401            ShadowStrategy::from_strategy_id(StrategyId::Balanced),
402            ShadowStrategy::Medium
403        );
404        assert_eq!(
405            ShadowStrategy::from_strategy_id(StrategyId::LowPower),
406            ShadowStrategy::LowRes
407        );
408    }
409
410    #[test]
411    fn apply_budget_low_power_selects_low_res_lane() {
412        let mut agent = ShadowAgent::default();
413        agent.apply_budget(budget(StrategyId::LowPower));
414        assert_eq!(agent.strategy, ShadowStrategy::LowRes);
415        assert_eq!(agent.strategy.lane_name(), LOW_RES_STRATEGY_NAME);
416        assert_eq!(agent.report_status().current_strategy, StrategyId::LowPower);
417    }
418
419    #[test]
420    fn apply_budget_high_performance_selects_standard_lane() {
421        let mut agent = ShadowAgent::default();
422        agent.apply_budget(budget(StrategyId::HighPerformance));
423        assert_eq!(agent.strategy, ShadowStrategy::Standard);
424        assert_eq!(agent.strategy.lane_name(), STANDARD_STRATEGY_NAME);
425        assert_eq!(
426            agent.report_status().current_strategy,
427            StrategyId::HighPerformance
428        );
429    }
430
431    #[test]
432    fn apply_budget_balanced_selects_medium_lane() {
433        let mut agent = ShadowAgent::default();
434        agent.apply_budget(budget(StrategyId::Balanced));
435        assert_eq!(agent.strategy, ShadowStrategy::Medium);
436        assert_eq!(agent.strategy.lane_name(), MEDIUM_STRATEGY_NAME);
437    }
438
439    #[test]
440    fn registered_lanes_match_strategy_names() {
441        let agent = ShadowAgent::default();
442        assert!(agent.lanes.get(STANDARD_STRATEGY_NAME).is_some());
443        assert!(agent.lanes.get(MEDIUM_STRATEGY_NAME).is_some());
444        assert!(agent.lanes.get(LOW_RES_STRATEGY_NAME).is_some());
445    }
446
447    #[test]
448    fn negotiate_offers_three_distinct_tiers() {
449        let mut agent = ShadowAgent::default();
450        let response = agent.negotiate(NegotiationRequest {
451            target_latency: Duration::from_millis(16),
452            priority_weight: 1.0,
453            constraints: Default::default(),
454            current_mode: khora_core::agent::mode::EngineMode::Playing,
455            agent_timing: agent.execution_timing(),
456        });
457
458        let ids: Vec<StrategyId> = response.strategies.iter().map(|s| s.id).collect();
459        assert!(ids.contains(&StrategyId::HighPerformance));
460        assert!(ids.contains(&StrategyId::Balanced));
461        assert!(ids.contains(&StrategyId::LowPower));
462
463        // VRAM quotes must be strictly decreasing across the tiers — three
464        // genuinely different pipelines, not relabeled copies.
465        let vram = |id: StrategyId| {
466            response
467                .strategies
468                .iter()
469                .find(|s| s.id == id)
470                .map(|s| s.estimated_vram)
471                .unwrap()
472        };
473        assert!(vram(StrategyId::HighPerformance) > vram(StrategyId::Balanced));
474        assert!(vram(StrategyId::Balanced) > vram(StrategyId::LowPower));
475        assert!(
476            vram(StrategyId::LowPower) > 0,
477            "LowRes still has real atlases"
478        );
479    }
480
481    #[test]
482    fn negotiate_under_vram_pressure_drops_expensive_tiers() {
483        let mut agent = ShadowAgent::default();
484        let response = agent.negotiate(NegotiationRequest {
485            target_latency: Duration::from_millis(16),
486            priority_weight: 1.0,
487            constraints: khora_core::control::gorna::ResourceConstraints {
488                // Below Medium's 22 MiB but above LowRes's ~5.5 MiB.
489                max_vram_bytes: Some(8 * 1024 * 1024),
490                ..Default::default()
491            },
492            current_mode: khora_core::agent::mode::EngineMode::Playing,
493            agent_timing: agent.execution_timing(),
494        });
495
496        let ids: Vec<StrategyId> = response.strategies.iter().map(|s| s.id).collect();
497        assert_eq!(ids, vec![StrategyId::LowPower]);
498    }
499}