diff --git a/packages/examples/src/examples/lights/ExampleLights.tsx b/packages/examples/src/examples/lights/ExampleLights.tsx index a7d821cd0..b8f590672 100644 --- a/packages/examples/src/examples/lights/ExampleLights.tsx +++ b/packages/examples/src/examples/lights/ExampleLights.tsx @@ -3,6 +3,7 @@ import { input, Light2d, loader, + ScanlineEffect, Sprite, Stage, state, @@ -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[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[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 diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5195b5b8e..2c8315e19 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -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). @@ -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_ diff --git a/packages/melonjs/src/renderable/light2d.js b/packages/melonjs/src/renderable/light2d.js index e80b3afdf..8d2c39dda 100644 --- a/packages/melonjs/src/renderable/light2d.js +++ b/packages/melonjs/src/renderable/light2d.js @@ -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"; /** @@ -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 { @@ -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, @@ -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); /** @@ -205,8 +154,6 @@ export default class Light2d extends Renderable { * @type {number} */ this.lightHeight = Math.max(radiusX, radiusY) * 0.075; - - createGradient(this); } /** @@ -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); + } + /** * returns a geometry representing the visible area of this light, in * world-space coordinates (so it aligns with the rendered gradient @@ -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() { @@ -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) { @@ -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); } /** @@ -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(); } } diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index f85a589da..e5428170c 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -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; } /** @@ -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); + 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 diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 2ae02170d..b5d8a7a03 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -439,6 +439,32 @@ export default class Renderer { } } + /** + * Render a `Light2d` instance. + * + * Each renderer implements its own strategy: the WebGL renderer + * draws lights as quads through a shared procedural radial-falloff + * fragment shader (no per-light texture, color and intensity + * encoded in the per-vertex tint so consecutive draws batch); the + * Canvas renderer caches a small `Gradient` config object per + * light in a `WeakMap` (rebuilt only when the light's radii / + * color / intensity change), rasterizes it with `Gradient.toCanvas()` + * into a single shared `CanvasRenderTarget`, and composites the + * result via `drawImage`. The base implementation is a no-op so + * renderers without a lighting path can be polymorphically + * substituted. + * + * Light2d itself is renderer-agnostic — it just calls + * `renderer.drawLight(this)` and relies on the renderer to pick + * the right machinery. + * @param {object} light - the Light2d instance to render + * @see Light2d + */ + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + drawLight(light) { + // base no-op; concrete renderers override + } + /** * Set the current fill & stroke style color. * By default, or upon reset, the value is set to #000000. diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index f1776fa19..65f17d99c 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -336,20 +336,41 @@ export default class LitQuadBatcher extends QuadBatcher { this.useShader(shader); + // keep the batcher's texture-unit bookkeeping aligned with the GL + // state we just mutated — see `QuadBatcher.blitTexture`. gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, source); + this.currentTextureUnit = 0; + this.boundTextures[0] = source; shader.setUniform("uSampler", 0); + // transform corners through the renderer transform — see + // `QuadBatcher.blitTexture` for the rationale. Only caller today + // is `WebGLRenderer.blitEffect`, which resets `currentTransform` + // to identity, so the matrix branch is dormant in practice. + 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(x, y, 0, 1, tint, 0, -1); - this.vertexData.push(x + width, y, 1, 1, tint, 0, -1); - this.vertexData.push(x, y + height, 0, 0, tint, 0, -1); - this.vertexData.push(x + width, y + height, 1, 0, tint, 0, -1); + 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); + this.currentTextureUnit = -1; delete this.boundTextures[0]; this.useShader(this.defaultShader); diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 59ca722e7..66074c562 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -193,23 +193,47 @@ export default class QuadBatcher extends MaterialBatcher { this.useShader(shader); - // bind the source texture to unit 0 + // bind the source texture to unit 0 — keep the batcher's internal + // texture-unit bookkeeping in sync with the actual GL state, or + // later `bindTexture2D` calls may early-out on a stale + // `currentTextureUnit` and bind to the wrong unit. gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, source); + this.currentTextureUnit = 0; + this.boundTextures[0] = source; shader.setUniform("uSampler", 0); - // push a screen-aligned quad with Y-flipped UVs + // push a screen-aligned quad with Y-flipped UVs, transformed by + // the current renderer transform. `WebGLRenderer.blitEffect` (the + // only caller today) resets `currentTransform` to identity before + // calling so FBO blits land in screen space; the matrix path is + // kept for any future world-space caller that wants its preDraw + // translate/scale honored. + 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(x, y, 0, 1, tint, 0); - this.vertexData.push(x + width, y, 1, 1, tint, 0); - this.vertexData.push(x, y + height, 0, 0, tint, 0); - this.vertexData.push(x + width, y + height, 1, 0, tint, 0); + 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 + // unbind the texture to prevent feedback loop on next frame, and + // drop the unit-0 cache slot so the next bind re-issues activeTexture. gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, null); + this.currentTextureUnit = -1; delete this.boundTextures[0]; // restore the default shader (also re-enables multi-texture batching) diff --git a/packages/melonjs/src/video/webgl/effects/radialGradient.js b/packages/melonjs/src/video/webgl/effects/radialGradient.js new file mode 100644 index 000000000..9c5f7d500 --- /dev/null +++ b/packages/melonjs/src/video/webgl/effects/radialGradient.js @@ -0,0 +1,150 @@ +import ShaderEffect from "../shadereffect.js"; + +/** + * additional import for TypeScript + * @import { Color } from "../../../math/color.ts"; + * @import { default as WebGLRenderer } from "../webgl_renderer.js"; + */ + +/** + * A procedural radial-gradient shader effect: solid color at the center + * fading linearly to fully transparent at the edge of the host quad. + * The falloff is naturally elliptical for non-square quads. + * + * **UV-space caveat.** The `apply(color, uv)` function receives + * `vRegion` — the atlas UVs of the host quad. The falloff math + * (`length(uv * 2 - 1)`) assumes those UVs span `[0, 1] × [0, 1]`, + * which is true when the quad samples a full-rect texture (a + * dedicated 1×1 white pixel, a non-atlased Sprite, an FBO blit, or + * the engine-provided light atlas used by `WebGLRenderer.drawLight`). + * If you attach this effect to a Sprite that uses a *sub-region* of a + * larger atlas, `uv` will be in `[u0..u1] × [v0..v1]` and the radial + * center will be misplaced. For atlas-based renderables, set the + * effect's `uColor`/`uIntensity` and pair it with a Sprite whose + * texture covers a full atlas, or use a Sprite created from a + * standalone image. + * + * The falloff curve is **linear** (`f = clamp(1 - d, 0, 1)`) to match + * the Canvas 2D `createRadialGradient` two-stop output exactly. Output + * is premultiplied so the result composes correctly under additive + * (`"lighter"`) blending across overlapping quads. + * + * Color & intensity come from **two stacked sources**, multiplied + * together: the `uColor`/`uIntensity` uniforms (set per-effect via + * `setColor` / `setIntensity` — the natural API for a single-instance + * shader attached to a renderable) AND the per-vertex tint coming + * through `aColor` (used by `WebGLRenderer.drawLight` to encode each + * light's color + intensity in the vertex stream so multiple lights + * sharing this shader can batch into a single draw call). For typical + * standalone usage the per-vertex tint is `(1,1,1,1)` and the uniforms + * drive the look; for the Light2d batching path the uniforms stay at + * defaults and the tint carries everything. + * @category Effects + * @example + * // Soft white spot, 50% peak alpha at center + * const spot = new RadialGradientEffect(renderer, { intensity: 0.5 }); + * @example + * // Tinted hotspot — orange center, full brightness, sized via the + * // host quad's bounds + * const hot = new RadialGradientEffect(renderer, { + * color: new Color(255, 128, 64), + * intensity: 1.0, + * }); + * hot.setIntensity(2.0); // pulse brighter at runtime + * @example + * // Pickup highlight — attach to any Renderable so it renders inside + * // the renderable's bounding rect (anchorPoint applies). Combine with + * // `blendMode = "lighter"` for the additive glow look. + * pickup.shader = new RadialGradientEffect(renderer, { + * color: new Color(120, 255, 200), // mint green + * intensity: 0.8, + * }); + * pickup.blendMode = "lighter"; + * @example + * // Damage / impact indicator — short-lived elliptical flash on hit. + * // The quad's width/height drive the falloff aspect for free. + * const flash = new RadialGradientEffect(renderer, { + * color: new Color(255, 32, 32), + * intensity: 1.5, + * }); + * // animate intensity to fade out + * tween.to({ intensity: 0 }, 200).onUpdate((s) => flash.setIntensity(s.intensity)); + * @example + * // Debug overlay — draw a soft circle wherever the player is to mark + * // a trigger zone, without baking a texture per zone. + * const zoneMarker = new RadialGradientEffect(renderer, { + * color: new Color(80, 160, 255), + * intensity: 0.4, + * }); + */ +export default class RadialGradientEffect extends ShaderEffect { + /** + * @param {WebGLRenderer} renderer - the current renderer instance + * @param {object} [options] - initial uniform values + * @param {Color} [options.color] - center color (0..255 RGB); defaults to white + * @param {number} [options.intensity=1] - peak alpha at the center (0..1+) + */ + constructor(renderer, options = {}) { + super( + renderer, + ` + uniform vec3 uColor; + uniform float uIntensity; + vec4 apply(vec4 color, vec2 uv) { + // recenter to [-1, 1] across the quad. The quad's own aspect + // ratio handles elliptical falloffs naturally — length(c) == 1 + // lies on the inscribed ellipse in world space. + vec2 c = uv * 2.0 - 1.0; + float d = length(c); + // linear ramp matches Canvas createRadialGradient's two-stop output + float f = clamp(1.0 - d, 0.0, 1.0); + // 'color' is the per-vertex tint, already premultiplied by + // alpha in the vertex shader (vColor = vec4(aColor.bgr * + // aColor.a, aColor.a)). For standalone use the tint is + // (1,1,1,1) and the uniforms drive the look; for the Light2d + // batching path the uniforms stay at default and the tint + // carries the per-light color + intensity. + vec3 rgb = color.rgb * uColor * uIntensity * f; + float a = color.a * uIntensity * f; + return vec4(rgb, a); + } + `, + ); + + // reused across `setColor` calls so we don't allocate a fresh + // 3-element array every frame on every light. + this._colorBuf = new Float32Array(3); + + const color = options.color; + if (color) { + this.setColor(color); + } else { + this._colorBuf[0] = 1; + this._colorBuf[1] = 1; + this._colorBuf[2] = 1; + this.setUniform("uColor", this._colorBuf); + } + this.setIntensity(options.intensity ?? 1); + } + + /** + * Set the center color. RGB only — alpha is ignored (the radial + * falloff supplies the per-pixel alpha). + * @param {Color} color - 0..255 RGB color + */ + setColor(color) { + this._colorBuf[0] = color.r / 255; + this._colorBuf[1] = color.g / 255; + this._colorBuf[2] = color.b / 255; + this.setUniform("uColor", this._colorBuf); + } + + /** + * Set the peak intensity. Acts as a brightness multiplier on the + * falloff curve; values above 1 over-saturate the center of the gradient. + * @param {number} intensity - 0..1+ multiplier + */ + setIntensity(intensity) { + this.setUniform("uIntensity", intensity); + } +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 4577840ee..458f5dbac 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -24,6 +24,7 @@ import LitQuadBatcher from "./batchers/lit_quad_batcher"; import MeshBatcher from "./batchers/mesh_batcher"; import PrimitiveBatcher from "./batchers/primitive_batcher"; import QuadBatcher from "./batchers/quad_batcher"; +import RadialGradientEffect from "./effects/radialGradient.js"; import { createLightUniformScratch, packLights } from "./lighting/pack.ts"; import { getMaxShaderPrecision } from "./utils/precision.js"; @@ -372,6 +373,27 @@ export default class WebGLRenderer extends Renderer { // release post-process FBOs (will be recreated on demand) this._renderTargetPool.destroy(); + + // drop the lazily-cached drawLight resources — after a context + // loss/restore the cached shader program and white-pixel atlas + // reference the OLD GL context and would error if reused. + // Lazy re-init happens on the next drawLight call. + if (this._lightShader !== undefined) { + this._lightShader.destroy?.(); + this._lightShader = undefined; + } + if (this._lightAtlas !== undefined) { + // `TextureCache` is keyed by the source image, not by the + // `TextureAtlas` instance. Dropping the wrong key would leak + // the entry (so a context lost / restore would later resolve + // the canvas to a `TextureAtlas` whose internal GL texture + // reference is invalid). Iterate the atlas's sources to drop + // every cached entry it registered in `cache.set(source, this)`. + this._lightAtlas.sources.forEach((source) => { + this.cache.delete?.(source); + }); + this._lightAtlas = undefined; + } } /** @@ -405,8 +427,20 @@ export default class WebGLRenderer extends Renderer { throw new Error("Invalid Batcher"); } - // fast path: already on the right batcher with no custom shader - if (this.currentBatcher === batcher && typeof shader !== "object") { + // resolve the target shader — the explicitly-passed one if any, + // otherwise the batcher's default. We always reconcile the + // currentShader to this target so a custom shader left bound by a + // prior call (e.g. `drawLight` parking the radial-gradient + // program) gets evicted before the next sprite batch flushes. + // `shader != null` excludes both `null` and `undefined` + // (`typeof null === "object"` would otherwise let null through). + const targetShader = shader != null ? shader : batcher.defaultShader; + + if ( + this.currentBatcher === batcher && + batcher.currentShader === targetShader + ) { + // fast path: same batcher, same shader — nothing to do. return this.currentBatcher; } @@ -424,9 +458,11 @@ export default class WebGLRenderer extends Renderer { this.currentBatcher.setProjection(this.projectionMatrix); } - if (typeof shader === "object") { - this.currentBatcher.useShader(shader); - } + // useShader() is internally a no-op when the shader is already + // bound; it flushes and rebinds otherwise. Pending vertices that + // were queued under the prior shader get drained against that + // shader before the switch, which is exactly what we want. + this.currentBatcher.useShader(targetShader); return this.currentBatcher; } @@ -550,6 +586,80 @@ export default class WebGLRenderer extends Renderer { } } + /** + * @inheritdoc + * + * Renders the light as a quad through a shared + * {@link RadialGradientEffect} fragment shader (procedural — no + * per-light texture). The shader and a shared 1×1 white-pixel atlas + * are lazy-allocated on first call and reused for every Light2d on + * this renderer. Each light's color and intensity are encoded into + * the per-vertex tint so consecutive `drawLight` calls accumulate + * into the quad batcher's buffer and flush together — N lights + * become 1 program switch + 1 flush instead of 2N + N. + * @param {object} light - the Light2d instance to render + */ + drawLight(light) { + if (this._lightShader === undefined) { + this._lightShader = new RadialGradientEffect(this); + } + // `setBatcher("quad", _lightShader)` switches the quad batcher's + // shader to the radial gradient if it isn't already bound (and + // flushes any sprite vertices queued under the previous shader). + // On subsequent back-to-back `drawLight` calls this is a no-op, + // so the lights pile into the same vertex buffer. + const batcher = this.setBatcher("quad", this._lightShader); + batcher.addQuad( + this._getLightAtlas(), + light.pos.x, + light.pos.y, + light.width, + light.height, + 0, + 0, + 1, + 1, + // 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), + ); + // Note: we deliberately do NOT switch back to the default shader + // here. The next `setBatcher` call (sprites, primitives, etc.) + // will reconcile to the right shader on its own (see + // `setBatcher`), and that's what unlocks the cross-light batch. + } + + /** + * Lazy-init a shared 1×1 white `TextureAtlas` used as the source + * texture for `drawLight`'s procedural shader. The shader ignores + * the sampled color, but `addQuad`'s vertex format includes a + * texture-unit attribute so we still need a real texture; sharing + * one across every light keeps them on the same multi-texture slot + * (no flush on light switch). + * @returns {TextureAtlas} + * @ignore + */ + _getLightAtlas() { + if (this._lightAtlas === undefined) { + // build a 1×1 white canvas — TextureAtlas wants an image-like + // source that can feed `gl.texImage2D` directly. + const canvas = globalThis.document + ? globalThis.document.createElement("canvas") + : new OffscreenCanvas(1, 1); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, 1, 1); + this._lightAtlas = new TextureAtlas( + createAtlas(1, 1, "lightWhite", "no-repeat"), + canvas, + ); + } + return this._lightAtlas; + } + /** * Begin capturing rendering to an offscreen FBO for post-effect processing. * @param {Renderable} renderable - the renderable requesting post-effect processing @@ -931,7 +1041,7 @@ export default class WebGLRenderer extends Renderer { this.setBatcher(useLit ? "litQuad" : "quad"); const shader = this.customShader; - if (typeof shader === "object") { + if (shader != null) { this.currentBatcher.useShader(shader); } @@ -1015,7 +1125,7 @@ export default class WebGLRenderer extends Renderer { this.setBatcher("mesh"); // apply custom shader if set on the renderable (via preDraw) - if (typeof this.customShader === "object") { + if (this.customShader != null) { this.currentBatcher.useShader(this.customShader); } @@ -1054,7 +1164,7 @@ export default class WebGLRenderer extends Renderer { gl.depthMask(false); // revert to default shader if custom was applied - if (typeof this.customShader === "object") { + if (this.customShader != null) { this.currentBatcher.useShader(this.currentBatcher.defaultShader); } } diff --git a/packages/melonjs/tests/lights.spec.js b/packages/melonjs/tests/lights.spec.js index 8e8401f72..13f21d708 100644 --- a/packages/melonjs/tests/lights.spec.js +++ b/packages/melonjs/tests/lights.spec.js @@ -10,6 +10,7 @@ import { state, video, } from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; import { createLightUniformScratch, packLights, @@ -837,23 +838,26 @@ describe("Light2d + Stage lighting", () => { game.world.removeChildNow(light, true); }); - it("draw() passes pos.x/pos.y to drawImage (anchor-aware regression guard)", () => { + it("draw() delegates to renderer.drawLight with the light instance", () => { + // Light2d is renderer-agnostic — `draw` just calls + // `renderer.drawLight(this)` and lets the renderer pick the + // implementation (procedural shader on WebGL, offscreen-canvas + // bake on Canvas). const light = spawn(150, 100, 30); - const drawImageCalls = []; + const drawLightCalls = []; const stub = { - drawImage: (img, x, y) => { - drawImageCalls.push({ x, y }); + drawLight: (l) => { + drawLightCalls.push(l); }, }; light.draw(stub); - expect(drawImageCalls).toHaveLength(1); - expect(drawImageCalls[0].x).toBe(150); - expect(drawImageCalls[0].y).toBe(100); + expect(drawLightCalls).toHaveLength(1); + expect(drawLightCalls[0]).toBe(light); game.world.removeChildNow(light, true); }); - it("illuminationOnly=true skips drawing the gradient texture", () => { + it("illuminationOnly=true skips renderer.drawLight", () => { // SpriteIlluminator workflow: a logical light source whose own // gradient isn't visible — only its effect on normal-mapped // sprites is. The light still feeds the cutout pass and the @@ -862,14 +866,14 @@ describe("Light2d + Stage lighting", () => { const light = spawn(150, 100, 30); light.illuminationOnly = true; - const drawImageCalls = []; + const drawLightCalls = []; const stub = { - drawImage: (img, x, y) => { - drawImageCalls.push({ x, y }); + drawLight: (l) => { + drawLightCalls.push(l); }, }; light.draw(stub); - expect(drawImageCalls).toHaveLength(0); + expect(drawLightCalls).toHaveLength(0); game.world.removeChildNow(light, true); }); @@ -1626,4 +1630,392 @@ describe("Light2d + Stage lighting", () => { expect(warnings.length).toBe(0); }); }); + + describe("renderer.drawLight (procedural / cached paths)", () => { + it("Light2d does not allocate any per-light texture in its constructor", () => { + // The pre-#1430 implementation built a `CanvasRenderTarget` in + // the constructor (per-light memory cost, irrespective of + // renderer). Both renderers now defer rendering machinery to + // `drawLight()`, so Light2d itself owns no canvas/texture. + const light = new Light2d(0, 0, 30); + expect(light.texture).toBeUndefined(); + light.destroy(); + }); + + it("Canvas drawLight caches the Gradient; same light reuses the same instance", () => { + const renderer = video.renderer; + // reset the cache to start clean + renderer._lightCache = undefined; + + const light = new Light2d(50, 50, 30); + renderer.drawLight(light); + expect(renderer._lightCache).toBeInstanceOf(WeakMap); + const firstEntry = renderer._lightCache.get(light); + expect(firstEntry).toBeDefined(); + expect(firstEntry.gradient).toBeDefined(); + const firstGradient = firstEntry.gradient; + + renderer.drawLight(light); + // no property changed → same Gradient instance reused + expect(renderer._lightCache.get(light).gradient).toBe(firstGradient); + + light.destroy(); + }); + + it("Canvas drawLight rebuilds the Gradient when radius changes", () => { + // fixes the original stale-texture bug: mutating radii after + // construction must invalidate the cached gradient on next draw. + const renderer = video.renderer; + renderer._lightCache = undefined; + + const light = new Light2d(50, 50, 30); + renderer.drawLight(light); + const oldEntry = renderer._lightCache.get(light); + + light.setRadii(60, 60); + renderer.drawLight(light); + const newEntry = renderer._lightCache.get(light); + expect(newEntry.radiusX).toBe(60); + expect(newEntry.radiusY).toBe(60); + // dimensions changed → new Gradient instance built + expect(newEntry.gradient).not.toBe(oldEntry.gradient); + + light.destroy(); + }); + + it("Canvas drawLight rebuilds the Gradient when color or intensity changes", () => { + const renderer = video.renderer; + renderer._lightCache = undefined; + + const light = new Light2d(0, 0, 25, 25, "#ff0000", 0.5); + renderer.drawLight(light); + const baseline = renderer._lightCache.get(light); + expect(baseline.r).toBe(255); + expect(baseline.intensity).toBe(0.5); + + light.color.parseCSS("#00ff00"); + renderer.drawLight(light); + expect(renderer._lightCache.get(light).g).toBe(255); + expect(renderer._lightCache.get(light).gradient).not.toBe( + baseline.gradient, + ); + + light.intensity = 0.9; + renderer.drawLight(light); + expect(renderer._lightCache.get(light).intensity).toBe(0.9); + + light.destroy(); + }); + + it("Canvas drawLight uses Gradient.toCanvas (not toCanvasGradient) so the cached gradient stays correct as the light moves", () => { + // `Gradient.toCanvasGradient(ctx)` caches the underlying native + // `CanvasGradient` against the calling context's *current* + // transform — using it inside `drawLight` would anchor the + // cached gradient to the light's first-drawn position, and + // every subsequent draw would render at the wrong offset. + // + // We use `Gradient.toCanvas`, which renders into a *separate* + // shared offscreen `CanvasRenderTarget` whose context has no + // inherited transform. Each call creates a fresh native + // `CanvasGradient` inside that isolated context — no anchoring. + // + // This regression test guarantees the implementation keeps + // using the transform-isolated path. + const renderer = video.renderer; + renderer._lightCache = undefined; + + const light = new Light2d(50, 50, 30); + renderer.drawLight(light); + const entry = renderer._lightCache.get(light); + expect(entry).toBeDefined(); + + let toCanvasCalls = 0; + let toCanvasGradientCalls = 0; + const origToCanvas = entry.gradient.toCanvas.bind(entry.gradient); + entry.gradient.toCanvas = (...args) => { + toCanvasCalls++; + return origToCanvas(...args); + }; + if (typeof entry.gradient.toCanvasGradient === "function") { + const origTCG = entry.gradient.toCanvasGradient.bind(entry.gradient); + entry.gradient.toCanvasGradient = (...args) => { + toCanvasGradientCalls++; + return origTCG(...args); + }; + } + + // move the light and redraw a couple of times — each draw must + // go through `toCanvas`, never `toCanvasGradient`. + light.pos.x = 100; + light.pos.y = 100; + renderer.drawLight(light); + light.pos.x = 200; + light.pos.y = 250; + renderer.drawLight(light); + + expect(toCanvasCalls).toBeGreaterThanOrEqual(2); + expect(toCanvasGradientCalls).toBe(0); + + light.destroy(); + }); + + it("Canvas reset() clears the light cache", () => { + const renderer = video.renderer; + renderer._lightCache = undefined; + + const light = new Light2d(0, 0, 25); + renderer.drawLight(light); + expect(renderer._lightCache).toBeInstanceOf(WeakMap); + + renderer.reset(); + expect(renderer._lightCache).toBeUndefined(); + + light.destroy(); + }); + + it("base Renderer.drawLight is a no-op (does not throw, returns undefined)", () => { + // Polymorphism guard: any renderer subclass without a custom + // drawLight should be safely substitutable in Light2d.draw. + const baseDrawLight = Renderer.prototype.drawLight; + expect(typeof baseDrawLight).toBe("function"); + const fakeLight = { + color: { r: 0, g: 0, b: 0 }, + radiusX: 1, + radiusY: 1, + intensity: 1, + pos: { x: 0, y: 0 }, + width: 2, + height: 2, + }; + expect(() => { + baseDrawLight.call({}, fakeLight); + }).not.toThrow(); + }); + }); +}); + +describe("RadialGradientEffect (standalone API, WebGL)", () => { + // `RadialGradientEffect` is the generic procedural radial-gradient + // shader that `WebGLRenderer.drawLight` happens to use for Light2d. + // Exposed standalone via constructor + setColor/setIntensity so + // tests (and any future caller — debug overlays, hotspots, etc.) + // can use it without going through Light2d. + // + // This block runs in its own WebGL-initialized describe so the tests + // actually exercise the shader rather than silently no-op'ing on + // Canvas. The parent describe uses `video.CANVAS`. + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + }); + + afterAll(() => { + // restore the file-level CANVAS init the parent describe started + // with — keeps the renderer choice deterministic for any sibling + // describe that might run after this one in the same file. + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + }); + + it("constructor accepts color/intensity options without throwing", async () => { + // Sanity: the test only makes sense if WebGL actually came up. + expect(video.renderer.WebGLVersion).toBeGreaterThan(0); + const { default: RadialGradientEffect } = await import( + "../src/video/webgl/effects/radialGradient.js" + ); + const { Color } = await import("../src/math/color.ts"); + expect(() => { + const eff = new RadialGradientEffect(video.renderer, { + color: new Color(255, 128, 64), + intensity: 0.8, + }); + eff.setColor(new Color(0, 255, 0)); + eff.setIntensity(1.5); + }).not.toThrow(); + }); + + it("constructor with no options uses sensible defaults (white, 1.0)", async () => { + expect(video.renderer.WebGLVersion).toBeGreaterThan(0); + const { default: RadialGradientEffect } = await import( + "../src/video/webgl/effects/radialGradient.js" + ); + expect(() => { + return new RadialGradientEffect(video.renderer); + }).not.toThrow(); + }); + + it("drawLight drains pending sprite vertices BEFORE binding the light shader", () => { + // Regression guard for the mid-batch corruption class: + // + // If `drawLight` flipped the active program (or rebound a texture + // unit) while the quad batcher still held sprite vertices queued + // under the default shader, those queued vertices would later + // flush against the wrong program / wrong texture binding and + // render garbage. + // + // The current implementation routes through + // `setBatcher("quad", _lightShader)` → `useShader(_lightShader)`, + // and `useShader` is documented to flush *first*, then bind the + // new program. So the queued sprite vertices are drained against + // `defaultShader` before the light shader takes over. This test + // enforces that ordering by spying on `flush` and capturing the + // active shader at flush time. + const renderer = video.renderer; + expect(renderer.WebGLVersion).toBeGreaterThan(0); + + // warm up the lazy resources so the test only measures the + // steady-state path. + const warmup = new Light2d(0, 0, 8); + renderer.drawLight(warmup); + warmup.destroy(); + + const batcher = renderer.batchers.get("quad"); + // reset the batcher to the default shader (the state a normal + // sprite draw would leave it in) and queue one quad's worth of + // vertices manually. + renderer.setBatcher("quad"); + expect(batcher.currentShader).toBe(batcher.defaultShader); + const baselineCount = batcher.vertexData.vertexCount; + batcher.vertexData.push(0, 0, 0, 0, 0xffffffff, 0); + batcher.vertexData.push(1, 0, 1, 0, 0xffffffff, 0); + batcher.vertexData.push(0, 1, 0, 1, 0xffffffff, 0); + batcher.vertexData.push(1, 1, 1, 1, 0xffffffff, 0); + expect(batcher.vertexData.vertexCount).toBe(baselineCount + 4); + + const events = []; + const origFlush = batcher.flush.bind(batcher); + batcher.flush = (...a) => { + // snapshot which shader the flush is targeting BEFORE + // useShader swaps it. + events.push(batcher.currentShader); + return origFlush(...a); + }; + + try { + const light = new Light2d(50, 50, 30); + renderer.drawLight(light); + light.destroy(); + } finally { + batcher.flush = origFlush; + } + + // First flush recorded must have happened while the default + // shader was still bound — that's the proof that the queued + // sprite vertices were drained before the light shader took + // over. + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[0]).toBe(batcher.defaultShader); + }); + + it("drawLight batches consecutive lights into a single flush + program switch", () => { + // The whole point of routing `drawLight` through `addQuad` (with + // per-vertex tint encoding color + intensity) is that N back-to- + // back lights pile into one shared vertex buffer instead of each + // taking its own draw call. This test enforces that contract. + const renderer = video.renderer; + expect(renderer.WebGLVersion).toBeGreaterThan(0); + + // warm up + const warmup = new Light2d(0, 0, 8); + renderer.drawLight(warmup); + warmup.destroy(); + + const batcher = renderer.batchers.get("quad"); + // reset to a clean baseline: default shader, empty vertex buffer. + renderer.setBatcher("quad"); + batcher.flush(); + expect(batcher.currentShader).toBe(batcher.defaultShader); + expect(batcher.vertexData.vertexCount).toBe(0); + + let flushCount = 0; + const origFlush = batcher.flush.bind(batcher); + batcher.flush = (...a) => { + flushCount++; + return origFlush(...a); + }; + + const lights = [ + new Light2d(50, 50, 30, 30, "#ff0000", 0.7), + new Light2d(150, 50, 30, 30, "#00ff00", 0.7), + new Light2d(250, 50, 30, 30, "#0000ff", 0.7), + new Light2d(50, 150, 30, 30, "#ffffff", 0.5), + ]; + + try { + for (const l of lights) { + renderer.drawLight(l); + } + } finally { + batcher.flush = origFlush; + for (const l of lights) { + l.destroy(); + } + } + + // At most one flush — the implicit one inside `useShader` when + // the radial-gradient shader is bound for the first light. + // Lights 2-N hit the same shader and accumulate without + // flushing. + expect(flushCount).toBeLessThanOrEqual(1); + // All four lights' vertices should still be queued (4 vertices + // per quad × 4 lights = 16). + expect(batcher.vertexData.vertexCount).toBe(16); + // And the radial-gradient shader is still bound — the next + // non-light draw will reconcile via setBatcher's targetShader + // logic. + expect(batcher.currentShader).toBe(renderer._lightShader); + }); + + it("drawLight uses a per-vertex tint to carry per-light color + intensity", () => { + // Architectural guarantee: the per-light color/intensity must + // flow through the vertex tint (so lights batch), NOT through + // `setColor` / `setIntensity` calls on the shared shader (which + // would be uniforms — unique per-program-state, killing batching). + const renderer = video.renderer; + expect(renderer.WebGLVersion).toBeGreaterThan(0); + + const warmup = new Light2d(0, 0, 8); + renderer.drawLight(warmup); + warmup.destroy(); + + let setColorCalls = 0; + let setIntensityCalls = 0; + const origSetColor = renderer._lightShader.setColor.bind( + renderer._lightShader, + ); + const origSetIntensity = renderer._lightShader.setIntensity.bind( + renderer._lightShader, + ); + renderer._lightShader.setColor = (...a) => { + setColorCalls++; + return origSetColor(...a); + }; + renderer._lightShader.setIntensity = (...a) => { + setIntensityCalls++; + return origSetIntensity(...a); + }; + + try { + const a = new Light2d(50, 50, 30, 30, "#ff0000", 0.5); + const b = new Light2d(100, 100, 30, 30, "#00ff00", 0.9); + renderer.drawLight(a); + renderer.drawLight(b); + a.destroy(); + b.destroy(); + } finally { + renderer._lightShader.setColor = origSetColor; + renderer._lightShader.setIntensity = origSetIntensity; + } + + expect(setColorCalls).toBe(0); + expect(setIntensityCalls).toBe(0); + }); });