khora_agents/physics_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 PhysicsAgent, the central orchestrator for the physics subsystem.
16//!
17//! This agent implements the full GORNA protocol to negotiate resource budgets
18//! with the DCC and adapt physics simulation quality based on system constraints.
19
20use std::time::{Duration, Instant};
21
22use crossbeam_channel::Sender;
23use khora_core::agent::Agent;
24use khora_core::control::gorna::{
25    AgentId, AgentStatus, NegotiationRequest, NegotiationResponse, ResourceBudget, StrategyId,
26    StrategyOption,
27};
28use khora_core::lane::PhysicsDeltaTime;
29use khora_core::lane::{Lane, LaneContext, LaneKind, LaneRegistry, Slot};
30use khora_core::physics::PhysicsProvider;
31use khora_core::telemetry::event::TelemetryEvent;
32use khora_core::telemetry::monitoring::GpuReport;
33use khora_data::ecs::World;
34use khora_lanes::physics_lane::StandardPhysicsLane;
35use khora_telemetry::metrics::registry::{GaugeHandle, MetricsRegistry};
36
37/// Scale factor to convert complexity units to estimated milliseconds.
38const COST_TO_MS_SCALE: f32 = 3.0;
39
40/// Strategies for physics simulation.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum PhysicsStrategy {
43    /// Standard high-precision physics.
44    #[default]
45    Standard,
46    /// Simplified physics for low-power mode.
47    Simplified,
48    /// Debug visualization mode.
49    Debug,
50}
51
52/// Holds telemetry handles for the physics subsystem.
53struct PhysicsMetrics {
54    /// Gauge for tracking active rigid bodies.
55    body_count: GaugeHandle,
56    /// Gauge for tracking active colliders.
57    collider_count: GaugeHandle,
58    /// Gauge for tracking simulation step duration.
59    step_time_ms: GaugeHandle,
60}
61
62/// The agent responsible for managing the physics simulation.
63///
64/// It acts as the Control Plane (ISA) for the physics subsystem,
65/// deciding which strategies (lanes) to deploy and managing the physics world.
66pub struct PhysicsAgent {
67    /// The concrete physics solver provider.
68    provider: Box<dyn PhysicsProvider>,
69    /// All physics lanes stored generically.
70    lanes: LaneRegistry,
71    /// Current selected strategy.
72    strategy: PhysicsStrategy,
73    /// Current GORNA strategy ID.
74    current_strategy: StrategyId,
75    /// Telemetry metrics.
76    metrics: Option<PhysicsMetrics>,
77    /// Sender for DCC telemetry events.
78    telemetry_sender: Option<Sender<TelemetryEvent>>,
79    /// Duration of the last physics step.
80    last_step_time: Duration,
81    /// Time budget allocated by GORNA.
82    time_budget: Duration,
83    /// Total number of frames simulated.
84    frame_count: u64,
85    /// Fixed timestep for physics simulation.
86    fixed_timestep: f32,
87}
88
89impl Agent for PhysicsAgent {
90    fn id(&self) -> AgentId {
91        AgentId::Physics
92    }
93
94    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
95        let body_count = self.provider.get_all_bodies().len() as u64;
96        let complexity_factor = 1.0 + (body_count as f32 / 100.0).min(5.0);
97
98        NegotiationResponse {
99            strategies: vec![
100                StrategyOption {
101                    id: StrategyId::LowPower,
102                    estimated_time: Duration::from_secs_f32(
103                        (0.5 * complexity_factor * COST_TO_MS_SCALE).max(0.1) / 1000.0,
104                    ),
105                    estimated_vram: 0,
106                },
107                StrategyOption {
108                    id: StrategyId::Balanced,
109                    estimated_time: Duration::from_secs_f32(
110                        (1.5 * complexity_factor * COST_TO_MS_SCALE).max(0.5) / 1000.0,
111                    ),
112                    estimated_vram: 0,
113                },
114                StrategyOption {
115                    id: StrategyId::HighPerformance,
116                    estimated_time: Duration::from_secs_f32(
117                        (3.0 * complexity_factor * COST_TO_MS_SCALE).max(1.0) / 1000.0,
118                    ),
119                    estimated_vram: 0,
120                },
121            ],
122        }
123    }
124
125    fn apply_budget(&mut self, budget: ResourceBudget) {
126        log::info!(
127            "PhysicsAgent: Strategy update to {:?} (time_limit={:?})",
128            budget.strategy_id,
129            budget.time_limit,
130        );
131
132        match budget.strategy_id {
133            StrategyId::LowPower => {
134                self.strategy = PhysicsStrategy::Simplified;
135                self.fixed_timestep = 1.0 / 30.0;
136            }
137            StrategyId::Balanced => {
138                self.strategy = PhysicsStrategy::Standard;
139                self.fixed_timestep = 1.0 / 60.0;
140            }
141            StrategyId::HighPerformance => {
142                self.strategy = PhysicsStrategy::Standard;
143                self.fixed_timestep = 1.0 / 120.0;
144            }
145            StrategyId::Custom(_) => {
146                log::warn!(
147                    "PhysicsAgent received unsupported custom strategy. Falling back to Standard."
148                );
149                self.strategy = PhysicsStrategy::Standard;
150                self.fixed_timestep = 1.0 / 60.0;
151            }
152        }
153
154        self.current_strategy = budget.strategy_id;
155        self.time_budget = budget.time_limit;
156    }
157
158    fn update(&mut self, context: &mut khora_core::EngineContext<'_>) {
159        if let Some(world_any) = context.world.as_deref_mut() {
160            if let Some(world) = world_any.downcast_mut::<World>() {
161                self.step(world, self.fixed_timestep);
162            }
163        }
164        self.emit_telemetry();
165    }
166
167    fn report_status(&self) -> AgentStatus {
168        let health_score = if self.time_budget.is_zero() || self.frame_count == 0 {
169            1.0
170        } else {
171            let ratio =
172                self.time_budget.as_secs_f32() / self.last_step_time.as_secs_f32().max(0.0001);
173            ratio.min(1.0)
174        };
175
176        let body_count = self.provider.get_all_bodies().len();
177        let collider_count = self.provider.get_all_colliders().len();
178
179        AgentStatus {
180            agent_id: self.id(),
181            health_score,
182            current_strategy: self.current_strategy,
183            is_stalled: self.frame_count == 0,
184            message: format!(
185                "step_time={:.2}ms bodies={} colliders={}",
186                self.last_step_time.as_secs_f32() * 1000.0,
187                body_count,
188                collider_count,
189            ),
190        }
191    }
192
193    fn execute(&mut self) {
194        // Physics step is performed in update() via the tactical coordination.
195    }
196
197    fn as_any(&self) -> &dyn std::any::Any {
198        self
199    }
200
201    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
202        self
203    }
204}
205
206impl PhysicsAgent {
207    /// Creates a new `PhysicsAgent` with a given provider.
208    pub fn new(provider: Box<dyn PhysicsProvider>) -> Self {
209        let mut lanes = LaneRegistry::new();
210        lanes.register(Box::new(StandardPhysicsLane::new()));
211        lanes.register(Box::new(khora_lanes::physics_lane::PhysicsDebugLane::new()));
212
213        Self {
214            provider,
215            lanes,
216            strategy: PhysicsStrategy::Standard,
217            current_strategy: StrategyId::Balanced,
218            metrics: None,
219            telemetry_sender: None,
220            last_step_time: Duration::ZERO,
221            time_budget: Duration::ZERO,
222            frame_count: 0,
223            fixed_timestep: 1.0 / 60.0,
224        }
225    }
226
227    /// Attaches a metrics registry to the agent for observability.
228    pub fn with_telemetry(mut self, registry: &MetricsRegistry) -> Self {
229        let metrics = PhysicsMetrics {
230            body_count: registry
231                .register_gauge(
232                    "physics",
233                    "body_count",
234                    "Total active rigid bodies",
235                    "count",
236                )
237                .unwrap(),
238            collider_count: registry
239                .register_gauge(
240                    "physics",
241                    "collider_count",
242                    "Total active colliders",
243                    "count",
244                )
245                .unwrap(),
246            step_time_ms: registry
247                .register_gauge(
248                    "physics",
249                    "step_time_ms",
250                    "Time spent in simulation step",
251                    "ms",
252                )
253                .unwrap(),
254        };
255        self.metrics = Some(metrics);
256        self
257    }
258
259    /// Attaches a DCC sender for telemetry events.
260    pub fn with_dcc_sender(mut self, sender: Sender<TelemetryEvent>) -> Self {
261        self.telemetry_sender = Some(sender);
262        self
263    }
264
265    /// Advances the physics simulation.
266    pub fn step(&mut self, world: &mut World, dt: f32) {
267        let start = Instant::now();
268
269        // Build LaneContext with all required data.
270        let mut ctx = LaneContext::new();
271        ctx.insert(PhysicsDeltaTime(dt));
272        ctx.insert(Slot::new(world));
273        ctx.insert(Slot::new(self.provider.as_mut()));
274
275        // Select and execute the appropriate lane.
276        let lane_name = self.select_lane_name();
277        if let Some(lane) = self.lanes.get(lane_name) {
278            if let Err(e) = lane.execute(&mut ctx) {
279                log::error!("Physics lane {} failed: {}", lane.strategy_name(), e);
280            }
281        }
282
283        self.last_step_time = start.elapsed();
284        self.frame_count += 1;
285
286        if let Some(metrics) = &self.metrics {
287            let _ = metrics
288                .body_count
289                .set(self.provider.get_all_bodies().len() as f64);
290            let _ = metrics
291                .collider_count
292                .set(self.provider.get_all_colliders().len() as f64);
293            let _ = metrics
294                .step_time_ms
295                .set(self.last_step_time.as_secs_f64() * 1000.0);
296        }
297    }
298
299    fn emit_telemetry(&self) {
300        if let Some(sender) = &self.telemetry_sender {
301            let report = GpuReport {
302                frame_number: self.frame_count,
303                draw_calls: 0,
304                triangles_rendered: 0,
305                ..Default::default()
306            };
307            let _ = sender.send(TelemetryEvent::GpuReport(report));
308        }
309    }
310
311    /// Returns the strategy name of the currently selected physics lane.
312    fn select_lane_name(&self) -> &'static str {
313        match self.strategy {
314            PhysicsStrategy::Standard | PhysicsStrategy::Simplified => "StandardPhysics",
315            PhysicsStrategy::Debug => "PhysicsDebug",
316        }
317    }
318
319    /// Selects the appropriate physics lane based on the current strategy.
320    pub fn select_lane(&self) -> &dyn Lane {
321        let name = self.select_lane_name();
322        self.lanes.get(name).unwrap_or_else(|| {
323            self.lanes
324                .find_by_kind(LaneKind::Physics)
325                .first()
326                .copied()
327                .expect("PhysicsAgent has no physics lanes configured")
328        })
329    }
330
331    /// Exposes raycasting from the provider.
332    pub fn cast_ray(
333        &self,
334        ray: &khora_core::physics::Ray,
335        max_toi: f32,
336        solid: bool,
337    ) -> Option<khora_core::physics::RaycastHit> {
338        self.provider.cast_ray(ray, max_toi, solid)
339    }
340
341    /// Returns debug rendering data from the provider.
342    pub fn get_debug_render_data(&self) -> (Vec<khora_core::math::Vec3>, Vec<[u32; 2]>) {
343        self.provider.get_debug_render_data()
344    }
345
346    /// Returns the duration of the last step.
347    pub fn last_step_time(&self) -> Duration {
348        self.last_step_time
349    }
350
351    /// Returns the total number of frames simulated.
352    pub fn frame_count(&self) -> u64 {
353        self.frame_count
354    }
355
356    /// Returns the current GORNA strategy ID.
357    pub fn current_strategy_id(&self) -> StrategyId {
358        self.current_strategy
359    }
360
361    /// Returns the current fixed timestep.
362    pub fn fixed_timestep(&self) -> f32 {
363        self.fixed_timestep
364    }
365
366    /// Sets the fixed timestep.
367    pub fn set_fixed_timestep(&mut self, dt: f32) {
368        self.fixed_timestep = dt;
369    }
370}