PAJAMA / BUILD
← Engineering series

04 / 12 · Build the splat

Move numbers from Rust into GPU memory

Build the Rust/WGSL data contract and draw one quad per instance.

A shader cannot follow a Rust pointer to a Vec. We must explicitly copy bytes into GPU memory and tell both sides what those bytes mean. This is one of the most useful debugging skills in graphics.

1. Start with a byte ruler

One f32 occupies four bytes. A vec4<f32> occupies sixteen. Our Instance has three vec4 blocks: center plus opacity, scale plus padding, RGB plus padding. Its stride is 48 bytes. The uniform contains a mat4 at bytes 0–63 and one vec4 at bytes 64–79, making 80 bytes. Rust repr(C), Pod and Zeroable make this explicit and checkable.

Do not assume a Rust [f32; 3] and a WGSL vec3 will automatically produce the same struct offsets. WGSL alignment rules matter. Padding deliberately with vec4 blocks makes this first contract easy to inspect. The tests check actual Rust sizes, while GPU validation and pixel tests check the complete path.

2. Describe access before sending data

UNIFORM is for per-frame camera and stage values. STORAGE holds an array of instances. COPY_DST permits queue.write_buffer uploads. A bind group layout describes binding numbers and access types; a bind group connects those slots to actual buffers. Binding 0 and 1 must match the WGSL declarations.

3. Build a rectangle with two triangles

A quad is six vertex invocations: two triangles sharing two corner positions. vertex_index chooses a corner, instance_index chooses a Gaussian record. Stage 2 writes the selected record’s RGB directly and forces alpha to one, so the rectangle has a hard boundary. We have not applied the Gaussian function yet.

4. Upload what actually changes

For two toy records, copying 96 bytes per frame is easy to inspect. For a real model, repeatedly uploading every record wastes bandwidth. The production-facing StgPass keeps source records resident and uploads only a uniform and sorted source indices. Learn the small direct version first, then make that one change in ownership.

RUN → OBSERVE

Make this step concrete

One solid orange rectangle. No falloff yet. Its color comes from the Rust record through a storage buffer.

Open GPU milestone 2 →
Terminal · repository root
cargo run --locked --example workshop -- 2
cargo test workshop::tests::host_shader_contract_and_invalid_input

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 · L7–20

Count the bytes on the CPU side.

Actual repository code
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Uniforms {
    view_projection: [[f32; 4]; 4], // bytes 0..64, column-major
    viewport_stage: [f32; 4],       // bytes 64..80: width, height, stage, padding
}

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Instance {
    center_alpha: [f32; 4], // bytes 0..16
    scale_pad: [f32; 4],    // bytes 16..32, activated standard deviations
    color_pad: [f32; 4],    // bytes 32..48, direct RGB (not SH coefficients)
}
Open full source with line numbers ↗
src/workshop.wgsl · L2–10

Match the shader offsets and binding slots.

Actual repository code
struct Uniforms { view_projection: mat4x4<f32>, viewport_stage: vec4<f32> };
struct Instance { center_alpha: vec4<f32>, scale_pad: vec4<f32>, color_pad: vec4<f32> };
@group(0) @binding(0) var<uniform> globals: Uniforms;
@group(0) @binding(1) var<storage, read> instances: array<Instance>;
struct Out {
    @builtin(position) clip: vec4<f32>,
    @location(0) local: vec2<f32>,
    @location(1) rgba: vec4<f32>,
};
Open full source with line numbers ↗
src/workshop.rs · L144–193

Create memory, describe its access, then bind it.

Actual repository code
        let uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("workshop-uniforms-80-bytes"),
            contents: bytemuck::bytes_of(&Uniforms::zeroed()),
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        });
        let instances = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("workshop-instances-2-times-48-bytes"),
            size: 96,
            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("workshop-bindings"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::VERTEX,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: true },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
            ],
        });
        let bindings = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("workshop-bind-group"),
            layout: &layout,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: uniforms.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: instances.as_entire_binding(),
                },
            ],
        });
Open full source with line numbers ↗

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

Predict it, change it, explain it

Set color_pad to [0, 1, 0, 0]. Explain why changing the fourth number alone has no visible effect in this milestone.

Check your reasoning

The quad becomes green. The shader reads only color_pad.xyz; the fourth number is padding. Opacity comes from center_alpha.w and stage 2 overrides it with one. A field name alone does not make a value affect rendering.

Keep the theory beside the code

Open the related derivation or lab ↗

WebGPU · pipeline, buffers and validation ↗