Most 2D projects that could have shadows ship without them, and the reason is bookkeeping rather than technique: an engine that asks you to draw a silhouette per object is asking for work nobody budgeted. @codexo/exojs-lighting starts from the opposite end. A light is a scene node, so a torch parents to the player and follows it. A surface gets its normals from its own silhouette, so art that was never authored for lighting still reacts to it. And what blocks light is read out of a description you already have — physics colliders, a tile layer, a sprite’s alpha.
Note: lighting ships as an official ExoJS extension package. Install it alongside the core:
npm install @codexo/exojs @codexo/exojs-lighting
There is no extension to register. A lighting system is an ordinary system you add to a scene.
Setup
import { Color, Scene } from '@codexo/exojs';import { LightmapLighting } from '@codexo/exojs-lighting';class CaveScene extends Scene { private lighting = new LightmapLighting(this.app, { ambient: new Color(20, 22, 34) }); override init(): void { this.systems.add(this.lighting); }}
ambient is the baseline every lit fragment receives regardless of any light — 255 per channel means “unlit areas keep their full albedo”, and a dark blue is the usual night. It is read every frame, so fading from day to night is one tween on a colour.
Register the system with the registry that ticks after the code moving your lights. app.systems runs its update phase before the active scene’s, so a system registered there sees lights the scene has not moved yet; scene.systems is usually what you want.
Three renderers, one vocabulary
The scene describes what emits and what blocks. Which system you construct decides how that becomes pixels, and nothing else changes between them — the same lights, the same occluders.
ForwardLighting
LightmapLighting
RadianceLighting
Where light is computed
inside the sprite fragment stage
in a target of its own, multiplied over the frame
the same target, filled by transporting radiance
Normal mapping
per material, on LitMaterial
per drawable, through a prepass
no
Shadows
no
yes, soft, from registered occluder sources
yes, with a penumbra that follows the source’s size
Light count
capped by maxLights (default 64)
uncapped
uncapped, and free: the cost is per probe
Extra passes
none
two, a third with normals
four to nine, depending on the view
Cost per light
a loop iteration per lit fragment
the fill of its own radius
none — the field costs what the screen costs
LightmapLighting and RadianceLighting light the frame the application drew, so the application is their first argument: they read its frame, install their passes in its frame slot, and follow its surface when it resizes. ForwardLighting shades inside the sprite stage, so it is the one that can be built without one — new ForwardLighting({ maxLights: 16 }).
Whichever you built, the running renderer answers for itself, which is what a status line or a debug overlay reads:
Lighting is the base the three share, so it is the type to take when a function accepts any of them. Pick the class whose properties the scene needs — ForwardLighting for normal maps on a LitMaterial, LightmapLighting for shadows and an uncapped light count, RadianceLighting for light that spreads.
Light that spreads
RadianceLighting fills the same light field from a chain of radiance cascades. Light propagates from what emits instead of falling off inside each light’s radius, so a lamp lights the room it stands in, a wall between two rooms leaves the second one dark, and a source with a size casts a penumbra that widens with distance.
import { Color } from '@codexo/exojs';import { PointLight, RadianceLighting } from '@codexo/exojs-lighting';import type { Application } from '@codexo/exojs';declare const app: Application;const lighting = new RadianceLighting(app, { ambient: new Color(8, 8, 14) });// Under `radiance` a light's `softness` sets the SIZE of the source, which is// what decides how soft the shadows it casts are.lighting.add(new PointLight({ radius: 300, intensity: 3, softness: 0.4 }));
Importing the class is what links the cascades, so a bundle that never constructs one never carries them. Its tuning sits beside the rest — probeSpacing, cascades, interval, all optional and all derived from the surface by default.
It needs a device that can render into float targets and is refused at construction where it cannot run, so you never get it by accident.
Lights are nodes
A light is a RenderNode that emits rather than draws. It inherits the transform, parents to whatever carries it, and every field is an ordinary property — so the engine’s tweens animate a light with no lighting-specific animation concept.
import { Color } from '@codexo/exojs';import { Lighting, PointLight } from '@codexo/exojs-lighting';import type { Application, Container } from '@codexo/exojs';declare const app: Application;declare const lighting: Lighting;declare const player: Container;const torch = lighting.add(new PointLight({ radius: 320, color: new Color(255, 180, 120), intensity: 1.4 }));player.addChild(torch);// A flicker is an ordinary tween on an ordinary property - there is no// lighting-specific animation concept to learn.app.tweens.create(torch).to({ intensity: 1.8 }, 0.4).start();
add returns the light, so creating, parenting and registering it is one expression. Registering the same light twice shades it once; destroying a registered light unregisters it.
Four shapes:
import { Color } from '@codexo/exojs';import { Lighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs-lighting';declare const lighting: Lighting;// Equal in every direction, falling off to nothing at its radius.lighting.add(new PointLight({ radius: 260 }));// A cone along the node's own rotation - aiming a spot is rotating it.lighting.add(new SpotLight({ radius: 400, angle: 35, coneSoftness: 0.3 }));// A segment: falloff is measured from the nearest point on it, so the pool of// light is a capsule. Neon tubes, light strips, lasers.lighting.add(new LineLight({ length: 120, radius: 160, color: new Color(120, 200, 255) }));// A direction and no position. Reaches everything the camera can see, falls off// nowhere, and its shadows are parallel.lighting.add(new SunLight({ intensity: 0.8 })).setRotation(-35);
A line light’s radius is the distance from the segment, so it reaches length / 2 + radius along its own axis and radius across it. A sun’s height is a slope rather than a length, because a source at no particular distance has no other meaning for one.
Shapes are deliberately not extensible. A shape is instance data a light-pass shader evaluates, and opening it up means either exposing that shader’s structure or accepting a draw call per shape.
Cookies
Every light takes an optional cookie texture — the cheapest large visual win here.
import type { Texture } from '@codexo/exojs';import { Lighting, PointLight } from '@codexo/exojs-lighting';declare const lighting: Lighting;declare const windowCross: Texture;lighting.add(new PointLight({ radius: 320, cookie: windowCross }));
The texture’s full 0..1 maps onto the light’s own bounding square, so the pattern turns with a cone light and scales with the radius — it is fixed to the lamp, not to the world. It is multiplied into the light, so a transparent part of the cookie casts nothing and an opaque white one changes nothing.
Lights sharing a cookie share a draw. A scene with three distinct cookies costs three draws rather than one — still one draw per texture, never one per light. forward ignores cookies: it shades inside the sprite stage, where a texture per light cannot be reached in one draw.
Normals nobody has to author
Most 2D projects have no normal maps, and a lighting system that looks bad without them is a lighting system nobody switches on. So normals are an upgrade, never an entry fee: a surface without them is lit as a plane rather than left black.
Where they come from depends on which renderer is shading.
Under forward, they are a material binding:
import type { Sprite, Texture } from '@codexo/exojs';import { AlphaNormals, Lighting, LitMaterial, NormalMap } from '@codexo/exojs-lighting';declare const lighting: Lighting;declare const crate: Sprite;declare const hero: Sprite;declare const heroNormals: Texture;declare const crateTexture: Texture;// Lit as a plane - no map, and not black.crate.material = new LitMaterial({ lighting });// An authored tangent-space map.hero.material = new LitMaterial({ lighting, normals: new NormalMap(heroNormals) });// Derived from the texture's own alpha, once at load.crate.material = new LitMaterial({ lighting, normals: new AlphaNormals(crateTexture) });
Every sprite drawn with a given material shares its map — in practice one material per atlas — and the map must have the same layout as the albedo atlas, frame for frame. Rotation and mirroring are handled in the shader.
The canonical input convention is OpenGL: green above the midpoint means the normal leans towards the top of the image, blue points out of the sprite plane, and a flat texel is (128, 128, 255). This is ExoJS’s own choice, not a universal standard: most authoring tools can write either convention and several — Substance’s mesh bakers among them — default to DirectX, so check what your exporter is set to. A map authored the other way up is declared rather than edited:
import type { Texture } from '@codexo/exojs';import { NormalMap } from '@codexo/exojs-lighting';declare const fromMax: Texture;const normals = new NormalMap(fromMax, { convention: 'directx' });
The setting travels with the source into both the forward shader and the lightmap prepass, and costs no texture copy and no per-frame readback. There is no auto-detection and no backend-dependent default.
Under lightmap there is no per-fragment surface to bind to: the renderer multiplies a frame that was already drawn. A normal prepass puts one back. Register a drawable and the renderer draws its normal map, at the drawable’s own place and orientation, into one attachment the light shader then reads:
import type { Sprite, Texture } from '@codexo/exojs';import { Lighting, NormalMap } from '@codexo/exojs-lighting';declare const lighting: Lighting;declare const crate: Sprite;declare const crateNormals: Texture;lighting.normalsFrom(crate, new NormalMap(crateNormals));
The drawable’s own texture supplies that coverage, so a silhouette claims a surface and the empty corners of its quad do not. What it inherits from every screen-space normal buffer: one normal per pixel, so overlapping surfaces resolve to the topmost.
Shadows you do not model
Nothing in this package has a castsShadow flag. A flag on a drawable would put lighting vocabulary on a class with no lighting concern, and it would tie the shadow silhouette to the sprite’s shape — which is wrong often enough that a tree casts the shadow of its trunk, not of its canopy.
Instead, what blocks light is a source, and the useful sources read descriptions you already have:
import type { Sprite } from '@codexo/exojs';import { AlphaOccluder, Lighting, PhysicsOccluder, PolygonOccluder, TilemapOccluder } from '@codexo/exojs-lighting';import type { OccluderPhysicsWorld, OccluderTileLayer } from '@codexo/exojs-lighting';declare const lighting: Lighting;declare const world: OccluderPhysicsWorld;declare const walls: OccluderTileLayer<unknown>;declare const tree: Sprite;// You already have colliders, so you already have shadows.lighting.occludeFrom(new PhysicsOccluder(world, { staticOnly: true }));// Follows the chunk streamer, so an infinite map streams its shadows.lighting.occludeFrom(new TilemapOccluder(walls));// A sprite's own silhouette, traced once at load.lighting.occludeFrom(new AlphaOccluder(tree));// The escape hatch, and the right answer whenever the shadow outline is not// the drawn one.lighting.occludeFrom( new PolygonOccluder([ { x: 0, y: 0 }, { x: 64, y: 0 }, { x: 64, y: 96 }, ]),);
PhysicsOccluder and TilemapOccluder take structurally typed arguments, so this package depends on neither the physics nor the tilemap package: a project without them pulls in nothing, and a project with a collision layer of its own can feed shadows from that instead.
softness is a property of the light, in 0..1, and the two renderers mean different things by it. Under lightmap it is filter width: the light stays a point and the shadow term is averaged over a band of its angular shadow row, up to three percent of a full turn. The edge widens, but it widens with distance from the light rather than from the wall, and it does not behave like a shadow cast by a source of that size. Under radiance it is source size: the emitter is given a width, and the penumbra follows from the geometry — it grows with the distance between the wall and what the shadow falls on.
Neither adds a pass. Under lightmap the filter samples every bin under its kernel and spends between 7 and 23 texture fetches per shadowed fragment doing it, which also bounds the kernel at ten bins either side — three percent of a turn at the default shadowResolution, and proportionally less as that rises.
How a shadow is computed
Every light gets one row of a shadow map. For a point, cone or line light that row is polar: for each of shadowResolution angular bins around the light, the distance to the nearest occluding edge. A sun has no centre to measure angles from, so its row is linear: one bin per strip across the light’s direction, holding how far along the light the nearest occluder in that strip sits.
The rows are built on the CPU from the segments the sources collected and uploaded as one texture; the light shader turns a fragment’s own direction into a bin and compares. That shape is chosen so the lights stay in a single instanced draw — a shadow pass per light would break the batch the renderer exists for.
Emission
A LitMaterial takes an emissive multiplier: how much light the surface emits of its own, as a multiple of its albedo.
import type { Sprite } from '@codexo/exojs';import { Lighting, LitMaterial } from '@codexo/exojs-lighting';declare const lighting: Lighting;declare const lava: Sprite;lava.material = new LitMaterial({ lighting, emissive: 2.4 });
It is added to the light term rather than to the colour, so emission scales the albedo the way a light does — a black pixel emits nothing however high it is set, and a transparent one stays transparent instead of glowing through its own alpha. Values above 1 push the surface past what a light could produce, which is what a post filter keyed on a threshold is there to catch.
Filters over the shaded frame
post is a filter chain over what the system produced, run as one pass in app.framePasses. A bloom belongs here rather than on a node, because it reads the light the system accumulated — including the parts no single node drew.
import { BloomFilter, type Application } from '@codexo/exojs';import { Lighting } from '@codexo/exojs-lighting';declare const app: Application;const lighting = new LightmapLighting(app, { post: [new BloomFilter({ threshold: 0.9 })] });
It needs app in either renderer, and a chain passed without one is refused at construction rather than quietly ignored. Under lightmap the composite writes an off-screen target in the light target’s own format and the chain reads that, so a threshold above 1.0 still has something to find — the light target is rgba16f wherever one can be rendered into, and lighting.hdr reports whether it is.
Seeing what the renderer sees
import { Lighting } from '@codexo/exojs-lighting';declare const lighting: Lighting;lighting.debug = 'light'; // the accumulated light field, without the scene's colourslighting.debug = 'normals'; // the prepass normals, encoded the way a normal map islighting.debug = 'occluders'; // the silhouettes the sources collected, over the scenelighting.debug = 'mask'; // the same silhouettes rasterised at the light field's own resolutionlighting.debug = null;
mask is the one the GPU-resident paths read: it says whether a wall is thick enough to be seen at the light field’s own resolution, which is what a cascade ray samples where an occluder is a drawable rather than an outline.
The occluders view is the one that explains the feature: it draws what the shadows are actually being cast from, which is usually the fastest way to find out that a source is reading the wrong thing.
Cost
Forward lighting costs fragments x active lights. With everything on screen lit and many overlapping lights, the fragment stage becomes the bottleneck well before the CPU does — measure before raising maxLights into the dozens on a full-screen scene.
The lightmap renderer costs the fill of each light’s own radius, plus two full-screen passes and a third when normals are registered. lightResolution (default 0.5) sets the light target’s density: light is low-frequency, so half resolution is hard to tell apart and costs a quarter of the fill.
The radiance renderer costs neither of those: it costs the probe grid. Every level of the chain holds the same number of texels, and the number of levels follows the view’s own diagonal, so the whole field is between four and nine full-screen-ish passes however many lights are in it. probeSpacing (default 2 light-field texels) is the knob that moves that cost, and halving it quadruples the grid.
Shadows cost the visible occluding edges times the lights that can see them, per frame. That is bounded by collecting only the region the visible lights jointly reach, by emitting only boundary edges — a hundred-tile corridor is four segments, not four hundred — and by caching whatever does not change.
import { Application, Color, Container, FixedResolutionCanvasSizing, type RenderingContext, ScaleModes, Scene, type Seconds, Sprite, Texture } from '@codexo/exojs';
import { AlphaOccluder, Lighting, LightmapLighting, PointLight, SpotLight } from '@codexo/exojs-lighting';
import { mountControlPanel, mountControls } from '@examples/runtime';
// Nothing here models a shadow. Each wall registers the outline it already
// has - its own rectangle, or, for the pillar, the silhouette traced out of
// its alpha channel - and the lightmap renderer turns that into a shadow for
// every light on screen, in one instanced draw.
//
// `softness` is a property of the light, not a second pass: it widens the
// shadow sample kernel, so the slider below costs nothing per light.
const canvasTexture = (width: number, height: number, paint: (context: CanvasRenderingContext2D) => void): Texture => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (context === null) throw new Error('2D canvas context unavailable.');
paint(context);
return new Texture(canvas, { scaleMode: ScaleModes.Linear, generateMipMap: false });
};
const floorTexture = canvasTexture(64, 64, context => {
context.fillStyle = '#b3aea5';
context.fillRect(0, 0, 64, 64);
context.fillStyle = '#a19c94';
context.fillRect(0, 0, 32, 32);
context.fillRect(32, 32, 32, 32);
});
const wallTexture = canvasTexture(8, 8, context => {
context.fillStyle = '#cfc9be';
context.fillRect(0, 0, 8, 8);
});
// A cross, so the traced outline is visibly NOT the sprite's bounding box.
const pillarSize = 96;
const pillarTexture = canvasTexture(pillarSize, pillarSize, context => {
const arm = pillarSize / 3;
context.fillStyle = '#d8cbb0';
context.fillRect(arm, 0, arm, pillarSize);
context.fillRect(0, arm, pillarSize, arm);
});
const wall = (x: number, y: number, width: number, height: number): Sprite => {
const sprite = new Sprite(wallTexture).setAnchor(0.5);
sprite.width = width;
sprite.height = height;
sprite.setPosition(x, y);
sprite.tint = new Color(150, 146, 138);
return sprite;
};
class ShadowCastersScene extends Scene {
private world!: Container;
private lighting!: Lighting;
private torch!: PointLight;
private beam!: SpotLight;
private turntable!: Sprite;
private elapsed = 0;
private hud!: ReturnType<typeof mountControls>;
override init(): void {
const { width, height } = this.app;
this.world = new Container();
this.lighting = new LightmapLighting(this.app, {
ambient: new Color(34, 36, 50),
lightResolution: 1,
});
this.systems.add(this.lighting);
const floor = new Sprite(floorTexture);
floor.width = width;
floor.height = height;
this.world.addChild(floor);
// Level geometry. Every wall is an opaque sprite, so its silhouette is the
// rectangle it is drawn as - there is nothing to author, and the outline
// follows the sprite however it is sized, moved or turned.
const walls = [wall(320, 200, 360, 28), wall(940, 250, 28, 320), wall(520, 560, 300, 28)];
this.turntable = wall(880, 560, 220, 24);
walls.push(this.turntable);
for (const piece of walls) {
this.world.addChild(piece);
this.lighting.occludeFrom(new AlphaOccluder(piece));
}
// The cross is the same one line, and the same nothing to author: what
// differs is that its silhouette is a cross rather than its bounding box.
const pillar = new Sprite(pillarTexture).setAnchor(0.5).setPosition(640, 380);
this.world.addChild(pillar);
this.lighting.occludeFrom(new AlphaOccluder(pillar));
this.torch = this.lighting.add(new PointLight({ radius: 520, intensity: 2.1, softness: 0.35, color: new Color(255, 196, 140) }));
this.beam = this.lighting.add(
new SpotLight({ radius: 760, angle: 28, coneSoftness: 0.35, intensity: 2.3, softness: 0.2, color: new Color(150, 210, 255) }),
);
this.beam.setPosition(120, 660);
this.hud = mountControls({
title: 'Shadow Casters',
hint: 'No object declares that it casts a shadow, and nothing here authors an outline: every caster is one line handing the lighting system the sprite it already draws.',
status: '',
});
const panel = mountControlPanel({ title: 'Shadows', corner: 'top-right' });
panel.addSlider({
label: 'Softness',
min: 0,
max: 1,
step: 0.05,
value: this.torch.softness,
onChange: value => {
this.torch.softness = value;
this.beam.softness = value;
},
});
panel.addToggle({
label: 'Show occluders',
value: false,
onChange: value => {
this.lighting.debug = value ? 'occluders' : null;
},
});
}
override update(delta: Seconds): void {
this.elapsed += delta;
this.torch.setPosition(640 + Math.cos(this.elapsed * 0.55) * 250, 360 + Math.sin(this.elapsed * 0.83) * 150);
// Aiming a spot is rotating it, so the beam sweeps by turning its node.
// Counter-clockwise from +x: up the screen is a POSITIVE angle, however
// far down the screen the world's y grows.
this.beam.rotation = 55 - Math.sin(this.elapsed * 0.4) * 35;
// A moving occluder needs no bookkeeping: the outline is local to the node.
this.turntable.rotation = this.elapsed * 22;
}
override draw(context: RenderingContext): void {
context.render(this.world);
this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`);
}
}
const app = new Application({
scenes: { ShadowCastersScene },
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizing: new FixedResolutionCanvasSizing(),
},
clearColor: new Color(6, 7, 12),
});
await app.start(ShadowCastersScene);
Walls that hand over the rectangle they are drawn as, a cross whose outline is traced from its alpha, and a softness slider — with nothing in the scene declaring that it casts a shadow.
import { Application, Color, Container, FixedResolutionCanvasSizing, type RenderingContext, RepeatingSprite, ScaleModes, Scene, type Seconds, Sprite, Texture } from '@codexo/exojs';
import { AlphaOccluder, type Lighting, type LightingDebugView, LightmapLighting, PointLight, PolygonOccluder, RadianceLighting } from '@codexo/exojs-lighting';
import { mountControlPanel, mountControls } from '@examples/runtime';
// Two rooms, one doorway, one lamp - and a switch between the renderer that
// draws a light and the one that transports it.
//
// Under `lightmap` a light is a pool with an edge: it reaches its radius and
// stops, and the wall carves a shadow out of that pool. Under `radiance` the
// lamp fills the room it stands in, the wall leaves the far room dark, and what
// comes through the doorway is a wedge that widens - because nothing is being
// drawn around the light at all. What the field holds is where light ARRIVES.
//
// The second thing to watch is what `softness` means, because it is not the
// same quantity in the two renderers:
//
// - Under `radiance` it is the SOURCE SIZE. The lamp becomes an emitter with a
// width, so every shadow in the scene softens at once and each penumbra
// grows with the distance from the wall that casts it, the way a real one
// does.
// - Under `lightmap` it is FILTER WIDTH. The lamp is still a point; the shadow
// term is blurred across a fixed fraction of a turn around it, so the
// penumbra widens with the distance from the LIGHT rather than from the
// wall, and no shadow ever behaves like one cast by an area source.
//
// The third thing to watch is the BOUNCE, which is what the red panel beside
// the doorway is for. The walls are outlines: they block, and an outline has
// no material to give anything back with. The panel is a drawable the camera
// paints, so the cascades read its own colour where a ray ends on it - switch
// the bounce off and the floor in front of it goes neutral.
//
// The panel controls pause the motion, place the lamp at a reproducible point
// on its own path, switch the bounce off, and show the intermediate fields, so
// two renderers can be compared at the same instant instead of by eye while
// everything moves.
const canvasTexture = (size: number, paint: (context: CanvasRenderingContext2D) => void): Texture => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (context === null) throw new Error('2D canvas context unavailable.');
paint(context);
return new Texture(canvas, { scaleMode: ScaleModes.Linear, generateMipMap: false });
};
const floorTexture = canvasTexture(64, context => {
context.fillStyle = '#3b3a36';
context.fillRect(0, 0, 64, 64);
context.fillStyle = '#343330';
context.fillRect(0, 0, 32, 32);
context.fillRect(32, 32, 32, 32);
});
const stoneTexture = canvasTexture(8, context => {
context.fillStyle = '#726a5e';
context.fillRect(0, 0, 8, 8);
});
// Saturated and bright on purpose: what a surface gives back is its own colour
// times what fell on it, so a dark or grey panel returns either nothing worth
// seeing or the lamp's own light again.
const panelTexture = canvasTexture(8, context => {
context.fillStyle = '#ff3b2f';
context.fillRect(0, 0, 8, 8);
});
// A mid neutral, where the rest of the floor is dark stone. The bounce is
// multiplied by whatever colour the receiver already has, so a dark floor
// shows a correct bounce as nothing at all - and a near-white one shows it as
// a wash the direct light saturates anyway.
const apronTexture = canvasTexture(8, context => {
context.fillStyle = '#8d8a82';
context.fillRect(0, 0, 8, 8);
});
interface Wall {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
// A doorway in the middle wall, two pillars in the far room to catch whatever
// comes through it, and one block beside the lamp so the near room has a
// shadow of its own to compare against.
const walls: readonly Wall[] = [
{ x: 640, y: 170, width: 34, height: 300 },
{ x: 640, y: 570, width: 34, height: 260 },
{ x: 880, y: 250, width: 36, height: 36 },
{ x: 1010, y: 470, width: 36, height: 36 },
{ x: 300, y: 560, width: 150, height: 34 },
];
/**
* The one surface in the scene with a material: a slab across the lamp's side
* of the doorway, drawn by the camera and occluding as coverage rather than as
* an outline, so what falls on it comes back off it in its own colour.
*/
const bouncePanel: Wall = { x: 452, y: 400, width: 26, height: 230 };
/**
* The floor the panel gives its colour back onto: on the lamp's side of it,
* which is the side a surface re-emits from, and far enough from the lamp that
* the direct term does not saturate the tint away.
*/
const bounceApron: Wall = { x: 360, y: 400, width: 150, height: 250 };
/** A wall's own box, as the four corners an occluder takes. */
const outline = (wall: Wall): readonly { x: number; y: number }[] => {
const halfWidth = wall.width / 2;
const halfHeight = wall.height / 2;
return [
{ x: wall.x - halfWidth, y: wall.y - halfHeight },
{ x: wall.x + halfWidth, y: wall.y - halfHeight },
{ x: wall.x + halfWidth, y: wall.y + halfHeight },
{ x: wall.x - halfWidth, y: wall.y + halfHeight },
];
};
/**
* What the bounce toggle switches on, rather than the renderer's own default
* of `0.5`. The panel returns its colour once, across a room, onto a floor the
* lamp already lights directly; at the default the tint is a couple of counts
* and the toggle reads as doing nothing.
*/
const bounceFactor = 0.9;
/** Levels the debug cycle walks, in the order it walks them. */
const debugViews: readonly LightingDebugView[] = [null, 'light', 'mask', 'occluders'];
/** Texels of light field per logical pixel. Stated here so the panel can show what was actually run at. */
const lightResolution = 1;
/**
* The lamp's own path, as a function of a phase in seconds. One expression, so
* the paused slider and the running clock place it identically.
*/
const lampAt = (phase: number): { x: number; y: number } => ({
x: 340 + Math.sin(phase * 0.3) * 120,
y: 360 + Math.cos(phase * 0.22) * 150,
});
/**
* Seconds for one full loop of BOTH terms, so a phase slider covers the whole
* path and the same phase is always the same place. The two rates are 0.3 and
* 0.22, whose common period is `2 * pi` over their greatest common measure of
* 0.02 - not over their difference.
*/
const loopSeconds = (Math.PI * 2) / 0.02;
/** Where the status line reads the lamp's real position into. */
const lampPosition = { x: 0, y: 0 };
class RadianceRoomsScene extends Scene {
private world!: Container;
private panel!: Sprite;
private lighting!: Lighting;
private cascading = true;
private intensity = 3;
private softness = 0.35;
private bounce = true;
private moving = true;
private debug: LightingDebugView = null;
private elapsed = 0;
private hud!: ReturnType<typeof mountControls>;
private phaseControl!: { set(value: number): void };
override init(): void {
const { width, height } = this.app;
this.world = new Container();
// Repeated, not stretched: a 64px tile scaled to the whole canvas turns its
// own checker into two quadrant-sized blocks whose edges read as a defect
// in the light rather than as a floor.
const floor = new RepeatingSprite(floorTexture);
floor.width = width;
floor.height = height;
this.world.addChild(floor);
for (const wall of walls) {
const sprite = new Sprite(stoneTexture).setAnchor(0.5);
sprite.width = wall.width;
sprite.height = wall.height;
sprite.setPosition(wall.x, wall.y);
this.world.addChild(sprite);
}
const apron = new Sprite(apronTexture).setAnchor(0.5);
apron.width = bounceApron.width;
apron.height = bounceApron.height;
apron.setPosition(bounceApron.x, bounceApron.y);
this.world.addChild(apron);
this.panel = new Sprite(panelTexture).setAnchor(0.5);
this.panel.width = bouncePanel.width;
this.panel.height = bouncePanel.height;
this.panel.setPosition(bouncePanel.x, bouncePanel.y);
this.world.addChild(this.panel);
this.build();
this.hud = mountControls({
title: 'Radiance Rooms',
hint: 'One lamp, one doorway. Switch the renderer to see the difference between a light that is drawn and light that is transported - they are different transport models and will not agree pixel for pixel. Pause the motion and set a phase to compare the two at the same instant.',
status: '',
});
const panel = mountControlPanel({ title: 'Lighting', corner: 'top-right' });
panel.addToggle({
label: 'Radiance',
value: true,
onChange: value => {
// A renderer is chosen by constructing it, so switching means building
// a new system - which is all a system is here: it owns its passes and
// takes them out again on `destroy()`. Importing the class is also what
// links it, so a project that only ever builds one leaves the other
// out of its bundle.
this.cascading = value;
this.build();
},
});
panel.addToggle({
label: 'Bounce',
value: this.bounce,
onChange: value => {
this.bounce = value;
// Only the cascades bounce; under the quads the toggle has nothing to
// rebuild.
if (this.cascading) {
this.build();
}
},
});
panel.addToggle({
label: 'Motion',
value: this.moving,
onChange: value => {
this.moving = value;
},
});
this.phaseControl = panel.addSlider({
label: 'Phase',
min: 0,
max: 1,
// A step is a step along the PATH, and the path is `loopSeconds` long:
// at this rate one is about ten pixels of lamp, which is what makes the
// slider a way to place the lamp rather than to jump it across the room.
step: 0.001,
value: 0,
onChange: value => {
// Placing the lamp by hand is what makes a comparison reproducible:
// the same phase is the same position in either renderer, however long
// either has been running.
this.elapsed = value * loopSeconds;
this.moveLamp();
},
});
panel.addCycle({
label: 'Field',
options: ['shaded', 'light', 'mask', 'occluders'],
index: 0,
onChange: index => {
this.debug = debugViews[index] ?? null;
this.lighting.debug = this.debug;
},
});
panel.addSlider({
label: 'Lamp',
min: 0.5,
max: 6,
step: 0.1,
value: this.intensity,
onChange: value => {
this.intensity = value;
this.lamp.intensity = value;
},
});
panel.addSlider({
// Source size under radiance, filter width under lightmap. See the note
// at the top of the file: the two are not the same quantity, and the
// slider is labelled for neither so that the difference stays visible.
label: 'Softness',
min: 0,
max: 1,
step: 0.05,
value: this.softness,
onChange: value => {
this.softness = value;
this.lamp.softness = value;
},
});
}
override update(delta: Seconds): void {
if (!this.moving) {
return;
}
this.elapsed += delta;
this.phaseControl.set((this.elapsed % loopSeconds) / loopSeconds);
// Moving the lamp is the clearest way to see that nothing about the far
// room is baked: the wedge through the doorway sweeps with it.
this.moveLamp();
}
override draw(context: RenderingContext): void {
context.render(this.world);
// Read off the lamp itself, not recomputed from the clock: the status line
// is what says two renderers were compared under the same conditions, so
// it has to report where the light actually is.
this.lamp.getWorldPosition(lampPosition);
const bounce = this.cascading && this.bounce ? 'bounce' : 'no bounce';
this.hud.setStatus(
`${this.lighting.quality} - ${bounce} - ${this.app.width}x${this.app.height} at ${lightResolution}x - lamp ${lampPosition.x.toFixed(1)}, ${lampPosition.y.toFixed(1)} - draw calls ${context.stats.drawCalls}`,
);
}
private moveLamp(): void {
const { x, y } = lampAt(this.elapsed);
this.lamp.setPosition(x, y);
}
private get lamp(): PointLight {
return this.lighting.lights[0] as PointLight;
}
/** Build the lighting system for the renderer currently selected. */
private build(): void {
if (this.lighting !== undefined) {
this.systems.remove(this.lighting);
this.lighting.destroy();
}
const options = { ambient: new Color(10, 11, 16), lightResolution };
this.lighting = this.cascading
? new RadianceLighting(this.app, { ...options, bounce: this.bounce ? bounceFactor : 0 })
: new LightmapLighting(this.app, options);
this.lighting.debug = this.debug;
this.systems.add(this.lighting);
this.lighting.add(new PointLight({ radius: 600, intensity: this.intensity, softness: this.softness, color: new Color(255, 226, 180) }));
// Placed straight away rather than on the next tick: switching renderer
// while the motion is paused would otherwise leave the new lamp at the
// origin, and the A/B this scene exists for would compare two different
// scenes.
this.moveLamp();
for (const wall of walls) {
this.lighting.occludeFrom(new PolygonOccluder(outline(wall)));
}
// The panel is the drawable one, and only under the cascades: they take a
// drawable as the coverage it paints and read its colour back out of the
// frame, which is what a bounce is. The quads walk segments instead, so
// there the same slab is registered as the outline of its own box - it
// still casts, it just has nothing to give back.
if (this.cascading) {
this.lighting.occludeFrom(new AlphaOccluder(this.panel));
} else {
this.lighting.occludeFrom(new PolygonOccluder(outline(bouncePanel)));
}
}
}
const app = new Application({
scenes: { RadianceRoomsScene },
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizing: new FixedResolutionCanvasSizing(),
},
clearColor: new Color(3, 4, 7),
});
await app.start(RadianceRoomsScene);
One lamp, two rooms and a doorway, with a switch between lightmap and radiance on the same scene. Watch the far room: under the light quads it is whatever the lamp’s radius reaches minus a shadow, and under the cascades it is dark except for the wedge coming through the opening. The softness slider is the lamp’s own size under radiance — widen it and every penumbra in the scene widens with it — and the width of an angular filter under lightmap, which is not the same quantity. Pause the motion and set a phase to compare the two renderers at the same instant; they are different transport models and will not agree pixel for pixel.
import { Application, Color, Container, FixedResolutionCanvasSizing, type RenderingContext, ScaleModes, Scene, type Seconds, Sprite, Texture, WrapModes } from '@codexo/exojs';
import { AlphaOccluder, Lighting, LightmapLighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs-lighting';
import { mountControlPanel, mountControls } from '@examples/runtime';
// Four light shapes, one scene, and the shape of the light doing the work that
// a texture would otherwise have to do.
//
// A cookie is one texture slot on the light: its full 0..1 lies on the light's
// own bounding square, so the pattern turns with a cone and scales with a
// radius. Nothing is projected, nothing is authored per wall - the window bars
// below are a 128x128 canvas carried by the lamp that casts them.
//
// The sun is the one shape that is not a pool of light: it has a direction and
// no position, so its shadows are parallel and it reaches whatever the camera
// can see.
const canvasTexture = (size: number, paint: (context: CanvasRenderingContext2D) => void, wrap = WrapModes.ClampToEdge): Texture => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (context === null) throw new Error('2D canvas context unavailable.');
paint(context);
return new Texture(canvas, { scaleMode: ScaleModes.Linear, wrapMode: wrap, generateMipMap: false });
};
const floorTexture = canvasTexture(64, context => {
context.fillStyle = '#4a4740';
context.fillRect(0, 0, 64, 64);
context.fillStyle = '#413e38';
context.fillRect(0, 0, 32, 32);
context.fillRect(32, 32, 32, 32);
});
// A window: bars of shadow across an otherwise open pane. Transparent where the
// light is blocked, because a cookie multiplies rather than adds.
const windowCookie = canvasTexture(128, context => {
context.clearRect(0, 0, 128, 128);
context.fillStyle = '#ffffff';
context.fillRect(10, 10, 108, 108);
context.globalCompositeOperation = 'destination-out';
context.fillRect(60, 10, 8, 108);
context.fillRect(10, 60, 108, 8);
});
// Leaf shade for the spot: a scatter of holes, so the cone reads as light
// falling through a canopy rather than as a cone.
const canopyCookie = canvasTexture(128, context => {
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 128, 128);
context.globalCompositeOperation = 'destination-out';
for (let index = 0; index < 60; index++) {
const angle = index * 2.399963;
const distance = Math.sqrt(index / 60) * 58;
context.beginPath();
context.arc(64 + Math.cos(angle) * distance, 64 + Math.sin(angle) * distance, 5 + (index % 4) * 2.5, 0, Math.PI * 2);
context.fill();
}
});
const blockTexture = canvasTexture(8, context => {
context.fillStyle = '#6d675d';
context.fillRect(0, 0, 8, 8);
});
const block = (x: number, y: number, width: number, height: number): Sprite => {
const sprite = new Sprite(blockTexture).setAnchor(0.5);
sprite.width = width;
sprite.height = height;
sprite.setPosition(x, y);
return sprite;
};
class LightCookiesScene extends Scene {
private world!: Container;
private lighting!: Lighting;
private sun!: SunLight;
private window!: PointLight;
private canopy!: SpotLight;
private tube!: LineLight;
private elapsed = 0;
private hud!: ReturnType<typeof mountControls>;
override init(): void {
const { width, height } = this.app;
this.world = new Container();
this.lighting = new LightmapLighting(this.app, { ambient: new Color(16, 18, 28), lightResolution: 1 });
this.systems.add(this.lighting);
const floor = new Sprite(floorTexture);
floor.width = width;
floor.height = height;
this.world.addChild(floor);
// Two pillars, so the sun has something to throw a parallel shadow from.
for (const [x, y] of [
[430, 250],
[430, 470],
] as const) {
const pillar = block(x, y, 36, 130);
this.world.addChild(pillar);
// The pillar's own silhouette, taken from the pillar. A polygon placed by
// `{ node: pillar }` would be in the pillar's LOCAL space - eight texels
// across, because a sized sprite carries its size as a scale - so points
// written at the size it appears at come out scaled a second time.
this.lighting.occludeFrom(new AlphaOccluder(pillar));
}
// Directional: no position, no falloff, parallel shadows. It travels along
// the node's rotation, so the time of day below is one number.
this.sun = this.lighting.add(new SunLight({ intensity: 0.55, softness: 0.05, color: new Color(255, 236, 205) }));
this.sun.rotation = 20;
// A point light wearing a window. The bars are the cookie, not geometry -
// nothing in the scene knows they exist.
//
// A cookie is a mask the LIGHT carries, so it turns, scales and travels
// with the light. This one drifts, which is what shows that: the bars move
// with the lamp rather than staying on the floor the way a real window's
// would. A pattern anchored to the world is a projection, and that is a
// different feature.
this.window = this.lighting.add(new PointLight({ radius: 300, intensity: 2.4, softness: 0.05, color: new Color(255, 214, 160), cookie: windowCookie }));
this.window.setPosition(880, 240);
// The same slot on a cone: the pattern turns with the light.
this.canopy = this.lighting.add(
new SpotLight({ radius: 420, angle: 34, coneSoftness: 0.4, intensity: 2.2, softness: 0.05, color: new Color(186, 255, 198), cookie: canopyCookie }),
);
this.canopy.setPosition(960, 620);
// No cookie, a different shape: falloff is measured from the segment, so
// the pool is a capsule - which is what a tube of neon actually looks like.
this.tube = this.lighting.add(new LineLight({ length: 260, radius: 64, intensity: 2.6, softness: 0.05, color: new Color(120, 190, 255) }));
this.tube.setPosition(300, 640);
this.hud = mountControls({
title: 'Light Cookies',
hint: 'Every pattern here is one texture on ONE light, carried by that light - it turns and scales with the lamp rather than being projected onto the world. Watch the window drift: its bars travel with the lamp instead of staying put on the floor, which is exactly the difference between a cookie and a world projection.',
status: '',
});
const panel = mountControlPanel({ title: 'Lights', corner: 'top-right' });
panel.addSlider({
label: 'Time of day',
min: -60,
max: 60,
step: 1,
value: this.sun.rotation,
onChange: value => {
this.sun.rotation = value;
},
});
panel.addSlider({
label: 'Sun',
min: 0,
max: 1.2,
step: 0.05,
value: this.sun.intensity,
onChange: value => {
this.sun.intensity = value;
},
});
panel.addToggle({
label: 'Cookies',
value: true,
onChange: value => {
this.window.cookie = value ? windowCookie : null;
this.canopy.cookie = value ? canopyCookie : null;
},
});
}
override update(delta: Seconds): void {
this.elapsed += delta;
// Aiming a cone is rotating it, and the cookie turns with it.
// Counter-clockwise from +x, so up the screen is +90 and not -90: the
// world's y grows downward while an angle still turns the way an angle
// turns.
this.canopy.rotation = 90 + Math.sin(this.elapsed * 0.35) * 20;
this.tube.rotation = Math.sin(this.elapsed * 0.25) * 12;
this.window.setPosition(880, 240 + Math.sin(this.elapsed * 0.6) * 40);
}
override draw(context: RenderingContext): void {
context.render(this.world);
this.hud.setStatus(`${this.lighting.activeLightCount} lights - draw calls ${context.stats.drawCalls}`);
}
}
const app = new Application({
scenes: { LightCookiesScene },
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizing: new FixedResolutionCanvasSizing(),
},
clearColor: new Color(4, 5, 9),
});
await app.start(LightCookiesScene);
Window bars and leaf shade as cookies on the lights themselves, a neon tube pooling in a capsule, and a sun with no position throwing parallel shadows.
import { Application, Color, Container, FixedResolutionCanvasSizing, type RenderingContext, ScaleModes, Scene, type Seconds, Sprite, Texture } from '@codexo/exojs';
import { AlphaNormals, Lighting, LightmapLighting, type NormalSource, PointLight } from '@codexo/exojs-lighting';
import { mountControlPanel, mountControls } from '@examples/runtime';
// The lightmap renderer multiplies a frame that was already drawn, so by the
// time the light field is composited there is no surface normal anywhere. A
// normal prepass puts one back: register a drawable and the renderer draws its
// normal map, at the drawable's own place and rotation, into an attachment the
// light shader then reads at its own screen position.
//
// Nothing is required of a drawable that is not registered - the attachment's
// alpha is coverage, and where it is zero the light lands with no `N dot L`
// term at all. That is what the "Normals" toggle below shows: without them
// every fragment takes the light head-on, so the relief flattens and the
// stones read slightly BRIGHTER rather than darker. Normals redistribute
// light across a surface; they never add any.
//
// Nobody authored a normal map here either. `AlphaNormals` reads the
// silhouette as a height field, once at load.
const canvasTexture = (width: number, height: number, paint: (context: CanvasRenderingContext2D) => void): Texture => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (context === null) throw new Error('2D canvas context unavailable.');
paint(context);
return new Texture(canvas, { scaleMode: ScaleModes.Linear, generateMipMap: false });
};
const floorTexture = canvasTexture(64, 64, context => {
context.fillStyle = '#3b3a37';
context.fillRect(0, 0, 64, 64);
context.fillStyle = '#353431';
context.fillRect(0, 0, 32, 32);
context.fillRect(32, 32, 32, 32);
});
// A cobble: opaque in the middle, transparent at the rim. The alpha gradient is
// the whole input the derived normals have, and it is enough to give the stone
// an edge that turns away from the light.
const cobbleSize = 96;
const cobbleTexture = canvasTexture(cobbleSize, cobbleSize, context => {
const gradient = context.createRadialGradient(cobbleSize / 2, cobbleSize / 2, cobbleSize * 0.12, cobbleSize / 2, cobbleSize / 2, cobbleSize * 0.48);
gradient.addColorStop(0, 'rgba(198, 190, 176, 1)');
gradient.addColorStop(0.72, 'rgba(176, 168, 154, 1)');
gradient.addColorStop(1, 'rgba(150, 142, 128, 0)');
context.fillStyle = gradient;
context.beginPath();
context.arc(cobbleSize / 2, cobbleSize / 2, cobbleSize * 0.48, 0, Math.PI * 2);
context.fill();
});
class LightmapNormalsScene extends Scene {
private world!: Container;
private lighting!: Lighting;
private cobbles: Sprite[] = [];
private normals!: NormalSource;
private torch!: PointLight;
private lantern!: PointLight;
private elapsed = 0;
private hud!: ReturnType<typeof mountControls>;
override init(): void {
const { width, height } = this.app;
this.world = new Container();
// `auto` with an application resolves to the lightmap renderer, which is
// the one with a light field for a prepass to feed.
this.lighting = new LightmapLighting(this.app, { ambient: new Color(20, 21, 30), lightResolution: 1 });
this.systems.add(this.lighting);
const floor = new Sprite(floorTexture);
floor.width = width;
floor.height = height;
this.world.addChild(floor);
// Derived once, shared by every cobble: the source holds the baked texture,
// and deriving it per sprite would run the same Sobel pass sixty times.
this.normals = new AlphaNormals(cobbleTexture);
for (let row = 0; row < 5; row++) {
for (let column = 0; column < 9; column++) {
const cobble = new Sprite(cobbleTexture).setAnchor(0.5);
const stagger = row % 2 === 0 ? 0 : 66;
cobble.width = 118;
cobble.height = 118;
cobble.setPosition(90 + column * 132 + stagger, 140 + row * 118);
// Turned, so the prepass has to rotate each normal into world space -
// a stone lit from the left must stay lit from the left however it sits.
cobble.rotation = (row * 9 + column * 23) % 360;
this.world.addChild(cobble);
this.cobbles.push(this.lighting.normalsFrom(cobble, this.normals));
}
}
this.torch = this.lighting.add(new PointLight({ radius: 480, intensity: 2.4, height: 34, color: new Color(255, 190, 130) }));
this.lantern = this.lighting.add(new PointLight({ radius: 360, intensity: 1.8, height: 90, color: new Color(150, 200, 255) }));
this.hud = mountControls({
title: 'Normals under the lightmap renderer',
hint: 'Nobody authored a normal map: the cobbles are round because their own alpha says so. Switch the normals off and every fragment takes the light head-on - the relief flattens and the stones read a little brighter, because a surface normal moves light around rather than adding any.',
status: '',
});
const panel = mountControlPanel({ title: 'Surfaces', corner: 'top-right' });
panel.addToggle({
label: 'Normals',
value: true,
onChange: value => {
for (const cobble of this.cobbles) {
if (value) {
this.lighting.normalsFrom(cobble, this.normals);
} else {
this.lighting.stopNormals(cobble);
}
}
},
});
panel.addToggle({
label: 'Show normals',
value: false,
onChange: value => {
this.lighting.debug = value ? 'normals' : null;
},
});
panel.addSlider({
label: 'Light height',
min: 8,
max: 160,
step: 2,
value: this.torch.height,
onChange: value => {
this.torch.height = value;
},
});
}
override update(delta: Seconds): void {
this.elapsed += delta;
this.torch.setPosition(640 + Math.cos(this.elapsed * 0.5) * 420, 380 + Math.sin(this.elapsed * 0.77) * 220);
this.lantern.setPosition(640 + Math.cos(this.elapsed * 0.31 + 2.2) * 300, 380 + Math.sin(this.elapsed * 0.44 + 1.1) * 260);
}
override draw(context: RenderingContext): void {
context.render(this.world);
this.hud.setStatus(`${this.lighting.activeSurfaceCount} lit surfaces - ${this.lighting.quality} renderer - draw calls ${context.stats.drawCalls}`);
}
}
const app = new Application({
scenes: { LightmapNormalsScene },
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizing: new FixedResolutionCanvasSizing(),
},
clearColor: new Color(5, 6, 11),
});
await app.start(LightmapNormalsScene);
Cobbles that nobody authored a normal map for: each hands the system its own silhouette, and a prepass draws the derived normals where the stone sits.
Where to go next
Post-processing covers the frame slot the lightmap renderer installs into, which is also how you would write a lighting system of your own — app.framePasses.addPass(myPass) hands a pass the finished frame and lets it write the canvas, with no agreement with this package at all.