Introduction

Every video game car, movie creature, and virtual building is covered in textures — flat images wrapped around 3D geometry so convincingly that the seams disappear. The trick that makes it work is deceptively simple: texture mapping.

The core idea is to give every vertex of a 3D mesh a pair of coordinates (u,v)(u, v) in the range [0,1]2[0, 1]^{2} that point into a flat image called the texture. When the GPU rasterizes a triangle — converts it to screen pixels — it smoothly interpolates the (u,v)(u, v) values across the triangle's interior, then looks up the color in the texture at each interpolated coordinate.

But there is a catch. Naive linear interpolation of UV coordinates in screen space produces visible distortion: grid lines bow, text warps, and checkerboard patterns pinch toward the horizon. The fix is perspective-correct interpolation, which divides by the depth ww before interpolating and multiplies back after. This one adjustment is what separates the blocky software renderers of the early 1990s from the crisp texturing of every GPU since.

Texture mapping was formalized by Ed Catmull in 1974 in his PhD thesis at the University of Utah — the same thesis that introduced the z-buffer. Both ideas became so foundational that today's rasterization pipelines are built on them.

Try It: Unfold the Cube

A cube has six square faces. In a texture atlas they are laid out as flat patches, each occupying a region of the [0,1]2[0,1]^{2} UV square. Click any face of the cube below to highlight it and see which UV island it corresponds to on the right.

<!-- Main layout — 3D cube on the left, UV atlas on the right -->
<div class="container">
  <div class="panel">
    <p class="label">3D Cube</p>
    <canvas id="cube3d" width="200" height="200" title="Click a face to highlight it"></canvas>
    <p class="hint">Click a face to see its UV island</p>
  </div>
  <div class="panel">
    <p class="label">UV Atlas</p>
    <canvas id="uvmap" width="200" height="200" title="UV layout of the cube faces"></canvas>
    <p class="hint">Each colored square is one face of the cube</p>
  </div>
</div>
<div class="info" id="info">Click a cube face to see its UV coordinates.</div>
<button id="resetBtn" type="button">Reset</button>
/* Global styles for the two-panel layout */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fff; }
.container { display: flex; gap: 1rem; flex-wrap: wrap; justify-content: center; padding: .5rem; }
.panel { display: flex; flex-direction: column; align-items: center; }
.label { font-weight: 700; font-size: .85rem; margin: 0 0 .3rem; color: #1d3557; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; cursor: pointer; background: #f4f7fa; }
.hint { font-size: .78rem; color: #666; margin: .3rem 0 0; max-width: 200px; text-align: center; }
.info { text-align: center; font-size: .9rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
button { display: block; margin: 0 auto; font: 600 14px system-ui; padding: .4rem .9rem;
         border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
// Human-readable face names (one per face, same order as FACES array)
const FACE_NAMES = ['Top','Bottom','Front','Back','Right','Left'];

// Fill colors — one per face index 0..5 — one per face
const FACE_COLORS = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#8ecae6'];

// Cross-unfold UV layout: pixel positions [left, top] of each 50x50 UV island inside the 200x200 atlas
// Cross unfold layout in UV space (200x200 canvas, each face = 50x50 px).
// Face indices: 0=top, 1=bottom, 2=front, 3=back, 4=right, 5=left
// UV pixel positions [col*50, row*50] within the 200x200 atlas:
const UV_RECTS = [
  [50, 0],   // Top face — center column, top row
  [50, 150], // Bottom face — center column, bottom row
  [50, 50],  // Front face — center column, middle row
  [50, 100], // Back face — center column, second row from bottom — placed below front in cross unfold for clarity
  [100, 50], // Right face — right column, middle row
  [0, 50],   // Left face — left column, middle row
];

// 8 corners of a unit cube [-1,+1]^3, projected to 2D with an isometric-like view
// 8 corners of a unit cube centered at origin, projected to 2D with a simple isometric-like view
const W = 200, H = 200;
function project([x, y, z]) {
  // Simplified oblique projection: x right, y up, z depth
  const angle = Math.PI / 6;
  const sx = Math.cos(angle), sy = Math.sin(angle);
  return [
    W/2 + (x - z) * sx * 60,
    H/2 + (x + z) * sy * 30 - y * 60
  ];
}

// Vertex list: indices 0-3 = back face, 4-7 = front face
const V = [
  [-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1], // back face vertices (z=-1)
  [-1,-1, 1],[1,-1, 1],[1,1, 1],[-1,1, 1]  // front face vertices (z=+1)
];

// Face definitions: 4 vertex indices per face (CCW winding from outside)
// Each face: 4 vertex indices (CCW winding when viewed from outside), face index 0..5
const FACES = [
  [3,2,6,7], // top face vertices
  [0,1,5,4], // bottom face vertices
  [4,5,6,7], // front face vertices
  [1,0,3,2], // back face vertices
  [5,1,2,6], // right face vertices
  [0,4,7,3], // left face vertices
];

// Faces visible in this isometric-ish projection — only 3 faces visible in this isometric-ish view
const VISIBLE = [0, 2, 4]; // top, front, right — the three visible faces in this view

let selected = -1;

const canvas3d = document.getElementById('cube3d');
const ctxC = canvas3d.getContext('2d');
const canvasUV = document.getElementById('uvmap');
const ctxU = canvasUV.getContext('2d');

function drawCube() {
  ctxC.clearRect(0, 0, W, H);
  // Draw each visible cube face as a filled polygon
  for (const fi of VISIBLE) {
    const verts = FACES[fi].map(i => project(V[i]));
    ctxC.beginPath();
    ctxC.moveTo(...verts[0]);
    for (let i = 1; i < verts.length; i++) ctxC.lineTo(...verts[i]);
    ctxC.closePath();
    const baseColor = FACE_COLORS[fi];
    if (fi === selected) {
      ctxC.fillStyle = baseColor;
      ctxC.strokeStyle = '#000';
      ctxC.lineWidth = 2.5;
    } else {
      // Unselected faces are dimmed when one is selected
      ctxC.fillStyle = selected === -1 ? baseColor + 'cc' : baseColor + '44';
      ctxC.strokeStyle = '#888';
      ctxC.lineWidth = 1;
    }
    ctxC.fill();
    ctxC.stroke();
    // Draw face name label in the center of the face
    const cx = verts.reduce((s,p)=>s+p[0],0)/4;
    const cy = verts.reduce((s,p)=>s+p[1],0)/4;
    ctxC.fillStyle = fi === selected ? '#fff' : (selected === -1 ? '#fff' : '#aaa');
    ctxC.font = `${fi===selected?'700 12':'600 10'}px system-ui`;
    ctxC.textAlign = 'center';
    ctxC.textBaseline = 'middle';
    ctxC.fillText(FACE_NAMES[fi], cx, cy);
  }
}

function drawUV() {
  ctxU.clearRect(0, 0, W, H);
  // Draw faint grid lines every 50 px (= 0.25 UV units)
  ctxU.strokeStyle = '#cdd9e3';
  ctxU.lineWidth = 0.5;
  for (let i = 0; i <= 4; i++) {
    ctxU.beginPath(); ctxU.moveTo(i*50, 0); ctxU.lineTo(i*50, H); ctxU.stroke();
    ctxU.beginPath(); ctxU.moveTo(0, i*50); ctxU.lineTo(W, i*50); ctxU.stroke();
  }
  // Draw each UV island as a colored rectangle
  for (let fi = 0; fi < 6; fi++) {
    const [px, py] = UV_RECTS[fi];
    const alpha = (selected === -1) ? 'cc' : (fi === selected ? 'ff' : '33');
    ctxU.fillStyle = FACE_COLORS[fi] + alpha;
    ctxU.strokeStyle = fi === selected ? '#000' : '#999';
    ctxU.lineWidth = fi === selected ? 2 : 1;
    ctxU.fillRect(px, py, 50, 50);
    ctxU.strokeRect(px, py, 50, 50);
    // Face name label in the center of each UV island
    ctxU.fillStyle = fi === selected ? '#fff' : (selected === -1 ? '#fff' : '#aaa');
    ctxU.font = `${fi===selected?'700 11':'600 9'}px system-ui`;
    ctxU.textAlign = 'center';
    ctxU.textBaseline = 'middle';
    ctxU.fillText(FACE_NAMES[fi], px+25, py+25);
  }
}

function getClickedFace(e) {
  // Convert mouse position to canvas coordinates and test each visible face
  const rect = canvas3d.getBoundingClientRect();
  const mx = (e.clientX - rect.left) * (W / rect.width);
  const my = (e.clientY - rect.top) * (H / rect.height);
  // Check faces back-to-front so the topmost visible face wins
  for (const fi of [...VISIBLE].reverse()) {
    const verts = FACES[fi].map(i => project(V[i]));
    if (pointInPolygon(mx, my, verts)) return fi;
  }
  return -1;
}

function pointInPolygon(px, py, poly) {
  // Ray-casting point-in-polygon test (even-odd rule)
  let inside = false;
  for (let i = 0, j = poly.length-1; i < poly.length; j=i++) {
    const [xi,yi] = poly[i], [xj,yj] = poly[j];
    if (((yi>py)!==(yj>py)) && px < (xj-xi)*(py-yi)/(yj-yi)+xi) inside = !inside;
  }
  return inside;
}

function setInfo(fi) {
  const el = document.getElementById('info');
  if (fi === -1) { el.textContent = 'Click a cube face to see its UV coordinates.'; return; }
  // Show face name and its UV bounding box coordinates
  el.textContent = FACE_NAMES[fi] + ' → UV ' + formatUV(fi);
}

function formatUV(fi) {
  const [px, py] = UV_RECTS[fi];
  const u0 = (px/200).toFixed(2), v0 = (py/200).toFixed(2);
  const u1 = ((px+50)/200).toFixed(2), v1 = ((py+50)/200).toFixed(2);
  return `(${u0}, ${v0}) – (${u1}, ${v1})`;
}

canvas3d.addEventListener('click', e => {
  const fi = getClickedFace(e);
  selected = (fi === selected) ? -1 : fi;
  drawCube(); drawUV(); setInfo(selected);
});

document.getElementById('resetBtn').addEventListener('click', () => {
  selected = -1;
  drawCube(); drawUV(); setInfo(-1);
});

drawCube(); drawUV(); setInfo(-1);
Result
Console

Notice how each face is a simple square in UV space. The UV layout shown here is the classic cross unfold — one of many valid unfoldings. Any point on the 3D cube maps to exactly one pixel of the texture via its (u,v)(u, v) coordinates, and when the GPU rasterizes a triangle it interpolates those coordinates across every screen pixel inside the triangle.

The Real Complexity

The straightforward version of texture mapping is two steps: (1) assign (u,v)(u, v) to vertices, (2) interpolate. But several layers of engineering sit underneath:

Perspective-correct interpolation. In screen space, a distant point on a triangle is compressed relative to a nearby one, so linear interpolation of (u,v)(u, v) is wrong. The correct formula interpolates u/wu/w and v/wv/w (where ww is the homogeneous depth) and then divides by 1/w1/w recovered at each pixel:

upixel= (u0/w0)λ0+(u1/w1)λ1+(u2/w2)λ2 (1/w0)λ0+(1/w1)λ1+(1/w2)λ2u_{\text{pixel}} = \frac{\,(u_0/w_0)\lambda_0 + (u_1/w_1)\lambda_1 + (u_2/w_2)\lambda_2\,}{(1/w_0)\lambda_0 + (1/w_1)\lambda_1 + (1/w_2)\lambda_2}

where λ0,λ1,λ2\lambda_0, \lambda_1, \lambda_2 are the barycentric coordinates of the pixel and w0,w1,w2w_0, w_1, w_2 are the vertex depths. Without this fix, textures shear toward the vanishing point.

Mipmaps. When a textured surface is far away, one screen pixel covers many texels. Sampling just the nearest texel produces aliasing — flickering, moiré patterns. A mipmap is a precomputed pyramid of down-scaled copies of the texture (each level half the size of the previous). The GPU selects the mipmap level d=log⁡2(max⁡(∥∂(u,v)/∂x∥,∥∂(u,v)/∂y∥)⋅T)d = \log_2(\max(\|\partial(u,v)/\partial x\|, \|\partial(u,v)/\partial y\|) \cdot T) that best matches the pixel footprint, then blends between adjacent levels (trilinear filtering).

UV seams and atlasing. A single mesh can have hundreds of UV islands — disconnected patches that pack into one texture atlas. Generating a good atlas (minimizing wasted space, avoiding seam stretching) is a packing problem related to bin packing. Modern tools like xAtlas and Blender's smart UV project solve it automatically.

Normal and displacement maps. A flat polygon can look bumpy if you texture-map a normal map — an image encoding surface normals per texel — and use those normals in the lighting equation instead of the true polygon normal. Displacement maps go further and actually move vertices, adding real geometric detail.

Where It Matters

UV texture mapping is the engine behind visual richness in virtually every domain that renders 3D content:

  • Video games: a single low-polygon mesh can look highly detailed when wrapped in a high-resolution diffuse, specular, normal, and emissive texture set. Modern PBR (Physically Based Rendering) workflows layer six or more texture maps per material.
  • Film VFX: photorealistic creatures and environments are built from millions of polygons, each UV-mapped to multi-gigabyte texture atlases painted by hand or generated by photogrammetry.
  • Augmented and virtual reality: real-world photographs are projected onto 3D geometry reconstructed from depth sensors. The UV parameterization determines how cleanly the photo aligns to the mesh.
  • Medical imaging: CT or MRI volume data is mapped onto patient-specific 3D organ meshes for surgical planning and simulation, using the same barycentric interpolation.
  • Digital fabrication: UV unfolding is used to flatten 3D surfaces before cutting patterns from flat sheet material — the seam placement problem is essentially the same as in game asset production.

The algorithm itself is the same across all these domains: find a (u,v)(u, v) parameterization of the surface that minimizes distortion, then sample a texture image at those coordinates using perspective-correct interpolation.

Conclusion

Texture mapping is one of those rare ideas that solved a problem so cleanly it has never needed replacing. A flat image, a pair of coordinates per vertex, and one division by depth — that is essentially all it takes to clothe an entire virtual world.

Ed Catmull's insight in 1974 was that the 3D and 2D problems could be separated: design the geometry freely, paint the surface freely, and connect them through UV coordinates at render time. Fifty years later, every GPU on the planet still works this way.

The next time you look at a video game character's jacket, a movie dragon's scales, or a virtual building's brick wall, you are seeing the same barycentric lookup that runs inside millions of triangles per second — perspective-correct, mipmap-filtered, and still fundamentally the same idea Catmull wrote down half a century ago.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/texture-mapping/Content licensed under CC BY-NC 4.0.