From 51c6d6d5cc258f059a4e551db92da95a0eda6b8c Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 19 Jun 2026 12:22:34 +0800 Subject: [PATCH] feat(gltf): glTF/GLB scene loader with lighting, materials, and 3D bounds Load a Blender-authored 3D scene via `me.level.load(...)` like a Tiled map: the GLB auto-registers with the level director and each mesh node becomes a `Mesh` viewed under `Camera3d`. - Parser (loader/parsers/gltf.js): node graph; POSITION / NORMAL / TEXCOORD_0 / COLOR_0 / indices; baseColorTexture + baseColorFactor; perspective cameras; scene bounds; KHR_lights_punctual lights. Uint32 index buffers preserved. - GLTFScene + level-director dispatch (gltf/glb); loader.getGLTF descriptor. - 3D mesh lighting: Light3d + LightingEnvironment (directional, half-Lambert + ambient), auto-loaded from the scene's authored sun; LitMeshBatcher with an opt-in `mesh.lit` so unlit meshes keep the lean path. - Material color: baseColorFactor -> mesh tint; vertex colors (COLOR_0). - Mesh.getBounds3d() + Camera3d.worldToScreen() + AABB3d export/fromVertices + vertex.ts transformedBounds/boundingRadius. - Fix: world-space meshes no longer leak the anchor-point offset into the mesh view matrix (Renderable.applyAnchorTransform) -- props rendered at the wrong position under Camera3d, sinking into the surfaces they rested on. - @melonjs/debug-plugin 16.1.0: 3D bounding-box wireframe overlay for meshes. - New glTF Scene example (Kenney Platformer Kit, CC0). ~50 new tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 20 +- packages/debug-plugin/CHANGELOG.md | 8 + packages/debug-plugin/package.json | 4 +- packages/debug-plugin/src/patches.js | 112 ++ .../public/assets/gltf/platformer-diorama.glb | Bin 0 -> 361296 bytes .../src/examples/gltf/ExampleGltf.tsx | 295 ++++ packages/examples/src/main.tsx | 13 + packages/examples/vite.config.ts | 13 +- packages/melonjs/CHANGELOG.md | 20 + packages/melonjs/package.json | 2 +- packages/melonjs/src/camera/camera3d.ts | 59 + packages/melonjs/src/index.ts | 3 + packages/melonjs/src/level/gltf/GLTFScene.js | 220 +++ packages/melonjs/src/level/level.js | 97 +- .../melonjs/src/level/tiled/TMXTileMap.js | 6 + packages/melonjs/src/lighting/light3d.ts | 97 ++ .../src/lighting/lighting_environment.ts | 152 ++ packages/melonjs/src/loader/cache.js | 3 + packages/melonjs/src/loader/loader.js | 77 + packages/melonjs/src/loader/parsers/gltf.js | 554 +++++++ packages/melonjs/src/loader/parsers/obj.js | 4 +- packages/melonjs/src/math/math.ts | 4 +- packages/melonjs/src/math/vertex.ts | 120 ++ .../melonjs/src/physics/broadphase/aabb3d.ts | 51 +- packages/melonjs/src/renderable/mesh.js | 257 +++- packages/melonjs/src/renderable/renderable.js | 32 +- packages/melonjs/src/video/buffer/vertex.js | 28 + .../video/webgl/batchers/lit_mesh_batcher.js | 88 ++ .../src/video/webgl/batchers/mesh_batcher.js | 190 ++- .../melonjs/src/video/webgl/effects/shine.js | 2 +- .../src/video/webgl/shaders/mesh-lit.frag | 36 + .../src/video/webgl/shaders/mesh-lit.vert | 22 + .../melonjs/src/video/webgl/webgl_renderer.js | 23 +- packages/melonjs/tests/camera3d.spec.js | 81 + packages/melonjs/tests/gltf.spec.js | 1313 +++++++++++++++++ packages/melonjs/tests/lighting3d.spec.js | 150 ++ packages/melonjs/tests/mesh.spec.js | 269 +++- packages/melonjs/tests/octree.spec.js | 78 + .../melonjs/tests/webgl_mesh_anchor.spec.js | 377 +++++ .../melonjs/tests/webgl_mesh_depth.spec.js | 72 +- 40 files changed, 4790 insertions(+), 162 deletions(-) create mode 100644 packages/examples/public/assets/gltf/platformer-diorama.glb create mode 100644 packages/examples/src/examples/gltf/ExampleGltf.tsx create mode 100644 packages/melonjs/src/level/gltf/GLTFScene.js create mode 100644 packages/melonjs/src/lighting/light3d.ts create mode 100644 packages/melonjs/src/lighting/lighting_environment.ts create mode 100644 packages/melonjs/src/loader/parsers/gltf.js create mode 100644 packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js create mode 100644 packages/melonjs/src/video/webgl/shaders/mesh-lit.frag create mode 100644 packages/melonjs/src/video/webgl/shaders/mesh-lit.vert create mode 100644 packages/melonjs/tests/gltf.spec.js create mode 100644 packages/melonjs/tests/lighting3d.spec.js create mode 100644 packages/melonjs/tests/webgl_mesh_anchor.spec.js 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 0000000000000000000000000000000000000000..6a000c0bfeaf1f2c66399e24ba03636d7d32cfdd GIT binary patch literal 361296 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�o;Qb14Ec;1 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 literal 0 HcmV?d00001 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); + }); }); });