feat(lights): WebGL-native procedural Light2d shader (closes #1430)#1434
feat(lights): WebGL-native procedural Light2d shader (closes #1430)#1434
Conversation
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>
There was a problem hiding this comment.
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-specificdrawLightpaths for WebGL (procedural shader) and Canvas (WeakMap-cached gradient bake). - Introduced
Light2dEffect(aShaderEffectsubclass) and updatedLight2d.draw()to delegate rendering to the renderer; addedLight2d.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.
| // 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); | ||
| } |
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>
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>
There was a problem hiding this comment.
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 (viagl.deleteTexture) and/or_whitePixelis 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();
}
`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>
There was a problem hiding this comment.
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()callsgl.activeTexture(gl.TEXTURE0)/gl.bindTexture(...)directly, but it does not synchronizeMaterialBatcher'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., setcurrentTextureUnitto 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 usesgl.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);
}
`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>
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>
- _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>
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>
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>
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>
| // 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), | ||
| ); |
| const canvas = globalThis.document | ||
| ? globalThis.document.createElement("canvas") | ||
| : new OffscreenCanvas(1, 1); | ||
| canvas.width = 1; | ||
| canvas.height = 1; | ||
| const ctx = canvas.getContext("2d"); |
| setRadii(radiusX, radiusY = radiusX) { | ||
| this.radiusX = radiusX; | ||
| this.radiusY = radiusY; | ||
| this.resize(radiusX * 2, radiusY * 2); | ||
| } |
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-ownedGradientcache on Canvas.Light2ditself becomes pure data — no shader knowledge, no canvas allocation, no renderer reference held.Architecture
Mirrors the
drawImageAPI surface — same shape on both renderers, very different machinery underneath:Renderer.drawLight(light)— base no-op for polymorphism.WebGLRenderer.drawLight(light)— quads through a sharedRadialGradientEffectfragment shader, routed through the standardaddQuadbatching path. Per-light color and intensity flow through the per-vertextintattribute (RGB = color, A = intensity), so consecutivedrawLightcalls accumulate in the quad batcher and flush together: N lights = 1 program switch + 1 flush, instead of 2N + N. Lazy 1×1 whiteTextureAtlasas the no-opuSamplersource, shared across every light. No per-light GL texture.CanvasRenderer.drawLight(light)— caches a smallGradientconfig object per Light2d in aWeakMap(rebuilt only when radii / color / intensity change), rasterizes viaGradient.toCanvas()into a single sharedCanvasRenderTarget, composites withdrawImage. 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'screateRadialGradienttwo-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-gradientShaderEffect. Constructor accepts{ color, intensity }, plussetColor/setIntensitysetters. Color/intensity stack from two sources multiplied together: theuColor/uIntensityuniforms (the natural API for a single-instance shader attached to a renderable) AND the per-vertexaColortint (used byWebGLRenderer.drawLightto 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 mutatingradiusX/Ypost-construction left the rendered light stale whilegetVisibleArea()(the cutout pass) tracked the new size. NamedsetRadii(notresize) so it doesn't shadowRenderable.resize(width, height).Light2dreduced to pure data — notexturefield, nocreateGradientcall, noCanvasRenderTargetimport. Constructor allocates only theColor+Ellipsepool entries.Latent bugs caught and fixed in flight
WebGLRenderer.setBatcherstale-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.setBatchernow always reconciles to either the explicitly-passed shader or the batcher'sdefaultShader.QuadBatcher.blitTexture/LitQuadBatcher.blitTexturetexture-unit desync — both methods calledgl.activeTexture(TEXTURE0)directly without updatingcurrentTextureUnit/boundTextures[0]. SubsequentbindTexture2Dcalls 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
radiusX/radiusY/color/intensitychange; WebGL reads live and packs into the vertex tint each call, so there's nothing to invalidate.Light2dis renderer-agnostic — future renderers (WebGPU, custom) just implementdrawLight(light)once.Test plan
pnpm buildclean (lint + types + bundle).Light2dconstructor; Canvas cache hit / re-bake onsetRadii/ re-bake on color or intensity change; cache cleared onCanvasRenderer.reset(); baseRenderer.drawLightpolymorphism;RadialGradientEffectstandalone construction in a WebGL-init describe; Canvas drawLight usesGradient.toCanvas(transform-isolated) nottoCanvasGradient(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 callssetColor/setIntensityper light.Lightsexample on WebGL withaddPostEffect(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).Lightsexample onvideo.CANVAS— newGradient.toCanvas+drawImagepath matches WebGL.Normal Map+SpriteIlluminatorexamples — lit pipeline still illuminates correctly; thesetBatcherreconciliation does not interact badly with the lit batcher.🤖 Generated with Claude Code