Texturing
Overview
Texturing maps image data or procedural functions onto surfaces to add visual detail without increasing geometric complexity. It is one of the most impactful techniques in real-time rendering, enabling photorealistic surfaces at minimal computational cost.
Texture Mapping Fundamentals
UV Coordinates
Every vertex is assigned a 2D coordinate (u, v) in [0,1]^2 that maps to a position in the texture image. The mapping from 3D surface to 2D texture space is called parameterization.
Common UV projections:
- Planar: Project along an axis (good for flat surfaces)
- Cylindrical/Spherical: Wrap around an axis (good for round objects)
- Box mapping: 6 planar projections selected by dominant normal
- Atlas unwrapping: Manual or automatic flattening of the mesh (minimizes distortion)
Texel Lookup
Given interpolated UV coordinates at a fragment, the texture coordinate maps to a texel (texture pixel):
texel_x = u * (width - 1)
texel_y = v * (height - 1)
The result is generally non-integer, requiring filtering.
Wrapping Modes
When UV coordinates fall outside [0,1]:
- Repeat: Tile the texture (fract(uv))
- Clamp: Clamp to edge texels
- Mirror: Reflect at boundaries
- Border: Return a constant border color
Texture Filtering
Nearest-Neighbor
Select the closest texel. Fast but produces blocky artifacts under magnification.
Bilinear Filtering
Interpolate between the four nearest texels:
c = lerp(lerp(c00, c10, frac_x), lerp(c01, c11, frac_x), frac_y)
Where frac_x, frac_y are the fractional parts of the texel coordinate. Smooth under magnification but blurry at oblique angles.
Mipmaps
A precomputed pyramid of progressively halved texture resolutions. Level 0 is the original; level k is (width/2^k) x (height/2^k).
The appropriate mip level is selected based on the screen-space footprint of the texel:
level = log2(max(|du/dx|, |dv/dy|) * texture_size)
Where du/dx and dv/dy are the texture coordinate derivatives (computed from adjacent fragments in a 2x2 quad).
Trilinear Filtering
Bilinear filtering on two adjacent mip levels, then linear interpolation between them:
c = lerp(bilinear(level_k), bilinear(level_k+1), frac(level))
Eliminates visible mip level transitions.
Anisotropic Filtering
Mipmaps assume isotropic (square) footprints, but surfaces viewed at oblique angles have elongated footprints. Anisotropic filtering takes multiple samples along the axis of elongation.
anisotropy_ratio = max_footprint / min_footprint // clamped to max (e.g., 16x)
num_samples = ceil(anisotropy_ratio)
Each sample is a trilinear lookup at the mip level corresponding to the minor axis. The results are averaged.
Normal Mapping
Bump Mapping (Blinn, 1978)
Perturbs the surface normal using a height map without modifying geometry:
N' = normalize(N - dh/du * T - dh/dv * B)
Where T and B are tangent and bitangent vectors, and dh/du, dh/dv are height derivatives.
Normal Mapping
Stores the perturbed normal directly in a texture (RGB = XYZ). Two conventions:
- Tangent space: Normals relative to the surface (blue-dominant textures). Requires a TBN matrix per vertex.
- Object space: Normals in object coordinates (colorful textures). Simpler but not reusable across different meshes.
Tangent-space to world-space transform:
N_world = normalize(TBN * (normal_map_sample * 2 - 1))
TBN = [T | B | N] // columns are tangent, bitangent, normal
Displacement Mapping
Actually moves vertices along the normal by the height value. Requires tessellation to produce sufficient geometry. True silhouette modification, unlike normal mapping.
Parallax Mapping
Approximates displacement by offsetting UV coordinates based on the view angle and height:
uv_offset = uv - V_tangent.xy / V_tangent.z * h(uv) * scale
Parallax Occlusion Mapping (POM): Ray marches through the height field in tangent space for accurate self-occlusion. Iterates until the ray intersects the height field, then performs binary refinement.
Shadow Mapping
Basic Algorithm
- Render scene depth from the light's viewpoint into a shadow map
- During shading, transform the fragment position into light space
- Compare fragment depth with the stored shadow map depth:
shadow = (fragment_depth_light > shadow_map_sample + bias) ? 0.0 : 1.0
Shadow Bias
Prevents self-shadowing artifacts (shadow acne) caused by depth precision limits:
bias = max(0.05 * (1.0 - N.L), 0.005)
Slope-scaled bias adapts to surface orientation. Too much bias causes "peter panning" (shadows detach from objects).
Percentage-Closer Filtering (PCF)
Sample multiple texels in the shadow map and average the binary test results:
shadow = 0
for each sample (dx, dy) in kernel:
shadow += (depth > shadow_map.sample(uv + (dx,dy)/resolution) + bias) ? 0 : 1
shadow /= num_samples
Produces soft shadow edges. Poisson disk or rotated kernel patterns reduce banding.
Cascaded Shadow Maps (CSM)
Split the view frustum into multiple depth ranges, each with its own shadow map. Near cascades get more texel density, reducing aliasing.
Cascade 0: [near, split_1] - highest resolution
Cascade 1: [split_1, split_2] - medium resolution
Cascade 2: [split_2, far] - lowest resolution
Split positions often use a logarithmic/practical split scheme:
C_log = near * (far/near)^(i/N)
C_uniform = near + (far-near) * i/N
C_practical = lerp(C_log, C_uniform, lambda)
Variance Shadow Maps (VSM)
Store depth and depth^2 in the shadow map. Use Chebyshev's inequality to estimate the probability of being in shadow. Allows hardware filtering (bilinear, mipmaps) of the shadow map, producing smooth soft shadows. Suffers from light bleeding artifacts.
Procedural Textures
Perlin Noise (1985)
Gradient noise function generating smooth, natural-looking pseudo-random patterns.
Algorithm:
- Define a grid with random gradient vectors at each integer lattice point
- For a point p, find the surrounding grid cell
- Compute dot products of gradient vectors with offset vectors to cell corners
- Interpolate with a smoothstep (quintic) fade function:
fade(t) = 6t^5 - 15t^4 + 10t^3
Properties: continuous, band-limited, tileable (with proper hashing), zero at integer points.
Fractal Brownian Motion (fBm)
Layer multiple octaves of noise at increasing frequency and decreasing amplitude:
fbm(p) = sum_{i=0}^{N} amplitude^i * noise(frequency^i * p)
Typical: frequency doubles (lacunarity = 2), amplitude halves (gain = 0.5) per octave.
Procedural Applications
- Marble:
sin(x + turbulence(p)) - Wood:
fract(sqrt(x^2 + y^2) + noise(p)) - Clouds: fBm in 3D with time-varying z
- Terrain heightmaps: fBm with domain warping
Simplex Noise
Improved variant using a simplex grid instead of a hypercube. Lower computational cost in higher dimensions (N+1 vertices per simplex vs 2^N for a cube). Better directional isotropy.
Texture Compression
Block Compression Formats
| Format | Ratio | Channels | Use Case | |---------|-------|----------|-----------------------------| | BC1/DXT1 | 6:1 | RGB+1-bit A | Color maps without alpha | | BC3/DXT5 | 4:1 | RGBA | Color maps with alpha | | BC5 | 2:1 | RG | Normal maps (XY only) | | BC7 | 3:1 | RGBA | High-quality color + alpha | | ASTC | Variable | RGBA | Mobile, variable block size |
Block compression divides the texture into 4x4 blocks, each compressed independently. Enables random access and hardware decompression at no runtime cost.
Texture Arrays, Atlases, and Bindless
- Texture arrays: Stack of same-size textures, indexed by layer. Avoids atlas UV issues.
- Texture atlases: Pack multiple textures into one large texture. Requires UV margin to prevent bleeding.
- Bindless textures: GPU-resident textures accessed by handle/index rather than binding slots. Eliminates binding overhead and descriptor set limits.
- Virtual texturing: Only resident pages of a huge texture are loaded on demand (megatexture). Uses a page table and feedback buffer.