From 98aa8d6da2cce69a0687c4b1159174486d98c108 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 10:53:26 +0800 Subject: [PATCH 1/7] Remove depthTest setting, improve container sorting, clean up renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPU depth sorting is incompatible with 2D alpha blending — the depth buffer and painter's algorithm cannot coexist for transparent sprites. The "z-buffer" option never worked correctly and has been removed. Changes: - Remove depthTest application setting, DepthTest type, and z-buffer option from settings, defaults, header, and renderer initialization - Simplify WebGL renderer: depth test disabled by default, only enabled temporarily inside drawMesh() for 3D mesh rendering - Remove depth buffer from clear() (drawMesh clears it locally) - Move customShader property to base Renderer class so Renderable preDraw/postDraw no longer checks renderer type via renderer.gl - Container: cache sort comparator via sortOn getter/setter - Container: simplify sort comparators, remove legacy null guards - Remove depthTest: "z-buffer" from platformer example - Add tests for sort comparators and sortOn caching Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/examples/platformer/createGame.ts | 1 - packages/melonjs/CHANGELOG.md | 8 ++- .../melonjs/src/application/application.ts | 6 -- .../application/defaultApplicationSettings.ts | 1 - packages/melonjs/src/application/header.ts | 6 +- packages/melonjs/src/application/settings.ts | 8 --- packages/melonjs/src/renderable/container.js | 35 ++++++----- packages/melonjs/src/renderable/renderable.js | 4 +- packages/melonjs/src/video/renderer.js | 15 ++--- .../melonjs/src/video/webgl/webgl_renderer.js | 34 +++-------- packages/melonjs/tests/container.spec.js | 61 +++++++++++++++++++ 11 files changed, 109 insertions(+), 70 deletions(-) diff --git a/packages/examples/src/examples/platformer/createGame.ts b/packages/examples/src/examples/platformer/createGame.ts index 2d77899a0..a13ff2e63 100644 --- a/packages/examples/src/examples/platformer/createGame.ts +++ b/packages/examples/src/examples/platformer/createGame.ts @@ -26,7 +26,6 @@ export const createGame = () => { scaleMethod: "flex-width", renderer: AUTO, preferWebGL1: false, - depthTest: "z-buffer", subPixel: false, }); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index c9252167b..b57879a92 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -30,13 +30,17 @@ - `Renderable.rotate(angle, v)` defaults to Z axis when no axis given (same 2D behavior) - `Renderable.scale(x, y, z)` defaults z to 1 (preserves Z dimension) - `QuadBatcher` and `MeshBatcher` now extend `MaterialBatcher` (was `Batcher`) — eliminates ~180 lines of duplicated texture management code -- WebGL context always created with `depth: true` for hardware depth buffer support -- WebGL `clear()` now always clears depth + color + stencil buffers +- WebGL context always created with `depth: true` for hardware depth buffer support (used by 3D mesh rendering) - WebGL renderer `setBatcher()` now syncs the projection matrix to the new batcher - **BREAKING**: `Text.draw()` and `BitmapText.draw()` no longer accept `text`, `x`, `y` parameters — standalone draw without a parent container is removed (deprecated since 10.6.0) - **BREAKING**: `Text.measureText()` no longer takes a `renderer` parameter (was unused) - **BREAKING**: `UITextButton` settings `backgroundColor` and `hoverColor` removed — use `hoverOffColor` and `hoverOnColor` instead - **BREAKING**: `Tween` no longer adds itself to `game.world` — uses event-based lifecycle (`TICK`, `GAME_AFTER_UPDATE`, `STATE_PAUSE`, `STATE_RESUME`, `GAME_RESET`) instead. Public API unchanged. `isPersistent` and `updateWhenPaused` properties still supported. +- **BREAKING**: removed `depthTest` application setting and `DepthTest` type — GPU depth sorting is incompatible with 2D alpha blending and the painter's algorithm. The `"z-buffer"` option never worked correctly for 2D sprites. Depth testing remains available internally for 3D mesh rendering only (`drawMesh`). +- Container: `sortOn` is now a getter/setter that caches the comparator function — avoids string lookup on each sort call +- Container: sort comparators simplified — removed legacy null guards (children always have `pos`) +- Renderer: `customShader` property moved to base `Renderer` class — `Renderable.preDraw`/`postDraw` no longer check renderer type via `renderer.gl` +- WebGL: `clear()` no longer clears the depth buffer (only used by `drawMesh` which clears it locally) ### Fixed - Geometry: `Rect.setSize()` now calls `updateBounds()` — fixes a regression from July 2024 (`4d185c902`) where replacing `Rect.setShape()` with `pos.set()` + `setSize()` during the TypeScript conversion left bounds stale, causing pointer event broadphase lookups to use `(0,0)` instead of the actual pointer position (see #817) diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 273780381..c33285a11 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -244,7 +244,6 @@ export default class Application { scale: autoScale ? 1.0 : +merged.scale || 1.0, zoomX: 0, zoomY: 0, - depthTest: merged.depthTest === "z-buffer" ? "z-buffer" : "sorting", scaleMethod: /^(fill-(min|max)|fit|flex(-(width|height))?|stretch)$/.test( merged.scaleMethod, ) @@ -383,11 +382,6 @@ export default class Application { // app starting time this.lastUpdate = globalThis.performance.now(); - // manually sort child if depthTest setting is "sorting" - this.world.autoSort = !( - this.renderer.type === "WEBGL" && this.settings.depthTest === "z-buffer" - ); - // only register event listeners once per instance if (!this.isInitialized) { /* eslint-disable @typescript-eslint/unbound-method */ diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index 28a732906..d1b55b885 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -7,7 +7,6 @@ export const defaultApplicationSettings = { scale: 1.0, scaleMethod: ScaleMethods.Manual, preferWebGL1: false, - depthTest: "sorting", powerPreference: "default", transparent: false, antiAlias: false, diff --git a/packages/melonjs/src/application/header.ts b/packages/melonjs/src/application/header.ts index c5dfe598f..98d56e95e 100644 --- a/packages/melonjs/src/application/header.ts +++ b/packages/melonjs/src/application/header.ts @@ -11,15 +11,11 @@ export function consoleHeader(app: Application): void { typeof app.renderer.GPURenderer === "string" ? ` (${app.renderer.GPURenderer})` : ""; - const depthTesting = - renderType.includes("WebGL") && app.renderer.depthTest === "z-buffer" - ? "Depth Test | " - : ""; const audioType = device.hasWebAudio ? "Web Audio" : "HTML5 Audio"; // output video information in the console console.log( - `${renderType} renderer${gpu_renderer} | ${depthTesting}${audioType} | ` + + `${renderType} renderer${gpu_renderer} | ${audioType} | ` + `pixel ratio ${device.devicePixelRatio} | ${ device.platform.nodeJS ? "node.js" diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 37c5e0059..a0ff4662c 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -14,8 +14,6 @@ type PhysicsType = "builtin" | "none"; type PowerPreference = "default" | "low-power"; -type DepthTest = "sorting" | "z-buffer"; - export type ApplicationSettings = { /** * renderer to use (CANVAS, WEBGL, AUTO), or a custom renderer class @@ -46,12 +44,6 @@ export type ApplicationSettings = { */ preferWebGL1: boolean; - /** - * ~Experimental~ the default method to sort object on the z axis in WebGL - * @default sorting - */ - depthTest: DepthTest; - /** * a hint to the user agent indicating what configuration of GPU is suitable for the WebGL context. To be noted that Safari and Chrome (since version 80) both default to "low-power" to save battery life and improve the user experience on these dual-GPU machines. * @default default diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 7db5d191e..e9bbc65bb 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -88,7 +88,8 @@ export default class Container extends Renderable { * @type {string} * @default "z" */ - this.sortOn = "z"; + this._sortOn = "z"; + this._comparator = this._sortZ; /** * Specify if the children list should be automatically sorted when adding a new child @@ -169,6 +170,20 @@ export default class Container extends Renderable { } } + /** + * The property of the child object that should be used to sort on this container. + * Accepted values: "x", "y", "z" + * @type {string} + * @default "z" + */ + get sortOn() { + return this._sortOn; + } + set sortOn(value) { + this._sortOn = value; + this._comparator = this["_sort" + value.toUpperCase()]; + } + /** * reset the container, removing all children, and resetting transforms. */ @@ -804,7 +819,7 @@ export default class Container extends Renderable { } this.pendingSort = defer(function () { // sort everything in this container - this.getChildren().sort(this["_sort" + this.sortOn.toUpperCase()]); + this.getChildren().sort(this._comparator); // clear the defer id this.pendingSort = null; // make sure we redraw everything @@ -829,7 +844,7 @@ export default class Container extends Renderable { * @ignore */ _sortZ(a, b) { - return b.pos && a.pos ? b.pos.z - a.pos.z : a.pos ? -Infinity : Infinity; + return b.pos.z - a.pos.z; } /** @@ -837,7 +852,7 @@ export default class Container extends Renderable { * @ignore */ _sortReverseZ(a, b) { - return a.pos && b.pos ? a.pos.z - b.pos.z : a.pos ? Infinity : -Infinity; + return a.pos.z - b.pos.z; } /** @@ -845,11 +860,7 @@ export default class Container extends Renderable { * @ignore */ _sortX(a, b) { - if (!b.pos || !a.pos) { - return a.pos ? -Infinity : Infinity; - } - const result = b.pos.z - a.pos.z; - return result ? result : b.pos.x - a.pos.x; + return b.pos.z - a.pos.z || b.pos.x - a.pos.x; } /** @@ -857,11 +868,7 @@ export default class Container extends Renderable { * @ignore */ _sortY(a, b) { - if (!b.pos || !a.pos) { - return a.pos ? -Infinity : Infinity; - } - const result = b.pos.z - a.pos.z; - return result ? result : b.pos.y - a.pos.y; + return b.pos.z - a.pos.z || b.pos.y - a.pos.y; } /** diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index bd32b60a8..75d8e64f5 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -752,7 +752,7 @@ export default class Renderable extends Rect { } // use this renderable shader if defined - if (this.shader && typeof renderer.gl !== "undefined") { + if (this.shader) { renderer.customShader = this.shader; } @@ -809,7 +809,7 @@ export default class Renderable extends Rect { } // revert to the default shader if defined - if (this.shader && typeof renderer.gl !== "undefined") { + if (this.shader) { renderer.customShader = undefined; } diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 9fd91b8d7..baa4cdce2 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -67,19 +67,20 @@ export default class Renderer { */ this.isContextValid = true; - /** - * the default method to sort object ("sorting", "z-buffer") - * @type {string} - * @default "sorting" - */ - this.depthTest = "sorting"; - /** * The GPU renderer string (WebGL only, undefined for Canvas) * @type {string|undefined} */ this.GPURenderer = undefined; + /** + * an optional custom shader to use instead of the default one. + * Set by a renderable's preDraw when a shader is assigned. + * (WebGL only, ignored by Canvas renderer) + * @type {GLShader|ShaderEffect|undefined} + */ + this.customShader = undefined; + /** * The Path2D instance used by the renderer to draw primitives * @type {Path2D} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 6643d4d2a..8547f5a27 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -151,19 +151,11 @@ export default class WebGLRenderer extends Renderer { this.addBatcher(new (CustomBatcher || PrimitiveBatcher)(this), "primitive"); this.addBatcher(new MeshBatcher(this), "mesh"); - // depth Test settings - this.depthTest = options.depthTest; - // default WebGL state(s) - if (this.depthTest === "z-buffer") { - this.gl.enable(this.gl.DEPTH_TEST); - // https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/depthFunc - this.gl.depthFunc(this.gl.LEQUAL); - this.gl.depthMask(true); - } else { - this.gl.disable(this.gl.DEPTH_TEST); - this.gl.depthMask(false); - } + // depth testing disabled for 2D (painter's algorithm handles z-ordering). + // drawMesh() enables it temporarily for 3D mesh rendering. + this.gl.disable(this.gl.DEPTH_TEST); + this.gl.depthMask(false); this.gl.disable(this.gl.SCISSOR_TEST); this.gl.enable(this.gl.BLEND); @@ -180,8 +172,7 @@ export default class WebGLRenderer extends Renderer { ); } - // an optional custom shader set by a renderable's preDraw - this.customShader = undefined; + // customShader is declared on the base Renderer class // Create a texture cache this.cache = new TextureCache(this, this.maxTextures); @@ -485,8 +476,8 @@ export default class WebGLRenderer extends Renderer { const clearColor = this.backgroundColor.toArray(); gl.clearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]); this.lineWidth = 1; - // always clear depth + color + stencil (depth buffer is always available) - gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); + // clear color + stencil (depth buffer is only used by drawMesh, which clears it locally) + gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); } /** @@ -649,9 +640,7 @@ export default class WebGLRenderer extends Renderer { this.currentBatcher.useShader(this.customShader); } - // save current depth state and force depth testing for 3D mesh - const depthWasEnabled = this.depthTest === "z-buffer"; - + // enable depth testing for 3D mesh rendering gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LESS); gl.depthMask(true); @@ -682,11 +671,8 @@ export default class WebGLRenderer extends Renderer { // restore blending and depth state gl.enable(gl.BLEND); - - if (!depthWasEnabled) { - gl.disable(gl.DEPTH_TEST); - gl.depthMask(false); - } + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); // revert to default shader if custom was applied if (typeof this.customShader === "object") { diff --git a/packages/melonjs/tests/container.spec.js b/packages/melonjs/tests/container.spec.js index a8059c267..5284a4a48 100644 --- a/packages/melonjs/tests/container.spec.js +++ b/packages/melonjs/tests/container.spec.js @@ -654,6 +654,67 @@ describe("Container", () => { }); }); + describe("sort comparators", () => { + it("_sortZ should sort descending by z", () => { + const a = new Renderable(0, 0, 1, 1); + const b = new Renderable(0, 0, 1, 1); + a.pos.z = 5; + b.pos.z = 10; + expect(container._sortZ(a, b)).toBeGreaterThan(0); // b first + expect(container._sortZ(b, a)).toBeLessThan(0); // b still first + }); + + it("_sortZ should return 0 for equal z", () => { + const a = new Renderable(0, 0, 1, 1); + const b = new Renderable(0, 0, 1, 1); + a.pos.z = 5; + b.pos.z = 5; + expect(container._sortZ(a, b)).toBe(0); + }); + + it("_sortReverseZ should sort ascending by z", () => { + const a = new Renderable(0, 0, 1, 1); + const b = new Renderable(0, 0, 1, 1); + a.pos.z = 5; + b.pos.z = 10; + expect(container._sortReverseZ(a, b)).toBeLessThan(0); // a first + expect(container._sortReverseZ(b, a)).toBeGreaterThan(0); // a still first + }); + + it("_sortX should sort by z first, then by x", () => { + const a = new Renderable(100, 0, 1, 1); + const b = new Renderable(200, 0, 1, 1); + a.pos.z = 5; + b.pos.z = 5; + // same z, should sort by x + expect(container._sortX(a, b)).toBeGreaterThan(0); // b.x > a.x + // different z, x ignored + b.pos.z = 10; + expect(container._sortX(a, b)).toBeGreaterThan(0); // b.z > a.z + }); + + it("_sortY should sort by z first, then by y", () => { + const a = new Renderable(0, 100, 1, 1); + const b = new Renderable(0, 200, 1, 1); + a.pos.z = 5; + b.pos.z = 5; + // same z, should sort by y + expect(container._sortY(a, b)).toBeGreaterThan(0); // b.y > a.y + // different z, y ignored + b.pos.z = 10; + expect(container._sortY(a, b)).toBeGreaterThan(0); // b.z > a.z + }); + + it("sortOn setter should update the cached comparator", () => { + container.sortOn = "y"; + expect(container._comparator).toBe(container._sortY); + container.sortOn = "x"; + expect(container._comparator).toBe(container._sortX); + container.sortOn = "z"; + expect(container._comparator).toBe(container._sortZ); + }); + }); + describe("enableChildBoundsUpdate", () => { it("child bounds should reflect absolute position after being added", () => { container.enableChildBoundsUpdate = true; From a7b47f037aee5e4904ac6f024851ea96d5b8b37c Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 11:00:50 +0800 Subject: [PATCH 2/7] Validate sortOn setter to prevent silent sorting errors Throw an error if sortOn is set to an unsupported value (only "x", "y", "z" are valid). Prevents undefined comparator from falling back to Array.sort's default lexicographic ordering. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/renderable/container.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index e9bbc65bb..8a512e35c 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -180,6 +180,11 @@ export default class Container extends Renderable { return this._sortOn; } set sortOn(value) { + if (value !== "x" && value !== "y" && value !== "z") { + throw new Error( + `Invalid sortOn value: "${value}" (expected "x", "y", or "z")`, + ); + } this._sortOn = value; this._comparator = this["_sort" + value.toUpperCase()]; } From 9bb84b25cc684208442b7e360f99714fe97a1e89 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 11:01:12 +0800 Subject: [PATCH 3/7] Remove Tween from addChild JSDoc (no longer added to containers) Tweens use event-based lifecycle since #1365 and are no longer added as container children. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/renderable/container.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 8a512e35c..6cabba0a0 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -226,7 +226,7 @@ export default class Container extends Renderable { * will not be in any container.
* if the given child implements an onActivateEvent method, that method will be called * once the child is added to this container. - * @param {Renderable|Entity|Sprite|Collectable|Trigger|Draggable|DropTarget|NineSliceSprite|ImageLayer|ColorLayer|Light2d|UIBaseElement|UISpriteElement|UITextButton|Text|BitmapText|Tween} child - Child to be added + * @param {Renderable|Entity|Sprite|Collectable|Trigger|Draggable|DropTarget|NineSliceSprite|ImageLayer|ColorLayer|Light2d|UIBaseElement|UISpriteElement|UITextButton|Text|BitmapText} child - Child to be added * @param {number} [z] - forces the z index of the child to the specified value * @returns {Renderable} the added child */ @@ -676,7 +676,7 @@ export default class Container extends Renderable { * Removes (and optionally destroys) a child from the container.
* (removal is immediate and unconditional)
* Never use keepalive=true with objects from {@link pool}. Doing so will create a memory leak. - * @param {Renderable|Entity|Sprite|Collectable|Trigger|Draggable|DropTarget|NineSliceSprite|ImageLayer|ColorLayer|Light2d|UIBaseElement|UISpriteElement|UITextButton|Text|BitmapText|Tween} child - Child to be removed + * @param {Renderable|Entity|Sprite|Collectable|Trigger|Draggable|DropTarget|NineSliceSprite|ImageLayer|ColorLayer|Light2d|UIBaseElement|UISpriteElement|UITextButton|Text|BitmapText} child - Child to be removed * @param {boolean} [keepalive=False] - True to prevent calling child.destroy() */ removeChildNow(child, keepalive) { From 4beff36ff1ff62e26bd71b4c208b2e88729acd7f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 11:08:14 +0800 Subject: [PATCH 4/7] Guard addChild/addChildAt with instanceof Renderable, remove dead code - addChild() and addChildAt() now throw if child is not a Renderable - Remove redundant isRenderable checks throughout Container (update, draw, updateBounds) since addChild guarantees all children are Renderable instances - Remove dead "non renderable" else branch in update loop - Remove Tween from addChild JSDoc (no longer added to containers) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/renderable/container.js | 105 +++++++++---------- 1 file changed, 48 insertions(+), 57 deletions(-) diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 6cabba0a0..89877b507 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -231,15 +231,15 @@ export default class Container extends Renderable { * @returns {Renderable} the added child */ addChild(child, z) { + if (!(child instanceof Renderable)) { + throw new Error(`${String(child)} is not an instance of Renderable`); + } if (child.ancestor instanceof Container) { child.ancestor.removeChildNow(child); } else { // only allocate a GUID if the object has no previous ancestor // (e.g. move one child from one container to another) - if (child.isRenderable) { - // allocated a GUID value (use child.id as based index if defined) - child.GUID = createGUID(child.id); - } + child.GUID = createGUID(child.id); } // add the new child @@ -314,16 +314,16 @@ export default class Container extends Renderable { * @returns {Renderable} the added child */ addChildAt(child, index) { + if (!(child instanceof Renderable)) { + throw new Error(`${String(child)} is not an instance of Renderable`); + } if (index >= 0 && index <= this.getChildren().length) { if (child.ancestor instanceof Container) { child.ancestor.removeChildNow(child); } else { // only allocate a GUID if the object has no previous ancestor // (e.g. move one child from one container to another) - if (child.isRenderable) { - // allocated a GUID value - child.GUID = createGUID(); - } + child.GUID = createGUID(); } // add the new child @@ -597,11 +597,9 @@ export default class Container extends Renderable { if (this.enableChildBoundsUpdate === true) { this.forEach((child) => { - if (child.isRenderable) { - const childBounds = child.updateBounds(absolute); - if (childBounds.isFinite()) { - bounds.addBounds(childBounds); - } + const childBounds = child.updateBounds(absolute); + if (childBounds.isFinite()) { + bounds.addBounds(childBounds); } }); } @@ -909,30 +907,25 @@ export default class Container extends Renderable { continue; } - if (obj.isRenderable) { - isFloating = globalFloatingCounter > 0 || obj.floating; - if (isFloating) { - globalFloatingCounter++; - } + isFloating = globalFloatingCounter > 0 || obj.floating; + if (isFloating) { + globalFloatingCounter++; + } - // check if object is in any active cameras - obj.inViewport = false; - // iterate through all cameras - cameras.forEach((camera) => { - if (camera.isVisible(obj, isFloating)) { - obj.inViewport = true; - } - }); + // check if object is in any active cameras + obj.inViewport = false; + // iterate through all cameras + cameras.forEach((camera) => { + if (camera.isVisible(obj, isFloating)) { + obj.inViewport = true; + } + }); - // update our object - this.isDirty |= (obj.inViewport || obj.alwaysUpdate) && obj.update(dt); + // update our object + this.isDirty |= (obj.inViewport || obj.alwaysUpdate) && obj.update(dt); - if (globalFloatingCounter > 0) { - globalFloatingCounter--; - } - } else { - // just directly call update() for non renderable object - this.isDirty |= obj.update(dt); + if (globalFloatingCounter > 0) { + globalFloatingCounter--; } } @@ -971,36 +964,34 @@ export default class Container extends Renderable { const children = this.getChildren(); for (let i = children.length, obj; i--, (obj = children[i]); ) { - if (obj.isRenderable) { - const isFloating = obj.floating === true; + const isFloating = obj.floating === true; - if (obj.inViewport || isFloating) { - // skip UI-only floating elements on non-default cameras - if (isFloating && isNonDefaultCamera && !obj.visibleInAllCameras) { - continue; - } + if (obj.inViewport || isFloating) { + // skip UI-only floating elements on non-default cameras + if (isFloating && isNonDefaultCamera && !obj.visibleInAllCameras) { + continue; + } - if (isFloating) { - renderer.save(); - renderer.resetTransform(); - if (isNonDefaultCamera) { - renderer.setProjection(viewport.screenProjection); - } + if (isFloating) { + renderer.save(); + renderer.resetTransform(); + if (isNonDefaultCamera) { + renderer.setProjection(viewport.screenProjection); } + } - obj.preDraw(renderer); - obj.draw(renderer, viewport); - obj.postDraw(renderer); + obj.preDraw(renderer); + obj.draw(renderer, viewport); + obj.postDraw(renderer); - if (isFloating) { - if (isNonDefaultCamera) { - renderer.setProjection(viewport.worldProjection); - } - renderer.restore(); + if (isFloating) { + if (isNonDefaultCamera) { + renderer.setProjection(viewport.worldProjection); } - - this.drawCount++; + renderer.restore(); } + + this.drawCount++; } } } From a6482320954fd636146f44b1b255d0a3ec788e68 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 11:18:59 +0800 Subject: [PATCH 5/7] Normalize sortOn to lowercase for case-insensitive validation Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/renderable/container.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 89877b507..7b9437701 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -180,13 +180,14 @@ export default class Container extends Renderable { return this._sortOn; } set sortOn(value) { - if (value !== "x" && value !== "y" && value !== "z") { + const v = value.toLowerCase(); + if (v !== "x" && v !== "y" && v !== "z") { throw new Error( `Invalid sortOn value: "${value}" (expected "x", "y", or "z")`, ); } - this._sortOn = value; - this._comparator = this["_sort" + value.toUpperCase()]; + this._sortOn = v; + this._comparator = this["_sort" + v.toUpperCase()]; } /** From 5cae2cb3c64fec17c3e7a11336c2c3f206730f7f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 11:36:48 +0800 Subject: [PATCH 6/7] Remove redundant typeof checks, optimize isIdentity short-circuit Renderable/Container cleanup: - Remove redundant typeof checks for ancestor, updateBounds, pos, getAbsolutePosition (guaranteed by addChild instanceof guard) - Simplify mask, _absPos, _bounds, _tint cleanup to truthiness checks - Simplify parentApp and isFloating ancestor checks - Remove currentTransform undefined check in Container.reset() - Remove unused Color and Bounds imports Matrix optimization: - Reorder isIdentity() comparisons in Matrix2d and Matrix3d to check translation components first (most commonly non-zero), enabling early exit for non-identity matrices in the hot path Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/math/matrix2d.ts | 21 ++++++----- packages/melonjs/src/math/matrix3d.ts | 13 +++---- packages/melonjs/src/renderable/container.js | 35 +++++++----------- packages/melonjs/src/renderable/renderable.js | 36 +++++++------------ 4 files changed, 45 insertions(+), 60 deletions(-) diff --git a/packages/melonjs/src/math/matrix2d.ts b/packages/melonjs/src/math/matrix2d.ts index 7bfea1c27..636898f54 100644 --- a/packages/melonjs/src/math/matrix2d.ts +++ b/packages/melonjs/src/math/matrix2d.ts @@ -403,16 +403,19 @@ export class Matrix2d { * @returns true if the matrix is an identity matrix */ isIdentity() { + const a = this.val; + + // check translation first (most commonly non-zero), then diagonal, then rest return ( - this.val[0] === 1 && - this.val[1] === 0 && - this.val[2] === 0 && - this.val[3] === 0 && - this.val[4] === 1 && - this.val[5] === 0 && - this.val[6] === 0 && - this.val[7] === 0 && - this.val[8] === 1 + a[6] === 0 && + a[7] === 0 && + a[0] === 1 && + a[4] === 1 && + a[8] === 1 && + a[1] === 0 && + a[2] === 0 && + a[3] === 0 && + a[5] === 0 ); } diff --git a/packages/melonjs/src/math/matrix3d.ts b/packages/melonjs/src/math/matrix3d.ts index 863cb0911..8a38936be 100644 --- a/packages/melonjs/src/math/matrix3d.ts +++ b/packages/melonjs/src/math/matrix3d.ts @@ -633,23 +633,24 @@ export class Matrix3d { isIdentity() { const a = this.val; + // check translation first (most commonly non-zero), then diagonal, then rest return ( + a[12] === 0 && + a[13] === 0 && a[0] === 1 && + a[5] === 1 && + a[10] === 1 && + a[15] === 1 && a[1] === 0 && a[2] === 0 && a[3] === 0 && a[4] === 0 && - a[5] === 1 && a[6] === 0 && a[7] === 0 && a[8] === 0 && a[9] === 0 && - a[10] === 1 && a[11] === 0 && - a[12] === 0 && - a[13] === 0 && - a[14] === 0 && - a[15] === 1 + a[14] === 0 ); } diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 7b9437701..0b8c72eb2 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -209,10 +209,7 @@ export default class Container extends Renderable { } } - if (typeof this.currentTransform !== "undefined") { - // just reset some variables - this.currentTransform.identity(); - } + this.currentTransform.identity(); this.backgroundColor.setColor(0, 0, 0, 0.0); } @@ -248,21 +245,17 @@ export default class Container extends Renderable { this.getChildren().push(child); // update child bounds to reflect the new ancestor - if (typeof child.updateBounds === "function") { - if (this.isFloating === true) { - // only parent container can be floating - child.floating = false; - } - child.updateBounds(); + if (this.isFloating === true) { + // only parent container can be floating + child.floating = false; } + child.updateBounds(); // set the child z value if required - if (typeof child.pos !== "undefined") { - if (typeof z === "number") { - child.pos.z = z; - } else if (this.autoDepth === true) { - child.pos.z = this.getChildren().length; - } + if (typeof z === "number") { + child.pos.z = z; + } else if (this.autoDepth === true) { + child.pos.z = this.getChildren().length; } if (this.autoSort === true) { @@ -332,13 +325,11 @@ export default class Container extends Renderable { this.getChildren().splice(index, 0, child); // update child bounds to reflect the new ancestor - if (typeof child.updateBounds === "function") { - if (this.isFloating === true) { - // only parent container can be floating - child.floating = false; - } - child.updateBounds(); + if (this.isFloating === true) { + // only parent container can be floating + child.floating = false; } + child.updateBounds(); if ( typeof child.onActivateEvent === "function" && diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 75d8e64f5..335971d8c 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -1,13 +1,13 @@ import { ObservablePoint } from "../geometries/observablePoint.ts"; import { Rect } from "./../geometries/rectangle.ts"; import { releaseAllPointerEvents } from "./../input/input.ts"; -import { Color, colorPool } from "./../math/color.ts"; +import { colorPool } from "./../math/color.ts"; import { clamp } from "./../math/math.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import { ObservableVector3d } from "../math/observableVector3d.ts"; import { vector2dPool } from "../math/vector2d.ts"; import Body from "./../physics/body.js"; -import { Bounds, boundsPool } from "./../physics/bounds.ts"; +import { boundsPool } from "./../physics/bounds.ts"; import pool from "../system/legacy_pool.js"; /** @@ -347,14 +347,9 @@ export default class Renderable extends Rect { * @return {Application} the parent application or undefined if not attached to any container/app */ get parentApp() { - if (typeof this._parentApp === "undefined") { - if ( - typeof this.ancestor !== "undefined" && - typeof this.ancestor.getRootAncestor === "function" - ) { - // the `app` property is only defined in the world "root" container - this._parentApp = this.ancestor.getRootAncestor().app; - } + if (!this._parentApp && this.ancestor) { + // the `app` property is only defined in the world "root" container + this._parentApp = this.ancestor.getRootAncestor().app; } return this._parentApp; } @@ -367,8 +362,7 @@ export default class Renderable extends Rect { get isFloating() { return ( this.floating === true || - (typeof this.ancestor !== "undefined" && - this.ancestor.isFloating === true) + (this.ancestor && this.ancestor.isFloating === true) ); } @@ -707,11 +701,7 @@ export default class Renderable extends Rect { } // TODO: cache the absolute position and invalidate when pos or ancestor changes this._absPos.set(this.pos.x, this.pos.y); - if ( - typeof this.ancestor !== "undefined" && - typeof this.ancestor.getAbsolutePosition === "function" && - this.floating !== true - ) { + if (this.ancestor && this.floating !== true) { this._absPos.add(this.ancestor.getAbsolutePosition()); } return this._absPos; @@ -745,7 +735,7 @@ export default class Renderable extends Rect { } // apply stencil mask if defined - if (typeof this.mask !== "undefined") { + if (this.mask) { renderer.translate(this.pos.x, this.pos.y); renderer.setMask(this.mask); renderer.translate(-this.pos.x, -this.pos.y); @@ -804,7 +794,7 @@ export default class Renderable extends Rect { renderer.clearTint(); // clear the mask if set - if (typeof this.mask !== "undefined") { + if (this.mask) { renderer.clearMask(); } @@ -857,24 +847,24 @@ export default class Renderable extends Rect { this.pos = undefined; - if (typeof this._absPos !== "undefined") { + if (this._absPos) { vector2dPool.release(this._absPos); this._absPos = undefined; } - if (this._bounds instanceof Bounds) { + if (this._bounds) { boundsPool.release(this._bounds); this._bounds = undefined; } this.onVisibilityChange = undefined; - if (typeof this.mask !== "undefined") { + if (this.mask) { pool.push(this.mask); this.mask = undefined; } - if (this._tint instanceof Color) { + if (this._tint) { colorPool.release(this._tint); this._tint = undefined; } From e69e40dc733564f435ba1d351429abf815cddbcf Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 12 Apr 2026 11:41:37 +0800 Subject: [PATCH 7/7] Fix lint error: cast getBounds() return to Bounds in focusOn Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/camera/camera2d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index 196e7af36..50a342a80 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -760,7 +760,7 @@ export default class Camera2d extends Renderable { * @param target - the renderable to focus the camera on */ focusOn(target: Renderable): void { - const bounds = target.getBounds(); + const bounds = target.getBounds() as Bounds; this.moveTo( bounds.left + bounds.width / 2 - this.width / 2, bounds.top + bounds.height / 2 - this.height / 2,