Skip to content

Textures and Samplers

A texture stores pixels. A sampler decides how those pixels are filtered and what happens when the coordinate leaves the 0 to 1 range. They are separate objects: one texture can be paired with a nearest sampler or a linear sampler.

The demo uses a 64×64 "rgba8unorm" texture. Each texel is 4 bytes. The format can be sampled and can be the target of writeTexture().

const texture = device.createTexture({
label: "Checkerboard",
size: [64, 64],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});

size may also be { width: 64, height: 64 }. dimension defaults to "2d". mipLevelCount and sampleCount default to 1.

bytesPerRow must be a multiple of 256. Sixty-four pixels times 4 bytes is exactly 256, so the row needs no extra padding. A copy taller than one texel block must set bytesPerRow.

const size = 64;
const bytesPerRow = 256;
const pixels = new Uint8Array(bytesPerRow * size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const checker = ((x >> 3) ^ (y >> 3)) & 1;
const offset = y * bytesPerRow + x * 4;
pixels[offset] = checker ? 232 : 28;
pixels[offset + 1] = checker ? 164 : 78;
pixels[offset + 2] = checker ? 64 : 130;
pixels[offset + 3] = 255;
}
}
device.queue.writeTexture(
{ texture },
pixels,
{ bytesPerRow },
{ width: size, height: size },
);

copyExternalImageToTexture() copies from an ImageBitmap or another canvas. That destination must include both COPY_DST and RENDER_ATTACHMENT in usage, and the format must be a plain color format that can be a render attachment and is an unorm or float format.

const sampler = device.createSampler({
magFilter: "nearest",
minFilter: "nearest",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});

magFilter and minFilter are "nearest" or "linear". "nearest" keeps the checkerboard edges sharp when the quad is larger than the texture. addressModeU and addressModeV are "clamp-to-edge", "repeat", or "mirror-repeat". The default is "clamp-to-edge".

Sample in the fragment shader with textureSample(). The call has to be in uniform control flow, so do not hide it in a branch that depends on the fragment coordinate.

@group(0) @binding(0) var gradientTex: texture_2d<f32>;
@group(0) @binding(1) var gradientSampler: sampler;
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
return textureSample(gradientTex, gradientSampler, uv);
}

Do not name the variable sampler; that identifier is the type. textureSample() causes an automatic layout to treat the sampler as "filtering". A "non-filtering" sampler is for textureLoad().

Texture coordinate (0, 0) is the first texel written by writeTexture(). These UVs put that first row at the top of the quad:

const vertices = new Float32Array([
// x, y, u, v
-0.7, -0.7, 0, 1,
0.7, -0.7, 1, 1,
0.7, 0.7, 1, 0,
-0.7, -0.7, 0, 1,
0.7, 0.7, 1, 0,
-0.7, 0.7, 0, 0,
]);

The bind group holds the view and the sampler. Passing a GPUTexture is also valid: the specification treats it as the default view. This sample calls createView() explicitly.

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

Each checker cell is 8×8 pixels. The quad is two triangles.

Checkerboard with nearest sampling

Overlapping triangles still have no notion of which one is in front. The next chapter adds a depth buffer. Field lists are on GPUTexture and GPUSampler.