Computer Graphics (rasterisation, ray tracing, WebGPU) Module 8 – Textures, filtering and anti-aliasing
Texture mapping and UV coordinates
How a pixel finds its texel: UV coordinates and which way up they run, repeat, mirror and clamp address modes, sRGB and linear textures, seams and padding.
What you will learn
- Find the texel that a pixel's texture coordinates read under nearest sampling
- Match an image upload to a mesh's UV convention so that textures appear upright
- Apply repeat, mirror-repeat and clamp-to-edge addressing and state the defaults of WebGPU, glTF and three.js
- Choose sRGB or linear texture formats for colour and data textures
- Explain why UV seams duplicate vertices and how much padding keeps islands from bleeding
Before you start
What you’ll learn · Before you start
What you will learn
- Find the texel that a pixel's texture coordinates read under nearest sampling
- Match an image upload to a mesh's UV convention so that textures appear upright
- Apply repeat, mirror-repeat and clamp-to-edge addressing and state the defaults of WebGPU, glTF and three.js
- Choose sRGB or linear texture formats for colour and data textures
- Explain why UV seams duplicate vertices and how much padding keeps islands from bleeding
Before you start
On this page
A wall drawn as two triangles has four corners, while the 1024 × 1024 photograph of bricks laid over it holds 1,048,576 texels. Texture mapping joins the two: each corner carries a texture coordinate, the rasteriser blends the coordinates across the triangles, and every pixel reads the texel at its own blended coordinate.
This lesson follows one pixel to its texel on an 8 × 8 texture, then settles the four things that go wrong in real renderers: images upside down, coordinates past the edge, colour and data decoded in the wrong way, and seams that bleed. Every number on the page comes from a program that runs on it.
A texture coordinate names a point of the image
The lesson’s texture is an 8 × 8 image of a capital F, and the wall’s four corners carry the texture coordinates: The (u, v) pair a vertex carries to say which point of a texture lies there, as fractions of the image: (0, 0) at the corner of the first texel in memory, (1, 1) at the far corner of the last. Between vertices they are interpolated. (0, 0), (1, 0), (1, 1) and (0, 1). Texture coordinates are fractions of the image, not texel numbers. WebGPU’s specification puts (0, 0) in the first texel: One element of a texture's grid, as a pixel is one element of an image. Texture coordinates scaled by the texture's size land inside one texel, the one nearest sampling reads. in memory and (1, 1) in the last, so the same coordinates fit a texture of 8 texels or of 4,096. The usual names for the pair are u, across the image, and v, down it.
A lookup turns a coordinate back into texels: multiply u by the width and v by the height, and the point lands inside one texel. Texel i covers u × width from i up to i + 1, so the texel a point falls in is the whole part of that product, and its centre lies at (i + 0.5) ÷ width. Reading that one texel is nearest sampling, the default of a WebGPU sampler: The object that tells a texture read how to filter and what to do with coordinates outside 0 to 1, a GPUSampler in WebGPU. Its defaults are nearest filtering and clamp-to-edge addressing.. The figure marks where pixel (5, 2) of the wall lands:
Texture space runs from (0, 0) at the first texel's corner to (1, 1) at the last
Text description of the diagram
An 8 by 8 grid of texels shows the lesson's texture: ink texels, drawn dark, form a capital F, and the rest are paper, drawn white. Along the top edge, u is marked at 0, 0.25, 0.5, 0.75 and 1, growing to the right; down the left edge, v is marked at the same values, growing downwards. So (0, 0) is the outer corner of the top-left texel, texel (0, 0), and (1, 1) the outer corner of the bottom-right texel, texel (7, 7). Each texel covers one eighth of the range in each direction.
A sample point sits at (u, v) = (0.344, 0.313), where pixel (5, 2) of the lesson's wall reads the texture. Scaled by the texture's size it lands at 8u = 2.75 and 8v = 2.5, inside texel (2, 2), which has a thick blue outline. That texel is ink, part of the F's upright stroke. A legend under the grid names the ink texels, the paper texels, the outlined texel and the sample point.
The program draws the wall on a 16 × 8 target, blending the corners’ coordinates with each pixel’s barycentric coordinates: Three weights, one per corner of a triangle, that add up to 1 and give a point as the weighted sum of the corners. A rasteriser gets them by dividing its three edge values by twice the triangle's area. More in “Barycentrics and perspective-correct interpolation” as the last lesson did, and prints three lookups:
// An 8 × 8 texture with an F on it, stored the way WebGPU and glTF store an image: row 0 first, and row 0 is the top
// of the picture. Texture coordinate (0, 0) is the outer corner of texel (0, 0), (1, 1) the far corner of texel (7, 7).
const TEXTURE = [
'........',
'.######.',
'.##.....',
'.#####..',
'.##.....',
'.##.....',
'.##.....',
'........',
];
const TW = 8;
const TH = 8;
// The nearest texel to (u, v): scale to texels, keep the whole part, and keep the index inside the texture.
function nearestTexel(u, v) {
const i = Math.min(TW - 1, Math.max(0, Math.floor(u * TW)));
const j = Math.min(TH - 1, Math.max(0, Math.floor(v * TH)));
return [i, j];
}
// A wall drawn as two triangles on a 16 × 8 target. Each corner carries its texture coordinates.
const SW = 16;
const SH = 8;
const corners = {
tl: { x: 0, y: 0, uv: [0, 0] },
tr: { x: SW, y: 0, uv: [1, 0] },
br: { x: SW, y: SH, uv: [1, 1] },
bl: { x: 0, y: SH, uv: [0, 1] },
};
const triangles = [
[corners.tl, corners.tr, corners.br],
[corners.tl, corners.br, corners.bl],
];
const edge = (a, b, p) => (p[0] - a.x) * (b.y - a.y) - (p[1] - a.y) * (b.x - a.x);
const topLeft = (a, b) => b.y > a.y || (b.y === a.y && b.x < a.x);
// The texture coordinates at a pixel centre: the corners' (u, v) blended with the pixel's barycentric weights.
function uvAt(p) {
for (let t of triangles) {
if (edge(t[0], t[1], [t[2].x, t[2].y]) < 0) t = [t[0], t[2], t[1]];
const e = [edge(t[1], t[2], p), edge(t[2], t[0], p), edge(t[0], t[1], p)];
const inside = e.every((ei, k) => ei > 0 || (ei === 0 && topLeft(t[(k + 1) % 3], t[(k + 2) % 3])));
if (!inside) continue;
const sum = e[0] + e[1] + e[2];
return [0, 1].map((k) => (e[0] * t[0].uv[k] + e[1] * t[1].uv[k] + e[2] * t[2].uv[k]) / sum);
}
return null;
}
console.log('The texture, row 0 first (v = 0 at the top):');
for (const row of TEXTURE) console.log(' ' + row);
console.log('\nThe wall: 16 × 8 pixels, each one showing the texel its (u, v) falls in');
for (let y = 0; y < SH; y++) {
let line = '';
for (let x = 0; x < SW; x++) {
const [u, v] = uvAt([x + 0.5, y + 0.5]);
const [i, j] = nearestTexel(u, v);
line += TEXTURE[j][i];
}
console.log(' ' + line);
}
const f = (n, d) => n.toFixed(d);
console.log('\nThree pixels and the texel each one reads');
console.log('pixel centre (u, v) (8u, 8v) texel value');
for (const [x, y] of [[5, 2], [9, 3], [14, 5]]) {
const [u, v] = uvAt([x + 0.5, y + 0.5]);
const [i, j] = nearestTexel(u, v);
const cols = [
`(${x}, ${y})`.padEnd(10),
`(${x + 0.5}, ${y + 0.5})`.padEnd(14),
`(${f(u, 5)}, ${f(v, 5)})`.padEnd(20),
`(${f(u * TW, 3)}, ${f(v * TH, 3)})`.padEnd(16),
`(${i}, ${j})`.padEnd(8),
TEXTURE[j][i],
];
console.log(cols.join(''));
}
console.log('\nOn a texel boundary and at the far edge');
for (const u of [0.25, 1]) {
console.log(`u = ${u}: 8u = ${u * TW}, so the lookup reads texel column ${nearestTexel(u, 0.5)[0]}`);
}
// A coordinate aimed exactly at a texel's edge depends on rounding: try every 0.01 on a texture 100 texels wide.
const left = [];
for (let i = 0; i < 100; i++) {
const u = Number((i / 100).toFixed(2));
if (Math.floor(u * 100) !== i) left.push(`${u.toFixed(2)} gives ${u * 100}`);
}
console.log(`\nOf the 100 edges u = 0.00, 0.01 … 0.99 of a 100-texel texture, ${left.length} read the texel to their left:`);
for (const line of left) console.log(' ' + line); Output
The texture, row 0 first (v = 0 at the top): ........ .######. .##..... .#####.. .##..... .##..... .##..... ........ The wall: 16 × 8 pixels, each one showing the texel its (u, v) falls in ................ ..############.. ..####.......... ..##########.... ..####.......... ..####.......... ..####.......... ................ Three pixels and the texel each one reads pixel centre (u, v) (8u, 8v) texel value (5, 2) (5.5, 2.5) (0.34375, 0.31250) (2.750, 2.500) (2, 2) # (9, 3) (9.5, 3.5) (0.59375, 0.43750) (4.750, 3.500) (4, 3) # (14, 5) (14.5, 5.5) (0.90625, 0.68750) (7.250, 5.500) (7, 5) . On a texel boundary and at the far edge u = 0.25: 8u = 2, so the lookup reads texel column 2 u = 1: 8u = 8, so the lookup reads texel column 7 Of the 100 edges u = 0.00, 0.01 … 0.99 of a 100-texel texture, 3 read the texel to their left: 0.29 gives 28.999999999999996 0.57 gives 56.99999999999999 0.58 gives 57.99999999999999
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node uv_lookup.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Each texel covers two pixels across and one down, because the wall is twice as wide as the texture in pixels. Pixel (5, 2) reads (0.34375, 0.3125), which scales to (2.75, 2.5): texel (2, 2), ink. A coordinate exactly on a texel’s edge belongs to the texel on its right, so u = 0.25 reads column 2. And u = 1 would be column 8, past the last texel; it reads column 7 only because the index is clamped, which is the subject of the address modes below. Follow the same pixel through each stage of the lookup:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The pixel. The rasteriser has found that the centre of pixel (5, 2), the point (5.5, 2.5), lies inside the wall. Coverage says only that; what the pixel shows comes from the texture.
- Blend the corners. The wall's corners carry (0, 0), (1, 0), (1, 1) and (0, 1). Blended with the pixel's barycentric weights they give (u, v) = (0.34375, 0.3125): a third of the way across the image and under a third of the way down.
- Scale to texels. The texture is 8 texels each way, so the point lies at 8u = 2.75 and 8v = 2.5, measured in texels from the corner of texel (0, 0).
- Inside the texture. Both coordinates lie between 0 and 1, so no address mode changes anything here. A sampler's address mode matters only for coordinates below 0 or at 1 and above.
- Nearest texel. The whole parts of 2.75 and 2.5 are 2 and 2, so the nearest texel is texel (2, 2): column 2 of row 2, counted from 0 at the top left.
- The value. Texel (2, 2) is ink, part of the F's upright stroke, so the pixel shows ink. A colour texture in an -srgb format would also be decoded to linear light on this read.
- A coordinate past 1. On the wall whose u runs from 0 to 3, a pixel can carry u = 1.3. Scaled by 8 that is 10.4, so the whole-number index is 10, beyond the last texel, 7.
- Three modes, three texels. Under repeat, index 10 becomes 10 − 8 = 2. Under mirror-repeat the second copy reads backwards, so 10 becomes 5. Under clamp-to-edge it becomes 7, the last texel. The same coordinate shows three different texels.
Aiming at a texel's edge
A coordinate written as i ÷ width points at the edge between two texels, and rounding decides which one it reads. The program tried every 0.01 on a texture 100 texels wide: 0.29, 0.57 and 0.58 read the texel to their left, because 0.29 × 100 comes out as 28.999999999999996. Aim at a texel’s centre, (i + 0.5) ÷ width, when you mean one texel.
Who decides each vertex’s coordinates? The artist or the modelling tool, when the mesh is made: a flat projection for a wall, a wrap around a cylinder for a can, or an unwrap that cuts a surface into flat pieces. Heckbert’s survey of texture mapping calls this choice the parameterisation; the renderer only blends what the vertices carry.
Checkpoint
Check: Finding the texel a coordinate reads
The image’s top row is where v starts, unless an upload flips it
v = 0 reads the first row of texture memory, so what lands in that row decides which way up a texture appears. Image
files store their top row first. glTF’s specification puts texture coordinate (0, 0) at the upper left corner of the
image, so a glTF model expects the top row at v = 0. WebGPU’s copyExternalImageToTexture copies the top row first
unless its flipY option is set, and WebGL transfers the upper left pixel first unless UNPACK_FLIP_Y_WEBGL is set.
three.js turns the other way by default: its Texture.flipY is true, which flips an image when it is uploaded, and
its glTF loader sets flipY = false on every texture it creates. The program draws the F with each pairing of an
upload and a UV convention:
// The F image of the lesson as a file stores it: the first row is the top of the picture.
const IMAGE = [
'........',
'.######.',
'.##.....',
'.#####..',
'.##.....',
'.##.....',
'.##.....',
'........',
];
const N = 8;
// Uploading copies the file's rows into texture memory. With flipY the last row goes first, as three.js does by
// default (Texture.flipY) and WebGPU's copyExternalImageToTexture does only when asked ({ flipY: true }).
const upload = (flipY) => (flipY ? [...IMAGE].reverse() : [...IMAGE]);
// Texture coordinate v = 0 reads memory row 0, in WebGPU and in WebGL alike. A quad's corners carry their (u, v):
// glTF's convention puts (0, 0) at the image's top left; the bottom-up convention of OpenGL tutorials puts v = 1
// at the top of the quad.
const UVS = {
'glTF (v = 0 at the top)': (x, y) => [(x + 0.5) / N, (y + 0.5) / N],
'bottom-up (v = 1 at the top)': (x, y) => [(x + 0.5) / N, 1 - (y + 0.5) / N],
};
function drawQuad(memory, uvAt) {
const rows = [];
for (let y = 0; y < N; y++) {
let line = '';
for (let x = 0; x < N; x++) {
const [u, v] = uvAt(x, y);
line += memory[Math.min(N - 1, Math.floor(v * N))][Math.min(N - 1, Math.floor(u * N))];
}
rows.push(line);
}
return rows;
}
for (const [convention, uvAt] of Object.entries(UVS)) {
const plain = drawQuad(upload(false), uvAt);
const flipped = drawQuad(upload(true), uvAt);
console.log(`UVs: ${convention}`);
console.log(' uploaded as stored uploaded with flipY');
for (let y = 0; y < N; y++) console.log(` ${plain[y]} ${flipped[y]}`);
const upright = (rows) => (rows.join('') === IMAGE.join('') ? 'upright' : 'upside down');
console.log(` ${upright(plain).padEnd(22)}${upright(flipped)}\n`);
} Output
UVs: glTF (v = 0 at the top) uploaded as stored uploaded with flipY ........ ........ .######. .##..... .##..... .##..... .#####.. .##..... .##..... .#####.. .##..... .##..... .##..... .######. ........ ........ upright upside down UVs: bottom-up (v = 1 at the top) uploaded as stored uploaded with flipY ........ ........ .##..... .######. .##..... .##..... .##..... .#####.. .#####.. .##..... .##..... .##..... .######. .##..... ........ ........ upside down upright
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node orientation.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Two of the four come out upright and two upside down. Neither convention is wrong on its own: the F is upright
whenever the upload and the UVs agree, and upside down whenever exactly one of them flips. A program that gives the
top of a quad v = 1 must flip the image as it uploads it, with WebGL’s UNPACK_FLIP_Y_WEBGL or three.js’s flipY;
glTF and WebGPU keep the image as stored and give the top v = 0.
| Where the texture comes from | What sits at v = 0 | What to do |
|---|---|---|
| A glTF model, its images copied with WebGPU’s defaults | The image’s top row, as glTF expects | Nothing: no flip anywhere |
| A glTF model loaded with three.js’s glTF loader | The top row: the loader turns flipY off |
Nothing |
| A texture loaded by hand in three.js, put on a glTF model | The bottom row: flipY is true by default |
Set texture.flipY = false |
| Bottom-up UVs from a WebGL program, ported to WebGPU | The top row, so the image appears upside down | Copy with flipY: true, or flip v once at load |
Flip once, at one end
Fix orientation where the data enters: the upload or the mesh’s UVs. A 1 - v added in a shader to fix one model
breaks the next model that was already right.
Checkpoint
Check: Which row v = 0 reads
Address modes decide what a coordinate past the edge reads
A floor’s corners may carry u from 0 to 8, so that a tile texture repeats eight times across it. Coordinates outside 0 to 1 are normal, and a sampler’s address mode: A sampler's rule for a texture coordinate outside 0 to 1, set for each axis: clamp-to-edge reads the edge texel, repeat starts the image again, and mirror-repeat reads every other copy backwards. decides what they read, separately for u and for v. WebGPU has three, and the Vulkan specification, which WebGPU’s points to for texel selection, applies each to the whole-number texel index:
- clamp-to-edge keeps the index inside the texture, so every coordinate past an edge reads the edge texel.
- repeat keeps the remainder after dividing by the size, so the image starts again: glTF’s examples are 2.2 read as 0.2, and −0.4 read as 0.6.
- mirror-repeat repeats too, but reads every other copy backwards: 2.2 stays 0.2, while −0.4 reads as 0.4.
The program samples a strip of four texels, A to D, from u = −1 to u = 2 under each mode, then lays the F over a wall whose u runs from 0 to 3:
// The three address modes of WebGPU's samplers, applied as the Vulkan specification's wrapping operation applies them:
// to the whole-number texel index i = floor(u × size), before the texel is read.
const mod = (a, n) => ((a % n) + n) % n; // the remainder that is never negative
const mirror = (n) => (n >= 0 ? n : -(1 + n));
const WRAP = {
'clamp-to-edge': (i, size) => Math.min(size - 1, Math.max(0, i)),
repeat: (i, size) => mod(i, size),
'mirror-repeat': (i, size) => size - 1 - mirror(mod(i, 2 * size) - size),
};
// A strip of four texels, A to D, sampled at 24 points from u = -1 to u = 2: eight samples per copy of the texture.
const STRIP = 'ABCD';
console.log('Nearest samples of the strip ABCD from u = -1 to u = 2');
console.log(' u < 0 0 ≤ u < 1 u ≥ 1');
for (const [mode, wrap] of Object.entries(WRAP)) {
let line = '';
for (let k = 0; k < 24; k++) {
const u = -1 + (k + 0.5) / 8;
line += STRIP[wrap(Math.floor(u * STRIP.length), STRIP.length)];
if (k === 7 || k === 15) line += ' ';
}
console.log(`${mode.padEnd(17)}${line}`);
}
// The same rules on the coordinate itself, as glTF's specification states them, with its two examples.
const wrapCoord = {
repeat: (u) => u - Math.floor(u),
'mirror-repeat': (u) => {
const t = u - 2 * Math.floor(u / 2); // where u falls in a pair of copies, 0 ≤ t < 2
return t <= 1 ? t : 2 - t;
},
};
console.log('\nglTF\'s examples, wrapped on the coordinate');
for (const [mode, wrap] of Object.entries(wrapCoord)) {
const out = [2.2, -0.4].map((u) => `${u} -> ${wrap(u).toFixed(1)}`).join(', ');
console.log(`${mode.padEnd(17)}${out}`);
}
// The F texture on a wall whose corners carry u from 0 to 3: three copies across, one down.
const F = ['........', '.######.', '.##.....', '.#####..', '.##.....', '.##.....', '.##.....', '........'];
console.log('\nThe F on a wall with u from 0 to 3, under each mode');
for (const [mode, wrap] of Object.entries(WRAP)) {
console.log(`${mode}:`);
for (let y = 0; y < 8; y++) {
let line = '';
for (let x = 0; x < 24; x++) {
const u = ((x + 0.5) / 24) * 3;
const v = (y + 0.5) / 8;
line += F[wrap(Math.floor(v * 8), 8)][wrap(Math.floor(u * 8), 8)];
}
console.log(' ' + line);
}
} Output
Nearest samples of the strip ABCD from u = -1 to u = 2
u < 0 0 ≤ u < 1 u ≥ 1
clamp-to-edge AAAAAAAA AABBCCDD DDDDDDDD
repeat AABBCCDD AABBCCDD AABBCCDD
mirror-repeat DDCCBBAA AABBCCDD DDCCBBAA
glTF's examples, wrapped on the coordinate
repeat 2.2 -> 0.2, -0.4 -> 0.6
mirror-repeat 2.2 -> 0.2, -0.4 -> 0.4
The F on a wall with u from 0 to 3, under each mode
clamp-to-edge:
........................
.######.................
.##.....................
.#####..................
.##.....................
.##.....................
.##.....................
........................
repeat:
........................
.######..######..######.
.##......##......##.....
.#####...#####...#####..
.##......##......##.....
.##......##......##.....
.##......##......##.....
........................
mirror-repeat:
........................
.######..######..######.
.##..........##..##.....
.#####....#####..#####..
.##..........##..##.....
.##..........##..##.....
.##..........##..##.....
........................
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node address_modes.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Clamp-to-edge gives one copy and then the edge column, stretched: the F’s last column is paper, so two thirds of the wall are blank. Repeat gives three upright copies. Mirror-repeat gives the F, its mirror image, and the F again, so each copy meets the edge of its own reflection. The defaults differ, and the difference is a common bug:
| Who | Default for u and v | Where it is stated |
|---|---|---|
| A WebGPU sampler | clamp-to-edge |
GPUSamplerDescriptor: addressModeU, V and W |
A glTF sampler with no wrapS or wrapT |
repeat (10497) | glTF 2.0, sampler.wrapS and wrapT, which renderers must follow |
A three.js Texture |
ClampToEdgeWrapping |
Texture.wrapS and wrapT |
A glTF floor that tiles therefore needs a WebGPU sampler made with addressModeU: 'repeat', because the descriptor’s
default would show one tile and a smear. Which mode suits a texture of yours? Answer three questions:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Address mode | Tiles a surface | Hides a mismatched edge | Keeps the edges apart |
|---|---|---|---|
| repeat Index 10 of 8 texels: texel 2: the image starts again | Good u from 0 to 3 gives three upright copies of the F | Poor the left edge meets the right edge: a mismatch shows as a line | Poor linear filtering at u = 0 also reads the last column |
| mirror-repeat Index 10 of 8 texels: texel 5: every other copy reads backwards | Mixed tiles, but every other copy is a mirror image: the F reads backwards | Good each copy meets its own mirror image, so the edges always match | Mixed the edge texel meets itself: no colour from the far side |
| clamp-to-edge Index 10 of 8 texels: texel 7: the edge texel, stretched | Poor u past 1 repeats the last column: one copy, then a smear | Mixed no copies meet, so there is no seam, and no tiling either | Good the edge never reads the far side: right for decals and atlas tiles |
How the answers decide
- “Must the texture repeat across the surface, with coordinates beyond 0 to 1?” Yes, it tiles: rules out clamp-to-edge. Coordinates past 1 under clamp-to-edge repeat the last column of texels, so the surface shows one copy and a smear.
- “Must the texture repeat across the surface, with coordinates beyond 0 to 1?” No, one copy covers it: rules out repeat and adds a caveat to mirror-repeat. With one copy, filtering at the border under repeat mixes in the far edge. Clamp keeps each edge to itself; mirror-repeat does too, but shows a mirrored copy wherever a coordinate strays past an edge.
- “Must the texture repeat across the surface, with coordinates beyond 0 to 1?” Yes, it tiles and “Do the texture's opposite edges match, so a copy can meet the next one without a line?” No, they differ: adds a caveat to repeat. Repeat puts the right edge next to the left one, and edges that differ show as a line at every copy.
- “Must the texture repeat across the surface, with coordinates beyond 0 to 1?” Yes, it tiles and “Does the image show something that must not appear mirrored, such as text, a logo or a face?” Yes: rules out mirror-repeat. Mirror-repeat shows every other copy backwards, which text, logos and faces cannot hide.
A texture that tiles needs repeat, or mirror-repeat when its edges do not match and nothing in it reads backwards; a texture that appears once, such as a decal or an atlas tile, needs clamp-to-edge, so that filtering at its border never mixes in the opposite edge.
Key idea
Coordinates beyond 0 to 1 are ordinary, and the sampler, not the texture, decides what they read: set the address mode for each axis on purpose, because WebGPU and three.js default to clamp-to-edge while glTF defaults to repeat.
Checkpoint
Check: Address modes past the edge
Colour textures are sRGB, data textures are linear
A texture holds either colour that a person painted or photographed, or numbers that the shader computes with, and
the two are stored differently. glTF’s specification says that the red, green and blue of base colour and emissive
textures are encoded with the sRGB: The standard colour space of the web, of most images and of most displays. Its values are encoded with a curve that gives dark tones more of the 256 codes of an 8-bit channel. More in “Colour, light and sRGB encoding” transfer function, which a renderer undoes to get linear
values, before filtering when it filters linearly. Its metallic-roughness, normal and occlusion textures are stored
with the linear transfer function: the code is the value. WebGPU’s formats carry the choice: an -srgb format such
as rgba8unorm-srgb decodes as the shader reads, and rgba8unorm hands over the code divided by 255. The program
reads one texel of each kind through both formats:
// What a shader receives from one 8-bit texel, through a plain rgba8unorm texture and through rgba8unorm-srgb, whose
// reads apply the sRGB decoding function first (WebGPU texture formats; the curve is the one glTF names for colour).
const unorm = (code) => code / 255;
const srgbDecode = (code) => {
const c = code / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const f4 = (x) => x.toFixed(4);
const rows = [
{ texture: 'base colour (stored as sRGB)', code: 128, right: 'srgb' },
{ texture: 'roughness (stored as linear)', code: 128, right: 'unorm' },
];
console.log('One texel of code 128, read through each format');
console.log('texture rgba8unorm rgba8unorm-srgb');
for (const r of rows) {
const a = `${f4(unorm(r.code))}${r.right === 'unorm' ? ' right' : ' wrong'}`;
const b = `${f4(srgbDecode(r.code))}${r.right === 'srgb' ? ' right' : ' wrong'}`;
console.log(`${r.texture.padEnd(31)}${a.padEnd(18)}${b}`);
}
// A tangent-space normal map stores each component n as the code of (n + 1) / 2: a flat texel, the normal (0, 0, 1),
// is stored as (128, 128, 255). glTF maps the read value back with value * 2 - 1, then normalises.
const toNormal = (rgb) => {
const n = rgb.map((c) => 2 * c - 1);
const len = Math.hypot(...n);
return n.map((c) => c / len);
};
const tilt = (n) => (Math.acos(n[2]) * 180) / Math.PI; // the angle from the surface's own normal, (0, 0, 1)
const flat = [128, 128, 255];
console.log('\nA flat normal-map texel (128, 128, 255)');
for (const [label, read] of [['rgba8unorm', unorm], ['rgba8unorm-srgb', srgbDecode]]) {
const n = toNormal(flat.map(read));
const light = [0.6, 0, 0.8]; // a light 36.9° from straight above, to the right
const nDotL = n[0] * light[0] + n[1] * light[1] + n[2] * light[2];
console.log(`${label.padEnd(16)} normal (${n.map(f4).join(', ')}) tilted ${tilt(n).toFixed(2)}° n · l = ${f4(nDotL)}`);
} Output
One texel of code 128, read through each format texture rgba8unorm rgba8unorm-srgb base colour (stored as sRGB) 0.5020 wrong 0.2159 right roughness (stored as linear) 0.5020 right 0.2159 wrong A flat normal-map texel (128, 128, 255) rgba8unorm normal (0.0039, 0.0039, 1.0000) tilted 0.32° n · l = 0.8023 rgba8unorm-srgb normal (-0.4430, -0.4430, 0.7795) tilted 38.79° n · l = 0.3578
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node colour_spaces.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The base colour code 128 must reach the shader as 0.2159, its linear light: Colour values proportional to the amount of light, so doubling a value doubles the light. Lighting, blending and filtering are computed on them. More in “Colour, light and sRGB encoding”; a plain
rgba8unorm texture gives 0.5020, so the whole texture looks washed out, as the colour lesson showed. A roughness of
code 128 means 0.5020; read through an -srgb format it arrives as 0.2159, so the surface looks far glossier than
it was made. A normal map suffers most. Its flat texel, (128, 128, 255), stands for the normal (0, 0, 1), and read as
linear it gives a normal tilted 0.32° by rounding. Decoded as sRGB, it tilts 38.79°, and a light 36.9° from straight
above lights it with n · l = 0.3578 instead of 0.8023: less than half.
| Texture | Stored as | WebGPU format, 8 bits a channel |
|---|---|---|
| Base colour, emissive | sRGB codes | rgba8unorm-srgb: decoded on every read, before filtering |
| Normal, roughness and metalness, occlusion | Linear values | rgba8unorm: the code divided by 255 |
| Height, masks, lookup tables | Linear values | rgba8unorm, or r8unorm for one channel |
One format for every texture
Loading every image of a model with the same -srgb format, or the same plain one, gets half of them wrong. Choose
the format from the material slot the texture fills, not from the file: a PNG can hold either kind.
Checkpoint
Check: sRGB colour and linear data textures
Seams copy vertices, and padding keeps islands apart
A cube cannot lie flat in a texture without being cut. Unwrapping it as a cross cuts 7 of its 12 edges, the UV seam: An edge of a surface that a UV unwrap cuts, so that the points along it lie at two places in the texture. A vertex on a seam is stored twice, once for each side, with the same position and different texture coordinates., and leaves the other 5 as folds, so its six faces become one connected UV island: One connected piece of a surface laid flat in texture space by an unwrap. A model's islands are packed into one texture with padding between them, so that filtering never mixes one island's colours into another's.:
Cutting 7 of a cube's 12 edges lays it flat as one island with 14 corners
Text description of the diagram
Six squares form a cross: a row of four, labelled left, front, right and back, with a top square above the front and a bottom square below it. This is one UV island: the faces of a cube laid flat in the texture.
The 5 lines inside the cross, drawn thin and solid, are folds: cube edges the island keeps, so the faces on either side of each one meet in the texture as they do in space. The whole outline of the cross is drawn dashed: it is made of 14 segments, the two sides of the 7 cube edges the unwrap cuts, the seams. A grey band around the outline is padding, where the island's edge colours are copied outwards.
Dots mark the 14 corners of the island. The cube has only 8 corners, so 6 of these dots are extra copies made by the seams. Three dots are drawn larger, with a hollow centre: the top-left corner of the top square, the top-left corner of the left square and the top-right corner of the back square. They are all the same corner of the cube, its back top left, which the unwrap places in three different points of the texture.
The cube has 8 corners, the island 14. A vertex carries one texture coordinate, so a corner that lies at three points of the texture becomes three vertices with the same position and different coordinates. Seams therefore add vertices, and a mesh is cut where the cuts are least visible: under an arm, along the back of a can.
Textures often gather many islands into one image, a texture atlas: One texture that holds many images or UV islands side by side, so that a single texture and sampler serve many surfaces. Its islands need padding, because filtering and smaller mip levels average across their borders., so that one texture serves a whole model. The danger lies at the islands’ borders. Linear filtering mixes neighbouring texels, and the smaller copies of a texture that a renderer uses for distant surfaces, its mip levels, average blocks of texels: at the border those mixes reach across into the next island. Padding guards against it by copying each island’s edge colour outwards into the gap. The program measures how much of a red neighbour bleeds into a skin-coloured island of 16 texels, for 0 to 16 texels of padding:
// One row of a 64-texel texture atlas. A skin-coloured island (value 1) fills texels 16 to 31; everything around it
// belongs to red islands (value 0). Padding copies the island's edge colour into the texels around it. Each mip level
// averages pairs of texels of the level above. The program samples every texel centre of the skin island with linear
// filtering at each level and reports how far the darkest sample falls below 1: how much red has bled in.
const SIZE = 64;
const SKIN = [16, 32]; // the island's first texel, and the first texel after it
function atlasRow(padding) {
const row = new Array(SIZE).fill(0);
for (let i = SKIN[0] - padding; i < SKIN[1] + padding; i++) row[i] = 1;
return row;
}
function mipChain(row) {
const levels = [row];
while (levels.at(-1).length > 1) {
const prev = levels.at(-1);
levels.push(Array.from({ length: prev.length / 2 }, (_, i) => (prev[2 * i] + prev[2 * i + 1]) / 2));
}
return levels;
}
// Linear filtering within one level: the two texels around u, weighted by distance (clamp-to-edge addressing).
function linear(level, u) {
const x = u * level.length - 0.5;
const i0 = Math.floor(x);
const a = x - i0;
const at = (i) => level[Math.min(level.length - 1, Math.max(0, i))];
return (1 - a) * at(i0) + a * at(i0 + 1);
}
console.log('Red in the skin island at each mip level (0 = none, 1 = all red), by padding in texels');
console.log('padding level 0 level 1 level 2 level 3 level 4 level 5 clean through');
for (const padding of [0, 1, 2, 4, 8, 16]) {
const levels = mipChain(atlasRow(padding));
const bleed = [];
for (let k = 0; k <= 5; k++) {
let darkest = 1;
for (let i = SKIN[0]; i < SKIN[1]; i++) darkest = Math.min(darkest, linear(levels[k], (i + 0.5) / SIZE));
bleed.push(1 - darkest);
}
const first = bleed.findIndex((b) => b > 1 / 255);
const through = first === -1 ? 'level 5' : `level ${first - 1}`;
console.log(`${String(padding).padEnd(9)}${bleed.map((b) => b.toFixed(3).padEnd(9)).join('')}${through}`);
} Output
Red in the skin island at each mip level (0 = none, 1 = all red), by padding in texels padding level 0 level 1 level 2 level 3 level 4 level 5 clean through 0 0.000 0.250 0.375 0.438 0.469 0.742 level 0 1 0.000 0.125 0.281 0.383 0.439 0.711 level 0 2 0.000 0.000 0.188 0.328 0.410 0.680 level 1 4 0.000 0.000 0.000 0.219 0.352 0.617 level 2 8 0.000 0.000 0.000 0.000 0.234 0.492 level 3 16 0.000 0.000 0.000 0.000 0.000 0.242 level 4
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node atlas_bleed.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Without padding, a quarter of the edge texel is red already at level 1, and nearly half at level 4. Each level averages twice as many texels as the one before, so each level needs twice the padding: 2 texels kept the island clean through level 1, 4 through level 2, 8 through level 3 and 16 through level 4. An island seen only up close needs a few texels of padding; one seen across a room needs as many texels as the levels it will be drawn from.
Image Viewer (Preview Any Format) Open a texture or an atlas, zoom in with the pixel grid on and click texels to read their codes: the top row of the picture is v = 0, and the padding shows as edge colours copied outwards.Checkpoint
Check: Seams and padding
Practise: sample a texture with all three modes
The exercise is the lookup of this lesson as two functions: wrap a texel index under each address mode, then sample the nearest texel on both axes. Its sample tests use the lesson’s strip, the F and a texture five texels wide:
Exercise · Easy · JavaScript
Nearest-texel sampling with three address modes
Write two functions in sample.mjs. A texture is { width, height, data }, where data holds width × height numbers row by row, and row 0 is the row that texture coordinate v = 0 reads.
wrapIndex(i, size, mode): the texel index that a whole-number indexibecomes under the address modemode, one of'clamp-to-edge','repeat'and'mirror-repeat', for a texturesizetexels across.imay be negative orsizeor more.sampleNearest(texture, u, v, modeU, modeV): the value of the nearest texel to the texture coordinates(u, v). Scale each coordinate by the texture's size, take the whole part withMath.floor, wrap it with the axis's mode, and read the texel. Both modes default to'clamp-to-edge', as a WebGPU sampler's do.
The starter clamps every index correctly, but its repeat goes wrong below 0 and it has no mirror-repeat. The sample tests use the lesson's strip of four texels, the 8 × 8 F texture and a texture five texels wide.
Starter code · sample.mjs
/**
* wrapIndex(i, size, mode): the texel index of whole-number index i under 'clamp-to-edge', 'repeat' or 'mirror-repeat'.
* sampleNearest(texture, u, v, modeU, modeV): the nearest texel's value; texture = { width, height, data }, row 0 at v = 0.
* This first version gets repeat wrong below 0 and treats mirror-repeat as clamp-to-edge.
*/
export function wrapIndex(i, size, mode) {
if (mode === 'repeat') return i % size;
return Math.min(size - 1, Math.max(0, i));
}
export function sampleNearest(texture, u, v, modeU = 'clamp-to-edge', modeV = 'clamp-to-edge') {
const i = wrapIndex(Math.floor(u * texture.width), texture.width, modeU);
const j = wrapIndex(Math.floor(v * texture.height), texture.height, modeV);
return texture.data[j * texture.width + i];
} The sample tests · sample.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { sampleNearest, wrapIndex } from './sample.mjs';
// The lesson's strip of four texels, A to D, and its 8 × 8 F texture (1 = ink, 0 = paper), row 0 at the top.
const STRIP = { width: 4, height: 1, data: ['A', 'B', 'C', 'D'] };
const F_ROWS = ['........', '.######.', '.##.....', '.#####..', '.##.....', '.##.....', '.##.....', '........'];
const F = { width: 8, height: 8, data: F_ROWS.join('').split('').map((c) => (c === '#' ? 1 : 0)) };
const strip = (mode) => Array.from({ length: 24 }, (_, k) => sampleNearest(STRIP, -1 + (k + 0.5) / 8, 0.5, mode)).join('');
test('clamp-to-edge keeps every index inside the texture', () => {
assert.equal(wrapIndex(-3, 4, 'clamp-to-edge'), 0);
assert.equal(wrapIndex(4, 4, 'clamp-to-edge'), 3);
assert.equal(strip('clamp-to-edge'), 'AAAAAAAAAABBCCDDDDDDDDDD');
assert.equal(sampleNearest(STRIP, 1, 0.5), 'D', 'u = 1 reads the last texel, as WebGPU clamps it');
});
test('repeat wraps indices below 0 and past the end', () => {
assert.equal(wrapIndex(-2, 4, 'repeat'), 2, 'u = -0.4 on four texels: floor(-1.6) = -2 reads texel 2');
assert.equal(wrapIndex(8, 4, 'repeat'), 0, 'u = 2.2: floor(8.8) = 8 reads texel 0');
assert.equal(strip('repeat'), 'AABBCCDDAABBCCDDAABBCCDD');
assert.equal(sampleNearest({ width: 5, height: 1, data: [10, 20, 30, 40, 50] }, -0.1, 0, 'repeat'), 50, 'five texels: floor(-0.5) = -1 reads the last one');
});
test('mirror-repeat reads every other copy backwards', () => {
assert.equal(wrapIndex(5, 4, 'mirror-repeat'), 2, 'u = 1.3 on four texels reads texel 2');
assert.equal(wrapIndex(-1, 4, 'mirror-repeat'), 0, 'u = -0.1 reads texel 0');
assert.equal(wrapIndex(-2, 4, 'mirror-repeat'), 1, 'u = -0.4 reads texel 1');
assert.equal(strip('mirror-repeat'), 'DDCCBBAAAABBCCDDDDCCBBAA');
});
test('two axes, each with its own mode', () => {
assert.equal(sampleNearest(F, 0.34375, 0.3125), 1, "pixel (5, 2) of the lesson's wall reads ink at texel (2, 2)");
assert.equal(sampleNearest(F, 0.90625, 0.6875), 0, 'pixel (14, 5) reads paper at texel (7, 5)');
assert.equal(sampleNearest(F, 2.3, 0.3, 'repeat', 'clamp-to-edge'), 1, 'u = 2.3 repeats to texel 2 of row 2');
assert.equal(sampleNearest(F, 1.25, 0.2, 'mirror-repeat', 'clamp-to-edge'), 1, 'u = 1.25 mirrors to texel 5 of row 1');
assert.equal(sampleNearest(F, 0.3, 1.2, 'clamp-to-edge', 'clamp-to-edge'), 0, 'v = 1.2 clamps to row 7, all paper');
assert.equal(sampleNearest(F, 0.3, 1.2, 'clamp-to-edge', 'repeat'), 1, 'v = 1.2 repeats to row 1, where texel 2 is ink');
}); A hint
JavaScript's % keeps the sign of the left side, so -2 % 4 is -2. Use ((i % n) + n) % n for a remainder that is never negative. For mirror-repeat, take the index modulo 2 × size: the first size positions read the texture forwards and the next size read it backwards, so position size + k reads texel size - 1 - k.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The self-check below covers lookups, orientation, address modes, formats and padding. It starts with whichever of those you have got wrong most often here, and it asks a missed question again later in the same session:
Practice that adapts to you
Practise this lesson
14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
First a definition that interviews often open with, answered here in full. Then two textures that misbehave in a real renderer; each row leads to the module’s question page, with a first step and the follow-ups:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid · Warm-up
What are a vertex's UV coordinates, and how does a sampler turn them into a texel?
They are the texture coordinates the vertex carries: a point of the image as fractions, u across and v down, with (0, 0) at the corner of the first texel in memory and (1, 1) at the far corner of the last, so the same values fit a texture of any size. The rasteriser blends the vertices' coordinates at each pixel. The sampler multiplies u by the texture's width and v by its height, and the point lands inside one texel: its column and row are the whole parts of the two products, and nearest sampling reads it. A coordinate below 0, or at 1 and above, first goes through the axis's address mode: clamp-to-edge, repeat or mirror-repeat. On an 8 × 8 texture, (0.34375, 0.3125) scales to (2.75, 2.5) and reads texel (2, 2).
-
Mid to senior
Your WebGPU renderer copies every image with flipY: true, because its own quads give the top of an image v = 1. After you add glTF models, their textures appear upside down, while the quads still look right. Explain why, and fix it without editing the assets.
-
Mid to senior
A character's texture atlas looks right up close, but as the camera moves away a red band from a neighbouring island creeps along the edge of the face. Explain the cause with numbers, and fix both the asset pipeline and the renderer.
Key takeaways
- Texture coordinates are fractions of the image: (0, 0) is the corner of the first texel in memory and (1, 1) the far corner of the last, and nearest sampling reads the texel whose index is the whole part of u × width.
- A texture appears upright when the upload and the mesh’s UV convention agree: glTF and WebGPU keep the image’s top
row at v = 0, while three.js flips images on upload unless
flipYis turned off. - Clamp-to-edge, repeat and mirror-repeat decide what coordinates beyond 0 to 1 read, and their defaults differ: clamp-to-edge in WebGPU and three.js, repeat in glTF.
- Colour textures are stored as sRGB and need an
-srgbformat; normal, roughness and other data textures are linear, and decoding them as sRGB tilts a flat normal by 38.79°. - Seams give one point of a surface several texture coordinates, so they duplicate vertices, and an atlas island needs about 2ᵏ texels of padding to stay clean through mip level k.
Terms in this lesson
- address mode
- A sampler's rule for a texture coordinate outside 0 to 1, set for each axis: clamp-to-edge reads the edge texel, repeat starts the image again, and mirror-repeat reads every other copy backwards.
- sampler
- The object that tells a texture read how to filter and what to do with coordinates outside 0 to 1, a GPUSampler in WebGPU. Its defaults are nearest filtering and clamp-to-edge addressing.
- texel
- One element of a texture's grid, as a pixel is one element of an image. Texture coordinates scaled by the texture's size land inside one texel, the one nearest sampling reads.
- texture atlas
- One texture that holds many images or UV islands side by side, so that a single texture and sampler serve many surfaces. Its islands need padding, because filtering and smaller mip levels average across their borders.
- texture coordinates
- The (u, v) pair a vertex carries to say which point of a texture lies there, as fractions of the image: (0, 0) at the corner of the first texel in memory, (1, 1) at the far corner of the last. Between vertices they are interpolated.
- UV island
- One connected piece of a surface laid flat in texture space by an unwrap. A model's islands are packed into one texture with padding between them, so that filtering never mixes one island's colours into another's.
- UV seam
- An edge of a surface that a UV unwrap cuts, so that the points along it lie at two places in the texture. A vertex on a seam is stored twice, once for each side, with the same position and different texture coordinates.
References
- WebGPU: 3.3 Coordinate Systems, 6.3 Texture Formats, 7.1 GPUSampler and 11.2.5 GPUCopyExternalImageSourceInfo (World Wide Web Consortium (W3C))
- Vulkan specification: Textures (texel coordinates and the wrapping operation) (Khronos Group)
- glTF 2.0 Specification: 3.8 Textures, Images and Samplers, and 3.9 Materials (Khronos Group)
- WebGL 1.0 Specification: texImage2D and the pixel storage parameters (UNPACK_FLIP_Y_WEBGL) (Khronos Group)
- three.js documentation: Texture (wrapS, wrapT, flipY and colorSpace) (three.js authors)
- three.js r186 source: examples/jsm/loaders/GLTFLoader.js (texture flipY and wrapping) (three.js authors)
- Survey of Texture Mapping (IEEE Computer Graphics and Applications, vol. 6, no. 11) (IEEE)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress