Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
- `ToneMappingEffect`: values are lifted by `exposure` and brought back into display range by a curve instead of clipping flat at white, which is what stops a bright effect reading as a white sticker. Three curves, chosen at construction: `aces` (filmic, the default), `reinhard` and `exponential`. Put it last, after `BloomEffect`, so what bloom spreads is mapped too. Without `hdr`, it is a grade rather than a colour-managed pipeline: the default targets are `RGBA8`, so the frame arrives already clamped and `exposure` is what gives the curve something to work on. Either way it deliberately does not convert linear to sRGB, because nothing linearized it
- `BloomEffect`: the bright parts of a frame bleed light into the pixels around them, which is what makes emitters, neon and specular highlights read as light rather than as bright paint. `threshold`, `intensity` and `radius` are settable live, and it sizes itself from the renderer. One gather pass with a soft knee, so a light fading through the threshold ramps in rather than popping. `GlowEffect` is an outline drawn outside a sprite's silhouette and returns early on an opaque fragment, so it never was the screen bloom people reached for it as

- 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 single post effect on a renderable that draws with primitives applies, instead of silently doing nothing. One effect is normally applied by drawing the renderable with the effect's own program rather than capturing it offscreen, which costs no render target but only works when everything the renderable draws is a textured quad: `fillRect` and the shape dispatch go to a batcher that never reads that shader. Measured with a `DesaturateEffect` over pure red, one effect read back `[255, 0, 0]` untouched while two read `[76, 76, 76]`, so only the single-effect case was ever wrong. `Trail` was affected and is fixed; a renderable of your own that draws with primitives sets `postEffectNeedsCapture` to opt in, and everything else keeps the cheap path exactly as before. Both GPU backends
- Input: a region covered by something that consumed the pointer is told it lost it, instead of being left in its hover state. A widget only ever got its leave by the pointer going outside its own bounds, so a button half covered by a panel stayed lit when the pointer slid off its exposed part and onto the panel, which never takes it out of the button's bounds. A consumed move now carries on down the candidate list, not to offer the event to anything underneath but to take it away from whatever still holds it. A consumed press, release or wheel does not, since none of those says where the pointer is
Expand Down
11 changes: 8 additions & 3 deletions packages/melonjs/src/renderable/instanced_mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,12 @@ export default class InstancedMesh extends Mesh {
* @internal
*/
_drawInstancedGroundShadow(renderer) {
if (typeof renderer.drawInstancedShadow !== "function") {
const shadowScale = this.shadowScale;
if (
typeof renderer.drawInstancedShadow !== "function" ||
!Number.isFinite(shadowScale) ||
shadowScale <= 0
) {
return;
}
// Sized from the PROTOTYPE's own footprint, with the same contact
Expand All @@ -746,8 +751,8 @@ export default class InstancedMesh extends Mesh {
const quad = getInstancedShadowQuad(
this,
Mesh,
hx,
hz,
hx * shadowScale,
hz * shadowScale,
this._geometryVersion ?? 0,
);
const group = this._modelMatrix.val;
Expand Down
19 changes: 18 additions & 1 deletion packages/melonjs/src/renderable/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ 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} [shadowScale=1] - multiplier for the ground shadow's footprint. Non-positive or non-finite values hide the shadow without hiding the mesh.
*/

/**
Expand Down Expand Up @@ -932,6 +933,18 @@ export default class Mesh extends Renderable {
? settings.shadowOpacity
: 0.45;

/**
* 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.
* @type {number}
* @default 1
*/
this.shadowScale =
typeof settings.shadowScale === "number" ? settings.shadowScale : 1;

/**
* Cached horizontal half-extent used to size the shadow, resolved on
* first shadowed draw. Cached because the alternative is a
Expand Down Expand Up @@ -1935,6 +1948,10 @@ export default class Mesh extends Renderable {
* @internal
*/
_drawGroundShadow(renderer) {
const shadowScale = this.shadowScale;
if (!Number.isFinite(shadowScale) || shadowScale <= 0) {
return;
}
const quad = getShadowQuad(renderer, this.lit === true, Mesh);
const model = this._modelMatrix.val;
const originX = model[12];
Expand Down Expand Up @@ -2031,7 +2048,7 @@ export default class Mesh extends Renderable {

// 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;
const k = (0.5 + strength * 0.5) * 2 * SHADOW_SPREAD * shadowScale;
// written into the quad's OWN model matrix rather than a module scratch:
// it is the quad's placement, the renderer copies it when queueing the
// deferred draw, and it keeps the placement inspectable from the outside
Expand Down
2 changes: 2 additions & 0 deletions packages/melonjs/src/renderable/sprite3d.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ 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.shadowScale=1] - multiplier for the ground shadow's footprint; non-positive or non-finite values hide the shadow
* @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.
Expand Down Expand Up @@ -288,6 +289,7 @@ export default class Sprite3d extends Mesh {
transparent: settings.transparent,
shadowGroundY: settings.shadowGroundY,
shadowOpacity: settings.shadowOpacity,
shadowScale: settings.shadowScale,
});

/**
Expand Down
205 changes: 205 additions & 0 deletions packages/melonjs/tests/ground_shadow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,99 @@ describe("Ground shadows (#1515)", () => {
mesh.destroy();
});
});
describe("shadowScale", () => {
it("defaults to the same footprint as an explicit 1", (ctx) => {
requireWebGL(ctx, renderer);
const mesh = makeMesh({ castGroundShadow: true });
expect(mesh.shadowScale).toBe(1);
drawOnce(mesh);
const before = renderer._shadowQuads.unlit._modelMatrix.val.slice();
const explicit = makeMesh({ castGroundShadow: true, shadowScale: 1 });
drawOnce(explicit);
expect(renderer._shadowQuads.unlit._modelMatrix.val).toEqual(before);
mesh.destroy();
explicit.destroy();
});

it.for([0.5, 2])(
"scales both axes by %s without moving or fading the blob",
(scale, ctx) => {
requireWebGL(ctx, renderer);
const mesh = makeMesh({
castGroundShadow: true,
shadowGroundY: 20,
shadowOpacity: 0.7,
});
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);
const before = renderer._shadowQuads.unlit._modelMatrix.val.slice();
const beforeAlpha = alpha;
mesh.shadowScale = scale;
drawOnce(mesh);
const after = renderer._shadowQuads.unlit._modelMatrix.val;
for (const i of [0, 2, 8, 10]) {
expect(after[i]).toBeCloseTo(before[i] * scale, 5);
}
for (const i of [12, 13, 14]) {
expect(after[i]).toBe(before[i]);
}
expect(alpha).toBe(beforeAlpha);
} finally {
spy.mockRestore();
mesh.destroy();
}
},
);

it("forwards the Sprite3d setting and responds to live changes", (ctx) => {
requireWebGL(ctx, renderer);
const sprite = new Sprite3d(0, 0, {
image: Renderer.getWhitePixel(),
width: 40,
height: 60,
castGroundShadow: true,
shadowScale: 2,
});
expect(sprite.shadowScale).toBe(2);
drawOnce(sprite);
const before = shadowAxes();
sprite.shadowScale = 0.5;
drawOnce(sprite);
expect(shadowAxes().x).toBeCloseTo(before.x / 4, 5);
expect(shadowAxes().z).toBeCloseTo(before.z / 4, 5);
sprite.destroy();
});

it.for([
0,
-1,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
])("hides only the shadow for scale %s", (scale, ctx) => {
requireWebGL(ctx, renderer);
const mesh = makeMesh({ castGroundShadow: true, shadowScale: scale });
const spy = vi.spyOn(renderer.gl, "drawElements");
try {
drawOnce(mesh);
expect(spy).toHaveBeenCalledTimes(1);
} finally {
spy.mockRestore();
mesh.destroy();
}
});
});

// ── Sprite3d, which is the whole point of the feature ───────────────

describe("Sprite3d", () => {
Expand Down Expand Up @@ -465,6 +558,74 @@ describe("Ground shadows (#1515)", () => {
many.destroy();
});

it.for([0.5, 2, 1e-8])(
"scales instanced footprints by %s and refreshes retained geometry",
(scale, ctx) => {
requireWebGL(ctx, renderer);
const mesh = makeInstanced(2);
drawOnce(mesh);
const oldQuad = mesh._shadowQuad;
const before = oldQuad.originalVertices.slice();
expect(Math.abs(before[0])).toBeGreaterThan(0);
mesh.shadowScale = scale;
drawOnce(mesh);
const resized = mesh._shadowQuad;
expect(resized.originalVertices).toHaveLength(before.length);
for (let i = 0; i < before.length; i++) {
expect(resized.originalVertices[i] / scale).toBeCloseTo(before[i], 5);
}
expect(resized).not.toBe(oldQuad);
drawOnce(mesh);
expect(mesh._shadowQuad).toBe(resized);
mesh.destroy();
},
);

it("keeps one instanced mesh's scale independent of another", (ctx) => {
requireWebGL(ctx, renderer);
const large = makeInstanced(2, { shadowScale: 2 });
const normal = makeInstanced(2);
drawOnce(large);
drawOnce(normal);
expect(Math.abs(normal._shadowQuad.originalVertices[0])).toBeGreaterThan(
0,
);
expect(Math.abs(large._shadowQuad.originalVertices[0])).toBeCloseTo(
Math.abs(normal._shadowQuad.originalVertices[0]) * 2,
5,
);
large.destroy();
normal.destroy();
});

it.for([
0,
-1,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
])(
"keeps the instances visible but hides their shadows for scale %s",
(scale, ctx) => {
requireWebGL(ctx, renderer);
const mesh = makeInstanced(2);
drawOnce(mesh);
mesh.shadowScale = scale;
const spy = vi.spyOn(renderer.gl, "drawElementsInstanced");
try {
drawOnce(mesh);
expect(spy).toHaveBeenCalledTimes(1);
mesh.shadowScale = 1;
spy.mockClear();
drawOnce(mesh);
expect(spy).toHaveBeenCalledTimes(2);
} finally {
spy.mockRestore();
mesh.destroy();
}
},
);

it("REGRESSION: no shadow flag means no extra draw", (ctx) => {
requireWebGL(ctx, renderer);
const gl = renderer.gl;
Expand Down Expand Up @@ -1020,6 +1181,50 @@ describe("Ground shadows (#1515)", () => {
return activate(makeMesh({ width: 40, ...settings }));
};

it.for([
["Mesh", Mesh],
["InstancedMesh", InstancedMesh],
])(
"an enlarged %s shadow becomes visible outside a wide flat prop",
([_name, Type], ctx) => {
requireWebGL(ctx, renderer);
const F = 40;
const prop = activate(
new Type(0, 40, {
vertices: new Float32Array([
-F,
0,
-F,
F,
0,
-F,
F,
0,
F,
-F,
0,
F,
]),
uvs: GEOMETRY.uvs,
indices: GEOMETRY.indices,
width: 1,
normalize: false,
cullBackFaces: false,
castGroundShadow: true,
shadowGroundY: FLOOR_Y,
instanceCount: 1,
}),
);
prop.tint.setColor(80, 140, 200);
const before = renderScene(prop)(96, 64)[0];
prop.shadowScale = 2;
const after = renderScene(prop)(96, 64)[0];
prop.destroy();
expect(before).toBe(FLOOR_RGB);
expect(after).toBeLessThan(FLOOR_RGB - 15);
},
);

it("darkens the ground beneath the caster, even though the ground draws AFTER it", (ctx) => {
requireWebGL(ctx, renderer);

Expand Down
Loading