Skip to main content

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 — owns `LaneKind::Render` lanes only.
16//!
17//! Per CLAD, an Agent owns exactly one `LaneKind` and stores **only** its
18//! own GORNA/strategy state.  Everything else (the graphics device, the
19//! render system, the GPU mesh cache, the per-frame `RenderWorld`) is
20//! looked up from the [`ServiceRegistry`] each frame — agents are not
21//! the owners of those resources.
22
23use 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
52/// Threshold for switching to Forward+ rendering.
53const FORWARD_PLUS_LIGHT_THRESHOLD: usize = 20;
54
55/// Scale factor converting lane cost units to milliseconds of GPU time.
56const COST_TO_MS_SCALE: f32 = 5.0;
57
58/// Approximate VRAM per mesh in bytes (vertex + index buffers).
59const DEFAULT_VRAM_PER_MESH: u64 = 100 * 1024;
60
61/// Rendering strategy selection mode.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum RenderingStrategy {
64    /// Simple unlit rendering (vertex colors only).
65    #[default]
66    Unlit,
67    /// Standard forward rendering with lighting (Blinn-Phong).
68    LitForward,
69    /// Full PBR (Cook-Torrance) forward rendering with shadows.
70    StandardPbr,
71    /// Forward+ (tiled forward) rendering with compute-based light culling.
72    ForwardPlus,
73    /// Automatic selection based on scene complexity (light count).
74    Auto,
75}
76
77/// The agent responsible for the main render pass (`LaneKind::Render`).
78///
79/// Holds **only** its own strategy state — every other dependency
80/// (`GraphicsDevice`, `RenderSystem`, `GpuCache`, `RenderWorldStore`,
81/// `FrameContext`) is fetched from `EngineContext::services` per frame.
82pub struct RenderAgent {
83    /// Render lanes — the agent's strategies.
84    lanes: LaneRegistry,
85    /// Current rendering strategy selection mode.
86    strategy: RenderingStrategy,
87    /// Current GORNA strategy ID applied via `apply_budget`.
88    current_strategy: StrategyId,
89    /// Time budget assigned by GORNA via `apply_budget`.
90    time_budget: Duration,
91    /// Shared, scheduler-written per-agent frame metrics. Read in
92    /// `report_status` to derive `health_score`; the agent stores no
93    /// per-frame counters of its own.
94    frame_status: Option<AgentFrameStatusMap>,
95}
96
97impl Agent for RenderAgent {
98    fn id(&self) -> AgentId {
99        AgentId::Renderer
100    }
101
102    /// Reads the world read-only (active-camera extraction) and writes shared
103    /// render resources (`RenderSystem`, `FrameGraph`), so it takes the world by
104    /// shared reference. The scheduler may run it concurrently with `Isolated`
105    /// agents (which touch disjoint state), never with another shared-resource
106    /// writer in the same wave.
107    fn access(&self) -> AgentAccess {
108        AgentAccess::SharedWorld
109    }
110
111    /// Buffers its main scene pass into the [`ScenePassSlot`] deck slot.
112    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        // Negotiate from a stub LaneContext: we don't have access to the live
119        // RenderWorld here (negotiate runs on the DCC thread), so we estimate
120        // costs against the lane defaults.
121        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            // Without a populated RenderWorld we can only quote VRAM at the
139            // lane-overhead level; per-mesh VRAM is folded in by the lane's
140            // own cost estimator at execute time when it has the real scene.
141            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        // The mesh-count component of VRAM is included implicitly — agents
165        // no longer own the RenderWorld so we cannot compute it here.  The
166        // arbiter accepts these as worst-case-overhead estimates.
167        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        // One-shot lane GPU initialization.  We fetch the device from the
207        // service registry, drive lane.on_initialize() once, and drop the
208        // device handle — the agent does not store it.
209        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        // The PipelineSystem backend — the shader/pipeline machine. Render
220        // lanes resolve their layouts + pipeline through it; it also owns the
221        // canonical Material layout (shared with the material projection's
222        // cached `GpuMaterial` bind groups).
223        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        // Look up every dependency from services — the agent owns none of these.
247        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        // PipelineSystem backend — migrated lanes re-fetch their pipeline by
267        // key each frame through this.
268        let pipeline_system = context
269            .runtime
270            .resources
271            .get::<Arc<dyn PipelineSystem>>()
272            .cloned();
273
274        // Image-based lighting bindings — baked once by the `ibl_bake` system
275        // (PreExtract, before this OUTPUT phase). Forwarded into the lane ctx so
276        // lit lanes bind the irradiance/specular/LUT block at group 3.
277        let ibl_bindings = context
278            .runtime
279            .resources
280            .get::<khora_data::IblBaker>()
281            .and_then(|b| b.bindings());
282
283        // Render lanes consume the per-frame `RenderWorld` from the LaneBus,
284        // populated by `RenderFlow` during the Substrate Pass.
285        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        // The engine inserts ColorTarget/DepthTarget/ClearColor and the shadow
291        // atlas data into the per-frame FrameContext after `begin_frame()`.
292        // ShadowAgent runs in OBSERVE (before OUTPUT) and publishes its atlas
293        // there as well. We just read both.
294        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        // Push the active camera view into the render system if present.
311        // Camera extraction is read-only, so the agent takes the world by
312        // shared reference (`AgentAccess::SharedWorld`) — never `&mut`.
313        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        // Encode the scene pass into a fresh command buffer; the FrameGraph
327        // submits it once all agents have finished recording.
328        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            // SAFETY: encoder is alive for this whole block; ctx (which holds
337            // the slot) is dropped before encoder.finish() consumes it.
338            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            // SAFETY: render_world is borrowed from the LaneBus, which lives
346            // for the entire frame and is read-only — the Ref's pointer
347            // outlives its only consumer (this lane).
348            ctx.insert(khora_core::lane::Ref::new(render_world));
349            // SAFETY: deck is borrowed from EngineContext for the duration
350            // of this agent.execute() call. Lit lanes read `ShadowEntries`
351            // (written by shadow_pass_lane in OBSERVE) from the deck to
352            // build per-light shadow uniforms.
353            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        // Buffer the pass into the deck; the engine folds it into the FrameGraph
390        // (scene → ui → overlay order) after the wave, so the agent never locks
391        // the shared graph.
392        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            // ShadowAgent (in OBSERVE) publishes the shadow atlas into the
431            // FrameContext that this agent reads. The scheduler uses this
432            // declaration to enforce ordering via the AgentCompletionMap.
433            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
460// ─────────────────────────────────────────────────────────────────────
461// Free helpers — kept off the agent struct per CLAD trait-purity rule.
462// ─────────────────────────────────────────────────────────────────────
463
464fn 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        // SimpleUnlit / LitForward / StandardPbr / ForwardPlus.
503        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}