diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index afdba634e..3302f1e80 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 597fac3c6..f0880e4f3 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -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 diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index 640141579..486cdad0c 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -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", diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index e3b4ddd97..b0f0cf735 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -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 @@ -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 | diff --git a/packages/melonjs/src/math/color.ts b/packages/melonjs/src/math/color.ts index 4c0a4911b..c4ea3a936 100644 --- a/packages/melonjs/src/math/color.ts +++ b/packages/melonjs/src/math/color.ts @@ -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; } /** diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 6a79cc167..576d6fe68 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -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. @@ -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. @@ -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) { @@ -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 @@ -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}` diff --git a/packages/melonjs/tests/color.spec.ts b/packages/melonjs/tests/color.spec.ts index 103a6fd6a..475d96875 100644 --- a/packages/melonjs/tests/color.spec.ts +++ b/packages/melonjs/tests/color.spec.ts @@ -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); }); }); @@ -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, + ); + } + }); + }); }); diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js index 99996d9bd..93f107ea8 100644 --- a/packages/melonjs/tests/mesh.spec.js +++ b/packages/melonjs/tests/mesh.spec.js @@ -3,6 +3,7 @@ import { Application, boot, Camera3d, + Color, Matrix2d, Matrix3d, Mesh, @@ -1481,4 +1482,132 @@ describe("Mesh × Camera3d world-space path", () => { expect(val[10]).toBeCloseTo(-2, 5); }); }); + describe("vertexColors", () => { + // a 5-vertex pyramid, so a length mismatch is unambiguous + const geometry = () => { + return { + vertices: new Float32Array([ + 0, 1, 0, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1, + ]), + uvs: new Float32Array([0.5, 0, 0, 1, 1, 1, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]), + normalize: false, + scale: 1, + width: 2, + height: 2, + }; + }; + + it("is undefined when nothing supplies it", () => { + // the batchers fall back to opaque white in this case, so a mesh + // that never asked for vertex colour costs nothing + const mesh = new Mesh(0, 0, geometry()); + expect(mesh.vertexColors).toBeUndefined(); + }); + + it("takes a packed Uint32Array as-is", () => { + const packed = new Uint32Array([1, 2, 3, 4, 5]); + const mesh = new Mesh(0, 0, { ...geometry(), vertexColors: packed }); + // no copy: this is exactly the form the batchers read + expect(mesh.vertexColors).toBe(packed); + }); + + it("packs a Color per vertex", () => { + const colors = [ + new Color(255, 0, 0), + new Color(0, 255, 0), + new Color(0, 0, 255), + new Color(255, 255, 255), + new Color(0, 0, 0), + ]; + const mesh = new Mesh(0, 0, { ...geometry(), vertexColors: colors }); + + expect(mesh.vertexColors).toBeInstanceOf(Uint32Array); + expect(mesh.vertexColors).toHaveLength(5); + for (let i = 0; i < colors.length; i++) { + expect(mesh.vertexColors[i]).toBe(colors[i].toUint32(colors[i].alpha)); + } + }); + + it("packs a plain array of already-packed numbers", () => { + const mesh = new Mesh(0, 0, { + ...geometry(), + vertexColors: [1, 2, 3, 4, 5], + }); + expect(mesh.vertexColors).toEqual(new Uint32Array([1, 2, 3, 4, 5])); + }); + + it("throws when the length does not match the vertex count", () => { + // silently padding would leave the tail of the mesh reading whatever + // the buffer held, which gets debugged as a lighting bug + expect(() => { + return new Mesh(0, 0, { + ...geometry(), + vertexColors: new Uint32Array([1, 2, 3]), + }); + }).toThrow(/vertexColors has 3 entries, expected 5/); + }); + + it("names both counts in the error", () => { + expect(() => { + return new Mesh(0, 0, { + ...geometry(), + vertexColors: [new Color(255, 0, 0)], + }); + }).toThrow( + /Mesh: vertexColors has 1 entries, expected 5 \(one per vertex\)/, + ); + }); + + describe("setVertexColor", () => { + it("starts the array on first use, leaving the rest white", () => { + const mesh = new Mesh(0, 0, geometry()); + mesh.setVertexColor(2, new Color(255, 0, 0)); + + expect(mesh.vertexColors).toBeInstanceOf(Uint32Array); + expect(mesh.vertexColors).toHaveLength(5); + expect(mesh.vertexColors[2]).toBe(new Color(255, 0, 0).toUint32(1)); + // untouched vertices stay opaque white rather than transparent + expect(mesh.vertexColors[0]).toBe(0xffffffff); + expect(mesh.vertexColors[4]).toBe(0xffffffff); + }); + + it("overwrites a colour supplied at construction", () => { + const mesh = new Mesh(0, 0, { + ...geometry(), + vertexColors: new Uint32Array([1, 2, 3, 4, 5]), + }); + mesh.setVertexColor(1, new Color(0, 255, 0)); + expect(mesh.vertexColors[1]).toBe(new Color(0, 255, 0).toUint32(1)); + expect(mesh.vertexColors[0]).toBe(1); + }); + + it("bumps the geometry version so the retained path re-uploads", () => { + // without this the colour applies on the immediate path and + // silently does not on the retained Camera3d one + const mesh = new Mesh(0, 0, geometry()); + const before = mesh._geometryVersion; + mesh.setVertexColor(0, new Color(255, 0, 0)); + expect(mesh._geometryVersion).toBeGreaterThan(before); + }); + + it("ignores an out-of-range index instead of throwing", () => { + const mesh = new Mesh(0, 0, geometry()); + expect(() => { + mesh.setVertexColor(99, new Color(255, 0, 0)); + mesh.setVertexColor(-1, new Color(255, 0, 0)); + }).not.toThrow(); + // and does not allocate the array for a write that never lands + expect(mesh.vertexColors).toBeUndefined(); + }); + + it("carries the colour's alpha", () => { + const mesh = new Mesh(0, 0, geometry()); + const half = new Color(255, 255, 255, 0.5); + mesh.setVertexColor(0, half); + expect(mesh.vertexColors[0]).toBe(half.toUint32(0.5)); + expect(mesh.vertexColors[0]).not.toBe(0xffffffff); + }); + }); + }); });