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
6 changes: 6 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
- **`moveTowards()` never reached its target — and returned the target instead of `this`** (all four vector classes) — the arrival test compared the remaining distance against `step * step` (wrong units: snapped way too early for steps above 1, dithered around the target forever for fractional steps), and on "arrival" it returned the **caller's target vector** without moving `this` at all — so the vector never landed, and chaining onto the result silently mutated the target. It now lands exactly on the target and always returns `this`, per its own documentation; negative-step flee semantics are unchanged.
- **Every preloaded video was fetched with forced anonymous CORS** — the video parser unconditionally stamped the `crossorigin` attribute with the loader's `crossOrigin` setting, which defaults to `undefined` — and per the HTML spec an *invalid* value (the string `"undefined"`) maps to `anonymous`, unlike a *missing* attribute (no CORS). Cross-origin videos served without CORS headers failed to load outright when they would have played fine untainted. The attribute is now only set when `crossOrigin` is actually configured (the empty string remains a valid, honored value).
- **Video preloading hung forever on autoplay-restricted browsers (e.g. iOS Safari) unless `autoplay: false` was spelled out** — the parser waited for `canplay`, which those browsers never fire for a video that isn't allowed to play, and only an *explicit* `autoplay: false` opted into the reliable `loadedmetadata` path — so the common manifest shape (no `autoplay` key) stalled the whole preloader and the game never started. Preloading now completes on `loadedmetadata` unless `autoplay: true` explicitly asks to wait for `canplay`.
- **Compressed-texture loading crashed on any device missing one compression family** — six `WEBKIT_`-prefixed extension fallbacks in `getSupportedCompressedTextureFormats()` referenced `this._gl`, a field that is never assigned (the context lives in `this.gl`). The fallback only evaluates when the primary extension is absent, so the whole feature *worked on ANGLE/Metal Macs* (which expose every family) *and threw a TypeError everywhere else* — Windows (no ASTC), iOS (no S3TC), software renderers — killing every `.dds`/`.pvr`/`.pkm`/`.ktx`/`.ktx2` load.
- **Nested multi-effect post-effects corrupted the render-target pool and crashed every frame** — a renderable with 2+ `postEffects` drawn inside a container with 2+ `postEffects` hit a half-guarded nesting path: the inner pass cleared the outer pass's capture target, its `end` popped the outer pass's pool slot, and the outer `endPostEffect` then dereferenced `undefined` (per-frame TypeError; with camera effects active it silently blitted the wrong texture instead). The pool now tracks a proper stack of passes — each nesting level gets its own lazily-allocated capture/ping-pong pair — and the saved effect projection is a per-pass stack too, so nested post-effect passes compose correctly.
- **Gradient shape fills (`fillEllipse`/`fillPolygon`/`fillArc`/`fillRoundRect`) broke inverted and nested masks** — the internal stencil pass gated its write phase on the *hidden* region of an inverted `setMask` (painting the gradient inside the cutout instead of the visible area) and restored a `NOTEQUAL` parity test instead of the mask's actual test — inverting the mask for every subsequent draw, and leaking outside-all-masks pixels for nested masks. The pass now tags the shape's visible pixels with a high stencil bit (collision-free with mask levels), clips the gradient to the tag, and re-installs the exact stencil test `setMask` had established. (`fillRect` gradients were unaffected — they render through a textured quad.)
- **`disableScissor()` didn't flush, so sprites batched inside the scissor escaped it** — GL scissor applies at draw time, not at batch time; every other scissor mutation (`enableScissor`, `clipRect`, `restore`) flushes pending vertices first, but `disableScissor` didn't — so the common `enableScissor → drawImage → disableScissor` pattern clipped nothing: the quads were still queued when the test was turned off.
- **WebGL `clearRect()` painted opaque black instead of erasing** — its JSDoc (and the Canvas renderer) promise transparent black, but the internal `clearColor()` default (`"#000000"`) parses with alpha 1. Visible on `transparent: true` canvases and inside post-effect render targets, where the "erased" region composited as a solid black box.
- **Canvas `clipRect()` skip-optimizations ignored the active transform** — the "full-viewport no-op" and "same-as-last-clip" early-outs compared the rect's raw *local* values, so a canvas-sized clipped container positioned off-origin got no clip at all, and a clipped container nested inside a same-sized clipped container skipped its own clip via the stale cache — both Canvas-only divergences from the transform-aware WebGL scissor path (the #1349 fix). The skip logic now reasons in device space (and always clips under rotation/skew, where an axis-aligned cache can't).
- **`Path2D.arc()`/`arcTo()`/`ellipse()` appended to an open path started a new sub-path instead of connecting** — the native Path2D spec joins an arc to the current point with a straight line (`arcTo`'s own documentation even promises it), but all three called `moveTo()` unconditionally. Under this release's new multi-sub-path fill semantics that stray sub-path is treated as a **hole**, so an arc appended to a filled outline punched a hole in the shape. They now connect per the spec; the canonical circle-hole idiom (explicit `moveTo` before `arc()`) still registers a hole, and a virgin-path `arc()` still starts cleanly.
- **`Rect.right`/`Rect.bottom` returned the width/height when the edge sat exactly at coordinate 0** — the getters computed `this.left + w || w`, so an edge landing precisely on 0 (falsy) fell back to the size. Bounds and collision math around the origin came out wrong for such rectangles.
- **`Rect.toPolygon()` handed out the rectangle's live vertices** — `Polygon.setVertices` stores a `Vector2d[]` by reference, so the "new" polygon shared the rect's actual points array: transforming or mutating the polygon silently corrupted the rectangle. The vertices are now cloned.
Expand Down
70 changes: 53 additions & 17 deletions packages/melonjs/src/video/canvas/canvas_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1255,31 +1255,67 @@ export default class CanvasRenderer extends Renderer {
* @param {number} height
*/
clipRect(x, y, width, height) {
const context = this.getContext();
const t = context.getTransform();

Comment on lines +1258 to +1260
// The two skip-optimizations below (full-viewport no-op and
// same-as-last-clip) must reason in DEVICE space — the rect arrives
// in LOCAL coordinates (e.g. Container clipping translates first,
// then clips at (0, 0)), so comparing raw values against the canvas
// box or the cache is wrong under any transform. The WebGL renderer
// transforms the rect the same way (see the #1349 fix).
if (t.b !== 0 || t.c !== 0) {
// rotated/skewed: the axis-aligned skip logic can't reason about
// the clipped region — always clip, and poison the cache so a
// following axis-aligned call can't false-match. The cache is an
// Int32Array (NaN would coerce to 0!) and widths are normalized
// to >= 0 below, so -1 in the width slot can never match.
context.beginPath();
context.rect(x, y, width, height);
context.clip();
this.currentScissor[2] = -1;
return;
}

// axis-aligned device-space box (scale can be negative — normalize),
// floored/ceiled to integers so the Int32Array cache is exact under
// fractional transforms (same discipline as the WebGL clipRect)
let dx = x * t.a + t.e;
let dy = y * t.d + t.f;
let dw = width * t.a;
let dh = height * t.d;
if (dw < 0) {
dx += dw;
dw = -dw;
}
if (dh < 0) {
dy += dh;
dh = -dh;
}
const ix = Math.floor(dx);
const iy = Math.floor(dy);
const iw = Math.ceil(dx + dw) - ix;
const ih = Math.ceil(dy + dh) - iy;

const canvas = this.getCanvas();
// if requested box is different from the current canvas size;
if (
x !== 0 ||
y !== 0 ||
width !== canvas.width ||
height !== canvas.height
) {
// if the requested box is different from the full canvas;
if (ix !== 0 || iy !== 0 || iw !== canvas.width || ih !== canvas.height) {
const currentScissor = this.currentScissor;
// if different from the current scissor box
if (
currentScissor[0] !== x ||
currentScissor[1] !== y ||
currentScissor[2] !== width ||
currentScissor[3] !== height
currentScissor[0] !== ix ||
currentScissor[1] !== iy ||
currentScissor[2] !== iw ||
currentScissor[3] !== ih
) {
const context = this.getContext();
context.beginPath();
context.rect(x, y, width, height);
context.clip();
// save the new currentScissor box
currentScissor[0] = x;
currentScissor[1] = y;
currentScissor[2] = width;
currentScissor[3] = height;
// save the new scissor box in device space
currentScissor[0] = ix;
currentScissor[1] = iy;
currentScissor[2] = iw;
currentScissor[3] = ih;
}
}
}
Expand Down
73 changes: 49 additions & 24 deletions packages/melonjs/src/video/rendertarget/render_target_pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
* Renderer-agnostic — the actual RenderTarget creation is delegated to a
* factory function provided by the renderer (WebGL, WebGPU, etc.).
*
* Camera effects use pool indices 0 and 1 (capture + ping-pong),
* sprite effects use indices 2 and 3.
* Camera effects use pool indices 0 and 1 (capture + ping-pong); sprite
* effects use indices 2 and 3, and each NESTED sprite pass (a multi-effect
* renderable drawn inside another multi-effect renderable) gets its own pair
* above that (4/5, 6/7, …), tracked by a stack of active bases so begin/end
* pairs compose like save/restore.
* Render targets are lazily created and resized to match the required dimensions.
* @ignore
*/
Expand All @@ -21,10 +24,23 @@ export default class RenderTargetPool {
this._factory = factory;
/** @type {RenderTarget[]} */
this._pool = [];
/** @type {number} */
this._activeBase = -1;
/** @type {number} */
this._previousBase = -1;
/**
* active pass bases, innermost last — a STACK, so nested begin/end
* pairs unwind correctly (two scalars silently corrupted the pool on
* nested passes: the inner end() popped the outer pass's slot)
* @type {number[]}
*/
this._baseStack = [];
}

/**
* The base index of the innermost active pass, or -1 when none.
* @returns {number}
*/
get activeBase() {
return this._baseStack.length > 0
? this._baseStack[this._baseStack.length - 1]
: -1;
}

/**
Expand Down Expand Up @@ -53,16 +69,24 @@ export default class RenderTargetPool {
* @returns {RenderTarget} the capture target (ready to bind)
*/
begin(isCamera, effectCount, width, height) {
const newBase = isCamera ? 0 : 2;
// guard against nested sprite post-effect passes (not yet supported)
if (!isCamera && this._activeBase === newBase) {
return this.get(this._activeBase, width, height);
let newBase;
if (isCamera) {
newBase = 0;
} else {
// each nested sprite pass gets its own capture/ping-pong pair
// (2/3, then 4/5, 6/7, … — lazily allocated)
let depth = 0;
for (const base of this._baseStack) {
if (base >= 2) {
depth++;
}
}
newBase = 2 + depth * 2;
}
this._previousBase = this._activeBase;
this._activeBase = newBase;
const rt = this.get(this._activeBase, width, height);
this._baseStack.push(newBase);
const rt = this.get(newBase, width, height);
if (effectCount > 1) {
this.get(this._activeBase + 1, width, height);
this.get(newBase + 1, width, height);
}
return rt;
}
Expand All @@ -72,21 +96,23 @@ export default class RenderTargetPool {
* @returns {RenderTarget|undefined} the capture target, or undefined if no active pass
*/
getCaptureTarget() {
if (this._activeBase < 0) {
const base = this.activeBase;
if (base < 0) {
return undefined;
}
return this._pool[this._activeBase];
return this._pool[base];
}

/**
* Get the ping-pong render target for the current active pass.
* @returns {RenderTarget|undefined} the ping-pong target, or undefined if no active pass
*/
getPingPongTarget() {
if (this._activeBase < 0) {
const base = this.activeBase;
if (base < 0) {
return undefined;
}
return this._pool[this._activeBase + 1];
return this._pool[base + 1];
}

/**
Expand All @@ -95,10 +121,10 @@ export default class RenderTargetPool {
* @returns {RenderTarget|null} the parent target, or null if returning to screen
*/
end() {
this._activeBase = this._previousBase;
this._previousBase = -1;
if (this._activeBase >= 0 && this._pool[this._activeBase]) {
return this._pool[this._activeBase];
this._baseStack.pop();
const base = this.activeBase;
if (base >= 0 && this._pool[base]) {
return this._pool[base];
}
return null;
}
Expand Down Expand Up @@ -126,7 +152,6 @@ export default class RenderTargetPool {
}
}
this._pool.length = 0;
this._activeBase = -1;
this._previousBase = -1;
this._baseStack.length = 0;
}
}
Loading
Loading