| // 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(); | |
| } |