PAJAMA / BUILD
← Engineering series

02 / 12 · Boot the renderer

Connect a window to the GPU

Initialize both hosts, handle resizing, and follow a frame’s lifetime without confusing a window with a texture.

A window is a place on the desktop. A surface is the GPU’s connection to that place. The texture you render into changes from frame to frame. Keeping a texture forever is like writing every letter onto yesterday’s envelope.

1. Ask for a compatible device

Instance discovers graphics backends. Adapter selects a GPU compatible with our surface. Device creates resources; Queue uploads data and submits command buffers. Request the capabilities you need, not every optional feature the GPU exposes. This workshop uses baseline rendering and storage buffers.

The attachment format is part of a pipeline’s contract. Query the surface formats before creating the pipeline. This series prefers a non-sRGB UNORM format to match the baseline’s numeric RGB convention; production color management needs an explicit source-color decision. Recreating a surface with a different format requires a compatible pipeline.

2. Create the host at the right moment

The native app creates its window inside resumed. It passes an Arc clone into create_surface so the surface can keep the window alive. On desktop only, pollster waits for asynchronous initialization. The browser instead awaits WorkshopRenderer.create(canvas); blocking that event loop would prevent the page from progressing.

On suspend, the sample drops the native GPU/surface state and window, then reconstructs them on resume. A production app would also preserve UI state and implement device-loss recovery. Here an unrecoverable validation failure stops with an error and a reload path; the lesson does not claim full recovery.

3. Size is measured in two units

A canvas may occupy 600 × 400 CSS pixels while its backing texture is 900 × 600 physical pixels at a capped device-pixel ratio of 1.5. Set both canvas dimensions and the surface configuration. Compute the camera aspect ratio from the actual render width and height. Never configure a zero-sized surface while a native window is minimized.

Wmathrmrender=mathrmround(WmathrmCSScdotd),a=Wmathrmrender/HmathrmrenderW_{mathrm{render}}=mathrm{round}(W_{mathrm{CSS}}cdot d),\qquad a=W_{mathrm{render}}/H_{mathrm{render}}

4. Surface errors are different events

Outdated or Lost triggers surface reconfiguration; Timeout or Occluded skips this frame. Validation stops the sample and reports the problem. Only a successfully acquired texture gets presented. Notice that wgpu 30 uses queue.present(frame); old code using a different API is not a reason to skip checking the version.

RUN → OBSERVE

Make this step concrete

The same clear screen in a native window and browser canvas. Resize each; the reported render resolution should track the backing surface.

Open GPU milestone 0 →
Terminal · repository root
rustup target add wasm32-unknown-unknown
cargo install wasm-pack --locked
npm ci
npm run dev
# Open the local URL printed by Wrangler, then /build/lab/?stage=0

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_surface.rs · L17–70

GPU setup is shared; the host supplies the surface.

Actual repository code
    pub async fn new(
        instance: wgpu::Instance,
        surface: wgpu::Surface<'static>,
        width: u32,
        height: u32,
    ) -> Result<Self, String> {
        let adapter = instance
            .request_adapter(&wgpu::RequestAdapterOptions {
                power_preference: wgpu::PowerPreference::HighPerformance,
                compatible_surface: Some(&surface),
                force_fallback_adapter: false,
                apply_limit_buckets: false,
            })
            .await
            .map_err(|e| e.to_string())?;
        let (device, queue) = adapter
            .request_device(&wgpu::DeviceDescriptor::default())
            .await
            .map_err(|e| e.to_string())?;
        let caps = surface.get_capabilities(&adapter);
        // Keep the educational RGB values in the same UNORM convention as the
        // research baseline. A production HDR renderer needs an explicit conversion.
        let format = caps
            .formats
            .iter()
            .copied()
            .find(|f| !f.is_srgb())
            .unwrap_or(caps.formats[0]);
        let config = wgpu::SurfaceConfiguration {
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            format,
            color_space: wgpu::SurfaceColorSpace::Auto,
            width: 1,
            height: 1,
            present_mode: wgpu::PresentMode::Fifo,
            alpha_mode: caps.alpha_modes[0],
            view_formats: vec![],
            desired_maximum_frame_latency: 2,
        };
        let pass = WorkshopPass::new(&device, format);
        let info = adapter.get_info();
        let adapter_label = format!("{:?} · {}", info.backend, info.name);
        let mut renderer = Self {
            surface,
            device,
            queue,
            config,
            pass,
            adapter_label,
            relight: None,
        };
        renderer.resize(width.max(1), height.max(1));
        Ok(renderer)
    }
Open full source with line numbers ↗
examples/workshop.rs · L77–130

The native window lives outside the pass.

Actual repository code
        fn resumed(&mut self, event_loop: &ActiveEventLoop) {
            if self.window.is_some() {
                return;
            }
            let attributes = Window::default_attributes()
                .with_title("4DGS Workshop · 0–7 stages · arrows orbit · Space pause · Home reset")
                .with_inner_size(winit::dpi::LogicalSize::new(960, 640))
                .with_visible(!self.smoke);
            let window = match event_loop.create_window(attributes) {
                Ok(window) => Arc::new(window),
                Err(e) => {
                    self.fail(event_loop, e.to_string());
                    return;
                }
            };
            let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
            if cfg!(target_os = "windows") {
                descriptor.backends = wgpu::Backends::DX12;
            }
            let instance = wgpu::Instance::new(descriptor);
            let surface = match instance.create_surface(window.clone()) {
                Ok(surface) => surface,
                Err(e) => {
                    self.fail(event_loop, e.to_string());
                    return;
                }
            };
            let size = window.inner_size();
            // Blocking is confined to desktop startup. The browser host awaits.
            self.gpu = match pollster::block_on(WorkshopSurface::new(
                instance,
                surface,
                size.width,
                size.height,
            )) {
                Ok(gpu) => {
                    println!("{}", gpu.adapter_label);
                    Some(gpu)
                }
                Err(e) => {
                    self.fail(event_loop, e);
                    return;
                }
            };
            self.window = Some(window);
            if self.frame.stage == 7 || self.smoke {
                if let Err(e) = self.load_model() {
                    self.fail(event_loop, e);
                    return;
                }
            }
            self.last = Instant::now();
            self.window.as_ref().unwrap().request_redraw();
        }
Open full source with line numbers ↗

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

Predict it, change it, explain it

Minimize and restore the native window. Then resize the browser lab to a narrow viewport. Which code should run, and which values must never be zero?

Check your reasoning

Resize updates the surface configuration and next frame’s projection. Native rendering skips a zero-sized window. Width and height passed to configure or prepare must be nonzero. CSS size alone does not resize GPU attachments.

Keep the theory beside the code

Open the related derivation or lab ↗

wgpu 30 · API and backend model ↗

winit 0.30 · windows and events ↗