PAJAMA / BUILD
← Engineering series

10 / 12 · Integrate, measure, ship

Integrate one pass into two hosts—and your engine

Define a reusable rendering boundary, then account for depth, color and device ownership in an existing engine.

Cross-platform rendering starts with ownership and contracts. A shared source file is useful only if its host assumptions are explicit. The most reusable object in this project is the render pass, not the browser page.

1. Keep the platform at the edges

Browser code handles fetch, DOM events and requestAnimationFrame. Native code handles files, winit events and a monotonic clock. Both feed a Frame into WorkshopSurface, which calls the same WorkshopPass and WGSL. The model parser, time evaluation, ordering and data contract do not need a second implementation for these two hosts.

wgpu translates the graphics work to supported backends. This sample selects D3D12 on Windows; macOS/Metal and Linux/Vulkan remain platform validation tasks until run there. A CUDA rasterizer would be a separate backend implementation sharing representation and algorithm contracts. CUDA is not a wgpu backend in this project.

2. Let the engine own the device and frame

Inside an existing Rust/wgpu engine, use StgPass directly. Construct it with the engine’s Device and attachment format. Each frame, call prepare with the engine’s Queue, view matrix, projection matrix, viewport and normalized time. Open the engine’s render pass, call draw, then let the engine submit and present. Do not create another device inside this plugin.

Submit one prepared frame before preparing another with the same mutable buffers. If a renderer prepares several cameras before submitting, each view needs its own buffer region or another explicit lifetime strategy. Otherwise the last write may supply data for earlier recorded work.

3. Agree on depth and color

Render opaque geometry first. Give the splat pipeline a compatible depth attachment, enable the appropriate depth comparison and keep splat depth writes disabled. Conventional Z often uses LessEqual; reverse Z often uses GreaterEqual, paired with the engine’s clear value and projection. Gaussian center-depth testing is an approximation, not exact volumetric intersection with meshes.

The render pass sample count and attachment format must match the pipeline. Current StgPass is single-sample. Decide how learned RGB maps into the engine’s linear/HDR working space before blending and tone mapping. Merely changing the attachment to sRGB or HDR does not establish that conversion.

4. Write a small host contract

Record: right-handed view with forward −Z; column-major matrices; WebGPU clip depth 0..1; viewport in physical pixels; normalized model time; declared color convention; matching depth and sample count. These lines are more valuable than a vague “works cross-platform” badge.

RUN → OBSERVE

Make this step concrete

One native host and one browser host render the same representation. The hidden-window smoke presents stages 0–7 and exits with status.

Open GPU milestone 7 →
Terminal · repository root
cargo run --locked --example workshop -- 7
cargo run --locked --example workshop -- --smoke
cargo check --locked --target wasm32-unknown-unknown

Commands use the reference repository. Rust/WGSL changes need a native rebuild or npm run build:wasm, followed by a browser reload.

IDEA → IMPLEMENTATION

Follow the running code

Excerpts are extracted from the repository at site build time. Expand only the part you are studying; full files include context and line numbers.

src/workshop_web.rs · L13–26

The browser creates only its surface before calling the shared host.

Actual repository code
    #[wasm_bindgen(js_name = create)]
    pub async fn create(canvas: web_sys::HtmlCanvasElement) -> Result<WorkshopRenderer, JsValue> {
        console_error_panic_hook::set_once();
        let width = canvas.width();
        let height = canvas.height();
        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
        let surface = instance
            .create_surface(wgpu::SurfaceTarget::Canvas(canvas))
            .map_err(|e| JsValue::from_str(&e.to_string()))?;
        let inner = WorkshopSurface::new(instance, surface, width, height)
            .await
            .map_err(|e| JsValue::from_str(&e))?;
        Ok(Self { inner })
    }
Open full source with line numbers ↗
examples/workshop.rs · L137–225

OS events are translated into the same input fields.

Actual repository code
        fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
            let Some(window) = self.window.clone() else {
                return;
            };
            if id != window.id() {
                return;
            }
            match event {
                WindowEvent::CloseRequested => event_loop.exit(),
                WindowEvent::Resized(size) => {
                    if let Some(gpu) = &mut self.gpu {
                        gpu.resize(size.width, size.height);
                    }
                    window.request_redraw();
                }
                WindowEvent::KeyboardInput { event, .. }
                    if event.state == ElementState::Pressed =>
                {
                    match event.logical_key {
                        Key::Named(NamedKey::Escape) => event_loop.exit(),
                        Key::Named(NamedKey::Space) => self.playing = !self.playing,
                        Key::Named(NamedKey::ArrowLeft) => self.frame.yaw -= 0.15,
                        Key::Named(NamedKey::ArrowRight) => self.frame.yaw += 0.15,
                        Key::Named(NamedKey::ArrowUp) => {
                            self.frame.pitch = (self.frame.pitch + 0.1).min(1.4)
                        }
                        Key::Named(NamedKey::ArrowDown) => {
                            self.frame.pitch = (self.frame.pitch - 0.1).max(-1.4)
                        }
                        Key::Named(NamedKey::Home) => {
                            self.frame = Frame {
                                stage: self.frame.stage,
                                ..Frame::default()
                            }
                        }
                        Key::Character(text) => {
                            if let Ok(stage @ 0..=7) = text.parse::<u32>() {
                                if stage == 7 {
                                    if let Err(e) = self.load_model() {
                                        self.fail(event_loop, e);
                                        return;
                                    }
                                }
                                self.frame.stage = stage;
                            }
                        }
                        _ => {}
                    }
                    window.request_redraw();
                }
                WindowEvent::RedrawRequested => {
                    if self.smoke && self.presented >= 8 {
                        return;
                    }
                    let now = Instant::now();
                    let dt = now.duration_since(self.last).as_secs_f32().min(0.1);
                    self.last = now;
                    if self.playing {
                        self.frame.time = (self.frame.time + dt / 10.0).fract();
                    }
                    let size = window.inner_size();
                    if size.width == 0 || size.height == 0 {
                        return;
                    }
                    if let Some(gpu) = &mut self.gpu {
                        match gpu.render(self.frame) {
                            Ok(Some(count)) if self.smoke => {
                                println!("presented stage={} visible={count}", self.frame.stage);
                                self.presented += 1;
                                if self.presented == 8 {
                                    event_loop.exit();
                                    return;
                                }
                                self.frame.stage = self.presented;
                            }
                            Ok(_) => {}
                            Err(e) => {
                                self.fail(event_loop, e);
                                return;
                            }
                        }
                    }
                    if self.playing || self.smoke {
                        window.request_redraw();
                    }
                }
                _ => {}
            }
        }
Open full source with line numbers ↗

Real-model render pass ↗ · Real-model shader ↗

Predict it, change it, explain it

Your engine uses reverse Z and 4× MSAA. Can you insert the existing StgPass unchanged into that pass?

Check your reasoning

No. Match depth comparison/projection/clear convention, and implement a matching multisample pipeline or use a deliberate single-sample composition stage. Also reconcile color space and attachment format.

Keep the theory beside the code

Open the related derivation or lab ↗

wgpu 30 · API and backend model ↗

winit 0.30 · windows and events ↗