diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx index 83fdfe34b..5f3df6038 100644 --- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx +++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx @@ -70,6 +70,21 @@ const createGame = async () => { renderer: video.AUTO, scale: "auto", cameraClass: Camera3d, + // This scene has no ground GEOMETRY to receive a blob: the + // "ground" is a screen-space stroked line grid (see + // `backdrop/GroundGrid.ts`), drawn in pixel coords at a computed + // horizon. A world-space ground shadow therefore has nothing + // correct to land on and drifts against the grid as the camera + // pitches, so this example opts out of the application default + // (which ships `true`). + castGroundShadow: false, + // 4x MSAA. The jet, enemies and terrain props are low-poly models + // with long straight edges, which alias badly as they recede — and + // this scene composites through post effects, so it relies on + // capture targets being multisampled too (#1556); without that the + // scene would rasterize into a single-sampled capture and the + // smoothing would be thrown away before it reached the canvas. + antiAlias: true, }); await app.init(); if (!app.renderer.supportsDepthBuffer) { diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 1fe90edb4..14742384a 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,75 +3,74 @@ ## [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 -- **Point and spot 3D lights** ([#1536](https://github.com/melonjs/melonJS/issues/1536)) — `Light3d` gains `"point"` and `"spot"` types alongside `"directional"` and `"ambient"`, on both GPU backends: a point light illuminates from a world `position` with quadratic falloff over `range` (the `Light2d` falloff model — tuned for pixel-unit worlds, deliberately not physical inverse-square), a spot adds an `innerConeAngle`/`outerConeAngle` cone with a smooth edge. All fields stay mutable at runtime (flicker, day/night, a swinging lamp). Loading a glTF scene now instantiates its authored `KHR_lights_punctual` point and spot lamps too — position, range and cone angles scene-scaled like the geometry — and every instantiated light carries its authored name, so `world.getChildByName("Sun")` finds it for runtime tuning. Authored physical intensities (lux/candela) are normalized to 1 by default as before; the new `level.load(name, { lightIntensityScale })` option multiplies them by a chosen factor instead, so relative light strengths from the authoring tool survive (e.g. `0.001` maps a 1000-lux sun to 1 and a half-strength fill to 0.5). The light uniform block grows from 8 to 12 floats per light (1568 bytes for the full 32-light rig) — custom shaders reading `Light3dBlock` need the new layout; `Light2d` / the 2D block are untouched -- **Mesh textures now sample generated mipmaps on both GPU backends** — mesh-path textures (OBJ/MTL, glTF, raw-geometry `Mesh`) get a full mip chain, trilinear minification and 4× anisotropic filtering, so distant and grazing-angle geometry stops shimmering: WebGL upgrades the min filter to `LINEAR_MIPMAP_LINEAR` over the chain it always generated (anisotropy via `EXT_texture_filter_anisotropic` where available), WebGPU builds the chain with blit passes at upload. Compressed assets (DDS/KTX/PVR/PKM) shipping an authored multi-level chain are trilinear-sampleable on both backends too — the chain they carry is used as-is, capped at what the asset provides. 2D rendering is untouched — sprite samplers are clamped to the base level, so a sprite sharing a mesh's image renders byte-identically — and `textureFilter: "nearest"` opts a mesh out (crisp pixel-art models keep hard minification) -- **Up to 32 lights, and light data in a uniform buffer** ([#1552](https://github.com/melonjs/melonJS/issues/1552)) — `MAX_LIGHTS` rises from 8 to **32**, for both the lit sprite path (`Light2d` + normal maps) and the lit mesh path (`Light3d`). The old cap was a compatibility limit, not a design choice: light data travelled in GLSL uniform arrays, which are charged against `MAX_FRAGMENT_UNIFORM_VECTORS` — a small driver-reported budget shared with every other uniform a shader declares, and one that a `vec3` consumes a full slot of. It now travels in a `std140` uniform buffer, charged against `MAX_UNIFORM_BLOCK_SIZE` instead (at least 16 KB everywhere, typically 64 KB); 32 lights occupy 1056 bytes there. A static light rig still costs **zero** GL calls per frame, as before. Note this raises the *capacity*, not the shading cost: the fragment loop still runs once per pixel per live light, so unused slots are free but filling them is not. The four lit shaders move to GLSL ES 3.00 as a consequence — uniform blocks do not exist in ES 1.00. **User shaders are unaffected**: `ShaderEffect` bodies and raw `GLShader` sources stay GLSL ES 1.00 -- **Backend-neutral vertex formats and draw topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)) — a vertex attribute can now be declared with a single `format` token (`"float32x3"`, `"unorm8x4"`) instead of a `size` + `type` + `normalized` triple, and a draw mode with a topology name (`"triangle-list"`, `"line-list"`). `Batcher.addAttribute` accepts three forms — a descriptor object, `(name, format, offset)`, and the existing `(name, size, glType, normalized, offset)` — and `Batcher.mode` accepts either vocabulary while still reading back as the GL enum. `Batcher.topology` is the new portable spelling. **The GL-enum form is supported indefinitely**, so custom batchers need no changes. Groundwork for [#1184](https://github.com/melonjs/melonJS/issues/1184): a format-declared layout needs no live rendering context, and describes itself to any backend. `VertexFormat` / `Topology` types and the `isVertexFormat` / `isTopology` / `resolveVertexFormat` / `PORTABLE_TOPOLOGIES` helpers are exported -- **A `"none"` blend mode on both GPU backends** — `setBlendMode("none")` disables blending outright (the source replaces the destination, alpha included). It was born as a WebGPU pipeline blend state; the WebGL renderer now honors it identically instead of silently falling back to `"normal"`. The related `setBlendEnabled`, `enableScissor` and `clearRenderTarget` renderer methods — WebGL-only before — are implemented on the WebGPU renderer as well, along with custom batcher overrides (`settings.batcher`/`settings.compositor`), the `settings.blendMode` startup value, `GPUVendor` (from the adapter info), and `failIfMajorPerformanceCaveat` (rejects a software fallback adapter, falling through to WebGL under AUTO) -- **Gradient and Text textures stopped power-of-two rounding** ([#1554](https://github.com/melonjs/melonJS/issues/1554)) — two allocation-stability schemes replace it. Gradients now rasterize into a **fixed 256×256 shared bake target** regardless of on-screen size and are stretched by the destination quad (visually equivalent: linear stop interpolation × linear texture filtering — verified pixel-identical on all three backends): the shared canvas is allocated once and never resized, every re-bake is a same-size texture update, and gradient memory is capped at 256 KB instead of growing with the largest gradient drawn. Text canvases now round to **32-pixel buckets** (grow-only, as before) instead of the next power of two: a ticking counter still re-bakes into identical dimensions (the cheap same-size upload path on every backend), while worst-case memory waste drops from up to 2× per axis to at most 31 px per axis -- **OBJ models carry vertex normals, so they can be lit** ([#1572](https://github.com/melonjs/melonJS/issues/1572)) — the OBJ parser read `vn` and discarded it, so `lit: true` on an OBJ shaded against a fallback while the same model imported from glTF lit correctly. Authored normals (`v//vn` and `v/vt/vn`) now reach the mesh, and a vertex shared between *different* normals is split so hard edges stay hard. A file supplying no normals gets them **generated** from face geometry — area-weighted, accumulated and normalized, i.e. smooth — computed after the parser's winding correction so they follow the final triangle orientation rather than the authored one. Normals are stored raw: the Y/Z axis bridge is applied at draw through the model matrix, exactly as it is for glTF. Smoothing groups (`s`) are still ignored, so a model relying on them for hard edges reads softer than authored; supply `vn` to control that precisely -- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)) — the MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright. Two of the remaining ones now land, both on foundations that already existed. **Specular** (`Ks` + `Ns`) gives the lit mesh path a Blinn-Phong highlight where it was previously half-Lambert diffuse plus an ambient floor — every material read as chalk. It is exposed as `mesh.specular` / `mesh.shininess` and gated on the **exponent**, not the colour: `Ns` of 0 is the format's "no highlight", and exporters routinely write a bright `Ks` beside it, so a material declaring one without the other stays matte and pays nothing. The highlight is masked by the *unwrapped* Lambert term — half-Lambert deliberately lifts the shadowed side, and a highlight on a surface facing away from the light reads as a rendering error. **`map_d`** drives `alphaCutoff` per *texel* rather than per material, which is what foliage, fences and decals actually need; it rides `mesh.alphaMap`, is fetched automatically by the MTL loader alongside `map_Kd`, and multiplies alpha **before** the cutout so the threshold sees the map's value. Both backends run the identical expression — the map is sampled unconditionally and weighted rather than branched around, which also keeps the WGSL sample in uniform control flow. A material with neither renders byte-identically to before. WebGPU note for custom mesh shaders: the mesh family's group 1 grows from two bindings to four (diffuse pair + opacity pair) and `MeshUniforms` from 176 to 208 bytes (`specular` = rgb + exponent, `eye` = camera world position); a custom WGSL module declaring only the diffuse pair is unaffected, since a module may declare a subset of its layout. Both are shown in the reworked **Per-material Textures** example — a chrome ball for the highlight, a perforated panel for the cutout. MTL's **`Pr` / `Pm`** roughness-metalness extension (which Blender's OBJ exporter writes by default) and glTF's `pbrMetallicRoughness` factors are both approximated onto the same terms through one shared mapping — roughness → exponent, metalness → tint between the dielectric baseline and the base colour — so a material described in either format shades identically. An explicitly authored `Ks`/`Ns` always wins over the derived approximation, and a fully-rough material (glTF's default) derives nothing, which is what leaves existing scenes untouched. Note this is an approximation onto a stylized half-Lambert model, **not** a PBR shading model; the `map_Pr` / `map_Pm` texture maps are not consumed -- **Per-material diffuse textures on a multi-material model** ([#1573](https://github.com/melonjs/melonJS/issues/1573)) — a multi-material OBJ bound whichever material's `map_Kd` came first for the *whole* model, so a crate with wood sides and a steel lid rendered entirely in wood. Each material's diffuse **colour** (`Kd`) already composed correctly — it is baked into a per-vertex colour buffer at construction — which made the asymmetry the confusing part. The `Mesh` now resolves each material's own texture and reduces the result to the shortest list of index ranges that actually need switching, exposed as `mesh.textureGroups`; both GPU backends draw one indexed range per entry over the same buffers (`drawElements` at a byte offset on WebGL, `drawIndexed` with a `firstIndex` on WebGPU), instanced meshes included. Adjacent materials sharing a map are merged, a material with no `map_Kd` of its own keeps the mesh-level texture, and a model that needs no split — every single-material one, and every `Kd`-only multi-material one — issues **exactly the one draw call it always did**. An explicit `texture:` still pins one binding over the whole model, and a per-material `map_Kd` naming an image that never loaded warns and falls back to the mesh-level texture — the mesh-level one itself still throws when it cannot be resolved, as it always has. The Canvas renderer is unaffected: it solid-fills multi-material meshes per triangle and never samples a texture. See the new **Per-material Textures** example -- **Mesh instancing** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — the new `InstancedMesh` draws one geometry many times in a **single call**, so cost scales with the number of instances rather than with `instances × vertices`. A forest of 100 000 trees is one 52-vertex geometry on the GPU plus a compact per-instance record each, instead of 100 000 copies of identical geometry — see the new **Instanced Forest** example, which renders exactly that at 60 fps on both GPU backends. `InstancedMesh` extends `Mesh`, so every existing setting works unchanged (`model` + `material` from an OBJ, raw geometry, `lit`, `cullBackFaces`, `rightHanded`, `tint`, `textureRepeat`, a custom `shader`); what it adds is the instance buffer. A record always carries a transform — packed as a **3×4 affine** rather than a full `mat4`, since the bottom row of an affine matrix is always `(0,0,0,1)` — plus two **opt-in** slots: `instanceColors` gives each instance a colour multiplied into the mesh tint, and `instanceData` gives it an opaque `vec4` that the built-in shading reads as emissive and a custom mesh shader may read as anything at all (a wind phase, an atlas offset, a random seed). Nobody pays for a slot they did not declare: the shader variants are compiled per declared combination, on first use. Placement is uniform-driven exactly as it is for a retained mesh, so **moving the whole group re-uploads nothing** and moving one instance re-uploads only that record; `visibleInstanceCount` draws the first N without touching the buffer at all, which is a distance-LOD knob costing one integer. `getBounds3d()` covers every instance so the group frustum-culls as one object. Requires a GPU backend (`renderer.supportsInstancing`, the new capability flag); the Canvas renderer falls back to drawing each instance individually — correct, and as slow as the scene it replaces -- **glTF `EXT_mesh_gpu_instancing`** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — authored instancing loads with no user code. A glTF node may carry per-instance `TRANSLATION` / `ROTATION` / `SCALE` accessors instead of being duplicated N times, which is what exporters write for linked duplicates; `level.load()` now turns such a node into an `InstancedMesh` while ordinary nodes stay ordinary meshes. `ROTATION` is accepted as float or as normalized `BYTE`/`SHORT` (the encoding exporters use to shrink large scatters), and any of the three attributes may be absent, taking its glTF default -- **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — signal that a mesh's geometry was edited in place (`originalVertices`, `uvs`, `indices`, normals or per-vertex colours), so the GPU copy is refreshed on the next draw. Moving, rotating, scaling, re-tinting or fading a mesh needs no signal — those are applied when drawing, not stored in the geometry -- **`antiAlias: true` now survives post effects, on both GPU backends** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — adding any post effect (a camera vignette, a chained blur, a mask around an effect) used to silently switch MSAA off: the scene rasterized into a single-sampled offscreen capture target, and the antialiased default framebuffer only ever received already-aliased pixels. Post-effect **capture** targets are now multisampled themselves — up to 4× (`min(4, MAX_SAMPLES)`) color + depth-stencil renderbuffers resolved through `blitFramebuffer` on WebGL, a per-target 4× texture resolved by the render pass on WebGPU — so rotated sprites, shape edges and 3D geometry keep their smoothed edges under an effect chain. Ping-pong intermediates deliberately stay single-sampled: effect blits are screen-aligned quads with no geometric edges to antialias. Verified pixel-equivalent to the no-effect MSAA output on both backends (an edge probe finds the identical intermediate-coverage signature — 3 distinct levels, the 4× quantization — with and without an active effect). Frame captures keep working mid-bracket: `toFrameTexture()` resolves the multisampled target before copying (reading from a multisampled framebuffer is a GL error), then restores it +- **WebGPU renderer** ([#1184](https://github.com/melonjs/melonJS/issues/1184)) — the default on capable browsers: `video.AUTO` negotiates **WebGPU → WebGL 2 → Canvas**. Covers the full engine feature set — 2D (sprites, text, particles, shapes, patterns, masks, blend modes, GPU tilemaps, multi-texture batching), lighting, frame captures, compressed textures, and the 3D tier (meshes, glTF scenes and models, `Sprite3d`, `Camera3d`, instancing). `antiAlias: true` maps to 4× MSAA. Requestable explicitly as `renderer: video.WEBGPU`, or per-run via the `#webgpu` / `#webgl` / `#canvas` URI fragments. See the reworked **Hello WebGPU** example +- **Shader effects on WebGPU, with dual-language bodies** — `ShaderEffect` accepts one body per shading language: `new ShaderEffect(renderer, { glsl, wgsl })`. Uniform names are shared, so one `setUniform` serves both; with no matching body the effect warns once and stays disabled while the scene keeps rendering. All 18 built-in effects render identically on both backends. Shader assets take the same shape: `{ type: "shader", src: { glsl, wgsl } }`. Existing GLSL-only effects are untouched — the generated GLSL is byte-identical to 19.x +- **Custom mesh shaders on both GPU backends, and `GLShader` is dual-language** — `new GLShader(renderer.gl, { vertex, fragment, wgsl })` carries a GLSL pair and/or a WGSL module, with `isWebGL` / `isWebGPU` reporting which exist. Each renderer hosts the realization it speaks, so one `mesh.shader` assignment serves both. A shader with no realization for the active backend is inert (built-in shading, one warning) rather than fatal +- **3D collision: the `Box3d` body shape** ([#1476](https://github.com/melonjs/melonJS/issues/1476)) — a body can now be pushed back along **Z**, which no shape could express before: every other shape is planar and the 2D SAT narrowphase resolves in the screen plane only. Adds an AABB-vs-AABB narrowphase, `ResponseObject.overlapZ` / `overlapNZ`, and `velZ` / `forceZ` / `frictionZ` / `maxVelZ` on `Body`. `raycast3d` gains an exact ray-vs-AABB test for `Box3d` bodies, replacing a bounding-sphere approximation that ignored depth. **The 2D path is unchanged**: Z arrives as scalars beside `overlapV` / `overlapN` / `body.vel` rather than widening them (`Vector3d` does not extend `Vector2d`), and planar pairs leave every Z field at `0`. Planar shapes mixed with a `Box3d` are treated as unbounded along Z, so adding one to a 2D game changes none of its existing collisions +- **Ground shadows for 3D objects** ([#1515](https://github.com/melonjs/melonJS/issues/1515)) — `castGroundShadow: true` gives a `Mesh`, `Sprite3d` or whole `InstancedMesh` scatter a soft contact shadow, without which 2.5D characters read as floating. Deliberately a **blob**, not a shadow map: an ellipse sized and turned by the caster's own footprint, which shrinks and fades with height once `shadowGroundY` is set — that is what reads as a jump. Configurable at three levels, most specific first: per object, per glTF scene (`level.load(name, { castGroundShadow, shadowGroundY })`), and application-wide (**on by default**). Costs one extra draw per shadowed object — or one for an entire instanced scatter, whatever the instance count. GPU backends only. Shown in the **Per-material Textures**, **Billboard Sprites** and **Instanced Forest** examples +- **Mesh instancing** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — `InstancedMesh` draws one geometry many times in a **single call**, so cost scales with instance count rather than `instances × vertices`. 100 000 trees at 60 fps on both GPU backends; see the new **Instanced Forest** example. Extends `Mesh`, so every existing setting works; adds an instance buffer carrying a packed 3×4 affine transform plus two opt-in slots (`instanceColors`, and an `instanceData` `vec4` the built-in shading reads as emissive and a custom shader may read as anything). Moving the whole group re-uploads nothing, `visibleInstanceCount` is a one-integer LOD knob, and `getBounds3d()` covers every instance so the group culls as one object. The Canvas renderer falls back to drawing each instance individually +- **glTF `EXT_mesh_gpu_instancing`** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — authored instancing loads with no user code: a node carrying per-instance TRS accessors becomes an `InstancedMesh`. `ROTATION` is accepted as float or normalized `BYTE`/`SHORT` +- **Point and spot 3D lights** ([#1536](https://github.com/melonjs/melonJS/issues/1536)) — `Light3d` gains `"point"` and `"spot"` alongside `"directional"` and `"ambient"`, on both GPU backends, all fields mutable at runtime. glTF scenes now instantiate their authored `KHR_lights_punctual` lamps, each carrying its authored name. `level.load(name, { lightIntensityScale })` preserves relative light strengths from the authoring tool. **The light uniform block grows from 8 to 12 floats per light** — custom shaders reading `Light3dBlock` need the new layout +- **Up to 32 lights, via a uniform buffer** ([#1552](https://github.com/melonjs/melonJS/issues/1552)) — `MAX_LIGHTS` rises from 8 to **32** for both lit sprites and lit meshes. The old cap was a compatibility limit: light data travelled in GLSL uniform arrays charged against a small driver budget, and now travels in a `std140` uniform buffer charged against `MAX_UNIFORM_BLOCK_SIZE` (≥16 KB everywhere). This raises capacity, not shading cost — the fragment loop still runs once per live light. The four lit shaders move to GLSL ES 3.00 as a consequence; **user shaders are unaffected** +- **Mesh textures sample generated mipmaps on both GPU backends** — mesh-path textures get a full mip chain, trilinear minification and 4× anisotropy, so distant and grazing geometry stops shimmering. 2D rendering is untouched (sprite samplers are clamped to the base level), and `textureFilter: "nearest"` opts a mesh out for crisp pixel-art models +- **OBJ models carry vertex normals, so they can be lit** ([#1572](https://github.com/melonjs/melonJS/issues/1572)) — the parser read `vn` and discarded it, so `lit: true` on an OBJ shaded against a fallback. Authored normals now reach the mesh, with vertices split where normals differ so hard edges stay hard; a file supplying none gets smooth normals generated from face geometry. Smoothing groups (`s`) are still ignored — supply `vn` for precise control +- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)) — `Ks` + `Ns` give the lit mesh path a Blinn-Phong highlight (previously every material read as chalk), exposed as `mesh.specular` / `mesh.shininess` and gated on the exponent, so a material declaring only a colour stays matte. `map_d` drives `alphaCutoff` per *texel* via `mesh.alphaMap` — what foliage, fences and decals need. MTL's `Pr`/`Pm` and glTF's `pbrMetallicRoughness` map onto the same terms, so a material described in either format shades identically; an authored `Ks`/`Ns` always wins. This is an approximation onto a stylized model, **not** PBR. WebGPU note: the mesh family's group 1 grows to four bindings and `MeshUniforms` to 208 bytes +- **Per-material diffuse textures on multi-material models** ([#1573](https://github.com/melonjs/melonJS/issues/1573)) — a multi-material OBJ bound the first material's `map_Kd` for the whole model, so a crate with wood sides and a steel lid rendered entirely in wood. Each material's texture now resolves into the shortest list of index ranges that need switching (`mesh.textureGroups`). A model needing no split issues **exactly the one draw call it always did** +- **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — signal that geometry was edited in place so the GPU copy refreshes on the next draw. Moving, rotating, scaling or re-tinting needs no signal +- **Backend-neutral vertex formats and topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)) — attributes can be declared with a single `format` token (`"float32x3"`) and draw modes with a topology name (`"triangle-list"`). **The GL-enum form is supported indefinitely**, so custom batchers need no changes +- **A `"none"` blend mode on both GPU backends** — `setBlendMode("none")` disables blending outright. `setBlendEnabled`, `enableScissor` and `clearRenderTarget` are now implemented on the WebGPU renderer too, along with custom batcher settings, `GPUVendor` and `failIfMajorPerformanceCaveat` +- **`antiAlias: true` survives post effects** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — adding any post effect used to silently switch MSAA off, because the scene rasterized into a single-sampled capture target. Capture targets are now multisampled themselves (up to 4×), so edges stay smooth under an effect chain. Ping-pong intermediates stay single-sampled — screen-aligned quads have no edges to antialias +- **Gradient and Text textures stopped power-of-two rounding** ([#1554](https://github.com/melonjs/melonJS/issues/1554)) — gradients rasterize into a fixed 256×256 shared target and are stretched by the destination quad (verified pixel-identical), capping gradient memory at 256 KB. Text canvases round to 32-pixel buckets instead of the next power of two, cutting worst-case waste from 2× per axis to 31 px ### Changed (breaking) -- **`video.AUTO` (the default) now prefers WebGPU: the ladder is WebGPU → WebGL 2 → Canvas** — on a WebGPU-capable browser an existing game using `AUTO` starts on the WebGPU backend, which renders the entire engine feature set identically to WebGL (all 44 examples verified side-by-side). `await app.init()` still always resolves under AUTO — the WebGPU adapter/device negotiation is awaited first and falls through to the synchronous WebGL/Canvas candidates when it rejects. Pin `renderer: video.WEBGL` (or the `#webgl` URI fragment) to keep a game on WebGL -- **`Batcher` is now the backend-neutral base class, and the WebGL base batcher is renamed `WebGLBatcher`** — the shared `Batcher` base defines the lifecycle contract every batcher honors (`init` / `bind` / `unbind` / `flush` / `reset` / `destroy`), with `WebGLBatcher` realizing it on GL state and the newly exported `WebGPUBatcher` (plus `WebGPUQuadBatcher` / `WebGPUPrimitiveBatcher`) on WebGPU render passes. Custom WebGL batchers change one word — extend `WebGLBatcher` instead of `Batcher`; constructor signature, settings and every method are unchanged. `renderer.addBatcher()` now rejects up front any batcher that does not extend that backend's base class (`WebGLBatcher` / `WebGPUBatcher`), instead of failing mid-draw later -- **Starting a game is now two steps: construct the `Application`, then `await app.init()`** — and calling `init()` is **mandatory**, not optional. `init()` is asynchronous because a WebGPU device cannot be acquired synchronously; it still resolves without suspending on the Canvas and WebGL backends. Renderer failures surface as a rejection of `init()` (e.g. `renderer: video.WEBGL` on a device without WebGL 2), no longer as a constructor throw. The pre-created bootstrap application and the `legacy` setting are removed with it; the exported `game` now names the most recently **initialized** `Application` — it never points at a half-built app, and it is `undefined` until the first `init()` resolves: +- **Starting a game is now two steps: construct the `Application`, then `await app.init()`** — and `init()` is **mandatory**. It is asynchronous because a WebGPU device cannot be acquired synchronously. Renderer failures now surface as a rejection rather than a constructor throw: ```js // before // after video.init(640, 480, { parent: "screen" }); const app = new Application(640, 480, { parent: "screen" }); await app.init(); ``` - **This applies just as much to code already using `new Application(...)` on 19.x**: constructing the instance no longer builds the renderer or appends the canvas — add `await app.init()` right after construction, or the application displays nothing. Two lifecycle notes that come with the split: `app.destroy()` is now **terminal** — a destroyed Application cannot be re-initialized (`init()` rejects; construct a new instance instead), and a repeated `init()` on a live app is a warned no-op. Custom renderer classes gained an argument-less async `init()` hook that `Application#init` awaits after construction — classes extending `Renderer` need no changes; a custom renderer that already had its own `init()` method should expect the engine to call it with no arguments -- **`video.init()`, `video.renderer`, `video.createCanvas()` and `video.getParent()` are removed** — deprecated since 18.3.0, 18.3.0, 19.7.0 and 18.3.0 respectively. Use the `Application` entry point (see the two-phase construction entry above). The failure mode differs — `video.init()` returned `false` when the canvas could not be created, `app.init()` rejects, so `catch` the rejection if you relied on the boolean. `video.renderer` becomes `app.renderer` (or `game.renderer` where only the global is in reach), `video.createCanvas()` becomes `app.renderer.createCanvas()` (added as an instance method, since `Application#renderer` is the supported entry point), and `video.getParent()` becomes `app.getParentElement()` -- **renderer capability flags replace backend type checks** — `Renderer.shaderLanguage` reports `"glsl"` on the WebGL backend and `null` on Canvas (no programmable pipeline); `Renderer.supportsDepthBuffer` reports whether depth-sorted 3D is available. Code testing `typeof renderer.gl !== "undefined"` or `renderer instanceof WebGLRenderer` should read these instead — those tests answer "no" for any future backend that is in fact capable, which would silently disable `ShaderEffect` and misfire the `Camera3d` warning -- **The WebGL renderer is now WebGL 2 only** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — the WebGL 1 fallback path is removed. `renderer: video.AUTO` falls back to the Canvas renderer on WebGL-1-only devices; `renderer: video.WEBGL` throws there. **User shaders need no changes** — `ShaderEffect` bodies and raw `GLShader` sources (GLSL ES 1.00) compile unchanged on WebGL 2 contexts. -- `preferWebGL1` setting and the `#webgl1` URI flag are removed (`#webgl` / `#webgl2` are synonyms) -- `device.isWebGLSupported()` now probes for a WebGL **2** context — it finally agrees with what renderer construction actually requests (the gate probed WebGL 1 before, so the two could disagree) -- `renderer.type` is always `"WebGL2"` for the WebGL renderer; `renderer.WebGLVersion` is deprecated (always `2`) -- behavior corrections on ex-WebGL-1 configs: `repeat` wrap now genuinely tiles non-power-of-two textures (was clamp + warning), `"darken"` / `"lighten"` blend modes use true MIN/MAX equations (were silently downgraded to `"normal"`), and `createPattern()` accepts non-power-of-two sources (threw before) -- TMX GPU tilemap eligibility is now advertised through `renderer.supportsShaderTileLayers` (a backend capability flag) instead of a WebGL-version check -- the WebGL renderer is now selected only on devices providing a WebGL 2 context **and** passing the `failIfMajorPerformanceCaveat` check (which melonJS leaves enabled by default, unlike the WebGL default of `false`) — a software rasterizer or blocklisted driver therefore gets the Canvas renderer under `video.AUTO`, and throws under `video.WEBGL`. Set `failIfMajorPerformanceCaveat: false` to accept such a context -- **each `Batcher` now owns an immutable Vertex Array Object** built at init ([#1509](https://github.com/melonjs/melonJS/issues/1509)): vertex attribute layout is frozen once built, batcher switches cost a single `bindVertexArray` (steady-state frames issue zero attribute-specification calls, measured), and `Batcher.unbind()` no longer disables attribute arrays. Custom batchers inheriting `Batcher.init()`/`bind()` need no changes. Custom shaders hosted by a built-in batcher must declare that batcher's attributes first, in layout order (ShaderEffect-generated vertex shaders already comply) — a console warning fires on mismatch. `GLShader.setVertexAttributes` is no longer called by the engine (still public) -- **mesh geometry is now supplied to shaders in model space** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — placement moved from the vertex data into the `uModelMatrix` / `uViewMatrix` / `uTint` uniforms. **This affects custom shaders used on a `Mesh` only** (including a `ShaderEffect` applied to a mesh); sprite and camera post-effect shaders are unaffected. Such a shader must position its vertices with `uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0)` and tint with `uTint`, instead of `uProjectionMatrix` alone — the engine warns on the console when a mesh shader declares none of them. `Mesh.vertices` / `Mesh.normals` are no longer refreshed by WebGL draws; `getBounds3d()` and `toPolygon()` compute from the model matrix instead and are now correct before the first draw - -- an attribute declared without an explicit `offset` is now packed after the previous one instead of defaulting to byte 0. Layouts that omitted offsets were previously overlapping every attribute at 0 and reading the wrong data; if you relied on that, pass explicit offsets ([#1551](https://github.com/melonjs/melonJS/issues/1551)) -- a `Batcher` whose vertex stride is not a multiple of 4 bytes now throws at construction. Previously it built with a fractional vertex size and silently discarded every vertex write ([#1551](https://github.com/melonjs/melonJS/issues/1551)) + **This applies to code already using `new Application(...)` on 19.x** — construction no longer builds the renderer or appends the canvas. `app.destroy()` is now terminal (construct a new instance instead), a repeated `init()` is a warned no-op, and the exported `game` names the most recently *initialized* application +- **`video.AUTO` prefers WebGPU: the ladder is WebGPU → WebGL 2 → Canvas** — an existing game using `AUTO` starts on WebGPU where available, which renders the entire feature set identically (all 44 examples verified side-by-side). Pin `renderer: video.WEBGL` to stay on WebGL +- **The WebGL renderer is WebGL 2 only** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — the WebGL 1 fallback is removed; `AUTO` falls back to Canvas on WebGL-1-only devices and `video.WEBGL` throws there. **User shaders need no changes.** `preferWebGL1` and `#webgl1` are removed, `device.isWebGLSupported()` now probes for WebGL 2, `renderer.type` is always `"WebGL2"`, and `renderer.WebGLVersion` is deprecated. Ex-WebGL-1 configs gain three corrections: `repeat` genuinely tiles non-power-of-two textures, `"darken"` / `"lighten"` use true MIN/MAX equations, and `createPattern()` accepts non-power-of-two sources +- **`video.init()`, `video.renderer`, `video.createCanvas()` and `video.getParent()` are removed** — deprecated since 18.3.0–19.7.0. Use `app.init()`, `app.renderer`, `app.renderer.createCanvas()` and `app.getParentElement()`. Note the failure mode differs: `video.init()` returned `false`, `app.init()` rejects +- **`Batcher` is now the backend-neutral base class; the WebGL base is renamed `WebGLBatcher`** — custom WebGL batchers change one word; signatures and methods are unchanged. `renderer.addBatcher()` now rejects a batcher not extending the active backend's base class up front, instead of failing mid-draw +- **Mesh geometry is supplied to shaders in model space** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — placement moved into the `uModelMatrix` / `uViewMatrix` / `uTint` uniforms. **Affects custom shaders on a `Mesh` only**; such a shader must position with `uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0)` and tint with `uTint` (the engine warns if it declares none of them). `getBounds3d()` and `toPolygon()` now compute from the model matrix and are correct before the first draw +- **Each `Batcher` owns an immutable Vertex Array Object** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — attribute layout is frozen at init and batcher switches cost one `bindVertexArray`. Custom batchers inheriting `init()`/`bind()` need no changes; custom shaders hosted by a built-in batcher must declare that batcher's attributes first, in layout order (a warning fires on mismatch) +- **Renderer capability flags replace backend type checks** — `Renderer.shaderLanguage` and `Renderer.supportsDepthBuffer` should be read instead of testing `renderer.gl` or `instanceof WebGLRenderer`, which answer "no" for any capable future backend. TMX GPU tilemap eligibility moves to `renderer.supportsShaderTileLayers` +- An attribute declared without an explicit `offset` is now packed after the previous one instead of defaulting to byte 0 — layouts omitting offsets were overlapping every attribute at 0 and reading the wrong data ([#1551](https://github.com/melonjs/melonJS/issues/1551)) +- A `Batcher` whose vertex stride is not a multiple of 4 bytes now throws at construction, instead of silently discarding every vertex write ([#1551](https://github.com/melonjs/melonJS/issues/1551)) +- The WebGL renderer is selected only on devices providing WebGL 2 **and** passing `failIfMajorPerformanceCaveat` (enabled by default, unlike the WebGL default) — a software rasterizer gets Canvas under `AUTO` and throws under `video.WEBGL` ### Deprecated -- `Application.updateAverageDelta` is renamed **`lastUpdateDelta`**. It holds the measured wall-clock cost of the most recent logic step and has never been an average — the exponential smoothing the name refers to was removed in 2015, a day after it was added. The old name keeps working as an alias and is scheduled for removal in 21.0.0, so no migration is required now. Note it is a different quantity from `updateDelta`, which is the *simulated* time one step advances +- `Application.updateAverageDelta` is renamed **`lastUpdateDelta`** — it holds the cost of the most recent logic step and has never been an average. The old name keeps working and is scheduled for removal in 21.0.0. Note it differs from `updateDelta`, which is the *simulated* time one step advances ### Performance -- **Vertex Array Objects for every batcher** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — vertex attribute layout is specified once at init rather than on every batcher switch and every mesh flush, so steady-state frames issue **zero** attribute-specification calls. How much GL traffic this saves depends on how often a scene alternates batchers: a scene that stays on one batcher saves about 2 calls per frame, one mixing sprites, meshes and primitives about 40 — in both cases well under a millisecond. The structural benefit is the larger one: attribute-state leaks between batchers become impossible by construction. -- **retained-mode mesh rendering** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — mesh geometry is uploaded to the GPU once and re-drawn from there, instead of being re-transformed on the CPU and re-uploaded every frame. Steady-state frames issue **zero** vertex uploads and run **zero** per-vertex CPU transforms, however much a mesh moves, rotates, scales or changes tint; only an explicit geometry edit re-uploads. A mesh past 65 535 vertices is also drawn in a single call rather than split into chunks. What to expect: the CPU cost of issuing a mesh draw drops by roughly **25–30% for small meshes** (tens of vertices, where only per-call overhead was ever at stake) and by **more than 95% for vertex-heavy ones** (thousands), because the saving is per-vertex work that no longer happens at all — so the bigger the model, the larger the share. Measured in the in-tree mesh benchmark (`drawmesh_bench.spec.js`, which now runs both paths side by side): an 8-vertex cube 1.8µs → 1.2µs per draw, a 5 000-vertex mesh 66µs → under 2.5µs and from 2 draw calls to 1. The change in shape matters more than any single figure. Measured on an Apple M4 Max (ANGLE Metal) over the vertex counts from [#1507](https://github.com/melonjs/melonJS/issues/1507), draw-phase CPU per frame: - -| vertices/frame | before | after | draw calls | -| --- | --- | --- | --- | -| 158k | 3.6ms | 0.20ms | 47 → 9 | -| 376k | 6.0ms | 0.20ms | 110 → 14 | -| 718k | 9.6ms | 0.27ms | 206 → 19 | -| 1.16M | 13.8ms | 0.29ms | 349 → 23 | - -The old path scales linearly with vertex count; the new one is flat, because no per-vertex work happens at all. At 1.16M vertices submitting the scene went from 83% of a 60fps frame budget to under 2%, and the draw-call collapse is the 16-bit chunking disappearing. Note this is the **CPU** cost of submitting the frame — GPU work is not waited on, so rasterization still costs what it costs, and a scene limited by fill rate rather than by geometry submission will see less of this back -- **immutable texture storage across the WebGL texture pipeline** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — every texture the WebGL renderer allocates now uses `texStorage2D` sized-format storage (`RGBA8`, mip-level count pinned at allocation) instead of per-level `texImage2D`, and content updates go through `texSubImage2D` into the existing allocation. Two effects. The structural one: a same-size content update — a ticking `Text`, a `Gradient` re-bake, a video frame — previously created a **fresh texture object on every change** (the old one lingering until garbage collection); it is now a pure data copy into storage the driver already owns, so a score counter updating every frame stops cycling ~60 texture allocations per second through the driver, and with the [#1554](https://github.com/melonjs/melonJS/issues/1554) size buckets the steady state of dynamic text/gradients allocates **nothing at all**. The secondary one: the driver validates mipmap completeness once at allocation instead of re-checking texture state at draw time — a per-draw CPU saving that is real but driver-dependent and too small to isolate in-frame, so no number is claimed for it. This is the same immutable-allocation model WebGPU mandates (`createTexture`), moving both backends onto one texture lifecycle -- **the cost of `antiAlias: true` under post effects, quantified** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — MSAA composing through effect chains (see *Added*) is paid for in memory and bandwidth, and the price is worth knowing. Arithmetic, not measurement: a 4× capture target keeps 4 color + 4 depth-stencil samples per pixel next to its 1× resolve texture — roughly **28 extra bytes per pixel on WebGL (~55 MB of GPU memory at 1080p)** and **~16 bytes per pixel on WebGPU (~32 MB at 1080p)**, where the multisampled depth attachment is shared with the canvas rather than per-target; both scale linearly with resolution. Per frame it adds one resolve blit per effect bracket, and draws inside the bracket write up to 4 samples per covered pixel — bandwidth, not shading cost, since fragment shaders still run once per pixel under MSAA. Only scene **capture** targets pay any of this (ping-pong intermediates stay 1×), and with `antiAlias: false` — the default — no multisampled storage exists at all, so nothing changes - -### Fixed -- **destroyed renderers stayed subscribed to global events forever** — `WebGLRenderer` subscribed to `GAME_RESET`, `ONCONTEXT_RESTORED` and `CANVAS_ONRESIZE`, and `CanvasRenderer` to `GAME_RESET`, all as **inline anonymous handlers** — which cannot be passed to `off()`, so nothing could ever unregister them. `CanvasRenderer` had no `destroy()` at all, inheriting the base no-op. Two consequences, both silent: a destroyed renderer kept reacting to those events, and — worse — each handler closes over the renderer, so the subscription pinned the renderer, its batchers and its GPU objects against garbage collection. Releasing the GL context alone did not help, because the JS graph was still reachable from the event bus. The same shape was in the scene graph: the **root `Container`** subscribed to `CANVAS_ONRESIZE` with an inline arrow, and **`World`** to `GAME_RESET` (with a context) and `LEVEL_LOADED` (inline), none of them ever removed — and `World` had no `destroy()` of its own, so a torn-down world kept resetting itself and clearing a broadphase nobody read. All of these are now per-instance fields and `destroy()` unregisters them, matching what the WebGPU backend already did. Any application that tears down and rebuilds — an SPA moving between scenes, a level reload — stops accumulating them -- **`Application.destroy()` leaked the WebGL context** — teardown deleted every GL object the renderer owned and removed the canvas from the DOM, but never handed back the **context** itself. A canvas keeps its context until the canvas is garbage-collected, which is non-deterministic and routinely delayed, so each destroyed application left a live context behind. Browsers cap how many they keep — around 16 on Chromium — and force-lose the oldest past that, which means a long-lived page that builds and tears down several applications accumulates dead-but-unfreed contexts until an unrelated later `getContext` stalls or comes back already lost. That hits any single-page app that moves between scenes or unmounts a game view (the examples gallery does exactly this on every navigation), and it was also making unrelated test suites time out in CI. `destroy()` now releases the context through `WEBGL_lose_context`. It stays idempotent, and since `destroy()` is already terminal — `Application.init()` refuses to run again afterwards — losing the context forecloses nothing that was previously possible. Renderers whose driver does not expose the extension are unaffected -- **the 3D broadphase silently dropped collisions between bodies at different depths** — under `Camera3d` the world's broadphase is an `Octree`, and `retrieve()` — the candidate feed for SAT collision, pointer picking, the 2D raycast and `adapter.queryAABB` — descended only into the octant the query item itself classified into. But every one of those consumers decides overlap in the **XY plane**: two bodies at different z that overlap in XY genuinely collide, and were never offered to each other as candidates. Whether a given pair was tested came down to which side of an octant boundary each happened to fall on. Measured on a randomized 300-body scene, **12 of 20 genuinely overlapping pairs were never surfaced**. `retrieve()` is now depth-blind: it classifies on x/y only and walks both depth halves of that quadrant, so x/y pruning still applies at every level and in both halves (an item lying wholly inside a different x/y quadrant cannot overlap, and midpoint-straddling items already live at the parent level). On a 600-body scene this costs nothing at all when the bodies share a gameplay plane — the candidate count is unchanged — and on a depth-spread scene it settles at the same candidate count as the flat one, which is the point: with depth no longer part of the decision, the candidate set depends only on the x/y distribution. The genuinely 3D queries are unaffected and still prune on depth: `queryAABB`, `querySphere`, `queryRay` and `queryFrustum` have their own entry points, and each is now pinned by a differential test against a brute-force scan. Note this removes the incidental "parallax at a distant z drops out of collision for free" behaviour that the 2.5D documentation described as best-effort — it was this defect seen from its good side. Exclude parallax deliberately instead, with `isKinematic = true` or `collisionType` / `collisionMask`, which is what the 2D path has always done -- **an entire 2.5D gameplay plane sat unpartitioned at the root of the octree** — `getIndex` returned −1 (meaning "straddles a midpoint, keep at this level") for an item sitting *exactly* on one. On x and y that is at least defensible, since an item there may genuinely span the boundary; on z it never is, because items are point-z in the broadphase and a point cannot straddle anything. It mattered because the root box is origin-centred, so its midpoints are `(0, 0, 0)` — the default `pos` of every renderable, and the shared gameplay z that the 2.5D recipe prescribes. Measured: 200 bodies on a `z = 0` plane all stayed at the root and `retrieve()` returned **200 of 200**, degrading the broadphase to a linear scan for exactly the layer holding the most bodies; the same 200 spread across z left only 10 at the root. Classification is now exact on all three axes — a midpoint belongs to the far/right/bottom child, and an item whose far edge merely touches one still counts as wholly inside the near side. Genuine straddlers and out-of-bounds items still stay at the parent, both under regression test. This is invisible to 2D games, which use a `QuadTree` and never construct an `Octree` -- **a mesh marked `lit` with no usable normals rendered solid black** — normalizing a zero-length normal yields NaN, which the shader turned into black fragments rather than something recognisable. That happens whenever `lit: true` meets geometry with no normals, and on the 2D-camera path generally, where world normals are never written. Such a mesh now degrades to **unlit** on both GPU backends: wrong, but recognisably the model instead of a hole in the scene. Note this makes the failure legible, it does not make a `Camera2d` mesh light — populating world normals on that path is tracked separately as [#1576](https://github.com/melonjs/melonJS/issues/1576) and remains open -- **`DropShadowEffect` rendered its shadow vertically mirrored (up instead of down) when chained with other effects on WebGL** — the pooled multi-effect path composites through capture FBOs, which are bottom-up under GL, so the y component of any directional UV arithmetic inside an effect body ran inverted relative to the single-effect fast path (and to the WebGPU backend, whose captures are top-down on both paths). Found by cross-backend comparison — earlier pixel-count probes were direction-blind. Effect bodies can now declare a `uUVYDir` uniform that the renderer feeds per draw path (+1 where `uv.y` grows downward, −1 on the GL pooled path); DropShadow uses it, so a positive `offsetY` means *down* on every path of both backends; `ShineEffect` adopts it too, so an angled sweep travels the documented direction (π/2 = top→bottom) on the pooled path as well -- **a scene containing only meshes stopped clearing its depth buffer after the first frame, and its geometry disappeared** — a regression from the 19.7 mesh state-ownership work ([#1468](https://github.com/melonjs/melonJS/issues/1468)), found while working on [#1552](https://github.com/melonjs/melonJS/issues/1552). The depth clear and the lit-mesh light upload both ran from `MeshBatcher.bind()`, which is a per-*transition* hook, not a per-frame one: `setBatcher` returns early when the requested batcher is already current. A scene with nothing else to draw — no sprites, no UI, no unlit mesh beside a lit one — therefore bound once and never again, leaving the depth attachment on the first frame's values, so anything receding from the camera failed the depth test and was not drawn at all. The same silence froze `Light3d` lighting at its first-frame values on such a scene. Both now refresh on the draw path, at no measurable cost (one boolean test per draw for the depth clear; the light upload is skipped outright when the lights have not changed) +- **Retained-mode mesh rendering** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — mesh geometry uploads to the GPU once and re-draws from there. Steady-state frames issue **zero** vertex uploads and **zero** per-vertex CPU transforms however much a mesh moves; only an explicit geometry edit re-uploads. Meshes past 65 535 vertices draw in a single call. Draw-phase CPU per frame, measured on an Apple M4 Max (ANGLE Metal): + + | vertices/frame | before | after | draw calls | + | --- | --- | --- | --- | + | 158k | 3.6ms | 0.20ms | 47 → 9 | + | 376k | 6.0ms | 0.20ms | 110 → 14 | + | 718k | 9.6ms | 0.27ms | 206 → 19 | + | 1.16M | 13.8ms | 0.29ms | 349 → 23 | + + The old path scales with vertex count; the new one is flat. At 1.16M vertices, submitting the scene went from 83% of a 60fps frame budget to under 2%. This is the **CPU** cost of submitting the frame — a scene limited by fill rate will see less of it back +- **Immutable texture storage across the WebGL pipeline** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — textures allocate with `texStorage2D` and update through `texSubImage2D`. A same-size update (a ticking `Text`, a re-baked gradient, a video frame) previously created a **fresh texture object every change**; it is now a data copy into existing storage, so a per-frame score counter stops cycling ~60 allocations a second — and with the [#1554](https://github.com/melonjs/melonJS/issues/1554) size buckets, the steady state allocates nothing at all +- **Vertex Array Objects for every batcher** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — attribute layout is specified once at init, so steady-state frames issue **zero** attribute-specification calls (~2 calls per frame saved on a single-batcher scene, ~40 on a mixed one — well under a millisecond either way). The structural benefit is larger: attribute-state leaks between batchers become impossible by construction +- **The cost of `antiAlias: true` under post effects** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — arithmetic, not measurement: a 4× capture target costs roughly **28 extra bytes per pixel on WebGL (~55 MB at 1080p)** and **~16 on WebGPU (~32 MB)**, plus one resolve blit per effect bracket. Only capture targets pay it, and with `antiAlias: false` (the default) no multisampled storage exists at all + +### Fixed +- **Untextured glTF materials rendered washed out** — `baseColorFactor` is linear per the glTF spec but was scaled by 255 straight into an 8-bit sRGB tint, so an authored mid-green (linear `0.29`) arrived as sRGB `0.58`. Now encoded through the sRGB transfer function, which also clamps the domain (an out-of-range factor used to NaN the whole tint) +- **An indexed (palette) PNG in a glTF asset rendered greyscale on Safari** — the glTF parser decoded to an `HTMLImageElement`, unlike the rest of the loader, which produces an `ImageBitmap`. Harmless for RGBA sources; for PNG `colorType 3`, WebKit's `copyExternalImageToTexture` uploads the raw palette *indices*, so the texture arrives with `r == g == b`. Other browsers normalise at decode and WebGL is unaffected, so it looked like a WebGPU regression in one browser. glTF images now decode to an `ImageBitmap` (RGBA by definition) +- **The 3D broadphase silently dropped collisions between bodies at different depths** — `Octree.retrieve()` descended only into the octant the query item classified into, but its consumers (SAT collision, pointer picking, the 2D raycast, `queryAABB`) all decide overlap in the **XY plane**. Measured on a randomized 300-body scene, **12 of 20 genuinely overlapping pairs were never surfaced**. `retrieve()` is now depth-blind. Note this removes the incidental "distant parallax drops out of collision for free" behaviour — exclude parallax deliberately with `isKinematic` or `collisionType` / `collisionMask` +- **An entire 2.5D gameplay plane sat unpartitioned at the root of the octree** — an item sitting *exactly* on a midpoint was kept at the parent level, and the root's midpoint is `(0, 0, 0)`: the default `pos` of every renderable and the shared gameplay z the 2.5D recipe prescribes. Measured: 200 bodies on a `z = 0` plane all stayed at the root and `retrieve()` returned **200 of 200**, degrading the broadphase to a linear scan for exactly the busiest layer. Classification is now exact on all three axes. Invisible to 2D games, which never construct an `Octree` +- **Destroyed renderers stayed subscribed to global events forever** — `WebGLRenderer`, `CanvasRenderer`, the root `Container` and `World` all subscribed with **inline anonymous handlers**, which cannot be passed to `off()`. Each handler closes over its owner, so the subscription pinned the renderer, its batchers and its GPU objects against garbage collection — releasing the GL context did not help, because the JS graph was still reachable from the event bus. All are now per-instance fields that `destroy()` unregisters. Any application that tears down and rebuilds stops accumulating them +- **`Application.destroy()` leaked the WebGL context** — teardown deleted every GL object and removed the canvas, but never released the **context**, which a canvas keeps until it is garbage-collected. Browsers cap live contexts (~16 on Chromium) and force-lose the oldest past that, so a page building and tearing down several applications eventually stalls an unrelated `getContext`. `destroy()` now releases it through `WEBGL_lose_context` +- **A scene containing only meshes stopped clearing its depth buffer after the first frame** — a regression from the 19.7 mesh state-ownership work ([#1468](https://github.com/melonjs/melonJS/issues/1468)). The depth clear and lit-mesh light upload ran from `MeshBatcher.bind()`, a per-*transition* hook: a scene with nothing else to draw bound once and never again, so anything receding from the camera failed the depth test and vanished, and `Light3d` lighting froze at first-frame values. Both now refresh on the draw path +- **A mesh marked `lit` with no usable normals rendered solid black** — normalizing a zero-length normal yields NaN. Such a mesh now degrades to **unlit**: wrong, but recognisably the model instead of a hole in the scene. This makes the failure legible, it does not make a `Camera2d` mesh light — that is tracked as [#1576](https://github.com/melonjs/melonJS/issues/1576) +- **`DropShadowEffect` rendered its shadow mirrored when chained with other effects on WebGL** — the pooled multi-effect path composites through capture FBOs, which are bottom-up under GL, so directional UV arithmetic ran inverted relative to the single-effect path and to WebGPU. Effect bodies can now declare a `uUVYDir` uniform that the renderer feeds per draw path; `DropShadowEffect` and `ShineEffect` use it, so a positive `offsetY` means *down* everywhere +- **`Body.destroy()` threw for any shape class not registered with the legacy object pool** — and because the bounds were released first, the throw left the body holding a recycled `Bounds` that failed much later, deep in the broadphase. Reachable with any user-defined shape type +- **An unknown shape-type pair crashed the physics step** — `Detector.collides` called straight into its lookup table, so a combination with no entry threw mid-step and took the world update with it. Now warns once and treats the pair as no collision ## [19.9.1] (melonJS 2) - _2026-07-28_ diff --git a/packages/melonjs/src/geometries/box3d.ts b/packages/melonjs/src/geometries/box3d.ts new file mode 100644 index 000000000..a863d0385 --- /dev/null +++ b/packages/melonjs/src/geometries/box3d.ts @@ -0,0 +1,310 @@ +import { Vector2d } from "../math/vector2d.ts"; +import { Vector3d } from "../math/vector3d.ts"; +import { Bounds } from "../physics/bounds.ts"; +import { AABB3d } from "../physics/broadphase/aabb3d.ts"; +import { createPool } from "../system/pool.ts"; +import { Polygon } from "./polygon.ts"; + +/** + * Smallest XY footprint edge handed to {@link Polygon#recalc}. See + * {@link Box3d#_syncFootprint} for why a zero-length edge is unsafe. + * @ignore + */ +const MIN_FOOTPRINT = 1e-6; + +/** + * An axis-aligned 3D box, usable as a {@link Body} collision shape. + * + * This is the shape that lets a body collide along **Z** as well as X and Y. + * Every other built-in shape ({@link Polygon}, {@link Rect}, {@link RoundRect}, + * {@link Ellipse}) is planar and is resolved by the 2D SAT narrowphase, which + * can only ever produce a 2D pushback — see {@link ResponseObject#overlapV}. + * A `Box3d` pair is instead resolved by an AABB-vs-AABB narrowphase that also + * fills {@link ResponseObject#overlapZ}. + * + * Coordinate convention matches the rest of melonJS 3D code (see + * {@link Camera3d}): **Y-down, +Z forward / away from the camera.** + * + * ## Position is the CENTER + * + * Unlike {@link Rect} (top-left) and like {@link Ellipse} (center), `pos` is + * the box **center**. That matches how 3D objects are placed everywhere else + * in the engine — {@link Mesh}, {@link Sprite3d} and the ground-shadow + * footprint all work from a center plus half-extents — and it keeps the + * narrowphase free of corner/center conversions on the hot path. + * + * ## Mixing with 2D shapes + * + * A `Box3d` can collide with a planar shape. The planar shape is treated as + * **unbounded along Z** (an infinitely extruded prism of its own outline), so + * the pair degrades to the ordinary 2D test on the XY footprint and the box's + * z never causes it to miss. This keeps an existing 2D game working unchanged + * when a single `Box3d` body is introduced: its world shapes go on colliding + * exactly as before. Use `collisionType` / `collisionMask` to opt specific + * shapes out of a 3D body. + * @category Geometry + * @example + * // a 64x16x64 floor slab centered on the origin + * const floor = new Box3d(0, 0, 0, 64, 16, 64); + * myFloor.body.addShape(floor); + */ +export class Box3d { + /** + * the center of the box, as an offset from the owning body's position + */ + pos: Vector3d; + + /** + * half the box size on each axis. Always non-negative; a negative + * extent passed to {@link Box3d#setShape} is stored as its magnitude, + * since a box with a mirrored axis has no meaning to the narrowphase. + */ + halfExtents: Vector3d; + + /** + * the shape type (used internally) + * @default "Box3d" + */ + type = "Box3d"; + + /** + * 2D XY footprint, kept in sync by {@link Box3d#setShape}. This is what + * {@link Box3d#getBounds} hands back, so every legacy 2D consumer + * ({@link Body#bounds}, the broadphase pre-gate, debug draw) sees a + * plain rectangle and needs no 3D awareness. + * @ignore + */ + _bounds: Bounds; + + /** + * XY footprint as a {@link Polygon}, kept in sync by + * {@link Box3d#setShape}. The degraded `Box3d` × planar-shape tests + * hand this to the existing polygon SAT, so no polygon is allocated + * per collision test. + * @ignore + */ + _footprint: Polygon; + + /** + * @param x - center of the box on the horizontal axis + * @param y - center of the box on the vertical axis + * @param z - center of the box on the depth axis + * @param width - width of the box + * @param height - height of the box + * @param depth - depth of the box + */ + constructor(x = 0, y = 0, z = 0, width = 0, height = 0, depth = 0) { + this.pos = new Vector3d(); + this.halfExtents = new Vector3d(); + this._bounds = new Bounds(); + // seeded with a unit quad; setShape rewrites the points immediately + this._footprint = new Polygon(0, 0, [ + new Vector2d(0, 0), + new Vector2d(1, 0), + new Vector2d(1, 1), + new Vector2d(0, 1), + ]); + this.setShape(x, y, z, width, height, depth); + } + + /** + * set new position and size for this box + * @param x - center of the box on the horizontal axis + * @param y - center of the box on the vertical axis + * @param z - center of the box on the depth axis + * @param width - width of the box + * @param height - height of the box + * @param depth - depth of the box + * @returns this box, for chaining + */ + setShape( + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + ) { + this.pos.set(x, y, z); + this.halfExtents.set( + Math.abs(width) * 0.5, + Math.abs(height) * 0.5, + Math.abs(depth) * 0.5, + ); + this._syncFootprint(); + return this; + } + + /** + * Rebuild the cached XY footprint (both the {@link Bounds} and the + * {@link Polygon}) from the current center and half-extents. + * + * The footprint polygon's `pos` is the **min corner**, not the center, + * because the SAT narrowphase resolves a shape's absolute position as + * `renderable.pos + ancestor.getAbsolutePosition() + shape.pos` and + * then treats `points` as offsets from it. + * @ignore + */ + _syncFootprint() { + const hx = this.halfExtents.x; + const hy = this.halfExtents.y; + // Footprint edges are floored at MIN_FOOTPRINT. `Polygon.recalc` + // normalizes each edge by its own length with no zero guard, so two + // coincident points yield `0 / 0 = NaN` normals and poison every + // later SAT axis test. A zero-width or zero-height box (including a + // default-constructed one) would do exactly that. Z is unaffected: + // a zero-DEPTH box is perfectly well-defined and stays exact. + const w = Math.max(hx * 2, MIN_FOOTPRINT); + const h = Math.max(hy * 2, MIN_FOOTPRINT); + const minX = this.pos.x - hx; + const minY = this.pos.y - hy; + + const points = this._footprint.points; + points[0].set(0, 0); + points[1].set(w, 0); + points[2].set(w, h); + points[3].set(0, h); + this._footprint.pos.set(minX, minY); + // rebuild edges / normals for the SAT axes + this._footprint.recalc(); + + this._bounds.setMinMax(minX, minY, minX + w, minY + h); + } + + /** + * width of the box + */ + get width(): number { + return this.halfExtents.x * 2; + } + set width(value: number) { + this.halfExtents.x = Math.abs(value) * 0.5; + this._syncFootprint(); + } + + /** + * height of the box + */ + get height(): number { + return this.halfExtents.y * 2; + } + set height(value: number) { + this.halfExtents.y = Math.abs(value) * 0.5; + this._syncFootprint(); + } + + /** + * depth of the box + */ + get depth(): number { + return this.halfExtents.z * 2; + } + set depth(value: number) { + this.halfExtents.z = Math.abs(value) * 0.5; + this._syncFootprint(); + } + + /** + * translate this box by the given offset + * @param x - x offset, or a vector carrying the whole offset + * @param [y] - y offset + * @param [z] - z offset + * @returns this box, for chaining + */ + shift(x: number | Vector3d, y = 0, z = 0) { + if (typeof x === "object") { + this.pos.add(x); + } else { + this.pos.set(this.pos.x + x, this.pos.y + y, this.pos.z + z); + } + this._syncFootprint(); + return this; + } + + /** + * the 2D XY footprint of this box. + * + * Deliberately 2D: this is the {@link Renderable#getBounds} contract that + * {@link Body} and the broadphase pre-gate already speak. For the depth + * extent use {@link Box3d#getBounds3d}. + * @returns the XY footprint + */ + getBounds(): Bounds { + return this._bounds; + } + + /** + * the 3D bounds of this box, in the body's local space. + * @param [out] - an existing AABB3d to write into, to avoid allocating + * @returns the 3D bounds + */ + getBounds3d(out?: AABB3d): AABB3d { + const target = out ?? new AABB3d(); + const hx = this.halfExtents.x; + const hy = this.halfExtents.y; + const hz = this.halfExtents.z; + target.setMinMax( + this.pos.x - hx, + this.pos.y - hy, + this.pos.z - hz, + this.pos.x + hx, + this.pos.y + hy, + this.pos.z + hz, + ); + return target; + } + + /** + * true if this box contains the given point + * @param x - point x, or a vector carrying the whole point + * @param [y] - point y + * @param [z] - point z + */ + contains(x: number | Vector3d, y = 0, z = 0): boolean { + const isVector = typeof x === "object"; + const px = isVector ? x.x : x; + const py = isVector ? x.y : y; + const pz = isVector ? x.z : z; + return ( + Math.abs(px - this.pos.x) <= this.halfExtents.x && + Math.abs(py - this.pos.y) <= this.halfExtents.y && + Math.abs(pz - this.pos.z) <= this.halfExtents.z + ); + } + + /** + * clone this box + * @returns a new Box3d + */ + clone(): Box3d { + return new Box3d( + this.pos.x, + this.pos.y, + this.pos.z, + this.width, + this.height, + this.depth, + ); + } +} + +export const box3dPool = createPool< + Box3d, + [ + x?: number, + y?: number, + z?: number, + width?: number, + height?: number, + depth?: number, + ] +>((x, y, z, width, height, depth) => { + const instance = new Box3d(x, y, z, width, height, depth); + + return { + instance, + reset(x = 0, y = 0, z = 0, width = 0, height = 0, depth = 0) { + instance.setShape(x, y, z, width, height, depth); + }, + }; +}); diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 6044c103c..151da8ffc 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -116,6 +116,7 @@ export * from "./application/settings.ts"; export * as audio from "./audio/audio.ts"; // export all public constants export * from "./const.ts"; +export { Box3d } from "./geometries/box3d.ts"; export { Ellipse } from "./geometries/ellipse.ts"; export { Line } from "./geometries/line.ts"; export { ObservablePoint } from "./geometries/observablePoint.ts"; diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 463f87719..bd92a190a 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -10,6 +10,7 @@ import InstancedMesh from "../../renderable/instanced_mesh.js"; import Mesh from "../../renderable/mesh.js"; import { fillInstances } from "./GLTFScene.js"; import { sampleChannel } from "./gltf_sampler.js"; +import { linearToSrgb8 } from "./srgb.js"; /** * additional import for TypeScript @@ -175,12 +176,14 @@ export default class GLTFModel extends Container { if (prim.instances) { fillInstances(mesh, prim.instances); } + // LINEAR per the glTF spec; a tint is 8-bit sRGB — see + // `linearToSrgb8` const f = prim.baseColorFactor; if (f) { mesh.tint.setColor( - Math.round(f[0] * 255), - Math.round(f[1] * 255), - Math.round(f[2] * 255), + linearToSrgb8(f[0]), + linearToSrgb8(f[1]), + linearToSrgb8(f[2]), ); } if (prim.colors) { diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index 4dcf93218..3f41be83b 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -6,6 +6,7 @@ import InstancedMesh from "../../renderable/instanced_mesh.js"; import Mesh from "../../renderable/mesh.js"; import { writeInstanceTRS } from "../../video/gpu/instancerecord.ts"; import GLTFModel from "./GLTFModel.js"; +import { linearToSrgb8 } from "./srgb.js"; /** * @classdesc @@ -224,12 +225,16 @@ export default class GLTFScene { // (RGB only; alpha/transparency is a separate feature — the mesh // path renders opaque.) Composes with COLOR_0 and the texture: the // batcher does factor × vertexColor × texel, matching glTF. + // + // The factor is LINEAR per the glTF spec and a tint is 8-bit sRGB, + // so it has to be encoded rather than scaled by 255 — see + // `linearToSrgb8`. const f = node.baseColorFactor; if (f) { mesh.tint.setColor( - Math.round(f[0] * 255), - Math.round(f[1] * 255), - Math.round(f[2] * 255), + linearToSrgb8(f[0]), + linearToSrgb8(f[1]), + linearToSrgb8(f[2]), ); } // per-vertex colors (COLOR_0) — multiplied by the tint per vertex diff --git a/packages/melonjs/src/level/gltf/srgb.js b/packages/melonjs/src/level/gltf/srgb.js new file mode 100644 index 000000000..31bcf97c8 --- /dev/null +++ b/packages/melonjs/src/level/gltf/srgb.js @@ -0,0 +1,26 @@ +/** + * Color-space bridge for glTF material factors. + * + * glTF 2.0 defines `pbrMetallicRoughness.baseColorFactor` in **linear** space + * (spec §3.9.2), while a melonJS `tint` is an 8-bit **sRGB** value — the same + * space as a CSS color or a texel out of a PNG. Handing the linear number + * straight to `tint.setColor(f * 255)` therefore displays every untextured + * glTF material far too light and desaturated: a linear `0.29` shows up as + * sRGB `0.58`, so an authored mid-green renders as pale mint. + * + * The encode below is the standard sRGB transfer function. + * @module level/gltf/srgb + */ + +/** + * Encode one linear channel (0..1) to an 8-bit sRGB value (0..255). + * @param {number} c - linear channel value + * @returns {number} the sRGB-encoded channel, rounded to 0..255 + */ +export function linearToSrgb8(c) { + // guard the domain: exporters can emit slightly out-of-range factors, and + // `Math.pow` on a negative base returns NaN, which would poison the tint + const v = c <= 0 ? 0 : c >= 1 ? 1 : c; + const s = v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055; + return Math.round(s * 255); +} diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index eaa2f5228..4d810a30f 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -509,10 +509,21 @@ export function multiplyMatrix(a, b) { } /** - * Decode a glTF image into an HTMLImageElement. Handles the three sources: - * an embedded `bufferView`, an inline `data:` URI, and an external image file - * referenced by relative `uri` (resolved against the asset URL `baseURI`). - * @returns {Promise} + * Decode a glTF image. Handles the three sources: an embedded `bufferView`, + * an inline `data:` URI, and an external image file referenced by relative + * `uri` (resolved against the asset URL `baseURI`). + * + * Resolves an `ImageBitmap` wherever the platform provides one — matching + * what the ordinary image loader (`parsers/image.js`) produces, so every + * texture in the engine reaches the GPU as the same, fully-decoded kind of + * source. That consistency is load-bearing rather than cosmetic: an + * `HTMLImageElement` decoded from an INDEXED (PNG `colorType` 3) file is + * uploaded by WebKit's `copyExternalImageToTexture` as raw palette indices, + * so the texture arrives greyscale. An `ImageBitmap` is RGBA by definition + * and has no such ambiguity. Nothing in the WebGPU API can correct this at + * upload time — `flipY` / `premultipliedAlpha` / `colorSpace` are the only + * knobs, and palette expansion belongs to the decoder. + * @returns {Promise} * @ignore */ function decodeImage(json, buffers, imageIndex, baseURI, settings) { @@ -527,6 +538,16 @@ function decodeImage(json, buffers, imageIndex, baseURI, settings) { ); blob = new Blob([slice], { type: image.mimeType || "image/png" }); } else if (image.uri && image.uri.startsWith("data:")) { + if (typeof globalThis.createImageBitmap === "function") { + return fetch(image.uri) + .then((r) => { + return r.blob(); + }) + .then(decodeBlob) + .catch(() => { + return loadImageFromUrl(image.uri); + }); + } return loadImageFromUrl(image.uri); } else if (image.uri) { // external image file — resolve relative to the asset URL and let the @@ -542,14 +563,48 @@ function decodeImage(json, buffers, imageIndex, baseURI, settings) { ), ); } + if ( + typeof globalThis.createImageBitmap === "function" && + typeof settings?.crossOrigin !== "string" + ) { + return fetch(url) + .then((r) => { + if (!r.ok) { + throw new Error(`glTF: failed to fetch image (${r.status})`); + } + return r.blob(); + }) + .then(decodeBlob) + .catch(() => { + return loadImageFromUrl(url, false, settings?.crossOrigin); + }); + } return loadImageFromUrl(url, false, settings?.crossOrigin); } else { return Promise.reject(new Error("glTF: unsupported image source")); } - // `revoke: true` — the blob URL is a transient handle, only needed until - // the image has decoded; release it on load/error to avoid leaking it for - // the lifetime of the document. - return loadImageFromUrl(URL.createObjectURL(blob), true); + return decodeBlob(blob); +} + +/** + * Decode a Blob to an ImageBitmap, falling back to the element path on a + * platform without `createImageBitmap`. + * @ignore + */ +function decodeBlob(blob) { + const viaElement = () => { + // `revoke: true` — the blob URL is a transient handle, only needed + // until the image has decoded + return loadImageFromUrl(URL.createObjectURL(blob), true); + }; + if (typeof globalThis.createImageBitmap !== "function") { + return viaElement(); + } + // Fall back rather than fail: `createImageBitmap` is STRICTER than the + // element path (it rejects sources the element decodes leniently), and a + // stricter loader would be a regression. This can only ever upgrade a + // decode, never break one. + return globalThis.createImageBitmap(blob).catch(viaElement); } /** @ignore */ diff --git a/packages/melonjs/src/physics/builtin/body.js b/packages/melonjs/src/physics/builtin/body.js index cdb968a7a..899f80ded 100644 --- a/packages/melonjs/src/physics/builtin/body.js +++ b/packages/melonjs/src/physics/builtin/body.js @@ -1,3 +1,4 @@ +import { Box3d, box3dPool } from "../../geometries/box3d.ts"; import { Ellipse } from "../../geometries/ellipse.ts"; import { Line, linePool } from "../../geometries/line.ts"; import { Point, pointPool } from "../../geometries/point.ts"; @@ -231,6 +232,70 @@ export default class Body { // cap by default to half the default gravity force this.maxVel.set(490, 490); + /** + * The current velocity of the body along the depth axis. + * + * Z arrives as **scalars beside** `vel` / `force` / `friction` / + * `maxVel` rather than by widening them to {@link Vector3d}, because + * `Vector3d` is not a subclass of {@link Vector2d}: retyping them — + * or the {@link Body#getVelocity} vector they hand back — would break + * every existing 2D consumer. They stay exactly as they were. + * + * All four default to the inert value, so a body that never touches + * them integrates z by `+= 0` and behaves identically to 19.x. Only a + * body carrying a {@link Box3d} shape can be pushed back along z by + * the solver. + * @public + * @type {number} + * @default 0 + * @see Body#forceZ + * @see Box3d + */ + this.velZ = 0; + + /** + * body force to apply along the depth axis in the current step. + * @public + * @type {number} + * @default 0 + * @see Body#velZ + */ + this.forceZ = 0; + + /** + * body friction along the depth axis. + * @public + * @type {number} + * @default 0 + * @see Body#setFriction + */ + this.frictionZ = 0; + + /** + * max velocity along the depth axis (to limit body velocity). + * @public + * @type {number} + * @default 490 + * @see Body#setMaxVelocity + */ + this.maxVelZ = 490; + + /** + * `true` when at least one of this body's shapes is a {@link Box3d}, + * i.e. this body has a depth extent and can be resolved along z. + * Maintained by {@link Body#addShape} / {@link Body#removeShape}. + * @readonly + * @public + * @type {boolean} + * @default false + */ + // derived rather than reset to `false`: `this.shapes` is only + // allocated when undefined, so a pooled body handed back for reuse + // still carries its previous shapes at this point. + this.hasDepth = this.shapes.some((shape) => { + return shape.type === "Box3d"; + }); + /** * Either this body is a static body or not. * A static body is completely fixed and can never change position or angle. @@ -637,6 +702,17 @@ export default class Body { this.shapes.push(shape); } this.bounds.addPoint(shape); + } else if (shape instanceof Box3d) { + if (!this.shapes.includes(shape)) { + // see removeShape + this.shapes.push(shape); + } + // Only the XY footprint goes into `this.bounds`. That bounds is a + // 2D `Bounds` and feeds the broadphase pre-gate, debug draw and + // `containsPoint` — all of which stay 2D. The depth extent lives + // on the shape and is read by the narrowphase directly. + this.bounds.addBounds(shape.getBounds()); + this.hasDepth = true; } else { // JSON object this.fromJSON(shape); @@ -764,12 +840,17 @@ export default class Body { /** * remove the specified shape from the body shape list - * @param {Polygon|Line|Ellipse} shape - a shape object + * @param {Polygon|Line|Ellipse|Box3d} shape - a shape object * @returns {number} the shape array length */ removeShape(shape) { // clear the current bounds this.bounds.clear(); + // `addShape` only ever raises `hasDepth`, so clear it here and let the + // re-add below raise it again if a Box3d is still in the list — + // otherwise removing the last Box3d would leave the body claiming a + // depth extent it no longer has. + this.hasDepth = false; // remove the shape from shape list remove(this.shapes, shape); // add everything left back @@ -849,24 +930,41 @@ export default class Body { ratio = totalMass > 0 ? other.body.mass / totalMass : 0.5; } + // Z half of the minimum translation vector. Both are 0 for every + // planar shape pair (see ResponseObject#overlapZ), so everything below + // stays bit-for-bit identical for a 2D body — no branch required. + // + // Defaulted with `??` because this method is DUCK-TYPED, not typed: + // it is public and callers legitimately hand it a plain object + // literal carrying only `overlapV` / `overlapN`. Reading the new + // fields off one of those yields `undefined`, and a single + // `vel * undefined` poisons `projVel` to NaN — which fails the + // `projVel > 0` gate and silently stops cancelling velocity into the + // surface, while the position write turns z into NaN. + const overlapZ = response.overlapZ ?? 0; + const overlapNZ = response.overlapNZ ?? 0; + // Move out of the other object shape this.ancestor.pos.set( this.ancestor.pos.x - overlap.x * ratio, this.ancestor.pos.y - overlap.y * ratio, - this.ancestor.pos.z, + this.ancestor.pos.z - overlapZ * ratio, ); // cancel the velocity component along the collision normal - const projVel = this.vel.x * overlapN.x + this.vel.y * overlapN.y; + const projVel = + this.vel.x * overlapN.x + this.vel.y * overlapN.y + this.velZ * overlapNZ; if (projVel > 0) { if (this.bounce > 0) { // reflect velocity along normal with bounce damping this.vel.x -= (1 + this.bounce) * projVel * ratio * overlapN.x; this.vel.y -= (1 + this.bounce) * projVel * ratio * overlapN.y; + this.velZ -= (1 + this.bounce) * projVel * ratio * overlapNZ; } else { // remove the velocity component along the collision normal this.vel.x -= projVel * ratio * overlapN.x; this.vel.y -= projVel * ratio * overlapN.y; + this.velZ -= projVel * ratio * overlapNZ; } } @@ -986,20 +1084,28 @@ export default class Body { * cap the body velocity (body.maxVel property) to the specified value
* @param {number} x - max velocity on x axis * @param {number} y - max velocity on y axis + * @param {number} [z] - max velocity on the depth axis; left unchanged when omitted, so existing two-argument calls keep their behaviour */ - setMaxVelocity(x, y) { + setMaxVelocity(x, y, z) { this.maxVel.x = x; this.maxVel.y = y; + if (typeof z !== "undefined") { + this.maxVelZ = z; + } } /** * set the body default friction * @param {number} x - horizontal friction * @param {number} y - vertical friction + * @param {number} [z] - depth friction; left unchanged when omitted, so existing two-argument calls keep their behaviour */ - setFriction(x = 0, y = 0) { + setFriction(x = 0, y = 0, z) { this.friction.x = x; this.friction.y = y; + if (typeof z !== "undefined") { + this.frictionZ = z; + } } /** @@ -1027,6 +1133,9 @@ export default class Body { if (this.force.y !== 0) { this.vel.y += this.force.y * deltaTime; } + if (this.forceZ !== 0) { + this.velZ += this.forceZ * deltaTime; + } // apply friction if defined if (this.friction.x > 0) { @@ -1043,6 +1152,13 @@ export default class Body { this.vel.y = ny < 0 ? ny : y > 0 ? y : 0; } + if (this.frictionZ > 0) { + const fz = this.frictionZ * deltaTime; + const nz = this.velZ + fz; + const z = this.velZ - fz; + + this.velZ = nz < 0 ? nz : z > 0 ? z : 0; + } // cap velocity if (this.vel.y !== 0) { @@ -1051,13 +1167,27 @@ export default class Body { if (this.vel.x !== 0) { this.vel.x = clamp(this.vel.x, -this.maxVel.x, this.maxVel.x); } + if (this.velZ !== 0) { + this.velZ = clamp(this.velZ, -this.maxVelZ, this.maxVelZ); + } // check if falling / jumping this.falling = this.vel.y * Math.sign(this.force.y) > 0; this.jumping = this.falling ? false : this.jumping; - // update the body ancestor position - this.ancestor.pos.add(this.vel); + // update the body ancestor position. + // + // Folded into a single `set` rather than `add(this.vel)` followed by a + // separate z write: `pos` may be an ObservableVector3d, and a second + // write would fire a second change notification every frame for every + // body in the world. With `velZ` at its default 0 this computes + // exactly what `add(this.vel)` did — `add` already resolves the + // missing z of a Vector2d to 0. + this.ancestor.pos.set( + this.ancestor.pos.x + this.vel.x, + this.ancestor.pos.y + this.vel.y, + this.ancestor.pos.z + this.velZ, + ); // Angular integration — gated so bodies that never touch the // rotation API pay zero cost. The instant either `angle` or @@ -1101,6 +1231,15 @@ export default class Body { linePool.release(shape); } else if (shape instanceof Polygon) { polygonPool.release(shape); + } else if (shape instanceof Box3d) { + // Box3d has its own pool. Without this branch it falls through + // to the legacy `pool.push`, which THROWS for any class that + // was never `pool.register`ed — and because `boundsPool.release` + // above has already run, the throw aborts `destroy` partway and + // leaves the body holding a recycled Bounds. The next + // broadphase insert then reads `bounds.min.x` off it and dies + // somewhere completely unrelated. + box3dPool.release(shape); } else { pool.push(shape); } diff --git a/packages/melonjs/src/physics/builtin/builtin-adapter.ts b/packages/melonjs/src/physics/builtin/builtin-adapter.ts index 83bffc6cb..17b551c74 100644 --- a/packages/melonjs/src/physics/builtin/builtin-adapter.ts +++ b/packages/melonjs/src/physics/builtin/builtin-adapter.ts @@ -20,6 +20,187 @@ import Body from "./body.js"; import Detector from "./detector.js"; import { raycastQuery } from "./raycast.ts"; +/** world-space AABB scratch for `raycast3d`; never escapes the call */ +const _rayBox = { + minX: 0, + minY: 0, + minZ: 0, + maxX: 0, + maxY: 0, + maxZ: 0, +}; + +/** reusable hit record for `rayAABB3d`; never escapes `raycast3d` */ +const _rayHit = { t: 0, nx: 0, ny: 0, nz: 0 }; + +/** + * Fill `out` with the world-space AABB of a renderable's {@link Box3d} + * shapes, unioned. Returns `false` when the renderable has no body, or a + * body with no `Box3d` in it — those keep the bounding-sphere path, so no + * existing `raycast3d` result changes. + * + * A shape's world position follows the same convention the SAT narrowphase + * uses: the renderable's absolute position plus the shape's local offset. + * @param renderable - the candidate to measure + * @param cx - the renderable's absolute x + * @param cy - the renderable's absolute y + * @param cz - the renderable's absolute z + * @param out - scratch AABB to fill; only written when this returns `true` + */ +function worldBox3d( + renderable: Renderable, + cx: number, + cy: number, + cz: number, + out: typeof _rayBox, +): boolean { + const body = (renderable as { body?: Body }).body; + if (body === undefined || !body.hasDepth) { + return false; + } + let found = false; + const shapes = body.shapes as unknown as { + type: string; + pos: { x: number; y: number; z: number }; + halfExtents: { x: number; y: number; z: number }; + }[]; + for (let i = 0, len = shapes.length; i < len; i++) { + const shape = shapes[i]; + if (shape.type !== "Box3d") continue; + const minX = cx + shape.pos.x - shape.halfExtents.x; + const minY = cy + shape.pos.y - shape.halfExtents.y; + const minZ = cz + shape.pos.z - shape.halfExtents.z; + const maxX = cx + shape.pos.x + shape.halfExtents.x; + const maxY = cy + shape.pos.y + shape.halfExtents.y; + const maxZ = cz + shape.pos.z + shape.halfExtents.z; + if (!found) { + found = true; + out.minX = minX; + out.minY = minY; + out.minZ = minZ; + out.maxX = maxX; + out.maxY = maxY; + out.maxZ = maxZ; + } else { + if (minX < out.minX) out.minX = minX; + if (minY < out.minY) out.minY = minY; + if (minZ < out.minZ) out.minZ = minZ; + if (maxX > out.maxX) out.maxX = maxX; + if (maxY > out.maxY) out.maxY = maxY; + if (maxZ > out.maxZ) out.maxZ = maxZ; + } + } + return found; +} + +/** + * Ray-vs-AABB via the slab method, for `t ∈ [0, 1]` along the segment + * `from → from + d`. Returns the entry fraction and the face normal of the + * slab that was entered last (which is the face actually hit), or `null` on + * a miss. + * + * A ray starting inside the box reports `t = 0`, matching the + * bounding-sphere path's "origin inside → t = 0" convention. Its normal is + * taken from the axis whose entry plane is nearest behind the origin, which + * keeps the value well-defined rather than zero. + * @param from - segment start + * @param dx - segment delta on x (`to.x - from.x`) + * @param dy - segment delta on y + * @param dz - segment delta on z + * @param box - the world-space AABB to test against + */ +function rayAABB3d( + from: Vector3d, + dx: number, + dy: number, + dz: number, + box: typeof _rayBox, +): typeof _rayHit | null { + let tMin = Number.NEGATIVE_INFINITY; + let tMax = Number.POSITIVE_INFINITY; + // axis that produced tMin, and the sign of the face entered + let axis = 0; + let sign = -1; + + // x slab + if (dx === 0) { + if (from.x < box.minX || from.x > box.maxX) return null; + } else { + const inv = 1 / dx; + let t1 = (box.minX - from.x) * inv; + let t2 = (box.maxX - from.x) * inv; + let s = -1; + if (t1 > t2) { + const tmp = t1; + t1 = t2; + t2 = tmp; + s = 1; + } + if (t1 > tMin) { + tMin = t1; + axis = 0; + sign = s; + } + if (t2 < tMax) tMax = t2; + if (tMin > tMax) return null; + } + + // y slab + if (dy === 0) { + if (from.y < box.minY || from.y > box.maxY) return null; + } else { + const inv = 1 / dy; + let t1 = (box.minY - from.y) * inv; + let t2 = (box.maxY - from.y) * inv; + let s = -1; + if (t1 > t2) { + const tmp = t1; + t1 = t2; + t2 = tmp; + s = 1; + } + if (t1 > tMin) { + tMin = t1; + axis = 1; + sign = s; + } + if (t2 < tMax) tMax = t2; + if (tMin > tMax) return null; + } + + // z slab + if (dz === 0) { + if (from.z < box.minZ || from.z > box.maxZ) return null; + } else { + const inv = 1 / dz; + let t1 = (box.minZ - from.z) * inv; + let t2 = (box.maxZ - from.z) * inv; + let s = -1; + if (t1 > t2) { + const tmp = t1; + t1 = t2; + t2 = tmp; + s = 1; + } + if (t1 > tMin) { + tMin = t1; + axis = 2; + sign = s; + } + if (t2 < tMax) tMax = t2; + if (tMin > tMax) return null; + } + + // entirely behind the origin, or entirely past the segment end + if (tMax < 0 || tMin > 1) return null; + + _rayHit.t = tMin < 0 ? 0 : tMin; + _rayHit.nx = axis === 0 ? sign : 0; + _rayHit.ny = axis === 1 ? sign : 0; + _rayHit.nz = axis === 2 ? sign : 0; + return _rayHit; +} + /** * Default {@link PhysicsAdapter} that wraps melonJS's native SAT-based * physics. Owns the active body set, the {@link Detector}, gravity, and @@ -119,7 +300,13 @@ export default class BuiltinAdapter implements PhysicsAdapter { // bodies, out-of-viewport bodies, and paused bodies. Otherwise a // stray applyForce call would leak indefinitely and fire as a // surprise impulse when the body becomes simulatable again. + // + // `forceZ` is part of that accumulator and MUST be cleared with it: + // left out, a single frame of input keeps accelerating the body + // along z forever, because callers set the force per-frame while a + // key is held and rely on this reset to stop. body.force.set(0, 0); + body.forceZ = 0; } // fire onCollisionEnd for pairs that separated this step this.detector.endFrame(); @@ -424,9 +611,10 @@ export default class BuiltinAdapter implements PhysicsAdapter { let bestFraction = Number.POSITIVE_INFINITY; let bestRenderable: Renderable | null = null; let bestEntryT = 0; - let bestCx = 0; - let bestCy = 0; - let bestCz = 0; + + let bestNx = 0; + let bestNy = 0; + let bestNz = 0; for (let i = 0, len = candidates.length; i < len; i++) { const r = candidates[i]; @@ -439,6 +627,25 @@ export default class BuiltinAdapter implements PhysicsAdapter { // so `.z` may not be visible to TS even though it's // always present at runtime. const cz = (center as { z?: number }).z ?? 0; + + // Exact ray-vs-AABB when this renderable carries a Box3d body, + // which is the case that matters: probing floor height under a + // character. Everything else keeps the bounding-sphere path + // below, so no existing raycast3d result changes. + if (worldBox3d(r, cx, cy, cz, _rayBox)) { + const hit = rayAABB3d(from, dx, dy, dz, _rayBox); + if (hit === null) continue; + if (hit.t < bestFraction) { + bestFraction = hit.t; + bestRenderable = r; + bestEntryT = hit.t; + bestNx = hit.nx; + bestNy = hit.ny; + bestNz = hit.nz; + } + continue; + } + // bounding-sphere radius = bounds half-diagonal (matches // `Camera3d.isVisible`'s circumradius convention). const bounds = r.getBounds(); @@ -482,9 +689,17 @@ export default class BuiltinAdapter implements PhysicsAdapter { bestFraction = t; bestRenderable = r; bestEntryT = t; - bestCx = cx; - bestCy = cy; - bestCz = cz; + // sphere normal, resolved at the entry point + const px = from.x + dx * t; + const py = from.y + dy * t; + const pz = from.z + dz * t; + const ux = px - cx; + const uy = py - cy; + const uz = pz - cz; + const uLen = Math.sqrt(ux * ux + uy * uy + uz * uz) || 1; + bestNx = ux / uLen; + bestNy = uy / uLen; + bestNz = uz / uLen; } } @@ -492,18 +707,14 @@ export default class BuiltinAdapter implements PhysicsAdapter { return null; } - const pointX = from.x + dx * bestEntryT; - const pointY = from.y + dy * bestEntryT; - const pointZ = from.z + dz * bestEntryT; - const nx = pointX - bestCx; - const ny = pointY - bestCy; - const nz = pointZ - bestCz; - const nLen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; - return { renderable: bestRenderable, - point: new Vector3d(pointX, pointY, pointZ), - normal: new Vector3d(nx / nLen, ny / nLen, nz / nLen), + point: new Vector3d( + from.x + dx * bestEntryT, + from.y + dy * bestEntryT, + from.z + dz * bestEntryT, + ), + normal: new Vector3d(bestNx, bestNy, bestNz), fraction: bestFraction, }; } diff --git a/packages/melonjs/src/physics/builtin/detector.js b/packages/melonjs/src/physics/builtin/detector.js index 1d86e3564..62620d9d7 100644 --- a/packages/melonjs/src/physics/builtin/detector.js +++ b/packages/melonjs/src/physics/builtin/detector.js @@ -7,6 +7,13 @@ import { testPolygonEllipse, testPolygonPolygon, } from "./sat.js"; +import { + testBox3dBox3d, + testBox3dEllipse, + testBox3dPolygon, + testEllipseBox3d, + testPolygonBox3d, +} from "./sat3d.js"; // pre-built lookup table for SAT collision tests to avoid string concatenation // Rect and RoundRect extend Polygon, so they reuse the Polygon SAT tests @@ -27,8 +34,30 @@ const SAT_LOOKUP = { EllipseRectangle: testEllipsePolygon, RectangleRoundRect: testPolygonPolygon, RoundRectRectangle: testPolygonPolygon, + // Box3d is the only shape with a depth extent, so Box3d-vs-Box3d is the + // only pair resolved by the 3D narrowphase. Every mixed pair degrades to + // the 2D test against the box's XY footprint, treating the planar shape + // as unbounded along Z — see `sat3d.js` / `Box3d` for why. EVERY mixed + // combination has to be listed: this table is looked up by string concat + // and a missing entry is a hard crash, not a missed collision. + Box3dBox3d: testBox3dBox3d, + Box3dPolygon: testBox3dPolygon, + PolygonBox3d: testPolygonBox3d, + Box3dRectangle: testBox3dPolygon, + RectangleBox3d: testPolygonBox3d, + Box3dRoundRect: testBox3dPolygon, + RoundRectBox3d: testPolygonBox3d, + Box3dEllipse: testBox3dEllipse, + EllipseBox3d: testEllipseBox3d, }; +/** + * Shape-type pairs already reported as unsupported, so a mismatched pair + * warns once instead of once per frame per pair. + * @ignore + */ +const reportedMissingPairs = new Set(); + /** * @import Entity from "../../renderable/entity/entity.js"; * @import Container from "../../renderable/container.js"; @@ -97,6 +126,12 @@ class Detector { overlapV: { x: 0, y: 0 }, normal: { x: 0, y: 0 }, depth: 0, + // Z half of the same three vectors, as scalars — see + // `ResponseObject.overlapNZ`. Always 0 unless both shapes + // are a Box3d and the contact resolved along Z. + overlapNZ: 0, + overlapZ: 0, + normalZ: 0, }, { a: null, @@ -106,6 +141,9 @@ class Detector { overlapV: { x: 0, y: 0 }, normal: { x: 0, y: 0 }, depth: 0, + overlapNZ: 0, + overlapZ: 0, + normalZ: 0, }, ]; } @@ -123,6 +161,8 @@ class Detector { const view = this._symViews[slot]; const oN = satResponse.overlapN; const oV = satResponse.overlapV; + const oNZ = satResponse.overlapNZ; + const oZ = satResponse.overlapZ; if (flip) { view.a = satResponse.b; view.b = satResponse.a; @@ -130,9 +170,12 @@ class Detector { view.overlapN.y = -oN.y; view.overlapV.x = -oV.x; view.overlapV.y = -oV.y; + view.overlapNZ = -oNZ; + view.overlapZ = -oZ; // MTV of original b = +overlapN (b moves along "from a to b" to escape) view.normal.x = oN.x; view.normal.y = oN.y; + view.normalZ = oNZ; } else { view.a = satResponse.a; view.b = satResponse.b; @@ -140,9 +183,12 @@ class Detector { view.overlapN.y = oN.y; view.overlapV.x = oV.x; view.overlapV.y = oV.y; + view.overlapNZ = oNZ; + view.overlapZ = oZ; // MTV of original a = -overlapN (a moves opposite of "from a to b" to escape) view.normal.x = -oN.x; view.normal.y = -oN.y; + view.normalZ = -oNZ; } view.overlap = satResponse.overlap; view.depth = satResponse.overlap; @@ -253,9 +299,27 @@ class Detector { let indexB = bodyB.shapes.length, shapeB; indexB--, (shapeB = bodyB.shapes[indexB]); ) { + // Resolve the narrowphase for this shape pair. An unlisted + // combination used to index straight into `.call(...)` and + // throw a TypeError mid-step, taking the whole world update + // with it — reachable today with any user-defined shape type, + // and newly reachable via `Box3d`. Warn once per pair and + // treat it as "no collision" instead: a missed contact is + // recoverable, a thrown physics step is not. + const test = SAT_LOOKUP[shapeA.type + shapeB.type]; + if (test === undefined) { + const pair = `${shapeA.type} / ${shapeB.type}`; + if (!reportedMissingPairs.has(pair)) { + reportedMissingPairs.add(pair); + console.warn( + `melonJS: no collision test for shape pair ${pair}; treating as no collision`, + ); + } + continue; + } // full SAT collision check if ( - SAT_LOOKUP[shapeA.type + shapeB.type].call( + test.call( this, bodyA.ancestor, // a reference to the object A shapeA, @@ -405,6 +469,12 @@ class Detector { while (extraPasses-- > 0 && this.collides(objA.body, objB.body)) { const overlap = this.response.overlapV; const overlapN = this.response.overlapN; + // Z half of the same two vectors. Both are 0 for + // every planar shape pair, so the arithmetic below + // is bit-for-bit inert for a 2D body — no branch + // needed to keep the legacy path unchanged. + const overlapZ = this.response.overlapZ; + const overlapNZ = this.response.overlapNZ; // mass ratio for proportional response const bothDynamic = !objA.body.isStatic && !objB.body.isStatic; @@ -427,27 +497,33 @@ class Detector { objA.body.ancestor.pos.set( objA.body.ancestor.pos.x - overlap.x * ratioA, objA.body.ancestor.pos.y - overlap.y * ratioA, - objA.body.ancestor.pos.z, + objA.body.ancestor.pos.z - overlapZ * ratioA, ); // cancel velocity into this surface (no bounce) const projVel = - objA.body.vel.x * overlapN.x + objA.body.vel.y * overlapN.y; + objA.body.vel.x * overlapN.x + + objA.body.vel.y * overlapN.y + + objA.body.velZ * overlapNZ; if (projVel > 0) { objA.body.vel.x -= projVel * ratioA * overlapN.x; objA.body.vel.y -= projVel * ratioA * overlapN.y; + objA.body.velZ -= projVel * ratioA * overlapNZ; } } if (objB.body.isStatic === false) { objB.body.ancestor.pos.set( objB.body.ancestor.pos.x + overlap.x * ratioB, objB.body.ancestor.pos.y + overlap.y * ratioB, - objB.body.ancestor.pos.z, + objB.body.ancestor.pos.z + overlapZ * ratioB, ); const projVel = - objB.body.vel.x * overlapN.x + objB.body.vel.y * overlapN.y; + objB.body.vel.x * overlapN.x + + objB.body.vel.y * overlapN.y + + objB.body.velZ * overlapNZ; if (projVel > 0) { objB.body.vel.x -= projVel * ratioB * overlapN.x; objB.body.vel.y -= projVel * ratioB * overlapN.y; + objB.body.velZ -= projVel * ratioB * overlapNZ; } } // update bounds after position changed diff --git a/packages/melonjs/src/physics/builtin/sat3d.js b/packages/melonjs/src/physics/builtin/sat3d.js new file mode 100644 index 000000000..f47f7d69e --- /dev/null +++ b/packages/melonjs/src/physics/builtin/sat3d.js @@ -0,0 +1,177 @@ +import { + testEllipsePolygon, + testPolygonEllipse, + testPolygonPolygon, +} from "./sat.js"; + +/** + * @import {Box3d} from "../../geometries/box3d.ts"; + * @import {Polygon} from "../../geometries/polygon.ts"; + * @import {Ellipse} from "../../geometries/ellipse.ts"; + * @import Renderable from "../../renderable/renderable.js"; + */ + +/** + * Absolute (world) center of a {@link Box3d} shape on one axis. + * + * Mirrors the convention the 2D SAT tests use — a shape's world position is + * `renderable.pos + renderable.ancestor.getAbsolutePosition() + shape.pos` — + * except that all three terms are read in 3D. `getAbsolutePosition()` already + * sums z across the whole ancestor chain. + * @ignore + */ +function absCenter(renderable, box, out) { + const anc = renderable.ancestor.getAbsolutePosition(); + out[0] = renderable.pos.x + anc.x + box.pos.x; + out[1] = renderable.pos.y + anc.y + box.pos.y; + out[2] = renderable.pos.z + anc.z + box.pos.z; + return out; +} + +// module scratch for the two box centers; never escapes testBox3dBox3d +const _centerA = [0, 0, 0]; +const _centerB = [0, 0, 0]; + +/** + * Check whether two axis-aligned 3D boxes collide. + * + * This is the only narrowphase in the engine that can produce a **Z** + * pushback. The separating-axis set of two AABBs is just the three world axes, + * so there is no projection loop: penetration on each axis is + * `(halfA + halfB) - |centerDelta|`, a non-positive value on any axis means + * separated, and the minimum translation vector is the axis with the smallest + * positive penetration. + * + * The MTV is a single axis, so exactly one of `overlapN.x`, `overlapN.y` and + * `overlapNZ` comes back non-zero — see {@link ResponseObject#overlapNZ}. When + * that axis is Z the 2D fields stay at zero, which is what makes a legacy 2D + * `onCollision` handler safely inert on a depth-only contact rather than + * wrong. + * @ignore + * @param {Renderable} a - a reference to the object A. + * @param {Box3d} boxA - a reference to the object A Box3d to be tested + * @param {Renderable} b - a reference to the object B. + * @param {Box3d} boxB - a reference to the object B Box3d to be tested + * @param {object} [response] - Response object that will be populated if they intersect. + * @returns {boolean} true if they intersect, false if they don't. + */ +export function testBox3dBox3d(a, boxA, b, boxB, response) { + const ca = absCenter(a, boxA, _centerA); + const cb = absCenter(b, boxB, _centerB); + + const ha = boxA.halfExtents; + const hb = boxB.halfExtents; + + const dx = cb[0] - ca[0]; + const dy = cb[1] - ca[1]; + const dz = cb[2] - ca[2]; + + const px = ha.x + hb.x - Math.abs(dx); + if (px <= 0) { + return false; + } + const py = ha.y + hb.y - Math.abs(dy); + if (py <= 0) { + return false; + } + const pz = ha.z + hb.z - Math.abs(dz); + if (pz <= 0) { + return false; + } + + if (response) { + response.a = a; + response.b = b; + + // Smallest positive penetration wins. Ties resolve X → Y → Z, which + // keeps a body resting on a floor being pushed straight up rather + // than sideways out of a corner when two axes penetrate equally. + if (px <= py && px <= pz) { + // `dx === 0` (perfectly concentric on this axis) has no + // meaningful side, so bias to +1 rather than emitting a zero + // normal, which would make the push-out a no-op and leave the + // pair overlapping forever. + const n = dx < 0 ? -1 : 1; + response.overlap = px; + response.overlapN.set(n, 0); + response.overlapV.set(n * px, 0); + response.overlapNZ = 0; + response.overlapZ = 0; + } else if (py <= pz) { + const n = dy < 0 ? -1 : 1; + response.overlap = py; + response.overlapN.set(0, n); + response.overlapV.set(0, n * py); + response.overlapNZ = 0; + response.overlapZ = 0; + } else { + const n = dz < 0 ? -1 : 1; + response.overlap = pz; + response.overlapN.set(0, 0); + response.overlapV.set(0, 0); + response.overlapNZ = n; + response.overlapZ = n * pz; + } + + response.aInB = + ha.x <= hb.x && + ha.y <= hb.y && + ha.z <= hb.z && + Math.abs(dx) <= hb.x - ha.x && + Math.abs(dy) <= hb.y - ha.y && + Math.abs(dz) <= hb.z - ha.z; + response.bInA = + hb.x <= ha.x && + hb.y <= ha.y && + hb.z <= ha.z && + Math.abs(dx) <= ha.x - hb.x && + Math.abs(dy) <= ha.y - hb.y && + Math.abs(dz) <= ha.z - hb.z; + } + + return true; +} + +/** + * Check whether a {@link Box3d} collides with a planar shape. + * + * The planar shape is treated as **unbounded along Z** — an infinitely + * extruded prism of its own outline — so the pair reduces to the ordinary 2D + * test between the box's XY footprint and that outline, and the box's z can + * never make it miss. See {@link Box3d} for why that is the compatible + * reading: it is what keeps an existing 2D game's world shapes colliding + * unchanged the moment one `Box3d` body is introduced. + * + * `overlapZ` is left at `0` by construction, since neither participant has a + * finite depth to resolve against. + * @ignore + */ +export function testBox3dPolygon(a, boxA, b, polyB, response) { + return testPolygonPolygon(a, boxA._footprint, b, polyB, response); +} + +/** + * Planar-shape-first form of {@link testBox3dPolygon}. + * @ignore + */ +export function testPolygonBox3d(a, polyA, b, boxB, response) { + return testPolygonPolygon(a, polyA, b, boxB._footprint, response); +} + +/** + * {@link Box3d} against an {@link Ellipse}, with the ellipse unbounded along + * Z. See {@link testBox3dPolygon}. + * @ignore + */ +export function testBox3dEllipse(a, boxA, b, ellipseB, response) { + return testPolygonEllipse(a, boxA._footprint, b, ellipseB, response); +} + +/** + * {@link Ellipse} against a {@link Box3d}, with the ellipse unbounded along + * Z. See {@link testBox3dPolygon}. + * @ignore + */ +export function testEllipseBox3d(a, ellipseA, b, boxB, response) { + return testEllipsePolygon(a, ellipseA, b, boxB._footprint, response); +} diff --git a/packages/melonjs/src/physics/response.js b/packages/melonjs/src/physics/response.js index d234289a2..35452073b 100644 --- a/packages/melonjs/src/physics/response.js +++ b/packages/melonjs/src/physics/response.js @@ -8,6 +8,8 @@ import { Vector2d } from "../math/vector2d.ts"; * @property {number} overlap Magnitude of the overlap on the shortest colliding axis * @property {Vector2d} overlapV The overlap vector (i.e. `overlapN.scale(overlap, overlap)`). If this vector is subtracted from the position of a, a and b will no longer be colliding * @property {Vector2d} overlapN The shortest colliding axis (unit-vector) + * @property {number} overlapNZ The Z component of the shortest colliding axis, as a unit scalar (`-1`, `0` or `1`). Always `0` for a collision between planar shapes + * @property {number} overlapZ The Z component of the overlap vector (i.e. `overlapNZ * overlap`). Always `0` for a collision between planar shapes * @property {boolean} aInB Whether the first object is entirely inside the second * @property {boolean} bInA Whether the second object is entirely inside the first * @property {number} indexShapeA The index of the colliding shape for the object a body @@ -19,6 +21,27 @@ class ResponseObject { this.b = null; this.overlapN = new Vector2d(); this.overlapV = new Vector2d(); + /** + * Z half of the minimum translation axis. + * + * Z arrives as **scalars beside** `overlapN` / `overlapV` rather than + * by widening them to {@link Vector3d}, because `Vector3d` is not a + * subclass of {@link Vector2d} — retyping them would break every + * existing consumer of a 2D collision response. + * + * The minimum translation axis is a single axis, so at most one of + * `overlapN.x`, `overlapN.y` and `overlapNZ` is ever non-zero. The 2D + * invariant `overlapV = overlapN * overlap` therefore extends + * unchanged as `overlapZ = overlapNZ * overlap`, and a collision + * resolved along Z leaves `overlapN` / `overlapV` at zero — a legacy + * 2D handler reading them applies no push, which is correct, because + * there is no 2D push to apply. + * + * Only ever non-zero when both shapes are a {@link Box3d}; every + * planar shape pair leaves these at `0`. + */ + this.overlapNZ = 0; + this.overlapZ = 0; this.aInB = true; this.bInA = true; this.indexShapeA = -1; @@ -41,6 +64,11 @@ class ResponseObject { this.overlap = Number.MAX_VALUE; this.indexShapeA = -1; this.indexShapeB = -1; + // Reset alongside `overlap`, so a Box3d pair resolved along Z cannot + // leak its Z push into the next test — which, for a planar pair, would + // be a Z push that no shape in the test has any depth to justify. + this.overlapNZ = 0; + this.overlapZ = 0; return this; } } diff --git a/packages/melonjs/tests/box3d-world.spec.js b/packages/melonjs/tests/box3d-world.spec.js new file mode 100644 index 000000000..6bc412a6d --- /dev/null +++ b/packages/melonjs/tests/box3d-world.spec.js @@ -0,0 +1,231 @@ +/** + * End-to-end Z resolution through a real world step (#1476). + * + * `box3d.spec.js` tests the narrowphase and `Body` in isolation. This file + * drives the whole path a game actually takes — `world.update()` → adapter + * step → broadphase → detector → push-out — because that is where the pieces + * can be individually correct and still not compose: the Octree has to hand + * the pair over, the 2D bounds pre-gate has to let a depth-only contact + * through, and the push-out has to land on `pos.z`. + */ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + Application, + Body, + Box3d, + boot, + collision, + Rect, + Renderable, + video, + World, +} from "../src/index.js"; + +/** + * A renderable centred on its position carrying a Box3d body. + * + * The body is attached BEFORE `addChild`: `Container.addChild` is what + * registers a body with the adapter (it reads `child.bodyDef` / `child.body` + * at insertion time), so a body assigned afterwards never enters the + * simulation and the object silently never collides. + */ +function addBox(world, { x, y, z, w, h, d, isStatic = false, type }) { + const r = new Renderable(x, y, w, h); + r.anchorPoint.set(0.5, 0.5); + r.isKinematic = false; + r.alwaysUpdate = true; + r.body = new Body(r, new Box3d(0, 0, 0, w, h, d)); + r.body.collisionType = type ?? collision.types.ENEMY_OBJECT; + r.body.collisionMask = collision.types.ALL_OBJECT; + r.body.isStatic = isStatic; + r.body.gravityScale = 0; + world.addChild(r, z); + return r; +} + +describe("Box3d — resolution through a full world step", () => { + /** @type {World} */ + let world; + + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + beforeEach(() => { + world = new World(0, 0, 800, 600); + // Octree broadphase — this is also what a 2.5D game sets + world.sortOn = "depth"; + }); + + it("pushes a body out of a static wall along Z", () => { + // mover overlaps the wall by 10 along z and nothing else + const wall = addBox(world, { + x: 100, + y: 100, + z: 200, + w: 200, + h: 200, + d: 40, + isStatic: true, + type: collision.types.WORLD_SHAPE, + }); + const mover = addBox(world, { + x: 100, + y: 100, + z: 170, + w: 40, + h: 40, + d: 40, + type: collision.types.PLAYER_OBJECT, + }); + + expect(wall).toBeDefined(); + const before = mover.pos.z; + world.update(16); + + // separated along z, and moved AWAY from the wall (toward -z) + expect(mover.pos.z).toBeLessThan(before); + expect(Math.abs(mover.pos.z - 200)).toBeGreaterThanOrEqual(40 / 2 + 40 / 2); + }); + + it("does not push along X or Y when only Z overlaps", () => { + addBox(world, { + x: 100, + y: 100, + z: 200, + w: 200, + h: 200, + d: 40, + isStatic: true, + type: collision.types.WORLD_SHAPE, + }); + const mover = addBox(world, { + x: 100, + y: 100, + z: 170, + w: 40, + h: 40, + d: 40, + type: collision.types.PLAYER_OBJECT, + }); + + world.update(16); + expect(mover.pos.x).toBeCloseTo(100, 5); + expect(mover.pos.y).toBeCloseTo(100, 5); + }); + + it("leaves a body alone when it is separated in Z only", () => { + // fully overlapping in XY, far apart in Z — the case a 2D shape + // would call a collision and push apart in the screen plane + addBox(world, { + x: 100, + y: 100, + z: 600, + w: 200, + h: 200, + d: 40, + isStatic: true, + type: collision.types.WORLD_SHAPE, + }); + const mover = addBox(world, { + x: 100, + y: 100, + z: 0, + w: 40, + h: 40, + d: 40, + type: collision.types.PLAYER_OBJECT, + }); + + world.update(16); + expect(mover.pos.x).toBeCloseTo(100, 5); + expect(mover.pos.y).toBeCloseTo(100, 5); + expect(mover.pos.z).toBeCloseTo(0, 5); + }); + + it("stops Z velocity into the surface", () => { + addBox(world, { + x: 100, + y: 100, + z: 200, + w: 200, + h: 200, + d: 40, + isStatic: true, + type: collision.types.WORLD_SHAPE, + }); + const mover = addBox(world, { + x: 100, + y: 100, + z: 170, + w: 40, + h: 40, + d: 40, + type: collision.types.PLAYER_OBJECT, + }); + mover.body.velZ = 5; // driving straight into the wall + + world.update(16); + expect(mover.body.velZ).toBeLessThanOrEqual(0); + }); + + it("clears forceZ every step, like force", () => { + // `force` is documented as being cancelled after every update cycle, + // and callers set it per-frame while a key is held. `forceZ` has to + // follow the same contract: left uncleared, one frame of input + // accelerates the body along z forever — which reads to a player as + // the controls being stuck. + const mover = addBox(world, { + x: 100, + y: 100, + z: 0, + w: 40, + h: 40, + d: 40, + type: collision.types.PLAYER_OBJECT, + }); + + mover.body.forceZ = 5; + world.update(16); + expect(mover.body.forceZ).toEqual(0); + + // and with no further input the body must stop gaining speed + const afterFirst = mover.body.velZ; + world.update(16); + expect(mover.body.velZ).toBeLessThanOrEqual(afterFirst); + }); + + it("a 2D body pair under the same world is untouched in Z", () => { + // the compat guarantee, through the full step rather than in isolation + const a = new Renderable(100, 100, 32, 32); + a.anchorPoint.set(0.5, 0.5); + a.isKinematic = false; + a.alwaysUpdate = true; + a.body = new Body(a, new Rect(0, 0, 32, 32)); + a.body.collisionType = collision.types.PLAYER_OBJECT; + a.body.gravityScale = 0; + world.addChild(a, 50); + + const b = new Renderable(116, 100, 32, 32); + b.anchorPoint.set(0.5, 0.5); + b.isKinematic = false; + b.alwaysUpdate = true; + b.body = new Body(b, new Rect(0, 0, 32, 32)); + b.body.collisionType = collision.types.ENEMY_OBJECT; + b.body.gravityScale = 0; + world.addChild(b, 50); + + world.update(16); + + // they separate in X as they always did, and neither moved in Z + expect(a.pos.z).toEqual(50); + expect(b.pos.z).toEqual(50); + expect(Number.isNaN(a.pos.x)).toBe(false); + }); +}); diff --git a/packages/melonjs/tests/box3d.spec.js b/packages/melonjs/tests/box3d.spec.js new file mode 100644 index 000000000..ceee8ef61 --- /dev/null +++ b/packages/melonjs/tests/box3d.spec.js @@ -0,0 +1,780 @@ +/** + * `Box3d` — the shape that lets a body collide along Z — plus the + * AABB-vs-AABB narrowphase behind it (#1476). + * + * Two things are under test here and they pull in opposite directions: + * + * 1. the new 3D behaviour is CORRECT — checked differentially against a + * brute-force AABB overlap, since a narrowphase that silently misses + * contacts is the failure mode that doesn't announce itself; and + * 2. the existing 2D behaviour is UNCHANGED — the whole point of doing + * this additively. Anything a 2D game could observe (`overlapV`, + * `overlapN`, the push-out, the number of position writes) has to come + * out bit-for-bit the same as before. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { + Body, + Box3d, + Ellipse, + Polygon, + Rect, + Renderable, + RoundRect, + Vector2d, +} from "../src/index.js"; +import Detector from "../src/physics/builtin/detector.js"; +import { testBox3dBox3d } from "../src/physics/builtin/sat3d.js"; +import ResponseObject from "../src/physics/response.js"; + +/** Deterministic LCG — reproducible failures beat `Math.random()`. */ +function lcg(seed) { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +/** + * A renderable positioned at (x, y, z) with a stubbed ancestor, matching the + * shape the SAT narrowphase expects (`.pos` + `.ancestor.getAbsolutePosition()`). + */ +function makeRenderable(x = 0, y = 0, z = 0) { + const r = new Renderable(x, y, 0, 0); + r.anchorPoint.set(0, 0); + r.pos.set(x, y, z); + r.ancestor = { + getAbsolutePosition() { + return { x: 0, y: 0, z: 0 }; + }, + }; + return r; +} + +/** world-space AABB of a Box3d owned by `renderable` */ +function worldBox(renderable, box) { + return { + minX: renderable.pos.x + box.pos.x - box.halfExtents.x, + minY: renderable.pos.y + box.pos.y - box.halfExtents.y, + minZ: renderable.pos.z + box.pos.z - box.halfExtents.z, + maxX: renderable.pos.x + box.pos.x + box.halfExtents.x, + maxY: renderable.pos.y + box.pos.y + box.halfExtents.y, + maxZ: renderable.pos.z + box.pos.z + box.halfExtents.z, + }; +} + +/** brute-force AABB overlap, strict (touching does not count) */ +function bruteOverlap(a, b) { + return ( + a.minX < b.maxX && + a.maxX > b.minX && + a.minY < b.maxY && + a.maxY > b.minY && + a.minZ < b.maxZ && + a.maxZ > b.minZ + ); +} + +describe("Box3d — shape", () => { + it("treats pos as the CENTER, not the corner", () => { + const box = new Box3d(10, 20, 30, 4, 6, 8); + expect(box.pos.x).toEqual(10); + expect(box.pos.y).toEqual(20); + expect(box.pos.z).toEqual(30); + expect(box.halfExtents.x).toEqual(2); + expect(box.halfExtents.y).toEqual(3); + expect(box.halfExtents.z).toEqual(4); + + const aabb = box.getBounds3d(); + expect(aabb.min.x).toEqual(8); + expect(aabb.max.x).toEqual(12); + expect(aabb.min.y).toEqual(17); + expect(aabb.max.y).toEqual(23); + expect(aabb.min.z).toEqual(26); + expect(aabb.max.z).toEqual(34); + }); + + it("exposes a 2D XY footprint via getBounds()", () => { + const box = new Box3d(10, 20, 30, 4, 6, 8); + const bounds = box.getBounds(); + expect(bounds.x).toEqual(8); + expect(bounds.y).toEqual(17); + expect(bounds.width).toEqual(4); + expect(bounds.height).toEqual(6); + }); + + it("keeps the footprint in sync when the size changes", () => { + const box = new Box3d(0, 0, 0, 2, 2, 2); + box.width = 10; + box.height = 20; + expect(box.getBounds().width).toEqual(10); + expect(box.getBounds().height).toEqual(20); + expect(box.getBounds().x).toEqual(-5); + expect(box.getBounds().y).toEqual(-10); + }); + + it("keeps the footprint in sync when shifted", () => { + const box = new Box3d(0, 0, 0, 4, 4, 4); + box.shift(10, 20, 30); + expect(box.pos.z).toEqual(30); + expect(box.getBounds().x).toEqual(8); + expect(box.getBounds().y).toEqual(18); + }); + + it("never produces NaN footprint normals on a degenerate box", () => { + // `Polygon.recalc` normalizes each edge by its own length with NO + // zero guard, so a zero-size footprint would yield 0/0 = NaN and + // poison every SAT axis test downstream. A default-constructed + // Box3d is exactly that case. + for (const box of [ + new Box3d(), + new Box3d(0, 0, 0, 0, 0, 0), + new Box3d(0, 0, 0, 0, 10, 10), + new Box3d(0, 0, 0, 10, 0, 10), + ]) { + for (const normal of box._footprint.normals) { + expect(Number.isFinite(normal.x)).toBe(true); + expect(Number.isFinite(normal.y)).toBe(true); + } + } + }); + + it("keeps a zero DEPTH exact (only the XY footprint is floored)", () => { + const box = new Box3d(0, 0, 0, 10, 10, 0); + expect(box.halfExtents.z).toEqual(0); + expect(box.depth).toEqual(0); + }); + + it("stores a negative extent as its magnitude", () => { + const box = new Box3d(0, 0, 0, -10, -20, -30); + expect(box.width).toEqual(10); + expect(box.height).toEqual(20); + expect(box.depth).toEqual(30); + }); + + it("clones into an independent box", () => { + const box = new Box3d(1, 2, 3, 4, 5, 6); + const copy = box.clone(); + copy.shift(100, 0, 0); + expect(box.pos.x).toEqual(1); + expect(copy.pos.x).toEqual(101); + }); + + it("contains() tests all three axes", () => { + const box = new Box3d(0, 0, 0, 10, 10, 10); + expect(box.contains(0, 0, 0)).toBe(true); + expect(box.contains(4, 4, 4)).toBe(true); + // inside in XY but outside in Z — the case a 2D shape cannot express + expect(box.contains(0, 0, 20)).toBe(false); + }); +}); + +describe("Box3d — narrowphase", () => { + let response; + + beforeEach(() => { + response = new ResponseObject(); + }); + + it("detects an overlap and reports a positive depth", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(5, 0, 0); + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(true); + expect(response.overlap).toBeCloseTo(5); + }); + + it("separates along Z alone — the contact a 2D shape cannot produce", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, 100); + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + // identical in XY, so any 2D test would call this a collision + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(false); + }); + + it("resolves along Z when Z is the shallowest axis", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, 8); + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(true); + expect(response.overlapNZ).toEqual(1); + expect(response.overlapZ).toBeCloseTo(2); + // the 2D half stays at zero, so a legacy 2D handler is inert, not wrong + expect(response.overlapN.x).toEqual(0); + expect(response.overlapN.y).toEqual(0); + expect(response.overlapV.x).toEqual(0); + expect(response.overlapV.y).toEqual(0); + }); + + it("leaves the Z half at zero when X or Y is the shallowest axis", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(8, 0, 0); + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(true); + expect(response.overlapN.x).toEqual(1); + expect(response.overlapNZ).toEqual(0); + expect(response.overlapZ).toEqual(0); + }); + + it("points the normal from a toward b on each axis", () => { + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + // b to the -Z side of a + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, -8); + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(true); + expect(response.overlapNZ).toEqual(-1); + expect(response.overlapZ).toBeCloseTo(-2); + }); + + it("never emits a zero normal for perfectly concentric boxes", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, 0); + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(true); + // a zero normal would make the push-out a no-op and leave the pair + // overlapping forever + const magnitude = + Math.abs(response.overlapN.x) + + Math.abs(response.overlapN.y) + + Math.abs(response.overlapNZ); + expect(magnitude).toEqual(1); + }); + + it("reports containment on all three axes", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, 0); + const small = new Box3d(0, 0, 0, 2, 2, 2); + const big = new Box3d(0, 0, 0, 20, 20, 20); + + expect(testBox3dBox3d(a, small, b, big, response.clear())).toBe(true); + expect(response.aInB).toBe(true); + expect(response.bInA).toBe(false); + }); + + it("does not report containment when only XY is enclosed", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, 0); + // thin in XY but DEEPER than the other box in Z + const tall = new Box3d(0, 0, 0, 2, 2, 100); + const big = new Box3d(0, 0, 0, 20, 20, 20); + + expect(testBox3dBox3d(a, tall, b, big, response.clear())).toBe(true); + expect(response.aInB).toBe(false); + }); + + it("honours the owning renderable's z position", () => { + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 0, 10, 10, 10); + + // same shape offsets, but the renderables are 100 apart in z + expect( + testBox3dBox3d( + makeRenderable(0, 0, 0), + boxA, + makeRenderable(0, 0, 100), + boxB, + response.clear(), + ), + ).toBe(false); + }); + + it("honours the shape's own z offset", () => { + const a = makeRenderable(0, 0, 0); + const b = makeRenderable(0, 0, 0); + const boxA = new Box3d(0, 0, 0, 10, 10, 10); + const boxB = new Box3d(0, 0, 100, 10, 10, 10); + + expect(testBox3dBox3d(a, boxA, b, boxB, response.clear())).toBe(false); + }); + + describe("differential sweep vs brute force", () => { + // The narrowphase contract here is EXACT, not conservative: unlike a + // broadphase it may neither miss a genuine overlap nor invent one. + // Both directions are aggregated so the report is a RATE — failing on + // the first mismatch hides how bad it is. + it("agrees with a brute-force AABB test over 4000 random pairs", () => { + const rand = lcg(0x5eed1476); + const PROBES = 4000; + const misses = []; + const falsePositives = []; + + for (let i = 0; i < PROBES; i++) { + const a = makeRenderable( + Math.round(rand() * 40 - 20), + Math.round(rand() * 40 - 20), + Math.round(rand() * 40 - 20), + ); + const b = makeRenderable( + Math.round(rand() * 40 - 20), + Math.round(rand() * 40 - 20), + Math.round(rand() * 40 - 20), + ); + const boxA = new Box3d( + Math.round(rand() * 10 - 5), + Math.round(rand() * 10 - 5), + Math.round(rand() * 10 - 5), + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + ); + const boxB = new Box3d( + Math.round(rand() * 10 - 5), + Math.round(rand() * 10 - 5), + Math.round(rand() * 10 - 5), + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + ); + + const expected = bruteOverlap(worldBox(a, boxA), worldBox(b, boxB)); + const actual = testBox3dBox3d(a, boxA, b, boxB, response.clear()); + + if (expected && !actual) { + misses.push(i); + } + if (!expected && actual) { + falsePositives.push(i); + } + } + + expect( + `${misses.length}/${PROBES} missed, ${falsePositives.length}/${PROBES} invented`, + ).toEqual(`0/${PROBES} missed, 0/${PROBES} invented`); + }); + + it("the reported MTV actually separates the pair, over 4000 random overlaps", () => { + // A depth that detects the overlap but points the wrong way (or is + // too short) still "passes" a boolean test — this is what catches it. + const rand = lcg(0xb0bed); + const PROBES = 4000; + let tested = 0; + const stillOverlapping = []; + + for (let i = 0; i < PROBES; i++) { + const a = makeRenderable( + Math.round(rand() * 20 - 10), + Math.round(rand() * 20 - 10), + Math.round(rand() * 20 - 10), + ); + const b = makeRenderable( + Math.round(rand() * 20 - 10), + Math.round(rand() * 20 - 10), + Math.round(rand() * 20 - 10), + ); + const boxA = new Box3d( + 0, + 0, + 0, + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + ); + const boxB = new Box3d( + 0, + 0, + 0, + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + 1 + Math.round(rand() * 20), + ); + + if (!testBox3dBox3d(a, boxA, b, boxB, response.clear())) { + continue; + } + tested++; + + // move `a` back along the reported MTV, exactly as + // `respondToCollision` does, then re-test with a hair of + // tolerance for float error + const moved = makeRenderable( + a.pos.x - response.overlapV.x, + a.pos.y - response.overlapV.y, + a.pos.z - response.overlapZ, + ); + const boxAw = worldBox(moved, boxA); + const boxBw = worldBox(b, boxB); + const EPS = 1e-9; + const shrunk = { + minX: boxAw.minX + EPS, + minY: boxAw.minY + EPS, + minZ: boxAw.minZ + EPS, + maxX: boxAw.maxX - EPS, + maxY: boxAw.maxY - EPS, + maxZ: boxAw.maxZ - EPS, + }; + if (bruteOverlap(shrunk, boxBw)) { + stillOverlapping.push(i); + } + } + + expect(tested).toBeGreaterThan(100); + expect( + `${stillOverlapping.length} of ${tested} still overlapping`, + ).toEqual(`0 of ${tested} still overlapping`); + }); + }); +}); + +describe("Box3d — mixing with 2D shapes", () => { + /** + * Every shape-type pair the detector can be handed must resolve to a + * test function. `SAT_LOOKUP` is a string-concat lookup and a missing + * entry used to throw a TypeError mid-step, taking the whole world + * update with it — so "does this pair even dispatch" is worth pinning + * for all of them, not just the new ones. + */ + const shapeFactories = { + Polygon: () => { + return new Polygon(0, 0, [ + new Vector2d(0, 0), + new Vector2d(16, 0), + new Vector2d(16, 16), + new Vector2d(0, 16), + ]); + }, + Rectangle: () => { + return new Rect(0, 0, 16, 16); + }, + RoundRect: () => { + return new RoundRect(0, 0, 16, 16, 4); + }, + Ellipse: () => { + return new Ellipse(8, 8, 16, 16); + }, + Box3d: () => { + return new Box3d(8, 8, 0, 16, 16, 16); + }, + }; + + it("dispatches every shape-type pair without throwing", () => { + const detector = new Detector({ + broadphase: { + retrieve: () => { + return []; + }, + }, + }); + const names = Object.keys(shapeFactories); + const failures = []; + + for (const nameA of names) { + for (const nameB of names) { + const rA = makeRenderable(0, 0, 0); + const rB = makeRenderable(4, 4, 0); + const bodyA = new Body(rA, shapeFactories[nameA]()); + const bodyB = new Body(rB, shapeFactories[nameB]()); + try { + detector.collides(bodyA, bodyB); + } catch (e) { + failures.push(`${nameA}/${nameB}: ${e.message}`); + } + } + } + + expect(failures).toEqual([]); + }); + + it("treats a planar shape as unbounded along Z", () => { + const detector = new Detector({ + broadphase: { + retrieve: () => { + return []; + }, + }, + }); + // box far away in Z; the rect has no depth to miss it with + const rA = makeRenderable(0, 0, 1000); + const rB = makeRenderable(0, 0, 0); + const bodyA = new Body(rA, new Box3d(8, 8, 0, 16, 16, 16)); + const bodyB = new Body(rB, new Rect(0, 0, 16, 16)); + + expect(detector.collides(bodyA, bodyB)).toBe(true); + // and it stays a 2D contact — no depth was resolved + expect(detector.response.overlapZ).toEqual(0); + expect(detector.response.overlapNZ).toEqual(0); + }); + + it("still separates two Box3d bodies by Z under the same detector", () => { + const detector = new Detector({ + broadphase: { + retrieve: () => { + return []; + }, + }, + }); + const rA = makeRenderable(0, 0, 1000); + const rB = makeRenderable(0, 0, 0); + const bodyA = new Body(rA, new Box3d(8, 8, 0, 16, 16, 16)); + const bodyB = new Body(rB, new Box3d(8, 8, 0, 16, 16, 16)); + + expect(detector.collides(bodyA, bodyB)).toBe(false); + }); +}); + +describe("Box3d — Body integration", () => { + it("raises hasDepth when a Box3d is added", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Rect(0, 0, 16, 16)); + expect(body.hasDepth).toBe(false); + + const box = new Box3d(0, 0, 0, 16, 16, 16); + body.addShape(box); + expect(body.hasDepth).toBe(true); + }); + + it("clears hasDepth when the last Box3d is removed", () => { + const r = makeRenderable(0, 0, 0); + const box = new Box3d(0, 0, 0, 16, 16, 16); + const body = new Body(r, box); + expect(body.hasDepth).toBe(true); + + body.removeShape(box); + expect(body.hasDepth).toBe(false); + }); + + it("keeps hasDepth while another Box3d remains", () => { + const r = makeRenderable(0, 0, 0); + const boxA = new Box3d(0, 0, 0, 16, 16, 16); + const boxB = new Box3d(32, 0, 0, 16, 16, 16); + const body = new Body(r, [boxA, boxB]); + + body.removeShape(boxA); + expect(body.hasDepth).toBe(true); + }); + + it("destroy() recycles a Box3d instead of throwing", () => { + // `Body.destroy` releases each shape to its own pool and falls back to + // the legacy `pool.push`, which THROWS for any class never registered + // with it. A Box3d hitting that fallback aborts destroy() partway — + // after `boundsPool.release(this.bounds)` has already run — leaving the + // body holding a recycled Bounds that blows up in the broadphase on a + // later frame, nowhere near the real cause. + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + expect(() => { + body.destroy(); + }).not.toThrow(); + // destroy ran to completion rather than aborting mid-way + expect(body.bounds).toBeUndefined(); + expect(body.shapes.length).toEqual(0); + }); + + it("destroy() still recycles a mixed shape list", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, [ + new Box3d(0, 0, 0, 16, 16, 16), + new Rect(0, 0, 16, 16), + ]); + expect(() => { + body.destroy(); + }).not.toThrow(); + expect(body.bounds).toBeUndefined(); + }); + + it("folds a Box3d's XY footprint into the body bounds", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 20, 10, 40)); + expect(body.getBounds().width).toEqual(20); + expect(body.getBounds().height).toEqual(10); + }); + + it("integrates velZ into the ancestor position", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + body.velZ = 3; + body.update(); + expect(r.pos.z).toBeGreaterThan(0); + }); + + it("applies forceZ to velZ", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + body.forceZ = 2; + body.update(); + expect(body.velZ).toBeGreaterThan(0); + }); + + it("caps velZ at maxVelZ", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + body.maxVelZ = 5; + body.velZ = 1000; + body.update(); + expect(body.velZ).toEqual(5); + }); + + it("pushes back along Z in respondToCollision", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + body.isStatic = false; + + const response = new ResponseObject(); + response.a = r; + response.b = makeRenderable(0, 0, 10); + response.overlap = 4; + response.overlapNZ = 1; + response.overlapZ = 4; + body.velZ = 10; + + body.respondToCollision(response); + expect(r.pos.z).toEqual(-4); + // velocity into the surface is cancelled + expect(body.velZ).toEqual(0); + }); + + it("setMaxVelocity / setFriction leave the Z axis alone when omitted", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + body.maxVelZ = 42; + body.frictionZ = 7; + + // the two-argument form every existing game uses + body.setMaxVelocity(1, 2); + body.setFriction(3, 4); + + expect(body.maxVelZ).toEqual(42); + expect(body.frictionZ).toEqual(7); + expect(body.maxVel.x).toEqual(1); + expect(body.friction.x).toEqual(3); + }); + + it("setMaxVelocity / setFriction write Z when given it", () => { + const r = makeRenderable(0, 0, 0); + const body = new Body(r, new Box3d(0, 0, 0, 16, 16, 16)); + body.setMaxVelocity(1, 2, 3); + body.setFriction(4, 5, 6); + expect(body.maxVelZ).toEqual(3); + expect(body.frictionZ).toEqual(6); + }); +}); + +describe("Box3d — 2D backward compatibility", () => { + // The headline guarantee of #1476: a game that never mentions Box3d + // cannot tell that any of this landed. + + it("leaves the Z half of the response at zero for every planar pair", () => { + const detector = new Detector({ + broadphase: { + retrieve: () => { + return []; + }, + }, + }); + const pairs = [ + [new Rect(0, 0, 16, 16), new Rect(0, 0, 16, 16)], + [new Ellipse(8, 8, 16, 16), new Ellipse(8, 8, 16, 16)], + [new Rect(0, 0, 16, 16), new Ellipse(8, 8, 16, 16)], + [new RoundRect(0, 0, 16, 16, 4), new Rect(0, 0, 16, 16)], + ]; + + for (const [shapeA, shapeB] of pairs) { + const bodyA = new Body(makeRenderable(0, 0, 0), shapeA); + const bodyB = new Body(makeRenderable(4, 4, 0), shapeB); + expect(detector.collides(bodyA, bodyB)).toBe(true); + expect(detector.response.overlapZ).toEqual(0); + expect(detector.response.overlapNZ).toEqual(0); + } + }); + + it("a body with no Box3d never moves in Z under respondToCollision", () => { + const r = makeRenderable(0, 0, 7); + const body = new Body(r, new Rect(0, 0, 16, 16)); + body.isStatic = false; + + const response = new ResponseObject(); + response.a = r; + response.b = makeRenderable(8, 0, 7); + response.overlap = 4; + response.overlapN.set(1, 0); + response.overlapV.set(4, 0); + + body.respondToCollision(response); + expect(r.pos.x).toEqual(-4); + // z untouched — the field defaults keep the new arithmetic inert + expect(r.pos.z).toEqual(7); + }); + + it("respondToCollision survives a response literal with no Z fields", () => { + // `respondToCollision` is public and DUCK-TYPED — callers hand it a + // plain object carrying only overlapV/overlapN. Reading the new Z + // fields off one of those gives `undefined`, and a single + // `vel * undefined` poisons projVel to NaN, which fails the + // `projVel > 0` gate and silently stops cancelling velocity into the + // surface. This is how it reaches real user code, so this is the + // shape the regression test uses. + const r = makeRenderable(0, 0, 5); + const body = new Body(r, new Rect(0, 0, 32, 32)); + body.isStatic = false; + body.vel.set(8, 0); + + body.respondToCollision({ + a: r, + b: makeRenderable(16, 0, 5), + overlapV: { x: 2, y: 0 }, + overlapN: { x: 1, y: 0 }, + }); + + expect(Number.isNaN(body.vel.x)).toBe(false); + expect(Number.isNaN(r.pos.z)).toBe(false); + expect(r.pos.z).toEqual(5); + // velocity into the surface was actually cancelled + expect(body.vel.x).toBeCloseTo(0); + }); + + it("a 2D body's update() leaves z untouched", () => { + const r = makeRenderable(0, 0, 12); + const body = new Body(r, new Rect(0, 0, 16, 16)); + body.vel.set(3, 4); + body.update(); + expect(r.pos.z).toEqual(12); + }); + + it("clear() resets the Z half so it cannot leak between tests", () => { + const response = new ResponseObject(); + response.overlapZ = 99; + response.overlapNZ = -1; + response.clear(); + expect(response.overlapZ).toEqual(0); + expect(response.overlapNZ).toEqual(0); + }); + + it("a planar contact after a Z contact reports no Z push", () => { + // the leak the clear() above exists to prevent, end to end + const detector = new Detector({ + broadphase: { + retrieve: () => { + return []; + }, + }, + }); + + const box3dA = new Body( + makeRenderable(0, 0, 0), + new Box3d(0, 0, 0, 16, 16, 16), + ); + const box3dB = new Body( + makeRenderable(0, 0, 8), + new Box3d(0, 0, 0, 16, 16, 16), + ); + expect(detector.collides(box3dA, box3dB)).toBe(true); + expect(detector.response.overlapZ).not.toEqual(0); + + const flatA = new Body(makeRenderable(0, 0, 0), new Rect(0, 0, 16, 16)); + const flatB = new Body(makeRenderable(4, 0, 0), new Rect(0, 0, 16, 16)); + expect(detector.collides(flatA, flatB)).toBe(true); + expect(detector.response.overlapZ).toEqual(0); + }); +}); diff --git a/packages/melonjs/tests/gltf-image-decode.spec.js b/packages/melonjs/tests/gltf-image-decode.spec.js new file mode 100644 index 000000000..30196d1c8 --- /dev/null +++ b/packages/melonjs/tests/gltf-image-decode.spec.js @@ -0,0 +1,212 @@ +/** + * glTF images must decode to an `ImageBitmap`, like every other texture the + * loader produces (`parsers/image.js` already does this). + * + * The loader used to resolve an `HTMLImageElement` instead. That is fine for + * an RGBA source, but WebKit's `copyExternalImageToTexture` uploads an + * `HTMLImageElement` decoded from an INDEXED PNG (`colorType` 3) as its raw + * palette INDICES, so the texture arrives greyscale (r == g == b) with the + * geometry untouched. Chrome's Dawn expands the palette, so it only ever + * showed up in Safari; WebGL2's `texImage2D` is unaffected, so it looked like + * a WebGPU regression. + * + * Nothing in the WebGPU API can correct this at upload time — `flipY`, + * `premultipliedAlpha` and `colorSpace` are the only knobs, and expanding a + * palette belongs to the decoder. So the fix belongs at decode, and this pins + * it there. + */ +import { describe, expect, it } from "vitest"; +import { parseGLTF } from "../src/loader/parsers/gltf.js"; + +/** CRC32 over `bytes`, as PNG chunks require. */ +function crc32(bytes) { + let c = ~0; + for (let i = 0; i < bytes.length; i++) { + c ^= bytes[i]; + for (let k = 0; k < 8; k++) { + c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); + } + } + return ~c >>> 0; +} + +function chunk(type, data) { + const name = new TextEncoder().encode(type); + const body = new Uint8Array(name.length + data.length); + body.set(name, 0); + body.set(data, name.length); + const out = new Uint8Array(8 + data.length + 4); + new DataView(out.buffer).setUint32(0, data.length); + out.set(body, 4); + new DataView(out.buffer).setUint32(out.length - 4, crc32(body)); + return out; +} + +/** + * A 2x2 PALETTE (colorType 3) PNG — the encoding that broke. Two entries: + * pure red and pure blue, so a correct decode can never come back greyscale. + */ +function indexedPNG() { + const ihdr = new Uint8Array(13); + const dv = new DataView(ihdr.buffer); + dv.setUint32(0, 2); // width + dv.setUint32(4, 2); // height + ihdr[8] = 8; // bit depth + ihdr[9] = 3; // colorType 3 = PALETTE + // raw scanlines: filter byte + one index per pixel + const raw = new Uint8Array([0, 0, 1, 0, 1, 0]); + // stored (uncompressed) zlib stream, so no deflate implementation is needed + const len = raw.length; + const idat = new Uint8Array(2 + 5 + len + 4); + idat[0] = 0x78; + idat[1] = 0x01; + idat[2] = 0x01; + idat[3] = len & 0xff; + idat[4] = (len >> 8) & 0xff; + idat[5] = ~len & 0xff; + idat[6] = (~len >> 8) & 0xff; + idat.set(raw, 7); + let a = 1; + let b = 0; + for (const byte of raw) { + a = (a + byte) % 65521; + b = (b + a) % 65521; + } + new DataView(idat.buffer).setUint32(idat.length - 4, ((b << 16) | a) >>> 0); + + const parts = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", ihdr), + chunk("PLTE", new Uint8Array([255, 0, 0, 0, 0, 255])), + chunk("IDAT", idat), + chunk("IEND", new Uint8Array(0)), + ]; + const total = parts.reduce((n, p) => { + return n + p.length; + }, 0); + const png = new Uint8Array(total); + let o = 0; + for (const p of parts) { + png.set(p, o); + o += p.length; + } + return png; +} + +/** a minimal GLB whose single texture is that indexed PNG, embedded */ +function buildTexturedGLB() { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const uvs = new Float32Array([0, 0, 1, 0, 0, 1]); + const indices = new Uint16Array([0, 1, 2]); + const png = indexedPNG(); + + const pad = (n) => { + return (4 - (n % 4)) % 4; + }; + const parts = [positions, uvs, indices, png]; + let offset = 0; + const offsets = parts.map((p) => { + const at = offset; + offset += p.byteLength + pad(p.byteLength); + return at; + }); + const bin = new Uint8Array(offset); + parts.forEach((p, i) => { + bin.set( + new Uint8Array(p.buffer ?? p, p.byteOffset ?? 0, p.byteLength), + offsets[i], + ); + }); + + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0, TEXCOORD_0: 1 }, + indices: 2, + material: 0, + }, + ], + }, + ], + materials: [{ pbrMetallicRoughness: { baseColorTexture: { index: 0 } } }], + textures: [{ source: 0 }], + images: [{ bufferView: 3, mimeType: "image/png" }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5126, count: 3, type: "VEC2" }, + { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: parts.map((p, i) => { + return { + buffer: 0, + byteOffset: offsets[i], + byteLength: p.byteLength, + }; + }), + buffers: [{ byteLength: bin.byteLength }], + }; + + const jsonBytes = new TextEncoder().encode(JSON.stringify(json)); + const jsonPad = new Uint8Array(jsonBytes.length + pad(jsonBytes.length)).fill( + 0x20, + ); + jsonPad.set(jsonBytes); + + const total = 12 + 8 + jsonPad.length + 8 + bin.length; + const glb = new Uint8Array(total); + const dv = new DataView(glb.buffer); + dv.setUint32(0, 0x46546c67, true); + dv.setUint32(4, 2, true); + dv.setUint32(8, total, true); + dv.setUint32(12, jsonPad.length, true); + dv.setUint32(16, 0x4e4f534a, true); + glb.set(jsonPad, 20); + dv.setUint32(20 + jsonPad.length, bin.length, true); + dv.setUint32(24 + jsonPad.length, 0x004e4942, true); + glb.set(bin, 28 + jsonPad.length); + return glb.buffer; +} + +describe("glTF image decode", () => { + it("decodes an embedded texture to an ImageBitmap, not an HTMLImageElement", async () => { + const scene = await parseGLTF(buildTexturedGLB()); + const image = scene.nodes.find((n) => { + return n.image !== undefined; + })?.image; + + expect(image).toBeDefined(); + // the assertion that fails if the loader goes back to `new Image()` + expect(image).toBeInstanceOf(ImageBitmap); + expect(image).not.toBeInstanceOf(HTMLImageElement); + }); + + it("expands an INDEXED (palette) PNG to real colour", async () => { + // the actual defect: a palette source must not survive as luminance. + // Drawing the decoded bitmap and reading it back proves the palette + // was applied — red and blue, never r == g == b. + const scene = await parseGLTF(buildTexturedGLB()); + const image = scene.nodes.find((n) => { + return n.image !== undefined; + })?.image; + + const canvas = document.createElement("canvas"); + canvas.width = 2; + canvas.height = 2; + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + const px = ctx.getImageData(0, 0, 2, 2).data; + + const first = [px[0], px[1], px[2]]; + const second = [px[4], px[5], px[6]]; + // palette entry 0 is red, entry 1 is blue — greyscale would collapse + // both to r == g == b + expect(first[0]).toBeGreaterThan(first[2]); + expect(second[2]).toBeGreaterThan(second[0]); + }); +}); diff --git a/packages/melonjs/tests/gltf-srgb.spec.js b/packages/melonjs/tests/gltf-srgb.spec.js new file mode 100644 index 000000000..93b6b1d94 --- /dev/null +++ b/packages/melonjs/tests/gltf-srgb.spec.js @@ -0,0 +1,143 @@ +/** + * glTF `baseColorFactor` is LINEAR; a melonJS tint is 8-bit sRGB. + * + * The loader used to hand the linear number straight to `tint.setColor(f*255)`, + * which rendered every untextured glTF material far too light and desaturated + * — an authored mid-green came out as pale mint. Surfaced by the 2.5D + * platformer's road, whose authored colour did not survive the round trip. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, GLTFModel, video } from "../src/index.js"; +import { linearToSrgb8 } from "../src/level/gltf/srgb.js"; + +describe("glTF linear → sRGB tint encode", () => { + it("maps the endpoints exactly", () => { + expect(linearToSrgb8(0)).toEqual(0); + expect(linearToSrgb8(1)).toEqual(255); + }); + + it("lightens mid-tones the way the sRGB transfer function does", () => { + // linear 0.5 is sRGB ~0.7354 → 188. The old `f * 255` gave 128. + expect(linearToSrgb8(0.5)).toEqual(188); + expect(linearToSrgb8(0.5)).not.toEqual(Math.round(0.5 * 255)); + }); + + it("round-trips the value the road material is authored at", () => { + // sRGB 0.29 stored as linear 0.0684 must come back out as ~0.29 + expect(linearToSrgb8(0.0684)).toBeCloseTo(0.29 * 255, -0.5); + expect(linearToSrgb8(0.3931)).toBeCloseTo(0.66 * 255, -0.5); + }); + + it("uses the linear segment near black", () => { + // below the 0.0031308 knee the curve is a plain 12.92x ramp + expect(linearToSrgb8(0.002)).toEqual(Math.round(0.002 * 12.92 * 255)); + }); + + it("clamps out-of-range factors instead of returning NaN", () => { + // `Math.pow` on a negative base is NaN, which would poison the tint; + // exporters do occasionally emit slightly out-of-range factors + expect(linearToSrgb8(-0.2)).toEqual(0); + expect(linearToSrgb8(1.4)).toEqual(255); + expect(Number.isNaN(linearToSrgb8(-0.2))).toBe(false); + }); + + it("is monotonic across the range", () => { + let prev = -1; + for (let i = 0; i <= 20; i++) { + const v = linearToSrgb8(i / 20); + expect(v).toBeGreaterThanOrEqual(prev); + prev = v; + } + }); +}); + +/** + * The tests above only exercise the helper — they would ALL still pass if the + * loader stopped calling it. These pin the wiring: a material factor has to + * arrive on the mesh tint sRGB-encoded, which is the thing that was actually + * broken. + */ +describe("glTF loader applies the encode to the mesh tint", () => { + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + /** one-triangle node carrying `factor` as its baseColorFactor */ + const modelWith = (factor) => { + return new GLTFModel( + { + bounds: { min: [-1, -1, -1], max: [1, 1, 1] }, + graph: { + roots: [0], + nodes: { + 0: { + index: 0, + name: "solid", + translation: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + matrix: null, + children: [], + primitives: [ + { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 0, 0, 0, 0]), + indices: new Uint16Array([0, 1, 2]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), + vertexCount: 3, + baseColorFactor: factor, + colors: undefined, + doubleSided: false, + }, + ], + }, + }, + }, + animations: [], + }, + { scale: 1, rightHanded: false }, + ); + }; + + const tintOf = (factor) => { + const mesh = modelWith(factor).getChildByName("solid")[0]; + return [mesh.tint.r, mesh.tint.g, mesh.tint.b]; + }; + + it("encodes a mid-tone factor rather than scaling it by 255", () => { + // linear 0.5 → sRGB 188, NOT 128. This is the assertion that fails if + // the loader goes back to `Math.round(f * 255)`. + expect(tintOf([0.5, 0.5, 0.5, 1])).toEqual([188, 188, 188]); + }); + + it("carries the road material's authored green through intact", () => { + // the case that surfaced the bug: linear 0.0684 must land near sRGB + // 0.29 (74), not at 17 + const [r, g, b] = tintOf([0.0684, 0.3931, 0.0783, 1]); + expect(r).toBeCloseTo(74, -0.7); + expect(g).toBeCloseTo(168, -0.7); + expect(b).toBeCloseTo(79, -0.7); + }); + + it("leaves white and black exactly at the endpoints", () => { + expect(tintOf([1, 1, 1, 1])).toEqual([255, 255, 255]); + expect(tintOf([0, 0, 0, 1])).toEqual([0, 0, 0]); + }); + + it("survives an out-of-range factor without NaN-ing the tint", () => { + const tint = tintOf([-0.1, 1.3, 0.5, 1]); + expect( + tint.every((c) => { + return Number.isFinite(c); + }), + ).toBe(true); + expect(tint[0]).toEqual(0); + expect(tint[1]).toEqual(255); + }); +}); diff --git a/packages/melonjs/tests/raycast3d-box3d.spec.js b/packages/melonjs/tests/raycast3d-box3d.spec.js new file mode 100644 index 000000000..6549996b3 --- /dev/null +++ b/packages/melonjs/tests/raycast3d-box3d.spec.js @@ -0,0 +1,247 @@ +/** + * `raycast3d` against a `Box3d` body — exact ray-vs-AABB instead of the + * bounding-sphere approximation (#1476). + * + * This is what makes floor-height probing usable: the sphere path derives + * its radius from `getBounds()` width/height only — a 2D bounds with NO z + * extent — so a wide flat floor slab reads as a huge sphere and reports a + * hit well above its actual surface. The box path reports the surface. + * + * Renderables WITHOUT a Box3d body keep the sphere path untouched, so no + * existing raycast3d result changes. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { + Application, + Body, + Box3d, + boot, + Renderable, + video, + World, +} from "../src/index.js"; + +/** + * A renderable centred on its position, carrying a `Box3d` body, added to + * `world` at depth `z`. + * + * Body attached BEFORE `addChild`, which is what registers it with the + * physics adapter (it reads `child.body` at insertion time). `raycast3d` + * itself walks the Octree rather than the adapter's body set, so it would + * work either way here — the order is kept correct so the helper isn't a + * pattern worth copying into something that does need the registration. + * + * `addChild(child, z)` also sets pos.z atomically — assigning depth + * afterwards would be overwritten by `Container.autoDepth`. + */ +function addBoxBody(world, { x, y, z, w, h, d }) { + const r = new Renderable(x, y, w, h); + r.anchorPoint.set(0.5, 0.5); + r.isKinematic = false; + r.body = new Body(r, new Box3d(0, 0, 0, w, h, d)); + world.addChild(r, z); + return r; +} + +describe("raycast3d — exact ray vs Box3d", () => { + beforeAll(async () => { + boot(); + const app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + /** + * Build a world with one Box3d-bodied renderable in it. + * Returns { world, target }. + */ + function worldWithBox(spec) { + const world = new World(0, 0, 800, 600); + world.sortOn = "depth"; + const target = addBoxBody(world, spec); + // force the world's per-frame broadphase rebuild so the target + // actually lands in the Octree before the ray is cast + world.update(16); + return { world, target }; + } + + it("reports the surface of the box, not a circumscribed sphere", () => { + // A wide, flat slab: 200 wide, 20 tall, 200 deep, centred at y=100. + // Its top face is at y = 90. The bounding-sphere radius derived from + // getBounds() would be √(200² + 20²)/2 ≈ 100.5, which would report a + // hit around y ≈ 0 — 90 units above the actual surface. + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 200, + h: 20, + d: 200, + }); + + const hit = world.adapter.raycast3d( + { x: 100, y: 0, z: 100 }, + { x: 100, y: 200, z: 100 }, + ); + + expect(hit).not.toBeNull(); + expect(hit.point.y).toBeCloseTo(90, 5); + }); + + it("reports the face normal of the entered slab", () => { + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 200, + h: 20, + d: 200, + }); + + // straight down onto the top face → normal points up (-Y, Y-down space) + const hit = world.adapter.raycast3d( + { x: 100, y: 0, z: 100 }, + { x: 100, y: 200, z: 100 }, + ); + expect(hit.normal.x).toEqual(0); + expect(hit.normal.y).toEqual(-1); + expect(hit.normal.z).toEqual(0); + }); + + it("reports the face normal when entering along Z", () => { + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 40, + h: 40, + d: 40, + }); + + const hit = world.adapter.raycast3d( + { x: 100, y: 100, z: 0 }, + { x: 100, y: 100, z: 200 }, + ); + expect(hit.normal.z).toEqual(-1); + expect(hit.point.z).toBeCloseTo(80, 5); + }); + + it("misses a box the ray passes beside", () => { + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 20, + h: 20, + d: 20, + }); + + // offset well outside the 20-wide box, but inside the radius a + // bounding sphere would have used + const hit = world.adapter.raycast3d( + { x: 130, y: 0, z: 100 }, + { x: 130, y: 200, z: 100 }, + ); + expect(hit).toBeNull(); + }); + + it("misses a box that is behind the ray origin", () => { + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 40, + h: 40, + d: 40, + }); + + const hit = world.adapter.raycast3d( + { x: 100, y: 200, z: 100 }, + { x: 100, y: 400, z: 100 }, + ); + expect(hit).toBeNull(); + }); + + it("misses a box past the segment end", () => { + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 40, + h: 40, + d: 40, + }); + + // segment stops at y = 50, box top face is at y = 80 + const hit = world.adapter.raycast3d( + { x: 100, y: 0, z: 100 }, + { x: 100, y: 50, z: 100 }, + ); + expect(hit).toBeNull(); + }); + + it("reports fraction 0 when the origin is inside the box", () => { + const { world } = worldWithBox({ + x: 100, + y: 100, + z: 100, + w: 40, + h: 40, + d: 40, + }); + + const hit = world.adapter.raycast3d( + { x: 100, y: 100, z: 100 }, + { x: 100, y: 300, z: 100 }, + ); + expect(hit).not.toBeNull(); + expect(hit.fraction).toEqual(0); + }); + + it("returns the nearest of several boxes", () => { + const world = new World(0, 0, 800, 600); + world.sortOn = "depth"; + + const near = addBoxBody(world, { + x: 100, + y: 100, + z: 100, + w: 40, + h: 40, + d: 40, + }); + addBoxBody(world, { x: 100, y: 300, z: 100, w: 40, h: 40, d: 40 }); + + world.update(16); + + const hit = world.adapter.raycast3d( + { x: 100, y: 0, z: 100 }, + { x: 100, y: 400, z: 100 }, + ); + expect(hit).not.toBeNull(); + expect(hit.renderable).toBe(near); + }); + + it("still uses the bounding sphere for a body with no Box3d", () => { + // unchanged legacy behaviour: a plain renderable is approximated by + // its circumradius, so a ray that misses the 40x40 box but stays + // inside the ~28.3 radius still reports a hit + const world = new World(0, 0, 800, 600); + world.sortOn = "depth"; + const target = new Renderable(100, 100, 40, 40); + target.anchorPoint.set(0.5, 0.5); + target.isKinematic = false; + world.addChild(target, 100); + world.update(16); + + const hit = world.adapter.raycast3d( + { x: 100, y: 100, z: 0 }, + { x: 100, y: 100, z: 200 }, + ); + expect(hit).not.toBeNull(); + expect(hit.renderable).toBe(target); + }); +});