From 99aed644c75d8aab106a929d8d439ac3dec578de Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 25 Jun 2026 20:10:23 +0800 Subject: [PATCH] test(sprite3d): cover the WebGL draw path + CHANGELOG highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1513. Adds an automated WebGL draw smoke for Sprite3d — the path that was previously only validated by the Billboard example screenshot (and where the getTexture regression once hid). `tests/sprite3d_webgl.spec.js` drives real Sprite3d instances through `WebGLRenderer.drawMesh` → `MeshBatcher` under a Camera3d and asserts the draw reaches the GPU (`drawElements > 0`) without throwing: - plain-image Sprite3d (the getTexture/atlas-resolution case) - animated spritesheet (atlas UVs uploaded) - alphaCutoff default 0.5 + the alphaCutoff:0 path (uniform set) - a flipped sprite Runs under software WebGL (failIfMajorPerformanceCaveat:false); skips when WebGL2 is unavailable, mirroring the existing webgl_mesh_depth harness. Also adds Sprite3d / 2.5D to the 19.8 CHANGELOG Highlights line (it was glTF-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/tests/sprite3d_webgl.spec.js | 196 ++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 packages/melonjs/tests/sprite3d_webgl.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 018b36d17..6979b3858 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -2,7 +2,7 @@ ## [19.8.0] (melonJS 2) - _unreleased_ -**Highlights:** glTF / GLB scene loading lands — author a 3D scene in Blender (or any DCC tool), export a `.glb`, and load it like a Tiled map with `level.load(...)`. Animated models play back through the same `setCurrentAnimation` / `play` / `pause` / `stop` API as a 2D `Sprite`. Scene meshes are lit by the authored sun, and 3D meshes can now report a real bounding box. +**Highlights:** glTF / GLB scene loading lands — author a 3D scene in Blender (or any DCC tool), export a `.glb`, and load it like a Tiled map with `level.load(...)`. Animated models play back through the same `setCurrentAnimation` / `play` / `pause` / `stop` API as a 2D `Sprite`. Scene meshes are lit by the authored sun, and 3D meshes can now report a real bounding box. And `Sprite3d` brings the 2.5D workflow — billboarded, frame-animated cut-out sprites that face a `Camera3d` (the Paper Mario look), sharing one `FrameAnimation` engine with the 2D `Sprite`. ### Added - **`loader.preload()` / `loader.load()` are now `await`-able** — `preload(assets)` returns a `Promise` that resolves once every asset has loaded (and rejects on failure), so you can `await loader.preload(assets)` instead of nesting an `onload` callback. `load(asset)` called **without** callbacks likewise returns a Promise for a one-off dynamic load. Both are fully back-compat: the callback forms (and `loader.onload` / `LOADER_PROGRESS` / `LOADER_ERROR` events) are unchanged, and `load(asset, onload, onerror)` still returns the resource count. (`preload` was already promise-based internally — it just didn't hand the promise back.) diff --git a/packages/melonjs/tests/sprite3d_webgl.spec.js b/packages/melonjs/tests/sprite3d_webgl.spec.js new file mode 100644 index 000000000..d73ac8e66 --- /dev/null +++ b/packages/melonjs/tests/sprite3d_webgl.spec.js @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + boot, + Camera3d, + Sprite3d, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * Sprite3d through the real WebGL draw path. + * + * The other Sprite3d specs run under the Canvas renderer and exercise the + * billboard / atlas / flip *math* directly (`_projectVerticesWorld`, `uvs`, + * `originalVertices`) — they never push a Sprite3d through `WebGLRenderer. + * drawMesh` → `MeshBatcher` (texture upload, the `uAlphaCutoff` uniform, atlas + * UVs reaching the GPU). That path was previously only validated by hand via the + * Billboard example screenshot — and it's exactly where a regression hid once + * (an atlas resolved without a usable `getTexture()` → `MeshBatcher.uploadTexture` + * read `.width` of `undefined`). These smokes draw real Sprite3d instances under + * a `Camera3d` + WebGL renderer and assert the draw actually reaches the GPU + * without throwing. + * + * Skips when WebGL2 isn't available (headless CI without GPU flags); runs + * locally and on GPU-backed runners. Mirrors the harness in + * `webgl_mesh_depth.spec.js`. + */ +describe("Sprite3d — WebGL draw path", () => { + let renderer; + + beforeAll(async () => { + await boot(); + try { + video.init(128, 128, { + parent: "screen", + renderer: video.WEBGL, + // headless chromium uses a software GL backend that trips the + // "major performance caveat" flag — opt out so the WebGL renderer + // is actually used instead of falling back to Canvas + failIfMajorPerformanceCaveat: false, + }); + } catch { + // genuine WebGL absence — tests skip below + } + if ( + video.renderer instanceof WebGLRenderer && + video.renderer.WebGLVersion === 2 + ) { + renderer = video.renderer; + } + }); + + afterAll(() => { + // restore AUTO so this spec doesn't leak a forced-WebGL renderer into + // other specs sharing the `video` global + try { + video.init(128, 128, { parent: "screen", renderer: video.AUTO }); + } catch { + // ignore + } + }); + + const requireWebGL2 = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL2 renderer not available in this environment"); + } + }; + + // solid opaque texture (alpha = 1 everywhere, so alphaCutoff keeps it all) + const makeTex = (w, h) => { + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + const ctx = c.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + return c; + }; + + // spy gl.drawElements to confirm the mesh actually reached the GPU (a draw + // that silently no-ops would pass a bare "didn't throw" check) + const spyDraw = (gl) => { + let count = 0; + const orig = gl.drawElements.bind(gl); + gl.drawElements = (...args) => { + count++; + return orig(...args); + }; + return { + count: () => { + return count; + }, + restore: () => { + gl.drawElements = orig; + }, + }; + }; + + // draw a Sprite3d head-on under a Camera3d; returns the drawElements count + const drawOnce = (sprite) => { + const cam = new Camera3d(0, 0, 128, 128); + cam.pos.set(0, 0, 400); + cam.lookAt(0, 0, 0); + const spy = spyDraw(renderer.gl); + try { + renderer.clear(); + sprite.preDraw(renderer, cam); + sprite.draw(renderer, cam); + sprite.postDraw(renderer, cam); + renderer.flush(); + return spy.count(); + } finally { + spy.restore(); + } + }; + + it("draws a plain-image Sprite3d (no framewidth) — the getTexture path", (ctx) => { + requireWebGL2(ctx); + // the exact regression case: a plain image with no framewidth must + // resolve a frame-aware atlas whose getTexture() the batcher can upload + const s = new Sprite3d(0, 0, { + image: makeTex(32, 32), + width: 64, + height: 64, + z: 0, + billboard: "cylindrical", + }); + let draws = 0; + expect(() => { + draws = drawOnce(s); + }).not.toThrow(); + expect(draws).toBeGreaterThan(0); + }); + + it("draws an animated spritesheet Sprite3d (atlas UVs reach the GPU)", (ctx) => { + requireWebGL2(ctx); + const s = new Sprite3d(0, 0, { + image: makeTex(128, 32), // 4× 32px frames + framewidth: 32, + frameheight: 32, + width: 48, + height: 48, + z: 0, + billboard: "spherical", + }); + s.addAnimation("walk", [0, 1, 2, 3], 100); + s.setCurrentAnimation("walk"); + s.update(120); // advance a frame so non-default UVs are uploaded + let draws = 0; + expect(() => { + draws = drawOnce(s); + }).not.toThrow(); + expect(draws).toBeGreaterThan(0); + }); + + it("draws with alphaCutoff (default 0.5) — sets the uniform without throwing", (ctx) => { + requireWebGL2(ctx); + const s = new Sprite3d(0, 0, { + image: makeTex(32, 32), + width: 64, + height: 64, + z: 0, + billboard: "cylindrical", + }); + expect(s.alphaCutoff).toBe(0.5); + expect(() => { + drawOnce(s); + }).not.toThrow(); + // re-draw with cutout disabled (different uniform value) — also fine + const opaque = new Sprite3d(0, 0, { + image: makeTex(32, 32), + width: 64, + height: 64, + z: 0, + alphaCutoff: 0, + }); + expect(() => { + drawOnce(opaque); + }).not.toThrow(); + }); + + it("draws a flipped Sprite3d without throwing", (ctx) => { + requireWebGL2(ctx); + const s = new Sprite3d(0, 0, { + image: makeTex(32, 32), + width: 64, + height: 64, + z: 0, + billboard: "cylindrical", + flipX: true, + }); + expect(() => { + drawOnce(s); + }).not.toThrow(); + }); +});