Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
67 changes: 67 additions & 0 deletions packages/melonjs/src/math/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
12 changes: 10 additions & 2 deletions packages/melonjs/src/renderable/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions packages/melonjs/tests/generate-normals.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
42 changes: 42 additions & 0 deletions packages/melonjs/tests/mesh.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading