Lighting
@yagejs/lighting adds radial lights and spotlights to a scene and gives
gameplay code a continuous 0..1 light level. Walls and other occluders block
light in both, so a stealth game or a maze-of-light game can use one set of
light sources for its visuals and its rules.
Install and enable lighting
Section titled “Install and enable lighting”npm install @yagejs/lightingInstall LightingPlugin after RendererPlugin:
import { Engine } from "@yagejs/core";import { LightingPlugin } from "@yagejs/lighting";import { RendererPlugin } from "@yagejs/renderer";
const engine = new Engine();engine.use(new RendererPlugin({ width: 800, height: 600 }));engine.use( new LightingPlugin({ ambient: { level: 0.2, color: 0xb0b8cc, }, }),);The ambient level controls how bright the scene and gameplay query are where
no light reaches. Both level and light intensity use a range from 0 to
1.
Add a light
Section titled “Add a light”A light follows the world position of the Transform on the same entity:
import { Transform, Vec2 } from "@yagejs/core";import { LightSource } from "@yagejs/lighting";
const torch = this.spawn("torch");torch.add(new Transform({ position: new Vec2(320, 180) }));
const light = torch.add( new LightSource({ radius: 180, intensity: 0.9, color: 0xffb060, }),);radius is measured in world pixels. The light follows parent transforms, but
transform scale does not resize it. Change the light directly when its
gameplay radius changes:
light.radius = 240;light.intensity = 0.7;light.color = 0x80aaff;light.size = 24;light.coneAngle = Math.PI / 3;light.castShadows = false;light.enabled = false;castShadows is true by default. Set it to false for a light that should
reach through walls, such as a global fill or a highlight on a selected
entity.
Disabling the light or its entity removes it from rendering and queries until it becomes active again. If light settings are durable game state, include their plain values in the game’s explicit save root and rebuild the light when the scene is created.
Give the lamp a size
Section titled “Give the lamp a size”radius says how far a light reaches. size says how wide the lamp itself
is, in world pixels, and it defaults to 0:
import { Transform, Vec2 } from "@yagejs/core";import { LightSource } from "@yagejs/lighting";
const lantern = this.spawn("lantern");lantern.add(new Transform({ position: new Vec2(420, 180) }));
lantern.add( new LightSource({ radius: 180, size: 24, }),);A lamp wider than a point is partly hidden behind a blocker’s edge, so the edge of its shadow is a band that runs from full light to full dark rather than a line. A point standing in that band is dimmed by the share of the lamp it can still see. The band widens with the distance from the blocker, the way a real shadow does: crisp against the wall the crate stands on, and blurred several metres behind it.
At size: 0 a light is a point and its shadows have hard edges.
levelAt() answers with the band whichever renderer a scene uses. To see it on
screen, give the scene shaderLighting() — see Draw shadows with soft
borders.
Narrow a light into a spotlight
Section titled “Narrow a light into a spotlight”cone limits a light to one direction. The angle is the full spread in
radians, and the entity’s rotation aims it:
import { Transform, Vec2 } from "@yagejs/core";import { LightSource } from "@yagejs/lighting";
const lamp = this.spawn("lamp");lamp.add(new Transform({ position: new Vec2(320, 180), rotation: 0 }));
const beam = lamp.add( new LightSource({ radius: 260, cone: { angle: Math.PI / 3 }, }),);
lamp.get(Transform).setRotation(Math.PI / 2); // aim the beam downwardbeam.coneAngle = Math.PI / 2; // widen itA cone changes where direct light lands, so levelAt() answers by it too: a
guard outside the beam reads as dark even when it is well inside the light’s
radius. The default spread is a whole turn, Math.PI * 2, which reaches every
direction.
softness fades the cone’s edge over a share of its spread, from 0 for an
edge on a line to 1 for a beam that is brightest along its aim and fades all
the way out:
import { Transform, Vec2 } from "@yagejs/core";import { LightSource } from "@yagejs/lighting";
const lamp = this.spawn("lamp");lamp.add(new Transform({ position: new Vec2(320, 180) }));
const lantern = lamp.add( new LightSource({ radius: 260, cone: { angle: Math.PI / 3, softness: 0.5 }, }),);
lantern.coneSoftness = 0.8; // fade over more of the spreadThe fade is part of the query, so a guard half-way into a soft edge reads as
half lit. shaderLighting() draws it; overlayLighting() draws one hard edge
through the middle of it. A whole-turn cone reaches every direction, so
softness does nothing to it.
Query the light level
Section titled “Query the light level”Resolve the scene’s LightingWorld from a component:
import { Component } from "@yagejs/core";import { LightingWorldKey } from "@yagejs/lighting";
class GuardVision extends Component { private readonly lighting = this.service(LightingWorldKey);
canSee(x: number, y: number): boolean { return this.lighting.levelAt(x, y) >= 0.45; }}levelAt(x, y) starts with the ambient level, adds the contribution from each
enabled light that reaches the point, and clamps the answer to 1. Each light
fades linearly from its full intensity at the centre to zero at its radius. The
query is scalar, so light colours do not affect it.
Each light’s contribution is scaled by the share of its lamp the point can see, and a light with a cone contributes only inside that cone. See Register occluder geometry for the full rule.
Sample a whole grid
Section titled “Sample a whole grid”A game that keeps a light field, such as a pathfinding cost map or a fog
layer, asks for many points at once with levelGridInto:
import { Component } from "@yagejs/core";import { LightingWorldKey } from "@yagejs/lighting";import type { LightGrid } from "@yagejs/lighting";
const GRID: LightGrid = { x: 0, y: 0, cols: 40, rows: 20, cellWidth: 16, cellHeight: 16,};
class LightField extends Component { private readonly lighting = this.service(LightingWorldKey); private readonly levels = new Float32Array(GRID.cols * GRID.rows);
rebuild(): void { this.lighting.levelGridInto(this.levels, GRID); }
levelAtCell(col: number, row: number): number { return this.levels[row * GRID.cols + col] ?? 0; }}x and y place the region’s top-left corner in world pixels. The buffer is
row-major, and each cell holds what levelAt returns for that cell’s centre.
You own the buffer, so a field rebuilt every frame allocates nothing.
Each light is summed only over the cells its radius reaches, and only against the occluders within that radius, so the cost grows with the lit area rather than with the size of the grid.
Change the scene’s ambient light at runtime:
import { LightingWorldKey } from "@yagejs/lighting";
const lighting = this.service(LightingWorldKey);
lighting.setAmbient(0.05);lighting.setAmbient(0.2, 0x8090b8);Configure the built-in renderer
Section titled “Configure the built-in renderer”@yagejs/lighting ships two renderers. This section covers overlayLighting(),
the default; shaderLighting() has its own section below.
The default renderer draws ambient colour and radial lights into an offscreen buffer, then multiplies it over the scene. A light is drawn only where no occluder stands between it and the surface. A light with a cone is drawn as a pie slice, turned by its entity. Coloured lights tint the surfaces they reach, as real coloured illumination does.
Renderers are configured under names, and defaultRenderer picks the one a
scene gets when it asks for nothing:
import { LightingPlugin, overlayLighting } from "@yagejs/lighting";
engine.use( new LightingPlugin({ renderers: { overlay: overlayLighting({ layer: "lighting", order: 900, resolutionScale: 0.5, antialias: true, }), }, defaultRenderer: "overlay", }),);Leave renderers out entirely and you get overlayLighting() under the name
"overlay", which is also the default. Once you write your own renderers,
defaultRenderer is required and has to name one of your entries — the plugin
throws at install rather than guessing which of them a scene should get.
The default layer is screen-space and uses order 900. A UI layer at the
conventional order 1000 stays above the lighting. Lower
resolutionScale to reduce the light buffer’s GPU cost at the expense of
sharper gradient edges.
The renderer follows the highest-priority enabled camera in each scene. With no camera, world coordinates map directly to the viewport.
Pick a renderer per scene
Section titled “Pick a renderer per scene”A scene names one of the configured renderers:
import { Scene } from "@yagejs/core";
class CaveScene extends Scene { readonly name = "cave"; readonly lighting = { renderer: "overlay" };}The name is read once, when the scene’s lighting world is created, so a scene
keeps the renderer it entered with. A name that is not in renderers throws
there, and the message lists the names that are.
An entry can be null, which keeps levelAt() and draws nothing. That is
what tests and headless gameplay want:
import { LightingPlugin } from "@yagejs/lighting";
engine.use( new LightingPlugin({ renderers: { none: null }, defaultRenderer: "none", }),);You still install RendererPlugin; a null entry only turns off lighting’s
visual backend.
Draw shadows with soft borders
Section titled “Draw shadows with soft borders”shaderLighting() is the second built-in renderer. It draws each light as one
quad and works out, for every pixel that quad covers, how much of the lamp the
occluders leave visible there — the same sum levelAt() does. A crate’s shadow
is therefore crisp against the floor it stands on and blurred several metres
behind it, a lamp wider than a post lights around the post, and a spotlight’s
edge fades over as much of its spread as softness says.
import { Scene } from "@yagejs/core";import { LightingPlugin, overlayLighting, shaderLighting,} from "@yagejs/lighting";
engine.use( new LightingPlugin({ renderers: { soft: shaderLighting(), hard: overlayLighting(), }, defaultRenderer: "soft", }),);
class CaveScene extends Scene { readonly name = "cave"; readonly lighting = { renderer: "soft" };}
class MenuScene extends Scene { readonly name = "menu"; readonly lighting = { renderer: "hard" };}Both renderers answer to the same levelAt(), so two scenes of one game can
name different renderers and a character still reads as equally lit in each.
Which renderer a scene wants
Section titled “Which renderer a scene wants”The overlay builds a shape per light and a shadow mask per light, and rebuilds them whenever that light or an occluder moves. Its cost grows with how many lights move and how much occluder outline each one reaches, and it spends almost nothing per pixel.
The shader renderer spends nothing on shapes and everything per pixel: for each pixel a light covers, it walks the occluder shapes in that light’s reach. Its cost grows with lit screen area times shapes nearby. A crowd of small lamps is cheap for it; a few lights that each cover the whole screen against a room full of geometry is not.
So: a room where shadow quality is the point, or a scene with many moving
lamps, wants shaderLighting(). A scene whose lights are large and whose
shadow edges do not need a border is cheaper on overlayLighting().
shaderLighting() takes the same layer and order as the overlay, plus
resolutionScale and fallback:
import { overlayLighting, shaderLighting } from "@yagejs/lighting";
shaderLighting({ layer: "lighting", order: 900, resolutionScale: 1, fallback: overlayLighting(),});resolutionScale starts at 1, where the drawn light lands on the same pixel
grid levelAt() is asked about. The overlay starts at 0.5. Lower it to cut
fill cost, at the price of a coarser shadow border.
When the shader cannot run
Section titled “When the shader cannot run”fallback takes another renderer factory, and a scene gets it instead when the
browser hands Pixi a WebGL 1 context. That context’s shader language has none
of what this renderer is written in, and it is the one device limit the
renderer can read before it draws anything. A scene on such a context with no
fallback throws as it is entered, naming the option.
A shader that a driver refuses on a context that does have the language is a
different case, and fallback does not cover it: the refusal goes to the
browser console, not to the renderer, so nothing in the engine sees it. A game
that wants a cheaper renderer on weaker devices configures both under names and
picks one per scene.
The renderer ships a WebGL shader and a WebGPU shader. Both draw the same picture from the same uniforms and the same occluder data.
Let light bounce off surfaces
Section titled “Let light bounce off surfaces”Real light does not stop at a shadow’s edge: a lit floor throws some of it
back, so the dark side of a crate is dim rather than black. bounce combines
the finished light with a blurred, low-resolution copy of itself, which
approximates that. It sits on the scene beside renderer, because how much
light a room throws back is part of how that room looks:
import { Scene } from "@yagejs/core";
class CaveScene extends Scene { readonly name = "cave"; readonly lighting = { renderer: "overlay", bounce: { strength: 0.8, radius: 90 }, };}strength runs from 0 to 1 and says how strongly the blurred copy shows.
radius is how far the blur reaches, in screen pixels: small values
soften shadow edges, large ones spread a glow across the room. The copy is
drawn at a low density of its own, so radius means the same thing whatever
the renderer’s resolutionScale is.
blend picks how the blurred copy meets the light, and the two choices look
different:
"max", the default, keeps the brighter of the two. Lit areas and shadow borders stay exactly as drawn, and only the dark is lifted. Pick it when the shadows themselves should stay crisp, which is usually the case with the shader renderer’s soft borders."mix"blends the whole picture toward the blurred copy. The room keeps its overall brightness, every shadow edge softens and the brightest spots dim a little. Pick it to take the hard edge off the overlay renderer’s shadows.
import { Scene } from "@yagejs/core";import type { SceneLightingOptions } from "@yagejs/lighting";
class CaveScene extends Scene { readonly name = "cave"; // Typed so "mix" stays a blend mode instead of widening to string. readonly lighting: SceneLightingOptions = { bounce: { strength: 0.6, radius: 60, blend: "mix" }, };}Set bounce on the plugin for the value every scene starts from, and null
on a scene that wants none of it:
import { Scene } from "@yagejs/core";import { LightingPlugin } from "@yagejs/lighting";
engine.use( new LightingPlugin({ ambient: { level: 0.1 }, bounce: { strength: 0.4, radius: 40 }, }),);
class MenuScene extends Scene { readonly name = "menu"; readonly lighting = { bounce: null };}A scene that leaves bounce out takes the plugin’s value, and bounce is off
when neither sets one. Like the renderer name, it is read once, when the scene
is entered, and a scene’s own value is checked there with an error naming the
scene.
The pass costs one more offscreen buffer per scene, and it runs only on frames where the lights themselves were redrawn.
Register occluder geometry
Section titled “Register occluder geometry”A LightOccluder marks the area an entity blocks light through:
import type { Entity } from "@yagejs/core";import { LightOccluder } from "@yagejs/lighting";
declare const wall: Entity, pillar: Entity, rock: Entity; // each has a Transform
wall.add( new LightOccluder({ shape: { type: "box", width: 96, height: 24 }, }),);
pillar.add( new LightOccluder({ shape: { type: "circle", radius: 20 }, }),);
rock.add( new LightOccluder({ shape: { type: "polygon", vertices: [ { x: -20, y: 12 }, { x: 0, y: -18 }, { x: 24, y: 10 }, ], }, }),);The shape uses local pixels. Its centre, rotation and scale follow the entity’s world transform. A uniform positive scale resizes the shape; any other scale turns it into a scaled outline, the same way a physics collider follows entity scale.
The rule the query applies:
- A light contributes in proportion to the share of its lamp the point can
see. The lamp is a line of width
sizecentred on the light and square to the direction from the point to it. Every occluder’s outline is projected onto that line from the point, the projections are merged, and the share left over scales the light’s contribution. - At
size: 0the share is all or nothing, and the rule is that the straight line between the light and the point must miss every enabled occluder. - Touching an edge or a corner counts as blocked, so two occluders that meet leave no gap for light.
- An occluder that contains a light does not block it, so a lamp mounted on a pillar still lights the room.
- A point inside an occluder is dark for every light outside it.
Disabling the component or its entity lets light through again. Occluders are
also available to custom lighting renderers through LightingWorld.occluders.
Projecting every occluder onto a wide lamp costs more than testing a straight
line once: about 3 ms for point lamps and about 10 ms for 24-pixel lamps, for
one levelGridInto call over 14,400 cells with 50 lights of radius 200 and
400 box occluders. Keep size at 0 for lights whose shadows do not need a
soft border.
Supply another renderer
Section titled “Supply another renderer”Add a factory to renderers when a game needs a different visual result:
import { LightingPlugin } from "@yagejs/lighting";import type { LightingRendererFactory } from "@yagejs/lighting";
const customLighting: LightingRendererFactory = ({ scene, world, renderer, bounce,}) => ({ render(frame) { // Read world.sources and world.occluders. // frame.camera is this scene's active, highest-priority camera. // frame.width and frame.height are the virtual viewport size. }, destroy() { // Release resources owned by this scene. },});
engine.use( new LightingPlugin({ renderers: { custom: customLighting }, defaultRenderer: "custom", }),);YAGE calls the factory once for each entered scene and calls destroy() when
that scene exits. Each source contains renderer-neutral light data, so a
custom renderer can draw visibility polygons, material lighting, or another
visual treatment without changing game entities. Apply the same occlusion rule
the query applies, or the drawn light and the queried light disagree.
The bounce the factory receives is what this scene resolved to: its own
setting, or the plugin’s, or null. Hand it to LightingComposite, the step
the built-in renderer finishes with, and a custom renderer gets the same
multiply composite and the same bounced light for free:
import { Container } from "pixi.js";import { LightingComposite } from "@yagejs/lighting";import type { LightingRendererFactory } from "@yagejs/lighting";import { SceneRenderTreeKey } from "@yagejs/renderer";
const customLighting: LightingRendererFactory = ({ scene, renderer, bounce }) => { const lightContainer = new Container(); const layer = scene .use(SceneRenderTreeKey) .ensureLayer({ name: "lighting", order: 900, space: "screen" });
const composite = new LightingComposite(renderer, { source: lightContainer, // the container this renderer draws into parent: layer.container, // a screen-space render layer width: renderer.virtualSize.width, height: renderer.virtualSize.height, resolutionScale: 0.5, bounce, });
return { render() { // Draw into lightContainer; call composite.invalidate() when it changed. composite.render(); }, destroy() { composite.destroy(); // before the source container goes lightContainer.destroy({ children: true }); }, };};Call composite.invalidate() whenever the drawn light changed and
composite.render() once per frame. It redraws nothing while the light stands
still, and it returns whether the buffer was redrawn. composite.destroy()
releases its buffers and takes the overlay out of the layer; the container you
drew into stays yours.
Both LightSource and LightOccluder expose an immutable position in world
pixels. For coordinate reads inside a render loop, keep a Vec2Buffer and
reuse it:
import { Vec2Buffer } from "@yagejs/core";import type { LightingRendererFactory } from "@yagejs/lighting";
const customLighting: LightingRendererFactory = ({ world }) => { const position = new Vec2Buffer(); // keep between render calls return { render() { for (const light of world.sources) { light.getPositionInto(position); // Draw the light at position.x, position.y. } for (const occluder of world.occluders) { occluder.getPositionInto(position); } }, destroy() {}, };};Each getPositionInto call overwrites and returns the same buffer without
constructing a Vec2. The coordinates include parent transforms. Moving a
light or occluder later does not change the buffer; call the getter again to
refresh it. Keep the ordinary position getter for values you retain or
share.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/lighting.md.