Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions packages/examples/src/examples/jungleRabbit/GameStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 44 additions & 6 deletions packages/examples/src/examples/jungleRabbit/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
41 changes: 34 additions & 7 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions packages/melonjs/src/level/gltf/GLTFModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions packages/melonjs/src/renderable/instanced_mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading