Skip to content

PBR & Lighting

Awaken lights every surface with a physically based Cook-Torrance model, up to eight lights, a flat ambient term, distance fog, and a choice of tonemapper - all evaluated in one forward fragment shader.

The lighting model

Surfaces are shaded with the Cook-Torrance microfacet BRDF, the same specular model used by Unity's URP, Unreal, and Godot. It combines three terms, all in MESH_WGSL (packages/render/src/shader.ts):

  • D - Normal distribution (GGX / Trowbridge-Reitz): distributionGGX(N, H, rough) - how many microfacets point toward the half-vector. Roughness controls the highlight's tightness.
  • G - Geometry (Smith with Schlick-GGX): geomSmith(N, V, L, rough) - self-shadowing and masking of microfacets at grazing angles.
  • F - Fresnel (Schlick): fresnel(cosT, F0) - reflectivity rising toward glancing angles. F0 is 0.04 for dielectrics, tinted toward the albedo as metallic rises.

The specular contribution per light is spec = D * G * F / (4 · (N·V) · (N·L)), and the diffuse contribution is the albedo weighted by kd = (1 − F) · (1 − metallic) so metals lose their diffuse lobe. Each light accumulates Lo += (kd·albedo + spec) · radiance · (N·L).

Roughness is clamped to [0.04, 1.0] (a perfectly smooth surface is not representable and would alias), and metallic to [0, 1].

The energy convention (why direct light has no 1/π)

This is the detail that makes imported Unity/Godot scenes look right. The direct term deliberately omits the 1/π diffuse normalization: engine light energies (Godot light_energy, Unity intensity) are authored as surface brightness, where the artist has already accounted for the 1/π, so an intensity of 1 reads as a roughly fully-lit surface.

The ambient term does keep the 1/π:

wgsl
let amb = albedo * (1.0 - metallic) * lights.ambientIntensity * lights.ambientColor / PI * ao;

Keeping 1/π on ambient but not on direct means the sun stays brighter than the fill light, so shadows read as shadows instead of washing out. Ambient is deliberately flat - driven by the neutral ambient colour, not tinted by the sky - because a coloured sky was greening every up-facing surface. Ambient is also the only term SSAO darkens (see Post-Processing).

Lights

A Light component (packages/render/src/light.ts) has four fields:

FieldTypeMeaning
kinddirectional / pointParallel rays vs. a positioned lamp
colorRGBLinear colour; multiplied by intensity into the radiance
intensitynumberBrightness multiplier
rangenumberPoint-light falloff distance (metres)

gatherLights() walks every active, enabled Light in the world and packs up to 8 into the Lights uniform. Each is a Light { data: vec4, color: vec4 }:

  • Directional - data.xyz is the travel direction (the world matrix's normalized −Z column), data.w = 0. Rotate the light's GameObject to aim it.
  • Point - data.xyz is the world position, data.w = 1. Attenuation is att = clamp(1 − dist/range, 0, 1)² - a smooth quadratic falloff to zero at range.
  • color.rgb is light.color × intensity; color.w carries the point-light range.

The Lights uniform header also carries scene-wide values: light count, ambientIntensity, shadowStrength, ambientColor, and fogDensity.

NOTE

Only the first 8 lights are shaded - there is no light culling or clustering per object. Keep the count of simultaneously-visible lights modest, and prefer one directional sun plus a few points.

The sun

The first directional light in the scene is the sun. It is the only light that casts shadows: its direction (cacheSunDir) drives the cascade fit, and its shadowStrength (from the render settings) controls how dark shadowed pixels get - 1 = fully dark, 0 = shadows off. Point lights and any additional directional lights contribute to shading but never to the shadow maps.

Ambient, fog, and the sky

  • Ambient comes from the Ambient effect in the render settings stack. With no Ambient effect present, ambientIntensity is 0 and surfaces are lit purely by direct light.
  • Fog is applied per-fragment after tonemapping: fogF = 1 − exp(−distance · fogDensity), blending the shaded colour toward the sky colour in the same view direction. Fogging toward the sky (not a flat grey) is what lets distant geometry blend into the background instead of fading to grey. The Fog effect sets fogDensity (an exp mode uses density directly; linear mode derives a density from its end distance).
  • Sky is a fullscreen gradient (skyColor()): a zenith→horizon blend above the horizon and a horizon→ground blend below, continuous at d.y = 0 so there is no seam. The same function is shared by the background sky pass and the fog blend. Set it with the Sky effect.

Tonemapping

The shaded HDR-ish colour is tonemapped in the mesh fragment shader before gamma and fog, selected by camera.skyHorizon.w:

ModeOperatorWhen
Linear (0)Hard clamp(c, 0, 1)Default / Godot imports - keeps stylized saturation
ACES (1)Narkowicz ACES filmicUnity / Unreal imports - rolls HDR highlights off instead of clipping to flat white

The mode is the tonemap field on the effect stack ("linear" or "aces") - a base mode, not an add/remove card. After tonemapping the colour is gamma-corrected (pow(c, 1/2.2)) and then fogged toward the gamma-space sky colour, so foreground and background share one colour space.

📸 Screenshot - save as img/render-pbr-metal-rough.png

A row of spheres varying metallic left→right and roughness top→bottom under one directional sun, showing the highlight tightening and the metal/dielectric split.

Where lighting values come from

  • Per-object albedo, metallic, roughness are the MeshRenderer fields (color, metallic, roughness, texture) packed into the instance record - see Materials.
  • Lights are Light components you add to GameObjects.
  • Ambient, fog, sky, tonemap live in the Render Settings effect stack.

See also

Awaken — browser-native WebGPU game engine.