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 eight 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 |
layers | number | Light-layer bitmask: the light reaches only objects whose MeshRenderer.lightLayers shares a bit with it. Default 4294967295 (every layer). See Light layers |
castShadows | bool | Point and spot only, default off: give the light a cube shadow map while it is one of the 16 most relevant shadow-casting lights in view. See Shadows |
shadowedLayers | number | Shown with castShadows on: the light-layer mask used instead of layers while the light holds a shadow map. Default every layer |
gatherLights() walks every active, enabled Light in the world and packs them into the Lights buffer. Each is a 64-byte Light { data: vec4, color: vec4, spot: vec4, layers: u32 } (plus padding):
- 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.layersis the light-layer mask, written as rawu32bits.
The Lights storage-buffer 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.
Light layers
Point and spot lights cast no shadows, so a lamp in one room also lights the floor of the next room through the wall. Light layers stop that: Light.layers and MeshRenderer.lightLayers are 32-bit masks, and a light reaches a surface only when the two share at least one bit ((light.layers & object.lightLayers) != 0).
- Layer 1 is the value
1, layer 2 is2, layer 3 is4, and so on up to layer 32 (2147483648). Add values to put something on several layers:5is layers 1 and 3. - Both fields default to
4294967295(every bit set), so a scene that never sets a mask renders exactly as before.-1also means every layer;0means none. - A typical setup gives each room its own bit, set on the room's lights and on its walls, floor and props. The sun and anything outdoors stay on every layer.
The mask applies to every light kind (directional, point and spot) in both lighting paths, the 16-light loop and the clustered loop. It does not touch:
- ambient, ambient specular and emission, which are not lights;
- the shadow map: an object casts the sun's shadow onto everything else whatever its layers;
- particles, which are lit by the scene light tone, not the light list, so masks do not affect them;
- a custom material whose shader reads
lights.itemsdirectly, which bypasses the mask unless it applies it.
Without clustered lighting, the 16 lights the shader loops are chosen once per frame, before any mask is applied, so a lamp masked away from everything near the camera still holds one of the 16 slots. A scene with many masked lamps should turn on Clustered lighting, which keeps up to 32 lights per cluster instead of 16 per frame. A light with layers set to 0 is never binned into a cluster.
Light masks are re-read every frame, so a script can change Light.layers with a plain field write. A MeshRenderer mask is picked up the same way as its colour: write it, then mark the entity moved, and the renderer patches that object's GPU record without rebuilding the scene.
ts
api.setField("MeshRenderer", "lightLayers", 0b10); // this object is now on layer 2 only
api.world.markMoved(api.entity); // picked up on the next frameBoth masks reach the GPU as raw u32 bits (the object's in its per-instance record, including the record a merged static batch reads, and the light's in its light record), not as floats: an f32 keeps 24 bits of mantissa, so a float lane would round the high layers away.
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) | Godot imports - keeps stylized saturation |
ACES (1, the default) | Narkowicz ACES filmic fit, then 1/2.2 gamma | New scenes and Unity / Unreal imports - rolls HDR highlights off instead of clipping to flat white |
ACES (Hill fit, sRGB) (2) | Stephen Hill's RRT + ODT fit, then the piecewise sRGB curve | Closer to Unity's ACES output; 0.18 grey lands at 91 of 255 against ACES's 140 |
The mode is the tonemap field on the effect stack ("linear", "aces" or "acesHill") - a base mode, not an add/remove card, set under Render Settings → Image. The optional Dither beside it adds up to one 8-bit code value of noise after the tonemap so dark gradients do not band.

Try it
+ → Create → Sphere, then in the Inspector's MeshRenderer set metallic to 1 and roughness to 0.1. Drag roughness up to 0.9 and watch the highlight spread from a pinpoint into a soft sheen.
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