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..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, @@ -112,6 +113,45 @@ 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. + // `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 is Mesh => c instanceof Mesh, + ); + 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 +323,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/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); + } + }); +}); 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/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 { 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() {}", {