Skip to main content

oriera_render/
lib.rs

1//! L5 Render — render graph and frame submission.
2//!
3//! v0.1 counts visible `Position` components as a stand-in for a real
4//! render graph (book T8), plus the `SetQuality` half of the L5
5//! contract: an adaptive quality governor that watches frame times and
6//! moves batching up or down with hysteresis, so a cognition-heavy
7//! frame degrades to fewer draw calls instead of dropping frames. The
8//! world comes from the L2 scheduler; render depends only on layers
9//! below it (L2 core, L3 object model) — never on L6 subsystems.
10
11use oriera_core::{FrameCtx, System};
12use oriera_ecs::{Position, World};
13
14/// Render quality tier. Drives the batching factor applied to visible
15/// geometry — the cheapest dial a frame-graph has.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum Quality {
18    Low,
19    Medium,
20    High,
21}
22
23impl Quality {
24    /// How many visible objects one batched draw call covers at this
25    /// tier. High = no extra batching; Low = aggressive.
26    fn batch_factor(self) -> u32 {
27        match self {
28            Quality::High => 1,
29            Quality::Medium => 4,
30            Quality::Low => 16,
31        }
32    }
33
34    fn step_down(self) -> Option<Quality> {
35        match self {
36            Quality::High => Some(Quality::Medium),
37            Quality::Medium => Some(Quality::Low),
38            Quality::Low => None,
39        }
40    }
41
42    fn step_up(self) -> Option<Quality> {
43        match self {
44            Quality::Low => Some(Quality::Medium),
45            Quality::Medium => Some(Quality::High),
46            Quality::High => None,
47        }
48    }
49}
50
51/// Consecutive over/under-budget frames the governor waits before
52/// switching tiers. Degrade fast (AI burst), recover slow (no flapping).
53const DEGRADE_AFTER: u32 = 30;
54const RECOVER_AFTER: u32 = 120;
55/// Avg must sit below this fraction of budget before recovering.
56const RECOVER_HEADROOM: f32 = 0.6;
57
58/// Frame submission system: cull + count what would be drawn, then
59/// batch according to the current [`Quality`].
60pub struct RenderSystem {
61    pub draw_calls: u32,
62    pub visible: u32,
63    /// Current tier; starts at High and moves only through
64    /// [`RenderSystem::observe_frame`].
65    pub quality: Quality,
66    over_streak: u32,
67    under_streak: u32,
68}
69
70impl RenderSystem {
71    pub fn new() -> Self {
72        Self {
73            draw_calls: 0,
74            visible: 0,
75            quality: Quality::High,
76            over_streak: 0,
77            under_streak: 0,
78        }
79    }
80
81    /// Feed the governor one frame's average time (from
82    /// `Scheduler::avg_frame_ms`) and the budget it is graded against.
83    /// Sustained overruns step quality down; sustained runs with real
84    /// headroom step it back up. Everything between resets both
85    /// streaks — the middle of the budget is where we want to stay.
86    pub fn observe_frame(&mut self, avg_ms: f32, budget_ms: f32) {
87        if avg_ms > budget_ms {
88            self.over_streak += 1;
89            self.under_streak = 0;
90            if self.over_streak >= DEGRADE_AFTER {
91                if let Some(q) = self.quality.step_down() {
92                    tracing::info!(from = ?self.quality, to = ?q, "L5 quality degrade");
93                    self.quality = q;
94                }
95                self.over_streak = 0;
96            }
97        } else if avg_ms < budget_ms * RECOVER_HEADROOM {
98            self.under_streak += 1;
99            self.over_streak = 0;
100            if self.under_streak >= RECOVER_AFTER {
101                if let Some(q) = self.quality.step_up() {
102                    tracing::info!(from = ?self.quality, to = ?q, "L5 quality recover");
103                    self.quality = q;
104                }
105                self.under_streak = 0;
106            }
107        } else {
108            self.over_streak = 0;
109            self.under_streak = 0;
110        }
111    }
112}
113
114impl System<World> for RenderSystem {
115    fn name(&self) -> &str {
116        "render"
117    }
118    fn run(&mut self, _ctx: &FrameCtx, world: &mut World) {
119        self.visible = world.query::<Position>().count() as u32;
120        let factor = self.quality.batch_factor();
121        self.draw_calls = self.visible.div_ceil(factor).max(1);
122    }
123}
124
125impl Default for RenderSystem {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use oriera_common::FrameIndex;
135
136    #[test]
137    fn counts_visible_positions() {
138        let mut world = World::new();
139        for i in 0..5 {
140            let e = world.spawn();
141            world.insert(
142                e,
143                Position {
144                    x: i as f32,
145                    y: 0.0,
146                },
147            );
148        }
149        let ctx = FrameCtx {
150            frame: FrameIndex(0),
151            dt: 0.0,
152            now: oriera_common::Timestamp(0),
153        };
154        let mut sys = RenderSystem::new();
155        sys.run(&ctx, &mut world);
156        assert_eq!(sys.visible, 5);
157        assert_eq!(sys.draw_calls, 5); // High tier: factor 1
158    }
159
160    #[test]
161    fn batching_reduces_draw_calls_at_lower_tiers() {
162        let mut world = World::new();
163        for i in 0..32 {
164            let e = world.spawn();
165            world.insert(
166                e,
167                Position {
168                    x: i as f32,
169                    y: 0.0,
170                },
171            );
172        }
173        let ctx = FrameCtx {
174            frame: FrameIndex(0),
175            dt: 0.0,
176            now: oriera_common::Timestamp(0),
177        };
178        let mut sys = RenderSystem::new();
179
180        sys.quality = Quality::Medium;
181        sys.run(&ctx, &mut world);
182        assert_eq!(sys.visible, 32);
183        assert_eq!(sys.draw_calls, 8); // 32 / 4
184
185        sys.quality = Quality::Low;
186        sys.run(&ctx, &mut world);
187        assert_eq!(sys.draw_calls, 2); // 32 / 16
188    }
189
190    #[test]
191    fn sustained_overruns_degrade_quality() {
192        let mut sys = RenderSystem::new();
193        for _ in 0..DEGRADE_AFTER {
194            sys.observe_frame(20.0, 16.7);
195        }
196        assert_eq!(sys.quality, Quality::Medium);
197        for _ in 0..DEGRADE_AFTER {
198            sys.observe_frame(20.0, 16.7);
199        }
200        assert_eq!(sys.quality, Quality::Low);
201        // Already at Low: stays, no panic, streak keeps cycling.
202        for _ in 0..DEGRADE_AFTER {
203            sys.observe_frame(20.0, 16.7);
204        }
205        assert_eq!(sys.quality, Quality::Low);
206    }
207
208    #[test]
209    fn in_budget_frames_reset_the_degrade_streak() {
210        let mut sys = RenderSystem::new();
211        for _ in 0..(DEGRADE_AFTER - 1) {
212            sys.observe_frame(20.0, 16.7);
213        }
214        sys.observe_frame(10.0, 16.7); // inside budget, above headroom
215        for _ in 0..(DEGRADE_AFTER - 1) {
216            sys.observe_frame(20.0, 16.7);
217        }
218        assert_eq!(sys.quality, Quality::High); // never accumulated a full streak
219    }
220
221    #[test]
222    fn recovery_needs_headroom_and_patience() {
223        let mut sys = RenderSystem::new();
224        sys.quality = Quality::Low;
225        // Fast but above headroom: no recovery.
226        for _ in 0..RECOVER_AFTER {
227            sys.observe_frame(11.0, 16.7);
228        }
229        assert_eq!(sys.quality, Quality::Low);
230        // Real headroom for long enough: step up once, not straight to High.
231        for _ in 0..RECOVER_AFTER {
232            sys.observe_frame(5.0, 16.7);
233        }
234        assert_eq!(sys.quality, Quality::Medium);
235        // Over-budget frames immediately reset the recovery streak.
236        for _ in 0..(RECOVER_AFTER - 1) {
237            sys.observe_frame(5.0, 16.7);
238        }
239        sys.observe_frame(20.0, 16.7);
240        for _ in 0..(RECOVER_AFTER - 1) {
241            sys.observe_frame(5.0, 16.7);
242        }
243        assert_eq!(sys.quality, Quality::Medium); // never reached the threshold
244    }
245}