From f9e78fb5b4018c34dcb7415f93add0cba760a87f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 15:03:48 +0800 Subject: [PATCH 1/2] feat(video): per-sprite depth in the GPU vertex stream (19.7.0 PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens `aVertex` from `vec2` to `vec3` across every batched shader (`quad-multi`, `quad-multi-lit`, `primitive`, `orthogonal-tmxlayer`, `quad` template used by ShaderEffect). Adds `renderer.setDepth(depth)` + `renderer.currentDepth` state on the base Renderer, plumbed through `RenderState`'s save/restore stack and forwarded automatically by `Renderable.preDraw(renderer)`. Batcher emit paths (`QuadBatcher.addQuad`, `LitQuadBatcher.addQuad`, `PrimitiveBatcher.drawVertices` + `#expandLinesToTriangles`) read `currentDepth` and write it as the z component of each vertex. Unblocks Camera3d (PR B): with a perspective projection, sprites at different depths now scale and parallax correctly. Under the default ortho projection there is no visible effect — every existing 2D path renders identically (verified end-to-end on every example). Backward-compatible for all documented public APIs: - `ShaderEffect` (fragment-only effects) — fragment template unchanged - `GLShader` declaring `attribute vec2 aVertex` — WebGL silently drops the z component; downstream attribute offsets shift but GLShader binds by name via `getAttribLocation`, so the shift is transparent - `drawImage` / `fillRect` / `strokeRect` / `setTint` / all renderer methods — signatures unchanged - `Mesh` — its batcher (already `vec3 aVertex`) is unchanged - `Renderable.preDraw` — same signature, gains one `setDepth` call The one exotic incompatibility (flagged in the CHANGELOG): subclasses of `QuadBatcher` / `PrimitiveBatcher` that reimplement `addQuad` / `drawVertices` from scratch and push to `vertexData` directly need to update the 6-arg push to the 7-arg form (insert `z` after `y`, default `0` works under ortho). Subclasses delegating to `super.addQuad` are unaffected. Tests ----- - 16 new in `depth.spec.js` covering state stack, preDraw forwarding, batcher strides (28 / 32 / 24), z-in-buffer readback, tint-slot regression guard (catches the original push() positional-layout bug by checking the UINT32 tint at offset 5), default depth = 0, and end-to-end perspective draw without GL error - 13 new in `depth_adversarial.spec.js` covering cross-batcher persistence, mid-batch per-vertex emission, NineSliceSprite multi-quad inheritance, nested preDraw/postDraw stack, blitTexture z=0 invariant (post-fx blits always screen-space), Mesh batcher independence, vec2 aVertex custom GLShader compat, RenderState stack growth past 32 slots, extreme values + NaN/Infinity, flush boundary preservation, and a 500-op deterministic fuzz - `vertexBuffer.spec.js` + `drawVertices.spec.js` updated to the new 6/7/8-float layouts (z slot at offset 2) - `sprite.spec.js` stub renderer gains a `setDepth: noop` Total: 3557 tests pass, 12 skipped, 0 GL errors across the full WebGL pipeline adversarial suite, CodeRabbit clean review. Drive-by fix ------------ `packages/examples/src/index.css` — `#screen` had `pointer-events: none` to keep the empty fixed overlay from blocking the index page after unmount, but the rule re-enabled clicks only on `canvas`. Examples that add HTML controls (the mesh3d / mesh3dMaterial dropdowns) ended up unclickable. Broadened to `#screen > *` so any direct child opts back in. Pre-existing bug, surfaced during local testing. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/examples/src/index.css | 10 +- packages/melonjs/CHANGELOG.md | 18 + packages/melonjs/package.json | 2 +- packages/melonjs/src/renderable/renderable.js | 6 + packages/melonjs/src/video/buffer/vertex.js | 22 +- packages/melonjs/src/video/renderer.js | 36 ++ packages/melonjs/src/video/renderstate.js | 20 + .../video/webgl/batchers/lit_quad_batcher.js | 68 +- .../video/webgl/batchers/primitive_batcher.js | 30 +- .../src/video/webgl/batchers/quad_batcher.js | 35 +- .../webgl/shaders/orthogonal-tmxlayer.vert | 6 +- .../src/video/webgl/shaders/primitive.vert | 14 +- .../video/webgl/shaders/quad-multi-lit.vert | 11 +- .../src/video/webgl/shaders/quad-multi.vert | 8 +- .../melonjs/src/video/webgl/shaders/quad.vert | 9 +- .../tests/batcher_attribute_leak.spec.js | 17 +- packages/melonjs/tests/depth.spec.js | 405 ++++++++++++ .../melonjs/tests/depth_adversarial.spec.js | 599 ++++++++++++++++++ packages/melonjs/tests/drawVertices.spec.js | 95 +-- packages/melonjs/tests/sprite.spec.js | 1 + packages/melonjs/tests/vertexBuffer.spec.js | 150 ++--- 21 files changed, 1371 insertions(+), 191 deletions(-) create mode 100644 packages/melonjs/tests/depth.spec.js create mode 100644 packages/melonjs/tests/depth_adversarial.spec.js diff --git a/packages/examples/src/index.css b/packages/examples/src/index.css index 566a662f57..0fef5547f4 100644 --- a/packages/examples/src/index.css +++ b/packages/examples/src/index.css @@ -37,14 +37,20 @@ body:has(#screen canvas) { * below the 41 px topbar. Without `pointer-events: none`, the * empty `#screen` div (after an example unmounts) still sits on * top of the index-page cards and silently swallows every click. - * The canvas inside opts back in with `pointer-events: auto`. + * Direct children opt back in with `pointer-events: auto` — + * not just the canvas, since examples may also append HTML + * controls (dropdowns, HUDs) to `#screen`. After unmount, no + * children remain → nothing blocks clicks. */ pointer-events: none; } +#screen > * { + pointer-events: auto; +} + #screen canvas { display: block; - pointer-events: auto; } /* Example top bar */ diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 6a0384434b..9b557be22a 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [19.7.0] (melonJS 2) - _unreleased_ + +**Highlights:** foundational vertex-pipeline work toward the 19.7 Camera3d release. Every batched-rendering shader (`Quad`, `LitQuad`, `Primitive`, GPU TMX) now carries per-sprite depth as a true `vec3` vertex attribute — unblocking perspective projection for sprites, enabling depth-aware custom `ShaderEffect`s, and aligning the entire batched pipeline with the WebGPU vertex model. Backward compatible: existing custom `GLShader` / `ShaderEffect` code keeps working unchanged. + +### Added +- **`renderer.setDepth(depth)`** — new public method on the base `Renderer`. Mirrors the existing `setTint` / `setColor` state-setter pattern. Sets `renderer.currentDepth`, which the batchers read at vertex-emit time and push as the z component of each vertex. `Renderable.preDraw` now forwards `this.depth` automatically, so user code typically never needs to call `setDepth` directly. +- Per-sprite depth on the GPU: all batched draw paths (`QuadBatcher`, `LitQuadBatcher`, `PrimitiveBatcher`, GPU TMX) now carry the renderable's `.depth` as the z component of their vertex stream. Default shaders consume it via `vec4(aVertex, 1.0)` in `gl_Position`. With an orthographic projection (the engine default), z has no visible effect — existing 2D apps render identically. With a perspective projection, sprites at different depths are correctly scaled and parallaxed by the projection matrix. + +### Changed +- **Vertex attribute layout: `aVertex` widened from `vec2` to `vec3`** across `quad-multi.vert`, `quad-multi-lit.vert`, `primitive.vert`, `orthogonal-tmxlayer.vert`. `Mesh`'s shader already used `vec3 aVertex` — all batchers are now uniform. Per-vertex stride grows by 4 bytes (24 → 28 for the quad layout). Custom shaders binding attributes by name (`gl.getAttribLocation`) — the standard pattern, and what `GLShader` enforces — keep working unchanged; the byte-offset shift of `aRegion` / `aColor` / `aTextureId` is transparent because the batcher updates its own `vertexAttribPointer` offsets. Custom shaders declaring `attribute vec2 aVertex;` continue to work — WebGL silently drops the unused z component. +- **`VertexArrayBuffer.push()` signature gained a `z` parameter** between `y` and `u`: `push(x, y, z, u, v, tint, textureId?, normalTextureId?)`. `VertexArrayBuffer` is an internal class (not exported), but **subclasses of `QuadBatcher` / `PrimitiveBatcher` that reimplement `addQuad` / `drawVertices` from scratch** (not via `super.addQuad()`) and push to `vertexData` directly need to update their push call to the 7-arg form (insert `z` after `y`, default `0` works under ortho). Subclasses that delegate to `super.addQuad()` are unaffected. + +### Fixed +- None. + +### Performance +- Stride grows ~16% per quad vertex. Real-world impact is below measurement noise on practical workloads — the extra 4 bytes per vertex sit inside the same upload + draw call. + ## [19.6.0] (melonJS 2) - _2026-05-23_ **Highlights:** WebGL context-loss hardening release. Fixes a Windows + Chrome crash where a GPU switch lost the WebGL context and a partial `GLShader.destroy()` left the next frame's `setUniform("uTime", …)` reading from `null` uniforms. Beyond the crash, the renderer now also transparently recovers the rest of the pipeline (vertex buffer, default GL state, batchers, texture cache) across a `webglcontextlost` → `webglcontextrestored` cycle, and shaders replay their cached uniforms on restore — so the game keeps drawing across a GPU switch without any intervention from user code. diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index a6ddd23225..f3ad09f81c 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "19.6.0", + "version": "19.7.0", "description": "melonJS Game Engine", "homepage": "http://www.melonjs.org/", "type": "module", diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 0b7843981e..31f9b009f4 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -860,6 +860,12 @@ export default class Renderable extends Rect { // apply the current tint and opacity renderer.setTint(this.tint, this.getOpacity()); + // forward depth to the renderer — pushed into the WebGL vertex + // stream as the `z` component by the batchers. No visible effect + // under the default ortho projection; required for perspective + // (Camera3d) to scale and parallax this renderable by distance. + renderer.setDepth(this.depth); + // apply blending if different from "normal" if (this.blendMode !== renderer.getBlendMode()) { renderer.setBlendMode(this.blendMode); diff --git a/packages/melonjs/src/video/buffer/vertex.js b/packages/melonjs/src/video/buffer/vertex.js index d7a0950ee0..281731254d 100644 --- a/packages/melonjs/src/video/buffer/vertex.js +++ b/packages/melonjs/src/video/buffer/vertex.js @@ -43,31 +43,33 @@ export default class VertexArrayBuffer { * push a new vertex to the buffer * @param {number} x - x position * @param {number} y - y position - * @param {number} u - texture U coordinate - * @param {number} v - texture V coordinate + * @param {number} z - z position (per-renderable depth; 0 under ortho) + * @param {number} u - texture U coordinate (or aNormal.x for primitives) + * @param {number} v - texture V coordinate (or aNormal.y for primitives) * @param {number} tint - tint color in UINT32 (argb) format * @param {number} [textureId] - texture unit index for multi-texture batching * @param {number} [normalTextureId] - paired normal-map texture unit index, or `-1` for unlit quads * @ignore */ - push(x, y, u, v, tint, textureId, normalTextureId) { + push(x, y, z, u, v, tint, textureId, normalTextureId) { const offset = this.vertexCount * this.vertexSize; this.bufferF32[offset] = x; this.bufferF32[offset + 1] = y; - this.bufferF32[offset + 2] = u; - this.bufferF32[offset + 3] = v; - this.bufferU32[offset + 4] = tint; - if (this.vertexSize > 5) { - this.bufferF32[offset + 5] = textureId || 0; - if (this.vertexSize > 6) { + this.bufferF32[offset + 2] = z; + this.bufferF32[offset + 3] = u; + this.bufferF32[offset + 4] = v; + this.bufferU32[offset + 5] = tint; + if (this.vertexSize > 6) { + this.bufferF32[offset + 6] = textureId || 0; + if (this.vertexSize > 7) { // `aNormalTextureId`: -1 (sentinel for unlit) is the safe // default when the caller doesn't supply one. Writing 0 // here would let the fragment shader's lit path activate // on unlit quads with whatever normal-map happened to be // bound at unit 0 — visible as garbage hemispheric // shading on every sprite. - this.bufferF32[offset + 6] = + this.bufferF32[offset + 7] = typeof normalTextureId === "number" ? normalTextureId : -1; } } diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 85b2a65973..5b4242d200 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -188,6 +188,23 @@ export default class Renderer { this.renderState.currentBlendMode = value; } + /** + * Current per-renderable depth value. WebGL batchers push it into the + * vertex stream as the `z` component of each vertex — a no-op under the + * default orthographic projection, used by perspective (Camera3d) to + * scale and parallax sprites by distance. Mirrors `renderable.depth`, + * set automatically by `Renderable.preDraw` via {@link Renderer#setDepth}. + * @type {number} + * @default 0 + */ + get currentDepth() { + return this.renderState.currentDepth; + } + + set currentDepth(value) { + this.renderState.currentDepth = value; + } + /** * return the height of the canvas which this renderer draws to * @returns {number} height of the system Canvas @@ -939,6 +956,25 @@ export default class Renderer { this.currentTint.setFloat(1.0, 1.0, 1.0, 1.0); } + /** + * Set the current per-renderable depth value. WebGL batchers push it + * into the vertex stream as the `z` component of each vertex — a no-op + * under the default orthographic projection, used by perspective + * (Camera3d) to scale and parallax sprites by distance. + * + * Typically called automatically by `Renderable.preDraw` from the + * renderable's `depth` property. User code only needs to call this + * directly when emitting draw calls outside of a `Renderable.draw()` + * — e.g. from a custom `Container.draw()` override. + * + * Honored by the save/restore stack like `setTint` / `setColor`. + * @param {number} depth - the depth value to set + * @see Renderable#depth + */ + setDepth(depth) { + this.currentDepth = depth; + } + /** * creates a Blob object representing the last rendered frame * @param {string} [type="image/png"] - A string indicating the image format diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js index 25a68b71ea..5a030132b5 100644 --- a/packages/melonjs/src/video/renderstate.js +++ b/packages/melonjs/src/video/renderstate.js @@ -63,6 +63,17 @@ export default class RenderState { */ this.currentShader = undefined; + /** + * current per-renderable depth value, pushed into the vertex stream + * by the WebGL batchers as the `z` component of each vertex. Has no + * visible effect under the engine's default orthographic projection; + * required for perspective (Camera3d) to scale sprites by distance. + * Mirrors `renderable.depth`, set by `Renderable.preDraw` via + * {@link Renderer#setDepth}. + * @type {number} + */ + this.currentDepth = 0; + // ---- pre-allocated save/restore stack ---- /** @@ -110,6 +121,9 @@ export default class RenderState { /** @ignore */ this._shaderStack = new Array(this._stackCapacity); + + /** @ignore */ + this._depthStack = new Float32Array(this._stackCapacity); } /** @@ -130,6 +144,7 @@ export default class RenderState { this._lineDashStack[depth] = this.lineDash; this._blendStack[depth] = this.currentBlendMode; this._shaderStack[depth] = this.currentShader; + this._depthStack[depth] = this.currentDepth; if (scissorTestActive) { this._scissorStack[depth].set(this.currentScissor); @@ -187,6 +202,7 @@ export default class RenderState { this.currentGradient = this._gradientStack[depth]; this.lineDash = this._lineDashStack[depth]; this.currentShader = this._shaderStack[depth]; + this.currentDepth = this._depthStack[depth]; const scissorActive = !!this._scissorActive[depth]; if (scissorActive) { this.currentScissor.set(this._scissorStack[depth]); @@ -215,6 +231,7 @@ export default class RenderState { this.currentScissor[2] = width; this.currentScissor[3] = height; this.currentShader = undefined; + this.currentDepth = 0; } /** @private — doubles stack capacity when exceeded */ @@ -234,6 +251,9 @@ export default class RenderState { const newScissorActive = new Uint8Array(newCap); newScissorActive.set(this._scissorActive); this._scissorActive = newScissorActive; + const newDepthStack = new Float32Array(newCap); + newDepthStack.set(this._depthStack); + this._depthStack = newDepthStack; this._stackCapacity = newCap; } } diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 65f17d99c2..4d61881046 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -43,8 +43,10 @@ export default class LitQuadBatcher extends QuadBatcher { Object.getPrototypeOf(QuadBatcher.prototype).init.call(this, renderer, { attributes: [ { + // vec3: (x, y, z). z carries `renderable.depth` for + // perspective projection (Camera3d). Stride = 32 bytes. name: "aVertex", - size: 2, + size: 3, type: renderer.gl.FLOAT, normalized: false, offset: 0 * Float32Array.BYTES_PER_ELEMENT, @@ -54,28 +56,28 @@ export default class LitQuadBatcher extends QuadBatcher { size: 2, type: renderer.gl.FLOAT, normalized: false, - offset: 2 * Float32Array.BYTES_PER_ELEMENT, + offset: 3 * Float32Array.BYTES_PER_ELEMENT, }, { name: "aColor", size: 4, type: renderer.gl.UNSIGNED_BYTE, normalized: true, - offset: 4 * Float32Array.BYTES_PER_ELEMENT, + offset: 5 * Float32Array.BYTES_PER_ELEMENT, }, { name: "aTextureId", size: 1, type: renderer.gl.FLOAT, normalized: false, - offset: 5 * Float32Array.BYTES_PER_ELEMENT, + offset: 6 * Float32Array.BYTES_PER_ELEMENT, }, { name: "aNormalTextureId", size: 1, type: renderer.gl.FLOAT, normalized: false, - offset: 6 * Float32Array.BYTES_PER_ELEMENT, + offset: 7 * Float32Array.BYTES_PER_ELEMENT, }, ], shader: { @@ -315,10 +317,49 @@ export default class LitQuadBatcher extends QuadBatcher { } const textureId = this.useMultiTexture ? unit : 0; - vertexData.push(vec0.x, vec0.y, u0, v0, tint, textureId, normalTextureId); - vertexData.push(vec1.x, vec1.y, u1, v0, tint, textureId, normalTextureId); - vertexData.push(vec2.x, vec2.y, u0, v1, tint, textureId, normalTextureId); - vertexData.push(vec3.x, vec3.y, u1, v1, tint, textureId, normalTextureId); + // z = current renderer depth (Renderable.preDraw); a no-op under ortho, + // consumed by perspective (Camera3d) — matches QuadBatcher.addQuad. + const z = this.renderer.currentDepth; + vertexData.push( + vec0.x, + vec0.y, + z, + u0, + v0, + tint, + textureId, + normalTextureId, + ); + vertexData.push( + vec1.x, + vec1.y, + z, + u1, + v0, + tint, + textureId, + normalTextureId, + ); + vertexData.push( + vec2.x, + vec2.y, + z, + u0, + v1, + tint, + textureId, + normalTextureId, + ); + vertexData.push( + vec3.x, + vec3.y, + z, + u1, + v1, + tint, + textureId, + normalTextureId, + ); } /** @@ -360,11 +401,12 @@ export default class LitQuadBatcher extends QuadBatcher { m.apply(vec3); } + // blits are always rendered at z = 0 (screen-space, ortho) const tint = 0xffffffff; - this.vertexData.push(vec0.x, vec0.y, 0, 1, tint, 0, -1); - this.vertexData.push(vec1.x, vec1.y, 1, 1, tint, 0, -1); - this.vertexData.push(vec2.x, vec2.y, 0, 0, tint, 0, -1); - this.vertexData.push(vec3.x, vec3.y, 1, 0, tint, 0, -1); + this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0, -1); + this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0, -1); + this.vertexData.push(vec2.x, vec2.y, 0, 0, 0, tint, 0, -1); + this.vertexData.push(vec3.x, vec3.y, 0, 1, 0, tint, 0, -1); this.flush(); diff --git a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js index 6d00a905f4..ee3f38c444 100644 --- a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js @@ -21,8 +21,10 @@ export default class PrimitiveBatcher extends Batcher { super.init(renderer, { attributes: [ { + // vec3: (x, y, z). z carries `renderable.depth` for + // perspective projection (Camera3d). Stride = 24 bytes. name: "aVertex", - size: 2, + size: 3, type: renderer.gl.FLOAT, normalized: false, offset: 0 * Float32Array.BYTES_PER_ELEMENT, @@ -32,14 +34,14 @@ export default class PrimitiveBatcher extends Batcher { size: 2, type: renderer.gl.FLOAT, normalized: false, - offset: 2 * Float32Array.BYTES_PER_ELEMENT, + offset: 3 * Float32Array.BYTES_PER_ELEMENT, }, { name: "aColor", size: 4, type: renderer.gl.UNSIGNED_BYTE, normalized: true, - offset: 4 * Float32Array.BYTES_PER_ELEMENT, + offset: 5 * Float32Array.BYTES_PER_ELEMENT, }, ], shader: { @@ -104,6 +106,10 @@ export default class PrimitiveBatcher extends Batcher { const vertexData = this.vertexData; const alpha = this.renderer.getGlobalAlpha(); const colorUint32 = this.renderer.currentColor.toUint32(alpha); + // z = current renderer depth (Renderable.preDraw); a no-op under ortho, + // consumed by perspective (Camera3d). Same value for every vertex — + // primitives don't have per-vertex depth. + const z = this.renderer.currentDepth; if (vertexData.isFull(vertexCount)) { // is the vertex buffer full if we add more vertices @@ -125,6 +131,7 @@ export default class PrimitiveBatcher extends Batcher { vertexData.push( x * m[0] + y * m[4] + m[12], x * m[1] + y * m[5] + m[13], + z, 0, 0, colorUint32, @@ -133,7 +140,7 @@ export default class PrimitiveBatcher extends Batcher { } else { for (let i = 0; i < vertexCount; i++) { const vert = verts[i]; - vertexData.push(vert.x, vert.y, 0, 0, colorUint32); + vertexData.push(vert.x, vert.y, z, 0, 0, colorUint32); } } @@ -157,6 +164,9 @@ export default class PrimitiveBatcher extends Batcher { const alpha = this.renderer.getGlobalAlpha(); const colorUint32 = this.renderer.currentColor.toUint32(alpha); const hasTransform = !viewMatrix.isIdentity(); + // z = current renderer depth (Renderable.preDraw); a no-op under ortho, + // consumed by perspective (Camera3d). + const z = this.renderer.currentDepth; // each line pair (2 verts) expands to 2 triangles (6 verts) const expandedCount = (vertexCount / 2) * 6; @@ -205,14 +215,14 @@ export default class PrimitiveBatcher extends Batcher { // two triangles forming a quad around the line segment // triangle 1: from+n, from-n, to-n - vertexData.push(fromX, fromY, nx, ny, colorUint32); - vertexData.push(fromX, fromY, -nx, -ny, colorUint32); - vertexData.push(toX, toY, -nx, -ny, colorUint32); + vertexData.push(fromX, fromY, z, nx, ny, colorUint32); + vertexData.push(fromX, fromY, z, -nx, -ny, colorUint32); + vertexData.push(toX, toY, z, -nx, -ny, colorUint32); // triangle 2: from+n, to-n, to+n - vertexData.push(fromX, fromY, nx, ny, colorUint32); - vertexData.push(toX, toY, -nx, -ny, colorUint32); - vertexData.push(toX, toY, nx, ny, colorUint32); + vertexData.push(fromX, fromY, z, nx, ny, colorUint32); + vertexData.push(toX, toY, z, -nx, -ny, colorUint32); + vertexData.push(toX, toY, z, nx, ny, colorUint32); } } } diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 66074c5628..977f2ec7a0 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -41,8 +41,10 @@ export default class QuadBatcher extends MaterialBatcher { super.init(renderer, { attributes: [ { + // vec3: (x, y, z). z carries `renderable.depth` for + // perspective projection (Camera3d). Stride = 28 bytes. name: "aVertex", - size: 2, + size: 3, type: renderer.gl.FLOAT, normalized: false, offset: 0 * Float32Array.BYTES_PER_ELEMENT, @@ -52,21 +54,21 @@ export default class QuadBatcher extends MaterialBatcher { size: 2, type: renderer.gl.FLOAT, normalized: false, - offset: 2 * Float32Array.BYTES_PER_ELEMENT, + offset: 3 * Float32Array.BYTES_PER_ELEMENT, }, { name: "aColor", size: 4, type: renderer.gl.UNSIGNED_BYTE, normalized: true, - offset: 4 * Float32Array.BYTES_PER_ELEMENT, + offset: 5 * Float32Array.BYTES_PER_ELEMENT, }, { name: "aTextureId", size: 1, type: renderer.gl.FLOAT, normalized: false, - offset: 5 * Float32Array.BYTES_PER_ELEMENT, + offset: 6 * Float32Array.BYTES_PER_ELEMENT, }, ], shader: { @@ -221,11 +223,13 @@ export default class QuadBatcher extends MaterialBatcher { m.apply(vec3); } + // blits are always rendered at z = 0 (screen-space, ortho); pre-PR + // behavior is preserved unchanged const tint = 0xffffffff; - this.vertexData.push(vec0.x, vec0.y, 0, 1, tint, 0); - this.vertexData.push(vec1.x, vec1.y, 1, 1, tint, 0); - this.vertexData.push(vec2.x, vec2.y, 0, 0, tint, 0); - this.vertexData.push(vec3.x, vec3.y, 1, 0, tint, 0); + this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0); + this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0); + this.vertexData.push(vec2.x, vec2.y, 0, 0, 0, tint, 0); + this.vertexData.push(vec3.x, vec3.y, 0, 1, 0, tint, 0); this.flush(); @@ -298,12 +302,15 @@ export default class QuadBatcher extends MaterialBatcher { m.apply(vec3); } - // 4 vertices per quad; the index buffer provides the 6 indices - // textureId is the unit index for multi-texture, or 0 for single-texture fallback + // 4 vertices per quad; the index buffer provides the 6 indices. + // textureId is the unit index for multi-texture, or 0 for single-texture fallback. + // z is the current renderer depth (set by Renderable.preDraw); a no-op under + // the default ortho projection, used by perspective (Camera3d). const textureId = this.useMultiTexture ? unit : 0; - vertexData.push(vec0.x, vec0.y, u0, v0, tint, textureId); - vertexData.push(vec1.x, vec1.y, u1, v0, tint, textureId); - vertexData.push(vec2.x, vec2.y, u0, v1, tint, textureId); - vertexData.push(vec3.x, vec3.y, u1, v1, tint, textureId); + const z = this.renderer.currentDepth; + vertexData.push(vec0.x, vec0.y, z, u0, v0, tint, textureId); + vertexData.push(vec1.x, vec1.y, z, u1, v0, tint, textureId); + vertexData.push(vec2.x, vec2.y, z, u0, v1, tint, textureId); + vertexData.push(vec3.x, vec3.y, z, u1, v1, tint, textureId); } } diff --git a/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.vert b/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.vert index 879fa4a40a..c479f9991d 100644 --- a/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.vert +++ b/packages/melonjs/src/video/webgl/shaders/orthogonal-tmxlayer.vert @@ -9,7 +9,9 @@ // `attribute`/`varying`) so the program can pair with the 3.00 fragment // shader that uses `usampler2D` / `texelFetch` for integer-typed lookups. -in vec2 aVertex; +// `aVertex.z` carries per-sprite depth (renderable.depth) — a no-op under +// the default ortho projection, used by perspective (Camera3d). +in vec3 aVertex; in vec2 aRegion; in vec4 aColor; @@ -19,7 +21,7 @@ out vec2 vRegion; out vec4 vColor; void main(void) { - gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0); + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); // premultiplied-alpha + bgra → rgba swap, same convention the batcher // uses for its default sprite shader vColor = vec4(aColor.bgr * aColor.a, aColor.a); diff --git a/packages/melonjs/src/video/webgl/shaders/primitive.vert b/packages/melonjs/src/video/webgl/shaders/primitive.vert index ed1165fa66..f2234c7d2a 100644 --- a/packages/melonjs/src/video/webgl/shaders/primitive.vert +++ b/packages/melonjs/src/video/webgl/shaders/primitive.vert @@ -1,6 +1,8 @@ -// Current vertex point -attribute vec2 aVertex; -// Per-vertex normal for line expansion +// Current vertex point — z carries per-sprite depth (renderable.depth), +// a no-op under the default ortho projection, used by perspective (Camera3d). +attribute vec3 aVertex; +// Per-vertex normal for line expansion (2D — line expansion is in the +// screen plane and doesn't participate in depth). attribute vec2 aNormal; attribute vec4 aColor; @@ -12,10 +14,10 @@ uniform float uLineWidth; varying vec4 vColor; void main(void) { - // Expand vertex position along the normal by half the line width - vec2 position = aVertex + aNormal * uLineWidth * 0.5; + // Expand vertex position along the normal by half the line width (2D) + vec2 position = aVertex.xy + aNormal * uLineWidth * 0.5; // Transform the vertex position by the projection matrix - gl_Position = uProjectionMatrix * vec4(position, 0.0, 1.0); + gl_Position = uProjectionMatrix * vec4(position, aVertex.z, 1.0); // Pass the remaining attributes to the fragment shader vColor = vec4(aColor.bgr * aColor.a, aColor.a); } diff --git a/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert b/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert index 6d03f30005..1352514a5c 100644 --- a/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert +++ b/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert @@ -2,7 +2,9 @@ // path). Carries a paired `aNormalTextureId` per vertex so the fragment // shader knows which `uNormalSampler` to read, and `vWorldPos` so the // lit math can compute per-fragment `lightPos - pos` deltas. -attribute vec2 aVertex; +// `aVertex.z` carries per-sprite depth (renderable.depth) — a no-op under +// the default ortho projection, used by perspective (Camera3d). +attribute vec3 aVertex; attribute vec2 aRegion; attribute vec4 aColor; attribute float aTextureId; @@ -17,14 +19,15 @@ varying float vNormalTextureId; // Pre-projection vertex position (in the renderer's pre-projection // space — typically camera-local for default cameras with the world // container's translate applied). Used by the lit fragment path to -// compute `lightPos - fragmentPos` for each Light2d. +// compute `lightPos - fragmentPos` for each Light2d. 2D — light math is +// in the screen plane, depth doesn't participate. varying vec2 vWorldPos; void main(void) { - gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0); + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); vColor = vec4(aColor.bgr * aColor.a, aColor.a); vRegion = aRegion; vTextureId = aTextureId; vNormalTextureId = aNormalTextureId; - vWorldPos = aVertex; + vWorldPos = aVertex.xy; } diff --git a/packages/melonjs/src/video/webgl/shaders/quad-multi.vert b/packages/melonjs/src/video/webgl/shaders/quad-multi.vert index 43e9d59ffd..2a78477c4e 100644 --- a/packages/melonjs/src/video/webgl/shaders/quad-multi.vert +++ b/packages/melonjs/src/video/webgl/shaders/quad-multi.vert @@ -1,5 +1,7 @@ -// Current vertex point -attribute vec2 aVertex; +// Current vertex point — z carries per-sprite depth (renderable.depth), +// has no visible effect under the default ortho projection but is required +// for perspective projection (Camera3d) to scale sprites by distance. +attribute vec3 aVertex; attribute vec2 aRegion; attribute vec4 aColor; attribute float aTextureId; @@ -13,7 +15,7 @@ varying float vTextureId; void main(void) { // Transform the vertex position by the projection matrix - gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0); + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); // Pass the remaining attributes to the fragment shader vColor = vec4(aColor.bgr * aColor.a, aColor.a); vRegion = aRegion; diff --git a/packages/melonjs/src/video/webgl/shaders/quad.vert b/packages/melonjs/src/video/webgl/shaders/quad.vert index f6faf15d59..83aaae58b3 100644 --- a/packages/melonjs/src/video/webgl/shaders/quad.vert +++ b/packages/melonjs/src/video/webgl/shaders/quad.vert @@ -1,5 +1,8 @@ -// Current vertex point -attribute vec2 aVertex; +// Current vertex point — z carries per-sprite depth (renderable.depth), +// a no-op under the default ortho projection, used by perspective (Camera3d). +// This is also the default vertex template used by ShaderEffect, so user- +// defined fragment-only effects honor the renderable's depth automatically. +attribute vec3 aVertex; attribute vec2 aRegion; attribute vec4 aColor; @@ -11,7 +14,7 @@ varying vec4 vColor; void main(void) { // Transform the vertex position by the projection matrix - gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0); + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); // Pass the remaining attributes to the fragment shader vColor = vec4(aColor.bgr * aColor.a, aColor.a); vRegion = aRegion; diff --git a/packages/melonjs/tests/batcher_attribute_leak.spec.js b/packages/melonjs/tests/batcher_attribute_leak.spec.js index c1bc0da155..05d459a662 100644 --- a/packages/melonjs/tests/batcher_attribute_leak.spec.js +++ b/packages/melonjs/tests/batcher_attribute_leak.spec.js @@ -5,8 +5,9 @@ import { boot, video, WebGLRenderer } from "../src/index.js"; * Regression guard for a GL state-leak between batchers. * * Each Batcher owns its own vertex attribute layout. `LitQuadBatcher` has - * 5 attributes at stride 28 (including `aNormalTextureId` at offset 24); - * `PrimitiveBatcher` has 3 attributes at stride 20. + * 5 attributes at stride 32 (including `aNormalTextureId` at offset 28); + * `PrimitiveBatcher` has 3 attributes at stride 24. (Strides include the + * vec3 `aVertex` z component added for Camera3d perspective in 19.7.) * * Vertex attribute enable/disable + stride/offset is *global* GL state, not * per-program. If batcher A enables a location and batcher B never disables @@ -93,8 +94,8 @@ describe("Batcher attribute leak between switches (WebGL)", () => { // End-to-end reproducer of the original report: a frame that draws a // sprite (quad path) followed by a primitive (line/rect) used to // throw INVALID_OPERATION on the primitive draw because quad's - // aNormalTextureId stayed enabled at stride 28 while the primitive - // buffer was only stride-20-sized. + // aNormalTextureId stayed enabled at a larger stride than the + // primitive buffer's stride. if (!isWebGL) { return; } @@ -153,10 +154,10 @@ describe("Batcher attribute leak between switches (WebGL)", () => { it("switching from litQuad → primitive disables litQuad-only attribute locations", () => { // The dispatch path that originally triggered the platformer crash: - // a normal-mapped sprite drawn through `litQuad` (5 attributes, - // stride 28) followed by a primitive draw (`primitive`, 3 attribs, - // stride 20). The lit batcher's `aNormalTextureId` and `aTextureId` - // must be disabled before the primitive's vertex buffer is uploaded. + // a normal-mapped sprite drawn through `litQuad` (5 attributes) + // followed by a primitive draw (`primitive`, 3 attribs). The lit + // batcher's `aNormalTextureId` and `aTextureId` must be disabled + // before the primitive's vertex buffer is uploaded. if (!isWebGL) { return; } diff --git a/packages/melonjs/tests/depth.spec.js b/packages/melonjs/tests/depth.spec.js new file mode 100644 index 0000000000..300ab99c0c --- /dev/null +++ b/packages/melonjs/tests/depth.spec.js @@ -0,0 +1,405 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + boot, + Renderable, + RenderState, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * Test suite for per-renderable depth plumbing (19.7). + * + * Covers the path that takes a `renderable.depth` value, threads it through + * `Renderable.preDraw` → `renderer.setDepth` → `renderer.currentDepth` → + * batcher emit functions → vertex stream as the `z` component of each + * vertex. Verifies that: + * + * 1. RenderState's save/restore stack honors `currentDepth`. + * 2. `Renderer.setDepth(v)` writes to `currentDepth`. + * 3. `Renderable.preDraw(renderer)` forwards `this.depth` to `setDepth`. + * 4. Default depth is 0. + * 5. WebGL batchers (`quad`, `litQuad`, `primitive`) declare `aVertex` at + * size 3 with the expected stride. + * 6. After a draw, the vertex buffer carries the current depth at the + * expected z offset of every emitted vertex. + * 7. A composite renderable (single preDraw → many addQuad calls) writes + * the same depth to every emitted vertex. + * 8. Custom shaders declaring `vec2 aVertex` continue to render without + * GL errors after the layout widened (backward compatibility). + */ +describe("RenderState — currentDepth", () => { + let state; + + beforeAll(() => { + state = new RenderState(); + }); + + it("should default currentDepth to 0", () => { + const fresh = new RenderState(); + expect(fresh.currentDepth).toBe(0); + }); + + it("should preserve currentDepth across save/restore", () => { + state.currentDepth = 5; + state.save(); + state.currentDepth = 99; + state.restore(800, 600); + + expect(state.currentDepth).toBe(5); + }); + + it("should preserve currentDepth across nested save/restore", () => { + state.currentDepth = 1; + state.save(); + state.currentDepth = 2; + state.save(); + state.currentDepth = 3; + state.restore(800, 600); + expect(state.currentDepth).toBe(2); + state.restore(800, 600); + expect(state.currentDepth).toBe(1); + }); + + it("should reset currentDepth to 0 on full reset", () => { + state.currentDepth = 42; + state.reset(800, 600); + expect(state.currentDepth).toBe(0); + }); +}); + +describe("Renderer.setDepth", () => { + let renderer; + + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + }); + + afterAll(() => { + // hand the world back to the default renderer for any later test files + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + it("should set renderer.currentDepth to the given value", () => { + renderer.setDepth(0); + expect(renderer.currentDepth).toBe(0); + + renderer.setDepth(7); + expect(renderer.currentDepth).toBe(7); + + renderer.setDepth(-42); + expect(renderer.currentDepth).toBe(-42); + }); + + it("should be reset by save/restore", () => { + renderer.setDepth(10); + renderer.save(); + renderer.setDepth(20); + expect(renderer.currentDepth).toBe(20); + renderer.restore(); + expect(renderer.currentDepth).toBe(10); + }); + + it("should default to 0 after renderer.reset", () => { + renderer.setDepth(50); + renderer.reset(); + expect(renderer.currentDepth).toBe(0); + }); +}); + +describe("Renderable.preDraw forwards depth", () => { + let renderer; + + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + }); + + afterAll(() => { + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + it("should call renderer.setDepth with renderable.depth during preDraw", () => { + const r = new Renderable(0, 0, 32, 32); + r.depth = 17; + + // spy on setDepth — preserve original so save/restore still works + const calls = []; + const original = renderer.setDepth.bind(renderer); + renderer.setDepth = (depth) => { + calls.push(depth); + original(depth); + }; + + try { + r.preDraw(renderer); + r.postDraw(renderer); + } finally { + renderer.setDepth = original; + } + + expect(calls).toContain(17); + }); + + it("should default to depth 0 when not explicitly set", () => { + const r = new Renderable(0, 0, 32, 32); + expect(r.depth).toBe(0); + + const calls = []; + const original = renderer.setDepth.bind(renderer); + renderer.setDepth = (depth) => { + calls.push(depth); + original(depth); + }; + + try { + r.preDraw(renderer); + r.postDraw(renderer); + } finally { + renderer.setDepth = original; + } + + expect(calls).toContain(0); + }); +}); + +describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => { + let renderer; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + }); + + afterAll(() => { + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + // QuadBatcher layout: aVertex(3) + aRegion(2) + aColor(4 UBYTE = 1 float-slot) + // + aTextureId(1) = 7 float-slots * 4 bytes = 28 bytes + it("QuadBatcher declares aVertex size 3, stride 28", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + const aVertex = batcher.attributes.find((a) => { + return a.name === "aVertex"; + }); + expect(aVertex).toBeDefined(); + expect(aVertex.size).toBe(3); + expect(batcher.stride).toBe(28); + }); + + // LitQuadBatcher adds aNormalTextureId at the tail → 8 float-slots = 32 bytes + it("LitQuadBatcher declares aVertex size 3, stride 32", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("litQuad"); + const aVertex = batcher.attributes.find((a) => { + return a.name === "aVertex"; + }); + expect(aVertex).toBeDefined(); + expect(aVertex.size).toBe(3); + expect(batcher.stride).toBe(32); + }); + + // PrimitiveBatcher: aVertex(3) + aNormal(2) + aColor(4 UBYTE = 1 float-slot) + // = 6 float-slots * 4 bytes = 24 bytes + it("PrimitiveBatcher declares aVertex size 3, stride 24", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("primitive"); + const aVertex = batcher.attributes.find((a) => { + return a.name === "aVertex"; + }); + expect(aVertex).toBeDefined(); + expect(aVertex.size).toBe(3); + expect(batcher.stride).toBe(24); + }); + + it("renderer.setDepth value is emitted as the z component of every vertex (quad)", () => { + if (!isWebGL) { + return; + } + + // switch to the quad batcher and clear any in-flight vertices + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + + // install a depth and draw one sprite — should produce 4 vertices, + // each carrying z = 42 at float-slot 2 (after x, y). + const tex = video.createCanvas(16, 16); + renderer.save(); + renderer.setDepth(42); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.restore(); + + // don't flush — read back from the still-pending vertex buffer + const vertexSize = batcher.vertexSize; + const f32 = batcher.vertexData.toFloat32(); + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(4); + + // z is at float-slot 2 of each vertex + for (let v = 0; v < 4; v++) { + expect(f32[v * vertexSize + 2]).toBeCloseTo(42, 5); + } + + // cleanup + batcher.vertexData.clear(); + }); + + it("default depth = 0 produces z = 0 in the vertex stream", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + + const tex = video.createCanvas(16, 16); + renderer.save(); + renderer.setDepth(0); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.restore(); + + const vertexSize = batcher.vertexSize; + const f32 = batcher.vertexData.toFloat32(); + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(4); + for (let v = 0; v < 4; v++) { + expect(f32[v * vertexSize + 2]).toBe(0); + } + batcher.vertexData.clear(); + }); + + it("emits the same depth across all vertices of a primitive draw", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("primitive"); + batcher.vertexData.clear(); + + renderer.save(); + renderer.setDepth(-13); + renderer.fillRect(10, 10, 50, 30); + renderer.restore(); + + const vertexSize = batcher.vertexSize; + const f32 = batcher.vertexData.toFloat32(); + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(3); + for (let v = 0; v < batcher.vertexData.vertexCount; v++) { + expect(f32[v * vertexSize + 2]).toBeCloseTo(-13, 5); + } + batcher.vertexData.clear(); + }); + + it("tint slot survives at the correct offset after vertex layout widening (regression)", () => { + // The original PR A had a bug where `VertexArrayBuffer.push()` still + // indexed by the old (pre-vec3) positional layout. The caller passed + // (x, y, z, u, v, tint, textureId) but push() wrote arg 4 ("v") to + // the tint-slot as UINT32 — corrupting tint into a bit-reinterpretation + // of a float v value, producing black/garbage rendering across every + // example. + // + // Guard: verify that after a drawImage with a known tint, the tint + // slot at the new offset 5 contains the actual tint uint32 — NOT a + // reinterpreted float. + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + + const tex = video.createCanvas(16, 16); + // distinctive tint: solid red with full alpha = 0xff0000ff in ABGR + // (drawImage's currentTint converts to uint32 via toUint32(alpha)) + renderer.save(); + renderer.setTint( + { r: 255, g: 0, b: 0, alpha: 1.0, glArray: [1, 0, 0, 1] }, + 1.0, + ); + renderer.setDepth(11); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.restore(); + + const f32 = batcher.vertexData.toFloat32(); + const u32 = batcher.vertexData.toUint32(); + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(4); + + // z at slot 2 must be 11 (proves z plumbing) + expect(f32[2]).toBeCloseTo(11, 5); + + // tint at slot 5 must be a meaningful UINT32 (non-zero, non-NaN-bit-pattern) + // — the actual value depends on currentTint × alpha encoding, but it + // MUST NOT be the bit-pattern of a float UV (which is in [0, 1] range + // → bit-pattern around 0 or 0x3F800000, NOT 0xff... — pre-fix bug + // produced exactly that) + const tintSlot = u32[5]; + expect(tintSlot).not.toBe(0); + // a normal tint uint32 with non-trivial alpha has high bits set; + // reinterpreted floats from UVs in [0,1] cluster around 0x3F800000 or 0 + expect(tintSlot).toBeGreaterThan(0xff_00_00_00 >>> 0); + + batcher.vertexData.clear(); + }); + + it("draws without GL error after vertex layout widening (regression)", () => { + // End-to-end: a frame of sprite + primitive draws at non-zero depth + // must not produce INVALID_OPERATION (stride mismatch) or + // INVALID_VALUE (out-of-range attribute) on either path. + if (!isWebGL) { + return; + } + + const gl = renderer.gl; + while (gl.getError() !== gl.NO_ERROR) { + /* drain */ + } + + const tex = video.createCanvas(16, 16); + renderer.save(); + renderer.setDepth(25); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.currentBatcher.flush(); + + renderer.setDepth(-5); + renderer.strokeRect(20, 20, 40, 40); + renderer.currentBatcher.flush(); + renderer.restore(); + + expect(gl.getError()).toBe(gl.NO_ERROR); + }); +}); diff --git a/packages/melonjs/tests/depth_adversarial.spec.js b/packages/melonjs/tests/depth_adversarial.spec.js new file mode 100644 index 0000000000..fd0c9bb08c --- /dev/null +++ b/packages/melonjs/tests/depth_adversarial.spec.js @@ -0,0 +1,599 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + boot, + GLShader, + NineSliceSprite, + RenderState, + ShaderEffect, + Sprite, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * Adversarial integration tests for the per-renderable depth pipeline + * (PR A — vec3 aVertex + renderer.setDepth, 19.7). + * + * The basic shape-of-API tests live in `depth.spec.js`. This file targets + * bug classes that only surface when the depth plumbing meets other + * subsystems mid-frame: + * + * - cross-batcher state persistence (depth is *renderer* state, not + * batcher state — switching batchers must not lose it) + * - per-vertex emission boundaries (mid-batch depth change should + * produce per-vertex distinct z without retroactively rewriting + * vertices already in the buffer) + * - composite renderables (Text → N character quads from one + * setDepth call all share that depth) + * - parent/child Container nesting (child's preDraw overrides + * parent's depth, postDraw pops it back via the save/restore stack) + * - blitTexture invariant (post-fx blits ignore currentDepth and emit + * z=0, since they live in screen space) + * - Mesh batcher independence (Mesh has its own projection — setDepth + * must not affect mesh vertices) + * - backward compat (custom shaders declaring `attribute vec2 aVertex` + * keep rendering without GL errors after the layout widened) + * - save/restore stack growth past initial 32-slot capacity + * - extreme depth values (NaN / Infinity / very-negative) don't crash GL + * - fuzz: random sequences of setDepth + draws produce no GL errors + */ +describe("depth pipeline adversarial integration", () => { + let renderer; + let gl; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + } + }); + + afterAll(() => { + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + function expectNoGLErrors() { + if (!isWebGL) { + return; + } + const err = gl.getError(); + if (err !== gl.NO_ERROR) { + expect.fail(`GL error after draw: 0x${err.toString(16)}`); + } + } + + function drainGLErrors() { + if (!isWebGL) { + return; + } + while (gl.getError() !== gl.NO_ERROR) { + /* drain stale errors from previous tests */ + } + } + + // ---- cross-batcher depth persistence ---- + + it("currentDepth survives a quad → primitive → quad batcher switch", () => { + if (!isWebGL) { + return; + } + renderer.setDepth(33); + renderer.setBatcher("quad"); + expect(renderer.currentDepth).toBe(33); + renderer.setBatcher("primitive"); + expect(renderer.currentDepth).toBe(33); + renderer.setBatcher("litQuad"); + expect(renderer.currentDepth).toBe(33); + renderer.setBatcher("quad"); + expect(renderer.currentDepth).toBe(33); + + // reset for following tests + renderer.setDepth(0); + }); + + // ---- mid-batch per-vertex emission ---- + + it("mid-batch depth change produces per-vertex distinct z without retroactive rewrite", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + const tex = video.createCanvas(16, 16); + + // first sprite at depth 100 + renderer.save(); + renderer.setDepth(100); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.restore(); + + // second sprite at depth 200 — same batcher, same in-flight buffer + renderer.save(); + renderer.setDepth(200); + renderer.drawImage(tex, 0, 0, 16, 16, 32, 0, 16, 16); + renderer.restore(); + + const f32 = batcher.vertexData.toFloat32(); + const vs = batcher.vertexSize; + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(8); + + // first quad's 4 vertices should still be at z=100 (not overwritten by setDepth(200)) + for (let v = 0; v < 4; v++) { + expect(f32[v * vs + 2]).toBeCloseTo(100, 5); + } + // second quad's 4 vertices should be at z=200 + for (let v = 4; v < 8; v++) { + expect(f32[v * vs + 2]).toBeCloseTo(200, 5); + } + batcher.vertexData.clear(); + }); + + // ---- composite renderable inheritance ---- + + it("NineSliceSprite (9-quad renderable) — every emitted quad inherits the same depth", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + + const tex = video.createCanvas(48, 48); + const nine = new NineSliceSprite(0, 0, { + width: 96, + height: 96, + image: tex, + framewidth: 48, + frameheight: 48, + }); + nine.depth = 77; + + nine.preDraw(renderer); + nine.draw(renderer); + nine.postDraw(renderer); + + const f32 = batcher.vertexData.toFloat32(); + const vs = batcher.vertexSize; + const n = batcher.vertexData.vertexCount; + // 9 quads × 4 verts = 36 (some may collapse if a slice is zero-sized, + // but we should see at least 4 distinct quads worth of vertices) + expect(n).toBeGreaterThanOrEqual(4 * 4); + + // every emitted vertex should share the same depth — composite + // renderables call preDraw once, which sets currentDepth once + for (let v = 0; v < n; v++) { + expect(f32[v * vs + 2]).toBeCloseTo(77, 5); + } + batcher.vertexData.clear(); + }); + + // ---- parent/child depth nesting via save/restore stack ---- + + it("nested preDraw/postDraw — child's vertices carry child's depth, then stack pops back", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + + const tex = video.createCanvas(16, 16); + const sprite = new Sprite(0, 0, { + framewidth: 16, + frameheight: 16, + image: tex, + }); + sprite.depth = 99; + + // simulate the engine's call order for a Container at depth=5 + // hosting a Sprite at depth=99 (avoids needing a viewport for + // Container.draw — we directly invoke the relevant preDraw / draw + // / postDraw chain). + renderer.save(); + renderer.setDepth(5); // parent's depth — would be set by Container.preDraw + const depthBeforeChild = renderer.currentDepth; + + sprite.preDraw(renderer); + sprite.draw(renderer); + sprite.postDraw(renderer); + + // after child's postDraw, the save/restore stack must have popped + // back to the parent's depth + expect(renderer.currentDepth).toBe(depthBeforeChild); + + renderer.restore(); + + // vertices emitted during sprite.draw should carry depth 99 (child's), + // not 5 (parent's) + const f32 = batcher.vertexData.toFloat32(); + const vs = batcher.vertexSize; + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(4); + for (let v = 0; v < 4; v++) { + expect(f32[v * vs + 2]).toBeCloseTo(99, 5); + } + batcher.vertexData.clear(); + }); + + // ---- blitTexture invariant (post-fx blits always z=0) ---- + + it("blitTexture ignores currentDepth and emits z=0 (screen-space invariant)", () => { + if (!isWebGL) { + return; + } + // install a deeply non-zero depth — the blit path should not pick it up + renderer.setDepth(999); + + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + + // build a real GL texture (blitTexture's first arg must be a raw + // WebGLTexture, not a TextureAtlas wrapper) + const rawTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, rawTex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([255, 255, 255, 255]), + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.bindTexture(gl.TEXTURE_2D, null); + + // blitTexture expects a shader with a `uSampler` uniform (single- + // texture path). The batcher's default shader uses multi-texture + // samplers (`uSampler0..N`), so we build a tiny ShaderEffect that + // provides the right uniform shape. + const fx = new ShaderEffect( + renderer, + `vec4 apply(vec4 color, vec2 uv) { return color; }`, + ); + + // intercept vertex pushes so we can inspect z values before the + // internal flush wipes them + const captured = []; + const originalPush = batcher.vertexData.push.bind(batcher.vertexData); + batcher.vertexData.push = (...args) => { + captured.push(args[2]); // z is the 3rd component (x, y, z, ...) + originalPush(...args); + }; + + try { + batcher.blitTexture(rawTex, 0, 0, 32, 32, fx); + } finally { + batcher.vertexData.push = originalPush; + fx.destroy(); + } + + // every blit vertex's z must be 0, regardless of currentDepth=999 + expect(captured.length).toBeGreaterThanOrEqual(4); + for (const z of captured) { + expect(z).toBe(0); + } + + // cleanup + gl.deleteTexture(rawTex); + renderer.setDepth(0); + batcher.vertexData.clear(); + }); + + // ---- Mesh batcher independence ---- + + it("Mesh batcher's pushMesh ignores currentDepth (Mesh owns its own projection)", () => { + if (!isWebGL) { + return; + } + // poison currentDepth — pushMesh must not pick this up; mesh vertices + // come from the mesh's own (already-projected) data + renderer.setDepth(-555); + + const batcher = renderer.setBatcher("mesh"); + batcher.vertexData.clear(); + + // minimal mesh-like object: one triangle at known z values + // (the WebGLRenderer.drawMesh path enables depth test + cull, so we + // can just push directly through the batcher and inspect the buffer) + const meshLike = { + vertices: new Float32Array([ + 0, + 0, + 11, // vert 0 at z=11 + 1, + 0, + 22, // vert 1 at z=22 + 0, + 1, + 33, // vert 2 at z=33 + ]), + uvs: new Float32Array([0, 0, 1, 0, 0, 1]), + indices: new Uint16Array([0, 1, 2]), + vertexCount: 3, + cullBackFaces: false, + texture: renderer.cache.get(video.createCanvas(8, 8)), + }; + + batcher.addMesh(meshLike, 0xffffffff); + + const f32 = batcher.vertexData.toFloat32(); + const vs = batcher.vertexSize; + expect(batcher.vertexData.vertexCount).toBe(3); + + // vertices in the mesh buffer must carry the mesh's *own* z values + // (11, 22, 33), not currentDepth (-555) + expect(f32[0 * vs + 2]).toBeCloseTo(11, 5); + expect(f32[1 * vs + 2]).toBeCloseTo(22, 5); + expect(f32[2 * vs + 2]).toBeCloseTo(33, 5); + + renderer.setDepth(0); + batcher.vertexData.clear(); + }); + + // ---- backward compat: custom shader declaring vec2 aVertex ---- + + it("custom GLShader declaring `attribute vec2 aVertex` renders without GL error", () => { + if (!isWebGL) { + return; + } + drainGLErrors(); + + // hand-crafted vertex shader using the pre-PR-A vec2 attribute + // declaration — must keep working because the batcher's stride and + // downstream attribute offsets stay invariant by *name* + const vertexSrc = ` + attribute vec2 aVertex; + attribute vec2 aRegion; + attribute vec4 aColor; + attribute float aTextureId; + uniform mat4 uProjectionMatrix; + varying vec2 vRegion; + varying vec4 vColor; + varying float vTextureId; + void main(void) { + gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0); + vColor = vec4(aColor.bgr * aColor.a, aColor.a); + vRegion = aRegion; + vTextureId = aTextureId; + } + `; + const fragmentSrc = ` + precision mediump float; + uniform sampler2D uSampler0; + varying vec2 vRegion; + varying vec4 vColor; + varying float vTextureId; + void main(void) { + gl_FragColor = texture2D(uSampler0, vRegion) * vColor; + } + `; + const shader = new GLShader(gl, vertexSrc, fragmentSrc, "mediump"); + + const batcher = renderer.setBatcher("quad", shader); + const tex = video.createCanvas(16, 16); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + batcher.flush(); + + expectNoGLErrors(); + + shader.destroy(); + // restore default shader for subsequent tests + renderer.setBatcher("quad"); + }); + + // ---- save/restore stack growth ---- + + it("RenderState — currentDepth survives save/restore beyond initial 32-slot capacity", () => { + const state = new RenderState(); + const depths = []; + // push 40 saves with distinct depths (capacity starts at 32 → forces _growStacks) + for (let i = 0; i < 40; i++) { + state.currentDepth = i + 1; // 1..40 + depths.push(state.currentDepth); + state.save(); + } + // mutate so restore has something to roll back from + state.currentDepth = 9999; + + // pop in reverse + for (let i = 39; i >= 0; i--) { + state.restore(800, 600); + expect(state.currentDepth).toBe(depths[i]); + } + }); + + // ---- extreme values ---- + + it("setDepth tolerates extreme values without GL errors", () => { + if (!isWebGL) { + return; + } + drainGLErrors(); + const tex = video.createCanvas(16, 16); + + for (const z of [ + 0, + -1e9, + 1e9, + -Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + Number.EPSILON, + -Number.EPSILON, + ]) { + renderer.save(); + renderer.setDepth(z); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + renderer.restore(); + expectNoGLErrors(); + } + + renderer.setDepth(0); + }); + + it("setDepth(NaN) / setDepth(Infinity) do not throw or produce GL errors", () => { + if (!isWebGL) { + return; + } + drainGLErrors(); + const tex = video.createCanvas(16, 16); + + // NaN and Infinity propagate to the vertex stream — the GPU's behavior + // is undefined visually but WebGL itself does not error on them + for (const z of [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ]) { + renderer.save(); + expect(() => { + renderer.setDepth(z); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + }).not.toThrow(); + renderer.restore(); + expectNoGLErrors(); + } + + renderer.setDepth(0); + }); + + // ---- flush boundary ---- + + it("flushing mid-frame and continuing to draw preserves depth correctly", () => { + if (!isWebGL) { + return; + } + const batcher = renderer.setBatcher("quad"); + batcher.vertexData.clear(); + const tex = video.createCanvas(8, 8); + + renderer.save(); + renderer.setDepth(7); + renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); + // forced flush — vertices are uploaded to the GPU; the next push + // goes into a freshly-cleared buffer slot 0 + batcher.flush(); + renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); + renderer.restore(); + + // the second sprite (post-flush) should still have z=7 + const f32 = batcher.vertexData.toFloat32(); + const vs = batcher.vertexSize; + expect(batcher.vertexData.vertexCount).toBeGreaterThanOrEqual(4); + for (let v = 0; v < 4; v++) { + expect(f32[v * vs + 2]).toBeCloseTo(7, 5); + } + batcher.vertexData.clear(); + }); + + // ---- fuzz ---- + + it("fuzz: 500 random (setDepth, draw, batcher-switch) ops produce no GL error", () => { + if (!isWebGL) { + return; + } + drainGLErrors(); + const tex = video.createCanvas(8, 8); + + // deterministic LCG for repro + let seed = 1234567; + const rand = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; + }; + + for (let i = 0; i < 500; i++) { + const op = Math.floor(rand() * 5); + const depth = (rand() - 0.5) * 2000; + renderer.save(); + renderer.setDepth(depth); + switch (op) { + case 0: + renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); + break; + case 1: + renderer.fillRect(0, 0, 10, 10); + break; + case 2: + renderer.strokeRect(0, 0, 10, 10); + break; + case 3: + // flush + switch batcher + renderer.flush(); + renderer.setBatcher(rand() < 0.5 ? "quad" : "primitive"); + break; + case 4: + // nested save/restore inside the outer save + renderer.save(); + renderer.setDepth(depth + 1); + renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); + renderer.restore(); + break; + default: + break; + } + renderer.restore(); + } + renderer.flush(); + expectNoGLErrors(); + + // after the burst, depth should be back to 0 (matched save/restore pairs) + expect(renderer.currentDepth).toBe(0); + }); + + // ---- one final sanity end-to-end ---- + + it("end-to-end frame: sprite + primitive + nineslice at varying depths, no error", () => { + if (!isWebGL) { + return; + } + drainGLErrors(); + const tex = video.createCanvas(16, 16); + + const sprite = new Sprite(0, 0, { + framewidth: 16, + frameheight: 16, + image: tex, + }); + sprite.depth = 10; + sprite.preDraw(renderer); + sprite.draw(renderer); + sprite.postDraw(renderer); + + renderer.save(); + renderer.setDepth(-5); + renderer.strokeRect(20, 20, 30, 30); + renderer.restore(); + + const tex48 = video.createCanvas(48, 48); + const nine = new NineSliceSprite(0, 0, { + width: 96, + height: 96, + image: tex48, + framewidth: 48, + frameheight: 48, + }); + nine.depth = 42; + nine.preDraw(renderer); + nine.draw(renderer); + nine.postDraw(renderer); + + renderer.flush(); + expectNoGLErrors(); + }); +}); diff --git a/packages/melonjs/tests/drawVertices.spec.js b/packages/melonjs/tests/drawVertices.spec.js index bb2fc90b56..830cc51823 100644 --- a/packages/melonjs/tests/drawVertices.spec.js +++ b/packages/melonjs/tests/drawVertices.spec.js @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import VertexArrayBuffer from "../src/video/buffer/vertex.js"; describe("VertexArrayBuffer", () => { - const VERTEX_SIZE = 5; // x, y, u/nx, v/ny, tint + // 6-float layout: x, y, z, u/nx, v/ny, tint — matches PrimitiveBatcher's + // post-PR-A stride (19.7). QuadBatcher adds aTextureId at slot 6. + const VERTEX_SIZE = 6; it("should initialize with correct state", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); @@ -17,56 +19,59 @@ describe("VertexArrayBuffer", () => { it("should allocate correct buffer size", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); - // 256 vertices * 5 floats * 4 bytes = 5120 bytes + // 256 vertices * 6 floats * 4 bytes expect(vab.buffer.byteLength).toBe(256 * VERTEX_SIZE * 4); }); it("should push vertices and increment count", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); - vab.push(10, 20, 0.5, 0.5, 0xffffffff); + vab.push(10, 20, 0, 0.5, 0.5, 0xffffffff); expect(vab.vertexCount).toBe(1); - vab.push(30, 40, 0.0, 1.0, 0xff00ff00); + vab.push(30, 40, 0, 0.0, 1.0, 0xff00ff00); expect(vab.vertexCount).toBe(2); }); it("should store correct float data", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); - vab.push(100, 200, 0.25, 0.75, 0xffffffff); + vab.push(100, 200, 7, 0.25, 0.75, 0xffffffff); const f32 = vab.toFloat32(); expect(f32[0]).toBe(100); expect(f32[1]).toBe(200); - expect(f32[2]).toBe(0.25); - expect(f32[3]).toBe(0.75); + expect(f32[2]).toBe(7); + expect(f32[3]).toBe(0.25); + expect(f32[4]).toBe(0.75); }); it("should store tint as Uint32 in the correct offset", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); const tint = 0xaabbccdd; - vab.push(0, 0, 0, 0, tint); + vab.push(0, 0, 0, 0, 0, tint); const u32 = vab.toUint32(); - expect(u32[4]).toBe(tint); + // tint sits at slot 5 (after x, y, z, u, v) + expect(u32[5]).toBe(tint); }); it("should write sequential vertices at correct offsets", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); - vab.push(1, 2, 3, 4, 0x11111111); - vab.push(5, 6, 7, 8, 0x22222222); + vab.push(1, 2, 3, 4, 5, 0x11111111); + vab.push(6, 7, 8, 9, 10, 0x22222222); const f32 = vab.toFloat32(); // second vertex starts at offset VERTEX_SIZE - expect(f32[VERTEX_SIZE]).toBe(5); - expect(f32[VERTEX_SIZE + 1]).toBe(6); - expect(f32[VERTEX_SIZE + 2]).toBe(7); - expect(f32[VERTEX_SIZE + 3]).toBe(8); + expect(f32[VERTEX_SIZE]).toBe(6); + expect(f32[VERTEX_SIZE + 1]).toBe(7); + expect(f32[VERTEX_SIZE + 2]).toBe(8); + expect(f32[VERTEX_SIZE + 3]).toBe(9); + expect(f32[VERTEX_SIZE + 4]).toBe(10); }); it("clear should reset vertex count but not deallocate", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); - vab.push(1, 2, 3, 4, 0xffffffff); - vab.push(5, 6, 7, 8, 0xffffffff); + vab.push(1, 2, 0, 3, 4, 0xffffffff); + vab.push(5, 6, 0, 7, 8, 0xffffffff); expect(vab.vertexCount).toBe(2); const bufferRef = vab.buffer; @@ -83,7 +88,7 @@ describe("VertexArrayBuffer", () => { // fill up most of the buffer for (let i = 0; i < 60; i++) { - vab.push(i, i, 0, 0, 0xffffffff); + vab.push(i, i, 0, 0, 0, 0xffffffff); } expect(vab.isFull(4)).toBe(true); // 60 + 4 >= 64 expect(vab.isFull(3)).toBe(false); // 60 + 3 < 64 @@ -97,8 +102,8 @@ describe("VertexArrayBuffer", () => { it("toFloat32 with subarray range", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); - vab.push(10, 20, 0, 0, 0xffffffff); - vab.push(30, 40, 0, 0, 0xffffffff); + vab.push(10, 20, 0, 0, 0, 0xffffffff); + vab.push(30, 40, 0, 0, 0, 0xffffffff); const sub = vab.toFloat32(0, VERTEX_SIZE); expect(sub.length).toBe(VERTEX_SIZE); @@ -110,21 +115,21 @@ describe("VertexArrayBuffer", () => { const vab = new VertexArrayBuffer(VERTEX_SIZE, 256); const tint1 = 0xaabbccdd; const tint2 = 0x11223344; - vab.push(0, 0, 0, 0, tint1); - vab.push(0, 0, 0, 0, tint2); + vab.push(0, 0, 0, 0, 0, tint1); + vab.push(0, 0, 0, 0, 0, tint2); const sub = vab.toUint32(VERTEX_SIZE, VERTEX_SIZE * 2); - expect(sub[4]).toBe(tint2); + expect(sub[5]).toBe(tint2); }); }); describe("drawVertices regression", () => { - const VERTEX_SIZE = 5; + const VERTEX_SIZE = 6; function drawVertices(vertexData, verts, vertexCount = verts.length) { for (let i = 0; i < vertexCount; i++) { const vert = verts[i]; - vertexData.push(vert.x, vert.y, 0, 0, 0xffffffff); + vertexData.push(vert.x, vert.y, 0, 0, 0, 0xffffffff); } } @@ -199,7 +204,9 @@ describe("drawVertices regression", () => { }); describe("lineWidth expansion", () => { - const VERTEX_SIZE = 5; + // 6-float layout: x, y, z, nx, ny, color — matches PrimitiveBatcher + // post-PR-A (19.7). Normals live at slots 3 and 4. + const VERTEX_SIZE = 6; function expandLinesToTriangles(vertexData, verts, vertexCount) { for (let i = 0; i < vertexCount; i += 2) { @@ -217,13 +224,13 @@ describe("lineWidth expansion", () => { const nx = -dy / len; const ny = dx / len; - vertexData.push(from.x, from.y, nx, ny, 0xffffffff); - vertexData.push(from.x, from.y, -nx, -ny, 0xffffffff); - vertexData.push(to.x, to.y, -nx, -ny, 0xffffffff); + vertexData.push(from.x, from.y, 0, nx, ny, 0xffffffff); + vertexData.push(from.x, from.y, 0, -nx, -ny, 0xffffffff); + vertexData.push(to.x, to.y, 0, -nx, -ny, 0xffffffff); - vertexData.push(from.x, from.y, nx, ny, 0xffffffff); - vertexData.push(to.x, to.y, -nx, -ny, 0xffffffff); - vertexData.push(to.x, to.y, nx, ny, 0xffffffff); + vertexData.push(from.x, from.y, 0, nx, ny, 0xffffffff); + vertexData.push(to.x, to.y, 0, -nx, -ny, 0xffffffff); + vertexData.push(to.x, to.y, 0, nx, ny, 0xffffffff); } } @@ -275,10 +282,11 @@ describe("lineWidth expansion", () => { expandLinesToTriangles(vertexData, verts, 2); const f32 = vertexData.toFloat32(); - expect(f32[2]).toBeCloseTo(0); - expect(f32[3]).toBeCloseTo(1); - expect(f32[VERTEX_SIZE + 2]).toBeCloseTo(0); - expect(f32[VERTEX_SIZE + 3]).toBeCloseTo(-1); + // normals live at slots 3 (nx) and 4 (ny) after x, y, z + expect(f32[3]).toBeCloseTo(0); + expect(f32[4]).toBeCloseTo(1); + expect(f32[VERTEX_SIZE + 3]).toBeCloseTo(0); + expect(f32[VERTEX_SIZE + 4]).toBeCloseTo(-1); }); it("should produce unit normals perpendicular to a vertical line", () => { @@ -291,8 +299,8 @@ describe("lineWidth expansion", () => { expandLinesToTriangles(vertexData, verts, 2); const f32 = vertexData.toFloat32(); - expect(f32[2]).toBeCloseTo(-1); - expect(f32[3]).toBeCloseTo(0); + expect(f32[3]).toBeCloseTo(-1); + expect(f32[4]).toBeCloseTo(0); }); it("should produce unit-length normals for a diagonal line", () => { @@ -305,8 +313,8 @@ describe("lineWidth expansion", () => { expandLinesToTriangles(vertexData, verts, 2); const f32 = vertexData.toFloat32(); - const nx = f32[2]; - const ny = f32[3]; + const nx = f32[3]; + const ny = f32[4]; const normalLength = Math.sqrt(nx * nx + ny * ny); expect(normalLength).toBeCloseTo(1); }); @@ -315,12 +323,13 @@ describe("lineWidth expansion", () => { const vertexData = new VertexArrayBuffer(VERTEX_SIZE, 256); const vert = { x: 42, y: 84 }; - vertexData.push(vert.x, vert.y, 0, 0, 0xffffffff); + vertexData.push(vert.x, vert.y, 0, 0, 0, 0xffffffff); const f32 = vertexData.toFloat32(); expect(f32[0]).toBe(42); expect(f32[1]).toBe(84); - expect(f32[2]).toBe(0); - expect(f32[3]).toBe(0); + expect(f32[2]).toBe(0); // z + expect(f32[3]).toBe(0); // nx + expect(f32[4]).toBe(0); // ny }); }); diff --git a/packages/melonjs/tests/sprite.spec.js b/packages/melonjs/tests/sprite.spec.js index 47a1f9731d..ddd5f14437 100644 --- a/packages/melonjs/tests/sprite.spec.js +++ b/packages/melonjs/tests/sprite.spec.js @@ -390,6 +390,7 @@ describe("Sprite", () => { }, setTint: noop, clearTint: noop, + setDepth: noop, setMask: noop, clearMask: noop, setBlendMode: noop, diff --git a/packages/melonjs/tests/vertexBuffer.spec.js b/packages/melonjs/tests/vertexBuffer.spec.js index 2d91bc0683..26cb8e0a9d 100644 --- a/packages/melonjs/tests/vertexBuffer.spec.js +++ b/packages/melonjs/tests/vertexBuffer.spec.js @@ -4,134 +4,140 @@ import { buildMultiTextureFragment } from "../src/video/webgl/shaders/multitextu describe("VertexArrayBuffer", () => { describe("push()", () => { - it("should write vertex data at the correct offsets (5 floats)", () => { - // vertexSize=5 matches the quad format: x, y, u, v, tint - const buf = new VertexArrayBuffer(5, 4); + // PR A (19.7) widened aVertex to vec3, adding a per-vertex `z` + // component at slot 2. All downstream offsets shifted by 1. + it("should write vertex data at the correct offsets (6 floats)", () => { + // vertexSize=6 matches the primitive format: x, y, z, nx, ny, color + const buf = new VertexArrayBuffer(6, 4); - buf.push(10, 20, 0.0, 1.0, 0xffffffff); + buf.push(10, 20, 5, 0.0, 1.0, 0xffffffff); expect(buf.vertexCount).toBe(1); expect(buf.bufferF32[0]).toBe(10); // x expect(buf.bufferF32[1]).toBe(20); // y - expect(buf.bufferF32[2]).toBe(0.0); // u - expect(buf.bufferF32[3]).toBe(1.0); // v - expect(buf.bufferU32[4]).toBe(0xffffffff); // tint + expect(buf.bufferF32[2]).toBe(5); // z + expect(buf.bufferF32[3]).toBe(0.0); // u / nx + expect(buf.bufferF32[4]).toBe(1.0); // v / ny + expect(buf.bufferU32[5]).toBe(0xffffffff); // tint }); - it("should write vertex data with textureId (6 floats)", () => { - // vertexSize=6 matches the multi-texture quad format - const buf = new VertexArrayBuffer(6, 4); + it("should write vertex data with textureId (7 floats)", () => { + // vertexSize=7 matches the multi-texture quad format + const buf = new VertexArrayBuffer(7, 4); - buf.push(10, 20, 0.0, 1.0, 0xffffffff, 3); + buf.push(10, 20, 5, 0.0, 1.0, 0xffffffff, 3); expect(buf.vertexCount).toBe(1); expect(buf.bufferF32[0]).toBe(10); // x expect(buf.bufferF32[1]).toBe(20); // y - expect(buf.bufferF32[2]).toBe(0.0); // u - expect(buf.bufferF32[3]).toBe(1.0); // v - expect(buf.bufferU32[4]).toBe(0xffffffff); // tint - expect(buf.bufferF32[5]).toBe(3); // textureId + expect(buf.bufferF32[2]).toBe(5); // z + expect(buf.bufferF32[3]).toBe(0.0); // u + expect(buf.bufferF32[4]).toBe(1.0); // v + expect(buf.bufferU32[5]).toBe(0xffffffff); // tint + expect(buf.bufferF32[6]).toBe(3); // textureId }); - it("should write default textureId 0 when not provided (vertexSize 6)", () => { - const buf = new VertexArrayBuffer(6, 4); + it("should write default textureId 0 when not provided (vertexSize 7)", () => { + const buf = new VertexArrayBuffer(7, 4); - buf.push(10, 20, 0.0, 1.0, 0xffffffff); + buf.push(10, 20, 0, 0.0, 1.0, 0xffffffff); expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[5]).toBe(0); // default 0 + expect(buf.bufferF32[6]).toBe(0); // default 0 }); - it("should not write textureId when vertexSize is 5", () => { - const buf = new VertexArrayBuffer(5, 4); + it("should not write textureId when vertexSize is 6", () => { + const buf = new VertexArrayBuffer(6, 4); - // write a sentinel at offset 5 - buf.bufferF32[5] = 99; - buf.push(10, 20, 0.0, 1.0, 0xffffffff); + // write a sentinel at offset 6 + buf.bufferF32[6] = 99; + buf.push(10, 20, 0, 0.0, 1.0, 0xffffffff); expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[5]).toBe(99); // untouched + expect(buf.bufferF32[6]).toBe(99); // untouched }); it("should write multiple vertices sequentially", () => { - const buf = new VertexArrayBuffer(5, 4); + const buf = new VertexArrayBuffer(6, 4); - buf.push(1, 2, 0.0, 0.0, 0xff000000); - buf.push(3, 4, 1.0, 1.0, 0x00ff0000); + buf.push(1, 2, 0, 0.0, 0.0, 0xff000000); + buf.push(3, 4, 7, 1.0, 1.0, 0x00ff0000); expect(buf.vertexCount).toBe(2); - // second vertex starts at offset 5 - expect(buf.bufferF32[5]).toBe(3); // x - expect(buf.bufferF32[6]).toBe(4); // y - expect(buf.bufferF32[7]).toBe(1.0); // u - expect(buf.bufferF32[8]).toBe(1.0); // v - expect(buf.bufferU32[9]).toBe(0x00ff0000); // tint + // second vertex starts at offset 6 + expect(buf.bufferF32[6]).toBe(3); // x + expect(buf.bufferF32[7]).toBe(4); // y + expect(buf.bufferF32[8]).toBe(7); // z + expect(buf.bufferF32[9]).toBe(1.0); // u / nx + expect(buf.bufferF32[10]).toBe(1.0); // v / ny + expect(buf.bufferU32[11]).toBe(0x00ff0000); // tint }); it("should write multiple vertices with textureId sequentially", () => { - const buf = new VertexArrayBuffer(6, 4); + const buf = new VertexArrayBuffer(7, 4); - buf.push(1, 2, 0.0, 0.0, 0xff000000, 0); - buf.push(3, 4, 1.0, 1.0, 0x00ff0000, 5); + buf.push(1, 2, 0, 0.0, 0.0, 0xff000000, 0); + buf.push(3, 4, 0, 1.0, 1.0, 0x00ff0000, 5); expect(buf.vertexCount).toBe(2); - expect(buf.bufferF32[5]).toBe(0); // textureId vertex 0 - expect(buf.bufferF32[11]).toBe(5); // textureId vertex 1 + expect(buf.bufferF32[6]).toBe(0); // textureId vertex 0 + expect(buf.bufferF32[13]).toBe(5); // textureId vertex 1 }); - it("should write vertex data with textureId + normalTextureId (7 floats)", () => { - // vertexSize=7 matches the lit-pipeline quad format: - // x, y, u, v, tint, textureId, normalTextureId - const buf = new VertexArrayBuffer(7, 4); + it("should write vertex data with textureId + normalTextureId (8 floats)", () => { + // vertexSize=8 matches the lit-pipeline quad format: + // x, y, z, u, v, tint, textureId, normalTextureId + const buf = new VertexArrayBuffer(8, 4); - buf.push(10, 20, 0.0, 1.0, 0xffffffff, 3, 2); + buf.push(10, 20, 5, 0.0, 1.0, 0xffffffff, 3, 2); expect(buf.vertexCount).toBe(1); expect(buf.bufferF32[0]).toBe(10); expect(buf.bufferF32[1]).toBe(20); - expect(buf.bufferF32[2]).toBe(0.0); - expect(buf.bufferF32[3]).toBe(1.0); - expect(buf.bufferU32[4]).toBe(0xffffffff); - expect(buf.bufferF32[5]).toBe(3); // textureId - expect(buf.bufferF32[6]).toBe(2); // normalTextureId + expect(buf.bufferF32[2]).toBe(5); // z + expect(buf.bufferF32[3]).toBe(0.0); + expect(buf.bufferF32[4]).toBe(1.0); + expect(buf.bufferU32[5]).toBe(0xffffffff); + expect(buf.bufferF32[6]).toBe(3); // textureId + expect(buf.bufferF32[7]).toBe(2); // normalTextureId }); - it("should default normalTextureId to -1 when not provided (vertexSize 7)", () => { - // Regression test: with vertexSize=7, omitting `normalTextureId` - // must NOT leave stale data at offset 6. The shader's lit path + it("should default normalTextureId to -1 when not provided (vertexSize 8)", () => { + // Regression test: with vertexSize=8, omitting `normalTextureId` + // must NOT leave stale data at offset 7. The shader's lit path // activates on `vNormalTextureId >= 0`; reading garbage there // causes unlit sprites to render through the lit path with // random normal-map / light state — visible as broken // hemispheric shading on every WebGL example after the // normal-map vertex format was introduced. - const buf = new VertexArrayBuffer(7, 4); + const buf = new VertexArrayBuffer(8, 4); // poison the slot to prove push() actively writes -1 - buf.bufferF32[6] = 99; + buf.bufferF32[7] = 99; - buf.push(10, 20, 0.0, 1.0, 0xffffffff, 3); + buf.push(10, 20, 0, 0.0, 1.0, 0xffffffff, 3); - expect(buf.bufferF32[6]).toBe(-1); + expect(buf.bufferF32[7]).toBe(-1); }); it("should accept negative normalTextureId (sentinel for unlit)", () => { - const buf = new VertexArrayBuffer(7, 4); - buf.push(0, 0, 0, 0, 0, 0, -1); - expect(buf.bufferF32[6]).toBe(-1); + const buf = new VertexArrayBuffer(8, 4); + buf.push(0, 0, 0, 0, 0, 0, 0, -1); + expect(buf.bufferF32[7]).toBe(-1); }); - it("should not touch offset 6 when vertexSize is 6 (backward compat)", () => { - // older quad batchers using vertexSize=6 mustn't get any - // writes past offset 5 - const buf = new VertexArrayBuffer(6, 4); + it("should not touch offset 7 when vertexSize is 7 (backward compat)", () => { + // older quad batchers using vertexSize=7 mustn't get any + // writes past offset 6 + const buf = new VertexArrayBuffer(7, 4); // pre-fill the next vertex's first slot with a sentinel - buf.bufferF32[6] = 77; + buf.bufferF32[7] = 77; // pass a normalTextureId — should be silently dropped because - // vertexSize is 6 (the unlit layout has no `aNormalTextureId`) - buf.push(10, 20, 0, 0, 0xff, 1, 9); - // offset 6 is the next vertex's first float — verify push() + // vertexSize is 7 (the unlit layout has no `aNormalTextureId`) + buf.push(10, 20, 0, 0, 0, 0xff, 1, 9); + // offset 7 is the next vertex's first float — verify push() // didn't bleed into it - expect(buf.bufferF32[6]).toBe(77); + expect(buf.bufferF32[7]).toBe(77); }); }); @@ -200,21 +206,21 @@ describe("VertexArrayBuffer", () => { describe("isFull()", () => { it("should return true when adding vertices would exceed capacity", () => { - const buf = new VertexArrayBuffer(5, 2); + const buf = new VertexArrayBuffer(6, 2); expect(buf.isFull(2)).toBe(true); expect(buf.isFull(1)).toBe(false); - buf.push(0, 0, 0, 0, 0); + buf.push(0, 0, 0, 0, 0, 0); expect(buf.isFull(1)).toBe(true); }); }); describe("clear()", () => { it("should reset vertex count to zero", () => { - const buf = new VertexArrayBuffer(5, 4); + const buf = new VertexArrayBuffer(6, 4); - buf.push(1, 2, 3, 4, 0); + buf.push(1, 2, 0, 3, 4, 0); expect(buf.vertexCount).toBe(1); buf.clear(); From 0157f1d2469c2cc4608efec4c31bb294949c481d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 15:27:48 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(camera2d):=20widen=20default=20near/far?= =?UTF-8?q?=20to=20=C2=B11e6=20(19.7=20PR=20A=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on PR #1463: with `aVertex.z` now participating in clip-space (PR A), any `Renderable.depth` outside the camera's `[near, far]` range maps outside clip space and the GPU silently culls the fragment. Pre-19.7 the default ±1000 didn't matter because clipspace.z was hardcoded to 0 in the vertex shader. The defaults are easy to exceed in practice: - `Container.autoDepth = true` (default) assigns `pos.z = childCount` — any container with >1000 children clip-culls - Y-sort patterns (`sprite.depth = sprite.pos.y`) on tall maps easily exceed 1000 Widens to ±1e6 to cover every realistic 2D depth value while staying well within float32 precision. Override per-camera for tighter z clipping (e.g. Camera3d will use a much smaller range for meaningful perspective z resolution). Adds a regression test in `depth.spec.js` that pins the new defaults and proves a depth=10000 sprite's clipspace.z lands inside [-1, 1] under the new ortho matrix. 3558 tests pass (was 3557 + 1 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/camera/camera2d.ts | 19 +++++++++----- packages/melonjs/tests/depth.spec.js | 33 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 9b557be22a..dbe7d8b7fd 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed - **Vertex attribute layout: `aVertex` widened from `vec2` to `vec3`** across `quad-multi.vert`, `quad-multi-lit.vert`, `primitive.vert`, `orthogonal-tmxlayer.vert`. `Mesh`'s shader already used `vec3 aVertex` — all batchers are now uniform. Per-vertex stride grows by 4 bytes (24 → 28 for the quad layout). Custom shaders binding attributes by name (`gl.getAttribLocation`) — the standard pattern, and what `GLShader` enforces — keep working unchanged; the byte-offset shift of `aRegion` / `aColor` / `aTextureId` is transparent because the batcher updates its own `vertexAttribPointer` offsets. Custom shaders declaring `attribute vec2 aVertex;` continue to work — WebGL silently drops the unused z component. - **`VertexArrayBuffer.push()` signature gained a `z` parameter** between `y` and `u`: `push(x, y, z, u, v, tint, textureId?, normalTextureId?)`. `VertexArrayBuffer` is an internal class (not exported), but **subclasses of `QuadBatcher` / `PrimitiveBatcher` that reimplement `addQuad` / `drawVertices` from scratch** (not via `super.addQuad()`) and push to `vertexData` directly need to update their push call to the 7-arg form (insert `z` after `y`, default `0` works under ortho). Subclasses that delegate to `super.addQuad()` are unaffected. +- **`Camera2d` default near/far widened from `±1000` to `±1e6`.** With `aVertex.z` now participating in clip-space, the previous defaults would silently clip-cull any sprite with `depth` outside that range — common pitfalls being `Container.autoDepth = true` (default, assigns `pos.z = childCount`) with >1000 children, and Y-sort patterns (`sprite.depth = sprite.pos.y`) on tall maps. The new range covers every realistic 2D depth value while staying well within float32 precision. Override per-camera (`camera.near = -100; camera.far = 100`) for tighter z clipping when needed (e.g. perspective mode in Camera3d, PR B). ### Fixed - None. diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index 026dd771c9..afc66cdc07 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -84,14 +84,21 @@ export default class Camera2d extends Renderable { damping: number; /** - * the closest point relative to the camera - * @default -1000 + * the closest point relative to the camera. Widened from `-1000` in + * 19.7 to accommodate sprites at large `depth` values now that + * `aVertex.z` participates in clip-space (PR A): the default + * `Container.autoDepth = true` assigns `pos.z = childCount` so any + * container with >1000 children would otherwise clip-cull, and the + * common Y-sort pattern `sprite.depth = sprite.pos.y` exceeds 1000 + * on tall maps. Override per-camera if you need tighter z clipping. + * @default -1e6 */ near: number; /** - * the furthest point relative to the camera. - * @default 1000 + * the furthest point relative to the camera. Widened from `1000` in + * 19.7 — see {@link Camera2d#near}. + * @default 1e6 */ far: number; @@ -206,9 +213,9 @@ export default class Camera2d extends Renderable { this.damping = 1.0; - this.near = -1000; + this.near = -1e6; - this.far = 1000; + this.far = 1e6; this.projectionMatrix = new Matrix3d(); diff --git a/packages/melonjs/tests/depth.spec.js b/packages/melonjs/tests/depth.spec.js index 300ab99c0c..12608fa084 100644 --- a/packages/melonjs/tests/depth.spec.js +++ b/packages/melonjs/tests/depth.spec.js @@ -402,4 +402,37 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => { expect(gl.getError()).toBe(gl.NO_ERROR); }); + + it("Camera2d default near/far cover large depth values without clip-culling (regression)", async () => { + // Pre-PR-A, `aVertex` was vec2 and `gl_Position` hardcoded z=0 in the + // vertex shader — clipspace.z was always 0, so the camera's near/far + // values didn't affect sprite clipping. With vec3 aVertex carrying + // per-sprite depth, clipspace.z = -depth / (near-far range). Any + // `Renderable.depth` outside the [near, far] range maps outside + // clip space and the GPU silently culls the fragment. + // + // Failure modes this catches: + // - `Container.autoDepth = true` (default!) assigns `pos.z = childCount` + // so any container with >old-far children would clip-cull + // - Y-sort on tall maps: `sprite.depth = sprite.pos.y` easily exceeds + // the old ±1000 default + // + // 19.7 widens Camera2d's default near/far from ±1000 to ±1e6 to keep + // every realistic 2D depth value visible. This test pins those defaults. + const { Camera2d } = await import("../src/index.js"); + const cam = new Camera2d(0, 0, 800, 600); + expect(cam.near).toBeLessThanOrEqual(-1e5); + expect(cam.far).toBeGreaterThanOrEqual(1e5); + + // end-to-end: render a sprite at depth=10000 (10× the pre-19.7 default + // far plane) and verify it produces no GL error AND the vertex still + // carries the depth in the buffer. If clip-culling fires, no GL error + // would surface, but the vertex itself would still be in the buffer. + // A more discriminating check: project depth=10000 through the camera's + // ortho matrix and verify clipspace.z lands inside [-1, 1]. + const m = cam.projectionMatrix.val; + // ortho row 3: clipspace.z = a[10] * z + a[14] * 1 + const clipZAtFar2D = m[10] * 10000 + m[14]; + expect(Math.abs(clipZAtFar2D)).toBeLessThan(1); + }); });