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'); + }); + }); +});