From 999ea874b3252637fd335b637e4fba3394e04880 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 31 Aug 2026 15:02:15 +0800 Subject: [PATCH] Mesh: generate normals for lit geometry built without them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/skills/melonjs-3d/SKILL.md | 26 +++++ packages/melonjs/src/math/vertex.ts | 67 +++++++++++ packages/melonjs/src/renderable/mesh.js | 12 +- .../melonjs/tests/generate-normals.spec.js | 109 ++++++++++++++++++ packages/melonjs/tests/mesh.spec.js | 42 +++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 packages/melonjs/tests/generate-normals.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 0a8123ffb..f33443441 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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)) +- Mesh: normals are generated from the geometry when a `lit` mesh is built without them. A lit mesh with no normals had nothing for the shader to light with and rendered **fullbright** — asking for lighting and silently getting flat colour — and every hand-built mesh had to write the same accumulate-and-normalize loop first. Flat versus smooth is decided by the geometry rather than a flag: face normals accumulate into their vertices weighted by area, so shared vertices average into smooth shading while a triangle soup (each face owning its three vertices) resolves to the face normal and shades flat. An explicit `settings.normals` still wins, and an unlit mesh gets none + ### Fixed - Mesh: `alpha = 0` painted the mesh **opaque black** instead of hiding it, on both GPU backends. `CanvasRenderer.drawMesh` has always skipped when the global alpha falls below `1/255` — the same guard eight other Canvas draw methods use — but neither GPU renderer had it, and the mesh path disables blending (`MeshBatcher.bind`), so the alpha never reached the blend stage: the shader multiplied the colour by zero and wrote the result opaque. Hiding a mesh with `alpha = 0` left a black silhouette of it, and the same property behaved differently per backend. Both GPU renderers now skip at the same threshold - 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 diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 854d026ff..038ebdf95 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -119,6 +119,31 @@ 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. +## Normals are generated for you + +A `lit` mesh needs per-vertex normals for the shader to light with. Supply them +if you have them; **omit them and the engine computes them from the geometry**: + +```js +const mesh = new Mesh(x, y, { vertices, uvs, indices, lit: true }); +// normals derived from the triangles — nothing else to do +``` + +You do not choose flat or smooth, because the geometry already decides. Face +normals accumulate into their vertices weighted by area, so where faces **share** +a vertex they average and the surface shades smoothly, and 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 and the result +is that face's normal, so it shades flat. Want faceted edges: duplicate the +vertices. Want smooth: share them. + +An explicit `settings.normals` always wins, and an unlit mesh gets none — there +would be nothing to read them. + +**A lit mesh with no normals used to render fullbright**, which looks like the +lighting is broken rather than absent. If an older scene suddenly picks up +shading, that is why. + ## Colouring a mesh There are four levels, and picking the wrong one is the usual reason a colour @@ -261,6 +286,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | symptom | cause | |---|---| +| a `lit` mesh renders fullbright | it had no normals — supply them, or let the engine generate them | | a gradient across one mesh is impossible | `tint` is per object — use `vertexColors` / `setVertexColor` | | a mesh stays solid as you fade it out | meshes render opaque; only `alpha` 0 (hidden) and 1 differ | | vertex colour applies under a 2D camera but not `Camera3d` | wrote the array directly without setting `needsUpdate` | diff --git a/packages/melonjs/src/math/vertex.ts b/packages/melonjs/src/math/vertex.ts index 908c19744..07b5e6edb 100644 --- a/packages/melonjs/src/math/vertex.ts +++ b/packages/melonjs/src/math/vertex.ts @@ -392,3 +392,70 @@ export function convexHull(points: Vector2d[]): Vector2d[] { return hull; } + +/** + * Compute per-vertex surface normals for indexed triangle geometry. + * + * Each triangle's normal is accumulated into its three vertices weighted by + * the triangle's area — that falls out of using the raw cross product rather + * than a normalized one — and the result is normalized at the end. Area + * weighting keeps a large face from being outvoted by a fan of slivers meeting + * at the same vertex. + * + * One algorithm covers both shading styles, because the answer depends on the + * geometry rather than on a flag: where vertices are shared between faces the + * accumulation averages them and the surface shades smoothly, and 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 and the surface shades flat. + * + * Degenerate triangles contribute a zero-length cross product and so drop out + * on their own; a vertex touched only by degenerate faces is left at zero + * rather than becoming NaN. + * @param vertices - vertex positions as x,y,z triplets + * @param indices - triangle vertex indices + * @param out - optional destination, sized `vertices.length` + * @returns unit normals as x,y,z triplets, one per vertex + */ +export function generateNormals( + vertices: Float32Array, + indices: Uint16Array | Uint32Array | number[], + out: Float32Array = new Float32Array(vertices.length), +): Float32Array { + out.fill(0); + + for (let i = 0; i + 2 < indices.length; i += 3) { + const ia = indices[i] * 3; + const ib = indices[i + 1] * 3; + const ic = indices[i + 2] * 3; + + const ux = vertices[ib] - vertices[ia]; + const uy = vertices[ib + 1] - vertices[ia + 1]; + const uz = vertices[ib + 2] - vertices[ia + 2]; + const wx = vertices[ic] - vertices[ia]; + const wy = vertices[ic + 1] - vertices[ia + 1]; + const wz = vertices[ic + 2] - vertices[ia + 2]; + + // left un-normalized on purpose: its length is twice the triangle's + // area, which is the weight we want + const nx = uy * wz - uz * wy; + const ny = uz * wx - ux * wz; + const nz = ux * wy - uy * wx; + + for (const at of [ia, ib, ic]) { + out[at] += nx; + out[at + 1] += ny; + out[at + 2] += nz; + } + } + + for (let at = 0; at + 2 < out.length; at += 3) { + const len = Math.hypot(out[at], out[at + 1], out[at + 2]); + if (len > 0) { + out[at] /= len; + out[at + 1] /= len; + out[at + 2] /= len; + } + } + return out; +} diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 576d6fe68..7deb7016e 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -8,6 +8,7 @@ import { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d } from "../math/vector2d.ts"; import { convexHull, + generateNormals, normalizeVertices, projectVertices, } from "../math/vertex.ts"; @@ -321,7 +322,7 @@ export default class Mesh extends Renderable { * @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.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 from the geometry when the mesh is `lit`. Generated normals average per vertex where faces share vertices (smooth shading) and equal the face normal where they do not (flat shading) — the geometry decides, not a flag. * @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. * @param {string|TextureAtlas|HTMLImageElement} [settings.alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). @@ -488,7 +489,14 @@ export default class Mesh extends Renderable { ? sourceNormals instanceof Float32Array ? sourceNormals : new Float32Array(sourceNormals) - : undefined; + : settings.lit === true + ? // A lit mesh with no normals has nothing for the shader to + // light with, so it renders fullbright — asking for `lit` + // and getting flat colour, with nothing said. Hand-built + // geometry almost never carries normals, so generate them + // rather than make every caller write the same loop. + generateNormals(this.originalVertices, this.indices) + : undefined; /** * world-space normals for the current draw, recomputed from diff --git a/packages/melonjs/tests/generate-normals.spec.js b/packages/melonjs/tests/generate-normals.spec.js new file mode 100644 index 000000000..2caab915a --- /dev/null +++ b/packages/melonjs/tests/generate-normals.spec.js @@ -0,0 +1,109 @@ +/** + * Surface normals generated from indexed triangle geometry. + * + * The engine had no way to produce these: a `Mesh` built from raw vertices and + * flagged `lit` carried no normals, so the shader had nothing to light with and + * the mesh rendered fullbright — asking for lighting and silently getting flat + * colour. Every hand-built mesh had to write the same accumulate-and-normalize + * loop first. + */ +import { describe, expect, it } from "vitest"; +import { generateNormals } from "../src/math/vertex.ts"; + +/** unit-length check, plus the direction */ +const expectNormal = (out, index, [x, y, z]) => { + const at = index * 3; + expect(out[at]).toBeCloseTo(x, 5); + expect(out[at + 1]).toBeCloseTo(y, 5); + expect(out[at + 2]).toBeCloseTo(z, 5); + expect(Math.hypot(out[at], out[at + 1], out[at + 2])).toBeCloseTo(1, 5); +}; + +describe("generateNormals", () => { + it("gives a single triangle its face normal", () => { + // counter-clockwise in the XY plane -> +Z + const out = generateNormals( + new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + new Uint16Array([0, 1, 2]), + ); + for (const v of [0, 1, 2]) { + expectNormal(out, v, [0, 0, 1]); + } + }); + + it("follows the winding", () => { + const out = generateNormals( + new Float32Array([0, 0, 0, 0, 1, 0, 1, 0, 0]), + new Uint16Array([0, 1, 2]), + ); + expectNormal(out, 0, [0, 0, -1]); + }); + + it("shades flat when no vertex is shared", () => { + // two coplanar-in-nothing triangles, each with its own vertices: every + // vertex belongs to one face, so its normal IS that face's normal + const out = generateNormals( + new Float32Array([ + // facing +Z + 0, 0, 0, 1, 0, 0, 0, 1, 0, + // facing +Y + 0, 0, 0, 0, 0, -1, 1, 0, 0, + ]), + new Uint16Array([0, 1, 2, 3, 4, 5]), + ); + expectNormal(out, 0, [0, 0, 1]); + expectNormal(out, 3, [0, -1, 0]); + }); + + it("shades smooth where faces share a vertex", () => { + // a shared vertex between a +Z face and a -Y face averages to the + // diagonal between them — the whole point of sharing vertices + const out = generateNormals( + new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, -1]), + new Uint16Array([0, 1, 2, 0, 3, 1]), + ); + const at = 0; + expect(out[at]).toBeCloseTo(0, 5); + expect(out[at + 1]).toBeCloseTo(-Math.SQRT1_2, 5); + expect(out[at + 2]).toBeCloseTo(Math.SQRT1_2, 5); + }); + + it("weights by area, so a sliver does not outvote a large face", () => { + const shared = new Float32Array([ + 0, 0, 0, 10, 0, 0, 0, 10, 0, 0, 0, -0.001, + ]); + const out = generateNormals(shared, new Uint16Array([0, 1, 2, 0, 3, 1])); + // the big +Z face dominates; the near-degenerate one barely tilts it + expect(out[2]).toBeGreaterThan(0.99); + }); + + it("leaves a vertex touched only by degenerate faces at zero, not NaN", () => { + const out = generateNormals( + // all three points identical: zero-area, zero cross product + new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1]), + new Uint16Array([0, 1, 2]), + ); + expect([...out]).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect([...out].some(Number.isNaN)).toBe(false); + }); + + it("writes into a supplied buffer, clearing it first", () => { + const out = new Float32Array(9).fill(99); + const same = generateNormals( + new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + new Uint16Array([0, 1, 2]), + out, + ); + expect(same).toBe(out); + expectNormal(out, 0, [0, 0, 1]); + }); + + it("ignores a trailing partial triangle rather than reading past the end", () => { + expect(() => { + generateNormals( + new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + new Uint16Array([0, 1, 2, 0, 1]), + ); + }).not.toThrow(); + }); +}); diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js index 93f107ea8..6d8942bbf 100644 --- a/packages/melonjs/tests/mesh.spec.js +++ b/packages/melonjs/tests/mesh.spec.js @@ -1610,4 +1610,46 @@ describe("Mesh × Camera3d world-space path", () => { }); }); }); + + /** unit-length check, plus the direction */ + const expectNormal = (out, index, [x, y, z]) => { + const at = index * 3; + expect(out[at]).toBeCloseTo(x, 5); + expect(out[at + 1]).toBeCloseTo(y, 5); + expect(out[at + 2]).toBeCloseTo(z, 5); + }; + + describe("generated normals", () => { + const geometry = (extra) => { + return { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 1, 0, 0, 1]), + indices: new Uint16Array([0, 1, 2]), + normalize: false, + scale: 1, + width: 1, + height: 1, + ...extra, + }; + }; + + it("generates them for a lit mesh built without any", () => { + const mesh = new Mesh(0, 0, geometry({ lit: true })); + expect(mesh.originalNormals).toBeInstanceOf(Float32Array); + expect(mesh.originalNormals).toHaveLength(9); + expectNormal(mesh.originalNormals, 0, [0, 0, 1]); + }); + + it("leaves an unlit mesh without them", () => { + // nothing would read them, so the work and the memory are skipped + const mesh = new Mesh(0, 0, geometry()); + expect(mesh.originalNormals).toBeUndefined(); + }); + + it("does not overwrite normals that were supplied", () => { + const supplied = new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0]); + const mesh = new Mesh(0, 0, geometry({ lit: true, normals: supplied })); + expect(mesh.originalNormals).toBe(supplied); + }); + }); });