Rendering

Lights & shadows

The Light component, LightSystem, and Shade's photometric light model - candela and lux intensity, GPU froxel clustering, cascaded and atlased shadow maps, and ReSTIR DI.

Light data lives on the Light ECS component. LightSystem mirrors every Light + Transform64 entity into Shade’s Scene, where each one becomes a Node3D subclass held in scene.lights. From there Shade takes over: point and spot lights are culled and binned into froxels on the GPU each frame and applied in the deferred pass; directional lights are never clustered and instead drive cascaded shadow maps over the whole view. This page covers the component, the four light types, Shade’s own light objects, and how shadows are generated and filtered.

Light + Transform64 entityLightSystem mirrors it into scene.lightsGPU light table rebuilt on version changecull and froxel binning, point and spot onlyshadow atlas refresh - CSM cascades, local tilesdeferred lighting resolve

The Light component

import { Light }     from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import Entity        from "@woosh/meep-engine/src/engine/ecs/Entity.js";

const light = new Light();
light.type.set(LightType.POINT);
light.color.setRGB(1, 0.9, 0.7);   // chromaticity only, see below
light.intensity.set(40);           // candelas
light.distance.set(8);             // cutoff radius, metres
light.radius.set(0.15);            // the source is 30 cm across; 0 is a point
light.castShadow.set(true);

const transform = new Transform64();
transform.setTranslation(0, 4, 0);

new Entity()
    .add(light)
    .add(transform)
    .build(ecd);

Set type before the entity is built. LightSystem reads it once, when the entity links, to decide which Shade object to create; it is the one field the system does not observe, so changing it later leaves the old light in the scene.

Properties

PropertyTypeDefaultDescription
typeObservedEnumLightType.DIRECTIONOne of the four constants below. Read once at link time
colorColorwhiteChromaticity in linear Rec.709. Its luminance is normalized away at upload - brightness comes from intensity alone. Pure black means the light is off
intensityVector11Candelas for POINT and SPOT, lux for DIRECTION
angleVector1Math.PI / 4SPOT only: the half-angle of the cone, in radians. Shade’s limit is π/2
penumbraVector10.4SPOT only: soft-edge share of the cone (0 = all umbra, 1 = all penumbra)
distanceVector11POINT and SPOT: the cutoff radius of the light’s influence - how far it reaches, not how big it is
radiusVector10The size of the source. POINT and SPOT: a world-space radius in metres - a 20 cm ceiling panel is 0.2. DIRECTION: an angular radius in radians - the sun’s is 0.006475. It caps the inverse-square falloff inside the source, sizes the specular highlight, softens the terminator and widens the shadow penumbra. 0 is a delta source - every highlight mirror-sharp, every shadow hard-edged - and is what an unset light gets. Serialized only when non-zero
castShadowObservedBooleanfalseWhether the renderer allocates a shadow-map slot for this light
maxShadowDistanceVector1InfinityDIRECTION only, and it has no effect: Shade fits its own cascades. The field round-trips so scene JSON that carries it still loads

Light.Type is a static alias for LightType, so light.type.set(Light.Type.POINT) also works.

Light types

import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
ConstantValueWhat LightSystem createsBehaviour
LightType.DIRECTION0Shade DirectionalLightParallel rays along the entity transform’s forward. Affects the whole scene, is never froxel-clustered, and is the only type that gets cascades
LightType.SPOT1SpotLightCone from the entity’s position along its forward. angle, penumbra and distance shape it
LightType.POINT2PointLightSphere of influence at the entity’s position; distance is the radius
LightType.AMBIENT3nothingSilently dropped - see below

Ambient lights are dropped; the environment is the ambient term

LightSystem has no Shade object for LightType.AMBIENT and returns null for it. The entity keeps its component, but nothing is added to the scene and nothing renders. This is deliberate: Shade lights indirect from the environment map by default, so a flat ambient light on top would double-count it.

There is always an environment to be the ambient term. GraphicsEngine.set_scene(scene) installs a default one when the scene arrives without it, and a scene that already has one keeps it. Replace it with graphics.set_environment_map(texture) - the texture must be octahedral-encoded. See Sky & environment.

EngineHarness.buildLights still builds an ambient entity (tagged ["Light", "Ambient"]). It costs one entity and renders nothing.

Registering LightSystem

LightSystem is a named export at engine/graphics3/LightSystem.js and takes (graphics, scene). There is nothing to pre-allocate or size up front: the GPU light table is rebuilt from the collection.

import { LightSystem }  from "@woosh/meep-engine/src/engine/graphics3/LightSystem.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";

await engine.entityManager.addSystem(
    new LightSystem(engine.graphics, EngineHarness.shadeScene(engine))
);

EngineHarness.shadeScene(engine) hands out the one Shade Scene per engine. LightSystem.startup() calls graphics.set_scene(scene), so registering it is also what tells the renderer which scene to draw - it is one of only three systems that do (the others are MeshSystem and ParticipatingMediaSystem).

LightSystem3 is a deprecated alias; use the unsuffixed name.

A sun and a fill from EngineHarness

EngineHarness.buildLights({ engine }) registers LightSystem if it is missing and builds two entities: a directional key light tagged ["Light", "Key"] and the ambient fill above.

OptionDefaultNotes
sunColor(1, 0.93, 0.87)Key-light chromaticity
sunIntensity0.9Lux
sunDirectionVector3(0.17, -1, 0.17)Fed to t64_look_rotation; the transform sits at (30, 70, 30)
castShadowtrueSet on the key light
ambientColor.whiteFill chromaticity
ambientIntensity0.07Fill intensity
sunShadowDistance1000Written to maxShadowDistance, which has no effect
shadowmapResolution2048Accepted and ignored - Shade sizes its own maps

EngineHarness.buildBasics({ engine }) calls it for you unless you pass enableLights: false, and forwards enableShadows as castShadow. Note that it forwards nothing else - a scene that wants a different sun calls buildLights itself, or reaches the light afterwards. The key light’s radius is left at 0, so its shadows have no penumbra; set it to the sun’s 0.006475 on the light afterwards for soft ones.

The default sun is dim, and automatic exposure is what hides it. 0.9 lux is a heavily overcast sky, and a scene lit by it looks correct only because auto-exposure opens up to meet it. Two consequences: the frame’s brightness follows whatever is on screen, so it shifts when something bright enters the view, and turning auto-exposure off leaves the scene at dusk. Every example on this site sets the sun to 6 lux and turns adaptation off:

await EngineHarness.buildLights({ engine, sunIntensity: 6 });

// or, after buildBasics has already made one:
engine.entityManager.dataset.traverseEntities([Light], (light) => {
    if (light.type.getValue() === LightType.DIRECTION) {
        light.intensity.set(6);
    }
});

engine.graphics.renderer.feature_automatic_exposure_enabled = false;

6 is not a physical daylight figure - it is what looks right against the default environment, which is also carrying the indirect light. A scene under a real sky HDR wants a sun that matches that sky instead, and a scene whose ground is near-white wants the same sun and a stop of negative exposure compensation rather than a dimmer light.

The stale-light trap

LightCollection bumps its version when a light is added or removed. Mutating a light in place does not. The GPU light table is only rebuilt when the version moves, so a light that has been recoloured, re-aimed or dimmed keeps rendering with the values it was uploaded with. Nothing about this is visible from the CPU: the light object holds the new value, every read-back is correct, and only the picture is stale.

scene.lights.needsUpdate = true is what says otherwise.

LightSystem does this for you. It listens for TRANSFORM64_EVENT_CHANGE on the entity and binds the component’s color, intensity, castShadow, angle, penumbra, distance and radius, flipping needsUpdate on any of them changing. Moving a light is therefore the one part you still have to say out loud: write the transform, then t64_announce_change(ecd, entity). If you hold a Shade light directly, needsUpdate is yours to remember:

const scene = EngineHarness.shadeScene(engine);
const sun = scene.lights.elements[0];

sun.intensity = 4.0;
scene.lights.needsUpdate = true;   // without this the GPU keeps the old value

Shade’s light objects

Working below the ECS - a standalone Shade scene, or an entity whose light you want to reach through - these are the types. All named exports.

ExportModule
LightCollectionshade/renderer/light/LightCollection.js
make_sunlightshade/renderer/light/make_sunlight.js
Lightshade/renderer/light/model/Light.js
DirectionalLightshade/renderer/light/model/DirectionalLight.js
PointLightshade/renderer/light/model/PointLight.js
SpotLightshade/renderer/light/model/SpotLight.js

A Shade Light extends Node3D. It is placed and aimed the same way a mesh is - write transform_local, then call updateMatrices() - and the engine’s convention is that a light shines along its forward. Scene.add routes anything tagged isLight into scene.lights on its own; there is no separate registration call.

FieldDefaultMeaning
colorwhiteChromaticity, linear Rec.709
intensity1Candelas (point, spot) or lux (directional)
radius0Physical size of the emitter. Feeds the area-light shading term for every type, and the penumbra width of point-light shadows
near_clip_distance0Pushes the shadow near plane out, so a light inside a lamp or lantern mesh is not shadowed by its own housing
casts_shadowtrueNote this defaults the opposite way to the ECS component’s castShadow

Per subclass:

  • DirectionalLight adds nothing. Its direction is entirely the transform’s forward.
  • PointLight adds distance = 1 and an intensity_lumens accessor (cd = lm / 4π).
  • SpotLight adds distance = FLOAT32_MAX, angle = Math.PI / 3 (a half-angle, limit π/2), penumbra = 0, and an intensity_lumens accessor using cd = lm / (2π(1 − cos θ)). Because θ is the current angle, reads and writes track it: narrowing the cone at a fixed lumens value raises on-axis intensity.

There are no light cookies - no projected texture on a spot light, in any form.

make_sunlight

import { make_sunlight } from "@woosh/meep-engine/src/shade/renderer/light/make_sunlight.js";

scene.lights.add(make_sunlight({ intensity: 2.2 }));

Defaults: intensity = 2.2, direction = Vector3(-0.2, -1, 0.2), temperature = 5500 K. It sets casts_shadow = true and radius = 0.006475, the sun’s angular radius seen from Earth, so the area-light term gives specular highlights the right size.

Why color carries no brightness

At upload, light_pack_color divides the colour by its own Rec.709 luminance and multiplies by intensity, so the triplet the GPU sees always has luminance exactly intensity whichever RGB was picked. Without that, a saturated blue would deliver roughly a tenth the luminance of a saturated green at the same nominal intensity, because the RGB-to-luminance weights are lopsided - and “intensity in candelas” would not mean anything. A colour whose luminance is below 1e-6 is treated as off rather than scaled to infinity.

Froxel clustering

Local lights are binned on the GPU each frame: a frustum and Hi-Z cull pass (graph_cull_lights) narrows the set, then graph_build_light_clusters and shader_cluster_assign_lights write per-cluster light lists that the deferred pass loops over. All of it lives in shade/renderer/light/cluster/ and none of it is configurable from outside; there is nothing to register or size.

The shape of the grid is fixed by three exported constants:

ConstantValueModule
LIGHT_CLUSTER_TILE_RESOLUTION32 pxlight/cluster/LIGHT_CLUSTER_RESOLUTION.js
LIGHT_CLUSTER_RESOLUTION_Z24 sliceslight/cluster/LIGHT_CLUSTER_RESOLUTION.js
LIGHT_CLUSTER_LOCAL_LIGHT_LIMIT128 lights per clusterlight/cluster/LIGHT_CLUSTER_LOCAL_LIGHT_LIMIT.js

Only point and spot lights are clustered - CLUSTER_LIGHT_TYPE has exactly the two members, and directional lights are deliberately absent because they touch every cluster anyway.

Shadows

renderer.feature_shadows_enabled (default true) is the master switch. Turning it off evicts every shadow map and clears the cast-shadow bit on every light record, so lights keep lighting and stop occluding. It is one of the plain booleans on the renderer catalogued under frame settings.

Directional: cascaded shadow maps

Directional lights use CSM with SHADOWMAP_CSM_CASCADE_COUNT = 3 cascades. Default per-cascade resolutions are [1740, 1440], with the last entry repeating - so cascade 0 is 1740², cascades 1 and 2 are 1440². The cascade blend fraction (SHADOWMAP_CSM_CASCADE_BLEND_FRACTION) is deliberately 0: expanding a cascade’s extents to guarantee overlap would cost texel density on every cascade all the time, to smooth a transition that is rarely visible. The shader still blends opportunistically over its border band wherever the next cascade happens to reach; where it does not, a hard cut is accepted.

Local lights: one shared atlas

Every shadow map - the three directional cascades included - is packed into a single 8192² depth atlas (4096 * 2) by a max-rectangles packer. A spot light takes one perspective view and one rect. A point light renders six cube faces and remaps them into a single octahedrally-encoded rect, with a SHADOWMAP_ATLAS_BORDER = 4 texel wrap skirt on each side so the filter kernel can cross an octahedral seam without bleeding into a neighbour; the usable inner surface is the slot side minus 8.

Local slots start at 128² and are then resized every frame by ShadowmapResolutionPolicy, which derives a desired power-of-two side from the light’s projected on-screen area:

  • Sides are clamped to [32, 1024]. Below 32 a shadow is too blocky to be worth drawing; above 1024 one light could monopolise the atlas.
  • Single-step resizes have a 3-frame cooldown and 0.2 log2 of hysteresis, so a light sitting on a power-of-two boundary does not flip-flop between sizes. Jumps of two steps or more (a camera cut, a sudden zoom) bypass the cooldown.
  • A global drop scale shrinks every light’s request by 10% per frame once atlas occupancy passes 0.85, and grows back once it falls below 0.50. The gap between the two is a deadband.

The refresh budget

Not every map is redrawn every frame. The per-frame budget is 8 views, where a spot light costs 1 view, a directional 3 (one per cascade), and a point light 6 (one per cube face) - so the cost of the refresh step tracks the rasterization work being asked for rather than the light count. Directional lights always refresh and charge against the pool, which reduces what is left for local lights that frame. Unused budget carries over between frames, so a point light that cannot fit in one frame’s remainder eventually gets its turn instead of starving.

Filtering

There are no variance shadow maps. What runs:

Light typeFilter
DirectionalPer-cascade 5-tap PCF over hardware comparison-gather taps (shadowmap_sample_5 on textureGatherCompare), with CSM cascade blending on top
SpotThe same 5-tap PCF, against a single perspective view
Point8 cone-distributed taps around the receiver-to-light vector, each re-encoded to the light’s octahedral map, resolved through a contact-hardening DPCF kernel. Tap directions come from the calling shader’s per-invocation random state, so neighbouring pixels decorrelate without a rotation table

Bias is applied as a world-space offset toward the light - a normal-offset term plus a slope-scaled depth term, both scaled by the world size of one shadow texel - rather than as a post-projection constant, so it stays stable as a cascade’s depth range grows.

PCSS and EVSM code is present in the tree (shadow/map/shader/pcss/, shadow/evsm/) but nothing imports it. Neither is part of the shipping frame; do not plan around them.

Tetrahedral point shadows

renderer.feature_tetrahedron_point_shadows (default false) swaps the six-face cube generator for a four-face tetrahedral one. Both remap into the same octahedral atlas encoding, so the sampler is identical and the flag is safe to flip at runtime - existing point maps are evicted and rebuilt on the next light pass. It exists for A/B comparison of the two generators, not as a quality setting.

No per-mesh shadow toggle

ShadedGeometryFlags.CastShadow and ReceiveShadow exist on the ShadedGeometry component and are part of its default flag set, but nothing in the render path reads them. Every instance in the scene is a potential caster and a receiver. See Meshes & materials.

ReSTIR DI

renderer.feature_restir_di_enabled (default false) turns on stochastic screen-bounded direct shadowing for local point and spot lights - an Unreal MegaLights-style path. When it is on, the deferred pass skips its froxel cluster loop entirely and consumes the ReSTIR-resolved radiance instead. Each pixel draws RIS candidate lights from its froxel cluster, reuses a reprojected reservoir from the previous frame, and resolves visibility with an optional screen-space contact trace before falling back to a traced ray.

const renderer = engine.graphics.renderer;   // null until the engine has started

renderer.feature_restir_di_enabled = true;
renderer.restir_di.m_initial = 16;
Field on renderer.restir_diDefaultMeaning
m_initial8Initial RIS candidate lights drawn per pixel
use_screen_space_shadowtrueTry a short screen-space contact trace before the ray-traced fallback; false is pure ray-traced visibility
denoise_enabledtrueRun the edge-aware à-trous denoiser on the resolved radiance
denoise_iterations3À-trous iteration count (step sizes 1, 2, 4, …); wider and smoother, and more expensive

The tuning object is ReSTIRDI, shade/renderer/restir/di/ReSTIRDI.js. It is created during renderer initialization, so read it after the engine has started.

Where to go next