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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "melonjs",
"version": "20.3.0",
"version": "20.4.0",
"description": "Build games with melonJS — 23 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.",
"author": {
"name": "melonJS",
Expand Down
8 changes: 8 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [20.4.0] (melonJS 2) - _unreleased_

### Added
- 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
- 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_

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/melonjs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "melonjs",
"version": "20.3.0",
"version": "20.4.0",
"description": "melonJS Game Engine",
"homepage": "http://www.melonjs.org/",
"type": "module",
Expand Down
50 changes: 50 additions & 0 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,53 @@ mesh under a transformed parent. The anchor is only honoured on the legacy
difference between a hundred trees and a hundred thousand. glTF scenes using
`EXT_mesh_gpu_instancing` load as an `InstancedMesh` automatically.

## Colouring a mesh

There are four levels, and picking the wrong one is the usual reason a colour
"does not apply". They all multiply together.

| level | how | use for |
|---|---|---|
| whole object | `mesh.tint.setColor(r, g, b)` | flash on hit, team colour, fading one object |
| per vertex | `settings.vertexColors`, or `mesh.setVertexColor(i, color)` | a gradient *within* one mesh — distance haze, a darker crease |
| per material | `textureGroups`, from a multi-material OBJ + MTL | a model whose parts differ, in one draw call |
| per instance | `new InstancedMesh(…, { instanceColors: true })` then `setInstanceColor(i, color)` | a thousand copies that differ |

**`tint` is per object.** That is the trap: build a terrain as one big mesh and
you can tint the whole valley or none of it. Anything that varies *across* a
single mesh is per-vertex.

```js
// fade a procedural terrain toward the sky the further out it goes
const ground = new Color(217, 230, 244);
const sky = new Color(207, 230, 247);
const haze = new Color();
for (let i = 0; i < mesh.vertexCount; i++) {
const t = Math.min(1, mesh.originalVertices[i * 3 + 2] / 6000);
mesh.setVertexColor(i, haze.copy(ground).lerp(sky, t));
}
```

Supply the whole array at construction when you already have it —
`vertexColors` takes a packed `Uint32Array` (the form the batchers read, so no
conversion) or one `Color` per vertex. A length that does not match
`vertexCount` **throws**; it is not padded, because a short array would leave
the tail of the mesh mis-coloured and that reads as a lighting bug.

Mutating the array directly is fine, but say so afterwards:

```js
mesh.vertexColors[i] = color.toUint32(color.alpha);
mesh.needsUpdate = true; // the retained Camera3d path uploads once
```

`setVertexColor` does that for you. Skip it and the colour applies under a 2D
camera and silently does not under `Camera3d`.

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.

## Sprite3d and billboards

`Sprite3d` is the 2.5D workhorse: a flat sprite living at a real depth, with
Expand Down Expand Up @@ -209,6 +256,9 @@ 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` |
| 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 |
| distant objects vanish or warp | scene exceeds the default far plane; `setClipPlanes` |
| distant surfaces z-fight | `near` too small for the scene scale |
Expand Down
8 changes: 7 additions & 1 deletion packages/melonjs/src/math/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,13 @@ export class Color {
const ug = (a[1] * 255) >> 0;
const ub = (a[2] * 255) >> 0;

return (((alpha * 255) >> 0) << 24) | (ur << 16) | (ug << 8) | ub;
// `>>> 0` and not `| 0`: any alpha at or above 0.5 sets bit 31, and the
// bitwise operators above yield a SIGNED int32 — so a method named
// `toUint32` was handing back a negative number for most colors. Every
// consumer writes it into a `Uint32Array` or a shader attribute, where
// the bit pattern is identical either way; what broke was reading it
// back, comparing it, or printing it.
return ((((alpha * 255) >> 0) << 24) | (ur << 16) | (ug << 8) | ub) >>> 0;
}

/**
Expand Down
82 changes: 81 additions & 1 deletion packages/melonjs/src/renderable/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,35 @@ const _combinedMatrix = new Matrix3d();
// Resolve any acceptable texture input (TextureAtlas, image / canvas
// object, or asset name) to a cached `TextureAtlas`. Throws if nothing
// resolves — Mesh requires a texture binding for its GL pipeline.
/**
* Normalize a `vertexColors` setting to the packed form both batchers read.
*
* A length mismatch throws rather than being padded or truncated: a short
* array leaves the tail of the mesh reading whatever the buffer held, which
* shows up as a handful of oddly-lit faces and gets debugged as a lighting
* problem rather than a length one.
* @param {Uint32Array|Color[]|number[]} source - packed colours, or one Color per vertex
* @param {number} vertexCount - how many vertices the mesh has
* @returns {Uint32Array} one packed RGBA8 colour per vertex
* @ignore
*/
function packVertexColors(source, vertexCount) {
if (source.length !== vertexCount) {
throw new Error(
`Mesh: vertexColors has ${source.length} entries, expected ${vertexCount} (one per vertex)`,
);
}
if (source instanceof Uint32Array) {
return source;
}
const packed = new Uint32Array(vertexCount);
for (let i = 0; i < vertexCount; i++) {
const entry = source[i];
packed[i] = typeof entry === "number" ? entry : entry.toUint32(entry.alpha);
}
return packed;
}

// `framewidth`/`frameheight` define the spritesheet cell size (defaulting
// to the whole image); a subclass like Sprite3d passes them so the atlas
// carries an animation frame grid.
Expand Down Expand Up @@ -291,6 +320,7 @@ export default class Mesh extends Renderable {
* @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it).
* @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it).
* @param {boolean} [settings.lit=false] - shade this mesh with the scene's {@link Light3d} lights (the lit mesh pipeline) instead of rendering fullbright. Set automatically by the glTF importer when the scene carries a directional, point or spot light. With `lit` on and no lights present the batcher uploads a white ambient, so the result is indistinguishable from unlit.
* @param {Uint32Array|Color[]|number[]} [settings.vertexColors] - per-vertex colour, one entry per vertex, multiplied into {@link Mesh#tint}. Either packed RGBA8 (`Uint32Array`, the form the batchers read — no conversion) or one {@link Color} per vertex. Omit for plain white. Lets a single mesh carry a gradient — fading a terrain toward the sky with distance, darkening a crease — which a per-object `tint` cannot express. An explicit value wins over the colours a multi-material OBJ bakes from its MTL.
* @param {number[]|Float32Array} [settings.normals] - per-vertex normals for the lit path. An explicit value wins over the ones an OBJ or glTF source supplies; omit it and they are taken from the model (or generated).
* @param {number[]|Float32Array} [settings.specular] - specular color `[r, g, b]` (0..1) for the lit path. Set by the OBJ loader from MTL `Ks`, and derived from glTF metallic/roughness.
* @param {number} [settings.shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface.
Expand Down Expand Up @@ -750,7 +780,13 @@ export default class Mesh extends Renderable {
* material has its own dedup scope in the OBJ parser), so
* every vertex belongs to exactly one material group and
* carries that group's color unambiguously.
* @type {Uint32Array}
*
* This is also what `settings.vertexColors` and
* {@link Mesh#setVertexColor} populate, so procedural geometry
* can carry a gradient a per-object `tint` cannot express.
* `undefined` when every vertex is plain white.
* @type {Uint32Array|undefined}
* @see Mesh#setVertexColor
*/
this.vertexColors = new Uint32Array(this.vertexCount);
for (const g of this.groups) {
Expand Down Expand Up @@ -890,6 +926,16 @@ export default class Mesh extends Renderable {
)
: undefined;

// An explicit `settings.vertexColors` wins over the colours the
// multi-material branch above bakes from an MTL — the same precedence
// `settings.normals` has over an OBJ's own normals.
if (settings.vertexColors !== undefined) {
this.vertexColors = packVertexColors(
settings.vertexColors,
this.vertexCount,
);
}

/**
* Per-mesh texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"`
* / `"no-repeat"`), or `undefined` to sample with the texture's own
Expand Down Expand Up @@ -1041,6 +1087,40 @@ export default class Mesh extends Renderable {
}
}

/**
* Set one vertex's colour, multiplied into {@link Mesh#tint}.
*
* The mesh starts carrying per-vertex colour on the first call — every
* other vertex is white until coloured, so a mesh built without
* `settings.vertexColors` looks unchanged until you touch it.
*
* Out-of-range indices are ignored rather than throwing, matching
* {@link InstancedMesh#setInstanceColor}.
*
* Bumps {@link Mesh#needsUpdate} for you: the retained `Camera3d` path
* uploads geometry once and compares the version, so a colour written
* without it would apply on the immediate path and silently not on the
* retained one.
* @param {number} index - the vertex to colour
* @param {Color} color - the vertex colour
* @example
* // fade a procedural terrain toward the sky with distance
* for (let i = 0; i < mesh.vertexCount; i++) {
* const t = Math.min(1, mesh.originalVertices[i * 3 + 2] / 6000);
* mesh.setVertexColor(i, haze.copy(ground).lerp(sky, t));
* }
*/
setVertexColor(index, color) {
if (index < 0 || index >= this.vertexCount) {
return;
}
if (this.vertexColors === undefined) {
this.vertexColors = new Uint32Array(this.vertexCount).fill(0xffffffff);
}
this.vertexColors[index] = color.toUint32(color.alpha);
this.needsUpdate = true;
}

/**
* A custom shader hosted on this mesh's draw, replacing the built-in
* mesh shading: a {@link GLShader} carrying a `{vertex, fragment}`
Expand Down
43 changes: 25 additions & 18 deletions packages/melonjs/tests/color.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,49 +588,37 @@ describe("Color", () => {
it("should return an unsigned 32-bit ARGB value", () => {
const color = new Color(255, 0, 0);
const uint32 = color.toUint32(1.0);
//expect(uint32).toEqual(0xFFFF0000);
// jasmine test the value as signed int32
expect(uint32).toEqual(-65536);
expect(uint32).toEqual(0xffff0000);
});

it("should handle alpha values", () => {
const color = new Color(255, 0, 0);
const uint32 = color.toUint32(0.5);
//expect(color.toUint32()).toEqual(0x7FFF0000);
// jasmine test the value as signed int32
expect(uint32).toEqual(2147418112);
expect(uint32).toEqual(0x7fff0000);
});

it("should shift the alpha value to the first byte", () => {
const color = new Color(0, 0, 0);
const uint32 = color.toUint32(0.25);
//expect(uint32).toEqual(0x3F000000);
// jasmine test the value as signed int32
expect(uint32).toEqual(1056964608);
expect(uint32).toEqual(0x3f000000);
});

it("should shift the red value to the second byte", () => {
const color = new Color(255, 0, 0);
const uint32 = color.toUint32(1.0);
//expect(uint32).toEqual(0xFFFF0000);
// jasmine test the value as signed int32
expect(uint32).toEqual(-65536);
expect(uint32).toEqual(0xffff0000);
});

it("should shift the green value to the third byte", () => {
const color = new Color(0, 255, 0);
const uint32 = color.toUint32(1.0);
//expect(uint32).toEqual(0xFF00FF00);
// jasmine test the value as signed int32
expect(uint32).toEqual(-16711936);
expect(uint32).toEqual(0xff00ff00);
});

it("should leave the blue value in the fourth byte", () => {
const color = new Color(0, 0, 255);
const uint32 = color.toUint32(1.0);
//expect(uint32).toEqual(0xFF0000FF);
// jasmine test the value as signed int32
expect(uint32).toEqual(-16776961);
expect(uint32).toEqual(0xff0000ff);
});
});

Expand All @@ -651,4 +639,23 @@ describe("Color", () => {
expect(copy.toHex()).toEqual("#8040FF");
});
});
describe("toUint32 signedness", () => {
it("round-trips through a Uint32Array unchanged", () => {
// the comparison a caller actually makes, e.g. against Mesh#vertexColors
const packed = new Uint32Array(1);
const color = new Color(12, 34, 56, 0.75);
packed[0] = color.toUint32(0.75);
expect(packed[0]).toBe(color.toUint32(0.75));
});

it("is never negative, whatever the alpha", () => {
// `|` yields a signed int32, so every colour with alpha >= 0.5 set
// bit 31 and came back negative from a method named toUint32
for (const alpha of [0, 0.25, 0.5, 0.75, 1]) {
expect(new Color(200, 100, 50).toUint32(alpha)).toBeGreaterThanOrEqual(
0,
);
}
});
});
});
Loading
Loading