Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 11 additions & 5 deletions packages/melonjs/src/renderable/text/text.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 9 additions & 9 deletions packages/melonjs/src/video/canvas/canvas_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 60 additions & 25 deletions packages/melonjs/src/video/gradient.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,32 @@ 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
*/
let sharedRenderTarget = null;
let sharedLastId = -1;
let sharedLastX = NaN;
let sharedLastY = NaN;
let sharedLastW = NaN;
let sharedLastH = NaN;
let nextGradientId = 0;

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
19 changes: 16 additions & 3 deletions packages/melonjs/src/video/webgl/webgl_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
18 changes: 15 additions & 3 deletions packages/melonjs/src/video/webgpu/webgpu_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
21 changes: 12 additions & 9 deletions packages/melonjs/tests/gradient.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
});
});

Expand Down
Loading
Loading