1use super::mesh_preparation::MeshPreparationSystem;
18use khora_core::lane::{ClearColor, ColorTarget, DepthTarget};
19use khora_core::{
20 agent::Agent,
21 control::gorna::{
22 AgentStatus, NegotiationRequest, NegotiationResponse, ResourceBudget, StrategyOption,
23 },
24 lane::{Lane, LaneContext, LaneKind, LaneRegistry, Slot},
25 math::Mat4,
26 renderer::{
27 api::{
28 resource::ViewInfo,
29 scene::{GpuMesh, RenderObject},
30 },
31 GraphicsDevice, RenderSystem,
32 },
33 EngineContext,
34};
35use khora_data::{
36 assets::Assets,
37 ecs::{Camera, GlobalTransform, World},
38};
39use khora_lanes::render_lane::{
40 ExtractRenderablesLane, ForwardPlusLane, LitForwardLane, RenderWorld, ShadowPassLane,
41 SimpleUnlitLane,
42};
43use std::sync::{Arc, Mutex, RwLock};
44use std::time::{Duration, Instant};
45
46use crossbeam_channel::Sender;
47use khora_core::control::gorna::{AgentId, StrategyId};
48use khora_core::renderer::api::pipeline::enums::PrimitiveTopology;
49use khora_core::renderer::api::pipeline::RenderPipelineId;
50use khora_core::telemetry::event::TelemetryEvent;
51use khora_core::telemetry::monitoring::GpuReport;
52
53const FORWARD_PLUS_LIGHT_THRESHOLD: usize = 20;
56
57const COST_TO_MS_SCALE: f32 = 5.0;
60
61const DEFAULT_VRAM_PER_MESH: u64 = 100 * 1024;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum RenderingStrategy {
67 #[default]
69 Unlit,
70 LitForward,
72 ForwardPlus,
74 Auto,
76}
77
78pub struct RenderAgent {
80 render_world: RenderWorld,
82 gpu_meshes: Arc<RwLock<Assets<GpuMesh>>>,
84 mesh_preparation_system: MeshPreparationSystem,
86 extract_lane: ExtractRenderablesLane,
88 lanes: LaneRegistry,
90 strategy: RenderingStrategy,
92 current_strategy: StrategyId,
94 device: Option<Arc<dyn GraphicsDevice>>,
96 render_system: Option<Arc<Mutex<Box<dyn RenderSystem>>>>,
98 telemetry_sender: Option<Sender<TelemetryEvent>>,
100 last_frame_time: Duration,
103 time_budget: Duration,
105 draw_call_count: u32,
107 triangle_count: u32,
109 frame_count: u64,
111}
112
113impl Agent for RenderAgent {
114 fn id(&self) -> AgentId {
115 AgentId::Renderer
116 }
117
118 fn negotiate(&mut self, request: NegotiationRequest) -> NegotiationResponse {
119 let mut strategies = Vec::new();
120 let mesh_count = self.render_world.meshes.len() as u64;
121 let base_vram = mesh_count * DEFAULT_VRAM_PER_MESH;
122
123 let mut ctx = LaneContext::new();
125 ctx.insert(Slot::new(&mut self.render_world));
126 ctx.insert(self.gpu_meshes.clone());
127
128 for lane in self.lanes.find_by_kind(LaneKind::Render) {
130 let cost = lane.estimate_cost(&ctx);
131 let estimated_time =
132 Duration::from_secs_f32((cost * COST_TO_MS_SCALE).max(0.1) / 1000.0);
133
134 let (strategy_id, vram_overhead) = match lane.strategy_name() {
135 "SimpleUnlit" => (StrategyId::LowPower, 0u64),
136 "LitForward" => {
137 (StrategyId::Balanced, mesh_count * 512 + 4096)
139 }
140 "ForwardPlus" => {
141 (
143 StrategyId::HighPerformance,
144 mesh_count * 512 + 4096 + 8 * 1024 * 1024,
145 )
146 }
147 _ => continue,
148 };
149
150 let estimated_vram = base_vram + vram_overhead;
151
152 if let Some(max_vram) = request.constraints.max_vram_bytes {
154 if estimated_vram > max_vram {
155 continue;
156 }
157 }
158
159 strategies.push(StrategyOption {
160 id: strategy_id,
161 estimated_time,
162 estimated_vram,
163 });
164 }
165
166 if strategies.is_empty() {
168 strategies.push(StrategyOption {
169 id: StrategyId::LowPower,
170 estimated_time: Duration::from_millis(1),
171 estimated_vram: base_vram,
172 });
173 }
174
175 NegotiationResponse { strategies }
176 }
177
178 fn apply_budget(&mut self, budget: ResourceBudget) {
179 log::info!(
180 "RenderAgent: Strategy update to {:?} (time_limit={:?})",
181 budget.strategy_id,
182 budget.time_limit,
183 );
184
185 match budget.strategy_id {
191 StrategyId::LowPower => {
192 self.strategy = RenderingStrategy::Auto;
193 }
194 StrategyId::Balanced => {
195 self.strategy = RenderingStrategy::LitForward;
196 }
197 StrategyId::HighPerformance => {
198 self.strategy = RenderingStrategy::ForwardPlus;
199 }
200 StrategyId::Custom(_) => {
201 log::warn!(
202 "RenderAgent received unsupported custom strategy. Falling back to Balanced."
203 );
204 self.strategy = RenderingStrategy::LitForward;
205 }
206 }
207
208 self.current_strategy = budget.strategy_id;
209 self.time_budget = budget.time_limit;
210 }
211
212 fn update(&mut self, context: &mut EngineContext<'_>) {
213 if self.device.is_none() {
215 if let Some(device_arc) = context.services.get::<Arc<dyn GraphicsDevice>>() {
216 self.device = Some(device_arc.clone());
217
218 let mut init_ctx = LaneContext::new();
220 init_ctx.insert(device_arc.clone());
221 for lane in self.lanes.all() {
222 if let Err(e) = lane.on_initialize(&mut init_ctx) {
223 log::error!("Failed to initialize lane {}: {}", lane.strategy_name(), e);
224 }
225 }
226 }
227 }
228
229 if self.render_system.is_none() {
231 if let Some(rs) = context.services.get::<Arc<Mutex<Box<dyn RenderSystem>>>>() {
232 self.render_system = Some(rs.clone());
233 }
234 }
235
236 let Some(device) = self.device.clone() else {
237 return;
238 };
239
240 if let Some(world_any) = context.world.as_deref_mut() {
242 if let Some(world) = world_any.downcast_mut::<World>() {
243 self.prepare_frame(world, device.as_ref());
245
246 let view_info = self.extract_camera_view(world);
248 if let Some(rs) = &self.render_system {
249 if let Ok(mut rs) = rs.lock() {
250 rs.prepare_frame(&view_info);
251 }
252 }
253 }
254 }
255
256 if let Some(rs) = self.render_system.clone() {
262 if let Ok(mut rs) = rs.lock() {
263 let clear_color = khora_core::math::LinearRgba::new(0.1, 0.1, 0.15, 1.0);
264 let selected_name = self.select_lane_name();
265
266 let render_world = &mut self.render_world;
267 let gpu_meshes = &self.gpu_meshes;
268 let lanes = &self.lanes;
269
270 let frame_start = Instant::now();
271
272 match rs.render_with_encoder(
273 clear_color,
274 Box::new(|encoder, render_ctx| {
275 let mut ctx = LaneContext::new();
276 ctx.insert(device.clone());
277 ctx.insert(gpu_meshes.clone());
278 let encoder_slot = Slot::new(encoder);
283 ctx.insert(unsafe {
284 std::mem::transmute::<
285 Slot<dyn khora_core::renderer::traits::CommandEncoder>,
286 Slot<dyn khora_core::renderer::traits::CommandEncoder>,
287 >(encoder_slot)
288 });
289 ctx.insert(Slot::new(render_world));
290 ctx.insert(ColorTarget(*render_ctx.color_target));
291 if let Some(dt) = render_ctx.depth_target {
292 ctx.insert(DepthTarget(*dt));
293 }
294 ctx.insert(ClearColor(render_ctx.clear_color));
295
296 for shadow_lane in lanes.find_by_kind(LaneKind::Shadow) {
298 if let Err(e) = shadow_lane.execute(&mut ctx) {
299 log::error!(
300 "Shadow lane {} failed: {}",
301 shadow_lane.strategy_name(),
302 e
303 );
304 }
305 }
306
307 if let Some(lane) = lanes.get(selected_name) {
309 if let Err(e) = lane.execute(&mut ctx) {
310 log::error!("Render lane {} failed: {}", lane.strategy_name(), e);
311 }
312 }
313 }),
314 ) {
315 Ok(_stats) => {
316 log::trace!("RenderAgent: Frame rendered successfully.");
317 }
318 Err(e) => log::error!("RenderAgent: Render error: {}", e),
319 }
320
321 self.last_frame_time = frame_start.elapsed();
322 }
323 }
324
325 self.draw_call_count = self.render_world.meshes.len() as u32;
327 self.triangle_count = self.count_triangles();
328 self.frame_count += 1;
329
330 self.emit_telemetry();
332 }
333
334 fn report_status(&self) -> AgentStatus {
335 let health_score = if self.time_budget.is_zero() || self.frame_count == 0 {
336 1.0
338 } else {
339 let ratio =
342 self.time_budget.as_secs_f32() / self.last_frame_time.as_secs_f32().max(0.0001);
343 ratio.min(1.0)
344 };
345
346 let total_lights = self.render_world.directional_light_count()
347 + self.render_world.point_light_count()
348 + self.render_world.spot_light_count();
349
350 AgentStatus {
351 agent_id: self.id(),
352 health_score,
353 current_strategy: self.current_strategy,
354 is_stalled: self.frame_count == 0 && self.device.is_some(),
355 message: format!(
356 "frame_time={:.2}ms draws={} tris={} lights={}",
357 self.last_frame_time.as_secs_f32() * 1000.0,
358 self.draw_call_count,
359 self.triangle_count,
360 total_lights,
361 ),
362 }
363 }
364
365 fn execute(&mut self) {
366 }
369
370 fn as_any(&self) -> &dyn std::any::Any {
371 self
372 }
373
374 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
375 self
376 }
377}
378
379impl RenderAgent {
380 pub fn new() -> Self {
382 let gpu_meshes = Arc::new(RwLock::new(Assets::new()));
383
384 let mut lanes = LaneRegistry::new();
386 lanes.register(Box::new(SimpleUnlitLane::new()));
387 lanes.register(Box::new(LitForwardLane::new()));
388 lanes.register(Box::new(ForwardPlusLane::new()));
389 lanes.register(Box::new(ShadowPassLane::new()));
390
391 Self {
392 render_world: RenderWorld::new(),
393 gpu_meshes: gpu_meshes.clone(),
394 mesh_preparation_system: MeshPreparationSystem::new(gpu_meshes),
395 extract_lane: ExtractRenderablesLane::new(),
396 lanes,
397 strategy: RenderingStrategy::Auto,
398 current_strategy: StrategyId::Balanced,
399 device: None,
400 render_system: None,
401 telemetry_sender: None,
402 last_frame_time: Duration::ZERO,
403 time_budget: Duration::ZERO,
404 draw_call_count: 0,
405 triangle_count: 0,
406 frame_count: 0,
407 }
408 }
409
410 pub fn render(
415 &mut self,
416 encoder: &mut dyn khora_core::renderer::traits::CommandEncoder,
417 render_ctx: &khora_core::renderer::api::core::RenderContext,
418 ) {
419 let frame_start = Instant::now();
420
421 let Some(device) = self.device.clone() else {
422 return;
423 };
424
425 self.draw_call_count = self.render_world.meshes.len() as u32;
426 self.triangle_count = self.count_triangles();
427
428 let mut ctx = LaneContext::new();
430 ctx.insert(device);
431 ctx.insert(self.gpu_meshes.clone());
432 let encoder_slot = Slot::new(encoder);
436 ctx.insert(unsafe {
437 std::mem::transmute::<
438 Slot<dyn khora_core::renderer::traits::CommandEncoder>,
439 Slot<dyn khora_core::renderer::traits::CommandEncoder>,
440 >(encoder_slot)
441 });
442 ctx.insert(Slot::new(&mut self.render_world));
443 ctx.insert(ColorTarget(*render_ctx.color_target));
444 if let Some(dt) = render_ctx.depth_target {
445 ctx.insert(DepthTarget(*dt));
446 }
447 ctx.insert(ClearColor(render_ctx.clear_color));
448
449 for shadow_lane in self.lanes.find_by_kind(LaneKind::Shadow) {
451 if let Err(e) = shadow_lane.execute(&mut ctx) {
452 log::error!("Shadow lane {} failed: {}", shadow_lane.strategy_name(), e);
453 }
454 }
455
456 let selected_name = self.select_lane_name();
458 if let Some(lane) = self.lanes.get(selected_name) {
459 if let Err(e) = lane.execute(&mut ctx) {
460 log::error!("Render lane {} failed: {}", lane.strategy_name(), e);
461 }
462 }
463
464 self.last_frame_time = frame_start.elapsed();
465 self.frame_count += 1;
466 }
467
468 pub fn with_strategy(strategy: RenderingStrategy) -> Self {
470 let mut agent = Self::new();
471 agent.strategy = strategy;
472 agent
473 }
474
475 pub fn with_telemetry_sender(mut self, sender: Sender<TelemetryEvent>) -> Self {
477 self.telemetry_sender = Some(sender);
478 self
479 }
480
481 pub fn add_lane(&mut self, lane: Box<dyn Lane>) {
483 self.lanes.register(lane);
484 }
485
486 pub fn set_strategy(&mut self, strategy: RenderingStrategy) {
488 self.strategy = strategy;
489 }
490
491 pub fn strategy(&self) -> RenderingStrategy {
493 self.strategy
494 }
495
496 pub fn lanes(&self) -> &LaneRegistry {
498 &self.lanes
499 }
500
501 fn select_lane_name(&self) -> &'static str {
503 match self.strategy {
504 RenderingStrategy::Unlit => "SimpleUnlit",
505 RenderingStrategy::LitForward => "LitForward",
506 RenderingStrategy::ForwardPlus => "ForwardPlus",
507 RenderingStrategy::Auto => {
508 let total_lights = self.render_world.directional_light_count()
509 + self.render_world.point_light_count()
510 + self.render_world.spot_light_count();
511
512 if total_lights > FORWARD_PLUS_LIGHT_THRESHOLD {
513 "ForwardPlus"
514 } else if total_lights > 0 {
515 "LitForward"
516 } else {
517 "SimpleUnlit"
518 }
519 }
520 }
521 }
522
523 pub fn select_lane(&self) -> &dyn Lane {
525 let name = self.select_lane_name();
526 self.lanes.get(name).unwrap_or_else(|| {
527 self.lanes
528 .find_by_kind(LaneKind::Render)
529 .first()
530 .copied()
531 .expect("RenderAgent has no render lanes configured")
532 })
533 }
534
535 pub fn prepare_frame(&mut self, world: &mut World, graphics_device: &dyn GraphicsDevice) {
541 log::trace!("RenderAgent: prepare_frame called");
542
543 self.mesh_preparation_system.run(world, graphics_device);
544
545 log::trace!("RenderAgent: Running extract_lane");
546 self.extract_lane.run(world, &mut self.render_world);
547 log::trace!(
548 "RenderAgent: Extracted {} meshes, {} views",
549 self.render_world.meshes.len(),
550 self.render_world.views.len()
551 );
552 }
553
554 pub fn produce_render_objects(&self) -> Vec<RenderObject> {
563 let mut render_objects = Vec::with_capacity(self.render_world.meshes.len());
564 let gpu_meshes_guard = self.gpu_meshes.read().unwrap();
565
566 let selected = self.select_lane();
568 let get_pipeline = |material: Option<
569 &khora_core::asset::AssetHandle<Box<dyn khora_core::asset::Material>>,
570 >|
571 -> RenderPipelineId {
572 if let Some(lane) = selected.as_any().downcast_ref::<SimpleUnlitLane>() {
573 return lane.get_pipeline_for_material(material);
574 }
575 if let Some(lane) = selected.as_any().downcast_ref::<LitForwardLane>() {
576 return lane.get_pipeline_for_material(material);
577 }
578 if let Some(lane) = selected.as_any().downcast_ref::<ForwardPlusLane>() {
579 return lane.get_pipeline_for_material(material);
580 }
581 RenderPipelineId(0)
582 };
583
584 for extracted_mesh in &self.render_world.meshes {
585 if let Some(gpu_mesh_handle) = gpu_meshes_guard.get(&extracted_mesh.cpu_mesh_uuid) {
586 let pipeline = get_pipeline(extracted_mesh.material.as_ref());
587
588 render_objects.push(RenderObject {
589 pipeline,
590 vertex_buffer: gpu_mesh_handle.vertex_buffer,
591 index_buffer: gpu_mesh_handle.index_buffer,
592 index_count: gpu_mesh_handle.index_count,
593 });
594 }
595 }
596
597 render_objects
598 }
599
600 pub fn extract_camera_view(&self, world: &World) -> ViewInfo {
615 let query = world.query::<(&Camera, &GlobalTransform)>();
617 let cameras: Vec<_> = query.collect();
618 log::trace!("Found {} cameras in scene", cameras.len());
619
620 for (camera, global_transform) in cameras {
622 log::trace!("Checking camera: is_active={}", camera.is_active);
623 if camera.is_active {
624 let camera_position = global_transform.0.translation();
626
627 let view_matrix = if let Some(inv) = global_transform.to_matrix().inverse() {
630 inv
631 } else {
632 log::warn!("Failed to invert camera transform, using identity");
633 Mat4::IDENTITY
634 };
635
636 let projection_matrix = camera.projection_matrix();
638
639 log::trace!("Camera extracted at position: {:?}", camera_position);
640 return ViewInfo::new(view_matrix, projection_matrix, camera_position);
641 }
642 }
643
644 log::warn!("No active camera found in scene, using default ViewInfo");
646 ViewInfo::default()
647 }
648
649 fn emit_telemetry(&self) {
651 if let Some(sender) = &self.telemetry_sender {
652 let report = GpuReport {
653 frame_number: self.frame_count,
654 draw_calls: self.draw_call_count,
655 triangles_rendered: self.triangle_count,
656 ..Default::default()
657 };
658 let _ = sender.send(TelemetryEvent::GpuReport(report));
659 }
660 }
661
662 fn count_triangles(&self) -> u32 {
664 let gpu_meshes_guard = match self.gpu_meshes.read() {
665 Ok(guard) => guard,
666 Err(_) => return 0,
667 };
668 let mut total = 0u32;
669 for mesh in &self.render_world.meshes {
670 if let Some(gpu_mesh) = gpu_meshes_guard.get(&mesh.cpu_mesh_uuid) {
671 total += match gpu_mesh.primitive_topology {
672 PrimitiveTopology::TriangleList => gpu_mesh.index_count / 3,
673 PrimitiveTopology::TriangleStrip => gpu_mesh.index_count.saturating_sub(2),
674 _ => 0,
675 };
676 }
677 }
678 total
679 }
680
681 pub fn last_frame_time(&self) -> Duration {
683 self.last_frame_time
684 }
685
686 pub fn frame_count(&self) -> u64 {
688 self.frame_count
689 }
690
691 pub fn current_strategy_id(&self) -> StrategyId {
693 self.current_strategy
694 }
695
696 pub fn render_world(&self) -> &RenderWorld {
698 &self.render_world
699 }
700
701 pub fn render_world_mut(&mut self) -> &mut RenderWorld {
703 &mut self.render_world
704 }
705
706 pub fn gpu_meshes(&self) -> &Arc<RwLock<Assets<GpuMesh>>> {
708 &self.gpu_meshes
709 }
710}
711
712impl Default for RenderAgent {
713 fn default() -> Self {
714 Self::new()
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use khora_core::control::gorna::{NegotiationRequest, ResourceConstraints, StrategyId};
722 use khora_core::math::{Mat4, Vec3};
723 use khora_core::renderer::light::{LightType, PointLight};
724 use khora_lanes::render_lane::ExtractedLight;
725 use std::time::Duration;
726
727 fn dummy_light(light_type: LightType) -> ExtractedLight {
728 ExtractedLight {
729 light_type,
730 position: Vec3::ZERO,
731 direction: Vec3::ZERO,
732 shadow_view_proj: Mat4::IDENTITY,
733 shadow_atlas_index: None,
734 }
735 }
736
737 #[test]
738 fn test_strategy_selection_auto() {
739 let mut agent = RenderAgent::with_strategy(RenderingStrategy::Auto);
740
741 assert_eq!(agent.select_lane_name(), "SimpleUnlit");
743
744 agent
746 .render_world_mut()
747 .lights
748 .push(dummy_light(LightType::Point(PointLight::default())));
749 assert_eq!(agent.select_lane_name(), "LitForward");
750
751 for _ in 0..20 {
753 agent
754 .render_world_mut()
755 .lights
756 .push(dummy_light(LightType::Point(PointLight::default())));
757 }
758 assert_eq!(agent.select_lane_name(), "ForwardPlus");
759 }
760
761 #[test]
762 fn test_negotiation_vram_limits() {
763 let mut agent = RenderAgent::new();
764
765 let req_unconstrained = NegotiationRequest {
767 target_latency: Duration::from_millis(16),
768 priority_weight: 1.0,
769 constraints: ResourceConstraints::default(),
770 };
771 let res = agent.negotiate(req_unconstrained);
772 assert_eq!(res.strategies.len(), 3);
773
774 let req_constrained = NegotiationRequest {
776 target_latency: Duration::from_millis(16),
777 priority_weight: 1.0,
778 constraints: ResourceConstraints {
779 max_vram_bytes: Some(10),
780 ..Default::default()
781 },
782 };
783 let res2 = agent.negotiate(req_constrained);
784 assert_eq!(res2.strategies.len(), 1);
785 assert_eq!(res2.strategies[0].id, StrategyId::LowPower);
786 }
787
788 #[test]
789 fn test_report_status_health() {
790 let mut agent = RenderAgent::new();
791 let status = agent.report_status();
793 assert_eq!(status.health_score, 1.0);
794
795 agent.frame_count = 1;
797 agent.time_budget = Duration::from_millis(10);
798 agent.last_frame_time = Duration::from_millis(20);
799 let status = agent.report_status();
800 assert_eq!(status.health_score, 0.5); agent.last_frame_time = Duration::from_millis(10);
804 let status = agent.report_status();
805 assert_eq!(status.health_score, 1.0);
806
807 agent.last_frame_time = Duration::from_millis(5);
809 let status = agent.report_status();
810 assert_eq!(status.health_score, 1.0); }
812}