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 0000000000..7c054d8d68 Binary files /dev/null and b/packages/examples/public/assets/gltf/Textures/texture-a.png differ diff --git a/packages/examples/public/assets/gltf/character.glb b/packages/examples/public/assets/gltf/character.glb new file mode 100644 index 0000000000..2e99b78746 Binary files /dev/null and b/packages/examples/public/assets/gltf/character.glb differ 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 0000000000..c7bc86171f Binary files /dev/null and b/packages/melonjs/tests/public/data/models/cube.png differ 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); + }); + }); });