device.createSampler() takes these fields; every default is the cheapest choice, so an empty descriptor gives point sampling with clamped coordinates:
| Field | Values (default first) | Effect |
|---|---|---|
| addressModeU, V, W | clamp-to-edge, repeat, mirror-repeat | Coordinates outside 0..1 |
| magFilter, minFilter | nearest, linear | Enlarged or shrunk texels |
| mipmapFilter | nearest, linear | Blend between mip levels (Mipmaps and Loading) |
| lodMinClamp, lodMaxClamp | 0, 32 | Restrict the mip levels used |
| compare | none | Makes a comparison sampler (Comparison Samplers) |
| maxAnisotropy | 1 (usually up to 16) | Anisotropic filtering, all filters linear |
The figure in 2D Textures shows the filters and address modes. nearest keeps hard texel edges, right for pixel art, lookup tables and integer data; linear blends four texels, the choice for photos and text. repeat tiles the "B" (the orange corner repeats at every tile), mirror-repeat flips alternate tiles so edges meet seamlessly, and clamp-to-edge smears the border texels outward: the orange corner fills the top-left region. There is no border-color mode. maxAnisotropy: 8 with the default nearest filters failed ("... is not FilterMode::Linear while using anisotropic filter"). Samplers are small immutable objects: create a handful at startup and share them.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="380"></canvas>
<canvas id="labels" width="600" height="380"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
// Texture coordinates run from -0.6 to 1.6, so the address mode decides what lies outside 0..1.
const code = /* wgsl */ `
@group(0) @binding(0) var letter: texture_2d<f32>;
@group(0) @binding(1) var smp: sampler;
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
let q = vec2f(f32(v & 1), f32(v >> 1));
return Out(vec4f(q * 2 - 1, 0, 1), vec2f(q.x, 1 - q.y) * 2.2 - 0.6);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let edge = max(step(abs(in.uv.x - 0.5), 0.5) * step(abs(in.uv.y - 0.5), 0.5), 0.6); // dim outside 0..1
return vec4f(textureSample(letter, smp, in.uv).rgb * edge, 1);
}`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
// A tiny 16 x 16 "B" with an orange top-left corner, painted with the 2D API.
const art = new OffscreenCanvas(16, 16), g = art.getContext('2d');
g.fillStyle = '#f5efe2'; g.fillRect(0, 0, 16, 16);
g.fillStyle = '#e0801e'; g.fillRect(0, 0, 5, 5);
g.fillStyle = '#14509a'; g.font = 'bold 14px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('B', 9, 9);
const texture = device.createTexture({ size: [16, 16], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
device.queue.copyExternalImageToTexture({ source: art }, { texture }, [16, 16]);
// maxAnisotropy needs linear filters: an error scope catches the mistake.
device.pushErrorScope('validation');
device.createSampler({ maxAnisotropy: 8 });
const anisoError = await device.popErrorScope();
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } });
const modes = ['repeat', 'mirror-repeat', 'clamp-to-edge'], filters = ['nearest', 'linear'];
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline);
filters.forEach((filter, row) => modes.forEach((mode, col) => {
const sampler = device.createSampler({ addressModeU: mode, addressModeV: mode, magFilter: filter, minFilter: filter });
pass.setBindGroup(0, device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView() }, { binding: 1, resource: sampler }] }));
pass.setViewport(14 + col * 196, 30 + row * 170, 176, 140, 0, 1);
pass.draw(4);
}));
pass.end();
device.queue.submit([encoder.finish()]);
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
filters.forEach((filter, row) => modes.forEach((mode, col) => ink.fillText(`${filter}, ${mode}`, 14 + col * 196, 24 + row * 170)));
ink.font = '11px ui-monospace, monospace'; ink.fillStyle = '#8a2b2b';
ink.fillText(`maxAnisotropy: 8 with nearest filters: ${anisoError ? anisoError.message.split('\n')[0] : 'ok'}`.slice(0, 92), 10, 372);
}
main();
</script>