diff --git a/README.md b/README.md index 502370c649..ee15e9f4ce 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ melonJS is designed so you can **focus on making games, not on graphics plumbing - **Complete engine, minimal footprint** — Physics, tilemaps, audio, input, cameras, tweens, particles, UI — a full game stack in a single tree-shakeable ES module. No dependency sprawl, no library stitching. -- **Tiled as a first-class citizen** — Deep [Tiled](https://www.mapeditor.org) integration built into the core: orthogonal, isometric, hexagonal and staggered maps, animated tilesets, collision shapes, object properties, compressed formats — all parsed and rendered natively, with GPU-accelerated tile rendering for orthogonal maps under WebGL 2. +- **Scenes, loaded in one call** — `me.level.load(name)` brings an authored scene straight into your world. [Tiled](https://www.mapeditor.org) is a first-class citizen for **2D** — orthogonal, isometric, hexagonal & staggered maps, animated tilesets, collision shapes, object properties, compressed formats, with GPU-accelerated tile rendering under WebGL 2 — and **glTF / GLB** is the equivalent for **3D scenes**: author in Blender (or any DCC tool), export a `.glb`, and the whole scene — meshes, materials, cameras, and lights — loads under a `Camera3d`, no per-mesh wiring. - **Batteries included, hackable by design** — Get started in minutes with minimal setup. When you need to go deeper: ES6 classes throughout, a plugin system for engine extensions, and a clean architecture that's easy to extend without fighting the framework. @@ -49,12 +49,14 @@ Graphics - Fast WebGL renderer for desktop and mobile devices with fallback to Canvas rendering - Extensible batcher system for custom rendering pipelines - High DPI resolution & Canvas advanced auto scaling -- Sprite with 9-slice scaling option, frame animation, and optional per-pixel normal-map shading for 3D-looking dynamic lights +- Sprite with 9-slice scaling option and frame animation - Built-in effects such as tinting, masking, and CSS-style blend modes (normal, additive, multiply, screen, darken, lighten) - Standard spritesheet, single and multiple Packed Textures support - Compressed texture support (DDS, KTX, KTX2, PVR, PKM) with automatic format detection and fallback - 3D mesh rendering with OBJ/MTL model loading, multi-material support, hardware depth testing, and perspective projection via `Camera3d` -- `Light2d` as a first-class `Renderable` — multiple dynamic lights, radial-gradient falloff, illumination-only mode, and procedural rendering via `drawLight` +- Lighting, in 2D and 3D: + - **2D** — `Light2d` as a first-class `Renderable` (multiple dynamic lights, radial-gradient falloff, illumination-only mode, procedural rendering via `drawLight`), plus optional per-pixel normal-map shading on sprites for 3D-looking dynamic lights + - **3D** — directional lights via `Light3d` / `LightingEnvironment` (half-Lambert diffuse + ambient floor), auto-loaded from a glTF scene's authored sun - Built-in shader effects (Flash, Outline, Glow, Dissolve, CRT, Hologram, etc.) with multi-pass chaining via `postEffects`, plus custom shader support via `ShaderEffect` for per-sprite fragment effects (WebGL) - Trail renderable for fading, tapering ribbons behind moving objects (speed lines, sword slashes, magic trails) - System & Bitmap Text with built-in typewriter effect @@ -92,7 +94,8 @@ UI - `Draggable` / `DropTarget` for drag-and-drop with configurable overlap or contains-check - `UITextButton` text button with hover, press, and key-bind support — built on `BitmapText` -Level Editor +Scenes +- Load a scene in one call with `me.level.load(name)` — 2D Tiled maps and 3D glTF scenes alike, auto-registered on preload - [Tiled](https://www.mapeditor.org) map format [up to 1.12](https://doc.mapeditor.org/en/stable/reference/tmx-changelog/) built-in support for easy level design - **GPU-accelerated tile rendering** for orthogonal maps under WebGL 2 — each layer draws as a single quad with no per-tile loop, ~5–8× faster than the legacy CPU renderer on dense maps. Honors animated tiles, flip bits, per-layer opacity/tint/blend, and oversized bottom-aligned tiles; falls back transparently to the CPU renderer on isometric/staggered/hexagonal layers or non-WebGL-2 contexts - Uncompressed and [compressed](https://github.com/melonjs/melonJS/tree/master/packages/tiled-inflate-plugin) Plain, Base64, CSV and JSON encoded XML tilemap loading @@ -110,11 +113,17 @@ Level Editor - Dynamic Layer and Object/Group ordering - Dynamic Entity loading via an extensible object factory registry — register custom handlers for any Tiled class name without modifying engine code - Shape based Tile collision support +- glTF / GLB 3D scenes — load an authored 3D scene with `me.level.load(...)`, the same one call as a Tiled map + - The whole scene loads at once — meshes, materials, cameras and lights — viewed under a `Camera3d` + - Automatically lit by the scene's directional lights (the sun set up in the authoring tool) + - Textured, solid-colored, and vertex-colored materials + - `.glb` and self-contained `.gltf` files + - Works with any glTF authoring tool (Blender, Maya, 3ds Max, Cinema 4D, …) Assets - Asynchronous asset loading with progress tracking - A fully customizable preloader -- Support for images, JSON, TMX/TSX, `.aseprite` / `.ase` binary, audio, video, binary and fonts +- Support for images, JSON, TMX/TSX, glTF / GLB 3D scenes, `.aseprite` / `.ase` binary, audio, video, binary and fonts Core - `Application` class as the modern entry point with built-in pause, resume, and `freeze()` (hit-stop) primitives @@ -173,6 +182,7 @@ Examples * [3D Mesh](https://melonjs.github.io/melonJS/examples/#/mesh-3d) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/mesh3d)) * [3D Mesh Material](https://melonjs.github.io/melonJS/examples/#/mesh-3d-material) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/mesh3dMaterial)) * [AfterBurner Clone](https://melonjs.github.io/melonJS/examples/#/after-burner) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/afterBurner)) — `Camera3d` + 3D Mesh arcade shooter +* [glTF Scene](https://melonjs.github.io/melonJS/examples/#/gltf) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/gltf)) — a Blender-authored, lit 3D scene loaded with `me.level.load` * [Trail](https://melonjs.github.io/melonJS/examples/#/trail) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/trail)) * [Shader Effects](https://melonjs.github.io/melonJS/examples/#/shader-effects) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/shaderEffects)) * [Spine](https://melonjs.github.io/melonJS/examples/#/spine) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/spine)) diff --git a/packages/debug-plugin/CHANGELOG.md b/packages/debug-plugin/CHANGELOG.md index ee8c117f0c..d7e9f46a62 100644 --- a/packages/debug-plugin/CHANGELOG.md +++ b/packages/debug-plugin/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 16.1.0 + +### Requirements +- Requires melonJS **19.8.0 or later** — the 3D mesh bounding-box overlay uses the new `Mesh.getBounds3d()` and `Camera3d.worldToScreen()` APIs (and the exported `AABB3d` type) introduced in 19.8. + +### Improvements +- The hitbox overlay now draws a correct **3D bounding-box wireframe** for `Mesh` renderables under a `Camera3d`. Previously a mesh only got the inherited flat 2D `getBounds()` rectangle — screen-flat, oversized, and unrelated to the 3D geometry. The new overlay projects the 8 corners of the mesh's world-space `AABB3d` through the camera (`Camera3d.worldToScreen`) and strokes the 12 edges in a screen-space pass, so the box tracks the mesh in perspective. Meshes under a `Camera2d` (the self-projected 2D path) keep the existing 2D box. + ## 16.0.0 ### Breaking Changes diff --git a/packages/debug-plugin/package.json b/packages/debug-plugin/package.json index 722e847c69..4f78e7a3f6 100644 --- a/packages/debug-plugin/package.json +++ b/packages/debug-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@melonjs/debug-plugin", - "version": "16.0.0", + "version": "16.1.0", "description": "melonJS debug plugin", "homepage": "https://www.npmjs.com/package/@melonjs/debug-plugin", "type": "module", @@ -43,7 +43,7 @@ "README.md" ], "peerDependencies": { - "melonjs": ">=19.5.0" + "melonjs": ">=19.8.0" }, "devDependencies": { "concurrently": "^9.2.1", diff --git a/packages/debug-plugin/src/patches.js b/packages/debug-plugin/src/patches.js index aeb40d0484..43832ff865 100644 --- a/packages/debug-plugin/src/patches.js +++ b/packages/debug-plugin/src/patches.js @@ -2,14 +2,18 @@ import { BitmapText, Bounds, Camera2d, + Camera3d, Container, Entity, game, ImageLayer, + Matrix3d, + Mesh, plugin, Renderable, Text, Vector2d, + Vector3d, } from "melonjs"; /** @@ -23,6 +27,102 @@ import { const sharedBodyAABB = new Bounds(); const sharedBodyVel = new Vector2d(); +// Scratch for the Mesh 3D bounding-box wireframe overlay. The 8 box corners +// (world space) and their projected screen positions are reused every frame; +// the two matrices save/restore the perspective projection around the +// screen-space line pass. Single-instance is safe — drawing is synchronous. +const _meshCorners = Array.from({ length: 8 }, () => { + return new Vector3d(); +}); +const _meshScreen = Array.from({ length: 8 }, () => { + return new Vector2d(); +}); +const _meshSavedProj = new Matrix3d(); +const _meshScreenProj = new Matrix3d(); +// the 12 edges of a box, indexing the 8 corners laid out by +// `strokeMeshWireframe`: near face (z=min) 0-3, far face (z=max) 4-7. +const BOX_EDGES = [ + [0, 1], + [1, 2], + [2, 3], + [3, 0], // near face + [4, 5], + [5, 6], + [6, 7], + [7, 4], // far face + [0, 4], + [1, 5], + [2, 6], + [3, 7], // connecting edges +]; + +/** + * Draw a {@link Mesh}'s world-space 3D bounding box as a green wireframe + * under a `Camera3d`. The 8 corners of `mesh.getBounds3d()` are projected to + * screen via `camera.worldToScreen`, then the 12 edges are stroked in a + * screen-space pass (identity transform + a screen-ortho projection) so the + * lines land exactly where the perspective-projected mesh is drawn. + * + * This replaces the flat 2D `getBounds()` rectangle the generic overlay would + * draw, which cannot describe a 3D mesh's extent. + * @param {*} renderer + * @param {import("./index").DebugPanelPlugin} panel + * @param {import("melonjs").Mesh} mesh + * @param {import("melonjs").Camera3d} camera + */ +function strokeMeshWireframe(renderer, panel, mesh, camera) { + const box = mesh.getBounds3d(); + if (!box.isFinite()) { + return; + } + const min = box.min; + const max = box.max; + // near face (z = min): 0..3, far face (z = max): 4..7 + _meshCorners[0].set(min.x, min.y, min.z); + _meshCorners[1].set(max.x, min.y, min.z); + _meshCorners[2].set(max.x, max.y, min.z); + _meshCorners[3].set(min.x, max.y, min.z); + _meshCorners[4].set(min.x, min.y, max.z); + _meshCorners[5].set(max.x, min.y, max.z); + _meshCorners[6].set(max.x, max.y, max.z); + _meshCorners[7].set(min.x, max.y, max.z); + for (let i = 0; i < 8; i++) { + // worldToScreen returns null for a corner at/behind the camera — + // projecting it would mirror the point and draw edges shooting across + // the screen, so skip the whole box when the mesh straddles the camera. + if (camera.worldToScreen(_meshCorners[i], _meshScreen[i]) === null) { + return; + } + } + + // stroke the edges in screen space: drop the camera view transform and + // swap the perspective projection for a screen ortho, so the already- + // projected pixel coordinates draw 1:1. The projection isn't part of the + // save/restore stack, so it's saved/restored explicitly (same pattern as + // the renderer's own blit path). + renderer.save(); + _meshSavedProj.copy(renderer.projectionMatrix); + renderer.currentTransform.identity(); + _meshScreenProj.ortho(0, camera.width, camera.height, 0, -1, 1); + renderer.setProjection(_meshScreenProj); + renderer.setColor("green"); + renderer.lineWidth = 1; + for (const [a, b] of BOX_EDGES) { + renderer.strokeLine( + _meshScreen[a].x, + _meshScreen[a].y, + _meshScreen[b].x, + _meshScreen[b].y, + ); + } + // flush the lines under the screen projection before restoring the + // perspective projection, or they'd be re-projected on the next flush. + renderer.flush(); + renderer.setProjection(_meshSavedProj); + renderer.restore(); + panel.counters.inc("shapes"); +} + /** * Stroke the orange body AABB and the red collision shapes for the * given renderable. Caller is responsible for positioning the renderer @@ -109,6 +209,18 @@ export function applyPatches(panel) { panel.counters.inc("draws"); } + // Mesh under a Camera3d: draw the proper 3D bounding-box wireframe + // instead of the flat, oversized 2D getBounds() box (which can't + // describe 3D geometry). Under a Camera2d a mesh self-projects to 2D, + // so it falls through to the generic box below. + if (this instanceof Mesh && panel.options.hitbox) { + const cam = this.parentApp?.viewport ?? game.viewport; + if (cam instanceof Camera3d) { + strokeMeshWireframe(renderer, panel, this, cam); + return; + } + } + // skip types that have their own dedicated patches, or when no // overlay is enabled (hitbox AND velocity both off ⇒ nothing // would be drawn). Note that hitbox and velocity are now diff --git a/packages/examples/public/assets/gltf/platformer-diorama.glb b/packages/examples/public/assets/gltf/platformer-diorama.glb new file mode 100644 index 0000000000..6a000c0bfe Binary files /dev/null and b/packages/examples/public/assets/gltf/platformer-diorama.glb differ diff --git a/packages/examples/src/examples/gltf/ExampleGltf.tsx b/packages/examples/src/examples/gltf/ExampleGltf.tsx new file mode 100644 index 0000000000..956f879bcf --- /dev/null +++ b/packages/examples/src/examples/gltf/ExampleGltf.tsx @@ -0,0 +1,295 @@ +/** + * melonJS — glTF/GLB scene loader example (Tier 1). + * Loads a Blender-authored scene (Kenney Platformer Kit, CC0) exported as + * GLB via the level director (`me.level.load`), then frames a Camera3d on it + * with Blender-style orbit controls. + * 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, + 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 — scales the whole scene up to screen size. Kept +// small so the framed camera distance stays inside Camera3d's far plane. +const SCALE = 32; + +/** + * A screen-fixed sky gradient drawn behind the scene. `Camera3d` doesn't + * clear to the world `backgroundColor`, so we paint our own sky as a + * `floating` (screen-space, perspective-exempt) renderable. + */ +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, "#3a8ee6"); // zenith blue + g.addColorStop(0.62, "#8ec7ff"); // mid sky + g.addColorStop(1, "#e7f4ff"); // pale horizon + 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 — ignore 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 = () => { + let app: Application; + try { + app = new Application(1024, 768, { + parent: "screen", + renderer: video.WEBGL, // Mesh rendering requires WebGL + scale: "auto", + cameraClass: Camera3dClass, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + globalThis.alert( + "This example couldn't start: WebGL isn't available.\n\n" + + "glTF mesh rendering requires a WebGL-capable browser/GPU.\n\n" + + `Details: ${reason}`, + ); + throw err; + } + + plugin.register(DebugPanelPlugin, "debugPanel"); + + let pointerCleanup: (() => void) | null = null; + let domCleanup: (() => void) | null = null; + + // frame the camera + add the sky + wire orbit controls, once the scene + // has been instantiated into the world (runs from level.load's onLoaded, + // after the container reset + mesh creation) + const setupScene = () => { + // sky behind everything (Camera3d doesn't clear to backgroundColor) + app.world.addChild(new SkyBackdrop(), -10000); + + // the parsed descriptor — used here only for camera framing + const scene = loader.getGLTF("diorama"); + if (!scene) { + return; + } + const { min, max } = scene.bounds; + // render space: glTF (x,y,z) → (x, -y, -z) * SCALE (rightHanded rotation) + const cx = ((min[0] + max[0]) / 2) * SCALE; + const cy = -((min[1] + max[1]) / 2) * SCALE; + const cz = -((min[2] + max[2]) / 2) * SCALE; + const spanX = (max[0] - min[0]) * SCALE; + + const camera = app.viewport as InstanceType; + const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + + // Tighten the clip planes to the scene's actual depth range. Camera3d + // defaults to near=0.1 / far=1000 (a 10000:1 ratio) which wastes nearly + // all depth-buffer precision up close — distant props then z-fight with + // the platform they rest on (a fence's base vanishing into the dirt). + // A near plane sized to the scene scale restores precision. + camera.setClipPlanes(SCALE, 4000); + + // Match the authored field of view — Camera3d defaults to 60°, but + // Kenney/Blender scenes are framed with a much narrower lens (~29° + // here). A wide lens exaggerates near-field perspective so the rounded + // grass front-lip looms over and visually swallows props behind it; + // matching the glTF `yfov` reproduces the Blender look. + if (scene.cameras.length > 0 && scene.cameras[0].perspective?.yfov) { + camera.fov = scene.cameras[0].perspective.yfov; + } + + // Showcase framing: a Blender-style 3/4 bird's-eye. The embedded glTF + // camera is a shallow gameplay angle that hides props on the back + // blocks of this stepped diorama behind the front ones — so instead of + // inheriting it we frame the whole scene: a steep look-down (clears the + // platform fronts) at a 3/4 yaw, pulled back far enough that the full + // row fits the (narrow-FOV) view. + let yaw = 0.15; + let pitch = -0.5; // negative = camera above, looking down (~30°) + // distance so the scene's horizontal span fits the camera's + // FOV-derived horizontal field, with headroom + const hfov = + 2 * Math.atan(Math.tan(camera.fov / 2) * (camera.width / camera.height)); + let distance = clamp((spanX * 0.5) / Math.tan(hfov / 2) + 200, 120, 3000); + const initYaw = yaw; + const initPitch = pitch; + const initDistance = distance; + + const updateCam = () => { + distance = clamp(distance, 120, 3000); + camera.pos.set( + cx + Math.sin(yaw) * Math.cos(pitch) * -distance, + cy + Math.sin(pitch) * distance, // up = -Y + cz - Math.cos(yaw) * Math.cos(pitch) * distance, + ); + camera.lookAt(cx, cy, cz); + }; + updateCam(); + + // drag to orbit (yaw = around Y axis, pitch = around X axis). + // radians of rotation per pixel dragged — kept low so a full-width + // drag is roughly a quarter turn rather than a full spin. + const ORBIT_SENSITIVITY = 0.0022; + let dragging = false; + let lastX = 0; + let lastY = 0; + // Use the camera-independent screen coordinates (gameScreenX/Y), NOT + // gameX/gameY: the latter are projected through the viewport, and since + // orbiting moves the camera every frame, the same pixel would map to a + // different world point each move — a feedback loop that makes the + // drag jump wildly. gameScreenX/Y come straight from the canvas/scale + // transform and stay stable while the camera moves. + 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; + pitch = clamp( + pitch - (ev.gameScreenY - lastY) * ORBIT_SENSITIVITY, + -1.45, + 1.45, + ); + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + updateCam(); + }); + pointerCleanup = () => { + input.releasePointerEvent("pointerdown", camera); + input.releasePointerEvent("pointerup", camera); + input.releasePointerEvent("pointermove", camera); + }; + + // on-screen control grid: yaw ◀▶ · pitch ▲▼ · zoom ± · reset + const panel = document.createElement("div"); + panel.style.cssText = + "position:absolute;top:60px;left:16px;display:grid;" + + "grid-template-columns:repeat(3,40px);grid-template-rows:repeat(3,40px);" + + "gap:4px;z-index:1000;font-family:sans-serif;"; + const mk = (label: string, area: string, fn: () => void) => { + const b = document.createElement("button"); + b.textContent = label; + b.style.cssText = + "background:#1a1a1a;color:#e0e0e0;border:1px solid #444;" + + `border-radius:4px;cursor:pointer;font-size:16px;grid-area:${area};`; + b.addEventListener("click", fn); + panel.appendChild(b); + }; + mk("▲", "1 / 2 / 2 / 3", () => { + pitch = clamp(pitch + 0.15, -1.45, 1.45); + updateCam(); + }); + mk("◀", "2 / 1 / 3 / 2", () => { + yaw -= 0.2; + updateCam(); + }); + mk("⟲", "2 / 2 / 3 / 3", () => { + yaw = initYaw; + pitch = initPitch; + distance = initDistance; + updateCam(); + }); + mk("▶", "2 / 3 / 3 / 4", () => { + yaw += 0.2; + updateCam(); + }); + mk("▼", "3 / 2 / 4 / 3", () => { + pitch = clamp(pitch - 0.15, -1.45, 1.45); + updateCam(); + }); + mk("+", "1 / 1 / 2 / 2", () => { + distance -= 70; + updateCam(); + }); + mk("-", "1 / 3 / 2 / 4", () => { + distance += 70; + updateCam(); + }); + + const hint = document.createElement("div"); + hint.textContent = "drag to orbit · buttons to rotate / zoom"; + hint.style.cssText = + "position:absolute;top:188px;left:16px;color:#cfe8ff;" + + "font-family:sans-serif;font-size:12px;z-index:1000;" + + "text-shadow:0 1px 2px rgba(0,0,0,0.6);"; + + const parent = app.renderer.getCanvas().parentElement; + if (parent) { + parent.style.position = "relative"; + parent.appendChild(panel); + parent.appendChild(hint); + } + domCleanup = () => { + panel.remove(); + hint.remove(); + }; + }; + + loader.preload( + [{ name: "diorama", type: "glb", src: `${base}platformer-diorama.glb` }], + () => { + state.change(state.DEFAULT, true); + // load the whole glTF scene into the world in one call — the glb + // auto-registered with the level director on preload, exactly like + // a Tiled map. `rightHanded` defaults to true for glTF scenes. + level.load("diorama", { scale: SCALE, onLoaded: setupScene }); + }, + ); + + return () => { + if (pointerCleanup) { + pointerCleanup(); + } + if (domCleanup) { + domCleanup(); + } + }; +}; + +export const ExampleGltf = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 1653f96607..bc58562d49 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -108,6 +108,11 @@ const ExampleMasking = lazy(() => default: m.ExampleMasking, })), ); +const ExampleGltf = lazy(() => + import("./examples/gltf/ExampleGltf").then((m) => ({ + default: m.ExampleGltf, + })), +); const ExampleMesh3d = lazy(() => import("./examples/mesh3d/ExampleMesh3d").then((m) => ({ default: m.ExampleMesh3d, @@ -360,6 +365,14 @@ const examples: { description: "Rotating textured 3D objects (cube, sphere, teapot) loaded from OBJ files with checkerboard texture and perspective projection.", }, + { + component: , + label: "glTF Scene", + path: "gltf", + sourceDir: "gltf", + description: + "A Blender-authored scene (Kenney Platformer Kit, CC0) exported to GLB and loaded via the glTF Tier-1 importer — each node instantiated as a Mesh under a Camera3d.", + }, { component: , label: "3D Material", diff --git a/packages/examples/vite.config.ts b/packages/examples/vite.config.ts index 199b846c0b..38360b477a 100644 --- a/packages/examples/vite.config.ts +++ b/packages/examples/vite.config.ts @@ -25,7 +25,16 @@ export default defineConfig({ dedupe: ["melonjs"], }, optimizeDeps: { - // Prevent pre-bundling the plugin so it resolves melonjs from the workspace - exclude: ["@melonjs/tiled-inflate-plugin"], + // Don't pre-bundle the workspace packages. Vite caches a pre-bundled + // copy of deps and does NOT re-bundle on a workspace rebuild, so a + // pre-bundled `melonjs` silently serves stale engine code after + // `pnpm build` (you edit the engine, rebuild, and the example keeps + // running the old bundle until a `--force` restart). Excluding them + // makes the dev server pick up `build/index.js` changes on reload. + exclude: [ + "melonjs", + "@melonjs/debug-plugin", + "@melonjs/tiled-inflate-plugin", + ], }, }); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8bfdb16a79..cad14b8a36 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [19.8.0] (melonJS 2) - _unreleased_ + +**Highlights:** glTF / GLB scene loading lands — author a 3D scene in Blender (or any DCC tool), export a `.glb`, and load it like a Tiled map with `me.level.load(...)`. Scene meshes are lit by the authored sun, and 3D meshes can now report a real bounding box. + +### Added +- **glTF / GLB scene loader (Tier 1)** — preload a `.glb`/`.gltf` and it auto-registers with the `level` director, so `me.level.load(name, { scale, rightHanded, onLoaded })` instantiates every mesh node as a `Mesh` in one call, exactly like a Tiled map. Parses the static node graph, mesh primitives (`POSITION` / `NORMAL` / `TEXCOORD_0` / `COLOR_0` / indices), materials (`pbrMetallicRoughness.baseColorTexture` + `baseColorFactor`), perspective cameras, scene bounds, and `KHR_lights_punctual` lights. `loader.getGLTF(name)` returns the raw `{ nodes, cameras, lights, bounds }` descriptor for custom framing/instantiation. View under a `Camera3d`. New **glTF Scene** example (Kenney Platformer Kit, CC0). +- **glTF material color** — `baseColorFactor` is applied as the mesh tint, so a solid-colored *untextured* material renders its color (previously it fell back to white). **Vertex colors** (`COLOR_0`, float or normalized byte/short, VEC3/VEC4) are read into per-vertex colors — untextured vertex-colored meshes (MagicaVoxel exports, vertex-painted models) render correctly. Factor, vertex color, and texture compose (`factor × vertexColor × texel`), and work under lighting. +- **3D mesh lighting** — `Light3d` (a manipulable directional light) + `LightingEnvironment` (a scene-level light container the mesh shader reads; `LightingEnvironment.default` is the active one). Loading a glTF scene instantiates its authored `KHR_lights_punctual` directional lights automatically, so meshes are lit by the same sun set up in the authoring tool. Half-Lambert diffuse + an ambient floor for a soft, stylized look. Meshes opt in via `mesh.lit` and render through a dedicated `LitMeshBatcher` — standalone unlit meshes keep the lean path and pay nothing for lighting. Directional lights only this release (point/spot are parsed but not yet shaded). +- **`Mesh.getBounds3d()`** — the mesh's world-space `AABB3d` (the 3D analog of `getBounds()`, which only describes a flat 2D box). Powers the debug-plugin's new 3D bounding-box wireframe overlay. +- **`Camera3d.worldToScreen(world, out?)`** — project a world point to screen-pixel coordinates (perspective divide included); returns `null` for points behind the camera. Useful for HUD elements pinned to 3D objects, picking, and debug overlays. +- **`AABB3d`** now exported, with `AABB3d.fromVertices(src, count, matrix?)` to build a box from a flat vertex buffer (delegates to the new `transformedBounds`). +- **Mesh `lit` / `normals` settings** and per-vertex world-space normal projection for the Camera3d lighting path. + +### Changed +- **`Renderable.applyAnchorTransform`** (default `true`) — new flag gating whether `preDraw` applies the `anchorPoint` offset to the renderer transform. `Mesh` sets it `false` on the `Camera3d` world-space path: a 3D mesh is positioned by its transform and has no anchor, so the normalized offset must not leak into the shared mesh view matrix. +- **`Mesh` preserves `Uint32Array` index buffers** instead of coercing them to `Uint16Array` — meshes with more than 65,535 vertices (e.g. high-poly glTF nodes) no longer have their indices silently truncated. + +### Fixed +- **glTF/3D meshes rendered at the wrong position under `Camera3d`** — props appeared sunk into / overlapping the surfaces they rested on, even though their parsed placement was numerically identical to the authoring tool. `Renderable.preDraw` was baking each mesh's normalized anchor-point offset (`width/2`, `height/2`) into the shared mesh batcher view matrix; since scene meshes size their bounds box per node, every mesh shifted by a different amount and lost their relative placement. The world-space mesh path now opts out of the anchor offset (see `applyAnchorTransform`), so meshes land exactly where the authoring tool put them. + ## [19.7.1] (melonJS 2) - _2026-06-14_ ### Fixed diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index 5f30b1cc81..04621f83a1 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "19.7.1", + "version": "19.8.0", "description": "melonJS Game Engine", "homepage": "http://www.melonjs.org/", "type": "module", diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index b2adfd63c6..8137299df8 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -1,5 +1,6 @@ import { Matrix3d } from "../math/matrix3d.ts"; import type { ObservableVector3d } from "../math/observableVector3d.ts"; +import { Vector2d } from "../math/vector2d.ts"; import { Vector3d } from "../math/vector3d.ts"; import type Container from "./../renderable/container.js"; import type Renderable from "./../renderable/renderable.js"; @@ -17,6 +18,8 @@ const AXIS_Y = new Vector3d(0, 1, 0); // single-threaded and these are only touched inside one method. const _viewMatrix = new Matrix3d(); const _viewProjection = new Matrix3d(); +// scratch point for worldToScreen, reused to avoid per-call allocation +const _wsPoint = new Vector3d(); /** * A perspective camera that extends {@link Camera2d} with a view @@ -649,6 +652,62 @@ export default class Camera3d extends Camera2d { return dirty; } + /** + * Project a world-space point to 2D screen (canvas pixel) coordinates + * through this camera's view + perspective projection (perspective divide + * included). The origin is top-left with **y down**, matching where + * geometry at `world` rasterizes and the engine's 2D draw space — so the + * result can be fed straight to the 2D draw API (HUD pinned to a 3D object, + * picking, debug overlays such as the 3D bounding-box wireframe). + * + * **Returns `null` when the point is at or behind the camera** (clip + * `w ≤ 0`) — projecting it would yield a mirrored/degenerate pixel, so + * callers (e.g. a debug wireframe) can skip it cleanly instead of drawing + * garbage. Otherwise returns the screen-space pixel coordinates. + * @param world - the world-space point to project + * @param [out] - optional Vector2d to receive the result (allocated if omitted) + * @returns the screen-space pixel coordinates, or `null` if behind the camera + */ + worldToScreen( + world: Vector3d, + out: Vector2d = new Vector2d(), + ): Vector2d | null { + // projection × view — built exactly like `_rebuildFrustumPlanes`: + // rotate (pitch then yaw), translate by -pos, then pre-multiply by the + // frustum projection. + _viewMatrix.identity(); + if (this.pitch !== 0) { + _viewMatrix.rotate(-this.pitch, AXIS_X); + } + if (this.yaw !== 0) { + _viewMatrix.rotate(-this.yaw, AXIS_Y); + } + _viewMatrix.translate(-this.pos.x, -this.pos.y, -this.depth); + _viewProjection.copy(this.frustum.projectionMatrix); + _viewProjection.multiply(_viewMatrix); + + // clip-space w (column-major): reject points at/behind the camera before + // the perspective divide would mirror them. + const m = _viewProjection.val; + const w = m[3] * world.x + m[7] * world.y + m[11] * world.z + m[15]; + if (w <= 0) { + return null; + } + + // `Matrix3d.apply` divides by the clip-space w → normalized device + // coordinates in [-1, 1]. + _wsPoint.set(world.x, world.y, world.z); + _viewProjection.apply(_wsPoint); + + // NDC → screen pixels. NDC +y points up, screen +y points down, so the + // y axis is flipped. + out.set( + (_wsPoint.x * 0.5 + 0.5) * this.width, + (1 - (_wsPoint.y * 0.5 + 0.5)) * this.height, + ); + return out; + } + /** * Recompute the frustum's six bounding planes from the current * `projectionMatrix × viewMatrix` (the world → clip matrix). diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 0aa7ae0595..135902cb5b 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -112,6 +112,8 @@ export { registerTiledObjectClass, registerTiledObjectFactory, } from "./level/tiled/TMXObjectFactory.js"; +export { Light3d } from "./lighting/light3d.ts"; +export { LightingEnvironment } from "./lighting/lighting_environment.ts"; export * as loader from "./loader/loader.js"; export { Color } from "./math/color.ts"; @@ -135,6 +137,7 @@ export type { RaycastHit3d, } from "./physics/adapter.ts"; export { Bounds } from "./physics/bounds.ts"; +export { AABB3d } from "./physics/broadphase/aabb3d.ts"; export { default as BuiltinAdapter } from "./physics/builtin/builtin-adapter.ts"; export { collision } from "./physics/collision.js"; export * as plugin from "./plugin/plugin.ts"; diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js new file mode 100644 index 0000000000..9f8f736dca --- /dev/null +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -0,0 +1,220 @@ +import { Light3d } from "../../lighting/light3d.ts"; +import { LightingEnvironment } from "../../lighting/lighting_environment.ts"; +import { getGLTF } from "../../loader/loader.js"; +import { boundingRadius } from "../../math/vertex.ts"; +import Mesh from "../../renderable/mesh.js"; + +/** + * @classdesc + * A loadable 3D scene parsed from a glTF / GLB asset. Instances are created + * and registered with the {@link level} director (usually automatically by + * the preloader), so a glTF scene loads with the same one-call ergonomics as + * a Tiled map: `me.level.load("myScene")`. + * + * Each glTF mesh node is instantiated as a {@link Mesh} carrying its own + * world transform, so the scene's relative scale and layout are preserved. + * View the result under a `Camera3d` for a coherent perspective. + */ +export default class GLTFScene { + /** + * @param {string} levelId - the glTF/GLB asset name (as preloaded) + */ + constructor(levelId) { + /** + * the level/asset name + * @type {string} + */ + this.name = levelId; + /** + * level format discriminator used by the level director's dispatch + * @type {string} + */ + this.format = "gltf"; + /** + * the parsed scene descriptor (`{ nodes, cameras, lights, bounds }`) + * @type {object} + */ + this.data = getGLTF(levelId); + /** + * the Light3d instances this scene added to the active + * LightingEnvironment, so they can be removed on reload / destroy. + * @type {Light3d[]} + * @ignore + */ + this._lights = []; + } + + /** + * the world-space bounds of the scene (`{ min, max }` in glTF units), or + * undefined if the scene failed to load. Handy for framing a `Camera3d`. + * @returns {object|undefined} + */ + get bounds() { + return this.data?.bounds; + } + + /** + * the cameras parsed from the glTF scene (each with a world matrix + + * perspective parameters), or an empty array. + * @returns {Array} + */ + get cameras() { + return this.data?.cameras ?? []; + } + + /** + * Instantiate every glTF mesh node as a `Mesh` in the given container. + * Called by the level director on `me.level.load(...)`. + * @param {Container} container - the target container (e.g. `game.world`) + * @param {object} [options] + * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale) + * @param {boolean} [options.rightHanded=true] - convert glTF Y-up right-handed + * geometry to the engine's Y-down via a rotation (no mirror). See the wiki. + * @param {boolean} [options.lights=true] - instantiate the scene's authored + * `KHR_lights_punctual` directional lights into {@link LightingEnvironment}.default + * so the meshes are lit by the sun set up in the authoring tool. Set false to + * keep the meshes unlit / manage lighting yourself. + */ + addTo(container, options = {}) { + if (!this.data) { + return; + } + const scale = options.scale ?? 1; + const rightHanded = options.rightHanded !== false; + const zSign = rightHanded ? -1 : 1; + + // the scene is lit when it carries authored directional lights and the + // caller didn't opt out — meshes then render through the lit batcher. + const lit = + options.lights !== false && + (this.data.lights ?? []).some((l) => { + return l.type === "directional"; + }); + + // scene meshes carry their own world transform — keep the container + // from reassigning per-child depth (the GPU depth test resolves + // occlusion between meshes under Camera3d) + container.autoDepth = false; + + for (const node of this.data.nodes) { + const m = node.world; + + // The node's world placement splits into a CENTER (the matrix + // translation, in render space) carried by the renderable's + // pos/depth, and the rotation/scale carried by currentTransform. + // Why: Camera3d frustum culling and depth sorting key off the + // renderable's pos — if every mesh stayed at pos (0,0,0) with all + // placement hidden inside currentTransform, they would all cull as + // a single point at the world origin and the whole scene would pop + // in/out together. The rendered geometry is identical either way + // (`_projectVerticesWorld` adds the pos offset back). + const cx = m[12] * scale; + const cy = -m[13] * scale; + const cz = zSign * m[14] * scale; + + // Conservative world-space bounding radius: the farthest vertex + // from the node origin, taken through the node's rotation/scale + // columns and the scene scale. Feeds the renderable's bounds so + // Camera3d's bounding-sphere test only culls a mesh once its + // geometry is genuinely outside the view (not just its center). + const radius = boundingRadius(node.vertices, node.vertexCount, m) * scale; + // Camera3d derives the cull sphere radius as √(w² + h²) / 2, so a + // square bounds box of side `radius · √2` yields a sphere of + // exactly `radius`. Guard against a zero-size (point) box. + const boxSize = Math.max(radius, 1) * Math.SQRT2; + + const mesh = new Mesh(0, 0, { + vertices: node.vertices, + uvs: node.uvs, + indices: node.indices, + normals: node.normals, + texture: node.image, + width: boxSize, + height: boxSize, + scale, + normalize: false, + rightHanded, + // light this mesh (via the lit batcher) when the scene has lights + lit, + // honor the glTF material's double-sided flag: thin/flat props + // (coins, fences, foliage) are double-sided and must NOT be + // back-face culled, or half their faces vanish + cullBackFaces: node.doubleSided !== true, + }); + // 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 + // path renders opaque.) Composes with COLOR_0 and the texture: the + // batcher does factor × vertexColor × texel, matching glTF. + const f = node.baseColorFactor; + if (f) { + mesh.tint.setColor( + Math.round(f[0] * 255), + Math.round(f[1] * 255), + Math.round(f[2] * 255), + ); + } + // per-vertex colors (COLOR_0) — multiplied by the tint per vertex + if (node.colors) { + mesh.vertexColors = node.colors; + } + + // rotation/scale only — the translation is carried by pos/depth + const local = m.slice(); + local[12] = 0; + local[13] = 0; + local[14] = 0; + mesh.currentTransform.val.set(local); + mesh.pos.set(cx, cy); + mesh.depth = cz; + mesh.name = node.name; + container.addChild(mesh); + } + + // Instantiate the scene's authored directional lights into the active + // LightingEnvironment so the meshes are lit by the same sun set up in + // the authoring tool (Blender etc.). Re-loading replaces this scene's + // own lights (tracked in `_lights`); other lights are left alone. + this._removeLights(); + if (options.lights !== false) { + for (const light of this.data.lights ?? []) { + if (light.type !== "directional") { + // point / spot lights are parsed but not yet shaded + continue; + } + const d = light.direction; + const l3d = new Light3d({ + type: "directional", + // bring the glTF-space direction into render space (same + // Y-down / rightHanded Y/Z bridge the geometry uses) + direction: [d[0], -d[1], zSign * d[2]], + color: [light.color[0], light.color[1], light.color[2]], + // glTF directional intensity is in lux (often thousands) — + // not meaningful for a stylized Lambert shader, so use a unit + // intensity and let the app tune `light.intensity` if needed. + intensity: 1, + }); + LightingEnvironment.default.addLight(l3d); + this._lights.push(l3d); + } + } + } + + /** Remove the lights this scene previously added. @ignore */ + _removeLights() { + for (const light of this._lights) { + LightingEnvironment.default.removeLight(light); + } + this._lights.length = 0; + } + + /** + * Director cleanup hook (parity with `TMXTileMap.destroy`). The meshes are + * owned by the container (reset by the director on the next load); here we + * also pull this scene's lights back out of the active LightingEnvironment. + * @ignore + */ + destroy() { + this._removeLights(); + } +} diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index f98dec8366..3c60f17a23 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -3,6 +3,7 @@ import { getTMX } from "./../loader/loader.js"; import state from "./../state/state.ts"; import { emit, LEVEL_LOADED } from "../system/event.ts"; import { resetGUID } from "./../utils/utils.ts"; +import GLTFScene from "./gltf/GLTFScene.js"; import TMXTileMap from "./tiled/TMXTileMap.js"; // our levels @@ -30,13 +31,22 @@ function safeLoadLevel(levelId, options, restart) { // update current level index currentLevelIdx = levelIdx.indexOf(levelId); - // add the specified level to the game world - loadTMXLevel( - levelId, - options.container, - options.flatten, - options.setViewportBounds, - ); + // add the specified level to the game world. TMX maps keep their + // dedicated loader (GUID reset + viewport bounds + object flattening); + // other formats (glTF/GLB scenes, …) use the generic duck-typed + // `addTo(container, options)` interface. + const targetLevel = levels[levelId]; + if (targetLevel.format === "tmx") { + loadTMXLevel( + levelId, + options.container, + options.flatten, + options.setViewportBounds, + ); + } else { + options.container.anchorPoint.set(0, 0); + targetLevel.addTo(options.container, options); + } // publish the corresponding message emit(LEVEL_LOADED, levelId); @@ -85,32 +95,43 @@ export const level = { * @name add * @memberof level * @public - * @param {string} format - level format (only "tmx" supported) + * @param {string} format - level format ("tmx" for Tiled maps, "gltf" / "glb" for 3D scenes) * @param {string} levelId - the level id (or name) * @param {Function} [callback] - a function to be called once the level is loaded * @returns {boolean} true if the level was loaded */ add(format, levelId, callback) { + let levelObject; switch (format) { case "tmx": - // just load the level with the XML stuff - if (levels[levelId] == null) { - levels[levelId] = new TMXTileMap(levelId, getTMX(levelId)); - levelIdx.push(levelId); - } else { - return false; - } - - // call the callback if defined - if (callback) { - callback(); - } - // true if level loaded - return true; - + levelObject = () => { + return new TMXTileMap(levelId, getTMX(levelId)); + }; + break; + case "gltf": + case "glb": + levelObject = () => { + return new GLTFScene(levelId); + }; + break; default: throw new Error("no level loader defined for format " + format); } + + // register the level once (idempotent) + if (levels[levelId] == null) { + levels[levelId] = levelObject(); + levelIdx.push(levelId); + } else { + return false; + } + + // call the callback if defined + if (callback) { + callback(); + } + // true if level loaded + return true; }, /** @@ -123,8 +144,10 @@ export const level = { * @param {object} [options] - additional optional parameters * @param {Container} [options.container=game.world] - container in which to load the specified level * @param {Function} [options.onLoaded=game.onLevelLoaded] - callback for when the level is fully loaded - * @param {boolean} [options.flatten=game.mergeGroup] - if true, flatten all objects into the given container - * @param {boolean} [options.setViewportBounds=true] - if true, set the viewport bounds to the map size + * @param {boolean} [options.flatten=game.mergeGroup] - (TMX only) if true, flatten all objects into the given container + * @param {boolean} [options.setViewportBounds=true] - (TMX only) if true, set the viewport bounds to the map size + * @param {number} [options.scale=1] - (glTF/GLB only) pixels per glTF unit applied to the whole scene + * @param {boolean} [options.rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror * @returns {boolean} true if the level was successfully loaded * @example * // the game assets to be be preloaded @@ -168,23 +191,19 @@ export const level = { throw new Error("level " + levelId + " not found"); } - if (levels[levelId] instanceof TMXTileMap) { - // check the status of the state mngr - const wasRunning = state.isRunning(); + // check the status of the state mngr + const wasRunning = state.isRunning(); - if (wasRunning) { - // stop the game loop to avoid - // some silly side effects - state.stop(); + if (wasRunning) { + // stop the game loop to avoid + // some silly side effects + state.stop(); - setTimeout(() => { - safeLoadLevel(levelId, options, true); - }); - } else { - safeLoadLevel(levelId, options); - } + setTimeout(() => { + safeLoadLevel(levelId, options, true); + }); } else { - throw new Error("no level loader defined"); + safeLoadLevel(levelId, options); } return true; }, diff --git a/packages/melonjs/src/level/tiled/TMXTileMap.js b/packages/melonjs/src/level/tiled/TMXTileMap.js index 8769311565..02435a7eb7 100644 --- a/packages/melonjs/src/level/tiled/TMXTileMap.js +++ b/packages/melonjs/src/level/tiled/TMXTileMap.js @@ -160,6 +160,12 @@ export default class TMXTileMap { */ this.name = levelId; + /** + * level format discriminator used by the level director's dispatch + * @type {string} + */ + this.format = "tmx"; + /** * width of the tilemap in tiles * @type {number} diff --git a/packages/melonjs/src/lighting/light3d.ts b/packages/melonjs/src/lighting/light3d.ts new file mode 100644 index 0000000000..ddd904ff5e --- /dev/null +++ b/packages/melonjs/src/lighting/light3d.ts @@ -0,0 +1,97 @@ +import { Color } from "../math/color.ts"; +import { Vector3d } from "../math/vector3d.ts"; + +/** + * Options accepted by the {@link Light3d} constructor. + * @category Lighting + */ +export interface Light3dOptions { + /** light type — only `"directional"` is shaded today; `"point"` is reserved. */ + type?: "directional" | "point"; + /** world-space direction the light travels along (directional lights). */ + direction?: [number, number, number]; + /** world-space position (point lights — reserved for a future release). */ + position?: [number, number, number]; + /** + * light color — a {@link Color}, a CSS color string, or an `[r, g, b]` + * array with components in `0..1` (the glTF convention). Defaults to white. + */ + color?: Color | string | [number, number, number]; + /** scalar multiplier on the light's contribution. Defaults to `1`. */ + intensity?: number; +} + +/** + * A manipulable 3D light source for the mesh lighting path — the 3D + * counterpart of {@link Light2d}, but a plain data object (a light draws + * nothing itself): add it to a {@link LightingEnvironment}, which feeds the + * mesh shader. + * + * Only **directional** lights (a "sun": a world-space `direction`, no falloff) + * are shaded in this release. The `type` / `position` fields are carried for a + * future point/spot release. Fields are public and mutable, so a light can be + * animated at runtime (e.g. a day/night cycle rotating `direction`). + * @category Lighting + * @example + * import { Light3d, LightingEnvironment } from "melonjs"; + * const sun = new Light3d({ direction: [0.3, 1, 0.2], color: "#fff", intensity: 1 }); + * LightingEnvironment.default.addLight(sun); + * // later, animate it: + * sun.direction.set(Math.sin(t), 1, Math.cos(t)).normalize(); + */ +export class Light3d { + /** `"directional"` (shaded) or `"point"` (reserved). */ + type: "directional" | "point"; + /** world-space travel direction (directional lights); kept normalized. */ + direction: Vector3d; + /** world-space position (point lights — reserved). */ + position: Vector3d; + /** the light color. */ + color: Color; + /** scalar multiplier on the light's contribution. */ + intensity: number; + + /** + * @param [options] - see {@link Light3dOptions} + */ + constructor(options: Light3dOptions = {}) { + this.type = options.type ?? "directional"; + + this.direction = new Vector3d(0, 1, 0); + if (options.direction) { + this.direction.set( + options.direction[0], + options.direction[1], + options.direction[2], + ); + } + this.direction.normalize(); + + this.position = new Vector3d(0, 0, 0); + if (options.position) { + this.position.set( + options.position[0], + options.position[1], + options.position[2], + ); + } + + if (options.color instanceof Color) { + this.color = options.color; + } else if (Array.isArray(options.color)) { + // glTF convention: [r, g, b] in 0..1 + this.color = new Color( + options.color[0] * 255, + options.color[1] * 255, + options.color[2] * 255, + 1, + ); + } else if (typeof options.color === "string") { + this.color = new Color().parseCSS(options.color); + } else { + this.color = new Color(255, 255, 255, 1); + } + + this.intensity = options.intensity ?? 1; + } +} diff --git a/packages/melonjs/src/lighting/lighting_environment.ts b/packages/melonjs/src/lighting/lighting_environment.ts new file mode 100644 index 0000000000..47e4ebcc7c --- /dev/null +++ b/packages/melonjs/src/lighting/lighting_environment.ts @@ -0,0 +1,152 @@ +import { Color } from "../math/color.ts"; +import { MAX_LIGHTS } from "../video/webgl/lighting/constants.ts"; +import type { Light3d } from "./light3d.ts"; + +/** + * Packed, shader-ready view of a {@link LightingEnvironment}, returned by + * {@link LightingEnvironment#pack}. Arrays are reused between calls. + * @category Lighting + */ +export interface PackedLighting { + /** number of active (directional) lights, clamped to `MAX_LIGHTS`. */ + count: number; + /** `MAX_LIGHTS × 3` surface→light directions (already negated, normalized). */ + directions: Float32Array; + /** `MAX_LIGHTS × 3` light colors premultiplied by intensity (0..1+). */ + colors: Float32Array; + /** `3` ambient color premultiplied by ambient intensity (0..1). */ + ambient: Float32Array; +} + +/** + * A scene-level container of {@link Light3d} sources plus an ambient term, + * consumed by the mesh shader to light {@link Mesh} renderables under a + * `Camera3d`. Lighting is applied only when the environment has at least one + * light; with none, meshes render fullbright (unlit) — so adding this is + * non-breaking. + * + * Use {@link LightingEnvironment.default} as the active environment (the mesh + * batcher reads it), or construct your own. Loading a glTF/GLB scene via + * `me.level.load(...)` instantiates the scene's authored lights into the + * default environment automatically. + * + * Only **directional** lights contribute today (see {@link Light3d}). + * @category Lighting + * @example + * import { Light3d, LightingEnvironment } from "melonjs"; + * LightingEnvironment.default.addLight(new Light3d({ direction: [0.4, 1, 0.3] })); + * LightingEnvironment.default.setAmbient("#404858", 1); + */ +export class LightingEnvironment { + /** the active environment read by the mesh batcher each frame. */ + static default = new LightingEnvironment(); + + /** the lights in this environment. */ + lights: Light3d[]; + /** ambient color (a flat floor added to every lit fragment). */ + ambientColor: Color; + /** scalar multiplier on {@link LightingEnvironment#ambientColor}. */ + ambientIntensity: number; + + private _dir: Float32Array; + private _color: Float32Array; + private _ambient: Float32Array; + + constructor() { + this.lights = []; + // a soft neutral ambient so faces turned away from the light aren't + // pure black once lighting is active + this.ambientColor = new Color(255, 255, 255, 1); + this.ambientIntensity = 0.3; + this._dir = new Float32Array(MAX_LIGHTS * 3); + this._color = new Float32Array(MAX_LIGHTS * 3); + this._ambient = new Float32Array(3); + } + + /** + * Add a light (no-op if already present). + * @param light - the light to add + * @returns the same light, for chaining + */ + addLight(light: Light3d): Light3d { + if (!this.lights.includes(light)) { + this.lights.push(light); + } + return light; + } + + /** + * Remove a previously added light. + * @param light - the light to remove + */ + removeLight(light: Light3d): void { + const i = this.lights.indexOf(light); + if (i !== -1) { + this.lights.splice(i, 1); + } + } + + /** Remove all lights. */ + clear(): void { + this.lights.length = 0; + } + + /** + * Set the ambient floor. + * @param color - a {@link Color} or CSS color string + * @param [intensity] - scalar multiplier (defaults to the current value) + * @returns this environment, for chaining + */ + setAmbient(color: Color | string, intensity?: number): this { + this.ambientColor = + color instanceof Color ? color : new Color().parseCSS(color); + if (typeof intensity === "number") { + this.ambientIntensity = intensity; + } + return this; + } + + /** + * Pack the active directional lights + ambient into shader-ready arrays. + * Reuses internal buffers — copy the result if you need to retain it. + * @returns the packed, shader-ready lighting state + */ + pack(): PackedLighting { + let count = 0; + for (const light of this.lights) { + if (count >= MAX_LIGHTS) { + break; + } + // only directional lights are shaded in this release + if (light.type !== "directional") { + continue; + } + const o = count * 3; + // store the surface→light vector (negated travel direction), + // normalized so the shader can `max(dot(N, dir), 0)` directly even + // if the light's direction was mutated at runtime without + // re-normalizing. + const dx = light.direction.x; + const dy = light.direction.y; + const dz = light.direction.z; + const len = Math.hypot(dx, dy, dz) || 1; + this._dir[o] = -dx / len; + this._dir[o + 1] = -dy / len; + this._dir[o + 2] = -dz / len; + const k = light.intensity; + this._color[o] = (light.color.r / 255) * k; + this._color[o + 1] = (light.color.g / 255) * k; + this._color[o + 2] = (light.color.b / 255) * k; + count++; + } + this._ambient[0] = (this.ambientColor.r / 255) * this.ambientIntensity; + this._ambient[1] = (this.ambientColor.g / 255) * this.ambientIntensity; + this._ambient[2] = (this.ambientColor.b / 255) * this.ambientIntensity; + return { + count, + directions: this._dir, + colors: this._color, + ambient: this._ambient, + }; + } +} diff --git a/packages/melonjs/src/loader/cache.js b/packages/melonjs/src/loader/cache.js index 5d752da5cb..46faafd814 100644 --- a/packages/melonjs/src/loader/cache.js +++ b/packages/melonjs/src/loader/cache.js @@ -25,3 +25,6 @@ export const objList = {}; // contains all the MTL material files export const mtlList = {}; + +// contains all the parsed glTF/GLB scene descriptors +export const gltfList = {}; diff --git a/packages/melonjs/src/loader/loader.js b/packages/melonjs/src/loader/loader.js index d9c0061881..3a65274e80 100644 --- a/packages/melonjs/src/loader/loader.js +++ b/packages/melonjs/src/loader/loader.js @@ -11,6 +11,7 @@ import { getBasename } from "../utils/file.ts"; import { binList, fontList, + gltfList, imgList, jsonList, mtlList, @@ -21,6 +22,7 @@ import { import { preloadAseprite } from "./parsers/aseprite.js"; import { preloadBinary } from "./parsers/binary.js"; import { preloadFontFace } from "./parsers/fontface.js"; +import { preloadGLTF } from "./parsers/gltf.js"; import { preloadImage } from "./parsers/image.js"; import { preloadJSON } from "./parsers/json.js"; import { preloadMTL } from "./parsers/mtl.js"; @@ -226,6 +228,8 @@ function initParsers() { setParser("video", preloadVideo); setParser("obj", preloadOBJ); setParser("mtl", preloadMTL); + setParser("gltf", preloadGLTF); + setParser("glb", preloadGLTF); setParser("aseprite", preloadAseprite); parserInitialized = true; } @@ -635,6 +639,15 @@ export function unload(asset) { delete objList[asset.name]; return true; + case "gltf": + case "glb": + if (!(asset.name in gltfList)) { + return false; + } + + delete gltfList[asset.name]; + return true; + case "mtl": if (!(asset.name in mtlList)) { return false; @@ -751,6 +764,16 @@ export function unloadAll() { } } + // unload all glTF/GLB scene resources + for (name in gltfList) { + if (gltfList.hasOwnProperty(name)) { + unload({ + name: name, + type: "glb", + }); + } + } + // unload all audio resources audio.unloadAll(); } @@ -858,6 +881,60 @@ export function getOBJ(elt) { return null; } +/** + * a parsed glTF/GLB scene descriptor, as returned by {@link loader.getGLTF} + * @typedef {object} GLTFData + * @property {object[]} nodes - one entry per mesh primitive (accumulated `world` transform, `vertices`, `normals`, `uvs`, `indices`, `vertexCount`, decoded baseColor `image`, `doubleSided`) + * @property {object[]} cameras - glTF cameras, each with its `world` transform + perspective parameters + * @property {object[]} lights - parsed `KHR_lights_punctual` lights (`type`, `color`, `intensity`, `range`, world-space `direction`/`position`, `name`) + * @property {{min: number[], max: number[]}} bounds - world-space scene bounds in glTF units + */ + +/** + * return the parsed glTF/GLB scene descriptor for the given asset name. + * + * The descriptor is `{ nodes, cameras, lights, bounds }`: + * - `nodes` — one entry per mesh primitive, each carrying its accumulated + * `world` transform (16 floats, column-major), `vertices`, `normals`, + * `uvs`, `indices`, `vertexCount`, a decoded baseColor `image` (or `null`), + * and a `doubleSided` flag. + * - `cameras` — glTF cameras, each with its `world` transform + perspective + * parameters. + * - `lights` — parsed `KHR_lights_punctual` lights (`type`, `color`, + * `intensity`, world-space `direction`/`position`); empty without the + * extension. The level director instantiates directional ones automatically. + * - `bounds` — world-space `{ min, max }` (glTF units), handy for framing. + * + * Most code never needs this: a preloaded glTF/GLB auto-registers with the + * {@link level} director, so the whole scene loads into a container in one + * call via `me.level.load(name)` — exactly like a Tiled map. Reach for + * `getGLTF` only when you want to inspect the raw descriptor (e.g. to frame + * a `Camera3d` from the embedded camera). + * @memberof loader + * @param {string} elt - name of the glTF/GLB file (as specified in the preload list) + * @returns {GLTFData|null} the parsed scene descriptor, or `null` if not found + * @category Assets + * @example + * me.loader.preload( + * [{ name: "diorama", type: "glb", src: "scenes/diorama.glb" }], + * () => { + * // load the whole scene into the world (view under a Camera3d) + * me.level.load("diorama", { scale: 32 }); + * + * // ...or inspect the raw descriptor for custom framing + * const scene = me.loader.getGLTF("diorama"); + * const { min, max } = scene.bounds; + * }, + * ); + */ +export function getGLTF(elt) { + elt = "" + elt; + if (elt in gltfList) { + return gltfList[elt]; + } + return null; +} + /** * return the specified MTL material data * @memberof loader diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js new file mode 100644 index 0000000000..1793097a7e --- /dev/null +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -0,0 +1,554 @@ +import { level } from "../../level/level.js"; +import { transformedBounds } from "../../math/vertex.ts"; +import { gltfList } from "../cache.js"; +import { fetchData } from "./fetchdata.js"; + +/** + * glTF 2.0 (.gltf / .glb) scene loader — Tier 1. + * + * Parses a glTF asset into a flat list of mesh nodes with their world + * transforms, geometry (positions / uvs / indices), and decoded baseColor + * texture, ready to instantiate as melonJS {@link Mesh} renderables. + * + * Tier 1 scope: static node graph + mesh primitives (POSITION / TEXCOORD_0 + * / indices) + pbrMetallicRoughness.baseColorTexture (and baseColorFactor). + * Out of scope: skinning, animations, morph targets, full PBR maps, + * KHR extensions, Draco compression. + * @ignore + */ + +// glTF componentType -> TypedArray + DataView reader +const COMPONENT = { + 5120: { array: Int8Array, size: 1, get: "getInt8" }, + 5121: { array: Uint8Array, size: 1, get: "getUint8" }, + 5122: { array: Int16Array, size: 2, get: "getInt16" }, + 5123: { array: Uint16Array, size: 2, get: "getUint16" }, + 5125: { array: Uint32Array, size: 4, get: "getUint32" }, + 5126: { array: Float32Array, size: 4, get: "getFloat32" }, +}; +// glTF accessor type -> component count +const TYPE_COUNT = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT2: 4, + MAT3: 9, + MAT4: 16, +}; + +/** + * Split a GLB binary container into its JSON description and binary buffer. + * @param {ArrayBuffer} arrayBuffer + * @returns {{ json: object, bin: Uint8Array | null }} + * @ignore + */ +export function parseGLB(arrayBuffer) { + const dv = new DataView(arrayBuffer); + const magic = dv.getUint32(0, true); + // 0x46546C67 === "glTF" + if (magic !== 0x46546c67) { + // not a binary container — assume a JSON .gltf + const json = JSON.parse(new TextDecoder().decode(arrayBuffer)); + return { json, bin: null }; + } + const length = dv.getUint32(8, true); + let offset = 12; + let json = null; + let bin = null; + while (offset < length) { + const chunkLength = dv.getUint32(offset, true); + const chunkType = dv.getUint32(offset + 4, true); + const start = offset + 8; + if (chunkType === 0x4e4f534a) { + // "JSON" + json = JSON.parse( + new TextDecoder().decode( + new Uint8Array(arrayBuffer, start, chunkLength), + ), + ); + } else if (chunkType === 0x004e4942) { + // "BIN\0" + bin = new Uint8Array(arrayBuffer, start, chunkLength); + } + offset = start + chunkLength; + } + return { json, bin }; +} + +/** + * Resolve every glTF buffer to a Uint8Array (GLB bin chunk or data: URI). + * @ignore + */ +function resolveBuffers(json, bin) { + return (json.buffers || []).map((buffer) => { + if (buffer.uri === undefined) { + return bin; + } + if (buffer.uri.startsWith("data:")) { + const base64 = buffer.uri.slice(buffer.uri.indexOf(",") + 1); + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; + } + // external .bin not supported in Tier 1 + throw new Error( + `glTF: external buffer uri not supported ("${buffer.uri}")`, + ); + }); +} + +/** + * Read an accessor into a flat TypedArray (stride-aware, non-interleaved + * fast-path covered as a subset). + * @ignore + */ +export function readAccessor(json, buffers, accessorIndex) { + const accessor = json.accessors[accessorIndex]; + const comp = COMPONENT[accessor.componentType]; + const numComp = TYPE_COUNT[accessor.type]; + if (comp === undefined || numComp === undefined) { + throw new Error( + `glTF: unsupported accessor (componentType ${accessor.componentType}, type "${accessor.type}")`, + ); + } + // An accessor with no bufferView is sparse-only or zero-initialized (out of + // Tier 1 scope). Fail with a clear message rather than a cryptic + // "cannot read byteStride of undefined" further down. + if (accessor.bufferView === undefined) { + throw new Error( + `glTF: accessor ${accessorIndex} has no bufferView (sparse / zero-initialized accessors are not supported)`, + ); + } + const view = json.bufferViews[accessor.bufferView]; + const elementSize = comp.size * numComp; + const stride = view.byteStride || elementSize; + const bytes = buffers[view.buffer]; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const base = (view.byteOffset || 0) + (accessor.byteOffset || 0); + const TypedArrayCtor = comp.array; + const out = new TypedArrayCtor(accessor.count * numComp); + for (let i = 0; i < accessor.count; i++) { + const elementOffset = base + i * stride; + for (let c = 0; c < numComp; c++) { + out[i * numComp + c] = dv[comp.get](elementOffset + c * comp.size, true); + } + } + return out; +} + +/** + * 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) + * and `VEC4`, and the three glTF color encodings: float `0..1`, and normalized + * `UNSIGNED_BYTE` / `UNSIGNED_SHORT`. + * @ignore + */ +function readVertexColors(json, buffers, accessorIndex) { + const accessor = json.accessors[accessorIndex]; + const raw = readAccessor(json, buffers, accessorIndex); + const numComp = TYPE_COUNT[accessor.type]; // 3 (rgb) or 4 (rgba) + // normalize integer encodings to 0..1; float is already 0..1 + const div = + accessor.componentType === 5121 + ? 255 + : accessor.componentType === 5123 + ? 65535 + : 1; + const out = new Uint32Array(accessor.count); + for (let i = 0; i < accessor.count; i++) { + const o = i * numComp; + const r = Math.round((raw[o] / div) * 255); + const g = Math.round((raw[o + 1] / div) * 255); + const b = Math.round((raw[o + 2] / div) * 255); + const a = numComp === 4 ? Math.round((raw[o + 3] / div) * 255) : 255; + // ARGB packed (A<<24 | R<<16 | G<<8 | B), matching Color.toUint32 + out[i] = ((a << 24) | (r << 16) | (g << 8) | b) >>> 0; + } + return out; +} + +// ---- 4x4 column-major matrix helpers (glTF convention) ---- + +const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +/** Compose a node's local matrix from its `matrix` or TRS fields. @ignore */ +export function nodeLocalMatrix(node) { + if (node.matrix) { + return node.matrix.slice(); + } + const [tx, ty, tz] = node.translation || [0, 0, 0]; + const [qx, qy, qz, qw] = node.rotation || [0, 0, 0, 1]; + const [sx, sy, sz] = node.scale || [1, 1, 1]; + 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; + return [ + (1 - (yy + zz)) * sx, + (xy + wz) * sx, + (xz - wy) * sx, + 0, + (xy - wz) * sy, + (1 - (xx + zz)) * sy, + (yz + wx) * sy, + 0, + (xz + wy) * sz, + (yz - wx) * sz, + (1 - (xx + yy)) * sz, + 0, + tx, + ty, + tz, + 1, + ]; +} + +/** + * Synthesize per-vertex normals for a primitive that lacks a `NORMAL` + * accessor: accumulate each triangle's face normal onto its three vertices, + * then normalize. Shared vertices end up area-weighted (smooth); isolated + * faces stay flat. A degenerate (zero-length) result falls back to +Y. + * @param {Float32Array} positions - x,y,z triplets + * @param {Uint16Array|Uint32Array} indices - triangle indices + * @param {number} vertexCount + * @returns {Float32Array} x,y,z normals, one per vertex + * @ignore + */ +function computeFlatNormals(positions, indices, vertexCount) { + const normals = new Float32Array(vertexCount * 3); + for (let i = 0; i < indices.length; i += 3) { + const a = indices[i] * 3; + const b = indices[i + 1] * 3; + const c = indices[i + 2] * 3; + const e1x = positions[b] - positions[a]; + const e1y = positions[b + 1] - positions[a + 1]; + const e1z = positions[b + 2] - positions[a + 2]; + const e2x = positions[c] - positions[a]; + const e2y = positions[c + 1] - positions[a + 1]; + const e2z = positions[c + 2] - positions[a + 2]; + // face normal = e1 × e2 (unnormalized → area-weighted accumulation) + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + // accumulate the (area-weighted) face normal onto each of the 3 + // vertices — unrolled to avoid a per-triangle array allocation + normals[a] += nx; + normals[a + 1] += ny; + normals[a + 2] += nz; + normals[b] += nx; + normals[b + 1] += ny; + normals[b + 2] += nz; + normals[c] += nx; + normals[c + 1] += ny; + normals[c + 2] += nz; + } + for (let i = 0; i < normals.length; i += 3) { + const x = normals[i]; + const y = normals[i + 1]; + const z = normals[i + 2]; + const len = Math.hypot(x, y, z); + if (len > 1e-8) { + normals[i] = x / len; + normals[i + 1] = y / len; + normals[i + 2] = z / len; + } else { + normals[i] = 0; + normals[i + 1] = 1; + normals[i + 2] = 0; + } + } + return normals; +} + +/** Normalize a 3-component vector (returns +Y on a zero-length input). @ignore */ +function normalize3(v) { + const len = Math.hypot(v[0], v[1], v[2]); + return len > 1e-8 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 1, 0]; +} + +/** Column-major 4x4 multiply: a * b. @ignore */ +export function multiplyMatrix(a, b) { + const out = new Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + out[col * 4 + row] = + a[0 * 4 + row] * b[col * 4 + 0] + + a[1 * 4 + row] * b[col * 4 + 1] + + a[2 * 4 + row] * b[col * 4 + 2] + + a[3 * 4 + row] * b[col * 4 + 3]; + } + } + return out; +} + +/** + * Decode a glTF image (embedded bufferView or data URI) into an + * HTMLImageElement. + * @returns {Promise} + * @ignore + */ +function decodeImage(json, buffers, imageIndex) { + const image = json.images[imageIndex]; + let blob; + if (image.bufferView !== undefined) { + const view = json.bufferViews[image.bufferView]; + const bytes = buffers[view.buffer]; + const slice = bytes.subarray( + view.byteOffset || 0, + (view.byteOffset || 0) + view.byteLength, + ); + blob = new Blob([slice], { type: image.mimeType || "image/png" }); + } else if (image.uri && image.uri.startsWith("data:")) { + return loadImageFromUrl(image.uri); + } else { + return Promise.reject(new Error("glTF: unsupported image source")); + } + // `revoke: true` — the blob URL is a transient handle, only needed until + // the image has decoded; release it on load/error to avoid leaking it for + // the lifetime of the document. + return loadImageFromUrl(URL.createObjectURL(blob), true); +} + +/** @ignore */ +function loadImageFromUrl(url, revoke = false) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + if (revoke) { + URL.revokeObjectURL(url); + } + resolve(img); + }; + img.onerror = () => { + if (revoke) { + URL.revokeObjectURL(url); + } + reject(new Error("glTF: failed to decode image")); + }; + img.src = url; + }); +} + +/** + * Parse a glTF/GLB ArrayBuffer into a flat, instantiable scene descriptor. + * @param {ArrayBuffer} arrayBuffer + * @returns {Promise} `{ nodes, cameras, bounds }` + * @ignore + */ +export async function parseGLTF(arrayBuffer) { + const { json, bin } = parseGLB(arrayBuffer); + const buffers = resolveBuffers(json, bin); + + // decode every image once, keyed by image index + const images = await Promise.all( + (json.images || []).map((_, i) => { + return decodeImage(json, buffers, i); + }), + ); + + // resolve material index -> decoded baseColor image + const materialImage = (materialIndex) => { + if (materialIndex === undefined) { + return null; + } + const mat = json.materials[materialIndex]; + const tex = mat?.pbrMetallicRoughness?.baseColorTexture; + if (!tex) { + return null; + } + const imageIndex = json.textures[tex.index].source; + return images[imageIndex] || null; + }; + + // resolve material index -> baseColorFactor [r,g,b,a] in 0..1 (defaults to + // opaque white). A material with no baseColorTexture but a non-white factor + // is a solid-colored mesh — without this it would render as the white-pixel + // fallback. + const materialBaseColor = (materialIndex) => { + if (materialIndex === undefined) { + return [1, 1, 1, 1]; + } + return ( + json.materials[materialIndex]?.pbrMetallicRoughness?.baseColorFactor ?? [ + 1, 1, 1, 1, + ] + ); + }; + + // walk the active scene's node graph, accumulating world matrices. + // A malformed asset is not allowed to crash the loader: a missing scene + // or scene-node list degrades to an empty (but valid) descriptor rather + // than throwing on a null dereference. + const meshNodes = []; + const cameras = []; + const lights = []; + // the top-level KHR_lights_punctual light definitions (nodes reference these + // by index); empty when the extension isn't used. + const lightDefs = json.extensions?.KHR_lights_punctual?.lights ?? []; + const sceneIndex = json.scene ?? 0; + const roots = json.scenes?.[sceneIndex]?.nodes ?? []; + + // Guard against cyclic node graphs. Per the glTF spec the node hierarchy + // is a strict tree (each node has at most one parent), so a node visited + // twice means the file is malformed — skip it rather than recursing + // forever into a stack overflow. + const visited = new Set(); + + const visit = (nodeIndex, parentWorld) => { + if (visited.has(nodeIndex)) { + return; + } + visited.add(nodeIndex); + const node = json.nodes[nodeIndex]; + const world = multiplyMatrix(parentWorld, nodeLocalMatrix(node)); + if (node.mesh !== undefined) { + const primitives = json.meshes[node.mesh].primitives; + for (const prim of primitives) { + const vertices = readAccessor(json, buffers, prim.attributes.POSITION); + const uvs = + prim.attributes.TEXCOORD_0 !== undefined + ? readAccessor(json, buffers, prim.attributes.TEXCOORD_0) + : new Float32Array((vertices.length / 3) * 2); + const vertexCount = vertices.length / 3; + let indices; + if (prim.indices !== undefined) { + const raw = readAccessor(json, buffers, prim.indices); + indices = raw instanceof Uint32Array ? raw : Uint16Array.from(raw); + } else { + // non-indexed primitive (drawArrays-style): synthesize a + // sequential index buffer so the geometry is still drawable. + const Indexed = vertexCount > 65535 ? Uint32Array : Uint16Array; + indices = new Indexed(vertexCount); + for (let i = 0; i < vertexCount; i++) { + indices[i] = i; + } + } + // per-vertex normals for lit shading — read NORMAL when present, + // otherwise synthesize them from the geometry so a mesh without + // authored normals can still be lit. + const normals = + prim.attributes.NORMAL !== undefined + ? readAccessor(json, buffers, prim.attributes.NORMAL) + : computeFlatNormals(vertices, indices, vertexCount); + // optional per-vertex colors (COLOR_0) → packed ARGB Uint32, for + // untextured vertex-colored meshes (MagicaVoxel, vertex paint). + const colors = + prim.attributes.COLOR_0 !== undefined + ? readVertexColors(json, buffers, prim.attributes.COLOR_0) + : undefined; + meshNodes.push({ + name: node.name || `node_${nodeIndex}`, + world, + vertices, + normals, + uvs, + indices, + vertexCount, + image: materialImage(prim.material), + // baseColorFactor [r,g,b,a] — applied as the mesh tint so a + // solid-colored (untextured) material renders its color + baseColorFactor: materialBaseColor(prim.material), + // per-vertex colors (COLOR_0), packed ARGB, or undefined + colors, + // honor the glTF material's double-sided flag — many props + // (coins, fences, foliage) are thin/flat double-sided + // geometry that a single-sided back-face cull would gut + doubleSided: + prim.material !== undefined && + json.materials[prim.material]?.doubleSided === true, + }); + } + } + if (node.camera !== undefined) { + cameras.push({ ...json.cameras[node.camera], world }); + } + // KHR_lights_punctual: a node references a light defined in the + // top-level extension. Resolve its world-space direction (directional / + // spot lights point down the node's local -Z) and position (point / + // spot) from the node world matrix, so consumers get ready-to-use data. + const lightIndex = node.extensions?.KHR_lights_punctual?.light; + if (lightIndex !== undefined && lightDefs[lightIndex] !== undefined) { + const def = lightDefs[lightIndex]; + lights.push({ + type: def.type, // "directional" | "point" | "spot" + color: def.color ?? [1, 1, 1], + intensity: def.intensity ?? 1, + range: def.range, + // world -Z axis of the node (third basis column negated), normalized + direction: normalize3([-world[8], -world[9], -world[10]]), + // world translation + position: [world[12], world[13], world[14]], + name: def.name, + }); + } + for (const child of node.children || []) { + visit(child, world); + } + }; + for (const root of roots) { + visit(root, IDENTITY); + } + + // scene bounds (world space) for camera framing — accumulate each node's + // transformed vertices into one AABB + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (const n of meshNodes) { + transformedBounds(n.vertices, n.vertexCount, n.world, min, max); + } + // a scene with no drawable geometry leaves the bounds at their sentinel + // ±Infinity — collapse to a degenerate box at the origin so consumers + // (camera framing, etc.) get finite numbers instead of NaN. + if (meshNodes.length === 0) { + min[0] = min[1] = min[2] = 0; + max[0] = max[1] = max[2] = 0; + } + + return { nodes: meshNodes, cameras, lights, bounds: { min, max } }; +} + +/** + * parse/preload a glTF/GLB file + * @param {loader.Asset} data - asset data + * @param {Function} [onload] - function to be called when the resource is loaded + * @param {Function} [onerror] - function to be called in case of error + * @param {Object} [settings] - Additional settings to be passed when loading the asset + * @returns {number} the amount of corresponding resource parsed/preloaded + * @ignore + */ +export function preloadGLTF(data, onload, onerror, settings) { + if (typeof gltfList[data.name] !== "undefined") { + return 0; + } + fetchData(data.src, "arrayBuffer", settings) + .then((buffer) => { + return parseGLTF(buffer); + }) + .then((scene) => { + gltfList[data.name] = scene; + // register with the level director so the scene can be loaded + // into the world via `me.level.load(name)`, like a Tiled map + level.add(data.type, data.name); + if (typeof onload === "function") { + onload(); + } + }) + .catch((error) => { + if (typeof onerror === "function") { + onerror(error); + } + }); + return 1; +} diff --git a/packages/melonjs/src/loader/parsers/obj.js b/packages/melonjs/src/loader/parsers/obj.js index 3cabfa31c1..b6a5bd84fc 100644 --- a/packages/melonjs/src/loader/parsers/obj.js +++ b/packages/melonjs/src/loader/parsers/obj.js @@ -48,7 +48,7 @@ const OBJ_INDEX_OFFSET = 1; * `uvs` (Float32Array), `indices` (Uint16Array), `vertexCount` (number), * `mtllib` (string|null), and `groups` * (Array<{materialName: string|null, start: number, count: number}>). - * `groups` follows the Three.js / glTF convention — each entry is a + * `groups` follows the glTF convention — each entry is a * contiguous slice of the shared `indices` buffer that draws as one * submesh against a single material. Single-material models still * produce a `groups` array of length 1, so consumers don't need a @@ -118,7 +118,7 @@ function parseOBJ(text) { // any `usemtl` produce a single group spanning all indices with // `materialName: null` — consumers can treat that uniformly with // the multi-material path. Field name `materialName` matches the - // Three.js / glTF convention for "name of the material this + // glTF convention for "name of the material this // submesh wants to be drawn with"; renderers / mesh objects look // it up in their own material table. const groups = []; diff --git a/packages/melonjs/src/math/math.ts b/packages/melonjs/src/math/math.ts index ba5aad1041..26dbdecde8 100644 --- a/packages/melonjs/src/math/math.ts +++ b/packages/melonjs/src/math/math.ts @@ -215,8 +215,8 @@ export function lerp(a: number, b: number, t: number): number { * After total elapsed time `t_total` (in seconds), the result * satisfies * `result = current + (target - current) * (1 - exp(-lambda * t_total))` - * regardless of how `t_total` was split across `dt` calls. Same - * algorithm as `THREE.MathUtils.damp` in Three.js. + * regardless of how `t_total` was split across `dt` calls — the standard + * frame-rate-independent exponential `damp` formulation. * * `lambda` is the decay rate in `1/seconds` — higher = snappier * convergence. A rule of thumb: `lambda = 5` reaches ≈ 99% of the diff --git a/packages/melonjs/src/math/vertex.ts b/packages/melonjs/src/math/vertex.ts index b187667086..908c197443 100644 --- a/packages/melonjs/src/math/vertex.ts +++ b/packages/melonjs/src/math/vertex.ts @@ -137,6 +137,126 @@ export function projectVertices( } } +/** + * Extend an axis-aligned bounding box by a set of 3D vertices transformed + * through a 4x4 matrix (translation included). `min` / `max` are updated in + * place, so the function can be called repeatedly to accumulate the bounds of + * many transformed vertex sets (e.g. every node of a scene graph). Seed `min` + * with `+Infinity` and `max` with `-Infinity` before the first call. + * @param src - source vertex positions (x,y,z triplets) + * @param count - number of vertices to read from `src` + * @param matrix - 4x4 matrix values (column-major, 16 elements) + * @param min - `[x,y,z]` lower corner, extended in place + * @param max - `[x,y,z]` upper corner, extended in place + */ +export function transformedBounds( + src: Float32Array, + count: number, + matrix: ArrayLike, + min: number[], + max: number[], +) { + const m0 = matrix[0]; + const m1 = matrix[1]; + const m2 = matrix[2]; + const m4 = matrix[4]; + const m5 = matrix[5]; + const m6 = matrix[6]; + const m8 = matrix[8]; + const m9 = matrix[9]; + const m10 = matrix[10]; + const m12 = matrix[12]; + const m13 = matrix[13]; + const m14 = matrix[14]; + + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const vx = src[i3]; + const vy = src[i3 + 1]; + const vz = src[i3 + 2]; + + const wx = m0 * vx + m4 * vy + m8 * vz + m12; + const wy = m1 * vx + m5 * vy + m9 * vz + m13; + const wz = m2 * vx + m6 * vy + m10 * vz + m14; + + if (wx < min[0]) { + min[0] = wx; + } + if (wy < min[1]) { + min[1] = wy; + } + if (wz < min[2]) { + min[2] = wz; + } + if (wx > max[0]) { + max[0] = wx; + } + if (wy > max[1]) { + max[1] = wy; + } + if (wz > max[2]) { + max[2] = wz; + } + } +} + +/** + * Compute the bounding-sphere radius of a set of 3D vertices around the local + * origin — the distance to the farthest vertex. When a matrix is supplied, + * each vertex is taken through its rotation/scale columns (the translation is + * ignored, so the radius stays centered on the transform's own origin). Handy + * for frustum / culling bounds on a transformed mesh. + * @param src - source vertex positions (x,y,z triplets) + * @param count - number of vertices to read from `src` + * @param [matrix] - optional 4x4 matrix (column-major); only the upper 3x3 rotation/scale is applied + * @returns the radius of the smallest origin-centered sphere enclosing the vertices + */ +export function boundingRadius( + src: Float32Array, + count: number, + matrix?: ArrayLike, +): number { + let maxLenSq = 0; + if (matrix === undefined) { + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const vx = src[i3]; + const vy = src[i3 + 1]; + const vz = src[i3 + 2]; + const lenSq = vx * vx + vy * vy + vz * vz; + if (lenSq > maxLenSq) { + maxLenSq = lenSq; + } + } + return Math.sqrt(maxLenSq); + } + + const m0 = matrix[0]; + const m1 = matrix[1]; + const m2 = matrix[2]; + const m4 = matrix[4]; + const m5 = matrix[5]; + const m6 = matrix[6]; + const m8 = matrix[8]; + const m9 = matrix[9]; + const m10 = matrix[10]; + + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const vx = src[i3]; + const vy = src[i3 + 1]; + const vz = src[i3 + 2]; + const ox = m0 * vx + m4 * vy + m8 * vz; + const oy = m1 * vx + m5 * vy + m9 * vz; + const oz = m2 * vx + m6 * vy + m10 * vz; + const lenSq = ox * ox + oy * oy + oz * oz; + if (lenSq > maxLenSq) { + maxLenSq = lenSq; + } + } + return Math.sqrt(maxLenSq); +} + const _defaultNormal: XYPoint = { x: 0, y: 0 }; /** diff --git a/packages/melonjs/src/physics/broadphase/aabb3d.ts b/packages/melonjs/src/physics/broadphase/aabb3d.ts index 8122271d42..f9d91bd913 100644 --- a/packages/melonjs/src/physics/broadphase/aabb3d.ts +++ b/packages/melonjs/src/physics/broadphase/aabb3d.ts @@ -1,6 +1,17 @@ import { Vector3d } from "../../math/vector3d.ts"; +import { transformedBounds } from "../../math/vertex.ts"; import type { XYZPoint } from "../../utils/types.ts"; +// column-major 4×4 identity, used when `fromVertices` is called without a matrix +const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +// Module scratch for `fromVertices`. `transformedBounds` (the shared vertex.ts +// helper) writes into `[x, y, z]` number arrays, but AABB3d stores `{x, y, z}` +// objects — these bridge the two without per-call allocation. Reused across +// calls; never escapes the method. +const _fvMin = [Infinity, Infinity, Infinity]; +const _fvMax = [-Infinity, -Infinity, -Infinity]; + /** * A 3D axis-aligned bounding box — the 3D sibling of {@link Bounds}. * @@ -16,8 +27,10 @@ import type { XYZPoint } from "../../utils/types.ts"; * * Lives next to {@link Octree} under `physics/broadphase/` rather * than `math/` to mirror {@link Bounds} (which lives in `physics/`, - * not `math/`). Aabb is a primitive of the broadphase, not a generic - * math primitive — it never escapes a query result. + * not `math/`). It originated as a broadphase-internal primitive; it is + * now also the public 3D-bounds type returned by {@link Mesh#getBounds3d} + * (the 3D analog of {@link Renderable#getBounds} → {@link Bounds}), built + * from mesh geometry via {@link AABB3d#fromVertices}. * @category Geometry */ export class AABB3d { @@ -238,6 +251,40 @@ export class AABB3d { if (aabb.max.z > this.max.z) this.max.z = aabb.max.z; } + /** + * Build this AABB from a flat vertex buffer (`x,y,z` triplets), optionally + * transformed by a column-major 4×4 matrix — the bridge from raw mesh / + * glTF geometry (e.g. a {@link Mesh}'s vertices or a parsed glTF node) to an + * `AABB3d`. **Replaces** the current bounds (seeds empty, then folds in the + * `count` vertices); for the point-array form use {@link AABB3d#addPoint}. + * + * The per-vertex math is **delegated to {@link transformedBounds}** (the + * shared `math/vertex.ts` helper) rather than duplicated here, so the + * flat-buffer ↔ AABB conversion stays in one place. Allocation-free. + * @param src - source vertex positions (`x,y,z` triplets) + * @param count - number of vertices to read from `src` + * @param [matrix] - optional column-major 4×4 (16 elements); identity if omitted + * @returns this AABB, for chaining + * @example + * // bounds of a glTF node's geometry under its world transform + * const box = new AABB3d().fromVertices(node.vertices, node.vertexCount, node.world); + */ + fromVertices(src: Float32Array, count: number, matrix?: ArrayLike) { + // seed empty so the result is built purely from these vertices + _fvMin[0] = _fvMin[1] = _fvMin[2] = Infinity; + _fvMax[0] = _fvMax[1] = _fvMax[2] = -Infinity; + transformedBounds(src, count, matrix ?? IDENTITY, _fvMin, _fvMax); + this.setMinMax( + _fvMin[0], + _fvMin[1], + _fvMin[2], + _fvMax[0], + _fvMax[1], + _fvMax[2], + ); + return this; + } + /** * Returns a deep copy of this AABB. */ diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index cb9ce94f2f..585662ca62 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -10,6 +10,7 @@ import { normalizeVertices, projectVertices, } from "../math/vertex.ts"; +import { AABB3d } from "../physics/broadphase/aabb3d.ts"; import Renderer from "./../video/renderer.js"; import { TextureAtlas } from "./../video/texture/atlas.js"; import Renderable from "./renderable.js"; @@ -85,6 +86,17 @@ function resolveGroupMaterial(group, materials) { * Includes a built-in perspective projection and supports 3D transforms * through the standard Renderable API (`rotate`, `scale`, `translate`). * Works on both WebGL (hardware depth testing) and Canvas (painter's algorithm) renderers. + * + * **Pivot — transforms are applied about the mesh's local origin `(0, 0, 0)`, + * NOT a normalized anchor point.** Unlike a {@link Sprite} (whose + * {@link Renderable#anchorPoint} recenters a `width`×`height` box), a mesh has + * real vertex coordinates, so rotation and scale pivot around the model origin + * the geometry is authored against — the standard convention for 3D meshes. + * Place the origin where you want the pivot at authoring time (or nest the + * mesh under a transformed parent to pivot elsewhere). Consequently, on the + * `Camera3d` world-space path the mesh opts out of the anchor offset entirely + * (see {@link Renderable#applyAnchorTransform}); the legacy 2D path still + * honors `anchorPoint` for backward compatibility. * @category Game Objects */ export default class Mesh extends Renderable { @@ -98,9 +110,12 @@ export default class Mesh extends Renderable { * @param {Uint16Array|number[]} [settings.indices] - triangle vertex indices (alternative to settings.model) * @param {HTMLImageElement|TextureAtlas|string} [settings.texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. * @param {string} [settings.material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. - * @param {number} settings.width - display width in pixels (the 3D model is normalized and scaled to fit this size) - * @param {number} settings.height - display height in pixels (the 3D model is normalized and scaled to fit this size) + * @param {number} settings.width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry. + * @param {number} [settings.height] - display height in pixels (normalized models only; ignored when `normalize: false`) * @param {boolean} [settings.cullBackFaces=true] - enable backface culling + * @param {boolean} [settings.normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved. + * @param {number} [settings.scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}. + * @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { @@ -118,6 +133,18 @@ export default class Mesh extends Renderable { * height: 200, * }); * + * // create from raw geometry that already carries its own world scale + * // (e.g. a glTF scene node) — keep real coordinates, no mirror + * let node = new me.Mesh(0, 0, { + * vertices: positions, // Float32Array of x,y,z triplets + * uvs: texcoords, // Float32Array of u,v pairs + * indices: tris, // Uint16Array of triangle indices + * texture: baseColorImage, + * width: 32, // pixels per unit + * normalize: false, + * rightHanded: true, + * }); + * * // 3D rotation using the standard rotate() API * mesh.rotate(Math.PI / 4, new me.Vector3d(0, 1, 0)); // rotate around Y axis * @@ -171,8 +198,16 @@ export default class Mesh extends Renderable { settings.uvs instanceof Float32Array ? settings.uvs : new Float32Array(settings.uvs); + // Preserve a typed index buffer as-is — coercing a Uint32Array to + // Uint16Array would truncate index values > 65535, silently + // corrupting meshes with more than 65535 vertices (the glTF parser + // emits Uint32 indices for exactly that case). Only a plain JS + // array is materialized, as Uint16 (the small-mesh default). The + // batcher chunks large meshes into ≤maxVertices flushes, so its own + // index buffer never needs more than 16 bits regardless. this.indices = - settings.indices instanceof Uint16Array + settings.indices instanceof Uint16Array || + settings.indices instanceof Uint32Array ? settings.indices : new Uint16Array(settings.indices); this.vertexCount = this.originalVertices.length / 3; @@ -192,6 +227,39 @@ export default class Mesh extends Renderable { */ this.vertices = new Float32Array(this.vertexCount * 3); + /** + * the source per-vertex normals (x,y,z triplets), or `undefined` if the + * mesh was built without them. Supplied by the glTF loader; used for + * lit shading under a `Camera3d` (see {@link LightingEnvironment}). + * @type {Float32Array|undefined} + */ + this.originalNormals = + settings.normals !== undefined + ? settings.normals instanceof Float32Array + ? settings.normals + : new Float32Array(settings.normals) + : undefined; + + /** + * world-space normals for the current draw, recomputed from + * {@link Mesh#originalNormals} along the Camera3d path. Empty (zero) when + * the mesh has no source normals — the shader then ignores lighting. + * @type {Float32Array} + */ + this.normals = new Float32Array(this.vertexCount * 3); + + /** + * Whether this mesh is lit by the active {@link LightingEnvironment}. + * When `true` it renders through the lit mesh batcher (diffuse shading + * from the scene's lights, using {@link Mesh#originalNormals}); when + * `false` (the default) it uses the lean unlit path and pays no lighting + * cost. The glTF loader sets this on scene meshes when the scene has + * lights. Only meaningful under a `Camera3d` + WebGL. + * @type {boolean} + * @default false + */ + this.lit = settings.lit === true; + /** * whether to cull back-facing triangles * @type {boolean} @@ -200,6 +268,32 @@ export default class Mesh extends Renderable { this.cullBackFaces = settings.cullBackFaces !== undefined ? settings.cullBackFaces : true; + /** + * Treat the source geometry as right-handed (Y-up, e.g. glTF) under + * the Camera3d world path. The default (`false`) Y-up→Y-down bridge + * negates Y only — a reflection, which mirrors the scene left/right. + * When `true`, the bridge negates Y **and** Z (a 180° rotation about + * X, determinant +1) so chirality is preserved and the result matches + * the authoring tool (no mirror); triangle winding is left untouched + * since a rotation doesn't invert it. + * @type {boolean} + * @default false + */ + this.rightHanded = settings.rightHanded === true; + + /** + * Uniform world-space scale (pixels per source unit) applied along the + * Camera3d world path. Defaults to `width`. Scene loaders (e.g. glTF) + * set this independently of `width` / `height` so those can describe + * the renderable's world-space bounds (used for frustum culling) while + * the geometry is still scaled by this factor — `width` alone can't + * serve both roles for a non-normalized scene mesh. + * @type {number} + * @default settings.width + */ + this.meshScale = + typeof settings.scale === "number" ? settings.scale : this.width; + // resolve material (MTL) — applies texture, tint, and opacity. // Two paths: // - Single-material: pick the first MTL entry, apply to the whole @@ -343,8 +437,13 @@ export default class Mesh extends Renderable { this.projectionMatrix.translate(0, 0, -2.5); // normalize original vertices to fit within [-0.5, 0.5] range - // so that width/height scaling works like Sprite - normalizeVertices(this.originalVertices); + // so that width/height scaling works like Sprite. Scene meshes + // (e.g. a glTF node that already carries its own world transform + // and shares a coordinate space with sibling meshes) opt out via + // `normalize: false` to preserve real-world scale across the scene. + if (settings.normalize !== false) { + normalizeVertices(this.originalVertices); + } this.anchorPoint.set(0.5, 0.5); @@ -393,8 +492,9 @@ export default class Mesh extends Renderable { * view + projection matrices, the same way it does for Sprites. * * Per-vertex math: `currentTransform × originalVertex`, then a uniform - * scale by `this.width`, a Y-flip (OBJ Y-up → engine Y-down), and a - * translate to `(offsetX, offsetY, offsetZ)`. + * scale by `this.width`, the Y-up→Y-down bridge (negate Y, and also Z when + * {@link Mesh#rightHanded} is set so the bridge is a rotation rather than a + * mirror), and a translate to `(offsetX, offsetY, offsetZ)`. * * @param {number} offsetX - world X to place the mesh center at * @param {number} offsetY - world Y to place the mesh center at @@ -404,7 +504,7 @@ export default class Mesh extends Renderable { _projectVerticesWorld(offsetX, offsetY, offsetZ) { const out = this.vertices; const src = this.originalVertices; - const scale = this.width; + const scale = this.meshScale; const m = this.currentTransform.val; // Include the translation column (m[12..14]) — a proper // matrix-vector multiplication treats the vertex as @@ -416,6 +516,10 @@ export default class Mesh extends Renderable { const tx = m[12]; const ty = m[13]; const tz = m[14]; + // right-handed source (glTF): negate Z as well as Y so the Y-up→ + // Y-down bridge is a rotation (det +1, no mirror) rather than a + // reflection. Left-handed default keeps Z as-is (legacy reflection). + const zSign = this.rightHanded ? -1 : 1; for (let i = 0; i < this.vertexCount; i++) { const i3 = i * 3; @@ -429,7 +533,52 @@ export default class Mesh extends Renderable { out[i3] = rx * scale + offsetX; out[i3 + 1] = -ry * scale + offsetY; - out[i3 + 2] = rz * scale + offsetZ; + out[i3 + 2] = zSign * rz * scale + offsetZ; + } + } + + /** + * Project {@link Mesh#originalNormals} into world space for lit shading, + * storing the result in {@link Mesh#normals}. Applies the rotation/scale of + * {@link Renderable#currentTransform} (no translation), the same Y-down / + * `rightHanded` Y/Z bridge as {@link Mesh#_projectVerticesWorld}, then + * renormalizes. No-op when the mesh has no source normals. + * + * Note: uses the transform's rotation/scale columns directly rather than + * the inverse-transpose, so normals are exact under rotation + uniform + * scale (the common case); a strongly non-uniform scale would skew them + * slightly — acceptable for the diffuse lighting path. + * @ignore + */ + _projectNormalsWorld() { + const src = this.originalNormals; + if (src === undefined) { + return; + } + const out = this.normals; + const m = this.currentTransform.val; + const zSign = this.rightHanded ? -1 : 1; + for (let i = 0; i < this.vertexCount; i++) { + const i3 = i * 3; + const nx = src[i3]; + const ny = src[i3 + 1]; + const nz = src[i3 + 2]; + const rx = m[0] * nx + m[4] * ny + m[8] * nz; + const ry = -(m[1] * nx + m[5] * ny + m[9] * nz); // Y-down flip + const rz = zSign * (m[2] * nx + m[6] * ny + m[10] * nz); + const len = Math.hypot(rx, ry, rz); + if (len > 1e-8) { + out[i3] = rx / len; + out[i3 + 1] = ry / len; + out[i3 + 2] = rz / len; + } else { + // degenerate normal → fall back to +Y (a unit vector) rather + // than zero: the shader does `normalize(vNormal)`, and + // normalize((0,0,0)) is NaN (black/garbage fragment). + out[i3] = 0; + out[i3 + 1] = 1; + out[i3 + 2] = 0; + } } } @@ -453,14 +602,25 @@ export default class Mesh extends Renderable { * @ignore */ _setupWorldSpace() { - const src = this._indicesOriginal; - const dst = new Uint16Array(src.length); - for (let i = 0; i < src.length; i += 3) { - dst[i] = src[i]; - dst[i + 1] = src[i + 2]; - dst[i + 2] = src[i + 1]; + // Only the reflection bridge (left-handed, Y-only negate) inverts + // winding and needs the reversed copy. `rightHanded` meshes (all glTF + // scenes) use a rotation bridge that preserves winding, so they keep + // `_indicesOriginal` and never read the reversed buffer — skip the + // allocation entirely for them. + if (this.rightHanded !== true) { + const src = this._indicesOriginal; + // match the source index type (Uint16Array OR Uint32Array): a copy + // into a hard-coded Uint16Array would truncate indices for meshes + // with > 65535 vertices. + const Ctor = src.constructor; + const dst = new Ctor(src.length); + for (let i = 0; i < src.length; i += 3) { + dst[i] = src[i]; + dst[i + 1] = src[i + 2]; + dst[i + 2] = src[i + 1]; + } + this._indicesReversed = dst; } - this._indicesReversed = dst; this._worldSpace = true; } @@ -484,6 +644,48 @@ export default class Mesh extends Renderable { this._useWorldSpace = game.viewport instanceof Camera3d; } + /** + * The mesh's world-space 3D axis-aligned bounding box, as projected by the + * most recent draw. This is the 3D analog of {@link Renderable#getBounds} + * (which returns a flat 2D box from `width`/`height` and so cannot describe + * a mesh's real extent). + * + * Under a `Camera3d` the mesh projects its vertices into world space every + * frame (see {@link Mesh#_projectVerticesWorld}), so the returned box tracks + * the live transform / animation. Under the 2D path the projected vertices + * are screen-space, so the box is only meaningful after a Camera3d draw — + * use {@link Renderable#getBounds} for the 2D case. + * + * The same {@link AABB3d} instance is returned each call (recomputed in + * place), so copy it (`.clone()`) if you need to keep it. + * @returns {AABB3d} the world-space bounding box (reused instance) + */ + getBounds3d() { + if (this._bounds3d === undefined) { + /** @ignore */ + this._bounds3d = new AABB3d(); + } + // `this.vertices` holds the last draw's world-space positions under + // Camera3d — bound them directly (identity matrix). Delegates the + // min/max sweep to AABB3d.fromVertices → transformedBounds. + return this._bounds3d.fromVertices(this.vertices, this.vertexCount); + } + + /** + * Prepare the renderer for drawing the mesh. On the `Camera3d` world-space + * path the mesh emits final world coordinates, so it opts out of the base + * anchor-point offset ({@link Renderable#applyAnchorTransform} = `false`) — + * otherwise the offset would leak into the shared mesh view matrix. The 2D + * path keeps the anchor. See the class description for the pivot rationale. + * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance + */ + preDraw(renderer) { + // world-space meshes pivot about their own origin, not a bounds-box + // anchor (see Mesh class doc + Renderable#applyAnchorTransform) + this.applyAnchorTransform = this._useWorldSpace !== true; + super.preDraw(renderer); + } + /** * Draw the mesh (automatically called by melonJS). Picks between two * projection paths based on the camera that was active when this mesh @@ -529,11 +731,26 @@ export default class Mesh extends Renderable { if (this._worldSpace !== true) { this._setupWorldSpace(); } - // Camera3d path: use the winding-reversed indices to keep - // `cullBackFaces: true` correct under the Y-flip in - // `_projectVerticesWorld`. - this.indices = this._indicesReversed; + // Camera3d path. The reflection bridge (Y-only negate) inverts + // winding, so it needs the reversed indices to keep + // `cullBackFaces: true` correct. The rotation bridge + // (`rightHanded`: Y+Z negate) preserves winding, so the + // original indices are already correct. + this.indices = this.rightHanded + ? this._indicesOriginal + : this._indicesReversed; + // Emit FINAL world coordinates: the node origin is baked into + // pos/depth and the geometry carries its own extent. The anchor + // offset is suppressed for this path via `applyAnchorTransform` + // (set in `preDraw`), so it does not leak into the view matrix. this._projectVerticesWorld(this.pos.x, this.pos.y, this.depth); + // world-space normals — only when this mesh is lit (the unlit batcher + // never reads `normals`, so projecting them otherwise is wasted + // per-frame work; glTF meshes carry normals even when the scene has + // no lights). No-op without source normals. + if (this.lit === true) { + this._projectNormalsWorld(); + } } else { // Camera2d / no-camera path: restore the original winding. // Important when the same Mesh instance was previously drawn diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 3beb9658df..6c18f38b03 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -223,6 +223,29 @@ export default class Renderable extends Rect { */ this.autoTransform = true; + /** + * Whether {@link Renderable#preDraw} applies the + * {@link Renderable#anchorPoint} offset to the renderer transform. + * + * When `true` (the default), the renderable is shifted by + * `-anchorPoint × (width, height)` so its anchor — not its top-left + * corner — aligns with its position. Correct for sprites and other 2D + * renderables. + * + * Set to `false` for a renderable that emits its own final world + * coordinates and takes its origin from geometry rather than a bounds + * box — e.g. a {@link Mesh} on the `Camera3d` world-space path (a 3D + * mesh is positioned by its transform and has no anchor). The WebGL mesh + * batcher reuses this same renderer transform as its **view matrix**, + * so applying the normalized anchor there would shift every mesh by + * half its OWN bounds box; because scene meshes size that box per node, + * props and the platforms they rest on would drift apart and overlap. + * @type {boolean} + * @default true + * @see Mesh#preDraw + */ + this.applyAnchorTransform = true; + /** * Define the renderable opacity
* Set to zero if you do not wish an object to be drawn @@ -859,8 +882,13 @@ export default class Renderable extends Rect { renderer.translate(-this.pos.x, -this.pos.y); } - // offset by the anchor point - renderer.translate(-ax, -ay); + // offset by the anchor point — unless this renderable manages its own + // world placement (see Renderable#applyAnchorTransform), in which case + // the normalized anchor offset must not leak into the renderer/view + // transform. + if (this.applyAnchorTransform !== false) { + renderer.translate(-ax, -ay); + } // apply the current tint and opacity renderer.setTint(this.tint, this.getOpacity()); diff --git a/packages/melonjs/src/video/buffer/vertex.js b/packages/melonjs/src/video/buffer/vertex.js index daf2eb983e..088d9d1873 100644 --- a/packages/melonjs/src/video/buffer/vertex.js +++ b/packages/melonjs/src/video/buffer/vertex.js @@ -135,6 +135,34 @@ export default class VertexArrayBuffer { return this; } + /** + * push a new lit-mesh vertex (mesh format + a world-space normal): + * x, y, z, u, v, tint, nx, ny, nz. Used by the lit mesh batcher, whose + * vertex layout is 12 floats (the 9 of {@link pushMesh} plus `aNormal`). + * @ignore + */ + pushMeshLit(x, y, z, u, v, tint, nx, ny, nz) { + const offset = this.vertexCount * this.vertexSize; + + this.bufferF32[offset] = x; + this.bufferF32[offset + 1] = y; + this.bufferF32[offset + 2] = z; + this.bufferF32[offset + 3] = u; + this.bufferF32[offset + 4] = v; + this.bufferF32[offset + 5] = ((tint >> 16) & 0xff) / 255; // R + this.bufferF32[offset + 6] = ((tint >> 8) & 0xff) / 255; // G + this.bufferF32[offset + 7] = (tint & 0xff) / 255; // B + this.bufferF32[offset + 8] = ((tint >>> 24) & 0xff) / 255; // A + // world-space normal (aNormal) + this.bufferF32[offset + 9] = nx; + this.bufferF32[offset + 10] = ny; + this.bufferF32[offset + 11] = nz; + + this.vertexCount++; + + return this; + } + /** * return a reference to the data in Float32 format * @ignore diff --git a/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js new file mode 100644 index 0000000000..307482cfc8 --- /dev/null +++ b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js @@ -0,0 +1,88 @@ +import { LightingEnvironment } from "../../../lighting/lighting_environment.ts"; +import { MAX_LIGHTS } from "../lighting/constants.ts"; +import litFragment from "./../shaders/mesh-lit.frag"; +import litVertex from "./../shaders/mesh-lit.vert"; +import MeshBatcher from "./mesh_batcher.js"; + +// resolve the lit fragment shader's light-array size from the single source of +// truth (MAX_LIGHTS) so the GLSL array can't diverge from the uniform packer. +// replaceAll: the token appears at every use site (the GLSL preprocessor may +// have already expanded any macro, leaving multiple occurrences). +const litFragmentResolved = litFragment.replaceAll( + "__MAX_LIGHTS__", + String(MAX_LIGHTS), +); + +// ambient used when a lit mesh is drawn with no active lights — render it +// fullbright (white ambient) rather than dark, so a `lit` mesh without a +// populated LightingEnvironment still looks like the unlit path. +const _WHITE_AMBIENT = new Float32Array([1, 1, 1]); + +/** + * A {@link MeshBatcher} variant that shades meshes with the active + * {@link LightingEnvironment} (half-Lambert diffuse from directional lights + + * ambient). It extends the unlit batcher, adding a world-space `aNormal` + * vertex attribute (12-float layout vs 9) and a lit shader. + * + * Meshes opt in via `mesh.lit` — so standalone, unlit meshes keep the lean + * 9-float `MeshBatcher` and pay nothing for lighting. Both batchers share the + * mesh-mode depth-clear state (a single clear per target) since this one + * inherits {@link MeshBatcher#bind}. + * @category Rendering + */ +export default class LitMeshBatcher extends MeshBatcher { + /** add the world-space normal attribute on top of the base layout. @ignore */ + _attributeLayout(renderer) { + const attributes = super._attributeLayout(renderer); + attributes.push({ + // aNormal: world-space vertex normal, pushed WITHOUT the view + // transform so lighting is evaluated in world space. + name: "aNormal", + size: 3, + type: renderer.gl.FLOAT, + normalized: false, + offset: 9 * Float32Array.BYTES_PER_ELEMENT, + }); + return attributes; + } + + /** use the lit (half-Lambert) shader. @ignore */ + _shaderSources() { + return { vertex: litVertex, 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; + vertexData.pushMeshLit( + x, + y, + z, + u, + v, + color, + n ? n[i3] : 0, + n ? n[i3 + 1] : 0, + n ? n[i3 + 2] : 0, + ); + } + + /** + * Enter the mesh-mode pass (depth state via the inherited base) and upload + * the active lighting environment to the lit shader. With no lights, a + * white ambient keeps the mesh fullbright. + */ + bind() { + super.bind(); + const lit = LightingEnvironment.default.pack(); + const shader = this.currentShader; + shader.setUniform("uLightCount", lit.count); + if (lit.count > 0) { + shader.setUniform("uLightDir", lit.directions); + shader.setUniform("uLightColor", lit.colors); + shader.setUniform("uAmbient", lit.ambient); + } else { + shader.setUniform("uAmbient", _WHITE_AMBIENT); + } + } +} diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 05f73cf4f6..e5cb404e3e 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -10,6 +10,22 @@ import { MaterialBatcher } from "./material_batcher.js"; // identity, so output (x, y) matches the legacy Vector2d path. const _v = new Vector3d(); +// Reused scratch for addMesh's per-chunk vertex dedup (`_remap`) and absolute +// index list (`_chunkIndices`), so a chunk doesn't allocate a fresh Map + +// array per mesh per frame (GC pressure on the draw path). Safe because +// addMesh runs synchronously and never re-enters (flush() only draws). Shared +// by MeshBatcher and LitMeshBatcher — only one addMesh runs at a time. +const _remap = new Map(); +const _chunkIndices = []; + +// Shared lazy-depth-clear state for the mesh-mode pass. Module-level (not +// per-instance) so the unlit `MeshBatcher` and the `LitMeshBatcher` — which +// extends it and inherits `bind()` — coordinate on a SINGLE depth clear per +// target. If each kept its own flag, switching between the two mid-frame would +// re-clear the shared depth buffer and break inter-mesh occlusion. The first +// `bind()` of either clears + marks clean; `RENDER_TARGET_CHANGED` re-arms it. +let _meshDepthDirty = true; + /** * Per-channel multiply two ARGB-packed Uint32 colors. Used by the * multi-material mesh path to combine a vertex's baked material color @@ -69,77 +85,74 @@ export default class MeshBatcher extends MaterialBatcher { */ init(renderer) { super.init(renderer, { - attributes: [ - { - name: "aVertex", - size: 3, - type: renderer.gl.FLOAT, - normalized: false, - offset: 0 * Float32Array.BYTES_PER_ELEMENT, - }, - { - name: "aRegion", - size: 2, - type: renderer.gl.FLOAT, - normalized: false, - offset: 3 * Float32Array.BYTES_PER_ELEMENT, - }, - { - // aColor: 4 normalized floats (R, G, B, A in [0, 1]) - // rather than packed 4×UNSIGNED_BYTE. The byte-packed - // path is byte-identical in memory but exposes the - // 4-byte slot to NaN-pattern bit values when the - // alpha byte is 0xFF and the red byte has its high - // bit set (R≥0x80) — a NaN-pattern that Apple's - // Metal-backed WebGL driver canonicalizes on some - // upload paths, zeroing the bytes the shader actually - // reads. The float path uses values in [0, 1] which - // never form NaN bit patterns; trades 12 bytes of - // vertex memory (4 vs 16 per color) for guaranteed - // driver-safety. Mesh vertex counts are typically - // modest (sub-thousand per Mesh) so the bandwidth - // hit is invisible. - name: "aColor", - size: 4, - type: renderer.gl.FLOAT, - normalized: false, - offset: 5 * Float32Array.BYTES_PER_ELEMENT, - }, - ], - shader: { - vertex: meshVertex, - fragment: meshFragment, - }, + attributes: this._attributeLayout(renderer), + shader: this._shaderSources(), indexed: true, }); - /** - * Tracks whether the active framebuffer's depth attachment still - * needs a clear before this batcher's next draw. Flipped to - * `true` by the `RENDER_TARGET_CHANGED` listener installed - * below (frame start, post-effect FBO bind/unbind), back to - * `false` by the first `bind()` of the new target after the - * lazy clear runs. Lifts depth clearing from per-mesh (legacy) - * to per-target — same model Three.js uses. The GPU's `LEQUAL` - * depth test then resolves inter-mesh occlusion per pixel - * against the accumulated buffer. - * @ignore - */ - this._depthDirty = true; - // Subscribe to the renderer's target-changed broadcast so we - // re-arm the lazy depth clear whenever the active framebuffer's - // attachments change identity (FBO bind/unbind for post-effects, - // frame-start `clear()`). Same pattern as `MaterialBatcher`'s - // `GPU_TEXTURE_CACHE_RESET` subscription — only batchers that - // care subscribe, so non-mesh batchers pay nothing for this. + // Subscribe to the renderer's target-changed broadcast so we re-arm the + // shared lazy depth clear (`_meshDepthDirty`) whenever the active + // framebuffer's attachments change identity (FBO bind/unbind for + // post-effects, frame-start `clear()`). Same pattern as + // `MaterialBatcher`'s `GPU_TEXTURE_CACHE_RESET` subscription — only + // batchers that care subscribe. if (!this._onTargetChanged) { this._onTargetChanged = () => { - this._depthDirty = true; + _meshDepthDirty = true; }; on(RENDER_TARGET_CHANGED, this._onTargetChanged); } } + /** + * The vertex attribute layout. The base (unlit) mesh batcher is + * `aVertex` (3) + `aRegion` (2) + `aColor` (4) = 9 floats. Subclasses + * (e.g. {@link LitMeshBatcher}) append their own attributes. + * @ignore + */ + _attributeLayout(renderer) { + return [ + { + name: "aVertex", + size: 3, + type: renderer.gl.FLOAT, + normalized: false, + offset: 0 * Float32Array.BYTES_PER_ELEMENT, + }, + { + name: "aRegion", + size: 2, + type: renderer.gl.FLOAT, + normalized: false, + offset: 3 * Float32Array.BYTES_PER_ELEMENT, + }, + { + // aColor: 4 normalized floats (R, G, B, A in [0, 1]) rather + // than packed 4×UNSIGNED_BYTE. The byte-packed path is + // byte-identical in memory but exposes the 4-byte slot to + // NaN-pattern bit values when the alpha byte is 0xFF and the + // red byte has its high bit set (R≥0x80) — a NaN-pattern that + // Apple's Metal-backed WebGL driver canonicalizes on some + // upload paths, zeroing the bytes the shader reads. The float + // path uses values in [0, 1] which never form NaN bit patterns. + name: "aColor", + size: 4, + type: renderer.gl.FLOAT, + normalized: false, + offset: 5 * Float32Array.BYTES_PER_ELEMENT, + }, + ]; + } + + /** + * The shader sources for this batcher (unlit by default). Subclasses + * override to supply a lit shader. + * @ignore + */ + _shaderSources() { + return { vertex: meshVertex, fragment: meshFragment }; + } + /** * Unsubscribe the `RENDER_TARGET_CHANGED` listener so a discarded * batcher doesn't keep getting notified (relevant on context loss / @@ -176,10 +189,10 @@ export default class MeshBatcher extends MaterialBatcher { gl.depthFunc(gl.LEQUAL); gl.depthMask(true); gl.disable(gl.BLEND); - if (this._depthDirty) { + if (_meshDepthDirty) { gl.clearDepth(1.0); gl.clear(gl.DEPTH_BUFFER_BIT); - this._depthDirty = false; + _meshDepthDirty = false; } } @@ -195,6 +208,26 @@ export default class MeshBatcher extends MaterialBatcher { gl.depthMask(false); } + /** + * Write one vertex into the buffer. The base (unlit) layout is + * `x, y, z, u, v, color`. Subclasses override to append per-vertex data + * matching their {@link MeshBatcher#_attributeLayout} (e.g. + * {@link LitMeshBatcher} pushes the world-space normal too). + * @param {object} vertexData - the batcher's vertex buffer + * @param {number} x + * @param {number} y + * @param {number} z + * @param {number} u + * @param {number} v + * @param {number} color - packed ARGB Uint32 + * @param {object} _mesh - the source mesh (unused here; for subclasses) + * @param {number} _i3 - the source vertex's `index * 3` (for subclasses) + * @ignore + */ + _pushVertex(vertexData, x, y, z, u, v, color, _mesh, _i3) { + vertexData.pushMesh(x, y, z, u, v, color); + } + /** * Add a textured mesh to the batch. When the mesh has a * `vertexColors` array (multi-material OBJ + bound MTL), each @@ -248,19 +281,19 @@ export default class MeshBatcher extends MaterialBatcher { const endIdx = Math.min(triIdx + maxTris * 3, indices.length); - // build a local vertex remap for this chunk + // build a local vertex remap for this chunk (reused scratch) // capture base offset before pushing any vertices const baseOffset = vertexData.vertexCount; - const remap = new Map(); - const chunkIndices = []; + _remap.clear(); + _chunkIndices.length = 0; let localCount = 0; for (let j = triIdx; j < endIdx; j++) { const origIdx = indices[j]; - let localIdx = remap.get(origIdx); + let localIdx = _remap.get(origIdx); if (localIdx === undefined) { localIdx = localCount++; - remap.set(origIdx, localIdx); + _remap.set(origIdx, localIdx); const i3 = origIdx * 3; const i2 = origIdx * 2; @@ -285,14 +318,27 @@ export default class MeshBatcher extends MaterialBatcher { const vertColor = vertexColors ? mulPackedARGB(vertexColors[origIdx], tint) : tint; - vertexData.pushMesh(x, y, z, uvs[i2], uvs[i2 + 1], vertColor); + // delegate the actual write so subclasses can add per-vertex + // data (e.g. LitMeshBatcher appends the world-space normal). + this._pushVertex( + vertexData, + x, + y, + z, + uvs[i2], + uvs[i2 + 1], + vertColor, + mesh, + i3, + ); } // absolute index = baseOffset + localIdx - chunkIndices.push(baseOffset + localIdx); + _chunkIndices.push(baseOffset + localIdx); } - // add raw indices (already absolute, bypass rebasing) - this.indexBuffer.addRaw(chunkIndices); + // add raw indices (already absolute, bypass rebasing) — addRaw + // copies the values, so reusing `_chunkIndices` next chunk is safe + this.indexBuffer.addRaw(_chunkIndices); triIdx = endIdx; } } diff --git a/packages/melonjs/src/video/webgl/effects/shine.js b/packages/melonjs/src/video/webgl/effects/shine.js index 75acf3d761..81de3fe328 100644 --- a/packages/melonjs/src/video/webgl/effects/shine.js +++ b/packages/melonjs/src/video/webgl/effects/shine.js @@ -3,7 +3,7 @@ import ShaderEffect from "../shadereffect.js"; /** * A shader effect that sweeps a bright highlight band across the sprite — * the classic "shine" pass commonly used for coins, gems, polished metal, - * and hover-highlighted UI elements. Similar to pixi-filters' ShineFilter. + * and hover-highlighted UI elements. * * Set `bands` > 1 to tile the sweep into N parallel glints (useful for the * "etched grooves" look of a coin's rim). An optional subtle brightness diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag new file mode 100644 index 0000000000..543629d36a --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -0,0 +1,36 @@ +// Lit mesh fragment shader: half-Lambert diffuse from up to MAX_LIGHTS +// directional lights plus an ambient floor. +// +// `__MAX_LIGHTS__` is replaced with the MAX_LIGHTS constant +// (src/video/webgl/lighting/constants.ts) by LitMeshBatcher._shaderSources at +// load time, so the array sizes / loop bound can't drift from the uniform +// packer. (Used directly rather than via a #define so it survives the GLSL +// preprocessor regardless of how it handles macros.) + +uniform sampler2D uSampler; + +uniform int uLightCount; +uniform vec3 uLightDir[__MAX_LIGHTS__]; // surface→light, normalized (world space) +uniform vec3 uLightColor[__MAX_LIGHTS__]; // color premultiplied by intensity +uniform vec3 uAmbient; // flat ambient floor + +varying vec4 vColor; +varying vec2 vRegion; +varying vec3 vNormal; + +void main(void) { + vec4 base = texture2D(uSampler, vRegion) * vColor; + + vec3 N = normalize(vNormal); + vec3 lit = uAmbient; + for (int i = 0; i < __MAX_LIGHTS__; i++) { + if (i >= uLightCount) { break; } + // Half-Lambert ("wrap") diffuse: dot * 0.5 + 0.5. Softens the + // terminator and lifts the shadowed side, for a gentler, more + // diffuse look than hard Lambert (which reads as harsh noon). + float ndl = dot(N, uLightDir[i]) * 0.5 + 0.5; + lit += uLightColor[i] * (ndl * ndl); + } + + gl_FragColor = vec4(base.rgb * lit, base.a); +} diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert new file mode 100644 index 0000000000..b347b09d74 --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert @@ -0,0 +1,22 @@ +// Lit mesh vertex shader (Camera3d + LightingEnvironment). Same as mesh.vert +// plus a world-space normal carried to the fragment shader for diffuse shading. +attribute vec3 aVertex; +attribute vec2 aRegion; +attribute vec4 aColor; +// World-space normal. The lit mesh batcher pushes normals WITHOUT the view +// transform, so lighting is evaluated in world space. +attribute vec3 aNormal; + +uniform mat4 uProjectionMatrix; + +varying vec2 vRegion; +varying vec4 vColor; +varying vec3 vNormal; + +void main(void) { + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); + vColor = vec4(aColor.rgb * aColor.a, aColor.a); + vRegion = aRegion; + // already world-space — interpolated, then renormalized per fragment + vNormal = aNormal; +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index c9e2b18e48..10a91a39af 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -22,6 +22,7 @@ import { generateJoinCircles, generateTriangleFan, } from "../utils/tessellation.js"; +import LitMeshBatcher from "./batchers/lit_mesh_batcher"; import LitQuadBatcher from "./batchers/lit_quad_batcher"; import MeshBatcher from "./batchers/mesh_batcher"; import PrimitiveBatcher from "./batchers/primitive_batcher"; @@ -199,6 +200,7 @@ export default class WebGLRenderer extends Renderer { } this.addBatcher(new (CustomBatcher || PrimitiveBatcher)(this), "primitive"); this.addBatcher(new MeshBatcher(this), "mesh"); + this.addBatcher(new LitMeshBatcher(this), "litMesh"); // default WebGL state(s) // depth testing disabled for 2D (painter's algorithm handles z-ordering). @@ -1274,15 +1276,18 @@ export default class WebGLRenderer extends Renderer { drawMesh(mesh) { const gl = this.gl; - // `setBatcher("mesh")` delegates all mesh-mode state setup - // (DEPTH_TEST enable, LEQUAL, depthMask, one-shot per-target - // depth clear, BLEND off) to `MeshBatcher.bind()`. Switching - // away from the mesh batcher restores non-mesh defaults via - // `MeshBatcher.unbind()`. Consecutive `drawMesh` calls pay - // zero state cost between them; the GPU's LEQUAL depth test - // resolves inter-mesh occlusion per pixel against the - // accumulated depth buffer. - this.setBatcher("mesh"); + // Route to the lit or unlit mesh batcher. `mesh.lit` meshes use the + // `LitMeshBatcher` (world-space normals + lighting); everything else + // uses the lean unlit `MeshBatcher`. Both share the mesh-mode depth + // state, so mixing them keeps inter-mesh occlusion correct. + // + // `setBatcher` delegates all mesh-mode state setup (DEPTH_TEST enable, + // LEQUAL, depthMask, one-shot per-target depth clear, BLEND off) to the + // batcher's `bind()`. Switching away restores non-mesh defaults via + // `unbind()`. Consecutive same-kind `drawMesh` calls pay zero state cost + // between them; the GPU's LEQUAL depth test resolves inter-mesh + // occlusion per pixel against the accumulated depth buffer. + this.setBatcher(mesh.lit === true ? "litMesh" : "mesh"); // apply custom shader if set on the renderable (via preDraw) if (this.customShader != null) { diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index 5a5bd7a284..122a81d00d 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -6,6 +6,7 @@ import { Frustum, Matrix3d, Renderable, + Vector2d, Vector3d, video, } from "../src/index.js"; @@ -696,4 +697,84 @@ describe("Camera3d", () => { expect(Camera2d.defaultSortOn).toBe("z"); }); }); + + describe("worldToScreen", () => { + const W = 800; + const H = 600; + // camera at the origin looking straight down +Z (the engine's + // "forward / away" axis), Y-down. A forward-axis point lands at the + // screen centre; offsets move predictably. + const mk = () => { + const cam = new Camera3d(0, 0, W, H); + cam.pos.set(0, 0, 0); + cam.lookAt(0, 0, 100); // yaw = pitch = 0, looking down +Z + return cam; + }; + + it("projects a forward-axis point to the screen centre", () => { + const p = mk().worldToScreen(new Vector3d(0, 0, 100)); + expect(p.x).toBeCloseTo(W / 2, 0); + expect(p.y).toBeCloseTo(H / 2, 0); + }); + + it("maps +X world to the right and +Y world downward (Y-down)", () => { + const cam = mk(); + const right = cam.worldToScreen(new Vector3d(10, 0, 100)); + const down = cam.worldToScreen(new Vector3d(0, 10, 100)); + expect(right.x).toBeGreaterThan(W / 2); // +X → right + expect(right.y).toBeCloseTo(H / 2, 0); + expect(down.y).toBeGreaterThan(H / 2); // +Y → down + expect(down.x).toBeCloseTo(W / 2, 0); + }); + + it("keeps an on-axis point centred regardless of depth (perspective)", () => { + const cam = mk(); + expect(cam.worldToScreen(new Vector3d(0, 0, 50)).x).toBeCloseTo(W / 2, 0); + expect(cam.worldToScreen(new Vector3d(0, 0, 900)).x).toBeCloseTo( + W / 2, + 0, + ); + }); + + it("foreshortens: an off-axis point's screen offset shrinks with depth", () => { + const cam = mk(); + const close = cam.worldToScreen(new Vector3d(10, 0, 50)); + const far = cam.worldToScreen(new Vector3d(10, 0, 500)); + expect(close.x - W / 2).toBeGreaterThan(far.x - W / 2); + expect(far.x).toBeGreaterThan(W / 2); // still right of centre + }); + + it("writes into the provided out vector and returns it", () => { + const out = new Vector2d(); + const r = mk().worldToScreen(new Vector3d(0, 0, 100), out); + expect(r).toBe(out); + expect(out.x).toBeCloseTo(W / 2, 0); + }); + + it("reacts to camera yaw — a forward point shifts off-centre when the camera turns", () => { + const cam = mk(); + const centred = cam.worldToScreen(new Vector3d(0, 0, 100)).x; + cam.yaw = 0.3; // turn right → the point is now to the left of view + const turned = cam.worldToScreen(new Vector3d(0, 0, 100)).x; + expect(Math.abs(turned - centred)).toBeGreaterThan(1); + }); + + it("H3: returns null for a point behind the camera (clip w ≤ 0)", () => { + const cam = mk(); // at origin, looking down +Z + // in front → a real pixel + expect(cam.worldToScreen(new Vector3d(0, 0, 100))).not.toBeNull(); + // behind the camera (−Z) → null, not a mirrored garbage pixel + expect(cam.worldToScreen(new Vector3d(0, 0, -100))).toBeNull(); + }); + + it("H3: does NOT write into `out` when the point is behind the camera", () => { + const cam = mk(); + const out = new Vector2d(); + out.set(-12345, -67890); // sentinel + const r = cam.worldToScreen(new Vector3d(0, 0, -50), out); + expect(r).toBeNull(); + expect(out.x).toBe(-12345); // untouched + expect(out.y).toBe(-67890); + }); + }); }); diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js new file mode 100644 index 0000000000..c6ea5ef1a3 --- /dev/null +++ b/packages/melonjs/tests/gltf.spec.js @@ -0,0 +1,1313 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + boot, + LightingEnvironment, + level, + loader, + Mesh, + video, +} from "../src/index.js"; +import GLTFScene from "../src/level/gltf/GLTFScene.js"; +import { gltfList } from "../src/loader/cache.js"; +import { + multiplyMatrix, + nodeLocalMatrix, + parseGLB, + parseGLTF, + readAccessor, +} from "../src/loader/parsers/gltf.js"; + +// apply a column-major 4x4 matrix to a vec3 (independent reference impl — +// deliberately NOT the parser's code, so a bug there can't hide) +function applyMat(m, v) { + return [ + m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], + m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], + m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14], + ]; +} + +// ── GLB container builders ────────────────────────────────────────────────── +// Pack a glTF JSON description + binary blob into a minimal GLB container +// (12-byte header + JSON chunk + BIN chunk), mirroring the binary layout the +// parser expects. Keeps the tests self-contained — no fixture file on disk. + +function packGLB(json, binU8) { + const jsonBytes = new TextEncoder().encode(JSON.stringify(json)); + const jsonPad = (4 - (jsonBytes.length % 4)) % 4; + const binPad = (4 - (binU8.length % 4)) % 4; + const total = 12 + 8 + jsonBytes.length + jsonPad + 8 + binU8.length + binPad; + const ab = new ArrayBuffer(total); + const dv = new DataView(ab); + const u8 = new Uint8Array(ab); + dv.setUint32(0, 0x46546c67, true); // magic "glTF" + dv.setUint32(4, 2, true); // version 2 + dv.setUint32(8, total, true); + let o = 12; + // JSON chunk + dv.setUint32(o, jsonBytes.length + jsonPad, true); + o += 4; + dv.setUint32(o, 0x4e4f534a, true); // "JSON" + o += 4; + u8.set(jsonBytes, o); + o += jsonBytes.length; + for (let i = 0; i < jsonPad; i++) { + u8[o++] = 0x20; // pad with spaces + } + // BIN chunk + dv.setUint32(o, binU8.length + binPad, true); + o += 4; + dv.setUint32(o, 0x004e4942, true); // "BIN\0" + o += 4; + u8.set(binU8, o); // trailing pad bytes stay zero + return ab; +} + +// concat a Float32 position buffer with an index buffer into one blob +function concatBin(positions, indices) { + const posU8 = new Uint8Array(positions.buffer); + const idxU8 = new Uint8Array( + indices.buffer, + indices.byteOffset, + indices.byteLength, + ); + const bin = new Uint8Array(posU8.length + idxU8.length); + bin.set(posU8, 0); + bin.set(idxU8, posU8.length); + return bin; +} + +// A single triangle, with the index buffer either Uint16 or Uint32. +// Three nodes: a translated+scaled mesh, a camera, and a 90°-Z-rotated mesh. +function buildSceneGLB({ uint32 = false } = {}) { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = uint32 + ? new Uint32Array([0, 1, 2]) + : new Uint16Array([0, 1, 2]); + const bin = concatBin(positions, indices); + const r = Math.SQRT1_2; // sin/cos of 45° → 90° rotation quaternion about Z + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1, 2] }], + nodes: [ + { mesh: 0, translation: [2, 3, 4], scale: [2, 2, 2] }, + { camera: 0, translation: [0, 0, 10] }, + { mesh: 0, rotation: [0, 0, r, r] }, + ], + cameras: [ + { + type: "perspective", + perspective: { yfov: 0.5, znear: 0.1, zfar: 100, aspectRatio: 1.5 }, + }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { + bufferView: 1, + componentType: uint32 ? 5125 : 5123, + count: 3, + type: "SCALAR", + }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positions.byteLength }, + { + buffer: 0, + byteOffset: positions.byteLength, + byteLength: indices.byteLength, + }, + ], + buffers: [{ byteLength: positions.byteLength + indices.byteLength }], + }; + return packGLB(json, bin); +} + +// ── Parser ────────────────────────────────────────────────────────────────── + +describe("parseGLB()", () => { + it("splits a GLB container into JSON + binary chunks", () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const ab = packGLB( + { asset: { version: "2.0" }, scene: 0, scenes: [{ nodes: [] }] }, + concatBin(positions, indices), + ); + const { json, bin } = parseGLB(ab); + expect(json.asset.version).toBe("2.0"); + expect(bin).toBeInstanceOf(Uint8Array); + // the BIN chunk is 4-byte aligned, so its length is the data size + // (42) rounded up to a multiple of 4 (44); the trailing pad bytes are + // harmless — accessors index by explicit byteOffset/byteLength + const dataLen = positions.byteLength + indices.byteLength; + expect(bin.byteLength).toBe(dataLen + ((4 - (dataLen % 4)) % 4)); + }); + + it("falls back to JSON parsing for a non-binary .gltf", () => { + const obj = { asset: { version: "2.0" }, scenes: [{ nodes: [] }] }; + const ab = new TextEncoder().encode(JSON.stringify(obj)).buffer; + const { json, bin } = parseGLB(ab); + expect(json.asset.version).toBe("2.0"); + expect(bin).toBeNull(); + }); +}); + +describe("parseGLTF()", () => { + it("instantiates one node per mesh primitive (cameras excluded)", async () => { + const scene = await parseGLTF(buildSceneGLB()); + // node0 (mesh) + node2 (mesh); node1 is a camera → not a mesh node + expect(scene.nodes).toHaveLength(2); + expect(scene.cameras).toHaveLength(1); + }); + + it("reads geometry through the accessors (positions + Uint16 indices)", async () => { + const scene = await parseGLTF(buildSceneGLB()); + const node = scene.nodes[0]; + expect(Array.from(node.vertices)).toEqual([0, 0, 0, 1, 0, 0, 0, 1, 0]); + expect(node.vertexCount).toBe(3); + expect(node.indices).toBeInstanceOf(Uint16Array); + expect(Array.from(node.indices)).toEqual([0, 1, 2]); + }); + + it("preserves Uint32 index buffers (large meshes)", async () => { + const scene = await parseGLTF(buildSceneGLB({ uint32: true })); + expect(scene.nodes[0].indices).toBeInstanceOf(Uint32Array); + expect(Array.from(scene.nodes[0].indices)).toEqual([0, 1, 2]); + }); + + it("defaults UVs to zero when TEXCOORD_0 is absent", async () => { + const scene = await parseGLTF(buildSceneGLB()); + // 3 vertices × 2 components, all zero + expect(scene.nodes[0].uvs).toBeInstanceOf(Float32Array); + expect(Array.from(scene.nodes[0].uvs)).toEqual([0, 0, 0, 0, 0, 0]); + }); + + it("composes a node's world matrix from TRS (translation + scale)", async () => { + const scene = await parseGLTF(buildSceneGLB()); + const m = scene.nodes[0].world; // column-major + expect(m[0]).toBeCloseTo(2, 5); // scale x + expect(m[5]).toBeCloseTo(2, 5); // scale y + expect(m[10]).toBeCloseTo(2, 5); // scale z + expect(m[12]).toBeCloseTo(2, 5); // translate x + expect(m[13]).toBeCloseTo(3, 5); // translate y + expect(m[14]).toBeCloseTo(4, 5); // translate z + }); + + it("composes a node's world matrix from a rotation quaternion", async () => { + const scene = await parseGLTF(buildSceneGLB()); + // node2 — 90° about Z: x axis → +y, y axis → −x + const m = scene.nodes[1].world; + expect(m[0]).toBeCloseTo(0, 5); + expect(m[1]).toBeCloseTo(1, 5); + expect(m[4]).toBeCloseTo(-1, 5); + expect(m[5]).toBeCloseTo(0, 5); + expect(m[10]).toBeCloseTo(1, 5); + }); + + it("places cameras with their world transform + perspective params", async () => { + const scene = await parseGLTF(buildSceneGLB()); + const cam = scene.cameras[0]; + expect(cam.type).toBe("perspective"); + expect(cam.perspective.yfov).toBeCloseTo(0.5, 5); + expect(cam.world[14]).toBeCloseTo(10, 5); // translate z = 10 + }); + + it("computes world-space scene bounds across all mesh nodes", async () => { + const scene = await parseGLTF(buildSceneGLB()); + const { min, max } = scene.bounds; + // node0 verts: (2,3,4)(4,3,4)(2,5,4); node2 verts: (0,0,0)(0,1,0)(-1,0,0) + expect(min[0]).toBeCloseTo(-1, 5); + expect(min[1]).toBeCloseTo(0, 5); + expect(min[2]).toBeCloseTo(0, 5); + expect(max[0]).toBeCloseTo(4, 5); + expect(max[1]).toBeCloseTo(5, 5); + expect(max[2]).toBeCloseTo(4, 5); + }); + + it("decodes a base64 data: URI buffer (non-GLB .gltf path)", async () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const bin = concatBin(positions, indices); + let s = ""; + for (let i = 0; i < bin.length; i++) { + s += String.fromCharCode(bin[i]); + } + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positions.byteLength }, + { + buffer: 0, + byteOffset: positions.byteLength, + byteLength: indices.byteLength, + }, + ], + buffers: [ + { + byteLength: bin.length, + uri: `data:application/octet-stream;base64,${btoa(s)}`, + }, + ], + }; + const ab = new TextEncoder().encode(JSON.stringify(json)).buffer; + const scene = await parseGLTF(ab); + expect(Array.from(scene.nodes[0].vertices)).toEqual([ + 0, 0, 0, 1, 0, 0, 0, 1, 0, + ]); + }); +}); + +// ── Adversarial / malformed-input robustness ───────────────────────────────── +// A malformed asset must degrade gracefully (empty-but-valid descriptor or a +// clear thrown error) — never crash the engine or silently render nothing. + +describe("parseGLTF() robustness", () => { + it("synthesizes a sequential index buffer for non-indexed primitives", async () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], // no indices + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positions.byteLength }, + ], + buffers: [{ byteLength: positions.byteLength }], + }; + const scene = await parseGLTF( + packGLB(json, new Uint8Array(positions.buffer)), + ); + // drawArrays-style geometry must still be drawable + expect(Array.from(scene.nodes[0].indices)).toEqual([0, 1, 2]); + }); + + it("returns finite, degenerate bounds for a scene with no geometry", async () => { + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [] }], + nodes: [], + }; + const scene = await parseGLTF(packGLB(json, new Uint8Array(0))); + expect(scene.nodes).toHaveLength(0); + expect(scene.bounds.min).toEqual([0, 0, 0]); + expect(scene.bounds.max).toEqual([0, 0, 0]); + }); + + it("degrades to an empty descriptor when the scenes array is missing", async () => { + const json = { asset: { version: "2.0" }, nodes: [] }; + const scene = await parseGLTF(packGLB(json, new Uint8Array(0))); + expect(scene.nodes).toHaveLength(0); + expect(scene.cameras).toHaveLength(0); + }); + + it("does not stack-overflow on a cyclic node graph", async () => { + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ children: [1] }, { children: [0] }], // 0 → 1 → 0 cycle + }; + // must complete (each node visited at most once), not recurse forever + const scene = await parseGLTF(packGLB(json, new Uint8Array(0))); + expect(scene.nodes).toHaveLength(0); + }); + + it("throws a clear error on an unsupported accessor componentType", async () => { + const positions = new Float32Array([0, 0, 0]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], + accessors: [ + { bufferView: 0, componentType: 9999, count: 1, type: "VEC3" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positions.byteLength }, + ], + buffers: [{ byteLength: positions.byteLength }], + }; + await expect( + parseGLTF(packGLB(json, new Uint8Array(positions.buffer))), + ).rejects.toThrow(/unsupported accessor/); + }); +}); + +// ── Normals & lights ───────────────────────────────────────────────────────── + +// concat any number of TypedArrays into one bin blob, returning byte offsets +function packParts(parts) { + const u8s = parts.map((p) => { + return new Uint8Array(p.buffer, p.byteOffset, p.byteLength); + }); + const total = u8s.reduce((n, u) => { + return n + u.length; + }, 0); + const bin = new Uint8Array(total); + const offsets = []; + let off = 0; + for (const u of u8s) { + offsets.push(off); + bin.set(u, off); + off += u.length; + } + return { bin, offsets }; +} + +describe("parseGLTF() — normals", () => { + it("reads the NORMAL accessor", async () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, normals, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [{ attributes: { POSITION: 0, NORMAL: 1 }, indices: 2 }], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: normals.byteLength }, + { buffer: 0, byteOffset: offsets[2], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + const scene = await parseGLTF(packGLB(json, bin)); + expect(Array.from(scene.nodes[0].normals)).toEqual([ + 0, 0, 1, 0, 0, 1, 0, 0, 1, + ]); + }); + + it("synthesizes unit flat normals when NORMAL is absent", async () => { + // a triangle in the XY plane → face normal along ±Z, unit length + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + const scene = await parseGLTF(packGLB(json, bin)); + const n = scene.nodes[0].normals; + expect(n.length).toBe(9); + for (let i = 0; i < 9; i += 3) { + expect(Math.hypot(n[i], n[i + 1], n[i + 2])).toBeCloseTo(1, 5); + expect(Math.abs(n[i + 2])).toBeCloseTo(1, 5); // ±Z + } + }); +}); + +describe("parseGLTF() — KHR_lights_punctual", () => { + // a single-triangle mesh node + one light node referencing `lightExt` + function buildLightGLB(lightDef, lightNode) { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1] }], + extensionsUsed: ["KHR_lights_punctual"], + extensions: { KHR_lights_punctual: { lights: [lightDef] } }, + nodes: [{ mesh: 0 }, lightNode], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + return packGLB(json, bin); + } + + it("parses a directional light (identity node → -Z world direction)", async () => { + const scene = await parseGLTF( + buildLightGLB( + { + type: "directional", + color: [1, 0.5, 0.25], + intensity: 1000, + name: "Sun", + }, + { extensions: { KHR_lights_punctual: { light: 0 } } }, + ), + ); + expect(scene.lights).toHaveLength(1); + const L = scene.lights[0]; + expect(L.type).toBe("directional"); + expect(L.color).toEqual([1, 0.5, 0.25]); + expect(L.intensity).toBe(1000); + expect(L.name).toBe("Sun"); + // glTF directional lights point down the node's local -Z; identity node + // → world direction (0, 0, -1) + expect(L.direction[0]).toBeCloseTo(0, 5); + expect(L.direction[1]).toBeCloseTo(0, 5); + expect(L.direction[2]).toBeCloseTo(-1, 5); + }); + + it("direction follows the light node's rotation (90° about X)", async () => { + const r = Math.SQRT1_2; // 90° about X: (r, 0, 0, r) + const scene = await parseGLTF( + buildLightGLB( + { type: "directional", intensity: 1 }, + { + rotation: [r, 0, 0, r], + extensions: { KHR_lights_punctual: { light: 0 } }, + }, + ), + ); + // rotating local -Z (0,0,-1) by +90° about X → (0, 1, 0) + const L = scene.lights[0]; + expect(L.direction[0]).toBeCloseTo(0, 5); + expect(L.direction[1]).toBeCloseTo(1, 5); + expect(L.direction[2]).toBeCloseTo(0, 5); + }); + + it("parses a point light with its world position; defaults color to white", async () => { + const scene = await parseGLTF( + buildLightGLB( + { type: "point", intensity: 5 }, + { + translation: [3, 4, 5], + extensions: { KHR_lights_punctual: { light: 0 } }, + }, + ), + ); + const L = scene.lights[0]; + expect(L.type).toBe("point"); + expect(L.color).toEqual([1, 1, 1]); // default + expect(L.position).toEqual([3, 4, 5]); + }); + + it("scenes without the extension have an empty lights array", async () => { + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.lights).toEqual([]); + }); +}); + +// ── Materials: baseColorFactor + vertex colors (COLOR_0) ────────────────────── + +describe("parseGLTF() — baseColorFactor & vertex colors", () => { + // unpack a packed-ARGB Uint32 into named channels for readable assertions + const argb = (u32) => { + return { + a: (u32 >>> 24) & 0xff, + r: (u32 >>> 16) & 0xff, + g: (u32 >>> 8) & 0xff, + b: u32 & 0xff, + }; + }; + + // single triangle with an optional material baseColorFactor + function buildFactorGLB(baseColorFactor) { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + materials: [{ pbrMetallicRoughness: { baseColorFactor } }], + meshes: [ + { + primitives: [ + { attributes: { POSITION: 0 }, indices: 1, material: 0 }, + ], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + return packGLB(json, bin); + } + + // single triangle with a COLOR_0 attribute of the given typed array + // (componentType inferred: Float32 → float, Uint8 → ubyte, Uint16 → ushort) + function buildColorGLB(colorTyped, numComp) { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, colorTyped, indices]); + const ct = + colorTyped instanceof Float32Array + ? 5126 + : colorTyped instanceof Uint8Array + ? 5121 + : 5123; + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [{ attributes: { POSITION: 0, COLOR_0: 1 }, indices: 2 }], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { + bufferView: 1, + componentType: ct, + count: 3, + type: numComp === 4 ? "VEC4" : "VEC3", + normalized: ct !== 5126, + }, + { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { + buffer: 0, + byteOffset: offsets[1], + byteLength: colorTyped.byteLength, + }, + { buffer: 0, byteOffset: offsets[2], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + return packGLB(json, bin); + } + + it("reads baseColorFactor from the material", async () => { + const scene = await parseGLTF(buildFactorGLB([1, 0, 0, 1])); + expect(scene.nodes[0].baseColorFactor).toEqual([1, 0, 0, 1]); + }); + + it("defaults baseColorFactor to opaque white when the material has none", async () => { + // buildSceneGLB has no materials at all + expect((await parseGLTF(buildSceneGLB())).nodes[0].baseColorFactor).toEqual( + [1, 1, 1, 1], + ); + }); + + it("reads COLOR_0 (float VEC4) into packed ARGB", async () => { + const scene = await parseGLTF( + buildColorGLB( + new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0.5]), + 4, + ), + ); + const c = scene.nodes[0].colors; + expect(c).toBeInstanceOf(Uint32Array); + expect(argb(c[0])).toEqual({ a: 255, r: 255, g: 0, b: 0 }); + expect(argb(c[1])).toEqual({ a: 255, r: 0, g: 255, b: 0 }); + expect(argb(c[2])).toEqual({ a: 128, r: 0, g: 0, b: 255 }); // 0.5→128 + }); + + it("ADVERSARIAL: normalizes a COLOR_0 UNSIGNED_BYTE encoding", async () => { + const scene = await parseGLTF( + buildColorGLB( + new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]), + 4, + ), + ); + expect(argb(scene.nodes[0].colors[1])).toEqual({ + a: 255, + r: 0, + g: 255, + b: 0, + }); + }); + + it("ADVERSARIAL: defaults COLOR_0 alpha to 255 for a VEC3 (no alpha channel)", async () => { + const scene = await parseGLTF( + buildColorGLB(new Float32Array([1, 1, 1, 0, 0, 0, 0.5, 0.5, 0.5]), 3), + ); + expect(argb(scene.nodes[0].colors[0]).a).toBe(255); + expect(argb(scene.nodes[0].colors[2])).toEqual({ + a: 255, + r: 128, + g: 128, + b: 128, + }); + }); + + it("leaves colors undefined when COLOR_0 is absent", async () => { + expect((await parseGLTF(buildSceneGLB())).nodes[0].colors).toBeUndefined(); + }); + + // GLTFScene application + const NAME = "__gltf_mat_apply"; + const COLOR_NAME = "__gltf_color_apply"; + beforeAll(async () => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + gltfList[NAME] = await parseGLTF(buildFactorGLB([1, 0, 0, 1])); + gltfList[COLOR_NAME] = await parseGLTF( + buildColorGLB(new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]), 4), + ); + }); + afterAll(() => { + delete gltfList[NAME]; + delete gltfList[COLOR_NAME]; + }); + + const fakeContainer = () => { + return { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + }; + + it("GLTFScene applies baseColorFactor as the mesh tint", () => { + const container = fakeContainer(); + new GLTFScene(NAME).addTo(container, { scale: 1 }); + const m = container.kids[0]; + expect(m.tint.r).toBe(255); + expect(m.tint.g).toBe(0); + expect(m.tint.b).toBe(0); + }); + + it("GLTFScene sets mesh.vertexColors from COLOR_0", () => { + const container = fakeContainer(); + new GLTFScene(COLOR_NAME).addTo(container, { scale: 1 }); + const m = container.kids[0]; + expect(m.vertexColors).toBeInstanceOf(Uint32Array); + expect(argb(m.vertexColors[0])).toEqual({ a: 255, r: 255, g: 0, b: 0 }); + }); +}); + +// ── Loader integration (cache / getter / unload) ───────────────────────────── + +describe("glTF loader integration", () => { + const NAME = "__gltf_test_scene"; + + beforeAll(async () => { + gltfList[NAME] = await parseGLTF(buildSceneGLB()); + }); + + afterAll(() => { + delete gltfList[NAME]; + }); + + it("getGLTF returns the parsed descriptor, or null when missing", () => { + const scene = loader.getGLTF(NAME); + expect(scene).toBe(gltfList[NAME]); + expect(scene.nodes).toHaveLength(2); + expect(loader.getGLTF("does-not-exist")).toBeNull(); + }); + + it("registers with the level director under gltf/glb formats", () => { + expect(level.add("glb", NAME)).toBe(true); + // idempotent — a second add for the same id is a no-op + expect(level.add("glb", NAME)).toBe(false); + }); + + it("unload removes a glb (and gltf) entry from the cache", async () => { + const tmp = "__gltf_unload_tmp"; + gltfList[tmp] = await parseGLTF(buildSceneGLB()); + expect(loader.unload({ name: tmp, type: "glb" })).toBe(true); + expect(tmp in gltfList).toBe(false); + // unloading a missing entry returns false rather than throwing + expect(loader.unload({ name: tmp, type: "gltf" })).toBe(false); + }); +}); + +// ── GLTFScene descriptor wrapper ───────────────────────────────────────────── + +describe("GLTFScene", () => { + const NAME = "__gltf_scene_meta"; + + beforeAll(async () => { + gltfList[NAME] = await parseGLTF(buildSceneGLB()); + }); + + afterAll(() => { + delete gltfList[NAME]; + }); + + it("exposes name, format, bounds and cameras from the descriptor", () => { + const scene = new GLTFScene(NAME); + expect(scene.name).toBe(NAME); + expect(scene.format).toBe("gltf"); + expect(scene.bounds.max[1]).toBeCloseTo(5, 5); + expect(scene.cameras).toHaveLength(1); + }); +}); + +// ── glTF → Mesh instantiation (needs a renderer for the texture cache) ──────── + +describe("GLTFScene → Mesh instantiation", () => { + const NAME = "__gltf_addto_scene"; + + beforeAll(async () => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + gltfList[NAME] = await parseGLTF(buildSceneGLB()); + }); + + afterAll(() => { + delete gltfList[NAME]; + }); + + it("instantiates one Mesh per mesh node into the container", () => { + const scene = new GLTFScene(NAME); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + scene.addTo(container, { scale: 10 }); + + // scene meshes carry their own depth — container must not reassign it + expect(container.autoDepth).toBe(false); + expect(container.kids).toHaveLength(2); + + const mesh = container.kids[0]; + expect(mesh).toBeInstanceOf(Mesh); + // rightHanded defaults on for glTF scenes (no mirror) + expect(mesh.rightHanded).toBe(true); + // the pixels-per-unit scale rides on meshScale, freeing width/height + // to describe the world-space bounds for frustum culling + expect(mesh.meshScale).toBe(10); + // node0's rotation/scale is preserved (scale 2) ... + expect(mesh.currentTransform.val[0]).toBeCloseTo(2, 5); + // ... but its translation is moved out to pos/depth so culling sees + // the mesh at its true world center (regression: all-at-origin cull) + expect(mesh.currentTransform.val[12]).toBe(0); + expect(mesh.pos.x).toBeCloseTo(20, 5); // 2 (tx) * scale 10 + expect(mesh.pos.y).toBeCloseTo(-30, 5); // -3 (ty) * scale 10 + expect(mesh.depth).toBeCloseTo(-40, 5); // -4 (tz) * scale 10 (rightHanded) + }); + + it("renders a vertex at the independently-computed world position (end-to-end)", () => { + // THE positioning check through the real loader path: node0 has + // translation (2,3,4) + scale 2; local vertex 1 is (1,0,0). World = + // scale·v + translation = (2·1+2, 3, 4) = (4,3,4). With sceneScale 10 + // and the rightHanded Y/Z negate, render = (4, -3, -4)·10 = + // (40, -30, -40). Computed here without any parser/loader code. + const scene = new GLTFScene(NAME); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + scene.addTo(container, { scale: 10 }); + const mesh = container.kids[0]; + mesh._projectVerticesWorld(mesh.pos.x, mesh.pos.y, mesh.depth); + // vertex index 1 → components at offset 3,4,5 + expect(mesh.vertices[3]).toBeCloseTo(40, 4); // x + expect(mesh.vertices[4]).toBeCloseTo(-30, 4); // y (Y-down) + expect(mesh.vertices[5]).toBeCloseTo(-40, 4); // z (+Z forward, negated) + }); + + it("gives each mesh node a distinct world position (frustum-cull regression)", () => { + // Regression: scene meshes used to keep pos (0,0,0) and hide all + // placement in currentTransform, so every mesh shared one cull point + // at the origin and the whole scene popped in/out together. + const scene = new GLTFScene(NAME); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + scene.addTo(container, { scale: 10 }); + const [a, b] = container.kids; + // node0 is translated (2,3,4); node2 is rotation-only at the origin + expect(a.pos.x).not.toBe(b.pos.x); + expect(b.pos.x).toBeCloseTo(0, 5); + expect(b.pos.y).toBeCloseTo(0, 5); + // a positive bounding radius so the mesh isn't culled to a point + expect(a.getBounds().width).toBeGreaterThan(0); + }); + + it("keeps raw geometry untouched when normalize is disabled", () => { + // glTF nodes share one coordinate space, so addTo passes + // normalize:false — the raw vertices must survive verbatim + const scene = new GLTFScene(NAME); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + scene.addTo(container); + expect(Array.from(container.kids[0].originalVertices)).toEqual([ + 0, 0, 0, 1, 0, 0, 0, 1, 0, + ]); + }); + + it("rightHanded negates Z in the world-space projection (no mirror)", () => { + const lh = new Mesh(0, 0, { + vertices: new Float32Array([0, 0, 0.5]), + uvs: new Float32Array([0, 0]), + indices: new Uint16Array([0, 0, 0]), + width: 10, + normalize: false, + }); + const rh = new Mesh(0, 0, { + vertices: new Float32Array([0, 0, 0.5]), + uvs: new Float32Array([0, 0]), + indices: new Uint16Array([0, 0, 0]), + width: 10, + normalize: false, + rightHanded: true, + }); + lh._projectVerticesWorld(0, 0, 0); + rh._projectVerticesWorld(0, 0, 0); + // left-handed (default reflection) keeps +Z, right-handed flips it + expect(lh.vertices[2]).toBeCloseTo(5, 5); + expect(rh.vertices[2]).toBeCloseTo(-5, 5); + }); + + it("rightHanded meshes keep the original winding under Camera3d", () => { + const rh = new Mesh(0, 0, { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 0, 0, 0, 0]), + indices: new Uint16Array([0, 1, 2]), + width: 10, + normalize: false, + rightHanded: true, + }); + rh._useWorldSpace = true; // simulate activation under Camera3d + rh.draw({ drawMesh() {} }); + // rotation bridge preserves winding → no reversed-index swap + expect(rh.indices).toBe(rh._indicesOriginal); + }); +}); + +// ── GLTFScene → lighting ───────────────────────────────────────────────────── + +describe("GLTFScene → lighting (KHR_lights_punctual)", () => { + const NAME = "__gltf_lit_scene"; + + beforeAll(async () => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, normals, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1] }], + extensionsUsed: ["KHR_lights_punctual"], + extensions: { + KHR_lights_punctual: { + lights: [{ type: "directional", color: [1, 1, 1], intensity: 1000 }], + }, + }, + nodes: [ + { mesh: 0 }, + { extensions: { KHR_lights_punctual: { light: 0 } } }, + ], + meshes: [ + { + primitives: [{ attributes: { POSITION: 0, NORMAL: 1 }, indices: 2 }], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: normals.byteLength }, + { buffer: 0, byteOffset: offsets[2], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + gltfList[NAME] = await parseGLTF(packGLB(json, bin)); + }); + + afterAll(() => { + delete gltfList[NAME]; + LightingEnvironment.default.clear(); + }); + + const fakeContainer = () => { + return { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + }; + + it("adds the authored directional light + flags meshes lit", () => { + LightingEnvironment.default.clear(); + const scene = new GLTFScene(NAME); + const container = fakeContainer(); + scene.addTo(container, { scale: 10 }); + + expect(LightingEnvironment.default.lights).toHaveLength(1); + expect(container.kids[0].lit).toBe(true); + const L = LightingEnvironment.default.lights[0]; + expect(L.type).toBe("directional"); + // glTF dir (0,0,-1) → render space [x, -y, zSign·z] (zSign=-1) → (0,0,1) + expect(L.direction.z).toBeCloseTo(1, 5); + + scene.destroy(); + expect(LightingEnvironment.default.lights).toHaveLength(0); // cleaned up + }); + + it("ADVERSARIAL: reloading replaces the scene's lights (no accumulation)", () => { + LightingEnvironment.default.clear(); + const scene = new GLTFScene(NAME); + scene.addTo(fakeContainer(), { scale: 10 }); + scene.addTo(fakeContainer(), { scale: 10 }); // re-add same instance + expect(LightingEnvironment.default.lights).toHaveLength(1); // not 2 + scene.destroy(); + }); + + it("options.lights:false leaves meshes unlit and adds no lights", () => { + LightingEnvironment.default.clear(); + const scene = new GLTFScene(NAME); + const container = fakeContainer(); + scene.addTo(container, { scale: 10, lights: false }); + expect(LightingEnvironment.default.lights).toHaveLength(0); + expect(container.kids[0].lit).toBe(false); + scene.destroy(); + }); +}); + +// ── ADVERSARIAL: matrix multiply (node hierarchy) ──────────────────────────── + +describe("multiplyMatrix() — column-major 4x4", () => { + const I = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const translate = (x, y, z) => { + return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]; + }; + const scale = (s) => { + return [s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1]; + }; + + it("identity is the multiplicative unit", () => { + const m = translate(3, 4, 5); + expect(multiplyMatrix(I, m)).toEqual(m); + expect(multiplyMatrix(m, I)).toEqual(m); + }); + + it("composes two translations additively (parent * local)", () => { + const c = multiplyMatrix(translate(10, 20, 30), translate(1, 2, 3)); + expect(applyMat(c, [0, 0, 0])).toEqual([11, 22, 33]); + }); + + it("parent scale scales the child's translation (parent * local)", () => { + // parent=scale2, local=translate(3,0,0): a child at its own origin lands + // at parent_scale * child_translation = (6,0,0). Catches a reversed + // multiply order (local*parent would give 3, not 6). + const c = multiplyMatrix(scale(2), translate(3, 0, 0)); + expect(applyMat(c, [0, 0, 0])).toEqual([6, 0, 0]); + }); + + it("is non-commutative (rotate∘translate ≠ translate∘rotate)", () => { + const rotZ90 = nodeLocalMatrix({ + rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2], + }); + const t = translate(2, 0, 0); + const a = applyMat(multiplyMatrix(rotZ90, t), [0, 0, 0]); // translate then rotate + const b = applyMat(multiplyMatrix(t, rotZ90), [0, 0, 0]); // rotate then translate + // rotZ90 * t : (2,0,0) rotated 90° about Z → (0,2,0) + expect(a[0]).toBeCloseTo(0, 5); + expect(a[1]).toBeCloseTo(2, 5); + // t * rotZ90 : origin rotated is origin, then translated → (2,0,0) + expect(b[0]).toBeCloseTo(2, 5); + expect(b[1]).toBeCloseTo(0, 5); + }); + + it("matches a hand-computed product of two rotations", () => { + const rz = nodeLocalMatrix({ + rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2], + }); // 90° Z + // rz * rz = 180° about Z: (1,0,0) → (-1,0,0) + const c = multiplyMatrix(rz, rz); + const r = applyMat(c, [1, 0, 0]); + expect(r[0]).toBeCloseTo(-1, 5); + expect(r[1]).toBeCloseTo(0, 5); + }); +}); + +// ── ADVERSARIAL: TRS → matrix ──────────────────────────────────────────────── + +describe("nodeLocalMatrix() — TRS composition", () => { + it("pure translation fills the 4th column only", () => { + const m = nodeLocalMatrix({ translation: [7, 8, 9] }); + expect(applyMat(m, [0, 0, 0])).toEqual([7, 8, 9]); + expect(applyMat(m, [1, 1, 1])).toEqual([8, 9, 10]); + }); + + it("pure scale scales each axis", () => { + const m = nodeLocalMatrix({ scale: [2, 3, 4] }); + expect(applyMat(m, [1, 1, 1])).toEqual([2, 3, 4]); + }); + + it("90° rotation about X maps +Y → +Z", () => { + const m = nodeLocalMatrix({ rotation: [Math.SQRT1_2, 0, 0, Math.SQRT1_2] }); + const r = applyMat(m, [0, 1, 0]); + expect(r[0]).toBeCloseTo(0, 5); + expect(r[1]).toBeCloseTo(0, 5); + expect(r[2]).toBeCloseTo(1, 5); + }); + + it("90° rotation about Y maps +X → −Z", () => { + const m = nodeLocalMatrix({ rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2] }); + const r = applyMat(m, [1, 0, 0]); + expect(r[0]).toBeCloseTo(0, 5); + expect(r[1]).toBeCloseTo(0, 5); + expect(r[2]).toBeCloseTo(-1, 5); + }); + + it("90° rotation about Z maps +X → +Y", () => { + const m = nodeLocalMatrix({ rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2] }); + const r = applyMat(m, [1, 0, 0]); + expect(r[0]).toBeCloseTo(0, 5); + expect(r[1]).toBeCloseTo(1, 5); + }); + + it("applies TRS in the correct order: world = T + R·S·v", () => { + // scale 2, rotate 90° about Z, translate (10,0,0). For v=(1,0,0): + // S→(2,0,0); R→(0,2,0); T→(10,2,0) + const m = nodeLocalMatrix({ + translation: [10, 0, 0], + rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2], + scale: [2, 2, 2], + }); + const r = applyMat(m, [1, 0, 0]); + expect(r[0]).toBeCloseTo(10, 5); + expect(r[1]).toBeCloseTo(2, 5); + expect(r[2]).toBeCloseTo(0, 5); + }); + + it("honors an explicit matrix override", () => { + const m = nodeLocalMatrix({ + matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1], + }); + expect(applyMat(m, [0, 0, 0])).toEqual([5, 6, 7]); + }); + + it("supports negative scale (mirror)", () => { + const m = nodeLocalMatrix({ scale: [-1, 1, 1] }); + expect(applyMat(m, [1, 0, 0])).toEqual([-1, 0, 0]); + }); +}); + +// ── ADVERSARIAL: accessor reading (stride / offset / interleave) ────────────── + +describe("readAccessor() — stride & offset", () => { + it("reads interleaved attributes via byteStride", () => { + // two VEC3s interleaved (POSITION, NORMAL) at stride 24; read POSITION + const f = new Float32Array([ + 1, + 2, + 3, + /*normal*/ 9, + 9, + 9, // + 4, + 5, + 6, + /*normal*/ 8, + 8, + 8, + ]); + const json = { + accessors: [ + { bufferView: 0, componentType: 5126, count: 2, type: "VEC3" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteStride: 24, byteLength: f.byteLength }, + ], + }; + const out = readAccessor(json, [new Uint8Array(f.buffer)], 0); + expect(Array.from(out)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("honors accessor.byteOffset (second accessor in one bufferView)", () => { + const f = new Float32Array([1, 1, 1, 2, 2, 2, 3, 3, 3]); + const json = { + accessors: [ + { + bufferView: 0, + byteOffset: 12, + componentType: 5126, + count: 2, + type: "VEC3", + }, + ], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: f.byteLength }], + }; + const out = readAccessor(json, [new Uint8Array(f.buffer)], 0); + // skip the first VEC3 (12 bytes) → reads (2,2,2),(3,3,3) + expect(Array.from(out)).toEqual([2, 2, 2, 3, 3, 3]); + }); + + it("honors bufferView.byteOffset", () => { + const f = new Float32Array([0, 0, 0, 7, 8, 9]); + const json = { + accessors: [ + { bufferView: 0, componentType: 5126, count: 1, type: "VEC3" }, + ], + bufferViews: [{ buffer: 0, byteOffset: 12, byteLength: 12 }], + }; + const out = readAccessor(json, [new Uint8Array(f.buffer)], 0); + expect(Array.from(out)).toEqual([7, 8, 9]); + }); + + it("H4: throws a clear error for an accessor with no bufferView (sparse/zero-init)", () => { + const json = { + // no bufferView → sparse-only / zero-initialized accessor (out of scope) + accessors: [{ componentType: 5126, count: 1, type: "VEC3" }], + bufferViews: [], + }; + expect(() => { + return readAccessor(json, [], 0); + }).toThrow(/no bufferView/); + }); + + it("H4: still reports unsupported componentType/type before the bufferView check", () => { + const json = { + accessors: [ + { bufferView: 0, componentType: 9999, count: 1, type: "VEC3" }, + ], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }], + }; + expect(() => { + return readAccessor(json, [new Uint8Array(12)], 0); + }).toThrow(/unsupported accessor/); + }); +}); + +// ── ADVERSARIAL: full parse with a non-identity parent (hierarchy) ──────────── + +describe("parseGLTF() — node hierarchy accumulation", () => { + function packGLB(json, binU8) { + const jb = new TextEncoder().encode(JSON.stringify(json)); + const jp = (4 - (jb.length % 4)) % 4; + const bp = (4 - (binU8.length % 4)) % 4; + const total = 12 + 8 + jb.length + jp + 8 + binU8.length + bp; + const ab = new ArrayBuffer(total); + const dv = new DataView(ab); + const u8 = new Uint8Array(ab); + dv.setUint32(0, 0x46546c67, true); + dv.setUint32(4, 2, true); + dv.setUint32(8, total, true); + let o = 12; + dv.setUint32(o, jb.length + jp, true); + o += 4; + dv.setUint32(o, 0x4e4f534a, true); + o += 4; + u8.set(jb, o); + o += jb.length; + for (let i = 0; i < jp; i++) { + u8[o++] = 0x20; + } + dv.setUint32(o, binU8.length + bp, true); + o += 4; + dv.setUint32(o, 0x004e4942, true); + o += 4; + u8.set(binU8, o); + return ab; + } + + it("applies a parent's translation+scale to a child mesh node", async () => { + const pos = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const idx = new Uint16Array([0, 1, 2]); + const bin = new Uint8Array(pos.byteLength + idx.byteLength); + bin.set(new Uint8Array(pos.buffer), 0); + bin.set(new Uint8Array(idx.buffer), pos.byteLength); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + // parent: scale 2, translate (10,0,0) + { children: [1], translation: [10, 0, 0], scale: [2, 2, 2] }, + // child mesh: translate (1,0,0) in the parent's (scaled) space + { mesh: 0, translation: [1, 0, 0] }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: pos.byteLength }, + { buffer: 0, byteOffset: pos.byteLength, byteLength: idx.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + const scene = await parseGLTF(packGLB(json, bin)); + const w = scene.nodes[0].world; + // child world translation = parent(scale2,trans10) applied to (1,0,0) + // = 2*1 + 10 = 12 → and the parent scale (2) survives on the diagonal + expect(applyMat(w, [0, 0, 0])).toEqual([12, 0, 0]); + expect(w[0]).toBeCloseTo(2, 5); // composed scale x + // a child vertex at local (1,0,0): 2*1 + 12 = 14 + expect(applyMat(w, [1, 0, 0])[0]).toBeCloseTo(14, 5); + }); +}); diff --git a/packages/melonjs/tests/lighting3d.spec.js b/packages/melonjs/tests/lighting3d.spec.js new file mode 100644 index 0000000000..1b5074a282 --- /dev/null +++ b/packages/melonjs/tests/lighting3d.spec.js @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { Color, Light3d, LightingEnvironment } from "../src/index.js"; +import { MAX_LIGHTS } from "../src/video/webgl/lighting/constants.ts"; +import litFrag from "../src/video/webgl/shaders/mesh-lit.frag"; + +/** + * Unit tests for the 3D mesh lighting primitives (Light3d + LightingEnvironment). + * Pure JS — no WebGL needed (the shader path is exercised end-to-end in the + * gltf example / Playwright). + */ +describe("Light3d", () => { + it("defaults: directional, white, intensity 1, +Y direction", () => { + const l = new Light3d(); + expect(l.type).toBe("directional"); + expect(l.intensity).toBe(1); + expect(l.color.r).toBe(255); + expect(l.color.g).toBe(255); + expect(l.color.b).toBe(255); + expect([l.direction.x, l.direction.y, l.direction.z]).toEqual([0, 1, 0]); + }); + + it("normalizes the direction on construction", () => { + const l = new Light3d({ direction: [0, 5, 0] }); + expect(l.direction.length()).toBeCloseTo(1, 5); + expect(l.direction.y).toBeCloseTo(1, 5); + }); + + it("accepts color as a CSS string", () => { + const l = new Light3d({ color: "#ff0000" }); + expect([l.color.r, l.color.g, l.color.b]).toEqual([255, 0, 0]); + }); + + it("accepts color as a glTF [r,g,b] array in 0..1", () => { + const l = new Light3d({ color: [1, 0.5, 0] }); + expect(l.color.r).toBe(255); + expect(l.color.g).toBeGreaterThan(120); // ~127 + expect(l.color.b).toBe(0); + }); + + it("accepts color as a Color instance (passthrough)", () => { + const c = new Color(10, 20, 30, 1); + const l = new Light3d({ color: c }); + expect(l.color).toBe(c); + }); + + it("carries type + position for a future point release", () => { + const l = new Light3d({ type: "point", position: [1, 2, 3] }); + expect(l.type).toBe("point"); + expect([l.position.x, l.position.y, l.position.z]).toEqual([1, 2, 3]); + }); +}); + +describe("LightingEnvironment", () => { + it("add / remove / clear, with no duplicate adds", () => { + const env = new LightingEnvironment(); + const a = new Light3d(); + env.addLight(a); + env.addLight(a); // dup ignored + expect(env.lights.length).toBe(1); + env.addLight(new Light3d()); + expect(env.lights.length).toBe(2); + env.removeLight(a); + expect(env.lights.length).toBe(1); + env.removeLight(a); // removing absent is safe + expect(env.lights.length).toBe(1); + env.clear(); + expect(env.lights.length).toBe(0); + }); + + it("exposes a shared default instance", () => { + expect(LightingEnvironment.default).toBeInstanceOf(LightingEnvironment); + }); + + it("pack(): negates + normalizes direction, premultiplies color by intensity", () => { + const env = new LightingEnvironment(); + env.addLight( + new Light3d({ direction: [0, 2, 0], color: [1, 0, 0], intensity: 2 }), + ); + const p = env.pack(); + expect(p.count).toBe(1); + // surface→light = -travel, normalized: [0, 2, 0] → travel +Y → store -Y + expect(p.directions[0]).toBeCloseTo(0, 5); + expect(p.directions[1]).toBeCloseTo(-1, 5); + expect(p.directions[2]).toBeCloseTo(0, 5); + // color (1,0,0) × intensity 2 + expect(p.colors[0]).toBeCloseTo(2, 5); + expect(p.colors[1]).toBeCloseTo(0, 5); + expect(p.colors[2]).toBeCloseTo(0, 5); + }); + + it("pack(): ambient is color × ambientIntensity", () => { + const env = new LightingEnvironment(); + env.setAmbient("#808080", 0.5); // 128/255 ≈ 0.502 + const p = env.pack(); + expect(p.ambient[0]).toBeCloseTo((128 / 255) * 0.5, 2); + }); + + it("ADVERSARIAL: pack() skips non-directional lights", () => { + const env = new LightingEnvironment(); + env.addLight(new Light3d({ type: "point" })); + env.addLight(new Light3d({ type: "directional" })); + expect(env.pack().count).toBe(1); // only the directional one + }); + + it("ADVERSARIAL: pack() clamps to MAX_LIGHTS", () => { + const env = new LightingEnvironment(); + for (let i = 0; i < MAX_LIGHTS + 4; i++) { + env.addLight(new Light3d()); + } + expect(env.pack().count).toBe(MAX_LIGHTS); + }); + + it("ADVERSARIAL: pack() reuses its buffers (later state overwrites)", () => { + const env = new LightingEnvironment(); + const light = new Light3d({ direction: [1, 0, 0], intensity: 1 }); + env.addLight(light); + const p1 = env.pack(); + expect(p1.directions[0]).toBeCloseTo(-1, 5); + // mutate the light at runtime and re-pack — same buffer, new values + light.direction.set(0, 0, 1); + const p2 = env.pack(); + expect(p2.directions).toBe(p1.directions); // same Float32Array + expect(p2.directions[0]).toBeCloseTo(0, 5); + expect(p2.directions[2]).toBeCloseTo(-1, 5); // normalized + negated + }); + + it("ADVERSARIAL: a runtime non-unit direction is normalized in pack()", () => { + const env = new LightingEnvironment(); + const light = new Light3d(); + light.direction.set(0, 0, 9); // not unit + env.addLight(light); + const p = env.pack(); + const len = Math.hypot(p.directions[0], p.directions[1], p.directions[2]); + expect(len).toBeCloseTo(1, 5); + }); +}); + +describe("lit mesh shader — MAX_LIGHTS drift guard", () => { + it("C2: sources the light-array size from the constant, not a hardcoded literal", () => { + // the shader must carry the replacement token (resolved from MAX_LIGHTS + // by LitMeshBatcher), so the GLSL array size can't drift from the packer + expect(litFrag).toContain("__MAX_LIGHTS__"); + // replaceAll (not replace) — the token appears at multiple use sites and + // the preprocessor may duplicate it; every one must be resolved or the + // shader fails to compile ('undeclared identifier'). + const resolved = litFrag.replaceAll("__MAX_LIGHTS__", String(MAX_LIGHTS)); + expect(resolved).not.toContain("__MAX_LIGHTS__"); + expect(resolved).toContain(`uLightDir[${MAX_LIGHTS}]`); + }); +}); diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js index 55b32d9ec1..fe8b2c5a74 100644 --- a/packages/melonjs/tests/mesh.spec.js +++ b/packages/melonjs/tests/mesh.spec.js @@ -12,10 +12,183 @@ import { Vector3d, video, } from "../src/index.js"; -import { normalizeVertices, projectVertices } from "../src/math/vertex.ts"; +import { + boundingRadius, + normalizeVertices, + projectVertices, + transformedBounds, +} from "../src/math/vertex.ts"; // ── Vertex Utilities ──────────────────────────────────────────────────────── +describe("transformedBounds()", () => { + const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + it("extends an AABB by identity-transformed vertices", () => { + const v = new Float32Array([-1, 2, 0, 3, -4, 5]); + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 2, IDENTITY, min, max); + expect(min).toEqual([-1, -4, 0]); + expect(max).toEqual([3, 2, 5]); + }); + + it("applies the matrix translation + scale", () => { + // scale 2, translate (10, 20, 30) — column-major + const m = [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 10, 20, 30, 1]; + const v = new Float32Array([1, 1, 1]); + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 1, m, min, max); + expect(min).toEqual([12, 22, 32]); + expect(max).toEqual([12, 22, 32]); + }); + + it("accumulates across multiple calls (multi-node scene)", () => { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(new Float32Array([0, 0, 0]), 1, IDENTITY, min, max); + transformedBounds(new Float32Array([5, -3, 7]), 1, IDENTITY, min, max); + expect(min).toEqual([0, -3, 0]); + expect(max).toEqual([5, 0, 7]); + }); + + // ── adversarial ────────────────────────────────────────────────────── + + it("ADVERSARIAL: captures ROTATED extents (catches row/col-major transposition)", () => { + // +90° about Z (column-major): (x,y,z) → (-y, x, z). + // A transposed implementation (reading rows, not columns) would give a + // different result — the prior tests, being diagonal-only, can't see it. + const m = [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const v = new Float32Array([2, 0, 0, 0, 3, 0]); // → (0,2,0) and (-3,0,0) + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 2, m, min, max); + expect(min[0]).toBeCloseTo(-3, 5); + expect(min[1]).toBeCloseTo(0, 5); + expect(max[0]).toBeCloseTo(0, 5); + expect(max[1]).toBeCloseTo(2, 5); + }); + + it("ADVERSARIAL: applies off-diagonal (shear) columns, not just the diagonal", () => { + // shear X by 0.5·Y (m4 set). A diagonal-only impl would drop the term + // and place the vertex at x=0 instead of x=1. + const m = [1, 0, 0, 0, 0.5, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const v = new Float32Array([0, 2, 0]); // x' = 0 + 0.5·2 = 1 → (1, 2, 0) + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 1, m, min, max); + expect(min).toEqual([1, 2, 0]); + expect(max).toEqual([1, 2, 0]); + }); + + it("ADVERSARIAL: reads only the first `count` vertices, ignoring trailing data", () => { + const v = new Float32Array([1, 1, 1, 99, 99, 99]); // 2nd vert must be ignored + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 1, IDENTITY, min, max); + expect(min).toEqual([1, 1, 1]); + expect(max).toEqual([1, 1, 1]); + }); + + it("ADVERSARIAL: negative (mirroring) scale keeps min ≤ max ordering", () => { + const m = [-1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1]; + const v = new Float32Array([2, 3, 4, -2, -3, -4]); // → (-2,-3,-4),(2,3,4) + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 2, m, min, max); + expect(min).toEqual([-2, -3, -4]); + expect(max).toEqual([2, 3, 4]); + }); + + it("ADVERSARIAL: count = 0 leaves the seeded AABB untouched", () => { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + transformedBounds(new Float32Array([5, 5, 5]), 0, IDENTITY, min, max); + expect(min).toEqual([Infinity, Infinity, Infinity]); + expect(max).toEqual([-Infinity, -Infinity, -Infinity]); + }); + + it("ADVERSARIAL: accumulates across nodes with DIFFERENT transforms", () => { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + // node A: translated +X; node B: translated -Y — union must span both + transformedBounds( + new Float32Array([0, 0, 0]), + 1, + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 0, 0, 1], + min, + max, + ); + transformedBounds( + new Float32Array([0, 0, 0]), + 1, + [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -7, 0, 1], + min, + max, + ); + expect(min).toEqual([0, -7, 0]); + expect(max).toEqual([10, 0, 0]); + }); +}); + +describe("boundingRadius()", () => { + it("returns the distance to the farthest vertex (no matrix)", () => { + const v = new Float32Array([1, 0, 0, 0, 3, 0, 0, 0, 0]); + expect(boundingRadius(v, 3)).toBeCloseTo(3, 5); + }); + + it("applies the matrix rotation/scale but ignores translation", () => { + // scale 2 + translate (100,100,100): radius must reflect scale (2) + // not the translation, since it is measured around the node origin + const m = [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 100, 100, 100, 1]; + const v = new Float32Array([1, 0, 0]); + expect(boundingRadius(v, 1, m)).toBeCloseTo(2, 5); + }); + + it("is zero for a single vertex at the origin", () => { + expect(boundingRadius(new Float32Array([0, 0, 0]), 1)).toBe(0); + }); + + // ── adversarial ────────────────────────────────────────────────────── + + it("ADVERSARIAL: is INVARIANT under pure rotation (length is preserved)", () => { + // +90° about Z: a rotation must not change the radius at all. + const m = [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const v = new Float32Array([2, 0, 0]); + expect(boundingRadius(v, 1, m)).toBeCloseTo(2, 5); + // same value as the un-transformed radius + expect(boundingRadius(v, 1)).toBeCloseTo(2, 5); + }); + + it("ADVERSARIAL: picks the farthest vertex AFTER non-uniform scale, not before", () => { + // pre-scale both vertices are length 1 (a tie). Scaling Y by 10 makes + // the y-axis vertex the farthest — the radius must reflect the + // post-transform distance, not the pre-transform one. + const m = [1, 0, 0, 0, 0, 10, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const v = new Float32Array([1, 0, 0, 0, 1, 0]); + expect(boundingRadius(v, 2, m)).toBeCloseTo(10, 5); + }); + + it("ADVERSARIAL: reads only the first `count` vertices", () => { + // the far (100,0,0) vertex must be ignored at count = 1 + const v = new Float32Array([1, 0, 0, 100, 0, 0]); + expect(boundingRadius(v, 1)).toBeCloseTo(1, 5); + }); + + it("ADVERSARIAL: returns 0 for count = 0", () => { + expect(boundingRadius(new Float32Array([9, 9, 9]), 0)).toBe(0); + }); + + it("ADVERSARIAL: ignores even a huge translation (radius is origin-centered)", () => { + // translation in the millions must not inflate the radius — it is + // measured around the transform's own origin (for the cull sphere). + const m = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1e6, -1e6, 1e6, 1]; + const v = new Float32Array([3, 0, 0]); + expect(boundingRadius(v, 1, m)).toBeCloseTo(3, 5); + }); +}); + describe("normalizeVertices()", () => { it("centers vertices at the origin", () => { const v = new Float32Array([1, 2, 3, 5, 6, 7]); @@ -1032,4 +1205,98 @@ describe("Mesh × Camera3d world-space path", () => { expect(m.vertices[1]).toBeCloseTo(-24, 4); expect(m.vertices[4]).toBeCloseTo(24, 4); }); + + // ── review fixes ────────────────────────────────────────────────────── + + const litPyramid = () => { + const s = buildPyramidSettings(); + s.normals = new Float32Array(s.vertices.length); // 5 verts × 3 + for (let i = 0; i < s.normals.length; i += 3) { + s.normals[i + 1] = 1; // +Y + } + s.rightHanded = true; + return s; + }; + + it("C1: an UNLIT world-space mesh skips normal projection (perf gate)", () => { + const m = new Mesh(0, 0, litPyramid()); + m.onActivateEvent(); // _useWorldSpace = true + m.lit = false; + m.draw(stubRenderer); // Camera3d world-space path, but unlit + // normals must stay zero — the unlit batcher never reads them + expect( + Array.from(m.normals).every((v) => { + return v === 0; + }), + ).toBe(true); + }); + + it("C1: a LIT world-space mesh DOES project normals", () => { + const m = new Mesh(0, 0, litPyramid()); + m.onActivateEvent(); + m.lit = true; + m.draw(stubRenderer); + // +Y source normals → world +Y after the rightHanded Y-flip is (0,-1,0) + expect( + Array.from(m.normals).some((v) => { + return v !== 0; + }), + ).toBe(true); + expect(m.normals[1]).toBeCloseTo(-1, 5); // first normal's y + }); + + it("L1: a degenerate (zero) source normal projects to unit +Y, not NaN/zero", () => { + const s = buildPyramidSettings(); + s.normals = new Float32Array(s.vertices.length); // all zero + s.rightHanded = true; + const m = new Mesh(0, 0, s); + m._projectNormalsWorld(); + expect([m.normals[0], m.normals[1], m.normals[2]]).toEqual([0, 1, 0]); + expect(Number.isNaN(m.normals[0])).toBe(false); + }); + + it("H2: a rightHanded mesh skips the reversed-index allocation", () => { + const m = new Mesh(0, 0, litPyramid()); // rightHanded: true + m.onActivateEvent(); + m.draw(stubRenderer); + expect(m._indicesReversed).toBeUndefined(); // never allocated + expect(m.indices).toBe(m._indicesOriginal); // uses original winding + }); + + it("H2: a non-rightHanded reversed buffer matches the source index type", () => { + const m = new Mesh(0, 0, buildPyramidSettings()); // rightHanded: false + // force a Uint32 source to prove no Uint16 truncation in the copy + m._indicesOriginal = new Uint32Array([0, 1, 2, 3, 4, 5]); + m._setupWorldSpace(); + expect(m._indicesReversed).toBeInstanceOf(Uint32Array); + // winding reversed per triangle + expect(Array.from(m._indicesReversed)).toEqual([0, 2, 1, 3, 5, 4]); + }); + + it("preserves a Uint32 index buffer — no >65535 truncation (large meshes)", () => { + // 70000 wraps to 4464 under a Uint16 coercion; it must survive intact + const idx = new Uint32Array([0, 1, 70000]); + const m = new Mesh(0, 0, { + vertices: new Float32Array(3), + uvs: new Float32Array(2), + indices: idx, + width: 10, + normalize: false, + }); + expect(m.indices).toBeInstanceOf(Uint32Array); + expect(m.indices[2]).toBe(70000); // not truncated to 4464 + expect(m.indices).toBe(idx); // kept by reference, no copy + }); + + it("materializes a plain index array as Uint16 (small-mesh default)", () => { + const m = new Mesh(0, 0, { + vertices: new Float32Array(3), + uvs: new Float32Array(2), + indices: [0, 1, 2], + width: 10, + normalize: false, + }); + expect(m.indices).toBeInstanceOf(Uint16Array); + expect(Array.from(m.indices)).toEqual([0, 1, 2]); + }); }); diff --git a/packages/melonjs/tests/octree.spec.js b/packages/melonjs/tests/octree.spec.js index 2b9ca0bf35..4182d68a03 100644 --- a/packages/melonjs/tests/octree.spec.js +++ b/packages/melonjs/tests/octree.spec.js @@ -10,6 +10,7 @@ import { video, World, } from "../src/index.js"; +import { transformedBounds } from "../src/math/vertex.ts"; import { AABB3d } from "../src/physics/broadphase/aabb3d.ts"; import Octree from "../src/physics/broadphase/octree.ts"; @@ -136,6 +137,83 @@ describe("Octree", () => { expect(b.max).toEqual({ x: 4, y: 5, z: 6 }); }); + describe("fromVertices (flat-buffer bridge → transformedBounds)", () => { + it("builds the AABB from a flat vertex buffer (no matrix = identity)", () => { + const v = new Float32Array([-1, 2, 0, 3, -4, 5]); + const a = new AABB3d().fromVertices(v, 2); + expect(a.min).toEqual({ x: -1, y: -4, z: 0 }); + expect(a.max).toEqual({ x: 3, y: 2, z: 5 }); + }); + + it("returns `this` for chaining", () => { + const a = new AABB3d(); + expect(a.fromVertices(new Float32Array([0, 0, 0]), 1)).toBe(a); + }); + + it("applies a matrix (scale + translate)", () => { + const m = [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 10, 20, 30, 1]; + const a = new AABB3d().fromVertices(new Float32Array([1, 1, 1]), 1, m); + expect(a.min).toEqual({ x: 12, y: 22, z: 32 }); + expect(a.max).toEqual({ x: 12, y: 22, z: 32 }); + }); + + it("ADVERSARIAL: captures ROTATED extents (transposition catch)", () => { + // +90° about Z: (x,y,z) → (-y, x, z) + const m = [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const v = new Float32Array([2, 0, 0, 0, 3, 0]); // → (0,2,0),(-3,0,0) + const a = new AABB3d().fromVertices(v, 2, m); + expect(a.min.x).toBeCloseTo(-3, 5); + expect(a.min.y).toBeCloseTo(0, 5); + expect(a.max.x).toBeCloseTo(0, 5); + expect(a.max.y).toBeCloseTo(2, 5); + }); + + it("ADVERSARIAL: REPLACES prior bounds (does not accumulate)", () => { + const a = new AABB3d(); + a.setMinMax(-999, -999, -999, 999, 999, 999); // stale, must be gone + a.fromVertices(new Float32Array([1, 1, 1]), 1); + expect(a.min).toEqual({ x: 1, y: 1, z: 1 }); + expect(a.max).toEqual({ x: 1, y: 1, z: 1 }); + }); + + it("ADVERSARIAL: reads only the first `count` vertices", () => { + const v = new Float32Array([1, 1, 1, 99, 99, 99]); + const a = new AABB3d().fromVertices(v, 1); + expect(a.max).toEqual({ x: 1, y: 1, z: 1 }); + }); + + it("ADVERSARIAL: count = 0 yields the empty (non-finite) AABB", () => { + const a = new AABB3d().fromVertices(new Float32Array([5, 5, 5]), 0); + expect(a.min.x).toBe(Infinity); + expect(a.max.x).toBe(-Infinity); + expect(a.isFinite()).toBe(false); + }); + + it("delegates to transformedBounds (results match the helper exactly)", () => { + // proves fromVertices is a thin bridge, not a re-implementation + const v = new Float32Array([-2, 7, 1, 5, -3, 9, 0, 0, -4, 11, 2, 6]); + const m = [1.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 3, 0, -8, 4, 2, 1]; + const refMin = [Infinity, Infinity, Infinity]; + const refMax = [-Infinity, -Infinity, -Infinity]; + transformedBounds(v, 4, m, refMin, refMax); + const a = new AABB3d().fromVertices(v, 4, m); + expect([a.min.x, a.min.y, a.min.z]).toEqual(refMin); + expect([a.max.x, a.max.y, a.max.z]).toEqual(refMax); + }); + + it("ADVERSARIAL: scratch reuse — two boxes don't bleed into each other", () => { + // fromVertices uses module scratch arrays; a second call must not + // corrupt the first box's result + const a = new AABB3d().fromVertices(new Float32Array([1, 1, 1]), 1); + const b = new AABB3d().fromVertices( + new Float32Array([100, 100, 100]), + 1, + ); + expect(a.max).toEqual({ x: 1, y: 1, z: 1 }); // unchanged by b + expect(b.max).toEqual({ x: 100, y: 100, z: 100 }); + }); + }); + it("survives NaN / Infinity bounds — flagged by isFinite", () => { const a = new AABB3d(); a.setMinMax(0, 0, 0, Infinity, 10, 10); diff --git a/packages/melonjs/tests/webgl_mesh_anchor.spec.js b/packages/melonjs/tests/webgl_mesh_anchor.spec.js new file mode 100644 index 0000000000..b0b7a86e0a --- /dev/null +++ b/packages/melonjs/tests/webgl_mesh_anchor.spec.js @@ -0,0 +1,377 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + boot, + Camera3d, + Matrix3d, + Mesh, + Vector3d, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * Regression tests for the glTF "prop sinks into the platform" bug. + * + * Symptom: under `Camera3d`, scene props rendered LOWER than they should, + * overlapping the platform they rested on — even though every parsed world + * transform was numerically identical to the authoring tool (Blender), and + * `Mesh._projectVerticesWorld` emitted correct world coordinates. + * + * Root cause (the reason it hid for so long): the bug was NOT in placement + * or in `_projectVerticesWorld` — it was one step DOWNSTREAM, in the view + * matrix the mesh batcher applies. `Renderable.preDraw` ran + * `renderer.translate(-ax, -ay)` (the anchor-point offset, `ax = width * + * anchorPoint.x`) into `renderer.currentTransform`, and the `MeshBatcher` + * uses that very transform as its `viewMatrix`. The world-space (Camera3d) + * path already bakes the final world position into the vertices, so that + * anchor translate was applied a SECOND time — and because each scene mesh + * sizes its bounds box (`width`/`height`) per node, the offset DIFFERED per + * mesh. A big platform shifted up a lot, a small prop shifted up a little → + * they drifted apart and overlapped. + * + * Every diagnostic probe that inspected `_projectVerticesWorld` output saw + * correct coordinates, so the tests below deliberately exercise the FULL + * draw path (`preDraw` → `draw` → the live view matrix) — that's the only + * place the bug is observable. + * + * The fix: a `Renderable#applyAnchorTransform` opt-out flag (default `true`, + * so sprites/2D are unchanged). `Mesh.preDraw` sets it to `false` on the + * world-space path, so the anchor translate is SUPPRESSED rather than + * compensated — width never enters the world-space pipeline at all. This + * mirrors how Three.js/pixi3d keep `anchor`/`center` on the Sprite class + * only, never on the 3D node. The invariant these tests lock in: **the net + * rendered position of a world-space mesh must not depend on its + * `width`/`height`**, and **the flag is exactly what gates that.** + * + * Like the sibling `webgl_mesh_depth.spec.js`, every test skips when WebGL2 + * isn't available (headless CI without GPU flags). + */ +describe("Mesh anchor-point leak under Camera3d (glTF prop-sink bug)", () => { + let renderer; + + beforeAll(async () => { + await boot(); + try { + video.init(128, 128, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + } catch { + // genuine WebGL absence — tests skip below + } + if ( + video.renderer instanceof WebGLRenderer && + video.renderer.WebGLVersion === 2 + ) { + renderer = video.renderer; + } + }); + + afterAll(() => { + try { + video.init(128, 128, { parent: "screen", renderer: video.AUTO }); + } catch { + // ignore — nothing to restore if init never succeeded + } + }); + + const requireWebGL2 = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL2 renderer not available in this environment"); + } + }; + + // a 1×1 solid-color canvas, used as the mesh's baseColor texture + const colorTex = (r, g, b) => { + const c = document.createElement("canvas"); + c.width = 1; + c.height = 1; + const x = c.getContext("2d"); + x.fillStyle = `rgb(${r},${g},${b})`; + x.fillRect(0, 0, 1, 1); + return c; + }; + + // a unit quad (half-extent 20) centered on the local origin, z = 0 — the + // same shape a flat glTF prop reduces to. `scale: 1` so the on-screen + // size is identical regardless of the `width` bounds value under test. + const QUAD_VERTS = [-20, -20, 0, 20, -20, 0, 20, 20, 0, -20, 20, 0]; + const QUAD_UVS = [0, 0, 1, 0, 1, 1, 0, 1]; + const QUAD_IDX = [0, 1, 2, 0, 2, 3]; + + const makeMesh = (width, rgb, x = 64, y = 64, z = 0) => { + const m = new Mesh(0, 0, { + vertices: new Float32Array(QUAD_VERTS), + uvs: new Float32Array(QUAD_UVS), + indices: new Uint16Array(QUAD_IDX), + texture: colorTex(rgb[0], rgb[1], rgb[2]), + // `width` === `height` === the per-node bounds box. The bug made + // the rendered position depend on THIS value; the fix must not. + width, + height: width, + scale: 1, + normalize: false, + rightHanded: true, + cullBackFaces: false, + }); + m.pos.set(x, y); + m.depth = z; + m.currentTransform.identity(); + // mark world-space (as `onActivateEvent` would under a Camera3d stage) + // so `Mesh.preDraw` opts out of the anchor transform. Tests that want + // the 2D path flip this back to false. + m._useWorldSpace = true; + return m; + }; + + // a real Camera3d so `Mesh.draw` takes the world-space branch + let cam; + beforeAll(() => { + if (renderer !== undefined) { + cam = new Camera3d(0, 0, 128, 128); + } + }); + + // run the FULL draw path against an identity camera view, then apply the + // live view matrix exactly as the mesh batcher does (per-vertex). The + // result is the position each vertex actually reaches on the GPU. Driving + // the real `preDraw` is essential — that is where the base class would + // inject the anchor translate into the view matrix, and where `Mesh` + // opts out of it for the world-space path. + const _v = new Vector3d(); + const renderedVertices = (mesh) => { + // identity camera view: isolate the anchor effect from any scroll + renderer.currentTransform.identity(); + mesh.preDraw(renderer); // world-space mesh → anchor transform suppressed + mesh.draw(renderer, cam); // world-space projection emits final world coords + const view = renderer.currentTransform; // == identity (no anchor leak) + const out = []; + for (let i = 0; i < mesh.vertices.length; i += 3) { + _v.set(mesh.vertices[i], mesh.vertices[i + 1], mesh.vertices[i + 2]); + view.apply(_v); + out.push( + Math.round(_v.x * 1000) / 1000, + Math.round(_v.y * 1000) / 1000, + Math.round(_v.z * 1000) / 1000, + ); + } + mesh.postDraw(renderer); // balances preDraw's save() + return out; + }; + + // ────────────────────────────────────────────────────────────────────── + // The core invariant — net rendered position is width-independent + // ────────────────────────────────────────────────────────────────────── + + it("net rendered position is INDEPENDENT of the mesh width/height", (ctx) => { + requireWebGL2(ctx); + // identical geometry + world placement, only the bounds box differs. + // width never enters the world-space path (the anchor offset is + // suppressed entirely, not compensated), so there is no float + // round-trip — even an odd, pathological 250:1 width is EXACT. + const small = renderedVertices(makeMesh(40, [200, 50, 50])); + const big = renderedVertices(makeMesh(900, [50, 200, 50])); + const huge = renderedVertices(makeMesh(4000, [50, 50, 200])); + const odd = renderedVertices(makeMesh(9999, [80, 80, 80])); + // pre-fix: each shifted by -width/2 → all differ by hundreds of px. + // post-fix: width plays no part → all identical, to the bit. + expect(big).toEqual(small); + expect(huge).toEqual(small); + expect(odd).toEqual(small); + }); + + it("ADVERSARIAL: a 250:1 width ratio still renders at the same spot (no fractional drift)", (ctx) => { + requireWebGL2(ctx); + // a coin (tiny bounds) and a long platform (huge bounds) sharing a + // world position is exactly the failing diorama case + const coin = renderedVertices(makeMesh(16, [255, 215, 0])); + const platform = renderedVertices(makeMesh(4000, [120, 80, 40])); + expect(platform).toEqual(coin); + }); + + it("ADVERSARIAL: the relative gap between a prop and the platform it sits on survives rendering", (ctx) => { + requireWebGL2(ctx); + // platform top and prop bottom are coplanar in WORLD space (gap = 0), + // the platform far larger than the prop. The render-space Y of the + // shared contact edge must come out EQUAL — pre-fix the larger + // platform shifted up more than the prop, opening a negative gap + // (prop sinks below the platform top). + const platform = renderedVertices(makeMesh(4000, [120, 80, 40], 64, 64, 0)); + const prop = renderedVertices(makeMesh(60, [220, 40, 40], 64, 64, 0)); + // lowest render-Y (top edge) of each — both quads are centered on the + // same world pos, so post-fix their top edges coincide exactly + const topY = (verts) => { + return Math.min(verts[1], verts[4], verts[7], verts[10]); + }; + const botY = (verts) => { + return Math.max(verts[1], verts[4], verts[7], verts[10]); + }; + // the platform is wider but the QUAD geometry is the same size (scale 1), + // so after the fix the two quads occupy the identical render rectangle + expect(topY(prop)).toBeCloseTo(topY(platform), 3); + expect(botY(prop)).toBeCloseTo(botY(platform), 3); + }); + + // ────────────────────────────────────────────────────────────────────── + // Pixel-level proof through the real GPU + // ────────────────────────────────────────────────────────────────────── + + const setupOrtho = () => { + const proj = new Matrix3d(); + proj.ortho(0, 128, 128, 0, -1000, 1000); + renderer.setProjection(proj); + }; + + const drawFull = (mesh) => { + renderer.currentTransform.identity(); + mesh.preDraw(renderer); + mesh.draw(renderer, cam); + mesh.postDraw(renderer); + }; + + const readCenter = () => { + const gl = renderer.gl; + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(64, 64, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + + it("ADVERSARIAL: a huge-width mesh still covers its true world center pixel", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + const gl = renderer.gl; + // neutral background + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + // small red quad at the canvas center — establishes the center is covered + drawFull(makeMesh(40, [220, 20, 20])); + let px = readCenter(); + expect(px[0]).toBeGreaterThan(150); // red present + + // HUGE-width green quad at the SAME world center. Pre-fix its anchor + // offset (≈ -2000 px) flings it off-screen, leaving the center red. + // Post-fix it lands dead center and overwrites with green. + drawFull(makeMesh(4000, [20, 220, 20])); + px = readCenter(); + expect(px[1]).toBeGreaterThan(150); // green now wins the center + expect(px[0]).toBeLessThan(120); // red was overdrawn + }); + + // ────────────────────────────────────────────────────────────────────── + // The flag itself — directly toggled, WITH vs WITHOUT + // ────────────────────────────────────────────────────────────────────── + + // Render a world-space mesh with the anchor flag ON or OFF, returning the + // first vertex as it reaches the GPU (after the live view matrix). The flag + // is driven through the REAL `Mesh.preDraw` via `_useWorldSpace` (`draw` + // still takes the world-space path because we pass a `Camera3d`), so we + // observe the production code's behavior, not a stubbed flag. + const firstVertexWithFlag = (width, anchorOn) => { + const m = makeMesh(width, [255, 255, 255], 50, 70, 10); + // Mesh.preDraw sets applyAnchorTransform = (_useWorldSpace !== true) + m._useWorldSpace = anchorOn ? false : true; + renderer.currentTransform.identity(); + m.preDraw(renderer); + expect(m.applyAnchorTransform).toBe(anchorOn); // flag wired as intended + m.draw(renderer, cam); // Camera3d → world-space projection regardless + const view = renderer.currentTransform; + _v.set(m.vertices[0], m.vertices[1], m.vertices[2]); + view.apply(_v); + const out = { x: _v.x, y: _v.y }; + m.postDraw(renderer); + return out; + }; + + it("ADVERSARIAL with vs without the flag: ON reproduces the bug, OFF fixes it", (ctx) => { + requireWebGL2(ctx); + const width = 4000; + const ax = width * 0.5; // anchorPoint.x === 0.5 + const ay = width * 0.5; + + // flag OFF (applyAnchorTransform = false): the world-space mesh lands + // at its true world position — the fix. + const off = firstVertexWithFlag(width, false); + + // flag ON (applyAnchorTransform = true): the base anchor translate + // leaks `(-ax, -ay)` into the view matrix — this is the ORIGINAL BUG. + const on = firstVertexWithFlag(width, true); + + // the ON case is shifted from the OFF case by exactly the anchor + // offset (thousands of px for this width) — proving the flag is what + // gates the bug, and that OFF is the correct position. + expect(off.x - on.x).toBeCloseTo(ax, 3); + expect(off.y - on.y).toBeCloseTo(ay, 3); + expect(Math.abs(off.x - on.x)).toBeGreaterThan(100); // not a rounding nit + }); + + it("the flag is the ONLY width-dependent term: OFF is width-invariant, ON is not", (ctx) => { + requireWebGL2(ctx); + // OFF: position identical across wildly different widths + const offSmall = firstVertexWithFlag(40, false); + const offHuge = firstVertexWithFlag(9999, false); + expect(offHuge.x).toBeCloseTo(offSmall.x, 3); + expect(offHuge.y).toBeCloseTo(offSmall.y, 3); + // ON: position diverges with width (the bug scales with the bounds box) + const onSmall = firstVertexWithFlag(40, true); + const onHuge = firstVertexWithFlag(9999, true); + expect(Math.abs(onHuge.x - onSmall.x)).toBeGreaterThan(1000); + }); + + // ────────────────────────────────────────────────────────────────────── + // Wiring — Mesh.preDraw derives the flag from the active render path + // ────────────────────────────────────────────────────────────────────── + + it("Mesh.preDraw opts the world-space path OUT of the anchor, keeps it for 2D", (ctx) => { + requireWebGL2(ctx); + const ws = makeMesh(80, [255, 255, 255]); // _useWorldSpace = true + ws.preDraw(renderer); + expect(ws.applyAnchorTransform).toBe(false); // 3D → suppressed + ws.postDraw(renderer); + + const twoD = makeMesh(80, [255, 255, 255]); + twoD._useWorldSpace = false; + twoD.preDraw(renderer); + expect(twoD.applyAnchorTransform).toBe(true); // 2D → anchor kept + twoD.postDraw(renderer); + }); + + it("the base class default is applyAnchorTransform = true (sprites/2D unchanged)", (ctx) => { + requireWebGL2(ctx); + // the fix must not change the default for sprites / 2D renderables — + // the base class ships the anchor ON. A freshly constructed Mesh, + // before any world-space preDraw has run, carries the base default. + const fresh = makeMesh(80, [255, 255, 255]); + expect(fresh.applyAnchorTransform).toBe(true); + }); + + it("world-space draw() emits pure world coordinates (anchor never baked into vertices)", (ctx) => { + requireWebGL2(ctx); + const m = makeMesh(80, [255, 255, 255], 50, 70, 10); + // reference: the bare world-space projection + m._projectVerticesWorld(m.pos.x, m.pos.y, m.depth); + const pureWorld = Array.from(m.vertices); + // the real draw path must produce the SAME vertices (no offset baked in; + // the anchor is handled by suppressing the view-matrix translate, not by + // moving vertices) + const stub = { drawMesh() {} }; + m.draw(stub, cam); + expect(Array.from(m.vertices)).toEqual(pureWorld); + }); + + it("the 2D (non-Camera3d) path still uses its own projection, unchanged", (ctx) => { + requireWebGL2(ctx); + // without a Camera3d viewport, draw() takes the legacy 2D branch + // (`_projectVertices`), untouched by this fix. + const m = makeMesh(80, [255, 255, 255], 50, 70, 10); + const stub = { drawMesh() {} }; + m._useWorldSpace = false; + m.draw(stub /* no viewport → 2D path */); + const twoD = Array.from(m.vertices); + // the world-space projection produces a different vertex set + m._projectVerticesWorld(m.pos.x, m.pos.y, m.depth); + expect(twoD).not.toEqual(Array.from(m.vertices)); + }); +}); diff --git a/packages/melonjs/tests/webgl_mesh_depth.spec.js b/packages/melonjs/tests/webgl_mesh_depth.spec.js index 8bac590667..6deb5b0b5a 100644 --- a/packages/melonjs/tests/webgl_mesh_depth.spec.js +++ b/packages/melonjs/tests/webgl_mesh_depth.spec.js @@ -116,8 +116,7 @@ describe("Mesh depth handling (issue #1468)", () => { * Vertices are in canvas-space (0..128) so they project 1:1 under * the ortho projection set up in `setupOrthoProjection`. */ - const makeQuadMesh = (cx, cy, z, tintRGBA) => { - const half = 24; + const makeQuadMesh = (cx, cy, z, tintRGBA, half = 24) => { const verts = new Float32Array([ cx - half, cy - half, @@ -362,5 +361,74 @@ describe("Mesh depth handling (issue #1468)", () => { expect(pxLeft[0]).toBeGreaterThan(150); expect(pxRight[2]).toBeGreaterThan(150); }); + + // ── ADVERSARIAL: depth accumulation across many meshes ────────────── + // Each drawMesh flushes immediately (separate draw call), so inter-mesh + // occlusion relies entirely on the depth buffer ACCUMULATING across a + // long run of draws via the one-shot per-target clear. These probe the + // exact glTF-scene shape: many props + platforms in one mesh run. + // + // The lazy depth clear fires on the first MeshBatcher.bind() of a new + // target — i.e. on a batcher *transition*. A real frame always has 2D + // / camera content before the mesh run, so the transition (and thus + // the depth clear) happens every frame. These tests share one renderer + // across the file, and consecutive mesh-only tests would otherwise + // leak stale depth into each other, so `freshFrame()` reproduces a real + // frame: clear (arms the lazy depth clear) + a 2D draw (puts the + // batcher in non-mesh state) so the first mesh below transitions and + // triggers the clear. Without this, the assertions become order- + // dependent (a test artifact, not an engine bug — every case here + // passes in isolation). + const freshFrame = () => { + renderer.backgroundColor.setColor(0, 0, 0, 255); + renderer.clear(); + renderer.setColor("#000000"); + renderer.fillRect(0, 0, 1, 1); // force a non-mesh batcher state + }; + + it("a near mesh survives MANY farther meshes drawn after it", (ctx) => { + requireWebGL2(ctx); + setupOrthoProjection(); + freshFrame(); + // closest mesh first, then 16 farther meshes all over the centre. + // If the depth buffer is wiped/not-accumulated between draws, a + // later far mesh overwrites the near one → red lost. + drawWithTint(makeQuadMesh(64, 64, 100, [220, 20, 20, 255])); + for (let i = 0; i < 16; i++) { + drawWithTint(makeQuadMesh(64, 64, 80 - i * 5, [20, 220, 20, 255])); + } + const px = readCenterPixel(); + expect(px[0]).toBeGreaterThan(150); // red (nearest) still wins + expect(px[1]).toBeLessThan(80); + }); + + it("a large far 'platform' drawn LAST does not overwrite a near 'prop'", (ctx) => { + requireWebGL2(ctx); + setupOrthoProjection(); + freshFrame(); + // THE glTF-scene scenario: a small near prop drawn first, then a + // big far platform (full canvas) drawn LAST that covers the prop's + // screen pixels. Correct depth → the platform's farther z is + // rejected and the prop survives. A depth-accumulation bug → the + // platform paints over the prop → prop "sinks into" the platform. + drawWithTint(makeQuadMesh(64, 64, 50, [220, 20, 20, 255], 12)); // prop + drawWithTint(makeQuadMesh(64, 64, -50, [20, 220, 20, 255], 64)); // platform + const px = readCenterPixel(); + expect(px[0]).toBeGreaterThan(150); // prop (red) survives + expect(px[1]).toBeLessThan(80); + }); + + it("a near 'prop' drawn AFTER a far 'platform' also wins (painter-correct order)", (ctx) => { + requireWebGL2(ctx); + setupOrthoProjection(); + freshFrame(); + // the other order: platform first, prop second. Prop is nearer → + // must paint over the platform regardless of order. + drawWithTint(makeQuadMesh(64, 64, -50, [20, 220, 20, 255], 64)); // platform + drawWithTint(makeQuadMesh(64, 64, 50, [220, 20, 20, 255], 12)); // prop + const px = readCenterPixel(); + expect(px[0]).toBeGreaterThan(150); // prop (red) on top + expect(px[1]).toBeLessThan(80); + }); }); });