Skip to main content

ranim_render/
schedule.rs

1use bevy_app::{First, SubApp};
2use bevy_ecs::{prelude::*, schedule::ScheduleLabel, system::SystemParam};
3
4use crate::{
5    pipelines::oit_resolve,
6    resource::{RenderTextureState, RenderTextures},
7    utils::WgpuContext,
8    world::resolve_scene_order,
9};
10
11/// Drives one render update: maintenance, extraction, preparation, and submission.
12#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
13struct RenderUpdate;
14
15/// Encodes and submits a frame, running the active view's schedule.
16#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
17struct RenderGraph;
18
19/// The schedules of the render world, in execution order.
20#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
21pub enum RenderSchedule {
22    /// Extract the current [`EvaluatedFrame`](ranim_core::frame::EvaluatedFrame).
23    /// Systems read complete typed batches and update render components or resources.
24    Extract,
25    /// Prepare and upload render resources after extraction and scene ordering.
26    Prepare,
27    /// Run the active view's ordered compute and draw phases.
28    View,
29}
30
31/// The ordered phases of [`RenderSchedule::View`] shared by all item plugins.
32#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
33pub enum ViewPhase {
34    /// Clear the frame's color and depth targets.
35    Clear,
36    /// Run compute work needed by subsequent draw passes.
37    Compute,
38    /// Draw all depth prepasses before any color passes.
39    Depth,
40    /// Draw color and collect transparent fragments.
41    Color,
42    /// Resolve the accumulated transparent fragments.
43    Resolve,
44}
45
46#[derive(Resource, Clone, Copy)]
47pub struct RenderDimensions {
48    pub width: u32,
49    pub height: u32,
50}
51
52#[derive(Resource)]
53pub struct FrameTarget {
54    pub(crate) render_view: wgpu::TextureView,
55    pub(crate) depth_stencil_view: wgpu::TextureView,
56    pub(crate) depth_bind_group: wgpu::BindGroup,
57    clear_color: wgpu::Color,
58    texture_state: RenderTextureState,
59}
60
61impl FrameTarget {
62    /// Color attachment for this frame's draw passes.
63    pub fn render_view(&self) -> &wgpu::TextureView {
64        &self.render_view
65    }
66
67    /// Depth attachment shared by the frame's depth and color passes.
68    pub fn depth_stencil_view(&self) -> &wgpu::TextureView {
69        &self.depth_stencil_view
70    }
71
72    pub(crate) fn new(textures: &RenderTextures, clear_color: wgpu::Color) -> Self {
73        Self {
74            render_view: textures.render_view.clone(),
75            depth_stencil_view: textures.depth_stencil_view.clone(),
76            depth_bind_group: textures.depth_bind_group.clone(),
77            clear_color,
78            texture_state: textures.state(),
79        }
80    }
81}
82
83#[derive(Resource, Default)]
84struct FrameEncoder(Option<wgpu::CommandEncoder>);
85
86#[derive(SystemParam)]
87pub struct RenderContext<'w> {
88    encoder: ResMut<'w, FrameEncoder>,
89}
90
91impl RenderContext<'_> {
92    pub fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
93        self.encoder
94            .0
95            .as_mut()
96            .expect("frame encoder was not initialized")
97    }
98}
99
100/// Always-present render profiler resource. GPU timer scopes are
101/// **runtime-toggled** (`RANIM_PROFILE_GPU=1` at startup or
102/// [`RenderProfiler::set_enabled`]): when off (the default) scope wrapping
103/// is a pass-through and no queries are resolved or polled, so profiling
104/// costs nothing; when on, each processed frame's scope tree is stored in
105/// `last_frame_scopes`. `inner` is `None` when the device lacks timestamp
106/// query support.
107#[derive(Resource)]
108pub struct RenderProfiler {
109    pub(crate) inner: Option<wgpu_profiler::GpuProfiler>,
110    enabled: std::sync::atomic::AtomicBool,
111    /// GPU timer scopes of the most recent processed frame.
112    pub(crate) last_frame_scopes: Option<Vec<wgpu_profiler::GpuTimerQueryResult>>,
113}
114
115impl RenderProfiler {
116    pub(crate) fn new(_ctx: &WgpuContext) -> Self {
117        Self {
118            inner: wgpu_profiler::GpuProfiler::new(
119                &_ctx.device,
120                wgpu_profiler::GpuProfilerSettings::default(),
121            )
122            .ok(),
123            enabled: std::sync::atomic::AtomicBool::new(
124                std::env::var("RANIM_PROFILE_GPU").is_ok_and(|v| v != "0" && !v.is_empty()),
125            ),
126            last_frame_scopes: None,
127        }
128    }
129
130    pub(crate) fn is_enabled(&self) -> bool {
131        self.enabled.load(std::sync::atomic::Ordering::Relaxed)
132    }
133
134    pub(crate) fn set_enabled(&self, on: bool) {
135        self.enabled.store(on, std::sync::atomic::Ordering::Relaxed);
136    }
137
138    /// Run `f` with `pass` wrapped in a GPU timer scope labeled `label`.
139    /// Pass-through while GPU timers are disabled or unsupported.
140    pub fn scope_pass<R, T>(&self, label: &str, pass: &mut R, f: impl FnOnce(&mut R) -> T) -> T
141    where
142        R: wgpu_profiler::ProfilerCommandRecorder,
143    {
144        if !self.is_enabled() {
145            return f(pass);
146        }
147        let Some(profiler) = self.inner.as_ref() else {
148            return f(pass);
149        };
150        let mut scope = profiler.scope(label.to_string(), pass);
151        f(&mut *scope)
152    }
153}
154
155pub(crate) fn install_schedules(app: &mut SubApp) {
156    app.update_schedule = Some(RenderUpdate.intern());
157    app.init_resource::<FrameEncoder>()
158        .init_schedule(RenderSchedule::Extract)
159        .init_schedule(RenderSchedule::Prepare)
160        .add_systems(RenderUpdate, render_update);
161    app.configure_sets(
162        RenderSchedule::View,
163        (
164            ViewPhase::Clear,
165            ViewPhase::Compute,
166            ViewPhase::Depth,
167            ViewPhase::Color,
168            ViewPhase::Resolve,
169        )
170            .chain(),
171    );
172    app.add_systems(RenderSchedule::View, clear.in_set(ViewPhase::Clear));
173    app.add_systems(
174        RenderSchedule::View,
175        oit_resolve::resolve.in_set(ViewPhase::Resolve),
176    );
177
178    app.add_systems(
179        RenderGraph,
180        (begin_frame, view_driver, submit, finish_frame).chain(),
181    );
182}
183
184fn render_update(world: &mut World) {
185    world.run_schedule(First);
186    world.run_schedule(RenderSchedule::Extract);
187    resolve_scene_order(world);
188    world.run_schedule(RenderSchedule::Prepare);
189    let _span = crate::cpu_probe::span("render_graph");
190    world.run_schedule(RenderGraph);
191}
192
193fn begin_frame(ctx: Res<WgpuContext>, mut encoder: ResMut<FrameEncoder>) {
194    encoder.0 = Some(
195        ctx.device
196            .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()),
197    );
198}
199
200fn clear(mut render: RenderContext, target: Res<FrameTarget>, profiler: Res<RenderProfiler>) {
201    let pass_desc = wgpu::RenderPassDescriptor {
202        label: Some("Clear Pass"),
203        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
204            depth_slice: None,
205            view: &target.render_view,
206            resolve_target: None,
207            ops: wgpu::Operations {
208                load: wgpu::LoadOp::Clear(target.clear_color),
209                store: wgpu::StoreOp::Store,
210            },
211        })],
212        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
213            view: &target.depth_stencil_view,
214            depth_ops: Some(wgpu::Operations {
215                load: wgpu::LoadOp::Clear(1.0),
216                store: wgpu::StoreOp::Store,
217            }),
218            stencil_ops: None,
219        }),
220        timestamp_writes: None,
221        occlusion_query_set: None,
222        multiview_mask: None,
223    };
224    let encoder = render.encoder();
225    let mut pass = encoder.begin_render_pass(&pass_desc);
226    profiler.scope_pass("clear", &mut pass, |_| ());
227}
228
229fn view_driver(world: &mut World) {
230    world.run_schedule(RenderSchedule::View);
231}
232
233fn submit(
234    ctx: Res<WgpuContext>,
235    mut encoder: ResMut<FrameEncoder>,
236    mut profiler: ResMut<RenderProfiler>,
237) {
238    let mut encoder = encoder.0.take().expect("frame encoder was not initialized");
239    if profiler.is_enabled()
240        && let Some(inner) = profiler.inner.as_mut()
241    {
242        inner.resolve_queries(&mut encoder);
243    }
244    ctx.queue.submit(Some(encoder.finish()));
245}
246
247fn finish_frame(
248    target: Res<FrameTarget>,
249    ctx: Res<WgpuContext>,
250    mut profiler: ResMut<RenderProfiler>,
251) {
252    // Processing timer queries forces a device poll (a sync point), so it
253    // only happens while GPU timers are explicitly enabled.
254    if profiler.is_enabled()
255        && let Some(inner) = profiler.inner.as_mut()
256        && inner.end_frame().is_ok()
257    {
258        ctx.device
259            .poll(wgpu::PollType::wait_indefinitely())
260            .unwrap();
261        if let Some(results) = inner.process_finished_frame(ctx.queue.get_timestamp_period()) {
262            profiler.last_frame_scopes = Some(results);
263        }
264    }
265    target.texture_state.mark_dirty();
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn registered_phases_enforce_the_shared_view_order() {
274        #[derive(Resource, Default)]
275        struct ExecutionLog(Vec<&'static str>);
276
277        let mut app = SubApp::new();
278        app.init_resource::<ExecutionLog>();
279        app.update_schedule = Some(RenderSchedule::View.intern());
280        app.configure_sets(
281            RenderSchedule::View,
282            (
283                ViewPhase::Clear,
284                ViewPhase::Compute,
285                ViewPhase::Depth,
286                ViewPhase::Color,
287                ViewPhase::Resolve,
288            )
289                .chain(),
290        );
291        // Deliberately register in reverse order.
292        app.add_systems(
293            RenderSchedule::View,
294            (
295                (|mut log: ResMut<ExecutionLog>| log.0.push("resolve")).in_set(ViewPhase::Resolve),
296                (|mut log: ResMut<ExecutionLog>| log.0.push("color")).in_set(ViewPhase::Color),
297                (|mut log: ResMut<ExecutionLog>| log.0.push("depth")).in_set(ViewPhase::Depth),
298                (|mut log: ResMut<ExecutionLog>| log.0.push("compute")).in_set(ViewPhase::Compute),
299                (|mut log: ResMut<ExecutionLog>| log.0.push("clear")).in_set(ViewPhase::Clear),
300            ),
301        );
302        app.update();
303        assert_eq!(
304            app.world().resource::<ExecutionLog>().0,
305            ["clear", "compute", "depth", "color", "resolve"]
306        );
307    }
308
309    #[test]
310    fn render_update_runs_extraction_before_preparation_and_graph() {
311        #[derive(Resource, Default)]
312        struct ExecutionLog(Vec<&'static str>);
313
314        let mut app = SubApp::new();
315        install_schedules(&mut app);
316        app.init_resource::<ExecutionLog>();
317        app.add_systems(First, |mut log: ResMut<ExecutionLog>| {
318            log.0.push("first");
319        });
320        app.add_systems(RenderSchedule::Extract, |mut log: ResMut<ExecutionLog>| {
321            log.0.push("extract");
322        });
323        app.add_systems(RenderSchedule::Prepare, |mut log: ResMut<ExecutionLog>| {
324            log.0.push("prepare");
325        });
326        // Replace only GPU execution; retain the installed update and prepare
327        // schedules so this exercises their ordering without creating a device.
328        app.add_schedule(Schedule::new(RenderGraph));
329        app.add_systems(RenderGraph, |mut log: ResMut<ExecutionLog>| {
330            log.0.push("graph")
331        });
332
333        app.update();
334        app.update();
335        assert_eq!(
336            app.world().resource::<ExecutionLog>().0,
337            ["first", "extract", "prepare", "graph"].repeat(2)
338        );
339    }
340}