Normal, Parallax & Environment Mapping

Normal maps store tangent-space normals (RGB = XYZ remapped from [-1, 1] to [0, 1]).

18.1 Normal mapping with a TBN matrix

Normal maps store tangent-space normals (RGB = XYZ remapped from [-1, 1] to [0, 1]).

Two-channel (BC5) normal maps: reconstruct z = sqrt(1 - x² - y²). Without precomputed tangents, derive a TBN from screen-space derivatives (dFdx(vWorldPos), dFdx(vUV)).

// Vertex shader
layout (location = 3) in vec4 aTangent;         // xyz = tangent, w = handedness (±1)
out mat3 vTBN;
void main() {
    vec3 N = normalize(uNormalMatrix * aNormal);
    vec3 T = normalize(uNormalMatrix * aTangent.xyz);
    T = normalize(T - dot(T, N) * N);           // Gram–Schmidt re-orthogonalization
    vec3 B = cross(N, T) * aTangent.w;
    vTBN = mat3(T, B, N);                        // columns: tangent, bitangent, normal
    ...
}

// Fragment shader
uniform sampler2D uNormalMap;                    // load with a LINEAR (non-sRGB) format!
void main() {
    vec3 tn = texture(uNormalMap, vUV).xyz * 2.0 - 1.0;    // [0,1] → [-1,1]
    // tn.y = -tn.y;   // flip green if the map uses DirectX convention
    vec3 N = normalize(vTBN * tn);                           // tangent → world space
    ...
}

18.2 Parallax & parallax occlusion mapping

Offset texture coordinates according to a height map and the view direction (in tangent space) to fake depth:

vec2 parallaxOcclusion(vec2 uv, vec3 viewTS) {     // viewTS: view direction in tangent space
    const float minLayers = 8.0, maxLayers = 32.0;
    float numLayers = mix(maxLayers, minLayers, abs(viewTS.z));
    float layerDepth = 1.0 / numLayers;
    vec2 deltaUV = viewTS.xy / viewTS.z * uHeightScale / numLayers;
    float currentDepth = 0.0;
    float mapDepth = 1.0 - texture(uHeightMap, uv).r;
    while (currentDepth < mapDepth) {
        uv -= deltaUV;
        mapDepth = 1.0 - textureLod(uHeightMap, uv, 0.0).r;
        currentDepth += layerDepth;
    }
    vec2 prevUV = uv + deltaUV;                    // interpolate between the last two layers
    float after = mapDepth - currentDepth;
    float before = (1.0 - textureLod(uHeightMap, prevUV, 0.0).r) - currentDepth + layerDepth;
    float w = after / (after - before);
    return mix(uv, prevUV, w);
}

18.3 Environment mapping

uniform samplerCube uEnv;
vec3 I = normalize(vWorldPos - uCameraPos);
vec3 R = reflect(I, N);
vec3 reflection = texture(uEnv, R).rgb;                    // mirror reflection
vec3 T = refract(I, N, 1.0 / 1.52);                        // glass (air → glass IOR)
vec3 refraction = texture(uEnv, T).rgb;
float F = 0.04 + 0.96 * pow(1.0 - max(dot(-I, N), 0.0), 5.0);
vec3 glass = mix(refraction, reflection, F);
// Chromatic dispersion: refract R, G, B with slightly different IORs

18.4 Skybox shader

// Vertex
out vec3 vDir;
void main() {
    vDir = aPos;                                   // cube vertex = direction
    mat4 viewNoTranslation = mat4(mat3(uView));
    vec4 p = uProjection * viewNoTranslation * vec4(aPos, 1.0);
    gl_Position = p.xyww;                          // depth = 1.0 (far plane); use GL_LEQUAL depth func
}
// Fragment
uniform samplerCube uSkybox;
in vec3 vDir;
out vec4 outColor;
void main() { outColor = texture(uSkybox, vDir); }