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 eight fields:

A point Light in the Inspector: kind, colour, intensity, range, light layers, castShadows ticked and the shadowedLayers mask it then uses.

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
layersnumberLight-layer bitmask: the light reaches only objects whose MeshRenderer.lightLayers shares a bit with it. Default 4294967295 (every layer). See Light layers
castShadowsboolPoint 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
shadowedLayersnumberShown 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.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.
  • layers is the light-layer mask, written as raw u32 bits.

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.

A point light with castShadows on above the Physics playground: the crates and ramp lit by it as well as the sun.

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 is 2, layer 3 is 4, and so on up to layer 32 (2147483648). Add values to put something on several layers: 5 is layers 1 and 3.
  • Both fields default to 4294967295 (every bit set), so a scene that never sets a mask renders exactly as before. -1 also means every layer; 0 means 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.items directly, 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 frame

Both 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, 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)Godot imports - keeps stylized saturation
ACES (1, the default)Narkowicz ACES filmic fit, then 1/2.2 gammaNew 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 curveCloser 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.

Ten spheres under one sun: the front row is non-metal (metallic 0), the back row metal (metallic 1), and roughness rises 0.1 → 0.9 left to right. The highlight is a sharp point on the left and spreads into a soft sheen on the right; the metals take their colour into the highlight.

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 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.