PAJAMA / BUILD
← Engineering series

03 / 12 · Boot the renderer

Teach the GPU to draw a triangle

Connect shader stages, draw calls and interpolation to visible pixels.

Three corners are enough to test most of the drawing path. If a triangle fails, loading a hundred thousand Gaussians will only hide the cause.

1. Give each invocation a vertex number

draw(0..3, 0..1) requests three vertices for one instance. The GPU runs vs_main with vertex_index 0, 1 and 2. For this first milestone, positions and colors live in tiny WGSL arrays, so no vertex buffer is needed. A range’s end is excluded: 0..3 means exactly three vertices.

2. Separate position from interpolated data

The vertex shader returns builtin(position), a four-number clip-space position. With w = 1, dividing xyz by w leaves the numbers unchanged. The x and y range −1 to +1 spans the viewport; WebGPU depth after division is 0 to 1. A location field is a varying: the rasterizer interpolates it across covered samples.

Our corner colors are red, green and blue. The fragment shader receives mixtures inside the triangle. No CPU loop visits each pixel. Fixed-function triangle rasterization determines coverage, then our fragment program supplies the color. Later, a Gaussian will use that same coverage machinery with a different color/alpha function.

3. A pipeline connects the pieces

Create a shader module from WGSL, then a render pipeline using vs_main and fs_main. The output color format must match the surface. TriangleList is the default topology. Depth is disabled for these first experiments; depth testing is not a prerequisite for producing a triangle.

The workshop keeps one pipeline across the toy stages and branches on a small stage uniform. That is a teaching convenience, not a recommendation to put every engine feature into one giant shader. Once behavior is understood, separate variants when their resource layouts or performance justify it.

4. Debug from the outside inward

First confirm clear/present works. Then verify the pipeline is bound, draw count is three, w is positive and clip positions are in range. Finally inspect fragment output. Change the shader to return solid magenta to isolate varying errors from coverage errors. Rebuild WASM after Rust or WGSL edits; a browser refresh alone cannot compile them.

RUN → OBSERVE

Make this step concrete

A triangle with three colored corners and smooth color interpolation, surrounded by the clear color.

Open GPU milestone 1 →
Terminal · repository root
cargo run --locked --example workshop -- 1
# After a Rust/WGSL edit, rebuild the browser binary:
npm run build:wasm

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.wgsl · L30–37

The first shader returns positions and colors directly.

Actual repository code
    if stage == 1u {
        let positions = array<vec2<f32>, 3>(vec2(-0.7, -0.6), vec2(0.7, -0.6), vec2(0.0, 0.7));
        let colors = array<vec3<f32>, 3>(vec3(1., 0., 0.), vec3(0., 1., 0.), vec3(0., 0., 1.));
        out.clip = vec4(positions[vertex], 0.5, 1.0);
        out.local = vec2(0.0);
        out.rgba = vec4(colors[vertex], 1.0);
        return out;
    }
Open full source with line numbers ↗
src/workshop.rs · L196–229

Shader code becomes a pipeline compatible with the render target.

Actual repository code
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("workshop-shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("workshop.wgsl").into()),
        });
        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("workshop-pipeline-layout"),
            bind_group_layouts: &[Some(&layout)],
            immediate_size: 0,
        });
        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("workshop-pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                compilation_options: Default::default(),
                buffers: &[],
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format,
                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            primitive: Default::default(),
            depth_stencil: None,
            multisample: Default::default(),
            multiview_mask: None,
            cache: None,
        });
Open full source with line numbers ↗

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

Predict it, change it, explain it

Change the draw count from three to two, predict the result, then restore it. Why does a TriangleList not produce a line?

Check your reasoning

Two vertices do not form a complete triangle, so no triangle is rasterized. The topology decides how vertices form primitives; a line requires a line topology, not merely fewer vertices.

Keep the theory beside the code

Open the related derivation or lab ↗

WebGPU · pipeline, buffers and validation ↗

wgpu 30 · API and backend model ↗