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.F0is0.04for 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:
| Field | Type | Meaning |
|---|---|---|
kind | directional / point / spot | Parallel rays, a positioned lamp, or a cone |
color | RGB | Linear colour; multiplied by intensity into the radiance |
intensity | number | Brightness multiplier |
range | number | Point and spot falloff distance (metres); ignored by a directional light |
spotAngle | number | Spot 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.xyzis the travel direction (the world matrix's normalized -Z column),data.w = 0. Rotate the light's GameObject to aim it. - Point -
data.xyzis 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.rgbislight.color x intensity;color.wcarries therange.
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,
ambientIntensityis0and surfaces are lit purely by direct light. The effect carries an equatorcolorand, optionally,skyandgroundcolours: 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 setsfogDensity(expmode uses density directly;linearmode 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 atd.y = 0so there is no seam. Set it with the Sky effect. - Sky bodies are part of the Sky effect rather than separate objects.
starsdraws a starfield at night,sunis the lit body (absent, it is derived from the scene's directional light), andmoonis 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
cubesamples atexture_cubeby 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.
| Mode | Operator | When |
|---|---|---|
Linear (0) | Hard clamp(c, 0, 1) | Default / Godot imports - keeps stylized saturation |
ACES (1) | Narkowicz ACES filmic | Unity / 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
MeshRendererfields (color,metallic,roughness,texture) packed into the instance record - see Materials. - Normal maps are the
normalTexturefield 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 leavesz = -1, which turns the surface black and immune to every lighting setting. - Lights are
Lightcomponents you add to GameObjects. - Ambient, fog, sky, tonemap live in the Render Settings effect stack.
See also
- Shadows - how the sun's cascaded shadow maps are built and sampled
- Post-Processing (Volume) - sky, ambient, fog, and the tonemap mode as effects
- Materials & Hook Shaders - where per-object albedo/metallic/roughness come from
- Camera - the uniform that carries sky colours and the tonemap flag
- Render Pipeline Overview - where lighting sits in the frame