use frust_gpu::diag::TimestampRing;
pub const LABEL_PREFIX: &str = "frust-engine ";
pub const TIMESTAMP_RING_LABEL: &str = "frust-engine gpu timestamps";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EngineSpan {
Prepass = 0,
Main = 1,
Composite = 2,
Blit = 3,
}
impl EngineSpan {
pub const ALL: [EngineSpan; 4] = [
EngineSpan::Prepass,
EngineSpan::Main,
EngineSpan::Composite,
EngineSpan::Blit,
];
pub const COUNT: usize = EngineSpan::ALL.len();
#[must_use]
pub const fn index(self) -> usize {
self as usize
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
EngineSpan::Prepass => "prepass",
EngineSpan::Main => "main",
EngineSpan::Composite => "composite",
EngineSpan::Blit => "blit",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FrameTimestamps<'a> {
ring: Option<&'a TimestampRing>,
}
impl<'a> FrameTimestamps<'a> {
#[must_use]
pub const fn inert() -> Self {
Self { ring: None }
}
#[must_use]
pub fn new(ring: &'a TimestampRing) -> Self {
Self {
ring: ring.is_active().then_some(ring),
}
}
#[must_use]
pub fn is_active(&self) -> bool {
self.ring.is_some()
}
#[must_use]
pub fn writes(&self, span: EngineSpan) -> Option<wgpu::RenderPassTimestampWrites<'a>> {
self.ring?.pass_writes(span.index())
}
}
pub fn labels_are_prefixed(source: &str) -> Result<(), String> {
for line in source.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
for opener in ["label: Some(\"", "label: \""] {
let Some(at) = trimmed.find(opener) else {
continue;
};
let rest = &trimmed[at + opener.len()..];
let Some(end) = rest.find('"') else {
continue;
};
let label = &rest[..end];
if !label.starts_with(LABEL_PREFIX) {
return Err(label.to_string());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn span_indices_are_the_reporting_order() {
assert_eq!(EngineSpan::COUNT, 4);
for (index, span) in EngineSpan::ALL.into_iter().enumerate() {
assert_eq!(span.index(), index, "{span:?} must keep its wire index");
}
assert_eq!(EngineSpan::Prepass.index(), 0);
assert_eq!(EngineSpan::Blit.index(), 3);
}
#[test]
fn span_names_are_the_frame_lines_own_field_names() {
let names: Vec<&str> = EngineSpan::ALL.into_iter().map(EngineSpan::name).collect();
assert_eq!(names, ["prepass", "main", "composite", "blit"]);
}
#[test]
fn an_inert_sink_times_no_pass() {
let sink = FrameTimestamps::inert();
assert!(!sink.is_active());
for span in EngineSpan::ALL {
assert!(sink.writes(span).is_none());
}
assert!(!FrameTimestamps::default().is_active());
}
#[test]
fn a_sink_over_an_inert_ring_is_itself_inert() {
let ring = TimestampRing::inert(EngineSpan::COUNT);
let sink = FrameTimestamps::new(&ring);
assert!(!sink.is_active());
assert!(sink.writes(EngineSpan::Main).is_none());
}
#[test]
fn the_label_scan_accepts_both_shapes_this_crate_writes() {
assert!(labels_are_prefixed(" label: Some(\"frust-engine clear\"),").is_ok());
assert!(labels_are_prefixed(" label: \"frust-engine hole punch\",").is_ok());
assert_eq!(
labels_are_prefixed(" label: Some(\"scratch\"),"),
Err("scratch".to_string())
);
assert_eq!(
labels_are_prefixed(" label: \"page\","),
Err("page".to_string())
);
}
#[test]
fn the_label_scan_ignores_a_comment_mentioning_one() {
assert!(labels_are_prefixed(" // label: Some(\"anything\") is prose here").is_ok());
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => std::thread::yield_now(),
}
}
}
#[test]
#[ignore = "needs a real GPU adapter offering TIMESTAMP_QUERY; run with \
`cargo test -p frust-engine -- --ignored` (pin the adapter on a \
multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
fn a_real_frame_reports_plausible_per_span_gpu_time() {
use crate::{EngineRenderer, EngineTarget, OutputAlpha};
use frust_gpu::{HeadlessTarget, TierCaps};
use frust_scene::{Scene, SceneBuilder};
use kurbo::{Affine, Rect};
use peniko::Brush;
use peniko::color::palette::css;
use std::time::Duration;
const SIZE: u32 = 512;
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
block_on(async {
let instance = wgpu::Instance::new(
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
);
let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
.await
.expect("no compatible GPU adapter");
println!(
"frust-engine gpu-timestamp adapter: {:?}",
adapter.get_info()
);
let caps = TierCaps::probe(&adapter);
if !caps.has_timestamp_query {
println!("adapter offers no TIMESTAMP_QUERY — the inert path is the whole story");
return;
}
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-engine gpu-timestamp device"),
required_features: wgpu::Features::TIMESTAMP_QUERY,
required_limits: wgpu::Limits::default(),
..Default::default()
})
.await
.expect("failed to create the device");
let mut renderer = EngineRenderer::new(&device, &caps, FORMAT, None)
.expect("engine renderer for a headless target");
renderer.finish_warm_up(&device);
let target = HeadlessTarget::new(&device, SIZE, SIZE, FORMAT);
let mut ring = frust_gpu::diag::TimestampRing::new(
&device,
&queue,
EngineSpan::COUNT,
TIMESTAMP_RING_LABEL,
);
assert!(
ring.is_active(),
"a TIMESTAMP_QUERY device must arm the ring"
);
let mut scene = Scene::new();
let mut builder = SceneBuilder::new(&mut scene);
builder.fill_rect(
Rect::new(0.0, 0.0, f64::from(SIZE), f64::from(SIZE)),
Brush::Solid(css::REBECCA_PURPLE),
);
builder.fill_rect(
Rect::new(16.0, 16.0, 480.0, 480.0),
Brush::Solid(css::CORNFLOWER_BLUE.with_alpha(0.5)),
);
builder.push_layer(Rect::new(32.0, 32.0, 464.0, 464.0), 0.5);
builder.fill_rect(
Rect::new(48.0, 48.0, 448.0, 448.0),
Brush::Solid(css::ORANGE),
);
builder.pop_layer();
let mut readings = 0_u64;
for frame in 0..(frust_gpu::diag::RING_FRAMES * 3) {
ring.begin_frame(&device);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-engine gpu-timestamp frame"),
});
renderer
.encode_traced(
&device,
&queue,
&mut encoder,
&scene,
EngineTarget {
view: target.view(),
format: FORMAT,
width: SIZE,
height: SIZE,
depth: None,
output: OutputAlpha::Premultiplied,
},
css::BLACK,
Affine::IDENTITY,
FrameTimestamps::new(&ring),
)
.expect("the engine must serve this frame");
ring.resolve(&mut encoder);
queue.submit([encoder.finish()]);
ring.end_frame();
renderer.end_frame(&queue);
let _ = device.poll(wgpu::PollType::wait_indefinitely());
if ring.harvested() > readings {
readings = ring.harvested();
let reading = ring.latest().expect("a harvest produces a reading");
println!(
"frame {frame}: main={:?} composite={:?} total={:?}",
reading.span(EngineSpan::Main.index()),
reading.span(EngineSpan::Composite.index()),
reading.total()
);
}
}
let reading = ring
.latest()
.expect("at least one reading must have landed");
assert_eq!(reading.len(), EngineSpan::COUNT);
assert!(
reading.span(EngineSpan::Main.index()) > Duration::ZERO,
"the frame's own surface passes drew: {reading:?}"
);
assert!(
reading.span(EngineSpan::Composite.index()) > Duration::ZERO,
"the sub-unity layer rendered into a page: {reading:?}"
);
assert_eq!(
reading.total(),
reading.span(EngineSpan::Main.index())
+ reading.span(EngineSpan::Composite.index())
+ reading.span(EngineSpan::Prepass.index())
+ reading.span(EngineSpan::Blit.index())
);
assert!(
reading.total() < Duration::from_millis(100),
"one 512x512 frame cannot plausibly take {:?}",
reading.total()
);
});
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
out.push(path);
}
}
}
#[test]
fn every_gpu_object_this_crate_creates_is_labelled_frust_engine() {
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
rust_files(&src, &mut files);
files.sort();
assert!(!files.is_empty(), "{} has no sources", src.display());
let mut failures = Vec::new();
for path in files {
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
if let Err(label) = labels_are_prefixed(&contents) {
failures.push(format!("{}: `{label}`", path.display()));
}
}
assert!(
failures.is_empty(),
"every GPU object must be labelled `{LABEL_PREFIX}<kind>`: {failures:?}"
);
}
}