blob: 00771e198e93c68adef1beeb145ea1ab5377f40b [file]
// Copyright 2025 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Demonstrates using Vello with the WebGL2 renderer in the browser.
#![allow(
clippy::cast_possible_truncation,
reason = "truncation has no appreciable impact in this demo"
)]
#![cfg(target_arch = "wasm32")]
mod probe;
use probe::ProbeIndicator;
use std::cell::RefCell;
use std::rc::Rc;
use vello_common::paint::ImageId;
use vello_common::{
kurbo::{Affine, Vec2},
paint::ImageSource,
};
use vello_example_scenes::image::ImageScene;
use vello_example_scenes::{
AnyScene,
performance::{FrameTiming, PerformancePanel, PerformanceStage, WebGlGpuTimer, now},
};
use vello_gpu::{RenderSettings, Scene};
use wasm_bindgen::prelude::*;
use web_sys::{Event, HtmlCanvasElement, KeyboardEvent, MouseEvent, WheelEvent};
struct RendererWrapper {
renderer: vello_gpu::WebGlRenderer,
resources: vello_gpu::Resources,
gpu_timer: Option<WebGlGpuTimer>,
}
impl RendererWrapper {
fn new(canvas: HtmlCanvasElement) -> Self {
let settings = RenderSettings::default();
let (renderer, resources) = vello_gpu::WebGlRenderer::new_with(&canvas, settings, true)
.expect("failed to create WebGL renderer");
Self {
renderer,
resources,
gpu_timer: WebGlGpuTimer::new(&canvas),
}
}
}
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,
probe_indicator: ProbeIndicator,
performance: PerformancePanel<2>,
canvas: HtmlCanvasElement,
}
impl AppState {
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 mut renderer_wrapper = RendererWrapper::new(canvas.clone());
let probe_indicator = ProbeIndicator::new(&mut renderer_wrapper.renderer);
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,
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,
probe_indicator,
performance: PerformancePanel::new(
"Vello GPU ยท native WebGL2",
[
PerformanceStage {
label: "Scene build",
description: "CPU time to reset and populate the scene.",
color: "#ef4444",
},
PerformanceStage {
label: "Render/submit",
description: "CPU time to issue WebGL commands; GPU completion is excluded.",
color: "#f59e0b",
},
],
timing_note,
),
canvas,
};
update_page_url(app_state.current_scene);
app_state.update_title();
app_state.upload_images_to_atlas();
app_state
}
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();
// 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(),
};
if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
gpu_timer.begin();
}
self.renderer_wrapper
.renderer
.render(
&self.scene,
&mut self.renderer_wrapper.resources,
&render_size,
&vello_gpu::WebGlTextureBindings::new(),
vello_common::color::AlphaColor::TRANSPARENT,
)
.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,
},
gpu_time,
)
}
fn frame(&mut self, timestamp: f64) {
self.probe_indicator.poll(timestamp);
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),
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);
if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
gpu_timer.reset();
}
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;
if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
gpu_timer.reset();
}
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;
if let Some(gpu_timer) = &mut self.renderer_wrapper.gpu_timer {
gpu_timer.reset();
}
self.performance.reset();
}
fn update_title(&self) {
web_sys::window()
.unwrap()
.document()
.unwrap()
.set_title(&format!(
"Vello GPU WebGL - 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;
}
/// Upload images to the WebGL atlas texture
/// This is the WebGL analogue of the winit example's `upload_images_to_atlas` function
fn upload_images_to_atlas(&mut self) {
// 1st example โ€” uploading pixmap directly to WebGL atlas
let pixmap1 = ImageScene::read_flower_image();
self.renderer_wrapper
.renderer
.upload_image(&mut self.renderer_wrapper.resources, &pixmap1)
.expect("failed to upload pixmap to WebGL atlas");
// 2nd example โ€” uploading from a WebGL texture
let pixmap2 = ImageScene::read_cowboy_image();
let texture2 = self.pixmap_to_webgl_texture(&pixmap2);
self.renderer_wrapper
.renderer
.upload_image(&mut self.renderer_wrapper.resources, &texture2)
.expect("failed to upload texture to WebGL atlas");
}
/// Convert a pixmap to WebGL texture
fn pixmap_to_webgl_texture(
&self,
pixmap: &vello_gpu::Pixmap,
) -> vello_gpu::WebGlTextureWithDimensions {
let width = pixmap.width();
let height = pixmap.height();
let rgba_data = pixmap.data_as_u8_slice();
let gl = &self.renderer_wrapper.renderer.gl_context();
let texture = gl.create_texture().unwrap();
gl.active_texture(web_sys::WebGl2RenderingContext::TEXTURE0);
gl.bind_texture(web_sys::WebGl2RenderingContext::TEXTURE_2D, Some(&texture));
gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
web_sys::WebGl2RenderingContext::TEXTURE_2D,
0,
web_sys::WebGl2RenderingContext::RGBA as i32,
width as i32,
height as i32,
0,
web_sys::WebGl2RenderingContext::RGBA,
web_sys::WebGl2RenderingContext::UNSIGNED_BYTE,
Some(rgba_data),
)
.unwrap();
gl.tex_parameteri(
web_sys::WebGl2RenderingContext::TEXTURE_2D,
web_sys::WebGl2RenderingContext::TEXTURE_MIN_FILTER,
web_sys::WebGl2RenderingContext::LINEAR as i32,
);
gl.tex_parameteri(
web_sys::WebGl2RenderingContext::TEXTURE_2D,
web_sys::WebGl2RenderingContext::TEXTURE_MAG_FILTER,
web_sys::WebGl2RenderingContext::LINEAR as i32,
);
gl.tex_parameteri(
web_sys::WebGl2RenderingContext::TEXTURE_2D,
web_sys::WebGl2RenderingContext::TEXTURE_WRAP_S,
web_sys::WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
);
gl.tex_parameteri(
web_sys::WebGl2RenderingContext::TEXTURE_2D,
web_sys::WebGl2RenderingContext::TEXTURE_WRAP_T,
web_sys::WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
);
vello_gpu::WebGlTextureWithDimensions {
texture,
width,
height,
}
}
}
#[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
body.append_child(&canvas).unwrap();
let scenes = {
let v = vello_example_scenes::get_example_scenes(
vello_example_scenes::Capabilities::default(),
vec![
ImageSource::opaque_id(ImageId::new(0)),
ImageSource::opaque_id(ImageId::new(1)),
],
)
.into_vec();
v.into_boxed_slice()
};
let app_state = Rc::new(RefCell::new(AppState::new(canvas.clone(), scenes)));
// 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() as u32 * dpr as u32;
let height = window.inner_height().unwrap().as_f64().unwrap() as u32 * 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,
..
} = RendererWrapper::new(canvas);
let render_size = vello_gpu::RenderSize { width, height };
renderer
.render(
&scene,
&mut resources,
&render_size,
&vello_gpu::WebGlTextureBindings::new(),
vello_common::color::AlphaColor::TRANSPARENT,
)
.unwrap();
}
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();
}