khora_agents/shadow_agent/
agent.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ShadowStrategy {
62 Standard,
65 Medium,
68 LowRes,
71}
72
73impl ShadowStrategy {
74 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 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
96pub struct ShadowAgent {
102 lanes: LaneRegistry,
104 strategy: ShadowStrategy,
106 time_budget: Duration,
108 current_strategy: StrategyId,
110 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 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 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 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 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 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 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 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 ctx.insert(Slot::new(&mut *context.deck));
277
278 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 }
304 let _ = frame_ctx; 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 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 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 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}