Mesh: generate normals for lit geometry built without them - #1627
Merged
Conversation
A `lit` mesh with no normals had nothing for the shader to light with, so it
rendered fullbright — you asked for lighting and silently got flat colour. The
docs said as much ("Empty (zero) when the mesh has no source normals — the
shader then ignores lighting") but nothing computed them, and nothing said so at
runtime. Every hand-built mesh had to write the same accumulate-and-normalize
loop before it could be lit.
`generateNormals(vertices, indices, out?)` joins `normalizeVertices` and
`projectVertices` in `math/vertex.ts` — internal, like its neighbours, so this
adds no public surface. `Mesh` calls it when `lit` is set and no normals came
from the settings or the model.
There is deliberately no flat/smooth flag, because the geometry already carries
the answer. Face normals accumulate into their vertices weighted by area — which
falls out of using the raw cross product rather than a normalized one, and keeps
a large face from being outvoted by a fan of slivers. Where faces SHARE a vertex
the accumulation averages them and the surface shades smoothly; where every
triangle carries its OWN three vertices (a triangle soup, which is how most
hand-built geometry comes out) each vertex belongs to one face, so the average
IS that face normal and it shades flat. Want facets: duplicate vertices. Want
smooth: share them.
Degenerate triangles contribute a zero-length cross product and drop out on
their own, so a vertex touched only by degenerate faces stays at zero rather
than becoming NaN.
An explicit `settings.normals` still wins, and an unlit mesh gets none — nothing
would read them.
Two of the first test expectations here were wrong about the cross-product sign
rather than the code being wrong; the hand-computed value settled it.
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.
The gap
A
Meshflaggedlitbut built without normals had nothing for the shader to light with, so it rendered fullbright — you asked for lighting and silently got flat colour. The property doc even said so:but nothing computed them, and nothing said so at runtime. Normals only ever arrived from a glTF/OBJ source, so every hand-built mesh had to write the same accumulate-and-normalize loop before it could be lit.
The API
generateNormals(vertices, indices, out?)joinsnormalizeVerticesandprojectVerticesinmath/vertex.ts. Internal like its neighbours — none of that module is exported fromindex.ts— so this adds no public surface, just the behaviour.Meshcalls it whenlitis set and no normals came from the settings or the model.An explicit
settings.normalsstill wins, and an unlit mesh gets none: nothing would read them.No flat/smooth flag, on purpose
The geometry already carries the answer, so a flag would only let you contradict it.
Face normals accumulate into their vertices weighted by area — which falls out of using the raw cross product rather than a normalized one, and keeps a large face from being outvoted by a fan of slivers meeting at the same vertex. Then:
Want faceted edges: duplicate the vertices. Want smooth: share them. One code path, both results.
Degenerate triangles contribute a zero-length cross product and drop out on their own, so a vertex touched only by degenerate faces stays at zero rather than becoming
NaN.Tests
11 new. The interesting ones are the properties that make the no-flag design hold:
[0,0,0], notNaNoutbuffer is cleared before accumulating; a trailing partial triangle is ignored rather than read pastPlus three at the
Meshlevel: generated for a lit mesh, absent for an unlit one, and an explicit array left untouched.Full suite: 6,459 passing, 266 files. eslint 0 errors, biome clean,
tscclean.Two of my first expectations were wrong about the cross-product sign rather than the code being wrong — hand-computing
u × w = (0, -1, 0)settled it. And theMesh-level tests initially threwCannot read properties of undefined (reading 'renderer')from the unguarded globalgameinresolveTextureAtlas, so they moved intomesh.spec.js, which has a booted Application.Docs
melonjs-3dgains a Normals are generated for you section explaining that the geometry decides flat versus smooth, plus a symptom row for the case this fixes:litmesh renders fullbrightWorth flagging in review: a lit mesh that was previously fullbright will now pick up shading. That is the fix, but it is a visible change for any existing scene in that state.
🤖 Generated with Claude Code
https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N