From c2e5a4927db00529625dbb2dae1bea82e809f5b7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 4 Apr 2026 18:08:21 +0800 Subject: [PATCH 01/10] Add gradient support to renderer (createLinearGradient / createRadialGradient) Closes #1352 - New Gradient class with addColorStop(), matching Canvas 2D API - createLinearGradient() and createRadialGradient() on base Renderer - setColor() accepts Gradient objects on both Canvas and WebGL renderers - Canvas: uses native CanvasGradient directly as fillStyle - WebGL: renders gradient to CanvasRenderTarget texture, draws via drawImage - WebGL non-rect shapes (ellipse, arc, polygon, roundRect) use stencil masking - Gradient state saved/restored with save()/restore() via RenderState - Canvas renderer restore() handles CanvasGradient fillStyle gracefully - New gradients example showcasing sky, health bars, shapes, rainbow star - 24 unit tests covering API, caching, save/restore, and color/gradient mixing Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- .../examples/gradients/ExampleGradients.tsx | 199 ++++++++++++ packages/examples/src/main.tsx | 13 + packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/index.ts | 2 + .../src/video/canvas/canvas_renderer.js | 21 +- packages/melonjs/src/video/gradient.js | 196 ++++++++++++ packages/melonjs/src/video/renderer.js | 37 +++ packages/melonjs/src/video/renderstate.js | 12 + .../melonjs/src/video/webgl/webgl_renderer.js | 132 +++++++- packages/melonjs/tests/gradient.spec.js | 300 ++++++++++++++++++ 11 files changed, 903 insertions(+), 12 deletions(-) create mode 100644 packages/examples/src/examples/gradients/ExampleGradients.tsx create mode 100644 packages/melonjs/src/video/gradient.js create mode 100644 packages/melonjs/tests/gradient.spec.js diff --git a/README.md b/README.md index 9dd8e5346..abaa6527f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Why melonJS melonJS is designed so you can **focus on making games, not on graphics plumbing**. -- **Canvas2D-inspired rendering API** — If you've used the HTML5 Canvas, you already know melonJS. The rendering API (`save`, `restore`, `translate`, `rotate`, `setColor`, `fillRect`, ...) follows the same familiar patterns — no render graphs, no shader pipelines, no instruction sets to learn. +- **[Canvas2D-inspired rendering API](https://github.com/melonjs/melonJS/wiki/Rendering-API)** — If you've used the HTML5 Canvas, you already know melonJS. The rendering API (`save`, `restore`, `translate`, `rotate`, `setColor`, `fillRect`, ...) follows the same familiar patterns — no render graphs, no shader pipelines, no instruction sets to learn. - **True renderer abstraction** — Write your game once, run it on WebGL or Canvas2D with zero code changes. The engine handles all GPU complexity behind a unified API, with automatic fallback when WebGL is not available. Designed to support future backends (WebGPU) without touching game code. diff --git a/packages/examples/src/examples/gradients/ExampleGradients.tsx b/packages/examples/src/examples/gradients/ExampleGradients.tsx new file mode 100644 index 000000000..ab530cdf1 --- /dev/null +++ b/packages/examples/src/examples/gradients/ExampleGradients.tsx @@ -0,0 +1,199 @@ +import { + Application as App, + type Application, + type CanvasRenderer, + Polygon, + Renderable, + Stage, + state, + type WebGLRenderer, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +type Renderer = CanvasRenderer | WebGLRenderer; + +/** + * A renderable that showcases linear and radial gradients. + */ +class GradientShowcase extends Renderable { + constructor() { + super(0, 0, 1024, 768); + this.anchorPoint.set(0, 0); + this.alwaysUpdate = true; + } + + draw(renderer: Renderer) { + const w = this.width; + const h = this.height; + + // ---- Sky background (linear gradient, top to bottom) ---- + const sky = renderer.createLinearGradient(0, 0, 0, h * 0.6); + sky.addColorStop(0, "#0B0B3B"); + sky.addColorStop(0.4, "#1a1a6e"); + sky.addColorStop(0.7, "#4a2080"); + sky.addColorStop(1, "#FF6B35"); + renderer.setColor(sky); + renderer.fillRect(0, 0, w, h * 0.6); + + // ---- Ground (linear gradient) ---- + const ground = renderer.createLinearGradient(0, h * 0.6, 0, h); + ground.addColorStop(0, "#2d5016"); + ground.addColorStop(0.5, "#1a3a0a"); + ground.addColorStop(1, "#0d1f05"); + renderer.setColor(ground); + renderer.fillRect(0, h * 0.6, w, h * 0.4); + + // ---- Sun (radial gradient) ---- + const sunX = w * 0.75; + const sunY = h * 0.35; + const sunR = 60; + const sun = renderer.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR); + sun.addColorStop(0, "#FFFFFF"); + sun.addColorStop(0.3, "#FFEE88"); + sun.addColorStop(0.7, "#FFAA33"); + sun.addColorStop(1, "rgba(255, 100, 0, 0)"); + renderer.setColor(sun); + renderer.fillRect(sunX - sunR, sunY - sunR, sunR * 2, sunR * 2); + + // ---- Sun glow (larger radial gradient) ---- + const glowR = 150; + const glow = renderer.createRadialGradient( + sunX, + sunY, + sunR * 0.5, + sunX, + sunY, + glowR, + ); + glow.addColorStop(0, "rgba(255, 200, 100, 0.3)"); + glow.addColorStop(1, "rgba(255, 100, 0, 0)"); + renderer.setColor(glow); + renderer.fillRect(sunX - glowR, sunY - glowR, glowR * 2, glowR * 2); + + // ---- Health bar background ---- + const barX = 30; + const barY = 30; + const barW = 200; + const barH = 24; + renderer.setColor("#333333"); + renderer.fillRect(barX - 2, barY - 2, barW + 4, barH + 4); + + // ---- Health bar fill (linear gradient, green to yellow to red) ---- + const healthPct = 0.7; + const health = renderer.createLinearGradient(barX, 0, barX + barW, 0); + health.addColorStop(0, "#00FF00"); + health.addColorStop(0.5, "#FFFF00"); + health.addColorStop(1, "#FF0000"); + renderer.setColor(health); + renderer.fillRect(barX, barY, barW * healthPct, barH); + + // ---- Mana bar (blue gradient) ---- + const manaY = barY + barH + 10; + renderer.setColor("#333333"); + renderer.fillRect(barX - 2, manaY - 2, barW + 4, barH + 4); + const mana = renderer.createLinearGradient(barX, 0, barX + barW, 0); + mana.addColorStop(0, "#0044FF"); + mana.addColorStop(0.5, "#00BBFF"); + mana.addColorStop(1, "#00FFFF"); + renderer.setColor(mana); + renderer.fillRect(barX, manaY, barW * 0.5, barH); + + // ---- Metallic button (vertical linear gradient) ---- + const btnX = 30; + const btnY = 110; + const btnW = 160; + const btnH = 40; + const btn = renderer.createLinearGradient(0, btnY, 0, btnY + btnH); + btn.addColorStop(0, "#EEEEEE"); + btn.addColorStop(0.5, "#AAAAAA"); + btn.addColorStop(0.51, "#888888"); + btn.addColorStop(1, "#CCCCCC"); + renderer.setColor(btn); + renderer.fillRect(btnX, btnY, btnW, btnH); + + // button border + renderer.setColor("#666666"); + renderer.strokeRect(btnX, btnY, btnW, btnH); + + // ---- Gradient on shapes ---- + + // gradient-filled circle + const circleGrad = renderer.createRadialGradient(65, 190, 0, 65, 190, 25); + circleGrad.addColorStop(0, "#FF6600"); + circleGrad.addColorStop(1, "#CC0000"); + renderer.setColor(circleGrad); + renderer.fillEllipse(65, 190, 25, 25); + + // gradient-filled rounded rect (pill) + const pillGrad = renderer.createLinearGradient(110, 0, 270, 0); + pillGrad.addColorStop(0, "#00CC88"); + pillGrad.addColorStop(1, "#0066FF"); + renderer.setColor(pillGrad); + renderer.fillRoundRect(110, 175, 160, 30, 15); + + // ---- Rainbow star (gradient on polygon) ---- + const starCx = 330; + const starCy = 190; + const outerR = 45; + const innerR = 20; + const points = 5; + const starVerts: { x: number; y: number }[] = []; + for (let i = 0; i < points * 2; i++) { + const angle = (i * Math.PI) / points - Math.PI / 2; + const r = i % 2 === 0 ? outerR : innerR; + starVerts.push({ + x: Math.cos(angle) * r, + y: Math.sin(angle) * r, + }); + } + // gradient coords are relative to the polygon's pos since + // fillPolygon translates the context by poly.pos + const rainbow = renderer.createRadialGradient(0, 0, 0, 0, 0, outerR); + rainbow.addColorStop(0, "#FF0000"); + rainbow.addColorStop(0.2, "#FF8800"); + rainbow.addColorStop(0.4, "#FFFF00"); + rainbow.addColorStop(0.6, "#00FF00"); + rainbow.addColorStop(0.8, "#0088FF"); + rainbow.addColorStop(1, "#8800FF"); + renderer.setColor(rainbow); + renderer.fill(new Polygon(starCx, starCy, starVerts)); + + // ---- Spotlight / vignette effect (large radial gradient) ---- + const vigR = Math.max(w, h) * 0.7; + const vig = renderer.createRadialGradient( + w / 2, + h / 2, + vigR * 0.3, + w / 2, + h / 2, + vigR, + ); + vig.addColorStop(0, "rgba(0, 0, 0, 0)"); + vig.addColorStop(1, "rgba(0, 0, 0, 0.5)"); + renderer.setColor(vig); + renderer.fillRect(0, 0, w, h); + + // ---- Labels ---- + renderer.setColor("#FFFFFF"); + } +} + +class GradientScreen extends Stage { + override onResetEvent(app: Application) { + app.world.backgroundColor.parseCSS("#000000"); + app.world.addChild(new GradientShowcase()); + } +} + +const createGame = () => { + const _app = new App(1024, 768, { + parent: "screen", + scale: "auto", + scaleMethod: "flex-width", + }); + + state.set(state.PLAY, new GradientScreen()); + state.change(state.PLAY); +}; + +export const ExampleGradients = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index bb247e1c6..c9bb1b57e 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -48,6 +48,11 @@ const ExampleDragAndDrop = lazy(() => default: m.ExampleDragAndDrop, })), ); +const ExampleGradients = lazy(() => + import("./examples/gradients/ExampleGradients").then((m) => ({ + default: m.ExampleGradients, + })), +); const ExampleGraphics = lazy(() => import("./examples/graphics/ExampleGraphics").then((m) => ({ default: m.ExampleGraphics, @@ -187,6 +192,14 @@ const examples: { description: "Interactive drag-and-drop with pointer events, collision detection, and drop zones.", }, + { + component: , + label: "Gradients", + path: "gradients", + sourceDir: "gradients", + description: + "Linear and radial gradients for sky backgrounds, health bars, UI buttons, and lighting effects.", + }, { component: , label: "Graphics", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 9e75a793f..d1a5e7c00 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [18.3.0] (melonJS 2) ### Added +- Renderer: `createLinearGradient()` and `createRadialGradient()` methods — create gradient fills that can be passed to `setColor()`, matching the Canvas 2D API. Works on both Canvas and WebGL renderers with all fill methods (`fillRect`, `fillEllipse`, `fillArc`, `fillPolygon`, `fillRoundRect`). Gradient state is saved/restored with `save()`/`restore()`. - Tiled: extensible object factory registry for `TMXTileMap.getObjects()` — object creation is now dispatched through a `Map`-based registry (like `loader.setParser`), with built-in factories for text, tile, and shape objects, plus class-based factories for Entity, Collectable, Trigger, Light2d, Sprite, NineSliceSprite, ImageLayer, and ColorLayer - Tiled: new public `registerTiledObjectFactory(type, factory)` and `registerTiledObjectClass(name, Constructor)` APIs allowing plugins to register custom Tiled object handlers by class name without modifying engine code - Tiled: `detectObjectType()` now checks `settings.class` and `settings.name` against the factory registry before falling through to structural detection, enabling class-based dispatch for custom types diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 28c0466d7..9679f47cf 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -51,6 +51,7 @@ import save from "./system/save.ts"; import timer from "./system/timer.ts"; import Tween from "./tweens/tween.ts"; import CanvasRenderer from "./video/canvas/canvas_renderer.js"; +import { Gradient } from "./video/gradient.js"; import Renderer from "./video/renderer.js"; import RenderState from "./video/renderstate.js"; import CanvasRenderTarget from "./video/rendertarget/canvasrendertarget.js"; @@ -119,6 +120,7 @@ export { DropTarget, Entity, // eslint-disable-line @typescript-eslint/no-deprecated GLShader, + Gradient, ImageLayer, Light2d, NineSliceSprite, diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index 828443e12..f8fd30b68 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -6,6 +6,7 @@ import { ONCONTEXT_RESTORED, on, } from "../../system/event.ts"; +import { Gradient } from "./../gradient.js"; import Renderer from "./../renderer.js"; import TextureCache from "./../texture/cache.js"; @@ -669,7 +670,10 @@ export default class CanvasRenderer extends Renderer { this.setBlendMode(result.blendMode); } // re-sync from the native context (which is authoritative for Canvas) - this.currentColor.copy(context.fillStyle); + // fillStyle may be a CanvasGradient/CanvasPattern — only sync if it's a color string + if (typeof context.fillStyle === "string") { + this.currentColor.copy(context.fillStyle); + } this.currentColor.glArray[3] = context.globalAlpha; // reset scissor cache so the next clipRect() won't skip this.currentScissor[0] = 0; @@ -724,16 +728,19 @@ export default class CanvasRenderer extends Renderer { /** * Set the current fill & stroke style color. * By default, or upon reset, the value is set to #000000. - * @param {Color|string} color - css color value + * @param {Color|string|Gradient} color - css color value or a Gradient object */ setColor(color) { - const currentColor = this.currentColor; const context = this.getContext(); - currentColor.copy(color); - // globalAlpha is applied at rendering time by the canvas - - context.strokeStyle = context.fillStyle = currentColor.toRGBA(); + if (color instanceof Gradient) { + this.renderState.currentGradient = color; + context.strokeStyle = context.fillStyle = color.toCanvasGradient(context); + } else { + this.renderState.currentGradient = null; + this.currentColor.copy(color); + context.strokeStyle = context.fillStyle = this.currentColor.toRGBA(); + } } /** diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js new file mode 100644 index 000000000..cfb98dfb3 --- /dev/null +++ b/packages/melonjs/src/video/gradient.js @@ -0,0 +1,196 @@ +import { nextPowerOfTwo } from "../math/math.ts"; +import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; + +/** + * @import {Color} from "../math/color.ts"; + */ + +/** + * A Gradient object representing a linear or radial gradient fill. + * Created via {@link CanvasRenderer#createLinearGradient}, {@link CanvasRenderer#createRadialGradient}, + * {@link WebGLRenderer#createLinearGradient}, or {@link WebGLRenderer#createRadialGradient}. + * Can be passed to {@link CanvasRenderer#setColor} or {@link WebGLRenderer#setColor} as a fill style. + */ +export class Gradient { + /** + * @param {"linear"|"radial"} type - the gradient type + * @param {number[]} coords - gradient coordinates [x0, y0, x1, y1] for linear, [x0, y0, r0, x1, y1, r1] for radial + */ + constructor(type, coords) { + /** + * gradient type + * @type {"linear"|"radial"} + */ + this.type = type; + + /** + * gradient coordinates + * @type {number[]} + * @ignore + */ + this.coords = coords; + + /** + * color stops + * @type {Array<{offset: number, color: string}>} + * @ignore + */ + this.colorStops = []; + + /** + * cached canvas gradient (for Canvas renderer) + * @type {CanvasGradient|undefined} + * @ignore + */ + this._canvasGradient = undefined; + + /** + * cached gradient render target (for WebGL renderer) + * @type {CanvasRenderTarget|undefined} + * @ignore + */ + this._renderTarget = undefined; + + /** + * whether the gradient needs to be regenerated + * @type {boolean} + * @ignore + */ + this._dirty = true; + } + + /** + * Add a color stop to the gradient. + * @param {number} offset - value between 0.0 and 1.0 + * @param {Color|string} color - a CSS color string or Color object + * @returns {Gradient} this gradient for chaining + * @example + * gradient.addColorStop(0, "#FF0000"); + * gradient.addColorStop(0.5, "green"); + * gradient.addColorStop(1, "blue"); + */ + addColorStop(offset, color) { + this.colorStops.push({ + offset, + color: typeof color === "string" ? color : color.toRGBA(), + }); + this._dirty = true; + this._canvasGradient = undefined; + this._renderTarget = undefined; + return this; + } + + /** + * Get or create a native CanvasGradient for use with a 2D context. + * @param {CanvasRenderingContext2D} context - the 2D context to create the gradient on + * @returns {CanvasGradient} + * @ignore + */ + toCanvasGradient(context) { + if (this._canvasGradient && !this._dirty) { + return this._canvasGradient; + } + + const c = this.coords; + if (this.type === "linear") { + this._canvasGradient = context.createLinearGradient( + c[0], + c[1], + c[2], + c[3], + ); + } else { + this._canvasGradient = context.createRadialGradient( + c[0], + c[1], + c[2], + c[3], + c[4], + c[5], + ); + } + + for (const stop of this.colorStops) { + this._canvasGradient.addColorStop(stop.offset, stop.color); + } + + this._dirty = false; + return this._canvasGradient; + } + + /** + * Render the gradient onto a canvas matching the given draw rect. + * Uses the original gradient coordinates so the result matches Canvas 2D behavior. + * @param {number} x - draw rect x + * @param {number} y - draw rect y + * @param {number} width - draw rect width + * @param {number} height - draw rect height + * @returns {HTMLCanvasElement|OffscreenCanvas} the rendered gradient canvas + * @ignore + */ + toCanvas(x, y, width, height) { + // use power-of-two dimensions for WebGL texture compatibility + const tw = nextPowerOfTwo(Math.max(1, Math.ceil(width))); + const th = nextPowerOfTwo(Math.max(1, Math.ceil(height))); + + // return cached texture if nothing changed + if ( + this._renderTarget && + !this._dirty && + this._lastX === x && + this._lastY === y && + this._renderTarget.width === tw && + this._renderTarget.height === th + ) { + return this._renderTarget.canvas; + } + + // reuse or create the render target + if (!this._renderTarget) { + this._renderTarget = new CanvasRenderTarget(tw, th); + } else if ( + this._renderTarget.width !== tw || + this._renderTarget.height !== th + ) { + this._renderTarget.canvas.width = tw; + this._renderTarget.canvas.height = th; + } + + const ctx = this._renderTarget.context; + ctx.clearRect(0, 0, tw, th); + + // create gradient with coordinates offset to the draw rect origin + const c = this.coords; + let gradient; + + if (this.type === "linear") { + gradient = ctx.createLinearGradient( + c[0] - x, + c[1] - y, + c[2] - x, + c[3] - y, + ); + } else { + gradient = ctx.createRadialGradient( + c[0] - x, + c[1] - y, + c[2], + c[3] - x, + c[4] - y, + c[5], + ); + } + + for (const stop of this.colorStops) { + gradient.addColorStop(stop.offset, stop.color); + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, tw, th); + + this._dirty = false; + this._lastX = x; + this._lastY = y; + return this._renderTarget.canvas; + } +} diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 2b8e3cf2a..18300fbf4 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -3,6 +3,7 @@ import { Color } from "./../math/color.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d } from "../math/vector2d.ts"; import { CANVAS_ONRESIZE, emit } from "../system/event.ts"; +import { Gradient } from "./gradient.js"; import RenderState from "./renderstate.js"; import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; @@ -256,6 +257,16 @@ export default class Renderer { this.currentBlendMode = mode; } + /** + * Set the current fill & stroke style color. + * By default, or upon reset, the value is set to #000000. + * @param {Color|string|Gradient} color - css color value or a Gradient object + */ + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + setColor(color) { + // implemented by subclasses + } + /** * get the current fill & stroke style color. * @returns {Color} current global color @@ -264,6 +275,32 @@ export default class Renderer { return this.currentColor; } + /** + * Create a linear gradient that can be used with {@link Renderer#setColor}. + * @param {number} x0 - x-axis coordinate of the start point + * @param {number} y0 - y-axis coordinate of the start point + * @param {number} x1 - x-axis coordinate of the end point + * @param {number} y1 - y-axis coordinate of the end point + * @returns {Gradient} a Gradient object + */ + createLinearGradient(x0, y0, x1, y1) { + return new Gradient("linear", [x0, y0, x1, y1]); + } + + /** + * Create a radial gradient that can be used with {@link Renderer#setColor}. + * @param {number} x0 - x-axis coordinate of the start circle + * @param {number} y0 - y-axis coordinate of the start circle + * @param {number} r0 - radius of the start circle + * @param {number} x1 - x-axis coordinate of the end circle + * @param {number} y1 - y-axis coordinate of the end circle + * @param {number} r1 - radius of the end circle + * @returns {Gradient} a Gradient object + */ + createRadialGradient(x0, y0, r0, x1, y1, r1) { + return new Gradient("radial", [x0, y0, r0, x1, y1, r1]); + } + /** * return the current global alpha * @returns {number} diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js index e80a86b1a..709d877f9 100644 --- a/packages/melonjs/src/video/renderstate.js +++ b/packages/melonjs/src/video/renderstate.js @@ -35,6 +35,12 @@ export default class RenderState { */ this.currentScissor = new Int32Array(4); + /** + * current gradient fill (null when using solid color) + * @type {Gradient|null} + */ + this.currentGradient = null; + /** * current blend mode * @type {string} @@ -77,6 +83,9 @@ export default class RenderState { /** @ignore */ this._scissorActive = new Uint8Array(this._stackCapacity); + /** @ignore */ + this._gradientStack = new Array(this._stackCapacity); + /** @ignore */ this._blendStack = new Array(this._stackCapacity); } @@ -95,6 +104,7 @@ export default class RenderState { this._colorStack[depth].copy(this.currentColor); this._tintStack[depth].copy(this.currentTint); this._matrixStack[depth].copy(this.currentTransform); + this._gradientStack[depth] = this.currentGradient; this._blendStack[depth] = this.currentBlendMode; if (scissorTestActive) { @@ -123,6 +133,7 @@ export default class RenderState { this.currentColor.copy(this._colorStack[depth]); this.currentTint.copy(this._tintStack[depth]); this.currentTransform.copy(this._matrixStack[depth]); + this.currentGradient = this._gradientStack[depth]; const scissorActive = !!this._scissorActive[depth]; if (scissorActive) { @@ -162,6 +173,7 @@ export default class RenderState { this._tintStack.push(new Color()); this._matrixStack.push(new Matrix2d()); this._scissorStack.push(new Int32Array(4)); + this._gradientStack.push(null); this._blendStack.push(undefined); } const newScissorActive = new Uint8Array(newCap); diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index cbc94211c..9fd4b7d87 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -8,6 +8,7 @@ import { ONCONTEXT_RESTORED, on, } from "../../system/event.ts"; +import { Gradient } from "../gradient.js"; import Renderer from "./../renderer.js"; import { createAtlas, TextureAtlas } from "./../texture/atlas.js"; import TextureCache from "./../texture/cache.js"; @@ -103,6 +104,9 @@ export default class WebGLRenderer extends Renderer { // scratch array for fillPolygon to avoid mutating polygon points this._polyVerts = []; + // current gradient state (null when using solid color) + this._currentGradient = null; + /** * The current transformation matrix used for transformations on the overall scene * (alias to renderState.currentTransform for backward compatibility) @@ -936,12 +940,21 @@ export default class WebGLRenderer extends Renderer { /** * Set the current fill & stroke style color. * By default, or upon reset, the value is set to #000000. - * @param {Color|string} color - css color string. + * @param {Color|string|Gradient} color - css color string or a Gradient object. */ setColor(color) { - const alpha = this.currentColor.alpha; - this.currentColor.copy(color); - this.currentColor.alpha *= alpha; + if (color instanceof Gradient) { + this.renderState.currentGradient = color; + this._currentGradient = color; + // ensure full opacity for gradient texture rendering + this.currentColor.alpha = 1.0; + } else { + this.renderState.currentGradient = null; + this._currentGradient = null; + const alpha = this.currentColor.alpha; + this.currentColor.copy(color); + this.currentColor.alpha *= alpha; + } } /** @@ -975,6 +988,18 @@ export default class WebGLRenderer extends Renderer { * @param {boolean} [antiClockwise=false] - draw arc anti-clockwise */ fillArc(x, y, radius, start, end, antiClockwise = false) { + if (this._currentGradient) { + this.#gradientMask( + () => { + this.fillArc(x, y, radius, start, end, antiClockwise); + }, + x - radius, + y - radius, + radius * 2, + radius * 2, + ); + return; + } this.setBatcher("primitive"); let diff = Math.abs(end - start); if (antiClockwise) { @@ -1026,6 +1051,18 @@ export default class WebGLRenderer extends Renderer { * @param {number} h - vertical radius of the ellipse */ fillEllipse(x, y, w, h) { + if (this._currentGradient) { + this.#gradientMask( + () => { + this.fillEllipse(x, y, w, h); + }, + x - w, + y - h, + w * 2, + h * 2, + ); + return; + } this.setBatcher("primitive"); const segments = Math.max( 8, @@ -1111,6 +1148,37 @@ export default class WebGLRenderer extends Renderer { * @param {Polygon} poly - the shape to draw */ fillPolygon(poly) { + if (this._currentGradient) { + const bounds = poly.getBounds(); + // translate to polygon's local space so gradient coords match + this.translate(poly.pos.x, poly.pos.y); + this.#gradientMask( + () => { + // draw polygon vertices directly (already translated) + this.setBatcher("primitive"); + const indices = poly.getIndices(); + const points = poly.points; + const verts = this._polyVerts; + const len = indices.length; + while (verts.length < len) { + verts.push({ x: 0, y: 0 }); + } + for (let i = 0; i < len; i++) { + const src = points[indices[i]]; + verts[i].x = src.x; + verts[i].y = src.y; + } + this.currentBatcher.drawVertices(this.gl.TRIANGLES, verts, len); + }, + // use local bounds (subtract pos since getBounds includes it) + bounds.x - poly.pos.x, + bounds.y - poly.pos.y, + bounds.width, + bounds.height, + ); + this.translate(-poly.pos.x, -poly.pos.y); + return; + } this.setBatcher("primitive"); this.translate(poly.pos.x, poly.pos.y); const indices = poly.getIndices(); @@ -1174,6 +1242,11 @@ export default class WebGLRenderer extends Renderer { * @param {number} height - The rectangle's height. */ fillRect(x, y, width, height) { + if (this._currentGradient) { + const canvas = this._currentGradient.toCanvas(x, y, width, height); + this.drawImage(canvas, 0, 0, width, height, x, y, width, height); + return; + } this.setBatcher("primitive"); // 2 triangles directly — avoids path2D + earcut overhead const right = x + width; @@ -1223,6 +1296,18 @@ export default class WebGLRenderer extends Renderer { * @param {number} radius - The rounded corner's radius. */ fillRoundRect(x, y, width, height, radius) { + if (this._currentGradient) { + this.#gradientMask( + () => { + this.fillRoundRect(x, y, width, height, radius); + }, + x, + y, + width, + height, + ); + return; + } this.setBatcher("primitive"); const r = Math.min(radius, width / 2, height / 2); const verts = []; @@ -1369,6 +1454,45 @@ export default class WebGLRenderer extends Renderer { * @returns {Array<{x: number, y: number}>} triangle vertices * @ignore */ + /** + * Draw a gradient-filled shape by masking with the shape and filling the bounding rect. + * Temporarily disables the gradient to prevent recursion in the fill methods. + * @param {Function} drawShape - draws the shape into the stencil buffer + * @param {number} x - bounding rect x + * @param {number} y - bounding rect y + * @param {number} w - bounding rect width + * @param {number} h - bounding rect height + * @ignore + */ + #gradientMask(drawShape, x, y, w, h) { + const gl = this.gl; + const grad = this._currentGradient; + this._currentGradient = null; + + this.flush(); + + // setup stencil — write shape + gl.enable(gl.STENCIL_TEST); + gl.clear(gl.STENCIL_BUFFER_BIT); + gl.colorMask(false, false, false, false); + gl.stencilFunc(gl.ALWAYS, 1, 0xff); + gl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE); + + drawShape(); + this.flush(); + + // use stencil to clip gradient + gl.colorMask(true, true, true, true); + gl.stencilFunc(gl.EQUAL, 1, 0xff); + gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); + + this._currentGradient = grad; + this.fillRect(x, y, w, h); + this.flush(); + + gl.disable(gl.STENCIL_TEST); + } + #generateTriangleFan(cx, cy, rx, ry, startAngle, endAngle, segments) { const angleStep = (endAngle - startAngle) / segments; const verts = []; diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js new file mode 100644 index 000000000..cabbe809f --- /dev/null +++ b/packages/melonjs/tests/gradient.spec.js @@ -0,0 +1,300 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, Gradient } from "../src/index.js"; + +describe("Gradient", () => { + let app; + + beforeAll(() => { + app = new Application(64, 64, { + parent: "screen", + scale: "auto", + }); + }); + + describe("createLinearGradient", () => { + it("should return a Gradient instance", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 100, 0); + expect(gradient).toBeInstanceOf(Gradient); + expect(gradient.type).toEqual("linear"); + }); + + it("should store gradient coordinates", () => { + const gradient = app.renderer.createLinearGradient(10, 20, 30, 40); + expect(gradient.coords).toEqual([10, 20, 30, 40]); + }); + + it("should support addColorStop chaining", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 100, 0); + const result = gradient + .addColorStop(0, "#FF0000") + .addColorStop(1, "#0000FF"); + expect(result).toBe(gradient); + expect(gradient.colorStops.length).toEqual(2); + }); + + it("should accept Color objects in addColorStop", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 100, 0); + gradient.addColorStop(0, "#FF0000"); + expect(gradient.colorStops[0].color).toEqual("#FF0000"); + }); + }); + + describe("createRadialGradient", () => { + it("should return a Gradient instance", () => { + const gradient = app.renderer.createRadialGradient(50, 50, 0, 50, 50, 50); + expect(gradient).toBeInstanceOf(Gradient); + expect(gradient.type).toEqual("radial"); + }); + + it("should store gradient coordinates", () => { + const gradient = app.renderer.createRadialGradient(10, 20, 5, 30, 40, 50); + expect(gradient.coords).toEqual([10, 20, 5, 30, 40, 50]); + }); + }); + + describe("setColor with gradient", () => { + it("should accept a Gradient in setColor without throwing", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + expect(() => { + app.renderer.setColor(gradient); + }).not.toThrow(); + }); + + it("should accept a regular color after a gradient", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + app.renderer.setColor(gradient); + expect(() => { + app.renderer.setColor("#FF0000"); + }).not.toThrow(); + }); + }); + + describe("fillRect with gradient", () => { + it("should not throw when filling a rect with a gradient", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "#FF0000"); + gradient.addColorStop(1, "#0000FF"); + app.renderer.setColor(gradient); + expect(() => { + app.renderer.fillRect(0, 0, 64, 64); + }).not.toThrow(); + }); + }); + + describe("mixing colors and gradients across fill calls", () => { + it("should fill with solid color after gradient", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + app.renderer.setColor(gradient); + app.renderer.fillRect(0, 0, 64, 32); + app.renderer.setColor("#00FF00"); + expect(() => { + app.renderer.fillRect(0, 32, 64, 32); + }).not.toThrow(); + expect(app.renderer.renderState.currentGradient).toBeNull(); + }); + + it("should fill with gradient after solid color", () => { + app.renderer.setColor("#FF0000"); + app.renderer.fillRect(0, 0, 64, 32); + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "green"); + gradient.addColorStop(1, "blue"); + app.renderer.setColor(gradient); + expect(() => { + app.renderer.fillRect(0, 32, 64, 32); + }).not.toThrow(); + expect(app.renderer.renderState.currentGradient).toBe(gradient); + }); + + it("should alternate between colors and gradients", () => { + const g1 = app.renderer.createLinearGradient(0, 0, 64, 0); + g1.addColorStop(0, "red"); + g1.addColorStop(1, "blue"); + const g2 = app.renderer.createRadialGradient(32, 32, 0, 32, 32, 32); + g2.addColorStop(0, "white"); + g2.addColorStop(1, "black"); + + expect(() => { + app.renderer.setColor(g1); + app.renderer.fillRect(0, 0, 64, 16); + app.renderer.setColor("#FF0000"); + app.renderer.fillRect(0, 16, 64, 16); + app.renderer.setColor(g2); + app.renderer.fillRect(0, 32, 64, 16); + app.renderer.setColor("#0000FF"); + app.renderer.fillRect(0, 48, 64, 16); + }).not.toThrow(); + expect(app.renderer.renderState.currentGradient).toBeNull(); + }); + + it("should handle save/restore when mixing colors and gradients for rendering", () => { + app.renderer.setColor("#FF0000"); + app.renderer.save(); + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "green"); + gradient.addColorStop(1, "blue"); + app.renderer.setColor(gradient); + app.renderer.fillRect(0, 0, 64, 32); + app.renderer.restore(); + // after restore, should be back to solid color + expect(app.renderer.renderState.currentGradient).toBeNull(); + expect(() => { + app.renderer.fillRect(0, 32, 64, 32); + }).not.toThrow(); + }); + }); + + describe("toCanvasGradient", () => { + it("should produce a CanvasGradient for linear type", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const canvasGradient = gradient.toCanvasGradient(ctx); + expect(canvasGradient).toBeInstanceOf(CanvasGradient); + }); + + it("should produce a CanvasGradient for radial type", () => { + const gradient = new Gradient("radial", [50, 50, 0, 50, 50, 50]); + gradient.addColorStop(0, "white"); + gradient.addColorStop(1, "black"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const canvasGradient = gradient.toCanvasGradient(ctx); + expect(canvasGradient).toBeInstanceOf(CanvasGradient); + }); + + it("should cache the CanvasGradient", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const first = gradient.toCanvasGradient(ctx); + const second = gradient.toCanvasGradient(ctx); + expect(first).toBe(second); + }); + + it("should invalidate cache after addColorStop", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + gradient.addColorStop(0, "red"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const first = gradient.toCanvasGradient(ctx); + gradient.addColorStop(1, "blue"); + const second = gradient.toCanvasGradient(ctx); + expect(first).not.toBe(second); + }); + }); + + describe("save/restore with gradient", () => { + it("should track gradient in renderState when set", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + + app.renderer.setColor(gradient); + expect(app.renderer.renderState.currentGradient).toBe(gradient); + }); + + it("should clear gradient in renderState when color is set", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + + app.renderer.setColor(gradient); + app.renderer.setColor("#00FF00"); + expect(app.renderer.renderState.currentGradient).toBeNull(); + }); + + it("should restore gradient after save(gradient) / setColor(solid) / restore", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + + app.renderer.setColor(gradient); + app.renderer.save(); + app.renderer.setColor("#00FF00"); + expect(app.renderer.renderState.currentGradient).toBeNull(); + app.renderer.restore(); + expect(app.renderer.renderState.currentGradient).toBe(gradient); + }); + + it("should restore solid color after save(solid) / setColor(gradient) / restore", () => { + app.renderer.setColor("#FF0000"); + app.renderer.save(); + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "green"); + gradient.addColorStop(1, "blue"); + app.renderer.setColor(gradient); + expect(app.renderer.renderState.currentGradient).toBe(gradient); + app.renderer.restore(); + expect(app.renderer.renderState.currentGradient).toBeNull(); + }); + + it("should handle nested save/restore with mixed colors and gradients", () => { + const gradient1 = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient1.addColorStop(0, "red"); + gradient1.addColorStop(1, "blue"); + const gradient2 = app.renderer.createRadialGradient( + 32, + 32, + 0, + 32, + 32, + 32, + ); + gradient2.addColorStop(0, "white"); + gradient2.addColorStop(1, "black"); + + // level 0: solid color + app.renderer.setColor("#FF0000"); + app.renderer.save(); + + // level 1: gradient1 + app.renderer.setColor(gradient1); + app.renderer.save(); + + // level 2: gradient2 + app.renderer.setColor(gradient2); + expect(app.renderer.renderState.currentGradient).toBe(gradient2); + + // restore to level 1: gradient1 + app.renderer.restore(); + expect(app.renderer.renderState.currentGradient).toBe(gradient1); + + // restore to level 0: solid color + app.renderer.restore(); + expect(app.renderer.renderState.currentGradient).toBeNull(); + }); + }); + + describe("toCanvas (texture)", () => { + it("should produce a canvas element matching the draw rect", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + const canvas = gradient.toCanvas(0, 0, 100, 50); + expect(canvas).toBeDefined(); + // dimensions are next power of two + expect(canvas.width).toEqual(128); + expect(canvas.height).toEqual(64); + }); + + it("should cache the canvas for same dimensions", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + const first = gradient.toCanvas(0, 0, 100, 50); + const second = gradient.toCanvas(0, 0, 100, 50); + expect(first).toBe(second); + }); + }); +}); From 597327f86dbd47f854311a0b4ee5dea318afcc37 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 4 Apr 2026 18:27:24 +0800 Subject: [PATCH 02/10] Address Copilot review: fix gradient alpha, restore sync, stencil nesting, cache example - WebGL setColor: preserve existing alpha instead of forcing 1.0 - WebGL restore: sync _currentGradient from renderState - #gradientMask: nest within existing mask levels instead of clearing stencil - Example: cache gradients in onActivateEvent, reuse across frames Co-Authored-By: Claude Opus 4.6 (1M context) --- .../examples/gradients/ExampleGradients.tsx | 263 ++++++++++-------- .../melonjs/src/video/webgl/webgl_renderer.js | 39 ++- 2 files changed, 178 insertions(+), 124 deletions(-) diff --git a/packages/examples/src/examples/gradients/ExampleGradients.tsx b/packages/examples/src/examples/gradients/ExampleGradients.tsx index ab530cdf1..b58cf014f 100644 --- a/packages/examples/src/examples/gradients/ExampleGradients.tsx +++ b/packages/examples/src/examples/gradients/ExampleGradients.tsx @@ -2,6 +2,7 @@ import { Application as App, type Application, type CanvasRenderer, + type Gradient, Polygon, Renderable, Stage, @@ -14,50 +15,58 @@ type Renderer = CanvasRenderer | WebGLRenderer; /** * A renderable that showcases linear and radial gradients. + * Gradients are created once and reused across frames. */ class GradientShowcase extends Renderable { + private sky!: Gradient; + private ground!: Gradient; + private sun!: Gradient; + private glow!: Gradient; + private health!: Gradient; + private mana!: Gradient; + private btn!: Gradient; + private circleGrad!: Gradient; + private pillGrad!: Gradient; + private rainbow!: Gradient; + private vig!: Gradient; + private star!: Polygon; + constructor() { super(0, 0, 1024, 768); this.anchorPoint.set(0, 0); - this.alwaysUpdate = true; } - draw(renderer: Renderer) { + onActivateEvent() { const w = this.width; const h = this.height; - - // ---- Sky background (linear gradient, top to bottom) ---- - const sky = renderer.createLinearGradient(0, 0, 0, h * 0.6); - sky.addColorStop(0, "#0B0B3B"); - sky.addColorStop(0.4, "#1a1a6e"); - sky.addColorStop(0.7, "#4a2080"); - sky.addColorStop(1, "#FF6B35"); - renderer.setColor(sky); - renderer.fillRect(0, 0, w, h * 0.6); - - // ---- Ground (linear gradient) ---- - const ground = renderer.createLinearGradient(0, h * 0.6, 0, h); - ground.addColorStop(0, "#2d5016"); - ground.addColorStop(0.5, "#1a3a0a"); - ground.addColorStop(1, "#0d1f05"); - renderer.setColor(ground); - renderer.fillRect(0, h * 0.6, w, h * 0.4); - - // ---- Sun (radial gradient) ---- + const renderer = this.parentApp.renderer; + + // sky + this.sky = renderer.createLinearGradient(0, 0, 0, h * 0.6); + this.sky.addColorStop(0, "#0B0B3B"); + this.sky.addColorStop(0.4, "#1a1a6e"); + this.sky.addColorStop(0.7, "#4a2080"); + this.sky.addColorStop(1, "#FF6B35"); + + // ground + this.ground = renderer.createLinearGradient(0, h * 0.6, 0, h); + this.ground.addColorStop(0, "#2d5016"); + this.ground.addColorStop(0.5, "#1a3a0a"); + this.ground.addColorStop(1, "#0d1f05"); + + // sun const sunX = w * 0.75; const sunY = h * 0.35; const sunR = 60; - const sun = renderer.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR); - sun.addColorStop(0, "#FFFFFF"); - sun.addColorStop(0.3, "#FFEE88"); - sun.addColorStop(0.7, "#FFAA33"); - sun.addColorStop(1, "rgba(255, 100, 0, 0)"); - renderer.setColor(sun); - renderer.fillRect(sunX - sunR, sunY - sunR, sunR * 2, sunR * 2); + this.sun = renderer.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR); + this.sun.addColorStop(0, "#FFFFFF"); + this.sun.addColorStop(0.3, "#FFEE88"); + this.sun.addColorStop(0.7, "#FFAA33"); + this.sun.addColorStop(1, "rgba(255, 100, 0, 0)"); - // ---- Sun glow (larger radial gradient) ---- + // sun glow const glowR = 150; - const glow = renderer.createRadialGradient( + this.glow = renderer.createRadialGradient( sunX, sunY, sunR * 0.5, @@ -65,75 +74,43 @@ class GradientShowcase extends Renderable { sunY, glowR, ); - glow.addColorStop(0, "rgba(255, 200, 100, 0.3)"); - glow.addColorStop(1, "rgba(255, 100, 0, 0)"); - renderer.setColor(glow); - renderer.fillRect(sunX - glowR, sunY - glowR, glowR * 2, glowR * 2); + this.glow.addColorStop(0, "rgba(255, 200, 100, 0.3)"); + this.glow.addColorStop(1, "rgba(255, 100, 0, 0)"); - // ---- Health bar background ---- + // health bar const barX = 30; - const barY = 30; const barW = 200; - const barH = 24; - renderer.setColor("#333333"); - renderer.fillRect(barX - 2, barY - 2, barW + 4, barH + 4); - - // ---- Health bar fill (linear gradient, green to yellow to red) ---- - const healthPct = 0.7; - const health = renderer.createLinearGradient(barX, 0, barX + barW, 0); - health.addColorStop(0, "#00FF00"); - health.addColorStop(0.5, "#FFFF00"); - health.addColorStop(1, "#FF0000"); - renderer.setColor(health); - renderer.fillRect(barX, barY, barW * healthPct, barH); - - // ---- Mana bar (blue gradient) ---- - const manaY = barY + barH + 10; - renderer.setColor("#333333"); - renderer.fillRect(barX - 2, manaY - 2, barW + 4, barH + 4); - const mana = renderer.createLinearGradient(barX, 0, barX + barW, 0); - mana.addColorStop(0, "#0044FF"); - mana.addColorStop(0.5, "#00BBFF"); - mana.addColorStop(1, "#00FFFF"); - renderer.setColor(mana); - renderer.fillRect(barX, manaY, barW * 0.5, barH); - - // ---- Metallic button (vertical linear gradient) ---- - const btnX = 30; + this.health = renderer.createLinearGradient(barX, 0, barX + barW, 0); + this.health.addColorStop(0, "#00FF00"); + this.health.addColorStop(0.5, "#FFFF00"); + this.health.addColorStop(1, "#FF0000"); + + // mana bar + this.mana = renderer.createLinearGradient(barX, 0, barX + barW, 0); + this.mana.addColorStop(0, "#0044FF"); + this.mana.addColorStop(0.5, "#00BBFF"); + this.mana.addColorStop(1, "#00FFFF"); + + // metallic button const btnY = 110; - const btnW = 160; const btnH = 40; - const btn = renderer.createLinearGradient(0, btnY, 0, btnY + btnH); - btn.addColorStop(0, "#EEEEEE"); - btn.addColorStop(0.5, "#AAAAAA"); - btn.addColorStop(0.51, "#888888"); - btn.addColorStop(1, "#CCCCCC"); - renderer.setColor(btn); - renderer.fillRect(btnX, btnY, btnW, btnH); - - // button border - renderer.setColor("#666666"); - renderer.strokeRect(btnX, btnY, btnW, btnH); - - // ---- Gradient on shapes ---- - - // gradient-filled circle - const circleGrad = renderer.createRadialGradient(65, 190, 0, 65, 190, 25); - circleGrad.addColorStop(0, "#FF6600"); - circleGrad.addColorStop(1, "#CC0000"); - renderer.setColor(circleGrad); - renderer.fillEllipse(65, 190, 25, 25); - - // gradient-filled rounded rect (pill) - const pillGrad = renderer.createLinearGradient(110, 0, 270, 0); - pillGrad.addColorStop(0, "#00CC88"); - pillGrad.addColorStop(1, "#0066FF"); - renderer.setColor(pillGrad); - renderer.fillRoundRect(110, 175, 160, 30, 15); - - // ---- Rainbow star (gradient on polygon) ---- - const starCx = 330; - const starCy = 190; + this.btn = renderer.createLinearGradient(0, btnY, 0, btnY + btnH); + this.btn.addColorStop(0, "#EEEEEE"); + this.btn.addColorStop(0.5, "#AAAAAA"); + this.btn.addColorStop(0.51, "#888888"); + this.btn.addColorStop(1, "#CCCCCC"); + + // circle + this.circleGrad = renderer.createRadialGradient(65, 190, 0, 65, 190, 25); + this.circleGrad.addColorStop(0, "#FF6600"); + this.circleGrad.addColorStop(1, "#CC0000"); + + // pill + this.pillGrad = renderer.createLinearGradient(110, 0, 270, 0); + this.pillGrad.addColorStop(0, "#00CC88"); + this.pillGrad.addColorStop(1, "#0066FF"); + + // rainbow star const outerR = 45; const innerR = 20; const points = 5; @@ -141,26 +118,20 @@ class GradientShowcase extends Renderable { for (let i = 0; i < points * 2; i++) { const angle = (i * Math.PI) / points - Math.PI / 2; const r = i % 2 === 0 ? outerR : innerR; - starVerts.push({ - x: Math.cos(angle) * r, - y: Math.sin(angle) * r, - }); + starVerts.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r }); } - // gradient coords are relative to the polygon's pos since - // fillPolygon translates the context by poly.pos - const rainbow = renderer.createRadialGradient(0, 0, 0, 0, 0, outerR); - rainbow.addColorStop(0, "#FF0000"); - rainbow.addColorStop(0.2, "#FF8800"); - rainbow.addColorStop(0.4, "#FFFF00"); - rainbow.addColorStop(0.6, "#00FF00"); - rainbow.addColorStop(0.8, "#0088FF"); - rainbow.addColorStop(1, "#8800FF"); - renderer.setColor(rainbow); - renderer.fill(new Polygon(starCx, starCy, starVerts)); - - // ---- Spotlight / vignette effect (large radial gradient) ---- + this.star = new Polygon(330, 190, starVerts); + this.rainbow = renderer.createRadialGradient(0, 0, 0, 0, 0, outerR); + this.rainbow.addColorStop(0, "#FF0000"); + this.rainbow.addColorStop(0.2, "#FF8800"); + this.rainbow.addColorStop(0.4, "#FFFF00"); + this.rainbow.addColorStop(0.6, "#00FF00"); + this.rainbow.addColorStop(0.8, "#0088FF"); + this.rainbow.addColorStop(1, "#8800FF"); + + // vignette const vigR = Math.max(w, h) * 0.7; - const vig = renderer.createRadialGradient( + this.vig = renderer.createRadialGradient( w / 2, h / 2, vigR * 0.3, @@ -168,12 +139,72 @@ class GradientShowcase extends Renderable { h / 2, vigR, ); - vig.addColorStop(0, "rgba(0, 0, 0, 0)"); - vig.addColorStop(1, "rgba(0, 0, 0, 0.5)"); - renderer.setColor(vig); + this.vig.addColorStop(0, "rgba(0, 0, 0, 0)"); + this.vig.addColorStop(1, "rgba(0, 0, 0, 0.5)"); + } + + draw(renderer: Renderer) { + const w = this.width; + const h = this.height; + const sunX = w * 0.75; + const sunY = h * 0.35; + const sunR = 60; + const glowR = 150; + const barX = 30; + const barY = 30; + const barW = 200; + const barH = 24; + const healthPct = 0.7; + const manaY = barY + barH + 10; + + // sky + renderer.setColor(this.sky); + renderer.fillRect(0, 0, w, h * 0.6); + + // ground + renderer.setColor(this.ground); + renderer.fillRect(0, h * 0.6, w, h * 0.4); + + // sun + renderer.setColor(this.sun); + renderer.fillRect(sunX - sunR, sunY - sunR, sunR * 2, sunR * 2); + + // sun glow + renderer.setColor(this.glow); + renderer.fillRect(sunX - glowR, sunY - glowR, glowR * 2, glowR * 2); + + // health bar + renderer.setColor("#333333"); + renderer.fillRect(barX - 2, barY - 2, barW + 4, barH + 4); + renderer.setColor(this.health); + renderer.fillRect(barX, barY, barW * healthPct, barH); + + // mana bar + renderer.setColor("#333333"); + renderer.fillRect(barX - 2, manaY - 2, barW + 4, barH + 4); + renderer.setColor(this.mana); + renderer.fillRect(barX, manaY, barW * 0.5, barH); + + // metallic button + renderer.setColor(this.btn); + renderer.fillRect(30, 110, 160, 40); + renderer.setColor("#666666"); + renderer.strokeRect(30, 110, 160, 40); + + // gradient shapes + renderer.setColor(this.circleGrad); + renderer.fillEllipse(65, 190, 25, 25); + + renderer.setColor(this.pillGrad); + renderer.fillRoundRect(110, 175, 160, 30, 15); + + renderer.setColor(this.rainbow); + renderer.fill(this.star); + + // vignette + renderer.setColor(this.vig); renderer.fillRect(0, 0, w, h); - // ---- Labels ---- renderer.setColor("#FFFFFF"); } } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 9fd4b7d87..70bfc6937 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -849,6 +849,8 @@ export default class WebGLRenderer extends Renderer { this.gl.disable(this.gl.SCISSOR_TEST); } } + // sync gradient from renderState + this._currentGradient = this.renderState.currentGradient; } /** @@ -946,8 +948,6 @@ export default class WebGLRenderer extends Renderer { if (color instanceof Gradient) { this.renderState.currentGradient = color; this._currentGradient = color; - // ensure full opacity for gradient texture rendering - this.currentColor.alpha = 1.0; } else { this.renderState.currentGradient = null; this._currentGradient = null; @@ -1467,30 +1467,53 @@ export default class WebGLRenderer extends Renderer { #gradientMask(drawShape, x, y, w, h) { const gl = this.gl; const grad = this._currentGradient; + const hasMask = this.maskLevel > 0; + const stencilRef = hasMask ? this.maskLevel + 1 : 1; this._currentGradient = null; this.flush(); - // setup stencil — write shape gl.enable(gl.STENCIL_TEST); - gl.clear(gl.STENCIL_BUFFER_BIT); gl.colorMask(false, false, false, false); - gl.stencilFunc(gl.ALWAYS, 1, 0xff); - gl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE); + + if (hasMask) { + // nest within existing mask level + gl.stencilFunc(gl.EQUAL, this.maskLevel, 0xff); + gl.stencilOp(gl.KEEP, gl.KEEP, gl.INCR); + } else { + gl.clear(gl.STENCIL_BUFFER_BIT); + gl.stencilFunc(gl.ALWAYS, 1, 0xff); + gl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE); + } drawShape(); this.flush(); // use stencil to clip gradient gl.colorMask(true, true, true, true); - gl.stencilFunc(gl.EQUAL, 1, 0xff); + gl.stencilFunc(gl.EQUAL, stencilRef, 0xff); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); this._currentGradient = grad; this.fillRect(x, y, w, h); this.flush(); - gl.disable(gl.STENCIL_TEST); + if (hasMask) { + // restore the parent mask level by decrementing stencil + this._currentGradient = null; + gl.colorMask(false, false, false, false); + gl.stencilFunc(gl.EQUAL, stencilRef, 0xff); + gl.stencilOp(gl.KEEP, gl.KEEP, gl.DECR); + drawShape(); + this.flush(); + gl.colorMask(true, true, true, true); + // restore parent mask stencil test + gl.stencilFunc(gl.NOTEQUAL, this.maskLevel + 1, 1); + gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); + this._currentGradient = grad; + } else { + gl.disable(gl.STENCIL_TEST); + } } #generateTriangleFan(cx, cy, rx, ry, startAngle, endAngle, segments) { From 91c15f61e558b63f1071bc6d3a430cf9378b94f8 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 4 Apr 2026 18:37:53 +0800 Subject: [PATCH 03/10] Address Copilot review: JSDoc import, offset validation, orphaned docs, test name - RenderState: add @import for Gradient type - Gradient.addColorStop: validate offset is 0.0-1.0 - WebGL renderer: fix orphaned #generateTriangleFan JSDoc block - Test: rename misleading "Color objects" test to "store color stop as string" Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/gradient.js | 3 +++ packages/melonjs/src/video/renderstate.js | 4 ++++ .../melonjs/src/video/webgl/webgl_renderer.js | 24 +++++++++---------- packages/melonjs/tests/gradient.spec.js | 2 +- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js index cfb98dfb3..9b92d04e7 100644 --- a/packages/melonjs/src/video/gradient.js +++ b/packages/melonjs/src/video/gradient.js @@ -70,6 +70,9 @@ export class Gradient { * gradient.addColorStop(1, "blue"); */ addColorStop(offset, color) { + if (offset < 0.0 || offset > 1.0) { + throw new Error("offset must be between 0.0 and 1.0"); + } this.colorStops.push({ offset, color: typeof color === "string" ? color : color.toRGBA(), diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js index 709d877f9..d03bc2647 100644 --- a/packages/melonjs/src/video/renderstate.js +++ b/packages/melonjs/src/video/renderstate.js @@ -1,6 +1,10 @@ import { Color } from "./../math/color.ts"; import { Matrix2d } from "../math/matrix2d.ts"; +/** + * @import {Gradient} from "./gradient.js"; + */ + /** * Renderer-agnostic state container with a pre-allocated save/restore stack. * diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 70bfc6937..57adae28c 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1442,18 +1442,6 @@ export default class WebGLRenderer extends Renderer { this.currentBatcher.drawVertices(this.gl.TRIANGLES, verts); } - /** - * Generate triangle fan vertices for an elliptical arc. - * @param {number} cx - center x - * @param {number} cy - center y - * @param {number} rx - horizontal radius - * @param {number} ry - vertical radius - * @param {number} startAngle - start angle in radians - * @param {number} endAngle - end angle in radians - * @param {number} segments - number of segments - * @returns {Array<{x: number, y: number}>} triangle vertices - * @ignore - */ /** * Draw a gradient-filled shape by masking with the shape and filling the bounding rect. * Temporarily disables the gradient to prevent recursion in the fill methods. @@ -1516,6 +1504,18 @@ export default class WebGLRenderer extends Renderer { } } + /** + * Generate triangle fan vertices for an elliptical arc. + * @param {number} cx - center x + * @param {number} cy - center y + * @param {number} rx - horizontal radius + * @param {number} ry - vertical radius + * @param {number} startAngle - start angle in radians + * @param {number} endAngle - end angle in radians + * @param {number} segments - number of segments + * @returns {Array<{x: number, y: number}>} triangle vertices + * @ignore + */ #generateTriangleFan(cx, cy, rx, ry, startAngle, endAngle, segments) { const angleStep = (endAngle - startAngle) / segments; const verts = []; diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js index cabbe809f..cd61577ae 100644 --- a/packages/melonjs/tests/gradient.spec.js +++ b/packages/melonjs/tests/gradient.spec.js @@ -32,7 +32,7 @@ describe("Gradient", () => { expect(gradient.colorStops.length).toEqual(2); }); - it("should accept Color objects in addColorStop", () => { + it("should store color stop as string", () => { const gradient = app.renderer.createLinearGradient(0, 0, 100, 0); gradient.addColorStop(0, "#FF0000"); expect(gradient.colorStops[0].color).toEqual("#FF0000"); From e90be77da7fdfae7bf4c96d097a7cdf3feedffdc Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 4 Apr 2026 18:39:01 +0800 Subject: [PATCH 04/10] Add edge case tests: offset validation, no stops, single stop, reuse, cache invalidation Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/tests/gradient.spec.js | 69 +++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js index cd61577ae..e5ddabdc6 100644 --- a/packages/melonjs/tests/gradient.spec.js +++ b/packages/melonjs/tests/gradient.spec.js @@ -296,5 +296,74 @@ describe("Gradient", () => { const second = gradient.toCanvas(0, 0, 100, 50); expect(first).toBe(second); }); + + it("should invalidate cache when position changes", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + const first = gradient.toCanvas(0, 0, 100, 50); + const second = gradient.toCanvas(10, 10, 100, 50); + // same canvas object reused, but re-rendered + expect(first).toBe(second); + }); + }); + + describe("addColorStop validation", () => { + it("should throw for offset less than 0", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + expect(() => { + gradient.addColorStop(-0.1, "red"); + }).toThrow(); + }); + + it("should throw for offset greater than 1", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + expect(() => { + gradient.addColorStop(1.1, "red"); + }).toThrow(); + }); + + it("should accept offset 0 and 1", () => { + const gradient = new Gradient("linear", [0, 0, 100, 0]); + expect(() => { + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + }).not.toThrow(); + }); + }); + + describe("gradient with no color stops", () => { + it("should not throw when creating gradient with no stops", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 100, 0); + expect(() => { + app.renderer.setColor(gradient); + }).not.toThrow(); + }); + }); + + describe("gradient reuse across multiple setColor calls", () => { + it("should allow the same gradient to be set multiple times", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0, "red"); + gradient.addColorStop(1, "blue"); + expect(() => { + app.renderer.setColor(gradient); + app.renderer.fillRect(0, 0, 32, 32); + app.renderer.setColor("#000000"); + app.renderer.setColor(gradient); + app.renderer.fillRect(32, 0, 32, 32); + }).not.toThrow(); + }); + }); + + describe("gradient with single color stop", () => { + it("should not throw with a single color stop", () => { + const gradient = app.renderer.createLinearGradient(0, 0, 64, 0); + gradient.addColorStop(0.5, "red"); + expect(() => { + app.renderer.setColor(gradient); + app.renderer.fillRect(0, 0, 64, 64); + }).not.toThrow(); + }); }); }); From 0487aacba8772cdc29cb3cf9596a509cf4c912c1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 5 Apr 2026 06:44:40 +0800 Subject: [PATCH 05/10] Add dashed line support (setLineDash / getLineDash) Closes #1351 - setLineDash() and getLineDash() on base Renderer, matching Canvas 2D API - Canvas renderer: pass-through to native ctx.setLineDash() - WebGL renderer: #dashSegments splits line segments based on dash pattern - Dash applied to strokeLine, path-based stroke, and strokePolygon - Dash state saved/restored via RenderState - Graphics example: second zigzag line now uses dashed pattern - 9 unit tests covering API, stroke, paths, and save/restore Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/examples/graphics/ExampleGraphics.tsx | 6 + packages/melonjs/CHANGELOG.md | 1 + .../src/video/canvas/canvas_renderer.js | 17 +++ packages/melonjs/src/video/renderer.js | 23 ++++ packages/melonjs/src/video/renderstate.js | 26 +++++ .../melonjs/src/video/webgl/webgl_renderer.js | 107 +++++++++++++++++- packages/melonjs/tests/linedash.spec.js | 89 +++++++++++++++ 7 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 packages/melonjs/tests/linedash.spec.js diff --git a/packages/examples/src/examples/graphics/ExampleGraphics.tsx b/packages/examples/src/examples/graphics/ExampleGraphics.tsx index 89eeaac4a..6a7803458 100644 --- a/packages/examples/src/examples/graphics/ExampleGraphics.tsx +++ b/packages/examples/src/examples/graphics/ExampleGraphics.tsx @@ -205,12 +205,18 @@ const createGame = () => { renderer.lineTo(840, 55); renderer.lineTo(940, 30); + renderer.stroke(); + + // dashed zigzag line + renderer.setLineDash([10, 6]); + renderer.beginPath(); renderer.moveTo(540, 50); renderer.lineTo(640, 75); renderer.lineTo(740, 50); renderer.lineTo(840, 75); renderer.lineTo(940, 50); renderer.stroke(); + renderer.setLineDash([]); renderer.setColor("#ff69b4"); renderer.fill(this.roundRect1); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index d1a5e7c00..36dfc0ef1 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [18.3.0] (melonJS 2) ### Added +- Renderer: `setLineDash()` and `getLineDash()` methods — set dash patterns for stroke operations, matching the Canvas 2D API. Works on both Canvas and WebGL renderers. Dash state is saved/restored with `save()`/`restore()`. - Renderer: `createLinearGradient()` and `createRadialGradient()` methods — create gradient fills that can be passed to `setColor()`, matching the Canvas 2D API. Works on both Canvas and WebGL renderers with all fill methods (`fillRect`, `fillEllipse`, `fillArc`, `fillPolygon`, `fillRoundRect`). Gradient state is saved/restored with `save()`/`restore()`. - Tiled: extensible object factory registry for `TMXTileMap.getObjects()` — object creation is now dispatched through a `Map`-based registry (like `loader.setParser`), with built-in factories for text, tile, and shape objects, plus class-based factories for Entity, Collectable, Trigger, Light2d, Sprite, NineSliceSprite, ImageLayer, and ColorLayer - Tiled: new public `registerTiledObjectFactory(type, factory)` and `registerTiledObjectClass(name, Constructor)` APIs allowing plugins to register custom Tiled object handlers by class name without modifying engine code diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index f8fd30b68..c0a21e038 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -501,6 +501,23 @@ export default class CanvasRenderer extends Renderer { * @param {number} endX - the end x coordinate * @param {number} endY - the end y coordinate */ + /** + * Set the line dash pattern. + * @param {number[]} segments - dash pattern + */ + setLineDash(segments) { + super.setLineDash(segments); + this.getContext().setLineDash(segments); + } + + /** + * Get the current line dash pattern. + * @returns {number[]} dash pattern + */ + getLineDash() { + return this.getContext().getLineDash(); + } + strokeLine(startX, startY, endX, endY) { if (this.getGlobalAlpha() < 1 / 255) { // Fast path: don't draw fully transparent diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 18300fbf4..37cb09397 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -301,6 +301,29 @@ export default class Renderer { return new Gradient("radial", [x0, y0, r0, x1, y1, r1]); } + /** + * Set the line dash pattern for stroke operations. + * @param {number[]} segments - an array of numbers specifying distances to alternately draw a line and a gap. An empty array clears the dash pattern (solid lines). + * @example + * // draw a dashed line + * renderer.setLineDash([10, 5]); + * renderer.strokeLine(0, 0, 100, 0); + * // clear the dash pattern + * renderer.setLineDash([]); + */ + setLineDash(segments) { + this.renderState.lineDash = segments; + this.renderState.lineDashOffset = 0; + } + + /** + * Get the current line dash pattern. + * @returns {number[]} the current dash pattern + */ + getLineDash() { + return this.renderState.lineDash; + } + /** * return the current global alpha * @returns {number} diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js index d03bc2647..a7c95959f 100644 --- a/packages/melonjs/src/video/renderstate.js +++ b/packages/melonjs/src/video/renderstate.js @@ -45,6 +45,18 @@ export default class RenderState { */ this.currentGradient = null; + /** + * current line dash pattern (empty array = solid line) + * @type {number[]} + */ + this.lineDash = []; + + /** + * current line dash offset + * @type {number} + */ + this.lineDashOffset = 0; + /** * current blend mode * @type {string} @@ -84,6 +96,12 @@ export default class RenderState { return new Int32Array(4); }); + /** @ignore */ + this._lineDashStack = new Array(this._stackCapacity); + + /** @ignore */ + this._lineDashOffsetStack = new Float64Array(this._stackCapacity); + /** @ignore */ this._scissorActive = new Uint8Array(this._stackCapacity); @@ -109,6 +127,8 @@ export default class RenderState { this._tintStack[depth].copy(this.currentTint); this._matrixStack[depth].copy(this.currentTransform); this._gradientStack[depth] = this.currentGradient; + this._lineDashStack[depth] = this.lineDash.slice(); + this._lineDashOffsetStack[depth] = this.lineDashOffset; this._blendStack[depth] = this.currentBlendMode; if (scissorTestActive) { @@ -138,6 +158,8 @@ export default class RenderState { this.currentTint.copy(this._tintStack[depth]); this.currentTransform.copy(this._matrixStack[depth]); this.currentGradient = this._gradientStack[depth]; + this.lineDash = this._lineDashStack[depth]; + this.lineDashOffset = this._lineDashOffsetStack[depth]; const scissorActive = !!this._scissorActive[depth]; if (scissorActive) { @@ -178,11 +200,15 @@ export default class RenderState { this._matrixStack.push(new Matrix2d()); this._scissorStack.push(new Int32Array(4)); this._gradientStack.push(null); + this._lineDashStack.push([]); this._blendStack.push(undefined); } const newScissorActive = new Uint8Array(newCap); newScissorActive.set(this._scissorActive); this._scissorActive = newScissorActive; + const newLineDashOffset = new Float64Array(newCap); + newLineDashOffset.set(this._lineDashOffsetStack); + this._lineDashOffsetStack = newLineDashOffset; this._stackCapacity = newCap; } } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 57adae28c..86a8ffa63 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -697,7 +697,27 @@ export default class WebGLRenderer extends Renderer { this.path2D.triangulatePath(), ); } else { - this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points); + const dash = this.renderState.lineDash; + if (dash.length > 0) { + const pts = this.path2D.points; + const dashed = []; + for (let i = 0; i < pts.length - 1; i += 2) { + dashed.push( + ...this.#dashSegments( + pts[i].x, + pts[i].y, + pts[i + 1].x, + pts[i + 1].y, + dash, + ), + ); + } + if (dashed.length > 0) { + this.currentBatcher.drawVertices(this.gl.LINES, dashed); + } + } else { + this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points); + } } } else { // dispatches to strokeRect/strokePolygon/etc. which each call setBatcher @@ -1083,10 +1103,18 @@ export default class WebGLRenderer extends Renderer { */ strokeLine(startX, startY, endX, endY) { this.setBatcher("primitive"); - this.path2D.beginPath(); - this.path2D.moveTo(startX, startY); - this.path2D.lineTo(endX, endY); - this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points); + const dash = this.renderState.lineDash; + if (dash.length > 0) { + const segments = this.#dashSegments(startX, startY, endX, endY, dash); + if (segments.length > 0) { + this.currentBatcher.drawVertices(this.gl.LINES, segments); + } + } else { + this.path2D.beginPath(); + this.path2D.moveTo(startX, startY); + this.path2D.lineTo(endX, endY); + this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points); + } } /** @@ -1124,7 +1152,27 @@ export default class WebGLRenderer extends Renderer { this.path2D.lineTo(nextPoint.x, nextPoint.y); } this.path2D.closePath(); - this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points); + const dash = this.renderState.lineDash; + if (dash.length > 0) { + const pts = this.path2D.points; + const dashed = []; + for (let i = 0; i < pts.length - 1; i += 2) { + dashed.push( + ...this.#dashSegments( + pts[i].x, + pts[i].y, + pts[i + 1].x, + pts[i + 1].y, + dash, + ), + ); + } + if (dashed.length > 0) { + this.currentBatcher.drawVertices(this.gl.LINES, dashed); + } + } else { + this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points); + } // add round joins at vertices for thick lines if (this.lineWidth > 1) { const radius = this.lineWidth / 2; @@ -1452,6 +1500,53 @@ export default class WebGLRenderer extends Renderer { * @param {number} h - bounding rect height * @ignore */ + /** + * Split a line segment into dashed sub-segments. + * @param {number} x0 - start x + * @param {number} y0 - start y + * @param {number} x1 - end x + * @param {number} y1 - end y + * @param {number[]} pattern - dash pattern [on, off, on, off, ...] + * @returns {Array<{x: number, y: number}>} pairs of start/end points for visible segments + * @ignore + */ + #dashSegments(x0, y0, x1, y1, pattern) { + const dx = x1 - x0; + const dy = y1 - y0; + const lineLen = Math.sqrt(dx * dx + dy * dy); + if (lineLen === 0 || pattern.length === 0) { + return [ + { x: x0, y: y0 }, + { x: x1, y: y1 }, + ]; + } + + const nx = dx / lineLen; + const ny = dy / lineLen; + const segments = []; + let dist = 0; + let patIdx = 0; + let drawing = true; // start with "on" + + while (dist < lineLen) { + const dashLen = pattern[patIdx % pattern.length]; + const segEnd = Math.min(dist + dashLen, lineLen); + + if (drawing) { + segments.push( + { x: x0 + nx * dist, y: y0 + ny * dist }, + { x: x0 + nx * segEnd, y: y0 + ny * segEnd }, + ); + } + + dist = segEnd; + drawing = !drawing; + patIdx++; + } + + return segments; + } + #gradientMask(drawShape, x, y, w, h) { const gl = this.gl; const grad = this._currentGradient; diff --git a/packages/melonjs/tests/linedash.spec.js b/packages/melonjs/tests/linedash.spec.js new file mode 100644 index 000000000..ea91ea506 --- /dev/null +++ b/packages/melonjs/tests/linedash.spec.js @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application } from "../src/index.js"; + +describe("LineDash", () => { + let app; + + beforeAll(() => { + app = new Application(64, 64, { + parent: "screen", + scale: "auto", + }); + }); + + describe("setLineDash / getLineDash", () => { + it("should default to an empty array (solid line)", () => { + expect(app.renderer.getLineDash()).toEqual([]); + }); + + it("should set and get a dash pattern", () => { + app.renderer.setLineDash([10, 5]); + expect(app.renderer.getLineDash()).toEqual([10, 5]); + }); + + it("should clear the dash pattern with an empty array", () => { + app.renderer.setLineDash([10, 5]); + app.renderer.setLineDash([]); + expect(app.renderer.getLineDash()).toEqual([]); + }); + + it("should accept a complex dash pattern", () => { + app.renderer.setLineDash([5, 3, 10, 3]); + expect(app.renderer.getLineDash()).toEqual([5, 3, 10, 3]); + app.renderer.setLineDash([]); + }); + }); + + describe("strokeLine with dash", () => { + it("should not throw when drawing a dashed line", () => { + app.renderer.setLineDash([10, 5]); + expect(() => { + app.renderer.strokeLine(0, 0, 64, 64); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should not throw when drawing a solid line after dashed", () => { + app.renderer.setLineDash([10, 5]); + app.renderer.strokeLine(0, 0, 64, 0); + app.renderer.setLineDash([]); + expect(() => { + app.renderer.strokeLine(0, 32, 64, 32); + }).not.toThrow(); + }); + }); + + describe("path-based stroke with dash", () => { + it("should not throw when stroking a path with dash", () => { + app.renderer.setLineDash([8, 4]); + app.renderer.beginPath(); + app.renderer.moveTo(0, 0); + app.renderer.lineTo(32, 32); + app.renderer.lineTo(64, 0); + expect(() => { + app.renderer.stroke(); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + }); + + describe("save/restore with dash", () => { + it("should restore dash pattern after save/restore", () => { + app.renderer.setLineDash([10, 5]); + app.renderer.save(); + app.renderer.setLineDash([3, 3]); + expect(app.renderer.getLineDash()).toEqual([3, 3]); + app.renderer.restore(); + expect(app.renderer.getLineDash()).toEqual([10, 5]); + app.renderer.setLineDash([]); + }); + + it("should restore solid line after save(solid) / setLineDash / restore", () => { + app.renderer.setLineDash([]); + app.renderer.save(); + app.renderer.setLineDash([10, 5]); + app.renderer.restore(); + expect(app.renderer.getLineDash()).toEqual([]); + }); + }); +}); From 49121d714c1c6d428a3295bc5eda4fbff698b249 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 5 Apr 2026 06:49:46 +0800 Subject: [PATCH 06/10] Fix gradient texture reupload, JSDoc, and addColorStop leak - drawImage: force GPU texture re-upload for gradient canvases - Gradient: keep _renderTarget on addColorStop (prevent GL texture leak) - Gradient: fix class JSDoc to reference base Renderer methods - toCanvas: document canvas resize cache invalidation Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/gradient.js | 12 ++++++++---- packages/melonjs/src/video/webgl/webgl_renderer.js | 9 +++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js index 9b92d04e7..f92b912f3 100644 --- a/packages/melonjs/src/video/gradient.js +++ b/packages/melonjs/src/video/gradient.js @@ -7,9 +7,8 @@ import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; /** * A Gradient object representing a linear or radial gradient fill. - * Created via {@link CanvasRenderer#createLinearGradient}, {@link CanvasRenderer#createRadialGradient}, - * {@link WebGLRenderer#createLinearGradient}, or {@link WebGLRenderer#createRadialGradient}. - * Can be passed to {@link CanvasRenderer#setColor} or {@link WebGLRenderer#setColor} as a fill style. + * Created via {@link Renderer#createLinearGradient} or {@link Renderer#createRadialGradient}. + * Can be passed to {@link Renderer#setColor} as a fill style. */ export class Gradient { /** @@ -79,7 +78,8 @@ export class Gradient { }); this._dirty = true; this._canvasGradient = undefined; - this._renderTarget = undefined; + // keep _renderTarget alive — the _dirty flag will trigger re-rendering + // in toCanvasGradient() / toCanvas(), avoiding a GL texture leak return this; } @@ -155,6 +155,10 @@ export class Gradient { this._renderTarget.width !== tw || this._renderTarget.height !== th ) { + // NOTE: resizing the canvas invalidates its GPU texture in the + // TextureCache, but the cache key (the canvas element itself) + // remains the same. The drawImage path must force a re-upload + // so the GPU texture matches the new content. this._renderTarget.canvas.width = tw; this._renderTarget.canvas.height = th; } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 86a8ffa63..f4112c71f 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -574,8 +574,13 @@ export default class WebGLRenderer extends Renderer { this.currentBatcher.useShader(shader); } - // force reuploading if the given image is a HTMLVideoElement - const reupload = typeof image.videoWidth !== "undefined"; + // force reuploading if the given image is a HTMLVideoElement or a + // gradient canvas whose content may have been re-rendered + const reupload = + typeof image.videoWidth !== "undefined" || + (this._currentGradient && + this._currentGradient._renderTarget && + this._currentGradient._renderTarget.canvas === image); const texture = this.cache.get(image); const uvs = texture.getUVs(sx, sy, sw, sh); this.currentBatcher.addQuad( From affb3c843dae32556a5c2e6c039b547737fca429 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 5 Apr 2026 07:05:44 +0800 Subject: [PATCH 07/10] Fix dash validation, infinite loop guard, texture reupload, JSDoc, tests - setLineDash: filter rejects negative, NaN, and Infinity values - #dashSegments: skip zero/negative dash values to prevent infinite loop - drawImage: only re-upload gradient texture when dirty - Canvas renderer: fix JSDoc ordering for strokeLine/setLineDash - Remove orphan JSDoc block in WebGL renderer - 23 linedash tests: validation, edge cases, all stroke methods, save/restore Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/video/canvas/canvas_renderer.js | 34 +++--- packages/melonjs/src/video/renderer.js | 4 +- .../melonjs/src/video/webgl/webgl_renderer.js | 25 ++-- packages/melonjs/tests/linedash.spec.js | 110 ++++++++++++++++++ 4 files changed, 145 insertions(+), 28 deletions(-) diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index c0a21e038..e153fc818 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -501,23 +501,6 @@ export default class CanvasRenderer extends Renderer { * @param {number} endX - the end x coordinate * @param {number} endY - the end y coordinate */ - /** - * Set the line dash pattern. - * @param {number[]} segments - dash pattern - */ - setLineDash(segments) { - super.setLineDash(segments); - this.getContext().setLineDash(segments); - } - - /** - * Get the current line dash pattern. - * @returns {number[]} dash pattern - */ - getLineDash() { - return this.getContext().getLineDash(); - } - strokeLine(startX, startY, endX, endY) { if (this.getGlobalAlpha() < 1 / 255) { // Fast path: don't draw fully transparent @@ -543,6 +526,23 @@ export default class CanvasRenderer extends Renderer { this.strokeLine(startX, startY, endX, endY); } + /** + * Set the line dash pattern. + * @param {number[]} segments - dash pattern + */ + setLineDash(segments) { + super.setLineDash(segments); + this.getContext().setLineDash(this.renderState.lineDash); + } + + /** + * Get the current line dash pattern. + * @returns {number[]} dash pattern + */ + getLineDash() { + return this.getContext().getLineDash(); + } + /** * Stroke the given me.Polygon on the screen * @param {Polygon} poly - the shape to draw diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 37cb09397..deff0a062 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -312,7 +312,9 @@ export default class Renderer { * renderer.setLineDash([]); */ setLineDash(segments) { - this.renderState.lineDash = segments; + this.renderState.lineDash = segments.filter((v) => { + return Number.isFinite(v) && v >= 0; + }); this.renderState.lineDashOffset = 0; } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index f4112c71f..5809dc568 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -579,6 +579,7 @@ export default class WebGLRenderer extends Renderer { const reupload = typeof image.videoWidth !== "undefined" || (this._currentGradient && + this._currentGradient._dirty && this._currentGradient._renderTarget && this._currentGradient._renderTarget.canvas === image); const texture = this.cache.get(image); @@ -1495,16 +1496,6 @@ export default class WebGLRenderer extends Renderer { this.currentBatcher.drawVertices(this.gl.TRIANGLES, verts); } - /** - * Draw a gradient-filled shape by masking with the shape and filling the bounding rect. - * Temporarily disables the gradient to prevent recursion in the fill methods. - * @param {Function} drawShape - draws the shape into the stencil buffer - * @param {number} x - bounding rect x - * @param {number} y - bounding rect y - * @param {number} w - bounding rect width - * @param {number} h - bounding rect height - * @ignore - */ /** * Split a line segment into dashed sub-segments. * @param {number} x0 - start x @@ -1535,6 +1526,10 @@ export default class WebGLRenderer extends Renderer { while (dist < lineLen) { const dashLen = pattern[patIdx % pattern.length]; + if (dashLen <= 0) { + patIdx++; + continue; + } const segEnd = Math.min(dist + dashLen, lineLen); if (drawing) { @@ -1552,6 +1547,16 @@ export default class WebGLRenderer extends Renderer { return segments; } + /** + * Draw a gradient-filled shape by masking with the shape and filling the bounding rect. + * Temporarily disables the gradient to prevent recursion in the fill methods. + * @param {Function} drawShape - draws the shape into the stencil buffer + * @param {number} x - bounding rect x + * @param {number} y - bounding rect y + * @param {number} w - bounding rect width + * @param {number} h - bounding rect height + * @ignore + */ #gradientMask(drawShape, x, y, w, h) { const gl = this.gl; const grad = this._currentGradient; diff --git a/packages/melonjs/tests/linedash.spec.js b/packages/melonjs/tests/linedash.spec.js index ea91ea506..0c867919a 100644 --- a/packages/melonjs/tests/linedash.spec.js +++ b/packages/melonjs/tests/linedash.spec.js @@ -67,6 +67,116 @@ describe("LineDash", () => { }); }); + describe("setLineDash input validation", () => { + it("should clone the array (not store by reference)", () => { + const pattern = [10, 5]; + app.renderer.setLineDash(pattern); + pattern[0] = 999; + expect(app.renderer.getLineDash()[0]).toEqual(10); + app.renderer.setLineDash([]); + }); + + it("should filter out negative values", () => { + app.renderer.setLineDash([10, -5, 8, -3]); + expect(app.renderer.getLineDash()).toEqual([10, 8]); + app.renderer.setLineDash([]); + }); + + it("should keep zero values", () => { + app.renderer.setLineDash([10, 0, 5, 0]); + expect(app.renderer.getLineDash()).toEqual([10, 0, 5, 0]); + app.renderer.setLineDash([]); + }); + + it("should handle all-negative values (becomes empty = solid)", () => { + app.renderer.setLineDash([-1, -2, -3]); + expect(app.renderer.getLineDash()).toEqual([]); + app.renderer.setLineDash([]); + }); + + it("should not throw with zero-length dash pattern on strokeLine", () => { + app.renderer.setLineDash([0, 0]); + expect(() => { + app.renderer.strokeLine(0, 0, 64, 64); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should not infinite loop with zero dash values on strokeLine", () => { + app.renderer.setLineDash([0, 5, 0, 10]); + expect(() => { + app.renderer.strokeLine(0, 0, 64, 0); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + }); + + describe("setLineDash edge cases", () => { + it("should filter NaN values", () => { + app.renderer.setLineDash([10, NaN, 5]); + const dash = app.renderer.getLineDash(); + expect(dash).not.toContain(NaN); + app.renderer.setLineDash([]); + }); + + it("should filter Infinity values", () => { + app.renderer.setLineDash([10, Infinity, 5]); + const dash = app.renderer.getLineDash(); + expect(dash).not.toContain(Infinity); + app.renderer.setLineDash([]); + }); + + it("should handle a single-element pattern", () => { + app.renderer.setLineDash([10]); + expect(() => { + app.renderer.strokeLine(0, 0, 64, 0); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should handle fractional values", () => { + app.renderer.setLineDash([0.5, 0.1]); + expect(() => { + app.renderer.strokeLine(0, 0, 64, 0); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + }); + + describe("dash on different stroke methods", () => { + it("should not throw with strokeRect", () => { + app.renderer.setLineDash([8, 4]); + expect(() => { + app.renderer.strokeRect(0, 0, 32, 32); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should not throw with strokeEllipse", () => { + app.renderer.setLineDash([8, 4]); + expect(() => { + app.renderer.strokeEllipse(32, 32, 16, 16); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should not throw with strokeArc", () => { + app.renderer.setLineDash([6, 3]); + expect(() => { + app.renderer.strokeArc(32, 32, 16, 0, Math.PI * 2); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should not throw on zero-length line", () => { + app.renderer.setLineDash([10, 5]); + expect(() => { + app.renderer.strokeLine(32, 32, 32, 32); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + }); + describe("save/restore with dash", () => { it("should restore dash pattern after save/restore", () => { app.renderer.setLineDash([10, 5]); From 85eaa39007d0727f1cb290fdd4103735a60198b7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 5 Apr 2026 07:12:46 +0800 Subject: [PATCH 08/10] RenderState: zero-allocation save for lineDash (store reference, not clone) setLineDash() creates a new array via filter(), so the reference is safe to store directly. Added test proving nested save/restore stays correct. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/renderstate.js | 2 +- packages/melonjs/tests/linedash.spec.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js index a7c95959f..98eb157ed 100644 --- a/packages/melonjs/src/video/renderstate.js +++ b/packages/melonjs/src/video/renderstate.js @@ -127,7 +127,7 @@ export default class RenderState { this._tintStack[depth].copy(this.currentTint); this._matrixStack[depth].copy(this.currentTransform); this._gradientStack[depth] = this.currentGradient; - this._lineDashStack[depth] = this.lineDash.slice(); + this._lineDashStack[depth] = this.lineDash; this._lineDashOffsetStack[depth] = this.lineDashOffset; this._blendStack[depth] = this.currentBlendMode; diff --git a/packages/melonjs/tests/linedash.spec.js b/packages/melonjs/tests/linedash.spec.js index 0c867919a..41b702ae7 100644 --- a/packages/melonjs/tests/linedash.spec.js +++ b/packages/melonjs/tests/linedash.spec.js @@ -195,5 +195,21 @@ describe("LineDash", () => { app.renderer.restore(); expect(app.renderer.getLineDash()).toEqual([]); }); + + it("save should not be affected by subsequent setLineDash calls", () => { + app.renderer.setLineDash([10, 5]); + app.renderer.save(); + // setLineDash creates a new array, so saved reference stays intact + app.renderer.setLineDash([20, 10]); + app.renderer.save(); + app.renderer.setLineDash([1, 1]); + // restore to [20, 10] + app.renderer.restore(); + expect(app.renderer.getLineDash()).toEqual([20, 10]); + // restore to [10, 5] + app.renderer.restore(); + expect(app.renderer.getLineDash()).toEqual([10, 5]); + app.renderer.setLineDash([]); + }); }); }); From 903ee64f25c5ad8a5e5fed529ab0d79798cf1088 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 5 Apr 2026 07:17:23 +0800 Subject: [PATCH 09/10] Remove unused lineDashOffset, add solid/dashed mixing tests - Remove lineDashOffset from RenderState (never read, only written to 0) - Add tests: solid after restore, dashed after restore, alternating 3-level nested save/restore, mixed dashed strokeLine with solid path stroke Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/renderer.js | 1 - packages/melonjs/src/video/renderstate.js | 15 ------ packages/melonjs/tests/linedash.spec.js | 65 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index deff0a062..b860f3ca6 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -315,7 +315,6 @@ export default class Renderer { this.renderState.lineDash = segments.filter((v) => { return Number.isFinite(v) && v >= 0; }); - this.renderState.lineDashOffset = 0; } /** diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js index 98eb157ed..1a6fc4885 100644 --- a/packages/melonjs/src/video/renderstate.js +++ b/packages/melonjs/src/video/renderstate.js @@ -51,12 +51,6 @@ export default class RenderState { */ this.lineDash = []; - /** - * current line dash offset - * @type {number} - */ - this.lineDashOffset = 0; - /** * current blend mode * @type {string} @@ -99,9 +93,6 @@ export default class RenderState { /** @ignore */ this._lineDashStack = new Array(this._stackCapacity); - /** @ignore */ - this._lineDashOffsetStack = new Float64Array(this._stackCapacity); - /** @ignore */ this._scissorActive = new Uint8Array(this._stackCapacity); @@ -128,7 +119,6 @@ export default class RenderState { this._matrixStack[depth].copy(this.currentTransform); this._gradientStack[depth] = this.currentGradient; this._lineDashStack[depth] = this.lineDash; - this._lineDashOffsetStack[depth] = this.lineDashOffset; this._blendStack[depth] = this.currentBlendMode; if (scissorTestActive) { @@ -159,8 +149,6 @@ export default class RenderState { this.currentTransform.copy(this._matrixStack[depth]); this.currentGradient = this._gradientStack[depth]; this.lineDash = this._lineDashStack[depth]; - this.lineDashOffset = this._lineDashOffsetStack[depth]; - const scissorActive = !!this._scissorActive[depth]; if (scissorActive) { this.currentScissor.set(this._scissorStack[depth]); @@ -206,9 +194,6 @@ export default class RenderState { const newScissorActive = new Uint8Array(newCap); newScissorActive.set(this._scissorActive); this._scissorActive = newScissorActive; - const newLineDashOffset = new Float64Array(newCap); - newLineDashOffset.set(this._lineDashOffsetStack); - this._lineDashOffsetStack = newLineDashOffset; this._stackCapacity = newCap; } } diff --git a/packages/melonjs/tests/linedash.spec.js b/packages/melonjs/tests/linedash.spec.js index 41b702ae7..f4075d0bf 100644 --- a/packages/melonjs/tests/linedash.spec.js +++ b/packages/melonjs/tests/linedash.spec.js @@ -196,6 +196,71 @@ describe("LineDash", () => { expect(app.renderer.getLineDash()).toEqual([]); }); + it("should draw solid line after restoring from dashed state", () => { + app.renderer.save(); + app.renderer.setLineDash([10, 5]); + app.renderer.strokeLine(0, 0, 64, 0); + app.renderer.restore(); + // after restore, dash is cleared — this should draw solid + expect(app.renderer.getLineDash()).toEqual([]); + expect(() => { + app.renderer.strokeLine(0, 32, 64, 32); + }).not.toThrow(); + }); + + it("should draw dashed line after restoring to dashed state", () => { + app.renderer.setLineDash([10, 5]); + app.renderer.save(); + app.renderer.setLineDash([]); + // draw solid + app.renderer.strokeLine(0, 0, 64, 0); + app.renderer.restore(); + // after restore, dash is back + expect(app.renderer.getLineDash()).toEqual([10, 5]); + expect(() => { + app.renderer.strokeLine(0, 32, 64, 32); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should alternate solid and dashed lines with save/restore", () => { + expect(() => { + // solid + app.renderer.strokeLine(0, 0, 64, 0); + + app.renderer.save(); + app.renderer.setLineDash([8, 4]); + // dashed + app.renderer.strokeLine(0, 10, 64, 10); + + app.renderer.save(); + app.renderer.setLineDash([]); + // solid again + app.renderer.strokeLine(0, 20, 64, 20); + app.renderer.restore(); + + // back to dashed [8, 4] + app.renderer.strokeLine(0, 30, 64, 30); + app.renderer.restore(); + + // back to solid + app.renderer.strokeLine(0, 40, 64, 40); + }).not.toThrow(); + expect(app.renderer.getLineDash()).toEqual([]); + }); + + it("should mix dashed strokeLine with solid path stroke", () => { + app.renderer.setLineDash([10, 5]); + app.renderer.strokeLine(0, 0, 64, 0); + app.renderer.setLineDash([]); + app.renderer.beginPath(); + app.renderer.moveTo(0, 16); + app.renderer.lineTo(64, 16); + expect(() => { + app.renderer.stroke(); + }).not.toThrow(); + }); + it("save should not be affected by subsequent setLineDash calls", () => { app.renderer.setLineDash([10, 5]); app.renderer.save(); From 5001a75022fae63705595f0c4ae602900738c888 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 5 Apr 2026 07:28:57 +0800 Subject: [PATCH 10/10] Use CanvasRenderTarget.invalidate() for gradient GPU texture reupload - Replace _needsReupload hack with proper invalidate(renderer) call - toCanvas() now accepts renderer parameter for GPU texture invalidation - Fix all-zero dash infinite loop with early bail-out - Add infinite loop tests for all-zero dash patterns Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/gradient.js | 4 +++- .../melonjs/src/video/webgl/webgl_renderer.js | 24 ++++++++++++------- packages/melonjs/tests/gradient.spec.js | 10 ++++---- packages/melonjs/tests/linedash.spec.js | 19 +++++++++++++++ 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js index f92b912f3..26880b0f4 100644 --- a/packages/melonjs/src/video/gradient.js +++ b/packages/melonjs/src/video/gradient.js @@ -124,6 +124,7 @@ export class Gradient { /** * Render the gradient onto a canvas matching the given draw rect. * Uses the original gradient coordinates so the result matches Canvas 2D behavior. + * @param {CanvasRenderer|WebGLRenderer} renderer - the active renderer (used to invalidate GPU texture) * @param {number} x - draw rect x * @param {number} y - draw rect y * @param {number} width - draw rect width @@ -131,7 +132,7 @@ export class Gradient { * @returns {HTMLCanvasElement|OffscreenCanvas} the rendered gradient canvas * @ignore */ - toCanvas(x, y, width, height) { + toCanvas(renderer, x, y, width, height) { // use power-of-two dimensions for WebGL texture compatibility const tw = nextPowerOfTwo(Math.max(1, Math.ceil(width))); const th = nextPowerOfTwo(Math.max(1, Math.ceil(height))); @@ -198,6 +199,7 @@ export class Gradient { this._dirty = false; this._lastX = x; this._lastY = y; + this._renderTarget.invalidate(renderer); return this._renderTarget.canvas; } } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 5809dc568..f6d5e6fd6 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -575,13 +575,8 @@ export default class WebGLRenderer extends Renderer { } // force reuploading if the given image is a HTMLVideoElement or a - // gradient canvas whose content may have been re-rendered - const reupload = - typeof image.videoWidth !== "undefined" || - (this._currentGradient && - this._currentGradient._dirty && - this._currentGradient._renderTarget && - this._currentGradient._renderTarget.canvas === image); + // force re-upload for video elements + const reupload = typeof image.videoWidth !== "undefined"; const texture = this.cache.get(image); const uvs = texture.getUVs(sx, sy, sw, sh); this.currentBatcher.addQuad( @@ -1297,7 +1292,7 @@ export default class WebGLRenderer extends Renderer { */ fillRect(x, y, width, height) { if (this._currentGradient) { - const canvas = this._currentGradient.toCanvas(x, y, width, height); + const canvas = this._currentGradient.toCanvas(this, x, y, width, height); this.drawImage(canvas, 0, 0, width, height, x, y, width, height); return; } @@ -1519,6 +1514,18 @@ export default class WebGLRenderer extends Renderer { const nx = dx / lineLen; const ny = dy / lineLen; + // bail out if pattern has no positive values (would loop forever) + if ( + !pattern.some((v) => { + return v > 0; + }) + ) { + return [ + { x: x0, y: y0 }, + { x: x1, y: y1 }, + ]; + } + const segments = []; let dist = 0; let patIdx = 0; @@ -1528,6 +1535,7 @@ export default class WebGLRenderer extends Renderer { const dashLen = pattern[patIdx % pattern.length]; if (dashLen <= 0) { patIdx++; + drawing = !drawing; continue; } const segEnd = Math.min(dist + dashLen, lineLen); diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js index e5ddabdc6..5865d7c82 100644 --- a/packages/melonjs/tests/gradient.spec.js +++ b/packages/melonjs/tests/gradient.spec.js @@ -281,7 +281,7 @@ describe("Gradient", () => { const gradient = new Gradient("linear", [0, 0, 100, 0]); gradient.addColorStop(0, "red"); gradient.addColorStop(1, "blue"); - const canvas = gradient.toCanvas(0, 0, 100, 50); + const canvas = gradient.toCanvas(app.renderer, 0, 0, 100, 50); expect(canvas).toBeDefined(); // dimensions are next power of two expect(canvas.width).toEqual(128); @@ -292,8 +292,8 @@ describe("Gradient", () => { const gradient = new Gradient("linear", [0, 0, 100, 0]); gradient.addColorStop(0, "red"); gradient.addColorStop(1, "blue"); - const first = gradient.toCanvas(0, 0, 100, 50); - const second = gradient.toCanvas(0, 0, 100, 50); + const first = gradient.toCanvas(app.renderer, 0, 0, 100, 50); + const second = gradient.toCanvas(app.renderer, 0, 0, 100, 50); expect(first).toBe(second); }); @@ -301,8 +301,8 @@ describe("Gradient", () => { const gradient = new Gradient("linear", [0, 0, 100, 0]); gradient.addColorStop(0, "red"); gradient.addColorStop(1, "blue"); - const first = gradient.toCanvas(0, 0, 100, 50); - const second = gradient.toCanvas(10, 10, 100, 50); + const first = gradient.toCanvas(app.renderer, 0, 0, 100, 50); + const second = gradient.toCanvas(app.renderer, 10, 10, 100, 50); // same canvas object reused, but re-rendered expect(first).toBe(second); }); diff --git a/packages/melonjs/tests/linedash.spec.js b/packages/melonjs/tests/linedash.spec.js index f4075d0bf..7e4c1a1af 100644 --- a/packages/melonjs/tests/linedash.spec.js +++ b/packages/melonjs/tests/linedash.spec.js @@ -109,6 +109,25 @@ describe("LineDash", () => { }).not.toThrow(); app.renderer.setLineDash([]); }); + + it("should not infinite loop with all-zero dash pattern", () => { + app.renderer.setLineDash([0, 0]); + expect(() => { + app.renderer.strokeLine(0, 0, 64, 0); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); + + it("should not infinite loop with all-zero pattern on path stroke", () => { + app.renderer.setLineDash([0, 0, 0]); + app.renderer.beginPath(); + app.renderer.moveTo(0, 0); + app.renderer.lineTo(64, 64); + expect(() => { + app.renderer.stroke(); + }).not.toThrow(); + app.renderer.setLineDash([]); + }); }); describe("setLineDash edge cases", () => {