Skip to content

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

Open
obiot wants to merge 1 commit into
masterfrom
feat/transparent-render-queue
Open

Mesh: a transparent pass, so a faded mesh actually fades#1635
obiot wants to merge 1 commit 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],
);
}
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