diff --git a/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx b/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx
index 3f5cce18b..af3101dfe 100644
--- a/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx
+++ b/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx
@@ -8,13 +8,151 @@ import {
game,
input,
Light2d,
+ Renderable,
Sprite,
Stage,
state,
+ Text,
video,
} from "melonjs";
import { createExampleComponent } from "../utils";
+/**
+ * Procedurally generate a plastered stone wall: a colour image plus the
+ * matching normal map.
+ *
+ * The orbs alone leave a fair question unanswered — what does this lighting do
+ * with geometry that is not a ball? A wall's normals mostly face the screen, so
+ * its highlight is a broad soft pool rather than a point, and the pools are
+ * what make the two moving lights legible: you see where they ARE, instead of
+ * inferring it from a glint.
+ *
+ * The normal is derived from a height field by central differences, the same
+ * way a baking tool would do it: mortar courses cut grooves, each block gets a
+ * slight dome, and a little value noise roughens the surface. The alpha channel
+ * carries the specular mask — mortar is matte, stone faces take a low sheen.
+ * @param {number} w - wall width in pixels
+ * @param {number} h - wall height in pixels
+ * @returns {object} the colour and normal canvases
+ */
+function generateWall(w: number, h: number) {
+ const BLOCK_W = 96;
+ const BLOCK_H = 44;
+ const MORTAR = 5;
+
+ // deterministic value noise — no Math.random, so the wall is identical
+ // on every run and screenshots stay comparable
+ const hash = (x: number, y: number) => {
+ let n = (x * 374761393 + y * 668265263) >>> 0;
+ n = ((n ^ (n >>> 13)) * 1274126177) >>> 0;
+ return ((n ^ (n >>> 16)) >>> 0) / 4294967295;
+ };
+ const smooth = (t: number) => t * t * (3 - 2 * t);
+ const noise = (x: number, y: number, period: number) => {
+ const xi = Math.floor(x / period);
+ const yi = Math.floor(y / period);
+ const xf = smooth(x / period - xi);
+ const yf = smooth(y / period - yi);
+ const a = hash(xi, yi);
+ const b = hash(xi + 1, yi);
+ const c = hash(xi, yi + 1);
+ const d = hash(xi + 1, yi + 1);
+ return (a * (1 - xf) + b * xf) * (1 - yf) + (c * (1 - xf) + d * xf) * yf;
+ };
+
+ // which course a row belongs to, and the half-block stagger on odd ones
+ const blockAt = (x: number, y: number) => {
+ const row = Math.floor(y / BLOCK_H);
+ const shift = (row & 1) === 1 ? BLOCK_W / 2 : 0;
+ const localX = (x + shift) % BLOCK_W;
+ const localY = y - row * BLOCK_H;
+ return { row, localX, localY };
+ };
+
+ // the surface: 0 in the mortar, domed across each block face
+ const height = (x: number, y: number) => {
+ const { row, localX, localY } = blockAt(x, y);
+ const inMortar =
+ localX < MORTAR ||
+ localX > BLOCK_W - MORTAR ||
+ localY < MORTAR ||
+ localY > BLOCK_H - MORTAR;
+ if (inMortar) {
+ return 0.08 * noise(x, y, 5);
+ }
+ const u = (localX - MORTAR) / (BLOCK_W - MORTAR * 2);
+ const v = (localY - MORTAR) / (BLOCK_H - MORTAR * 2);
+ const dome = Math.sin(Math.PI * u) * Math.sin(Math.PI * v);
+ return (
+ 0.55 +
+ 0.3 * dome +
+ 0.1 * noise(x + row * 37, y, 9) +
+ 0.05 * noise(x, y, 3)
+ );
+ };
+
+ const colorCanvas = document.createElement("canvas");
+ colorCanvas.width = w;
+ colorCanvas.height = h;
+ const cctx = colorCanvas.getContext("2d") as CanvasRenderingContext2D;
+ const cimg = cctx.createImageData(w, h);
+
+ const normalCanvas = document.createElement("canvas");
+ normalCanvas.width = w;
+ normalCanvas.height = h;
+ const nctx = normalCanvas.getContext("2d") as CanvasRenderingContext2D;
+ const nimg = nctx.createImageData(w, h);
+
+ const STRENGTH = 2.6;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const i = (y * w + x) * 4;
+ const { localX, localY } = blockAt(x, y);
+ const inMortar =
+ localX < MORTAR ||
+ localX > BLOCK_W - MORTAR ||
+ localY < MORTAR ||
+ localY > BLOCK_H - MORTAR;
+
+ // colour: cool grey stone, darker mortar, a little per-block
+ // variation so the courses do not read as a printed pattern
+ const tone = inMortar
+ ? 0.34 + 0.06 * noise(x, y, 4)
+ : 0.62 + 0.14 * noise(x * 0.7, y * 0.7, 26) + 0.05 * noise(x, y, 3);
+ cimg.data[i + 0] = Math.round(255 * tone * 0.86);
+ cimg.data[i + 1] = Math.round(255 * tone * 0.88);
+ cimg.data[i + 2] = Math.round(255 * tone);
+ cimg.data[i + 3] = 255;
+
+ // normal from the height field, by central differences
+ const l = height((x - 1 + w) % w, y);
+ const r = height((x + 1) % w, y);
+ const d = height(x, (y - 1 + h) % h);
+ const u2 = height(x, (y + 1) % h);
+ let nx = (l - r) * STRENGTH;
+ // (u2 - d), not (d - u2): image Y grows downward while a normal map
+ // encodes Y UP, so the two flip. Get it backwards and the relief
+ // reads inside-out — mortar courses become ridges, block faces
+ // become hollows, and a light overhead lights the wrong edge of
+ // every stone. `generateOrb` gets there by negating dy instead.
+ let ny = (u2 - d) * STRENGTH;
+ let nz = 1;
+ const inv = 1 / Math.hypot(nx, ny, nz);
+ nx *= inv;
+ ny *= inv;
+ nz *= inv;
+ nimg.data[i + 0] = Math.round((nx * 0.5 + 0.5) * 255);
+ nimg.data[i + 1] = Math.round((ny * 0.5 + 0.5) * 255);
+ nimg.data[i + 2] = Math.round((nz * 0.5 + 0.5) * 255);
+ // specular mask: mortar is dead matte, stone takes a low sheen
+ nimg.data[i + 3] = inMortar ? 10 : Math.round(90 + 60 * noise(x, y, 17));
+ }
+ }
+ cctx.putImageData(cimg, 0, 0);
+ nctx.putImageData(nimg, 0, 0);
+ return { colorCanvas, normalCanvas };
+}
+
/**
* Procedurally generate a sphere "orb" sprite paired with a normal map.
* The color image is a soft radial gradient; the normal map encodes the
@@ -28,6 +166,14 @@ function generateOrb(
mid: "#888888",
edge: "rgba(40, 40, 40, 0)",
},
+ // Write worn patches into the normal map's ALPHA channel. The engine
+ // reads that channel as the per-texel SPECULAR MASK, so the scuffs stop
+ // reflecting while their diffuse shading stays perfectly smooth — one
+ // sprite, shiny in places and matte in others. Nothing else consumes a
+ // normal map's alpha (the sprite's own transparency comes from its
+ // colour texture), and normal maps upload with premultiply off, so the
+ // channel arrives intact.
+ scuffed = false,
) {
const colorCanvas = document.createElement("canvas");
colorCanvas.width = size;
@@ -42,7 +188,7 @@ function generateOrb(
size / 2,
);
radial.addColorStop(0, tint.inner);
- radial.addColorStop(0.85, tint.mid);
+ radial.addColorStop(0.955, tint.mid);
radial.addColorStop(1, tint.edge);
cctx.fillStyle = radial;
cctx.fillRect(0, 0, size, size);
@@ -80,7 +226,22 @@ function generateOrb(
imgData.data[i + 0] = Math.round((dx * 0.5 + 0.5) * 255);
imgData.data[i + 1] = Math.round((dy * 0.5 + 0.5) * 255);
imgData.data[i + 2] = Math.round((nz * 0.5 + 0.5) * 255);
- imgData.data[i + 3] = 255;
+ // 255 = fully reflective. The band pattern below rubs that down
+ // without touching the RGB, so the surface still curves the light
+ // the same way — it just stops shining there.
+ let gloss = 255;
+ if (scuffed) {
+ // One half polished, the other rubbed matte, with a soft
+ // boundary. Deliberately large and simple: the mask can only
+ // show where the highlight actually falls, so a few small
+ // patches would be visible for a fraction of the light's
+ // orbit and read as noise. Half and half means you always see
+ // it — the highlight crosses the seam and dies.
+ const t = Math.max(0, Math.min(1, (dx + dy) * 1.6 + 0.5));
+ const eased = t * t * (3 - 2 * t);
+ gloss = Math.round(20 + eased * 235);
+ }
+ imgData.data[i + 3] = gloss;
}
}
nctx.putImageData(imgData, 0, 0);
@@ -90,7 +251,12 @@ function generateOrb(
class PlayScreen extends Stage {
onResetEvent() {
+ // The orb's WORLD size, and the resolution its texture is generated
+ // at. The canvas is 728 wide but displays around 1280, so everything
+ // is upscaled ~1.8x — baking the orb at 2x and drawing it back down
+ // keeps the silhouette crisp instead of magnifying 192 source pixels.
const orbSize = 192;
+ const ORB_SUPERSAMPLE = 2;
// Three orbs with different base colors. Each generation creates
// its own color canvas (per-orb tint) but they all share the same
@@ -115,22 +281,71 @@ class PlayScreen extends Stage {
edge: "rgba(0, 10, 60, 0)",
},
];
+ // The wall goes in FIRST so it sits behind the orbs. It is a lit
+ // sprite like any other — same `normalMap` setting, same two lights —
+ // which is the point: nothing about this lighting is specific to the
+ // orbs. Its `shininess` is low, because plaster is not polished; the
+ // mortar is masked out entirely by the normal map's alpha.
+ const wall = generateWall(game.viewport.width, game.viewport.height);
+ const backdrop = new Sprite(
+ game.viewport.width / 2,
+ game.viewport.height / 2,
+ {
+ image: wall.colorCanvas,
+ normalMap: wall.normalCanvas,
+ shininess: 12,
+ anchorPoint: { x: 0.5, y: 0.5 },
+ },
+ );
+ game.world.addChild(backdrop);
+
const yMid = game.viewport.height / 2;
const xs = [
game.viewport.width * 0.25,
game.viewport.width * 0.5,
game.viewport.width * 0.75,
];
+ // The normal map gives every orb its SHAPE — brightness following the
+ // surface angle — and `shininess` decides how it SHINES. Three points
+ // on the exponent's range: a broad wash, a polished sheen, a pinpoint
+ // glint. Watch them as the light orbits — each highlight slides across
+ // its surface, because it only appears where a texel happens to
+ // reflect the light back at the screen, and the tighter the exponent
+ // the further it travels for the same movement. That is what separates
+ // specular from plain diffuse, which merely brightens and dims.
+ // `shininess` defaults to 0 (matte), so a sprite that never sets it is
+ // unaffected.
+ const shininess = [16, 64, 200];
+ const labels = ["broad wash", "polished, scuffed", "mirror glint"];
for (let i = 0; i < xs.length; i++) {
- const { colorCanvas, normalCanvas } = generateOrb(orbSize, palette[i]);
+ const texSize = orbSize * ORB_SUPERSAMPLE;
+ const { colorCanvas, normalCanvas } = generateOrb(
+ texSize,
+ palette[i],
+ i === 1,
+ );
const orb = new Sprite(xs[i], yMid, {
image: colorCanvas,
- framewidth: orbSize,
- frameheight: orbSize,
+ framewidth: texSize,
+ frameheight: texSize,
normalMap: normalCanvas,
+ shininess: shininess[i],
anchorPoint: { x: 0.5, y: 0.5 },
});
+ // drawn back down to the world size — the extra pixels buy edge
+ // quality, not a bigger orb
+ orb.scale(1 / ORB_SUPERSAMPLE);
game.world.addChild(orb);
+
+ const caption = new Text(xs[i], yMid + orbSize * 0.62, {
+ font: "Arial",
+ size: "15px",
+ fillStyle: "#e8e8f0",
+ textAlign: "center",
+ text: `${labels[i]}\nshininess: ${shininess[i]}`,
+ });
+ caption.setOpacity(0.85);
+ game.world.addChild(caption);
}
// ambient floor — without it the unlit hemispheres of each orb
@@ -159,6 +374,45 @@ class PlayScreen extends Stage {
input.registerPointerEvent("pointermove", game.viewport, (event) => {
cursor.centerOn(event.gameX, event.gameY);
});
+
+ // A second light, warm and on its own orbit. Specular accumulates PER
+ // LIGHT and takes each light's own colour, so every orb carries two
+ // highlights at once — one white, one amber — sliding independently as
+ // the two sources move. It is also what keeps the scene alive before
+ // the pointer is touched.
+ const lamp = new Light2d(
+ game.viewport.width / 2,
+ game.viewport.height / 2,
+ 620,
+ 620,
+ "#ffb266",
+ 1.2,
+ );
+ lamp.illuminationOnly = true;
+ game.world.addChild(lamp);
+
+ // A renderable that draws nothing and only advances the lamp — the
+ // same shape the 3D examples use for their turntables.
+ class LampOrbit extends Renderable {
+ elapsed = 0;
+
+ constructor() {
+ super(0, 0, 1, 1);
+ this.alwaysUpdate = true;
+ }
+
+ override update(dt: number) {
+ this.elapsed += dt;
+ const t = this.elapsed * 0.0009;
+ lamp.centerOn(
+ game.viewport.width / 2 + Math.cos(t) * game.viewport.width * 0.36,
+ game.viewport.height / 2 +
+ Math.sin(t * 1.3) * game.viewport.height * 0.3,
+ );
+ return true;
+ }
+ }
+ game.world.addChild(new LampOrbit());
}
onDestroyEvent() {
diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx
index 979820912..68a245d0b 100644
--- a/packages/examples/src/main.tsx
+++ b/packages/examples/src/main.tsx
@@ -410,7 +410,7 @@ const examples: {
path: "normal-map",
sourceDir: "normalMap",
description:
- "Per-pixel sprite lighting from normal maps. Three procedurally-generated orbs (red, green, blue base colors paired with a sphere normal map) react to a moving Light2d.",
+ "Per-pixel sprite lighting from normal maps, with specular highlights. Three procedurally-generated orbs at different shininess, on a stone wall, lit by two moving Light2d sources.",
},
{
component: ,
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index a6476dab7..5a9186f10 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -3,12 +3,14 @@
## [20.7.0] (melonJS 2) - _unreleased_
### Added
+- Sprite: `shininess` gives a normal-mapped sprite a specular highlight, one that slides across the surface as a light moves past rather than the whole sprite merely brightening, in each light's own colour. Gated on the exponent as MTL `Ns` is, so `0` is matte and every existing sprite renders exactly as before; the normal map's alpha masks it per texel, and the Canvas renderer ignores it
- Mesh: a tangent-space normal map gives a lit surface its relief. MTL `map_bump`, `map_Bump`, `bump` and `norm` are loaded and applied per material, `settings.normalMap` sets one directly, and the tangent frame is derived per fragment on both backends, so no tangent vertex attribute is needed. A map line's option flags (`-bm`, `-s`, `-clamp` and the rest) are stripped rather than read as part of the filename, which is what an exporter's `map_Bump -bm 1.000000 rock-normal.png` needs ([#1574](https://github.com/melonjs/melonJS/issues/1574))
### Changed
- Loader: `load()` no longer names asset types while resolving a `src`. A type whose `src` is not a bare path declares `normalizeSrc` and `needsBaseURL` instead, which is how `fontface` unwraps a `url(...)` descriptor before the base URL goes on, and leaves an installed `local()` family alone ([#1648](https://github.com/melonjs/melonJS/issues/1648), thanks @ICOM725)
### Fixed
+- Mesh: `lit: true` under a `Camera2d` says so instead of silently doing nothing. Lighting a mesh needs world-space normals and a world-space fragment position, and that path has neither, since its vertices are already the projected output, so the mesh degrades to unlit and now warns once, naming `Camera3d` as the way to light it ([#1576](https://github.com/melonjs/melonJS/issues/1576))
- Trigger: a trigger targeting a glTF level forwards the scene options it was given. `scale`, `rightHanded`, `lights`, `lightIntensityScale`, `castGroundShadow` and `shadowGroundY` were dropped before `level.load()` saw them, so a Tiled-authored trigger loaded its scene at the default scale and handedness whatever the map said ([#1649](https://github.com/melonjs/melonJS/issues/1649), thanks @ICOM725)
- Body: rotation pivoted about the wrong point for any renderable away from the world origin. `body.bounds` is already renderable-local and the pivot subtracted `renderable.pos` from it a second time, so a 40x40 body on a renderable at `(100, 50)` turned about `(-80, -30)` rather than its own centre
- `Body.rotate()` no longer throws on a `Box3d` or `Point` shape, neither of which can rotate. Such a shape keeps its orientation and still contributes its bounds
diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md
index faea31bd0..e6aed93b9 100644
--- a/packages/melonjs/skills/melonjs-3d/SKILL.md
+++ b/packages/melonjs/skills/melonjs-3d/SKILL.md
@@ -527,6 +527,14 @@ and spot, a stylised quadratic falloff, not inverse-square),
**`Light2d` is 2D-only** and produces visible artifacts under perspective
projection. Do not combine it with `Camera3d`.
+**`lit: true` only does anything under a `Camera3d`.** Lighting a mesh needs
+world-space normals and a world-space fragment position, and the 2D-camera path
+has neither: its vertices are already the projected output, so there is no world
+space left to light in. Such a mesh degrades to unlit and warns once, naming the
+fix. If you want lit 3D props in an otherwise 2D game, set
+`cameraClass: Camera3d` on the application. (For 2D **sprites**, lighting is a
+separate system that works under a 2D camera as normal, see the lighting skill.)
+
A `lit` mesh takes a **tangent-space normal map**, so relief comes from the
material rather than from geometry — `settings.normalMap` (a loader key or any
image-like source), or MTL `map_bump` / `bump` / `norm`, which the MTL loader
diff --git a/packages/melonjs/skills/melonjs-lighting/SKILL.md b/packages/melonjs/skills/melonjs-lighting/SKILL.md
index bfd71de88..48e600b22 100644
--- a/packages/melonjs/skills/melonjs-lighting/SKILL.md
+++ b/packages/melonjs/skills/melonjs-lighting/SKILL.md
@@ -1,6 +1,6 @@
---
name: melonjs-lighting
-description: "Use this skill for 2D dynamic lighting in melonJS — Light2d, ambient light on a Stage, multiple lights, and per-pixel normal-mapped sprites authored in SpriteIlluminator. Covers the ambient-cutout model, which parts need a GPU backend, and why Light2d does not survive a Camera3d. Triggers on: Light2d, lighting, ambientLight, ambientLightingColor, normalMap, SpriteIlluminator, per-pixel lighting, drawLight, torch, glow, darkness."
+description: "Use this skill for 2D dynamic lighting in melonJS — Light2d, ambient light on a Stage, multiple lights, and per-pixel normal-mapped sprites authored in SpriteIlluminator. Covers specular highlights via shininess, the ambient-cutout model, which parts need a GPU backend, and why Light2d does not survive a Camera3d. Triggers on: Light2d, specular, shininess, highlight, glint, lighting, ambientLight, ambientLightingColor, normalMap, SpriteIlluminator, per-pixel lighting, drawLight, torch, glow, darkness."
license: MIT
---
@@ -83,6 +83,50 @@ atlas can carry one too: `new TextureAtlas(json, image, { normalMap })` pairs a
normal texture sharing the colour texture's UVs, and a `Sprite` built from that
atlas picks it up in preference to its own `settings.normalMap`.
+## Specular highlights
+
+A normal map gives a sprite **shape**: the light knows which way each texel
+faces, so a torch reveals bumps and crevices. `shininess` decides whether it
+also **shines**:
+
+```js
+const sword = new Sprite(x, y, {
+ image: "sword",
+ normalMap: "sword_n",
+ shininess: 64, // 0 (the default) is matte
+});
+```
+
+The difference is what the highlight *does*. Diffuse brightness depends only on
+the angle between the surface and the light, so a torch moving past just makes
+the sprite brighter. A specular highlight only appears where a texel reflects
+the light **toward the screen**, so it slides across the surface as the light
+moves: armour that glints as you walk by, rather than armour that is merely lit.
+
+Low values give a broad sheen (worn metal, wet stone); high values a tight glint
+(polished steel, glass). It is gated on the exponent exactly as MTL `Ns` is on
+the 3D path, so `0` means matte however bright the scene, and a sprite that
+never sets it renders exactly as it always did.
+
+Three things worth knowing:
+
+- **It needs a `normalMap`.** With no surface directions there is nothing to
+ reflect, and the sprite stays matte whatever `shininess` says.
+- **The normal map's alpha channel masks it per texel.** 255 is fully
+ reflective, 0 is dead matte, so one sprite can be a shiny blade with a matte
+ leather grip. A normal map with no alpha detail (the usual case) shines
+ uniformly. Note the corollary: if your normal map already uses alpha for
+ something, that channel now also decides gloss.
+- **It accumulates per light.** Each light contributes its own highlight in its
+ own colour, so a sprite between a warm lamp and a cool one carries two
+ distinct glints that move independently.
+
+The highlight takes the light's own colour and is *added* on top of the lit
+result rather than multiplied into it, which is why a glint blows out toward
+white on a dark sprite instead of tinting with it.
+
+See it running: [Normal Map example](https://melonjs.github.io/melonJS/examples/#/normal-map).
+
Unlit areas of a normal-mapped sprite render pure black unless you raise
`Stage.ambientLightingColor` (default black) — that is the base level added to
every lit pixel, and it is a different knob from `Stage.ambientLight`.
diff --git a/packages/melonjs/src/lighting/light2d.ts b/packages/melonjs/src/lighting/light2d.ts
index 99d4651a3..13cc94753 100644
--- a/packages/melonjs/src/lighting/light2d.ts
+++ b/packages/melonjs/src/lighting/light2d.ts
@@ -26,11 +26,60 @@ import type WebGLRenderer from "../video/webgl/webgl_renderer.js";
* allocation, no renderer reference held.
* @category Lighting
* @see stage.lights
+ * @see [Lights example](https://melonjs.github.io/melonJS/examples/#/lights) — several coloured lights over a night scene
+ * @see [Normal Map example](https://melonjs.github.io/melonJS/examples/#/normal-map) — per-pixel lighting and specular highlights
+ * @see [SpriteIlluminator example](https://melonjs.github.io/melonJS/examples/#/sprite-illuminator) — an authored normal-map workflow
+ * @example
+ * // A soft glowing spot, the simplest case. Add it like any renderable;
+ * // it registers itself with the active Stage on activation.
+ * const torch = new Light2d(x, y, 160, 160, "#ffcc88", 0.9);
+ * app.world.addChild(torch);
+ *
+ * // darkness for it to cut through (on your Stage)
+ * this.ambientLight.parseCSS("#000000d0");
+ * @example
+ * // Per-pixel lighting: the sprite reacts to the light's DIRECTION, not
+ * // just its distance, because the normal map says which way each texel
+ * // faces. `shininess` adds a highlight that slides as the light moves.
+ * await loader.preload([
+ * { name: "crate", type: "image", src: "data/img/crate.png" },
+ * { name: "crate_n", type: "image", src: "data/img/crate_n.png" },
+ * ]);
+ *
+ * const crate = new Sprite(x, y, {
+ * image: "crate",
+ * normalMap: "crate_n", // enables per-pixel lighting on this sprite
+ * shininess: 48, // 0 (the default) is matte, no highlight
+ * });
+ * app.world.addChild(crate);
+ *
+ * const lamp = new Light2d(x, y, 300, 300, "#ffffff", 1.2);
+ * // the light itself stays invisible — only its EFFECT on the crate shows
+ * lamp.illuminationOnly = true;
+ * app.world.addChild(lamp);
+ *
+ * // unlit areas are pure black without this (on your Stage)
+ * this.ambientLightingColor.setColor(40, 40, 50);
+ * @example
+ * // Several lights at once: each contributes its own diffuse shading AND
+ * // its own coloured highlight, so a normal-mapped sprite between a warm
+ * // and a cool source carries two distinct glints.
+ * app.world.addChild(new Light2d(200, 150, 260, 260, "#ffb266", 1.0));
+ * app.world.addChild(new Light2d(520, 320, 260, 260, "#66b2ff", 1.0));
+ * @example
+ * // Parented to a renderable, so it follows through the transform chain
+ * const player = new Sprite(x, y, { image: "player" });
+ * const halo = new Light2d(0, 0, 120, 120, "#fff2cc", 0.8);
+ * player.addChild(halo);
*/
export default class Light2d extends Renderable {
/**
- * the color of the light
+ * the color of the light. A normal-mapped sprite's diffuse shading AND
+ * its specular highlight both take this colour, so a warm lamp gives a
+ * warm glint.
* @default "#FFF"
+ * @example
+ * light.color.parseCSS("#ff8844");
*/
color: Color;
@@ -41,8 +90,12 @@ export default class Light2d extends Renderable {
radiusY: number;
/**
- * The intensity of the light
+ * The intensity of the light. Scales both the gradient's inner alpha and
+ * the per-pixel shading a normal-mapped sprite receives from it.
* @default 0.7
+ * @example
+ * // pulse a torch
+ * torch.intensity = 0.8 + Math.sin(time * 0.01) * 0.15;
*/
intensity: number;
@@ -64,6 +117,12 @@ export default class Light2d extends Renderable {
*
* Default `false`, preserving the legacy "soft glowing spot" behavior.
* @default false
+ * @example
+ * // a logical light source: it shades normal-mapped sprites but draws
+ * // no glow of its own, so you see the effect and not the lamp
+ * const sun = new Light2d(x, y, 900, 900, "#ffffff", 1.5);
+ * sun.illuminationOnly = true;
+ * app.world.addChild(sun);
*/
illuminationOnly: boolean;
@@ -81,6 +140,13 @@ export default class Light2d extends Renderable {
*
* Named `lightHeight` (not just `height`) to avoid colliding with the
* bbox-height getter Light2d inherits from `Rect`.
+ * @example
+ * // grazing light: long shadows across the normal map's detail, good
+ * // for showing off surface relief
+ * light.lightHeight = light.radiusX * 0.02;
+ *
+ * // head-on: flatter, more even coverage of the lit hemisphere
+ * light.lightHeight = light.radiusX * 0.4;
*/
lightHeight: number;
diff --git a/packages/melonjs/src/lighting/light3d.ts b/packages/melonjs/src/lighting/light3d.ts
index ef83e0839..5496b28d9 100644
--- a/packages/melonjs/src/lighting/light3d.ts
+++ b/packages/melonjs/src/lighting/light3d.ts
@@ -85,6 +85,9 @@ export interface Light3dOptions {
* (e.g. a day/night cycle rotating `direction`, a flickering torch fading
* `intensity`, a searchlight sweeping its cone).
* @category Lighting
+ * @see [Material Textures example](https://melonjs.github.io/melonJS/examples/#/material-textures) — a key light plus ambient over MTL materials
+ * @see [Night City example](https://melonjs.github.io/melonJS/examples/#/night-city) — many lights across an instanced scene
+ * @see {@link Light2d} for the 2D sprite equivalent, which is a separate system
* @example
* import { Light3d } from "melonjs";
*
diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js
index 2d342bd4a..1a9dbaa51 100644
--- a/packages/melonjs/src/renderable/mesh.js
+++ b/packages/melonjs/src/renderable/mesh.js
@@ -19,6 +19,12 @@ import Texture2d from "./../video/texture/texture2d.ts";
import { getShadowQuad, hasVerticalExtent } from "./groundshadow.js";
import Renderable from "./renderable.js";
+// Fired at most once per session: a `lit` mesh drawn under a 2D camera cannot
+// be lit, and saying so every frame would bury the rest of the console. Module
+// scope rather than per-instance, matching `_warnedNoGpuTileSupportOnce` in
+// TMXLayer — the message is about the scene's setup, not about one mesh.
+let _warnedLitUnder2dOnce = false;
+
/**
* additional import for TypeScript
* @import CanvasRenderer from "./../video/canvas/canvas_renderer.js";
@@ -362,7 +368,7 @@ function buildTextureGroups(
* @property {number[]|Float32Array} [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.
* @property {number[]|Float32Array} [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.
* @property {number} [shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface.
- * @property {string|TextureAtlas|HTMLImageElement} [normalMap] - tangent-space normal map (MTL `map_bump`/`bump`/`norm`), perturbing the lit path's shading normal per fragment. Needs `lit` to have any effect.
+ * @property {string|TextureAtlas|HTMLImageElement} [normalMap] - tangent-space normal map (MTL `map_bump`/`bump`/`norm`), perturbing the lit path's shading normal per fragment. Needs `lit` to have any effect, and a `Camera3d`. See the [Material Textures example](https://melonjs.github.io/melonJS/examples/#/material-textures).
* @property {string|TextureAtlas|HTMLImageElement} [alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`).
* @property {boolean} [castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`.
* @property {boolean} [transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded
@@ -2131,6 +2137,28 @@ export default class Mesh extends Renderable {
// get culled, leaving the model looking inside-out.
this.indices = this._indicesOriginal;
this._projectVertices(this.pos.x, this.pos.y, 1000);
+ // `lit` is a no-op here, and silently so — the lit shaders need
+ // world-space normals and a world-space fragment position, and this
+ // path has neither: its vertices ARE the projected output, so there
+ // is no world space left to light in. The mesh degrades to unlit,
+ // which is the model without shading rather than a hole, but a flag
+ // that quietly does nothing is worth one line in the console.
+ // A `Camera3d` is the supported way to light a mesh (#1576).
+ // `_useWorldSpace` is what the app's OWN camera resolved to, while
+ // the branch we are in reflects the camera of THIS pass. A scene
+ // with a Camera3d main view and a Camera2d minimap is supported and
+ // documented, and its mesh really is lit — on the pass that counts.
+ // Nagging it would be wrong, and its remedy is already in place.
+ if (
+ this.lit === true &&
+ this._useWorldSpace !== true &&
+ _warnedLitUnder2dOnce === false
+ ) {
+ _warnedLitUnder2dOnce = true;
+ console.warn(
+ "melonJS: `lit: true` has no effect under a Camera2d — a lit mesh needs world-space normals, which only the 3D path writes. Set `cameraClass: Camera3d` in the application settings to light it, or `lit: false` to silence this.",
+ );
+ }
}
renderer.drawMesh(this);
}
diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js
index ba5bc55ef..d051207fa 100644
--- a/packages/melonjs/src/renderable/sprite.js
+++ b/packages/melonjs/src/renderable/sprite.js
@@ -37,6 +37,7 @@ export default class Sprite extends Renderable {
* @param {number} [settings.flipY] - flip the sprite on the vertical axis
* @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame). Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. For spritesheet atlases the anchor also becomes the cached atlas's per-frame pivot (see {@link TextureAtlas}).
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap|Texture2d|string} [settings.normalMap] - optional normal-map texture used for per-pixel lighting (SpriteIlluminator-style). Same layout/UVs as `settings.image`. When omitted (default), the sprite renders unlit and pays no extra cost. Ignored by the Canvas renderer. Note: `HTMLVideoElement` is intentionally not supported — normal maps encode static surface directions in RGB, and the engine caches the GL texture per image reference (a video would freeze on frame 0).
+ * @param {number} [settings.shininess=0] - specular exponent: how tight this sprite's highlight is, or 0 for none. Needs a `normalMap` — see {@link Sprite#shininess}
* @example
* // create a single sprite from a standalone image, with anchor in the center
* let sprite = new me.Sprite(0, 0, {
@@ -128,6 +129,58 @@ export default class Sprite extends Renderable {
*/
this._normalMap = null;
+ /**
+ * How tight this sprite's specular highlight is, or `0` (the default)
+ * for none.
+ *
+ * A normal map tells the light which way each texel FACES, which gives
+ * a sprite shape; this decides whether it also SHINES. The highlight
+ * only appears where a texel happens to reflect a light toward the
+ * screen, so it slides across the surface as the light moves, rather
+ * than the whole sprite merely brightening — the difference between
+ * armour that glints as you walk past and armour that is just lit.
+ *
+ * Low values give a broad sheen (worn metal, wet stone), high values a
+ * tight glint (polished steel, glass). Gated on the exponent exactly
+ * as MTL `Ns` and {@link Mesh#shininess} are, so `0` means "matte"
+ * however bright the scene is, and every existing sprite is unchanged.
+ *
+ * **Needs a `normalMap`**: with no surface directions there is nothing
+ * to reflect, and the sprite stays matte whatever this is set to. The
+ * normal map's ALPHA channel masks it per texel, so one sprite can
+ * have a shiny blade and a matte leather grip; a normal map with no
+ * alpha detail (the usual case) shines uniformly.
+ *
+ * Ignored by the Canvas renderer.
+ * @type {number}
+ * @default 0
+ * @see [Normal Map example](https://melonjs.github.io/melonJS/examples/#/normal-map) — three orbs across the
+ * exponent's range, on a normal-mapped wall, lit by two moving lights
+ * @example
+ * // a polished blade: the highlight slides across it as a torch
+ * // moves past, instead of the sprite merely brightening
+ * const sword = new Sprite(x, y, {
+ * image: "sword",
+ * normalMap: "sword_n",
+ * shininess: 64, // a tight, metallic glint
+ * });
+ * @example
+ * // masked per texel: the normal map's ALPHA channel decides where
+ * // the sprite is glossy, so one sprite can be a shiny blade with a
+ * // matte leather grip. 255 = fully reflective, 0 = dead matte.
+ * const nm = document.createElement("canvas");
+ * // ...draw the normals into RGB, the gloss mask into A...
+ * const axe = new Sprite(x, y, {
+ * image: "axe",
+ * normalMap: nm,
+ * shininess: 96,
+ * });
+ * @example
+ * // matte again at runtime
+ * axe.shininess = 0;
+ */
+ this.shininess = 0;
+
/**
* flicker settings
* @ignore
@@ -307,6 +360,12 @@ export default class Sprite extends Renderable {
}
}
+ // the specular exponent belongs with the map it depends on — it does
+ // nothing without one (see Sprite#shininess)
+ if (typeof settings.shininess === "number") {
+ this.shininess = settings.shininess;
+ }
+
// store/reset the current atlas information if specified
if (typeof settings.atlas !== "undefined") {
this.textureAtlas = settings.atlas;
@@ -430,6 +489,23 @@ export default class Sprite extends Renderable {
*
* Silently ignored by the Canvas renderer.
* @type {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap|null}
+ * @see {@link Sprite#shininess} to add a specular highlight on top
+ * @see [Normal Map example](https://melonjs.github.io/melonJS/examples/#/normal-map)
+ * @see [SpriteIlluminator example](https://melonjs.github.io/melonJS/examples/#/sprite-illuminator)
+ * @example
+ * // the sidecar pattern: one `_n` image beside each diffuse image
+ * await loader.preload([
+ * { name: "hero", type: "image", src: "data/img/hero.png" },
+ * { name: "hero_n", type: "image", src: "data/img/hero_n.png" },
+ * ]);
+ * const hero = new Sprite(x, y, { image: "hero", normalMap: "hero_n" });
+ *
+ * // ...or assigned later, from any image-like source
+ * hero.normalMap = loader.getImage("hero_n");
+ *
+ * // an atlas can carry one for every region it packs, which a Sprite
+ * // built from that atlas picks up in preference to its own setting
+ * const atlas = new TextureAtlas(json, image, { normalMap });
*/
get normalMap() {
return this._normalMap;
@@ -833,6 +909,9 @@ export default class Sprite extends Renderable {
// `currentNormalMap` from the renderer; Canvas ignores it.
if (this._normalMap !== null) {
renderer.currentNormalMap = this._normalMap;
+ // travels with the map, because it is meaningless without one:
+ // no surface directions, nothing to reflect
+ renderer.currentShininess = this.shininess;
}
}
@@ -844,6 +923,7 @@ export default class Sprite extends Renderable {
// Clear the slot so a subsequent un-lit sprite isn't accidentally lit.
if (this._normalMap !== null) {
renderer.currentNormalMap = null;
+ renderer.currentShininess = 0;
}
super.postDraw(renderer);
}
diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts
index ed3f71cfd..ef476b1a5 100644
--- a/packages/melonjs/src/state/stage.ts
+++ b/packages/melonjs/src/state/stage.ts
@@ -97,6 +97,17 @@ export default class Stage {
* returns early and the lights still draw as additive glows.
* @default rgba(0,0,0,0)
* @see Light2d
+ * @see {@link Stage#ambientLightingColor} for the other, easily confused knob
+ * @see [Lights example](https://melonjs.github.io/melonJS/examples/#/lights)
+ * @example
+ * class PlayScene extends Stage {
+ * onResetEvent() {
+ * // near-black night. The alpha is what turns the pass ON:
+ * // leave it at 0 and lights still glow, but nothing darkens
+ * this.ambientLight.parseCSS("#0a0a1ee0");
+ * this.addChild(new Light2d(x, y, 200, 200, "#ffcc88", 1));
+ * }
+ * }
*/
ambientLight: Color;
@@ -108,6 +119,17 @@ export default class Stage {
* black. Defaults to black (0, 0, 0) — sprites without a
* `normalMap` ignore it entirely.
* @default "#000000"
+ * @see {@link Stage#ambientLight} for the darkness overlay, which is a
+ * different knob with a confusingly similar name
+ * @see [Normal Map example](https://melonjs.github.io/melonJS/examples/#/normal-map)
+ * @example
+ * class PlayScene extends Stage {
+ * onResetEvent() {
+ * // without this the hemisphere facing away from every light
+ * // renders pure black, which reads as a hole in the sprite
+ * this.ambientLightingColor.setColor(60, 60, 70);
+ * }
+ * }
*/
ambientLightingColor: Color;
diff --git a/packages/melonjs/src/video/buffer/vertex.js b/packages/melonjs/src/video/buffer/vertex.js
index bc75be60f..bf54d37f7 100644
--- a/packages/melonjs/src/video/buffer/vertex.js
+++ b/packages/melonjs/src/video/buffer/vertex.js
@@ -62,10 +62,16 @@ export default class VertexArrayBuffer {
* @param {number} tint - tint color in UINT32 (argb) format
* @param {number} [textureId] - texture unit index for multi-texture batching
* @param {number} [normalTextureId] - paired normal-map texture unit index, or `-1` for unlit quads
+ * @param {number} [shininess] - specular exponent for the GL lit-quad layout,
+ * which is 9 floats; 0 (matte) when omitted. NOTE the WebGPU lit layout is 8
+ * floats and carries its exponent in slot 7 instead, the slot whose omitted
+ * default here is the `-1` unlit sentinel — that batcher therefore always
+ * passes an explicit value through its own `pushQuadVertices` override and
+ * never relies on this default.
* @ignore
* @internal
*/
- push(x, y, z, u, v, tint, textureId, normalTextureId) {
+ push(x, y, z, u, v, tint, textureId, normalTextureId, shininess) {
const offset = this.vertexCount * this.vertexSize;
this.bufferF32[offset] = x;
@@ -85,6 +91,15 @@ export default class VertexArrayBuffer {
// shading on every sprite.
this.bufferF32[offset + 7] =
typeof normalTextureId === "number" ? normalTextureId : -1;
+ if (this.vertexSize > 8) {
+ // `aShininess` on the GL lit-quad layout, which carries
+ // both the normal-map slot and the exponent. 0 is matte,
+ // and matte is what every sprite that never opts in must
+ // get — so the fallback here is 0, not the -1 sentinel
+ // above (a negative exponent would make `pow` explode).
+ this.bufferF32[offset + 8] =
+ typeof shininess === "number" ? shininess : 0;
+ }
}
}
diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js
index 7672dde61..adc1f00c3 100644
--- a/packages/melonjs/src/video/renderer.js
+++ b/packages/melonjs/src/video/renderer.js
@@ -260,6 +260,16 @@ export default class Renderer {
*/
this.currentNormalMap = null;
+ /**
+ * Specular exponent for the sprite currently being drawn, paired with
+ * `currentNormalMap` and cleared with it. `0` is "matte", which is
+ * every sprite that does not opt in.
+ * @type {number}
+ * @ignore
+ * @internal
+ */
+ this.currentShininess = 0;
+
/**
* Number of active `Light2d` instances uploaded to the lit batcher
* for the current frame. Set by `setLightUniforms`. The WebGL
diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
index 1ee48d828..53d0a444b 100644
--- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
@@ -20,7 +20,8 @@ import QuadBatcher from "./quad_batcher.js";
/**
* Lit-aware variant of `QuadBatcher` for the SpriteIlluminator workflow.
*
- * Adds a 5th vertex attribute (`aNormalTextureId`) so each quad knows
+ * Adds a 5th and 6th vertex attribute (`aNormalTextureId`, `aShininess`)
+ * so each quad knows
* which slot its normal map occupies, and owns the per-frame
* `Light2dBlock` uniform buffer that the lit fragment shader iterates.
*
@@ -56,7 +57,7 @@ export default class LitQuadBatcher extends QuadBatcher {
attributes: [
{
// vec3: (x, y, z). z carries `renderable.depth` for
- // perspective projection (Camera3d). Stride = 32 bytes.
+ // perspective projection (Camera3d). Stride = 36 bytes.
name: "aVertex",
format: "float32x3",
offset: 0 * Float32Array.BYTES_PER_ELEMENT,
@@ -81,6 +82,19 @@ export default class LitQuadBatcher extends QuadBatcher {
format: "float32",
offset: 7 * Float32Array.BYTES_PER_ELEMENT,
},
+ {
+ // Specular exponent, per QUAD rather than per normal-map
+ // slot. A uniform array indexed by the slot would be
+ // tighter on bandwidth, but uniform arrays are charged
+ // against `MAX_FRAGMENT_UNIFORM_VECTORS` — the scarcity
+ // that forced the light data into a UBO — and it would
+ // make two sprites sharing one normal map at different
+ // exponents fight over the slot. 4 bytes a vertex, on the
+ // lit path only, buys both problems away.
+ name: "aShininess",
+ format: "float32",
+ offset: 8 * Float32Array.BYTES_PER_ELEMENT,
+ },
],
shader: {
vertex: quadMultiLitVertex,
@@ -591,6 +605,10 @@ export default class LitQuadBatcher extends QuadBatcher {
}
let normalTextureId = -1;
+ // Meaningless without a normal map — there are no surface directions
+ // to reflect — so it rides on the same condition and stays 0 whenever
+ // the quad falls back to the unlit sentinel below.
+ let shininess = 0;
if (normalMap !== null && this.useMultiTexture) {
const epoch = this._cacheEpoch;
normalTextureId = this.resolveNormalUnit(normalMap);
@@ -610,6 +628,9 @@ export default class LitQuadBatcher extends QuadBatcher {
// flat shading is wrong, but visibly and recoverably so.
normalTextureId = -1;
}
+ if (normalTextureId >= 0) {
+ shininess = this.renderer.currentShininess || 0;
+ }
}
// Stamp per-sprite depth onto z BEFORE the transform — see
@@ -633,6 +654,7 @@ export default class LitQuadBatcher extends QuadBatcher {
tint,
textureId,
normalTextureId,
+ shininess,
);
vertexData.push(
vec1.x,
@@ -643,6 +665,7 @@ export default class LitQuadBatcher extends QuadBatcher {
tint,
textureId,
normalTextureId,
+ shininess,
);
vertexData.push(
vec2.x,
@@ -653,6 +676,7 @@ export default class LitQuadBatcher extends QuadBatcher {
tint,
textureId,
normalTextureId,
+ shininess,
);
vertexData.push(
vec3.x,
@@ -663,6 +687,7 @@ export default class LitQuadBatcher extends QuadBatcher {
tint,
textureId,
normalTextureId,
+ shininess,
);
}
@@ -712,10 +737,11 @@ export default class LitQuadBatcher extends QuadBatcher {
// blits are always rendered at z = 0 (screen-space, ortho)
const tint = 0xffffffff;
- this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0, -1);
- this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0, -1);
- this.vertexData.push(vec2.x, vec2.y, 0, 0, 0, tint, 0, -1);
- this.vertexData.push(vec3.x, vec3.y, 0, 1, 0, tint, 0, -1);
+ // trailing 0 is `aShininess`: a blit carries no material
+ this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0, -1, 0);
+ this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0, -1, 0);
+ this.vertexData.push(vec2.x, vec2.y, 0, 0, 0, tint, 0, -1, 0);
+ this.vertexData.push(vec3.x, vec3.y, 0, 1, 0, tint, 0, -1, 0);
this.flush();
diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js
index 13e6fab93..81a9a4bf2 100644
--- a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js
+++ b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js
@@ -94,6 +94,7 @@ export function buildLitMultiTextureFragment(maxTextures) {
lines.push("in vec2 vRegion;");
lines.push("in float vTextureId;");
lines.push("in float vNormalTextureId;");
+ lines.push("in float vShininess;");
lines.push("in vec2 vWorldPos;");
lines.push("out vec4 fragColor;");
lines.push("");
@@ -129,6 +130,17 @@ export function buildLitMultiTextureFragment(maxTextures) {
lines.push(" normal.y = -normal.y;");
lines.push(" vec3 lighting = uAmbient;");
+ // Specular accumulates SEPARATELY from diffuse, because it is light
+ // reflected OFF the surface rather than the surface's own colour lit up:
+ // it is added at the end, not multiplied into the albedo, which is why a
+ // glint blows out to white on a dark sprite.
+ lines.push(" vec3 specular = vec3(0.0);");
+ // Per-texel mask from the normal map's ALPHA. Free — the sample is
+ // already taken — and safe, because normal maps upload with
+ // premultiplied alpha OFF (multiplying through alpha would corrupt the
+ // encoding), so the channel survives intact. A map authored without one
+ // is opaque, which reads as "uniformly shiny" and is the sensible default.
+ lines.push(" float specMask = normalSample.a;");
// ES 3.00 permits a non-constant bound, so this runs exactly as many
// iterations as there are live lights — unused capacity costs nothing
lines.push(" int count = min(int(uLightCount), " + MAX_LIGHTS + ");");
@@ -147,9 +159,38 @@ export function buildLitMultiTextureFragment(maxTextures) {
lines.push(
" lighting += uLights[i].colorHeight.rgb * (lp.w * att * NdotL);",
);
+ // Blinn-Phong, gated on the exponent exactly as the mesh path gates on
+ // `Ns`: zero means matte however bright the light is, so every sprite
+ // that never opts in runs the same maths it always did and the branch is
+ // uniform across a quad (vShininess is flat per quad).
+ lines.push(" if (vShininess > 0.0) {");
+ // The view vector is a CONSTANT here, which is what makes this sound in
+ // 2D where it is not on the mesh-under-2D-camera path: a sprite lies in
+ // the screen plane and the camera looks straight down -Z at it, so there
+ // is no per-fragment world position to get wrong.
+ lines.push(
+ " vec3 halfVec = normalize(lightDir + vec3(0.0, 0.0, 1.0));",
+ );
+ lines.push(" float NdotH = max(0.0, dot(normal, halfVec));");
+ // `NdotL > 0` gates it on the surface actually facing the light: without
+ // that, a back-facing texel whose half-vector happens to line up picks up
+ // a highlight from a light behind it.
+ lines.push(" float facing = step(0.0001, NdotL);");
+ lines.push(
+ " specular += uLights[i].colorHeight.rgb * (lp.w * att * facing * specMask * pow(NdotH, vShininess));",
+ );
+ lines.push(" }");
lines.push(" }");
- lines.push(" fragColor = vec4(color.rgb * lighting, color.a) * vColor;");
+ // `specular * color.a`, because the pipeline is PREMULTIPLIED: `color.rgb`
+ // already carries its own alpha, so the diffuse term self-cancels where the
+ // sprite is transparent, but an added highlight would not. A normal map is
+ // usually fully opaque even where its diffuse is cut away (every
+ // SpriteIlluminator export is), so without this a shiny sprite paints an
+ // additive glow across its own cut-out.
+ lines.push(
+ " fragColor = vec4(color.rgb * lighting + specular * color.a, color.a) * vColor;",
+ );
lines.push("}");
return lines.join("\n");
diff --git a/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert b/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert
index 952df0ff1..16c098781 100644
--- a/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert
+++ b/packages/melonjs/src/video/webgl/shaders/quad-multi-lit.vert
@@ -10,6 +10,9 @@ in vec2 aRegion;
in vec4 aColor;
in float aTextureId;
in float aNormalTextureId;
+// specular exponent for this quad, 0 = matte (the default for every sprite
+// that does not opt in)
+in float aShininess;
uniform mat4 uProjectionMatrix;
@@ -17,6 +20,7 @@ out vec2 vRegion;
out vec4 vColor;
out float vTextureId;
out float vNormalTextureId;
+out float vShininess;
// Pre-projection vertex position (in the renderer's pre-projection
// space — typically camera-local for default cameras with the world
// container's translate applied). Used by the lit fragment path to
@@ -30,5 +34,6 @@ void main(void) {
vRegion = aRegion;
vTextureId = aTextureId;
vNormalTextureId = aNormalTextureId;
+ vShininess = aShininess;
vWorldPos = aVertex.xy;
}
diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js
index 4f2c3d206..c7388a112 100644
--- a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js
+++ b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js
@@ -1,5 +1,7 @@
// the lighting math modules are pure CPU code shared with the GL backend
// (published std140 layout, no GL calls)
+
+import { transformQuadCorners } from "../../gpu/quadcorners.ts";
import {
BLOCK_BYTES,
BLOCK_FLOATS,
@@ -31,7 +33,47 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher {
* @override
*/
init(renderer, settings) {
- super.init(renderer, settings);
+ // Its OWN vertex layout rather than the shared frozen quad one: the
+ // lit family carries a per-quad specular exponent, and widening the
+ // base layout would charge every unlit sprite in the engine 4 bytes a
+ // vertex for a term it never evaluates. Mirrors the GL side, where
+ // `LitQuadBatcher` likewise declares a wider layout than `QuadBatcher`.
+ super.init(
+ renderer,
+ settings ?? {
+ shaderKey: "litQuad",
+ topology: "triangle-list",
+ attributes: [
+ {
+ name: "aVertex",
+ format: "float32x3",
+ offset: 0 * Float32Array.BYTES_PER_ELEMENT,
+ },
+ {
+ name: "aRegion",
+ format: "float32x2",
+ offset: 3 * Float32Array.BYTES_PER_ELEMENT,
+ },
+ {
+ name: "aColor",
+ format: "unorm8x4",
+ offset: 5 * Float32Array.BYTES_PER_ELEMENT,
+ },
+ {
+ name: "aTextureId",
+ format: "float32",
+ offset: 6 * Float32Array.BYTES_PER_ELEMENT,
+ },
+ {
+ // specular exponent, 0 = matte (every sprite that does
+ // not opt in)
+ name: "aShininess",
+ format: "float32",
+ offset: 7 * Float32Array.BYTES_PER_ELEMENT,
+ },
+ ],
+ },
+ );
const cache = renderer.pipelineCache;
const device = renderer.device;
@@ -58,14 +100,14 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher {
},
],
});
- // the lit family rides the frozen quad vertex layout
+ // the lit family rides its own layout, registered by super.init above
this.shaderKey = cache.registerShader(litQuadWGSL, {
bindGroupLayouts: [
cache.frameLayout,
this.litMaterialLayout,
this.lightsLayout,
],
- vertexLayoutKey: "quad",
+ vertexLayoutKey: "litQuad",
label: "melonJS lit quad shader",
});
@@ -268,10 +310,45 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher {
this.currentMaterial = combined;
}
+ // Flushes on a material change above, so a quad's exponent cannot
+ // smear onto the previous segment's vertices.
+ this.currentQuadShininess = renderer.currentShininess || 0;
// transform + push the four corners exactly like the base batcher
this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint);
}
+ /**
+ * Push the four corners with this batcher's extra `aShininess`
+ * component appended.
+ * @param {number} x - destination x
+ * @param {number} y - destination y
+ * @param {number} w - destination width
+ * @param {number} h - destination height
+ * @param {number} u0 - texture UV (u0)
+ * @param {number} v0 - texture UV (v0)
+ * @param {number} u1 - texture UV (u1)
+ * @param {number} v1 - texture UV (v1)
+ * @param {number} tint - tint color in UINT32 (argb) format
+ * @param {number} [textureId=0] - the segment slot
+ * @override
+ */
+ pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, textureId = 0) {
+ const vertexData = this.vertexData;
+ const shininess = this.currentQuadShininess || 0;
+ const [vec0, vec1, vec2, vec3] = transformQuadCorners(
+ this.renderer.currentTransform,
+ x,
+ y,
+ w,
+ h,
+ this.renderer.currentDepth,
+ );
+ vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, textureId, shininess);
+ vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, textureId, shininess);
+ vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, textureId, shininess);
+ vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, textureId, shininess);
+ }
+
/**
* Drop every combined color+normal bind group — each pairing embeds a
* sampler resolved from the default texture filter, so a filter change
diff --git a/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl
index eab5dcf7b..9f3d85580 100644
--- a/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl
+++ b/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl
@@ -5,7 +5,9 @@
// The std140 Light2dBlock binds at group 2 with a dynamic offset — one
// snapshot per setLightUniforms call, per the queue-write ordering law.
//
-// Vertex layout: the frozen 28-byte quad layout, unchanged.
+// Vertex layout: this family's OWN 32-byte `litQuad` layout — the frozen
+// quad layout plus a per-quad `aShininess`, so unlit sprites are not charged
+// for a term they never evaluate.
struct FrameUniforms {
projection : mat4x4,
@@ -43,6 +45,8 @@ struct VSOut {
// pre-projection coordinates — the space packLights translates the
// light positions into (world after the camera translate)
@location(2) vWorldPos : vec2f,
+ // specular exponent, flat across the quad; 0 = matte
+ @location(3) vShininess : f32,
};
@vertex
@@ -51,6 +55,7 @@ fn vertex_main(
@location(1) aRegion : vec2f,
@location(2) aColor : vec4f,
@location(3) aTextureId : f32,
+ @location(4) aShininess : f32,
) -> VSOut {
var out : VSOut;
let clip = uFrame.projection * vec4f(aVertex, 1.0);
@@ -59,6 +64,7 @@ fn vertex_main(
out.vColor = vec4f(aColor.bgr * aColor.a, aColor.a);
out.vRegion = aRegion;
out.vWorldPos = aVertex.xy;
+ out.vShininess = aShininess;
return out;
}
@@ -75,6 +81,14 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f {
normal.y = -normal.y;
var lighting = uLights.ambient.rgb;
+ // Specular accumulates SEPARATELY: it is light reflected OFF the surface,
+ // not the surface's own colour lit up, so it is ADDED at the end rather
+ // than multiplied into the albedo (which is why a glint blows out to
+ // white on a dark sprite). GL twin: multitexture-lit.js.
+ var specular = vec3f(0.0);
+ // per-texel mask from the normal map's ALPHA — free, the sample is already
+ // taken, and intact because normal maps upload with premultiply OFF
+ let specMask = normalSample.a;
let count = min(i32(uLights.countPad.x), 32);
for (var i = 0; i < count; i = i + 1) {
let lp = uLights.lights[i].posRadiusIntensity;
@@ -88,7 +102,24 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f {
let lightDir = normalize(vec3f(toLight, ch.w));
let ndotl = max(0.0, dot(normal, lightDir));
lighting = lighting + ch.rgb * (lp.w * att * ndotl);
+ // Blinn-Phong, gated on the exponent exactly as the mesh path gates
+ // on `Ns`: zero is matte however bright the light, so a sprite that
+ // never opts in runs what it always did.
+ if (in.vShininess > 0.0) {
+ // the view vector is a CONSTANT in 2D — a sprite lies in the
+ // screen plane and the camera looks straight down -Z at it, so
+ // there is no per-fragment world position to get wrong
+ let halfVec = normalize(lightDir + vec3f(0.0, 0.0, 1.0));
+ let ndoth = max(0.0, dot(normal, halfVec));
+ // gate on the surface facing the light, or a back-facing texel
+ // whose half-vector lines up picks up a highlight from behind
+ let facing = step(0.0001, ndotl);
+ specular = specular + ch.rgb * (lp.w * att * facing * specMask * pow(ndoth, in.vShininess));
+ }
}
- return vec4f(color.rgb * lighting, color.a) * in.vColor;
+ // `specular * color.a`: the pipeline is premultiplied, so the diffuse term
+ // self-cancels where the sprite is transparent but an added highlight would
+ // not. A normal map is usually opaque even where its diffuse is cut away.
+ return vec4f(color.rgb * lighting + specular * color.a, color.a) * in.vColor;
}
diff --git a/packages/melonjs/tests/depth.spec.js b/packages/melonjs/tests/depth.spec.js
index 6ea829447..0638ec91a 100644
--- a/packages/melonjs/tests/depth.spec.js
+++ b/packages/melonjs/tests/depth.spec.js
@@ -279,8 +279,9 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => {
expect(batcher.stride).toBe(28);
});
- // LitQuadBatcher adds aNormalTextureId at the tail → 8 float-slots = 32 bytes
- it("LitQuadBatcher declares aVertex size 3, stride 32", (ctx) => {
+ // LitQuadBatcher adds aNormalTextureId and aShininess at the tail
+ // → 9 float-slots = 36 bytes
+ it("LitQuadBatcher declares aVertex size 3, stride 36", (ctx) => {
if (skipIfNoWebGL(ctx)) {
return;
}
@@ -290,7 +291,7 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => {
});
expect(aVertex).toBeDefined();
expect(aVertex.size).toBe(3);
- expect(batcher.stride).toBe(32);
+ expect(batcher.stride).toBe(36);
});
// PrimitiveBatcher: aVertex(3) + aNormal(2) + aColor(4 UBYTE = 1 float-slot)
diff --git a/packages/melonjs/tests/lit_quad_specular.spec.js b/packages/melonjs/tests/lit_quad_specular.spec.js
new file mode 100644
index 000000000..07ca0f4a5
--- /dev/null
+++ b/packages/melonjs/tests/lit_quad_specular.spec.js
@@ -0,0 +1,176 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { boot, Sprite } from "../src/index.js";
+import { buildLitMultiTextureFragment } from "../src/video/webgl/shaders/multitexture-lit.js";
+import litQuadVertex from "../src/video/webgl/shaders/quad-multi-lit.vert?raw";
+import {
+ getWebGLRenderer,
+ releaseWebGLRenderer,
+ requireWebGL,
+} from "./helpers/webgl-context.js";
+
+/**
+ * Specular highlights on 2D sprites.
+ *
+ * A normal map tells the light which way each texel FACES, which gives a
+ * sprite shape. Shininess decides whether it also SHINES: a highlight only
+ * appears where a texel happens to reflect a light toward the screen, so it
+ * slides across the surface as the light moves rather than the whole sprite
+ * brightening.
+ *
+ * The contract is per QUAD, carried on a vertex attribute, and gated on the
+ * exponent exactly as the mesh path gates on MTL `Ns` — so every sprite that
+ * never opts in runs the maths it always did. That regression guard is the
+ * most important thing in this file.
+ */
+describe("2D specular", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ boot();
+ renderer = await getWebGLRenderer(64, 64);
+ });
+
+ afterAll(() => {
+ // hand the shared context back so the next spec file does not
+ // inherit our batcher selection
+ releaseWebGLRenderer();
+ });
+
+ describe("the Sprite property", () => {
+ /**
+ * @param {object} [extra] - settings merged over the minimum
+ * @returns {Sprite} a sprite over a tiny canvas source
+ */
+ const sprite = (extra = {}) => {
+ const image = document.createElement("canvas");
+ image.width = image.height = 8;
+ return new Sprite(0, 0, { image, ...extra });
+ };
+
+ it("defaults to 0 — matte, which is every existing sprite", () => {
+ expect(sprite().shininess).toBe(0);
+ });
+
+ it("is read from settings", () => {
+ expect(sprite({ shininess: 64 }).shininess).toBe(64);
+ });
+
+ it("rides the renderer's per-sprite slot, and clears with the normal map", () => {
+ // it travels WITH the map because it is meaningless without one:
+ // no surface directions, nothing to reflect
+ // the real renderer — chasing stub methods for `super.preDraw`
+ // tests the stub, not the engine
+ const stub = renderer;
+ stub.currentNormalMap = null;
+ stub.currentShininess = 0;
+ const s = sprite({ shininess: 32 });
+ s._normalMap = { width: 4, height: 4 };
+ s.preDraw(stub);
+ expect(stub.currentShininess).toBe(32);
+ expect(stub.currentNormalMap).toBe(s._normalMap);
+ s.postDraw(stub);
+ expect(stub.currentShininess).toBe(0);
+ expect(stub.currentNormalMap).toBe(null);
+ });
+
+ it("REGRESSION: a sprite with no normal map touches neither slot", () => {
+ // the pairing is what keeps an opted-out sprite off the lit path
+ // the real renderer — chasing stub methods for `super.preDraw`
+ // tests the stub, not the engine
+ const stub = renderer;
+ stub.currentNormalMap = null;
+ stub.currentShininess = 0;
+ const s = sprite({ shininess: 99 });
+ s.preDraw(stub);
+ expect(stub.currentShininess).toBe(0);
+ expect(stub.currentNormalMap).toBe(null);
+ });
+ });
+
+ describe("the vertex layout", () => {
+ it("writes the exponent into the layout's last slot", (ctx) => {
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.setBatcher("litQuad");
+ batcher.vertexData.clear();
+ const size = batcher.vertexData.vertexSize;
+ // the layout's last slot is aShininess
+ expect(size).toBe(9);
+
+ batcher.vertexData.push(1, 2, 0, 0, 0, 0xffffffff, 0, 3, 48);
+ expect(batcher.vertexData.bufferF32[8]).toBe(48);
+ });
+
+ it("REGRESSION: an omitted exponent writes 0, not the -1 unlit sentinel", (ctx) => {
+ requireWebGL(ctx, renderer);
+ // -1 is the sentinel for `aNormalTextureId` one slot earlier; a
+ // negative exponent reaching `pow()` would explode, so the two
+ // defaults deliberately differ
+ const batcher = renderer.setBatcher("litQuad");
+ batcher.vertexData.clear();
+ // poison the slot first: `clear()` only resets the vertex count, so
+ // a fresh buffer reads 0 there anyway and the assertion below would
+ // pass on a build that never writes it at all
+ batcher.vertexData.bufferF32[8] = -999;
+ batcher.vertexData.push(1, 2, 0, 0, 0, 0xffffffff, 0);
+ expect(batcher.vertexData.bufferF32[7]).toBe(-1);
+ expect(batcher.vertexData.bufferF32[8]).toBe(0);
+ });
+
+ it("REGRESSION: the unlit quad layout is untouched", (ctx) => {
+ requireWebGL(ctx, renderer);
+ // widening the shared layout would charge every unlit sprite in
+ // the engine 4 bytes a vertex for a term it never evaluates
+ expect(renderer.setBatcher("quad").stride).toBe(28);
+ expect(renderer.setBatcher("litQuad").stride).toBe(36);
+ });
+ });
+
+ describe("the shader", () => {
+ const fragment = buildLitMultiTextureFragment(4);
+
+ it("gates the term on the exponent", () => {
+ // zero is matte however bright the light, so a sprite that never
+ // opts in runs exactly the maths it always did
+ expect(fragment).toContain("if (vShininess > 0.0)");
+ });
+
+ it("ADDS the highlight rather than multiplying it into the albedo", () => {
+ // a highlight is light reflected OFF the surface, not the
+ // surface's own colour lit up — which is why it blows out to
+ // white on a dark sprite instead of tinting with it
+ expect(fragment).toContain("color.rgb * lighting + specular");
+ expect(fragment).not.toContain("color.rgb * (lighting + specular)");
+ // ...but weighted by the sprite's own alpha, or a shiny sprite
+ // paints an additive glow across its cut-out: the pipeline is
+ // premultiplied, so `color.rgb` self-cancels where alpha is 0 and
+ // an unweighted highlight would not
+ expect(fragment).toContain("specular * color.a");
+ });
+
+ it("uses a CONSTANT view vector — the thing that makes 2D sound", () => {
+ // a sprite lies in the screen plane and the camera looks straight
+ // down -Z at it, so there is no per-fragment world position to get
+ // wrong (the reason this cannot be done for a mesh under a 2D
+ // camera, see #1576)
+ expect(fragment).toContain("vec3(0.0, 0.0, 1.0)");
+ });
+
+ it("masks per texel from the normal map's alpha", () => {
+ // free — the sample is already taken — and intact, because normal
+ // maps upload with premultiplied alpha OFF
+ expect(fragment).toContain("float specMask = normalSample.a;");
+ });
+
+ it("gates on the surface facing the light", () => {
+ // without it a back-facing texel whose half-vector happens to line
+ // up picks up a highlight from a light behind it
+ expect(fragment).toContain("float facing = step(0.0001, NdotL);");
+ });
+
+ it("declares the attribute and varying end to end", () => {
+ expect(litQuadVertex).toContain("in float aShininess;");
+ expect(litQuadVertex).toContain("vShininess = aShininess;");
+ expect(fragment).toContain("in float vShininess;");
+ });
+ });
+});
diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js
index 1ca496707..3f512c873 100644
--- a/packages/melonjs/tests/mesh.spec.js
+++ b/packages/melonjs/tests/mesh.spec.js
@@ -1,4 +1,4 @@
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
Application,
boot,
@@ -1270,6 +1270,48 @@ describe("Mesh × Camera3d world-space path", () => {
expect(Number.isNaN(m.normals[0])).toBe(false);
});
+ it("warns ONCE, and only for a LIT mesh, under a 2D camera (#1576)", async () => {
+ // A FRESH module registry, because the one-shot latch is module scope
+ // (deliberately — the message is about the scene's setup, not about one
+ // mesh). Any earlier test in this file that draws on the 2D path
+ // consumes it, so asserting on the shared instance proves nothing
+ // either way: silence would be the latch, not the guard.
+ vi.resetModules();
+ const { default: FreshMesh } = await import("../src/renderable/mesh.js");
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const said = () => {
+ return warn.mock.calls.filter((c) => {
+ return String(c[0]).includes("Camera2d");
+ });
+ };
+
+ // an UNLIT mesh on the same path must stay quiet: the warning is about
+ // the FLAG, not the path, and every 2D game drawing a plain mesh would
+ // otherwise be nagged
+ const plain = new FreshMesh(0, 0, litPyramid());
+ plain._useWorldSpace = false;
+ plain.lit = false;
+ plain.draw(stubRenderer);
+ expect(said()).toHaveLength(0);
+
+ // now a lit one: exactly one line, naming the fix
+ const lit = new FreshMesh(0, 0, litPyramid());
+ lit._useWorldSpace = false;
+ lit.lit = true;
+ lit.draw(stubRenderer);
+ expect(said()).toHaveLength(1);
+ expect(String(said()[0][0])).toContain("Camera3d");
+
+ // ...and not again, however many meshes or frames follow
+ lit.draw(stubRenderer);
+ const another = new FreshMesh(0, 0, litPyramid());
+ another._useWorldSpace = false;
+ another.lit = true;
+ another.draw(stubRenderer);
+ expect(said()).toHaveLength(1);
+ warn.mockRestore();
+ });
+
it("H2: a rightHanded mesh skips the reversed-index allocation", () => {
const m = new Mesh(0, 0, litPyramid()); // rightHanded: true
m.onActivateEvent();
diff --git a/packages/melonjs/tests/trigger_level_change.spec.js b/packages/melonjs/tests/trigger_level_change.spec.js
index e36212a56..818259846 100644
--- a/packages/melonjs/tests/trigger_level_change.spec.js
+++ b/packages/melonjs/tests/trigger_level_change.spec.js
@@ -234,9 +234,12 @@ describe("Trigger level change (#1646)", () => {
loadOptions.push(settings);
};
- // Drive the hide tween to completion -> onComplete -> the load. Stop
- // ticking the moment the load starts: further ticks re-fire onComplete
- // and would queue a second load.
+ // Drive the hide tween to completion -> onComplete -> the load, in ONE
+ // tick. Ticking in a loop until `loaded.length` moves cannot work: with
+ // the game loop running the load is deferred through a timer, so
+ // `loaded` is still empty on the next iteration, the tween fires
+ // `onComplete` again, and a second and third load queue up behind the
+ // first. That raced — it failed roughly one run in three.
const tween = seen[0].effect.tween;
// Yield to a MACROTASK between ticks, not a microtask: with the loop
// running the load is deferred through `defer`, i.e. a timer, so a
@@ -247,8 +250,9 @@ describe("Trigger level change (#1646)", () => {
setTimeout(resolve, 0);
});
};
- for (let i = 1; i <= 20 && loaded.length === 0; i++) {
- tween._onTick(i * 5);
+ // one tick past the 10ms duration finishes it outright
+ tween._onTick(1000);
+ for (let i = 0; i < 20 && loaded.length === 0; i++) {
await nextTask();
}
// and let the reveal chained after the load settle
diff --git a/packages/melonjs/tests/webgl_vao_call_counts.spec.js b/packages/melonjs/tests/webgl_vao_call_counts.spec.js
index 99896cd65..989303447 100644
--- a/packages/melonjs/tests/webgl_vao_call_counts.spec.js
+++ b/packages/melonjs/tests/webgl_vao_call_counts.spec.js
@@ -69,14 +69,14 @@ describe("WebGL VAO call counts (#1509 acceptance)", () => {
}
};
- it("rebuilding all vertex states costs exactly 19 pointer + 19 enable calls", (ctx) => {
+ it("rebuilding all vertex states costs exactly 20 pointer + 20 enable calls", (ctx) => {
requireWebGL(ctx);
resetCounts();
for (const batcher of renderer.batchers.values()) {
batcher.createVertexState();
}
- expect(counts.vertexAttribPointer).toBe(19);
- expect(counts.enableVertexAttribArray).toBe(19);
+ expect(counts.vertexAttribPointer).toBe(20);
+ expect(counts.enableVertexAttribArray).toBe(20);
expect(counts.disableVertexAttribArray).toBe(0);
});
diff --git a/packages/melonjs/tests/webgl_vao_state.spec.js b/packages/melonjs/tests/webgl_vao_state.spec.js
index 7cf6a3ab9..b7a26e83a 100644
--- a/packages/melonjs/tests/webgl_vao_state.spec.js
+++ b/packages/melonjs/tests/webgl_vao_state.spec.js
@@ -21,7 +21,7 @@ describe("WebGL vertex state (VAO) isolation", () => {
// (batcher name → expected stride in bytes)
const STRIDES = {
quad: 28,
- litQuad: 32,
+ litQuad: 36,
primitive: 24,
mesh: 36,
litMesh: 48,
diff --git a/packages/melonjs/tests/webgpu_lit_quads.spec.js b/packages/melonjs/tests/webgpu_lit_quads.spec.js
index 62451d6b8..7e24109b6 100644
--- a/packages/melonjs/tests/webgpu_lit_quads.spec.js
+++ b/packages/melonjs/tests/webgpu_lit_quads.spec.js
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { Color, WebGPURenderer } from "../src/index.js";
import { BLOCK_BYTES } from "../src/video/webgl/lighting/std140.ts";
import WebGPULitQuadBatcher from "../src/video/webgpu/batchers/lit_quad_batcher.js";
+import litQuadSource from "../src/video/webgpu/shaders/quad-lit.wgsl?raw";
import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js";
/**
@@ -31,13 +32,87 @@ describe("WebGPU 2D lighting", () => {
lit = new WebGPULitQuadBatcher(renderer);
});
- it("registers the lit family against the frozen quad layout", () => {
+ it("registers the lit family against its own vertex layout", () => {
expect(lit.shaderKey).toMatch(/^effect:/);
// a second instance (device-loss re-init) reuses the module text
const again = new WebGPULitQuadBatcher(renderer);
expect(again.shaderKey).toBe(lit.shaderKey);
});
+ /**
+ * Specular (#1576 follow-up): a sprite with a normal map and a
+ * non-zero `shininess` gets a highlight that MOVES with the light,
+ * instead of just brightening.
+ *
+ * Pinned at the vertex data, because that is the whole contract on
+ * this backend: the exponent rides a per-quad attribute, and the
+ * shader gates the term on it being > 0.
+ */
+ describe("specular", () => {
+ const atlas = { name: "colors" };
+ const normalMap = { width: 4, height: 4 };
+
+ /**
+ * @param {number} shininess - the renderer-side exponent
+ * @returns {Float32Array} the quad's four vertices
+ */
+ const quadWith = (shininess) => {
+ renderer.currentShininess = shininess;
+ lit.addQuad(atlas, 0, 0, 32, 32, 0, 0, 1, 1, 0xffffffff, false, {
+ ...normalMap,
+ });
+ const size = lit.vertexSize;
+ return lit.vertexData.bufferF32.slice(0, size * 4);
+ };
+
+ it("declares its OWN vertex layout, wider than the shared quad one", () => {
+ // widening the shared layout would charge every unlit sprite
+ // in the engine for a term it never evaluates
+ expect(lit.stride).toBe(32);
+ expect(
+ lit.attributes.map((a) => {
+ return a.name;
+ }),
+ ).toContain("aShininess");
+ });
+
+ it("writes the exponent onto all four vertices", () => {
+ const v = quadWith(48);
+ const size = lit.vertexSize;
+ for (let i = 0; i < 4; i++) {
+ expect(v[i * size + 7]).toBe(48);
+ }
+ });
+
+ it("REGRESSION: a sprite that never opts in carries 0", () => {
+ // every existing lit sprite: the shader's `> 0` gate then runs
+ // exactly the maths it always did
+ const v = quadWith(0);
+ const size = lit.vertexSize;
+ for (let i = 0; i < 4; i++) {
+ expect(v[i * size + 7]).toBe(0);
+ }
+ });
+
+ it("does not leak one sprite's exponent onto the next", () => {
+ const shiny = quadWith(64);
+ expect(shiny[7]).toBe(64);
+ lit.flush();
+ const matte = quadWith(0);
+ expect(matte[7]).toBe(0);
+ });
+
+ it("the shader gates the term on the exponent, and ADDS it", () => {
+ // added, not multiplied into the albedo: a highlight is light
+ // reflected off the surface, so it blows out to white on a
+ // dark sprite rather than tinting with it
+ expect(litQuadSource).toContain("if (in.vShininess > 0.0)");
+ expect(litQuadSource).toContain("color.rgb * lighting + specular");
+ // the view vector is a constant in 2D — no world position
+ expect(litQuadSource).toContain("vec3f(0.0, 0.0, 1.0)");
+ });
+ });
+
it("every setLightUniforms call owns its snapshot bytes (distinct dynamic offsets)", () => {
lit.setLightUniforms(packed());
const first = lit.lightBinding;