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