From 5f5d58d92e921d2f96e8c2b24c273a6a74d71554 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 5 Aug 2026 13:49:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?WebGPU=203D=20tier=20and=20full=20backend?= =?UTF-8?q?=20parity=20=E2=80=94=2020.0.0=20(#1184,=20#1536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebGPU renderer completes the engine contract and becomes the AUTO default (WebGPU → WebGL 2 → Canvas, negotiated inside app.init()): - 3D tier: drawMesh through unlit/lit WGSL pipelines — retained model-space geometry under Camera3d (upload once, placement/tint/ cutoff/emissive per-draw uniforms) and the CPU-projected 2D path; per-mesh cull/winding as pipeline state; depth as pass load/store ops (one clear per target per frame, pure-2D passes byte-identical) - point + spot Light3d on both backends (#1536): 12-float std140 light entries (posRange/dirCone/colorInner), quadratic-over-range falloff, runtime-mutable fields; glTF loader instantiates all three punctual types (scene-scaled), carries authored light names, and gains a lightIntensityScale load option - custom mesh shaders on both backends: GLShader is now dual-language — object sources form {vertex, fragment, wgsl} with isWebGL/isWebGPU flags; mesh.shader hosts it (warn-and-degrade on mismatch); shader assets grow the matching complete-program shape - antiAlias => 4x MSAA on WebGPU canvas passes; mesh textures gain generated mip chains + trilinear + 4x anisotropy on both backends (authored compressed chains included; "nearest" opts out); 8-slot multi-texture quad batching - renderer parity batch: setBlendMode("none") on WebGL; enableScissor/ setBlendEnabled/clearRenderTarget, settings.batcher, settings.blendMode, GPUVendor, failIfMajorPerformanceCaveat on WebGPU; frameless-video guard on WebGL; instance-scoped compressed-format memo - shine adopts the uUVYDir seam (vertical sweeps ran backwards on the GL pooled path); GLTFScene flags meshes lit for lamp-only scenes - shared neutral hoists: gpu/meshvertex.ts, gpu/quadcorners.ts, gpu/primitives.ts (~250 duplicated lines deleted) - d.ts hygiene: @internal + a strip-internal build pass keep renderer internals out of the published typings - examples swept to video.AUTO; 3D examples guard on supportsDepthBuffer - documentation refresh across JSDoc and README (retained-mesh story, capability flags, dual-language shaders, glTF light options) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- README.md | 17 +- .../afterBurner/ExampleAfterBurner.tsx | 23 +- .../src/examples/aquarium/ExampleAquarium.tsx | 2 +- .../examples/billboard/ExampleBillboard.tsx | 2 +- .../src/examples/camera3d/ExampleCamera3d.tsx | 2 +- .../ExampleCompressedTextures.tsx | 2 +- .../examples/deviceTest/ExampleDeviceTest.tsx | 2 +- .../dragAndDrop/ExampleDragAndDrop.tsx | 2 +- .../src/examples/gltf/ExampleGltf.tsx | 2 +- .../examples/gltf/ExampleGltfCharacter.tsx | 2 +- .../src/examples/heatHaze/ExampleHeatHaze.tsx | 2 +- .../src/examples/mesh3d/ExampleMesh3d.tsx | 21 +- .../ExampleMultiMaterialMesh.tsx | 24 +- .../examples/nightcity/ExampleNightCity.tsx | 2 +- .../examples/normalMap/ExampleNormalMap.tsx | 2 +- .../ExampleSpriteIlluminator.tsx | 2 +- .../examples/svgShapes/ExampleSVGShapes.tsx | 2 +- .../src/examples/waterOverworld/game.ts | 2 +- .../src/examples/webgpu/ExampleWebGPU.tsx | 27 +- packages/melonjs/CHANGELOG.md | 8 +- packages/melonjs/package.json | 2 +- packages/melonjs/scripts/strip-internal.ts | 73 ++ .../melonjs/src/application/application.ts | 50 +- packages/melonjs/src/application/settings.ts | 89 ++- packages/melonjs/src/camera/camera3d.ts | 18 +- packages/melonjs/src/const.ts | 89 +-- packages/melonjs/src/level/gltf/GLTFScene.js | 99 ++- packages/melonjs/src/level/level.js | 11 +- packages/melonjs/src/lighting/light3d.ts | 77 +- packages/melonjs/src/loader/loader.js | 62 +- packages/melonjs/src/loader/parsers/gltf.js | 3 + packages/melonjs/src/loader/parsers/shader.js | 175 +++-- packages/melonjs/src/renderable/mesh.js | 84 ++- packages/melonjs/src/renderable/renderable.js | 16 +- .../melonjs/src/video/effects/dropShadow.js | 16 +- .../melonjs/src/video/effects/shadereffect.js | 47 ++ packages/melonjs/src/video/effects/shine.js | 18 +- packages/melonjs/src/video/gpu/meshchunk.ts | 78 +++ packages/melonjs/src/video/gpu/meshvertex.ts | 133 ++++ packages/melonjs/src/video/gpu/primitives.ts | 163 +++++ packages/melonjs/src/video/gpu/quadcorners.ts | 53 ++ packages/melonjs/src/video/renderer.js | 31 +- .../melonjs/src/video/utils/autodetect.js | 19 +- .../src/video/webgl/batchers/batcher.js | 29 + .../video/webgl/batchers/lit_mesh_batcher.js | 52 +- .../video/webgl/batchers/lit_quad_batcher.js | 57 +- .../video/webgl/batchers/material_batcher.js | 30 +- .../src/video/webgl/batchers/mesh_batcher.js | 189 ++--- .../video/webgl/batchers/primitive_batcher.js | 129 +--- .../src/video/webgl/batchers/quad_batcher.js | 73 +- packages/melonjs/src/video/webgl/glshader.js | 262 ++++++- .../src/video/webgl/lighting/pack3d.ts | 126 +++- .../src/video/webgl/lighting/std140.ts | 78 ++- .../src/video/webgl/shaders/mesh-lit.frag | 43 +- .../src/video/webgl/shaders/mesh-lit.vert | 8 +- .../melonjs/src/video/webgl/webgl_renderer.js | 106 ++- .../video/webgpu/batchers/lit_mesh_batcher.js | 272 ++++++++ .../video/webgpu/batchers/lit_quad_batcher.js | 12 + .../src/video/webgpu/batchers/mesh_batcher.js | 618 ++++++++++++++++ .../webgpu/batchers/primitive_batcher.js | 117 +--- .../src/video/webgpu/batchers/quad_batcher.js | 277 ++++++-- .../video/webgpu/buffer/retained_geometry.js | 144 ++++ .../src/video/webgpu/pipeline/cache.js | 71 +- .../webgpu/renderers/tmxlayer/orthogonal.js | 33 +- .../src/video/webgpu/shaders/blit.wgsl | 55 ++ .../src/video/webgpu/shaders/mesh-lit.wgsl | 147 ++++ .../src/video/webgpu/shaders/mesh.wgsl | 81 +++ .../src/video/webgpu/shaders/mipblit.wgsl | 30 + .../src/video/webgpu/shaders/quad.wgsl | 66 +- .../melonjs/src/video/webgpu/texture/store.js | 198 +++++- .../src/video/webgpu/webgpu_renderer.js | 660 ++++++++++++++++-- packages/melonjs/tests/gltf.spec.js | 167 +++++ .../tests/helpers/webgpu-mock-renderer.js | 70 +- packages/melonjs/tests/lighting3d.spec.js | 77 +- .../tests/lighting_block_wiring.spec.js | 21 +- .../melonjs/tests/lighting_std140.spec.js | 99 +-- packages/melonjs/tests/renderer_auto.spec.js | 67 ++ packages/melonjs/tests/shader-loader.spec.js | 109 ++- .../tests/shadereffect_dual_body.spec.js | 28 + .../melonjs/tests/webgl_mesh_mipmap.spec.js | 141 ++++ .../melonjs/tests/webgl_review_fixes.spec.js | 221 ++++++ .../melonjs/tests/webgl_sync_program.spec.js | 53 ++ .../melonjs/tests/webgpu_compressed.spec.js | 10 + .../tests/webgpu_custom_mesh_shader.spec.js | 290 ++++++++ .../melonjs/tests/webgpu_lit_mesh.spec.js | 255 +++++++ .../melonjs/tests/webgpu_mesh_batcher.spec.js | 374 ++++++++++ .../melonjs/tests/webgpu_mesh_depth.spec.js | 322 +++++++++ .../tests/webgpu_mesh_retained.spec.js | 203 ++++++ .../tests/webgpu_mesh_validate.spec.js | 174 +++++ packages/melonjs/tests/webgpu_mipmaps.spec.js | 208 ++++++ packages/melonjs/tests/webgpu_msaa.spec.js | 212 ++++++ .../melonjs/tests/webgpu_pipeline.spec.js | 80 +++ .../melonjs/tests/webgpu_post_effect.spec.js | 6 +- .../melonjs/tests/webgpu_quad_batcher.spec.js | 50 +- .../melonjs/tests/webgpu_renderer.spec.js | 10 +- .../tests/webgpu_texture_store.spec.js | 16 + .../melonjs/tests/wgsl_shader_asset.spec.js | 116 +++ packages/melonjs/tsconfig.build.json | 1 + .../{SpineBatcher.js => WebGLSpineBatcher.js} | 0 99 files changed, 7759 insertions(+), 1138 deletions(-) create mode 100644 packages/melonjs/scripts/strip-internal.ts create mode 100644 packages/melonjs/src/video/gpu/meshchunk.ts create mode 100644 packages/melonjs/src/video/gpu/meshvertex.ts create mode 100644 packages/melonjs/src/video/gpu/primitives.ts create mode 100644 packages/melonjs/src/video/gpu/quadcorners.ts create mode 100644 packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js create mode 100644 packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js create mode 100644 packages/melonjs/src/video/webgpu/buffer/retained_geometry.js create mode 100644 packages/melonjs/src/video/webgpu/shaders/blit.wgsl create mode 100644 packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl create mode 100644 packages/melonjs/src/video/webgpu/shaders/mesh.wgsl create mode 100644 packages/melonjs/src/video/webgpu/shaders/mipblit.wgsl create mode 100644 packages/melonjs/tests/renderer_auto.spec.js create mode 100644 packages/melonjs/tests/webgl_mesh_mipmap.spec.js create mode 100644 packages/melonjs/tests/webgl_review_fixes.spec.js create mode 100644 packages/melonjs/tests/webgl_sync_program.spec.js create mode 100644 packages/melonjs/tests/webgpu_custom_mesh_shader.spec.js create mode 100644 packages/melonjs/tests/webgpu_lit_mesh.spec.js create mode 100644 packages/melonjs/tests/webgpu_mesh_batcher.spec.js create mode 100644 packages/melonjs/tests/webgpu_mesh_depth.spec.js create mode 100644 packages/melonjs/tests/webgpu_mesh_retained.spec.js create mode 100644 packages/melonjs/tests/webgpu_mesh_validate.spec.js create mode 100644 packages/melonjs/tests/webgpu_mipmaps.spec.js create mode 100644 packages/melonjs/tests/webgpu_msaa.spec.js create mode 100644 packages/melonjs/tests/wgsl_shader_asset.spec.js rename packages/spine-plugin/src/{SpineBatcher.js => WebGLSpineBatcher.js} (100%) diff --git a/README.md b/README.md index 2e673fe842..e9ba4ddff5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A modern & lightweight HTML5 game engine ------------------------------------------------------------------------------- ![melonJS](https://melonjs.org/img/alex4-github.png) -[melonJS](https://melonjs.org/) is an open-source 2.5D game engine designed for indie developers — perspective and orthogonal cameras, GPU-accelerated tilemap rendering, post-processing effects, custom shaders, 3D mesh support, polygon-accurate physics, modern Tiled workflows, and high performance. Runs on WebGL or Canvas2D with automatic fallback, tree-shakeable so you only pay for what you use, and the entire engine fits in ~150 KB minzipped of vanilla JS/TS with no toolchain lock-in. Built with ES6 classes and bundled with [esbuild](https://esbuild.github.io). +[melonJS](https://melonjs.org/) is an open-source 2.5D game engine designed for indie developers — perspective and orthogonal cameras, GPU-accelerated tilemap rendering, post-processing effects, custom shaders, 3D mesh support, polygon-accurate physics, modern Tiled workflows, and high performance. Runs on WebGPU, WebGL 2 or Canvas2D with automatic fallback, tree-shakeable so you only pay for what you use, and the entire engine fits in ~150 KB minzipped of vanilla JS/TS with no toolchain lock-in. Built with ES6 classes and bundled with [esbuild](https://esbuild.github.io). [melonJS](https://melonjs.org/) is licensed under the [MIT License](LICENSE.md) and actively maintained by the team at AltByte in Singapore. @@ -28,11 +28,11 @@ melonJS is designed so you can **focus on making games, not on graphics plumbing - **[Canvas2D-inspired rendering API](https://github.com/melonjs/melonJS/wiki/Rendering-API)** — If you've used the HTML5 Canvas, you already know melonJS. The rendering API (`save`, `restore`, `translate`, `rotate`, `setColor`, `fillRect`, ...) follows the same familiar patterns — no render graphs, no shader pipelines, no instruction sets to learn. -- **True renderer abstraction** — Write your game once, run it on WebGL or Canvas2D with zero code changes. The engine handles all GPU complexity behind a unified API, with automatic fallback when WebGL is not available. Designed to support future backends (WebGPU) without touching game code. +- **True renderer abstraction** — Write your game once, run it on WebGPU, WebGL 2 or Canvas2D with zero code changes. The engine handles all GPU complexity behind a unified API: the default `AUTO` mode negotiates the best available backend (WebGPU → WebGL 2 → Canvas) at startup, and the entire feature set renders identically on both GPU backends. - **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** — `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`. +- **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 on the GPU backends — 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. @@ -46,7 +46,7 @@ Compatibility - Compatible with all major browsers (Chrome, Safari, Firefox, Opera, Edge) and mobile devices Graphics -- Fast WebGL renderer for desktop and mobile devices with fallback to Canvas rendering +- Fast GPU renderers (WebGPU and WebGL 2) for desktop and mobile devices, with fallback to Canvas rendering - Extensible batcher system for custom rendering pipelines - High DPI resolution & Canvas advanced auto scaling - Sprite with 9-slice scaling option and frame animation @@ -57,8 +57,8 @@ Graphics - 3D mesh rendering with OBJ/MTL model loading, multi-material support, hardware depth testing, and perspective projection via `Camera3d` — ~30% faster mesh rendering with near-zero per-frame allocation (a re-drawn static mesh produces no GC garbage) - 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** — `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) + - **3D** — `Light3d` directional, point, spot and 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 suns and lamps +- Built-in shader effects (Flash, Outline, Glow, Dissolve, CRT, Hologram, etc.) with multi-pass chaining via `postEffects`, plus custom shader support on both GPU backends: `ShaderEffect` for per-sprite fragment effects (GLSL and/or WGSL bodies) and complete custom mesh shader programs via `mesh.shader` (a dual-language `GLShader`: GLSL pair and/or WGSL module) - Trail renderable for fading, tapering ribbons behind moving objects (speed lines, sword slashes, magic trails) - System & Bitmap Text with built-in typewriter effect - Video sprite playback @@ -98,7 +98,7 @@ UI Scenes - 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 + - **GPU-accelerated tile rendering** for orthogonal maps on the GPU backends (WebGL 2 and WebGPU) — 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 under the Canvas renderer - Uncompressed and [compressed](https://github.com/melonjs/melonJS/tree/master/packages/tiled-inflate-plugin) Plain, Base64, CSV and JSON encoded XML tilemap loading - Orthogonal, Isometric, Hexagonal (both normal and staggered) and Oblique maps - Multiple layers with per-layer alpha, tinting and blend modes (multiple background/foreground, collision and Image layers) @@ -116,7 +116,7 @@ Scenes - Shape based Tile collision support - 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) + - Automatically lit by the scene's authored lights — the sun plus any point/spot lamps set up in the authoring tool - Textured, solid-colored, and vertex-colored materials - 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 @@ -179,6 +179,7 @@ Examples * [SVG Shapes](https://melonjs.github.io/melonJS/examples/#/svg-shapes) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/svgShapes)) * [Graphics](https://melonjs.github.io/melonJS/examples/#/graphics) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/graphics)) * [Hello World](https://melonjs.github.io/melonJS/examples/#/hello-world) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/helloWorld)) +* [Hello WebGPU](https://melonjs.github.io/melonJS/examples/#/webgpu) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/webgpu)) — the WebGPU backend in action, with the renderer negotiation surfaced on screen * [Whac-A-Mole](https://melonjs.github.io/melonJS/examples/#/whac-a-mole) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/whac-a-mole)) * [Compressed Textures](https://melonjs.github.io/melonJS/examples/#/compressed-textures) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/compressedTextures)) * [Aquarium](https://melonjs.github.io/melonJS/examples/#/aquarium) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/aquarium)) — screen-space water refraction with `renderer.toFrameTexture()`: fish swim across a seabed, then a water surface captures the live frame on the GPU and re-samples it through a scrolling `NoiseTexture2d` flow map (the standard screen-texture / opaque-frame-copy pattern) diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx index ef81fb4cc4..2004dd939c 100644 --- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx +++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx @@ -54,28 +54,31 @@ const createGame = async () => { // StrictMode remount, the preload callback bails for the rest of // the session, and the game never starts. Picks up cleanly once // the utils.tsx remount path is fixed (separate review thread). - // AfterBurner requires WebGL — `renderer: video.WEBGL` throws (post - // #1479) when the browser/GPU can't provide a context (driver - // blocklisted, software fallback failing the perf-caveat check, etc.). - // Surface a clear browser-level message to the user instead of - // letting the React tree render a stuck blank canvas with an obscure - // error buried in the dev console. + // AfterBurner needs a GPU backend — `video.AUTO` negotiates WebGPU + // first, then WebGL 2, and only lands on Canvas when neither exists + // (driver blocklisted, software fallback failing the perf-caveat + // check, etc.). Canvas has no Camera3d/mesh path, so surface a clear + // browser-level message instead of letting the React tree render a + // stuck blank canvas with an obscure error buried in the dev console. let app: Application; try { app = new Application(1024, 576, { parent: "screen", - renderer: video.WEBGL, + renderer: video.AUTO, scale: "auto", cameraClass: Camera3d, }); await app.init(); + if (!app.renderer.supportsDepthBuffer) { + throw new Error("no GPU backend available (Canvas fallback)"); + } } catch (err) { const reason = err instanceof Error ? err.message : String(err); globalThis.alert( - "AfterBurner couldn't start: WebGL isn't available in this browser.\n\n" + + "AfterBurner couldn't start: no GPU rendering is available in this browser.\n\n" + "This showcase uses Camera3d + 3D mesh rendering, which require a " + - "WebGL-capable browser/GPU. Try enabling hardware acceleration in " + - "your browser settings, or open this example in a different browser.\n\n" + + "WebGPU- or WebGL-capable browser/GPU. Try enabling hardware acceleration " + + "in your browser settings, or open this example in a different browser.\n\n" + `Details: ${reason}`, ); // Re-throw so the React example boundary doesn't think we diff --git a/packages/examples/src/examples/aquarium/ExampleAquarium.tsx b/packages/examples/src/examples/aquarium/ExampleAquarium.tsx index f8e9ea5f94..56c95448e7 100644 --- a/packages/examples/src/examples/aquarium/ExampleAquarium.tsx +++ b/packages/examples/src/examples/aquarium/ExampleAquarium.tsx @@ -338,7 +338,7 @@ const createGame = async () => { // full-screen renderables cover it regardless of the container size) scale: "auto", // toFrameTexture + ShaderEffect are WebGL features - renderer: video.WEBGL, + renderer: video.AUTO, antiAlias: true, // render at sub-pixel positions so the slow-swimming fish glide smoothly // instead of snapping pixel-to-pixel (default floors dx/dy to integers) diff --git a/packages/examples/src/examples/billboard/ExampleBillboard.tsx b/packages/examples/src/examples/billboard/ExampleBillboard.tsx index 0547c6263a..f2cbb46c7f 100644 --- a/packages/examples/src/examples/billboard/ExampleBillboard.tsx +++ b/packages/examples/src/examples/billboard/ExampleBillboard.tsx @@ -134,7 +134,7 @@ const createGame = async () => { try { app = new Application(1024, 768, { parent: "screen", - renderer: video.WEBGL, + renderer: video.AUTO, scale: "auto", cameraClass: Camera3dClass, antiAlias: true, diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx index aa2ac15b2f..a3b1dffc71 100644 --- a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx +++ b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx @@ -57,7 +57,7 @@ const createGame = async () => { // to Camera2d via its own constructor regardless). const app = new Application(1024, 768, { parent: "screen", - renderer: video.WEBGL, + renderer: video.AUTO, scale: "auto", cameraClass: Camera3dClass, }); diff --git a/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx b/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx index 750b0ac681..257877e6da 100644 --- a/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx +++ b/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx @@ -242,7 +242,7 @@ const createGame = async () => { const app = new Application(800, 600, { parent: "screen", scaleMethod: "flex", - renderer: video.WEBGL, + renderer: video.AUTO, }); await app.init(); diff --git a/packages/examples/src/examples/deviceTest/ExampleDeviceTest.tsx b/packages/examples/src/examples/deviceTest/ExampleDeviceTest.tsx index b11f08ba71..ad19c7c789 100644 --- a/packages/examples/src/examples/deviceTest/ExampleDeviceTest.tsx +++ b/packages/examples/src/examples/deviceTest/ExampleDeviceTest.tsx @@ -96,7 +96,7 @@ const createGame = async () => { // canvas down to a horizontal banner. parent: "screen", scaleMethod: "flex", - renderer: video.CANVAS, + renderer: video.AUTO, }); await app.init(); } catch { diff --git a/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx b/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx index 7ece8a4f77..67f5e85db0 100644 --- a/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx +++ b/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx @@ -143,7 +143,7 @@ const createGame = async () => { const app = new Application(1024, 768, { parent: "screen", scale: "auto", - renderer: video.CANVAS, + renderer: video.AUTO, }); await app.init(); } catch { diff --git a/packages/examples/src/examples/gltf/ExampleGltf.tsx b/packages/examples/src/examples/gltf/ExampleGltf.tsx index e29790f5e1..27277f7409 100644 --- a/packages/examples/src/examples/gltf/ExampleGltf.tsx +++ b/packages/examples/src/examples/gltf/ExampleGltf.tsx @@ -79,7 +79,7 @@ const createGame = async () => { try { app = new Application(1024, 768, { parent: "screen", - renderer: video.WEBGL, // Mesh rendering requires WebGL + renderer: video.AUTO, scale: "auto", cameraClass: Camera3dClass, antiAlias: true, diff --git a/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx b/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx index 2d17dd3100..401c9cb8bf 100644 --- a/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx +++ b/packages/examples/src/examples/gltf/ExampleGltfCharacter.tsx @@ -82,7 +82,7 @@ const createGame = async () => { try { app = new Application(1024, 768, { parent: "screen", - renderer: video.WEBGL, // Mesh rendering requires WebGL + renderer: video.AUTO, scale: "auto", cameraClass: Camera3dClass, }); diff --git a/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx b/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx index c4bbe96e53..d2967eab99 100644 --- a/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx +++ b/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx @@ -251,7 +251,7 @@ const createGame = async () => { parent: "screen", scale: "auto", // Light2d normal-map lighting + toFrameTexture are WebGL features - renderer: video.WEBGL, + renderer: video.AUTO, antiAlias: true, subPixel: true, }); diff --git a/packages/examples/src/examples/mesh3d/ExampleMesh3d.tsx b/packages/examples/src/examples/mesh3d/ExampleMesh3d.tsx index 357c07a76d..e09872ed74 100644 --- a/packages/examples/src/examples/mesh3d/ExampleMesh3d.tsx +++ b/packages/examples/src/examples/mesh3d/ExampleMesh3d.tsx @@ -22,25 +22,28 @@ import { createExampleComponent } from "../utils"; const base = `${import.meta.env.BASE_URL}assets/mesh3d/`; const createGame = async () => { - // mesh3d uses `me.Mesh`, which requires WebGL. Switch to - // `renderer: video.WEBGL` so the engine throws (post #1479) when the - // browser/GPU can't provide a context, instead of silently falling - // back to Canvas and producing a broken scene with no signal. + // mesh3d uses `me.Mesh`, which needs a GPU backend. `video.AUTO` + // negotiates WebGPU first, then WebGL 2; only a browser with neither + // lands on Canvas — fail loudly there instead of rendering a broken + // scene with no signal. let app: Application; try { app = new Application(1024, 768, { parent: "screen", - renderer: video.WEBGL, + renderer: video.AUTO, scale: "auto", }); await app.init(); + if (!app.renderer.supportsDepthBuffer) { + throw new Error("no GPU backend available (Canvas fallback)"); + } } catch (err) { const reason = err instanceof Error ? err.message : String(err); globalThis.alert( - "This example couldn't start: WebGL isn't available in this browser.\n\n" + - "The 3D mesh rendering used by this showcase requires a WebGL-capable " + - "browser/GPU. Try enabling hardware acceleration in your browser " + - "settings, or open this example in a different browser.\n\n" + + "This example couldn't start: no GPU rendering is available in this browser.\n\n" + + "The 3D mesh rendering used by this showcase requires a WebGPU- or " + + "WebGL-capable browser/GPU. Try enabling hardware acceleration in your " + + "browser settings, or open this example in a different browser.\n\n" + `Details: ${reason}`, ); throw err; diff --git a/packages/examples/src/examples/multiMaterialMesh/ExampleMultiMaterialMesh.tsx b/packages/examples/src/examples/multiMaterialMesh/ExampleMultiMaterialMesh.tsx index 7d5d37e3f5..4f9d247fec 100644 --- a/packages/examples/src/examples/multiMaterialMesh/ExampleMultiMaterialMesh.tsx +++ b/packages/examples/src/examples/multiMaterialMesh/ExampleMultiMaterialMesh.tsx @@ -62,27 +62,29 @@ const CRAFTS = [ // ─── entry point ────────────────────────────────────────────────── const createGame = async () => { - // `renderer: video.WEBGL` throws (post #1479) when the browser/GPU - // can't provide a context. Surface a clear browser-level message - // instead of letting the React tree render a stuck blank canvas. + // Multi-material 3D meshes need a GPU backend for usable frame rates + // — Canvas would solid-fill per triangle in JS, correct but 10-50× + // slower than the GPU rasterizer. `video.AUTO` negotiates WebGPU + // first, then WebGL 2; fail loudly on the Canvas fallback instead of + // letting the React tree render a stuck blank canvas. let app: Application; try { app = new Application(CANVAS_W, CANVAS_H, { parent: "screen", - // Multi-material 3D meshes need the WebGL renderer for usable - // frame rates — Canvas would solid-fill per triangle in JS, - // correct but 10-50× slower than the GPU rasterizer. - renderer: video.WEBGL, + renderer: video.AUTO, scale: "auto", }); await app.init(); + if (!app.renderer.supportsDepthBuffer) { + throw new Error("no GPU backend available (Canvas fallback)"); + } } catch (err) { const reason = err instanceof Error ? err.message : String(err); globalThis.alert( - "This example couldn't start: WebGL isn't available in this browser.\n\n" + - "The 3D mesh rendering used by this showcase requires a WebGL-capable " + - "browser/GPU. Try enabling hardware acceleration in your browser " + - "settings, or open this example in a different browser.\n\n" + + "This example couldn't start: no GPU rendering is available in this browser.\n\n" + + "The 3D mesh rendering used by this showcase requires a WebGPU- or " + + "WebGL-capable browser/GPU. Try enabling hardware acceleration in your " + + "browser settings, or open this example in a different browser.\n\n" + `Details: ${reason}`, ); throw err; diff --git a/packages/examples/src/examples/nightcity/ExampleNightCity.tsx b/packages/examples/src/examples/nightcity/ExampleNightCity.tsx index 0132e98a9c..2bfe5a1673 100644 --- a/packages/examples/src/examples/nightcity/ExampleNightCity.tsx +++ b/packages/examples/src/examples/nightcity/ExampleNightCity.tsx @@ -97,7 +97,7 @@ const createGame = async () => { try { app = new Application(1024, 768, { parent: "screen", - renderer: video.WEBGL, // Mesh rendering requires WebGL + renderer: video.AUTO, scale: "auto", cameraClass: Camera3dClass, // Showcase the decoupled antiAlias / textureFilter combo: diff --git a/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx b/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx index ffa1a750ce..3f5cce18b2 100644 --- a/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx +++ b/packages/examples/src/examples/normalMap/ExampleNormalMap.tsx @@ -175,7 +175,7 @@ const createGame = async () => { // Normal-map lighting needs the WebGL renderer's lit pipeline. // Under `video.AUTO`, a Canvas fallback would render the orbs // as flat sprites and emit a one-shot console warning. - renderer: video.WEBGL, + renderer: video.AUTO, }); await app.init(); diff --git a/packages/examples/src/examples/spriteIlluminator/ExampleSpriteIlluminator.tsx b/packages/examples/src/examples/spriteIlluminator/ExampleSpriteIlluminator.tsx index e27486c00f..768e3f38f0 100644 --- a/packages/examples/src/examples/spriteIlluminator/ExampleSpriteIlluminator.tsx +++ b/packages/examples/src/examples/spriteIlluminator/ExampleSpriteIlluminator.tsx @@ -183,7 +183,7 @@ const createGame = async () => { parent: "screen", scaleMethod: "flex", // per-pixel normal-map lighting needs the WebGL renderer - renderer: video.WEBGL, + renderer: video.AUTO, }); await app.init(); diff --git a/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx b/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx index 44c6e25bd9..b750b809dc 100644 --- a/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx +++ b/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx @@ -18,7 +18,7 @@ const createGame = async () => { try { const app = new Application(1024, 840, { parent: "screen", - renderer: video.WEBGL, + renderer: video.AUTO, blendMode: "normal", }); await app.init(); diff --git a/packages/examples/src/examples/waterOverworld/game.ts b/packages/examples/src/examples/waterOverworld/game.ts index ead46dab02..d12b9937c4 100644 --- a/packages/examples/src/examples/waterOverworld/game.ts +++ b/packages/examples/src/examples/waterOverworld/game.ts @@ -35,7 +35,7 @@ export const createGame = async () => { try { const app = new Application(960, 640, { parent: "screen", - renderer: video.WEBGL, + renderer: video.AUTO, scale: "auto", scaleMethod: "fit", antiAlias: false, diff --git a/packages/examples/src/examples/webgpu/ExampleWebGPU.tsx b/packages/examples/src/examples/webgpu/ExampleWebGPU.tsx index a62bf80ecc..da7e7c98d4 100644 --- a/packages/examples/src/examples/webgpu/ExampleWebGPU.tsx +++ b/packages/examples/src/examples/webgpu/ExampleWebGPU.tsx @@ -33,8 +33,33 @@ const makeMelonCanvas = () => { ctx.beginPath(); ctx.arc(64, 64, 60, 0, Math.PI * 2); ctx.fill(); + // watermelon rind stripes — WITHOUT them the melon is a featureless + // radial gradient and the spin reads as pointless shimmer instead of + // rotation + ctx.save(); + ctx.beginPath(); + ctx.arc(64, 64, 60, 0, Math.PI * 2); + ctx.clip(); + ctx.strokeStyle = "rgba(29, 58, 32, 0.55)"; + ctx.lineWidth = 9; + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(64, 64); + // wavy rind stripe from the center out + ctx.quadraticCurveTo( + 64 + Math.cos(angle + 0.35) * 34, + 64 + Math.sin(angle + 0.35) * 34, + 64 + Math.cos(angle) * 62, + 64 + Math.sin(angle) * 62, + ); + ctx.stroke(); + } + ctx.restore(); ctx.strokeStyle = "#1d3a20"; ctx.lineWidth = 5; + ctx.beginPath(); + ctx.arc(64, 64, 60, 0, Math.PI * 2); ctx.stroke(); return canvas; }; @@ -144,7 +169,7 @@ export const ExampleWebGPU = createExampleComponent(async () => { parent: "screen", scale: "auto", // opt-in only — `video.AUTO` never selects the WebGPU backend - renderer: video.WEBGPU, + renderer: video.AUTO, backgroundColor: "#10212f", }); // a WebGPU device cannot be acquired synchronously — this await is diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index bb2008d895..49b2131611 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -4,12 +4,17 @@ ### Added - **Shader effects run on the WebGPU renderer, and effect bodies are dual-language** — `ShaderEffect` (and every built-in effect) now works on both GPU backends. An effect body can be a GLSL string exactly as before, or one body per shading language: `new ShaderEffect(renderer, { glsl, wgsl })` — the renderer compiles the body matching its `shaderLanguage`, uniform names are shared so one `setUniform` serves both, and when no matching body exists the effect warns once and stays disabled (`enabled === false`) while the scene keeps rendering — the same graceful contract the Canvas renderer always had. All 18 dual-language built-in effects (Vignette, Blur, ColorMatrix/Desaturate/Invert/Sepia, Dissolve, DropShadow, Flash, Glow, Hologram, Outline, Pixelate, Scanline, Shine, TintPulse, Wave, ChromaticAberration) render identically under WebGL and WebGPU, through both the post-effect chain (cameras, multi-effect ping-pong, `screen_texture`/`screen_uv`/`noise_uv` builtins) and the single-effect fast path. The WGSL authoring convention — one uniform struct at `@group(3) @binding(0)` whose member names are the `setUniform` names, texture/sampler pairs for `setTexture`, builtins under their established names — is documented on the `ShaderEffect` class. Shader assets gain the matching dual shape: `{ type: "shader", src: { glsl, wgsl } }` (or inline via `data`), fetching what is declared and preloading successfully even when the active backend matches neither (inert stub, unload-safe). Existing GLSL-only effects and assets are untouched: the generated GLSL is byte-identical to 19.x -- **Experimental WebGPU renderer** — requestable as `renderer: video.WEBGPU` or via the `#webgpu` URI fragment. The backend negotiates its adapter/device asynchronously inside `await app.init()` (the workload the new two-phase `Application` startup exists for) and covers the **full 2D contract**: sprites, text and particles through a WGSL quad pipeline (packed-tint vertex stream identical to the WebGL layout, single-texture batching with the multi-texture upgrade seam left in the vertex format), filled/stroked shapes and the Path2D API through a primitive pipeline (thick lines via the shared frame-globals uniform block), all six blend modes as pipeline blend states (including min/max darken/lighten), patterns with per-axis repeat samplers, transform-derived scissor clipping, mid-frame scissored clears, and stencil-based `setMask`/`clearMask`, plus the GPU tile path: orthogonal TMX layers draw through a WGSL port of the shader tilemap renderer (one quad per tileset, per-layer GID index texture, animated tiles included [#1445 parity]). Frames record into one command encoder / render pass with a `depth24plus-stencil8` attachment carried from day one; the backend-neutral vertex formats and topologies of [#1551](https://github.com/melonjs/melonJS/issues/1551) are consumed declaratively into the pipeline layouts ([#1492](https://github.com/melonjs/melonJS/issues/1492)), and frame globals live in a bind-group-0 uniform buffer (the [#1555](https://github.com/melonjs/melonJS/issues/1555) shape). The rest of the 2D feature set follows suit: **2D lights and normal-map lighting** (`Light2d` glow quads, ambient-light cutouts and the std140 lit-sprite path, all through the reserved lights bind group), **`toFrameTexture()`** frame captures (alpha preserved, and row 0 is the top of the frame where the GL capture is bottom-up — GLSL capture shaders flip with `1.0 - uv.y`, their WGSL twins must not), **gradient fills of arbitrary shapes** (the stencil gradient-mask machinery as pipeline variants), and **compressed textures** (BC / ETC2 / ASTC through whichever `texture-compression-*` device features the adapter offers, consuming the loader's existing dds/ktx/ktx2/pvr/pkm parsers unchanged; PVRTC has no WebGPU equivalent and reports unsupported). Not yet implemented — meshes/`Camera3d` (the 3D tier): those capability flags stay honestly `false` and 3D scenes need `video.WEBGL`. **Deliberately excluded from `video.AUTO`**, opt-in until feature parity; `app.init()` rejects when WebGPU is unavailable rather than quietly substituting another renderer. See the reworked **Hello WebGPU** example ([#1184](https://github.com/melonjs/melonJS/issues/1184)) +- **WebGPU renderer** — the default on WebGPU-capable browsers: `video.AUTO` (the default `renderer` setting) now negotiates **WebGPU first, then WebGL 2, then Canvas** — the WebGPU attempt is a full adapter/device negotiation awaited inside `app.init()`, falling through to the synchronous candidates when it rejects, so `init()` always resolves under AUTO. Also requestable explicitly as `renderer: video.WEBGPU` (fails loudly, never substitutes) or via the `#webgpu` URI fragment; `#webgl` / `#canvas` force the other backends per-run. The backend covers the **full 2D contract**: sprites, text and particles through a WGSL quad pipeline (packed-tint vertex stream identical to the WebGL layout, **multi-texture batching** — one draw segment spans up to eight distinct textures, selected per quad by the vertex stream's texture id, so a texture change no longer breaks the batch), filled/stroked shapes and the Path2D API through a primitive pipeline (thick lines via the shared frame-globals uniform block), all six blend modes as pipeline blend states (including min/max darken/lighten), patterns with per-axis repeat samplers, transform-derived scissor clipping, mid-frame scissored clears, and stencil-based `setMask`/`clearMask`, plus the GPU tile path: orthogonal TMX layers draw through a WGSL port of the shader tilemap renderer (one quad per tileset, per-layer GID index texture, animated tiles included [#1445 parity]). Frames record into one command encoder / render pass with a `depth24plus-stencil8` attachment carried from day one; the backend-neutral vertex formats and topologies of [#1551](https://github.com/melonjs/melonJS/issues/1551) are consumed declaratively into the pipeline layouts ([#1492](https://github.com/melonjs/melonJS/issues/1492)), and frame globals live in a bind-group-0 uniform buffer (the [#1555](https://github.com/melonjs/melonJS/issues/1555) shape). The rest of the 2D feature set follows suit: **2D lights and normal-map lighting** (`Light2d` glow quads, ambient-light cutouts and the std140 lit-sprite path, all through the reserved lights bind group), **`toFrameTexture()`** frame captures (alpha preserved, and row 0 is the top of the frame where the GL capture is bottom-up — GLSL capture shaders flip with `1.0 - uv.y`, their WGSL twins must not), **gradient fills of arbitrary shapes** (the stencil gradient-mask machinery as pipeline variants), and **compressed textures** (BC / ETC2 / ASTC through whichever `texture-compression-*` device features the adapter offers, consuming the loader's existing dds/ktx/ktx2/pvr/pkm parsers unchanged; PVRTC has no WebGPU equivalent and reports unsupported). The **3D tier** completes the contract: `drawMesh` renders textured triangle meshes through unlit and lit WGSL mesh pipelines (`Light3d` half-Lambert directional + ambient via the std140 light block at the reserved lights bind group) — retained model-space geometry under `Camera3d` (upload once; placement, tint, alpha cutout and emissive ride one per-draw uniform snapshot, so moving or re-tinting a mesh never re-uploads geometry; `supportsDepthBuffer` / `supportsRetainedMesh` are now `true`) as well as the CPU-projected 2D-camera mesh path, with per-mesh back-face culling and winding as pipeline state, per-mesh `textureRepeat` / `textureFilter`, multi-material vertex colors and Uint32 indices, and depth realized as the render pass's load/store ops (one depth clear per render target per frame, the GL policy — pure-2D scenes keep byte-identical passes). glTF scenes and animated models, `Sprite3d` billboards and split-screen `Camera3d` viewports run unchanged on top. **`antiAlias: true` maps to 4× MSAA** on canvas passes (multisampled color + depth resolving into the canvas view; offscreen post-effect targets stay single-sampled, exactly like the GL pool FBOs). See the reworked **Hello WebGPU** example ([#1184](https://github.com/melonjs/melonJS/issues/1184)) +- **Custom mesh shaders on both GPU backends, and `GLShader` is now dual-language** — `mesh.shader` hosts a complete custom shader program on the WebGPU renderer too, closing the last WebGL/WebGPU feature gap. The same `GLShader` class carries it, exactly the way `ShaderEffect` carries dual bodies: alongside the classic positional `(gl, vertex, fragment)` form, the constructor accepts a sources object — `new GLShader(renderer.gl, { vertex, fragment, wgsl })` — holding a GLSL program pair and/or a complete WGSL module, with the new `isWebGL` / `isWebGPU` flags reporting which realizations exist (`renderer.gl` is simply undefined on non-WebGL backends, skipping the GLSL compile). Each renderer hosts the realization it speaks; the WGSL module is written against the documented mesh contract (`vertex_main`/`fragment_main` entry points over the frozen mesh vertex layout, with the frame globals, mesh texture/sampler, per-draw `MeshUniforms` and, on `lit` meshes, the `Light3dBlock` as bind groups — see the `GLShader` class docs) and `drawMesh` realizes it as its own pipeline family on both the retained (`Camera3d`) and CPU-projected paths. Shader assets grow the matching shape: `{ type: "shader", src: { vertex, fragment, wgsl } }` compiles into ONE shared `GLShader` carrying every declared realization (a `wgsl` source that declares its own `@vertex` entry point is recognized as a complete module rather than an effect body; either side omittable), so one `mesh.shader = loader.getShader(...)` assignment serves WebGL and WebGPU unchanged. Degradation is never fatal: a shader without a realization for the active backend is inert (built-in shading, one warning), and a module that fails asynchronous WGSL validation logs its errors and falls back the same way +- **Point and spot 3D lights** ([#1536](https://github.com/melonjs/melonJS/issues/1536)) — `Light3d` gains `"point"` and `"spot"` types alongside `"directional"` and `"ambient"`, on both GPU backends: a point light illuminates from a world `position` with quadratic falloff over `range` (the `Light2d` falloff model — tuned for pixel-unit worlds, deliberately not physical inverse-square), a spot adds an `innerConeAngle`/`outerConeAngle` cone with a smooth edge. All fields stay mutable at runtime (flicker, day/night, a swinging lamp). Loading a glTF scene now instantiates its authored `KHR_lights_punctual` point and spot lamps too — position, range and cone angles scene-scaled like the geometry — and every instantiated light carries its authored name, so `world.getChildByName("Sun")` finds it for runtime tuning. Authored physical intensities (lux/candela) are normalized to 1 by default as before; the new `level.load(name, { lightIntensityScale })` option multiplies them by a chosen factor instead, so relative light strengths from the authoring tool survive (e.g. `0.001` maps a 1000-lux sun to 1 and a half-strength fill to 0.5). The light uniform block grows from 8 to 12 floats per light (1568 bytes for the full 32-light rig) — custom shaders reading `Light3dBlock` need the new layout; `Light2d` / the 2D block are untouched +- **Mesh textures now sample generated mipmaps on both GPU backends** — mesh-path textures (OBJ/MTL, glTF, raw-geometry `Mesh`) get a full mip chain, trilinear minification and 4× anisotropic filtering, so distant and grazing-angle geometry stops shimmering: WebGL upgrades the min filter to `LINEAR_MIPMAP_LINEAR` over the chain it always generated (anisotropy via `EXT_texture_filter_anisotropic` where available), WebGPU builds the chain with blit passes at upload. Compressed assets (DDS/KTX/PVR/PKM) shipping an authored multi-level chain are trilinear-sampleable on both backends too — the chain they carry is used as-is, capped at what the asset provides. 2D rendering is untouched — sprite samplers are clamped to the base level, so a sprite sharing a mesh's image renders byte-identically — and `textureFilter: "nearest"` opts a mesh out (crisp pixel-art models keep hard minification) - **Up to 32 lights, and light data in a uniform buffer** ([#1552](https://github.com/melonjs/melonJS/issues/1552)) — `MAX_LIGHTS` rises from 8 to **32**, for both the lit sprite path (`Light2d` + normal maps) and the lit mesh path (`Light3d`). The old cap was a compatibility limit, not a design choice: light data travelled in GLSL uniform arrays, which are charged against `MAX_FRAGMENT_UNIFORM_VECTORS` — a small driver-reported budget shared with every other uniform a shader declares, and one that a `vec3` consumes a full slot of. It now travels in a `std140` uniform buffer, charged against `MAX_UNIFORM_BLOCK_SIZE` instead (at least 16 KB everywhere, typically 64 KB); 32 lights occupy 1056 bytes there. A static light rig still costs **zero** GL calls per frame, as before. Note this raises the *capacity*, not the shading cost: the fragment loop still runs once per pixel per live light, so unused slots are free but filling them is not. The four lit shaders move to GLSL ES 3.00 as a consequence — uniform blocks do not exist in ES 1.00. **User shaders are unaffected**: `ShaderEffect` bodies and raw `GLShader` sources stay GLSL ES 1.00 - **Backend-neutral vertex formats and draw topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)) — a vertex attribute can now be declared with a single `format` token (`"float32x3"`, `"unorm8x4"`) instead of a `size` + `type` + `normalized` triple, and a draw mode with a topology name (`"triangle-list"`, `"line-list"`). `Batcher.addAttribute` accepts three forms — a descriptor object, `(name, format, offset)`, and the existing `(name, size, glType, normalized, offset)` — and `Batcher.mode` accepts either vocabulary while still reading back as the GL enum. `Batcher.topology` is the new portable spelling. **The GL-enum form is supported indefinitely**, so custom batchers need no changes. Groundwork for [#1184](https://github.com/melonjs/melonJS/issues/1184): a format-declared layout needs no live rendering context, and describes itself to any backend. `VertexFormat` / `Topology` types and the `isVertexFormat` / `isTopology` / `resolveVertexFormat` / `PORTABLE_TOPOLOGIES` helpers are exported +- **A `"none"` blend mode on both GPU backends** — `setBlendMode("none")` disables blending outright (the source replaces the destination, alpha included). It was born as a WebGPU pipeline blend state; the WebGL renderer now honors it identically instead of silently falling back to `"normal"`. The related `setBlendEnabled`, `enableScissor` and `clearRenderTarget` renderer methods — WebGL-only before — are implemented on the WebGPU renderer as well, along with custom batcher overrides (`settings.batcher`/`settings.compositor`), the `settings.blendMode` startup value, `GPUVendor` (from the adapter info), and `failIfMajorPerformanceCaveat` (rejects a software fallback adapter, falling through to WebGL under AUTO) - **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — signal that a mesh's geometry was edited in place (`originalVertices`, `uvs`, `indices`, normals or per-vertex colours), so the GPU copy is refreshed on the next draw. Moving, rotating, scaling, re-tinting or fading a mesh needs no signal — those are applied when drawing, not stored in the geometry ### Changed (breaking) +- **`video.AUTO` (the default) now prefers WebGPU: the ladder is WebGPU → WebGL 2 → Canvas** — on a WebGPU-capable browser an existing game using `AUTO` starts on the WebGPU backend, which renders the entire engine feature set identically to WebGL (all 44 examples verified side-by-side). `await app.init()` still always resolves under AUTO — the WebGPU adapter/device negotiation is awaited first and falls through to the synchronous WebGL/Canvas candidates when it rejects. Pin `renderer: video.WEBGL` (or the `#webgl` URI fragment) to keep a game on WebGL - **`Batcher` is now the backend-neutral base class, and the WebGL base batcher is renamed `WebGLBatcher`** — the shared `Batcher` base defines the lifecycle contract every batcher honors (`init` / `bind` / `unbind` / `flush` / `reset` / `destroy`), with `WebGLBatcher` realizing it on GL state and the newly exported `WebGPUBatcher` (plus `WebGPUQuadBatcher` / `WebGPUPrimitiveBatcher`) on WebGPU render passes. Custom WebGL batchers change one word — extend `WebGLBatcher` instead of `Batcher`; constructor signature, settings and every method are unchanged. `renderer.addBatcher()` now rejects up front any batcher that does not extend that backend's base class (`WebGLBatcher` / `WebGPUBatcher`), instead of failing mid-draw later - **Starting a game is now two steps: construct the `Application`, then `await app.init()`** — and calling `init()` is **mandatory**, not optional. `init()` is asynchronous because a WebGPU device cannot be acquired synchronously; it still resolves without suspending on the Canvas and WebGL backends. Renderer failures surface as a rejection of `init()` (e.g. `renderer: video.WEBGL` on a device without WebGL 2), no longer as a constructor throw. The pre-created bootstrap application and the `legacy` setting are removed with it; the exported `game` now names the most recently **initialized** `Application` — it never points at a half-built app, and it is `undefined` until the first `init()` resolves: ```js @@ -50,6 +55,7 @@ The old path scales linearly with vertex count; the new one is flat, because no per-vertex work happens at all. At 1.16M vertices submitting the scene went from 83% of a 60fps frame budget to under 2%, and the draw-call collapse is the 16-bit chunking disappearing. Note this is the **CPU** cost of submitting the frame — GPU work is not waited on, so rasterization still costs what it costs, and a scene limited by fill rate rather than by geometry submission will see less of this back ### Fixed +- **`DropShadowEffect` rendered its shadow vertically mirrored (up instead of down) when chained with other effects on WebGL** — the pooled multi-effect path composites through capture FBOs, which are bottom-up under GL, so the y component of any directional UV arithmetic inside an effect body ran inverted relative to the single-effect fast path (and to the WebGPU backend, whose captures are top-down on both paths). Found by cross-backend comparison — earlier pixel-count probes were direction-blind. Effect bodies can now declare a `uUVYDir` uniform that the renderer feeds per draw path (+1 where `uv.y` grows downward, −1 on the GL pooled path); DropShadow uses it, so a positive `offsetY` means *down* on every path of both backends; `ShineEffect` adopts it too, so an angled sweep travels the documented direction (π/2 = top→bottom) on the pooled path as well - **a scene containing only meshes stopped clearing its depth buffer after the first frame, and its geometry disappeared** — a regression from the 19.7 mesh state-ownership work ([#1468](https://github.com/melonjs/melonJS/issues/1468)), found while working on [#1552](https://github.com/melonjs/melonJS/issues/1552). The depth clear and the lit-mesh light upload both ran from `MeshBatcher.bind()`, which is a per-*transition* hook, not a per-frame one: `setBatcher` returns early when the requested batcher is already current. A scene with nothing else to draw — no sprites, no UI, no unlit mesh beside a lit one — therefore bound once and never again, leaving the depth attachment on the first frame's values, so anything receding from the camera failed the depth test and was not drawn at all. The same silence froze `Light3d` lighting at its first-frame values on such a scene. Both now refresh on the draw path, at no measurable cost (one boolean test per draw for the depth clear; the light upload is skipped outright when the lights have not changed) ## [19.9.1] (melonJS 2) - _2026-07-28_ diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index 95f8c366f7..a275a83ade 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -86,7 +86,7 @@ "serve": "serve docs", "prepublishOnly": "pnpm dist:publish", "clean": "tsx scripts/clean.ts", - "types": "tsc --project tsconfig.build.json", + "types": "tsc --project tsconfig.build.json && tsx scripts/strip-internal.ts", "test:types": "tsc" } } diff --git a/packages/melonjs/scripts/strip-internal.ts b/packages/melonjs/scripts/strip-internal.ts new file mode 100644 index 0000000000..211759bf2b --- /dev/null +++ b/packages/melonjs/scripts/strip-internal.ts @@ -0,0 +1,73 @@ +/** + * Post-process the emitted `.d.ts` files, removing every declaration whose + * JSDoc carries an `@internal` tag. + * + * tsc's own `stripInternal` only honors the tag for TypeScript sources — + * declarations generated from JSDoc'd JavaScript keep their internal + * members, so engine internals (pass lifecycle, texture retirement, …) + * would otherwise surface in consumers' autocomplete. This pass walks the + * declaration AST with the TypeScript API and splices those members out, + * doc comment included. Runs as the tail of the `types` script. + */ +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +const ROOT = join(import.meta.dirname, "..", "build"); + +function* walkFiles(dir: string): Generator { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + yield* walkFiles(path); + } else if (path.endsWith(".d.ts")) { + yield path; + } + } +} + +function isInternal(node: ts.Node): boolean { + return ts + .getJSDocTags(node) + .some((tag) => tag.tagName.getText() === "internal"); +} + +let filesTouched = 0; +let membersStripped = 0; + +for (const path of walkFiles(ROOT)) { + const text = readFileSync(path, "utf8"); + if (!text.includes("@internal")) { + continue; + } + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true); + + // collect the full text ranges (leading trivia included, so the doc + // comment goes with the declaration) of every @internal-tagged node: + // class members plus top-level statements + const ranges: Array<{ start: number; end: number }> = []; + const collect = (node: ts.Node) => { + if (isInternal(node)) { + ranges.push({ start: node.getFullStart(), end: node.getEnd() }); + return; // no need to descend into a removed subtree + } + node.forEachChild(collect); + }; + source.forEachChild(collect); + + if (ranges.length === 0) { + continue; + } + // splice back-to-front so earlier ranges stay valid + let out = text; + for (const range of ranges.sort((a, b) => b.start - a.start)) { + out = out.slice(0, range.start) + out.slice(range.end); + } + writeFileSync(path, out); + filesTouched += 1; + membersStripped += ranges.length; +} + +console.log( + `strip-internal: removed ${membersStripped} internal declaration(s) across ${filesTouched} file(s)`, +); diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 32af5b6ead..234e8a4290 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -365,8 +365,8 @@ export default class Application { } else if (uriFragment.canvas === true) { settings.renderer = CANVAS; } else if (uriFragment.webgpu === true) { - // opt-in only, exactly like passing `renderer: video.WEBGPU` — - // `AUTO` never selects it (see the WEBGPU doc block) + // exactly like passing `renderer: video.WEBGPU`: requires the + // backend rather than trusting AUTO's ladder to land on it settings.renderer = WEBGPU; } @@ -510,12 +510,34 @@ export default class Application { if (typeof this.settings.renderer === "number") { switch (this.settings.renderer) { - case AUTO: - // "I want WebGL if available, Canvas otherwise" — try - // WebGL, silently fall back to Canvas if it isn't - // available. - this.renderer = autoDetectRenderer(this.settings as any); + case AUTO: { + // WebGPU first, then WebGL 2, then Canvas. WebGPU support + // can only be proven by negotiating an adapter/device, so + // the attempt is a full backend init awaited here — + // rejection falls through to the synchronous candidates + // (autoDetectRenderer) instead of failing the application. + let negotiated; + if (typeof globalThis.navigator?.gpu !== "undefined") { + const attempt = new WebGPURenderer(this.settings as any); + attempt.parentApplication = this; + try { + await attempt.init(); + negotiated = attempt; + } catch (e) { + // release the half-built backend (listeners + // unhook; no device was retained on rejection) + attempt.destroy(); + console.log( + `AUTO: WebGPU unavailable (${ + e instanceof Error ? e.message : String(e) + }) — falling back to WebGL`, + ); + } + } + this.renderer = + negotiated ?? autoDetectRenderer(this.settings as any); break; + } case WEBGL: // "I require WebGL" — fail loudly if it isn't // available instead of silently falling back to @@ -540,14 +562,14 @@ export default class Application { this.renderer = new WebGLRenderer(this.settings as any); break; case WEBGPU: - // Experimental, and deliberately excluded from `AUTO` until - // it reaches feature parity with the WebGL backend — someone - // asking for WebGPU is testing WebGPU, and a quiet - // substitution would make the test meaningless. Construction + // "I require WebGPU" — like WEBGL, an explicit request + // either runs on WebGPU or fails; a quiet substitution + // would make the request meaningless. Construction // acquires the GPUCanvasContext (throws when WebGPU is - // unavailable, surfacing as a rejection of this method); the - // adapter/device negotiation happens in the - // `renderer.init()` await below. + // unavailable, surfacing as a rejection of this method); + // the adapter/device negotiation happens in the + // `renderer.init()` await below. Use `video.AUTO` for + // the WebGPU → WebGL → Canvas fallback ladder. this.renderer = new WebGPURenderer(this.settings as any); break; default: diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 11e0a42acf..8b9166f9c2 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -36,21 +36,28 @@ type PowerPreference = "default" | "low-power" | "high-performance"; export type ApplicationSettings = { /** - * Renderer to use. Three built-in modes (constants from `me.video`): + * Renderer to use. Four built-in modes (constants from `me.video`): * - * - {@link CANVAS} — HTML5 Canvas backend. No shader / mesh / - * Camera3d support. + * - {@link AUTO} — negotiate the best available backend: + * **WebGPU → WebGL 2 → Canvas**. `await app.init()` always + * resolves under AUTO; the WebGPU attempt is a full adapter/device + * negotiation awaited inside `init()`, falling through to the + * synchronous candidates when it rejects. Under the Canvas tail the + * GPU-only subsystems (Camera3d, retained meshes, ShaderEffect, + * Light2d/Light3d shading, GPU tilemap) degrade or disable — gate on + * the renderer capability flags (`supportsDepthBuffer`, + * `shaderLanguage`, …) if your scene depends on them. + * - {@link WEBGPU} — require the WebGPU backend. `app.init()` + * **rejects** when no adapter/device can be acquired — it never + * substitutes another backend. * - {@link WEBGL} — **requires WebGL 2** (the WebGL renderer is - * WebGL 2 only since 20.0). Throws at `new Application(...)` - * time if a WebGL 2 context is unavailable (WebGL-1-only device, - * driver-blocklisted GPU, perf-caveat failure, etc.). Use this when - * your scene needs Camera3d, Mesh, ShaderEffect, Light2d or GPU - * tilemap — you'd rather fail fast than render a stuck blank canvas. - * - {@link AUTO} — try WebGL 2, silently fall back to Canvas if - * unavailable. Application construction always succeeds. The - * WebGL-only subsystems (Camera3d, Mesh, ShaderEffect, Light2d, - * GPU tilemap) silently stop working under the Canvas fallback — - * if your scene depends on any of those, use `WEBGL` instead. + * WebGL 2 only since 20.0). `app.init()` **rejects** if a WebGL 2 + * context is unavailable (WebGL-1-only device, driver-blocklisted + * GPU, perf-caveat failure, etc.) — fail fast rather than render a + * stuck blank canvas. + * - {@link CANVAS} — the HTML5 Canvas backend. No programmable + * pipeline: no shaders/lighting, no Camera3d/depth path (meshes + * only render on the CPU-projected 2D-camera path). * * Or pass a custom `Renderer` subclass instance for full control. * @default AUTO @@ -99,15 +106,19 @@ export type ApplicationSettings = { transparent: boolean; /** - * whether to enable or not video scaling interpolation + * whether to enable or not video scaling interpolation. + * On the GPU backends this drives polygon-edge antialiasing — + * **4× MSAA on WebGPU**, the context `antialias` flag on WebGL — + * while texture sampling smoothness is controlled separately by + * `textureFilter`. * @default false */ antiAlias: boolean; /** * Default texture magnification/minification filter, **decoupled from - * `antiAlias`** (WebGL only — the 2D Canvas renderer has no per-texture - * filtering and ignores this). + * `antiAlias`** (GPU backends — WebGL and WebGPU; the 2D Canvas + * renderer has no per-texture filtering and ignores this). * * `antiAlias` conflates two separate concerns: polygon-edge antialiasing * (MSAA) *and* texture sampling smoothness. This setting separates the @@ -169,13 +180,15 @@ export type ApplicationSettings = { physic: PhysicsType; /** - * Enable the WebGL2 procedural shader path for orthogonal tile layers. - * When `true` (default), eligible layers render via a single quad per - * tileset + a fragment shader doing per-fragment GID lookup, bypassing - * the per-tile draw loop entirely. Layers that don't qualify - * (Canvas renderer, non-orthogonal, collection-of-image tilesets, - * tilerendersize "grid", non-zero tileoffset, oversampled beyond the - * shader's overflow window) fall back to the legacy path automatically. + * Enable the GPU procedural shader path for orthogonal tile layers + * (backends advertising `renderer.supportsShaderTileLayers` — WebGL 2 + * and WebGPU). When `true` (default), eligible layers render via a + * single quad per tileset + a fragment shader doing per-fragment GID + * lookup, bypassing the per-tile draw loop entirely. Layers that + * don't qualify (Canvas renderer, non-orthogonal, + * collection-of-image tilesets, tilerendersize "grid", non-zero + * tileoffset, oversampled beyond the shader's overflow window) fall + * back to the legacy path automatically. * Set to `false` to disable globally. * @default true */ @@ -186,10 +199,11 @@ export type ApplicationSettings = { * (a software rasterizer, a blocklisted driver). Note this is stricter * than the WebGL default, which is `false`. * - * Combined with the WebGL 2 requirement, the effect is: under - * {@link AUTO} such a machine gets the Canvas renderer, and under - * {@link WEBGL} construction throws. Set to `false` to accept a - * software or blocklisted WebGL context instead. + * The WebGPU backend honors it too, by rejecting a fallback + * (software) adapter. The effect: under {@link AUTO} such a machine + * gets the Canvas renderer, and under {@link WEBGL} / {@link WEBGPU} + * `app.init()` rejects. Set to `false` to accept a software or + * blocklisted context instead. * @default true */ failIfMajorPerformanceCaveat: boolean; @@ -232,13 +246,16 @@ export type ApplicationSettings = { backgroundColor: string; /** - * a custom batcher class (WebGL only) + * a custom batcher class (extend the active backend's base: + * `WebGLBatcher` / `WebGPUBatcher`) * @deprecated since 18.1.0 — use `batcher` instead */ compositor?: (new (renderer: any) => WebGLBatcher) | undefined; /** - * a custom batcher class (WebGL only) + * a custom batcher class, riding the quad/primitive slots on either + * GPU backend — extend `WebGLBatcher` under WebGL, `WebGPUBatcher` + * under WebGPU (`addBatcher` rejects a wrong-backend class loudly) */ batcher?: (new (renderer: any) => WebGLBatcher) | undefined; @@ -251,14 +268,14 @@ export type ApplicationSettings = { * (e.g. the loader screen) explicitly use {@link Camera2d} regardless * of this setting. * - * **WebGL requirement.** Camera classes whose + * **GPU-backend requirement.** Camera classes whose * `static defaultSortOn === "depth"` (Camera3d and any subclass) need - * the WebGL renderer — perspective projection, depth attachment and - * mesh draw all live in the WebGL backend. Pairing such a - * `cameraClass` with `renderer: video.AUTO` on a system where AUTO - * falls back to Canvas emits a `console.warn` at construction time - * and produces a non-functional render. Use `renderer: video.WEBGL` - * to get a hard throw instead. + * a renderer with a depth buffer (`renderer.supportsDepthBuffer` — + * WebGL 2 or WebGPU). Pairing such a `cameraClass` with + * `renderer: video.AUTO` on a system where AUTO falls back to Canvas + * emits a `console.warn` and produces a non-functional render. Pin + * `renderer: video.WEBGL` (or `WEBGPU`) to make `app.init()` reject + * instead. * @default Camera2d */ cameraClass?: new ( diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index c4bee988be..9891d99d73 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -33,14 +33,16 @@ const _bScratchB = new Vector3d(); * replacement for `Camera2d` — inherits the post-effect FBO bracket, * color-matrix, fade / shake / follow plumbing, and screen viewport. * - * **WebGL required.** Camera3d's perspective projection, depth-buffer - * painter sort and mesh draw path all live in the WebGL renderer; the - * Canvas backend has none of these and would render a stuck blank scene. - * Construct the Application with `renderer: video.WEBGL` to get a hard - * throw at construction time if WebGL is unavailable. Pairing - * `cameraClass: Camera3d` with `video.AUTO` will emit a `console.warn` - * at construction (and silently misrender) when AUTO falls back to - * Canvas — see {@link ApplicationSettings.renderer} for the contract. + * **GPU backend required.** Camera3d's perspective projection, + * depth-buffer painter sort and retained mesh draw path need a renderer + * with a depth buffer (`renderer.supportsDepthBuffer` — WebGL 2 or + * WebGPU); the Canvas backend has none of these and would render a + * stuck blank scene. Construct the Application with + * `renderer: video.WEBGL` or `video.WEBGPU` to make `app.init()` reject + * when that backend is unavailable. Pairing `cameraClass: Camera3d` + * with `video.AUTO` will emit a `console.warn` (and silently misrender) + * when AUTO falls back to Canvas — see + * {@link ApplicationSettings.renderer} for the contract. * * Conventions: * - **Y-down + +Z forward.** Sprite at higher `pos.y` appears lower diff --git a/packages/melonjs/src/const.ts b/packages/melonjs/src/const.ts index 12ccb31fea..56f51c0f3a 100644 --- a/packages/melonjs/src/const.ts +++ b/packages/melonjs/src/const.ts @@ -1,65 +1,72 @@ /** - * Select the HTML5 Canvas renderer. Lower performance and no shader / - * mesh / Camera3d support, but supported on every browser including - * environments where WebGL is unavailable (some embedded webviews, - * stripped-down kiosk browsers, GPU blocklisted by driver policy). + * Select the HTML5 Canvas renderer. Lower performance and no + * programmable pipeline, but supported on every browser including + * environments where the GPU backends are unavailable (some embedded + * webviews, stripped-down kiosk browsers, GPU blocklisted by driver + * policy). * * Use when the example / game uses only 2D sprites + primitives and you - * want the broadest possible reach. Anything depending on `ShaderEffect`, - * `Mesh`, `Camera3d`, GPU TMX tile rendering or `Light2d` will silently - * not work — those subsystems are WebGL-only. + * want the broadest possible reach. Anything depending on the GPU + * backends degrades or disables here: `ShaderEffect` and `Light2d` + * shading stay inert, GPU TMX tile rendering falls back to the per-tile + * path, and `Mesh` / `Camera3d` lose the depth-buffer path (meshes only + * render CPU-projected under a 2D camera — `supportsDepthBuffer` is + * `false`). */ export const CANVAS = 0; /** - * Require the WebGL renderer. **Throws at `new Application(...)` time - * if WebGL is unavailable** (driver-blocklisted GPU, software fallback - * failing the `failIfMajorPerformanceCaveat` check, no `WebGLRenderingContext` - * in the environment, etc.) — does NOT silently fall back to Canvas. + * Require the WebGL renderer. **`app.init()` rejects if WebGL 2 is + * unavailable** (WebGL-1-only device, driver-blocklisted GPU, software + * fallback failing the `failIfMajorPerformanceCaveat` check, etc.) — + * it does NOT silently fall back to Canvas. * - * Use this when your scene needs WebGL (Camera3d, Mesh, ShaderEffect, - * Light2d, GPU tilemap) and you'd rather fail fast with a clear error - * than have the engine render a stuck blank canvas. + * Use this when your scene needs a GPU backend (Camera3d, Mesh, + * ShaderEffect, Light2d, GPU tilemap) pinned to WebGL specifically and + * you'd rather fail fast with a clear error than have the engine render + * a stuck blank canvas. * - * If Canvas fallback is acceptable when WebGL isn't there, use - * {@link AUTO} instead. + * If falling back is acceptable, use {@link AUTO} instead (WebGPU → + * WebGL 2 → Canvas). */ export const WEBGL = 1; /** - * Auto-select the renderer: prefer WebGL when available, silently fall - * back to Canvas otherwise. Application construction always succeeds. + * Auto-select the renderer: try WebGPU first (when the browser exposes it + * and an adapter/device negotiates successfully), fall back to WebGL 2, + * then to Canvas. `await app.init()` always resolves — a failed candidate + * falls through to the next rather than rejecting. * - * {@link WEBGPU} is **not** a candidate here and will not be selected - * automatically, however capable the browser — it stays opt-in until it - * reaches parity with WebGL. + * The WebGPU attempt is a full backend initialization (support can only be + * proven by negotiating a device), so on WebGPU-capable browsers `init()` + * settles after the adapter handshake; browsers without `navigator.gpu` + * skip straight to the synchronous WebGL probe. * - * Use this when your scene works under both renderers (2D sprites, - * primitives, basic tile maps) and you want the engine to pick the - * best available backend. Note: subsystems that require WebGL - * (Camera3d, Mesh, ShaderEffect, Light2d, GPU tilemap) will silently - * stop working under the Canvas fallback path — if your scene depends - * on any of those, use {@link WEBGL} so the failure surfaces at - * construction time instead of as a black canvas at runtime. + * Note: subsystems that require a GPU backend (Camera3d, Mesh, + * ShaderEffect, Light2d, GPU tilemap) will silently stop working under + * the terminal Canvas fallback — if your scene depends on any of those, + * use {@link WEBGL} (or {@link WEBGPU}) so the failure surfaces at + * startup instead of as a black canvas at runtime. + * + * A specific backend can still be forced per-run with the `#webgpu`, + * `#webgl` or `#canvas` URI fragments. */ export const AUTO = 2; /** - * Require the **experimental** WebGPU renderer. It covers the full - * non-post-effect 2D contract: sprites, text and particles (the quad - * pipeline), filled/stroked shapes and Path2D (the primitive pipeline), - * blend modes, clipping and stencil masks. Not yet implemented: post - * effects / ShaderEffect (WGSL story pending), lights, meshes/Camera3d, - * and GPU tile layers — scenes relying on those need {@link WEBGL}. + * Require the WebGPU renderer. The backend covers the full rendering + * contract of the WebGL renderer — the 2D tier (sprites, text, + * primitives, blend modes, masks and clipping, patterns, ShaderEffect / + * post effects in WGSL, 2D lights and normal maps, frame captures, + * gradient fills, compressed textures, GPU tile layers) and the 3D tier + * (retained and accumulated meshes, lit and unlit, Camera3d, Light3d, + * glTF scenes and models, Sprite3d billboards). + * * `app.init()` rejects when WebGPU is unavailable in the environment; * like {@link WEBGL} it fails loudly rather than falling back, so a - * missing capability surfaces at startup. - * - * Deliberately excluded from {@link AUTO}: until the WebGPU backend reaches - * feature parity with WebGL it is opt-in only, so no existing game can be - * silently moved onto an incomplete backend by a browser gaining support. - * You have to ask for it by name (or with the `#webgpu` URI fragment) to - * exercise it. + * missing capability surfaces at startup. Use {@link AUTO} if fallback + * to WebGL / Canvas is acceptable — AUTO already prefers WebGPU when + * the browser can negotiate a device. */ export const WEBGPU = 3; diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index ecfaad8b9e..e1479c3b53 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -64,10 +64,21 @@ export default class GLTFScene { * @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] - 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(...))`. + * `KHR_lights_punctual` lights — directional suns, point and spot lamps + * (plus a soft ambient fill) — to the world as {@link Light3d} + * renderables, so the meshes are lit as set up in the authoring tool. + * Set false to keep the meshes unlit / manage lighting yourself with + * `world.addChild(new Light3d(...))`. Each instantiated light carries + * its authored name, so `world.getChildByName("Sun")` finds it for + * runtime tuning (day/night cycles, flicker). + * @param {number} [options.lightIntensityScale] - multiply each light's + * AUTHORED intensity by this factor instead of normalizing it to 1. + * glTF stores physical units — lux for suns (a Blender daylight sun is + * ~1000+), candela for lamps — which blow out the engine's stylized + * half-Lambert shading when used raw, so the default keeps every light + * at unit intensity and lets the app tune. With this option the + * authored ratios survive: e.g. `0.001` maps a 1000-lux sun to 1 while + * a half-strength 500-lux fill lands at 0.5. */ addTo(container, options = {}) { if (!this.data) { @@ -77,12 +88,16 @@ export default class GLTFScene { const rightHanded = options.rightHanded !== false; const zSign = rightHanded ? -1 : 1; - // the scene is lit when it carries authored directional lights and the - // caller didn't opt out — meshes then render through the lit batcher. + // the scene is lit when it carries ANY shading-capable authored light + // (directional sun, point or spot lamp) and the caller didn't opt out + // — meshes then render through the lit batcher. A lamp-only scene must + // count too, or its instantiated lights would shine on unlit meshes. const lit = options.lights !== false && (this.data.lights ?? []).some((l) => { - return l.type === "directional"; + return ( + l.type === "directional" || l.type === "point" || l.type === "spot" + ); }); // scene meshes carry their own world transform — keep the container @@ -99,7 +114,7 @@ export default class GLTFScene { const model = new GLTFModel(this.data, { scale, rightHanded, lit }); model.name = this.name; container.addChild(model); - this._addLights(container, zSign, options); + this._addLights(container, zSign, scale, options); return; } @@ -191,11 +206,11 @@ export default class GLTFScene { container.addChild(mesh); } - this._addLights(container, zSign, options); + this._addLights(container, zSign, scale, options); } /** - * Add the scene's authored directional lights (plus a soft ambient fill) to + * Add the scene's authored lights — directional, point and spot — (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 @@ -203,33 +218,65 @@ export default class GLTFScene { * {@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 {number} scale - the scene's world scale (positions/ranges follow it) * @param {object} options - the `addTo` options (`lights` toggle) * @ignore */ - _addLights(container, zSign, options) { + _addLights(container, zSign, scale, options) { if (options.lights === false) { return; } + // glTF intensities are physical units (lux for directional, candela for + // point/spot — often in the thousands), which blow out the stylized + // half-Lambert shading when used raw. Default: normalize every light to + // unit intensity and let the app tune. With `lightIntensityScale` the + // authored values are kept, multiplied by the given factor, so relative + // light strengths from the authoring tool survive. + const intensityScale = + typeof options.lightIntensityScale === "number" + ? options.lightIntensityScale + : null; 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({ + // glTF-space → render space: the same Y-down / rightHanded Y/Z + // bridge the geometry uses, for directions AND positions + const direction = [d[0], -d[1], zSign * d[2]]; + const intensity = + intensityScale !== null ? light.intensity * intensityScale : 1; + let light3d; + if (light.type === "directional") { + light3d = new Light3d({ type: "directional", - // bring the glTF-space direction into render space (same - // Y-down / rightHanded Y/Z bridge the geometry uses) - direction: [d[0], -d[1], zSign * d[2]], + direction, color: [light.color[0], light.color[1], light.color[2]], - // glTF directional intensity is in lux (often thousands) — - // not meaningful for a stylized Lambert shader, so use a unit - // intensity and let the app tune `light.intensity` if needed. - intensity: 1, - }), - ); + intensity, + }); + } else if (light.type === "point" || light.type === "spot") { + const p = light.position; + light3d = new Light3d({ + type: light.type, + position: [p[0] * scale, -p[1] * scale, zSign * p[2] * scale], + direction, + color: [light.color[0], light.color[1], light.color[2]], + intensity, + // glTF range is in source units — scale like the geometry. + // Absent means unbounded in the spec; the engine's quadratic + // falloff needs a scale, so fall back to the Light3d default + range: + typeof light.range === "number" ? light.range * scale : undefined, + innerConeAngle: light.innerConeAngle, + outerConeAngle: light.outerConeAngle, + }); + } else { + continue; + } + // carry the authored name so the app can look the light up for + // runtime tuning (e.g. `world.getChildByName("Sun")`) + if (light.name) { + light3d.name = light.name; + } + container.addChild(light3d); added++; } // a soft ambient fill so the shadow side of lit meshes isn't pure black. diff --git a/packages/melonjs/src/level/level.js b/packages/melonjs/src/level/level.js index 3c60f17a23..8be051541e 100644 --- a/packages/melonjs/src/level/level.js +++ b/packages/melonjs/src/level/level.js @@ -148,6 +148,8 @@ export const level = { * @param {boolean} [options.setViewportBounds=true] - (TMX only) if true, set the viewport bounds to the map size * @param {number} [options.scale=1] - (glTF/GLB only) pixels per glTF unit applied to the whole scene * @param {boolean} [options.rightHanded=true] - (glTF/GLB only) convert the right-handed (Y-up) source to the engine's Y-down via a rotation rather than a mirror + * @param {boolean} [options.lights=true] - (glTF/GLB only) add the scene's authored `KHR_lights_punctual` lights (plus a soft ambient fill) as {@link Light3d} world children; each carries its authored name for `getChildByName` lookups + * @param {number} [options.lightIntensityScale] - (glTF/GLB only) multiply each light's authored physical intensity (lux/candela) by this factor instead of normalizing it to 1 — see {@link GLTFScene#addTo} * @returns {boolean} true if the level was successfully loaded * @example * // the game assets to be be preloaded @@ -174,6 +176,13 @@ export const level = { * levelContainer.translate(-levelContainer.width / 2, -levelContainer.height / 2 ); * // add it to the game world * app.world.addChild(levelContainer); + * + * // load a glTF/GLB scene (preloaded with type "glb") under a Camera3d: + * // 50 pixels per glTF unit, authored lux/candela intensities kept at + * // a 1/1000 scale instead of being normalized to 1 + * me.level.load("diorama", { scale: 50, lightIntensityScale: 0.001 }); + * // the authored lights are world children — grab the sun for a day/night cycle + * const sun = app.world.getChildByName("Sun")[0]; */ load(levelId, options) { options = Object.assign( @@ -226,7 +235,7 @@ export const level = { * @name getCurrentLevel * @memberof level * @public - * @returns {TMXTileMap} + * @returns {TMXTileMap|GLTFScene} the current level object (a TMXTileMap for Tiled maps, a GLTFScene for glTF/GLB scenes) */ getCurrentLevel() { return levels[this.getCurrentLevelId()]; diff --git a/packages/melonjs/src/lighting/light3d.ts b/packages/melonjs/src/lighting/light3d.ts index c476336d23..2577427bb2 100644 --- a/packages/melonjs/src/lighting/light3d.ts +++ b/packages/melonjs/src/lighting/light3d.ts @@ -9,13 +9,18 @@ import state from "../state/state.ts"; */ export interface Light3dOptions { /** - * light type. `"directional"` (a sun — shaded) and `"ambient"` (a flat fill - * added to every lit pixel) are used today; `"point"` is reserved. + * light type: `"directional"` (a sun), `"ambient"` (a flat fill added to + * every lit pixel), `"point"` (radiates from `position` with quadratic + * falloff over `range`) or `"spot"` (a point light confined to a cone + * along `direction`). + */ + type?: "directional" | "ambient" | "point" | "spot"; + /** + * world-space direction the light travels along (directional lights, + * and the cone axis of spot lights). */ - 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). */ + /** world-space position (point and spot lights). */ position?: [number, number, number]; /** * light color — a {@link Color}, a CSS color string, or an `[r, g, b]` @@ -24,6 +29,23 @@ export interface Light3dOptions { color?: Color | string | [number, number, number]; /** scalar multiplier on the light's contribution. Defaults to `1`. */ intensity?: number; + /** + * falloff distance in world units (point and spot lights): the light + * fades quadratically from full strength at its position to zero at + * `range` — the same stylized model as {@link Light2d}'s radius, not a + * physical inverse-square. Defaults to `1000`. + */ + range?: number; + /** + * spot cone: angle (radians) from the axis where the light is at full + * strength. Defaults to `0`. + */ + innerConeAngle?: number; + /** + * spot cone: angle (radians) from the axis where the light reaches + * zero, fading smoothly from `innerConeAngle`. Defaults to `π/4`. + */ + outerConeAngle?: number; } /** @@ -33,15 +55,20 @@ export interface Light3dOptions { * 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: + * Four types are shaded: * - **`"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. + * - **`"point"`** — radiates from `position`, fading quadratically to zero at + * `range` (the same stylized falloff as {@link Light2d}'s radius). + * - **`"spot"`** — a point light confined to a cone along `direction`, at + * full strength inside `innerConeAngle` and fading smoothly to zero at + * `outerConeAngle`. * - * `"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`). + * Fields are public and mutable, so a light can be animated at runtime + * (e.g. a day/night cycle rotating `direction`, a flickering torch fading + * `intensity`, a searchlight sweeping its cone). * @category Lighting * @example * import { Light3d } from "melonjs"; @@ -51,20 +78,41 @@ export interface Light3dOptions { * app.world.addChild(sun); * app.world.addChild(new Light3d({ type: "ambient", intensity: 0.3 })); * + * // a street lamp pool and a searchlight cone + * app.world.addChild(new Light3d({ + * type: "point", position: [120, -40, 60], color: "#ffb45e", range: 300, + * })); + * app.world.addChild(new Light3d({ + * type: "spot", position: [0, -200, 0], direction: [0, 1, 0], + * range: 800, innerConeAngle: 0.2, outerConeAngle: 0.45, + * })); + * * // 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 extends Renderable { - /** `"directional"` / `"ambient"` (shaded) or `"point"` (reserved). */ - override type: "directional" | "ambient" | "point"; - /** world-space travel direction (directional lights); kept normalized. */ + /** `"directional"`, `"ambient"`, `"point"` or `"spot"`. */ + override type: "directional" | "ambient" | "point" | "spot"; + /** + * world-space travel direction (directional lights, spot cone axis); + * kept normalized. + */ direction: Vector3d; - /** world-space position (point lights — reserved). */ + /** world-space position (point and spot lights). */ position: Vector3d; /** the light color. */ color: Color; /** scalar multiplier on the light's contribution. */ intensity: number; + /** + * falloff distance in world units (point/spot) — full strength at the + * position, zero at `range`. + */ + range: number; + /** spot cone inner angle (radians) — full strength inside it. */ + innerConeAngle: number; + /** spot cone outer angle (radians) — zero beyond it. */ + outerConeAngle: number; /** * @param [options] - see {@link Light3dOptions} @@ -111,6 +159,9 @@ export class Light3d extends Renderable { } this.intensity = options.intensity ?? 1; + this.range = options.range ?? 1000; + this.innerConeAngle = options.innerConeAngle ?? 0; + this.outerConeAngle = options.outerConeAngle ?? Math.PI / 4; // nothing to draw, and no transform to apply — keep it off the // renderer-state path entirely diff --git a/packages/melonjs/src/loader/loader.js b/packages/melonjs/src/loader/loader.js index 22a2725d62..cb4fca9c58 100644 --- a/packages/melonjs/src/loader/loader.js +++ b/packages/melonjs/src/loader/loader.js @@ -129,7 +129,7 @@ export function setOptions(options) { * @name setBaseURL * @memberof loader * @public - * @param {string} type - "*", "audio", "video", "binary", "image", "json", "js", "tmx", "tsx", "fontface", "aseprite", "shader" + * @param {string} type - "*", "audio", "video", "binary", "image", "json", "js", "tmx", "tsx", "fontface", "aseprite", "shader", "obj", "mtl", "gltf", "glb" * @param {string} [url="./"] - default base URL * @example * // change the base URL relative address for audio assets @@ -154,6 +154,10 @@ export function setBaseURL(type, url = "./") { baseURL["fontface"] = url; baseURL["aseprite"] = url; baseURL["shader"] = url; + // deliberately NOT part of the wildcard: 3D asset types resolve + // their internal references (map_Kd textures, glTF buffers) relative + // to their own file, so a global prefix would double-prefix them — + // set them individually when needed } } @@ -293,7 +297,7 @@ function onLoadingError(res) { * @typedef {object} Asset * @memberof loader * @property {string} name - name of the asset - * @property {string} type - the type of the asset ("audio"|"binary"|"image"|"json"|"js"|"tmx"|"tmj"|"tsx"|"tsj"|"fontface"|"video"|"aseprite"|"shader") + * @property {string} type - the type of the asset ("audio"|"binary"|"image"|"json"|"js"|"tmx"|"tmj"|"tsx"|"tsj"|"fontface"|"video"|"aseprite"|"shader"|"obj"|"mtl"|"gltf"|"glb") * @property {string|string[]} [src] - path and/or file name of the resource (for audio assets only the path is required). * For image assets, an array of sources can be provided as a fallback chain (e.g. compressed texture formats by priority, with a PNG fallback). * The loader will try each source in order and use the first one that loads successfully. @@ -334,6 +338,14 @@ function onLoadingError(res) { * { name: "'kenpixel'", type: "fontface", src: "data/font/kenvector_future.woff2" } * // video resources * {name: "intro", type: "video", src: "data/video/"} + * // 3D assets: Wavefront OBJ model + MTL material library + * {name: "ship", type: "obj", src: "data/models/ship.obj"} + * {name: "ship", type: "mtl", src: "data/models/ship.mtl"} + * // glTF / GLB scene (auto-registers with the level director) + * {name: "diorama", type: "glb", src: "data/scenes/diorama.glb"} + * // shader assets: an effect body, or a complete dual-backend program + * {name: "flash", type: "shader", src: "shaders/flash.frag"} + * {name: "toon", type: "shader", src: {vertex: "shaders/toon.vert", fragment: "shaders/toon.frag", wgsl: "shaders/toon.wgsl"}} */ /** @@ -949,6 +961,7 @@ export function getJSON(elt) { * // data.uvs — Float32Array of u,v texture coordinates * // data.indices — Uint16Array of triangle vertex indices * // data.vertexCount — number of unique vertices + * // data.groups — usemtl material groups ({materialName, start, count} index ranges) * }); */ export function getOBJ(elt) { @@ -963,16 +976,18 @@ export function getOBJ(elt) { /** * a parsed glTF/GLB scene descriptor, as returned by {@link loader.getGLTF} * @typedef {object} GLTFData - * @property {object[]} nodes - one entry per mesh primitive (accumulated `world` transform, `vertices`, `normals`, `uvs`, `indices`, `vertexCount`, decoded baseColor `image`, `doubleSided`) + * @property {object[]} nodes - one entry per mesh primitive: accumulated `world` transform, `vertices`, `normals`, `uvs`, `indices`, `vertexCount`, decoded baseColor `image` (or `null`), `baseColorFactor`, per-vertex `colors`, sampler-derived `textureRepeat`/`textureFilter`, `alphaCutoff`, `emissive`, `unlit` (KHR_materials_unlit), `doubleSided`, and the source node `name` * @property {Array<{world: number[], type?: string, perspective?: {yfov?: number, aspectRatio?: number, znear?: number, zfar?: number}, orthographic?: object}>} cameras - glTF cameras, each with its `world` transform + the glTF camera parameters (`perspective` for perspective cameras, `orthographic` otherwise) - * @property {object[]} lights - parsed `KHR_lights_punctual` lights (`type`, `color`, `intensity`, `range`, world-space `direction`/`position`, `name`) + * @property {object[]} lights - parsed `KHR_lights_punctual` lights (`type`, `color`, `intensity`, `range`, `innerConeAngle`/`outerConeAngle` for spots, world-space `direction`/`position`, `name`) * @property {{min: number[], max: number[]}} bounds - world-space scene bounds in glTF units + * @property {object[]} graph - the full node graph (every node's TRS/matrix + children), for custom traversal + * @property {object[]} animations - parsed node animations (consumed by `GLTFModel` playback) */ /** * return the parsed glTF/GLB scene descriptor for the given asset name. * - * The descriptor is `{ nodes, cameras, lights, bounds }`: + * The descriptor is `{ nodes, cameras, lights, bounds, graph, animations }`: * - `nodes` — one entry per mesh primitive, each carrying its accumulated * `world` transform (16 floats, column-major), `vertices`, `normals`, * `uvs`, `indices`, `vertexCount`, a decoded baseColor `image` (or `null`), @@ -980,8 +995,10 @@ export function getOBJ(elt) { * - `cameras` — glTF cameras, each with its `world` transform + perspective * parameters. * - `lights` — parsed `KHR_lights_punctual` lights (`type`, `color`, - * `intensity`, world-space `direction`/`position`); empty without the - * extension. The level director instantiates directional ones automatically. + * `intensity`, `range`, spot cone angles, world-space + * `direction`/`position`, `name`); empty without the extension. The + * level director instantiates directional, point and spot lights + * automatically (see {@link level.load} options). * - `bounds` — world-space `{ min, max }` (glTF units), handy for framing. * * Most code never needs this: a preloaded glTF/GLB auto-registers with the @@ -1032,18 +1049,21 @@ export function getGLTF(elt) { * is reset to `false`, so it is auto-destroyed with the renderable it is * assigned to, like any hand-constructed effect. * - * A shader asset declared as a **`{vertex, fragment}` program pair** (see - * the example) compiles into a raw {@link GLShader} instead — the type the - * advanced paths take directly (a `Mesh` custom shader, + * A shader asset declared as a **complete program** — a + * `{vertex, fragment}` GLSL pair and/or a full `wgsl` module (see the + * example) — compiles into a raw {@link GLShader} instead, carrying one + * realization per GPU backend (`isWebGL` / `isWebGPU`): the type the + * hosted paths take directly (a `Mesh` custom shader, * `renderer.customShader`, a custom batcher). Same shared-instance * semantics, and `GLShader.clone()` likewise yields a caller-owned copy. * - * Shader assets are WebGL-only: under the Canvas renderer a fragment-body - * asset returns an inert `ShaderEffect` stub (same behavior as constructing - * one directly), and a program-pair asset returns `null` (a raw GL program - * has no canvas analog). Note that shader assets require `video.init()` to - * have been called — an inherent precondition of the preload flow, since - * the loading screen itself needs the renderer. + * Degradation is never fatal: a fragment-body asset without a body in the + * active renderer's language (or on Canvas) is an inert `ShaderEffect` + * stub, and a complete-program asset without a realization for the active + * backend is an inert `GLShader` — assigning either just keeps the + * built-in rendering. Note that shader assets require an initialized + * Application (`await app.init()`) — an inherent precondition of the + * preload flow, since the loading screen itself needs the renderer. * @memberof loader * @param {string} elt - name of the shader asset (as specified in the preload list) * @returns {ShaderEffect|GLShader|null} the shared, precompiled shader, or `null` if not found @@ -1057,18 +1077,21 @@ export function getGLTF(elt) { * uniform float uIntensity; * vec4 apply(vec4 color, vec2 uv) { return mix(color, vec4(1.0), uIntensity); } * ` }, - * // or a complete {vertex, fragment} program pair → a raw GLShader + * // or a complete program — a {vertex, fragment} GLSL pair and/or a + * // full WGSL module → one GLShader carrying both realizations; the + * // active renderer hosts the one it speaks * { name: "toonMesh", type: "shader", src: { * vertex: "shaders/toon.vert", * fragment: "shaders/toon.frag", + * wgsl: "shaders/toon.wgsl", * } }, * ], () => { * // one shared program — same uniform state for every user * mySprite.shader = me.loader.getShader("waterRipple"); * // private copy with its own uniforms (caller-owned, shared = false) * boss.shader = me.loader.getShader("flash").clone(); - * // a program pair comes back as a GLShader, e.g. for a custom Mesh shader - * renderer.drawMesh(myMesh, { shader: me.loader.getShader("toonMesh") }); + * // a complete program hosts on a mesh, replacing the built-in shading + * myMesh.shader = me.loader.getShader("toonMesh"); * }); */ export function getShader(elt) { @@ -1105,6 +1128,7 @@ export function getShader(elt) { * const materials = me.loader.getMTL("fox"); * // materials["colormap"].Kd — [r, g, b] diffuse color (0-1 range) * // materials["colormap"].d — opacity (0-1) + * // materials["colormap"].Ke — [r, g, b] emissive color (glow, applied as Mesh.emissive) * // materials["colormap"].map_Kd — resolved texture URL * }); */ diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index 3d8bdea6a5..322ce56683 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -757,6 +757,9 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { color: def.color ?? [1, 1, 1], intensity: def.intensity ?? 1, range: def.range, + // spot cone angles (radians) — the spec defaults + innerConeAngle: def.spot?.innerConeAngle ?? 0, + outerConeAngle: def.spot?.outerConeAngle ?? Math.PI / 4, // world -Z axis of the node (third basis column negated), normalized direction: normalize3([-world[8], -world[9], -world[10]]), // world translation diff --git a/packages/melonjs/src/loader/parsers/shader.js b/packages/melonjs/src/loader/parsers/shader.js index e0a918616b..7d3f1aa077 100644 --- a/packages/melonjs/src/loader/parsers/shader.js +++ b/packages/melonjs/src/loader/parsers/shader.js @@ -4,6 +4,20 @@ import GLShader from "../../video/webgl/glshader.js"; import { shaderList } from "../cache.js"; import { fetchData } from "./fetchdata.js"; +// a `wgsl` source is either an effect BODY (the apply() convention — the +// realization generates the vertex stage around it) or a complete MODULE +// (a custom mesh shader). A module must declare its own `@vertex` entry +// point named `vertex_main`; a body never legally can — the realization +// already emits one, so a body containing it would fail compilation +// anyway. Comments are stripped before sniffing: a body whose comment +// merely MENTIONS "@vertex" must not be misrouted into a raw program. +const WGSL_MODULE = /@vertex\b[\s\S]*?\bfn\s+vertex_main\b/; +const WGSL_COMMENTS = /\/\/[^\n]*|\/\*[\s\S]*?\*\//g; + +function isWGSLModule(source) { + return WGSL_MODULE.test(source.replace(WGSL_COMMENTS, "")); +} + let _renderer; // gracefully capture a reference to the active renderer without adding more @@ -16,14 +30,16 @@ on(VIDEO_INIT, (renderer) => { /** * compile a shader asset's source into its shared, loader-owned instance: * a fragment body (the `apply()` convention) compiles into a - * {@link ShaderEffect}; a full `{vertex, fragment}` program pair compiles - * into a raw {@link GLShader} — for the advanced paths that take one (a - * `Mesh` custom shader, `renderer.customShader`, a custom batcher). - * Program pairs are WebGL-only: under the Canvas renderer a pair asset - * stores `null` (with a warning) — a raw GL program has no canvas analog, - * unlike ShaderEffect's inert Canvas stub. - * @param {string|{vertex: string, fragment: string, precision?: string}} source - the fragment body, or a complete program pair - * @returns {ShaderEffect|GLShader|null} the compiled asset, flagged `shared` + * {@link ShaderEffect}; a complete program — a `{vertex, fragment}` GLSL + * pair and/or a `wgsl` full module, either omittable — compiles into a + * {@link GLShader} carrying whichever realizations were declared, for the + * hosted paths that take one (a `Mesh` custom shader, + * `renderer.customShader`, a custom batcher). The active renderer hosts + * the realization it speaks; a shader without one for this backend is + * inert — assigning it degrades to built-in shading, never fails. + * @param {string|object} source - the fragment body (or `{glsl, wgsl}` + * bodies), or a complete program: `{vertex, fragment, wgsl?, precision?}` + * @returns {ShaderEffect|GLShader} the compiled asset, flagged `shared` * @throws if called before any `app.init()` resolved (no renderer to compile against) * @ignore */ @@ -34,6 +50,30 @@ export function compileShaderAsset(source) { ); } if (typeof source === "object" && source !== null) { + const hasPair = + typeof source.vertex === "string" && typeof source.fragment === "string"; + // a wgsl source that declares an @vertex vertex_main is a complete + // module (a custom mesh shader), not an effect body — see above + const wgslModule = + typeof source.wgsl === "string" && isWGSLModule(source.wgsl) + ? source.wgsl + : null; + + // complete-program shapes → one GLShader carrying every declared + // realization; `renderer.gl` is undefined on non-WebGL backends, + // which simply skips the GLSL compile (isWebGL stays false) + if (hasPair || wgslModule !== null) { + const shader = new GLShader(_renderer.gl, { + vertex: source.vertex, + fragment: source.fragment, + precision: source.precision, + wgsl: wgslModule ?? undefined, + label: "melonJS shader asset", + }); + shader.shared = true; + return shader; + } + // dual-language fragment bodies ({glsl, wgsl} — either may be // omitted): the ShaderEffect constructor picks the body matching // the renderer's shading language, and degrades to the inert stub @@ -43,32 +83,9 @@ export function compileShaderAsset(source) { effect.shared = true; return effect; } - if ( - typeof source.vertex !== "string" || - typeof source.fragment !== "string" - ) { - throw new Error( - "a program pair needs both `vertex` and `fragment` GLSL sources", - ); - } - // the pair is GLSL source compiled as-is, so the backend has to speak - // GLSL — not merely have a programmable pipeline - if (_renderer.shaderLanguage !== "glsl") { - console.warn( - `shader asset: {vertex, fragment} program pairs are GLSL and are unavailable on this renderer (shader language: ${ - _renderer.shaderLanguage ?? "none" - })`, - ); - return null; - } - const shader = new GLShader( - _renderer.gl, - source.vertex, - source.fragment, - source.precision, + throw new Error( + "a program pair needs both `vertex` and `fragment` GLSL sources", ); - shader.shared = true; - return shader; } const effect = new ShaderEffect(_renderer, source); // loader-owned: a renderable's cleanup must never auto-destroy it — @@ -89,10 +106,14 @@ export function compileShaderAsset(source) { * a shared {@link ShaderEffect} carrying one body per shading language; * the renderer compiles the matching one, and when none matches the * preload still succeeds with an inert (`enabled === false`) effect; - * - a complete **program pair** — `src: {vertex: url, fragment: url}` or - * `data: {vertex: glsl, fragment: glsl}` — → compiles into a shared raw - * {@link GLShader}, for the advanced paths that take one (a `Mesh` - * custom shader, `renderer.customShader`, a custom batcher). + * - a complete **program** — `src: {vertex: url, fragment: url}` (GLSL + * pair), `src: {wgsl: url}` where the WGSL declares its own `@vertex` + * entry point (a complete module), or both together for a dual-backend + * asset — → compiles into a shared raw {@link GLShader} carrying the + * declared realizations (`isWebGL` / `isWebGPU`), for the hosted paths + * that take one (a `Mesh` custom shader, `renderer.customShader`, a + * custom batcher); the active renderer hosts the realization it speaks + * and a missing one degrades to built-in shading. * * Always compiled AT LOAD TIME, so the GLSL compile cost lands in the * loading screen and compile errors carry the asset name. An initialized @@ -133,71 +154,45 @@ export function preloadShader(data, onload, onerror, settings) { return 1; } - // `src` as {glsl, wgsl} effect-body URLs (either may be omitted) → - // fetch what is declared, compile the dual-body ShaderEffect (same - // Promise.all pattern as the program pair below) - if ( - typeof data.src === "object" && - data.src !== null && - (typeof data.src.glsl === "string" || typeof data.src.wgsl === "string") - ) { - const languages = ["glsl", "wgsl"].filter((language) => { - return typeof data.src[language] === "string"; - }); - Promise.all( - languages.map((language) => { - return fetchData(data.src[language], "text", settings); - }), - ) - .then((sources) => { - // concurrent-load guard — see the single-source path below - if (typeof shaderList[data.name] === "undefined") { - const bodies = {}; - languages.forEach((language, index) => { - bodies[language] = sources[index]; - }); - shaderList[data.name] = compileShaderAsset(bodies); - } - if (typeof onload === "function") { - onload(); - } - }) - .catch((error) => { - if (typeof onerror === "function") { - onerror(new Error(`shader asset "${data.name}": ${error.message}`)); - } - }); - return 1; - } - - // `src` as a {vertex, fragment} pair of URLs → fetch both, compile a - // raw GLShader program + // `src` as an object of URLs — {glsl, wgsl} effect bodies, a + // {vertex, fragment} GLSL program pair, or a complete dual-backend + // program {vertex, fragment, wgsl}: fetch whatever is declared and + // let compileShaderAsset dispatch on the assembled shape if (typeof data.src === "object" && data.src !== null) { - if ( - typeof data.src.vertex !== "string" || - typeof data.src.fragment !== "string" - ) { + const fields = ["glsl", "wgsl", "vertex", "fragment"].filter((field) => { + return typeof data.src[field] === "string"; + }); + const pairComplete = + (typeof data.src.vertex === "string") === + (typeof data.src.fragment === "string"); + if (fields.length === 0 || !pairComplete) { if (typeof onerror === "function") { onerror( new Error( - `shader asset "${data.name}": a program pair needs both \`src.vertex\` and \`src.fragment\` URLs`, + fields.length === 0 + ? `shader asset "${data.name}": \`src\` needs {glsl, wgsl} body URLs and/or a {vertex, fragment} program pair` + : `shader asset "${data.name}": a program pair needs both \`src.vertex\` and \`src.fragment\` URLs`, ), ); } return 1; } - Promise.all([ - fetchData(data.src.vertex, "text", settings), - fetchData(data.src.fragment, "text", settings), - ]) - .then(([vertex, fragment]) => { + Promise.all( + fields.map((field) => { + return fetchData(data.src[field], "text", settings); + }), + ) + .then((sources) => { // concurrent-load guard — see the single-source path below if (typeof shaderList[data.name] === "undefined") { - shaderList[data.name] = compileShaderAsset({ - vertex, - fragment, - precision: data.precision, + const parts = {}; + fields.forEach((field, index) => { + parts[field] = sources[index]; }); + if (typeof data.precision === "string") { + parts.precision = data.precision; + } + shaderList[data.name] = compileShaderAsset(parts); } if (typeof onload === "function") { onload(); diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 668b60c7cd..0531c214ab 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -119,7 +119,21 @@ function resolveGroupMaterial(group, materials) { * or from raw geometry data (vertices, uvs, indices). * Includes a built-in perspective projection and supports 3D transforms * through the standard Renderable API (`rotate`, `scale`, `translate`). - * Works on both WebGL (hardware depth testing) and Canvas (painter's algorithm) renderers. + * Works on both GPU backends — WebGL 2 and WebGPU, with hardware depth + * testing — and on the Canvas renderer via the CPU-projected 2D-camera + * path (painter's algorithm, no depth buffer). + * + * **Retained rendering** — under a {@link Camera3d} on a GPU backend + * (`renderer.supportsRetainedMesh`), the mesh's model-space geometry is + * uploaded to GPU buffers **once** and every frame just draws it by + * reference: placement, camera, tint, alpha and emissive all ride + * per-draw uniforms, so moving, rotating, scaling or re-tinting a mesh + * never re-uploads geometry (near-zero per-frame allocation). Editing + * the geometry itself in place is still possible — signal it with + * {@link Mesh#needsUpdate} and the GPU copy refreshes on the next draw. + * Under a 2D camera (or on Canvas) the mesh instead re-projects its + * vertices on the CPU each frame — same API, the camera and backend + * decide the path. * * **Pivot — transforms are applied about the mesh's local origin `(0, 0, 0)`, * NOT a normalized anchor point.** Unlike a {@link Sprite} (whose @@ -154,9 +168,9 @@ export default class Mesh extends Renderable { * @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"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). 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. - * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. WebGL only (ignored by the Canvas renderer). - * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. WebGL mesh path only. - * @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). WebGL mesh path only. + * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. On the mesh path, linear filtering also samples a generated mip chain with trilinear minification and 4× anisotropy (distant geometry stops shimmering) — `"nearest"` opts out, keeping crisp pixel-art models on hard level-0 sampling. GPU backends only (ignored by the Canvas renderer). + * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). + * @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { @@ -276,7 +290,7 @@ export default class Mesh extends Renderable { /** * the projected vertex positions. * - * **Not refreshed on the WebGL `Camera3d` path.** There, geometry is + * **Not refreshed on the retained `Camera3d` path.** There, geometry is * uploaded once in model space and placed by the GPU, so nothing * projects vertices per frame and this array holds whatever it last * did. It is still maintained by the Canvas renderer and by the 2D @@ -321,7 +335,7 @@ export default class Mesh extends Renderable { * from the scene's lights, using {@link Mesh#originalNormals}); when * `false` (the default) it uses the lean unlit path and pays no lighting * cost. The glTF loader sets this on scene meshes when the scene has - * lights. Only meaningful under a `Camera3d` + WebGL. + * lights. Only meaningful under a `Camera3d` on a GPU backend. * @type {boolean} * @default false */ @@ -333,7 +347,7 @@ export default class Mesh extends Renderable { * decals) that needs no blending or back-to-front sorting. `0` (the * default) disables the cutout and the mesh renders fully opaque. Set by * the glTF loader from a material's `alphaMode: "MASK"` / `alphaCutoff`. - * WebGL mesh path only (the Canvas renderer ignores it). + * GPU mesh path only (the Canvas renderer ignores it). * @type {number} * @default 0 */ @@ -347,7 +361,7 @@ export default class Mesh extends Renderable { * screens, glowing eyes). `undefined` (the default) means no emission and * keeps the mesh on the lean path. Set by the glTF loader from a material's * `emissiveFactor` (× `KHR_materials_emissive_strength`) and by the OBJ - * loader from an MTL's `Ke`. WebGL mesh path only. + * loader from an MTL's `Ke`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). * @type {Float32Array|undefined} */ this.emissive = toEmissive(settings.emissive); @@ -553,22 +567,23 @@ export default class Mesh extends Renderable { // Optional texture magnification filter (`"nearest"` for crisp pixel-art // upscaling, `"linear"` for smooth). When omitted the texture keeps the - // renderer's global `antiAlias` default. WebGL only — the Canvas renderer - // ignores it. + // renderer's global `antiAlias` default. GPU backends only — the Canvas + // renderer ignores it. WebGL stores its GL enum; other backends (the + // WebGPU texture store) consume the string form directly. // // NOTE: unlike `textureRepeat` above, this still mutates the shared // per-image atlas — the texture-unit cache doesn't discriminate by // filter, so a true per-mesh filter needs the unit-key change planned // with the #1410 TextureCache refactor. Two consumers of one image // wanting different filters is last-writer-wins until then. - if ( - hasRealTexture && - typeof settings.textureFilter === "string" && - game.renderer.gl - ) { - const gl = game.renderer.gl; - this.texture.filter = - settings.textureFilter === "nearest" ? gl.NEAREST : gl.LINEAR; + if (hasRealTexture && typeof settings.textureFilter === "string") { + const gl = game.renderer?.gl; + if (gl) { + this.texture.filter = + settings.textureFilter === "nearest" ? gl.NEAREST : gl.LINEAR; + } else { + this.texture.filter = settings.textureFilter; + } } /** @@ -669,6 +684,35 @@ export default class Mesh extends Renderable { } } + /** + * A custom shader hosted on this mesh's draw, replacing the built-in + * mesh shading: a {@link GLShader} carrying a `{vertex, fragment}` + * GLSL program (hosted by the WebGL renderer) and/or a complete + * `wgsl` module (hosted by the WebGPU renderer — see the GLShader + * class docs for the module contract). One object serves both + * backends; a shader without a realization for the active backend + * (or `null`) degrades to the built-in shading. The tint, texture, + * placement and (for `lit` meshes) light data keep flowing through + * their usual uniforms — the custom shader decides what to do with + * them. + * @type {GLShader|undefined} + * @example + * me.loader.preload([{ name: "toon", type: "shader", src: { + * vertex: "shaders/toon.vert", // GLSL pair for WebGL + * fragment: "shaders/toon.frag", + * wgsl: "shaders/toon.wgsl", // complete module for WebGPU + * }}], () => { + * myMesh.shader = me.loader.getShader("toon"); + * }); + */ + get shader() { + return super.shader; + } + + set shader(value) { + super.shader = value; + } + /** * Compose this mesh's placement into a single matrix: where its model-space * geometry sits in the world. @@ -964,8 +1008,8 @@ export default class Mesh extends Renderable { * Multi-material meshes need no extra `drawMesh` calls per material vs * single-material — each material's diffuse color is baked into * `vertexColors` at construction time and pushed through the renderer's - * per-vertex `aColor` (WebGL) or per-triangle solid-fill (Canvas) path. - * The WebGL batcher may still chunk very large meshes across multiple + * per-vertex `aColor` (GPU backends) or per-triangle solid-fill (Canvas) + * path. The GPU batchers may still chunk very large meshes across multiple * `drawElements` calls to fit its vertex/index buffer limits. * * The active path is picked from the `viewport` passed in by diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index d2ff230a64..cc69a5c17b 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -292,10 +292,12 @@ export default class Renderable extends Rect { this.mask = undefined; /** - * the list of post-processing shader effects applied to this renderable (WebGL only). - * Effects are applied in order. Use {@link addPostEffect}, {@link getPostEffect}, - * and {@link removePostEffect} to manage effects, or assign directly. - * In Canvas mode, this property is ignored. + * the list of post-processing shader effects applied to this renderable + * (GPU backends — WebGL and WebGPU). Effects are applied in order. Use + * {@link addPostEffect}, {@link getPostEffect}, and + * {@link removePostEffect} to manage effects, or assign directly. + * On the Canvas renderer effects stay inert (the scene keeps rendering + * un-effected). * @type {Array} * @default [] * @example @@ -475,7 +477,11 @@ export default class Renderable extends Rect { effect.destroy(); } } - if (typeof value === "undefined") { + // null clears like undefined: loader.getShader stores null for a + // shader asset the active backend cannot compile (e.g. a GLSL-only + // program pair under WebGPU) — assigning that must degrade to "no + // shader", not enqueue a null the effect filter would trip over + if (value == null) { this.postEffects.length = 0; } else { this.postEffects = [value]; diff --git a/packages/melonjs/src/video/effects/dropShadow.js b/packages/melonjs/src/video/effects/dropShadow.js index 1bc40d9f38..08b2abb619 100644 --- a/packages/melonjs/src/video/effects/dropShadow.js +++ b/packages/melonjs/src/video/effects/dropShadow.js @@ -8,6 +8,7 @@ struct ShadowUniforms { uShadowColor : vec3f, uShadowOpacity : f32, uTextureSize : vec2f, + uUVYDir : f32, }; @group(3) @binding(0) var fx : ShadowUniforms; @@ -18,7 +19,11 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { // check if the shadow source pixel is opaque. Level-0 sample: past a // non-uniform return implicit derivatives are unavailable (sprites are // single-level textures, so identical output) - let offset = fx.uShadowOffset / fx.uTextureSize; + var offset = fx.uShadowOffset / fx.uTextureSize; + // uUVYDir keeps "down" pointing down whatever the sampled space's + // vertical orientation (always +1 under WebGPU; the GL pooled path + // sets -1 for its bottom-up captures) + offset.y = offset.y * fx.uUVYDir; let shadowAlpha = textureSampleLevel(uTexture, uSampler, uv - offset, 0.0).a; if (shadowAlpha > 0.0) { return vec4f(fx.uShadowColor, shadowAlpha * fx.uShadowOpacity) * vColor; @@ -59,12 +64,17 @@ export default class DropShadowEffect extends ShaderEffect { uniform vec3 uShadowColor; uniform float uShadowOpacity; uniform vec2 uTextureSize; + uniform float uUVYDir; vec4 apply(vec4 color, vec2 uv) { if (color.a > 0.0) { return color; } - // check if the shadow source pixel is opaque + // check if the shadow source pixel is opaque. uUVYDir keeps + // "down" pointing down whatever the sampled space's vertical + // orientation (+1 sampling the sprite atlas directly, -1 on + // the pooled path's bottom-up capture FBOs) vec2 offset = uShadowOffset / uTextureSize; + offset.y *= uUVYDir; float shadowAlpha = texture2D(uSampler, uv - offset).a; if (shadowAlpha > 0.0) { return vec4(uShadowColor, shadowAlpha * uShadowOpacity) * vColor; @@ -86,6 +96,8 @@ export default class DropShadowEffect extends ShaderEffect { ); this.setUniform("uShadowOpacity", options.opacity ?? 0.5); this.setUniform("uTextureSize", new Float32Array(texSize)); + // uv.y grows downward until a renderer path says otherwise + this.setUniform("uUVYDir", 1.0); } /** diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index 6ab773188d..da1a572af8 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -62,6 +62,21 @@ import WGSLEffectRealization from "./wgsl_realization.js"; * inside a varying branch must use * `textureSampleLevel(uTexture, uSampler, uv, 0.0)` (a WGSL * uniform-control-flow rule; identical output for sprite textures). + * + * ## Directional UV arithmetic (`uUVYDir`) + * + * The vertical orientation of `apply()`'s UV space depends on the draw + * path: sampling the sprite directly, `uv.y` grows downward, but the + * WebGL multi-effect (pooled) path composites through capture FBOs whose + * rows are bottom-up — there `uv.y` grows upward. A body that offsets its + * sampling coordinate vertically (a drop shadow, a directional smear) + * would render mirrored on that path. Declare a `float uUVYDir` uniform + * (WGSL: `uUVYDir : f32` in the uniform struct) and multiply vertical UV + * offsets by it: the renderer feeds `+1` where `uv.y` grows downward — + * including every WebGPU path — and `-1` on the WebGL pooled path, so + * "down" stays down everywhere. Initialize it to `1.0` with `setUniform`; + * bodies that don't declare it are unaffected. The built-in + * {@link DropShadowEffect} is the reference use. * @category Rendering * @example * // one effect, both backends: dual-language body @@ -389,6 +404,38 @@ export default class ShaderEffect { return this; } + /** + * Feed the UV y-direction of the space `apply()` is about to run in: + * `+1` where `uv.y` grows downward (direct sprite sampling, and every + * WebGPU path), `-1` on the WebGL pooled-blit path, whose capture FBOs + * are bottom-up — there, y-directional UV arithmetic inside a body + * runs vertically mirrored unless compensated. Directional bodies + * (DropShadow) declare a `uUVYDir` uniform and multiply their y + * offsets with it, so "down" means down on every path of every + * backend. A silent no-op for bodies that don't declare the uniform. + * @param {number} dir - +1 (uv.y grows downward) or -1 (upward) + * @ignore + */ + _setUVYDir(dir) { + if (this._uvYDir === dir || this.destroyed === true) { + return; + } + if ( + typeof this._shader !== "undefined" && + !this._shader.suspended && + typeof this._shader.uniforms.uUVYDir !== "undefined" + ) { + this._uvYDir = dir; + this._shader.setUniform("uUVYDir", dir); + } else if ( + typeof this.wgslRealization !== "undefined" && + this.wgslRealization.hasUniform("uUVYDir") + ) { + this._uvYDir = dir; + this.wgslRealization.setUniform("uUVYDir", dir); + } + } + /** * Feed the `noise_uv` builtin's frame rect for the object about to draw: * the source texture dimensions, the destination (object) dimensions, and diff --git a/packages/melonjs/src/video/effects/shine.js b/packages/melonjs/src/video/effects/shine.js index c75281d025..00171bca39 100644 --- a/packages/melonjs/src/video/effects/shine.js +++ b/packages/melonjs/src/video/effects/shine.js @@ -13,6 +13,7 @@ struct ShineUniforms { uPulseDepth : f32, uPulseSpeed : f32, uTime : f32, + uUVYDir : f32, }; @group(3) @binding(0) var fx : ShineUniforms; @@ -22,9 +23,13 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { } // Optional brightness pulse on the base color. let pulse = (1.0 - fx.uPulseDepth) + fx.uPulseDepth * sin(fx.uTime * fx.uPulseSpeed); + // Directional UV arithmetic — an ABSOLUTE coordinate, so a flipped + // space mirrors the position (1 - uv.y), not merely the sign (see the + // ShaderEffect uUVYDir docs; the renderer feeds the space's direction) + let y = select(uv.y, 1.0 - uv.y, fx.uUVYDir < 0.0); // Project uv along the sweep axis; tile by uShineBands (wrap-around // distance keeps sweeps gapless — see the GLSL twin's rationale). - let pos = uv.x * cos(fx.uShineAngle) + uv.y * sin(fx.uShineAngle); + let pos = uv.x * cos(fx.uShineAngle) + y * sin(fx.uShineAngle); let localX = fract(pos * fx.uShineBands); let sweep = fract(fx.uTime * fx.uShineSpeed); let d = abs(localX - sweep); @@ -97,12 +102,18 @@ export default class ShineEffect extends ShaderEffect { uniform float uPulseDepth; uniform float uPulseSpeed; uniform float uTime; + uniform float uUVYDir; vec4 apply(vec4 color, vec2 uv) { if (color.a == 0.0) return color; // Optional brightness pulse on the base color. float pulse = (1.0 - uPulseDepth) + uPulseDepth * sin(uTime * uPulseSpeed); + // Directional UV arithmetic — an ABSOLUTE coordinate, so a + // flipped space mirrors the position (1.0 - uv.y), not just + // the sign (see the ShaderEffect uUVYDir docs); without it, + // a vertical sweep runs backwards on bottom-up captures + float y = uUVYDir < 0.0 ? 1.0 - uv.y : uv.y; // Project uv along the sweep axis (uShineAngle). - float pos = uv.x * cos(uShineAngle) + uv.y * sin(uShineAngle); + float pos = uv.x * cos(uShineAngle) + y * sin(uShineAngle); // Tile by uShineBands so we get N parallel glints in unison. // localX and sweep both live in [0,1] tile-space; the // wrap-around distance (min(d, 1-d)) lets the glint exit @@ -139,6 +150,9 @@ export default class ShineEffect extends ShaderEffect { this.setUniform("uPulseDepth", options.pulseDepth ?? 0.0); this.setUniform("uPulseSpeed", options.pulseSpeed ?? 3.0); this.setUniform("uTime", 0.0); + // top-down default; the batchers feed the actual space direction + // per draw through _setUVYDir (bottom-up pooled captures on GL) + this.setUniform("uUVYDir", 1.0); } /** diff --git a/packages/melonjs/src/video/gpu/meshchunk.ts b/packages/melonjs/src/video/gpu/meshchunk.ts new file mode 100644 index 0000000000..bf3d9f47b0 --- /dev/null +++ b/packages/melonjs/src/video/gpu/meshchunk.ts @@ -0,0 +1,78 @@ +/** + * The versioned-remap vertex dedup shared by the mesh batchers of both + * backends — pure CPU code, no GPU references (the `lighting/` modules set + * the precedent for cross-backend sharing). + * + * The accumulated mesh path re-indexes each chunk's triangles against a + * chunk-local vertex range. Dedup uses a "versioned" typed-array remap + * rather than a `Map`: a `Map` here churned the GC badly, because V8's + * `Map.clear()` drops the backing table, so re-filling it each chunk + * reallocated as it grew — and the cost scaled with vertex count (a dense + * mesh = MBs/sec of garbage). Instead, `remapSlot[orig]` holds the local + * index assigned to original-vertex `orig` THIS chunk, valid only when + * `remapStamp[orig] === stamp`. Bumping `stamp` per chunk invalidates every + * entry in O(1) — no clearing, no allocation. The arrays grow lazily (to a + * power of two ≥ the largest mesh's vertex count) and are reused. + * + * Module-level sharing is safe because a chunk is built synchronously and + * never re-enters — only one `addMesh` runs at a time, whichever backend + * (or coexisting renderer) drives it, and no remap state persists across + * chunks. + * @ignore + */ + +let remapSlot = new Int32Array(0); +let remapStamp = new Int32Array(0); +let stamp = 0; +const chunkIndices: number[] = []; + +/** + * Ensure the versioned-remap scratch arrays can index every vertex of a + * mesh with `vertexCount` vertices. Grows to the next power of two and + * reuses thereafter (one-time cost when a larger mesh first appears). + * @ignore + */ +export function ensureRemapCapacity(vertexCount: number): void { + if (remapSlot.length >= vertexCount) { + return; + } + // next power of two ≥ vertexCount (Math.clz32 → leading-zero count) + const cap = vertexCount <= 1 ? 1 : 1 << (32 - Math.clz32(vertexCount - 1)); + remapSlot = new Int32Array(cap); + remapStamp = new Int32Array(cap); // zero-filled; stamp is always ≥ 1 in use +} + +/** + * Start a new chunk: invalidate the whole remap in O(1) and hand back the + * shared (emptied) chunk-index list. The stamp resets before int32 overflow + * (~weeks of continuous rendering away), keeping stored stamps valid. + * @returns the shared chunk-index array, emptied + * @ignore + */ +export function beginChunk(): number[] { + if (stamp >= 0x7fffffff) { + remapStamp.fill(0); + stamp = 0; + } + stamp++; + chunkIndices.length = 0; + return chunkIndices; +} + +/** + * The local index assigned to `origIdx` this chunk, or -1 when the vertex + * has not been emitted yet. + * @ignore + */ +export function remapIndex(origIdx: number): number { + return remapStamp[origIdx] === stamp ? remapSlot[origIdx] : -1; +} + +/** + * Record the local index assigned to `origIdx` for the rest of this chunk. + * @ignore + */ +export function assignIndex(origIdx: number, localIdx: number): void { + remapStamp[origIdx] = stamp; + remapSlot[origIdx] = localIdx; +} diff --git a/packages/melonjs/src/video/gpu/meshvertex.ts b/packages/melonjs/src/video/gpu/meshvertex.ts new file mode 100644 index 0000000000..78b81f0d4e --- /dev/null +++ b/packages/melonjs/src/video/gpu/meshvertex.ts @@ -0,0 +1,133 @@ +/** + * Retained mesh vertex-data assembly shared by the mesh batchers of both + * backends — pure CPU code, no GPU references (the `meshchunk.ts` / + * `lighting/` precedent). Kept in ONE place because the two layouts exist + * in an unlit and a lit variant on each backend, and the subtle parts — + * the ARGB unpack order and the lit path's zero-length-normal guard — + * must never drift between the four call sites. + * + * The data deliberately carries no placement, camera or tint information — + * those are uniforms — so it stays valid for the lifetime of the geometry. + * @ignore + */ + +/** the minimal mesh surface this module reads — `@ignore` */ +interface RetainedMeshSource { + originalVertices: Float32Array; + originalNormals?: Float32Array | null; + uvs: Float32Array; + vertexColors?: Uint32Array | null; + vertexCount: number; +} + +// Growable scratch for assembling a mesh's interleaved vertex data before +// it is uploaded to its retained buffers. Module-level sharing is safe: +// building is synchronous and never re-enters, whichever backend (or +// coexisting renderer) drives it, so a rebuild allocates nothing +// steady-state. +let buildScratch = new Float32Array(0); + +/** + * a reusable Float32Array of at least `floatCount` floats + * @ignore + */ +export function retainedScratch(floatCount: number): Float32Array { + if (buildScratch.length < floatCount) { + buildScratch = new Float32Array(floatCount); + } + return buildScratch; +} + +/** + * Write one mesh's model-space geometry into `out` in the unlit mesh + * layout — `x, y, z, u, v, r, g, b, a` — striding by `vertexSize`. + * @param mesh - the mesh to read geometry from + * @param out - destination scratch, at least `vertexCount × vertexSize` long + * @param vertexSize - floats per vertex in the hosting batcher's layout + * @returns number of floats written + * @ignore + */ +export function buildMeshVertexData( + mesh: RetainedMeshSource, + out: Float32Array, + vertexSize: number, +): number { + const vertices = mesh.originalVertices; + const uvs = mesh.uvs; + const colors = mesh.vertexColors; + const count = mesh.vertexCount; + let o = 0; + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const i2 = i * 2; + const c = colors ? colors[i] : 0xffffffff; + out[o] = vertices[i3]; + out[o + 1] = vertices[i3 + 1]; + out[o + 2] = vertices[i3 + 2]; + out[o + 3] = uvs[i2]; + out[o + 4] = uvs[i2 + 1]; + out[o + 5] = ((c >> 16) & 0xff) / 255; + out[o + 6] = ((c >> 8) & 0xff) / 255; + out[o + 7] = (c & 0xff) / 255; + out[o + 8] = ((c >>> 24) & 0xff) / 255; + o += vertexSize; + } + return o; +} + +/** + * The lit variant: the unlit layout plus the model-space normal — + * `…, nx, ny, nz` — so lighting can be evaluated after the shader rotates + * it into world space. + * + * A zero-length normal must not reach the shader: it does + * `normalize(vNormal)`, and normalizing a zero vector is NaN — every + * fragment touching that vertex goes black or garbage. Degenerate + * triangles in OBJ/glTF assets produce these, so substitute a unit + * vector, matching what the CPU projection path does. + * @param mesh - the mesh to read geometry from + * @param out - destination scratch, at least `vertexCount × vertexSize` long + * @param vertexSize - floats per vertex in the hosting batcher's layout + * @returns number of floats written + * @ignore + */ +export function buildLitMeshVertexData( + mesh: RetainedMeshSource, + out: Float32Array, + vertexSize: number, +): number { + const vertices = mesh.originalVertices; + const normals = mesh.originalNormals; + const uvs = mesh.uvs; + const colors = mesh.vertexColors; + const count = mesh.vertexCount; + let o = 0; + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const i2 = i * 2; + const c = colors ? colors[i] : 0xffffffff; + out[o] = vertices[i3]; + out[o + 1] = vertices[i3 + 1]; + out[o + 2] = vertices[i3 + 2]; + out[o + 3] = uvs[i2]; + out[o + 4] = uvs[i2 + 1]; + out[o + 5] = ((c >> 16) & 0xff) / 255; + out[o + 6] = ((c >> 8) & 0xff) / 255; + out[o + 7] = (c & 0xff) / 255; + out[o + 8] = ((c >>> 24) & 0xff) / 255; + const nx = normals ? normals[i3] : 0; + const ny = normals ? normals[i3 + 1] : 0; + const nz = normals ? normals[i3 + 2] : 0; + if (nx * nx + ny * ny + nz * nz > 1e-16) { + out[o + 9] = nx; + out[o + 10] = ny; + out[o + 11] = nz; + } else { + out[o + 9] = 0; + out[o + 10] = 1; + out[o + 11] = 0; + } + o += vertexSize; + } + return o; +} diff --git a/packages/melonjs/src/video/gpu/primitives.ts b/packages/melonjs/src/video/gpu/primitives.ts new file mode 100644 index 0000000000..7fa0493083 --- /dev/null +++ b/packages/melonjs/src/video/gpu/primitives.ts @@ -0,0 +1,163 @@ +/** + * Primitive-batcher geometry assembly shared by both backends — pure CPU + * code, no GPU references (the `meshchunk.ts` / `meshvertex.ts` + * precedent). The vertex layout is the primitive tier's 6-float + * `x, y, z, nx, ny, packed-color` stream on both backends. + * @ignore + */ + +/** the minimal vertex-buffer surface this module writes — `@ignore` */ +interface PrimitiveVertexData { + push( + x: number, + y: number, + z: number, + nx: number, + ny: number, + color: number, + ): void; + isFull(count: number): boolean; +} + +/** a 4×4 column-major transform (Matrix3d-shaped) — `@ignore` */ +interface MatrixLike { + val: Float32Array; + isIdentity(): boolean; +} + +interface PointLike { + x: number; + y: number; +} + +/** + * Push `verts[start..end)` into the vertex buffer, transformed by the + * given view matrix. The caller guarantees the range fits. + * + * The transform includes the z column (m[8] / m[9] / m[10] / m[14]) so + * Camera3d's view matrix (X/Y-axis rotation) actually rotates the + * primitive in 3D. For 2D matrices those slots are identity, so output + * (x, y, z) is bit-identical to the legacy 2D-only multiply. + * @ignore + */ +export function pushPrimitiveRange( + vertexData: PrimitiveVertexData, + viewMatrix: MatrixLike, + verts: PointLike[], + start: number, + end: number, + colorUint32: number, + z: number, +): void { + if (!viewMatrix.isIdentity()) { + const m = viewMatrix.val; + for (let i = start; i < end; i++) { + const vert = verts[i]; + const x = vert.x; + const y = vert.y; + vertexData.push( + x * m[0] + y * m[4] + z * m[8] + m[12], + x * m[1] + y * m[5] + z * m[9] + m[13], + x * m[2] + y * m[6] + z * m[10] + m[14], + 0, + 0, + colorUint32, + ); + } + } else { + for (let i = start; i < end; i++) { + const vert = verts[i]; + vertexData.push(vert.x, vert.y, z, 0, 0, colorUint32); + } + } +} + +/** + * Expand line pairs into triangles with perpendicular normals. The vertex + * shader offsets each vertex by `aNormal * uLineWidth * 0.5`, producing + * thick lines without manual geometry expansion in the renderer. The + * caller has already switched its draw mode to triangles. + * + * Each line pair expands to 2 triangles (6 vertices) — capacity is + * checked per pair through the `flush` callback, so a dashed/long + * thick-line path larger than the whole buffer flushes mid-shape instead + * of silently dropping the out-of-range writes (pairs are independent + * quads, so a mid-shape flush is invisible). + * + * The view matrix is applied (z column included) without mutating the + * inputs. Note: the perpendicular normal is computed in pre-projection + * world space, which appears non-perpendicular under perspective — a + * known limitation shared by both backends. + * @param vertexData - destination vertex stream + * @param viewMatrix - the current view matrix + * @param verts - line vertices in pairs [from, to, from, to, ...] + * @param vertexCount - number of vertices to consume + * @param colorUint32 - packed line color + * @param z - the current renderer depth + * @param flush - drains the vertex buffer when a pair no longer fits + * @ignore + */ +export function expandLinesToTriangles( + vertexData: PrimitiveVertexData, + viewMatrix: MatrixLike, + verts: PointLike[], + vertexCount: number, + colorUint32: number, + z: number, + flush: () => void, +): void { + const hasTransform = !viewMatrix.isIdentity(); + const m = hasTransform ? viewMatrix.val : null; + + for (let i = 0; i < vertexCount; i += 2) { + const from = verts[i]; + const to = verts[i + 1]; + + if (vertexData.isFull(6)) { + flush(); + } + + let fromX: number; + let fromY: number; + let fromZ: number; + let toX: number; + let toY: number; + let toZ: number; + if (m !== null) { + fromX = from.x * m[0] + from.y * m[4] + z * m[8] + m[12]; + fromY = from.x * m[1] + from.y * m[5] + z * m[9] + m[13]; + fromZ = from.x * m[2] + from.y * m[6] + z * m[10] + m[14]; + toX = to.x * m[0] + to.y * m[4] + z * m[8] + m[12]; + toY = to.x * m[1] + to.y * m[5] + z * m[9] + m[13]; + toZ = to.x * m[2] + to.y * m[6] + z * m[10] + m[14]; + } else { + fromX = from.x; + fromY = from.y; + fromZ = z; + toX = to.x; + toY = to.y; + toZ = z; + } + + // compute perpendicular unit normal + const dx = toX - fromX; + const dy = toY - fromY; + const len = Math.sqrt(dx * dx + dy * dy); + + if (len === 0) { + continue; + } + + const nx = -dy / len; + const ny = dx / len; + + // two triangles forming a quad around the line segment + vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32); + vertexData.push(fromX, fromY, fromZ, -nx, -ny, colorUint32); + vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32); + + vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32); + vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32); + vertexData.push(toX, toY, toZ, nx, ny, colorUint32); + } +} diff --git a/packages/melonjs/src/video/gpu/quadcorners.ts b/packages/melonjs/src/video/gpu/quadcorners.ts new file mode 100644 index 0000000000..e9b406e038 --- /dev/null +++ b/packages/melonjs/src/video/gpu/quadcorners.ts @@ -0,0 +1,53 @@ +import type { Matrix3d } from "../../math/matrix3d.ts"; +import { Vector3d } from "../../math/vector3d.ts"; + +/** + * The quad corner transform shared by the quad batchers of both backends + * (and their lit variants) — pure CPU code, no GPU references. + * + * A reusable pool of four `Vector3d` corners: the per-sprite depth rides + * `z` so `Matrix3d.apply` fully rotates the vertex under a `Camera3d` + * view matrix, while 2D-only matrices leave (x, y) bit-identical to the + * legacy `Vector2d` path with z passing through unchanged. Callers must + * always pass `z` explicitly — the vectors are shared, so leftover depth + * from a previous quad would otherwise leak into a z = 0 blit. + * + * Module-level sharing is safe: a quad is assembled synchronously and the + * corners are consumed before the next call, whichever backend (or + * coexisting renderer) drives it. + * @ignore + */ +const corners = [ + new Vector3d(), + new Vector3d(), + new Vector3d(), + new Vector3d(), +]; + +/** + * Set the four corners of an axis-aligned quad and transform them by `m` + * (skipped when `m` is null/identity — the common untransformed case). + * Returns the shared corner pool, valid until the next call: + * `[topLeft, topRight, bottomLeft, bottomRight]`. + * @ignore + */ +export function transformQuadCorners( + m: Matrix3d | null | undefined, + x: number, + y: number, + w: number, + h: number, + z: number, +): Vector3d[] { + corners[0].set(x, y, z); + corners[1].set(x + w, y, z); + corners[2].set(x, y + h, z); + corners[3].set(x + w, y + h, z); + if (m && !m.isIdentity()) { + m.apply(corners[0]); + m.apply(corners[1]); + m.apply(corners[2]); + m.apply(corners[3]); + } + return corners; +} diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 750f074828..9bae192636 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -104,8 +104,14 @@ export default class Renderer { this.path2D = new Path2D(); /** - * The renderer type : Canvas, WebGL, etc... - * (override this property with a specific value when implementing a custom renderer) + * The renderer backend identity — the built-in renderers report + * `"CANVAS"`, `"WebGL2"` and `"WebGPU"`. Use it for identity + * checks (code coupled to one backend's machinery); prefer the + * capability flags (`shaderLanguage`, `supportsDepthBuffer`, + * `supportsRetainedMesh`, `supportsShaderTileLayers`) when the + * requirement is a capability rather than a specific backend. + * (override this property with a specific value when implementing + * a custom renderer) * @type {string} */ this.type = "Generic"; @@ -141,8 +147,8 @@ export default class Renderer { /** * The source language this backend accepts for user-supplied shaders, * or `null` when it has no programmable pipeline at all (the Canvas - * backend). `"glsl"` on the WebGL backend; a future WebGPU backend - * reports `"wgsl"`. + * backend). `"glsl"` on the WebGL backend, `"wgsl"` on the WebGPU + * backend. * * Consumers that need a *specific* language — `ShaderEffect` and the * loader's `{vertex, fragment}` shader assets both hand GLSL source @@ -246,8 +252,8 @@ export default class Renderer { } /** - * Current per-renderable depth value. GPU batchers (WebGL today, - * WebGPU once it lands) push it into the vertex stream as the `z` + * Current per-renderable depth value. The GPU batchers (WebGL and + * WebGPU) push it into the vertex stream as the `z` * component of each vertex — a no-op under the default orthographic * projection, used by perspective (Camera3d) to * scale and parallax sprites by distance. Mirrors `renderable.depth`, @@ -316,11 +322,20 @@ export default class Renderer { * `uvs` (Float32Array, u/v pairs), `indices` (Uint16Array, triangle indices), * `texture` (TextureAtlas), `vertexCount` (number), and optionally * `cullBackFaces` (boolean, default true). - * WebGL uses hardware depth testing; Canvas uses painter's algorithm (back-to-front sort). + * + * On the GPU backends (WebGL and WebGPU — hardware depth testing), + * passing a `modelMatrix` selects the **retained** path: the mesh's + * model-space geometry stays resident on the GPU and the matrix + * places it, so redrawing never re-uploads vertices (see + * {@link Renderer#supportsRetainedMesh}). Without a matrix the + * vertices are taken as already CPU-projected (the 2D-camera path — + * the only path the Canvas renderer supports, using painter's + * algorithm). `Mesh.draw` selects the right form automatically. * @param {Mesh} mesh - a Mesh renderable or compatible object + * @param {Matrix3d} [modelMatrix] - the mesh's placement, for the retained path (GPU backends) */ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - drawMesh(mesh) {} + drawMesh(mesh, modelMatrix) {} /** * Reset context state diff --git a/packages/melonjs/src/video/utils/autodetect.js b/packages/melonjs/src/video/utils/autodetect.js index 1d4e670f9b..e4f45d592e 100644 --- a/packages/melonjs/src/video/utils/autodetect.js +++ b/packages/melonjs/src/video/utils/autodetect.js @@ -2,19 +2,14 @@ import { isWebGLSupported } from "../../system/device"; import CanvasRenderer from "../canvas/canvas_renderer"; import WebGLRenderer from "../webgl/webgl_renderer"; -// Ordered backend candidates, walked front-to-back: the first one whose -// support probe passes and whose construction succeeds wins. Kept as a -// list (rather than an if-chain) so a future backend prepends a single -// entry without reshaping the negotiation. +// Ordered SYNCHRONOUS backend candidates, walked front-to-back: the first +// one whose support probe passes and whose construction succeeds wins. // -// WebGPU is deliberately NOT a candidate here, and should not be added -// until it reaches feature parity with the WebGL backend. `AUTO` is what -// existing games use, so listing WebGPU would migrate them onto a partial -// backend the day their browser gained support — silently, and without -// anyone asking. It stays opt-in through `renderer: video.WEBGPU` (or the -// `#webgpu` URI fragment), which is also what makes testing it meaningful: -// an explicit request either runs on WebGPU or fails, and never quietly -// falls back to something else. +// WebGPU is not in this list even though `AUTO` now prefers it — its +// support can only be proven by negotiating an adapter/device, which is +// asynchronous. `Application.init()`'s AUTO case awaits that attempt first +// and only falls through to these candidates when it rejects, keeping this +// module the synchronous tail of the ladder. const BACKEND_CANDIDATES = [ { name: "webgl2", diff --git a/packages/melonjs/src/video/webgl/batchers/batcher.js b/packages/melonjs/src/video/webgl/batchers/batcher.js index 29baac5989..76f0ba12ae 100644 --- a/packages/melonjs/src/video/webgl/batchers/batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/batcher.js @@ -374,6 +374,33 @@ export class WebGLBatcher extends Batcher { } } + /** + * Ensure the draw about to be recorded runs on THIS batcher's program. + * Normally `bind()` guaranteed that, but an `ONCONTEXT_RESTORED` + * emission recompiles every live GLShader — each replaying its uniform + * snapshot on its OWN program — leaving whichever recompiled last + * really bound while the renderer's cache still named another. The + * renderer invalidates its program cache on that event, so this + * pointer compare re-issues `useProgram` exactly when the drift + * happened and costs two loads otherwise. Called by every flush path + * (the base and the quad override). + * @ignore + */ + syncProgram() { + const shader = this.currentShader; + if ( + typeof shader !== "undefined" && + this.renderer.currentProgram !== shader.program + ) { + // the RAW rebind only — never useShader() from inside a flush + // (it drains pending vertices itself: recursion). Attribute + // state lives in the batcher's vertex state and uniforms are + // per-program, so useProgram is the whole fix. + shader.bind(); + this.renderer.currentProgram = shader.program; + } + } + /** * Select the shader to use for compositing * @see GLShader @@ -693,6 +720,8 @@ export class WebGLBatcher extends Batcher { const gl = this.gl; const vertexSize = vertex.vertexSize; + this.syncProgram(); + // Upload byte length covers exactly the vertices we've pushed. // Use the Uint8 view (NOT Float32) to keep packed-color bytes // intact — see `VertexArrayBuffer.bufferU8` for why this matters 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 b822904928..1e5ed84863 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_mesh_batcher.js @@ -1,8 +1,9 @@ import state from "../../../state/state.ts"; +import { buildLitMeshVertexData } from "../../gpu/meshvertex.ts"; import UniformBlock from "../buffer/uniformblock.js"; import { MAX_LIGHTS } from "../lighting/constants.ts"; import { packMeshLights } from "../lighting/pack3d.ts"; -import { BLOCK_FLOATS, writeLight3dBlock } from "../lighting/std140.ts"; +import { BLOCK3D_FLOATS, writeLight3dBlock } from "../lighting/std140.ts"; import litFragment from "./../shaders/mesh-lit.frag"; import litVertex from "./../shaders/mesh-lit.vert"; import MeshBatcher from "./mesh_batcher.js"; @@ -58,7 +59,7 @@ export default class LitMeshBatcher extends MeshBatcher { this._bindingPoint ??= renderer.reserveUniformBindingPoint(); this.lightBlock = new UniformBlock( renderer.gl, - BLOCK_FLOATS, + BLOCK3D_FLOATS, this._bindingPoint, ); this._lightBlockProgram = null; @@ -141,45 +142,9 @@ export default class LitMeshBatcher extends MeshBatcher { * @ignore */ buildRetainedVertexData(mesh, out) { - const vertices = mesh.originalVertices; - const normals = mesh.originalNormals; - const uvs = mesh.uvs; - const colors = mesh.vertexColors; - const count = mesh.vertexCount; - let o = 0; - for (let i = 0; i < count; i++) { - const i3 = i * 3; - const i2 = i * 2; - const c = colors ? colors[i] : 0xffffffff; - out[o] = vertices[i3]; - out[o + 1] = vertices[i3 + 1]; - out[o + 2] = vertices[i3 + 2]; - out[o + 3] = uvs[i2]; - out[o + 4] = uvs[i2 + 1]; - out[o + 5] = ((c >> 16) & 0xff) / 255; - out[o + 6] = ((c >> 8) & 0xff) / 255; - out[o + 7] = (c & 0xff) / 255; - out[o + 8] = ((c >>> 24) & 0xff) / 255; - // A zero-length normal must not reach the shader: it does - // `normalize(vNormal)`, and normalize(vec3(0)) is NaN — every - // fragment touching that vertex goes black or garbage. Degenerate - // triangles in OBJ/glTF assets produce these, so substitute a unit - // vector, matching what `_projectNormalsWorld` does on the CPU path. - const nx = normals ? normals[i3] : 0; - const ny = normals ? normals[i3 + 1] : 0; - const nz = normals ? normals[i3 + 2] : 0; - if (nx * nx + ny * ny + nz * nz > 1e-16) { - out[o + 9] = nx; - out[o + 10] = ny; - out[o + 11] = nz; - } else { - out[o + 9] = 0; - out[o + 10] = 1; - out[o + 11] = 0; - } - o += this.vertexSize; - } - return o; + // the shared neutral builder (zero-normal guard included) — one + // copy for both backends, see `gpu/meshvertex.ts` + return buildLitMeshVertexData(mesh, out, this.vertexSize); } /** @@ -219,8 +184,9 @@ export default class LitMeshBatcher extends MeshBatcher { this.lightBlock.upload( writeLight3dBlock(this.lightBlock.data, { count: lit.count, - directions: lit.directions, - colors: lit.colors, + posRange: lit.posRange, + dirCone: lit.dirCone, + colorInner: lit.colorInner, ambient: hasLight ? a : _WHITE_AMBIENT, }), ); diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index d6d07af586..e4282feedc 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -1,4 +1,5 @@ import { off, on, TEXTURE2D_DESTROYED } from "../../../system/event.ts"; +import { transformQuadCorners } from "../../gpu/quadcorners.ts"; import UniformBlock from "../buffer/uniformblock.js"; import { MAX_LIGHTS } from "../lighting/constants.ts"; import { @@ -8,7 +9,7 @@ import { } from "../lighting/std140.ts"; import { buildLitMultiTextureFragment } from "./../shaders/multitexture-lit.js"; import quadMultiLitVertex from "./../shaders/quad-multi-lit.vert"; -import QuadBatcher, { V_ARRAY } from "./quad_batcher.js"; +import QuadBatcher from "./quad_batcher.js"; /** * additional import for TypeScript @@ -489,6 +490,9 @@ export default class LitQuadBatcher extends QuadBatcher { Math.min(v0, v1), ); } + // direct atlas sampling: uv.y grows downward — see + // QuadBatcher.addQuad + this.currentShader._setUVYDir?.(1); } let normalTextureId = -1; @@ -516,22 +520,16 @@ export default class LitQuadBatcher extends QuadBatcher { normalTextureId = unit; } - // Stamp per-sprite depth onto z BEFORE `m.apply` — see - // `QuadBatcher.addQuad` for the full rationale. V_ARRAY is the - // shared Vector3d pool from `QuadBatcher`. - const m = this.viewMatrix; - const z = this.renderer.currentDepth; - const vec0 = V_ARRAY[0].set(x, y, z); - const vec1 = V_ARRAY[1].set(x + w, y, z); - const vec2 = V_ARRAY[2].set(x, y + h, z); - const vec3 = V_ARRAY[3].set(x + w, y + h, z); - - if (!m.isIdentity()) { - m.apply(vec0); - m.apply(vec1); - m.apply(vec2); - m.apply(vec3); - } + // Stamp per-sprite depth onto z BEFORE the transform — see + // `QuadBatcher.addQuad` for the full rationale (shared corner pool) + const [vec0, vec1, vec2, vec3] = transformQuadCorners( + this.viewMatrix, + x, + y, + w, + h, + this.renderer.currentDepth, + ); const textureId = this.useMultiTexture ? unit : 0; vertexData.push( @@ -602,22 +600,23 @@ export default class LitQuadBatcher extends QuadBatcher { // `noise_uv` builtin: a blit is a full-frame quad — identity rect shader._setNoiseUVRect?.(width, height, width, height, 0, 0); + // bottom-up capture FBO: uv.y grows upward inside apply() — see + // QuadBatcher.blitTexture + shader._setUVYDir?.(-1); + // transform corners through the renderer transform — see // `QuadBatcher.blitTexture` for the rationale. Only caller today // is `WebGLRenderer.blitEffect`, which resets `currentTransform` // to identity, so the matrix branch is dormant in practice. - // Explicit z = 0 because V_ARRAY is Vector3d (shared with addQuad). - const m = this.viewMatrix; - const vec0 = V_ARRAY[0].set(x, y, 0); - const vec1 = V_ARRAY[1].set(x + width, y, 0); - const vec2 = V_ARRAY[2].set(x, y + height, 0); - const vec3 = V_ARRAY[3].set(x + width, y + height, 0); - if (m && !m.isIdentity()) { - m.apply(vec0); - m.apply(vec1); - m.apply(vec2); - m.apply(vec3); - } + // Explicit z = 0: the shared corner pool is Vector3d. + const [vec0, vec1, vec2, vec3] = transformQuadCorners( + this.viewMatrix, + x, + y, + width, + height, + 0, + ); // blits are always rendered at z = 0 (screen-space, ortho) const tint = 0xffffffff; diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index e470546b97..4aa2d2f32a 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -202,6 +202,14 @@ export class MaterialBatcher extends WebGLBatcher { mipmaps[i].data, ); } + // Cap the sampled chain at what the asset actually carries: + // compressed sources are excluded from generateMipmap, so under + // ES3 completeness rules a mipmap min filter (the mesh path's + // trilinear upgrade) over a chain that stops short of 1×1 is + // MIPMAP-INCOMPLETE and samples opaque black. With the cap, a + // single-level DDS/PKM stays complete at level 0 and an + // authored multi-level chain becomes legally trilinear. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL, mipmaps.length - 1); } else if (pixels === null) { // allocation without data — srcOffset overload requires a view gl.texImage2D( @@ -409,10 +417,20 @@ export class MaterialBatcher extends WebGLBatcher { // `textureFilter`), otherwise fall back to the renderer-wide default // (the `textureFilter` setting, decoupled from MSAA — see // WebGLRenderer#getDefaultTextureFilter) - const filter = + let filter = typeof texture.filter !== "undefined" ? texture.filter : this.renderer._glTextureFilter(); + // the STRING form ("nearest"/"linear") is what non-GL renderers + // store (the WebGPU texture store consumes it directly) — an + // atlas that met one of those first must still upload correctly + // here, so map it to the GL enum instead of feeding texParameteri + // a string + if (filter === "nearest") { + filter = this.gl.NEAREST; + } else if (filter === "linear") { + filter = this.gl.LINEAR; + } // `w`/`h` historically came from callers (e.g. `addQuad`) that // passed the DESTINATION quad size, not the texture size. That // broke the downstream POT check — a 480×1216 atlas drawn into @@ -429,9 +447,17 @@ export class MaterialBatcher extends WebGLBatcher { // w/h for sources that have neither. const texW = source.width || source.videoWidth || w; const texH = source.height || source.videoHeight || h; + // a video with no decoded frame yet (readyState < HAVE_CURRENT_DATA) + // has nothing to upload — texImage2D on it is browser-dependent + // (an exception on some engines, an empty upload plus a GL error + // on others). Allocate a blank texture instead and skip the copy; + // the video path force-re-uploads every frame, so content lands + // the moment a frame exists — same contract as the WebGPU store. + const frameless = + typeof source.videoWidth !== "undefined" && source.readyState < 2; this.createTexture2D( unit, - source, + frameless ? null : source, filter, wrap, texW, diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 73c91a6539..ec5b6a5fce 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -1,5 +1,12 @@ import { Matrix3d } from "../../../math/matrix3d.ts"; import { off, on, RENDER_TARGET_CHANGED } from "../../../system/event.ts"; +import { + assignIndex, + beginChunk, + ensureRemapCapacity, + remapIndex, +} from "../../gpu/meshchunk.ts"; +import { buildMeshVertexData, retainedScratch } from "../../gpu/meshvertex.ts"; import RetainedGeometry from "../buffer/retained_geometry.js"; import meshFragment from "./../shaders/mesh.frag"; import meshVertex from "./../shaders/mesh.vert"; @@ -13,51 +20,10 @@ const _IDENTITY_MATRIX = new Matrix3d(); // setPlacementUniforms runs synchronously and never re-enters. const _TINT_RGBA = new Float32Array(4); -// Growable scratch for assembling a mesh's interleaved vertex data before it -// is uploaded to its retained buffer. Reused across meshes (building is -// synchronous and never re-enters) so a rebuild allocates nothing steady-state. -let _buildScratch = new Float32Array(0); -function retainedScratch(floatCount) { - if (_buildScratch.length < floatCount) { - _buildScratch = new Float32Array(floatCount); - } - return _buildScratch; -} - -// Reused scratch for addMesh's per-chunk vertex dedup and absolute index list -// (`_chunkIndices`), so a chunk allocates nothing per mesh per frame (GC -// pressure on the draw path). Safe because addMesh runs synchronously and never -// re-enters (flush() only draws). Shared by MeshBatcher and LitMeshBatcher — -// only one addMesh runs at a time. -// -// Dedup uses a "versioned" typed-array remap rather than a `Map`: a `Map` here -// churned the GC badly, because V8's `Map.clear()` drops the backing table, so -// re-filling it each chunk reallocated as it grew — and the cost scaled with -// vertex count (a dense mesh = MBs/sec of garbage). Instead, `_remapSlot[orig]` -// holds the local index assigned to original-vertex `orig` THIS chunk, valid -// only when `_remapStamp[orig] === _stamp`. Bumping `_stamp` per chunk -// invalidates every entry in O(1) — no clearing, no allocation. The arrays grow -// lazily (to a power of two ≥ the largest mesh's vertex count) and are reused. -let _remapSlot = new Int32Array(0); -let _remapStamp = new Int32Array(0); -let _stamp = 0; -const _chunkIndices = []; - -/** - * Ensure the versioned-remap scratch arrays can index every vertex of a mesh - * with `vertexCount` vertices. Grows to the next power of two and reuses - * thereafter (one-time cost when a larger mesh first appears). - * @ignore - */ -function ensureRemapCapacity(vertexCount) { - if (_remapSlot.length >= vertexCount) { - return; - } - // next power of two ≥ vertexCount (Math.clz32 → leading-zero count) - const cap = vertexCount <= 1 ? 1 : 1 << (32 - Math.clz32(vertexCount - 1)); - _remapSlot = new Int32Array(cap); - _remapStamp = new Int32Array(cap); // zero-filled; _stamp is always ≥ 1 in use -} +// The per-chunk vertex dedup (versioned typed-array remap) lives in the +// backend-neutral `gpu/meshchunk.ts`, shared with the WebGPU mesh batcher — +// see the rationale there. Safe because addMesh runs synchronously and never +// re-enters (flush() only draws); only one addMesh runs at a time. // The lazy-depth-clear state for the mesh-mode pass lives on the RENDERER // (`renderer._meshDepthDirty`), not per batcher instance: the unlit @@ -143,6 +109,23 @@ export default class MeshBatcher extends MaterialBatcher { // tint and silently suppress the very first set. this.currentTintValue = undefined; + // GL textures already upgraded to trilinear minification (mesh + // textures sample their mip chain — see applyMeshMaterial). WeakSet: + // entries die with their GL texture objects. + this.trilinearTextures = new WeakSet(); + + // 4× anisotropic filtering rides the same upgrade (oblique surfaces + // keep detail plain trilinear blurs away). Resolved per init — a + // context restore re-runs this against the fresh context. + const gl = this.gl; + this.anisotropicExt = gl.getExtension("EXT_texture_filter_anisotropic"); + this.maxAnisotropy = this.anisotropicExt + ? Math.min( + 4, + gl.getParameter(this.anisotropicExt.MAX_TEXTURE_MAX_ANISOTROPY_EXT), + ) + : 0; + // arm the (renderer-owned) lazy depth clear for the first mesh pass renderer._meshDepthDirty = true; @@ -395,27 +378,8 @@ export default class MeshBatcher extends MaterialBatcher { * @ignore */ buildRetainedVertexData(mesh, out) { - const vertices = mesh.originalVertices; - const uvs = mesh.uvs; - const colors = mesh.vertexColors; - const count = mesh.vertexCount; - let o = 0; - for (let i = 0; i < count; i++) { - const i3 = i * 3; - const i2 = i * 2; - const c = colors ? colors[i] : 0xffffffff; - out[o] = vertices[i3]; - out[o + 1] = vertices[i3 + 1]; - out[o + 2] = vertices[i3 + 2]; - out[o + 3] = uvs[i2]; - out[o + 4] = uvs[i2 + 1]; - out[o + 5] = ((c >> 16) & 0xff) / 255; - out[o + 6] = ((c >> 8) & 0xff) / 255; - out[o + 7] = (c & 0xff) / 255; - out[o + 8] = ((c >>> 24) & 0xff) / 255; - o += this.vertexSize; - } - return o; + // the shared neutral builder — one copy for both backends + return buildMeshVertexData(mesh, out, this.vertexSize); } /** @@ -580,11 +544,72 @@ export default class MeshBatcher extends MaterialBatcher { true, mesh.textureRepeat, ); - if (unit !== this.currentSamplerUnit) { + // guarded like every other per-mesh uniform below: a custom mesh + // shader that never samples the texture (vertex colors only) does + // not declare `uSampler`, and setUniform throws on unknown names + if ( + unit !== this.currentSamplerUnit && + this.currentShader.uniforms?.uSampler !== undefined + ) { this.currentShader.setUniform("uSampler", unit); this.currentSamplerUnit = unit; } + // Mesh textures sample their mip chain: `createTexture2D` already + // runs `generateMipmap` for every plain image upload, but the min + // filter stays LINEAR so the chain went unused — upgrade to + // trilinear once per GL texture. `textureFilter: "nearest"` opts + // out (crisp pixel-art models keep hard minification), and a sprite + // sharing the exact (source, wrap) unit sees the same + // last-writer-wins caveat as the `textureFilter` setting. + const gl = this.gl; + const glFilter = + typeof mesh.texture.filter !== "undefined" + ? mesh.texture.filter + : this.renderer._glTextureFilter(); + // the filter can be a GL enum (this backend's Mesh) or the string + // form (an atlas first configured under a non-GL renderer) + if (glFilter === gl.LINEAR || glFilter === "linear") { + const glTexture = this.boundTextures[unit]; + const source = + typeof mesh.texture.getTexture === "function" + ? mesh.texture.getTexture() + : null; + // TextureResource-backed sources own their upload and carry no + // generated chain — a mipmap min filter over their single level + // is mipmap-incomplete under ES3 (samples opaque black), so they + // stay on plain LINEAR. Videos re-upload every frame, which + // resets MIN_FILTER back to LINEAR — re-apply the upgrade per + // draw for them instead of trusting the once-per-texture set. + const resourceOwned = + source !== null && typeof source.upload === "function"; + const isVideo = + source !== null && typeof source.videoWidth !== "undefined"; + if ( + typeof glTexture !== "undefined" && + !resourceOwned && + (isVideo || !this.trilinearTextures.has(glTexture)) + ) { + // uploads/bind tracking can skip real GL calls — force the + // binding so the parameter lands on the right texture + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, glTexture); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + if (this.maxAnisotropy > 1) { + gl.texParameterf( + gl.TEXTURE_2D, + this.anisotropicExt.TEXTURE_MAX_ANISOTROPY_EXT, + this.maxAnisotropy, + ); + } + this.trilinearTextures.add(glTexture); + } + } + // alpha cutout (glTF alphaMode MASK): discard fragments whose final alpha // is below the mesh's threshold (0 = disabled). The built-in mesh shaders // declare `uAlphaCutoff`; a custom shader without it is left untouched. @@ -663,29 +688,19 @@ export default class MeshBatcher extends MaterialBatcher { const endIdx = Math.min(triIdx + maxTris * 3, indices.length); - // build a local vertex remap for this chunk (reused scratch). - // capture base offset before pushing any vertices. Bump the stamp to - // invalidate the whole remap in O(1) (resetting before int32 overflow, - // ~weeks of continuous rendering away, keeps the stored stamps valid). + // build a local vertex remap for this chunk (shared reused + // scratch — see gpu/meshchunk.ts). Capture the base offset + // before pushing any vertices. const baseOffset = vertexData.vertexCount; - if (_stamp >= 0x7fffffff) { - _remapStamp.fill(0); - _stamp = 0; - } - _stamp++; - _chunkIndices.length = 0; + const chunkIndices = beginChunk(); let localCount = 0; for (let j = triIdx; j < endIdx; j++) { const origIdx = indices[j]; - let localIdx; - if (_remapStamp[origIdx] === _stamp) { - // already emitted this chunk — reuse its local index - localIdx = _remapSlot[origIdx]; - } else { + let localIdx = remapIndex(origIdx); + if (localIdx === -1) { localIdx = localCount++; - _remapStamp[origIdx] = _stamp; - _remapSlot[origIdx] = localIdx; + assignIndex(origIdx, localIdx); const i3 = origIdx * 3; const i2 = origIdx * 2; @@ -713,12 +728,12 @@ export default class MeshBatcher extends MaterialBatcher { ); } // absolute index = baseOffset + localIdx - _chunkIndices.push(baseOffset + localIdx); + chunkIndices.push(baseOffset + localIdx); } // add raw indices (already absolute, bypass rebasing) — addRaw - // copies the values, so reusing `_chunkIndices` next chunk is safe - this.indexBuffer.addRaw(_chunkIndices); + // copies the values, so reusing the shared chunk list is safe + this.indexBuffer.addRaw(chunkIndices); triIdx = endIdx; } } diff --git a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js index 46755082e7..213a125e5c 100644 --- a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js @@ -1,3 +1,7 @@ +import { + expandLinesToTriangles, + pushPrimitiveRange, +} from "../../gpu/primitives.ts"; import primitiveFragment from "./../shaders/primitive.frag"; import primitiveVertex from "./../shaders/primitive.vert"; import { resolveTopology } from "../utils/topology.js"; @@ -146,34 +150,17 @@ export default class PrimitiveBatcher extends WebGLBatcher { * @ignore */ #pushRange(verts, start, end, colorUint32, z) { - const viewMatrix = this.viewMatrix; - const vertexData = this.vertexData; - if (!viewMatrix.isIdentity()) { - // Full 3D transform including the z column (m[8] / m[9] / - // m[10] / m[14]) so Camera3d's view matrix (X/Y-axis - // rotation) actually rotates the primitive in 3D. For 2D - // matrices those slots are identity, so output (x, y, z) - // is bit-identical to the legacy 2D-only multiply. - const m = viewMatrix.val; - for (let i = start; i < end; i++) { - const vert = verts[i]; - const x = vert.x; - const y = vert.y; - vertexData.push( - x * m[0] + y * m[4] + z * m[8] + m[12], - x * m[1] + y * m[5] + z * m[9] + m[13], - x * m[2] + y * m[6] + z * m[10] + m[14], - 0, - 0, - colorUint32, - ); - } - } else { - for (let i = start; i < end; i++) { - const vert = verts[i]; - vertexData.push(vert.x, vert.y, z, 0, 0, colorUint32); - } - } + // the shared neutral range push (z-column-aware transform) — one + // copy for both backends, see `gpu/primitives.ts` + pushPrimitiveRange( + this.vertexData, + this.viewMatrix, + verts, + start, + end, + colorUint32, + z, + ); } /** @@ -261,82 +248,24 @@ export default class PrimitiveBatcher extends WebGLBatcher { * @ignore */ #expandLinesToTriangles(verts, vertexCount) { - const viewMatrix = this.viewMatrix; - const vertexData = this.vertexData; - const alpha = this.renderer.getGlobalAlpha(); - const colorUint32 = this.renderer.currentColor.toUint32(alpha); - const hasTransform = !viewMatrix.isIdentity(); - // z = current renderer depth (Renderable.preDraw); a no-op under ortho, - // consumed by perspective (Camera3d). - const z = this.renderer.currentDepth; - - // switch to TRIANGLES mode + // switch to TRIANGLES mode, then delegate the expansion to the + // shared neutral helper — see `gpu/primitives.ts` if (this.mode !== this.gl.TRIANGLES) { this.flush(this.mode); this.mode = this.gl.TRIANGLES; } - - const m = hasTransform ? viewMatrix.val : null; - - for (let i = 0; i < vertexCount; i += 2) { - const from = verts[i]; - const to = verts[i + 1]; - - // each line pair expands to 2 triangles (6 vertices) — check - // capacity per pair, so a dashed/long thick-line path larger than - // the whole buffer flushes mid-shape instead of silently dropping - // the out-of-range writes (pairs are independent quads, so a - // mid-shape flush is invisible) - if (vertexData.isFull(6)) { + expandLinesToTriangles( + this.vertexData, + this.viewMatrix, + verts, + vertexCount, + this.renderer.currentColor.toUint32(this.renderer.getGlobalAlpha()), + // z = current renderer depth (Renderable.preDraw); a no-op under + // ortho, consumed by perspective (Camera3d) + this.renderer.currentDepth, + () => { this.flush(); - } - - // apply view matrix to base positions without mutating - // inputs. Includes the z column for parity with the simple- - // line path and Vector3d quad batcher — Camera3d's view - // matrix needs depth-aware rotation. Note: the perpendicular - // normal is still computed in pre-projection world space, - // which appears non-perpendicular under perspective — known - // limitation, separate from the Vector3d migration. - let fromX, fromY, fromZ, toX, toY, toZ; - if (hasTransform) { - fromX = from.x * m[0] + from.y * m[4] + z * m[8] + m[12]; - fromY = from.x * m[1] + from.y * m[5] + z * m[9] + m[13]; - fromZ = from.x * m[2] + from.y * m[6] + z * m[10] + m[14]; - toX = to.x * m[0] + to.y * m[4] + z * m[8] + m[12]; - toY = to.x * m[1] + to.y * m[5] + z * m[9] + m[13]; - toZ = to.x * m[2] + to.y * m[6] + z * m[10] + m[14]; - } else { - fromX = from.x; - fromY = from.y; - fromZ = z; - toX = to.x; - toY = to.y; - toZ = z; - } - - // compute perpendicular unit normal - const dx = toX - fromX; - const dy = toY - fromY; - const len = Math.sqrt(dx * dx + dy * dy); - - if (len === 0) { - continue; - } - - const nx = -dy / len; - const ny = dx / len; - - // two triangles forming a quad around the line segment - // triangle 1: from+n, from-n, to-n - vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32); - vertexData.push(fromX, fromY, fromZ, -nx, -ny, colorUint32); - vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32); - - // triangle 2: from+n, to-n, to+n - vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32); - vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32); - vertexData.push(toX, toY, toZ, nx, ny, colorUint32); - } + }, + ); } } diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 35da5eeca4..228b207376 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -1,4 +1,4 @@ -import { Vector3d } from "../../../math/vector3d.ts"; +import { transformQuadCorners } from "../../gpu/quadcorners.ts"; import IndexBuffer from "../buffer/index.js"; import { buildMultiTextureFragment } from "./../shaders/multitexture.js"; import quadMultiVertex from "./../shaders/quad-multi.vert"; @@ -10,21 +10,6 @@ import { MaterialBatcher } from "./material_batcher.js"; * @import {TextureAtlas} from "./../../texture/atlas.js"; */ -// a pool of reusable vectors used by `addQuad` to transform the four -// quad corners. Vector3d (not Vector2d) so the per-sprite depth set on -// `z` flows through `Matrix3d.apply` — required for Camera3d's view -// matrix (Y/X-axis rotation) to actually rotate the vertex in 3D space. -// For 2D-only matrices the z column is identity, so `(x, y)` output is -// bit-identical to the Vector2d path. Exported so `LitQuadBatcher` -// reuses the same pool — JS is single-threaded and `addQuad` is -// synchronous, so concurrent access can't happen. -export const V_ARRAY = [ - new Vector3d(), - new Vector3d(), - new Vector3d(), - new Vector3d(), -]; - /** * A WebGL Compositor object. This class handles all of the WebGL state
* Pushes texture regions or shape geometry into WebGL buffers, automatically flushes to GPU @@ -165,6 +150,8 @@ export default class QuadBatcher extends MaterialBatcher { const gl = this.gl; const vertexSize = vertex.vertexSize; + this.syncProgram(); + // (index buffer binding is captured in the vertex state — no // per-flush rebind) @@ -218,6 +205,11 @@ export default class QuadBatcher extends MaterialBatcher { // `noise_uv` builtin: a blit is a full-frame quad — identity rect shader._setNoiseUVRect?.(width, height, width, height, 0, 0); + // the blit samples a bottom-up capture FBO (the Y-flipped UVs + // below): uv.y grows UPWARD inside apply(), so directional bodies + // (DropShadow) flip their y arithmetic to keep "down" down + shader._setUVYDir?.(-1); + // (re)bind any extra textures a ShaderEffect declared via setTexture, // to their reserved high units — after the source claims unit 0 shader._prepareTextures?.(this); @@ -228,20 +220,17 @@ export default class QuadBatcher extends MaterialBatcher { // calling so FBO blits land in screen space; the matrix path is // kept for any future world-space caller that wants its preDraw // translate/scale honored. - const m = this.viewMatrix; - // blits are always at z = 0 (screen-space). Setting z explicitly - // matters because V_ARRAY is Vector3d — any leftover z from a - // prior addQuad would otherwise leak into the transform. - const vec0 = V_ARRAY[0].set(x, y, 0); - const vec1 = V_ARRAY[1].set(x + width, y, 0); - const vec2 = V_ARRAY[2].set(x, y + height, 0); - const vec3 = V_ARRAY[3].set(x + width, y + height, 0); - if (m && !m.isIdentity()) { - m.apply(vec0); - m.apply(vec1); - m.apply(vec2); - m.apply(vec3); - } + // blits are always at z = 0 (screen-space) — the shared corner + // pool is Vector3d, so the explicit z stops leftover depth from a + // prior addQuad leaking into the transform + const [vec0, vec1, vec2, vec3] = transformQuadCorners( + this.viewMatrix, + x, + y, + width, + height, + 0, + ); // blits stay at z = 0 (screen-space, ortho); pre-PR behavior // preserved unchanged @@ -321,6 +310,9 @@ export default class QuadBatcher extends MaterialBatcher { Math.min(v0, v1), ); } + // direct atlas sampling: uv.y grows downward (directional bodies + // compensate on the pooled path, where captures are bottom-up) + this.currentShader._setUVYDir?.(1); } // Transform vertices. Stamp per-sprite depth onto z BEFORE @@ -328,19 +320,14 @@ export default class QuadBatcher extends MaterialBatcher { // rotates the vertex. For 2D-only matrices the z column is // identity, so output (x, y) is bit-identical to the legacy // Vector2d path and z passes through unchanged. - const m = this.viewMatrix; - const z = this.renderer.currentDepth; - const vec0 = V_ARRAY[0].set(x, y, z); - const vec1 = V_ARRAY[1].set(x + w, y, z); - const vec2 = V_ARRAY[2].set(x, y + h, z); - const vec3 = V_ARRAY[3].set(x + w, y + h, z); - - if (!m.isIdentity()) { - m.apply(vec0); - m.apply(vec1); - m.apply(vec2); - m.apply(vec3); - } + const [vec0, vec1, vec2, vec3] = transformQuadCorners( + this.viewMatrix, + x, + y, + w, + h, + this.renderer.currentDepth, + ); // 4 vertices per quad; the index buffer provides the 6 indices. // textureId is the unit index for multi-texture, or 0 for diff --git a/packages/melonjs/src/video/webgl/glshader.js b/packages/melonjs/src/video/webgl/glshader.js index 4ecba469ba..a0b07040e7 100644 --- a/packages/melonjs/src/video/webgl/glshader.js +++ b/packages/melonjs/src/video/webgl/glshader.js @@ -11,14 +11,42 @@ import { minify } from "./utils/string.js"; import { captureValue, extractUniforms } from "./utils/uniforms.js"; /** - * a base GL Shader object + * a complete custom shader program. Historically a WebGL-only GLSL + * program (the class keeps its name for compatibility), since 20.0 it can + * carry one realization per GPU backend — a `{vertex, fragment}` GLSL + * pair for the WebGL renderer and/or a complete `wgsl` module for the + * WebGPU renderer — mirroring how {@link ShaderEffect} carries one body + * per shading language. The {@link GLShader#isWebGL} / {@link GLShader#isWebGPU} + * flags report which realizations exist; a renderer hosts the one it + * speaks and anything else degrades to the built-in shading (never fatal). + * + * ### The WGSL module contract (custom `Mesh` shaders) + * + * The `wgsl` source is a complete module hosted by the mesh batchers: + * entry points must be named **`vertex_main`** (`@vertex`) and + * **`fragment_main`** (`@fragment`), over the frozen mesh vertex layout — + * `@location(0) aVertex : vec3f`, `@location(1) aRegion : vec2f`, + * `@location(2) aColor : vec4f`, plus `@location(3) aNormal : vec3f` on + * the 48-byte `lit` layout. Bind groups (declare only what is read): + * group 0 `FrameUniforms {projection : mat4x4, lineWidth : f32}`, + * group 1 the mesh texture + sampler, group 2 the `Light3dBlock` (lit + * host only), group 3 `MeshUniforms {model, view : mat4x4, + * tint, params, emissive : vec4f}` (`params.x` = alpha cutout). The + * vertex stage owns the projection (`projection * view * model`) and must + * remap the GL-convention clip z: `(clip.z + clip.w) * 0.5`. WGSL + * validation is asynchronous and never throws — a failing module logs its + * errors once and the mesh falls back to the built-in shading. * @category Rendering */ export default class GLShader { /** - * @param {WebGLRenderingContext} gl - the current WebGL rendering context - * @param {string} vertex - a string containing the GLSL source code to set - * @param {string} fragment - a string containing the GLSL source code to set + * @param {WebGLRenderingContext} [gl] - the current WebGL rendering + * context (`renderer.gl` — undefined on non-WebGL renderers, which + * simply skips the GLSL realization) + * @param {string|object} vertex - a string containing the GLSL vertex + * source, OR a sources object `{vertex, fragment, wgsl, precision, + * label}` carrying one realization per backend (any omittable) + * @param {string} [fragment] - a string containing the GLSL fragment source * @param {string} [precision=auto detected] - float precision ('lowp', 'mediump' or 'highp'). * @see https://developer.mozilla.org/en-US/docs/Games/Techniques/3D_on_the_web/GLSL_Shaders * @example @@ -41,14 +69,82 @@ export default class GLShader { * ) * // use the shader * myShader.bind(); + * @example + * // a dual-backend custom mesh shader: the same object hosts on + * // whichever GPU renderer is active (gl is undefined on WebGPU) + * myMesh.shader = new me.GLShader(app.renderer.gl, { + * vertex: toonVertexGLSL, + * fragment: toonFragmentGLSL, + * wgsl: toonModuleWGSL, + * }); */ constructor(gl, vertex, fragment, precision) { + // object sources form: {vertex, fragment, wgsl, precision, label} + let wgsl; + let label; + if (typeof vertex === "object" && vertex !== null) { + const sources = vertex; + vertex = sources.vertex; + fragment = sources.fragment; + precision = sources.precision ?? precision; + wgsl = sources.wgsl; + label = sources.label; + } + /** * the active gl rendering context * @type {WebGLRenderingContext} */ this.gl = gl; + /** + * `true` when this shader carries a WebGL realization: a + * `{vertex, fragment}` GLSL pair compiled against a live context. + * @type {boolean} + * @readonly + */ + this.isWebGL = + gl != null && typeof vertex === "string" && typeof fragment === "string"; + + /** + * `true` when this shader carries a WebGPU realization: a complete + * `wgsl` module (see the class docs for the module contract). + * @type {boolean} + * @readonly + */ + this.isWebGPU = typeof wgsl === "string"; + + /** + * the complete WGSL module source, when {@link GLShader#isWebGPU} + * @type {string|undefined} + */ + this.wgsl = wgsl; + + /** + * optional debug label, carried onto the GPU shader module (shows + * up in browser GPU error messages and profilers) + * @type {string|undefined} + */ + this.label = label; + + // flipped false when asynchronous WGSL validation reports errors — + // the mesh batchers then fall back to their built-in shading + this.wgslValid = true; + + // per-host WGSL family registrations for the current device + // generation: {epoch, keys: Map} — compared + // against the pipeline cache's epoch so a device loss re-registers + this._wgslRegistrations = { epoch: -1, keys: new Map() }; + + if (!this.isWebGL && !this.isWebGPU) { + // no realization at all (e.g. a GLSL-only asset loaded under a + // non-WebGL renderer): warn and stay inert — assigning the + // shader is harmless, hosts skip it and keep built-in shading + console.warn( + "GLShader: no usable realization — provide {vertex, fragment} GLSL sources with a WebGL context, and/or a complete `wgsl` module for the WebGPU renderer", + ); + } + /** * `true` once {@link destroy} has been called. After this flag is * `true`, every method on the shader is a silent no-op — callers @@ -92,18 +188,26 @@ export default class GLShader { // uniform writes are cached + replayed across a context cycle this._uniformCache = Object.create(null); - // defer compile if constructed mid-suspended-window; replay handles it - if (gl.isContextLost()) { - this.suspended = true; + if (this.isWebGL) { + // defer compile if constructed mid-suspended-window; replay handles it + if (gl.isContextLost()) { + this.suspended = true; + this.program = null; + this.uniforms = null; + this.attributes = null; + } else { + this._compile(); + } + + // context lifecycle only concerns the GL program — a WGSL-only + // shader has nothing to tear down or rebuild + on(ONCONTEXT_LOST, this._onContextLost, this); + on(ONCONTEXT_RESTORED, this._onContextRestored, this); + } else { this.program = null; this.uniforms = null; this.attributes = null; - } else { - this._compile(); } - - on(ONCONTEXT_LOST, this._onContextLost, this); - on(ONCONTEXT_RESTORED, this._onContextRestored, this); } /** @@ -199,7 +303,8 @@ export default class GLShader { * Installs this shader program as part of current rendering state */ bind() { - if (this.destroyed || this.suspended) { + // program === null also covers shaders with no WebGL realization + if (this.destroyed || this.suspended || this.program === null) { return; } this.gl.useProgram(this.program); @@ -211,7 +316,9 @@ export default class GLShader { * @returns {GLint} number indicating the location of the variable name if found. Returns -1 otherwise */ getAttribLocation(name) { - if (this.destroyed || this.suspended) { + // attributes === null also covers shaders with no WebGL realization + // — the same silent-no-op contract as bind()/setUniform() + if (this.destroyed || this.suspended || this.attributes === null) { return -1; } const attr = this.attributes[name]; @@ -255,8 +362,10 @@ export default class GLShader { } this._uniformCache[name] = cached; - if (this.suspended) { - // deferred: replay handles the live write on restore + if (this.suspended || this.uniforms === null) { + // deferred: the restore replay handles the live write. A shader + // with no WebGL realization only caches — its WGSL side has no + // custom uniforms to receive the value (see the module contract) return; } @@ -323,12 +432,13 @@ export default class GLShader { if (this.destroyed) { throw new Error("GLShader.clone: shader has been destroyed"); } - const copy = new GLShader( - this.gl, - this._sourceVertex, - this._sourceFragment, - this._precision, - ); + const copy = new GLShader(this.gl, { + vertex: this._sourceVertex, + fragment: this._sourceFragment, + precision: this._precision, + wgsl: this.wgsl, + label: this.label, + }); // replay this shader's cached uniform values onto the clone (the same // snapshot store the context-loss recovery replays from). When the // clone is constructed mid-context-loss its program compiles deferred @@ -337,13 +447,112 @@ export default class GLShader { // uniforms (same guard the restore replay uses) so a name cached // during an earlier suspended window can't make setUniform throw. for (const name of Object.keys(this._uniformCache)) { - if (copy.suspended || typeof copy.uniforms[name] !== "undefined") { + if ( + copy.suspended || + copy.uniforms === null || + typeof copy.uniforms[name] !== "undefined" + ) { copy.setUniform(name, this._uniformCache[name]); } } return copy; } + /** + * Register this shader's WGSL module as a pipeline family for one + * hosting mesh batcher, against that host's positional group-layout + * list and frozen vertex layout. Registered once per host per device + * generation; the module text is namespaced by the host key so the + * same shader yields distinct families (distinct pipeline layouts) on + * the unlit and lit hosts. Asynchronous WGSL validation errors flip + * {@link GLShader#wgslValid} and the host falls back to its built-in + * shading (warn-and-degrade, never fatal). + * @param {object} cache - the WebGPU renderer's pipeline cache + * @param {string} host - the hosting family's vertex-layout key + * @param {GPUBindGroupLayout[]} bindGroupLayouts - the host's group list + * @param {string} vertexLayoutKey - the host's registered vertex layout + * @returns {string|null} the family key to pass to the pipeline cache's + * `get`, or null when the module failed validation on this device + * generation (the host then uses its built-in family) + * @ignore + */ + registerWGSL(cache, host, bindGroupLayouts, vertexLayoutKey) { + // epoch first, invalid-gate second: a device loss replaces the cache + // (new epoch), and the fresh device gets a fresh validation verdict + // even for a shader that failed on the previous one + if (this._wgslRegistrations.epoch !== cache.epoch) { + this._wgslRegistrations = { epoch: cache.epoch, keys: new Map() }; + this.wgslValid = true; + } + if (this.wgslValid === false) { + return null; + } + let key = this._wgslRegistrations.keys.get(host); + if (typeof key === "undefined") { + // namespace suffix LAST so compilation-error line numbers still + // match the authored module text + key = cache.registerShader(`${this.wgsl}\n// melonJS host: ${host}`, { + bindGroupLayouts, + vertexLayoutKey, + label: this.label ?? "melonJS custom mesh shader", + }); + this._wgslRegistrations.keys.set(host, key); + + const module = cache.modules?.[key]; + if (typeof module?.getCompilationInfo === "function") { + module + .getCompilationInfo() + .then((info) => { + const errors = info.messages.filter((message) => { + return message.type === "error"; + }); + if (errors.length > 0) { + this.wgslValid = false; + console.warn( + `GLShader${this.label ? ` (${this.label})` : ""}: WGSL compilation failed — falling back to the built-in mesh shading\n${errors + .map((message) => { + return ` line ${message.lineNum}: ${message.message}`; + }) + .join("\n")}`, + ); + } + }) + .catch(() => {}); + } + + // Pipeline-level containment: a module can compile CLEAN yet + // declare a binding absent from this host's pipeline layout — + // that error only surfaces at pipeline creation, and an invalid + // pipeline poisons every frame that records it (the whole + // submit fails). Build one representative pipeline inside an + // error scope; any validation error flips the same fallback. + const device = cache.device; + if (typeof device?.pushErrorScope === "function") { + device.pushErrorScope("validation"); + try { + cache.get(key, "triangle-list", "none", true, "none", { + cullMode: "none", + frontFace: "ccw", + }); + } catch { + /* surfaced through the scope below */ + } + device + .popErrorScope() + .then((error) => { + if (error) { + this.wgslValid = false; + console.warn( + `GLShader${this.label ? ` (${this.label})` : ""}: WGSL module is incompatible with the mesh pipeline layout — falling back to the built-in mesh shading\n ${error.message}`, + ); + } + }) + .catch(() => {}); + } + } + return key; + } + /** * destroy this shader objects resources (program, attributes, uniforms). * Idempotent — calling destroy twice (or after a context-lost suspend) @@ -377,5 +586,12 @@ export default class GLShader { this._sourceVertex = null; this._sourceFragment = null; this._uniformCache = null; + + // WGSL side: the GPU shader modules are owned by the pipeline cache + // (shared with any other shader of the same text) — only drop the + // registrations and the module source + this.wgsl = undefined; + this.isWebGPU = false; + this._wgslRegistrations = { epoch: -1, keys: new Map() }; } } diff --git a/packages/melonjs/src/video/webgl/lighting/pack3d.ts b/packages/melonjs/src/video/webgl/lighting/pack3d.ts index 2ce7cd670a..080911fbf3 100644 --- a/packages/melonjs/src/video/webgl/lighting/pack3d.ts +++ b/packages/melonjs/src/video/webgl/lighting/pack3d.ts @@ -6,39 +6,57 @@ import { MAX_LIGHTS } from "./constants.ts"; * @ignore */ export interface PackedMeshLighting { - /** number of active directional lights, clamped to `MAX_LIGHTS`. */ + /** number of active shaded 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; + /** + * `MAX_LIGHTS × 4` — `[x, y, z, range]` per light; `range` is `-1` for + * directional lights (the shader's type sentinel). + */ + posRange: Float32Array; + /** + * `MAX_LIGHTS × 4` — `[dx, dy, dz, cos(outerConeAngle)]` per light; + * `cosOuter` is `-1` when no cone applies (directional carries the + * pre-negated surface→light vector, point carries no direction). + */ + dirCone: Float32Array; + /** + * `MAX_LIGHTS × 4` — `[r, g, b, cos(innerConeAngle)]` per light, + * color premultiplied by intensity. + */ + colorInner: 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 _posRange = new Float32Array(MAX_LIGHTS * 4); +const _dirCone = new Float32Array(MAX_LIGHTS * 4); +const _colorInner = new Float32Array(MAX_LIGHTS * 4); const _ambient = new Float32Array(3); const _result: PackedMeshLighting = { count: 0, - directions: _dir, - colors: _color, + posRange: _posRange, + dirCone: _dirCone, + colorInner: _colorInner, 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 + * into the std140-ready arrays the mesh-lit shader reads: + * - **directional** lights contribute a surface→light direction (negated + * travel direction, normalized) + a color premultiplied by intensity. + * Re-normalized here so a direction mutated at runtime without * re-normalizing still shades correctly. + * - **point** lights contribute their world position, `range` (quadratic + * falloff over the range — the Light2d model) and premultiplied color. + * - **spot** lights add their travel direction and the cone cosines + * (`innerConeAngle` clamped just inside `outerConeAngle`, so the + * smoothstep denominator never collapses). * - **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). + * 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 @@ -59,24 +77,72 @@ export function packMeshLights( ab += (light.color.b / 255) * k; continue; } - // only directional lights are shaded in this release - if (light.type !== "directional" || count >= MAX_LIGHTS) { + if (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 o = count * 4; 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++; + _colorInner[o] = (light.color.r / 255) * k; + _colorInner[o + 1] = (light.color.g / 255) * k; + _colorInner[o + 2] = (light.color.b / 255) * k; + _colorInner[o + 3] = 0; + + if (light.type === "directional") { + const dx = light.direction.x; + const dy = light.direction.y; + const dz = light.direction.z; + const len = Math.hypot(dx, dy, dz) || 1; + // directional sentinel: range < 0; position unused + _posRange[o] = 0; + _posRange[o + 1] = 0; + _posRange[o + 2] = 0; + _posRange[o + 3] = -1; + // store the surface→light vector (negated travel direction) + _dirCone[o] = -dx / len; + _dirCone[o + 1] = -dy / len; + _dirCone[o + 2] = -dz / len; + _dirCone[o + 3] = -1; + count++; + continue; + } + if (light.type === "point" || light.type === "spot") { + _posRange[o] = light.position.x; + _posRange[o + 1] = light.position.y; + _posRange[o + 2] = light.position.z; + // quadratic falloff needs a scale — a degenerate range would + // make the light a point-sized pop + _posRange[o + 3] = Math.max(light.range ?? 0, 1); + if (light.type === "spot") { + const dx = light.direction.x; + const dy = light.direction.y; + const dz = light.direction.z; + const len = Math.hypot(dx, dy, dz) || 1; + // the light's TRAVEL direction (cone axis), normalized + _dirCone[o] = dx / len; + _dirCone[o + 1] = dy / len; + _dirCone[o + 2] = dz / len; + // floor the outer angle so the inner clamp below can never + // invert the smoothstep edges (edge0 >= edge1 is undefined + // in GLSL, NaN-prone in WGSL) — an authored cone under + // ~0.11° is a degenerate laser anyway + const outer = Math.max(light.outerConeAngle ?? Math.PI / 4, 2e-3); + // inner strictly inside outer, or the cone edge divides by 0 + const inner = Math.max( + Math.min(light.innerConeAngle ?? 0, outer - 1e-3), + 0, + ); + _dirCone[o + 3] = Math.cos(outer); + _colorInner[o + 3] = Math.cos(inner); + } else { + // point: no cone — the shader's "no cone" sentinel + _dirCone[o] = 0; + _dirCone[o + 1] = 0; + _dirCone[o + 2] = 0; + _dirCone[o + 3] = -1; + } + count++; + } + // unknown types are skipped } } _ambient[0] = ar; diff --git a/packages/melonjs/src/video/webgl/lighting/std140.ts b/packages/melonjs/src/video/webgl/lighting/std140.ts index ac159c4015..29ee0af709 100644 --- a/packages/melonjs/src/video/webgl/lighting/std140.ts +++ b/packages/melonjs/src/video/webgl/lighting/std140.ts @@ -158,24 +158,51 @@ export function writeLight2dBlock( return HEADER_FLOATS + count * LIGHT_FLOATS; } +/** + * floats occupied by one 3D light: three `vec4`s. The 3D block grew from + * two when point/spot shading landed (#1536) — a spot light needs position + * + range + direction + both cone cosines + color, which cannot fold into + * two vec4s' padding. The 2D block keeps its two-vec4 stride. + * @ignore + */ +export const LIGHT3D_FLOATS = VEC4 * 3; + +/** + * total floats in a 3D light block, header included + * @ignore + */ +export const BLOCK3D_FLOATS = HEADER_FLOATS + MAX_LIGHTS * LIGHT3D_FLOATS; + +/** + * total bytes in a 3D light block + * @ignore + */ +export const BLOCK3D_BYTES = BLOCK3D_FLOATS * Float32Array.BYTES_PER_ELEMENT; + /** * Lay out 3D (`Light3d`) data. * * ```glsl * struct Light3dData { - * vec4 direction; // x, y, z, unused - * vec4 color; // r, g, b, unused + * vec4 posRange; // x, y, z = world position · w = range; + * // w < 0 marks a DIRECTIONAL light (xyz unused) + * vec4 dirCone; // x, y, z = direction · w = cos(outerConeAngle); + * // w = -1 marks "no cone" (directional dir is the + * // pre-negated surface→light vector; a point light + * // carries no direction) + * vec4 colorInner; // r, g, b = color × intensity · w = cos(innerConeAngle) * }; * ``` * - * The two unused `w` slots are padding a `vec3` would have cost anyway; - * they are left for a future point/spot light's range and cone angle - * (see #1536) rather than tightened away. - * @param out - staging buffer, at least `BLOCK_FLOATS` long + * The type is inferred from the sentinels rather than a tag slot: + * `posRange.w < 0` → directional; otherwise positional, with a cone + * applied when `dirCone.w > -1`. + * @param out - staging buffer, at least `BLOCK3D_FLOATS` long * @param packed - what {@link packMeshLights} produced * @param packed.count - number of live lights, clamped to `MAX_LIGHTS` - * @param packed.directions - surface→light unit vector per light - * @param packed.colors - `[r, g, b]` per light, premultiplied by intensity + * @param packed.posRange - `[x, y, z, range]` per light (range -1 for directional) + * @param packed.dirCone - `[dx, dy, dz, cosOuter]` per light (cosOuter -1 for none) + * @param packed.colorInner - `[r, g, b, cosInner]` per light * @param packed.ambient - `[r, g, b]` ambient floor; absent means black * @returns how many floats of `out` are live, for a partial upload * @ignore @@ -184,8 +211,9 @@ export function writeLight3dBlock( out: Float32Array, packed: { count: number; - directions: Float32Array; - colors: Float32Array; + posRange: Float32Array; + dirCone: Float32Array; + colorInner: Float32Array; ambient?: ArrayLike; }, ): number { @@ -193,21 +221,27 @@ export function writeLight3dBlock( const count = Math.min( Math.max(packed.count | 0, 0), MAX_LIGHTS, - Math.floor(packed.directions.length / 3), - Math.floor(packed.colors.length / 3), + Math.floor(packed.posRange.length / 4), + Math.floor(packed.dirCone.length / 4), + Math.floor(packed.colorInner.length / 4), ); writeHeader(out, count, packed.ambient); for (let i = 0; i < count; i++) { - const o = HEADER_FLOATS + i * LIGHT_FLOATS; - out[o] = packed.directions[i * 3]; - out[o + 1] = packed.directions[i * 3 + 1]; - out[o + 2] = packed.directions[i * 3 + 2]; - out[o + 3] = 0; - out[o + 4] = packed.colors[i * 3]; - out[o + 5] = packed.colors[i * 3 + 1]; - out[o + 6] = packed.colors[i * 3 + 2]; - out[o + 7] = 0; + const o = HEADER_FLOATS + i * LIGHT3D_FLOATS; + const i4 = i * 4; + out[o] = packed.posRange[i4]; + out[o + 1] = packed.posRange[i4 + 1]; + out[o + 2] = packed.posRange[i4 + 2]; + out[o + 3] = packed.posRange[i4 + 3]; + out[o + 4] = packed.dirCone[i4]; + out[o + 5] = packed.dirCone[i4 + 1]; + out[o + 6] = packed.dirCone[i4 + 2]; + out[o + 7] = packed.dirCone[i4 + 3]; + out[o + 8] = packed.colorInner[i4]; + out[o + 9] = packed.colorInner[i4 + 1]; + out[o + 10] = packed.colorInner[i4 + 2]; + out[o + 11] = packed.colorInner[i4 + 3]; } - return HEADER_FLOATS + count * LIGHT_FLOATS; + return HEADER_FLOATS + count * LIGHT3D_FLOATS; } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index 5940939029..2263255ce6 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -25,13 +25,16 @@ uniform sampler2D uSampler; uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) uniform vec3 uEmissive; // self-illumination color (0 = none) -// One directional light. `direction` is surface→light, normalized, in world -// space; `color` is premultiplied by intensity. The unused w components are -// padding a vec3 would have cost anyway — reserved for a point/spot light's -// range and cone angle rather than tightened away. +// One light, type inferred from sentinels (see std140.ts): +// posRange.w < 0 -> directional (dirCone.xyz = surface->light, normalized); +// otherwise positional at posRange.xyz with quadratic falloff over +// posRange.w, plus a spot cone when dirCone.w > -1 (dirCone.xyz = the cone +// axis / travel direction, w = cos(outer), colorInner.w = cos(inner)). +// colorInner.rgb is premultiplied by intensity. struct Light3dData { - vec4 direction; - vec4 color; + vec4 posRange; + vec4 dirCone; + vec4 colorInner; }; layout(std140) uniform Light3dBlock { @@ -47,6 +50,7 @@ layout(std140) uniform Light3dBlock { in vec4 vColor; in vec2 vRegion; in vec3 vNormal; +in vec3 vWorldPos; out vec4 fragColor; @@ -69,11 +73,34 @@ void main(void) { // trip count would also index `uLights` out of range int count = min(int(uLightCount), __MAX_LIGHTS__); for (int i = 0; i < count; i++) { + vec4 pr = uLights[i].posRange; + vec4 dc = uLights[i].dirCone; + vec3 L; + float atten = 1.0; + if (pr.w < 0.0) { + // directional: dirCone.xyz is already the surface->light vector + L = dc.xyz; + } else { + // positional: quadratic falloff over range (the Light2d model — + // stylized, not physical inverse-square, which is unusable in + // pixel-unit worlds with unit intensities) + vec3 toLight = pr.xyz - vWorldPos; + float dist = max(length(toLight), 1e-4); + L = toLight / dist; + float linearAtt = max(0.0, 1.0 - dist / pr.w); + atten = linearAtt * linearAtt; + if (dc.w > -1.0) { + // spot cone: -L is the light->surface direction; fade from + // the inner cone cosine to the outer + float cd = dot(-L, dc.xyz); + atten *= smoothstep(dc.w, uLights[i].colorInner.w, cd); + } + } // Half-Lambert ("wrap") diffuse: dot * 0.5 + 0.5. Softens the // terminator and lifts the shadowed side, for a gentler, more // diffuse look than hard Lambert (which reads as harsh noon). - float ndl = dot(N, uLights[i].direction.xyz) * 0.5 + 0.5; - lit += uLights[i].color.rgb * (ndl * ndl); + float ndl = dot(N, L) * 0.5 + 0.5; + lit += uLights[i].colorInner.rgb * (ndl * ndl * atten); } // emissive self-illuminates: added AFTER lighting so it glows at full diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert index 7b1577a21f..5274f53da0 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.vert @@ -22,10 +22,14 @@ uniform vec4 uTint; out vec2 vRegion; out vec4 vColor; out vec3 vNormal; +// world-space fragment position — positional lights (point / spot) fall +// off with distance, so the fragment stage needs where the surface IS +out vec3 vWorldPos; void main(void) { - gl_Position = - uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0); + vec4 worldPos = uModelMatrix * vec4(aVertex, 1.0); + gl_Position = uProjectionMatrix * uViewMatrix * worldPos; + vWorldPos = worldPos.xyz; vec4 tinted = aColor * uTint; vColor = vec4(tinted.rgb * tinted.a, tinted.a); vRegion = aRegion; diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 47e5a29d35..0f306cfb49 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -52,9 +52,6 @@ const _tempMatrix = new Matrix3d(); const _savedTransform = new Matrix3d(); const _savedProjection = new Matrix3d(); -// list of supported compressed texture formats -let supportedCompressedTextureFormats; - /** * a WebGL renderer object * @category Rendering @@ -336,6 +333,23 @@ export default class WebGLRenderer extends Renderer { this.reset(); }); + // Every live GLShader recompiles on this event, and each recompile + // binds its own program to replay its uniform snapshot — so the + // REAL current program afterwards is whichever shader recompiled + // last, while this cache still names whatever drew before the + // event. Invalidate it (order-immune: whatever is really bound, + // the next flush's syncProgram re-issues useProgram) instead of + // trusting a stale match and drawing with a foreign program. + // Latent for years; surfaced when the Light3dBlock outgrew the + // Light2dBlock — a foreign mesh-lit program parks its block at + // default binding point 0, whose 2D buffer is now too small, and a + // plain sprite flush dies with INVALID_OPERATION. + on(ONCONTEXT_RESTORED, (renderer) => { + if (renderer === this) { + this.currentProgram = undefined; + } + }); + // register to the CANVAS resize channel on(CANVAS_ONRESIZE, (width, height) => { this.flush(); @@ -359,13 +373,17 @@ export default class WebGLRenderer extends Renderer { * @return {Object} */ getSupportedCompressedTextureFormats() { - if (typeof supportedCompressedTextureFormats === "undefined") { + // per-instance (the WebGPU backend's convention): a module-level memo + // would be shared across coexisting renderer instances and survive a + // destroyed context — divergence-by-drift, even if format support + // rarely differs in practice + if (typeof this._compressedTextureFormats === "undefined") { const gl = this.gl; if (typeof gl === "undefined" || gl === null) { // WebGL context not available return super.getSupportedCompressedTextureFormats(); } - supportedCompressedTextureFormats = { + this._compressedTextureFormats = { astc: gl.getExtension("WEBGL_compressed_texture_astc") || gl.getExtension("WEBKIT_WEBGL_compressed_texture_astc"), @@ -392,15 +410,15 @@ export default class WebGLRenderer extends Renderer { // ETC2 is a superset of ETC1 — if we have ETC2 but not ETC1, // synthesize ETC1 support so that PKM/KTX ETC1 textures work if ( - !supportedCompressedTextureFormats.etc1 && - supportedCompressedTextureFormats.etc2 + !this._compressedTextureFormats.etc1 && + this._compressedTextureFormats.etc2 ) { - supportedCompressedTextureFormats.etc1 = { + this._compressedTextureFormats.etc1 = { COMPRESSED_RGB_ETC1_WEBGL: 0x8d64, }; } } - return supportedCompressedTextureFormats; + return this._compressedTextureFormats; } /** @@ -1660,9 +1678,26 @@ export default class WebGLRenderer extends Renderer { // occlusion per pixel against the accumulated depth buffer. this.setBatcher(mesh.lit === true ? "litMesh" : "mesh"); - // apply custom shader if set on the renderable (via preDraw) - if (this.customShader != null) { + // apply custom shader if set on the renderable (via preDraw) — + // hostable only when it carries a live GL program (a WGSL-only + // GLShader has none: keep the built-in shading, and say so once). + // The warn keys on the DECLARED realization (isWebGL), not the live + // program: a dual shader drawn during a lost-context window merely + // lacks its program transiently and must neither warn nor consume + // the one-shot for a later genuinely-unhostable shader. + const hostedShader = + this.customShader != null && this.customShader.program != null; + if (hostedShader) { this.currentBatcher.useShader(this.customShader); + } else if ( + this.customShader != null && + this.customShader.isWebGL !== true && + this._meshShaderWarned !== true + ) { + this._meshShaderWarned = true; + console.warn( + "melonJS: this custom shader cannot be hosted on a Mesh by the WebGL renderer (no compiled GLSL program) — the mesh draws with the built-in shading", + ); } // toggle backface culling per-mesh — varies across meshes in @@ -1679,25 +1714,30 @@ export default class WebGLRenderer extends Renderer { gl.frontFace(retained && mesh.rightHanded !== true ? gl.CW : gl.CCW); } - const tint = this.currentTint.toUint32(this.getGlobalAlpha()); - if (retained) { - this.currentBatcher.drawRetainedMesh(mesh, modelMatrix, tint); - } else { - this.currentBatcher.addMesh(mesh, tint); - this.flush(); - } - - if (mesh.cullBackFaces) { - gl.disable(gl.CULL_FACE); - // restore the default orientation alongside the cull toggle: leaving - // it at CW would leak to any later consumer of the context that reads - // `gl_FrontFacing` or enables culling itself - gl.frontFace(gl.CCW); - } + // finally: a throw mid-draw (e.g. a texture upload failing) must not + // leak the cull toggle or leave the custom program bound — the NEXT + // unshaded mesh would silently draw with it + try { + const tint = this.currentTint.toUint32(this.getGlobalAlpha()); + if (retained) { + this.currentBatcher.drawRetainedMesh(mesh, modelMatrix, tint); + } else { + this.currentBatcher.addMesh(mesh, tint); + this.flush(); + } + } finally { + if (mesh.cullBackFaces) { + gl.disable(gl.CULL_FACE); + // restore the default orientation alongside the cull toggle: + // leaving it at CW would leak to any later consumer of the + // context that reads `gl_FrontFacing` or enables culling itself + gl.frontFace(gl.CCW); + } - // revert to default shader if custom was applied - if (this.customShader != null) { - this.currentBatcher.useShader(this.currentBatcher.defaultShader); + // revert to default shader if custom was applied + if (hostedShader) { + this.currentBatcher.useShader(this.currentBatcher.defaultShader); + } } } @@ -1871,6 +1911,8 @@ export default class WebGLRenderer extends Renderer { *
* - "lighten" : retains the lightest pixels of both layers
*
+ * - "none" : blending disabled — the source replaces the destination + * outright, alpha included (matches the WebGPU renderer's "none")
* Other CSS blend modes ("overlay", "color-dodge", "color-burn", "hard-light", "soft-light", * "difference", "exclusion") may be supported by the Canvas renderer (browser-dependent) * and will always fall back to "normal" in WebGL.
@@ -1921,6 +1963,12 @@ export default class WebGLRenderer extends Renderer { gl.blendFunc(gl.ONE, gl.ONE); break; + case "none": + // replace: source overwrites destination, alpha included — + // the WebGPU backend's "none" pipeline blend state parity + gl.disable(gl.BLEND); + break; + default: gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(srcAlpha, gl.ONE_MINUS_SRC_ALPHA); diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js new file mode 100644 index 0000000000..a1b3da4b83 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js @@ -0,0 +1,272 @@ +import state from "../../../state/state.ts"; +import { buildLitMeshVertexData } from "../../gpu/meshvertex.ts"; +// the lighting math modules are pure CPU code shared with the GL backend +// (published std140 layout, no GL calls) +import { packMeshLights } from "../../webgl/lighting/pack3d.ts"; +import { + BLOCK3D_BYTES, + BLOCK3D_FLOATS, + writeLight3dBlock, +} from "../../webgl/lighting/std140.ts"; +import litMeshWGSL from "../shaders/mesh-lit.wgsl"; +import WebGPUMeshBatcher from "./mesh_batcher.js"; + +// ambient used when a lit mesh is drawn with no active 3D lights at all — +// 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]); + +/** + * The WebGPU lit mesh batcher — meshes shaded by the active stage's + * {@link Light3d} lights (half-Lambert diffuse from directional lights + + * ambient), the port of the GL `LitMeshBatcher`: + * + * - adds the `aNormal` vertex attribute (48-byte layout vs 36) and the lit + * shader family; meshes opt in via `mesh.lit`, so unlit meshes keep the + * lean base batcher and pay nothing for lighting + * - the std140 `Light3dBlock` binds at group 2 with a dynamic offset. The + * GL batcher re-packs and dirty-compares per draw + * (`UniformBlock.upload`); here a change lands in a FRESH arena region + * (queue-write law — an in-place write would retroactively re-light + * draws already recorded this frame), and an unchanged rig re-uses the + * frame's existing snapshot at zero upload cost + * @augments WebGPUMeshBatcher + * @category Rendering + */ +export default class WebGPULitMeshBatcher extends WebGPUMeshBatcher { + /** + * @override + */ + init(renderer, settings) { + // a new device invalidates the device-scoped lights layout — drop + // it so it is recreated below (base init sets this.device) + if (renderer.device !== this.device) { + this.lightsLayout = undefined; + } + super.init(renderer, settings); + // the base init may have short-circuited on an already-registered + // module (never calling bindGroupLayoutList) — the snapshot path + // still needs a live layout on THIS instance + this.ensureLightsLayout(); + + // CPU staging for the std140 block + the bytes of the frame's last + // snapshot (the dirty compare) + this.blockData = new Float32Array(BLOCK3D_FLOATS); + this.uploadedBlock = new Float32Array(BLOCK3D_FLOATS); + // light-block bind group per arena page (pages persist across frames) + this.lightBindGroups = new Map(); + // the current snapshot binding: {bindGroup, dynamicOffset, frameId} + this.lightBinding = null; + } + + /** + * @override + * @ignore + */ + defaultSettings() { + const settings = super.defaultSettings(); + settings.shaderKey = "meshLit"; + return settings; + } + + /** + * @override + * @ignore + */ + shaderSource() { + return litMeshWGSL; + } + + /** add the normal attribute on top of the base layout. @ignore */ + attributeLayout() { + const attributes = super.attributeLayout(); + attributes.push({ + // aNormal: model-space on the retained path (the shader rotates + // it), world-space on the accumulated path (identity model) + name: "aNormal", + format: "float32x3", + offset: 9 * Float32Array.BYTES_PER_ELEMENT, + }); + return attributes; + } + + /** + * build the light-block layout once per device (init drops it when the + * device changes) + * @ignore + */ + ensureLightsLayout() { + if (typeof this.lightsLayout === "undefined") { + this.lightsLayout = this.device.createBindGroupLayout({ + label: "melonJS light3d block", + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { + type: "uniform", + hasDynamicOffset: true, + minBindingSize: BLOCK3D_BYTES, + }, + }, + ], + }); + } + return this.lightsLayout; + } + + /** + * the lit family swaps the group-2 filler for the light-block layout + * @override + * @ignore + */ + bindGroupLayoutList(cache) { + return [ + cache.frameLayout, + cache.materialLayout, + this.ensureLightsLayout(), + this.meshLayout, + ]; + } + + /** push the 12-float lit vertex, appending the world-space normal. @ignore */ + pushVertex(vertexData, x, y, z, u, v, color, mesh, i3) { + const n = mesh.normals; + vertexData.pushMeshLit( + x, + y, + z, + u, + v, + color, + n ? n[i3] : 0, + n ? n[i3 + 1] : 0, + n ? n[i3 + 2] : 0, + ); + } + + /** + * Same as the base layout plus the model-space normal (zero-normal + * guarded), via the shared neutral builder — see `gpu/meshvertex.ts`. + * @param {object} mesh - the mesh to read geometry from + * @param {Float32Array} out - destination scratch + * @returns {number} number of floats written + * @override + * @ignore + */ + buildRetainedVertexData(mesh, out) { + return buildLitMeshVertexData(mesh, out, this.vertexSize); + } + + /** + * Refresh the light snapshot before a draw. Per draw and not per bind, + * for the same reason as the GL batcher: `setBatcher` early-returns + * when this batcher is already current, so a purely-lit scene would + * otherwise shade every frame with its first frame's lights. + * + * Cost when the rig is static: one pack plus one array compare, zero + * queue writes — the frame's existing snapshot region is re-bound. + * With NO lights at all a white ambient keeps a `lit` mesh fullbright + * (matching the unlit path); an ambient-only scene uses its real + * ambient. + * @override + * @ignore + */ + updatePassState() { + const renderer = this.renderer; + const stage = state.current(); + const lit = packMeshLights(stage ? stage._activeLights3d : null); + const a = lit.ambient; + const hasLight = lit.count > 0 || a[0] > 0 || a[1] > 0 || a[2] > 0; + writeLight3dBlock(this.blockData, { + count: lit.count, + posRange: lit.posRange, + dirCone: lit.dirCone, + colorInner: lit.colorInner, + ambient: hasLight ? a : WHITE_AMBIENT, + }); + + // an unchanged block within the frame re-uses its snapshot + if ( + this.lightBinding !== null && + this.lightBinding.frameId === renderer.frameId + ) { + const prev = this.uploadedBlock; + const next = this.blockData; + let dirty = false; + for (let i = 0; i < BLOCK3D_FLOATS; i++) { + if (prev[i] !== next[i]) { + dirty = true; + break; + } + } + if (dirty === false) { + return; + } + } + + // a change (or a new frame — the arenas were reset) snapshots into + // a fresh region: draws already recorded keep their own bytes + const device = this.device; + const region = renderer.effectUniformArena.alloc( + BLOCK3D_BYTES, + device.limits.minUniformBufferOffsetAlignment, + ); + device.queue.writeBuffer( + region.buffer, + region.offset, + this.blockData.buffer, + 0, + BLOCK3D_BYTES, + ); + let bindGroup = this.lightBindGroups.get(region.buffer); + if (typeof bindGroup === "undefined") { + bindGroup = device.createBindGroup({ + label: "melonJS light3d block", + layout: this.lightsLayout, + entries: [ + { + binding: 0, + resource: { buffer: region.buffer, size: BLOCK3D_BYTES }, + }, + ], + }); + this.lightBindGroups.set(region.buffer, bindGroup); + } + this.lightBinding = { + bindGroup, + dynamicOffset: region.offset, + frameId: renderer.frameId, + }; + this.uploadedBlock.set(this.blockData); + } + + /** + * lit draws bind the light block at group 2 + * @override + * @ignore + */ + bindLights(pass) { + // a stale or missing snapshot (a flush outside the drawMesh bracket, + // a new frame) refreshes first — the arena region it pointed into + // was reset + if ( + this.lightBinding === null || + this.lightBinding.frameId !== this.renderer.frameId + ) { + this.updatePassState(); + } + pass.setBindGroup(2, this.lightBinding.bindGroup, [ + this.lightBinding.dynamicOffset, + ]); + } + + /** + * @override + */ + reset() { + super.reset(); + this.lightBindGroups.clear(); + this.lightBinding = null; + } +} diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js index 5572834d4d..fde2c18154 100644 --- a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js @@ -276,11 +276,23 @@ export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { * sampler resolved from the default texture filter, so a filter change * must rebuild them (the lit-tier counterpart of the texture store's * invalidateBindGroups; the resident textures stay). + * @override */ clearMaterialCache() { + super.clearMaterialCache(); this.litMaterials.clear(); } + /** + * the lit material model is the combined color+normal bind group, not + * the base segment slots + * @override + * @ignore + */ + hasPendingMaterial() { + return this.currentMaterial !== null; + } + /** * lit draws bind the combined material at 1 and the light block at 2 * @override diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js new file mode 100644 index 0000000000..998af2e10b --- /dev/null +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -0,0 +1,618 @@ +import { Matrix3d } from "../../../math/matrix3d.ts"; +import { + assignIndex, + beginChunk, + ensureRemapCapacity, + remapIndex, +} from "../../gpu/meshchunk.ts"; +import { buildMeshVertexData, retainedScratch } from "../../gpu/meshvertex.ts"; +import WebGPURetainedGeometry from "../buffer/retained_geometry.js"; +import meshWGSL from "../shaders/mesh.wgsl"; +import WebGPUBatcher from "./webgpu_batcher.js"; + +/** + * Byte size of the per-draw mesh uniform block: + * mat4x4 model (64) + mat4x4 view (64) + vec4 tint (16) + vec4 params + * (alphaCutoff, reserved ×3) (16) + vec4 emissive (16) → 176. + * @ignore + */ +export const MESH_UNIFORM_SIZE = 176; + +// Shared identity model matrix for draws whose vertices are already placed +// (the 2D-camera path pre-projects them on the CPU). Never mutated. +const IDENTITY_MATRIX = new Matrix3d(); + +// Scratch for assembling one MeshUniforms snapshot (44 floats = 176 bytes). +// Reused — setPlacementUniforms runs synchronously and never re-enters. +const UNIFORM_SCRATCH = new Float32Array(MESH_UNIFORM_SIZE / 4); + +/** + * The WebGPU mesh batcher — textured triangle meshes with the same + * geometry contract as the WebGL `MeshBatcher` (36-byte unlit layout, + * model-space retained geometry, uniform-driven placement) realized on + * this backend's recording model: + * + * - mesh mode is PIPELINE state, not device state: every mesh flush looks + * its pipeline up with the mesh axes (depth write + LEQUAL, per-mesh + * cull/frontFace from `renderer.drawMesh`) and blend forced "none" — + * there is no `bind()`/`unbind()` state to own, and the depth clear is + * the pass's `depthLoadOp` (see `resolveDepthOps`), not a draw-time op + * - placement/tint/cutoff/emissive ride ONE per-draw uniform snapshot + * (group 3, dynamic offset into the effect uniform arena) — the + * queue-write law: a shared region would be retroactively clobbered for + * draws already recorded this frame + * - the accumulated path chunks through the shared versioned-remap dedup + * (`gpu/meshchunk.ts`) into the frame's vertex arena plus a per-frame + * INDEX arena (`renderer.indexArena`) — one `drawIndexed` per chunk + * @augments WebGPUBatcher + * @category Rendering + */ +export default class WebGPUMeshBatcher extends WebGPUBatcher { + /** + * @param {import("../webgpu_renderer.js").default} renderer - the owning renderer + * @override + */ + init(renderer, settings) { + super.init(renderer, settings ?? this.defaultSettings()); + const cache = renderer.pipelineCache; + + // group-3 layout for the per-draw uniform snapshot, cached by shape + // signature (shared with the lit subclass and across device losses + // via the pipeline cache's own lifecycle) + this.meshLayout = cache.getEffectLayout(this.uniformSignature(), [ + { + binding: 0, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { + type: "uniform", + hasDynamicOffset: true, + minBindingSize: MESH_UNIFORM_SIZE, + }, + }, + ]); + + // register the family: it rides this batcher's own vertex layout + // (the key the base init just registered the layout under), with + // the subclass-controlled module text and group-layout list. A + // re-init against a SURVIVING cache (reset with a valid device) + // short-circuits on the module text — registerShader would dedupe + // anyway, but evaluating bindGroupLayoutList first would orphan a + // fresh lit lightsLayout per re-init + const vertexLayoutKey = this.shaderKey; + const source = this.shaderSource(); + const registered = cache.registeredModules.get(source); + if (typeof registered !== "undefined") { + this.shaderKey = registered; + } else { + this.shaderKey = cache.registerShader(source, { + bindGroupLayouts: this.bindGroupLayoutList(cache), + vertexLayoutKey, + label: `melonJS ${vertexLayoutKey} shader`, + }); + } + // the host identity for custom modules: a hosted custom shader is + // registered against this batcher's vertex layout and group list + this.vertexLayoutKey = vertexLayoutKey; + // the custom shader hosted for the NEXT draw (a WGSL-carrying + // GLShader routed by `renderer.drawMesh`, cleared after each mesh), + // or null + this.customShader = null; + + // CPU index staging for the accumulated path (uint32 — matches the + // backend's index convention and keeps writeBuffer 4-byte aligned) + this.indexData = new Uint32Array(this.vertexData.maxVertex * 6); + this.indexCount = 0; + + // the material bind group the pending vertices were queued under + this.currentMaterial = null; + // per-draw uniform snapshot binding: {bindGroup, dynamicOffset} + this.uniformBinding = null; + // uniform bind group per arena page (pages persist across frames) + this.uniformBindGroups = new Map(); + // the mesh pass axes for the NEXT flush — mutated in place by + // `renderer.drawMesh` per mesh (read synchronously at pipeline lookup) + this.meshState = { cullMode: "back", frontFace: "ccw" }; + + // Retained geometry per mesh (model-space buffers uploaded once). A + // re-init means a new device or a fresh batcher life, so anything + // held is stale — release it rather than leak it. + if (this.retained !== undefined) { + this.releaseAllRetained(); + } + this.retained = new Map(); + } + + /** + * the first-init settings — the lit subclass overrides the key and + * appends its normal attribute + * @ignore + */ + defaultSettings() { + return { + shaderKey: "mesh", + topology: "triangle-list", + attributes: this.attributeLayout(), + }; + } + + /** + * the WGSL module text of this family + * @ignore + */ + shaderSource() { + return meshWGSL; + } + + /** + * The vertex attribute layout — identical to the WebGL mesh batcher's: + * `aVertex` (3) + `aRegion` (2) + `aColor` (4 floats, deliberately not + * unorm8x4 — the layout is shared with GL, where float colors dodge + * NaN-pattern canonicalization on Metal-backed drivers) = 9 floats. + * @ignore + */ + attributeLayout() { + return [ + { + name: "aVertex", + format: "float32x3", + offset: 0 * Float32Array.BYTES_PER_ELEMENT, + }, + { + name: "aRegion", + format: "float32x2", + offset: 3 * Float32Array.BYTES_PER_ELEMENT, + }, + { + name: "aColor", + format: "float32x4", + offset: 5 * Float32Array.BYTES_PER_ELEMENT, + }, + ]; + } + + /** + * the shape signature keying the group-3 layout in the pipeline cache + * @ignore + */ + uniformSignature() { + return `mesh:u${MESH_UNIFORM_SIZE}`; + } + + /** + * The shader family for the next recorded draw: the built-in family, + * or the hosted custom module when `renderer.drawMesh` routed a + * WGSL-carrying {@link GLShader} here — registered lazily against + * THIS host's vertex layout and group list, so one instance serves + * the unlit and lit hosts as distinct families. An invalid module + * (failed async validation) falls back to the built-in family: the + * mesh keeps drawing, default-shaded — the ShaderEffect + * warn-and-degrade contract. + * @returns {string} the pipeline-cache family key + * @ignore + */ + activeShaderKey() { + const custom = this.customShader; + if (custom === null) { + return this.shaderKey; + } + // registerWGSL owns the epoch/validity bookkeeping (a fresh device + // re-validates an invalidated module) and returns null while invalid + const cache = this.renderer.pipelineCache; + const key = custom.registerWGSL( + cache, + this.vertexLayoutKey, + this.bindGroupLayoutList(cache), + this.vertexLayoutKey, + ); + return key ?? this.shaderKey; + } + + /** + * the positional bind-group-layout list for this family — the lit + * subclass swaps the group-2 filler for its light-block layout + * @ignore + */ + bindGroupLayoutList(cache) { + return [ + cache.frameLayout, + cache.materialLayout, + cache.emptyLayout, + this.meshLayout, + ]; + } + + /** + * Resolve the group-1 material for a mesh: its texture through the + * texture store, honouring the mesh's own `textureRepeat` wrap override + * (#1503 — sampler state per use, never a mutation of the shared + * per-image atlas). A material change with vertices pending flushes + * them under the previous binding. + * @param {object} mesh - the mesh whose material should be applied + * @ignore + */ + applyMeshMaterial(mesh) { + const renderer = this.renderer; + const filter = + typeof mesh.texture.filter === "string" + ? mesh.texture.filter + : renderer.getDefaultTextureFilter(); + const material = renderer.textureStore.getBinding(mesh.texture, { + repeat: mesh.textureRepeat, + // mesh textures sample a generated mip chain — trilinear + // minification keeps distant geometry from shimmering, while + // "nearest" opts out (crisp pixel-art models) and 2D consumers + // of the same image stay lod-clamped to level 0 + mipmaps: filter === "linear", + }); + if (material !== this.currentMaterial) { + this.flush(); + this.currentMaterial = material; + } + } + + /** + * Snapshot the per-draw uniforms — placement (`model`, `view`), tint, + * and the mesh's material scalars (alpha cutoff, emissive) — into a + * fresh dynamic-offset region of the effect uniform arena. The draws + * recorded after this bind against exactly these bytes. + * @param {Matrix3d} modelMatrix - the mesh's own placement, or identity + * when its vertices are already positioned + * @param {number} tint - tint colour in UINT32 (argb) format + * @param {object} mesh - the mesh (alphaCutoff / emissive source) + * @ignore + */ + setPlacementUniforms(modelMatrix, tint, mesh) { + const renderer = this.renderer; + const device = this.device; + const scratch = UNIFORM_SCRATCH; + scratch.set(modelMatrix.val, 0); + scratch.set(renderer.currentTransform.val, 16); + scratch[32] = ((tint >>> 16) & 0xff) / 255; + scratch[33] = ((tint >>> 8) & 0xff) / 255; + scratch[34] = (tint & 0xff) / 255; + scratch[35] = ((tint >>> 24) & 0xff) / 255; + scratch[36] = mesh.alphaCutoff || 0; + scratch[37] = 0; + scratch[38] = 0; + scratch[39] = 0; + const em = mesh.emissive; + scratch[40] = em ? em[0] : 0; + scratch[41] = em ? em[1] : 0; + scratch[42] = em ? em[2] : 0; + scratch[43] = 0; + + const region = renderer.effectUniformArena.alloc( + MESH_UNIFORM_SIZE, + device.limits.minUniformBufferOffsetAlignment, + ); + device.queue.writeBuffer( + region.buffer, + region.offset, + scratch.buffer, + 0, + MESH_UNIFORM_SIZE, + ); + let bindGroup = this.uniformBindGroups.get(region.buffer); + if (typeof bindGroup === "undefined") { + bindGroup = device.createBindGroup({ + label: "melonJS mesh uniforms", + layout: this.meshLayout, + entries: [ + { + binding: 0, + resource: { buffer: region.buffer, size: MESH_UNIFORM_SIZE }, + }, + ], + }); + this.uniformBindGroups.set(region.buffer, bindGroup); + } + this.uniformBinding = { bindGroup, dynamicOffset: region.offset }; + } + + /** + * Write one vertex into the staging buffer. The base (unlit) layout is + * `x, y, z, u, v, color` — subclasses override to append per-vertex + * data matching their attribute layout. + * @ignore + */ + pushVertex(vertexData, x, y, z, u, v, color, _mesh, _i3) { + vertexData.pushMesh(x, y, z, u, v, color); + } + + /** + * Add a textured mesh to the batch (the accumulated path: vertices + * already CPU-projected, identity model matrix). Chunks triangles + * across flushes through the shared versioned-remap dedup, exactly + * like the WebGL batcher. Multi-material vertex colors ride `aColor`; + * the runtime tint stays a uniform. + * @param {object} mesh - a Mesh with vertices, uvs, indices, texture + * @param {number} tint - tint color in UINT32 (argb) format + */ + addMesh(mesh, tint) { + const vertices = mesh.vertices; + const uvs = mesh.uvs; + const indices = mesh.indices; + const vertexColors = mesh.vertexColors; + + this.updatePassState(); + this.applyMeshMaterial(mesh); + this.setPlacementUniforms(IDENTITY_MATRIX, tint, mesh); + + const maxVerts = this.vertexData.maxVertex; + const maxIndices = this.indexData.length; + + ensureRemapCapacity(mesh.vertexCount); + + let triIdx = 0; + while (triIdx < indices.length) { + const vertexData = this.vertexData; + const availVerts = maxVerts - vertexData.vertexCount; + const availIndices = maxIndices - this.indexCount; + // each triangle needs at most 3 new vertices and 3 indices + const maxTris = Math.min( + Math.floor(availVerts / 3), + Math.floor(availIndices / 3), + ); + + if (maxTris === 0) { + this.flush(); + continue; + } + + const endIdx = Math.min(triIdx + maxTris * 3, indices.length); + + const baseOffset = vertexData.vertexCount; + const chunkIndices = beginChunk(); + let localCount = 0; + + for (let j = triIdx; j < endIdx; j++) { + const origIdx = indices[j]; + let localIdx = remapIndex(origIdx); + if (localIdx === -1) { + localIdx = localCount++; + assignIndex(origIdx, localIdx); + + const i3 = origIdx * 3; + const i2 = origIdx * 2; + const vertColor = vertexColors ? vertexColors[origIdx] : 0xffffffff; + this.pushVertex( + vertexData, + vertices[i3], + vertices[i3 + 1], + vertices[i3 + 2], + uvs[i2], + uvs[i2 + 1], + vertColor, + mesh, + i3, + ); + } + chunkIndices.push(baseOffset + localIdx); + } + + // append the chunk's absolute indices to the staging array + let o = this.indexCount; + for (let i = 0; i < chunkIndices.length; i++) { + this.indexData[o++] = chunkIndices[i]; + } + this.indexCount = o; + triIdx = endIdx; + } + } + + /** + * Record the pending mesh vertices as one indexed draw: vertex bytes + * into the vertex arena, index bytes into the index arena, pipeline + * from the mesh family with the depth/cull axes and blend forced + * "none" (the GL mesh mode disables BLEND). + * @override + */ + flush() { + const vertexData = this.vertexData; + const vertexCount = vertexData.vertexCount; + const indexCount = this.indexCount; + if (vertexCount === 0 || indexCount === 0) { + return; + } + const renderer = this.renderer; + const device = this.device; + const pass = renderer.ensurePass(); + + const byteLength = vertexCount * this.stride; + const vertexRegion = renderer.vertexArena.alloc(byteLength); + device.queue.writeBuffer( + vertexRegion.buffer, + vertexRegion.offset, + vertexData.toUint8(), + 0, + byteLength, + ); + + const indexBytes = indexCount * 4; + const indexRegion = renderer.indexArena.alloc(indexBytes); + device.queue.writeBuffer( + indexRegion.buffer, + indexRegion.offset, + this.indexData.buffer, + 0, + indexBytes, + ); + + const pipeline = renderer.pipelineCache.get( + this.activeShaderKey(), + "triangle-list", + // mesh mode never blends — occlusion comes from the depth test + "none", + renderer.premultipliedAlpha, + renderer.stencilMode, + this.meshState, + ); + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + pass.setBindGroup(1, this.currentMaterial); + this.bindLights(pass); + pass.setBindGroup(3, this.uniformBinding.bindGroup, [ + this.uniformBinding.dynamicOffset, + ]); + pass.setVertexBuffer( + 0, + vertexRegion.buffer, + vertexRegion.offset, + byteLength, + ); + pass.setIndexBuffer( + indexRegion.buffer, + "uint32", + indexRegion.offset, + indexBytes, + ); + pass.drawIndexed(indexCount); + + vertexData.clear(); + this.indexCount = 0; + } + + /** + * bind group 2 — the unlit family interposes the shared empty group; + * the lit subclass overrides with its light block + * @ignore + */ + bindLights(pass) { + pass.setBindGroup(2, this.renderer.pipelineCache.emptyBindGroup); + } + + /** + * Write one mesh's model-space geometry into `out` in this batcher's + * vertex layout, returning how many floats were written — the shared + * neutral builder (`gpu/meshvertex.ts`), one copy for both backends. + * @param {object} mesh - the mesh to read geometry from + * @param {Float32Array} out - destination scratch + * @returns {number} number of floats written + * @ignore + */ + buildRetainedVertexData(mesh, out) { + return buildMeshVertexData(mesh, out, this.vertexSize); + } + + /** + * Get this mesh's retained geometry, building or refreshing it when + * the mesh's geometry version has moved on. + * @param {object} mesh - the mesh whose geometry is wanted + * @returns {WebGPURetainedGeometry} up-to-date geometry for the mesh + * @ignore + */ + retainedGeometryFor(mesh) { + let geometry = this.retained.get(mesh); + if (geometry === undefined) { + geometry = new WebGPURetainedGeometry(this.renderer); + this.retained.set(mesh, geometry); + } + const version = mesh._geometryVersion ?? 0; + if (geometry.uploadedVersion !== version) { + const scratch = retainedScratch(mesh.vertexCount * this.vertexSize); + const floats = this.buildRetainedVertexData(mesh, scratch); + geometry.upload(scratch, floats, mesh._indicesOriginal, version); + } + return geometry; + } + + /** + * Draw a mesh from its retained geometry: bind the persistent buffers + * and record one indexed draw, with placement supplied entirely by the + * per-draw uniform snapshot. Unlike {@link WebGPUMeshBatcher#addMesh} + * this accumulates nothing and never chunks — the whole mesh is one + * draw regardless of size. + * @param {object} mesh - the mesh to draw + * @param {Matrix3d} modelMatrix - where the mesh sits in the world + * @param {number} tint - tint colour in UINT32 (argb) format + * @ignore + */ + drawRetainedMesh(mesh, modelMatrix, tint) { + // anything queued must land first, or this draw would reorder + // ahead of it + this.flush(); + + this.updatePassState(); + this.applyMeshMaterial(mesh); + this.setPlacementUniforms(modelMatrix, tint, mesh); + + const renderer = this.renderer; + const pass = renderer.ensurePass(); + const geometry = this.retainedGeometryFor(mesh); + + const pipeline = renderer.pipelineCache.get( + this.activeShaderKey(), + "triangle-list", + "none", + renderer.premultipliedAlpha, + renderer.stencilMode, + this.meshState, + ); + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + pass.setBindGroup(1, this.currentMaterial); + this.bindLights(pass); + pass.setBindGroup(3, this.uniformBinding.bindGroup, [ + this.uniformBinding.dynamicOffset, + ]); + pass.setVertexBuffer(0, geometry.vertexBuffer); + pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat); + pass.drawIndexed(geometry.indexCount); + + // stamp: a version bump later this frame must go to fresh buffers + geometry.lastDrawnFrameId = renderer.frameId; + } + + /** + * Release the retained geometry held for one mesh, if any. + * @param {object} mesh - the mesh whose geometry should be freed + * @ignore + */ + releaseRetained(mesh) { + const geometry = this.retained.get(mesh); + if (geometry !== undefined) { + geometry.destroy(); + this.retained.delete(mesh); + } + } + + /** + * Release every retained geometry this batcher holds. + * @ignore + */ + releaseAllRetained() { + this.retained.forEach((geometry) => { + geometry.destroy(); + }); + this.retained.clear(); + } + + /** + * @override + */ + reset() { + super.reset(); + this.indexCount = 0; + this.currentMaterial = null; + this.customShader = null; + this.uniformBinding = null; + this.uniformBindGroups.clear(); + this.releaseAllRetained(); + } + + /** + * @override + */ + destroy() { + this.reset(); + super.destroy(); + } +} diff --git a/packages/melonjs/src/video/webgpu/batchers/primitive_batcher.js b/packages/melonjs/src/video/webgpu/batchers/primitive_batcher.js index cf941edeb8..09e72224fe 100644 --- a/packages/melonjs/src/video/webgpu/batchers/primitive_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/primitive_batcher.js @@ -1,3 +1,7 @@ +import { + expandLinesToTriangles, + pushPrimitiveRange, +} from "../../gpu/primitives.ts"; import WebGPUBatcher from "./webgpu_batcher.js"; /** @@ -166,31 +170,17 @@ export default class WebGPUPrimitiveBatcher extends WebGPUBatcher { * @ignore */ #pushRange(verts, start, end, colorUint32, z) { - const viewMatrix = this.renderer.currentTransform; - const vertexData = this.vertexData; - if (!viewMatrix.isIdentity()) { - // full 3D transform including the z column so Camera3d's view - // matrix (X/Y-axis rotation) actually rotates the primitive - const m = viewMatrix.val; - for (let i = start; i < end; i++) { - const vert = verts[i]; - const x = vert.x; - const y = vert.y; - vertexData.push( - x * m[0] + y * m[4] + z * m[8] + m[12], - x * m[1] + y * m[5] + z * m[9] + m[13], - x * m[2] + y * m[6] + z * m[10] + m[14], - 0, - 0, - colorUint32, - ); - } - } else { - for (let i = start; i < end; i++) { - const vert = verts[i]; - vertexData.push(vert.x, vert.y, z, 0, 0, colorUint32); - } - } + // the shared neutral range push (z-column-aware transform) — one + // copy for both backends, see `gpu/primitives.ts` + pushPrimitiveRange( + this.vertexData, + this.renderer.currentTransform, + verts, + start, + end, + colorUint32, + z, + ); } /** @@ -262,74 +252,23 @@ export default class WebGPUPrimitiveBatcher extends WebGPUBatcher { * @ignore */ #expandLinesToTriangles(verts, vertexCount) { - const renderer = this.renderer; - const viewMatrix = renderer.currentTransform; - const vertexData = this.vertexData; - const colorUint32 = renderer.currentColor.toUint32( - renderer.getGlobalAlpha(), - ); - const hasTransform = !viewMatrix.isIdentity(); - const z = renderer.currentDepth; - - // switch to triangle-list topology + // switch to triangle-list topology, then delegate the expansion to + // the shared neutral helper — see `gpu/primitives.ts` if (this.topology !== "triangle-list") { this.flush(this.topology); this.topology = "triangle-list"; } - - const m = hasTransform ? viewMatrix.val : null; - - for (let i = 0; i < vertexCount; i += 2) { - const from = verts[i]; - const to = verts[i + 1]; - - // each line pair expands to 2 triangles (6 vertices) — check - // capacity per pair so an over-capacity path flushes mid-shape - // instead of silently dropping writes - if (vertexData.isFull(6)) { + const renderer = this.renderer; + expandLinesToTriangles( + this.vertexData, + renderer.currentTransform, + verts, + vertexCount, + renderer.currentColor.toUint32(renderer.getGlobalAlpha()), + renderer.currentDepth, + () => { this.flush(); - } - - // apply the view matrix (z column included) without mutating - // the inputs. The perpendicular normal stays in pre-projection - // space — same known limitation as the GL backend. - let fromX, fromY, fromZ, toX, toY, toZ; - if (hasTransform) { - fromX = from.x * m[0] + from.y * m[4] + z * m[8] + m[12]; - fromY = from.x * m[1] + from.y * m[5] + z * m[9] + m[13]; - fromZ = from.x * m[2] + from.y * m[6] + z * m[10] + m[14]; - toX = to.x * m[0] + to.y * m[4] + z * m[8] + m[12]; - toY = to.x * m[1] + to.y * m[5] + z * m[9] + m[13]; - toZ = to.x * m[2] + to.y * m[6] + z * m[10] + m[14]; - } else { - fromX = from.x; - fromY = from.y; - fromZ = z; - toX = to.x; - toY = to.y; - toZ = z; - } - - // compute perpendicular unit normal - const dx = toX - fromX; - const dy = toY - fromY; - const len = Math.sqrt(dx * dx + dy * dy); - - if (len === 0) { - continue; - } - - const nx = -dy / len; - const ny = dx / len; - - // two triangles forming a quad around the line segment - vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32); - vertexData.push(fromX, fromY, fromZ, -nx, -ny, colorUint32); - vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32); - - vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32); - vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32); - vertexData.push(toX, toY, toZ, nx, ny, colorUint32); - } + }, + ); } } diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index d58ff8b17f..2e5db9e051 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -1,20 +1,9 @@ -import { Vector3d } from "../../../math/vector3d.ts"; import IndexBuffer from "../../buffer/index.js"; +import { transformQuadCorners } from "../../gpu/quadcorners.ts"; import { prepareEffectBinding } from "../effect_binding.js"; +import { MAX_QUAD_TEXTURES } from "../pipeline/cache.js"; import WebGPUBatcher from "./webgpu_batcher.js"; -// a pool of reusable vectors used by `addQuad` to transform the four quad -// corners — Vector3d so the per-sprite depth on `z` flows through -// `Matrix3d.apply` (same rationale as the WebGL QuadBatcher's pool; -// duplicated rather than imported so the WebGPU tier never pulls the -// GL batcher chain into its module graph) -const V_ARRAY = [ - new Vector3d(), - new Vector3d(), - new Vector3d(), - new Vector3d(), -]; - /** * The WebGPU quad batcher — textured-quad accumulation with the same * frozen 28-byte vertex layout as the WebGL `QuadBatcher` and the same @@ -69,7 +58,9 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { }, ); - // the material bind group the pending vertices were queued under + // the material bind group the pending vertices were queued under — + // FAST-PATH state only: effect pipelines bind a single-source + // material, while the normal path batches across texture slots this.currentMaterial = null; // the ShaderEffect the pending vertices were queued under — the @@ -78,6 +69,25 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // the backdrop (no offscreen target) this.currentEffect = null; + // multi-texture segment state: up to MAX_QUAD_TEXTURES distinct + // (texture view, sampler) pairs share one draw segment, selected + // per quad by aTextureId — a flush is forced only by the NINTH + // distinct texture (or the usual capacity/effect boundaries) + /** @type {Map} slot key → slot index */ + this.segmentKeys = new Map(); + /** @type {{view: GPUTextureView, sampler: GPUSampler}[]} */ + this.segmentEntries = []; + // the composed group-1 bind group for the pending segment (lazy) + this.segmentGroup = null; + // composed bind groups cached by their slot-resource identity — + // steady-state segments re-use instead of re-creating (the + // litMaterials precedent; cleared on reset and filter changes) + /** @type {Map} */ + this.composedGroups = new Map(); + // monotonic ids for views/samplers, for composition cache keys + this.resourceIds = new WeakMap(); + this.nextResourceId = 1; + // static index buffer: 6 indices per 4 vertices, filled once by the // renderer-agnostic CPU pattern and uploaded at creation const maxQuads = this.vertexData.maxVertex / 4; @@ -134,19 +144,18 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { if (effect._screenTextureUniforms?.length > 0) { renderer.captureFrame(); } - } - // single-texture batching: adopt the quad's material, flushing the - // vertices queued under the previous one - const bindGroup = renderer.textureStore.getBinding(texture, { - force: reupload, - }); - if (bindGroup !== this.currentMaterial) { - this.flush(); - this.currentMaterial = bindGroup; - } + // the fast path stays single-texture: effect pipelines bind a + // single-source material at group 1, and each quad flushes on + // its own anyway + const bindGroup = renderer.textureStore.getBinding(texture, { + force: reupload, + }); + if (bindGroup !== this.currentMaterial) { + this.flush(); + this.currentMaterial = bindGroup; + } - if (effect !== null) { // feed the effect's `noise_uv` builtin with this quad's frame // rect — min() normalizes flipped (swapped) UVs const source = texture.getTexture(); @@ -158,16 +167,141 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { Math.min(u0, u1), Math.min(v0, v1), ); - } - this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint); + this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, 0); - if (effect !== null) { // per-quad draw under the fast path: each sprite needs its own // capture state, noise rect and uniform snapshot (draw-time // setUniform mutation included) this.flush(); + return; } + + // multi-texture batching: resolve the quad's texture to a segment + // slot, flushing only when an over-capacity NINTH texture appears + this.pushQuadVertices( + x, + y, + w, + h, + u0, + v0, + u1, + v1, + tint, + this.segmentSlotFor(texture, reupload), + ); + } + + /** + * Resolve a texture to its slot in the pending segment, claiming a new + * slot (and flushing a full segment) when this (view, sampler) pair is + * new. The upload rules — same-frame content changes, recycled units, + * forced video re-uploads — all live in the store's resident-record + * path, exactly as before. + * @param {object} texture - the texture atlas to resolve + * @param {boolean} reupload - force the source pixels to re-upload + * @returns {number} the slot index written to aTextureId + * @ignore + */ + segmentSlotFor(texture, reupload) { + const renderer = this.renderer; + const store = renderer.textureStore; + const record = store.getResidentRecord(texture, { force: reupload }); + const wrap = texture.repeat ?? "no-repeat"; + const filter = + typeof texture.filter === "string" + ? texture.filter + : renderer.getDefaultTextureFilter(); + const slotKey = `${this.resourceId(record.view)}|${filter}|${wrap}`; + let slot = this.segmentKeys.get(slotKey); + if (typeof slot === "undefined") { + if (this.segmentEntries.length >= MAX_QUAD_TEXTURES) { + // segment at capacity — the pending quads draw with THEIR + // eight textures, and this quad starts the next segment + this.flush(); + } + slot = this.segmentEntries.length; + this.segmentEntries.push({ + view: record.view, + sampler: store.getSampler(filter, wrap), + }); + this.segmentKeys.set(slotKey, slot); + this.segmentGroup = null; + } + return slot; + } + + /** + * a stable id for a GPU resource object (bind-group composition keys) + * @ignore + */ + resourceId(resource) { + let id = this.resourceIds.get(resource); + if (typeof id === "undefined") { + id = this.nextResourceId++; + this.resourceIds.set(resource, id); + } + return id; + } + + /** + * The composed group-1 bind group for the pending segment: the claimed + * slots, with empty slots padded by slot 0 (every declared binding + * needs a resource; the padding is never selected). Cached by the slot + * resources' identity, so steady-state segments re-use one group. + * @returns {GPUBindGroup} the segment's material bind group + * @ignore + */ + composeSegmentGroup() { + if (this.segmentGroup !== null) { + return this.segmentGroup; + } + const entries = this.segmentEntries; + const first = entries[0]; + const groupEntries = []; + let key = ""; + for (let slot = 0; slot < MAX_QUAD_TEXTURES; slot++) { + const entry = entries[slot] ?? first; + key += `${this.resourceId(entry.view)}.${this.resourceId(entry.sampler)}|`; + groupEntries.push({ binding: slot, resource: entry.view }); + groupEntries.push({ + binding: MAX_QUAD_TEXTURES + slot, + resource: entry.sampler, + }); + } + let group = this.composedGroups.get(key); + if (typeof group === "undefined") { + group = this.device.createBindGroup({ + label: "melonJS quad materials", + layout: this.renderer.pipelineCache.multiMaterialLayout, + entries: groupEntries, + }); + this.composedGroups.set(key, group); + } + this.segmentGroup = group; + return group; + } + + /** + * start the next segment fresh (the pending one was just recorded) + * @ignore + */ + resetSegment() { + this.segmentKeys.clear(); + this.segmentEntries.length = 0; + this.segmentGroup = null; + } + + /** + * Drop every composed segment bind group — each embeds samplers + * resolved from the default texture filter, so a filter change must + * rebuild them (the multi-texture counterpart of the texture store's + * invalidateBindGroups). + */ + clearMaterialCache() { + this.composedGroups.clear(); + this.resetSegment(); } /** @@ -175,32 +309,27 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * and lit addQuad paths. Stamps per-sprite depth onto z BEFORE * `m.apply` so Camera3d's view matrix (3D R⁻¹ ∘ T(-pos)) fully rotates * the vertex; for 2D-only matrices the z column is identity, so the - * output (x, y) is bit-identical and z passes through. textureId is 0 - * under single-texture batching (layout kept for the multi-texture - * upgrade). + * output (x, y) is bit-identical and z passes through. textureId is the + * quad's segment slot (constant across its four corners); the lit and + * fast paths pass 0 (single-texture bind groups). * @ignore */ - pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint) { + pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, textureId = 0) { const vertexData = this.vertexData; - const m = this.renderer.currentTransform; - const z = this.renderer.currentDepth; - const vec0 = V_ARRAY[0].set(x, y, z); - const vec1 = V_ARRAY[1].set(x + w, y, z); - const vec2 = V_ARRAY[2].set(x, y + h, z); - const vec3 = V_ARRAY[3].set(x + w, y + h, z); - - if (!m.isIdentity()) { - m.apply(vec0); - m.apply(vec1); - m.apply(vec2); - m.apply(vec3); - } + const [vec0, vec1, vec2, vec3] = transformQuadCorners( + this.renderer.currentTransform, + x, + y, + w, + h, + this.renderer.currentDepth, + ); // 4 vertices per quad; the index buffer provides the 6 indices - vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, 0); - vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, 0); - vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, 0); - vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, 0); + vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, textureId); + vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, textureId); + vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, textureId); + vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, textureId); } /** @@ -232,7 +361,9 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { const pass = renderer.ensurePass(); const pipeline = renderer.pipelineCache.get( - binding?.key ?? "quad", + // no effect → the single-texture blit family (the quad family's + // group 1 is the eight-slot segment layout) + binding?.key ?? "blit", "triangle-list", keepBlend ? renderer.currentBlendMode : "none", renderer.premultipliedAlpha, @@ -275,13 +406,20 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { } /** - * indexed draw: 6 indices per 4 queued vertices, region-relative + * indexed draw: 6 indices per 4 queued vertices, region-relative. The + * fast path binds its single-source material; the normal path binds + * the composed segment (up to eight textures). * @param {GPURenderPassEncoder} pass - the open pass * @param {number} vertexCount - pending vertex count * @override */ recordDraw(pass, vertexCount) { - pass.setBindGroup(1, this.currentMaterial); + pass.setBindGroup( + 1, + this.currentEffect !== null + ? this.currentMaterial + : this.composeSegmentGroup(), + ); pass.setIndexBuffer(this.indexBuffer, "uint32"); pass.drawIndexed((vertexCount / 4) * 6); } @@ -290,13 +428,13 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * @override */ flush(topology) { - if (this.currentMaterial === null) { - // nothing was ever queued under a material this frame - this.vertexData.clear(); - return; - } const effect = this.currentEffect; if (effect !== null && this.vertexData.vertexCount > 0) { + if (this.currentMaterial === null) { + // defensive: fast-path vertices with no adopted material + this.vertexData.clear(); + return; + } // fast-path draw: same recording as the base flush, through the // effect's pipeline family with its group-3 binding. An effect // without a WGSL realization draws plain (graceful, GL-parity @@ -307,7 +445,27 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { return; } } + if ( + this.vertexData.vertexCount > 0 && + this.hasPendingMaterial() === false + ) { + // defensive: pending vertices but no material was ever claimed + // (out-of-contract pushes) — recording would bind nothing valid + this.vertexData.clear(); + return; + } super.flush(topology); + this.resetSegment(); + } + + /** + * whether the pending vertices have a material to draw with — the lit + * subclass overrides (its material model is the combined color+normal + * group, not the segment slots) + * @ignore + */ + hasPendingMaterial() { + return this.segmentEntries.length > 0; } /** @@ -361,6 +519,9 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { reset() { super.reset(); this.currentEffect = null; + this.currentMaterial = null; + this.resetSegment(); + this.composedGroups.clear(); } /** @@ -370,6 +531,8 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { this.indexBuffer?.destroy(); this.indexBuffer = null; this.currentMaterial = null; + this.resetSegment(); + this.composedGroups.clear(); super.destroy(); } } diff --git a/packages/melonjs/src/video/webgpu/buffer/retained_geometry.js b/packages/melonjs/src/video/webgpu/buffer/retained_geometry.js new file mode 100644 index 0000000000..3658e7aa88 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/buffer/retained_geometry.js @@ -0,0 +1,144 @@ +// Scratch for padding an odd-count uint16 index upload to the 4-byte +// multiple `queue.writeBuffer` requires. Grows to the largest mesh seen and +// is reused (uploads are version-change-rare and never re-enter). +let indexPadScratch = new Uint16Array(0); + +/** + * The WebGPU twin of the GL `RetainedGeometry`: one mesh's model-space + * geometry resident on the GPU — a vertex buffer and an index buffer, + * uploaded on geometry-version change and bound per draw (`setVertexBuffer` + * / `setIndexBuffer`; there is no VAO to capture, binding is explicit and + * cheap under this backend). + * + * The two queue-ordering laws shape the upload rules: + * 1. `queue.writeBuffer` executes before EVERY draw recorded this frame — + * so a version bump on a mesh that has already drawn this frame + * (Sprite3d animation UVs, a second camera) must land in FRESH buffers, + * or the earlier draw would retroactively show the new geometry. + * 2. destroying a buffer referenced by recorded draws fails the whole + * submit — replaced buffers retire through `renderer.retireBuffer` and + * die after the frame's submit. + * @ignore + */ +export default class WebGPURetainedGeometry { + /** + * @param {import("../webgpu_renderer.js").default} renderer - the owning renderer + */ + constructor(renderer) { + this.renderer = renderer; + /** @type {GPUBuffer|null} */ + this.vertexBuffer = null; + /** @type {GPUBuffer|null} */ + this.indexBuffer = null; + /** @type {"uint16"|"uint32"} */ + this.indexFormat = "uint16"; + this.indexCount = 0; + // geometry version the buffers hold; -1 forces the first upload + this.uploadedVersion = -1; + // the last frame a draw was recorded against these buffers — the + // in-place-vs-fresh upload decision (queue law 1) + this.lastDrawnFrameId = -1; + // allocated capacities, for the in-place reuse check + this.vertexCapacity = 0; + this.indexCapacity = 0; + } + + /** + * Upload (or re-upload) the interleaved vertex data and indices. + * @param {Float32Array} vertexData - interleaved data scratch + * @param {number} floatCount - number of floats to upload + * @param {Uint16Array|Uint32Array} indices - the mesh's authored indices + * @param {number} version - the mesh's geometry version being uploaded + */ + upload(vertexData, floatCount, indices, version) { + const renderer = this.renderer; + const device = renderer.device; + const vertexBytes = floatCount * Float32Array.BYTES_PER_ELEMENT; + const wide = indices instanceof Uint32Array; + // writeBuffer sizes must be 4-byte multiples — pad an odd uint16 run + const indexBytes = (indices.byteLength + 3) & ~3; + + // in-place re-use is safe only when nothing recorded this frame + // references the buffers and the new data fits the allocations + const fresh = + this.vertexBuffer === null || + this.lastDrawnFrameId === renderer.frameId || + vertexBytes > this.vertexCapacity || + indexBytes > this.indexCapacity; + if (fresh) { + this.releaseBuffers(); + this.vertexBuffer = device.createBuffer({ + label: "melonJS retained mesh vertices", + size: vertexBytes, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + this.indexBuffer = device.createBuffer({ + label: "melonJS retained mesh indices", + size: indexBytes, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }); + this.vertexCapacity = vertexBytes; + this.indexCapacity = indexBytes; + this.lastDrawnFrameId = -1; + } + + device.queue.writeBuffer( + this.vertexBuffer, + 0, + vertexData.buffer, + vertexData.byteOffset, + vertexBytes, + ); + + let indexSource = indices; + if (indexBytes !== indices.byteLength) { + // odd uint16 count: stage through the padded scratch + if (indexPadScratch.length < indices.length + 1) { + indexPadScratch = new Uint16Array(indices.length + 1); + } + indexPadScratch.set(indices); + indexPadScratch[indices.length] = 0; + indexSource = indexPadScratch; + } + device.queue.writeBuffer( + this.indexBuffer, + 0, + indexSource.buffer, + indexSource.byteOffset, + indexBytes, + ); + + this.indexFormat = wide ? "uint32" : "uint16"; + this.indexCount = indices.length; + this.uploadedVersion = version; + } + + /** + * retire both buffers (frame-safe) without resetting the version — the + * shared tail of re-allocation and destruction + * @ignore + */ + releaseBuffers() { + if (this.vertexBuffer !== null) { + this.renderer.retireBuffer(this.vertexBuffer); + this.vertexBuffer = null; + } + if (this.indexBuffer !== null) { + this.renderer.retireBuffer(this.indexBuffer); + this.indexBuffer = null; + } + this.vertexCapacity = 0; + this.indexCapacity = 0; + } + + /** + * Release the GPU buffers. Idempotent — a destroyed geometry object + * simply re-uploads if ever used again. + */ + destroy() { + this.releaseBuffers(); + this.uploadedVersion = -1; + this.indexCount = 0; + this.lastDrawnFrameId = -1; + } +} diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js index 51a940e66b..4bfabe59dc 100644 --- a/packages/melonjs/src/video/webgpu/pipeline/cache.js +++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js @@ -1,8 +1,19 @@ +import blitWGSL from "../shaders/blit.wgsl"; import clearWGSL from "../shaders/clear.wgsl"; import primitiveWGSL from "../shaders/primitive.wgsl"; import quadWGSL from "../shaders/quad.wgsl"; import { CLEAR_UNIFORM_SIZE, FRAME_UNIFORM_SIZE } from "./bindgroups.js"; +/** + * Texture slots per quad draw segment — the quad family batches quads + * across up to this many distinct textures before a flush is forced (the + * GL sampler-ladder equivalent). 8 texture + 8 sampler bindings stays + * comfortably inside WebGPU's base limits (16 sampled textures / 16 + * samplers per stage) with headroom for the effect families' samplers. + * @ignore + */ +export const MAX_QUAD_TEXTURES = 8; + /** * The depth-stencil attachment format every pass and every pipeline of this * backend declares. Attached from day one — masks use the stencil half now, @@ -228,6 +239,26 @@ export default class WebGPUPipelineCache { { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, ], }); + // the quad family's group 1: eight texture slots (bindings 0-7) and + // their samplers (8-15) — one draw segment spans up to eight + // distinct textures, selected per quad by aTextureId + const multiEntries = []; + for (let slot = 0; slot < MAX_QUAD_TEXTURES; slot++) { + multiEntries.push({ + binding: slot, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }); + multiEntries.push({ + binding: MAX_QUAD_TEXTURES + slot, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }); + } + this.multiMaterialLayout = device.createBindGroupLayout({ + label: "melonJS quad materials layout", + entries: multiEntries, + }); // ---- shader modules ---------------------------------------------- this.modules = { @@ -235,6 +266,12 @@ export default class WebGPUPipelineCache { label: "melonJS quad shader", code: quadWGSL, }), + // the single-texture compositing family (render-target blits) — + // same group-1 shape as the effect scaffold + blit: device.createShaderModule({ + label: "melonJS blit shader", + code: blitWGSL, + }), primitive: device.createShaderModule({ label: "melonJS primitive shader", code: primitiveWGSL, @@ -248,6 +285,9 @@ export default class WebGPUPipelineCache { // ---- pipeline layouts -------------------------------------------- this.pipelineLayouts = { quad: device.createPipelineLayout({ + bindGroupLayouts: [this.frameLayout, this.multiMaterialLayout], + }), + blit: device.createPipelineLayout({ bindGroupLayouts: [this.frameLayout, this.materialLayout], }), primitive: device.createPipelineLayout({ @@ -265,6 +305,8 @@ export default class WebGPUPipelineCache { this.registeredModules = new Map(); /** @type {Map} family key → vertex-layout alias */ this.vertexLayoutAliases = new Map(); + // the blit family rides the frozen quad vertex layout + this.vertexLayoutAliases.set("blit", "quad"); /** @type {Map} shape signature → group-3 layout */ this.effectLayouts = new Map(); @@ -383,6 +425,12 @@ export default class WebGPUPipelineCache { * @param {string} blendMode - blend mode (normalized internally) * @param {boolean} premultipliedAlpha - source premultiplication flag * @param {string} [stencilMode="none"] - "none" | "write" | "test" | "tag" | "mark" + * @param {{cullMode: string, frontFace: string}} [meshState] - mesh pass + * state: its presence switches the depth half of the attachment on — + * depth writes enabled, "less-equal" testing (the GL mesh mode's + * LEQUAL, keeping coplanar geometry stable) — and sets the per-mesh + * face-culling axes. Omitted by the whole 2D tier, whose keys and + * descriptors stay byte-identical to a build without a mesh path. * @returns {GPURenderPipeline} the pipeline */ get( @@ -391,10 +439,14 @@ export default class WebGPUPipelineCache { blendMode, premultipliedAlpha, stencilMode = "none", + meshState, ) { const blend = normalizeBlendMode(blendMode); const pma = premultipliedAlpha !== false; - const key = `${shaderKey}|${topology}|${blend}|${pma ? 1 : 0}|${stencilMode}|${this.format}|${this.sampleCount}`; + let key = `${shaderKey}|${topology}|${blend}|${pma ? 1 : 0}|${stencilMode}|${this.format}|${this.sampleCount}`; + if (meshState) { + key += `|mesh:${meshState.cullMode}:${meshState.frontFace}`; + } let pipeline = this.pipelines.get(key); if (typeof pipeline === "undefined") { const stencil = STENCIL_STATES[stencilMode] ?? STENCIL_STATES.none; @@ -403,6 +455,14 @@ export default class WebGPUPipelineCache { const vertexLayout = this.vertexLayouts.get( this.vertexLayoutAliases.get(shaderKey) ?? shaderKey, ); + const primitive = { + topology, + stripIndexFormat: topology.endsWith("-strip") ? "uint32" : undefined, + }; + if (meshState) { + primitive.cullMode = meshState.cullMode; + primitive.frontFace = meshState.frontFace; + } pipeline = this.device.createRenderPipeline({ label: `melonJS ${key}`, layout: this.pipelineLayouts[shaderKey], @@ -423,14 +483,11 @@ export default class WebGPUPipelineCache { }, ], }, - primitive: { - topology, - stripIndexFormat: topology.endsWith("-strip") ? "uint32" : undefined, - }, + primitive, depthStencil: { format: DEPTH_STENCIL_FORMAT, - depthWriteEnabled: false, - depthCompare: "always", + depthWriteEnabled: !!meshState, + depthCompare: meshState ? "less-equal" : "always", stencilFront: stencil.stencil, stencilBack: stencil.stencil, stencilReadMask: stencil.readMask, diff --git a/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js b/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js index a94cf6ffa0..4ad800e824 100644 --- a/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js +++ b/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js @@ -1,4 +1,4 @@ -import { Vector3d } from "../../../../math/vector3d.ts"; +import { transformQuadCorners } from "../../../gpu/quadcorners.ts"; import tmxLayerWGSL from "../../shaders/tmxlayer.wgsl"; /** @@ -15,15 +15,6 @@ import tmxLayerWGSL from "../../shaders/tmxlayer.wgsl"; */ const TMX_UNIFORM_SIZE = 112; -// scratch vectors for the CPU corner transform (same rationale as the -// quad batcher's pool: per-sprite depth flows through Matrix3d.apply) -const V_ARRAY = [ - new Vector3d(), - new Vector3d(), - new Vector3d(), - new Vector3d(), -]; - /** * GPU-accelerated renderer for orthogonal TMX tile layers on the WebGPU * backend — the WGSL realization of the WebGL shader tile path: the @@ -264,20 +255,14 @@ export default class OrthogonalTMXLayerGPURenderer { // the transformed quad, shared by every tileset pass (28-byte // stride: x,y,z, u,v, packed tint, textureId) - const m = renderer.currentTransform; - const z = renderer.currentDepth; - const identity = m.isIdentity(); - const corners = [ - V_ARRAY[0].set(worldX, worldY, z), - V_ARRAY[1].set(worldX + worldW, worldY, z), - V_ARRAY[2].set(worldX, worldY + worldH, z), - V_ARRAY[3].set(worldX + worldW, worldY + worldH, z), - ]; - if (!identity) { - for (const corner of corners) { - m.apply(corner); - } - } + const corners = transformQuadCorners( + renderer.currentTransform, + worldX, + worldY, + worldW, + worldH, + renderer.currentDepth, + ); const f32 = this.vertexF32; const u32 = this.vertexU32; const uvs = [0, 0, 1, 0, 0, 1, 1, 1]; diff --git a/packages/melonjs/src/video/webgpu/shaders/blit.wgsl b/packages/melonjs/src/video/webgpu/shaders/blit.wgsl new file mode 100644 index 0000000000..8c288c93ac --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/blit.wgsl @@ -0,0 +1,55 @@ +// melonJS WebGPU blit shader — one source texture over the frozen quad +// vertex layout. The compositing primitive: render-target blits and the +// no-effect post-FX composite ride this family, which keeps the +// single-texture group-1 shape the effect scaffold shares (the QUAD family +// batches across eight textures and has its own group-1 layout). +// +// Vertex layout (28-byte stride, frozen, shared with the WebGL backend): +// @location(0) aVertex float32x3 offset 0 +// @location(1) aRegion float32x2 offset 12 +// @location(2) aColor unorm8x4 offset 20 +// @location(3) aTextureId float32 offset 24 (unused here) +// +// aColor arrives from a packed ARGB uint32 (Color.toUint32). Little-endian +// memory bytes are [B,G,R,A] and unorm8x4 maps byte i -> component i, so the +// attribute reads (B,G,R,A) — the same .bgr swizzle as the GLSL sources +// reconstructs RGB, premultiplied by alpha. + +struct FrameUniforms { + projection : mat4x4, + // unused by this shader; part of the shared frame-globals block + lineWidth : f32, +}; + +@group(0) @binding(0) var uFrame : FrameUniforms; +@group(1) @binding(0) var uTexture : texture_2d; +@group(1) @binding(1) var uSampler : sampler; + +struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + @location(1) vColor : vec4f, +}; + +@vertex +fn vertex_main( + @location(0) aVertex : vec3f, + @location(1) aRegion : vec2f, + @location(2) aColor : vec4f, + @location(3) aTextureId : f32, +) -> VSOut { + var out : VSOut; + let clip = uFrame.projection * vec4f(aVertex, 1.0); + // Matrix3d.ortho/perspective emit GL-convention clip z in [-w, w]; + // WebGPU clips z outside [0, w]. Without this remap any vertex with a + // non-zero depth (Sprite3d, renderable.depth) is silently clipped away. + out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w); + out.vColor = vec4f(aColor.bgr * aColor.a, aColor.a); + out.vRegion = aRegion; + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + return textureSample(uTexture, uSampler, in.vRegion) * in.vColor; +} diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl new file mode 100644 index 0000000000..59a76e157e --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -0,0 +1,147 @@ +// melonJS WebGPU lit mesh shader — the WGSL port of mesh-lit.vert / +// mesh-lit.frag: half-Lambert diffuse from directional Light3d lights plus +// an ambient floor, over the mesh tier's uniform-driven placement. +// +// Vertex layout (48-byte stride, shared with the WebGL backend): the +// 36-byte unlit layout plus +// @location(3) aNormal float32x3 offset 36 +// aNormal is MODEL-space on the retained path (rotated below by the model +// matrix's upper 3×3) and world-space on the accumulated path — which +// supplies an identity model matrix, so one shader serves both. +// +// The std140 Light3dBlock binds at group 2 with a dynamic offset — one +// snapshot per light change per frame, per the queue-write ordering law. +// Its layout is written by src/video/webgl/lighting/std140.ts and the two +// must agree byte for byte — a mismatch does not fail validation, it +// silently shifts every light. + +struct FrameUniforms { + projection : mat4x4, + // unused by this shader; part of the shared frame-globals block + lineWidth : f32, +}; + +struct MeshUniforms { + model : mat4x4, + view : mat4x4, + tint : vec4f, + // x = alpha cutout threshold (0 = disabled); y, z, w reserved + params : vec4f, + // self-illumination added AFTER lighting (r, g, b; w reserved) + emissive : vec4f, +}; + +// One light, type inferred from sentinels (see std140.ts): +// posRange.w < 0 → directional (dirCone.xyz = surface→light, normalized); +// otherwise positional at posRange.xyz with quadratic falloff over +// posRange.w, plus a spot cone when dirCone.w > -1 (dirCone.xyz = the +// cone axis / travel direction, w = cos(outer), colorInner.w = +// cos(inner)). colorInner.rgb is premultiplied by intensity. +struct Light3dData { + posRange : vec4f, + dirCone : vec4f, + colorInner : vec4f, +}; + +// header = count + pad (vec4), ambient rgb + pad (vec4), then 32 lights +// of 3 vec4 each — 1568 bytes total (BLOCK3D_BYTES) +struct Light3dBlock { + countPad : vec4f, + ambient : vec4f, + lights : array, +}; + +@group(0) @binding(0) var uFrame : FrameUniforms; +@group(1) @binding(0) var uTexture : texture_2d; +@group(1) @binding(1) var uSampler : sampler; +@group(2) @binding(0) var uLights : Light3dBlock; +@group(3) @binding(0) var uMesh : MeshUniforms; + +struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + @location(1) vColor : vec4f, + @location(2) vNormal : vec3f, + // world-space fragment position — positional lights (point / spot) + // fall off with distance, so the fragment stage needs where the + // surface IS + @location(3) vWorldPos : vec3f, +}; + +@vertex +fn vertex_main( + @location(0) aVertex : vec3f, + @location(1) aRegion : vec2f, + @location(2) aColor : vec4f, + @location(3) aNormal : vec3f, +) -> VSOut { + var out : VSOut; + let worldPos = uMesh.model * vec4f(aVertex, 1.0); + let clip = uFrame.projection * uMesh.view * worldPos; + // GL-convention clip z in [-w, w] remapped to WebGPU's [0, w] + out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w); + let tinted = aColor * uMesh.tint; + out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); + out.vRegion = aRegion; + out.vWorldPos = worldPos.xyz; + // Rotate the normal into world space with the model matrix's upper + // 3×3. Lighting is evaluated in world space, so the view transform is + // deliberately excluded. Uniform scale cancels when the fragment + // renormalizes; non-uniform scale is approximated — an exact result + // would need the inverse-transpose. + let m = uMesh.model; + out.vNormal = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz) * aNormal; + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + // sampled unconditionally, before the discard (uniform control flow) + let base = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + // hard alpha cutout (glTF alphaMode MASK) — discard before any shading + // so cut-away texels cost nothing and never write depth + if (base.a < uMesh.params.x) { + discard; + } + + let n = normalize(in.vNormal); + var lit = uLights.ambient.rgb; + // clamped to the array size, not just taken on trust: if the block + // ever read as something other than what the writer put there, an + // unbounded trip count would also index out of range + let count = min(i32(uLights.countPad.x), 32); + for (var i = 0; i < count; i = i + 1) { + let pr = uLights.lights[i].posRange; + let dc = uLights.lights[i].dirCone; + var lightDir : vec3f; + var atten = 1.0; + if (pr.w < 0.0) { + // directional: dirCone.xyz is already the surface→light vector + lightDir = dc.xyz; + } else { + // positional: quadratic falloff over range (the Light2d model — + // stylized, not physical inverse-square, which is unusable in + // pixel-unit worlds with unit intensities) + let toLight = pr.xyz - in.vWorldPos; + let dist = max(length(toLight), 1e-4); + lightDir = toLight / dist; + let linearAtt = max(0.0, 1.0 - dist / pr.w); + atten = linearAtt * linearAtt; + if (dc.w > -1.0) { + // spot cone: -lightDir is the light→surface direction; fade + // from the inner cone cosine to the outer + let cd = dot(-lightDir, dc.xyz); + atten = atten * smoothstep(dc.w, uLights.lights[i].colorInner.w, cd); + } + } + // Half-Lambert ("wrap") diffuse: dot * 0.5 + 0.5, squared. Softens + // the terminator and lifts the shadowed side — gentler than hard + // Lambert, which reads as harsh noon. + let ndl = dot(n, lightDir) * 0.5 + 0.5; + lit = lit + uLights.lights[i].colorInner.rgb * (ndl * ndl * atten); + } + + // emissive self-illuminates: added AFTER lighting so it glows at full + // strength regardless of the scene lights (neon, lava, glowing eyes) + return vec4f(base.rgb * lit + uMesh.emissive.rgb, base.a); +} diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl new file mode 100644 index 0000000000..e7c77f07e7 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl @@ -0,0 +1,81 @@ +// melonJS WebGPU mesh shader — the WGSL port of mesh.vert / mesh.frag. +// +// Vertex layout (36-byte stride, shared with the WebGL backend): +// @location(0) aVertex float32x3 offset 0 +// @location(1) aRegion float32x2 offset 12 +// @location(2) aColor float32x4 offset 20 +// +// aColor is four straight floats (R, G, B, A in [0, 1]) — NOT the quad +// tier's packed unorm8x4 — so no BGRA swizzle applies. The float layout is +// shared with the GL backend, where it exists to dodge NaN-pattern +// canonicalization on Metal-backed drivers. +// +// Placement is uniform-driven so retained geometry uploads once and reuses: +// clip = projection × view × model × vertex +// One MeshUniforms snapshot binds per draw (group 3, dynamic offset — the +// queue-write ordering law: a shared region would be retroactively +// clobbered for draws already recorded). The accumulated path (2D camera, +// CPU-projected vertices) supplies an identity model matrix. + +struct FrameUniforms { + projection : mat4x4, + // unused by this shader; part of the shared frame-globals block + lineWidth : f32, +}; + +struct MeshUniforms { + // the mesh's own placement (axis bridge + mesh scale included) + model : mat4x4, + // the camera view plus any ancestor container transform + view : mat4x4, + // per-draw tint × global alpha (r, g, b, a) — kept out of the vertex + // data so re-tinting never invalidates retained geometry + tint : vec4f, + // x = alpha cutout threshold (0 = disabled); y, z, w reserved + params : vec4f, + // self-illumination added on top (r, g, b; w reserved) + emissive : vec4f, +}; + +@group(0) @binding(0) var uFrame : FrameUniforms; +@group(1) @binding(0) var uTexture : texture_2d; +@group(1) @binding(1) var uSampler : sampler; +@group(3) @binding(0) var uMesh : MeshUniforms; + +struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + @location(1) vColor : vec4f, +}; + +@vertex +fn vertex_main( + @location(0) aVertex : vec3f, + @location(1) aRegion : vec2f, + @location(2) aColor : vec4f, +) -> VSOut { + var out : VSOut; + let clip = + uFrame.projection * uMesh.view * uMesh.model * vec4f(aVertex, 1.0); + // GL-convention clip z in [-w, w] remapped to WebGPU's [0, w] + out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w); + // tint first, then premultiply — matches the fragment's expectation + let tinted = aColor * uMesh.tint; + out.vColor = vec4f(tinted.rgb * tinted.a, tinted.a); + out.vRegion = aRegion; + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + // sampled unconditionally, before the discard (uniform control flow) + let color = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + // hard alpha cutout (glTF alphaMode MASK): drop cut texels so foliage / + // fences / decals read crisp without blending or sorting + if (color.a < uMesh.params.x) { + discard; + } + // emissive adds a self-lit color on top (neon, lava, screens); the + // unlit path has no lighting, so it is simply added to the base color + return vec4f(color.rgb + uMesh.emissive.rgb, color.a); +} diff --git a/packages/melonjs/src/video/webgpu/shaders/mipblit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mipblit.wgsl new file mode 100644 index 0000000000..4ce948d6f0 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/mipblit.wgsl @@ -0,0 +1,30 @@ +// melonJS WebGPU mip-chain blit — renders mip level N-1 into level N with +// linear filtering, one fullscreen triangle per level. Used by the texture +// store's mipmap generation (mesh-path textures); runs in its own encoder +// outside the frame's pass. + +@group(0) @binding(0) var srcTexture : texture_2d; +@group(0) @binding(1) var srcSampler : sampler; + +struct VSOut { + @builtin(position) position : vec4f, + @location(0) uv : vec2f, +}; + +@vertex +fn vertex_main(@builtin(vertex_index) index : u32) -> VSOut { + // bufferless fullscreen triangle; uv (0,0) lands on the attachment's + // top-left texel so the downsample is orientation-preserving + var out : VSOut; + let x = f32((index << 1u) & 2u); + let y = f32(index & 2u); + out.uv = vec2f(x, y); + out.position = vec4f(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + // the bind group's view exposes exactly one level — level 0 of the view + return textureSampleLevel(srcTexture, srcSampler, in.uv, 0.0); +} diff --git a/packages/melonjs/src/video/webgpu/shaders/quad.wgsl b/packages/melonjs/src/video/webgpu/shaders/quad.wgsl index 6a67f3ff07..6532758568 100644 --- a/packages/melonjs/src/video/webgpu/shaders/quad.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/quad.wgsl @@ -1,18 +1,24 @@ -// melonJS WebGPU quad shader — the WGSL port of quad-multi.vert / quad.frag. +// melonJS WebGPU quad shader — the WGSL port of quad-multi.vert / +// multitexture.frag: MULTI-TEXTURE batching, eight texture+sampler slots +// per draw segment. `aTextureId` selects the slot per quad, so a segment +// flushes only when a NINTH distinct texture appears (or on the usual +// blend/effect/capacity boundaries) — the GL sampler-ladder equivalent. // // Vertex layout (28-byte stride, frozen, shared with the WebGL backend): // @location(0) aVertex float32x3 offset 0 // @location(1) aRegion float32x2 offset 12 // @location(2) aColor unorm8x4 offset 20 -// @location(3) aTextureId float32 offset 24 +// @location(3) aTextureId float32 offset 24 (slot index, 0..7) // // aColor arrives from a packed ARGB uint32 (Color.toUint32). Little-endian // memory bytes are [B,G,R,A] and unorm8x4 maps byte i -> component i, so the // attribute reads (B,G,R,A) — the same .bgr swizzle as the GLSL sources // reconstructs RGB, premultiplied by alpha. // -// aTextureId is always 0 under single-texture batching; the location is kept -// so a later multi-texture upgrade changes only group 1 and this fragment. +// Sampling uses textureSampleLevel(…, 0.0): the slot index is not uniform +// across the draw, which bars implicit-derivative textureSample under WGSL's +// uniformity analysis — and 2D quads sample level 0 by contract anyway +// (their samplers are lod-clamped; mip chains are a mesh-path feature). struct FrameUniforms { projection : mat4x4, @@ -21,13 +27,30 @@ struct FrameUniforms { }; @group(0) @binding(0) var uFrame : FrameUniforms; -@group(1) @binding(0) var uTexture : texture_2d; -@group(1) @binding(1) var uSampler : sampler; +@group(1) @binding(0) var uTexture0 : texture_2d; +@group(1) @binding(1) var uTexture1 : texture_2d; +@group(1) @binding(2) var uTexture2 : texture_2d; +@group(1) @binding(3) var uTexture3 : texture_2d; +@group(1) @binding(4) var uTexture4 : texture_2d; +@group(1) @binding(5) var uTexture5 : texture_2d; +@group(1) @binding(6) var uTexture6 : texture_2d; +@group(1) @binding(7) var uTexture7 : texture_2d; +@group(1) @binding(8) var uSampler0 : sampler; +@group(1) @binding(9) var uSampler1 : sampler; +@group(1) @binding(10) var uSampler2 : sampler; +@group(1) @binding(11) var uSampler3 : sampler; +@group(1) @binding(12) var uSampler4 : sampler; +@group(1) @binding(13) var uSampler5 : sampler; +@group(1) @binding(14) var uSampler6 : sampler; +@group(1) @binding(15) var uSampler7 : sampler; struct VSOut { @builtin(position) position : vec4f, @location(0) vRegion : vec2f, @location(1) vColor : vec4f, + // constant across a quad's four vertices, so plain interpolation + // reproduces it exactly; rounded back to the slot index per fragment + @location(2) vTextureId : f32, }; @vertex @@ -45,10 +68,39 @@ fn vertex_main( out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w); out.vColor = vec4f(aColor.bgr * aColor.a, aColor.a); out.vRegion = aRegion; + out.vTextureId = aTextureId; return out; } @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { - return textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + let uv = in.vRegion; + var color : vec4f; + switch i32(in.vTextureId + 0.5) { + case 1: { + color = textureSampleLevel(uTexture1, uSampler1, uv, 0.0); + } + case 2: { + color = textureSampleLevel(uTexture2, uSampler2, uv, 0.0); + } + case 3: { + color = textureSampleLevel(uTexture3, uSampler3, uv, 0.0); + } + case 4: { + color = textureSampleLevel(uTexture4, uSampler4, uv, 0.0); + } + case 5: { + color = textureSampleLevel(uTexture5, uSampler5, uv, 0.0); + } + case 6: { + color = textureSampleLevel(uTexture6, uSampler6, uv, 0.0); + } + case 7: { + color = textureSampleLevel(uTexture7, uSampler7, uv, 0.0); + } + default: { + color = textureSampleLevel(uTexture0, uSampler0, uv, 0.0); + } + } + return color * in.vColor; } diff --git a/packages/melonjs/src/video/webgpu/texture/store.js b/packages/melonjs/src/video/webgpu/texture/store.js index daea150d6e..ad8e0c895c 100644 --- a/packages/melonjs/src/video/webgpu/texture/store.js +++ b/packages/melonjs/src/video/webgpu/texture/store.js @@ -1,4 +1,5 @@ import { GPU_TEXTURE_CACHE_RESET, off, on } from "../../../system/event.ts"; +import mipblitWGSL from "../shaders/mipblit.wgsl"; import { COMPRESSED_FORMATS, uploadCompressedTexture } from "./compressed.js"; /** @@ -45,7 +46,7 @@ export default class WebGPUTextureStore { * @returns {GPUSampler} the sampler * @ignore */ - getSampler(filter, repeat) { + getSampler(filter, repeat, mipmaps = false) { // same per-axis mapping as MaterialBatcher.createTexture2D const addressModeU = /^repeat(-x)?$/.test(repeat) ? "repeat" @@ -53,7 +54,7 @@ export default class WebGPUTextureStore { const addressModeV = /^repeat(-y)?$/.test(repeat) ? "repeat" : "clamp-to-edge"; - const key = `${filter}|${addressModeU}|${addressModeV}`; + const key = `${filter}|${addressModeU}|${addressModeV}|${mipmaps ? "mip" : "flat"}`; let sampler = this.samplers.get(key); if (typeof sampler === "undefined") { sampler = this.device.createSampler({ @@ -62,6 +63,16 @@ export default class WebGPUTextureStore { minFilter: filter, addressModeU, addressModeV, + // mip: trilinear minification over the generated chain (the + // mesh path), with 4× anisotropy — oblique surfaces (ground + // planes, walls at grazing angles) keep detail that plain + // trilinear blurs away. Valid because the mip variant is + // only built fully linear. flat: clamp every consumer to + // level 0, so a texture UPGRADED to a mip chain by a mesh + // keeps rendering byte-identically for the sprites sharing it + ...(mipmaps + ? { mipmapFilter: "linear", maxAnisotropy: 4 } + : { lodMinClamp: 0, lodMaxClamp: 0 }), }); this.samplers.set(key, sampler); } @@ -96,7 +107,12 @@ export default class WebGPUTextureStore { if ( typeof record === "undefined" || options.force === true || - record.source !== source + record.source !== source || + // a mip-wanting consumer over a resident level-0-only record — + // same source, but the texture must be rebuilt with a chain + (options.mipmaps === true && + record.compressed !== true && + (record.mipLevelCount ?? 1) === 1) ) { // compressed sources (parsed dds/ktx/pvr/pkm) carry pre-encoded // block data: a dedicated createTexture + per-mip writeTexture @@ -123,14 +139,19 @@ export default class WebGPUTextureStore { uploadCompressedTexture(this.device, gpuTexture, source, metrics); record = { texture: gpuTexture, - // GL parity: the GL backend samples compressed textures - // with plain LINEAR/NEAREST min filters and never the - // mip chain — restrict the bind-group view to level 0 - // (the full chain stays uploaded in the texture) + // 2D consumers stay lod-clamped to level 0 (sprites + // sharing the asset render byte-identically) … view: gpuTexture.createView({ baseMipLevel: 0, mipLevelCount: 1, }), + // … while a mip-wanting consumer (the mesh path) can + // sample the AUTHORED chain — compressed assets ship + // their mips pre-encoded, no generation needed (the GL + // twin: TEXTURE_MAX_LEVEL caps the chain to what the + // asset carries) + fullView: gpuTexture.createView(), + mipLevelCount: source.mipmaps.length, source, width: source.width, height: source.height, @@ -142,7 +163,12 @@ export default class WebGPUTextureStore { } record.frameId = this.renderer.frameId; this.lastRecord = record; - return this.bindGroupFor(record, texture, wrap); + return this.bindGroupFor( + record, + texture, + wrap, + options.mipmaps === true, + ); } // prefer real pixel dimensions; HTMLVideoElement exposes them // through videoWidth/videoHeight (width/height default to 0) @@ -159,6 +185,11 @@ export default class WebGPUTextureStore { record.width !== width || record.height !== height || record.frameId === this.renderer.frameId || + // a mip-wanting consumer (the mesh path) upgrades a resident + // level-0-only texture to a full chain — never the reverse: + // flat consumers of a mipped record sample level 0 via their + // lod-clamped sampler + (options.mipmaps === true && (record.mipLevelCount ?? 1) === 1) || // a recycled unit whose resident texture came from the // compressed path cannot adopt an image source: its format // is non-renderable and copyExternalImageToTexture would @@ -171,11 +202,18 @@ export default class WebGPUTextureStore { if (typeof record !== "undefined") { this.retire(record.texture); } + // full chain down to 1×1 when the mesh path asks for mips + const mipLevelCount = + options.mipmaps === true + ? Math.floor(Math.log2(Math.max(width, height))) + 1 + : 1; const gpuTexture = this.device.createTexture({ label: "melonJS texture", size: [width, height], format: "rgba8unorm", + mipLevelCount, // RENDER_ATTACHMENT is required by copyExternalImageToTexture + // (and by the mip-chain blit passes) usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | @@ -188,6 +226,7 @@ export default class WebGPUTextureStore { width, height, frameId: -1, + mipLevelCount, bindGroupBySampler: new Map(), }; this.records.set(unit, record); @@ -198,17 +237,32 @@ export default class WebGPUTextureStore { record.source = source; } - // same premultiplication convention as the GL path's - // UNPACK_PREMULTIPLY_ALPHA_WEBGL default; no flipY (both APIs - // store row 0 = image top) - this.device.queue.copyExternalImageToTexture( - { source }, - { - texture: record.texture, - premultipliedAlpha: texture.premultipliedAlpha !== false, - }, - [record.width, record.height], - ); + // An HTMLVideoElement without a current frame has no backing + // resource and copyExternalImageToTexture THROWS (autoplay + // blocked, first frame not yet decoded) — where GL's texImage2D + // silently uploads nothing. Skip the copy; the video path + // re-uploads with force every frame, so the content lands the + // moment a frame exists. + const frameless = + typeof HTMLVideoElement !== "undefined" && + source instanceof HTMLVideoElement && + source.readyState < 2; + if (frameless === false) { + // same premultiplication convention as the GL path's + // UNPACK_PREMULTIPLY_ALPHA_WEBGL default; no flipY (both APIs + // store row 0 = image top) + this.device.queue.copyExternalImageToTexture( + { source }, + { + texture: record.texture, + premultipliedAlpha: texture.premultipliedAlpha !== false, + }, + [record.width, record.height], + ); + if ((record.mipLevelCount ?? 1) > 1) { + this.generateMipmaps(record); + } + } } // stamp: this record's texture is (about to be) referenced by draws @@ -219,7 +273,88 @@ export default class WebGPUTextureStore { // combined bind groups from the raw view this.lastRecord = record; - return this.bindGroupFor(record, texture, wrap); + return this.bindGroupFor(record, texture, wrap, options.mipmaps === true); + } + + /** + * Build the full mip chain of a freshly-uploaded record: one blit pass + * per level, each rendering the previous level with linear filtering. + * Runs in its own encoder submitted immediately — queue ordering places + * it after the level-0 copy (a queue operation) and before the frame's + * own command buffer, so the frame's draws sample a complete chain. + * @param {object} record - the resident record (mipLevelCount > 1) + * @ignore + */ + generateMipmaps(record) { + const device = this.device; + if (typeof this.mipPipeline === "undefined") { + const module = device.createShaderModule({ + label: "melonJS mip blit", + code: mipblitWGSL, + }); + this.mipLayout = device.createBindGroupLayout({ + label: "melonJS mip blit layout", + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + ], + }); + // deliberately NOT a pipeline-cache family: mip passes have no + // depth-stencil attachment and target the texture format, both + // of which every cached pipeline hard-declares otherwise + this.mipPipeline = device.createRenderPipeline({ + label: "melonJS mip blit", + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.mipLayout], + }), + vertex: { module, entryPoint: "vertex_main" }, + fragment: { + module, + entryPoint: "fragment_main", + targets: [{ format: "rgba8unorm" }], + }, + primitive: { topology: "triangle-list" }, + }); + this.mipSampler = device.createSampler({ + label: "melonJS mip blit sampler", + magFilter: "linear", + minFilter: "linear", + }); + } + const encoder = device.createCommandEncoder({ label: "melonJS mipgen" }); + for (let level = 1; level < record.mipLevelCount; level++) { + const bindGroup = device.createBindGroup({ + label: "melonJS mip blit binding", + layout: this.mipLayout, + entries: [ + { + binding: 0, + resource: record.texture.createView({ + baseMipLevel: level - 1, + mipLevelCount: 1, + }), + }, + { binding: 1, resource: this.mipSampler }, + ], + }); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: record.texture.createView({ + baseMipLevel: level, + mipLevelCount: 1, + }), + loadOp: "clear", + storeOp: "store", + }, + ], + }); + pass.setPipeline(this.mipPipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + } + device.queue.submit([encoder.finish()]); } /** @@ -227,20 +362,35 @@ export default class WebGPUTextureStore { * the image and compressed upload paths) * @ignore */ - bindGroupFor(record, texture, wrap) { + bindGroupFor(record, texture, wrap, wantMips = false) { const filter = typeof texture.filter === "string" ? texture.filter : this.renderer.getDefaultTextureFilter(); - const samplerKey = `${filter}|${wrap}`; + // trilinear only for the consumer that asked for mips, over a record + // that has them, with linear filtering ("nearest" opts out — crisp + // pixel-art models); everyone else stays lod-clamped to level 0 + const mip = + wantMips === true && + (record.mipLevelCount ?? 1) > 1 && + filter === "linear"; + const samplerKey = `${filter}|${wrap}|${mip ? "mip" : "flat"}`; let bindGroup = record.bindGroupBySampler.get(samplerKey); if (typeof bindGroup === "undefined") { bindGroup = this.device.createBindGroup({ label: "melonJS material", layout: this.renderer.pipelineCache.materialLayout, entries: [ - { binding: 0, resource: record.view }, - { binding: 1, resource: this.getSampler(filter, wrap) }, + { + binding: 0, + // compressed records keep a level-0 `view` for 2D + // consumers and a `fullView` over the authored chain + // for mip sampling; generated-chain records have one + // full view serving both (the sampler's lod clamp + // does the 2D restriction there) + resource: mip ? (record.fullView ?? record.view) : record.view, + }, + { binding: 1, resource: this.getSampler(filter, wrap, mip) }, ], }); record.bindGroupBySampler.set(samplerKey, bindGroup); diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 1638e623f1..0517b525a7 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -29,7 +29,9 @@ import { createLightUniformScratch, packLights, } from "../webgl/lighting/pack.ts"; +import WebGPULitMeshBatcher from "./batchers/lit_mesh_batcher.js"; import WebGPULitQuadBatcher from "./batchers/lit_quad_batcher.js"; +import WebGPUMeshBatcher from "./batchers/mesh_batcher.js"; import WebGPUPrimitiveBatcher from "./batchers/primitive_batcher.js"; import WebGPUQuadBatcher from "./batchers/quad_batcher.js"; import WebGPUBatcher from "./batchers/webgpu_batcher.js"; @@ -49,6 +51,32 @@ const tempMatrix = new Matrix3d(); // scratch: the projection saved across a blitEffect quad const blitSavedProjection = new Matrix3d(); +/** + * Resolve the depth half of a pass's load/store ops — split out pure so the + * single-clear-per-target-per-frame policy is unit-testable without a + * device. Until the first mesh ever draws, the ops are the original + * clear/discard pair: the depth half costs pure-2D applications nothing and + * their passes stay byte-identical. Once the mesh path is active, depth + * persists across pass restarts (load/store — the GL parity where the depth + * buffer survives mid-frame stencil clears and captures) and clears only + * where a clear is armed: frame start, render-target change, or a fresh + * attachment (whose "load" would read zeros and fail every LEQUAL test). + * @param {boolean} meshDepthActive - whether drawMesh has ever run + * @param {boolean} pendingDepthClear - whether a depth clear is armed + * @returns {{depthLoadOp: string, depthStoreOp: string}} the pass ops + * @ignore + * @internal + */ +export function resolveDepthOps(meshDepthActive, pendingDepthClear) { + if (meshDepthActive !== true) { + return { depthLoadOp: "clear", depthStoreOp: "discard" }; + } + return { + depthLoadOp: pendingDepthClear === true ? "clear" : "load", + depthStoreOp: "store", + }; +} + /** * The **experimental** WebGPU renderer. * @@ -120,14 +148,18 @@ export default class WebGPURenderer extends Renderer { this.shaderLanguage = "wgsl"; // capability flags describe what the backend can DO today - // (supportsDepthBuffer / supportsRetainedMesh stay false until - // their paths land) // orthogonal TMX layers draw through the WGSL shader tile path this.supportsShaderTileLayers = true; + // the mesh tier: depth-tested drawing (the depth half of the shared + // attachment) and retained model-space mesh geometry — Camera3d + // scenes take the uniforms-only drawMesh(mesh, modelMatrix) path + this.supportsDepthBuffer = true; + this.supportsRetainedMesh = true; // lazy orientation-specific GPU tilemap renderer (device-scoped: // dropped on device loss, rebuilt on first use) - /** @ignore */ + /** @ignore + * @internal */ this.orthogonalTMXRenderer = undefined; // create a texture cache @@ -140,110 +172,187 @@ export default class WebGPURenderer extends Renderer { // active Gradient (setColor(Gradient)) — honored on fillRect via // the Canvas-baked gradient texture, and on arbitrary shapes by // clipping that baked rect through the stencil (gradientMask) - /** @ignore */ + /** @ignore + * @internal */ this.currentGradient = null; // scratch vertices for fillRect (2 triangles) and fillPolygon - /** @ignore */ + /** @ignore + * @internal */ this.rectTriangles = Array.from({ length: 6 }, () => { return { x: 0, y: 0 }; }); - /** @ignore */ + /** @ignore + * @internal */ this.polyVerts = []; // scratch bounds for the clipRect screen-space AABB derivation - /** @ignore */ + /** @ignore + * @internal */ this.clipAABB = new Bounds(); // the stencil reference the masked render phase compares against - /** @ignore */ + /** @ignore + * @internal */ this.maskVisibleRef = 0; /** * the batchers registered with this renderer, by name * @type {Map} * @ignore + * @internal */ this.batchers = new Map(); /** * the currently active batcher * @ignore + * @internal */ this.currentBatcher = null; // GPU-facing infrastructure, created by init() once a device exists - /** @ignore */ + /** @ignore + * @internal */ this.pipelineCache = null; - /** @ignore */ + /** @ignore + * @internal */ this.vertexArena = null; - /** @ignore */ + // per-frame index regions for the accumulated mesh path + /** @ignore + * @internal */ + this.indexArena = null; + /** @ignore + * @internal */ this.uniformRing = null; - /** @ignore */ + /** @ignore + * @internal */ this.textureStore = null; // per-frame recording state - /** @ignore */ + /** @ignore + * @internal */ this.commandEncoder = null; - /** @ignore */ + /** @ignore + * @internal */ this.renderPass = null; // the active offscreen render target (null = the canvas). Retargeting // is a pass break: the next pass opens on the target's color view. - /** @ignore */ + /** @ignore + * @internal */ this.currentRenderTarget = null; // consumed as the next pass's colorLoadOp "clear" (fresh target) - /** @ignore */ + /** @ignore + * @internal */ this.pendingColorClear = false; - /** @ignore */ + /** @ignore + * @internal */ this.pendingClearValue = null; - /** @ignore */ + /** @ignore + * @internal */ this.pendingStencilClear = false; + // consumed as the next pass's depthLoadOp "clear" once the mesh path + // is active — armed at frame start, on render-target changes and on + // depth-attachment recreation (single clear per target per frame, + // the GL mesh-mode policy) + /** @ignore + * @internal */ + this.pendingDepthClear = false; + // sticky: flips true on the first drawMesh ever and stays — before + // that, every pass keeps the original clear/discard depth ops and + // pure-2D applications see zero change + /** @ignore + * @internal */ + this.meshDepthActive = false; + // the split-screen camera viewport (top-left origin, canvas passes + // only) — null = full target. Persists across frames like gl.viewport + /** @ignore + * @internal */ + this.viewportRect = null; + // one-shot warn: a customShader (ShaderEffect fast path) cannot host + // a mesh draw on this backend + /** @ignore + * @internal */ + this.meshEffectWarned = false; // the canvas GPUTexture handle of the current frame — kept beside its // view because captureFrame copies from the TEXTURE, not the view - /** @ignore */ + /** @ignore + * @internal */ this.frameTexture = null; // the shared frame-capture slot (screen_texture builtin), lazy - /** @ignore */ + /** @ignore + * @internal */ this.captureTexture = undefined; // 1×1 transparent stand-in bound where a declared texture has no // source yet (never-captured screen_texture, unset setTexture slot) - /** @ignore */ + /** @ignore + * @internal */ this.stubTexture = null; // per-depth projection save slots for nested post-effect passes - /** @ignore */ + /** @ignore + * @internal */ this.effectProjectionStack = []; - /** @ignore */ + /** @ignore + * @internal */ this.effectPassDepth = 0; // per-bind effect uniform snapshots (created by init) - /** @ignore */ + /** @ignore + * @internal */ this.effectUniformArena = null; // monotonically increasing frame id — the texture store uses it to // detect same-frame content changes that need a fresh texture - /** @ignore */ + /** @ignore + * @internal */ this.frameId = 0; // GPUTextures replaced mid-frame: destroying them immediately would // invalidate draws already recorded against them (submit rejects the // whole command buffer) — they retire at frame end, after submit - /** @ignore */ + /** @ignore + * @internal */ this.retiredTextures = []; // the lineWidth value written into the current frame-globals slot — // the primitive batcher compares against THIS (not its own cache) // because clear() rewrites the slot every frame - /** @ignore */ + /** @ignore + * @internal */ this.currentFrameLineWidth = 1; - /** @ignore */ + /** @ignore + * @internal */ this.frameTextureView = null; - /** @ignore */ + /** @ignore + * @internal */ this.depthTexture = null; - /** @ignore */ + // MSAA state (antiAlias: true): canvas passes render into a shared + // multisampled color texture resolving into the canvas view, with a + // matching multisampled depth-stencil twin. Offscreen render targets + // stay single-sampled — GL parity, where only the default + // framebuffer is ever antialiased and pool FBOs are not. + /** @ignore + * @internal */ + this.canvasSampleCount = 1; + /** @ignore + * @internal */ + this.msaaColorTexture = null; + /** @ignore + * @internal */ + this.msaaColorView = null; + /** @ignore + * @internal */ + this.msaaDepthTexture = null; + /** @ignore + * @internal */ this.currentPipeline = null; - /** @ignore */ + /** @ignore + * @internal */ this.currentFrameBinding = null; - /** @ignore */ + /** @ignore + * @internal */ this.scissorActive = false; // premultiplied-alpha flag mirrored from setBlendMode (pipeline key) - /** @ignore */ + /** @ignore + * @internal */ this.premultipliedAlpha = true; // stencil mode consulted by the pipeline lookup: "none"|"write"|"test" - /** @ignore */ + /** @ignore + * @internal */ this.stencilMode = "none"; // reset the renderer on game reset (stored so destroy() can @@ -261,6 +370,11 @@ export default class WebGPURenderer extends Renderer { this.flush(); this.createDepthTexture(); } + // a stored split-screen viewport was un-flipped against the OLD + // canvas height — drop it, like the GL resize path's + // setViewport(0, 0, width, height); cameras re-set theirs next + // frame + this.viewportRect = null; }; on(CANVAS_ONRESIZE, this.onCanvasResize); } @@ -277,6 +391,14 @@ export default class WebGPURenderer extends Renderer { * suitable adapter is found */ async init() { + // idempotent while the device is live: Application.init()'s AUTO + // case awaits a full init during backend negotiation, then its + // common path awaits init again on the winner — a second + // negotiation would leak the first device. (restoreDevice clears + // `device` before re-running, so the restore path passes through.) + if (typeof this.device !== "undefined" && this.isContextValid === true) { + return; + } const gpu = globalThis.navigator?.gpu; if (typeof gpu === "undefined") { throw new Error( @@ -298,6 +420,18 @@ export default class WebGPURenderer extends Renderer { "WebGPU: no suitable GPUAdapter found (adapter request returned null)", ); } + // the WebGPU analogue of GL's failIfMajorPerformanceCaveat: a + // fallback adapter is software rendering — reject it when the app + // asked for hardware-or-nothing (under AUTO this falls through to + // the WebGL candidate, which honors the same setting) + if ( + this.settings.failIfMajorPerformanceCaveat === true && + adapter.isFallbackAdapter === true + ) { + throw new Error( + "WebGPU: only a fallback (software) adapter is available and failIfMajorPerformanceCaveat is set", + ); + } this.adapter = adapter; // request the compressed-texture families the adapter offers, so @@ -323,6 +457,8 @@ export default class WebGPURenderer extends Renderer { " ", ) || undefined; + // the GL backend's GPUVendor twin (debug-renderer-info UNMASKED_VENDOR) + this.GPUVendor = info.vendor || undefined; } // a lost device is this backend's context loss. `device.lost` @@ -367,17 +503,48 @@ export default class WebGPURenderer extends Renderer { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, pageSize: 64 << 10, }); + this.indexArena = new WebGPUBufferArena(this.device, { + label: "melonJS index arena", + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }); this.textureStore = new WebGPUTextureStore(this); + // antiAlias maps to 4× MSAA on canvas passes (the GL context's + // antialias flag equivalent); 4× is universally supported for the + // canvas and depth formats + this.canvasSampleCount = this.settings.antiAlias === true ? 4 : 1; + this.pipelineCache.sampleCount = this.canvasSampleCount; this.createDepthTexture(); // register the built-in batchers (device-dependent, so here rather - // than the constructor — a device-loss restore re-runs this path) + // than the constructor — a device-loss restore re-runs this path). + // A custom batcher override rides the quad/primitive slots exactly + // like the WebGL renderer's constructor — `addBatcher` rejects a + // non-WebGPUBatcher class loudly, so a GL-only custom batcher fails + // with a clear message instead of mid-draw if (this.batchers.size === 0) { - this.addBatcher(new WebGPUQuadBatcher(this), "quad", true); - this.addBatcher(new WebGPUPrimitiveBatcher(this), "primitive"); - this.addBatcher(new WebGPULitQuadBatcher(this), "litQuad"); + const CustomBatcher = this.settings.batcher || this.settings.compositor; + this.addBatcher( + new (CustomBatcher || WebGPUQuadBatcher)(this), + "quad", + true, + ); + this.addBatcher( + new (CustomBatcher || WebGPUPrimitiveBatcher)(this), + "primitive", + ); + if (!CustomBatcher) { + this.addBatcher(new WebGPULitQuadBatcher(this), "litQuad"); + } + this.addBatcher(new WebGPUMeshBatcher(this), "mesh"); + this.addBatcher(new WebGPULitMeshBatcher(this), "litMesh"); } + // apply the configured blend mode up front (the GL constructor's + // parity): before the first GAME_RESET, out-of-bracket draws (unit + // tests, pre-stage drawing) would otherwise run on RenderState's + // "none" default with blending disabled + this.setBlendMode(this.settings.blendMode); + this.isContextValid = true; } @@ -389,28 +556,59 @@ export default class WebGPURenderer extends Renderer { * @param {number} [width] - required width (defaults to the canvas) * @param {number} [height] - required height (defaults to the canvas) * @ignore + * @internal */ - createDepthTexture(width, height) { + createDepthTexture(width, height, sampleCount = 1) { const canvas = this.getCanvas(); width = Math.max(1, width ?? canvas.width); height = Math.max(1, height ?? canvas.height); - if ( - this.depthTexture && - this.depthTexture.width === width && - this.depthTexture.height === height - ) { + // single-sampled and multisampled passes each keep their own + // attachment (a pass's attachments must share one sample count) + const slot = sampleCount > 1 ? "msaaDepthTexture" : "depthTexture"; + const current = this[slot]; + if (current && current.width === width && current.height === height) { return; } - if (this.depthTexture) { + if (current) { // recorded passes may reference the old attachment — retire it - this.retireTexture(this.depthTexture); + this.retireTexture(current); } - this.depthTexture = this.device.createTexture({ - label: "melonJS depth-stencil", + this[slot] = this.device.createTexture({ + label: `melonJS depth-stencil${sampleCount > 1 ? " msaa" : ""}`, size: [width, height], format: DEPTH_STENCIL_FORMAT, + sampleCount, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + // a fresh attachment read with a "load" op is all zeros — every + // LEQUAL test would fail — so recreation always arms a depth clear + this.pendingDepthClear = true; + } + + /** + * (re)create the shared multisampled color texture canvas passes render + * into (resolved into the canvas view at every pass end). + * @param {number} width - required width in pixels + * @param {number} height - required height in pixels + * @ignore + * @internal + */ + createMsaaColorTexture(width, height) { + const current = this.msaaColorTexture; + if (current && current.width === width && current.height === height) { + return; + } + if (current) { + this.retireTexture(current); + } + this.msaaColorTexture = this.device.createTexture({ + label: "melonJS msaa color", + size: [width, height], + format: this.preferredFormat, + sampleCount: this.canvasSampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT, }); + this.msaaColorView = this.msaaColorTexture.createView(); } /** @@ -436,6 +634,7 @@ export default class WebGPURenderer extends Renderer { } this.vertexArena.reset(); + this.indexArena.reset(); this.uniformRing.reset(); this.effectUniformArena.reset(); this.frameId++; @@ -452,6 +651,8 @@ export default class WebGPURenderer extends Renderer { this.maskLevel = 0; const [r, g, b, a] = this.backgroundColor.toArray(); + // frame start clears every attachment half, depth included + this.pendingDepthClear = true; this.beginPass({ colorLoadOp: "clear", clearValue: { r, g, b, a }, @@ -463,7 +664,9 @@ export default class WebGPURenderer extends Renderer { // a new frame is a new render pass on the active target — same // per-frame signal the WebGL backend emits from its clear() - emit(RENDER_TARGET_CHANGED, this.renderTarget); + // the event payload is the RENDERER on every other emitter (both + // backends), and subscribers filter on `emitter === this.renderer` + emit(RENDER_TARGET_CHANGED, this); } /** @@ -471,6 +674,7 @@ export default class WebGPURenderer extends Renderer { * encoder and acquiring the canvas texture on first use this frame) * @param {object} [opts] - load operations for the pass * @ignore + * @internal */ beginPass(opts = {}) { if (this.commandEncoder === null) { @@ -506,32 +710,59 @@ export default class WebGPURenderer extends Renderer { this.pendingColorClear = false; this.pendingClearValue = null; this.pendingStencilClear = false; - // every attachment of a pass must have identical dimensions — the - // shared depth-stencil tracks the active target's size (targets are - // canvas-sized in the 2D flow, so this recreates nothing in practice) + // every attachment of a pass must have identical dimensions AND one + // sample count — canvas passes are multisampled when antiAlias is + // on (resolving into the canvas view), offscreen targets never are + // (GL parity: only the default framebuffer is antialiased) const [width, height] = this.getTargetSize(); - this.createDepthTexture(width, height); + const sampleCount = target === null ? this.canvasSampleCount : 1; + this.createDepthTexture(width, height, sampleCount); + // every pipeline recorded into this pass must declare its count — + // the cache keys on it, so both variants coexist compiled + this.pipelineCache.sampleCount = sampleCount; + let colorAttachment; + if (sampleCount > 1) { + this.createMsaaColorTexture(width, height); + colorAttachment = { + view: this.msaaColorView, + // resolved into the canvas view at every pass end; samples + // are stored so mid-frame restarts can load them back + resolveTarget: colorView, + loadOp: colorLoadOp ?? "load", + clearValue, + storeOp: "store", + }; + } else { + colorAttachment = { + view: colorView, + loadOp: colorLoadOp ?? "load", + clearValue, + storeOp: "store", + }; + } + // resolve AFTER createDepthTexture — a recreation arms the clear + const { depthLoadOp, depthStoreOp } = resolveDepthOps( + this.meshDepthActive, + this.pendingDepthClear, + ); + this.pendingDepthClear = false; this.renderPass = this.commandEncoder.beginRenderPass({ label: "melonJS pass", - colorAttachments: [ - { - view: colorView, - loadOp: colorLoadOp ?? "load", - clearValue, - storeOp: "store", - }, - ], + colorAttachments: [colorAttachment], depthStencilAttachment: { - view: this.depthTexture.createView(), - depthLoadOp: "clear", + view: (sampleCount > 1 + ? this.msaaDepthTexture + : this.depthTexture + ).createView(), + depthLoadOp, depthClearValue: 1.0, - depthStoreOp: "discard", + depthStoreOp, stencilLoadOp: stencilLoadOp ?? "load", stencilClearValue: 0, stencilStoreOp: "store", }, }); - this.renderPass.setViewport(0, 0, width, height, 0, 1); + this.applyViewport(); this.applyScissor(); // a new pass resets the stencil reference to 0 — re-apply the mask // reference so content masked ACROSS a pass restart (post-effect @@ -546,6 +777,7 @@ export default class WebGPURenderer extends Renderer { * target, or the canvas * @returns {[number, number]} [width, height] * @ignore + * @internal */ getTargetSize() { const target = this.currentRenderTarget; @@ -565,6 +797,7 @@ export default class WebGPURenderer extends Renderer { * @param {object} [options] - retarget options * @param {boolean} [options.clear=false] - open the next pass with a clearing color load * @ignore + * @internal */ setRenderTarget(target, options = {}) { this.currentBatcher?.flush(); @@ -577,6 +810,11 @@ export default class WebGPURenderer extends Renderer { options.clear === true || (target?.pendingClear ?? false); this.pendingClearValue = options.clearValue ?? null; this.pendingStencilClear = options.clearStencil === true; + // a target change re-arms the depth clear in BOTH directions — the + // GL RENDER_TARGET_CHANGED re-arm: each destination gets one depth + // clear per frame (the shared attachment holds the other target's + // depth, which is stale data from this destination's point of view) + this.pendingDepthClear = true; if (target) { target.pendingClear = false; } @@ -589,6 +827,7 @@ export default class WebGPURenderer extends Renderer { * on the retired list and is destroyed after submit (or abandon). * @param {GPUTexture} texture - the texture to dispose of * @ignore + * @internal */ retireTexture(texture) { if (this.commandEncoder !== null) { @@ -598,11 +837,26 @@ export default class WebGPURenderer extends Renderer { } } + /** + * Dispose of a GPUBuffer safely — the buffer twin of + * {@link WebGPURenderer#retireTexture}, and the same reasoning: a buffer + * referenced by already-recorded draws (retained mesh geometry) must + * outlive the frame's submit. GPUBuffer and GPUTexture share the + * `destroy()` contract, so retired buffers ride the same list. + * @param {GPUBuffer} buffer - the buffer to dispose of + * @ignore + * @internal + */ + retireBuffer(buffer) { + this.retireTexture(buffer); + } + /** * The 1×1 transparent-black stand-in view — bound where a declared * effect texture has no source yet, so bind groups stay valid. * @returns {GPUTextureView} the stub view * @ignore + * @internal */ getStubTextureView() { if (this.stubTexture === null) { @@ -623,6 +877,68 @@ export default class WebGPURenderer extends Renderer { return this.stubTextureView; } + /** + * Restrict rendering to a sub-rectangle of the canvas — the split-screen + * camera surface (`Camera2d`/`Camera3d._setupNonDefaultProjection`). + * Callers pass GL-convention rects with a BOTTOM-left origin (the flip is + * baked into the camera code, which the GL backend depends on); WebGPU + * viewports are top-left, so it is un-flipped here. The rect applies to + * canvas passes only — offscreen post-effect targets always render full + * size, like the GL pool path re-viewporting per target. + * @param {number} x - viewport x (pixels) + * @param {number} y - viewport y, bottom-left origin (pixels) + * @param {number} width - viewport width (pixels) + * @param {number} height - viewport height (pixels) + * @override + */ + setViewport(x, y, width, height) { + // dynamic pass state applies to draws recorded after the call — + // vertices queued under the previous viewport must land first + this.currentBatcher?.flush(); + const canvas = this.getCanvas(); + this.viewportRect = { + x, + y: canvas.height - y - height, + width, + height, + }; + this.applyViewport(); + } + + /** + * apply the effective viewport to the open pass (re-run on every pass + * restart — pass state does not carry across passes) + * @ignore + * @internal + */ + applyViewport() { + if (this.renderPass === null) { + return; + } + const [targetWidth, targetHeight] = this.getTargetSize(); + let x = 0; + let y = 0; + let width = targetWidth; + let height = targetHeight; + const rect = this.viewportRect; + if (rect != null && this.currentRenderTarget === null) { + // clamp — a viewport extending past the attachment is a WebGPU + // validation error, not a GL-style silent clip (stale rects + // survive canvas resizes) + x = Math.min(Math.max(rect.x, 0), targetWidth); + y = Math.min(Math.max(rect.y, 0), targetHeight); + width = Math.min(rect.width, targetWidth - x); + height = Math.min(rect.height, targetHeight - y); + if (width <= 0 || height <= 0) { + x = 0; + y = 0; + width = targetWidth; + height = targetHeight; + } + } + this.renderPass.setViewport(x, y, width, height, 0, 1); + } + /** * disable the scissor test — pass-model realization of the GL call * (pending vertices drain, the open pass widens back to the target) @@ -645,6 +961,7 @@ export default class WebGPURenderer extends Renderer { * render attachment), so the very next pass can sample it hazard-free. * @returns {import("./texture/frametexture.js").WebGPUFrameTexture|null} the shared capture, or null when no device * @ignore + * @internal */ captureFrame() { return this.toFrameTexture(); @@ -786,6 +1103,8 @@ export default class WebGPURenderer extends Renderer { * @param {Renderable} renderable - the renderable carrying postEffects * @returns {boolean} true when an offscreen pass began * @override + * @ignore + * @internal */ beginPostEffect(renderable) { const effects = renderable.postEffects.filter((fx) => { @@ -853,6 +1172,8 @@ export default class WebGPURenderer extends Renderer { * per effect, ping-ponging between pool targets for chains. * @param {Renderable} renderable - the renderable passed to beginPostEffect * @override + * @ignore + * @internal */ endPostEffect(renderable) { const effects = renderable.postEffects.filter((fx) => { @@ -969,6 +1290,7 @@ export default class WebGPURenderer extends Renderer { * Also the pass-restart primitive: masks break the pass to clear the * stencil, post effects will break it to retarget. * @ignore + * @internal */ ensurePass() { if (this.renderPass === null) { @@ -1005,6 +1327,7 @@ export default class WebGPURenderer extends Renderer { * drop the open pass and encoder without submitting (mid-frame * exception recovery, reset, destroy) * @ignore + * @internal */ abandonFrame() { if (this.renderPass !== null) { @@ -1023,6 +1346,7 @@ export default class WebGPURenderer extends Renderer { // never leave an offscreen target active for the next frame this.currentRenderTarget = null; this.pendingColorClear = false; + this.pendingDepthClear = false; this.currentPipeline = null; // the recorded draws are dropped with the command buffer, so any // texture retired during the frame can go now @@ -1033,6 +1357,7 @@ export default class WebGPURenderer extends Renderer { * destroy GPUTextures replaced mid-frame, now that the command buffer * referencing them has been submitted (or abandoned) * @ignore + * @internal */ destroyRetiredTextures() { if (this.retiredTextures.length > 0) { @@ -1046,6 +1371,7 @@ export default class WebGPURenderer extends Renderer { /** * re-apply the current scissor state to the open pass * @ignore + * @internal */ applyScissor() { if (this.renderPass === null) { @@ -1208,6 +1534,103 @@ export default class WebGPURenderer extends Renderer { return this.currentBatcher; } + /** + * Draw a textured triangle mesh — the mesh-path contract of the base + * renderer, on this backend's pipeline-state model. Unlike GL there is + * no device state to toggle: depth testing (write + LEQUAL) and the + * per-mesh face culling are axes of the pipeline the mesh batcher + * looks up per flush, and the once-per-target depth clear is the + * pass's `depthLoadOp` (armed by `clear()`/`setRenderTarget`). + * @param {object} mesh - a Mesh object with vertices, uvs, indices, and texture properties + * @param {Matrix3d} [modelMatrix] - the mesh's model matrix. When given, + * the mesh is drawn from persistent model-space geometry and placed + * entirely by uniforms; when omitted, its vertices are taken as + * already positioned and accumulated through the batcher (the + * 2D-camera path). + * @override + */ + drawMesh(mesh, modelMatrix) { + const retained = modelMatrix !== undefined; + + if (this.meshDepthActive !== true) { + // the depth half of the attachment is live from here on — pass + // restarts preserve it and clears follow the armed policy (see + // resolveDepthOps). The pass open RIGHT NOW was begun with the + // pre-mesh discard ops, so arm a clear as well: a mid-frame + // restart on this activation frame (mask, capture) must CLEAR + // rather than "load" the discarded — undefined — contents. + // Costs one extra depth clear on this frame only. + this.meshDepthActive = true; + this.pendingDepthClear = true; + } + + // a hosted custom shader (mesh.shader): a GLShader carrying a WGSL + // module (isWebGPU) is a complete mesh-contract program — route it + // to the batcher, which realizes it as its own shader family. + // Anything else in the slot cannot host a mesh here — a ShaderEffect + // (WGSL effect realizations are bound to the frozen quad vertex + // layout) or a GLSL-only shader — so draw the mesh with the built-in + // shading rather than dropping it, and say so once. + const customShader = + this.customShader != null && this.customShader.isWebGPU === true + ? this.customShader + : null; + if ( + this.customShader != null && + customShader === null && + this.meshEffectWarned !== true + ) { + this.meshEffectWarned = true; + console.warn( + "melonJS: this custom shader cannot be hosted on a Mesh by the WebGPU renderer (it carries no `wgsl` module) — the mesh draws with the built-in shading", + ); + } + + const batcher = this.setBatcher( + mesh.lit === true && this.batchers.has("litMesh") ? "litMesh" : "mesh", + ); + batcher.customShader = customShader; + + // per-mesh culling is a pipeline axis here. Retained geometry keeps + // its authored winding, so the axis bridge's reflection (which + // mirrors handedness) is undone by flipping which orientation counts + // as front-facing; right-handed meshes are bridged by a rotation, + // which preserves winding. Winding is irrelevant with culling off — + // "ccw" there keeps the pipeline permutation count down. + const culling = mesh.cullBackFaces === true; + const state = batcher.meshState; + state.cullMode = culling ? "back" : "none"; + state.frontFace = + culling && retained && mesh.rightHanded !== true ? "cw" : "ccw"; + + // finally: the hosted module is per-mesh state — a throw mid-draw + // (e.g. an unsupported-format texture upload) must not leak it into + // a later mesh (or the frame-end drain) that didn't ask for it + try { + const tint = this.currentTint.toUint32(this.getGlobalAlpha()); + if (retained) { + batcher.drawRetainedMesh(mesh, modelMatrix, tint); + } else { + batcher.addMesh(mesh, tint); + // drain the batcher only — renderer.flush() submits the frame + batcher.flush(); + } + } finally { + batcher.customShader = null; + } + } + + /** + * Release any retained geometry held for the given mesh (called from + * `Mesh.onDeactivateEvent` / `Mesh.destroy`). + * @param {object} mesh - the mesh whose GPU geometry should be freed + */ + deleteMeshGeometry(mesh) { + this.batchers.forEach((batcher) => { + batcher.releaseRetained?.(mesh); + }); + } + /** * Reset the transform to identity */ @@ -1408,6 +1831,7 @@ export default class WebGPURenderer extends Renderer { * push a fresh frame-globals slot (projection + lineWidth); records the * lineWidth written so batchers can detect when the slot goes stale * @ignore + * @internal */ pushFrameGlobals() { this.currentFrameBinding = this.uniformRing.pushFrameGlobals( @@ -1653,6 +2077,7 @@ export default class WebGPURenderer extends Renderer { * for drawLight's procedural effect (same rationale as the GL backend) * @returns {TextureAtlas} * @ignore + * @internal */ getLightAtlas() { if (this.lightAtlas === undefined) { @@ -1768,6 +2193,22 @@ export default class WebGPURenderer extends Renderer { return; } + this.setScissorRect(sx, sy, sw, sh); + } + + /** + * Clamp a screen-space scissor box to the canvas and make it the + * active scissor (shared tail of {@link WebGPURenderer#clipRect} and + * {@link WebGPURenderer#enableScissor}). + * @param {number} sx - screen-space x + * @param {number} sy - screen-space y + * @param {number} sw - width + * @param {number} sh - height + * @ignore + * @internal + */ + setScissorRect(sx, sy, sw, sh) { + const canvas = this.getCanvas(); // clamp to the canvas — WebGPU raises a validation error on any // out-of-attachment scissor where GL silently clamps; degenerate // boxes become a 0-sized scissor (nothing draws), not an error @@ -1802,6 +2243,71 @@ export default class WebGPURenderer extends Renderer { this.applyScissor(); } + /** + * Enable the scissor test with the given rectangle (transformed by the + * current transform, like the WebGL renderer). Unlike + * {@link WebGPURenderer#clipRect} a full-canvas rectangle still + * enables the scissor rather than reading as "no clip". + * @param {number} x - x coordinate of the scissor rectangle + * @param {number} y - y coordinate of the scissor rectangle + * @param {number} width - width of the scissor rectangle + * @param {number} height - height of the scissor rectangle + * @override + */ + enableScissor(x, y, width, height) { + const aabb = this.clipAABB; + aabb.clear(); + aabb.addFrame(x, y, x + width, y + height, this.currentTransform); + const sx = Math.floor(aabb.min.x); + const sy = Math.floor(aabb.min.y); + this.setScissorRect( + sx, + sy, + Math.ceil(aabb.max.x - sx), + Math.ceil(aabb.max.y - sy), + ); + } + + /** + * Enable or disable blending — blend state is pipeline state on this + * backend, so this maps onto the blend-mode axis: disabling stashes + * the current mode and switches to "none" (source replaces + * destination), enabling restores the stashed mode. + * @param {boolean} enable - whether blending should be enabled + * @override + */ + setBlendEnabled(enable) { + if (enable === false) { + if (this.currentBlendMode !== "none") { + this.savedBlendMode = this.currentBlendMode; + this.setBlendMode("none", this.premultipliedAlpha); + } + } else if (typeof this.savedBlendMode === "string") { + this.setBlendMode(this.savedBlendMode, this.premultipliedAlpha); + this.savedBlendMode = undefined; + } + } + + /** + * Clear the current render target with transparent black — the + * offscreen-target analogue of a frame clear, realized as this + * backend's pass-level clear (the next pass on the target loads + * cleared content instead of recording a clearing draw). + * @override + */ + clearRenderTarget() { + // anything already recorded against the target must land first — + // the pass restart below is what applies the clear ordering + this.currentBatcher?.flush(); + if (this.renderPass !== null) { + this.renderPass.end(); + this.renderPass = null; + } + this.pendingColorClear = true; + this.pendingClearValue = { r: 0, g: 0, b: 0, a: 0 }; + emit(RENDER_TARGET_CHANGED, this); + } + /** * A mask limits rendering elements to the shape and position of the * given mask object — realized on the stencil half of the pass's @@ -1883,6 +2389,7 @@ export default class WebGPURenderer extends Renderer { * then the marker is stripped and the mask's exact render test is * re-installed. * @ignore + * @internal */ gradientMask(drawShape, x, y, w, h) { const grad = this.currentGradient; @@ -2549,9 +3056,11 @@ export default class WebGPURenderer extends Renderer { super.setAntiAlias(enable); this.currentBatcher?.flush(); this.textureStore?.invalidateBindGroups(); - // the lit tier caches combined color+normal bind groups outside the - // store — each embeds a sampler resolved from the default filter + // the lit tier caches combined color+normal bind groups and the + // quad tier caches composed segment groups outside the store — + // each embeds samplers resolved from the default filter this.batchers.get("litQuad")?.clearMaterialCache(); + this.batchers.get("quad")?.clearMaterialCache(); } /** @@ -2563,21 +3072,25 @@ export default class WebGPURenderer extends Renderer { super.setTextureFilter(mode); this.currentBatcher?.flush(); this.textureStore?.invalidateBindGroups(); - // the lit tier caches combined color+normal bind groups outside the - // store — each embeds a sampler resolved from the default filter + // the lit tier caches combined color+normal bind groups and the + // quad tier caches composed segment groups outside the store — + // each embeds samplers resolved from the default filter this.batchers.get("litQuad")?.clearMaterialCache(); + this.batchers.get("quad")?.clearMaterialCache(); } /** * rebuild every GPU-facing resource after a device loss — the WebGPU * analogue of the WebGL context-restore path * @ignore + * @internal */ async restoreDevice() { // tear down everything tied to the dead device this.abandonFrame(); this.textureStore?.destroy(); this.vertexArena?.destroy(); + this.indexArena?.destroy(); this.uniformRing?.destroy(); this.pipelineCache?.clear(); // device-scoped post-effect state: the shared capture, pool targets @@ -2595,6 +3108,9 @@ export default class WebGPURenderer extends Renderer { // in drawTileLayer rebuilds the renderer lazily this.orthogonalTMXRenderer = undefined; this.depthTexture = null; + this.msaaDepthTexture = null; + this.msaaColorTexture = null; + this.msaaColorView = null; this.device = undefined; this.adapter = undefined; // the replacement device may offer different compression families @@ -2660,6 +3176,7 @@ export default class WebGPURenderer extends Renderer { } this.textureStore?.destroy(); this.vertexArena?.destroy(); + this.indexArena?.destroy(); this.uniformRing?.destroy(); this.pipelineCache?.clear(); this.captureTexture?.destroy(); @@ -2675,6 +3192,11 @@ export default class WebGPURenderer extends Renderer { this.currentRenderTarget = null; this.depthTexture?.destroy(); this.depthTexture = null; + this.msaaDepthTexture?.destroy(); + this.msaaDepthTexture = null; + this.msaaColorTexture?.destroy(); + this.msaaColorTexture = null; + this.msaaColorView = null; this.context.unconfigure(); this.device.destroy(); this.device = undefined; diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index 26a03e186a..c787912c20 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -554,6 +554,38 @@ describe("parseGLTF() — KHR_lights_punctual", () => { const scene = await parseGLTF(buildSceneGLB()); expect(scene.lights).toEqual([]); }); + + it("captures spot cone angles, with the spec defaults when absent (#1536)", async () => { + const scene = await parseGLTF( + buildLightGLB( + { + type: "spot", + intensity: 2, + range: 12, + spot: { innerConeAngle: 0.25, outerConeAngle: 0.75 }, + }, + { + translation: [1, 2, 3], + extensions: { KHR_lights_punctual: { light: 0 } }, + }, + ), + ); + const L = scene.lights[0]; + expect(L.type).toBe("spot"); + expect(L.range).toBe(12); + expect(L.innerConeAngle).toBeCloseTo(0.25, 5); + expect(L.outerConeAngle).toBeCloseTo(0.75, 5); + + // defaults per the KHR spec: inner 0, outer π/4 + const defaulted = await parseGLTF( + buildLightGLB( + { type: "spot", spot: {} }, + { extensions: { KHR_lights_punctual: { light: 0 } } }, + ), + ); + expect(defaulted.lights[0].innerConeAngle).toBe(0); + expect(defaulted.lights[0].outerConeAngle).toBeCloseTo(Math.PI / 4, 5); + }); }); // ── Materials: baseColorFactor + vertex colors (COLOR_0) ────────────────────── @@ -1084,6 +1116,141 @@ describe("GLTFScene → lighting (KHR_lights_punctual)", () => { expect(container.kids[0].lit).toBe(false); }); + it("instantiates point/spot lights with scene-scaled position + range (#1536)", () => { + const NAME2 = "__gltf_punctual_scene"; + // same mesh, but a spot light at (1, 2, 3) with a range + cone + const source = gltfList[NAME]; + gltfList[NAME2] = { + ...source, + lights: [ + { + type: "spot", + color: [0, 1, 0], + intensity: 800, + range: 5, + innerConeAngle: 0.2, + outerConeAngle: 0.6, + direction: [0, 0, -1], + position: [1, 2, 3], + }, + ], + }; + try { + const scene = new GLTFScene(NAME2); + const container = fakeContainer(); + scene.addTo(container, { scale: 10 }); + + const lights = lightsOf(container); + const spot = lights.find((l) => { + return l.type === "spot"; + }); + expect(spot).toBeDefined(); + // glTF (1,2,3) at scale 10, rightHanded default (zSign -1): + // [x·s, -y·s, -z·s] + expect(spot.position.x).toBeCloseTo(10, 5); + expect(spot.position.y).toBeCloseTo(-20, 5); + expect(spot.position.z).toBeCloseTo(-30, 5); + // range scales like the geometry; cones pass through; intensity + // is unit-normalized (candela is meaningless for the stylized tier) + expect(spot.range).toBeCloseTo(50, 5); + expect(spot.innerConeAngle).toBeCloseTo(0.2, 5); + expect(spot.outerConeAngle).toBeCloseTo(0.6, 5); + expect(spot.intensity).toBe(1); + // a positional light counts as "added", so the ambient fill applies + expect( + lights.some((l) => { + return l.type === "ambient"; + }), + ).toBe(true); + // and a lamp-only scene flags its meshes lit — otherwise its own + // lights would shine on the unlit fast path and change nothing + expect(container.kids[0].lit).toBe(true); + } finally { + delete gltfList[NAME2]; + } + }); + + it("options.lightIntensityScale keeps authored intensity ratios", () => { + const NAME3 = "__gltf_intensity_scene"; + const source = gltfList[NAME]; + // a 1000-lux sun and a half-strength 500-candela point lamp + gltfList[NAME3] = { + ...source, + lights: [ + { + type: "directional", + color: [1, 1, 1], + intensity: 1000, + direction: [0, 0, -1], + position: [0, 0, 0], + }, + { + type: "point", + color: [1, 0, 0], + intensity: 500, + direction: [0, 0, -1], + position: [1, 0, 0], + }, + ], + }; + try { + const scene = new GLTFScene(NAME3); + const container = fakeContainer(); + scene.addTo(container, { scale: 10, lightIntensityScale: 0.001 }); + + const lights = lightsOf(container); + const directional = lights.find((l) => { + return l.type === "directional"; + }); + const point = lights.find((l) => { + return l.type === "point"; + }); + // authored lux/candela × scale — the 2:1 authored ratio survives + expect(directional.intensity).toBeCloseTo(1, 5); + expect(point.intensity).toBeCloseTo(0.5, 5); + + // without the option, every light is unit-normalized (the default) + const plain = fakeContainer(); + scene.addTo(plain, { scale: 10 }); + for (const l of lightsOf(plain)) { + if (l.type !== "ambient") { + expect(l.intensity).toBe(1); + } + } + } finally { + delete gltfList[NAME3]; + } + }); + + it("carries the authored light name onto the Light3d (getChildByName lookup)", () => { + const NAME4 = "__gltf_named_light_scene"; + const source = gltfList[NAME]; + gltfList[NAME4] = { + ...source, + lights: [ + { + type: "directional", + color: [1, 1, 1], + intensity: 1000, + direction: [0, 0, -1], + position: [0, 0, 0], + name: "Sun", + }, + ], + }; + try { + const scene = new GLTFScene(NAME4); + const container = fakeContainer(); + scene.addTo(container, { scale: 10 }); + const sun = lightsOf(container).find((l) => { + return l.type === "directional"; + }); + expect(sun.name).toBe("Sun"); + } finally { + delete gltfList[NAME4]; + } + }); + it("KHR_materials_unlit: an unlit-material mesh stays unlit even in a lit scene", async () => { // a lit scene (directional light) whose single mesh uses an unlit material const UNLIT = "__gltf_unlit_in_lit"; diff --git a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js index 1d7c518902..51e44defd6 100644 --- a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js +++ b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js @@ -24,6 +24,15 @@ export function createMockWebGPURenderer() { captureFrames: 0, // one entry per queue.writeTexture textureWrites: [], + // one entry per textureStore.getBinding: {texture, options} + textureBindings: [], + // one entry per device.createBuffer: the descriptor + createdBuffers: [], + // buffers handed to renderer.retireBuffer, in order + retiredBuffers: [], + // pass.setVertexBuffer / setIndexBuffer arguments, in order + vertexBufferBinds: [], + indexBufferBinds: [], }; const pass = { @@ -36,8 +45,12 @@ export function createMockWebGPURenderer() { calls.materialBinds.push(group); } }, - setVertexBuffer() {}, - setIndexBuffer() {}, + setVertexBuffer(slot, buffer, offset, size) { + calls.vertexBufferBinds.push({ slot, buffer, offset, size }); + }, + setIndexBuffer(buffer, format, offset, size) { + calls.indexBufferBinds.push({ buffer, format, offset, size }); + }, draw(count) { calls.draws.push(count); }, @@ -85,8 +98,10 @@ export function createMockWebGPURenderer() { }, }, createBuffer(descriptor) { + calls.createdBuffers.push(descriptor); return { size: descriptor.size, + label: descriptor.label, destroy() {}, getMappedRange() { return new ArrayBuffer(descriptor.size); @@ -134,9 +149,22 @@ export function createMockWebGPURenderer() { }, frameLayout: {}, materialLayout: {}, + multiMaterialLayout: {}, emptyLayout: {}, - get(shaderKey, topology, blendMode, premultipliedAlpha, stencilMode) { - const key = `${shaderKey}|${topology}|${blendMode}|${premultipliedAlpha}|${stencilMode}`; + get( + shaderKey, + topology, + blendMode, + premultipliedAlpha, + stencilMode, + meshState, + ) { + // the mesh-axes suffix appends ONLY when the axis is present — + // existing 2D key assertions stay byte-identical + let key = `${shaderKey}|${topology}|${blendMode}|${premultipliedAlpha}|${stencilMode}`; + if (meshState) { + key += `|mesh:${meshState.cullMode}:${meshState.frontFace}`; + } calls.pipelineKeys.push(key); if (!pipelines.has(key)) { pipelines.set(key, { key }); @@ -145,8 +173,23 @@ export function createMockWebGPURenderer() { }, }, vertexArena: { + page: { label: "vertex page 0" }, alloc() { - return { buffer: {}, offset: 0 }; + return { buffer: this.page, offset: 0 }; + }, + }, + // per-frame index regions for the accumulated mesh path — bump + // allocator so consecutive flushes get distinct offsets + indexArena: { + offset: 0, + page: { label: "index page 0" }, + alloc(byteLength) { + const region = { buffer: this.page, offset: this.offset }; + this.offset += (byteLength + 3) & ~3; + return region; + }, + reset() { + this.offset = 0; }, }, // bump allocator over labeled fake pages, alignment-honoring — the @@ -179,9 +222,13 @@ export function createMockWebGPURenderer() { return this.stubView; }, retireTexture() {}, + retireBuffer(buffer) { + calls.retiredBuffers.push(buffer); + }, textureStore: { // one stable bind-group token per atlas object - getBinding(texture) { + getBinding(texture, options) { + calls.textureBindings.push({ texture, options }); if (!materialBindings.has(texture)) { materialBindings.set(texture, { texture }); } @@ -200,8 +247,15 @@ export function createMockWebGPURenderer() { } return this.records.get(texture); }, - getSampler(filter, repeat) { - return { filter, repeat }; + samplers: new Map(), + getSampler(filter, repeat, mipmaps = false) { + // cached per combo like the real store — composition caches + // key on sampler identity + const key = `${filter}|${repeat}|${mipmaps}`; + if (!this.samplers.has(key)) { + this.samplers.set(key, { filter, repeat, mipmaps }); + } + return this.samplers.get(key); }, }, getDefaultTextureFilter() { diff --git a/packages/melonjs/tests/lighting3d.spec.js b/packages/melonjs/tests/lighting3d.spec.js index f3b7e7624c..db5169eb94 100644 --- a/packages/melonjs/tests/lighting3d.spec.js +++ b/packages/melonjs/tests/lighting3d.spec.js @@ -62,7 +62,7 @@ describe("Light3d", () => { expect(l.intensity).toBe(0.4); }); - it("carries type + position for a future point release", () => { + it("carries type + position + range + cone fields for point/spot", () => { const l = new Light3d({ type: "point", position: [1, 2, 3] }); expect(l.type).toBe("point"); expect([l.position.x, l.position.y, l.position.z]).toEqual([1, 2, 3]); @@ -119,12 +119,15 @@ describe("packMeshLights", () => { ]); expect(p.count).toBe(1); // 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); + expect(p.dirCone[0]).toBeCloseTo(0, 5); + expect(p.dirCone[1]).toBeCloseTo(-1, 5); + expect(p.dirCone[2]).toBeCloseTo(0, 5); + // directional sentinels: range < 0, no cone + expect(p.posRange[3]).toBe(-1); + expect(p.dirCone[3]).toBe(-1); // color (1,0,0) × intensity 2 - expect(p.colors[0]).toBeCloseTo(2, 5); - expect(p.colors[1]).toBeCloseTo(0, 5); + expect(p.colorInner[0]).toBeCloseTo(2, 5); + expect(p.colorInner[1]).toBeCloseTo(0, 5); }); it("sums ambient lights into the ambient color (color × intensity)", () => { @@ -144,12 +147,58 @@ describe("packMeshLights", () => { expect(p.ambient[0]).toBeCloseTo(0.3, 5); }); - it("ADVERSARIAL: skips non-directional (point) lights", () => { + it("packs point lights: position + range, no-cone sentinel (#1536)", () => { const p = packMeshLights([ - new Light3d({ type: "point" }), - new Light3d({ type: "directional" }), + new Light3d({ + type: "point", + position: [10, -20, 30], + color: [0, 1, 0], + intensity: 2, + range: 250, + }), ]); - expect(p.count).toBe(1); // only the directional one + expect(p.count).toBe(1); + expect([p.posRange[0], p.posRange[1], p.posRange[2]]).toEqual([ + 10, -20, 30, + ]); + expect(p.posRange[3]).toBe(250); + // a point light has no cone: the sentinel disables the cone factor + expect(p.dirCone[3]).toBe(-1); + expect(p.colorInner[1]).toBeCloseTo(2, 5); + }); + + it("packs spot lights: cone cosines, inner clamped inside outer (#1536)", () => { + const p = packMeshLights([ + new Light3d({ + type: "spot", + position: [0, 0, 0], + direction: [0, 0, 4], // non-unit — must normalize + range: 500, + innerConeAngle: 0.2, + outerConeAngle: 0.6, + }), + ]); + expect(p.count).toBe(1); + // the cone axis is the TRAVEL direction (not negated), normalized + expect(p.dirCone[2]).toBeCloseTo(1, 5); + expect(p.dirCone[3]).toBeCloseTo(Math.cos(0.6), 5); + expect(p.colorInner[3]).toBeCloseTo(Math.cos(0.2), 5); + + // inner >= outer collapses the smoothstep denominator — clamp keeps + // cosInner strictly greater than cosOuter + const degenerate = packMeshLights([ + new Light3d({ + type: "spot", + innerConeAngle: 1.0, + outerConeAngle: 0.5, + }), + ]); + expect(degenerate.colorInner[3]).toBeGreaterThan(degenerate.dirCone[3]); + }); + + it("a degenerate range packs as at least 1 (falloff needs a scale)", () => { + const p = packMeshLights([new Light3d({ type: "point", range: 0 })]); + expect(p.posRange[3]).toBe(1); }); it("ADVERSARIAL: clamps to MAX_LIGHTS", () => { @@ -163,18 +212,18 @@ describe("packMeshLights", () => { it("ADVERSARIAL: reuses its buffers (later state overwrites)", () => { const light = new Light3d({ direction: [1, 0, 0], intensity: 1 }); const p1 = packMeshLights([light]); - expect(p1.directions[0]).toBeCloseTo(-1, 5); + expect(p1.dirCone[0]).toBeCloseTo(-1, 5); light.direction.set(0, 0, 1); const p2 = packMeshLights([light]); - expect(p2.directions).toBe(p1.directions); // same Float32Array - expect(p2.directions[2]).toBeCloseTo(-1, 5); // normalized + negated + expect(p2.dirCone).toBe(p1.dirCone); // same Float32Array + expect(p2.dirCone[2]).toBeCloseTo(-1, 5); // normalized + negated }); it("ADVERSARIAL: a runtime non-unit direction is normalized in pack", () => { const light = new Light3d(); light.direction.set(0, 0, 9); // not unit const p = packMeshLights([light]); - const len = Math.hypot(p.directions[0], p.directions[1], p.directions[2]); + const len = Math.hypot(p.dirCone[0], p.dirCone[1], p.dirCone[2]); expect(len).toBeCloseTo(1, 5); }); }); diff --git a/packages/melonjs/tests/lighting_block_wiring.spec.js b/packages/melonjs/tests/lighting_block_wiring.spec.js index 6189a4bdd2..98e5a023eb 100644 --- a/packages/melonjs/tests/lighting_block_wiring.spec.js +++ b/packages/melonjs/tests/lighting_block_wiring.spec.js @@ -204,16 +204,19 @@ describe("lit batchers → light uniform block (issue #1552)", () => { // runs, since the light upload moved out of `bind()` batcher.bind(); batcher.updatePassState(); - const gpu = readback(batcher.lightBlock, HEADER_FLOATS + 8); + const gpu = readback(batcher.lightBlock, HEADER_FLOATS + 12); expect(gpu[0]).toBe(1); // one directional light - // surface→light is the negated travel direction, normalized - expect(gpu[8]).toBeCloseTo(0, 5); - expect(gpu[9]).toBeCloseTo(-1, 5); - expect(gpu[10]).toBeCloseTo(0, 5); - // colour premultiplied by intensity, w padding left at zero - expect(gpu[12]).toBeCloseTo(1, 5); - expect(gpu[13]).toBeCloseTo(0, 5); - expect(gpu[15]).toBe(0); + // 12-float stride: posRange (directional sentinel range -1), + // then dirCone with surface→light = negated travel direction + expect(gpu[11]).toBe(-1); + expect(gpu[12]).toBeCloseTo(0, 5); + expect(gpu[13]).toBeCloseTo(-1, 5); + expect(gpu[14]).toBeCloseTo(0, 5); + // no cone on a directional light + expect(gpu[15]).toBe(-1); + // colour premultiplied by intensity + expect(gpu[16]).toBeCloseTo(1, 5); + expect(gpu[17]).toBeCloseTo(0, 5); expect(gl.getError()).toBe(gl.NO_ERROR); } finally { stage._activeLights3d = previous; diff --git a/packages/melonjs/tests/lighting_std140.spec.js b/packages/melonjs/tests/lighting_std140.spec.js index b9b1d243c7..b7d131c6ac 100644 --- a/packages/melonjs/tests/lighting_std140.spec.js +++ b/packages/melonjs/tests/lighting_std140.spec.js @@ -3,8 +3,10 @@ import { MAX_LIGHTS } from "../src/video/webgl/lighting/constants.ts"; import { BLOCK_BYTES, BLOCK_FLOATS, + BLOCK3D_FLOATS, HEADER_FLOATS, LIGHT_FLOATS, + LIGHT3D_FLOATS, writeLight2dBlock, writeLight3dBlock, } from "../src/video/webgl/lighting/std140.ts"; @@ -53,13 +55,16 @@ describe("std140 light block layout (issue #1552)", () => { // float 7 padding // float 8 first light, component 0 // - // per light, 8 floats: - // 2D: [x, y, radius, intensity] [r, g, b, height] - // 3D: [dx, dy, dz, 0] [r, g, b, 0] + // per light: + // 2D, 8 floats: [x, y, radius, intensity] [r, g, b, height] + // 3D, 12 floats: [px, py, pz, range] [dx, dy, dz, cosOuter] + // [r, g, b, cosInner] (range -1 = directional, + // cosOuter -1 = no cone) const COUNT = 0; const AMBIENT = 4; const FIRST_LIGHT = 8; const PER_LIGHT = 8; + const PER_LIGHT3D = 12; describe("the constants themselves", () => { it("puts the first light at float 8, not float 4", () => { @@ -228,33 +233,41 @@ describe("std140 light block layout (issue #1552)", () => { }); describe("3D light block", () => { + const scratch3d = () => { + return new Float32Array(BLOCK3D_FLOATS); + }; + /** * @param {number} n - how many lights - * @returns {object} packed mesh light data + * @returns {object} packed mesh light data (writer shape) */ const packed3d = (n) => { - const directions = new Float32Array(MAX_LIGHTS * 3); - const colors = new Float32Array(MAX_LIGHTS * 3); + const posRange = new Float32Array(MAX_LIGHTS * 4); + const dirCone = new Float32Array(MAX_LIGHTS * 4); + const colorInner = new Float32Array(MAX_LIGHTS * 4); for (let i = 0; i < n; i++) { - directions[i * 3] = 10 + i; - directions[i * 3 + 1] = 20 + i; - directions[i * 3 + 2] = 30 + i; - colors[i * 3] = 40 + i; - colors[i * 3 + 1] = 50 + i; - colors[i * 3 + 2] = 60 + i; + posRange.set([10 + i, 20 + i, 30 + i, 100 + i], i * 4); + dirCone.set([40 + i, 50 + i, 60 + i, 0.5], i * 4); + colorInner.set([70 + i, 80 + i, 90 + i, 0.9], i * 4); } return { count: n, - directions, - colors, + posRange, + dirCone, + colorInner, ambient: new Float32Array([7, 8, 9]), }; }; + it("spends 12 floats per 3D light and sizes the block for the cap", () => { + expect(LIGHT3D_FLOATS).toBe(12); + expect(BLOCK3D_FLOATS).toBe(HEADER_FLOATS + MAX_LIGHTS * 12); + }); + it("shares the header layout with the 2D block", () => { // both blocks are read by the same helper on the JS side, so a // divergence here would only show up in one of the two shaders - const out = createLightBlockScratch(); + const out = scratch3d(); writeLight3dBlock(out, packed3d(2)); expect(out[COUNT]).toBe(2); expect(out[AMBIENT]).toBe(7); @@ -263,48 +276,43 @@ describe("std140 light block layout (issue #1552)", () => { expect(out[7]).toBe(0); }); - it("places direction and colour at hand-computed offsets", () => { - const out = createLightBlockScratch(); + it("places every field of the first light at hand-computed offsets", () => { + const out = scratch3d(); writeLight3dBlock(out, packed3d(1)); + // posRange expect(out[FIRST_LIGHT]).toBe(10); expect(out[FIRST_LIGHT + 1]).toBe(20); expect(out[FIRST_LIGHT + 2]).toBe(30); + expect(out[FIRST_LIGHT + 3]).toBe(100); + // dirCone expect(out[FIRST_LIGHT + 4]).toBe(40); expect(out[FIRST_LIGHT + 5]).toBe(50); expect(out[FIRST_LIGHT + 6]).toBe(60); + expect(out[FIRST_LIGHT + 7]).toBeCloseTo(0.5); + // colorInner + expect(out[FIRST_LIGHT + 8]).toBe(70); + expect(out[FIRST_LIGHT + 9]).toBe(80); + expect(out[FIRST_LIGHT + 10]).toBe(90); + expect(out[FIRST_LIGHT + 11]).toBeCloseTo(0.9); }); - it("zeroes the two reserved w components", () => { - // reserved for a future point/spot light's range and cone angle - // (#1536); garbage there would become data the moment they are used - const out = createLightBlockScratch(); - out.fill(999); + it("strides by 12 floats for every slot", () => { + const out = scratch3d(); writeLight3dBlock(out, packed3d(MAX_LIGHTS)); for (let i = 0; i < MAX_LIGHTS; i++) { - const o = FIRST_LIGHT + i * PER_LIGHT; - expect(out[o + 3], `light ${i} direction w`).toBe(0); - expect(out[o + 7], `light ${i} colour w`).toBe(0); - } - }); - - it("strides by 8 floats even though the source strides by 3", () => { - // the source arrays are vec3-packed and the destination is - // vec4-padded; conflating the two is the single most likely bug - const out = createLightBlockScratch(); - writeLight3dBlock(out, packed3d(MAX_LIGHTS)); - for (let i = 0; i < MAX_LIGHTS; i++) { - const o = FIRST_LIGHT + i * PER_LIGHT; - expect(out[o], `light ${i} dx`).toBe(10 + i); - expect(out[o + 4], `light ${i} r`).toBe(40 + i); + const o = FIRST_LIGHT + i * PER_LIGHT3D; + expect(out[o], `light ${i} px`).toBe(10 + i); + expect(out[o + 4], `light ${i} dx`).toBe(40 + i); + expect(out[o + 8], `light ${i} r`).toBe(70 + i); } }); it("returns the live prefix and writes no further", () => { - const out = createLightBlockScratch(); + const out = scratch3d(); out.fill(-1); const used = writeLight3dBlock(out, packed3d(3)); - expect(used).toBe(FIRST_LIGHT + 24); - for (let i = used; i < BLOCK_FLOATS; i++) { + expect(used).toBe(FIRST_LIGHT + 3 * PER_LIGHT3D); + for (let i = used; i < BLOCK3D_FLOATS; i++) { expect(out[i], `float ${i}`).toBe(-1); } }); @@ -428,13 +436,14 @@ describe("std140 light block — driver-reported layout (issue #1552)", () => { [ "uLightCount", "uAmbient", - "uLights[0].direction", - "uLights[0].color", - "uLights[1].direction", + "uLights[0].posRange", + "uLights[0].dirCone", + "uLights[0].colorInner", + "uLights[1].posRange", ], ); - expect(size).toBe(BLOCK_BYTES); - expect(offsets).toEqual([0, 16, 32, 48, 64]); + expect(size).toBe(BLOCK3D_FLOATS * Float32Array.BYTES_PER_ELEMENT); + expect(offsets).toEqual([0, 16, 32, 48, 64, 80]); }); it("the last light fits inside the block the driver reserved", (ctx) => { diff --git a/packages/melonjs/tests/renderer_auto.spec.js b/packages/melonjs/tests/renderer_auto.spec.js new file mode 100644 index 0000000000..63d66aae28 --- /dev/null +++ b/packages/melonjs/tests/renderer_auto.spec.js @@ -0,0 +1,67 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + CanvasRenderer, + video, + WebGLRenderer, + WebGPURenderer, +} from "../src/index.js"; + +/** + * The `video.AUTO` ladder: WebGPU first (proven by a full adapter/device + * negotiation), WebGL 2 next, Canvas as the terminal fallback — and AUTO + * must always RESOLVE, never reject, whatever the environment offers. + * Which rung this environment lands on is probed with a bare adapter + * request (the same negotiation init performs), so the assertion adapts + * to the runner without spinning up a second Application. + */ +describe("video.AUTO renderer ladder", () => { + let webgpuAvailable = false; + let app; + + beforeAll(async () => { + try { + const adapter = await globalThis.navigator?.gpu?.requestAdapter(); + webgpuAvailable = adapter != null; + } catch { + webgpuAvailable = false; + } + app = new Application(64, 64, { + renderer: video.AUTO, + consoleHeader: false, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + it("always resolves, landing on WebGPU exactly when the environment negotiates it", () => { + expect(app.isInitialized).toBe(true); + if (webgpuAvailable) { + expect(app.renderer).toBeInstanceOf(WebGPURenderer); + } else { + // no WebGPU in this environment — the synchronous tail picks + // WebGL 2 (or Canvas where even that is unavailable) + expect( + app.renderer instanceof WebGLRenderer || + app.renderer instanceof CanvasRenderer, + ).toBe(true); + } + // whoever won, it carries its owning application + expect(app.renderer.parentApplication).toBe(app); + }); + + it("a second init() on a live WebGPU renderer is a no-op, not a renegotiation", async (ctx) => { + if (!webgpuAvailable) { + ctx.skip("WebGPU not available in this environment"); + return; + } + const device = app.renderer.device; + // AUTO already awaited init() once during negotiation and once on + // the common path — a third call must keep the same device + await app.renderer.init(); + expect(app.renderer.device).toBe(device); + }); +}); diff --git a/packages/melonjs/tests/shader-loader.spec.js b/packages/melonjs/tests/shader-loader.spec.js index 9e6cc77e8a..4a0a30191c 100644 --- a/packages/melonjs/tests/shader-loader.spec.js +++ b/packages/melonjs/tests/shader-loader.spec.js @@ -1,9 +1,10 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { boot, GLShader, game, loader, + Renderable, ShaderEffect, WebGLRenderer, } from "../src/index.js"; @@ -643,4 +644,110 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { expect(loader.getShader("wgsl-only")).toBe(null); }); }); + + describe("complete programs with a WGSL module ({vertex, fragment, wgsl})", () => { + const PAIR_VERTEX = [ + "attribute vec3 aVertex;", + "uniform mat4 uProjectionMatrix;", + "void main(void) {", + " gl_Position = uProjectionMatrix * vec4(aVertex, 1.0);", + "}", + ].join("\n"); + const PAIR_FRAGMENT = [ + "void main(void) {", + " gl_FragColor = vec4(1.0);", + "}", + ].join("\n"); + // a complete module — the `@vertex` entry point is what routes a + // `wgsl` source to the program path instead of the effect-body path + const WGSL_MODULE = [ + "@vertex", + "fn vertex_main(@location(0) aVertex : vec3f) -> @builtin(position) vec4f {", + " return vec4f(aVertex, 1.0);", + "}", + "@fragment", + "fn fragment_main() -> @location(0) vec4f {", + " return vec4f(1.0);", + "}", + ].join("\n"); + + it("a dual-backend program compiles the GLSL pair on WebGL and carries the wgsl module", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "dual-program", + type: "shader", + data: { + vertex: PAIR_VERTEX, + fragment: PAIR_FRAGMENT, + wgsl: WGSL_MODULE, + }, + }); + const shader = loader.getShader("dual-program"); + expect(shader).toBeInstanceOf(GLShader); + expect(shader.shared).toBe(true); + // both realizations present: the GL program is live, the module rides along + expect(shader.isWebGL).toBe(true); + expect(shader.isWebGPU).toBe(true); + expect(shader.wgsl).toBe(WGSL_MODULE); + expect(shader.program).not.toBe(null); + loader.unload({ name: "dual-program", type: "shader" }); + }); + + it("src URLs: all three sources fetch, the GLSL pair compiles on WebGL", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "dual-program-src", + type: "shader", + src: { + vertex: `data:text/plain,${encodeURIComponent(PAIR_VERTEX)}`, + fragment: `data:text/plain,${encodeURIComponent(PAIR_FRAGMENT)}`, + wgsl: `data:text/plain,${encodeURIComponent(WGSL_MODULE)}`, + }, + }); + expect(loader.getShader("dual-program-src")).toBeInstanceOf(GLShader); + loader.unload({ name: "dual-program-src", type: "shader" }); + }); + + it("a wgsl-module-only program on a GLSL renderer loads as a WebGPU-only shader (inert here, no warning)", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await loader.load({ + name: "module-only", + type: "shader", + data: { wgsl: WGSL_MODULE }, + }); + const shader = loader.getShader("module-only"); + expect(shader).toBeInstanceOf(GLShader); + // it HAS a realization (WebGPU) — just not one this backend hosts + expect(shader.isWebGPU).toBe(true); + expect(shader.isWebGL).toBe(false); + expect(shader.program).toBe(null); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + loader.unload({ name: "module-only", type: "shader" }); + }); + + it("assigning a null shader (unknown asset) clears rather than crashes", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const target = new Renderable(0, 0, 10, 10); + target.shader = loader.getShader("no-such-asset"); + expect(target.postEffects).toHaveLength(0); + // the renderer's effect filter never sees a null entry + expect(() => { + game.renderer.beginPostEffect(target); + }).not.toThrow(); + }); + }); }); diff --git a/packages/melonjs/tests/shadereffect_dual_body.spec.js b/packages/melonjs/tests/shadereffect_dual_body.spec.js index b07603dc0e..ecc369bedd 100644 --- a/packages/melonjs/tests/shadereffect_dual_body.spec.js +++ b/packages/melonjs/tests/shadereffect_dual_body.spec.js @@ -249,4 +249,32 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { expect(effect.wgslRealization.gpu).toBeNull(); }); }); + + describe("_setUVYDir (directional-body orientation seam)", () => { + it("DropShadow declares uUVYDir, defaults +1, and takes the pooled -1", async () => { + const { default: DropShadowEffect } = await import( + "../src/video/effects/dropShadow.js" + ); + const effect = new DropShadowEffect(wgslRenderer); + expect(effect.wgslRealization.hasUniform("uUVYDir")).toBe(true); + // down is down until a renderer path says otherwise + expect(effect.wgslRealization.values.get("uUVYDir")).toBe(1); + // the GL pooled path flips it (WebGPU never calls with -1) + effect._setUVYDir(-1); + expect(effect.wgslRealization.values.get("uUVYDir")).toBe(-1); + // cached: same direction re-fed per blit costs nothing + effect._setUVYDir(-1); + effect._setUVYDir(1); + expect(effect.wgslRealization.values.get("uUVYDir")).toBe(1); + }); + + it("a body without the uniform ignores it silently", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + warn.mockClear(); + effect._setUVYDir(-1); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); }); diff --git a/packages/melonjs/tests/webgl_mesh_mipmap.spec.js b/packages/melonjs/tests/webgl_mesh_mipmap.spec.js new file mode 100644 index 0000000000..e27975f5e4 --- /dev/null +++ b/packages/melonjs/tests/webgl_mesh_mipmap.spec.js @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Mesh } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * Mesh-texture mipmaps on the WebGL backend: `createTexture2D` has always + * generated the chain for plain image uploads — the mesh path now samples + * it, upgrading the texture's min filter to trilinear on first mesh use. + * "nearest" meshes opt out (crisp pixel-art models), and the upgrade is + * mesh-path-only: sprite-created textures keep their plain min filter. + */ +describe("WebGL mesh texture mipmaps", () => { + let renderer; + + beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeTexturedMesh = (settings = {}) => { + const canvas = document.createElement("canvas"); + canvas.width = 32; + canvas.height = 32; + canvas.getContext("2d").fillRect(0, 0, 32, 32); + return new Mesh(0, 0, { + vertices: [-8, -8, 0, 8, -8, 0, 8, 8, 0, -8, 8, 0], + uvs: [0, 0, 1, 0, 1, 1, 0, 1], + indices: [0, 1, 2, 0, 2, 3], + width: 16, + height: 16, + texture: canvas, + ...settings, + }); + }; + + const boundMinFilter = () => { + const gl = renderer.gl; + return gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER); + }; + + it("a linear-filtered mesh texture upgrades to trilinear minification (+ anisotropy)", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeTexturedMesh({ textureFilter: "linear" }); + try { + renderer.drawMesh(mesh); + // applyMeshMaterial leaves the mesh texture bound on the active + // unit — the chain generated at upload is now actually sampled + expect(boundMinFilter()).toBe(gl.LINEAR_MIPMAP_LINEAR); + // and anisotropic where the driver offers it (headless software + // rasterizers may not — the upgrade is best-effort) + const ext = gl.getExtension("EXT_texture_filter_anisotropic"); + if (ext) { + expect( + gl.getTexParameter(gl.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT), + ).toBeGreaterThan(1); + } + } finally { + renderer.deleteMeshGeometry?.(mesh); + } + }); + + it("a nearest-filtered mesh keeps hard level-0 minification (opt-out)", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeTexturedMesh({ textureFilter: "nearest" }); + try { + renderer.drawMesh(mesh); + expect(boundMinFilter()).toBe(gl.NEAREST); + } finally { + renderer.deleteMeshGeometry?.(mesh); + } + }); + + it("a TextureResource-backed source keeps plain LINEAR (no generated chain to sample)", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeTexturedMesh({ textureFilter: "linear" }); + // a resource-owned upload: `createTexture2D` skips generateMipmap + // for these, so a mipmap min filter would be mipmap-incomplete + // under ES3 and sample opaque black + mesh.texture.getTexture = () => { + return { + width: 32, + height: 32, + upload() {}, + }; + }; + try { + renderer.drawMesh(mesh); + expect(boundMinFilter()).toBe(gl.LINEAR); + } finally { + renderer.deleteMeshGeometry?.(mesh); + } + }); + + it("a video texture re-applies the trilinear upgrade after a forced re-upload", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeTexturedMesh({ textureFilter: "linear" }); + // video-shaped source with no decoded frame: the frameless guard + // allocates blank (generateMipmap still runs), and the per-frame + // forced re-upload resets MIN_FILTER back to LINEAR each time + mesh.texture.getTexture = () => { + return { + width: 0, + height: 0, + videoWidth: 32, + videoHeight: 32, + readyState: 0, + }; + }; + try { + renderer.drawMesh(mesh); + expect(boundMinFilter()).toBe(gl.LINEAR_MIPMAP_LINEAR); + // simulate the video path's per-frame forced re-upload (resets + // the min filter), then draw the mesh again + renderer.currentBatcher.uploadTexture( + mesh.texture, + undefined, + undefined, + true, + true, + mesh.textureRepeat, + ); + expect(boundMinFilter()).toBe(gl.LINEAR); + renderer.drawMesh(mesh); + // videos bypass the once-per-texture cache — upgraded again + expect(boundMinFilter()).toBe(gl.LINEAR_MIPMAP_LINEAR); + } finally { + renderer.deleteMeshGeometry?.(mesh); + } + }); +}); diff --git a/packages/melonjs/tests/webgl_review_fixes.spec.js b/packages/melonjs/tests/webgl_review_fixes.spec.js new file mode 100644 index 0000000000..e9c9adcd25 --- /dev/null +++ b/packages/melonjs/tests/webgl_review_fixes.spec.js @@ -0,0 +1,221 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { GLShader, Mesh } from "../src/index.js"; +import ShineEffect from "../src/video/effects/shine.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * WebGL twins of the custom-mesh-shader coverage (the WebGPU side lives in + * webgpu_custom_mesh_shader.spec.js), plus the review-driven parity fixes: + * the hosted-shader guard and one-shot warn in `drawMesh`, the guarded + * `uSampler`, exception-safe hosting, `setBlendMode("none")`, and shine's + * `uUVYDir` adoption. + */ + +// a complete GLSL program over the mesh contract (attributes in the frozen +// layout order, engine-fed uniforms declared) +const TOON_VERTEX = [ + "attribute vec3 aVertex;", + "attribute vec2 aRegion;", + "attribute vec4 aColor;", + "uniform mat4 uProjectionMatrix;", + "uniform mat4 uViewMatrix;", + "uniform mat4 uModelMatrix;", + "varying vec2 vRegion;", + "varying vec4 vColor;", + "void main(void) {", + " gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0);", + " vRegion = aRegion;", + " vColor = aColor;", + "}", +].join("\n"); +const TOON_FRAGMENT = [ + "precision mediump float;", + "uniform sampler2D uSampler;", + "varying vec2 vRegion;", + "varying vec4 vColor;", + "void main(void) {", + " vec4 color = texture2D(uSampler, vRegion) * vColor;", + " gl_FragColor = vec4(floor(color.rgb * 4.0) / 4.0, color.a);", + "}", +].join("\n"); +// no texture read at all — vertex colors only (no uSampler declared) +const FLAT_FRAGMENT = [ + "precision mediump float;", + "varying vec2 vRegion;", + "varying vec4 vColor;", + "void main(void) {", + " gl_FragColor = vColor;", + "}", +].join("\n"); + +const WGSL_MODULE = [ + "@vertex", + "fn vertex_main(@location(0) aVertex : vec3f) -> @builtin(position) vec4f {", + " return vec4f(aVertex, 1.0);", + "}", + "@fragment", + "fn fragment_main() -> @location(0) vec4f { return vec4f(1.0); }", +].join("\n"); + +let renderer; + +beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); +}); + +afterAll(() => { + releaseWebGLRenderer(); +}); + +describe("WebGL drawMesh — custom shader hosting", () => { + afterEach(() => { + if (renderer) { + renderer.customShader = undefined; + renderer._meshShaderWarned = false; + } + }); + + const makeTexturedMesh = (settings = {}) => { + const canvas = document.createElement("canvas"); + canvas.width = 32; + canvas.height = 32; + canvas.getContext("2d").fillRect(0, 0, 32, 32); + return new Mesh(0, 0, { + vertices: [-8, -8, 0, 8, -8, 0, 8, 8, 0, -8, 8, 0], + uvs: [0, 0, 1, 0, 1, 1, 0, 1], + indices: [0, 1, 2, 0, 2, 3], + width: 16, + height: 16, + texture: canvas, + ...settings, + }); + }; + + it("hosts a complete GLSL program and reverts to the default shader", (ctx) => { + requireWebGL(ctx, renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const shader = new GLShader(renderer.gl, TOON_VERTEX, TOON_FRAGMENT); + const mesh = makeTexturedMesh(); + renderer.customShader = shader; + try { + renderer.drawMesh(mesh); + const batcher = renderer.currentBatcher; + // the finally bracket reverted to the built-in shading — the + // next unshaded mesh must not silently draw with the custom one + expect(batcher.currentShader).toBe(batcher.defaultShader); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + shader.destroy(); + mesh.destroy(); + } + }); + + it("a custom shader that never samples the texture (no uSampler) draws without throwing", (ctx) => { + requireWebGL(ctx, renderer); + const shader = new GLShader(renderer.gl, TOON_VERTEX, FLAT_FRAGMENT); + const mesh = makeTexturedMesh(); + renderer.customShader = shader; + try { + expect(() => { + renderer.drawMesh(mesh); + }).not.toThrow(); + } finally { + shader.destroy(); + mesh.destroy(); + } + }); + + it("a WGSL-only shader warns once and draws with the built-in shading", (ctx) => { + requireWebGL(ctx, renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const shader = new GLShader(undefined, { wgsl: WGSL_MODULE }); + const mesh = makeTexturedMesh(); + renderer.customShader = shader; + try { + renderer.drawMesh(mesh); + renderer.drawMesh(mesh); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/Mesh/); + const batcher = renderer.currentBatcher; + expect(batcher.currentShader).toBe(batcher.defaultShader); + } finally { + warn.mockRestore(); + mesh.destroy(); + } + }); + + it("an exception mid-draw still reverts the hosted shader and the cull toggle", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const shader = new GLShader(renderer.gl, TOON_VERTEX, TOON_FRAGMENT); + const mesh = makeTexturedMesh({ cullBackFaces: true }); + renderer.customShader = shader; + // route the draw into a throw after the shader was hosted + renderer.drawMesh(mesh); // warm: batcher selected + const batcher = renderer.currentBatcher; + const original = batcher.addMesh; + batcher.addMesh = () => { + throw new Error("boom"); + }; + try { + expect(() => { + renderer.drawMesh(mesh); + }).toThrow("boom"); + expect(batcher.currentShader).toBe(batcher.defaultShader); + expect(gl.isEnabled(gl.CULL_FACE)).toBe(false); + } finally { + batcher.addMesh = original; + shader.destroy(); + mesh.destroy(); + } + }); +}); + +describe("WebGL setBlendMode('none')", () => { + it("disables blending (replace), and a later mode re-enables it", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + try { + expect(renderer.setBlendMode("none")).toBe("none"); + expect(gl.isEnabled(gl.BLEND)).toBe(false); + // no silent downgrade to "normal" (the pre-fix behavior) + expect(renderer.currentBlendMode).toBe("none"); + expect(renderer.setBlendMode("normal")).toBe("normal"); + expect(gl.isEnabled(gl.BLEND)).toBe(true); + } finally { + renderer.setBlendMode("normal"); + } + }); +}); + +describe("ShineEffect uUVYDir adoption", () => { + it("declares the directional uniform, defaulted top-down, and accepts the feed", (ctx) => { + requireWebGL(ctx, renderer); + const fx = new ShineEffect(renderer, { angle: Math.PI / 2 }); + try { + // active on the compiled program (the sweep uses it, so the + // compiler cannot eliminate it) — the batcher feed sites can + // flip it on the bottom-up pooled path + expect(typeof fx._shader.uniforms.uUVYDir).not.toBe("undefined"); + expect(() => { + fx._setUVYDir(-1); + fx._setUVYDir(1); + }).not.toThrow(); + } finally { + fx.destroy(); + } + }); +}); diff --git a/packages/melonjs/tests/webgl_sync_program.spec.js b/packages/melonjs/tests/webgl_sync_program.spec.js new file mode 100644 index 0000000000..226b762f46 --- /dev/null +++ b/packages/melonjs/tests/webgl_sync_program.spec.js @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import WebGLBatcher from "../src/video/webgl/batchers/batcher.js"; + +/** + * `WebGLBatcher.syncProgram` — the raw program rebind that heals + * renderer/batcher program drift after an emitted ONCONTEXT_RESTORED + * (every GLShader recompiles and binds itself to replay uniforms, leaving + * a FOREIGN program bound while the renderer's `currentProgram` cache is + * stale). Pure pointer-compare logic — pinned over a stub, no context. + */ +describe("WebGLBatcher.syncProgram", () => { + const makeStub = (shaderProgram, rendererProgram) => { + const shader = { + program: shaderProgram, + bind: vi.fn(), + }; + return { + stub: { + currentShader: shader, + renderer: { currentProgram: rendererProgram }, + }, + shader, + }; + }; + + it("rebinds when the renderer's program cache drifted (raw bind, no flush)", () => { + const program = { id: "mine" }; + const { stub, shader } = makeStub(program, { id: "foreign" }); + WebGLBatcher.prototype.syncProgram.call(stub); + expect(shader.bind).toHaveBeenCalledTimes(1); + expect(stub.renderer.currentProgram).toBe(program); + }); + + it("also rebinds when the cache was invalidated (undefined)", () => { + const program = { id: "mine" }; + const { stub, shader } = makeStub(program, undefined); + WebGLBatcher.prototype.syncProgram.call(stub); + expect(shader.bind).toHaveBeenCalledTimes(1); + expect(stub.renderer.currentProgram).toBe(program); + }); + + it("no-ops in the steady state (pointer match) and with no shader", () => { + const program = { id: "mine" }; + const { stub, shader } = makeStub(program, program); + WebGLBatcher.prototype.syncProgram.call(stub); + expect(shader.bind).not.toHaveBeenCalled(); + + const bare = { currentShader: undefined, renderer: {} }; + expect(() => { + WebGLBatcher.prototype.syncProgram.call(bare); + }).not.toThrow(); + }); +}); diff --git a/packages/melonjs/tests/webgpu_compressed.spec.js b/packages/melonjs/tests/webgpu_compressed.spec.js index e97e377006..a65e5aa4b1 100644 --- a/packages/melonjs/tests/webgpu_compressed.spec.js +++ b/packages/melonjs/tests/webgpu_compressed.spec.js @@ -200,6 +200,16 @@ describe("WebGPU compressed textures", () => { store.getBinding(atlas); expect(writes).toHaveLength(2); + // the mesh path (mipmaps: true) samples the AUTHORED chain through + // the full view — no re-upload, no recreate, distinct bind group + const meshBind = store.getBinding(atlas, { mipmaps: true }); + expect(meshBind).not.toBe(bindGroup); + expect( + meshBind.descriptor.entries[0].resource.viewDescriptor, + ).toBeUndefined(); + expect(writes).toHaveLength(2); + expect(created).toHaveLength(1); + // a recycled unit must NOT adopt a same-size image source into the // compressed-format texture (non-renderable format — the copy would // fail validation while the stale pixels kept serving): it recreates diff --git a/packages/melonjs/tests/webgpu_custom_mesh_shader.spec.js b/packages/melonjs/tests/webgpu_custom_mesh_shader.spec.js new file mode 100644 index 0000000000..c4ca6bb38d --- /dev/null +++ b/packages/melonjs/tests/webgpu_custom_mesh_shader.spec.js @@ -0,0 +1,290 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it, vi } from "vitest"; +import { Color, GLShader, WebGPURenderer } from "../src/index.js"; +import WebGPULitMeshBatcher from "../src/video/webgpu/batchers/lit_mesh_batcher.js"; +import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +// a minimal-but-plausible custom module (the exact text is irrelevant to +// the mock device — registration keys on it verbatim) +const TOON_WGSL = ` +@vertex +fn vertex_main(@location(0) aVertex : vec3f) -> @builtin(position) vec4f { + return vec4f(aVertex, 1.0); +} +@fragment +fn fragment_main() -> @location(0) vec4f { + return vec4f(1.0); +} +`; + +// a WGSL-only custom shader: no GL context, no GLSL pair — the dual +// GLShader shape a WebGPU-only game constructs directly +function makeToonShader(label) { + return new GLShader(undefined, { wgsl: TOON_WGSL, label }); +} + +function makeMesh(overrides = {}) { + return { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + vertexCount: 4, + texture: { id: "atlas" }, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + lit: false, + cullBackFaces: false, + rightHanded: false, + ...overrides, + }; +} + +describe("GLShader — WGSL realization (isWebGPU)", () => { + it("a wgsl-only shader carries the module, flags itself, and stays quiet", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const shader = makeToonShader("toon"); + expect(shader.wgsl).toBe(TOON_WGSL); + expect(shader.isWebGPU).toBe(true); + expect(shader.isWebGL).toBe(false); + expect(shader.shared).toBe(false); + expect(shader.wgslValid).toBe(true); + expect(shader.label).toBe("toon"); + // it has a realization — no "no usable realization" warning + expect(warn).not.toHaveBeenCalled(); + // and no GL program to bind — GL-side entry points are inert no-ops + expect(shader.program).toBe(null); + expect(() => { + shader.bind(); + shader.setUniform("uAnything", 1); + }).not.toThrow(); + warn.mockRestore(); + }); + + it("a shader with NO realization at all warns and stays inert", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const shader = new GLShader(undefined, { + vertex: "void main(){}", + fragment: "void main(){}", + }); + expect(shader.isWebGL).toBe(false); + expect(shader.isWebGPU).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/realization/); + warn.mockRestore(); + }); + + it("registerWGSL namespaces the module per host and caches per host key", () => { + const renderer = createMockWebGPURenderer(); + const cache = renderer.pipelineCache; + const shader = makeToonShader(); + + const meshKey = shader.registerWGSL(cache, "mesh", [], "mesh"); + const again = shader.registerWGSL(cache, "mesh", [], "mesh"); + const litKey = shader.registerWGSL(cache, "meshLit", [], "meshLit"); + + expect(again).toBe(meshKey); + // distinct families per host — the module text is suffixed with the + // host key so the cache's text-dedup cannot collapse them + expect(litKey).not.toBe(meshKey); + expect(cache.modules[meshKey].code).toBe( + `${TOON_WGSL}\n// melonJS host: mesh`, + ); + expect(cache.modules[litKey].code).toBe( + `${TOON_WGSL}\n// melonJS host: meshLit`, + ); + }); + + it("a device-generation change (cache epoch) re-registers and clears an invalid verdict", () => { + const renderer = createMockWebGPURenderer(); + const cache = renderer.pipelineCache; + const shader = makeToonShader(); + + shader.registerWGSL(cache, "mesh", [], "mesh"); + shader.wgslValid = false; + expect(shader._wgslRegistrations.epoch).toBe(cache.epoch); + + cache.epoch += 1; + const key = shader.registerWGSL(cache, "mesh", [], "mesh"); + expect(shader._wgslRegistrations.epoch).toBe(cache.epoch); + expect(shader._wgslRegistrations.keys.get("mesh")).toBe(key); + // a fresh device gets a fresh validation verdict + expect(shader.wgslValid).toBe(true); + }); + + it("async WGSL validation errors flip wgslValid=false and warn once", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const shader = makeToonShader("broken"); + const cache = { + epoch: 7, + registerShader() { + return "effect:9"; + }, + modules: { + "effect:9": { + getCompilationInfo() { + return Promise.resolve({ + messages: [ + { type: "error", lineNum: 3, message: "unknown identifier" }, + { type: "info", lineNum: 1, message: "fine" }, + ], + }); + }, + }, + }, + }; + + shader.registerWGSL(cache, "mesh", [], "mesh"); + await Promise.resolve(); + await Promise.resolve(); + expect(shader.wgslValid).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/broken/); + expect(warn.mock.calls[0][0]).toMatch(/line 3/); + warn.mockRestore(); + }); +}); + +describe("WebGPUMeshBatcher — hosted custom shader family", () => { + it("activeShaderKey routes to the custom family, per host, and falls back when invalid", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const litBatcher = new WebGPULitMeshBatcher(renderer); + const shader = makeToonShader(); + + // no custom shader → the built-in family + expect(batcher.activeShaderKey()).toBe(batcher.shaderKey); + + batcher.customShader = shader; + const customKey = batcher.activeShaderKey(); + expect(customKey).not.toBe(batcher.shaderKey); + // stable across lookups (registered once) + expect(batcher.activeShaderKey()).toBe(customKey); + + // the lit host realizes the same instance as a different family + litBatcher.customShader = shader; + const litCustomKey = litBatcher.activeShaderKey(); + expect(litCustomKey).not.toBe(customKey); + expect(litCustomKey).not.toBe(litBatcher.shaderKey); + + // failed validation → built-in shading (the mesh keeps drawing) + shader.wgslValid = false; + expect(batcher.activeShaderKey()).toBe(batcher.shaderKey); + }); + + it("an accumulated flush records its pipeline under the custom family", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const shader = makeToonShader(); + + batcher.customShader = shader; + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.flush(); + + const customKey = shader._wgslRegistrations.keys.get("mesh"); + const lastKey = renderer.calls.pipelineKeys.at(-1); + expect(lastKey.startsWith(`${customKey}|`)).toBe(true); + // the draw itself is unchanged — same indexed geometry + expect(renderer.calls.drawIndexed).toEqual([6]); + }); +}); + +describe("WebGPURenderer.drawMesh — custom shader routing (real prototype over a stub)", () => { + function makeStub(batcher) { + return { + batchers: new Map([["mesh", batcher]]), + currentBatcher: batcher, + setBatcher(name) { + return this.batchers.get(name); + }, + currentTint: new Color(255, 255, 255, 1), + getGlobalAlpha() { + return 1; + }, + customShader: undefined, + meshDepthActive: false, + meshEffectWarned: false, + pendingDepthClear: false, + }; + } + + it("routes a WGSL-carrying shader to the batcher for the draw, then clears it", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = makeStub(batcher); + const shader = makeToonShader(); + stub.customShader = shader; + + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + + // the flush inside drawMesh saw the custom family… + const customKey = shader._wgslRegistrations.keys.get("mesh"); + expect(typeof customKey).toBe("string"); + expect(renderer.calls.pipelineKeys.at(-1).startsWith(`${customKey}|`)).toBe( + true, + ); + // …and no warning fired — this is the supported path + expect(stub.meshEffectWarned).toBe(false); + // per-mesh state: never leaks into the next draw + expect(batcher.customShader).toBe(null); + + stub.customShader = undefined; + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + expect( + renderer.calls.pipelineKeys.at(-1).startsWith(`${batcher.shaderKey}|`), + ).toBe(true); + }); + + it("an exception mid-draw still clears the batcher's custom shader", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = makeStub(batcher); + stub.customShader = makeToonShader(); + batcher.addMesh = () => { + throw new Error("boom"); + }; + + expect(() => { + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + }).toThrow("boom"); + // the finally bracket: the hosted module never leaks into a later + // mesh (or the frame-end drain) after a mid-draw failure + expect(batcher.customShader).toBe(null); + }); + + it("clone() carries the wgsl module, label and flags (caller-owned copy)", () => { + const shader = new GLShader(undefined, { + wgsl: TOON_WGSL, + label: "toon", + }); + shader.shared = true; + const copy = shader.clone(); + expect(copy.wgsl).toBe(TOON_WGSL); + expect(copy.label).toBe("toon"); + expect(copy.isWebGPU).toBe(true); + expect(copy.isWebGL).toBe(false); + // ownership never carries — the clone is caller-owned + expect(copy.shared).toBe(false); + }); + + it("a shader with no wgsl module still warns once and draws with built-in shading", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = makeStub(batcher); + // a ShaderEffect (or GLSL-only GLShader) in the slot — not hostable + stub.customShader = { enabled: true }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/Mesh/); + expect( + renderer.calls.pipelineKeys.at(-1).startsWith(`${batcher.shaderKey}|`), + ).toBe(true); + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + warn.mockRestore(); + }); +}); diff --git a/packages/melonjs/tests/webgpu_lit_mesh.spec.js b/packages/melonjs/tests/webgpu_lit_mesh.spec.js new file mode 100644 index 0000000000..7442dcca3a --- /dev/null +++ b/packages/melonjs/tests/webgpu_lit_mesh.spec.js @@ -0,0 +1,255 @@ +import "./helpers/webgpu-globals.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// initialize the module graph through the package index BEFORE any deep +// import — a bare deep import of state.ts hits the camera module cycle +import { state } from "../src/index.js"; +import { + BLOCK3D_BYTES, + BLOCK3D_FLOATS, + writeLight3dBlock, +} from "../src/video/webgl/lighting/std140.ts"; +import WebGPULitMeshBatcher from "../src/video/webgpu/batchers/lit_mesh_batcher.js"; +import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * The lit mesh tier on WebGPU — Light3d packing into the std140 block + * (byte-equal with the shared writer), the fresh-region-per-change + * snapshot rule, the white-ambient fallback, and the 48-byte lit vertex + * layout with its degenerate-normal substitution. + */ +function makeLitMesh(overrides = {}) { + return { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0]), + originalVertices: new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0]), + normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), + originalNormals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1]), + indices: new Uint16Array([0, 1, 2]), + _indicesOriginal: new Uint16Array([0, 1, 2]), + _geometryVersion: 0, + vertexCount: 3, + texture: { id: "atlas" }, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + lit: true, + cullBackFaces: true, + rightHanded: false, + ...overrides, + }; +} + +function makeSun(overrides = {}) { + return { + type: "directional", + direction: { x: 0, y: 0, z: 1 }, + color: { r: 255, g: 128, b: 0 }, + intensity: 1, + ...overrides, + }; +} + +describe("WebGPULitMeshBatcher (mock device)", () => { + let renderer; + let batcher; + let currentSpy; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPULitMeshBatcher(renderer); + currentSpy = vi.spyOn(state, "current").mockReturnValue({ + _activeLights3d: new Set(), + }); + }); + + afterEach(() => { + currentSpy.mockRestore(); + }); + + function setLights(...lights) { + currentSpy.mockReturnValue({ _activeLights3d: new Set(lights) }); + } + + function lightWrites() { + return renderer.calls.writes.filter((w) => { + return ( + w.buffer === renderer.effectUniformArena.page && + w.size === BLOCK3D_BYTES + ); + }); + } + + it("freezes the 48-byte lit layout (base + aNormal) under its own key", () => { + expect(batcher.stride).toBe(48); + expect(batcher.vertexSize).toBe(12); + expect(batcher.attributes.at(-1)).toMatchObject({ + name: "aNormal", + format: "float32x3", + offset: 36, + }); + // distinct family from the unlit batcher, same group-3 layout + const unlit = new WebGPUMeshBatcher(renderer); + expect(batcher.shaderKey).not.toBe(unlit.shaderKey); + expect(batcher.meshLayout).toBe(unlit.meshLayout); + }); + + it("the snapshot bytes are byte-equal with writeLight3dBlock's output", () => { + const sun = makeSun(); + setLights(sun); + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + + const expected = new Float32Array(BLOCK3D_FLOATS); + writeLight3dBlock(expected, { + count: 1, + // directional: range sentinel -1, position unused + posRange: new Float32Array([0, 0, 0, -1]), + // surface→light: negated travel direction, normalized (the + // negation makes the zero components NEGATIVE zero — byte-equal + // means matching those bits too), no cone + dirCone: new Float32Array([-0, -0, -1, -1]), + colorInner: new Float32Array([1, 128 / 255, 0, 0]), + ambient: [0, 0, 0], + }); + const write = lightWrites().at(-1); + expect(Array.from(write.floats)).toEqual(Array.from(expected)); + + // and the block binds at group 2 with the region's dynamic offset + const group2 = renderer.calls.bindGroups.find((b) => { + return b.index === 2; + }); + expect(group2.dynamicOffsets).toEqual([write.offset]); + }); + + it("no 3D lights at all → fullbright white ambient (unlit parity)", () => { + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + const f = lightWrites().at(-1).floats; + expect(f[0]).toBe(0); // count + expect([f[4], f[5], f[6]]).toEqual([1, 1, 1]); // white ambient + }); + + it("an ambient-only scene keeps its real ambient (no white override)", () => { + setLights({ + type: "ambient", + color: { r: 255, g: 0, b: 0 }, + intensity: 0.5, + direction: { x: 0, y: 0, z: 0 }, + }); + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + const f = lightWrites().at(-1).floats; + expect(f[4]).toBeCloseTo(0.5); + expect(f[5]).toBe(0); + expect(f[6]).toBe(0); + }); + + it("a static rig re-uses the frame's snapshot; a light change lands in a fresh region", () => { + const sun = makeSun(); + setLights(sun); + const mesh = makeLitMesh(); + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + expect(lightWrites()).toHaveLength(1); + + // same frame, unchanged lights: zero new uploads + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + expect(lightWrites()).toHaveLength(1); + + // same frame, mutated light: fresh region, distinct offset (the + // queue-write law — recorded draws keep their own bytes) + sun.intensity = 0.25; + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + const writes = lightWrites(); + expect(writes).toHaveLength(2); + expect(writes[1].offset).not.toBe(writes[0].offset); + }); + + it("a new frame re-snapshots even an unchanged rig (the arena was reset)", () => { + setLights(makeSun()); + const mesh = makeLitMesh(); + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + renderer.frameId++; + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + expect(lightWrites()).toHaveLength(2); + }); + + it("accumulated vertices carry world-space normals; the tint stays a uniform", () => { + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + const vertexWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.vertexArena.page; + }); + expect(vertexWrite.size).toBe(3 * 48); + // vertex 0 normal at floats 9..11 + expect(vertexWrite.floats[9]).toBe(0); + expect(vertexWrite.floats[10]).toBe(0); + expect(vertexWrite.floats[11]).toBe(1); + }); + + it("retained lit geometry substitutes a unit vector for degenerate normals", () => { + const mesh = makeLitMesh({ + originalNormals: new Float32Array([0, 0, 1, 0, 0, 0, 0, 0, 1]), + }); + const model = { val: new Float32Array(16) }; + model.val[0] = model.val[5] = model.val[10] = model.val[15] = 1; + batcher.drawRetainedMesh(mesh, model, 0xffffffff); + + const vertexWrite = renderer.calls.writes.find((w) => { + return w.buffer.label === "melonJS retained mesh vertices"; + }); + // vertex 1 (floats 12..23): degenerate → (0, 1, 0) + expect(vertexWrite.floats[12 + 9]).toBe(0); + expect(vertexWrite.floats[12 + 10]).toBe(1); + expect(vertexWrite.floats[12 + 11]).toBe(0); + }); + + it("reset drops the light snapshot alongside the base state", () => { + setLights(makeSun()); + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + batcher.reset(); + expect(batcher.lightBinding).toBe(null); + expect(batcher.lightBindGroups.size).toBe(0); + }); + + it("a re-init against the surviving cache keeps the lights layout and re-arms the snapshot state", () => { + setLights(makeSun()); + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + const layout = batcher.lightsLayout; + const key = batcher.shaderKey; + + // the reset-with-valid-device window: same device, same cache — + // the module short-circuit must not orphan a fresh layout, and + // the arena-scoped snapshot state must not survive + batcher.init(renderer); + expect(batcher.shaderKey).toBe(key); + expect(batcher.lightsLayout).toBe(layout); + expect(batcher.lightBinding).toBe(null); + expect(batcher.lightBindGroups.size).toBe(0); + + // and the batcher still functions end to end after the re-init + batcher.addMesh(makeLitMesh(), 0xffffffff); + batcher.flush(); + expect(renderer.calls.drawIndexed.at(-1)).toBe(3); + }); + + it("a device change rebuilds the lights layout on the new device", () => { + const layout = batcher.lightsLayout; + const fresh = createMockWebGPURenderer(); + batcher.init(fresh); + expect(batcher.lightsLayout).not.toBe(layout); + // a second lit instance against the SAME cache also gets its own + // live layout despite the module short-circuit + const sibling = new WebGPULitMeshBatcher(fresh); + expect(sibling.lightsLayout).toBeDefined(); + expect(sibling.shaderKey).toBe(batcher.shaderKey); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mesh_batcher.spec.js b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js new file mode 100644 index 0000000000..2fe6b019fe --- /dev/null +++ b/packages/melonjs/tests/webgpu_mesh_batcher.spec.js @@ -0,0 +1,374 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Color, WebGPURenderer } from "../src/index.js"; +import WebGPULitMeshBatcher from "../src/video/webgpu/batchers/lit_mesh_batcher.js"; +import WebGPUMeshBatcher, { + MESH_UNIFORM_SIZE, +} from "../src/video/webgpu/batchers/mesh_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * A duck-typed mesh in the shape the batcher consumes — the accumulated + * path reads working `vertices` (CPU-projected), `uvs`, `indices`, and the + * material fields. A unit quad split into two triangles by default. + */ +function makeMesh(overrides = {}) { + return { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + vertexCount: 4, + texture: { id: "atlas" }, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + lit: false, + cullBackFaces: true, + rightHanded: false, + ...overrides, + }; +} + +describe("WebGPUMeshBatcher (mock device)", () => { + let renderer; + let batcher; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + }); + + it("freezes the 36-byte unlit layout with a float32x4 color", () => { + expect(batcher.stride).toBe(36); + expect(batcher.vertexSize).toBe(9); + expect( + batcher.attributes.map((a) => { + return a.format; + }), + ).toEqual([ + "float32x3", + "float32x2", + // four floats, deliberately not unorm8x4 — layout parity with GL + "float32x4", + ]); + }); + + it("registers the mesh family with the four-group layout list and its own vertex layout", () => { + // dedup by module text: a second batcher re-registers the same key + const again = new WebGPUMeshBatcher(renderer); + expect(again.shaderKey).toBe(batcher.shaderKey); + expect(renderer.pipelineCache.effectLayouts.has("mesh:u176")).toBe(true); + expect(MESH_UNIFORM_SIZE).toBe(176); + }); + + it("addMesh dedups indexed vertices: 6 indices land as 4 vertices + drawIndexed(6)", () => { + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.flush(); + + // writes: [0] uniform snapshot, [1] vertex bytes, [2] index bytes + const vertexWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.vertexArena.page; + }); + expect(vertexWrite.size).toBe(4 * 36); + const indexWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.indexArena.page; + }); + expect(indexWrite.size).toBe(6 * 4); + const indices = new Uint32Array( + indexWrite.view.buffer, + 0, + indexWrite.size >> 2, + ); + expect(Array.from(indices)).toEqual([0, 1, 2, 0, 2, 3]); + expect(renderer.calls.drawIndexed).toEqual([6]); + }); + + it("mesh flushes force blend none, carry the mesh axes in the key, and bind all four groups", () => { + renderer.currentBlendMode = "additive"; + batcher.meshState.cullMode = "back"; + batcher.meshState.frontFace = "cw"; + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.flush(); + + expect(renderer.calls.pipelineKeys).toHaveLength(1); + expect(renderer.calls.pipelineKeys[0]).toBe( + `${batcher.shaderKey}|triangle-list|none|true|none|mesh:back:cw`, + ); + const indices = renderer.calls.bindGroups.map((b) => { + return b.index; + }); + expect(indices).toContain(0); + expect(indices).toContain(1); + expect(indices).toContain(2); + expect(indices).toContain(3); + // group 2 is the shared empty filler on the unlit family + const lights = renderer.calls.bindGroups.find((b) => { + return b.index === 2; + }); + expect(lights.group).toBe(renderer.pipelineCache.emptyBindGroup); + }); + + it("snapshots the per-draw uniforms at their pinned offsets", () => { + const mesh = makeMesh({ + alphaCutoff: 0.5, + emissive: new Float32Array([0.25, 0.5, 0.75]), + }); + // tint = ARGB 0x80(A) 40(R) 80(G) c0(B) + batcher.addMesh(mesh, 0x804080c0); + batcher.flush(); + + const uniformWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.effectUniformArena.page; + }); + expect(uniformWrite.size).toBe(MESH_UNIFORM_SIZE); + const f = uniformWrite.floats; + // model = identity (accumulated path) + expect(f[0]).toBe(1); + expect(f[5]).toBe(1); + expect(f[10]).toBe(1); + expect(f[15]).toBe(1); + // view = renderer.currentTransform (identity in the mock) + expect(f[16]).toBe(1); + expect(f[21]).toBe(1); + // tint rgba floats + expect(f[32]).toBeCloseTo(0x40 / 255); + expect(f[33]).toBeCloseTo(0x80 / 255); + expect(f[34]).toBeCloseTo(0xc0 / 255); + expect(f[35]).toBeCloseTo(0x80 / 255); + // params.x = alphaCutoff, emissive rgb + expect(f[36]).toBe(0.5); + expect(f[40]).toBe(0.25); + expect(f[41]).toBe(0.5); + expect(f[42]).toBe(0.75); + + // the snapshot binds at group 3 with the region's dynamic offset + const group3 = renderer.calls.bindGroups.find((b) => { + return b.index === 3; + }); + expect(group3.dynamicOffsets).toEqual([uniformWrite.offset]); + }); + + it("two draws snapshot two distinct uniform regions (queue-write law)", () => { + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.flush(); + batcher.addMesh(makeMesh(), 0xff0000ff); + batcher.flush(); + + const uniformWrites = renderer.calls.writes.filter((w) => { + return w.buffer === renderer.effectUniformArena.page; + }); + expect(uniformWrites).toHaveLength(2); + expect(uniformWrites[0].offset).not.toBe(uniformWrites[1].offset); + const offsets = renderer.calls.bindGroups + .filter((b) => { + return b.index === 3; + }) + .map((b) => { + return b.dynamicOffsets[0]; + }); + expect(offsets).toEqual([uniformWrites[0].offset, uniformWrites[1].offset]); + }); + + it("per-vertex colors ride aColor as floats; the tint stays a uniform", () => { + const mesh = makeMesh({ + // vertex 1 = opaque red, others white + vertexColors: new Uint32Array([ + 0xffffffff, 0xffff0000, 0xffffffff, 0xffffffff, + ]), + }); + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + + const vertexWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.vertexArena.page; + }); + // vertex 1 starts at float 9; color floats at +5 (r,g,b,a) + expect(vertexWrite.floats[9 + 5]).toBeCloseTo(1); + expect(vertexWrite.floats[9 + 6]).toBeCloseTo(0); + expect(vertexWrite.floats[9 + 7]).toBeCloseTo(0); + expect(vertexWrite.floats[9 + 8]).toBeCloseTo(1); + }); + + it("the mesh's textureRepeat and mip request reach the texture store", () => { + batcher.addMesh(makeMesh({ textureRepeat: "repeat" }), 0xffffffff); + // the mock renderer's default filter is linear → the mesh path + // asks for the generated mip chain + expect(renderer.calls.textureBindings[0].options).toEqual({ + repeat: "repeat", + mipmaps: true, + }); + }); + + it("a nearest-filtered mesh texture opts out of mipmaps", () => { + batcher.addMesh( + makeMesh({ texture: { id: "atlas", filter: "nearest" } }), + 0xffffffff, + ); + expect(renderer.calls.textureBindings[0].options.mipmaps).toBe(false); + }); + + it("an over-capacity mesh chunks across flushes with every index accounted for", () => { + // a triangle fan of maxVertex+2 vertices: every triangle shares + // vertex 0, so dedup matters and the fan cannot fit one flush + const maxVertex = batcher.vertexData.maxVertex; + const vertCount = maxVertex + 2; + const vertices = new Float32Array(vertCount * 3); + const uvs = new Float32Array(vertCount * 2); + const triCount = vertCount - 2; + const indices = new Uint32Array(triCount * 3); + for (let t = 0; t < triCount; t++) { + indices[t * 3] = 0; + indices[t * 3 + 1] = t + 1; + indices[t * 3 + 2] = t + 2; + } + batcher.addMesh( + makeMesh({ vertices, uvs, indices, vertexCount: vertCount }), + 0xffffffff, + ); + batcher.flush(); + + expect(renderer.calls.drawIndexed.length).toBeGreaterThan(1); + const totalIndices = renderer.calls.drawIndexed.reduce((a, b) => { + return a + b; + }, 0); + expect(totalIndices).toBe(triCount * 3); + }); + + it("reset drops pending staging and cached uniform bind groups", () => { + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.reset(); + expect(batcher.indexCount).toBe(0); + expect(batcher.vertexData.vertexCount).toBe(0); + expect(batcher.uniformBinding).toBe(null); + batcher.flush(); + expect(renderer.calls.drawIndexed).toHaveLength(0); + }); +}); + +describe("WebGPURenderer.drawMesh (real prototype over a stub)", () => { + function makeStub(batcher) { + return { + batchers: new Map([["mesh", batcher]]), + currentBatcher: batcher, + setBatcherNames: [], + setBatcher(name) { + this.setBatcherNames.push(name); + return this.batchers.get(name); + }, + currentTint: new Color(255, 255, 255, 1), + getGlobalAlpha() { + return 1; + }, + customShader: undefined, + meshDepthActive: false, + meshEffectWarned: false, + pendingDepthClear: false, + }; + } + + it("activates the depth path and maps culling to the pipeline axes", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = makeStub(batcher); + + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + expect(stub.meshDepthActive).toBe(true); + // accumulated + left-handed: culling on, ccw (the CW flip is + // retained-path only — accumulated vertices are already bridged) + expect(batcher.meshState).toEqual({ cullMode: "back", frontFace: "ccw" }); + expect(renderer.calls.drawIndexed).toEqual([6]); + + WebGPURenderer.prototype.drawMesh.call( + stub, + makeMesh({ cullBackFaces: false }), + ); + expect(batcher.meshState.cullMode).toBe("none"); + }); + + it("the activation flip arms a depth clear ONCE (the activation-frame pass carries discard ops)", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = makeStub(batcher); + + // first-ever mesh draw: the open pass was begun pre-mesh with + // depthStoreOp "discard" — a later non-arming pass restart (mask, + // capture) must clear rather than load the discarded contents + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + expect(stub.meshDepthActive).toBe(true); + expect(stub.pendingDepthClear).toBe(true); + + // steady state: no re-arm per draw (that would defeat the + // depth-persists-across-restarts policy) + stub.pendingDepthClear = false; + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + expect(stub.pendingDepthClear).toBe(false); + }); + + it("a customShader warns once, and the mesh still draws (un-effected)", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = makeStub(batcher); + stub.customShader = { enabled: true }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/Mesh/); + // the meshes drew regardless + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + warn.mockRestore(); + }); + + it("lit meshes route to the litMesh batcher when registered", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const litBatcher = new WebGPULitMeshBatcher(renderer); + const stub = makeStub(batcher); + stub.batchers.set("litMesh", litBatcher); + + WebGPURenderer.prototype.drawMesh.call(stub, makeMesh()); + WebGPURenderer.prototype.drawMesh.call( + stub, + makeMesh({ + lit: true, + normals: new Float32Array(12), + }), + ); + expect(stub.setBatcherNames).toEqual(["mesh", "litMesh"]); + // per-mesh axes land on the batcher that draws, and the two + // batchers hold INDEPENDENT state objects + expect(litBatcher.meshState).not.toBe(batcher.meshState); + }); + + it("an active stencil mask threads through the mesh flush key", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + renderer.stencilMode = "test"; + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.flush(); + expect(renderer.calls.pipelineKeys[0]).toContain("|test|mesh:"); + }); + + it("index-capacity-limited meshes chunk too (few vertices, many triangles)", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + // 4 vertices shared by more triangles than the index staging holds + const triCount = Math.floor(batcher.indexData.length / 3) + 50; + const indices = new Uint32Array(triCount * 3); + for (let t = 0; t < triCount; t++) { + indices[t * 3] = 0; + indices[t * 3 + 1] = 1 + (t % 2); + indices[t * 3 + 2] = 2 + (t % 2); + } + batcher.addMesh(makeMesh({ indices }), 0xffffffff); + batcher.flush(); + + expect(renderer.calls.drawIndexed.length).toBeGreaterThan(1); + const total = renderer.calls.drawIndexed.reduce((a, b) => { + return a + b; + }, 0); + expect(total).toBe(triCount * 3); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mesh_depth.spec.js b/packages/melonjs/tests/webgpu_mesh_depth.spec.js new file mode 100644 index 0000000000..fceb7eeafa --- /dev/null +++ b/packages/melonjs/tests/webgpu_mesh_depth.spec.js @@ -0,0 +1,322 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it } from "vitest"; +import { WebGPURenderer } from "../src/index.js"; +import { resolveDepthOps } from "../src/video/webgpu/webgpu_renderer.js"; + +/** + * The depth half of the shared depth-stencil attachment — the WebGPU + * realization of the GL mesh path's single-depth-clear-per-target-per-frame + * policy (#1480). The load/store resolution is a pure function, and the + * arming sites run as real prototype methods over recording stubs, so the + * whole policy is pinned without a GPU device. + */ +describe("WebGPU mesh depth policy", () => { + describe("resolveDepthOps (pure)", () => { + it("before any mesh ever draws, every pass keeps the original clear/discard pair", () => { + // pure-2D applications must see byte-identical passes whether or + // not a mesh path exists in the build — including when a clear + // got armed by machinery shared with the mesh path + expect(resolveDepthOps(false, false)).toEqual({ + depthLoadOp: "clear", + depthStoreOp: "discard", + }); + expect(resolveDepthOps(false, true)).toEqual({ + depthLoadOp: "clear", + depthStoreOp: "discard", + }); + }); + + it("once the mesh path is active, an armed clear clears and stores", () => { + expect(resolveDepthOps(true, true)).toEqual({ + depthLoadOp: "clear", + depthStoreOp: "store", + }); + }); + + it("once the mesh path is active, an unarmed restart preserves depth", () => { + // pass restarts that keep the target (captures, mask stencil + // clears, ensurePass reopens) must not lose occlusion state — + // the GL parity where the depth buffer survives mid-frame + expect(resolveDepthOps(true, false)).toEqual({ + depthLoadOp: "load", + depthStoreOp: "store", + }); + }); + }); + + describe("arming sites (real prototypes over stubs)", () => { + it("setRenderTarget arms the depth clear in both directions", () => { + const stub = { + currentBatcher: { flush() {} }, + renderPass: null, + currentRenderTarget: null, + pendingColorClear: false, + pendingClearValue: null, + pendingStencilClear: false, + pendingDepthClear: false, + }; + const target = { colorView: {}, pendingClear: false }; + + WebGPURenderer.prototype.setRenderTarget.call(stub, target); + expect(stub.pendingDepthClear).toBe(true); + + // the return trip to the canvas re-arms too: the shared + // attachment holds the offscreen target's depth, which is stale + // data from the canvas's point of view + stub.pendingDepthClear = false; + WebGPURenderer.prototype.setRenderTarget.call(stub, null); + expect(stub.pendingDepthClear).toBe(true); + }); + + it("depth-attachment recreation arms the clear; a same-size reuse does not", () => { + const stub = { + getCanvas() { + return { width: 64, height: 48 }; + }, + depthTexture: null, + pendingDepthClear: false, + retired: [], + retireTexture(texture) { + this.retired.push(texture); + }, + device: { + createTexture({ size }) { + return { width: size[0], height: size[1] }; + }, + }, + }; + + // first creation: a fresh attachment read with "load" is zeros + WebGPURenderer.prototype.createDepthTexture.call(stub, 64, 48); + expect(stub.pendingDepthClear).toBe(true); + + // same size: early return, no re-arm + stub.pendingDepthClear = false; + WebGPURenderer.prototype.createDepthTexture.call(stub, 64, 48); + expect(stub.pendingDepthClear).toBe(false); + expect(stub.retired).toHaveLength(0); + + // size change: old attachment retires (recorded passes may + // reference it) and the fresh one arms a clear + const old = stub.depthTexture; + WebGPURenderer.prototype.createDepthTexture.call(stub, 128, 48); + expect(stub.pendingDepthClear).toBe(true); + expect(stub.retired).toEqual([old]); + }); + + it("abandonFrame resets the armed clear with the rest of the pending state", () => { + const stub = { + renderPass: null, + commandEncoder: null, + frameTextureView: null, + frameTexture: null, + currentRenderTarget: null, + pendingColorClear: true, + pendingDepthClear: true, + currentPipeline: null, + destroyRetiredTextures() {}, + }; + WebGPURenderer.prototype.abandonFrame.call(stub); + expect(stub.pendingDepthClear).toBe(false); + expect(stub.pendingColorClear).toBe(false); + }); + + it("beginPass realizes the policy table end to end (real prototype)", () => { + const passes = []; + const stub = { + commandEncoder: null, + renderPass: null, + currentRenderTarget: null, + frameTextureView: null, + frameTexture: null, + pendingColorClear: false, + pendingClearValue: null, + pendingStencilClear: false, + pendingDepthClear: false, + meshDepthActive: false, + depthTexture: null, + currentPipeline: {}, + maskVisibleRef: 0, + viewportRect: null, + scissorActive: false, + canvasSampleCount: 1, + pipelineCache: {}, + retireTexture() {}, + applyScissor() {}, + applyViewport() {}, + getTargetSize() { + return [64, 48]; + }, + getCanvas() { + return { width: 64, height: 48 }; + }, + createDepthTexture: WebGPURenderer.prototype.createDepthTexture, + beginPass: WebGPURenderer.prototype.beginPass, + context: { + getCurrentTexture() { + return { + createView() { + return { canvas: true }; + }, + }; + }, + }, + device: { + createCommandEncoder() { + return { + beginRenderPass(descriptor) { + passes.push(descriptor); + return { + end() {}, + setViewport() {}, + setStencilReference() {}, + }; + }, + }; + }, + createTexture({ size }) { + return { + width: size[0], + height: size[1], + createView() { + return { depth: true }; + }, + }; + }, + }, + }; + const depthOf = (i) => { + return [ + passes[i].depthStencilAttachment.depthLoadOp, + passes[i].depthStencilAttachment.depthStoreOp, + ]; + }; + + // pre-mesh frames: byte-identical to the original ops, even + // though the fresh depth texture armed a clear + stub.beginPass(); + expect(depthOf(0)).toEqual(["clear", "discard"]); + + // the mesh path activates: an unarmed restart preserves depth + stub.renderPass = null; + stub.meshDepthActive = true; + stub.pendingDepthClear = false; + stub.beginPass(); + expect(depthOf(1)).toEqual(["load", "store"]); + + // an armed clear (frame start / target change) clears once… + stub.renderPass = null; + stub.pendingDepthClear = true; + stub.beginPass(); + expect(depthOf(2)).toEqual(["clear", "store"]); + // …and is consumed: the next restart loads again + stub.renderPass = null; + stub.beginPass(); + expect(depthOf(3)).toEqual(["load", "store"]); + + // a mask's stencil clear leaves depth loading (independent ops) + stub.renderPass = null; + stub.beginPass({ stencilLoadOp: "clear" }); + expect(passes[4].depthStencilAttachment.stencilLoadOp).toBe("clear"); + expect(depthOf(4)).toEqual(["load", "store"]); + }); + + it("retireBuffer parks on the retired list while a frame records, destroys otherwise", () => { + const parked = []; + const recording = { + commandEncoder: {}, + retiredTextures: parked, + retireTexture: WebGPURenderer.prototype.retireTexture, + }; + const buffer = { + destroyed: false, + destroy() { + this.destroyed = true; + }, + }; + WebGPURenderer.prototype.retireBuffer.call(recording, buffer); + expect(parked).toEqual([buffer]); + expect(buffer.destroyed).toBe(false); + + const idle = { + commandEncoder: null, + retiredTextures: [], + retireTexture: WebGPURenderer.prototype.retireTexture, + }; + WebGPURenderer.prototype.retireBuffer.call(idle, buffer); + expect(buffer.destroyed).toBe(true); + }); + }); + + describe("split-screen viewport (real prototypes over stubs)", () => { + function makeViewportStub() { + const applied = []; + return { + applied, + flushed: 0, + currentBatcher: { + flush() {}, + }, + viewportRect: null, + currentRenderTarget: null, + renderPass: { + setViewport(...args) { + applied.push(args); + }, + }, + getCanvas() { + return { width: 640, height: 480 }; + }, + getTargetSize() { + return [640, 480]; + }, + setViewport: WebGPURenderer.prototype.setViewport, + applyViewport: WebGPURenderer.prototype.applyViewport, + }; + } + + it("un-flips the GL bottom-left rect the cameras bake in", () => { + const stub = makeViewportStub(); + // camera3d passes (x, canvasHeight - screenY - h, w, h) for a + // camera at screen (10, 20) sized 320×240 + stub.setViewport(10, 480 - 20 - 240, 320, 240); + expect(stub.viewportRect).toEqual({ + x: 10, + y: 20, + width: 320, + height: 240, + }); + expect(stub.applied).toEqual([[10, 20, 320, 240, 0, 1]]); + }); + + it("offscreen targets always render full size; the canvas rect returns after", () => { + const stub = makeViewportStub(); + stub.setViewport(0, 240, 320, 240); + stub.applied.length = 0; + + stub.currentRenderTarget = { colorView: {} }; + stub.applyViewport(); + expect(stub.applied).toEqual([[0, 0, 640, 480, 0, 1]]); + + stub.currentRenderTarget = null; + stub.applyViewport(); + expect(stub.applied).toEqual([ + [0, 0, 640, 480, 0, 1], + [0, 0, 320, 240, 0, 1], + ]); + }); + + it("clamps a stale rect after a canvas shrink (validation, not GL silent clip)", () => { + const stub = makeViewportStub(); + stub.setViewport(500, 0, 320, 480); + // the canvas "shrank": the stored rect now overflows the target + stub.getTargetSize = () => { + return [512, 480]; + }; + stub.applied.length = 0; + stub.applyViewport(); + const [x, , width] = stub.applied[0]; + expect(x + width).toBeLessThanOrEqual(512); + }); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mesh_retained.spec.js b/packages/melonjs/tests/webgpu_mesh_retained.spec.js new file mode 100644 index 0000000000..0f1f9bfae8 --- /dev/null +++ b/packages/melonjs/tests/webgpu_mesh_retained.spec.js @@ -0,0 +1,203 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { WebGPURenderer } from "../src/index.js"; +import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * The retained mesh path on WebGPU — the GL retained-geometry contract + * (upload once, placement by uniforms, `needsUpdate` invalidation) plus + * the queue-law hardening this backend demands: a version bump on a mesh + * that already drew this frame lands in FRESH buffers with the old pair + * retired, never an in-place write. + */ +function makeRetainedMesh(overrides = {}) { + return { + originalVertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + _indicesOriginal: new Uint16Array([0, 1, 2, 0, 2, 3]), + _geometryVersion: 0, + vertexCount: 4, + texture: { id: "atlas" }, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + lit: false, + cullBackFaces: true, + rightHanded: false, + ...overrides, + }; +} + +const MODEL = (() => { + // stand-in model matrix: identity with a translation — only .val is read + const val = new Float32Array(16); + val[0] = val[5] = val[10] = val[15] = 1; + val[12] = 32; + val[13] = 16; + return { val }; +})(); + +describe("WebGPU retained mesh geometry (mock device)", () => { + let renderer; + let batcher; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + }); + + function geometryWrites() { + return renderer.calls.writes.filter((w) => { + return ( + w.buffer.label === "melonJS retained mesh vertices" || + w.buffer.label === "melonJS retained mesh indices" + ); + }); + } + + it("uploads once and redraws from resident buffers (zero re-upload)", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(geometryWrites()).toHaveLength(2); + expect(renderer.calls.drawIndexed).toEqual([6]); + + renderer.frameId++; + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + batcher.drawRetainedMesh(mesh, MODEL, 0xff00ff00); + // still just the original two geometry writes — placement and tint + // changes ride the uniform snapshot alone + expect(geometryWrites()).toHaveLength(2); + expect(renderer.calls.drawIndexed).toEqual([6, 6, 6]); + }); + + it("binds the retained buffers, not the arenas, and stamps the draw frame", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + + const geometry = batcher.retained.get(mesh); + expect(geometry).toBeDefined(); + const vBind = renderer.calls.vertexBufferBinds.at(-1); + expect(vBind.buffer).toBe(geometry.vertexBuffer); + const iBind = renderer.calls.indexBufferBinds.at(-1); + expect(iBind.buffer).toBe(geometry.indexBuffer); + expect(iBind.format).toBe("uint16"); + expect(geometry.lastDrawnFrameId).toBe(renderer.frameId); + }); + + it("the model matrix lands in the uniform snapshot (placement is uniforms, not geometry)", () => { + batcher.drawRetainedMesh(makeRetainedMesh(), MODEL, 0xffffffff); + const uniformWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.effectUniformArena.page; + }); + expect(uniformWrite.floats[12]).toBe(32); + expect(uniformWrite.floats[13]).toBe(16); + }); + + it("a geometry version bump re-uploads exactly once", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(geometryWrites()).toHaveLength(2); + + renderer.frameId++; + mesh._geometryVersion++; + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(geometryWrites()).toHaveLength(4); + + renderer.frameId++; + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(geometryWrites()).toHaveLength(4); + }); + + it("a cross-frame version bump re-uses the buffers in place (no retire)", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + const geometry = batcher.retained.get(mesh); + const vertexBuffer = geometry.vertexBuffer; + + renderer.frameId++; + mesh._geometryVersion++; + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(geometry.vertexBuffer).toBe(vertexBuffer); + expect(renderer.calls.retiredBuffers).toHaveLength(0); + }); + + it("a SAME-frame version bump after a draw goes to fresh buffers and retires the old pair (queue law)", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + const geometry = batcher.retained.get(mesh); + const oldVertexBuffer = geometry.vertexBuffer; + const oldIndexBuffer = geometry.indexBuffer; + + // same frame (Sprite3d UV animation, a second camera draw) + mesh._geometryVersion++; + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(geometry.vertexBuffer).not.toBe(oldVertexBuffer); + expect(renderer.calls.retiredBuffers).toEqual([ + oldVertexBuffer, + oldIndexBuffer, + ]); + }); + + it("uint32 indices keep their format; odd uint16 counts pad the write to 4 bytes", () => { + const wide = makeRetainedMesh({ + _indicesOriginal: new Uint32Array([0, 1, 2, 0, 2, 3]), + }); + batcher.drawRetainedMesh(wide, MODEL, 0xffffffff); + expect(renderer.calls.indexBufferBinds.at(-1).format).toBe("uint32"); + + // one triangle = 3 uint16 = 6 bytes → padded to 8 + const odd = makeRetainedMesh({ + _indicesOriginal: new Uint16Array([0, 1, 2]), + }); + batcher.drawRetainedMesh(odd, MODEL, 0xffffffff); + const indexWrite = renderer.calls.writes.find((w) => { + return w.buffer.label === "melonJS retained mesh indices" && w.size === 8; + }); + expect(indexWrite).toBeDefined(); + expect(renderer.calls.drawIndexed.at(-1)).toBe(3); + }); + + it("deleteMeshGeometry releases through every batcher and forgets the mesh", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + const geometry = batcher.retained.get(mesh); + const buffers = [geometry.vertexBuffer, geometry.indexBuffer]; + + const stub = { batchers: new Map([["mesh", batcher]]) }; + WebGPURenderer.prototype.deleteMeshGeometry.call(stub, mesh); + expect(batcher.retained.has(mesh)).toBe(false); + expect(renderer.calls.retiredBuffers).toEqual(buffers); + }); + + it("a device-loss re-init drops every retained geometry", () => { + batcher.drawRetainedMesh(makeRetainedMesh(), MODEL, 0xffffffff); + batcher.drawRetainedMesh(makeRetainedMesh(), MODEL, 0xffffffff); + expect(batcher.retained.size).toBe(2); + batcher.init(renderer); + expect(batcher.retained.size).toBe(0); + }); + + it("an accumulated draw right after a retained one flows through the arenas untouched", () => { + const mesh = makeRetainedMesh(); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + + batcher.addMesh( + { + ...makeRetainedMesh(), + vertices: makeRetainedMesh().originalVertices, + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + }, + 0xffffffff, + ); + batcher.flush(); + const vBind = renderer.calls.vertexBufferBinds.at(-1); + expect(vBind.buffer).toBe(renderer.vertexArena.page); + const iBind = renderer.calls.indexBufferBinds.at(-1); + expect(iBind.buffer).toBe(renderer.indexArena.page); + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mesh_validate.spec.js b/packages/melonjs/tests/webgpu_mesh_validate.spec.js new file mode 100644 index 0000000000..570344355b --- /dev/null +++ b/packages/melonjs/tests/webgpu_mesh_validate.spec.js @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Application, GLShader, video } from "../src/index.js"; +import meshWGSL from "../src/video/webgpu/shaders/mesh.wgsl"; +import litMeshWGSL from "../src/video/webgpu/shaders/mesh-lit.wgsl"; + +/** + * Device-gated validation of the mesh tier's WGSL modules and pipeline + * permutations: the shaders must compile with ZERO errors on the real + * device (`getCompilationInfo` — WGSL validation issues never throw), and + * the mesh pipeline family must build across its depth/cull/frontFace × + * stencil axes without validation errors. CI runners without WebGPU skip + * visibly; run locally against a real adapter. + */ +describe("mesh tier WGSL + pipelines validate on the device", () => { + let app; + let webgpuReady = false; + + beforeAll(async () => { + try { + app = new Application(64, 64, { renderer: video.WEBGPU }); + await app.init(); + webgpuReady = true; + } catch { + webgpuReady = false; + } + }); + + afterAll(() => { + if (webgpuReady) { + app.destroy(); + } + }); + + it("exposes the adapter's vendor as GPUVendor (the GL twin)", (ctx) => { + if (!webgpuReady) { + ctx.skip("WebGPU not available in this environment"); + return; + } + // real adapters report a vendor string; undefined only when the + // browser redacts adapter info entirely + const vendor = app.renderer.GPUVendor; + expect(typeof vendor === "string" || typeof vendor === "undefined").toBe( + true, + ); + }); + + it("the mesh WGSL modules compile clean", async (ctx) => { + if (!webgpuReady) { + ctx.skip("WebGPU not available in this environment"); + return; + } + const failures = []; + for (const [name, code] of Object.entries({ + mesh: meshWGSL, + meshLit: litMeshWGSL, + })) { + const module = app.renderer.device.createShaderModule({ code }); + const info = await module.getCompilationInfo(); + for (const message of info.messages) { + if (message.type === "error") { + failures.push( + `${name}: ${message.message} (line ${message.lineNum})`, + ); + } + } + } + expect(failures).toEqual([]); + }); + + it("the mesh pipeline family builds across its state axes", (ctx) => { + if (!webgpuReady) { + ctx.skip("WebGPU not available in this environment"); + return; + } + const renderer = app.renderer; + const errors = []; + renderer.device.addEventListener?.("uncapturederror", (event) => { + errors.push(String(event.error?.message ?? event.error)); + }); + for (const family of ["mesh", "litMesh"]) { + const batcher = renderer.batchers.get(family); + for (const cullMode of ["none", "back"]) { + for (const frontFace of ["ccw", "cw"]) { + for (const stencilMode of ["none", "test"]) { + const pipeline = renderer.pipelineCache.get( + batcher.shaderKey, + "triangle-list", + "none", + true, + stencilMode, + { cullMode, frontFace }, + ); + expect(pipeline).toBeDefined(); + } + } + } + } + expect(errors).toEqual([]); + }); + + it("a custom WGSL mesh shader registers, validates and builds a pipeline on both hosts", async (ctx) => { + if (!webgpuReady) { + ctx.skip("WebGPU not available in this environment"); + return; + } + const renderer = app.renderer; + const errors = []; + renderer.device.addEventListener?.("uncapturederror", (event) => { + errors.push(String(event.error?.message ?? event.error)); + }); + // the documented module contract: vertex_main/fragment_main entry + // points over the frozen mesh layout and the 4-group list, clip-z + // remap included — quantized "toon" sampling as the custom logic + const toon = new GLShader(undefined, { + wgsl: ` + struct FrameUniforms { projection : mat4x4, lineWidth : f32 }; + struct MeshUniforms { + model : mat4x4, view : mat4x4, + tint : vec4f, params : vec4f, emissive : vec4f, + }; + @group(0) @binding(0) var uFrame : FrameUniforms; + @group(1) @binding(0) var uTexture : texture_2d; + @group(1) @binding(1) var uSampler : sampler; + @group(3) @binding(0) var uMesh : MeshUniforms; + struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + }; + @vertex + fn vertex_main(@location(0) aVertex : vec3f, + @location(1) aRegion : vec2f, + @location(2) aColor : vec4f) -> VSOut { + var out : VSOut; + let clip = uFrame.projection * uMesh.view * uMesh.model + * vec4f(aVertex, 1.0); + out.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w); + out.vRegion = aRegion; + return out; + } + @fragment + fn fragment_main(in : VSOut) -> @location(0) vec4f { + let color = textureSample(uTexture, uSampler, in.vRegion); + return vec4f(floor(color.rgb * 4.0) / 4.0, color.a); + } + `, + }); + for (const family of ["mesh", "litMesh"]) { + const batcher = renderer.batchers.get(family); + batcher.customShader = toon; + const key = batcher.activeShaderKey(); + batcher.customShader = null; + expect(key).not.toBe(batcher.shaderKey); + const info = + await renderer.pipelineCache.modules[key].getCompilationInfo(); + expect( + info.messages.filter((message) => { + return message.type === "error"; + }), + ).toEqual([]); + const pipeline = renderer.pipelineCache.get( + key, + "triangle-list", + "none", + true, + "none", + { cullMode: "back", frontFace: "ccw" }, + ); + expect(pipeline).toBeDefined(); + } + // the async validation verdict stayed positive + expect(toon.wgslValid).toBe(true); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/melonjs/tests/webgpu_mipmaps.spec.js b/packages/melonjs/tests/webgpu_mipmaps.spec.js new file mode 100644 index 0000000000..3e731e20db --- /dev/null +++ b/packages/melonjs/tests/webgpu_mipmaps.spec.js @@ -0,0 +1,208 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import WebGPUTextureStore from "../src/video/webgpu/texture/store.js"; + +/** + * Mesh-texture mipmaps on the WebGPU backend: a mip-wanting consumer (the + * mesh path) gets a full generated chain and a trilinear sampler, while + * every 2D consumer of the same image stays lod-clamped to level 0 — the + * Godot-style split where minification quality is a 3D concern and sprite + * output never changes. + */ +describe("WebGPUTextureStore mipmaps", () => { + let renderer; + let store; + let createdTextures; + let samplers; + let mipgen; + + function makeSource(width, height) { + return { width, height }; + } + + function makeAtlas(source, options = {}) { + return { + getTexture() { + return source; + }, + repeat: options.repeat ?? "no-repeat", + filter: options.filter, + __unit: options.unit ?? 0, + }; + } + + beforeEach(() => { + createdTextures = []; + samplers = []; + mipgen = { submits: 0, passes: [], draws: 0 }; + const device = { + createTexture(descriptor) { + const texture = { + label: descriptor.label, + size: descriptor.size, + mipLevelCount: descriptor.mipLevelCount ?? 1, + destroyed: false, + destroy() { + this.destroyed = true; + }, + createView(viewDescriptor) { + return { texture: this, viewDescriptor }; + }, + }; + createdTextures.push(texture); + return texture; + }, + createSampler(descriptor) { + samplers.push(descriptor); + return { descriptor }; + }, + createBindGroup(descriptor) { + return { descriptor }; + }, + createShaderModule(descriptor) { + return { label: descriptor.label }; + }, + createBindGroupLayout(descriptor) { + return { label: descriptor.label }; + }, + createPipelineLayout() { + return {}; + }, + createRenderPipeline(descriptor) { + return { descriptor }; + }, + createCommandEncoder() { + return { + beginRenderPass(descriptor) { + mipgen.passes.push(descriptor); + return { + setPipeline() {}, + setBindGroup() {}, + draw() { + mipgen.draws++; + }, + end() {}, + }; + }, + finish() { + return {}; + }, + }; + }, + queue: { + copyExternalImageToTexture() {}, + submit() { + mipgen.submits++; + }, + }, + }; + renderer = { + device, + frameId: 1, + commandEncoder: null, + retiredTextures: [], + retireTexture(texture) { + texture.destroy(); + }, + cache: { + getUnit(texture) { + return texture.__unit; + }, + peekAllUnits(texture) { + return [texture.__unit]; + }, + }, + pipelineCache: { materialLayout: {} }, + getDefaultTextureFilter() { + return "linear"; + }, + }; + store = new WebGPUTextureStore(renderer); + }); + + it("a mip-wanting consumer gets a full chain with one blit pass per level", () => { + store.getBinding(makeAtlas(makeSource(64, 32)), { mipmaps: true }); + // floor(log2(64)) + 1 = 7 levels + expect(createdTextures[0].mipLevelCount).toBe(7); + // 6 downsample passes, one draw each, submitted immediately + expect(mipgen.passes).toHaveLength(6); + expect(mipgen.draws).toBe(6); + expect(mipgen.submits).toBe(1); + // each pass renders INTO one level, sampling the one above it + const first = mipgen.passes[0].colorAttachments[0]; + expect(first.view.viewDescriptor.baseMipLevel).toBe(1); + }); + + it("2D consumers stay lod-clamped to level 0; the mesh path samples the chain", () => { + const source = makeSource(64, 64); + // sprite first (flat), then the mesh path (mips) on the same unit + const spriteBinding = store.getBinding(makeAtlas(source)); + const meshBinding = store.getBinding(makeAtlas(source), { + mipmaps: true, + }); + expect(meshBinding).not.toBe(spriteBinding); + + const flat = samplers.find((s) => { + return s.lodMaxClamp === 0; + }); + expect(flat).toBeDefined(); + expect(flat.mipmapFilter).toBeUndefined(); + const mip = samplers.find((s) => { + return s.mipmapFilter === "linear"; + }); + expect(mip).toBeDefined(); + expect(mip.lodMaxClamp).toBeUndefined(); + // the mesh sampler is 4× anisotropic (valid: fully linear); flat + // samplers never are + expect(mip.maxAnisotropy).toBe(4); + expect(flat.maxAnisotropy).toBeUndefined(); + }); + + it("the mesh path upgrades a resident flat record in place (retire + regenerate)", () => { + const source = makeSource(32, 32); + store.getBinding(makeAtlas(source)); + expect(createdTextures[0].mipLevelCount).toBe(1); + expect(mipgen.submits).toBe(0); + + store.getBinding(makeAtlas(source), { mipmaps: true }); + expect(createdTextures).toHaveLength(2); + expect(createdTextures[0].destroyed).toBe(true); + expect(createdTextures[1].mipLevelCount).toBe(6); + expect(mipgen.submits).toBe(1); + + // and never downgrades: a later flat consumer keeps the chain + store.getBinding(makeAtlas(source)); + expect(createdTextures).toHaveLength(2); + }); + + it("a nearest-filtered mesh texture keeps hard level-0 sampling (opt-out)", () => { + store.getBinding(makeAtlas(makeSource(32, 32), { filter: "nearest" }), { + mipmaps: true, + }); + // the MATERIAL sampler is flat nearest, lod-clamped to level 0 (the + // only linear sampler in sight is the mip blit's internal one, which + // carries no mipmapFilter of its own) + expect( + samplers.some((s) => { + return s.mipmapFilter === "linear"; + }), + ).toBe(false); + const material = samplers.find((s) => { + return s.magFilter === "nearest"; + }); + expect(material).toBeDefined(); + expect(material.lodMaxClamp).toBe(0); + }); + + it("a 1×1 source (the white-pixel fallback) never generates", () => { + store.getBinding(makeAtlas(makeSource(1, 1)), { mipmaps: true }); + expect(createdTextures[0].mipLevelCount).toBe(1); + expect(mipgen.submits).toBe(0); + }); + + it("plain 2D uploads are unchanged (no chain, no submits — regression pin)", () => { + store.getBinding(makeAtlas(makeSource(128, 128))); + expect(createdTextures[0].mipLevelCount).toBe(1); + expect(mipgen.submits).toBe(0); + }); +}); diff --git a/packages/melonjs/tests/webgpu_msaa.spec.js b/packages/melonjs/tests/webgpu_msaa.spec.js new file mode 100644 index 0000000000..893e56e6cd --- /dev/null +++ b/packages/melonjs/tests/webgpu_msaa.spec.js @@ -0,0 +1,212 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it } from "vitest"; +import { WebGPURenderer } from "../src/index.js"; +import WebGPUPipelineCache from "../src/video/webgpu/pipeline/cache.js"; + +/** + * MSAA on the WebGPU backend (`antiAlias: true` → 4× canvas passes): + * canvas passes render into the shared multisampled color texture and + * resolve into the canvas view; offscreen render targets stay 1× (GL + * parity — only the default framebuffer is ever antialiased); the + * pipeline sample count is per-pass state owned by `beginPass`. + */ +function createMockDevice() { + const pipelines = []; + return { + pipelines, + createBindGroupLayout(d) { + return { label: d.label }; + }, + createBindGroup(d) { + return { label: d.label }; + }, + createShaderModule(d) { + return { label: d.label }; + }, + createPipelineLayout() { + return {}; + }, + createRenderPipeline(descriptor) { + const pipeline = { descriptor }; + pipelines.push(pipeline); + return pipeline; + }, + }; +} + +function makePassStub({ canvasSampleCount = 4, target = null } = {}) { + const passes = []; + const created = []; + return { + passes, + created, + commandEncoder: null, + renderPass: null, + currentRenderTarget: target, + frameTextureView: null, + frameTexture: null, + pendingColorClear: false, + pendingClearValue: null, + pendingStencilClear: false, + pendingDepthClear: false, + meshDepthActive: false, + depthTexture: null, + msaaColorTexture: null, + msaaColorView: null, + msaaDepthTexture: null, + preferredFormat: "bgra8unorm", + currentPipeline: {}, + maskVisibleRef: 0, + viewportRect: null, + scissorActive: false, + canvasSampleCount, + pipelineCache: {}, + retireTexture() {}, + applyScissor() {}, + applyViewport() {}, + getTargetSize() { + return [64, 48]; + }, + getCanvas() { + return { width: 64, height: 48 }; + }, + createDepthTexture: WebGPURenderer.prototype.createDepthTexture, + createMsaaColorTexture: WebGPURenderer.prototype.createMsaaColorTexture, + beginPass: WebGPURenderer.prototype.beginPass, + context: { + getCurrentTexture() { + return { + createView() { + return { canvas: true }; + }, + }; + }, + }, + device: { + createCommandEncoder() { + return { + beginRenderPass(descriptor) { + passes.push(descriptor); + return { + end() {}, + setViewport() {}, + setStencilReference() {}, + }; + }, + }; + }, + createTexture(descriptor) { + const texture = { + width: descriptor.size[0], + height: descriptor.size[1], + sampleCount: descriptor.sampleCount ?? 1, + label: descriptor.label, + createView() { + return { texture }; + }, + }; + created.push(texture); + return texture; + }, + }, + }; +} + +describe("WebGPU MSAA (antiAlias)", () => { + it("sampleCount is a pipeline-key axis with the count in the descriptor", () => { + const device = createMockDevice(); + const cache = new WebGPUPipelineCache(device, "bgra8unorm"); + cache.registerVertexLayout("quad", 28, [ + { format: "float32x3", offset: 0 }, + { format: "float32x2", offset: 12 }, + { format: "unorm8x4", offset: 20 }, + { format: "float32", offset: 24 }, + ]); + const single = cache.get("quad", "triangle-list", "normal", true); + cache.sampleCount = 4; + const multi = cache.get("quad", "triangle-list", "normal", true); + expect(multi).not.toBe(single); + expect(multi.descriptor.multisample.count).toBe(4); + expect(single.descriptor.multisample.count).toBe(1); + }); + + it("a 4× canvas pass renders into the msaa texture and resolves into the canvas view", () => { + const stub = makePassStub({ canvasSampleCount: 4 }); + stub.beginPass(); + + const [descriptor] = stub.passes; + const color = descriptor.colorAttachments[0]; + // the msaa texture is the attachment, the canvas view the resolve + expect(color.view.texture).toBe(stub.msaaColorTexture); + expect(color.resolveTarget).toEqual({ canvas: true }); + // samples persist across mid-frame restarts + expect(color.storeOp).toBe("store"); + // the depth twin matches the color sample count + expect(descriptor.depthStencilAttachment.view.texture).toBe( + stub.msaaDepthTexture, + ); + expect(stub.msaaDepthTexture.sampleCount).toBe(4); + // pipeline lookups recorded into this pass must declare 4× + expect(stub.pipelineCache.sampleCount).toBe(4); + }); + + it("offscreen target passes stay single-sampled (GL parity)", () => { + const stub = makePassStub({ + canvasSampleCount: 4, + target: { colorView: { offscreen: true } }, + }); + stub.beginPass(); + + const [descriptor] = stub.passes; + const color = descriptor.colorAttachments[0]; + expect(color.view).toEqual({ offscreen: true }); + expect("resolveTarget" in color).toBe(false); + expect(descriptor.depthStencilAttachment.view.texture).toBe( + stub.depthTexture, + ); + expect(stub.pipelineCache.sampleCount).toBe(1); + expect(stub.msaaColorTexture).toBe(null); + }); + + it("antiAlias off keeps the exact single-sample pass shape (regression pin)", () => { + const stub = makePassStub({ canvasSampleCount: 1 }); + stub.beginPass(); + + const [descriptor] = stub.passes; + const color = descriptor.colorAttachments[0]; + expect(color.view).toEqual({ canvas: true }); + expect("resolveTarget" in color).toBe(false); + expect(stub.msaaColorTexture).toBe(null); + expect(stub.pipelineCache.sampleCount).toBe(1); + }); + + it("the msaa attachments track size changes with retirement, arming the depth clear", () => { + const stub = makePassStub({ canvasSampleCount: 4 }); + const retired = []; + stub.retireTexture = (texture) => { + retired.push(texture); + }; + stub.beginPass(); + const firstColor = stub.msaaColorTexture; + const firstDepth = stub.msaaDepthTexture; + + // same size: both re-used + stub.renderPass = null; + stub.beginPass(); + expect(stub.msaaColorTexture).toBe(firstColor); + expect(stub.msaaDepthTexture).toBe(firstDepth); + + // canvas grew: both recreate, old pair retires, depth clear armed + stub.getTargetSize = () => { + return [128, 48]; + }; + stub.renderPass = null; + stub.pendingDepthClear = false; + stub.beginPass(); + expect(stub.msaaColorTexture).not.toBe(firstColor); + expect(retired).toContain(firstColor); + expect(retired).toContain(firstDepth); + // consumed by the very pass that armed it + expect(stub.passes[2].depthStencilAttachment.depthLoadOp).toBe("clear"); + }); +}); diff --git a/packages/melonjs/tests/webgpu_pipeline.spec.js b/packages/melonjs/tests/webgpu_pipeline.spec.js index e34b6e1938..ffc0e68fe1 100644 --- a/packages/melonjs/tests/webgpu_pipeline.spec.js +++ b/packages/melonjs/tests/webgpu_pipeline.spec.js @@ -355,4 +355,84 @@ describe("WebGPU pipeline (device-free units)", () => { expect(second.epoch).toBeGreaterThan(first.epoch); }); }); + + describe("mesh pipeline axes (the 3D tier)", () => { + function makeCache() { + const device = createMockDevice(); + const cache = new WebGPUPipelineCache(device, "bgra8unorm"); + cache.registerVertexLayout("quad", 28, [ + { format: "float32x3", offset: 0 }, + { format: "float32x2", offset: 12 }, + { format: "unorm8x4", offset: 20 }, + { format: "float32", offset: 24 }, + ]); + return { device, cache }; + } + + it("meshState is a key axis: every cull/frontFace pair is its own pipeline, distinct from the 2D one", () => { + const { device, cache } = makeCache(); + const flat = cache.get("quad", "triangle-list", "none", true); + const meshOf = (cullMode, frontFace) => { + return cache.get("quad", "triangle-list", "none", true, "none", { + cullMode, + frontFace, + }); + }; + const backCcw = meshOf("back", "ccw"); + expect(backCcw).not.toBe(flat); + expect(meshOf("back", "ccw")).toBe(backCcw); + expect(meshOf("back", "cw")).not.toBe(backCcw); + expect(meshOf("none", "ccw")).not.toBe(backCcw); + expect(device.pipelines).toHaveLength(4); + }); + + it("mesh descriptors enable depth write + LEQUAL and carry the culling axes", () => { + const { cache } = makeCache(); + const { descriptor } = cache.get( + "quad", + "triangle-list", + "none", + true, + "none", + { cullMode: "back", frontFace: "cw" }, + ); + // GL mesh-mode parity: LEQUAL (not LESS) keeps coplanar + // geometry from z-fighting itself out of existence + expect(descriptor.depthStencil.depthWriteEnabled).toBe(true); + expect(descriptor.depthStencil.depthCompare).toBe("less-equal"); + expect(descriptor.primitive.cullMode).toBe("back"); + expect(descriptor.primitive.frontFace).toBe("cw"); + }); + + it("mesh state merges with the stencil axes — a mesh inside a mask honors both", () => { + const { cache } = makeCache(); + const { descriptor } = cache.get( + "quad", + "triangle-list", + "none", + true, + "test", + { cullMode: "none", frontFace: "ccw" }, + ); + expect(descriptor.depthStencil.depthWriteEnabled).toBe(true); + expect(descriptor.depthStencil.depthCompare).toBe("less-equal"); + expect(descriptor.depthStencil.stencilFront.compare).toBe("equal"); + expect(descriptor.depthStencil.stencilWriteMask).toBe(0); + }); + + it("2D descriptors are unchanged with the mesh axes absent (regression pin)", () => { + const { cache } = makeCache(); + const { descriptor } = cache.get("quad", "triangle-list", "normal", true); + expect(descriptor.depthStencil.depthWriteEnabled).toBe(false); + expect(descriptor.depthStencil.depthCompare).toBe("always"); + // the culling keys must be ABSENT, not merely defaulted — the + // 2D descriptor stays byte-identical to a build with no mesh path + expect("cullMode" in descriptor.primitive).toBe(false); + expect("frontFace" in descriptor.primitive).toBe(false); + expect(descriptor.primitive).toEqual({ + topology: "triangle-list", + stripIndexFormat: undefined, + }); + }); + }); }); diff --git a/packages/melonjs/tests/webgpu_post_effect.spec.js b/packages/melonjs/tests/webgpu_post_effect.spec.js index fa7fc2c3c5..c64aadcaa6 100644 --- a/packages/melonjs/tests/webgpu_post_effect.spec.js +++ b/packages/melonjs/tests/webgpu_post_effect.spec.js @@ -236,6 +236,7 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { expect(samplerEntry.resource).toEqual({ filter: "linear", repeat: "repeat", + mipmaps: false, }); }); }); @@ -420,9 +421,10 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { }; batcher.blitTexture(source, 0, 0, 100, 100, glslOnly, true); - // plain quad family, current blend kept — scene content is never lost + // the single-texture blit family, current blend kept — scene + // content is never lost expect(renderer.calls.pipelineKeys.at(-1)).toBe( - "quad|triangle-list|normal|true|none", + "blit|triangle-list|normal|true|none", ); expect(renderer.calls.drawIndexed).toEqual([6]); }); diff --git a/packages/melonjs/tests/webgpu_quad_batcher.spec.js b/packages/melonjs/tests/webgpu_quad_batcher.spec.js index 81164d3ed9..95c73e9327 100644 --- a/packages/melonjs/tests/webgpu_quad_batcher.spec.js +++ b/packages/melonjs/tests/webgpu_quad_batcher.spec.js @@ -68,20 +68,56 @@ describe("WebGPUQuadBatcher", () => { expect([floats[21], floats[22]]).toEqual([140, 1060]); }); - it("a texture change flushes the quads queued under the previous material", () => { + it("a texture change claims a new slot — quads batch across textures in ONE segment", () => { batcher.addQuad(atlasA, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); batcher.addQuad(atlasA, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlasB, 0, 8, 8, 8, 0, 0, 1, 1, 0xffffffff); + // no flush on the texture change: B claimed slot 1 expect(renderer.calls.drawIndexed).toEqual([]); - batcher.addQuad(atlasB, 0, 8, 8, 8, 0, 0, 1, 1, 0xffffffff); - // the two atlas-A quads drained under A's bind group - expect(renderer.calls.drawIndexed).toEqual([12]); + batcher.flush(); + // one draw for all three quads, one COMPOSED bind group at group 1 + expect(renderer.calls.drawIndexed).toEqual([18]); expect(renderer.calls.materialBinds).toHaveLength(1); - expect(renderer.calls.materialBinds[0].texture).toBe(atlasA); + // aTextureId per vertex: quads 1-2 slot 0, quad 3 slot 1 (float + // at offset 6 of each 7-float vertex) + const vertexWrite = renderer.calls.writes.find((w) => { + return w.buffer === renderer.vertexArena.page; + }); + const slotOf = (vertex) => { + return vertexWrite.floats[vertex * 7 + 6]; + }; + expect(slotOf(0)).toBe(0); + expect(slotOf(4)).toBe(0); + expect(slotOf(8)).toBe(1); + }); + it("the ninth distinct texture forces the segment flush", () => { + const atlases = Array.from({ length: 9 }, (_, i) => { + return { name: `atlas${i}` }; + }); + for (const atlas of atlases) { + batcher.addQuad(atlas, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + } + // the first eight batched; the ninth drained them as one draw + expect(renderer.calls.drawIndexed).toEqual([8 * 6]); + batcher.flush(); + expect(renderer.calls.drawIndexed).toEqual([8 * 6, 6]); + // two composed bind groups, one per segment + expect(renderer.calls.materialBinds).toHaveLength(2); + }); + + it("a steady-state segment re-uses its composed bind group across flushes", () => { + batcher.addQuad(atlasA, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlasB, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.flush(); + batcher.addQuad(atlasA, 0, 8, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlasB, 8, 8, 8, 8, 0, 0, 1, 1, 0xffffffff); batcher.flush(); - expect(renderer.calls.drawIndexed).toEqual([12, 6]); - expect(renderer.calls.materialBinds[1].texture).toBe(atlasB); + expect(renderer.calls.materialBinds).toHaveLength(2); + expect(renderer.calls.materialBinds[1]).toBe( + renderer.calls.materialBinds[0], + ); }); it("a flush with no material ever adopted records nothing", () => { diff --git a/packages/melonjs/tests/webgpu_renderer.spec.js b/packages/melonjs/tests/webgpu_renderer.spec.js index b97e82b83f..a1fab4e71d 100644 --- a/packages/melonjs/tests/webgpu_renderer.spec.js +++ b/packages/melonjs/tests/webgpu_renderer.spec.js @@ -70,11 +70,11 @@ describe("WebGPURenderer (experimental bootstrap)", () => { // (ShaderEffect, {vertex, fragment} shader assets) key off this // to refuse handing it GLSL source expect(app.renderer.shaderLanguage).toBe("wgsl"); - // mock stage: these flags describe what works TODAY, and none of - // those paths exist yet — each flips when its implementation lands - expect(app.renderer.supportsDepthBuffer).toBe(false); - expect(app.renderer.supportsShaderTileLayers).toBe(false); - expect(app.renderer.supportsRetainedMesh).toBe(false); + // these flags describe what works TODAY: the shader tile path, the + // depth-tested mesh tier and retained mesh geometry all exist + expect(app.renderer.supportsDepthBuffer).toBe(true); + expect(app.renderer.supportsShaderTileLayers).toBe(true); + expect(app.renderer.supportsRetainedMesh).toBe(true); }); it("holds a configured device, context and preferred format after init()", (ctx) => { diff --git a/packages/melonjs/tests/webgpu_texture_store.spec.js b/packages/melonjs/tests/webgpu_texture_store.spec.js index 75db7b1435..caf05ad2b8 100644 --- a/packages/melonjs/tests/webgpu_texture_store.spec.js +++ b/packages/melonjs/tests/webgpu_texture_store.spec.js @@ -266,4 +266,20 @@ describe("WebGPUTextureStore", () => { emit(GPU_TEXTURE_CACHE_RESET); }).not.toThrow(); }); + + it("a video element without a decoded frame skips the upload instead of throwing", () => { + // a real