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:
- Mapping: How do you know which part of the texture goes where on the 3D object?
- Sampling: What happens when a texture pixel doesn't align perfectly with a screen pixel?
- Perspective: How do you make textures look right as they recede into the distance?
- 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:
- GPU calculates how big one texel is on screen (texel-to-pixel ratio)
- Picks appropriate MIP level (or two levels for blending)
- 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:
- Performance: Reading from smaller textures is faster (better cache locality)
- Quality: Averaging is already done; no aliasing/shimmering
- 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); // MirrorMistake #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:
- Texture compression: Use BC1/BC3/BC7 (VRAM savings + faster reads)
- Smaller textures: 1024×1024 instead of 4096×4096 where possible
- LOD bias: Force lower MIP levels at distance
- Streaming: Load high-res textures only when close
- 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:
- Load a brick texture
- Draw a textured cube
- Toggle between nearest/bilinear/trilinear filtering (keyboard keys)
- Watch the visual quality differences
- Add rotation to see MIP level transitions
- 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:
- UV coordinates: How to map 2D texture space to 3D geometry
- Texture filtering: Nearest, bilinear, trilinear - each with trade-offs
- MIP-mapping: Pre-computed resolution levels for quality and performance
- Perspective correction: Why textures look right despite projection
- 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.