concinnity_core/render/shadow_schedule.rs
1//! Cross-backend cascade re-render scheduling for the cascaded shadow map. The
2//! shadow pass re-rasterizes all scene geometry into every cascade slice, so it
3//! is one of the heaviest passes; `ShadowUpdate::Hybrid` amortizes the far
4//! cascades across frames (near cascade every frame, one far cascade round-robin)
5//! while keeping each slice primed before it is sampled. Shared by all three
6//! backends so the policy lives once next to the CSM math in `csm.rs`.
7
8use crate::components::ShadowUpdate;
9use crate::gfx::render_types::NUM_SHADOW_CASCADES;
10
11/// Round-robin clock + primed-set state for the cascade re-render schedule. One
12/// per renderer; `next_mask` advances it once per frame and returns which
13/// cascades to re-render. The caller refreshes only those cascades' light VPs and
14/// re-rasterizes only those slices, so skipped cascades keep the depth + VP they
15/// were last rendered with.
16#[derive(Debug, Default)]
17pub struct ShadowCascadeScheduler {
18 // Advances once per shadow update; selects which far cascade Hybrid mode
19 // refreshes this frame.
20 clock: u32,
21 // Bit `i` set once cascade `i` has been rendered since the scheduler was
22 // created. Unprimed cascades are force-rendered so a slice is never sampled
23 // before it holds valid depth.
24 primed_mask: u32,
25}
26
27impl ShadowCascadeScheduler {
28 /// Choose which cascades to re-render this frame and advance the round-robin
29 /// clock. Delegates the selection to the pure `select_cascade_mask` and
30 /// applies its side effects: advance the clock and record the newly primed
31 /// set. Bit `i` set in the result means cascade `i` re-renders this frame.
32 pub fn next_mask(&mut self, update: ShadowUpdate, active_cascades: u32) -> u32 {
33 let (mask, primed) =
34 select_cascade_mask(update, self.clock, self.primed_mask, active_cascades);
35 self.clock = self.clock.wrapping_add(1);
36 self.primed_mask = primed;
37 mask
38 }
39}
40
41// Pure cascade-selection step, split out of `next_mask` so the priming +
42// round-robin policy is unit-testable without renderer state.
43//
44// Given the update policy, the current round-robin clock, and which cascades
45// have already been primed, returns `(render_mask, new_primed_mask)`:
46//
47// - `scheduled` is the policy's steady-state set (EveryFrame = all; Hybrid =
48// the near cascade plus one round-robin far cascade).
49// - any cascade not yet primed is force-rendered this frame so its slice is
50// never sampled before it holds valid depth. Because a cascade's bit is set
51// in `primed` the first frame it renders and never cleared, priming renders
52// each cascade exactly once on first access; it is never re-primed.
53fn select_cascade_mask(
54 update: ShadowUpdate,
55 clock: u32,
56 primed: u32,
57 active_cascades: u32,
58) -> (u32, u32) {
59 // Only the first `active` cascades exist this frame; the rest are never
60 // scheduled (and never sampled -- the shader bounds its lookup by the same
61 // count). `primed` may carry bits for cascades that were active under a
62 // higher count; `& all` masks them off so they never force a render.
63 let active = active_cascades.clamp(1, NUM_SHADOW_CASCADES as u32);
64 let all = (1u32 << active) - 1;
65 let scheduled = match update {
66 ShadowUpdate::EveryFrame => all,
67 ShadowUpdate::Hybrid => {
68 let far_count = (active - 1).max(1);
69 let far = 1 + (clock % far_count);
70 (1u32 | (1u32 << far)) & all
71 }
72 };
73 let unprimed = all & !primed;
74 let mask = (scheduled | unprimed) & all;
75 (mask, primed | mask)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::{ShadowCascadeScheduler, select_cascade_mask};
81 use crate::components::ShadowUpdate;
82 use crate::gfx::render_types::NUM_SHADOW_CASCADES;
83
84 const ALL: u32 = (1u32 << NUM_SHADOW_CASCADES) - 1;
85
86 #[test]
87 fn first_access_primes_every_cascade_at_once() {
88 // From the unprimed state both policies render all cascades on the
89 // very first frame, so no slice is sampled before it holds depth.
90 for update in [ShadowUpdate::Hybrid, ShadowUpdate::EveryFrame] {
91 let (mask, primed) = select_cascade_mask(update, 0, 0, NUM_SHADOW_CASCADES as u32);
92 assert_eq!(mask, ALL, "{update:?} should prime all cascades on frame 0");
93 assert_eq!(primed, ALL, "every cascade is primed after frame 0");
94 }
95 }
96
97 #[test]
98 fn fewer_active_cascades_only_schedule_the_live_slots() {
99 // With active_cascades = 2, only cascades 0 and 1 are ever scheduled --
100 // the inactive 2,3 never render even if their primed bits are stale from
101 // a higher count. EveryFrame renders both; Hybrid (far_count = 1) also
102 // renders both (no far cascade to amortize with only one far slot).
103 let live = 0b11u32;
104 for update in [ShadowUpdate::Hybrid, ShadowUpdate::EveryFrame] {
105 // Stale primed bits for the now-inactive cascades must not leak in.
106 let (mask, primed) = select_cascade_mask(update, 0, ALL, 2);
107 assert_eq!(mask, live, "{update:?} scheduled an inactive cascade");
108 assert_eq!(
109 primed & !live,
110 ALL & !live,
111 "inactive primed bits untouched"
112 );
113 }
114 // active = 1: only the near cascade ever renders.
115 let (mask, _) = select_cascade_mask(ShadowUpdate::Hybrid, 3, 0, 1);
116 assert_eq!(mask, 0b1);
117 }
118
119 #[test]
120 fn priming_never_re_renders_a_cascade() {
121 // Once primed, Hybrid drops back to its steady-state set: the near
122 // cascade plus exactly one far cascade. The full-prime render only
123 // happens while a cascade is still unprimed, never again.
124 let far_count = (NUM_SHADOW_CASCADES as u32 - 1).max(1);
125 for clock in 0..(far_count * 3) {
126 let (mask, primed) =
127 select_cascade_mask(ShadowUpdate::Hybrid, clock, ALL, NUM_SHADOW_CASCADES as u32);
128 assert_eq!(primed, ALL, "already-primed set is unchanged");
129 assert_eq!(mask & 1, 1, "near cascade refreshes every frame");
130 let extra = (mask & !1u32).count_ones();
131 assert_eq!(extra, 1, "exactly one far cascade refreshes per frame");
132 }
133 }
134
135 #[test]
136 fn hybrid_round_robins_every_far_cascade() {
137 // Over `far_count` consecutive frames the steady-state Hybrid mask
138 // touches each far cascade exactly once.
139 let far_count = (NUM_SHADOW_CASCADES as u32 - 1).max(1);
140 let mut union = 0u32;
141 for clock in 0..far_count {
142 let (mask, _) =
143 select_cascade_mask(ShadowUpdate::Hybrid, clock, ALL, NUM_SHADOW_CASCADES as u32);
144 union |= mask;
145 }
146 assert_eq!(union, ALL, "every cascade is refreshed within one round");
147 }
148
149 #[test]
150 fn every_frame_always_renders_all() {
151 for clock in 0..5 {
152 let (mask, primed) = select_cascade_mask(
153 ShadowUpdate::EveryFrame,
154 clock,
155 ALL,
156 NUM_SHADOW_CASCADES as u32,
157 );
158 assert_eq!(mask, ALL);
159 assert_eq!(primed, ALL);
160 }
161 }
162
163 #[test]
164 fn priming_is_monotonic_and_one_shot_per_cascade() {
165 // Drive the policy frame by frame from the unprimed state and assert
166 // the primed set only grows, and any cascade rendered purely for
167 // priming (in the mask but not in that frame's scheduled set) is
168 // rendered at most once across the whole run.
169 let mut primed = 0u32;
170 let mut prime_renders = [0u32; NUM_SHADOW_CASCADES];
171 for clock in 0..(NUM_SHADOW_CASCADES as u32 + 4) {
172 let far_count = (NUM_SHADOW_CASCADES as u32 - 1).max(1);
173 let scheduled_near_far = 1u32 | (1u32 << (1 + clock % far_count));
174 let before = primed;
175 let (mask, next) = select_cascade_mask(
176 ShadowUpdate::Hybrid,
177 clock,
178 primed,
179 NUM_SHADOW_CASCADES as u32,
180 );
181 assert_eq!(next & before, before, "primed set must never lose a bit");
182 for (c, count) in prime_renders.iter_mut().enumerate() {
183 let bit = 1u32 << c;
184 let primed_only = (mask & bit != 0) && (scheduled_near_far & bit == 0);
185 if primed_only {
186 *count += 1;
187 }
188 }
189 primed = next;
190 }
191 for (c, count) in prime_renders.iter().enumerate() {
192 assert!(
193 *count <= 1,
194 "cascade {c} was force-primed {count} times (>1)"
195 );
196 }
197 assert_eq!(primed, ALL, "all cascades primed after the run");
198 }
199
200 #[test]
201 fn scheduler_advances_clock_and_accumulates_primes() {
202 // The struct wrapper primes everything on frame 0, then steady-states
203 // to near + one far cascade and rotates the far cascade each frame.
204 let mut sched = ShadowCascadeScheduler::default();
205 assert_eq!(
206 sched.next_mask(ShadowUpdate::Hybrid, NUM_SHADOW_CASCADES as u32),
207 ALL,
208 "frame 0 primes all"
209 );
210 let far_count = (NUM_SHADOW_CASCADES as u32 - 1).max(1);
211 let mut union = 0u32;
212 for _ in 0..far_count {
213 let mask = sched.next_mask(ShadowUpdate::Hybrid, NUM_SHADOW_CASCADES as u32);
214 assert_eq!(mask & 1, 1, "near cascade refreshes every frame");
215 assert_eq!((mask & !1u32).count_ones(), 1, "one far cascade per frame");
216 union |= mask;
217 }
218 assert_eq!(union, ALL, "every cascade refreshed within one round");
219 }
220}