diff --git a/README.md b/README.md index 88fd611db8..d46e3776e0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Graphics - **2D** — `Light2d` as a first-class `Renderable` (multiple dynamic lights, radial-gradient falloff, illumination-only mode, procedural rendering via `drawLight`), plus optional per-pixel normal-map shading on sprites for 3D-looking dynamic lights - **3D** — `Light3d` directional, point, spot and ambient lights, added to the world like `Light2d` (half-Lambert diffuse + ambient fill, quadratic falloff and cone angles on the punctual types, runtime-manipulable for day/night), auto-loaded from a glTF scene's authored suns and lamps - up to **32 simultaneous lights** on either path, with light data travelling in a uniform buffer — a static light rig costs zero GPU state changes per frame -- Ground ("blob") shadows for 3D objects — **on by default** — so characters and props read as standing on the floor instead of hovering. An ellipse matching the caster's own footprint and rotation, shrinking and fading with height above the ground; one extra draw per object, and **one for an entire `InstancedMesh` scatter regardless of instance count**. Controllable per object, per glTF scene (`level.load(name, { castGroundShadow })`) or application-wide. Deliberately not shadow mapping: it answers "where is this standing", which is what a 2.5D scene needs +- Ground ("blob") shadows for 3D objects — **on by default** — so characters and props read as standing on the floor instead of hovering. An ellipse matching the caster's own footprint and rotation, shrinking and fading with height above the ground; one extra draw per object, and **one for an entire `InstancedMesh` scatter regardless of instance count**. Shaped by the light rather than always straight down: `shadowLight` points a blob at a `Light3d` and is re-read every draw, so a day/night cycle carries every shadow with it, while `shadowOffset` slides it out from the caster's feet, `shadowStretch` lengthens it for a low sun, `shadowScale` sizes it independently of the object, and `shadowGroundNormal` lays it on a floor that is not level. All of them work on an `InstancedMesh` too, as one value for the whole set. Controllable per object, per glTF scene (`level.load(name, { castGroundShadow, shadowLight, … })`) or application-wide. Deliberately not shadow mapping: it answers "where is this standing", which is what a 2.5D scene needs - Built-in shader effects (Flash, Outline, Glow, Bloom, ToneMapping, Dissolve, CRT, Hologram, etc.) with multi-pass chaining via `addPostEffect()`, plus custom shader support on both GPU backends: `ShaderEffect` for per-sprite fragment effects (GLSL and/or WGSL bodies) and complete custom mesh shader programs via `mesh.shader` (a dual-language `GLShader`: GLSL pair and/or WGSL module) - Optional HDR rendering (`hdr: true`) — the camera's render targets become half-float, so additive light stops saturating on every write and `BloomEffect` thresholds on genuinely over-bright pixels instead of merely bright ones, with `ToneMappingEffect` and a `toneMapping` setting to compress the result back into display range when your art calls for it. Off by default and additive: nothing is inserted into your effect chain, so turning it on does not restyle a game whose look is built on clipped whites. On WebGPU it goes one step further — `hdrOutput: true` presents the frame in the display's own dynamic range rather than clamping it to SDR at the last step - Trail renderable for fading, tapering ribbons behind moving objects (speed lines, sword slashes, magic trails) @@ -69,7 +69,7 @@ Graphics - Video sprite playback Sound -- Web Audio support with 3D spatial audio and stereo panning based on [Howler](https://howlerjs.com) +- Web Audio support with 3D spatial audio and stereo panning, plus a procedural tone/noise API. The playback backend began as [howler.js](https://howlerjs.com) and is now a customized derivative vendored into the engine, so this is **not** a runtime dependency (see `src/audio/backend/LICENSE`) - Built-in procedural audio primitives (envelope-shaped oscillators, white/pink/brown noise) for SFX without sample assets - Direct `AudioContext` / master-gain accessors for custom WebAudio graphs that mix with the engine's master volume / mute @@ -192,6 +192,8 @@ Examples * [3D Mesh](https://melonjs.github.io/melonJS/examples/#/mesh-3d) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/mesh3d)) * [3D Mesh Material](https://melonjs.github.io/melonJS/examples/#/mesh-3d-material) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/mesh3dMaterial)) * [AfterBurner Clone](https://melonjs.github.io/melonJS/examples/#/after-burner) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/afterBurner)) — `Camera3d` + 3D Mesh arcade shooter +* [Ground Shadows](https://melonjs.github.io/melonJS/examples/#/ground-shadows) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/groundShadows)) — every blob-shadow setting side by side under an orbiting sun, including blobs lying on a slope and a 70-instance scatter that costs one extra draw +* [Jungle Rabbit](https://melonjs.github.io/melonJS/examples/#/jungle-rabbit) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/jungleRabbit)) — a low-poly river endless runner: Blender-modelled props through `InstancedMesh`, a rigged paddling boat, a procedural valley and shadows laid on its banks * [glTF Scene](https://melonjs.github.io/melonJS/examples/#/gltf) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/gltf)) — a Blender-authored, lit 3D scene loaded with `level.load` * [glTF Animated Model](https://melonjs.github.io/melonJS/examples/#/gltf-character) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/gltf)) — a rigged character (Kenney Blocky Characters) with node animation, driven by the Sprite-aligned `setCurrentAnimation` / `play` / `pause` / `stop` API * [Trail](https://melonjs.github.io/melonJS/examples/#/trail) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/trail)) diff --git a/packages/examples/LICENSE.md b/packages/examples/LICENSE.md index da9f81d46c..cff81abaaa 100644 --- a/packages/examples/LICENSE.md +++ b/packages/examples/LICENSE.md @@ -112,6 +112,8 @@ legally required, credited here as a courtesy. The 3D models in `public/assets/jungleRabbit/` — boat, carrot, log, rock, palm, fern, leaves, flowers and bird — were modelled for this example and are covered by the MIT license above, as is the procedurally generated terrain and water. +The `groundShadows` example borrows the same models rather than shipping its +own, so this covers both. The music track `bgm/jungle-theme.mp3` is by **Vlad Krotov**, published on Pixabay: diff --git a/packages/examples/public/assets/jungleRabbit/bigleaf.glb b/packages/examples/public/assets/jungleRabbit/bigleaf.glb index 88b72ac03d..ebf2955ca7 100644 Binary files a/packages/examples/public/assets/jungleRabbit/bigleaf.glb and b/packages/examples/public/assets/jungleRabbit/bigleaf.glb differ diff --git a/packages/examples/src/examples/groundShadows/ExampleGroundShadows.tsx b/packages/examples/src/examples/groundShadows/ExampleGroundShadows.tsx new file mode 100644 index 0000000000..da537dc08a --- /dev/null +++ b/packages/examples/src/examples/groundShadows/ExampleGroundShadows.tsx @@ -0,0 +1,931 @@ +/** + * melonJS — ground shadows, every knob on one floor. + * + * A turntable rather than a game: a checkered studio floor with a slope cut + * into its right-hand side, and a row of props each wired to ONE shadow + * setting so the difference between them is the only thing moving. The sun + * orbits, so every blob that takes its direction from a light sweeps with it, + * and the one that does not visibly stays put. + * + * What it proves, station by station: + * + * 1. `castGroundShadow` — the plain contact blob, nothing else set. + * 2. `shadowOpacity` — the same prop twice, 0.12 against 0.85. + * 3. `shadowScale` — the same prop twice, 0.6 against 2.0. + * 4. `shadowStretch` + `shadowOffset` — pulled along the light and slid out + * from the caster's feet, with the stretch driven live off the sun's + * elevation: a low sun smears, a high one does not. + * 5. `shadowGroundY` — a bird that climbs, whose blob shrinks and fades as + * the gap between it and the named floor opens up. + * 6. `shadowDirectionX`/`shadowDirectionZ` — a hand-set direction that + * ignores the sun entirely. The control for stations 1-5. + * 7. `InstancedMesh` — sixty ferns, sixty blobs, one extra draw call. + * 8. `shadowGroundNormal` — props standing on the slope, their blobs lying + * IN it rather than hovering through it. + * 9. `InstancedMesh` + `shadowGroundNormal` — one tilted plane for a whole + * set, which is the shape the instanced path supports: one set per facet. + * + * The "Shadows" checkbox turns `castGroundShadow` off across the scene, which + * is the only honest way to see what the blobs were contributing. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + Application, + Camera3d as Camera3dClass, + type CanvasRenderer, + InstancedMesh, + input, + Light3d, + loader, + Matrix3d, + Mesh, + math, + type Pointer, + plugin, + Renderable, + Sprite3d, + state, + Vector3d, + video, + type WebGLRenderer, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +// The props are Jungle Rabbit's: single merged primitives carrying their own +// palette strip, which is exactly what a scatter wants and saves this example +// having to agree with a palette it does not own. +const base = `${import.meta.env.BASE_URL}assets/jungleRabbit/`; + +/** render-space Y of the level floor. Y is DOWN, so the floor is the ceiling of Y. */ +const GROUND_Y = 0; + +/** pixels per glTF unit for every prop in the scene */ +const MODEL_SCALE = 80; + +/** the floor's extent */ +const X_MIN = -1900; +const X_MAX = 1900; +const Z_MIN = -550; +const Z_MAX = 1700; +/** one floor vertex every this many pixels; 50 puts one exactly on the break */ +const FLOOR_STEP = 50; + +/** where the level floor stops and the slope starts */ +const RAMP_X0 = 800; +/** rise over run of the slope — 0.55 is about 29 degrees */ +const RAMP_GRAD = 0.55; + +/** + * Render-space Y of the ground at a given x: level up to the break, then + * climbing. Up is NEGATIVE y, so climbing subtracts. + * @param x - world x + * @returns world y of the ground surface there + */ +const groundAt = (x: number) => GROUND_Y - Math.max(0, x - RAMP_X0) * RAMP_GRAD; + +/** + * The slope's UP normal, in RENDER space — which is the space + * `shadowGroundNormal` is read in, and where up is `(0, -1, 0)`. + * + * The surface is `y = -grad * x`, so a tangent along x is `(1, -grad, 0)` and + * the perpendicular with a negative (upward) y component is `(-grad, -1, 0)`. + * At `grad = 0` that is world up, which is the level case. + */ +const RAMP_LEN = Math.hypot(RAMP_GRAD, 1); +const RAMP_NORMAL: [number, number, number] = [ + -RAMP_GRAD / RAMP_LEN, + -1 / RAMP_LEN, + 0, +]; + +/** + * Where the slope's plane crosses x = 0, extrapolated back. + * + * An `InstancedMesh` gets ONE shadow plane for the whole set, and that plane + * passes through the set's own origin in X and Z at `shadowGroundY`. The set + * sits at the world origin, so the value it needs is the plane's height THERE, + * not the height under any particular fern. + */ +const RAMP_INTERCEPT = GROUND_Y + RAMP_X0 * RAMP_GRAD; + +/** how far out the sun marker orbits */ +const SUN_RADIUS = 4600; +/** radians per millisecond — a full turn in about 24 seconds */ +const SUN_SPEED = 0.00026; + +const AXIS_Y = new Vector3d(0, 1, 0); + +/* ------------------------------------------------------------------ *\ + Baked textures. Nothing here is loaded: the floor, the labels and + the sun are all drawn once into a canvas at boot. +\* ------------------------------------------------------------------ */ + +/** A dusk sky, drawn screen-fixed behind the scene. */ +function bakeSky() { + const c = document.createElement("canvas"); + c.width = 1; + c.height = 512; + const ctx = c.getContext("2d"); + if (ctx) { + const g = ctx.createLinearGradient(0, 0, 0, 512); + g.addColorStop(0, "#0e1d33"); + g.addColorStop(0.5, "#3f6286"); + g.addColorStop(0.82, "#9fb4c6"); + g.addColorStop(1, "#dcd2c0"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 1, 512); + } + return c; +} + +class SkyBackdrop extends Renderable { + private sky = bakeSky(); + + constructor() { + super(0, 0, 1, 1); + this.floating = true; // screen-space — exempt from the perspective camera + this.anchorPoint.set(0, 0); + } + + override draw(renderer: CanvasRenderer | WebGLRenderer) { + renderer.drawImage( + this.sky, + 0, + 0, + 1, + 512, + 0, + 0, + renderer.width, + renderer.height, + ); + } +} + +/** + * A pale checkerboard. The point of a check rather than a flat wash is that a + * soft dark ellipse on a flat colour is hard to read as lying ON something, + * and the squares also make the slope's foreshortening visible. + * @returns a tiling 2x2 check + */ +function bakeChecker() { + const CELL = 128; + const c = document.createElement("canvas"); + c.width = CELL * 2; + c.height = CELL * 2; + const ctx = c.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#cfcabd"; + ctx.fillRect(0, 0, CELL * 2, CELL * 2); + ctx.fillStyle = "#b9b3a4"; + ctx.fillRect(0, 0, CELL, CELL); + ctx.fillRect(CELL, CELL, CELL, CELL); + // a hairline grid on top, so a single cell still reads at a grazing angle + ctx.strokeStyle = "rgba(255,255,255,0.35)"; + ctx.lineWidth = 2; + ctx.strokeRect(0, 0, CELL, CELL); + ctx.strokeRect(CELL, CELL, CELL, CELL); + } + return c; +} +/** world pixels covered by one full repeat of the check */ +const CHECK_SPAN = 512; + +/** + * A caption, baked at twice its drawn size so it stays crisp when the camera + * comes in close. + * @param title - the setting being shown + * @param detail - what to look at + * @returns the label canvas + */ +function bakeLabel(title: string, detail: string) { + const W = 1024; + const H = 256; + const c = document.createElement("canvas"); + c.width = W; + c.height = H; + const ctx = c.getContext("2d"); + if (ctx) { + ctx.fillStyle = "rgba(12, 18, 28, 0.74)"; + ctx.beginPath(); + ctx.roundRect(8, 8, W - 16, H - 16, 28); + ctx.fill(); + ctx.strokeStyle = "rgba(255, 228, 160, 0.55)"; + ctx.lineWidth = 4; + ctx.stroke(); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "#ffe4a0"; + ctx.font = "600 62px ui-monospace, monospace"; + ctx.fillText(title, W / 2, 92, W - 72); + ctx.fillStyle = "#e8eef5"; + ctx.font = "400 44px system-ui, sans-serif"; + ctx.fillText(detail, W / 2, 168, W - 72); + } + return c; +} + +/** + * The sun itself: a radial falloff, so the marker reads as a light source + * rather than a white disc pasted on the sky. + * @returns the glow canvas + */ +function bakeGlow() { + const S = 256; + const c = document.createElement("canvas"); + c.width = S; + c.height = S; + const ctx = c.getContext("2d"); + if (ctx) { + const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); + g.addColorStop(0, "rgba(255, 252, 232, 1)"); + g.addColorStop(0.28, "rgba(255, 228, 150, 0.92)"); + g.addColorStop(0.62, "rgba(255, 176, 72, 0.34)"); + g.addColorStop(1, "rgba(255, 150, 60, 0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, S, S); + } + return c; +} + +/* ------------------------------------------------------------------ *\ + Geometry +\* ------------------------------------------------------------------ */ + +/** + * Raw geometry plus the material image for one preloaded prop. + * + * Every asset here is a single merged primitive with an identity node + * transform, so `nodes[0]` is the whole model and its vertices can be used + * without folding a node matrix in first. `image` is the base-color texture + * the glTF carried, which is what lets the example stay out of the business of + * owning a palette. + * @param name - the preloaded asset name + * @returns settings ready to spread into a `Mesh` or an `InstancedMesh` + */ +const propGeometry = (name: string) => { + const node = loader.getGLTF(name)?.nodes[0]; + if (node === undefined) { + throw new Error(`Ground shadows: "${name}" did not preload.`); + } + return { + vertices: node.vertices, + uvs: node.uvs, + normals: node.normals, + indices: node.indices, + // Spread conditionally, not passed as possibly-undefined: under + // `exactOptionalPropertyTypes` an explicit `undefined` is not the same + // as leaving the key out, and a material with no base-color texture + // leaves `image` null and `textureFilter` unset. + ...(node.image !== null ? { texture: node.image } : {}), + ...(node.textureFilter !== undefined + ? { textureFilter: node.textureFilter } + : {}), + }; +}; + +/** + * The studio floor: one mesh, level to `RAMP_X0` and climbing after it. + * + * Authored Y-UP like any other mesh geometry — the mesh bridge negates y on + * the way in — so the slope's vertices rise with a POSITIVE local y, and its + * authored normal is `(-grad, 1, 0)`. Negated, that is `RAMP_NORMAL`, which is + * the value the props on it hand to `shadowGroundNormal`: the floor and the + * blobs lying on it are then guaranteed to be talking about the same plane. + * @param checker - the tiling floor texture + * @returns the floor mesh + */ +const createFloor = (checker: HTMLCanvasElement) => { + const nx = Math.round((X_MAX - X_MIN) / FLOOR_STEP) + 1; + const nz = Math.round((Z_MAX - Z_MIN) / FLOOR_STEP) + 1; + const verts = new Float32Array(nx * nz * 3); + const uvs = new Float32Array(nx * nz * 2); + const norms = new Float32Array(nx * nz * 3); + // 16 bits tops out at 65 535; this grid is 69 x 51, so the indices fit, + // but the assertion is cheap and the grid is a constant away from not. + const indices = new Uint32Array((nx - 1) * (nz - 1) * 6); + + let v = 0; + let t = 0; + for (let iz = 0; iz < nz; iz++) { + const z = Z_MIN + iz * FLOOR_STEP; + for (let ix = 0; ix < nx; ix++) { + const x = X_MIN + ix * FLOOR_STEP; + // authored up, so the slope's height is positive + const up = GROUND_Y - groundAt(x); + verts[v] = x; + verts[v + 1] = up; + verts[v + 2] = z; + // the surface is y = f(x), constant in z, so the normal lies in XY + const grad = x > RAMP_X0 ? RAMP_GRAD : 0; + const len = Math.hypot(grad, 1); + norms[v] = -grad / len; + norms[v + 1] = 1 / len; + norms[v + 2] = 0; + v += 3; + uvs[t] = x / CHECK_SPAN; + uvs[t + 1] = z / CHECK_SPAN; + t += 2; + } + } + + let i = 0; + for (let iz = 0; iz < nz - 1; iz++) { + for (let ix = 0; ix < nx - 1; ix++) { + const a = iz * nx + ix; + const b = a + 1; + const c = a + nx; + const d = c + 1; + indices[i] = a; + indices[i + 1] = c; + indices[i + 2] = b; + indices[i + 3] = b; + indices[i + 4] = c; + indices[i + 5] = d; + i += 6; + } + } + + return new Mesh(0, GROUND_Y, { + vertices: verts, + uvs, + normals: norms, + indices, + texture: checker, + // raw world coordinates: no unit-cube fit, no rescale + normalize: false, + scale: 1, + width: X_MAX - X_MIN, + height: Z_MAX - Z_MIN, + // the UVs run well past 1, and without this the check clamps to its + // edge texels and the whole floor reads as one flat wash + textureRepeat: "repeat", + textureFilter: "linear", + cullBackFaces: false, + // lit, so the floor itself darkens as the sun goes down: the blobs are + // not the only thing in the scene that knows where the light is + lit: true, + // A flat plane lying on the floor IS the floor. Shadowing it with a + // blob of its own puts a dark ellipse under the whole scene. + castGroundShadow: false, + }); +}; + +/* ------------------------------------------------------------------ *\ + The sun +\* ------------------------------------------------------------------ */ + +/** + * The sun's TRAVEL direction, which is the way its shadows go. + * + * Render space is Y-down, so a sun above the scene travels with a POSITIVE y. + * Azimuth turns the horizontal part; elevation tips it over. At 90 degrees the + * light is straight down and the horizontal part vanishes, which is why the + * stretch and offset stations go quiet at the top of the slider. + * @param out - vector to write + * @param azimuth - radians around Y + * @param elevation - radians above the horizon + * @returns `out`, normalized + */ +const sunDirection = (out: Vector3d, azimuth: number, elevation: number) => { + const horizontal = Math.cos(elevation); + return out + .set( + horizontal * Math.sin(azimuth), + Math.sin(elevation), + horizontal * Math.cos(azimuth), + ) + .normalize(); +}; + +/* ------------------------------------------------------------------ *\ + The scene +\* ------------------------------------------------------------------ */ + +const createGame = async () => { + let app: Application; + try { + // 1920 x 1080 and `scale: "auto"`: the whole point of the example is + // reading a soft edge against a check, so it is authored at a size + // where that edge has pixels to be soft in. + app = new Application(1920, 1080, { + parent: "screen", + renderer: video.AUTO, + scale: "auto", + cameraClass: Camera3dClass, + antiAlias: true, + }); + await app.init(); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + globalThis.alert( + "This example couldn't start: no GPU renderer is available.\n\n" + + `Details: ${reason}`, + ); + throw err; + } + + plugin.register(DebugPanelPlugin, "debugPanel"); + + let torndown = false; + let pointerCleanup: (() => void) | null = null; + let domCleanup: (() => void) | null = null; + + const setupScene = () => { + if (torndown) { + return; + } + const world = app.world; + + // The sky is a screen-space backdrop, so it has to be drawn BEFORE the + // meshes. Under `Camera3d` the world sorts on depth and this camera + // looks along +Z, so "behind everything" is the LARGEST depth. + world.addChild(new SkyBackdrop(), 100000); + + /* -- lights ------------------------------------------------------- */ + + // The sun every station but #6 takes its direction from. One light, + // shared: `shadowLight` is read every draw, so moving this vector + // moves every blob that points at it. + const sun = new Light3d({ + type: "directional", + direction: [0, 1, 0], + color: "#fff3d6", + intensity: 1.25, + }); + world.addChild(sun); + // a cool fill, so the unlit sides are not black and the blobs stay the + // darkest thing on screen + world.addChild( + new Light3d({ type: "ambient", color: "#9db4cf", intensity: 0.52 }), + ); + + // where the light is COMING FROM, which is back along its travel + const sunMarker = new Sprite3d(0, 0, { + image: bakeGlow(), + width: 560, + height: 560, + billboard: "spherical", + lit: false, + transparent: true, + // nothing in the scene should be able to slice the sun along a + // hard straight line, and it has no surface to be occluded on + depthTest: false, + castGroundShadow: false, + }); + world.addChild(sunMarker, -100); + + /* -- the floor ---------------------------------------------------- */ + + world.addChild(createFloor(bakeChecker()), 0); + + /* -- stations ----------------------------------------------------- */ + + // Every caster collected here, so the "Shadows" checkbox has one list + // to walk and nothing can be left out of it by accident. + const casters: (Mesh | InstancedMesh)[] = []; + + /** + * One prop, standing on the ground at (x, z). + * @param model - preloaded asset name + * @param x - world x + * @param z - world z + * @param scale - pixels per glTF unit + * @param shadow - the shadow settings this station is demonstrating + * @returns the mesh, already added to the world + */ + const station = ( + model: string, + x: number, + z: number, + scale: number, + shadow: Record, + ) => { + const ground = groundAt(x); + const mesh = new Mesh(x, ground, { + ...propGeometry(model), + normalize: false, + scale, + width: 120, + height: 200, + cullBackFaces: false, + lit: true, + castGroundShadow: true, + // Where the floor IS. Left unset the blob falls back to the + // caster's own base at full strength, which is right for a + // thing that never leaves the ground and wrong for everything + // else — and `shadowOffset` is only honoured once the game has + // named a plane to slide along. + shadowGroundY: ground, + ...shadow, + }); + // `addChild(child, z)`, not a `depth` write afterwards: the second + // argument IS the child's z, and it overwrites whatever `pos.z` + // already held. + world.addChild(mesh, z); + casters.push(mesh); + return mesh; + }; + + /** + * The caption above a station. Placed by HEIGHT ABOVE THE GROUND + * rather than by world y, so a station on the slope needs no second + * set of hand-picked numbers. + * @param title - the setting + * @param detail - what to watch + * @param x - world x + * @param z - world z + * @param height - pixels above the ground at that x + */ + const caption = ( + title: string, + detail: string, + x: number, + z: number, + height: number, + ) => { + const label = new Sprite3d(x, groundAt(x) - height, { + image: bakeLabel(title, detail), + width: 640, + height: 160, + billboard: "spherical", + lit: false, + transparent: true, + // a caption has no ground to make contact with, and nothing + // should be able to cut it in half + depthTest: false, + castGroundShadow: false, + }); + world.addChild(label, z); + }; + + // Two rows well apart in Z, so neighbouring captions separate on + // screen instead of stacking into one unreadable pile. + const ROW_A = -200; + const ROW_B = 900; + + /* 1 — the plain contact blob. */ + station("palm", -1600, ROW_A, MODEL_SCALE, { shadowLight: sun }); + caption("castGroundShadow", "the contact blob", -1600, ROW_A, 400); + + /* 2 — opacity, the same prop twice. */ + station("bigleaf", -1150, ROW_A, MODEL_SCALE * 1.2, { + shadowLight: sun, + shadowOpacity: 0.12, + }); + station("bigleaf", -800, ROW_A, MODEL_SCALE * 1.2, { + shadowLight: sun, + shadowOpacity: 0.85, + }); + caption("shadowOpacity", "0.12 vs 0.85", -975, ROW_A, 760); + + /* 3 — footprint, independent of the caster. */ + station("palm", -330, ROW_A, MODEL_SCALE, { + shadowLight: sun, + shadowScale: 0.5, + }); + station("palm", 60, ROW_A, MODEL_SCALE, { + shadowLight: sun, + shadowScale: 2, + }); + caption("shadowScale", "0.5 vs 2.0", -135, ROW_A, 400); + + /* 4 — pulled and slid along the light. The stretch is driven off the + sun's elevation below, which is the whole reason the pair exists: + a low sun smears, a high one does not. */ + const smear = station("palm", 560, ROW_A, MODEL_SCALE, { + shadowLight: sun, + shadowStretch: 2.4, + shadowOffset: 0.9, + shadowOpacity: 0.5, + }); + caption("shadowStretch + shadowOffset", "follows the sun", 560, ROW_A, 760); + + /* 5 — the height fade. `shadowGroundY` is what makes it possible to + be above the floor at all: without a named plane the blob rides the + caster's own base and never leaves it. */ + const flier = station("carrot", -1500, ROW_B, MODEL_SCALE * 1.8, { + shadowLight: sun, + shadowOpacity: 0.6, + }); + caption("shadowGroundY", "fades as it climbs", -1500, ROW_B, 1000); + + /* 6 — the control. A hand-set direction, no `shadowLight`, so this one + blob stays put while every other one sweeps. */ + station("rock", -1230, ROW_B - 260, MODEL_SCALE * 1.4, { + shadowLight: sun, + }); + station("rock", -600, ROW_B - 310, MODEL_SCALE * 1.2, { shadowLight: sun }); + station("log", -950, ROW_B, MODEL_SCALE * 1.5, { + shadowDirectionX: 1, + shadowDirectionZ: 0.35, + shadowStretch: 2, + shadowOffset: 0.8, + }); + caption("shadowDirectionX / Z", "fixed, ignores the sun", -950, ROW_B, 170); + + /* 7 — a scatter. Seventy ferns, one draw call, and one more for the + seventy blobs: the shadow quad is shared and the instance buffer is + the same one the meshes are drawn from. */ + const placement = new Matrix3d(); + const scatter = ( + model: string, + count: number, + xLo: number, + xHi: number, + zLo: number, + zHi: number, + scale: number, + shadow: Record, + ) => { + const mesh = new InstancedMesh(0, GROUND_Y, { + ...propGeometry(model), + normalize: false, + scale, + width: 120, + height: 200, + cullBackFaces: false, + lit: true, + instanceCount: count, + castGroundShadow: true, + ...shadow, + }); + world.addChild(mesh, 0); + casters.push(mesh); + for (let i = 0; i < count; i++) { + const x = math.randomFloat(xLo, xHi); + const z = math.randomFloat(zLo, zHi); + // instance transforms are MODEL units relative to the set's + // origin, and authored y-up, so a render-space height below + // the origin is a negative local y + placement + .identity() + .translate(x / scale, -(groundAt(x) - GROUND_Y) / scale, z / scale) + // each plant faces its own way, so one model does not read + // as one plant copied sixty times + .rotate(math.randomFloat(0, Math.PI * 2), AXIS_Y); + const jitter = math.randomFloat(0.8, 1.2); + placement.scale(jitter, jitter, jitter); + mesh.setInstance(i, placement); + } + return mesh; + }; + + const flat = scatter("fern", 70, -420, 460, 600, 1340, MODEL_SCALE, { + shadowLight: sun, + shadowGroundY: GROUND_Y, + shadowOpacity: 0.55, + shadowOffset: 0.7, + }); + caption("InstancedMesh", "70 blobs, one extra call", 20, 1520, 420); + + /* 8 — the slope. Each prop stands on it, so each blob's plane is the + facet under that prop: `shadowGroundY` says where it is, and + `shadowGroundNormal` says which way it faces. */ + for (const x of [1120, 1430, 1740]) { + station("palm", x, ROW_A, MODEL_SCALE, { + shadowLight: sun, + shadowGroundNormal: RAMP_NORMAL, + shadowOffset: 0.8, + shadowStretch: 1.6, + }); + } + caption("shadowGroundNormal", "blobs lie IN the slope", 1430, ROW_A, 760); + + /* 9 — one tilted plane for a whole set, which is the shape the + instanced path supports: the plane passes through the SET's origin, + so what it wants is the intercept at x = 0, not the height under any + one fern. Curved ground is one set per facet. */ + const sloped = scatter("fern", 45, 1010, 1840, 600, 1340, MODEL_SCALE, { + shadowLight: sun, + shadowGroundY: RAMP_INTERCEPT, + shadowGroundNormal: RAMP_NORMAL, + shadowOpacity: 0.55, + shadowOffset: 0.7, + }); + caption("InstancedMesh + normal", "one plane for the set", 1420, 1520, 420); + + /* -- camera ------------------------------------------------------- */ + + const camera = app.viewport as InstanceType; + // the scene runs to about 2 400 px across its diagonal and the camera + // pulls back past 4 000, so the far plane has to clear both + camera.setClipPlanes(20, 24000); + + const CENTRE_X = 0; + const CENTRE_Z = 520; + // Looking back down the row from the far side, which is the view that + // puts the slope and both scatters broadside to the camera: their blobs + // are the widest thing on screen rather than the most foreshortened. + let yaw = Math.PI + 0.18; + let tilt = 0.42; // radians above the floor — low enough to see under things + let distance = 3250; + const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + + const updateCam = () => { + distance = clamp(distance, 1400, 9000); + tilt = clamp(tilt, 0.12, 1.35); + const flat = Math.cos(tilt) * distance; + // up is -Y in render space + camera.pos.set( + CENTRE_X + Math.sin(yaw) * flat, + GROUND_Y - Math.sin(tilt) * distance, + ); + camera.depth = CENTRE_Z - Math.cos(yaw) * flat; + camera.lookAt(CENTRE_X, GROUND_Y - 480, CENTRE_Z); + }; + updateCam(); + + // drag to orbit — screen coordinates, not world ones: orbiting moves + // the camera every frame, so a world-projected pixel would map + // somewhere new on each move and the drag would jump + let dragging = false; + let lastX = 0; + let lastY = 0; + input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => { + dragging = true; + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + }); + input.registerPointerEvent("pointerup", camera, () => { + dragging = false; + }); + input.registerPointerEvent("pointermove", camera, (ev: Pointer) => { + if (!dragging) { + return; + } + yaw += (ev.gameScreenX - lastX) * 0.0036; + tilt += (ev.gameScreenY - lastY) * -0.0022; + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + updateCam(); + }); + pointerCleanup = () => { + input.releasePointerEvent("pointerdown", camera); + input.releasePointerEvent("pointerup", camera); + input.releasePointerEvent("pointermove", camera); + }; + + /* -- the clock ---------------------------------------------------- */ + + let azimuth = 0.9; + let elevation = 0.52; + let running = true; + let clock = 0; + const dir = new Vector3d(); + let readout: HTMLDivElement | null = null; + + /** + * Drives the sun, the marker, the bird and the one stretch that is + * computed rather than set. Nothing drawn: `update` is the only reason + * it is in the world. + */ + class Director extends Renderable { + constructor() { + super(0, 0, 1, 1); + // the scene is static, so nothing else would tick this + this.alwaysUpdate = true; + } + + override update(dt: number) { + clock += dt; + if (running) { + azimuth += dt * SUN_SPEED; + } + sunDirection(sun.direction, azimuth, elevation); + + // the marker sits back along the travel direction: with a + // positive (downward) y, -y is up + sunMarker.pos.set( + CENTRE_X - sun.direction.x * SUN_RADIUS, + GROUND_Y - sun.direction.y * SUN_RADIUS, + ); + sunMarker.depth = CENTRE_Z - sun.direction.z * SUN_RADIUS; + + // A low sun throws a long shadow. Set live rather than once, + // because `shadowStretch` is read every draw like the rest of + // them — and it fades as it pulls, so an extreme value + // degrades into nothing instead of into a smear. + const lift = Math.max(Math.sin(elevation), 0.18); + const pull = clamp(0.85 / lift, 1, 3); + smear.shadowStretch = pull; + // ...and the scatters take the same pull. One stretch, one + // direction and one offset serve a whole instanced set, which + // is what lets a scatter sit next to loose props without + // looking lit from somewhere else. + flat.shadowStretch = pull; + sloped.shadowStretch = pull; + + // the bird climbs and sinks over six seconds, which is the + // height fade: the blob shrinks and thins as the gap opens + const bob = 0.5 - 0.5 * Math.cos(clock * 0.00105); + flier.pos.y = GROUND_Y - 60 - bob * 660; + + if (readout !== null) { + dir.copy(sun.direction); + readout.textContent = + `azimuth ${(((azimuth * 180) / Math.PI) % 360) | 0}° ` + + `elevation ${((elevation * 180) / Math.PI) | 0}°\n` + + `direction ${dir.x.toFixed(2)}, ${dir.y.toFixed(2)}, ${dir.z.toFixed(2)}`; + } + return true; + } + + override draw() { + // nothing to draw + } + } + world.addChild(new Director(), -1000); + + /* -- controls ----------------------------------------------------- */ + + const panel = document.createElement("div"); + panel.style.cssText = + "position:absolute;top:56px;left:16px;z-index:1000;" + + "font-family:ui-monospace,monospace;font-size:12px;color:#f3ecdf;" + + "background:rgba(10,16,26,0.66);padding:10px 12px;border-radius:8px;" + + "line-height:1.7;text-shadow:0 1px 2px rgba(0,0,0,0.8);"; + + const pause = document.createElement("button"); + pause.textContent = "pause sun"; + pause.style.cssText = + "font:inherit;color:inherit;background:rgba(255,255,255,0.12);" + + "border:1px solid rgba(255,255,255,0.3);border-radius:5px;" + + "padding:3px 9px;cursor:pointer;"; + pause.onclick = () => { + running = !running; + pause.textContent = running ? "pause sun" : "resume sun"; + }; + + const elevationRow = document.createElement("label"); + elevationRow.style.cssText = "display:flex;gap:8px;align-items:center;"; + const elevationSlider = document.createElement("input"); + elevationSlider.type = "range"; + elevationSlider.min = "8"; + elevationSlider.max = "88"; + elevationSlider.value = String(Math.round((elevation * 180) / Math.PI)); + elevationSlider.oninput = () => { + elevation = (Number(elevationSlider.value) * Math.PI) / 180; + }; + elevationRow.append(document.createTextNode("sun height"), elevationSlider); + + const shadowRow = document.createElement("label"); + shadowRow.style.cssText = "display:flex;gap:8px;align-items:center;"; + const shadowToggle = document.createElement("input"); + shadowToggle.type = "checkbox"; + shadowToggle.checked = true; + shadowToggle.oninput = () => { + // the honest A/B: the blobs are the ONLY difference between the + // two states, so whatever changes on screen is what they were + // contributing + for (const caster of casters) { + caster.castGroundShadow = shadowToggle.checked; + } + }; + shadowRow.append(shadowToggle, document.createTextNode("shadows")); + + readout = document.createElement("div"); + readout.style.cssText = "white-space:pre;opacity:0.8;margin-top:4px;"; + + const hint = document.createElement("div"); + hint.style.cssText = "opacity:0.65;margin-top:4px;"; + hint.textContent = "drag to orbit"; + + panel.append(pause, elevationRow, shadowRow, readout, hint); + const parent = app.renderer.getCanvas().parentElement ?? document.body; + parent.appendChild(panel); + domCleanup = () => { + panel.remove(); + }; + }; + + loader.preload( + [ + { name: "palm", type: "glb", src: `${base}palm.glb` }, + { name: "fern", type: "glb", src: `${base}fern.glb` }, + { name: "bigleaf", type: "glb", src: `${base}bigleaf.glb` }, + { name: "rock", type: "glb", src: `${base}rock.glb` }, + { name: "log", type: "glb", src: `${base}log.glb` }, + { name: "carrot", type: "glb", src: `${base}carrot.glb` }, + ], + () => { + // Leave `state.LOADING`, or the loading screen stays pinned over + // the running scene for good — the loader does not change state on + // its own. + state.change(state.DEFAULT, true); + setupScene(); + }, + ); + + return () => { + torndown = true; + pointerCleanup?.(); + domCleanup?.(); + }; +}; + +export const ExampleGroundShadows = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts index 1fdc490711..8af273c200 100644 --- a/packages/examples/src/examples/jungleRabbit/GameStage.ts +++ b/packages/examples/src/examples/jungleRabbit/GameStage.ts @@ -132,7 +132,11 @@ import { RIDE_Y, ROCK_COUNT, ROCK_HALF, - SHADOW_LIFT, + SHADOW_DIR_X, + SHADOW_DIR_Z, + SHADOW_OFFSET, + SHADOW_SINK, + SHADOW_STRETCH, SKY, SPAWN_AHEAD, SPAWN_BEHIND, @@ -146,9 +150,11 @@ import { STEER_LIMIT, STEER_MAX, SUN_AHEAD, + TERRAIN_OVERHANG, TEXT_RAMP_BOTTOM, TEXT_RAMP_TOP, TILE_LEN, + TILE_NX, TRAIL_FOAM, TRAIL_LIFT, TRAIL_POINTS, @@ -161,10 +167,15 @@ import { TRAIL_WIDTH_NEAR, TREE_SPAWN_AHEAD, TUMBLE_RATE, + VEG_SHADOW_OFFSET, + VEG_SHADOW_OPACITY, + VEG_SHADOW_SEAM_LIFT, + VEG_SHADOW_STRETCH, VIEW_W, WARMUP_DISTANCE, WATER_LEVEL, } from "./constants"; + import { menuText } from "./menuText"; import type { Geometry } from "./props"; import { PALETTE_CELLS, WHITE_CELL } from "./props"; @@ -282,6 +293,15 @@ interface Prop { */ interface Scatter { mesh: InstancedMesh; + /** + * The bank this set plants on, when it plants on only one. + * + * A set that carries a ground shadow has ONE plane for all of it, and the + * two banks are mirror images: same heights, opposite tilt. So a shadowed + * set belongs to a single bank, and the frontier it streams against is + * that bank's alone. + */ + side?: -1 | 1; /** * Where each instance sits, so the game can read positions back — plus the * pose it was placed with. `yaw`/`jitter` used to be rolled inline and @@ -594,8 +614,12 @@ export class GameStage extends Stage { TREE_SPAWN_AHEAD, [0.55, 1.34], true, + false, + 0.55, ); - // broad leaves through the middle of the bank + // Broad leaves through the middle of the bank, with the widest blob of + // the three: they stand furthest apart, so theirs is the one that reads + // as a single plant's shadow rather than as ground shading. this.addScatter( geometry("bigleaf"), palette, @@ -605,6 +629,8 @@ export class GameStage extends Stage { TREE_SPAWN_AHEAD, [0.68, 1.52], true, + false, + 0.6, ); // Flowers, in two colours, threaded through the same band as the // leaves. Nothing about the bank was any colour but green before, and @@ -636,7 +662,10 @@ export class GameStage extends Stage { true, true, ); - // palms overhead and furthest out, leaning over the gorge + // Palms overhead and furthest out, leaning over the gorge. Their blob + // is sized to the trunk, not the crown: the crown's footprint is a + // fiction under a sun this low, and a trunk-sized spot is what says + // where the tree stands. this.addScatter( geometry("palm"), palette, @@ -646,6 +675,8 @@ export class GameStage extends Stage { TREE_SPAWN_AHEAD, [0.6, 1.64], true, + false, + 0.4, ); for (let i = 0; i < CARROT_COUNT; i++) { this.addProp( @@ -694,7 +725,13 @@ export class GameStage extends Stage { // a lit and a shaded fur tone, which is enough shape at this size. lit: false, castGroundShadow: true, - shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + // on the water, not floated above it: the blob is thrown clear by + // the sun's own direction now rather than by fake altitude + shadowGroundY: WATER_LEVEL + SHADOW_SINK, + shadowDirectionX: SHADOW_DIR_X, + shadowDirectionZ: SHADOW_DIR_Z, + shadowOffset: SHADOW_OFFSET, + shadowStretch: SHADOW_STRETCH, }); // The hull's own box. A SENSOR: the engine reports the contact and the // game decides what it means (a life, a lurch, a hit-stop, a pickup) — @@ -1182,7 +1219,108 @@ export class GameStage extends Stage { band: [number, number] = [0.58, 1.05], spin = false, drift = false, + shadow?: number, + side?: -1 | 1, + normal?: [number, number, number], + groundY?: number, ): Scatter { + // A bank set that casts a ground shadow is planted one FACET at a + // time, per bank. `shadow` is the blob's size as a multiple of the + // plant's own footprint, and leaving it out means no shadow and no + // split: a set that casts nothing has no plane to match. + // + // The gorge is drawn as flat quads between terrain columns, not as the + // smooth parabola the profile describes, so a plane that matches a + // facet matches the surface exactly: no height error, no tilt error. + // A set spanning several facets would have to pick one plane for all + // of them, which is what drew every blob at a single height and left + // a hard stripe across the hillside. + // + // Per bank as well, because `valleyY` reads `|x|`: the two sides sit + // at matching heights with MIRRORED tilt, so one normal cannot serve + // both. The flat plateau past the rim is the exception and takes one + // set for both banks, since level is level either way. + if (onWall && shadow !== undefined && normal === undefined) { + const facet = (2 * HALF_W * TERRAIN_OVERHANG) / (TILE_NX - 1); + const x0 = HALF_W * band[0]; + const x1 = HALF_W * band[1]; + const span = x1 - x0; + // the last facet's set, returned so the caller still gets ONE handle + let last: Scatter | undefined; + for ( + let edge = Math.floor(x0 / facet) * facet; + edge < x1; + edge += facet + ) { + const lo = Math.max(x0, edge); + const hi = Math.min(x1, edge + facet); + if (hi - lo < 1) { + continue; + } + // The chord the terrain actually draws, which runs COLUMN to + // COLUMN — not between this band's own edges. A band that + // starts part-way through a facet (the ferns begin at 0.55 of + // the half-width, the palms at 0.6, neither of which is a + // column) gives a secant of the parabola instead, and a secant + // of a convex curve lies BELOW the chord it is standing in + // for: about three pixels at the waterline, against a lift of + // half a pixel. The blobs were drawn, lost the depth test to + // the ground mesh, and never appeared — which is exactly the + // "ferns at the bottom of the slope cast nothing" report. + const colLo = edge; + const colHi = edge + facet; + const slope = (valleyY(colHi) - valleyY(colLo)) / facet; + const length = Math.hypot(slope, 1); + const share = (hi - lo) / span; + // The plane an instanced set casts onto passes through the + // SET'S origin, and every set sits at x = 0: so the height it + // is given is this chord carried back to the centre line, not + // the chord's own height. Both banks share it, since `valleyY` + // is even and only the tilt is mirrored. + const intercept = GROUND_Y + valleyY(colLo) - slope * colLo; + if (lo >= HALF_W) { + // level: one set serves both banks + last = this.addScatter( + geometry, + palette, + scale, + Math.max(1, Math.round(count * share)), + onWall, + spawnAhead, + [lo / HALF_W, hi / HALF_W], + spin, + drift, + shadow, + undefined, + [0, -1, 0], + intercept, + ); + continue; + } + for (const bank of [-1, 1] as const) { + last = this.addScatter( + geometry, + palette, + scale, + Math.max(1, Math.round((count * share) / 2)), + onWall, + spawnAhead, + [lo / HALF_W, hi / HALF_W], + spin, + drift, + shadow, + bank, + // up normal of that chord; +y is DOWN, so up is -1 + [(bank * slope) / length, -1 / length, 0], + // raised clear of the seams; the level sets below take + // the intercept as it is, having no seam to clear + intercept - VEG_SHADOW_SEAM_LIFT, + ); + } + } + return last ?? this.scatters[this.scatters.length - 1]; + } + const mesh = new InstancedMesh(0, GROUND_Y, { ...geometry, texture: palette, @@ -1194,11 +1332,28 @@ export class GameStage extends Stage { cullBackFaces: false, lit: true, instanceCount: count, - // An instanced set gets ONE ground-shadow plane for all of it, and - // this floor is a parabola — every instance would cast onto the - // same height and the blobs would slide off the slope. The carrots - // and the boat still cast, being ordinary meshes. - castGroundShadow: false, + // One plane for the whole set, which is why the set is one facet + // of one bank: `shadowGroundY` puts that plane through the set's + // origin and `shadowGroundNormal` tilts it onto the facet, and + // every blob is projected onto it. + castGroundShadow: + normal !== undefined && groundY !== undefined && shadow !== undefined, + ...(normal !== undefined && groundY !== undefined && shadow !== undefined + ? { + shadowGroundNormal: normal, + shadowGroundY: groundY, + shadowScale: shadow, + // thrown and pulled along the same sun the carrots and + // the boat use, so the bank does not read as lit from + // somewhere else. All three are one value for the whole + // set, which is all a set can carry and all this needs. + shadowDirectionX: SHADOW_DIR_X, + shadowDirectionZ: SHADOW_DIR_Z, + shadowOffset: VEG_SHADOW_OFFSET, + shadowStretch: VEG_SHADOW_STRETCH, + shadowOpacity: VEG_SHADOW_OPACITY, + } + : {}), }); this.app.world.addChild(mesh, 0); const items = []; @@ -1212,6 +1367,7 @@ export class GameStage extends Stage { const live = spawnAhead + SPAWN_BEHIND; const scatter: Scatter = { mesh, + ...(side !== undefined ? { side } : {}), items, onWall, spawnAhead, @@ -1223,7 +1379,9 @@ export class GameStage extends Stage { // floor become the placement rule again — the very model the // frontier exists to replace — and a bare stretch then repeats // forever at a fixed period. - spacing: live / Math.max(1, count / 2), + // A set pinned to one bank puts ALL of its instances there, so it + // covers the live stretch with the full count rather than half. + spacing: live / Math.max(1, side === undefined ? count / 2 : count), spin, drift, }; @@ -1297,7 +1455,14 @@ export class GameStage extends Stage { // Serve whichever side has fallen furthest behind. Choosing at random // lets one bank starve for a stretch purely by chance, which is the // other half of how a bald patch forms. - const i = scatter.frontier[0] <= scatter.frontier[1] ? 0 : 1; + const i = + scatter.side !== undefined + ? scatter.side < 0 + ? 0 + : 1 + : scatter.frontier[0] <= scatter.frontier[1] + ? 0 + : 1; // Jittered, or the bank reads as a picket fence. The frontier advances // by the mean either way, so the jitter costs no density. const step = scatter.spacing * math.randomFloat(0.55, 1.45); @@ -1340,7 +1505,7 @@ export class GameStage extends Stage { // — a plain uniform spread — as the run goes on. const warmup = Math.min(1, this.travelled / WARMUP_DISTANCE); const bias = 0.3 + 0.7 * warmup; - const dir = side ?? (Math.random() < 0.5 ? -1 : 1); + const dir = scatter.side ?? side ?? (Math.random() < 0.5 ? -1 : 1); const x = scatter.onWall ? math.randomFloat(HALF_W * scatter.band[0], HALF_W * scatter.band[1]) * dir @@ -1415,7 +1580,11 @@ export class GameStage extends Stage { // flat boulder and lights correctly. lit: kind !== "carrot", castGroundShadow: true, - shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + shadowGroundY: WATER_LEVEL + SHADOW_SINK, + shadowDirectionX: SHADOW_DIR_X, + shadowDirectionZ: SHADOW_DIR_Z, + shadowOffset: SHADOW_OFFSET, + shadowStretch: SHADOW_STRETCH, // The engine default (0.45), for both kinds. A carrot used to be // darkened to 0.78 here because its shadow was barely there — but // that was the renderer replaying the river plane over the top of @@ -1476,9 +1645,8 @@ export class GameStage extends Stage { sprite.pos.x = x; sprite.pos.y = WATER_LEVEL; - // Render space is Y-DOWN, so the floor an object stands on is a GREATER - // y than the object: the shadow plane is `pos.y + LIFT`, never minus. - sprite.shadowGroundY = sprite.pos.y + SHADOW_LIFT; + // the plane the blob lands on is the water the prop sits in + sprite.shadowGroundY = sprite.pos.y + SHADOW_SINK; sprite.depth = this.travelled + aheadOfSkier; // keep the frontier honest even on the initial fill, or the first // respawns measure from zero and pile up at the near edge diff --git a/packages/examples/src/examples/jungleRabbit/constants.ts b/packages/examples/src/examples/jungleRabbit/constants.ts index f428acbe33..20f09f3631 100644 --- a/packages/examples/src/examples/jungleRabbit/constants.ts +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -91,14 +91,103 @@ export const RIVER_FLOW = 0; export const RIPPLE_UV = 520; /** - * How far above the water a blob shadow floats, in world units. + * How far the blob shadows are thrown along the sun's direction, in world + * units, and how much longer they are along it. * - * The engine centres a blob under its caster and does not offset it by the - * light direction, so a boulder sitting in the shallows hides its own contact - * shadow completely from this camera. A small lift brings the near edge out - * from under the rock; too much and the blob rides up over the top of it. + * This used to be `SHADOW_LIFT`, a few units of fake altitude added to the + * shadow plane. A boulder sitting in the shallows hides its own contact + * shadow completely from this camera, and raising the plane was the only + * lever there was: it does not slide the blob out from under the rock, it + * floats the blob UP, and past a few units it rides over the top of the rock + * as a dark ring. The engine's own 3D skill warns against exactly that. + * + * The shadows are offset along the real sun now, so the lever is gone and the + * plane sits on the water where it belongs. + */ +/** + * How far a shadow is thrown, in multiples of the blob's own radius. + * + * One value for everything now. It used to be two, a carrot's and a + * boulder's, because the setting took a world distance and a boulder's blob + * is about three times a carrot's: 38 and 110 units. Both worked out at the + * same 1.45 of their own radius, which is what a world distance was hiding. + * The engine takes the ratio directly, so the two collapse. + * + * It is roughly `stretch - 1`: enough to shift a stretched ellipse so its + * trailing edge still sits at the caster's feet, which is what keeps a + * shadow attached to the thing casting it. + */ +export const SHADOW_OFFSET = 1.45; +export const SHADOW_SINK = 8; +/** + * The ground bearing the shadows are thrown along. + * + * Taken from where the sun is DRAWN, not from the `Light3d`. The two do not + * agree: the billboard sits dead ahead at `SUN_AHEAD` and about ten degrees + * up, so its light travels toward the camera, while the light's own direction + * was chosen for how it shades the valley walls and travels away from it. They + * are 107 degrees apart. + * + * Shading can afford a direction picked for looks, because nothing in the + * frame contradicts it. A shadow cannot: the player can see the sun, so a + * shadow pointing away from it reads as a bug. It also happens to be the + * visible choice, since a shadow thrown toward the camera lands in front of + * its caster instead of hiding behind it. + */ +export const SHADOW_DIR_X = 0; +export const SHADOW_DIR_Z = -1; +export const SHADOW_STRETCH = 2.5; + +/** + * The bank planting's own shadow settings, which are NOT the boat's. + * + * A plant's blob has to fight three things the boat's does not: it is drawn + * against a lit green hillside rather than against water, it is one of a + * thousand rather than one of one, and `shadowStretch` fades it by + * `1 / sqrt(stretch)` on the way. Tuned by measuring instead of by eye: the + * bank used to darken under its planting by about 3 of 255, where the + * shadow showcase's stations measure 34 to 69, which is why the shadows were + * there in every draw and still read as missing. + * + * The offset is shorter than the boat's because it is in blob RADII and the + * blobs below are now near full size: 1.45 radii of a full-size blob throws + * a fern's shadow most of a plant away from the fern. + */ +export const VEG_SHADOW_OPACITY = 0.5; +export const VEG_SHADOW_OFFSET = 0.3; +/** + * The bank planting's stretch, which is NOT the boat's 2.5 either. + * + * A blob stretched two and a half times its own length is a bar, and a + * hillside of them is a set of dark stripes lying across the slope. The boat + * gets away with it because there is one of it, it is large, and it sits on + * open water where a long shadow reads as a wake. A plant does not: it is one + * of a thousand, it is small, and the eye reads the smear as geometry. + * + * `shadowOffset` tracks it, since the distance that keeps a shadow attached to + * its caster is about `stretch - 1` of the blob's radius. + */ +export const VEG_SHADOW_STRETCH = 1.25; + +/** + * How far a bank set's shadow plane is raised off its own facet, in world px. + * + * A blob is a flat quad on ONE plane, and a set gets ONE plane: the chord of + * its own facet. A blob near a facet SEAM reaches past it, and there the chord + * extended diverges from the next facet's chord and ends up under it, so the + * neighbouring terrain is drawn over that part of the blob and it is cut along + * a dead-straight line. Measured at a tenth of all blob rims, up to 13 px deep; + * the level plateau sets, whose plane cannot diverge from level ground, were + * the control at exactly zero. + * + * The divergence a blob can reach is `|slope step| * radius`, and the slope + * step between adjacent facets of this parabola is a constant + * `2 * WALL_H / HALF_W^2 * facet` = 0.196. The widest blob here is about 71 px, + * so 15 px clears every seam. The cost is that the blob floats that far above + * its own facet, which at this scale is under half a model unit and reads as + * nothing, where the cut read as a crease in the hillside. */ -export const SHADOW_LIFT = 8; +export const VEG_SHADOW_SEAM_LIFT = 15; /** length of one terrain tile along +Z */ export const TILE_LEN = 2400; diff --git a/packages/examples/src/examples/jungleRabbit/scenery.ts b/packages/examples/src/examples/jungleRabbit/scenery.ts index 771c403d3f..cb7f4dbc9c 100644 --- a/packages/examples/src/examples/jungleRabbit/scenery.ts +++ b/packages/examples/src/examples/jungleRabbit/scenery.ts @@ -47,13 +47,21 @@ import { HALF_W, HUD_Z, MODEL_SCALE, + SHADOW_DIR_X, + SHADOW_DIR_Z, SKY, SUN_AHEAD, + TERRAIN_OVERHANG, TILE_LEN, + TILE_NX, TITLE_CARROT_SCALE, TITLE_CARROT_X, TITLE_CARROT_Y, TITLE_CARROT_Z, + VEG_SHADOW_OFFSET, + VEG_SHADOW_OPACITY, + VEG_SHADOW_SEAM_LIFT, + VEG_SHADOW_STRETCH, VIEW_H, VIEW_W, WATER_LEVEL, @@ -204,15 +212,41 @@ export const buildBackdrop = (app: Application) => { const placement = new Matrix3d(); // The same authored models the run uses, planted the same way: a menu // built from different scenery reads as a separate screen bolted on. - const beds: [string, number, [number, number]][] = [ - ["fern", 330, [0.55, 1.34]], - ["bigleaf", 175, [0.68, 1.52]], + // `shadow` is the blob's size as a multiple of the plant's own footprint; + // the blooms leave it out and cast nothing, because a flower is too small + // to put a believable shadow under and there are a hundred and fifty of + // them threaded through the plants that do. + const beds: [string, number, [number, number], number?][] = [ + ["fern", 330, [0.55, 1.34], 0.55], + ["bigleaf", 175, [0.68, 1.52], 0.6], ["flower-red", 75, [0.58, 1.3]], ["flower-pink", 75, [0.58, 1.3]], - ["palm", 170, [0.6, 1.64]], + ["palm", 170, [0.6, 1.64], 0.4], ]; - for (const [asset, count, band] of beds) { + + /** + * Plant one set, optionally casting onto ONE named plane. + * @param asset - the preloaded model + * @param count - how many instances + * @param lo - inner edge of the span it occupies, in world x + * @param hi - outer edge + * @param shadow - blob size as a multiple of the plant's footprint + * @param bank - which side to plant on, or undefined for both + * @param normal - up normal of the facet it stands on, in render space + * @param groundY - that facet's plane carried back to x = 0 + */ + const plant = ( + asset: string, + count: number, + lo: number, + hi: number, + shadow?: number, + bank?: -1 | 1, + normal?: [number, number, number], + groundY?: number, + ) => { const geometry: Geometry = modelGeometry(asset); + const casts = shadow !== undefined && normal !== undefined; const mesh = new InstancedMesh(0, GROUND_Y, { ...geometry, texture: palette, @@ -224,13 +258,24 @@ export const buildBackdrop = (app: Application) => { cullBackFaces: false, lit: true, instanceCount: count, - castGroundShadow: false, + castGroundShadow: casts, + ...(casts + ? { + shadowGroundNormal: normal, + shadowGroundY: groundY, + shadowScale: shadow, + shadowDirectionX: SHADOW_DIR_X, + shadowDirectionZ: SHADOW_DIR_Z, + shadowOffset: VEG_SHADOW_OFFSET, + shadowStretch: VEG_SHADOW_STRETCH, + shadowOpacity: VEG_SHADOW_OPACITY, + } + : {}), }); world.addChild(mesh, 0); for (let i = 0; i < count; i++) { - const x = - math.randomFloat(HALF_W * band[0], HALF_W * band[1]) * - (Math.random() < 0.5 ? -1 : 1); + const side = bank ?? (Math.random() < 0.5 ? -1 : 1); + const x = math.randomFloat(lo, hi) * side; const y = GROUND_Y + valleyY(x); const z = math.random(200, TILE_LEN * 3); placement @@ -243,6 +288,72 @@ export const buildBackdrop = (app: Application) => { placement.scale(jitter, jitter, jitter); mesh.setInstance(i, placement); } + }; + + // An `InstancedMesh` gets ONE shadow plane, so a set that casts is planted + // one FACET of one bank at a time, exactly as the run does it. The gorge is + // drawn as flat quads between terrain columns, so a plane matched to a + // facet matches the surface exactly: no height error and no tilt error. + // Per bank as well, because `valleyY` reads `|x|` and the two sides sit at + // matching heights with MIRRORED tilt. The plateau past the rim is level + // either way and takes one set for both. + const facet = (2 * HALF_W * TERRAIN_OVERHANG) / (TILE_NX - 1); + for (const [asset, count, band, shadow] of beds) { + const x0 = HALF_W * band[0]; + const x1 = HALF_W * band[1]; + if (shadow === undefined) { + plant(asset, count, x0, x1); + continue; + } + const span = x1 - x0; + for (let edge = Math.floor(x0 / facet) * facet; edge < x1; edge += facet) { + const lo = Math.max(x0, edge); + const hi = Math.min(x1, edge + facet); + if (hi - lo < 1) { + continue; + } + // COLUMN to column, not band edge to band edge: see the same spot + // in `GameStage.addScatter`. A secant taken across a band that + // starts part-way through a facet lies below the facet, and every + // blob on it is buried under the ground mesh. + const colLo = edge; + const colHi = edge + facet; + const slope = (valleyY(colHi) - valleyY(colLo)) / facet; + const length = Math.hypot(slope, 1); + const share = (hi - lo) / span; + // the set sits at x = 0, and its plane passes through its own + // origin, so what it needs is this chord carried back to the + // centre line rather than the chord's own height + const intercept = GROUND_Y + valleyY(colLo) - slope * colLo; + if (lo >= HALF_W) { + plant( + asset, + Math.max(1, Math.round(count * share)), + lo, + hi, + shadow, + undefined, + [0, -1, 0], + intercept, + ); + continue; + } + for (const side of [-1, 1] as const) { + plant( + asset, + Math.max(1, Math.round((count * share) / 2)), + lo, + hi, + shadow, + side, + // up normal of that chord; +y is DOWN, so up is -1 + [(side * slope) / length, -1 / length, 0], + // raised clear of the seams; the level sets take the + // intercept as it is, having no seam to clear + intercept - VEG_SHADOW_SEAM_LIFT, + ); + } + } } const camera = app.viewport as Camera3d; diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index c17605480e..865133d34a 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -148,6 +148,11 @@ const ExampleForest = lazy(() => default: m.ExampleForest, })), ); +const ExampleGroundShadows = lazy(() => + import("./examples/groundShadows/ExampleGroundShadows").then((m) => ({ + default: m.ExampleGroundShadows, + })), +); const ExampleGltfCharacter = lazy(() => import("./examples/gltf/ExampleGltfCharacter").then((m) => ({ default: m.ExampleGltfCharacter, @@ -507,6 +512,14 @@ const examples: { description: "100 000 trees drawn from one geometry in a single call, scattered by the glTF asset itself.", }, + { + component: , + label: "Ground Shadows", + path: "ground-shadows", + sourceDir: "groundShadows", + description: + "Every blob-shadow setting side by side on one floor, under an orbiting sun, including shadows lying on a slope.", + }, { component: , label: "Night City Flythrough", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 3744d02830..eb1d5d521f 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,9 @@ ## [20.8.0] (melonJS 2) - _unreleased_ ### Added +- **`Mesh#shadowScale`**: a size multiplier for the ground shadow, independent of the object's own size, so a wide flat-bottomed prop can show a blob that would otherwise hide under it. Defaults to `1`; `0` or less hides the shadow and leaves the object drawn. Works on `Sprite3d`, `InstancedMesh` and `GLTFModel`, and can be animated on a set, whose shared quad is rewritten in place rather than rebuilt ([#1631](https://github.com/melonjs/melonJS/issues/1631), [#1712](https://github.com/melonjs/melonJS/issues/1712), thanks @snowyukitty) +- **Ground shadow shape** (`shadowOffset`, `shadowStretch`, `shadowDirectionX` / `shadowDirectionZ`, `shadowLight`): slide the blob along the ground and lengthen it, for a light that is not overhead. The offset is in multiples of the blob's own radius rather than world units, so one value serves casters of any size, and it needs `shadowGroundY`, since a flat quad can only be slid across a plane the game has named; the stretch is clamped and fades as it pulls. `shadowLight` names a `Light3d` to take the direction from and is re-read every draw, so a day or night cycle carries every shadow with it; nothing is inferred, a scene with no named light gets no direction. An `InstancedMesh` honours both, as one offset and one stretch for the whole set: they ride the quad its blobs share rather than the matrix that also places them, so the blobs lengthen without the scatter smearing. Art direction rather than a projection ([#1631](https://github.com/melonjs/melonJS/issues/1631)) +- **`Mesh#shadowGroundNormal`**: the up normal of the floor a ground shadow lands on, so the blob can lie on a slope instead of always being level. Pairs with `shadowGroundY`, which says where that floor is. The blob is rotated onto the plane rather than projected, so it keeps its size, and it stays directly under its caster; the tilt is clamped at 75 degrees. On an `InstancedMesh` one plane serves the whole set, through the set's origin at `shadowGroundY`, so a scatter over curved ground is one set per flat facet ([#1631](https://github.com/melonjs/melonJS/issues/1631)) - **3D particles** (`elevation`, `elevationVariation`): the launch lifts out of the emitter's plane, so a burst is a volume rather than a disc facing the viewer and a trail can recede. Both default to `0`, which leaves the 2D path exactly as it was ([#1696](https://github.com/melonjs/melonJS/issues/1696)) - **`minSpread` / `maxSpread`**: `angle` and `elevation` become an AXIS and each particle leaves at a polar angle off it, giving a cone, a flat disc or a whole sphere. Sampling azimuth and elevation independently cannot describe a ring tangent to a surface, since there the elevation is a function of the azimuth ([#1696](https://github.com/melonjs/melonJS/issues/1696)) - **`ProgressBar`**, a gauge renderable: track, value-sized fill, optional border and label, four directions, colour or gradient. The loading screen is built on it @@ -10,15 +13,15 @@ - **`toneMapping` setting and `renderer.setToneMapping(mode, options)`**: `"aces"`, `"reinhard"` or `"exponential"`, kept last on the camera and driveable from a settings screen - **`ToneMappingEffect`**: exposure plus a filmic curve instead of clipping flat at white. `white` sets what reads as display white - **`BloomEffect`**: bright areas bleed light into their surroundings -- **`Mesh#depthTest`**: whether geometry in front of a mesh hides it, `true` by default. Set `false` for an additive glow with no surface to be occluded on +- **`Mesh#depthTest`**: whether geometry in front of a mesh hides it, `true` by default. Set `false` for an additive glow, or a world-space label, with no surface to be occluded on. Honoured by `Sprite3d` too - **Physics: `Sphere` collision shape**, resolved against another `Sphere`, a `Box3d`, or any planar shape through its XY silhouette - **`raycast3d` on a sphere body** reports that sphere's surface, not a bounding sphere derived from the renderable's 2D bounds - **`Renderable#setPosition(x, y, z)`**, `z` optional: `pos` is 3D but typed 2D, so `pos.set(x, y)` silently zeroes the depth - **`Camera3d#setBasis(right, up, forward)` and `lookAt(target, up)`**: pose a camera from a basis the game already holds, which is what a view over a curved surface needs -- Mesh: `shadowScale` adjusts the ground shadow's footprint independently of the object size, including Sprite3d and InstancedMesh. Defaults to `1`; set `0` to hide the shadow. - ### Fixed +- A glTF scene loaded with `level.load()` takes every ground-shadow setting, not just `castGroundShadow` and `shadowGroundY`. `shadowLight`, the direction pair, the offset, the stretch, the scale, the opacity and the ground normal reached a rigged `GLTFModel` but not a static scene, so a loaded scene could not be told where its sun was +- `GLTFModel` passes the ground-shadow settings to the parts it builds. Only `castGroundShadow` and `shadowGroundY` reached them, so `shadowOpacity` was silently ignored on every loaded model, which is most of the props a scene has - A particle was culled and sorted at twice its depth, because the emitter's depth was stamped onto each particle as a container-local `pos.z` and then summed again by the chain walk. Under a `Camera3d` a burst anywhere but the near face of the scene drew nothing at all - Destroying a `ParticleEmitter` returns its particles to the pool and cancels the sort it still owed. `Container#destroy` emptied itself through its own public `reset()`, which `ParticleEmitter` redefines to re-apply settings, so on an emitter that call did neither - Pointer events reach whatever is drawn on top. `pos.z` is container-local, so a button inside one panel outranked another panel stacked over it. A covered region is also told when it loses the pointer diff --git a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md index 7e6d167074..90786ba539 100644 --- a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md @@ -40,6 +40,13 @@ or it renders flat. See `melonjs-3d` for the camera. | `lightIntensityScale` | — | keep authored intensity ratios instead of normalising every light to 1 | | `castGroundShadow` | inherits the app setting (**on**) | blob shadows for this scene's meshes | | `shadowGroundY` | each object's own base | world Y of the floor the blobs land on | +| `shadowOpacity` | `0.45` | how dark the blobs are | +| `shadowScale` | `1` | blob footprint multiplier; `0` or less hides them | +| `shadowOffset` | `0` | slide them along the light, in blob radii; needs `shadowGroundY` | +| `shadowStretch` | `1` | lengthen them along it, clamped to 3 | +| `shadowDirectionX` / `shadowDirectionZ` | `0` | the ground direction they are cast along | +| `shadowGroundNormal` | world up | up normal of the floor, for a scene that is not level | +| `shadowLight` | — | a `Light3d` to take the direction from, re-read every draw | `onLoaded` receives the level id — it is a "done" signal, not a handle on the scene. You need it, or `async`: with the game loop running, `level.load` stops @@ -79,10 +86,11 @@ world.addChild(new Trigger(x, y, { })); ``` -`scale`, `rightHanded`, `lights`, `lightIntensityScale`, `castGroundShadow` and -`shadowGroundY` all travel, and so do `container`, `onLoaded`, `flatten` and -`setViewportBounds`. That list is **hardcoded** in `Trigger` — a `level.load` -option outside it is dropped without a warning. +Every `level.load` option above travels, including the whole ground-shadow set, +and so do `container`, `onLoaded`, `flatten` and `setViewportBounds`. The list is +**derived** from `level.loadOptions` rather than restated, so an option added to +a level format arrives here for free. It used to be hand-written and had fallen +three releases behind the glTF path (#1649). **`async` is deliberately excluded.** A trigger with a `color` + `duration` sets it itself, because it needs the promise to sequence its fade and reveal; a @@ -271,11 +279,21 @@ Omit `shadowGroundY` and each blob sits at its own object's base at full strength, which is right for props already resting on the ground. Set it when things jump or fly, so the shadow stays on the floor and shrinks with height. -A blob is centred on its caster's x/z and is never offset by light direction, so -a wide flat-bottomed prop hides its own shadow under itself. Raising -`shadowGroundY` to force one into view floats the blob up over the object as a -dark ring rather than sliding it clear — the setting is for things that leave -the ground, not a visibility knob. +A blob sits on its caster's x/z by default, so a wide flat-bottomed prop hides +its own shadow under itself. The knob for that is `shadowScale`, which widens +the blob until its edge shows past the prop, or `shadowOffset` plus a direction, +which slides it clear. **Not** `shadowGroundY`: raising that floats the blob up +over the object as a dark ring instead of sliding it out, because it is for +things that leave the ground, not a visibility control. + +For a light that is not overhead, name one: `shadowLight` takes the direction +from a `Light3d` and is re-read every draw, so a day or night cycle carries +every shadow in the scene with it. Nothing is inferred — a scene with no named +light and no `shadowDirectionX`/`Z` gets no direction, because a scene with +several lights has no non-arbitrary answer. `shadowStretch` lengthens the blob +along that direction and fades it as it pulls; `shadowGroundNormal` lays it on +a floor that is not level. All of these apply to an `InstancedMesh` too, as one +value for the whole set. ## OBJ/MTL diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index ec215dc4f4..7612e5054b 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -229,7 +229,9 @@ behind the wall, and this is not a synonym for "additive". Set it `false` for a mesh standing in for a screen-space effect: an additive glow carrying a world position only so it can sort and move with the thing it -belongs to. Left depth tested, a flat billboard is sliced along a hard straight +belongs to. A world-space caption billboarded over a prop wants it for the +same reason, since it has no surface for the prop's own foliage to be in front +of. Left depth tested, a flat billboard is sliced along a hard straight line the moment any geometry is nearer at some pixel. That shows up worst around something round — a glow on a rock near a planet's limb gets cut, and no offset escapes it, because the near surface bulges further toward the @@ -660,23 +662,131 @@ the model needs no tangent attribute, and an unmapped mesh is unaffected. This is the 3D counterpart of the `Sprite` normal maps in the lighting skill; they are separate systems and a `Mesh` uses this one. +### What the ground-shadow tier can and cannot do + +Check this table before claiming a setting is unsupported. Every one of these +is read **every draw**, so all of them can be animated. + +| setting | `Mesh` / `Sprite3d` / `GLTFModel` part | `InstancedMesh` | +|---|---|---| +| `castGroundShadow` | yes | yes | +| `shadowGroundY` | yes, the caster's own floor | yes, **one plane** through the SET's origin | +| `shadowGroundNormal` | yes | yes, one plane for the set | +| `shadowOpacity` | yes | yes | +| `shadowScale` | yes | yes | +| `shadowOffset` | yes | yes, one throw for the set | +| `shadowStretch` | yes | yes, one stretch for the set | +| `shadowDirectionX` / `Z` | yes | yes | +| `shadowLight` | yes | yes | +| height fade as the caster rises | yes | **no** | +| a different value per instance | n/a | **no**, and there is no plan for one | + +The two "no"s are the same limitation seen twice: a set gets ONE shadow plane +and ONE shared quad, so the instance's own height is dropped and nothing can +vary between instances. Everything else is one value for the whole set, which +is what a set means. A scatter over curved ground is therefore one set per flat +facet, and a flying scatter wanting a height fade has to be per-object meshes. + +Nothing here is per-object-only. If a claim that a setting "does not work on an +`InstancedMesh`" is in your head, it is out of date: `shadowOffset` and +`shadowStretch` both reached the instanced tier in 20.8. + Ground shadows are **on by default** (the `castGroundShadow` application setting), and need a GPU backend and a `Camera3d`. As a blanket default they skip geometry with no vertical extent — a ground plane. Per object, `castGroundShadow: true`/`false` overrides the app setting and is obeyed as -given, safeguard included; `shadowGroundY` names the floor the blob lands on. +given, safeguard included; `shadowGroundY` names the floor the blob lands on, +`shadowOpacity` how dark it is and `shadowScale` how big it is. The blob is an ellipse sized to the caster's own footprint and placed at the -caster's x/z — it is **never offset by light direction**. So a tall or narrow -object (a character, a tree, a pickup) shows its shadow clearly, while a wide, -flat-bottomed one resting on the floor covers its own completely from a camera -looking down at it. That is the shadow behaving correctly, not a bug. +caster's x/z. So a tall or narrow object (a character, a tree, a pickup) shows +its shadow clearly, while a wide, flat-bottomed one resting on the floor covers +its own completely from a camera looking down at it. That is the shadow +behaving correctly, not a bug. **Do not chase it by raising `shadowGroundY`.** Lifting the plane does not slide the blob out from under the object, it floats the blob *up* — and past a few -units it projects over the top of the caster as a dark halo ringing it. If an -object needs a visible shadow, give it a smaller footprint relative to its -height, or accept that a boulder bedded in the ground has none. +units it projects over the top of the caster as a dark halo ringing it. + +The simplest fix is a bigger blob, so its edge shows past the object: + +```js +mesh.shadowScale = 1.5; // 1 is the default; 0 hides the shadow +``` + +`shadowScale` works everywhere, `InstancedMesh` included. Changing it on a +set rewrites the shared quad's four corners in place rather than rebuilding +it, as does a `shadowLight` that moves, so an animated sun costs no +allocation. + +Or, for the look of a sun that is not overhead, use the shape controls: + +```js +mesh.shadowOffset = 1.5; // slide it out, in blob radii +mesh.shadowDirectionX = 1; // the ground direction it is cast along +mesh.shadowDirectionZ = 0; +mesh.shadowStretch = 2; // and lengthen it along the same line +``` + +`shadowOffset` is measured in multiples of the blob's **own radius**, not in +world units, so one value serves a boulder and a pebble and every part of a +glTF model. About `stretch - 1` is what keeps the shadow attached to its +caster. It is honoured **only when `shadowGroundY` is set**, because the +blob is a flat quad on one named plane: sliding it across a plane the game has +not named puts it where there may be no floor. `shadowStretch` is clamped to 3 +and the blob fades as it pulls, so an extreme value degrades to nothing rather +than to a smear. + +An `InstancedMesh` honours both, as **one** offset and **one** stretch for the +whole set, which is all a shared set can carry. Both ride the quad the blobs +share rather than the matrix that also places them: a stretch in the matrix +would scale the instance positions too and smear the scatter instead of +lengthening its blobs. So a scatter can sit next to loose props under the same +sun without looking lit from somewhere else. + +`shadowLight` takes the direction from a `Light3d` you name, re-read every +draw, so a moving sun carries the shadows with it. Nothing is inferred: the +engine never picks a dominant light, because a scene with several has no +non-arbitrary answer and one with none has no answer at all. + +### A floor that is not level + +`shadowGroundY` says where the floor is; `shadowGroundNormal` says which way +it faces: + +```js +prop.shadowGroundY = floorY; +prop.shadowGroundNormal = [Math.sin(0.35), -Math.cos(0.35), 0]; // a 20 deg slope +``` + +World up is `(0, -1, 0)`, because render space is Y-down, and that is the +default. The blob is **rotated** onto the plane rather than projected, so it +keeps its size; it still sits directly under the caster, since the normal +turns it rather than moving it. The tilt is clamped at 75 degrees, past which +an edge-on blob has nothing left to show. It needs `shadowGroundY`, for the +same reason the offset does: tilting the bounds fallback has no anchor to turn +about. + +On an `InstancedMesh` one plane serves the **whole set**: it passes through +the set's origin at `shadowGroundY`, faces the normal, and every blob is +projected onto it whatever height its own instance sits at, exactly as the +level case puts every blob at one height. A scatter over curved ground is +therefore one set per flat facet, each with the facet's own plane, not one +set with an averaged normal. + +All three compose on a set: the stretch turns the shared quad, the offset +slides the whole set along the light, and both are carried onto the tilted +plane rather than applied flat, so a bank's planting lies along its slope +instead of hovering across it. + +Give blobs to the plants that stand apart. Dense low ground cover is better +left without: a thousand blobs each as wide as its own fern sum to a darkened +hillside, not to a thousand shadows, and ground cover is the ground. + +These are **art direction, not a projection**. A stretched ellipse is not a +silhouette and has no contact with terrain, so on ground that is not the plane +you named it will not lie on it. Direction-correct shadows want a shadow map, +which this tier does not do. ### Get the sign right: the floor is a GREATER y @@ -734,6 +844,13 @@ Loaded through the same level director as everything else: ```js await loader.preload([{ name: "diorama", type: "glb", src: "data/diorama.glb" }]); level.load("diorama", { scale, castGroundShadow, shadowGroundY, onLoaded }); + +// every ground-shadow setting travels, not just those two: a loaded scene can +// be told where its sun is, and `shadowLight` is re-read every draw +level.load("diorama", { + scale, castGroundShadow: true, shadowGroundY: 0, + shadowLight: sun, shadowOffset: 0.6, shadowStretch: 1.8, async: true, +}); ``` `.obj` / `.mtl` are also supported loader types. @@ -812,7 +929,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | fog does not match the sky after a background fade | an explicit `color` was passed; omit it to track `renderer.backgroundColor` | | geometry clips before it has finished fading | fog `far` beyond the clip far — omit the distances and they default to the clip planes | | one marker must stay readable in fog | `fog: false` on that mesh | -| an object casts no visible shadow | wide and flat-bottomed — its own blob is underneath it; raising `shadowGroundY` haloes it instead of revealing it | +| an object casts no visible shadow | wide and flat-bottomed — its own blob is underneath it; raise `shadowScale` (or slide it out with `shadowOffset`), not `shadowGroundY`, which haloes it instead of revealing it | | a dark ring around the top of an object | `shadowGroundY` lifted too far, floating the blob up into the caster | | a mesh sits at the wrong depth after being added | `autoDepth` overwrote `pos.z` with the child index — pass `addChild(mesh, z)` | | a mesh sits half its size off | `anchorPoint` — only on the 2D-camera path; a `Camera3d` mesh pivots on its model origin | diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 8391f92c85..c00c662051 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -102,6 +102,14 @@ export default class GLTFModel extends Container { * @param {boolean} [options.lit=false] - render the part meshes through the lit batcher * @param {boolean} [options.castGroundShadow] - give the parts a ground shadow; omit to inherit the application setting * @param {number} [options.shadowGroundY] - world Y of the floor those shadows land on + * @param {number} [options.shadowOpacity=0.45] - opacity of those shadows before any height fade + * @param {number} [options.shadowScale=1] - size multiplier for those shadows (see {@link Mesh#shadowScale}) + * @param {number} [options.shadowOffset=0] - how far to slide them along their direction, in multiples of each blob's own radius; needs `shadowGroundY` (see {@link Mesh#shadowOffset}) + * @param {number} [options.shadowStretch=1] - how much longer they are along that direction, clamped to 3 + * @param {number} [options.shadowDirectionX=0] - x of the ground direction they are cast along + * @param {number} [options.shadowDirectionZ=0] - z of the ground direction they are cast along + * @param {number[]|Vector3d} [options.shadowGroundNormal] - the UP normal of the floor those shadows land on; needs `shadowGroundY` (see {@link Mesh#shadowGroundNormal}) + * @param {Light3d} [options.shadowLight] - a {@link Light3d} to take that direction from instead */ constructor(data, options = {}) { super(0, 0); @@ -289,6 +297,18 @@ export default class GLTFModel extends Container { ? hasVerticalExtent(prim.vertices, prim.vertexCount) : castGroundShadow, shadowGroundY: options.shadowGroundY, + // the rest of the shadow controls, which did not reach a + // glTF model at all before: a wide flat-bottomed prop is + // exactly the case they exist for, and a prop is usually + // loaded rather than built + shadowOpacity: options.shadowOpacity, + shadowScale: options.shadowScale, + shadowOffset: options.shadowOffset, + shadowStretch: options.shadowStretch, + shadowDirectionX: options.shadowDirectionX, + shadowDirectionZ: options.shadowDirectionZ, + shadowGroundNormal: options.shadowGroundNormal, + shadowLight: options.shadowLight, }); if (prim.instances) { fillInstances(mesh, prim.instances); diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index 1498b0a54d..e95ab8c230 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -10,6 +10,9 @@ import GLTFModel from "./GLTFModel.js"; /** * additional import for TypeScript * @import Container from "../../renderable/container.js"; + * @import Mesh from "../../renderable/mesh.js"; + * @import {Light3d} from "../../lighting/light3d.ts"; + * @import {Vector3d} from "../../math/vector3d.ts"; */ /** * A loadable 3D scene parsed from a glTF / GLB asset. Instances are created @@ -82,6 +85,14 @@ export default class GLTFScene { "lightIntensityScale", "castGroundShadow", "shadowGroundY", + "shadowOpacity", + "shadowScale", + "shadowOffset", + "shadowStretch", + "shadowDirectionX", + "shadowDirectionZ", + "shadowGroundNormal", + "shadowLight", ]); /** @@ -118,11 +129,47 @@ export default class GLTFScene { * shadows land on ({@link Mesh#shadowGroundY}); omit it and each blob sits * at its own object's base at full strength, which is right for a scene * whose props already rest on the ground. + * @param {number} [options.shadowOpacity] - how dark the blobs are ({@link Mesh#shadowOpacity}) + * @param {number} [options.shadowScale] - blob footprint multiplier ({@link Mesh#shadowScale}) + * @param {number} [options.shadowOffset] - how far to slide them along the light, in blob radii ({@link Mesh#shadowOffset}) + * @param {number} [options.shadowStretch] - how much longer they are along it ({@link Mesh#shadowStretch}) + * @param {number} [options.shadowDirectionX] - x of the ground direction they are cast along ({@link Mesh#shadowDirectionX}) + * @param {number} [options.shadowDirectionZ] - z of that direction ({@link Mesh#shadowDirectionZ}) + * @param {number[]|Vector3d} [options.shadowGroundNormal] - up normal of the floor, for a scene that is not level ({@link Mesh#shadowGroundNormal}) + * @param {Light3d} [options.shadowLight] - a light to take the direction from, re-read every draw ({@link Mesh#shadowLight}) + * @example + * // a loaded scene lit by a sun the game owns, so a day/night cycle + * // carries the shadows with it + * const sun = new Light3d({ direction: [0.4, 1, 0.2] }); + * app.world.addChild(sun); + * await level.load("village", { + * scale: 40, + * castGroundShadow: true, + * shadowGroundY: 0, + * shadowLight: sun, + * shadowOffset: 0.6, + * shadowStretch: 1.8, + * async: true, + * }); */ addTo(container, options = {}) { if (!this.data) { return; } + // The shape controls, forwarded as one object so the animated and the + // static path cannot drift apart. Each is passed RAW: `undefined` means + // "not set", which is what lets a `Mesh` fall back to its own default. + const shadowOptions = { + shadowGroundY: options.shadowGroundY, + shadowOpacity: options.shadowOpacity, + shadowScale: options.shadowScale, + shadowOffset: options.shadowOffset, + shadowStretch: options.shadowStretch, + shadowDirectionX: options.shadowDirectionX, + shadowDirectionZ: options.shadowDirectionZ, + shadowGroundNormal: options.shadowGroundNormal, + shadowLight: options.shadowLight, + }; const scale = options.scale ?? 1; const rightHanded = options.rightHanded !== false; // tri-state on purpose: `undefined` means "the caller said nothing", @@ -161,7 +208,7 @@ export default class GLTFScene { rightHanded, lit, castGroundShadow, - shadowGroundY: options.shadowGroundY, + ...shadowOptions, }); model.name = this.name; container.addChild(model); @@ -250,7 +297,7 @@ export default class GLTFScene { castGroundShadow === true ? hasVerticalExtent(node.vertices, node.vertexCount) : castGroundShadow, - shadowGroundY: options.shadowGroundY, + ...shadowOptions, }); if (instances) { fillInstances(mesh, instances); diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 40369a5e93..46f092bedb 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -10,6 +10,9 @@ import TMXTileMap from "./tiled/TMXTileMap.js"; /** * additional import for TypeScript * @import Container from "../renderable/container.js"; + * @import Mesh from "../renderable/mesh.js"; + * @import {Light3d} from "../lighting/light3d.ts"; + * @import {Vector3d} from "../math/vector3d.ts"; */ // our levels const levels = {}; @@ -128,6 +131,14 @@ function levelIdAt(offset) { * @property {number} [lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity by this factor instead of normalizing it to 1 * @property {boolean} [castGroundShadow] - (glTF/GLB only) give every mesh in the scene a ground shadow; omit to inherit the application's `castGroundShadow` setting (on by default) * @property {number} [shadowGroundY] - (glTF/GLB only) world Y the ground shadows land on + * @property {number} [shadowOpacity] - (glTF/GLB only) how dark the blobs are ({@link Mesh#shadowOpacity}) + * @property {number} [shadowScale] - (glTF/GLB only) blob footprint multiplier ({@link Mesh#shadowScale}) + * @property {number} [shadowOffset] - (glTF/GLB only) how far to slide them along the light, in blob radii ({@link Mesh#shadowOffset}) + * @property {number} [shadowStretch] - (glTF/GLB only) how much longer they are along it ({@link Mesh#shadowStretch}) + * @property {number} [shadowDirectionX] - (glTF/GLB only) x of the ground direction they are cast along + * @property {number} [shadowDirectionZ] - (glTF/GLB only) z of that direction + * @property {number[]|Vector3d} [shadowGroundNormal] - (glTF/GLB only) up normal of the floor, for a scene that is not level + * @property {Light3d} [shadowLight] - (glTF/GLB only) a {@link Light3d} to take the shadow direction from, re-read every draw */ /** diff --git a/packages/melonjs/src/renderable/groundshadow.js b/packages/melonjs/src/renderable/groundshadow.js index 9153af5f98..eda1c49220 100644 --- a/packages/melonjs/src/renderable/groundshadow.js +++ b/packages/melonjs/src/renderable/groundshadow.js @@ -220,6 +220,148 @@ export function getShadowQuad(renderer, lit, MeshClass) { return quad; } +/** + * Rotation taking the world up axis onto `n`, applied to a vector lying in + * the horizontal plane. + * + * The blob is ROTATED onto the receiving plane rather than projected onto it. + * A vertical projection would lengthen it by `1 / cos(tilt)` along the slope + * and an orthogonal one would shrink it by `cos(tilt)`, and either way the + * blob would change size for no reason the game asked for, by more and more + * exactly where the feature is used. A rotation preserves its shape. + * + * Derived rather than composed from a general axis-angle: the source axis is + * always world up, so the usual `k x p` terms collapse and this is the + * Rodrigues matrix with `ky = 0` written out. + * @param {number} nx - x of the unit surface normal + * @param {number} ny - y of it + * @param {number} nz - z of it + * @param {number} px - x of the horizontal vector to rotate + * @param {number} pz - z of it + * @param {Float64Array} out - receives the rotated vector + * @ignore + * @internal + */ +export function tiltOntoPlane(nx, ny, nz, px, pz, out) { + const horizontal = nx * nx + nz * nz; + if (horizontal < 1e-12) { + // parallel to world up: nothing to do, or a half turn for a ceiling + const flip = ny <= 0 ? 1 : -1; + out[0] = px * flip; + out[1] = 0; + out[2] = pz; + return; + } + // `cos` of the rotation is `WORLD_UP . n`, and WORLD_UP is (0, -1, 0) + const cos = -ny; + const w = (1 + ny) / horizontal; + out[0] = (cos + w * nz * nz) * px - w * nx * nz * pz; + out[1] = nx * px + nz * pz; + out[2] = -w * nx * nz * px + (cos + w * nx * nx) * pz; +} + +/** + * How far from vertical a ground shadow's floor may tilt the blob, as the + * cosine of the angle. Shared by the per-object and the instanced tiers. + * + * A blob is a flat quad with no thickness and no contact with what it lies + * on, and the further it is tilted toward the viewer's line of sight the less + * of it there is to see: at ninety degrees it is edge-on and gone. Past about + * seventy-five degrees there is nothing left worth drawing, so the tilt stops + * there rather than letting a game dial in a shadow standing on its rim. + * @ignore + * @internal + */ +export const SHADOW_MAX_TILT_COS = Math.cos((75 * Math.PI) / 180); + +/** + * Clamp a floor normal to the tilt ceiling, keeping it unit length. + * + * Clamped toward up rather than refused: a game sliding a normal off a + * curved surface should get a shadow that stops tilting, not one that + * vanishes at some threshold. A normal that points down, or sideways, is + * the same case taken to its limit and lands on the ceiling too, so the + * result always faces up and its `y` is never zero. + * @param {number} nx - x of the unit up normal + * @param {number} ny - y of it (negative is up: render space is Y-down) + * @param {number} nz - z of it + * @param {Float64Array} out - receives the clamped normal + * @ignore + * @internal + */ +export function clampShadowTilt(nx, ny, nz, out) { + if (-ny < SHADOW_MAX_TILT_COS) { + const flat = Math.hypot(nx, nz); + const wanted = Math.sqrt(1 - SHADOW_MAX_TILT_COS * SHADOW_MAX_TILT_COS); + const scale = flat > 1e-6 ? wanted / flat : 0; + nx *= scale; + nz *= scale; + ny = -SHADOW_MAX_TILT_COS; + } + out[0] = nx; + out[1] = ny; + out[2] = nz; +} + +/** scratch for the vectors `tiltOntoPlane` turns */ +/** + * Ceiling on `shadowStretch`, shared by both tiers. + * + * A blob is a round smudge standing in for a shape nobody traced; the further + * it is pulled the more plainly it is a smear rather than a shadow. Past about + * three times its own length it stops reading as one at all. + * @ignore + * @internal + */ +export const SHADOW_MAX_STRETCH = 3; + +/** + * The usable stretch for a given setting: 1 for anything below it or not a + * number, and never past {@link SHADOW_MAX_STRETCH}. + * + * Shared so the per-object and instanced tiers cannot drift apart — the same + * asset has to draw the same blob whichever one happens to carry it. + * @param {number} value - the requested `shadowStretch` + * @returns {number} the stretch to apply, in `[1, SHADOW_MAX_STRETCH]` + * @ignore + * @internal + */ +export function resolveShadowStretch(value) { + if (!Number.isFinite(value) || value < 1) { + return 1; + } + return value > SHADOW_MAX_STRETCH ? SHADOW_MAX_STRETCH : value; +} + +/** + * Lengthen a ground-plane vector along a ground direction. + * + * The anisotropic scale both tiers stretch a blob with: + * + * S = I + (stretch - 1) · d ⊗ d (d unit, in the ground plane) + * S·v = v + (stretch - 1) · (v · d) · d + * + * which leaves anything perpendicular to `d` exactly as it was, so the blob + * grows along the light and keeps its width across it. + * @param {number} vx - x of the vector to stretch + * @param {number} vz - z of the vector to stretch + * @param {number} dx - x of the unit ground direction + * @param {number} dz - z of the unit ground direction + * @param {number} gain - `stretch - 1` + * @param {Float64Array|number[]} out - receives `[x, z]` + * @returns {Float64Array|number[]} `out` + * @ignore + * @internal + */ +export function stretchAlong(vx, vz, dx, dz, gain, out) { + const dot = vx * dx + vz * dz; + out[0] = vx + gain * dot * dx; + out[1] = vz + gain * dot * dz; + return out; +} + +const _axis = new Float64Array(3); + /** * The blob quad an `InstancedMesh` draws from — sized to the prototype's own * footprint (#1515). @@ -244,35 +386,107 @@ export function getShadowQuad(renderer, lit, MeshClass) { * @ignore * @internal */ -export function getInstancedShadowQuad(mesh, MeshClass, halfX, halfZ, version) { - const key = `${mesh.lit === true}:${version}:${halfX}:${halfZ}`; +/** + * Write the quad's four corners as `±u ±v` into a 12-float array. + * @param {Float32Array} out - the vertex array to fill + * @param {number} ux - x of the first axis + * @param {number} uy - y of the first axis + * @param {number} uz - z of the first axis + * @param {number} vx - x of the second axis + * @param {number} vy - y of the second axis + * @param {number} vz - z of the second axis + * @ignore + * @internal + */ +function writeQuadCorners(out, ux, uy, uz, vx, vy, vz) { + out[0] = -ux - vx; + out[1] = -uy - vy; + out[2] = -uz - vz; + out[3] = ux - vx; + out[4] = uy - vy; + out[5] = uz - vz; + out[6] = ux + vx; + out[7] = uy + vy; + out[8] = uz + vz; + out[9] = -ux + vx; + out[10] = -uy + vy; + out[11] = -uz + vz; +} + +export function getInstancedShadowQuad( + mesh, + MeshClass, + axX, + axZ, + azX, + azZ, + version, + nx = 0, + ny = 1, + nz = 0, +) { + const lit = mesh.lit === true; + const key = `${lit}:${version}:${axX}:${axZ}:${azX}:${azZ}:${nx}:${ny}:${nz}`; let quad = mesh._shadowQuad; if (quad !== undefined && quad._shadowKey === key) { return quad; } + // The tilt is BAKED into the four vertices rather than applied by the + // group matrix. The instanced shadow reaches the GPU as + // `uModelMatrix * (instancePosition + quadOffset)`, one matrix for the + // whole set, so a rotation put there would turn the instance POSITIONS + // too and slide the whole scatter. Baked here it rides on the offsets + // alone, and the matrix does the other half: its Y row is the plane's + // equation, a VERTICAL projection onto the plane, and a vector already + // in the plane projects onto itself. So the two compose, and the blob + // lands with the rotated quad's true shape rather than stretched by + // `1 / cos(tilt)` the way a flat quad projected vertically would be. + // + // That also makes the vertex stage dropping `aVertex.y` harmless: the + // projection reads only the vertex's own X and Z, which are exactly what + // survive. Nothing about the slope rides on the Y it throws away. + // + // `nx/ny/nz` is the plane's normal in GROUP-LOCAL space: the caller pulls + // the world normal back through the axis bridge, because these vertices + // are in the space the bridge starts from. + // The two axes arrive as ground-plane vectors rather than half-widths, + // because `shadowStretch` turns them: the stretched blob's axes are no + // longer along local X and local Z. Anything the whole set shares belongs + // here — the quad is the one thing in this path that is NOT multiplied + // into the instance positions. + tiltOntoPlane(-nx, -ny, -nz, axX, axZ, _axis); + const ux = _axis[0]; + const uy = _axis[1]; + const uz = _axis[2]; + tiltOntoPlane(-nx, -ny, -nz, azX, azZ, _axis); + const vx = _axis[0]; + const vy = _axis[1]; + const vz = _axis[2]; + + // Only four corners moved. A sun that sweeps changes them every frame, and + // rebuilding a mesh and its GPU buffers at that rate to move twelve floats + // is the kind of cost that makes a feature not worth having — so when a + // quad is already here and still wants the same shader, the geometry is + // written in place and the version bumped, which is exactly what the + // retained path re-uploads on. + if (quad !== undefined && quad.lit === lit) { + writeQuadCorners(quad.originalVertices, ux, uy, uz, vx, vy, vz); + quad.needsUpdate = true; + quad._shadowKey = key; + return quad; + } quad?.destroy(); + const vertices = new Float32Array(12); + writeQuadCorners(vertices, ux, uy, uz, vx, vy, vz); quad = new MeshClass(0, 0, { - vertices: new Float32Array([ - -halfX, - 0, - -halfZ, - halfX, - 0, - -halfZ, - halfX, - 0, - halfZ, - -halfX, - 0, - halfZ, - ]), + vertices, uvs: QUAD_UVS, indices: QUAD_INDICES, normals: QUAD_NORMALS, texture: getShadowFalloff(), width: 1, normalize: false, - lit: mesh.lit === true, + lit, cullBackFaces: false, }); quad._blendedDraw = true; diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index 23c7f79819..88880fae60 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -7,7 +7,13 @@ import { writeIdentityTransform, writeInstanceTransform, } from "../video/gpu/instancerecord.ts"; -import { getInstancedShadowQuad } from "./groundshadow.js"; +import { + clampShadowTilt, + getInstancedShadowQuad, + resolveShadowStretch, + stretchAlong, + tiltOntoPlane, +} from "./groundshadow.js"; import Mesh from "./mesh.js"; /** @@ -24,7 +30,14 @@ const _instanceMatrix = new Matrix3d(); // scratch for the flattened group matrix a shadow pass draws with — // synchronous, single-use per draw, never held +/** scratch for the shadow throw, turned onto the plane */ +const _THROW = new Float64Array(3); + const _SHADOW_MATRIX = new Matrix3d(); +/** scratch for the clamped floor normal of an instanced shadow */ +const _shadowNormal = new Float64Array(3); +/** scratch for one blob axis lengthened along the light */ +const _STRETCHED = new Float64Array(2); const _instanceBounds = new AABB3d(); // scratch for dirtyRange() — read synchronously and never retained const _dirtySpan = [0, 0]; @@ -712,19 +725,51 @@ export default class InstancedMesh extends Mesh { * Draw a ground shadow for every visible instance — one call for the whole * set, over the instance buffer the mesh itself just drew from (#1515). * - * The group matrix is passed with its Y basis column zeroed and its - * translation Y set to the ground, so whatever Y the shadow vertex stage - * produces is flattened onto the floor. That is what lets the ground - * height reach the shader without a new uniform. + * The group matrix is passed with its Y ROW replaced by the floor's + * plane equation and its translation Y set to the ground, so whatever Y + * the shadow vertex stage produces is projected onto the floor. That is + * what lets the ground reach the shader without a new uniform, and it + * is why a tilted floor costs no shader change either: a gradient in + * that row is a slope, zero is level. * - * One ground height serves the whole set — flat terrain. Per-instance - * ground height would have to ride the `instanceData` slot, which the - * built-in shading already reads as emissive. + * One plane serves the whole set, level or tilted. Per-instance ground + * height would have to ride the `instanceData` slot, which the built-in + * shading already reads as emissive. * @param {WebGLRenderer} renderer - the active renderer * @ignore * @internal + * @example + * // A scatter's blobs share ONE plane, so a scatter over curved ground is + * // one set per flat facet, each with that facet's own plane. Everything + * // else is one value for the whole set, which is all a set can carry. + * const bank = new InstancedMesh(0, 0, { + * ...fernGeometry, + * instanceCount: 60, + * castGroundShadow: true, + * shadowGroundY: planeHeightAtTheSetsOrigin, + * shadowGroundNormal: [facetNx, facetNy, 0], // world up is (0,-1,0) + * shadowLight: sun, + * shadowOffset: 0.6, + * shadowStretch: 1.8, + * }); */ _drawInstancedGroundShadow(renderer) { + // `shadowOffset`, `shadowStretch` and `shadowScale` all apply here, the + // same as on a per-object blob, so a scatter is lit by the same + // settings as the loose props standing next to it. + // + // What a shared set cannot carry is a setting that differs PER + // INSTANCE — and none of those three do: one offset, one stretch, one + // scale for the whole set. The only real constraint is WHERE each one + // is applied. The vertex stage builds `uModelMatrix · (instancePos + + // quadOffset · footprint)`, so anything put in the matrix multiplies + // the instance POSITIONS too and smears the scatter instead of its + // blobs. The stretch therefore rides in the shared QUAD, which the + // positions never pass through, exactly as the slope's tilt already + // does; only the throw, a pure translation identical for every blob, + // belongs in the matrix. + // + // Still one plane for the whole set: see `shadowGroundNormal`. const shadowScale = this.shadowScale; if ( typeof renderer.drawInstancedShadow !== "function" || @@ -748,37 +793,183 @@ export default class InstancedMesh extends Mesh { hx = hx < least ? least : hx; hz = hz < least ? least : hz; } + // The plane the whole set lands on: through the set's origin at + // `shadowGroundY`, facing `shadowGroundNormal`. Level unless the game + // named BOTH, the same rule as the per-object tier: a normal without a + // floor has nothing to turn about. + let nX = 0; + let nY = -1; + let nZ = 0; + const groundY = this.shadowGroundY; + const tilt = this.shadowGroundNormal; + if (tilt !== undefined && groundY !== undefined) { + clampShadowTilt(tilt.x, tilt.y, tilt.z, _shadowNormal); + nX = _shadowNormal[0]; + nY = _shadowNormal[1]; + nZ = _shadowNormal[2]; + } + const group = this._modelMatrix.val; + // The same normal pulled back into GROUP-LOCAL space for the bake, + // because that is the space the quad vertices live in. A normal + // crosses a matrix as `transpose(G) · n`, exact up to length for a + // rotation and a uniform scale, and the length is renormalized away. + // At the plain axis bridge `diag(s, -s, ±s)` that is the world normal + // with its y negated and its z carrying the handedness sign. + let lx = group[0] * nX + group[1] * nY + group[2] * nZ; + let ly = group[4] * nX + group[5] * nY + group[6] * nZ; + let lz = group[8] * nX + group[9] * nY + group[10] * nZ; + const localLength = Math.hypot(lx, ly, lz); + if (localLength > 1e-12) { + lx /= localLength; + ly /= localLength; + lz /= localLength; + } else { + lx = 0; + ly = 1; + lz = 0; + } + // The ground direction the light travels, resolved ONCE: the stretch + // below turns the quad's axes along it and the throw further down + // slides the whole set along the same vector, so reading the light + // twice could only let the two disagree inside a frame. + let dirX = this.shadowDirectionX; + let dirZ = this.shadowDirectionZ; + const light = this.shadowLight; + if (light !== undefined && light.direction !== undefined) { + // the direction a light TRAVELS along is the way its shadows go + dirX = light.direction.x; + dirZ = light.direction.z; + } + const dirLen = Math.hypot(dirX, dirZ); + if (dirLen > 1e-6) { + dirX /= dirLen; + dirZ /= dirLen; + } else { + dirX = 0; + dirZ = 0; + } + + // The blob's two ground axes, in the GROUP-LOCAL space the quad's + // vertices live in: local X and local Z before any stretch. + let axX = hx * shadowScale; + let axZ = 0; + let azX = 0; + let azZ = hz * shadowScale; + const stretch = resolveShadowStretch(this.shadowStretch); + let stretchFade = 1; + if (stretch !== 1 && dirLen > 1e-6) { + // the same pull-back the normal takes, for the same reason: these + // axes are in the space the axis bridge starts from, so the world + // direction has to be brought back through it. Its local Y is + // dropped — the stretch happens IN the ground plane, and the tilt + // onto the slope is applied afterwards, exactly as the per-object + // tier orders the two. + let dlx = group[0] * dirX + group[2] * dirZ; + let dlz = group[8] * dirX + group[10] * dirZ; + const dlLen = Math.hypot(dlx, dlz); + if (dlLen > 1e-6) { + dlx /= dlLen; + dlz /= dlLen; + const gain = stretch - 1; + stretchAlong(axX, axZ, dlx, dlz, gain, _STRETCHED); + axX = _STRETCHED[0]; + axZ = _STRETCHED[1]; + stretchAlong(azX, azZ, dlx, dlz, gain, _STRETCHED); + azX = _STRETCHED[0]; + azZ = _STRETCHED[1]; + // the same darkness spread over more ground is less of it + // anywhere, which is what lets an extreme value degrade into + // nothing rather than into a smear + stretchFade = 1 / Math.sqrt(stretch); + } + } const quad = getInstancedShadowQuad( this, Mesh, - hx * shadowScale, - hz * shadowScale, + axX, + axZ, + azX, + azZ, this._geometryVersion ?? 0, + lx, + ly, + lz, ); - const group = this._modelMatrix.val; const out = _SHADOW_MATRIX.val; for (let i = 0; i < 16; i++) { out[i] = group[i]; } - // Zero the Y ROW — `out[1]`, `out[5]`, `out[9]`, the Y components of all - // three basis columns — not the Y column. Clearing the column alone - // kills only the instance's own height; the X and Z columns keep their - // Y components, so a group rotated about X or Z would tilt the blobs - // onto a slanted plane and sink half of them below the floor. Zeroing - // the row makes every output Y the translation, whatever the rotation. - out[1] = 0; - out[5] = 0; - out[9] = 0; - // ...and put that plane at the ground. Render space is Y-DOWN, so the - // floor is a GREATER Y than the objects standing on it. - // lifted a hair off the floor, for the reason spelled out at - // `SHADOW_LIFT` in mesh.js: a coplanar blob is order-dependent. Y is - // DOWN, so off the floor is a smaller y. - const ground = - this.shadowGroundY !== undefined - ? this.shadowGroundY - : this.getBounds3d().bottom; - out[13] = ground - this.meshScale * 0.01; + // Replace the Y ROW — `out[1]`, `out[5]`, `out[9]`, the Y components + // of all three basis columns — with the plane's equation, so whatever + // Y the vertex stage produces is thrown away and every output Y is + // the plane's height at that vertex's own world X and Z: + // + // y = groundY + sx · (x - originX) + sz · (z - originZ) + // + // The ROW and not the Y column. Clearing the column alone kills only + // the instance's own height; the X and Z columns keep their Y + // components, so a group rotated about X or Z would tilt the blobs + // through those and sink half of them below the floor. Rewriting the + // row makes every output Y the plane's, whatever the rotation. + // + // This is a VERTICAL projection onto the plane, and the quad was baked + // pre-rotated into that plane for exactly this reason: a vertex + // already in the plane projects onto itself, so the blob keeps the + // rotated quad's true shape instead of stretching by `1 / cos(tilt)`. + // On level ground the gradient is zero and this is the flat case. + // + // ONE plane for the whole set, tilted or not. The instance's own + // height goes the way of everything else in the row: the vertex stage + // builds `instancePos + offset` and pushes the sum through this one + // matrix, so a row that passed a position's height through would + // multiply the offset by the same row, and no single row can both + // keep a height and slope an offset around it. A scatter over curved + // ground is one set per flat facet, not one set with a normal. + const sx = -nX / nY; + const sz = -nZ / nY; + out[1] = sx * group[0] + sz * group[2]; + out[5] = sx * group[4] + sz * group[6]; + out[9] = sx * group[8] + sz * group[10]; + // ...and put that plane at the ground, lifted a hair along its normal + // for the reason spelled out at `SHADOW_LIFT` in mesh.js: a coplanar + // blob is order-dependent. The plane passes through the set's ORIGIN, + // so the translation's own X and Z drop out of the intercept, and a + // lift of `l` along the unit normal moves the intercept by `l / nY`: + // at the default normal that is `groundY - l`, off the floor in a + // Y-down space, where the floor is a GREATER Y than what stands on it. + const ground = groundY !== undefined ? groundY : this.getBounds3d().bottom; + out[13] = ground + (this.meshScale * 0.01) / nY; + + // And the throw along the light, which the shared matrix CAN carry: it + // is one translation, the same for every blob in the set, so moving + // the instance positions by it moves each blob by exactly the amount + // it was going to move anyway. (The stretch cannot go here for that + // same reason read the other way: it scales with distance from the + // origin, so the positions would spread.) Turned onto the plane first, + // exactly as the per-object path does, or a horizontal slide would + // climb off a tilted floor. + // + // Measured in the blob's own radius like everywhere else, but the + // radius here is the SET's prototype footprint rather than one + // caster's: every instance shares the quad, so they share the unit. + const throwRatio = this.shadowOffset; + if ( + this.shadowGroundY !== undefined && + Number.isFinite(throwRatio) && + throwRatio !== 0 && + dirLen > 1e-6 + ) { + // `hx`/`hz` are MODEL units and already carry the spread; the + // drawn blob's world radius is those times the set's scale and + // `shadowScale`, which is the unit the ratio is in. Measured + // BEFORE the stretch, like the per-object tier: pulling the blob + // out must not also push it away from the feet it belongs to. + const reach = throwRatio * (hx + hz) * 0.5 * shadowScale * this.meshScale; + tiltOntoPlane(nX, nY, nZ, dirX * reach, dirZ * reach, _THROW); + out[12] += _THROW[0]; + out[13] += _THROW[1]; + out[14] += _THROW[2]; + } const tint = renderer.currentTint; const savedR = tint.r; @@ -788,7 +979,7 @@ export default class InstancedMesh extends Mesh { const savedTintAlpha = tint.alpha; const savedAlpha = renderer.getGlobalAlpha(); tint.setColor(0, 0, 0); - renderer.setGlobalAlpha(this.shadowOpacity * savedAlpha); + renderer.setGlobalAlpha(this.shadowOpacity * stretchFade * savedAlpha); try { renderer.drawInstancedShadow(this, _SHADOW_MATRIX, quad); } finally { diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 5e89b25e48..7211a09c4b 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -6,6 +6,7 @@ import { specularFromMetallicRoughness } from "./../loader/parsers/pbr.ts"; import { Color } from "../math/color.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d } from "../math/vector2d.ts"; +import { Vector3d } from "../math/vector3d.ts"; import { convexHull, generateNormals, @@ -16,7 +17,14 @@ import { AABB3d } from "../physics/broadphase/aabb3d.ts"; import Renderer from "./../video/renderer.js"; import { TextureAtlas } from "./../video/texture/atlas.js"; import Texture2d from "./../video/texture/texture2d.ts"; -import { getShadowQuad, hasVerticalExtent } from "./groundshadow.js"; +import { + clampShadowTilt, + getShadowQuad, + hasVerticalExtent, + resolveShadowStretch, + stretchAlong, + tiltOntoPlane, +} from "./groundshadow.js"; import Renderable from "./renderable.js"; // Fired at most once per session: a `lit` mesh drawn under a 2D camera cannot @@ -30,6 +38,7 @@ let _warnedLitUnder2dOnce = false; * @import CanvasRenderer from "./../video/canvas/canvas_renderer.js"; * @import WebGLRenderer from "./../video/webgl/webgl_renderer.js"; * @import Camera2d from "../camera/camera2d.ts"; + * @import Light3d from "../lighting/light3d.ts"; * @import GLShader from "../video/webgl/glshader.js"; */ @@ -60,6 +69,32 @@ const SHADOW_MIN_AXIS_RATIO = 0.5; // contact shadows spread a little anyway, because no light source is a point. const SHADOW_SPREAD = 1.2; +/** scratch for the three vectors `tiltOntoPlane` turns per shadow */ +const _tilted = new Float64Array(3); +/** scratch for one blob axis lengthened along the light */ +const _STRETCH = new Float64Array(2); + +/** + * Copy a caller's normal into a vector this mesh owns, normalized. + * + * Copied rather than retained, the way {@link Light3d} treats its own + * vectors: a game that keeps mutating the array it passed would otherwise be + * steering this mesh's shadow by accident. + * @param {number[]|Vector3d} value - the normal to read + * @returns {Vector3d} a vector owned by the mesh + * @ignore + * @internal + */ +function readShadowNormal(value) { + const out = Array.isArray(value) + ? new Vector3d(value[0], value[1], value[2]) + : new Vector3d(value.x, value.y, value.z); + const length = Math.hypot(out.x, out.y, out.z); + // a zero normal describes no plane at all; fall back to level rather than + // dividing by it + return length > 1e-6 ? out.scale(1 / length) : out.set(0, -1, 0); +} + // reusable matrix for combining projection × model in draw() const _combinedMatrix = new Matrix3d(); @@ -379,6 +414,12 @@ function buildTextureGroups( * @property {number} [shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. * @property {number} [shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. * @property {number} [shadowScale=1] - multiplier for the ground shadow's footprint. Non-positive or non-finite values hide the shadow without hiding the mesh. + * @property {number} [shadowOffset=0] - how far to slide the shadow along `shadowDirectionX`/`shadowDirectionZ`, in multiples of the blob's own radius, for the look of a light that is not directly overhead. A ratio rather than a world distance so one value serves casters of any size. Honoured only when `shadowGroundY` is set, since the blob is a flat quad and sliding it off a plane the game has NOT named puts it somewhere there is no floor. On an `InstancedMesh` it is one throw for the whole set. + * @property {number} [shadowStretch=1] - how much longer the blob is along `shadowDirectionX`/`shadowDirectionZ`, for the look of a low light. Clamped to 3, and the shadow fades as it stretches. On an `InstancedMesh` it is one stretch for the whole set. + * @property {number} [shadowDirectionX=0] - x of the ground direction the shadow is cast along. Together with `shadowDirectionZ`, zero length means no offset and no stretch. + * @property {number} [shadowDirectionZ=0] - z of the ground direction the shadow is cast along. + * @property {number[]|Vector3d} [shadowGroundNormal] - the UP normal of the floor the shadow lands on, for a surface that is not level. Needs `shadowGroundY`, which says where that floor is; this says which way it faces. Defaults to world up, and the tilt is clamped at 75 degrees. On an `InstancedMesh` one plane serves the whole set, through the set's origin at `shadowGroundY`, so a scatter over curved ground is one set per facet. + * @property {Light3d} [shadowLight] - a {@link Light3d} to take the direction from instead of setting it by hand. Read every draw, so a moving sun carries the shadow with it. Nothing is inferred from the scene: no light here means no direction. */ /** @@ -919,6 +960,13 @@ export default class Mesh extends Renderable { * object above it. * @type {number|undefined} * @default undefined + * @example + * // a collectible bobbing above the floor: naming the floor is what + * // lets the blob shrink and fade as it rises. Left unset the blob + * // rides the object's own base and never leaves it. + * const coin = new Mesh(x, floorY, { ...geometry, castGroundShadow: true }); + * coin.shadowGroundY = floorY; + * coin.pos.y = floorY - 40 - 30 * Math.sin(t); */ this.shadowGroundY = settings.shadowGroundY; @@ -937,14 +985,120 @@ export default class Mesh extends Renderable { * Multiplier for the ground shadow's footprint, independent of the * object's size, ground height and opacity. Values above `1` can reveal * the blob beneath a wide prop; values below `1` tighten it. - * Non-positive or non-finite values hide the shadow. Can be changed live. - * Changing the value on an {@link InstancedMesh} rebuilds its shadow quad. + * Non-positive or non-finite values hide the shadow. Can be changed live + * on an {@link InstancedMesh} too: the set's shared quad is rewritten in + * place rather than rebuilt, so animating it allocates nothing. * @type {number} * @default 1 + * @example + * // a crate's own blob hides underneath it, so widen it until the edge + * // shows; `0` and below hide the shadow and leave the mesh drawn + * crate.shadowScale = 1.6; */ this.shadowScale = typeof settings.shadowScale === "number" ? settings.shadowScale : 1; + /** + * How far to slide the ground shadow along + * {@link Mesh#shadowDirectionX}/{@link Mesh#shadowDirectionZ}, in + * multiples of the blob's OWN radius, which is what gives the look of + * a light that is not directly overhead. + * + * A ratio rather than a world distance. The distance that reads right + * is the one that shifts a stretched ellipse far enough for its + * trailing edge to stay at the caster's feet, which is about + * `stretch - 1` of its radius, so a world distance has to be retuned + * for every size of thing and cannot serve the parts of one glTF model + * at all. {@link Mesh#shadowScale} rides in that radius, so widening + * the blob carries the offset with it. + * + * Honoured ONLY when {@link Mesh#shadowGroundY} is set. The blob is a + * flat quad on one named plane with no contact with terrain, so sliding + * it across a plane the game has not named puts it somewhere there may + * be no floor at all. Setting `shadowGroundY` is the game saying where + * its floor is, and that is the only case where an offset is honest. + * + * An {@link InstancedMesh} honours it too, as one throw shared by the + * whole set, measured in the SET's own blob radius. + * @type {number} + * @default 0 + */ + this.shadowOffset = + typeof settings.shadowOffset === "number" ? settings.shadowOffset : 0; + + /** + * How much longer the ground shadow is along its direction, for the + * look of a low light. `1` leaves it round. + * + * Clamped to 3, and the blob fades as it stretches: this is art + * direction, not a projection, and the further it is pulled the less + * there is to believe. Values below `1` are treated as `1`; shortening + * the blob is what {@link Mesh#shadowScale} is for. + * + * An {@link InstancedMesh} honours it too: one stretch along one + * direction for the whole set, baked into the quad its blobs share + * rather than into the matrix that also places them. + * @type {number} + * @default 1 + */ + this.shadowStretch = + typeof settings.shadowStretch === "number" ? settings.shadowStretch : 1; + + /** + * X of the ground direction the shadow is cast along. + * @type {number} + * @default 0 + * @see Mesh#shadowOffset + */ + this.shadowDirectionX = + typeof settings.shadowDirectionX === "number" + ? settings.shadowDirectionX + : 0; + + /** + * Z of the ground direction the shadow is cast along. Together with + * {@link Mesh#shadowDirectionX}, a zero-length pair means no offset and + * no stretch. + * @type {number} + * @default 0 + * @see Mesh#shadowOffset + */ + this.shadowDirectionZ = + typeof settings.shadowDirectionZ === "number" + ? settings.shadowDirectionZ + : 0; + + /** + * A {@link Light3d} to take the shadow direction from, instead of + * setting it by hand. + * + * Read every draw, so a sun that moves carries the shadow with it. + * NOTHING is inferred: the engine never picks a dominant light for you, + * because a scene with several has no non-arbitrary answer and one with + * none has no answer at all. Name the light or get no direction. + * @type {Light3d|undefined} + * @example + * // one sun, every shadow in the scene following it. Read every draw, + * // so a day/night cycle needs nothing else. + * const sun = new Light3d({ direction: [0.4, 1, 0.2] }); + * app.world.addChild(sun); + * for (const prop of props) { + * prop.castGroundShadow = true; + * prop.shadowGroundY = floorY; + * prop.shadowLight = sun; // instead of shadowDirectionX / Z + * prop.shadowOffset = 0.8; // slid out from its feet, in blob radii + * prop.shadowStretch = 2; // and pulled long, for a low sun + * } + * // later: turning the sun turns every blob with it + * sun.direction.set(Math.sin(t), 1, Math.cos(t)).normalize(); + */ + this.shadowLight = settings.shadowLight; + + this._shadowGroundNormal = undefined; + if (settings.shadowGroundNormal !== undefined) { + this.shadowGroundNormal = settings.shadowGroundNormal; + } + /** * Cached horizontal half-extent used to size the shadow, resolved on * first shadowed draw. Cached because the alternative is a @@ -2046,6 +2200,89 @@ export default class Mesh extends Renderable { } } + // A light that is not overhead: slide the blob along the ground and + // pull it out along the same line. + // + // The basis below is already an ORIENTED, anisotropic pair — `axX/axZ` + // and `azX/azZ` come from the caster's own model columns and carry + // independent lengths — so stretching it needs no new primitive, only + // an anisotropic scale applied in world XZ: + // + // S = I + (stretch - 1) · d ⊗ d (d unit, in the ground plane) + // S·v = v + (stretch - 1) · (v · d) · d + // + // which leaves anything perpendicular to `d` exactly as it was. + let offsetX = 0; + let offsetZ = 0; + let stretchFade = 1; + const light = this.shadowLight; + let dirX = this.shadowDirectionX; + let dirZ = this.shadowDirectionZ; + if (light !== undefined && light.direction !== undefined) { + // the direction a light TRAVELS along is the way its shadows go + dirX = light.direction.x; + dirZ = light.direction.z; + } + const dirLen = Math.hypot(dirX, dirZ); + if (dirLen > 1e-6) { + dirX /= dirLen; + dirZ /= dirLen; + + // Shared with the instanced tier, so the same asset cannot draw a + // differently-stretched blob depending on which one carries it. + const stretch = resolveShadowStretch(this.shadowStretch); + if (stretch !== 1) { + const gain = stretch - 1; + stretchAlong(axX, axZ, dirX, dirZ, gain, _STRETCH); + axX = _STRETCH[0]; + axZ = _STRETCH[1]; + stretchAlong(azX, azZ, dirX, dirZ, gain, _STRETCH); + azX = _STRETCH[0]; + azZ = _STRETCH[1]; + // the same darkness spread over more ground is less of it + // anywhere, and fading as it pulls is what lets an extreme + // value degrade into nothing rather than into a smear + stretchFade = 1 / Math.sqrt(stretch); + } + + // Only on a plane the game NAMED: see `shadowOffset`. + // + // Measured in the blob's OWN radii, not in world units. The + // distance that reads right is the one that shifts a stretched + // ellipse far enough for its trailing edge to stay at the + // caster's feet, and that is a property of the caster: a value in + // world units has to be retuned for every size of thing. Taken + // against `extent * SHADOW_SPREAD`, the blob's radius at full + // strength, so a rising object's shadow does not slide as the + // height fade shrinks it. + const ratio = this.shadowOffset; + if (this.shadowGroundY !== undefined && Number.isFinite(ratio)) { + // `shadowScale` is in the product because the unit is the + // blob's OWN radius, and scaling the blob changes that + // radius. Left out, a game that widened its blob would find + // the trailing edge creeping back under the caster, which is + // the one relationship the ratio exists to hold. + const distance = ratio * extent * SHADOW_SPREAD * shadowScale; + offsetX = dirX * distance; + offsetZ = dirZ * distance; + } + } + + // The tilt of the floor it lands on. Only alongside a named plane: + // `shadowGroundY` says where that floor is and this says which way it + // faces, and tilting the bounds fallback has nothing to tilt about. + let nX = 0; + let nY = -1; + let nZ = 0; + const normal = this.shadowGroundNormal; + if (normal !== undefined && this.shadowGroundY !== undefined) { + // clamped toward up rather than refused; see `clampShadowTilt` + clampShadowTilt(normal.x, normal.y, normal.z, _tilted); + nX = _tilted[0]; + nY = _tilted[1]; + nZ = _tilted[2]; + } + // the quad is a unit square, so a half-extent of `k · axis` needs the // basis column to be twice that const k = (0.5 + strength * 0.5) * 2 * SHADOW_SPREAD * shadowScale; @@ -2056,22 +2293,40 @@ export default class Mesh extends Renderable { quad._modelMatrix = new Matrix3d(); } const out = quad._modelMatrix.val; - out[0] = axX * k; - out[1] = 0; - out[2] = axZ * k; + // Both ground axes turned onto the plane, and the quad's own axis + // becomes the normal itself — negated, because the quad's vertices + // carry `(0, -1, 0)` normals and the lit batcher pushes those through + // this matrix. At the default normal this is `(0, 1, 0)` and every + // term below is the level case unchanged. + tiltOntoPlane(nX, nY, nZ, axX, axZ, _tilted); + out[0] = _tilted[0] * k; + out[1] = _tilted[1] * k; + out[2] = _tilted[2] * k; out[3] = 0; - out[4] = 0; - out[5] = 1; - out[6] = 0; + out[4] = -nX; + out[5] = -nY; + out[6] = -nZ; out[7] = 0; - out[8] = azX * k; - out[9] = 0; - out[10] = azZ * k; + tiltOntoPlane(nX, nY, nZ, azX, azZ, _tilted); + out[8] = _tilted[0] * k; + out[9] = _tilted[1] * k; + out[10] = _tilted[2] * k; out[11] = 0; - out[12] = originX; - // render space is Y-DOWN, so lifting off the floor is a SMALLER y - out[13] = groundY - extent * SHADOW_LIFT; - out[14] = originZ; + // The offset rides IN the plane, turned onto it like the axes: slid + // horizontally instead, it would climb off a tilted floor by + // `distance * tan(tilt)` and hang in the air or sink into the hill. + tiltOntoPlane(nX, nY, nZ, offsetX, offsetZ, _tilted); + // And the lift steps along the normal. At the default that is a + // SMALLER y, which is off the floor in a Y-down space, exactly as + // before. `extent` is measured BEFORE any stretch, so pulling the + // blob out does not also lift it off the floor it is lying on. + const lift = extent * SHADOW_LIFT; + out[12] = originX + _tilted[0] + nX * lift; + // The blob still sits directly UNDER the caster: the normal turns it, + // it does not move it. Projecting the origin along the normal instead + // would slide a jumping character's shadow down the slope as it rose. + out[13] = groundY + _tilted[1] + nY * lift; + out[14] = originZ + _tilted[2] + nZ * lift; out[15] = 1; // Stash and restore by hand rather than save()/restore(): restore() @@ -2088,7 +2343,9 @@ export default class Mesh extends Renderable { const savedTintAlpha = tint.alpha; const savedAlpha = renderer.getGlobalAlpha(); tint.setColor(0, 0, 0); - renderer.setGlobalAlpha(this.shadowOpacity * strength * savedAlpha); + renderer.setGlobalAlpha( + this.shadowOpacity * strength * stretchFade * savedAlpha, + ); try { renderer.drawMesh(quad, quad._modelMatrix); } finally { @@ -2097,6 +2354,57 @@ export default class Mesh extends Renderable { } } + /** + * The UP normal of the floor this object's shadow lands on, for a + * surface that is not level. + * + * {@link Mesh#shadowGroundY} says where the floor is and this says + * which way it faces, so it is honoured only alongside it: tilting the + * fallback plane, which is the caster's own bounds, has no anchor to + * tilt about. Defaults to world up, which is `(0, -1, 0)` because + * render space is Y-down, and that default reproduces a level blob + * exactly. + * + * The blob is ROTATED onto the plane, not projected, so it keeps its + * shape; it still sits directly under the caster rather than sliding + * along the slope, and it is still a flat quad with no contact with + * what it lies on. The tilt is clamped at 75 degrees, past which an + * edge-on blob has nothing left to show. + * + * On an {@link InstancedMesh} the plane serves the WHOLE set: it passes + * through the set's origin at `shadowGroundY` and faces this way, and + * every blob is projected onto it whatever height its own instance sits + * at, exactly as the level case puts every blob at one height. A scatter + * spread over curved ground is therefore one set per flat facet, each + * with the facet's own plane, not one set with an averaged normal. + * @type {Vector3d|undefined} + * @default undefined + * @example + * // a prop resting on a 20 degree slope that falls away along +x + * prop.shadowGroundY = floorY; + * prop.shadowGroundNormal = [Math.sin(0.35), -Math.cos(0.35), 0]; + */ + get shadowGroundNormal() { + return this._shadowGroundNormal; + } + + /** + * Takes an array or a `Vector3d` and keeps a normalized copy of its own, + * so a game that goes on mutating what it passed does not steer this + * mesh's shadow by accident. `undefined` puts the blob back level. + * @param {number[]|Vector3d|undefined} value - the floor's up normal + * @example + * // a prop standing on a 20 degree hillside: `shadowGroundY` says where + * // the floor is, this says which way it faces. World up is (0, -1, 0), + * // because render space is Y-down. + * prop.shadowGroundY = groundAt(prop.pos.x); + * prop.shadowGroundNormal = [Math.sin(0.35), -Math.cos(0.35), 0]; + */ + set shadowGroundNormal(value) { + this._shadowGroundNormal = + value === undefined ? undefined : readShadowNormal(value); + } + /** * Draw the mesh (automatically called by melonJS). Picks between two * projection paths based on the camera that was active when this mesh diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 27e36aa25e..18285ec936 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -169,6 +169,12 @@ export default class Sprite3d extends Mesh { * @param {number} [settings.shadowGroundY] - world Y of the floor the blob shadow lands on. Omit and it falls back to the sprite's own base — which for a billboard moves with the camera, so a scene that knows where its floor is should say so. * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the sprite, before any height fade * @param {number} [settings.shadowScale=1] - multiplier for the ground shadow's footprint; non-positive or non-finite values hide the shadow + * @param {number} [settings.shadowOffset=0] - how far to slide the shadow along its direction, in multiples of the blob's own radius; needs `shadowGroundY` (see {@link Mesh#shadowOffset}) + * @param {number} [settings.shadowStretch=1] - how much longer the blob is along its direction, clamped to 3 + * @param {number} [settings.shadowDirectionX=0] - x of the ground direction the shadow is cast along + * @param {number} [settings.shadowDirectionZ=0] - z of the ground direction the shadow is cast along + * @param {number[]|Vector3d} [settings.shadowGroundNormal] - the UP normal of the floor the shadow lands on; needs `shadowGroundY` (see {@link Mesh#shadowGroundNormal}) + * @param {Light3d} [settings.shadowLight] - a {@link Light3d} to take that direction from instead * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}. * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it. * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded. @@ -287,9 +293,18 @@ export default class Sprite3d extends Mesh { // raw for the same reason as above: `undefined` means "decide from // the draw's alpha", and coercing it would pin every sprite opaque transparent: settings.transparent, + // raw for the same reason: `Mesh` reads it as `!== false`, so an + // omitted value has to arrive as `undefined` to mean "depth tested" + depthTest: settings.depthTest, shadowGroundY: settings.shadowGroundY, shadowOpacity: settings.shadowOpacity, shadowScale: settings.shadowScale, + shadowOffset: settings.shadowOffset, + shadowStretch: settings.shadowStretch, + shadowDirectionX: settings.shadowDirectionX, + shadowDirectionZ: settings.shadowDirectionZ, + shadowGroundNormal: settings.shadowGroundNormal, + shadowLight: settings.shadowLight, }); /** diff --git a/packages/melonjs/src/renderable/trigger.js b/packages/melonjs/src/renderable/trigger.js index a65c1ec323..b9cac518d8 100644 --- a/packages/melonjs/src/renderable/trigger.js +++ b/packages/melonjs/src/renderable/trigger.js @@ -87,6 +87,14 @@ export default class Trigger extends Renderable { * @param {number} [settings.lightIntensityScale] - Scale the glTF lights' authored intensity. See {@link level.load} * @param {boolean} [settings.castGroundShadow] - Enable ground shadows for glTF meshes. See {@link level.load} * @param {number} [settings.shadowGroundY] - World Y for glTF ground shadows. See {@link level.load} + * @param {number} [settings.shadowOpacity] - How dark those blobs are. See {@link level.load} + * @param {number} [settings.shadowScale] - Blob footprint multiplier. See {@link level.load} + * @param {number} [settings.shadowOffset] - Slide them along the light, in blob radii. See {@link level.load} + * @param {number} [settings.shadowStretch] - Lengthen them along it. See {@link level.load} + * @param {number} [settings.shadowDirectionX] - X of the ground direction they are cast along. See {@link level.load} + * @param {number} [settings.shadowDirectionZ] - Z of that direction. See {@link level.load} + * @param {number[]|Vector3d} [settings.shadowGroundNormal] - Up normal of the floor, for a scene that is not level. See {@link level.load} + * @param {Light3d} [settings.shadowLight] - A light to take the shadow direction from. See {@link level.load} * @example * // fade transition (default) * world.addChild(new Trigger(x, y, { diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert index 34ec2958c9..c4a2bad1ed 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert @@ -23,9 +23,13 @@ // horizontal scale = length(vec3(aInstanceRow0.x, aInstanceRow1.x, aInstanceRow2.x)) // // Flattening onto the ground plane is `uModelMatrix`'s job: the caller passes -// the group matrix with its Y basis column zeroed and its translation Y set +// the group matrix with its Y ROW replaced by the floor's plane equation (zero +// on level ground, the slope's gradient otherwise) and its translation Y set // to the ground height, so whatever Y this stage produces is discarded and -// every blob lands on the floor. +// every blob is projected onto the floor. That is also why `aVertex.y` is +// dropped below without loss: on a slope the caller bakes the quad already +// rotated into the plane, and a vertical projection of its footprint is the +// rotated quad itself. attribute vec3 aVertex; attribute vec2 aRegion; attribute vec4 aColor; diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl index a8130c33ce..fbf838c915 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl @@ -18,8 +18,13 @@ // however its tree is turned, and only the horizontal footprint sets its // width. Translation and horizontal scale come straight out of the record's // three rows with no matrix built. Flattening onto the ground is -// `uMesh.model`'s job — the caller passes the group matrix with its Y basis -// column zeroed and its translation Y set to the ground height. +// `uMesh.model`'s job — the caller passes the group matrix with its Y ROW +// replaced by the floor's plane equation (zero on level ground, the slope's +// gradient otherwise) and its translation Y set to the ground height, so +// every blob is projected onto the floor. That is also why `aVertex.y` is +// dropped below without loss: on a slope the caller bakes the quad already +// rotated into the plane, and a vertical projection of its footprint is the +// rotated quad itself. struct FrameUniforms { projection : mat4x4, diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index 8013d41d82..7191058419 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -6,6 +6,7 @@ import { level, loader, Mesh, + Vector3d, video, } from "../src/index.js"; import GLTFScene from "../src/level/gltf/GLTFScene.js"; @@ -878,6 +879,138 @@ describe("GLTFScene → Mesh instantiation", () => { delete gltfList[NAME]; }); + it("forwards every ground-shadow option to the meshes it builds", () => { + const scene = new GLTFScene(NAME); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + const sun = { direction: { x: 1, y: 1, z: 0 } }; + scene.addTo(container, { + scale: 10, + castGroundShadow: true, + shadowGroundY: 42, + shadowOpacity: 0.8, + shadowScale: 1.75, + shadowOffset: 0.9, + shadowStretch: 2.25, + shadowDirectionX: 3, + shadowDirectionZ: 4, + shadowGroundNormal: [0.5, -Math.sqrt(0.75), 0], + shadowLight: sun, + }); + // `GLTFScene` builds its own settings object rather than passing the + // caller's, so an option it forgets to name is dropped in silence. + // Only `castGroundShadow` and `shadowGroundY` used to be named, which + // left a loaded scene unable to say where its sun was. + const mesh = container.kids.find((k) => { + return k.castGroundShadow === true; + }); + expect(mesh).toBeDefined(); + expect(mesh.shadowGroundY).toBe(42); + expect(mesh.shadowOpacity).toBe(0.8); + expect(mesh.shadowScale).toBe(1.75); + expect(mesh.shadowOffset).toBe(0.9); + expect(mesh.shadowStretch).toBe(2.25); + expect(mesh.shadowDirectionX).toBe(3); + expect(mesh.shadowDirectionZ).toBe(4); + expect(mesh.shadowGroundNormal.x).toBeCloseTo(0.5, 6); + expect(mesh.shadowLight).toBe(sun); + }); + + it("leaves the shadow settings at their defaults when none are given", () => { + const scene = new GLTFScene(NAME); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + scene.addTo(container, { scale: 10 }); + // passed RAW, so an omitted option arrives as `undefined` and the mesh + // keeps its own default rather than being pinned to a coerced value + const mesh = container.kids[0]; + expect(mesh.shadowGroundY).toBeUndefined(); + expect(mesh.shadowLight).toBeUndefined(); + expect(mesh.shadowGroundNormal).toBeUndefined(); + expect(mesh.shadowOpacity).toBe(0.45); + expect(mesh.shadowScale).toBe(1); + expect(mesh.shadowOffset).toBe(0); + expect(mesh.shadowStretch).toBe(1); + }); + + it("ADVERSARIAL: survives the loadOptions filter a forwarder applies", () => { + // `Trigger` and friends do NOT pass a caller's settings through whole: + // they filter by `GLTFScene.loadOptions` (see `level.loadOptions`), so + // an option read by `addTo` but missing from that list is silently + // dropped on every forwarded load. Six glTF options were lost that way + // for three releases (#1649), and the test above would not have caught + // it, because it calls `addTo` directly. This one filters first. + const asked = { + scale: 10, + castGroundShadow: true, + shadowGroundY: 42, + shadowOpacity: 0.8, + shadowScale: 1.75, + shadowOffset: 0.9, + shadowStretch: 2.25, + shadowDirectionX: 3, + shadowDirectionZ: 4, + shadowGroundNormal: [0.5, -Math.sqrt(0.75), 0], + shadowLight: { direction: { x: 1, y: 1, z: 0 } }, + }; + const allowed = new Set(GLTFScene.loadOptions); + const filtered = Object.fromEntries( + Object.entries(asked).filter(([k]) => { + return allowed.has(k); + }), + ); + // nothing was dropped on the way through the list + expect(Object.keys(filtered).sort()).toEqual(Object.keys(asked).sort()); + + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + new GLTFScene(NAME).addTo(container, filtered); + const mesh = container.kids.find((k) => { + return k.castGroundShadow === true; + }); + expect(mesh.shadowStretch).toBe(2.25); + expect(mesh.shadowOffset).toBe(0.9); + expect(mesh.shadowLight).toBe(asked.shadowLight); + }); + + it("ADVERSARIAL: a Vector3d ground normal survives, not just an array", () => { + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + new GLTFScene(NAME).addTo(container, { + scale: 10, + castGroundShadow: true, + shadowGroundY: 0, + shadowGroundNormal: new Vector3d(0.5, -Math.sqrt(0.75), 0), + }); + const mesh = container.kids.find((k) => { + return k.castGroundShadow === true; + }); + // the accessor COPIES and renormalizes, so the mesh must not alias the + // caller's vector: mutating theirs afterwards must not steer the blob + expect(mesh.shadowGroundNormal.x).toBeCloseTo(0.5, 6); + expect(mesh.shadowGroundNormal.y).toBeCloseTo(-Math.sqrt(0.75), 6); + }); + it("instantiates one Mesh per mesh node into the container", () => { const scene = new GLTFScene(NAME); const container = { diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index 894968d634..10c9738a60 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -422,6 +422,505 @@ describe("Ground shadows (#1515)", () => { }); }); + /** + * Offset and stretch (#1631 items 2 and 3). + * + * The blob is a flat quad on one named plane, so an offset is only honest + * where the game has NAMED that plane. The stretch needs no new primitive: + * the basis is already an oriented, anisotropic pair, so pulling it out is + * an anisotropic scale `S = I + (stretch - 1)·d⊗d` applied in world XZ. + */ + describe("shadowOffset / shadowStretch", () => { + /** + * A mesh on a NAMED floor, which is what an offset needs, and sitting + * ON it rather than above it. + * + * `shadowGroundY` at the caster's own origin means no height fade, so + * `strength` is 1 and the drawn blob is its full-strength size. That + * matters because `shadowOffset` is measured in full-strength radii: + * it deliberately does NOT shrink with the fade, so that a rising + * object's shadow does not slide back under it as it goes. + */ + const onFloor = (settings = {}) => { + return makeMesh({ + castGroundShadow: true, + shadowGroundY: 0, + ...settings, + }); + }; + const origin = (lit = false) => { + const m = renderer._shadowQuads[lit ? "lit" : "unlit"]._modelMatrix.val; + return { x: m[12], z: m[14] }; + }; + + /** the blob's own radius, which is the unit `shadowOffset` is in */ + const blobRadius = () => { + const a = shadowAxes(); + return (a.x + a.z) / 2; + }; + + it("slides the blob by the asked multiple of its OWN radius", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor(); + drawOnce(mesh); + drawOnce(mesh); + const before = origin(); + const r = blobRadius(); + mesh.shadowDirectionX = 3; + mesh.shadowDirectionZ = 4; // length 5, so it must be normalised + mesh.shadowOffset = 2; + drawOnce(mesh); + const after = origin(); + // 2 radii along (0.6, 0.8) + expect(after.x - before.x).toBeCloseTo(2 * r * 0.6, 4); + expect(after.z - before.z).toBeCloseTo(2 * r * 0.8, 4); + mesh.destroy(); + }); + + it("throws two differently sized casters the same RELATIVE distance", (ctx) => { + requireWebGL(ctx, renderer); + // the whole reason the unit is a ratio: one value has to serve a + // big caster and a small one, which a world distance cannot do + const shifts = []; + for (const width of [16, 64]) { + const mesh = onFloor({ width, shadowDirectionX: 1 }); + drawOnce(mesh); + drawOnce(mesh); + const before = origin().x; + const r = blobRadius(); + mesh.shadowOffset = 1.5; + drawOnce(mesh); + shifts.push((origin().x - before) / r); + mesh.destroy(); + } + // different sizes, different world distances, same ratio + expect(shifts[0]).toBeCloseTo(1.5, 4); + expect(shifts[1]).toBeCloseTo(1.5, 4); + }); + + it("refuses to slide off a plane the game never named", (ctx) => { + requireWebGL(ctx, renderer); + // no `shadowGroundY`: the blob falls back to the caster's own base, + // and there is no claim about where the floor is to slide across + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + const before = origin(); + mesh.shadowDirectionX = 1; + mesh.shadowOffset = 25; + drawOnce(mesh); + expect(origin().x).toBeCloseTo(before.x, 6); + expect(origin().z).toBeCloseTo(before.z, 6); + mesh.destroy(); + }); + + it("does nothing without a direction to go in", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor(); + // twice: the caster's half-extent is resolved lazily on the first + // shadowed draw, so the first frame is not comparable with later ones + drawOnce(mesh); + drawOnce(mesh); + const before = { o: origin(), a: shadowAxes() }; + // asked for both, but with nowhere to point + mesh.shadowOffset = 40; + mesh.shadowStretch = 2; + drawOnce(mesh); + expect(origin().x).toBeCloseTo(before.o.x, 6); + expect(origin().z).toBeCloseTo(before.o.z, 6); + expect(shadowAxes().x).toBeCloseTo(before.a.x, 6); + expect(shadowAxes().z).toBeCloseTo(before.a.z, 6); + mesh.destroy(); + }); + + it("lengthens along the direction and leaves the perpendicular alone", (ctx) => { + requireWebGL(ctx, renderer); + // direction along world X, so the X axis stretches and Z must not + const mesh = onFloor({ shadowDirectionX: 1, shadowDirectionZ: 0 }); + drawOnce(mesh); + const before = shadowAxes(); + mesh.shadowStretch = 2; + drawOnce(mesh); + const after = shadowAxes(); + expect(after.x).toBeCloseTo(before.x * 2, 5); + expect(after.z).toBeCloseTo(before.z, 5); + mesh.destroy(); + }); + + it("stretches along a DIAGONAL, where both basis axes contribute", (ctx) => { + requireWebGL(ctx, renderer); + // A direction along a world axis only exercises one of the two + // basis columns on an axis-aligned caster: the other projects to + // zero and could be left untouched without anything noticing. + // At 45 degrees both carry a share. + const d = Math.SQRT1_2; + // the blob is a unit square mapped by the two basis columns, so its + // reach along a unit vector is half the sum of their projections + const support = (ux, uz) => { + const m = renderer._shadowQuads.unlit._modelMatrix.val; + return ( + (Math.abs(m[0] * ux + m[2] * uz) + Math.abs(m[8] * ux + m[10] * uz)) * + 0.5 + ); + }; + const mesh = onFloor({ shadowDirectionX: d, shadowDirectionZ: d }); + drawOnce(mesh); + drawOnce(mesh); + const along = support(d, d); + const across = support(d, -d); + mesh.shadowStretch = 2.5; + drawOnce(mesh); + expect(support(d, d)).toBeCloseTo(along * 2.5, 4); + // and nothing at right angles to it moved + expect(support(d, -d)).toBeCloseTo(across, 4); + mesh.destroy(); + }); + + it("clamps the stretch rather than letting it run", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1 }); + drawOnce(mesh); + const before = shadowAxes().x; + mesh.shadowStretch = 50; + drawOnce(mesh); + // 3, the ceiling, not 50 + expect(shadowAxes().x).toBeCloseTo(before * 3, 5); + mesh.destroy(); + }); + + it.for([0.25, 0, -2, Number.NaN, Number.POSITIVE_INFINITY])( + "treats a stretch of %s as 1 rather than shrinking the blob", + (stretch, ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1 }); + drawOnce(mesh); + const before = shadowAxes(); + mesh.shadowStretch = stretch; + drawOnce(mesh); + expect(shadowAxes().x).toBeCloseTo(before.x, 5); + expect(shadowAxes().z).toBeCloseTo(before.z, 5); + mesh.destroy(); + }, + ); + + it("fades as it stretches, so an extreme value goes to nothing", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1, shadowOpacity: 0.8 }); + let alpha; + const draw = renderer.drawMesh.bind(renderer); + const spy = vi + .spyOn(renderer, "drawMesh") + .mockImplementation((object, matrix) => { + if (object !== mesh) { + alpha = renderer.getGlobalAlpha(); + } + return draw(object, matrix); + }); + try { + drawOnce(mesh); // warm-up, as above + drawOnce(mesh); + const flat = alpha; + mesh.shadowStretch = 4; // clamps to 3 + drawOnce(mesh); + // The alpha round-trips through an 8-bit packed tint, so it + // lands on the nearest 1/255 and cannot be compared more + // finely than that: 147/255 stretches to 84.87/255, which is + // read back as 85/255. + expect(Math.abs(alpha - flat / Math.sqrt(3))).toBeLessThanOrEqual( + 1 / 255, + ); + // and it is genuinely fainter, not merely different + expect(alpha).toBeLessThan(flat); + } finally { + spy.mockRestore(); + mesh.destroy(); + } + }); + + it("takes the direction from a named light, and follows it when it moves", (ctx) => { + requireWebGL(ctx, renderer); + const sun = { direction: { x: 1, y: -1, z: 0 } }; + const mesh = onFloor({ shadowLight: sun, shadowOffset: 2 }); + drawOnce(mesh); + drawOnce(mesh); + const east = origin(); + const reach = 2 * blobRadius(); + // the sun swings round; the shadow goes with it + sun.direction.x = 0; + sun.direction.z = 1; + drawOnce(mesh); + const south = origin(); + expect(east.x - south.x).toBeCloseTo(reach, 4); + expect(south.z - east.z).toBeCloseTo(reach, 4); + mesh.destroy(); + }); + + it("prefers the light over a direction set by hand", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ + shadowDirectionX: -1, + shadowLight: { direction: { x: 1, y: -1, z: 0 } }, + shadowOffset: 1.5, + }); + drawOnce(mesh); + drawOnce(mesh); + const withLight = origin().x; + const reach = 1.5 * blobRadius(); + mesh.shadowLight = undefined; + drawOnce(mesh); + // the hand-set direction is the OPPOSITE way, so the two differ by 2x + expect(withLight - origin().x).toBeCloseTo(2 * reach, 4); + mesh.destroy(); + }); + + it("puts an instanced throw in the matrix and the stretch nowhere near it", (ctx) => { + requireWebGL(ctx, renderer); + // The offset is ONE translation shared by the whole set, so the + // group matrix carries it. The stretch must NOT: an anisotropic + // scale there reaches the instance POSITIONS through the same + // matrix and smears the scatter. It rides the shared quad instead + // (see `InstancedMesh > shadowStretch`), which is why the matrix + // below is untouched by it. + const build = () => { + const mesh = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: 2, + castGroundShadow: true, + shadowGroundY: 20, + shadowDirectionX: 1, + shadowDirectionZ: 0, + }); + const placement = new Matrix3d(); + for (let i = 0; i < 2; i++) { + placement.identity().translate(i * 8, 0, 0); + mesh.setInstance(i, placement); + } + return mesh; + }; + const matrixOf = (m) => { + const spy = vi.spyOn(renderer, "drawInstancedShadow"); + try { + drawOnce(m); + expect(spy).toHaveBeenCalled(); + return [...spy.mock.calls[0][1].val]; + } finally { + spy.mockRestore(); + } + }; + const mesh = build(); + const before = matrixOf(mesh); + mesh.shadowOffset = 2; + const after = matrixOf(mesh); + // the throw moved it along +x, and only the translation moved + expect(after[12] - before[12]).toBeGreaterThan(1); + expect(after[14]).toBeCloseTo(before[14], 6); + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10]) { + expect(after[i]).toBeCloseTo(before[i], 6); + } + + // the stretch, by contrast, leaves this matrix exactly as it was + const pinned = matrixOf(mesh); + mesh.shadowStretch = 3; + expect(matrixOf(mesh)).toEqual(pinned); + mesh.destroy(); + }); + + it("leaves the lift alone, so a stretched blob still lies on the floor", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1 }); + drawOnce(mesh); + const before = shadowGroundY(); + mesh.shadowStretch = 3; + drawOnce(mesh); + expect(shadowGroundY()).toBeCloseTo(before, 6); + mesh.destroy(); + }); + }); + + /** + * `shadowGroundNormal` — the blob on a floor that is not level. + * + * The blob is ROTATED onto the plane rather than projected, so it keeps + * its shape: a vertical projection would lengthen it by `1/cos(tilt)` and + * an orthogonal one shrink it by `cos(tilt)`, growing exactly where the + * feature is used. It also stays directly under the caster; the normal + * turns it, it does not move it. + */ + describe("shadowGroundNormal", () => { + const onFloor = (settings = {}) => { + return makeMesh({ + castGroundShadow: true, + shadowGroundY: 0, + ...settings, + }); + }; + const matrix = () => { + return [...renderer._shadowQuads.unlit._modelMatrix.val]; + }; + /** the quad's three basis columns, as vectors */ + const basis = (m) => { + return { + ax: [m[0], m[1], m[2]], + up: [m[4], m[5], m[6]], + az: [m[8], m[9], m[10]], + }; + }; + const dot = (a, b) => { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + }; + const len = (a) => { + return Math.hypot(a[0], a[1], a[2]); + }; + const unit = (v) => { + const l = len(v); + return [v[0] / l, v[1] / l, v[2] / l]; + }; + + it("world up is the default, and reproduces a level blob exactly", (ctx) => { + requireWebGL(ctx, renderer); + const plain = onFloor(); + drawOnce(plain); + drawOnce(plain); + const before = matrix(); + // WORLD_UP is (0, -1, 0): render space is Y-down + plain.shadowGroundNormal = [0, -1, 0]; + drawOnce(plain); + expect(matrix()).toEqual(before); + plain.destroy(); + }); + + it("lays the blob in the plane, both axes perpendicular to the normal", (ctx) => { + requireWebGL(ctx, renderer); + for (const n of [ + [0.6, -0.8, 0], + [0, -0.707106, 0.707106], + [0.4, -0.6, 0.69282], + ]) { + const mesh = onFloor({ shadowGroundNormal: n }); + drawOnce(mesh); + drawOnce(mesh); + const b = basis(matrix()); + const u = unit(n); + expect(Math.abs(dot(unit(b.ax), u))).toBeLessThan(1e-6); + expect(Math.abs(dot(unit(b.az), u))).toBeLessThan(1e-6); + // and the quad's own axis IS the normal, negated + expect(b.up[0]).toBeCloseTo(-u[0], 5); + expect(b.up[1]).toBeCloseTo(-u[1], 5); + expect(b.up[2]).toBeCloseTo(-u[2], 5); + mesh.destroy(); + } + }); + + it("rotates rather than projects, so the blob keeps its size", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor(); + drawOnce(mesh); + drawOnce(mesh); + const flat = basis(matrix()); + // 40 degrees of tilt: a projection would change these by 1/cos or + // cos, about 30% either way + mesh.shadowGroundNormal = [Math.sin(0.698), -Math.cos(0.698), 0]; + drawOnce(mesh); + const tilted = basis(matrix()); + expect(len(tilted.ax)).toBeCloseTo(len(flat.ax), 5); + expect(len(tilted.az)).toBeCloseTo(len(flat.az), 5); + }); + + it("keeps the blob under the caster, and lifts along the normal", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor(); + drawOnce(mesh); + drawOnce(mesh); + const flat = matrix(); + mesh.shadowGroundNormal = [0.5, -Math.sqrt(0.75), 0]; + drawOnce(mesh); + const tilted = matrix(); + // the lift is the only thing that moved it, and it went along the + // normal: a +x component appears where a level blob had none + const liftY = flat[13] - 0; + expect(tilted[12] - flat[12]).toBeCloseTo(0.5 * -liftY, 5); + expect(tilted[14]).toBeCloseTo(flat[14], 6); + mesh.destroy(); + }); + + it("carries the offset IN the plane instead of sliding off it", (ctx) => { + requireWebGL(ctx, renderer); + // without this the blob climbs off a tilted floor by + // distance * tan(tilt) and hangs in the air + const n = [Math.sin(0.6), -Math.cos(0.6), 0]; + const mesh = onFloor({ + shadowGroundNormal: n, + shadowDirectionX: 1, + shadowDirectionZ: 0, + }); + drawOnce(mesh); + drawOnce(mesh); + const before = matrix(); + mesh.shadowOffset = 2; + drawOnce(mesh); + const after = matrix(); + const moved = [ + after[12] - before[12], + after[13] - before[13], + after[14] - before[14], + ]; + // perpendicular to the normal: it stayed on the floor + expect(Math.abs(dot(moved, n))).toBeLessThan(1e-5); + expect(len(moved)).toBeGreaterThan(1); + mesh.destroy(); + }); + + it("clamps the tilt rather than letting a blob stand on its rim", (ctx) => { + requireWebGL(ctx, renderer); + // very nearly vertical, which would be edge-on and invisible + const mesh = onFloor({ shadowGroundNormal: [0.999, -0.0447, 0] }); + drawOnce(mesh); + drawOnce(mesh); + const up = basis(matrix()).up; + // 75 degrees from world up is the ceiling + const tilt = Math.acos(Math.min(1, Math.abs(up[1]))); + expect((tilt * 180) / Math.PI).toBeCloseTo(75, 3); + mesh.destroy(); + }); + + it("is ignored without a named plane to tilt", (ctx) => { + requireWebGL(ctx, renderer); + // no shadowGroundY: the fallback is the caster's own bounds, which + // gives the tilt nothing to turn about + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + drawOnce(mesh); + const before = matrix(); + mesh.shadowGroundNormal = [0.6, -0.8, 0]; + drawOnce(mesh); + expect(matrix()).toEqual(before); + mesh.destroy(); + }); + + it("normalizes what it is given, and copies it", (ctx) => { + requireWebGL(ctx, renderer); + const given = [0, -7, 0]; // not a unit vector + const mesh = onFloor({ shadowGroundNormal: given }); + expect(mesh.shadowGroundNormal.y).toBeCloseTo(-1, 9); + // mutating what was passed must not steer the mesh + given[0] = 99; + expect(mesh.shadowGroundNormal.x).toBe(0); + mesh.destroy(); + }); + + it("falls back to level for a zero normal rather than dividing by it", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowGroundNormal: [0, 0, 0] }); + drawOnce(mesh); + drawOnce(mesh); + const b = basis(matrix()); + expect(b.up[1]).toBeCloseTo(1, 9); + expect(Number.isFinite(matrix()[12])).toBe(true); + mesh.destroy(); + }); + }); + // ── Sprite3d, which is the whole point of the feature ─────────────── describe("Sprite3d", () => { @@ -533,6 +1032,639 @@ describe("Ground shadows (#1515)", () => { return mesh; }; + /** + * `shadowGroundNormal` on a SET: one plane for all of it, tilted. + * + * The vertex stage builds `instancePos + (v.x, 0, v.z) * footprint` + * and pushes the sum through ONE matrix, so the slope has to live in + * that matrix's Y row as the plane's equation: a vertical projection. + * The quad is baked pre-rotated into the plane so the projection + * returns its true shape. Nothing about that is visible from a draw + * count, so these replay exactly what the shader computes, from the + * matrix handed to `drawInstancedShadow` and the baked quad, and check + * where every blob vertex lands. The first case is the one the + * example found: a row left as the group's keeps each instance's + * height but drops the quad's tilt, and a row zeroed drops both. + */ + describe("shadowGroundNormal", () => { + const sloped = (settings = {}) => { + const mesh = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: 4, + castGroundShadow: true, + ...settings, + }); + const placement = new Matrix3d(); + for (let i = 0; i < 4; i++) { + // spread along X, climbing in Y, one of them twice the size: + // a set that is NOT on one height, so a row that passed an + // instance's own height through would show + placement.identity().translate(i * 8, i * 5, i * 2); + if (i === 2) { + placement.scale(2, 2, 2); + } + mesh.setInstance(i, placement); + } + return mesh; + }; + const shadowMatrix = (mesh) => { + const spy = vi.spyOn(renderer, "drawInstancedShadow"); + try { + drawOnce(mesh); + expect(spy).toHaveBeenCalled(); + return [...spy.mock.calls[0][1].val]; + } finally { + spy.mockRestore(); + } + }; + /** the vertex stage replayed: Y of the baked vertex DROPPED */ + const landed = (m, mesh, i, k) => { + const record = mesh.getInstance(i).val; + const footprint = Math.hypot(record[0], record[1], record[2]); + const v = mesh._shadowQuad.originalVertices; + const x = record[12] + v[k * 3] * footprint; + const y = record[13]; + const z = record[14] + v[k * 3 + 2] * footprint; + return [ + m[0] * x + m[4] * y + m[8] * z + m[12], + m[1] * x + m[5] * y + m[9] * z + m[13], + m[2] * x + m[6] * y + m[10] * z + m[14], + ]; + }; + const NORMAL = [Math.sin(0.6), -Math.cos(0.6), 0]; + + it("lands every blob vertex on the plane, whatever height its instance sits at", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = sloped({ shadowGroundY: 40, shadowGroundNormal: NORMAL }); + const m = shadowMatrix(mesh); + const lift = mesh.meshScale * 0.01; + const sx = -NORMAL[0] / NORMAL[1]; + // the plane through the set's origin at groundY, lifted along + // its normal + const planeY = (x) => { + return 40 + sx * (x - m[12]) + lift / NORMAL[1]; + }; + const heights = new Set(); + for (let i = 0; i < 4; i++) { + for (let k = 0; k < 4; k++) { + const q = landed(m, mesh, i, k); + expect(q[1]).toBeCloseTo(planeY(q[0]), 4); + heights.add(Math.round(q[1])); + } + } + // ...and it IS a slope: the blobs do not share one height + expect(heights.size).toBeGreaterThan(1); + mesh.destroy(); + }); + + it("keeps the blob's size on the slope rather than stretching it", (ctx) => { + requireWebGL(ctx, renderer); + // 60 degrees: a flat quad projected vertically onto this plane + // would come out twice as long along the slope + const steep = [Math.sin(Math.PI / 3), -Math.cos(Math.PI / 3), 0]; + const level = sloped({ shadowGroundY: 40 }); + const tilted = sloped({ shadowGroundY: 40, shadowGroundNormal: steep }); + const flat = shadowMatrix(level); + const m = shadowMatrix(tilted); + const span = (mat, mesh) => { + const a = landed(mat, mesh, 0, 0); + const b = landed(mat, mesh, 0, 1); + return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); + }; + expect(span(m, tilted)).toBeCloseTo(span(flat, level), 3); + level.destroy(); + tilted.destroy(); + }); + + it("is ignored without shadowGroundY, like the per-object tier", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = sloped({ shadowGroundNormal: NORMAL }); + const m = shadowMatrix(mesh); + expect(m[1]).toBe(0); + expect(m[5]).toBe(0); + expect(m[9]).toBe(0); + expect(m[13]).toBeCloseTo( + mesh.getBounds3d().bottom - mesh.meshScale * 0.01, + 6, + ); + mesh.destroy(); + }); + + it("clamps the tilt at 75 degrees on a set too", (ctx) => { + requireWebGL(ctx, renderer); + const past = [Math.sin(1.48), -Math.cos(1.48), 0]; + const mesh = sloped({ shadowGroundY: 40, shadowGroundNormal: past }); + const m = shadowMatrix(mesh); + // the row's gradient along X is the slope the plane was given + const gradient = m[1] / m[0]; + expect(gradient).toBeCloseTo(Math.tan((75 * Math.PI) / 180), 5); + expect(gradient).toBeLessThan(Math.tan(1.48)); + mesh.destroy(); + }); + + it("world up is the default, and reproduces the level matrix exactly", (ctx) => { + requireWebGL(ctx, renderer); + const plain = sloped({ shadowGroundY: 40 }); + const named = sloped({ + shadowGroundY: 40, + shadowGroundNormal: [0, -1, 0], + }); + const a = shadowMatrix(plain); + const b = shadowMatrix(named); + expect(b).toEqual(a); + plain.destroy(); + named.destroy(); + }); + }); + + /** + * `shadowStretch` on a SET. + * + * This used to be refused outright, on the grounds that an anisotropic + * scale reaches the GPU through the group matrix and would smear the + * instance POSITIONS along with each blob. That is true of the MATRIX + * and only of the matrix: the vertex stage builds + * `uModelMatrix * (instancePos + quadOffset * footprint)`, so the + * shared QUAD is the one thing the positions never pass through — the + * same reason the slope's tilt is baked there. One stretch, one + * direction, one quad for the whole set, which is all a set needs. + * + * So these check both halves: that the blob genuinely lengthens along + * the light, and that nothing else in the scatter moved. + */ + describe("shadowStretch", () => { + const EAST = { shadowDirectionX: 1, shadowDirectionZ: 0 }; + + /** the quad's two ground axes, read back from its baked corners */ + const quadAxes = (mesh) => { + const v = mesh._shadowQuad.originalVertices; + return { + u: [(v[3] - v[0]) / 2, (v[4] - v[1]) / 2, (v[5] - v[2]) / 2], + v: [(v[9] - v[0]) / 2, (v[10] - v[1]) / 2, (v[11] - v[2]) / 2], + }; + }; + /** the blob's half-extent along a ground direction */ + const reachAlong = (mesh, dx, dz) => { + const { u, v } = quadAxes(mesh); + return ( + Math.abs(u[0] * dx + u[2] * dz) + Math.abs(v[0] * dx + v[2] * dz) + ); + }; + + it("lengthens the blob along the light and leaves its width alone", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(3, { + shadowGroundY: 0, + ...EAST, + }); + drawOnce(mesh); + const alongFlat = reachAlong(mesh, 1, 0); + const acrossFlat = reachAlong(mesh, 0, 1); + mesh.shadowStretch = 2.5; + drawOnce(mesh); + expect(reachAlong(mesh, 1, 0)).toBeCloseTo(alongFlat * 2.5, 4); + // across the light it is EXACTLY as wide as it was: the scale + // is `I + (s-1)·d⊗d`, which is the identity perpendicular to d + expect(reachAlong(mesh, 0, 1)).toBeCloseTo(acrossFlat, 5); + mesh.destroy(); + }); + + it("stretches along a DIAGONAL light, not along the set's own axes", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(3, { + shadowGroundY: 0, + // length 5, so it has to be normalised before it is used + shadowDirectionX: 3, + shadowDirectionZ: 4, + }); + drawOnce(mesh); + const dx = 0.6; + const dz = 0.8; + const along = reachAlong(mesh, dx, dz); + const across = reachAlong(mesh, -dz, dx); + mesh.shadowStretch = 2; + drawOnce(mesh); + expect(reachAlong(mesh, dx, dz)).toBeCloseTo(along * 2, 4); + expect(reachAlong(mesh, -dz, dx)).toBeCloseTo(across, 4); + mesh.destroy(); + }); + + it("THE POINT: lengthens each blob without moving the scatter", (ctx) => { + requireWebGL(ctx, renderer); + // no offset, so the only thing that could move a blob is the + // stretch leaking into the positions — which is exactly the + // failure the old refusal was written to avoid + const mesh = makeInstanced(4, { shadowGroundY: 0, ...EAST }); + const spy = vi.spyOn(renderer, "drawInstancedShadow"); + const centres = () => { + spy.mockClear(); + drawOnce(mesh); + const m = spy.mock.calls[0][1].val; + return Array.from({ length: 4 }, (_, i) => { + const r = mesh.getInstance(i).val; + return [ + m[0] * r[12] + m[4] * r[13] + m[8] * r[14] + m[12], + m[2] * r[12] + m[6] * r[13] + m[10] * r[14] + m[14], + ]; + }); + }; + try { + const before = centres(); + // the instances are spread along x, so a smear would show + expect(before[3][0]).not.toBeCloseTo(before[0][0], 3); + mesh.shadowStretch = 3; + const after = centres(); + for (let i = 0; i < 4; i++) { + expect(after[i][0]).toBeCloseTo(before[i][0], 5); + expect(after[i][1]).toBeCloseTo(before[i][1], 5); + } + } finally { + spy.mockRestore(); + mesh.destroy(); + } + }); + + it("clamps at 3, the same ceiling the per-object tier uses", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2, { shadowGroundY: 0, ...EAST }); + drawOnce(mesh); + const flat = reachAlong(mesh, 1, 0); + mesh.shadowStretch = 3; + drawOnce(mesh); + const atCeiling = reachAlong(mesh, 1, 0); + mesh.shadowStretch = 50; + drawOnce(mesh); + expect(reachAlong(mesh, 1, 0)).toBeCloseTo(atCeiling, 5); + expect(atCeiling).toBeCloseTo(flat * 3, 4); + mesh.destroy(); + }); + + it.for([1, 0.5, 0, -2, Number.NaN, Number.POSITIVE_INFINITY])( + "leaves the blob alone at shadowStretch %s", + (value, ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2, { shadowGroundY: 0, ...EAST }); + drawOnce(mesh); + const flat = reachAlong(mesh, 1, 0); + mesh.shadowStretch = value; + drawOnce(mesh); + expect(reachAlong(mesh, 1, 0)).toBeCloseTo(flat, 5); + mesh.destroy(); + }, + ); + + it("does nothing without a direction to stretch along", (ctx) => { + requireWebGL(ctx, renderer); + // no light and no direction: a zero-length pair names no way + // to pull, and guessing one would be an invention + const mesh = makeInstanced(2, { shadowGroundY: 0, shadowStretch: 3 }); + drawOnce(mesh); + const pulled = reachAlong(mesh, 1, 0); + mesh.shadowStretch = 1; + drawOnce(mesh); + expect(reachAlong(mesh, 1, 0)).toBeCloseTo(pulled, 5); + mesh.destroy(); + }); + + it("takes the direction from a light, and refreshes in place as it moves", (ctx) => { + requireWebGL(ctx, renderer); + const sun = { direction: { x: 1, y: 1, z: 0 } }; + const mesh = makeInstanced(2, { + shadowGroundY: 0, + shadowLight: sun, + shadowStretch: 2.5, + }); + drawOnce(mesh); + const quad = mesh._shadowQuad; + const version = quad._geometryVersion; + expect(reachAlong(mesh, 1, 0)).toBeGreaterThan(reachAlong(mesh, 0, 1)); + // the sun swings a quarter turn: the blob must follow it + sun.direction.x = 0; + sun.direction.z = 1; + drawOnce(mesh); + expect(reachAlong(mesh, 0, 1)).toBeGreaterThan(reachAlong(mesh, 1, 0)); + // ...and a sweeping sun must not allocate a mesh per frame + expect(mesh._shadowQuad).toBe(quad); + expect(quad._geometryVersion).toBeGreaterThan(version); + mesh.destroy(); + }); + + it("fades as it stretches, like the per-object tier", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2, { + shadowGroundY: 0, + shadowOpacity: 0.8, + ...EAST, + }); + let alpha; + const draw = renderer.drawInstancedShadow.bind(renderer); + const spy = vi + .spyOn(renderer, "drawInstancedShadow") + .mockImplementation((...args) => { + alpha = renderer.getGlobalAlpha(); + return draw(...args); + }); + try { + drawOnce(mesh); + const flat = alpha; + mesh.shadowStretch = 4; // clamps to 3 + drawOnce(mesh); + // the alpha round-trips through an 8-bit packed tint, so it + // lands on the nearest 1/255 and cannot be compared finer + expect(Math.abs(alpha - flat / Math.sqrt(3))).toBeLessThanOrEqual( + 1 / 255, + ); + expect(alpha).toBeLessThan(flat); + } finally { + spy.mockRestore(); + mesh.destroy(); + } + }); + + it("composes with a slope: a stretched blob still lies IN the plane", (ctx) => { + requireWebGL(ctx, renderer); + const normal = [Math.sin(0.6), -Math.cos(0.6), 0]; + const mesh = makeInstanced(3, { + shadowGroundY: 40, + shadowGroundNormal: normal, + shadowStretch: 2.5, + ...EAST, + }); + const spy = vi.spyOn(renderer, "drawInstancedShadow"); + try { + drawOnce(mesh); + const m = spy.mock.calls[0][1].val; + const lift = mesh.meshScale * 0.01; + const sx = -normal[0] / normal[1]; + const q = mesh._shadowQuad.originalVertices; + for (let i = 0; i < 3; i++) { + const r = mesh.getInstance(i).val; + const footprint = Math.hypot(r[0], r[1], r[2]); + for (let k = 0; k < 4; k++) { + // the vertex stage, replayed: the quad's own Y is dropped + const x = r[12] + q[k * 3] * footprint; + const y = r[13]; + const z = r[14] + q[k * 3 + 2] * footprint; + const wx = m[0] * x + m[4] * y + m[8] * z + m[12]; + const wy = m[1] * x + m[5] * y + m[9] * z + m[13]; + expect(wy).toBeCloseTo( + 40 + sx * (wx - m[12]) + lift / normal[1], + 4, + ); + } + } + } finally { + spy.mockRestore(); + mesh.destroy(); + } + }); + + it("matches the per-object tier for the same asset and settings", (ctx) => { + requireWebGL(ctx, renderer); + // the whole reason the two tiers share `resolveShadowStretch` + // and `stretchAlong`: one tree in a scatter and the same tree + // standing loose beside it have to draw the same blob + const settings = { + castGroundShadow: true, + shadowGroundY: 0, + shadowStretch: 2.5, + shadowDirectionX: 3, + shadowDirectionZ: 4, + }; + const loose = makeMesh(settings); + drawOnce(loose); + drawOnce(loose); + // the per-object blob's two ground axes live in its quad's + // model matrix, as the X and Z basis columns of a unit square + const m = renderer._shadowQuads.unlit._modelMatrix.val; + const looseReach = (dx, dz) => { + return ( + Math.abs((m[0] * dx + m[2] * dz) / 2) + + Math.abs((m[8] * dx + m[10] * dz) / 2) + ); + }; + const looseAlong = looseReach(0.6, 0.8); + const looseAcross = looseReach(-0.8, 0.6); + loose.destroy(); + + const set = makeInstanced(1, settings); + drawOnce(set); + // The instanced quad is in MODEL units and the per-object + // matrix in world ones, so the absolute sizes differ by the + // mesh scale. What has to match is the SHAPE: how much longer + // the blob is along the light than across it. + const along = reachAlong(set, 0.6, 0.8); + const across = reachAlong(set, -0.8, 0.6); + expect(along / across).toBeCloseTo(looseAlong / looseAcross, 3); + expect(along).toBeGreaterThan(across); + set.destroy(); + }); + + /* ── adversarial ──────────────────────────────────────────── */ + + it("ADVERSARIAL: stretches along the WORLD light, not the set's own axis", (ctx) => { + requireWebGL(ctx, renderer); + // The quad's axes live in GROUP-LOCAL space, so the world + // direction has to be pulled back through the group matrix. A + // set turned a quarter turn about Y that skipped the pull-back + // would stretch across the light instead of along it, and no + // test on an unrotated set could tell. + // + // Measured as a RATIO against the same set unstretched: the + // fixture's own blob is not round, so an absolute along/across + // number says nothing. + const ratio = (mesh) => { + return reachAlong(mesh, 1, 0) / reachAlong(mesh, 0, 1); + }; + const flat = makeInstanced(2, { shadowGroundY: 0, ...EAST }); + drawOnce(flat); + const base = ratio(flat); + flat.destroy(); + + const upright = makeInstanced(2, { + shadowGroundY: 0, + ...EAST, + shadowStretch: 2.5, + }); + drawOnce(upright); + expect(ratio(upright) / base).toBeCloseTo(2.5, 4); + upright.destroy(); + + // turned a quarter turn: the world +x light now lands on the + // set's local z, so the LONG axis must have swapped sides + const turned = makeInstanced(2, { + shadowGroundY: 0, + ...EAST, + shadowStretch: 2.5, + }); + turned.rotate(Math.PI / 2, new Vector3d(0, 1, 0)); + drawOnce(turned); + const { u, v } = quadAxes(turned); + const lenU = Math.hypot(u[0], u[2]); + const lenV = Math.hypot(v[0], v[2]); + const plain = makeInstanced(2, { shadowGroundY: 0, ...EAST }); + plain.rotate(Math.PI / 2, new Vector3d(0, 1, 0)); + drawOnce(plain); + const flatAxes = quadAxes(plain); + const flatU = Math.hypot(flatAxes.u[0], flatAxes.u[2]); + const flatV = Math.hypot(flatAxes.v[0], flatAxes.v[2]); + // the stretch landed on the OTHER local axis than it did + // upright, which is the pull-back doing its job + expect(lenV / flatV).toBeCloseTo(2.5, 3); + expect(lenU / flatU).toBeCloseTo(1, 3); + turned.destroy(); + plain.destroy(); + }); + + it("ADVERSARIAL: shadowScale and shadowStretch multiply, not override", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2, { shadowGroundY: 0, ...EAST }); + drawOnce(mesh); + const plain = reachAlong(mesh, 1, 0); + mesh.shadowScale = 2; + drawOnce(mesh); + expect(reachAlong(mesh, 1, 0)).toBeCloseTo(plain * 2, 4); + mesh.shadowStretch = 1.5; + drawOnce(mesh); + // both, not the last one written + expect(reachAlong(mesh, 1, 0)).toBeCloseTo(plain * 3, 4); + // ...and across the light only `shadowScale` applies + expect(reachAlong(mesh, 0, 1)).toBeCloseTo(reachAlong(mesh, 0, 1), 6); + mesh.destroy(); + }); + + it("ADVERSARIAL: offset, stretch and a slope all compose", (ctx) => { + requireWebGL(ctx, renderer); + const normal = [Math.sin(0.5), -Math.cos(0.5), 0]; + const sx = -normal[0] / normal[1]; + const build = (offset) => { + return makeInstanced(3, { + shadowGroundY: 40, + shadowGroundNormal: normal, + shadowStretch: 2.5, + shadowOffset: offset, + ...EAST, + }); + }; + const matrixOf = (mesh) => { + const spy = vi.spyOn(renderer, "drawInstancedShadow"); + try { + drawOnce(mesh); + return [...spy.mock.calls[0][1].val]; + } finally { + spy.mockRestore(); + } + }; + const still = build(0); + const a = matrixOf(still); + still.destroy(); + const thrown = build(1.2); + const b = matrixOf(thrown); + + // the throw moved it along the light... + expect(b[12] - a[12]).toBeGreaterThan(1); + // ...and rode IN the plane while doing so, which is the whole + // point: slid flat instead, a stretched set climbs off the hill + // by `distance * tan(tilt)` + expect(b[13] - a[13]).toBeCloseTo(sx * (b[12] - a[12]), 4); + expect(b[14]).toBeCloseTo(a[14], 5); + + // and every blob vertex still lands on that one plane + const q = thrown._shadowQuad.originalVertices; + for (let i = 0; i < 3; i++) { + const r = thrown.getInstance(i).val; + const f = Math.hypot(r[0], r[1], r[2]); + for (let k = 0; k < 4; k++) { + const x = r[12] + q[k * 3] * f; + const z = r[14] + q[k * 3 + 2] * f; + const wx = b[0] * x + b[4] * r[13] + b[8] * z + b[12]; + const wy = b[1] * x + b[5] * r[13] + b[9] * z + b[13]; + expect(wy).toBeCloseTo(b[13] + sx * (wx - b[12]), 3); + } + } + thrown.destroy(); + }); + + it("ADVERSARIAL: a light pointing straight down stretches nothing", (ctx) => { + requireWebGL(ctx, renderer); + // a sun directly overhead has no ground direction at all, and + // normalising a zero-length pair would divide by zero + const noon = { direction: { x: 0, y: 1, z: 0 } }; + const plain = makeInstanced(2, { shadowGroundY: 0, shadowLight: noon }); + drawOnce(plain); + const a = reachAlong(plain, 1, 0); + const b = reachAlong(plain, 0, 1); + plain.destroy(); + + const asked = makeInstanced(2, { + shadowGroundY: 0, + shadowLight: noon, + shadowStretch: 3, + shadowOffset: 2, + }); + drawOnce(asked); + // identical to the set that asked for nothing, and finite + expect(reachAlong(asked, 1, 0)).toBeCloseTo(a, 5); + expect(reachAlong(asked, 0, 1)).toBeCloseTo(b, 5); + expect(Number.isFinite(a)).toBe(true); + asked.destroy(); + }); + + it("ADVERSARIAL: flipping `lit` rebuilds the quad instead of editing it", (ctx) => { + requireWebGL(ctx, renderer); + // the in-place refresh is only safe while the quad still wants + // the same shader; `lit` decides which batcher draws it, so + // that one change has to make a new mesh + const mesh = makeInstanced(2, { + shadowGroundY: 0, + ...EAST, + shadowStretch: 2, + }); + drawOnce(mesh); + const first = mesh._shadowQuad; + expect(first.lit).toBe(false); + mesh.lit = true; + drawOnce(mesh); + expect(mesh._shadowQuad).not.toBe(first); + expect(mesh._shadowQuad.lit).toBe(true); + mesh.destroy(); + }); + + it("ADVERSARIAL: the stretch fade rides ON the caller's alpha", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2, { + shadowGroundY: 0, + shadowOpacity: 0.8, + ...EAST, + }); + let alpha; + const draw = renderer.drawInstancedShadow.bind(renderer); + const spy = vi + .spyOn(renderer, "drawInstancedShadow") + .mockImplementation((...args) => { + alpha = renderer.getGlobalAlpha(); + return draw(...args); + }); + try { + mesh.shadowStretch = 4; // clamps to 3 + renderer.setGlobalAlpha(0.5); + drawOnce(mesh); + // opacity × fade × the alpha already on the renderer, and + // the renderer gets its own alpha back afterwards + expect(Math.abs(alpha - 0.8 / Math.sqrt(3) / 2)).toBeLessThanOrEqual( + 1 / 255, + ); + expect(renderer.getGlobalAlpha()).toBeCloseTo(0.5, 6); + } finally { + renderer.setGlobalAlpha(1); + spy.mockRestore(); + mesh.destroy(); + } + }); + }); + it("costs ONE extra draw, whatever the instance count", (ctx) => { requireWebGL(ctx, renderer); const gl = renderer.gl; @@ -566,6 +1698,7 @@ describe("Ground shadows (#1515)", () => { drawOnce(mesh); const oldQuad = mesh._shadowQuad; const before = oldQuad.originalVertices.slice(); + const version = oldQuad._geometryVersion; expect(Math.abs(before[0])).toBeGreaterThan(0); mesh.shadowScale = scale; drawOnce(mesh); @@ -574,7 +1707,12 @@ describe("Ground shadows (#1515)", () => { for (let i = 0; i < before.length; i++) { expect(resized.originalVertices[i] / scale).toBeCloseTo(before[i], 5); } - expect(resized).not.toBe(oldQuad); + // Resized in PLACE, not rebuilt. Four corners moved, and a + // light that sweeps moves them every frame — allocating a mesh + // and its GPU buffers at that rate to write twelve floats is + // what the geometry version exists to avoid. + expect(resized).toBe(oldQuad); + expect(resized._geometryVersion).toBeGreaterThan(version); drawOnce(mesh); expect(mesh._shadowQuad).toBe(resized); mesh.destroy(); diff --git a/packages/melonjs/tests/sprite3d.spec.js b/packages/melonjs/tests/sprite3d.spec.js index cd537690c0..30f567c783 100644 --- a/packages/melonjs/tests/sprite3d.spec.js +++ b/packages/melonjs/tests/sprite3d.spec.js @@ -305,6 +305,38 @@ describe("Sprite3d billboard projection", () => { expect(c.alphaCutoff).toBe(0.25); }); + it("forwards depthTest to the mesh, which documents it and reads `!== false`", () => { + const img = document.createElement("canvas"); + img.width = 32; + img.height = 32; + img.getContext("2d").fillRect(0, 0, 32, 32); + // omitted → depth tested, the documented default + expect( + new Sprite3d(0, 0, { image: img, width: 32, height: 32 }).depthTest, + ).toBe(true); + // `false` has to survive the constructor: `Sprite3d` builds its own + // settings object for `Mesh` rather than passing the caller's, so a key + // it forgets to name is dropped in silence — which is what happened + // here, leaving a documented option with no effect at all. + expect( + new Sprite3d(0, 0, { + image: img, + width: 32, + height: 32, + depthTest: false, + }).depthTest, + ).toBe(false); + // and a `true` passed by hand is still true, not flattened + expect( + new Sprite3d(0, 0, { + image: img, + width: 32, + height: 32, + depthTest: true, + }).depthTest, + ).toBe(true); + }); + it("world quad size falls back to the frame size when width is omitted", () => { const sheet = document.createElement("canvas"); sheet.width = 64;