Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
6 changes: 6 additions & 0 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions packages/melonjs/src/video/webgl/webgl_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
9 changes: 9 additions & 0 deletions packages/melonjs/src/video/webgpu/webgpu_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions packages/melonjs/tests/mesh-alpha.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading