blob: 939e1953f8e58316a28581499b191c47dde6a445 [file]
// Copyright 2026 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Demonstrates using Vello GPU through wgpu's WebGPU backend in the browser.
#![allow(
clippy::cast_possible_truncation,
reason = "truncation has no appreciable impact in this demo"
)]
#![cfg(target_arch = "wasm32")]
use std::{cell::RefCell, rc::Rc};
use vello_common::{
fearless_simd::Level,
kurbo::{Affine, Vec2},
paint::{ImageId, ImageSource},
};
use vello_example_scenes::{
AnyScene,
image::ImageScene,
performance::{FrameTiming, PerformancePanel, PerformanceStage, now},
};
use vello_gpu::{Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene};
use wasm_bindgen::prelude::*;
use web_sys::{Event, HtmlCanvasElement, KeyboardEvent, MouseEvent, WheelEvent};
use wgpu::CurrentSurfaceTexture;
struct RendererWrapper {
renderer: Renderer,
resources: vello_gpu::Resources,
device: wgpu::Device,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
surface_format: wgpu::TextureFormat,
depth_texture_view: wgpu::TextureView,
}
impl RendererWrapper {
async fn new(canvas: HtmlCanvasElement) -> Self {
let width = canvas.width();
let height = canvas.height();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
display: None,
backends: wgpu::Backends::BROWSER_WEBGPU,
flags: wgpu::InstanceFlags::from_build_config().with_env(),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
backend_options: wgpu::BackendOptions::from_env_or_default(),
});
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
.expect("Canvas surface to be valid");
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
})
.await
.expect("Adapter to be valid");
let capabilities = surface.get_capabilities(&adapter);
let surface_format = capabilities
.formats
.iter()
.copied()
.find(|format| !format.is_srgb())
.unwrap_or(capabilities.formats[0]);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..Default::default()
})
.await
.expect("Device to be valid");
// Configure the surface
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width,
height,
present_mode: wgpu::PresentMode::Fifo,
color_space: wgpu::SurfaceColorSpace::Auto,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
desired_maximum_frame_latency: 2,
view_formats: vec![],
};
surface.configure(&device, &surface_config);
let settings = RenderSettings {
level: Level::try_detect().unwrap_or(Level::baseline()),
..Default::default()
};
let (renderer, resources) = Renderer::new_with(
&device,
&RenderTargetConfig {
format: surface_format,
width: width.try_into().unwrap(),
height: height.try_into().unwrap(),
},
settings,
);
let depth_texture_view = Renderer::create_depth_texture_view(
&device,
&vello_gpu::RenderSize {
width: width.try_into().unwrap(),
height: height.try_into().unwrap(),
},
);
Self {
renderer,
resources,
device,
queue,
surface,
surface_format,
depth_texture_view,
}
}
fn resize(&mut self, width: u32, height: u32) {
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: self.surface_format,
width,
height,
present_mode: wgpu::PresentMode::Fifo,
color_space: wgpu::SurfaceColorSpace::Auto,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
desired_maximum_frame_latency: 2,
view_formats: vec![],
};
self.surface.configure(&self.device, &surface_config);
self.depth_texture_view = Renderer::create_depth_texture_view(
&self.device,
&vello_gpu::RenderSize {
width: width.try_into().unwrap(),
height: height.try_into().unwrap(),
},
);
}
}
fn client_to_canvas_px(canvas: &HtmlCanvasElement, client_x: f64, client_y: f64) -> Vec2 {
let rect = canvas.get_bounding_client_rect();
let width = rect.width().max(1.0);
let height = rect.height().max(1.0);
let x = ((client_x - rect.left()) / width).clamp(0.0, 1.0);
let y = ((client_y - rect.top()) / height).clamp(0.0, 1.0);
Vec2::new(x * canvas.width() as f64, y * canvas.height() as f64)
}
fn client_delta_to_canvas_px(canvas: &HtmlCanvasElement, delta_x: f64, delta_y: f64) -> Vec2 {
let rect = canvas.get_bounding_client_rect();
let scale_x = canvas.width() as f64 / rect.width().max(1.0);
let scale_y = canvas.height() as f64 / rect.height().max(1.0);
Vec2::new(delta_x * scale_x, delta_y * scale_y)
}
/// State that handles scene rendering and interactions
struct AppState {
scenes: Box<[AnyScene<Scene>]>,
current_scene: usize,
scene: Scene,
transform: Affine,
mouse_down: bool,
last_cursor_position: Option<Vec2>,
last_drag_client_position: Option<Vec2>,
width: u32,
height: u32,
renderer_wrapper: RendererWrapper,
performance: PerformancePanel<3>,
canvas: HtmlCanvasElement,
}
impl AppState {
async fn new(canvas: HtmlCanvasElement, scenes: Box<[AnyScene<Scene>]>) -> Self {
let width = canvas.width();
let height = canvas.height();
let current_scene = initial_scene_index(scenes.len());
let renderer_wrapper = RendererWrapper::new(canvas.clone()).await;
let mut app_state = Self {
scenes,
current_scene,
scene: Scene::new(width as u16, height as u16),
transform: Affine::IDENTITY,
mouse_down: false,
last_cursor_position: None,
last_drag_client_position: None,
width,
height,
renderer_wrapper,
performance: PerformancePanel::new(
"Vello GPU · wgpu → WebGPU",
[
PerformanceStage {
label: "Scene build",
description: "CPU time to reset and populate the scene.",
color: "#ef4444",
},
PerformanceStage {
label: "Render/encode",
description: "CPU time to encode the renderer's WebGPU commands.",
color: "#f59e0b",
},
PerformanceStage {
label: "Submit/present",
description: "CPU time to submit commands and present the surface; GPU completion is excluded.",
color: "#3b82f6",
},
],
"GPU timing is unavailable for the WebGPU demo",
),
canvas,
};
update_page_url(app_state.current_scene);
app_state.update_title();
// Upload images to the renderer's texture atlas.
app_state.upload_images_to_atlas();
app_state
}
fn render(&mut self) -> Option<FrameTiming<3>> {
let frame_start = now();
self.scene.reset();
// Render the current scene with transform
self.scenes[self.current_scene].render(
&mut self.scene,
&mut self.renderer_wrapper.resources,
self.transform,
);
let scene_end = now();
let render_size = vello_gpu::RenderSize {
width: self.width.try_into().unwrap(),
height: self.height.try_into().unwrap(),
};
let surface_texture = match self.renderer_wrapper.surface.get_current_texture() {
CurrentSurfaceTexture::Success(surface_texture) => surface_texture,
CurrentSurfaceTexture::Occluded
| CurrentSurfaceTexture::Timeout
| CurrentSurfaceTexture::Outdated
| CurrentSurfaceTexture::Suboptimal(_) => {
return None;
}
CurrentSurfaceTexture::Lost => panic!("Surface was lost"),
CurrentSurfaceTexture::Validation => {
panic!("Validation error getting surface")
}
};
let surface_texture_view = surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.renderer_wrapper
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
self.renderer_wrapper
.renderer
.render(
&self.scene,
&mut self.renderer_wrapper.resources,
&self.renderer_wrapper.device,
&self.renderer_wrapper.queue,
&mut encoder,
&render_size,
&surface_texture_view,
Some(&self.renderer_wrapper.depth_texture_view),
&vello_gpu::TextureBindings::new(),
vello_gpu::TargetInit::Clear(vello_gpu::ClearSettings::default()),
)
.unwrap();
let render_end = now();
self.renderer_wrapper.queue.submit([encoder.finish()]);
self.renderer_wrapper.queue.present(surface_texture);
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,
})
}
fn frame(&mut self, timestamp: f64) {
let timing = self.render();
let scene = self.current_scene + 1;
let scene_count = self.scenes.len();
self.performance.record(
timestamp,
timing,
scene,
scene_count,
self.width,
self.height,
);
}
fn resize(&mut self, width: u32, height: u32) {
self.canvas.set_width(width);
self.canvas.set_height(height);
self.width = width;
self.height = height;
self.scene.reset_and_resize(width as u16, height as u16);
self.renderer_wrapper.resize(width, height);
self.performance.reset();
}
fn next_scene(&mut self) {
self.current_scene = (self.current_scene + 1) % self.scenes.len();
update_page_url(self.current_scene);
self.update_title();
self.transform = Affine::IDENTITY;
self.performance.reset();
}
fn prev_scene(&mut self) {
self.current_scene = if self.current_scene == 0 {
self.scenes.len() - 1
} else {
self.current_scene - 1
};
update_page_url(self.current_scene);
self.update_title();
self.transform = Affine::IDENTITY;
self.performance.reset();
}
fn update_title(&self) {
web_sys::window()
.unwrap()
.document()
.unwrap()
.set_title(&format!(
"Vello GPU WebGPU via wgpu - Page {}/{}",
self.current_scene + 1,
self.scenes.len()
));
}
fn reset_transform(&mut self) {
self.transform = Affine::IDENTITY;
}
fn handle_key(&mut self, key: &str) {
if let Some(scene) = self.scenes.get_mut(self.current_scene) {
scene.handle_key(key);
}
}
fn handle_mouse_down(&mut self, x: f64, y: f64) {
self.mouse_down = true;
self.last_cursor_position = Some(client_to_canvas_px(&self.canvas, x, y));
self.last_drag_client_position = Some(Vec2::new(x, y));
}
fn handle_mouse_up(&mut self) {
self.mouse_down = false;
self.last_cursor_position = None;
self.last_drag_client_position = None;
}
fn handle_mouse_move(&mut self, x: f64, y: f64) {
let current_pos = client_to_canvas_px(&self.canvas, x, y);
if self.mouse_down
&& let Some(last_client_pos) = self.last_drag_client_position
{
let delta = client_delta_to_canvas_px(
&self.canvas,
x - last_client_pos.x,
y - last_client_pos.y,
);
self.transform = Affine::translate(delta) * self.transform;
}
self.last_cursor_position = Some(current_pos);
self.last_drag_client_position = Some(Vec2::new(x, y));
}
fn handle_wheel(
&mut self,
client_x: f64,
client_y: f64,
delta_y: f64,
ctrl_key: bool,
delta_mode: u32,
) {
let cursor_pos = client_to_canvas_px(&self.canvas, client_x, client_y);
self.last_cursor_position = Some(cursor_pos);
let scale = if ctrl_key {
0.01
} else {
let line_mult = if delta_mode == 1 { 16.0 } else { 1.0 };
0.002 * line_mult
};
let zoom_factor = (-delta_y * scale).exp();
self.transform = Affine::translate(cursor_pos)
* Affine::scale(zoom_factor)
* Affine::translate(-cursor_pos)
* self.transform;
}
fn upload_images_to_atlas(&mut self) {
let mut encoder =
self.renderer_wrapper
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Upload Image pass"),
});
// 1st example — uploading a pixmap directly to the texture atlas.
let pixmap1 = ImageScene::read_flower_image();
self.renderer_wrapper.renderer.upload_image(
&mut self.renderer_wrapper.resources,
&self.renderer_wrapper.device,
&self.renderer_wrapper.queue,
&mut encoder,
&pixmap1,
);
// 2nd example — uploading from a wgpu texture.
let pixmap2 = ImageScene::read_cowboy_image();
let texture2 = self.upload_image_to_texture(
&self.renderer_wrapper.device,
&self.renderer_wrapper.queue,
&pixmap2,
);
self.renderer_wrapper.renderer.upload_image(
&mut self.renderer_wrapper.resources,
&self.renderer_wrapper.device,
&self.renderer_wrapper.queue,
&mut encoder,
&texture2,
);
self.renderer_wrapper.queue.submit([encoder.finish()]);
}
fn upload_image_to_texture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
image: &Pixmap,
) -> wgpu::Texture {
let image_width = image.width() as u32;
let image_height = image.height() as u32;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Uploaded Image Texture"),
size: wgpu::Extent3d {
width: image_width,
height: image_height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
image.data_as_u8_slice(),
wgpu::TexelCopyBufferLayout {
offset: 0,
// 4 bytes per RGBA pixel
bytes_per_row: Some(4 * image_width),
rows_per_image: Some(image_height),
},
wgpu::Extent3d {
width: image_width,
height: image_height,
depth_or_array_layers: 1,
},
);
texture
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = requestAnimationFrame)]
fn request_animation_frame(f: &Closure<dyn FnMut(f64)>);
}
/// Creates a `HTMLCanvasElement` of the given dimensions and renders the given scenes into it,
/// with interactive controls for panning, zooming, and switching between scenes.
pub async fn run_interactive(canvas_width: u16, canvas_height: u16) {
let canvas = web_sys::Window::document(&web_sys::window().unwrap())
.unwrap()
.create_element("canvas")
.unwrap()
.dyn_into::<HtmlCanvasElement>()
.unwrap();
canvas.set_width(canvas_width as u32);
canvas.set_height(canvas_height as u32);
canvas.style().set_property("width", "100%").unwrap();
canvas.style().set_property("height", "100%").unwrap();
let body = web_sys::Window::document(&web_sys::window().unwrap())
.unwrap()
.body()
.unwrap();
// Apply background color so white text can be seen.
body.style()
.set_property("background-color", "#111")
.unwrap();
// Add canvas to body
web_sys::Window::document(&web_sys::window().unwrap())
.unwrap()
.body()
.unwrap()
.append_child(&canvas)
.unwrap();
let scenes = vello_example_scenes::get_example_scenes(
vello_example_scenes::Capabilities::default(),
vec![
ImageSource::opaque_id(ImageId::new(0)),
ImageSource::opaque_id(ImageId::new(1)),
],
);
let app_state = Rc::new(RefCell::new(AppState::new(canvas.clone(), scenes).await));
// Set up animation frame loop
{
let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
let g = f.clone();
let app_state = app_state.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move |timestamp: f64| {
app_state.borrow_mut().frame(timestamp);
request_animation_frame(f.borrow().as_ref().unwrap());
}) as Box<dyn FnMut(f64)>));
request_animation_frame(g.borrow().as_ref().unwrap());
}
// Set up window resize event handler
{
let app_state = app_state.clone();
let closure = Closure::wrap(Box::new(move |_: Event| {
let window = web_sys::window().unwrap();
let dpr = window.device_pixel_ratio();
let width = (window.inner_width().unwrap().as_f64().unwrap() * dpr) as u32;
let height = (window.inner_height().unwrap().as_f64().unwrap() * dpr) as u32;
app_state.borrow_mut().resize(width, height);
}) as Box<dyn FnMut(_)>);
let window = web_sys::window().unwrap();
window
.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}
// Set up event handlers
// Mouse down
{
let app_state = app_state.clone();
let closure = Closure::wrap(Box::new(move |event: MouseEvent| {
app_state
.borrow_mut()
.handle_mouse_down(event.client_x() as f64, event.client_y() as f64);
}) as Box<dyn FnMut(_)>);
canvas
.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}
// Mouse up
{
let app_state = app_state.clone();
let window = web_sys::window().unwrap();
let closure = Closure::wrap(Box::new(move |_event: MouseEvent| {
app_state.borrow_mut().handle_mouse_up();
}) as Box<dyn FnMut(_)>);
window
.add_event_listener_with_callback("mouseup", closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}
// Mouse move
{
let app_state = app_state.clone();
let window = web_sys::window().unwrap();
let closure = Closure::wrap(Box::new(move |event: MouseEvent| {
app_state
.borrow_mut()
.handle_mouse_move(event.client_x() as f64, event.client_y() as f64);
}) as Box<dyn FnMut(_)>);
window
.add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}
// Mouse wheel
{
let app_state = app_state.clone();
let closure = Closure::wrap(Box::new(move |event: WheelEvent| {
event.prevent_default();
app_state.borrow_mut().handle_wheel(
event.client_x() as f64,
event.client_y() as f64,
event.delta_y(),
event.ctrl_key(),
event.delta_mode(),
);
}) as Box<dyn FnMut(_)>);
let opts = web_sys::AddEventListenerOptions::new();
canvas
.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
closure.as_ref().unchecked_ref(),
&opts,
)
.unwrap();
closure.forget();
}
// Keyboard events (document level)
{
let app_state = app_state.clone();
let document = web_sys::window().unwrap().document().unwrap();
let closure = Closure::wrap(Box::new(move |event: KeyboardEvent| {
let key = event.key();
match key.as_str() {
"ArrowRight" => app_state.borrow_mut().next_scene(),
"ArrowLeft" => app_state.borrow_mut().prev_scene(),
" " => app_state.borrow_mut().reset_transform(),
_ => app_state.borrow_mut().handle_key(key.as_str()),
}
}) as Box<dyn FnMut(_)>);
document
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}
// Create instructions element
let document = web_sys::window().unwrap().document().unwrap();
let instructions = document.create_element("div").unwrap();
instructions.set_inner_html(
"Left/Right Arrow: Change scene | Space: Reset view | Mouse Drag: Pan | Mouse Wheel: Zoom",
);
let style = instructions
.dyn_ref::<web_sys::HtmlElement>()
.unwrap()
.style();
style.set_property("position", "fixed").unwrap();
style.set_property("bottom", "10px").unwrap();
style.set_property("left", "10px").unwrap();
style
.set_property("background", "rgba(0, 0, 0, 0.5)")
.unwrap();
style.set_property("color", "white").unwrap();
style.set_property("padding", "5px 10px").unwrap();
style.set_property("border-radius", "5px").unwrap();
style.set_property("font-family", "sans-serif").unwrap();
style.set_property("pointer-events", "none").unwrap();
document
.body()
.unwrap()
.append_child(&instructions)
.unwrap();
}
/// Creates a `HTMLCanvasElement` and renders a single scene into it
pub async fn render_scene(scene: Scene, width: u16, height: u16) {
let canvas = web_sys::Window::document(&web_sys::window().unwrap())
.unwrap()
.create_element("canvas")
.unwrap()
.dyn_into::<HtmlCanvasElement>()
.unwrap();
canvas.set_width(width as u32);
canvas.set_height(height as u32);
canvas.style().set_property("width", "100%").unwrap();
canvas.style().set_property("height", "100%").unwrap();
// Add canvas to body
web_sys::Window::document(&web_sys::window().unwrap())
.unwrap()
.body()
.unwrap()
.append_child(&canvas)
.unwrap();
let RendererWrapper {
mut renderer,
mut resources,
device,
queue,
surface,
depth_texture_view,
..
} = RendererWrapper::new(canvas).await;
let render_size = vello_gpu::RenderSize { width, height };
let surface_texture = match surface.get_current_texture() {
CurrentSurfaceTexture::Success(surface_texture) => surface_texture,
e => panic!("Error getting initial surface: {e:?}"),
};
let surface_texture_view = surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
renderer
.render(
&scene,
&mut resources,
&device,
&queue,
&mut encoder,
&render_size,
&surface_texture_view,
Some(&depth_texture_view),
&vello_gpu::TextureBindings::new(),
vello_gpu::TargetInit::Clear(vello_gpu::ClearSettings::default()),
)
.unwrap();
queue.submit([encoder.finish()]);
queue.present(surface_texture);
}
fn initial_scene_index(scene_count: usize) -> usize {
web_sys::window()
.and_then(|window| window.location().search().ok())
.and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
.and_then(|params| params.get("page"))
.and_then(|page| page.parse::<usize>().ok())
.filter(|page| (1..=scene_count).contains(page))
.map_or(0, |page| page - 1)
}
fn update_page_url(scene_index: usize) {
web_sys::window()
.unwrap()
.history()
.unwrap()
.replace_state_with_url(
&JsValue::NULL,
"",
Some(&format!("?page={}", scene_index + 1)),
)
.unwrap();
}