Skip to content

Ground shadows: shape and placement controls (scale, offset, light direction) #1631

Description

@obiot

Follow-up to #1515, which shipped the blob shadow itself. Three related gaps surfaced while building a chase-camera 3D scene; parking them together to revisit.

Today Mesh._drawGroundShadow places the quad at the caster's own x/z:

out[12] = originX;
out[13] = groundY - extent * SHADOW_LIFT;
out[14] = originZ;

with an ellipse sized to the caster's footprint times a module-level SHADOW_SPREAD (1.2). So a blob is always centred under its caster, always the same relative size, and never influenced by where the light is.


1. Per-object shadow scale

shadowOpacity is per mesh; size is not — it comes from the footprint and a constant. There is no way to say "this character's shadow should read a little larger" or "this pickup's a little tighter".

This is the one with the best cost/benefit. A wide, flat-bottomed prop resting on the ground hides its own blob completely from a camera looking down at it — nothing is visibly wrong, there is simply no shadow — and a slightly larger blob resolves it with no lifting and no physical claims. Suggested shape: shadowScale (default 1), sitting next to shadowOpacity.

2. Author-supplied offset

shadowOffset as a world-space x/z the game sets, giving the sun-angle look without the engine inferring anything, and without needing to decide which light wins.

3. Offset derived from the light direction

The automatic version of (2): take the scene's dominant directional Light3d, project onto the ground plane, offset the blob and stretch its major axis along that vector.

Recorded with the reasons it was not done first, so it is not re-proposed without them being addressed:

  • the blob is a flat quad on one named plane, with no occlusion and no contact with terrain. An offset blob slides off the slope it is supposed to be lying on as soon as the ground is not flat.
  • a low sun should lengthen a shadow, which a footprint-sized ellipse cannot express — so the offset alone looks more wrong, not less.
  • it needs an arbitrary "which light wins" rule for a scene with several, plus a defined fallback when there is no directional light.
  • coupling it to a real light makes the blob look like it is modelling something, which invites scrutiny the model cannot survive. Direction-correct shadows want a shadow map, which is past the scope of this tier.

Also considered and rejected

Clamping or warning when shadowGroundY is lifted above the caster's base. It looks like a guard against a real trap — lifting the plane does not slide the blob out from under an object, it floats it up, and past a few units it projects over the top as a dark ring. But the same setting is legitimately used for a jumping or flying object whose shadow must stay on the floor and shrink with height, so a clamp would break the case the setting exists for. Documented in the melonjs-3d and melonjs-3d-assets skills instead (#1630).


All three default to current behaviour, so none is breaking.

Activity

  1. obiot commented on Oct 5, 2026

    @obiot
    MemberAuthor

    Items 2 and 3 are implemented in #1714, as one explicit feature rather than two. Recording here how each of the four recorded objections is answered, since the issue asked that they not be re-proposed without that.

    1. An offset blob slides off a slope. The offset is honoured only when shadowGroundY is set. That setting is the game stating where its floor is, and it is the only case where sliding a flat quad across it is honest. The fallback, where the blob sits at the caster's own base, refuses the offset.

    2. A low sun should lengthen a shadow, which a footprint ellipse cannot express. It can, and more cheaply than this issue assumed. The basis is already oriented and anisotropic — axX/axZ and azX/azZ come from the caster's own model columns with independent lengths (mesh.js:1970-1973) — and both merely share one scalar k. The stretch is S = I + (stretch - 1)·d⊗d applied in world XZ, which leaves everything perpendicular to d untouched. No new primitive and no shader change.

    3. Which light wins, and the fallback with none. Nothing is inferred. shadowLight names one light and is read every draw; shadowDirectionX/shadowDirectionZ set it by hand. No light named means no direction, so there is no dominant-light rule and no fallback to define.

    4. Coupling to a real light invites scrutiny the model cannot survive. The stretch is clamped to 3 and the blob fades by 1/√stretch as it pulls, so an extreme value degrades into nothing rather than a smear. The controls are named and documented as art direction, and the skill states plainly that this is not a projection and that direction-correct shadows want a shadow map, which this tier does not do.

    Two things found while doing it, both worth recording:

    • It is per-object only. An InstancedMesh shares one quad across instances that each carry their own rotation, so a world-space direction cannot be baked into the shared geometry. It ignores both halves rather than honouring one, with a test pinning that. Instanced support would need per-instance shadow data, which is a separate piece of work.
    • GLTFModel was dropping shadow settings. It forwarded only castGroundShadow and shadowGroundY, so shadowOpacity never reached a loaded model, which is most of the props a scene has. Fixed in the same PR. Worth knowing because it also means item 1 (shadowScale, Add per-object ground shadow scaling #1712) would not have reached a glTF model either, which is where the motivating wide flat-bottomed prop usually comes from.

    Item 1 is #1712, under review. The two compose: shadowScale sizes the blob, shadowStretch pulls it along one axis.

  2. obiot commented on Oct 7, 2026

    @obiot
    MemberAuthor

    All three landed in 20.8.0.

    1. Per-object shadow scale — shadowScale, default 1, sitting next to shadowOpacity as suggested. 0 or less hides the blob and leaves the object drawn (#1712, thanks @snowyukitty).
    2. Author-supplied offset — shadowOffset, in multiples of the blob's own radius rather than world units, so one value serves casters of any size. It needs shadowGroundY, since a flat quad can only be slid across a plane the game has named (#1715).
    3. Offset derived from the light — shadowLight names a Light3d to take the direction from, re-read every draw so a day/night cycle carries every shadow with it. shadowDirectionX / shadowDirectionZ are the manual form. Nothing is inferred: a scene with no named light gets no direction (#1715).

    The two objections recorded above as reasons not to do (3) first were addressed rather than waived:

    • "an offset blob slides off the slope it is supposed to be lying on" — shadowGroundNormal gives the ground's up normal, so the blob is rotated onto the plane rather than projected. It keeps its size, stays under its caster, and the tilt is clamped at 75 degrees.
    • "a low sun should lengthen a shadow, which a footprint-sized ellipse cannot express" — shadowStretch lengthens along the light direction, clamped, and fading as it pulls.

    All of it works on InstancedMesh too, as one offset and one stretch for the whole set: they ride the quad the blobs share rather than the matrix that also places them, so the blobs lengthen without the scatter smearing. The two things an instanced set still cannot do are a per-instance value and a height fade, both consequences of one plane and one shared quad.

    Everything defaults to the previous behaviour, so none of it was breaking. There is a #/ground-shadows example exercising all nine settings with an orbiting light.

    Closing as done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions