khora_agents/render_agent/
agent.rs1use std::sync::{Arc, Mutex, RwLock};
24use std::time::Duration;
25
26use khora_core::agent::{
27 Agent, AgentAccess, AgentDependency, AgentImportance, DependencyKind, ExecutionPhase,
28 ExecutionTiming,
29};
30use khora_core::control::gorna::{
31 measured_frame_time_ms, AgentFrameStatusMap, AgentId, AgentStatus, NegotiationRequest,
32 NegotiationResponse, ResourceBudget, StrategyId, StrategyOption,
33};
34use khora_core::lane::{
35 ClearColor, ColorTarget, DepthTarget, LaneContext, LaneKind, LaneRegistry, ShadowAtlasView,
36 ShadowComparisonSampler, Slot,
37};
38use khora_core::renderer::api::core::FrameContext;
39use khora_core::renderer::api::scene::GpuMesh;
40use khora_core::renderer::traits::PipelineSystem;
41use khora_core::renderer::{GraphicsDevice, RenderSystem};
42use khora_core::EngineContext;
43use khora_data::assets::Assets;
44use khora_data::ecs::World;
45use khora_data::render::{
46 extract_active_camera_view, PassContribution, PassDescriptor, RenderWorld, ResourceId,
47 ScenePassSlot,
48};
49use khora_data::AssetStore;
50use khora_lanes::render_lane::{ForwardPlusLane, LitForwardLane, SimpleUnlitLane, StandardPbrLane};
51
52const FORWARD_PLUS_LIGHT_THRESHOLD: usize = 20;
54
55const COST_TO_MS_SCALE: f32 = 5.0;
57
58const DEFAULT_VRAM_PER_MESH: u64 = 100 * 1024;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum RenderingStrategy {
64 #[default]
66 Unlit,
67 LitForward,
69 StandardPbr,
71 ForwardPlus,
73 Auto,
75}
76
77pub struct RenderAgent {
83 lanes: LaneRegistry,
85 strategy: RenderingStrategy,
87 current_strategy: StrategyId,
89 time_budget: Duration,
91 frame_status: Option<AgentFrameStatusMap>,
95}
96
97impl Agent for RenderAgent {
98 fn id(&self) -> AgentId {
99 AgentId::Renderer
100 }
101
102 fn access(&self) -> AgentAccess {
108 AgentAccess::SharedWorld
109 }
110
111 fn deck_writes(&self) -> Vec<std::any::TypeId> {
113 vec![std::any::TypeId::of::<ScenePassSlot>()]
114 }
115
116 fn negotiate(&mut self, request: NegotiationRequest) -> NegotiationResponse {
117 let mut strategies = Vec::new();
118 let mut stub_world = RenderWorld::new();
122 let mut ctx = LaneContext::new();
123 ctx.insert(Slot::new(&mut stub_world));
124
125 for lane in self.lanes.find_by_kind(LaneKind::Render) {
126 let cost = lane.estimate_cost(&ctx);
127 let estimated_time =
128 Duration::from_secs_f32((cost * COST_TO_MS_SCALE).max(0.1) / 1000.0);
129
130 let (strategy_id, vram_overhead) = match lane.strategy_name() {
131 "SimpleUnlit" => (StrategyId::LowPower, 0u64),
132 "LitForward" => (StrategyId::Balanced, 4096u64),
133 "StandardPbr" => (StrategyId::Custom(1), 4096u64),
134 "ForwardPlus" => (StrategyId::HighPerformance, 4096 + 8 * 1024 * 1024),
135 _ => continue,
136 };
137
138 let estimated_vram = vram_overhead;
142
143 if let Some(max_vram) = request.constraints.max_vram_bytes {
144 if estimated_vram > max_vram {
145 continue;
146 }
147 }
148
149 strategies.push(StrategyOption {
150 id: strategy_id,
151 estimated_time,
152 estimated_vram,
153 });
154 }
155
156 if strategies.is_empty() {
157 strategies.push(StrategyOption {
158 id: StrategyId::LowPower,
159 estimated_time: Duration::from_millis(1),
160 estimated_vram: 0,
161 });
162 }
163
164 let _ = DEFAULT_VRAM_PER_MESH;
168
169 NegotiationResponse {
170 strategies,
171 timing_adjustment: None,
172 }
173 }
174
175 fn apply_budget(&mut self, budget: ResourceBudget) {
176 log::info!(
177 "RenderAgent: Strategy update to {:?} (time_limit={:?})",
178 budget.strategy_id,
179 budget.time_limit,
180 );
181
182 match budget.strategy_id {
183 StrategyId::LowPower => self.strategy = RenderingStrategy::Auto,
184 StrategyId::Balanced => self.strategy = RenderingStrategy::LitForward,
185 StrategyId::HighPerformance => self.strategy = RenderingStrategy::ForwardPlus,
186 StrategyId::Custom(1) => self.strategy = RenderingStrategy::StandardPbr,
187 StrategyId::Custom(other) => {
188 log::warn!(
189 "RenderAgent received unsupported custom strategy {other}. Falling back to Balanced."
190 );
191 self.strategy = RenderingStrategy::LitForward;
192 }
193 }
194
195 self.current_strategy = budget.strategy_id;
196 self.time_budget = budget.time_limit;
197 }
198
199 fn on_initialize(&mut self, context: &mut EngineContext<'_>) {
200 self.frame_status = context
201 .runtime
202 .resources
203 .get::<AgentFrameStatusMap>()
204 .cloned();
205
206 let Some(device_arc) = context
210 .runtime
211 .backends
212 .get::<Arc<dyn GraphicsDevice>>()
213 .cloned()
214 else {
215 log::warn!("RenderAgent: graphics device unavailable in on_initialize");
216 return;
217 };
218
219 let pipeline_system = context
224 .runtime
225 .resources
226 .get::<Arc<dyn PipelineSystem>>()
227 .cloned();
228
229 let mut init_ctx = LaneContext::new();
230 init_ctx.insert(device_arc);
231 if let Some(ps) = pipeline_system {
232 init_ctx.insert(ps);
233 }
234 for lane in self.lanes.all() {
235 if let Err(e) = lane.on_initialize(&mut init_ctx) {
236 log::error!(
237 "RenderAgent: Failed to initialize lane {}: {}",
238 lane.strategy_name(),
239 e
240 );
241 }
242 }
243 }
244
245 fn execute(&mut self, context: &mut EngineContext<'_>) {
246 let Some(device_arc) = context.runtime.backends.get::<Arc<dyn GraphicsDevice>>() else {
248 return;
249 };
250 let device: Arc<dyn GraphicsDevice> = (*device_arc).clone();
251
252 let Some(rs_arc) = context
253 .runtime
254 .backends
255 .get::<Arc<Mutex<Box<dyn RenderSystem>>>>()
256 else {
257 return;
258 };
259 let render_system: Arc<Mutex<Box<dyn RenderSystem>>> = (*rs_arc).clone();
260
261 let Some(asset_store) = context.runtime.resources.get::<AssetStore>() else {
262 return;
263 };
264 let gpu_meshes: Arc<RwLock<Assets<GpuMesh>>> = asset_store.store::<GpuMesh>();
265
266 let pipeline_system = context
269 .runtime
270 .resources
271 .get::<Arc<dyn PipelineSystem>>()
272 .cloned();
273
274 let ibl_bindings = context
278 .runtime
279 .resources
280 .get::<khora_data::IblBaker>()
281 .and_then(|b| b.bindings());
282
283 let Some(render_world): Option<&RenderWorld> = context.bus.get() else {
286 log::warn!("RenderAgent: no RenderWorld in LaneBus (RenderFlow not run?)");
287 return;
288 };
289
290 let Some(fctx) = context.runtime.resources.get::<Arc<FrameContext>>() else {
295 log::warn!("RenderAgent: no FrameContext in services");
296 return;
297 };
298 let Some(color_target) = fctx.get::<ColorTarget>().map(|a| *a) else {
299 log::warn!("RenderAgent: ColorTarget missing in FrameContext");
300 return;
301 };
302 let depth_target = fctx.get::<DepthTarget>().map(|a| *a);
303 let clear_color = fctx
304 .get::<ClearColor>()
305 .map(|a| *a)
306 .unwrap_or_else(|| ClearColor(khora_core::math::LinearRgba::new(0.1, 0.1, 0.15, 1.0)));
307 let shadow_atlas = fctx.get::<ShadowAtlasView>().map(|a| *a);
308 let shadow_sampler = fctx.get::<ShadowComparisonSampler>().map(|a| *a);
309
310 if let Some(world_any) = context.world_ref() {
314 if let Some(world) = world_any.downcast_ref::<World>() {
315 if let Some(view_info) = extract_active_camera_view(world) {
316 if let Ok(mut rs) = render_system.lock() {
317 rs.prepare_frame(&view_info);
318 }
319 }
320 }
321 }
322
323 let strategy = self.strategy;
324 let select_name = lane_name_for_strategy(strategy, render_world);
325
326 let mut encoder = device.create_command_encoder(Some("Khora Scene Encoder"));
329 {
330 let mut ctx = LaneContext::new();
331 ctx.insert(device.clone());
332 ctx.insert(gpu_meshes.clone());
333 if let Some(ps) = pipeline_system.clone() {
334 ctx.insert(ps);
335 }
336 let encoder_slot = Slot::new(encoder.as_mut());
339 ctx.insert(unsafe {
340 std::mem::transmute::<
341 Slot<dyn khora_core::renderer::traits::CommandEncoder>,
342 Slot<dyn khora_core::renderer::traits::CommandEncoder>,
343 >(encoder_slot)
344 });
345 ctx.insert(khora_core::lane::Ref::new(render_world));
349 ctx.insert(Slot::new(&mut *context.deck));
354 ctx.insert(color_target);
355 if let Some(dt) = depth_target {
356 ctx.insert(dt);
357 }
358 ctx.insert(clear_color);
359 if let Some(ibl) = ibl_bindings {
360 ctx.insert(ibl);
361 }
362 if let Some(view) = shadow_atlas {
363 ctx.insert(view);
364 }
365 if let Some(sampler) = shadow_sampler {
366 ctx.insert(sampler);
367 }
368
369 if let Some(lane) = self.lanes.get(select_name) {
370 if let Err(e) = lane.execute(&mut ctx) {
371 log::error!("Render lane {} failed: {}", lane.strategy_name(), e);
372 }
373 }
374 }
375 let Some(cmd_buf) = encoder.finish() else {
376 log::error!(
377 "RenderAgent: encoder.finish() returned None — backend reported failure, \
378 skipping ScenePass submission"
379 );
380 return;
381 };
382
383 let mut descriptor = PassDescriptor::new("ScenePass")
384 .writes(ResourceId::Color)
385 .writes(ResourceId::Depth);
386 if shadow_atlas.is_some() {
387 descriptor = descriptor.reads(ResourceId::ShadowAtlas);
388 }
389 context.deck.slot::<ScenePassSlot>().0 = Some(PassContribution {
393 descriptor,
394 command_buffer: cmd_buf,
395 });
396 }
397
398 fn report_status(&self) -> AgentStatus {
399 let measured_time_ms = measured_frame_time_ms(&self.frame_status, self.id());
400 let health_score = if self.time_budget.is_zero() || measured_time_ms <= 0.0 {
401 1.0
402 } else {
403 (self.time_budget.as_secs_f32() * 1000.0 / measured_time_ms).min(1.0)
404 };
405
406 AgentStatus {
407 agent_id: self.id(),
408 health_score,
409 current_strategy: self.current_strategy,
410 is_stalled: false,
411 message: format!("frame_time={measured_time_ms:.2}ms"),
412 }
413 }
414
415 fn as_any(&self) -> &dyn std::any::Any {
416 self
417 }
418
419 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
420 self
421 }
422
423 fn execution_timing(&self) -> ExecutionTiming {
424 ExecutionTiming {
425 allowed_phases: vec![ExecutionPhase::OUTPUT],
426 default_phase: ExecutionPhase::OUTPUT,
427 priority: 1.0,
428 importance: AgentImportance::Critical,
429 fixed_timestep: None,
430 dependencies: vec![AgentDependency {
434 target: AgentId::ShadowRenderer,
435 kind: DependencyKind::Hard,
436 condition: None,
437 }],
438 }
439 }
440}
441
442impl Default for RenderAgent {
443 fn default() -> Self {
444 let mut lanes = LaneRegistry::new();
445 lanes.register(Box::new(SimpleUnlitLane::new()));
446 lanes.register(Box::new(LitForwardLane::new()));
447 lanes.register(Box::new(StandardPbrLane::default()));
448 lanes.register(Box::new(ForwardPlusLane::new()));
449
450 Self {
451 lanes,
452 strategy: RenderingStrategy::Auto,
453 current_strategy: StrategyId::Balanced,
454 time_budget: Duration::ZERO,
455 frame_status: None,
456 }
457 }
458}
459
460fn lane_name_for_strategy(strategy: RenderingStrategy, world: &RenderWorld) -> &'static str {
465 match strategy {
466 RenderingStrategy::Unlit => "SimpleUnlit",
467 RenderingStrategy::LitForward => "LitForward",
468 RenderingStrategy::StandardPbr => "StandardPbr",
469 RenderingStrategy::ForwardPlus => "ForwardPlus",
470 RenderingStrategy::Auto => {
471 let total_lights = world.directional_light_count()
472 + world.point_light_count()
473 + world.spot_light_count();
474 if total_lights > FORWARD_PLUS_LIGHT_THRESHOLD {
475 "ForwardPlus"
476 } else if total_lights > 0 {
477 "LitForward"
478 } else {
479 "SimpleUnlit"
480 }
481 }
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use khora_core::agent::{EngineMode, ExecutionTiming};
489 use khora_core::control::gorna::{NegotiationRequest, ResourceConstraints, StrategyId};
490
491 #[test]
492 fn test_negotiate_offers_all_default_strategies() {
493 let mut agent = RenderAgent::default();
494 let req = NegotiationRequest {
495 target_latency: Duration::from_millis(16),
496 priority_weight: 1.0,
497 constraints: ResourceConstraints::default(),
498 current_mode: EngineMode::Playing,
499 agent_timing: ExecutionTiming::default(),
500 };
501 let res = agent.negotiate(req);
502 assert_eq!(res.strategies.len(), 4);
504 }
505
506 #[test]
507 fn test_negotiate_vram_constrained_returns_only_low_power() {
508 let mut agent = RenderAgent::default();
509 let req = NegotiationRequest {
510 target_latency: Duration::from_millis(16),
511 priority_weight: 1.0,
512 constraints: ResourceConstraints {
513 max_vram_bytes: Some(10),
514 ..Default::default()
515 },
516 current_mode: EngineMode::Playing,
517 agent_timing: ExecutionTiming::default(),
518 };
519 let res = agent.negotiate(req);
520 assert_eq!(res.strategies.len(), 1);
521 assert_eq!(res.strategies[0].id, StrategyId::LowPower);
522 }
523
524 #[test]
525 fn test_apply_budget_records_strategy_in_status() {
526 let mut agent = RenderAgent::default();
527 agent.apply_budget(ResourceBudget {
528 strategy_id: StrategyId::HighPerformance,
529 time_limit: Duration::from_millis(12),
530 memory_limit: None,
531 extra_params: std::collections::HashMap::new(),
532 });
533 assert_eq!(
534 agent.report_status().current_strategy,
535 StrategyId::HighPerformance
536 );
537 }
538
539 #[test]
540 fn test_report_status_initial_state() {
541 let agent = RenderAgent::default();
542 let status = agent.report_status();
543 assert_eq!(status.agent_id, AgentId::Renderer);
544 assert_eq!(status.health_score, 1.0);
545 assert!(!status.is_stalled);
546 }
547}