diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f0880e4f3..0a8123ffb 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -6,6 +6,7 @@ - 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)) ### Fixed +- 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 ## [20.3.0] (melonJS 2) - _2026-08-31_ diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index b0f0cf735..854d026ff 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -166,6 +166,11 @@ On a lit mesh the colour multiplies the **lit** result, so it behaves as albedo rather than as an emissive override — a vertex colour will not make an unlit face bright. +`alpha` hides a mesh as it hides anything else: at 0 the draw is skipped. There +is no *partial* mesh transparency though — the mesh path renders opaque, so a +mesh at `alpha = 0.5` draws fully opaque rather than half see-through. Fade a +mesh out and it will stay solid until it vanishes. + ## Sprite3d and billboards `Sprite3d` is the 2.5D workhorse: a flat sprite living at a real depth, with @@ -257,6 +262,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | symptom | cause | |---|---| | a gradient across one mesh is impossible | `tint` is per object — use `vertexColors` / `setVertexColor` | +| a mesh stays solid as you fade it out | meshes render opaque; only `alpha` 0 (hidden) and 1 differ | | vertex colour applies under a 2D camera but not `Camera3d` | wrote the array directly without setting `needsUpdate` | | `Mesh: vertexColors has N entries, expected M` | one colour per *vertex*, not per triangle or per index | | nothing renders, or a backdrop covers everything | wrong depth sign — "far" is *larger* z when looking along +Z | diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index ec52ab1bd..599b62cb3 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -2133,6 +2133,17 @@ export default class WebGLRenderer extends Renderer { } drawMesh(mesh, modelMatrix) { + // A fully transparent mesh must not draw — and here that is not merely + // a saving. The mesh path disables `GL_BLEND` (see `MeshBatcher.bind`), + // so the alpha never reaches the blend stage: the shader multiplies the + // colour by zero and the result is written OPAQUE BLACK. Setting + // `alpha = 0` to hide a mesh painted a black silhouette of it. + // `CanvasRenderer.drawMesh` has always skipped at this threshold, as do + // eight other draw methods there; this is the same guard. + if (this.getGlobalAlpha() < 1 / 255) { + return; + } + const gl = this.gl; const retained = modelMatrix !== undefined; diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index dfddd2431..bfc305e5b 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1839,6 +1839,15 @@ export default class WebGPURenderer extends Renderer { } drawMesh(mesh, modelMatrix) { + // A fully transparent mesh must not draw. The mesh pipeline renders + // opaque, so the alpha never reaches a blend stage: the colour is + // multiplied by zero and written as opaque black, and `alpha = 0` + // paints a black silhouette instead of hiding the mesh. + // `CanvasRenderer.drawMesh` has always skipped at this threshold. + if (this.getGlobalAlpha() < 1 / 255) { + return; + } + const retained = modelMatrix !== undefined; // A ground shadow waits for the end of the mesh pass rather than diff --git a/packages/melonjs/tests/mesh-alpha.spec.js b/packages/melonjs/tests/mesh-alpha.spec.js new file mode 100644 index 000000000..5b258256c --- /dev/null +++ b/packages/melonjs/tests/mesh-alpha.spec.js @@ -0,0 +1,125 @@ +/** + * A fully transparent mesh must not draw. + * + * `CanvasRenderer.drawMesh` has skipped at `getGlobalAlpha() < 1 / 255` since + * forever — it is the same guard nine other Canvas draw methods use. The GPU + * renderers had no such guard, and `MeshBatcher.bind()` disables `GL_BLEND`, so + * the alpha byte never reached the blend stage: the shader multiplied the + * colour by zero and wrote it OPAQUE BLACK. The same `alpha = 0` that hides a + * sprite painted a black silhouette of the mesh. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, Mesh } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +const SIZE = 64; + +describe("Mesh opacity", () => { + let renderer; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // genuinely unavailable — every test below skips + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } + }); + + afterAll(() => { + try { + releaseWebGLRenderer(); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + /** a quad covering the whole target, in raw pixel coordinates */ + const quad = () => { + const mesh = new Mesh(0, 0, { + vertices: new Float32Array([ + 0, + 0, + 0, + SIZE, + 0, + 0, + SIZE, + SIZE, + 0, + 0, + SIZE, + 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normalize: false, + scale: 1, + width: SIZE, + height: SIZE, + }); + // `vertices` is the PROJECTED output buffer, filled during `draw()`. + // This harness calls `drawMesh` directly, so seed it from the source — + // the ortho projection above already maps these to pixels. + mesh.vertices.set(mesh.originalVertices); + return mesh; + }; + + /** + * `drawMesh` selects a batcher as its first act, so a draw that was + * skipped never gets that far. Cheaper and far more deterministic than a + * pixel probe, which in a bare renderer harness measures nothing. + */ + const reachesTheBatcher = (alpha) => { + const spy = vi.spyOn(renderer, "setBatcher"); + renderer.save(); + renderer.setGlobalAlpha(alpha); + let reached; + try { + renderer.drawMesh(quad()); + } finally { + // read BEFORE restoring: mockRestore() clears the call history + reached = spy.mock.calls.length > 0; + renderer.restore(); + spy.mockRestore(); + } + return reached; + }; + + it("does not draw at alpha 0", (ctx) => { + requireWebGL(ctx); + // with GL_BLEND off on the mesh path, a mesh that reaches the batcher + // at alpha 0 is multiplied to black and written OPAQUE — a black + // silhouette where the caller asked for nothing + expect(reachesTheBatcher(0)).toBe(false); + }); + + it("skips below the 1/255 threshold the Canvas renderer already uses", (ctx) => { + requireWebGL(ctx); + expect(reachesTheBatcher(0.5 / 255)).toBe(false); + }); + + it("still draws at one full step of alpha", (ctx) => { + requireWebGL(ctx); + // the guard must not swallow draws that would be visible + expect(reachesTheBatcher(2 / 255)).toBe(true); + }); + + it("still draws at full alpha", (ctx) => { + requireWebGL(ctx); + expect(reachesTheBatcher(1)).toBe(true); + }); +});