diff --git a/packages/examples/public/assets/gltf/forest.glb b/packages/examples/public/assets/gltf/forest.glb new file mode 100644 index 000000000..2aafbe895 Binary files /dev/null and b/packages/examples/public/assets/gltf/forest.glb differ diff --git a/packages/examples/src/examples/forest/ExampleForest.tsx b/packages/examples/src/examples/forest/ExampleForest.tsx new file mode 100644 index 000000000..f891347b2 --- /dev/null +++ b/packages/examples/src/examples/forest/ExampleForest.tsx @@ -0,0 +1,254 @@ +/** + * melonJS — mesh instancing example (#1508). + * + * A forest of 100 000 trees drawn from ONE copy of the geometry, in a single + * draw call. Nothing here builds the instances: the `.glb` carries them as + * `EXT_mesh_gpu_instancing` per-instance transforms, so `level.load()` + * produces an `InstancedMesh` on its own — the same one call a Tiled map + * takes. + * + * The `visibleInstanceCount` slider is the cheap level-of-detail knob: + * moving it changes how many trees are drawn without re-uploading anything. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + Application, + Camera3d as Camera3dClass, + type CanvasRenderer, + type InstancedMesh, + input, + level, + loader, + type Pointer, + plugin, + Renderable, + state, + video, + type WebGLRenderer, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +const base = `${import.meta.env.BASE_URL}assets/gltf/`; + +// pixels per glTF unit +const SCALE = 26; + +/** A dusk sky, drawn screen-fixed behind the scene. */ +function bakeSky() { + const c = document.createElement("canvas"); + c.width = 1; + c.height = 512; + const ctx = c.getContext("2d"); + if (ctx) { + const g = ctx.createLinearGradient(0, 0, 0, 512); + g.addColorStop(0, "#1b3a63"); + g.addColorStop(0.55, "#6f8fb5"); + g.addColorStop(1, "#e5c9a3"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 1, 512); + } + return c; +} + +class SkyBackdrop extends Renderable { + private sky = bakeSky(); + + constructor() { + super(0, 0, 1, 1); + this.floating = true; // screen-space — exempt from the perspective camera + this.anchorPoint.set(0, 0); + } + + override draw(renderer: CanvasRenderer | WebGLRenderer) { + renderer.drawImage( + this.sky, + 0, + 0, + 1, + 512, + 0, + 0, + renderer.width, + renderer.height, + ); + } +} + +const createGame = async () => { + let app: Application; + try { + app = new Application(1024, 768, { + parent: "screen", + renderer: video.AUTO, + scale: "auto", + cameraClass: Camera3dClass, + antiAlias: true, + }); + await app.init(); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + globalThis.alert( + "This example couldn't start: no GPU renderer is available.\n\n" + + `Details: ${reason}`, + ); + throw err; + } + + plugin.register(DebugPanelPlugin, "debugPanel"); + + let torndown = false; + let pointerCleanup: (() => void) | null = null; + let domCleanup: (() => void) | null = null; + + const setupScene = () => { + if (torndown) { + return; + } + // The sky is a screen-space backdrop, so it has to be drawn BEFORE + // the meshes. Under `Camera3d` the world sorts on depth, and this + // camera looks along +Z — so "behind everything" is the largest + // depth, not the smallest. A negative z here reads as *nearest* and + // paints the whole forest over, which looks exactly like nothing + // rendering at all. + app.world.addChild(new SkyBackdrop(), 100000); + + // the loader turned the instanced node into an InstancedMesh — find it + // so the slider can drive `visibleInstanceCount` + const trees = app.world.getChildByName("Trees")[0] as + | InstancedMesh + | undefined; + + const camera = app.viewport as InstanceType; + // the ground runs to ±12 000 px at this scale, so the far plane has + // to reach past the diagonal or the tree line is clipped mid-scene + camera.setClipPlanes(SCALE, 70000); + + // Framed from INSIDE the forest rather than above it. The trees are + // only ~3 units tall against a scatter 300 units across, so a camera + // placed by "distance from the centre" ends up tens of tree-heights + // up and the whole thing reads as a lawn seen from a plane. Eye + // height and horizontal distance are therefore separate: the eye sits + // just under the canopy and looks level, which is what makes it a + // forest view. + let yaw = -0.54; + let eyeHeight = 420; // px — just above the canopy, looking out over it + let distance = 9000; // px out from the centre, horizontally + const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + + const updateCam = () => { + distance = clamp(distance, 900, 18000); + eyeHeight = clamp(eyeHeight, 60, 4000); + // up is -Y in render space + camera.pos.set(Math.sin(yaw) * -distance, -eyeHeight); + camera.depth = -Math.cos(yaw) * distance; + // look level, at the far tree line rather than down at the floor + camera.lookAt(0, -eyeHeight * 0.92, 0); + }; + updateCam(); + + // drag to orbit — screen coordinates, not world ones: orbiting moves + // the camera every frame, so a world-projected pixel would map + // somewhere new on each move and the drag would jump + const ORBIT_SENSITIVITY = 0.0022; + let dragging = false; + let lastX = 0; + let lastY = 0; + input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => { + dragging = true; + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + }); + input.registerPointerEvent("pointerup", camera, () => { + dragging = false; + }); + input.registerPointerEvent("pointermove", camera, (ev: Pointer) => { + if (!dragging) { + return; + } + yaw += (ev.gameScreenX - lastX) * ORBIT_SENSITIVITY; + // vertical drag rises above the canopy instead of tilting, so the + // view stays a forest view at every height + eyeHeight += (ev.gameScreenY - lastY) * 6; + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + updateCam(); + }); + pointerCleanup = () => { + input.releasePointerEvent("pointerdown", camera); + input.releasePointerEvent("pointerup", camera); + input.releasePointerEvent("pointermove", camera); + }; + + // the LOD knob: how many of the instances to draw + const total = trees?.instanceCount ?? 0; + const panel = document.createElement("div"); + panel.style.cssText = + "position:absolute;top:60px;left:16px;z-index:1000;" + + "font-family:sans-serif;font-size:12px;color:#f0e6d8;" + + "text-shadow:0 1px 2px rgba(0,0,0,0.7);"; + const readout = document.createElement("div"); + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = String(total); + slider.value = String(total); + slider.style.cssText = "width:220px;display:block;margin-top:6px;"; + const render = () => { + readout.textContent = + `${slider.value} / ${total} trees · 1 geometry · 1 draw call` + + (trees ? "" : " (instanced node not found)"); + }; + slider.addEventListener("input", () => { + if (trees) { + // no re-upload: the records stay put, the draw is just shorter + trees.visibleInstanceCount = Number(slider.value); + } + render(); + }); + render(); + panel.appendChild(readout); + panel.appendChild(slider); + + const hint = document.createElement("div"); + hint.textContent = "drag to orbit"; + hint.style.cssText = "margin-top:8px;opacity:0.75;"; + panel.appendChild(hint); + + const parent = app.renderer.getCanvas().parentElement; + if (parent) { + parent.style.position = "relative"; + parent.appendChild(panel); + } + domCleanup = () => { + panel.remove(); + }; + }; + + loader.preload( + [{ name: "forest", type: "glb", src: `${base}forest.glb` }], + () => { + state.change(state.DEFAULT, true); + // one call — the instanced node becomes an InstancedMesh, the + // ground stays an ordinary Mesh, and the authored sun lights both + level.load("forest", { scale: SCALE, onLoaded: setupScene }); + }, + ); + + return () => { + // Deliberately NOT app.destroy(): the example host re-parents the + // existing canvas on a same-example remount and assumes the engine is + // still alive, so destroying here leaves a dead canvas and a blank + // screen on the second visit. `torndown` covers the other order — an + // unmount while the 3 MB glb is still loading, after which setupScene + // must not register handlers nobody will ever remove. + torndown = true; + pointerCleanup?.(); + domCleanup?.(); + }; +}; + +export const ExampleForest = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 0e9e1e989..a40412819 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -128,6 +128,11 @@ const ExampleGltf = lazy(() => default: m.ExampleGltf, })), ); +const ExampleForest = lazy(() => + import("./examples/forest/ExampleForest").then((m) => ({ + default: m.ExampleForest, + })), +); const ExampleGltfCharacter = lazy(() => import("./examples/gltf/ExampleGltfCharacter").then((m) => ({ default: m.ExampleGltfCharacter, @@ -440,6 +445,14 @@ const examples: { description: "A rigged blocky character (Kenney, CC0) loaded from GLB, using node-TRS animation over a rigid hierarchy.", }, + { + component: , + label: "Instanced Forest", + path: "forest", + sourceDir: "forest", + description: + "100 000 trees drawn from one geometry in a single call, scattered by the glTF asset itself.", + }, { component: , label: "Night City Flythrough", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index a4ff6106e..c4fce71ab 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -12,6 +12,8 @@ - **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 +- **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 diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 5bcbdb5d9..6044c103c 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -40,6 +40,7 @@ import { DropTarget } from "./renderable/dragndrop.js"; import Entity from "./renderable/entity/entity.js"; import FrameAnimation from "./renderable/frameAnimation.js"; import ImageLayer from "./renderable/imagelayer.js"; +import InstancedMesh from "./renderable/instanced_mesh.js"; import Mesh from "./renderable/mesh.js"; import NineSliceSprite from "./renderable/nineslicesprite.js"; import Renderable from "./renderable/renderable.js"; @@ -202,6 +203,7 @@ export { Gradient, HologramEffect, ImageLayer, + InstancedMesh, InvertEffect, isPortableTopology, isTopology, diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index c27da66b9..6cef3f10e 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -5,7 +5,9 @@ import { } from "../../loader/parsers/gltf.js"; import { parseAnimationOptions } from "../../renderable/animation.ts"; import Container from "../../renderable/container.js"; +import InstancedMesh from "../../renderable/instanced_mesh.js"; import Mesh from "../../renderable/mesh.js"; +import { fillInstances } from "./GLTFScene.js"; import { sampleChannel } from "./gltf_sampler.js"; /** @@ -122,7 +124,11 @@ export default class GLTFModel extends Container { this._world[idx] = new Array(16); for (const prim of node.primitives) { - const mesh = new Mesh(0, 0, { + // an instanced node inside an ANIMATED asset still instances: + // the node's own TRS is animated, the records place the copies + // within it + const MeshClass = prim.instances ? InstancedMesh : Mesh; + const mesh = new MeshClass(0, 0, { vertices: prim.vertices, uvs: prim.uvs, indices: prim.indices, @@ -147,6 +153,9 @@ export default class GLTFModel extends Container { // thin/flat double-sided parts must not be back-face culled cullBackFaces: prim.doubleSided !== true, }); + if (prim.instances) { + fillInstances(mesh, prim.instances); + } const f = prim.baseColorFactor; if (f) { mesh.tint.setColor( diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index e1479c3b5..f4e4f6201 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -1,7 +1,9 @@ import { Light3d } from "../../lighting/light3d.ts"; import { getGLTF } from "../../loader/loader.js"; import { boundingRadius } from "../../math/vertex.ts"; +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"; /** @@ -145,7 +147,16 @@ export default class GLTFScene { // exactly `radius`. Guard against a zero-size (point) box. const boxSize = Math.max(radius, 1) * Math.SQRT2; - const mesh = new Mesh(0, 0, { + // EXT_mesh_gpu_instancing: the node is one geometry stamped out + // many times (a scattered forest, a crowd). One InstancedMesh + // draws the lot in a single call instead of N separate meshes, + // each with its own GPU copy of identical geometry. + const instances = node.instances; + // `count: 0` is an authored EMPTY scatter (everything culled at + // author time) — it must draw zero copies, not one stray prototype + // at the node origin, so it still becomes an InstancedMesh + const MeshClass = instances ? InstancedMesh : Mesh; + const mesh = new MeshClass(0, 0, { vertices: node.vertices, uvs: node.uvs, indices: node.indices, @@ -176,6 +187,9 @@ export default class GLTFScene { // back-face culled, or half their faces vanish cullBackFaces: node.doubleSided !== true, }); + if (instances) { + fillInstances(mesh, instances); + } // material baseColorFactor → mesh tint, so an untextured solid-color // material renders its color instead of the white-pixel fallback. // (RGB only; alpha/transparency is a separate feature — the mesh @@ -298,3 +312,63 @@ export default class GLTFScene { */ destroy() {} } + +/** + * Fill an {@link InstancedMesh}'s records from an + * `EXT_mesh_gpu_instancing` node's per-instance TRS arrays. + * + * The records are written in the node's own (glTF, Y-up, right-handed) + * space, **verbatim** — no scene scale, no axis bridge. That is not a + * simplification but the consequence of where the instance transform sits + * in the chain the shader evaluates: + * + * clip = projection × view × model(group) × instance × vertex + * + * The group's model matrix already carries the scene scale and the Y/Z + * bridge, and it is applied AFTER the instance transform — so an instance + * expressed in the same space as the vertices comes out placed correctly. + * Pre-scaling or pre-bridging the records here applies both twice (which + * put the first version of this forest 26× too far out, and well outside + * the far plane). + * @param {InstancedMesh} mesh - the mesh to fill + * @param {object} instances - `{count, translation, rotation, scale}` + * @ignore + */ +export function fillInstances(mesh, instances) { + const { count, translation, rotation, scale } = instances; + // A negative per-instance scale mirrors the geometry, which inverts its + // winding — but face culling is decided once per mesh, so those instances + // would render inside-out. Blender mirrors on linked duplicates produce + // exactly this, so say so rather than let it look like a modelling error. + if (scale !== undefined && mesh.cullBackFaces === true) { + for (let i = 0; i < scale.length; i++) { + if (scale[i] < 0) { + console.warn( + "glTF: EXT_mesh_gpu_instancing has mirrored (negative-scale) instances — face culling is per-mesh, so those instances render inside-out; set `doubleSided` on the material to avoid it", + ); + break; + } + } + } + mesh.instanceCount = count; + const floats = mesh.instanceLayout.floats; + for (let i = 0; i < count; i++) { + const t3 = i * 3; + const r4 = i * 4; + writeInstanceTRS( + mesh.instanceBuffer, + i * floats, + translation ? translation[t3] : 0, + translation ? translation[t3 + 1] : 0, + translation ? translation[t3 + 2] : 0, + rotation ? rotation[r4] : 0, + rotation ? rotation[r4 + 1] : 0, + rotation ? rotation[r4 + 2] : 0, + rotation ? rotation[r4 + 3] : 1, + scale ? scale[t3] : 1, + scale ? scale[t3 + 1] : 1, + scale ? scale[t3 + 2] : 1, + ); + } + mesh.markInstancesDirty(0, count); +} diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index 322ce5668..814ee9579 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -182,6 +182,139 @@ export function readAccessor(json, buffers, accessorIndex) { return out; } +/** + * Read an `EXT_mesh_gpu_instancing` node's per-instance TRS accessors. + * + * The extension stores up to three parallel accessors — `TRANSLATION` + * (VEC3), `ROTATION` (VEC4 quaternion) and `SCALE` (VEC3) — each with one + * element per instance. Any of them may be absent, in which case that + * component takes its glTF default (no translation, identity rotation, unit + * scale). + * + * `ROTATION` may be stored as normalized `BYTE` or `SHORT` as well as float + * (the spec allows it, and exporters use it to shrink large scatters). + * `readAccessor` returns raw component values, so those encodings are scaled + * back to [-1, 1] here — read raw, a byte-encoded quaternion would arrive as + * values up to 127 and every instance would be flung out of the scene. + * @param {object} json - the glTF document + * @param {ArrayBuffer[]} buffers - the resolved binary buffers + * @param {object} attributes - the extension's `attributes` map + * @returns {object|undefined} `{count, translation, rotation, scale}`, or undefined when empty + * @ignore + */ +/** + * Scale a normalized-integer quaternion accessor back to [-1, 1]. + * @param {ArrayLike} raw - the raw component values + * @param {number} componentType - the accessor's glTF componentType + * @returns {ArrayLike} the de-normalized quaternion components + * @ignore + */ +function normalizeQuaternions(raw, componentType) { + // float components are already in range + if (componentType === 5126) { + return raw; + } + // signed normalized: c / MAX, clamped at -1 (the spec's rule for the + // extra negative value two's complement allows) + const max = componentType === 5120 ? 127 : componentType === 5122 ? 32767 : 0; + if (max === 0) { + // An unsigned encoding cannot represent a quaternion's negative + // components. Passing it through raw is not "looking wrong" — a + // component of 255 makes the basis blow up by ~1e5 and destroys the + // scene — so fail loudly, as readAccessor does for a bad componentType. + throw new Error( + `glTF: EXT_mesh_gpu_instancing ROTATION must be float or normalized signed byte/short (componentType ${componentType})`, + ); + } + const out = new Float32Array(raw.length); + for (let i = 0; i < raw.length; i++) { + out[i] = Math.max(raw[i] / max, -1); + } + return out; +} + +/** + * @param {object} json - the glTF document + * @param {ArrayBuffer[]} buffers - the resolved binary buffers + * @param {object} attributes - the extension's `attributes` map + * @returns {object|undefined} `{count, translation, rotation, scale}`, or undefined when empty + * @ignore + */ +function readInstanceAttributes(json, buffers, attributes) { + if (!attributes) { + return undefined; + } + // The extension pins the accessor types; a mismatch would divide by the + // wrong component count and yield a fractional instance count, which + // truncates one way when allocating and another when filling. + const expect = (name, type) => { + const index = attributes[name]; + if (index === undefined) { + return; + } + const accessor = json.accessors[index]; + if (accessor.type !== type) { + throw new Error( + `glTF: EXT_mesh_gpu_instancing ${name} must be ${type} (accessor ${index} is ${accessor.type})`, + ); + } + if (accessor.sparse !== undefined) { + // readAccessor ignores sparse overrides, so the instances would + // silently land at their base-buffer positions + throw new Error( + `glTF: EXT_mesh_gpu_instancing ${name} uses a sparse accessor, which is not supported`, + ); + } + }; + expect("TRANSLATION", "VEC3"); + expect("ROTATION", "VEC4"); + expect("SCALE", "VEC3"); + + const translation = + attributes.TRANSLATION !== undefined + ? readAccessor(json, buffers, attributes.TRANSLATION) + : undefined; + const rotation = + attributes.ROTATION !== undefined + ? normalizeQuaternions( + readAccessor(json, buffers, attributes.ROTATION), + json.accessors[attributes.ROTATION].componentType, + ) + : undefined; + const scale = + attributes.SCALE !== undefined + ? readAccessor(json, buffers, attributes.SCALE) + : undefined; + + // The spec requires every supplied accessor to have the same count. Take + // the MINIMUM rather than the first present one: a truncated export would + // otherwise read past the end of the short accessor, and `undefined` + // arithmetic writes NaN records — instances that silently vanish on the + // GPU while the rest render. + const counts = []; + if (translation !== undefined) { + counts.push(translation.length / 3); + } + if (rotation !== undefined) { + counts.push(rotation.length / 4); + } + if (scale !== undefined) { + counts.push(scale.length / 3); + } + const count = counts.length > 0 ? Math.floor(Math.min(...counts)) : 0; + if (counts.length > 1 && Math.min(...counts) !== Math.max(...counts)) { + console.warn( + `glTF: EXT_mesh_gpu_instancing attributes disagree on count (${counts.join(", ")}) — using ${count}`, + ); + } + if (counts.length === 0) { + // the extension is declared with no attributes at all — nothing to + // instance, so the node stays an ordinary single mesh + return undefined; + } + return { count, translation, rotation, scale }; +} + /** * Read a `COLOR_0` accessor into a packed ARGB Uint32 per vertex (the format * {@link Mesh}'s `vertexColors` consumes). Handles `VEC3` (alpha defaults to 1) @@ -721,12 +854,26 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { const nodeName = node.name || `node_${nodeIndex}`; const primitives = []; if (node.mesh !== undefined) { + // EXT_mesh_gpu_instancing: the node carries per-instance TRS + // accessors, i.e. one geometry stamped out many times. This is what + // an exporter writes for linked duplicates (a scattered forest, a + // crowd), and it maps straight onto an InstancedMesh — so read it + // here rather than making every consumer discover it. + const instancing = node.extensions?.EXT_mesh_gpu_instancing; + const instances = instancing + ? readInstanceAttributes(json, buffers, instancing.attributes) + : undefined; for (const prim of json.meshes[node.mesh].primitives) { const geo = readPrimitiveGeometry(prim); primitives.push(geo); // flat static entry — same shape (+ world + name) as before so the // static path and bounds computation are unchanged - meshNodes.push({ name: nodeName, world, ...geo }); + meshNodes.push({ name: nodeName, world, ...geo, instances }); + // the animated path builds from `graphNodes[].primitives`, so + // the records have to ride along there as well — otherwise a + // single animation clip anywhere in the asset silently drops + // instancing for the whole scene + primitives[primitives.length - 1].instances = instances; } } // graph node: rest TRS (glTF defaults when a field is absent), an explicit diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js new file mode 100644 index 000000000..a9e1f7433 --- /dev/null +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -0,0 +1,642 @@ +import Camera3d from "../camera/camera3d.ts"; +import { Matrix3d } from "../math/matrix3d.ts"; +import { AABB3d } from "../physics/broadphase/aabb3d.ts"; +import { + instanceRecordLayout, + readInstanceTransform, + writeIdentityTransform, + writeInstanceTransform, +} from "../video/gpu/instancerecord.ts"; +import Mesh from "./mesh.js"; + +// scratch reused by getBounds3d(); never handed out +const _instanceMatrix = new Matrix3d(); +const _instanceBounds = new AABB3d(); +// scratch for dirtyRange() — read synchronously and never retained +const _dirtySpan = [0, 0]; + +/** + * A {@link Mesh} drawn many times from one copy of its geometry. + * + * Ordinary meshes pay for every repeat: a forest of five thousand trees is + * five thousand geometries on the GPU, each re-uploaded when it changes. An + * `InstancedMesh` uploads the geometry **once** and stamps out copies from a + * small per-instance record, so cost scales with the number of instances + * rather than with `instances × vertices`, and the whole thing draws in a + * single call. + * + * Every `Mesh` setting works unchanged — `model` + `material` from an OBJ, + * raw `vertices`/`uvs`/`indices`, `lit`, `cullBackFaces`, `rightHanded`, + * `tint`, `textureRepeat`, the lot. What an instanced mesh adds is the + * instance buffer: + * + * - a **transform** per instance, always present; + * - a **colour** per instance, when `instanceColors` is declared, multiplied + * into the mesh tint; + * - an opaque **`vec4`** per instance, when `instanceData` is declared. The + * built-in lit shading reads its `rgb` as emissive; a custom + * {@link Mesh#shader} may read it as a wind phase, an atlas offset, a + * random seed — whatever the shader wants. + * + * The mesh's own position, rotation and scale keep their ordinary meaning and + * act as the **group** transform: moving an `InstancedMesh` moves every + * instance with one uniform write and re-uploads nothing. + * + * Requires a GPU backend (`renderer.supportsInstancing`). Under the Canvas + * renderer the instances are drawn one at a time through the ordinary CPU + * mesh path — correct, but without any of the benefit. + * @augments Mesh + * @category Rendering + * @example + * const forest = new me.InstancedMesh(0, 0, { + * model: "tree", + * material: "tree", + * width: 64, + * lit: true, + * instanceCount: 5000, // pre-allocate + * instanceColors: true, + * instanceData: true, + * }); + * + * const placement = new me.Matrix3d(); // one scratch — zero allocation + * for (let i = 0; i < forest.instanceCount; i++) { + * placement.identity().translate(x, y, z).scale(s); + * forest.setInstance(i, placement); + * forest.setInstanceColor(i, autumnTint); + * forest.setInstanceData(i, windPhase, seed, 0, 0); + * } + * + * // draw only the nearest 1200 — no re-upload, just a smaller draw + * forest.visibleInstanceCount = 1200; + * app.world.addChild(forest, 10); + */ +export default class InstancedMesh extends Mesh { + /** + * @param {number} x - the x coordinate of the group origin + * @param {number} y - the y coordinate of the group origin + * @param {object} settings - every {@link Mesh} setting, plus those below + * @param {number} [settings.instanceCount=0] - number of instances to pre-allocate. Instances start at the group origin (identity transform) until placed; `addInstance` grows past this. + * @param {boolean} [settings.instanceColors=false] - give each instance its own colour (16 bytes per instance), multiplied into the mesh tint + * @param {boolean} [settings.instanceData=false] - give each instance an opaque `vec4` (16 bytes per instance). Read as emissive by the built-in lit shading, or as anything at all by a custom mesh shader. + */ + constructor(x, y, settings) { + super(x, y, settings); + + /** + * How this mesh's per-instance records are laid out — record width and + * the offset of each opt-in slot. Consumed by the batchers to describe + * the instance vertex buffer. + * @type {object} + * @ignore + */ + this.instanceLayout = instanceRecordLayout( + settings.instanceColors === true, + settings.instanceData === true, + ); + + /** + * The packed instance records, `instanceLayout.floats` per instance. + * Editable in place for bulk updates — announce those with + * {@link InstancedMesh#needsInstanceUpdate} or + * {@link InstancedMesh#markInstancesDirty}, since writing here bypasses + * the setters that track the dirty range. + * @type {Float32Array} + */ + this.instanceBuffer = new Float32Array(0); + + /** + * bumped whenever the buffer is reallocated, so a renderer holding a + * GPU copy knows its capacity assumption is stale + * @type {number} + * @ignore + */ + this._instanceVersion = 0; + + // dirty span in floats, as [first, lastExclusive); empty when first + // exceeds last + // Monotonic revision of the record CONTENTS. The dirty span alone is + // not enough once more than one GPU buffer holds a copy (the unlit and + // lit batchers keep separate ones, and `lit` is a public field): the + // first consumer to drain the span would leave the second stuck on + // stale records forever. Each buffer records the revision it reached, + // so a consumer that missed an edit re-uploads in full. + /** @ignore */ + this._instanceRevision = 0; + // the revision the CURRENT span started from — a consumer at exactly + // this revision can take the cheap partial upload + /** @ignore */ + this._spanFromRevision = 0; + + /** @ignore */ + this._dirtyFirst = Infinity; + /** @ignore */ + this._dirtyLast = 0; + + /** @ignore */ + this._instanceCount = 0; + /** @ignore */ + this._visibleInstanceCount = -1; + // revision the cull box was last sized for + /** @ignore */ + this._cullRevision = -1; + + if (settings.instanceCount > 0) { + this.instanceCount = settings.instanceCount; + } + } + + /** + * How many instances this mesh holds. + * + * Growing pre-allocates the new records as identity transforms (an + * unplaced instance sits at the group origin rather than collapsing onto + * a zero matrix); shrinking keeps the allocation, so a count that + * oscillates does not thrash it. + * @type {number} + */ + get instanceCount() { + return this._instanceCount; + } + + set instanceCount(count) { + // `| 0` alone wraps anything past 2^31 to a negative and NaN to 0, so + // an accidental `1e10` would silently empty the mesh rather than fail + const wanted = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0; + const floats = this.instanceLayout.floats; + if (wanted * floats > this.instanceBuffer.length) { + // grow geometrically so building a large set one addInstance at a + // time stays linear overall rather than quadratic + const capacity = Math.max(wanted, this._instanceCount * 2, 8); + const grown = new Float32Array(capacity * floats); + grown.set(this.instanceBuffer); + this.instanceBuffer = grown; + this._instanceVersion++; + } + // initialize whatever is newly in range + for (let i = this._instanceCount; i < wanted; i++) { + writeIdentityTransform(this.instanceBuffer, i * floats); + if (this.instanceLayout.hasColor) { + const at = i * floats + this.instanceLayout.colorOffset; + this.instanceBuffer[at] = 1; + this.instanceBuffer[at + 1] = 1; + this.instanceBuffer[at + 2] = 1; + this.instanceBuffer[at + 3] = 1; + } + if (this.instanceLayout.hasData) { + // a recycled slot still holds the dead instance's vec4, and the + // built-in shading reads its rgb as emissive — so a brand-new + // instance would inherit the old one's glow + const at = i * floats + this.instanceLayout.dataOffset; + this.instanceBuffer[at] = 0; + this.instanceBuffer[at + 1] = 0; + this.instanceBuffer[at + 2] = 0; + this.instanceBuffer[at + 3] = 0; + } + } + if (wanted > this._instanceCount) { + this.markInstancesDirty( + this._instanceCount, + wanted - this._instanceCount, + ); + } + this._instanceCount = wanted; + } + + /** + * How many instances are actually drawn, counted from the first. + * + * The cheap culling and level-of-detail knob: sort the instances by + * distance once, then draw fewer of them by moving this single number — + * no re-upload, no rebuild. Defaults to every instance (`-1`). + * @type {number} + * @example + * forest.visibleInstanceCount = playerIsIndoors ? 0 : 1200; + */ + get visibleInstanceCount() { + return this._visibleInstanceCount < 0 + ? this._instanceCount + : Math.min(this._visibleInstanceCount, this._instanceCount); + } + + set visibleInstanceCount(count) { + // NaN would read as 0 and silently stop the mesh drawing + if (!Number.isFinite(count)) { + this._visibleInstanceCount = -1; + return; + } + this._visibleInstanceCount = count < 0 ? -1 : Math.trunc(count); + } + + /** + * Append an instance, growing the buffer as needed. + * @param {Matrix3d} [transform] - where this instance sits, relative to the group. Defaults to the group origin. + * @param {object} [options] - optional per-instance slots + * @param {Color} [options.color] - instance colour (requires `instanceColors`) + * @param {number[]} [options.data] - four numbers for the custom slot (requires `instanceData`) + * @returns {number} the new instance's index + */ + addInstance(transform, options) { + const index = this._instanceCount; + this.instanceCount = index + 1; + if (transform !== undefined) { + this.setInstance(index, transform); + } + if (options !== undefined) { + if (options.color !== undefined) { + this.setInstanceColor(index, options.color); + } + if (options.data !== undefined) { + // a short array would leave components undefined, and + // `Float32Array[i] = undefined` is NaN — which propagates + // through the shader as black or discarded fragments + const [x = 0, y = 0, z = 0, w = 0] = options.data; + this.setInstanceData(index, x, y, z, w); + } + } + return index; + } + + /** + * Remove an instance by moving the last one into its place. + * + * Swapping is what keeps this O(1) instead of shifting every record after + * the hole — but it means **indices are not stable across a removal**: the + * instance that was last now answers to `index`. Callers holding indices + * must re-read them, or avoid removal in favour of + * {@link InstancedMesh#visibleInstanceCount}. + * @param {number} index - the instance to remove + */ + removeInstance(index) { + const last = this._instanceCount - 1; + if (index < 0 || index > last) { + return; + } + const floats = this.instanceLayout.floats; + if (index !== last) { + this.instanceBuffer.copyWithin( + index * floats, + last * floats, + (last + 1) * floats, + ); + this.markInstancesDirty(index, 1); + } + this._instanceCount = last; + } + + /** + * Place one instance. + * @param {number} index - the instance to place + * @param {Matrix3d} transform - where it sits, relative to the group + */ + setInstance(index, transform) { + if (index < 0 || index >= this._instanceCount) { + return; + } + writeInstanceTransform( + this.instanceBuffer, + index * this.instanceLayout.floats, + transform, + ); + this.markInstancesDirty(index, 1); + } + + /** + * Read one instance's transform back. + * @param {number} index - the instance to read + * @param {Matrix3d} [out] - matrix to write into; a new one is allocated when omitted + * @returns {Matrix3d|undefined} the transform, or `undefined` for an out-of-range index + */ + getInstance(index, out = new Matrix3d()) { + if (index < 0 || index >= this._instanceCount) { + return undefined; + } + return readInstanceTransform( + this.instanceBuffer, + index * this.instanceLayout.floats, + out, + ); + } + + /** + * Set one instance's colour, multiplied into the mesh tint. + * @param {number} index - the instance to colour + * @param {Color} color - the instance colour + */ + setInstanceColor(index, color) { + const layout = this.instanceLayout; + if (!layout.hasColor || index < 0 || index >= this._instanceCount) { + return; + } + const at = index * layout.floats + layout.colorOffset; + this.instanceBuffer[at] = color.r / 255; + this.instanceBuffer[at + 1] = color.g / 255; + this.instanceBuffer[at + 2] = color.b / 255; + this.instanceBuffer[at + 3] = color.alpha; + this.markInstancesDirty(index, 1); + } + + /** + * Set one instance's custom `vec4`. + * @param {number} index - the instance to write + * @param {number} x - first component (emissive red, under the built-in lit shading) + * @param {number} y - second component + * @param {number} z - third component + * @param {number} [w=0] - fourth component + */ + setInstanceData(index, x, y, z, w = 0) { + const layout = this.instanceLayout; + if (!layout.hasData || index < 0 || index >= this._instanceCount) { + return; + } + const at = index * layout.floats + layout.dataOffset; + this.instanceBuffer[at] = x; + this.instanceBuffer[at + 1] = y; + this.instanceBuffer[at + 2] = z; + this.instanceBuffer[at + 3] = w; + this.markInstancesDirty(index, 1); + } + + /** + * Announce that a span of instances was edited directly through + * {@link InstancedMesh#instanceBuffer}, so the next draw re-uploads it. + * The per-instance setters do this themselves. + * @param {number} first - first instance changed + * @param {number} count - how many + */ + markInstancesDirty(first, count) { + const floats = this.instanceLayout.floats; + // clamp to what is actually allocated: an out-of-range span reaches + // the upload as an out-of-bounds typed-array view and throws from + // inside the draw loop + const limit = this.instanceBuffer.length; + const from = Math.min(Math.max(0, first | 0) * floats, limit); + const to = Math.min(Math.max(0, (first | 0) + (count | 0)) * floats, limit); + if (to <= from) { + return; + } + if (this._dirtyLast <= this._dirtyFirst) { + // the span was empty: remember what revision consumers must be at + // to take the cheap partial path + this._spanFromRevision = this._instanceRevision; + } + if (from < this._dirtyFirst) { + this._dirtyFirst = from; + } + if (to > this._dirtyLast) { + this._dirtyLast = to; + } + this._instanceRevision++; + } + + /** + * Announce that the whole instance buffer was edited in place. The + * instancing counterpart of {@link Mesh#needsUpdate} — and, like it, a + * signal rather than a state, so it is write-only. + * @type {boolean} + * @example + * forest.instanceBuffer.set(myRecords); + * forest.needsInstanceUpdate = true; + */ + // eslint-disable-next-line accessor-pairs + set needsInstanceUpdate(value) { + if (value !== false) { + this.markInstancesDirty(0, this._instanceCount); + } + } + + /** + * The dirty span, in floats, as `[first, lastExclusive)`. Empty when + * nothing changed since the last upload. + * @returns {number[]} the span + * @ignore + */ + dirtyRange() { + // reuses one array: this is read once per instanced mesh per frame, + // and the class is sold on allocating nothing in the steady state + _dirtySpan[0] = this._dirtyLast > this._dirtyFirst ? this._dirtyFirst : 0; + _dirtySpan[1] = + this._dirtyLast > this._dirtyFirst + ? this._dirtyLast - this._dirtyFirst + : 0; + return _dirtySpan; + } + + /** + * What one GPU buffer must upload to catch up, given the revision it last + * reached. Replaces a shared "clear the dirty flag" step, which the first + * consumer would drain out from under the second. + * @param {number} uploadedRevision - the revision that buffer holds + * @returns {object} `{first, count, full, revision}` — float span, or `full` + * @ignore + */ + instanceUpload(uploadedRevision) { + const revision = this._instanceRevision; + if (uploadedRevision === revision) { + return { first: 0, count: 0, full: false, revision }; + } + if (uploadedRevision === this._spanFromRevision) { + const [first, count] = this.dirtyRange(); + return { first, count, full: false, revision }; + } + // this buffer missed edits the span no longer describes + return { first: 0, count: 0, full: true, revision }; + } + + /** + * Called by the batcher once the dirty span has been uploaded. + * @ignore + */ + clearInstanceDirty() { + this._dirtyFirst = Infinity; + this._dirtyLast = 0; + this._spanFromRevision = this._instanceRevision; + } + + /** + * The world-space bounding box of every instance, so the whole group + * frustum-culls as one object. + * + * Bounds the prototype geometry through each instance transform and then + * through the group placement — correct before the first draw, like the + * base implementation, and independent of what any draw left behind. + * @returns {AABB3d} the union of all instance bounds (reused instance) + */ + /** + * Resize the renderable's own 2D bounds box so it encloses every + * instance. + * + * This is what makes the group frustum-cull correctly. `Camera3d.isVisible` + * tests a sphere derived from `getBounds()` — the flat width/height box — + * **not** {@link InstancedMesh#getBounds3d}. Left at the prototype's size, + * a forest scattered over thousands of units presents a box a few tens of + * pixels across at the group origin: pan the camera just past that origin + * and every instance disappears at once, while still on screen. + * @ignore + */ + _refreshCullVolume() { + if (this._instanceCount === 0) { + return; + } + const bounds = super.getBounds(); + if (this._cullRevision !== this._instanceRevision) { + this._cullRevision = this._instanceRevision; + const box = this.getBounds3d(); + const origin = this._composeModelMatrix().val; + let radius = 0; + for (const x of [box.min.x, box.max.x]) { + for (const y of [box.min.y, box.max.y]) { + for (const z of [box.min.z, box.max.z]) { + radius = Math.max( + radius, + Math.hypot(x - origin[12], y - origin[13], z - origin[14]), + ); + } + } + } + this._cullRadius = Math.max(radius, 1); + } + // Widen the box rather than resizing the renderable: `width`/`height` + // are the mesh's own geometry scale and feed the model matrix, so + // growing them would grow the mesh. Camera3d only reads the box's + // width/height (its centre comes from getAbsolutePosition), so a + // symmetric box of the right size is all that is needed. + const r = this._cullRadius; + bounds.setMinMax(-r, -r, r, r); + return bounds; + } + + /** + * The 2D bounds box, widened to enclose every instance. + * + * This is the box `Camera3d.isVisible` actually culls against — left at + * the prototype's size, a scatter spanning thousands of units would + * vanish wholesale the moment the group origin left the frustum. + * @returns {Bounds} the bounds + */ + getBounds() { + return this._refreshCullVolume() ?? super.getBounds(); + } + + getBounds3d() { + if (this._bounds3d === undefined) { + /** @ignore */ + this._bounds3d = new AABB3d(); + } + const count = this._instanceCount; + if (count === 0) { + return super.getBounds3d(); + } + const group = this._composeModelMatrix(); + const floats = this.instanceLayout.floats; + for (let i = 0; i < count; i++) { + readInstanceTransform(this.instanceBuffer, i * floats, _instanceMatrix); + // group × instance — the order the shader applies them in + const placed = _instanceBounds.fromVertices( + this.originalVertices, + this.vertexCount, + multiplyInto(group, _instanceMatrix).val, + ); + // the first fold clears, so the union starts from this instance + // rather than from whatever the previous frame left behind + this._bounds3d.addAABB(placed, i === 0); + } + return this._bounds3d; + } + + /** + * Draw the instanced mesh. Under a GPU backend this is one instanced + * draw call; the Canvas renderer has no instancing, so each instance is + * drawn through the ordinary mesh path instead. + * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance + * @param {Camera2d} [viewport] - the camera rendering this frame + */ + draw(renderer, viewport) { + if (this._instanceCount === 0 || this.visibleInstanceCount === 0) { + return; + } + this._refreshCullVolume(); + // The instanced GPU path is a world-space (Camera3d) path, exactly as + // the retained mesh path is: it hands the renderer a model matrix and + // lets the shader place the geometry. Under a 2D camera the base class + // pre-projects vertices on the CPU instead, so taking the instanced + // path there would apply a different projection AND leak the anchor + // transform into the shared view matrix. Fall back per instance. + const useWorldSpace = + viewport !== undefined + ? viewport instanceof Camera3d + : this._useWorldSpace === true; + if (renderer.supportsInstancing !== true || !useWorldSpace) { + this._drawInstancesIndividually(renderer, viewport); + return; + } + if (this._worldSpace !== true) { + this._setupWorldSpace(false); + } + this.indices = this._indicesOriginal; + renderer.drawMesh(this, this._composeModelMatrix()); + } + + /** + * Fallback for renderers without instancing (Canvas): walk the instances + * and draw the prototype once per instance, composing the group and + * instance transforms by hand. Correct, and as slow as the uninstanced + * scene it replaces — the point is that a scene authored for instancing + * still renders everywhere. + * @param {CanvasRenderer} renderer - a renderer instance + * @param {Camera2d} [viewport] - the camera rendering this frame + * @ignore + */ + _drawInstancesIndividually(renderer, viewport) { + const count = this.visibleInstanceCount; + const floats = this.instanceLayout.floats; + const savedX = this.pos.x; + const savedY = this.pos.y; + const savedZ = this.pos.z; + try { + for (let i = 0; i < count; i++) { + readInstanceTransform(this.instanceBuffer, i * floats, _instanceMatrix); + // Where the instance lands is the TRANSLATION OF `group × + // instance`, not the raw record: the record is in model space, + // and the group matrix carries the mesh scale and the Y/Z axis + // bridge. Adding the raw value collapses the scatter toward the + // origin and mirrors it in Y. + const placed = multiplyInto( + this._composeModelMatrix(), + _instanceMatrix, + ).val; + this.pos.set(placed[12], placed[13], placed[14]); + super.draw(renderer, viewport); + } + } finally { + // a throw mid-loop would otherwise strand the renderable at + // whichever instance was being drawn + this.pos.set(savedX, savedY, savedZ); + } + } + + /** + * Release the instance records along with the mesh. + * @ignore + */ + destroy() { + this.instanceBuffer = new Float32Array(0); + this._instanceCount = 0; + super.destroy(); + } +} + +const _composed = new Matrix3d(); + +/** + * `group × instance` into a scratch matrix, without disturbing either. + * @param {Matrix3d} group - the group transform + * @param {Matrix3d} instance - the instance transform (written into) + * @returns {Matrix3d} `instance`, now holding the product + * @ignore + */ +function multiplyInto(group, instance) { + _composed.copy(group); + _composed.multiply(instance); + instance.copy(_composed); + return instance; +} diff --git a/packages/melonjs/src/video/gpu/instancerecord.ts b/packages/melonjs/src/video/gpu/instancerecord.ts new file mode 100644 index 000000000..6be139996 --- /dev/null +++ b/packages/melonjs/src/video/gpu/instancerecord.ts @@ -0,0 +1,347 @@ +import type { Matrix3d } from "../../math/matrix3d.ts"; + +/** + * The per-instance record shared by both GPU backends (#1508). + * + * An instance carries a **row-major 3×4 affine transform** — three `vec4` + * rows — rather than a full 4×4: the bottom row of an affine matrix is + * always `(0, 0, 0, 1)`, so storing it would waste 16 bytes and a fourth + * attribute slot per instance. Two further slots are opt-in, declared once + * per instanced mesh: + * + * The transform occupies 12 floats (48 bytes); a colour slot adds 4 more + * (16 bytes) when `instanceColors` is declared, and a custom-data slot + * another 4 when `instanceData` is. + * + * Colour is four floats rather than four packed bytes for the same reason + * `aColor` is on the mesh vertex layout: a packed 4-byte slot can form a + * NaN bit pattern that Apple's Metal-backed driver canonicalizes on upload, + * zeroing the bytes the shader reads. + * + * The custom slot is deliberately **opaque** — the stock lit shader reads + * its `rgb` as emissive, while a custom mesh shader is free to read it as a + * wind phase, an atlas offset or a random seed. + * @ignore + */ + +/** + * floats occupied by the 3×4 transform + * @ignore + */ +export const TRANSFORM_FLOATS = 12; + +/** + * floats occupied by an optional `vec4` slot + * @ignore + */ +export const SLOT_FLOATS = 4; + +/** + * Describes one instanced mesh's record: how wide it is and where each + * optional slot begins. Offsets are in floats; multiply by 4 for bytes. + * @ignore + */ +export interface InstanceRecordLayout { + /** floats per instance */ + floats: number; + /** bytes per instance (the vertex-buffer `arrayStride`) */ + stride: number; + /** float offset of the colour slot, or -1 when absent */ + colorOffset: number; + /** float offset of the custom-data slot, or -1 when absent */ + dataOffset: number; + /** whether the colour slot is present */ + hasColor: boolean; + /** whether the custom-data slot is present */ + hasData: boolean; +} + +/** + * Build the record layout for the given opt-in slots. + * @param hasColor - whether a per-instance colour slot is present + * @param hasData - whether a per-instance custom `vec4` slot is present + * @returns the resolved layout + * @ignore + */ +export function instanceRecordLayout( + hasColor: boolean, + hasData: boolean, +): InstanceRecordLayout { + let floats = TRANSFORM_FLOATS; + const colorOffset = hasColor ? floats : -1; + if (hasColor) { + floats += SLOT_FLOATS; + } + const dataOffset = hasData ? floats : -1; + if (hasData) { + floats += SLOT_FLOATS; + } + return { + floats, + stride: floats * Float32Array.BYTES_PER_ELEMENT, + colorOffset, + dataOffset, + hasColor, + hasData, + }; +} + +/** + * Write a matrix into an instance record as three row-major `vec4` rows. + * + * `Matrix3d` stores its values **column-major** (the GL convention — + * translation lives at `val[12..14]`), so this transposes the upper 3×4 on + * the way in. The shader transposes back when it rebuilds a `mat4`. + * @param target - the CPU-side instance buffer + * @param offset - float offset of this instance's record + * @param matrix - the transform to write + * @ignore + */ +export function writeInstanceTransform( + target: Float32Array, + offset: number, + matrix: Matrix3d, +): void { + const m = matrix.val; + // row 0: basis x-components + translation x + target[offset] = m[0]; + target[offset + 1] = m[4]; + target[offset + 2] = m[8]; + target[offset + 3] = m[12]; + // row 1 + target[offset + 4] = m[1]; + target[offset + 5] = m[5]; + target[offset + 6] = m[9]; + target[offset + 7] = m[13]; + // row 2 + target[offset + 8] = m[2]; + target[offset + 9] = m[6]; + target[offset + 10] = m[10]; + target[offset + 11] = m[14]; +} + +/** + * Write a translation / rotation / scale triple into an instance record. + * + * This is the shape authored instancing arrives in: glTF's + * `EXT_mesh_gpu_instancing` stores per-instance `TRANSLATION`, `ROTATION` + * (a quaternion) and `SCALE` accessors rather than matrices. Composing them + * straight into the record avoids building a `Matrix3d` per instance — + * a forest of five thousand trees would otherwise allocate five thousand + * throwaway matrices at load. + * + * The rotation is applied first, then scale, then translation (glTF's + * `T * R * S` convention), and the result is transposed into the record's + * row-major rows. + * @param target - the CPU-side instance buffer + * @param offset - float offset of this instance's record + * @param tx - translation x + * @param ty - translation y + * @param tz - translation z + * @param qx - rotation quaternion x + * @param qy - rotation quaternion y + * @param qz - rotation quaternion z + * @param qw - rotation quaternion w + * @param sx - scale x + * @param sy - scale y + * @param sz - scale z + * @ignore + */ +export function writeInstanceTRS( + target: Float32Array, + offset: number, + tx: number, + ty: number, + tz: number, + qx: number, + qy: number, + qz: number, + qw: number, + sx: number, + sy: number, + sz: number, +): void { + // quaternion → 3×3 basis (columns), then each column scaled + const x2 = qx + qx; + const y2 = qy + qy; + const z2 = qz + qz; + const xx = qx * x2; + const xy = qx * y2; + const xz = qx * z2; + const yy = qy * y2; + const yz = qy * z2; + const zz = qz * z2; + const wx = qw * x2; + const wy = qw * y2; + const wz = qw * z2; + + // row 0 = (basis[0][0], basis[1][0], basis[2][0], tx) + target[offset] = (1 - (yy + zz)) * sx; + target[offset + 1] = (xy - wz) * sy; + target[offset + 2] = (xz + wy) * sz; + target[offset + 3] = tx; + // row 1 + target[offset + 4] = (xy + wz) * sx; + target[offset + 5] = (1 - (xx + zz)) * sy; + target[offset + 6] = (yz - wx) * sz; + target[offset + 7] = ty; + // row 2 + target[offset + 8] = (xz - wy) * sx; + target[offset + 9] = (yz + wx) * sy; + target[offset + 10] = (1 - (xx + yy)) * sz; + target[offset + 11] = tz; +} + +/** + * Write an identity transform into an instance record — what a freshly + * grown slot holds until the caller places it, so an unplaced instance + * renders at the group origin rather than collapsing onto a zero matrix. + * @param target - the CPU-side instance buffer + * @param offset - float offset of this instance's record + * @ignore + */ +export function writeIdentityTransform( + target: Float32Array, + offset: number, +): void { + target[offset] = 1; + target[offset + 1] = 0; + target[offset + 2] = 0; + target[offset + 3] = 0; + target[offset + 4] = 0; + target[offset + 5] = 1; + target[offset + 6] = 0; + target[offset + 7] = 0; + target[offset + 8] = 0; + target[offset + 9] = 0; + target[offset + 10] = 1; + target[offset + 11] = 0; +} + +/** + * Read an instance's transform back into a `Matrix3d`, undoing the + * row-major packing. + * @param source - the CPU-side instance buffer + * @param offset - float offset of this instance's record + * @param out - the matrix to write into + * @returns `out` + * @ignore + */ +export function readInstanceTransform( + source: Float32Array, + offset: number, + out: Matrix3d, +): Matrix3d { + const m = out.val; + m[0] = source[offset]; + m[4] = source[offset + 1]; + m[8] = source[offset + 2]; + m[12] = source[offset + 3]; + m[1] = source[offset + 4]; + m[5] = source[offset + 5]; + m[9] = source[offset + 6]; + m[13] = source[offset + 7]; + m[2] = source[offset + 8]; + m[6] = source[offset + 9]; + m[10] = source[offset + 10]; + m[14] = source[offset + 11]; + // the row the record does not store + m[3] = 0; + m[7] = 0; + m[11] = 0; + m[15] = 1; + return out; +} + +/** + * The attribute names an instanced shader declares, in record order. Shared + * by both backends so the GL attribute records and the WGSL vertex layout + * describe the same thing. + * @ignore + */ +export const INSTANCE_ATTRIBUTE_NAMES = { + rows: ["aInstanceRow0", "aInstanceRow1", "aInstanceRow2"], + color: "aInstanceColor", + data: "aInstanceData", +} as const; + +/** + * Shader locations for the instance slots, counted from the first location + * after a tier's geometry attributes. + * + * These are **fixed per slot rather than sequential**, so a variant that + * omits the colour slot does not shift the data slot down. On WebGPU that is + * what lets one WGSL module text serve every variant — WGSL has no + * preprocessor to renumber `@location` with — and on WebGL it keeps the two + * backends describing the same thing. + * @ignore + */ +export const INSTANCE_SLOT_OFFSETS = { + rows: [0, 1, 2], + color: 3, + data: 4, +} as const; + +/** + * Backend-neutral attribute descriptors for an instance record, in the + * `{name, format, offset}` vocabulary both backends consume. + * @param layout - the record layout + * @param baseLocation - first shader location for this record (the location + * after the tier's geometry attributes). Omit on WebGL, whose attribute + * locations come from the shader's own declaration order. + * @returns attribute descriptors covering the whole record + * @ignore + */ +export function instanceAttributes( + layout: InstanceRecordLayout, + baseLocation?: number, +) { + const bytes = Float32Array.BYTES_PER_ELEMENT; + const at = (slot: number) => { + return baseLocation === undefined + ? undefined + : { shaderLocation: baseLocation + slot }; + }; + const attributes: { + name: string; + format: string; + offset: number; + shaderLocation?: number; + }[] = [ + { + name: INSTANCE_ATTRIBUTE_NAMES.rows[0], + format: "float32x4", + offset: 0, + ...at(INSTANCE_SLOT_OFFSETS.rows[0]), + }, + { + name: INSTANCE_ATTRIBUTE_NAMES.rows[1], + format: "float32x4", + offset: 4 * bytes, + ...at(INSTANCE_SLOT_OFFSETS.rows[1]), + }, + { + name: INSTANCE_ATTRIBUTE_NAMES.rows[2], + format: "float32x4", + offset: 8 * bytes, + ...at(INSTANCE_SLOT_OFFSETS.rows[2]), + }, + ]; + if (layout.hasColor) { + attributes.push({ + name: INSTANCE_ATTRIBUTE_NAMES.color, + format: "float32x4", + offset: layout.colorOffset * bytes, + ...at(INSTANCE_SLOT_OFFSETS.color), + }); + } + if (layout.hasData) { + attributes.push({ + name: INSTANCE_ATTRIBUTE_NAMES.data, + format: "float32x4", + offset: layout.dataOffset * bytes, + ...at(INSTANCE_SLOT_OFFSETS.data), + }); + } + return attributes; +} diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 9bae19263..8e723112a 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -144,6 +144,16 @@ export default class Renderer { */ this.supportsRetainedMesh = false; + /** + * Whether this renderer backend can draw one geometry many times in a + * single call from per-instance data — what {@link InstancedMesh} + * needs. `false` here on the base/Canvas renderer, which falls back to + * drawing each instance individually. + * @type {boolean} + * @default false + */ + this.supportsInstancing = false; + /** * The source language this backend accepts for user-supplied shaders, * or `null` when it has no programmable pipeline at all (the Canvas diff --git a/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js index 1e5ed8486..bee555e45 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js @@ -6,6 +6,7 @@ import { packMeshLights } from "../lighting/pack3d.ts"; import { BLOCK3D_FLOATS, writeLight3dBlock } from "../lighting/std140.ts"; import litFragment from "./../shaders/mesh-lit.frag"; import litVertex from "./../shaders/mesh-lit.vert"; +import litInstancedVertex from "./../shaders/mesh-lit-instanced.vert"; import MeshBatcher from "./mesh_batcher.js"; // resolve the lit fragment shader's light-array size from the single source of @@ -117,6 +118,17 @@ export default class LitMeshBatcher extends MeshBatcher { return { vertex: litVertex, fragment: litFragmentResolved }; } + /** + * The instanced lit pair. The fragment stage is the SAME shader the + * non-instanced path uses — the per-instance emissive term inside it is + * `#ifdef`-guarded, so the two programs differ only by the defines the + * variant is compiled with, and the lighting loop exists in one place. + * @ignore + */ + _instancedShaderSources() { + return { vertex: litInstancedVertex, fragment: litFragmentResolved }; + } + /** push the 12-float lit vertex, appending the mesh's world-space normal. @ignore */ _pushVertex(vertexData, x, y, z, u, v, color, mesh, i3) { const n = mesh.normals; diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index ec5b6a5fc..3dff08ade 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -1,5 +1,6 @@ import { Matrix3d } from "../../../math/matrix3d.ts"; import { off, on, RENDER_TARGET_CHANGED } from "../../../system/event.ts"; +import { instanceAttributes } from "../../gpu/instancerecord.ts"; import { assignIndex, beginChunk, @@ -7,9 +8,14 @@ import { remapIndex, } from "../../gpu/meshchunk.ts"; import { buildMeshVertexData, retainedScratch } from "../../gpu/meshvertex.ts"; +import WebGLInstanceBuffer from "../buffer/instance_buffer.js"; import RetainedGeometry from "../buffer/retained_geometry.js"; +import WebGLVertexState from "../buffer/vertexstate.js"; +import GLShader from "../glshader.js"; import meshFragment from "./../shaders/mesh.frag"; import meshVertex from "./../shaders/mesh.vert"; +import meshInstancedVertex from "./../shaders/mesh-instanced.vert"; +import { injectDefines } from "../utils/string.js"; import { MaterialBatcher } from "./material_batcher.js"; // Shared identity model matrix for draws whose vertices are already placed @@ -102,6 +108,23 @@ export default class MeshBatcher extends MaterialBatcher { } this.retained = new Map(); + // Per-mesh instance buffers, and the vertex states pairing a mesh's + // retained geometry with its instance records. Same lifetime rule as + // `retained`: a re-init means a new context, so drop what is held. + if (this.instanced !== undefined) { + this.releaseAllInstanced(); + } + this.instanced = new Map(); + + // Instanced shader variants, keyed on the opt-in slots a mesh + // declares. Compiled on first use rather than up front: most scenes + // use one combination, and a scene with no instanced mesh at all + // compiles none. Dropped on re-init with everything else GL-owned. + this.instancedShaders?.forEach((shader) => { + shader.destroy(); + }); + this.instancedShaders = new Map(); + // last `uTint` value pushed, same redundant-set guard — but the // sentinel is `undefined`, NOT a number: a packed ARGB tint spans the // whole 32-bit range, and white at full alpha (0xffffffff) reads back @@ -225,6 +248,13 @@ export default class MeshBatcher extends MaterialBatcher { * @ignore */ destroy() { + // variants hold GL programs AND stay subscribed to the context-loss + // events until destroyed — an orphan would try to recompile against a + // dead context on the next restore + this.instancedShaders?.forEach((shader) => { + shader.destroy(); + }); + this.instancedShaders?.clear(); if (this._onTargetChanged) { off(RENDER_TARGET_CHANGED, this._onTargetChanged); this._onTargetChanged = null; @@ -444,12 +474,265 @@ export default class MeshBatcher extends MaterialBatcher { gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); } + /** + * Get (building or refreshing as needed) the GPU state one instanced mesh + * draws from: its retained prototype geometry, its instance buffer, and + * the vertex state binding the two together. + * + * The vertex state is rebuilt only when a buffer object it references is + * replaced — a growing instance set reallocates, a merely-edited one does + * not — because a vertex array holding a deleted buffer keeps it alive per + * the GL spec and silently draws stale data. + * @param {InstancedMesh} mesh - the mesh to draw + * @returns {object} `{geometry, instances, vertexState}` + * @ignore + */ + instancedStateFor(mesh) { + const gl = this.gl; + const geometry = this.retainedGeometryFor(mesh); + let state = this.instanced.get(mesh); + if (state === undefined) { + state = { + instances: new WebGLInstanceBuffer(gl), + vertexState: null, + builtVersion: -1, + builtGeometry: null, + builtShader: null, + uploadedRevision: -1, + }; + this.instanced.set(mesh, state); + } + + // push whatever the CPU side changed before the layout is described, + // so a first upload has allocated the buffer by the time the vertex + // state points attribute records at it + // what THIS buffer must upload to catch up — a shared "clear the dirty + // flag" step would let the unlit batcher drain the span before the lit + // one had seen it + const plan = mesh.instanceUpload(state.uploadedRevision); + const usedBytes = mesh.instanceCount * mesh.instanceLayout.stride; + if (plan.full) { + state.instances.upload(mesh.instanceBuffer, 0, 0, Infinity); + } else { + state.instances.upload( + mesh.instanceBuffer, + plan.first, + plan.count, + usedBytes, + ); + } + state.uploadedRevision = plan.revision; + mesh.clearInstanceDirty(); + + const stale = + state.vertexState === null || + state.builtVersion !== mesh._instanceVersion || + state.builtGeometry !== geometry.vertexBuffer || + // the layout is frozen against the CURRENT program's attribute + // locations, so a different program needs a different vertex state + // — otherwise the rows stay wired to the old locations and the + // mesh silently reads zeros (a singular matrix collapses it) + state.builtShader !== this.currentShader; + if (stale) { + // geometry group (per vertex) + instance group (per instance) — + // one vertex array describing both buffers + const descriptor = { + buffers: [ + { + buffer: geometry.vertexBuffer, + stride: this.stride, + attributes: this.attributes, + }, + { + buffer: state.instances.buffer, + stride: mesh.instanceLayout.stride, + stepMode: "instance", + attributes: this._instanceAttributeRecords(mesh.instanceLayout), + }, + ], + indexBuffer: { + bind: () => { + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, geometry.glIndexBuffer); + }, + }, + resolveLocation: (name) => { + return this.currentShader.getAttribLocation(name); + }, + }; + if (state.vertexState === null) { + state.vertexState = new WebGLVertexState(gl, descriptor); + } else { + state.vertexState.build(descriptor); + } + state.builtVersion = mesh._instanceVersion; + state.builtGeometry = geometry.vertexBuffer; + state.builtShader = this.currentShader; + } + return { geometry, state }; + } + + /** + * The instanced shader for a given record layout, compiled on first use. + * + * Variants are source permutations rather than runtime branches: the + * optional slots are `#ifdef`-guarded, so a mesh that declares neither + * costs no unused attributes and no dead code. `minify` deliberately + * preserves newlines so the directives survive it. + * @param {object} layout - the instance record layout + * @returns {GLShader} the shader for that combination + * @ignore + */ + instancedShaderFor(layout) { + const key = (layout.hasColor ? 1 : 0) | (layout.hasData ? 2 : 0); + let shader = this.instancedShaders.get(key); + if (shader === undefined) { + const defines = + (layout.hasColor ? "#define INSTANCE_COLORS\n" : "") + + (layout.hasData ? "#define INSTANCE_DATA\n" : ""); + const sources = this._instancedShaderSources(); + // only INSTANCE_DATA reaches the fragment stage (as the + // per-instance emissive term); injecting the colour flag there too + // would compile four distinct fragment texts where two suffice + const fragmentDefines = layout.hasData ? "#define INSTANCE_DATA\n" : ""; + shader = new GLShader(this.gl, { + vertex: injectDefines(sources.vertex, defines), + fragment: injectDefines(sources.fragment, fragmentDefines), + label: `melonJS instanced mesh ${key}`, + }); + this.instancedShaders.set(key, shader); + } + return shader; + } + + /** + * The instanced shader sources for this batcher (unlit by default). + * Subclasses override to supply the lit pair. + * @ignore + */ + _instancedShaderSources() { + return { vertex: meshInstancedVertex, fragment: meshFragment }; + } + + /** + * Resolve an instance record layout into GL attribute records, in the + * `{name, size, type, normalized, offset}` shape the vertex state wants. + * Every slot is a `float32x4`. + * @param {object} layout - the instance record layout + * @returns {object[]} attribute records + * @ignore + */ + _instanceAttributeRecords(layout) { + const gl = this.gl; + return instanceAttributes(layout).map((attr) => { + return { + name: attr.name, + size: 4, + type: gl.FLOAT, + normalized: false, + offset: attr.offset, + }; + }); + } + + /** + * Draw every visible instance of a mesh in one call. + * + * The geometry is bound once and the GPU walks the instance buffer, + * advancing the per-instance attributes one record per copy. Placement of + * the *group* still rides the ordinary uniforms, so moving the whole set + * costs one matrix and re-uploads nothing. + * @param {InstancedMesh} mesh - the mesh to draw + * @param {Matrix3d} modelMatrix - where the group sits in the world + * @param {number} tint - tint colour in UINT32 (argb) format + * @ignore + */ + drawInstancedMesh(mesh, modelMatrix, tint) { + const gl = this.gl; + const count = mesh.visibleInstanceCount; + if (count === 0) { + return; + } + + // anything queued must land first, or this draw reorders ahead of it + this.flush(); + + // the instanced variant must be current BEFORE the vertex state is + // built: its attribute locations are what the layout is frozen against + // A custom mesh shader is NOT hosted on the instanced path: its + // attribute declarations decide the vertex-state layout, so a shader + // that omits (or reorders) the instance slots wires them to the wrong + // locations — or leaves them disabled, which makes the instance matrix + // singular and collapses the mesh to a point. The WebGPU backend has + // the same limitation, so both warn and fall back identically. + if ( + this.renderer.customShader != null && + this._instancedShaderWarned !== true + ) { + this._instancedShaderWarned = true; + console.warn( + "melonJS: a custom shader cannot be hosted on an InstancedMesh — the mesh draws with the built-in instanced shading", + ); + } + this.useShader(this.instancedShaderFor(mesh.instanceLayout)); + + this.updatePassState(); + this.applyMeshMaterial(mesh); + this.setPlacementUniforms(modelMatrix, tint); + + const { geometry, state } = this.instancedStateFor(mesh); + state.vertexState.bind(); + gl.drawElementsInstanced( + this.mode, + geometry.indexCount, + geometry.indexType, + 0, + count, + ); + + // Hand the default shader and this batcher's own vertex state back. + // Both matter: `bind()` only restores the default program when the + // batcher is re-entered, and `setBatcher` returns early when it is + // already current — so a following non-instanced mesh would otherwise + // draw through the instanced program, reading per-instance attributes + // that no longer have a buffer behind them. + this.useShader(this.defaultShader); + this.vertexState.bind(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); + } + + /** + * Release the instance buffer and vertex state held for one mesh, if any. + * @param {object} mesh - the mesh whose instance state should be freed + * @ignore + */ + releaseInstanced(mesh) { + const state = this.instanced?.get(mesh); + if (state !== undefined) { + state.vertexState?.destroy(); + state.instances.destroy(); + this.instanced.delete(mesh); + } + } + + /** + * Release every instance buffer this batcher holds. + * @ignore + */ + releaseAllInstanced() { + this.instanced?.forEach((state) => { + state.vertexState?.destroy(); + state.instances.destroy(); + }); + this.instanced?.clear(); + } + /** * Release the retained geometry held for one mesh, if any. * @param {object} mesh - the mesh whose geometry should be freed * @ignore */ releaseRetained(mesh) { + this.releaseInstanced(mesh); const geometry = this.retained.get(mesh); if (geometry !== undefined) { geometry.destroy(); @@ -462,6 +745,7 @@ export default class MeshBatcher extends MaterialBatcher { * @ignore */ releaseAllRetained() { + this.releaseAllInstanced(); this.retained.forEach((geometry) => { geometry.destroy(); }); diff --git a/packages/melonjs/src/video/webgl/buffer/instance_buffer.js b/packages/melonjs/src/video/webgl/buffer/instance_buffer.js new file mode 100644 index 000000000..7e0b83fd6 --- /dev/null +++ b/packages/melonjs/src/video/webgl/buffer/instance_buffer.js @@ -0,0 +1,97 @@ +/** + * The GPU half of an {@link InstancedMesh}'s instance records — one + * `ARRAY_BUFFER` holding the packed per-instance transforms (and the opt-in + * colour / custom slots), uploaded with `bufferSubData` over only the range + * the CPU side actually touched. + * + * This is the instancing counterpart of {@link RetainedGeometry}: the mesh's + * geometry is uploaded once and never rewritten, and this buffer carries + * everything that differs between the copies. Moving one tree in a forest of + * five thousand therefore re-uploads 48 bytes, not the geometry and not the + * other 4 999 records. + * + * The buffer is sized to the mesh's capacity rather than its live instance + * count, so growing by one instance does not reallocate on the GPU until the + * CPU-side array itself grows. + * @ignore + */ +export default class WebGLInstanceBuffer { + /** + * @param {WebGL2RenderingContext} gl - the WebGL context + */ + constructor(gl) { + this.gl = gl; + + /** + * the GL buffer holding the packed instance records + * @type {WebGLBuffer} + */ + this.buffer = gl.createBuffer(); + + /** + * byte capacity currently allocated on the GPU; a CPU-side array + * larger than this forces a full reallocation rather than a sub-update + * @type {number} + */ + this.capacity = 0; + } + + /** + * Push the dirty span of `data` to the GPU. + * + * Three cases, cheapest last: the CPU array outgrew the allocation (full + * `bufferData`), nothing is dirty (no call at all), or a span changed + * (one `bufferSubData` covering exactly it). + * @param {Float32Array} data - the CPU-side instance records + * @param {number} firstFloat - first dirty float offset (inclusive) + * @param {number} floatCount - number of dirty floats, 0 when clean + * @param {number} usedBytes - bytes of `data` actually in use + */ + upload(data, firstFloat, floatCount, usedBytes) { + const gl = this.gl; + if (floatCount === 0 && usedBytes <= this.capacity) { + // nothing to do — and no reason to perturb the ARRAY_BUFFER binding + return; + } + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); + + if (usedBytes > this.capacity) { + // (re)allocate to the CPU array's full length, so subsequent + // growth up to that capacity is a sub-update rather than a realloc + gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); + this.capacity = data.byteLength; + return; + } + + if (floatCount === 0) { + return; + } + + // upload through a Uint8Array view for the same reason the retained + // geometry does: some drivers canonicalize NaN bit patterns on a + // float upload, which would corrupt packed values + const byteOffset = firstFloat * Float32Array.BYTES_PER_ELEMENT; + const byteLength = floatCount * Float32Array.BYTES_PER_ELEMENT; + gl.bufferSubData( + gl.ARRAY_BUFFER, + byteOffset, + new Uint8Array(data.buffer, data.byteOffset + byteOffset, byteLength), + ); + } + + /** + * Release the GL buffer. Safe to call twice, and on a lost context — the + * `isBuffer` probe is itself error-free, whereas deleting a handle whose + * context died raises INVALID_OPERATION. + */ + destroy() { + const gl = this.gl; + if (this.buffer !== null) { + if (gl.isBuffer(this.buffer)) { + gl.deleteBuffer(this.buffer); + } + this.buffer = null; + } + this.capacity = 0; + } +} diff --git a/packages/melonjs/src/video/webgl/buffer/vertexstate.js b/packages/melonjs/src/video/webgl/buffer/vertexstate.js index e40a1ee8d..0e0d43dd5 100644 --- a/packages/melonjs/src/video/webgl/buffer/vertexstate.js +++ b/packages/melonjs/src/video/webgl/buffer/vertexstate.js @@ -1,14 +1,47 @@ +/** + * Normalize a descriptor into the list-of-buffer-layouts form. + * + * The single-buffer shape (`attributes` + `stride` + `buffer`) is the + * original one and remains the common case, so it is accepted verbatim and + * widened here into a one-entry list rather than pushed onto every caller. + * A descriptor that declares `buffers` is used as-is. + * @param {object} descriptor - the descriptor to read + * @returns {object[]} buffer layout groups + * @ignore + */ +function bufferGroups(descriptor) { + if (Array.isArray(descriptor.buffers)) { + return descriptor.buffers; + } + return [ + { + buffer: descriptor.buffer, + stride: descriptor.stride, + stepMode: "vertex", + attributes: descriptor.attributes, + }, + ]; +} + /** * A WebGL Vertex State — a Vertex Array Object owning a frozen vertex * buffer layout: one `vertexAttribPointer` / `enableVertexAttribArray` per * attribute against a given vertex buffer, plus the ELEMENT_ARRAY_BUFFER * binding when the geometry is indexed. * - * This is the engine's `GPUVertexState` analogue. The layout it is built - * from (the `attributes` + `stride` pair — a `GPUVertexBufferLayout` and - * its `arrayStride`) is immutable once built, exactly like a vertex layout - * baked into a render pipeline. A future WebGPU backend replaces this - * class wholesale without its callers changing. + * This is the engine's `GPUVertexState` analogue, and the layout it is built + * from is a `GPUVertexBufferLayout[]`: a list of buffer groups, each with its + * own buffer, `arrayStride` and `stepMode`. Attributes in a `"vertex"` group + * advance once per vertex (the ordinary case); attributes in an + * `"instance"` group advance once per *instance*, which is what + * `drawElementsInstanced` reads to stamp out N copies of one geometry. + * The layout is immutable once built, exactly like a vertex layout baked + * into a render pipeline. + * + * Attribute divisors are vertex-array state, so a fresh VAO starts with every + * divisor at 0 and `build()` creates one each time — an instanced layout can + * therefore never leak its divisors into an unrelated vertex state, and the + * single-buffer path issues no `vertexAttribDivisor` calls at all. * * Every method that mutates GL binding state saves and restores the live * bindings itself, so building or rebuilding one vertex state can never @@ -18,11 +51,15 @@ */ export default class WebGLVertexState { /** + * Accepts either shape — the single-buffer one (`attributes` + `stride` + + * `buffer`) or a list of buffer groups (`buffers`). They are equivalent; + * the former is the latter with one `"vertex"` group. * @param {WebGL2RenderingContext} gl - the WebGL context * @param {object} descriptor - the vertex layout to realize - * @param {object[]} descriptor.attributes - attribute definitions (`name`, `size`, `type`, `normalized`, `offset`) - * @param {number} descriptor.stride - size of a single vertex in bytes (`arrayStride`) - * @param {WebGLBuffer} descriptor.buffer - the vertex buffer the attribute pointers read from + * @param {object[]} [descriptor.attributes] - attribute definitions (`name`, `size`, `type`, `normalized`, `offset`) + * @param {number} [descriptor.stride] - size of a single vertex in bytes (`arrayStride`) + * @param {WebGLBuffer} [descriptor.buffer] - the vertex buffer the attribute pointers read from + * @param {object[]} [descriptor.buffers] - buffer groups, each `{buffer, stride, attributes, stepMode}` where `stepMode` is `"vertex"` (default) or `"instance"` * @param {Function} descriptor.resolveLocation - maps an attribute name to its shader location (`-1` when absent) * @param {WebGLIndexBuffer} [descriptor.indexBuffer] - index buffer to capture, for indexed geometry */ @@ -63,15 +100,24 @@ export default class WebGLVertexState { * replacing any previous one. Called on construction and from every * buffer-recreation path — a vertex state referencing a deleted buffer * keeps it alive per the GL spec and draws stale data. - * @param {object} [changes] - descriptor fields to replace first (e.g. recreated `buffer` / `indexBuffer`) + * @param {object} [changes] - descriptor fields to replace first (e.g. recreated `buffer` / `buffers` / `indexBuffer`) */ build(changes) { const gl = this.gl; if (changes !== undefined) { + // the two descriptor shapes are alternatives, not a union: leaving + // a stale `buffers` in place while `buffer` is replaced would keep + // drawing from the old buffer, silently + if (changes.buffers !== undefined) { + this.descriptor.buffer = undefined; + this.descriptor.attributes = undefined; + this.descriptor.stride = undefined; + } else if (changes.buffer !== undefined) { + this.descriptor.buffers = undefined; + } Object.assign(this.descriptor, changes); } - const { attributes, stride, buffer, indexBuffer, resolveLocation } = - this.descriptor; + const { indexBuffer, resolveLocation } = this.descriptor; const saved = this.#captureBindings(); const replaced = this.handle; @@ -80,30 +126,41 @@ export default class WebGLVertexState { this.handle = gl.createVertexArray(); gl.bindVertexArray(this.handle); - // ARRAY_BUFFER is NOT vertex-array state, but each pointer record - // captures whichever buffer is bound at the time it is issued - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - for (const attr of attributes) { - const location = resolveLocation(attr.name); - if (location === -1) { - // on a lost context every location is -1 (the shader never - // compiled); that is expected and rebuilt on restore - if (!gl.isContextLost()) { - console.warn( - `melonJS: vertex attribute "${attr.name}" not found in the shader — skipped in the vertex state`, - ); + for (const group of bufferGroups(this.descriptor)) { + // ARRAY_BUFFER is NOT vertex-array state, but each pointer record + // captures whichever buffer is bound at the time it is issued — + // which is exactly how one vertex array reads several buffers + gl.bindBuffer(gl.ARRAY_BUFFER, group.buffer); + const perInstance = group.stepMode === "instance"; + for (const attr of group.attributes) { + const location = resolveLocation(attr.name); + if (location === -1) { + // on a lost context every location is -1 (the shader never + // compiled); that is expected and rebuilt on restore + if (!gl.isContextLost()) { + console.warn( + `melonJS: vertex attribute "${attr.name}" not found in the shader — skipped in the vertex state`, + ); + } + continue; + } + gl.enableVertexAttribArray(location); + gl.vertexAttribPointer( + location, + attr.size, + attr.type, + attr.normalized, + group.stride, + attr.offset, + ); + if (perInstance) { + // advance once per instance rather than per vertex. Only + // issued for instance groups: a fresh vertex array already + // has every divisor at 0, so the ordinary path stays free + // of these calls entirely. + gl.vertexAttribDivisor(location, 1); } - continue; } - gl.enableVertexAttribArray(location); - gl.vertexAttribPointer( - location, - attr.size, - attr.type, - attr.normalized, - stride, - attr.offset, - ); } if (indexBuffer) { diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert new file mode 100644 index 000000000..9e6cd8154 --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/mesh-instanced.vert @@ -0,0 +1,67 @@ +// Instanced mesh vertex shader (#1508). +// +// The unlit mesh vertex stage, with the geometry drawn many times from one +// copy: `aVertex`/`aRegion`/`aColor` advance per vertex as usual, while the +// `aInstance*` attributes advance once per INSTANCE (their vertex buffer is +// declared with a divisor of 1). So one geometry plus a small per-instance +// record produces N copies in a single draw call: +// +// clip = projection × view × model(group) × instance × vertex +// +// `uModelMatrix` places the whole group — moving an InstancedMesh is still +// one uniform write — and the per-instance transform places each copy within +// it. +// +// The instance transform arrives as three ROW-major vec4 rows rather than a +// full mat4: the bottom row of an affine matrix is always (0,0,0,1), so +// storing it would waste 16 bytes and a fourth attribute slot per instance. +// GLSL's mat4 constructor takes COLUMNS, hence the transpose below. +attribute vec3 aVertex; +attribute vec2 aRegion; +attribute vec4 aColor; + +attribute vec4 aInstanceRow0; +attribute vec4 aInstanceRow1; +attribute vec4 aInstanceRow2; +#ifdef INSTANCE_COLORS +attribute vec4 aInstanceColor; +#endif +#ifdef INSTANCE_DATA +attribute vec4 aInstanceData; +#endif + +uniform mat4 uProjectionMatrix; +uniform mat4 uViewMatrix; +uniform mat4 uModelMatrix; +uniform vec4 uTint; + +varying vec2 vRegion; +varying vec4 vColor; +#ifdef INSTANCE_DATA +varying vec4 vInstanceData; +#endif + +mat4 instanceMatrix() { + return mat4( + vec4(aInstanceRow0.x, aInstanceRow1.x, aInstanceRow2.x, 0.0), + vec4(aInstanceRow0.y, aInstanceRow1.y, aInstanceRow2.y, 0.0), + vec4(aInstanceRow0.z, aInstanceRow1.z, aInstanceRow2.z, 0.0), + vec4(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w, 1.0)); +} + +void main(void) { + mat4 instance = instanceMatrix(); + gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * instance + * vec4(aVertex, 1.0); + + vec4 tinted = aColor * uTint; +#ifdef INSTANCE_COLORS + tinted *= aInstanceColor; +#endif + // tint first, then premultiply — matches the fragment shader's expectation + vColor = vec4(tinted.rgb * tinted.a, tinted.a); + vRegion = aRegion; +#ifdef INSTANCE_DATA + vInstanceData = aInstanceData; +#endif +} diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert new file mode 100644 index 000000000..c6816b007 --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit-instanced.vert @@ -0,0 +1,78 @@ +#version 300 es +// Instanced lit mesh vertex shader (#1508). +// +// The lit mesh vertex stage drawn many times from one copy of the geometry: +// `aVertex`/`aRegion`/`aColor`/`aNormal` advance per vertex, the `aInstance*` +// attributes once per INSTANCE (their buffer is declared with a divisor of 1): +// +// clip = projection × view × model(group) × instance × vertex +// +// GLSL ES 3.00 for the same reason mesh-lit.vert is — the fragment stage reads +// light data from a uniform block, and both stages of a program must share a +// dialect. (The UNLIT instanced shader stays ES 1.00: `attribute mat4`/`vec4` +// are legal there, so instancing itself forces no dialect change.) +// +// The instance transform arrives as three ROW-major vec4 rows rather than a +// full mat4 — the bottom row of an affine matrix is always (0,0,0,1), so +// storing it would waste 16 bytes and an attribute slot per instance. GLSL's +// mat4 constructor takes COLUMNS, hence the transpose below. +in vec3 aVertex; +in vec2 aRegion; +in vec4 aColor; +in vec3 aNormal; + +in vec4 aInstanceRow0; +in vec4 aInstanceRow1; +in vec4 aInstanceRow2; +#ifdef INSTANCE_COLORS +in vec4 aInstanceColor; +#endif +#ifdef INSTANCE_DATA +in vec4 aInstanceData; +#endif + +uniform mat4 uProjectionMatrix; +uniform mat4 uViewMatrix; +uniform mat4 uModelMatrix; +uniform vec4 uTint; + +out vec2 vRegion; +out vec4 vColor; +out vec3 vNormal; +out vec3 vWorldPos; +#ifdef INSTANCE_DATA +out vec4 vInstanceData; +#endif + +mat4 instanceMatrix() { + return mat4( + vec4(aInstanceRow0.x, aInstanceRow1.x, aInstanceRow2.x, 0.0), + vec4(aInstanceRow0.y, aInstanceRow1.y, aInstanceRow2.y, 0.0), + vec4(aInstanceRow0.z, aInstanceRow1.z, aInstanceRow2.z, 0.0), + vec4(aInstanceRow0.w, aInstanceRow1.w, aInstanceRow2.w, 1.0)); +} + +void main(void) { + mat4 instance = instanceMatrix(); + vec4 worldPos = uModelMatrix * instance * vec4(aVertex, 1.0); + gl_Position = uProjectionMatrix * uViewMatrix * worldPos; + vWorldPos = worldPos.xyz; + + vec4 tinted = aColor * uTint; +#ifdef INSTANCE_COLORS + tinted *= aInstanceColor; +#endif + vColor = vec4(tinted.rgb * tinted.a, tinted.a); + vRegion = aRegion; + + // Rotate the normal into world space through BOTH transforms, in the same + // order the position takes them. Uniform scale cancels when the fragment + // shader renormalizes; non-uniform scale is approximated exactly as the + // non-instanced path approximates it (an exact result would need the + // inverse-transpose) — so an instanced mesh shades like its uninstanced + // twin rather than subtly differently. + vNormal = mat3(uModelMatrix) * mat3(instance) * aNormal; +#ifdef INSTANCE_DATA + vInstanceData = aInstanceData; +#endif +} diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index 2263255ce..eb1c78cd9 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -51,6 +51,10 @@ in vec4 vColor; in vec2 vRegion; in vec3 vNormal; in vec3 vWorldPos; +#ifdef INSTANCE_DATA +// per-instance custom slot — read as emissive by this built-in shading +in vec4 vInstanceData; +#endif out vec4 fragColor; @@ -105,5 +109,9 @@ void main(void) { // emissive self-illuminates: added AFTER lighting so it glows at full // strength regardless of the scene lights (neon, lava, glowing eyes). - fragColor = vec4(base.rgb * lit + uEmissive, base.a); + vec3 emissive = uEmissive; +#ifdef INSTANCE_DATA + emissive += vInstanceData.rgb; +#endif + fragColor = vec4(base.rgb * lit + emissive, base.a); } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.frag b/packages/melonjs/src/video/webgl/shaders/mesh.frag index 47902557f..605632ca3 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -3,6 +3,12 @@ uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) uniform vec3 uEmissive; // self-illumination color added on top (0 = none) varying vec4 vColor; varying vec2 vRegion; +#ifdef INSTANCE_DATA +// per-instance custom slot. The built-in shading reads its rgb as emissive, +// so a forest can glow per tree without a uniform per instance; a CUSTOM mesh +// shader is free to read the same slot as anything else entirely. +varying vec4 vInstanceData; +#endif void main(void) { vec4 color = texture2D(uSampler, vRegion) * vColor; @@ -13,5 +19,9 @@ void main(void) { } // emissive adds a self-lit color on top (neon, lava, screens); the unlit // path has no lighting, so it's simply added to the base color. - gl_FragColor = vec4(color.rgb + uEmissive, color.a); + vec3 emissive = uEmissive; +#ifdef INSTANCE_DATA + emissive += vInstanceData.rgb; +#endif + gl_FragColor = vec4(color.rgb + emissive, color.a); } diff --git a/packages/melonjs/src/video/webgl/utils/string.js b/packages/melonjs/src/video/webgl/utils/string.js index abe7ba411..2671a0eec 100644 --- a/packages/melonjs/src/video/webgl/utils/string.js +++ b/packages/melonjs/src/video/webgl/utils/string.js @@ -14,3 +14,37 @@ export function minify(src) { return src; } + +/** + * Prepend preprocessor defines to a GLSL source, respecting `#version`. + * + * `#version` must be the very first statement of a GLSL ES 3.00 shader — + * anything before it, including a `#define`, is a compile error. So defines + * go immediately *after* that line when one is present, and at the top when + * it is not (GLSL ES 1.00 sources declare no version). + * + * Used to compile shader variants from one source: the optional parts are + * `#ifdef`-guarded, and the variant is what defines are injected here. + * @param {string} source - the shader source + * @param {string} defines - `#define` lines, each newline-terminated + * @returns {string} the source with the defines injected + * @ignore + */ +export function injectDefines(source, defines) { + if (!defines) { + return source; + } + // GLSL ES 3.00 allows comments (and whitespace) BEFORE `#version`, so the + // directive is not necessarily at byte 0 — a licence header above it is + // enough to push the defines in front of it, which fails to compile and + // leaves the draw with no program at all. Skip leading comments too, and + // tolerate a `#version` line that ends the source without a newline. + const lead = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*/.exec(source)[0]; + const rest = source.slice(lead.length); + const version = /^#version[^\n]*(?:\n|$)/.exec(rest); + if (version === null) { + return defines + source; + } + const directive = version[0].endsWith("\n") ? version[0] : `${version[0]}\n`; + return lead + directive + defines + rest.slice(version[0].length); +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 44f656096..acea99fba 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -277,6 +277,9 @@ export default class WebGLRenderer extends Renderer { // hand it their model matrix instead of pre-transformed vertices this.supportsRetainedMesh = true; + // drawElementsInstanced is WebGL 2 core + this.supportsInstancing = true; + // GLSL, not "shaders exist" — `ShaderEffect` and the loader's // `{vertex, fragment}` assets hand their source straight to the // driver, so the language is the thing they have to agree on @@ -1749,7 +1752,16 @@ export default class WebGLRenderer extends Renderer { // unshaded mesh would silently draw with it try { const tint = this.currentTint.toUint32(this.getGlobalAlpha()); - if (retained) { + if ( + mesh.instanceLayout !== undefined && + retained && + this.supportsInstancing === true + ) { + // one geometry, N copies, one call — the per-instance records + // carry the placement each copy differs by, while `modelMatrix` + // places the group as a whole + this.currentBatcher.drawInstancedMesh(mesh, modelMatrix, tint); + } else if (retained) { this.currentBatcher.drawRetainedMesh(mesh, modelMatrix, tint); } else { this.currentBatcher.addMesh(mesh, tint); diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js index a1b3da4b8..17aee9f95 100644 --- a/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js @@ -8,6 +8,7 @@ import { BLOCK3D_FLOATS, writeLight3dBlock, } from "../../webgl/lighting/std140.ts"; +import { LIT_INSTANCED } from "../shaders/mesh-instanced.js"; import litMeshWGSL from "../shaders/mesh-lit.wgsl"; import WebGPUMeshBatcher from "./mesh_batcher.js"; @@ -73,6 +74,15 @@ export default class WebGPULitMeshBatcher extends WebGPUMeshBatcher { * @override * @ignore */ + /** + * the lit tier's instanced variant: its geometry attributes run to + * location 3 (the normal), so the instance slots start at 4 + * @ignore + */ + instancedVariant() { + return LIT_INSTANCED; + } + shaderSource() { return litMeshWGSL; } diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 998af2e10..3fa4d9671 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -1,4 +1,5 @@ import { Matrix3d } from "../../../math/matrix3d.ts"; +import { instanceAttributes } from "../../gpu/instancerecord.ts"; import { assignIndex, beginChunk, @@ -6,8 +7,13 @@ import { remapIndex, } from "../../gpu/meshchunk.ts"; import { buildMeshVertexData, retainedScratch } from "../../gpu/meshvertex.ts"; +import WebGPUInstanceBuffer from "../buffer/instance_buffer.js"; import WebGPURetainedGeometry from "../buffer/retained_geometry.js"; import meshWGSL from "../shaders/mesh.wgsl"; +import { + buildInstancedMeshWGSL, + UNLIT_INSTANCED, +} from "../shaders/mesh-instanced.js"; import WebGPUBatcher from "./webgpu_batcher.js"; /** @@ -120,6 +126,188 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { this.releaseAllRetained(); } this.retained = new Map(); + + // Per-mesh instance record buffers, and the pipeline-cache family key + // per declared slot combination. Same lifetime rule as `retained`: a + // re-init means a new device, so drop what is held. + if (this.instanced !== undefined) { + this.releaseAllInstanced(); + } + this.instanced = new Map(); + this.instancedKeys = new Map(); + } + + /** + * The instanced variant options for this tier (unlit by default) — where + * its instance attributes start and how its vertex stage places them. + * @ignore + */ + instancedVariant() { + return UNLIT_INSTANCED; + } + + /** + * The pipeline-cache family for a given record layout, registered on + * first use. + * + * WGSL has no preprocessor, so the variant is a module DERIVED from this + * tier's ordinary source (see `buildInstancedMeshWGSL`) rather than the + * same text compiled with different defines. Its vertex layout is two + * groups: this batcher's geometry layout, plus the instance records + * stepping once per instance. + * @param {object} layout - the instance record layout + * @returns {string} the family key + * @ignore + */ + instancedFamilyFor(layout) { + const key = (layout.hasColor ? 1 : 0) | (layout.hasData ? 2 : 0); + let familyKey = this.instancedKeys.get(key); + if (familyKey !== undefined) { + return familyKey; + } + const cache = this.renderer.pipelineCache; + const variant = this.instancedVariant(); + const source = buildInstancedMeshWGSL(this.shaderSource(), { + ...variant, + hasColor: layout.hasColor, + hasData: layout.hasData, + }); + const layoutKey = `${this.vertexLayoutKey}Instanced${key}`; + cache.registerVertexLayout(layoutKey, [ + { + stride: this.stride, + attributes: this.attributes, + }, + { + stride: layout.stride, + stepMode: "instance", + attributes: instanceAttributes(layout, variant.baseLocation), + }, + ]); + familyKey = cache.registerShader(source, { + bindGroupLayouts: this.bindGroupLayoutList(cache), + vertexLayoutKey: layoutKey, + label: `melonJS ${layoutKey} shader`, + }); + this.instancedKeys.set(key, familyKey); + return familyKey; + } + + /** + * Get (creating on first use) the GPU instance buffer for one mesh, with + * its dirty span already uploaded. + * @param {InstancedMesh} mesh - the mesh being drawn + * @returns {WebGPUInstanceBuffer} the up-to-date buffer + * @ignore + */ + instanceBufferFor(mesh) { + let buffer = this.instanced.get(mesh); + if (buffer === undefined) { + buffer = new WebGPUInstanceBuffer(this.renderer); + this.instanced.set(mesh, buffer); + } + const plan = mesh.instanceUpload(buffer.uploadedRevision ?? -1); + if (plan.full) { + // this buffer missed edits the span no longer describes + buffer.capacity = 0; + } + buffer.upload( + mesh.instanceBuffer, + plan.first, + plan.count, + mesh.instanceCount * mesh.instanceLayout.stride, + ); + buffer.uploadedRevision = plan.revision; + mesh.clearInstanceDirty(); + return buffer; + } + + /** + * Draw every visible instance of a mesh in one recorded call. + * @param {InstancedMesh} mesh - the mesh to draw + * @param {Matrix3d} modelMatrix - where the group sits in the world + * @param {number} tint - tint colour in UINT32 (argb) format + * @ignore + */ + drawInstancedMesh(mesh, modelMatrix, tint) { + const count = mesh.visibleInstanceCount; + if (count === 0) { + return; + } + // anything queued must land first, or this draw would reorder ahead + this.flush(); + + this.updatePassState(); + this.applyMeshMaterial(mesh); + this.setPlacementUniforms(modelMatrix, tint, mesh); + + const renderer = this.renderer; + // A custom mesh shader is not hosted on the instanced path: the + // instanced families own their vertex layout (geometry group + + // per-instance group at pinned locations), which a custom module does + // not declare. Same limitation as the WebGL backend, warned the same + // way, so the two agree rather than one silently ignoring it. + if (this.customShader != null && this.instancedShaderWarned !== true) { + this.instancedShaderWarned = true; + console.warn( + "melonJS: a custom shader cannot be hosted on an InstancedMesh — the mesh draws with the built-in instanced shading", + ); + } + const pass = renderer.ensurePass(); + const geometry = this.retainedGeometryFor(mesh); + const instances = this.instanceBufferFor(mesh); + + const pipeline = renderer.pipelineCache.get( + this.instancedFamilyFor(mesh.instanceLayout), + "triangle-list", + "none", + renderer.premultipliedAlpha, + renderer.stencilMode, + this.meshState, + ); + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + pass.setBindGroup(1, this.currentMaterial); + this.bindLights(pass); + pass.setBindGroup(3, this.uniformBinding.bindGroup, [ + this.uniformBinding.dynamicOffset, + ]); + pass.setVertexBuffer(0, geometry.vertexBuffer); + pass.setVertexBuffer(1, instances.buffer); + pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat); + pass.drawIndexed(geometry.indexCount, count); + + // stamp both halves: an edit later this frame must go to fresh buffers + geometry.lastDrawnFrameId = renderer.frameId; + instances.lastDrawnFrameId = renderer.frameId; + } + + /** + * Release the instance buffer held for one mesh, if any. + * @param {object} mesh - the mesh whose instance records should be freed + * @ignore + */ + releaseInstanced(mesh) { + const buffer = this.instanced?.get(mesh); + if (buffer !== undefined) { + buffer.destroy(); + this.instanced.delete(mesh); + } + } + + /** + * Release every instance buffer this batcher holds. + * @ignore + */ + releaseAllInstanced() { + this.instanced?.forEach((buffer) => { + buffer.destroy(); + }); + this.instanced?.clear(); } /** @@ -577,6 +765,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { * @ignore */ releaseRetained(mesh) { + this.releaseInstanced(mesh); const geometry = this.retained.get(mesh); if (geometry !== undefined) { geometry.destroy(); @@ -589,6 +778,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { * @ignore */ releaseAllRetained() { + this.releaseAllInstanced(); this.retained.forEach((geometry) => { geometry.destroy(); }); diff --git a/packages/melonjs/src/video/webgpu/buffer/instance_buffer.js b/packages/melonjs/src/video/webgpu/buffer/instance_buffer.js new file mode 100644 index 000000000..b6094e682 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/buffer/instance_buffer.js @@ -0,0 +1,123 @@ +/** + * The WebGPU twin of the GL `WebGLInstanceBuffer`: one `InstancedMesh`'s + * per-instance records resident on the GPU, bound as a second vertex buffer + * whose layout steps once per instance. + * + * The same two queue-ordering laws that shape `WebGPURetainedGeometry` + * apply, and matter more here because instance records change far more often + * than geometry does: + * + * 1. `queue.writeBuffer` executes before **every** draw recorded this frame. + * So editing an instance on a mesh that has already drawn this frame — a + * second camera, a mid-frame animation step — must land in a FRESH + * buffer, or the earlier draw would retroactively show the new placement. + * 2. Destroying a buffer referenced by recorded draws fails the whole + * submit, so a replaced buffer retires through `renderer.retireBuffer` + * and dies after the frame's submit. + * + * Within a frame that has not yet drawn, an edit is a partial + * `writeBuffer` covering only the dirty span — moving one tree in a forest + * of five thousand writes 48 bytes. + * @ignore + */ +export default class WebGPUInstanceBuffer { + /** + * @param {import("../webgpu_renderer.js").default} renderer - the owning renderer + */ + constructor(renderer) { + this.renderer = renderer; + /** @type {GPUBuffer|null} */ + this.buffer = null; + /** allocated capacity in bytes @type {number} */ + this.capacity = 0; + /** the frame a draw was last recorded against this buffer @type {number} */ + this.lastDrawnFrameId = -1; + } + + /** + * Push the dirty span of `data` to the GPU, reallocating when the record + * set outgrew the allocation or when this buffer has already been drawn + * from this frame. + * @param {Float32Array} data - the CPU-side instance records + * @param {number} firstFloat - first dirty float offset (inclusive) + * @param {number} floatCount - number of dirty floats, 0 when clean + * @param {number} usedBytes - bytes of `data` actually in use + */ + upload(data, firstFloat, floatCount, usedBytes) { + const renderer = this.renderer; + const device = renderer.device; + // writeBuffer offsets and sizes must both be 4-byte multiples; a + // record is a whole number of floats, so spans are aligned already + const bytes = Float32Array.BYTES_PER_ELEMENT; + const drawnThisFrame = this.lastDrawnFrameId === renderer.frameId; + const outgrew = usedBytes > this.capacity; + + if (this.buffer === null || outgrew || (drawnThisFrame && floatCount > 0)) { + if (this.buffer !== null) { + // a draw recorded earlier this frame still references it + renderer.retireBuffer(this.buffer); + } + // allocate to the CPU array's full length so growth up to that + // capacity stays a partial write. A zero-length buffer is invalid, + // so never request less than one record's worth. + this.capacity = Math.max(data.byteLength, usedBytes); + this.buffer = device.createBuffer({ + label: "melonJS instance records", + size: Math.max(this.capacity, 4), + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + this.lastDrawnFrameId = -1; + // a fresh buffer starts empty, so the WHOLE record set goes in — + // writing only the dirty span would leave the rest zeroed, which + // reads as every other instance collapsed onto the origin + this.write(0, 0, data.byteLength, data); + return; + } + + if (floatCount === 0) { + return; + } + this.write( + firstFloat * bytes, + firstFloat * bytes, + floatCount * bytes, + data, + ); + } + + /** + * One `queue.writeBuffer`, in BYTES throughout. + * + * `writeBuffer`'s `dataOffset` and `size` are counted in *elements* when + * `data` is a TypedArray and in *bytes* otherwise — an ambiguity that + * silently changes meaning if the staging array's type ever changes. The + * engine's other GPU uploads all pass a `Uint8Array` view with byte + * counts, so this does too: one convention, no element/byte confusion. + * @param {number} bufferOffset - destination byte offset + * @param {number} byteOffset - source byte offset into `data` + * @param {number} byteLength - bytes to copy + * @param {Float32Array} data - the CPU-side instance records + * @ignore + */ + write(bufferOffset, byteOffset, byteLength, data) { + this.renderer.device.queue.writeBuffer( + this.buffer, + bufferOffset, + new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + byteOffset, + byteLength, + ); + } + + /** + * Release the GPU buffer (retired when a frame is recording). + */ + destroy() { + if (this.buffer !== null) { + this.renderer.retireBuffer(this.buffer); + this.buffer = null; + } + this.capacity = 0; + this.lastDrawnFrameId = -1; + } +} diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js index 4bfabe59d..adaa57159 100644 --- a/packages/melonjs/src/video/webgpu/pipeline/cache.js +++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js @@ -401,21 +401,51 @@ export default class WebGPUPipelineCache { * backend-neutral vertex formats (#1492): the records already carry * `format` and `offset` in WebGPU vocabulary, shader locations are * declaration order. + * Accepts either the single-buffer form `(key, stride, attributes)` or a + * list of buffer groups `(key, [{stride, stepMode, attributes}, …])` — + * the latter is what an instanced family registers, with the geometry + * group at index 0 and a `stepMode: "instance"` group after it. Shader + * locations are assigned in declaration order and continue **across** + * groups, since WGSL locations are a single namespace. * @param {string} shaderKey - "quad" | "primitive" - * @param {number} stride - vertex byte stride - * @param {{format: string, offset: number}[]} attributes - frozen records + * @param {number|object[]} stride - vertex byte stride, or the buffer-group list + * @param {{format: string, offset: number}[]} [attributes] - frozen records (single-buffer form) */ registerVertexLayout(shaderKey, stride, attributes) { - this.vertexLayouts.set(shaderKey, { - arrayStride: stride, - attributes: attributes.map((a, i) => { - return { - format: a.format, - offset: a.offset, - shaderLocation: i, + const groups = Array.isArray(stride) + ? stride + : [{ stride, attributes, stepMode: "vertex" }]; + let location = 0; + this.vertexLayouts.set( + shaderKey, + groups.map((group) => { + const layout = { + arrayStride: group.stride, + attributes: group.attributes.map((a) => { + // an attribute may pin its own location. Instanced + // families do: their optional slots come and go per + // variant, and sequential numbering would shift the + // remaining ones, so every WGSL variant would need + // renumbering. Pinned locations keep the module text + // identical whichever slots are present. + const shaderLocation = a.shaderLocation ?? location; + location = shaderLocation + 1; + return { + format: a.format, + offset: a.offset, + shaderLocation, + }; + }), }; + // omitted entirely for per-vertex groups: "vertex" is the + // WebGPU default, and emitting it would change the descriptor + // every existing pipeline is built from + if (group.stepMode === "instance") { + layout.stepMode = "instance"; + } + return layout; }), - }); + ); } /** @@ -469,7 +499,7 @@ export default class WebGPUPipelineCache { vertex: { module: this.modules[shaderKey], entryPoint: "vertex_main", - buffers: vertexLayout ? [vertexLayout] : [], + buffers: vertexLayout ?? [], }, fragment: { module: this.modules[shaderKey], diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js new file mode 100644 index 000000000..1f3c87483 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-instanced.js @@ -0,0 +1,207 @@ +import { + INSTANCE_ATTRIBUTE_NAMES, + INSTANCE_SLOT_OFFSETS, +} from "../../gpu/instancerecord.ts"; + +/** + * Derive an instanced mesh WGSL module from the ordinary one (#1508). + * + * WGSL has no preprocessor, and a module carries **both** entry points in + * one file — so an instanced variant cannot be a second vertex file the way + * it is on the WebGL side. Rather than copy the module (which for the lit + * tier would duplicate the whole lighting loop, and the `Light3dBlock` + * layout that must agree byte-for-byte with the uniform packer), this + * rewrites the shared source: + * + * - everything before `@vertex` is kept — structs, bindings, `VSOut` — + * with one extra inter-stage field when the custom slot is in play; + * - the vertex stage is replaced with one that reads the per-instance + * attributes and applies them; + * - the fragment stage is kept **verbatim**, apart from one substitution + * that folds the per-instance slot into the emissive term. + * + * So the lighting loop, the light block and the alpha-cutout rule exist in + * exactly one place, and an instanced mesh shades identically to its + * uninstanced twin by construction rather than by review. + * + * Shader locations for the instance slots are **pinned** + * (`INSTANCE_SLOT_OFFSETS`), so a variant that omits the colour slot does + * not renumber the data slot. + * @param {string} source - the ordinary module (`mesh.wgsl` / `mesh-lit.wgsl`) + * @param {object} options - variant options + * @param {number} options.baseLocation - first vertex location after the tier's geometry attributes + * @param {number} options.varyingLocation - first free inter-stage location in `VSOut` + * @param {boolean} options.hasColor - whether the per-instance colour slot is present + * @param {boolean} options.hasData - whether the per-instance custom slot is present + * @param {string} options.geometryInputs - the tier's geometry attribute declarations + * @param {string} options.body - the tier's placement body, which reads the `instance` matrix the generated preamble declares and must leave a `clip` value for the shared tail + * @returns {string} the instanced module text + * @ignore + */ +export function buildInstancedMeshWGSL(source, options) { + const { + baseLocation, + varyingLocation, + hasColor, + hasData, + geometryInputs, + body, + } = options; + + // match the entry points at the START of a line: `@vertex` mentioned in a + // doc comment would otherwise slice the module in the wrong place + const vertexAt = source.search(/^@vertex\b/m); + const fragmentAt = source.search(/^@fragment\b/m); + if (vertexAt < 0 || fragmentAt < 0 || fragmentAt < vertexAt) { + throw new Error( + "buildInstancedMeshWGSL: the source module must declare both stages, vertex first", + ); + } + + let head = source.slice(0, vertexAt); + let fragment = source.slice(fragmentAt); + + // the custom slot travels to the fragment stage as one more varying, and + // is folded into the term the fragment already adds after shading + if (hasData) { + head = head.replace( + /(struct VSOut \{[\s\S]*?)\n\};/, + `$1\n\t// per-instance custom slot; this built-in shading reads its\n\t// rgb as emissive (a custom module may read it as anything)\n\t@location(${varyingLocation}) vInstanceData : vec4f,\n};`, + ); + const emissive = "uMesh.emissive.rgb"; + if (!fragment.includes(emissive)) { + throw new Error( + "buildInstancedMeshWGSL: the fragment stage no longer reads `uMesh.emissive.rgb` — the per-instance emissive substitution needs updating", + ); + } + fragment = fragment.replaceAll( + emissive, + "(uMesh.emissive.rgb + in.vInstanceData.rgb)", + ); + } + + const rows = INSTANCE_ATTRIBUTE_NAMES.rows; + const inputs = [geometryInputs]; + rows.forEach((name, i) => { + inputs.push( + `\t@location(${baseLocation + INSTANCE_SLOT_OFFSETS.rows[i]}) ${name} : vec4f,`, + ); + }); + if (hasColor) { + inputs.push( + `\t@location(${baseLocation + INSTANCE_SLOT_OFFSETS.color}) ${INSTANCE_ATTRIBUTE_NAMES.color} : vec4f,`, + ); + } + if (hasData) { + inputs.push( + `\t@location(${baseLocation + INSTANCE_SLOT_OFFSETS.data}) ${INSTANCE_ATTRIBUTE_NAMES.data} : vec4f,`, + ); + } + + const vertex = [ + "@vertex", + "fn vertex_main(", + inputs.join("\n"), + ") -> VSOut {", + "\tvar out : VSOut;", + "\t// The instance transform arrives as three ROW-major vec4 rows, not a", + "\t// full mat4: the bottom row of an affine matrix is always (0,0,0,1),", + "\t// so storing it would waste 16 bytes and an attribute slot per", + "\t// instance. mat4x4f takes COLUMNS, hence the transpose.", + "\tlet instance = mat4x4f(", + `\t\tvec4f(${rows[0]}.x, ${rows[1]}.x, ${rows[2]}.x, 0.0),`, + `\t\tvec4f(${rows[0]}.y, ${rows[1]}.y, ${rows[2]}.y, 0.0),`, + `\t\tvec4f(${rows[0]}.z, ${rows[1]}.z, ${rows[2]}.z, 0.0),`, + `\t\tvec4f(${rows[0]}.w, ${rows[1]}.w, ${rows[2]}.w, 1.0));`, + body, + "\t// GL-convention clip z in [-w, w] remapped to WebGPU's [0, w]", + "\tout.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);", + hasColor + ? `\tlet tinted = aColor * uMesh.tint * ${INSTANCE_ATTRIBUTE_NAMES.color};` + : "\tlet tinted = aColor * uMesh.tint;", + "\t// tint first, then premultiply — matches the fragment's expectation", + "\tout.vColor = vec4f(tinted.rgb * tinted.a, tinted.a);", + "\tout.vRegion = aRegion;", + hasData ? `\tout.vInstanceData = ${INSTANCE_ATTRIBUTE_NAMES.data};` : null, + "\treturn out;", + "}", + "", + "", + ] + .filter((line) => { + return line !== null; + }) + .join("\n"); + + const module = head + vertex + fragment; + + // The generated vertex stage hard-codes which VSOut members it writes, so + // a member added to the shared source would silently arrive as zeros in + // the fragment stage (WGSL zero-initializes `var out : VSOut`). Fail + // instead — every declared member must be assigned. + const struct = /struct VSOut \{([\s\S]*?)\n\};/.exec(head); + if (struct !== null) { + const generated = module.slice(module.search(/^@vertex\b/m)); + for (const [, member] of struct[1].matchAll( + /@location\(\d+\)\s+(\w+)\s*:/g, + )) { + if (!generated.includes(`out.${member}`)) { + throw new Error( + `buildInstancedMeshWGSL: VSOut member "${member}" is never written by the generated vertex stage — the derived module has drifted from its source`, + ); + } + } + // and no two members may share an inter-stage location + const locations = [...struct[1].matchAll(/@location\((\d+)\)/g)].map( + (m) => { + return m[1]; + }, + ); + if (new Set(locations).size !== locations.length) { + throw new Error( + "buildInstancedMeshWGSL: duplicate @location in the derived VSOut", + ); + } + } + + return module; +} + +/** the unlit tier's geometry inputs and placement body @ignore */ +export const UNLIT_INSTANCED = { + baseLocation: 3, + varyingLocation: 2, + geometryInputs: [ + "\t@location(0) aVertex : vec3f,", + "\t@location(1) aRegion : vec2f,", + "\t@location(2) aColor : vec4f,", + ].join("\n"), + body: [ + "\tlet clip = uFrame.projection * uMesh.view * uMesh.model * instance", + "\t\t* vec4f(aVertex, 1.0);", + ].join("\n"), +}; + +/** the lit tier's geometry inputs, placement body and normal handling @ignore */ +export const LIT_INSTANCED = { + baseLocation: 4, + varyingLocation: 4, + geometryInputs: [ + "\t@location(0) aVertex : vec3f,", + "\t@location(1) aRegion : vec2f,", + "\t@location(2) aColor : vec4f,", + "\t@location(3) aNormal : vec3f,", + ].join("\n"), + body: [ + "\tlet worldPos = uMesh.model * instance * vec4f(aVertex, 1.0);", + "\tlet clip = uFrame.projection * uMesh.view * worldPos;", + "\tout.vWorldPos = worldPos.xyz;", + "\t// Rotate the normal through BOTH transforms, in the order the", + "\t// position takes them. Non-uniform scale is approximated exactly as", + "\t// the uninstanced path approximates it, so an instanced mesh shades", + "\t// like its uninstanced twin rather than subtly differently.", + "\tlet m = uMesh.model;", + "\tlet mi = mat3x3f(instance[0].xyz, instance[1].xyz, instance[2].xyz);", + "\tout.vNormal = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * mi * aNormal;", + ].join("\n"), +}; diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index c32660650..bd4b58ae7 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -156,6 +156,7 @@ export default class WebGPURenderer extends Renderer { // scenes take the uniforms-only drawMesh(mesh, modelMatrix) path this.supportsDepthBuffer = true; this.supportsRetainedMesh = true; + this.supportsInstancing = true; // lazy orientation-specific GPU tilemap renderer (device-scoped: // dropped on device loss, rebuilt on first use) /** @ignore @@ -1626,7 +1627,16 @@ export default class WebGPURenderer extends Renderer { // a later mesh (or the frame-end drain) that didn't ask for it try { const tint = this.currentTint.toUint32(this.getGlobalAlpha()); - if (retained) { + if ( + mesh.instanceLayout !== undefined && + retained && + this.supportsInstancing === true + ) { + // one geometry, N copies, one recorded draw — the per-instance + // records carry what each copy differs by, while `modelMatrix` + // places the group as a whole + batcher.drawInstancedMesh(mesh, modelMatrix, tint); + } else if (retained) { batcher.drawRetainedMesh(mesh, modelMatrix, tint); } else { batcher.addMesh(mesh, tint); diff --git a/packages/melonjs/tests/forest_asset.spec.js b/packages/melonjs/tests/forest_asset.spec.js new file mode 100644 index 000000000..75178dae7 --- /dev/null +++ b/packages/melonjs/tests/forest_asset.spec.js @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import forestUrl from "../../examples/public/assets/gltf/forest.glb?url"; +import { parseGLB } from "../src/loader/parsers/gltf.js"; + +/** + * Asset integrity for the forest example (#1508). + * + * The example proves nothing by itself: if `forest.glb` ever stopped + * carrying `EXT_mesh_gpu_instancing` it would still render — as 400 + * separate meshes, or as one tree — and the loader path the example exists + * to exercise would be silently untested. + * + * That is not hypothetical. The asset is meant to be re-authored in + * Blender, whose glTF exporter only writes the extension for **linked + * duplicates** (`Alt+D`, sharing one mesh data-block) with its GPU-instances + * option enabled; `Shift+D` copies, or an object-level modifier, silently + * produce N separate meshes instead. So the invariants are pinned here + * rather than left to a one-time check at authoring time. + * + * These assertions describe the asset's CONTRACT, not the generator that + * currently produces it — a Blender export satisfying them passes unchanged. + */ +describe("forest.glb", () => { + const load = async () => { + const response = await fetch(forestUrl); + expect(response.ok, "forest.glb should be reachable").toBe(true); + return parseGLB(await response.arrayBuffer()); + }; + + it("is a valid GLB carrying a JSON and a binary chunk", async () => { + const { json, bin } = await load(); + expect(json).toBeDefined(); + expect(json.asset.version).toBe("2.0"); + expect(bin).not.toBe(null); + expect(bin.byteLength).toBeGreaterThan(0); + }); + + it("declares EXT_mesh_gpu_instancing", async () => { + const { json } = await load(); + expect(json.extensionsUsed).toContain("EXT_mesh_gpu_instancing"); + }); + + it("has a node carrying per-instance TRANSLATION", async () => { + const { json } = await load(); + const instanced = json.nodes.filter((node) => { + return node.extensions?.EXT_mesh_gpu_instancing !== undefined; + }); + expect(instanced.length).toBeGreaterThan(0); + for (const node of instanced) { + const attributes = node.extensions.EXT_mesh_gpu_instancing.attributes; + expect(attributes.TRANSLATION, node.name).toBeDefined(); + // and it references a real VEC3 accessor with instances in it + const accessor = json.accessors[attributes.TRANSLATION]; + expect(accessor.type, node.name).toBe("VEC3"); + expect(accessor.count, node.name).toBeGreaterThan(1); + } + }); + + it("every instance attribute has the SAME count — the spec requires it", async () => { + const { json } = await load(); + for (const node of json.nodes) { + const attributes = + node.extensions?.EXT_mesh_gpu_instancing?.attributes ?? null; + if (attributes === null) { + continue; + } + const counts = Object.values(attributes).map((index) => { + return json.accessors[index].count; + }); + expect(new Set(counts).size, `${node.name} attribute counts`).toBe(1); + } + }); + + it("carries the tree geometry ONCE, not once per instance", async () => { + // the regression that matters: an export that lost the extension + // would come back as N nodes over N meshes, and the whole point of + // the example would be gone with nothing failing + const { json } = await load(); + const instanced = json.nodes.find((node) => { + return node.extensions?.EXT_mesh_gpu_instancing !== undefined; + }); + const instanceCount = + json.accessors[ + instanced.extensions.EXT_mesh_gpu_instancing.attributes.TRANSLATION + ].count; + + // a handful of meshes (the tree, the ground), NOT one per instance + expect(json.meshes.length).toBeLessThan(8); + expect(json.meshes.length).toBeLessThan(instanceCount); + expect(json.nodes.length).toBeLessThan(instanceCount); + + // and the instanced node's own mesh has real geometry behind it + const primitive = json.meshes[instanced.mesh].primitives[0]; + expect(primitive.attributes.POSITION).toBeDefined(); + expect(json.accessors[primitive.attributes.POSITION].count).toBeGreaterThan( + 3, + ); + }); + + it("scatters enough trees to be worth instancing", async () => { + // a two-instance asset would pass every structural check above while + // demonstrating nothing + const { json } = await load(); + const instanced = json.nodes.find((node) => { + return node.extensions?.EXT_mesh_gpu_instancing !== undefined; + }); + const count = + json.accessors[ + instanced.extensions.EXT_mesh_gpu_instancing.attributes.TRANSLATION + ].count; + expect(count).toBeGreaterThanOrEqual(100); + }); + + it("costs records per instance, not geometry per instance", async () => { + // The memory claim, asserted RELATIVE to the instance count rather + // than as a fixed ceiling — a fixed one silently becomes either + // meaningless or an obstacle as the scatter grows. + // + // An instance record is 32 bytes on disk (float translation + short + // quaternion + float scale) and 48 on the GPU. Baking the same tree out N + // times would cost its whole vertex set each time (52 verts of + // position + normal + uv + colour ≈ 2.3 KB), so the file must stay + // FAR closer to the former than the latter. + const { json } = await load(); + const instanced = json.nodes.find((node) => { + return node.extensions?.EXT_mesh_gpu_instancing !== undefined; + }); + const count = + json.accessors[ + instanced.extensions.EXT_mesh_gpu_instancing.attributes.TRANSLATION + ].count; + const response = await fetch(forestUrl); + const bytes = (await response.arrayBuffer()).byteLength; + + // generous headroom over the ~40 B/instance the records actually + // cost, while still an order of magnitude under baked-out geometry + expect(bytes).toBeLessThan(count * 200); + }); +}); diff --git a/packages/melonjs/tests/gltf_instancing.spec.js b/packages/melonjs/tests/gltf_instancing.spec.js new file mode 100644 index 000000000..1ec1b2b5d --- /dev/null +++ b/packages/melonjs/tests/gltf_instancing.spec.js @@ -0,0 +1,632 @@ +import { describe, expect, it, vi } from "vitest"; +import { Matrix3d, Vector3d } from "../src/index.js"; +import { parseGLTF } from "../src/loader/parsers/gltf.js"; +import { + instanceAttributes, + instanceRecordLayout, + readInstanceTransform, + writeInstanceTRS, + writeInstanceTransform, +} from "../src/video/gpu/instancerecord.ts"; + +/** + * `EXT_mesh_gpu_instancing` (#1508) — authored instancing. + * + * A glTF node may carry per-instance TRANSLATION / ROTATION / SCALE + * accessors instead of being duplicated N times; it is what an exporter + * writes for linked duplicates. Reading it turns a scattered forest into one + * `InstancedMesh` with no user code. + * + * These build real glTF documents rather than mocking the reader, so the + * accessor plumbing (byte offsets, component encodings, defaults) is + * genuinely exercised. + */ +describe("EXT_mesh_gpu_instancing", () => { + // a minimal single-triangle mesh plus whatever instance accessors the + // test asks for, as one self-contained glTF document + const buildDocument = (instanceAttributes, extra = {}) => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const chunks = [{ data: new Uint8Array(positions.buffer) }]; + const accessors = [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }, + ]; + const bufferViews = [{ buffer: 0, byteOffset: 0, byteLength: 36 }]; + let offset = 36; + const attributes = {}; + for (const [name, spec] of Object.entries(instanceAttributes)) { + const bytes = new Uint8Array(spec.array.buffer.slice(0)); + chunks.push({ data: bytes }); + bufferViews.push({ + buffer: 0, + byteOffset: offset, + byteLength: bytes.byteLength, + }); + accessors.push({ + bufferView: bufferViews.length - 1, + componentType: spec.componentType, + count: spec.count, + type: spec.type, + normalized: spec.normalized, + }); + attributes[name] = accessors.length - 1; + offset += bytes.byteLength; + } + + const total = chunks.reduce((sum, c) => { + return sum + c.data.byteLength; + }, 0); + const merged = new Uint8Array(total); + let at = 0; + for (const chunk of chunks) { + merged.set(chunk.data, at); + at += chunk.data.byteLength; + } + + // a real .gltf document with the binary embedded as a data URI, so + // parseGLTF runs its whole path (buffer resolution, bufferViews, + // accessor strides) rather than a mocked shortcut + let binary = ""; + for (const byte of merged) { + binary += String.fromCharCode(byte); + } + const json = { + asset: { version: "2.0" }, + extensionsUsed: ["EXT_mesh_gpu_instancing"], + scenes: [{ nodes: [0] }], + scene: 0, + nodes: [ + { + mesh: 0, + name: "Trees", + extensions: extra.noExtension + ? undefined + : { EXT_mesh_gpu_instancing: { attributes } }, + }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], + accessors, + bufferViews, + buffers: [ + { + byteLength: merged.byteLength, + uri: `data:application/octet-stream;base64,${btoa(binary)}`, + }, + ], + }; + return new TextEncoder().encode(JSON.stringify(json)).buffer; + }; + + const parse = async (document) => { + return await parseGLTF(document, undefined, {}); + }; + + const TRANSLATIONS = new Float32Array([ + 0, 0, 0, 10, 0, 0, 0, 0, 20, -5, 0, -5, + ]); + + it("reads a node's per-instance translations", async () => { + const data = await parse( + buildDocument({ + TRANSLATION: { + array: TRANSLATIONS, + componentType: 5126, + count: 4, + type: "VEC3", + }, + }), + ); + const node = data.nodes[0]; + expect(node.instances).toBeDefined(); + expect(node.instances.count).toBe(4); + expect(Array.from(node.instances.translation)).toEqual( + Array.from(TRANSLATIONS), + ); + // the geometry is carried ONCE, not per instance — the whole point + expect(node.vertexCount).toBe(3); + }); + + it("leaves an ordinary node without instances", async () => { + const data = await parse(buildDocument({}, { noExtension: true })); + expect(data.nodes[0].instances).toBeUndefined(); + }); + + it("a node declaring the extension with no attributes is not instanced", async () => { + // tolerated rather than treated as an error: there is simply nothing + // to instance, and the node should still render once + const data = await parse(buildDocument({})); + expect(data.nodes[0].instances).toBeUndefined(); + }); + + it("derives the count from whichever accessor is present", async () => { + // TRANSLATION absent, SCALE present — the count must come from SCALE + // rather than defaulting to zero and silently dropping the node + const data = await parse( + buildDocument({ + SCALE: { + array: new Float32Array([1, 1, 1, 2, 2, 2]), + componentType: 5126, + count: 2, + type: "VEC3", + }, + }), + ); + expect(data.nodes[0].instances.count).toBe(2); + expect(data.nodes[0].instances.translation).toBeUndefined(); + expect(Array.from(data.nodes[0].instances.scale)).toEqual([ + 1, 1, 1, 2, 2, 2, + ]); + }); + + it("reads all three attributes together", async () => { + const data = await parse( + buildDocument({ + TRANSLATION: { + array: new Float32Array([1, 2, 3]), + componentType: 5126, + count: 1, + type: "VEC3", + }, + ROTATION: { + array: new Float32Array([0, 0, 0, 1]), + componentType: 5126, + count: 1, + type: "VEC4", + }, + SCALE: { + array: new Float32Array([2, 2, 2]), + componentType: 5126, + count: 1, + type: "VEC3", + }, + }), + ); + const instances = data.nodes[0].instances; + expect(instances.count).toBe(1); + expect(Array.from(instances.translation)).toEqual([1, 2, 3]); + expect(Array.from(instances.rotation)).toEqual([0, 0, 0, 1]); + expect(Array.from(instances.scale)).toEqual([2, 2, 2]); + }); + + it("scales a normalized SHORT rotation back to [-1, 1]", async () => { + // exporters use normalized integers to shrink large scatters. Read + // raw, a short-encoded quaternion arrives as values up to 32767 and + // every instance is flung out of the scene. + const quarterTurn = Math.SQRT1_2; // sin/cos of 45° + const encoded = new Int16Array([ + 0, + Math.round(quarterTurn * 32767), + 0, + Math.round(quarterTurn * 32767), + ]); + const data = await parse( + buildDocument({ + ROTATION: { + array: encoded, + componentType: 5122, + count: 1, + type: "VEC4", + normalized: true, + }, + }), + ); + const rotation = data.nodes[0].instances.rotation; + expect(rotation[0]).toBeCloseTo(0, 4); + expect(rotation[1]).toBeCloseTo(quarterTurn, 4); + expect(rotation[3]).toBeCloseTo(quarterTurn, 4); + // and it is a unit quaternion, which is what makes it a rotation + const length = Math.hypot(...rotation); + expect(length).toBeCloseTo(1, 4); + }); + + it("scales a normalized BYTE rotation, clamping the extra negative value", async () => { + // two's complement gives -128 where the normalized range stops at + // -127; the spec says clamp rather than overshoot + const encoded = new Int8Array([-128, 0, 0, 127]); + const data = await parse( + buildDocument({ + ROTATION: { + array: encoded, + componentType: 5120, + count: 1, + type: "VEC4", + normalized: true, + }, + }), + ); + const rotation = data.nodes[0].instances.rotation; + expect(rotation[0]).toBe(-1); + expect(rotation[3]).toBeCloseTo(1, 5); + }); + + it("leaves a float ROTATION untouched", async () => { + const exact = new Float32Array([0.5, -0.5, 0.5, 0.5]); + const data = await parse( + buildDocument({ + ROTATION: { + array: exact, + componentType: 5126, + count: 1, + type: "VEC4", + }, + }), + ); + expect(Array.from(data.nodes[0].instances.rotation)).toEqual( + Array.from(exact), + ); + }); +}); + +/** + * The TRS → instance-record composition (#1508). Authored instancing + * arrives as translation / quaternion / scale rather than matrices, and is + * composed straight into the row-major record — a forest of five thousand + * trees would otherwise allocate five thousand throwaway matrices at load. + * + * Composing by hand is exactly the kind of code that is subtly wrong in a + * way nothing catches: a transposed rotation still looks like "trees at + * angles". So these check it against the matrix path that is already + * trusted, rather than against my own arithmetic. + */ +describe("writeInstanceTRS", () => { + const FLOATS = 12; + + const record = (t, r, s) => { + const out = new Float32Array(FLOATS); + writeInstanceTRS(out, 0, ...t, ...r, ...s); + return out; + }; + + // the row-major record read back as a Matrix3d, for comparison against + // the matrix path + const asMatrix = (out) => { + return readInstanceTransform(out, 0, new Matrix3d()); + }; + + it("an identity TRS is the identity transform", () => { + const out = record([0, 0, 0], [0, 0, 0, 1], [1, 1, 1]); + expect(asMatrix(out).isIdentity()).toBe(true); + }); + + it("translation lands in the rows' w components, not a fourth row", () => { + const out = record([7, 8, 9], [0, 0, 0, 1], [1, 1, 1]); + expect(out[3]).toBe(7); + expect(out[7]).toBe(8); + expect(out[11]).toBe(9); + // and the record is 12 floats — there is no fourth row to write + expect(out).toHaveLength(12); + }); + + it("scale multiplies the basis columns, leaving translation alone", () => { + const out = record([5, 0, 0], [0, 0, 0, 1], [2, 3, 4]); + const m = asMatrix(out); + expect(m.val[0]).toBeCloseTo(2, 5); + expect(m.val[5]).toBeCloseTo(3, 5); + expect(m.val[10]).toBeCloseTo(4, 5); + expect(m.val[12]).toBeCloseTo(5, 5); + }); + + it("matches the trusted matrix path for a rotation about Y", () => { + // the real check: compose the same rotation through Matrix3d and + // compare. A transposed quaternion basis passes every "is it + // rotated?" eyeball test and fails this one. + const angle = Math.PI / 3; + const half = angle / 2; + const out = record( + [0, 0, 0], + [0, Math.sin(half), 0, Math.cos(half)], + [1, 1, 1], + ); + const expected = new Matrix3d() + .identity() + .rotate(angle, new Vector3d(0, 1, 0)); + const got = asMatrix(out); + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10]) { + expect(got.val[i], `val[${i}]`).toBeCloseTo(expected.val[i], 5); + } + }); + + it("matches the matrix path for a rotation about an arbitrary axis", () => { + const axis = new Vector3d(1, 2, 3); + const length = Math.hypot(axis.x, axis.y, axis.z); + const angle = 0.7; + const half = angle / 2; + const sin = Math.sin(half) / length; + const out = record( + [0, 0, 0], + [axis.x * sin, axis.y * sin, axis.z * sin, Math.cos(half)], + [1, 1, 1], + ); + const expected = new Matrix3d().identity().rotate(angle, axis); + const got = asMatrix(out); + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10]) { + expect(got.val[i], `val[${i}]`).toBeCloseTo(expected.val[i], 5); + } + }); + + it("applies rotation BEFORE translation (glTF's T * R * S order)", () => { + // getting the order backwards rotates the whole scatter about the + // origin instead of rotating each instance in place — every tree + // still stands up, but the forest is in the wrong place + const half = Math.PI / 4; // 90° about Y, halved + const out = record( + [10, 0, 0], + [0, Math.sin(half), 0, Math.cos(half)], + [1, 1, 1], + ); + // the translation survives verbatim; it is not rotated by R + expect(out[3]).toBeCloseTo(10, 5); + expect(out[11]).toBeCloseTo(0, 5); + }); + + it("composes rotation and scale in the right order", () => { + // R * S, not S * R: scaling after rotating would shear a + // non-uniformly scaled instance + const half = Math.PI / 4; + const out = record( + [0, 0, 0], + [0, Math.sin(half), 0, Math.cos(half)], + [2, 1, 1], + ); + const expected = new Matrix3d() + .identity() + .rotate(Math.PI / 2, new Vector3d(0, 1, 0)) + .scale(2, 1, 1); + const got = asMatrix(out); + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10]) { + expect(got.val[i], `val[${i}]`).toBeCloseTo(expected.val[i], 5); + } + }); + + it("round-trips through the record without drift", () => { + const half = 0.3; + const out = record( + [1.5, -2.5, 3.5], + [Math.sin(half) * 0.6, Math.sin(half) * 0.8, 0, Math.cos(half)], + [1.25, 1.25, 1.25], + ); + const again = new Float32Array(FLOATS); + writeInstanceTransform(again, 0, asMatrix(out)); + for (let i = 0; i < FLOATS; i++) { + expect(again[i], `float ${i}`).toBeCloseTo(out[i], 5); + } + }); +}); + +/** + * Hardening found by code review (#1508): malformed and adversarial + * instancing data. Each of these previously produced a silently wrong scene + * — NaN records, a stray prototype, or a basis blown up by ~1e5 — rather + * than an error. + */ +describe("EXT_mesh_gpu_instancing — malformed input", () => { + const doc = (attributes, opts = {}) => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const chunks = [new Uint8Array(positions.buffer)]; + const accessors = [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + ]; + const bufferViews = [{ buffer: 0, byteOffset: 0, byteLength: 36 }]; + let offset = 36; + const attrs = {}; + for (const [name, spec] of Object.entries(attributes)) { + const bytes = new Uint8Array(spec.array.buffer.slice(0)); + chunks.push(bytes); + bufferViews.push({ + buffer: 0, + byteOffset: offset, + byteLength: bytes.byteLength, + }); + const accessor = { + bufferView: bufferViews.length - 1, + componentType: spec.componentType, + count: spec.count, + type: spec.type, + }; + if (spec.sparse) { + accessor.sparse = { count: 1 }; + } + accessors.push(accessor); + attrs[name] = accessors.length - 1; + offset += bytes.byteLength; + } + const total = chunks.reduce((n, c) => { + return n + c.byteLength; + }, 0); + const merged = new Uint8Array(total); + let at = 0; + for (const c of chunks) { + merged.set(c, at); + at += c.byteLength; + } + let binary = ""; + for (const b of merged) { + binary += String.fromCharCode(b); + } + return new TextEncoder().encode( + JSON.stringify({ + asset: { version: "2.0" }, + extensionsUsed: ["EXT_mesh_gpu_instancing"], + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { + mesh: 0, + name: "Trees", + extensions: { + EXT_mesh_gpu_instancing: { attributes: opts.empty ? {} : attrs }, + }, + }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], + accessors, + bufferViews, + buffers: [ + { + byteLength: merged.byteLength, + uri: `data:application/octet-stream;base64,${btoa(binary)}`, + }, + ], + }), + ).buffer; + }; + + const vec3 = (n) => { + return { + array: new Float32Array(n * 3), + componentType: 5126, + count: n, + type: "VEC3", + }; + }; + + it("clamps to the SHORTEST accessor and warns when counts disagree", async () => { + // reading past the short accessor gives undefined -> NaN records, and + // NaN clip coordinates make those instances silently disappear + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const data = await parseGLTF( + doc({ + TRANSLATION: vec3(10), + ROTATION: { + array: new Float32Array(4 * 2), + componentType: 5126, + count: 2, + type: "VEC4", + }, + }), + undefined, + {}, + ); + expect(data.nodes[0].instances.count).toBe(2); + expect(warn).toHaveBeenCalled(); + expect(String(warn.mock.calls[0][0])).toMatch(/disagree on count/); + warn.mockRestore(); + }); + + it("rejects a wrongly-typed accessor rather than deriving a fractional count", async () => { + await expect( + parseGLTF( + doc({ + TRANSLATION: { + array: new Float32Array(4 * 4), + componentType: 5126, + count: 4, + type: "VEC4", // must be VEC3 + }, + }), + undefined, + {}, + ), + ).rejects.toThrow(/TRANSLATION must be VEC3/); + }); + + it("rejects a sparse instance accessor instead of reading its base data", async () => { + await expect( + parseGLTF( + doc({ TRANSLATION: { ...vec3(4), sparse: true } }), + undefined, + {}, + ), + ).rejects.toThrow(/sparse/); + }); + + it("rejects an UNSIGNED quaternion encoding", async () => { + // passed through raw, a component of 255 makes the basis ~1e5 and + // destroys the scene + await expect( + parseGLTF( + doc({ + ROTATION: { + array: new Uint16Array([0, 0, 0, 65535]), + componentType: 5123, + count: 1, + type: "VEC4", + }, + }), + undefined, + {}, + ), + ).rejects.toThrow(/ROTATION must be float or normalized/); + }); + + it("an extension with NO attributes leaves the node un-instanced", async () => { + const data = await parseGLTF(doc({}, { empty: true }), undefined, {}); + expect(data.nodes[0].instances).toBeUndefined(); + }); + + it("an authored EMPTY scatter yields zero instances, not one stray copy", async () => { + const data = await parseGLTF(doc({ TRANSLATION: vec3(0) }), undefined, {}); + expect(data.nodes[0].instances).toBeDefined(); + expect(data.nodes[0].instances.count).toBe(0); + }); +}); + +describe("writeInstanceTRS — adversarial", () => { + const record = (t, r, s) => { + const out = new Float32Array(12); + writeInstanceTRS(out, 0, ...t, ...r, ...s); + return out; + }; + + it("a non-unit quaternion distorts the basis (exporters emit these)", () => { + // Documents the behaviour rather than pretending it cannot happen: the + // standard quaternion->basis formula assumes a unit quaternion, so a + // denormalized one silently bakes a scale into the instance. The axis + // the rotation is about stays unit length; the perpendicular ones do + // not, which is what makes it hard to spot by eye. + const out = record([0, 0, 0], [0.6, 0, 0, 0.6], [1, 1, 1]); + expect(Math.hypot(out[0], out[4], out[8])).toBeCloseTo(1, 5); + const perpendicular = Math.hypot(out[1], out[5], out[9]); + expect(perpendicular).not.toBeCloseTo(1, 3); + }); + + it("a mirrored (negative) scale flips the basis determinant", () => { + const out = record([0, 0, 0], [0, 0, 0, 1], [-1, 1, 1]); + expect(out[0]).toBeCloseTo(-1, 5); + // the other axes are untouched — only X mirrors + expect(out[5]).toBeCloseTo(1, 5); + expect(out[10]).toBeCloseTo(1, 5); + }); + + it("writes at a NON-ZERO offset inside a wide record", () => { + // every other test uses offset 0, so the offset arithmetic against + // colorOffset/dataOffset is otherwise unexercised + const layout = instanceRecordLayout(true, true); + const buffer = new Float32Array(3 * layout.floats).fill(7); + writeInstanceTRS(buffer, 2 * layout.floats, 5, 6, 7, 0, 0, 0, 1, 1, 1, 1); + const at = 2 * layout.floats; + expect(buffer[at + 3]).toBe(5); + expect(buffer[at + 7]).toBe(6); + expect(buffer[at + 11]).toBe(7); + // the neighbouring record and this record's own slots are untouched + expect(buffer[at - 1]).toBe(7); + expect(buffer[at + layout.colorOffset]).toBe(7); + }); +}); + +describe("the instance record layout", () => { + it("numbers the optional slots at FIXED offsets, not sequentially", () => { + // this is what lets ONE derived WGSL module serve every variant: a + // variant without the colour slot must not renumber the data slot + const dataOnly = instanceAttributes(instanceRecordLayout(false, true), 8); + const both = instanceAttributes(instanceRecordLayout(true, true), 8); + const dataSlot = dataOnly[dataOnly.length - 1]; + const bothData = both[both.length - 1]; + expect(dataSlot.shaderLocation).toBe(12); // base + 4, NOT base + 3 + expect(bothData.shaderLocation).toBe(12); + // same location, different byte offsets — the colour slot moved it + expect(dataSlot.offset).toBe(48); + expect(bothData.offset).toBe(64); + }); + + it("packs with no hole when data is declared without colour", () => { + const layout = instanceRecordLayout(false, true); + expect(layout.dataOffset).toBe(12); + expect(layout.stride).toBe(64); + expect(layout.colorOffset).toBe(-1); + }); +}); diff --git a/packages/melonjs/tests/webgl_mesh_instanced.spec.js b/packages/melonjs/tests/webgl_mesh_instanced.spec.js new file mode 100644 index 000000000..a10d1551d --- /dev/null +++ b/packages/melonjs/tests/webgl_mesh_instanced.spec.js @@ -0,0 +1,921 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + Camera3d, + Color, + InstancedMesh, + Matrix3d, + Mesh, +} from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * Mesh instancing (#1508) — one geometry drawn N times from a per-instance + * record buffer. + * + * The claims worth pinning are all about *what does not happen*: N instances + * must cost ONE draw call, moving one instance must re-upload only its own + * record, and moving the whole group must upload nothing at all. Those are + * asserted by counting GL calls rather than by timing frames — counts are + * exact and reproducible in CI, where the rasterizer is software and frame + * times mean nothing. + */ +describe("Mesh instancing (#1508)", () => { + let renderer; + let camera; + + // a unit quad: two triangles, enough geometry to be a real indexed draw + const GEOMETRY = { + vertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), + }; + + beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); + camera = new Camera3d(0, 0, 128, 128); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeInstanced = (count, settings = {}) => { + return new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: count, + ...settings, + }); + }; + + const drawOnce = (mesh) => { + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + renderer.flush(); + }; + + describe("the record layout", () => { + it("is 48 bytes bare — a 3x4 transform, not a mat4", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(4); + expect(mesh.instanceLayout.stride).toBe(48); + expect(mesh.instanceLayout.floats).toBe(12); + expect(mesh.instanceLayout.hasColor).toBe(false); + expect(mesh.instanceLayout.hasData).toBe(false); + mesh.destroy(); + }); + + it("grows by exactly 16 bytes per opt-in slot, in declaration order", (ctx) => { + requireWebGL(ctx, renderer); + const colored = makeInstanced(2, { instanceColors: true }); + expect(colored.instanceLayout.stride).toBe(64); + expect(colored.instanceLayout.colorOffset).toBe(12); + expect(colored.instanceLayout.dataOffset).toBe(-1); + + const both = makeInstanced(2, { + instanceColors: true, + instanceData: true, + }); + expect(both.instanceLayout.stride).toBe(80); + expect(both.instanceLayout.colorOffset).toBe(12); + expect(both.instanceLayout.dataOffset).toBe(16); + + // data without colour packs against the transform, leaving no hole + const dataOnly = makeInstanced(2, { instanceData: true }); + expect(dataOnly.instanceLayout.stride).toBe(64); + expect(dataOnly.instanceLayout.dataOffset).toBe(12); + colored.destroy(); + both.destroy(); + dataOnly.destroy(); + }); + + it("round-trips a transform through the row-major packing", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(1); + const source = new Matrix3d(); + source.identity().translate(11, 22, 33).scale(2, 3, 4); + mesh.setInstance(0, source); + + const read = mesh.getInstance(0); + // the packing drops the constant bottom row; everything else must + // survive exactly + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14]) { + expect(read.val[i], `val[${i}]`).toBeCloseTo(source.val[i], 5); + } + expect(read.val[15]).toBe(1); + mesh.destroy(); + }); + + it("an unplaced instance is identity, not a zero matrix", (ctx) => { + requireWebGL(ctx, renderer); + // a zero matrix would collapse every unplaced copy onto the origin + // and render nothing — silent, and very confusing + const mesh = makeInstanced(3); + const read = mesh.getInstance(2); + expect(read.isIdentity()).toBe(true); + mesh.destroy(); + }); + }); + + describe("draw calls", () => { + it("N instances issue exactly ONE instanced draw, and no per-instance draw", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(64); + const placement = new Matrix3d(); + for (let i = 0; i < 64; i++) { + placement.identity().translate(i * 4, 0, 0); + mesh.setInstance(i, placement); + } + drawOnce(mesh); // first draw uploads geometry + records + + const instancedSpy = vi.spyOn(gl, "drawElementsInstanced"); + const elementsSpy = vi.spyOn(gl, "drawElements"); + drawOnce(mesh); + expect(instancedSpy).toHaveBeenCalledTimes(1); + // 64 copies from one call — the whole point + expect(instancedSpy.mock.calls[0][4]).toBe(64); + expect(elementsSpy).not.toHaveBeenCalled(); + expect(gl.getError()).toBe(gl.NO_ERROR); + instancedSpy.mockRestore(); + elementsSpy.mockRestore(); + mesh.destroy(); + }); + + it("a steady frame uploads NOTHING — no geometry, no records", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(32); + drawOnce(mesh); + + const bufferData = vi.spyOn(gl, "bufferData"); + const bufferSubData = vi.spyOn(gl, "bufferSubData"); + for (let frame = 0; frame < 3; frame++) { + drawOnce(mesh); + } + expect(bufferData).not.toHaveBeenCalled(); + expect(bufferSubData).not.toHaveBeenCalled(); + bufferData.mockRestore(); + bufferSubData.mockRestore(); + mesh.destroy(); + }); + + it("moving ONE instance re-uploads one record, not the buffer and not the geometry", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(100); + drawOnce(mesh); + + const bufferData = vi.spyOn(gl, "bufferData"); + const bufferSubData = vi.spyOn(gl, "bufferSubData"); + const placement = new Matrix3d(); + placement.identity().translate(5, 5, 5); + mesh.setInstance(42, placement); + drawOnce(mesh); + + // geometry untouched, and the record upload is a sub-update + expect(bufferData).not.toHaveBeenCalled(); + expect(bufferSubData).toHaveBeenCalledTimes(1); + // exactly one 48-byte record, at instance 42's offset + const [, byteOffset, view] = bufferSubData.mock.calls[0]; + expect(byteOffset).toBe(42 * 48); + expect(view.byteLength).toBe(48); + bufferData.mockRestore(); + bufferSubData.mockRestore(); + mesh.destroy(); + }); + + it("moving the GROUP uploads nothing at all", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(50); + drawOnce(mesh); + + const bufferData = vi.spyOn(gl, "bufferData"); + const bufferSubData = vi.spyOn(gl, "bufferSubData"); + // the group transform rides a uniform, so the whole forest moves + // for the price of one matrix + mesh.pos.set(120, 40, 8); + mesh.currentTransform.rotate(0.5); + drawOnce(mesh); + expect(bufferData).not.toHaveBeenCalled(); + expect(bufferSubData).not.toHaveBeenCalled(); + bufferData.mockRestore(); + bufferSubData.mockRestore(); + mesh.destroy(); + }); + + it("a contiguous edit coalesces into ONE sub-upload spanning it", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(64); + drawOnce(mesh); + + const bufferSubData = vi.spyOn(gl, "bufferSubData"); + const placement = new Matrix3d(); + for (let i = 10; i < 20; i++) { + placement.identity().translate(i, 0, 0); + mesh.setInstance(i, placement); + } + drawOnce(mesh); + expect(bufferSubData).toHaveBeenCalledTimes(1); + const [, byteOffset, view] = bufferSubData.mock.calls[0]; + expect(byteOffset).toBe(10 * 48); + expect(view.byteLength).toBe(10 * 48); + bufferSubData.mockRestore(); + mesh.destroy(); + }); + + it("visibleInstanceCount changes the draw without any upload", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(80); + drawOnce(mesh); + + const instancedSpy = vi.spyOn(gl, "drawElementsInstanced"); + const bufferSubData = vi.spyOn(gl, "bufferSubData"); + mesh.visibleInstanceCount = 12; + drawOnce(mesh); + expect(instancedSpy.mock.calls[0][4]).toBe(12); + expect(bufferSubData).not.toHaveBeenCalled(); + + // and back to everything + mesh.visibleInstanceCount = -1; + drawOnce(mesh); + expect(instancedSpy.mock.calls[1][4]).toBe(80); + expect(bufferSubData).not.toHaveBeenCalled(); + instancedSpy.mockRestore(); + bufferSubData.mockRestore(); + mesh.destroy(); + }); + + it("zero visible instances draws nothing at all", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(10); + drawOnce(mesh); + const instancedSpy = vi.spyOn(gl, "drawElementsInstanced"); + mesh.visibleInstanceCount = 0; + drawOnce(mesh); + expect(instancedSpy).not.toHaveBeenCalled(); + instancedSpy.mockRestore(); + mesh.destroy(); + }); + + it("leaves the default program current, so a later plain mesh is unaffected", (ctx) => { + requireWebGL(ctx, renderer); + // the instanced variant reads per-instance attributes; leaving it + // bound would make the next ordinary mesh read a buffer that is no + // longer there + const gl = renderer.gl; + const mesh = makeInstanced(4); + drawOnce(mesh); + const batcher = renderer.batchers.get("mesh"); + expect(batcher.currentShader).toBe(batcher.defaultShader); + expect(gl.getError()).toBe(gl.NO_ERROR); + mesh.destroy(); + }); + }); + + describe("instance management", () => { + it("addInstance appends and grows without reallocating every time", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(0); + const placement = new Matrix3d(); + for (let i = 0; i < 40; i++) { + placement.identity().translate(i, 0, 0); + expect(mesh.addInstance(placement)).toBe(i); + } + expect(mesh.instanceCount).toBe(40); + // geometric growth: the backing array is not resized 40 times + expect(mesh.instanceBuffer.length).toBeGreaterThanOrEqual(40 * 12); + expect(mesh._instanceVersion).toBeLessThan(10); + expect(mesh.getInstance(39).val[12]).toBeCloseTo(39, 5); + mesh.destroy(); + }); + + it("removeInstance swaps the last into the hole (documented index shuffle)", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(3); + const placement = new Matrix3d(); + for (let i = 0; i < 3; i++) { + placement.identity().translate(i * 10, 0, 0); + mesh.setInstance(i, placement); + } + mesh.removeInstance(0); + expect(mesh.instanceCount).toBe(2); + // the LAST instance now answers to index 0 — the whole reason the + // removal is O(1), and the reason it is documented as invalidating + expect(mesh.getInstance(0).val[12]).toBeCloseTo(20, 5); + mesh.destroy(); + }); + + it("out-of-range accesses are ignored rather than corrupting the buffer", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2); + const placement = new Matrix3d(); + placement.identity().translate(99, 99, 99); + expect(() => { + mesh.setInstance(-1, placement); + mesh.setInstance(5, placement); + mesh.removeInstance(9); + }).not.toThrow(); + expect(mesh.instanceCount).toBe(2); + expect(mesh.getInstance(0).isIdentity()).toBe(true); + expect(mesh.getInstance(7)).toBeUndefined(); + mesh.destroy(); + }); + + it("shrinking keeps the allocation so an oscillating count never thrashes", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(64); + const allocated = mesh.instanceBuffer.length; + const version = mesh._instanceVersion; + mesh.instanceCount = 4; + mesh.instanceCount = 64; + expect(mesh.instanceBuffer.length).toBe(allocated); + expect(mesh._instanceVersion).toBe(version); + mesh.destroy(); + }); + + it("the optional setters are inert when their slot was not declared", (ctx) => { + requireWebGL(ctx, renderer); + // writing a colour into a record with no colour slot would land in + // the NEXT instance's transform — silently scrambling the scene + const mesh = makeInstanced(2); + mesh.setInstanceColor(0, new Color(255, 0, 0)); + mesh.setInstanceData(0, 1, 2, 3, 4); + expect(mesh.getInstance(1).isIdentity()).toBe(true); + mesh.destroy(); + }); + + it("declared slots are written where the layout says", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(2, { + instanceColors: true, + instanceData: true, + }); + mesh.setInstanceColor(1, new Color(255, 128, 0, 0.5)); + mesh.setInstanceData(1, 7, 8, 9); + const base = 1 * mesh.instanceLayout.floats; + const color = base + mesh.instanceLayout.colorOffset; + expect(mesh.instanceBuffer[color]).toBeCloseTo(1, 5); + expect(mesh.instanceBuffer[color + 1]).toBeCloseTo(128 / 255, 5); + expect(mesh.instanceBuffer[color + 3]).toBeCloseTo(0.5, 5); + const data = base + mesh.instanceLayout.dataOffset; + expect(mesh.instanceBuffer[data]).toBe(7); + expect(mesh.instanceBuffer[data + 3]).toBe(0); + // and instance 0 is untouched by either write + expect(mesh.getInstance(0).isIdentity()).toBe(true); + mesh.destroy(); + }); + + it("a bulk edit announced with needsInstanceUpdate re-uploads the lot", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(16); + drawOnce(mesh); + + const bufferSubData = vi.spyOn(gl, "bufferSubData"); + mesh.instanceBuffer[0] = 3; + mesh.needsInstanceUpdate = true; + drawOnce(mesh); + expect(bufferSubData).toHaveBeenCalledTimes(1); + expect(bufferSubData.mock.calls[0][2].byteLength).toBe(16 * 48); + bufferSubData.mockRestore(); + mesh.destroy(); + }); + }); + + describe("the lit tier and the variant matrix", () => { + it("a lit instanced mesh draws instanced through the lit batcher", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(24, { lit: true }); + drawOnce(mesh); + + const instancedSpy = vi.spyOn(gl, "drawElementsInstanced"); + drawOnce(mesh); + expect(instancedSpy).toHaveBeenCalledTimes(1); + expect(instancedSpy.mock.calls[0][4]).toBe(24); + expect(gl.getError()).toBe(gl.NO_ERROR); + // and it really is the lit batcher, not the unlit one + expect(renderer.currentBatcher).toBe(renderer.batchers.get("litMesh")); + instancedSpy.mockRestore(); + mesh.destroy(); + }); + + it("compiles one program per declared slot combination, and only on demand", (ctx) => { + requireWebGL(ctx, renderer); + const batcher = renderer.batchers.get("mesh"); + batcher.instancedShaders.forEach((shader) => { + shader.destroy(); + }); + batcher.instancedShaders.clear(); + + const bare = makeInstanced(2); + drawOnce(bare); + expect(batcher.instancedShaders.size).toBe(1); + + // the same combination reuses its program + const alsoBare = makeInstanced(2); + drawOnce(alsoBare); + expect(batcher.instancedShaders.size).toBe(1); + + // a different combination compiles its own + const colored = makeInstanced(2, { instanceColors: true }); + drawOnce(colored); + expect(batcher.instancedShaders.size).toBe(2); + + const both = makeInstanced(2, { + instanceColors: true, + instanceData: true, + }); + drawOnce(both); + expect(batcher.instancedShaders.size).toBe(3); + expect(renderer.gl.getError()).toBe(renderer.gl.NO_ERROR); + bare.destroy(); + alsoBare.destroy(); + colored.destroy(); + both.destroy(); + }); + + it("every slot combination links on both tiers", (ctx) => { + requireWebGL(ctx, renderer); + // the adversarial one: a variant that fails to link would fall back + // to no program and draw nothing, silently. Compile all eight. + const gl = renderer.gl; + for (const lit of [false, true]) { + for (const instanceColors of [false, true]) { + for (const instanceData of [false, true]) { + const mesh = makeInstanced(3, { + lit, + instanceColors, + instanceData, + }); + drawOnce(mesh); + const batcher = renderer.batchers.get(lit ? "litMesh" : "mesh"); + const key = (instanceColors ? 1 : 0) | (instanceData ? 2 : 0); + const shader = batcher.instancedShaders.get(key); + const label = `lit=${lit} colors=${instanceColors} data=${instanceData}`; + expect(shader, label).toBeDefined(); + expect(gl.isProgram(shader.program), label).toBe(true); + expect( + gl.getProgramParameter(shader.program, gl.LINK_STATUS), + label, + ).toBe(true); + expect(gl.getError(), label).toBe(gl.NO_ERROR); + mesh.destroy(); + } + } + } + }); + + it("the lit variant declares the instance attributes it was compiled for", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeInstanced(4, { + lit: true, + instanceColors: true, + instanceData: true, + }); + drawOnce(mesh); + const shader = renderer.batchers.get("litMesh").instancedShaders.get(3); + // asked of the LINKED PROGRAM, not the engine's parsed attribute + // map: `extractAttributes` regex-scans the source and so counts + // `#ifdef`-guarded declarations too, which would make this pass + // even if the guards did nothing + for (const name of [ + "aVertex", + "aNormal", + "aInstanceRow0", + "aInstanceRow1", + "aInstanceRow2", + "aInstanceColor", + "aInstanceData", + ]) { + expect( + gl.getAttribLocation(shader.program, name), + name, + ).toBeGreaterThanOrEqual(0); + } + mesh.destroy(); + }); + + it("a bare variant genuinely omits the optional attributes", (ctx) => { + requireWebGL(ctx, renderer); + // the point of the ifdefs: an undeclared slot consumes no attribute + // slot in the linked program, so the 16-slot budget is spent only + // on what the mesh actually declared + const gl = renderer.gl; + const mesh = makeInstanced(4, { lit: true }); + drawOnce(mesh); + const shader = renderer.batchers.get("litMesh").instancedShaders.get(0); + expect( + gl.getAttribLocation(shader.program, "aInstanceRow0"), + ).toBeGreaterThanOrEqual(0); + expect(gl.getAttribLocation(shader.program, "aInstanceColor")).toBe(-1); + expect(gl.getAttribLocation(shader.program, "aInstanceData")).toBe(-1); + mesh.destroy(); + }); + + it("the engine's attribute map AGREES with the linked program for every declared slot", (ctx) => { + requireWebGL(ctx, renderer); + // The invariant that keeps instancing correct. `extractAttributes` + // numbers attributes by scanning the SOURCE (guarded declarations + // included), while the vertex state points buffers at whatever that + // map says. If the two ever disagreed for an attribute the layout + // actually declares, the pointers would land on the wrong locations + // and the mesh would render garbage — with no GL error at all. + const gl = renderer.gl; + for (const lit of [false, true]) { + for (const instanceColors of [false, true]) { + for (const instanceData of [false, true]) { + const mesh = makeInstanced(3, { + lit, + instanceColors, + instanceData, + }); + drawOnce(mesh); + const batcher = renderer.batchers.get(lit ? "litMesh" : "mesh"); + const key = (instanceColors ? 1 : 0) | (instanceData ? 2 : 0); + const shader = batcher.instancedShaders.get(key); + + const declared = [ + "aInstanceRow0", + "aInstanceRow1", + "aInstanceRow2", + ]; + if (instanceColors) { + declared.push("aInstanceColor"); + } + if (instanceData) { + declared.push("aInstanceData"); + } + for (const name of declared) { + const label = `${name} lit=${lit} key=${key}`; + const real = gl.getAttribLocation(shader.program, name); + expect(real, label).toBeGreaterThanOrEqual(0); + expect(shader.getAttribLocation(name), label).toBe(real); + } + mesh.destroy(); + } + } + } + }); + + it("the instance attributes are actually bound, at the program's own locations, with divisor 1", (ctx) => { + requireWebGL(ctx, renderer); + // end-to-end proof that the pointers landed where the linked + // program expects them, and step per instance rather than per vertex + const gl = renderer.gl; + const mesh = makeInstanced(6, { + lit: true, + instanceColors: true, + instanceData: true, + }); + drawOnce(mesh); + + const batcher = renderer.batchers.get("litMesh"); + const shader = batcher.instancedShaders.get(3); + const state = batcher.instanced.get(mesh); + expect(state).toBeDefined(); + state.vertexState.bind(); + + for (const name of [ + "aInstanceRow0", + "aInstanceRow1", + "aInstanceRow2", + "aInstanceColor", + "aInstanceData", + ]) { + const loc = gl.getAttribLocation(shader.program, name); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED), + name, + ).toBe(true); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_DIVISOR), + name, + ).toBe(1); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_STRIDE), + name, + ).toBe(80); + } + // while the GEOMETRY attributes in the same vertex array still step + // per vertex — the two groups must not have been conflated + for (const name of ["aVertex", "aRegion", "aColor", "aNormal"]) { + const loc = gl.getAttribLocation(shader.program, name); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_DIVISOR), + name, + ).toBe(0); + } + gl.bindVertexArray(null); + expect(gl.getError()).toBe(gl.NO_ERROR); + mesh.destroy(); + }); + + it("a guarded-out slot is absent from the program and never pointed at", (ctx) => { + requireWebGL(ctx, renderer); + // the benign half of the map/program mismatch: the parsed map may + // still number a guarded-out attribute, but the layout never lists + // it, so nothing ever enables or points at that location + const gl = renderer.gl; + const mesh = makeInstanced(4, { lit: true }); // no optional slots + drawOnce(mesh); + const batcher = renderer.batchers.get("litMesh"); + const shader = batcher.instancedShaders.get(0); + const stale = shader.getAttribLocation("aInstanceColor"); + expect(gl.getAttribLocation(shader.program, "aInstanceColor")).toBe(-1); + + batcher.instanced.get(mesh).vertexState.bind(); + if (stale >= 0) { + // the map numbered it; the vertex array must NOT have enabled it + expect(gl.getVertexAttrib(stale, gl.VERTEX_ATTRIB_ARRAY_ENABLED)).toBe( + false, + ); + } + gl.bindVertexArray(null); + expect(gl.getError()).toBe(gl.NO_ERROR); + mesh.destroy(); + }); + + it("mixing instanced and plain meshes in one frame keeps both correct", (ctx) => { + requireWebGL(ctx, renderer); + // the regression this guards: the instanced program stays bound + // after its draw, and the next plain mesh reads per-instance + // attributes with no buffer behind them + const gl = renderer.gl; + const instanced = makeInstanced(8, { lit: true }); + const plain = makeInstanced(0, { lit: true }); // no instances + plain.instanceCount = 0; + + drawOnce(instanced); + const batcher = renderer.batchers.get("litMesh"); + expect(batcher.currentShader).toBe(batcher.defaultShader); + drawOnce(instanced); + expect(gl.getError()).toBe(gl.NO_ERROR); + instanced.destroy(); + plain.destroy(); + }); + }); + + describe("bounds", () => { + it("getBounds3d covers every instance, not just the prototype", (ctx) => { + requireWebGL(ctx, renderer); + // whole-group frustum culling depends on this: a box around the + // prototype alone would cull a forest the moment its origin left + // the view + const mesh = makeInstanced(3); + const placement = new Matrix3d(); + placement.identity().translate(0, 0, 0); + mesh.setInstance(0, placement); + placement.identity().translate(100, 0, 0); + mesh.setInstance(1, placement); + placement.identity().translate(0, 60, 0); + mesh.setInstance(2, placement); + + const bounds = mesh.getBounds3d(); + // assert the EXTENT, not signed maxima: the group matrix carries + // the Y-up→Y-down bridge, so an instance placed at +60 legitimately + // lands at -60. What matters here is that the box spans all three + // instances rather than hugging the prototype. + expect(bounds.max.x - bounds.min.x).toBeGreaterThan(90); + expect(bounds.max.y - bounds.min.y).toBeGreaterThan(50); + + // and it is genuinely wider than the prototype alone + const single = makeInstanced(1); + const solo = single.getBounds3d(); + expect(bounds.max.x - bounds.min.x).toBeGreaterThan( + solo.max.x - solo.min.x, + ); + single.destroy(); + mesh.destroy(); + }); + + it("an empty instanced mesh falls back to the prototype bounds", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeInstanced(0); + expect(() => { + return mesh.getBounds3d(); + }).not.toThrow(); + expect(mesh.getBounds3d().isFinite()).toBe(true); + mesh.destroy(); + }); + }); +}); + +/** + * Regressions found by code review (#1508). Every test here fails against + * the pre-review implementation — they exist because the original suite + * asserted CPU-side records and GL call counts, which is structurally blind + * to "the right call was made with the wrong state". + */ +describe("Mesh instancing — reviewed regressions", () => { + let renderer; + let camera; + + const GEOMETRY = { + vertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), + }; + + beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); + camera = new Camera3d(0, 0, 128, 128); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const make = (count, settings = {}) => { + return new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: count, + ...settings, + }); + }; + + const drawOnce = (mesh) => { + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + renderer.flush(); + }; + + const scatter = (mesh, spread) => { + const m = new Matrix3d(); + for (let i = 0; i < mesh.instanceCount; i++) { + const a = (i / mesh.instanceCount) * Math.PI * 2; + m.identity().translate(Math.cos(a) * spread, 0, Math.sin(a) * spread); + mesh.setInstance(i, m); + } + }; + + it("the CULL box grows to cover the scatter, not the prototype", (ctx) => { + requireWebGL(ctx, renderer); + // Camera3d culls on getBounds() (the 2D box), never on getBounds3d(). + // Left at the prototype's size the whole forest vanishes as soon as + // the group origin leaves the frustum, with every tree still on screen. + const mesh = make(64); + const prototypeBox = mesh.getBounds().width; + scatter(mesh, 4000); + const scatterBox = mesh.getBounds().width; + expect(scatterBox).toBeGreaterThan(prototypeBox * 10); + // the cull sphere Camera3d derives must actually reach the instances + const bounds = mesh.getBounds(); + const radius = + Math.sqrt(bounds.width * bounds.width + bounds.height * bounds.height) / + 2; + expect(radius).toBeGreaterThanOrEqual(4000); + mesh.destroy(); + }); + + it("the cull box tracks later edits", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = make(8); + scatter(mesh, 100); + const near = mesh.getBounds().width; + scatter(mesh, 9000); + expect(mesh.getBounds().width).toBeGreaterThan(near * 10); + mesh.destroy(); + }); + + it("a recycled slot does NOT inherit the dead instance's custom data", (ctx) => { + requireWebGL(ctx, renderer); + // the built-in shading reads instanceData.rgb as emissive, so a stale + // slot makes a brand-new instance glow + const mesh = make(6, { instanceData: true }); + mesh.setInstanceData(5, 9, 9, 9, 9); + mesh.instanceCount = 3; + mesh.instanceCount = 6; + const at = 5 * mesh.instanceLayout.floats + mesh.instanceLayout.dataOffset; + expect(Array.from(mesh.instanceBuffer.slice(at, at + 4))).toEqual([ + 0, 0, 0, 0, + ]); + mesh.destroy(); + }); + + it("both batchers see an edit when `lit` is toggled between frames", (ctx) => { + requireWebGL(ctx, renderer); + // the unlit and lit batchers keep SEPARATE GPU buffers; a shared + // "clear the dirty flag" step let the first to draw drain the span, + // freezing the other at a stale transform forever + const gl = renderer.gl; + const mesh = make(16); + const m = new Matrix3d(); + + drawOnce(mesh); // unlit buffer created + mesh.lit = true; + drawOnce(mesh); // lit buffer created + mesh.lit = false; + m.identity().translate(123, 0, 0); + mesh.setInstance(7, m); + drawOnce(mesh); // unlit buffer gets the edit + + const sub = vi.spyOn(gl, "bufferSubData"); + const full = vi.spyOn(gl, "bufferData"); + mesh.lit = true; + drawOnce(mesh); // lit buffer MUST catch up + expect(sub.mock.calls.length + full.mock.calls.length).toBeGreaterThan(0); + sub.mockRestore(); + full.mockRestore(); + mesh.destroy(); + }); + + it("an out-of-range dirty span cannot throw from inside the draw", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = make(4); + mesh.markInstancesDirty(0, 99999); + expect(() => { + drawOnce(mesh); + }).not.toThrow(); + expect(renderer.gl.getError()).toBe(renderer.gl.NO_ERROR); + mesh.destroy(); + }); + + it("a real plain Mesh drawn after an instanced one is unaffected", (ctx) => { + requireWebGL(ctx, renderer); + // the original version of this test built another InstancedMesh with + // zero instances and never drew it, so it asserted nothing + const gl = renderer.gl; + const instanced = make(8, { lit: true }); + const plain = new Mesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + lit: true, + }); + drawOnce(instanced); + + const elements = vi.spyOn(gl, "drawElements"); + const instancedSpy = vi.spyOn(gl, "drawElementsInstanced"); + drawOnce(plain); + expect(elements).toHaveBeenCalledTimes(1); + expect(instancedSpy).not.toHaveBeenCalled(); + expect(gl.getError()).toBe(gl.NO_ERROR); + elements.mockRestore(); + instancedSpy.mockRestore(); + instanced.destroy(); + plain.destroy(); + }); + + it("a custom shader on an instanced mesh warns once and keeps drawing", (ctx) => { + requireWebGL(ctx, renderer); + // hosting it would let a shader that omits the instance slots wire the + // transform rows to nothing — a singular matrix collapses the mesh + const gl = renderer.gl; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const batcher = renderer.batchers.get("mesh"); + batcher._instancedShaderWarned = false; + const mesh = make(4); + mesh.shader = renderer.batchers.get("quad").defaultShader; + drawOnce(mesh); + drawOnce(mesh); + expect( + warn.mock.calls.filter((a) => { + return /InstancedMesh/.test(String(a[0])); + }).length, + ).toBe(1); + expect(gl.getError()).toBe(gl.NO_ERROR); + warn.mockRestore(); + mesh.shader = undefined; + mesh.destroy(); + }); + + it("destroy() releases the compiled shader variants", (ctx) => { + requireWebGL(ctx, renderer); + // they hold GL programs AND stay subscribed to context-loss events + const mesh = make(4, { instanceColors: true }); + drawOnce(mesh); + const batcher = renderer.batchers.get("mesh"); + const shader = batcher.instancedShaders.get(1); + expect(shader).toBeDefined(); + mesh.destroy(); + // destroy() is exercised on a throwaway batcher so the shared renderer + // keeps working for later specs + batcher.instancedShaders.forEach((s) => { + s.destroy(); + }); + expect(shader.destroyed).toBe(true); + batcher.instancedShaders.clear(); + }); +}); diff --git a/packages/melonjs/tests/webgl_shader_defines.spec.js b/packages/melonjs/tests/webgl_shader_defines.spec.js new file mode 100644 index 000000000..4266c1194 --- /dev/null +++ b/packages/melonjs/tests/webgl_shader_defines.spec.js @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { extractAttributes } from "../src/video/webgl/utils/attributes.js"; +import { injectDefines, minify } from "../src/video/webgl/utils/string.js"; + +/** + * Shader-source manipulation, pinned as pure functions — no GL context + * needed, so these run everywhere and cost nothing. + * + * Both cases here are real bugs found while building the instanced mesh + * shader variants (#1508), and both fail *silently* when wrong: a shader + * that will not compile leaves the draw with no program (nothing renders, + * no exception), and an attribute map that disagrees with the linked + * program points vertex data at the wrong locations (garbage geometry, no + * error). + */ +describe("injectDefines", () => { + // what an ES 1.00 source looks like: no #version line at all + const ES100 = "attribute vec3 aVertex;\nvoid main(void) {}\n"; + // ES 3.00 REQUIRES #version to be the very first statement + const ES300 = "#version 300 es\nin vec3 aVertex;\nvoid main(void) {}\n"; + const DEFINES = "#define INSTANCE_COLORS\n#define INSTANCE_DATA\n"; + + it("returns the source untouched when there is nothing to inject", () => { + expect(injectDefines(ES300, "")).toBe(ES300); + expect(injectDefines(ES100, "")).toBe(ES100); + }); + + it("puts defines at the very top of a source with no #version", () => { + expect(injectDefines(ES100, DEFINES)).toBe(DEFINES + ES100); + }); + + it("keeps #version first and puts the defines AFTER it", () => { + // the bug: prepending would produce `#define…` before `#version`, + // which is a compile error on every ES 3.00 shader — and a shader + // that fails to compile renders nothing rather than throwing + const result = injectDefines(ES300, DEFINES); + expect(result.startsWith("#version 300 es\n")).toBe(true); + expect(result).toBe( + "#version 300 es\n" + DEFINES + "in vec3 aVertex;\nvoid main(void) {}\n", + ); + // the first non-empty line is still the version directive + const firstLine = result + .split("\n") + .find((line) => { + return line.trim() !== ""; + }) + .trim(); + expect(firstLine).toBe("#version 300 es"); + }); + + it("handles a #version line with a trailing comment or padding", () => { + const padded = "#version 300 es // GLSL ES 3.00\nin vec3 aVertex;\n"; + const result = injectDefines(padded, DEFINES); + expect(result.startsWith("#version 300 es // GLSL ES 3.00\n")).toBe(true); + expect(result.indexOf("#define")).toBeGreaterThan( + result.indexOf("#version"), + ); + }); + + it("tolerates leading blank lines before #version", () => { + const lead = "\n\n#version 300 es\nin vec3 aVertex;\n"; + const result = injectDefines(lead, DEFINES); + // whatever precedes #version is preserved verbatim, and the defines + // still land after the directive rather than before it + expect(result.indexOf("#define")).toBeGreaterThan( + result.indexOf("#version"), + ); + expect(result.startsWith("\n\n#version 300 es\n")).toBe(true); + }); + + it("preserves define order", () => { + const result = injectDefines(ES300, DEFINES); + expect(result.indexOf("INSTANCE_COLORS")).toBeLessThan( + result.indexOf("INSTANCE_DATA"), + ); + }); + + it("only ever treats a LEADING #version as the directive", () => { + // `#version` mentioned in a comment further down is not a directive, + // and must not be mistaken for the insertion point + const source = "in vec3 aVertex;\n// mentions #version 300 es in prose\n"; + expect(injectDefines(source, DEFINES)).toBe(DEFINES + source); + }); + + it("survives minify, which the compile path applies afterwards", () => { + // minify deliberately preserves newlines so preprocessor directives + // stay on their own lines — if that ever changed, every #ifdef in the + // mesh shaders would collapse onto one line and stop working + const minified = minify(injectDefines(ES300, DEFINES)); + expect(minified).toContain("#version 300 es\n"); + expect(minified).toContain("#define INSTANCE_COLORS\n"); + expect(minified).toContain("#define INSTANCE_DATA\n"); + }); + + it("keeps #ifdef blocks intact through minify", () => { + const guarded = [ + "#version 300 es", + "#ifdef INSTANCE_DATA", + "in vec4 vInstanceData;", + "#endif", + "void main(void) {}", + ].join("\n"); + const minified = minify(guarded); + expect(minified).toContain("#ifdef INSTANCE_DATA\n"); + expect(minified).toContain("#endif\n"); + }); +}); + +describe("extractAttributes", () => { + const shaderOf = (vertex) => { + // extractAttributes only reads `.vertex` off the shader object + return { vertex }; + }; + + it("numbers attributes by declaration order, both GLSL dialects", () => { + expect( + extractAttributes( + null, + shaderOf("attribute vec3 aVertex;\nattribute vec2 aRegion;\n"), + ), + ).toEqual({ aVertex: 0, aRegion: 1 }); + expect( + extractAttributes(null, shaderOf("in vec3 aVertex;\nin vec4 aColor;\n")), + ).toEqual({ aVertex: 0, aColor: 1 }); + }); + + it("counts #ifdef-guarded declarations too — known, and load-bearing", () => { + // This is a regex over the SOURCE, not a query of the linked program, + // so a guarded-out attribute still gets a number. That is benign but + // non-obvious, and it is why an "is this attribute present?" check must + // ask GL (`gl.getAttribLocation(program, name)`) rather than this map: + // `bindAttribLocation` on a name the program does not declare is + // silently ignored, so the numbering stays self-consistent while the + // map claims slots the program never uses. + const attributes = extractAttributes( + null, + shaderOf( + [ + "#version 300 es", + "in vec3 aVertex;", + "in vec4 aInstanceRow0;", + "#ifdef INSTANCE_COLORS", + "in vec4 aInstanceColor;", + "#endif", + "#ifdef INSTANCE_DATA", + "in vec4 aInstanceData;", + "#endif", + ].join("\n"), + ), + ); + expect(attributes).toEqual({ + aVertex: 0, + aInstanceRow0: 1, + aInstanceColor: 2, + aInstanceData: 3, + }); + }); + + it("assigns each name a DISTINCT location, guarded or not", () => { + // two names sharing a location would fail to link — the failure mode + // this numbering has to avoid + const attributes = extractAttributes( + null, + shaderOf( + [ + "in vec3 aVertex;", + "in vec2 aRegion;", + "in vec4 aColor;", + "in vec3 aNormal;", + "in vec4 aInstanceRow0;", + "in vec4 aInstanceRow1;", + "in vec4 aInstanceRow2;", + "#ifdef INSTANCE_COLORS", + "in vec4 aInstanceColor;", + "#endif", + "#ifdef INSTANCE_DATA", + "in vec4 aInstanceData;", + "#endif", + ].join("\n"), + ), + ); + const locations = Object.values(attributes); + expect(new Set(locations).size).toBe(locations.length); + // and the highest stays inside the guaranteed minimum of 16 attribute + // slots even with every optional slot counted — the fullest instanced + // lit layout is 9 declarations + expect(Math.max(...locations)).toBeLessThan(16); + }); + + it("ignores `in` inside a function parameter list", () => { + // only a line-leading qualifier is a vertex attribute; the same word + // appears in parameter lists, where it means something else entirely + const attributes = extractAttributes( + null, + shaderOf( + [ + "in vec3 aVertex;", + "vec4 shade(in vec3 normal, in vec4 color) { return color; }", + ].join("\n"), + ), + ); + expect(attributes).toEqual({ aVertex: 0 }); + }); +}); diff --git a/packages/melonjs/tests/webgl_vertexstate.spec.js b/packages/melonjs/tests/webgl_vertexstate.spec.js index f0261db5f..0101d5ca6 100644 --- a/packages/melonjs/tests/webgl_vertexstate.spec.js +++ b/packages/melonjs/tests/webgl_vertexstate.spec.js @@ -359,4 +359,305 @@ describe("WebGLVertexState", () => { gl.bindVertexArray(null); state.destroy(); }); + + /** + * Multi-buffer layouts and per-instance step mode (#1508). A vertex state + * may now describe several buffer groups, each with its own buffer, + * stride and `stepMode` — the `GPUVertexBufferLayout[]` shape. These pin + * the new axis AND that the single-buffer path is untouched by it, which + * matters because every GL batcher depends on this class. + */ + describe("multi-buffer layouts and instance step mode", () => { + // a per-instance record: a row-major 3x4 transform (3 x vec4) + const INSTANCE_STRIDE = 48; + const INSTANCE_ATTRIBUTES = [ + { name: "aInstanceRow0", size: 4, type: 0, normalized: false, offset: 0 }, + { + name: "aInstanceRow1", + size: 4, + type: 0, + normalized: false, + offset: 16, + }, + { + name: "aInstanceRow2", + size: 4, + type: 0, + normalized: false, + offset: 32, + }, + ]; + + const allLocations = (name) => { + return ( + { + aVertex: 0, + aRegion: 1, + aColor: 2, + aInstanceRow0: 3, + aInstanceRow1: 4, + aInstanceRow2: 5, + }[name] ?? -1 + ); + }; + + const makeBuffer = () => { + const previous = gl.getParameter(gl.ARRAY_BUFFER_BINDING); + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(600), gl.STREAM_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, previous); + return buffer; + }; + + // geometry group + instance group, the InstancedMesh shape + const makeInstanced = (overrides = {}) => { + for (const attr of INSTANCE_ATTRIBUTES) { + attr.type = gl.FLOAT; + } + const geometryBuffer = makeBuffer(); + const instanceBuffer = makeBuffer(); + const state = new WebGLVertexState(gl, { + buffers: [ + { + buffer: geometryBuffer, + stride: STRIDE, + attributes: ATTRIBUTES, + }, + { + buffer: instanceBuffer, + stride: INSTANCE_STRIDE, + stepMode: "instance", + attributes: INSTANCE_ATTRIBUTES, + }, + ], + resolveLocation: allLocations, + ...overrides, + }); + return { state, geometryBuffer, instanceBuffer }; + }; + + it("the single-buffer shape issues NO vertexAttribDivisor at all", (ctx) => { + requireWebGL(ctx); + // the regression pin: the ordinary path must be byte-identical to + // what it was before instancing existed. A fresh vertex array + // already has every divisor at 0, so the calls are not merely + // redundant — issuing them would be a behaviour change. + const spy = vi.spyOn(gl, "vertexAttribDivisor"); + try { + const state = makeState(); + expect(spy).not.toHaveBeenCalled(); + state.bind(); + for (const attr of ATTRIBUTES) { + expect( + gl.getVertexAttrib( + fixedLocations(attr.name), + gl.VERTEX_ATTRIB_ARRAY_DIVISOR, + ), + attr.name, + ).toBe(0); + } + gl.bindVertexArray(null); + state.destroy(); + } finally { + spy.mockRestore(); + } + }); + + it("applies the divisor to instance-step attributes only", (ctx) => { + requireWebGL(ctx); + const { state, geometryBuffer, instanceBuffer } = makeInstanced(); + state.bind(); + // geometry group advances per vertex + for (const attr of ATTRIBUTES) { + expect( + gl.getVertexAttrib( + allLocations(attr.name), + gl.VERTEX_ATTRIB_ARRAY_DIVISOR, + ), + attr.name, + ).toBe(0); + } + // instance group advances per instance + for (const attr of INSTANCE_ATTRIBUTES) { + const loc = allLocations(attr.name); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_DIVISOR), + attr.name, + ).toBe(1); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED), + attr.name, + ).toBe(true); + } + expect(gl.getError()).toBe(gl.NO_ERROR); + gl.bindVertexArray(null); + state.destroy(); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + }); + + it("each group's attributes read from that group's own buffer and stride", (ctx) => { + requireWebGL(ctx); + // adversarial: both groups declare an attribute at offset 0, so a + // build that bound only one buffer would silently cross-read + const { state, geometryBuffer, instanceBuffer } = makeInstanced(); + state.bind(); + expect(gl.getVertexAttrib(0, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING)).toBe( + geometryBuffer, + ); + expect(gl.getVertexAttrib(3, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING)).toBe( + instanceBuffer, + ); + // and each carries its own stride, not the other's + expect(gl.getVertexAttrib(0, gl.VERTEX_ATTRIB_ARRAY_STRIDE)).toBe(STRIDE); + expect(gl.getVertexAttrib(3, gl.VERTEX_ATTRIB_ARRAY_STRIDE)).toBe( + INSTANCE_STRIDE, + ); + // interleaved instance record: offsets within the group resolve + // against the group's own base + expect(gl.getVertexAttribOffset(4, gl.VERTEX_ATTRIB_ARRAY_POINTER)).toBe( + 16, + ); + expect(gl.getVertexAttribOffset(5, gl.VERTEX_ATTRIB_ARRAY_POINTER)).toBe( + 32, + ); + gl.bindVertexArray(null); + state.destroy(); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + }); + + it("divisors never leak into an unrelated vertex state", (ctx) => { + requireWebGL(ctx); + // the classic instancing bug. Divisor is vertex-array state and + // every build creates a fresh array, so this holds by + // construction — pin it so a future "optimization" that reuses a + // handle cannot silently reintroduce it. + const { + state: instanced, + geometryBuffer, + instanceBuffer, + } = makeInstanced(); + instanced.bind(); + const plain = makeState({ resolveLocation: allLocations }); + plain.bind(); + for (let loc = 0; loc <= 5; loc++) { + expect(gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_DIVISOR)).toBe(0); + } + gl.bindVertexArray(null); + plain.destroy(); + instanced.destroy(); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + }); + + it("a rebuild re-declares the divisors on the replacement array", (ctx) => { + requireWebGL(ctx); + // build() discards the old vertex array; the new one starts with + // every divisor at 0, so they must be re-issued or the mesh would + // draw one instance N times on top of itself + const { state, geometryBuffer, instanceBuffer } = makeInstanced(); + const replacement = makeBuffer(); + state.build({ + buffers: [ + { buffer: geometryBuffer, stride: STRIDE, attributes: ATTRIBUTES }, + { + buffer: replacement, + stride: INSTANCE_STRIDE, + stepMode: "instance", + attributes: INSTANCE_ATTRIBUTES, + }, + ], + }); + state.bind(); + expect(gl.getVertexAttrib(3, gl.VERTEX_ATTRIB_ARRAY_DIVISOR)).toBe(1); + // only the replaced group was re-pointed + expect(gl.getVertexAttrib(3, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING)).toBe( + replacement, + ); + expect(gl.getVertexAttrib(0, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING)).toBe( + geometryBuffer, + ); + gl.bindVertexArray(null); + state.destroy(); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + gl.deleteBuffer(replacement); + }); + + it("building a multi-buffer layout leaks none of the bindings it used", (ctx) => { + requireWebGL(ctx); + // the existing single-buffer hygiene test, generalized: several + // buffers are bound during the build now + const other = makeState(); + const otherBuffer = gl.createBuffer(); + other.bind(); + gl.bindBuffer(gl.ARRAY_BUFFER, otherBuffer); + + const { state, geometryBuffer, instanceBuffer } = makeInstanced(); + + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe(other.handle); + expect(gl.getParameter(gl.ARRAY_BUFFER_BINDING)).toBe(otherBuffer); + + gl.bindVertexArray(null); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + state.destroy(); + other.destroy(); + gl.deleteBuffer(otherBuffer); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + }); + + it("an undeclared instance attribute is skipped without a bad divisor call", (ctx) => { + requireWebGL(ctx); + // vertexAttribDivisor(-1, 1) is INVALID_VALUE — the skip must + // happen before the divisor, not after + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const divisorSpy = vi.spyOn(gl, "vertexAttribDivisor"); + try { + const { state, geometryBuffer, instanceBuffer } = makeInstanced({ + resolveLocation: (name) => { + return name === "aInstanceRow2" ? -1 : allLocations(name); + }, + }); + for (const call of divisorSpy.mock.calls) { + expect(call[0]).not.toBe(-1); + } + expect(divisorSpy).toHaveBeenCalledTimes(2); + expect(gl.getError()).toBe(gl.NO_ERROR); + state.destroy(); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + } finally { + divisorSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + + it("issues no divisor calls on a lost context", (ctx) => { + requireWebGL(ctx); + // every location resolves -1 while the context is lost; the build + // must stay silent and issue nothing rather than erroring + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const divisorSpy = vi.spyOn(gl, "vertexAttribDivisor"); + const lostSpy = vi.spyOn(gl, "isContextLost").mockReturnValue(true); + try { + const { state, geometryBuffer, instanceBuffer } = makeInstanced({ + resolveLocation: () => { + return -1; + }, + }); + expect(divisorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + state.destroy(); + gl.deleteBuffer(geometryBuffer); + gl.deleteBuffer(instanceBuffer); + } finally { + lostSpy.mockRestore(); + divisorSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + }); }); diff --git a/packages/melonjs/tests/webgpu_mesh_instanced.spec.js b/packages/melonjs/tests/webgpu_mesh_instanced.spec.js new file mode 100644 index 000000000..de40ef6e8 --- /dev/null +++ b/packages/melonjs/tests/webgpu_mesh_instanced.spec.js @@ -0,0 +1,550 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it } from "vitest"; +import { instanceRecordLayout } from "../src/video/gpu/instancerecord.ts"; +import WebGPUInstanceBuffer from "../src/video/webgpu/buffer/instance_buffer.js"; +import WebGPUPipelineCache from "../src/video/webgpu/pipeline/cache.js"; +import meshWGSL from "../src/video/webgpu/shaders/mesh.wgsl"; +import { + buildInstancedMeshWGSL, + LIT_INSTANCED, + UNLIT_INSTANCED, +} from "../src/video/webgpu/shaders/mesh-instanced.js"; +import meshLitWGSL from "../src/video/webgpu/shaders/mesh-lit.wgsl"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * WebGPU mesh instancing (#1508). + * + * The WebGPU half differs from the WebGL one in a way worth pinning: WGSL + * has **no preprocessor**, and a module carries both entry points in one + * file — so the instanced variant is a module *derived* from the ordinary + * source rather than the same text compiled with different defines. These + * tests hold that derivation honest, because a divergence between the two + * would be invisible: the instanced mesh would simply shade differently + * from its uninstanced twin, with nothing failing. + */ +describe("the derived instanced WGSL module", () => { + const variantsOf = (source, variant) => { + return [ + [false, false], + [true, false], + [false, true], + [true, true], + ].map(([hasColor, hasData]) => { + return { + hasColor, + hasData, + text: buildInstancedMeshWGSL(source, { + ...variant, + hasColor, + hasData, + }), + }; + }); + }; + + it("keeps the ordinary module's head and fragment stage verbatim", () => { + // the whole reason for deriving rather than copying: the lit tier's + // lighting loop and its std140 Light3dBlock — which must agree byte + // for byte with the uniform packer — exist in exactly ONE place + const bare = buildInstancedMeshWGSL(meshLitWGSL, { + ...LIT_INSTANCED, + hasColor: false, + hasData: false, + }); + const fragment = meshLitWGSL.slice(meshLitWGSL.indexOf("@fragment")); + expect(bare).toContain(fragment); + // and the bindings/structs ahead of the vertex stage came across too + expect(bare).toContain("struct Light3dData"); + expect(bare).toContain("uLights"); + }); + + it("replaces ONLY the vertex stage", () => { + const bare = buildInstancedMeshWGSL(meshWGSL, { + ...UNLIT_INSTANCED, + hasColor: false, + hasData: false, + }); + // exactly one of each entry point survives + expect(bare.match(/@vertex/g)).toHaveLength(1); + expect(bare.match(/@fragment/g)).toHaveLength(1); + expect(bare).toContain("fn vertex_main("); + expect(bare).toContain("fn fragment_main("); + // the ordinary placement line is gone, the instanced one is there + expect(bare).toContain("uMesh.model * instance"); + expect(bare).toContain("let instance = mat4x4f("); + }); + + it("declares the instance rows at PINNED locations, per tier", () => { + // pinned rather than sequential so a variant that omits the colour + // slot does not renumber the data slot — which is what lets one + // derivation serve every variant without renumbering the module + for (const { text } of variantsOf(meshWGSL, UNLIT_INSTANCED)) { + expect(text).toContain("@location(3) aInstanceRow0"); + expect(text).toContain("@location(4) aInstanceRow1"); + expect(text).toContain("@location(5) aInstanceRow2"); + } + for (const { text } of variantsOf(meshLitWGSL, LIT_INSTANCED)) { + // the lit tier's geometry runs to location 3 (the normal) + expect(text).toContain("@location(4) aInstanceRow0"); + expect(text).toContain("@location(6) aInstanceRow2"); + } + }); + + it("declares an optional slot only when it is present, at a fixed location", () => { + for (const { hasColor, hasData, text } of variantsOf( + meshWGSL, + UNLIT_INSTANCED, + )) { + const label = `colors=${hasColor} data=${hasData}`; + expect(text.includes("aInstanceColor"), label).toBe(hasColor); + expect(text.includes("aInstanceData"), label).toBe(hasData); + if (hasColor) { + expect(text, label).toContain("@location(6) aInstanceColor"); + } + if (hasData) { + // location 7 whether or not the colour slot is there + expect(text, label).toContain("@location(7) aInstanceData"); + } + } + }); + + it("folds the custom slot into the emissive term, and only then", () => { + const [bare, , , withData] = variantsOf(meshLitWGSL, LIT_INSTANCED); + expect(bare.text).toContain("uMesh.emissive.rgb"); + expect(bare.text).not.toContain("vInstanceData"); + // with the slot declared, the fragment adds it to the emissive it + // already applies — the lighting maths itself is untouched + expect(withData.text).toContain( + "(uMesh.emissive.rgb + in.vInstanceData.rgb)", + ); + expect(withData.text).toContain("vInstanceData : vec4f,"); + expect(withData.text).toContain("out.vInstanceData = aInstanceData;"); + }); + + it("multiplies the instance colour into the tint, and only when declared", () => { + const [bare, withColor] = variantsOf(meshWGSL, UNLIT_INSTANCED); + expect(bare.text).toContain("let tinted = aColor * uMesh.tint;"); + expect(withColor.text).toContain( + "let tinted = aColor * uMesh.tint * aInstanceColor;", + ); + }); + + it("composes the normal through both transforms on the lit tier", () => { + const bare = buildInstancedMeshWGSL(meshLitWGSL, { + ...LIT_INSTANCED, + hasColor: false, + hasData: false, + }); + // group × instance, the same order the position takes them + expect(bare).toContain( + "out.vNormal = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * mi * aNormal;", + ); + }); + + it("fails loudly if the fragment stage stops carrying the emissive term", () => { + // the drift guard. If someone renames or restructures the emissive + // term in mesh-lit.wgsl, the substitution would silently no-op and + // per-instance emissive would just stop working — so it throws. + const drifted = meshLitWGSL.replace( + /uMesh\.emissive\.rgb/g, + "uMesh.selfLit.rgb", + ); + expect(() => { + buildInstancedMeshWGSL(drifted, { + ...LIT_INSTANCED, + hasColor: false, + hasData: true, + }); + }).toThrow(/emissive/); + }); + + it("rejects a source that is not a two-stage module", () => { + expect(() => { + buildInstancedMeshWGSL("fn helper() {}", { + ...UNLIT_INSTANCED, + hasColor: false, + hasData: false, + }); + }).toThrow(/both stages/); + }); +}); + +describe("WebGPUInstanceBuffer (mock renderer)", () => { + // 3 instances of a bare 48-byte record + const RECORDS = 3; + const FLOATS = 12; + const STRIDE = 48; + + const makeData = () => { + return new Float32Array(RECORDS * FLOATS).fill(1); + }; + + it("allocates on first use and writes the whole record set", () => { + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + + expect(renderer.calls.createdBuffers).toHaveLength(1); + const descriptor = renderer.calls.createdBuffers[0]; + expect(descriptor.size).toBeGreaterThanOrEqual(RECORDS * STRIDE); + // VERTEX so it can be a second vertex buffer, COPY_DST so records + // can be written into it — nothing else + expect(descriptor.usage).toBe( + GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + ); + expect(renderer.calls.writes).toHaveLength(1); + expect(renderer.calls.writes[0].offset).toBe(0); + }); + + it("a clean buffer uploads nothing at all", () => { + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + renderer.calls.writes.length = 0; + + buffer.upload(data, 0, 0, RECORDS * STRIDE); + expect(renderer.calls.writes).toHaveLength(0); + expect(renderer.calls.createdBuffers).toHaveLength(1); + }); + + it("a dirty span writes ONLY that span, in place", () => { + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + renderer.calls.writes.length = 0; + + // instance 1 only + buffer.upload(data, FLOATS, FLOATS, RECORDS * STRIDE); + expect(renderer.calls.createdBuffers).toHaveLength(1); // no realloc + expect(renderer.calls.writes).toHaveLength(1); + expect(renderer.calls.writes[0].offset).toBe(STRIDE); + expect(renderer.calls.writes[0].size).toBe(STRIDE); + }); + + it("reallocates and retires when the record set outgrows the allocation", () => { + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const small = makeData(); + buffer.upload(small, 0, small.length, RECORDS * STRIDE); + const first = buffer.buffer; + + const large = new Float32Array(64 * FLOATS).fill(2); + buffer.upload(large, 0, large.length, 64 * STRIDE); + expect(renderer.calls.createdBuffers).toHaveLength(2); + // the old buffer retires rather than being destroyed outright: a draw + // recorded earlier this frame may still reference it, and destroying + // such a buffer fails the whole submit + expect(renderer.calls.retiredBuffers).toContain(first); + expect(buffer.buffer).not.toBe(first); + }); + + it("an edit AFTER a draw this frame goes to a FRESH buffer (queue-ordering law)", () => { + // The WebGPU-specific hazard with no GL equivalent. + // `queue.writeBuffer` executes before EVERY draw recorded this frame, + // so writing in place here would retroactively move the instances the + // already-recorded draw is about to render. + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + const first = buffer.buffer; + + // a draw was recorded against it this frame + buffer.lastDrawnFrameId = renderer.frameId; + renderer.calls.writes.length = 0; + + buffer.upload(data, FLOATS, FLOATS, RECORDS * STRIDE); + expect(buffer.buffer).not.toBe(first); + expect(renderer.calls.retiredBuffers).toContain(first); + // the fresh buffer gets the FULL record set, not just the dirty span — + // it starts empty, so a partial write would leave the rest as zeros + expect(renderer.calls.writes).toHaveLength(1); + expect(renderer.calls.writes[0].offset).toBe(0); + expect(renderer.calls.writes[0].size).toBe(data.byteLength); + // and the stamp is cleared, so the next edit this frame writes in place + expect(buffer.lastDrawnFrameId).toBe(-1); + }); + + it("an edit in a LATER frame writes in place — the guard is per frame, not sticky", () => { + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + buffer.lastDrawnFrameId = renderer.frameId; + + // next frame: the earlier draw has been submitted, so in-place is safe + renderer.frameId += 1; + renderer.calls.writes.length = 0; + const before = buffer.buffer; + buffer.upload(data, FLOATS, FLOATS, RECORDS * STRIDE); + + expect(buffer.buffer).toBe(before); + expect(renderer.calls.createdBuffers).toHaveLength(1); + expect(renderer.calls.writes[0].offset).toBe(STRIDE); + }); + + it("a clean pass after a draw does NOT reallocate", () => { + // nothing changed, so there is nothing that could apply retroactively + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + buffer.lastDrawnFrameId = renderer.frameId; + + buffer.upload(data, 0, 0, RECORDS * STRIDE); + expect(renderer.calls.createdBuffers).toHaveLength(1); + expect(renderer.calls.retiredBuffers).toHaveLength(0); + }); + + it("every write is 4-byte aligned in offset AND size, for any span", () => { + // writeBuffer rejects an unaligned offset or size outright. Records + // are whole floats so this holds by construction — pinned because a + // future packed slot (unorm8x4, say) could break it silently for + // spans that happen not to be exercised by the other tests. + for (const [hasColor, hasData] of [ + [false, false], + [true, false], + [false, true], + [true, true], + ]) { + const layout = instanceRecordLayout(hasColor, hasData); + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const count = 5; + const data = new Float32Array(count * layout.floats); + buffer.upload(data, 0, data.length, count * layout.stride); + renderer.calls.writes.length = 0; + + for (let first = 0; first < count; first++) { + for (let span = 1; span + first <= count; span++) { + buffer.upload( + data, + first * layout.floats, + span * layout.floats, + count * layout.stride, + ); + } + } + for (const write of renderer.calls.writes) { + const label = `colors=${hasColor} data=${hasData}`; + expect(write.offset % 4, label).toBe(0); + expect(write.size % 4, label).toBe(0); + } + } + }); + + it("a span reaching the LAST record stays inside the allocation", () => { + // an off-by-one here writes past the end and the whole submit fails + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + renderer.calls.writes.length = 0; + + buffer.upload(data, (RECORDS - 1) * FLOATS, FLOATS, RECORDS * STRIDE); + const write = renderer.calls.writes[0]; + expect(write.offset).toBe((RECORDS - 1) * STRIDE); + expect(write.offset + write.size).toBe(RECORDS * STRIDE); + expect(write.offset + write.size).toBeLessThanOrEqual(buffer.capacity); + }); + + it("exactly filling the allocation does NOT reallocate; one byte more does", () => { + // the boundary condition of the `usedBytes > capacity` test + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + const capacity = buffer.capacity; + const first = buffer.buffer; + + // exactly at capacity — reuse + buffer.upload(data, 0, FLOATS, capacity); + expect(buffer.buffer).toBe(first); + expect(renderer.calls.createdBuffers).toHaveLength(1); + + // one byte over — reallocate + buffer.upload(data, 0, FLOATS, capacity + 1); + expect(buffer.buffer).not.toBe(first); + expect(renderer.calls.createdBuffers).toHaveLength(2); + }); + + it("an empty record set never requests a zero-sized buffer", () => { + // createBuffer({size: 0}) is a validation error + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + buffer.upload(new Float32Array(0), 0, 0, 0); + expect(renderer.calls.createdBuffers[0].size).toBeGreaterThan(0); + }); + + it("a growth that ALSO happens after a draw reallocates exactly once", () => { + // both realloc reasons at the same time must not retire twice or + // leave a buffer unretired + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const small = makeData(); + buffer.upload(small, 0, small.length, RECORDS * STRIDE); + const first = buffer.buffer; + buffer.lastDrawnFrameId = renderer.frameId; + + const large = new Float32Array(32 * FLOATS); + buffer.upload(large, 0, large.length, 32 * STRIDE); + expect(renderer.calls.createdBuffers).toHaveLength(2); + expect(renderer.calls.retiredBuffers).toEqual([first]); + }); + + it("carries the right BYTES, not just the right byte counts", () => { + // the element-vs-byte trap: writeBuffer counts dataOffset/size in + // ELEMENTS for a TypedArray and BYTES otherwise, so a mismatched + // convention still produces plausible-looking calls while copying a + // quarter of the data. Check the payload, not the arithmetic. + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + for (let i = 0; i < data.length; i++) { + data[i] = i; + } + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + expect(Array.from(renderer.calls.writes[0].floats)).toEqual( + Array.from(data), + ); + + renderer.calls.writes.length = 0; + // record 1 only: floats 12..23 + buffer.upload(data, FLOATS, FLOATS, RECORDS * STRIDE); + expect(Array.from(renderer.calls.writes[0].floats)).toEqual( + Array.from(data.subarray(FLOATS, 2 * FLOATS)), + ); + }); + + it("destroy retires the buffer and is safe twice", () => { + const renderer = createMockWebGPURenderer(); + const buffer = new WebGPUInstanceBuffer(renderer); + const data = makeData(); + buffer.upload(data, 0, data.length, RECORDS * STRIDE); + const gpu = buffer.buffer; + + buffer.destroy(); + expect(renderer.calls.retiredBuffers).toContain(gpu); + expect(buffer.buffer).toBe(null); + expect(() => { + buffer.destroy(); + }).not.toThrow(); + expect(renderer.calls.retiredBuffers).toHaveLength(1); + }); +}); + +describe("the instanced vertex layout (mock device)", () => { + function createMockDevice() { + const pipelines = []; + return { + pipelines, + createBindGroupLayout(d) { + return { label: d.label }; + }, + createBindGroup(d) { + return { label: d.label }; + }, + createShaderModule(d) { + return { label: d.label }; + }, + createPipelineLayout() { + return {}; + }, + createRenderPipeline(descriptor) { + const pipeline = { descriptor }; + pipelines.push(pipeline); + return pipeline; + }, + }; + } + + const registerInstanced = (cache, hasColor, hasData, baseLocation = 3) => { + const layout = instanceRecordLayout(hasColor, hasData); + const bytes = Float32Array.BYTES_PER_ELEMENT; + cache.registerVertexLayout("instanced", [ + { + stride: 36, + attributes: [ + { format: "float32x3", offset: 0 }, + { format: "float32x2", offset: 12 }, + { format: "float32x4", offset: 20 }, + ], + }, + { + stride: layout.stride, + stepMode: "instance", + attributes: [ + { format: "float32x4", offset: 0, shaderLocation: baseLocation }, + { format: "float32x4", offset: 16, shaderLocation: baseLocation + 1 }, + { format: "float32x4", offset: 32, shaderLocation: baseLocation + 2 }, + ...(hasColor + ? [ + { + format: "float32x4", + offset: layout.colorOffset * bytes, + shaderLocation: baseLocation + 3, + }, + ] + : []), + ...(hasData + ? [ + { + format: "float32x4", + offset: layout.dataOffset * bytes, + shaderLocation: baseLocation + 4, + }, + ] + : []), + ], + }, + ]); + return layout; + }; + + it("puts the records in a second buffer that steps per instance", () => { + const device = createMockDevice(); + const cache = new WebGPUPipelineCache(device, "bgra8unorm"); + registerInstanced(cache, false, false); + cache.get("instanced", "triangle-list", "none", true); + + const { buffers } = device.pipelines[0].descriptor.vertex; + expect(buffers).toHaveLength(2); + // the geometry group must stay per-vertex — WebGPU's default, and + // emitting "vertex" explicitly would change every 2D descriptor + expect("stepMode" in buffers[0]).toBe(false); + expect(buffers[1].stepMode).toBe("instance"); + expect(buffers[1].arrayStride).toBe(48); + }); + + it("keeps the data slot's location fixed whether or not colour is present", () => { + // the property the derived module depends on + const withBoth = createMockDevice(); + const cacheBoth = new WebGPUPipelineCache(withBoth, "bgra8unorm"); + registerInstanced(cacheBoth, true, true); + cacheBoth.get("instanced", "triangle-list", "none", true); + + const dataOnly = createMockDevice(); + const cacheData = new WebGPUPipelineCache(dataOnly, "bgra8unorm"); + registerInstanced(cacheData, false, true); + cacheData.get("instanced", "triangle-list", "none", true); + + const locationOf = (device) => { + const attrs = device.pipelines[0].descriptor.vertex.buffers[1].attributes; + return attrs[attrs.length - 1].shaderLocation; + }; + // last attribute is the data slot in both cases, at the same location + expect(locationOf(withBoth)).toBe(7); + expect(locationOf(dataOnly)).toBe(7); + // but at different byte offsets, since the colour slot moved it + const both = withBoth.pipelines[0].descriptor.vertex.buffers[1]; + const data = dataOnly.pipelines[0].descriptor.vertex.buffers[1]; + expect(both.attributes[both.attributes.length - 1].offset).toBe(64); + expect(data.attributes[data.attributes.length - 1].offset).toBe(48); + expect(both.arrayStride).toBe(80); + expect(data.arrayStride).toBe(64); + }); +}); diff --git a/packages/melonjs/tests/webgpu_pipeline.spec.js b/packages/melonjs/tests/webgpu_pipeline.spec.js index ffc0e68fe..99655cad2 100644 --- a/packages/melonjs/tests/webgpu_pipeline.spec.js +++ b/packages/melonjs/tests/webgpu_pipeline.spec.js @@ -157,6 +157,70 @@ describe("WebGPU pipeline (device-free units)", () => { return { device, cache }; } + it("a single-buffer registration emits exactly one layout, with no stepMode (#1508 pin)", () => { + // the multi-buffer generalization must leave every existing + // pipeline descriptor byte-identical: one entry in `buffers`, + // sequential shader locations, and NO `stepMode` key at all — + // "vertex" is the WebGPU default, so emitting it explicitly would + // change every descriptor the 2D tier is built from + const { device, cache } = makeCache(); + cache.get("quad", "triangle-list", "normal", true); + const { buffers } = device.pipelines[0].descriptor.vertex; + expect(buffers).toHaveLength(1); + expect("stepMode" in buffers[0]).toBe(false); + expect(buffers[0]).toEqual({ + arrayStride: 28, + attributes: [ + { format: "float32x3", offset: 0, shaderLocation: 0 }, + { format: "float32x2", offset: 12, shaderLocation: 1 }, + { format: "unorm8x4", offset: 20, shaderLocation: 2 }, + { format: "float32", offset: 24, shaderLocation: 3 }, + ], + }); + }); + + it("a two-group registration puts the instance layout second, locations continuing across groups", () => { + const device = createMockDevice(); + const cache = new WebGPUPipelineCache(device, "bgra8unorm"); + cache.registerVertexLayout("instancedMesh", [ + { + stride: 36, + attributes: [ + { format: "float32x3", offset: 0 }, + { format: "float32x2", offset: 12 }, + { format: "float32x4", offset: 20 }, + ], + }, + { + stride: 48, + stepMode: "instance", + attributes: [ + { format: "float32x4", offset: 0 }, + { format: "float32x4", offset: 16 }, + { format: "float32x4", offset: 32 }, + ], + }, + ]); + cache.get("instancedMesh", "triangle-list", "none", true); + const { buffers } = device.pipelines[0].descriptor.vertex; + expect(buffers).toHaveLength(2); + expect("stepMode" in buffers[0]).toBe(false); + expect(buffers[1].stepMode).toBe("instance"); + expect(buffers[1].arrayStride).toBe(48); + // WGSL locations are one namespace: the instance group continues + // from where the geometry group stopped, it does not restart at 0 + expect( + buffers[0].attributes.map((a) => { + return a.shaderLocation; + }), + ).toEqual([0, 1, 2]); + expect( + buffers[1].attributes.map((a) => { + return a.shaderLocation; + }), + ).toEqual([3, 4, 5]); + }); + it("the same state tuple returns the same pipeline object", () => { const { device, cache } = makeCache(); const a = cache.get("quad", "triangle-list", "normal", true);