Web App (WebGPU + WASM)

examples/webapp is a self-contained browser application: the delta RGB delay effect compiled to WebAssembly, rendered via WebGPU, with a React overlay for controls.

This is not a remote-control panel for the native engine — it is the engine running inside the browser.

cargo run -p webapp   # doesn't apply — see build steps below

What it is

LayerTechnology
GPU renderingRust → WASM (cdylib), wgpu WebGPU backend
Camera captureJavaScript (getUserMedia + canvas)
Control UIReact 18 + TypeScript, built with Vite
Build toolingTrunk (WASM bundler for Rust)

The Rust code never runs natively — it targets wasm32-unknown-unknown only (#![cfg(target_arch = "wasm32")]). wgpu's BROWSER_WEBGPU backend talks directly to the browser's WebGPU API (Chrome/Edge 113+).

Browser requirements

WebGPU is required. Check compatibility:

BrowserWebGPU status
Chrome 113+✅ Enabled by default
Edge 113+✅ Enabled by default
Firefox🚧 Behind a flag (dom.webgpu.enabled)
Safari 18+✅ Enabled by default

The app also requests webcam access (getUserMedia). Serve it over HTTPS or localhost — browsers block camera access on plain HTTP origins.

Building and running

Prerequisites

# Rust WASM target
rustup target add wasm32-unknown-unknown

# Trunk (WASM bundler)
cargo install trunk

# Node.js (for the React UI build)
node --version  # 18+ recommended

First run

cd examples/webapp

# Build the React UI once (Trunk does this automatically if ui/dist is missing)
cd ui && npm install && npm run build && cd ..

# Start the dev server — opens http://localhost:8080
trunk serve

Trunk compiles the Rust WASM, bundles it, copies the React build output from ui/dist/ into the final bundle, and serves everything at http://localhost:8080.

The React UI only needs a rebuild when you change files under ui/src/. The Trunk hook skips the npm build if ui/dist/ already exists, so subsequent trunk serve calls are fast.

Production build

trunk build --release
# Output in dist/ (configurable in Trunk.toml)

The dist/ directory is self-contained — serve it with any static HTTP server.

Architecture

Startup sequence

Browser loads index.html
  └── Trunk loads WASM module
        └── TrunkApplicationStarted fires
              ├── start() — initialise WebGPU device, textures, pipeline
              ├── getUserMedia() — open webcam
              └── requestAnimationFrame loop begins
                    ├── JS: capture webcam frame → RGBA bytes
                    ├── JS: call update_webcam_frame(data, w, h) → WASM
                    └── Rust: render frame with WebGPU

Camera → GPU

The browser has no direct GPU-texture-from-camera API, so frames travel through a CPU copy each tick:

// index.html — JS side
ctx.drawImage(video, 0, 0, w, h);          // draw video into offscreen canvas
const img = ctx.getImageData(0, 0, w, h);  // read RGBA pixels from canvas
update_webcam_frame(data, w, h);           // call into WASM
#![allow(unused)]
fn main() {
// lib.rs — Rust side
#[wasm_bindgen]
pub fn update_webcam_frame(data: &[u8], width: u32, height: u32) {
    // write_buffer → uploads RGBA bytes to the webcam GPU texture
}
}

This is the main performance ceiling for high-resolution inputs — the getImageData call reads from the GPU back to CPU each frame. For 1280×720 it's fine in practice.

WASM exports (window.rustjay)

After startup, the JS side registers the WASM parameter setters on window.rustjay:

window.rustjay = { set_delay_r, set_delay_g, set_delay_b, set_mix };

The React component calls these directly:

// DelaySliders.tsx
const call = useCallback((fn: string, value: number) => {
    window.rustjay?.[fn]?.(value);
}, []);

// on slider change:
call('set_delay_r', newValue);

There is no network round-trip — the React UI and the WASM renderer run in the same browser tab, communicating through thread_local! state:

#![allow(unused)]
fn main() {
thread_local! {
    static PARAMS: RefCell<Params> = RefCell::new(Params::default());
}

#[wasm_bindgen]
pub fn set_delay_r(v: i32) {
    PARAMS.with(|p| p.borrow_mut().delay_r = v.clamp(-64, 64));
}
}

Render loop

The render loop runs via requestAnimationFrame — no Winit, no event loop, no threads. It reads the current PARAMS, uploads uniforms, and runs the WebGPU render pass:

#![allow(unused)]
fn main() {
fn render_frame(app: &mut App) {
    let params = PARAMS.with(|p| *p.borrow());
    // upload uniforms, run render pass, copy to feedback texture
}
}

The feedback texture (previous frame's output) is updated with copy_texture_to_texture at the end of each frame so the delta shader can read it next tick.

The effect

The effect is a simplified version of examples/delta: per-channel pixel offset with a mix between the live camera and the feedback texture.

┌──────────┐     pixel offset (R, G, B independently)
│  Webcam  │ ──────────────────────────────────────────┐
│  (live)  │                                           ↓
└──────────┘                                    ┌─────────────┐
                                                │    WGSL     │ → output
┌──────────┐     sampled with uv offset         │   shader    │
│ Feedback │ ──────────────────────────────────→│             │
│ (t-1)    │                                    └─────────────┘
└──────────┘         ↑
                     └── copy_texture_to_texture each frame

Parameters exposed to the React UI:

ExportTypeRangeEffect
set_delay_ri32[-64, 64]Red channel horizontal pixel offset
set_delay_gi32[-64, 64]Green channel horizontal pixel offset
set_delay_bi32[-64, 64]Blue channel horizontal pixel offset
set_mixf32[0, 1]Blend between live camera and feedback

Extending the webapp

Adding a parameter

1. Add to the Params struct and export a setter:

#![allow(unused)]
fn main() {
// lib.rs
pub struct Params {
    pub delay_r: i32,
    // ...
    pub brightness: f32,   // add this
}

#[wasm_bindgen]
pub fn set_brightness(v: f32) {
    PARAMS.with(|p| p.borrow_mut().brightness = v.clamp(0.0, 2.0));
}
}

2. Pass it through uniforms:

#![allow(unused)]
fn main() {
// delta.rs
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct DeltaUniforms {
    // existing fields ...
    pub brightness: f32,
    pub _pad: [f32; 3],
}
}

3. Use it in the shader (src/shaders/delta.wgsl):

out = out * u.brightness;

4. Add a slider in React (ui/src/components/DelaySliders.tsx):

<Slider
    label="Brightness"
    value={brightness}
    min={0} max={2} step={0.01}
    onChange={(v) => { setBrightness(v); call('set_brightness', v); }}
    color="#ffee44"
/>

Then rebuild the React UI (cd ui && npm run build) and restart trunk serve.

Replacing the effect

The rendering logic lives in src/delta.rs and src/shaders/delta.wgsl. Swap in a different WGSL shader and update DeltaUniforms to match. The startup, camera capture, and React overlay are all independent of the specific effect.

Project layout

examples/webapp/
├── src/
│   ├── lib.rs          # WASM entry point, render loop, wasm-bindgen exports
│   ├── delta.rs        # Pipeline creation, DeltaUniforms
│   ├── webcam.rs       # update_webcam helper (CPU→GPU texture upload)
│   └── shaders/
│       └── delta.wgsl  # Fragment shader
├── ui/
│   ├── src/
│   │   ├── App.tsx
│   │   └── components/
│   │       └── DelaySliders.tsx   # React control overlay
│   └── package.json
├── index.html          # Trunk entry — wires WASM init, webcam loop, React mount
└── Trunk.toml          # Build config — port 8080, React pre-build hook