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;

Ambient carries no 1/π either. The ambient colour an engine hands over is irradiance already convolved with the cosine lobe (Unity evaluates its ambient probe as albedo x SH, Godot as albedo x ambient_light x energy), which is the same convention the direct term uses. Dividing only ambient by π made indirect light 3.14x darker than the sun relative to the source engine: sunlit surfaces matched Unity while everything the sun missed collapsed toward black. Measured on an imported island scene, the ground ambient band is 0.017 linear, and 0.017 / π is 0.005 - black on any albedo, and unreachable by any shadow setting.

Ambient is the only term SSAO darkens (see Post-Processing).

Lights

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

FieldTypeMeaning
kinddirectional / point / spotParallel rays, a positioned lamp, or a cone
colorRGBLinear colour; multiplied by intensity into the radiance
intensitynumberBrightness multiplier
rangenumberPoint and spot falloff distance (metres); ignored by a directional light
spotAnglenumberSpot only: the full cone angle in degrees. The edge is feathered over the outer quarter of the cone

gatherLights() walks every active, enabled Light in the world and packs them 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.
  • Spot - a positioned light like a point, plus a cone axis and cos(half-angle). Other kinds write a sentinel the shader reads as "not a spot".
  • color.rgb is light.color x intensity; color.w carries the range.

The Lights uniform header also carries scene-wide values: light count, ambientIntensity, shadowStrength, ambientColor, the ambient sky/ground bands, and fogDensity.

Two falloff models

range falloff is (1 - dist/range)² by default: a hard cutoff, so nothing past the radius is lit at all and the edge can be visible on a large flat surface. Turn on Physical light falloff in the render settings for URP's windowed inverse-square instead, which is what imported Unity lamp intensities were authored against.

The light budget, and Forward+

The default forward path shades up to MAX_LIGHTS (16) lights per fragment. When more lights are visible than that, directional lights are always kept and point lights compete for the remaining slots by brightness and nearness to the camera.

Turn on Clustered lighting in the render settings for Forward+: lights are binned on the GPU into a grid of screen tiles x exponential depth slices, and each fragment shades only the lights in its own cluster, up to MAX_LIGHTS_PER_CLUSTER (32). A town with 70 lamps then lights correctly instead of keeping the nearest 16.

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. The effect carries an equator color and, optionally, sky and ground colours: the shader blends the three by the surface normal's Y, which is Unity's Gradient (trilight) ambient. A flat ambient is the case where all three are equal.
  • Ambient specular (image-based lighting) is applied alongside it. Without it a metal has no diffuse (metallic kills it) and no environment reflection either, so it renders almost black however bright the scene is. This was the single biggest reason imported metal, glass and polished stone read as dead grey.
  • Fog is applied per-fragment: fogF = 1 - exp(-distance * fogDensity), blending toward the fog effect's own authored colour, which is Unity's behaviour. The Fog effect sets fogDensity (exp mode uses density directly; linear mode derives a density from its end distance).
  • Sky is a fullscreen gradient (skyColor()): a zenith to horizon blend above the horizon and a horizon to ground blend below, continuous at d.y = 0 so there is no seam. Set it with the Sky effect.
  • Sky bodies are part of the Sky effect rather than separate objects. stars draws a starfield at night, sun is the lit body (absent, it is derived from the scene's directional light), and moon is a second body drawn with a phase terminator. sunSquare (0 to 1) makes both bodies square-edged, and it shapes the highlight they strike off glossy surfaces as well as the disc, because a square light leaving a round glint on water gives the trick away.
  • Cubemap skies are supported: a Sky effect with a cube samples a texture_cube by view direction instead of the gradient.

Tonemapping

The scene renders in linear HDR to an rgba16float target. Mesh shaders never tonemap: they output linear values, and the post composite tonemaps and gamma-encodes once, which is how Unity and Unreal are arranged. Gizmos and the inset view are composited after the tonemap, so editor furniture is not graded with the scene.

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.

📸 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.
  • Normal maps are the normalTexture field on the same component. They are stored two-channel (bc5-rg-unorm), so the shader reconstructs Z rather than reading a blue channel. A normal map read as three channels leaves z = -1, which turns the surface black and immune to every lighting setting.
  • 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.