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;