From 0b15e1d9945dc6074616a2388bbcd9fed246c648 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 18 Sep 2026 15:07:18 +0800 Subject: [PATCH 1/2] 2D specular highlights, and `lit` stops lying under a Camera2d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces that close out the lighting scope question. **Sprite.shininess** gives a normal-mapped sprite a specular highlight: one that appears only where a texel reflects a light back at the screen, so it slides across the surface as the light moves rather than the whole sprite brightening. Gated on the exponent exactly as MTL `Ns` is, so 0 is matte and every existing sprite is bit-identical. The normal map's alpha masks it per texel, which costs nothing — that sample is already taken, and normal maps upload with premultiply off so the channel survives. Carried per quad on a vertex attribute rather than a uniform array indexed by the normal-map slot: uniform arrays are charged against `MAX_FRAGMENT_UNIFORM_VECTORS`, the scarcity that drove the light data into a UBO, and a per-slot value would make two sprites sharing one normal map at different exponents fight over it. WebGL widens its lit layout to 36 bytes; WebGPU registers its OWN `litQuad` layout rather than widening the shared frozen quad one, so unlit sprites pay nothing either side. The highlight is multiplied by the sprite's own alpha. The pipeline is premultiplied, so the diffuse term self-cancels where a sprite is transparent but an ADDED highlight does not — and a normal map is typically opaque even where its diffuse is cut away (every shipped SpriteIlluminator export is 100% opaque against a 55%-transparent diffuse). Without the weight, a shiny sprite paints an additive glow across its own cut-out. **`lit: true` under a `Camera2d`** warns once instead of silently doing nothing (#1576). Lighting a mesh needs world-space normals and a world-space fragment position; that path has neither, since its vertices are already the projected output. Suppressed when the mesh's own camera is 3D, so a Camera3d-main/Camera2d-minimap scene is not nagged about a mesh that really is lit. **The normal-map example** demonstrates it: three orbs across the exponent range, one half-polished and half-worn through the alpha mask, a second orbiting coloured light so each highlight is visibly per-light, and a procedural stone wall showing what the same lighting does with flat geometry. Orb textures are supersampled 2x and the colour falloff tightened, since the canvas upscales ~1.8x. Review caught two defects before this landed: the missing alpha weight above, and an inverted green channel in the wall's normal map (image Y grows down, normal maps encode Y up) which rendered its relief inside-out. It also found two assertions that could not fail — the one-shot warning test was reading a latch already consumed by earlier tests in the file, now isolated with a fresh module registry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../examples/normalMap/ExampleNormalMap.tsx | 264 +++++++++++++++++- packages/examples/src/main.tsx | 2 +- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/skills/melonjs-3d/SKILL.md | 8 + .../melonjs/skills/melonjs-lighting/SKILL.md | 46 ++- packages/melonjs/src/lighting/light2d.ts | 70 ++++- packages/melonjs/src/lighting/light3d.ts | 3 + packages/melonjs/src/renderable/mesh.js | 30 +- packages/melonjs/src/renderable/sprite.js | 80 ++++++ packages/melonjs/src/state/stage.ts | 22 ++ packages/melonjs/src/video/buffer/vertex.js | 17 +- packages/melonjs/src/video/renderer.js | 10 + .../video/webgl/batchers/lit_quad_batcher.js | 38 ++- .../video/webgl/shaders/multitexture-lit.js | 43 ++- .../video/webgl/shaders/quad-multi-lit.vert | 5 + .../video/webgpu/batchers/lit_quad_batcher.js | 83 +++++- .../src/video/webgpu/shaders/quad-lit.wgsl | 35 ++- packages/melonjs/tests/depth.spec.js | 7 +- .../melonjs/tests/lit_quad_specular.spec.js | 176 ++++++++++++ packages/melonjs/tests/mesh.spec.js | 44 ++- .../tests/webgl_vao_call_counts.spec.js | 6 +- .../melonjs/tests/webgl_vao_state.spec.js | 2 +- .../melonjs/tests/webgpu_lit_quads.spec.js | 77 ++++- 23 files changed, 1038 insertions(+), 32 deletions(-) create mode 100644 packages/melonjs/tests/lit_quad_specular.spec.js 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/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; From 3098787323e5f13eca1d50872a6e88838f534cb9 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 18 Sep 2026 15:16:23 +0800 Subject: [PATCH 2/2] Tests: remove a race in the trigger transition spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test drove the hide tween in a loop bounded by `loaded.length === 0`, but 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. It failed roughly one run in three, and took out CI on #1683. One tick past the duration completes the tween outright, then wait for the deferred load without touching it again. Ten consecutive runs green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../melonjs/tests/trigger_level_change.spec.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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