Mesh: let procedural geometry supply per-vertex colours - #1625
Merged
Conversation
Both batchers already wrote a per-vertex `aColor` — WebGL at mesh_batcher.js:1447, WebGPU at :766 — falling back to opaque white when the array is absent. What was missing was any way to supply it: `mesh.vertexColors` was only ever built internally, at mesh.js:755, for a multi-material OBJ with a bound MTL. A mesh you built yourself could not reach it. That matters because `tint` is per OBJECT. A terrain built as one mesh can be tinted whole or not at all, so there is no way to fade its far end toward the sky or darken a crease — the workaround is to split the mesh, which defeats the single draw call. Per-vertex colour is also how you fake aerial perspective and ambient occlusion without a shader, which is worth having while the engine has no distance fog (#1622). - `settings.vertexColors` takes packed RGBA8 (`Uint32Array`, the form the batchers read, passed through untouched) or one `Color` per vertex. An explicit value wins over the multi-material bake, the same precedence `settings.normals` has over an OBJ's own normals. - `setVertexColor(index, color)` mirrors `InstancedMesh#setInstanceColor` — same argument order, same silent out-of-range behaviour. - Both reuse the existing `needsUpdate` signal rather than adding a flag. That is load-bearing: it bumps `_geometryVersion`, which the retained Camera3d path compares, so a colour written without it would apply on the immediate path and silently not on the retained one. `setVertexColor` bumps it for you. - A length that does not match `vertexCount` throws, naming both counts. A short array would leave the tail of the mesh reading whatever the buffer held, which gets debugged as a lighting bug rather than a length one. Neither batcher changes. Writing the first test turned up a real defect in `Color#toUint32()`: the packing ends in `|`, which yields a SIGNED int32, so a method named `toUint32` and documented as returning "a Uint32 ARGB representation" handed back -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 it back, comparing it, or printing it. Fixed with `>>> 0`. The four existing tests knew: each had the correct expectation commented out with a note that the value came back signed, and one is named "should return an unsigned 32-bit ARGB value" while asserting -65536. Those assertions are restored to the values that were commented out. Closes #1624 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1624.
The gap
Both mesh batchers already write a per-vertex colour into the vertex buffer as
aColor, falling back to opaque white when the array is absent — WebGL atmesh_batcher.js:1447, WebGPU at:766. So the rendering side was already done, on both backends.What was missing was any way to supply it.
mesh.vertexColorsis only ever constructed atmesh.js:755, for a multi-material OBJ with a bound MTL, so each material group'sKdbakes onto its own vertices and the model draws in one call. Nosettings.vertexColors, no setter, no@param— a mesh you built yourself could not reach it.That matters because
tintis per object. A terrain built as one mesh can be tinted whole or not at all, so there is no way to fade its far end toward the sky, or darken a crease. The workaround is to split the mesh, which defeats the single draw call.The API
Four additions to
renderable/mesh.js. Neither batcher changes.settings.vertexColorstakes aUint32Array(the form the batchers read — passed through untouched) or aColor[]/number[], packed on the way in. An explicit value wins over the multi-material bake, the same precedencesettings.normalshas over an OBJ's own normals.setVertexColor(index, color)matchessetInstanceColor's argument order and its silent out-of-range behaviour, and lazily creates a white array on first use.needsUpdaterather than adding a flag. This is load-bearing, not cosmetic: it bumps_geometryVersion, which the retainedCamera3dpath compares (mesh_batcher.js:467), so a colour written without it would apply on the immediate path and silently not on the retained one.Presence is the opt-in — no enabling flag, since the batcher already defaults to white. (Other libraries gate this behind a material boolean defaulting to
false, which is a reliable "why is my mesh white" trap. Not copied.)A defect found by writing the first test
The test compared
mesh.vertexColors[i]againstcolor.toUint32()and failed:4278255360vs-16711936.Color#toUint32()ends its packing with|, which yields a signed int32 — so a method namedtoUint32, documented as returning "a Uint32 ARGB representation", returned a negative number for any colour with alpha at or above 0.5. Every consumer inside the engine writes it into aUint32Arrayor a shader attribute where the bit pattern is identical, so nothing rendered wrong. What broke was reading it back, comparing it, or printing it.The existing tests knew. All four had the correct expectation commented out:
One is named "should return an unsigned 32-bit ARGB value" while asserting
-65536. Fixed with>>> 0, and those four assertions restored to the values that were commented out.Tests
13 new, covering both construction forms, the throw and its message, lazy array creation leaving other vertices white, the
needsUpdatebump, out-of-range being a no-op that doesn't allocate, alpha carried through, plus round-trip and signedness coverage ontoUint32.Full suite: 6,444 passing, 264 files. eslint 0 errors, biome clean,
tscclean, typedoc 0 errors.Also
melonjs-3dgains a Colouring a mesh section: the four levels (objecttint/ per vertex / per material / per instance), the "tintis per object" trap, theneedsUpdateprotocol, that it multiplies the lit result so it behaves as albedo rather than an emissive override, and three symptom rows.20.4.0, with.claude-plugin/plugin.jsonmoved in step (the release guard added in Agent skills, an llms.txt API index, and the defects verifying them uncovered #1620 requires it).Built to be consumed: the terrain in a 3D example, one procedural mesh, now fades into the sky with distance and darkens in the crease. That is the thing
tintcould not express.🤖 Generated with Claude Code
https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N