Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

The 8 × 8 F texture on a grid with u from 0 to 1 across and v from 0 to 1 down; texel (2, 2) is outlined with a sample point in it.Texture space of the 8 × 8 F textureu grows to the right and v grows down000.250.250.50.50.750.7511u →v ↓ink texel (#)paper texel (.)texel (2, 2), which pixel (5, 2) reads(u, v) = (0.344, 0.313): 8u = 2.75, 8v = 2.5

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:

A wall of 16 × 8 pixels textured with the 8 × 8 F JavaScript · lookup/uv_lookup.mjs
// 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

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:

Interactive · stage by stage From a pixel to the texel it shows Step through pixel (5, 2) of the wall, from its centre to the texel it reads, then send a coordinate outside 0 to 1 through the same stages.
From a pixel to the texel it shows, step 8 of 8. Showing Pixel centre (centre (5.5, 2.5)), Blended texture coords ((0.34375, 0.3125)), Scaled to texels (8u = 10.4, index 10), Address mode (repeat, mirror or clamp), Nearest texel (texel 2, 5 or 7), Texel value (?). In focus: Address mode, Nearest texel.Pixel centrecentre (5.5, 2.5)Blended texture coords(0.34375, 0.3125)Scaled to texels8u = 10.4, index 10Address moderepeat, mirror or clampNearest texeltexel 2, 5 or 7Texel value?

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 A texture is 16 texels wide. With nearest sampling, which texel column does u = 0.40 read? Columns count from 0.

    Type a number.

    A hint

    Multiply u by the width and keep the whole part.

    Show the answer: Question 1 of 2

    Answer: 6 column

    16 × 0.40 = 6.4, which lies inside texel 6, the one that covers 6 up to 7. Its centre is at (6 + 0.5) ÷ 16 = 0.40625, just to the right of the point.

  2. Question 2 of 2 On the 8-texel F, u = 0.25 lies exactly on the edge between texel columns 1 and 2. Which column does nearest sampling read?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Column 2

    Texel i covers u × width from i up to i + 1, edges included on the left. The program printed it: u = 0.25 reads column 2. Computed coordinates near an edge can land on either side after rounding, which is why a lookup meant for one texel aims at its centre.

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:

Two UV conventions, two uploads JavaScript · orientation/orientation.mjs
// 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

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

  1. One question A glTF model's texture appears upside down in your renderer. The model's UVs follow glTF. What is the likely cause?

    Choose one answer.

    Show the answer: One question

    Answer: The image was flipped when it was uploaded, so its bottom row sits at v = 0

    An image is upright when the upload and the UVs agree. glTF and WebGPU both keep the image's top row at v = 0, so the flip came from the upload: three.js's flipY default, or flipY: true in copyExternalImageToTexture.

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:

Three address modes on a strip and on a wall JavaScript · address/address_modes.mjs
// 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

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:

Interactive · answer and compare Which address mode suits this texture? Answer three questions about a texture of yours. The table marks the modes that fit, the ones that fit with a caveat, and why.
Must the texture repeat across the surface, with coordinates beyond 0 to 1?
Do the texture's opposite edges match, so a copy can meet the next one without a line?
Does the image show something that must not appear mirrored, such as text, a logo or a face?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

The three address modes against what a texture needs at and beyond its edges
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

  1. Question 1 of 2 Under repeat, which coordinate between 0 and 1 does u = −0.25 read?

    Type a number.

    A hint

    Keep the fractional part, counted upwards from the integer below.

    Show the answer: Question 1 of 2

    Answer: 0.75 (anything from 0.749 to 0.751 counts)

    Repeat keeps u − floor(u). The integer below −0.25 is −1, so the remainder is −0.25 − (−1) = 0.75, just as glTF's own example reads −0.4 as 0.6.

  2. Question 2 of 2 A texture is 8 texels wide and its sampler uses mirror-repeat. Which texel does u = 1.8 read?

    Type a number.

    A hint

    Find the whole-number index first, then remember that the second copy reads backwards.

    Show the answer: Question 2 of 2

    Answer: 1 texel

    8 × 1.8 = 14.4, index 14. Indices 8 to 15 are the second copy, read backwards, so index 8 + k reads texel 7 − k: 14 is 8 + 6 and reads texel 1. Under repeat it would read texel 6, and under clamp-to-edge texel 7.

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:

Colour and data texels through rgba8unorm and rgba8unorm-srgb JavaScript · colour/colour_spaces.mjs
// 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

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

  1. Question 1 of 2 A roughness texture is uploaded as rgba8unorm-srgb. What does a texel of code 128 give the shader, and what does the surface look like?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0.2159, so the surface looks glossier than it was made

    Roughness is data, stored with the linear transfer function, so code 128 means 128 ÷ 255 = 0.5020. Read through an -srgb format it is decoded as if it were a colour and arrives as 0.2159, less than half the roughness.

  2. Question 2 of 2 Which WebGPU format suits an 8-bit tangent-space normal map?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: rgba8unorm

    glTF stores normal, metallic-roughness and occlusion textures as linear values, so they need a plain unorm format. Only colour textures, base colour and emissive, use -srgb formats.

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.:

A cube unwrapped as a cross of six squares: its outline is dashed seams, inner lines are folds, and a padding band runs around it.A cube unwrapped into one UV island8 corners in space become 14 in the texturetopleftfrontrightbackbottomseam: one of the 7 cube edges the unwrap cutsfold: one of the 5 edges the island keepspadding: edge colours copied outwardsone corner of the cube, in three places

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:

Red bleeding into an atlas island, by mip level and padding JavaScript · bleed/atlas_bleed.mjs
// 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

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

  1. Question 1 of 2 In the lesson's atlas test, how many texels of padding kept the skin island clean through mip level 3?

    Type a number.

    A hint

    Read the row of the table whose last column says level 3.

    Show the answer: Question 1 of 2

    Answer: 8 texels

    With 8 texels of padding no red reached the island at levels 0 to 3, and 0.234 of red reached it at level 4. Each level averages twice as many texels as the one above it, so the padding doubles per level: 2, 4, 8, 16.

  2. Question 2 of 2 A cube is unwrapped into six separate square islands, one per face, instead of a cross. How many corners do its islands have in the texture?

    Type a number.

    A hint

    Count the corners of each island, then add them up; separate islands share no corners.

    Show the answer: Question 2 of 2

    Answer: 24 corners

    Six separate squares have 4 corners each, 24 in all: every one of the 12 edges is now a seam, and each of the 8 cube corners appears in three islands. The cross keeps 5 edges as folds, which is why it needs only 14.

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 index i becomes under the address mode mode, one of 'clamp-to-edge', 'repeat' and 'mirror-repeat', for a texture size texels across. i may be negative or size or 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 with Math.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.

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.

  1. Question 1 of 14 A texture is 32 × 32 texels. With nearest sampling, which texel row does v = 0.70 read?

    Type a number.

    Show the answer: Question 1 of 14

    Answer: 22 row

    32 × 0.70 = 22.4, inside row 22, which covers 22 up to 23. Rows count from 0 at the top in WebGPU and glTF.

  2. Question 2 of 14 Why does nearest sampling take the whole part of u × width instead of rounding it to the nearest integer?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: Texel i covers u × width from i up to i + 1, so the whole part names the texel the point lies in

    Measured in texels, texel i spans i to i + 1 and its centre is at i + 0.5. The whole part of the scaled coordinate is the texel that contains the point, which is the nearest texel centre.

  3. Question 3 of 14 What coordinate u is the centre of texel 5 of a texture 8 texels wide?

    Type a number.

    Show the answer: Question 3 of 14

    Answer: 0.6875 (anything from 0.6874 to 0.6876 counts)

    The centre of texel i lies at (i + 0.5) ÷ width = 5.5 ÷ 8 = 0.6875. Aiming at the centre keeps rounding from moving the lookup into a neighbour.

  4. Question 4 of 14 In WebGPU, which row of the image does v = 0 read after copyExternalImageToTexture with its default options?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: The image's top row

    WebGPU's texture coordinate (0, 0) is in the first texel in memory order, and the copy puts the image's top row there. glTF's convention matches: its (0, 0) is the image's upper left corner.

  5. Question 5 of 14 A WebGL program set UNPACK_FLIP_Y_WEBGL and gave its quads bottom-up UVs. You port it to WebGPU, copy images with copyExternalImageToTexture and keep the UVs. What do you change?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: Pass flipY: true to the copy, so the upload agrees with the bottom-up UVs as before

    The image is upright when the upload and the UVs agree. The WebGL program agreed by flipping on upload, so the port keeps the bottom-up UVs and flips the copy, or flips v once in the mesh data and copies without a flip.

  6. Question 6 of 14 A glTF sampler gives no wrapS. Which address mode must a renderer use for u?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: Repeat

    glTF's specification gives wrapS and wrapT a default of 10497, repeat, and says client implementations must follow the wrapping modes, so a WebGPU sampler for it is made with addressModeU and addressModeV set to repeat.

  7. Question 7 of 14 A floor's UVs run from 0 to 8 so that a tile texture repeats 8 times, but the floor shows one tile and then a smear. What is wrong?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: The sampler kept the default clamp-to-edge, so every coordinate past 1 reads the edge texels

    Coordinates past 1 are what make tiling work, and only repeat or mirror-repeat turn them back into the texture. Set addressModeU and addressModeV to repeat for a tile that was made to match at its edges.

  8. Question 8 of 14 The strip ABCD is sampled at eight points between u = 1 and u = 2 under mirror-repeat. What does it read?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: DDCCBBAA

    Mirror-repeat reads copies forwards and backwards in turn, so from u = 1 to 2 it reads D, C, B, A, each twice at eight samples. Every copy meets its neighbour at the same texel, so the edges always match.

  9. Question 9 of 14 Which glTF textures are stored with the sRGB transfer function?

    Choose every answer that is right.

    Show the answer: Question 9 of 14

    Answer:

    • Base colour
    • Emissive

    Colour that people look at, base colour and emissive, is stored as sRGB codes and decoded before computing. Everything the shader computes with, normals, roughness, metalness and occlusion, is stored as linear values.

  10. Question 10 of 14 A base colour texture is uploaded as rgba8unorm. What does a texel of code 128 give the shader?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: 0.5020 instead of 0.2159, so the texture looks washed out

    Base colour is stored as sRGB codes, and code 128 stands for 0.2159 of full light. Read as a plain unorm value it arrives as 0.5020, more than twice as bright, which flattens and lightens the whole texture.

  11. Question 11 of 14 A normal map is wrongly read through an -srgb format. By how many degrees does its flat texel (128, 128, 255) tilt away from (0, 0, 1)? Use the lesson's program.

    Type a number.

    Show the answer: Question 11 of 14

    Answer: 38.79 degrees (anything from 38.69 to 38.89 counts)

    Decoded, 128 becomes 0.2159, and 2 × 0.2159 − 1 = −0.5683 for both x and y while z stays 1. Normalised, the vector is (−0.4430, −0.4430, 0.7795), and acos(0.7795) = 38.79°. Read as linear, the tilt is only 0.32°.

  12. Question 12 of 14 Why does a mesh need several vertices at one point that lies on a UV seam?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: A vertex carries one texture coordinate, and the point lies at two or more places in the texture

    The cube's 8 corners became 14 in the cross unwrap because a corner on a seam lies at several points of the texture, and each needs its own vertex.

  13. Question 13 of 14 From the lesson's measurement, about how much padding does an atlas island need to stay clean through mip level k?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: About 2ᵏ texels

    Each mip level averages pairs of the texels above it, so a texel at level k stands for 2ᵏ texels of level 0, and the gap must be that wide before the averages stop reaching the next island.

  14. Question 14 of 14 Following the measured pattern, how many texels of padding would keep an island clean through mip level 5?

    Type a number.

    Show the answer: Question 14 of 14

    Answer: 32 texels

    The padding doubled per level, 2, 4, 8 and 16 for levels 1 to 4, so level 5 needs 32. In the program even 16 texels let 0.242 of red in at level 5, where one texel stands for 32 texels of the full-size image.

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.

  1. 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).

  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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

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 flipY is 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 -srgb format; 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.