Skip to content

Mesh: a transparent pass, so a faded mesh actually fades - #1635

Open
obiot wants to merge 3 commits into
masterfrom
feat/transparent-render-queue
Open

Mesh: a transparent pass, so a faded mesh actually fades#1635
obiot wants to merge 3 commits into
masterfrom
feat/transparent-render-queue

Conversation

@obiot

@obiot obiot commented Sep 1, 2026

Copy link
Copy Markdown
Member

Closes #1516.

The symptom, measured

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 × a, a) straight into the target.

A white mesh at half opacity over a blue background:

centre pixel
before [127, 127, 127] — darkened toward black, background contributes nothing
after [127, 127, 255] — blended

That is the same defect as the far end of the range, which painted an opaque black silhouette until #1626 fixed it. The Canvas backend never had it: meshes there already fade under the 2D context, so the same scene faded on Canvas and darkened on GPU.

Nobody was relying on the darkening. The sledding example sets rabbit.alpha = 0.35 for an invulnerability flash and has been getting a dark rabbit rather than a translucent one — visibly fixed by this branch.

The change

Draws resolving to fractional alpha are queued and replayed after the opaque pass, back-to-front, blending, writing no depth but still depth-tested — so transparent objects composite with each other and stay correctly hidden behind opaque geometry.

ghost.setOpacity(0.4);                                   // just fades
glow.transparent = true; glow.blendMode = "additive";    // texture alpha + additive
marker.transparent = false;                              // pinned opaque

Blending honours the renderable's existing blendMode — no new property.

Ground shadows become the queue's first client rather than the feature itself. queueGroundShadow / flushGroundShadows remain as a delegate and an alias, so ground_shadow.spec.js passes 52/52 with nothing but internals renames — the regression gate for the whole refactor.

Three defects found on the way, none of them the feature

  1. beginBlendedDraw double-multiplied alpha. It passed renderer.premultipliedAlpha to the blend function, but that flag describes source textures — the mesh shaders premultiply unconditionally. A non-premultiplied context selected SRC_ALPHA and multiplied a second time. Invisible while decals were the only client, because their source colour is black and 0 × anything = 0.
  2. Renderer.reset() leaked. It zeroed the queue count without releasing entry references, keeping every queued renderable reachable until its pooled slot was reused. Harmless with two shared quads; not with arbitrary user meshes.
  3. Sprite3d's alphaCutoff: 0.5 would have defeated transparent: true outright — discarding every soft texel before blending saw it, which is exactly the case the flag exists for. 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 per draw, plus three early-returning flushes per frame. No shader change. WebGPU pipeline keys are unchanged — the blend token and |dw0 axis from #1515 already covered this, so pipeline/cache.js needed no edit at all.

Tests

tests/transparent_queue.spec.js — 19 assertions: the measured symptom as an absolute pixel, the true/false escape hatches, order-independence of submission, near-on-top, not-painted-over-by-a-later-opaque-mesh, still-occluded-by-nearer-geometry, both #1630 drain traps, double-queueing with distinct matrices, destroy-before-drain, pool reuse and reference release, no re-queue during replay, mesh-mode state restored, and no blend-mode leak into the 2D cache.

Mutation-checked, all five bite: no sort (2 fail), reversed sort (1), screen-space guard removed (2), references not released (1), and the double-premultiply above (2).

271 files / 6550 tests, lint and types clean. Verified on screen on WebGL and on WebGPU/Metal, with the unfogged/untransparent examples unchanged.

Out of scope, deliberately

Intersecting-transparency correctness (per-object sorting cannot order interpenetrating meshes — documented), the 2D-camera accumulated path, per-instance sorting inside an InstancedMesh, advanced destination-capture blend modes, and soft particles.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N

`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI lite review requested due to automatic review settings September 1, 2026 08:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a dedicated transparent pass for the 3D mesh tier so fractional-alpha meshes (e.g. setOpacity(0.5)) actually blend instead of darkening toward black, aligning GPU behavior with the Canvas backend and fixing the long-standing symptom described in #1516.

Changes:

  • Introduces a transparent queue in Renderer (queueTransparent / flushTransparent) with back-to-front replay, blending enabled, depth writes disabled, depth test preserved.
  • Routes eligible retained mesh draws into the transparent queue on both WebGL and WebGPU backends; ground shadows become a client of the same queue (legacy aliases preserved).
  • Adds/updates tests to cover the transparent pass behavior and adjusts docs/changelog (including Mesh.transparent and Sprite3d alphaCutoff behavior).

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/melonjs/src/video/renderer.js Adds transparent queue + flush logic; keeps ground-shadow APIs as aliases and adds removal/reset safety.
packages/melonjs/src/video/webgl/webgl_renderer.js Routes transparent retained draws into the queue; adds per-entry blend application helper.
packages/melonjs/src/video/webgl/batchers/mesh_batcher.js Uses per-entry replay blend mode (_replayBlend) to enter/exit blended state during replay.
packages/melonjs/src/video/webgpu/webgpu_renderer.js Routes transparent retained draws into the queue; removes queued entries on geometry deletion.
packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js Uses _replayBlend to select pipeline blend state + disable depth writes during replay.
packages/melonjs/src/renderable/mesh.js Documents and implements settings.transparent / mesh.transparent tri-state behavior.
packages/melonjs/src/renderable/sprite3d.js Lowers default alphaCutoff when transparent: true is explicitly requested; forwards transparent.
packages/melonjs/src/renderable/container.js Flushes the transparent pass before entering screen-space (floating) bracket.
packages/melonjs/src/camera/camera2d.ts Flushes the transparent pass after world draw, before FX.
packages/melonjs/src/application/application.ts Flushes the transparent pass at end-of-frame for all-mesh scenes.
packages/melonjs/tests/transparent_queue.spec.js New test suite asserting blending, ordering, drain contract, pool reuse, and state restoration.
packages/melonjs/tests/ground_shadow.spec.js Updates ground-shadow tests to use the transparent queue counters/pool names.
packages/melonjs/tests/webgl_mesh_fog.spec.js Pins fog test mesh to opaque pass to avoid transparent compositing changing the pixel probe.
packages/melonjs/tests/webgpu_mesh_retained.spec.js Updates stub to include removeQueuedTransparent due to deletion now purging the queue.
packages/melonjs/skills/melonjs-3d/SKILL.md Documents the new transparency behavior and options.
packages/melonjs/CHANGELOG.md Adds release note for 3D soft transparency and related behavior changes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +2110 to +2127
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],
);
}
… queue

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings September 2, 2026 12:35
@obiot

obiot commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Follow-up commit — review fixes + per-target queues

A code review of the first commit found five defects, all fixed in 4616b2391, plus two architectural changes that came out of it.

The five:

A transparent InstancedMesh was routed into the pass and then replayed opaque on both backends measured [127,127,127] — the set was deferred to end of frame and darkened anyway, strictly worse than never deferring it
blendMode: "none" threw mid-frame a supported token with no blend state by contract; the replay dereferenced it
Entries replayed under the drain site's live view a faded mesh inside a positioned container drew at the container's offset
The alpha cutout thresholded the drawn alpha Sprite3d defaults the cutoff to 0.5, so a fade-out hard-cut to nothing at 49% opacity
The WebGPU replay read the mutable premultipliedAlpha straight-alpha content earlier in the frame leaves it false, reapplying alpha a second time

One queue per render target. Keyed on the shared render-target pool's active base — one number that means the same thing on both backends. 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. Keyed this way the two cannot meet, and each pass drains its own on the way out.

Overlays drain their own entries. Container.draw now drains while the screen projection is still installed. Replayed after the bracket closed, entries went through the camera's perspective with 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. That was a regression introduced by the pass itself, so it is fixed rather than documented.

API surface

Additive only, nothing removed or changed:

  • new public: Mesh#transparent / settings.transparent (tri-state), forwarded by Sprite3d; Renderer#flushTransparent()
  • deprecated but working: flushGroundShadows(), queueGroundShadow() — both delegate to the new names
  • everything else (queueTransparent, flushTransparentPass, transparentQueue, transparentTarget, resetScreenSpace, …) is @ignore, so it never reaches the published .d.ts

Two behaviour changes existing games will see: a faded mesh now fades instead of darkening, and alphaCutoff thresholds the material's alpha so a fading cutout keeps its shape.

Verification

  • 6600 tests (up from 6550), lint 0 errors, types clean
  • every fix mutation-checked — reverted individually, confirmed the named test fails
  • all 50 examples run on both backends in real Chrome (WebGPU/Metal, then again with navigator.gpu removed so the AUTO ladder falls to WebGL2 on the same driver): 100/100 clean, no blank canvases. The only backend divergence is compressed-textures, which is correct — WebGL has PVRTC, WebGPU does not

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The transparent-pass sort key computation in Renderer.queueTransparent assumes a rigid view matrix, which can mis-order transparency when ancestor transforms include scaling.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +441 to +449
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;
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings September 2, 2026 13:05
@obiot

obiot commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Copilot's finding is correct and is now fixed in 24b7c9ded — it landed on the same thing an independent review raised, which moved it from "residual, file an issue" to "fix it now".

The pass ordered its queue by squared distance from an eye recovered as -Rᵀ·t. That identity holds only for an orthonormal upper 3×3, and the matrix being read is not the camera's view alone: Container.draw folds every ancestor transform into currentTransform, so one 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):

object true view distance² via -Rᵀ·t
at (300, 0, 0) 1 810 000 1 090 000
at (0, 0, -200) 1 440 000 1 440 000

The first is genuinely farther; the extraction ranks them the other way round.

The fix pushes the position through the view rather than extracting the eye from it. That needs no assumption about the matrix, and agrees exactly with the old form whenever the view really is rigid, since a rotation preserves length — which is why every existing ordering test still passes unchanged.

Pinned by a test built on those measured numbers: restoring the old formula fails it with expected 1090000 to be 1810000.

One related spot deliberately left alone: MeshBatcher.setPlacementUniforms derives uEyePosition the same way for the specular half-vector. It has the same assumption, but it predates this PR, only affects specular highlight direction rather than draw order, and correcting it needs a real matrix inverse. Worth its own issue rather than widening this one.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

WebGLRenderer.applyBlendFunction uses lazily initialized blend-enum lookup tables without initializing them, which can cause a runtime error if invoked before the first setBlendMode.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +2116 to +2144
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);
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(
GL_BLEND_FACTOR[state.srcFactor],
GL_BLEND_FACTOR[state.dstFactor],
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mesh/Sprite3d: blended (alpha) pass for soft-transparent 3D sprites

2 participants