WebGPU Textures & Samplers

A texture is an image on the GPU. A sampler decides how the shader reads it.

Textures put pictures, patterns and text on your shapes. In this lesson you will make a texture from raw pixels, copy a canvas drawing into one, and draw both on a square.

A picture drawn with the 2D canvas API, copied into a texture and drawn by WebGPU.

Textures vs Buffers

You already know buffers. A buffer is a plain list of bytes. A texture is different:

BufferTexture
ShapeA flat list of bytesA 1D, 2D or 3D grid of pixels
Pixel formatNone. You decide what the bytes mean.Fixed, for example rgba8unorm
Reading in WGSLBy index: data[i]By coordinate, with a sampler: textureSample(...)
FilteringNoYes. The GPU can blend neighbouring pixels.

A pixel in a texture is often called a texel.

Create a Texture

Use device.createTexture(). Give it a size, a format and a usage:

const texture = device.createTexture({
  size: [TEXTURE_SIZE, TEXTURE_SIZE],
  format: "rgba8unorm",
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
});
  • "rgba8unorm" means 4 bytes per texel: red, green, blue and alpha. Each byte goes from 0 to 255, and the shader sees it as 0.0 to 1.0.
  • TEXTURE_BINDING lets a shader read the texture.
  • COPY_DST lets you copy data into it.

Upload Raw Pixels

device.queue.writeTexture() copies bytes from JavaScript into a texture. You tell it how many bytes are in each row with bytesPerRow:

device.queue.writeTexture(
  { texture },
  createCheckerboardPixels(),
  { bytesPerRow: TEXTURE_SIZE * BYTES_PER_PIXEL },
  [TEXTURE_SIZE, TEXTURE_SIZE]
);

The pixels are a Uint8Array, filled row by row from the top:

Example

Build an 8×8 checkerboard, with an orange texel in the top-left corner:

function createCheckerboardPixels() {
  const pixels = new Uint8Array(TEXTURE_SIZE * TEXTURE_SIZE * BYTES_PER_PIXEL);
  for (let y = 0; y < TEXTURE_SIZE; y++) {
    for (let x = 0; x < TEXTURE_SIZE; x++) {
      const isTopLeft = x === 0 && y === 0;
      const isLight = (x + y) % 2 === 0;
      const color = isTopLeft ? topLeftMarker : isLight ? lightSquare : darkSquare;
      pixels.set(color, (y * TEXTURE_SIZE + x) * BYTES_PER_PIXEL);
    }
  }
  return pixels;
}
Edit & Run »
Note writeTexture accepts any bytesPerRow. Copies inside the GPU, from a buffer to a texture, need bytesPerRow to be a multiple of 256.

Texture Coordinates (UV)

Shaders do not read textures by pixel number. They use UV coordinates from 0 to 1, so the same code works for any texture size:

  • (0, 0) is the top-left corner of the texture.
  • (1, 1) is the bottom-right corner.
  • u goes left to right, v goes top to bottom.

Each corner of the square gets a UV, next to its position. The GPU blends the UVs across the square, so every pixel knows where to read:

return new Float32Array([
  left, top, 0, 0,
  left, bottom, 0, 1,
  right, bottom, 1, 1,
  left, top, 0, 0,
  right, bottom, 1, 1,
  right, top, 1, 0
]);
Warning In clip space, y points up. In UV space, v points down. So the top corners of the square get v = 0. If your image appears upside down, this is usually why.

Samplers

A sampler tells the GPU how to read a texture: how to blend texels, and what to do outside the 0-to-1 range.

const sampler = device.createSampler({
  magFilter: "nearest",
  minFilter: "nearest"
});

The two most important filter values are:

  • "nearest": use the single closest texel. Sharp, blocky pixels. Good for pixel art.
  • "linear": blend the four closest texels. Smooth. Good for photos.

Example

The same 4×4 texture, drawn with a nearest sampler (left) and a linear sampler (right):

const nearestSampler = device.createSampler({
  magFilter: "nearest",
  minFilter: "nearest"
});
const linearSampler = device.createSampler({
  magFilter: "linear",
  minFilter: "linear"
});
Edit & Run »

The example makes one bind group per sampler. It draws the left square with one and the right square with the other. The texture is the same.

Reading a Texture in WGSL

In WGSL, a texture has the type texture_2d<f32>, and a sampler has the type sampler. Read a colour with textureSample(texture, sampler, uv):

Example

Pass the UV from the vertex shader, and sample the texture in the fragment shader:

struct VertexOutput {
  @builtin(position) position: vec4f,
  @location(0) uv: vec2f,
}

@group(0) @binding(0) var checkerTexture: texture_2d<f32>;
@group(0) @binding(1) var checkerSampler: sampler;

@vertex
fn vertexMain(@location(0) position: vec2f, @location(1) uv: vec2f) -> VertexOutput {
  var output: VertexOutput;
  output.position = vec4f(position, 0.0, 1.0);
  output.uv = uv;
  return output;
}

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return textureSample(checkerTexture, checkerSampler, input.uv);
}
Edit & Run »
Note textureSample can only be used in a fragment shader. In a vertex or compute shader, use textureSampleLevel or textureLoad instead.

Bind the Texture and Sampler

Textures and samplers reach the shader through a bind group, just like uniform buffers. Pass a texture view for the texture, and the sampler itself:

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: texture.createView() },
    { binding: 1, resource: sampler }
  ]
});

Then call pass.setBindGroup(0, bindGroup) before drawing.

Upload an Image

To put a picture in a texture, use device.queue.copyExternalImageToTexture(). The source can be an ImageBitmap, an HTMLCanvasElement, an OffscreenCanvas, a video frame and more.

The example draws its own picture with the 2D canvas API, so it needs no image file:

Example

Draw text and shapes on an OffscreenCanvas, then copy it into a texture:

function drawPicture() {
  const pictureCanvas = new OffscreenCanvas(IMAGE_WIDTH, IMAGE_HEIGHT);
  const drawing = pictureCanvas.getContext("2d");
  const gradient = drawing.createLinearGradient(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
  gradient.addColorStop(0, "#1e3a8a");
  gradient.addColorStop(1, "#9d174d");
  drawing.fillStyle = gradient;
  drawing.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
  drawing.fillStyle = "#ffffff";
  drawing.font = "bold 52px system-ui, sans-serif";
  drawing.fillText("Hello, WebGPU!", 40, 140);
  return pictureCanvas;
}

const texture = device.createTexture({
  size: [IMAGE_WIDTH, IMAGE_HEIGHT],
  format: "rgba8unorm",
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
});
device.queue.copyExternalImageToTexture(
  { source: drawPicture() },
  { texture },
  [IMAGE_WIDTH, IMAGE_HEIGHT]
);
Edit & Run »
Warning copyExternalImageToTexture needs the texture usage to include RENDER_ATTACHMENT as well as COPY_DST. Without it, you get a validation error.

To load a real image file, fetch it, turn it into an ImageBitmap, and copy that in the same way:

const response = await fetch("photo.png");
const bitmap = await createImageBitmap(await response.blob());
const photoTexture = device.createTexture({
  size: [bitmap.width, bitmap.height],
  format: "rgba8unorm",
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
});
device.queue.copyExternalImageToTexture({ source: bitmap }, { texture: photoTexture }, [bitmap.width, bitmap.height]);

Sampler Options

Filters decide how texels are blended:

OptionValuesDescription
magFilter"nearest", "linear"Used when the texture is drawn bigger than its real size (magnified)
minFilter"nearest", "linear"Used when the texture is drawn smaller than its real size (minified)
mipmapFilter"nearest", "linear"How to blend between mipmap levels, if the texture has them
maxAnisotropy1 to 16Sharper textures on surfaces seen at an angle. Needs all filters set to "linear".
compare"less", "greater", …Makes a comparison sampler, used for depth textures and shadows

Address modes decide what happens when a UV is below 0 or above 1:

OptionValueResult
addressModeU, addressModeV, addressModeW"clamp-to-edge"The edge texels are stretched outwards. The default.
"repeat"The texture tiles: 1.25 reads the same as 0.25
"mirror-repeat"The texture tiles, flipping every other copy
Tip To tile a floor texture, use addressModeU: "repeat" and addressModeV: "repeat", and give the corners UVs bigger than 1. A UV of 4 tiles the texture four times.

Exercises

1. A texture is 64 texels wide with format "rgba8unorm". What is bytesPerRow in writeTexture?

Show answer

64 × 4 = 256

2. In the checkerboard example, change the sampler to "linear". What happens to the squares?

Show answer

The edges between squares become soft and blurry, because the GPU blends neighbouring texels.

3. Make the checkerboard tile 3 times in each direction.

Show answer

Add repeat address modes to the sampler, and multiply the UV in the fragment shader:

const sampler = device.createSampler({
  magFilter: "nearest",
  minFilter: "nearest",
  addressModeU: "repeat",
  addressModeV: "repeat"
});
return textureSample(checkerTexture, checkerSampler, input.uv * 3.0);

4. Where is the orange texel drawn, and why?

Show answer

In the top-left corner. It is the first texel in the data, and UV (0, 0) is the top-left of the texture.

Summary

  • A texture is a grid of texels with a fixed format, such as rgba8unorm.
  • Upload raw bytes with writeTexture, and images or canvases with copyExternalImageToTexture.
  • UV coordinates go from (0, 0) at the top-left to (1, 1) at the bottom-right.
  • A sampler sets filtering ("nearest" or "linear") and address modes ("clamp-to-edge" or "repeat").
  • In WGSL, read a texture_2d<f32> with textureSample(texture, sampler, uv).

Official documentation