khora_agents/render_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 RenderAgent, the central orchestrator for the rendering subsystem.
16
17use 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
53/// Threshold for switching to Forward+ rendering.
54/// When the scene has more than this many lights, Forward+ is preferred.
55const FORWARD_PLUS_LIGHT_THRESHOLD: usize = 20;
56
57/// Scale factor converting lane cost units to milliseconds of GPU time.
58/// Used by `negotiate()` to provide realistic time estimates to GORNA.
59const COST_TO_MS_SCALE: f32 = 5.0;
60
61/// Approximate VRAM per mesh in bytes (vertex + index buffers).
62const DEFAULT_VRAM_PER_MESH: u64 = 100 * 1024;
63
64/// Rendering strategy selection mode.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum RenderingStrategy {
67    /// Simple unlit rendering (vertex colors only).
68    #[default]
69    Unlit,
70    /// Standard forward rendering with lighting.
71    LitForward,
72    /// Forward+ (tiled forward) rendering with compute-based light culling.
73    ForwardPlus,
74    /// Automatic selection based on scene complexity (light count).
75    Auto,
76}
77
78/// The agent responsible for managing the state and logic of the rendering pipeline.
79pub struct RenderAgent {
80    // Intermediate data structure populated by the extraction phase.
81    render_world: RenderWorld,
82    // Cache for GPU-side mesh assets (shared via Arc with MeshPreparationSystem).
83    gpu_meshes: Arc<RwLock<Assets<GpuMesh>>>,
84    // System that handles uploading CPU meshes to the GPU.
85    mesh_preparation_system: MeshPreparationSystem,
86    // Lane that extracts data from the ECS into the RenderWorld.
87    extract_lane: ExtractRenderablesLane,
88    // All processing lanes (render + shadow) stored generically.
89    lanes: LaneRegistry,
90    // Current rendering strategy selection mode.
91    strategy: RenderingStrategy,
92    // Current active strategy ID from negotiation.
93    current_strategy: StrategyId,
94    // Cached reference to the graphics device for lane lifecycle management.
95    device: Option<Arc<dyn GraphicsDevice>>,
96    // Cached reference to the render system (obtained from ServiceRegistry in update()).
97    render_system: Option<Arc<Mutex<Box<dyn RenderSystem>>>>,
98    // Optional channel to emit telemetry events to the DCC.
99    telemetry_sender: Option<Sender<TelemetryEvent>>,
100    // --- Performance Metrics (for GORNA health reporting) ---
101    // Duration of the last render() call.
102    last_frame_time: Duration,
103    // Time budget assigned by GORNA via apply_budget().
104    time_budget: Duration,
105    // Number of draw calls issued in the last frame.
106    draw_call_count: u32,
107    // Number of triangles rendered in the last frame.
108    triangle_count: u32,
109    // Total number of frames rendered since agent creation.
110    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        // Build a minimal context for cost estimation.
124        let mut ctx = LaneContext::new();
125        ctx.insert(Slot::new(&mut self.render_world));
126        ctx.insert(self.gpu_meshes.clone());
127
128        // Build strategy options from each available render lane using actual cost data.
129        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                    // Uniform buffers: ~512B per mesh + ~4KB global uniforms.
138                    (StrategyId::Balanced, mesh_count * 512 + 4096)
139                }
140                "ForwardPlus" => {
141                    // LitForward overhead + ~8MB compute buffers for light culling.
142                    (
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            // Respect VRAM constraints from the negotiation request.
153            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        // Always guarantee at least the LowPower fallback.
167        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        // Map the GORNA strategy to our internal rendering strategy.
186        // Lanes remain alive — we only switch which one is active.
187        // LowPower maps to Auto — this lets the agent use Unlit when there are
188        // no lights, but automatically switch to LitForward when the scene
189        // contains lights so that shadows and lighting work correctly.
190        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        // Cache the graphics device from the service registry.
214        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                // Initialize all lanes via Lane abstraction.
219                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        // Cache the render system from the service registry.
230        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        // Step 1: Extract scene data from ECS into RenderWorld.
241        if let Some(world_any) = context.world.as_deref_mut() {
242            if let Some(world) = world_any.downcast_mut::<World>() {
243                // Access the CPU mesh assets and material assets.
244                self.prepare_frame(world, device.as_ref());
245
246                // Step 2: Extract camera view and push to RenderSystem.
247                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        // Step 3: Render — call render_with_encoder on the cached render system.
257        //
258        // The closure builds a LaneContext and executes lanes through the Lane abstraction:
259        //   1. Shadow lanes: encode depth-only draw calls, patch lights, store shadow resources
260        //   2. Main pass: render scene with shadow data
261        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                        // SAFETY: encoder lives for the entirety of this closure.
279                        // ctx is created and consumed within the same closure scope.
280                        // transmute erases the trait object lifetime ('1 → 'static)
281                        // which is safe because the data outlives the Slot.
282                        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                        // 1. Execute shadow lanes (they insert ShadowAtlasView + ShadowComparisonSampler)
297                        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                        // 2. Execute selected render lane
308                        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        // Update frame metrics.
326        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        // Emit telemetry to the DCC if a sender is wired.
331        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            // No budget assigned yet or no frames rendered — report healthy.
337            1.0
338        } else {
339            // Health = how well we fit within the GORNA time budget.
340            // 1.0 = at or under budget, <1.0 = over budget.
341            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        // RenderAgent doesn't do anything in generic execute()
367        // Use render() method for actual rendering with encoder
368    }
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    /// Creates a new `RenderAgent` with default lanes and automatic strategy selection.
381    pub fn new() -> Self {
382        let gpu_meshes = Arc::new(RwLock::new(Assets::new()));
383
384        // Register all default lanes (render + shadow) generically.
385        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    /// Renders the scene using the provided encoder and render context.
411    ///
412    /// This is the main rendering method that encodes GPU commands via the selected lane.
413    /// Shadow lanes are executed first, followed by the selected render lane.
414    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        // Build LaneContext with all required data.
429        let mut ctx = LaneContext::new();
430        ctx.insert(device);
431        ctx.insert(self.gpu_meshes.clone());
432        // SAFETY: encoder lives for the entirety of this method call.
433        // ctx is stack-scoped and dropped before returning.
434        // transmute erases the trait object lifetime ('a → 'static).
435        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        // 1. Execute shadow lanes (they insert ShadowAtlasView + ShadowComparisonSampler)
450        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        // 2. Execute selected render lane
457        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    /// Creates a new `RenderAgent` with the specified rendering strategy.
469    pub fn with_strategy(strategy: RenderingStrategy) -> Self {
470        let mut agent = Self::new();
471        agent.strategy = strategy;
472        agent
473    }
474
475    /// Attaches a telemetry sender so the agent can emit `GpuReport` events to the DCC.
476    pub fn with_telemetry_sender(mut self, sender: Sender<TelemetryEvent>) -> Self {
477        self.telemetry_sender = Some(sender);
478        self
479    }
480
481    /// Adds a custom lane to the registry.
482    pub fn add_lane(&mut self, lane: Box<dyn Lane>) {
483        self.lanes.register(lane);
484    }
485
486    /// Sets the rendering strategy.
487    pub fn set_strategy(&mut self, strategy: RenderingStrategy) {
488        self.strategy = strategy;
489    }
490
491    /// Returns the current rendering strategy.
492    pub fn strategy(&self) -> RenderingStrategy {
493        self.strategy
494    }
495
496    /// Returns a reference to the lane registry.
497    pub fn lanes(&self) -> &LaneRegistry {
498        &self.lanes
499    }
500
501    /// Returns the strategy name of the currently selected render lane.
502    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    /// Selects the appropriate render lane based on the current strategy.
524    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    /// Prepares all rendering data for the current frame.
536    ///
537    /// This method runs the entire Control Plane logic for rendering:
538    /// 1. Prepares GPU resources for any newly loaded meshes.
539    /// 2. Extracts all visible objects from the ECS into the internal `RenderWorld`.
540    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    /// Translates the prepared data from the `RenderWorld` into a list of `RenderObject`s.
555    ///
556    /// This method should be called after `prepare_frame`. It reads the intermediate
557    /// `RenderWorld` and produces the final, low-level data structure required by
558    /// the `RenderSystem`.
559    ///
560    /// Uses the selected lane's domain-specific pipeline selection if available,
561    /// otherwise falls back to a default pipeline.
562    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        // Downcast to concrete lane types to get pipeline selection.
567        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    /// Extracts the active camera from the ECS world and generates a `ViewInfo`.
601    ///
602    /// This method queries the ECS for entities with both a `Camera` and `GlobalTransform`
603    /// component, finds the first active camera, and constructs a ViewInfo containing
604    /// the camera's view and projection matrices.
605    ///
606    /// # Arguments
607    ///
608    /// * `world`: The ECS world containing camera entities
609    ///
610    /// # Returns
611    ///
612    /// A `ViewInfo` containing the camera's matrices and position. If no active camera
613    /// is found, returns a default ViewInfo with identity matrices.
614    pub fn extract_camera_view(&self, world: &World) -> ViewInfo {
615        // Query for entities with Camera and GlobalTransform components
616        let query = world.query::<(&Camera, &GlobalTransform)>();
617        let cameras: Vec<_> = query.collect();
618        log::trace!("Found {} cameras in scene", cameras.len());
619
620        // Find the first active camera
621        for (camera, global_transform) in cameras {
622            log::trace!("Checking camera: is_active={}", camera.is_active);
623            if camera.is_active {
624                // Extract camera position from the global transform
625                let camera_position = global_transform.0.translation();
626
627                // Calculate the view matrix from the global transform
628                // The view matrix is the inverse of the camera's world transform
629                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                // Get the projection matrix from the camera
637                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        // No active camera found, return default
645        log::warn!("No active camera found in scene, using default ViewInfo");
646        ViewInfo::default()
647    }
648
649    /// Emits a `GpuReport` telemetry event to the DCC with current frame metrics.
650    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    /// Counts the total triangles in the current render world.
663    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    /// Returns the duration of the last frame's render pass.
682    pub fn last_frame_time(&self) -> Duration {
683        self.last_frame_time
684    }
685
686    /// Returns the total number of frames rendered.
687    pub fn frame_count(&self) -> u64 {
688        self.frame_count
689    }
690
691    /// Returns the current GORNA strategy ID.
692    pub fn current_strategy_id(&self) -> StrategyId {
693        self.current_strategy
694    }
695
696    /// Returns a reference to the internal RenderWorld.
697    pub fn render_world(&self) -> &RenderWorld {
698        &self.render_world
699    }
700
701    /// Returns a mutable reference to the internal RenderWorld.
702    pub fn render_world_mut(&mut self) -> &mut RenderWorld {
703        &mut self.render_world
704    }
705
706    /// Returns a reference to the GPU meshes cache.
707    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        // 0 lights -> SimpleUnlit
742        assert_eq!(agent.select_lane_name(), "SimpleUnlit");
743
744        // 1 light -> LitForward
745        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        // 21 lights -> ForwardPlus
752        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        // 1. Unconstrained: should return LowPower (Unlit), Balanced (LitForward), HighPerformance (ForwardPlus)
766        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        // 2. Tightly constrained VRAM (10 bytes max is too small for Balanced/HighPerformance)
775        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        // initially frame count is 0, should be healthy
792        let status = agent.report_status();
793        assert_eq!(status.health_score, 1.0);
794
795        // Frame count > 0 and budget 10ms, but took 20ms
796        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); // 10 / 20
801
802        // At budget
803        agent.last_frame_time = Duration::from_millis(10);
804        let status = agent.report_status();
805        assert_eq!(status.health_score, 1.0);
806
807        // Under budget
808        agent.last_frame_time = Duration::from_millis(5);
809        let status = agent.report_status();
810        assert_eq!(status.health_score, 1.0); // min(1.0, 10/5=2.0)
811    }
812}