Skip to content

Latest commit

 

History

History
38 lines (26 loc) · 8.05 KB

lighting.md

File metadata and controls

38 lines (26 loc) · 8.05 KB

Lighting and Shadows

Joy Engine lights scenes with one directional key light (FrameInputs::directional_light, or the environment's sun or moon), a sky-and-ground skylight, uniform ambient light, image-based lighting for layered surfaces, and any number of point lights. Opaque surfaces shade with deferred lighting where the device supports it and forward elsewhere; both paths read the same light clusters and shadow maps and run the shading model's own response, so they draw the same image.

Point Lights and Light Clusters

Frames may supply any number of point lights. The renderer skips invalid lights and lights whose range cannot reach the view, ranks the rest by how strongly they light the view (luminance, scaled down by the camera's distance from the light's reach), and draws up to MAX_POINT_LIGHTS (1024), strongest first. RendererDiagnostics::point_lights and point_lights_omitted report the split.

Each frame the CPU bins the drawn lights into light clusters (lighting/clusters.rs): 16 by 9 screen tiles and 24 depth slices, spaced exponentially from the near plane to the farthest depth any light reaches. A light joins every cell its range sphere can touch, and lists keep the frame's light order. Every lit pass (forward and deferred surfaces, terrain, and volumetric fog) reads only the lights listed for the cell a point lies in, so a pixel pays for the lights that reach it rather than for every light in the frame. The lights and lists travel in textures rather than storage buffers, which keeps one path on WebGL 2. The lists hold 131,072 references together; when a frame needs more, the weakest lights lose their cells first and cluster_references_dropped counts the loss.

Deferred Lighting

With RendererOptions::deferred (on by default; Renderer::set_deferred and r.Deferred switch it), opaque surfaces shade in three steps after the depth pre-pass:

  1. The G-buffer pass draws terrain and every opaque material whose model supports deferred lighting. Each fragment writes the light that does not depend on the frame's direct lights (emission, ambient, skylight, and image-based light) into the scene color, motion when scene effects need it, and one Rgba32Uint G-buffer texel: the engine's header word (the shading normal in octahedral form and whether the surface receives shadows) and three words the shading model encodes. The pipeline writes the model's lighting slot into the stencil buffer.
  2. One full-screen lighting pass per shading model with visible surfaces runs where the stencil holds that model's slot. Each pixel rebuilds its world position from depth, decodes its surface with the model's materialDecode, and adds the engine's direct light loop (the key light with its cascades and the cluster's point lights with their shadows) through the model's materialDirect.
  3. A forward pass draws the opaque materials that shade forward, the sky, and, when no scene effect needs it separately, transparency.

The built-in lit model and terrain store their color; the layered model stores its diffuse color, F0, roughness, a gray F90, and one optional lobe (haze, an untinted coat with the default coat F0, or fuzz). Layered materials that need more (thin film, a colored F90, a tinted coat or custom coat F0, or two optional lobes) shade forward automatically, as do unlit materials, game models without the deferred functions, and transparency. RendererDiagnostics::deferred, lighting_passes, and forward_opaque_batches show what a frame did. Game models opt in by implementing materialEncode and materialDecode (shading models).

Deferred lighting needs a backend other than WebGL 2, an HDR scene format, and room for the scene color, motion, and G-buffer in one pass (GpuCapabilities::deferred). WebGL 2 and debug views shade every surface forward with the same clusters and shadows.

Shadows

ShadowOptions (on by default; r.Shadows switches them) sets the quality preset, map sizes, receiver offset, caster depth bias, shadow distance, how cascade splits blend uniform and logarithmic spacing (cascade_split_lambda, 0.5 by default; long shadow distances want values near 1 so the first cascade stays sharp near the camera, since receivers move by a multiple of each cascade's texel), and how many point lights may cast at once (max_point_lights, up to MAX_SHADOWED_POINT_LIGHTS, 32). Terrain draws only the tiles each shadow view reaches, and tiles the camera cannot see draw no finer than the cascade's texels resolve.

Quality Cascades Cascade size Largest point face Largest point atlas Blocker / filter taps
Low (default) 3 1024 256 2048 8 / 12
Balanced 4 1536 512 4096 16 / 24
Cinematic 4 2048 1024 4096 24 / 48

The key light's cascades split the view out to the shadow distance between uniform and logarithmic spacing. Each cascade bounds its slice of the view frustum with a sphere, so its footprint holds still as the camera turns, moves in whole texels so edges do not shimmer, and reaches toward the light only as far as the casters above its footprint, which keeps its depth range tight. Neighboring cascades blend across the last tenth of each, and shadows fade out over the last tenth of the shadow distance.

Point lights set to cast take shadow slots in importance order when their range touches a caster. Each renders six cube faces into square tiles of one shared depth atlas, all in one pass with a viewport per tile; the atlas starts small and grows toward the preset's size as frames ask for more faces. A face is about as many texels as the light's reach covers pixels on screen, up to the preset's largest face; when the atlas is full, the least important lights' faces shrink first, then lights lose their shadows. Every shadow view draws only the casters whose bounds reach it, including casters outside the camera's view.

Receivers (shadow-receiver.wgsl) filter both kinds with percentage-closer soft shadows: a blocker search over the region the light's size could shadow finds the average occluder depth, the penumbra follows from the light's size (the key light's angular radius, or a point light's source radius) and the blocker-to-receiver distance, and a Vogel disk of hardware comparisons, rotated per pixel (and per frame while temporal antialiasing accumulates), averages visibility over it. Receivers move off their surface by at least a few texels of the map they read, and every tap compares against the receiver's own plane, so wide kernels do not shadow slopes with themselves. Filter widths are measured in texels of the map that is read, so a setting behaves the same in a near cascade, a far cascade, and a small atlas tile. WebGL 2 runs the same filters with comparisons built from nearest-sampled depth reads (WebGL 2 returns zero from depth textures read without a nearest sampler or comparison, which a unit test guards).

Debugging and Profiling

DebugView::LightComplexity (r.DebugView lightComplexity) colors surfaces by how many lights their cluster lists, from blue at one to red at 32 or more. LightingOnly shows the irradiance a white surface receives, and ShadowCascades tints each cascade. Frame profiles time the G-buffer and deferred lighting phases separately from the forward scene pass, and the STATS panel shows the lighting path, light and cluster counts, and shadow views; see console and profiling.

Tests

crates/joy-renderer/tests/integration/rendering_lighting.rs renders the same scene deferred and forward and requires them to match within the G-buffer's precision, checks that layers the G-buffer cannot hold shade forward, registers game shading models with and without the deferred functions (and rejects ones that break the contract), and lights a 400-light grid on both paths. Unit tests cover cluster binning and overflow, atlas fitting and placement, cascade fitting, and that every lit and deferred module validates and its forward passes translate to WebGL 2.