Skip to content

feat(lights): WebGL-native procedural Light2d shader (closes #1430)#1434

Merged
obiot merged 18 commits intomasterfrom
feat/light2d-procedural-shader
May 7, 2026
Merged

feat(lights): WebGL-native procedural Light2d shader (closes #1430)#1434
obiot merged 18 commits intomasterfrom
feat/light2d-procedural-shader

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented May 6, 2026

Closes #1430.

Summary

Replace Light2d's per-light "build a radial gradient on an offscreen canvas, upload as a GL texture, drawImage every frame" pipeline with a procedural fragment shader on WebGL and a renderer-owned Gradient cache on Canvas. Light2d itself becomes pure data — no shader knowledge, no canvas allocation, no renderer reference held.

Architecture

Mirrors the drawImage API surface — same shape on both renderers, very different machinery underneath:

  • Renderer.drawLight(light) — base no-op for polymorphism.
  • WebGLRenderer.drawLight(light) — quads through a shared RadialGradientEffect fragment shader, routed through the standard addQuad batching path. Per-light color and intensity flow through the per-vertex tint attribute (RGB = color, A = intensity), so consecutive drawLight calls accumulate in the quad batcher and flush together: N lights = 1 program switch + 1 flush, instead of 2N + N. Lazy 1×1 white TextureAtlas as the no-op uSampler source, shared across every light. No per-light GL texture.
  • CanvasRenderer.drawLight(light) — caches a small Gradient config object per Light2d in a WeakMap (rebuilt only when radii / color / intensity change), rasterizes via Gradient.toCanvas() into a single shared CanvasRenderTarget, composites with drawImage. The render target is one-per-engine, so the heavy bitmap memory stays at O(1) regardless of how many lights are active.
  • Light2d.draw(renderer) collapses to one line: renderer.drawLight(this).

Falloff curve is linear (f = clamp(1 - d, 0, 1)) — matches Canvas's createRadialGradient two-stop interpolation exactly to preserve visual parity for users with calibrated scenes.

What's new

  • RadialGradientEffect (src/video/webgl/effects/radialGradient.js) — generic procedural radial-gradient ShaderEffect. Constructor accepts { color, intensity }, plus setColor / setIntensity setters. Color/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 aColor tint (used by WebGLRenderer.drawLight to encode per-light state in the vertex stream so multiple lights sharing this shader batch into a single draw call).
  • Light2d.setRadii(radiusX, radiusY) — fixes the latent bug where mutating radiusX/Y post-construction left the rendered light stale while getVisibleArea() (the cutout pass) tracked the new size. Named setRadii (not resize) so it doesn't shadow Renderable.resize(width, height).
  • Light2d reduced to pure data — no texture field, no createGradient call, no CanvasRenderTarget import. Constructor allocates only the Color + Ellipse pool entries.

Latent bugs caught and fixed in flight

  • WebGLRenderer.setBatcher stale-custom-shader leak — 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 (post-effect FBO blit, etc.) could stay bound and silently render the next sprite batch through the wrong program. setBatcher now always reconciles to either the explicitly-passed shader or the batcher's defaultShader.
  • QuadBatcher.blitTexture / LitQuadBatcher.blitTexture texture-unit desync — both methods called gl.activeTexture(TEXTURE0) directly without updating currentTextureUnit / boundTextures[0]. Subsequent bindTexture2D calls could short-circuit on the stale cache and bind on the wrong unit. Both now sync the cache on bind and reset on unbind.

Wins

  • Zero per-light GL texture — multi-texture batching no longer fights "one slot per light".
  • N consecutive lights = 1 program switch + 1 flush on WebGL — vs the original "one upload + drawImage per light".
  • Sharp at any camera zoom — procedural, not bitmap.
  • Reactive to property mutation — Canvas-side cache invalidates automatically on radiusX/radiusY/color/intensity change; WebGL reads live and packs into the vertex tint each call, so there's nothing to invalidate.
  • Light2d is renderer-agnostic — future renderers (WebGPU, custom) just implement drawLight(light) once.

Test plan

  • 86 test files / 2909 tests pass (existing 2897 + 12 new).
  • pnpm build clean (lint + types + bundle).
  • New tests pin: no per-light texture in Light2d constructor; Canvas cache hit / re-bake on setRadii / re-bake on color or intensity change; cache cleared on CanvasRenderer.reset(); base Renderer.drawLight polymorphism; RadialGradientEffect standalone construction in a WebGL-init describe; Canvas drawLight uses Gradient.toCanvas (transform-isolated) not toCanvasGradient (transform-anchored); WebGL drawLight drains pending sprite vertices before binding the light shader; WebGL drawLight batches consecutive lights into a single flush + single program switch; WebGL drawLight uses the per-vertex tint, never calls setColor / setIntensity per light.
  • Visual: Lights example on WebGL with addPostEffect(VignetteEffect) + addPostEffect(ScanlineEffect) chain — confirmed lights render inside the camera's multi-pass post-effect FBO bracket (scanlines ride through both spotlights, vignette darkens uniformly).
  • Visual: Lights example on video.CANVAS — new Gradient.toCanvas + drawImage path matches WebGL.
  • Visual: Normal Map + SpriteIlluminator examples — lit pipeline still illuminates correctly; the setBatcher reconciliation does not interact badly with the lit batcher.

🤖 Generated with Claude Code

Replace the per-light "build a radial gradient on an offscreen canvas,
upload as a GL texture, drawImage every frame" pipeline with a single
quad rendered through a shared procedural fragment shader. Light2d
itself becomes pure data — no shader knowledge, no canvas allocation,
no renderer reference.

Architecture (mirrors `drawImage` semantics):

- `Renderer.drawLight(light)` — base no-op for polymorphism.
- `WebGLRenderer.drawLight(light)` — single quad through a shared
  `Light2dEffect` shader. One program, all lights share. Lazy 1×1
  white pixel as the no-op `uSampler` source. No per-light GL texture.
- `CanvasRenderer.drawLight(light)` — offscreen-canvas bake (existing
  `createGradient` logic moved here), cached in a `WeakMap<Light2d>`
  keyed by light identity. Re-bakes when radii/color/intensity change.
  Reuses the same `CanvasRenderTarget` when only color/intensity
  changes (no realloc); only re-allocates on dimension change.
- `Light2d.draw(renderer)` collapses to one line:
  `renderer.drawLight(this)`.

Falloff curve is **linear** (`f = clamp(1 - d, 0, 1)`) — matches
Canvas's `createRadialGradient` two-stop interpolation exactly to
preserve visual parity for users with calibrated scenes.

`Light2dEffect` exposes a clean semantic API (`setColor`,
`setIntensity`, `setRadii`) plus matching constructor options, so it
can be used standalone (tests, debug overlays, custom paths) — not
just via `WebGLRenderer.drawLight`.

Also adds:
- `Light2d.resize(radiusX, radiusY)` — fixes the latent bug where
  mutating `radiusX/Y` post-construction left the rendered light
  stale while `getVisibleArea()` (the cutout pass) tracked the new
  size. The renderer's gradient cache (Canvas) now auto-invalidates;
  WebGL is procedural and adapts immediately.
- 9 new tests pinning: no per-light texture in Light2d's constructor,
  Canvas cache hit/re-bake on resize/color/intensity, target reuse
  optimization, cache cleared on renderer reset, base no-op
  polymorphism, Light2dEffect standalone construction.

Wins over the previous pipeline:
- Zero per-light GL texture (multi-texture batching no longer fights
  one slot per light).
- No allocation in Light2d's constructor (deferred to first draw,
  cached at the renderer).
- Sharp at any camera zoom (procedural).
- Reactive to property mutation (no manual rebake API needed).

2906 / 2906 tests pass; lit pipeline (SpriteIlluminator + Normal Map
examples) unchanged — it reads `Light2d.{pos, color, intensity,
lightHeight, getBounds}` which are all preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 07:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors Light2d rendering to be renderer-driven via a new Renderer.drawLight(light) API, replacing the previous per-light offscreen-canvas + GL-texture upload workflow on WebGL with a procedural fragment-shader approach while keeping a cached offscreen-canvas bake on Canvas.

Changes:

  • Added Renderer.drawLight(light) (base no-op) and implemented renderer-specific drawLight paths for WebGL (procedural shader) and Canvas (WeakMap-cached gradient bake).
  • Introduced Light2dEffect (a ShaderEffect subclass) and updated Light2d.draw() to delegate rendering to the renderer; added Light2d.resize(radiusX, radiusY) to keep bounds in sync with radii changes.
  • Updated tests and changelog to reflect the new rendering architecture and cache behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/melonjs/src/video/renderer.js Adds the new polymorphic drawLight(light) API as a base no-op.
packages/melonjs/src/video/webgl/webgl_renderer.js Implements drawLight via a shared Light2dEffect and a lazily-created 1×1 white texture.
packages/melonjs/src/video/webgl/effects/light2d.js Adds the procedural radial-falloff shader effect and its setter-based API.
packages/melonjs/src/video/canvas/canvas_renderer.js Moves gradient baking into the renderer with a per-light WeakMap cache and invalidation logic.
packages/melonjs/src/renderable/light2d.js Removes per-light texture allocation; delegates draw to renderer.drawLight; adds resize.
packages/melonjs/tests/lights.spec.js Updates Light2d draw expectations and adds cache-related tests (plus intended Light2dEffect tests).
packages/melonjs/CHANGELOG.md Documents the new rendering pipeline, new API, and Light2d.resize.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +52 to +61
// recenter to [-1, 1] and undo the quad's aspect stretch so
// the falloff stays circular when radiusX !== radiusY
vec2 c = (uv * 2.0 - 1.0) / uAspect;
float d = length(c);
// linear ramp matches Canvas createRadialGradient's two-stop output
float f = clamp(1.0 - d, 0.0, 1.0);
float a = f * uIntensity;
// premultiplied: composes correctly under additive ("lighter") blending
return vec4(uColor * a, a);
}
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
Comment thread packages/melonjs/tests/lights.spec.js Outdated
obiot and others added 2 commits May 6, 2026 17:34
Light2d's `preDraw` translates `(-ax, -ay)` (anchor adjustment with
anchorPoint = 0.5, 0.5) into the renderer's `currentTransform`. The
prior implementation used `QuadBatcher.blitTexture` to emit the
procedural quad, but `blitTexture` is designed for FBO blits and
deliberately skips the view-matrix step — leaving the light's
gradient offset by `(radiusX, radiusY)` from the cutout (which uses
world-space coords via `getVisibleArea()`).

Fix: emit the four corner vertices directly in `drawLight`, applying
`batcher.viewMatrix` (= `renderer.currentTransform`) to each. Same
mechanism as `addQuad` uses for sprite vertices. Canvas mode was
already correct because `drawImage` respects the 2D context's
transform stack natively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The procedural radial-falloff shader was over-correcting for elliptical
lights: it computed `aspect = [1, radiusY/radiusX]` and divided UV-space
distance by it, which shrunk the falloff prematurely along the smaller
axis. For the Lights example (radiusX=200, radiusY=140), the WebGL light
reached alpha=0 at world distance 0.7×140=98 along Y instead of 140 —
visibly smaller than the cutout (which uses world-space radii via
`getVisibleArea`).

The math: a quad's UV space is normalized to `[0, 1]` regardless of
quad dimensions, so `c = uv*2 - 1` directly gives `(world_dx/radiusX,
world_dy/radiusY)`. `length(c) == 1` already lies exactly on the
ellipse boundary — no extra aspect correction needed. The Canvas path
was already correct because `createGradient` uses scaleX/scaleY context
transforms to handle ellipticals.

Removed `uAspect` uniform, `Light2dEffect.setRadii()`, and the matching
`setRadii` call from `WebGLRenderer.drawLight`. Updated the standalone
API test to match the simpler surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 09:41
Previous commit (5e71841) inlined a transformed-vertex emit in
WebGLRenderer.drawLight, duplicating most of QuadBatcher.blitTexture.
Cleaner: have blitTexture itself apply the viewMatrix to the four
corner vertices.

Backward-compatible: the existing FBO-blit caller (`blitEffect`)
already calls `currentTransform.identity()` before invoking
blitTexture, so the matrix multiply is a no-op for that path. New
world-space callers (`drawLight`) get the renderer's accumulated
transform applied for free.

Applied to both QuadBatcher.blitTexture and LitQuadBatcher's override.
WebGLRenderer.drawLight collapses back to a single blitTexture call
(60 lines → 8). No public API change; net -55 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/melonjs/src/video/webgl/webgl_renderer.js:666

  • _getWhitePixel() creates and retains a WebGLTexture but there’s no corresponding cleanup on renderer reset/context restore, so the instance can hold an invalid texture handle (and leak GPU resources on normal teardown). Ensure this texture is deleted (via gl.deleteTexture) and/or _whitePixel is cleared when the renderer is reset/destroyed.
		// flush pending draws BEFORE creating/resizing FBOs,
		// since FBO construction temporarily changes GL framebuffer bindings
		this.flush();
		this.save();
		// save the current projection (not part of the render state stack)
		this._savedEffectProjection.copy(this.projectionMatrix);

		const rt = this._renderTargetPool.begin(isCamera, effects.length, w, h);
		// FBO creation/resize uses TEXTURE0 — invalidate the batcher's cache for that unit
		if (this.currentBatcher && this.currentBatcher.boundTextures) {
			delete this.currentBatcher.boundTextures[0];
		}
		rt.bind();
		this.setViewport(0, 0, w, h);
		this.disableScissor();
		this.setGlobalAlpha(1.0);
		this.setBlendMode("normal");

		if (isCamera) {
			this.clear();
		} else {
			this.clearRenderTarget();
		}

Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
Comment thread packages/melonjs/src/video/webgl/effects/light2d.js Outdated
Comment thread packages/melonjs/CHANGELOG.md Outdated
Comment thread packages/melonjs/tests/lights.spec.js Outdated
Comment thread packages/melonjs/src/renderable/light2d.js
obiot and others added 2 commits May 6, 2026 17:49
`setBatcher` returns the batcher; use that directly instead of
re-reading `this.currentBatcher`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shader is a generic procedural radial gradient — solid color at
center fading linearly to transparent at the edge of the host quad.
`WebGLRenderer.drawLight()` is one consumer, but the effect is just as
useful for debug overlays, hotspots, pickup glows, hit-flash indicators,
trigger-zone markers, etc.

Renamed:
- File `effects/light2d.js` → `effects/radialGradient.js`
- Class `Light2dEffect` → `RadialGradientEffect`
- Updated WebGLRenderer.drawLight, the changelog entry, and the
  standalone-API tests.

Documentation reframed around the generic primitive (no Light2d
mention in the description), with five usage examples covering the
main patterns (soft spot, hotspot, pickup highlight, damage flash,
debug overlay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 09:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

packages/melonjs/src/video/webgl/batchers/quad_batcher.js:233

  • blitTexture() calls gl.activeTexture(gl.TEXTURE0) / gl.bindTexture(...) directly, but it does not synchronize MaterialBatcher's internal texture-unit tracking (currentTextureUnit, boundTextures, etc.). This can desync the batcher state vs. real GL state (especially after multi-texture draws) and cause subsequent texture binds to hit the wrong unit. Consider resetting/synchronizing the batcher’s texture-unit bookkeeping around the blit (e.g., set currentTextureUnit to 0 before binding and/or force it to -1 after unbinding so the next bind re-activates the correct unit).
	blitTexture(source, x, y, width, height, shader) {
		const gl = this.gl;

		this.useShader(shader);

		// bind the source texture to unit 0
		gl.activeTexture(gl.TEXTURE0);
		gl.bindTexture(gl.TEXTURE_2D, source);
		shader.setUniform("uSampler", 0);

		// push a screen-aligned quad with Y-flipped UVs, transformed by
		// the current renderer transform. `WebGLRenderer.blitEffect` resets
		// `currentTransform` to identity before calling so FBO blits are
		// unaffected; world-space callers (e.g. `drawLight`) get the
		// `preDraw` translate / scale applied automatically.
		const m = this.viewMatrix;
		const vec0 = V_ARRAY[0].set(x, y);
		const vec1 = V_ARRAY[1].set(x + width, y);
		const vec2 = V_ARRAY[2].set(x, y + height);
		const vec3 = V_ARRAY[3].set(x + width, y + height);
		if (m && !m.isIdentity()) {
			m.apply(vec0);
			m.apply(vec1);
			m.apply(vec2);
			m.apply(vec3);
		}

		const tint = 0xffffffff;
		this.vertexData.push(vec0.x, vec0.y, 0, 1, tint, 0);
		this.vertexData.push(vec1.x, vec1.y, 1, 1, tint, 0);
		this.vertexData.push(vec2.x, vec2.y, 0, 0, tint, 0);
		this.vertexData.push(vec3.x, vec3.y, 1, 0, tint, 0);

		this.flush();

		// unbind the texture to prevent feedback loop on next frame
		gl.activeTexture(gl.TEXTURE0);
		gl.bindTexture(gl.TEXTURE_2D, null);
		delete this.boundTextures[0];

		// restore the default shader (also re-enables multi-texture batching)
		this.useShader(this.defaultShader);
	}

packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js:372

  • Same issue as QuadBatcher.blitTexture: this override uses gl.activeTexture(gl.TEXTURE0) / gl.bindTexture(...) directly without updating the batcher’s internal texture unit bookkeeping (currentTextureUnit, boundTextures, etc.). That can leave GL state and batcher state inconsistent and break subsequent texture binds/draws. Please synchronize/reset the batcher tracking around this blit.
	blitTexture(source, x, y, width, height, shader) {
		const gl = this.gl;

		this.useShader(shader);

		gl.activeTexture(gl.TEXTURE0);
		gl.bindTexture(gl.TEXTURE_2D, source);
		shader.setUniform("uSampler", 0);

		// transform corners through the renderer transform — see
		// `QuadBatcher.blitTexture` for the rationale (FBO callers reset
		// `currentTransform` to identity, world-space callers get the
		// translate/scale applied).
		const m = this.viewMatrix;
		const vec0 = V_ARRAY[0].set(x, y);
		const vec1 = V_ARRAY[1].set(x + width, y);
		const vec2 = V_ARRAY[2].set(x, y + height);
		const vec3 = V_ARRAY[3].set(x + width, y + height);
		if (m && !m.isIdentity()) {
			m.apply(vec0);
			m.apply(vec1);
			m.apply(vec2);
			m.apply(vec3);
		}

		const tint = 0xffffffff;
		this.vertexData.push(vec0.x, vec0.y, 0, 1, tint, 0, -1);
		this.vertexData.push(vec1.x, vec1.y, 1, 1, tint, 0, -1);
		this.vertexData.push(vec2.x, vec2.y, 0, 0, tint, 0, -1);
		this.vertexData.push(vec3.x, vec3.y, 1, 0, tint, 0, -1);

		this.flush();

		gl.activeTexture(gl.TEXTURE0);
		gl.bindTexture(gl.TEXTURE_2D, null);
		delete this.boundTextures[0];

		this.useShader(this.defaultShader);
	}

Comment thread packages/melonjs/src/renderable/light2d.js Outdated
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js
obiot and others added 2 commits May 6, 2026 18:05
`CanvasRenderer.drawLight` now caches a `Gradient` (created via
`this.createRadialGradient(...)`) instead of an inline `_bakeLight`
helper that called the native `createRadialGradient` directly. Each
frame, `gradient.toCanvas(this, 0, 0, 2r, 2r)` renders into the
engine's shared `CanvasRenderTarget` (one per renderer, reused across
all gradients) and `drawImage` crops + stretches the circular result
into the elliptical bounding box.

Trade-off vs the previous per-light cached canvas approach:
- Memory: O(1) shared target instead of one canvas per light.
- CPU: gradient re-renders on every drawLight call (the shared target
  is overwritten between lights) — acceptable for typical scenes
  (~480 small fills/sec at 8 lights × 60 fps), and the re-render uses
  the cached `CanvasGradient` instance so only `fillRect` runs each
  time, not the gradient setup.

Tests updated to assert on the cached `gradient` instance instead of a
per-light `target`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rawImage

`Gradient.toCanvasGradient(ctx)` returns a cached native `CanvasGradient`
bound to the given context — usable directly as a `fillStyle`. So instead
of `Gradient.toCanvas` (which renders the gradient to a shared offscreen
target on every call, then drawImage's the result), we can just set the
cached `CanvasGradient` and `fillRect` on the renderer's main context.

Per-frame work for one light:
- Before: `clearRect` + `createRadialGradient` + `addColorStop`×2 +
  `fillRect` on shared offscreen + `drawImage` with src/dst crop & stretch.
- After: `fillRect` on the main context (gradient is cached after the
  first call; subsequent calls return the same `CanvasGradient` object).

Elliptical lights handled via a non-uniform `ctx.scale()` around the
light's center — the cached gradient stays circular, the scale stretches
it into the elliptical contour. Same trick the WebGL shader uses
(quad UV aspect handles ellipses naturally).

The shared `CanvasRenderTarget` in `Gradient.toCanvas` is no longer
touched by the lighting path; if no other consumer needs it, that
allocation can be dropped in a future cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 10:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Comment thread packages/melonjs/src/video/canvas/canvas_renderer.js
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js
Comment thread packages/melonjs/src/renderable/light2d.js Outdated
Comment thread packages/melonjs/src/video/renderer.js Outdated
Comment thread packages/melonjs/CHANGELOG.md Outdated
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
obiot and others added 2 commits May 6, 2026 18:31
The toCanvasGradient + transformed-fillRect variant (9f08b38) was
faster per-frame but the manual `save/translate/scale/translate/
fillStyle/fillRect/restore` plumbing made the call site significantly
harder to read for a marginal perf win in the hot path of a feature
that's already gated to scenes with active lights.

Reverted to the simpler `gradient.toCanvas(this, 0, 0, r2, r2)` +
`drawImage(canvas, 0, 0, r2, r2, light.pos.x, light.pos.y, w, h)`.
Memory still O(1) (`Gradient.toCanvas` shares one CanvasRenderTarget
across the engine); CPU per frame is small for typical scenes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename Light2d.resize(rx, ry) to setRadii(rx, ry) so it no longer
  shadows Renderable.resize(width, height) — generic Renderable callers
  keep working when the instance is a Light2d.
- WebGLRenderer.reset() now drops the cached _lightShader and
  _whitePixel; otherwise after a context loss/restore those references
  point at the OLD GL context and would error on reuse.
- WebGLRenderer._getWhitePixel() now syncs currentBatcher.boundTextures[0]
  and currentTextureUnit after the bind so the batcher's texture-state
  cache stays consistent with actual GL state.
- RadialGradientEffect.setColor() reuses a Float32Array(3) instead of
  allocating a fresh 3-element array on every call (one per light per
  frame).
- Move RadialGradientEffect standalone tests into their own
  WebGL-initialized describe block instead of silently no-op'ing under
  the Canvas-init parent describe.
- Sweep stale references from Light2d JSDoc, Renderer.drawLight JSDoc,
  and CHANGELOG (Light2dEffect → RadialGradientEffect, offscreen-canvas
  bake → Gradient + toCanvas, resize → setRadii).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 10:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
obiot and others added 2 commits May 6, 2026 19:54
- _getWhitePixel: comment said we "drop" the boundTextures cache slot,
  but the code now syncs it to the new binding. Reword to match.
- Light2d: tighten the visibleArea / anchorPoint inline comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WebGLRenderer.drawLight set the procedural light shader's color and
intensity uniforms — and lazily bound a 1x1 white texture to TEXTURE0 —
*before* the batcher flushed any pending sprite vertices. setUniform
internally calls gl.useProgram(_lightShader) to upload the value, so a
queued sprite batch left over from the prior frame's draw could end up
flushed against the wrong program and the wrong unit-0 texture, rendering
garbage.

setBatcher("quad") only flushes on a batcher switch — if we're already
on the quad batcher, pending vertices sit in the buffer until the next
addQuad / blitTexture call. Add an explicit flush right after the
setBatcher call (before any GL state mutation) so the queue drains
cleanly with the prior shader and texture bindings still active.

Spotted by Copilot review on PR #1434.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 11:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment thread packages/melonjs/src/video/webgl/batchers/quad_batcher.js
Comment thread packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
obiot and others added 2 commits May 6, 2026 20:39
Re-route WebGLRenderer.drawLight from blitTexture to the standard quad
batcher's addQuad path. Each light's color and intensity now ride on the
per-vertex tint attribute (RGB = color, A = intensity) — so back-to-back
drawLight calls share the same shader program and the same 1×1 white
texture, accumulate into the quad batcher's vertex buffer, and flush
together. N consecutive lights = 1 program switch + 1 flush, instead of
2N + N (one per blitTexture).

Changes:

- RadialGradientEffect: shader now multiplies uColor * uIntensity by the
  per-vertex tint (color.rgb / color.a). Standalone callers that set the
  uniforms directly still work — the tint is (1,1,1,1) when the effect
  is attached to a regular renderable. Light2d-via-drawLight uses the
  uniform defaults and packs everything into the tint via toUint32.

- WebGLRenderer.drawLight: routes through setBatcher("quad", _lightShader)
  + addQuad(whiteAtlas, ..., light.color.toUint32(light.intensity)).
  No setColor / setIntensity calls; no blitTexture call. The radial-
  gradient shader is left bound on exit — the next setBatcher call
  reconciles back to the default shader on its own.

- WebGLRenderer.setBatcher: now always reconciles the active shader to
  either the explicitly-passed one or the batcher's default shader. The
  previous "fast path returns early when batcher matches and no shader
  given" left a stale custom shader bound across calls; the new logic
  ensures sprites drawn after a lights pass don't render against the
  light shader. useShader is internally a no-op when the shader matches,
  so the hot path stays cheap.

- WebGLRenderer._getWhitePixel removed; replaced by _getLightAtlas
  (returns a TextureAtlas wrapping a 1×1 white canvas, so it flows
  through addQuad's standard texture-binding path).

- Reset path: drops _lightAtlas instead of _whitePixel on context loss /
  context restore.

Tests: 4 new regression guards in tests/lights.spec.js —

  1. Canvas drawLight uses Gradient.toCanvas (transform-isolated)
     rather than toCanvasGradient (which would anchor the cached native
     CanvasGradient to the light's first-drawn position).
  2. WebGL drawLight drains pending sprite vertices BEFORE binding the
     light shader (mid-batch corruption guard).
  3. WebGL drawLight batches consecutive lights into a single flush + a
     single program switch (the new contract).
  4. WebGL drawLight uses the per-vertex tint to carry color+intensity,
     never calls setColor/setIntensity on the shared shader (kills any
     accidental regression that would re-introduce per-light uniform
     traffic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both QuadBatcher.blitTexture and LitQuadBatcher.blitTexture call
gl.activeTexture(gl.TEXTURE0) directly without updating the batcher's
internal currentTextureUnit + boundTextures bookkeeping. If
currentTextureUnit was non-zero from a prior batch, a later
bindTexture2D call could short-circuit on the cached unit and bind on
the wrong unit, producing incorrect rendering.

Set currentTextureUnit = 0 + boundTextures[0] = source on bind, and
currentTextureUnit = -1 on the unbind so the next bind re-issues
gl.activeTexture cleanly.

Spotted by Copilot review on PR #1434.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 12:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Comment thread packages/melonjs/src/video/webgl/batchers/quad_batcher.js Outdated
Comment thread packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js Outdated
Comment thread packages/melonjs/src/video/webgl/effects/radialGradient.js
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
obiot and others added 2 commits May 7, 2026 07:01
1. Real bug: WebGLRenderer.reset's _lightAtlas eviction used the wrong
   cache key. TextureCache is keyed by source image (per
   `cache.set(source, this)` in atlas.js), not by the TextureAtlas
   instance — so the entry was never removed and the orphan mapping
   pointed at a TextureAtlas whose internal GL texture referenced the
   OLD (lost) context. Iterate `_lightAtlas.sources` and delete each
   entry by its source key instead.

2. setBatcher null footgun: `typeof shader === "object"` lets `null`
   through (typeof null === "object"), and `useShader(null)` blows up
   inside `shader.bind()`. Tightened to `shader != null` (excludes both
   undefined and null). Same fix applied to the matching customShader
   checks in drawImage and drawMesh for consistency.

3. Stale doc: Light2d.draw inline JSDoc still said "offscreen-canvas
   bake on Canvas". Updated to describe the cached-Gradient + shared
   CanvasRenderTarget path.

4. CHANGELOG inaccuracy: claimed "the WebGL procedural shader is fed
   fresh uniforms every call" — but the new path doesn't call setColor
   or setIntensity at all; per-light state flows through the vertex
   tint. Reworded.

5. CHANGELOG / Light2d / Renderer doc tightening: "O(1) memory" is
   true for the shared render target but the per-light WeakMap still
   holds one Gradient config per Light2d. Reworded to "the heavy bitmap
   memory stays at O(1)" and noted the per-light cache holds only the
   gradient stops, not the bitmap.

6. Test cleanup: replaced the prototype-walk + dead stubRenderer block
   in the "base Renderer.drawLight no-op" test with a direct
   `Renderer.prototype.drawLight` import.

7. Test deterministic-renderer: WebGL describe's afterAll now restores
   `video.CANVAS` (the parent describe's choice) rather than
   `video.AUTO`, so any sibling describe that runs afterward sees the
   same renderer the parent did.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single `viewport.shader = vignette` (which uses the no-FBO
fast path) with `viewport.addPostEffect(vignette) +
addPostEffect(scanline)` — the multi-pass FBO ping-pong path. Visually
proves that lights render inside the camera's post-effect FBO bracket
on both Canvas and WebGL: the scanline overlay rides through both the
mouse-tracked white spot and the fixed orange light, and the vignette
darkens uniformly across lit and unlit regions.

The CRT scanlines (mild opacity 0.18, slight curvature 0.015) also
match the pixel-art SEGA arcade aesthetic of the Streets of Rage
backdrop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 23:17
obiot and others added 2 commits May 7, 2026 07:19
Address remaining Copilot review comments on PR #1434:

1. QuadBatcher.blitTexture / LitQuadBatcher.blitTexture comments still
   referred to drawLight as a world-space caller — drawLight now uses
   addQuad. The matrix branch in blitTexture is dormant in practice
   (only caller is WebGLRenderer.blitEffect, which resets transform to
   identity before calling). Reword the comments accordingly.

2. RadialGradientEffect docs implied `uv` is always [0,1] across the
   quad. In reality `apply(color, uv)` receives `vRegion` (atlas UVs)
   from the ShaderEffect wrapper, so the falloff math
   `length(uv * 2 - 1)` only reads as a centered ellipse when the
   host quad samples a full-rect texture. The Light2d batching path
   does (it uses a dedicated 1×1 white atlas with explicit (0,0,1,1)
   UVs in addQuad), but a standalone "pickup.shader = effect" usage on
   a Sprite that draws a sub-region of an atlas would render with the
   gradient miscentered. Add a "UV-space caveat" paragraph documenting
   the constraint and the supported host-quad shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes from PR #1434 weren't yet captured:

- WebGLRenderer.setBatcher: latent stale-custom-shader leak. The fast
  path returned early when the batcher matched and no shader was
  passed, so a custom shader bound by a prior call (post-effect FBO
  blit, drawLight's radial-gradient program) stayed bound and the next
  sprite batch silently rendered through the wrong program. setBatcher
  now always reconciles to either the passed shader or the batcher's
  defaultShader.

- QuadBatcher.blitTexture / LitQuadBatcher.blitTexture: did not sync
  currentTextureUnit / boundTextures[0] with the GL state they mutated.
  After a blit ran with a non-zero currentTextureUnit, subsequent
  bindTexture2D calls could short-circuit on the stale cached unit and
  bind on the wrong unit. Both blits now sync the cache on bind and
  reset on unbind.

Also clarified the RadialGradientEffect entry: the shader takes color
+ intensity from two stacked sources multiplied together (uniforms +
per-vertex tint), which is how Light2d's batching path encodes each
light without needing per-light setUniform calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment on lines +622 to +626
// pack the light's color (RGB) and intensity (A) into the
// vertex tint — the shader's `apply()` reads `color.rgb` and
// `color.a` as the per-light values.
light.color.toUint32(light.intensity),
);
Comment on lines +647 to +652
const canvas = globalThis.document
? globalThis.document.createElement("canvas")
: new OffscreenCanvas(1, 1);
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext("2d");
Comment on lines +206 to +210
setRadii(radiusX, radiusY = radiusX) {
this.radiusX = radiusX;
this.radiusY = radiusY;
this.resize(radiusX * 2, radiusY * 2);
}
@obiot obiot merged commit c008ae0 into master May 7, 2026
6 checks passed
@obiot obiot deleted the feat/light2d-procedural-shader branch May 7, 2026 00:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Light2d: WebGL-native rendering via custom shader (replace offscreen canvas)

2 participants