diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7973b60..d48f89b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,37 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Added an opt-in 2D lighting simulation, enabled with the new `lighting: true` engine option (default `false`). When enabled every scene gets a `FlickerSystem` and `LightingSystem` that render darkness veils, point/cone lights with flicker, and occluder shadows via the new `DarknessComponent`, `PointLightComponent`, `ConeLightComponent`, and `LightOccluderComponent` components. The overlay is rasterized with the 2D Canvas API and re-uploaded to the GPU every frame, which carries a performance penalty — this is why the feature is off by default. The systems can also be added manually to individual scenes with `scene.world.add(new ex.LightingSystem())` without enabling the engine option.
+
+ ```typescript
+ const game = new ex.Engine({ lighting: true });
+ const lamp = new ex.Actor({ pos: ex.vec(100, 100) });
+ lamp.addComponent(new ex.PointLightComponent({ color: ex.Color.fromRGB(255, 200, 80), radius: 200 }));
+ ```
+
+ Pass a `LightingConfig` object instead of `true` to tune the simulation's defaults — including the ambient brightness floor/color assumed everywhere a scene has no explicit `DarknessComponent` override:
+
+ ```typescript
+ const game = new ex.Engine({
+ lighting: {
+ enabled: true,
+ zIndex: 100, // z-index of the lighting overlay
+ cullPadding: 64, // world-pixel padding around the camera frustum when culling lights/occluders
+ ambientIntensity: 0.1, // ambient brightness floor (0.0 to 1.0)
+ ambientColor: ex.Color.fromRGB(60, 60, 200), // tints the darkness veil toward this color
+ tintAlphaFactor: 0.35, // fraction of a light's intensity used for its colored tint
+ shadowNearOpacity: 0.92, // occluder shadow opacity at the occluder's edge
+ shadowMidOpacity: 0.6 // occluder shadow opacity at 40% of the shadow's reach
+ }
+ });
+ ```
+
+ Any entity with a collider-like shape can cast a dynamic shadow by adding a `LightOccluderComponent`:
+
+ ```typescript
+ const crate = new ex.Actor({ pos: ex.vec(300, 200), width: 40, height: 40 });
+ crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 40, height: 40 } }));
+ ```
- Materials are now enumerable for debugging and tooling (like the Excalibur Dev Tools browser extension): every `Material` has a unique `material.id` and public `material.vertexSource`/`material.fragmentSource` getters, and is automatically registered with its context, retrievable via `game.graphicsContext.materials`. The registry holds materials weakly so it does not prevent garbage collection.
- Added the `ex.glsl` tagged template literal for authoring `Material` fragment shaders. It lights up GLSL syntax highlighting, adds the `#version`/`precision` boilerplate, injects the `pixel_texture()` pixel art filter on demand, and handles alpha premultiplication automatically.
diff --git a/sandbox/index.html b/sandbox/index.html
index 7c953558..5becb0e4 100644
--- a/sandbox/index.html
+++ b/sandbox/index.html
@@ -23,6 +23,7 @@
Arcade: No clipping divergent collisions
Edge colliders work in a tilemap
Triangulation
+ Lighting
Parallax
Parallel
Sound
diff --git a/sandbox/tests/lighting/index.html b/sandbox/tests/lighting/index.html
new file mode 100644
index 00000000..6af23931
--- /dev/null
+++ b/sandbox/tests/lighting/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Lighting
+
+
+ WASD/arrows pan camera, +/- zoom, move mouse to aim the cone light
+
+
+
diff --git a/sandbox/tests/lighting/index.ts b/sandbox/tests/lighting/index.ts
new file mode 100644
index 00000000..3ff38b74
--- /dev/null
+++ b/sandbox/tests/lighting/index.ts
@@ -0,0 +1,104 @@
+var game = new ex.Engine({
+ width: 800,
+ height: 600,
+ displayMode: ex.DisplayMode.FitScreenAndFill,
+ lighting: {
+ enabled: true,
+ ambientIntensity: 0.1,
+ ambientColor: ex.Color.fromRGB(60, 60, 200)
+ }
+});
+
+var environment = new ex.Actor({ name: 'environment' });
+environment.addComponent(new ex.DarknessComponent({ color: ex.Color.fromRGB(0, 0, 10), intensity: 0.9 }));
+game.add(environment);
+
+var room = new ex.Actor({ name: 'room', pos: ex.vec(750, 150) });
+room.addComponent(new ex.DarknessComponent({ color: ex.Color.fromRGB(5, 5, 20), intensity: 0.95, width: 300, height: 200 }));
+game.add(room);
+
+var lamp = new ex.Actor({ pos: ex.vec(400, 300), radius: 5, color: ex.Color.fromRGB(255, 200, 80) });
+lamp.addComponent(
+ new ex.PointLightComponent({
+ color: ex.Color.fromRGB(255, 200, 80),
+ intensity: 0.6,
+ radius: 300,
+ flicker: { frequency: 2.5, amplitude: 0.15, secondaryFrequency: 5.1 }
+ })
+);
+game.add(lamp);
+
+var flashlight = new ex.Actor({ pos: ex.vec(200, 420), radius: 5, color: ex.Color.White });
+var cone = new ex.ConeLightComponent({
+ color: ex.Color.fromRGB(200, 255, 200),
+ intensity: 0.8,
+ radius: 400,
+ angle: Math.PI / 4,
+ softness: 0.3
+});
+flashlight.addComponent(cone);
+game.add(flashlight);
+game.input.pointers.primary.on('move', (evt) => {
+ cone.direction = evt.worldPos.sub(flashlight.pos).toAngle();
+});
+
+var crate = new ex.Actor({ pos: ex.vec(520, 260), width: 40, height: 40, color: ex.Color.Brown });
+crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 40, height: 40 } }));
+crate.actions.repeatForever((ctx) => ctx.rotateBy({ angleRadiansOffset: Math.PI, duration: 4000 }));
+game.add(crate);
+
+var pillar = new ex.Actor({ pos: ex.vec(300, 180), radius: 25, color: ex.Color.Gray });
+pillar.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'circle', radius: 25 } }));
+game.add(pillar);
+
+var wedge = new ex.Actor({
+ pos: ex.vec(460, 460),
+ color: ex.Color.DarkGray,
+ collider: ex.Shape.Polygon([ex.vec(-30, 20), ex.vec(30, 20), ex.vec(0, -30)])
+});
+wedge.addComponent(
+ new ex.LightOccluderComponent({ shape: { kind: 'polygon', vertices: [ex.vec(-30, 20), ex.vec(30, 20), ex.vec(0, -30)] } })
+);
+game.add(wedge);
+
+var roomLamp = new ex.Actor({ pos: ex.vec(750, 150), radius: 5, color: ex.Color.fromRGB(255, 120, 120) });
+roomLamp.addComponent(new ex.PointLightComponent({ color: ex.Color.fromRGB(255, 120, 120), intensity: 0.8, radius: 120 }));
+game.add(roomLamp);
+
+for (let x = -2; x < 12; x++) {
+ for (let y = -2; y < 10; y++) {
+ var tile = new ex.Actor({
+ pos: ex.vec(x * 100, y * 100),
+ width: 96,
+ height: 96,
+ color: (x + y) % 2 === 0 ? ex.Color.fromRGB(80, 120, 80) : ex.Color.fromRGB(100, 140, 100),
+ z: -1
+ });
+ game.add(tile);
+ }
+}
+
+game.onPostUpdate = () => {
+ var kb = game.input.keyboard;
+ var speed = 5;
+ if (kb.isHeld(ex.Keys.A) || kb.isHeld(ex.Keys.Left)) {
+ game.currentScene.camera.pos.x -= speed;
+ }
+ if (kb.isHeld(ex.Keys.D) || kb.isHeld(ex.Keys.Right)) {
+ game.currentScene.camera.pos.x += speed;
+ }
+ if (kb.isHeld(ex.Keys.W) || kb.isHeld(ex.Keys.Up)) {
+ game.currentScene.camera.pos.y -= speed;
+ }
+ if (kb.isHeld(ex.Keys.S) || kb.isHeld(ex.Keys.Down)) {
+ game.currentScene.camera.pos.y += speed;
+ }
+ if (kb.wasPressed(ex.Keys.Equal)) {
+ game.currentScene.camera.zoom *= 1.25;
+ }
+ if (kb.wasPressed(ex.Keys.Minus)) {
+ game.currentScene.camera.zoom /= 1.25;
+ }
+};
+
+game.start();
diff --git a/src/engine/camera.ts b/src/engine/camera.ts
index f3961d1e..b672e0de 100644
--- a/src/engine/camera.ts
+++ b/src/engine/camera.ts
@@ -854,6 +854,7 @@ export class Camera implements CanUpdate, CanInitialize {
public draw(ctx: ExcaliburGraphicsContext): void {
// default to the current position
this.pos.clone(this.drawPos);
+ this.updateTransform(this.drawPos);
// interpolation if fixed update is on
// must happen on the draw, because more draws are potentially happening than updates
diff --git a/src/engine/engine.ts b/src/engine/engine.ts
index 32134371..a7d9c276 100644
--- a/src/engine/engine.ts
+++ b/src/engine/engine.ts
@@ -62,6 +62,8 @@ import { Director, DirectorEvents } from './director/director';
import { InputHost } from './input/input-host';
import type { PhysicsConfig } from './collision/physics-config';
import { getDefaultPhysicsConfig } from './collision/physics-config';
+import type { LightingConfig } from './lighting/lighting-config';
+import { getDefaultLightingConfig } from './lighting/lighting-config';
import type { DeepRequired } from './util/required';
import type { Context } from './context';
import { createContext, useContext } from './context';
@@ -402,6 +404,19 @@ export interface EngineOptions {
*/
physics?: boolean | PhysicsConfig;
+ /**
+ * Optionally enable the 2D lighting simulation in excalibur, adding the {@apilink LightingSystem} and
+ * {@apilink FlickerSystem} to every scene.
+ *
+ * **Potential performance impact** — the lighting overlay is rasterized with the 2D Canvas API and
+ * re-uploaded to the GPU every frame, which carries a performance penalty.
+ *
+ * Pass a {@apilink LightingConfig} to tune the lighting simulation's defaults instead of just enabling it.
+ *
+ * Default is false
+ */
+ lighting?: boolean | LightingConfig;
+
/**
* Optionally specify scenes with their transitions and loaders to excalibur's scene {@apilink Director}
*
@@ -571,6 +586,11 @@ export class Engine implements CanInitialize,
*/
public physics!: DeepRequired;
+ /**
+ * Direct access to the lighting configuration for excalibur
+ */
+ public lighting!: DeepRequired;
+
/**
* Optionally set the maximum fps if not set Excalibur will go as fast as the device allows.
*
@@ -868,6 +888,7 @@ export class Engine implements CanInitialize,
antialiasing: true,
pixelArt: false,
garbageCollection: true,
+ lighting: false,
powerPreference: 'high-performance',
pointerScope: PointerScope.Canvas,
suppressConsoleBootMessage: undefined,
@@ -1212,6 +1233,18 @@ O|===|* >________________>\n\
mergeDeep(this.physics, options.physics!);
}
+ if (typeof options.lighting === 'boolean') {
+ this.lighting = {
+ ...getDefaultLightingConfig(),
+ enabled: options.lighting
+ };
+ } else {
+ this.lighting = {
+ ...getDefaultLightingConfig()
+ };
+ mergeDeep(this.lighting, options.lighting!);
+ }
+
this.director = new Director(this, options.scenes!);
this.director.events.pipe(this.events);
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 80b9cf29..7f461974 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -28,6 +28,8 @@ export * from './plugin';
export * from './tile-map/index';
+export * from './lighting/index';
+
export * from './timer';
export * from './trigger';
export * from './screen-element';
diff --git a/src/engine/lighting/cone-light-component.ts b/src/engine/lighting/cone-light-component.ts
new file mode 100644
index 00000000..4f504643
--- /dev/null
+++ b/src/engine/lighting/cone-light-component.ts
@@ -0,0 +1,69 @@
+import { Component } from '../entity-component-system/component';
+import { Color } from '../color';
+import { Logger } from '../util/log';
+import type { FlickerOptions } from './flicker-options';
+import type { PointLightComponentOptions } from './point-light-component';
+
+export interface ConeLightComponentOptions extends PointLightComponentOptions {
+ /**
+ * Radius of the light in world pixels. Default 200
+ */
+ radius?: number;
+ /**
+ * Total angle arc of the wedge in radians. Default Math.PI / 3
+ */
+ angle?: number;
+ /**
+ * World-space heading angle in radians (0 = Right). Default 0
+ */
+ direction?: number;
+ /**
+ * Edge smoothing ratio. 0.0 represents hard cuts, 1.0 represents full dissipation. Default 0.25
+ */
+ softness?: number;
+}
+
+/**
+ * Emits light restricted to a directional angular wedge,
+ * cutting through any {@apilink DarknessComponent} veil.
+ */
+export class ConeLightComponent extends Component {
+ // @ts-ignore
+ private static _NAME = 'ConeLightComponent';
+
+ public color: Color;
+ public intensity: number;
+ public radius: number;
+ public angle: number;
+ public direction: number;
+ public softness: number;
+ public flicker?: FlickerOptions;
+ public enabled: boolean;
+ /**
+ * Runtime intensity after flicker calculations are applied, written by the {@apilink FlickerSystem}
+ */
+ public currentIntensity: number;
+
+ constructor(options?: ConeLightComponentOptions) {
+ super();
+ this.color = options?.color ?? Color.White;
+ this.intensity = options?.intensity ?? 1.0;
+ this.radius = options?.radius ?? 200;
+ this.angle = options?.angle ?? Math.PI / 3;
+ this.direction = options?.direction ?? 0;
+ this.softness = options?.softness ?? 0.25;
+ this.flicker = options?.flicker;
+ this.enabled = options?.enabled ?? true;
+ this.currentIntensity = this.intensity;
+ if (process.env.NODE_ENV === 'development') {
+ if (this.radius < 0 || this.intensity < 0) {
+ Logger.getInstance().warn(
+ `ConeLightComponent created with negative radius (${this.radius}) or intensity (${this.intensity}), this is likely a bug`
+ );
+ }
+ if (this.softness < 0 || this.softness > 1) {
+ Logger.getInstance().warn(`ConeLightComponent softness (${this.softness}) should be between 0.0 and 1.0`);
+ }
+ }
+ }
+}
diff --git a/src/engine/lighting/darkness-component.ts b/src/engine/lighting/darkness-component.ts
new file mode 100644
index 00000000..251eed7b
--- /dev/null
+++ b/src/engine/lighting/darkness-component.ts
@@ -0,0 +1,45 @@
+import { Component } from '../entity-component-system/component';
+import { Color } from '../color';
+
+export interface DarknessComponentOptions {
+ /**
+ * Color of the darkness veil. Default rgb(0, 0, 10)
+ */
+ color?: Color;
+ /**
+ * Opacity of the darkness veil (0.0 to 1.0). Default 0.85
+ */
+ intensity?: number;
+ /**
+ * Width of the darkness boundary in world pixels. Set to Infinity for global coverage. Default Infinity
+ */
+ width?: number;
+ /**
+ * Height of the darkness boundary in world pixels. Set to Infinity for global coverage. Default Infinity
+ */
+ height?: number;
+}
+
+/**
+ * Controls the darkness veil drawn over a scene or room by the {@apilink LightingSystem}.
+ *
+ * With `width`/`height` of `Infinity` (the default) the veil covers the whole screen. With finite
+ * dimensions the veil is a "room" rectangle in world pixels centered on the owning entity's position.
+ */
+export class DarknessComponent extends Component {
+ // @ts-ignore
+ private static _NAME = 'DarknessComponent';
+
+ public color: Color;
+ public intensity: number;
+ public width: number;
+ public height: number;
+
+ constructor(options?: DarknessComponentOptions) {
+ super();
+ this.color = options?.color ?? Color.fromRGB(0, 0, 10);
+ this.intensity = options?.intensity ?? 0.85;
+ this.width = options?.width ?? Infinity;
+ this.height = options?.height ?? Infinity;
+ }
+}
diff --git a/src/engine/lighting/flicker-options.ts b/src/engine/lighting/flicker-options.ts
new file mode 100644
index 00000000..61e2cf34
--- /dev/null
+++ b/src/engine/lighting/flicker-options.ts
@@ -0,0 +1,17 @@
+/**
+ * Options for light flicker driven by the {@apilink FlickerSystem} using deterministic layered sine waves.
+ */
+export interface FlickerOptions {
+ /**
+ * Frequency of the flicker oscillation in Hz.
+ */
+ frequency: number;
+ /**
+ * Maximum deviation from base intensity (0.0 to 1.0).
+ */
+ amplitude: number;
+ /**
+ * Optional secondary wave frequency for asymmetrical, organic modulation.
+ */
+ secondaryFrequency?: number;
+}
diff --git a/src/engine/lighting/flicker-system.ts b/src/engine/lighting/flicker-system.ts
new file mode 100644
index 00000000..54623f51
--- /dev/null
+++ b/src/engine/lighting/flicker-system.ts
@@ -0,0 +1,61 @@
+import { System, SystemType } from '../entity-component-system/system';
+import { SystemPriority } from '../entity-component-system/priority';
+import type { World } from '../entity-component-system/world';
+import type { Query } from '../entity-component-system/query';
+import { PointLightComponent } from './point-light-component';
+import { ConeLightComponent } from './cone-light-component';
+
+/**
+ * Modulates active light intensities ahead of the lighting render pass
+ * utilizing deterministic layered sine waves.
+ *
+ * Writes {@apilink PointLightComponent.currentIntensity} and {@apilink ConeLightComponent.currentIntensity},
+ * so it must run before the {@apilink LightingSystem} rasterizes the frame.
+ */
+export class FlickerSystem extends System {
+ static priority = SystemPriority.Average;
+ public readonly systemType = SystemType.Update;
+
+ private _pointQuery!: Query;
+ private _coneQuery!: Query;
+ private _elapsed = 0;
+
+ public initialize(world: World): void {
+ this._pointQuery = world.query([PointLightComponent]);
+ this._coneQuery = world.query([ConeLightComponent]);
+ }
+
+ public update(elapsed: number): void {
+ this._elapsed += elapsed / 1000;
+ const t = this._elapsed;
+
+ const pointEntities = this._pointQuery.entities;
+ for (let i = 0; i < pointEntities.length; i++) {
+ this._applyFlicker(pointEntities[i].get(PointLightComponent)!, t);
+ }
+ const coneEntities = this._coneQuery.entities;
+ for (let i = 0; i < coneEntities.length; i++) {
+ this._applyFlicker(coneEntities[i].get(ConeLightComponent)!, t);
+ }
+ }
+
+ private _applyFlicker(light: PointLightComponent | ConeLightComponent, t: number): void {
+ if (!light.enabled) {
+ light.currentIntensity = 0;
+ return;
+ }
+
+ if (!light.flicker) {
+ light.currentIntensity = light.intensity;
+ return;
+ }
+
+ const { frequency, amplitude, secondaryFrequency } = light.flicker;
+ let offset = Math.sin(t * frequency * Math.PI * 2) * amplitude;
+ if (secondaryFrequency) {
+ offset += Math.sin(t * secondaryFrequency * Math.PI * 2) * amplitude * 0.4;
+ offset /= 1.4;
+ }
+ light.currentIntensity = Math.max(0, light.intensity + offset);
+ }
+}
diff --git a/src/engine/lighting/index.ts b/src/engine/lighting/index.ts
new file mode 100644
index 00000000..81efd6b5
--- /dev/null
+++ b/src/engine/lighting/index.ts
@@ -0,0 +1,8 @@
+export * from './lighting-config';
+export type * from './flicker-options';
+export * from './darkness-component';
+export * from './point-light-component';
+export * from './cone-light-component';
+export * from './light-occluder-component';
+export * from './flicker-system';
+export * from './lighting-system';
diff --git a/src/engine/lighting/light-occluder-component.ts b/src/engine/lighting/light-occluder-component.ts
new file mode 100644
index 00000000..d214fa93
--- /dev/null
+++ b/src/engine/lighting/light-occluder-component.ts
@@ -0,0 +1,108 @@
+import { Component } from '../entity-component-system/component';
+import { Vector } from '../math/vector';
+
+export type OccluderShape =
+ | { kind: 'box'; width: number; height: number; anchor?: Vector }
+ | { kind: 'polygon'; vertices: Vector[] }
+ | { kind: 'circle'; radius: number };
+
+/** A shadow volume silhouette approximated by a closed polygon in screen space */
+export type PolyOccluder = { kind: 'poly'; verts: Vector[] };
+/** A shadow volume silhouette approximated by a circle in screen space */
+export type CircleOccluder = { kind: 'circle'; center: Vector; radius: number };
+/** World/screen-space occluder geometry consumed by {@apilink LightingSystem} to cast shadows */
+export type Occluder = PolyOccluder | CircleOccluder;
+
+export interface LightOccluderComponentOptions {
+ /**
+ * Shape of the occluder, independent of any collider geometry on the entity
+ */
+ shape: OccluderShape;
+ /**
+ * When false, skips shadow volume geometry generation for this occluder. Default true
+ */
+ castShadows?: boolean;
+ /**
+ * Local space coordinate offset shifting the occluder geometry away from the transform origin. Default Vector.Zero
+ */
+ offset?: Vector;
+}
+
+/**
+ * Marks an entity as a light-blocking obstacle that projects dynamic shadows in the {@apilink LightingSystem}.
+ *
+ * Box shapes are centered on the transform origin by default (`anchor: Vector.Half`, matching {@apilink Shape.Box}).
+ * Polygon and circle shapes are positioned by their own vertex/center coordinates, same as {@apilink Shape.Polygon}
+ * and {@apilink Shape.Circle} — `offset` shifts any shape further from the transform origin in local space.
+ */
+export class LightOccluderComponent extends Component {
+ // @ts-ignore
+ private static _NAME = 'LightOccluderComponent';
+
+ public castShadows: boolean;
+
+ private _shape: OccluderShape;
+ private _offset: Vector;
+ private _localVertices: Vector[] | null = null;
+
+ constructor(options: LightOccluderComponentOptions) {
+ super();
+ this._shape = options.shape;
+ this.castShadows = options.castShadows ?? true;
+ this._offset = options.offset ?? Vector.Zero;
+ }
+
+ public get shape(): OccluderShape {
+ return this._shape;
+ }
+
+ public set shape(shape: OccluderShape) {
+ this._shape = shape;
+ this._localVertices = null;
+ }
+
+ public get offset(): Vector {
+ return this._offset;
+ }
+
+ public set offset(offset: Vector) {
+ this._offset = offset;
+ this._localVertices = null;
+ }
+
+ public clone(): LightOccluderComponent {
+ return new LightOccluderComponent({
+ shape: this.shape,
+ castShadows: this.castShadows,
+ offset: this.offset.clone()
+ });
+ }
+
+ /**
+ * Evaluates the bounding shape vertices in local space, factoring in local offsets.
+ * Returns an empty array for circular primitives. Cached until `shape` or `offset` are reassigned.
+ */
+ public localVertices(): Vector[] {
+ if (this._localVertices) {
+ return this._localVertices;
+ }
+
+ if (this._shape.kind === 'circle') {
+ return (this._localVertices = []);
+ }
+
+ let baseVerts: Vector[] = [];
+ if (this._shape.kind === 'polygon') {
+ baseVerts = this._shape.vertices;
+ } else {
+ const anchor = this._shape.anchor ?? Vector.Half;
+ const left = -this._shape.width * anchor.x;
+ const top = -this._shape.height * anchor.y;
+ const right = this._shape.width - this._shape.width * anchor.x;
+ const bottom = this._shape.height - this._shape.height * anchor.y;
+ baseVerts = [new Vector(left, top), new Vector(right, top), new Vector(right, bottom), new Vector(left, bottom)];
+ }
+
+ return (this._localVertices = baseVerts.map((v) => v.add(this._offset)));
+ }
+}
diff --git a/src/engine/lighting/lighting-config.ts b/src/engine/lighting/lighting-config.ts
new file mode 100644
index 00000000..347cf1f5
--- /dev/null
+++ b/src/engine/lighting/lighting-config.ts
@@ -0,0 +1,58 @@
+import type { DeepRequired } from '../util/required';
+import type { Color } from '../color';
+
+/**
+ * Lighting simulation configuration, normalized from {@apilink EngineOptions.lighting}
+ */
+export interface LightingConfig {
+ /**
+ * Enables the scene {@apilink LightingSystem} and {@apilink FlickerSystem}. Default false.
+ *
+ * The lighting overlay is rendered with the 2D Canvas API and re-uploaded to the GPU every frame,
+ * which carries a performance penalty — this is why lighting is opt-in.
+ */
+ enabled: boolean;
+ /**
+ * The z-index of the provisioned lighting overlay. Default 100.
+ *
+ * Overridden per-instance by {@apilink LightingSystemOptions.zIndex}.
+ */
+ zIndex?: number;
+ /**
+ * World-pixel padding added around the camera frustum when culling lights/occluders. Default 64.
+ */
+ cullPadding?: number;
+ /**
+ * Ambient brightness floor (0.0 to 1.0) subtracted from darkness intensity. Default 0.05.
+ */
+ ambientIntensity?: number;
+ /**
+ * Ambient light color, blended into any {@apilink DarknessComponent} veil proportional to
+ * `ambientIntensity` (e.g. a blue ambient at low intensity produces a moonlit veil). Default `null`,
+ * meaning no color tint is applied.
+ */
+ ambientColor?: Color | null;
+ /**
+ * Fraction of a light's intensity used when painting its colored tint (0.0 to 1.0). Default 0.35.
+ */
+ tintAlphaFactor?: number;
+ /**
+ * Occluder shadow radial gradient opacity at the occluder (near) edge (0.0 to 1.0). Default 0.92.
+ */
+ shadowNearOpacity?: number;
+ /**
+ * Occluder shadow radial gradient opacity at 40% of the shadow's reach (0.0 to 1.0). Default 0.6.
+ */
+ shadowMidOpacity?: number;
+}
+
+export const getDefaultLightingConfig: () => DeepRequired = () => ({
+ enabled: false,
+ zIndex: 100,
+ cullPadding: 64,
+ ambientIntensity: 0.05,
+ ambientColor: null,
+ tintAlphaFactor: 0.35,
+ shadowNearOpacity: 0.92,
+ shadowMidOpacity: 0.6
+});
diff --git a/src/engine/lighting/lighting-system.ts b/src/engine/lighting/lighting-system.ts
new file mode 100644
index 00000000..7c4a53fa
--- /dev/null
+++ b/src/engine/lighting/lighting-system.ts
@@ -0,0 +1,989 @@
+import { System, SystemType } from '../entity-component-system/system';
+import { SystemPriority } from '../entity-component-system/priority';
+import type { World } from '../entity-component-system/world';
+import type { Query } from '../entity-component-system/query';
+import { TransformComponent } from '../entity-component-system/components/transform-component';
+import type { Scene } from '../scene';
+import type { Engine } from '../engine';
+import type { Camera } from '../camera';
+import { Vector } from '../math/vector';
+import type { AffineMatrix } from '../math/affine-matrix';
+import { CoordPlane } from '../math/coord-plane';
+import { canonicalizeAngle } from '../math/util';
+import { Color } from '../color';
+import { ScreenElement } from '../screen-element';
+import { Canvas } from '../graphics/canvas';
+import { Logger } from '../util/log';
+import { BoundingBox } from '../collision/bounding-box';
+import { DarknessComponent } from './darkness-component';
+import { PointLightComponent } from './point-light-component';
+import { ConeLightComponent } from './cone-light-component';
+import { LightOccluderComponent } from './light-occluder-component';
+import type { Occluder } from './light-occluder-component';
+
+interface DarknessEntry {
+ comp: DarknessComponent;
+ transform: TransformComponent;
+ cached: RoomClip | null;
+ lastCenterX: number | null;
+ lastCenterY: number | null;
+ lastCamX: number | null;
+ lastCamY: number | null;
+ lastZoom: number | null;
+ lastRotation: number | null;
+ lastWidth: number | null;
+ lastHeight: number | null;
+}
+
+/**
+ * A darkness room rect's clip geometry: `worldBounds` (axis-aligned in world space, since rooms have
+ * no independent rotation) for containment tests, `screenCorners` (rotated through the camera transform)
+ * for drawing/clipping the veil.
+ */
+interface RoomClip {
+ worldBounds: BoundingBox;
+ screenCorners: [Vector, Vector, Vector, Vector];
+}
+
+interface AmbientResult {
+ intensity: number;
+ color: Color | null;
+}
+
+interface LightEntry {
+ light: TLight;
+ transform: TransformComponent;
+ /** This frame's screen-space position, mutated in place every frame via an AffineMatrix `dest` write - never reallocated. */
+ screenPos: Vector;
+ screenRadius: number;
+ visible: boolean;
+}
+
+interface OccluderEntry {
+ comp: LightOccluderComponent;
+ transform: TransformComponent;
+ cached: Occluder | null;
+ cachedLocalVerts: Vector[] | null;
+ lastX: number | null;
+ lastY: number | null;
+ lastRotation: number | null;
+ lastScaleX: number | null;
+ lastScaleY: number | null;
+}
+
+/**
+ * An {@apilink Occluder}'s geometry pre-transformed to screen space for the current frame's camera -
+ * computed once per occluder per frame (in `_collectScreenOccluders`) rather than once per (light,
+ * occluder) pair, since an occluder's screen position doesn't depend on which light is asking.
+ */
+type ScreenOccluder = { kind: 'circle'; screenCenter: Vector; screenRadius: number } | { kind: 'poly'; screenVerts: Vector[] };
+
+interface ConeGradientOptions {
+ startAngle: number;
+ endAngle: number;
+ softness: number;
+}
+
+/**
+ * Finds the room darkness rect (if any) that contains a screen-space point, used to clip light/shadow
+ * drawing. The containment test is done in world space (via `camInverse`) rather than against the
+ * rotated screen quad directly, since rooms are always axis-aligned in world space.
+ */
+function findRoomClip(camInverse: AffineMatrix, screenPos: Vector, roomClips: RoomClip[]): RoomClip | undefined {
+ const worldPos = camInverse.multiply(screenPos);
+ return roomClips.find((clip) => clip.worldBounds.contains(worldPos));
+}
+
+/** Traces a closed path through a room clip's (possibly camera-rotated) screen-space quad corners */
+function pathRoomQuad(ctx: CanvasRenderingContext2D, corners: readonly [Vector, Vector, Vector, Vector]): void {
+ ctx.moveTo(corners[0].x, corners[0].y);
+ ctx.lineTo(corners[1].x, corners[1].y);
+ ctx.lineTo(corners[2].x, corners[2].y);
+ ctx.lineTo(corners[3].x, corners[3].y);
+ ctx.closePath();
+}
+
+/** Projects `v` away from `source` out to `reach` world/screen units, used to close off shadow volume far edges */
+function projectAway(v: Vector, source: Vector, reach: number): Vector {
+ const dx = v.x - source.x;
+ const dy = v.y - source.y;
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
+ return new Vector(v.x + (dx / len) * reach, v.y + (dy / len) * reach);
+}
+
+/**
+ * Computes a 2D shadow volume polygon projecting away from a light source point.
+ *
+ * Wraps the occluder's screen-space hull: the nearest hull vertex to the light becomes the shadow's
+ * near tip, the min/max angular hull vertices (as seen from the light) are the silhouette edge
+ * extremes, and those two silhouette vertices are projected out to `reach` to close off the far edge.
+ *
+ */
+function shadowPolygon(lightSource: Vector, occluderScreenVerts: Vector[], reach: number): Vector[] {
+ let nearestDistSq = Infinity;
+ let minAngle = Infinity;
+ let maxAngle = -Infinity;
+ let minAngleIdx = 0;
+ let maxAngleIdx = 0;
+ let nearestIdx = 0;
+
+ const refAngle = Math.atan2(occluderScreenVerts[0].y - lightSource.y, occluderScreenVerts[0].x - lightSource.x);
+
+ for (let i = 0; i < occluderScreenVerts.length; i++) {
+ // light source to occluder vertex
+ const rawAngle = Math.atan2(occluderScreenVerts[i].y - lightSource.y, occluderScreenVerts[i].x - lightSource.x);
+ let delta = canonicalizeAngle(rawAngle - refAngle); // [0, 2*PI)
+ if (delta > Math.PI) {
+ delta -= 2 * Math.PI; // (-PI, PI]
+ }
+ const angle = refAngle + delta;
+ if (angle < minAngle) {
+ minAngle = angle;
+ minAngleIdx = i;
+ }
+ if (angle > maxAngle) {
+ maxAngle = angle;
+ maxAngleIdx = i;
+ }
+
+ const sqDist = lightSource.squareDistance(occluderScreenVerts[i]);
+ if (sqDist < nearestDistSq) {
+ nearestDistSq = sqDist;
+ nearestIdx = i;
+ }
+ }
+
+ const farMin = projectAway(occluderScreenVerts[minAngleIdx], lightSource, reach);
+ const farMax = projectAway(occluderScreenVerts[maxAngleIdx], lightSource, reach);
+
+ return [occluderScreenVerts[nearestIdx], occluderScreenVerts[minAngleIdx], farMin, farMax, occluderScreenVerts[maxAngleIdx]];
+}
+
+/** Renders a circular profile occlusion shadow block masking light distribution. */
+function drawShadowCircle(
+ ctx: CanvasRenderingContext2D,
+ lightScreen: Vector,
+ center: Vector,
+ screenRadius: number,
+ reach: number,
+ grad: CanvasGradient
+): void {
+ const dx = center.x - lightScreen.x;
+ const dy = center.y - lightScreen.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist <= screenRadius) {
+ return;
+ }
+
+ const angleToCenter = Math.atan2(dy, dx);
+ const halfAngle = Math.asin(Math.min(1, screenRadius / dist));
+
+ const t1 = angleToCenter - halfAngle;
+ const t2 = angleToCenter + halfAngle;
+
+ const tp1 = new Vector(center.x + Math.cos(t1 + Math.PI / 2) * screenRadius, center.y + Math.sin(t1 + Math.PI / 2) * screenRadius);
+ const tp2 = new Vector(center.x + Math.cos(t2 - Math.PI / 2) * screenRadius, center.y + Math.sin(t2 - Math.PI / 2) * screenRadius);
+
+ const far1 = projectAway(tp1, lightScreen, reach);
+ const far2 = projectAway(tp2, lightScreen, reach);
+
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.moveTo(tp1.x, tp1.y);
+ ctx.lineTo(far1.x, far1.y);
+ ctx.lineTo(far2.x, far2.y);
+ ctx.lineTo(tp2.x, tp2.y);
+ ctx.arc(center.x, center.y, screenRadius, t2 - Math.PI / 2, t1 + Math.PI / 2, true);
+ ctx.closePath();
+ ctx.fill();
+}
+
+export interface LightingSystemOptions {
+ /**
+ * The z-index of the provisioned lighting overlay. Defaults to 100.
+ */
+ zIndex?: number;
+ /**
+ * Fixed anchor screen positioning coordinate. When omitted the overlay is anchored to the
+ * top left of the full visible canvas ({@apilink Screen.unsafeArea}) every frame.
+ */
+ pos?: Vector;
+ /**
+ * Fixed resolution dimensions. Defaults to tracking the screen {@apilink Screen.resolution} when omitted.
+ */
+ size?: { width: number; height: number };
+ /**
+ * Hook an external ScreenElement host rather than provisioning an independent one.
+ * The caller becomes responsible for its position and size.
+ */
+ screenElement?: ScreenElement;
+ /**
+ * Overrides {@apilink EngineOptions.lighting}'s `ambientIntensity` for this scene's instance.
+ */
+ ambientIntensity?: number;
+ /**
+ * Overrides {@apilink EngineOptions.lighting}'s `ambientColor` for this scene's instance.
+ */
+ ambientColor?: Color | null;
+}
+
+/**
+ * Composite canvas-driven visibility system tracking ambient, darkness,
+ * light masks, and ray projected shadow volumes.
+ *
+ * Renders via a {@apilink Canvas} graphic on a screen-space {@apilink ScreenElement} named 'lighting'.
+ * The raster pipeline per frame: ambient-blended darkness veil → room rect fills/clips → camera frustum
+ * cull → per-light destination-out punch (with occluder shadows subtracted) → colored tint passes.
+ *
+ * Enabled scene-wide via {@apilink EngineOptions.lighting}, or add an instance manually to a scene's world.
+ *
+ * **Potentially performance impacting** — the overlay is rasterized with the 2D Canvas API and re-uploaded
+ * to the GPU every frame.
+ *
+ * Known limitations: circle occluder radii scale uniformly with `Math.min(scale.x, scale.y)` so a
+ * non-uniformly scaled occluder won't cast an elliptical shadow, darkness room rects have no independent
+ * rotation of their own (though they do rotate along with the camera), and polygon shadow volumes can
+ * split at corners when the silhouette edge is not face on to the light.
+ */
+export class LightingSystem extends System {
+ static priority = SystemPriority.Highest;
+ public readonly systemType = SystemType.Draw;
+
+ private _options: LightingSystemOptions;
+ private _engine!: Engine;
+ private _scene!: Scene;
+
+ private _lightingEntity!: ScreenElement;
+ private _lightingCanvas!: Canvas;
+ private _offscreen: HTMLCanvasElement | null = null;
+ private _offscreenCtx: CanvasRenderingContext2D | null = null;
+
+ private _darknessQuery!: Query;
+ private _pointQuery!: Query;
+ private _coneQuery!: Query;
+ private _occluderQuery!: Query;
+
+ private _darknessEntries: DarknessEntry[] = [];
+ private _pointLights: LightEntry[] = [];
+ private _coneLights: LightEntry[] = [];
+ private _occluderEntries: OccluderEntry[] = [];
+ private _ambientScratch: AmbientResult = { intensity: 0, color: null };
+
+ constructor(options?: LightingSystemOptions) {
+ super();
+ this._options = options ?? {};
+ }
+
+ public initialize(world: World, scene: Scene): void {
+ this._scene = scene;
+ this._engine = scene.engine;
+
+ this._initDarkness(world);
+ this._initPointLights(world);
+ this._initConeLights(world);
+ this._initOccluders(world);
+ this._initCanvas(scene);
+ }
+
+ private _initDarkness(world: World): void {
+ this._darknessQuery = world.query([DarknessComponent, TransformComponent]);
+ for (const e of this._darknessQuery.entities) {
+ this._darknessEntries.push({
+ comp: e.get(DarknessComponent)!,
+ transform: e.get(TransformComponent)!,
+ cached: null,
+ lastCenterX: null,
+ lastCenterY: null,
+ lastCamX: null,
+ lastCamY: null,
+ lastZoom: null,
+ lastRotation: null,
+ lastWidth: null,
+ lastHeight: null
+ });
+ }
+ this._darknessQuery.entityAdded$.subscribe((e) => {
+ this._darknessEntries.push({
+ comp: e.get(DarknessComponent)!,
+ transform: e.get(TransformComponent)!,
+ cached: null,
+ lastCenterX: null,
+ lastCenterY: null,
+ lastCamX: null,
+ lastCamY: null,
+ lastZoom: null,
+ lastRotation: null,
+ lastWidth: null,
+ lastHeight: null
+ });
+ });
+ this._darknessQuery.entityRemoved$.subscribe((e) => {
+ const comp = e.get(DarknessComponent)!;
+ const index = this._darknessEntries.findIndex((entry) => entry.comp === comp);
+ if (index > -1) {
+ this._darknessEntries.splice(index, 1);
+ }
+ });
+ }
+
+ private _initPointLights(world: World): void {
+ this._pointQuery = world.query([PointLightComponent, TransformComponent]);
+ for (const e of this._pointQuery.entities) {
+ this._pointLights.push({
+ light: e.get(PointLightComponent)!,
+ transform: e.get(TransformComponent)!,
+ screenPos: new Vector(0, 0),
+ screenRadius: 0,
+ visible: false
+ });
+ }
+ this._pointQuery.entityAdded$.subscribe((e) => {
+ this._pointLights.push({
+ light: e.get(PointLightComponent)!,
+ transform: e.get(TransformComponent)!,
+ screenPos: new Vector(0, 0),
+ screenRadius: 0,
+ visible: false
+ });
+ });
+ this._pointQuery.entityRemoved$.subscribe((e) => {
+ const light = e.get(PointLightComponent)!;
+ const index = this._pointLights.findIndex((entry) => entry.light === light);
+ if (index > -1) {
+ this._pointLights.splice(index, 1);
+ }
+ });
+ }
+
+ private _initConeLights(world: World): void {
+ this._coneQuery = world.query([ConeLightComponent, TransformComponent]);
+ for (const e of this._coneQuery.entities) {
+ this._coneLights.push({
+ light: e.get(ConeLightComponent)!,
+ transform: e.get(TransformComponent)!,
+ screenPos: new Vector(0, 0),
+ screenRadius: 0,
+ visible: false
+ });
+ }
+ this._coneQuery.entityAdded$.subscribe((e) => {
+ this._coneLights.push({
+ light: e.get(ConeLightComponent)!,
+ transform: e.get(TransformComponent)!,
+ screenPos: new Vector(0, 0),
+ screenRadius: 0,
+ visible: false
+ });
+ });
+ this._coneQuery.entityRemoved$.subscribe((e) => {
+ const light = e.get(ConeLightComponent)!;
+ const index = this._coneLights.findIndex((entry) => entry.light === light);
+ if (index > -1) {
+ this._coneLights.splice(index, 1);
+ }
+ });
+ }
+
+ private _initOccluders(world: World): void {
+ this._occluderQuery = world.query([LightOccluderComponent, TransformComponent]);
+ for (const e of this._occluderQuery.entities) {
+ this._occluderEntries.push({
+ comp: e.get(LightOccluderComponent)!,
+ transform: e.get(TransformComponent)!,
+ cached: null,
+ cachedLocalVerts: null,
+ lastX: null,
+ lastY: null,
+ lastRotation: null,
+ lastScaleX: null,
+ lastScaleY: null
+ });
+ }
+ this._occluderQuery.entityAdded$.subscribe((e) => {
+ this._occluderEntries.push({
+ comp: e.get(LightOccluderComponent)!,
+ transform: e.get(TransformComponent)!,
+ cached: null,
+ cachedLocalVerts: null,
+ lastX: null,
+ lastY: null,
+ lastRotation: null,
+ lastScaleX: null,
+ lastScaleY: null
+ });
+ });
+ this._occluderQuery.entityRemoved$.subscribe((e) => {
+ const comp = e.get(LightOccluderComponent)!;
+ const index = this._occluderEntries.findIndex((entry) => entry.comp === comp);
+ if (index > -1) {
+ this._occluderEntries.splice(index, 1);
+ }
+ });
+ }
+
+ private _initCanvas(scene: Scene): void {
+ this._offscreen = document.createElement('canvas');
+ this._offscreenCtx = this._offscreen.getContext('2d');
+
+ const initialWidth = this._options.size?.width ?? this._engine.screen.resolution.width;
+ const initialHeight = this._options.size?.height ?? this._engine.screen.resolution.height;
+
+ this._offscreen.width = initialWidth;
+ this._offscreen.height = initialHeight;
+
+ this._lightingCanvas = new Canvas({
+ width: initialWidth,
+ height: initialHeight,
+ draw: (ctx) => this._renderLightingCanvas(ctx)
+ });
+
+ if (this._options.screenElement) {
+ if (process.env.NODE_ENV === 'development') {
+ Logger.getInstance().debug('LightingSystem is using a provided ScreenElement, its position and size will not be managed');
+ }
+ this._lightingEntity = this._options.screenElement;
+ } else {
+ this._lightingEntity = new ScreenElement({
+ name: 'lighting',
+ pos: this._options.pos ?? Vector.Zero,
+ width: initialWidth,
+ height: initialHeight,
+ z: this._options.zIndex ?? this._engine.lighting.zIndex,
+ coordPlane: CoordPlane.Screen,
+ color: Color.Transparent
+ });
+ scene.add(this._lightingEntity);
+ }
+
+ this._lightingEntity.graphics.use(this._lightingCanvas);
+ }
+
+ public update(elapsed: number): void {
+ const screen = this._engine.screen;
+ this._lightingEntity.transform.coordPlane = CoordPlane.Screen;
+
+ // Camera.transform isn't finalized for this frame until Camera.draw() applies fixed-update
+ // interpolation/pixel-snapping - normally that's done by GraphicsSystem (SystemPriority.Average),
+ const graphicsContext = this._engine.graphicsContext;
+ graphicsContext.save();
+ this._scene.camera.draw(graphicsContext);
+ graphicsContext.restore();
+
+ if (!this._options.pos && !this._options.screenElement) {
+ // Anchor the overlay to the top left of the full visible canvas, the unsafeArea spans the
+ // whole resolution and its topLeft is negative by the clip amount in clipping display modes
+ this._lightingEntity.pos = screen.unsafeArea.topLeft;
+ }
+
+ if (!this._options.size) {
+ if (this._lightingCanvas.width !== screen.resolution.width || this._lightingCanvas.height !== screen.resolution.height) {
+ const w = screen.resolution.width;
+ const h = screen.resolution.height;
+
+ this._lightingCanvas.width = w;
+ this._lightingCanvas.height = h;
+
+ if (this._lightingEntity.graphics.current) {
+ this._lightingEntity.graphics.current.width = w;
+ this._lightingEntity.graphics.current.height = h;
+ }
+
+ if (this._offscreen) {
+ this._offscreen.width = w;
+ this._offscreen.height = h;
+ }
+ }
+ }
+
+ // rasterize() forces this frame's light/darkness/occluder state to be re-rendered.
+ // it is called here rather than left lazy so the raster cost is attributed to
+ this._lightingCanvas.rasterize();
+ }
+
+ /**
+ * Writes the scene's effective ambient intensity/color into `dest` (avoids allocating a fresh
+ * object every frame).
+ */
+ private _computeAmbient(dest: AmbientResult): void {
+ dest.intensity = this._options.ambientIntensity ?? this._engine.lighting.ambientIntensity;
+ dest.color = this._options.ambientColor ?? this._engine.lighting.ambientColor;
+ }
+
+ private _darknessFill(d: DarknessComponent, ambientIntensity: number, ambientColor: Color | null): string {
+ const effectiveAlpha = Math.max(0, d.intensity - ambientIntensity);
+ let color = d.color;
+ if (ambientColor) {
+ // Tint the veil toward the ambient light color proportional to its intensity
+ color = new Color(
+ d.color.r + (ambientColor.r - d.color.r) * ambientIntensity,
+ d.color.g + (ambientColor.g - d.color.g) * ambientIntensity,
+ d.color.b + (ambientColor.b - d.color.b) * ambientIntensity
+ );
+ }
+ return Color.fromRGB(color.r, color.g, color.b, effectiveAlpha).toRGBA();
+ }
+
+ private _drawDarknessVeil(
+ ctx: CanvasRenderingContext2D,
+ w: number,
+ h: number,
+ camera: Camera,
+ ambientIntensity: number,
+ ambientColor: Color | null
+ ): RoomClip[] {
+ const roomClips: RoomClip[] = [];
+
+ for (let i = 0; i < this._darknessEntries.length; i++) {
+ const entry = this._darknessEntries[i];
+ const d = entry.comp;
+
+ if (d.width === Infinity || d.height === Infinity) {
+ ctx.fillStyle = this._darknessFill(d, ambientIntensity, ambientColor);
+ ctx.fillRect(0, 0, w, h);
+ continue;
+ }
+
+ const clip = this._computeRoomClip(entry, camera);
+ roomClips.push(clip);
+
+ ctx.fillStyle = this._darknessFill(d, ambientIntensity, ambientColor);
+ ctx.beginPath();
+ pathRoomQuad(ctx, clip.screenCorners);
+ ctx.fill();
+ }
+
+ return roomClips;
+ }
+
+ /**
+ * Computes (and caches) a room darkness rect's world bounds and camera-rotated screen quad. Only
+ * rebuilds when the room's world position/dimensions or the camera's pos/zoom/rotation changed since
+ * last frame - the screen quad depends on the camera's full transform, not just zoom/rotation, so
+ * camera pan alone must invalidate it too or the room's screen position goes stale.
+ */
+ private _computeRoomClip(entry: DarknessEntry, camera: Camera): RoomClip {
+ const d = entry.comp;
+ const pos = entry.transform.pos;
+ const camPos = camera.pos;
+ const unchanged =
+ entry.cached &&
+ entry.lastCenterX === pos.x &&
+ entry.lastCenterY === pos.y &&
+ entry.lastCamX === camPos.x &&
+ entry.lastCamY === camPos.y &&
+ entry.lastZoom === camera.zoom &&
+ entry.lastRotation === camera.rotation &&
+ entry.lastWidth === d.width &&
+ entry.lastHeight === d.height;
+
+ if (unchanged) {
+ return entry.cached!;
+ }
+
+ const hw = d.width / 2;
+ const hh = d.height / 2;
+ const worldBounds = BoundingBox.fromDimension(d.width, d.height, Vector.Half, pos);
+ const screenCorners: [Vector, Vector, Vector, Vector] = [
+ camera.transform.multiply(new Vector(pos.x - hw, pos.y - hh)),
+ camera.transform.multiply(new Vector(pos.x + hw, pos.y - hh)),
+ camera.transform.multiply(new Vector(pos.x + hw, pos.y + hh)),
+ camera.transform.multiply(new Vector(pos.x - hw, pos.y + hh))
+ ];
+
+ entry.cached = { worldBounds, screenCorners };
+ entry.lastCenterX = pos.x;
+ entry.lastCenterY = pos.y;
+ entry.lastCamX = camPos.x;
+ entry.lastCamY = camPos.y;
+ entry.lastZoom = camera.zoom;
+ entry.lastRotation = camera.rotation;
+ entry.lastWidth = d.width;
+ entry.lastHeight = d.height;
+
+ return entry.cached;
+ }
+
+ private _inCameraView(cullBounds: BoundingBox, worldPos: Vector, radius: number): boolean {
+ return (
+ cullBounds.left < worldPos.x + radius &&
+ worldPos.x - radius < cullBounds.right &&
+ cullBounds.top < worldPos.y + radius &&
+ worldPos.y - radius < cullBounds.bottom
+ );
+ }
+
+ /**
+ * Computes (once per frame, shared by the erase and tint passes) whether each light in `entries` is
+ * enabled and within `cullBounds`, and its screen-space position/radius. Writes into each entry's
+ * persistent `screenPos` in place rather than allocating a new Vector.
+ */
+ private _updateLightVisibility(
+ entries: LightEntry[],
+ cullBounds: BoundingBox,
+ camera: Camera
+ ): void {
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ const light = entry.light;
+ if (!light.enabled || !this._inCameraView(cullBounds, entry.transform.pos, light.radius)) {
+ entry.visible = false;
+ continue;
+ }
+ entry.visible = true;
+ camera.transform.multiply(entry.transform.pos, entry.screenPos);
+ entry.screenRadius = light.radius * camera.zoom;
+ }
+ }
+
+ /**
+ * Computes (and caches) an occluder's world-space shadow geometry. Only re-projects through the
+ * entity's transform when its position/rotation/scale or the component's shape/offset changed
+ * since last frame — avoids rebuilding shadow geometry for static occluders every frame.
+ */
+ private _computeOccluderGeometry(entry: OccluderEntry): Occluder {
+ const xf = entry.transform.get();
+ const pos = xf.pos;
+ const rotation = xf.rotation;
+ const scale = xf.scale;
+ const localVerts = entry.comp.localVertices();
+
+ const unchanged =
+ entry.cached &&
+ entry.cachedLocalVerts === localVerts &&
+ entry.lastX === pos.x &&
+ entry.lastY === pos.y &&
+ entry.lastRotation === rotation &&
+ entry.lastScaleX === scale.x &&
+ entry.lastScaleY === scale.y;
+
+ if (unchanged) {
+ return entry.cached!;
+ }
+
+ entry.cachedLocalVerts = localVerts;
+ entry.lastX = pos.x;
+ entry.lastY = pos.y;
+ entry.lastRotation = rotation;
+ entry.lastScaleX = scale.x;
+ entry.lastScaleY = scale.y;
+
+ if (entry.comp.shape.kind === 'circle') {
+ entry.cached = {
+ kind: 'circle',
+ center: xf.apply(entry.comp.offset),
+ radius: entry.comp.shape.radius * Math.min(Math.abs(scale.x), Math.abs(scale.y))
+ };
+ } else {
+ entry.cached = {
+ kind: 'poly',
+ verts: localVerts.map((v) => xf.apply(v))
+ };
+ }
+
+ return entry.cached;
+ }
+
+ private _collectOccluders(): Occluder[] {
+ const occluders: Occluder[] = [];
+ for (let i = 0; i < this._occluderEntries.length; i++) {
+ const entry = this._occluderEntries[i];
+ if (!entry.comp.castShadows) {
+ continue;
+ }
+ occluders.push(this._computeOccluderGeometry(entry));
+ }
+ return occluders;
+ }
+
+ /**
+ * Transforms every occluder's (cached, world-space) geometry to screen space once for this frame,
+ * so `_drawOccluderShadows` can reuse the same screen-space geometry for every light instead of
+ * re-transforming it once per (light, occluder) pair.
+ */
+ private _collectScreenOccluders(camera: Camera): ScreenOccluder[] {
+ const occluders = this._collectOccluders();
+ const screenOccluders: ScreenOccluder[] = [];
+ for (let i = 0; i < occluders.length; i++) {
+ const occ = occluders[i];
+ if (occ.kind === 'circle') {
+ screenOccluders.push({
+ kind: 'circle',
+ screenCenter: camera.transform.multiply(occ.center),
+ screenRadius: occ.radius * camera.zoom
+ });
+ } else {
+ screenOccluders.push({ kind: 'poly', screenVerts: occ.verts.map((v) => camera.transform.multiply(v)) });
+ }
+ }
+ return screenOccluders;
+ }
+
+ private _drawOccluderShadows(ctx: CanvasRenderingContext2D, lightScreen: Vector, occluders: ScreenOccluder[], reach: number): void {
+ const shadowNearOpacity = this._engine.lighting.shadowNearOpacity;
+ const shadowMidOpacity = this._engine.lighting.shadowMidOpacity;
+ for (let i = 0; i < occluders.length; i++) {
+ const occ = occluders[i];
+ if (occ.kind === 'circle') {
+ const dx = occ.screenCenter.x - lightScreen.x;
+ const dy = occ.screenCenter.y - lightScreen.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ const nearDist = Math.max(0, dist - occ.screenRadius);
+ const grad = ctx.createRadialGradient(lightScreen.x, lightScreen.y, nearDist, lightScreen.x, lightScreen.y, reach);
+ grad.addColorStop(0, `rgba(0,0,0,${shadowNearOpacity})`);
+ grad.addColorStop(0.4, `rgba(0,0,0,${shadowMidOpacity})`);
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
+
+ drawShadowCircle(ctx, lightScreen, occ.screenCenter, occ.screenRadius, reach, grad);
+ } else {
+ const poly = shadowPolygon(lightScreen, occ.screenVerts, reach);
+ if (poly.length < 3) {
+ continue;
+ }
+
+ const nearMidX = (poly[0].x + poly[3].x) / 2;
+ const nearMidY = (poly[0].y + poly[3].y) / 2;
+ const nearDist = Math.sqrt((nearMidX - lightScreen.x) ** 2 + (nearMidY - lightScreen.y) ** 2);
+
+ const grad = ctx.createRadialGradient(lightScreen.x, lightScreen.y, nearDist, lightScreen.x, lightScreen.y, reach);
+ grad.addColorStop(0, `rgba(0,0,0,${shadowNearOpacity})`);
+ grad.addColorStop(0.4, `rgba(0,0,0,${shadowMidOpacity})`);
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
+
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.moveTo(poly[0].x, poly[0].y);
+ for (let j = 1; j < poly.length; j++) {
+ ctx.lineTo(poly[j].x, poly[j].y);
+ }
+ ctx.closePath();
+ ctx.fill();
+ }
+ }
+ }
+
+ private _paintPointGradient(c: CanvasRenderingContext2D, screenPos: Vector, screenRadius: number, alpha: number): void {
+ const grad = c.createRadialGradient(screenPos.x, screenPos.y, 0, screenPos.x, screenPos.y, screenRadius);
+ grad.addColorStop(0, `rgba(255,255,255,${alpha})`);
+ grad.addColorStop(0.6, `rgba(255,255,255,${alpha * 0.6})`);
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ c.fillStyle = grad;
+ c.beginPath();
+ c.arc(screenPos.x, screenPos.y, screenRadius, 0, Math.PI * 2);
+ c.fill();
+ }
+
+ private _paintConeGradient(
+ c: CanvasRenderingContext2D,
+ screenPos: Vector,
+ screenRadius: number,
+ alpha: number,
+ cone: ConeGradientOptions
+ ): void {
+ const softEdgeStart = Math.max(0, 1 - cone.softness);
+
+ const grad = c.createRadialGradient(screenPos.x, screenPos.y, 0, screenPos.x, screenPos.y, screenRadius);
+ grad.addColorStop(0, `rgba(255,255,255,${alpha})`);
+ grad.addColorStop(softEdgeStart * 0.7, `rgba(255,255,255,${alpha * 0.5})`);
+ grad.addColorStop(softEdgeStart, `rgba(255,255,255,${alpha * 0.2})`);
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+
+ c.fillStyle = grad;
+ c.beginPath();
+ c.moveTo(screenPos.x, screenPos.y);
+ c.arc(screenPos.x, screenPos.y, screenRadius, cone.startAngle, cone.endAngle);
+ c.closePath();
+ c.fill();
+ }
+
+ /**
+ * Draws a single light's shape into the offscreen scratch canvas, punches its occluder shadows
+ * out of it (destination-out), then punches the scratch out of the darkness veil (lights erase
+ * darkness).
+ */
+ private _drawLight(
+ ctx: CanvasRenderingContext2D,
+ screenPos: Vector,
+ screenRadius: number,
+ alpha: number,
+ occluders: ScreenOccluder[],
+ roomClips: RoomClip[],
+ w: number,
+ h: number,
+ camera: Camera,
+ cone?: ConeGradientOptions
+ ): void {
+ if (!this._offscreenCtx || !this._offscreen) {
+ return;
+ }
+ this._offscreenCtx.clearRect(0, 0, w, h);
+
+ const activeClip = findRoomClip(camera.inverse, screenPos, roomClips);
+
+ if (activeClip) {
+ this._offscreenCtx.save();
+ this._offscreenCtx.beginPath();
+ pathRoomQuad(this._offscreenCtx, activeClip.screenCorners);
+ this._offscreenCtx.clip();
+ }
+
+ const shadowReach = activeClip ? Vector.distance(activeClip.screenCorners[0], activeClip.screenCorners[2]) : Math.sqrt(w ** 2 + h ** 2);
+
+ this._offscreenCtx.globalCompositeOperation = 'source-over';
+ if (cone) {
+ this._paintConeGradient(this._offscreenCtx, screenPos, screenRadius, alpha, cone);
+ } else {
+ this._paintPointGradient(this._offscreenCtx, screenPos, screenRadius, alpha);
+ }
+
+ this._offscreenCtx.globalCompositeOperation = 'destination-out';
+ this._drawOccluderShadows(this._offscreenCtx, screenPos, occluders, shadowReach);
+
+ if (activeClip) {
+ this._offscreenCtx.restore();
+ }
+
+ ctx.save();
+ if (activeClip) {
+ ctx.beginPath();
+ pathRoomQuad(ctx, activeClip.screenCorners);
+ ctx.clip();
+ }
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.drawImage(this._offscreen, 0, 0);
+ ctx.restore();
+ }
+
+ /**
+ * Paints the additive colored tint for a non-white light, clipped to its containing room rect
+ */
+ private _drawColorTint(
+ ctx: CanvasRenderingContext2D,
+ light: PointLightComponent | ConeLightComponent,
+ screenPos: Vector,
+ camera: Camera,
+ roomClips: RoomClip[],
+ wedge?: { start: number; end: number }
+ ): void {
+ const activeClip = findRoomClip(camera.inverse, screenPos, roomClips);
+
+ ctx.save();
+ if (activeClip) {
+ ctx.beginPath();
+ pathRoomQuad(ctx, activeClip.screenCorners);
+ ctx.clip();
+ }
+ ctx.globalCompositeOperation = 'source-over';
+
+ const screenRadius = light.radius * camera.zoom;
+ const tintAlpha = light.currentIntensity * this._engine.lighting.tintAlphaFactor;
+
+ const grad = ctx.createRadialGradient(screenPos.x, screenPos.y, 0, screenPos.x, screenPos.y, screenRadius);
+ grad.addColorStop(0, Color.fromRGB(light.color.r, light.color.g, light.color.b, tintAlpha).toRGBA());
+ grad.addColorStop(0.5, Color.fromRGB(light.color.r, light.color.g, light.color.b, tintAlpha * 0.4).toRGBA());
+ grad.addColorStop(1, Color.fromRGB(light.color.r, light.color.g, light.color.b, 0).toRGBA());
+
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ if (wedge) {
+ ctx.moveTo(screenPos.x, screenPos.y);
+ ctx.arc(screenPos.x, screenPos.y, screenRadius, wedge.start, wedge.end);
+ ctx.closePath();
+ } else {
+ ctx.arc(screenPos.x, screenPos.y, screenRadius, 0, Math.PI * 2);
+ }
+ ctx.fill();
+ ctx.restore();
+ }
+
+ private _drawPointLights(
+ ctx: CanvasRenderingContext2D,
+ occluders: ScreenOccluder[],
+ roomClips: RoomClip[],
+ w: number,
+ h: number,
+ camera: Camera
+ ): void {
+ for (let i = 0; i < this._pointLights.length; i++) {
+ const entry = this._pointLights[i];
+ if (!entry.visible) {
+ continue;
+ }
+ this._drawLight(ctx, entry.screenPos, entry.screenRadius, entry.light.currentIntensity, occluders, roomClips, w, h, camera);
+ }
+ }
+
+ private _drawConeLights(
+ ctx: CanvasRenderingContext2D,
+ occluders: ScreenOccluder[],
+ roomClips: RoomClip[],
+ w: number,
+ h: number,
+ camera: Camera
+ ): void {
+ for (let i = 0; i < this._coneLights.length; i++) {
+ const entry = this._coneLights[i];
+ if (!entry.visible) {
+ continue;
+ }
+ const light = entry.light;
+ const halfAngle = light.angle / 2;
+ const screenDirection = light.direction + camera.rotation;
+
+ this._drawLight(ctx, entry.screenPos, entry.screenRadius, light.currentIntensity, occluders, roomClips, w, h, camera, {
+ startAngle: screenDirection - halfAngle,
+ endAngle: screenDirection + halfAngle,
+ softness: light.softness
+ });
+ }
+ }
+
+ private _drawColorTints(ctx: CanvasRenderingContext2D, roomClips: RoomClip[], camera: Camera): void {
+ for (let i = 0; i < this._pointLights.length; i++) {
+ const entry = this._pointLights[i];
+ if (!entry.visible || entry.light.color.equal(Color.White)) {
+ continue;
+ }
+ this._drawColorTint(ctx, entry.light, entry.screenPos, camera, roomClips);
+ }
+
+ for (let i = 0; i < this._coneLights.length; i++) {
+ const entry = this._coneLights[i];
+ if (!entry.visible || entry.light.color.equal(Color.White)) {
+ continue;
+ }
+ const light = entry.light;
+ const halfAngle = light.angle / 2;
+ const screenDirection = light.direction + camera.rotation;
+ this._drawColorTint(ctx, light, entry.screenPos, camera, roomClips, {
+ start: screenDirection - halfAngle,
+ end: screenDirection + halfAngle
+ });
+ }
+ }
+
+ private _renderLightingCanvas(ctx: CanvasRenderingContext2D): void {
+ const camera = this._scene.camera;
+
+ const w = this._lightingCanvas.width;
+ const h = this._lightingCanvas.height;
+
+ ctx.clearRect(0, 0, w, h);
+
+ this._computeAmbient(this._ambientScratch);
+ const roomClips = this._drawDarknessVeil(ctx, w, h, camera, this._ambientScratch.intensity, this._ambientScratch.color);
+
+ const cullPadding = this._engine.lighting.cullPadding;
+ const vp = camera.viewport;
+ const cullBounds = new BoundingBox(vp.left - cullPadding, vp.top - cullPadding, vp.right + cullPadding, vp.bottom + cullPadding);
+ this._updateLightVisibility(this._pointLights, cullBounds, camera);
+ this._updateLightVisibility(this._coneLights, cullBounds, camera);
+
+ const occluders = this._collectScreenOccluders(camera);
+
+ this._drawPointLights(ctx, occluders, roomClips, w, h, camera);
+ this._drawConeLights(ctx, occluders, roomClips, w, h, camera);
+ this._drawColorTints(ctx, roomClips, camera);
+ }
+}
diff --git a/src/engine/lighting/point-light-component.ts b/src/engine/lighting/point-light-component.ts
new file mode 100644
index 00000000..5db592e8
--- /dev/null
+++ b/src/engine/lighting/point-light-component.ts
@@ -0,0 +1,63 @@
+import { Component } from '../entity-component-system/component';
+import { Color } from '../color';
+import { Logger } from '../util/log';
+import type { FlickerOptions } from './flicker-options';
+
+export interface PointLightComponentOptions {
+ /**
+ * Color of the light. Non-white colors are painted as an additive tint. Default Color.White
+ */
+ color?: Color;
+ /**
+ * Base intensity of the light (0.0 to 1.0). Default 1.0
+ */
+ intensity?: number;
+ /**
+ * Radius of the light in world pixels. Default 150
+ */
+ radius?: number;
+ /**
+ * Optional flicker animation applied by the {@apilink FlickerSystem}
+ */
+ flicker?: FlickerOptions;
+ /**
+ * Default true
+ */
+ enabled?: boolean;
+}
+
+/**
+ * Emits light uniformly in all directions from the owning entity's position,
+ * cutting through any {@apilink DarknessComponent} veil.
+ */
+export class PointLightComponent extends Component {
+ // @ts-ignore
+ private static _NAME = 'PointLightComponent';
+
+ public color: Color;
+ public intensity: number;
+ public radius: number;
+ public flicker?: FlickerOptions;
+ public enabled: boolean;
+ /**
+ * Runtime intensity after flicker calculations are applied, written by the {@apilink FlickerSystem}
+ */
+ public currentIntensity: number;
+
+ constructor(options?: PointLightComponentOptions) {
+ super();
+ this.color = options?.color ?? Color.White;
+ this.intensity = options?.intensity ?? 1.0;
+ this.radius = options?.radius ?? 150;
+ this.flicker = options?.flicker;
+ this.enabled = options?.enabled ?? true;
+ this.currentIntensity = this.intensity;
+ if (process.env.NODE_ENV === 'development') {
+ if (this.radius < 0 || this.intensity < 0) {
+ Logger.getInstance().warn(
+ `PointLightComponent created with negative radius (${this.radius}) or intensity (${this.intensity}), this is likely a bug`
+ );
+ }
+ }
+ }
+}
diff --git a/src/engine/lighting/tsconfig.json b/src/engine/lighting/tsconfig.json
new file mode 100644
index 00000000..20d5e2e0
--- /dev/null
+++ b/src/engine/lighting/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "strict": true
+ }
+}
diff --git a/src/engine/scene.ts b/src/engine/scene.ts
index f3ea2388..21e57c3d 100644
--- a/src/engine/scene.ts
+++ b/src/engine/scene.ts
@@ -40,6 +40,8 @@ import { InputHost } from './input/input-host';
import { PointerScope } from './input/pointer-scope';
import { getDefaultPhysicsConfig } from './collision/physics-config';
import { PauseSystem } from './util/pause-system';
+import { FlickerSystem } from './lighting/flicker-system';
+import { LightingSystem } from './lighting/lighting-system';
export class PreLoadEvent {
loader!: DefaultLoader;
@@ -360,6 +362,16 @@ export class Scene implements CanInitialize, CanActiv
// Initialize camera first
this.camera._initialize(engine);
+ // Lighting is opt-in, skip provisioning the systems entirely when disabled
+ if (this.engine.lighting.enabled) {
+ if (!this.world.systemManager.get(FlickerSystem)) {
+ this.world.add(new FlickerSystem());
+ }
+ if (!this.world.systemManager.get(LightingSystem)) {
+ this.world.add(new LightingSystem());
+ }
+ }
+
this.world.systemManager.initialize();
for (const plugin of engine.plugins) {
diff --git a/src/spec/assets/images/lighting-system-spec/box-occluder-shadow.png b/src/spec/assets/images/lighting-system-spec/box-occluder-shadow.png
new file mode 100644
index 0000000000000000000000000000000000000000..e71a7a76689eed2b250e99364959eac694fff6f0
GIT binary patch
literal 10042
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
o;s5{Z1zL#=3=C|bb}}=AOnl {
+ let engine: ex.Engine;
+
+ afterEach(() => {
+ engine.stop();
+ engine.dispose();
+ engine = null;
+ });
+
+ describe('engine gating', () => {
+ it('is not added to scenes by default', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ await engine.currentScene._initialize(engine);
+
+ expect(engine.lighting.enabled).toBe(false);
+ expect(engine.currentScene.world.systemManager.get(ex.LightingSystem)).toBeFalsy();
+ expect(engine.currentScene.world.systemManager.get(ex.FlickerSystem)).toBeFalsy();
+ });
+
+ it('is added to scenes when the lighting engine option is enabled', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+
+ expect(engine.lighting.enabled).toBe(true);
+ expect(engine.currentScene.world.systemManager.get(ex.LightingSystem)).toBeInstanceOf(ex.LightingSystem);
+ expect(engine.currentScene.world.systemManager.get(ex.FlickerSystem)).toBeInstanceOf(ex.FlickerSystem);
+ });
+
+ it('can be added manually to a scene when the engine option is disabled', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const lighting = new ex.LightingSystem();
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+
+ expect(engine.currentScene.world.systemManager.get(ex.LightingSystem)).toBe(lighting);
+ });
+
+ it('does not double add when a custom instance is present and the engine option is enabled', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ const lighting = new ex.LightingSystem({ zIndex: 50 });
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+
+ const lightingSystems = engine.currentScene.world.systemManager.systems.filter((s) => s instanceof ex.LightingSystem);
+ expect(lightingSystems).toEqual([lighting]);
+ });
+
+ it('still adds a FlickerSystem when a custom LightingSystem instance is pre-added', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ engine.currentScene.world.add(new ex.LightingSystem({ zIndex: 50 }));
+ await engine.currentScene._initialize(engine);
+
+ expect(engine.currentScene.world.systemManager.get(ex.FlickerSystem)).toBeInstanceOf(ex.FlickerSystem);
+ });
+ });
+
+ describe('provisioning', () => {
+ it('provisions a screen-space host screen element sized to the screen resolution', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+
+ const [lightingEntity] = engine.currentScene.world.entityManager.getByName('lighting');
+ expect(lightingEntity).toBeDefined();
+ const transform = lightingEntity.get(ex.TransformComponent);
+ expect(transform.coordPlane).toBe(ex.CoordPlane.Screen);
+ expect(transform.z).toBe(100);
+
+ const graphics = lightingEntity.get(ex.GraphicsComponent);
+ expect(graphics.current).toBeInstanceOf(ex.Canvas);
+ expect(graphics.current.width).toBe(engine.screen.resolution.width);
+ expect(graphics.current.height).toBe(engine.screen.resolution.height);
+ });
+
+ it('respects a fixed size option and does not resync to the screen', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const lighting = new ex.LightingSystem({ size: { width: 32, height: 16 } });
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ engine.currentScene.update(engine, 16);
+ engine.currentScene.draw(engine.graphicsContext, 16);
+
+ const [lightingEntity] = engine.currentScene.world.entityManager.getByName('lighting');
+ expect(lightingEntity.get(ex.GraphicsComponent).current.width).toBe(32);
+ expect(lightingEntity.get(ex.GraphicsComponent).current.height).toBe(16);
+ });
+
+ it('respects a fixed pos option and does not resync to the screen unsafe area', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const lighting = new ex.LightingSystem({ pos: ex.vec(5, 5) });
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ engine.currentScene.update(engine, 16);
+ engine.currentScene.draw(engine.graphicsContext, 16);
+
+ const [lightingEntity] = engine.currentScene.world.entityManager.getByName('lighting');
+ expect(lightingEntity.get(ex.TransformComponent).pos).toBeVector(ex.vec(5, 5));
+ });
+
+ it('uses a provided ScreenElement host instead of provisioning its own', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const customHost = new ex.ScreenElement({ name: 'custom-lighting-host', width: 100, height: 100 });
+ engine.currentScene.add(customHost);
+ const lighting = new ex.LightingSystem({ screenElement: customHost });
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ expect(engine.currentScene.world.entityManager.getByName('lighting').length).toBe(0);
+ expect(customHost.graphics.current).toBeInstanceOf(ex.Canvas);
+ });
+
+ it('renders the lighting canvas during the draw pass', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const darkness = new ex.Actor();
+ darkness.addComponent(new ex.DarknessComponent({ intensity: 0.9 }));
+ engine.currentScene.add(darkness);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const renderSpy = vi.spyOn(lighting as any, '_renderLightingCanvas');
+
+ engine.currentScene.update(engine, 16);
+ engine.currentScene.draw(engine.graphicsContext, 16);
+
+ expect(renderSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('darkness and ambient light', () => {
+ it('blends the darkness veil toward the ambient light color', async () => {
+ engine = TestUtils.engine({
+ width: 100,
+ height: 100,
+ lighting: { enabled: true, ambientColor: ex.Color.fromRGB(0, 0, 255), ambientIntensity: 0.5 }
+ });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const room = new ex.Actor();
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(room);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ const [r, g, b, a] = ctx.getImageData(50, 50, 1, 1).data;
+ // Black veil blended halfway toward blue ambient at alpha 1 - 0.5
+ expect(r).toBe(0);
+ expect(g).toBe(0);
+ expect(b).toBeCloseTo(128, -1);
+ expect(a).toBeCloseTo(128, -1);
+ });
+
+ it('subtracts ambient intensity from the darkness veil opacity', async () => {
+ engine = TestUtils.engine({
+ width: 100,
+ height: 100,
+ lighting: { enabled: true, ambientColor: ex.Color.White, ambientIntensity: 0.3 }
+ });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const room = new ex.Actor();
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 0.8 }));
+ engine.currentScene.add(room);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ const [, , , a] = ctx.getImageData(50, 50, 1, 1).data;
+ expect(a / 255).toBeCloseTo(0.8 - 0.3, 1);
+ });
+
+ it('overrides the engine ambient config with a per-instance ambientIntensity/ambientColor', async () => {
+ engine = TestUtils.engine({
+ width: 100,
+ height: 100,
+ lighting: { enabled: true, ambientColor: ex.Color.White, ambientIntensity: 0 }
+ });
+ const lighting = new ex.LightingSystem({ ambientColor: ex.Color.fromRGB(0, 0, 255), ambientIntensity: 0.5 });
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const room = new ex.Actor();
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(room);
+ engine.currentScene.update(engine, 16);
+
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ const [r, g, b, a] = ctx.getImageData(50, 50, 1, 1).data;
+ // Black veil blended halfway toward blue ambient at alpha 1 - 0.5, per the instance override
+ expect(r).toBe(0);
+ expect(g).toBe(0);
+ expect(b).toBeCloseTo(128, -1);
+ expect(a).toBeCloseTo(128, -1);
+ });
+ });
+
+ describe('camera rotation', () => {
+ it('rotates a cone light wedge along with the camera', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+ engine.currentScene.camera.pos = ex.vec(50, 50);
+
+ const world = new ex.Actor();
+ world.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(world);
+
+ const flashlight = new ex.Actor({ pos: ex.vec(50, 50) });
+ // Half-angle 45deg pointing right (direction 0) - "right" and "down" probes sit squarely
+ // inside/outside the wedge so a 90deg camera rotation flips which one is lit.
+ flashlight.addComponent(new ex.ConeLightComponent({ radius: 40, angle: Math.PI / 2, direction: 0, softness: 0 }));
+ engine.currentScene.add(flashlight);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+
+ engine.currentScene.update(engine, 16);
+ (lighting as any)._renderLightingCanvas(ctx);
+ const rightAtRotation0 = ctx.getImageData(75, 50, 1, 1).data[3];
+ const downAtRotation0 = ctx.getImageData(50, 75, 1, 1).data[3];
+ expect(rightAtRotation0).toBeLessThan(200);
+ expect(downAtRotation0).toBeGreaterThan(200);
+
+ engine.currentScene.camera.rotation = Math.PI / 2;
+ engine.currentScene.update(engine, 16);
+ (lighting as any)._renderLightingCanvas(ctx);
+ const rightAtRotation90 = ctx.getImageData(75, 50, 1, 1).data[3];
+ const downAtRotation90 = ctx.getImageData(50, 75, 1, 1).data[3];
+ expect(rightAtRotation90).toBeGreaterThan(200);
+ expect(downAtRotation90).toBeLessThan(200);
+ });
+
+ it('rotates a finite darkness room rect along with the camera', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+ engine.currentScene.camera.pos = ex.vec(50, 50);
+ engine.currentScene.camera.rotation = Math.PI / 2;
+
+ // A wide, short room - at 90deg rotation this becomes narrow and tall on screen
+ const room = new ex.Actor({ pos: ex.vec(50, 50) });
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1, width: 60, height: 20 }));
+ engine.currentScene.add(room);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ // Was inside the unrotated (wide) rect, now outside the rotated (narrow) one
+ expect(ctx.getImageData(75, 50, 1, 1).data[3]).toBe(0);
+ // Was outside the unrotated (short) rect, now inside the rotated (tall) one
+ expect(ctx.getImageData(50, 75, 1, 1).data[3]).toBeGreaterThan(200);
+ });
+
+ it('follows a panning camera instead of leaving a finite darkness room rect stuck at its first screen position', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+ engine.currentScene.camera.pos = ex.vec(50, 50);
+
+ const room = new ex.Actor({ pos: ex.vec(50, 50) });
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1, width: 60, height: 20 }));
+ engine.currentScene.add(room);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBeGreaterThan(200); // room center - dark
+
+ // Pan the camera away from the (unmoved) room without touching zoom/rotation
+ engine.currentScene.camera.pos = ex.vec(150, 50);
+ engine.currentScene.update(engine, 16);
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ // The room's screen quad must have followed the pan - this pixel is no longer inside it
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBe(0);
+ });
+
+ it('clips a light to its containing room rect using the rotated geometry, not the unrotated one', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+ engine.currentScene.camera.pos = ex.vec(50, 50);
+ engine.currentScene.camera.rotation = Math.PI / 2;
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ // A wide, short room - at 90deg rotation this becomes narrow (x) and tall (y) on screen
+ const room = new ex.Actor({ pos: ex.vec(50, 50) });
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1, width: 60, height: 20 }));
+ engine.currentScene.add(room);
+
+ const lamp = new ex.Actor({ pos: ex.vec(50, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 25 }));
+ engine.currentScene.add(lamp);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ // Within the light's raw radius but outside the rotated (narrow) room - must stay clipped/dark
+ expect(ctx.getImageData(65, 50, 1, 1).data[3]).toBeGreaterThan(200);
+ // Within the light's raw radius and inside the rotated (tall) room - should be lit
+ expect(ctx.getImageData(50, 70, 1, 1).data[3]).toBeLessThan(200);
+ });
+
+ it('casts a polygon occluder shadow on the correct side when the occluder lands due-left of the light', async () => {
+ // Regression test: at camera.rotation = PI, an occluder positioned to the world-right of a light
+ // ends up screen-left of it - exactly the atan2 branch cut (+-PI) shadowPolygon's silhouette
+ // min/max angle tracking must unwrap correctly across, or it picks the wrong pair of "extreme"
+ // vertices and the shadow lands on the wrong side entirely.
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+ engine.currentScene.camera.pos = ex.vec(50, 50);
+ engine.currentScene.camera.rotation = Math.PI;
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const lamp = new ex.Actor({ pos: ex.vec(50, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 90 }));
+ engine.currentScene.add(lamp);
+
+ // World-right of the light -> screen-left of it once rotated by PI
+ const crate = new ex.Actor({ pos: ex.vec(80, 50) });
+ crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 10, height: 10 } }));
+ engine.currentScene.add(crate);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ // Between the light and the occluder's near edge - nothing blocks it, should be lit
+ expect(ctx.getImageData(35, 50, 1, 1).data[3]).toBeLessThan(200);
+ // Beyond the occluder's far edge, in its shadow - should stay dark
+ expect(ctx.getImageData(10, 50, 1, 1).data[3]).toBeGreaterThan(200);
+ });
+ });
+
+ describe('occluder shadows', () => {
+ it('casts a shadow behind a circle occluder', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const lamp = new ex.Actor({ pos: ex.vec(30, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 90 }));
+ engine.currentScene.add(lamp);
+
+ const pillar = new ex.Actor({ pos: ex.vec(60, 50) });
+ pillar.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'circle', radius: 8 } }));
+ engine.currentScene.add(pillar);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ expect(ctx.getImageData(45, 50, 1, 1).data[3]).toBeLessThan(200); // before the pillar - lit
+ expect(ctx.getImageData(90, 50, 1, 1).data[3]).toBeGreaterThan(200); // behind the pillar - shadowed
+ });
+
+ it('does not throw and still lights the scene when a circle occluder contains the light itself', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const lamp = new ex.Actor({ pos: ex.vec(50, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 40 }));
+ engine.currentScene.add(lamp);
+
+ const bubble = new ex.Actor({ pos: ex.vec(50, 50) });
+ bubble.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'circle', radius: 30 } }));
+ engine.currentScene.add(bubble);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+
+ expect(() => (lighting as any)._renderLightingCanvas(ctx)).not.toThrow();
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBeLessThan(200);
+ });
+
+ it('skips shadow casting for an occluder with castShadows disabled', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const lamp = new ex.Actor({ pos: ex.vec(30, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 100 }));
+ engine.currentScene.add(lamp);
+
+ const crate = new ex.Actor({ pos: ex.vec(60, 50) });
+ crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 10, height: 10 }, castShadows: false }));
+ engine.currentScene.add(crate);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ // Beyond the (shadow-disabled) crate - should still be lit since it casts no shadow
+ expect(ctx.getImageData(75, 50, 1, 1).data[3]).toBeLessThan(200);
+ });
+ });
+
+ describe('colored light tint', () => {
+ it('paints an additive colored tint for a non-white point light', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const lamp = new ex.Actor({ pos: ex.vec(50, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ color: ex.Color.fromRGB(255, 0, 0), radius: 40 }));
+ engine.currentScene.add(lamp);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ const [r, g, b, a] = ctx.getImageData(50, 50, 1, 1).data;
+ expect(r).toBeGreaterThan(200);
+ expect(g).toBe(0);
+ expect(b).toBe(0);
+ expect(a).toBeGreaterThan(0);
+ });
+
+ it('paints an additive colored wedge tint for a non-white cone light', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const flashlight = new ex.Actor({ pos: ex.vec(50, 50) });
+ flashlight.addComponent(
+ new ex.ConeLightComponent({ color: ex.Color.fromRGB(0, 0, 255), radius: 40, angle: Math.PI / 2, direction: 0, softness: 0 })
+ );
+ engine.currentScene.add(flashlight);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ // Inside the wedge (direction 0 = right)
+ const [r, g, b, a] = ctx.getImageData(70, 50, 1, 1).data;
+ expect(r).toBe(0);
+ expect(g).toBe(0);
+ expect(b).toBeGreaterThan(200);
+ expect(a).toBeGreaterThan(0);
+ });
+ });
+
+ describe('culling and lifecycle', () => {
+ it('does not throw and leaves the scene unlit when the only light is far outside the culled camera view', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+ engine.currentScene.camera.pos = ex.vec(50, 50);
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const farAway = new ex.Actor({ pos: ex.vec(10_000, 10_000) });
+ farAway.addComponent(new ex.PointLightComponent({ radius: 10 }));
+ engine.currentScene.add(farAway);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+
+ expect(() => (lighting as any)._renderLightingCanvas(ctx)).not.toThrow();
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBeGreaterThan(200);
+ });
+
+ it('skips a disabled light at render time even when its currentIntensity has not been zeroed', async () => {
+ // No FlickerSystem in this scene (LightingSystem added manually), so currentIntensity stays at its
+ // constructor default - isolates the render-time `!light.enabled` check from FlickerSystem's own
+ // zeroing of currentIntensity for disabled lights.
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const lighting = new ex.LightingSystem();
+ engine.currentScene.world.add(lighting);
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const torch = new ex.Actor({ pos: ex.vec(50, 50) });
+ const light = new ex.PointLightComponent({ enabled: false, radius: 40 });
+ torch.addComponent(light);
+ engine.currentScene.add(torch);
+ engine.currentScene.update(engine, 16);
+ expect(light.currentIntensity).toBe(1); // never zeroed - no FlickerSystem ran
+
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBeGreaterThan(200);
+ });
+
+ it('stops rendering a light once its component is removed', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const veil = new ex.Actor();
+ veil.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(veil);
+
+ const torch = new ex.Actor({ pos: ex.vec(50, 50) });
+ torch.addComponent(new ex.PointLightComponent({ radius: 40 }));
+ engine.currentScene.add(torch);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBeLessThan(200);
+
+ torch.removeComponent(ex.PointLightComponent, true);
+ engine.currentScene.update(engine, 16);
+ (lighting as any)._renderLightingCanvas(ctx);
+ expect(ctx.getImageData(50, 50, 1, 1).data[3]).toBeGreaterThan(200);
+ });
+
+ it('resizes the lighting canvas and offscreen buffer when the screen resolution changes', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const [lightingEntity] = engine.currentScene.world.entityManager.getByName('lighting');
+ expect(lightingEntity.get(ex.GraphicsComponent).current.width).toBe(100);
+
+ engine.screen.resolution = { width: 150, height: 80 };
+ engine.currentScene.update(engine, 16);
+ engine.currentScene.draw(engine.graphicsContext, 16);
+
+ expect(lightingEntity.get(ex.GraphicsComponent).current.width).toBe(150);
+ expect(lightingEntity.get(ex.GraphicsComponent).current.height).toBe(80);
+ });
+
+ it('reuses cached room/occluder geometry when rendered twice with nothing changed', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ambientIntensity: 0 } });
+ await engine.currentScene._initialize(engine);
+ engine.screen.setCurrentCamera(engine.currentScene.camera);
+
+ const room = new ex.Actor({ pos: ex.vec(50, 50) });
+ room.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1, width: 60, height: 40 }));
+ engine.currentScene.add(room);
+
+ const lamp = new ex.Actor({ pos: ex.vec(30, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 40 }));
+ engine.currentScene.add(lamp);
+
+ const crate = new ex.Actor({ pos: ex.vec(60, 50) });
+ crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 10, height: 10 } }));
+ engine.currentScene.add(crate);
+ engine.currentScene.update(engine, 16);
+
+ const lighting = engine.currentScene.world.systemManager.get(ex.LightingSystem);
+ const raster = document.createElement('canvas');
+ raster.width = 100;
+ raster.height = 100;
+ const ctx = raster.getContext('2d');
+ (lighting as any)._renderLightingCanvas(ctx);
+ const first = Array.from(ctx.getImageData(0, 0, 100, 100).data);
+
+ (lighting as any)._renderLightingCanvas(ctx);
+ const second = Array.from(ctx.getImageData(0, 0, 100, 100).data);
+
+ expect(second).toEqual(first);
+ });
+ });
+
+ describe('flicker', () => {
+ it('forces disabled lights to zero intensity', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+
+ const torch = new ex.Actor({ pos: ex.vec(50, 50) });
+ const pointLight = new ex.PointLightComponent({ intensity: 1.0, radius: 50, enabled: false });
+ torch.addComponent(pointLight);
+ engine.currentScene.add(torch);
+
+ engine.currentScene.update(engine, 16);
+
+ expect(pointLight.currentIntensity).toBe(0);
+ });
+
+ it('holds lights without flicker at their base intensity', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+
+ const torch = new ex.Actor({ pos: ex.vec(50, 50) });
+ const pointLight = new ex.PointLightComponent({ intensity: 0.7 });
+ torch.addComponent(pointLight);
+ engine.currentScene.add(torch);
+
+ engine.currentScene.update(engine, 16);
+
+ expect(pointLight.currentIntensity).toBe(0.7);
+ });
+
+ it('modulates flickering lights within amplitude and never below zero', async () => {
+ engine = TestUtils.engine({ width: 100, height: 100, lighting: true });
+ await engine.currentScene._initialize(engine);
+
+ const torch = new ex.Actor({ pos: ex.vec(50, 50) });
+ const pointLight = new ex.PointLightComponent({
+ intensity: 0.5,
+ flicker: { frequency: 2.5, amplitude: 0.2, secondaryFrequency: 5.1 }
+ });
+ const spot = new ex.Actor({ pos: ex.vec(25, 25) });
+ const coneLight = new ex.ConeLightComponent({
+ intensity: 0.1,
+ flicker: { frequency: 13, amplitude: 1 }
+ });
+ torch.addComponent(pointLight);
+ spot.addComponent(coneLight);
+ engine.currentScene.add(torch);
+ engine.currentScene.add(spot);
+
+ for (let i = 0; i < 20; i++) {
+ engine.currentScene.update(engine, 16);
+ expect(pointLight.currentIntensity).toBeGreaterThanOrEqual(0.5 - 0.2);
+ expect(pointLight.currentIntensity).toBeLessThanOrEqual(0.5 + 0.2);
+ expect(coneLight.currentIntensity).toBeGreaterThanOrEqual(0);
+ expect(coneLight.currentIntensity).toBeLessThanOrEqual(0.1 + 1);
+ }
+ });
+ });
+
+ describe('components', () => {
+ it('lighting config has the documented ambient defaults', () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ expect(engine.lighting.ambientIntensity).toBe(0.05);
+ expect(engine.lighting.ambientColor).toBe(null);
+ });
+
+ it('components have the documented defaults', () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const darkness = new ex.DarknessComponent();
+ expect(darkness.color).toEqual(ex.Color.fromRGB(0, 0, 10));
+ expect(darkness.intensity).toBe(0.85);
+ expect(darkness.width).toBe(Infinity);
+ expect(darkness.height).toBe(Infinity);
+
+ const point = new ex.PointLightComponent();
+ expect(point.color).toEqual(ex.Color.White);
+ expect(point.intensity).toBe(1.0);
+ expect(point.radius).toBe(150);
+ expect(point.flicker).toBeUndefined();
+ expect(point.enabled).toBe(true);
+ expect(point.currentIntensity).toBe(1.0);
+
+ const cone = new ex.ConeLightComponent();
+ expect(cone.radius).toBe(200);
+ expect(cone.angle).toBeCloseTo(Math.PI / 3);
+ expect(cone.direction).toBe(0);
+ expect(cone.softness).toBe(0.25);
+ expect(cone.currentIntensity).toBe(1.0);
+
+ const occluder = new ex.LightOccluderComponent({ shape: { kind: 'circle', radius: 5 } });
+ expect(occluder.castShadows).toBe(true);
+ expect(occluder.offset).toEqual(ex.Vector.Zero);
+ });
+
+ it('evaluates occluder local vertices with offset applied', () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const box = new ex.LightOccluderComponent({
+ shape: { kind: 'box', width: 10, height: 20 },
+ offset: ex.vec(1, 2)
+ });
+ expect(box.localVertices()).toEqual([ex.vec(-4, -8), ex.vec(6, -8), ex.vec(6, 12), ex.vec(-4, 12)]);
+
+ const polygon = new ex.LightOccluderComponent({
+ shape: { kind: 'polygon', vertices: [ex.vec(0, 0), ex.vec(5, 0), ex.vec(0, 5)] },
+ offset: ex.vec(-1, -1)
+ });
+ expect(polygon.localVertices()).toEqual([ex.vec(-1, -1), ex.vec(4, -1), ex.vec(-1, 4)]);
+
+ const circle = new ex.LightOccluderComponent({ shape: { kind: 'circle', radius: 5 }, offset: ex.vec(0, 10) });
+ expect(circle.localVertices()).toEqual([]);
+ });
+
+ it('can clone an entity carrying a LightOccluderComponent', () => {
+ engine = TestUtils.engine({ width: 100, height: 100 });
+ const wall = new ex.Actor({ pos: ex.vec(10, 10), color: ex.Color.Gray });
+ wall.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 10, height: 10 }, offset: ex.vec(1, 2) }));
+
+ expect(() => wall.clone()).not.toThrow();
+ const clonedOccluder = wall.clone().get(ex.LightOccluderComponent);
+ expect(clonedOccluder.shape).toEqual({ kind: 'box', width: 10, height: 10 });
+ expect(clonedOccluder.offset).toEqual(ex.vec(1, 2));
+ });
+ });
+
+ describe('@visual', () => {
+ async function setupLightingEngine(lighting?: Partial): Promise {
+ const visualEngine = TestUtils.engine({ width: 100, height: 100, lighting: { enabled: true, ...lighting } });
+ await visualEngine.currentScene._initialize(visualEngine);
+ visualEngine.screen.setCurrentCamera(visualEngine.currentScene.camera);
+ return visualEngine;
+ }
+
+ function drawFrame(visualEngine: ex.Engine): void {
+ visualEngine.graphicsContext.clear();
+ visualEngine.currentScene.update(visualEngine, 16);
+ visualEngine.currentScene.draw(visualEngine.graphicsContext, 16);
+ visualEngine.graphicsContext.flush();
+ }
+
+ it('renders a point light punching through the darkness veil', async () => {
+ engine = await setupLightingEngine({ ambientIntensity: 0 });
+
+ const world = new ex.Actor();
+ world.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(world);
+
+ const lamp = new ex.Actor({ pos: ex.vec(50, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 40 }));
+ engine.currentScene.add(lamp);
+
+ drawFrame(engine);
+ await expect(engine.canvas).toEqualImage('/src/spec/assets/images/lighting-system-spec/point-light.png');
+ });
+
+ it('renders a cone light wedge', async () => {
+ engine = await setupLightingEngine({ ambientIntensity: 0 });
+
+ const world = new ex.Actor();
+ world.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(world);
+
+ const flashlight = new ex.Actor({ pos: ex.vec(20, 50) });
+ flashlight.addComponent(new ex.ConeLightComponent({ radius: 70, angle: Math.PI / 4, direction: 0, softness: 0.25 }));
+ engine.currentScene.add(flashlight);
+
+ drawFrame(engine);
+ await expect(engine.canvas).toEqualImage('/src/spec/assets/images/lighting-system-spec/cone-light.png');
+ });
+
+ it('renders occluder shadows cast by a box occluder', async () => {
+ engine = await setupLightingEngine({ ambientIntensity: 0 });
+
+ const world = new ex.Actor();
+ world.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 1 }));
+ engine.currentScene.add(world);
+
+ const lamp = new ex.Actor({ pos: ex.vec(30, 50) });
+ lamp.addComponent(new ex.PointLightComponent({ radius: 60 }));
+ engine.currentScene.add(lamp);
+
+ const crate = new ex.Actor({ pos: ex.vec(60, 50) });
+ crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 10, height: 10 } }));
+ engine.currentScene.add(crate);
+
+ drawFrame(engine);
+ await expect(engine.canvas).toEqualImage('/src/spec/assets/images/lighting-system-spec/box-occluder-shadow.png');
+ });
+
+ it('renders a colored ambient tint over the darkness veil', async () => {
+ engine = await setupLightingEngine({ ambientColor: ex.Color.fromRGB(60, 60, 200), ambientIntensity: 0.3 });
+
+ const world = new ex.Actor();
+ world.addComponent(new ex.DarknessComponent({ color: ex.Color.Black, intensity: 0.9 }));
+ engine.currentScene.add(world);
+
+ drawFrame(engine);
+ await expect(engine.canvas).toEqualImage('/src/spec/assets/images/lighting-system-spec/colored-ambient.png');
+ });
+ });
+});