Skip to content

Mesh: generate normals for lit geometry built without them - #1627

Merged
obiot merged 1 commit into
masterfrom
feat/mesh-generate-normals
Aug 31, 2026
Merged

Mesh: generate normals for lit geometry built without them#1627
obiot merged 1 commit into
masterfrom
feat/mesh-generate-normals

Conversation

@obiot

@obiot obiot commented Aug 31, 2026

Copy link
Copy Markdown
Member

The gap

A Mesh flagged lit but 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:

Empty (zero) when the mesh has no source normals — the shader then ignores lighting.

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

// normals derived from the triangles — nothing else to do
const mesh = new Mesh(x, y, { vertices, uvs, indices, lit: true });

generateNormals(vertices, indices, out?) joins normalizeVertices and projectVertices in math/vertex.ts. Internal like its neighbours — none of that module is exported from index.ts — so this adds no public surface, just the behaviour. Mesh calls it when lit is set and no normals came from the settings or the model.

An explicit settings.normals still 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:

  • where faces share a vertex, the accumulation averages them → smooth
  • where every triangle carries its own three vertices (a triangle soup, which is how most hand-built geometry comes out), each vertex belongs to exactly one face, so the average is that face's normal → flat

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:

  • flat when no vertex is shared, smooth where faces share one — same function, both asserted
  • area weighting: a near-degenerate sliver sharing a vertex with a large face barely tilts it
  • a vertex touched only by degenerate faces stays [0,0,0], not NaN
  • winding determines direction; the out buffer is cleared before accumulating; a trailing partial triangle is ignored rather than read past

Plus three at the Mesh level: 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, tsc clean.

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 the Mesh-level tests initially threw Cannot read properties of undefined (reading 'renderer') from the unguarded global game in resolveTextureAtlas, so they moved into mesh.spec.js, which has a booted Application.

Docs

melonjs-3d gains a Normals are generated for you section explaining that the geometry decides flat versus smooth, plus a symptom row for the case this fixes:

symptom cause
a lit mesh renders fullbright it had no normals — supply them, or let the engine generate them

Worth 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

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
Copilot AI lite review requested due to automatic review settings August 31, 2026 07:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 646bc07 into master Aug 31, 2026
6 checks passed
@obiot
obiot deleted the feat/mesh-generate-normals branch August 31, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants