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.
Textures vs Buffers
You already know buffers. A buffer is a plain list of bytes. A texture is different:
| Buffer | Texture | |
|---|---|---|
| Shape | A flat list of bytes | A 1D, 2D or 3D grid of pixels |
| Pixel format | None. You decide what the bytes mean. | Fixed, for example rgba8unorm |
| Reading in WGSL | By index: data[i] | By coordinate, with a sampler: textureSample(...) |
| Filtering | No | Yes. 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_BINDINGlets a shader read the texture.COPY_DSTlets 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 »
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.ugoes left to right,vgoes 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
]);
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 »
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 »
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:
| Option | Values | Description |
|---|---|---|
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 |
maxAnisotropy | 1 to 16 | Sharper 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:
| Option | Value | Result |
|---|---|---|
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 |
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 withcopyExternalImageToTexture. - 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>withtextureSample(texture, sampler, uv).
Official documentation
Go deeper with the specifications and reference documentation:
- developer.mozilla.org
MDN: GPUDevice.createTexture()
Texture sizes, formats, mip levels and usage flags.
- developer.mozilla.org
MDN: GPUDevice.createSampler()
Filtering, address modes and anisotropy.
- developer.mozilla.org
MDN: GPUQueue.copyExternalImageToTexture()
Uploading images, canvases and video frames to the GPU.
- w3.org
WGSL specification: textureSample
The built-in that reads a filtered texel in a fragment shader.