From c6af237ec5f133d01e292c7852a33b09b291388a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 1 Sep 2026 16:21:37 +0800 Subject: [PATCH 1/4] Mesh: a transparent pass, so a faded mesh actually fades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setOpacity(0.5)` did not make a mesh see-through. The mesh tier renders opaque — `MeshBatcher.bind()` disables GL_BLEND — and every mesh vertex shader premultiplies, so a faded mesh wrote `(rgb x a, a)` straight into the target: darkened toward black, with the background contributing nothing. Measured before the change: a white mesh at half opacity over a blue background read [127,127,127], not [127,127,255]. That is the same defect as the far end of the range, which painted an opaque black silhouette until it was fixed, and the Canvas backend never had it — meshes there already fade under the 2D context. Nobody was relying on the darkening; the uncommitted sledding example sets `rabbit.alpha = 0.35` for an invulnerability flash and has been getting a dark rabbit rather than a translucent one. Draws resolving to fractional alpha are now queued and replayed after the opaque pass, back-to-front, blending, writing no depth but still depth-tested. `transparent: true` opts in a soft-alpha TEXTURE the automatic check cannot see into; `transparent: false` pins the old behaviour. Blending honours the renderable's existing `blendMode`. Ground shadows become the queue's first client rather than the feature itself. `queueGroundShadow` and `flushGroundShadows` stay as a delegate and an alias, so the 52 tests in ground_shadow.spec.js pass with nothing but internals renames — the regression gate for the whole refactor. Three defects found on the way, none of them the feature: - `beginBlendedDraw` passed `renderer.premultipliedAlpha` to the blend function. That flag describes source TEXTURES; the mesh shaders premultiply unconditionally, so a non-premultiplied context selected SRC_ALPHA and multiplied by alpha twice. Invisible while decals were the only client, because their source colour is black. - `Renderer.reset()` zeroed the queue count without releasing entry references, keeping every queued renderable reachable until its pooled slot was reused. - `Sprite3d`'s `alphaCutoff` default of 0.5 would have discarded every soft texel before blending saw it, defeating `transparent: true` outright. It now drops to 1/255 when transparency is explicit. Also removes two fossils that would mislead the next reader here: a comment in `setBatcher` describing a drain #1630 deleted, and an orphaned JSDoc block documenting the method that went with it. Additive: an opaque scene pays two property reads and one compare against a packed tint both backends already compute, plus three early-returning flushes per frame. No shader change, and WebGPU pipeline keys are unchanged. Closes #1516 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/skills/melonjs-3d/SKILL.md | 49 +- .../melonjs/src/application/application.ts | 2 +- packages/melonjs/src/camera/camera2d.ts | 2 +- packages/melonjs/src/renderable/container.js | 2 +- packages/melonjs/src/renderable/mesh.js | 49 ++ packages/melonjs/src/renderable/sprite3d.js | 15 +- packages/melonjs/src/video/renderer.js | 234 +++++++--- .../src/video/webgl/batchers/mesh_batcher.js | 44 +- .../melonjs/src/video/webgl/webgl_renderer.js | 95 ++-- .../src/video/webgpu/batchers/mesh_batcher.js | 5 +- .../src/video/webgpu/webgpu_renderer.js | 39 +- packages/melonjs/tests/ground_shadow.spec.js | 30 +- .../melonjs/tests/transparent_queue.spec.js | 432 ++++++++++++++++++ packages/melonjs/tests/webgl_mesh_fog.spec.js | 9 +- .../tests/webgpu_mesh_retained.spec.js | 7 +- 16 files changed, 865 insertions(+), 150 deletions(-) create mode 100644 packages/melonjs/tests/transparent_queue.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index c92ca82ee..7b240b297 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.4.0] (melonJS 2) - _unreleased_ ### Added +- **Soft transparency for the 3D tier** ([#1516](https://github.com/melonjs/melonJS/issues/1516)): a mesh now fades when you fade it. `setOpacity(0.5)` used to write premultiplied colour with blending off, so the mesh came out **darkened toward black** with the background contributing nothing — the same defect as the fully-transparent end of the range, which painted an opaque black silhouette until it was fixed. Draws that resolve to fractional alpha go into a **transparent pass** instead: replayed back-to-front after the opaque one, blending, writing no depth but still depth-tested, so transparent objects composite with each other and stay correctly hidden behind opaque geometry. Blending honours the renderable's existing `blendMode`, so `"additive"` gives glows. `transparent: true` opts in a soft-alpha *texture* (a glTF `alphaMode: "BLEND"` material, a glow sprite) that the automatic check cannot see into — `Sprite3d` drops its `alphaCutoff` default to `1/255` in that case, since a half-opacity cutout would otherwise discard the soft edge before blending saw it; `transparent: false` pins the old opaque behaviour. Sorting is per object, so intersecting transparent meshes remain order-dependent. Ground shadows now ride the same pass as its first client. Needs a GPU backend and a `Camera3d`; a scene with no transparent objects renders identically and never enters the queue - **Height falloff for distance fog** ([#1633](https://github.com/melonjs/melonJS/issues/1633)): `camera.setFog({ ..., fogHeight, heightFalloff })` makes fog density drop with altitude, so mist pools in low ground instead of hanging as thickly over a ridge as over the valley floor. Uniform fog gives you one dial for two jobs — tune it so a valley has atmosphere and the skyline washes out with it, tune it so the peaks stay crisp and the low ground has no air in it. This separates them. `heightFalloff` defaults to `0`, which is not a special case but the same integral with the dial at zero, so a scene that omits it renders exactly as before. Costs one `exp` per vertex: the density falls off exponentially with height, and an exponential integrates analytically along a straight segment, so there is no ray marching and no volume texture. Render space is **Y-down**, so `fogHeight` is the floor and density rises below it — the opposite sign to the usual published form - **Distance fog for the 3D tier** ([#1622](https://github.com/melonjs/melonJS/issues/1622)): `camera.setFog({ mode, near, far, density, color })` fades mesh geometry toward a colour with distance — `"linear"` between two distances, or `"exp2"` from a single density, the two parameterisations inherited from fixed-function graphics pipelines. It is the cheapest thing that stops a 3D scene reading as flat cut-outs, and it hides the far plane so props can appear without a visible edge. Every parameter is optional and the omitted ones resolve **live**: the distances track the camera's own clip planes, so fog cannot silently disagree with them after a later `setClipPlanes`, and the colour tracks `renderer.backgroundColor`, so geometry dissolves into the sky you already set — including through a day/night fade. Pass `color` only when the fog should differ from the backdrop. Measured radially and applied per fragment, so it neither slides as the camera turns nor bands across large triangles. Fog belongs to the camera, so split-screen and minimap views fog independently and a `Camera2d` never fogs; a mesh opts out with `fog: false`, for a marker that must stay readable at any distance. **Off by default**, and not merely skipped at runtime: fog is compiled out on both backends — `#define FOG` on WebGL, an `enable_fog` pipeline-overridable constant on WebGPU — so a scene that never calls `setFog` runs the shader it ran before fog existed - Mesh: `settings.vertexColors` and `setVertexColor(index, color)` give procedural geometry a per-vertex colour, multiplied into `tint`. Both batchers already wrote a per-vertex `aColor` on WebGL and WebGPU, but the array could only ever be built internally from a multi-material OBJ — so a mesh you built yourself had no way to reach it. `tint` is per *object*, so a terrain built as one mesh could only be tinted whole; this is what lets it fade toward the sky with distance, or darken in a crease, without splitting the mesh or writing a shader. Takes packed RGBA8 (`Uint32Array`, the form the batchers read) or one `Color` per vertex; a length that does not match the vertex count throws rather than mis-colouring the tail ([#1624](https://github.com/melonjs/melonJS/issues/1624)) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 04e864ee0..4f88d0ef9 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-3d -description: "Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, height fog, heightFalloff, castGroundShadow, lit." +description: "Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, height fog, heightFalloff, transparent, transparency, alpha, blendMode, fade, castGroundShadow, lit." license: MIT --- @@ -112,6 +112,50 @@ Both hold at any camera position. A HUD given the huge z that would put it on top in 2D lands at the far end of the level instead, with the scenery drawing over it. +## Transparency + +A mesh fades by setting its opacity — there is nothing else to switch on: + +```js +ghost.setOpacity(0.4); +``` + +Meshes render in two phases. The **opaque pass** writes depth in sort order; the +**transparent pass** replays afterwards, back-to-front, blending and writing no +depth. A draw lands in the second whenever its alpha is fractional. + +That default matters because the opaque path writes premultiplied colour with +blending off, so before this a faded mesh came out **darkened toward black** +rather than see-through — the background contributed nothing. + +**`transparent: true`** when the transparency is in the TEXTURE rather than the +opacity — a soft-edged glow, smoke, a glTF material with `alphaMode: "BLEND"`. +The automatic check reads the draw's alpha and cannot see into a texture. Watch +`alphaCutoff` here: it discards texels *before* blending sees them, so a soft +edge needs a low cutoff (`Sprite3d` drops its own default to `1/255` when you +set `transparent: true`). + +**`transparent: false`** pins a mesh to the opaque pass however it is faded. + +Blending uses the renderable's existing `blendMode`, so a glow is one property: + +```js +const glow = new Mesh(0, 0, { + ...quad, texture: glowTexture, + transparent: true, blendMode: "additive", alphaCutoff: 0, +}); +``` + +| | | +| --- | --- | +| sorting | **per object**, by distance from the camera | +| intersecting transparent meshes | may pop as the camera moves — split them, or accept it | +| `InstancedMesh` | sorts as **one** object; instances draw in buffer order | +| needs | a GPU backend and a `Camera3d` | + +Ground shadows ride the same pass — a blob is a decal, and decals are its first +client rather than a feature of their own. + ## Distance fog Off until you ask for it, and one call on the camera: @@ -448,6 +492,9 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | distant surfaces z-fight | `near` too small for the scene scale | | black canvas under `Camera3d` | Canvas renderer (no depth buffer) — check the `console.warn` | | everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera | +| a faded mesh goes dark instead of see-through | `transparent: false` on it, or a 2D camera — the transparent pass needs a `Camera3d` | +| a soft-edged glow has hard edges | `alphaCutoff` discarded the soft texels; lower it | +| two transparent objects pop as the camera moves | per-object sorting cannot order intersecting geometry | | a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth | | fog hangs in the sky as thickly as in the valley | uniform fog — add `heightFalloff` so it pools low | | mist sits on the ridges instead of the valley floor | the `fogHeight` sign — Y is DOWN here, density rises below it | diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 6b95824ca..680afd3d2 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -1135,7 +1135,7 @@ export default class Application { // ground shadows are held back until every opaque mesh is down // (#1515); a scene that is nothing but meshes never switches away // from mesh mode, so the pass is closed here - this.renderer.flushGroundShadows(); + this.renderer.flushTransparent(); // flush/render our frame this.renderer.flush(); diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index a0652c11c..9f06c6745 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -1056,7 +1056,7 @@ export default class Camera2d extends Renderable { // world is down, and this is where that is true — still inside the // camera's FBO/post-effect bracket, so they land in the frame the // camera is about to resolve rather than after it has been composited. - renderer.flushGroundShadows(); + renderer.flushTransparent(); // draw the viewport/camera effects this.drawFX(renderer); diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 0f42d835f..993045a89 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1297,7 +1297,7 @@ export default class Container extends Renderable { // so they cannot be replayed once it is installed, and this // is where they belong in the order anyway: over the world, // under the overlay about to be drawn. - renderer.flushGroundShadows?.(); + renderer.flushTransparent?.(); renderer.beginScreenSpace?.(); renderer.save(); renderer.resetTransform(); diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 42c15ff16..eafcd8fa3 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -327,6 +327,7 @@ export default class Mesh extends Renderable { * @param {number} [settings.shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface. * @param {string|TextureAtlas|HTMLImageElement} [settings.alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). * @param {boolean} [settings.castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`. + * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded * @param {boolean} [settings.fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does * @param {number} [settings.shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. @@ -654,6 +655,54 @@ export default class Mesh extends Renderable { */ this.fog = typeof settings.fog === "boolean" ? settings.fog : undefined; + /** + * Whether this mesh draws in the **transparent pass** — blended, + * back-to-front, writing no depth — instead of the opaque one. + * + * Left **unset** (`undefined`, the default) the mesh goes transparent + * whenever the draw resolves to fractional alpha, so `setOpacity(0.5)` + * simply fades it. That is the useful default because the opaque path + * writes premultiplied colour with blending off: a faded mesh comes out + * *darkened toward black* rather than see-through, which is a defect + * rather than a contract — the fully transparent end of the same range + * used to paint an opaque black silhouette until it was fixed. + * + * Set it **`true`** when the transparency lives in the TEXTURE rather + * than in the opacity — a soft-edged glow, smoke, a glTF material with + * `alphaMode: "BLEND"`. The automatic check reads the draw's alpha and + * cannot see into the texture. Note that `alphaCutoff` discards texels + * before blending sees them, so a soft edge needs a low cutoff (see + * {@link Sprite3d}, which lowers its default for exactly this). + * + * Set it **`false`** to keep a mesh in the opaque pass however it is + * faded — it will darken rather than fade, and it will keep writing + * depth. + * + * Sorting is **per object**, by distance from the camera, so two + * intersecting or mutually enclosing transparent meshes may pop as the + * camera moves; split them, or accept it. Needs a GPU backend and a + * {@link Camera3d} — the 2D-camera path is unaffected. + * @type {boolean|undefined} + * @default undefined + * @see Renderable#blendMode + * @example + * // a ghost that fades in — nothing else needed + * ghost.setOpacity(0.4); + * + * // a glow that blends at full opacity, and additively + * const glow = new Mesh(0, 0, { + * ...quad, + * texture: glowTexture, + * transparent: true, + * blendMode: "additive", + * alphaCutoff: 0, + * }); + */ + this.transparent = + typeof settings.transparent === "boolean" + ? settings.transparent + : undefined; + /** * World Y of the floor the shadow lands on, or `undefined` (the * default) to mean "this object is standing on the ground" — the diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 2336a6ea6..be4db56a4 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -252,8 +252,18 @@ export default class Sprite3d extends Mesh { // discarded, giving a clean transparent silhouette with correct depth // testing and no back-to-front sorting. Pass `alphaCutoff: 0` to // disable (fully opaque), or tune the threshold. + // The 0.5 default cuts a clean silhouette out of a sprite's + // transparent frame background. It would also DEFEAT a soft-alpha + // sprite: every texel below half opacity is discarded before + // blending can see it, which is exactly the case `transparent: true` + // exists for. So an explicitly transparent sprite drops to the + // invisible-texel threshold unless the caller named a cutoff. alphaCutoff: - typeof settings.alphaCutoff === "number" ? settings.alphaCutoff : 0.5, + typeof settings.alphaCutoff === "number" + ? settings.alphaCutoff + : settings.transparent === true + ? 1 / 255 + : 0.5, // ground shadow (#1515) — forwarded explicitly, like everything // else here: this constructor hands `Mesh` a built settings object // rather than the caller's, so anything not named is dropped @@ -262,6 +272,9 @@ export default class Sprite3d extends Mesh { // flatten it to an explicit false, silently opting every sprite out // of a scene-wide default castGroundShadow: settings.castGroundShadow, + // raw for the same reason as above: `undefined` means "decide from + // the draw's alpha", and coercing it would pin every sprite opaque + transparent: settings.transparent, shadowGroundY: settings.shadowGroundY, shadowOpacity: settings.shadowOpacity, }); diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 80e7ee1bf..d354dc2a6 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -335,29 +335,6 @@ export default class Renderer { */ flush() {} - /** - * Hold one ground shadow ({@link Mesh#castGroundShadow}) back until the end - * of the mesh pass (#1515). - * - * A blob shadow deliberately writes no depth, so that two overlapping at - * one ground height blend instead of fighting. The price of that choice is - * that it leaves nothing in the depth buffer to defend itself with: any - * opaque mesh drawn afterwards simply paints over it. The ground plane is - * exactly that mesh whenever it sorts after the props standing on it — the - * common case, because a large plane's single sort key says nothing useful - * about where it sits relative to what stands on it. - * - * So shadows are collected here and drawn once the opaque meshes are down. - * Depth *testing* stays on throughout, so a shadow is still correctly - * hidden behind geometry genuinely in front of it: deferring changes only - * who paints over whom among draws that write no depth. - * @param {object} quad - the shared shadow quad - * @param {Matrix3d} modelMatrix - where the blob sits (copied, not retained) - * @param {number} tint - packed ARGB tint for the draw - * @param {object} [instanced] - the `InstancedMesh` whose instance buffer - * supplies one blob per instance, for the instanced tier - * @ignore - */ /** * Mark the start of a screen-space (`floating`) draw, during which the * camera's screen projection is installed and world-space geometry cannot @@ -377,61 +354,152 @@ export default class Renderer { this._screenSpaceDepth = depth > 0 ? depth : 0; } - queueGroundShadow(quad, modelMatrix, tint, instanced) { - const pool = (this._shadowPool ??= []); - const at = this._shadowCount ?? 0; - // The matrix is COPIED, not referenced: every shadow in a scene shares - // one quad, so `quad._modelMatrix` is rewritten by the next caster long - // before this entry is drawn. Slots are reused frame to frame, so a - // steady scene allocates nothing after its first. + /** + * Hold one draw back for the transparent pass. + * + * The mesh tier renders in two phases. The **opaque pass** writes depth and + * runs in whatever order the container sorted; the **transparent pass** + * replays this queue afterwards, back-to-front, with blending on and depth + * writes off. Depth *testing* stays on throughout, so a transparent object + * is still correctly hidden behind opaque geometry in front of it. + * + * Both halves are necessary. Blending needs back-to-front order to + * composite correctly, and a draw that writes no depth has nothing to + * defend itself with — any opaque mesh drawn afterwards simply paints over + * it. A ground plane does exactly that whenever it sorts after the props + * standing on it, which is the common case, because a large plane's single + * sort key says nothing about where it sits relative to what stands on it. + * @param {object} mesh - the renderable to replay + * @param {Matrix3d} modelMatrix - where it sits (COPIED, not retained: the + * decal clients share one quad whose matrix the next caster overwrites long + * before this entry is drawn) + * @param {number} tint - packed ARGB tint for the draw + * @param {string} [blend] - blend mode for this entry alone + * @param {object} [instanced] - the `InstancedMesh` whose instance buffer + * supplies one draw per instance, for the instanced tier + * @ignore + */ + queueTransparent(mesh, modelMatrix, tint, blend, instanced) { + const pool = (this._transparentPool ??= []); + const at = this._transparentCount ?? 0; + // Slots are reused frame to frame, so a steady scene allocates nothing + // after its first. let entry = pool[at]; if (entry === undefined) { - entry = { quad: null, matrix: new Matrix3d(), tint: 0, instanced: null }; + entry = { + mesh: null, + matrix: new Matrix3d(), + tint: 0, + blend: "normal", + key: 0, + instanced: null, + }; pool[at] = entry; } - entry.quad = quad; + entry.mesh = mesh; entry.matrix.copy(modelMatrix); entry.tint = tint; + entry.blend = blend ?? "normal"; entry.instanced = instanced ?? null; - this._shadowCount = at + 1; + // Squared radial distance from the eye, taken once here rather than per + // comparison. Radial rather than view-space z for the same reason the + // fog distance is: no sign-convention trap, and stable as the camera + // turns. The view is rigid, so its inverse translation is -Rᵀ·t. + const v = this.currentTransform.val; + const m = modelMatrix.val; + const ex = -(v[0] * v[12] + v[1] * v[13] + v[2] * v[14]); + const ey = -(v[4] * v[12] + v[5] * v[13] + v[6] * v[14]); + const ez = -(v[8] * v[12] + v[9] * v[13] + v[10] * v[14]); + const dx = m[12] - ex; + const dy = m[13] - ey; + const dz = m[14] - ez; + entry.key = dx * dx + dy * dy + dz * dz; + this._transparentCount = at + 1; + } + + /** + * Queue one ground-shadow decal. + * + * A blob shadow is a decal, and decals are the transparent pass's first + * client rather than a feature of their own. + * @param {object} quad - the shared shadow quad + * @param {Matrix3d} modelMatrix - where the blob sits + * @param {number} tint - packed ARGB tint for the draw + * @param {object} [instanced] - the instanced tier's source mesh + * @deprecated since 20.4.0, use {@link Renderer#queueTransparent} + * @ignore + */ + queueGroundShadow(quad, modelMatrix, tint, instanced) { + this.queueTransparent(quad, modelMatrix, tint, "normal", instanced); } /** - * Draw every ground shadow queued since the last drain, then empty the - * queue (#1515). Called when the renderer leaves mesh mode, and once more - * at end of frame for a scene made only of meshes, which never switches - * away from mesh mode on its own. + * Run the transparent pass: draw everything queued since the last drain, + * back-to-front, then empty the queue. + * + * Called at the three points where the world draw is genuinely finished — + * `Container.draw` just before a floating child, `Camera2d.draw` once the + * container is down, and `Application.draw` at end of frame. Deliberately + * NOT on a batcher transition: anything non-mesh sorting mid-scene raises + * one, and every mesh still to come would then paint over what was just + * replayed. * * Inert on a backend that never queues — the Canvas renderer has no depth - * buffer and so no ground shadows at all. + * buffer, and composites through the 2D context instead. */ - flushGroundShadows() { - const count = this._shadowCount ?? 0; + flushTransparent() { + const count = this._transparentCount ?? 0; if ( count === 0 || - this._shadowFlushing === true || - // A queued blob is WORLD-space geometry, and replaying it needs the + this._transparentFlushing === true || + // A queued entry is WORLD-space geometry, and replaying it needs the // world projection. `Container.draw` installs the camera's screen // projection around a `floating` child, so a drain triggered from - // inside that window feeds every blob screen-space clip coordinates + // inside that window feeds every entry screen-space clip coordinates // and lands it off-screen — one HUD silently deleted every ground // shadow in the scene. Skipping is safe rather than lossy: the // queue survives, and `Container.draw` drains it just BEFORE - // opening the window, which is also where the blobs belong — - // under the overlay, over the world. + // opening the window, which is also where these belong — over the + // world, under the overlay. (this._screenSpaceDepth ?? 0) > 0 ) { return; } - const pool = this._shadowPool; + const pool = this._transparentPool; + // Back-to-front, because blending is order-dependent. Sorted over the + // LIVE range only — pooled slots past `count` hold stale entries that + // must not be dragged into it. Binary insertion: stable, allocation + // free, and near-linear on the nearly-sorted input a depth-sorted + // container already produces. If a scene ever proves that wrong, sort + // an index scratch with typed keys rather than reaching for + // `Array.prototype.sort` over the pool. + for (let i = 1; i < count; i++) { + const entry = pool[i]; + const key = entry.key; + let lo = 0; + let hi = i; + while (lo < hi) { + const mid = (lo + hi) >> 1; + // descending: farthest first + if (pool[mid].key < key) { + hi = mid; + } else { + lo = mid + 1; + } + } + for (let j = i; j > lo; j--) { + pool[j] = pool[j - 1]; + } + pool[lo] = entry; + } // emptied BEFORE the replay, not after: the draws below re-enter // `setBatcher`, which calls back in here, and a queue still holding - // entries would recurse. `_shadowFlushing` guards the same door from - // the other side, and covers the re-entry into `drawMesh`. - this._shadowCount = 0; - this._shadowFlushing = true; + // entries would recurse. `_transparentFlushing` guards the same door + // from the other side, and covers the re-entry into `drawMesh`. + this._transparentCount = 0; + this._transparentFlushing = true; try { - // The colour the shadow was queued WITH has to be put back, because + // The colour the entry was queued WITH has to be put back, because // both backends rebuild the draw tint from `currentTint` + // `getGlobalAlpha()` — long since restored to the scene's values by // the time this replay runs. @@ -452,23 +520,68 @@ export default class Renderer { packed & 0xff, ); this.setGlobalAlpha(((packed >>> 24) & 0xff) / 255); + // read by the batchers instead of a per-mesh flag: the same + // mesh can be queued twice under different modes, and the + // entry is the only thing that knows which is which + this._replayBlend = entry.blend; if (entry.instanced !== null) { - this.drawInstancedShadow(entry.instanced, entry.matrix, entry.quad); + this.drawInstancedShadow(entry.instanced, entry.matrix, entry.mesh); } else { - this.drawMesh(entry.quad, entry.matrix); + this.drawMesh(entry.mesh, entry.matrix); } // drop the references, so a destroyed mesh is not held alive by // a pooled slot until that slot is next reused - entry.quad = null; + entry.mesh = null; entry.instanced = null; } } finally { + this._replayBlend = null; tint.setColor(savedR, savedG, savedB, savedTintAlpha); this.setGlobalAlpha(savedAlpha); } } finally { - this._shadowFlushing = false; + this._transparentFlushing = false; + } + } + + /** + * Run the transparent pass. + * @deprecated since 20.4.0, use {@link Renderer#flushTransparent} + */ + flushGroundShadows() { + this.flushTransparent(); + } + + /** + * Drop any queued entry belonging to this mesh, so a destroyed renderable + * is never replayed — which would re-upload geometry for something the + * caller has finished with. + * @param {object} mesh - the renderable being torn down + * @ignore + */ + removeQueuedTransparent(mesh) { + const count = this._transparentCount ?? 0; + if (count === 0) { + return; + } + const pool = this._transparentPool; + let write = 0; + for (let read = 0; read < count; read++) { + const entry = pool[read]; + if (entry.mesh === mesh || entry.instanced === mesh) { + entry.mesh = null; + entry.instanced = null; + continue; + } + if (write !== read) { + // order-preserving compaction: swap so no live entry is lost + // and the emptied slot stays in the pool for reuse + pool[read] = pool[write]; + pool[write] = entry; + } + write++; } + this._transparentCount = write; } /** @@ -502,7 +615,16 @@ export default class Renderer { // frame we are abandoning would paint them after `clear()`, and on the // context-lost branch would draw geometry belonging to the dead // context. The frame is being thrown away; its shadows go with it. - this._shadowCount = 0; + // null the references as well as the count: a discarded queue would + // otherwise keep every queued renderable reachable until its pooled + // slot happened to be reused + const queued = this._transparentCount ?? 0; + for (let i = 0; i < queued; i++) { + const entry = this._transparentPool[i]; + entry.mesh = null; + entry.instanced = null; + } + this._transparentCount = 0; this.renderState.reset(this.width, this.height); this.resetTransform(); this.setBlendMode(this.settings.blendMode); diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index be9beac4f..177f1dd44 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -506,21 +506,6 @@ export default class MeshBatcher extends MaterialBatcher { drawRetainedMesh(mesh, modelMatrix, tint) { const gl = this.gl; - // A ground shadow is deferred to the end of the mesh pass rather than - // drawn here (#1515). It writes no depth, so it has nothing to defend - // itself with: every opaque mesh still to come would paint straight - // over it — the ground plane above all, which routinely sorts after the - // props standing on it. The renderer replays the queue once the opaque - // meshes are down, and calls back in with `_shadowFlushing` set. - if ( - mesh._blendedDraw === true && - this.renderer._shadowFlushing !== true && - this.renderer.queueGroundShadow !== undefined - ) { - this.renderer.queueGroundShadow(mesh, modelMatrix, tint); - return; - } - // anything the caller had queued must land first, or this draw would // reorder ahead of it this.flush(); @@ -544,9 +529,9 @@ export default class MeshBatcher extends MaterialBatcher { // whole frame rendering against stale depth. this.updatePassState(); - const blended = mesh._blendedDraw === true; - if (blended === true) { - this.beginBlendedDraw(); + const blend = this.renderer._replayBlend ?? null; + if (blend !== null) { + this.beginBlendedDraw(blend); } const slices = mesh.textureGroups; @@ -584,7 +569,7 @@ export default class MeshBatcher extends MaterialBatcher { } } - if (blended === true) { + if (blend !== null) { this.endBlendedDraw(); } @@ -608,18 +593,15 @@ export default class MeshBatcher extends MaterialBatcher { * the ground. * @ignore */ - beginBlendedDraw() { - const gl = this.gl; - gl.enable(gl.BLEND); - gl.blendEquation(gl.FUNC_ADD); - gl.blendFunc( - this.renderer.premultipliedAlpha ? gl.ONE : gl.SRC_ALPHA, - gl.ONE_MINUS_SRC_ALPHA, - ); - // depth TEST stays on — the shadow must still be occluded by geometry - // in front of it — but writes are off, so overlapping shadows at one - // ground height blend instead of fighting under LEQUAL - gl.depthMask(false); + beginBlendedDraw(mode = "normal") { + // straight through the renderer's tables rather than the cached + // `setBlendMode`: this state belongs to one entry of the transparent + // pass and is torn down after it, so the 2D cache must not learn it + this.renderer.applyBlendFunction(mode); + // depth TEST stays on — a transparent object must still be occluded by + // geometry in front of it — but writes are off, so overlapping + // transparent draws blend instead of fighting under LEQUAL + this.gl.depthMask(false); } /** diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 5e02d3df1..b23ac3777 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -590,6 +590,10 @@ export default class WebGLRenderer extends Renderer { * @param {object} mesh - the mesh whose geometry should be freed */ deleteMeshGeometry(mesh) { + // a mesh torn down between being queued and the transparent pass + // running must not be replayed — the replay would re-upload geometry + // for something the caller has finished with + this.removeQueuedTransparent(mesh); this.batchers?.forEach((batcher) => { batcher.releaseRetained?.(mesh); }); @@ -864,11 +868,6 @@ export default class WebGLRenderer extends Renderer { } if (this.currentBatcher !== batcher) { - // Leaving mesh mode drains the deferred ground-shadow queue first, - // so the blobs land on top of every opaque mesh in the pass (#1515). - // Switching *within* mesh mode (lit ↔ unlit) must NOT drain it — - // the meshes still to come are exactly what the shadows have to - // beat. if (this.currentBatcher !== undefined) { // flush the current batcher, then let it tear down any // state it set up at `bind()` time (Mesh batcher restores @@ -894,23 +893,6 @@ export default class WebGLRenderer extends Renderer { return this.currentBatcher; } - /** - * Whether the device state right now is the scene's, so a deferred - * ground-shadow drain (#1515) would actually land where it is meant to. - * - * A batcher transition is normally the end of the mesh pass — but not - * every one is. `setMask` fills its shape through the primitive batcher - * with **colour writes off and `stencilOp(…, INCR)` armed**, so draining - * there both discards every blob and stamps their footprints into the mask - * being built. A transition inside a post-effect bracket is bound to the - * child's FBO, so the blobs would be captured into that effect chain and - * lost from the world. In either case the queue simply waits: the camera - * drains it at the end of the world walk, which is the point that is always - * correct. - * @returns {boolean} true when a drain is safe here - * @ignore - */ - /** * Reset the gl transform to identity */ @@ -2114,12 +2096,42 @@ export default class WebGLRenderer extends Renderer { * @param {object} quad - the shared shadow quad * @ignore */ + /** + * Install a blend function for one draw WITHOUT touching + * `currentBlendMode`. + * + * The transparent pass sets state per entry and restores mesh-mode defaults + * afterwards, so the 2D blend cache must not learn about it — the next + * ordinary draw would then skip a `setBlendMode` it genuinely needs. Same + * deliberate cache bypass `MeshBatcher.beginBlendedDraw` documents. + * @param {string} mode - a blend mode token + * @ignore + */ + applyBlendFunction(mode) { + const gl = this.gl; + // ALWAYS premultiplied, whatever `this.premultipliedAlpha` says — that + // flag describes source TEXTURES, while every mesh vertex shader + // premultiplies its own output unconditionally + // (`vColor = vec4(tinted.rgb * tinted.a, tinted.a)`). Passing the flag + // here selects SRC_ALPHA and multiplies by alpha a second time: a + // half-faded white mesh over blue comes out at three-quarter blue + // instead of full. It went unnoticed while decals were the only client, + // because their source colour is black and 0 × anything is 0. + const state = blendStateFor(normalizeBlendMode(mode), true); + gl.enable(gl.BLEND); + gl.blendEquation(GL_BLEND_OP[state.operation]); + gl.blendFunc( + GL_BLEND_FACTOR[state.srcFactor], + GL_BLEND_FACTOR[state.dstFactor], + ); + } + drawInstancedShadow(mesh, shadowMatrix, quad) { const tint = this.currentTint.toUint32(this.getGlobalAlpha()); - // deferred to the end of the mesh pass for the same reason a per-object - // shadow is — see `queueGroundShadow` - if (this._shadowFlushing !== true) { - this.queueGroundShadow(quad, shadowMatrix, tint, mesh); + // held back for the transparent pass for the same reason a per-object + // decal is — see `queueTransparent` + if (this._transparentFlushing !== true) { + this.queueTransparent(quad, shadowMatrix, tint, "normal", mesh); return; } this.setBatcher(quad.lit === true ? "litMesh" : "mesh"); @@ -2141,6 +2153,35 @@ export default class WebGLRenderer extends Renderer { const gl = this.gl; const retained = modelMatrix !== undefined; + // Route a transparent draw into the transparent pass rather than + // drawing it here. Hoisted above the batcher selection so an opaque + // scene pays two property reads and one compare against a value this + // method already computes further down for the draw itself. + // + // `mesh.transparent` is tri-state: `true` always (a soft-alpha TEXTURE + // is invisible to the check below), `false` never, and unset means + // "whenever this draw resolves to fractional alpha" — the opaque path + // writes premultiplied colour with blending off, so a faded mesh + // darkens toward black instead of fading, which is a bug rather than a + // contract. `_blendedDraw` marks the internal decal quads. + const packedTint = this.currentTint.toUint32(this.getGlobalAlpha()); + if ( + retained && + this._transparentFlushing !== true && + (mesh._blendedDraw === true || + mesh.transparent === true || + (mesh.transparent !== false && packedTint >>> 24 !== 0xff)) + ) { + this.queueTransparent( + mesh, + modelMatrix, + packedTint, + mesh._blendedDraw === true ? "normal" : mesh.blendMode, + undefined, + ); + return; + } + // Route to the lit or unlit mesh batcher. `mesh.lit` meshes use the // `LitMeshBatcher` (world-space normals + lighting); everything else // uses the lean unlit `MeshBatcher`. Both share the mesh-mode depth @@ -2196,7 +2237,7 @@ export default class WebGLRenderer extends Renderer { // leak the cull toggle or leave the custom program bound — the NEXT // unshaded mesh would silently draw with it try { - const tint = this.currentTint.toUint32(this.getGlobalAlpha()); + const tint = packedTint; if ( mesh.instanceLayout !== undefined && retained && diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 93b06999d..0eb19218b 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -989,7 +989,8 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { // stops writing depth, so overlapping shadows blend instead of // fighting. Set per draw, never left behind: an ordinary mesh must // resolve to exactly the pipeline it always did. - const blended = mesh._blendedDraw === true; + const blend = renderer._replayBlend ?? null; + const blended = blend !== null; // `undefined` rather than `true` for the ordinary case: the axis reads // `!== false`, so leaving it unset keeps `meshState` byte-for-byte what // it was before this existed, and the pipeline key gains nothing @@ -998,7 +999,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { const pipeline = renderer.pipelineCache.get( this.activeShaderKey(), "triangle-list", - blended ? "normal" : "none", + blend ?? "none", renderer.premultipliedAlpha, renderer.stencilMode, this.meshState, diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 4e4514922..bae5fc84b 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1811,10 +1811,10 @@ export default class WebGPURenderer extends Renderer { */ drawInstancedShadow(mesh, shadowMatrix, quad) { const tint = this.currentTint.toUint32(this.getGlobalAlpha()); - // deferred to the end of the mesh pass for the same reason a per-object - // shadow is — see `Renderer#queueGroundShadow` - if (this._shadowFlushing !== true) { - this.queueGroundShadow(quad, shadowMatrix, tint, mesh); + // held back for the transparent pass for the same reason a per-object + // decal is — see `Renderer#queueTransparent` + if (this._transparentFlushing !== true) { + this.queueTransparent(quad, shadowMatrix, tint, "normal", mesh); return; } this.setBatcher(quad.lit === true ? "litMesh" : "mesh"); @@ -1833,19 +1833,30 @@ export default class WebGPURenderer extends Renderer { const retained = modelMatrix !== undefined; - // A ground shadow waits for the end of the mesh pass rather than - // drawing here: it writes no depth, so every opaque mesh still to come - // would paint over it (#1515). `flushGroundShadows` calls back in with - // `_shadowFlushing` set, and that pass takes the branch below. + // Route a transparent draw into the transparent pass rather than + // drawing it here. It writes no depth, so every opaque mesh still to + // come would paint over it, and blending needs back-to-front order. + // + // `mesh.transparent` is tri-state: `true` always (a soft-alpha TEXTURE + // is invisible to the check below), `false` never, and unset means + // "whenever this draw resolves to fractional alpha" — the opaque + // pipeline writes premultiplied colour with blend `"none"`, so a faded + // mesh darkens toward black instead of fading. `_blendedDraw` marks the + // internal decal quads. Kept in step with the WebGL twin. + const packedTint = this.currentTint.toUint32(this.getGlobalAlpha()); if ( - mesh._blendedDraw === true && retained && - this._shadowFlushing !== true + this._transparentFlushing !== true && + (mesh._blendedDraw === true || + mesh.transparent === true || + (mesh.transparent !== false && packedTint >>> 24 !== 0xff)) ) { - this.queueGroundShadow( + this.queueTransparent( mesh, modelMatrix, - this.currentTint.toUint32(this.getGlobalAlpha()), + packedTint, + mesh._blendedDraw === true ? "normal" : mesh.blendMode, + undefined, ); return; } @@ -1933,6 +1944,10 @@ export default class WebGPURenderer extends Renderer { * @param {object} mesh - the mesh whose GPU geometry should be freed */ deleteMeshGeometry(mesh) { + // a mesh torn down between being queued and the transparent pass + // running must not be replayed — the replay would re-upload geometry + // for something the caller has finished with + this.removeQueuedTransparent(mesh); this.batchers.forEach((batcher) => { batcher.releaseRetained?.(mesh); }); diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index 0deb96275..921deb5e8 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -737,11 +737,11 @@ describe("Ground shadows (#1515)", () => { mesh.preDraw(renderer); mesh.draw(renderer, camera); mesh.postDraw(renderer); - expect(renderer._shadowCount).toBeGreaterThan(0); + expect(renderer._transparentCount).toBeGreaterThan(0); const spy = vi.spyOn(renderer.gl, "drawElements"); renderer.reset(); - expect(renderer._shadowCount).toBe(0); + expect(renderer._transparentCount).toBe(0); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); mesh.destroy(); @@ -763,14 +763,14 @@ describe("Ground shadows (#1515)", () => { mesh.preDraw(renderer); mesh.draw(renderer, camera); mesh.postDraw(renderer); - const queued = renderer._shadowCount; + const queued = renderer._transparentCount; expect(queued).toBeGreaterThan(0); renderer.setBatcher("quad"); - expect(renderer._shadowCount).toBe(queued); + expect(renderer._transparentCount).toBe(queued); renderer.flushGroundShadows(); - expect(renderer._shadowCount).toBe(0); + expect(renderer._transparentCount).toBe(0); mesh.destroy(); }); @@ -785,20 +785,20 @@ describe("Ground shadows (#1515)", () => { mesh.preDraw(renderer); mesh.draw(renderer, camera); mesh.postDraw(renderer); - const queued = renderer._shadowCount; + const queued = renderer._transparentCount; expect(queued).toBeGreaterThan(0); renderer.beginScreenSpace(); try { renderer.flushGroundShadows(); // held, not lost - expect(renderer._shadowCount).toBe(queued); + expect(renderer._transparentCount).toBe(queued); } finally { renderer.endScreenSpace(); } // and released the moment the window closes renderer.flushGroundShadows(); - expect(renderer._shadowCount).toBe(0); + expect(renderer._transparentCount).toBe(0); mesh.destroy(); }); @@ -823,11 +823,11 @@ describe("Ground shadows (#1515)", () => { mesh.preDraw(renderer); mesh.draw(renderer, camera); mesh.postDraw(renderer); - const queued = renderer._shadowCount; + const queued = renderer._transparentCount; expect(queued).toBeGreaterThan(0); renderer.setBatcher("litMesh"); - expect(renderer._shadowCount).toBe(queued); + expect(renderer._transparentCount).toBe(queued); renderer.setBatcher("quad"); mesh.destroy(); }); @@ -844,16 +844,16 @@ describe("Ground shadows (#1515)", () => { mesh.preDraw(renderer); mesh.draw(renderer, camera); mesh.postDraw(renderer); - const queued = renderer._shadowCount; + const queued = renderer._transparentCount; expect(queued).toBeGreaterThan(0); renderer.setMask(new Rect(0, 0, 32, 32)); - expect(renderer._shadowCount).toBe(queued); + expect(renderer._transparentCount).toBe(queued); renderer.clearMask(); // and once the mask is done, the queue is still there to be drawn renderer.flushGroundShadows(); - expect(renderer._shadowCount).toBe(0); + expect(renderer._transparentCount).toBe(0); mesh.destroy(); }); @@ -871,8 +871,8 @@ describe("Ground shadows (#1515)", () => { m.draw(renderer, camera); m.postDraw(renderer); } - expect(renderer._shadowCount).toBe(2); - const [first, second] = renderer._shadowPool; + expect(renderer._transparentCount).toBe(2); + const [first, second] = renderer._transparentPool; expect(first.matrix).not.toBe(second.matrix); expect(first.matrix.val[12]).not.toBe(second.matrix.val[12]); // alpha rides the packed ARGB tint, per entry diff --git a/packages/melonjs/tests/transparent_queue.spec.js b/packages/melonjs/tests/transparent_queue.spec.js new file mode 100644 index 000000000..6986855f7 --- /dev/null +++ b/packages/melonjs/tests/transparent_queue.spec.js @@ -0,0 +1,432 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, Matrix3d, Mesh, TextureAtlas } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +/** + * The transparent pass (#1516). + * + * The mesh tier renders opaque: `MeshBatcher.bind()` disables `GL_BLEND`, and + * the vertex stage premultiplies. So a faded mesh used to write + * `(rgb × a, a)` straight into the target — **darkened toward black, with the + * background contributing nothing**. Measured before this existed: a white mesh + * at `setOpacity(0.5)` over a blue background read `[127, 127, 127]`, not + * `[127, 127, 255]`. + * + * Draws that resolve to fractional alpha now go into a queue instead, replayed + * back-to-front with blending on and depth writes off once the world draw is + * finished. Ground-shadow decals are the same queue's first client. + */ +describe("the transparent pass (#1516)", () => { + const SIZE = 128; + let renderer; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // genuinely unavailable — every test below skips + } + }); + + afterAll(() => { + try { + releaseWebGLRenderer(); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + let _atlas = null; + const whiteAtlas = () => { + if (_atlas === null) { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const c2d = canvas.getContext("2d"); + c2d.fillStyle = "#ffffff"; + c2d.fillRect(0, 0, 1, 1); + _atlas = new TextureAtlas( + { framewidth: 1, frameheight: 1, image: canvas }, + canvas, + ); + } + return _atlas; + }; + + /** a small quad centred on the world origin, on the retained path */ + const quad = (half = 16) => { + const mesh = new Mesh(0, 0, { + vertices: [ + -half, + -half, + 0, + half, + -half, + 0, + half, + half, + 0, + -half, + half, + 0, + ], + uvs: [0, 0, 1, 0, 1, 1, 0, 1], + indices: [0, 1, 2, 0, 2, 3], + texture: whiteAtlas(), + width: half * 2, + height: half * 2, + cullBackFaces: false, + lit: false, + }); + mesh._useWorldSpace = true; + return mesh; + }; + + /** world (0,0) at the canvas centre; a wide z range so depth is free */ + const EYE_Z = 5000; + + const setup = (bg = [0, 0, 255]) => { + const proj = new Matrix3d(); + proj.ortho(-SIZE / 2, SIZE / 2, SIZE / 2, -SIZE / 2, -10000, 10000); + renderer.setProjection(proj); + // Put the eye beyond the scene rather than at the origin. Under this + // projection a GREATER z is nearer (measured, not assumed — the + // convention here is the inverse of the OpenGL one), and the queue + // sorts on distance from the eye, so an eye at the origin would rank + // the two in opposite directions. A real `Camera3d` installs a view + // that makes them agree; this stands in for it. + renderer.currentTransform.identity().translate(0, 0, -EYE_Z); + renderer.backgroundColor.setColor(bg[0], bg[1], bg[2], 255); + renderer.clear(); + }; + + const readPixel = (x = SIZE / 2, y = SIZE / 2) => { + const gl = renderer.gl; + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + + const draw = (mesh, depth = 0) => { + mesh.depth = depth; + mesh.preDraw(renderer); + mesh.draw(renderer); + mesh.postDraw(renderer); + }; + + describe("the symptom", () => { + it("fades a half-opacity mesh instead of darkening it toward black", (ctx) => { + requireWebGL(ctx); + // The measured failure signature is [127,127,127] — the background + // contributing nothing because the premultiplied colour REPLACED + // it. Correct is [127,127,255]: white over blue at half coverage. + setup([0, 0, 255]); + const mesh = quad(); + mesh.tint.setColor(255, 255, 255); + mesh.setOpacity(0.5); + draw(mesh); + renderer.flushTransparent(); + const px = readPixel(); + expect(px[2]).toBeGreaterThan(200); // the blue SURVIVES + expect(px[0]).toBeGreaterThan(100); + expect(px[0]).toBeLessThan(155); + }); + + it("still writes an opaque mesh straight through", (ctx) => { + requireWebGL(ctx); + setup([0, 0, 255]); + const mesh = quad(); + mesh.tint.setColor(255, 255, 255); + draw(mesh); + renderer.flushTransparent(); + expect(Array.from(readPixel()).slice(0, 3)).toEqual([255, 255, 255]); + }); + + it("`transparent: false` pins the old opaque behaviour", (ctx) => { + requireWebGL(ctx); + // the escape hatch: darkened, background replaced + setup([0, 0, 255]); + const mesh = quad(); + mesh.transparent = false; + mesh.tint.setColor(255, 255, 255); + mesh.setOpacity(0.5); + draw(mesh); + renderer.flushTransparent(); + const px = readPixel(); + expect(px[2]).toBeLessThan(155); // blue did NOT survive + }); + + it("`transparent: true` queues even at full opacity", (ctx) => { + requireWebGL(ctx); + // the texture-alpha case the automatic check cannot see + setup(); + const mesh = quad(); + mesh.transparent = true; + draw(mesh); + expect(renderer._transparentCount).toBe(1); + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(0); + }); + }); + + describe("the additive guarantee", () => { + it("never enters the queue for an opaque scene", (ctx) => { + requireWebGL(ctx); + setup(); + const spy = vi.spyOn(renderer, "queueTransparent"); + draw(quad(), 0); + draw(quad(), 10); + const calls = spy.mock.calls.length; + spy.mockRestore(); + expect(calls).toBe(0); + expect(renderer._transparentCount ?? 0).toBe(0); + }); + + it("a flush on an empty queue draws nothing and throws nothing", (ctx) => { + requireWebGL(ctx); + setup(); + const spy = vi.spyOn(renderer, "drawMesh"); + expect(() => { + return renderer.flushTransparent(); + }).not.toThrow(); + const calls = spy.mock.calls.length; + spy.mockRestore(); + expect(calls).toBe(0); + }); + }); + + describe("ordering", () => { + /** near red over far blue, both half alpha, over white */ + const composite = (nearFirst) => { + setup([255, 255, 255]); + const near = quad(); + near.tint.setColor(255, 0, 0); + near.setOpacity(0.5); + const far = quad(); + far.tint.setColor(0, 0, 255); + far.setOpacity(0.5); + if (nearFirst) { + draw(near, 900); + draw(far, 100); + } else { + draw(far, 100); + draw(near, 900); + } + renderer.flushTransparent(); + return readPixel(); + }; + + it("composites identically whichever order the two were submitted", (ctx) => { + requireWebGL(ctx); + // the sort is the whole point: submission order must not matter + const a = composite(true); + const b = composite(false); + for (const channel of [0, 1, 2]) { + expect(Math.abs(a[channel] - b[channel])).toBeLessThan(3); + } + }); + + it("puts the near object on top, not the one submitted last", (ctx) => { + requireWebGL(ctx); + // near is RED; if the far blue won, the blue channel would dominate + const px = composite(true); + expect(px[0]).toBeGreaterThan(px[2]); + }); + + it("is not painted over by an opaque mesh submitted afterwards", (ctx) => { + requireWebGL(ctx); + // The reason the pass is deferred at all. A transparent draw writes + // no depth, so an opaque mesh BEHIND it, drawn later, would replace + // it outright if the transparent one had gone down immediately. + setup([255, 255, 255]); + const ghost = quad(); + ghost.tint.setColor(255, 0, 0); + ghost.setOpacity(0.5); + draw(ghost, 900); + const floor = quad(48); + floor.tint.setColor(0, 255, 0); + draw(floor, 100); + renderer.flushTransparent(); + const px = readPixel(); + // red survives over the green floor rather than being replaced + expect(px[0]).toBeGreaterThan(100); + }); + + it("is still occluded by opaque geometry genuinely in front of it", (ctx) => { + requireWebGL(ctx); + // depth TEST stays on; only depth WRITES are off + setup([255, 255, 255]); + const wall = quad(48); + wall.tint.setColor(0, 255, 0); + draw(wall, 900); + const ghost = quad(); + ghost.tint.setColor(255, 0, 0); + ghost.setOpacity(0.5); + draw(ghost, 100); + renderer.flushTransparent(); + const px = readPixel(); + expect(px[1]).toBeGreaterThan(200); + expect(px[0]).toBeLessThan(60); + }); + }); + + describe("the drain contract", () => { + const queueOne = () => { + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + expect(renderer._transparentCount).toBe(1); + return mesh; + }; + + it("does not drain on a batcher switch", (ctx) => { + requireWebGL(ctx); + // #1630: anything non-mesh sorting mid-scene raises this, and every + // mesh still to come would then paint over what was replayed + queueOne(); + renderer.setBatcher("quad"); + expect(renderer._transparentCount).toBe(1); + renderer.setBatcher("mesh"); + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(0); + }); + + it("refuses to drain while a screen projection is installed", (ctx) => { + requireWebGL(ctx); + // #1630: the entries are WORLD-space geometry; replayed under the + // screen ortho they land off-screen and are silently lost + queueOne(); + renderer.beginScreenSpace(); + try { + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(1); + } finally { + renderer.endScreenSpace(); + } + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(0); + }); + }); + + describe("adversarial", () => { + it("keeps a mesh queued twice apart, with its own matrix and alpha", (ctx) => { + requireWebGL(ctx); + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh, 100); + draw(mesh, 900); + expect(renderer._transparentCount).toBe(2); + const [a, b] = renderer._transparentPool; + // the matrix is COPIED, not referenced — one entry must not carry + // the other's placement + expect(a.matrix.val[14]).not.toBe(b.matrix.val[14]); + renderer.flushTransparent(); + }); + + it("drops a destroyed mesh instead of replaying it", (ctx) => { + requireWebGL(ctx); + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + expect(renderer._transparentCount).toBe(1); + renderer.removeQueuedTransparent(mesh); + expect(renderer._transparentCount).toBe(0); + const spy = vi.spyOn(renderer, "drawMesh"); + renderer.flushTransparent(); + const calls = spy.mock.calls.length; + spy.mockRestore(); + expect(calls).toBe(0); + }); + + it("keeps the other entries when one is removed", (ctx) => { + requireWebGL(ctx); + setup(); + const doomed = quad(); + const keep = quad(); + doomed.setOpacity(0.5); + keep.setOpacity(0.5); + draw(doomed, 100); + draw(keep, 200); + renderer.removeQueuedTransparent(doomed); + expect(renderer._transparentCount).toBe(1); + expect(renderer._transparentPool[0].mesh).toBe(keep); + renderer.flushTransparent(); + }); + + it("reuses pooled entries and releases their references after a drain", (ctx) => { + requireWebGL(ctx); + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + const entry = renderer._transparentPool[0]; + const length = renderer._transparentPool.length; + renderer.flushTransparent(); + // nothing retained: a destroyed mesh must not be reachable from a + // pooled slot until that slot happens to be reused + expect(entry.mesh).toBe(null); + expect(entry.instanced).toBe(null); + setup(); + draw(mesh); + expect(renderer._transparentPool[0]).toBe(entry); + expect(renderer._transparentPool.length).toBe(length); + renderer.flushTransparent(); + }); + + it("does not re-queue during its own replay", (ctx) => { + requireWebGL(ctx); + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + renderer.flushTransparent(); + // a replay that re-queued would never converge + expect(renderer._transparentCount).toBe(0); + }); + + it("leaves mesh-mode state as it found it", (ctx) => { + requireWebGL(ctx); + const gl = renderer.gl; + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + renderer.flushTransparent(); + // blending off and depth writes back on, or the next opaque mesh + // silently draws with the transparent pass's state + expect(gl.getParameter(gl.BLEND)).toBe(false); + expect(gl.getParameter(gl.DEPTH_WRITEMASK)).toBe(true); + }); + + it("does not let one entry's blend mode leak into the next", (ctx) => { + requireWebGL(ctx); + setup([0, 0, 0]); + const glow = quad(); + glow.transparent = true; + glow.blendMode = "additive"; + glow.tint.setColor(255, 0, 0); + const plain = quad(); + plain.transparent = true; + plain.tint.setColor(0, 0, 255); + draw(glow, 900); + draw(plain, 100); + renderer.flushTransparent(); + // the 2D cache must not have learned the per-entry state + expect(renderer.currentBlendMode).toBe("normal"); + }); + }); +}); diff --git a/packages/melonjs/tests/webgl_mesh_fog.spec.js b/packages/melonjs/tests/webgl_mesh_fog.spec.js index 3337fd0cf..dc149a803 100644 --- a/packages/melonjs/tests/webgl_mesh_fog.spec.js +++ b/packages/melonjs/tests/webgl_mesh_fog.spec.js @@ -461,7 +461,14 @@ describe("mesh distance fog (#1622)", () => { // alpha-cutout mesh is the grey halo around every cut edge. The // upper bound below is what excludes it. renderer.setFog(fog({ near: 0, invRange: 1 / 100 })); - const px = drawRed(quad(), 500, 0.5); + // pinned to the OPAQUE pass: this is about the fog blend maths, and + // a fractional alpha would otherwise route the draw into the + // transparent pass (#1516), where the pixel is composited rather + // than replaced and the assertion below would measure something + // else entirely + const opaque = quad(); + opaque.transparent = false; + const px = drawRed(opaque, 500, 0.5); renderer.setFog(null); // Only the colour channels are read: the drawing buffer reports an // opaque alpha on readback whatever the fragment wrote, so px[3] diff --git a/packages/melonjs/tests/webgpu_mesh_retained.spec.js b/packages/melonjs/tests/webgpu_mesh_retained.spec.js index 0f1f9bfae..20c2a4abe 100644 --- a/packages/melonjs/tests/webgpu_mesh_retained.spec.js +++ b/packages/melonjs/tests/webgpu_mesh_retained.spec.js @@ -167,7 +167,12 @@ describe("WebGPU retained mesh geometry (mock device)", () => { const geometry = batcher.retained.get(mesh); const buffers = [geometry.vertexBuffer, geometry.indexBuffer]; - const stub = { batchers: new Map([["mesh", batcher]]) }; + // `deleteMeshGeometry` also purges the transparent queue, so the stub + // needs that method — the real one lives on the base `Renderer` + const stub = { + batchers: new Map([["mesh", batcher]]), + removeQueuedTransparent: () => {}, + }; WebGPURenderer.prototype.deleteMeshGeometry.call(stub, mesh); expect(batcher.retained.has(mesh)).toBe(false); expect(renderer.calls.retiredBuffers).toEqual(buffers); From 4616b239100fc20d798951a73285ddc8acd89ad9 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 2 Sep 2026 20:34:11 +0800 Subject: [PATCH 2/4] Mesh: fix the transparent replay, and give each render target its own queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the transparent pass found five defects. An InstancedMesh was routed into the pass and then replayed OPAQUE on both backends — nothing in the routing predicate looks at instancing, but neither instanced draw read the replay blend. The set was deferred to end of frame and drawn darkened anyway, which is strictly worse than never deferring it. Measured: [127,127,127] where the blue background should have survived. `blendMode: "none"` threw mid-frame. It is a supported token and `blendStateFor` returns no state for it by contract (it means replace), so the replay dereferenced undefined. It now disables blending, which is what replace is, and matches the pipeline WebGPU already built from the same absent state. A queued entry replayed under whatever view was live at the drain, not the one it was recorded with. `Container.draw` translates by its own position before walking its children, so a faded mesh inside a positioned container drew at the container's offset — a visible jump the moment it started fading. Each entry now carries its own view. The alpha cutout thresholded the DRAWN alpha, so fading a mesh pushed every texel through the test at once. `Sprite3d` defaults the cutoff to 0.5, so a sprite fading out hard-cut to nothing at 49% opacity instead of fading through it. The threshold now reads the material's own alpha: a surface's cut-out shape does not change when the object fades. The WebGPU replay read the mutable `premultipliedAlpha` flag. It describes source TEXTURES and is left false by anything drawing straight-alpha content earlier in the frame, which selected `src-alpha` and applied alpha a second time — the defect the WebGL side had just been fixed for. The queue is now per render target rather than per renderer, keyed on the shared render-target pool's active base. A post effect binds its own target part-way through a scene, and a drain fired inside that bracket used to replay the world's queued geometry into the effect's offscreen buffer, baking the scene's transparent objects into one renderable's texture. Keyed this way the two cannot meet, and each pass drains its own on the way out. `Container.draw` now drains an overlay's own entries while its screen projection is still installed. Replayed after the bracket closed they went through the camera's perspective with their vertices at view-space z = 0 — the camera itself — and the divide deleted them: a faded mesh inside a floating child rendered zero pixels anywhere in the frame while the same mesh at full opacity drew correctly. `Application.draw` resets the screen-space bracket every frame. The bracket is not exception-safe, and `reset()` only runs on a stage change, so a floating child that threw would otherwise block every later drain for the rest of the session with the queue growing all the while. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/skills/melonjs-3d/SKILL.md | 13 +- .../melonjs/src/application/application.ts | 8 + packages/melonjs/src/renderable/container.js | 11 + packages/melonjs/src/renderable/mesh.js | 22 +- packages/melonjs/src/video/renderer.js | 196 ++++++- .../src/video/webgl/batchers/mesh_batcher.js | 15 + .../src/video/webgl/shaders/mesh-lit.frag | 9 +- .../melonjs/src/video/webgl/shaders/mesh.frag | 13 +- .../melonjs/src/video/webgl/webgl_renderer.js | 17 + .../src/video/webgpu/batchers/mesh_batcher.js | 19 +- .../src/video/webgpu/shaders/mesh-lit.wgsl | 6 +- .../src/video/webgpu/shaders/mesh.wgsl | 6 +- .../src/video/webgpu/webgpu_renderer.js | 6 + packages/melonjs/tests/application.spec.js | 27 + .../melonjs/tests/transparent_queue.spec.js | 505 +++++++++++++++++- .../melonjs/tests/webgl_mesh_depth.spec.js | 116 +++- packages/melonjs/tests/webgpu_mipmaps.spec.js | 2 +- .../tests/webgpu_transparent_pass.spec.js | 360 +++++++++++++ 19 files changed, 1293 insertions(+), 59 deletions(-) create mode 100644 packages/melonjs/tests/webgpu_transparent_pass.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7b240b297..98adefac0 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Ground shadows: a scene could lose every blob it drew, in two different ways. Shadows are queued and replayed once the opaque meshes are down, and the replay used to be triggered by any batcher switch. That fired in two places it should not have: inside the screen-projection window `Container.draw` opens around a `floating` child, where world-space blobs were fed screen-space clip coordinates and landed off-screen — so a single HUD deleted every ground shadow in the scene — and in the middle of a scene whenever anything non-mesh sorted there (a particle emitter, a sprite), after which the meshes still to come painted straight over the blobs just put down. The queue now drains only where the world draw is actually finished: before a floating child, at the camera, and at end of frame. A **non-floating** 2D renderable drawn part-way through a 3D scene consequently draws under the shadows rather than over them +- Mesh: an alpha-cutout mesh **vanished entirely** once it faded past its own threshold. The cutout compared the *drawn* alpha against `alphaCutoff`, and fading a mesh scales every texel's alpha at once — so a `Sprite3d`, whose threshold defaults to `0.5`, popped out of existence at 49% opacity rather than fading through it, and a mesh at a higher cutoff shed its soft edges as it faded. The threshold now applies to the material's own alpha (the texel, times any opacity map), which is what it describes: a surface's cut-out shape does not change when the object fades - Mesh: `alpha = 0` painted the mesh **opaque black** instead of hiding it, on both GPU backends. `CanvasRenderer.drawMesh` has always skipped when the global alpha falls below `1/255` — the same guard eight other Canvas draw methods use — but neither GPU renderer had it, and the mesh path disables blending (`MeshBatcher.bind`), so the alpha never reached the blend stage: the shader multiplied the colour by zero and wrote the result opaque. Hiding a mesh with `alpha = 0` left a black silhouette of it, and the same property behaved differently per backend. Both GPU renderers now skip at the same threshold - Color: `toUint32()` returned a **negative** number for any colour with alpha at or above 0.5. The packing used `|`, which yields a signed int32, so a method named `toUint32` — documented as returning "a Uint32 ARGB representation" — handed back e.g. `-16711936` for green. Every consumer inside the engine writes it into a `Uint32Array` or a shader attribute where the bit pattern is identical, so nothing rendered wrong; what broke was reading the value back, comparing it, or printing it. The four unit tests covering this had the correct expectations commented out and the signed values asserted instead - Container: a `floating` child in a depth-sorted world was ordered by its **screen** position. `Container.draw` gives a floating child `resetTransform()` and the camera's screen projection, so its `pos.x/y` are canvas pixels — but `_sortDepth` fed those to a world-space distance and subtracted the camera position on top. Two consequences, both visible under a `Camera3d`: a HUD's layering depended on where it sat on the screen (a score in a corner scored `20² + 16²` and floated above the scene, while the same text centred scored `512² + 200²` and sank behind it), and it drifted as the camera travelled, so a HUD that was correct at the start of a level was buried by the end of it. A floating child is now ordered by `|pos.z|` alone — a small depth draws in front of the world, a large one behind it — which is the convention screen-space content already used (a HUD at -150, a sky backdrop at -10000 or 100000), now holding at any camera position and from anywhere on the screen rather than by luck of the numbers diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 4f88d0ef9..eabe62d37 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -133,11 +133,19 @@ opacity — a soft-edged glow, smoke, a glTF material with `alphaMode: "BLEND"`. The automatic check reads the draw's alpha and cannot see into a texture. Watch `alphaCutoff` here: it discards texels *before* blending sees them, so a soft edge needs a low cutoff (`Sprite3d` drops its own default to `1/255` when you -set `transparent: true`). +set `transparent: true`). The cutoff thresholds the MATERIAL's alpha, not the +drawn alpha, so a fading cutout mesh keeps its shape instead of disappearing at +its own threshold. + +The glTF loader does **not** set this for you: one loaded mesh can merge several +materials and the flag routes the whole mesh, so a `"BLEND"` material sharing +geometry with an opaque one would drag the opaque half into the transparent pass. **`transparent: false`** pins a mesh to the opaque pass however it is faded. -Blending uses the renderable's existing `blendMode`, so a glow is one property: +Blending uses the renderable's existing `blendMode`, so a glow is one property. +The advanced modes (`"overlay"`, `"difference"`, and the rest that need a +compositing pass) fall back to `"normal"` here, on both backends: ```js const glow = new Mesh(0, 0, { @@ -494,6 +502,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera | | a faded mesh goes dark instead of see-through | `transparent: false` on it, or a 2D camera — the transparent pass needs a `Camera3d` | | a soft-edged glow has hard edges | `alphaCutoff` discarded the soft texels; lower it | +| a glTF `alphaMode: "BLEND"` material draws opaque | the loader does not set `transparent` — one mesh can merge several materials, so set it yourself | | two transparent objects pop as the camera moves | per-object sorting cannot order intersecting geometry | | a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth | | fog hangs in the sky as thickly as in the valley | uniform fog — add `heightFalloff` so it pools low | diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 680afd3d2..337eb1c04 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -1118,6 +1118,14 @@ export default class Application { // prepare renderer to draw a new frame this.renderer.clear(); + // The screen-space bracket `Container.draw` opens around a + // floating child is not exception-safe: a child that throws + // leaves the depth raised, and every later drain of the + // transparent queue then silently skips — for the rest of the + // session, since `reset()` only runs on a stage change. A frame is + // the natural boundary, and by here the previous one is over. + this.renderer.resetScreenSpace?.(); + // Distance fog belongs to the camera that installed it, and is // installed once per camera in `Camera2d.draw`. Clearing it here // means a frame starts with none, so anything drawn before a camera diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 993045a89..11001845f 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1319,6 +1319,17 @@ export default class Container extends Renderable { obj.postDraw(renderer); if (isFloating) { + // Put down anything the overlay itself queued, while its + // SCREEN projection is still installed. A floating child + // shares the render target with the world, so its + // transparent meshes land in the same queue — but not the + // same projection, and replaying them after the restore + // below sends world-space geometry through the camera's + // perspective. Their vertices sit at view-space z = 0, + // which is the camera itself, and the perspective divide + // deletes them: a faded HUD mesh silently disappeared + // while the same mesh drew correctly at full opacity. + renderer.flushTransparentPass?.(); // Restore the projection the camera had installed for // this draw pass — non-default cameras use a separate // `worldProjection`; the default camera just uses diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index eafcd8fa3..eb9c7f7b1 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -670,9 +670,25 @@ export default class Mesh extends Renderable { * Set it **`true`** when the transparency lives in the TEXTURE rather * than in the opacity — a soft-edged glow, smoke, a glTF material with * `alphaMode: "BLEND"`. The automatic check reads the draw's alpha and - * cannot see into the texture. Note that `alphaCutoff` discards texels - * before blending sees them, so a soft edge needs a low cutoff (see - * {@link Sprite3d}, which lowers its default for exactly this). + * cannot see into the texture. The glTF loader does not set this for + * you: one loaded mesh can merge several materials, and this flag + * routes the whole mesh, so a `"BLEND"` material sharing geometry with + * an opaque one would drag the opaque half into the transparent pass. + * Note that `alphaCutoff` discards texels before blending sees them, so + * a soft edge needs a low cutoff (see {@link Sprite3d}, which lowers + * its default for exactly this). The cutoff thresholds the MATERIAL's + * alpha, not the drawn alpha, so a fading cutout mesh keeps its shape + * rather than disappearing at its own threshold. + * + * The pass composites premultiplied, which is what the mesh vertex + * stage always emits. A texture uploaded with straight alpha and drawn + * with `transparent: true` therefore reads slightly bright at its soft + * texels; upload it premultiplied (the default) and it is exact. + * + * {@link Renderable#blendMode} is honoured per entry, with one limit: + * the advanced modes (`"overlay"`, `"difference"`, and the rest that + * need a compositing pass) fall back to `"normal"` here on both + * backends, since the pass rasterizes directly into the target. * * Set it **`false`** to keep a mesh in the opaque pass however it is * faded — it will darken rather than fade, and it will keep writing diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index d354dc2a6..0a0e1db57 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -22,6 +22,14 @@ import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; * @import {default as Texture2d} from "./texture/texture2d.ts"; */ +/** + * The drain site's own view, held while the transparent pass installs each + * entry's. One for the whole engine — the pass is never re-entered, which + * `_transparentFlushing` enforces. + * @ignore + */ +const _savedView = new Matrix3d(); + /** * a base renderer object * @category Rendering @@ -345,6 +353,21 @@ export default class Renderer { this._screenSpaceDepth = (this._screenSpaceDepth ?? 0) + 1; } + /** + * Force the screen-space bracket back to zero at a frame boundary. + * + * `Container.draw` opens the bracket around a floating child without a + * `finally`, so a child that throws never closes it — and while it stays + * open every drain of the transparent queue is skipped, which would + * silently disable the pass for the rest of the session and grow the queue + * a frame at a time. Called once per frame, where the count is always zero + * in normal operation. + * @ignore + */ + resetScreenSpace() { + this._screenSpaceDepth = 0; + } + /** * Mark the end of a screen-space draw. * @ignore @@ -380,8 +403,9 @@ export default class Renderer { * @ignore */ queueTransparent(mesh, modelMatrix, tint, blend, instanced) { - const pool = (this._transparentPool ??= []); - const at = this._transparentCount ?? 0; + const queue = this.transparentQueue(); + const pool = queue.pool; + const at = queue.count; // Slots are reused frame to frame, so a steady scene allocates nothing // after its first. let entry = pool[at]; @@ -389,6 +413,7 @@ export default class Renderer { entry = { mesh: null, matrix: new Matrix3d(), + view: new Matrix3d(), tint: 0, blend: "normal", key: 0, @@ -398,6 +423,14 @@ export default class Renderer { } entry.mesh = mesh; entry.matrix.copy(modelMatrix); + // The view the entry was queued UNDER, captured because the replay + // happens somewhere else entirely. Both backends read the view live off + // `currentTransform` at draw time, and `Container.draw` translates by + // its own position before walking its children — so a mesh queued + // inside a positioned container and replayed after that bracket closed + // would draw at the container's offset from where it belongs. Visible + // as a jump the instant a mesh starts fading. + entry.view.copy(this.currentTransform); entry.tint = tint; entry.blend = blend ?? "normal"; entry.instanced = instanced ?? null; @@ -414,7 +447,70 @@ export default class Renderer { const dy = m[13] - ey; const dz = m[14] - ez; entry.key = dx * dx + dy * dy + dz * dz; - this._transparentCount = at + 1; + queue.count = at + 1; + } + + /** + * The render target a queued entry belongs to, as a stable small integer + * (`-1` for the screen). + * + * Both GPU backends run their post-effect passes through the shared + * {@link RenderTargetPool}, whose `activeBase` names the innermost active + * pass — so this is one number that means the same thing on both, and on + * the Canvas renderer (which has no pool, and never queues) it is + * constant. + * @returns {number} the current target's key + * @ignore + */ + transparentTarget() { + return this._renderTargetPool?.activeBase ?? -1; + } + + /** + * The transparent queue belonging to one render target, created on first + * use. + * + * There is one queue **per target**, not one per renderer, and that is the + * whole point: a queue can only ever be replayed into the target its + * entries were recorded for. A post effect binds its own target part-way + * through a scene, and a drain that fires inside that bracket used to + * replay the *world's* queued geometry into the effect's offscreen buffer + * — baking the scene's transparent objects into one renderable's texture. + * Keyed this way the two never meet: the effect's drain sees only what was + * queued inside the effect, and the world's queue waits for the camera. + * @param {number} [key] - the target, defaulting to the current one + * @returns {{pool: object[], count: number}} that target's queue + * @ignore + */ + transparentQueue(key = this.transparentTarget()) { + const queues = (this._transparentQueues ??= []); + // `-1` is the screen, so shift into a dense array rather than a Map: + // the key is a small integer and this runs per queued draw + const at = key + 1; + let queue = queues[at]; + if (queue === undefined) { + queue = { pool: [], count: 0 }; + queues[at] = queue; + } + return queue; + } + + /** + * The pooled entries of the current target's queue. + * @type {object[]} + * @ignore + */ + get _transparentPool() { + return this.transparentQueue().pool; + } + + /** + * How many entries the current target has queued. + * @type {number} + * @ignore + */ + get _transparentCount() { + return this.transparentQueue().count; } /** @@ -434,8 +530,8 @@ export default class Renderer { } /** - * Run the transparent pass: draw everything queued since the last drain, - * back-to-front, then empty the queue. + * Run the transparent pass: draw everything the CURRENT render target has + * queued since its last drain, back-to-front, then empty that queue. * * Called at the three points where the world draw is genuinely finished — * `Container.draw` just before a floating child, `Camera2d.draw` once the @@ -444,13 +540,17 @@ export default class Renderer { * one, and every mesh still to come would then paint over what was just * replayed. * + * Only the current target's queue is touched — see + * {@link Renderer#transparentQueue}. A post effect binds an offscreen + * target part-way through a scene, and a drain fired in there must not + * reach the world's queued geometry; each pass drains its own on the way + * out. + * * Inert on a backend that never queues — the Canvas renderer has no depth * buffer, and composites through the 2D context instead. */ flushTransparent() { - const count = this._transparentCount ?? 0; if ( - count === 0 || this._transparentFlushing === true || // A queued entry is WORLD-space geometry, and replaying it needs the // world projection. `Container.draw` installs the camera's screen @@ -465,7 +565,27 @@ export default class Renderer { ) { return; } - const pool = this._transparentPool; + this.flushTransparentPass(); + } + + /** + * Replay one target's queue, without the screen-projection guard above. + * + * Called directly when a render pass is ending: the pass owns both its + * target and the projection its entries were recorded under, so replaying + * them right there is correct whether that projection is the world's or a + * floating child's screen space. Left queued they would be stranded — the + * target is about to be unbound and its key reused by a later pass, which + * would then replay them somewhere they never belonged. + * @ignore + */ + flushTransparentPass() { + const queue = this.transparentQueue(); + const count = queue.count; + if (count === 0 || this._transparentFlushing === true) { + return; + } + const pool = queue.pool; // Back-to-front, because blending is order-dependent. Sorted over the // LIVE range only — pooled slots past `count` hold stale entries that // must not be dragged into it. Binary insertion: stable, allocation @@ -492,11 +612,11 @@ export default class Renderer { } pool[lo] = entry; } - // emptied BEFORE the replay, not after: the draws below re-enter - // `setBatcher`, which calls back in here, and a queue still holding - // entries would recurse. `_transparentFlushing` guards the same door - // from the other side, and covers the re-entry into `drawMesh`. - this._transparentCount = 0; + // emptied BEFORE the replay, not after: a queue still holding entries + // when the draws below run would recurse if anything on that path + // drained again. `_transparentFlushing` guards the same door from the + // other side, and covers the re-entry into `drawMesh`. + queue.count = 0; this._transparentFlushing = true; try { // The colour the entry was queued WITH has to be put back, because @@ -510,6 +630,11 @@ export default class Renderer { // the colour's OWN alpha, which `setColor(r, g, b)` resets to 1 const savedTintAlpha = tint.alpha; const savedAlpha = this.getGlobalAlpha(); + // `currentTransform` is one object for the frame — `save()`/ + // `restore()` push copies onto a stack rather than swapping it — + // so each entry's view can be installed in place and the drain + // site's own transform put back at the end. + _savedView.copy(this.currentTransform); try { for (let i = 0; i < count; i++) { const entry = pool[i]; @@ -524,6 +649,7 @@ export default class Renderer { // mesh can be queued twice under different modes, and the // entry is the only thing that knows which is which this._replayBlend = entry.blend; + this.currentTransform.copy(entry.view); if (entry.instanced !== null) { this.drawInstancedShadow(entry.instanced, entry.matrix, entry.mesh); } else { @@ -536,6 +662,7 @@ export default class Renderer { } } finally { this._replayBlend = null; + this.currentTransform.copy(_savedView); tint.setColor(savedR, savedG, savedB, savedTintAlpha); this.setGlobalAlpha(savedAlpha); } @@ -560,11 +687,24 @@ export default class Renderer { * @ignore */ removeQueuedTransparent(mesh) { - const count = this._transparentCount ?? 0; - if (count === 0) { - return; + // every target, not just the current one: a mesh destroyed during a + // post-effect pass can still be queued in the world's queue outside it + for (const queue of this._transparentQueues ?? []) { + if (queue !== undefined && queue.count > 0) { + this._removeQueuedFrom(queue, mesh); + } } - const pool = this._transparentPool; + } + + /** + * Drop one mesh's entries from a single target's queue. + * @param {{pool: object[], count: number}} queue - the queue to compact + * @param {object} mesh - the renderable being torn down + * @ignore + */ + _removeQueuedFrom(queue, mesh) { + const count = queue.count; + const pool = queue.pool; let write = 0; for (let read = 0; read < count; read++) { const entry = pool[read]; @@ -581,7 +721,7 @@ export default class Renderer { } write++; } - this._transparentCount = write; + queue.count = write; } /** @@ -618,13 +758,21 @@ export default class Renderer { // null the references as well as the count: a discarded queue would // otherwise keep every queued renderable reachable until its pooled // slot happened to be reused - const queued = this._transparentCount ?? 0; - for (let i = 0; i < queued; i++) { - const entry = this._transparentPool[i]; - entry.mesh = null; - entry.instanced = null; + for (const queue of this._transparentQueues ?? []) { + if (queue === undefined) { + continue; + } + for (let i = 0; i < queue.count; i++) { + const entry = queue.pool[i]; + entry.mesh = null; + entry.instanced = null; + } + queue.count = 0; } - this._transparentCount = 0; + // belt and braces with `resetScreenSpace`, which is what actually + // bounds an unbalanced bracket — `reset()` runs on a stage change and + // on context restore, NOT once per frame + this._screenSpaceDepth = 0; this.renderState.reset(this.width, this.height); this.resetTransform(); this.setBlendMode(this.settings.blendMode); diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 177f1dd44..df853b1a7 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -917,6 +917,17 @@ export default class MeshBatcher extends MaterialBatcher { this.useShader(this.instancedShaderFor(mesh.instanceLayout)); this.updatePassState(); + + // An instanced mesh routes into the transparent pass on exactly the + // same terms as a retained one — the predicate in `drawMesh` reads + // nothing about instancing — so it must replay on the same terms too. + // Without this the whole set is deferred to end-of-frame and then + // drawn opaque anyway, which is strictly worse than not deferring it. + const blend = this.renderer._replayBlend ?? null; + if (blend !== null) { + this.beginBlendedDraw(blend); + } + const slices = mesh.textureGroups; if (slices === undefined) { this.applyMeshMaterial(mesh); @@ -950,6 +961,10 @@ export default class MeshBatcher extends MaterialBatcher { } } + if (blend !== null) { + this.endBlendedDraw(); + } + // Hand the default shader and this batcher's own vertex state back. // Both matter: `bind()` only restores the default program when the // batcher is re-entered, and `setBatcher` returns early when it is diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index 019614669..d17dc9684 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -105,7 +105,7 @@ vec3 applyFog(vec3 rgb, float a) { void main(void) { - vec4 base = texture(uSampler, vRegion) * vColor; + vec4 base = texture(uSampler, vRegion); // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one // material can cut out to the shape of a leaf rather than at a single @@ -119,9 +119,16 @@ void main(void) { // hard alpha cutout (glTF alphaMode MASK) — discard before any shading // so cut-away texels cost nothing and never write depth. + // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint + // multiply. The cutout describes the shape of the surface, and that shape + // does not change when the object fades: testing the drawn alpha instead + // makes a cutout mesh vanish completely the moment its opacity crosses its + // own threshold — and `Sprite3d` defaults that threshold to 0.5, so half a + // fade-out was a hard pop. if (base.a < uAlphaCutoff) { discard; } + base *= vColor; // A mesh marked `lit` with no usable normals — the 2D-camera path, // which leaves world normals unwritten, or geometry that supplied none — diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.frag b/packages/melonjs/src/video/webgl/shaders/mesh.frag index cddd5707a..4d9b53a0e 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -53,7 +53,7 @@ varying vec4 vInstanceData; #endif void main(void) { - vec4 color = texture2D(uSampler, vRegion) * vColor; + vec4 texel = texture2D(uSampler, vRegion); // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one // material can cut out to the shape of a leaf rather than at a single // threshold across the whole surface. Red channel: the format stores a @@ -62,12 +62,19 @@ void main(void) { // is bound the second sampler is filler (the diffuse texture), so the // value is thrown away — and both backends run the identical expression // instead of one branching and the other not. - color.a *= mix(1.0, texture2D(uAlphaMap, vRegion).r, uHasAlphaMap); + texel.a *= mix(1.0, texture2D(uAlphaMap, vRegion).r, uHasAlphaMap); // hard alpha cutout (glTF alphaMode MASK): drop fully-transparent texels // so foliage / fences / decals read crisp without blending or sorting. - if (color.a < uAlphaCutoff) { + // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint + // multiply. The cutout describes the shape of the surface, and that shape + // does not change when the object fades: testing the drawn alpha instead + // makes a cutout mesh vanish completely the moment its opacity crosses its + // own threshold — and `Sprite3d` defaults that threshold to 0.5, so half a + // fade-out was a hard pop. + if (texel.a < uAlphaCutoff) { discard; } + vec4 color = texel * vColor; // emissive adds a self-lit color on top (neon, lava, screens); the unlit // path has no lighting, so it's simply added to the base color. vec3 emissive = uEmissive; diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index b23ac3777..a0ef0b59e 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1434,6 +1434,12 @@ export default class WebGLRenderer extends Renderer { return; } + // Replay anything this pass queued while its own target is still bound. + // The entries were recorded for THIS target under THIS projection, and + // the key is about to be handed back to the pool — so this is the only + // moment they can be drawn where they belong. + this.flushTransparentPass(); + const isCamera = renderable._postEffectManaged; const rt1 = this._renderTargetPool.getCaptureTarget(); const rt2 = this._renderTargetPool.getPingPongTarget(); @@ -2118,6 +2124,17 @@ export default class WebGLRenderer extends Renderer { // instead of full. It went unnoticed while decals were the only client, // because their source colour is black and 0 × anything is 0. const state = blendStateFor(normalizeBlendMode(mode), true); + if (state === undefined) { + // `"none"` is replace — the source overwrites the destination, + // alpha included — and blending switched off is exactly that. + // It reaches here because `blendMode` is an unvalidated property + // and `"none"` is a supported token, so a faded mesh may legally + // carry it; without this the `state.operation` read below throws + // mid-frame. Matches the WebGPU pipeline cache, which builds a + // replace pipeline from the same absent blend state. + gl.disable(gl.BLEND); + return; + } gl.enable(gl.BLEND); gl.blendEquation(GL_BLEND_OP[state.operation]); gl.blendFunc( diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 0eb19218b..2c5f6fb26 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -271,13 +271,18 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { const geometry = this.retainedGeometryFor(mesh); const instances = this.instanceBufferFor(mesh); - this.meshState.depthWrite = undefined; + // see the WebGL batcher: an instanced mesh is routed into the + // transparent pass by the same predicate, so it replays by the same + // rules — its own blend mode, depth test on, depth writes off + const blend = renderer._replayBlend ?? null; + const blended = blend !== null; + this.meshState.depthWrite = blended ? false : undefined; this.meshState.fog = renderer._fog3d != null ? true : undefined; const pipeline = renderer.pipelineCache.get( this.instancedFamilyFor(mesh.instanceLayout), "triangle-list", - "none", - renderer.premultipliedAlpha, + blend ?? "none", + blended ? true : renderer.premultipliedAlpha, renderer.stencilMode, this.meshState, ); @@ -1000,7 +1005,13 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { this.activeShaderKey(), "triangle-list", blend ?? "none", - renderer.premultipliedAlpha, + // A replayed entry is ALWAYS premultiplied — the mesh vertex + // stage premultiplies its own output unconditionally, whatever + // `premultipliedAlpha` (which describes source TEXTURES) happens + // to hold. It is not constant: anything drawing straight-alpha + // content earlier in the frame leaves it `false`, which would + // select `src-alpha` here and apply alpha a second time. + blended ? true : renderer.premultipliedAlpha, renderer.stencilMode, this.meshState, ); diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl index 182d153be..cc8813f37 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -187,7 +187,7 @@ fn vertex_main( @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { // sampled unconditionally, before the discard (uniform control flow) - var base = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + var base = textureSample(uTexture, uSampler, in.vRegion); // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one // material can cut out to the shape of a leaf rather than at a single // threshold across the whole surface. Sampled unconditionally and @@ -196,9 +196,13 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { base.a = base.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y); // hard alpha cutout (glTF alphaMode MASK) — discard before any shading // so cut-away texels cost nothing and never write depth + // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint + // multiply — see mesh.frag: the cutout is the surface's shape, and a + // fading object must keep it rather than pop out at its own threshold. if (base.a < uMesh.params.x) { discard; } + base = base * in.vColor; // see mesh-lit.frag: a `lit` mesh with no usable normals must degrade to // unlit rather than normalize a zero vector to NaN and render black diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl index 685636a0c..e960a4c5e 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl @@ -170,7 +170,7 @@ fn vertex_main( @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { // sampled unconditionally, before the discard (uniform control flow) - var color = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + var color = textureSample(uTexture, uSampler, in.vRegion); // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one // material can cut out to the shape of a leaf rather than at a single // threshold across the whole surface. Sampled unconditionally and @@ -179,9 +179,13 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { color.a = color.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y); // hard alpha cutout (glTF alphaMode MASK): drop cut texels so foliage / // fences / decals read crisp without blending or sorting + // Thresholded on the MATERIAL's own alpha, deliberately BEFORE the tint + // multiply — see mesh.frag: the cutout is the surface's shape, and a + // fading object must keep it rather than pop out at its own threshold. if (color.a < uMesh.params.x) { discard; } + color = color * in.vColor; // emissive adds a self-lit color on top (neon, lava, screens); the // unlit path has no lighting, so it is simply added to the base color return vec4f( diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index bae5fc84b..27158c6b9 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1262,6 +1262,12 @@ export default class WebGPURenderer extends Renderer { return; } + // Replay anything this pass queued while its own target is still bound. + // The entries were recorded for THIS target under THIS projection, and + // the key is about to be handed back to the pool — so this is the only + // moment they can be drawn where they belong. + this.flushTransparentPass(); + const isCamera = renderable._postEffectManaged; const pool = this._renderTargetPool; const rt1 = pool.getCaptureTarget(); diff --git a/packages/melonjs/tests/application.spec.js b/packages/melonjs/tests/application.spec.js index 453ea853f..7273c1bf1 100644 --- a/packages/melonjs/tests/application.spec.js +++ b/packages/melonjs/tests/application.spec.js @@ -105,6 +105,33 @@ describe("Application", () => { }); }); + describe("the frame boundary resets the screen-space bracket", () => { + it("clears it every draw, so an unbalanced overlay cannot wedge the transparent pass", async () => { + // `Container.draw` opens the screen-space bracket around a floating + // child with no `finally`, so a child that throws leaves it open — + // and while it is open every drain of the transparent queue is + // skipped. Without this the pass stays dead until the next STAGE + // CHANGE (the only thing that calls `renderer.reset()`), silently, + // with the queue growing every frame. + boot(); + const app = new Application(64, 64, { + parent: "screen", + renderer: video.CANVAS, + consoleHeader: false, + }); + await app.init(); + try { + app.renderer.beginScreenSpace(); // ...and never closed + expect(app.renderer._screenSpaceDepth).toBe(1); + app.isDirty = true; + app.draw(); + expect(app.renderer._screenSpaceDepth).toBe(0); + } finally { + app.destroy(); + } + }); + }); + describe("physics startup banner", () => { it("reports the built-in adapter via its stable physicLabel, not a class name", async () => { boot(); diff --git a/packages/melonjs/tests/transparent_queue.spec.js b/packages/melonjs/tests/transparent_queue.spec.js index 6986855f7..40b07295c 100644 --- a/packages/melonjs/tests/transparent_queue.spec.js +++ b/packages/melonjs/tests/transparent_queue.spec.js @@ -1,5 +1,23 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { boot, Matrix3d, Mesh, TextureAtlas } from "../src/index.js"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + boot, + Camera3d, + Container, + InstancedMesh, + Matrix3d, + Mesh, + ShaderEffect, + Sprite3d, + TextureAtlas, +} from "../src/index.js"; import { getWebGLRenderer, releaseWebGLRenderer, @@ -46,6 +64,13 @@ describe("the transparent pass (#1516)", () => { } }; + afterEach(() => { + // A test that fails part-way leaves entries queued, and the next test + // then reports a count from its predecessor's leftovers rather than + // its own — one real failure cascades into several misleading ones. + renderer?.reset(); + }); + let _atlas = null; const whiteAtlas = () => { if (_atlas === null) { @@ -429,4 +454,480 @@ describe("the transparent pass (#1516)", () => { expect(renderer.currentBlendMode).toBe("normal"); }); }); + + // ────────────────────────────────────────────────────────────────────── + // What the review found: the routing predicate reads nothing about + // instancing, nothing about the blend token's validity, and nothing about + // where the queue will be drained. Each of those was a real defect. + // ────────────────────────────────────────────────────────────────────── + + describe("the paths the predicate reaches but the replay forgot", () => { + /** an instanced quad of the same size as `quad()`, one instance at the origin */ + const instancedQuad = (half = 16) => { + const mesh = new InstancedMesh(0, 0, { + vertices: new Float32Array([ + -half, + -half, + 0, + half, + -half, + 0, + half, + half, + 0, + -half, + half, + 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + texture: whiteAtlas(), + width: half * 2, + height: half * 2, + cullBackFaces: false, + lit: false, + normalize: false, + instanceCount: 1, + }); + mesh._useWorldSpace = true; + mesh.setInstance(0, new Matrix3d()); + return mesh; + }; + + it("fades an InstancedMesh instead of darkening it", (ctx) => { + requireWebGL(ctx); + // The predicate defers an instanced mesh exactly as it defers a + // retained one — nothing in it looks at `instanceLayout`. The + // replay has to honour that: a set queued and then drawn opaque + // anyway is strictly WORSE than never deferring it, because the + // draw has also been reordered to end of frame for nothing. + // Measured before the fix: [127,127,127], the same darkening + // signature the whole pass exists to remove. + setup([0, 0, 255]); + const mesh = instancedQuad(); + mesh.tint.setColor(255, 255, 255); + mesh.setOpacity(0.5); + draw(mesh); + expect(renderer._transparentCount).toBe(1); + renderer.flushTransparent(); + const px = readPixel(); + expect(px[2]).toBeGreaterThan(200); // the blue SURVIVES + expect(px[0]).toBeGreaterThan(100); + expect(px[0]).toBeLessThan(155); + mesh.destroy(); + }); + + it('replays a `blendMode` of "none" without throwing', (ctx) => { + requireWebGL(ctx); + // `blendMode` is a plain property and `"none"` is a supported + // token, so a faded mesh may legally carry it — and `"none"` has + // no blend state by contract (it is replace). Dereferencing the + // missing state threw mid-frame, out of the drain. + setup([0, 0, 255]); + const mesh = quad(); + mesh.blendMode = "none"; + mesh.tint.setColor(255, 255, 255); + mesh.setOpacity(0.5); + draw(mesh); + expect(() => { + renderer.flushTransparent(); + }).not.toThrow(); + // replace semantics: the source overwrites, so the premultiplied + // half-alpha white lands as-is and the background does NOT survive + expect(readPixel()[2]).toBeLessThan(200); + }); + + it("replays under the transform it was queued with", (ctx) => { + requireWebGL(ctx); + // A queued entry carries a model matrix but the view is live on + // the renderer, and `Container.draw` translates by its own + // position before walking its children. Drained after that bracket + // closed, the entry used to draw at the container's offset from + // where it belongs — a visible jump the instant a mesh fades. + setup([0, 0, 255]); + const mesh = quad(); + mesh.tint.setColor(255, 255, 255); + mesh.setOpacity(0.5); + const OFFSET = 40; + renderer.save(); + renderer.translate(OFFSET, 0); + draw(mesh); + renderer.restore(); + renderer.flushTransparent(); + // centre of the canvas is where the OPAQUE draw would land; the + // faded one belongs `OFFSET` to the right of it + const atOffset = readPixel(SIZE / 2 + OFFSET, SIZE / 2); + const atOrigin = readPixel(SIZE / 2, SIZE / 2); + expect(atOffset[0]).toBeGreaterThan(100); // drawn here... + expect(atOffset[2]).toBeGreaterThan(200); // ...and blended + expect(Array.from(atOrigin).slice(0, 3)).toEqual([0, 0, 255]); + }); + + it("fades a lit mesh too", (ctx) => { + requireWebGL(ctx); + // the lit batcher inherits the replay, and an override that + // dropped `_replayBlend` would silently un-fade the lit tier + setup([0, 0, 255]); + const mesh = quad(); + mesh.lit = true; + mesh.tint.setColor(255, 255, 255); + mesh.setOpacity(0.5); + draw(mesh); + expect(renderer._transparentCount).toBe(1); + renderer.flushTransparent(); + expect(readPixel()[2]).toBeGreaterThan(200); // blue survives + }); + }); + + describe("the blend state actually reaches the GPU", () => { + it("an additive entry really adds", (ctx) => { + requireWebGL(ctx); + // Asserting the 2D blend cache is left clean says nothing about + // whether the entry's own mode was ever installed: an + // `applyBlendFunction` that ignored `mode` passed every existing + // test. Red additive over blue must read magenta. + setup([0, 0, 255]); + const mesh = quad(); + mesh.transparent = true; + mesh.blendMode = "additive"; + mesh.tint.setColor(255, 0, 0); + draw(mesh); + renderer.flushTransparent(); + const px = readPixel(); + expect(px[0]).toBeGreaterThan(200); // the red ADDED + expect(px[2]).toBeGreaterThan(200); // on top of the blue + }); + + it("restores the blend FUNCTION, not just the enable bit", (ctx) => { + requireWebGL(ctx); + // The replay overwrites the function behind the 2D cache's back, + // so leaving only `GL_BLEND` off would let the next 2D draw + // short-circuit its `setBlendMode` and inherit `additive`. + const gl = renderer.gl; + setup([0, 0, 0]); + const mesh = quad(); + mesh.transparent = true; + mesh.blendMode = "additive"; + draw(mesh); + renderer.flushTransparent(); + expect(gl.getParameter(gl.BLEND_DST_RGB)).toBe(gl.ONE_MINUS_SRC_ALPHA); + }); + }); + + describe("the queue holds nothing alive", () => { + it("`reset()` releases the queued references", (ctx) => { + requireWebGL(ctx); + // the drain's own release was pinned; `reset()`'s was not, so a + // revert to a bare `_transparentCount = 0` passed the suite while + // pinning a destroyed mesh alive in a pooled slot + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + expect(renderer._transparentPool[0].mesh).not.toBe(null); + renderer.reset(); + expect(renderer._transparentCount).toBe(0); + expect(renderer._transparentPool[0].mesh).toBe(null); + expect(renderer._transparentPool[0].instanced).toBe(null); + }); + }); + + describe("an overlay puts its own transparency down before it leaves", () => { + // A `floating` child shares the render TARGET with the world — only + // the projection swaps — so its transparent meshes land in the world's + // queue. Replayed after the bracket closes they go through the + // camera's perspective instead of the flat screen ortho, and their + // vertices sit at view-space z = 0: the camera itself. The divide + // deletes them. Measured before the fix: a faded HUD mesh drew ZERO + // pixels anywhere in the frame, while the same mesh at full opacity + // drew correctly — so fading a HUD model made it vanish. + + // A REAL `Camera3d`, not a stub: `Mesh.draw` decides between the + // world-space GPU path and the 2D CPU projection by testing + // `viewport instanceof Camera3d`, so a look-alike silently takes the + // other path and tests nothing that matters here. `isDefault` is + // forced because a standalone camera is not the application's, and + // `Container.draw` skips floating children on a non-default camera. + const camera3d = () => { + const camera = new Camera3d(0, 0, SIZE, SIZE); + Object.defineProperty(camera, "isDefault", { + get: () => { + return true; + }, + }); + camera.pos.set(0, 0, -EYE_Z); + // `Camera2d.draw` is what normally fills this in, and it is a + // TOP-LEFT origin ortho — which is why the HUD below sits at the + // middle of the screen rather than at the world origin + // the wide z range Camera3d itself uses: its perspective near plane is + // 0.1, so floating content left at the default depth 0 would be clipped + camera.screenProjection.ortho(0, SIZE, SIZE, 0, -1e6, 1e6); + return camera; + }; + + /** a faded quad placed centre-screen, as an overlay would be */ + const hudQuad = () => { + const hud = quad(); + hud.floating = true; + // screen coordinates: the floating projection is a top-left-origin + // ortho over the canvas, so this is the middle of the screen + hud.pos.set(SIZE / 2, SIZE / 2, 0); + hud.tint.setColor(255, 255, 255); + hud.setOpacity(0.5); + return hud; + }; + + it("draws a faded mesh inside a floating child", (ctx) => { + requireWebGL(ctx); + const viewport = camera3d(); + renderer.setProjection(viewport.projectionMatrix); + renderer.currentTransform.identity().translate(0, 0, -EYE_Z); + renderer.backgroundColor.setColor(0, 0, 255, 255); + renderer.clear(); + + const hud = hudQuad(); + const world = new Container(0, 0, SIZE, SIZE); + world.addChild(hud); + + world.preDraw(renderer); + world.draw(renderer, viewport); + world.postDraw(renderer); + renderer.flushTransparent(); + + // white over blue at half coverage, drawn under the SCREEN ortho. + // Before the fix this read [0,0,255] — and a scan of the whole + // frame found ZERO non-background pixels, so the mesh was not + // displaced, it was gone. + const px = readPixel(); + expect(px[2]).toBeGreaterThan(200); + expect(px[0]).toBeGreaterThan(100); + expect(px[0]).toBeLessThan(155); + }); + + it("drains before restoring the world projection, not after", (ctx) => { + requireWebGL(ctx); + // order is the whole fix: after the restore the entry replays + // through the perspective and is annihilated + const viewport = camera3d(); + renderer.setProjection(viewport.projectionMatrix); + renderer.currentTransform.identity().translate(0, 0, -EYE_Z); + renderer.clear(); + + const order = []; + const drain = renderer.flushTransparentPass.bind(renderer); + const project = renderer.setProjection.bind(renderer); + renderer.flushTransparentPass = (...a) => { + // only a drain that actually had something to put down counts: + // `Container.draw` also drains BEFORE opening the bracket, and + // on an empty queue that call would otherwise satisfy this + // assertion all by itself + if (renderer._transparentCount > 0) { + order.push("drain"); + } + return drain(...a); + }; + renderer.setProjection = (m) => { + if (m === viewport.worldProjection || m === viewport.projectionMatrix) { + order.push("restore"); + } + return project(m); + }; + try { + const hud = hudQuad(); + const world = new Container(0, 0, SIZE, SIZE); + world.addChild(hud); + world.preDraw(renderer); + world.draw(renderer, viewport); + world.postDraw(renderer); + } finally { + renderer.flushTransparentPass = drain; + renderer.setProjection = project; + } + expect(order.indexOf("drain")).toBeGreaterThanOrEqual(0); + expect(order.indexOf("drain")).toBeLessThan(order.lastIndexOf("restore")); + }); + }); + + describe("an unbalanced overlay bracket cannot disable the pass", () => { + it("recovers at the frame boundary", (ctx) => { + requireWebGL(ctx); + // `Container.draw` opens the screen-space bracket without a + // `finally`, so a floating child that throws leaves it open — and + // while it is open every drain is skipped. Left to `reset()` that + // would persist until the next STAGE CHANGE, not the next frame, + // so the pass would stay dead and the queue grow all the while. + setup(); + renderer.beginScreenSpace(); // ...and never closed + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(1); // blocked, as designed + + renderer.resetScreenSpace(); // what `Application.draw` does per frame + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(0); + }); + }); + + // ────────────────────────────────────────────────────────────────────── + // One queue PER RENDER TARGET. + // + // A post effect binds its own offscreen target part-way through a scene. + // With a single renderer-wide queue, a drain that fired inside that + // bracket replayed the WORLD's queued geometry into the effect's buffer — + // baking the scene's transparent objects into one renderable's texture, + // and removing them from the scene. Keyed per target the two never meet. + // ────────────────────────────────────────────────────────────────────── + + describe("a queue belongs to the target it was recorded for", () => { + /** a renderable stand-in with two effects, which forces the FBO path */ + const effectPass = () => { + const passthrough = () => { + return new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return color; + } + `, + ); + }; + return { + postEffects: [passthrough(), passthrough()], + _postEffectManaged: false, + }; + }; + + const destroyPass = (pass) => { + for (const fx of pass.postEffects) { + fx.destroy(); + } + }; + + it("keeps the world's entries out of an effect's queue", (ctx) => { + requireWebGL(ctx); + setup(); + const world = quad(); + world.setOpacity(0.5); + draw(world); + expect(renderer._transparentCount).toBe(1); + + const pass = effectPass(); + expect(renderer.beginPostEffect(pass)).toBe(true); + // inside the bracket the current target is the effect's own, and + // its queue is empty — the world's entry is NOT visible here + expect(renderer._transparentCount).toBe(0); + // ...and a drain in here must not consume it + renderer.flushTransparent(); + renderer.endPostEffect(pass); + renderer.flush(); + + // back outside, the world's entry survived untouched + expect(renderer._transparentCount).toBe(1); + renderer.flushTransparent(); + expect(renderer._transparentCount).toBe(0); + destroyPass(pass); + }); + + it("gives each target its own queue", (ctx) => { + requireWebGL(ctx); + setup(); + draw(Object.assign(quad(), { transparent: true })); + const outside = renderer.transparentTarget(); + const pass = effectPass(); + renderer.beginPostEffect(pass); + const inside = renderer.transparentTarget(); + draw(Object.assign(quad(), { transparent: true })); + expect(renderer.transparentQueue(inside).count).toBe(1); + expect(renderer.transparentQueue(outside).count).toBe(1); + expect(inside).not.toBe(outside); + renderer.endPostEffect(pass); + renderer.flush(); + renderer.flushTransparent(); + destroyPass(pass); + }); + + it("drains a pass's own queue before its target is unbound", (ctx) => { + requireWebGL(ctx); + // otherwise the entries are stranded: the key returns to the pool + // and a later pass reusing it would replay them into ITS target + setup(); + const pass = effectPass(); + renderer.beginPostEffect(pass); + const inside = renderer.transparentTarget(); + draw(Object.assign(quad(), { transparent: true })); + expect(renderer.transparentQueue(inside).count).toBe(1); + renderer.endPostEffect(pass); + renderer.flush(); + expect(renderer.transparentQueue(inside).count).toBe(0); + destroyPass(pass); + }); + + it("sweeps every target when a queued mesh is destroyed", (ctx) => { + requireWebGL(ctx); + setup(); + const doomed = quad(); + doomed.transparent = true; + draw(doomed); + const pass = effectPass(); + renderer.beginPostEffect(pass); + // destroyed from inside a pass, while queued OUTSIDE it + renderer.removeQueuedTransparent(doomed); + renderer.endPostEffect(pass); + renderer.flush(); + expect(renderer._transparentCount).toBe(0); + destroyPass(pass); + }); + + it("`reset()` clears every target's queue, not just the current one", (ctx) => { + requireWebGL(ctx); + setup(); + draw(Object.assign(quad(), { transparent: true })); + const outside = renderer.transparentTarget(); + renderer._renderTargetPool.begin(false, 2, SIZE, SIZE); + const inside = renderer.transparentTarget(); + draw(Object.assign(quad(), { transparent: true })); + renderer._renderTargetPool.end(); + expect(renderer.transparentQueue(inside).count).toBe(1); + renderer.reset(); + expect(renderer.transparentQueue(inside).count).toBe(0); + expect(renderer.transparentQueue(outside).count).toBe(0); + expect(renderer.transparentQueue(inside).pool[0].mesh).toBe(null); + }); + }); +}); + +/** + * `Sprite3d` picks the cutout threshold that decides whether a fading sprite + * survives, so the two interact and the choice is pinned here. + */ +describe("Sprite3d and the alpha cutout", () => { + beforeAll(async () => { + await boot(); + }); + + it("drops the cutoff to the floor when explicitly transparent", () => { + const sprite = new Sprite3d(0, 0, { + width: 16, + height: 16, + transparent: true, + }); + expect(sprite.alphaCutoff).toBeCloseTo(1 / 255, 6); + }); + + it("keeps the 0.5 default otherwise", () => { + const sprite = new Sprite3d(0, 0, { width: 16, height: 16 }); + expect(sprite.alphaCutoff).toBe(0.5); + }); + + it("an explicit cutoff always wins", () => { + const sprite = new Sprite3d(0, 0, { + width: 16, + height: 16, + transparent: true, + alphaCutoff: 0.25, + }); + expect(sprite.alphaCutoff).toBe(0.25); + }); }); diff --git a/packages/melonjs/tests/webgl_mesh_depth.spec.js b/packages/melonjs/tests/webgl_mesh_depth.spec.js index 865016283..0467820ca 100644 --- a/packages/melonjs/tests/webgl_mesh_depth.spec.js +++ b/packages/melonjs/tests/webgl_mesh_depth.spec.js @@ -431,13 +431,20 @@ describe("Mesh depth handling (issue #1468)", () => { // Layer 2 — alpha cutout (glTF alphaMode MASK) // ────────────────────────────────────────────────────────────────────── // - // The mesh shaders `discard` a fragment whose final alpha is below - // `uAlphaCutoff`. With no blending (mesh mode disables BLEND), a discarded - // fragment leaves the background untouched. These drive the fragment alpha - // via the global alpha (which becomes `vColor.a` through the batcher) and - // read back the centre pixel: below the cutoff → background survives; at / - // above → the mesh paints. Doubles as a smoke test that both shaders still - // COMPILE with the new uniform and the batcher's `setUniform` path runs. + // The mesh shaders `discard` a fragment whose MATERIAL alpha — the texel, + // times the opacity map — is below `uAlphaCutoff`. With no blending (mesh + // mode disables BLEND), a discarded fragment leaves the background + // untouched. These drive the texel alpha and read back the centre pixel: + // below the cutoff → background survives; at / above → the mesh paints. + // Doubles as a smoke test that both shaders still COMPILE with the new + // uniform and the batcher's `setUniform` path runs. + // + // The threshold is deliberately NOT applied to the drawn alpha. `vColor.a` + // carries the renderable's opacity, and a cutout mesh must keep its shape + // as it fades rather than pop out of existence the moment its opacity + // crosses its own threshold — `Sprite3d` defaults that threshold to 0.5, + // so half a fade-out used to be a hard cut to nothing. The last test here + // pins that. describe("alpha cutout (Layer 2)", () => { const readCenter = () => { @@ -461,11 +468,38 @@ describe("Mesh depth handling (issue #1468)", () => { renderer.fillRect(0, 0, 1, 1); // force a non-mesh batcher state }; - const drawCutoutMesh = (alpha) => { + // a 1x1 atlas at a chosen alpha, which is what the cutout thresholds + const _fadedAtlas = new Map(); + const getFadedAtlas = (alpha) => { + let atlas = _fadedAtlas.get(alpha); + if (atlas === undefined) { + const tex = document.createElement("canvas"); + tex.width = 1; + tex.height = 1; + const ctx = tex.getContext("2d"); + ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; + ctx.fillRect(0, 0, 1, 1); + atlas = new TextureAtlas( + { + framewidth: 1, + frameheight: 1, + image: tex, + name: `white_1x1_a${alpha}`, + }, + tex, + false, + ); + _fadedAtlas.set(alpha, atlas); + } + return atlas; + }; + + const drawCutoutMesh = (texelAlpha, opacity = 1) => { const mesh = makeQuadMesh(64, 64, 0, [220, 20, 20, 255]); mesh.alphaCutoff = 0.5; + mesh.texture = getFadedAtlas(texelAlpha); renderer.currentTint.setColor(...mesh.tintRGBA); - renderer.setGlobalAlpha(alpha); // becomes vColor.a in the shader + renderer.setGlobalAlpha(opacity); renderer.drawMesh(mesh); renderer.setGlobalAlpha(1); // restore for sibling tests }; @@ -474,7 +508,7 @@ describe("Mesh depth handling (issue #1468)", () => { requireWebGL2(ctx); setupOrtho(); freshFrame(); - drawCutoutMesh(0.3); // 0.3 < 0.5 → discard every fragment + drawCutoutMesh(0.3); // texel 0.3 < 0.5 → discard every fragment const px = readCenter(); expect(px[0]).toBeLessThan(60); // red dropped → black background }); @@ -483,7 +517,7 @@ describe("Mesh depth handling (issue #1468)", () => { requireWebGL2(ctx); setupOrtho(); freshFrame(); - drawCutoutMesh(0.9); // 0.9 >= 0.5 → fragment kept + drawCutoutMesh(0.9); // texel 0.9 >= 0.5 → fragment kept const px = readCenter(); expect(px[0]).toBeGreaterThan(150); // red paints through }); @@ -492,18 +526,66 @@ describe("Mesh depth handling (issue #1468)", () => { requireWebGL2(ctx); setupOrtho(); freshFrame(); - // At alpha 0.45 the cutout=0.5 case (test above) discards to black. - // With alphaCutoff at its 0 default, `a < 0` is never true → the same - // fragment survives. Output RGB is premultiplied (≈ 220·0.45 ≈ 99), - // so a kept fragment reads clearly above the discarded-to-black floor. + // At texel alpha 0.45 the cutout=0.5 case (test above) discards to + // black. With alphaCutoff at its 0 default, `a < 0` is never true → + // the same fragment survives. Output RGB is premultiplied + // (≈ 220·0.45 ≈ 99), so a kept fragment reads clearly above the + // discarded-to-black floor. const mesh = makeQuadMesh(64, 64, 0, [220, 20, 20, 255]); + mesh.texture = getFadedAtlas(0.45); renderer.currentTint.setColor(...mesh.tintRGBA); - renderer.setGlobalAlpha(0.45); renderer.drawMesh(mesh); - renderer.setGlobalAlpha(1); const px = readCenter(); expect(px[0]).toBeGreaterThan(50); // kept (≈99), not discarded (≈0) }); + + it("a fading cutout mesh keeps its shape instead of vanishing", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + freshFrame(); + // An opaque texel under a 0.5 cutoff, faded to 30% opacity. The + // cutout must not see the fade: this is a `Sprite3d` at its default + // cutoff being faded out, which used to hard-cut to nothing at 49%. + // Premultiplied output ≈ 220·0.3 ≈ 66, well clear of black. + drawCutoutMesh(1, 0.3); + const px = readCenter(); + expect(px[0]).toBeGreaterThan(40); + }); + + it("...and so does a LIT one", (ctx) => { + requireWebGL2(ctx); + // The lit tier is a separate shader with its own copy of the + // cutout, and the unlit test above cannot see it: reverting + // `mesh-lit.frag` alone left every other test in this file and in + // the transparent-pass spec passing. A mesh with no normals + // degrades to unlit shading by design, which is why the same + // threshold assertion works here. + setupOrtho(); + freshFrame(); + const mesh = makeQuadMesh(64, 64, 0, [220, 20, 20, 255]); + mesh.alphaCutoff = 0.5; + mesh.lit = true; + mesh.texture = getFadedAtlas(1); + renderer.currentTint.setColor(...mesh.tintRGBA); + renderer.setGlobalAlpha(0.3); + renderer.drawMesh(mesh); + renderer.setGlobalAlpha(1); + expect(readCenter()[0]).toBeGreaterThan(40); + }); + + it("a LIT cutout still discards below the threshold", (ctx) => { + requireWebGL2(ctx); + // the other half: moving the test must not disable the cutout + setupOrtho(); + freshFrame(); + const mesh = makeQuadMesh(64, 64, 0, [220, 20, 20, 255]); + mesh.alphaCutoff = 0.5; + mesh.lit = true; + mesh.texture = getFadedAtlas(0.3); + renderer.currentTint.setColor(...mesh.tintRGBA); + renderer.drawMesh(mesh); + expect(readCenter()[0]).toBeLessThan(60); + }); }); // ────────────────────────────────────────────────────────────────────── diff --git a/packages/melonjs/tests/webgpu_mipmaps.spec.js b/packages/melonjs/tests/webgpu_mipmaps.spec.js index 3e731e20d..ffa15896d 100644 --- a/packages/melonjs/tests/webgpu_mipmaps.spec.js +++ b/packages/melonjs/tests/webgpu_mipmaps.spec.js @@ -6,7 +6,7 @@ import WebGPUTextureStore from "../src/video/webgpu/texture/store.js"; * Mesh-texture mipmaps on the WebGPU backend: a mip-wanting consumer (the * mesh path) gets a full generated chain and a trilinear sampler, while * every 2D consumer of the same image stays lod-clamped to level 0 — the - * Godot-style split where minification quality is a 3D concern and sprite + * conventional split, where minification quality is a 3D concern and sprite * output never changes. */ describe("WebGPUTextureStore mipmaps", () => { diff --git a/packages/melonjs/tests/webgpu_transparent_pass.spec.js b/packages/melonjs/tests/webgpu_transparent_pass.spec.js new file mode 100644 index 000000000..54e3f9f91 --- /dev/null +++ b/packages/melonjs/tests/webgpu_transparent_pass.spec.js @@ -0,0 +1,360 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Color, Matrix3d } from "../src/index.js"; +import { instanceRecordLayout } from "../src/video/gpu/instancerecord.ts"; +import Renderer from "../src/video/renderer.js"; +import WebGLRenderer from "../src/video/webgl/webgl_renderer.js"; +import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * The transparent pass (#1516) on WebGPU. + * + * The pass is backend-neutral by design — the queue and the sort live on the + * base `Renderer` — but the two halves that are NOT shared are exactly the + * two that can drift silently: the routing predicate, duplicated in each + * backend's `drawMesh`, and the per-entry blend state, which each backend + * installs its own way. A divergence renders correctly on the machine the + * author happened to test and wrongly on the other, with nothing failing. + */ + +/** + * Drive a backend's REAL `drawMesh` far enough to see whether the predicate + * routed, without a GPU behind it. + * + * The predicate returns before touching any device state, so a routed draw + * completes; an unrouted one continues into the recording path and throws on + * the absent context. That throw is the "did not route" signal and nothing + * else is read from it — the drawing path itself is covered by the specs that + * own it. + * @param {Function} RendererClass - the backend under test + * @param {object} mesh - the mesh to offer + * @param {number} alpha - the global alpha in force + * @param {boolean} flushing - whether a drain is already running + * @param {boolean} retained - whether a model matrix is supplied + * @returns {object[]} the `queueTransparent` argument lists recorded + */ +const routeThrough = ( + RendererClass, + mesh, + { alpha = 1, flushing = false, retained = true } = {}, +) => { + const queued = []; + const probe = Object.create(RendererClass.prototype); + Object.assign(probe, { + currentTint: new Color(255, 255, 255, 1), + getGlobalAlpha: () => { + return alpha; + }, + queueTransparent: (...args) => { + return queued.push(args); + }, + _transparentFlushing: flushing, + }); + try { + probe.drawMesh(mesh, retained ? new Matrix3d() : undefined); + } catch { + // past the predicate and into the device path — not under test here + } + return queued; +}; + +const BACKENDS = [ + ["WebGL", WebGLRenderer], + ["WebGPU", WebGPURenderer], +]; + +describe("the routing predicate agrees across backends", () => { + // Each case names what the predicate must decide and why. Run against both + // backends from one table: the point is not that either is individually + // right, but that a change to one and not the other cannot pass. + const CASES = [ + { + what: "a faded mesh routes", + mesh: {}, + opts: { alpha: 0.5 }, + routes: true, + }, + { + what: "an opaque mesh does not", + mesh: {}, + opts: {}, + routes: false, + }, + { + what: "`transparent: true` routes at full opacity", + mesh: { transparent: true }, + opts: {}, + routes: true, + }, + { + what: "`transparent: false` never routes", + mesh: { transparent: false }, + opts: { alpha: 0.5 }, + routes: false, + }, + { + what: "an internal decal quad routes", + mesh: { _blendedDraw: true }, + opts: {}, + routes: true, + }, + { + what: "a non-retained draw never routes", + mesh: { transparent: true }, + opts: { retained: false }, + routes: false, + }, + { + what: "nothing routes during a drain", + mesh: { transparent: true }, + opts: { flushing: true }, + routes: false, + }, + ]; + + for (const [name, RendererClass] of BACKENDS) { + describe(name, () => { + for (const { what, mesh, opts, routes } of CASES) { + it(what, () => { + const queued = routeThrough(RendererClass, mesh, opts); + expect(queued).toHaveLength(routes ? 1 : 0); + }); + } + + it('hands the decal quads `"normal"` and everything else its own mode', () => { + const [decal] = routeThrough(RendererClass, { + _blendedDraw: true, + blendMode: "additive", + }); + // an internal decal is not a place to honour a stray blendMode + expect(decal[3]).toBe("normal"); + const [glow] = routeThrough( + RendererClass, + { transparent: true, blendMode: "additive" }, + {}, + ); + expect(glow[3]).toBe("additive"); + }); + }); + } + + it("routes on the same alpha threshold on both backends", () => { + // 254/255 rounds to a packed alpha below 0xff and must route; a true + // 1.0 must not. The boundary is where an accumulated container opacity + // of 0.999 lands, so a backend drifting by one step here would defer + // (or fail to defer) an entire scene. + for (const [, RendererClass] of BACKENDS) { + expect( + routeThrough(RendererClass, {}, { alpha: 254 / 255 }), + ).toHaveLength(1); + expect(routeThrough(RendererClass, {}, { alpha: 1 })).toHaveLength(0); + } + }); +}); + +/** + * A mesh shaped for the retained WebGPU path. Mirrors + * `webgpu_mesh_retained.spec.js`. + * @param {object} overrides - fields to replace + * @returns {object} the mesh stand-in + */ +function makeRetainedMesh(overrides = {}) { + return { + originalVertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + _indicesOriginal: new Uint16Array([0, 1, 2, 0, 2, 3]), + _geometryVersion: 0, + vertexCount: 4, + texture: { id: "atlas" }, + alphaCutoff: 0, + lit: false, + cullBackFaces: true, + rightHanded: false, + ...overrides, + }; +} + +/** + * The same, plus the per-instance fields `instanceBufferFor` consumes. + * @param {object} overrides - fields to replace + * @returns {object} the instanced mesh stand-in + */ +function makeInstancedMesh(overrides = {}) { + const layout = instanceRecordLayout(false, false); + const instanceCount = 2; + return makeRetainedMesh({ + instanceLayout: layout, + instanceCount, + visibleInstanceCount: instanceCount, + instanceBuffer: new Float32Array( + (instanceCount * layout.stride) / Float32Array.BYTES_PER_ELEMENT, + ), + instanceUpload: () => { + return { full: true, first: 0, count: instanceCount, revision: 1 }; + }, + clearInstanceDirty: () => {}, + ...overrides, + }); +} + +const MODEL = new Matrix3d(); + +describe("the replay installs its blend state (mock device)", () => { + let renderer; + let batcher; + let states; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + // the mock's key carries the blend token and the premultiplied bit but + // not the depth-write axis, so snapshot the mesh state as it is asked for + states = []; + const get = renderer.pipelineCache.get.bind(renderer.pipelineCache); + renderer.pipelineCache.get = (...args) => { + states.push({ ...args[5] }); + return get(...args); + }; + }); + + /** the blend token and premultiplied bit out of the last pipeline key */ + const lastKey = () => { + const parts = renderer.calls.pipelineKeys.at(-1).split("|"); + return { blend: parts[2], premultiplied: parts[3] }; + }; + + const DRAWS = [ + [ + "retained", + (mesh) => { + return batcher.drawRetainedMesh(mesh, MODEL, 0x80ffffff); + }, + makeRetainedMesh, + ], + [ + "instanced", + (mesh) => { + return batcher.drawInstancedMesh(mesh, MODEL, 0x80ffffff); + }, + makeInstancedMesh, + ], + ]; + + for (const [name, drawIt, make] of DRAWS) { + describe(name, () => { + it("carries the entry's own blend mode into the pipeline", () => { + renderer._replayBlend = "additive"; + drawIt(make()); + expect(lastKey().blend).toBe("additive"); + }); + + it("stops writing depth for a replayed entry", () => { + renderer._replayBlend = "normal"; + drawIt(make()); + expect(states.at(-1).depthWrite).toBe(false); + }); + + it("leaves the ordinary draw exactly as it was", () => { + renderer._replayBlend = null; + drawIt(make()); + expect(lastKey().blend).toBe("none"); + // `undefined`, not `true`: the axis reads `!== false`, so an + // unset value keeps the key byte-identical to before the pass + expect(states.at(-1).depthWrite).toBeUndefined(); + }); + + it("stays premultiplied after straight-alpha content drew earlier", () => { + // `premultipliedAlpha` describes source TEXTURES and is mutable — + // anything drawing a straight-alpha atlas earlier in the frame + // leaves it `false`. The mesh vertex stage premultiplies its own + // output unconditionally, so honouring the flag here selects + // `src-alpha` and applies alpha a SECOND time. + renderer.premultipliedAlpha = false; + renderer._replayBlend = "normal"; + drawIt(make()); + expect(lastKey().premultiplied).toBe("true"); + }); + }); + } +}); + +describe("the queue itself is backend-neutral", () => { + it("lives on the base renderer, not on either backend", () => { + // the sort, the pool, the per-target keying and the drain guards are + // all shared code; only the routing predicate and the blend + // installation are duplicated, and those are pinned above + for (const method of [ + "queueTransparent", + "flushTransparent", + "flushTransparentPass", + "transparentQueue", + "transparentTarget", + "removeQueuedTransparent", + ]) { + expect(typeof Renderer.prototype[method]).toBe("function"); + expect( + Object.hasOwn(WebGPURenderer.prototype, method) || + Object.hasOwn(WebGLRenderer.prototype, method), + ).toBe(false); + } + }); + + it("both backends drain their own pass before unbinding its target", () => { + // The queue is keyed by render target, so a pass that ends without + // draining strands its entries: the key returns to the pool and the + // next pass to claim it would replay them into a target they were + // never recorded for. Each backend contributes the same one call at + // the same point — asserted here rather than assumed, because a + // backend quietly missing it is invisible until something renders in + // the wrong buffer. + for (const [name, RendererClass] of BACKENDS) { + let drained = 0; + const probe = Object.create(RendererClass.prototype); + Object.assign(probe, { + flushTransparentPass: () => { + return drained++; + }, + postEffects: undefined, + }); + const renderable = { + // two effects force the offscreen path; one non-managed effect + // takes the customShader fast path and never binds a target + postEffects: [{ enabled: true }, { enabled: true }], + _postEffectManaged: false, + }; + try { + probe.endPostEffect(renderable); + } catch { + // past the drain and into the device path — not under test + } + expect(drained, `${name} drained its pass`).toBe(1); + } + }); + + it("neither backend drains a pass that never bound a target", () => { + // the single-effect fast path composites through `customShader` with + // no offscreen target, so there is no separate queue to put down + for (const [name, RendererClass] of BACKENDS) { + let drained = 0; + const probe = Object.create(RendererClass.prototype); + Object.assign(probe, { + flushTransparentPass: () => { + return drained++; + }, + }); + try { + probe.endPostEffect({ + postEffects: [{ enabled: true }], + _postEffectManaged: false, + }); + } catch { + // not under test + } + expect(drained, `${name} left the fast path alone`).toBe(0); + } + }); +}); From 24b7c9ded883c454233744c39addb94c004e6254 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 2 Sep 2026 21:05:30 +0800 Subject: [PATCH 3/4] Mesh: sort the transparent pass by true view distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass ordered its queue by squared distance from an eye recovered as -Rᵀ·t. That identity only holds when the upper 3×3 is orthonormal, and the matrix it reads is not the camera's view alone: `Container.draw` folds every ancestor transform into `currentTransform`, so a single scaled container makes the extraction wrong and silently mis-orders the pass — a transparent object composited over something actually in front of it. Measured, with a view scaled (3, 1, 1) and translated (0, 0, -1000): an object at (300, 0, 0) is 1_810_000 away and one at (0, 0, -200) is 1_440_000, so the first is farther. The extraction ranks them 1_090_000 and 1_440_000 — the opposite order. Pushing the position through the view instead needs no assumption about the matrix, and agrees exactly with the old form whenever the view really is rigid, since a rotation preserves length. Raised independently by two reviewers on #1635. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/src/video/renderer.js | 25 ++++++++---- .../melonjs/tests/transparent_queue.spec.js | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 0a0e1db57..1e7b5a1e6 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -434,18 +434,27 @@ export default class Renderer { entry.tint = tint; entry.blend = blend ?? "normal"; entry.instanced = instanced ?? null; - // Squared radial distance from the eye, taken once here rather than per + // Squared distance from the camera, taken once here rather than per // comparison. Radial rather than view-space z for the same reason the // fog distance is: no sign-convention trap, and stable as the camera - // turns. The view is rigid, so its inverse translation is -Rᵀ·t. + // turns. + // + // Measured by pushing the position THROUGH the view, not by extracting + // the eye from it. The eye is recoverable as -Rᵀ·t only when the upper + // 3×3 is orthonormal, and the accumulated transform here is not just + // the camera's: `Container.draw` folds every ancestor into it, so one + // scaled container makes that extraction wrong and silently mis-orders + // the pass. The length of the view-space position needs no such + // assumption, and agrees exactly with the old form whenever the view + // really is rigid — a rotation preserves length. const v = this.currentTransform.val; const m = modelMatrix.val; - const ex = -(v[0] * v[12] + v[1] * v[13] + v[2] * v[14]); - const ey = -(v[4] * v[12] + v[5] * v[13] + v[6] * v[14]); - const ez = -(v[8] * v[12] + v[9] * v[13] + v[10] * v[14]); - const dx = m[12] - ex; - const dy = m[13] - ey; - const dz = m[14] - ez; + const mx = m[12]; + const my = m[13]; + const mz = m[14]; + const dx = v[0] * mx + v[4] * my + v[8] * mz + v[12]; + const dy = v[1] * mx + v[5] * my + v[9] * mz + v[13]; + const dz = v[2] * mx + v[6] * my + v[10] * mz + v[14]; entry.key = dx * dx + dy * dy + dz * dz; queue.count = at + 1; } diff --git a/packages/melonjs/tests/transparent_queue.spec.js b/packages/melonjs/tests/transparent_queue.spec.js index 40b07295c..6984f2f78 100644 --- a/packages/melonjs/tests/transparent_queue.spec.js +++ b/packages/melonjs/tests/transparent_queue.spec.js @@ -231,6 +231,44 @@ describe("the transparent pass (#1516)", () => { }); }); + describe("the sort key survives a scaled ancestor", () => { + it("orders by true view distance, not by an extracted eye", (ctx) => { + requireWebGL(ctx); + // `Container.draw` folds every ancestor transform into + // `currentTransform`, so what the queue sees is not just the + // camera's view — one scaled container makes the upper 3x3 + // non-orthonormal. Recovering the eye as -Rᵀ·t is invalid there, + // and the two formulas disagree about which object is farther. + // + // View: scale (3, 1, 1) then translate (0, 0, -1000). + // A at (300, 0, 0) -> view ( 900, 0, -1000), d² = 1_810_000 + // B at ( 0, 0, -200) -> view ( 0, 0, -1200), d² = 1_440_000 + // so A is farther. The eye extraction gives eye = (0, 0, 1000): + // A -> (300, 0, -1000), d² = 1_090_000 + // B -> ( 0, 0, -1200), d² = 1_440_000 + // which ranks B farther — the opposite order, and B would then be + // composited on top of something actually in front of it. + setup(); + const v = renderer.currentTransform; + v.identity(); + v.val[0] = 3; // non-uniform scale, as a scaled container leaves + v.val[14] = -1000; + + const near = quad(); + near.transparent = true; + draw(near, -200); + const far = quad(); + far.transparent = true; + far.pos.set(300, 0, 0); + draw(far, 0); + + const [b, a] = renderer._transparentPool; + expect(Math.round(b.key)).toBe(1_440_000); + expect(Math.round(a.key)).toBe(1_810_000); + expect(a.key).toBeGreaterThan(b.key); // fails under -Rᵀ·t + }); + }); + describe("ordering", () => { /** near red over far blue, both half alpha, over white */ const composite = (nearFirst) => { From a7c2e1b6408ca609ae9872b2c837df56a9dae687 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 3 Sep 2026 08:15:49 +0800 Subject: [PATCH 4/4] Mesh: keep the custom shader on a replayed draw, and close the coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A replayed transparent draw lost the mesh's custom shader. Both backends read `customShader` live at draw time, and by the time the pass replays, the renderable's `postDraw` has restored it to whatever was current before — so a mesh hosting a custom shader silently fell back to the built-in shading the moment it faded, and at full opacity too under `transparent: true`. The entry already carried the view, the tint and the blend mode for exactly this reason; the shader is the one piece of per-draw state it missed. `resetFrameState` (was `resetScreenSpace`) now also purges queues a previous frame left behind. Only `reset()` did that, and it runs on a stage change, not per frame — so a frame abandoned between `beginPostEffect` and `endPostEffect` stranded entries under a target key the pool then hands to a LATER pass, which would replay a dead frame's geometry into its own buffer. Mutation testing found sixteen behaviours that could break silently. Now pinned: `settings.transparent` read by the `Mesh` constructor and forwarded by `Sprite3d` (every existing test assigned the property afterwards, so the documented constructor path was never exercised); the WebGPU instanced ground-shadow re-queue guard, whose inversion cost seven failures on WebGL and none here; both WGSL cutout fixes, invisible without an adapter; all five drain sites, each individually removable before; the destroy path through `deleteMeshGeometry` rather than the internal method it calls; the instanced blend-bracket teardown; sort stability on tied keys; and the deprecated `queueGroundShadow` alias, which had no caller left to exercise it. Internal members are tagged `@internal` as well as `@ignore`. The first hides them from the docs, the second is what strips them from the emitted `.d.ts` — without it, the pass lifecycle showed up in consumers' autocomplete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/src/application/application.ts | 2 +- packages/melonjs/src/video/renderer.js | 54 +++- .../melonjs/src/video/webgl/webgl_renderer.js | 1 + packages/melonjs/tests/application.spec.js | 52 ++++ .../melonjs/tests/transparent_queue.spec.js | 281 +++++++++++++++++- .../tests/webgpu_transparent_pass.spec.js | 69 +++++ 6 files changed, 452 insertions(+), 7 deletions(-) diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 337eb1c04..d38182268 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -1124,7 +1124,7 @@ export default class Application { // transparent queue then silently skips — for the rest of the // session, since `reset()` only runs on a stage change. A frame is // the natural boundary, and by here the previous one is over. - this.renderer.resetScreenSpace?.(); + this.renderer.resetFrameState?.(); // Distance fog belongs to the camera that installed it, and is // installed once per camera in `Camera2d.draw`. Clearing it here diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 1e7b5a1e6..923b95d78 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -348,13 +348,16 @@ export default class Renderer { * camera's screen projection is installed and world-space geometry cannot * be replayed. Balanced by {@link Renderer#endScreenSpace}. * @ignore + * @internal */ beginScreenSpace() { + /** @ignore @internal */ this._screenSpaceDepth = (this._screenSpaceDepth ?? 0) + 1; } /** - * Force the screen-space bracket back to zero at a frame boundary. + * Put per-frame renderer state back to a known-good baseline. + * * * `Container.draw` opens the bracket around a floating child without a * `finally`, so a child that throws never closes it — and while it stays @@ -363,14 +366,33 @@ export default class Renderer { * a frame at a time. Called once per frame, where the count is always zero * in normal operation. * @ignore + * @internal */ - resetScreenSpace() { + resetFrameState() { this._screenSpaceDepth = 0; + // Purge anything a previous frame left queued. Only `reset()` did this, + // and that runs on a stage change, not per frame — so a frame abandoned + // between `beginPostEffect` and `endPostEffect` left entries keyed to a + // render target whose key the pool then hands to a LATER pass, which + // would replay a dead frame's geometry into its own buffer. Empty in + // normal operation: every queue is drained by the frame that filled it. + for (const queue of this._transparentQueues ?? []) { + if (queue === undefined || queue.count === 0) { + continue; + } + for (let i = 0; i < queue.count; i++) { + queue.pool[i].mesh = null; + queue.pool[i].instanced = null; + queue.pool[i].shader = null; + } + queue.count = 0; + } } /** * Mark the end of a screen-space draw. * @ignore + * @internal */ endScreenSpace() { const depth = (this._screenSpaceDepth ?? 0) - 1; @@ -401,6 +423,7 @@ export default class Renderer { * @param {object} [instanced] - the `InstancedMesh` whose instance buffer * supplies one draw per instance, for the instanced tier * @ignore + * @internal */ queueTransparent(mesh, modelMatrix, tint, blend, instanced) { const queue = this.transparentQueue(); @@ -418,6 +441,7 @@ export default class Renderer { blend: "normal", key: 0, instanced: null, + shader: null, }; pool[at] = entry; } @@ -434,6 +458,13 @@ export default class Renderer { entry.tint = tint; entry.blend = blend ?? "normal"; entry.instanced = instanced ?? null; + // The custom shader hosted on this draw, for the same reason the blend + // mode is held here: both backends read it LIVE off the renderer, and + // by the time the replay runs the renderable's `postDraw` has restored + // it to whatever was current before. A mesh with a custom shader was + // silently losing it the moment it faded — drawn with the built-in + // shading instead, at full opacity too under `transparent: true`. + entry.shader = this.customShader ?? null; // Squared distance from the camera, taken once here rather than per // comparison. Radial rather than view-space z for the same reason the // fog distance is: no sign-convention trap, and stable as the camera @@ -470,6 +501,7 @@ export default class Renderer { * constant. * @returns {number} the current target's key * @ignore + * @internal */ transparentTarget() { return this._renderTargetPool?.activeBase ?? -1; @@ -490,8 +522,10 @@ export default class Renderer { * @param {number} [key] - the target, defaulting to the current one * @returns {{pool: object[], count: number}} that target's queue * @ignore + * @internal */ transparentQueue(key = this.transparentTarget()) { + /** @ignore @internal */ const queues = (this._transparentQueues ??= []); // `-1` is the screen, so shift into a dense array rather than a Map: // the key is a small integer and this runs per queued draw @@ -508,6 +542,7 @@ export default class Renderer { * The pooled entries of the current target's queue. * @type {object[]} * @ignore + * @internal */ get _transparentPool() { return this.transparentQueue().pool; @@ -517,6 +552,7 @@ export default class Renderer { * How many entries the current target has queued. * @type {number} * @ignore + * @internal */ get _transparentCount() { return this.transparentQueue().count; @@ -533,6 +569,7 @@ export default class Renderer { * @param {object} [instanced] - the instanced tier's source mesh * @deprecated since 20.4.0, use {@link Renderer#queueTransparent} * @ignore + * @internal */ queueGroundShadow(quad, modelMatrix, tint, instanced) { this.queueTransparent(quad, modelMatrix, tint, "normal", instanced); @@ -587,6 +624,7 @@ export default class Renderer { * target is about to be unbound and its key reused by a later pass, which * would then replay them somewhere they never belonged. * @ignore + * @internal */ flushTransparentPass() { const queue = this.transparentQueue(); @@ -626,6 +664,7 @@ export default class Renderer { // drained again. `_transparentFlushing` guards the same door from the // other side, and covers the re-entry into `drawMesh`. queue.count = 0; + /** @ignore @internal */ this._transparentFlushing = true; try { // The colour the entry was queued WITH has to be put back, because @@ -639,6 +678,7 @@ export default class Renderer { // the colour's OWN alpha, which `setColor(r, g, b)` resets to 1 const savedTintAlpha = tint.alpha; const savedAlpha = this.getGlobalAlpha(); + const savedShader = this.customShader; // `currentTransform` is one object for the frame — `save()`/ // `restore()` push copies onto a stack rather than swapping it — // so each entry's view can be installed in place and the drain @@ -657,7 +697,9 @@ export default class Renderer { // read by the batchers instead of a per-mesh flag: the same // mesh can be queued twice under different modes, and the // entry is the only thing that knows which is which + /** @ignore @internal */ this._replayBlend = entry.blend; + this.customShader = entry.shader ?? undefined; this.currentTransform.copy(entry.view); if (entry.instanced !== null) { this.drawInstancedShadow(entry.instanced, entry.matrix, entry.mesh); @@ -668,9 +710,11 @@ export default class Renderer { // a pooled slot until that slot is next reused entry.mesh = null; entry.instanced = null; + entry.shader = null; } } finally { this._replayBlend = null; + this.customShader = savedShader; this.currentTransform.copy(_savedView); tint.setColor(savedR, savedG, savedB, savedTintAlpha); this.setGlobalAlpha(savedAlpha); @@ -694,6 +738,7 @@ export default class Renderer { * caller has finished with. * @param {object} mesh - the renderable being torn down * @ignore + * @internal */ removeQueuedTransparent(mesh) { // every target, not just the current one: a mesh destroyed during a @@ -710,6 +755,7 @@ export default class Renderer { * @param {{pool: object[], count: number}} queue - the queue to compact * @param {object} mesh - the renderable being torn down * @ignore + * @internal */ _removeQueuedFrom(queue, mesh) { const count = queue.count; @@ -720,6 +766,7 @@ export default class Renderer { if (entry.mesh === mesh || entry.instanced === mesh) { entry.mesh = null; entry.instanced = null; + entry.shader = null; continue; } if (write !== read) { @@ -775,10 +822,11 @@ export default class Renderer { const entry = queue.pool[i]; entry.mesh = null; entry.instanced = null; + entry.shader = null; } queue.count = 0; } - // belt and braces with `resetScreenSpace`, which is what actually + // belt and braces with `resetFrameState`, which is what actually // bounds an unbalanced bracket — `reset()` runs on a stage change and // on context restore, NOT once per frame this._screenSpaceDepth = 0; diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index a0ef0b59e..5a7ba5c0c 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -2112,6 +2112,7 @@ export default class WebGLRenderer extends Renderer { * deliberate cache bypass `MeshBatcher.beginBlendedDraw` documents. * @param {string} mode - a blend mode token * @ignore + * @internal */ applyBlendFunction(mode) { const gl = this.gl; diff --git a/packages/melonjs/tests/application.spec.js b/packages/melonjs/tests/application.spec.js index 7273c1bf1..ad5c32d93 100644 --- a/packages/melonjs/tests/application.spec.js +++ b/packages/melonjs/tests/application.spec.js @@ -132,6 +132,58 @@ describe("Application", () => { }); }); + describe("the frame closes the transparent pass", () => { + it("drains at end of frame, before the renderer flush", async () => { + // A scene that is nothing but meshes never switches batcher, so + // this is the only drain that runs — and nothing pinned it: every + // pixel test drains by hand, so deleting the call site was silent. + boot(); + const app = new Application(64, 64, { + parent: "screen", + renderer: video.CANVAS, + consoleHeader: false, + }); + await app.init(); + // The camera drains too, so counting drains proves nothing — what + // this pins is that one happens after the STAGE is fully drawn and + // before the renderer flush. + const order = []; + const stage = state.current(); + const stageDraw = stage.draw.bind(stage); + const drain = app.renderer.flushTransparent.bind(app.renderer); + const flush = app.renderer.flush.bind(app.renderer); + stage.draw = (...a) => { + const r = stageDraw(...a); + order.push("stage-done"); + return r; + }; + app.renderer.flushTransparent = (...a) => { + order.push("drain"); + return drain(...a); + }; + app.renderer.flush = (...a) => { + order.push("flush"); + return flush(...a); + }; + try { + app.isDirty = true; + app.draw(); + } finally { + stage.draw = stageDraw; + app.renderer.flushTransparent = drain; + app.renderer.flush = flush; + app.destroy(); + } + const done = order.indexOf("stage-done"); + const last = order.lastIndexOf("drain"); + expect(done).toBeGreaterThanOrEqual(0); + expect(order).toContain("flush"); + // a drain AFTER the whole stage is down, and before the flush + expect(last).toBeGreaterThan(done); + expect(last).toBeLessThan(order.indexOf("flush")); + }); + }); + describe("physics startup banner", () => { it("reports the built-in adapter via its stable physicLabel, not a class name", async () => { boot(); diff --git a/packages/melonjs/tests/transparent_queue.spec.js b/packages/melonjs/tests/transparent_queue.spec.js index 6984f2f78..bfd85e1d2 100644 --- a/packages/melonjs/tests/transparent_queue.spec.js +++ b/packages/melonjs/tests/transparent_queue.spec.js @@ -290,6 +290,29 @@ describe("the transparent pass (#1516)", () => { return readPixel(); }; + it("keeps submission order for entries at the same distance", (ctx) => { + requireWebGL(ctx); + // Coplanar decals and a mesh queued twice land on identical keys, + // and blending is order-dependent — so the binary insertion has to + // be stable, which the strict `<` gives it. With `<=` the pair + // silently swaps and the wrong one wins the pixel. + setup([255, 255, 255]); + const first = quad(); + first.transparent = true; + first.tint.setColor(255, 0, 0); + const second = quad(); + second.transparent = true; + second.tint.setColor(0, 0, 255); + draw(first, 0); + draw(second, 0); // same depth => same key + expect(renderer._transparentPool[0].key).toBe( + renderer._transparentPool[1].key, + ); + renderer.flushTransparent(); + const px = readPixel(); + expect(px[2]).toBeGreaterThan(px[0]); // the LATER submission on top + }); + it("composites identically whichever order the two were submitted", (ctx) => { requireWebGL(ctx); // the sort is the whole point: submission order must not matter @@ -406,7 +429,10 @@ describe("the transparent pass (#1516)", () => { mesh.setOpacity(0.5); draw(mesh); expect(renderer._transparentCount).toBe(1); - renderer.removeQueuedTransparent(mesh); + // through the production call site, not the internal method: a + // destroyed mesh reaches the queue via `deleteMeshGeometry`, and + // severing that link left both of these tests passing + renderer.deleteMeshGeometry(mesh); expect(renderer._transparentCount).toBe(0); const spy = vi.spyOn(renderer, "drawMesh"); renderer.flushTransparent(); @@ -555,6 +581,23 @@ describe("the transparent pass (#1516)", () => { mesh.destroy(); }); + it("leaves mesh-mode state clean after an INSTANCED replay", (ctx) => { + requireWebGL(ctx); + // The retained path's teardown is pinned; the instanced one was + // not, and it is the same hazard: `depthMask(false)` left on makes + // the next frame's one-shot depth clear a no-op, so the whole frame + // renders against stale depth. + const gl = renderer.gl; + setup(); + const mesh = instancedQuad(); + mesh.setOpacity(0.5); + draw(mesh); + renderer.flushTransparent(); + expect(gl.getParameter(gl.DEPTH_WRITEMASK)).toBe(true); + expect(gl.isEnabled(gl.BLEND)).toBe(false); + mesh.destroy(); + }); + it('replays a `blendMode` of "none" without throwing', (ctx) => { requireWebGL(ctx); // `blendMode` is a plain property and `"none"` is a supported @@ -617,6 +660,79 @@ describe("the transparent pass (#1516)", () => { }); }); + describe("a replayed draw keeps the shader it was queued with", () => { + it("carries the mesh's custom shader into the replay", (ctx) => { + requireWebGL(ctx); + // Both backends read `customShader` LIVE at draw time, and by the + // time the pass replays, the renderable's `postDraw` has restored + // it to whatever was current before. A mesh hosting a custom + // shader therefore lost it the instant it faded, silently falling + // back to the built-in shading — and at FULL opacity too under + // `transparent: true`, which is not a pre-existing defect but a + // regression the pass would have introduced. + setup(); + const fx = new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return color; + } + `, + ); + try { + const mesh = quad(); + mesh.setOpacity(0.5); + // the production path: one effect on a non-managed renderable + // takes `beginPostEffect`'s fast path, which installs it as + // `customShader` — and `postDraw` -> `restore()` takes it away + // again, before the drain + mesh.addPostEffect(fx); + draw(mesh); + expect(renderer._transparentCount).toBe(1); + expect(renderer.customShader).toBeUndefined(); + + const seen = []; + const drawMesh = renderer.drawMesh.bind(renderer); + renderer.drawMesh = (...args) => { + seen.push(renderer.customShader); + return drawMesh(...args); + }; + try { + renderer.flushTransparent(); + } finally { + renderer.drawMesh = drawMesh; + } + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(fx); + // and it must not leak out of the pass + expect(renderer.customShader).toBeUndefined(); + } finally { + fx.destroy(); + } + }); + + it("replays an ordinary mesh with no shader at all", (ctx) => { + requireWebGL(ctx); + // the restore has to put back what was there, including nothing + setup(); + const mesh = quad(); + mesh.setOpacity(0.5); + draw(mesh); + const seen = []; + const drawMesh = renderer.drawMesh.bind(renderer); + renderer.drawMesh = (...args) => { + seen.push(renderer.customShader); + return drawMesh(...args); + }; + try { + renderer.flushTransparent(); + } finally { + renderer.drawMesh = drawMesh; + } + expect(seen[0]).toBeUndefined(); + }); + }); + describe("the blend state actually reaches the GPU", () => { it("an additive entry really adds", (ctx) => { requireWebGL(ctx); @@ -652,6 +768,19 @@ describe("the transparent pass (#1516)", () => { }); }); + describe("the deprecated aliases still work", () => { + it("`queueGroundShadow` routes into the transparent queue", (ctx) => { + requireWebGL(ctx); + // no caller left in src, so nothing exercised it — but it ships as + // a documented deprecation and has to keep working + setup(); + renderer.queueGroundShadow(quad(), new Matrix3d(), 0x80ffffff); + expect(renderer._transparentCount).toBe(1); + renderer.flushGroundShadows(); + expect(renderer._transparentCount).toBe(0); + }); + }); + describe("the queue holds nothing alive", () => { it("`reset()` releases the queued references", (ctx) => { requireWebGL(ctx); @@ -742,6 +871,58 @@ describe("the transparent pass (#1516)", () => { expect(px[0]).toBeLessThan(155); }); + it("puts world transparency DOWN before opening the overlay bracket", (ctx) => { + requireWebGL(ctx); + // `Container.draw` drains just before the screen-space bracket + // opens, and that ordering is the whole point of the comment + // there: over the world, under the overlay. The contract is that + // the queue is EMPTY by the time the bracket opens — asserted at + // that instant, because asserting on the final pixel is defeated + // by the child walk order. + const viewport = camera3d(); + const flat = new Matrix3d(); + flat.ortho(0, SIZE, SIZE, 0, -1e6, 1e6); + viewport.projectionMatrix.copy(flat); + viewport.worldProjection.copy(flat); + renderer.setProjection(flat); + renderer.currentTransform.identity(); + renderer.clear(); + + const world = new Container(0, 0, SIZE, SIZE); + world.autoSort = false; // keep the draw order predictable + // children are walked BACKWARDS, so the overlay goes in first to + // be drawn last + const overlay = hudQuad(); + world.addChild(overlay); + const faded = quad(); + faded.pos.set(SIZE / 2, SIZE / 2, 0); + faded.setOpacity(0.5); + // a NON-floating child is gated on `inViewport`, which nothing sets + // in this bare harness — without it the child is skipped and the + // test proves nothing + faded.inViewport = true; + world.addChild(faded); + + const atBracket = []; + const begin = renderer.beginScreenSpace.bind(renderer); + renderer.beginScreenSpace = (...a) => { + atBracket.push(renderer._transparentCount); + return begin(...a); + }; + try { + world.preDraw(renderer); + world.draw(renderer, viewport); + world.postDraw(renderer); + } finally { + renderer.beginScreenSpace = begin; + } + renderer.flushTransparent(); + + expect(atBracket).toHaveLength(1); + // the world's transparency is already down + expect(atBracket[0]).toBe(0); + }); + it("drains before restoring the world projection, not after", (ctx) => { requireWebGL(ctx); // order is the whole fix: after the restore the entry replays @@ -802,10 +983,59 @@ describe("the transparent pass (#1516)", () => { renderer.flushTransparent(); expect(renderer._transparentCount).toBe(1); // blocked, as designed - renderer.resetScreenSpace(); // what `Application.draw` does per frame + renderer.resetFrameState(); // what `Application.draw` does per frame renderer.flushTransparent(); expect(renderer._transparentCount).toBe(0); }); + + it("purges a queue an abandoned frame left behind", (ctx) => { + requireWebGL(ctx); + // A frame that dies between `beginPostEffect` and `endPostEffect` + // never drains that pass's queue, and the pool hands its key to a + // LATER pass — which would then replay a dead frame's geometry into + // its own target. Only `reset()` cleared this, and that runs on a + // stage change, not per frame. + setup(); + renderer._renderTargetPool.begin(false, 2, SIZE, SIZE); + const stranded = renderer.transparentTarget(); + const mesh = quad(); + mesh.transparent = true; + draw(mesh); + expect(renderer.transparentQueue(stranded).count).toBe(1); + renderer._renderTargetPool.end(); // pass unwinds without draining + + renderer.resetFrameState(); + expect(renderer.transparentQueue(stranded).count).toBe(0); + expect(renderer.transparentQueue(stranded).pool[0].mesh).toBe(null); + }); + }); + + describe("the camera closes the pass before its own effects", () => { + it("drains before drawFX, so a flash covers the transparency too", (ctx) => { + requireWebGL(ctx); + // The camera's flash/fade is painted over the finished world. Drain + // after it and transparent geometry lands ON TOP of the flash — + // the one thing it must never do. Nothing pinned the ordering. + const camera = new Camera3d(0, 0, SIZE, SIZE); + const order = []; + const drain = renderer.flushTransparent.bind(renderer); + renderer.flushTransparent = (...a) => { + order.push("drain"); + return drain(...a); + }; + const fx = camera.drawFX.bind(camera); + camera.drawFX = (...a) => { + order.push("drawFX"); + return fx(...a); + }; + try { + camera.draw(renderer, new Container(0, 0, SIZE, SIZE)); + } finally { + renderer.flushTransparent = drain; + camera.drawFX = fx; + } + expect(order).toEqual(["drain", "drawFX"]); + }); }); // ────────────────────────────────────────────────────────────────────── @@ -911,7 +1141,7 @@ describe("the transparent pass (#1516)", () => { const pass = effectPass(); renderer.beginPostEffect(pass); // destroyed from inside a pass, while queued OUTSIDE it - renderer.removeQueuedTransparent(doomed); + renderer.deleteMeshGeometry(doomed); renderer.endPostEffect(pass); renderer.flush(); expect(renderer._transparentCount).toBe(0); @@ -936,6 +1166,39 @@ describe("the transparent pass (#1516)", () => { }); }); +describe("Mesh reads `transparent` from its settings", () => { + // Every routing test assigns the property after construction, so the + // documented constructor path — the one the JSDoc example itself uses — + // was never exercised: a glTF BLEND glow built with `transparent: true` + // could silently draw opaque. + beforeAll(async () => { + await boot(); + }); + + const build = (settings) => { + return new Mesh(0, 0, { + vertices: [0, 0, 0, 1, 0, 0, 1, 1, 0], + uvs: [0, 0, 1, 0, 1, 1], + indices: [0, 1, 2], + width: 1, + height: 1, + ...settings, + }); + }; + + it("keeps `true`", () => { + expect(build({ transparent: true }).transparent).toBe(true); + }); + + it("keeps `false`", () => { + expect(build({ transparent: false }).transparent).toBe(false); + }); + + it("leaves it unset when omitted, for the automatic check", () => { + expect(build({}).transparent).toBeUndefined(); + }); +}); + /** * `Sprite3d` picks the cutout threshold that decides whether a fading sprite * survives, so the two interact and the choice is pinned here. @@ -959,6 +1222,18 @@ describe("Sprite3d and the alpha cutout", () => { expect(sprite.alphaCutoff).toBe(0.5); }); + it("forwards the transparent flag, not just the cutoff", () => { + // the cutoff drop was pinned, the flag it depends on was not — so the + // sprite could get the 1/255 cutoff for being transparent and then + // draw opaque anyway, which is the exact artefact the flag prevents + const sprite = new Sprite3d(0, 0, { + width: 16, + height: 16, + transparent: true, + }); + expect(sprite.transparent).toBe(true); + }); + it("an explicit cutoff always wins", () => { const sprite = new Sprite3d(0, 0, { width: 16, diff --git a/packages/melonjs/tests/webgpu_transparent_pass.spec.js b/packages/melonjs/tests/webgpu_transparent_pass.spec.js index 54e3f9f91..3ea7aaf25 100644 --- a/packages/melonjs/tests/webgpu_transparent_pass.spec.js +++ b/packages/melonjs/tests/webgpu_transparent_pass.spec.js @@ -5,6 +5,8 @@ import { instanceRecordLayout } from "../src/video/gpu/instancerecord.ts"; import Renderer from "../src/video/renderer.js"; import WebGLRenderer from "../src/video/webgl/webgl_renderer.js"; import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import meshWGSL from "../src/video/webgpu/shaders/mesh.wgsl"; +import meshLitWGSL from "../src/video/webgpu/shaders/mesh-lit.wgsl"; import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; @@ -282,6 +284,73 @@ describe("the replay installs its blend state (mock device)", () => { } }); +describe("the instanced ground-shadow decal defers exactly once", () => { + // The guard is what stops the replay re-queueing what it is replaying. On + // WebGL an inversion fails 7 tests; on WebGPU it failed none — the mock + // suite never runs the replay -> drawInstancedShadow path, so the same + // mutation left every instanced shadow queued forever and never drawn. + for (const [name, RendererClass] of BACKENDS) { + it(`${name} queues outside a drain and draws inside one`, () => { + const queued = []; + const drawn = []; + const probe = Object.create(RendererClass.prototype); + Object.assign(probe, { + currentTint: new Color(255, 255, 255, 1), + getGlobalAlpha: () => { + return 1; + }, + queueTransparent: (...args) => { + return queued.push(args); + }, + setBatcher: () => {}, + currentBatcher: { + drawInstancedShadow: (...a) => { + return drawn.push(a); + }, + }, + _transparentFlushing: false, + }); + const mesh = { instanceLayout: {} }; + const quad = { lit: false }; + + probe.drawInstancedShadow(mesh, new Matrix3d(), quad); + expect(queued, `${name} defers outside a drain`).toHaveLength(1); + expect(drawn, `${name} does not draw it yet`).toHaveLength(0); + // the instanced mesh rides along so the replay can find its buffer + expect(queued[0][4]).toBe(mesh); + + probe._transparentFlushing = true; + probe.drawInstancedShadow(mesh, new Matrix3d(), quad); + expect(queued, `${name} does not re-queue during a drain`).toHaveLength( + 1, + ); + expect(drawn, `${name} draws it during a drain`).toHaveLength(1); + }); + } +}); + +describe("the WGSL shaders keep the cutout before the tint", () => { + // Both WGSL twins were completely unpinned: reverting the discard to + // post-tint alpha — the fade-pop bug — survived the whole suite, because + // there is no adapter in CI and nothing else reads the source. Order in + // the text is a crude pin, but it catches exactly that revert. + for (const [name, source] of [ + ["mesh.wgsl", meshWGSL], + ["mesh-lit.wgsl", meshLitWGSL], + ]) { + it(`${name} discards on material alpha, before the tint multiply`, () => { + const fragment = source.slice(source.indexOf("@fragment")); + const discard = fragment.indexOf("uMesh.params.x"); + const tint = fragment.indexOf("in.vColor"); + expect(discard, `${name}: no cutout found`).toBeGreaterThan(-1); + expect(tint, `${name}: no tint multiply found`).toBeGreaterThan(-1); + // the tint must be applied AFTER the discard, or a fading cutout + // mesh vanishes at its own threshold + expect(discard).toBeLessThan(tint); + }); + } +}); + describe("the queue itself is backend-neutral", () => { it("lives on the base renderer, not on either backend", () => { // the sort, the pool, the per-target keying and the drain guards are