From e8b01f444c3d63b41e3cff5c3994ad58fd91d84f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 20 Jun 2026 08:34:05 +0800 Subject: [PATCH] feat(gltf): node animation + Sprite-aligned animation API; Light3d as a world renderable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds glTF node/TRS animation and unifies the 2D/3D animation + lighting APIs. Animation - glTF node animation: assets with animation channels load as a rig-driven GLTFModel that keeps the node hierarchy intact (an animated parent carries its children). Per-frame TRS sampling (LERP/SLERP/STEP; CUBICSPLINE value), allocation-free pose path. Parser emits the node graph + animation clips. - Sprite-aligned API on both Sprite and GLTFModel via a shared parseAnimationOptions helper: setCurrentAnimation(name, { loop, speed, onComplete, next }), getAnimationNames, animationspeed, play/pause/stop (additive + non-breaking on Sprite — legacy string/fn/no-arg forms preserved). Lighting (unify 3D with 2D) - Light3d is now a world Renderable managed exactly like Light2d: add it to the world and the active stage auto-tracks it; ambient is a Light3d type:"ambient". LightingEnvironment removed (folded into stage active-light set + packMeshLights). - Both lights co-located in src/lighting/; Light2d converted to TypeScript. Materials / loader - Mesh.textureRepeat (honors glTF sampler wrapS/wrapT, default REPEAT) so tiling UVs sample correctly instead of clamping flat (tracked limitation: #1503). - External glTF resources: external .bin + image uris resolved relative to the asset URL, crossOrigin forwarded. Fixes - Camera3d.isVisible no longer NaN-culls sizeless grouping containers (and their whole subtree) — infinite-bounds containers are always visible. Example: glTF Animated Model (Kenney Blocky Characters, CC0) with clip selector, speed, play/pause/stop, and drag-to-orbit. ~120 new tests (parser graph/animations/wrap, sampler, GLTFModel, lighting, Sprite play/pause/stop, parseAnimationOptions). Full suite 4237/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 +- .../public/assets/gltf/Textures/texture-a.png | Bin 0 -> 20171 bytes .../examples/public/assets/gltf/character.glb | Bin 0 -> 113596 bytes .../examples/gltf/ExampleGltfCharacter.tsx | 283 ++++++++++ packages/examples/src/main.tsx | 13 + packages/melonjs/CHANGELOG.md | 21 +- packages/melonjs/src/camera/camera3d.ts | 12 + packages/melonjs/src/index.ts | 5 +- packages/melonjs/src/level/gltf/GLTFModel.js | 494 ++++++++++++++++++ packages/melonjs/src/level/gltf/GLTFScene.js | 111 ++-- .../melonjs/src/level/gltf/gltf_sampler.js | 154 ++++++ .../light2d.js => lighting/light2d.ts} | 213 ++++---- packages/melonjs/src/lighting/light3d.ts | 79 ++- .../src/lighting/lighting_environment.ts | 152 ------ packages/melonjs/src/loader/parsers/gltf.js | 470 ++++++++++++----- packages/melonjs/src/loader/parsers/mtl.js | 50 +- packages/melonjs/src/renderable/animation.ts | 66 +++ packages/melonjs/src/renderable/mesh.js | 24 +- packages/melonjs/src/renderable/sprite.js | 117 ++++- packages/melonjs/src/state/stage.ts | 30 +- packages/melonjs/src/system/bootstrap.ts | 2 +- .../video/webgl/batchers/lit_mesh_batcher.js | 31 +- .../src/video/webgl/lighting/pack3d.ts | 87 +++ .../src/video/webgl/shaders/mesh-lit.vert | 2 +- packages/melonjs/tests/animation.spec.js | 80 +++ packages/melonjs/tests/camera3d.spec.js | 20 + packages/melonjs/tests/gltf.spec.js | 324 ++++++++++-- packages/melonjs/tests/gltf_model.spec.js | 313 +++++++++++ packages/melonjs/tests/gltf_sampler.spec.js | 156 ++++++ packages/melonjs/tests/lighting3d.spec.js | 161 +++--- packages/melonjs/tests/lights.spec.js | 53 ++ packages/melonjs/tests/loader.spec.js | 47 ++ packages/melonjs/tests/mesh.spec.js | 41 ++ .../tests/public/data/models/cube-missing.mtl | 3 + .../melonjs/tests/public/data/models/cube.mtl | 3 + .../melonjs/tests/public/data/models/cube.png | Bin 0 -> 87 bytes packages/melonjs/tests/sprite.spec.js | 278 ++++++++++ 37 files changed, 3325 insertions(+), 584 deletions(-) create mode 100644 packages/examples/public/assets/gltf/Textures/texture-a.png create mode 100644 packages/examples/public/assets/gltf/character.glb create mode 100644 packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx create mode 100644 packages/melonjs/src/level/gltf/GLTFModel.js create mode 100644 packages/melonjs/src/level/gltf/gltf_sampler.js rename packages/melonjs/src/{renderable/light2d.js => lighting/light2d.ts} (61%) delete mode 100644 packages/melonjs/src/lighting/lighting_environment.ts create mode 100644 packages/melonjs/src/renderable/animation.ts create mode 100644 packages/melonjs/src/video/webgl/lighting/pack3d.ts create mode 100644 packages/melonjs/tests/animation.spec.js create mode 100644 packages/melonjs/tests/gltf_model.spec.js create mode 100644 packages/melonjs/tests/gltf_sampler.spec.js create mode 100644 packages/melonjs/tests/public/data/models/cube-missing.mtl create mode 100644 packages/melonjs/tests/public/data/models/cube.mtl create mode 100644 packages/melonjs/tests/public/data/models/cube.png diff --git a/README.md b/README.md index ee15e9f4ce..fa04cfe00b 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. -- **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. +- **Scenes, loaded in one call** — `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, lights, and node animation — loads under a `Camera3d`, no per-mesh wiring. Animated models play back through the same animation API as a 2D `Sprite`. - **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. @@ -56,7 +56,7 @@ Graphics - 3D mesh rendering with OBJ/MTL model loading, multi-material support, hardware depth testing, and perspective projection via `Camera3d` - 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 + - **3D** — `Light3d` directional + ambient lights, added to the world like `Light2d` (half-Lambert diffuse + ambient fill, runtime-manipulable for day/night), 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 @@ -95,7 +95,7 @@ UI - `UITextButton` text button with hover, press, and key-bind support — built on `BitmapText` Scenes -- Load a scene in one call with `me.level.load(name)` — 2D Tiled maps and 3D glTF scenes alike, auto-registered on preload +- Load a scene in one call with `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 @@ -113,11 +113,12 @@ Scenes - 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 +- glTF / GLB 3D scenes — load an authored 3D scene with `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 + - Node animation — walk/idle/sprint characters, spinning pickups, doors, lifts — played through the same `setCurrentAnimation` / `play` / `pause` / `stop` API as a 2D `Sprite` + - `.glb` and `.gltf` files, with embedded *or* external buffers & textures - Works with any glTF authoring tool (Blender, Maya, 3ds Max, Cinema 4D, …) Assets @@ -182,7 +183,8 @@ 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` +* [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 `level.load` +* [glTF Animated Model](https://melonjs.github.io/melonJS/examples/#/gltf-character) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/gltf)) — a rigged character (Kenney Blocky Characters) with node animation, driven by the Sprite-aligned `setCurrentAnimation` / `play` / `pause` / `stop` API * [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/examples/public/assets/gltf/Textures/texture-a.png b/packages/examples/public/assets/gltf/Textures/texture-a.png new file mode 100644 index 0000000000000000000000000000000000000000..7c054d8d689811c2f71f8c7828a081b6b77b911d GIT binary patch literal 20171 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 ZKVx2eO1{j7si2`y22WQ%mvv4FO#l%ts0IK4 literal 0 HcmV?d00001 diff --git a/packages/examples/public/assets/gltf/character.glb b/packages/examples/public/assets/gltf/character.glb new file mode 100644 index 0000000000000000000000000000000000000000..2e99b78746751b93308d360f3d55e8906824d1c8 GIT binary patch literal 113596 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 literal 0 HcmV?d00001 diff --git a/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx b/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx new file mode 100644 index 0000000000..5a783a4cc9 --- /dev/null +++ b/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx @@ -0,0 +1,283 @@ +/** + * melonJS — glTF/GLB animated model example. + * Loads a rigged blocky character (Kenney Blocky Characters, CC0) exported as + * GLB via the level director via `level.load`. The asset defines node-TRS + * animation clips (walk, idle, sprint, …) over a rigid node hierarchy — no + * skinning — driven through the Sprite-aligned animation API + * (`setCurrentAnimation` / `play` / `pause` / `stop`). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + Application, + Camera3d as Camera3dClass, + type CanvasRenderer, + type GLTFModel, + 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 — the character is ~1.8 units tall, so this puts it at a +// few hundred pixels on screen. +const SCALE = 200; + +/** + * A screen-fixed sky gradient drawn behind the model. `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, "#2b5876"); + g.addColorStop(0.6, "#5b86a8"); + g.addColorStop(1, "#c7dceb"); + 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 domCleanup: (() => void) | null = null; + let pointerCleanup: (() => void) | null = null; + + // frame the camera + add the sky + wire the animation controls once the + // model has been instantiated into the world (runs from level.load's + // onLoaded, after the container reset + model creation) + const setupScene = () => { + app.world.addChild(new SkyBackdrop(), -10000); + + const scene = loader.getGLTF("character"); + // the animated asset loads as a single GLTFModel named after the asset + const model = app.world.getChildByName("character")[0] as GLTFModel; + if (!scene || !model) { + return; + } + + // frame a Camera3d on the model: center on its bounds, look down a touch + // at a 3/4 yaw, pulled back to fit the model height. + const { min, max } = scene.bounds; + const cx = ((min[0] + max[0]) / 2) * SCALE; + const cy = -((min[1] + max[1]) / 2) * SCALE; // render space: -Y is up + const cz = -((min[2] + max[2]) / 2) * SCALE; + const spanY = (max[1] - min[1]) * SCALE; + + const camera = app.viewport as InstanceType; + camera.setClipPlanes(SCALE * 0.1, 8000); + const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + + // orbit state — drag to rotate around the character + let yaw = 0.5; + let pitch = -0.12; + let distance = spanY * 2.4 + 200; + const updateCam = () => { + pitch = clamp(pitch, -1.45, 1.45); + distance = clamp(distance, 120, 4000); + 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 — radians per pixel dragged. Use the camera-independent + // screen coords (gameScreenX/Y), NOT gameX/gameY: the latter are + // projected through the viewport, so 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. gameScreenX/Y come + // straight from the canvas/scale transform and stay stable. (Same + // approach as the glTF Scene example.) + const ORBIT_SENSITIVITY = 0.0022; + let dragging = false; + let lastX = 0; + let lastY = 0; + input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => { + dragging = true; + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + }); + input.registerPointerEvent("pointerup", camera, () => { + dragging = false; + }); + input.registerPointerEvent("pointermove", camera, (ev: Pointer) => { + if (!dragging) { + return; + } + yaw += (ev.gameScreenX - lastX) * ORBIT_SENSITIVITY; + pitch -= (ev.gameScreenY - lastY) * ORBIT_SENSITIVITY; + lastX = ev.gameScreenX; + lastY = ev.gameScreenY; + updateCam(); + }); + pointerCleanup = () => { + input.releasePointerEvent("pointerdown", camera); + input.releasePointerEvent("pointerup", camera); + input.releasePointerEvent("pointermove", camera); + }; + + // start walking + const clips = model.getAnimationNames(); + const initial = clips.includes("walk") ? "walk" : clips[0]; + model.setCurrentAnimation(initial); + + // ── on-screen animation controls ────────────────────────────────── + const panel = document.createElement("div"); + panel.style.cssText = + "position:absolute;top:60px;left:16px;z-index:1000;" + + "font-family:sans-serif;font-size:13px;color:#e0e0e0;" + + "background:rgba(20,20,28,0.72);padding:10px 12px;border-radius:8px;" + + "display:flex;flex-direction:column;gap:8px;min-width:170px;"; + + // clip selector — every clip the asset defines + const select = document.createElement("select"); + select.style.cssText = + "background:#1a1a1a;color:#e0e0e0;border:1px solid #555;" + + "border-radius:4px;padding:4px;font-size:13px;"; + for (const name of clips) { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + select.appendChild(opt); + } + select.value = initial; + select.addEventListener("change", () => { + model.play(select.value); + }); + panel.appendChild(select); + + // transport: play / pause / stop (the Sprite-aligned API) + const row = document.createElement("div"); + row.style.cssText = "display:flex;gap:6px;"; + const mkBtn = (label: string, fn: () => void) => { + const b = document.createElement("button"); + b.textContent = label; + b.style.cssText = + "flex:1;background:#2a2a3a;color:#e0e0e0;border:1px solid #555;" + + "border-radius:4px;cursor:pointer;padding:5px 0;font-size:13px;"; + b.addEventListener("click", fn); + row.appendChild(b); + }; + mkBtn("▶ play", () => model.play(select.value)); + mkBtn("⏸ pause", () => model.pause()); + mkBtn("⏹ stop", () => model.stop()); + panel.appendChild(row); + + // speed multiplier + const speedLabel = document.createElement("label"); + speedLabel.style.cssText = "display:flex;align-items:center;gap:8px;"; + const speedValue = document.createElement("span"); + speedValue.textContent = "1.0×"; + speedValue.style.minWidth = "34px"; + const speed = document.createElement("input"); + speed.type = "range"; + speed.min = "0"; + speed.max = "3"; + speed.step = "0.1"; + speed.value = "1"; + speed.style.flex = "1"; + speed.addEventListener("input", () => { + model.animationspeed = Number.parseFloat(speed.value); + speedValue.textContent = `${model.animationspeed.toFixed(1)}×`; + }); + speedLabel.append("speed", speed, speedValue); + panel.appendChild(speedLabel); + + const hint = document.createElement("div"); + hint.textContent = "drag to rotate · pick a clip · play / pause / stop"; + hint.style.cssText = "font-size:11px;color:#9fc3e0;"; + panel.appendChild(hint); + + const parent = app.renderer.getCanvas().parentElement; + if (parent) { + parent.style.position = "relative"; + parent.appendChild(panel); + } + domCleanup = () => { + panel.remove(); + }; + }; + + loader.preload( + // the GLB references an external texture (Textures/texture-a.png), + // resolved relative to the asset URL by the loader — no repackaging. + [{ name: "character", type: "glb", src: `${base}character.glb` }], + () => { + state.change(state.DEFAULT, true); + level.load("character", { scale: SCALE, onLoaded: setupScene }); + }, + ); + + return () => { + if (pointerCleanup) { + pointerCleanup(); + } + if (domCleanup) { + domCleanup(); + } + }; +}; + +export const ExampleGltfCharacter = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index bc58562d49..176caa7d84 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -113,6 +113,11 @@ const ExampleGltf = lazy(() => default: m.ExampleGltf, })), ); +const ExampleGltfCharacter = lazy(() => + import("./examples/gltf/ExampleGltfCharacter").then((m) => ({ + default: m.ExampleGltfCharacter, + })), +); const ExampleMesh3d = lazy(() => import("./examples/mesh3d/ExampleMesh3d").then((m) => ({ default: m.ExampleMesh3d, @@ -373,6 +378,14 @@ const examples: { 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: "glTF Animated Model", + path: "gltf-character", + sourceDir: "gltf", + description: + "A rigged blocky character (Kenney Blocky Characters, CC0) loaded from GLB — node-TRS animation over a rigid hierarchy (walk, idle, sprint, …) driven through the Sprite-aligned setCurrentAnimation / play / pause / stop API.", + }, { component: , label: "3D Material", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index cad14b8a36..912fb94761 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -2,23 +2,30 @@ ## [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. +**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 `level.load(...)`. Animated models play back through the same `setCurrentAnimation` / `play` / `pause` / `stop` API as a 2D `Sprite`. 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 / GLB scene loader (Tier 1)** — preload a `.glb`/`.gltf` and it auto-registers with the `level` director, so `level.load(name, { scale, rightHanded, onLoaded })` instantiates every mesh node as a `Mesh` in one call, exactly like a Tiled map. Parses the node graph, mesh primitives (`POSITION` / `NORMAL` / `TEXCOORD_0` / `COLOR_0` / indices), materials (`pbrMetallicRoughness.baseColorTexture` + `baseColorFactor`), perspective cameras, scene bounds, `KHR_lights_punctual` lights, and node animations. `loader.getGLTF(name)` returns the raw `{ nodes, cameras, lights, bounds, graph, animations }` descriptor for custom framing/instantiation. View under a `Camera3d`. New **glTF Scene** example (Kenney Platformer Kit, CC0). +- **glTF node animation + `GLTFModel`** — assets that define animation channels load as a single rig-driven `GLTFModel` that keeps the node **hierarchy** intact (a parent transform carries its children — rotate a character's `torso` and its `arm`/`head` follow). Each frame the active clip is sampled (translation/scale `LERP`, rotation `SLERP`, plus `STEP`; `CUBICSPLINE` keyframe values) and the rig is re-posed. This is rigid node/TRS animation (no vertex skinning) — walk/idle/sprint characters, spinning pickups, doors, lifts. The animation API mirrors `Sprite`: `setCurrentAnimation(name, { loop, speed, onComplete, next })`, `isCurrentAnimation`, `getAnimationNames`, `animationspeed` (a playback multiplier), `play` / `pause` / `stop`. Retrieve the model after loading with `world.getChildByName(assetName)[0]`. New **glTF Animated Model** example (Kenney Blocky Characters, CC0). +- **Aligned 2D + 3D animation API** — `Sprite.setCurrentAnimation(name, options)` now also accepts an options object `{ loop, speed, onComplete, next }` (the existing string / callback / no-arg forms are unchanged), plus a `speed` playback multiplier, and new `getAnimationNames()`. Both `Sprite` and `GLTFModel` gained `play(name?, options?)` (switch-and-play, or resume), chainable `pause()`, and `stop()` (reset to the first frame / bind pose) so 2D and 3D animation share one vocabulary. +- **External glTF resources** — the loader resolves external `.bin` buffers and image `uri`s relative to the asset URL (via `fetchData`, honoring the loader's crossOrigin / nocache settings), so a `.glb`/`.gltf` that references a separate texture file (e.g. Kenney's `Textures/foo.png`) loads as-shipped without repackaging. Self-contained GLBs (embedded buffers + data-URI / bufferView images) are unaffected. +- **OBJ/MTL textures auto-load** (#1505) — `preloadMTL` now loads each material's `map_Kd` texture automatically, resolved relative to the `.mtl` file, so an OBJ model's textures "come for free" like a glTF scene's. Preloading the model + material is enough — no separate per-texture preload entry needed (the explicit `texture:` still wins, and the legacy preload-it-yourself flow keeps working). A missing texture is warned and skipped (the mesh falls back to the white pixel) rather than aborting the load. +- **`Mesh` `textureRepeat` setting** — texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture, for geometry whose UVs fall outside `[0, 1]` and rely on the texture tiling. The glTF loader sets it from each material's sampler `wrapS` / `wrapT` (defaulting to REPEAT, the glTF spec default) — without it such assets sampled flat edge texels and looked untextured. Never applied to the shared white-pixel fallback. - **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). +- **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). Loading a glTF scene adds its authored `KHR_lights_punctual` directional lights (plus a soft ambient fill) automatically, so meshes are lit by the same sun set up in the authoring tool. Fields are mutable, so a light can be animated at runtime (e.g. a day/night cycle rotating `direction`). 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 + ambient 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. +- **`Renderable.applyAnchorTransform`** (default `true`) — new flag controlling whether `preDraw` applies the `anchorPoint` offset to the renderer transform. Defaults to the existing behavior; `Mesh` sets it `false` on the `Camera3d` world-space path (a 3D mesh is positioned by its transform and has no anchor box, so the normalized offset must not leak into the shared mesh view matrix). +- **`Mesh` supports meshes with more than 65,535 vertices** — a `Uint32Array` index buffer is preserved as-is instead of being coerced to `Uint16Array`, so high-poly meshes (e.g. large 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. +- **`Camera3d` culled sizeless grouping containers (and their whole subtree)** — `Camera3d.isVisible` derived a bounding-sphere radius of `√(w²+h²)/2` from the object's bounds. A container with no intrinsic size has infinite/cleared bounds, making the radius `NaN`; `intersectsSphere(_, NaN)` is `false`, so the container was reported invisible and its children were never updated *or* drawn. Such a container can't be frustum-culled meaningfully and is now always visible (children are culled individually), matching `Camera2d`. This is what kept a nested `GLTFModel` rig from rendering under a 3D camera. + +### Performance +- **Allocation-free glTF animation pose path** — sampling and re-posing an animated `GLTFModel` each frame allocates nothing: matrix composition and multiplication write in place (`composeTRSInto` / `multiplyMatrixInto`) into preallocated per-node world buffers, and the keyframe sampler reuses scratch vectors. No per-frame GC churn even for dense, many-node rigs. ## [19.7.1] (melonJS 2) - _2026-06-14_ diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 8137299df8..9736cb7892 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -576,6 +576,18 @@ export default class Camera3d extends Camera2d { // `pos.z`) here, which silently mis-culled children of any // container whose own depth was non-zero. const bounds = obj.getBounds(); + // A grouping container with no intrinsic size has infinite / cleared + // bounds (left=+∞, right=-∞). Its width/height are non-finite, so the + // radius below would be NaN and `intersectsSphere` would silently report + // it (and its whole subtree) invisible — skipping both its draw AND its + // update. Such a container can't be frustum-culled meaningfully, so treat + // it as always visible and let its children be culled individually + // (matching Camera2d, which special-cases the same sentinel). This is + // what keeps e.g. a GLTFModel rig (meshes nested under a sizeless + // container) rendering under a 3D camera. + if (!bounds.isFinite()) { + return true; + } // Half-diagonal — the conservative bounding-sphere radius for // a rectangular bounds rect. `max(w, h) * 0.5` is the // inradius and can mark a renderable invisible while one of diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 135902cb5b..c74a6c32b1 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -10,6 +10,7 @@ import MaskEffect from "./camera/effects/mask_effect.ts"; import ShakeEffect from "./camera/effects/shake_effect.ts"; import Frustum from "./camera/frustum.ts"; import Pointer from "./input/pointer.ts"; +import GLTFModel from "./level/gltf/GLTFModel.js"; import TMXHexagonalRenderer from "./level/tiled/renderer/TMXHexagonalRenderer.js"; import TMXIsometricRenderer from "./level/tiled/renderer/TMXIsometricRenderer.js"; import TMXOrthogonalRenderer from "./level/tiled/renderer/TMXOrthogonalRenderer.js"; @@ -21,6 +22,7 @@ import TMXTileMap from "./level/tiled/TMXTileMap.js"; import TMXTileset from "./level/tiled/TMXTileset.js"; import TMXTilesetGroup from "./level/tiled/TMXTilesetGroup.js"; import * as TMXUtils from "./level/tiled/TMXUtils.js"; +import Light2d from "./lighting/light2d.ts"; import { ColorMatrix } from "./math/color_matrix.ts"; import ParticleEmitter from "./particles/emitter.ts"; import Particle from "./particles/particle.ts"; @@ -37,7 +39,6 @@ import { Draggable } from "./renderable/draggable.js"; import { DropTarget } from "./renderable/dragndrop.js"; import Entity from "./renderable/entity/entity.js"; import ImageLayer from "./renderable/imagelayer.js"; -import Light2d from "./renderable/light2d.js"; import Mesh from "./renderable/mesh.js"; import NineSliceSprite from "./renderable/nineslicesprite.js"; import Renderable from "./renderable/renderable.js"; @@ -113,7 +114,6 @@ export { 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"; @@ -176,6 +176,7 @@ export { FlashEffect, Frustum, GLShader, + GLTFModel, GlowEffect, Gradient, HologramEffect, diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js new file mode 100644 index 0000000000..2254d74184 --- /dev/null +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -0,0 +1,494 @@ +import { + composeTRS, + composeTRSInto, + multiplyMatrixInto, +} from "../../loader/parsers/gltf.js"; +import { parseAnimationOptions } from "../../renderable/animation.ts"; +import Container from "../../renderable/container.js"; +import Mesh from "../../renderable/mesh.js"; +import { sampleChannel } from "./gltf_sampler.js"; + +/** + * additional import for TypeScript + * @import { AnimationOptions } from "../../renderable/animation.ts"; + */ + +// column-major identity, the root's parent transform +const IDENTITY16 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +// per-frame scratch reused while composing one node's local matrix (DFS visits +// a node fully before recursing, and the local matrix is consumed by the +// world-matrix multiply before the next node is touched, so a single shared +// set is safe and keeps update() allocation-free per node) +const _t = [0, 0, 0]; +const _r = [0, 0, 0, 1]; +const _s = [1, 1, 1]; +const _val = [0, 0, 0, 0]; +const _localScratch = new Array(16); + +/** + * @classdesc + * A rig-driven 3D model loaded from an animated glTF/GLB asset. Unlike a static + * {@link GLTFScene} (which flattens each node into an independent {@link Mesh}), + * a `GLTFModel` keeps the node **hierarchy** intact so a parent transform + * carries its children — e.g. rotating a character's `torso` moves the attached + * `arm` and `head`. Each frame, the active animation clip is sampled, world + * matrices are propagated down the tree, and every part mesh's placement is + * re-derived. + * + * The animation API mirrors {@link Sprite} for familiarity — `setCurrentAnimation`, + * `isCurrentAnimation`, `getAnimationNames`, `play`/`pause`, `animationpause` — + * but uses the cleaner options form everywhere: `setCurrentAnimation(name, { + * loop, speed, onComplete, next })`. Here `animationspeed` is a **playback + * multiplier** (1 = authored speed), not a per-frame delay. + * + * Instances are created automatically by {@link GLTFScene} when the asset + * defines animation channels; you usually obtain one via `level.load(...)` + * rather than constructing it directly. + * @augments Container + */ +export default class GLTFModel extends Container { + /** + * @param {object} data - the parsed glTF descriptor (`{ graph, animations, bounds, ... }`) + * @param {object} [options] + * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale) + * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror) + * @param {boolean} [options.lit=false] - render the part meshes through the lit batcher + */ + constructor(data, options = {}) { + super(0, 0); + + /** + * pixels per glTF unit (uniform scene scale) + * @type {number} + * @ignore + */ + this.scale = options.scale ?? 1; + // right-handed (glTF) → negate Z as well as Y so the Y-up→Y-down bridge + // is a rotation, matching Mesh#rightHanded / GLTFScene + this._zSign = options.rightHanded !== false ? -1 : 1; + + // scene meshes carry their own world transform; the GPU depth test + // resolves occlusion, so don't let the container reassign child depth + this.autoDepth = false; + + // This container is a logical group sitting at the world origin — its + // child meshes carry absolute world placement themselves. It has no + // meaningful anchor box, and its width/height are Infinity (the Container + // default), so the base `preDraw` anchor offset `width * anchorPoint` + // would be `Infinity * 0 = NaN` and NaN-poison the renderer transform, + // silently dropping every child mesh. Opt out of the anchor offset + // entirely (same mechanism Mesh uses on the Camera3d world path). + this.applyAnchorTransform = false; + + /** the node hierarchy keyed by glTF node index @ignore */ + this._nodes = data.graph.nodes; + /** root node indices @ignore */ + this._roots = data.graph.roots; + /** glTF node index → its part Mesh instances (one per primitive) @ignore */ + this._meshByNode = {}; + /** glTF node index → cached rest (bind-pose) local matrix @ignore */ + this._restMatrix = {}; + /** + * glTF node index → its world matrix, a persistent 16-element buffer + * recomputed in place every pose (a child reads its parent's buffer + * during the DFS, so each node needs its own). Preallocated here so the + * per-frame pose path allocates nothing. + * @ignore + */ + this._world = {}; + + // a generous per-part cull radius taken from the whole scene's bounds so + // the model culls as a unit (a limb never pops out while the body is on + // screen). Camera3d derives the cull sphere as √(w²+h²)/2, so a square + // box of side `radius·√2` yields a sphere of exactly `radius`. + const b = data.bounds; + const dx = b.max[0] - b.min[0]; + const dy = b.max[1] - b.min[1]; + const dz = b.max[2] - b.min[2]; + const radius = (Math.hypot(dx, dy, dz) / 2) * this.scale; + const boxSize = Math.max(radius, 1) * Math.SQRT2; + + const lit = options.lit === true; + const rightHanded = options.rightHanded !== false; + + // build the rest matrices + instantiate a Mesh per mesh-node primitive + for (const idx in this._nodes) { + const node = this._nodes[idx]; + this._restMatrix[idx] = node.matrix + ? node.matrix + : composeTRS(node.translation, node.rotation, node.scale); + // persistent per-node world-matrix buffer (recomputed in place each pose) + this._world[idx] = new Array(16); + + for (const prim of node.primitives) { + const mesh = new Mesh(0, 0, { + vertices: prim.vertices, + uvs: prim.uvs, + indices: prim.indices, + normals: prim.normals, + texture: prim.image, + width: boxSize, + height: boxSize, + scale: this.scale, + normalize: false, + rightHanded, + lit, + // honor the glTF sampler wrap (default REPEAT) — many exporters + // author UVs outside [0,1] that tile; clamping flattens them + textureRepeat: prim.textureRepeat, + // thin/flat double-sided parts must not be back-face culled + cullBackFaces: prim.doubleSided !== true, + }); + const f = prim.baseColorFactor; + if (f) { + mesh.tint.setColor( + Math.round(f[0] * 255), + Math.round(f[1] * 255), + Math.round(f[2] * 255), + ); + } + if (prim.colors) { + mesh.vertexColors = prim.colors; + } + mesh.name = node.name; + (this._meshByNode[idx] ??= []).push(mesh); + this.addChild(mesh); + } + } + + // index the animation clips, pre-grouping each clip's channels by the + // node they target (so sampling a node is a single map lookup) + /** name → clip `{ name, duration, channelsByNode, animatedNodes }` @ignore */ + this.anim = {}; + for (const clip of data.animations ?? []) { + const channelsByNode = new Map(); + for (const ch of clip.channels) { + if (!channelsByNode.has(ch.node)) { + channelsByNode.set(ch.node, []); + } + channelsByNode.get(ch.node).push(ch); + } + this.anim[clip.name] = { + name: clip.name, + duration: clip.duration, + channelsByNode, + animatedNodes: new Set(channelsByNode.keys()), + }; + } + + /** + * playback multiplier for the current animation (1 = authored speed). + * @type {number} + * @default 1 + */ + this.animationspeed = 1; + + /** + * pause/resume the current animation without losing its pose or time. + * @type {boolean} + * @default false + */ + this.animationpause = false; + + /** + * a callback fired each time the current animation completes a cycle. + * @type {Function} + * @default undefined + */ + // this.onended; + + // current animation state + /** @ignore */ + this.current = { name: undefined, time: 0, length: 0 }; + /** loop-completion callback (built from the options) @ignore */ + this.resetAnim = undefined; + /** set when a `loop:false` clip has finished its single cycle @ignore */ + this._animDone = false; + + // pose to the bind/rest pose so the model is correctly assembled even + // before any clip plays + this._pose(); + } + + /** + * the names of every animation clip defined by the source asset. + * @returns {string[]} + * @example + * model.getAnimationNames(); // ["idle", "walk", "sprint", ...] + */ + getAnimationNames() { + return Object.keys(this.anim); + } + + /** + * return true if `name` is the currently playing animation. + * @param {string} name - animation clip id + * @returns {boolean} + */ + isCurrentAnimation(name) { + return this.current.name === name; + } + + /** + * play the given animation clip. The second argument mirrors {@link Sprite} + * and accepts the same forms: omit to loop forever, a `string` to chain to + * another clip when this one ends, a `function` legacy completion callback + * (return `false` to hold the final pose), or an options object. + * @param {string} name - animation clip id (see {@link GLTFModel#getAnimationNames}) + * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior + * @param {boolean} [preserveTime=false] - keep the current playback time instead of restarting at 0 + * @returns {GLTFModel} this, for chaining + * @example + * model.setCurrentAnimation("walk"); // loop forever + * model.setCurrentAnimation("die", { loop: false }); // play once, hold last pose + * model.setCurrentAnimation("jump", { next: "idle" }); // jump, then idle + * model.setCurrentAnimation("walk", { speed: 2 }); // twice as fast + * model.setCurrentAnimation("emote-yes", () => spawnFx()); // legacy callback + */ + setCurrentAnimation(name, options, preserveTime = false) { + if (this.anim[name] === undefined) { + throw new Error("animation id '" + name + "' not defined"); + } + if (this.isCurrentAnimation(name)) { + return this; + } + this.current.name = name; + this.current.length = this.anim[name].duration; + const opts = parseAnimationOptions(options); + this.animationspeed = opts.speed; + this._animDone = false; + const onComplete = opts.onComplete; + if (opts.legacyFn) { + // legacy bare-function callback (return false → hold the last pose) + this.resetAnim = onComplete; + } else if (typeof opts.next === "string") { + const next = opts.next; + this.resetAnim = () => { + if (typeof onComplete === "function") { + onComplete(); + } + this.setCurrentAnimation(next); + }; + } else if (opts.loop === false) { + // play once: fire onComplete, hold the final pose + this.resetAnim = () => { + if (typeof onComplete === "function") { + onComplete(); + } + this._animDone = true; + return false; + }; + } else if (typeof onComplete === "function") { + this.resetAnim = () => { + onComplete(); + }; + } else { + this.resetAnim = undefined; + } + if (!preserveTime) { + this.current.time = 0; + } + this._pose(); + this.isDirty = true; + return this; + } + + /** + * Play an animation clip, or resume the current one. A shorthand for + * {@link GLTFModel#setCurrentAnimation}: call with a clip name to switch to + * (and start) it, or with no argument to resume after {@link GLTFModel#pause}. + * Always clears the paused state. + * @param {string} [name] - clip id to play; omit to just resume + * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation}) + * @returns {GLTFModel} this, for chaining + * @example + * model.play("walk"); // switch to + play "walk" + * model.play("die", { loop: false }); // play once, hold the last pose + * model.pause(); + * model.play(); // resume + */ + play(name, options) { + this.animationpause = false; + if (name !== undefined) { + this.setCurrentAnimation(name, options); + } + return this; + } + + /** + * Pause the current animation, freezing it at its current pose. Resume with + * {@link GLTFModel#play}. + * @returns {GLTFModel} this, for chaining + */ + pause() { + this.animationpause = true; + return this; + } + + /** + * Stop playback and reset the rig to its bind/rest pose (no clip active). + * After this {@link GLTFModel#isCurrentAnimation} is false for every clip; + * call {@link GLTFModel#play} to start again. (Use {@link GLTFModel#pause} + * instead to freeze in place.) + * @returns {GLTFModel} this, for chaining + */ + stop() { + this.current.name = undefined; + this.current.time = 0; + this.current.length = 0; + this.resetAnim = undefined; + this._animDone = false; + this.animationpause = false; + // re-pose with no active clip → every node falls back to its rest matrix + this._pose(); + this.isDirty = true; + return this; + } + + /** + * Advance the active clip and re-pose the rig. + * @param {number} dt - elapsed time since the last update, in milliseconds + * @returns {boolean} true if the model (or any child) needs redrawing + * @protected + */ + update(dt) { + if ( + this.current.name !== undefined && + !this.animationpause && + !this._animDone && + this.current.length > 0 + ) { + const duration = this.current.length; + // glTF keyframe times are in seconds; dt is in milliseconds + this.current.time += (dt / 1000) * this.animationspeed; + if (this.current.time >= duration) { + if (typeof this.onended === "function") { + this.onended(); + } + if (typeof this.resetAnim === "function") { + if (this.resetAnim() === false) { + // hold the final pose + this.current.time = duration; + } else if (this.current.time >= duration) { + // default loop / loop-with-callback: wrap the overflow + // (guarded — a chain may already have reset the time) + this.current.time %= duration; + } + } else { + this.current.time %= duration; + } + } + this._pose(); + this.isDirty = true; + } + return super.update(dt); + } + + /** + * Sample the active clip (if any) and propagate world transforms down the + * node tree, writing each part mesh's placement. Nodes the current clip does + * not animate use their cached rest matrix. + * @ignore + */ + _pose() { + const clip = this.current.name ? this.anim[this.current.name] : null; + const t = this.current.time; + for (const root of this._roots) { + this._visit(root, IDENTITY16, clip, t); + } + } + + /** + * DFS one node: compose its local matrix, multiply by the parent world, + * apply to its meshes, recurse into children. + * @ignore + */ + _visit(idx, parentWorld, clip, t) { + const node = this._nodes[idx]; + if (node === undefined) { + return; + } + // `local` may be the shared `_localScratch` (animated node) — it's + // consumed by the multiply below before any child overwrites it. + const local = this._localMatrix(idx, clip, t); + // write into this node's persistent world buffer (distinct from + // parentWorld and local, so the in-place multiply is safe); children + // read it as their parentWorld during recursion. + const world = multiplyMatrixInto(this._world[idx], parentWorld, local); + const meshes = this._meshByNode[idx]; + if (meshes !== undefined) { + for (const mesh of meshes) { + this._applyWorldToMesh(mesh, world); + } + } + for (const child of node.children) { + this._visit(child, world, clip, t); + } + } + + /** + * The node's local matrix: sampled TRS when the active clip animates it + * (starting from the rest pose, overriding only the animated components), + * otherwise the cached rest matrix. + * @returns {number[]} 16-element column-major matrix + * @ignore + */ + _localMatrix(idx, clip, t) { + const node = this._nodes[idx]; + if (clip === null || !clip.animatedNodes.has(node.index)) { + // cached rest matrix — never mutated, safe to return directly + return this._restMatrix[idx]; + } + // start from the rest TRS, then override the channels this clip drives + _t[0] = node.translation[0]; + _t[1] = node.translation[1]; + _t[2] = node.translation[2]; + _r[0] = node.rotation[0]; + _r[1] = node.rotation[1]; + _r[2] = node.rotation[2]; + _r[3] = node.rotation[3]; + _s[0] = node.scale[0]; + _s[1] = node.scale[1]; + _s[2] = node.scale[2]; + for (const ch of clip.channelsByNode.get(node.index)) { + sampleChannel(ch, t, _val); + if (ch.path === "translation") { + _t[0] = _val[0]; + _t[1] = _val[1]; + _t[2] = _val[2]; + } else if (ch.path === "rotation") { + _r[0] = _val[0]; + _r[1] = _val[1]; + _r[2] = _val[2]; + _r[3] = _val[3]; + } else { + _s[0] = _val[0]; + _s[1] = _val[1]; + _s[2] = _val[2]; + } + } + // in-place into the shared scratch (consumed immediately by the caller's + // world multiply, before the next node is visited) + return composeTRSInto(_localScratch, _t, _r, _s); + } + + /** + * Split a node's world matrix into the renderable placement a {@link Mesh}'s + * Camera3d path expects: the translation (scaled, Y/Z-bridged) becomes + * `pos`/`depth`, the rotation+scale becomes `currentTransform` (translation + * zeroed). Mirrors the static {@link GLTFScene} center-split, recomputed per + * frame. + * @ignore + */ + _applyWorldToMesh(mesh, world) { + mesh.pos.set(world[12] * this.scale, -world[13] * this.scale); + mesh.depth = this._zSign * world[14] * this.scale; + const v = mesh.currentTransform.val; + v.set(world); + v[12] = 0; + v[13] = 0; + v[14] = 0; + mesh.isDirty = true; + } +} diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index 9f8f736dca..97682b2904 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -1,15 +1,15 @@ 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"; +import GLTFModel from "./GLTFModel.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")`. + * a Tiled map: `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. @@ -35,13 +35,6 @@ export default class GLTFScene { * @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 = []; } /** @@ -64,16 +57,17 @@ export default class GLTFScene { /** * Instantiate every glTF mesh node as a `Mesh` in the given container. - * Called by the level director on `me.level.load(...)`. + * Called by the level director on `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. + * @param {boolean} [options.lights=true] - add the scene's authored + * `KHR_lights_punctual` directional lights (plus a soft ambient fill) to the + * world as {@link Light3d} renderables, so the meshes are lit by the sun set + * up in the authoring tool. Set false to keep the meshes unlit / manage + * lighting yourself with `world.addChild(new Light3d(...))`. */ addTo(container, options = {}) { if (!this.data) { @@ -96,6 +90,19 @@ export default class GLTFScene { // occlusion between meshes under Camera3d) container.autoDepth = false; + // Animated asset → keep the node hierarchy intact inside one rig-driven + // GLTFModel (a parent transform carries its children). Static asset → + // the flat-mesh path below. The model is named after the asset so it can + // be retrieved from the world (`world.getChildByName(name)[0]`) to drive + // playback. Lights are still instantiated (shared block at the end). + if ((this.data.animations ?? []).length > 0) { + const model = new GLTFModel(this.data, { scale, rightHanded, lit }); + model.name = this.name; + container.addChild(model); + this._addLights(container, zSign, options); + return; + } + for (const node of this.data.nodes) { const m = node.world; @@ -134,6 +141,9 @@ export default class GLTFScene { scale, normalize: false, rightHanded, + // honor the glTF sampler wrap (default REPEAT) so tiling UVs + // (UVs outside [0,1]) sample correctly instead of clamping flat + textureRepeat: node.textureRepeat, // light this mesh (via the lit batcher) when the scene has lights lit, // honor the glTF material's double-sided flag: thin/flat props @@ -171,19 +181,34 @@ export default class GLTFScene { 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({ + this._addLights(container, zSign, options); + } + + /** + * Add the scene's authored directional lights (plus a soft ambient fill) to + * the world as {@link Light3d} renderables, so the meshes are lit by the + * same sun set up in the authoring tool (Blender etc.). Shared by the static + * and animated paths. The lights are ordinary world children — the level + * director's `container.reset()` removes them on the next load, exactly like + * {@link Light2d}, so there's nothing to track or tear down here. + * @param {Container} container - the target container the lights are added to + * @param {number} zSign - the Y-up→Y-down Z bridge sign (rightHanded → -1) + * @param {object} options - the `addTo` options (`lights` toggle) + * @ignore + */ + _addLights(container, zSign, options) { + if (options.lights === false) { + return; + } + let added = 0; + 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; + container.addChild( + new Light3d({ type: "directional", // bring the glTF-space direction into render space (same // Y-down / rightHanded Y/Z bridge the geometry uses) @@ -193,28 +218,26 @@ export default class GLTFScene { // 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); - } + }), + ); + added++; } - } - - /** Remove the lights this scene previously added. @ignore */ - _removeLights() { - for (const light of this._lights) { - LightingEnvironment.default.removeLight(light); + // a soft ambient fill so the shadow side of lit meshes isn't pure black. + // `KHR_lights_punctual` has no ambient light type, so this is an engine + // default; only meaningful when the scene actually has directional lights + // (otherwise the meshes render fullbright / unlit). + if (added > 0) { + container.addChild( + new Light3d({ type: "ambient", color: "#ffffff", intensity: 0.3 }), + ); } - 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. + * Director cleanup hook (parity with `TMXTileMap.destroy`). Nothing to do — + * the meshes and lights are ordinary world children, removed by the + * director's `container.reset()` on the next load. * @ignore */ - destroy() { - this._removeLights(); - } + destroy() {} } diff --git a/packages/melonjs/src/level/gltf/gltf_sampler.js b/packages/melonjs/src/level/gltf/gltf_sampler.js new file mode 100644 index 0000000000..a29bcc3d03 --- /dev/null +++ b/packages/melonjs/src/level/gltf/gltf_sampler.js @@ -0,0 +1,154 @@ +/** + * Keyframe sampling for glTF node-TRS animation. Pure, engine-free helpers so + * the interpolation math can be unit-tested in isolation from the renderer. + * + * A parsed channel (see the glTF loader) has the shape: + * `{ node, path, times: Float32Array, values: Float32Array, stride, interpolation }` + * where `stride` is the component count of one value (3 for translation/scale, + * 4 for a rotation quaternion) and `interpolation` is `"LINEAR"` | `"STEP"` | + * `"CUBICSPLINE"`. + * @ignore + */ + +// reused result for findKeyframe — the value is consumed immediately by the +// caller (sampleChannel destructures it on return), so a single shared object +// keeps the per-frame sample path allocation-free. +const _kf = { i0: 0, i1: 0, alpha: 0 }; + +/** + * Locate the keyframe interval for time `t` in the (ascending) `times` array. + * Clamps to the endpoints — glTF animations do not extrapolate beyond their + * first/last keyframe. Returns the bracketing indices and the 0..1 blend factor. + * + * The returned object is **reused** across calls (read/copy its fields + * immediately; don't retain the reference). + * @param {ArrayLike} times - keyframe times, ascending + * @param {number} t - sample time (same units as `times`, i.e. seconds) + * @returns {{ i0: number, i1: number, alpha: number }} reused result object + * @ignore + */ +export function findKeyframe(times, t) { + const n = times.length; + if (n === 0 || t <= times[0]) { + _kf.i0 = 0; + _kf.i1 = 0; + _kf.alpha = 0; + return _kf; + } + if (t >= times[n - 1]) { + _kf.i0 = n - 1; + _kf.i1 = n - 1; + _kf.alpha = 0; + return _kf; + } + // binary search for the last index whose time is <= t + let lo = 0; + let hi = n - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (times[mid] <= t) { + lo = mid; + } else { + hi = mid - 1; + } + } + const span = times[lo + 1] - times[lo]; + _kf.i0 = lo; + _kf.i1 = lo + 1; + // guard against a zero-length span (duplicate keyframe times) + _kf.alpha = span > 0 ? (t - times[lo]) / span : 0; + return _kf; +} + +/** + * Spherical-linear interpolation between two quaternions stored in `values` at + * component offsets `o0` and `o1`. Picks the shortest arc (sign-flips the second + * quaternion when the dot is negative) and falls back to normalized-lerp for + * nearly-parallel inputs (where `sin(theta)` underflows). Result is normalized. + * @param {ArrayLike} values - flat quaternion buffer (xyzw per key) + * @param {number} o0 - offset of the first quaternion + * @param {number} o1 - offset of the second quaternion + * @param {number} t - blend factor 0..1 + * @param {number[]} out - 4-element [x,y,z,w] result + * @ignore + */ +export function slerpQuat(values, o0, o1, t, out) { + const ax = values[o0]; + const ay = values[o0 + 1]; + const az = values[o0 + 2]; + const aw = values[o0 + 3]; + let bx = values[o1]; + let by = values[o1 + 1]; + let bz = values[o1 + 2]; + let bw = values[o1 + 3]; + let cosom = ax * bx + ay * by + az * bz + aw * bw; + // shortest path: negate the second quaternion if the dot is negative + if (cosom < 0) { + cosom = -cosom; + bx = -bx; + by = -by; + bz = -bz; + bw = -bw; + } + let s0; + let s1; + if (cosom > 0.9995) { + // quaternions almost parallel — normalized lerp avoids a divide-by-~0 + s0 = 1 - t; + s1 = t; + } else { + const omega = Math.acos(cosom); + const sinom = Math.sin(omega); + s0 = Math.sin((1 - t) * omega) / sinom; + s1 = Math.sin(t * omega) / sinom; + } + let ox = s0 * ax + s1 * bx; + let oy = s0 * ay + s1 * by; + let oz = s0 * az + s1 * bz; + let ow = s0 * aw + s1 * bw; + const len = Math.hypot(ox, oy, oz, ow) || 1; + ox /= len; + oy /= len; + oz /= len; + ow /= len; + out[0] = ox; + out[1] = oy; + out[2] = oz; + out[3] = ow; + return out; +} + +/** + * Sample one animation channel at time `t`, writing `channel.stride` components + * into `out`. Rotation channels (stride 4) use {@link slerpQuat}; translation / + * scale (stride 3) use component-wise linear interpolation. `STEP` holds the + * lower keyframe; `CUBICSPLINE` uses the keyframe value (its tangents are + * ignored — a deliberate Tier-1 approximation, not full spline evaluation). + * @param {{times: ArrayLike, values: ArrayLike, stride: number, interpolation: string}} channel + * @param {number} t - sample time in seconds + * @param {number[]} out - destination, at least `channel.stride` long + * @returns {number[]} `out` + * @ignore + */ +export function sampleChannel(channel, t, out) { + const { times, values, stride, interpolation } = channel; + let { i0, i1, alpha } = findKeyframe(times, t); + if (interpolation === "STEP") { + alpha = 0; + } + // CUBICSPLINE stores 3 values per keyframe (inTangent, value, outTangent); + // the actual value is the middle third. We sample that value and linearly + // blend (tangents ignored). LINEAR / STEP store a single value per keyframe. + const cubic = interpolation === "CUBICSPLINE"; + const block = cubic ? stride * 3 : stride; + const valueOffset = cubic ? stride : 0; + const o0 = i0 * block + valueOffset; + const o1 = i1 * block + valueOffset; + if (stride === 4) { + return slerpQuat(values, o0, o1, alpha, out); + } + for (let c = 0; c < stride; c++) { + out[c] = values[o0 + c] + (values[o1 + c] - values[o0 + c]) * alpha; + } + return out; +} diff --git a/packages/melonjs/src/renderable/light2d.js b/packages/melonjs/src/lighting/light2d.ts similarity index 61% rename from packages/melonjs/src/renderable/light2d.js rename to packages/melonjs/src/lighting/light2d.ts index 8d2c39dda8..fe79864fbe 100644 --- a/packages/melonjs/src/renderable/light2d.js +++ b/packages/melonjs/src/lighting/light2d.ts @@ -1,14 +1,9 @@ -import { ellipsePool } from "./../geometries/ellipse.ts"; -import { colorPool } from "./../math/color.ts"; +import { Ellipse, ellipsePool } from "../geometries/ellipse.ts"; +import { Color, colorPool } from "../math/color.ts"; +import Renderable from "../renderable/renderable.js"; import state from "../state/state.ts"; -import Renderable from "./renderable.js"; - -/** - * additional import for TypeScript - * @import {Color} from "./../math/color.ts"; - * @import {Ellipse} from "./../geometries/ellipse.ts"; - * @import Renderer from "./../video/renderer.js"; - */ +import type CanvasRenderer from "../video/canvas/canvas_renderer.js"; +import type WebGLRenderer from "../video/webgl/webgl_renderer.js"; /** * A 2D point light. @@ -29,9 +24,65 @@ import Renderable from "./renderable.js"; * * Light2d itself is renderer-agnostic — no shader knowledge, no canvas * allocation, no renderer reference held. + * @category Lighting * @see stage.lights */ export default class Light2d extends Renderable { + /** + * the color of the light + * @default "#FFF" + */ + color: Color; + + /** The horizontal radius of the light */ + radiusX: number; + + /** The vertical radius of the light */ + radiusY: number; + + /** + * The intensity of the light + * @default 0.7 + */ + intensity: number; + + /** + * the world-space geometry of the light's visible area, rewritten each + * frame by {@link Light2d#getVisibleArea} from transform-aware bounds. + * @ignore + */ + visibleArea: Ellipse; + + /** + * When `true`, this light acts as a pure illumination source — the + * gradient texture isn't drawn. The light still feeds the `Stage` + * ambient-cutout pass and the WebGL lit-sprite pipeline's per-frame + * uniforms, so normal-mapped sprites still get shaded by it. Use this for + * SpriteIlluminator-style demos where the light should be invisible (only + * its effect on normal-mapped surfaces is what you want to see). + * + * Default `false`, preserving the legacy "soft glowing spot" behavior. + * @default false + */ + illuminationOnly: boolean; + + /** + * Light height above the sprite plane (Z axis), in the same units as + * `radiusX`/`radiusY`. Used by the WebGL lit-sprite pipeline as the Z + * component of the light direction in the `dot(normal, lightDir)` + * calculation: a low height makes the lighting graze across the surface + * (long visible shadows on normal-map detail), a high height makes it + * head-on (more uniform brightness on the lit hemisphere). + * + * Default is `max(radiusX, radiusY) * 0.075` — a balanced look at the + * asset's native scale that prevents lights at the sprite's center from + * producing degenerate flat shading. + * + * Named `lightHeight` (not just `height`) to avoid colliding with the + * bbox-height getter Light2d inherits from `Rect`. + */ + lightHeight: number; + /** * Create a 2D point light. * @@ -52,56 +103,39 @@ export default class Light2d extends Renderable { * inner alpha; the `Stage.ambientLight` color and alpha control how * dark the unlit areas are. Use `light.blendMode` to override the * default additive blend if needed. - * @param {number} x - The horizontal position of the light's center (matches `Ellipse(x, y, w, h)` conventions). - * @param {number} y - The vertical position of the light's center. - * @param {number} radiusX - The horizontal radius of the light. - * @param {number} [radiusY=radiusX] - The vertical radius of the light. - * @param {Color|string} [color="#FFF"] - The color of the light at full intensity. - * @param {number} [intensity=0.7] - The peak alpha of the radial gradient at the light's center (0–1). + * @param x - The horizontal position of the light's center (matches `Ellipse(x, y, w, h)` conventions). + * @param y - The vertical position of the light's center. + * @param radiusX - The horizontal radius of the light. + * @param [radiusY=radiusX] - The vertical radius of the light. + * @param [color="#FFF"] - The color of the light at full intensity. + * @param [intensity=0.7] - The peak alpha of the radial gradient at the light's center (0–1). */ constructor( - x, - y, - radiusX, - radiusY = radiusX, - color = "#FFF", - intensity = 0.7, + x: number, + y: number, + radiusX: number, + radiusY: number = radiusX, + color: Color | string = "#FFF", + intensity: number = 0.7, ) { // pos is the light's CENTER (matches `Ellipse(x, y, w, h)` and // `Sprite` conventions); the centered anchor below makes Renderable's // transform stack scale/rotate around that center too. super(x, y, radiusX * 2, radiusY * 2); - /** - * the color of the light - * @type {Color} - * @default "#FFF" - */ - this.color = colorPool.get().parseCSS(color); + this.color = colorPool.get(); + if (color instanceof Color) { + this.color.copy(color); + } else { + this.color.parseCSS(color); + } - /** - * The horizontal radius of the light - * @type {number} - */ this.radiusX = radiusX; - - /** - * The vertical radius of the light - * @type {number} - */ this.radiusY = radiusY; - - /** - * The intensity of the light - * @type {number} - * @default 0.7 - */ this.intensity = intensity; /** * the default blend mode to be applied when rendering this light - * @type {string} - * @default "lighter" * @see CanvasRenderer#setBlendMode * @see WebGLRenderer#setBlendMode */ @@ -109,7 +143,6 @@ export default class Light2d extends Renderable { // initial shape — `getVisibleArea()` rewrites this each frame from // transform-aware bounds. - /** @ignore */ this.visibleArea = ellipsePool.get( this.pos.x, this.pos.y, @@ -120,39 +153,7 @@ export default class Light2d extends Renderable { // centered anchor — transforms (scale, rotate) pivot around `pos`. this.anchorPoint.set(0.5, 0.5); - /** - * When `true`, this light acts as a pure illumination source — - * the gradient texture isn't drawn. The light still feeds the - * `Stage` ambient-cutout pass and the WebGL lit-sprite - * pipeline's per-frame uniforms, so normal-mapped sprites still - * get shaded by it. Use this for SpriteIlluminator-style demos - * where the light should be invisible (only its effect on - * normal-mapped surfaces is what you want to see). - * - * Default `false`, preserving the legacy "soft glowing spot" - * behavior. - * @type {boolean} - * @default false - */ this.illuminationOnly = false; - - /** - * Light height above the sprite plane (Z axis), in the same - * units as `radiusX`/`radiusY`. Used by the WebGL lit-sprite - * pipeline as the Z component of the light direction in the - * `dot(normal, lightDir)` calculation: a low height makes the - * lighting graze across the surface (long visible shadows on - * normal-map detail), a high height makes it head-on (more - * uniform brightness on the lit hemisphere). - * - * Default is `max(radiusX, radiusY) * 0.075` — a balanced look - * at the asset's native scale that prevents lights at the - * sprite's center from producing degenerate flat shading. - * - * Named `lightHeight` (not just `height`) to avoid colliding - * with the bbox-height getter Light2d inherits from `Rect`. - * @type {number} - */ this.lightHeight = Math.max(radiusX, radiusY) * 0.075; } @@ -161,12 +162,11 @@ export default class Light2d extends Renderable { * Overrides Rect's getter, which assumes `pos` is the bbox top-left and * returns `pos.x + width/2`. Light2d uses `anchorPoint = (0.5, 0.5)`, so * `pos` already IS the center. - * @type {number} */ - get centerX() { + override get centerX(): number { return this.pos.x; } - set centerX(value) { + override set centerX(value: number) { this.pos.x = value; this.recalc(); this.updateBounds(); @@ -175,12 +175,11 @@ export default class Light2d extends Renderable { /** * the vertical coordinate of this light's center. * @see Light2d#centerX - * @type {number} */ - get centerY() { + override get centerY(): number { return this.pos.y; } - set centerY(value) { + override set centerY(value: number) { this.pos.y = value; this.recalc(); this.updateBounds(); @@ -200,10 +199,10 @@ export default class Light2d extends Renderable { * `Renderable.resize(width, height)` — code that operates on a * generic `Renderable` and calls `.resize(w, h)` keeps working when * the instance happens to be a `Light2d`. - * @param {number} radiusX - new horizontal radius - * @param {number} [radiusY=radiusX] - new vertical radius + * @param radiusX - new horizontal radius + * @param [radiusY=radiusX] - new vertical radius */ - setRadii(radiusX, radiusY = radiusX) { + setRadii(radiusX: number, radiusY: number = radiusX) { this.radiusX = radiusX; this.radiusY = radiusY; this.resize(radiusX * 2, radiusY * 2); @@ -213,9 +212,9 @@ export default class Light2d extends Renderable { * returns a geometry representing the visible area of this light, in * world-space coordinates (so it aligns with the rendered gradient * regardless of camera scroll or container parenting). - * @returns {Ellipse} the light visible mask + * @returns the light visible mask */ - getVisibleArea() { + getVisibleArea(): Ellipse { const b = this.getBounds(); // `b.width/b.height` are the transform-aware (and anchor-aware) bbox // dimensions, so the cutout tracks scale changes. @@ -224,17 +223,17 @@ export default class Light2d extends Renderable { /** * update function - * @returns {boolean} true if dirty + * @returns true if dirty */ - update() { + override update(): boolean { return true; } /** * preDraw this Light2d (automatically called by melonJS) - * @param {Renderer} renderer - a renderer instance + * @param renderer - a renderer instance */ - preDraw(renderer) { + override preDraw(renderer: CanvasRenderer | WebGLRenderer) { super.preDraw(renderer); renderer.setBlendMode(this.blendMode); } @@ -246,9 +245,9 @@ export default class Light2d extends Renderable { * own implementation (procedural shader on WebGL; cached `Gradient` * rasterized into a shared `CanvasRenderTarget` on Canvas). Light2d * itself doesn't know which path is used. - * @param {Renderer} renderer - a renderer instance + * @param renderer - a renderer instance */ - draw(renderer) { + override draw(renderer: CanvasRenderer | WebGLRenderer) { if (this.illuminationOnly) { return; } @@ -262,11 +261,8 @@ export default class Light2d extends Renderable { * part of the world tree walk. * @ignore */ - onActivateEvent() { - const stage = state.current(); - if (stage && typeof stage._registerLight === "function") { - stage._registerLight(this); - } + override onActivateEvent() { + state.current()?._registerLight(this); } /** @@ -274,24 +270,19 @@ export default class Light2d extends Renderable { * removed from a container. * @ignore */ - onDeactivateEvent() { - const stage = state.current(); - if (stage && typeof stage._unregisterLight === "function") { - stage._unregisterLight(this); - } + override onDeactivateEvent() { + state.current()?._unregisterLight(this); } /** - * Destroy function
+ * Destroy function * @ignore */ - destroy() { + override destroy() { colorPool.release(this.color); - this.color = undefined; ellipsePool.release(this.visibleArea); - this.visibleArea = undefined; - // Cache entry in the Canvas renderer (if any) becomes GC-eligible - // via its WeakMap when this Light2d is no longer referenced. + // The Canvas renderer's per-light gradient cache entry (if any) becomes + // GC-eligible via its WeakMap once this Light2d is no longer referenced. super.destroy(); } } diff --git a/packages/melonjs/src/lighting/light3d.ts b/packages/melonjs/src/lighting/light3d.ts index ddd904ff5e..c476336d23 100644 --- a/packages/melonjs/src/lighting/light3d.ts +++ b/packages/melonjs/src/lighting/light3d.ts @@ -1,13 +1,18 @@ import { Color } from "../math/color.ts"; import { Vector3d } from "../math/vector3d.ts"; +import Renderable from "../renderable/renderable.js"; +import state from "../state/state.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"; + /** + * light type. `"directional"` (a sun — shaded) and `"ambient"` (a flat fill + * added to every lit pixel) are used today; `"point"` is reserved. + */ + type?: "directional" | "ambient" | "point"; /** world-space direction the light travels along (directional lights). */ direction?: [number, number, number]; /** world-space position (point lights — reserved for a future release). */ @@ -22,26 +27,36 @@ export interface Light3dOptions { } /** - * 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. + * A 3D light source for the mesh lighting path — the 3D counterpart of + * {@link Light2d}. Like `Light2d`, a `Light3d` is a world {@link Renderable}: + * add it to a container with `app.world.addChild(light)` and it auto-registers + * with the active {@link Stage}, so any lit mesh in that scene is shaded by it. + * Remove it from the world to turn it off. A light draws nothing itself. + * + * Two types are used today: + * - **`"directional"`** — a sun: a world-space `direction`, no falloff. Shaded + * via half-Lambert diffuse. + * - **`"ambient"`** — a flat fill added to every lit pixel (the dark side of a + * mesh never goes fully black). `direction` / `position` are ignored. * - * 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`). + * `"point"` is reserved for a future release. Fields are public and mutable, so + * a light can be animated at runtime (e.g. a day/night cycle rotating + * `direction`, or fading `intensity`). * @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: + * import { Light3d } from "melonjs"; + * + * // a sun + a soft ambient fill, added to the world like any renderable + * const sun = new Light3d({ direction: [0.3, 1, 0.2], color: "#fff" }); + * app.world.addChild(sun); + * app.world.addChild(new Light3d({ type: "ambient", intensity: 0.3 })); + * + * // animate the sun in-game (direction is the way light travels) * sun.direction.set(Math.sin(t), 1, Math.cos(t)).normalize(); */ -export class Light3d { - /** `"directional"` (shaded) or `"point"` (reserved). */ - type: "directional" | "point"; +export class Light3d extends Renderable { + /** `"directional"` / `"ambient"` (shaded) or `"point"` (reserved). */ + override type: "directional" | "ambient" | "point"; /** world-space travel direction (directional lights); kept normalized. */ direction: Vector3d; /** world-space position (point lights — reserved). */ @@ -55,6 +70,9 @@ export class Light3d { * @param [options] - see {@link Light3dOptions} */ constructor(options: Light3dOptions = {}) { + // a light has no visual footprint — a sizeless renderable at the origin + super(0, 0, 0, 0); + this.type = options.type ?? "directional"; this.direction = new Vector3d(0, 1, 0); @@ -93,5 +111,32 @@ export class Light3d { } this.intensity = options.intensity ?? 1; + + // nothing to draw, and no transform to apply — keep it off the + // renderer-state path entirely + this.autoTransform = false; + } + + /** + * Register with the active stage's 3D-light set on activation (when added to + * a rooted container), mirroring {@link Light2d}. + * @ignore + */ + override onActivateEvent() { + state.current()?._registerLight3d(this); } + + /** + * Deregister from the active stage when removed from the world. + * @ignore + */ + override onDeactivateEvent() { + state.current()?._unregisterLight3d(this); + } + + /** + * A light has no visual representation. + * @ignore + */ + override draw() {} } diff --git a/packages/melonjs/src/lighting/lighting_environment.ts b/packages/melonjs/src/lighting/lighting_environment.ts deleted file mode 100644 index 47e4ebcc7c..0000000000 --- a/packages/melonjs/src/lighting/lighting_environment.ts +++ /dev/null @@ -1,152 +0,0 @@ -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/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index 1793097a7e..d46c0d541c 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -11,9 +11,11 @@ import { fetchData } from "./fetchdata.js"; * 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. + * / indices) + pbrMetallicRoughness.baseColorTexture (and baseColorFactor) + + * node TRS animation (translation / rotation / scale channels, the rigid + * hierarchical animation used by e.g. Kenney's blocky characters). + * Out of scope: skinning (vertex skinning / JOINTS_0 / WEIGHTS_0), morph + * targets, full PBR maps, KHR extensions, Draco compression. * @ignore */ @@ -77,28 +79,68 @@ export function parseGLB(arrayBuffer) { } /** - * Resolve every glTF buffer to a Uint8Array (GLB bin chunk or data: URI). + * Resolve a glTF relative resource URI (external `.bin` / image) against the + * asset's own URL, the same way a browser resolves a relative ``. + * Returns an absolute URL string, or `null` when the asset URL is unknown + * (e.g. a GLB parsed straight from an ArrayBuffer in a test) — the caller then + * fails with a clear "external resource" message instead of fetching garbage. * @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); +function resolveURI(uri, baseURI) { + if (baseURI === undefined || baseURI === null) { + return null; + } + // `new URL` handles ./, ../, %20-encoding and absolute base normalization. + // Resolve the (possibly relative) asset URL against the document first so a + // page-relative `data.src` like "assets/x.gltf" becomes absolute. + const absoluteBase = new URL( + baseURI, + typeof document !== "undefined" ? document.baseURI : undefined, + ); + return new URL(uri, absoluteBase).href; +} + +/** + * Decode a single base64 `data:` URI payload into a Uint8Array. + * @ignore + */ +function decodeDataURI(uri) { + const base64 = uri.slice(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; +} + +/** + * Resolve every glTF buffer to a Uint8Array. Handles the GLB binary chunk + * (no uri), embedded `data:` URIs, and external `.bin` files fetched relative + * to the asset URL (`baseURI`). Async because external buffers are fetched. + * @ignore + */ +function resolveBuffers(json, bin, baseURI, settings) { + return Promise.all( + (json.buffers || []).map((buffer) => { + if (buffer.uri === undefined) { + return bin; } - return out; - } - // external .bin not supported in Tier 1 - throw new Error( - `glTF: external buffer uri not supported ("${buffer.uri}")`, - ); - }); + if (buffer.uri.startsWith("data:")) { + return decodeDataURI(buffer.uri); + } + // external .bin — fetch it relative to the asset URL + const url = resolveURI(buffer.uri, baseURI); + if (url === null) { + throw new Error( + `glTF: external buffer "${buffer.uri}" cannot be resolved without the asset URL`, + ); + } + return fetchData(url, "arrayBuffer", settings).then((ab) => { + return new Uint8Array(ab); + }); + }), + ); } /** @@ -175,14 +217,21 @@ function readVertexColors(json, buffers, accessorIndex) { 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]; +/** + * Compose a column-major 4x4 local matrix from translation / rotation + * (quaternion) / scale arrays — the glTF TRS convention — writing into `out`. + * In-place so the per-frame animation pose path allocates nothing. + * @param {number[]} out - 16-element destination (returned) + * @param {number[]} translation - [tx, ty, tz] + * @param {number[]} rotation - quaternion [qx, qy, qz, qw] + * @param {number[]} scale - [sx, sy, sz] + * @returns {number[]} `out` + * @ignore + */ +export function composeTRSInto(out, translation, rotation, scale) { + const [tx, ty, tz] = translation; + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; const x2 = qx + qx; const y2 = qy + qy; const z2 = qz + qz; @@ -195,24 +244,47 @@ export function nodeLocalMatrix(node) { 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, - ]; + out[0] = (1 - (yy + zz)) * sx; + out[1] = (xy + wz) * sx; + out[2] = (xz - wy) * sx; + out[3] = 0; + out[4] = (xy - wz) * sy; + out[5] = (1 - (xx + zz)) * sy; + out[6] = (yz + wx) * sy; + out[7] = 0; + out[8] = (xz + wy) * sz; + out[9] = (yz - wx) * sz; + out[10] = (1 - (xx + yy)) * sz; + out[11] = 0; + out[12] = tx; + out[13] = ty; + out[14] = tz; + out[15] = 1; + return out; +} + +/** + * Allocating form of {@link composeTRSInto} — returns a fresh 16-element array. + * @param {number[]} translation - [tx, ty, tz] + * @param {number[]} rotation - quaternion [qx, qy, qz, qw] + * @param {number[]} scale - [sx, sy, sz] + * @returns {number[]} 16-element column-major matrix + * @ignore + */ +export function composeTRS(translation, rotation, scale) { + return composeTRSInto(new Array(16), translation, rotation, scale); +} + +/** Compose a node's local matrix from its `matrix` or TRS fields. @ignore */ +export function nodeLocalMatrix(node) { + if (node.matrix) { + return node.matrix.slice(); + } + return composeTRS( + node.translation || [0, 0, 0], + node.rotation || [0, 0, 0, 1], + node.scale || [1, 1, 1], + ); } /** @@ -278,9 +350,13 @@ function normalize3(v) { 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); +/** + * Column-major 4x4 multiply `a * b`, writing into `out`. `out` must NOT alias + * `a` or `b` (results are written as they're computed). In-place so the + * per-frame pose path allocates nothing. + * @ignore + */ +export function multiplyMatrixInto(out, a, b) { for (let col = 0; col < 4; col++) { for (let row = 0; row < 4; row++) { out[col * 4 + row] = @@ -293,13 +369,19 @@ export function multiplyMatrix(a, b) { return out; } +/** Allocating form of {@link multiplyMatrixInto}: `a * b` → fresh array. @ignore */ +export function multiplyMatrix(a, b) { + return multiplyMatrixInto(new Array(16), a, b); +} + /** - * Decode a glTF image (embedded bufferView or data URI) into an - * HTMLImageElement. + * Decode a glTF image into an HTMLImageElement. Handles the three sources: + * an embedded `bufferView`, an inline `data:` URI, and an external image file + * referenced by relative `uri` (resolved against the asset URL `baseURI`). * @returns {Promise} * @ignore */ -function decodeImage(json, buffers, imageIndex) { +function decodeImage(json, buffers, imageIndex, baseURI, settings) { const image = json.images[imageIndex]; let blob; if (image.bufferView !== undefined) { @@ -312,6 +394,21 @@ function decodeImage(json, buffers, imageIndex) { blob = new Blob([slice], { type: image.mimeType || "image/png" }); } else if (image.uri && image.uri.startsWith("data:")) { return loadImageFromUrl(image.uri); + } else if (image.uri) { + // external image file — resolve relative to the asset URL and let the + // browser fetch it directly (no object-URL to revoke). Forward the + // loader's crossOrigin so a cross-origin texture isn't tainted (which + // would throw on the WebGL `texImage2D` upload); same-origin is + // unaffected. + const url = resolveURI(image.uri, baseURI); + if (url === null) { + return Promise.reject( + new Error( + `glTF: external image "${image.uri}" cannot be resolved without the asset URL`, + ), + ); + } + return loadImageFromUrl(url, false, settings?.crossOrigin); } else { return Promise.reject(new Error("glTF: unsupported image source")); } @@ -322,9 +419,14 @@ function decodeImage(json, buffers, imageIndex) { } /** @ignore */ -function loadImageFromUrl(url, revoke = false) { +function loadImageFromUrl(url, revoke = false, crossOrigin) { return new Promise((resolve, reject) => { const img = new Image(); + // must be set before `src` to take effect; only for real (non-blob, + // non-data) URLs that may be cross-origin + if (typeof crossOrigin === "string") { + img.crossOrigin = crossOrigin; + } img.onload = () => { if (revoke) { URL.revokeObjectURL(url); @@ -343,18 +445,23 @@ function loadImageFromUrl(url, revoke = false) { /** * Parse a glTF/GLB ArrayBuffer into a flat, instantiable scene descriptor. - * @param {ArrayBuffer} arrayBuffer - * @returns {Promise} `{ nodes, cameras, bounds }` + * @param {ArrayBuffer} arrayBuffer - the .glb / .gltf bytes + * @param {string} [baseURI] - the asset's own URL, used to resolve external + * `.bin` buffers and image files referenced by relative `uri`. Omit for a fully + * self-contained GLB (embedded buffers + data-URI / bufferView images). + * @param {object} [settings] - loader settings forwarded to `fetchData` for + * external resources (crossOrigin / withCredentials / nocache). + * @returns {Promise} `{ nodes, cameras, lights, bounds, graph, animations }` * @ignore */ -export async function parseGLTF(arrayBuffer) { +export async function parseGLTF(arrayBuffer, baseURI, settings) { const { json, bin } = parseGLB(arrayBuffer); - const buffers = resolveBuffers(json, bin); + const buffers = await resolveBuffers(json, bin, baseURI, settings); // decode every image once, keyed by image index const images = await Promise.all( (json.images || []).map((_, i) => { - return decodeImage(json, buffers, i); + return decodeImage(json, buffers, i, baseURI, settings); }), ); @@ -363,13 +470,13 @@ export async function parseGLTF(arrayBuffer) { if (materialIndex === undefined) { return null; } - const mat = json.materials[materialIndex]; + 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; + const imageIndex = json.textures?.[tex.index]?.source; + return imageIndex !== undefined ? images[imageIndex] || null : null; }; // resolve material index -> baseColorFactor [r,g,b,a] in 0..1 (defaults to @@ -381,12 +488,47 @@ export async function parseGLTF(arrayBuffer) { return [1, 1, 1, 1]; } return ( - json.materials[materialIndex]?.pbrMetallicRoughness?.baseColorFactor ?? [ - 1, 1, 1, 1, - ] + json.materials?.[materialIndex]?.pbrMetallicRoughness + ?.baseColorFactor ?? [1, 1, 1, 1] ); }; + // resolve material index -> melonJS texture wrap mode, honoring the glTF + // sampler's `wrapS` / `wrapT`. The glTF default sampler wrap is REPEAT + // (10497) on both axes — many exporters author UVs outside `[0, 1]` that + // rely on it, so a missing sampler / texture must default to "repeat", not + // clamp. CLAMP_TO_EDGE is 33071; MIRRORED_REPEAT (33648) has no melonJS + // equivalent and maps to plain repeat. + const CLAMP = 33071; + const materialTextureRepeat = (materialIndex) => { + let wrapS = 10497; + let wrapT = 10497; + const tex = + materialIndex !== undefined + ? json.materials?.[materialIndex]?.pbrMetallicRoughness + ?.baseColorTexture + : undefined; + if (tex) { + const samplerIndex = json.textures?.[tex.index]?.sampler; + const sampler = + samplerIndex !== undefined ? json.samplers?.[samplerIndex] : undefined; + wrapS = sampler?.wrapS ?? 10497; + wrapT = sampler?.wrapT ?? 10497; + } + const repeatS = wrapS !== CLAMP; + const repeatT = wrapT !== CLAMP; + if (repeatS && repeatT) { + return "repeat"; + } + if (repeatS) { + return "repeat-x"; + } + if (repeatT) { + return "repeat-y"; + } + return "no-repeat"; + }; + // 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 @@ -400,12 +542,79 @@ export async function parseGLTF(arrayBuffer) { const sceneIndex = json.scene ?? 0; const roots = json.scenes?.[sceneIndex]?.nodes ?? []; + // Read one mesh primitive's geometry (positions / uvs / indices / normals / + // colors) + resolved material color. Shared by the flat static `meshNodes` + // list and the hierarchical `graph` (animated path) so geometry is read + // exactly once per primitive and both views share the same typed arrays. + const readPrimitiveGeometry = (prim) => { + 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; + return { + vertices, + normals, + uvs, + indices, + vertexCount, + image: materialImage(prim.material), + // texture wrap mode from the glTF sampler (default REPEAT) — see + // materialTextureRepeat; carried so the Mesh samples tiling UVs + // correctly instead of clamping to flat edge texels + textureRepeat: materialTextureRepeat(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, + }; + }; + // 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(); + // the full node hierarchy (animated path): glTF node index → graph node + // carrying rest TRS, children, and any mesh primitives. Built alongside the + // flat `meshNodes` from the same single geometry read. + const graphNodes = {}; + const visit = (nodeIndex, parentWorld) => { if (visited.has(nodeIndex)) { return; @@ -413,64 +622,30 @@ export async function parseGLTF(arrayBuffer) { visited.add(nodeIndex); const node = json.nodes[nodeIndex]; const world = multiplyMatrix(parentWorld, nodeLocalMatrix(node)); + const nodeName = node.name || `node_${nodeIndex}`; + const primitives = []; 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, - }); + for (const prim of json.meshes[node.mesh].primitives) { + const geo = readPrimitiveGeometry(prim); + primitives.push(geo); + // flat static entry — same shape (+ world + name) as before so the + // static path and bounds computation are unchanged + meshNodes.push({ name: nodeName, world, ...geo }); } } + // graph node: rest TRS (glTF defaults when a field is absent), an explicit + // `matrix` if the node used one, children, and its mesh primitives. The + // animated path samples into a mutable copy of this TRS each frame. + graphNodes[nodeIndex] = { + index: nodeIndex, + name: nodeName, + translation: node.translation ? node.translation.slice() : [0, 0, 0], + rotation: node.rotation ? node.rotation.slice() : [0, 0, 0, 1], + scale: node.scale ? node.scale.slice() : [1, 1, 1], + matrix: node.matrix ? node.matrix.slice() : null, + children: (node.children || []).slice(), + primitives, + }; if (node.camera !== undefined) { cameras.push({ ...json.cameras[node.camera], world }); } @@ -516,7 +691,53 @@ export async function parseGLTF(arrayBuffer) { max[0] = max[1] = max[2] = 0; } - return { nodes: meshNodes, cameras, lights, bounds: { min, max } }; + // node-TRS animation clips. Each channel binds a sampler (keyframe times + + // values) to a node's translation / rotation / scale. Other paths (weights / + // morph targets) are skipped — out of Tier-1 scope. Channels targeting a node + // outside the active scene are dropped. `duration` is the latest keyframe + // time across the clip's samplers (seconds). + const animations = (json.animations || []).map((anim, ai) => { + let duration = 0; + const channels = []; + for (const ch of anim.channels) { + const path = ch.target?.path; + const nodeIndex = ch.target?.node; + if ( + nodeIndex === undefined || + graphNodes[nodeIndex] === undefined || + (path !== "translation" && path !== "rotation" && path !== "scale") + ) { + continue; + } + const sampler = anim.samplers[ch.sampler]; + const times = readAccessor(json, buffers, sampler.input); + const values = readAccessor(json, buffers, sampler.output); + if (times.length > 0) { + duration = Math.max(duration, times[times.length - 1]); + } + channels.push({ + node: nodeIndex, + path, + times, + values, + // component count per keyframe value (rotation = quaternion VEC4) + stride: path === "rotation" ? 4 : 3, + interpolation: sampler.interpolation || "LINEAR", + }); + } + return { name: anim.name || `anim_${ai}`, duration, channels }; + }); + + return { + nodes: meshNodes, + cameras, + lights, + bounds: { min, max }, + // hierarchical node graph + animation clips for the animated path; the + // static path ignores both. `graph.nodes` is keyed by glTF node index. + graph: { roots, nodes: graphNodes }, + animations, + }; } /** @@ -534,7 +755,10 @@ export function preloadGLTF(data, onload, onerror, settings) { } fetchData(data.src, "arrayBuffer", settings) .then((buffer) => { - return parseGLTF(buffer); + // pass the asset URL so external `.bin` / image `uri`s resolve + // relative to it (a GLB with an external texture, like Kenney's + // blocky characters, loads as-shipped without repackaging) + return parseGLTF(buffer, data.src, settings); }) .then((scene) => { gltfList[data.name] = scene; diff --git a/packages/melonjs/src/loader/parsers/mtl.js b/packages/melonjs/src/loader/parsers/mtl.js index 62439ae070..47e6b5e654 100644 --- a/packages/melonjs/src/loader/parsers/mtl.js +++ b/packages/melonjs/src/loader/parsers/mtl.js @@ -1,5 +1,7 @@ +import { getBasename } from "../../utils/file.ts"; import { mtlList } from "../cache.js"; import { fetchData } from "./fetchdata.js"; +import { preloadImage } from "./image.js"; // supported MTL properties const SUPPORTED_PROPS = new Set([ @@ -144,7 +146,53 @@ export function preloadMTL(data, onload, onerror, settings) { fetchData(data.src, "text", settings) .then((response) => { - mtlList[data.name] = parseMTL(response, basePath); + const materials = parseMTL(response, basePath); + mtlList[data.name] = materials; + // Auto-load the diffuse textures referenced by `map_Kd`, resolved + // relative to the MTL file and registered under that resolved path — + // so a Mesh built with `material:` (and no explicit `texture:`) finds + // them via `getImage(map_Kd)` without the caller having to preload + // each texture separately (parity with the glTF loader, which fetches + // a scene's external textures automatically). A texture that fails to + // load is warned and skipped (the mesh falls back to the white pixel), + // so one missing map_Kd doesn't abort the whole load. + const texturePaths = [ + ...new Set( + Object.values(materials) + .map((material) => { + return material.map_Kd; + }) + .filter(Boolean), + ), + ]; + return Promise.all( + texturePaths.map((path) => { + return new Promise((resolve) => { + // register under the basename — `getImage` (used by Mesh to + // resolve `map_Kd`) normalizes its lookup key via getBasename, + // so the image must be stored under that same key to be found. + const loading = preloadImage( + { name: getBasename(path), src: path }, + resolve, + () => { + console.warn( + `melonJS: MTL texture "${path}" could not be loaded`, + ); + resolve(); + }, + settings, + ); + // preloadImage returns 0 when the image is already cached — + // it then never calls our onload, so resolve now to avoid + // hanging the Promise.all. + if (loading === 0) { + resolve(); + } + }); + }), + ); + }) + .then(() => { if (typeof onload === "function") { onload(); } diff --git a/packages/melonjs/src/renderable/animation.ts b/packages/melonjs/src/renderable/animation.ts new file mode 100644 index 0000000000..fdc2b72960 --- /dev/null +++ b/packages/melonjs/src/renderable/animation.ts @@ -0,0 +1,66 @@ +/** + * Normalized options for a played animation, shared by the 2D {@link Sprite} + * frame-animation path and the 3D {@link Mesh} keyframe-animation path so both + * accept the same `setCurrentAnimation(name, …)` second argument. + * @category Animation + */ +export interface AnimationOptions { + /** called when the animation completes a cycle (each loop, or once if `loop: false`). */ + onComplete?: (() => unknown) | undefined; + /** name of an animation to switch to when this one finishes. */ + next?: string | undefined; + /** loop forever (default) or play once and hold the last frame. */ + loop: boolean; + /** playback rate multiplier (1 = authored speed). */ + speed: number; + /** + * the argument was a bare function — the legacy `Sprite` callback whose + * `false` return holds the last frame. Carried so callers can preserve that + * exact contract; not part of the public options shape. + * @ignore + */ + legacyFn?: boolean; +} + +/** + * Normalize the polymorphic second argument of `setCurrentAnimation` into a + * uniform {@link AnimationOptions}. Accepts: + * - `undefined` → loop forever + * - a `string` → the name of the next animation to chain to + * - a `function` → legacy completion callback (return `false` to hold the last frame) + * - an options object → `{ onComplete, next, loop, speed }` + * @param arg - the second argument passed to `setCurrentAnimation` + * @returns the normalized options + * @category Animation + */ +export function parseAnimationOptions( + arg?: + | string + | (() => unknown) + | { + onComplete?: () => unknown; + next?: string; + loop?: boolean; + speed?: number; + } + // `null` is passed by the internal animation-chain call + // (`setCurrentAnimation(next, null, true)`), so it must be accepted. + | null, +): AnimationOptions { + if (typeof arg === "string") { + return { next: arg, loop: true, speed: 1 }; + } + if (typeof arg === "function") { + return { onComplete: arg, loop: true, speed: 1, legacyFn: true }; + } + if (arg !== null && typeof arg === "object") { + return { + onComplete: arg.onComplete, + next: arg.next, + // only an explicit `false` disables looping + loop: arg.loop !== false, + speed: typeof arg.speed === "number" ? arg.speed : 1, + }; + } + return { loop: true, speed: 1 }; +} diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 585662ca62..cff15b13ad 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -116,6 +116,7 @@ export default class Mesh extends Renderable { * @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}. + * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture. Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { @@ -230,7 +231,7 @@ export default class Mesh extends Renderable { /** * 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}). + * lit shading under a `Camera3d` (see {@link Light3d}). * @type {Float32Array|undefined} */ this.originalNormals = @@ -249,7 +250,7 @@ export default class Mesh extends Renderable { this.normals = new Float32Array(this.vertexCount * 3); /** - * Whether this mesh is lit by the active {@link LightingEnvironment}. + * Whether this mesh is lit by the active stage's {@link Light3d} lights. * 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 @@ -420,11 +421,30 @@ export default class Mesh extends Renderable { // without a `texture:` or a `map_Kd`-bearing `material:` (the // GPU pipeline still needs something to sample; tint / per- // vertex color does the actual coloring). + const hasRealTexture = !!textureSource; if (!textureSource) { textureSource = Renderer.getWhitePixel(); } this.texture = resolveTextureAtlas(textureSource); + // Optional texture wrap mode. Some assets author UVs outside the + // `[0, 1]` range and rely on the sampler repeating the texture (this is + // the glTF default sampler behavior); the mesh would otherwise clamp to + // the edge texels and look flat / untextured. Applied only to a real + // texture — never the shared white-pixel fallback, which is global and + // must stay `"no-repeat"`. One of `"repeat"` / `"repeat-x"` / + // `"repeat-y"` / `"no-repeat"`. + // + // NOTE: `cache.get(image)` returns a TextureAtlas shared per source + // image, so this sets the wrap mode IMAGE-GLOBALLY — every consumer of + // the same image object samples with this wrap. Harmless for glTF (each + // asset decodes its own image objects), but don't point two meshes that + // need different wrap modes at the same image. (Tracked in #1503, to be + // fixed with the #1410 TextureCache refactor.) + if (hasRealTexture && typeof settings.textureRepeat === "string") { + this.texture.repeat = settings.textureRepeat; + } + /** * Projection matrix applied automatically before the model transform in draw(). * Defaults to a perspective projection (45° FOV, camera at z=-2.5) suitable for diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index 85faa24f10..cffc72d79b 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -4,6 +4,7 @@ import { Color } from "../math/color.ts"; import { vector2dPool } from "../math/vector2d.ts"; import { on } from "../system/event.ts"; import { TextureAtlas } from "./../video/texture/atlas.js"; +import { parseAnimationOptions } from "./animation.ts"; import Renderable from "./renderable.js"; // flicker interval in ms (~15 flashes per second) @@ -143,6 +144,16 @@ export default class Sprite extends Renderable { // animation frame delta this.dt = 0; + // playback rate multiplier set per-play via the options form of + // setCurrentAnimation (1 = authored speed). Scales how fast `dt` + // accumulates, on top of each frame's `delay`. + this._animSpeed = 1; + + // set true when a `loop: false` animation has completed its single + // cycle, so update() stops advancing without touching `animationpause` + // (cleared whenever a new animation is selected). + this._animDone = false; + /** * flicker settings * @ignore @@ -388,17 +399,55 @@ export default class Sprite extends Renderable { } /** - * play or resume the current animation or video + * Play an animation, or resume the current animation / video. A shorthand: + * call with an animation id to switch to (and start) it, or with no argument + * to resume after {@link Sprite#pause}. Always clears the paused state. The + * options mirror {@link Sprite#setCurrentAnimation} and the 3D + * {@link GLTFModel#play}, so 2D and 3D animation share one API. + * @param {string} [name] - animation id to play; omit to just resume + * @param {string|Function|object} [options] - loop / chain / completion behavior (see {@link Sprite#setCurrentAnimation}) + * @returns {Sprite} Reference to this object for method chaining + * @example + * sprite.play("walk"); // switch to + play "walk" + * sprite.play("die", { loop: false }); // play once, hold the last frame + * sprite.pause(); + * sprite.play(); // resume */ - play() { + play(name, options) { this.animationpause = false; + // `name` only applies to frame animations; a video sprite just resumes + if (name !== undefined && !this.isVideo) { + this.setCurrentAnimation(name, options); + } + return this; } /** - * play or resume the current animation or video + * Pause the current animation or video, freezing the current frame. Resume + * with {@link Sprite#play}. + * @returns {Sprite} Reference to this object for method chaining */ pause() { this.animationpause = true; + return this; + } + + /** + * Stop the current animation or video and reset it to the first frame + * (paused). (Use {@link Sprite#pause} instead to freeze in place.) + * @returns {Sprite} Reference to this object for method chaining + */ + stop() { + this.animationpause = true; + this._animDone = false; + this.dt = 0; + if (this.isVideo) { + this.image.pause(); + this.image.currentTime = 0; + } else if (this.current.name !== undefined && this.current.length > 0) { + this.setAnimationFrame(0); + } + return this; } /** @@ -566,15 +615,40 @@ export default class Sprite extends Renderable { if (!this.isCurrentAnimation(name)) { this.current.name = name; this.current.length = this.anim[this.current.name].length; - if (typeof resetAnim === "string") { - this.resetAnim = this.setCurrentAnimation.bind( - this, - resetAnim, - null, - true, - ); - } else if (typeof resetAnim === "function") { - this.resetAnim = resetAnim; + const opts = parseAnimationOptions(resetAnim); + this._animSpeed = opts.speed; + this._animDone = false; + const onComplete = opts.onComplete; + if (opts.legacyFn) { + // legacy bare-function callback: invoked at each loop end, + // return `false` to hold the last frame (contract unchanged) + this.resetAnim = onComplete; + } else if (typeof opts.next === "string") { + // chain to another animation when this one ends (the legacy + // string form and the options `next` field), firing + // `onComplete` first when provided + const next = opts.next; + this.resetAnim = () => { + if (typeof onComplete === "function") { + onComplete(); + } + this.setCurrentAnimation(next, null, true); + }; + } else if (opts.loop === false) { + // play once: fire onComplete, hold the last frame, and stop + // advancing (without touching `animationpause`) + this.resetAnim = () => { + if (typeof onComplete === "function") { + onComplete(); + } + this._animDone = true; + return false; + }; + } else if (typeof onComplete === "function") { + // loop forever, firing onComplete at each cycle + this.resetAnim = () => { + onComplete(); + }; } else { this.resetAnim = undefined; } @@ -619,6 +693,19 @@ export default class Sprite extends Renderable { return this.current.name === name; } + /** + * the names of every animation defined on this sprite (via + * {@link Sprite#addAnimation}). + * @returns {string[]} the defined animation names + * @example + * sprite.addAnimation("walk", [0, 1, 2, 3]); + * sprite.addAnimation("idle", [4, 5]); + * sprite.getAnimationNames(); // ["walk", "idle"] + */ + getAnimationNames() { + return Object.keys(this.anim); + } + /** * change the current texture atlas region for this sprite * @see Texture.getRegion @@ -728,11 +815,13 @@ export default class Sprite extends Renderable { this.isDirty = !this.image.paused; } else { // Update animation if necessary - if (!this.animationpause && this.current.length > 1) { + if (!this.animationpause && !this._animDone && this.current.length > 1) { let duration = this.getAnimationFrameObjectByIndex( this.current.idx, ).delay; - this.dt += dt; + // `_animSpeed` (per-play multiplier) scales how fast the frame + // delay is consumed — 2 = twice as fast, 0.5 = half speed + this.dt += dt * this._animSpeed; while (this.dt >= duration) { this.isDirty = true; this.dt -= duration; diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index 7e222620dd..7ea89203d3 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -1,8 +1,9 @@ import type Application from "./../application/application.ts"; import Camera2d from "./../camera/camera2d.ts"; +import type Light2d from "./../lighting/light2d.ts"; +import type { Light3d } from "./../lighting/light3d.ts"; import { Color } from "./../math/color.ts"; import type World from "./../physics/world.js"; -import type Light2d from "./../renderable/light2d.js"; import { emit, STAGE_RESET } from "../system/event.ts"; import type Renderer from "./../video/renderer.js"; @@ -80,6 +81,14 @@ export default class Stage { */ _activeLights: Set; + /** + * Internal set of active 3D lights, auto-populated by `Light3d`'s + * `onActivateEvent` / `onDeactivateEvent` hooks. Read by the lit mesh + * batcher each frame to shade `lit` meshes under a `Camera3d`. + * @ignore + */ + _activeLights3d: Set; + /** * an ambient light that will be added to the stage rendering * @default "#000000" @@ -113,6 +122,7 @@ export default class Stage { this.cameras = new Map(); this.lights = new Map(); this._activeLights = new Set(); + this._activeLights3d = new Set(); this.ambientLight = new Color(0, 0, 0, 0); this.ambientLightingColor = new Color(0, 0, 0, 1); this.settings = Object.assign({}, default_settings, settings || {}); @@ -135,6 +145,23 @@ export default class Stage { this._activeLights.delete(light); } + /** + * Called by `Light3d.onActivateEvent` to register a 3D light with the stage. + * Read by the lit mesh batcher. Users normally don't call this. + * @ignore + */ + _registerLight3d(light: Light3d): void { + this._activeLights3d.add(light); + } + + /** + * Called by `Light3d.onDeactivateEvent` to deregister a 3D light. + * @ignore + */ + _unregisterLight3d(light: Light3d): void { + this._activeLights3d.delete(light); + } + /** * Object reset function * @ignore @@ -369,6 +396,7 @@ export default class Stage { }); this.lights.clear(); this._activeLights.clear(); + this._activeLights3d.clear(); // notify the object this.onDestroyEvent(app); } diff --git a/packages/melonjs/src/system/bootstrap.ts b/packages/melonjs/src/system/bootstrap.ts index 2401a30bcd..9631b8b7cf 100644 --- a/packages/melonjs/src/system/bootstrap.ts +++ b/packages/melonjs/src/system/bootstrap.ts @@ -1,12 +1,12 @@ import { initKeyboardEvent } from "../input/keyboard.ts"; import { registerBuiltinTiledClass } from "../level/tiled/TMXObjectFactory.js"; +import Light2d from "../lighting/light2d.ts"; import { setNocache } from "../loader/loader.js"; import Particle from "../particles/particle.ts"; import Collectable from "../renderable/collectable.js"; import ColorLayer from "../renderable/colorlayer.js"; import Entity from "../renderable/entity/entity.js"; import ImageLayer from "../renderable/imagelayer.js"; -import Light2d from "../renderable/light2d.js"; import NineSliceSprite from "../renderable/nineslicesprite.js"; import Renderable from "../renderable/renderable.js"; import Sprite from "../renderable/sprite.js"; diff --git a/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js index 307482cfc8..d8ad249256 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js @@ -1,5 +1,6 @@ -import { LightingEnvironment } from "../../../lighting/lighting_environment.ts"; +import state from "../../../state/state.ts"; import { MAX_LIGHTS } from "../lighting/constants.ts"; +import { packMeshLights } from "../lighting/pack3d.ts"; import litFragment from "./../shaders/mesh-lit.frag"; import litVertex from "./../shaders/mesh-lit.vert"; import MeshBatcher from "./mesh_batcher.js"; @@ -13,14 +14,14 @@ const litFragmentResolved = litFragment.replaceAll( 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. +// ambient used when a lit mesh is drawn with no active directional lights — +// render it fullbright (white ambient) rather than dark, so a `lit` mesh in a +// scene without lights 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 + + * A {@link MeshBatcher} variant that shades meshes with the active stage's + * {@link Light3d} lights (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. * @@ -69,20 +70,26 @@ export default class LitMeshBatcher extends MeshBatcher { /** * 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. + * the active stage's 3D lights to the lit shader. With NO lights at all + * (no directional and no ambient), a white ambient keeps a `lit` mesh + * fullbright (so it matches the unlit path); an ambient-only scene still + * uses its real ambient. */ bind() { super.bind(); - const lit = LightingEnvironment.default.pack(); + const stage = state.current(); + const lit = packMeshLights(stage ? stage._activeLights3d : null); 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); } + // use the packed ambient whenever any light contributed (directional + // OR ambient); fall back to fullbright white only when the scene has no + // 3D lights at all — otherwise an ambient-only setup would be ignored. + const a = lit.ambient; + const hasLight = lit.count > 0 || a[0] > 0 || a[1] > 0 || a[2] > 0; + shader.setUniform("uAmbient", hasLight ? a : _WHITE_AMBIENT); } } diff --git a/packages/melonjs/src/video/webgl/lighting/pack3d.ts b/packages/melonjs/src/video/webgl/lighting/pack3d.ts new file mode 100644 index 0000000000..2ce7cd670a --- /dev/null +++ b/packages/melonjs/src/video/webgl/lighting/pack3d.ts @@ -0,0 +1,87 @@ +import type { Light3d } from "../../../lighting/light3d.ts"; +import { MAX_LIGHTS } from "./constants.ts"; + +/** + * Uniform-ready packing of the active 3D lights for the mesh-lit shader. + * @ignore + */ +export interface PackedMeshLighting { + /** 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` directional light colors premultiplied by intensity. */ + colors: Float32Array; + /** the summed ambient color (RGB, 0..1+). */ + ambient: Float32Array; +} + +// reused output buffers — the packed result is consumed immediately each frame +// by the lit mesh batcher, so a single shared set is safe and allocation-free. +const _dir = new Float32Array(MAX_LIGHTS * 3); +const _color = new Float32Array(MAX_LIGHTS * 3); +const _ambient = new Float32Array(3); +const _result: PackedMeshLighting = { + count: 0, + directions: _dir, + colors: _color, + ambient: _ambient, +}; + +/** + * Pack an iterable of {@link Light3d} (e.g. the active `Stage`'s 3D-light set) + * into the uniform arrays the mesh-lit shader reads: + * - **directional** lights contribute a surface→light direction (negated travel + * direction, normalized) + a color premultiplied by intensity, up to + * `MAX_LIGHTS`. Re-normalized here so a direction mutated at runtime without + * re-normalizing still shades correctly. + * - **ambient** lights are summed into a single flat ambient color. + * + * Other types (`"point"`) are skipped — not shaded yet. The same buffers are + * returned each call (overwritten in place). + * @param lights - iterable of lights, or `null`/`undefined` (treated as empty) + * @returns the packed lighting (reused instance) + * @ignore + */ +export function packMeshLights( + lights: Iterable | null | undefined, +): PackedMeshLighting { + let count = 0; + let ar = 0; + let ag = 0; + let ab = 0; + if (lights) { + for (const light of lights) { + if (light.type === "ambient") { + const k = light.intensity; + ar += (light.color.r / 255) * k; + ag += (light.color.g / 255) * k; + ab += (light.color.b / 255) * k; + continue; + } + // only directional lights are shaded in this release + if (light.type !== "directional" || count >= MAX_LIGHTS) { + continue; + } + const o = count * 3; + const dx = light.direction.x; + const dy = light.direction.y; + const dz = light.direction.z; + const len = Math.hypot(dx, dy, dz) || 1; + // store the surface→light vector (negated travel direction), normalized + _dir[o] = -dx / len; + _dir[o + 1] = -dy / len; + _dir[o + 2] = -dz / len; + const k = light.intensity; + _color[o] = (light.color.r / 255) * k; + _color[o + 1] = (light.color.g / 255) * k; + _color[o + 2] = (light.color.b / 255) * k; + count++; + } + } + _ambient[0] = ar; + _ambient[1] = ag; + _ambient[2] = ab; + _result.count = count; + return _result; +} diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert index b347b09d74..1bef60c481 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert @@ -1,4 +1,4 @@ -// Lit mesh vertex shader (Camera3d + LightingEnvironment). Same as mesh.vert +// Lit mesh vertex shader (Camera3d + Light3d). Same as mesh.vert // plus a world-space normal carried to the fragment shader for diffuse shading. attribute vec3 aVertex; attribute vec2 aRegion; diff --git a/packages/melonjs/tests/animation.spec.js b/packages/melonjs/tests/animation.spec.js new file mode 100644 index 0000000000..563ac735dc --- /dev/null +++ b/packages/melonjs/tests/animation.spec.js @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { parseAnimationOptions } from "../src/renderable/animation.ts"; + +/** + * Unit tests for parseAnimationOptions — the shared helper that normalizes the + * polymorphic 2nd argument of `setCurrentAnimation` (used by both the 2D Sprite + * and the 3D GLTFModel animation paths). Pure, no engine deps. + */ +describe("parseAnimationOptions", () => { + it("undefined → loop forever at authored speed", () => { + expect(parseAnimationOptions(undefined)).toEqual({ loop: true, speed: 1 }); + }); + + it("null (internal chain call) → loop forever at authored speed", () => { + // the animation-chain calls setCurrentAnimation(next, null, true) + expect(parseAnimationOptions(null)).toEqual({ loop: true, speed: 1 }); + }); + + it("no argument → loop forever at authored speed", () => { + expect(parseAnimationOptions()).toEqual({ loop: true, speed: 1 }); + }); + + it("a string → chains to that animation (next), looping", () => { + expect(parseAnimationOptions("walk")).toEqual({ + next: "walk", + loop: true, + speed: 1, + }); + }); + + it("a function → legacy completion callback (legacyFn flagged)", () => { + const fn = () => {}; + const opts = parseAnimationOptions(fn); + expect(opts.onComplete).toBe(fn); + expect(opts.legacyFn).toBe(true); + expect(opts.loop).toBe(true); + expect(opts.speed).toBe(1); + }); + + it("an options object maps through, defaulting loop=true / speed=1", () => { + const onComplete = () => {}; + const opts = parseAnimationOptions({ onComplete, next: "idle" }); + expect(opts.onComplete).toBe(onComplete); + expect(opts.next).toBe("idle"); + expect(opts.loop).toBe(true); + expect(opts.speed).toBe(1); + expect(opts.legacyFn).toBeUndefined(); + }); + + it("options loop:false disables looping (only an explicit false)", () => { + expect(parseAnimationOptions({ loop: false }).loop).toBe(false); + // any other falsy-ish value is NOT treated as false + expect(parseAnimationOptions({}).loop).toBe(true); + expect(parseAnimationOptions({ loop: undefined }).loop).toBe(true); + }); + + it("options speed passes through, including 0", () => { + expect(parseAnimationOptions({ speed: 2 }).speed).toBe(2); + expect(parseAnimationOptions({ speed: 0.5 }).speed).toBe(0.5); + // 0 is a valid speed (freeze) — must not fall back to the default 1 + expect(parseAnimationOptions({ speed: 0 }).speed).toBe(0); + }); + + it("ADVERSARIAL: a non-numeric speed falls back to 1", () => { + // guards the `typeof speed === "number"` check + expect(parseAnimationOptions({ speed: "fast" }).speed).toBe(1); + }); + + it("ADVERSARIAL: a string is treated as next, NOT a legacy callback", () => { + const opts = parseAnimationOptions("die"); + expect(opts.next).toBe("die"); + expect(opts.legacyFn).toBeUndefined(); + expect(opts.onComplete).toBeUndefined(); + }); + + it("ADVERSARIAL: an options object never sets legacyFn (only a bare function does)", () => { + const opts = parseAnimationOptions({ onComplete: () => {}, loop: false }); + expect(opts.legacyFn).toBeUndefined(); + }); +}); diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index 122a81d00d..d9666efb17 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -631,6 +631,26 @@ describe("Camera3d", () => { expect(child.pos.z).toBe(0); // local — sanity-check the test setup expect(cam.isVisible(child)).toBe(true); }); + + it("ADVERSARIAL: a sizeless container (infinite bounds) is always visible, never NaN-culled", async () => { + // A logical grouping container with no intrinsic size has + // infinite / cleared bounds (left=+∞, right=-∞), so width/height + // are non-finite and the bounding-sphere radius would be NaN. + // `intersectsSphere(_, NaN)` is false, which would silently cull the + // container AND skip its whole subtree (both draw and update). Such a + // container can't be frustum-culled meaningfully, so it must report + // visible and let its children be culled individually — the bug that + // kept a GLTFModel rig (meshes nested under a sizeless container) + // from rendering under a 3D camera. + const cam = setupCam(); + const Container = (await import("../src/renderable/container.js")) + .default; + const group = new Container(0, 0); // default width/height = Infinity + expect(group.getBounds().isFinite()).toBe(false); + // place it well outside any finite frustum sphere — still visible + group.pos.set(99999, 99999); + expect(cam.isVisible(group)).toBe(true); + }); }); describe("backward compat with Camera2d API", () => { diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index c6ea5ef1a3..298da43631 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1,12 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - boot, - LightingEnvironment, - level, - loader, - Mesh, - video, -} from "../src/index.js"; +import { boot, Light3d, level, loader, Mesh, video } from "../src/index.js"; import GLTFScene from "../src/level/gltf/GLTFScene.js"; import { gltfList } from "../src/loader/cache.js"; import { @@ -346,6 +339,35 @@ describe("parseGLTF() robustness", () => { parseGLTF(packGLB(json, new Uint8Array(positions.buffer))), ).rejects.toThrow(/unsupported accessor/); }); + + it("ADVERSARIAL: a primitive referencing a material with no `materials` array degrades to defaults (no throw)", 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 }], + // prim.material points at an entry, but `materials` is absent entirely + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }], + 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)), + ); + const node = scene.nodes[0]; + // material helpers fall back gracefully rather than throwing on the + // missing `materials` array + expect(node.image).toBeNull(); + expect(node.baseColorFactor).toEqual([1, 1, 1, 1]); + expect(node.textureRepeat).toBe("repeat"); + expect(node.doubleSided).toBe(false); + }); }); // ── Normals & lights ───────────────────────────────────────────────────────── @@ -985,7 +1007,6 @@ describe("GLTFScene → lighting (KHR_lights_punctual)", () => { afterAll(() => { delete gltfList[NAME]; - LightingEnvironment.default.clear(); }); const fakeContainer = () => { @@ -997,41 +1018,40 @@ describe("GLTFScene → lighting (KHR_lights_punctual)", () => { }, }; }; + const lightsOf = (c) => { + return c.kids.filter((k) => { + return k instanceof Light3d; + }); + }; - it("adds the authored directional light + flags meshes lit", () => { - LightingEnvironment.default.clear(); + it("adds the authored directional light (+ ambient fill) as world children + flags meshes lit", () => { const scene = new GLTFScene(NAME); const container = fakeContainer(); scene.addTo(container, { scale: 10 }); - expect(LightingEnvironment.default.lights).toHaveLength(1); + // lights are ordinary Light3d renderables added to the world (the level + // director's container.reset() removes them on the next load — same + // lifecycle as Light2d, so the scene tracks nothing) + const lights = lightsOf(container); + const directional = lights.filter((l) => { + return l.type === "directional"; + }); + const ambient = lights.filter((l) => { + return l.type === "ambient"; + }); + expect(directional).toHaveLength(1); + expect(ambient).toHaveLength(1); // soft ambient fill 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(); + expect(directional[0].direction.z).toBeCloseTo(1, 5); }); 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(lightsOf(container)).toHaveLength(0); expect(container.kids[0].lit).toBe(false); - scene.destroy(); }); }); @@ -1311,3 +1331,247 @@ describe("parseGLTF() — node hierarchy accumulation", () => { expect(applyMat(w, [1, 0, 0])[0]).toBeCloseTo(14, 5); }); }); + +// ── node graph + animation parsing ────────────────────────────────────────── + +// Build a GLB with a 2-node hierarchy (root → mesh child) and one "walk" clip +// animating the root's translation (LINEAR) and the child's rotation (STEP). +function buildAnimGLB() { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const times = new Float32Array([0, 0.5, 1]); // 3 keyframes, duration 1s + const transValues = new Float32Array([0, 0, 0, 1, 0, 0, 2, 0, 0]); // VEC3 × 3 + // VEC4 × 3 quaternions (identity, 90°Z, 180°Z) — values are not asserted + const r = Math.SQRT1_2; + const rotValues = new Float32Array([0, 0, 0, 1, 0, 0, r, r, 0, 0, 1, 0]); + const { bin, offsets } = packParts([ + positions, + indices, + times, + transValues, + rotValues, + ]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { name: "root", translation: [0, 0, 0], children: [1] }, + { name: "child", mesh: 0, translation: [5, 0, 0] }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + animations: [ + { + name: "walk", + channels: [ + { sampler: 0, target: { node: 0, path: "translation" } }, + { sampler: 1, target: { node: 1, path: "rotation" } }, + // a weights channel must be silently dropped (out of scope) + { sampler: 0, target: { node: 0, path: "weights" } }, + ], + samplers: [ + { input: 2, output: 3, interpolation: "LINEAR" }, + { input: 2, output: 4, interpolation: "STEP" }, + ], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + { bufferView: 2, componentType: 5126, count: 3, type: "SCALAR" }, + { bufferView: 3, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 4, componentType: 5126, count: 3, type: "VEC4" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: indices.byteLength }, + { buffer: 0, byteOffset: offsets[2], byteLength: times.byteLength }, + { buffer: 0, byteOffset: offsets[3], byteLength: transValues.byteLength }, + { buffer: 0, byteOffset: offsets[4], byteLength: rotValues.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + return packGLB(json, bin); +} + +describe("parseGLTF() — node graph", () => { + it("emits the full hierarchy keyed by node index, with rest TRS + children", async () => { + const scene = await parseGLTF(buildAnimGLB()); + expect(scene.graph.roots).toEqual([0]); + const root = scene.graph.nodes[0]; + expect(root.name).toBe("root"); + expect(root.children).toEqual([1]); + expect(root.translation).toEqual([0, 0, 0]); + expect(root.rotation).toEqual([0, 0, 0, 1]); // default identity quat + expect(root.scale).toEqual([1, 1, 1]); // default + expect(root.primitives).toHaveLength(0); // empty transform node + }); + + it("attaches mesh primitives to their node in the graph", async () => { + const scene = await parseGLTF(buildAnimGLB()); + const child = scene.graph.nodes[1]; + expect(child.name).toBe("child"); + expect(child.translation).toEqual([5, 0, 0]); + expect(child.primitives).toHaveLength(1); + expect(child.primitives[0].vertexCount).toBe(3); + }); + + it("graph primitives share the SAME typed arrays as the flat node list (single read)", async () => { + const scene = await parseGLTF(buildAnimGLB()); + // flat meshNodes[0] is node 1's primitive; graph.nodes[1].primitives[0] + // must reference the identical buffer (not a re-read copy) + expect(scene.graph.nodes[1].primitives[0].vertices).toBe( + scene.nodes[0].vertices, + ); + }); +}); + +describe("parseGLTF() — animations", () => { + it("parses clips with name, duration, and per-channel keyframes", async () => { + const scene = await parseGLTF(buildAnimGLB()); + expect(scene.animations).toHaveLength(1); + const clip = scene.animations[0]; + expect(clip.name).toBe("walk"); + expect(clip.duration).toBeCloseTo(1, 6); // last keyframe time + }); + + it("resolves each channel's node, path, interpolation, stride + buffers", async () => { + const { animations } = await parseGLTF(buildAnimGLB()); + const chans = animations[0].channels; + const trans = chans.find((c) => { + return c.path === "translation"; + }); + const rot = chans.find((c) => { + return c.path === "rotation"; + }); + expect(trans.node).toBe(0); + expect(trans.stride).toBe(3); + expect(trans.interpolation).toBe("LINEAR"); + expect(Array.from(trans.times)).toEqual([0, 0.5, 1]); + expect(Array.from(trans.values)).toEqual([0, 0, 0, 1, 0, 0, 2, 0, 0]); + expect(rot.node).toBe(1); + expect(rot.stride).toBe(4); // quaternion + expect(rot.interpolation).toBe("STEP"); + }); + + it("ADVERSARIAL: drops unsupported channel paths (weights / morph targets)", async () => { + const { animations } = await parseGLTF(buildAnimGLB()); + // only translation + rotation survive; the `weights` channel is dropped + expect(animations[0].channels).toHaveLength(2); + expect( + animations[0].channels.some((c) => { + return c.path === "weights"; + }), + ).toBe(false); + }); + + it("ADVERSARIAL: a static asset reports an empty animations array (+ a graph)", async () => { + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.animations).toEqual([]); + // the graph is still emitted for every scene + expect(Object.keys(scene.graph.nodes).length).toBeGreaterThan(0); + }); +}); + +// ── texture wrap (sampler wrapS / wrapT → melonJS repeat mode) ─────────────── + +// a 1×1 transparent PNG as a data URI — decodes in the browser test env so the +// material's baseColorTexture resolves to a real image +const PNG_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"; + +// Build a single textured triangle whose material's sampler uses the given +// wrap modes. `sampler` may be omitted entirely to exercise the glTF default. +function buildWrapGLB(sampler) { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const uvs = new Float32Array([0, 0, 1, 0, 0, 1]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, uvs, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0, TEXCOORD_0: 1 }, + indices: 2, + material: 0, + }, + ], + }, + ], + materials: [{ pbrMetallicRoughness: { baseColorTexture: { index: 0 } } }], + textures: [ + sampler === undefined ? { source: 0 } : { source: 0, sampler: 0 }, + ], + samplers: sampler === undefined ? undefined : [sampler], + images: [{ uri: PNG_1x1 }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5126, count: 3, type: "VEC2" }, + { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: uvs.byteLength }, + { buffer: 0, byteOffset: offsets[2], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + return packGLB(json, bin); +} + +describe("parseGLTF() — texture wrap mode", () => { + const REPEAT = 10497; + const CLAMP = 33071; + + it("defaults to REPEAT when no sampler is present (glTF spec default)", async () => { + const scene = await parseGLTF(buildWrapGLB(undefined)); + expect(scene.nodes[0].textureRepeat).toBe("repeat"); + }); + + it("REPEAT on both axes → 'repeat'", async () => { + const scene = await parseGLTF( + buildWrapGLB({ wrapS: REPEAT, wrapT: REPEAT }), + ); + expect(scene.nodes[0].textureRepeat).toBe("repeat"); + }); + + it("CLAMP on both axes → 'no-repeat'", async () => { + const scene = await parseGLTF(buildWrapGLB({ wrapS: CLAMP, wrapT: CLAMP })); + expect(scene.nodes[0].textureRepeat).toBe("no-repeat"); + }); + + it("REPEAT-S / CLAMP-T → 'repeat-x'", async () => { + const scene = await parseGLTF( + buildWrapGLB({ wrapS: REPEAT, wrapT: CLAMP }), + ); + expect(scene.nodes[0].textureRepeat).toBe("repeat-x"); + }); + + it("CLAMP-S / REPEAT-T → 'repeat-y'", async () => { + const scene = await parseGLTF( + buildWrapGLB({ wrapS: CLAMP, wrapT: REPEAT }), + ); + expect(scene.nodes[0].textureRepeat).toBe("repeat-y"); + }); + + it("ADVERSARIAL: a sampler that omits wrapS/wrapT defaults each to REPEAT", async () => { + const scene = await parseGLTF(buildWrapGLB({})); // empty sampler + expect(scene.nodes[0].textureRepeat).toBe("repeat"); + }); + + it("ADVERSARIAL: MIRRORED_REPEAT (no melonJS equivalent) maps to plain repeat", async () => { + const scene = await parseGLTF(buildWrapGLB({ wrapS: 33648, wrapT: 33648 })); + expect(scene.nodes[0].textureRepeat).toBe("repeat"); + }); + + it("ADVERSARIAL: an untextured material still defaults to 'repeat'", async () => { + // buildSceneGLB's mesh nodes have no material at all + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.nodes[0].textureRepeat).toBe("repeat"); + }); +}); diff --git a/packages/melonjs/tests/gltf_model.spec.js b/packages/melonjs/tests/gltf_model.spec.js new file mode 100644 index 0000000000..52e88fde29 --- /dev/null +++ b/packages/melonjs/tests/gltf_model.spec.js @@ -0,0 +1,313 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { boot, GLTFModel, video } from "../src/index.js"; + +// a small real texture so part meshes resolve a non-white-pixel atlas (lets us +// assert the glTF wrap mode is forwarded onto the mesh texture) +let TEX; + +/** + * Unit tests for GLTFModel — the rig that drives node-TRS animation over a + * glTF node hierarchy and exposes the Sprite-aligned animation API. + * + * A synthetic two-node descriptor is used (no GLB decode needed): + * node 0 "parent" — animated (translation / rotation), child of nothing + * └─ node 1 "child" — a 1-triangle mesh at local translation (1, 0, 0) + * so we can assert hierarchy propagation by reading the child mesh's `pos`. + */ + +// minimal drawable primitive (one triangle) for the child node +const PRIM = () => { + return { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 0, 0, 0, 0]), + indices: new Uint16Array([0, 1, 2]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), + vertexCount: 3, + // a real texture + glTF-default repeat wrap, so the wrap mode forwarding + // onto the part mesh can be asserted + image: TEX, + textureRepeat: "repeat", + baseColorFactor: [1, 1, 1, 1], + colors: undefined, + doubleSided: false, + }; +}; + +// build a fresh descriptor each time (GLTFModel keeps mutable rest TRS refs) +const makeData = () => { + return { + bounds: { min: [-1, -1, -1], max: [1, 1, 1] }, + graph: { + roots: [0], + nodes: { + 0: { + index: 0, + name: "parent", + translation: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + matrix: null, + children: [1], + primitives: [], + }, + 1: { + index: 1, + name: "child", + translation: [1, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + matrix: null, + children: [], + primitives: [PRIM()], + }, + }, + }, + animations: [ + { + name: "move", + duration: 1, + channels: [ + { + node: 0, + path: "translation", + times: [0, 1], + values: [0, 0, 0, 5, 0, 0], + stride: 3, + interpolation: "LINEAR", + }, + ], + }, + { + name: "spin", + duration: 1, + channels: [ + { + node: 0, + path: "rotation", + // identity → 180° about Z ([0,0,1,0]) + times: [0, 1], + values: [0, 0, 0, 1, 0, 0, 1, 0], + stride: 4, + interpolation: "LINEAR", + }, + ], + }, + ], + }; +}; + +const makeModel = () => { + return new GLTFModel(makeData(), { scale: 1, rightHanded: false }); +}; +// the single child mesh (node 1's one primitive) +const childOf = (model) => { + return model.getChildByName("child")[0]; +}; + +describe("GLTFModel", () => { + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + TEX = video.createCanvas(8, 8); + }); + + afterAll(() => { + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + it("instantiates one Mesh per mesh-node primitive, named after the node", () => { + const model = makeModel(); + expect(model.getChildByName("child").length).toBe(1); + // the empty parent node contributes no mesh + expect(model.getChildByName("parent").length).toBe(0); + }); + + it("getAnimationNames lists every clip", () => { + expect(makeModel().getAnimationNames().sort()).toEqual(["move", "spin"]); + }); + + it("opts out of the anchor offset (sizeless container would NaN-poison the transform)", () => { + // the container's width/height are Infinity, so the base preDraw anchor + // offset `width * anchorPoint` is `Infinity * 0 = NaN`; opting out is + // what keeps the nested part meshes rendering (see Camera3d.isVisible fix) + expect(makeModel().applyAnchorTransform).toBe(false); + }); + + it("forwards the glTF texture wrap mode onto each part mesh", () => { + const mesh = childOf(makeModel()); + // PRIM() carries textureRepeat:"repeat" + a real texture → the atlas + // must end up REPEAT-wrapped (tiling UVs sample correctly vs clamping) + expect(mesh.texture.repeat).toBe("repeat"); + }); + + it("poses to the bind/rest pose on construction (parent at origin → child at its local x)", () => { + const model = makeModel(); + // rest: parent translation 0, child local (1,0,0) → child world.x = 1 + expect(childOf(model).pos.x).toBeCloseTo(1, 5); + }); + + it("HIERARCHY: animating the parent's translation carries the child", () => { + const model = makeModel(); + // loop:false so the endpoint pose is held (a looping clip wraps exactly + // at t == duration, by design) + model.setCurrentAnimation("move", { loop: false }); + model.update(500); // t = 0.5 → parent tx = 2.5 → child world.x = 2.5 + 1 + expect(childOf(model).pos.x).toBeCloseTo(3.5, 4); + model.update(500); // t = 1.0 → clamp+hold → parent tx = 5 → child world.x = 6 + expect(childOf(model).pos.x).toBeCloseTo(6, 4); + }); + + it("HIERARCHY: rotating the parent rotates the child's position about it", () => { + const model = makeModel(); + model.setCurrentAnimation("spin", { loop: false }); + model.update(1000); // t = 1 (held) → parent rotated 180° about Z + // child local (1,0,0) rotated 180°Z → (-1,0,0) + expect(childOf(model).pos.x).toBeCloseTo(-1, 4); + expect(childOf(model).pos.y).toBeCloseTo(0, 4); + }); + + it("loops by default (wraps past duration)", () => { + const model = makeModel(); + model.setCurrentAnimation("move"); + model.update(1500); // 1.5 → wraps to 0.5 → child.x = 3.5 + expect(childOf(model).pos.x).toBeCloseTo(3.5, 4); + expect(model.isCurrentAnimation("move")).toBe(true); + }); + + it("animationspeed multiplies playback rate", () => { + const model = makeModel(); + model.setCurrentAnimation("move", { speed: 2 }); + model.update(250); // 0.25s × 2 = t 0.5 → child.x = 3.5 + expect(childOf(model).pos.x).toBeCloseTo(3.5, 4); + }); + + it("options loop:false plays once, holds the final pose, fires onComplete once", () => { + const model = makeModel(); + let done = 0; + model.setCurrentAnimation("move", { + loop: false, + onComplete: () => { + return done++; + }, + }); + model.update(2000); // overshoot → clamps at t=1 → child.x = 6 + expect(childOf(model).pos.x).toBeCloseTo(6, 4); + expect(done).toBe(1); + model.update(2000); // frozen (_animDone) → still 6, no more callbacks + expect(childOf(model).pos.x).toBeCloseTo(6, 4); + expect(done).toBe(1); + }); + + it("options next chains to another clip, firing onComplete first", () => { + const model = makeModel(); + let fired = 0; + model.setCurrentAnimation("move", { + next: "spin", + onComplete: () => { + return fired++; + }, + }); + model.update(1000); // move completes → onComplete + switch to spin + expect(fired).toBe(1); + expect(model.isCurrentAnimation("spin")).toBe(true); + }); + + // ── play / pause / stop ──────────────────────────────────────────────── + + it("play(name) switches and starts a clip", () => { + const model = makeModel(); + model.play("spin"); + expect(model.isCurrentAnimation("spin")).toBe(true); + expect(model.animationpause).toBe(false); + }); + + it("pause() freezes the pose, play() resumes", () => { + const model = makeModel(); + model.setCurrentAnimation("move"); + model.update(250); // t 0.25 → child.x = 1 + 1.25 = 2.25 + model.pause(); + const frozen = childOf(model).pos.x; + model.update(1000); // paused → no change + expect(childOf(model).pos.x).toBeCloseTo(frozen, 6); + model.play(); + model.update(250); // resumes advancing + expect(childOf(model).pos.x).toBeGreaterThan(frozen); + }); + + it("stop() resets to the bind pose and clears the current animation", () => { + const model = makeModel(); + model.setCurrentAnimation("move"); + model.update(800); // moved away from rest + expect(childOf(model).pos.x).not.toBeCloseTo(1, 2); + model.stop(); + expect(model.isCurrentAnimation("move")).toBe(false); + expect(model.current.name).toBeUndefined(); + // back to the rest pose: child at its local x = 1 + expect(childOf(model).pos.x).toBeCloseTo(1, 5); + }); + + // ── adversarial ───────────────────────────────────────────────────────── + + it("ADVERSARIAL: re-selecting the same clip is a no-op (does not reset time)", () => { + const model = makeModel(); + model.setCurrentAnimation("move"); + model.update(400); // t 0.4 + model.setCurrentAnimation("move"); // same → must NOT restart + const x = childOf(model).pos.x; + // child.x at t0.4 = 1 + (0.4*5) = 3.0 ; a reset would put it at 1.0 + expect(x).toBeCloseTo(3.0, 4); + }); + + it("ADVERSARIAL: animationpause halts advancement entirely", () => { + const model = makeModel(); + model.setCurrentAnimation("move"); + model.animationpause = true; + model.update(1000); + expect(childOf(model).pos.x).toBeCloseTo(1, 5); // never left rest + }); + + it("ADVERSARIAL: speed 0 freezes the animation", () => { + const model = makeModel(); + model.setCurrentAnimation("move", { speed: 0 }); + model.update(1000); + expect(childOf(model).pos.x).toBeCloseTo(1, 5); + }); + + it("ADVERSARIAL: stop() after a play-once unfreezes so a later clip animates", () => { + const model = makeModel(); + model.setCurrentAnimation("move", { loop: false }); + model.update(2000); // held + _animDone + model.stop(); // clears _animDone + clip + model.play("move"); // loop again + model.update(500); + expect(childOf(model).pos.x).toBeCloseTo(3.5, 4); // advancing again + }); + + it("ADVERSARIAL: unknown clip name throws", () => { + expect(() => { + return makeModel().setCurrentAnimation("nope"); + }).toThrow(); + }); + + it("ADVERSARIAL: a non-animated node keeps its rest transform while a sibling animates", () => { + // child node is never targeted by any clip → its LOCAL transform stays + // at rest; only the inherited parent motion moves it. Verify the child's + // own rotation/scale columns stay identity after the parent spins. + const model = makeModel(); + model.setCurrentAnimation("spin", { loop: false }); + model.update(1000); + const v = childOf(model).currentTransform.val; + // world rotation is the PARENT's 180°Z (m00 = m11 = -1), proving the + // child inherited it rather than carrying its own (which is identity) + expect(v[0]).toBeCloseTo(-1, 4); + expect(v[5]).toBeCloseTo(-1, 4); + }); +}); diff --git a/packages/melonjs/tests/gltf_sampler.spec.js b/packages/melonjs/tests/gltf_sampler.spec.js new file mode 100644 index 0000000000..9014aca737 --- /dev/null +++ b/packages/melonjs/tests/gltf_sampler.spec.js @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + findKeyframe, + sampleChannel, + slerpQuat, +} from "../src/level/gltf/gltf_sampler.js"; + +/** + * Unit tests for the glTF node-TRS keyframe sampler. Pure math — no renderer. + */ +describe("findKeyframe", () => { + const times = [0, 1, 2, 3]; + + it("clamps below the first keyframe (no extrapolation)", () => { + expect(findKeyframe(times, -5)).toEqual({ i0: 0, i1: 0, alpha: 0 }); + }); + + it("clamps at/above the last keyframe", () => { + expect(findKeyframe(times, 3)).toEqual({ i0: 3, i1: 3, alpha: 0 }); + expect(findKeyframe(times, 99)).toEqual({ i0: 3, i1: 3, alpha: 0 }); + }); + + it("brackets an interior time with the correct blend factor", () => { + expect(findKeyframe(times, 1.25)).toEqual({ i0: 1, i1: 2, alpha: 0.25 }); + expect(findKeyframe(times, 2.5)).toEqual({ i0: 2, i1: 3, alpha: 0.5 }); + }); + + it("lands exactly on a keyframe → alpha 0 at that index", () => { + expect(findKeyframe(times, 2)).toEqual({ i0: 2, i1: 3, alpha: 0 }); + }); + + it("ADVERSARIAL: empty times array does not crash", () => { + expect(findKeyframe([], 0.5)).toEqual({ i0: 0, i1: 0, alpha: 0 }); + }); + + it("ADVERSARIAL: duplicate keyframe times (zero span) → alpha 0, no divide-by-zero", () => { + const dup = [0, 1, 1, 2]; + const r = findKeyframe(dup, 1); + // t === 1 lands on the first index whose time is <= t → index 2 here, but + // the contract that matters: alpha is finite (never NaN/Infinity) + expect(Number.isFinite(r.alpha)).toBe(true); + expect(r.alpha).toBe(0); + }); +}); + +describe("slerpQuat", () => { + const out = [0, 0, 0, 0]; + + it("t=0 / t=1 return the endpoints", () => { + const q = [0, 0, 0, 1, 0, 0, 0.7071, 0.7071]; // identity → 90° about Z + slerpQuat(q, 0, 4, 0, out); + expect(out[3]).toBeCloseTo(1, 4); + slerpQuat(q, 0, 4, 1, out); + expect(out[2]).toBeCloseTo(0.7071, 4); + expect(out[3]).toBeCloseTo(0.7071, 4); + }); + + it("midpoint of identity→90°(Z) is 45° about Z, normalized", () => { + const q = [0, 0, 0, 1, 0, 0, 0.70710678, 0.70710678]; + slerpQuat(q, 0, 4, 0.5, out); + // 45° about Z = (0,0,sin22.5,cos22.5) + expect(out[2]).toBeCloseTo(Math.sin(Math.PI / 8), 4); + expect(out[3]).toBeCloseTo(Math.cos(Math.PI / 8), 4); + expect(Math.hypot(out[0], out[1], out[2], out[3])).toBeCloseTo(1, 5); + }); + + it("ADVERSARIAL: takes the shortest arc when the dot is negative", () => { + // q and -q represent the same orientation; slerp must not spin the long + // way. identity vs negated-identity → midpoint stays at identity. + const q = [0, 0, 0, 1, 0, 0, 0, -1]; + slerpQuat(q, 0, 4, 0.5, out); + expect(Math.abs(out[3])).toBeCloseTo(1, 4); + expect(out[0]).toBeCloseTo(0, 5); + expect(out[1]).toBeCloseTo(0, 5); + expect(out[2]).toBeCloseTo(0, 5); + }); + + it("ADVERSARIAL: nearly-parallel quaternions use the lerp fallback (no NaN)", () => { + const a = Math.cos(0.0001); + const s = Math.sin(0.0001); + const q = [0, 0, s, a, 0, 0, s * 1.0001, a]; + slerpQuat(q, 0, 4, 0.5, out); + expect( + out.every((v) => { + return Number.isFinite(v); + }), + ).toBe(true); + expect(Math.hypot(out[0], out[1], out[2], out[3])).toBeCloseTo(1, 5); + }); +}); + +describe("sampleChannel", () => { + const out = [0, 0, 0, 0]; + + it("LINEAR vec3 (translation) lerps component-wise", () => { + const channel = { + times: [0, 1], + values: [0, 0, 0, 10, 20, -30], + stride: 3, + interpolation: "LINEAR", + }; + sampleChannel(channel, 0.5, out); + expect([out[0], out[1], out[2]]).toEqual([5, 10, -15]); + }); + + it("STEP holds the lower keyframe value", () => { + const channel = { + times: [0, 1], + values: [1, 2, 3, 100, 200, 300], + stride: 3, + interpolation: "STEP", + }; + sampleChannel(channel, 0.99, out); + expect([out[0], out[1], out[2]]).toEqual([1, 2, 3]); + }); + + it("rotation (stride 4) uses slerp", () => { + const channel = { + times: [0, 1], + values: [0, 0, 0, 1, 0, 0, 0.70710678, 0.70710678], + stride: 4, + interpolation: "LINEAR", + }; + sampleChannel(channel, 0.5, out); + expect(out[2]).toBeCloseTo(Math.sin(Math.PI / 8), 4); + expect(out[3]).toBeCloseTo(Math.cos(Math.PI / 8), 4); + }); + + it("ADVERSARIAL: CUBICSPLINE reads the middle (value) of each keyframe block, ignores tangents", () => { + // 2 keyframes, stride 3, cubicspline blocks = [inTangent, value, outTangent] + // key0: in=[9,9,9] value=[0,0,0] out=[9,9,9] + // key1: in=[9,9,9] value=[10,0,0] out=[9,9,9] + const channel = { + times: [0, 1], + values: [9, 9, 9, 0, 0, 0, 9, 9, 9, 9, 9, 9, 10, 0, 0, 9, 9, 9], + stride: 3, + interpolation: "CUBICSPLINE", + }; + sampleChannel(channel, 0.5, out); + // linear blend of the two VALUES (tangents must be ignored): 0..10 → 5 + expect(out[0]).toBeCloseTo(5, 5); + expect(out[1]).toBeCloseTo(0, 5); + expect(out[2]).toBeCloseTo(0, 5); + }); + + it("ADVERSARIAL: sampling past the end clamps to the last value", () => { + const channel = { + times: [0, 1], + values: [0, 0, 0, 7, 8, 9], + stride: 3, + interpolation: "LINEAR", + }; + sampleChannel(channel, 5, out); + expect([out[0], out[1], out[2]]).toEqual([7, 8, 9]); + }); +}); diff --git a/packages/melonjs/tests/lighting3d.spec.js b/packages/melonjs/tests/lighting3d.spec.js index 1b5074a282..438d5f8296 100644 --- a/packages/melonjs/tests/lighting3d.spec.js +++ b/packages/melonjs/tests/lighting3d.spec.js @@ -1,21 +1,34 @@ -import { describe, expect, it } from "vitest"; -import { Color, Light3d, LightingEnvironment } from "../src/index.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + boot, + Color, + game, + Light3d, + Stage, + state, + video, +} from "../src/index.js"; +import Renderable from "../src/renderable/renderable.js"; import { MAX_LIGHTS } from "../src/video/webgl/lighting/constants.ts"; +import { packMeshLights } from "../src/video/webgl/lighting/pack3d.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). + * Unit tests for the 3D mesh lighting primitives. Since 19.8 a `Light3d` is a + * world {@link Renderable} (like `Light2d`): add it to the world and the active + * stage auto-tracks it; the lit mesh batcher packs the stage's active 3D lights + * each frame via {@link packMeshLights}. There is no `LightingEnvironment`. */ describe("Light3d", () => { + it("is a Renderable (added to the world like Light2d)", () => { + expect(new Light3d()).toBeInstanceOf(Renderable); + }); + 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.color.r, l.color.g, l.color.b]).toEqual([255, 255, 255]); expect([l.direction.x, l.direction.y, l.direction.z]).toEqual([0, 1, 0]); }); @@ -43,6 +56,12 @@ describe("Light3d", () => { expect(l.color).toBe(c); }); + it("supports an ambient type (fill light, direction ignored)", () => { + const l = new Light3d({ type: "ambient", intensity: 0.4 }); + expect(l.type).toBe("ambient"); + expect(l.intensity).toBe(0.4); + }); + it("carries type + position for a future point release", () => { const l = new Light3d({ type: "point", position: [1, 2, 3] }); expect(l.type).toBe("point"); @@ -50,86 +69,108 @@ describe("Light3d", () => { }); }); -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( +describe("Light3d ↔ Stage registration", () => { + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + const s = new Stage(); + state.set(state.DEFAULT, s); + state.change(state.DEFAULT, true); + }); + + afterAll(() => { + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.AUTO, + }); + }); + + it("registers with the active stage when added to the world, deregisters when removed", () => { + const stage = state.current(); + const light = new Light3d({ direction: [0, 1, 0] }); + game.world.addChild(light); + expect(stage._activeLights3d.has(light)).toBe(true); + // removeChildNow (not removeChild, which defers) fires onDeactivateEvent + // synchronously so the deregistration is observable in-test + game.world.removeChildNow(light); + expect(stage._activeLights3d.has(light)).toBe(false); + }); +}); + +describe("packMeshLights", () => { + it("null / empty input → zero lights, zero ambient", () => { + const p = packMeshLights(null); + expect(p.count).toBe(0); + expect([p.ambient[0], p.ambient[1], p.ambient[2]]).toEqual([0, 0, 0]); + expect(packMeshLights([]).count).toBe(0); + }); + + it("negates + normalizes direction, premultiplies color by intensity", () => { + const p = packMeshLights([ 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 + // surface→light = -travel, normalized: 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(); + it("sums ambient lights into the ambient color (color × intensity)", () => { + const p = packMeshLights([ + new Light3d({ type: "ambient", color: "#808080", intensity: 0.5 }), + ]); + expect(p.count).toBe(0); // ambient is not a directional light 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: multiple ambient lights accumulate", () => { + // white × 0.1 + white × 0.2 = 0.3 (intensities avoid color int rounding) + const p = packMeshLights([ + new Light3d({ type: "ambient", color: "#ffffff", intensity: 0.1 }), + new Light3d({ type: "ambient", color: "#ffffff", intensity: 0.2 }), + ]); + expect(p.ambient[0]).toBeCloseTo(0.3, 5); + }); + + it("ADVERSARIAL: skips non-directional (point) lights", () => { + const p = packMeshLights([ + new Light3d({ type: "point" }), + new Light3d({ type: "directional" }), + ]); + expect(p.count).toBe(1); // only the directional one }); - it("ADVERSARIAL: pack() clamps to MAX_LIGHTS", () => { - const env = new LightingEnvironment(); + it("ADVERSARIAL: clamps to MAX_LIGHTS", () => { + const lights = []; for (let i = 0; i < MAX_LIGHTS + 4; i++) { - env.addLight(new Light3d()); + lights.push(new Light3d()); } - expect(env.pack().count).toBe(MAX_LIGHTS); + expect(packMeshLights(lights).count).toBe(MAX_LIGHTS); }); - it("ADVERSARIAL: pack() reuses its buffers (later state overwrites)", () => { - const env = new LightingEnvironment(); + it("ADVERSARIAL: reuses its buffers (later state overwrites)", () => { const light = new Light3d({ direction: [1, 0, 0], intensity: 1 }); - env.addLight(light); - const p1 = env.pack(); + const p1 = packMeshLights([light]); 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(); + const p2 = packMeshLights([light]); 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(); + it("ADVERSARIAL: a runtime non-unit direction is normalized in pack", () => { const light = new Light3d(); light.direction.set(0, 0, 9); // not unit - env.addLight(light); - const p = env.pack(); + const p = packMeshLights([light]); const len = Math.hypot(p.directions[0], p.directions[1], p.directions[2]); expect(len).toBeCloseTo(1, 5); }); diff --git a/packages/melonjs/tests/lights.spec.js b/packages/melonjs/tests/lights.spec.js index 13f21d7084..a0f01efc8d 100644 --- a/packages/melonjs/tests/lights.spec.js +++ b/packages/melonjs/tests/lights.spec.js @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { boot, + Color, Container, Ellipse, game, @@ -2019,3 +2020,55 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { expect(setIntensityCalls).toBe(0); }); }); + +describe("Light2d — constructor & setRadii", () => { + beforeAll(() => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + }); + + it("accepts a CSS color string", () => { + const l = new Light2d(0, 0, 10, 10, "#ff0000"); + expect([l.color.r, l.color.g, l.color.b]).toEqual([255, 0, 0]); + l.destroy(); + }); + + it("accepts a Color instance (copied, not aliased)", () => { + const c = new Color(10, 20, 30, 1); + const l = new Light2d(0, 0, 10, 10, c); + expect([l.color.r, l.color.g, l.color.b]).toEqual([10, 20, 30]); + expect(l.color).not.toBe(c); // pooled copy, not the same instance + l.destroy(); + }); + + it("defaults: blendMode 'lighter', illuminationOnly false, lightHeight = max(rx,ry)*0.075", () => { + const l = new Light2d(0, 0, 40, 20); + expect(l.blendMode).toBe("lighter"); + expect(l.illuminationOnly).toBe(false); + expect(l.lightHeight).toBeCloseTo(40 * 0.075, 6); // max(40,20)*0.075 + l.destroy(); + }); + + it("setRadii updates radii and the bounding box", () => { + const l = new Light2d(50, 50, 10, 10); + l.setRadii(40, 20); + expect(l.radiusX).toBe(40); + expect(l.radiusY).toBe(20); + // resize(radiusX*2, radiusY*2) → bbox 80 × 40 + expect(l.getBounds().width).toBe(80); + expect(l.getBounds().height).toBe(40); + l.destroy(); + }); + + it("setRadii with one argument applies it to both axes", () => { + const l = new Light2d(0, 0, 10, 10); + l.setRadii(25); + expect(l.radiusX).toBe(25); + expect(l.radiusY).toBe(25); + l.destroy(); + }); +}); diff --git a/packages/melonjs/tests/loader.spec.js b/packages/melonjs/tests/loader.spec.js index 99107d9a56..68151bcd2c 100644 --- a/packages/melonjs/tests/loader.spec.js +++ b/packages/melonjs/tests/loader.spec.js @@ -475,4 +475,51 @@ describe("loader", () => { ); expect(result).toBe(1); }); + + describe("MTL texture auto-loading (map_Kd)", () => { + it("auto-loads a material's map_Kd texture relative to the .mtl, registered under its resolved path", async () => { + await expect( + new Promise((resolve, reject) => { + loader.load( + { name: "cubemtl", type: "mtl", src: "/data/models/cube.mtl" }, + () => { + const mats = loader.getMTL("cubemtl"); + // map_Kd resolved relative to the .mtl folder + const mapKd = mats?.cube?.map_Kd; + // the texture was loaded WITHOUT a separate preload entry, + // registered under the resolved map_Kd path + resolve( + mapKd === "/data/models/cube.png" && + loader.getImage("/data/models/cube.png") !== null, + ); + }, + () => { + reject(new Error("failed to load cube.mtl")); + }, + ); + }), + ).resolves.toBe(true); + }); + + it("does not abort the load when a map_Kd texture is missing (mesh falls back to white)", async () => { + // the .mtl parses fine; only the (absent) texture fails → onload still fires + await expect( + new Promise((resolve, reject) => { + loader.load( + { + name: "cubemtl2", + type: "mtl", + src: "/data/models/cube-missing.mtl", + }, + () => { + resolve(loader.getMTL("cubemtl2") !== null); + }, + () => { + reject(new Error("MTL load aborted")); + }, + ); + }), + ).resolves.toBe(true); + }); + }); }); diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js index fe8b2c5a74..a6436ee809 100644 --- a/packages/melonjs/tests/mesh.spec.js +++ b/packages/melonjs/tests/mesh.spec.js @@ -1299,4 +1299,45 @@ describe("Mesh × Camera3d world-space path", () => { expect(m.indices).toBeInstanceOf(Uint16Array); expect(Array.from(m.indices)).toEqual([0, 1, 2]); }); + + // ── textureRepeat setting (tiling UVs, e.g. glTF default wrap) ────────── + + it("textureRepeat applies the wrap mode to a real texture", () => { + const m = new Mesh(0, 0, { + vertices: new Float32Array(9), + uvs: new Float32Array(6), + indices: [0, 1, 2], + texture: video.createCanvas(8, 8), + width: 10, + normalize: false, + textureRepeat: "repeat", + }); + expect(m.texture.repeat).toBe("repeat"); + }); + + it("texture defaults to 'no-repeat' when textureRepeat is omitted", () => { + const m = new Mesh(0, 0, { + vertices: new Float32Array(9), + uvs: new Float32Array(6), + indices: [0, 1, 2], + texture: video.createCanvas(8, 8), + width: 10, + normalize: false, + }); + expect(m.texture.repeat).toBe("no-repeat"); + }); + + it("ADVERSARIAL: textureRepeat is ignored for the white-pixel fallback (no global mutation)", () => { + // no texture/material → the shared 1×1 white pixel is used; mutating its + // repeat would poison every other untextured mesh in the engine + const m = new Mesh(0, 0, { + vertices: new Float32Array(9), + uvs: new Float32Array(6), + indices: [0, 1, 2], + width: 10, + normalize: false, + textureRepeat: "repeat", + }); + expect(m.texture.repeat).not.toBe("repeat"); + }); }); diff --git a/packages/melonjs/tests/public/data/models/cube-missing.mtl b/packages/melonjs/tests/public/data/models/cube-missing.mtl new file mode 100644 index 0000000000..c93b464ab3 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/cube-missing.mtl @@ -0,0 +1,3 @@ +newmtl cube +Kd 0.5 0.5 0.5 +map_Kd nope.png diff --git a/packages/melonjs/tests/public/data/models/cube.mtl b/packages/melonjs/tests/public/data/models/cube.mtl new file mode 100644 index 0000000000..f25912fc3a --- /dev/null +++ b/packages/melonjs/tests/public/data/models/cube.mtl @@ -0,0 +1,3 @@ +newmtl cube +Kd 1.0 1.0 1.0 +map_Kd cube.png diff --git a/packages/melonjs/tests/public/data/models/cube.png b/packages/melonjs/tests/public/data/models/cube.png new file mode 100644 index 0000000000000000000000000000000000000000..c7bc86171f43d86c5357b64c3d71f3406c877007 GIT binary patch literal 87 zcmeAS@N?(olHy`uVBq!ia0vp^3P9|@!3HF&`%2dVDLGFU$B>MBZx1pu0(nge|JUbv dIDnWS@SaV literal 0 HcmV?d00001 diff --git a/packages/melonjs/tests/sprite.spec.js b/packages/melonjs/tests/sprite.spec.js index ddd5f14437..a84d27bf2f 100644 --- a/packages/melonjs/tests/sprite.spec.js +++ b/packages/melonjs/tests/sprite.spec.js @@ -470,4 +470,282 @@ describe("Sprite", () => { expect(s.normalMap).toBeNull(); }); }); + + describe("animation API (options + speed)", () => { + // fresh 4-frame sprite (64×64 image / 32px frames = indices 0..3), + // isolated from the shared `sprite` above + const makeSprite = () => { + const s = new Sprite(0, 0, { + framewidth: 32, + frameheight: 32, + image: video.createCanvas(64, 64), + }); + s.addAnimation("a", [0, 1, 2, 3], 100); // 4 frames, 100ms each + s.addAnimation("b", [0, 1], 100); + return s; + }; + + // ── legacy forms must keep working (non-breaking) ────────────────── + + it("legacy: loops by default", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.update(400); // one full cycle → wraps to frame 0 + expect(s.getCurrentAnimationFrame()).toBe(0); + expect(s.isCurrentAnimation("a")).toBe(true); + }); + + it("legacy: a string 2nd arg chains to the next animation", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", "b"); + s.update(400); + expect(s.isCurrentAnimation("b")).toBe(true); + }); + + it("legacy: a function returning false holds the last frame (called once)", () => { + const s = makeSprite(); + let calls = 0; + s.setCurrentAnimation("a", () => { + calls++; + return false; + }); + s.update(400); + expect(s.getCurrentAnimationFrame()).toBe(3); // held at last frame + expect(calls).toBe(1); + }); + + it("legacy: a function returning truthy keeps looping (called each cycle)", () => { + const s = makeSprite(); + let calls = 0; + s.setCurrentAnimation("a", () => { + calls++; + return true; + }); + s.update(400); + s.update(400); + expect(calls).toBe(2); + expect(s.isCurrentAnimation("a")).toBe(true); + }); + + // ── new options-object form ──────────────────────────────────────── + + it("options loop:false plays once, holds the last frame, fires onComplete once", () => { + const s = makeSprite(); + let done = 0; + s.setCurrentAnimation("a", { + loop: false, + onComplete: () => { + done++; + }, + }); + s.update(400); // completes + expect(s.getCurrentAnimationFrame()).toBe(3); + expect(done).toBe(1); + // must NOT advance or re-fire afterwards + s.update(400); + s.update(400); + expect(done).toBe(1); + expect(s.getCurrentAnimationFrame()).toBe(3); + }); + + it("options onComplete (looping) fires every cycle", () => { + const s = makeSprite(); + let n = 0; + s.setCurrentAnimation("a", { + onComplete: () => { + n++; + }, + }); + s.update(400); + s.update(400); + expect(n).toBe(2); + expect(s.isCurrentAnimation("a")).toBe(true); + }); + + it("options next chains, firing onComplete first", () => { + const s = makeSprite(); + const order = []; + s.setCurrentAnimation("a", { + next: "b", + onComplete: () => { + return order.push("done"); + }, + }); + s.update(400); + expect(s.isCurrentAnimation("b")).toBe(true); + expect(order).toEqual(["done"]); + }); + + it("options speed:2 advances twice as fast", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", { speed: 2 }); + s.update(50); // 50 × 2 = 100 effective → one frame + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("options speed:0.5 advances half as fast", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", { speed: 0.5 }); + s.update(100); // 100 × 0.5 = 50 < 100 → no advance + expect(s.getCurrentAnimationFrame()).toBe(0); + s.update(100); // cumulative 100 → advance one frame + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("getAnimationNames returns every defined animation", () => { + // the Sprite constructor auto-defines a "default" animation + expect(makeSprite().getAnimationNames().sort()).toEqual([ + "a", + "b", + "default", + ]); + }); + + // ── adversarial ──────────────────────────────────────────────────── + + it("ADVERSARIAL: a play-once animation un-sticks when another is selected", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", { loop: false }); + s.update(400); // done + held + s.setCurrentAnimation("b"); // switch + expect(s._animDone).toBe(false); + s.update(100); + expect(s.isCurrentAnimation("b")).toBe(true); + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("ADVERSARIAL: speed resets to 1 when switching without a speed option", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", { speed: 4 }); + s.setCurrentAnimation("b"); // no speed → back to 1× + s.update(50); // 50 < 100 at 1× → no advance + expect(s.getCurrentAnimationFrame()).toBe(0); + }); + + it("ADVERSARIAL: speed:0 freezes the animation", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", { speed: 0 }); + s.update(1000); + expect(s.getCurrentAnimationFrame()).toBe(0); + }); + + it("ADVERSARIAL: options onComplete return value is ignored (only the legacy fn holds)", () => { + const s = makeSprite(); + // returning false from onComplete must NOT hold — only the legacy + // bare-function form has that contract + s.setCurrentAnimation("a", { + onComplete: () => { + return false; + }, + }); + s.update(400); + expect(s.isCurrentAnimation("a")).toBe(true); // still looping + expect(s.getCurrentAnimationFrame()).toBe(0); // wrapped, not held + }); + + it("ADVERSARIAL: animationpause halts the options path too", () => { + const s = makeSprite(); + s.setCurrentAnimation("a", { loop: true }); + s.animationpause = true; + s.update(400); + expect(s.getCurrentAnimationFrame()).toBe(0); + }); + + it("ADVERSARIAL: re-selecting the SAME animation is a no-op (no reset mid-play)", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.update(100); // idx → 1 + s.setCurrentAnimation("a"); // same anim → must not reset to frame 0 + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + // ── play() / pause() / stop() shorthands (2D ↔ 3D parity) ────────── + + it("play(name) switches to and starts the animation", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.play("b"); + expect(s.isCurrentAnimation("b")).toBe(true); + expect(s.animationpause).toBe(false); + }); + + it("play(name, options) forwards options (loop:false holds last frame)", () => { + const s = makeSprite(); + let done = 0; + s.play("a", { + loop: false, + onComplete: () => { + return done++; + }, + }); + s.update(400); // one cycle + expect(s.getCurrentAnimationFrame()).toBe(3); // held + expect(done).toBe(1); + s.update(400); // _animDone → frozen + expect(s.getCurrentAnimationFrame()).toBe(3); + }); + + it("play() with no argument resumes after pause()", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.pause(); + expect(s.animationpause).toBe(true); + s.update(100); // paused → no advance + expect(s.getCurrentAnimationFrame()).toBe(0); + s.play(); + expect(s.animationpause).toBe(false); + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("pause() returns this and freezes the current frame", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.update(100); // idx → 1 + expect(s.pause()).toBe(s); // chainable + s.update(1000); // frozen + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("stop() resets to the first frame and pauses", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.update(150); // idx → 1 + expect(s.stop()).toBe(s); // chainable + expect(s.getCurrentAnimationFrame()).toBe(0); + expect(s.animationpause).toBe(true); + s.update(1000); // stays put + expect(s.getCurrentAnimationFrame()).toBe(0); + }); + + it("ADVERSARIAL: stop() then play() restarts from the first frame", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.update(250); // idx → 2 + s.stop(); // → frame 0, paused + s.play(); // resume + s.update(100); // advance one frame from 0 + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("ADVERSARIAL: stop() clears a held play-once so it can advance again", () => { + const s = makeSprite(); + s.play("a", { loop: false }); + s.update(400); // held at last frame, _animDone + s.stop(); // resets frame + clears _animDone + s.play("a"); // loop again + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(1); // advancing again + }); + + it("ADVERSARIAL: play(name) un-pauses in one call", () => { + const s = makeSprite(); + s.setCurrentAnimation("a"); + s.pause(); + s.play("b"); // must both switch AND resume + expect(s.isCurrentAnimation("b")).toBe(true); + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + }); });