Skip to main content

unlit3dx_examples/
headless.rs

1//! The headless run: the whole scene, stepped inline without a window,
2//! threads or audio output.
3//!
4//! The windowed mode spreads the worlds across threads: a render
5//! sub-world on the main thread, remote physics, audio and gameplay
6//! worlds on their own threads. The headless run keeps the same worlds
7//! and the same job and snapshot plumbing, but every world lives in one
8//! place and the run drives them in a fixed order: gameplay ticks,
9//! physics drains its jobs and steps, the synthesizer renders the tracks,
10//! then the render world drains its commands and draws. With
11//! every world stepped at a fixed rate, a run is reproducible down to
12//! the captured frames and audio samples.
13
14use std::sync::Arc;
15
16use unlit_wgpu::readback::readback_texture;
17use unlit3d::frame::{Frame, dispatch_frame};
18use unlit3d::prelude::*;
19use unlit3dx::prelude::*;
20
21use crate::scene::{
22    AudioSetup, HEADLESS_HZ, PanelSlots, SceneStepper, mount_scene_content, setup_render_world,
23    spawn_scene,
24};
25
26/// The audio sample rate the headless run renders at, in hertz.
27pub const AUDIO_SAMPLE_RATE: u32 = 48_000;
28
29/// How many channels the headless run mixes: the interleaved samples the
30/// audio world's fill dispatch writes, and the stream the encoder reads.
31pub const AUDIO_CHANNELS: usize = 2;
32
33/// The headless run's settings.
34pub struct HeadlessConfig {
35    /// The frame size, in pixels.
36    pub size: (u32, u32),
37    /// How many frames to step before the run ends.
38    pub frames: u32,
39    /// Which frames to capture, by index, ascending.
40    /// An empty list makes the run audio-only: the render world still drains
41    /// its commands and dispatches its frames, but none is drawn.
42    ///
43    /// The frames come back in the order they were stepped. Every index must
44    /// name a frame the run steps and each must follow the one before, or
45    /// [`run`] panics: an index past the last frame matches nothing, and a
46    /// list that went backwards would hand its frames back in an order the
47    /// caller did not name them in.
48    pub capture: Vec<u32>,
49    /// The rate the mix is pulled at, and the rate the recorded audio is
50    /// written at. The synthesizer takes it from the stream it renders into.
51    pub audio_sample_rate: u32,
52    /// The soundfont the synthesizer plays, as raw bytes: the demo
53    /// passes the same embedded bytes the windowed mode builds with.
54    pub soundfont: Arc<[u8]>,
55}
56
57/// What one headless run produced.
58pub struct HeadlessOutput {
59    /// The captured frames, each RGBA, row-major, top to bottom.
60    pub frames: Vec<Vec<u8>>,
61    /// The mixed audio, interleaved stereo.
62    pub audio: Vec<f32>,
63}
64
65/// Run the scene headless and answer the captured frames and audio.
66///
67/// A headless run has no way to recover a world that failed to come up,
68/// so a broken setup panics instead of returning a hole in the capture.
69pub fn run(
70    config: &HeadlessConfig,
71    device: &wgpu::Device,
72    queue: &wgpu::Queue,
73    capabilities: DeviceCapabilities,
74) -> HeadlessOutput {
75    // Every capture entry has to name a frame the run steps, and the entries
76    // have to ascend: a frame is drawn where the steps left it, so the frames
77    // come back in the order they were stepped. An index past the last frame
78    // matches nothing, and a list that went backwards would answer in its own
79    // order rather than the caller's.
80    for (position, index) in config.capture.iter().enumerate() {
81        assert!(
82            *index < config.frames,
83            "capture entry {position} names frame {index}, but the run steps {} frames",
84            config.frames
85        );
86        if position > 0 {
87            assert!(
88                config.capture[position - 1] < *index,
89                "capture entry {position} names frame {index} after frame {}, so the capture does not ascend",
90                config.capture[position - 1]
91            );
92        }
93    }
94
95    let mut host = World::new();
96
97    // The render sub-world, drained inline every frame, exactly as the
98    // windowed mode's draw behaviour drains it.
99    let render = spawn_sub_world(&mut host, "render", 64);
100
101    // The scene's worlds, stepped by this run: the same worlds, queues and
102    // mailboxes the windowed mode builds on threads, so both modes share
103    // one scene graph. The synthesizer is the same factory the audio thread
104    // builds, so the headless run's samples match the windowed one's by
105    // construction; a stepped world has no device, so its voice builds on the
106    // first fill this run pulls, at the rate those fills name.
107    // An empty soundfont — the build embeds a stub when the asset is absent —
108    // is not a synthesizer to build: the world stays silent instead of
109    // failing to load it on the first tick.
110    let audio_setup = if config.soundfont.is_empty() {
111        AudioSetup::Silent
112    } else {
113        AudioSetup::Pulled {
114            soundfont: config.soundfont.clone(),
115            autoplay_background: true,
116        }
117    };
118    // Nothing feeds input to a run with no window, but the gameplay world
119    // still takes a buffer: a panel lays its frames out from it, and a run
120    // that never pushes an event simply never sees one.
121    let input = InputWriter::new();
122    let worlds = spawn_scene::<Stepped>(
123        &mut host,
124        render.clone(),
125        audio_setup,
126        PanelSlots::default(),
127        &input,
128        true,
129    );
130    let (renderer, pass) =
131        setup_render_world(&mut host, render.entity, device, queue, capabilities);
132    // No control panel headless: nothing is there to click. The camera is
133    // still the scene's, so it arrives as a job like the meshes do.
134    mount_scene_content(&render, pass, None);
135    let offscreen = {
136        let mut nested = host
137            .root_scope()
138            .query_one::<&mut World>(render.entity)
139            .expect("the render sub-world exists");
140        let world = nested.get();
141        let context = world
142            .root_scope()
143            .query_one::<&Renderer>(renderer.0)
144            .expect("the renderer is a resource entity")
145            .get()
146            .context();
147        PersistentTarget::bind(
148            world,
149            context,
150            pass.0,
151            device,
152            config.size,
153            TargetConfig::default(),
154        )
155    };
156
157    let mut output = HeadlessOutput {
158        frames: Vec::new(),
159        audio: Vec::new(),
160    };
161    let mut frame = Frame {
162        delta_time: (1.0 / HEADLESS_HZ) as f32,
163        index: 0,
164        size: config.size,
165    };
166    // The stepper carries the step remainders: physics runs at PHYSICS_HZ
167    // and the synthesizer at its own sample rate, neither necessarily an
168    // integer multiple of the frame rate, so both accumulate their
169    // fractional steps across frames. The frame's duration is the exact
170    // step the run asks for, not a measured one, so the same frames give the
171    // same steps every run.
172    let mut stepper = SceneStepper::new(PHYSICS_HZ, Some(config.audio_sample_rate));
173    // The interleaved sample buffer, reused across frames so the audio
174    // loop does not allocate one per frame.
175    let mut samples_out: Vec<f32> = Vec::new();
176
177    for index in 0..config.frames {
178        frame.index = index;
179
180        // The worlds step once for this frame: gameplay first, so the notes
181        // its sequences play reach the audio world before it steps, then
182        // physics as its rate comes due and audio for the jobs booked.
183        stepper.step(&host, &worlds, 1.0 / HEADLESS_HZ);
184
185        // Audio: render whatever samples the frame's duration asks for.
186        let frames = stepper.audio_frames(1.0 / HEADLESS_HZ);
187        if frames > 0 {
188            samples_out.clear();
189            samples_out.resize(frames * AUDIO_CHANNELS, 0.0);
190            let filled = with_stepped_world(&host, worlds.audio.entity, |audio| {
191                dispatch_audio_fill(
192                    audio,
193                    &mut samples_out,
194                    config.audio_sample_rate,
195                    AUDIO_CHANNELS as u16,
196                );
197            });
198            assert!(filled.is_ok(), "the audio world is a stepped world");
199            output.audio.extend_from_slice(&samples_out);
200        }
201
202        // Render: drain the sub-world's commands — the scene build rides
203        // on that drain — then dispatch the frame. The dispatch happens every
204        // frame, an audio-only run included: it is what advances the frame's
205        // behaviours, so the path a capture freezes is the path the run walked.
206        // Only the frames a capture names are drawn.
207        //
208        // Drawing every captured frame is enough because a draw does not
209        // advance the scene: it reads what the steps left behind, and the state
210        // it writes is only what a frame publishes for its readers — the
211        // target a panel lays itself out against and the URLs its widgets
212        // opened — which the next frame overwrites anyway. So the frame drawn
213        // for a capture is the frame that would have been drawn on time. A
214        // scene that accumulates across draws — a ping-pong target, a temporal
215        // filter — would have to draw every frame.
216        let mut nested = host
217            .root_scope()
218            .query_one::<&mut World>(render.entity)
219            .expect("the render sub-world exists");
220        let world = nested.get();
221        drain_sub_world(world);
222        dispatch_frame(world, &mut frame);
223        if config.capture.contains(&index) {
224            let target = offscreen.as_ref().expect("the offscreen target exists");
225            let context = {
226                let mut renderer_row = world
227                    .root_scope()
228                    .query_one::<&mut Renderer>(renderer.0)
229                    .expect("the renderer is a resource entity");
230                renderer_row.get().render(&*world);
231                renderer_row.get().context()
232            };
233            let texture = {
234                let mut graph = context
235                    .try_graph(&world.root_scope())
236                    .expect("the frame's resource graph exists");
237                graph
238                    .get()
239                    .get(&target.texture)
240                    .cloned()
241                    .expect("the target texture is a resource node")
242            };
243            output
244                .frames
245                .push(readback_texture(device, queue, &texture));
246        }
247    }
248
249    output
250}