5 min read
On this page

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

  1. Render scene depth from the light's viewpoint into a shadow map
  2. During shading, transform the fragment position into light space
  3. 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:

  1. Define a grid with random gradient vectors at each integer lattice point
  2. For a point p, find the surrounding grid cell
  3. Compute dot products of gradient vectors with offset vectors to cell corners
  4. 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.