perf: improve rendering performance by ~20x

This commit is contained in:
Maze Winther
2026-04-22 16:48:37 +02:00
parent f1b45b4328
commit cceb835a84
11 changed files with 780 additions and 357 deletions
+9 -1
View File
@@ -353,6 +353,14 @@ impl GpuContext {
self.supports_external_texture_copies
}
/// The HTML canvas that owns the backing WebGL context, if running on the
/// WebGL fallback. Callers on that path can mount this canvas directly
/// instead of copying pixels out of it every frame.
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub fn gl_canvas(&self) -> Option<&web_sys::HtmlCanvasElement> {
self.gl_canvas.as_ref()
}
pub fn render_texture_to_surface(
&self,
texture: &wgpu::Texture,
@@ -571,7 +579,7 @@ impl GpuContext {
}
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
fn render_texture_to_gl_canvas_surface(
pub fn render_texture_to_gl_canvas_surface(
&self,
texture: &wgpu::Texture,
width: u32,
+1 -1
View File
@@ -24,7 +24,7 @@ serde-wasm-bindgen = "0.6.5"
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
wasm-bindgen = "0.2.116"
wasm-bindgen-futures = "0.4.66"
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] }
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window", "Performance"] }
[features]
default = ["wasm"]
+39 -18
View File
@@ -11,6 +11,7 @@ use crate::gpu::{
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
with_gpu_runtime,
};
use crate::perf;
struct CompositorRuntime {
canvas: web_sys::HtmlCanvasElement,
@@ -24,13 +25,21 @@ thread_local! {
#[wasm_bindgen(js_name = initCompositor)]
pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
with_gpu_runtime(|gpu_runtime| {
let document = web_sys::window()
.and_then(|window| window.document())
.ok_or_else(|| JsValue::from_str("Document is not available"))?;
let canvas: web_sys::HtmlCanvasElement = document
.create_element("canvas")?
.dyn_into()
.map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?;
// On WebGL, wgpu is bound to a specific canvas; reuse it so the UI
// can mount the output directly instead of copying pixels through
// an intermediate 2D canvas every frame. On WebGPU, surface rendering
// works against any canvas so we create a fresh one.
let canvas = if let Some(gl_canvas) = gpu_runtime.context.gl_canvas() {
gl_canvas.clone()
} else {
let document = web_sys::window()
.and_then(|window| window.document())
.ok_or_else(|| JsValue::from_str("Document is not available"))?;
document
.create_element("canvas")?
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?
};
canvas.set_width(width);
canvas.set_height(height);
@@ -119,8 +128,12 @@ pub fn release_texture(id: String) -> Result<(), JsValue> {
#[wasm_bindgen(js_name = renderFrame)]
pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
perf::reset();
let t_deserialize = perf::now_ms();
let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options)
.map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?;
perf::record("wasm.deserialize", perf::now_ms() - t_deserialize);
with_gpu_runtime(|gpu_runtime| {
COMPOSITOR_RUNTIME.with(|runtime| {
@@ -132,13 +145,16 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
};
if gpu_runtime.context.supports_surface_rendering() {
let t_surface = perf::now_ms();
let surface = gpu_runtime
.context
.instance()
.create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
.map_err(|error| JsValue::from_str(&error.to_string()))?;
perf::record("wasm.surfaceCreate", perf::now_ms() - t_surface);
runtime
let t_render = perf::now_ms();
let result = runtime
.compositor
.render_frame(
&gpu_runtime.context,
@@ -147,24 +163,29 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
surface: &surface,
},
)
.map_err(|error| JsValue::from_str(&error.to_string()))
.map_err(|error| JsValue::from_str(&error.to_string()));
perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render);
result
} else {
// WebGL cannot surface-render to an arbitrary canvas element.
// Composite to a texture, then blit to the canvas via the 2D context.
// WebGL surface-renders to the canvas its GL context was created
// on; `init_compositor` already pointed `runtime.canvas` at that
// same canvas, so presenting writes directly to the canvas the
// UI mounts. No intermediate copy.
let t_composite = perf::now_ms();
let texture = runtime
.compositor
.render_frame_to_texture(&gpu_runtime.context, &frame)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
perf::record("wasm.compositeToTexture", perf::now_ms() - t_composite);
let t_present = perf::now_ms();
gpu_runtime
.context
.render_texture_via_gl_canvas(
&texture,
&runtime.canvas,
frame.width,
frame.height,
)
.map_err(|error| JsValue::from_str(&error.to_string()))
.render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present);
Ok(())
}
})
})
+51
View File
@@ -0,0 +1,51 @@
#![cfg(target_arch = "wasm32")]
//! Per-frame profile buffer for the render pipeline.
//!
//! Sub-span timings are recorded into a thread-local during `renderFrame`
//! and drained by JS via `getLastFrameProfile()`.
use std::cell::RefCell;
use js_sys::{Array, Object, Reflect};
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
thread_local! {
static LAST_FRAME_PROFILE: RefCell<Vec<(&'static str, f64)>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn now_ms() -> f64 {
web_sys::window()
.and_then(|window| window.performance())
.map(|performance| performance.now())
.unwrap_or(0.0)
}
pub(crate) fn reset() {
LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().clear());
}
pub(crate) fn record(name: &'static str, duration_ms: f64) {
LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().push((name, duration_ms)));
}
#[wasm_bindgen(js_name = getLastFrameProfile)]
pub fn get_last_frame_profile() -> Array {
LAST_FRAME_PROFILE.with(|cell| {
let entries = cell.borrow();
let array = Array::new_with_length(entries.len() as u32);
for (index, (name, duration_ms)) in entries.iter().enumerate() {
let entry = Object::new();
Reflect::set(&entry, &JsValue::from_str("name"), &JsValue::from_str(name))
.expect("set name");
Reflect::set(
&entry,
&JsValue::from_str("durationMs"),
&JsValue::from_f64(*duration_ms),
)
.expect("set durationMs");
array.set(index as u32, entry.into());
}
array
})
}
+4
View File
@@ -6,6 +6,8 @@ mod effects;
mod gpu;
#[cfg(target_arch = "wasm32")]
mod masks;
#[cfg(target_arch = "wasm32")]
mod perf;
#[cfg(target_arch = "wasm32")]
pub use compositor::*;
@@ -15,4 +17,6 @@ pub use effects::*;
pub use gpu::*;
#[cfg(target_arch = "wasm32")]
pub use masks::*;
#[cfg(target_arch = "wasm32")]
pub use perf::*;
pub use time::*;