Mesh: a transparent pass, so a faded mesh actually fades - #1635
Conversation
`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
There was a problem hiding this comment.
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.transparentandSprite3dalphaCutoff 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.
| 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
Follow-up commit — review fixes + per-target queuesA code review of the first commit found five defects, all fixed in The five:
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. API surfaceAdditive only, nothing removed or changed:
Two behaviour changes existing games will see: a faded mesh now fades instead of darkening, and Verification
|
There was a problem hiding this comment.
🟡 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
| 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's finding is correct and is now fixed in The pass ordered its queue by squared distance from an eye recovered as Measured, with a view scaled
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 One related spot deliberately left alone: |
There was a problem hiding this comment.
🟡 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
| 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], | ||
| ); | ||
| } |
Closes #1516.
The symptom, measured
setOpacity(0.5)did not make a mesh see-through. The mesh tier renders opaque —MeshBatcher.bind()disablesGL_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:
[127, 127, 127]— darkened toward black, background contributes nothing[127, 127, 255]— blendedThat 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.35for 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.
Blending honours the renderable's existing
blendMode— no new property.Ground shadows become the queue's first client rather than the feature itself.
queueGroundShadow/flushGroundShadowsremain as a delegate and an alias, soground_shadow.spec.jspasses 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
beginBlendedDrawdouble-multiplied alpha. It passedrenderer.premultipliedAlphato the blend function, but that flag describes source textures — the mesh shaders premultiply unconditionally. A non-premultiplied context selectedSRC_ALPHAand multiplied a second time. Invisible while decals were the only client, because their source colour is black and0 × anything = 0.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.Sprite3d'salphaCutoff: 0.5would have defeatedtransparent: trueoutright — discarding every soft texel before blending saw it, which is exactly the case the flag exists for. It now drops to1/255when transparency is explicit.Also removes two fossils that would mislead the next reader here: a comment in
setBatcherdescribing 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
|dw0axis from #1515 already covered this, sopipeline/cache.jsneeded no edit at all.Tests
tests/transparent_queue.spec.js— 19 assertions: the measured symptom as an absolute pixel, thetrue/falseescape 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