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}