feat: add GPU timing to WebGL browser examples (#1852)
Add asynchronous GPU execution timing to the native WebGL2 and
wgpu-WebGL browser examples using disjoint timer queries.
- Vello Hybrid WebGL backend - Tiger scene 1
<img width="418" height="340" alt="image"
src="https://github.com/user-attachments/assets/134fdfe5-d7ab-4428-b29a-c4c1c29733d9"
/>
- Vello Hybrid WebGL backend - Filters scene 7
<img width="415" height="340" alt="image"
src="https://github.com/user-attachments/assets/fb28fc68-6b5c-45fd-bf13-2476271276b8"
/>
Created with assistance from GPT-5.6 Sol.
diff --git a/sparse_strips/vello_example_scenes/Cargo.toml b/sparse_strips/vello_example_scenes/Cargo.toml
index df3940c..afb5d3c 100644
--- a/sparse_strips/vello_example_scenes/Cargo.toml
+++ b/sparse_strips/vello_example_scenes/Cargo.toml
@@ -12,6 +12,12 @@
[features]
default = []
cpu = ["dep:vello_cpu"]
+webgl_gpu_timing = [
+ "web-sys/ExtDisjointTimerQuery",
+ "web-sys/HtmlCanvasElement",
+ "web-sys/WebGl2RenderingContext",
+ "web-sys/WebGlQuery",
+]
[dependencies]
bytemuck = { workspace = true, features = [] }
diff --git a/sparse_strips/vello_example_scenes/src/performance.rs b/sparse_strips/vello_example_scenes/src/performance.rs
index c2195a2..dca19ab 100644
--- a/sparse_strips/vello_example_scenes/src/performance.rs
+++ b/sparse_strips/vello_example_scenes/src/performance.rs
@@ -7,13 +7,123 @@
reason = "Sample windows are bounded to 120 entries"
)]
+#[cfg(feature = "webgl_gpu_timing")]
+use std::collections::VecDeque;
use std::fmt::Write as _;
use wasm_bindgen::JsCast;
use web_sys::HtmlElement;
+#[cfg(feature = "webgl_gpu_timing")]
+use web_sys::{ExtDisjointTimerQuery, HtmlCanvasElement, WebGl2RenderingContext, WebGlQuery};
const MAX_SAMPLES: usize = 120;
const PANEL_UPDATE_INTERVAL_MS: f64 = 250.0;
const SUSPENDED_FRAME_THRESHOLD_MS: f64 = 250.0;
+#[cfg(feature = "webgl_gpu_timing")]
+const MAX_PENDING_GPU_QUERIES: usize = 8;
+
+#[cfg(feature = "webgl_gpu_timing")]
+#[derive(Debug)]
+/// Measures GPU-clock elapsed time for WebGL command ranges without blocking for results.
+///
+/// [`begin`](Self::begin) marks the start of a range in the GPU command stream, and
+/// [`end`](Self::end) marks its end. The result is the time the GPU takes to process the
+/// commands submitted between those markers.
+///
+/// Measurements can include GPU stalls or idle gaps inside the range, but not CPU execution time.
+pub struct WebGlGpuTimer {
+ gl: WebGl2RenderingContext,
+ pending: VecDeque<WebGlQuery>,
+ active: Option<WebGlQuery>,
+}
+
+#[cfg(feature = "webgl_gpu_timing")]
+impl WebGlGpuTimer {
+ pub fn new(canvas: &HtmlCanvasElement) -> Option<Self> {
+ let gl = canvas
+ .get_context("webgl2")
+ .ok()
+ .flatten()?
+ .dyn_into::<WebGl2RenderingContext>()
+ .ok()?;
+ gl.get_extension("EXT_disjoint_timer_query_webgl2")
+ .ok()
+ .flatten()?;
+
+ Some(Self {
+ gl,
+ pending: VecDeque::with_capacity(MAX_PENDING_GPU_QUERIES),
+ active: None,
+ })
+ }
+
+ /// Starts a measurement unless one is active or the pending-result queue is full.
+ pub fn begin(&mut self) {
+ if self.active.is_some() || self.pending.len() == MAX_PENDING_GPU_QUERIES {
+ return;
+ }
+ let Some(query) = self.gl.create_query() else {
+ return;
+ };
+ self.gl
+ .begin_query(ExtDisjointTimerQuery::TIME_ELAPSED_EXT, &query);
+ self.active = Some(query);
+ }
+
+ /// Ends the active measurement and queues it for asynchronous retrieval.
+ pub fn end(&mut self) {
+ let Some(query) = self.active.take() else {
+ return;
+ };
+ self.gl.end_query(ExtDisjointTimerQuery::TIME_ELAPSED_EXT);
+ self.pending.push_back(query);
+ }
+
+ /// Returns the oldest available measurement in milliseconds without waiting for the GPU.
+ ///
+ /// If the GPU timer becomes unreliable, for example after a GPU reset or clock-speed
+ /// change, all pending measurements are discarded because their durations may be incorrect.
+ pub fn poll(&mut self) -> Option<f64> {
+ let disjoint = self
+ .gl
+ .get_parameter(ExtDisjointTimerQuery::GPU_DISJOINT_EXT)
+ .ok()
+ .and_then(|value| value.as_bool())
+ .unwrap_or(false);
+ if disjoint {
+ self.clear_pending();
+ return None;
+ }
+
+ let query = self.pending.front()?;
+ let available = self
+ .gl
+ .get_query_parameter(query, WebGl2RenderingContext::QUERY_RESULT_AVAILABLE)
+ .as_bool()
+ .unwrap_or(false);
+ if !available {
+ return None;
+ }
+
+ let query = self.pending.pop_front().unwrap();
+ let duration_ns = self
+ .gl
+ .get_query_parameter(&query, WebGl2RenderingContext::QUERY_RESULT)
+ .as_f64();
+ self.gl.delete_query(Some(&query));
+ duration_ns.map(|duration_ns| duration_ns / 1_000_000.0)
+ }
+
+ /// Discards pending measurements; this must be called outside an active measurement.
+ pub fn reset(&mut self) {
+ self.clear_pending();
+ }
+
+ fn clear_pending(&mut self) {
+ for query in self.pending.drain(..) {
+ self.gl.delete_query(Some(&query));
+ }
+ }
+}
#[derive(Clone, Copy, Debug)]
pub struct PerformanceStage {
@@ -39,6 +149,7 @@
frame_intervals: Vec<f64>,
stage_times: [Vec<f64>; N],
total_times: Vec<f64>,
+ gpu_times: Vec<f64>,
}
impl<const N: usize> PerformancePanel<N> {
@@ -96,6 +207,16 @@
frame_intervals: Vec::with_capacity(MAX_SAMPLES),
stage_times: std::array::from_fn(|_| Vec::with_capacity(MAX_SAMPLES)),
total_times: Vec::with_capacity(MAX_SAMPLES),
+ gpu_times: Vec::with_capacity(MAX_SAMPLES),
+ }
+ }
+
+ pub fn record_gpu_time(&mut self, duration_ms: Option<f64>) {
+ if let Some(duration_ms) = duration_ms
+ && duration_ms.is_finite()
+ && duration_ms >= 0.0
+ {
+ push_sample(&mut self.gpu_times, duration_ms);
}
}
@@ -149,6 +270,7 @@
samples.clear();
}
self.total_times.clear();
+ self.gpu_times.clear();
}
fn update_text(&self, scene: usize, scene_count: usize, width: u32, height: u32) {
@@ -161,6 +283,7 @@
let stage_stats = std::array::from_fn(|index| {
SampleStats::from_samples(&self.stage_times[index]).unwrap()
});
+ let gpu = SampleStats::from_samples(&self.gpu_times);
let html = format_panel_html(&PanelSnapshot {
backend: self.backend,
@@ -174,6 +297,8 @@
total: &total,
stage_stats: &stage_stats,
sample_count: self.total_times.len(),
+ gpu: gpu.as_ref(),
+ gpu_sample_count: self.gpu_times.len(),
});
self.element.set_inner_html(&html);
}
@@ -191,12 +316,20 @@
total: &'a SampleStats,
stage_stats: &'a [SampleStats; N],
sample_count: usize,
+ gpu: Option<&'a SampleStats>,
+ gpu_sample_count: usize,
}
fn format_panel_html<const N: usize>(panel: &PanelSnapshot<'_, N>) -> String {
let fps = 1_000.0 / panel.frame.average;
- let timeline_duration = panel.frame.average.max(panel.total.average);
- let other_percent = (100.0 - 100.0 * panel.total.average / timeline_duration).max(0.0);
+ let timeline_duration = panel
+ .gpu
+ .map_or(panel.frame.average, |gpu| {
+ panel.frame.average.max(gpu.average)
+ })
+ .max(panel.total.average);
+ let other_percent =
+ 100.0 * (panel.frame.average - panel.total.average).max(0.0) / timeline_duration;
let other_average = (panel.frame.average - panel.total.average).max(0.0);
let mut html = String::with_capacity(4096);
@@ -223,6 +356,11 @@
)
.unwrap();
}
+ if panel.gpu.is_some() {
+ html.push_str(
+ r#"<span class="vello-perf-tooltip-row"><strong>GPU execution</strong><span>GPU time for rendering, submission, and presentation commands. Results arrive asynchronously and overlap CPU work.</span></span>"#,
+ );
+ }
html.push_str(
r#"<span class="vello-perf-tooltip-row"><strong>Browser/rAF</strong><span>Browser work, scheduling, and display wait.</span></span>
<span class="vello-perf-tooltip-row"><strong>Full render</strong><span>Sum of measured stages, on the CPU.</span></span>
@@ -251,14 +389,30 @@
write!(
html,
r#"<div style="width:{other_percent:.3}%;background:rgba(255,255,255,.16)"></div>
+</div>"#,
+ )
+ .unwrap();
+ if let Some(gpu) = panel.gpu {
+ let gpu_percent = 100.0 * gpu.average / timeline_duration;
+ write!(
+ html,
+ r#"<div style="display:flex;justify-content:space-between;margin:6px 0 4px">
+ <span>Average GPU execution</span><strong>{:.2} ms</strong>
</div>
-<div style="display:grid;grid-template-columns:10px 1fr 58px 58px 58px;column-gap:6px;align-items:center;margin-top:7px">
+<div style="height:10px;overflow:hidden;border:1px solid rgba(255,255,255,.45);border-radius:3px">
+ <div style="width:{gpu_percent:.3}%;height:100%;background:#a855f7"></div>
+</div>"#,
+ gpu.average,
+ )
+ .unwrap();
+ }
+ html.push_str(
+ r#"<div style="display:grid;grid-template-columns:10px 1fr 58px 58px 58px;column-gap:6px;align-items:center;margin-top:7px">
<span></span><span style="opacity:.7">Stage (ms)</span>
<span style="text-align:right;opacity:.7">avg</span>
<span style="text-align:right;opacity:.7">p95</span>
<span style="text-align:right;opacity:.7">max</span>"#,
- )
- .unwrap();
+ );
for (stage, stats) in panel.stages.iter().zip(panel.stage_stats) {
write!(
html,
@@ -276,14 +430,54 @@
r#"<span style="width:8px;height:8px;background:rgba(255,255,255,.35);border-radius:2px"></span>
<span>Browser/rAF</span>
<span style="text-align:right">{other_average:.2}</span>
- <span></span><span></span>
-</div>
+ <span></span><span></span>"#,
+ )
+ .unwrap();
+ if let Some(gpu) = panel.gpu {
+ write!(
+ html,
+ r#"<span style="width:8px;height:8px;background:#a855f7;border-radius:2px"></span>
+ <span>GPU execution</span>
+ <span style="text-align:right">{:.2}</span>
+ <span style="text-align:right">{:.2}</span>
+ <span style="text-align:right">{:.2}</span>"#,
+ gpu.average, gpu.p95, gpu.max,
+ )
+ .unwrap();
+ }
+ write!(
+ html,
+ r#"</div>
<div style="display:flex;justify-content:space-between;margin-top:8px;padding-top:7px;border-top:1px solid rgba(255,255,255,.25)">
<strong>Full CPU render</strong><strong>{:.2} ms avg</strong>
-</div>
-<div style="margin-top:3px;opacity:.65">{} samples</div>
+</div>"#,
+ panel.total.average,
+ )
+ .unwrap();
+ if let Some(gpu) = panel.gpu {
+ write!(
+ html,
+ r#"<div style="display:flex;justify-content:space-between;margin-top:3px">
+ <strong>Full GPU execution</strong><strong>{:.2} ms avg</strong>
+</div>"#,
+ gpu.average,
+ )
+ .unwrap();
+ }
+ write!(
+ html,
+ r#"<div style="margin-top:3px;opacity:.65">{} CPU samples"#,
+ panel.sample_count,
+ )
+ .unwrap();
+ if panel.gpu.is_some() {
+ write!(html, " · {} GPU samples", panel.gpu_sample_count).unwrap();
+ }
+ write!(
+ html,
+ r#"</div>
<div style="opacity:.65">{}</div>"#,
- panel.total.average, panel.sample_count, panel.timing_note,
+ panel.timing_note,
)
.unwrap();
html
diff --git a/sparse_strips/vello_hybrid/examples/native_webgl/Cargo.toml b/sparse_strips/vello_hybrid/examples/native_webgl/Cargo.toml
index f8227de..96df70d 100644
--- a/sparse_strips/vello_hybrid/examples/native_webgl/Cargo.toml
+++ b/sparse_strips/vello_hybrid/examples/native_webgl/Cargo.toml
@@ -20,7 +20,7 @@
log = { workspace = true }
vello_common = { workspace = true }
vello_hybrid = { workspace = true, features = ["webgl"] }
-vello_example_scenes = { workspace = true }
+vello_example_scenes = { workspace = true, features = ["webgl_gpu_timing"] }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
web-sys = { workspace = true, features = [
diff --git a/sparse_strips/vello_hybrid/examples/native_webgl/src/lib.rs b/sparse_strips/vello_hybrid/examples/native_webgl/src/lib.rs
index fffd651..d6d650b 100644
--- a/sparse_strips/vello_hybrid/examples/native_webgl/src/lib.rs
+++ b/sparse_strips/vello_hybrid/examples/native_webgl/src/lib.rs
@@ -19,7 +19,7 @@
use vello_example_scenes::image::ImageScene;
use vello_example_scenes::{
AnyScene,
- performance::{FrameTiming, PerformancePanel, PerformanceStage, now},
+ performance::{FrameTiming, PerformancePanel, PerformanceStage, WebGlGpuTimer, now},
};
use vello_hybrid::{RenderSettings, Scene};
use wasm_bindgen::prelude::*;
@@ -28,6 +28,7 @@
struct RendererWrapper {
renderer: vello_hybrid::WebGlRenderer,
resources: vello_hybrid::Resources,
+ gpu_timer: Option<WebGlGpuTimer>,
}
impl RendererWrapper {
@@ -38,6 +39,7 @@
Self {
renderer,
resources,
+ gpu_timer: WebGlGpuTimer::new(&canvas),
}
}
}
@@ -81,6 +83,11 @@
let current_scene = initial_scene_index(scenes.len());
let renderer_wrapper = RendererWrapper::new(canvas.clone());
+ let timing_note = if renderer_wrapper.gpu_timer.is_some() {
+ "GPU queries are asynchronous and may arrive several frames later"
+ } else {
+ "GPU timing unavailable: EXT_disjoint_timer_query_webgl2 is unsupported"
+ };
let mut app_state = Self {
scenes,
@@ -107,7 +114,7 @@
color: "#f59e0b",
},
],
- "GPU executes asynchronously and is not timed",
+ timing_note,
),
canvas,
};
@@ -119,7 +126,12 @@
app_state
}
- fn render(&mut self) -> FrameTiming<2> {
+ fn render(&mut self) -> (FrameTiming<2>, Option<f64>) {
+ let gpu_time = self
+ .renderer_wrapper
+ .gpu_timer
+ .as_mut()
+ .and_then(WebGlGpuTimer::poll);
let frame_start = now();
self.scene.reset();
@@ -136,6 +148,9 @@
height: self.height,
};
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.begin();
+ }
self.renderer_wrapper
.renderer
.render(
@@ -145,18 +160,25 @@
&vello_hybrid::WebGlTextureBindings::new(),
)
.unwrap();
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.end();
+ }
let frame_end = now();
- FrameTiming {
- stages_ms: [scene_end - frame_start, frame_end - scene_end],
- total_ms: frame_end - frame_start,
- }
+ (
+ FrameTiming {
+ stages_ms: [scene_end - frame_start, frame_end - scene_end],
+ total_ms: frame_end - frame_start,
+ },
+ gpu_time,
+ )
}
fn frame(&mut self, timestamp: f64) {
- let timing = self.render();
+ let (timing, gpu_time) = self.render();
let scene = self.current_scene + 1;
let scene_count = self.scenes.len();
+ self.performance.record_gpu_time(gpu_time);
self.performance.record(
timestamp,
Some(timing),
@@ -174,6 +196,9 @@
self.height = height;
self.scene.reset_and_resize(width as u16, height as u16);
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.reset();
+ }
self.performance.reset();
}
@@ -182,6 +207,9 @@
update_page_url(self.current_scene);
self.update_title();
self.transform = Affine::IDENTITY;
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.reset();
+ }
self.performance.reset();
}
@@ -194,6 +222,9 @@
update_page_url(self.current_scene);
self.update_title();
self.transform = Affine::IDENTITY;
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.reset();
+ }
self.performance.reset();
}
@@ -568,6 +599,7 @@
let RendererWrapper {
mut renderer,
mut resources,
+ ..
} = RendererWrapper::new(canvas);
let render_size = vello_hybrid::RenderSize {
diff --git a/sparse_strips/vello_hybrid/examples/wgpu_webgl/Cargo.toml b/sparse_strips/vello_hybrid/examples/wgpu_webgl/Cargo.toml
index 2662e5b..3908969 100644
--- a/sparse_strips/vello_hybrid/examples/wgpu_webgl/Cargo.toml
+++ b/sparse_strips/vello_hybrid/examples/wgpu_webgl/Cargo.toml
@@ -20,7 +20,7 @@
log = { workspace = true }
vello_common = { workspace = true }
vello_hybrid = { workspace = true }
-vello_example_scenes = { workspace = true }
+vello_example_scenes = { workspace = true, features = ["webgl_gpu_timing"] }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
web-sys = { workspace = true, features = [
diff --git a/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs b/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs
index 7438e62..45f24b0 100644
--- a/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs
+++ b/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs
@@ -9,8 +9,7 @@
)]
#![cfg(target_arch = "wasm32")]
-use std::cell::RefCell;
-use std::rc::Rc;
+use std::{cell::RefCell, rc::Rc};
use vello_common::{
fearless_simd::Level,
kurbo::{Affine, Point},
@@ -19,7 +18,7 @@
use vello_example_scenes::{
AnyScene,
image::ImageScene,
- performance::{FrameTiming, PerformancePanel, PerformanceStage, now},
+ performance::{FrameTiming, PerformancePanel, PerformanceStage, WebGlGpuTimer, now},
};
use vello_hybrid::{Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene};
use wasm_bindgen::prelude::*;
@@ -44,6 +43,7 @@
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
depth_texture_view: wgpu::TextureView,
+ gpu_timer: Option<WebGlGpuTimer>,
}
impl RendererWrapper {
@@ -119,6 +119,7 @@
queue,
surface,
depth_texture_view,
+ gpu_timer: WebGlGpuTimer::new(&canvas),
}
}
@@ -138,6 +139,9 @@
&self.device,
&vello_hybrid::RenderSize { width, height },
);
+ if let Some(gpu_timer) = &mut self.gpu_timer {
+ gpu_timer.reset();
+ }
}
}
@@ -163,6 +167,11 @@
let current_scene = initial_scene_index(scenes.len());
let renderer_wrapper = RendererWrapper::new(canvas.clone()).await;
+ let timing_note = if renderer_wrapper.gpu_timer.is_some() {
+ "GPU queries are asynchronous and may arrive several frames later"
+ } else {
+ "GPU timing unavailable: EXT_disjoint_timer_query_webgl2 is unsupported"
+ };
let mut app_state = Self {
scenes,
@@ -193,7 +202,7 @@
color: "#3b82f6",
},
],
- "GPU executes asynchronously and is not timed",
+ timing_note,
),
canvas,
};
@@ -206,7 +215,12 @@
app_state
}
- fn render(&mut self) -> Option<FrameTiming<3>> {
+ fn render(&mut self) -> (Option<FrameTiming<3>>, Option<f64>) {
+ let gpu_time = self
+ .renderer_wrapper
+ .gpu_timer
+ .as_mut()
+ .and_then(WebGlGpuTimer::poll);
let frame_start = now();
self.scene.reset();
@@ -229,7 +243,7 @@
| CurrentSurfaceTexture::Timeout
| CurrentSurfaceTexture::Outdated
| CurrentSurfaceTexture::Suboptimal(_) => {
- return None;
+ return (None, gpu_time);
}
CurrentSurfaceTexture::Lost => panic!("Surface was lost"),
CurrentSurfaceTexture::Validation => {
@@ -245,6 +259,9 @@
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.begin();
+ }
self.renderer_wrapper
.renderer
.render(
@@ -263,22 +280,29 @@
self.renderer_wrapper.queue.submit([encoder.finish()]);
surface_texture.present();
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.end();
+ }
let frame_end = now();
- Some(FrameTiming {
- stages_ms: [
- scene_end - frame_start,
- render_end - scene_end,
- frame_end - render_end,
- ],
- total_ms: frame_end - frame_start,
- })
+ (
+ Some(FrameTiming {
+ stages_ms: [
+ scene_end - frame_start,
+ render_end - scene_end,
+ frame_end - render_end,
+ ],
+ total_ms: frame_end - frame_start,
+ }),
+ gpu_time,
+ )
}
fn frame(&mut self, timestamp: f64) {
- let timing = self.render();
+ let (timing, gpu_time) = self.render();
let scene = self.current_scene + 1;
let scene_count = self.scenes.len();
+ self.performance.record_gpu_time(gpu_time);
self.performance.record(
timestamp,
timing,
@@ -305,6 +329,9 @@
update_page_url(self.current_scene);
self.update_title();
self.transform = Affine::IDENTITY;
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.reset();
+ }
self.performance.reset();
}
@@ -317,6 +344,9 @@
update_page_url(self.current_scene);
self.update_title();
self.transform = Affine::IDENTITY;
+ if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
+ gpu_timer.reset();
+ }
self.performance.reset();
}
@@ -678,6 +708,7 @@
queue,
surface,
depth_texture_view,
+ ..
} = RendererWrapper::new(canvas).await;
let render_size = vello_hybrid::RenderSize {