From 30233c8710378144a49467b678fae1b20a1874a9 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 6 Aug 2026 15:07:47 +0800 Subject: [PATCH 1/2] Gradient and Text textures: allocation-stable sizing without power-of-two rounding (#1554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POT rounding on baked Gradient/Text textures was size hysteresis, not a WebGL 1 leftover — consecutive re-bakes must land on identical texture dimensions so updates stay on the cheap same-size upload path. Replace it with two schemes that keep the stability and shrink the waste: - Gradients bake into a FIXED 256x256 shared target (1:1 up to 256, transform-scaled beyond; the destination quad's stretch inverts the scale exactly). The shared canvas is allocated once and never resized, every re-bake is a same-size update, and gradient memory is capped at 256 KB. toCanvas now returns {canvas, width, height} — the drawImage source rect — and the bake-reuse identity is the draw rect itself (dimension comparison would alias sizes on a fixed canvas). - Text canvases round to 32-pixel buckets (grow-only preserved) instead of the next power of two: a ticking counter re-bakes into identical dimensions and the same canvas element, while worst-case waste drops from up to 2x per axis to at most 31 px per axis. 21 adversarial tests: pixel-level ramp correctness 1:1 and downscaled (row monotonicity as a banding detector), non-uniform radial scaling, offset rects, canvas identity stability, reuse vs dirty-repaint, the size-aliasing trap, degenerate/fractional rects, edge-padding opacity; text bucket-exactness property sweep, ticking-counter identity, boundary growth, never-shrinks, multiline height, huge-font waste bound. The gradients example diffs to zero changed pixels on all three backends. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/renderable/text/text.js | 16 +- .../src/video/canvas/canvas_renderer.js | 18 +- packages/melonjs/src/video/gradient.js | 85 +++++-- .../melonjs/src/video/webgl/webgl_renderer.js | 19 +- .../src/video/webgpu/webgpu_renderer.js | 18 +- packages/melonjs/tests/gradient.spec.js | 21 +- packages/melonjs/tests/gradient_bake.spec.js | 227 ++++++++++++++++++ packages/melonjs/tests/text_bucket.spec.js | 153 ++++++++++++ 9 files changed, 504 insertions(+), 54 deletions(-) create mode 100644 packages/melonjs/tests/gradient_bake.spec.js create mode 100644 packages/melonjs/tests/text_bucket.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 49b2131611..2565762df8 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -11,6 +11,7 @@ - **Up to 32 lights, and light data in a uniform buffer** ([#1552](https://github.com/melonjs/melonJS/issues/1552)) — `MAX_LIGHTS` rises from 8 to **32**, for both the lit sprite path (`Light2d` + normal maps) and the lit mesh path (`Light3d`). The old cap was a compatibility limit, not a design choice: light data travelled in GLSL uniform arrays, which are charged against `MAX_FRAGMENT_UNIFORM_VECTORS` — a small driver-reported budget shared with every other uniform a shader declares, and one that a `vec3` consumes a full slot of. It now travels in a `std140` uniform buffer, charged against `MAX_UNIFORM_BLOCK_SIZE` instead (at least 16 KB everywhere, typically 64 KB); 32 lights occupy 1056 bytes there. A static light rig still costs **zero** GL calls per frame, as before. Note this raises the *capacity*, not the shading cost: the fragment loop still runs once per pixel per live light, so unused slots are free but filling them is not. The four lit shaders move to GLSL ES 3.00 as a consequence — uniform blocks do not exist in ES 1.00. **User shaders are unaffected**: `ShaderEffect` bodies and raw `GLShader` sources stay GLSL ES 1.00 - **Backend-neutral vertex formats and draw topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)) — a vertex attribute can now be declared with a single `format` token (`"float32x3"`, `"unorm8x4"`) instead of a `size` + `type` + `normalized` triple, and a draw mode with a topology name (`"triangle-list"`, `"line-list"`). `Batcher.addAttribute` accepts three forms — a descriptor object, `(name, format, offset)`, and the existing `(name, size, glType, normalized, offset)` — and `Batcher.mode` accepts either vocabulary while still reading back as the GL enum. `Batcher.topology` is the new portable spelling. **The GL-enum form is supported indefinitely**, so custom batchers need no changes. Groundwork for [#1184](https://github.com/melonjs/melonJS/issues/1184): a format-declared layout needs no live rendering context, and describes itself to any backend. `VertexFormat` / `Topology` types and the `isVertexFormat` / `isTopology` / `resolveVertexFormat` / `PORTABLE_TOPOLOGIES` helpers are exported - **A `"none"` blend mode on both GPU backends** — `setBlendMode("none")` disables blending outright (the source replaces the destination, alpha included). It was born as a WebGPU pipeline blend state; the WebGL renderer now honors it identically instead of silently falling back to `"normal"`. The related `setBlendEnabled`, `enableScissor` and `clearRenderTarget` renderer methods — WebGL-only before — are implemented on the WebGPU renderer as well, along with custom batcher overrides (`settings.batcher`/`settings.compositor`), the `settings.blendMode` startup value, `GPUVendor` (from the adapter info), and `failIfMajorPerformanceCaveat` (rejects a software fallback adapter, falling through to WebGL under AUTO) +- **Gradient and Text textures stopped power-of-two rounding** ([#1554](https://github.com/melonjs/melonJS/issues/1554)) — two allocation-stability schemes replace it. Gradients now rasterize into a **fixed 256×256 shared bake target** regardless of on-screen size and are stretched by the destination quad (visually equivalent: linear stop interpolation × linear texture filtering — verified pixel-identical on all three backends): the shared canvas is allocated once and never resized, every re-bake is a same-size texture update, and gradient memory is capped at 256 KB instead of growing with the largest gradient drawn. Text canvases now round to **32-pixel buckets** (grow-only, as before) instead of the next power of two: a ticking counter still re-bakes into identical dimensions (the cheap same-size upload path on every backend), while worst-case memory waste drops from up to 2× per axis to at most 31 px per axis - **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — signal that a mesh's geometry was edited in place (`originalVertices`, `uvs`, `indices`, normals or per-vertex colours), so the GPU copy is refreshed on the next draw. Moving, rotating, scaling, re-tinting or fading a mesh needs no signal — those are applied when drawing, not stored in the geometry ### Changed (breaking) diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index 0bfba7da1c..4ede5eda1b 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -1,6 +1,6 @@ import { game } from "../../application/application.ts"; import { Color, colorPool } from "../../math/color.ts"; -import { nextPowerOfTwo } from "../../math/math.ts"; + import CanvasRenderTarget from "../../video/rendertarget/canvasrendertarget.js"; import { resolveAnchorPoint } from "../anchorPoint.ts"; import Renderable from "../renderable.js"; @@ -361,10 +361,16 @@ export default class Text extends Renderable { true, ); - // round the offscreen canvas size to the next power of two - // (required for WebGL1, harmless for WebGL2/Canvas) - const width = nextPowerOfTwo(this.metrics.width); - const height = nextPowerOfTwo(this.metrics.height); + // Quantize the offscreen canvas size to 32-pixel buckets: small + // metric changes (a score ticking, typewriter text) land on the + // SAME canvas dimensions, so the re-bake stays a same-size texture + // update — the cheap path on every backend (a size change means + // respecifying GL storage / retiring the WebGPU texture). Coarser + // than exact sizing on purpose (hysteresis), far tighter than the + // old power-of-two rounding (waste is bounded at 31px per axis + // instead of up to 2× each). + const width = Math.ceil(this.metrics.width / 32) * 32; + const height = Math.ceil(this.metrics.height / 32) * 32; // invalidate the texture const renderer = this.parentApp?.renderer ?? game.renderer; diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index 167ea784d7..dd637c1e6a 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -449,18 +449,18 @@ export default class CanvasRenderer extends Renderer { this._lightCache.set(light, entry); } const r2 = entry.radius * 2; - // `Gradient.toCanvas` renders into a shared `CanvasRenderTarget` - // (one per engine, reused across all gradients) and returns its - // canvas. `drawImage` with explicit src/dst rects crops the POT - // padding and stretches the circular gradient into the - // elliptical bounding box `(light.width × light.height)`. - const canvas = entry.gradient.toCanvas(this, 0, 0, r2, r2); + // `Gradient.toCanvas` renders into the fixed-resolution shared + // `CanvasRenderTarget` (one per engine, reused across all + // gradients). `drawImage` with the returned source rect crops the + // padding and stretches the circular gradient into the elliptical + // bounding box `(light.width × light.height)`. + const baked = entry.gradient.toCanvas(this, 0, 0, r2, r2); this.drawImage( - canvas, + baked.canvas, 0, 0, - r2, - r2, + baked.width, + baked.height, light.pos.x, light.pos.y, light.width, diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js index 0880df7a0b..db9f6c2c7c 100644 --- a/packages/melonjs/src/video/gradient.js +++ b/packages/melonjs/src/video/gradient.js @@ -3,11 +3,23 @@ import { colorPool } from "../math/color.ts"; /** * @import {Color} from "../math/color.ts"; */ -import { nextPowerOfTwo } from "../math/math.ts"; import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; /** - * Shared render target for WebGL gradient textures. + * The gradient bake resolution: gradients are rasterized into a FIXED + * 256×256 shared target regardless of on-screen size and stretched by the + * destination quad — a gradient is piecewise-linear between its stops and + * the GPU interpolates linearly between texels, so a capped bake is + * visually equivalent to a full-size one. The fixed size means the shared + * target is allocated exactly once and never resized: every re-bake is a + * same-size texture update (the cheap path on every backend), and memory + * is capped at 256 KB instead of growing with the largest gradient drawn. + * @ignore + */ +const GRADIENT_BAKE_SIZE = 256; + +/** + * Shared render target for GPU gradient textures. * Reused across all Gradient instances to avoid GPU memory leaks. * @ignore */ @@ -15,6 +27,8 @@ let sharedRenderTarget = null; let sharedLastId = -1; let sharedLastX = NaN; let sharedLastY = NaN; +let sharedLastW = NaN; +let sharedLastH = NaN; let nextGradientId = 0; /** @@ -151,49 +165,63 @@ 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) + * Render the gradient into the fixed-resolution shared bake target for + * the given draw rect. Uses the original gradient coordinates so the + * result matches Canvas 2D behavior. Rects larger than the bake target + * are rasterized scaled-down; the destination quad stretches them back, + * which is visually equivalent (linear stop interpolation × linear + * texture filtering). The returned `width`/`height` describe the region + * of the canvas the caller must use as the drawImage SOURCE rect. + * @param {CanvasRenderer|WebGLRenderer} renderer - the active renderer (used to invalidate the GPU texture) * @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 + * @returns {{canvas: HTMLCanvasElement|OffscreenCanvas, width: number, height: number}} the shared gradient canvas + the used source-rect size * @ignore */ 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))); - - // skip if this gradient already rendered to the shared target at these coords + const w = Math.max(1, width); + const h = Math.max(1, height); + // bake 1:1 up to the target size, scaled-down beyond it + const sx = w > GRADIENT_BAKE_SIZE ? GRADIENT_BAKE_SIZE / w : 1; + const sy = h > GRADIENT_BAKE_SIZE ? GRADIENT_BAKE_SIZE / h : 1; + const sw = w * sx; + const sh = h * sy; + + // skip if this gradient already rendered to the shared target for + // this exact rect (the target is fixed-size, so the RECT — not the + // canvas dimensions — is the identity of the last bake) if ( sharedRenderTarget && sharedLastId === this._id && !this._dirty && sharedLastX === x && sharedLastY === y && - sharedRenderTarget.width === tw && - sharedRenderTarget.height === th + sharedLastW === w && + sharedLastH === h ) { this._renderTarget = sharedRenderTarget; - return this._renderTarget.canvas; + return { canvas: this._renderTarget.canvas, width: sw, height: sh }; } - // reuse the shared render target to avoid GPU memory leaks + // the shared target is allocated once and never resized — every + // bake is a same-size update, the cheap path on every backend if (!sharedRenderTarget) { - sharedRenderTarget = new CanvasRenderTarget(tw, th); - } else if ( - sharedRenderTarget.width !== tw || - sharedRenderTarget.height !== th - ) { - sharedRenderTarget.canvas.width = tw; - sharedRenderTarget.canvas.height = th; + sharedRenderTarget = new CanvasRenderTarget( + GRADIENT_BAKE_SIZE, + GRADIENT_BAKE_SIZE, + ); } this._renderTarget = sharedRenderTarget; const ctx = this._renderTarget.context; - ctx.clearRect(0, 0, tw, th); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, GRADIENT_BAKE_SIZE, GRADIENT_BAKE_SIZE); + + // bake through the scale so gradient coordinates stay in draw-rect + // (logical) space; the destination quad's stretch inverts it exactly + ctx.setTransform(sx, 0, 0, sy, 0, 0); // create gradient with coordinates offset to the draw rect origin const c = this.coords; @@ -222,14 +250,21 @@ export class Gradient { } ctx.fillStyle = gradient; - ctx.fillRect(0, 0, tw, th); + // fill the WHOLE canvas (in logical units) so the padding beyond the + // used region carries the extended gradient — linear filtering at the + // source-rect edge then samples gradient-colored texels, not + // transparent ones (same edge behavior as the old full-canvas bake) + ctx.fillRect(0, 0, GRADIENT_BAKE_SIZE / sx, GRADIENT_BAKE_SIZE / sy); + ctx.setTransform(1, 0, 0, 1, 0, 0); this._dirty = false; sharedLastId = this._id; sharedLastX = x; sharedLastY = y; + sharedLastW = w; + sharedLastH = h; this._renderTarget.invalidate(renderer); - return this._renderTarget.canvas; + return { canvas: this._renderTarget.canvas, width: sw, height: sh }; } /** diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 0f306cfb49..e0eb842c6b 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -2498,9 +2498,22 @@ export default class WebGLRenderer extends Renderer { */ fillRect(x, y, width, height) { if (this._currentGradient) { - // toCanvas() calls invalidate() which flushes pending draws - const canvas = this._currentGradient.toCanvas(this, x, y, width, height); - this.drawImage(canvas, 0, 0, width, height, x, y, width, height); + // toCanvas() calls invalidate() which flushes pending draws. + // The bake is fixed-resolution: the returned width/height are the + // SOURCE rect inside the shared canvas (scaled-down for large + // rects), stretched back by the destination quad. + const baked = this._currentGradient.toCanvas(this, x, y, width, height); + this.drawImage( + baked.canvas, + 0, + 0, + baked.width, + baked.height, + x, + y, + width, + height, + ); return; } this.setBatcher("primitive"); diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 0517b525a7..5ab3f3a21c 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -2755,9 +2755,21 @@ export default class WebGPURenderer extends Renderer { fillRect(x, y, width, height) { if (this.currentGradient) { // toCanvas() bakes the gradient through the Canvas 2D API and - // draws it as a textured quad — same path as the GL backend - const canvas = this.currentGradient.toCanvas(this, x, y, width, height); - this.drawImage(canvas, 0, 0, width, height, x, y, width, height); + // draws it as a textured quad — same path as the GL backend. + // The bake is fixed-resolution: the returned width/height are + // the SOURCE rect inside the shared canvas. + const baked = this.currentGradient.toCanvas(this, x, y, width, height); + this.drawImage( + baked.canvas, + 0, + 0, + baked.width, + baked.height, + x, + y, + width, + height, + ); return; } this.setBatcher("primitive"); diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js index ab5ceaafee..b418e2ba0a 100644 --- a/packages/melonjs/tests/gradient.spec.js +++ b/packages/melonjs/tests/gradient.spec.js @@ -335,24 +335,27 @@ describe("Gradient", () => { }); describe("toCanvas (texture)", () => { - it("should produce a canvas element matching the draw rect", () => { + it("bakes into the fixed 256×256 shared canvas and returns the used source rect", () => { const gradient = new Gradient("linear", [0, 0, 100, 0]); gradient.addColorStop(0, "red"); gradient.addColorStop(1, "blue"); - const canvas = gradient.toCanvas(app.renderer, 0, 0, 100, 50); - expect(canvas).toBeDefined(); - // dimensions are next power of two - expect(canvas.width).toEqual(128); - expect(canvas.height).toEqual(64); + const baked = gradient.toCanvas(app.renderer, 0, 0, 100, 50); + expect(baked.canvas).toBeDefined(); + // the shared target is fixed-size (allocated once, never resized) + expect(baked.canvas.width).toEqual(256); + expect(baked.canvas.height).toEqual(256); + // the source rect matches the draw rect on the 1:1 path + expect(baked.width).toEqual(100); + expect(baked.height).toEqual(50); }); - it("should cache the canvas for same dimensions", () => { + it("should reuse the shared 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(app.renderer, 0, 0, 100, 50); const second = gradient.toCanvas(app.renderer, 0, 0, 100, 50); - expect(first).toBe(second); + expect(first.canvas).toBe(second.canvas); }); it("should invalidate cache when position changes", () => { @@ -362,7 +365,7 @@ describe("Gradient", () => { 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); + expect(first.canvas).toBe(second.canvas); }); }); diff --git a/packages/melonjs/tests/gradient_bake.spec.js b/packages/melonjs/tests/gradient_bake.spec.js new file mode 100644 index 0000000000..1f93c2c33d --- /dev/null +++ b/packages/melonjs/tests/gradient_bake.spec.js @@ -0,0 +1,227 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, Gradient, video } from "../src/index.js"; + +/** + * Adversarial coverage of the fixed-resolution gradient bake (#1554): + * gradients rasterize into a FIXED 256×256 shared target regardless of + * on-screen size and are stretched by the destination quad — so the + * shared canvas is allocated once and never resized, every re-bake is a + * same-size texture update, and memory is capped. The returned + * `{canvas, width, height}` is the drawImage SOURCE rect contract. + */ +describe("Gradient.toCanvas — fixed-resolution bake", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(200, 150, { + parent: "screen", + renderer: video.CANVAS, + }); + await app.init(); + }); + + const px = (canvas, x, y) => { + return canvas + .getContext("2d") + .getImageData(Math.round(x), Math.round(y), 1, 1).data; + }; + + it("caps the bake: a huge rect returns a source rect ≤ 256 on a 256×256 canvas", () => { + const g = new Gradient("linear", [0, 0, 4000, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 4000, 3000); + expect(baked.canvas.width).toBe(256); + expect(baked.canvas.height).toBe(256); + expect(baked.width).toBeLessThanOrEqual(256); + expect(baked.height).toBeLessThanOrEqual(256); + expect(baked.width).toBeGreaterThan(0); + expect(baked.height).toBeGreaterThan(0); + }); + + it("small rects bake 1:1 (exact source rect) — but the canvas never shrinks", () => { + const g = new Gradient("linear", [0, 0, 100, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 100, 50); + expect(baked.width).toBe(100); + expect(baked.height).toBe(50); + expect(baked.canvas.width).toBe(256); + expect(baked.canvas.height).toBe(256); + }); + + it("the shared canvas element identity is STABLE across differently-sized bakes", () => { + const g = new Gradient("linear", [0, 0, 10, 0]); + g.addColorStop(0, "#ffffff"); + g.addColorStop(1, "#000000"); + const a = g.toCanvas(app.renderer, 0, 0, 10, 10).canvas; + const b = g.toCanvas(app.renderer, 0, 0, 5000, 5000).canvas; + const c = g.toCanvas(app.renderer, 0, 0, 33.7, 12.2).canvas; + expect(b).toBe(a); + expect(c).toBe(a); + }); + + it("1:1 pixel correctness: a red→blue ramp is red at the start, blue at the end, mixed mid-way", () => { + const g = new Gradient("linear", [0, 0, 100, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 100, 20); + const start = px(baked.canvas, 1, 10); + const mid = px(baked.canvas, 50, 10); + const end = px(baked.canvas, 98, 10); + expect(start[0]).toBeGreaterThan(230); // red dominates + expect(start[2]).toBeLessThan(30); + expect(end[2]).toBeGreaterThan(230); // blue dominates + expect(end[0]).toBeLessThan(30); + expect(mid[0]).toBeGreaterThan(60); // genuinely mixed + expect(mid[2]).toBeGreaterThan(60); + }); + + it("scaled-down bake keeps the ramp: endpoints correct, monotonic in between (no banding reversal)", () => { + const g = new Gradient("linear", [0, 0, 1024, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 1024, 64); + expect(baked.width).toBe(256); + const y = Math.floor(baked.height / 2); + const startPx = px(baked.canvas, 0, y); + const endPx = px(baked.canvas, 255, y); + expect(startPx[0]).toBeGreaterThan(230); + expect(endPx[2]).toBeGreaterThan(230); + // blue must never DECREASE along the ramp (a reversal would be a + // real banding/ordering artifact, not a rounding wobble) + const row = baked.canvas.getContext("2d").getImageData(0, y, 256, 1).data; + let prevBlue = row[2]; + for (let i = 1; i < 256; i++) { + const blue = row[i * 4 + 2]; + expect(blue).toBeGreaterThanOrEqual(prevBlue - 2); // 2 = quantization slack + prevBlue = Math.max(prevBlue, blue); + } + }); + + it("draw-rect offset is honored: gradient coordinates are absolute, the bake is rect-relative", () => { + // ramp lives at x = 500..600 in world space; baking the rect at + // x = 500 must put the ramp START at canvas x = 0 + const g = new Gradient("linear", [500, 0, 600, 0]); + g.addColorStop(0, "#00ff00"); + g.addColorStop(1, "#000000"); + const baked = g.toCanvas(app.renderer, 500, 0, 100, 10); + expect(px(baked.canvas, 1, 5)[1]).toBeGreaterThan(230); + expect(px(baked.canvas, 98, 5)[1]).toBeLessThan(30); + }); + + it("radial: the center texel carries the inner stop, even under non-uniform downscale", () => { + // a circle centered in a wide rect: x is downscaled (1000 → 256), + // y is 1:1 — the bake is elliptical, the destination stretch + // restores it; the CENTER color must survive the transform + const g = new Gradient("radial", [500, 50, 0, 500, 50, 40]); + g.addColorStop(0, "#ffff00"); + g.addColorStop(1, "#000000"); + const baked = g.toCanvas(app.renderer, 0, 0, 1000, 100); + const cx = (500 / 1000) * baked.width; + const cy = (50 / 100) * baked.height; + const center = px(baked.canvas, cx, cy); + expect(center[0]).toBeGreaterThan(230); + expect(center[1]).toBeGreaterThan(230); + // well outside the (scaled) radius → outer stop + const outside = px(baked.canvas, 5, cy); + expect(outside[0]).toBeLessThan(30); + }); + + it("re-baking the same rect skips the repaint (scribble survives)", () => { + const g = new Gradient("linear", [0, 0, 64, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 64, 64); + // vandalize a pixel, then ask for the exact same bake again + baked.canvas.getContext("2d").fillStyle = "#00ff00"; + baked.canvas.getContext("2d").fillRect(10, 10, 1, 1); + const again = g.toCanvas(app.renderer, 0, 0, 64, 64); + expect(px(again.canvas, 10, 10)[1]).toBeGreaterThan(230); // survived → no repaint + }); + + it("a color-stop change dirties the gradient and forces a repaint", () => { + const g = new Gradient("linear", [0, 0, 64, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 64, 64); + baked.canvas.getContext("2d").fillStyle = "#00ff00"; + baked.canvas.getContext("2d").fillRect(10, 10, 1, 1); + g.addColorStop(0.5, "#ff00ff"); // marks dirty + const again = g.toCanvas(app.renderer, 0, 0, 64, 64); + expect(px(again.canvas, 10, 10)[1]).toBeLessThan(200); // repainted over + }); + + it("the SAME gradient at a different rect SIZE repaints (fixed canvas can't alias sizes)", () => { + // adversarial: the old reuse check compared canvas dimensions, which + // are now constant — reusing here would serve a 64-wide bake for a + // 128-wide request. The rect itself must be the identity. + const g = new Gradient("linear", [0, 0, 128, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + g.toCanvas(app.renderer, 0, 0, 64, 64); + const wide = g.toCanvas(app.renderer, 0, 0, 128, 64); + // at x=100 the 128-wide ramp is mostly blue; a stale 64-wide bake + // would have padding-extended full blue at 100 too — so probe x=60: + // 128-ramp at 60/128 is mixed, stale 64-ramp at 60/64 is near-blue + const probe = px(wide.canvas, 60, 32); + expect(probe[0]).toBeGreaterThan(80); // red still present → fresh bake + }); + + it("alternating two gradients over the shared target repaints each time", () => { + const red = new Gradient("linear", [0, 0, 64, 0]); + red.addColorStop(0, "#ff0000"); + red.addColorStop(1, "#ff0000"); + const blue = new Gradient("linear", [0, 0, 64, 0]); + blue.addColorStop(0, "#0000ff"); + blue.addColorStop(1, "#0000ff"); + for (let i = 0; i < 3; i++) { + expect( + px(red.toCanvas(app.renderer, 0, 0, 64, 64).canvas, 32, 32)[0], + ).toBeGreaterThan(230); + expect( + px(blue.toCanvas(app.renderer, 0, 0, 64, 64).canvas, 32, 32)[2], + ).toBeGreaterThan(230); + } + }); + + it("degenerate rects never crash and clamp to a ≥1×≥1 source", () => { + const g = new Gradient("linear", [0, 0, 10, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + for (const [w, h] of [ + [0, 0], + [0.4, 0.4], + [-5, 10], + [1, 0], + ]) { + const baked = g.toCanvas(app.renderer, 0, 0, w, h); + expect(baked.width).toBeGreaterThanOrEqual(1); + expect(baked.height).toBeGreaterThanOrEqual(1); + expect(baked.canvas.width).toBe(256); + } + }); + + it("fractional rects keep the exact fractional source size on the 1:1 path", () => { + const g = new Gradient("linear", [0, 0, 100, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 100.7, 33.3); + expect(baked.width).toBeCloseTo(100.7, 5); + expect(baked.height).toBeCloseTo(33.3, 5); + }); + + it("the padding beyond the source rect carries the EXTENDED gradient, not transparency (edge-filter parity)", () => { + const g = new Gradient("linear", [0, 0, 64, 0]); + g.addColorStop(0, "#ff0000"); + g.addColorStop(1, "#0000ff"); + const baked = g.toCanvas(app.renderer, 0, 0, 64, 64); + // one texel past the used region on both axes must be opaque + // (the clamped end color), or linear filtering at the source-rect + // edge would bleed transparency into a stretched draw + expect(px(baked.canvas, 70, 10)[3]).toBe(255); + expect(px(baked.canvas, 10, 70)[3]).toBe(255); + expect(px(baked.canvas, 70, 10)[2]).toBeGreaterThan(230); // extended = end stop + }); +}); diff --git a/packages/melonjs/tests/text_bucket.spec.js b/packages/melonjs/tests/text_bucket.spec.js new file mode 100644 index 0000000000..38581f50b4 --- /dev/null +++ b/packages/melonjs/tests/text_bucket.spec.js @@ -0,0 +1,153 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, Text, video } from "../src/index.js"; + +/** + * Adversarial coverage of the 32-pixel text-canvas buckets (#1554): + * small metric changes must land on the SAME canvas dimensions (so the + * re-bake stays a same-size texture update on every backend), growth + * only happens across bucket boundaries, and the canvas never shrinks + * (the pre-existing grow-only rule). Replaces the power-of-two rounding, + * whose waste was multiplicative instead of ≤31px per axis. + */ +describe("Text — 32px canvas buckets", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(320, 240, { + parent: "screen", + renderer: video.CANVAS, + }); + await app.init(); + }); + + const makeText = (str, size = 16) => { + return new Text(0, 0, { + font: "Arial", + size, + text: str, + fillStyle: "#ffffff", + }); + }; + + const bucket = (n) => { + return Math.ceil(n / 32) * 32; + }; + + it("the canvas lands exactly on the metric's 32px bucket (no power-of-two jumps)", () => { + const t = makeText("Hello World"); + const c = t.canvasTexture; + expect(c.width % 32).toBe(0); + expect(c.height % 32).toBe(0); + expect(c.width).toBe(bucket(t.metrics.width)); + expect(c.height).toBe(bucket(t.metrics.height)); + // waste is bounded additively — the whole point of the change + expect(c.width - t.metrics.width).toBeLessThan(32); + expect(c.height - t.metrics.height).toBeLessThan(32); + }); + + it("property sweep: every string's canvas is bucket-exact and minimal", () => { + const strings = [ + "a", + "ab", + "counter: 0", + "counter: 10", + "counter: 100", + "WWWWWWWW", + "iiiiiiii", + ".", + "— em dash —", + "0123456789".repeat(3), + "multi\nline\ntext", + "tall\ntext\nwith\nfour", + " leading and trailing ", + ]; + for (const str of strings) { + const t = makeText(str); + const c = t.canvasTexture; + expect(c.width % 32, str).toBe(0); + expect(c.height % 32, str).toBe(0); + expect(c.width, str).toBeGreaterThanOrEqual(Math.ceil(t.metrics.width)); + expect(c.width - t.metrics.width, str).toBeLessThan(32); + expect(c.height - t.metrics.height, str).toBeLessThan(32); + } + }); + + it("a ticking counter stays in the SAME bucket: same dimensions, same canvas element", () => { + const t = makeText("Score: 10"); + const c = t.canvasTexture; + const canvasEl = c.canvas; + const w = c.width; + const h = c.height; + // digits are near-uniform width — every tick must be a same-size + // re-bake into the SAME canvas (texture-cache identity stable) + for (const s of ["Score: 11", "Score: 12", "Score: 19", "Score: 18"]) { + t.setText(s); + expect(t.canvasTexture.canvas, s).toBe(canvasEl); + expect(t.canvasTexture.width, s).toBe(w); + expect(t.canvasTexture.height, s).toBe(h); + } + }); + + it("crossing a bucket boundary grows to the NEXT bucket, not a power of two", () => { + const t = makeText("x"); + const first = t.canvasTexture.width; + // grow the string until the canvas is forced past 128px — under + // POT rounding the next stop after 128 was 256; buckets must land + // on a 32 multiple strictly closer than doubling + let s = "x"; + while (t.metrics.width <= 128) { + s += "x"; + t.setText(s); + } + const grown = t.canvasTexture.width; + expect(grown).toBeGreaterThan(first); + expect(grown % 32).toBe(0); + expect(grown).toBe(bucket(t.metrics.width)); + expect(grown - t.metrics.width).toBeLessThan(32); + }); + + it("the canvas NEVER shrinks (grow-only hysteresis preserved)", () => { + const t = makeText("a much much longer string of text here"); + const grownW = t.canvasTexture.width; + const grownH = t.canvasTexture.height; + t.setText("."); + expect(t.canvasTexture.width).toBe(grownW); + expect(t.canvasTexture.height).toBe(grownH); + // and the bake is still marked for re-upload + expect(t.isDirty).toBe(true); + }); + + it("empty text never crashes and never resizes to zero", () => { + const t = makeText("something"); + const w = t.canvasTexture.width; + t.setText(""); + expect(t.canvasTexture.width).toBe(w); + expect(t.canvasTexture.width).toBeGreaterThan(0); + t.setText("back"); + expect(t.canvasTexture.width).toBeGreaterThan(0); + }); + + it("multiline growth buckets the HEIGHT independently of the width", () => { + const t = makeText("line"); + const w = t.canvasTexture.width; + const h = t.canvasTexture.height; + t.setText("line\nline\nline\nline\nline"); + expect(t.canvasTexture.width).toBe(w); // width metrics unchanged + expect(t.canvasTexture.height).toBeGreaterThan(h); + expect(t.canvasTexture.height % 32).toBe(0); + expect(t.canvasTexture.height - t.metrics.height).toBeLessThan(32); + }); + + it("a huge font size still buckets tightly (no multiplicative blow-up)", () => { + const t = makeText("BIG", 180); + const c = t.canvasTexture; + expect(c.width % 32).toBe(0); + expect(c.width - t.metrics.width).toBeLessThan(32); + expect(c.height - t.metrics.height).toBeLessThan(32); + // sanity: POT would have padded up to nearly 2× in the worst case; + // the bucket keeps total waste under one row/column of 32 + const waste = (c.width * c.height) / (t.metrics.width * t.metrics.height); + expect(waste).toBeLessThan(1.8); + }); +}); From cba8691fcb5c8ed4362bf87dd5cb47aa36a990e4 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 6 Aug 2026 15:24:50 +0800 Subject: [PATCH 2/2] tests: borrow the shared renderer in the #1554 specs (context-budget fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new spec files each booted their own Application into the shared vitest page — the exact anti-pattern the webgl-context helper documents: the session's GL context budget overflows and some UNRELATED late-running spec's beforeAll times out (renderTargetPool + texturecache-batcher-reset on CI, twice, deterministically). Both specs now borrow the session's single shared renderer with requireWebGL skip guards, adding zero contexts and zero live game loops to the shared page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/tests/gradient_bake.spec.js | 107 +++++++++++-------- packages/melonjs/tests/text_bucket.spec.js | 49 ++++++--- 2 files changed, 96 insertions(+), 60 deletions(-) diff --git a/packages/melonjs/tests/gradient_bake.spec.js b/packages/melonjs/tests/gradient_bake.spec.js index 1f93c2c33d..28dbd5fcc0 100644 --- a/packages/melonjs/tests/gradient_bake.spec.js +++ b/packages/melonjs/tests/gradient_bake.spec.js @@ -1,5 +1,10 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { Application, boot, Gradient, video } from "../src/index.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Gradient } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; /** * Adversarial coverage of the fixed-resolution gradient bake (#1554): @@ -10,15 +15,17 @@ import { Application, boot, Gradient, video } from "../src/index.js"; * `{canvas, width, height}` is the drawImage SOURCE rect contract. */ describe("Gradient.toCanvas — fixed-resolution bake", () => { - let app; + // borrow the session's single shared renderer — specs must never boot + // their own Application into the shared page (context-budget hazard, + // see helpers/webgl-context.js) + let renderer; beforeAll(async () => { - boot(); - app = new Application(200, 150, { - parent: "screen", - renderer: video.CANVAS, - }); - await app.init(); + renderer = await getWebGLRenderer(200, 150); + }); + + afterAll(() => { + releaseWebGLRenderer(); }); const px = (canvas, x, y) => { @@ -27,11 +34,12 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { .getImageData(Math.round(x), Math.round(y), 1, 1).data; }; - it("caps the bake: a huge rect returns a source rect ≤ 256 on a 256×256 canvas", () => { + it("caps the bake: a huge rect returns a source rect ≤ 256 on a 256×256 canvas", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 4000, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 4000, 3000); + const baked = g.toCanvas(renderer, 0, 0, 4000, 3000); expect(baked.canvas.width).toBe(256); expect(baked.canvas.height).toBe(256); expect(baked.width).toBeLessThanOrEqual(256); @@ -40,33 +48,36 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { expect(baked.height).toBeGreaterThan(0); }); - it("small rects bake 1:1 (exact source rect) — but the canvas never shrinks", () => { + it("small rects bake 1:1 (exact source rect) — but the canvas never shrinks", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 100, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 100, 50); + const baked = g.toCanvas(renderer, 0, 0, 100, 50); expect(baked.width).toBe(100); expect(baked.height).toBe(50); expect(baked.canvas.width).toBe(256); expect(baked.canvas.height).toBe(256); }); - it("the shared canvas element identity is STABLE across differently-sized bakes", () => { + it("the shared canvas element identity is STABLE across differently-sized bakes", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 10, 0]); g.addColorStop(0, "#ffffff"); g.addColorStop(1, "#000000"); - const a = g.toCanvas(app.renderer, 0, 0, 10, 10).canvas; - const b = g.toCanvas(app.renderer, 0, 0, 5000, 5000).canvas; - const c = g.toCanvas(app.renderer, 0, 0, 33.7, 12.2).canvas; + const a = g.toCanvas(renderer, 0, 0, 10, 10).canvas; + const b = g.toCanvas(renderer, 0, 0, 5000, 5000).canvas; + const c = g.toCanvas(renderer, 0, 0, 33.7, 12.2).canvas; expect(b).toBe(a); expect(c).toBe(a); }); - it("1:1 pixel correctness: a red→blue ramp is red at the start, blue at the end, mixed mid-way", () => { + it("1:1 pixel correctness: a red→blue ramp is red at the start, blue at the end, mixed mid-way", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 100, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 100, 20); + const baked = g.toCanvas(renderer, 0, 0, 100, 20); const start = px(baked.canvas, 1, 10); const mid = px(baked.canvas, 50, 10); const end = px(baked.canvas, 98, 10); @@ -78,11 +89,12 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { expect(mid[2]).toBeGreaterThan(60); }); - it("scaled-down bake keeps the ramp: endpoints correct, monotonic in between (no banding reversal)", () => { + it("scaled-down bake keeps the ramp: endpoints correct, monotonic in between (no banding reversal)", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 1024, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 1024, 64); + const baked = g.toCanvas(renderer, 0, 0, 1024, 64); expect(baked.width).toBe(256); const y = Math.floor(baked.height / 2); const startPx = px(baked.canvas, 0, y); @@ -100,25 +112,27 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { } }); - it("draw-rect offset is honored: gradient coordinates are absolute, the bake is rect-relative", () => { + it("draw-rect offset is honored: gradient coordinates are absolute, the bake is rect-relative", (ctx) => { + requireWebGL(ctx, renderer); // ramp lives at x = 500..600 in world space; baking the rect at // x = 500 must put the ramp START at canvas x = 0 const g = new Gradient("linear", [500, 0, 600, 0]); g.addColorStop(0, "#00ff00"); g.addColorStop(1, "#000000"); - const baked = g.toCanvas(app.renderer, 500, 0, 100, 10); + const baked = g.toCanvas(renderer, 500, 0, 100, 10); expect(px(baked.canvas, 1, 5)[1]).toBeGreaterThan(230); expect(px(baked.canvas, 98, 5)[1]).toBeLessThan(30); }); - it("radial: the center texel carries the inner stop, even under non-uniform downscale", () => { + it("radial: the center texel carries the inner stop, even under non-uniform downscale", (ctx) => { + requireWebGL(ctx, renderer); // a circle centered in a wide rect: x is downscaled (1000 → 256), // y is 1:1 — the bake is elliptical, the destination stretch // restores it; the CENTER color must survive the transform const g = new Gradient("radial", [500, 50, 0, 500, 50, 40]); g.addColorStop(0, "#ffff00"); g.addColorStop(1, "#000000"); - const baked = g.toCanvas(app.renderer, 0, 0, 1000, 100); + const baked = g.toCanvas(renderer, 0, 0, 1000, 100); const cx = (500 / 1000) * baked.width; const cy = (50 / 100) * baked.height; const center = px(baked.canvas, cx, cy); @@ -129,39 +143,42 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { expect(outside[0]).toBeLessThan(30); }); - it("re-baking the same rect skips the repaint (scribble survives)", () => { + it("re-baking the same rect skips the repaint (scribble survives)", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 64, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 64, 64); + const baked = g.toCanvas(renderer, 0, 0, 64, 64); // vandalize a pixel, then ask for the exact same bake again baked.canvas.getContext("2d").fillStyle = "#00ff00"; baked.canvas.getContext("2d").fillRect(10, 10, 1, 1); - const again = g.toCanvas(app.renderer, 0, 0, 64, 64); + const again = g.toCanvas(renderer, 0, 0, 64, 64); expect(px(again.canvas, 10, 10)[1]).toBeGreaterThan(230); // survived → no repaint }); - it("a color-stop change dirties the gradient and forces a repaint", () => { + it("a color-stop change dirties the gradient and forces a repaint", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 64, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 64, 64); + const baked = g.toCanvas(renderer, 0, 0, 64, 64); baked.canvas.getContext("2d").fillStyle = "#00ff00"; baked.canvas.getContext("2d").fillRect(10, 10, 1, 1); g.addColorStop(0.5, "#ff00ff"); // marks dirty - const again = g.toCanvas(app.renderer, 0, 0, 64, 64); + const again = g.toCanvas(renderer, 0, 0, 64, 64); expect(px(again.canvas, 10, 10)[1]).toBeLessThan(200); // repainted over }); - it("the SAME gradient at a different rect SIZE repaints (fixed canvas can't alias sizes)", () => { + it("the SAME gradient at a different rect SIZE repaints (fixed canvas can't alias sizes)", (ctx) => { + requireWebGL(ctx, renderer); // adversarial: the old reuse check compared canvas dimensions, which // are now constant — reusing here would serve a 64-wide bake for a // 128-wide request. The rect itself must be the identity. const g = new Gradient("linear", [0, 0, 128, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - g.toCanvas(app.renderer, 0, 0, 64, 64); - const wide = g.toCanvas(app.renderer, 0, 0, 128, 64); + g.toCanvas(renderer, 0, 0, 64, 64); + const wide = g.toCanvas(renderer, 0, 0, 128, 64); // at x=100 the 128-wide ramp is mostly blue; a stale 64-wide bake // would have padding-extended full blue at 100 too — so probe x=60: // 128-ramp at 60/128 is mixed, stale 64-ramp at 60/64 is near-blue @@ -169,7 +186,8 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { expect(probe[0]).toBeGreaterThan(80); // red still present → fresh bake }); - it("alternating two gradients over the shared target repaints each time", () => { + it("alternating two gradients over the shared target repaints each time", (ctx) => { + requireWebGL(ctx, renderer); const red = new Gradient("linear", [0, 0, 64, 0]); red.addColorStop(0, "#ff0000"); red.addColorStop(1, "#ff0000"); @@ -178,15 +196,16 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { blue.addColorStop(1, "#0000ff"); for (let i = 0; i < 3; i++) { expect( - px(red.toCanvas(app.renderer, 0, 0, 64, 64).canvas, 32, 32)[0], + px(red.toCanvas(renderer, 0, 0, 64, 64).canvas, 32, 32)[0], ).toBeGreaterThan(230); expect( - px(blue.toCanvas(app.renderer, 0, 0, 64, 64).canvas, 32, 32)[2], + px(blue.toCanvas(renderer, 0, 0, 64, 64).canvas, 32, 32)[2], ).toBeGreaterThan(230); } }); - it("degenerate rects never crash and clamp to a ≥1×≥1 source", () => { + it("degenerate rects never crash and clamp to a ≥1×≥1 source", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 10, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); @@ -196,27 +215,29 @@ describe("Gradient.toCanvas — fixed-resolution bake", () => { [-5, 10], [1, 0], ]) { - const baked = g.toCanvas(app.renderer, 0, 0, w, h); + const baked = g.toCanvas(renderer, 0, 0, w, h); expect(baked.width).toBeGreaterThanOrEqual(1); expect(baked.height).toBeGreaterThanOrEqual(1); expect(baked.canvas.width).toBe(256); } }); - it("fractional rects keep the exact fractional source size on the 1:1 path", () => { + it("fractional rects keep the exact fractional source size on the 1:1 path", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 100, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 100.7, 33.3); + const baked = g.toCanvas(renderer, 0, 0, 100.7, 33.3); expect(baked.width).toBeCloseTo(100.7, 5); expect(baked.height).toBeCloseTo(33.3, 5); }); - it("the padding beyond the source rect carries the EXTENDED gradient, not transparency (edge-filter parity)", () => { + it("the padding beyond the source rect carries the EXTENDED gradient, not transparency (edge-filter parity)", (ctx) => { + requireWebGL(ctx, renderer); const g = new Gradient("linear", [0, 0, 64, 0]); g.addColorStop(0, "#ff0000"); g.addColorStop(1, "#0000ff"); - const baked = g.toCanvas(app.renderer, 0, 0, 64, 64); + const baked = g.toCanvas(renderer, 0, 0, 64, 64); // one texel past the used region on both axes must be opaque // (the clamped end color), or linear filtering at the source-rect // edge would bleed transparency into a stretched draw diff --git a/packages/melonjs/tests/text_bucket.spec.js b/packages/melonjs/tests/text_bucket.spec.js index 38581f50b4..5bc2f94ba1 100644 --- a/packages/melonjs/tests/text_bucket.spec.js +++ b/packages/melonjs/tests/text_bucket.spec.js @@ -1,5 +1,10 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { Application, boot, Text, video } from "../src/index.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Text } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; /** * Adversarial coverage of the 32-pixel text-canvas buckets (#1554): @@ -10,15 +15,17 @@ import { Application, boot, Text, video } from "../src/index.js"; * whose waste was multiplicative instead of ≤31px per axis. */ describe("Text — 32px canvas buckets", () => { - let app; + // borrow the session's single shared renderer — specs must never boot + // their own Application into the shared page (context-budget hazard, + // see helpers/webgl-context.js) + let renderer; beforeAll(async () => { - boot(); - app = new Application(320, 240, { - parent: "screen", - renderer: video.CANVAS, - }); - await app.init(); + renderer = await getWebGLRenderer(320, 240); + }); + + afterAll(() => { + releaseWebGLRenderer(); }); const makeText = (str, size = 16) => { @@ -34,7 +41,8 @@ describe("Text — 32px canvas buckets", () => { return Math.ceil(n / 32) * 32; }; - it("the canvas lands exactly on the metric's 32px bucket (no power-of-two jumps)", () => { + it("the canvas lands exactly on the metric's 32px bucket (no power-of-two jumps)", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("Hello World"); const c = t.canvasTexture; expect(c.width % 32).toBe(0); @@ -46,7 +54,8 @@ describe("Text — 32px canvas buckets", () => { expect(c.height - t.metrics.height).toBeLessThan(32); }); - it("property sweep: every string's canvas is bucket-exact and minimal", () => { + it("property sweep: every string's canvas is bucket-exact and minimal", (ctx) => { + requireWebGL(ctx, renderer); const strings = [ "a", "ab", @@ -73,7 +82,8 @@ describe("Text — 32px canvas buckets", () => { } }); - it("a ticking counter stays in the SAME bucket: same dimensions, same canvas element", () => { + it("a ticking counter stays in the SAME bucket: same dimensions, same canvas element", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("Score: 10"); const c = t.canvasTexture; const canvasEl = c.canvas; @@ -89,7 +99,8 @@ describe("Text — 32px canvas buckets", () => { } }); - it("crossing a bucket boundary grows to the NEXT bucket, not a power of two", () => { + it("crossing a bucket boundary grows to the NEXT bucket, not a power of two", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("x"); const first = t.canvasTexture.width; // grow the string until the canvas is forced past 128px — under @@ -107,7 +118,8 @@ describe("Text — 32px canvas buckets", () => { expect(grown - t.metrics.width).toBeLessThan(32); }); - it("the canvas NEVER shrinks (grow-only hysteresis preserved)", () => { + it("the canvas NEVER shrinks (grow-only hysteresis preserved)", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("a much much longer string of text here"); const grownW = t.canvasTexture.width; const grownH = t.canvasTexture.height; @@ -118,7 +130,8 @@ describe("Text — 32px canvas buckets", () => { expect(t.isDirty).toBe(true); }); - it("empty text never crashes and never resizes to zero", () => { + it("empty text never crashes and never resizes to zero", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("something"); const w = t.canvasTexture.width; t.setText(""); @@ -128,7 +141,8 @@ describe("Text — 32px canvas buckets", () => { expect(t.canvasTexture.width).toBeGreaterThan(0); }); - it("multiline growth buckets the HEIGHT independently of the width", () => { + it("multiline growth buckets the HEIGHT independently of the width", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("line"); const w = t.canvasTexture.width; const h = t.canvasTexture.height; @@ -139,7 +153,8 @@ describe("Text — 32px canvas buckets", () => { expect(t.canvasTexture.height - t.metrics.height).toBeLessThan(32); }); - it("a huge font size still buckets tightly (no multiplicative blow-up)", () => { + it("a huge font size still buckets tightly (no multiplicative blow-up)", (ctx) => { + requireWebGL(ctx, renderer); const t = makeText("BIG", 180); const c = t.canvasTexture; expect(c.width % 32).toBe(0);