Skip to content
Comfy Engine

Getting started with wgpu: from a window to a textured quad

Getting started with wgpu: from a window to a textured quad

wgpu has a reputation for being a wall of setup code before anything appears on screen. That reputation is partly earned: the first triangle takes more lines than in older APIs. But the lines are not random. Every object you create answers one specific question about how your program talks to the GPU, and once you know what those questions are, the setup reads like a checklist instead of a ritual.

This piece is that checklist. I describe the concepts and the order you meet them, then walk the path from an empty window to a textured quad, and finish with the errors almost everyone hits in their first week. I keep the Rust side at the level of concepts, because wgpu's exact function signatures have shifted between releases. The official examples in the wgpu repository are the reference for whatever version you install; this is the map to read them with.

What wgpu is, in one paragraph

wgpu is a Rust graphics library that implements the WebGPU API. On native platforms it translates your calls to Vulkan, Metal, Direct3D 12 or OpenGL; in the browser it can use the browser's WebGPU support, or fall back to WebGL2 for a subset of features. You write one renderer and it runs on all of them. Shaders are written in WGSL, WebGPU's shading language, and wgpu also accepts some other formats through translation.

The cast of objects

Think of this as a chain where each object is created from the one before it.

Instance

The instance is the entry point. It knows which backends are available on this machine (Vulkan, Metal and so on). You create one at startup and rarely touch it again.

Surface

The surface is the thing you draw into on screen: a handle that connects wgpu to your window. You create it from the instance and a window, typically one made with winit. On the web, the surface is tied to a canvas element.

Adapter

The adapter represents a physical GPU (or a software fallback). You ask the instance for one, usually with hints such as "prefer the high-performance GPU" and "must be able to draw to this surface". On a laptop with integrated and discrete graphics, this is where the choice is made.

Device and queue

From the adapter you request a device and a queue, together. The device is your logical connection to the GPU: you use it to create every resource (buffers, textures, shaders, pipelines). The queue is where you submit work and upload data. Requesting the device is also where you declare the features and limits you need. Ask only for what you use, because the defaults are chosen to work everywhere, including the web.

Surface configuration

Before drawing, you configure the surface with the device: its size, its texture format and a present mode (roughly, how vsync behaves). The surface reports which formats it supports. Pick one, and remember whether it is an sRGB format, because that decides whether the GPU encodes your linear colours for you on write.

Shader module and render pipeline

A shader module is your compiled WGSL. A render pipeline combines shaders with all the fixed state around them: the layout of vertex data, which output format it writes to, the blend mode, how triangles are assembled and culled. Pipelines are expensive to create and cheap to use, so you build them at load time and reuse them every frame.

Bind groups and their layouts

Shaders need resources: a texture, a sampler, a uniform buffer with a camera matrix. A bind group layout describes the shape ("slot 0 is a texture, slot 1 is a sampler"), and a bind group is a concrete set of resources that fits that shape. The pipeline layout lists which bind group layouts the pipeline expects. At draw time you set a bind group, and the shader sees those resources. This indirection feels heavy at first, and it is exactly what makes switching textures between sprites cheap.

Command encoder and render pass

Each frame you create a command encoder, begin a render pass on the current surface texture, record commands (set pipeline, set bind groups, set buffers, draw), end the pass, finish the encoder and submit the result to the queue. Then you present the surface texture. Nothing runs on the GPU until submission.

The path from window to quad

Here is the order of work, written as a plain checklist rather than real calls:

startup:
  create window
  instance = new instance
  surface = instance.create_surface(window)
  adapter = instance.request_adapter(compatible with surface)
  device, queue = adapter.request_device(features, limits)
  configure surface (size, format, present mode)
  load shader module from WGSL source
  create vertex buffer and index buffer for one quad
  load image, create texture, write pixels via queue, create view and sampler
  create bind group layout, bind group, pipeline layout, render pipeline

each frame:
  get current surface texture, create a view of it
  encoder = device.create_command_encoder()
  begin render pass (clear colour) on the view
    set pipeline, set bind group 0, set vertex and index buffers
    draw 6 indices
  end pass, submit encoder to queue, present

Most of that list runs once. The per-frame part is short, and that is the shape of every wgpu renderer, however large it grows.

The quad data

A quad is two triangles. With an index buffer you store four vertices and six indices instead of six full vertices. Each vertex has a position and a texture coordinate:

#[repr(C)]
#[derive(Clone, Copy)]
struct Vertex {
    pos: [f32; 2],
    uv: [f32; 2],
}

const VERTICES: [Vertex; 4] = [
    Vertex { pos: [-0.5, -0.5], uv: [0.0, 1.0] },
    Vertex { pos: [ 0.5, -0.5], uv: [1.0, 1.0] },
    Vertex { pos: [ 0.5,  0.5], uv: [1.0, 0.0] },
    Vertex { pos: [-0.5,  0.5], uv: [0.0, 0.0] },
];

const INDICES: [u16; 6] = [0, 1, 2, 0, 2, 3];

#[repr(C)] keeps the field order and layout predictable, which matters because the GPU reads these bytes directly. Most people use a small crate such as bytemuck to turn the array into a byte slice for the buffer upload. Positions here are in clip space, where the visible screen runs from -1 to 1 on both axes, so this quad covers the middle quarter of the window. Note the flipped V coordinate: in WebGPU texture space, V grows downward, so the top of the quad gets v = 0.0.

Each vertex is 16 bytes (four f32 values). That number becomes the stride in the vertex buffer layout you give the pipeline, with the position at offset 0 and the UV at offset 8. Get either wrong and you get a stretched or invisible quad with no error at all.

The shader

Here is a complete WGSL shader for a textured quad:

struct VsOut {
    @builtin(position) clip: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

@vertex
fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
    var out: VsOut;
    out.clip = vec4<f32>(pos, 0.0, 1.0);
    out.uv = uv;
    return out;
}

@group(0) @binding(0) var tex: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    return textureSample(tex, samp, in.uv);
}

The @location numbers on the vertex inputs must match the shader locations in your vertex buffer layout. The @group(0) @binding(0) and @binding(1) must match the bind group layout: slot 0 a 2D float texture, slot 1 a sampler, both visible to the fragment stage. When those three descriptions (Rust vertex struct, buffer layout, WGSL) agree, the quad appears.

For pixel art, create the sampler with nearest filtering for both magnification and minification. Linear filtering on a 16 by 16 sprite scaled to 256 pixels gives you a blurry smear.

A worked example: texture upload size

Say you load a 256 by 256 RGBA image. Each pixel is 4 bytes, so a row is 1,024 bytes and the whole texture is 262,144 bytes. When you write it through the queue, you describe the layout with "bytes per row" (1,024) and "rows per image" (256). Get bytes per row wrong and the image shears diagonally, because each row starts at the wrong offset.

There is one more trap: when copying between buffers and textures, rather than writing through the queue helper, bytes per row must be a multiple of 256. A 100 pixel wide RGBA image has 400 bytes per row, which you must pad to 512. The queue write path is more forgiving, which is one reason beginners should use it first.

Common first errors

These are the ones I see over and over, in roughly the order people hit them.

  1. Validation error about a missing or mismatched binding. The bind group, the bind group layout and the shader disagree. Check group numbers, binding numbers, resource types and shader stage visibility. The error message names the binding; read it slowly.
  2. Blank window, no errors. Often the clear colour works but the quad is missing. Check that you actually called draw with 6 indices, that the index format matches (u16 vs u32), that the vertex stride is right and that back-face culling is not removing triangles wound the "wrong" way.
  3. Colours look washed out or too dark. An sRGB versus linear mismatch. If the surface format is sRGB and your texture is sampled as sRGB, the round trip is correct. If one of them is not, colours shift. This matters even more once you start working with lights, as in the 2D lighting and bloom walkthrough.
  4. Crash or error on resize. The surface must be reconfigured with the new size when the window changes. A size of zero (minimised windows) must be skipped, not configured.
  5. "Surface lost" or "outdated" when getting the current texture. Reconfigure the surface and try again on the next frame. This is normal, not a bug in your code.
  6. Works on desktop, fails in the browser. You requested a feature or limit the web does not offer. Start from the default limits and add only what you need.

Where to go next

Once the quad works, the next steps are almost mechanical. Add a uniform buffer with a camera matrix so you can work in world units instead of clip space. Batch many sprites into one vertex buffer and one draw. Put several sprites into one texture atlas so they share a bind group. Each of these is a small, testable change to a renderer you now understand.

If you are deciding how much of this to build yourself versus use from a framework, the piece on how to make a game engine puts the renderer in context with the rest of the loop. More rendering articles are in the graphics section.

A good exercise to finish: make the quad's texture a 4 by 4 checkerboard you generate in code, upload it, and switch the sampler between nearest and linear filtering with a key press. Seeing the two side by side teaches you more about samplers than any amount of reading.

Filed in Graphics & Shaders · Rust Gamedev

Written by Odalys Brisco

Odalys covers graphics programming: wgpu, shaders, lighting and the maths behind a good-looking 2D frame. She came to it from technical art and still sketches every effect on paper before writing a line of WGSL. Based in Valencia.

Keep reading