1use frust_gpu::diag::TimestampRing;
33
34pub const LABEL_PREFIX: &str = "frust-engine ";
36
37pub const TIMESTAMP_RING_LABEL: &str = "frust-engine gpu timestamps";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum EngineSpan {
48 Prepass = 0,
51 Main = 1,
55 Composite = 2,
58 Blit = 3,
61}
62
63impl EngineSpan {
64 pub const ALL: [EngineSpan; 4] = [
66 EngineSpan::Prepass,
67 EngineSpan::Main,
68 EngineSpan::Composite,
69 EngineSpan::Blit,
70 ];
71
72 pub const COUNT: usize = EngineSpan::ALL.len();
75
76 #[must_use]
78 pub const fn index(self) -> usize {
79 self as usize
80 }
81
82 #[must_use]
84 pub const fn name(self) -> &'static str {
85 match self {
86 EngineSpan::Prepass => "prepass",
87 EngineSpan::Main => "main",
88 EngineSpan::Composite => "composite",
89 EngineSpan::Blit => "blit",
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, Default)]
104pub struct FrameTimestamps<'a> {
105 ring: Option<&'a TimestampRing>,
106}
107
108impl<'a> FrameTimestamps<'a> {
109 #[must_use]
111 pub const fn inert() -> Self {
112 Self { ring: None }
113 }
114
115 #[must_use]
119 pub fn new(ring: &'a TimestampRing) -> Self {
120 Self {
121 ring: ring.is_active().then_some(ring),
122 }
123 }
124
125 #[must_use]
127 pub fn is_active(&self) -> bool {
128 self.ring.is_some()
129 }
130
131 #[must_use]
135 pub fn writes(&self, span: EngineSpan) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
136 self.ring?.pass_writes(span.index())
137 }
138}
139
140pub fn labels_are_prefixed(source: &str) -> Result<(), String> {
149 for line in source.lines() {
150 let trimmed = line.trim_start();
151 if trimmed.starts_with("//") {
153 continue;
154 }
155 for opener in ["label: Some(\"", "label: \""] {
156 let Some(at) = trimmed.find(opener) else {
157 continue;
158 };
159 let rest = &trimmed[at + opener.len()..];
160 let Some(end) = rest.find('"') else {
161 continue;
162 };
163 let label = &rest[..end];
164 if !label.starts_with(LABEL_PREFIX) {
165 return Err(label.to_string());
166 }
167 }
168 }
169 Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::path::{Path, PathBuf};
176
177 #[test]
178 fn span_indices_are_the_reporting_order() {
179 assert_eq!(EngineSpan::COUNT, 4);
180 for (index, span) in EngineSpan::ALL.into_iter().enumerate() {
181 assert_eq!(span.index(), index, "{span:?} must keep its wire index");
182 }
183 assert_eq!(EngineSpan::Prepass.index(), 0);
184 assert_eq!(EngineSpan::Blit.index(), 3);
185 }
186
187 #[test]
188 fn span_names_are_the_frame_lines_own_field_names() {
189 let names: Vec<&str> = EngineSpan::ALL.into_iter().map(EngineSpan::name).collect();
190 assert_eq!(names, ["prepass", "main", "composite", "blit"]);
191 }
192
193 #[test]
194 fn an_inert_sink_times_no_pass() {
195 let sink = FrameTimestamps::inert();
196 assert!(!sink.is_active());
197 for span in EngineSpan::ALL {
198 assert!(sink.writes(span).is_none());
199 }
200 assert!(!FrameTimestamps::default().is_active());
201 }
202
203 #[test]
204 fn a_sink_over_an_inert_ring_is_itself_inert() {
205 let ring = TimestampRing::inert(EngineSpan::COUNT);
206 let sink = FrameTimestamps::new(&ring);
207 assert!(!sink.is_active());
208 assert!(sink.writes(EngineSpan::Main).is_none());
209 }
210
211 #[test]
212 fn the_label_scan_accepts_both_shapes_this_crate_writes() {
213 assert!(labels_are_prefixed(" label: Some(\"frust-engine clear\"),").is_ok());
214 assert!(labels_are_prefixed(" label: \"frust-engine hole punch\",").is_ok());
215 assert_eq!(
216 labels_are_prefixed(" label: Some(\"scratch\"),"),
217 Err("scratch".to_string())
218 );
219 assert_eq!(
220 labels_are_prefixed(" label: \"page\","),
221 Err("page".to_string())
222 );
223 }
224
225 #[test]
226 fn the_label_scan_ignores_a_comment_mentioning_one() {
227 assert!(labels_are_prefixed(" // label: Some(\"anything\") is prose here").is_ok());
228 }
229
230 fn block_on<F: std::future::Future>(future: F) -> F::Output {
234 use std::task::{Context, Poll, Waker};
235
236 let waker = Waker::noop();
237 let mut cx = Context::from_waker(waker);
238 let mut future = std::pin::pin!(future);
239 loop {
240 match future.as_mut().poll(&mut cx) {
241 Poll::Ready(value) => return value,
242 Poll::Pending => std::thread::yield_now(),
243 }
244 }
245 }
246
247 #[test]
248 #[ignore = "needs a real GPU adapter offering TIMESTAMP_QUERY; run with \
249 `cargo test -p frust-engine -- --ignored` (pin the adapter on a \
250 multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
251 fn a_real_frame_reports_plausible_per_span_gpu_time() {
252 use crate::renderer::FramePass;
253 use crate::{EngineRenderer, EngineTarget, OutputAlpha};
254 use frust_gpu::{HeadlessTarget, TierCaps};
255 use frust_scene::{Scene, SceneBuilder};
256 use kurbo::{Affine, Rect};
257 use peniko::Brush;
258 use peniko::color::palette::css;
259 use std::time::Duration;
260
261 const SIZE: u32 = 512;
262 const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
263
264 block_on(async {
265 let instance = wgpu::Instance::new(
266 wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
267 );
268 let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
269 .await
270 .expect("no compatible GPU adapter");
271 println!(
272 "frust-engine gpu-timestamp adapter: {:?}",
273 adapter.get_info()
274 );
275 let caps = TierCaps::probe(&adapter);
276 if !caps.has_timestamp_query {
277 println!("adapter offers no TIMESTAMP_QUERY — the inert path is the whole story");
278 return;
279 }
280 let (device, queue) = adapter
281 .request_device(&wgpu::DeviceDescriptor {
282 label: Some("frust-engine gpu-timestamp device"),
283 required_features: wgpu::Features::TIMESTAMP_QUERY,
286 required_limits: wgpu::Limits::default(),
287 ..Default::default()
288 })
289 .await
290 .expect("failed to create the device");
291
292 let mut renderer = EngineRenderer::new(&device, &caps, FORMAT, None)
293 .expect("engine renderer for a headless target");
294 renderer.finish_warm_up(&device);
295 let target = HeadlessTarget::new(&device, SIZE, SIZE, FORMAT);
296 let mut ring = frust_gpu::diag::TimestampRing::new(
297 &device,
298 &queue,
299 EngineSpan::COUNT,
300 TIMESTAMP_RING_LABEL,
301 );
302 assert!(
303 ring.is_active(),
304 "a TIMESTAMP_QUERY device must arm the ring"
305 );
306
307 let mut scene = Scene::new();
312 let mut builder = SceneBuilder::new(&mut scene);
313 builder.fill_rect(
314 Rect::new(0.0, 0.0, f64::from(SIZE), f64::from(SIZE)),
315 Brush::Solid(css::REBECCA_PURPLE),
316 );
317 builder.fill_rect(
318 Rect::new(16.0, 16.0, 480.0, 480.0),
319 Brush::Solid(css::CORNFLOWER_BLUE.with_alpha(0.5)),
320 );
321 builder.push_layer(Rect::new(32.0, 32.0, 464.0, 464.0), 0.5);
322 builder.fill_rect(
323 Rect::new(48.0, 48.0, 448.0, 448.0),
324 Brush::Solid(css::ORANGE),
325 );
326 builder.pop_layer();
327
328 let mut readings = 0_u64;
334 let mut failures = Vec::new();
335 for frame in 0..(frust_gpu::diag::RING_FRAMES * 3) {
336 ring.begin_frame(&device);
337 let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
338 label: Some("frust-engine gpu-timestamp frame"),
339 });
340 renderer
341 .encode_traced(
342 &device,
343 &queue,
344 &mut encoder,
345 &scene,
346 EngineTarget {
347 view: target.view(),
348 format: FORMAT,
349 width: SIZE,
350 height: SIZE,
351 depth: None,
352 output: OutputAlpha::Premultiplied,
353 },
354 css::BLACK,
355 Affine::IDENTITY,
356 FrameTimestamps::new(&ring),
357 )
358 .expect("the engine must serve this frame");
359 let passes = renderer.last_frame_passes();
364 println!("frame {frame}: passes {passes:?}");
365 assert!(
366 passes.iter().any(|pass| matches!(
367 pass,
368 FramePass::Opening | FramePass::Opaque | FramePass::Surface
369 )),
370 "frame {frame} recorded no drawing surface pass: {passes:?}"
371 );
372 ring.resolve(&mut encoder);
373 queue.submit([encoder.finish()]);
374 ring.end_frame();
375 renderer.end_frame(&queue);
376 let _ = device.poll(wgpu::PollType::wait_indefinitely());
378 if ring.harvested() > readings {
379 readings = ring.harvested();
380 let reading = ring.latest().expect("a harvest produces a reading");
381 println!(
382 "frame {frame}: main={:?} composite={:?} total={:?}",
383 reading.span(EngineSpan::Main.index()),
384 reading.span(EngineSpan::Composite.index()),
385 reading.total()
386 );
387 for (span, why) in [
388 (EngineSpan::Main, "the frame's own surface passes drew"),
389 (
390 EngineSpan::Composite,
391 "the sub-unity layer rendered into a page",
392 ),
393 ] {
394 if !reading.is_measured(span.index())
395 || reading.span(span.index()) == Duration::ZERO
396 {
397 failures.push(format!(
398 "reading at frame {frame}: {} unmeasured or zero, but {why}: \
399 {reading:?}",
400 span.name()
401 ));
402 }
403 }
404 }
405 }
406
407 assert!(
408 readings >= frust_gpu::diag::RING_FRAMES as u64,
409 "a test that waits on every frame must harvest a reading per lap slot, \
410 got {readings}"
411 );
412 assert!(failures.is_empty(), "{failures:#?}");
413 let reading = ring
414 .latest()
415 .expect("at least one reading must have landed");
416 assert_eq!(reading.len(), EngineSpan::COUNT);
417 assert_eq!(
420 reading.total(),
421 reading.span(EngineSpan::Main.index())
422 + reading.span(EngineSpan::Composite.index())
423 + reading.span(EngineSpan::Prepass.index())
424 + reading.span(EngineSpan::Blit.index())
425 );
426 assert!(
427 reading.total() < Duration::from_millis(100),
428 "one 512x512 frame cannot plausibly take {:?}",
429 reading.total()
430 );
431 });
432 }
433
434 fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
436 let Ok(entries) = std::fs::read_dir(dir) else {
437 return;
438 };
439 for entry in entries.flatten() {
440 let path = entry.path();
441 if path.is_dir() {
442 rust_files(&path, out);
443 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
444 out.push(path);
445 }
446 }
447 }
448
449 #[test]
450 fn every_gpu_object_this_crate_creates_is_labelled_frust_engine() {
451 let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
455 let mut files = Vec::new();
456 rust_files(&src, &mut files);
457 files.sort();
458 assert!(!files.is_empty(), "{} has no sources", src.display());
459
460 let mut failures = Vec::new();
461 for path in files {
462 let Ok(contents) = std::fs::read_to_string(&path) else {
463 continue;
464 };
465 if let Err(label) = labels_are_prefixed(&contents) {
466 failures.push(format!("{}: `{label}`", path.display()));
467 }
468 }
469 assert!(
470 failures.is_empty(),
471 "every GPU object must be labelled `{LABEL_PREFIX}<kind>`: {failures:?}"
472 );
473 }
474}