PAJAMA / BUILD
← Engineering series

11 / 12 · Integrate, measure, ship

Measure first, then design a compute rasterizer

Separate bottlenecks and connect today’s quad renderer to a future tile-based backend.

Two splats teach correctness. A hundred thousand reveal costs. The first optimization is to find out which cost you actually have: transfer, CPU sorting, vertex work, pixel overdraw or synchronization.

1. Separate the clocks

Measure CPU preparation, CPU upload/submission, GPU pass duration and presentation separately. The time around queue.submit is not the time the GPU spent rendering. GPU timestamp queries are optional features; report their absence instead of filling the field with a CPU estimate. Our lab’s frame counter deliberately does not claim GPU milliseconds.

2. Work scales with coverage, not just point count

Two scenes with equal Gaussian count can have different costs. Huge overlapping quads generate many fragment invocations. A tiny alpha can still require enough work to decide to discard it. Reduce resolution as an experiment: a large speedup suggests pixel cost, while unchanged time suggests another bottleneck. Keep camera and model time fixed during comparisons.

The current real pass sorts visible centers on the CPU and repeats geometric evaluation in the shader. Its center-margin culling and maximum footprint clamp are baseline heuristics, not conservative production solutions. Document these choices before interpreting image quality or speed differences.

3. Build the compute path as explicit stages

A typical tile path projects splats, computes bounds, counts tile overlaps, prefix-sums counts into offsets, writes (tile, depth, splat) entries, sorts entries, finds per-tile ranges and composites pixels. Each stage has buffer capacities and overflow behavior. “Move it to compute” does not remove sorting, memory traffic or synchronization.

Our existing mini_compute example is a deliberately smaller reference: a few CPU-ordered splats, one compute invocation per pixel, front-to-back transmittance, GPU readback and comparison with a CPU implementation. It does not implement tile binning or GPU sorting. Run that first before designing the larger pipeline.

C←C+Tαici,T←T(1−αi),Cfinal=C+TCbgC\leftarrow C+T\alpha_i c_i,\qquad T\leftarrow T(1-\alpha_i),\qquad C_{\mathrm{final}}=C+T C_{\mathrm{bg}}

4. Port algorithms, not platform accidents

CUDA, Metal compute and WGSL compute differ in execution and memory capabilities. Keep a CPU reference, fixed input fixture, documented sort tie-breaks and pixel tolerances. A backend must preserve the model and composition contract, even if its workgroup sizes, scan or sort implementation differ.

RUN → OBSERVE

Make this step concrete

CPU/GPU pixel checks pass for the small compute rasterizer; reversing order changes the output. Readback latency is not a throughput benchmark.

Open GPU milestone 7 →
Terminal · repository root
cargo run --locked --example mini_compute
# Inspect artifacts/mini-compute/ and compare the correct/reversed-order PNGs.
cargo run --locked --example native_smoke

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.rs · L254–306

Inspect the current CPU-to-GPU boundary before moving work across it.

Actual repository code
    pub fn prepare(
        &mut self,
        queue: &wgpu::Queue,
        frame: Frame,
        size: [u32; 2],
    ) -> Result<u32, String> {
        frame.validate()?;
        if size.contains(&0) {
            return Err("Cannot prepare a zero-sized target".into());
        }
        let (view, projection) = camera(frame, size[0], size[1]);
        self.stage = frame.stage;
        if frame.stage == 7 {
            let pass = self.real.as_mut().ok_or("Load the STG-Lite model first")?;
            self.count = pass
                .prepare(
                    queue,
                    frame.time,
                    view.to_cols_array_2d(),
                    projection.to_cols_array_2d(),
                    size,
                )?
                .visible_count as u32;
        } else {
            let data = instances(frame, view);
            self.count = data.len() as u32;
            queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&data));
            queue.write_buffer(
                &self.uniforms,
                0,
                bytemuck::bytes_of(&Uniforms {
                    view_projection: (projection * view).to_cols_array_2d(),
                    viewport_stage: [size[0] as f32, size[1] as f32, frame.stage as f32, 0.0],
                }),
            );
        }
        Ok(if frame.stage == 0 { 0 } else { self.count })
    }

    pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
        if self.stage == 0 {
            return;
        }
        if self.stage == 7 {
            if let Some(real) = &self.real {
                real.draw(pass);
            }
            return;
        }
        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, &self.bindings, &[]);
        pass.draw(0..if self.stage == 1 { 3 } else { 6 }, 0..self.count);
    }
Open full source with line numbers ↗

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

Predict it, change it, explain it

Why can a renderer get slower when its splat count stays constant but the camera moves closer?

Check your reasoning

Projected footprints become larger and overlap more pixels, increasing fragment work and blending. Counting splats alone misses that cost. A tile implementation also creates more tile-overlap entries.

Keep the theory beside the code

Open the related derivation or lab ↗

3D Gaussian Splatting · original research ↗

WebGPU · pipeline, buffers and validation ↗

wgpu 30 · API and backend model ↗