Graphics · 05.04.2025

Texture Mapping - How 2D Images Wrap Around 3D Objects

The Problem With Colored Triangles

After writing my article on how 3D graphics work, I could draw triangles. Millions of them. In any color I wanted. I felt pretty good about that.

Then I tried to make something that looked real. A brick wall. A wooden table. A character's face. And I realized: colored triangles look like... colored triangles. Everything was flat-shaded, cartoony, like a PS1 game (which is cool, but not what I was going for).

To make 3D objects look real, you need texture mapping - the technique of wrapping 2D images around 3D geometry. It's how games make a few triangles look like a detailed brick wall, how a simple sphere becomes Earth with continents and oceans, how a flat polygon becomes a character's face.

I spent a week implementing this. My first textured cube looked like a Picasso painting - all the textures were warped and distorted. Then I learned about UV coordinates. Then perspective-correct interpolation. Then MIP-mapping because my textures looked terrible at different distances. Then anisotropic filtering because they still looked terrible at angles.

This article is everything I learned about taking a flat image and making it look right on 3D geometry. We'll implement basic texture mapping in OpenGL, understand why textures look wrong, and fix them step by step. By the end, you'll understand why your game drops from 60 FPS to 30 when you crank up texture quality.

Let's wrap some images around triangles.

What Is Texture Mapping?

At its core, texture mapping is deceptively simple: take a 2D image (the texture) and paste it onto a 3D surface (the geometry).

The Concept:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2D Texture Image: 3D Geometry:
┌─────────────┐
│ │ ┌─────┐
│ │ ───► │ │ ← Textured cube
│ │ ││ looks like bricks
│ │ └─────┘
└─────────────┘
(Brick texture)

For each pixel on the 3D surface:
1. Figure out which texture pixel it corresponds to
2. Sample that texture pixel's color
3. Use that color for the 3D pixel

Simple, right? (Narrator: It was not simple.)

The hard parts:

  1. Mapping: How do you know which part of the texture goes where on the 3D object?
  2. Sampling: What happens when a texture pixel doesn't align perfectly with a screen pixel?
  3. Perspective: How do you make textures look right as they recede into the distance?
  4. Performance: How do you do this for millions of pixels per frame without destroying FPS?

Let's tackle each one.

UV Coordinates: The Map

When you define 3D geometry, you give each vertex a position (X, Y, Z). For texture mapping, you also give each vertex UV coordinates - a position in texture space.

UV coordinates are typically normalized from 0.0 to 1.0:

  • U = horizontal position (like X, but for textures)
  • V = vertical position (like Y, but for textures)
Texture Space (UV):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

V
↑ (0,1) ┌─────────────┐ (1,1)
│ │ │
│ │ │
│ │ │
│ │ │
│ (0,0) └─────────────┘ (1,0)
└──────────────────────────────► U

Normalized coordinates:
- (0, 0) = bottom-left of texture
- (1, 1) = top-right of texture
- (0.5, 0.5) = center of texture

Mapping a Triangle

Here's a triangle in 3D space with UV coordinates:

3D Triangle: Texture:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

 v0 (0,1) ┌─────────┐ (1,1)
 ◆ (X,Y,Z) ││
 / \ ││
 / \ ││
 / \ ││
v1◆─────◆v2 (0,0)└─────────┘ (1,0)

Vertex data:
v0: position (0, 1, 0), UV (0.5, 1.0) ← top of triangle = top-center of texture
v1: position (-1, 0, 0), UV (0.0, 0.0) ← left = bottom-left of texture 
v2: position (1, 0, 0), UV (1.0, 0.0) ← right = bottom-right of texture

When rasterizing:
For each pixel inside the triangle:
 1. Calculate barycentric coordinates (how close to each vertex)
 2. Interpolate UV using those coordinates
 3. Look up that UV in the texture
 4. Use that color for the pixel

OpenGL Texture Coordinates

In OpenGL, you add UV coordinates to your vertex data:

// Before (colored triangles):
float vertices[] = {
 // positions // colors
 -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // vertex 0: red
 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // vertex 1: green
 0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // vertex 2: blue
};
 
// After (textured triangles):
float vertices[] = {
 // positions // colors // texture coords
 -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, // bottom-left
 0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // bottom-right
 0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.5f, 1.0f // top-center
};
 
// Now OpenGL knows:
// - Where to draw (positions)
// - What color to blend (colors - optional with textures)
// - Which part of texture to use (UV coords)

The vertex shader passes UVs to the fragment shader, which samples the texture:

// Vertex Shader
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
 
out vec2 TexCoord;
 
void main() {
 gl_Position = vec4(aPos, 1.0);
 TexCoord = aTexCoord; // Pass to fragment shader
}
 
// Fragment Shader
#version 330 core
in vec2 TexCoord;
out vec4 FragColor;
 
uniform sampler2D ourTexture; // The texture
 
void main() {
 FragColor = texture(ourTexture, TexCoord); // Sample at TexCoord
}

That texture() function is where the magic (and problems) happen.

Texture Filtering: Making Pixels Look Right

Here's the first problem: your texture is, say, 512×512 pixels. But when rendered on screen, that triangle might be:

  • Magnified (texture covers 2000×2000 screen pixels) - texture is too small
  • Minified (texture covers 100×100 screen pixels) - texture is too large

What do you do when there's not a 1:1 mapping between texture pixels (texels) and screen pixels?

Nearest Neighbor (Point Sampling)

The simplest approach: for each screen pixel, find the closest texel and use its color.

Nearest Neighbor Filtering:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Texture (zoomed): Screen (magnified):
┌─┬─┬─┬─┐ 
│R│R│B│B│ ┌──┬──┬──┬──┬──┬──┐
├─┼─┼─┼─┤ ───► │R │R │R │B │B │B │
│R│R│B│B│ ├──┼──┼──┼──┼──┼──┤
├─┼─┼─┼─┤ │R │R │R │B │B │B │
│G│G│Y│Y│ ├──┼──┼──┼──┼──┼──┤
├─┼─┼─┼─┤ │R │R │R │B │B │B │
│G│G│Y│Y│ ├──┼──┼──┼──┼──┼──┤
└─┴─┴─┴─┘ │G │G │G │Y │Y │Y │
 ├──┼──┼──┼──┼──┼──┤
Each screen pixel │G │G │G │Y │Y │Y │
picks nearest texel ├──┼──┼──┼──┼──┼──┤
 │G │G │G │Y │Y │Y │
Result: Blocky, pixelated └──┴──┴──┴──┴──┴──┘

Pros: Fast, simple Cons: Looks terrible when magnified (blocky), causes aliasing when minified

This is the "Minecraft aesthetic" - intentionally pixelated. For most games, you want something smoother.

Bilinear Filtering

Instead of picking the nearest texel, sample the 4 closest texels and blend them:

Bilinear Filtering:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Texture (zoomed): Sample point (screen pixel):
┌─────┬─────┬─────┐ 
│ R │ R │ B │ Sample point at (1.5, 1.5)
│ │ │ │ ↓
├─────┼─────┼─────┤ ┌──┴──┐
│ R │ R │ B │ │ │
│ │ ╲ │ ╱ │ └─────┘
├─────┼────┼╳────┤
│ G │ G │ Y │ Sample 4 neighbors:
│ │ │ │ - Top-left: R
└─────┴─────┴─────┘ - Top-right: B
 - Bottom-left: G
 - Bottom-right: Y

Blend based on distance:
 weight_tl = (1 - dx) * (1 - dy) ← close to top-left
 weight_tr = dx * (1 - dy) ← close to top-right
 weight_bl = (1 - dx) * dy ← close to bottom-left
 weight_br = dx * dy ← close to bottom-right
 
 final_color = R*weight_tl + B*weight_tr + 
 G*weight_bl + Y*weight_br

Result: Smooth gradients, no blocky edges

Pros: Smooth, much better visual quality Cons: 4× more texture reads (slower), can still look blurry when minified

In OpenGL:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

The Minification Problem

Bilinear filtering helps when magnifying, but what about minifying? Imagine a 1024×1024 texture displayed on a 2×2 pixel area. You're trying to represent 1 million texels with 4 pixels.

Minification Problem:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

High-res texture: Tiny on screen:
┌─────────────────┐ 
│ Detailed brick │ ┌─┐ ← Only 4 pixels!
│ with mortar │ ───► │░│ How to represent
│ cracks, stains │ └─┘ all that detail?
│ lighting, etc │ 
└─────────────────┘ 

Bilinear only samples 4 texels
But we need to average over thousands!

Result: Aliasing, shimmering, moiré patterns
Especially bad when moving/rotating

This is where MIP-mapping saves the day.

MIP-Mapping: Pre-Computed LoDs

MIP stands for "multum in parvo" (Latin: "much in little"). MIP-mapping is a technique where you pre-generate smaller versions of your texture at multiple resolutions.

MIP Chain:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Level 0 (original): 512×512 8192 KB
┌────────────────┐
│ Full detail │
│ brick texture │
└────────────────┘

Level 1: 256×256 (1/4 size) 2048 KB
┌────────┐
│ Less │
│ detail │
└────────┘

Level 2: 128×128 512 KB
┌────┐
│Mid │
└────┘

Level 3: 64×64 128 KB
┌──┐
│Lo│
└──┘

...continues until 1×1 1 byte

Total MIP chain size = ~1.33× original
(Geometric series: 1 + 1/4 + 1/16 + 1/64 + ...)

But gives MASSIVE performance and quality improvements!

How MIP-Mapping Works

When rendering:

  1. GPU calculates how big one texel is on screen (texel-to-pixel ratio)
  2. Picks appropriate MIP level (or two levels for blending)
  3. Samples from that level
MIP Level Selection:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Close to camera: Far from camera:
Large on screen Small on screen
┌──────────┐ ┌┐
│ │ ││ ← tiny
│ Texture │ └┘
│ │ 
└──────────┘ 
Use MIP level 0 Use MIP level 5
(full resolution) (very low resolution)

Texel-to-pixel ratio:
 < 1.0 = magnified → use level 0
 = 1.0 = perfect → use level 0
 = 2.0 = minified → use level 1
 = 4.0 = minified → use level 2
 = 8.0 = minified → use level 3
 etc.

Why this helps:

  1. Performance: Reading from smaller textures is faster (better cache locality)
  2. Quality: Averaging is already done; no aliasing/shimmering
  3. Memory bandwidth: Fewer bytes read from memory

In OpenGL:

// Generate MIP chain automatically
glGenerateMipmap(GL_TEXTURE_2D);
 
// Use trilinear filtering (bilinear + MIP interpolation)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

Trilinear Filtering

Trilinear filtering takes MIP-mapping one step further: instead of picking one MIP level, it blends between two adjacent levels.

Trilinear Filtering:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Say texel-to-pixel ratio = 3.5
→ Between MIP level 1 (2×) and level 2 (4×)

Step 1: Bilinear sample from level 1
┌────────┐
│ ■ │ ← Sample 4 texels, blend
└────────┘
Result: color1

Step 2: Bilinear sample from level 2
┌────┐
│ ■ │ ← Sample 4 texels, blend
└────┘
Result: color2

Step 3: Blend between levels
fraction = 0.5 (halfway between 2× and 4×)
final_color = mix(color1, color2, 0.5)

Total: 8 texture reads (4 from each level)
But much smoother transitions between MIP levels!

This prevents the "MIP banding" artifact where you see harsh transitions between MIP levels.

Perspective-Correct Interpolation

Here's a subtle but critical problem: when you interpolate UVs across a triangle, you can't just do linear interpolation in screen space. You need to account for perspective.

The Perspective Problem:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

3D Space (what it should look like):
 Far
 •───────────•
 / \
 / \
 / \
 / \
 / \
 •─────────────────────•
 Near

UV should be evenly spaced in 3D
But when projected to screen...

Screen Space (2D projection):
 Far (small)
 •────•
 / \
 / \
 / \
 / \
 •──────────────•
 Near (large)

Linear interpolation in screen space = wrong!
UVs bunch up near the far edge
Result: Textures look warped, especially on floors/roads

The Fix: Perspective Division

The GPU handles this automatically, but here's what it does:

Perspective-Correct Interpolation:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

For each vertex:
1. Start with: (U, V, W) where W = 1/Z (depth)

2. Interpolate: (U/W, V/W, 1/W) linearly in screen space

3. At each pixel:
 U_correct = (U/W) / (1/W)
 V_correct = (V/W) / (1/W)

This accounts for perspective distortion!

Example:
Near vertex: UV = (0, 0), Z = 1 → (0/1, 0/1, 1/1) = (0, 0, 1)
Far vertex: UV = (1, 1), Z = 10 → (1/10, 1/10, 1/10) = (0.1, 0.1, 0.1)

Midpoint in screen:
 Interpolated: (0.05, 0.05, 0.55)
 Corrected: (0.05/0.55, 0.05/0.55) = (0.09, 0.09)
 
Without correction it would be (0.5, 0.5) - wrong!

Modern GPUs do this automatically. On PS1 they didn't, which is why PS1 textures wobble and warp so much - it's iconic now, but it was a limitation.

In OpenGL, you don't need to do anything special - perspective correction is built-in. But understanding it helps explain why old games look the way they do.

Anisotropic Filtering: The Final Boss

Even with trilinear filtering and MIP-mapping, there's still one case where textures look terrible: surfaces viewed at steep angles.

The Anisotropic Problem:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Floor texture viewed at angle:
 Camera
 ↓
 
 \
 \
 \
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
█████████████████████████████████████ ← Floor
█████████████████████████████████████
█████████████████████████████████████

The pixel footprint is:
 Wide in X direction (many texels)
 Narrow in Y direction (few texels)

Bilinear/trilinear samples a square region
But we need a stretched, anisotropic region!

Result: Blurry in the distance
The texture "fades" instead of staying crisp

How Anisotropic Filtering Works

Anisotropic filtering samples multiple texels along the direction of anisotropy (the stretch direction):

Isotropic (trilinear): Anisotropic (16×):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Sample region: Sample region:
 ┌─┐ ┌──────────────┐
 │■│ │■■■■■■■■■■■■■■│
 └─┘ └──────────────┘
 
4-8 texture reads 16-64 texture reads
Square footprint Elongated footprint
Blurry at angles Crisp at angles

The "16×" means:
Up to 16:1 anisotropy ratio
Can sample a 16×1 region instead of 1×1

This is why cranking anisotropic filtering in game settings murders your FPS - you're doing 16× more texture reads in the worst case.

In OpenGL:

// Check max supported anisotropy
float maxAniso;
glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &maxAniso);
 
// Enable anisotropic filtering (up to 16×)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, 
 fmin(16.0f, maxAniso));

Performance impact:

  • Nearest: baseline (fastest)
  • Bilinear: ~1.2× slower
  • Trilinear: ~1.5× slower
  • Anisotropic 4×: ~2× slower
  • Anisotropic 16×: ~3-4× slower

This is why games offer it as a setting. On high-end GPUs, no problem. On integrated graphics, it can be the difference between 60 FPS and 30 FPS.

Putting It All Together: Textured Cube Demo

Let's implement a textured cube in OpenGL with all the techniques we've covered:

// Load texture
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
 
// Load image data (using stb_image or similar)
int width, height, nrChannels;
unsigned char *data = stbi_load("brick.jpg", &width, &height, &nrChannels, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, 
 GL_RGB, GL_UNSIGNED_BYTE, data);
 
// Generate MIP chain
glGenerateMipmap(GL_TEXTURE_2D);
 
// Set filtering modes
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 
// Enable anisotropic filtering
float maxAniso;
glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &maxAniso);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, maxAniso);
 
stbi_image_free(data);
 
// Cube vertices with UV coordinates
float vertices[] = {
 // positions // texture coords
 // Front face
 -0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
 0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
 0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
 -0.5f, 0.5f, 0.5f, 0.0f, 1.0f,
 // Back face
 -0.5f, -0.5f, -0.5f, 1.0f, 0.0f,
 0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
 0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
 -0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
 // ... (other faces)
};
 
// Render loop
while (!glfwWindowShouldClose(window)) {
 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
 // Bind texture
 glBindTexture(GL_TEXTURE_2D, texture);
 
 // Draw cube
 glBindVertexArray(VAO);
 glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0);
 
 glfwSwapBuffers(window);
}

Visual Quality Comparison

Here's what different filtering modes look like:

Texture Quality Comparison (Magnified Texture):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Nearest Neighbor:
 ████████████████
 ████████████████ ← Blocky, pixelated
 ████████████████ Sharp edges
 ████████████████ Retro aesthetic

Bilinear:
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ← Smooth gradients
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ No jaggies
 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ Can look blurry

Trilinear + MIP:
 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ← Smooth at all distances
 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ No shimmering
 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ Better performance

Anisotropic 16×:
 ░░░░░░░░░░░░░░░░
 ░░░░░░░░░░░░░░░░ ← Crisp at angles
 ░░░░░░░░░░░░░░░░ Best quality
 ░░░░░░░░░░░░░░░░ Highest cost

Common Texture Mapping Mistakes

Mistake #1: Forgetting to Enable Textures

// This doesn't work:
glBindTexture(GL_TEXTURE_2D, texture);
glDrawArrays(GL_TRIANGLES, 0, 36);
 
// Need to actually use it in shader:
// Fragment Shader:
uniform sampler2D ourTexture;
FragColor = texture(ourTexture, TexCoord); // ← Actually sample it!

Mistake #2: UV Coordinates Outside [0, 1]

By default, UVs outside 0-1 will repeat the texture. This might not be what you want:

// Control behavior:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); // Repeat (tile)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); // Clamp
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT); // Mirror

Mistake #3: Not Generating MIP Maps

// This looks terrible when minified:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
 
// Need to generate MIP chain:
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

Mistake #4: Wrong Image Format

// Image is RGBA but you specify RGB:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0,
 GL_RGB, GL_UNSIGNED_BYTE, data); // ← Wrong! Missing alpha
 
// Should be:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0,
 GL_RGBA, GL_UNSIGNED_BYTE, data);

Mistake #5: Texture Coordinates Flipped

Different image loaders have different conventions for Y-axis direction. You might need to flip:

// Option 1: Flip during load
stbi_set_flip_vertically_on_load(true);
 
// Option 2: Flip UVs in vertex data
// Instead of: V = 0.0 (bottom), V = 1.0 (top)
// Use: V = 1.0 (bottom), V = 0.0 (top)

Performance Considerations

Why does texture quality impact FPS so much?

Texture Memory Bandwidth Cost:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1920×1080 resolution @ 60 FPS
= 124,416,000 pixels per second

Worst case (anisotropic 16×):
- 64 texture reads per pixel (trilinear × 2 levels × 16 samples)
- 4 bytes per texel (RGBA)
= 31.8 GB/s bandwidth!

Modern GPU memory bandwidth:
- High-end: 400-900 GB/s
- Mid-range: 200-400 GB/s
- Low-end: 50-200 GB/s

Anisotropic filtering can eat 10-20% of bandwidth!

Optimization strategies:

  1. Texture compression: Use BC1/BC3/BC7 (VRAM savings + faster reads)
  2. Smaller textures: 1024×1024 instead of 4096×4096 where possible
  3. LOD bias: Force lower MIP levels at distance
  4. Streaming: Load high-res textures only when close
  5. Atlas packing: Multiple textures in one to reduce binds

Advanced Topics (Brief Mentions)

There's more to texture mapping than we've covered:

Normal Mapping: Store surface normals in texture for detailed lighting without geometry Displacement Mapping: Actually modify geometry based on texture Parallax Mapping: Fake depth by offsetting UVs based on view angle Cube Maps: 6-sided textures for skyboxes and reflections 3D Textures: Volumetric textures for effects like smoke/clouds Texture Arrays: Multiple textures in one object for efficiency

Each of these could be its own article, but they all build on the fundamentals we've covered.

Why This Matters

Texture mapping is the difference between "3D graphics" and "games that look real." It's how:

  • Minecraft looks blocky and pixelated (nearest neighbor)
  • Modern games have crisp textures at any angle (anisotropic)
  • Old games shimmer in the distance (no MIP-mapping)
  • PS1 games have that characteristic texture wobble (no perspective correction)

Understanding texture filtering helps you:

  • Debug visual quality issues in your own projects
  • Make informed trade-offs between quality and performance
  • Appreciate the engineering in games you play
  • Know which settings to tweak first when FPS drops

Try It Yourself

Want to experiment with texture mapping?

Simple project:

  1. Load a brick texture
  2. Draw a textured cube
  3. Toggle between nearest/bilinear/trilinear filtering (keyboard keys)
  4. Watch the visual quality differences
  5. Add rotation to see MIP level transitions
  6. Try anisotropic filtering and measure FPS impact

Resources:

  • LearnOpenGL: Excellent tutorial on texture mapping
  • stb_image.h: Easy image loading library
  • OpenGL wiki: Reference for all texture functions
  • RenderDoc: Capture frames and inspect texture sampling

Textures to try:

  • Checkerboard pattern (shows filtering clearly)
  • High-frequency detail (brick, grass) - shows MIP-mapping importance
  • Wood grain (shows anisotropic need when viewed at angles)

The best way to understand texture mapping is to implement it, break it, see the artifacts, and fix them. That's how I learned, and it's way more effective than reading theory.

The Big Picture

Here's what we've covered:

  1. UV coordinates: How to map 2D texture space to 3D geometry
  2. Texture filtering: Nearest, bilinear, trilinear - each with trade-offs
  3. MIP-mapping: Pre-computed resolution levels for quality and performance
  4. Perspective correction: Why textures look right despite projection
  5. Anisotropic filtering: The final quality improvement for angled surfaces

From colored triangles to detailed, textured objects - that's texture mapping. It's not just "paste an image on a model." It's careful interpolation, filtering, LOD selection, and perspective correction all happening millions of times per second.

Now when you see a game setting labeled "Texture Quality" or "Anisotropic Filtering," you know exactly what it's doing and why it costs FPS. And when you implement your own renderer, you'll know which shortcuts to take and which details matter.

Go texture some triangles. Make them look real. And remember: if it looks wrong, check your UVs first. It's always the UVs.


P.S.: The first time I got a textured cube rendering correctly, I spent 20 minutes just rotating it and zooming in and out, watching the MIP levels transition. It's weirdly mesmerizing. You'll probably do the same.