From 4b19dcfc2ee8ebe55e0da76e4a917c227c37da48 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 8 Aug 2026 16:25:41 +0800 Subject: [PATCH 1/3] Ground shadows for 3D objects (#1515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `castGroundShadow` gives a Mesh, a Sprite3d billboard or a whole InstancedMesh scatter a soft blob shadow on the ground — the thing 2.5D scenes had no way to get, and without which characters and props read as floating however carefully they are placed. Deliberately not simulated: for paper-thin billboards a shadow map costs far more than this engine wants to spend and looks worse, because a flat silhouette has to be special-cased to cast anything sensible. What the player needs is contact, and a blob says exactly that. On by default (the new `castGroundShadow` application setting), and controllable at three levels, most specific first: per object, per glTF scene via `level.load(name, { castGroundShadow, shadowGroundY })`, then application-wide. The two blanket forms skip meshes with no vertical extent — a flat plane lying on the floor IS the floor, and shadowing it with itself smears the whole ground. 2D games are untouched whatever the setting says: the shadow rides the retained Camera3d path only. The blob is an ellipse built from the caster's own model-space footprint carried through its transform, so it matches the object's aspect ratio and turns with it; a thin upright panel gets a thin shadow lying along the panel rather than a disc reading as perpendicular to it. Read from `currentTransform`, not the model matrix, because a billboarded Sprite3d builds that from a camera-facing basis and the blob would spin with the camera. Shadows are held back until every opaque mesh in the pass is down, then drawn in one go. A blob writes no depth (so two overlapping at one ground height blend instead of fighting), which leaves it nothing to defend itself with, and a ground plane routinely sorts after the props standing on it. Depth testing stays on, so a shadow is still correctly hidden behind geometry genuinely in front of it. The queue is drained when the renderer leaves mesh mode — but not on a lit/unlit switch, and not while a mask is being stencilled in or inside a post-effect bracket, where the device state is not the scene's — and at the end of the camera's own draw, inside its FBO bracket. The instanced tier costs ONE extra draw for an entire scatter regardless of instance count: the blobs are read from the same instance buffer the meshes draw from, through a standalone shader that reads only the transform rows, so per-instance colour and emissive cannot leak into them. Its quad carries the prototype's extents in its own vertices, so the same asset draws the same shadow instanced or standalone. An object that does not opt in is untouched — no extra draw, no extra state, no changed pipeline key — and the shared falloff texture and quads are allocated lazily, so an application with no shadows builds neither. Also here, found while building it: - `registerShader` deduped on module source alone, silently handing any later caller the first registration's vertex layout. One module can legitimately serve several layouts; now keyed on both, with the compiled module still shared. - WebGPU pipelines gain a conditional `depthWrite` axis, appended to the key only when false so existing pipelines are byte-identical. Both GPU backends. The Canvas renderer has no depth buffer and draws none. 50 tests covering the backward-compatibility contract, opt-in precedence, the deferred queue, resource lifetime and pixels — the last because every draw-count, GL-state and matrix assertion passed while the feature rendered nothing at all. Docs: CHANGELOG, both READMEs, and the Working-in-3D, glTF, supported- assets and 2.5D wiki pages (the last two carried caveats this removes). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- README.md | 1 + .../examples/billboard/ExampleBillboard.tsx | 18 +- .../src/examples/forest/ExampleForest.tsx | 16 +- .../src/examples/gltf/ExampleGltf.tsx | 47 +- .../ExampleMaterialTextures.tsx | 39 + packages/melonjs/CHANGELOG.md | 1 + .../melonjs/src/application/application.ts | 5 + .../application/defaultApplicationSettings.ts | 1 + packages/melonjs/src/application/settings.ts | 36 + packages/melonjs/src/camera/camera2d.ts | 6 + packages/melonjs/src/level/gltf/GLTFModel.js | 15 + packages/melonjs/src/level/gltf/GLTFScene.js | 27 +- packages/melonjs/src/level/level.js | 5 + .../melonjs/src/renderable/groundshadow.js | 288 +++++ .../melonjs/src/renderable/instanced_mesh.js | 91 ++ packages/melonjs/src/renderable/mesh.js | 331 +++++ packages/melonjs/src/renderable/sprite3d.js | 10 + packages/melonjs/src/video/renderer.js | 111 ++ .../src/video/webgl/batchers/mesh_batcher.js | 246 ++++ .../webgl/shaders/mesh-shadow-instanced.vert | 64 + .../melonjs/src/video/webgl/webgl_renderer.js | 52 + .../src/video/webgpu/batchers/mesh_batcher.js | 139 +- .../src/video/webgpu/pipeline/cache.js | 46 +- .../webgpu/shaders/mesh-shadow-instanced.wgsl | 92 ++ .../src/video/webgpu/webgpu_renderer.js | 58 + packages/melonjs/tests/ground_shadow.spec.js | 1147 +++++++++++++++++ .../tests/helpers/webgpu-mock-renderer.js | 11 +- .../tests/webgl_pipeline_adversarial.spec.js | 6 +- .../melonjs/tests/webgpu_mesh_batcher.spec.js | 60 + .../melonjs/tests/webgpu_pipeline.spec.js | 18 + 30 files changed, 2969 insertions(+), 18 deletions(-) create mode 100644 packages/melonjs/src/renderable/groundshadow.js create mode 100644 packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert create mode 100644 packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl create mode 100644 packages/melonjs/tests/ground_shadow.spec.js diff --git a/README.md b/README.md index c20257a97..4826631e9 100644 --- a/README.md +++ b/README.md @@ -61,6 +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 - Built-in shader effects (Flash, Outline, Glow, 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) - Trail renderable for fading, tapering ribbons behind moving objects (speed lines, sword slashes, magic trails) - System & Bitmap Text with built-in typewriter effect diff --git a/packages/examples/src/examples/billboard/ExampleBillboard.tsx b/packages/examples/src/examples/billboard/ExampleBillboard.tsx index f2cbb46c7..ab4b6aa14 100644 --- a/packages/examples/src/examples/billboard/ExampleBillboard.tsx +++ b/packages/examples/src/examples/billboard/ExampleBillboard.tsx @@ -83,9 +83,12 @@ function bakeGrid() { c.height = 64; const ctx = c.getContext("2d"); if (ctx) { - ctx.fillStyle = "#161a24"; + // light enough for a ground shadow to have something to darken: a blob + // is a subtraction, and on the near-black this used to be there was + // nothing to subtract from + ctx.fillStyle = "#4a5372"; ctx.fillRect(0, 0, 64, 64); - ctx.strokeStyle = "#2c3550"; + ctx.strokeStyle = "#6f7ba3"; ctx.lineWidth = 2; ctx.strokeRect(0, 0, 64, 64); } @@ -212,6 +215,11 @@ const createGame = async () => { z: 0, billboard: mode, anchorPoint: "bottom", // feet at pos — character only + castGroundShadow: true, + // the floor plane, which the game knows and the engine does + // not: left unset the blob falls back to the sprite's own + // base, and a billboard's base moves with the camera + shadowGroundY: GY, }); guy.addAnimation("walk", WALK_FRAMES, 90); guy.setCurrentAnimation("walk"); @@ -228,6 +236,12 @@ const createGame = async () => { z: 0, billboard: "spherical", // no anchorPoint — the default center is correct here + // a floating label has no ground beneath it to make contact + // with, so it opts out of the application-wide default. Its + // blob happens to be edge-on and invisible at this camera + // height — saying so explicitly keeps that from depending on + // where the camera happens to sit + castGroundShadow: false, }); app.world.addChild(tag); } diff --git a/packages/examples/src/examples/forest/ExampleForest.tsx b/packages/examples/src/examples/forest/ExampleForest.tsx index f891347b2..7145cd143 100644 --- a/packages/examples/src/examples/forest/ExampleForest.tsx +++ b/packages/examples/src/examples/forest/ExampleForest.tsx @@ -36,6 +36,10 @@ const base = `${import.meta.env.BASE_URL}assets/gltf/`; // pixels per glTF unit const SCALE = 26; +// The forest floor: the glb's ground plane sits at y = 0, and the trees stand +// on it, so every blob in the scene lands here. +const GROUND_Y = 0; + /** A dusk sky, drawn screen-fixed behind the scene. */ function bakeSky() { const c = document.createElement("canvas"); @@ -234,7 +238,17 @@ const createGame = async () => { state.change(state.DEFAULT, true); // one call — the instanced node becomes an InstancedMesh, the // ground stays an ordinary Mesh, and the authored sun lights both - level.load("forest", { scale: SCALE, onLoaded: setupScene }); + // Instanced ground shadows (#1515) come from the load option: ONE + // extra draw for the whole scatter, however many trees are visible, + // read from the same instance buffer the trees draw from. The + // scene's ground plane is skipped automatically — it has no height + // to cast, and shadowing it with itself would smear the floor. + level.load("forest", { + scale: SCALE, + castGroundShadow: true, + shadowGroundY: GROUND_Y, + onLoaded: setupScene, + }); }, ); diff --git a/packages/examples/src/examples/gltf/ExampleGltf.tsx b/packages/examples/src/examples/gltf/ExampleGltf.tsx index 27277f740..dd6054ed2 100644 --- a/packages/examples/src/examples/gltf/ExampleGltf.tsx +++ b/packages/examples/src/examples/gltf/ExampleGltf.tsx @@ -112,6 +112,41 @@ const createGame = async () => { if (!scene) { return; } + // Give each prop the surface it actually stands over (#1515). The scene + // has platforms at three heights, so there is no single floor to pass as + // `shadowGroundY` — and left unset, a blob sits at the object's OWN + // base, which for a hovering pickup is mid-air directly beneath it, + // hidden by the pickup. Render space is Y-DOWN, so "below" is a GREATER + // y: the right surface is the smallest platform top still greater than + // the prop's base. Setting it is also what switches on the shrink-and- + // fade with height, which is what makes a floating coin read as floating. + const meshes = app.world.children.filter( + (c) => (c as any).castGroundShadow !== undefined, + ) as any[]; + const platformTops = meshes + .filter((m) => /^block/.test(m.name ?? "")) + .map((m) => m.getBounds3d().top); + for (const prop of meshes) { + if (/^block/.test(prop.name ?? "")) { + continue; + } + const base = prop.getBounds3d().bottom; + let ground: number | undefined; + for (const top of platformTops) { + // A tolerance, not a strict compare: a prop resting on a platform + // has a base that IS that platform's top, but only to within + // float error — and an exact test skips the very surface it + // stands on, dropping its shadow a tier down where the upper + // platform hides it. Half a pixel at this scene's scale. + if (top >= base - 0.5 && (ground === undefined || top < ground)) { + ground = top; + } + } + if (ground !== undefined) { + prop.shadowGroundY = ground; + } + } + const { min, max } = scene.bounds; // render space: glTF (x,y,z) → (x, -y, -z) * SCALE (rightHanded rotation) const cx = ((min[0] + max[0]) / 2) * SCALE; @@ -283,7 +318,17 @@ const createGame = async () => { // load the whole glTF scene into the world in one call — the glb // auto-registered with the level director on preload, exactly like // a Tiled map. `rightHanded` defaults to true for glTF scenes. - level.load("diorama", { scale: SCALE, onLoaded: setupScene }); + // Ground shadows (#1515) for the whole scene in one option: every + // prop gets a blob at its own base — no `shadowGroundY` here, + // because a diorama's props rest on platforms at several different + // heights rather than on one floor. The scene's ground/platform + // meshes are skipped automatically: they have no height to cast + // from, and shadowing them with themselves would smear the terrain. + level.load("diorama", { + scale: SCALE, + castGroundShadow: true, + onLoaded: setupScene, + }); }, ); diff --git a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx index 8b8224876..daf18f2a6 100644 --- a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx +++ b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx @@ -46,6 +46,12 @@ const MESH_BASE = `${import.meta.env.BASE_URL}assets/mesh3d/`; // the three props, left to right along X at a common depth const PROP_Y = 0; +// Where the floor sits, and so where every blob lands. Render space is Y-DOWN, +// so this is BELOW the props at PROP_Y. Chosen to meet their bases (the widest +// prop is 170 across, i.e. 85 below its own origin) — a floor further down +// would be correct but would read as three floating objects, because the +// shadow shrinks and fades with height exactly as it should. +const GROUND_Y = 85; const PROP_Z = 520; const SPACING = 210; @@ -129,6 +135,30 @@ function buildScene(app: Application) { world.addChild(key); world.addChild(new Light3d(0, 0, { type: "ambient", color: "#3b4870" })); + // a floor for the shadows to land on + const F = 900; + const floor = new Mesh(0, GROUND_Y, { + vertices: new Float32Array([-F, 0, -F, F, 0, -F, F, 0, F, -F, 0, F]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0]), + width: F * 2 * Math.SQRT2, + height: F * 2 * Math.SQRT2, + scale: 1, + normalize: false, + cullBackFaces: false, + // unlit on purpose: the key light points UP in this Y-down scene, so a + // lit ground plane would only ever receive ambient + lit: false, + }); + // farther than the props, so the world's depth sort draws it FIRST. A + // shadow does not write depth (that is what lets two overlap), so anything + // drawn after it simply paints over it — a floor sharing the props' depth + // sorts arbitrarily against them and wins half the time. + floor.depth = PROP_Z - 300; + floor.tint.setColor(150, 152, 160); + world.addChild(floor); + const props: Mesh[] = []; // ── crate: three diffuse maps, one per material ────────────── @@ -137,7 +167,12 @@ function buildScene(app: Application) { material: "crate", width: 150, lit: true, + castGroundShadow: true, + shadowGroundY: GROUND_Y, }); + // a quarter turn, so the shipping label faces the camera rather than + // sitting edge-on + crate.rotate(Math.PI / 2, AXIS_Y); crate.depth = PROP_Z; props.push(crate); @@ -148,6 +183,8 @@ function buildScene(app: Application) { material: "props", width: 170, lit: true, + castGroundShadow: true, + shadowGroundY: GROUND_Y, }); ball.depth = PROP_Z; props.push(ball); @@ -163,6 +200,8 @@ function buildScene(app: Application) { // nothing — the two are a pair. alphaCutoff: 0.5, cullBackFaces: false, + castGroundShadow: true, + shadowGroundY: GROUND_Y, }); panel.depth = PROP_Z; props.push(panel); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 461d9c813..c2a260bd0 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.0.0] (melonJS 2) - _unreleased_ ### Added +- **Ground shadows for 3D objects** ([#1515](https://github.com/melonjs/melonJS/issues/1515)) — `castGroundShadow: true` gives a `Mesh`, a `Sprite3d` billboard or a whole `InstancedMesh` scatter a soft shadow on the ground, which 2.5D scenes had no way to get: without one, characters and props read as floating however carefully they are placed. This is a **blob** shadow and deliberately not a simulated one — for the paper-thin billboards a 2.5D game is made of, a shadow map costs far more than this engine wants to spend *and* looks worse, because a flat silhouette has to be special-cased to cast anything sensible at all. What the player actually needs is contact: where the object stands, and how far off the ground it is mid-jump. Three properties, and nothing else to configure: `castGroundShadow` (the whole opt-in), `shadowGroundY` (the world Y of the floor — the game knows it, from collision; left unset the blob sits at the object's own base at full strength, which is right for something already resting on the ground), and `shadowOpacity` (`0.45` by default). Set `shadowGroundY` and the blob **shrinks and fades with height**, which is what reads as a jump. The blob is not a disc: it is an ellipse sized from the caster's own footprint and **turned by its rotation**, so a flat upright panel gets a thin shadow lying along the panel rather than a circle that reads as perpendicular to it, and it spreads slightly past the footprint the way a real contact shadow does (a box sitting flat on the floor would otherwise cover its own shadow exactly). A paper-thin caster — a billboard, a plate — keeps a minimum minor axis so it stays a blob rather than degenerating to a hairline. Cost is one extra draw per shadowed object, and for an `InstancedMesh` **one extra draw for the entire scatter regardless of instance count**: the blobs are read from the same instance buffer the meshes themselves draw from, through a standalone shader that reads only the transform rows — so a 100 000-tree forest with shadows is two draws, and per-instance colour or emissive cannot leak into them. Shadows are held back until every opaque mesh in the pass is drawn, then drawn in one go: a blob writes no depth (so two overlapping at one ground height blend instead of fighting), which leaves it nothing to defend itself with, and a ground plane routinely sorts *after* the props standing on it. Depth *testing* stays on throughout, so a shadow is still correctly hidden behind geometry genuinely in front of it. Both GPU backends; the Canvas renderer has no depth buffer and so no ground shadows. **An object that does not opt in is untouched** — no extra draw, no extra state, no changed pipeline, and the shared falloff texture and quad are allocated lazily, so an application with no shadows builds neither. **On by default**, and controllable at three levels, most specific first: per object (`castGroundShadow` on the mesh, which always wins), per glTF scene (`level.load(name, { castGroundShadow, shadowGroundY })`), and application-wide (the new `castGroundShadow` application setting, which ships `true`). A 2D game is untouched whatever it says — the shadow rides the retained `Camera3d` path only, so the Canvas renderer and the 2D-camera path draw none; set the application setting `false` to opt a 3D game out wholesale (one whose lighting is already baked, or that brings its own shadows, would otherwise get two). The two blanket forms carry one safeguard the per-object form does not: they **skip meshes with no vertical extent**, because a flat plane lying on the floor *is* the floor and shadowing it with itself smears a blob across the whole ground — so a glTF scene shipping its ground as a plane opts in correctly with a single option and no per-node fiddling. Shown in the reworked **Per-material Textures** (a crate, a chrome ball and a perforated panel, each with a shadow matching its own footprint), **Billboard Sprites** and **Instanced Forest** examples - **Shader effects run on the WebGPU renderer, and effect bodies are dual-language** — `ShaderEffect` (and every built-in effect) now works on both GPU backends. An effect body can be a GLSL string exactly as before, or one body per shading language: `new ShaderEffect(renderer, { glsl, wgsl })` — the renderer compiles the body matching its `shaderLanguage`, uniform names are shared so one `setUniform` serves both, and when no matching body exists the effect warns once and stays disabled (`enabled === false`) while the scene keeps rendering — the same graceful contract the Canvas renderer always had. All 18 dual-language built-in effects (Vignette, Blur, ColorMatrix/Desaturate/Invert/Sepia, Dissolve, DropShadow, Flash, Glow, Hologram, Outline, Pixelate, Scanline, Shine, TintPulse, Wave, ChromaticAberration) render identically under WebGL and WebGPU, through both the post-effect chain (cameras, multi-effect ping-pong, `screen_texture`/`screen_uv`/`noise_uv` builtins) and the single-effect fast path. The WGSL authoring convention — one uniform struct at `@group(3) @binding(0)` whose member names are the `setUniform` names, texture/sampler pairs for `setTexture`, builtins under their established names — is documented on the `ShaderEffect` class. Shader assets gain the matching dual shape: `{ type: "shader", src: { glsl, wgsl } }` (or inline via `data`), fetching what is declared and preloading successfully even when the active backend matches neither (inert stub, unload-safe). Existing GLSL-only effects and assets are untouched: the generated GLSL is byte-identical to 19.x - **WebGPU renderer** — the default on WebGPU-capable browsers: `video.AUTO` (the default `renderer` setting) now negotiates **WebGPU first, then WebGL 2, then Canvas** — the WebGPU attempt is a full adapter/device negotiation awaited inside `app.init()`, falling through to the synchronous candidates when it rejects, so `init()` always resolves under AUTO. Also requestable explicitly as `renderer: video.WEBGPU` (fails loudly, never substitutes) or via the `#webgpu` URI fragment; `#webgl` / `#canvas` force the other backends per-run. The backend covers the **full 2D contract**: sprites, text and particles through a WGSL quad pipeline (packed-tint vertex stream identical to the WebGL layout, **multi-texture batching** — one draw segment spans up to eight distinct textures, selected per quad by the vertex stream's texture id, so a texture change no longer breaks the batch), filled/stroked shapes and the Path2D API through a primitive pipeline (thick lines via the shared frame-globals uniform block), all six blend modes as pipeline blend states (including min/max darken/lighten), patterns with per-axis repeat samplers, transform-derived scissor clipping, mid-frame scissored clears, and stencil-based `setMask`/`clearMask`, plus the GPU tile path: orthogonal TMX layers draw through a WGSL port of the shader tilemap renderer (one quad per tileset, per-layer GID index texture, animated tiles included [#1445 parity]). Frames record into one command encoder / render pass with a `depth24plus-stencil8` attachment carried from day one; the backend-neutral vertex formats and topologies of [#1551](https://github.com/melonjs/melonJS/issues/1551) are consumed declaratively into the pipeline layouts ([#1492](https://github.com/melonjs/melonJS/issues/1492)), and frame globals live in a bind-group-0 uniform buffer (the [#1555](https://github.com/melonjs/melonJS/issues/1555) shape). The rest of the 2D feature set follows suit: **2D lights and normal-map lighting** (`Light2d` glow quads, ambient-light cutouts and the std140 lit-sprite path, all through the reserved lights bind group), **`toFrameTexture()`** frame captures (alpha preserved, and row 0 is the top of the frame where the GL capture is bottom-up — GLSL capture shaders flip with `1.0 - uv.y`, their WGSL twins must not), **gradient fills of arbitrary shapes** (the stencil gradient-mask machinery as pipeline variants), and **compressed textures** (BC / ETC2 / ASTC through whichever `texture-compression-*` device features the adapter offers, consuming the loader's existing dds/ktx/ktx2/pvr/pkm parsers unchanged; PVRTC has no WebGPU equivalent and reports unsupported). The **3D tier** completes the contract: `drawMesh` renders textured triangle meshes through unlit and lit WGSL mesh pipelines (`Light3d` half-Lambert directional + ambient via the std140 light block at the reserved lights bind group) — retained model-space geometry under `Camera3d` (upload once; placement, tint, alpha cutout and emissive ride one per-draw uniform snapshot, so moving or re-tinting a mesh never re-uploads geometry; `supportsDepthBuffer` / `supportsRetainedMesh` are now `true`) as well as the CPU-projected 2D-camera mesh path, with per-mesh back-face culling and winding as pipeline state, per-mesh `textureRepeat` / `textureFilter`, multi-material vertex colors and Uint32 indices, and depth realized as the render pass's load/store ops (one depth clear per render target per frame, the GL policy — pure-2D scenes keep byte-identical passes). glTF scenes and animated models, `Sprite3d` billboards and split-screen `Camera3d` viewports run unchanged on top. **`antiAlias: true` maps to 4× MSAA** on canvas passes (multisampled color + depth resolving into the canvas view — and post-effect capture targets carry their own multisampled half, see the dedicated MSAA entry below). See the reworked **Hello WebGPU** example ([#1184](https://github.com/melonjs/melonJS/issues/1184)) - **Custom mesh shaders on both GPU backends, and `GLShader` is now dual-language** — `mesh.shader` hosts a complete custom shader program on the WebGPU renderer too, closing the last WebGL/WebGPU feature gap. The same `GLShader` class carries it, exactly the way `ShaderEffect` carries dual bodies: alongside the classic positional `(gl, vertex, fragment)` form, the constructor accepts a sources object — `new GLShader(renderer.gl, { vertex, fragment, wgsl })` — holding a GLSL program pair and/or a complete WGSL module, with the new `isWebGL` / `isWebGPU` flags reporting which realizations exist (`renderer.gl` is simply undefined on non-WebGL backends, skipping the GLSL compile). Each renderer hosts the realization it speaks; the WGSL module is written against the documented mesh contract (`vertex_main`/`fragment_main` entry points over the frozen mesh vertex layout, with the frame globals, mesh texture/sampler, per-draw `MeshUniforms` and, on `lit` meshes, the `Light3dBlock` as bind groups — see the `GLShader` class docs) and `drawMesh` realizes it as its own pipeline family on both the retained (`Camera3d`) and CPU-projected paths. Shader assets grow the matching shape: `{ type: "shader", src: { vertex, fragment, wgsl } }` compiles into ONE shared `GLShader` carrying every declared realization (a `wgsl` source that declares its own `@vertex` entry point is recognized as a complete module rather than an effect body; either side omittable), so one `mesh.shader = loader.getShader(...)` assignment serves WebGL and WebGPU unchanged. Degradation is never fatal: a shader without a realization for the active backend is inert (built-in shading, one warning), and a module that fails asynchronous WGSL validation logs its errors and falls back the same way diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 234e8a429..dd5930bad 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -1124,6 +1124,11 @@ export default class Application { // set back to flag this.isDirty = false; + // ground shadows are held back until every opaque mesh is down + // (#1515); a scene that is nothing but meshes never switches away + // from mesh mode, so the pass is closed here + this.renderer.flushGroundShadows(); + // flush/render our frame this.renderer.flush(); diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index 78bdbe9f8..ca66290a7 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -10,6 +10,7 @@ export const defaultApplicationSettings = { transparent: false, antiAlias: false, textureFilter: "auto", + castGroundShadow: true, consoleHeader: true, blendMode: "normal", physic: "builtin", diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index cc3dfd0e8..87e3eb696 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -154,6 +154,42 @@ export type ApplicationSettings = { */ textureFilter: "auto" | "nearest" | "linear"; + /** + * whether 3D objects cast a soft "blob" shadow on the ground by default + * ([#1515](https://github.com/melonjs/melonJS/issues/1515)). + * + * A ground shadow is what stops a character or prop reading as *floating* + * in a 2.5D scene — it answers "where is this standing", not "where is the + * light". It is deliberately not a simulated shadow; see + * {@link Mesh#castGroundShadow}. + * + * This is the **default** for every {@link Mesh}, {@link Sprite3d} and + * {@link InstancedMesh}; each can override it with its own + * `castGroundShadow` (which wins), and {@link level.load} takes the same + * option for one glTF scene (which wins over this). + * + * Because it is a blanket opt-in, it skips meshes with **no vertical + * extent** — a flat plane lying on the floor *is* the floor, and shadowing + * it with itself would smear a blob across the whole ground. A per-object + * `castGroundShadow: true` bypasses that safeguard, being an explicit + * instruction. + * + * **On by default.** Requires a GPU backend *and* a {@link Camera3d}, so a + * 2D game is untouched whatever this says — the Canvas renderer has no + * depth buffer and the 2D-camera path draws none. Set it `false` to opt a + * 3D game out wholesale (a scene with baked lighting, or one bringing its + * own shadows, would otherwise get two). + * @default true + * @example + * // opt a 3D game out — e.g. its lighting is already baked into the models + * const app = new Application(1024, 768, { + * cameraClass: Camera3d, + * castGroundShadow: false, + * }); + * await app.init(); + */ + castGroundShadow: boolean; + /** * whether to display melonJS version and basic device information in the console * @default true diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index 0c59ac522..7ea6621e0 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -1030,6 +1030,12 @@ export default class Camera2d extends Renderable { container.draw(r, this); } + // Ground shadows (#1515) are held back until every opaque mesh in the + // world is down, and this is where that is true — still inside the + // camera's FBO/post-effect bracket, so they land in the frame the + // camera is about to resolve rather than after it has been composited. + renderer.flushGroundShadows(); + // draw the viewport/camera effects this.drawFX(renderer); diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 571e06fd2..463f87719 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -5,6 +5,7 @@ import { } from "../../loader/parsers/gltf.js"; import { parseAnimationOptions } from "../../renderable/animation.ts"; import Container from "../../renderable/container.js"; +import { hasVerticalExtent } from "../../renderable/groundshadow.js"; import InstancedMesh from "../../renderable/instanced_mesh.js"; import Mesh from "../../renderable/mesh.js"; import { fillInstances } from "./GLTFScene.js"; @@ -56,6 +57,8 @@ export default class GLTFModel extends Container { * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale) * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror) * @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 */ constructor(data, options = {}) { super(0, 0); @@ -113,6 +116,11 @@ export default class GLTFModel extends Container { const lit = options.lit === true; const rightHanded = options.rightHanded !== false; + // tri-state: `undefined` falls through to the application setting + const castGroundShadow = + typeof options.castGroundShadow === "boolean" + ? options.castGroundShadow + : undefined; // build the rest matrices + instantiate a Mesh per mesh-node primitive for (const idx in this._nodes) { @@ -156,6 +164,13 @@ export default class GLTFModel extends Container { shininess: prim.shininess, // thin/flat double-sided parts must not be back-face culled cullBackFaces: prim.doubleSided !== true, + // ground shadows (#1515), same scene-wide rule as the static + // path: a blanket opt-in skips parts with no vertical extent + castGroundShadow: + castGroundShadow === true + ? hasVerticalExtent(prim.vertices, prim.vertexCount) + : castGroundShadow, + shadowGroundY: options.shadowGroundY, }); 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 ea4100cf8..4dcf93218 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -1,6 +1,7 @@ import { Light3d } from "../../lighting/light3d.ts"; import { getGLTF } from "../../loader/loader.js"; import { boundingRadius } from "../../math/vertex.ts"; +import { hasVerticalExtent } from "../../renderable/groundshadow.js"; import InstancedMesh from "../../renderable/instanced_mesh.js"; import Mesh from "../../renderable/mesh.js"; import { writeInstanceTRS } from "../../video/gpu/instancerecord.ts"; @@ -88,6 +89,12 @@ export default class GLTFScene { } const scale = options.scale ?? 1; const rightHanded = options.rightHanded !== false; + // tri-state on purpose: `undefined` means "the caller said nothing", + // which falls through to the application setting at draw time + const castGroundShadow = + typeof options.castGroundShadow === "boolean" + ? options.castGroundShadow + : undefined; const zSign = rightHanded ? -1 : 1; // the scene is lit when it carries ANY shading-capable authored light @@ -113,7 +120,13 @@ export default class GLTFScene { // be retrieved from the world (`world.getChildByName(name)[0]`) to drive // playback. Lights are still instantiated (shared block at the end). if ((this.data.animations ?? []).length > 0) { - const model = new GLTFModel(this.data, { scale, rightHanded, lit }); + const model = new GLTFModel(this.data, { + scale, + rightHanded, + lit, + castGroundShadow, + shadowGroundY: options.shadowGroundY, + }); model.name = this.name; container.addChild(model); this._addLights(container, zSign, scale, options); @@ -190,6 +203,18 @@ export default class GLTFScene { // (coins, fences, foliage) are double-sided and must NOT be // back-face culled, or half their faces vanish cullBackFaces: node.doubleSided !== true, + // Ground shadows (#1515). A scene-wide opt-in skips nodes with + // no vertical extent: a glTF scene ships its ground as a flat + // plane, and shadowing that with itself smears a blob across + // the whole floor. Left unset by the caller this stays + // `undefined`, which is what lets the application-level + // setting through — passing `false` explicitly opts the scene + // out of that default. + castGroundShadow: + castGroundShadow === true + ? hasVerticalExtent(node.vertices, node.vertexCount) + : castGroundShadow, + shadowGroundY: options.shadowGroundY, }); if (instances) { fillInstances(mesh, instances); diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 8be051541..5612f674c 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -150,6 +150,8 @@ export const level = { * @param {boolean} [options.rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror * @param {boolean} [options.lights=true] - (glTF/GLB only) add the scene's authored `KHR_lights_punctual` lights (plus a soft ambient fill) as {@link Light3d} world children; each carries its authored name for `getChildByName` lookups * @param {number} [options.lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity (lux/candela) by this factor instead of normalizing it to 1 — see {@link GLTFScene#addTo} + * @param {boolean} [options.castGroundShadow] - (glTF/GLB only) give this scene's meshes a ground shadow ({@link Mesh#castGroundShadow}). Overrides the application's `castGroundShadow` setting for this scene, in both directions; omit it to inherit. As a scene-wide opt-in it skips nodes with no vertical extent — a scene's ground plane is exactly that, and shadowing it with itself smears a blob across the whole floor + * @param {number} [options.shadowGroundY] - (glTF/GLB only) world Y of the floor those 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 * @returns {boolean} true if the level was successfully loaded * @example * // the game assets to be be preloaded @@ -181,6 +183,9 @@ export const level = { * // 50 pixels per glTF unit, authored lux/candela intensities kept at * // a 1/1000 scale instead of being normalized to 1 * me.level.load("diorama", { scale: 50, lightIntensityScale: 0.001 }); + * // …and give every prop in it a ground shadow landing on the floor at y = 0 + * // (the scene's own ground plane is skipped — it has no height to cast) + * me.level.load("diorama", { scale: 50, castGroundShadow: true, shadowGroundY: 0 }); * // the authored lights are world children — grab the sun for a day/night cycle * const sun = app.world.getChildByName("Sun")[0]; */ diff --git a/packages/melonjs/src/renderable/groundshadow.js b/packages/melonjs/src/renderable/groundshadow.js new file mode 100644 index 000000000..8d2a13767 --- /dev/null +++ b/packages/melonjs/src/renderable/groundshadow.js @@ -0,0 +1,288 @@ +import Renderer from "./../video/renderer.js"; + +/** + * Ground ("blob") shadows for 3D objects — #1515. + * + * A soft dark ellipse painted on the ground beneath an object. This is not a + * simulated shadow and does not try to be: for the paper-thin billboard + * characters a 2.5D game is made of, a real shadow map both costs far more + * than this engine wants to spend and *looks worse*, because a flat quad's + * silhouette has to be special-cased to cast anything sensible at all. What + * the player actually needs from a shadow here is contact — where the object + * is standing, and how far off the ground it is mid-jump — and a blob says + * exactly that. + * + * The blob is one textured quad laid flat on the ground plane, drawn through + * the ordinary mesh path with blending on and depth writes off. Every shadow + * in the scene shares one geometry and one texture, so a scene full of them + * costs one draw each and no extra memory. + * @ignore + */ + +/** + * Side length of the shared falloff bake. Large enough that the ramp reads + * smooth when a shadow fills a good part of the screen, small enough that the + * whole thing is a rounding error against any real texture. + * @ignore + */ +const FALLOFF_SIZE = 128; + +/** + * The shared radial-falloff canvas, or `null` before first use. + * + * Kept as a **CPU canvas** for the same reason + * {@link Renderer.getWhitePixel} is: the GPU copy lives in the renderer's + * `TextureCache`, which is wiped and rebuilt on context loss, so keeping the + * source on the CPU means the shadow survives a lost context for free. It is + * also why this can be module-level while the quad below cannot. + * @ignore + */ +let falloffCanvas = null; + +/** + * Bake (once) a radial alpha ramp: opaque white at the centre, fading to + * fully transparent at the rim. + * + * White rather than black because the colour comes from the draw tint — that + * way the same bake serves a black shadow, a tinted one, or a coloured pool + * of light, and nothing has to be re-baked to change it. + * @returns {HTMLCanvasElement|OffscreenCanvas} the shared falloff canvas + * @ignore + */ +export function getShadowFalloff() { + if (falloffCanvas !== null) { + return falloffCanvas; + } + const canvas = Renderer.createCanvas(FALLOFF_SIZE, FALLOFF_SIZE, true); + const context = canvas.getContext("2d"); + if (context === null) { + throw new Error( + "groundshadow: 2D context unavailable on the allocated canvas", + ); + } + const image = context.createImageData(FALLOFF_SIZE, FALLOFF_SIZE); + const data = image.data; + const centre = (FALLOFF_SIZE - 1) / 2; + let at = 0; + for (let y = 0; y < FALLOFF_SIZE; y++) { + for (let x = 0; x < FALLOFF_SIZE; x++) { + const dx = (x - centre) / centre; + const dy = (y - centre) / centre; + const distance = Math.sqrt(dx * dx + dy * dy); + // smoothstep from the centre to the rim rather than a linear ramp: + // a linear falloff leaves a visible hard disc in the middle and a + // noticeable seam where it reaches zero + const t = distance >= 1 ? 0 : 1 - distance; + const alpha = t * t * (3 - 2 * t); + data[at] = 255; + data[at + 1] = 255; + data[at + 2] = 255; + data[at + 3] = Math.round(alpha * 255); + at += 4; + } + } + context.putImageData(image, 0, 0); + falloffCanvas = canvas; + return falloffCanvas; +} + +/** + * Drop the shared bake. Only the tests need this — the canvas is CPU-side and + * costs 64 KB, so an application never has a reason to release it. + * @ignore + */ +export function resetShadowFalloff() { + falloffCanvas = null; +} + +/** + * Whether a mesh's geometry has any vertical extent at all. + * + * The test a **blanket** opt-in needs: a ground shadow says "this object is + * standing above the floor", and a flat plane lying in the floor is the floor. + * Shadowing it with itself smears a blob the size of the whole ground across + * it, which is what a scene-wide `castGroundShadow` would otherwise do to + * every glTF scene that ships one. A per-object opt-in bypasses this — that + * one is an explicit instruction. + * + * Deliberately model-space and unrotated: the caller asks about the geometry, + * not about where it currently sits. + * @param {Float32Array} vertices - model-space vertex positions (x, y, z) + * @param {number} vertexCount - how many vertices to read + * @returns {boolean} true when the geometry is not flat in Y + * @ignore + */ +export function hasVerticalExtent(vertices, vertexCount) { + if (vertices === undefined || vertexCount === 0) { + return false; + } + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (let i = 0; i < vertexCount; i++) { + const y = vertices[i * 3 + 1]; + if (y < min) { + min = y; + } + if (y > max) { + max = y; + } + } + // relative to the horizontal size, so the answer does not depend on the + // units a model happens to be authored in + let spread = 0; + for (let i = 0; i < vertexCount; i++) { + const x = Math.abs(vertices[i * 3]); + const z = Math.abs(vertices[i * 3 + 2]); + const wider = x > z ? x : z; + if (wider > spread) { + spread = wider; + } + } + return max - min > spread * 1e-3; +} + +/** + * The shadow quad: a 1×1 square lying in the ground plane, centred on the + * origin, facing up. + * + * Render space is Y-DOWN, so the ground plane is XZ and "up" is `-Y` — the + * same convention `Sprite3d.WORLD_UP` states. + * @ignore + */ +const QUAD_VERTICES = new Float32Array([ + -0.5, 0, -0.5, 0.5, 0, -0.5, 0.5, 0, 0.5, -0.5, 0, 0.5, +]); +const QUAD_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]); +const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3]); +const QUAD_NORMALS = new Float32Array([0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0]); + +/** + * Borrow the shadow quad for a renderer, building it on first use. + * + * Cached **on the renderer**, not at module level, for two reasons: a `Mesh` + * resolves its texture through the active renderer's texture cache, so a + * module-level one would bind itself to whichever application happened to + * construct it first; and hanging it off the renderer means it dies with the + * renderer instead of outliving it. + * + * Two are kept — lit and unlit — so the shadow can be drawn through whichever + * mesh batcher its owner is already using. Sharing one unlit quad between lit + * owners would force a `litMesh → mesh → litMesh` batcher transition per + * shadowed object, each costing a bind/unbind pair and a flush: far more than + * the single draw the shadow is supposed to be. + * + * Retained geometry is keyed per mesh object, so one quad uploads once and + * every shadow in the scene redraws it at a different matrix for free. + * @param {object} renderer - the active renderer + * @param {boolean} lit - whether the owning mesh draws lit + * @param {Function} MeshClass - the `Mesh` constructor (passed in to avoid a + * circular import: `Mesh` owns the shadow, not the other way round) + * @returns {object} the shared shadow quad for that tier + * @ignore + */ +export function getShadowQuad(renderer, lit, MeshClass) { + let quads = renderer._shadowQuads; + if (quads === undefined) { + quads = renderer._shadowQuads = {}; + } + const key = lit === true ? "lit" : "unlit"; + let quad = quads[key]; + if (quad === undefined) { + quad = new MeshClass(0, 0, { + vertices: QUAD_VERTICES, + uvs: QUAD_UVS, + indices: QUAD_INDICES, + normals: QUAD_NORMALS, + texture: getShadowFalloff(), + // the geometry is already unit-sized; placement rides the matrix + width: 1, + normalize: false, + lit: lit === true, + // a ground quad is seen from above and, on a mirrored bridge, from + // whichever side the winding lands on — never cull it + cullBackFaces: false, + }); + // Internal: this mesh draws with blending on and depth writes off. + // Deliberately NOT a public `Mesh` option (#1516): a public blended + // mesh axis means owning back-to-front sorting for arbitrary + // translucent geometry, which a flat ground-hugging blob does not need. + quad._blendedDraw = true; + quads[key] = quad; + } + return quad; +} + +/** + * The blob quad an `InstancedMesh` draws from — sized to the prototype's own + * footprint (#1515). + * + * The shared quad is a unit square, and the instanced vertex stage scales it by + * each record's horizontal scale alone. That silently assumes the prototype + * geometry is exactly 1 unit across, which is true of almost nothing: glTF + * meshes load with `normalize: false` and keep their authored size, so a tree + * 3 units wide got a blob sized for a 1-unit tree. It also skipped the contact + * spread and the minor-axis floor the per-object tier applies, so the SAME + * asset drew a different shadow depending on whether it was instanced. + * + * Baking the extents into four vertices fixes it with no shader change and no + * per-draw uniform: retained geometry is keyed per mesh object, so this uploads + * once per `InstancedMesh` and is redrawn for free. + * @param {object} mesh - the InstancedMesh casting the shadows + * @param {Function} MeshClass - the `Mesh` constructor (avoids a circular import) + * @param {number} halfX - model-space half-extent along X, spread applied + * @param {number} halfZ - model-space half-extent along Z, spread applied + * @param {number} version - the prototype's geometry version + * @returns {object} the quad to draw this scatter's blobs from + * @ignore + */ +export function getInstancedShadowQuad(mesh, MeshClass, halfX, halfZ, version) { + const key = `${mesh.lit === true}:${version}:${halfX}:${halfZ}`; + let quad = mesh._shadowQuad; + if (quad !== undefined && quad._shadowKey === key) { + return quad; + } + quad?.destroy(); + quad = new MeshClass(0, 0, { + vertices: new Float32Array([ + -halfX, + 0, + -halfZ, + halfX, + 0, + -halfZ, + halfX, + 0, + halfZ, + -halfX, + 0, + halfZ, + ]), + uvs: QUAD_UVS, + indices: QUAD_INDICES, + normals: QUAD_NORMALS, + texture: getShadowFalloff(), + width: 1, + normalize: false, + lit: mesh.lit === true, + cullBackFaces: false, + }); + quad._blendedDraw = true; + quad._shadowKey = key; + mesh._shadowQuad = quad; + return quad; +} + +/** + * Release the shadow quads a renderer built, if any. Called from renderer + * teardown so the retained GPU geometry goes with it. + * @param {object} renderer - the renderer being torn down + * @ignore + */ +export function releaseShadowQuads(renderer) { + const quads = renderer._shadowQuads; + if (quads !== undefined) { + quads.lit?.destroy(); + quads.unlit?.destroy(); + renderer._shadowQuads = undefined; + } +} diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index a9e1f7433..0309d4f79 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -7,10 +7,15 @@ import { writeIdentityTransform, writeInstanceTransform, } from "../video/gpu/instancerecord.ts"; +import { getInstancedShadowQuad } from "./groundshadow.js"; import Mesh from "./mesh.js"; // scratch reused by getBounds3d(); never handed out const _instanceMatrix = new Matrix3d(); + +// scratch for the flattened group matrix a shadow pass draws with — +// synchronous, single-use per draw, never held +const _SHADOW_MATRIX = new Matrix3d(); const _instanceBounds = new AABB3d(); // scratch for dirtyRange() — read synchronously and never retained const _dirtySpan = [0, 0]; @@ -574,6 +579,9 @@ export default class InstancedMesh extends Mesh { } this.indices = this._indicesOriginal; renderer.drawMesh(this, this._composeModelMatrix()); + if (this._castsGroundShadow(renderer) === true) { + this._drawInstancedGroundShadow(renderer); + } } /** @@ -618,6 +626,89 @@ export default class InstancedMesh extends Mesh { * Release the instance records along with the mesh. * @ignore */ + /** + * 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. + * + * 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. + * @param {WebGLRenderer} renderer - the active renderer + * @ignore + */ + _drawInstancedGroundShadow(renderer) { + if (typeof renderer.drawInstancedShadow !== "function") { + return; + } + // Sized from the PROTOTYPE's own footprint, with the same contact + // spread and minor-axis floor the per-object tier applies — otherwise + // the identical asset draws a different shadow instanced than it does + // standalone. The instanced vertex stage multiplies these by each + // record's horizontal scale. + this._measureShadowFootprint(); + const spread = 1.2; + let hx = this._shadowHalfX * spread; + let hz = this._shadowHalfZ * spread; + const major = hx > hz ? hx : hz; + const least = major * 0.5; + if (major > 1e-6) { + hx = hx < least ? least : hx; + hz = hz < least ? least : hz; + } + const quad = getInstancedShadowQuad( + this, + Mesh, + hx, + hz, + this._geometryVersion ?? 0, + ); + 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; + + const tint = renderer.currentTint; + const savedR = tint.r; + const savedG = tint.g; + const savedB = tint.b; + // the colour's OWN alpha, which `setColor(r, g, b)` resets to 1 + const savedTintAlpha = tint.alpha; + const savedAlpha = renderer.getGlobalAlpha(); + tint.setColor(0, 0, 0); + renderer.setGlobalAlpha(this.shadowOpacity * savedAlpha); + try { + renderer.drawInstancedShadow(this, _SHADOW_MATRIX, quad); + } finally { + tint.setColor(savedR, savedG, savedB, savedTintAlpha); + renderer.setGlobalAlpha(savedAlpha); + } + } + destroy() { this.instanceBuffer = new Float32Array(0); this._instanceCount = 0; diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 53fd2b3f2..947292c8c 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -15,6 +15,7 @@ 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 Renderable from "./renderable.js"; /** @@ -23,6 +24,33 @@ import Renderable from "./renderable.js"; * @import WebGLRenderer from "./../video/webgl/webgl_renderer.js"; */ +// How far above the ground an object rises before its shadow is gone, as a +// multiple of the object's own horizontal extent. Tuned by eye: a character +// roughly two of its own widths up has clearly left the floor. +const SHADOW_FADE_SPAN = 6; + +// How far the blob is lifted off the ground plane, as a fraction of its own +// radius. A shadow drawn EXACTLY coplanar with the floor it sits on is +// order-dependent: both survive the LEQUAL depth test at equal depth, so +// whichever draws last wins, and the sort between two objects at one depth is +// arbitrary. Lifting it a hair toward the sky makes it win by depth instead of +// by luck. Proportional to the radius so it holds at any scale — a fixed +// epsilon would z-fight on a large shadow and float visibly on a small one. +const SHADOW_LIFT = 0.01; + +// How thin a blob is allowed to get on its minor axis, as a fraction of its +// major. A paper-thin caster — a `Sprite3d` billboard, a flat plate — projects +// honestly to a hairline, which reads as a rendering fault rather than a +// shadow; this keeps it an ellipse that is still clearly elongated along the +// object. +const SHADOW_MIN_AXIS_RATIO = 0.5; + +// How far the blob spreads past the caster's own footprint. A shadow sized to +// exactly the footprint is invisible under anything with vertical sides — a +// crate sitting flat on the floor covers its own shadow completely — and real +// contact shadows spread a little anyway, because no light source is a point. +const SHADOW_SPREAD = 1.2; + // reusable matrix for combining projection × model in draw() const _combinedMatrix = new Matrix3d(); @@ -513,6 +541,82 @@ export default class Mesh extends Renderable { */ this.alphaMap = undefined; + /** + * Cast a soft dark ellipse — a "blob" shadow — on the ground beneath + * this mesh (#1515). + * + * Not a simulated shadow, deliberately: what a 2.5D scene needs from + * one is *contact* — where the object is standing, and how far off the + * ground it is mid-jump. It costs one extra draw per shadowed object, + * shares one geometry and one texture with every other shadow in the + * scene, and is inert while `false`. + * + * Requires a GPU backend and a {@link Camera3d}: the shadow rides the + * retained world-space path, so the Canvas renderer and the 2D-camera + * path draw none. + * + * Left **unset** (`undefined`, the default) this follows the + * application's `castGroundShadow` setting — with one safeguard: a + * scene-wide opt-in skips meshes with no vertical extent, because a + * flat plane lying on the floor is the floor, and shadowing it with + * itself smears the whole ground. Setting the property here is an + * explicit instruction and always obeyed, safeguard included. + * @type {boolean|undefined} + * @default undefined + * @see Mesh#shadowGroundY + */ + this.castGroundShadow = + typeof settings.castGroundShadow === "boolean" + ? settings.castGroundShadow + : undefined; + + /** + * World Y of the floor the shadow lands on, or `undefined` (the + * default) to mean "this object is standing on the ground" — the + * shadow sits at the object's own base, at full strength, and does not + * shrink or fade. + * + * Set it, and the shadow shrinks and fades as the object rises above + * it: the readable part of a jump. The game already knows this value + * from collision, which is why it is not derived — deriving it from the + * object's live bounds would make the "ground" jump with the jumper, + * so the height could never be anything but zero. + * + * Render space is Y-DOWN, so the floor is a **greater** Y than the + * object above it. + * @type {number|undefined} + * @default undefined + */ + this.shadowGroundY = settings.shadowGroundY; + + /** + * Opacity of the shadow directly beneath the object, before any + * height fade. + * @type {number} + * @default 0.45 + */ + this.shadowOpacity = + typeof settings.shadowOpacity === "number" + ? settings.shadowOpacity + : 0.45; + + /** + * Cached horizontal half-extent used to size the shadow, resolved on + * first shadowed draw. Cached because the alternative is a + * `getBounds3d()` per frame per shadow, which re-bounds every source + * vertex through the model matrix. + * @ignore + */ + this._shadowHalfX = -1; + this._shadowHalfZ = -1; + // the geometry version the two above (and `_shadowHasHeight`) were + // measured at; -1 means "never". Recomputed when the mesh signals a + // geometry change, so a deforming mesh — or a `Sprite3d` whose frame + // bake rewrites `originalVertices` every animation step — does not keep + // a blob frozen at whatever its first shadowed draw happened to see. + this._shadowGeomVersion = -1; + this._shadowHasHeight = false; + /** * whether to cull back-facing triangles * @type {boolean} @@ -1235,6 +1339,224 @@ export default class Mesh extends Renderable { super.preDraw(renderer); } + /** + * Whether this mesh casts a ground shadow on this draw (#1515). + * + * Precedence, most specific first: the mesh's own `castGroundShadow`, then + * the application's `castGroundShadow` setting. A per-mesh value is an + * explicit instruction and is obeyed as given; the application default is a + * blanket one, so it skips meshes with no vertical extent — which is what a + * ground plane is, and a flat plane lying on the floor has nothing to cast. + * Read off the renderer rather than the global application instance, the + * same way the `textureFilter` default is. + * @param {WebGLRenderer|WebGPURenderer} renderer - the active renderer + * @returns {boolean} true if a shadow should be drawn + * @ignore + */ + _castsGroundShadow(renderer) { + if (typeof this.castGroundShadow === "boolean") { + return this.castGroundShadow; + } + if (renderer.settings?.castGroundShadow !== true) { + return false; + } + // Measured with the footprint and cached against the geometry version. + // This runs per mesh per FRAME on the application-default path — which + // is now the default — and `hasVerticalExtent` walks every vertex + // twice, so an uncached call makes a 100k-vertex ground plane pay 200k + // iterations a frame purely to answer "no". + this._measureShadowFootprint(); + return this._shadowHasHeight; + } + + /** + * Measure the model-space footprint the blob is built from, and whether the + * geometry has any height at all — once per geometry version. + * @ignore + */ + _measureShadowFootprint() { + const version = this._geometryVersion ?? 0; + if (this._shadowGeomVersion === version) { + return; + } + const src = this.originalVertices; + let hx = 0; + let hz = 0; + for (let i = 0; i < this.vertexCount; i++) { + const i3 = i * 3; + const x = Math.abs(src[i3]); + const z = Math.abs(src[i3 + 2]); + if (x > hx) { + hx = x; + } + if (z > hz) { + hz = z; + } + } + // about the model ORIGIN, not the bounding box centre: the shadow is + // centred on where the object is placed, so an off-centre model needs + // the blob wide enough to still cover it + this._shadowHalfX = hx; + this._shadowHalfZ = hz; + this._shadowHasHeight = hasVerticalExtent(src, this.vertexCount); + this._shadowGeomVersion = version; + } + + /** + * Draw this mesh's ground shadow: the shared blob quad, flattened onto the + * ground plane beneath the object (#1515). + * + * The quad is shared by every shadow in the scene and its geometry is + * uploaded once, so this is one draw and no allocation. + * @param {WebGLRenderer} renderer - the active renderer + * @ignore + */ + _drawGroundShadow(renderer) { + const quad = getShadowQuad(renderer, this.lit === true, Mesh); + const model = this._modelMatrix.val; + const originX = model[12]; + const originY = model[13]; + const originZ = model[14]; + + this._measureShadowFootprint(); + + // The footprint axes: the model-space half-extents carried through the + // object's own rotation and scale, then flattened onto the ground. + // + // Taken from `currentTransform` + `meshScale` rather than from the model + // matrix, and that distinction matters: a billboarded `Sprite3d` builds + // its model matrix from a CAMERA-FACING basis, so reading that would + // make its blob spin as the camera orbits. `currentTransform` carries + // only what the object itself was turned by. + // + // Doing it as two axes rather than one radius is what gives a shadow + // that matches the object: a thin upright panel gets a thin ellipse + // lying along the panel — turning as the panel turns — instead of a disc + // that reads as perpendicular to it. + const c = this.currentTransform.val; + const s = this.meshScale; + const zSign = this.rightHanded ? -1 : 1; + const hx = this._shadowHalfX * s; + const hz = this._shadowHalfZ * s; + // Columns 0 and 2 of the horizontal basis, Y dropped. `_composeModelMatrix` + // applies the axis bridge as a ROW scale — `diag(s, -s, ±s) · transform` — + // so it is the world **Z component of both columns** that carries `zSign`, + // not one whole column. Folding it into `hz` instead mirrors the basis + // about world X, and a right-handed (glTF) caster's blob then turns the + // WRONG WAY: an elongated prop rotated θ gets an ellipse at −θ. + let axX = c[0] * hx; + let axZ = c[2] * hx * zSign; + let azX = c[8] * hz; + let azZ = c[10] * hz * zSign; + let axLen = Math.hypot(axX, axZ); + let azLen = Math.hypot(azX, azZ); + + // A paper-thin caster — a `Sprite3d` billboard, a flat plate — has no + // depth at all on one axis, and an honest projection of it is a + // hairline, which reads as a rendering fault rather than a shadow. Give + // the minor axis a floor relative to the major, so the blob stays a + // blob: still clearly elongated along the object, never degenerate. + const minor = axLen > azLen ? azLen : axLen; + const major = axLen > azLen ? axLen : azLen; + const least = major * SHADOW_MIN_AXIS_RATIO; + if (minor < least && major > 1e-6) { + if (axLen < least) { + if (axLen > 1e-6) { + const k = least / axLen; + axX *= k; + axZ *= k; + } else { + // nothing to scale up — grow along the perpendicular of the + // axis that DOES have length, in the ground plane + axX = (-azZ / azLen) * least; + axZ = (azX / azLen) * least; + } + axLen = least; + } + if (azLen < least) { + if (azLen > 1e-6) { + const k = least / azLen; + azX *= k; + azZ *= k; + } else { + azX = (-axZ / axLen) * least; + azZ = (axX / axLen) * least; + } + azLen = least; + } + } + + // one scalar for the height fade and the lift — the blob's own size + const extent = (axLen + azLen) * 0.5; + + // Render space is Y-DOWN, so the floor sits at a GREATER Y than the + // object standing on it. `bottom` is `max.y` for the same reason. + let groundY = this.shadowGroundY; + let strength = 1; + if (groundY === undefined) { + groundY = this.getBounds3d().bottom; + } else { + // how far the object floats above that floor, as a fraction of its + // own size — a blob that shrinks and fades is what reads as height + const height = groundY - originY; + const fade = 1 - height / (extent * SHADOW_FADE_SPAN); + strength = fade > 0 ? (fade < 1 ? fade : 1) : 0; + if (strength === 0) { + return; + } + } + + // 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; + // 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 + if (quad._modelMatrix === undefined) { + quad._modelMatrix = new Matrix3d(); + } + const out = quad._modelMatrix.val; + out[0] = axX * k; + out[1] = 0; + out[2] = axZ * k; + out[3] = 0; + out[4] = 0; + out[5] = 1; + out[6] = 0; + out[7] = 0; + out[8] = azX * k; + 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[13] = groundY - extent * SHADOW_LIFT; + out[14] = originZ; + out[15] = 1; + + // Stash and restore by hand rather than save()/restore(): restore() + // runs setBlendMode, which would re-enable GL_BLEND for the rest of an + // otherwise opaque mesh pass — MeshBatcher.bind() turned it off behind + // that cache's back. Opacity must go through the global alpha too: + // drawMesh builds its tint as currentTint.toUint32(getGlobalAlpha()), + // and toUint32 ignores the colour's own alpha. + const tint = renderer.currentTint; + const savedR = tint.r; + const savedG = tint.g; + const savedB = tint.b; + // the colour's OWN alpha, which `setColor(r, g, b)` resets to 1 + const savedTintAlpha = tint.alpha; + const savedAlpha = renderer.getGlobalAlpha(); + tint.setColor(0, 0, 0); + renderer.setGlobalAlpha(this.shadowOpacity * strength * savedAlpha); + try { + renderer.drawMesh(quad, quad._modelMatrix); + } finally { + tint.setColor(savedR, savedG, savedB, savedTintAlpha); + renderer.setGlobalAlpha(savedAlpha); + } + } + /** * Draw the mesh (automatically called by melonJS). Picks between two * projection paths based on the camera that was active when this mesh @@ -1295,6 +1617,15 @@ export default class Mesh extends Renderable { // previously drawn through the reversing path below. this.indices = this._indicesOriginal; renderer.drawMesh(this, this._composeModelMatrix()); + if ( + this._castsGroundShadow(renderer) === true && + this.instanceLayout === undefined + ) { + // after the object, so the shadow blends over whatever the + // object did not cover. An InstancedMesh takes its own path + // — one shadow draw for the whole set, not one per instance. + this._drawGroundShadow(renderer); + } return; } // Camera3d path. The reflection bridge (Y-only negate) inverts diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index ccbaf75c9..2336a6ea6 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -254,6 +254,16 @@ export default class Sprite3d extends Mesh { // disable (fully opaque), or tune the threshold. alphaCutoff: typeof settings.alphaCutoff === "number" ? settings.alphaCutoff : 0.5, + // ground shadow (#1515) — forwarded explicitly, like everything + // else here: this constructor hands `Mesh` a built settings object + // rather than the caller's, so anything not named is dropped + // passed through RAW, not coerced: `undefined` is meaningful here — + // it means "follow the application setting" — and `=== true` would + // flatten it to an explicit false, silently opting every sprite out + // of a scene-wide default + castGroundShadow: settings.castGroundShadow, + shadowGroundY: settings.shadowGroundY, + shadowOpacity: settings.shadowOpacity, }); /** diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 8e723112a..47c6f0b37 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -326,6 +326,110 @@ export default class Renderer { */ flush() {} + /** + * Hold one ground shadow ({@link Mesh#castGroundShadow}) back until the end + * of the mesh pass (#1515). + * + * A blob shadow deliberately writes no depth, so that two overlapping at + * one ground height blend instead of fighting. The price of that choice is + * that it leaves nothing in the depth buffer to defend itself with: any + * opaque mesh drawn afterwards simply paints over it. The ground plane is + * exactly that mesh whenever it sorts after the props standing on it — the + * common case, because a large plane's single sort key says nothing useful + * about where it sits relative to what stands on it. + * + * So shadows are collected here and drawn once the opaque meshes are down. + * Depth *testing* stays on throughout, so a shadow is still correctly + * hidden behind geometry genuinely in front of it: deferring changes only + * who paints over whom among draws that write no depth. + * @param {object} quad - the shared shadow quad + * @param {Matrix3d} modelMatrix - where the blob sits (copied, not retained) + * @param {number} tint - packed ARGB tint for the draw + * @param {object} [instanced] - the `InstancedMesh` whose instance buffer + * supplies one blob per instance, for the instanced tier + * @ignore + */ + queueGroundShadow(quad, modelMatrix, tint, instanced) { + const pool = (this._shadowPool ??= []); + const at = this._shadowCount ?? 0; + // The matrix is COPIED, not referenced: every shadow in a scene shares + // one quad, so `quad._modelMatrix` is rewritten by the next caster long + // before this entry is drawn. Slots are reused frame to frame, so a + // steady scene allocates nothing after its first. + let entry = pool[at]; + if (entry === undefined) { + entry = { quad: null, matrix: new Matrix3d(), tint: 0, instanced: null }; + pool[at] = entry; + } + entry.quad = quad; + entry.matrix.copy(modelMatrix); + entry.tint = tint; + entry.instanced = instanced ?? null; + this._shadowCount = at + 1; + } + + /** + * Draw every ground shadow queued since the last drain, then empty the + * queue (#1515). Called when the renderer leaves mesh mode, and once more + * at end of frame for a scene made only of meshes, which never switches + * away from mesh mode on its own. + * + * Inert on a backend that never queues — the Canvas renderer has no depth + * buffer and so no ground shadows at all. + */ + flushGroundShadows() { + const count = this._shadowCount ?? 0; + if (count === 0 || this._shadowFlushing === true) { + return; + } + const pool = this._shadowPool; + // emptied BEFORE the replay, not after: the draws below re-enter + // `setBatcher`, which calls back in here, and a queue still holding + // entries would recurse. `_shadowFlushing` guards the same door from + // the other side, and covers the re-entry into `drawMesh`. + this._shadowCount = 0; + this._shadowFlushing = true; + try { + // The colour the shadow was queued WITH has to be put back, because + // both backends rebuild the draw tint from `currentTint` + + // `getGlobalAlpha()` — long since restored to the scene's values by + // the time this replay runs. + const tint = this.currentTint; + const savedR = tint.r; + const savedG = tint.g; + const savedB = tint.b; + // the colour's OWN alpha, which `setColor(r, g, b)` resets to 1 + const savedTintAlpha = tint.alpha; + const savedAlpha = this.getGlobalAlpha(); + try { + for (let i = 0; i < count; i++) { + const entry = pool[i]; + const packed = entry.tint; + tint.setColor( + (packed >>> 16) & 0xff, + (packed >>> 8) & 0xff, + packed & 0xff, + ); + this.setGlobalAlpha(((packed >>> 24) & 0xff) / 255); + if (entry.instanced !== null) { + this.drawInstancedShadow(entry.instanced, entry.matrix, entry.quad); + } else { + this.drawMesh(entry.quad, entry.matrix); + } + // drop the references, so a destroyed mesh is not held alive by + // a pooled slot until that slot is next reused + entry.quad = null; + entry.instanced = null; + } + } finally { + tint.setColor(savedR, savedG, savedB, savedTintAlpha); + this.setGlobalAlpha(savedAlpha); + } + } finally { + this._shadowFlushing = false; + } + } + /** * Draw a textured triangle mesh. * The mesh object must provide: `vertices` (Float32Array, x/y/z triplets), @@ -351,6 +455,13 @@ export default class Renderer { * Reset context state */ reset() { + // Drop any ground shadows still queued (#1515) BEFORE anything below + // can trip a drain. A reset re-inits every batcher and then switches + // batcher, which is itself a drain point — replaying entries from the + // frame we are abandoning would paint them after `clear()`, and on the + // context-lost branch would draw geometry belonging to the dead + // context. The frame is being thrown away; its shadows go with it. + this._shadowCount = 0; this.renderState.reset(this.width, this.height); this.resetTransform(); this.setBlendMode(this.settings.blendMode); diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 092bc0425..f912f5200 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -15,6 +15,7 @@ import GLShader from "../glshader.js"; import meshFragment from "./../shaders/mesh.frag"; import meshVertex from "./../shaders/mesh.vert"; import meshInstancedVertex from "./../shaders/mesh-instanced.vert"; +import meshShadowInstancedVertex from "./../shaders/mesh-shadow-instanced.vert"; import { injectDefines } from "../utils/string.js"; import { MaterialBatcher } from "./material_batcher.js"; @@ -146,6 +147,14 @@ export default class MeshBatcher extends MaterialBatcher { }); this.instancedShaders = new Map(); + // the standalone ground-shadow program (#1515) is GL-owned too. An + // orphan keeps its program AND its context-lost/restored subscriptions + // alive, and would try to recompile against a dead context on the next + // restore — the same hazard the instanced variants above are dropped + // for. + this.shadowShader?.destroy(); + this.shadowShader = undefined; + // last `uTint` value pushed, same redundant-set guard — but the // sentinel is `undefined`, NOT a number: a packed ARGB tint spans the // whole 32-bit range, and white at full alpha (0xffffffff) reads back @@ -275,6 +284,8 @@ export default class MeshBatcher extends MaterialBatcher { this.instancedShaders?.forEach((shader) => { shader.destroy(); }); + this.shadowShader?.destroy(); + this.shadowShader = undefined; this.instancedShaders?.clear(); if (this._onTargetChanged) { off(RENDER_TARGET_CHANGED, this._onTargetChanged); @@ -480,12 +491,37 @@ export default class MeshBatcher extends MaterialBatcher { drawRetainedMesh(mesh, modelMatrix, tint) { const gl = this.gl; + // A ground shadow is deferred to the end of the mesh pass rather than + // drawn here (#1515). It writes no depth, so it has nothing to defend + // itself with: every opaque mesh still to come would paint straight + // over it — the ground plane above all, which routinely sorts after the + // props standing on it. The renderer replays the queue once the opaque + // meshes are down, and calls back in with `_shadowFlushing` set. + if ( + mesh._blendedDraw === true && + this.renderer._shadowFlushing !== true && + this.renderer.queueGroundShadow !== undefined + ) { + this.renderer.queueGroundShadow(mesh, modelMatrix, tint); + return; + } + // anything the caller had queued must land first, or this draw would // reorder ahead of it this.flush(); + // Strictly BEFORE the blended-draw toggle below: `updatePassState` + // runs the one-shot depth clear, and `gl.clear(DEPTH_BUFFER_BIT)` + // silently does nothing with `depthMask` off. A blended mesh drawn + // first in a frame would otherwise swallow the clear and leave the + // whole frame rendering against stale depth. this.updatePassState(); + const blended = mesh._blendedDraw === true; + if (blended === true) { + this.beginBlendedDraw(); + } + const slices = mesh.textureGroups; if (slices === undefined) { this.applyMeshMaterial(mesh); @@ -521,12 +557,69 @@ export default class MeshBatcher extends MaterialBatcher { } } + if (blended === true) { + this.endBlendedDraw(); + } + // hand the batcher's own vertex state back, so a subsequent // accumulated draw uploads and draws through its buffers, not these this.vertexState.bind(); gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); } + /** + * Enter the blended mesh draw state: alpha blending on, depth test still + * on, depth WRITES off (#1515). + * + * The blend function is set explicitly rather than through + * `renderer.setBlendMode`. That method short-circuits when the mode is + * unchanged, and `MeshBatcher.bind()` has already disabled `GL_BLEND` + * behind that cache's back — so asking for the mode the cache already + * claims would leave blending off and the draw opaque. Merely calling + * `setBlendEnabled(true)` is no better: it inherits whatever function the + * last 2D draw left, and under `"additive"` a dark shadow would *brighten* + * the ground. + * @ignore + */ + beginBlendedDraw() { + const gl = this.gl; + gl.enable(gl.BLEND); + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc( + this.renderer.premultipliedAlpha ? gl.ONE : gl.SRC_ALPHA, + gl.ONE_MINUS_SRC_ALPHA, + ); + // depth TEST stays on — the shadow must still be occluded by geometry + // in front of it — but writes are off, so overlapping shadows at one + // ground height blend instead of fighting under LEQUAL + gl.depthMask(false); + } + + /** + * Leave the blended draw state, restoring mesh mode exactly as + * {@link MeshBatcher#bind} left it. + * + * The blend FUNCTION is put back too, not just the enable bit. This draw + * overwrote it without touching `renderer.currentBlendMode`, so a later 2D + * draw asking for the mode the cache already claims would short-circuit + * and silently inherit this one's function. Re-issuing through + * `setBlendMode` with the cache invalidated restores both, and leaves the + * cache reading exactly what it read before. + * @ignore + */ + endBlendedDraw() { + const gl = this.gl; + gl.depthMask(true); + const renderer = this.renderer; + const mode = renderer.currentBlendMode; + if (typeof mode === "string") { + renderer.currentBlendMode = null; + renderer.setBlendMode(mode, renderer.currentPremultipliedAlpha); + } + // back to mesh mode, which is opaque + gl.disable(gl.BLEND); + } + /** * Get (building or refreshing as needed) the GPU state one instanced mesh * draws from: its retained prototype geometry, its instance buffer, and @@ -773,6 +866,157 @@ export default class MeshBatcher extends MaterialBatcher { gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); } + /** + * The instanced ground-shadow program (#1515), compiled on first use. + * + * ONE shader, not a variant per record layout: it reads only the three + * transform rows, so `hasColor` / `hasData` make no difference to it — + * which is also what stops a forest with per-instance colour and emissive + * from getting coloured, glowing shadows. + * @returns {GLShader} the shadow program + * @ignore + */ + instancedShadowShader() { + if (this.shadowShader === undefined) { + this.shadowShader = new GLShader(this.gl, { + vertex: meshShadowInstancedVertex, + // the UNLIT fragment stage, on both tiers, not + // `_instancedShaderSources().fragment`. A blob needs nothing + // from lighting — it samples the falloff and multiplies by the + // tint — and borrowing the lit tier's pairs a GLSL ES 3.00 + // fragment shader with this ES 1.00 vertex shader, which does + // not link ("Fragment shader version does not match other + // shader versions") and takes the whole lit instanced tier + // down with it. It would also read `vNormal` / `vWorldPos`, + // which a flat blob never writes. + fragment: meshFragment, + label: "melonJS instanced mesh shadow", + }); + } + return this.shadowShader; + } + + /** + * The vertex state pairing the SHARED shadow quad's geometry with this + * mesh's own instance records. + * + * Kept in its own slot with its own staleness fields rather than reusing + * the main pass's: that one keys on `builtGeometry` and `builtShader`, and + * the shadow pass differs in both, so sharing a slot would make the two + * passes rebuild each other's vertex array on every single draw. + * + * The instance buffer itself IS shared — the main pass already uploaded + * it this frame, so this adds no upload. + * @param {InstancedMesh} mesh - the mesh casting the shadows + * @param {object} quadGeometry - the shared shadow quad's retained geometry + * @returns {object} the per-mesh instanced state, with `shadowVertexState` built + * @ignore + */ + instancedShadowStateFor(mesh, quadGeometry) { + // Deliberately NOT `instancedStateFor`. That one keys its staleness on + // the CURRENTLY BOUND shader, and by here we are bound to the standalone + // shadow program — so it would judge the MAIN vertex state stale, tear + // it down and rebuild it against a program that has none of the mesh's + // attributes (warning about each), and the next frame's main draw would + // rebuild it straight back: two VAO teardowns per instanced mesh per + // frame, forever. + // + // The main pass has already run — the shadow queue drains after every + // opaque mesh — so the state and its uploaded instance buffer exist. + // The fallback covers a caller that somehow arrives first. + const state = + this.instanced.get(mesh) ?? this.instancedStateFor(mesh).state; + const stale = + state.shadowVertexState === undefined || + state.shadowBuiltVersion !== mesh._instanceVersion || + state.shadowBuiltGeometry !== quadGeometry.vertexBuffer || + state.shadowBuiltShader !== this.currentShader; + if (stale === true) { + const gl = this.gl; + const descriptor = { + buffers: [ + { + buffer: quadGeometry.vertexBuffer, + stride: this.stride, + // TRIMMED to position / region / colour at the original + // stride, so the offsets still land. The lit tier carries + // `aNormal`, which the standalone shadow program does not + // declare — leaving it in warns on every build. A flat + // blob has no use for a normal. + attributes: this.attributes.slice(0, 3), + }, + { + // only the three rows: the shadow reads no colour and no + // custom data, so declaring them would make the vertex + // state warn about attributes its program does not have + buffer: state.instances.buffer, + stride: mesh.instanceLayout.stride, + stepMode: "instance", + attributes: this._instanceAttributeRecords( + mesh.instanceLayout, + ).slice(0, 3), + }, + ], + indexBuffer: { + bind: () => { + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, quadGeometry.glIndexBuffer); + }, + }, + resolveLocation: (name) => { + return this.currentShader.getAttribLocation(name); + }, + }; + if (state.shadowVertexState === undefined) { + state.shadowVertexState = new WebGLVertexState(gl, descriptor); + } else { + state.shadowVertexState.build(descriptor); + } + state.shadowBuiltVersion = mesh._instanceVersion; + state.shadowBuiltGeometry = quadGeometry.vertexBuffer; + state.shadowBuiltShader = this.currentShader; + } + return state; + } + + /** + * Draw one flat blob per instance, in a single call, from the same + * instance buffer the mesh itself drew from (#1515). + * @param {InstancedMesh} mesh - the mesh casting the shadows + * @param {Matrix3d} shadowMatrix - the group matrix, flattened onto the ground + * @param {number} tint - tint colour in UINT32 (argb) format + * @param {object} quad - the shared shadow quad mesh + * @ignore + */ + drawInstancedShadow(mesh, shadowMatrix, tint, quad) { + const gl = this.gl; + const count = mesh.visibleInstanceCount; + if (count === 0) { + return; + } + this.flush(); + this.useShader(this.instancedShadowShader()); + this.updatePassState(); + this.applyMeshMaterial(quad); + this.setPlacementUniforms(shadowMatrix, tint); + + const quadGeometry = this.retainedGeometryFor(quad); + const state = this.instancedShadowStateFor(mesh, quadGeometry); + this.beginBlendedDraw(); + state.shadowVertexState.bind(); + gl.drawElementsInstanced( + this.mode, + quadGeometry.indexCount, + quadGeometry.indexType, + 0, + count, + ); + this.endBlendedDraw(); + + this.useShader(this.defaultShader); + this.vertexState.bind(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); + } + /** * Release the instance buffer and vertex state held for one mesh, if any. * @param {object} mesh - the mesh whose instance state should be freed @@ -781,6 +1025,7 @@ export default class MeshBatcher extends MaterialBatcher { releaseInstanced(mesh) { const state = this.instanced?.get(mesh); if (state !== undefined) { + state.shadowVertexState?.destroy(); state.vertexState?.destroy(); state.instances.destroy(); this.instanced.delete(mesh); @@ -793,6 +1038,7 @@ export default class MeshBatcher extends MaterialBatcher { */ releaseAllInstanced() { this.instanced?.forEach((state) => { + state.shadowVertexState?.destroy(); state.vertexState?.destroy(); state.instances.destroy(); }); diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert new file mode 100644 index 000000000..5b2a7154a --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/mesh-shadow-instanced.vert @@ -0,0 +1,64 @@ +// Instanced ground-shadow vertex shader (#1515). +// +// One flat blob per instance, drawn from the SAME instance buffer the mesh +// itself uses — so a forest of a hundred thousand trees costs one extra draw +// call, not a hundred thousand. +// +// This is a STANDALONE shader, not a variant of the instanced mesh family, +// and that is the whole trick. It reads only the three transform rows, never +// `aInstanceColor` or `aInstanceData`, which means: +// +// * one shader serves every record layout — no hasColor/hasData matrix; +// * a forest with per-instance colour and per-instance emissive cannot end +// up with coloured, glowing shadows, because neither slot is read; +// * the vertex layout can declare just the rows, leaving the buffer's +// stride and offsets untouched. +// +// The instance's rotation and vertical scale are DISCARDED: a blob lies flat +// on the ground whichever way its tree is turned, and only the horizontal +// footprint decides how wide it is. What survives is the translation and the +// horizontal scale, both read straight out of the rows with no matrix built: +// +// translation = (aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w) +// 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 +// to the ground height, so whatever Y this stage produces is discarded and +// every blob lands on the floor. +attribute vec3 aVertex; +attribute vec2 aRegion; +attribute vec4 aColor; + +attribute vec4 aInstanceRow0; +attribute vec4 aInstanceRow1; +attribute vec4 aInstanceRow2; + +uniform mat4 uProjectionMatrix; +uniform mat4 uViewMatrix; +uniform mat4 uModelMatrix; +uniform vec4 uTint; + +varying vec2 vRegion; +varying vec4 vColor; + +void main(void) { + vec3 instancePos = vec3(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w); + // column 0 of the instance basis carries the X axis times its scale; a + // scatter is rotated about the vertical axis, so its length is the + // horizontal footprint whatever the rotation + float footprint = length( + vec3(aInstanceRow0.x, aInstanceRow1.x, aInstanceRow2.x)); + + // the quad is a unit square in the ground plane (XZ); only its horizontal + // extent is scaled, and its own Y is irrelevant — uModelMatrix flattens it + vec3 local = instancePos + vec3(aVertex.x, 0.0, aVertex.z) * footprint; + + gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix + * vec4(local, 1.0); + + // tint first, then premultiply — matches the fragment shader's expectation + vec4 tinted = aColor * uTint; + vColor = vec4(tinted.rgb * tinted.a, tinted.a); + vRegion = aRegion; +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index acea99fba..228150271 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1,6 +1,7 @@ import { Color, colorPool } from "./../../math/color.ts"; import { Matrix3d } from "../../math/matrix3d.ts"; import { Bounds } from "../../physics/bounds.ts"; +import { releaseShadowQuads } from "../../renderable/groundshadow.js"; import { CANVAS_ONRESIZE, emit, @@ -473,6 +474,9 @@ export default class WebGLRenderer extends Renderer { } destroy() { + // the shared ground-shadow quads (#1515) hold retained GPU geometry + // keyed off this renderer's batchers — released before those go + releaseShadowQuads(this); if (this.batchers) { this.batchers.forEach((batcher) => { batcher.destroy?.(); @@ -679,6 +683,14 @@ export default class WebGLRenderer extends Renderer { } if (this.currentBatcher !== batcher) { + // Leaving mesh mode drains the deferred ground-shadow queue first, + // so the blobs land on top of every opaque mesh in the pass (#1515). + // Switching *within* mesh mode (lit ↔ unlit) must NOT drain it — + // the meshes still to come are exactly what the shadows have to + // beat. + if (name !== "mesh" && name !== "litMesh" && this._canDrainShadows()) { + this.flushGroundShadows(); + } if (this.currentBatcher !== undefined) { // flush the current batcher, then let it tear down any // state it set up at `bind()` time (Mesh batcher restores @@ -704,6 +716,26 @@ export default class WebGLRenderer extends Renderer { return this.currentBatcher; } + /** + * Whether the device state right now is the scene's, so a deferred + * ground-shadow drain (#1515) would actually land where it is meant to. + * + * A batcher transition is normally the end of the mesh pass — but not + * every one is. `setMask` fills its shape through the primitive batcher + * with **colour writes off and `stencilOp(…, INCR)` armed**, so draining + * there both discards every blob and stamps their footprints into the mask + * being built. A transition inside a post-effect bracket is bound to the + * child's FBO, so the blobs would be captured into that effect chain and + * lost from the world. In either case the queue simply waits: the camera + * drains it at the end of the world walk, which is the point that is always + * correct. + * @returns {boolean} true when a drain is safe here + * @ignore + */ + _canDrainShadows() { + return this.maskLevel === 0 && this._effectPassDepth === 0; + } + /** * Reset the gl transform to identity */ @@ -1692,6 +1724,26 @@ export default class WebGLRenderer extends Renderer { * entirely by uniforms; when omitted, its vertices are taken as already * positioned and accumulated through the batcher (the 2D-camera path). */ + /** + * Draw one ground shadow per instance of an `InstancedMesh` (#1515), in a + * single call over the same instance buffer the mesh drew from. + * @param {InstancedMesh} mesh - the mesh casting the shadows + * @param {Matrix3d} shadowMatrix - the group matrix flattened onto the ground + * @param {object} quad - the shared shadow quad + * @ignore + */ + drawInstancedShadow(mesh, shadowMatrix, quad) { + const tint = this.currentTint.toUint32(this.getGlobalAlpha()); + // deferred to the end of the mesh pass for the same reason a per-object + // shadow is — see `queueGroundShadow` + if (this._shadowFlushing !== true) { + this.queueGroundShadow(quad, shadowMatrix, tint, mesh); + return; + } + this.setBatcher(quad.lit === true ? "litMesh" : "mesh"); + this.currentBatcher.drawInstancedShadow(mesh, shadowMatrix, tint, quad); + } + drawMesh(mesh, modelMatrix) { const gl = this.gl; const retained = modelMatrix !== undefined; diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 35689a3ce..170404723 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -14,6 +14,7 @@ import { buildInstancedMeshWGSL, UNLIT_INSTANCED, } from "../shaders/mesh-instanced.js"; +import meshShadowInstancedWGSL from "../shaders/mesh-shadow-instanced.wgsl"; import WebGPUBatcher from "./webgpu_batcher.js"; /** @@ -137,6 +138,9 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { } this.instanced = new Map(); this.instancedKeys = new Map(); + // ground-shadow families, keyed by record shape like `instancedKeys` + // — the module is shared, the instance buffer's stride is not + this.shadowFamilyKeys = new Map(); } /** @@ -265,6 +269,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { const geometry = this.retainedGeometryFor(mesh); const instances = this.instanceBufferFor(mesh); + this.meshState.depthWrite = undefined; const pipeline = renderer.pipelineCache.get( this.instancedFamilyFor(mesh.instanceLayout), "triangle-list", @@ -305,6 +310,126 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { instances.lastDrawnFrameId = renderer.frameId; } + /** + * The instanced ground-shadow family (#1515), registered on first use. + * + * ONE family, not one per record layout: the module reads only the three + * transform rows, so `hasColor` / `hasData` make no difference — and the + * vertex layout declares only those rows, leaving the instance buffer's + * stride and offsets exactly as the mesh pass uses them. + * @param {object} layout - the mesh's instance record layout + * @returns {string} the pipeline family key + * @ignore + */ + instancedShadowFamily(layout) { + // Keyed by the record shape, exactly as `instancedFamilyFor` is, and + // for a sharper reason: the shadow reads only the three transform + // rows, so ONE module serves every layout — but the instance buffer's + // `arrayStride` is baked into the vertex layout, and it is 48 / 64 / 80 + // bytes depending on which optional slots the records carry. Caching a + // single family would hand the second scatter the first one's stride + // and its blobs would land at garbage positions. + const key = (layout.hasColor ? 1 : 0) | (layout.hasData ? 2 : 0); + let familyKey = this.shadowFamilyKeys.get(key); + if (familyKey !== undefined) { + return familyKey; + } + const cache = this.renderer.pipelineCache; + const layoutKey = `${this.vertexLayoutKey}InstancedShadow${key}`; + cache.registerVertexLayout(layoutKey, [ + { + stride: this.stride, + // TRIMMED to position / region / colour, at the original stride + // so the offsets still land. The lit tier carries `aNormal` at + // location 3, which is where the instance rows start — declaring + // the full layout collides with them and the pipeline is + // rejected outright ("shader location used more than once"). A + // flat blob has no use for a normal, so dropping it is also what + // lets ONE module serve both tiers. + attributes: this.attributes.slice(0, 3), + }, + { + stride: layout.stride, + stepMode: "instance", + // the three rows only — the shadow reads no colour, no data + attributes: instanceAttributes(layout, 3).slice(0, 3), + }, + ]); + familyKey = cache.registerShader(meshShadowInstancedWGSL, { + bindGroupLayouts: this.bindGroupLayoutList(cache), + vertexLayoutKey: layoutKey, + label: `melonJS instanced mesh shadow ${key}`, + }); + this.shadowFamilyKeys.set(key, familyKey); + return familyKey; + } + + /** + * Record one flat blob per instance, in a single call, over the same + * instance buffer the mesh itself drew from (#1515). + * @param {InstancedMesh} mesh - the mesh casting the shadows + * @param {Matrix3d} shadowMatrix - the group matrix flattened onto the ground + * @param {number} tint - tint colour in UINT32 (argb) format + * @param {object} quad - the shared shadow quad + * @ignore + */ + drawInstancedShadow(mesh, shadowMatrix, tint, quad) { + const count = mesh.visibleInstanceCount; + if (count === 0) { + return; + } + this.flush(); + this.updatePassState(); + this.applyMeshMaterial(quad); + this.setPlacementUniforms(shadowMatrix, tint, quad); + + // Cull state is a PIPELINE axis here, and `meshState.cullMode` / + // `frontFace` are written only by `WebGPURenderer.drawMesh` — which this + // path bypasses. Left alone the blob inherits whatever the last ordinary + // mesh set, and since the shadow matrix's horizontal block has negative + // determinant under the glTF bridge, an inherited `"back"` can cull the + // blobs outright. A ground quad is seen from above and, on a mirrored + // bridge, from whichever side the winding lands on — never cull it. + this.meshState.cullMode = "none"; + this.meshState.frontFace = "ccw"; + + const renderer = this.renderer; + const pass = renderer.ensurePass(); + const quadGeometry = this.retainedGeometryFor(quad); + const instances = this.instanceBufferFor(mesh); + + // blended, and no depth write: overlapping blobs blend rather than + // fight under LEQUAL + this.meshState.depthWrite = false; + const pipeline = renderer.pipelineCache.get( + this.instancedShadowFamily(mesh.instanceLayout), + "triangle-list", + "normal", + renderer.premultipliedAlpha, + renderer.stencilMode, + this.meshState, + ); + this.meshState.depthWrite = undefined; + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + pass.setBindGroup(1, this.currentMaterial); + this.bindLights(pass); + pass.setBindGroup(3, this.uniformBinding.bindGroup, [ + this.uniformBinding.dynamicOffset, + ]); + pass.setVertexBuffer(0, quadGeometry.vertexBuffer); + pass.setVertexBuffer(1, instances.buffer); + pass.setIndexBuffer(quadGeometry.indexBuffer, quadGeometry.indexFormat); + pass.drawIndexed(quadGeometry.indexCount, count); + + quadGeometry.lastDrawnFrameId = renderer.frameId; + instances.lastDrawnFrameId = renderer.frameId; + } + /** * Release the instance buffer held for one mesh, if any. * @param {object} mesh - the mesh whose instance records should be freed @@ -702,6 +827,9 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { indexBytes, ); + // the accumulated path is opaque; a blended draw only reaches the + // retained path, so this must never inherit a stale flag + this.meshState.depthWrite = undefined; const pipeline = renderer.pipelineCache.get( this.activeShaderKey(), "triangle-list", @@ -817,10 +945,19 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { const pass = renderer.ensurePass(); const geometry = this.retainedGeometryFor(mesh); + // A blended mesh (the ground shadow, #1515) keeps the depth TEST but + // stops writing depth, so overlapping shadows blend instead of + // fighting. Set per draw, never left behind: an ordinary mesh must + // resolve to exactly the pipeline it always did. + const blended = mesh._blendedDraw === true; + // `undefined` rather than `true` for the ordinary case: the axis reads + // `!== false`, so leaving it unset keeps `meshState` byte-for-byte what + // it was before this existed, and the pipeline key gains nothing + this.meshState.depthWrite = blended ? false : undefined; const pipeline = renderer.pipelineCache.get( this.activeShaderKey(), "triangle-list", - "none", + blended ? "normal" : "none", renderer.premultipliedAlpha, renderer.stencilMode, this.meshState, diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js index 3028e3574..4afd6a57b 100644 --- a/packages/melonjs/src/video/webgpu/pipeline/cache.js +++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js @@ -323,6 +323,9 @@ export default class WebGPUPipelineCache { // and same-shape effects share one module and its pipelines /** @type {Map} moduleText → family key */ this.registeredModules = new Map(); + // compiled GPUShaderModule per source text, shared across the families + // that source serves (one module, several vertex layouts) + this.modulesBySource = new Map(); /** @type {Map} family key → vertex-layout alias */ this.vertexLayoutAliases = new Map(); // the blit family rides the frozen quad vertex layout @@ -375,21 +378,39 @@ export default class WebGPUPipelineCache { * @returns {string} the family key to pass to {@link WebGPUPipelineCache#get} */ registerShader(code, options = {}) { - let key = this.registeredModules.get(code); + const alias = options.vertexLayoutKey ?? "quad"; + // Keyed by (source, vertex layout), not by source alone. One module can + // legitimately serve several vertex layouts — the instanced ground + // shadow (#1515) reads only the transform rows, so one module covers + // every record shape while the instance buffer's `arrayStride` still + // differs per shape. Keying on the source alone silently handed every + // later caller the FIRST registration's layout, which reads the records + // at the wrong stride. Callers registering one layout per module (all + // of them, until now) are unaffected: same source + same alias still + // dedupes to the same family. + const cacheKey = `${alias}\u0000${code}`; + let key = this.registeredModules.get(cacheKey); if (typeof key === "undefined") { key = `effect:${this.registeredModules.size}`; - this.registeredModules.set(code, key); - this.modules[key] = this.device.createShaderModule({ - label: options.label ?? `melonJS ${key} shader`, - code, - }); + this.registeredModules.set(cacheKey, key); + // the compiled module IS shared across layouts — only the pipeline + // layout and vertex-layout alias below are per-family + let module = this.modulesBySource.get(code); + if (module === undefined) { + module = this.device.createShaderModule({ + label: options.label ?? `melonJS ${key} shader`, + code, + }); + this.modulesBySource.set(code, module); + } + this.modules[key] = module; this.pipelineLayouts[key] = this.device.createPipelineLayout({ bindGroupLayouts: options.bindGroupLayouts ?? [ this.frameLayout, this.materialLayout, ], }); - this.vertexLayoutAliases.set(key, options.vertexLayoutKey ?? "quad"); + this.vertexLayoutAliases.set(key, alias); } return key; } @@ -496,6 +517,12 @@ export default class WebGPUPipelineCache { let key = `${shaderKey}|${topology}|${blend}|${pma ? 1 : 0}|${stencilMode}|${this.format}|${this.sampleCount}`; if (meshState) { key += `|mesh:${meshState.cullMode}:${meshState.frontFace}`; + // Appended ONLY when it differs from the default. Adding it + // unconditionally would remint every existing mesh pipeline for a + // value none of them changed. + if (meshState.depthWrite === false) { + key += "|dw0"; + } } let pipeline = this.pipelines.get(key); if (typeof pipeline === "undefined") { @@ -536,7 +563,10 @@ export default class WebGPUPipelineCache { primitive, depthStencil: { format: DEPTH_STENCIL_FORMAT, - depthWriteEnabled: !!meshState, + // `!== false`, not `!!`: mesh draws that never set the field (and + // every bare `{cullMode, frontFace}` literal) must keep writing + // depth exactly as they did before the axis existed + depthWriteEnabled: meshState ? meshState.depthWrite !== false : false, depthCompare: meshState ? "less-equal" : "always", stencilFront: stencil.stencil, stencilBack: stencil.stencil, diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl new file mode 100644 index 000000000..136d05bd4 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-shadow-instanced.wgsl @@ -0,0 +1,92 @@ +// Instanced ground-shadow module (#1515) — the WGSL twin of +// `webgl/shaders/mesh-shadow-instanced.vert`. +// +// One flat blob per instance, drawn from the SAME instance buffer the mesh +// itself uses, so a forest of a hundred thousand trees costs one extra draw +// rather than a hundred thousand. +// +// Hand-written and STANDALONE, rather than derived from the mesh module by +// `buildInstancedMeshWGSL`. Deriving it would inherit three things it must +// not have: the `hasColor`/`hasData` variant matrix (this reads neither slot, +// so one module serves every record layout), the per-instance tint multiply +// and emissive substitution (which would give a forest coloured, glowing +// shadows), and the VSOut member-written guard, which on the lit tier +// requires a body that writes `vWorldPos` and `vNormal` — neither of which a +// flat unlit blob has any use for. +// +// The instance's rotation and vertical scale are DISCARDED: a blob lies flat +// 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. + +struct FrameUniforms { + projection : mat4x4, + // unused by this shader; part of the shared frame-globals block. Its size + // has to match the block the layout declares EXACTLY — a struct that reads + // past `minBindingSize` is a pipeline-creation error, not a silent + // over-read, and takes the whole frame's command buffer down with it. + lineWidth : f32, +}; + +struct MeshUniforms { + model : mat4x4, + view : mat4x4, + tint : vec4f, + params : vec4f, + emissive : vec4f, + specular : vec4f, + eye : vec4f, +}; + +@group(0) @binding(0) var uFrame : FrameUniforms; +@group(1) @binding(0) var uTexture : texture_2d; +@group(1) @binding(1) var uSampler : sampler; +@group(1) @binding(2) var uAlphaMap : texture_2d; +@group(1) @binding(3) var uAlphaSampler : sampler; +@group(3) @binding(0) var uMesh : MeshUniforms; + +struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + @location(1) vColor : vec4f, +}; + +@vertex +fn vertex_main( + @location(0) aVertex : vec3f, + @location(1) aRegion : vec2f, + @location(2) aColor : vec4f, + @location(3) aInstanceRow0 : vec4f, + @location(4) aInstanceRow1 : vec4f, + @location(5) aInstanceRow2 : vec4f, +) -> VSOut { + var out : VSOut; + + let instancePos = vec3f(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w); + // column 0 of the instance basis is the X axis times its scale; a scatter + // is rotated about the vertical axis, so its length is the horizontal + // footprint whatever the rotation + let footprint = length( + vec3f(aInstanceRow0.x, aInstanceRow1.x, aInstanceRow2.x)); + + // the quad is a unit square in the ground plane (XZ); its own Y is + // irrelevant, because uMesh.model flattens it onto the floor + let local = instancePos + vec3f(aVertex.x, 0.0, aVertex.z) * footprint; + + let clip = uFrame.projection * uMesh.view * uMesh.model * vec4f(local, 1.0); + // WebGPU clip space is [0, w] in Z where GL is [-w, w] + out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w); + + let tinted = aColor * uMesh.tint; + out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); + out.vRegion = aRegion; + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + let texel = textureSample(uTexture, uSampler, in.vRegion); + return texel * in.vColor; +} diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index bd4b58ae7..cc47bda51 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1,6 +1,7 @@ import { Color, colorPool } from "../../math/color.ts"; import { Matrix3d } from "../../math/matrix3d.ts"; import { Bounds } from "../../physics/bounds.ts"; +import { releaseShadowQuads } from "../../renderable/groundshadow.js"; import { CANVAS_ONRESIZE, emit, @@ -1543,6 +1544,23 @@ export default class WebGPURenderer extends Renderer { throw new Error("Invalid Batcher"); } if (this.currentBatcher !== batcher) { + // Leaving mesh mode drains the deferred ground-shadow queue first, + // so the blobs land on top of every opaque mesh in the pass (#1515). + // Switching *within* mesh mode (lit ↔ unlit) must NOT drain it — the + // meshes still to come are exactly what the shadows have to beat. + if ( + name !== "mesh" && + name !== "litMesh" && + // see WebGLRenderer#_canDrainShadows: a transition raised while + // a mask is being stencilled in (colour writes off) or inside a + // post-effect bracket is not the end of the mesh pass, and + // draining there loses the blobs. The camera drain still gets + // them. + this.maskLevel === 0 && + this.stencilMode !== "write" + ) { + this.flushGroundShadows(); + } if (this.currentBatcher !== null) { this.currentBatcher.flush(); this.currentBatcher.unbind(); @@ -1568,9 +1586,46 @@ export default class WebGPURenderer extends Renderer { * 2D-camera path). * @override */ + /** + * Draw one ground shadow per instance of an `InstancedMesh` (#1515), in a + * single recorded call over the mesh's own instance buffer. + * @param {InstancedMesh} mesh - the mesh casting the shadows + * @param {Matrix3d} shadowMatrix - the group matrix flattened onto the ground + * @param {object} quad - the shared shadow quad + * @ignore + */ + drawInstancedShadow(mesh, shadowMatrix, quad) { + const tint = this.currentTint.toUint32(this.getGlobalAlpha()); + // deferred to the end of the mesh pass for the same reason a per-object + // shadow is — see `Renderer#queueGroundShadow` + if (this._shadowFlushing !== true) { + this.queueGroundShadow(quad, shadowMatrix, tint, mesh); + return; + } + this.setBatcher(quad.lit === true ? "litMesh" : "mesh"); + this.currentBatcher.drawInstancedShadow(mesh, shadowMatrix, tint, quad); + } + drawMesh(mesh, modelMatrix) { const retained = modelMatrix !== undefined; + // A ground shadow waits for the end of the mesh pass rather than + // drawing here: it writes no depth, so every opaque mesh still to come + // would paint over it (#1515). `flushGroundShadows` calls back in with + // `_shadowFlushing` set, and that pass takes the branch below. + if ( + mesh._blendedDraw === true && + retained && + this._shadowFlushing !== true + ) { + this.queueGroundShadow( + mesh, + modelMatrix, + this.currentTint.toUint32(this.getGlobalAlpha()), + ); + return; + } + if (this.meshDepthActive !== true) { // the depth half of the attachment is live from here on — pass // restarts preserve it and clears follow the armed policy (see @@ -3207,6 +3262,9 @@ export default class WebGPURenderer extends Renderer { * @override */ destroy() { + // the shared ground-shadow quads (#1515) hold retained GPU geometry + // keyed off this renderer's batchers — released before those go + releaseShadowQuads(this); off(GAME_RESET, this.onGameReset); off(CANVAS_ONRESIZE, this.onCanvasResize); this.abandonFrame(); diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js new file mode 100644 index 000000000..dc4ff6463 --- /dev/null +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -0,0 +1,1147 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { defaultApplicationSettings } from "../src/application/defaultApplicationSettings.ts"; +import { + Camera3d, + InstancedMesh, + Matrix3d, + Mesh, + Rect, + Sprite3d, + Vector3d, + WebGLRenderer, + WebGPURenderer, +} from "../src/index.js"; +import { + getShadowQuad, + releaseShadowQuads, +} from "../src/renderable/groundshadow.js"; +import Renderer from "../src/video/renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * Ground ("blob") shadows — #1515. + * + * Two things are under test and they pull in opposite directions. The feature + * itself: a soft, blended, depth-test-but-not-depth-write quad under an + * object. And the guarantee that surrounds it: a mesh that does **not** opt in + * must cost and render exactly what it did before any of this existed. The + * second is the harder one, because the mesh pass keeps its blend and depth + * state at *pass* scope — `MeshBatcher.bind()` runs only on a batcher + * transition — so anything the shadow changes per draw it must put back. + */ +describe("Ground shadows (#1515)", () => { + let renderer; + let camera; + + // a quad standing upright, so it has a horizontal extent to size a shadow + // from and something to sit above + const GEOMETRY = { + vertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), + }; + + beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); + camera = new Camera3d(0, 0, 128, 128); + // Ground shadows are ON by default application-wide, which is what the + // "the shipped default" case below asserts. Every other test here is + // about an EXPLICIT opt-in and its cost, so they run against a pinned + // `false` — otherwise "an unshadowed mesh is untouched" would be + // testing a mesh that is, in fact, shadowed. + renderer.settings.castGroundShadow = false; + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeMesh = (settings = {}) => { + return new Mesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + ...settings, + }); + }; + + // One frame's worth of drawing for a single mesh. `flushGroundShadows` is + // what `Application.draw` calls at end of frame, and it is not optional + // here: a shadow is DEFERRED to the end of the mesh pass (it writes no + // depth, so anything opaque drawn after it — the ground above all — would + // paint straight over it). Without the drain, no shadow is ever issued. + const drawOnce = (mesh) => { + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + renderer.flushGroundShadows(); + renderer.flush(); + }; + + // The blob's placement lives in the shared quad's own model matrix — the + // shadow is sized by two independent ground-plane axes (so it can be an + // oriented ellipse rather than a disc), which no single scalar captures. + const shadowGroundY = (lit = false) => { + return renderer._shadowQuads[lit ? "lit" : "unlit"]._modelMatrix.val[13]; + }; + // the blob's half-extent along each ground axis + const shadowAxes = (lit = false) => { + const m = renderer._shadowQuads[lit ? "lit" : "unlit"]._modelMatrix.val; + return { + x: Math.hypot(m[0], m[2]) / 2, + z: Math.hypot(m[8], m[10]) / 2, + }; + }; + + // ── the backward-compatibility contract ───────────────────────────── + + describe("an unshadowed mesh is untouched", () => { + it("issues exactly one draw", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeMesh(); + drawOnce(mesh); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(mesh); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + mesh.destroy(); + }); + + it("leaves BLEND off and depth writes on, as mesh mode requires", (ctx) => { + requireWebGL(ctx, renderer); + // nothing asserted this before, and it is the actual contract the + // shadow could break: both are pass-scoped state that `bind()` only + // re-establishes on a batcher transition + const gl = renderer.gl; + const mesh = makeMesh(); + drawOnce(mesh); + expect(gl.isEnabled(gl.BLEND)).toBe(false); + expect(gl.getParameter(gl.DEPTH_WRITEMASK)).toBe(true); + mesh.destroy(); + }); + }); + + describe("a shadowed mesh restores what it borrowed", () => { + it("BLEND is off and depth writes are back on afterwards", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + expect(gl.isEnabled(gl.BLEND)).toBe(false); + expect(gl.getParameter(gl.DEPTH_WRITEMASK)).toBe(true); + expect(gl.getError()).toBe(gl.NO_ERROR); + mesh.destroy(); + }); + + it("ADVERSARIAL: the blend MODE cache is left exactly as found", (ctx) => { + requireWebGL(ctx, renderer); + // the shadow sets the blend function directly, without going + // through setBlendMode. If it left the cache claiming one mode + // while GL held another's function, the next 2D draw asking for + // that mode would short-circuit and silently inherit this one. + renderer.setBlendMode("additive"); + const before = renderer.currentBlendMode; + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + expect(renderer.currentBlendMode).toBe(before); + renderer.setBlendMode("normal"); + mesh.destroy(); + }); + + it("ADVERSARIAL: the renderer tint and global alpha are unchanged", (ctx) => { + requireWebGL(ctx, renderer); + // the shadow darkens the tint and lowers the alpha for its own + // draw; leaking either would tint every later object + renderer.setGlobalAlpha(0.8); + renderer.currentTint.setColor(10, 20, 30); + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + expect(renderer.getGlobalAlpha()).toBeCloseTo(0.8, 5); + expect(renderer.currentTint.r).toBe(10); + expect(renderer.currentTint.g).toBe(20); + expect(renderer.currentTint.b).toBe(30); + renderer.setGlobalAlpha(1); + renderer.currentTint.setColor(255, 255, 255); + mesh.destroy(); + }); + }); + + // ── the shadow itself ─────────────────────────────────────────────── + + describe("the shadow draw", () => { + it("adds exactly one draw call", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const plain = makeMesh(); + const shadowed = makeMesh({ castGroundShadow: true }); + drawOnce(plain); + drawOnce(shadowed); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(plain); + const plainDraws = spy.mock.calls.length; + drawOnce(shadowed); + const shadowedDraws = spy.mock.calls.length - plainDraws; + expect(shadowedDraws).toBe(plainDraws + 1); + spy.mockRestore(); + plain.destroy(); + shadowed.destroy(); + }); + + it("shares one quad across every shadowed mesh", (ctx) => { + requireWebGL(ctx, renderer); + // one geometry upload for the whole scene, however many shadows + const a = makeMesh({ castGroundShadow: true }); + const b = makeMesh({ castGroundShadow: true }); + drawOnce(a); + const used = renderer._shadowQuads.unlit; + drawOnce(b); + // the same object, not merely an equivalent one — retained + // geometry is keyed per mesh instance, so sharing the object is + // what makes it one upload for the whole scene + expect(renderer._shadowQuads.unlit).toBe(used); + a.destroy(); + b.destroy(); + }); + + it("keeps a lit owner on the lit tier", (ctx) => { + requireWebGL(ctx, renderer); + // a shared unlit quad between lit meshes would force a + // litMesh -> mesh -> litMesh batcher transition per shadowed object + const mesh = makeMesh({ castGroundShadow: true, lit: true }); + drawOnce(mesh); + expect(renderer._shadowQuads.lit).toBeDefined(); + expect(renderer._shadowQuads.lit.lit).toBe(true); + mesh.destroy(); + }); + + it("ADVERSARIAL: the shadow sits BELOW the mesh, not above it", (ctx) => { + requireWebGL(ctx, renderer); + // render space is Y-DOWN, so the floor is a GREATER Y than the + // object standing on it. The sign is the single easiest thing to + // get backwards here, and backwards puts the blob in mid-air. + const mesh = makeMesh({ castGroundShadow: true }); + mesh.pos.set(0, 0, 100); + drawOnce(mesh); + expect(shadowGroundY()).toBeGreaterThan(mesh.pos.y); + mesh.destroy(); + }); + + it("fades out entirely once the object is high enough", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeMesh({ castGroundShadow: true }); + mesh.pos.set(0, 0, 100); + // a floor far below (greater Y) than the object + mesh.shadowGroundY = 10000; + drawOnce(mesh); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(mesh); + // the object's own draw only — the shadow skipped entirely + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + mesh.destroy(); + }); + + it("shrinks as the object rises", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh({ castGroundShadow: true }); + mesh.pos.set(0, 0, 100); + mesh.shadowGroundY = mesh.getBounds3d().bottom; + drawOnce(mesh); + const grounded = shadowAxes().x; + + // lift it: the floor is now further below (greater Y difference) + mesh.shadowGroundY = mesh.getBounds3d().bottom + 20; + drawOnce(mesh); + expect(shadowAxes().x).toBeLessThan(grounded); + mesh.destroy(); + }); + }); + + // ── the blended state, at the moment it matters ───────────────────── + + describe("the draw state each range is issued under", () => { + /** + * Record the blend / depth-write state at every `drawElements`. + * + * A pixel test would be the more direct proof, but a standalone + * `Camera3d` never runs its own draw, so nothing establishes the view + * and projection uniforms and the frame comes back empty — which is + * why the other 3D specs count calls rather than read pixels. What is + * asserted here is the mechanism itself: the state each draw is issued + * under, which is what makes the shadow soft and what must be handed + * back afterwards. The look is verified visually on the forest + * example. + */ + const stateAtEachDraw = (mesh) => { + const gl = renderer.gl; + const seen = []; + const original = gl.drawElements; + gl.drawElements = function (...args) { + seen.push({ + blend: gl.isEnabled(gl.BLEND), + depthWrite: gl.getParameter(gl.DEPTH_WRITEMASK), + }); + return original.apply(this, args); + }; + try { + drawOnce(mesh); + } finally { + gl.drawElements = original; + } + return seen; + }; + + it("the object opaque, its shadow blended and depth-write-free", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + + const seen = stateAtEachDraw(mesh); + expect(seen).toHaveLength(2); + // the object itself is unchanged: opaque, writing depth + expect(seen[0]).toEqual({ blend: false, depthWrite: true }); + // the shadow blends, and does NOT write depth — so two overlapping + // shadows at one ground height blend instead of fighting under + // LEQUAL + expect(seen[1]).toEqual({ blend: true, depthWrite: false }); + mesh.destroy(); + }); + + it("REGRESSION: an unshadowed mesh draws under untouched state", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + drawOnce(mesh); + expect(stateAtEachDraw(mesh)).toEqual([ + { blend: false, depthWrite: true }, + ]); + mesh.destroy(); + }); + }); + // ── Sprite3d, which is the whole point of the feature ─────────────── + + describe("Sprite3d", () => { + const makeSprite = (settings = {}) => { + return new Sprite3d(0, 0, { + image: Renderer.getWhitePixel(), + width: 40, + height: 60, + ...settings, + }); + }; + + it("REGRESSION: the setting survives the Sprite3d constructor", (ctx) => { + requireWebGL(ctx, renderer); + // Sprite3d hands `Mesh` a BUILT settings object rather than the + // caller's, so anything it does not name is dropped. That silently + // made every sprite shadowless while a Mesh-only test suite stayed + // green — the one case the feature exists for. + const sprite = makeSprite({ castGroundShadow: true }); + expect(sprite.castGroundShadow).toBe(true); + sprite.destroy(); + }); + + it("forwards the tuning settings too, not just the flag", (ctx) => { + requireWebGL(ctx, renderer); + const sprite = makeSprite({ + castGroundShadow: true, + shadowOpacity: 0.9, + shadowGroundY: 123, + }); + expect(sprite.shadowOpacity).toBe(0.9); + expect(sprite.shadowGroundY).toBe(123); + sprite.destroy(); + }); + + it("REGRESSION: InstancedMesh forwards it too", (ctx) => { + requireWebGL(ctx, renderer); + // It passes `settings` straight through today, where Sprite3d + // whitelists — which is exactly why Sprite3d silently dropped this + // and InstancedMesh did not. Pinned so a future refactor to a + // whitelist has to notice. + const mesh = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: 2, + castGroundShadow: true, + shadowOpacity: 0.7, + }); + expect(mesh.castGroundShadow).toBe(true); + expect(mesh.shadowOpacity).toBe(0.7); + mesh.destroy(); + }); + + it("defaults to unset — meaning 'follow the application setting'", (ctx) => { + requireWebGL(ctx, renderer); + // tri-state, not a boolean: `undefined` has to survive the Sprite3d + // settings whitelist, or a scene-wide default can never reach it + expect(makeSprite().castGroundShadow).toBeUndefined(); + }); + + it("ADVERSARIAL: a billboard's shadow still lands BELOW it", (ctx) => { + requireWebGL(ctx, renderer); + // the real Y-down sign trap: Sprite3d's billboard branch bypasses + // the Y-negating axis bridge entirely and writes pos.y straight + // into the matrix, so a Mesh-only test proves nothing here + const sprite = makeSprite({ castGroundShadow: true, billboard: "face" }); + sprite.pos.set(0, 0, 300); + drawOnce(sprite); + expect(shadowGroundY()).toBeGreaterThan(sprite.pos.y); + sprite.destroy(); + }); + + it("adds exactly one draw call, as for a mesh", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const plain = makeSprite(); + const shadowed = makeSprite({ castGroundShadow: true }); + drawOnce(plain); + drawOnce(shadowed); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(plain); + const plainDraws = spy.mock.calls.length; + drawOnce(shadowed); + expect(spy.mock.calls.length - plainDraws).toBe(plainDraws + 1); + spy.mockRestore(); + plain.destroy(); + shadowed.destroy(); + }); + }); + // ── the instanced tier: one draw for the whole set ────────────────── + + describe("InstancedMesh", () => { + const makeInstanced = (count, settings = {}) => { + const mesh = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: count, + castGroundShadow: true, + ...settings, + }); + const placement = new Matrix3d(); + for (let i = 0; i < count; i++) { + placement.identity().translate(i * 8, 0, 0); + mesh.setInstance(i, placement); + } + return mesh; + }; + + it("costs ONE extra draw, whatever the instance count", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const few = makeInstanced(4); + const many = makeInstanced(400); + drawOnce(few); + drawOnce(many); + + const spy = vi.spyOn(gl, "drawElementsInstanced"); + drawOnce(few); + const fewDraws = spy.mock.calls.length; + drawOnce(many); + const manyDraws = spy.mock.calls.length - fewDraws; + // two apiece — the trees, then every shadow in one call. 400 + // instances cost exactly what 4 do. + expect(fewDraws).toBe(2); + expect(manyDraws).toBe(2); + // ...and the shadow pass covers the whole set + expect(spy.mock.calls.at(-1)[4]).toBe(400); + expect(gl.getError()).toBe(gl.NO_ERROR); + spy.mockRestore(); + few.destroy(); + many.destroy(); + }); + + it("REGRESSION: no shadow flag means no extra draw", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(16, { castGroundShadow: false }); + drawOnce(mesh); + + const spy = vi.spyOn(gl, "drawElementsInstanced"); + drawOnce(mesh); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + mesh.destroy(); + }); + + it("ADVERSARIAL: per-instance colour and data do NOT tint the shadows", (ctx) => { + requireWebGL(ctx, renderer); + // The hazard the standalone shader exists to avoid: the instanced + // mesh families multiply aInstanceColor into the tint and add + // aInstanceData as emissive, so a variant-derived shadow would give + // a forest coloured, glowing blobs. This shader declares neither. + const mesh = makeInstanced(8, { + instanceColors: true, + instanceData: true, + }); + for (let i = 0; i < 8; i++) { + mesh.setInstanceData(i, 1, 0, 0, 1); + } + drawOnce(mesh); + const shader = renderer.currentBatcher.shadowShader; + expect(shader).toBeDefined(); + expect(shader.getAttribLocation("aInstanceColor")).toBe(-1); + expect(shader.getAttribLocation("aInstanceData")).toBe(-1); + expect(renderer.gl.getError()).toBe(renderer.gl.NO_ERROR); + mesh.destroy(); + }); + + it("REGRESSION: a LIT instanced mesh's shadow shader links", (ctx) => { + requireWebGL(ctx, renderer); + // The whole instanced suite used to be unlit, and that gap hid a + // real break: the shadow's vertex stage is GLSL ES 1.00, while the + // LIT tier's fragment stage is `#version 300 es`, so pairing them + // fails to link and takes every lit instanced mesh down with it. + // A link failure surfaces as a thrown init, not a wrong pixel. + const mesh = makeInstanced(4, { lit: true }); + expect(() => { + drawOnce(mesh); + }).not.toThrow(); + const shader = renderer.currentBatcher.shadowShader; + expect(shader).toBeDefined(); + expect(shader.program).not.toBeNull(); + expect(renderer.gl.getError()).toBe(renderer.gl.NO_ERROR); + mesh.destroy(); + }); + + it("REGRESSION: the blob is sized from the PROTOTYPE, not assumed 1 unit", (ctx) => { + requireWebGL(ctx, renderer); + // The instanced vertex stage scales the quad by each record's + // horizontal scale alone, so a unit quad silently assumes the + // prototype is exactly 1 unit across. glTF meshes load unnormalised + // and are not — a 3-unit-wide tree got a blob sized for a 1-unit + // one, and the same asset drew a different shadow instanced than + // standalone. Nothing about that is visible from a draw count. + const wide = makeInstanced(2, { + width: 40, + vertices: new Float32Array([-3, -1, -3, 3, -1, -3, 3, 1, 3, -3, 1, 3]), + }); + drawOnce(wide); + const quad = wide._shadowQuad; + expect(quad).toBeDefined(); + // half-extent 3, times the 1.2 contact spread + const halfX = Math.max( + ...quad.originalVertices.filter((_, i) => { + return i % 3 === 0; + }), + ); + expect(halfX).toBeCloseTo(3 * 1.2, 4); + // and it is NOT the shared unit quad + expect(quad).not.toBe(renderer._shadowQuads?.unlit); + wide.destroy(); + }); + + it("REGRESSION: the shadow draw does not rebuild the MAIN vertex state", (ctx) => { + requireWebGL(ctx, renderer); + // The shadow runs under its own program. Asking the main + // instanced helper for state while that program is bound makes it + // judge the main vertex state stale (it keys on the bound shader), + // tear it down and rebuild it against a program missing every mesh + // attribute — and the next frame's main draw rebuilds it back. + // Identity comparison cannot see it: `build()` reuses the object and + // only swaps the VAO. Counting the GL calls can. + const mesh = makeInstanced(4, { lit: true }); + drawOnce(mesh); + const gl = renderer.gl; + const created = vi.spyOn(gl, "createVertexArray"); + const deleted = vi.spyOn(gl, "deleteVertexArray"); + drawOnce(mesh); + drawOnce(mesh); + // steady state: both vertex states are built and stay built + expect(created).not.toHaveBeenCalled(); + expect(deleted).not.toHaveBeenCalled(); + created.mockRestore(); + deleted.mockRestore(); + mesh.destroy(); + }); + + it("ADVERSARIAL: the shadow vertex state is built once, not per frame", (ctx) => { + requireWebGL(ctx, renderer); + // the two passes use different geometry AND different programs, so + // sharing one state slot would make each rebuild the other's vertex + // array every single draw + const mesh = makeInstanced(8); + drawOnce(mesh); + const state = renderer.currentBatcher.instanced.get(mesh); + const built = state.shadowVertexState; + const main = state.vertexState; + drawOnce(mesh); + drawOnce(mesh); + expect(state.shadowVertexState).toBe(built); + expect(state.vertexState).toBe(main); + mesh.destroy(); + }); + }); + + // ── where the opt-in comes from ───────────────────────────────────── + + describe("opt-in precedence", () => { + // the setting the renderer was built with; restored after each case so + // a leaked `true` cannot quietly satisfy a later test + const withAppSetting = (value, fn) => { + const settings = renderer.settings; + const had = settings.castGroundShadow; + settings.castGroundShadow = value; + try { + fn(); + } finally { + settings.castGroundShadow = had; + } + }; + + const drawsShadow = (mesh) => { + const gl = renderer.gl; + drawOnce(mesh); + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(mesh); + const count = spy.mock.calls.length; + spy.mockRestore(); + return count > 1; + }; + + it("the shipped default is ON", (ctx) => { + requireWebGL(ctx, renderer); + // the application default ships `true` — a 3D object that opts into + // nothing still gets its shadow. 2D games are unaffected whatever + // this says: the shadow rides the retained Camera3d path only. + expect(defaultApplicationSettings.castGroundShadow).toBe(true); + const mesh = makeMesh(); + expect(mesh.castGroundShadow).toBeUndefined(); + withAppSetting(true, () => { + expect(drawsShadow(mesh)).toBe(true); + }); + mesh.destroy(); + }); + + it("an application setting of false opts a whole game out", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + withAppSetting(false, () => { + expect(drawsShadow(mesh)).toBe(false); + }); + mesh.destroy(); + }); + + it("the application setting opts a mesh in", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + withAppSetting(true, () => { + expect(drawsShadow(mesh)).toBe(true); + }); + mesh.destroy(); + }); + + it("a per-mesh false overrides the application setting", (ctx) => { + requireWebGL(ctx, renderer); + // the direction that matters for a ground plane in a scene whose + // application default is on + const mesh = makeMesh({ castGroundShadow: false }); + withAppSetting(true, () => { + expect(drawsShadow(mesh)).toBe(false); + }); + mesh.destroy(); + }); + + it("a per-mesh true survives an application setting of false", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh({ castGroundShadow: true }); + withAppSetting(false, () => { + expect(drawsShadow(mesh)).toBe(true); + }); + mesh.destroy(); + }); + + it("the blanket opt-in SKIPS a flat mesh — that is the ground plane", (ctx) => { + requireWebGL(ctx, renderer); + // a plane lying in the ground plane has no height to cast from, and + // shadowing it with itself smears a blob over the whole floor + const flat = new Mesh(0, 0, { + vertices: new Float32Array([-9, 0, -9, 9, 0, -9, 9, 0, 9, -9, 0, 9]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0]), + width: 32, + normalize: false, + }); + withAppSetting(true, () => { + expect(drawsShadow(flat)).toBe(false); + }); + flat.destroy(); + }); + + it("…but an EXPLICIT opt-in on that same flat mesh is obeyed", (ctx) => { + requireWebGL(ctx, renderer); + // the safeguard is for blanket defaults only — asking for it + // directly is an instruction, e.g. a floating platform + const flat = new Mesh(0, 0, { + vertices: new Float32Array([-9, 0, -9, 9, 0, -9, 9, 0, 9, -9, 0, 9]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0]), + width: 32, + normalize: false, + castGroundShadow: true, + }); + withAppSetting(false, () => { + expect(drawsShadow(flat)).toBe(true); + }); + flat.destroy(); + }); + + it("REGRESSION: Sprite3d does not flatten `undefined` to false", (ctx) => { + requireWebGL(ctx, renderer); + // its settings whitelist used to coerce with `=== true`, which + // would opt every sprite out of an application-wide default while + // a Mesh-only suite stayed green — the same bug class as #1515's + // original dropped setting + const sprite = new Sprite3d(0, 0, { + image: Renderer.getWhitePixel(), + width: 40, + height: 60, + }); + expect(sprite.castGroundShadow).toBeUndefined(); + withAppSetting(true, () => { + expect(drawsShadow(sprite)).toBe(true); + }); + sprite.destroy(); + }); + }); + + // ── lifetime and the deferred queue ───────────────────────────────── + + describe("the deferred queue and what owns it", () => { + it("a reset DISCARDS pending shadows rather than replaying them", (ctx) => { + requireWebGL(ctx, renderer); + // `reset()` re-inits every batcher and then switches batcher, and a + // batcher switch is itself a drain point. Replaying entries from + // the frame being abandoned would paint them after `clear()` — and + // on the context-lost branch would draw geometry belonging to the + // dead context. + const mesh = makeMesh({ castGroundShadow: true }); + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + expect(renderer._shadowCount).toBeGreaterThan(0); + + const spy = vi.spyOn(renderer.gl, "drawElements"); + renderer.reset(); + expect(renderer._shadowCount).toBe(0); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + mesh.destroy(); + }); + + it("leaving mesh mode drains the queue without being asked", (ctx) => { + requireWebGL(ctx, renderer); + // the real drain site — every other test here calls + // flushGroundShadows() by hand, which would keep passing if this + // site were deleted + const mesh = makeMesh({ castGroundShadow: true }); + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + expect(renderer._shadowCount).toBeGreaterThan(0); + + renderer.setBatcher("quad"); + expect(renderer._shadowCount).toBe(0); + mesh.destroy(); + }); + + it("switching WITHIN mesh mode does not drain early", (ctx) => { + requireWebGL(ctx, renderer); + // lit <-> unlit is still inside the pass: the meshes yet to come are + // exactly what the shadows have to be drawn on top of + const mesh = makeMesh({ castGroundShadow: true }); + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + const queued = renderer._shadowCount; + expect(queued).toBeGreaterThan(0); + + renderer.setBatcher("litMesh"); + expect(renderer._shadowCount).toBe(queued); + renderer.setBatcher("quad"); + mesh.destroy(); + }); + + it("REGRESSION: a mask being stencilled in does NOT drain the queue", (ctx) => { + requireWebGL(ctx, renderer); + // `setMask` fills its shape through the primitive batcher with + // colour writes OFF and `stencilOp(…, INCR)` armed. That is a + // batcher transition, so an unguarded drain fires there — which + // both discards every blob (nothing is written) and stamps their + // footprints into the mask being built, so the masked renderable + // then draws outside its own mask. + const mesh = makeMesh({ castGroundShadow: true }); + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + const queued = renderer._shadowCount; + expect(queued).toBeGreaterThan(0); + + renderer.setMask(new Rect(0, 0, 32, 32)); + expect(renderer._shadowCount).toBe(queued); + renderer.clearMask(); + + // and once the mask is done, the queue is still there to be drawn + renderer.flushGroundShadows(); + expect(renderer._shadowCount).toBe(0); + mesh.destroy(); + }); + + it("each queued entry keeps its OWN matrix and tint", (ctx) => { + requireWebGL(ctx, renderer); + // one quad serves every caster, so `quad._modelMatrix` is rewritten + // by the next one before the queue is drained — holding a reference + // instead of a copy collapses every blob onto the last caster + const a = makeMesh({ castGroundShadow: true, shadowOpacity: 0.2 }); + const b = makeMesh({ castGroundShadow: true, shadowOpacity: 0.8 }); + a.pos.set(-40, 0, 100); + b.pos.set(40, 0, 100); + for (const m of [a, b]) { + m.preDraw(renderer); + m.draw(renderer, camera); + m.postDraw(renderer); + } + expect(renderer._shadowCount).toBe(2); + const [first, second] = renderer._shadowPool; + expect(first.matrix).not.toBe(second.matrix); + expect(first.matrix.val[12]).not.toBe(second.matrix.val[12]); + // alpha rides the packed ARGB tint, per entry + expect(first.tint >>> 24).not.toBe(second.tint >>> 24); + renderer.flushGroundShadows(); + a.destroy(); + b.destroy(); + }); + }); + + describe("resource lifetime", () => { + it("releaseShadowQuads drops the quads and their retained geometry", (ctx) => { + requireWebGL(ctx, renderer); + // The quads hang off the renderer and hold retained GPU geometry. + // `releaseShadowQuads` existed but NOTHING called it, so every + // renderer teardown leaked both quads and their buffers; it is now + // wired into `destroy()` on both GPU backends. + // + // Driven through a bare bag rather than the shared test renderer: + // `getShadowQuad` only uses it to cache on, and destroying the + // shared renderer would take every later test with it. + const bag = {}; + const lit = getShadowQuad(bag, true, Mesh); + const unlit = getShadowQuad(bag, false, Mesh); + expect(bag._shadowQuads).toBeDefined(); + expect(lit).not.toBe(unlit); + const destroyed = []; + for (const quad of [lit, unlit]) { + const original = quad.destroy.bind(quad); + quad.destroy = () => { + destroyed.push(quad); + original(); + }; + } + + releaseShadowQuads(bag); + // both tiers destroyed, and the slot cleared so a later frame + // rebuilds rather than handing out a destroyed mesh + expect(destroyed).toHaveLength(2); + expect(bag._shadowQuads).toBeUndefined(); + // idempotent: teardown paths run twice more often than you think + expect(() => { + releaseShadowQuads(bag); + }).not.toThrow(); + }); + + it("both GPU backends release them on teardown", (ctx) => { + requireWebGL(ctx, renderer); + // the call sites themselves — a leak returns the moment either + // `destroy()` stops calling it + for (const proto of [WebGLRenderer, WebGPURenderer]) { + expect(String(proto.prototype.destroy)).toContain("releaseShadowQuads"); + } + }); + }); + + // ── the pixels (Layer 2) ──────────────────────────────────────────── + // + // Every assertion above is about draw calls and GL state, and ALL of them + // passed while the feature rendered nothing at all: the shadow was issued + // correctly and then painted over by the ground, which sorts after the + // props standing on it and is opaque. Only reading the framebuffer catches + // that, so this block does. + + describe("pixels", () => { + const FLOOR_Y = 60; + const FLOOR_RGB = 200; + + // ortho over world x/z in [-100, 100], then a quarter turn about X so + // the XZ ground plane faces the camera instead of sitting edge-on. + // NEGATIVE quarter turn: the engine treats a greater projected z as + // nearer, and render space is Y-DOWN, so "up" — toward a camera above + // the floor — has to map to a greater z. + const setupGroundView = () => { + const projection = new Matrix3d(); + projection.ortho(-100, 100, 100, -100, -1000, 1000); + projection.rotate(-Math.PI / 2, new Vector3d(1, 0, 0)); + renderer.setProjection(projection); + }; + + const readAt = (x, y) => { + const gl = renderer.gl; + const pixel = new Uint8Array(4); + gl.finish(); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return pixel; + }; + + const makeFloor = () => { + const F = 90; + const floor = new Mesh(0, FLOOR_Y, { + vertices: new Float32Array([-F, 0, -F, F, 0, -F, F, 0, F, -F, 0, F]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0]), + scale: 1, + width: 1, + normalize: false, + cullBackFaces: false, + lit: false, + }); + floor.tint.setColor(FLOOR_RGB, FLOOR_RGB, FLOOR_RGB); + return floor; + }; + + // what Mesh.onActivateEvent does under a Camera3d. Without it preDraw + // leaves the anchor offset in `currentTransform` — which IS the mesh + // view matrix — and shifts the whole scene by half the mesh width. + const activate = (mesh) => { + mesh._useWorldSpace = true; + return mesh; + }; + + /** + * Draw a floor and one prop, in the order that broke this feature: the + * prop (and so its shadow) FIRST, the opaque ground plane after. + * @returns {Function} sampler for the resulting framebuffer + */ + const renderScene = (prop) => { + // `clear()`, not just `clearColor()`: the mesh pass's one-shot depth + // clear is armed by RENDER_TARGET_CHANGED, which only the frame-start + // clear emits. Without it this scene renders against depth values + // left behind by the tests above and nothing survives the depth test. + renderer.clear(); + renderer.clearColor("#ffffff"); + setupGroundView(); + const floor = activate(makeFloor()); + // the caster first, the ground after — a large ground plane's single + // sort key says nothing about where it sits relative to what stands + // on it, so this order is ordinary, not contrived + for (const mesh of [prop, floor]) { + renderer.currentTint.copy(mesh.tint); + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + } + renderer.flushGroundShadows(); + renderer.flush(); + renderer.currentTint.setColor(255, 255, 255); + floor.destroy(); + return readAt; + }; + + const makeCaster = (settings) => { + return activate(makeMesh({ width: 40, ...settings })); + }; + + it("darkens the ground beneath the caster, even though the ground draws AFTER it", (ctx) => { + requireWebGL(ctx, renderer); + + const plain = makeCaster({ lit: false }); + const before = renderScene(plain)(64, 64); + plain.destroy(); + + const caster = makeCaster({ + lit: false, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + const after = renderScene(caster)(64, 64); + caster.destroy(); + + expect(before[0]).toBe(FLOOR_RGB); + // this is the whole bug: it read exactly FLOOR_RGB — no shadow at + // all — while every draw-call and GL-state assertion above passed + expect(after[0]).toBeLessThan(FLOOR_RGB - 20); + }); + + it("is soft-edged, not a hard disc — intermediate alpha at the rim", (ctx) => { + requireWebGL(ctx, renderer); + const caster = makeCaster({ + lit: false, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + const at = renderScene(caster); + // sampled along the MAJOR axis (screen x here): the caster is an + // upright quad with no depth, so its blob is a correctly elongated + // ellipse and the minor axis is only a few pixels across + const centre = at(64, 64)[0]; + const rim = at(69, 64)[0]; + const outside = at(100, 64)[0]; + caster.destroy(); + + // a falloff that failed to reach the shader renders the quad flat, + // which is a hard-edged SQUARE — the rim would then read either + // fully shaded or not shaded at all, never between + expect(centre).toBeLessThan(rim); + expect(rim).toBeLessThan(outside); + expect(outside).toBe(FLOOR_RGB); + }); + + it("a LIT caster's shadow lands too (it rides the lit batcher)", (ctx) => { + requireWebGL(ctx, renderer); + const caster = makeCaster({ + lit: true, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + const after = renderScene(caster)(64, 64); + caster.destroy(); + expect(after[0]).toBeLessThan(FLOOR_RGB - 20); + }); + + it("is an ellipse along the caster's footprint, not a disc", (ctx) => { + requireWebGL(ctx, renderer); + // the GEOMETRY quad is upright in XY — it has real width and no + // depth at all, so a disc would read as perpendicular to it + const caster = makeCaster({ + lit: false, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + drawOnce(caster); + const axes = shadowAxes(); + expect(axes.x).toBeGreaterThan(axes.z * 1.5); + caster.destroy(); + }); + + it("REGRESSION: a right-handed caster's ellipse is not MIRRORED", (ctx) => { + requireWebGL(ctx, renderer); + // `_composeModelMatrix` applies the axis bridge as a ROW scale, so + // the world Z component of BOTH basis columns carries the sign. + // Folding it into one column instead mirrors the ellipse about + // world X, and a glTF prop rotated by θ gets a blob at −θ. A 90° + // test cannot see this — mirrored and correct coincide there — so + // this one turns 30°, and compares the two handednesses directly. + const angle = Math.PI / 6; + const axisOf = (rightHanded) => { + const mesh = makeCaster({ + lit: false, + rightHanded, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + mesh.rotate(angle, new Vector3d(0, 1, 0)); + drawOnce(mesh); + const m = renderer._shadowQuads.unlit._modelMatrix.val; + mesh.destroy(); + return { x: m[0], z: m[2] }; + }; + const left = axisOf(false); + const right = axisOf(true); + // the bridge negates Z, so the major axis's Z component must flip + // sign between the two — mirroring makes them agree instead + expect(Math.sign(right.z)).toBe(-Math.sign(left.z)); + expect(Math.abs(right.z)).toBeCloseTo(Math.abs(left.z), 4); + expect(Math.sign(right.x)).toBe(Math.sign(left.x)); + }); + + it("the ellipse turns with the caster", (ctx) => { + requireWebGL(ctx, renderer); + const caster = makeCaster({ + lit: false, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + drawOnce(caster); + const before = renderer._shadowQuads.unlit._modelMatrix.val.slice(); + // a quarter turn about the vertical axis has to swing the blob's + // long axis with it — a disc would be indistinguishable here + caster.rotate(Math.PI / 2, new Vector3d(0, 1, 0)); + drawOnce(caster); + const after = renderer._shadowQuads.unlit._modelMatrix.val; + // the major axis started along world X and must not still be there + expect(Math.abs(after[0])).toBeLessThan(Math.abs(before[0]) * 0.5); + expect(Math.abs(after[2])).toBeGreaterThan(Math.abs(before[2]) + 1); + caster.destroy(); + }); + + it("the INSTANCED tier lands on the ground too", (ctx) => { + requireWebGL(ctx, renderer); + // the instanced blobs ride a standalone shader over the mesh's own + // instance buffer, so nothing above proves they reach the screen — + // and a shader-location clash there is a pipeline error on WebGPU + // that draw-count assertions cannot see + const scatter = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 40, + normalize: false, + instanceCount: 3, + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + const placement = new Matrix3d(); + for (let i = 0; i < 3; i++) { + placement.identity().translate((i - 1) * 30, 0, 0); + scatter.setInstance(i, placement); + } + activate(scatter); + + const after = renderScene(scatter)(64, 64); + scatter.destroy(); + expect(after[0]).toBeLessThan(FLOOR_RGB - 10); + }); + + it("a Sprite3d billboard's shadow lands on the ground", (ctx) => { + requireWebGL(ctx, renderer); + // the tier the feature exists for, and the one no draw-count or + // matrix assertion can vouch for: a billboard builds its model + // matrix from a camera-facing basis, which is a different code path + // from every Mesh above + const sprite = new Sprite3d(0, 0, { + image: Renderer.getWhitePixel(), + width: 40, + height: 60, + billboard: "cylindrical", + anchorPoint: "bottom", + castGroundShadow: true, + shadowGroundY: FLOOR_Y, + }); + activate(sprite); + const after = renderScene(sprite)(64, 64); + sprite.destroy(); + expect(after[0]).toBeLessThan(FLOOR_RGB - 10); + }); + + it("no opt-in leaves the ground untouched", (ctx) => { + requireWebGL(ctx, renderer); + const caster = makeCaster({ lit: false }); + const after = renderScene(caster)(64, 64); + caster.destroy(); + expect(after[0]).toBe(FLOOR_RGB); + }); + }); +}); diff --git a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js index bc94bfa72..847cbc5eb 100644 --- a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js +++ b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js @@ -136,11 +136,16 @@ export function createMockWebGPURenderer() { registeredModules: new Map(), effectLayouts: new Map(), registerVertexLayout() {}, - registerShader(code) { - let key = this.registeredModules.get(code); + registerShader(code, options = {}) { + // keyed by (source, vertex layout) exactly as the real cache is: + // one module may serve several vertex layouts, and keying on the + // source alone hands every later caller the first layout + const alias = options.vertexLayoutKey ?? "quad"; + const cacheKey = `${alias}\u0000${code}`; + let key = this.registeredModules.get(cacheKey); if (typeof key === "undefined") { key = `effect:${this.registeredModules.size}`; - this.registeredModules.set(code, key); + this.registeredModules.set(cacheKey, key); this.modules[key] = { code }; } return key; diff --git a/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js b/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js index e6f8fab26..a89060c70 100644 --- a/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js +++ b/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js @@ -928,7 +928,11 @@ describe("WebGL pipeline adversarial integration", () => { } expect(true).toBe(true); // reached the end of all seeds without throwing - }); + // 60s, matching the sibling fuzz in webgl_vao_adversarial: 1000 random + // ops each issue their own draws, and a mesh op now issues a second one + // for its ground shadow (on by default since #1515), so the default 15s + // budget no longer fits on a loaded machine. + }, 60000); // ---- Per-batcher vertex-state ownership (VAO era) ---- diff --git a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js index 6b0481661..272446380 100644 --- a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js +++ b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js @@ -1,6 +1,7 @@ import "./helpers/webgpu-globals.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { Color, WebGPURenderer } from "../src/index.js"; +import { instanceRecordLayout } from "../src/video/gpu/instancerecord.ts"; import WebGPULitMeshBatcher from "../src/video/webgpu/batchers/lit_mesh_batcher.js"; import WebGPUMeshBatcher, { MESH_UNIFORM_SIZE, @@ -373,3 +374,62 @@ describe("WebGPURenderer.drawMesh (real prototype over a stub)", () => { expect(total).toBe(triCount * 3); }); }); + +/** + * Ground shadows on the instanced tier (#1515) — WebGPU only. + * + * The shadow module reads nothing but the three transform rows, so ONE module + * serves every record shape. The vertex LAYOUT does not: the instance buffer's + * `arrayStride` is baked into it, and that is 48 / 64 / 80 bytes depending on + * which optional slots the records carry. Nothing about a wrong stride is + * visible from a draw count — the blobs simply land at garbage positions. + */ +describe("instanced ground-shadow families (#1515)", () => { + const layoutFor = (hasColor, hasData) => { + return instanceRecordLayout(hasColor, hasData); + }; + + it("mints a distinct family per instance-record shape", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const plain = batcher.instancedShadowFamily(layoutFor(false, false)); + const coloured = batcher.instancedShadowFamily(layoutFor(true, false)); + const both = batcher.instancedShadowFamily(layoutFor(true, true)); + // a single cached family would hand the second scatter the first + // one's stride + expect(new Set([plain, coloured, both]).size).toBe(3); + }); + + it("reuses the family for a repeat of the same shape", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const a = batcher.instancedShadowFamily(layoutFor(true, false)); + const b = batcher.instancedShadowFamily(layoutFor(true, false)); + expect(a).toBe(b); + }); + + it("each family's instance buffer carries ITS OWN stride", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const seen = new Map(); + const spy = vi + .spyOn(renderer.pipelineCache, "registerVertexLayout") + .mockImplementation((key, buffers) => { + seen.set(key, buffers[1].stride); + }); + for (const [c, d] of [ + [false, false], + [true, false], + [true, true], + ]) { + const layout = layoutFor(c, d); + batcher.instancedShadowFamily(layout); + // whichever key this shape registered under must carry its stride + expect([...seen.values()]).toContain(layout.stride); + } + // three shapes, three strides, three layout keys + expect(seen.size).toBe(3); + expect(new Set(seen.values()).size).toBe(3); + spy.mockRestore(); + }); +}); diff --git a/packages/melonjs/tests/webgpu_pipeline.spec.js b/packages/melonjs/tests/webgpu_pipeline.spec.js index 99655cad2..7ad483de3 100644 --- a/packages/melonjs/tests/webgpu_pipeline.spec.js +++ b/packages/melonjs/tests/webgpu_pipeline.spec.js @@ -374,6 +374,24 @@ describe("WebGPU pipeline (device-free units)", () => { expect(cache.modules[keyA]).toBeDefined(); }); + it("…but a same-body module under a DIFFERENT vertex layout is its own family", () => { + const { cache } = makeCache(); + // One module can legitimately serve several vertex layouts — the + // instanced ground shadow (#1515) reads only the transform rows, so + // one body covers every record shape while the instance buffer's + // `arrayStride` still differs per shape. Keying on the body alone + // handed the second caller the FIRST one's layout, i.e. the wrong + // stride, with nothing failing. + const body = "fn shared() {}"; + const a = cache.registerShader(body, { vertexLayoutKey: "rowsA" }); + const b = cache.registerShader(body, { vertexLayoutKey: "rowsB" }); + expect(b).not.toBe(a); + expect(cache.vertexLayoutAliases.get(a)).toBe("rowsA"); + expect(cache.vertexLayoutAliases.get(b)).toBe("rowsB"); + // the compiled module itself is still shared — one body, one compile + expect(cache.modules[a]).toBe(cache.modules[b]); + }); + it("registered families ride an aliased vertex layout through get()", () => { const { cache } = makeCache(); const key = cache.registerShader("fn c() {}", { From 61c6039edf25486a635057075034d598002aecdf Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 8 Aug 2026 16:38:01 +0800 Subject: [PATCH 2/3] Narrow world children with a type guard, and test the glTF tri-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `world.children` is typed as the base `Renderable`, so `castGroundShadow` and `getBounds3d()` — which live on `Mesh` — are not visible on it. The diorama example silenced that with `as any` instead of proving it, which switched off checking for every member access in the loop that assigns `shadowGroundY`: exactly where the compiler is worth having. `filter((c): c is Mesh => c instanceof Mesh)` narrows properly. It also removes a latent bug: the old predicate was `castGroundShadow !== undefined`, which only selected anything because this scene passes `castGroundShadow: true` to `level.load`. The flag is deliberately TRI-STATE — `undefined` means "follow the application setting" — so on a scene that did not pass the option, that filter would have matched nothing and the whole ground-resolution loop would have silently done nothing. That contract had no test, so add four against the animated glTF path: an omitted option must leave the flag `undefined` (or an application-wide default can never reach a glTF scene), `true` and `false` must both forward, `shadowGroundY` must reach the part meshes, and a scene-wide opt-in must still skip a part with no vertical extent. Verified the first fails if the loader flattens `undefined` to `false`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../src/examples/gltf/ExampleGltf.tsx | 9 ++- packages/melonjs/tests/gltf_model.spec.js | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/examples/src/examples/gltf/ExampleGltf.tsx b/packages/examples/src/examples/gltf/ExampleGltf.tsx index dd6054ed2..cf1375069 100644 --- a/packages/examples/src/examples/gltf/ExampleGltf.tsx +++ b/packages/examples/src/examples/gltf/ExampleGltf.tsx @@ -14,6 +14,7 @@ import { input, level, loader, + Mesh, type Pointer, plugin, Renderable, @@ -120,9 +121,13 @@ const createGame = async () => { // y: the right surface is the smallest platform top still greater than // the prop's base. Setting it is also what switches on the shrink-and- // fade with height, which is what makes a floating coin read as floating. + // `instanceof`, not a cast: `world.children` is typed as the BASE class, + // so `castGroundShadow` / `getBounds3d()` — which live on `Mesh` — are + // not visible on it. Narrowing proves they are there instead of + // silencing the compiler, which keeps every member access below checked. const meshes = app.world.children.filter( - (c) => (c as any).castGroundShadow !== undefined, - ) as any[]; + (c): c is Mesh => c instanceof Mesh, + ); const platformTops = meshes .filter((m) => /^block/.test(m.name ?? "")) .map((m) => m.getBounds3d().top); diff --git a/packages/melonjs/tests/gltf_model.spec.js b/packages/melonjs/tests/gltf_model.spec.js index eef910dbc..a03ea8775 100644 --- a/packages/melonjs/tests/gltf_model.spec.js +++ b/packages/melonjs/tests/gltf_model.spec.js @@ -317,3 +317,70 @@ describe("GLTFModel", () => { expect(v[5]).toBeCloseTo(-1, 4); }); }); + +/** + * Ground shadows through the animated glTF path (#1515). + * + * The opt-in is TRI-STATE — `undefined` means "follow the application + * setting" — so what the loader must NOT do is flatten it. Forwarding an + * omitted option as an explicit `false` would silently opt every animated + * glTF scene out of an application-wide default, and nothing about that is + * visible from a draw count or a screenshot. + */ +describe("GLTFModel ground shadows (#1515)", () => { + const partsOf = (options) => { + const model = new GLTFModel(makeData(), { + scale: 1, + rightHanded: false, + ...options, + }); + return model.children; + }; + + // a prim lying flat in the ground plane — every y identical + const FLAT = () => { + return { + ...PRIM(), + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 0, 1]), + }; + }; + + it("leaves the flag UNSET when the caller says nothing", () => { + // the application setting must still be able to reach these meshes + for (const part of partsOf({})) { + expect(part.castGroundShadow).toBeUndefined(); + } + }); + + it("opts the scene in, and forwards the ground height", () => { + const parts = partsOf({ castGroundShadow: true, shadowGroundY: 7 }); + expect(parts.length).toBeGreaterThan(0); + for (const part of parts) { + // PRIM() spans y 0..1, so it has height to cast from + expect(part.castGroundShadow).toBe(true); + expect(part.shadowGroundY).toBe(7); + } + }); + + it("opts the scene OUT explicitly, overriding an application default", () => { + for (const part of partsOf({ castGroundShadow: false })) { + expect(part.castGroundShadow).toBe(false); + } + }); + + it("a scene-wide opt-in SKIPS a part with no vertical extent", () => { + // a flat plane lying in the floor IS the floor; shadowing it with + // itself smears a blob across the whole ground + const data = makeData(); + data.graph.nodes[1].primitives = [FLAT()]; + const model = new GLTFModel(data, { + scale: 1, + rightHanded: false, + castGroundShadow: true, + }); + expect(model.children.length).toBeGreaterThan(0); + for (const part of model.children) { + expect(part.castGroundShadow).toBe(false); + } + }); +}); From 888666b37b21e2aa7e90c7b512bcf15c7c0c0534 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 8 Aug 2026 17:50:46 +0800 Subject: [PATCH 3/3] Don't make every WebGL spec pay for ground shadows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground shadows (#1515) ship ON, so every 3D mesh draw now issues a second one. That is free on a discrete GPU and is not on CI's SwiftShader software renderer: `renderTargetPool` and `texturecache-batcher-reset` both ran past their timeout there — 190s for a suite whose tests all passed — while the local suite stayed green throughout. The suites that are not testing shadows should not be paying for them, so the shared WebGL test renderer pins the setting off. `ground_shadow.spec` opts itself in per test and asserts the shipped default separately against `defaultApplicationSettings`, so coverage of the real default is unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/tests/helpers/webgl-context.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/melonjs/tests/helpers/webgl-context.js b/packages/melonjs/tests/helpers/webgl-context.js index ced05e29d..3b67d5181 100644 --- a/packages/melonjs/tests/helpers/webgl-context.js +++ b/packages/melonjs/tests/helpers/webgl-context.js @@ -100,6 +100,13 @@ export async function getWebGLRenderer(width = 128, height = 128) { // caveat" flag; without opting out the renderer silently falls // back to Canvas and every WebGL spec skips failIfMajorPerformanceCaveat: false, + // Ground shadows (#1515) ship ON, which means a second draw for + // every 3D mesh. The suites that are not testing them should not + // pay for them: on CI's software renderer that extra GPU work is + // enough to push unrelated suites past their timeout. The shadow + // spec opts itself in per test, and asserts the shipped default + // separately against `defaultApplicationSettings`. + castGroundShadow: false, }); await app.init(); } catch {