The pipeline: shaders, buffers, and the first triangle#

The software rasteriser did three jobs per triangle: move the vertices to the screen, decide which pixels are covered, and choose each pixel’s colour. On the graphics card the middle job is fixed hardware. The first and last are programs you write, the vertex shader and the fragment shader, and a pipeline binds them to a description of the data they are fed.

This chapter draws one triangle, then the same data through an index buffer, and ends with what the card says when something is wrong.

The shader#

Both programs live in one WGSL source:

struct VOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) col: vec3<f32>,
};

@vertex
fn vs(@location(0) p: vec2<f32>, @location(1) c: vec3<f32>) -> VOut {
    var o: VOut;
    o.pos = vec4<f32>(p, 0.0, 1.0);
    o.col = c;
    return o;
}

@fragment
fn fs(i: VOut) -> @location(0) vec4<f32> {
    return vec4<f32>(i.col, 1.0);
}

vs runs once per vertex. Its inputs carry @location numbers, which is how the vertex data below finds them, and its output must include a @builtin(position): the vertex in clip space, x, y, z, w. With z = 0 and w = 1 the divide changes nothing, so x and y here are already normalised device coordinates, -1 to +1 with y up.

fs runs once per covered pixel. What it receives is vs’s output interpolated across the triangle with barycentric weights, exactly as the rasteriser chapter computed them by hand, and perspective-correct without being asked. Its @location(0) output is the colour written to the target.

WGSL is strictly typed: vec2<f32> is two 32-bit floats, let names a value, var a variable. There is no double precision; everything the card computes here is f32.

$gpu->shader($source) compiles it, and a mistake in it is a Perl exception carrying the compiler’s message, shown below.

Vertex data#

Three vertices, each a position and a colour, packed as 32-bit floats:

#                 x     y      r  g  b
my @tri = ( -0.8, -0.8,   1, 0, 0,
             0.8, -0.8,   0, 1, 0,
             0.0,  0.8,   0, 0, 1 );
my $verts = $gpu->vertex_buffer(pack 'f*', @tri);
print $verts->size;                            # 60

Fifteen floats of four bytes each. The buffer is bytes on the card and knows nothing of their meaning; the pipeline supplies that.

The pipeline#

my $pipe = $gpu->render_pipeline($gpu->shader($WGSL),
    vertex => 'vs', fragment => 'fs',
    vertex_buffers => [ { attributes => ['float32x2', 'float32x3'] } ],
);

vertex_buffers describes each buffer as a list of attribute formats. The short form above lays them end to end: float32x2 at @location(0), offset 0; float32x3 at @location(1), offset 8; and a stride of 20 bytes from one vertex to the next. Written out in full, which is necessary only when the layout is not that simple:

vertex_buffers => [ {
    stride     => 20,
    attributes => [ { format => 'float32x2', location => 0, offset => 0 },
                    { format => 'float32x3', location => 1, offset => 8 } ],
} ],

The format names are WebGPU’s own: float32 to float32x4, the uint32 and sint32 families, unorm8x4, float16x2. A name that is not one of them is refused with the whole list.

The pipeline also fixes the format of the target it draws into, rgba8unorm unless told otherwise. For a window that must be the surface’s format:

my $pipe = $gpu->render_pipeline($shader, format => $surf->format, ...);

Compiling a pipeline is the expensive step. Build it once, before the frame loop, and use it every frame.

The draw#

my ($W, $H) = (256, 256);
my $img = $gpu->texture(width => $W, height => $H);
$gpu->render_pass($img, clear => [0.1, 0.1, 0.1, 1])
    ->pipeline($pipe)->vertices($verts)->draw(3)->end;

Every recording call returns the pass, so a frame reads as one sentence: this pipeline, these vertices, three of them, done. The order matters in one place only: pipeline comes first, because what follows is checked against it.

Read back, the pixels say what was drawn:

centre                   63 65 127 255
corner                   26 26 26 255
near bottom-left vertex  246 3 6 255
near bottom-right vertex 3 246 6 255
near top vertex          2 4 249 255

The corner is the clear colour, 0.1 as a byte. Each vertex’s neighbourhood is almost pure red, green or blue, and the centre is a mix of all three, which is the interpolation at work. Written out with save_png from the previous chapter, it is the familiar colour-blended triangle, point upwards: NDC’s y points up and the texture’s top row comes first, the same flip foundations made in its screen mapping.

The same pixels as the software rasteriser#

The rasteriser chapter filled a triangle with corners at pixel positions (1,1), (17,2) and (6,8) on a 20 by 10 canvas and printed 54 pixels. Converted to NDC and drawn by the card into a 20 by 10 texture, printing # for every pixel that was written:

my @ndc = map { (2 * $_->[0] / 20 - 1, 1 - 2 * $_->[1] / 10) } [1,1], [17,2], [6,8];
....................
.########...........
..##############....
...###########......
...#########........
....#######.........
.....####...........
......#.............
....................
....................

54 pixels, identical to the figure the edge function produced, and identical again with the vertex order reversed. The card is running the same inside test at the same pixel centres.

Index buffers#

A quad is two triangles sharing an edge. Listing six vertices repeats two of them; an index buffer lists each vertex once and says which three make each triangle:

my $corners = $gpu->vertex_buffer(pack 'f*',
    -0.5, -0.5,   1, 0.8, 0.2,
     0.5, -0.5,   1, 0.8, 0.2,
     0.5,  0.5,   1, 0.8, 0.2,
    -0.5,  0.5,   1, 0.8, 0.2);
my $quad = $gpu->index_buffer(pack 'L*', 0, 1, 2,  0, 2, 3);

my $sq = $gpu->texture(width => 64, height => 64);
$gpu->render_pass($sq, clear => [0, 0, 0, 1])
    ->pipeline($pipe)->vertices($corners)->indices($quad)->draw_indexed(6)->end;

Same pipeline, same vertex layout; only the drawing call changed.

The quad covers 1024 pixels of the 64 by 64 target, which is 32 by 32: half the width and half the height, exactly. draw_indexed counts indices, not vertices.

This is the meshes chapter’s indexed triangle mesh, a vertex array and an index array, going to the card as one buffer each.

Indices are 32-bit by default. Sixteen-bit indices halve the buffer and must be declared, because the bytes alone cannot say which they are:

my $q16 = $gpu->index_buffer(pack 'S*', 0, 1, 2, 0, 2, 3);   # 12 bytes
$gpu->render_pass($sq, clear => [0, 0, 0, 1])
    ->pipeline($pipe)->vertices($corners)->indices($q16, 'uint16')->draw_indexed(6)->end;

The same 12 bytes without the 'uint16' read as three 32-bit indices, and the draw is refused at end: Index 6 extends beyond limit 3. Did you bind the correct index buffer?

When something is wrong#

Every failure is a Perl exception carrying wgpu’s own text, so eval traps it and $@ explains it. A call that succeeds returns its object, which is what makes the chaining above work and ... ->end or die behave. The exceptions arrive at three places, and knowing which saves time.

At shader: the WGSL does not compile. The message points into the source by line and column:

shader compilation failed: Validation Error

Caused by:
  In Device::create_shader_module

Shader '' parsing error: expected `;`, found "return"
   ┌─ wgsl:11:5
   │
11 │     return o;
   │     ^^^^^^ expected `;`

The missing semicolon was at the end of o.col = c on line 10; the parser noticed on line 11. Line numbers count from the first line of the WGSL string, not of the Perl file. A type error names the entry point, here fs returning a vec3 where it declared a vec4:

Shader validation error: Entry point fs at Fragment is invalid

At render_pipeline: the shader does not fit the description. An entry point name that is not in the shader, or a shader input no buffer supplies:

Unable to find entry point 'vertex_main'
Location[1] Float32x3 interpolated as Some(Perspective) with sampling
Some(Center) is not provided by the previous stage outputs

The second is what a vertex_buffers list with only float32x2 in it produces for this shader: nothing feeds @location(1).

At end: the recording was inconsistent. Mistakes made while recording are collected and reported when the pass is submitted, so the exception comes from end even though the call at fault was earlier. The message names the command:

mistake

the message ends with

draw with no vertices call

requires vertex buffer 0 to be set

draw(6) on a three-vertex buffer

Vertex 6 extends beyond limit 3

pipeline format differs from the target

the RenderPass uses textures with formats [Some(Bgra8UnormSrgb)] but the RenderPipeline ... uses attachments with formats [Some(Rgba8Unorm)]

a shader var<uniform> never bound

expects a BindGroup to be set at index 0

The format row is the one every windowed program hits once: a pipeline built with the default rgba8unorm, drawn into a frame whose surface is bgra8unorm-srgb.

The one that says nothing#

Symptom: the triangle is the wrong shape, or its colours are wrong, and nothing raised an exception. Cause: a vertex format with fewer components than the shader’s input. WebGPU allows it deliberately and fills in the missing components, so the layout ['float32x2', 'float32x2'] for this shader’s vec2 and vec3 inputs is accepted. Its computed stride is 16 bytes instead of 20, so every vertex after the first is read from the wrong place. On a 64 by 64 target the correct layout covered 1352 pixels with a blue-grey centre, 63 67 125; the short one covered 1146 pixels with a green centre, 22 62 0.

The layout and the pack template describe the same bytes twice. When a picture is nearly right, compare the two before suspecting anything else.

Next#

The triangle’s corners were already in clip space. The next chapter feeds the card model coordinates and the matrices from foundations, and brings back the depth buffer, culling and lighting of the software arc.