diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts index 1fdc49071..dc6889697 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, @@ -694,7 +698,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) — @@ -1415,7 +1425,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 +1490,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 f428acbe3..5539ac59a 100644 --- a/packages/examples/src/examples/jungleRabbit/constants.ts +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -91,14 +91,52 @@ 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_LIFT = 8; +export const SHADOW_DIR_X = 0; +export const SHADOW_DIR_Z = -1; +export const SHADOW_STRETCH = 2.5; /** length of one terrain tile along +Z */ export const TILE_LEN = 2400; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 0cd1b2d09..f86564afa 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.8.0] (melonJS 2) - _unreleased_ ### Added +- **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. Per-object, and art direction rather than a projection ([#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 @@ -17,6 +18,7 @@ - **`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 ### Fixed +- `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/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index ec215dc4f..f20774799 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -667,16 +667,43 @@ skip geometry with no vertical extent — a ground plane. Per object, given, safeguard included; `shadowGroundY` names the floor the blob lands on. 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. + +Use the shape controls instead: + +```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. Both are per-object — an `InstancedMesh` shares one quad +across instances that each carry their own rotation, so it ignores them. + +`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. + +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 diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 8391f92c8..e154cde89 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -102,6 +102,12 @@ 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.shadowOffset=0] - world distance to slide them along their direction; 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 {Light3d} [options.shadowLight] - a {@link Light3d} to take that direction from instead */ constructor(data, options = {}) { super(0, 0); @@ -289,6 +295,16 @@ 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, + shadowOffset: options.shadowOffset, + shadowStretch: options.shadowStretch, + shadowDirectionX: options.shadowDirectionX, + shadowDirectionZ: options.shadowDirectionZ, + shadowLight: options.shadowLight, }); if (prim.instances) { fillInstances(mesh, prim.instances); diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index 63c34793e..07d8ed918 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -725,6 +725,19 @@ export default class InstancedMesh extends Mesh { * @internal */ _drawInstancedGroundShadow(renderer) { + // NOTE: `shadowOffset` and `shadowStretch` are per-object controls and + // are ignored here. That is a CHOICE, and only half of it is forced. + // + // `shadowStretch` genuinely cannot work: an anisotropic world-space + // scale reaches this path through the group matrix, which multiplies + // the instance POSITIONS as well as each quad, so it would smear the + // whole scatter rather than lengthen each blob. `shadowOffset` is a + // pure translation and would compose perfectly well. + // + // Honouring the one that works and not the one that does not would + // leave an instanced set lit by the same settings as its per-object + // neighbours and looking different for no reason the game can see, so + // neither applies. `shadowScale` is uniform and has no such problem. if (typeof renderer.drawInstancedShadow !== "function") { return; } diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index de8907379..d028ee3e8 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -30,6 +30,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 +61,18 @@ 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; +/** + * Ceiling on {@link Mesh#shadowStretch}. + * + * A stretched ellipse is not a projected silhouette, and the further it is + * pulled the more plainly it is a smear rather than a shadow. Past about three + * times its own footprint there is nothing left to read, so the setting stops + * there rather than letting a game dial in something that can only look broken. + * @ignore + * @internal + */ +const SHADOW_MAX_STRETCH = 3; + // reusable matrix for combining projection × model in draw() const _combinedMatrix = new Matrix3d(); @@ -378,6 +391,11 @@ function buildTextureGroups( * @property {boolean} [fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does * @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} [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. + * @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. + * @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 {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. */ /** @@ -932,6 +950,85 @@ export default class Mesh extends Renderable { ? settings.shadowOpacity : 0.45; + /** + * 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. + * + * 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. + * + * Per-object: an {@link InstancedMesh} ignores this, because its + * instances share one quad and each carries its own rotation. + * @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. + * + * Per-object, for the same reason as {@link Mesh#shadowOffset}. + * @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} + */ + this.shadowLight = settings.shadowLight; + /** * Cached horizontal half-extent used to size the shadow, resolved on * first shadowed draw. Cached because the alternative is a @@ -2029,6 +2126,72 @@ 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; + + let stretch = this.shadowStretch; + if (!Number.isFinite(stretch) || stretch < 1) { + stretch = 1; + } else if (stretch > SHADOW_MAX_STRETCH) { + stretch = SHADOW_MAX_STRETCH; + } + if (stretch !== 1) { + const gain = stretch - 1; + const axDot = axX * dirX + axZ * dirZ; + axX += gain * axDot * dirX; + axZ += gain * axDot * dirZ; + const azDot = azX * dirX + azZ * dirZ; + azX += gain * azDot * dirX; + azZ += gain * azDot * dirZ; + // 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)) { + const distance = ratio * extent * SHADOW_SPREAD; + offsetX = dirX * distance; + offsetZ = dirZ * distance; + } + } + // 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; @@ -2051,10 +2214,12 @@ export default class Mesh extends Renderable { out[9] = 0; out[10] = azZ * k; out[11] = 0; - out[12] = originX; - // render space is Y-DOWN, so lifting off the floor is a SMALLER y + out[12] = originX + offsetX; + // render space is Y-DOWN, so lifting off the floor is a SMALLER y. + // `extent` is measured BEFORE any stretch, so pulling the blob out + // does not also lift it off the floor it is lying on. out[13] = groundY - extent * SHADOW_LIFT; - out[14] = originZ; + out[14] = originZ + offsetZ; out[15] = 1; // Stash and restore by hand rather than save()/restore(): restore() @@ -2071,7 +2236,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 { diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 1081846e3..053ca08c9 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -168,6 +168,11 @@ export default class Sprite3d extends Mesh { * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link 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.shadowOffset=0] - world distance to slide the shadow along its direction; 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 {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. @@ -288,6 +293,11 @@ export default class Sprite3d extends Mesh { transparent: settings.transparent, shadowGroundY: settings.shadowGroundY, shadowOpacity: settings.shadowOpacity, + shadowOffset: settings.shadowOffset, + shadowStretch: settings.shadowStretch, + shadowDirectionX: settings.shadowDirectionX, + shadowDirectionZ: settings.shadowDirectionZ, + shadowLight: settings.shadowLight, }); /** diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index 921deb5e8..936ea506c 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -329,6 +329,298 @@ describe("Ground shadows (#1515)", () => { mesh.destroy(); }); }); + /** + * 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("is ignored by an InstancedMesh, both halves of it", (ctx) => { + requireWebGL(ctx, renderer); + // one shared quad, each instance with its own transform: a + // world-space direction cannot be baked in, so neither half + // applies rather than one of them silently doing so + const mesh = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: 2, + castGroundShadow: true, + shadowGroundY: 20, + }); + const placement = new Matrix3d(); + for (let i = 0; i < 2; i++) { + placement.identity().translate(i * 8, 0, 0); + mesh.setInstance(i, placement); + } + drawOnce(mesh); + const before = mesh._shadowQuad.originalVertices.slice(); + mesh.shadowDirectionX = 1; + mesh.shadowOffset = 30; + mesh.shadowStretch = 3; + drawOnce(mesh); + expect(Array.from(mesh._shadowQuad.originalVertices)).toEqual( + Array.from(before), + ); + 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(); + }); + }); + // ── Sprite3d, which is the whole point of the feature ─────────────── describe("Sprite3d", () => {