T.TAO
Back to Blog
/5 min read/Graphics Engine

Metal #7 Lighting

#ComputerGraphics#GraphicsEngine#Metal

This note covers the basic lighting models and how to organize multiple lights in Metal.

Lambertian diffuse

An ideal diffuse surface scatters incoming light equally in every direction. The energy arriving at the surface is proportional to the cosine of the angle between the incoming direction and the normal β€” Lambert's cosine law:

MSLfloat3 n = normalize(in.normalWS);
float3 l = normalize(-light.direction);     // pointing towards the light
float  ndotl = saturate(dot(n, l));
float3 diffuse = baseColor * light.color * ndotl;

saturate rather than max(dot, 0) is habit; they are equivalent. What matters is that you clamp at all: ndotl is negative on back-facing geometry, and left alone it produces negative colour that propagates as black artefacts through an HDR pipeline.

Phong and Blinn-Phong speculars

Phong computes the highlight from the angle between the reflection vector and the view:

MSLfloat3 r = reflect(-l, n);
float  spec = pow(saturate(dot(r, v)), shininess);

Blinn-Phong replaces the reflection vector with the half vector, the bisector of light and view:

MSLfloat3 h = normalize(l + v);
float  spec = pow(saturate(dot(n, h)), shininess * 4);

Blinn-Phong is the more common choice, for two reasons. First, normalize(l + v) is slightly cheaper than reflect. Second and more importantly: when both light and view approach grazing angles, Phong's dot(r, v) goes negative early and the highlight is cut off, leaving a hard edge. Blinn-Phong has no such discontinuity, and its highlight shape at grazing angles is closer to measured reality.

The shininess exponent is not equivalent between the two models; empirically Blinn needs two to four times Phong's value for a highlight of similar size.

The three light types

C// in the shared header
typedef enum { LightTypeDirectional, LightTypePoint, LightTypeSpot } LightType;

typedef struct {
    vector_float3 position;      // point / spot
    vector_float3 direction;     // directional / spot
    vector_float3 color;
    float         intensity;
    float         range;         // point / spot
    float         innerCone;     // spot, stored as a cosine
    float         outerCone;
    uint          type;
} Light;

A directional light has no position, only a direction, and its rays are parallel everywhere. Suns and moons. No attenuation.

A point light emits from a point in every direction, falling off with distance.

A spot light is a point light with a cone restriction. Soften the cone edge or the boundary aliases badly:

MSLfloat  cosAngle = dot(normalize(fragToLight), normalize(-light.direction));
float  cone     = smoothstep(light.outerCone, light.innerCone, cosAngle);

Note the two smoothstep bounds are in reverse order (outer first), because cosine decreases as the angle grows.

Attenuation

The physically correct falloff is inverse square:

MSLfloat d    = length(light.position - in.positionWS);
float atten = 1.0 / (d * d);

Using it directly has two practical problems: 1/dΒ² tends to infinity next to the light, and light theoretically never reaches zero, so every light would have to be evaluated for the whole scene.

The practical form adds a windowing function so intensity reaches zero smoothly at range:

MSLfloat d      = length(light.position - in.positionWS);
float ratio  = d / light.range;
float window = saturate(1.0 - ratio * ratio * ratio * ratio);
float atten  = window * window / (d * d + 1.0);

The + 1.0 removes the singularity up close, and the fourth-power window makes both the value and its derivative continuous at range β€” a discontinuous derivative shows up as a visible brightness jump when things move.

With an explicit range, culling becomes possible: the CPU or a compute shader can test whether a light's sphere of influence intersects an object (or a screen tile) and skip it entirely if not. This is the basis of every tiled and clustered renderer.

Where to put the multi-light loop

The most direct approach loops over all lights in the fragment function:

MSLfragment float4 fragment_lit(VertexOut in [[stage_in]],
                             constant Light *lights [[buffer(13)]],
                             constant uint  &lightCount [[buffer(14)]])
{
    float3 n = normalize(in.normalWS);
    float3 v = normalize(cameraPosition - in.positionWS);
    float3 color = ambient * baseColor;

    for (uint i = 0; i < lightCount; i++) {
        color += shade(lights[i], n, v, in.positionWS, baseColor);
    }
    return float4(color, 1);
}

This is single-pass forward. It is simple and it supports transparency and MSAA, but it costs O(objects Γ— lights): every fragment walks every light, even the ones nowhere near it. It stops scaling after a few dozen lights.

The alternatives:

  • Multi-pass forward β€” one pass per light, accumulated with additive blending. The old-API approach, now essentially unused: the geometry is processed N times over.
  • Deferred rendering β€” write geometric attributes into a G-Buffer, then shade per light in screen space. The complexity becomes O(objects + lights Γ— covered pixels). See Metal #11 Deferred Rendering.
  • Tiled / clustered forward (Forward+) β€” a compute pass assigns lights to screen tiles (or 3D clusters in the frustum) and each fragment only iterates the list for its own tile. This keeps forward rendering's support for transparency and MSAA while bringing the per-tile light count back under control. On Apple Silicon's TBDR architecture this is especially natural, since the hardware already works tile by tile.

Ambient light, and what comes next

The ambient * baseColor in the code above is the crudest possible approximation: assume uniform light arriving from every direction. It keeps shadows from going pure black, but it is entirely fake β€” real ambient light varies with direction.

The first improvement is hemisphere lighting: interpolate between a sky colour and a ground colour according to whether the normal points up or down:

MSLfloat  up      = n.y * 0.5 + 0.5;
float3 ambient = mix(groundColor, skyColor, up);

One line, and far better than a constant. Above that you get irradiance maps and spherical harmonics, which belong to image-based lighting; we touch on those in Metal #15 Reflection and Refraction.

The next note covers materials, which organize all of these parameters.

In this series

Metal β†’
  1. 01Metal #0 Swift Review
  2. 02Metal #1 Initialization
  3. 03Metal #2 Rendering Pipeline
  4. 04Metal #3 Vertex Function
  5. 05Metal #4 Fragment Function
  6. 06Metal #5 Texture
  7. 07Metal #6 Navigation
  8. 08Metal #7 Lighting
  9. 09Metal #8 Materials
  10. 10Metal #9 Render Passes
  11. 11Metal #10 Shadow
  12. 12Metal #11 Deferred Rendering
  13. 13Metal #12 Particle System
  14. 14Metal #13 Tessellation
  15. 15Metal #14 Post-processing
  16. 16Metal #15 Reflection and Refraction
  17. 17Metal #16 Animation
  18. 18Metal #17 Ray Tracing (I) Rendering Algorithm
  19. 19Metal #18 Ray Tracing (II) Shadows and Lighting
  20. 20Metal #19 Ray Tracing (III) Performance Optimization
  21. 21Metal #21 [Appendix] Compute Shaders
  22. 22Metal #22 [Appendix] Metal in SwiftUI