Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4377e52
feat(lights): WebGL-native procedural Light2d shader (closes #1430)
obiot May 6, 2026
5e71841
fix(lights): apply renderer.currentTransform in WebGLRenderer.drawLight
obiot May 6, 2026
c47d996
fix(lights): drop uAspect uniform — quad UV already handles ellipses
obiot May 6, 2026
92def62
refactor(lights): fold viewMatrix application into blitTexture
obiot May 6, 2026
70d6d22
style(lights): use setBatcher's return value in drawLight
obiot May 6, 2026
d9f5a9f
refactor(effects): rename Light2dEffect → RadialGradientEffect
obiot May 6, 2026
2561964
refactor(lights): use the melonJS Gradient class for the Canvas bake
obiot May 6, 2026
9f08b38
perf(lights): use toCanvasGradient + fillRect instead of toCanvas + d…
obiot May 6, 2026
015bdb2
revert: use Gradient.toCanvas + drawImage for the Canvas light path
obiot May 6, 2026
a883b01
refactor(lights): address Copilot review concerns on PR #1434
obiot May 6, 2026
03d4ba3
docs(lights): refresh stale comments in Light2d + _getWhitePixel
obiot May 6, 2026
15704ec
fix(lights): flush pending vertices before mutating shader/texture state
obiot May 6, 2026
c5e91bf
perf(lights): batch consecutive Light2d draws via per-vertex tint
obiot May 6, 2026
f2c0a2e
fix(batchers): sync currentTextureUnit in blitTexture
obiot May 6, 2026
03619eb
fix(lights): address CodeRabbit review findings
obiot May 6, 2026
e195eae
docs(examples): chain Vignette + Scanline post-effects in Lights demo
obiot May 6, 2026
88d185e
docs(lights): fix stale references and document UV-space caveat
obiot May 6, 2026
055df5c
docs(changelog): cover setBatcher reconcile + blitTexture unit sync
obiot May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions packages/examples/src/examples/lights/ExampleLights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
input,
Light2d,
loader,
ScanlineEffect,
Sprite,
Stage,
state,
Expand Down Expand Up @@ -62,11 +63,20 @@ class PlayScreen extends Stage {
// post-effect FBO so the vignette below applies to the lighting too.
this.ambientLight.parseCSS("#1117");

// vignette demonstrates that lights are now captured by post-effects
// (the ambient fill darkens at the screen edges as the vignette adds
// its own darkening on top).
game.viewport.shader = new VignetteEffect(
video.renderer as Parameters<typeof VignetteEffect>[0],
// chain TWO post-effects on the viewport to exercise the multi-pass
// FBO ping-pong (Renderable.postEffects), not the single-shader fast
// path. Lights MUST render inside that FBO bracket — if they escape
// it, you'll see the gradients sit on top of the scanlines / past
// the vignette instead of being darkened with everything else.
// This is the visual contract `Stage.drawLighting` + the procedural
// drawLight pipeline have to honor on both Canvas and WebGL.
const renderer = video.renderer as Parameters<typeof VignetteEffect>[0];
game.viewport.addPostEffect(new VignetteEffect(renderer));
game.viewport.addPostEffect(
new ScanlineEffect(renderer, {
opacity: 0.18,
curvature: 0.015,
}),
);

// white light follows the mouse; orange light stays put
Expand Down
6 changes: 6 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
- Light2d: `illuminationOnly` boolean (default `false`) — when set to `true` the light's own gradient texture isn't drawn, but the light still feeds the cutout pass and the lit-sprite shader's per-frame uniforms. Useful for SpriteIlluminator-style demos where the light should be a logical source, not a visible glow.
- Light2d: `lightHeight` property (default `max(radiusX, radiusY) * 0.075`) — the Z-axis component of the light direction in the lit shader's `dot(normal, lightDir)`. Low values graze across the surface (dramatic normal-map detail); high values make lighting head-on (more uniform brightness).
- Two new examples: `Normal Map` (three procedurally-generated 3D orbs in red/green/blue base colors reacting to a moving cursor light, demonstrating that the normal-map controls shape while the color texture controls hue) and `SpriteIlluminator` (faithful port of CodeAndWeb's cocos2d-x dynamic-lighting demo: animated character + foreground prop tile lit by a moving cursor light, full SpriteIlluminator + TexturePacker asset workflow).
- WebGL: procedural `Light2d` rendering (closes #1430). New `Renderer.drawLight(light)` API replaces the per-light offscreen-canvas pipeline. The WebGL renderer renders lights as quads through a shared `RadialGradientEffect` fragment shader (linear radial falloff, matches `createRadialGradient`'s two-stop interpolation for visual parity with the Canvas path); no per-light GL texture is allocated. Per-light color and intensity flow through the vertex `tint` attribute (RGB = color, A = intensity), so consecutive `drawLight` calls accumulate into the quad batcher and flush together — N lights = 1 program switch + 1 flush instead of 2N + N. The Canvas renderer caches a small `Gradient` config object per light in a `WeakMap` (rebuilt only when radii / color / intensity change), rasterizes it via `Gradient.toCanvas()` into a single shared `CanvasRenderTarget`, and composites with `drawImage` — the offscreen render target is shared across every gradient in the engine, so the heavy bitmap memory stays at O(1). Light2d itself becomes pure data — no `CanvasRenderTarget`, no shader knowledge, no renderer reference.
- Light2d: `setRadii(radiusX, radiusY)` method. Updates the radii and the underlying bbox (via `Renderable.resize(width, height)`) so `getBounds()` and `getVisibleArea()` track the new size. Fixes a latent bug where mutating `radiusX/Y` after construction left the rendered light stale while the cutout pass moved. Named `setRadii` (not `resize`) so it does not shadow `Renderable.resize(width, height)`.
- WebGL: new `RadialGradientEffect` shader effect (`video/webgl/effects/radialGradient.js`). Generic procedural radial gradient — solid color at center fading linearly to transparent at the edge of the host quad. Constructor accepts `{ color, intensity }`, plus `setColor` / `setIntensity` setters. The quad's UV-space aspect handles elliptical falloff naturally — no per-axis uniform required. Color and intensity stack from two sources multiplied together: the `uColor` / `uIntensity` uniforms (the natural API for a single-instance shader attached to a renderable) AND the per-vertex tint in `aColor` (used by `WebGLRenderer.drawLight` to encode each light's color + intensity in the vertex stream so multiple lights sharing this shader batch into a single draw call). Used internally by `WebGLRenderer.drawLight`; available for any custom path that wants a soft procedural circle.

### Changed
- Lights are now rendered inside the camera's post-effect FBO bracket — vignette, scanlines, ColorMatrix and any other camera shader effect now wrap the lighting output (closes #1398). The `Stage.draw()` lighting block has been removed; rendering happens via the world tree walk and a public `Stage.drawLighting(renderer, camera)` pass invoked by each camera (subclassable for custom lighting).
Expand All @@ -20,6 +23,9 @@
- `Stage.drawLighting`: ambient-overlay cutouts now align with each light's rendered gradient when the camera is scrolled or the light is parented to a translated container. `light.getVisibleArea()` returns world-space coordinates (via `getBounds()` → `getAbsolutePosition()`), but `drawLighting` runs after the world container's `translate(-cameraPos)` has been popped from the renderer — so cutouts were landing at world coords inside a camera-local FBO. The fix re-applies the camera's world-to-screen translate inside `drawLighting`. Visible only when `ambientLight` is set with a scrolling camera (e.g. a torch on the player in a dark side-scrolling level): pre-fix, the bright gradient followed the player but the dark fill stopped cutting around it, leaving a phantom hole at a fixed world position.
- WebGL: vertex attribute leak between batchers. Each batcher owns its own attribute layout (e.g. `LitQuadBatcher` has 5 attributes at stride 28; `PrimitiveBatcher` has 3 at stride 20). On batcher switch the previous batcher's enabled vertex attribute locations stayed live with their old stride/offset — when the new batcher's smaller vertex buffer was uploaded, GL validated the stale attributes against it and threw `INVALID_OPERATION: glDrawArrays: Vertex buffer is not big enough for the draw call`. Fixed by `Batcher.unbind()` (disables the locations the batcher enabled), called from `WebGLRenderer.setBatcher` whenever the active batcher changes. Latent before the lit pipeline because no two existing batchers had attribute layouts that overlapped that way.
- WebGL: `gl.useProgram` leak after `setLightUniforms`. `Camera2d.draw()` calls `renderer.setLightUniforms(...)` every frame even when the scene has zero lights, which writes `uLightCount = 0` to `LitQuadBatcher`'s shader. `GLShader.setUniform()` calls `gl.useProgram()` internally to guarantee the right program is active for the upload, leaving the GL state pointing at the lit shader even when the active batcher is the unlit one. The next sprite draw (4-attribute vertex data) was being fed to the lit shader (5 attributes), rendering as garbage. Fixed by restoring the active batcher's program after `setLightUniforms` if it isn't `LitQuadBatcher` itself.
- Light2d: stale gradient texture on radius/color/intensity change. The pre-#1430 implementation baked the gradient once at construction and re-used the same `CanvasRenderTarget` indefinitely; mutating `radiusX/Y`, `color`, or `intensity` left the rendered light out of sync with `getVisibleArea()` (the cutout pass) and the lit shader (which already used current values). The new `drawLight` path auto-invalidates on property change — the Canvas-side `Gradient` cache rebuilds on next draw when any of `radiusX`/`radiusY`/`color`/`intensity` differ from the cached entry, and the WebGL renderer reads `light.color` / `light.intensity` live each call and packs them into the per-vertex tint, so there is nothing to invalidate.
- WebGL: stale custom shader leaking past `WebGLRenderer.setBatcher`. The previous fast path returned early when the active batcher matched and no shader was provided, so a custom shader bound by a prior call (e.g. a post-effect FBO blit, or `drawLight`'s radial-gradient program) could stay bound and silently render the next sprite batch through the wrong program. `setBatcher` now always reconciles the active shader to either the explicitly-passed one or the batcher's `defaultShader`; `useShader` is internally a no-op when the shader already matches, so the hot path stays cheap. Latent before this PR because no caller stacked a custom shader followed by a default-shader call without an intervening `useShader`.
- WebGL: `QuadBatcher.blitTexture` / `LitQuadBatcher.blitTexture` did not sync `currentTextureUnit` / `boundTextures[0]` with the GL state they mutated. After a blit (typically a post-effect FBO) ran with a non-zero `currentTextureUnit`, subsequent `bindTexture2D` calls could short-circuit on the stale cached unit and bind the new texture on the wrong unit, producing rendering corruption on the next sprite batch. Both methods now set `currentTextureUnit = 0` + `boundTextures[0] = source` on bind and reset to `-1` on unbind so the next bind re-issues `gl.activeTexture` cleanly.

## [19.2.0] (melonJS 2) - _2026-04-29_

Expand Down
134 changes: 52 additions & 82 deletions packages/melonjs/src/renderable/light2d.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { game } from "../application/application.ts";
import { ellipsePool } from "./../geometries/ellipse.ts";
import { colorPool } from "./../math/color.ts";
import state from "../state/state.ts";
import CanvasRenderTarget from "../video/rendertarget/canvasrendertarget.js";
import Renderable from "./renderable.js";

/**
Expand All @@ -12,68 +10,25 @@ import Renderable from "./renderable.js";
* @import Renderer from "./../video/renderer.js";
*/

/** @ignore */
function createGradient(light) {
const context = light.texture.context;

const x1 = light.texture.width / 2;
const y1 = light.texture.height / 2;

const radiusX = light.radiusX;
const radiusY = light.radiusY;

let scaleX;
let scaleY;
let invScaleX;
let invScaleY;
let gradient;

light.texture.clear();

if (radiusX >= radiusY) {
scaleX = 1;
invScaleX = 1;
scaleY = radiusY / radiusX;
invScaleY = radiusX / radiusY;
gradient = context.createRadialGradient(
x1,
y1 * invScaleY,
0,
x1,
radiusY * invScaleY,
radiusX,
);
} else {
scaleY = 1;
invScaleY = 1;
scaleX = radiusX / radiusY;
invScaleX = radiusY / radiusX;
gradient = context.createRadialGradient(
x1 * invScaleX,
y1,
0,
x1 * invScaleX,
y1,
radiusY,
);
}

gradient.addColorStop(0, light.color.toRGBA(light.intensity));
gradient.addColorStop(1, light.color.toRGBA(0.0));

context.fillStyle = gradient;

context.setTransform(scaleX, 0, 0, scaleY, 0, 0);
context.fillRect(
0,
0,
light.texture.width * invScaleX,
light.texture.height * invScaleY,
);
}

/**
* A 2D point light.
*
* Light2d carries the *properties* of a light (color, radii, intensity,
* height, flags, position) and asks the active renderer to render it
* via `renderer.drawLight(this)`. The renderer picks the right machinery:
*
* - **WebGL**: a single quad through a shared procedural radial-falloff
* fragment shader (`RadialGradientEffect`). One shader is reused across
* every Light2d on the renderer; no per-light texture is allocated.
* - **Canvas**: a small `Gradient` config object (cached per-light in a
* `WeakMap` and rebuilt only when radii / color / intensity change),
* rasterized via `Gradient.toCanvas()` on every draw into a single
* shared `CanvasRenderTarget`, then composited with `drawImage`. The
* per-light cache holds only the gradient stops, not the bitmap — the
* render target is one-per-engine.
*
* Light2d itself is renderer-agnostic — no shader knowledge, no canvas
* allocation, no renderer reference held.
* @see stage.lights
*/
export default class Light2d extends Renderable {
Expand Down Expand Up @@ -152,8 +107,8 @@ export default class Light2d extends Renderable {
*/
this.blendMode = "lighter";

// initial shape — getVisibleArea() updates this each frame from
// world-space bounds. `pos` is the center (anchorPoint set below).
// initial shape — `getVisibleArea()` rewrites this each frame from
// transform-aware bounds.
/** @ignore */
this.visibleArea = ellipsePool.get(
this.pos.x,
Expand All @@ -162,13 +117,7 @@ export default class Light2d extends Renderable {
this.height,
);

/** @ignore */
this.texture = new CanvasRenderTarget(this.width, this.height, {
offscreenCanvas: false,
});

// centered anchor — `pos` is the visual center, transforms (scale,
// rotate) pivot around it.
// centered anchor — transforms (scale, rotate) pivot around `pos`.
this.anchorPoint.set(0.5, 0.5);

/**
Expand Down Expand Up @@ -205,8 +154,6 @@ export default class Light2d extends Renderable {
* @type {number}
*/
this.lightHeight = Math.max(radiusX, radiusY) * 0.075;

createGradient(this);
}

/**
Expand Down Expand Up @@ -239,6 +186,29 @@ export default class Light2d extends Renderable {
this.updateBounds();
}

/**
* Set new radii for this light.
*
* Updates `radiusX`/`radiusY` and the underlying bbox (via
* `Renderable.resize(width, height)`) so `getBounds()` and
* `getVisibleArea()` — which feed the ambient-cutout pass — track the
* new size. The Canvas renderer's gradient cache auto-invalidates on
* next draw via its property comparison; the WebGL procedural shader
* adapts to the new dimensions automatically.
*
* Named `setRadii` (not `resize`) so it does not shadow
* `Renderable.resize(width, height)` — code that operates on a
* generic `Renderable` and calls `.resize(w, h)` keeps working when
* the instance happens to be a `Light2d`.
* @param {number} radiusX - new horizontal radius
* @param {number} [radiusY=radiusX] - new vertical radius
*/
setRadii(radiusX, radiusY = radiusX) {
this.radiusX = radiusX;
this.radiusY = radiusY;
this.resize(radiusX * 2, radiusY * 2);
}
Comment thread
obiot marked this conversation as resolved.
Comment thread
obiot marked this conversation as resolved.

/**
* returns a geometry representing the visible area of this light, in
* world-space coordinates (so it aligns with the rendered gradient
Expand All @@ -254,7 +224,6 @@ export default class Light2d extends Renderable {

/**
* update function
* @param {number} dt - time since the last update in milliseconds.
* @returns {boolean} true if dirty
*/
update() {
Expand All @@ -263,8 +232,6 @@ export default class Light2d extends Renderable {

/**
* preDraw this Light2d (automatically called by melonJS)
* Note: The renderer should set the blend mode again (after drawing other Light2d objects)
* to ensure colors blend correctly in the CanvasRenderer.
* @param {Renderer} renderer - a renderer instance
*/
preDraw(renderer) {
Expand All @@ -273,15 +240,19 @@ export default class Light2d extends Renderable {
}

/**
* draw this Light2d (automatically called by melonJS)
* draw this Light2d (automatically called by melonJS).
*
* Delegates to `renderer.drawLight(this)` — each renderer picks its
* own implementation (procedural shader on WebGL; cached `Gradient`
* rasterized into a shared `CanvasRenderTarget` on Canvas). Light2d
* itself doesn't know which path is used.
* @param {Renderer} renderer - a renderer instance
* @param {Camera2d} [viewport] - the viewport to (re)draw
*/
draw(renderer) {
if (this.illuminationOnly) {
return;
}
renderer.drawImage(this.texture.canvas, this.pos.x, this.pos.y);
renderer.drawLight(this);
}

/**
Expand Down Expand Up @@ -317,11 +288,10 @@ export default class Light2d extends Renderable {
destroy() {
colorPool.release(this.color);
this.color = undefined;
const renderer = this.parentApp?.renderer ?? game.renderer;
this.texture.destroy(renderer);
this.texture = undefined;
ellipsePool.release(this.visibleArea);
this.visibleArea = undefined;
// Cache entry in the Canvas renderer (if any) becomes GC-eligible
// via its WeakMap when this Light2d is no longer referenced.
super.destroy();
}
}
70 changes: 70 additions & 0 deletions packages/melonjs/src/video/canvas/canvas_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export default class CanvasRenderer extends Renderer {
reset() {
super.reset();
this.clearColor(this.currentColor, this.settings.transparent !== true);
// drop the per-light gradient cache; entries will lazily re-bake on
// the next `drawLight()` call.
this._lightCache = undefined;
}

/**
Expand Down Expand Up @@ -295,6 +298,73 @@ export default class CanvasRenderer extends Renderer {
context.drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh);
}

/**
* @inheritdoc
*
* Renders the light by drawing a cached radial `Gradient` via
* `Gradient.toCanvas()`. The Gradient instance is cached per-Light2d
* in a `WeakMap` and rebuilt only when the light's radii / color /
* intensity change. `toCanvas` itself shares a single
* `CanvasRenderTarget` across all gradients in the engine, so memory
* stays at O(1) regardless of how many lights are active.
*
* The cached Gradient is always circular (outer radius =
* `max(radiusX, radiusY)`) — `drawImage`'s non-uniform stretch
* produces the elliptical falloff for non-square lights, matching
* the procedural shader's behavior on WebGL.
* @param {object} light - the Light2d instance to render
*/
drawLight(light) {
if (this._lightCache === undefined) {
this._lightCache = new WeakMap();
}
let entry = this._lightCache.get(light);
const c = light.color;
if (
entry === undefined ||
entry.radiusX !== light.radiusX ||
entry.radiusY !== light.radiusY ||
entry.r !== c.r ||
entry.g !== c.g ||
entry.b !== c.b ||
entry.intensity !== light.intensity
) {
const r = Math.max(light.radiusX, light.radiusY);
const gradient = this.createRadialGradient(r, r, 0, r, r, r);
Comment thread
obiot marked this conversation as resolved.
gradient.addColorStop(0, c.toRGBA(light.intensity));
gradient.addColorStop(1, c.toRGBA(0.0));
entry = {
gradient,
radius: r,
radiusX: light.radiusX,
radiusY: light.radiusY,
r: c.r,
g: c.g,
b: c.b,
intensity: light.intensity,
};
this._lightCache.set(light, entry);
}
const r2 = entry.radius * 2;
// `Gradient.toCanvas` renders into a shared `CanvasRenderTarget`
// (one per engine, reused across all gradients) and returns its
// canvas. `drawImage` with explicit src/dst rects crops the POT
// padding and stretches the circular gradient into the
// elliptical bounding box `(light.width × light.height)`.
const canvas = entry.gradient.toCanvas(this, 0, 0, r2, r2);
this.drawImage(
canvas,
0,
0,
r2,
r2,
light.pos.x,
light.pos.y,
light.width,
light.height,
);
}

/**
* Draw a pattern within the given rectangle.
* @param {CanvasPattern} pattern - Pattern object
Expand Down
Loading
Loading