diff --git a/packages/examples/src/examples/aquarium/ExampleAquarium.tsx b/packages/examples/src/examples/aquarium/ExampleAquarium.tsx index 01039c42b2..f8e9ea5f94 100644 --- a/packages/examples/src/examples/aquarium/ExampleAquarium.tsx +++ b/packages/examples/src/examples/aquarium/ExampleAquarium.tsx @@ -94,6 +94,42 @@ vec4 apply(vec4 color, vec2 uv) { } `; +// the WGSL twin — same logic and uniform names. The WebGPU capture is +// top-down (row 0 = the top of the frame, matching the quad's UV), so the +// GLSL body's Y flip for the Y-up framebuffer copy is dropped. +const WATER_FRAGMENT_WGSL = ` +struct AquariumUniforms { + uTime : f32, + uStrength : f32, +}; +@group(3) @binding(0) var fx : AquariumUniforms; +@group(3) @binding(1) var uScene : texture_2d; +@group(3) @binding(2) var uSceneSampler : sampler; +@group(3) @binding(3) var uNoise : texture_2d; +@group(3) @binding(4) var uNoiseSampler : sampler; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let s = uv; + + // two noise layers scrolling apart -> a living flow field + let f1 = textureSample(uNoise, uNoiseSampler, s * 1.6 + vec2f(fx.uTime * 0.03, fx.uTime * 0.05)).rg; + let f2 = textureSample(uNoise, uNoiseSampler, s * 2.7 - vec2f(fx.uTime * 0.04, fx.uTime * 0.02)).rg; + let flow = f1 + f2 - vec2f(1.0); + + // refract the captured scene at the displaced screen coord + let scene = textureSample(uScene, uSceneSampler, clamp(s + flow * fx.uStrength, vec2f(0.0), vec2f(1.0))).rgb; + + // the water texture (the sprite's own), gently scrolled, as a wet sheen + let water = textureSample(uTexture, uSampler, uv * 0.6 + flow * 0.02).rgb; + var outc = scene * (vec3f(0.75) + 0.5 * water); + + // caustic sparkle where the flow layers pinch together + let caustic = pow(max(f1.r * f2.g, 0.0), 3.0) * 1.2; + outc = outc + vec3f(0.10, 0.20, 0.24) * caustic; + return vec4f(outc, 1.0); +} +`; + // a fish that swims horizontally and turns around at the tank edges class Fish extends Sprite { private speed: number; @@ -318,7 +354,11 @@ const createGame = async () => { loader.preload( [ { name: "aquariumAtlas", type: "image", src: `${base}aquarium.webp` }, - { name: "aquariumWater", type: "shader", data: WATER_FRAGMENT }, + { + name: "aquariumWater", + type: "shader", + data: { glsl: WATER_FRAGMENT, wgsl: WATER_FRAGMENT_WGSL }, + }, ], () => { state.change(state.PLAY); diff --git a/packages/examples/src/examples/aseprite/ExampleAseprite.tsx b/packages/examples/src/examples/aseprite/ExampleAseprite.tsx index 06c9066367..5cc36649a0 100644 --- a/packages/examples/src/examples/aseprite/ExampleAseprite.tsx +++ b/packages/examples/src/examples/aseprite/ExampleAseprite.tsx @@ -3,80 +3,78 @@ * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. * See `packages/examples/LICENSE.md` for full license + asset credits. */ -import * as me from "melonjs"; -import { useEffect } from "react"; +import type * as me from "melonjs"; +import { createExampleComponent } from "../utils"; import { createGame } from "./game"; import { paladin } from "./play"; -export const ExampleAseprite = () => { - useEffect(() => { - // `game` is undefined until the first Application finishes init() - if (!me.game?.isInitialized) { - void createGame(); - } - }, []); +const Game = createExampleComponent(createGame); +export const ExampleAseprite = () => { return ( - // float above the fixed #screen overlay (see index.css), like the - // tiledMapLoader / spine selectors — in normal flow the game surface - // paints over the controls -
-
Animation:
- -
+
Animation:
+ + + ); }; diff --git a/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx b/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx index 54ab3ece4a..6458cffa3c 100644 --- a/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx +++ b/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx @@ -37,7 +37,6 @@ const createGame = async () => { const app = new Application(canvasW, canvasH, { parent: "screen", renderer: video.AUTO, - preferWebGL1: false, }); await app.init(); } catch { diff --git a/packages/examples/src/examples/clipping/ExampleClipping.tsx b/packages/examples/src/examples/clipping/ExampleClipping.tsx index 25639d6544..013de5ffe3 100644 --- a/packages/examples/src/examples/clipping/ExampleClipping.tsx +++ b/packages/examples/src/examples/clipping/ExampleClipping.tsx @@ -40,7 +40,7 @@ class OverflowingRect extends Renderable { renderer.setColor(this.color); renderer.fillRect(0, 0, this.width, this.height); renderer.setColor("#ffffff"); - renderer.setLineWidth(3); + renderer.lineWidth = 3; renderer.strokeRect(0, 0, this.width, this.height); } } @@ -96,7 +96,7 @@ class ClipOutline extends Renderable { draw(renderer: Parameters[0]) { renderer.setColor(this.color); - renderer.setLineWidth(3); + renderer.lineWidth = 3; renderer.strokeRect(this.pos.x, this.pos.y, this.width, this.height); } } diff --git a/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx b/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx index 3b92491f9c..c4bbe96e53 100644 --- a/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx +++ b/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx @@ -51,6 +51,31 @@ vec4 apply(vec4 color, vec2 uv) { } `; +// the WGSL twin — same logic and uniform names. The WebGPU capture is +// top-down (row 0 = the top of the frame, matching the quad's UV), so the +// GLSL Y flip is dropped: the floor weight becomes (0.15 + y) and the +// upward scroll flips sign, everything else carries over. +const HAZE_FRAGMENT_WGSL = ` +struct HazeUniforms { + uTime : f32, + uStrength : f32, +}; +@group(3) @binding(0) var fx : HazeUniforms; +@group(3) @binding(1) var uScene : texture_2d; +@group(3) @binding(2) var uSceneSampler : sampler; +@group(3) @binding(3) var uNoise : texture_2d; +@group(3) @binding(4) var uNoiseSampler : sampler; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let s = uv; + let n1 = textureSample(uNoise, uNoiseSampler, vec2f(s.x * 2.0, s.y * 1.6 + fx.uTime * 0.18)).r; + let n2 = textureSample(uNoise, uNoiseSampler, vec2f(s.x * 3.3 + 0.5, s.y * 2.2 + fx.uTime * 0.11)).r; + let wob = (n1 + n2 - 1.0) * fx.uStrength * (0.15 + s.y); // stronger near the floor + let d = clamp(s + vec2f(wob, wob * 0.35), vec2f(0.0), vec2f(1.0)); + return vec4f(textureSample(uScene, uSceneSampler, d).rgb, 1.0); +} +`; + // an embossed metal-ish tile: a base colour with a bevel highlight/shadow, so // the normal-mapped relief reads clearly under the moving light const tileAlbedo = (size: number, hue: number) => { @@ -238,7 +263,13 @@ const createGame = async () => { state.set(state.PLAY, new PlayScreen()); loader.preload( - [{ name: "heatHaze", type: "shader", data: HAZE_FRAGMENT }], + [ + { + name: "heatHaze", + type: "shader", + data: { glsl: HAZE_FRAGMENT, wgsl: HAZE_FRAGMENT_WGSL }, + }, + ], () => { state.change(state.PLAY); }, diff --git a/packages/examples/src/examples/masking/ExampleMasking.tsx b/packages/examples/src/examples/masking/ExampleMasking.tsx index b875e909a7..5986a34a07 100644 --- a/packages/examples/src/examples/masking/ExampleMasking.tsx +++ b/packages/examples/src/examples/masking/ExampleMasking.tsx @@ -25,7 +25,6 @@ const createGame = async () => { parent: "screen", scaleMethod: "fit", renderer: video.AUTO, - preferWebGL1: false, }); await app.init(); } catch { diff --git a/packages/examples/src/examples/platformer-matter/createGame.ts b/packages/examples/src/examples/platformer-matter/createGame.ts index d9a7b4b9fd..6cbc809a38 100644 --- a/packages/examples/src/examples/platformer-matter/createGame.ts +++ b/packages/examples/src/examples/platformer-matter/createGame.ts @@ -79,7 +79,6 @@ export const createGame = async () => { parent: "screen", scaleMethod: "flex-width", renderer: video.AUTO, - preferWebGL1: false, subPixel: false, highPrecisionShader: false, physic: new MatterAdapter({ gravity: { x: 0, y: 5 } }), diff --git a/packages/examples/src/examples/platformer-matter/play.ts b/packages/examples/src/examples/platformer-matter/play.ts index 0a6df8d788..36cef6a53f 100644 --- a/packages/examples/src/examples/platformer-matter/play.ts +++ b/packages/examples/src/examples/platformer-matter/play.ts @@ -13,7 +13,6 @@ import { type ShaderEffect, Stage, VignetteEffect, - WebGLRenderer, } from "melonjs"; import { VirtualJoypad } from "./entities/controls"; import UIContainer from "./entities/HUD"; @@ -57,17 +56,17 @@ export class PlayScreen extends Stage { app.world.addChild(this.virtualJoypad); } - // Vignette post-effect (WebGL only). Cache the effect across resets - // so we don't stack a new VignetteEffect per restart. Same goes for + // Vignette post-effect, cached across resets so we don't stack a + // new VignetteEffect per restart. No backend check needed: on a + // renderer without a programmable pipeline (Canvas) the effect + // self-disables and the scene renders without it. Same goes for // the color grading — set from the identity each time so contrast // and saturation don't compound on each level reload. - if (app.renderer instanceof WebGLRenderer) { - if (!this.vignette) { - this.vignette = new VignetteEffect(app.renderer); - } - if (!app.viewport.postEffects.includes(this.vignette)) { - app.viewport.addPostEffect(this.vignette); - } + if (!this.vignette) { + this.vignette = new VignetteEffect(app.renderer); + } + if (!app.viewport.postEffects.includes(this.vignette)) { + app.viewport.addPostEffect(this.vignette); } app.viewport.colorMatrix .copy(IDENTITY_COLOR_MATRIX) diff --git a/packages/examples/src/examples/platformer/createGame.ts b/packages/examples/src/examples/platformer/createGame.ts index ff5477c5a6..094237ae70 100644 --- a/packages/examples/src/examples/platformer/createGame.ts +++ b/packages/examples/src/examples/platformer/createGame.ts @@ -30,7 +30,6 @@ export const createGame = async () => { parent: "screen", scaleMethod: "flex-width", renderer: video.AUTO, - preferWebGL1: false, subPixel: false, highPrecisionShader: false, }); diff --git a/packages/examples/src/examples/platformer/play.ts b/packages/examples/src/examples/platformer/play.ts index b51542336b..e27bdbc51d 100644 --- a/packages/examples/src/examples/platformer/play.ts +++ b/packages/examples/src/examples/platformer/play.ts @@ -11,7 +11,6 @@ import { plugin, Stage, VignetteEffect, - WebGLRenderer, } from "melonjs"; import { VirtualJoypad } from "./entities/controls"; import UIContainer from "./entities/HUD"; @@ -52,11 +51,10 @@ export class PlayScreen extends Stage { } // vignette post-effect + built-in color grading (always applied last). - // VignetteEffect is WebGL-only; on the Canvas renderer just skip it - // and let the color grading still apply. - if (app.renderer instanceof WebGLRenderer) { - app.viewport.addPostEffect(new VignetteEffect(app.renderer)); - } + // No backend check needed: on a renderer without a programmable + // pipeline (Canvas) the effect self-disables and the scene renders + // without it, while the color grading still applies. + app.viewport.addPostEffect(new VignetteEffect(app.renderer)); app.viewport.colorMatrix.contrast(1.1).saturate(1.1); // play some music diff --git a/packages/examples/src/examples/plinko-planck/createGame.ts b/packages/examples/src/examples/plinko-planck/createGame.ts index ebadfaef19..68615a04c0 100644 --- a/packages/examples/src/examples/plinko-planck/createGame.ts +++ b/packages/examples/src/examples/plinko-planck/createGame.ts @@ -39,7 +39,6 @@ export const createGame = async () => { scaleMethod: "fit", scaleTarget, renderer: video.AUTO, - preferWebGL1: false, subPixel: false, highPrecisionShader: false, // Anti-aliasing smooths every procedural draw call — the peg diff --git a/packages/examples/src/examples/pool-matter/createGame.ts b/packages/examples/src/examples/pool-matter/createGame.ts index 87484e100e..274c5f3d1e 100644 --- a/packages/examples/src/examples/pool-matter/createGame.ts +++ b/packages/examples/src/examples/pool-matter/createGame.ts @@ -30,7 +30,6 @@ export const createGame = async () => { parent: "screen", scaleMethod: "fit", renderer: video.AUTO, - preferWebGL1: false, subPixel: false, highPrecisionShader: false, // Anti-aliasing smooths the painted edges on the table sprite, diff --git a/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx b/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx index fac30691bd..44c6e25bd9 100644 --- a/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx +++ b/packages/examples/src/examples/svgShapes/ExampleSVGShapes.tsx @@ -19,7 +19,6 @@ const createGame = async () => { const app = new Application(1024, 840, { parent: "screen", renderer: video.WEBGL, - preferWebGL1: false, blendMode: "normal", }); await app.init(); diff --git a/packages/examples/src/examples/tiledMapLoader/ExampleTiledMapLoader.tsx b/packages/examples/src/examples/tiledMapLoader/ExampleTiledMapLoader.tsx index 539de64273..7e913981da 100644 --- a/packages/examples/src/examples/tiledMapLoader/ExampleTiledMapLoader.tsx +++ b/packages/examples/src/examples/tiledMapLoader/ExampleTiledMapLoader.tsx @@ -15,7 +15,7 @@ import { plugin, state, } from "melonjs"; -import { useEffect } from "react"; +import { createExampleComponent } from "../utils"; import { levels, resources } from "./resources"; const loadLevel = (name: string) => { @@ -78,7 +78,6 @@ const createGame = async () => { const app = new Application(1024, 768, { parent: "screen", scaleMethod: "fill-max", - preferWebGL1: false, }); await app.init(); @@ -125,12 +124,13 @@ const LevelSelector = () => { ); }; +const Game = createExampleComponent(createGame); + export const ExampleTiledMapLoader = () => { - useEffect(() => { - // `game` is undefined until the first Application finishes init() - if (!game?.isInitialized) { - void createGame(); - } - }, []); - return ; + return ( + <> + + + + ); }; diff --git a/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx b/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx index f5fa03dc69..560838aec1 100644 --- a/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx +++ b/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx @@ -3,37 +3,35 @@ * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. * See `packages/examples/LICENSE.md` for full license + asset credits. */ -import * as me from "melonjs"; -import { useEffect } from "react"; + +import { createExampleComponent } from "../utils"; import { createGame } from "./game"; -export const ExampleWaterOverworld = () => { - useEffect(() => { - // `game` is undefined until the first Application finishes init() - if (!me.game?.isInitialized) { - void createGame(); - } - }, []); +const Game = createExampleComponent(createGame); +export const ExampleWaterOverworld = () => { return ( - // float above the fixed #screen overlay (see index.css) -
- A/D or ←/→ move · W/↑ jump · hold Shift to run · S toggles the debug panel - — the pond refracts the scene via screen_texture /{" "} - screen_uv / noise_uv -
+ <> + + {/* float above the fixed #screen overlay (see index.css) */} +
+ A/D or ←/→ move · W/↑ jump · hold Shift to run · S toggles the debug + panel — the pond refracts the scene via screen_texture /{" "} + screen_uv / noise_uv +
+ ); }; diff --git a/packages/examples/src/examples/waterOverworld/entities.ts b/packages/examples/src/examples/waterOverworld/entities.ts index 1e368ab743..a88afa0675 100644 --- a/packages/examples/src/examples/waterOverworld/entities.ts +++ b/packages/examples/src/examples/waterOverworld/entities.ts @@ -427,7 +427,60 @@ export class WaterTextureObj extends me.Sprite { } `; - const water = new me.ShaderEffect(me.game.renderer, fragment); + // the WGSL twin — same logic and uniform names, picked by the + // engine when the WebGPU renderer is active + const fragmentWGSL = ` + struct WaterUniforms { + rangeWater : f32, + uTime : f32, + uEdgeAmp : f32, + uEdgeFreq : f32, + uScreenAmp : f32, + uScreenFreq : f32, + uFlowAmp : f32, + uNoiseAmp : f32, + }; + @group(3) @binding(0) var fx : WaterUniforms; + @group(3) @binding(1) var uNoise : texture_2d; + @group(3) @binding(2) var uNoiseSampler : sampler; + @group(3) @binding(3) var uNoise2 : texture_2d; + @group(3) @binding(4) var uNoise2Sampler : sampler; + + fn apply(color : vec4f, uv : vec2f) -> vec4f { + let t = fx.uTime % 6283.18; + + let flow1 = textureSample(uNoise, uNoiseSampler, noise_uv + vec2f(t * 0.05)).rg; + let flow2 = textureSample(uNoise, uNoiseSampler, noise_uv * 2.3 - vec2f(t * 0.03)).rg; + let flow = (flow1 + flow2 * 0.5) - vec2f(0.75); + let noise = 2.0 * textureSample(uNoise2, uNoise2Sampler, noise_uv + vec2f(0.5, 0.2) * t).rg - vec2f(1.0); + + let n = textureSample(uNoise, uNoiseSampler, vec2f(noise_uv.x * 3.0, t * 0.1)).r; + let edgeWave = sin(noise_uv.x * fx.uEdgeFreq + t * 1.8) * fx.uEdgeAmp + + sin(noise_uv.x * fx.uEdgeFreq * 2.2 - t * 2.6) * fx.uEdgeAmp * 0.5 + + (n - 0.5) * fx.uEdgeAmp * 0.6; + let edgeOffset = vec2f(0.0, edgeWave); + + let screenEdgeWave = sin(screen_uv.x * fx.uScreenFreq + t * 1.4) * fx.uScreenAmp + + sin(screen_uv.x * fx.uScreenFreq * 2.2 - t * 2.1) * fx.uScreenAmp * 0.5; + // screen_uv is y-down on this backend (the GLSL twin's is + // y-up): the reflection is the y-down mirror of GL's + // y-up affine (rangeWater + screenEdgeWave - y), so the + // waterline sits at the same screen height + let dynamicRange = 2.0 - fx.rangeWater + screenEdgeWave; + + var normalizedUV = screen_uv; + normalizedUV.y = dynamicRange - normalizedUV.y; + + var refractedScreen = textureSample(screen_texture, screen_sampler, normalizedUV + flow * fx.uFlowAmp); + refractedScreen = refractedScreen * textureSample(uTexture, uSampler, uv + edgeOffset + noise * fx.uNoiseAmp); + return refractedScreen; + } + `; + + const water = new me.ShaderEffect(me.game.renderer, { + glsl: fragment, + wgsl: fragmentWGSL, + }); water.setTexture("uNoise", noise.getTexture(), "repeat"); water.setTexture("uNoise2", noise2.getTexture(), "repeat"); diff --git a/packages/examples/src/examples/whac-a-mole/play.ts b/packages/examples/src/examples/whac-a-mole/play.ts index a519c0e30d..7c0017ec15 100644 --- a/packages/examples/src/examples/whac-a-mole/play.ts +++ b/packages/examples/src/examples/whac-a-mole/play.ts @@ -43,7 +43,9 @@ export class PlayScreen extends Stage { // add our HUD (scores/hiscore) this.HUD = new HUDContainer(); - app.world.addChild(this.HUD); + // z above every grass strip (i * 10 + 10) so the score draws on top — + // addChild assigns the z, a depth set in the constructor won't stick + app.world.addChild(this.HUD, 100); // start the main soundtrack audio.playTrack("whack"); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 3ab1a08aa8..bb2008d895 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,7 +3,8 @@ ## [20.0.0] (melonJS 2) - _unreleased_ ### Added -- **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 non-post-effect 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`. 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). Not yet implemented — post effects / ShaderEffect (WGSL story pending), lights, meshes/Camera3d, GPU tile layers, compressed textures: the capability flags stay honestly `false` and scenes relying on those 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)) +- **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)) - **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 - **`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 diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index 0eb9f3cb4e..32af5b6ead 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -560,6 +560,10 @@ export default class Application { this.renderer = new CustomRenderer(this.settings); } + // the renderer carries its owning application — engine code holding + // a renderer reference must use this rather than the global game + this.renderer.parentApplication = this; + // let the backend acquire whatever it cannot acquire synchronously — // an immediate resolve for Canvas and WebGL, the adapter/device // negotiation for WebGPU. This await is why `init()` is asynchronous. diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index e08e802fe1..0c59ac522c 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -20,8 +20,8 @@ import { VIEWPORT_ONRESIZE, } from "../system/event.ts"; import timer from "../system/timer.ts"; +import ColorMatrixEffect from "./../video/effects/colorMatrix.js"; import type Renderer from "./../video/renderer.js"; -import ColorMatrixEffect from "./../video/webgl/effects/colorMatrix.js"; import type CameraEffect from "./effects/camera_effect.ts"; import FadeEffect from "./effects/fade_effect.ts"; import ShakeEffect from "./effects/shake_effect.ts"; diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 23dc694380..5bcbdb5d96 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -62,6 +62,25 @@ import save from "./system/save.ts"; import timer from "./system/timer.ts"; import Tween from "./tweens/tween.ts"; import CanvasRenderer from "./video/canvas/canvas_renderer.js"; +import BlurEffect from "./video/effects/blur.js"; +import ChromaticAberrationEffect from "./video/effects/chromaticAberration.js"; +import ColorMatrixEffect from "./video/effects/colorMatrix.js"; +import DesaturateEffect from "./video/effects/desaturate.js"; +import DissolveEffect from "./video/effects/dissolve.js"; +import DropShadowEffect from "./video/effects/dropShadow.js"; +import FlashEffect from "./video/effects/flash.js"; +import GlowEffect from "./video/effects/glow.js"; +import HologramEffect from "./video/effects/hologram.js"; +import InvertEffect from "./video/effects/invert.js"; +import OutlineEffect from "./video/effects/outline.js"; +import PixelateEffect from "./video/effects/pixelate.js"; +import ScanlineEffect from "./video/effects/scanline.js"; +import SepiaEffect from "./video/effects/sepia.js"; +import ShaderEffect from "./video/effects/shadereffect.js"; +import ShineEffect from "./video/effects/shine.js"; +import TintPulseEffect from "./video/effects/tintPulse.js"; +import VignetteEffect from "./video/effects/vignette.js"; +import WaveEffect from "./video/effects/wave.js"; import { Batcher } from "./video/gpu/batcher.js"; import { isPortableTopology, @@ -83,26 +102,7 @@ import Texture2d from "./video/texture/texture2d.ts"; import { WebGLBatcher } from "./video/webgl/batchers/batcher.js"; import PrimitiveBatcher from "./video/webgl/batchers/primitive_batcher.js"; import QuadBatcher from "./video/webgl/batchers/quad_batcher.js"; -import BlurEffect from "./video/webgl/effects/blur.js"; -import ChromaticAberrationEffect from "./video/webgl/effects/chromaticAberration.js"; -import ColorMatrixEffect from "./video/webgl/effects/colorMatrix.js"; -import DesaturateEffect from "./video/webgl/effects/desaturate.js"; -import DissolveEffect from "./video/webgl/effects/dissolve.js"; -import DropShadowEffect from "./video/webgl/effects/dropShadow.js"; -import FlashEffect from "./video/webgl/effects/flash.js"; -import GlowEffect from "./video/webgl/effects/glow.js"; -import HologramEffect from "./video/webgl/effects/hologram.js"; -import InvertEffect from "./video/webgl/effects/invert.js"; -import OutlineEffect from "./video/webgl/effects/outline.js"; -import PixelateEffect from "./video/webgl/effects/pixelate.js"; -import ScanlineEffect from "./video/webgl/effects/scanline.js"; -import SepiaEffect from "./video/webgl/effects/sepia.js"; -import ShineEffect from "./video/webgl/effects/shine.js"; -import TintPulseEffect from "./video/webgl/effects/tintPulse.js"; -import VignetteEffect from "./video/webgl/effects/vignette.js"; -import WaveEffect from "./video/webgl/effects/wave.js"; import GLShader from "./video/webgl/glshader.js"; -import ShaderEffect from "./video/webgl/shadereffect.js"; import WebGLRenderer from "./video/webgl/webgl_renderer.js"; import WebGPUPrimitiveBatcher from "./video/webgpu/batchers/primitive_batcher.js"; import WebGPUQuadBatcher from "./video/webgpu/batchers/quad_batcher.js"; diff --git a/packages/melonjs/src/loader/parsers/compressed_textures/compressed_image.js b/packages/melonjs/src/loader/parsers/compressed_textures/compressed_image.js index a36c06f341..6f65416627 100644 --- a/packages/melonjs/src/loader/parsers/compressed_textures/compressed_image.js +++ b/packages/melonjs/src/loader/parsers/compressed_textures/compressed_image.js @@ -66,10 +66,10 @@ function hasRequiredExtension(imgExt) { } export function parseCompressedImage(arrayBuffer, imgExt) { - // check if the current renderer is WebGL - if (!_renderer.type.includes("WebGL")) { + // compressed textures need a GPU backend (WebGL or WebGPU) + if (!_renderer.type.includes("WebGL") && !_renderer.type.includes("WebGPU")) { throw new Error( - "unsupported texture format: " + imgExt + " (WebGL renderer required)", + "unsupported texture format: " + imgExt + " (a GPU renderer required)", ); } diff --git a/packages/melonjs/src/loader/parsers/shader.js b/packages/melonjs/src/loader/parsers/shader.js index 4641515535..e0a918616b 100644 --- a/packages/melonjs/src/loader/parsers/shader.js +++ b/packages/melonjs/src/loader/parsers/shader.js @@ -1,6 +1,6 @@ import { on, VIDEO_INIT } from "../../system/event.ts"; +import ShaderEffect from "../../video/effects/shadereffect.js"; import GLShader from "../../video/webgl/glshader.js"; -import ShaderEffect from "../../video/webgl/shadereffect.js"; import { shaderList } from "../cache.js"; import { fetchData } from "./fetchdata.js"; @@ -34,6 +34,15 @@ export function compileShaderAsset(source) { ); } if (typeof source === "object" && source !== null) { + // 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 + // when none matches — the preload itself always succeeds + if (typeof source.glsl === "string" || typeof source.wgsl === "string") { + const effect = new ShaderEffect(_renderer, source); + effect.shared = true; + return effect; + } if ( typeof source.vertex !== "string" || typeof source.fragment !== "string" @@ -70,11 +79,16 @@ export function compileShaderAsset(source) { /** * parse/preload a shader asset, from a `src` URL (or data: URI) or inline - * GLSL via the `data` field. Two source shapes are accepted: + * source via the `data` field. Three source shapes are accepted: * * - a GLSL **fragment body** following the ShaderEffect convention (uniform * declarations + `vec4 apply(vec4, vec2)`) → compiles into a shared * {@link ShaderEffect}; + * - a **dual-language body** — `src: {glsl: url, wgsl: url}` or + * `data: {glsl: source, wgsl: source}`, either language omittable — → + * 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` @@ -119,6 +133,43 @@ 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 if (typeof data.src === "object" && data.src !== null) { diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index d2b188e371..d2ff230a64 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -854,8 +854,20 @@ export default class Renderable extends Rect { * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer object */ preDraw(renderer) { - const ax = this.width * this.anchorPoint.x; - const ay = this.height * this.anchorPoint.y; + // The anchor offset of an `Infinity`-sized renderable (a Container's + // default size, ColorLayer) is `NaN` (`Infinity * 0`) or `±Infinity` — + // one `translate()` by that writes NaN into the transform's z column + // (the sub-pixel snap only repairs tx/ty), and every batched vertex + // recorded under it inherits z = NaN and is clipped away by the GPU + // backends. The 2D canvas context ignores non-finite transforms per + // spec; match it by treating the offset as 0 — an anchor point has no + // meaningful offset on an infinite size anyway. + const ax = Number.isFinite(this.width) + ? this.width * this.anchorPoint.x + : 0; + const ay = Number.isFinite(this.height) + ? this.height * this.anchorPoint.y + : 0; // save renderer context renderer.save(); diff --git a/packages/melonjs/src/video/webgl/effects/blur.js b/packages/melonjs/src/video/effects/blur.js similarity index 64% rename from packages/melonjs/src/video/webgl/effects/blur.js rename to packages/melonjs/src/video/effects/blur.js index e85d4dcfd6..0d3d16197d 100644 --- a/packages/melonjs/src/video/webgl/effects/blur.js +++ b/packages/melonjs/src/video/effects/blur.js @@ -1,4 +1,30 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct BlurUniforms { + uBlurStrength : f32, + uTextureSize : vec2f, +}; +@group(3) @binding(0) var fx : BlurUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let texel = fx.uBlurStrength / fx.uTextureSize; + var sum = vec4f(0.0); + // 9-tap box blur + sum += textureSample(uTexture, uSampler, uv + vec2f(-texel.x, -texel.y)); + sum += textureSample(uTexture, uSampler, uv + vec2f(0.0, -texel.y)); + sum += textureSample(uTexture, uSampler, uv + vec2f(texel.x, -texel.y)); + sum += textureSample(uTexture, uSampler, uv + vec2f(-texel.x, 0.0)); + sum += textureSample(uTexture, uSampler, uv); + sum += textureSample(uTexture, uSampler, uv + vec2f(texel.x, 0.0)); + sum += textureSample(uTexture, uSampler, uv + vec2f(-texel.x, texel.y)); + sum += textureSample(uTexture, uSampler, uv + vec2f(0.0, texel.y)); + sum += textureSample(uTexture, uSampler, uv + vec2f(texel.x, texel.y)); + return (sum / 9.0) * vColor; +} +`; /** * A shader effect that applies a box blur to the sprite. @@ -18,9 +44,8 @@ export default class BlurEffect extends ShaderEffect { * @param {number[]} [options.textureSize=[256, 256]] - texture dimensions [width, height] */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform float uBlurStrength; uniform vec2 uTextureSize; vec4 apply(vec4 color, vec2 uv) { @@ -39,7 +64,8 @@ export default class BlurEffect extends ShaderEffect { return (sum / 9.0) * vColor; } `, - ); + wgsl: wgslFragment, + }); this.strength = options.strength ?? 1.0; const texSize = options.textureSize ?? [256, 256]; diff --git a/packages/melonjs/src/video/webgl/effects/chromaticAberration.js b/packages/melonjs/src/video/effects/chromaticAberration.js similarity index 71% rename from packages/melonjs/src/video/webgl/effects/chromaticAberration.js rename to packages/melonjs/src/video/effects/chromaticAberration.js index 28ebca5d34..02a1b5db97 100644 --- a/packages/melonjs/src/video/webgl/effects/chromaticAberration.js +++ b/packages/melonjs/src/video/effects/chromaticAberration.js @@ -1,4 +1,23 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct AberrationUniforms { + uOffset : f32, + uTextureSize : vec2f, +}; +@group(3) @binding(0) var fx : AberrationUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let texel = fx.uOffset / fx.uTextureSize; + let r = textureSample(uTexture, uSampler, uv + vec2f(texel.x, 0.0)).r; + let g = textureSample(uTexture, uSampler, uv).g; + let b = textureSample(uTexture, uSampler, uv - vec2f(texel.x, 0.0)).b; + let a = textureSample(uTexture, uSampler, uv).a; + return vec4f(r, g, b, a) * vColor; +} +`; /** * A shader effect that offsets the RGB color channels to create a @@ -21,9 +40,8 @@ export default class ChromaticAberrationEffect extends ShaderEffect { * @param {number[]} [options.textureSize=[256, 256]] - texture dimensions [width, height] */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform float uOffset; uniform vec2 uTextureSize; vec4 apply(vec4 color, vec2 uv) { @@ -35,7 +53,8 @@ export default class ChromaticAberrationEffect extends ShaderEffect { return vec4(r, g, b, a) * vColor; } `, - ); + wgsl: wgslFragment, + }); this.offset = options.offset ?? 3.0; const texSize = options.textureSize ?? [256, 256]; diff --git a/packages/melonjs/src/video/webgl/effects/colorMatrix.js b/packages/melonjs/src/video/effects/colorMatrix.js similarity index 87% rename from packages/melonjs/src/video/webgl/effects/colorMatrix.js rename to packages/melonjs/src/video/effects/colorMatrix.js index 4046635448..653db1459b 100644 --- a/packages/melonjs/src/video/webgl/effects/colorMatrix.js +++ b/packages/melonjs/src/video/effects/colorMatrix.js @@ -1,5 +1,18 @@ -import { ColorMatrix } from "../../../math/color_matrix.ts"; -import ShaderEffect from "../shadereffect.js"; +import { ColorMatrix } from "../../math/color_matrix.ts"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct ColorMatrixUniforms { + uColorMatrix : mat4x4f, +}; +@group(3) @binding(0) var fx : ColorMatrixUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return fx.uColorMatrix * color; +} +`; /** * A shader effect that applies a 4x4 color transformation matrix. @@ -23,15 +36,15 @@ export default class ColorMatrixEffect extends ShaderEffect { * @param {ColorMatrix} [options.matrix] - an initial color matrix. Defaults to identity. */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform mat4 uColorMatrix; vec4 apply(vec4 color, vec2 uv) { return uColorMatrix * color; } `, - ); + wgsl: wgslFragment, + }); /** * the internal color matrix diff --git a/packages/melonjs/src/video/webgl/effects/desaturate.js b/packages/melonjs/src/video/effects/desaturate.js similarity index 100% rename from packages/melonjs/src/video/webgl/effects/desaturate.js rename to packages/melonjs/src/video/effects/desaturate.js diff --git a/packages/melonjs/src/video/webgl/effects/dissolve.js b/packages/melonjs/src/video/effects/dissolve.js similarity index 64% rename from packages/melonjs/src/video/webgl/effects/dissolve.js rename to packages/melonjs/src/video/effects/dissolve.js index 145442f1dd..b1eb34cc06 100644 --- a/packages/melonjs/src/video/webgl/effects/dissolve.js +++ b/packages/melonjs/src/video/effects/dissolve.js @@ -1,4 +1,64 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct DissolveUniforms { + uDissolveProgress : f32, + uEdgeColor : vec3f, + uEdgeWidth : f32, +}; +@group(3) @binding(0) var fx : DissolveUniforms; + +// pseudo-random hash +fn dissolveHash(p : vec2f) -> f32 { + return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453); +} +// smooth value noise +fn dissolveNoise(p : vec2f) -> f32 { + let i = floor(p); + var f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(dissolveHash(i), dissolveHash(i + vec2f(1.0, 0.0)), f.x), + mix(dissolveHash(i + vec2f(0.0, 1.0)), dissolveHash(i + vec2f(1.0, 1.0)), f.x), f.y); +} +// fractal brownian motion for organic shapes +fn dissolveFBM(p : vec2f) -> f32 { + var v = 0.0; + var a = 0.5; + var q = p; + for (var i = 0; i < 4; i++) { + v += a * dissolveNoise(q); + q *= 2.0; + a *= 0.5; + } + return v; +} + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + if (fx.uDissolveProgress <= 0.0) { + return color; + } + let n = dissolveFBM(uv * 8.0); + if (n < fx.uDissolveProgress) { + discard; + } + // glowing burn edge + let dist = (n - fx.uDissolveProgress) / fx.uEdgeWidth; + if (dist < 1.0) { + let t = 1.0 - dist; + // 3-stop gradient: red edge -> orange -> bright white core + let red = vec3f(0.8, 0.1, 0.0); + var glow = select( + mix(fx.uEdgeColor, vec3f(1.0, 0.95, 0.8), (t - 0.5) * 2.0), + mix(red, fx.uEdgeColor, t * 2.0), + t < 0.5); + glow *= 1.0 + t * t; + return vec4f(mix(color.rgb, glow, t) * color.a, color.a); + } + return color; +} +`; /** * A shader effect that dissolves the sprite using a noise-based threshold. @@ -24,9 +84,8 @@ export default class DissolveEffect extends ShaderEffect { * @param {number} [options.edgeWidth=0.1] - width of the colored edge (0.0–1.0) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform float uDissolveProgress; uniform vec3 uEdgeColor; uniform float uEdgeWidth; @@ -71,7 +130,8 @@ export default class DissolveEffect extends ShaderEffect { return color; } `, - ); + wgsl: wgslFragment, + }); this.progress = options.progress ?? 0.0; this.setUniform("uDissolveProgress", this.progress); diff --git a/packages/melonjs/src/video/webgl/effects/dropShadow.js b/packages/melonjs/src/video/effects/dropShadow.js similarity index 71% rename from packages/melonjs/src/video/webgl/effects/dropShadow.js rename to packages/melonjs/src/video/effects/dropShadow.js index 8f0a92aebd..1bc40d9f38 100644 --- a/packages/melonjs/src/video/webgl/effects/dropShadow.js +++ b/packages/melonjs/src/video/effects/dropShadow.js @@ -1,4 +1,31 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct ShadowUniforms { + uShadowOffset : vec2f, + uShadowColor : vec3f, + uShadowOpacity : f32, + uTextureSize : vec2f, +}; +@group(3) @binding(0) var fx : ShadowUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + if (color.a > 0.0) { + return color; + } + // 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; + let shadowAlpha = textureSampleLevel(uTexture, uSampler, uv - offset, 0.0).a; + if (shadowAlpha > 0.0) { + return vec4f(fx.uShadowColor, shadowAlpha * fx.uShadowOpacity) * vColor; + } + return color; +} +`; /** * A shader effect that adds a drop shadow beneath the sprite. @@ -26,9 +53,8 @@ export default class DropShadowEffect extends ShaderEffect { * @param {number[]} [options.textureSize=[256, 256]] - texture dimensions [width, height] */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec2 uShadowOffset; uniform vec3 uShadowColor; uniform float uShadowOpacity; @@ -46,7 +72,8 @@ export default class DropShadowEffect extends ShaderEffect { return color; } `, - ); + wgsl: wgslFragment, + }); const texSize = options.textureSize ?? [256, 256]; this.setUniform( diff --git a/packages/melonjs/src/video/webgl/effects/flash.js b/packages/melonjs/src/video/effects/flash.js similarity index 78% rename from packages/melonjs/src/video/webgl/effects/flash.js rename to packages/melonjs/src/video/effects/flash.js index bd3f479c11..a2dc7e73e4 100644 --- a/packages/melonjs/src/video/webgl/effects/flash.js +++ b/packages/melonjs/src/video/effects/flash.js @@ -1,4 +1,18 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct FlashUniforms { + uFlashColor : vec3f, + uFlashIntensity : f32, +}; +@group(3) @binding(0) var fx : FlashUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(mix(color.rgb, fx.uFlashColor * color.a, fx.uFlashIntensity), color.a); +} +`; /** * A shader effect that flashes the sprite with a solid color. @@ -29,16 +43,16 @@ export default class FlashEffect extends ShaderEffect { * @param {number} [options.intensity=0.0] - initial flash intensity (0.0–1.0) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uFlashColor; uniform float uFlashIntensity; vec4 apply(vec4 color, vec2 uv) { return vec4(mix(color.rgb, uFlashColor * color.a, uFlashIntensity), color.a); } `, - ); + wgsl: wgslFragment, + }); const color = options.color ?? [1.0, 1.0, 1.0]; this.intensity = options.intensity ?? 0.0; diff --git a/packages/melonjs/src/video/webgl/effects/glow.js b/packages/melonjs/src/video/effects/glow.js similarity index 73% rename from packages/melonjs/src/video/webgl/effects/glow.js rename to packages/melonjs/src/video/effects/glow.js index 9773deed30..1f2eb83b39 100644 --- a/packages/melonjs/src/video/webgl/effects/glow.js +++ b/packages/melonjs/src/video/effects/glow.js @@ -1,4 +1,34 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct GlowUniforms { + uGlowColor : vec3f, + uGlowWidth : f32, + uGlowIntensity : f32, + uTextureSize : vec2f, +}; +@group(3) @binding(0) var fx : GlowUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + if (color.a > 0.0) { + return color; + } + // sample in a circle to create a soft glow (level-0: non-uniform flow) + var a = 0.0; + let texel = fx.uGlowWidth / fx.uTextureSize; + for (var angle = 0.0; angle < 6.28; angle += 0.785) { + let offset = vec2f(cos(angle), sin(angle)) * texel; + a += textureSampleLevel(uTexture, uSampler, uv + offset, 0.0).a; + } + a = a / 8.0 * fx.uGlowIntensity; + if (a > 0.0) { + return vec4f(fx.uGlowColor * a, a) * vColor; + } + return color; +} +`; /** * A shader effect that adds a colored glow around the sprite. @@ -23,9 +53,8 @@ export default class GlowEffect extends ShaderEffect { * @param {number[]} [options.textureSize=[256, 256]] - texture dimensions [width, height] */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uGlowColor; uniform float uGlowWidth; uniform float uGlowIntensity; @@ -48,7 +77,8 @@ export default class GlowEffect extends ShaderEffect { return color; } `, - ); + wgsl: wgslFragment, + }); const color = options.color ?? [1.0, 1.0, 1.0]; const width = options.width ?? 3.0; diff --git a/packages/melonjs/src/video/effects/glsl_realization.js b/packages/melonjs/src/video/effects/glsl_realization.js new file mode 100644 index 0000000000..6d657733f8 --- /dev/null +++ b/packages/melonjs/src/video/effects/glsl_realization.js @@ -0,0 +1,148 @@ +import quadVertex from "../webgl/shaders/quad.vert"; + +/* + * The GLSL realization of a ShaderEffect body — the pure source-assembly + * half of the WebGL path, extracted verbatim so the neutral ShaderEffect + * class can dispatch per backend while the emitted GLSL stays + * byte-identical (pinned by tests/effects_golden_glsl.spec.js). + * + * ---- Shader builtins ------------------------------------------------------- + * + * Inside a ShaderEffect fragment body, three names get special treatment so + * users never compute screen/frame UVs by hand: + * + * - `uniform sampler2D : screen_texture;` — the engine strips the + * annotation and keeps the sampler filled with a capture of everything + * drawn so far (a back-buffer copy, via the renderer's shared + * toFrameTexture slot). An optional wrap mode is accepted: + * `: screen_texture(repeat)`. + * - `screen_uv` — varying with this fragment's position in that capture + * (0..1 across the screen). + * - `noise_uv` — varying with a frame-local coordinate across the drawn + * object (undoes atlas packing; scaled to object pixels so patterns keep + * their density when the destination is scaled). + * + * The builtins only activate when referenced, and never when the body + * carries its OWN declaration of the identifier — a shader that used these + * names before this feature keeps compiling unchanged. + */ +const SCREEN_TEXTURE_ANNOTATION = + /\buniform\s+sampler2D\s+([A-Za-z_]\w*)\s*:\s*screen_texture(?:\((repeat|repeat-x|repeat-y|no-repeat)\))?\s*;/g; +const SCREEN_UV_IDENTIFIER = /\bscreen_uv\b/; +const NOISE_UV_IDENTIFIER = /\bnoise_uv\b/; + +/** + * whether the body declares `name` itself (as a varying/uniform/attribute) — + * the engine then leaves that identifier fully user-managed + * @ignore + */ +function hasOwnDeclaration(source, name) { + return new RegExp( + `\\b(?:varying|uniform|attribute)\\s+\\w+\\s+${name}\\s*;`, + ).test(source); +} + +/** + * Parse the builtin usages out of a fragment body: collect and strip the + * `: screen_texture` annotations, and detect the `screen_uv` / `noise_uv` + * varyings. Bodies without builtins pass through byte-identical. + * @ignore + */ +function parseShaderBuiltins(fragmentBody) { + const screenTextures = []; + const body = fragmentBody.replace( + SCREEN_TEXTURE_ANNOTATION, + (match, name, repeat = "no-repeat") => { + screenTextures.push({ name, repeat }); + // keep the plain `uniform sampler2D ;` declaration + return match.replace(/\s*:\s*screen_texture(?:\([\w-]+\))?/, ""); + }, + ); + const screenUV = + (screenTextures.length > 0 || SCREEN_UV_IDENTIFIER.test(body)) && + !hasOwnDeclaration(body, "screen_uv"); + const noiseUV = + NOISE_UV_IDENTIFIER.test(body) && !hasOwnDeclaration(body, "noise_uv"); + return { body, screenTextures, screenUV, noiseUV }; +} + +/** + * The effect's vertex shader: the stock quad template, or — when a builtin + * varying is in play — a variant that additionally computes `screen_uv` + * (clip space → 0..1) and/or `noise_uv` (atlas UV → frame-local, fed by + * `_setNoiseUVRect` through the `ME_*` uniforms). Built deterministically + * from a template; user source is never rewritten. + * @ignore + */ +function buildEffectVertex(builtins) { + if (!builtins.screenUV && !builtins.noiseUV) { + return quadVertex; + } + return [ + "attribute vec3 aVertex;", + "attribute vec2 aRegion;", + "attribute vec4 aColor;", + "uniform mat4 uProjectionMatrix;", + "varying vec2 vRegion;", + "varying vec4 vColor;", + ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), + ...(builtins.noiseUV + ? [ + "uniform vec2 ME_size_obj;", + "uniform vec2 ME_size_img;", + "uniform vec2 ME_offset;", + "varying vec2 noise_uv;", + ] + : []), + "void main(void) {", + " vec4 ME_clip = uProjectionMatrix * vec4(aVertex, 1.0);", + " gl_Position = ME_clip;", + ...(builtins.screenUV + ? [" screen_uv = ME_clip.xy / ME_clip.w * 0.5 + 0.5;"] + : []), + ...(builtins.noiseUV + ? [ + " noise_uv = aRegion * (ME_size_img / ME_size_obj) - ME_offset / ME_size_obj;", + ] + : []), + " vColor = vec4(aColor.bgr * aColor.a, aColor.a);", + " vRegion = aRegion;", + "}", + ].join("\n"); +} + +/** + * Assemble the complete GLSL program sources for a ShaderEffect fragment + * body: parse the builtins, build the vertex variant, and wrap the user's + * `apply()` with the texture-sampling boilerplate. + * @param {string} fragmentBody - the user body (GLSL, `vec4 apply(vec4, vec2)` convention) + * @returns {{vertex: string, fragment: string, screenTextures: Array<{name: string, repeat: string}>, noiseUV: boolean}} the assembled sources + builtin usage + * @ignore + */ +export function buildGLSLProgram(fragmentBody) { + // Shader builtins: parse & strip `: screen_texture` annotations, + // detect the free `screen_uv` / `noise_uv` varyings (see the module + // header). A body using none of them passes through untouched. + const builtins = parseShaderBuiltins(fragmentBody); + + // wrap the user's apply() with the texture-sampling boilerplate + const fragment = [ + "uniform sampler2D uSampler;", + "varying vec4 vColor;", + "varying vec2 vRegion;", + ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), + ...(builtins.noiseUV ? ["varying vec2 noise_uv;"] : []), + builtins.body, + "void main(void) {", + " vec4 texColor = texture2D(uSampler, vRegion) * vColor;", + " gl_FragColor = apply(texColor, vRegion);", + "}", + ].join("\n"); + + return { + vertex: buildEffectVertex(builtins), + fragment, + screenTextures: builtins.screenTextures, + noiseUV: builtins.noiseUV, + }; +} diff --git a/packages/melonjs/src/video/webgl/effects/hologram.js b/packages/melonjs/src/video/effects/hologram.js similarity index 69% rename from packages/melonjs/src/video/webgl/effects/hologram.js rename to packages/melonjs/src/video/effects/hologram.js index 39d75a151c..7ef2a94867 100644 --- a/packages/melonjs/src/video/webgl/effects/hologram.js +++ b/packages/melonjs/src/video/effects/hologram.js @@ -1,4 +1,26 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct HologramUniforms { + uHoloColor : vec3f, + uHoloIntensity : f32, + uTime : f32, +}; +@group(3) @binding(0) var fx : HologramUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + // scan line + let scan = sin(uv.y * 200.0 + fx.uTime * 5.0) * 0.5 + 0.5; + // flicker + let flicker = 0.95 + 0.05 * sin(fx.uTime * 30.0); + // color shift + var holo = mix(color.rgb, fx.uHoloColor * color.a, fx.uHoloIntensity); + holo *= (1.0 - scan * 0.15) * flicker; + return vec4f(holo, color.a * flicker); +} +`; /** * A shader effect that simulates a holographic projection with @@ -21,9 +43,8 @@ export default class HologramEffect extends ShaderEffect { * @param {number} [options.intensity=0.5] - effect intensity (0.0–1.0) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uHoloColor; uniform float uHoloIntensity; uniform float uTime; @@ -38,7 +59,8 @@ export default class HologramEffect extends ShaderEffect { return vec4(holo, color.a * flicker); } `, - ); + wgsl: wgslFragment, + }); const color = options.color ?? [0.1, 0.7, 1.0]; this.setUniform("uHoloColor", new Float32Array(color)); diff --git a/packages/melonjs/src/video/webgl/effects/invert.js b/packages/melonjs/src/video/effects/invert.js similarity index 100% rename from packages/melonjs/src/video/webgl/effects/invert.js rename to packages/melonjs/src/video/effects/invert.js diff --git a/packages/melonjs/src/video/webgl/effects/outline.js b/packages/melonjs/src/video/effects/outline.js similarity index 67% rename from packages/melonjs/src/video/webgl/effects/outline.js rename to packages/melonjs/src/video/effects/outline.js index fde1b3ad91..fce1bdddb2 100644 --- a/packages/melonjs/src/video/webgl/effects/outline.js +++ b/packages/melonjs/src/video/effects/outline.js @@ -1,4 +1,36 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct OutlineUniforms { + uOutlineColor : vec3f, + uOutlineWidth : f32, + uTextureSize : vec2f, +}; +@group(3) @binding(0) var fx : OutlineUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + if (color.a > 0.0) { + return color; + } + // sample neighbors to detect edges (level-0: non-uniform flow) + let texel = fx.uOutlineWidth / fx.uTextureSize; + var a = 0.0; + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(-texel.x, 0.0), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(texel.x, 0.0), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(0.0, -texel.y), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(0.0, texel.y), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(-texel.x, -texel.y), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(texel.x, -texel.y), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(-texel.x, texel.y), 0.0).a); + a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(texel.x, texel.y), 0.0).a); + if (a > 0.0) { + return vec4f(fx.uOutlineColor, a) * vColor; + } + return color; +} +`; /** * A shader effect that draws a colored outline around the sprite. @@ -26,9 +58,8 @@ export default class OutlineEffect extends ShaderEffect { * @param {number[]} [options.textureSize] - texture dimensions [width, height] (defaults to renderer size) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uOutlineColor; uniform float uOutlineWidth; uniform vec2 uTextureSize; @@ -53,7 +84,8 @@ export default class OutlineEffect extends ShaderEffect { return color; } `, - ); + wgsl: wgslFragment, + }); const color = options.color ?? [1.0, 1.0, 1.0]; const width = options.width ?? 1.0; diff --git a/packages/melonjs/src/video/webgl/effects/pixelate.js b/packages/melonjs/src/video/effects/pixelate.js similarity index 74% rename from packages/melonjs/src/video/webgl/effects/pixelate.js rename to packages/melonjs/src/video/effects/pixelate.js index bb686f9937..00116bbd04 100644 --- a/packages/melonjs/src/video/webgl/effects/pixelate.js +++ b/packages/melonjs/src/video/effects/pixelate.js @@ -1,4 +1,20 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct PixelateUniforms { + uPixelSize : f32, + uTextureSize : vec2f, +}; +@group(3) @binding(0) var fx : PixelateUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let texel = fx.uPixelSize / fx.uTextureSize; + let snapped = texel * floor(uv / texel) + texel * 0.5; + return textureSample(uTexture, uSampler, snapped) * vColor; +} +`; /** * A shader effect that pixelates the sprite by snapping UV coordinates @@ -20,9 +36,8 @@ export default class PixelateEffect extends ShaderEffect { * @param {number[]} [options.textureSize=[256, 256]] - texture dimensions [width, height] */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform float uPixelSize; uniform vec2 uTextureSize; vec4 apply(vec4 color, vec2 uv) { @@ -31,7 +46,8 @@ export default class PixelateEffect extends ShaderEffect { return texture2D(uSampler, snapped) * vColor; } `, - ); + wgsl: wgslFragment, + }); this.size = options.size ?? 4.0; const texSize = options.textureSize ?? [256, 256]; diff --git a/packages/melonjs/src/video/webgl/effects/radialGradient.js b/packages/melonjs/src/video/effects/radialGradient.js similarity index 89% rename from packages/melonjs/src/video/webgl/effects/radialGradient.js rename to packages/melonjs/src/video/effects/radialGradient.js index 9c5f7d5000..1a599df51d 100644 --- a/packages/melonjs/src/video/webgl/effects/radialGradient.js +++ b/packages/melonjs/src/video/effects/radialGradient.js @@ -1,8 +1,8 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; /** * additional import for TypeScript - * @import { Color } from "../../../math/color.ts"; + * @import { Color } from "../../math/color.ts"; * @import { default as WebGLRenderer } from "../webgl_renderer.js"; */ @@ -85,9 +85,8 @@ export default class RadialGradientEffect extends ShaderEffect { * @param {number} [options.intensity=1] - peak alpha at the center (0..1+) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uColor; uniform float uIntensity; vec4 apply(vec4 color, vec2 uv) { @@ -109,7 +108,25 @@ export default class RadialGradientEffect extends ShaderEffect { return vec4(rgb, a); } `, - ); + // the WGSL twin — same logic and uniform names; `color` arrives + // tinted (textureSample * vColor), identical to the GLSL contract + wgsl: ` + struct RadialGradientUniforms { + uColor : vec3f, + uIntensity : f32, + }; + @group(3) @binding(0) var fx : RadialGradientUniforms; + + fn apply(color : vec4f, uv : vec2f) -> vec4f { + let c = uv * 2.0 - vec2f(1.0); + let d = length(c); + let f = clamp(1.0 - d, 0.0, 1.0); + let rgb = color.rgb * fx.uColor * fx.uIntensity * f; + let a = color.a * fx.uIntensity * f; + return vec4f(rgb, a); + } + `, + }); // reused across `setColor` calls so we don't allocate a fresh // 3-element array every frame on every light. diff --git a/packages/melonjs/src/video/webgl/effects/scanline.js b/packages/melonjs/src/video/effects/scanline.js similarity index 69% rename from packages/melonjs/src/video/webgl/effects/scanline.js rename to packages/melonjs/src/video/effects/scanline.js index e92ec08d32..00ba90a313 100644 --- a/packages/melonjs/src/video/webgl/effects/scanline.js +++ b/packages/melonjs/src/video/effects/scanline.js @@ -1,4 +1,44 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct ScanlineUniforms { + uScanlineOpacity : f32, + uCurvature : f32, + uVignetteStrength : f32, +}; +@group(3) @binding(0) var fx : ScanlineUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + var c = color; + var coords = uv; + + // barrel distortion (CRT curvature) + if (fx.uCurvature > 0.0) { + var centered = uv * 2.0 - 1.0; + centered *= 1.0 + fx.uCurvature * dot(centered, centered); + coords = centered * 0.5 + 0.5; + if (coords.x < 0.0 || coords.x > 1.0 || coords.y < 0.0 || coords.y > 1.0) { + discard; + } + c = textureSampleLevel(uTexture, uSampler, coords, 0.0) * vColor; + } + + // scanlines + let line = sin(coords.y * 800.0) * 0.5 + 0.5; + c = vec4f(c.rgb * (1.0 - line * fx.uScanlineOpacity), c.a); + + // vignette + if (fx.uVignetteStrength > 0.0) { + let vig = coords * (1.0 - coords); + let vigFactor = vig.x * vig.y * 15.0; + c = vec4f(c.rgb * clamp(pow(vigFactor, fx.uVignetteStrength), 0.0, 1.0), c.a); + } + + return c; +} +`; /** * A shader effect that overlays horizontal scanlines on the sprite. @@ -25,9 +65,8 @@ export default class ScanlineEffect extends ShaderEffect { * @param {number} [options.vignetteStrength=0.0] - edge darkening strength (0.0 = none, 0.3 = subtle) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform float uScanlineOpacity; uniform float uCurvature; uniform float uVignetteStrength; @@ -59,7 +98,8 @@ export default class ScanlineEffect extends ShaderEffect { return color; } `, - ); + wgsl: wgslFragment, + }); this.setUniform("uScanlineOpacity", options.opacity ?? 0.25); this.setUniform("uCurvature", options.curvature ?? 0.0); diff --git a/packages/melonjs/src/video/webgl/effects/sepia.js b/packages/melonjs/src/video/effects/sepia.js similarity index 100% rename from packages/melonjs/src/video/webgl/effects/sepia.js rename to packages/melonjs/src/video/effects/sepia.js diff --git a/packages/melonjs/src/video/webgl/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js similarity index 66% rename from packages/melonjs/src/video/webgl/shadereffect.js rename to packages/melonjs/src/video/effects/shadereffect.js index 3951433008..6ab773188d 100644 --- a/packages/melonjs/src/video/webgl/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -5,122 +5,83 @@ import { on, } from "../../system/event.ts"; import Texture2d from "../texture/texture2d.ts"; -import GLShader from "./glshader.js"; -import quadVertex from "./shaders/quad.vert"; - -/* - * ---- Shader builtins ------------------------------------------------------- - * - * Inside a ShaderEffect fragment body, three names get special treatment so - * users never compute screen/frame UVs by hand: - * - * - `uniform sampler2D : screen_texture;` — the engine strips the - * annotation and keeps the sampler filled with a capture of everything - * drawn so far (a back-buffer copy, via the renderer's shared - * toFrameTexture slot). An optional wrap mode is accepted: - * `: screen_texture(repeat)`. - * - `screen_uv` — varying with this fragment's position in that capture - * (0..1 across the screen). - * - `noise_uv` — varying with a frame-local coordinate across the drawn - * object (undoes atlas packing; scaled to object pixels so patterns keep - * their density when the destination is scaled). - * - * The builtins only activate when referenced, and never when the body - * carries its OWN declaration of the identifier — a shader that used these - * names before this feature keeps compiling unchanged. - */ -const SCREEN_TEXTURE_ANNOTATION = - /\buniform\s+sampler2D\s+([A-Za-z_]\w*)\s*:\s*screen_texture(?:\((repeat|repeat-x|repeat-y|no-repeat)\))?\s*;/g; -const SCREEN_UV_IDENTIFIER = /\bscreen_uv\b/; -const NOISE_UV_IDENTIFIER = /\bnoise_uv\b/; - -/** - * whether the body declares `name` itself (as a varying/uniform/attribute) — - * the engine then leaves that identifier fully user-managed - * @ignore - */ -function hasOwnDeclaration(source, name) { - return new RegExp( - `\\b(?:varying|uniform|attribute)\\s+\\w+\\s+${name}\\s*;`, - ).test(source); -} - -/** - * Parse the builtin usages out of a fragment body: collect and strip the - * `: screen_texture` annotations, and detect the `screen_uv` / `noise_uv` - * varyings. Bodies without builtins pass through byte-identical. - * @ignore - */ -function parseShaderBuiltins(fragmentBody) { - const screenTextures = []; - const body = fragmentBody.replace( - SCREEN_TEXTURE_ANNOTATION, - (match, name, repeat = "no-repeat") => { - screenTextures.push({ name, repeat }); - // keep the plain `uniform sampler2D ;` declaration - return match.replace(/\s*:\s*screen_texture(?:\([\w-]+\))?/, ""); - }, - ); - const screenUV = - (screenTextures.length > 0 || SCREEN_UV_IDENTIFIER.test(body)) && - !hasOwnDeclaration(body, "screen_uv"); - const noiseUV = - NOISE_UV_IDENTIFIER.test(body) && !hasOwnDeclaration(body, "noise_uv"); - return { body, screenTextures, screenUV, noiseUV }; -} - -/** - * The effect's vertex shader: the stock quad template, or — when a builtin - * varying is in play — a variant that additionally computes `screen_uv` - * (clip space → 0..1) and/or `noise_uv` (atlas UV → frame-local, fed by - * `_setNoiseUVRect` through the `ME_*` uniforms). Built deterministically - * from a template; user source is never rewritten. - * @ignore - */ -function buildEffectVertex(builtins) { - if (!builtins.screenUV && !builtins.noiseUV) { - return quadVertex; - } - return [ - "attribute vec3 aVertex;", - "attribute vec2 aRegion;", - "attribute vec4 aColor;", - "uniform mat4 uProjectionMatrix;", - "varying vec2 vRegion;", - "varying vec4 vColor;", - ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), - ...(builtins.noiseUV - ? [ - "uniform vec2 ME_size_obj;", - "uniform vec2 ME_size_img;", - "uniform vec2 ME_offset;", - "varying vec2 noise_uv;", - ] - : []), - "void main(void) {", - " vec4 ME_clip = uProjectionMatrix * vec4(aVertex, 1.0);", - " gl_Position = ME_clip;", - ...(builtins.screenUV - ? [" screen_uv = ME_clip.xy / ME_clip.w * 0.5 + 0.5;"] - : []), - ...(builtins.noiseUV - ? [ - " noise_uv = aRegion * (ME_size_img / ME_size_obj) - ME_offset / ME_size_obj;", - ] - : []), - " vColor = vec4(aColor.bgr * aColor.a, aColor.a);", - " vRegion = aRegion;", - "}", - ].join("\n"); -} +import GLShader from "../webgl/glshader.js"; +import { buildGLSLProgram } from "./glsl_realization.js"; +import WGSLEffectRealization from "./wgsl_realization.js"; /** * A simplified shader class for applying custom fragment effects to renderables. * Only requires a fragment `apply()` function — the vertex shader, uniforms, and * texture sampling boilerplate are handled automatically. - * In Canvas mode, the shader is silently disabled (all methods become no-ops). + * + * ## Dual-language bodies + * + * An effect body is written in the active renderer's shading language: + * GLSL on the WebGL renderer, WGSL on the WebGPU renderer. Pass a plain + * string for a GLSL-only effect (the historical form), or one body per + * language for an effect that runs on both backends: + * + * ```js + * new ShaderEffect(renderer, { glsl: glslBody, wgsl: wgslBody }); + * ``` + * + * The renderer compiles the body matching its + * {@link Renderer#shaderLanguage}. When no matching body exists — a + * GLSL-only effect on the WebGPU renderer, any effect on the Canvas + * renderer — the effect warns once and stays **disabled** + * (`enabled === false`, every method a safe no-op): the scene renders + * without the effect, it never breaks. + * + * ## The WGSL convention + * + * A WGSL body mirrors the GLSL one — declarations plus an apply function, + * compiled verbatim inside engine boilerplate: + * + * - `fn apply(color : vec4f, uv : vec2f) -> vec4f` — required; receives + * the sampled, tinted pixel and its UV, returns the modified color. + * - Uniforms are the members of ONE struct bound as + * `@group(3) @binding(0) var fx : MyUniforms;` — member names + * are the {@link ShaderEffect#setUniform} names, so a dual-language + * effect uses the same uniform names in both bodies and one + * `setUniform` call serves both. Supported member types: `f32`, `i32`, + * `u32`, `vec2f`, `vec3f`, `vec4f`, `mat3x3f`, `mat4x4f`, + * `array`. + * - Extra {@link ShaderEffect#setTexture} samplers are texture/sampler + * pairs at explicit consecutive group-3 bindings (from 1): + * `@group(3) @binding(1) var uNoise : texture_2d;` + * `@group(3) @binding(2) var uNoiseSampler : sampler;` + * - The source texture is available as `uTexture` with `uSampler` + * (`textureSample(uTexture, uSampler, uv)` — the WGSL spelling of + * GLSL's `texture2D(uSampler, uv)`), and the interpolated tint as + * `vColor`, under the same names as the GLSL side. + * - The shader builtins keep their names: `screen_uv`, `noise_uv`, and + * `screen_texture` — sampled through `screen_sampler` (clamped) or + * `screen_sampler_repeat` (wrapping), replacing the GLSL + * `: screen_texture(repeat)` annotation. + * - Porting note: a texture sampled after a non-uniform `return` or + * inside a varying branch must use + * `textureSampleLevel(uTexture, uSampler, uv, 0.0)` (a WGSL + * uniform-control-flow rule; identical output for sprite textures). * @category Rendering * @example + * // one effect, both backends: dual-language body + * mySprite.shader = new ShaderEffect(renderer, { + * glsl: ` + * uniform float uStrength; + * vec4 apply(vec4 color, vec2 uv) { + * return vec4(color.rgb * uStrength, color.a); + * } + * `, + * wgsl: ` + * struct Fx { uStrength : f32, }; + * @group(3) @binding(0) var fx : Fx; + * fn apply(color : vec4f, uv : vec2f) -> vec4f { + * return vec4f(color.rgb * fx.uStrength, color.a); + * } + * `, + * }); + * mySprite.shader.setUniform("uStrength", 0.5); // sets either backend + * @example * // create a grayscale effect * mySprite.shader = new ShaderEffect(renderer, ` * vec4 apply(vec4 color, vec2 uv) { @@ -192,13 +153,18 @@ export default class ShaderEffect { shared = false; /** - * @param {WebGLRenderer|CanvasRenderer} renderer - the current renderer instance - * @param {string} fragmentBody - GLSL code containing a `vec4 apply(vec4 color, vec2 uv)` function - * that receives the sampled pixel color and UV coordinates, and returns the modified color. - * You can declare additional `uniform` variables before the `apply()` function. - * @param {string} [precision=auto detected] - float precision ('lowp', 'mediump' or 'highp') + * @param {WebGLRenderer|WebGPURenderer|CanvasRenderer} renderer - the current renderer instance + * @param {string|{glsl?: string, wgsl?: string}} body - the effect body: + * a GLSL string (containing a `vec4 apply(vec4 color, vec2 uv)` function — + * unchanged from previous versions), or an object carrying one body per + * shading language (`glsl` and/or `wgsl`, the WGSL body defining + * `fn apply(color : vec4f, uv : vec2f) -> vec4f`). The renderer picks the + * body matching its {@link Renderer#shaderLanguage}; when no matching body + * exists the effect warns once and stays disabled (`enabled === false`), + * exactly like the Canvas renderer. + * @param {string} [precision=auto detected] - float precision ('lowp', 'mediump' or 'highp'), GLSL only */ - constructor(renderer, fragmentBody, precision) { + constructor(renderer, body, precision) { /** * the renderer this effect was created for — kept so destroy and * context-loss can release the texture units reserved on its cache @@ -209,85 +175,108 @@ export default class ShaderEffect { this._renderer = renderer; /** - * the construction "recipe" (fragment body + precision), kept so + * the construction "recipe" (body + precision), kept VERBATIM so * {@link clone} can compile an independent copy. Stored before the - * Canvas-mode early return so cloning behaves consistently there too. + * disabled-stub early return so cloning behaves consistently there too. * @ignore */ - this._fragmentBody = fragmentBody; + this._fragmentBody = body; /** @ignore */ this._precision = precision; - // GLSL specifically, not "is there a GPU backend": the body below is - // handed to the driver as GLSL source, so a backend speaking another - // shading language cannot run it either. - if (renderer.shaderLanguage !== "glsl") { + // resolve the body matching this renderer's shading language: a bare + // string keeps meaning GLSL (the historical signature), an object + // carries one body per language + const bodies = typeof body === "string" ? { glsl: body } : (body ?? {}); + const language = renderer.shaderLanguage; + const source = language !== null ? bodies[language] : undefined; + + if (typeof source !== "string") { + // no body this backend can compile — same inert-stub contract as + // the Canvas renderer: warn, stay disabled, every method no-ops console.warn( - `ShaderEffect requires a GLSL backend and is disabled on this renderer (shader language: ${ - renderer.shaderLanguage ?? "none" - })`, + language === null + ? "ShaderEffect requires a GPU backend and is disabled on this renderer (no programmable pipeline)" + : `ShaderEffect has no ${language} body and is disabled on this renderer (provide a { ${language}: ... } source)`, ); return; } - // Shader builtins: parse & strip `: screen_texture` annotations, - // detect the free `screen_uv` / `noise_uv` varyings (see the module - // header). A body using none of them passes through untouched. - const builtins = parseShaderBuiltins(fragmentBody); - - /** - * samplers annotated `: screen_texture` — the renderer checks this to - * know when to refresh the shared frame capture before the effect draws - * @type {Array<{name: string, repeat: string}>} - * @ignore - */ - this._screenTextureUniforms = builtins.screenTextures; - /** @ignore */ - this._hasNoiseUV = builtins.noiseUV; - - // wrap the user's apply() with the texture-sampling boilerplate - const fragment = [ - "uniform sampler2D uSampler;", - "varying vec4 vColor;", - "varying vec2 vRegion;", - ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), - ...(builtins.noiseUV ? ["varying vec2 noise_uv;"] : []), - builtins.body, - "void main(void) {", - " vec4 texColor = texture2D(uSampler, vRegion) * vColor;", - " gl_FragColor = apply(texColor, vRegion);", - "}", - ].join("\n"); - - /** @ignore */ - this._shader = new GLShader( - renderer.gl, - buildEffectVertex(builtins), - fragment, - precision || renderer.shaderPrecision, - ); - this.enabled = true; - /** * extra texture samplers bound via {@link setTexture}, keyed by the * uniform name → `{ image, repeat, tex }` (`tex` is the uploaded GL - * texture, created lazily on first draw) + * texture, created lazily on first draw; unused on WebGPU, where the + * bind group is built lazily instead) * @ignore */ this._extraTextures = new Map(); - // wire every annotated screen sampler to the renderer's shared frame - // capture — a live GPU-resident entry, re-bound fresh on each draw and - // skipped while no capture has been taken yet - for (const screenTexture of builtins.screenTextures) { - this.setTexture( - screenTexture.name, - renderer.getSharedFrameTexture(), - screenTexture.repeat, + if (language === "glsl") { + // assemble the GLSL sources (builtin parsing + boilerplate) — the + // pure-assembly half lives in glsl_realization.js, pinned + // byte-identical by the generated-GLSL golden spec + const program = buildGLSLProgram(source); + + /** + * samplers annotated `: screen_texture` — the renderer checks this to + * know when to refresh the shared frame capture before the effect draws + * @type {Array<{name: string, repeat: string}>} + * @ignore + */ + this._screenTextureUniforms = program.screenTextures; + /** @ignore */ + this._hasNoiseUV = program.noiseUV; + + /** @ignore */ + this._shader = new GLShader( + renderer.gl, + program.vertex, + program.fragment, + precision || renderer.shaderPrecision, ); + this.enabled = true; + + // wire every annotated screen sampler to the renderer's shared frame + // capture — a live GPU-resident entry, re-bound fresh on each draw and + // skipped while no capture has been taken yet + for (const screenTexture of program.screenTextures) { + this.setTexture( + screenTexture.name, + renderer.getSharedFrameTexture(), + screenTexture.repeat, + ); + } + } else { + // WGSL: parse the body's declarations (WebGPU has no uniform + // reflection — offsets are computed CPU-side) and assemble the + // module; GPU objects are built lazily by the renderer's effect + // path. A parse failure disables the effect like a missing body. + const realization = new WGSLEffectRealization(source); + if (!realization.valid) { + console.warn( + `ShaderEffect: invalid WGSL body — ${realization.error} (effect disabled)`, + ); + return; + } + /** @ignore */ + this.wgslRealization = realization; + // same renderer-facing capture contract as the GLSL side: a + // non-empty list means "refresh the frame capture before I draw" + this._screenTextureUniforms = realization.builtins.screenTexture + ? [ + { + name: "screen_texture", + repeat: realization.builtins.screenSamplerRepeat + ? "repeat" + : "no-repeat", + }, + ] + : []; + this._hasNoiseUV = realization.builtins.noiseUV; + this.enabled = true; } - // flip enabled across context loss so beginPostEffect skips us + // flip enabled across context/device loss so beginPostEffect skips us on(ONCONTEXT_LOST, this._onContextLost, this); on(ONCONTEXT_RESTORED, this._onContextRestored, this); } @@ -336,8 +325,15 @@ export default class ShaderEffect { // a loss window — defeating that replay — or while the user had the // effect disabled. Canvas stubs (no shader) and destroyed effects // (partial-state immunity, see destroy()) keep no-oping. - if (typeof this._shader !== "undefined" && this.destroyed !== true) { + if (this.destroyed === true) { + return; + } + if (typeof this._shader !== "undefined") { this._shader.setUniform(name, value); + } else if (typeof this.wgslRealization !== "undefined") { + // WGSL: write the CPU mirror; the value is snapshot-uploaded at + // the effect's next bind (survives device loss for free) + this.wgslRealization.setUniform(name, value); } } @@ -381,6 +377,14 @@ export default class ShaderEffect { typeof this._shader.uniforms.uTime !== "undefined" ) { this._shader.setUniform("uTime", seconds); + } else if ( + typeof this.wgslRealization !== "undefined" && + this.destroyed !== true && + this.wgslRealization.hasUniform("uTime") + ) { + // WGSL twin of the active-uniform check: the parsed struct map is + // authoritative (no compiler elimination under WebGPU) + this.wgslRealization.setUniform("uTime", seconds); } return this; } @@ -407,12 +411,23 @@ export default class ShaderEffect { u0, v0, ) { - if ( - this._hasNoiseUV !== true || - typeof this._shader === "undefined" || - this.destroyed === true || - this._shader.suspended - ) { + if (this._hasNoiseUV !== true || this.destroyed === true) { + return; + } + if (typeof this.wgslRealization !== "undefined") { + // WGSL: write the engine ME struct's CPU mirror (size_obj @0, + // size_img @2, offset @4 — the scaffold's MEBuiltins layout); + // snapshot-uploaded alongside the uniform block at bind time + const me = this.wgslRealization.meMirror; + me[0] = Math.max(Math.abs(objectWidth), 1); + me[1] = Math.max(Math.abs(objectHeight), 1); + me[2] = Math.max(sourceWidth, 1); + me[3] = Math.max(sourceHeight, 1); + me[4] = u0 * me[2]; + me[5] = v0 * me[3]; + return; + } + if (typeof this._shader === "undefined" || this._shader.suspended) { return; } // guard each set on the ACTIVE uniforms map — the compiler eliminates @@ -504,8 +519,41 @@ export default class ShaderEffect { "ShaderEffect.setTexture does not support HTMLVideoElement (extra textures upload once and would freeze on the first frame)", ); } - // Canvas stub (no shader) and destroyed effects: keep the inert no-op - if (typeof this._shader === "undefined" || this.destroyed === true) { + // Canvas stub (no realization at all) and destroyed effects: keep the + // inert no-op + if (this.destroyed === true) { + return this; + } + if (typeof this.wgslRealization !== "undefined") { + // WGSL: the sampler must be a texture var the body DECLARED (the + // bind group is built from the parsed binding table — an + // undeclared name has no binding to fill) + const declared = this.wgslRealization.textures.some((t) => { + return t.name === name; + }); + if (!declared) { + console.warn( + `ShaderEffect.setTexture: "${name}" is not a texture declared in the WGSL body (expected a \`@group(3) @binding(n) var ${name} : texture_2d;\` + sampler pair)`, + ); + return this; + } + const wgslLive = + image instanceof Texture2d && image.isGPUResident === true; + if (!wgslLive && image instanceof Texture2d) { + image = image.getTexture(); + } + this._extraTextures.set(name, { + image, + repeat, + tex: null, + live: wgslLive, + unit: undefined, + }); + // the cached bind group (if any) referenced the previous texture + this.wgslRealization.gpu?.invalidateBindGroup?.(); + return this; + } + if (typeof this._shader === "undefined") { return this; } // A GPU-resident LIVE source (a frame capture from @@ -679,6 +727,17 @@ export default class ShaderEffect { this._fragmentBody, this._precision, ); + // WGSL: replay the exact user-set values recorded by the realization + // (not a mirror read-back), then re-declare the extra textures + if (this.wgslRealization && copy.wgslRealization) { + for (const [name, value] of this.wgslRealization.values) { + copy.setUniform(name, value); + } + for (const [name, entry] of this._extraTextures) { + copy.setTexture(name, entry.image, entry.repeat); + } + return copy; + } // Canvas-mode effects are inert stubs — nothing further to copy if (this._shader && copy._shader) { // replay this effect's cached uniform values onto the clone (same @@ -706,48 +765,53 @@ export default class ShaderEffect { return copy; } + // the GL-program pass-throughs below additionally guard on `_shader`: + // a WGSL-realized effect is `enabled` without ever owning a GL program + /** @ignore */ bind() { - if (this.enabled) { + if (this.enabled && this._shader) { this._shader.bind(); } } /** @ignore */ getAttribLocation(name) { - return this.enabled ? this._shader.getAttribLocation(name) : -1; + return this.enabled && this._shader + ? this._shader.getAttribLocation(name) + : -1; } /** @ignore */ setVertexAttributes(gl, attributes, stride) { - if (this.enabled) { + if (this.enabled && this._shader) { this._shader.setVertexAttributes(gl, attributes, stride); } } /** @ignore */ get program() { - return this.enabled ? this._shader.program : null; + return this.enabled && this._shader ? this._shader.program : null; } /** @ignore */ get vertex() { - return this.enabled ? this._shader.vertex : null; + return this.enabled && this._shader ? this._shader.vertex : null; } /** @ignore */ get fragment() { - return this.enabled ? this._shader.fragment : null; + return this.enabled && this._shader ? this._shader.fragment : null; } /** @ignore */ get attributes() { - return this.enabled ? this._shader.attributes : {}; + return this.enabled && this._shader ? this._shader.attributes : {}; } /** @ignore */ get uniforms() { - return this.enabled ? this._shader.uniforms : {}; + return this.enabled && this._shader ? this._shader.uniforms : {}; } /** @@ -768,6 +832,19 @@ export default class ShaderEffect { off(ONCONTEXT_LOST, this._onContextLost, this); off(ONCONTEXT_RESTORED, this._onContextRestored, this); + // WGSL: retire the effect-owned resident textures (static setTexture + // uploads), then drop the lazily-built GPU state; the pipeline cache + // keeps the shared module (bounded retention, rebuilt per epoch) + if (this.wgslRealization) { + const gpu = this.wgslRealization.gpu; + if (gpu?.residentTextures) { + for (const resident of gpu.residentTextures.values()) { + this._renderer.retireTexture?.(resident.texture); + } + } + this.wgslRealization.releaseGPU(); + this._extraTextures.clear(); + } // _shader is undefined on Canvas-mode effects (early-returned) if (this._shader) { // release any extra textures bound via setTexture — both the GL diff --git a/packages/melonjs/src/video/webgl/effects/shine.js b/packages/melonjs/src/video/effects/shine.js similarity index 78% rename from packages/melonjs/src/video/webgl/effects/shine.js rename to packages/melonjs/src/video/effects/shine.js index 3c8c96a7bf..c75281d025 100644 --- a/packages/melonjs/src/video/webgl/effects/shine.js +++ b/packages/melonjs/src/video/effects/shine.js @@ -1,4 +1,40 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct ShineUniforms { + uShineColor : vec3f, + uShineWidth : f32, + uShineSpeed : f32, + uShineIntensity : f32, + uShineAngle : f32, + uShineBands : f32, + uPulseDepth : f32, + uPulseSpeed : f32, + uTime : f32, +}; +@group(3) @binding(0) var fx : ShineUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + if (color.a == 0.0) { + return color; + } + // Optional brightness pulse on the base color. + let pulse = (1.0 - fx.uPulseDepth) + fx.uPulseDepth * sin(fx.uTime * fx.uPulseSpeed); + // 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 localX = fract(pos * fx.uShineBands); + let sweep = fract(fx.uTime * fx.uShineSpeed); + let d = abs(localX - sweep); + let dist = min(d, 1.0 - d); + let glint = smoothstep(fx.uShineWidth, 0.0, dist) * fx.uShineIntensity; + // premultiplied-alpha discipline mirrors the GLSL twin + let result = color.rgb * pulse + fx.uShineColor * glint * color.a; + return vec4f(result, color.a); +} +`; /** * A shader effect that sweeps a bright highlight band across the sprite — @@ -50,9 +86,8 @@ export default class ShineEffect extends ShaderEffect { * @param {number} [options.pulseSpeed=3.0] - pulse oscillation rate (radians/second) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uShineColor; uniform float uShineWidth; uniform float uShineSpeed; @@ -89,7 +124,8 @@ export default class ShineEffect extends ShaderEffect { return vec4(result, color.a); } `, - ); + wgsl: wgslFragment, + }); this.setUniform( "uShineColor", diff --git a/packages/melonjs/src/video/webgl/effects/tintPulse.js b/packages/melonjs/src/video/effects/tintPulse.js similarity index 75% rename from packages/melonjs/src/video/webgl/effects/tintPulse.js rename to packages/melonjs/src/video/effects/tintPulse.js index e95fc5f547..c3943c063b 100644 --- a/packages/melonjs/src/video/webgl/effects/tintPulse.js +++ b/packages/melonjs/src/video/effects/tintPulse.js @@ -1,4 +1,21 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct TintPulseUniforms { + uPulseColor : vec3f, + uPulseSpeed : f32, + uPulseIntensity : f32, + uTime : f32, +}; +@group(3) @binding(0) var fx : TintPulseUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let pulse = (sin(fx.uTime * fx.uPulseSpeed * 6.2832) * 0.5 + 0.5) * fx.uPulseIntensity; + return vec4f(mix(color.rgb, fx.uPulseColor * color.a, pulse), color.a); +} +`; /** * A shader effect that pulses a color overlay on the sprite. @@ -26,9 +43,8 @@ export default class TintPulseEffect extends ShaderEffect { * @param {number} [options.intensity=0.3] - maximum tint strength (0.0–1.0) */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform vec3 uPulseColor; uniform float uPulseSpeed; uniform float uPulseIntensity; @@ -38,7 +54,8 @@ export default class TintPulseEffect extends ShaderEffect { return vec4(mix(color.rgb, uPulseColor * color.a, pulse), color.a); } `, - ); + wgsl: wgslFragment, + }); const color = options.color ?? [1.0, 0.0, 0.0]; this.setUniform("uPulseColor", new Float32Array(color)); diff --git a/packages/melonjs/src/video/webgl/effects/vignette.js b/packages/melonjs/src/video/effects/vignette.js similarity index 73% rename from packages/melonjs/src/video/webgl/effects/vignette.js rename to packages/melonjs/src/video/effects/vignette.js index 626fdbdcd4..f20d9c8dae 100644 --- a/packages/melonjs/src/video/webgl/effects/vignette.js +++ b/packages/melonjs/src/video/effects/vignette.js @@ -1,4 +1,31 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the effect body, dual-authored: same logic, same uniform names, one body +// per shading language — the ShaderEffect base picks the one matching the +// active renderer (renderer.shaderLanguage) +const fragment = ` + uniform float uStrength; + uniform float uSize; + vec4 apply(vec4 color, vec2 uv) { + vec2 vig = uv * (1.0 - uv); + float v = clamp(pow(vig.x * vig.y * uSize, uStrength), 0.0, 1.0); + return vec4(color.rgb * v, color.a); + } + `; + +const wgslFragment = ` +struct VignetteUniforms { + uStrength : f32, + uSize : f32, +}; +@group(3) @binding(0) var fx : VignetteUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let vig = uv * (1.0 - uv); + let v = clamp(pow(vig.x * vig.y * fx.uSize, fx.uStrength), 0.0, 1.0); + return vec4f(color.rgb * v, color.a); +} +`; /** * A shader effect that darkens the edges of the screen, drawing focus @@ -24,18 +51,7 @@ export default class VignetteEffect extends ShaderEffect { * @param {number} [options.size=25.0] - vignette spread multiplier (higher = smaller dark area) */ constructor(renderer, options = {}) { - super( - renderer, - ` - uniform float uStrength; - uniform float uSize; - vec4 apply(vec4 color, vec2 uv) { - vec2 vig = uv * (1.0 - uv); - float v = clamp(pow(vig.x * vig.y * uSize, uStrength), 0.0, 1.0); - return vec4(color.rgb * v, color.a); - } - `, - ); + super(renderer, { glsl: fragment, wgsl: wgslFragment }); this.strength = options.strength ?? 0.15; this.size = options.size ?? 25.0; diff --git a/packages/melonjs/src/video/webgl/effects/wave.js b/packages/melonjs/src/video/effects/wave.js similarity index 75% rename from packages/melonjs/src/video/webgl/effects/wave.js rename to packages/melonjs/src/video/effects/wave.js index f8eb133c74..f6459e30fb 100644 --- a/packages/melonjs/src/video/webgl/effects/wave.js +++ b/packages/melonjs/src/video/effects/wave.js @@ -1,4 +1,22 @@ -import ShaderEffect from "../shadereffect.js"; +import ShaderEffect from "./shadereffect.js"; + +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct WaveUniforms { + uAmplitude : f32, + uFrequency : f32, + uSpeed : f32, + uTime : f32, +}; +@group(3) @binding(0) var fx : WaveUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let wave = sin(uv.y * fx.uFrequency + fx.uTime * fx.uSpeed) * fx.uAmplitude; + let distorted = vec2f(uv.x + wave, uv.y); + return textureSample(uTexture, uSampler, distorted) * vColor; +} +`; /** * A shader effect that applies a sine wave distortion to the sprite. @@ -21,9 +39,8 @@ export default class WaveEffect extends ShaderEffect { * @param {number} [options.speed=2.0] - wave animation speed */ constructor(renderer, options = {}) { - super( - renderer, - ` + super(renderer, { + glsl: ` uniform float uAmplitude; uniform float uFrequency; uniform float uSpeed; @@ -34,7 +51,8 @@ export default class WaveEffect extends ShaderEffect { return texture2D(uSampler, distorted) * vColor; } `, - ); + wgsl: wgslFragment, + }); this.setUniform("uAmplitude", options.amplitude ?? 0.01); this.setUniform("uFrequency", options.frequency ?? 10.0); diff --git a/packages/melonjs/src/video/effects/wgsl/layout.js b/packages/melonjs/src/video/effects/wgsl/layout.js new file mode 100644 index 0000000000..31b455171e --- /dev/null +++ b/packages/melonjs/src/video/effects/wgsl/layout.js @@ -0,0 +1,86 @@ +/** + * WGSL uniform-address-space layout calculator. + * + * WebGPU has no uniform reflection: when `setUniform("uStrength", v)` must + * land at the right byte offset of an effect's uniform buffer, the offsets + * have to be computed CPU-side from the struct the body declares. These are + * the WGSL *uniform* address-space rules (WGSL spec §memory-layouts): every + * member is aligned to its type's alignment, vec3 aligns like vec4, arrays + * require a 16-byte-multiple element stride, and the struct itself rounds + * up to 16 bytes. + * + * Kept deliberately to the types an effect uniform block meaningfully uses; + * an unsupported type is a parse failure upstream (effect disables), never + * a silently-wrong offset. + * @ignore + */ + +/** + * `{align, size}` per supported member type, keyed by the canonical + * spelling. Aliases (`vec2` form) are normalized before lookup. + * @ignore + */ +const TYPE_LAYOUT = { + f32: { align: 4, size: 4 }, + i32: { align: 4, size: 4 }, + u32: { align: 4, size: 4 }, + vec2f: { align: 8, size: 8 }, + // vec3 aligns to 16 with 12 bytes of data — the classic layout trap + vec3f: { align: 16, size: 12 }, + vec4f: { align: 16, size: 16 }, + // matCxR: C columns of vecR, column stride = roundUp(16, sizeof(vecR)) + mat3x3f: { align: 16, size: 48 }, + mat4x4f: { align: 16, size: 64 }, +}; + +const ARRAY_TYPE = /^array<\s*vec4f\s*,\s*(\d+)\s*>$/; + +/** + * normalize the `vecN` / `matCxR` spellings onto the short forms + * used as TYPE_LAYOUT keys + * @param {string} type - a WGSL type token + * @returns {string} the canonical spelling + * @ignore + */ +export function normalizeWGSLType(type) { + return type + .replace(/\s+/g, "") + .replace(/^vec([234])$/, "vec$1f") + .replace(/^mat([34])x([34])$/, "mat$1x$2f"); +} + +/** + * round `value` up to the next multiple of `alignment` (a power of two) + * @ignore + */ +function roundUp(alignment, value) { + return (value + alignment - 1) & ~(alignment - 1); +} + +/** + * Compute the uniform-buffer layout for a parsed struct member list. + * @param {Array<{name: string, type: string}>} members - struct members in declaration order + * @returns {{map: Map, size: number}|null} + * the name → placement map and the total (16-byte-rounded) struct size, + * or `null` when a member type is unsupported + * @ignore + */ +export function computeUniformLayout(members) { + const map = new Map(); + let cursor = 0; + for (const member of members) { + const type = normalizeWGSLType(member.type); + let layout = TYPE_LAYOUT[type]; + if (typeof layout === "undefined") { + const array = ARRAY_TYPE.exec(type); + if (array === null) { + return null; + } + layout = { align: 16, size: 16 * parseInt(array[1], 10) }; + } + const offset = roundUp(layout.align, cursor); + map.set(member.name, { offset, size: layout.size, type }); + cursor = offset + layout.size; + } + return { map, size: roundUp(16, cursor) }; +} diff --git a/packages/melonjs/src/video/effects/wgsl/parse.js b/packages/melonjs/src/video/effects/wgsl/parse.js new file mode 100644 index 0000000000..cb0e8bcf24 --- /dev/null +++ b/packages/melonjs/src/video/effects/wgsl/parse.js @@ -0,0 +1,285 @@ +import { computeUniformLayout } from "./layout.js"; + +/** + * Declaration-only parser for a WGSL ShaderEffect body. + * + * The body is real WGSL, compiled verbatim inside the engine scaffold — + * nothing is rewritten. This parser only reads declarations to learn what + * the engine must provide around it: + * + * - `fn apply(color : vec4f, uv : vec2f) -> vec4f` — required entry point + * - at most one `@group(3) @binding(0) var : ;` + * whose struct members become the `setUniform` names (offsets computed + * by the layout calculator — WebGPU has no reflection) + * - extra `setTexture` samplers as texture/sampler PAIRS at explicit + * consecutive bindings: `@group(3) @binding(k) var t : texture_2d;` + * immediately followed by `@binding(k+1) var s : sampler;` (k ≥ 1) + * - builtin identifiers activated on reference: `screen_uv`, `noise_uv`, + * `screen_texture` (+ `screen_sampler` / `screen_sampler_repeat`) + * + * Every failure mode returns `{ok: false, error}` — the effect then warns + * and disables (the generalized missing-language behavior), it never + * throws and never guesses at offsets. + * @ignore + */ + +const APPLY_FN = /\bfn\s+apply\s*\(/; +const UNIFORM_DECL = + /@group\(\s*3\s*\)\s*@binding\(\s*0\s*\)\s*var\s*<\s*uniform\s*>\s*([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)\s*;/; +const RESOURCE_DECL = + /@group\(\s*3\s*\)\s*@binding\(\s*(\d+)\s*\)\s*var\s+([A-Za-z_]\w*)\s*:\s*(texture_2d|sampler)\s*;/g; +const GROUP3_ANY = /@group\(\s*3\s*\)\s*@binding\(\s*(\d+)\s*\)/g; + +/** + * strip `//` line and `/* *\/` block comments so declarations inside + * comments are never honored (scratch copy — the original compiles). + * A plain single-pass scanner rather than a regex: comment-matching + * patterns are where polynomial backtracking hides (ReDoS), and a + * character scan is provably linear. + * @ignore + */ +function stripComments(source) { + const parts = []; + const length = source.length; + let start = 0; + let i = 0; + while (i < length) { + if (source[i] === "/" && source[i + 1] === "/") { + parts.push(source.slice(start, i), " "); + i += 2; + while (i < length && source[i] !== "\n") { + i++; + } + start = i; + } else if (source[i] === "/" && source[i + 1] === "*") { + parts.push(source.slice(start, i), " "); + i += 2; + // WGSL block comments NEST — track depth + let depth = 1; + while (i < length && depth > 0) { + if (source[i] === "/" && source[i + 1] === "*") { + depth++; + i += 2; + } else if (source[i] === "*" && source[i + 1] === "/") { + depth--; + i += 2; + } else { + i++; + } + } + start = i; + } else { + i++; + } + } + parts.push(source.slice(start)); + return parts.join(""); +} + +/** + * split a struct body on top-level commas only — `array` + * carries a comma inside its angle brackets and must stay one member + * @param {string} body - the text between the struct's braces + * @returns {string[]} one entry per member declaration + * @ignore + */ +function splitMembers(body) { + const members = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + const character = body[i]; + if (character === "<") { + depth++; + } else if (character === ">") { + depth--; + } else if (character === "," && depth === 0) { + members.push(body.slice(start, i)); + start = i + 1; + } + } + members.push(body.slice(start)); + return members; +} + +/** + * whether the body declares the identifier itself (a `var`/`let`/`const` + * or struct member of that name) — the builtin then stays user-managed, + * same contract as the GLSL side + * @ignore + */ +function hasOwnDeclaration(source, name) { + return new RegExp( + `(?:\\bvar\\s*(?:<[^>]*>)?\\s+|\\blet\\s+|\\bconst\\s+)${name}\\s*[:=]`, + ).test(source); +} + +/** + * Parse a WGSL effect body's declarations. + * @param {string} body - the user WGSL body + * @returns {object} `{ok: true, layout, structSize, uniformVar, textures, builtins, maxUserBinding}` + * or `{ok: false, error}` + * @ignore + */ +export function parseWGSLBody(body) { + const source = stripComments(body); + + if (!APPLY_FN.test(source)) { + return { + ok: false, + error: + "a WGSL effect body must define `fn apply(color : vec4f, uv : vec2f) -> vec4f`", + }; + } + + // ---- the (optional) uniform struct at @group(3) @binding(0) ---------- + let layout = new Map(); + let structSize = 0; + let uniformVar = null; + const uniform = UNIFORM_DECL.exec(source); + if (uniform !== null) { + const structName = uniform[2]; + const struct = new RegExp(`struct\\s+${structName}\\s*\\{([^}]*)\\}`).exec( + source, + ); + if (struct === null) { + return { + ok: false, + error: `uniform struct \`${structName}\` is not declared in the body`, + }; + } + const members = []; + for (const entry of splitMembers(struct[1])) { + const trimmed = entry.trim(); + if (trimmed === "") { + continue; + } + const member = /^([A-Za-z_]\w*)\s*:\s*([A-Za-z0-9_<>,\s]+)$/.exec( + trimmed, + ); + if (member === null) { + return { + ok: false, + error: `unsupported struct member declaration \`${trimmed}\``, + }; + } + members.push({ name: member[1], type: member[2].trim() }); + } + const computed = computeUniformLayout(members); + if (computed === null) { + return { + ok: false, + error: `unsupported uniform member type in struct \`${structName}\` (supported: f32, i32, u32, vec2f, vec3f, vec4f, mat3x3f, mat4x4f, array)`, + }; + } + layout = computed.map; + structSize = computed.size; + uniformVar = uniform[1]; + } + + // ---- extra texture/sampler pairs at explicit group-3 bindings --------- + const resources = []; + RESOURCE_DECL.lastIndex = 0; + for (const match of source.matchAll(RESOURCE_DECL)) { + resources.push({ + binding: parseInt(match[1], 10), + name: match[2], + kind: match[3] === "sampler" ? "sampler" : "texture", + }); + } + resources.sort((a, b) => { + return a.binding - b.binding; + }); + const textures = []; + const seen = new Set(); + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + if (resource.binding === 0 || seen.has(resource.binding)) { + return { + ok: false, + error: `invalid or duplicate @group(3) @binding(${resource.binding})`, + }; + } + seen.add(resource.binding); + if (resource.kind === "texture") { + const sampler = resources[i + 1]; + if ( + typeof sampler === "undefined" || + sampler.kind !== "sampler" || + sampler.binding !== resource.binding + 1 + ) { + return { + ok: false, + error: `texture \`${resource.name}\` needs its sampler at @binding(${resource.binding + 1})`, + }; + } + seen.add(sampler.binding); + textures.push({ + name: resource.name, + binding: resource.binding, + samplerName: sampler.name, + samplerBinding: sampler.binding, + }); + i++; + } else { + return { + ok: false, + error: `sampler \`${resource.name}\` at @binding(${resource.binding}) has no texture at @binding(${resource.binding - 1})`, + }; + } + } + + // any OTHER group-3 binding the regexes didn't classify is a shape we + // can't build a bind group for + let maxUserBinding = 0; + GROUP3_ANY.lastIndex = 0; + for (const match of source.matchAll(GROUP3_ANY)) { + const binding = parseInt(match[1], 10); + if (binding !== 0 && !seen.has(binding)) { + return { + ok: false, + error: `unsupported declaration at @group(3) @binding(${binding}) (only one uniform struct at binding 0 and texture/sampler pairs are supported)`, + }; + } + maxUserBinding = Math.max(maxUserBinding, binding); + } + + // ---- builtins, activated on reference ------------------------------- + const screenTexture = + /\bscreen_texture\b/.test(source) && + !hasOwnDeclaration(source, "screen_texture"); + const builtins = { + screenTexture, + screenSamplerClamp: /\bscreen_sampler\b/.test(source), + screenSamplerRepeat: /\bscreen_sampler_repeat\b/.test(source), + screenUV: + (screenTexture || /\bscreen_uv\b/.test(source)) && + !hasOwnDeclaration(source, "screen_uv"), + noiseUV: + /\bnoise_uv\b/.test(source) && !hasOwnDeclaration(source, "noise_uv"), + // the interpolated tint (the GLSL `vColor` varying) — bodies that + // re-sample the source texture reference it to re-apply the tint + vColor: /\bvColor\b/.test(source) && !hasOwnDeclaration(source, "vColor"), + }; + if ( + builtins.screenTexture && + !builtins.screenSamplerClamp && + !builtins.screenSamplerRepeat + ) { + return { + ok: false, + error: + "`screen_texture` is sampled through `screen_sampler` (clamp) or `screen_sampler_repeat` — reference one of them", + }; + } + + return { + ok: true, + layout, + structSize, + uniformVar, + textures, + builtins, + maxUserBinding, + }; +} diff --git a/packages/melonjs/src/video/effects/wgsl/scaffold.js b/packages/melonjs/src/video/effects/wgsl/scaffold.js new file mode 100644 index 0000000000..6adbbf2d52 --- /dev/null +++ b/packages/melonjs/src/video/effects/wgsl/scaffold.js @@ -0,0 +1,159 @@ +/** + * WGSL module assembly for a ShaderEffect body — the WGSL counterpart of + * `glsl_realization.js`'s source templating. The user body is embedded + * VERBATIM (WGSL module-scope declarations are order-independent); the + * scaffold contributes the vertex/fragment entry points over the frozen + * 28-byte quad layout, the frame-globals and material groups, and the + * builtin declarations the parser detected. + * + * Frozen conventions carried over from the core quad shader: + * - clip-z remap `(z + w) * 0.5` (GL-convention [-w, w] → WebGPU [0, w]) + * - packed-ARGB attribute read as (B,G,R,A) → `.bgr * .a` premultiply + * - `screen_uv` is y-down (`0.5 - ndc.y * 0.5`): WebGPU texture row 0 is + * the top, so the capture is sampled without any flip + * @ignore + */ + +/** + * builtin group-3 bindings are assigned ABOVE the highest user binding so + * they can never collide: ME uniform first, then the capture texture and + * its sampler variants + * @param {object} parsed - the parseWGSLBody result + * @returns {{me: number, screenTexture: number, screenSamplerClamp: number, screenSamplerRepeat: number}} + * assigned binding indices (-1 when the builtin is unused) + * @ignore + */ +export function assignBuiltinBindings(parsed) { + let next = + Math.max(parsed.maxUserBinding, parsed.structSize > 0 ? 0 : -1) + 1; + const bindings = { + me: -1, + screenTexture: -1, + screenSamplerClamp: -1, + screenSamplerRepeat: -1, + }; + if (parsed.builtins.noiseUV) { + bindings.me = next++; + } + if (parsed.builtins.screenTexture) { + bindings.screenTexture = next++; + if (parsed.builtins.screenSamplerClamp) { + bindings.screenSamplerClamp = next++; + } + if (parsed.builtins.screenSamplerRepeat) { + bindings.screenSamplerRepeat = next; + } + } + return bindings; +} + +/** + * Assemble the complete WGSL module for an effect body. + * @param {string} body - the user WGSL body (embedded verbatim) + * @param {object} parsed - the parseWGSLBody result + * @returns {{code: string, bindings: object}} the module text and the + * builtin binding assignment (consumed when building the bind group) + * @ignore + */ +export function buildWGSLModule(body, parsed) { + const builtins = parsed.builtins; + const bindings = assignBuiltinBindings(parsed); + + const scaffold = [ + "// ---- melonJS effect scaffold ----", + "struct FrameUniforms {", + "\tprojection : mat4x4,", + "\tlineWidth : f32,", + "};", + "@group(0) @binding(0) var uFrame : FrameUniforms;", + "@group(1) @binding(0) var uTexture : texture_2d;", + "@group(1) @binding(1) var uSampler : sampler;", + ]; + + if (builtins.screenUV) { + scaffold.push("var screen_uv : vec2f;"); + } + if (builtins.vColor) { + // the interpolated tint, exposed under its GLSL varying name so + // re-sampling bodies port literally + scaffold.push("var vColor : vec4f;"); + } + if (builtins.noiseUV) { + scaffold.push( + "var noise_uv : vec2f;", + "struct MEBuiltins {", + "\tsize_obj : vec2f,", + "\tsize_img : vec2f,", + "\toffset : vec2f,", + "};", + `@group(3) @binding(${bindings.me}) var ME : MEBuiltins;`, + ); + } + if (builtins.screenTexture) { + scaffold.push( + `@group(3) @binding(${bindings.screenTexture}) var screen_texture : texture_2d;`, + ); + if (builtins.screenSamplerClamp) { + scaffold.push( + `@group(3) @binding(${bindings.screenSamplerClamp}) var screen_sampler : sampler;`, + ); + } + if (builtins.screenSamplerRepeat) { + scaffold.push( + `@group(3) @binding(${bindings.screenSamplerRepeat}) var screen_sampler_repeat : sampler;`, + ); + } + } + + scaffold.push( + "struct VSOut {", + "\t@builtin(position) position : vec4f,", + "\t@location(0) vRegion : vec2f,", + "\t@location(1) vColor : vec4f,", + ...(builtins.screenUV ? ["\t@location(2) vScreenUV : vec2f,"] : []), + ...(builtins.noiseUV ? ["\t@location(3) vNoiseUV : vec2f,"] : []), + "};", + "", + "@vertex", + "fn vertex_main(", + "\t@location(0) aVertex : vec3f,", + "\t@location(1) aRegion : vec2f,", + "\t@location(2) aColor : vec4f,", + "\t@location(3) aTextureId : f32,", + ") -> VSOut {", + "\tvar out : VSOut;", + "\tlet clip = uFrame.projection * vec4f(aVertex, 1.0);", + "\t// GL-convention clip z in [-w, w] -> WebGPU [0, w]", + "\tout.position = vec4f(clip.xy, (clip.z + clip.w) * 0.5, clip.w);", + "\tout.vColor = vec4f(aColor.bgr * aColor.a, aColor.a);", + "\tout.vRegion = aRegion;", + ...(builtins.screenUV + ? [ + "\tlet ndc = clip.xy / clip.w;", + "\t// y-down: capture row 0 = screen top under WebGPU", + "\tout.vScreenUV = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);", + ] + : []), + ...(builtins.noiseUV + ? [ + "\tout.vNoiseUV = aRegion * (ME.size_img / ME.size_obj) - ME.offset / ME.size_obj;", + ] + : []), + "\treturn out;", + "}", + "", + "// ---- user body (verbatim) ----", + body, + "", + "@fragment", + "fn fragment_main(in : VSOut) -> @location(0) vec4f {", + ...(builtins.screenUV ? ["\tscreen_uv = in.vScreenUV;"] : []), + ...(builtins.noiseUV ? ["\tnoise_uv = in.vNoiseUV;"] : []), + ...(builtins.vColor ? ["\tvColor = in.vColor;"] : []), + "\tlet texColor = textureSample(uTexture, uSampler, in.vRegion) * in.vColor;", + "\treturn apply(texColor, in.vRegion);", + "}", + ); + + return { code: scaffold.join("\n"), bindings }; +} diff --git a/packages/melonjs/src/video/effects/wgsl_realization.js b/packages/melonjs/src/video/effects/wgsl_realization.js new file mode 100644 index 0000000000..2ec3a6026e --- /dev/null +++ b/packages/melonjs/src/video/effects/wgsl_realization.js @@ -0,0 +1,164 @@ +import { parseWGSLBody } from "./wgsl/parse.js"; +import { buildWGSLModule } from "./wgsl/scaffold.js"; + +/** + * The WGSL realization of a ShaderEffect — the WebGPU counterpart of the + * GLShader the GLSL branch compiles. + * + * WebGPU has no uniform reflection, so this object carries everything the + * backend needs to drive the effect: the parsed uniform layout (name → + * byte placement), a CPU-side mirror of the uniform block that + * `setUniform` writes into, the texture binding table, and the assembled + * module text. GPU objects (shader module, bind group layout, bind group) + * are built lazily by the renderer's effect path — uniform VALUES are + * snapshotted from the mirror into a per-frame arena at every bind, so a + * shared effect bound twice in a frame with different values stays + * correct under WebGPU's queue-write-before-draws ordering. + * @ignore + */ +export default class WGSLEffectRealization { + /** + * Parse + assemble; on any parse failure `valid` is false and `error` + * carries the reason (the effect then warns and stays disabled). + * @param {string} body - the user WGSL body + */ + constructor(body) { + const parsed = parseWGSLBody(body); + + /** + * whether the body parsed into a realizable shape + * @type {boolean} + */ + this.valid = parsed.ok === true; + /** @type {string|null} */ + this.error = parsed.ok === true ? null : parsed.error; + + if (!this.valid) { + return; + } + + /** name → {offset, size, type} placement in the uniform block */ + this.layout = parsed.layout; + /** total uniform block byte size (0 = no uniform struct) */ + this.structSize = parsed.structSize; + /** user texture/sampler pairs (binding table) */ + this.textures = parsed.textures; + /** builtin usage flags */ + this.builtins = parsed.builtins; + + const module = buildWGSLModule(body, parsed); + /** the complete assembled WGSL module text */ + this.code = module.code; + /** builtin binding assignment (ME / capture texture / samplers) */ + this.builtinBindings = module.bindings; + + // CPU mirror of the uniform block; snapshot-uploaded per bind + this.cpu = new ArrayBuffer(Math.max(this.structSize, 0)); + this.f32 = new Float32Array(this.cpu); + this.i32 = new Int32Array(this.cpu); + this.u32 = new Uint32Array(this.cpu); + + // CPU mirror of the engine `MEBuiltins` struct (noise_uv): size_obj + // at float 0, size_img at 2, offset at 4; 32 bytes with tail padding + this.meMirror = new Float32Array(8); + + /** + * last user-set value per uniform name — the clone-replay source + * (exact values, not mirror read-back) + * @type {Map} + */ + this.values = new Map(); + + // names already warned about, so a per-frame setUniform on an + // unknown name doesn't flood the console + this.warned = new Set(); + + /** + * device-scoped GPU state, built lazily by the renderer's effect + * path and invalidated via the pipeline-cache epoch (device loss) + * @type {object|null} + */ + this.gpu = null; + } + + /** + * Write a uniform value into the CPU mirror. + * @param {string} name - a struct member name of the body's uniform block + * @param {number|number[]|Float32Array|{val: Float32Array}} value - the value + * (objects exposing `.val` — Matrix2d/3d, ColorMatrix — are unwrapped) + * @returns {boolean} true when the name exists and the value was written + */ + setUniform(name, value) { + const placement = this.layout.get(name); + if (typeof placement === "undefined") { + if (!this.warned.has(name)) { + this.warned.add(name); + console.warn( + `ShaderEffect: unknown WGSL uniform "${name}" (not a member of the effect's uniform struct)`, + ); + } + return false; + } + // unwrap matrix-like objects the GLSL path also accepts + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + ArrayBuffer.isView(value) === false && + typeof value.val !== "undefined" + ) { + value = value.val; + } + // GLSL accepts booleans for bool/int uniforms — normalize + if (typeof value === "boolean") { + value = value ? 1 : 0; + } + const index = placement.offset >> 2; + if (typeof value === "number") { + if (placement.type === "i32") { + this.i32[index] = value | 0; + } else if (placement.type === "u32") { + this.u32[index] = value >>> 0; + } else { + this.f32[index] = value; + } + this.values.set(name, value); + } else if (placement.type === "mat3x3f" && value.length === 9) { + // a 9-float column-major matrix (the uniformMatrix3fv shape): + // WGSL mat3x3f columns are vec4-strided — place each column at + // float offset c*4, or columns 2-3 read scrambled + for (let column = 0; column < 3; column++) { + for (let row = 0; row < 3; row++) { + this.f32[index + column * 4 + row] = value[column * 3 + row]; + } + } + this.values.set(name, Array.from(value)); + } else { + const array = value; + const count = Math.min(array.length, placement.size >> 2); + for (let i = 0; i < count; i++) { + this.f32[index + i] = array[i]; + } + this.values.set(name, Array.from(array)); + } + return true; + } + + /** + * whether the uniform block declares the given member (the WGSL + * counterpart of the GLSL active-uniform check `setTime` relies on) + * @param {string} name - member name + * @returns {boolean} true when present + */ + hasUniform(name) { + return this.layout.has(name); + } + + /** + * drop device-scoped state (device loss / destroy) — the CPU mirror + * and values survive, so nothing needs replaying on restore + */ + releaseGPU() { + this.gpu = null; + } +} diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 94e210f70f..750f074828 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -110,6 +110,14 @@ export default class Renderer { */ this.type = "Generic"; + /** + * The {@link Application} this renderer belongs to, set by + * `Application.init()` — engine code holding a renderer reference + * must use this rather than the global game instance. + * @type {Application|undefined} + */ + this.parentApplication = undefined; + /** * Whether this renderer backend can draw TMX tile layers through a * GPU shader path (see the `gpuTilemap` application setting). diff --git a/packages/melonjs/src/video/rendertarget/webgpurendertarget.js b/packages/melonjs/src/video/rendertarget/webgpurendertarget.js new file mode 100644 index 0000000000..e6c705e1b2 --- /dev/null +++ b/packages/melonjs/src/video/rendertarget/webgpurendertarget.js @@ -0,0 +1,233 @@ +import RenderTarget from "./rendertarget.ts"; + +/** + * WebGPU offscreen render target — the WebGPU counterpart of the WebGL + * FBO-backed {@link WebGLRenderTarget}, used by the post-effect chain + * through the shared {@link RenderTargetPool}. + * + * Owns one color `GPUTexture` (renderable AND sampleable — the whole point + * of a post-effect target) in the renderer's preferred canvas format, so + * pipelines keyed on that format serve canvas and offscreen passes alike. + * The depth-stencil attachment is NOT per-target: every pass shares the + * renderer's canvas-sized depth-stencil texture (targets in this flow are + * canvas-sized, only one pass is open at a time — and sharing the stencil + * means a mask active around a post-effect renderable keeps clipping its + * offscreen content, which is what masks promise). + * + * Under the recording model "binding" a target is a pass break: draws + * recorded after {@link WebGPURenderer#setRenderTarget} land in a new pass + * whose color attachment is this texture. Mid-frame resize/destroy retire + * the texture (a texture referenced by recorded draws must not be + * destroyed before submit). + * @augments RenderTarget + * @category Rendering + */ +export default class WebGPURenderTarget extends RenderTarget { + /** + * @param {import("../webgpu/webgpu_renderer.js").default} renderer - the owning renderer + * @param {number} width - width in pixels + * @param {number} height - height in pixels + */ + constructor(renderer, width, height) { + super(); + this.renderer = renderer; + this.width = 0; + this.height = 0; + /** @type {GPUTexture|null} */ + this.texture = null; + /** @type {GPUTextureView|null} */ + this.colorView = null; + /** + * bumped whenever the backing texture is reallocated — cached bind + * groups referencing the old view key on this + * @type {number} + */ + this.generation = 0; + // lazy (view + linear clamp sampler) pairing against the material + // layout — what a blit binds at group 1 to sample this target + this.materialBindGroup = null; + this.materialBindGroupGeneration = -1; + // consumed by the renderer as a colorLoadOp "clear" on next retarget + this.pendingClear = false; + + this.resize(width, height); + } + + /** + * (Re)create the backing texture at the given size. No-op when the size + * is unchanged; otherwise the old texture retires (mid-frame safe) and + * `generation` advances so stale bind groups rebuild. + * @param {number} width - new width in pixels + * @param {number} height - new height in pixels + */ + resize(width, height) { + width = Math.max(1, width | 0); + height = Math.max(1, height | 0); + if (this.width === width && this.height === height) { + return; + } + if (this.texture !== null) { + this.renderer.retireTexture(this.texture); + } + this.width = width; + this.height = height; + this.texture = this.renderer.device.createTexture({ + label: "melonJS render target", + size: [width, height], + format: this.renderer.preferredFormat, + usage: + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_SRC, + }); + this.colorView = this.texture.createView(); + this.generation++; + } + + /** + * Request a clear on next use: consumed as the pass's `colorLoadOp: + * "clear"` when the renderer next targets this — cheaper than a clearing + * draw, and the WebGPU analogue of `clearRenderTarget`. + */ + clear() { + this.pendingClear = true; + } + + /** + * make this target the active draw destination (a pass break under the + * recording model) + * @override + */ + bind() { + this.renderer.setRenderTarget(this); + } + + /** + * restore the canvas as the draw destination + * @override + */ + unbind() { + this.renderer.setRenderTarget(null); + } + + /** + * The group-1 (material) bind group sampling this target with a linear + * clamp sampler — what the effect blit binds as its source. Rebuilt + * lazily when the backing texture was reallocated. + * @returns {GPUBindGroup} the bind group + * @ignore + */ + getMaterialBindGroup() { + if ( + this.materialBindGroup === null || + this.materialBindGroupGeneration !== this.generation + ) { + const renderer = this.renderer; + this.materialBindGroup = renderer.device.createBindGroup({ + label: "melonJS render-target material", + layout: renderer.pipelineCache.materialLayout, + entries: [ + { binding: 0, resource: this.colorView }, + { + binding: 1, + resource: renderer.textureStore.getSampler("linear", "no-repeat"), + }, + ], + }); + this.materialBindGroupGeneration = this.generation; + } + return this.materialBindGroup; + } + + /** + * Synchronous readback is impossible under WebGPU — use + * {@link WebGPURenderTarget#readPixels}. + * @throws {Error} always + * @override + */ + getImageData() { + throw new Error( + "WebGPURenderTarget.getImageData: WebGPU readback is asynchronous — use `await target.readPixels()` instead", + ); + } + + /** + * Asynchronously read back this target's pixels. + * @param {number} [x=0] - x of the top-left corner + * @param {number} [y=0] - y of the top-left corner + * @param {number} [width=this.width] - width of the area to read + * @param {number} [height=this.height] - height of the area to read + * @returns {Promise} the pixel data (RGBA order) + */ + async readPixels(x = 0, y = 0, width = this.width, height = this.height) { + const renderer = this.renderer; + const device = renderer.device; + // clamp the read window to the target (an out-of-bounds copy is a + // validation error, where a caller passing only x/y expects a crop) + x = Math.max(0, x | 0); + y = Math.max(0, y | 0); + width = Math.max(1, Math.min(width | 0, this.width - x)); + height = Math.max(1, Math.min(height | 0, this.height - y)); + // bytesPerRow must be a multiple of 256 + const bytesPerRow = (width * 4 + 255) & ~255; + const buffer = device.createBuffer({ + label: "melonJS readback", + size: bytesPerRow * height, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + try { + // pending draws into this target must land before the copy + renderer.flush(); + const encoder = device.createCommandEncoder({ + label: "melonJS readback", + }); + encoder.copyTextureToBuffer( + { texture: this.texture, origin: { x, y } }, + { buffer, bytesPerRow }, + [width, height], + ); + device.queue.submit([encoder.finish()]); + await buffer.mapAsync(GPUMapMode.READ); + const mapped = new Uint8Array(buffer.getMappedRange()); + const out = new Uint8ClampedArray(width * height * 4); + const bgra = renderer.preferredFormat.startsWith("bgra"); + for (let row = 0; row < height; row++) { + const src = row * bytesPerRow; + const dst = row * width * 4; + for (let px = 0; px < width; px++) { + const s = src + px * 4; + const d = dst + px * 4; + if (bgra) { + out[d] = mapped[s + 2]; + out[d + 1] = mapped[s + 1]; + out[d + 2] = mapped[s]; + } else { + out[d] = mapped[s]; + out[d + 1] = mapped[s + 1]; + out[d + 2] = mapped[s + 2]; + } + out[d + 3] = mapped[s + 3]; + } + } + buffer.unmap(); + return new ImageData(out, width, height); + } finally { + buffer.destroy(); + } + } + + /** + * Release the backing texture (retired when a frame is recording). + * @override + */ + destroy() { + if (this.texture !== null) { + this.renderer.retireTexture(this.texture); + this.texture = null; + this.colorView = null; + } + this.materialBindGroup = null; + this.width = 0; + this.height = 0; + } +} diff --git a/packages/melonjs/src/video/texture/atlas.js b/packages/melonjs/src/video/texture/atlas.js index 4037b712cf..0ab55b034f 100644 --- a/packages/melonjs/src/video/texture/atlas.js +++ b/packages/melonjs/src/video/texture/atlas.js @@ -1,13 +1,22 @@ -import { game } from "../../application/application.ts"; import { getImage } from "./../../loader/loader.js"; import { Vector2d } from "../../math/vector2d.ts"; import Sprite from "./../../renderable/sprite.js"; +import { on, VIDEO_INIT } from "../../system/event.ts"; import pool from "../../system/legacy_pool.js"; import { parseAseprite } from "./parser/aseprite.js"; import { parseSpriteSheet } from "./parser/spritesheet.js"; import { parseTexturePacker } from "./parser/texturepacker.js"; import Texture2d from "./texture2d.ts"; +// The active renderer, captured at video init — atlas registration goes +// through the renderer's own cache rather than the global game instance +// (`on`, not `once`: a re-init — new Application after destroy — must +// re-capture the new renderer). +let _renderer; +on(VIDEO_INIT, (renderer) => { + _renderer = renderer; +}); + /** * additional import for TypeScript * @import NineSliceSprite from "./../../renderable/nineslicesprite.js"; @@ -258,7 +267,7 @@ export class TextureAtlas extends Texture2d { // Add self to TextureCache if cache !== false if (cache !== false) { this.sources.forEach((source) => { - game.renderer.cache.set(source, this); + _renderer.cache.set(source, this); }); } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 08b08f15af..47e5a29d35 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -10,6 +10,7 @@ import { on, RENDER_TARGET_CHANGED, } from "../../system/event.ts"; +import RadialGradientEffect from "../effects/radialGradient.js"; import { Gradient } from "../gradient.js"; import Renderer from "./../renderer.js"; import RenderTargetPool from "../rendertarget/render_target_pool.js"; @@ -28,7 +29,6 @@ import LitQuadBatcher from "./batchers/lit_quad_batcher"; import MeshBatcher from "./batchers/mesh_batcher"; import PrimitiveBatcher from "./batchers/primitive_batcher"; import QuadBatcher from "./batchers/quad_batcher"; -import RadialGradientEffect from "./effects/radialGradient.js"; import { createLightUniformScratch, packLights } from "./lighting/pack.ts"; import OrthogonalTMXLayerGPURenderer from "./renderers/tmxlayer/orthogonal.js"; import { getMaxShaderPrecision } from "./utils/precision.js"; diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js new file mode 100644 index 0000000000..5572834d4d --- /dev/null +++ b/packages/melonjs/src/video/webgpu/batchers/lit_quad_batcher.js @@ -0,0 +1,320 @@ +// the lighting math modules are pure CPU code shared with the GL backend +// (published std140 layout, no GL calls) +import { + BLOCK_BYTES, + BLOCK_FLOATS, + writeLight2dBlock, +} from "../../webgl/lighting/std140.ts"; +import litQuadWGSL from "../shaders/quad-lit.wgsl"; +import WebGPUQuadBatcher from "./quad_batcher.js"; + +/** + * The WebGPU lit quad batcher — normal-mapped sprites shaded by the + * Light2d std140 block, the port of the GL `LitQuadBatcher` under this + * backend's single-texture-per-draw-segment model: + * + * - group 1 is a combined material (color texture + normal map, four + * entries); a change of either flushes the segment, so there is no + * per-vertex normal-texture id and no sampler ladder + * - group 2 binds the std140 `Light2dBlock` with a dynamic offset — each + * `setLightUniforms` call snapshots the packed block into the effect + * uniform arena (queue writes execute before every recorded draw, so a + * shared buffer region per camera would be retroactively clobbered) + * - normal maps live outside the TextureCache (same as GL): resident + * GPUTextures keyed by source object, re-uploaded when the source's + * duck-typed `version` stamp advances (NoiseTexture2d and friends) + * @augments WebGPUQuadBatcher + * @category Rendering + */ +export default class WebGPULitQuadBatcher extends WebGPUQuadBatcher { + /** + * @override + */ + init(renderer, settings) { + super.init(renderer, settings); + const cache = renderer.pipelineCache; + const device = renderer.device; + + this.litMaterialLayout = device.createBindGroupLayout({ + label: "melonJS lit material", + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + ], + }); + this.lightsLayout = device.createBindGroupLayout({ + label: "melonJS light block", + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { + type: "uniform", + hasDynamicOffset: true, + minBindingSize: BLOCK_BYTES, + }, + }, + ], + }); + // the lit family rides the frozen quad vertex layout + this.shaderKey = cache.registerShader(litQuadWGSL, { + bindGroupLayouts: [ + cache.frameLayout, + this.litMaterialLayout, + this.lightsLayout, + ], + vertexLayoutKey: "quad", + label: "melonJS lit quad shader", + }); + + // CPU staging for the std140 block (zero-filled tail keeps the full + // BLOCK_BYTES write valid for minBindingSize) + this.blockData = new Float32Array(BLOCK_FLOATS); + // light-block bind group per arena page (pages persist across frames) + this.lightBindGroups = new Map(); + // the current frame's snapshot binding: {bindGroup, dynamicOffset} + this.lightBinding = null; + + // combined color+normal bind groups: colorView → (normalView → group) + this.litMaterials = new Map(); + // resident normal-map textures: source → {texture, view, version, width, height} + this.normalTextures = new Map(); + } + + /** + * Snapshot the packed light uniforms for the draws that follow — called + * by {@link WebGPURenderer#setLightUniforms} once per camera per frame. + * @param {object} packed - what packLights produced + */ + setLightUniforms(packed) { + const renderer = this.renderer; + const device = this.device; + writeLight2dBlock(this.blockData, packed); + const region = renderer.effectUniformArena.alloc( + BLOCK_BYTES, + device.limits.minUniformBufferOffsetAlignment, + ); + device.queue.writeBuffer( + region.buffer, + region.offset, + this.blockData.buffer, + 0, + BLOCK_BYTES, + ); + let bindGroup = this.lightBindGroups.get(region.buffer); + if (typeof bindGroup === "undefined") { + bindGroup = device.createBindGroup({ + label: "melonJS light block", + layout: this.lightsLayout, + entries: [ + { + binding: 0, + resource: { buffer: region.buffer, size: BLOCK_BYTES }, + }, + ], + }); + this.lightBindGroups.set(region.buffer, bindGroup); + } + this.lightBinding = { + bindGroup, + dynamicOffset: region.offset, + // the arena region only holds these bytes until the next frame's + // reset — the binding must never outlive the frame it was + // snapshotted in + frameId: renderer.frameId, + }; + } + + /** + * Whether a light block was snapshotted during the given frame — the + * renderer's lit gate requires this: a previous-frame binding points + * into a reset arena region, so it would sample reused bytes. + * @param {number} frameId - the renderer's current frame stamp + * @returns {boolean} true when lit draws can bind a valid light block + */ + hasCurrentLightBinding(frameId) { + return this.lightBinding !== null && this.lightBinding.frameId === frameId; + } + + /** + * Resolve (and lazily upload) the resident texture for a normal-map + * source, re-uploading when its duck-typed `version` stamp advances. + * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} source - the normal-map image + * @returns {object} {texture, view, version, width, height} + * @ignore + */ + residentNormalMap(source) { + const version = source.version ?? 0; + const frameId = this.renderer.frameId; + let entry = this.normalTextures.get(source); + const width = source.width || source.videoWidth || 1; + const height = source.height || source.videoHeight || 1; + if ( + typeof entry === "undefined" || + entry.width !== width || + entry.height !== height || + // An in-place re-upload is retroactive: queue writes execute + // before EVERY draw recorded this frame, so a version bump on a + // map already sampled this frame must land in a fresh texture or + // earlier draws would show the new pixels (same rule as the + // texture store's color records) + (entry.version !== version && entry.frameId === frameId) + ) { + if (typeof entry !== "undefined") { + this.renderer.retireTexture(entry.texture); + } + const texture = this.device.createTexture({ + label: "melonJS normal map", + size: [width, height], + format: "rgba8unorm", + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + entry = { + texture, + view: texture.createView(), + version: undefined, + width, + height, + frameId: -1, + }; + this.normalTextures.set(source, entry); + } + if (entry.version !== version) { + // normals are geometry, not color — never premultiply + this.device.queue.copyExternalImageToTexture( + { source }, + { texture: entry.texture, premultipliedAlpha: false }, + [width, height], + ); + entry.version = version; + } + // stamp: draws recorded this frame sample this texture + entry.frameId = frameId; + return entry; + } + + /** + * Add a lit textured quad — the base contract plus the normal map the + * segment shades with. + * @param {object} texture - the texture atlas to draw with + * @param {number} x - destination x + * @param {number} y - destination y + * @param {number} w - destination width + * @param {number} h - destination height + * @param {number} u0 - texture UV (u0) + * @param {number} v0 - texture UV (v0) + * @param {number} u1 - texture UV (u1) + * @param {number} v1 - texture UV (v1) + * @param {number} tint - tint color in UINT32 (argb) format + * @param {boolean} [reupload=false] - force the source pixels to re-upload + * @param {object} [normalMap] - the normal-map image paired with the texture + * @override + */ + addQuad( + texture, + x, + y, + w, + h, + u0, + v0, + u1, + v1, + tint, + reupload = false, + normalMap, + ) { + const renderer = this.renderer; + // resolve the combined color+normal material for this segment + const record = renderer.textureStore.getResidentRecord(texture, { + force: reupload, + }); + const normal = this.residentNormalMap(normalMap); + + let byNormal = this.litMaterials.get(record.view); + if (typeof byNormal === "undefined") { + byNormal = new Map(); + this.litMaterials.set(record.view, byNormal); + } + let combined = byNormal.get(normal.view); + if (typeof combined === "undefined") { + const filter = + typeof texture.filter === "string" + ? texture.filter + : renderer.getDefaultTextureFilter(); + const wrap = texture.repeat ?? "no-repeat"; + const store = renderer.textureStore; + combined = this.device.createBindGroup({ + label: "melonJS lit material", + layout: this.litMaterialLayout, + entries: [ + { binding: 0, resource: record.view }, + { binding: 1, resource: store.getSampler(filter, wrap) }, + { binding: 2, resource: normal.view }, + { binding: 3, resource: store.getSampler(filter, "no-repeat") }, + ], + }); + byNormal.set(normal.view, combined); + } + + if (this.vertexData.isFull(4) || combined !== this.currentMaterial) { + this.flush(); + this.currentMaterial = combined; + } + + // transform + push the four corners exactly like the base batcher + this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint); + } + + /** + * Drop every combined color+normal bind group — each pairing embeds a + * 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). + */ + clearMaterialCache() { + this.litMaterials.clear(); + } + + /** + * lit draws bind the combined material at 1 and the light block at 2 + * @override + */ + recordDraw(pass, vertexCount) { + pass.setBindGroup(1, this.currentMaterial); + if (this.lightBinding !== null) { + pass.setBindGroup(2, this.lightBinding.bindGroup, [ + this.lightBinding.dynamicOffset, + ]); + } + pass.setIndexBuffer(this.indexBuffer, "uint32"); + pass.drawIndexed((vertexCount / 4) * 6); + } + + /** + * @override + */ + reset() { + super.reset(); + for (const entry of this.normalTextures.values()) { + this.renderer.retireTexture(entry.texture); + } + this.normalTextures.clear(); + this.litMaterials.clear(); + this.lightBindGroups.clear(); + this.lightBinding = null; + } + + /** + * @override + */ + destroy() { + this.reset(); + super.destroy(); + } +} diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index ef496bf72f..d58ff8b17f 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -1,5 +1,6 @@ import { Vector3d } from "../../../math/vector3d.ts"; import IndexBuffer from "../../buffer/index.js"; +import { prepareEffectBinding } from "../effect_binding.js"; import WebGPUBatcher from "./webgpu_batcher.js"; // a pool of reusable vectors used by `addQuad` to transform the four quad @@ -71,6 +72,12 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // the material bind group the pending vertices were queued under this.currentMaterial = null; + // the ShaderEffect the pending vertices were queued under — the + // single-effect fast path (renderer.customShader): the sprite's own + // quad draws through the effect's pipeline, composited live against + // the backdrop (no offscreen target) + this.currentEffect = null; + // 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; @@ -105,14 +112,33 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { */ addQuad(texture, x, y, w, h, u0, v0, u1, v1, tint, reupload = false) { const vertexData = this.vertexData; + const renderer = this.renderer; if (vertexData.isFull(4)) { this.flush(); } + // single-effect fast path: adopt the active customShader, draining + // vertices queued under the previous state first (they must flush + // under THEIR pipeline, not the incoming one) + const effect = renderer.customShader ?? null; + if (effect !== this.currentEffect) { + this.flush(); + this.currentEffect = effect; + } + + if (effect !== null) { + // `screen_texture`: refresh the shared capture with everything + // drawn so far BEFORE this sprite's own quad, so the effect + // samples the scene behind it (a pass break + encoder copy) + 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 = this.renderer.textureStore.getBinding(texture, { + const bindGroup = renderer.textureStore.getBinding(texture, { force: reupload, }); if (bindGroup !== this.currentMaterial) { @@ -120,10 +146,42 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { this.currentMaterial = bindGroup; } - // Transform vertices. Stamp 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 output (x, y) is bit-identical and z passes through. + 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(); + effect._setNoiseUVRect?.( + source.width || source.videoWidth || 1, + source.height || source.videoHeight || 1, + w, + h, + Math.min(u0, u1), + Math.min(v0, v1), + ); + } + + this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint); + + 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(); + } + } + + /** + * Transform and queue the four corners of a quad — shared by the base + * 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). + * @ignore + */ + pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint) { + const vertexData = this.vertexData; const m = this.renderer.currentTransform; const z = this.renderer.currentDepth; const vec0 = V_ARRAY[0].set(x, y, z); @@ -138,15 +196,84 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { m.apply(vec3); } - // 4 vertices per quad; the index buffer provides the 6 indices. - // textureId is 0 under single-texture batching (layout kept for - // the multi-texture upgrade). + // 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); } + /** + * Composite a render target through an effect's pipeline as one + * screen-space quad — the WebGPU counterpart of the GL blitTexture. + * The caller ({@link WebGPURenderer#blitEffect}) has already set the + * screen-space projection; this records the draw with the effect's + * shader family and group-3 binding (uniforms snapshot-uploaded per + * bind). An effect without a WGSL realization composites as a plain + * un-effected blit, so the scene content is never lost. + * @param {import("../../rendertarget/webgpurendertarget.js").default} source - the render target to sample + * @param {number} x - destination x + * @param {number} y - destination y + * @param {number} w - destination width + * @param {number} h - destination height + * @param {ShaderEffect} [effect] - the effect to composite with + * @param {boolean} [keepBlend=false] - keep the current blend mode (else replace) + * @ignore + */ + blitTexture(source, x, y, w, h, effect, keepBlend = false) { + // drain pending quads under their own material first + this.flush(); + const renderer = this.renderer; + + // identity noise_uv rect for the fullscreen quad (GL parity), BEFORE + // the uniform snapshot below + effect?._setNoiseUVRect?.(w, h, w, h, 0, 0); + const binding = effect ? prepareEffectBinding(renderer, effect) : null; + + const pass = renderer.ensurePass(); + const pipeline = renderer.pipelineCache.get( + binding?.key ?? "quad", + "triangle-list", + keepBlend ? renderer.currentBlendMode : "none", + renderer.premultipliedAlpha, + renderer.stencilMode, + ); + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + pass.setBindGroup(1, source.getMaterialBindGroup()); + if (binding?.hasEffectGroup) { + // positional through group 3 — the reserved lights slot gets the + // shared empty group + pass.setBindGroup(2, renderer.pipelineCache.emptyBindGroup); + pass.setBindGroup(3, binding.bindGroup, binding.dynamicOffsets); + } + + // one quad, UNFLIPPED UVs (WebGPU texture row 0 is the top — the GL + // blit flips V because GL FBOs are bottom-up), white tint + const vertexData = this.vertexData; + vertexData.push(x, y, 0, 0, 0, 0xffffffff, 0); + vertexData.push(x + w, y, 0, 1, 0, 0xffffffff, 0); + vertexData.push(x, y + h, 0, 0, 1, 0xffffffff, 0); + vertexData.push(x + w, y + h, 0, 1, 1, 0xffffffff, 0); + const byteLength = 4 * this.stride; + const region = renderer.vertexArena.alloc(byteLength); + this.device.queue.writeBuffer( + region.buffer, + region.offset, + vertexData.toUint8(), + 0, + byteLength, + ); + pass.setVertexBuffer(0, region.buffer, region.offset, byteLength); + pass.setIndexBuffer(this.indexBuffer, "uint32"); + pass.drawIndexed(6); + vertexData.clear(); + } + /** * indexed draw: 6 indices per 4 queued vertices, region-relative * @param {GPURenderPassEncoder} pass - the open pass @@ -168,9 +295,74 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { this.vertexData.clear(); return; } + const effect = this.currentEffect; + if (effect !== null && this.vertexData.vertexCount > 0) { + // 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 + // with a disabled effect). + const binding = prepareEffectBinding(this.renderer, effect); + if (binding !== null) { + this.flushWithEffect(binding); + return; + } + } super.flush(topology); } + /** + * record the pending vertices through an effect's pipeline — blending + * KEPT (the sprite composites live against the backdrop, the defining + * semantic of the fast path vs the pooled blit) + * @param {object} binding - the prepared effect binding + * @ignore + */ + flushWithEffect(binding) { + const renderer = this.renderer; + const vertexData = this.vertexData; + const vertexCount = vertexData.vertexCount; + const pass = renderer.ensurePass(); + const byteLength = vertexCount * this.stride; + + const region = renderer.vertexArena.alloc(byteLength); + this.device.queue.writeBuffer( + region.buffer, + region.offset, + vertexData.toUint8(), + 0, + byteLength, + ); + + const pipeline = renderer.pipelineCache.get( + binding.key, + "triangle-list", + renderer.currentBlendMode, + renderer.premultipliedAlpha, + renderer.stencilMode, + ); + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + if (binding.hasEffectGroup) { + pass.setBindGroup(2, renderer.pipelineCache.emptyBindGroup); + pass.setBindGroup(3, binding.bindGroup, binding.dynamicOffsets); + } + pass.setVertexBuffer(0, region.buffer, region.offset, byteLength); + this.recordDraw(pass, vertexCount); + vertexData.clear(); + } + + /** + * @override + */ + reset() { + super.reset(); + this.currentEffect = null; + } + /** * @override */ diff --git a/packages/melonjs/src/video/webgpu/buffer/arena.js b/packages/melonjs/src/video/webgpu/buffer/arena.js index ddafbead89..a199e99d83 100644 --- a/packages/melonjs/src/video/webgpu/buffer/arena.js +++ b/packages/melonjs/src/video/webgpu/buffer/arena.js @@ -49,11 +49,16 @@ export default class WebGPUBufferArena { * Reserve `byteLength` bytes and return where they live. The region is * valid until the next `reset()`. * @param {number} byteLength - bytes to reserve (must fit in one page) + * @param {number} [alignment=4] - required offset alignment (a power of + * two; effect uniform snapshots pass the device's + * `minUniformBufferOffsetAlignment` so the region can serve as a + * dynamic bind-group offset) * @returns {{buffer: GPUBuffer, offset: number}} the reserved region */ - alloc(byteLength) { - // writeBuffer offsets must be 4-byte aligned + alloc(byteLength, alignment = 4) { + // writeBuffer offsets must be 4-byte aligned (callers may need more) const aligned = (byteLength + 3) & ~3; + this.offset = (this.offset + alignment - 1) & ~(alignment - 1); if (aligned > this.pageSize) { throw new Error( `WebGPUBufferArena: allocation of ${byteLength} bytes exceeds the page size (${this.pageSize})`, diff --git a/packages/melonjs/src/video/webgpu/effect_binding.js b/packages/melonjs/src/video/webgpu/effect_binding.js new file mode 100644 index 0000000000..90f64ed233 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/effect_binding.js @@ -0,0 +1,387 @@ +/** + * The WebGPU realization of a ShaderEffect at draw time: building the + * device-scoped state (module registration, group-3 layout, resident + * textures) lazily and per pipeline-cache epoch, and producing — per bind — + * the group-3 bind group plus dynamic offsets over a fresh uniform + * snapshot. + * + * Snapshot-per-bind is a correctness requirement, not a convenience: + * `queue.writeBuffer` data executes before EVERY draw recorded in the + * frame, so an effect bound twice in one frame with different uniform + * values (a shared effect on two renderables, draw-time mutation) must + * give each bind its own bytes — exactly the vertex arena's rationale, + * applied to uniforms. + * @ignore + */ + +/** + * round up to a power-of-two alignment + * @ignore + */ +function alignUp(value, alignment) { + return (value + alignment - 1) & ~(alignment - 1); +} + +/** + * byte size of the engine MEBuiltins struct (3×vec2f, 16-byte rounded) + * @ignore + */ +const ME_SIZE = 32; + +/** + * (Re)build the device-scoped GPU state of a WGSL effect realization — + * called lazily on first bind and again after a device loss (detected via + * the pipeline-cache epoch). + * @param {import("./webgpu_renderer.js").default} renderer - the renderer + * @param {import("../effects/wgsl_realization.js").default} realization - the effect's WGSL realization + * @ignore + */ +function buildEffectGPU(renderer, effect, realization) { + const cache = renderer.pipelineCache; + const bindings = realization.builtinBindings; + const builtins = realization.builtins; + const hasUniform = realization.structSize > 0; + const hasME = builtins.noiseUV; + + // ---- the group-3 layout, shared per shape signature ------------------ + const entries = []; + const signatureParts = []; + if (hasUniform) { + entries.push({ + binding: 0, + visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, + buffer: { + type: "uniform", + hasDynamicOffset: true, + minBindingSize: realization.structSize, + }, + }); + signatureParts.push(`u${realization.structSize}`); + } + for (const texture of realization.textures) { + entries.push({ + binding: texture.binding, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }); + entries.push({ + binding: texture.samplerBinding, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }); + signatureParts.push(`t${texture.binding}`); + } + if (hasME) { + entries.push({ + binding: bindings.me, + visibility: GPUShaderStage.VERTEX, + buffer: { + type: "uniform", + hasDynamicOffset: true, + minBindingSize: ME_SIZE, + }, + }); + signatureParts.push(`me${bindings.me}`); + } + if (builtins.screenTexture) { + entries.push({ + binding: bindings.screenTexture, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }); + signatureParts.push(`st${bindings.screenTexture}`); + if (builtins.screenSamplerClamp) { + entries.push({ + binding: bindings.screenSamplerClamp, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }); + // every entry variant must contribute a signature token, or two + // different shapes share one cached layout and bind groups break + signatureParts.push("sc"); + } + if (builtins.screenSamplerRepeat) { + entries.push({ + binding: bindings.screenSamplerRepeat, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }); + signatureParts.push("sr"); + } + } + + const hasEffectGroup = entries.length > 0; + let effectLayout = null; + let layouts; + if (hasEffectGroup) { + effectLayout = cache.getEffectLayout(signatureParts.join("|"), entries); + // positional through group 3: the reserved lights slot (group 2) + // interposes the shared empty layout + layouts = [ + cache.frameLayout, + cache.materialLayout, + cache.emptyLayout, + effectLayout, + ]; + } else { + layouts = [cache.frameLayout, cache.materialLayout]; + } + + // identical module text (clones, same-body effects) registers once and + // shares every pipeline; the shape signature being derived from that + // same text keeps the pipeline layout consistent per key + const key = cache.registerShader(realization.code, { + bindGroupLayouts: layouts, + vertexLayoutKey: "quad", + label: "melonJS effect shader", + }); + + // WGSL validation is asynchronous and never throws: a body that parsed + // but fails compilation would otherwise invalidate every pass drawing + // it, dropping whole frames forever. Surface it and disable the effect + // — the same warn-and-disable contract as a missing-language body. + // (Fire-and-forget: draws recorded before the result resolve harmlessly + // against the invalid pipeline; once disabled, the effect is filtered.) + 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) { + console.warn( + `ShaderEffect: WGSL compilation failed — effect disabled\n${errors + .map((message) => { + return ` line ${message.lineNum}: ${message.message}`; + }) + .join("\n")}`, + ); + effect.enabled = false; + realization.valid = false; + realization.releaseGPU(); + } + }) + .catch(() => {}); + } + + // the per-bind snapshot: uniform struct + ME block share ONE arena + // region (dynamic offsets must each be minUniformBufferOffsetAlignment + // aligned, so the ME slice starts at the next aligned boundary) + const uniformAlignment = + renderer.device.limits?.minUniformBufferOffsetAlignment ?? 256; + const meLocalOffset = hasUniform + ? alignUp(realization.structSize, uniformAlignment) + : 0; + const snapshotSize = hasME + ? meLocalOffset + ME_SIZE + : hasUniform + ? realization.structSize + : 0; + + realization.gpu = { + epoch: cache.epoch, + key, + hasEffectGroup, + effectLayout, + hasUniform, + hasME, + uniformAlignment, + meLocalOffset, + snapshotSize, + // per-entry resident textures for static setTexture sources + residentTextures: new Map(), + bindGroup: null, + bindKey: "", + invalidateBindGroup() { + this.bindGroup = null; + this.bindKey = ""; + }, + }; +} + +/** + * upload a static setTexture source into a resident effect-owned texture + * @ignore + */ +function residentTexture(renderer, gpu, name, entry) { + let resident = gpu.residentTextures.get(name); + if (typeof resident === "undefined" || resident.image !== entry.image) { + if (typeof resident !== "undefined") { + renderer.retireTexture(resident.texture); + } + const width = entry.image.width || 1; + const height = entry.image.height || 1; + const texture = renderer.device.createTexture({ + label: "melonJS effect texture", + size: [width, height], + format: "rgba8unorm", + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + renderer.device.queue.copyExternalImageToTexture( + { source: entry.image }, + // effect inputs keep raw texel values (GL parity: uploaded with + // premultiply OFF) + { texture, premultipliedAlpha: false }, + [width, height], + ); + resident = { image: entry.image, texture, view: texture.createView() }; + gpu.residentTextures.set(name, resident); + } + return resident; +} + +/** + * Prepare an effect for a draw: ensure device state, snapshot the uniform + * mirror(s) into the effect uniform arena, and return the pipeline family + * key plus the group-3 binding for the pass. + * @param {import("./webgpu_renderer.js").default} renderer - the renderer + * @param {import("../effects/shadereffect.js").default} effect - the effect to bind + * @returns {{key: string, hasEffectGroup: boolean, bindGroup: GPUBindGroup|null, dynamicOffsets: number[]}|null} + * the binding, or null when the effect has no WGSL realization + * @ignore + */ +export function prepareEffectBinding(renderer, effect) { + const realization = effect.wgslRealization; + if (typeof realization === "undefined" || !realization.valid) { + return null; + } + if ( + realization.gpu === null || + realization.gpu.epoch !== renderer.pipelineCache.epoch + ) { + buildEffectGPU(renderer, effect, realization); + } + const gpu = realization.gpu; + if (!gpu.hasEffectGroup) { + return { + key: gpu.key, + hasEffectGroup: false, + bindGroup: null, + dynamicOffsets: [], + }; + } + + // ---- snapshot the CPU mirrors into a fresh arena region -------------- + const dynamicOffsets = []; + let region = null; + if (gpu.snapshotSize > 0) { + region = renderer.effectUniformArena.alloc( + gpu.snapshotSize, + gpu.uniformAlignment, + ); + if (gpu.hasUniform) { + renderer.device.queue.writeBuffer( + region.buffer, + region.offset, + realization.cpu, + 0, + realization.structSize, + ); + dynamicOffsets.push(region.offset); + } + if (gpu.hasME) { + renderer.device.queue.writeBuffer( + region.buffer, + region.offset + gpu.meLocalOffset, + realization.meMirror.buffer, + 0, + ME_SIZE, + ); + dynamicOffsets.push(region.offset + gpu.meLocalOffset); + } + } + + // ---- (re)build the bind group when any referenced resource moved ---- + const capture = renderer.captureTexture; + const bindKey = [ + region === null ? "-" : region.buffer.label, + // only screen_texture consumers key on the capture — a capture + // reallocation must not rebuild unrelated effects' bind groups + realization.builtins.screenTexture ? (capture?.generation ?? -1) : -1, + gpu.residentTextures.size, + ].join("|"); + let stale = gpu.bindGroup === null || gpu.bindKey !== bindKey; + // a live extra texture (frame capture passed via setTexture) re-keys on + // its own generation + if (!stale) { + for (const entry of effect._extraTextures.values()) { + if (entry.live && entry.image.generation !== entry.boundGeneration) { + stale = true; + break; + } + } + } + if (stale) { + const entries = []; + if (gpu.hasUniform) { + entries.push({ + binding: 0, + resource: { buffer: region.buffer, size: realization.structSize }, + }); + } + for (const texture of realization.textures) { + const entry = effect._extraTextures.get(texture.name); + let view; + if (typeof entry === "undefined") { + // declared but never set: bind the renderer's 1×1 stub so the + // bind group stays valid (samples transparent black) + view = renderer.getStubTextureView(); + } else if (entry.live === true) { + view = entry.image.view ?? renderer.getStubTextureView(); + entry.boundGeneration = entry.image.generation; + } else { + view = residentTexture(renderer, gpu, texture.name, entry).view; + } + entries.push({ binding: texture.binding, resource: view }); + entries.push({ + binding: texture.samplerBinding, + resource: renderer.textureStore.getSampler( + "linear", + entry?.repeat ?? "no-repeat", + ), + }); + } + if (gpu.hasME) { + entries.push({ + binding: realization.builtinBindings.me, + resource: { buffer: region.buffer, size: ME_SIZE }, + }); + } + if (realization.builtins.screenTexture) { + entries.push({ + binding: realization.builtinBindings.screenTexture, + resource: capture?.view ?? renderer.getStubTextureView(), + }); + if (realization.builtins.screenSamplerClamp) { + entries.push({ + binding: realization.builtinBindings.screenSamplerClamp, + resource: renderer.textureStore.getSampler("linear", "no-repeat"), + }); + } + if (realization.builtins.screenSamplerRepeat) { + entries.push({ + binding: realization.builtinBindings.screenSamplerRepeat, + resource: renderer.textureStore.getSampler("linear", "repeat"), + }); + } + } + gpu.bindGroup = renderer.device.createBindGroup({ + label: "melonJS effect binding", + layout: gpu.effectLayout, + entries, + }); + gpu.bindKey = bindKey; + } + + return { + key: gpu.key, + hasEffectGroup: true, + bindGroup: gpu.bindGroup, + dynamicOffsets, + }; +} diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js index cf3c33c265..51a940e66b 100644 --- a/packages/melonjs/src/video/webgpu/pipeline/cache.js +++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js @@ -134,6 +134,35 @@ const STENCIL_STATES = { writeMask: 0, colorWriteMask: 0xf, }, + // gradient-mask phases (the GL #gradientMask parity): + // - "tag" stamps the shape's pixels with the dynamic stencil reference + // on a cleared stencil (GL's ALWAYS/REPLACE, color writes off) — + // replace, not increment, so overdrawing shape geometry is harmless + // - "mark" writes the reference only where the stencil's low 7 bits + // already equal the reference's low bits — tags/untags the high-bit + // marker inside an active mask without disturbing mask levels + tag: { + stencil: { + compare: "always", + failOp: "keep", + depthFailOp: "keep", + passOp: "replace", + }, + readMask: 0xff, + writeMask: 0xff, + colorWriteMask: 0, + }, + mark: { + stencil: { + compare: "equal", + failOp: "keep", + depthFailOp: "keep", + passOp: "replace", + }, + readMask: 0x7f, + writeMask: 0xff, + colorWriteMask: 0, + }, }; /** @@ -228,6 +257,100 @@ export default class WebGPUPipelineCache { bindGroupLayouts: [this.clearFrameLayout], }), }; + + // ---- registered (non-built-in) shader families ------------------- + // effect modules are deduplicated by their exact module text: clones + // and same-shape effects share one module and its pipelines + /** @type {Map} moduleText → family key */ + this.registeredModules = new Map(); + /** @type {Map} family key → vertex-layout alias */ + this.vertexLayoutAliases = new Map(); + /** @type {Map} shape signature → group-3 layout */ + this.effectLayouts = new Map(); + + // group 2 (lights) is reserved but unused by the 2D tier: pipeline + // layouts are positional, so families binding group 3 interpose a + // shared empty layout — and passes set the matching empty bind group, + // which some implementations validate even for empty layouts + this.emptyLayout = device.createBindGroupLayout({ + label: "melonJS empty group layout", + entries: [], + }); + this.emptyBindGroup = device.createBindGroup({ + label: "melonJS empty group", + layout: this.emptyLayout, + entries: [], + }); + + /** + * Device generation this cache belongs to — bumped once per cache + * construction. Consumers holding device-scoped objects built + * against this cache (effect modules, bind groups) compare their + * recorded epoch and lazily rebuild after a device loss. + * @type {number} + */ + this.epoch = ++WebGPUPipelineCache.epochCounter; + } + + /** + * monotonic construction counter backing {@link WebGPUPipelineCache#epoch} + * @ignore + */ + static epochCounter = 0; + + /** + * Register a shader family beyond the built-in three, routing it through + * the same keyed {@link WebGPUPipelineCache#get} lookup. Identical module + * text registers once — the returned key is stable per text, so clones + * and same-body effects share the module and every pipeline built on it. + * @param {string} code - the complete WGSL module text + * @param {object} [options] - family options + * @param {GPUBindGroupLayout[]} [options.bindGroupLayouts] - full positional + * bind-group-layout list (defaults to `[frameLayout, materialLayout]`) + * @param {string} [options.vertexLayoutKey="quad"] - reuse a registered + * vertex layout under this family (the effect blit rides the quad layout) + * @param {string} [options.label] - debug label for the shader module + * @returns {string} the family key to pass to {@link WebGPUPipelineCache#get} + */ + registerShader(code, options = {}) { + let key = this.registeredModules.get(code); + if (typeof key === "undefined") { + key = `effect:${this.registeredModules.size}`; + this.registeredModules.set(code, key); + this.modules[key] = this.device.createShaderModule({ + label: options.label ?? `melonJS ${key} shader`, + code, + }); + this.pipelineLayouts[key] = this.device.createPipelineLayout({ + bindGroupLayouts: options.bindGroupLayouts ?? [ + this.frameLayout, + this.materialLayout, + ], + }); + this.vertexLayoutAliases.set(key, options.vertexLayoutKey ?? "quad"); + } + return key; + } + + /** + * Fetch (building on first use) the group-3 bind-group layout for an + * effect shape. Effects with the same shape — same uniform block size, + * same texture/sampler bindings, same builtins — share one layout, so + * their pipeline layouts (and pipelines) are shareable too. + * @param {string} signature - the shape signature (caller-composed) + * @param {GPUBindGroupLayoutEntry[]} entries - the layout entries + * @returns {GPUBindGroupLayout} the cached layout + */ + getEffectLayout(signature, entries) { + let layout = this.effectLayouts.get(signature); + if (typeof layout === "undefined") { + layout = this.device.createBindGroupLayout({ + label: `melonJS effect layout ${signature}`, + entries, + }); + this.effectLayouts.set(signature, layout); + } + return layout; } /** @@ -259,7 +382,7 @@ export default class WebGPUPipelineCache { * @param {string} topology - portable topology name ("triangle-list", …) * @param {string} blendMode - blend mode (normalized internally) * @param {boolean} premultipliedAlpha - source premultiplication flag - * @param {string} [stencilMode="none"] - "none" | "write" | "test" + * @param {string} [stencilMode="none"] - "none" | "write" | "test" | "tag" | "mark" * @returns {GPURenderPipeline} the pipeline */ get( @@ -275,7 +398,11 @@ export default class WebGPUPipelineCache { let pipeline = this.pipelines.get(key); if (typeof pipeline === "undefined") { const stencil = STENCIL_STATES[stencilMode] ?? STENCIL_STATES.none; - const vertexLayout = this.vertexLayouts.get(shaderKey); + // registered families may alias a built-in vertex layout (effect + // blits ride the frozen quad layout) + const vertexLayout = this.vertexLayouts.get( + this.vertexLayoutAliases.get(shaderKey) ?? shaderKey, + ); pipeline = this.device.createRenderPipeline({ label: `melonJS ${key}`, layout: this.pipelineLayouts[shaderKey], diff --git a/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js b/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js new file mode 100644 index 0000000000..a94cf6ffa0 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/renderers/tmxlayer/orthogonal.js @@ -0,0 +1,437 @@ +import { Vector3d } from "../../../../math/vector3d.ts"; +import tmxLayerWGSL from "../../shaders/tmxlayer.wgsl"; + +/** + * additional imports for TypeScript + * @import { default as TMXLayer } from "../../../../level/tiled/TMXLayer.js"; + * @import { default as WebGPURenderer } from "../../webgpu_renderer.js"; + */ + +/** + * byte size of the TMXUniforms block (see tmxlayer.wgsl): ten vec2f (80) + * interleaved with one vec4f at a 16-aligned offset, two f32 and the + * trailing vec4f tint → 112 bytes + * @ignore + */ +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 + * visible region draws as ONE quad per tileset, with the fragment shader + * sampling a per-layer GID index texture and the tileset atlas. + * + * Structure mirrors the WebGL twin; the mechanisms are the backend's: + * - the GID index / animation lookup are renderer-owned `rgba8unorm` + * textures written with `queue.writeTexture` (byte-exact `textureLoad` + * reads, no sampler needed) — re-uploaded only when `layer.dataVersion` + * moves or an animation frame advances + * - the per-draw uniforms snapshot into the effect uniform arena with a + * dynamic offset (each tileset pass gets its own region, per the + * queue-write-before-draws ordering) + * - the tileset atlas rides the standard texture-store material path + * - pipelines flow through the registered `tmxlayer.wgsl` family, keyed + * on the frame's blend/stencil state like every other draw + * @ignore + */ +export default class OrthogonalTMXLayerGPURenderer { + /** + * @param {WebGPURenderer} renderer - the WebGPU renderer instance + */ + constructor(renderer) { + this.renderer = renderer; + const cache = renderer.pipelineCache; + + // group-3 shape: the uniform block + the two lookup textures + this.tmxLayout = cache.getEffectLayout("tmx", [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { + type: "uniform", + hasDynamicOffset: true, + minBindingSize: TMX_UNIFORM_SIZE, + }, + }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + ]); + this.key = cache.registerShader(tmxLayerWGSL, { + bindGroupLayouts: [ + cache.frameLayout, + cache.materialLayout, + cache.emptyLayout, + this.tmxLayout, + ], + vertexLayoutKey: "quad", + label: "melonJS tmx layer shader", + }); + // the pipeline cache this state was built against — a device loss + // rebuilds the cache, and the renderer drops this instance with it + this.epoch = cache.epoch; + + /** + * per-layer GID index texture: layer → {texture, view, version} + * @type {Map} + */ + this.resources = new Map(); + /** + * per-tileset animation lookup: tileset → {texture, view, data, tileCount} + * @type {Map} + */ + this.animLookups = new Map(); + /** + * per-(layer, tileset) cached group-3 bind group, keyed on the + * uniform arena page it binds + * @type {Map>} + */ + this.bindGroups = new Map(); + + // CPU staging for the uniform block and the quad vertices + this.uniformScratch = new Float32Array(TMX_UNIFORM_SIZE / 4); + this.vertexScratch = new ArrayBuffer(4 * 28); + this.vertexF32 = new Float32Array(this.vertexScratch); + this.vertexU32 = new Uint32Array(this.vertexScratch); + } + + /** + * Retire every lookup texture and empty the local maps — called from + * `WebGPURenderer.reset()` (GAME_RESET) so each level starts clean. + * @ignore + */ + reset() { + for (const resource of this.resources.values()) { + this.renderer.retireTexture(resource.texture); + } + for (const entry of this.animLookups.values()) { + this.renderer.retireTexture(entry.texture); + } + this.resources.clear(); + this.animLookups.clear(); + this.bindGroups.clear(); + } + + /** + * Get-or-create the per-layer GID index texture, re-uploading when the + * layer's data version moved (`setTile`/`clearTile` mutations). + * @param {TMXLayer} layer - the layer + * @returns {{texture: GPUTexture, view: GPUTextureView, version: number}} + * @ignore + */ + getResource(layer) { + const device = this.renderer.device; + let resource = this.resources.get(layer); + if (resource === undefined) { + const texture = device.createTexture({ + label: "melonJS tmx index", + size: [layer.cols, layer.rows], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + resource = { texture, view: texture.createView(), version: -1 }; + this.resources.set(layer, resource); + } + if (resource.version !== layer.dataVersion) { + // reinterpret the layer's Uint16Array payload as RGBA bytes + // (zero-copy view; little-endian = every browser) + device.queue.writeTexture( + { texture: resource.texture }, + new Uint8Array(layer.layerData.buffer), + { bytesPerRow: layer.cols * 4, rowsPerImage: layer.rows }, + [layer.cols, layer.rows], + ); + resource.version = layer.dataVersion; + } + return resource; + } + + /** + * Get-or-update the per-tileset animation lookup (undefined for + * tilesets without animated tiles). Texel `localId` encodes the + * CURRENT frame's local id as (R = lo byte, G = hi byte). + * @param {object} tileset - the tileset + * @param {number} tileCount - atlas grid tile count + * @returns {object|undefined} the lookup entry + * @ignore + */ + getOrUpdateAnimLookup(tileset, tileCount) { + if (!tileset.isAnimated || tileset.animations.size === 0) { + return undefined; + } + const device = this.renderer.device; + let entry = this.animLookups.get(tileset); + if (entry === undefined) { + // identity mapping (localId → localId); animated entries get + // overwritten below + const data = new Uint8Array(tileCount * 4); + for (let id = 0; id < tileCount; id++) { + data[id * 4 + 0] = id & 0xff; + data[id * 4 + 1] = (id >> 8) & 0xff; + } + const texture = device.createTexture({ + label: "melonJS tmx anim lookup", + size: [tileCount, 1], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + entry = { + texture, + view: texture.createView(), + data, + tileCount, + uploaded: false, + }; + this.animLookups.set(tileset, entry); + } + // rewrite current-frame ids; upload only when a texel changed + // (tileset.update advanced a frame). At most one upload per frame — + // queue writes execute before every recorded draw, and animation + // state only moves in the update phase, so the frame stays coherent. + const data = entry.data; + let dirty = !entry.uploaded; + for (const [localId, anim] of tileset.animations) { + const off = localId * 4; + const cur = anim.cur.tileid; + const lo = cur & 0xff; + const hi = (cur >> 8) & 0xff; + if (data[off] !== lo || data[off + 1] !== hi) { + data[off] = lo; + data[off + 1] = hi; + dirty = true; + } + } + if (dirty) { + device.queue.writeTexture( + { texture: entry.texture }, + data, + { bytesPerRow: entry.tileCount * 4, rowsPerImage: 1 }, + [entry.tileCount, 1], + ); + entry.uploaded = true; + } + return entry; + } + + /** + * Draw an orthogonal TMX layer through the shader path. + * @param {TMXLayer} layer - the layer to draw + * @param {object} rect - the visible viewport rect (world coords) + */ + draw(layer, rect) { + const renderer = this.renderer; + const tileWidth = layer.tilewidth; + const tileHeight = layer.tileheight; + const cols = layer.cols; + const rows = layer.rows; + + // visible region in tile-coord space + const startTileX = Math.max(0, Math.floor(rect.pos.x / tileWidth)); + const startTileY = Math.max(0, Math.floor(rect.pos.y / tileHeight)); + const endTileX = Math.min( + cols, + Math.ceil((rect.pos.x + rect.width) / tileWidth), + ); + const endTileY = Math.min( + rows, + Math.ceil((rect.pos.y + rect.height) / tileHeight), + ); + if (endTileX <= startTileX || endTileY <= startTileY) { + return; + } + + const worldX = startTileX * tileWidth; + const worldY = startTileY * tileHeight; + const worldW = (endTileX - startTileX) * tileWidth; + const worldH = (endTileY - startTileY) * tileHeight; + + // drain pending sprite vertices under their own pipeline, then use + // the quad batcher's frozen machinery (staging layout, index buffer) + const batcher = renderer.setBatcher("quad"); + batcher.flush(); + const pass = renderer.ensurePass(); + const device = renderer.device; + + const resource = this.getResource(layer); + + // 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 f32 = this.vertexF32; + const u32 = this.vertexU32; + const uvs = [0, 0, 1, 0, 0, 1, 1, 1]; + for (let i = 0; i < 4; i++) { + const base = i * 7; + f32[base] = corners[i].x; + f32[base + 1] = corners[i].y; + f32[base + 2] = corners[i].z; + f32[base + 3] = uvs[i * 2]; + f32[base + 4] = uvs[i * 2 + 1]; + u32[base + 5] = 0xffffffff; + f32[base + 6] = 0; + } + const vertexRegion = renderer.vertexArena.alloc(4 * 28); + device.queue.writeBuffer( + vertexRegion.buffer, + vertexRegion.offset, + this.vertexScratch, + 0, + 4 * 28, + ); + + // layer-level uniform values (identical for every tileset pass) + const scratch = this.uniformScratch; + scratch[0] = cols; + scratch[1] = rows; + scratch[2] = tileWidth; + scratch[3] = tileHeight; + scratch[18] = startTileX; + scratch[19] = startTileY; + scratch[20] = endTileX - startTileX; + scratch[21] = endTileY - startTileY; + scratch[22] = layer.getOpacity(); + const tint = layer.tint ? layer.tint.toArray() : null; + scratch[24] = tint ? tint[0] : 1; + scratch[25] = tint ? tint[1] : 1; + scratch[26] = tint ? tint[2] : 1; + scratch[27] = tint ? tint[3] : 1; + + const pipeline = renderer.pipelineCache.get( + this.key, + "triangle-list", + renderer.currentBlendMode, + renderer.premultipliedAlpha, + renderer.stencilMode, + ); + if (pipeline !== renderer.currentPipeline) { + pass.setPipeline(pipeline); + renderer.currentPipeline = pipeline; + } + const frame = renderer.currentFrameBinding; + pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); + pass.setBindGroup(2, renderer.pipelineCache.emptyBindGroup); + pass.setVertexBuffer(0, vertexRegion.buffer, vertexRegion.offset, 4 * 28); + pass.setIndexBuffer(batcher.indexBuffer, "uint32"); + + // one pass per tileset — the GID-range guard hides other tilesets + const tilesets = layer.tilesets.tilesets; + let layerGroups = this.bindGroups.get(layer); + if (layerGroups === undefined) { + layerGroups = new Map(); + this.bindGroups.set(layer, layerGroups); + } + for (let i = 0; i < tilesets.length; i++) { + const tileset = tilesets[i]; + // eligibility should have excluded these — guard defensively + if (tileset.isCollection || tileset.image === undefined) { + continue; + } + + const tsW = tileset.tilewidth; + const tsH = tileset.tileheight; + const margin = tileset.margin; + const spacing = tileset.spacing; + const atlasW = tileset.image.width; + const atlasH = tileset.image.height; + const atlasCols = Math.max( + 1, + Math.floor((atlasW - margin * 2 + spacing) / (tsW + spacing)), + ); + const atlasRows = Math.max( + 1, + Math.floor((atlasH - margin * 2 + spacing) / (tsH + spacing)), + ); + + const animEntry = this.getOrUpdateAnimLookup( + tileset, + atlasCols * atlasRows, + ); + // every declared binding needs a view — reuse the index texture + // as the filler (never read: uAnimSize stays 0) + const animView = animEntry ? animEntry.view : resource.view; + + // per-tileset uniform values + snapshot into the arena + scratch[4] = tsW; + scratch[5] = tsH; + scratch[6] = Math.max(0, Math.ceil(tsW / tileWidth) - 1); + scratch[7] = Math.max(0, Math.ceil(tsH / tileHeight) - 1); + scratch[8] = atlasCols; + scratch[9] = atlasRows; + scratch[10] = 1 / atlasW; + scratch[11] = 1 / atlasH; + scratch[12] = margin; + scratch[13] = margin; + scratch[14] = spacing; + scratch[15] = spacing; + scratch[16] = tileset.firstgid; + scratch[17] = tileset.lastgid; + scratch[23] = animEntry ? animEntry.tileCount : 0; + const region = renderer.effectUniformArena.alloc( + TMX_UNIFORM_SIZE, + device.limits?.minUniformBufferOffsetAlignment ?? 256, + ); + device.queue.writeBuffer( + region.buffer, + region.offset, + scratch.buffer, + 0, + TMX_UNIFORM_SIZE, + ); + + // group-3 bind group, cached per (layer, tileset) while the + // arena page and lookup views stay the same + let cached = layerGroups.get(tileset); + if ( + cached === undefined || + cached.pageLabel !== region.buffer.label || + cached.animView !== animView + ) { + cached = { + pageLabel: region.buffer.label, + animView, + bindGroup: device.createBindGroup({ + label: "melonJS tmx binding", + layout: this.tmxLayout, + entries: [ + { + binding: 0, + resource: { + buffer: region.buffer, + size: TMX_UNIFORM_SIZE, + }, + }, + { binding: 1, resource: resource.view }, + { binding: 2, resource: animView }, + ], + }), + }; + layerGroups.set(tileset, cached); + } + + pass.setBindGroup(1, renderer.textureStore.getBinding(tileset.texture)); + pass.setBindGroup(3, cached.bindGroup, [region.offset]); + pass.drawIndexed(6); + } + } +} diff --git a/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl new file mode 100644 index 0000000000..eab5dcf7bc --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/quad-lit.wgsl @@ -0,0 +1,94 @@ +// melonJS WebGPU lit quad shader — the WGSL port of quad-multi-lit.vert / +// multitexture-lit.js under this backend's single-texture-per-draw-segment +// model: ONE color texture and ONE normal map bound per flush (group 1), +// so the GL sampler ladder and the per-vertex normal-texture id are gone. +// The std140 Light2dBlock binds at group 2 with a dynamic offset — one +// snapshot per setLightUniforms call, per the queue-write ordering law. +// +// Vertex layout: the frozen 28-byte quad layout, unchanged. + +struct FrameUniforms { + projection : mat4x4, + // unused by this shader; part of the shared frame-globals block + lineWidth : f32, +}; + +struct Light2dData { + // x, y, radius, intensity + posRadiusIntensity : vec4f, + // r, g, b, lightHeight + colorHeight : vec4f, +}; + +// the std140 layout published by src/video/webgl/lighting/std140.ts: +// header = count + pad (vec4), ambient rgb + pad (vec4), then 32 lights +// of 2 vec4 each — 1056 bytes total +struct Light2dBlock { + 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(1) @binding(2) var uNormal : texture_2d; +@group(1) @binding(3) var uNormalSampler : sampler; +@group(2) @binding(0) var uLights : Light2dBlock; + +struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + @location(1) vColor : vec4f, + // pre-projection coordinates — the space packLights translates the + // light positions into (world after the camera translate) + @location(2) vWorldPos : vec2f, +}; + +@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); + // 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); + out.vColor = vec4f(aColor.bgr * aColor.a, aColor.a); + out.vRegion = aRegion; + out.vWorldPos = aVertex.xy; + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + let color = textureSample(uTexture, uSampler, in.vRegion); + // the normal map is UV-paired with the color atlas — same vRegion + let normalSample = textureSample(uNormal, uNormalSampler, in.vRegion); + + // Decode 0..1 -> -1..1. Normal maps use the Y-up authoring convention + // but screen space here is Y-down — flip Y so dot(normal, lightDir) + // runs in one coherent coordinate system (GL-backend parity). + var normal = normalize(normalSample.rgb * 2.0 - vec3f(1.0)); + normal.y = -normal.y; + + var lighting = uLights.ambient.rgb; + let count = min(i32(uLights.countPad.x), 32); + for (var i = 0; i < count; i = i + 1) { + let lp = uLights.lights[i].posRadiusIntensity; + let ch = uLights.lights[i].colorHeight; + let toLight = lp.xy - in.vWorldPos; + let dist = length(toLight); + // quadratic attenuation over [0, radius]: a wider plateau near the + // light and a softer feathered edge than the linear formula + let linearAtt = max(0.0, 1.0 - dist / max(lp.z, 1.0)); + let att = linearAtt * linearAtt; + let lightDir = normalize(vec3f(toLight, ch.w)); + let ndotl = max(0.0, dot(normal, lightDir)); + lighting = lighting + ch.rgb * (lp.w * att * ndotl); + } + + return vec4f(color.rgb * lighting, color.a) * in.vColor; +} diff --git a/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl b/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl new file mode 100644 index 0000000000..2d88ec74f7 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/shaders/tmxlayer.wgsl @@ -0,0 +1,201 @@ +// melonJS WebGPU orthogonal TMX layer shader — the WGSL port of +// orthogonal-tmxlayer.vert/frag (WebGL2). +// +// Per fragment: recover the world-pixel position from the quad UV, walk +// candidate cells (the geometric cell, plus cells whose oversized +// bottom-aligned tiles could reach this fragment), fetch GIDs from the +// per-layer index texture, and sample the tileset atlas at the correct +// sub-region. One draw per tileset — the GID-range guard discards cells +// belonging to other tilesets. +// +// Index texture encoding (rgba8unorm, one cell per texel): +// R = GID low byte, G = GID high byte, B = flip mask (H|V<<1|AD<<2) +// Animation lookup (rgba8unorm, 1 row): per local id, the CURRENT frame's +// local id, same R/G byte-pair encoding. +// +// textureLoad replaces texelFetch (byte-exact, no sampler); the atlas +// sample uses textureSampleLevel — the sample sites sit in non-uniform +// control flow, where implicit-derivative sampling is not allowed (WGSL +// uniformity rule; atlases are single-level so the output is identical). + +struct FrameUniforms { + projection : mat4x4, + lineWidth : f32, +}; + +struct TMXUniforms { + uMapSize : vec2f, + uCellSize : vec2f, + uTileSize : vec2f, + uOverflow : vec2f, + uTilesetCols : vec2f, + uInvTilesetSize : vec2f, + uTilesetMargin : vec4f, // (marginX, marginY, spacingX, spacingY) + uGidRange : vec2f, // (firstgid, lastgid) + uVisibleStart : vec2f, + uVisibleSize : vec2f, + uOpacity : f32, + uAnimSize : f32, // entries in the anim lookup, 0 when disabled + uTint : vec4f, +}; + +@group(0) @binding(0) var uFrame : FrameUniforms; +@group(1) @binding(0) var uTexture : texture_2d; // tileset atlas +@group(1) @binding(1) var uSampler : sampler; +@group(3) @binding(0) var tmx : TMXUniforms; +@group(3) @binding(1) var uTileIndex : texture_2d; // per-layer GID index +@group(3) @binding(2) var uAnimLookup : texture_2d; + +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); + // GL-convention clip z in [-w, w] -> WebGPU [0, w] + 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; +} + +const MAX_OVERFLOW : i32 = 4; + +struct CellSample { + hit : bool, + color : vec4f, +} + +// Try to render the tile at cell (cx, cy) for the current fragment — +// identical logic to the GLSL tryRenderCell, returning a hit/color pair +// (WGSL has no out parameters). +fn tryRenderCell(cx : i32, cy : i32, worldPx : vec2f) -> CellSample { + var miss : CellSample; + miss.hit = false; + miss.color = vec4f(0.0); + + let mapW = i32(tmx.uMapSize.x); + let mapH = i32(tmx.uMapSize.y); + if (cx < 0 || cx >= mapW || cy < 0 || cy >= mapH) { + return miss; + } + + // byte-exact read: rgba8unorm texel back to byte ints + let cellF = textureLoad(uTileIndex, vec2i(cx, cy), 0); + let cell = vec4u(cellF * 255.0 + 0.5); + let firstGid = i32(tmx.uGidRange.x); + let gid = i32(cell.r) | (i32(cell.g) << 8u); + if (gid < firstGid || gid > i32(tmx.uGidRange.y)) { + return miss; + } + + // oversized tiles are bottom-aligned vertically, left-aligned horizontally + let tileWorldOrigin = vec2f( + f32(cx) * tmx.uCellSize.x, + (f32(cy) + 1.0) * tmx.uCellSize.y - tmx.uTileSize.y, + ); + var inTile = (worldPx - tileWorldOrigin) / tmx.uTileSize; + if (inTile.x < 0.0 || inTile.x >= 1.0 || inTile.y < 0.0 || inTile.y >= 1.0) { + return miss; + } + + // flip mask + axis-swap trick: AD transposes; with AD set, H and V + // swap their effective axes (matches the legacy buildFlipTransform) + let flipMask = i32(cell.b); + let flipH = f32(flipMask & 1); + let flipV = f32((flipMask >> 1u) & 1); + let flipAD = f32((flipMask >> 2u) & 1); + inTile = mix(inTile, inTile.yx, vec2f(flipAD)); + let effH = mix(flipH, flipV, flipAD); + let effV = mix(flipV, flipH, flipAD); + inTile.x = mix(inTile.x, 1.0 - inTile.x, effH); + inTile.y = mix(inTile.y, 1.0 - inTile.y, effV); + + var localId = gid - firstGid; + + // animated tiles: swap the local id for the current frame's id (the + // CPU rewrites the lookup in lockstep with tileset.update) + if (tmx.uAnimSize > 0.5) { + let animF = textureLoad(uAnimLookup, vec2i(localId, 0), 0); + let animTexel = vec4u(animF * 255.0 + 0.5); + localId = i32(animTexel.r) | (i32(animTexel.g) << 8u); + } + + let row = floor(f32(localId) / tmx.uTilesetCols.x); + let col = f32(localId) - row * tmx.uTilesetCols.x; + let tileOriginPx = tmx.uTilesetMargin.xy + + vec2f(col, row) * (tmx.uTileSize + tmx.uTilesetMargin.zw); + let texelPx = tileOriginPx + inTile * tmx.uTileSize; + let texelUV = texelPx * tmx.uInvTilesetSize; + + let sampled = textureSampleLevel(uTexture, uSampler, texelUV, 0.0); + if (sampled.a <= 0.0) { + return miss; + } + var out : CellSample; + out.hit = true; + out.color = sampled; + return out; +} + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + let tileCoord = tmx.uVisibleStart + in.vRegion * tmx.uVisibleSize; + let geomCell = floor(tileCoord); + let worldPx = tileCoord * tmx.uCellSize; + + let gx = i32(geomCell.x); + let gy = i32(geomCell.y); + let overflowX = i32(tmx.uOverflow.x + 0.5); + let overflowY = i32(tmx.uOverflow.y + 0.5); + + var result : CellSample; + + // fast path: tiles fit the cell exactly — only the geometric cell can + // contain this fragment's tile + if (overflowX == 0 && overflowY == 0) { + result = tryRenderCell(gx, gy, worldPx); + if (!result.hit) { + discard; + } + return vec4f(result.color.rgb, result.color.a * tmx.uOpacity) * tmx.uTint; + } + + // slow path: oversized tiles. Scan candidates dy high→low, dx low→high + // and take the FIRST match — "right-down" render order puts later + // cells on top + var found = false; + for (var idy = 0; idy <= MAX_OVERFLOW; idy++) { + let dy = MAX_OVERFLOW - idy; + if (dy > overflowY) { + continue; + } + for (var dx = 0; dx <= MAX_OVERFLOW; dx++) { + if (dx > overflowX) { + break; + } + let candidate = tryRenderCell(gx - dx, gy + dy, worldPx); + if (candidate.hit) { + result = candidate; + found = true; + break; + } + } + if (found) { + break; + } + } + if (!found) { + discard; + } + return vec4f(result.color.rgb, result.color.a * tmx.uOpacity) * tmx.uTint; +} diff --git a/packages/melonjs/src/video/webgpu/texture/compressed.js b/packages/melonjs/src/video/webgpu/texture/compressed.js new file mode 100644 index 0000000000..56ca01d85d --- /dev/null +++ b/packages/melonjs/src/video/webgpu/texture/compressed.js @@ -0,0 +1,91 @@ +/** + * WebGL compressed-format constant → WebGPU texture format + block + * metrics. The loader parsers (dds/pvr/pkm/ktx/ktx2) are backend-neutral + * and emit WebGL enum constants; this table is the WebGPU side of that + * contract. Families gate on the device features requested at init: + * "texture-compression-bc", "texture-compression-etc2", + * "texture-compression-astc" (PVRTC has no WebGPU equivalent). + * @ignore + */ +export const COMPRESSED_FORMATS = new Map([ + // S3TC / BC (texture-compression-bc) + [0x83f0, { format: "bc1-rgba-unorm", blockW: 4, blockH: 4, bytes: 8 }], + [0x83f2, { format: "bc2-rgba-unorm", blockW: 4, blockH: 4, bytes: 16 }], + [0x83f3, { format: "bc3-rgba-unorm", blockW: 4, blockH: 4, bytes: 16 }], + [0x8c4c, { format: "bc1-rgba-unorm-srgb", blockW: 4, blockH: 4, bytes: 8 }], + [0x8c4d, { format: "bc1-rgba-unorm-srgb", blockW: 4, blockH: 4, bytes: 8 }], + [0x8c4e, { format: "bc2-rgba-unorm-srgb", blockW: 4, blockH: 4, bytes: 16 }], + [0x8c4f, { format: "bc3-rgba-unorm-srgb", blockW: 4, blockH: 4, bytes: 16 }], + [0x8e8c, { format: "bc7-rgba-unorm", blockW: 4, blockH: 4, bytes: 16 }], + // ETC1 payload is valid ETC2 rgb8 (superset) + [0x8d64, { format: "etc2-rgb8unorm", blockW: 4, blockH: 4, bytes: 8 }], + // ETC2 / EAC (texture-compression-etc2) + [0x9274, { format: "etc2-rgb8unorm", blockW: 4, blockH: 4, bytes: 8 }], + [0x9275, { format: "etc2-rgb8unorm-srgb", blockW: 4, blockH: 4, bytes: 8 }], + [0x9276, { format: "etc2-rgb8a1unorm", blockW: 4, blockH: 4, bytes: 8 }], + [0x9277, { format: "etc2-rgb8a1unorm-srgb", blockW: 4, blockH: 4, bytes: 8 }], + [0x9278, { format: "etc2-rgba8unorm", blockW: 4, blockH: 4, bytes: 16 }], + [0x9279, { format: "etc2-rgba8unorm-srgb", blockW: 4, blockH: 4, bytes: 16 }], + [0x9270, { format: "eac-r11unorm", blockW: 4, blockH: 4, bytes: 8 }], + [0x9271, { format: "eac-r11snorm", blockW: 4, blockH: 4, bytes: 8 }], + [0x9272, { format: "eac-rg11unorm", blockW: 4, blockH: 4, bytes: 16 }], + [0x9273, { format: "eac-rg11snorm", blockW: 4, blockH: 4, bytes: 16 }], + // ASTC (texture-compression-astc) — all 16 bytes per block + [0x93b0, { format: "astc-4x4-unorm", blockW: 4, blockH: 4, bytes: 16 }], + [0x93b1, { format: "astc-5x4-unorm", blockW: 5, blockH: 4, bytes: 16 }], + [0x93b2, { format: "astc-5x5-unorm", blockW: 5, blockH: 5, bytes: 16 }], + [0x93b3, { format: "astc-6x5-unorm", blockW: 6, blockH: 5, bytes: 16 }], + [0x93b4, { format: "astc-6x6-unorm", blockW: 6, blockH: 6, bytes: 16 }], + [0x93b5, { format: "astc-8x5-unorm", blockW: 8, blockH: 5, bytes: 16 }], + [0x93b6, { format: "astc-8x6-unorm", blockW: 8, blockH: 6, bytes: 16 }], + [0x93b7, { format: "astc-8x8-unorm", blockW: 8, blockH: 8, bytes: 16 }], + // ASTC sRGB (KTX1 files carry the raw GL internal formats) + [0x93d0, { format: "astc-4x4-unorm-srgb", blockW: 4, blockH: 4, bytes: 16 }], + [0x93d1, { format: "astc-5x4-unorm-srgb", blockW: 5, blockH: 4, bytes: 16 }], + [0x93d2, { format: "astc-5x5-unorm-srgb", blockW: 5, blockH: 5, bytes: 16 }], + [0x93d3, { format: "astc-6x5-unorm-srgb", blockW: 6, blockH: 5, bytes: 16 }], + [0x93d4, { format: "astc-6x6-unorm-srgb", blockW: 6, blockH: 6, bytes: 16 }], + [0x93d5, { format: "astc-8x5-unorm-srgb", blockW: 8, blockH: 5, bytes: 16 }], + [0x93d6, { format: "astc-8x6-unorm-srgb", blockW: 8, blockH: 6, bytes: 16 }], + [0x93d7, { format: "astc-8x8-unorm-srgb", blockW: 8, blockH: 8, bytes: 16 }], +]); + +/** + * the optional device features the renderer requests when the adapter + * offers them + * @ignore + */ +export const COMPRESSION_FEATURES = [ + "texture-compression-bc", + "texture-compression-etc2", + "texture-compression-astc", +]; + +/** + * Upload every mip level of a parsed CompressedImage into a GPUTexture + * via queue.writeTexture (block-aligned rows, largest mip first — the + * parser contract). + * @param {GPUDevice} device - the device + * @param {GPUTexture} texture - destination created with the mapped format + * @param {object} image - the parsed CompressedImage ({mipmaps, format}) + * @param {object} metrics - the COMPRESSED_FORMATS entry for image.format + * @ignore + */ +export function uploadCompressedTexture(device, texture, image, metrics) { + const mipmaps = image.mipmaps; + for (let level = 0; level < mipmaps.length; level++) { + const mip = mipmaps[level]; + const blocksPerRow = Math.ceil(mip.width / metrics.blockW); + const rows = Math.ceil(mip.height / metrics.blockH); + device.queue.writeTexture( + { texture, mipLevel: level }, + mip.data, + { + offset: 0, + bytesPerRow: blocksPerRow * metrics.bytes, + rowsPerImage: rows, + }, + [mip.width, mip.height], + ); + } +} diff --git a/packages/melonjs/src/video/webgpu/texture/frametexture.js b/packages/melonjs/src/video/webgpu/texture/frametexture.js new file mode 100644 index 0000000000..9235066177 --- /dev/null +++ b/packages/melonjs/src/video/webgpu/texture/frametexture.js @@ -0,0 +1,96 @@ +import Texture2d from "../../texture/texture2d.ts"; + +/** + * The WebGPU counterpart of the WebGL `FrameTexture`: a GPU-resident + * capture of the frame rendered so far, refreshed IN PLACE by + * {@link WebGPURenderer#captureFrame} via `copyTextureToTexture` — an + * encoder-ordered command, so the copy sees exactly the draws recorded + * before the capture point and none after (no queue-write retroactivity). + * + * Effects sample it through the `screen_texture` builtin: the effect's + * group-3 bind group binds `view`, keyed by `generation` so a size-change + * reallocation rebuilds stale bind groups. `isGPUResident` keeps the + * `setTexture` discriminant contract of the WebGL twin. + * @augments Texture2d + * @ignore + */ +export class WebGPUFrameTexture extends Texture2d { + /** + * monotonic generation source shared by every capture instance + * @ignore + */ + static generationCounter = 0; + + /** + * @param {import("../webgpu_renderer.js").default} renderer - the owning renderer + * @param {number} width - capture width in pixels + * @param {number} height - capture height in pixels + */ + constructor(renderer, width, height) { + super(); + this.renderer = renderer; + /** + * marks this as a live GPU-resident source — see {@link ShaderEffect#setTexture} + * @type {boolean} + */ + this.isGPUResident = true; + /** @type {GPUTexture} */ + this.gpuTexture = null; + this.realloc(width, height); + } + + /** + * (Re)allocate the backing texture at the given size, keeping this + * object's identity — the caller-owned-refresh contract of + * `toFrameTexture({target})`. The old texture is retired (draws + * already recorded against it stay valid) and `generation` advances, + * so bind groups referencing the old view re-key instead of pointing + * at a destroyed texture and failing every subsequent submit. + * @param {number} width - capture width in pixels + * @param {number} height - capture height in pixels + */ + realloc(width, height) { + if (this.gpuTexture !== null) { + this.renderer.retireTexture(this.gpuTexture); + } + /** @type {number} */ + this.width = width; + /** @type {number} */ + this.height = height; + /** + * unique per allocation (module-wide counter) — the bind-group key + * @type {number} + */ + this.generation = ++WebGPUFrameTexture.generationCounter; + this.gpuTexture = this.renderer.device.createTexture({ + label: "melonJS frame capture", + size: [width, height], + format: this.renderer.preferredFormat, + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING, + }); + /** @type {GPUTextureView} */ + this.view = this.gpuTexture.createView(); + } + + /** + * The opaque GPU-resident backing — itself. + * @returns {WebGPUFrameTexture} + */ + getTexture() { + return this; + } + + /** + * Release the backing texture (retired if a frame is recording, so + * draws already recorded against it stay valid). Idempotent. + */ + destroy() { + if (this.gpuTexture !== null) { + this.renderer.retireTexture(this.gpuTexture); + this.gpuTexture = null; + this.view = null; + } + } +} + +export default WebGPUFrameTexture; diff --git a/packages/melonjs/src/video/webgpu/texture/store.js b/packages/melonjs/src/video/webgpu/texture/store.js index 9650904082..daea150d6e 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 { COMPRESSED_FORMATS, uploadCompressedTexture } from "./compressed.js"; /** * Renderer-owned GPU texture store for the WebGPU backend — the counterpart @@ -97,6 +98,52 @@ export default class WebGPUTextureStore { options.force === true || record.source !== source ) { + // compressed sources (parsed dds/ktx/pvr/pkm) carry pre-encoded + // block data: a dedicated createTexture + per-mip writeTexture + // path, and static content — no same-frame re-upload concerns + if (source.compressed === true) { + if (typeof record === "undefined" || record.source !== source) { + const metrics = COMPRESSED_FORMATS.get(source.format); + if (typeof metrics === "undefined") { + throw new Error( + `WebGPUTextureStore: unsupported compressed texture format 0x${source.format.toString(16)}`, + ); + } + if (typeof record !== "undefined") { + this.retire(record.texture); + } + const gpuTexture = this.device.createTexture({ + label: "melonJS compressed texture", + size: [source.width, source.height], + format: metrics.format, + mipLevelCount: source.mipmaps.length, + // compressed formats cannot be render attachments + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + 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) + view: gpuTexture.createView({ + baseMipLevel: 0, + mipLevelCount: 1, + }), + source, + width: source.width, + height: source.height, + frameId: -1, + compressed: true, + bindGroupBySampler: new Map(), + }; + this.records.set(unit, record); + } + record.frameId = this.renderer.frameId; + this.lastRecord = record; + return this.bindGroupFor(record, texture, wrap); + } // prefer real pixel dimensions; HTMLVideoElement exposes them // through videoWidth/videoHeight (width/height default to 0) const width = source.width || source.videoWidth || 1; @@ -111,7 +158,12 @@ export default class WebGPUTextureStore { typeof record === "undefined" || record.width !== width || record.height !== height || - record.frameId === this.renderer.frameId + record.frameId === this.renderer.frameId || + // a recycled unit whose resident texture came from the + // compressed path cannot adopt an image source: its format + // is non-renderable and copyExternalImageToTexture would + // fail validation while the stale pixels kept serving + record.compressed === true ) { // (re)create at the source size; the replaced texture retires // at frame end — destroying it now would invalidate draws @@ -163,6 +215,19 @@ export default class WebGPUTextureStore { // recorded in the current frame record.frameId = this.renderer.frameId; + // stash for getResidentRecord — composers (the lit batcher) build + // combined bind groups from the raw view + this.lastRecord = record; + + return this.bindGroupFor(record, texture, wrap); + } + + /** + * the per-sampler material bind group for a resident record (shared by + * the image and compressed upload paths) + * @ignore + */ + bindGroupFor(record, texture, wrap) { const filter = typeof texture.filter === "string" ? texture.filter @@ -183,6 +248,20 @@ export default class WebGPUTextureStore { return bindGroup; } + /** + * Ensure the texture is resident (same upload/validation path as + * getBinding) and return its record — callers composing their own bind + * groups (the lit batcher pairs the color view with a normal map) need + * the raw view rather than the standard material group. + * @param {object} texture - the texture atlas to resolve + * @param {object} [options] - same options as getBinding + * @returns {object} the resident record ({texture, view, width, height, …}) + */ + getResidentRecord(texture, options) { + this.getBinding(texture, options); + return this.lastRecord; + } + /** * Dispose of a GPUTexture safely: while a frame is being recorded the * texture may be referenced by already-recorded draws (destroying it @@ -192,11 +271,7 @@ export default class WebGPUTextureStore { * @ignore */ retire(gpuTexture) { - if (this.renderer.commandEncoder !== null) { - this.renderer.retiredTextures.push(gpuTexture); - } else { - gpuTexture.destroy(); - } + this.renderer.retireTexture(gpuTexture); } /** diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 541db0f9c5..1638e623f1 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -11,8 +11,11 @@ import { on, RENDER_TARGET_CHANGED, } from "../../system/event.ts"; +import RadialGradientEffect from "../effects/radialGradient.js"; import { Gradient } from "../gradient.js"; import Renderer from "../renderer.js"; +import RenderTargetPool from "../rendertarget/render_target_pool.js"; +import WebGPURenderTarget from "../rendertarget/webgpurendertarget.js"; import { createAtlas, TextureAtlas } from "../texture/atlas.js"; import TextureCache from "../texture/cache.js"; import { dashPath, dashSegments } from "../utils/dash.js"; @@ -20,6 +23,13 @@ import { generateJoinCircles, generateTriangleFan, } from "../utils/tessellation.js"; +// pure CPU lighting math shared with the GL backend (published std140 +// layout, no GL calls) +import { + createLightUniformScratch, + packLights, +} from "../webgl/lighting/pack.ts"; +import WebGPULitQuadBatcher from "./batchers/lit_quad_batcher.js"; import WebGPUPrimitiveBatcher from "./batchers/primitive_batcher.js"; import WebGPUQuadBatcher from "./batchers/quad_batcher.js"; import WebGPUBatcher from "./batchers/webgpu_batcher.js"; @@ -29,10 +39,15 @@ import WebGPUPipelineCache, { DEPTH_STENCIL_FORMAT, normalizeBlendMode, } from "./pipeline/cache.js"; +import OrthogonalTMXLayerGPURenderer from "./renderers/tmxlayer/orthogonal.js"; +import { COMPRESSION_FEATURES } from "./texture/compressed.js"; +import { WebGPUFrameTexture } from "./texture/frametexture.js"; import WebGPUTextureStore from "./texture/store.js"; // scratch matrix for the affine-components form of transform() const tempMatrix = new Matrix3d(); +// scratch: the projection saved across a blitEffect quad +const blitSavedProjection = new Matrix3d(); /** * The **experimental** WebGPU renderer. @@ -104,10 +119,16 @@ export default class WebGPURenderer extends Renderer { /** @type {"glsl"|"wgsl"|null} */ this.shaderLanguage = "wgsl"; - // capability flags stay at their base-class `false` defaults - // (supportsDepthBuffer / supportsShaderTileLayers / - // supportsRetainedMesh): they describe what the backend can DO - // today, not what it will grow — each flips when its path lands + // 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; + // lazy orientation-specific GPU tilemap renderer (device-scoped: + // dropped on device loss, rebuilt on first use) + /** @ignore */ + this.orthogonalTMXRenderer = undefined; // create a texture cache this.cache = new TextureCache(this); @@ -116,12 +137,11 @@ export default class WebGPURenderer extends Renderer { // aliasing as the WebGL backend this.currentTransform = this.renderState.currentTransform; - // active Gradient (setColor(Gradient)); phase 1 only honors it on - // fillRect via the Canvas-baked gradient texture + // 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 */ this.currentGradient = null; - /** @ignore */ - this.gradientShapeWarned = false; // scratch vertices for fillRect (2 triangles) and fillPolygon /** @ignore */ @@ -165,6 +185,36 @@ export default class WebGPURenderer extends Renderer { this.commandEncoder = null; /** @ignore */ 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 */ + this.currentRenderTarget = null; + // consumed as the next pass's colorLoadOp "clear" (fresh target) + /** @ignore */ + this.pendingColorClear = false; + /** @ignore */ + this.pendingClearValue = null; + /** @ignore */ + this.pendingStencilClear = false; + // the canvas GPUTexture handle of the current frame — kept beside its + // view because captureFrame copies from the TEXTURE, not the view + /** @ignore */ + this.frameTexture = null; + // the shared frame-capture slot (screen_texture builtin), lazy + /** @ignore */ + 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 */ + this.stubTexture = null; + // per-depth projection save slots for nested post-effect passes + /** @ignore */ + this.effectProjectionStack = []; + /** @ignore */ + this.effectPassDepth = 0; + // per-bind effect uniform snapshots (created by init) + /** @ignore */ + this.effectUniformArena = null; // monotonically increasing frame id — the texture store uses it to // detect same-frame content changes that need a fresh texture /** @ignore */ @@ -250,7 +300,13 @@ export default class WebGPURenderer extends Renderer { } this.adapter = adapter; - this.device = await adapter.requestDevice(); + // request the compressed-texture families the adapter offers, so + // the loader's dds/ktx/pvr/pkm assets can upload natively + this.device = await adapter.requestDevice({ + requiredFeatures: COMPRESSION_FEATURES.filter((feature) => { + return adapter.features.has(feature); + }), + }); // identify the driver in the console header, same as the WebGL // backend does through its debug-renderer-info strings. The @@ -288,6 +344,9 @@ export default class WebGPURenderer extends Renderer { device: this.device, format: this.preferredFormat, alphaMode: this.settings.transparent ? "premultiplied" : "opaque", + // COPY_SRC on top of the default: captureFrame() copies the + // canvas texture into the shared capture (screen_texture builtin) + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, }); // GPU-facing infrastructure, in dependency order @@ -303,6 +362,11 @@ export default class WebGPURenderer extends Renderer { this.vertexArena = new WebGPUBufferArena(this.device, { label: "melonJS vertex arena", }); + this.effectUniformArena = new WebGPUBufferArena(this.device, { + label: "melonJS effect uniform arena", + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + pageSize: 64 << 10, + }); this.textureStore = new WebGPUTextureStore(this); this.createDepthTexture(); @@ -311,19 +375,25 @@ export default class WebGPURenderer extends Renderer { if (this.batchers.size === 0) { this.addBatcher(new WebGPUQuadBatcher(this), "quad", true); this.addBatcher(new WebGPUPrimitiveBatcher(this), "primitive"); + this.addBatcher(new WebGPULitQuadBatcher(this), "litQuad"); } this.isContextValid = true; } /** - * (re)create the depth-stencil attachment at the current canvas size + * (re)create the shared depth-stencil attachment. Defaults to the canvas + * size; `beginPass` passes the active target's size because every + * attachment of a pass must have identical dimensions (in the 2D flow + * all targets are canvas-sized, so this recreates nothing per frame). + * @param {number} [width] - required width (defaults to the canvas) + * @param {number} [height] - required height (defaults to the canvas) * @ignore */ - createDepthTexture() { + createDepthTexture(width, height) { const canvas = this.getCanvas(); - const width = Math.max(1, canvas.width); - const height = Math.max(1, canvas.height); + width = Math.max(1, width ?? canvas.width); + height = Math.max(1, height ?? canvas.height); if ( this.depthTexture && this.depthTexture.width === width && @@ -331,7 +401,10 @@ export default class WebGPURenderer extends Renderer { ) { return; } - this.depthTexture?.destroy(); + if (this.depthTexture) { + // recorded passes may reference the old attachment — retire it + this.retireTexture(this.depthTexture); + } this.depthTexture = this.device.createTexture({ label: "melonJS depth-stencil", size: [width, height], @@ -364,7 +437,15 @@ export default class WebGPURenderer extends Renderer { this.vertexArena.reset(); this.uniformRing.reset(); + this.effectUniformArena.reset(); this.frameId++; + // a frame always begins on the canvas, outside any effect bracket + // (an exception that escaped between begin/endPostEffect last frame + // must not leave the projection stack wound up) + this.currentRenderTarget = null; + this.pendingColorClear = false; + this.effectPassDepth = 0; + this._renderTargetPool?.reset?.(); // clear() also resets the stroke line width, like the GL backend this.lineWidth = 1; this.stencilMode = "none"; @@ -397,16 +478,46 @@ export default class WebGPURenderer extends Renderer { label: "melonJS frame", }); } - if (this.frameTextureView === null) { - this.frameTextureView = this.context.getCurrentTexture().createView(); + // the pass's color attachment: the active render target's view, or + // the canvas texture (acquired once per frame) + let colorView; + const target = this.currentRenderTarget; + if (target !== null) { + colorView = target.colorView; + } else { + if (this.frameTextureView === null) { + this.frameTexture = this.context.getCurrentTexture(); + this.frameTextureView = this.frameTexture.createView(); + } + colorView = this.frameTextureView; + } + // a retarget with a pending clear opens with a clearing load — the + // WebGPU analogue of clearRenderTarget, no clearing draw needed + let colorLoadOp = opts.colorLoadOp; + let clearValue = opts.clearValue; + let stencilLoadOp = opts.stencilLoadOp; + if (typeof colorLoadOp === "undefined" && this.pendingColorClear) { + colorLoadOp = "clear"; + clearValue = this.pendingClearValue ?? { r: 0, g: 0, b: 0, a: 0 }; + if (typeof stencilLoadOp === "undefined" && this.pendingStencilClear) { + stencilLoadOp = "clear"; + } } + 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) + const [width, height] = this.getTargetSize(); + this.createDepthTexture(width, height); this.renderPass = this.commandEncoder.beginRenderPass({ label: "melonJS pass", colorAttachments: [ { - view: this.frameTextureView, - loadOp: opts.colorLoadOp ?? "load", - clearValue: opts.clearValue, + view: colorView, + loadOp: colorLoadOp ?? "load", + clearValue, storeOp: "store", }, ], @@ -415,18 +526,443 @@ export default class WebGPURenderer extends Renderer { depthLoadOp: "clear", depthClearValue: 1.0, depthStoreOp: "discard", - stencilLoadOp: opts.stencilLoadOp ?? "load", + stencilLoadOp: stencilLoadOp ?? "load", stencilClearValue: 0, stencilStoreOp: "store", }, }); - const canvas = this.getCanvas(); - this.renderPass.setViewport(0, 0, canvas.width, canvas.height, 0, 1); + this.renderPass.setViewport(0, 0, width, height, 0, 1); 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 + // retargets, captures) keeps testing against the right level + this.renderPass.setStencilReference(this.maskVisibleRef); // pipeline/bind state does not carry across passes this.currentPipeline = null; } + /** + * pixel dimensions of the active draw destination — the current render + * target, or the canvas + * @returns {[number, number]} [width, height] + * @ignore + */ + getTargetSize() { + const target = this.currentRenderTarget; + if (target !== null) { + return [target.width, target.height]; + } + const canvas = this.getCanvas(); + return [canvas.width, canvas.height]; + } + + /** + * Switch the active draw destination — a pass break under the recording + * model: pending vertices drain, the open pass ends, and the next pass + * (opened lazily by `ensurePass`) targets the given render target's + * color view, or the canvas when `target` is null. + * @param {import("../rendertarget/webgpurendertarget.js").default|null} target - the render target, or null for the canvas + * @param {object} [options] - retarget options + * @param {boolean} [options.clear=false] - open the next pass with a clearing color load + * @ignore + */ + setRenderTarget(target, options = {}) { + this.currentBatcher?.flush(); + if (this.renderPass !== null) { + this.renderPass.end(); + this.renderPass = null; + } + this.currentRenderTarget = target ?? null; + this.pendingColorClear = + options.clear === true || (target?.pendingClear ?? false); + this.pendingClearValue = options.clearValue ?? null; + this.pendingStencilClear = options.clearStencil === true; + if (target) { + target.pendingClear = false; + } + } + + /** + * Dispose of a GPUTexture safely: while a frame is recording, the + * texture may be referenced by already-recorded draws — destroying it + * would make the whole `queue.submit()` fail validation — so it parks + * on the retired list and is destroyed after submit (or abandon). + * @param {GPUTexture} texture - the texture to dispose of + * @ignore + */ + retireTexture(texture) { + if (this.commandEncoder !== null) { + this.retiredTextures.push(texture); + } else { + texture.destroy(); + } + } + + /** + * 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 + */ + getStubTextureView() { + if (this.stubTexture === null) { + this.stubTexture = this.device.createTexture({ + label: "melonJS stub texture", + size: [1, 1], + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + this.device.queue.writeTexture( + { texture: this.stubTexture }, + new Uint8Array(4), + {}, + [1, 1], + ); + this.stubTextureView = this.stubTexture.createView(); + } + return this.stubTextureView; + } + + /** + * disable the scissor test — pass-model realization of the GL call + * (pending vertices drain, the open pass widens back to the target) + * @override + */ + disableScissor() { + if (this.scissorActive === true) { + this.currentBatcher?.flush(); + this.scissorActive = false; + this.applyScissor(); + } + } + + /** + * Capture everything drawn so far into the shared frame-capture slot — + * the backing of the `screen_texture` effect builtin. A pass break: the + * open pass ends, an encoder-ordered `copyTextureToTexture` snapshots + * the active destination (render target or canvas), and drawing resumes + * lazily with a preserving load. The capture is copy-only (never a + * 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 + */ + captureFrame() { + return this.toFrameTexture(); + } + + /** + * Capture the current frame — everything drawn to the active target so + * far — into a {@link Texture2d}, entirely on the GPU (an encoder-ordered + * `copyTextureToTexture`; no readback round-trip). Same contract as the + * WebGL backend's `toFrameTexture`: a shared, renderer-owned slot by + * default, `target: null` for a fresh caller-owned capture, or a prior + * capture as `target` to refresh it in place; `options.region` captures + * a sub-region (framebuffer pixels, bottom-left origin — converted to + * this backend's top-left copy origin internally). + * + * Two documented divergences from the GL capture: + * - alpha is preserved (the GL path captures into an opaque RGB texture) + * - row 0 of the capture is the TOP of the frame (matching `screen_uv`), + * where the GL capture is bottom-up — GLSL bodies sampling a capture + * flip with `1.0 - uv.y`; their WGSL twins must not. + * @param {object} [options] + * @param {Texture2d|null} [options.target] - omit for the shared renderer + * slot; a prior capture to refresh it in place; `null` to mint a fresh, + * caller-owned capture (`destroy()` it yourself when done) + * @param {Bounds|{x: number, y: number, width: number, height: number}} [options.region] - capture + * only this sub-region; defaults to the whole frame + * @returns {Texture2d|null} a GPU-resident texture holding the captured + * frame, or null when no device is available + */ + toFrameTexture(options = {}) { + if (typeof this.device === "undefined") { + return null; + } + + const [fullWidth, fullHeight] = this.getTargetSize(); + + // resolve the capture rect (same clamp rules as the GL backend: + // origin clamped into the frame first, then sized to what remains) + let x = 0; + let y = 0; + let w = fullWidth; + let h = fullHeight; + const region = options.region; + if (typeof region !== "undefined") { + x = Math.min(Math.max(0, Math.floor(region.x || 0)), fullWidth - 1); + y = Math.min(Math.max(0, Math.floor(region.y || 0)), fullHeight - 1); + const rw = Number.isFinite(region.width) + ? Math.ceil(region.width) + : fullWidth - x; + const rh = Number.isFinite(region.height) + ? Math.ceil(region.height) + : fullHeight - y; + w = Math.max(1, Math.min(fullWidth - x, rw)); + h = Math.max(1, Math.min(fullHeight - y, rh)); + } + + // a non-null target must be a capture THIS renderer returned — any + // other Texture2d has no GPU backing here, and a foreign capture + // would retire textures on the wrong device + if (typeof options.target !== "undefined" && options.target !== null) { + if (!(options.target instanceof WebGPUFrameTexture)) { + throw new Error( + "WebGPURenderer.toFrameTexture: `target` must be a capture returned by this method", + ); + } + if (options.target.renderer !== this) { + throw new Error( + "WebGPURenderer.toFrameTexture: `target` belongs to a different renderer", + ); + } + } + + // drain pending geometry + end the pass: the copy is encoder-ordered, + // so it sees exactly the draws recorded before this point + this.currentBatcher?.flush(); + if (this.renderPass !== null) { + this.renderPass.end(); + this.renderPass = null; + } + + // resolve the source texture: the active target, or the canvas + // (acquired now if nothing drew yet this frame) + let source; + if (this.currentRenderTarget !== null) { + source = this.currentRenderTarget.texture; + } else { + if (this.frameTexture === null) { + this.frameTexture = this.context.getCurrentTexture(); + this.frameTextureView = this.frameTexture.createView(); + } + source = this.frameTexture; + } + + // destination: the shared slot, a fresh caller-owned capture, or the + // given capture refreshed in place + const shared = typeof options.target === "undefined"; + let frame = shared + ? this.captureTexture + : options.target === null + ? undefined + : options.target; + + if (typeof frame === "undefined") { + frame = new WebGPUFrameTexture(this, w, h); + if (shared) { + this.captureTexture = frame; + } + } else if ( + frame.width !== w || + frame.height !== h || + frame.gpuTexture === null + ) { + // size change or released backing: reallocate keeping the object + // identity — generation advances so stale bind groups re-key + frame.realloc(w, h); + } + + if (this.commandEncoder === null) { + this.commandEncoder = this.device.createCommandEncoder({ + label: "melonJS frame", + }); + } + // convert the public bottom-left region origin to the copy's + // top-left one + this.commandEncoder.copyTextureToTexture( + { texture: source, origin: [x, fullHeight - y - h] }, + { texture: frame.gpuTexture }, + [w, h], + ); + return frame; + } + + /** + * Begin a post-effect pass for the given renderable — the WebGPU + * realization of the WebGL FBO path: the renderable's whole content + * renders into a pooled offscreen target, composited by + * {@link WebGPURenderer#endPostEffect}. The single-effect fast path + * (customShader, no offscreen target) mirrors the WebGL split. + * @param {Renderable} renderable - the renderable carrying postEffects + * @returns {boolean} true when an offscreen pass began + * @override + */ + beginPostEffect(renderable) { + const effects = renderable.postEffects.filter((fx) => { + return fx.enabled !== false; + }); + if (effects.length === 0) { + this.customShader = undefined; + return false; + } + // single effect on a non-managed renderable: fast path (no target) + if (effects.length === 1 && !renderable._postEffectManaged) { + this.customShader = effects[0]; + return false; + } + + // pooled path: children render with the default pipeline + this.customShader = undefined; + + const isCamera = renderable._postEffectManaged; + const canvas = this.getCanvas(); + + this.save(); + // save the current projection (not part of the render state stack) — + // one preallocated slot per nesting depth + let savedProjection = this.effectProjectionStack[this.effectPassDepth]; + if (typeof savedProjection === "undefined") { + savedProjection = this.effectProjectionStack[this.effectPassDepth] = + new Matrix3d(); + } + savedProjection.copy(this.projectionMatrix); + this.effectPassDepth++; + + this._renderTargetPool ??= new RenderTargetPool((w, h) => { + return new WebGPURenderTarget(this, w, h); + }); + const rt = this._renderTargetPool.begin( + isCamera, + effects.length, + canvas.width, + canvas.height, + ); + // retarget with the appropriate clear: a camera's offscreen pass + // starts like a frame (background color + fresh stencil), a + // sprite's starts transparent so unpainted texels stay see-through + if (isCamera) { + const [r, g, b, a] = this.backgroundColor.toArray(); + this.setRenderTarget(rt, { + clear: true, + clearValue: { r, g, b, a }, + clearStencil: true, + }); + } else { + this.setRenderTarget(rt, { clear: true }); + } + this.disableScissor(); + this.setGlobalAlpha(1.0); + this.setBlendMode("normal"); + return true; + } + + /** + * End a post-effect pass: retarget to the parent (or the canvas), + * refresh the shared frame capture for `screen_texture` consumers, and + * composite the offscreen content through the effect chain — one blit + * per effect, ping-ponging between pool targets for chains. + * @param {Renderable} renderable - the renderable passed to beginPostEffect + * @override + */ + endPostEffect(renderable) { + const effects = renderable.postEffects.filter((fx) => { + return fx.enabled !== false; + }); + if (effects.length === 0) { + return; + } + // the fast path set customShader — nothing offscreen to composite + if (effects.length === 1 && !renderable._postEffectManaged) { + return; + } + + const isCamera = renderable._postEffectManaged; + const pool = this._renderTargetPool; + const rt1 = pool.getCaptureTarget(); + const rt2 = pool.getPingPongTarget(); + const keepBlend = !isCamera; + const canvas = this.getCanvas(); + const w = canvas.width; + const h = canvas.height; + + // `screen_texture` builtin: for a CAMERA the "screen" is the scene + // itself — capture the still-active offscreen target; for a sprite + // chain it's everything behind it — captured after the retarget + const needsScreenTexture = effects.some((fx) => { + return fx._screenTextureUniforms?.length > 0; + }); + if (needsScreenTexture && isCamera) { + this.captureFrame(); + } + + const parentRT = pool.end(); + + // clip the composite to the camera viewport for non-default cameras + // (the offscreen content sits at the camera's screen position) + if (isCamera && renderable.isDefault === false) { + this.clipRect( + renderable.screenX, + renderable.screenY, + renderable.width, + renderable.height, + ); + } + + this.setRenderTarget(parentRT); + emit(RENDER_TARGET_CHANGED, this); + + if (needsScreenTexture && !isCamera) { + this.captureFrame(); + } + + if (effects.length === 1) { + this.blitEffect(rt1, 0, 0, w, h, effects[0], keepBlend); + } else { + // multi-pass: ping-pong between the two pool targets + let src = rt1; + let dst = rt2; + for (let i = 0; i < effects.length - 1; i++) { + this.setRenderTarget(dst, { clear: true }); + this.blitEffect(src, 0, 0, w, h, effects[i], false); + const tmp = src; + src = dst; + dst = tmp; + } + this.setRenderTarget(parentRT); + this.blitEffect(src, 0, 0, w, h, effects[effects.length - 1], keepBlend); + } + + if (isCamera && renderable.isDefault === false) { + this.disableScissor(); + } + + // restore renderer state and the projection saved in beginPostEffect + this.restore(); + this.effectPassDepth--; + this.projectionMatrix.copy( + this.effectProjectionStack[this.effectPassDepth], + ); + this.pushFrameGlobals(); + } + + /** + * Draw a pooled render target through an effect's pipeline as a + * screen-space quad — the compositing primitive of the post-effect + * chain. Blending is disabled for camera blits (the target is fully + * composited) and kept for per-sprite blits (transparent texels must + * not overwrite the scene). + * @param {import("../rendertarget/webgpurendertarget.js").default} source - the target to sample + * @param {number} x - destination x + * @param {number} y - destination y + * @param {number} width - destination width + * @param {number} height - destination height + * @param {ShaderEffect} effect - the effect to composite with + * @param {boolean} [keepBlend=false] - keep the current blend mode + * @override + */ + blitEffect(source, x, y, width, height, effect, keepBlend = false) { + const batcher = this.setBatcher("quad"); + // screen-space ortho for the blit quad (not the camera's world + // projection); restored — with a fresh frame-globals slot each way — + // right after + blitSavedProjection.copy(this.projectionMatrix); + this.projectionMatrix.ortho(0, width, height, 0, -1, 1); + this.pushFrameGlobals(); + batcher.blitTexture(source, x, y, width, height, effect, keepBlend); + this.projectionMatrix.copy(blitSavedProjection); + this.pushFrameGlobals(); + } + /** * a draw is about to be recorded outside the clear()/flush() bracket * (unit tests, user code) — open a pass that preserves the canvas. @@ -460,6 +996,7 @@ export default class WebGPURenderer extends Renderer { this.device.queue.submit([this.commandEncoder.finish()]); this.commandEncoder = null; this.frameTextureView = null; + this.frameTexture = null; } this.destroyRetiredTextures(); } @@ -481,6 +1018,11 @@ export default class WebGPURenderer extends Renderer { this.commandEncoder = null; } this.frameTextureView = null; + this.frameTexture = null; + // an abandoned frame may have died inside a post-effect bracket — + // never leave an offscreen target active for the next frame + this.currentRenderTarget = null; + this.pendingColorClear = false; this.currentPipeline = null; // the recorded draws are dropped with the command buffer, so any // texture retired during the frame can go now @@ -509,18 +1051,18 @@ export default class WebGPURenderer extends Renderer { if (this.renderPass === null) { return; } - const canvas = this.getCanvas(); + const [width, height] = this.getTargetSize(); if (this.scissorActive === true) { // clamp defensively: an out-of-attachment scissor is a WebGPU // validation error that invalidates the whole pass (GL clamps) const s = this.currentScissor; - const x = Math.min(Math.max(s[0], 0), canvas.width); - const y = Math.min(Math.max(s[1], 0), canvas.height); - const w = Math.min(Math.max(s[2], 0), canvas.width - x); - const h = Math.min(Math.max(s[3], 0), canvas.height - y); + const x = Math.min(Math.max(s[0], 0), width); + const y = Math.min(Math.max(s[1], 0), height); + const w = Math.min(Math.max(s[2], 0), width - x); + const h = Math.min(Math.max(s[3], 0), height - y); this.renderPass.setScissorRect(x, y, w, h); } else { - this.renderPass.setScissorRect(0, 0, canvas.width, canvas.height); + this.renderPass.setScissorRect(0, 0, width, height); } } @@ -593,6 +1135,31 @@ export default class WebGPURenderer extends Renderer { this.restore(); } + /** + * Draw a TMX tile layer: WGSL-eligible layers (`renderMode === + * "shader"`) draw through the GPU tile path — one quad per tileset, + * GID lookup in a per-layer index texture; everything else falls + * through to the base per-tile loop. + * @param {object} layer - the TMXLayer to draw + * @param {object} rect - the visible region in world coords + * @override + */ + drawTileLayer(layer, rect) { + if (layer.renderMode === "shader" && layer.orientation === "orthogonal") { + // device-scoped, lazily (re)built: a device loss rebuilds the + // pipeline cache, detected via the epoch stamp + if ( + this.orthogonalTMXRenderer === undefined || + this.orthogonalTMXRenderer.epoch !== this.pipelineCache.epoch + ) { + this.orthogonalTMXRenderer = new OrthogonalTMXLayerGPURenderer(this); + } + this.orthogonalTMXRenderer.draw(layer, rect); + return; + } + super.drawTileLayer(layer, rect); + } + /** * Add a batcher to this renderer. * @param {WebGPUBatcher} batcher - a batcher instance @@ -887,7 +1454,26 @@ export default class WebGPURenderer extends Renderer { dy |= 0; } - this.setBatcher("quad"); + // same lit gate as the GL backend: only normal-mapped sprites in a + // lit scene pay the lit path; everything else stays on "quad". + // Two extra conditions this backend needs: + // - an active customShader (the single-effect fast path) wins over + // the lit gate — the lit pipeline has no effect stage, so routing + // the quad there would silently drop the effect. A deliberate + // divergence from GL (which runs the effect's shader over its lit + // vertex stream): here the sprite draws effected-but-unlit rather + // than lit-but-uneffected, and never silently loses the effect. + // - the light block must have been snapshotted THIS frame: the lit + // batcher's binding points into the per-frame effect-uniform arena, + // so a stale (previous-frame) binding would sample reused bytes. + const lit = this.batchers.get("litQuad"); + const useLit = + typeof lit !== "undefined" && + this.customShader == null && + this.activeLightCount > 0 && + this.currentNormalMap !== null && + lit.hasCurrentLightBinding(this.frameId); + this.setBatcher(useLit ? "litQuad" : "quad"); const texture = this.cache.get(image); // Video sources need their GPU texture refreshed as the video @@ -916,9 +1502,180 @@ export default class WebGPURenderer extends Renderer { uvs[3], this.currentTint.toUint32(this.getGlobalAlpha()), reupload, + useLit ? this.currentNormalMap : undefined, ); } + /** + * The compressed-texture families this device supports, in the same + * shape as the GL backend (one key per family; supported families hold + * an object of WebGL format constants — the values the loader parsers + * emit — unsupported ones are null, so the shared + * hasSupportedCompressedFormats works unchanged). PVRTC has no WebGPU + * equivalent and stays null. + * @returns {object} one key per extension family + * @override + */ + getSupportedCompressedTextureFormats() { + if (typeof this.supportedCompressedFormats === "undefined") { + const features = this.device?.features; + const bc = features?.has("texture-compression-bc") === true; + const etc2 = features?.has("texture-compression-etc2") === true; + const astc = features?.has("texture-compression-astc") === true; + const table = { + astc: astc + ? { + COMPRESSED_RGBA_ASTC_4x4_KHR: 0x93b0, + COMPRESSED_RGBA_ASTC_5x4_KHR: 0x93b1, + COMPRESSED_RGBA_ASTC_5x5_KHR: 0x93b2, + COMPRESSED_RGBA_ASTC_6x5_KHR: 0x93b3, + COMPRESSED_RGBA_ASTC_6x6_KHR: 0x93b4, + COMPRESSED_RGBA_ASTC_8x5_KHR: 0x93b5, + COMPRESSED_RGBA_ASTC_8x6_KHR: 0x93b6, + COMPRESSED_RGBA_ASTC_8x8_KHR: 0x93b7, + COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR: 0x93d0, + COMPRESSED_SRGB8_ALPHA8_ASTC_5x4_KHR: 0x93d1, + COMPRESSED_SRGB8_ALPHA8_ASTC_5x5_KHR: 0x93d2, + COMPRESSED_SRGB8_ALPHA8_ASTC_6x5_KHR: 0x93d3, + COMPRESSED_SRGB8_ALPHA8_ASTC_6x6_KHR: 0x93d4, + COMPRESSED_SRGB8_ALPHA8_ASTC_8x5_KHR: 0x93d5, + COMPRESSED_SRGB8_ALPHA8_ASTC_8x6_KHR: 0x93d6, + COMPRESSED_SRGB8_ALPHA8_ASTC_8x8_KHR: 0x93d7, + } + : null, + bptc: bc ? { COMPRESSED_RGBA_BPTC_UNORM_EXT: 0x8e8c } : null, + s3tc: bc + ? { + COMPRESSED_RGB_S3TC_DXT1_EXT: 0x83f0, + COMPRESSED_RGBA_S3TC_DXT3_EXT: 0x83f2, + COMPRESSED_RGBA_S3TC_DXT5_EXT: 0x83f3, + } + : null, + s3tc_srgb: bc + ? { + COMPRESSED_SRGB_S3TC_DXT1_EXT: 0x8c4c, + COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT: 0x8c4d, + COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT: 0x8c4e, + COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT: 0x8c4f, + } + : null, + pvrtc: null, + // ETC1 payloads upload as ETC2 rgb8 (superset) + etc1: etc2 ? { COMPRESSED_RGB_ETC1_WEBGL: 0x8d64 } : null, + etc2: etc2 + ? { + COMPRESSED_RGB8_ETC2: 0x9274, + COMPRESSED_SRGB8_ETC2: 0x9275, + COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2: 0x9276, + COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2: 0x9277, + COMPRESSED_RGBA8_ETC2_EAC: 0x9278, + COMPRESSED_SRGB8_ALPHA8_ETC2_EAC: 0x9279, + COMPRESSED_R11_EAC: 0x9270, + COMPRESSED_SIGNED_R11_EAC: 0x9271, + COMPRESSED_RG11_EAC: 0x9272, + COMPRESSED_SIGNED_RG11_EAC: 0x9273, + } + : null, + }; + // no device = the renegotiation window after a device loss: the + // all-null table is only valid for this instant and must not be + // memoized, or the replacement device's families would stay + // locked to null + if (typeof this.device === "undefined") { + return table; + } + this.supportedCompressedFormats = table; + } + return this.supportedCompressedFormats; + } + + /** + * Pack the active 2D lights and hand the std140 block to the lit + * batcher — the WebGPU realization of the backend-neutral lighting + * contract (Camera2d calls this once per camera per frame). + * @param {Set|Array} lights - active lights + * @param {Color} ambient - the ambient lighting floor + * @param {number} [translateX=0] - camera translate x + * @param {number} [translateY=0] - camera translate y + * @override + */ + setLightUniforms(lights, ambient, translateX = 0, translateY = 0) { + if (this.lightUniformsScratch === undefined) { + this.lightUniformsScratch = createLightUniformScratch(); + } + const packed = packLights( + lights, + ambient, + translateX, + translateY, + this.lightUniformsScratch, + ); + this.activeLightCount = packed.count; + const lit = this.batchers.get("litQuad"); + if (lit && typeof lit.setLightUniforms === "function") { + lit.setLightUniforms(packed); + } + } + + /** + * Draw a Light2d glow quad through the radial-gradient effect's + * single-effect fast path — the light's color and intensity ride the + * per-vertex tint, so back-to-back lights share the same pipeline. + * @param {Light2d} light - the light to draw + * @override + */ + drawLight(light) { + if (this.lightShader === undefined) { + this.lightShader = new RadialGradientEffect(this); + } + const previousShader = this.customShader; + this.customShader = this.lightShader; + const batcher = this.setBatcher("quad"); + batcher.addQuad( + this.getLightAtlas(), + light.pos.x, + light.pos.y, + light.width, + light.height, + 0, + 0, + 1, + 1, + // pack the light's color (RGB) and intensity (A) into the + // vertex tint — the effect reads color.rgb / color.a + light.color.toUint32(light.intensity), + ); + this.customShader = previousShader; + } + + /** + * Lazy-init a shared 1×1 white TextureAtlas used as the source texture + * for drawLight's procedural effect (same rationale as the GL backend) + * @returns {TextureAtlas} + * @ignore + */ + getLightAtlas() { + if (this.lightAtlas === undefined) { + const canvas = globalThis.document + ? globalThis.document.createElement("canvas") + : new OffscreenCanvas(1, 1); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, 1, 1); + // cache=false: the atlas is only ever drawn directly through + // addQuad, so it needs no global TextureCache registration (and + // this renderer method must not reach for the global game) + this.lightAtlas = new TextureAtlas( + createAtlas(1, 1, "lightWhite", "no-repeat"), + canvas, + false, + ); + } + return this.lightAtlas; + } + /** * Draw a pattern within the given rectangle. * @param {TextureAtlas} pattern - pattern object returned by {@link WebGPURenderer#createPattern} @@ -1113,19 +1870,72 @@ export default class WebGPURenderer extends Renderer { } /** - * gradient fills of arbitrary shapes need the stencil gradient-mask - * machinery (deferred with post effects) — fall back to a solid fill - * with a one-time console warning + * Fill an arbitrary shape with the current gradient by clipping the + * baked-gradient rect to the shape through the stencil — the GL + * backend's #gradientMask re-expressed as pipeline stencil variants. + * + * Outside a mask: the stencil is cleared (a pass break), the shape's + * pixels are stamped with reference 1 ("tag": always/replace, color + * writes off), and the gradient rect draws under the "test" mode. + * Inside a mask: the shape's VISIBLE pixels (stencil low 7 bits equal + * to the mask's visible value — mask levels never use the high bit) + * get a high-bit marker via "mark", the gradient clips to the marker, + * then the marker is stripped and the mask's exact render test is + * re-installed. * @ignore */ - warnGradientShape() { - if (this.gradientShapeWarned !== true) { - this.gradientShapeWarned = true; - console.warn( - "WebGPURenderer: gradient fills of non-rectangular shapes are " + - "not supported yet — falling back to a solid fill", - ); + gradientMask(drawShape, x, y, w, h) { + const grad = this.currentGradient; + const hasMask = this.maskLevel > 0; + // the tag/untag phases re-enter the shape's public fill method — + // null the gradient so they take the plain solid path + this.currentGradient = null; + + this.currentBatcher?.flush(); + + const visibleRef = this.maskVisibleRef; + let markRef = 1; + + if (hasMask) { + markRef = 0x80 | visibleRef; + this.stencilMode = "mark"; + this.ensurePass().setStencilReference(markRef); + } else { + // stencil clear = pass break (color survives via loadOp "load") + if (this.renderPass !== null) { + this.renderPass.end(); + this.renderPass = null; + } + this.beginPass({ stencilLoadOp: "clear" }); + this.stencilMode = "tag"; + this.ensurePass().setStencilReference(markRef); + } + drawShape(); + this.currentBatcher?.flush(); + + // clip the gradient fill (a baked-texture quad) to the marked pixels + this.stencilMode = "test"; + this.ensurePass().setStencilReference(markRef); + this.currentGradient = grad; + this.fillRect(x, y, w, h); + this.currentBatcher?.flush(); + + if (hasMask) { + // clear the marker: replace writes the reference — a no-op on + // unmarked visible pixels, and it strips the high bit from the + // marked ones + this.currentGradient = null; + this.stencilMode = "mark"; + this.ensurePass().setStencilReference(visibleRef); + drawShape(); + this.currentBatcher?.flush(); + this.currentGradient = grad; + // re-install the exact render test setMask had established + this.stencilMode = "test"; + } else { + this.stencilMode = "none"; } + this.ensurePass().setStencilReference(this.maskVisibleRef); } /** @@ -1160,7 +1970,16 @@ export default class WebGPURenderer extends Renderer { */ fillArc(x, y, radius, start, end, antiClockwise = false) { if (this.currentGradient) { - this.warnGradientShape(); + this.gradientMask( + () => { + this.fillArc(x, y, radius, start, end, antiClockwise); + }, + x - radius, + y - radius, + radius * 2, + radius * 2, + ); + return; } this.setBatcher("primitive"); let diff = Math.abs(end - start); @@ -1214,7 +2033,16 @@ export default class WebGPURenderer extends Renderer { */ fillEllipse(x, y, w, h) { if (this.currentGradient) { - this.warnGradientShape(); + this.gradientMask( + () => { + this.fillEllipse(x, y, w, h); + }, + x - w, + y - h, + w * 2, + h * 2, + ); + return; } this.setBatcher("primitive"); const segments = Math.max( @@ -1322,7 +2150,35 @@ export default class WebGPURenderer extends Renderer { */ fillPolygon(poly) { if (this.currentGradient) { - this.warnGradientShape(); + const bounds = poly.getBounds(); + // translate to polygon's local space so gradient coords match + this.translate(poly.pos.x, poly.pos.y); + this.gradientMask( + () => { + // draw polygon vertices directly (already translated) + this.setBatcher("primitive"); + const indices = poly.getIndices(); + const points = poly.points; + const verts = this.polyVerts; + const len = indices.length; + while (verts.length < len) { + verts.push({ x: 0, y: 0 }); + } + for (let i = 0; i < len; i++) { + const src = points[indices[i]]; + verts[i].x = src.x; + verts[i].y = src.y; + } + this.currentBatcher.drawVertices("triangle-list", verts, len); + }, + // use local bounds (subtract pos since getBounds includes it) + bounds.x - poly.pos.x, + bounds.y - poly.pos.y, + bounds.width, + bounds.height, + ); + this.translate(-poly.pos.x, -poly.pos.y); + return; } this.setBatcher("primitive"); this.translate(poly.pos.x, poly.pos.y); @@ -1447,7 +2303,16 @@ export default class WebGPURenderer extends Renderer { */ fillRoundRect(x, y, width, height, radius) { if (this.currentGradient) { - this.warnGradientShape(); + this.gradientMask( + () => { + this.fillRoundRect(x, y, width, height, radius); + }, + x, + y, + width, + height, + ); + return; } this.setBatcher("primitive"); const r = Math.min(radius, width / 2, height / 2); @@ -1684,6 +2549,9 @@ 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 + this.batchers.get("litQuad")?.clearMaterialCache(); } /** @@ -1695,6 +2563,9 @@ 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 + this.batchers.get("litQuad")?.clearMaterialCache(); } /** @@ -1709,9 +2580,25 @@ export default class WebGPURenderer extends Renderer { this.vertexArena?.destroy(); this.uniformRing?.destroy(); this.pipelineCache?.clear(); + // device-scoped post-effect state: the shared capture, pool targets + // and stub die with the device; effect GPU realizations self-heal + // via the pipeline-cache epoch + this.captureTexture = undefined; + this._renderTargetPool?.destroy(); + this._renderTargetPool = null; + this.effectUniformArena?.destroy(); + this.effectUniformArena = null; + this.stubTexture = null; + this.stubTextureView = null; + this.currentRenderTarget = null; + // tile-path lookup textures died with the device; the epoch check + // in drawTileLayer rebuilds the renderer lazily + this.orthogonalTMXRenderer = undefined; this.depthTexture = null; this.device = undefined; this.adapter = undefined; + // the replacement device may offer different compression families + this.supportedCompressedFormats = undefined; // renegotiate + rebuild (init also re-registers batcher layouts); // resident textures lazily re-upload because the cache still maps // sources to atlases while the unit assignments start fresh @@ -1738,6 +2625,11 @@ export default class WebGPURenderer extends Renderer { */ reset() { this.abandonFrame(); + // a reset can land mid-post-effect-pass — unwind the bracket state + this.effectPassDepth = 0; + this.customShader = undefined; + // level transition: free the per-layer/per-tileset lookup textures + this.orthogonalTMXRenderer?.reset(); super.reset(); // re-init batchers when recovering from a device loss, plain reset // otherwise — the same split as the WebGL restore path. A reset @@ -1770,6 +2662,17 @@ export default class WebGPURenderer extends Renderer { this.vertexArena?.destroy(); this.uniformRing?.destroy(); this.pipelineCache?.clear(); + this.captureTexture?.destroy(); + this.captureTexture = undefined; + this.orthogonalTMXRenderer = undefined; + this._renderTargetPool?.destroy(); + this._renderTargetPool = null; + this.effectUniformArena?.destroy(); + this.effectUniformArena = null; + this.stubTexture?.destroy(); + this.stubTexture = null; + this.stubTextureView = null; + this.currentRenderTarget = null; this.depthTexture?.destroy(); this.depthTexture = null; this.context.unconfigure(); diff --git a/packages/melonjs/tests/__snapshots__/effects_golden_glsl.spec.js.snap b/packages/melonjs/tests/__snapshots__/effects_golden_glsl.spec.js.snap new file mode 100644 index 0000000000..087339c00c --- /dev/null +++ b/packages/melonjs/tests/__snapshots__/effects_golden_glsl.spec.js.snap @@ -0,0 +1,99 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ShaderEffect generated-GLSL golden > a body using every builtin assembles to the pinned sources > all-builtins fragment 1`] = ` +"uniform sampler2D uSampler; +varying vec4 vColor; +varying vec2 vRegion; +varying vec2 screen_uv; +varying vec2 noise_uv; + +uniform sampler2D uScene; +uniform float uStrength; +uniform float uTime; +vec4 apply(vec4 color, vec2 uv) { + vec4 scene = texture2D(uScene, screen_uv); + vec2 n = noise_uv; + return mix(color, scene, uStrength * n.x + uTime * 0.0); +} + +void main(void) { + vec4 texColor = texture2D(uSampler, vRegion) * vColor; + gl_FragColor = apply(texColor, vRegion); +}" +`; + +exports[`ShaderEffect generated-GLSL golden > a body using every builtin assembles to the pinned sources > all-builtins vertex 1`] = ` +"attribute vec3 aVertex; +attribute vec2 aRegion; +attribute vec4 aColor; +uniform mat4 uProjectionMatrix; +varying vec2 vRegion; +varying vec4 vColor; +varying vec2 screen_uv; +uniform vec2 ME_size_obj; +uniform vec2 ME_size_img; +uniform vec2 ME_offset; +varying vec2 noise_uv; +void main(void) { + vec4 ME_clip = uProjectionMatrix * vec4(aVertex, 1.0); + gl_Position = ME_clip; + screen_uv = ME_clip.xy / ME_clip.w * 0.5 + 0.5; + noise_uv = aRegion * (ME_size_img / ME_size_obj) - ME_offset / ME_size_obj; + vColor = vec4(aColor.bgr * aColor.a, aColor.a); + vRegion = aRegion; +}" +`; + +exports[`ShaderEffect generated-GLSL golden > a built-in effect class assembles to its pinned sources > vignette fragment 1`] = ` +"uniform sampler2D uSampler; +varying vec4 vColor; +varying vec2 vRegion; + + uniform float uStrength; + uniform float uSize; + vec4 apply(vec4 color, vec2 uv) { + vec2 vig = uv * (1.0 - uv); + float v = clamp(pow(vig.x * vig.y * uSize, uStrength), 0.0, 1.0); + return vec4(color.rgb * v, color.a); + } + +void main(void) { + vec4 texColor = texture2D(uSampler, vRegion) * vColor; + gl_FragColor = apply(texColor, vRegion); +}" +`; + +exports[`ShaderEffect generated-GLSL golden > a builtin-free body assembles to the pinned sources (stock vertex) > plain fragment 1`] = ` +"uniform sampler2D uSampler; +varying vec4 vColor; +varying vec2 vRegion; + +uniform float uStrength; +vec4 apply(vec4 color, vec2 uv) { + return vec4(color.rgb * uStrength, color.a); +} + +void main(void) { + vec4 texColor = texture2D(uSampler, vRegion) * vColor; + gl_FragColor = apply(texColor, vRegion); +}" +`; + +exports[`ShaderEffect generated-GLSL golden > a builtin-free body assembles to the pinned sources (stock vertex) > plain vertex 1`] = ` +"attribute vec3 aVertex; +attribute vec2 aRegion; +attribute vec4 aColor; + +uniform mat4 uProjectionMatrix; + +varying vec2 vRegion; +varying vec4 vColor; + +void main(void) { + + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); + + vColor = vec4(aColor.bgr * aColor.a, aColor.a); + vRegion = aRegion; +}" +`; diff --git a/packages/melonjs/tests/__snapshots__/wgsl_scaffold.spec.js.snap b/packages/melonjs/tests/__snapshots__/wgsl_scaffold.spec.js.snap new file mode 100644 index 0000000000..3c3e92639b --- /dev/null +++ b/packages/melonjs/tests/__snapshots__/wgsl_scaffold.spec.js.snap @@ -0,0 +1,108 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WGSL effect scaffold > all builtins: full module snapshot 1`] = ` +"// ---- melonJS effect scaffold ---- +struct FrameUniforms { + projection : mat4x4, + lineWidth : f32, +}; +@group(0) @binding(0) var uFrame : FrameUniforms; +@group(1) @binding(0) var uTexture : texture_2d; +@group(1) @binding(1) var uSampler : sampler; +var screen_uv : vec2f; +var noise_uv : vec2f; +struct MEBuiltins { + size_obj : vec2f, + size_img : vec2f, + offset : vec2f, +}; +@group(3) @binding(1) var ME : MEBuiltins; +@group(3) @binding(2) var screen_texture : texture_2d; +@group(3) @binding(3) var screen_sampler : sampler; +struct VSOut { + @builtin(position) position : vec4f, + @location(0) vRegion : vec2f, + @location(1) vColor : vec4f, + @location(2) vScreenUV : vec2f, + @location(3) vNoiseUV : vec2f, +}; + +@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); + // GL-convention clip z in [-w, w] -> WebGPU [0, w] + 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; + let ndc = clip.xy / clip.w; + // y-down: capture row 0 = screen top under WebGPU + out.vScreenUV = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + out.vNoiseUV = aRegion * (ME.size_img / ME.size_obj) - ME.offset / ME.size_obj; + return out; +} + +// ---- user body (verbatim) ---- + +struct Fx { uStrength : f32, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let scene = textureSample(screen_texture, screen_sampler, screen_uv); + return mix(color, scene, fx.uStrength * noise_uv.x); +} + + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + screen_uv = in.vScreenUV; + noise_uv = in.vNoiseUV; + let texColor = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + return apply(texColor, in.vRegion); +}" +`; + +exports[`WGSL effect scaffold > plain body: full module snapshot 1`] = ` +"// ---- melonJS effect scaffold ---- +struct FrameUniforms { + projection : mat4x4, + 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); + // GL-convention clip z in [-w, w] -> WebGPU [0, w] + 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; +} + +// ---- user body (verbatim) ---- +fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + +@fragment +fn fragment_main(in : VSOut) -> @location(0) vec4f { + let texColor = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + return apply(texColor, in.vRegion); +}" +`; diff --git a/packages/melonjs/tests/effects_golden_glsl.spec.js b/packages/melonjs/tests/effects_golden_glsl.spec.js new file mode 100644 index 0000000000..8272ed8b7c --- /dev/null +++ b/packages/melonjs/tests/effects_golden_glsl.spec.js @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ShaderEffect, VignetteEffect } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +/** + * Golden test for the GLSL sources ShaderEffect assembles around a user + * fragment body — the backward-compatibility proof for the neutral-effects + * refactor: hoisting ShaderEffect out of the WebGL tree and splitting the + * realization must keep the generated GLSL BYTE-IDENTICAL, so existing + * custom effects compile to exactly the same programs. + * + * The snapshots (tests/__snapshots__/effects_golden_glsl.spec.js.snap) were + * generated against the pre-refactor assembly and are committed — a + * mismatch means the refactor changed the emitted source, which is a + * regression by definition (run with `--update` ONLY for a deliberate, + * changelog-worthy change to the assembly). + */ + +// exercises every builtin at once: an annotated screen_texture sampler +// (with wrap mode), screen_uv, noise_uv, a user uniform and uTime +const ALL_BUILTINS_BODY = ` +uniform sampler2D uScene : screen_texture(repeat); +uniform float uStrength; +uniform float uTime; +vec4 apply(vec4 color, vec2 uv) { + vec4 scene = texture2D(uScene, screen_uv); + vec2 n = noise_uv; + return mix(color, scene, uStrength * n.x + uTime * 0.0); +} +`; + +// no builtins: must ride the stock quad vertex shader untouched +const PLAIN_BODY = ` +uniform float uStrength; +vec4 apply(vec4 color, vec2 uv) { + return vec4(color.rgb * uStrength, color.a); +} +`; + +describe("ShaderEffect generated-GLSL golden", () => { + let renderer; + + beforeAll(async () => { + renderer = await getWebGLRenderer(64, 64); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + it("a body using every builtin assembles to the pinned sources", (ctx) => { + if (typeof renderer === "undefined") { + ctx.skip(); + return; + } + const effect = new ShaderEffect(renderer, ALL_BUILTINS_BODY); + expect(effect._shader._sourceVertex).toMatchSnapshot("all-builtins vertex"); + expect(effect._shader._sourceFragment).toMatchSnapshot( + "all-builtins fragment", + ); + effect.destroy(); + }); + + it("a builtin-free body assembles to the pinned sources (stock vertex)", (ctx) => { + if (typeof renderer === "undefined") { + ctx.skip(); + return; + } + const effect = new ShaderEffect(renderer, PLAIN_BODY); + expect(effect._shader._sourceVertex).toMatchSnapshot("plain vertex"); + expect(effect._shader._sourceFragment).toMatchSnapshot("plain fragment"); + effect.destroy(); + }); + + it("a built-in effect class assembles to its pinned sources", (ctx) => { + if (typeof renderer === "undefined") { + ctx.skip(); + return; + } + const effect = new VignetteEffect(renderer); + expect(effect._shader._sourceFragment).toMatchSnapshot("vignette fragment"); + effect.destroy(); + }); +}); diff --git a/packages/melonjs/tests/floating_container_text.spec.js b/packages/melonjs/tests/floating_container_text.spec.js new file mode 100644 index 0000000000..15bcf4d8c3 --- /dev/null +++ b/packages/melonjs/tests/floating_container_text.spec.js @@ -0,0 +1,159 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Container, + Renderable, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * Regression coverage for renderables nested inside a *floating* Container + * (the classic HUD shape: `container.floating = true` with plain BitmapText / + * sprite children). The children draw through the quad batcher exactly like a + * direct floating renderable does, so a nested child must produce the same + * pixels as the same renderable added directly to the world — the floating + * bracket in `Container.draw` (save / resetTransform / screenProjection swap / + * restore) must not eat them. + * + * Pixels are asserted by reading the default framebuffer straight after + * `app.draw()` — same-task readback, so `preserveDrawingBuffer` is not needed. + */ +const SIZE = 64; + +// paints an opaque solid quad at `pos` THROUGH the quad batcher (drawImage of +// a solid canvas — the exact path BitmapText glyphs take), so the spec covers +// the batched-sprite pipeline without needing a font fixture +class SolidQuad extends Renderable { + constructor(x, y, w, h, color) { + super(x, y, w, h); + this.anchorPoint.set(0, 0); + const source = document.createElement("canvas"); + source.width = w; + source.height = h; + const ctx2d = source.getContext("2d"); + ctx2d.fillStyle = color; + ctx2d.fillRect(0, 0, w, h); + this.image = source; + } + + draw(renderer) { + renderer.drawImage( + this.image, + 0, + 0, + this.width, + this.height, + this.pos.x, + this.pos.y, + this.width, + this.height, + ); + } +} + +describe("floating Container children (WebGL)", () => { + let app; + let renderer; + let gl; + + beforeAll(async () => { + await boot(); + app = new Application(SIZE, SIZE, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + antiAlias: false, + }); + await app.init(); + renderer = app.renderer; + if (renderer instanceof WebGLRenderer) { + gl = renderer.gl; + } + }); + + afterEach(() => { + // each test owns its scene graph — drop leftovers without waiting for + // the deferred remove path + if (app) { + for (const child of app.world.getChildren().slice()) { + app.world.removeChildNow(child); + } + } + }); + + const requireWebGL = (ctx) => { + if (gl === undefined) { + ctx.skip("WebGL2 renderer not available in this environment"); + } + }; + + // run one engine frame: update (computes inViewport for nested children) + // then a forced draw + const drawFrame = () => { + app.world.update(16); + app.repaint(); + app.draw(); + }; + + // read one pixel of the default framebuffer at canvas coords (x, y) + const readPixel = (x, y) => { + const px = new Uint8Array(4); + gl.readPixels(x, SIZE - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return Array.from(px); + }; + + it("control: a non-floating renderable in the world produces pixels", (ctx) => { + requireWebGL(ctx); + app.world.addChild(new SolidQuad(8, 8, 16, 16, "#ff0000")); + drawFrame(); + expect(readPixel(16, 16)).toEqual([255, 0, 0, 255]); + }); + + it("control: a directly-floating renderable produces pixels", (ctx) => { + requireWebGL(ctx); + const quad = new SolidQuad(8, 8, 16, 16, "#00ff00"); + quad.floating = true; + app.world.addChild(quad); + drawFrame(); + expect(readPixel(16, 16)).toEqual([0, 255, 0, 255]); + }); + + it("a renderable nested in a floating container produces pixels", (ctx) => { + requireWebGL(ctx); + const hud = new Container(); + hud.floating = true; + hud.addChild(new SolidQuad(8, 8, 16, 16, "#0000ff")); + app.world.addChild(hud); + drawFrame(); + expect(readPixel(16, 16)).toEqual([0, 0, 255, 255]); + }); + + it("a renderable nested in a floating container at depth Infinity produces pixels", (ctx) => { + requireWebGL(ctx); + // the classic HUD boilerplate — `depth = Infinity` to draw on top + const hud = new Container(); + hud.floating = true; + hud.depth = Number.POSITIVE_INFINITY; + hud.addChild(new SolidQuad(8, 8, 16, 16, "#ffff00")); + app.world.addChild(hud); + drawFrame(); + expect(readPixel(16, 16)).toEqual([255, 255, 0, 255]); + }); + + it("an Infinity-sized floating container leaves the transform finite for later draws", (ctx) => { + requireWebGL(ctx); + // a HUD-and-world frame: the floating bracket plus the container's + // own save/restore must leave the world sibling drawn after the HUD + // untouched — both must land on screen + const hud = new Container(); + hud.floating = true; + hud.addChild(new SolidQuad(40, 40, 8, 8, "#00ffff")); + app.world.addChild(hud, 1); + app.world.addChild(new SolidQuad(8, 8, 16, 16, "#ff00ff"), 2); + drawFrame(); + expect(readPixel(44, 44)).toEqual([0, 255, 255, 255]); + expect(readPixel(16, 16)).toEqual([255, 0, 255, 255]); + }); +}); diff --git a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js index 3f912a09f2..1d7c518902 100644 --- a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js +++ b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js @@ -18,14 +18,20 @@ export function createMockWebGPURenderer() { setPipeline: 0, // group-1 (material) bind groups as recorded by the quad batcher materialBinds: [], + // every setBindGroup: {index, group, dynamicOffsets} + bindGroups: [], pushFrameGlobals: 0, + captureFrames: 0, + // one entry per queue.writeTexture + textureWrites: [], }; const pass = { setPipeline() { calls.setPipeline++; }, - setBindGroup(index, group) { + setBindGroup(index, group, dynamicOffsets) { + calls.bindGroups.push({ index, group, dynamicOffsets }); if (index === 1) { calls.materialBinds.push(group); } @@ -46,18 +52,37 @@ export function createMockWebGPURenderer() { const renderer = { calls, pass, + // frame stamp consumed by the same-frame re-upload rules and the + // light-binding staleness gate; tests advance it to cross frames + frameId: 1, device: { + limits: { minUniformBufferOffsetAlignment: 256 }, queue: { writeBuffer(buffer, offset, data, dataOffset, size) { - // snapshot the bytes like the real queue does - const copy = data.slice(dataOffset, dataOffset + size); + // snapshot the bytes like the real queue does (data may be + // a TypedArray view or a raw ArrayBuffer, per the real + // overloads) + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const copy = bytes.slice(dataOffset, dataOffset + size); calls.writes.push({ + buffer, offset, size, floats: new Float32Array(copy.buffer, 0, size >> 2), view: new DataView(copy.buffer), }); }, + copyExternalImageToTexture() {}, + writeTexture(destination, data, layout, size) { + calls.textureWrites.push({ + texture: destination.texture, + bytes: data.length, + size, + }); + }, }, createBuffer(descriptor) { return { @@ -69,9 +94,47 @@ export function createMockWebGPURenderer() { unmap() {}, }; }, + createBindGroup(descriptor) { + return { layout: descriptor.layout, entries: descriptor.entries }; + }, + createBindGroupLayout(descriptor) { + return { label: descriptor.label, entries: descriptor.entries }; + }, + createTexture(descriptor) { + return { + size: descriptor.size, + destroy() {}, + createView() { + return { texture: this }; + }, + }; + }, }, pipelineCache: { + epoch: 1, + emptyBindGroup: { empty: true }, + registeredModules: new Map(), + effectLayouts: new Map(), registerVertexLayout() {}, + registerShader(code) { + let key = this.registeredModules.get(code); + if (typeof key === "undefined") { + key = `effect:${this.registeredModules.size}`; + this.registeredModules.set(code, key); + this.modules[key] = { code }; + } + return key; + }, + modules: {}, + getEffectLayout(signature) { + if (!this.effectLayouts.has(signature)) { + this.effectLayouts.set(signature, { signature }); + } + return this.effectLayouts.get(signature); + }, + frameLayout: {}, + materialLayout: {}, + emptyLayout: {}, get(shaderKey, topology, blendMode, premultipliedAlpha, stencilMode) { const key = `${shaderKey}|${topology}|${blendMode}|${premultipliedAlpha}|${stencilMode}`; calls.pipelineKeys.push(key); @@ -86,6 +149,36 @@ export function createMockWebGPURenderer() { return { buffer: {}, offset: 0 }; }, }, + // bump allocator over labeled fake pages, alignment-honoring — the + // effect-uniform snapshot path exercises this + effectUniformArena: { + offset: 0, + page: { label: "effect page 0" }, + alloc(byteLength, alignment = 4) { + this.offset = (this.offset + alignment - 1) & ~(alignment - 1); + const region = { buffer: this.page, offset: this.offset }; + this.offset += (byteLength + 3) & ~3; + return region; + }, + reset() { + this.offset = 0; + }, + }, + captureTexture: undefined, + customShader: undefined, + captureFrame() { + calls.captureFrames++; + this.captureTexture = { + view: { capture: calls.captureFrames }, + generation: calls.captureFrames, + }; + return this.captureTexture; + }, + stubView: { stub: true }, + getStubTextureView() { + return this.stubView; + }, + retireTexture() {}, textureStore: { // one stable bind-group token per atlas object getBinding(texture) { @@ -94,6 +187,25 @@ export function createMockWebGPURenderer() { } return materialBindings.get(texture); }, + // one stable record per atlas object (the lit batcher composes + // combined bind groups from the raw view) + records: new Map(), + getResidentRecord(texture) { + if (!this.records.has(texture)) { + this.records.set(texture, { + view: { texture }, + width: 64, + height: 64, + }); + } + return this.records.get(texture); + }, + getSampler(filter, repeat) { + return { filter, repeat }; + }, + }, + getDefaultTextureFilter() { + return "linear"; }, ensurePass() { return pass; diff --git a/packages/melonjs/tests/lights.spec.js b/packages/melonjs/tests/lights.spec.js index bdd7d408cc..85e6501322 100644 --- a/packages/melonjs/tests/lights.spec.js +++ b/packages/melonjs/tests/lights.spec.js @@ -1840,7 +1840,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { // Sanity: the test only makes sense if WebGL actually came up. expect(renderer.WebGLVersion).toBe(2); const { default: RadialGradientEffect } = await import( - "../src/video/webgl/effects/radialGradient.js" + "../src/video/effects/radialGradient.js" ); const { Color } = await import("../src/math/color.ts"); expect(() => { @@ -1856,7 +1856,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { it("constructor with no options uses sensible defaults (white, 1.0)", async () => { expect(renderer.WebGLVersion).toBe(2); const { default: RadialGradientEffect } = await import( - "../src/video/webgl/effects/radialGradient.js" + "../src/video/effects/radialGradient.js" ); expect(() => { return new RadialGradientEffect(renderer); diff --git a/packages/melonjs/tests/shader-loader.spec.js b/packages/melonjs/tests/shader-loader.spec.js index f6164e2504..9e6cc77e8a 100644 --- a/packages/melonjs/tests/shader-loader.spec.js +++ b/packages/melonjs/tests/shader-loader.spec.js @@ -565,4 +565,82 @@ describe("shader assets + clone under context loss", () => { expect(loader.getShader("pair-broken")).toBe(null); }); }); + + describe("dual-language {glsl, wgsl} shader assets", () => { + const WGSL_FLASH = ` +struct Fx { uIntensity : f32, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return mix(color, vec4f(1.0), fx.uIntensity); +} +`; + + it("inline {glsl, wgsl} data compiles the language the renderer speaks", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "dual-inline", + type: "shader", + data: { glsl: FLASH, wgsl: WGSL_FLASH }, + }); + const fx = loader.getShader("dual-inline"); + expect(fx).toBeInstanceOf(ShaderEffect); + expect(fx.shared).toBe(true); + // on the WebGL renderer the GLSL side compiled... + expect(fx.enabled).toBe(true); + expect(typeof fx._shader.uniforms.uIntensity).not.toBe("undefined"); + // ...and the untouched recipe still carries both bodies for clone() + expect(fx._fragmentBody.wgsl).toBe(WGSL_FLASH); + loader.unload({ name: "dual-inline", type: "shader" }); + }); + + it("src {glsl, wgsl} URLs fetch both and compile the matching one", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "dual-url", + type: "shader", + src: { + glsl: `data:text/plain,${encodeURIComponent(FLASH)}`, + wgsl: `data:text/plain,${encodeURIComponent(WGSL_FLASH)}`, + }, + }); + const fx = loader.getShader("dual-url"); + expect(fx).toBeInstanceOf(ShaderEffect); + expect(fx.enabled).toBe(true); + expect(typeof fx._shader.uniforms.uIntensity).not.toBe("undefined"); + loader.unload({ name: "dual-url", type: "shader" }); + }); + + it("a wgsl-only asset on a GLSL renderer preloads as an inert stub (never fails the load)", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + // the mirror of the GLSL-only-on-WebGPU case: the language does + // not match, the preload succeeds, the effect is disabled — a + // mixed manifest must never brick a game + await loader.load({ + name: "wgsl-only", + type: "shader", + data: { wgsl: WGSL_FLASH }, + }); + const fx = loader.getShader("wgsl-only"); + expect(fx).toBeInstanceOf(ShaderEffect); + expect(fx.enabled).toBe(false); + expect(fx.shared).toBe(true); + // the whole inert no-op contract holds, unload included + expect(() => { + fx.setUniform("uIntensity", 1); + fx.setTime(1); + }).not.toThrow(); + expect(loader.unload({ name: "wgsl-only", type: "shader" })).toBe(true); + expect(fx.destroyed).toBe(true); + expect(loader.getShader("wgsl-only")).toBe(null); + }); + }); }); diff --git a/packages/melonjs/tests/shadereffect_dual_body.spec.js b/packages/melonjs/tests/shadereffect_dual_body.spec.js new file mode 100644 index 0000000000..b07603dc0e --- /dev/null +++ b/packages/melonjs/tests/shadereffect_dual_body.spec.js @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ShaderEffect } from "../src/index.js"; + +/** + * The dual-language body contract, device-free: ShaderEffect picks the + * body matching `renderer.shaderLanguage`, degrades to the inert stub + * when none matches (warn + enabled=false — the Canvas contract + * generalized), and the WGSL realization's CPU mirror places setUniform + * values at the parsed offsets. Only surfaces that need no live GPU/GL + * are exercised here; the GLSL realization keeps its own WebGL-gated + * suites, and the WebGPU draw path is covered by the post-effect specs. + */ +describe("ShaderEffect dual-language bodies", () => { + const WGSL_BODY = ` +struct Fx { + uStrength : f32, + uColor : vec3f, + uTime : f32, +}; +@group(3) @binding(0) var fx : Fx; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(color.rgb * fx.uColor * fx.uStrength, color.a); +} +`; + const GLSL_BODY = ` +uniform float uStrength; +vec4 apply(vec4 color, vec2 uv) { return color * uStrength; } +`; + + const wgslRenderer = { shaderLanguage: "wgsl" }; + const glslRenderer = { shaderLanguage: "glsl", gl: undefined }; + const canvasRenderer = { shaderLanguage: null }; + + const created = []; + function make(renderer, body) { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect(renderer, body); + warn.mockRestore(); + created.push(effect); + return effect; + } + + afterEach(() => { + for (const effect of created.splice(0)) { + if (!effect.destroyed) { + effect.destroy(); + } + } + }); + + describe("body dispatch matrix", () => { + it("a WGSL renderer realizes the wgsl body", () => { + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + expect(effect.enabled).toBe(true); + expect(effect.wgslRealization).toBeDefined(); + expect(effect._shader).toBeUndefined(); + }); + + it("a WGSL renderer with a dual body picks wgsl", () => { + const effect = make(wgslRenderer, { glsl: GLSL_BODY, wgsl: WGSL_BODY }); + expect(effect.enabled).toBe(true); + expect(effect.wgslRealization).toBeDefined(); + }); + + it("a bare string keeps meaning GLSL: inert on a WGSL renderer", () => { + const effect = make(wgslRenderer, GLSL_BODY); + expect(effect.enabled).toBe(false); + expect(effect.wgslRealization).toBeUndefined(); + }); + + it("a {glsl}-only body is inert on a WGSL renderer", () => { + expect(make(wgslRenderer, { glsl: GLSL_BODY }).enabled).toBe(false); + }); + + it("a {wgsl}-only body is inert on a GLSL renderer (no GL program built)", () => { + const effect = make(glslRenderer, { wgsl: WGSL_BODY }); + expect(effect.enabled).toBe(false); + expect(effect._shader).toBeUndefined(); + }); + + it("every shape is inert on a renderer with no shading language", () => { + expect(make(canvasRenderer, GLSL_BODY).enabled).toBe(false); + expect( + make(canvasRenderer, { glsl: GLSL_BODY, wgsl: WGSL_BODY }).enabled, + ).toBe(false); + }); + + it("an invalid WGSL body warns with the parse reason and stays inert", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect(wgslRenderer, { + wgsl: "fn not_apply() -> f32 { return 1.0; }", + }); + created.push(effect); + expect(effect.enabled).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("invalid WGSL body"), + ); + warn.mockRestore(); + }); + + it("inert stubs keep the full no-op contract (Canvas parity)", () => { + const effect = make(wgslRenderer, GLSL_BODY); + expect(() => { + effect.setUniform("uStrength", 1); + effect.setTime(2); + effect.destroy(); + }).not.toThrow(); + expect(effect.destroyed).toBe(true); + }); + }); + + describe("WGSL uniform mirror", () => { + it("setUniform writes bytes at the parsed offsets (vec3 alignment included)", () => { + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + effect.setUniform("uStrength", 0.5); + effect.setUniform("uColor", [0.25, 0.5, 0.75]); + + const mirror = effect.wgslRealization.f32; + expect(mirror[0]).toBeCloseTo(0.5); + // uColor aligns to 16 → floats 4..6 + expect([mirror[4], mirror[5], mirror[6]]).toEqual([0.25, 0.5, 0.75]); + }); + + it("an unknown uniform name warns once and is ignored", () => { + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + effect.setUniform("uNope", 1); + effect.setUniform("uNope", 2); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it("setTime writes uTime only when the struct declares it", () => { + const withTime = make(wgslRenderer, { wgsl: WGSL_BODY }); + expect(withTime.setTime(3)).toBe(withTime); + const offset = withTime.wgslRealization.layout.get("uTime").offset >> 2; + expect(withTime.wgslRealization.f32[offset]).toBe(3); + + const without = make(wgslRenderer, { + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + }); + expect(() => { + return without.setTime(3); + }).not.toThrow(); + }); + + it("mat3x3f values land on vec4-strided columns (9-float column-major input)", () => { + const effect = make(wgslRenderer, { + wgsl: ` +struct Fx { uMat : mat3x3f, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(fx.uMat * color.rgb, color.a); +} +`, + }); + effect.setUniform("uMat", [1, 2, 3, 4, 5, 6, 7, 8, 9]); + const mirror = effect.wgslRealization.f32; + // columns at float offsets 0/4/8, each 3 floats + padding + expect([...mirror.slice(0, 3)]).toEqual([1, 2, 3]); + expect([...mirror.slice(4, 7)]).toEqual([4, 5, 6]); + expect([...mirror.slice(8, 11)]).toEqual([7, 8, 9]); + }); + + it("boolean values normalize to 0/1 like the GLSL path", () => { + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + effect.setUniform("uStrength", true); + expect(effect.wgslRealization.f32[0]).toBe(1); + effect.setUniform("uStrength", false); + expect(effect.wgslRealization.f32[0]).toBe(0); + }); + + it("_setNoiseUVRect feeds the ME mirror when noise_uv is used", () => { + const effect = make(wgslRenderer, { + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return vec4f(noise_uv, 0.0, color.a); }", + }); + effect._setNoiseUVRect(256, 128, 64, 32, 0.5, 0.25); + const me = effect.wgslRealization.meMirror; + expect([...me.slice(0, 6)]).toEqual([64, 32, 256, 128, 128, 32]); + }); + }); + + describe("setTexture on the WGSL realization", () => { + const TEXTURED = ` +@group(3) @binding(1) var uNoise : texture_2d; +@group(3) @binding(2) var uNoiseSampler : sampler; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return color * textureSample(uNoise, uNoiseSampler, uv); +} +`; + + it("stores entries for declared textures, refuses undeclared names", () => { + const effect = make(wgslRenderer, { wgsl: TEXTURED }); + const image = { width: 8, height: 8 }; + effect.setTexture("uNoise", image, "repeat"); + expect(effect._extraTextures.get("uNoise")).toMatchObject({ + image, + repeat: "repeat", + }); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + effect.setTexture("uUndeclared", image); + expect(effect._extraTextures.has("uUndeclared")).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("uUndeclared")); + warn.mockRestore(); + }); + + it("rejects HTMLVideoElement sources with a TypeError on every backend", () => { + const effect = make(wgslRenderer, { wgsl: TEXTURED }); + expect(() => { + effect.setTexture("uNoise", { videoWidth: 640 }); + }).toThrow(TypeError); + }); + }); + + describe("clone", () => { + it("replays user-set values and extra textures, resets shared", () => { + const original = make(wgslRenderer, { wgsl: WGSL_BODY }); + original.setUniform("uStrength", 0.75); + original.setUniform("uColor", [1, 0, 0]); + original.shared = true; + + const copy = original.clone(); + created.push(copy); + expect(copy.enabled).toBe(true); + expect(copy.shared).toBe(false); + expect(copy.wgslRealization).not.toBe(original.wgslRealization); + expect(copy.wgslRealization.f32[0]).toBeCloseTo(0.75); + expect(copy.wgslRealization.f32[4]).toBe(1); + }); + + it("throws when cloning a destroyed effect", () => { + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + effect.destroy(); + expect(() => { + return effect.clone(); + }).toThrow(/destroyed/); + }); + }); + + describe("destroy", () => { + it("is idempotent and releases the GPU state handle", () => { + const effect = make(wgslRenderer, { wgsl: WGSL_BODY }); + effect.destroy(); + effect.destroy(); + expect(effect.destroyed).toBe(true); + expect(effect.enabled).toBe(false); + expect(effect.wgslRealization.gpu).toBeNull(); + }); + }); +}); diff --git a/packages/melonjs/tests/webgpu_compressed.spec.js b/packages/melonjs/tests/webgpu_compressed.spec.js new file mode 100644 index 0000000000..e97e377006 --- /dev/null +++ b/packages/melonjs/tests/webgpu_compressed.spec.js @@ -0,0 +1,249 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it } from "vitest"; +import { Renderer, WebGPURenderer } from "../src/index.js"; +import { + COMPRESSED_FORMATS, + uploadCompressedTexture, +} from "../src/video/webgpu/texture/compressed.js"; +import WebGPUTextureStore from "../src/video/webgpu/texture/store.js"; + +/** + * Compressed-texture support on the WebGPU backend: the WebGL-enum → + * GPUTextureFormat map, block-aligned per-mip writeTexture uploads, the + * device-feature → format-family shape (shared with the loader's + * capability gate), and the texture store's compressed branch. + */ +describe("WebGPU compressed textures", () => { + it("every mapped format carries coherent block metrics", () => { + for (const [enumValue, metrics] of COMPRESSED_FORMATS) { + expect(enumValue).toBeGreaterThan(0x8000); + expect(metrics.format).toMatch(/^(bc|etc2|eac|astc)/); + expect(metrics.blockW).toBeGreaterThanOrEqual(4); + expect(metrics.blockH).toBeGreaterThanOrEqual(4); + expect([8, 16]).toContain(metrics.bytes); + } + }); + + it("uploads each mip with block-aligned bytesPerRow", () => { + const writes = []; + const device = { + queue: { + writeTexture(dst, data, layout, size) { + writes.push({ dst, byteLength: data.byteLength, layout, size }); + }, + }, + }; + const image = { + format: 0x83f0, // DXT1 → bc1 (4×4 blocks, 8 bytes) + mipmaps: [ + { data: new Uint8Array(32), width: 8, height: 8 }, + { data: new Uint8Array(8), width: 4, height: 4 }, + { data: new Uint8Array(8), width: 2, height: 2 }, + ], + }; + const texture = { label: "t" }; + uploadCompressedTexture( + device, + texture, + image, + COMPRESSED_FORMATS.get(0x83f0), + ); + + expect(writes).toHaveLength(3); + // 8px = 2 blocks per row × 8 bytes + expect(writes[0].layout.bytesPerRow).toBe(16); + expect(writes[0].layout.rowsPerImage).toBe(2); + expect(writes[0].size).toEqual([8, 8]); + expect(writes[0].dst.mipLevel).toBe(0); + // 4px and the 2px tail mip round up to one block + expect(writes[1].layout.bytesPerRow).toBe(8); + expect(writes[2].layout.bytesPerRow).toBe(8); + expect(writes[2].dst.mipLevel).toBe(2); + }); + + it("format families mirror the device features in the GL shape", () => { + const stub = { + device: { features: new Set(["texture-compression-bc"]) }, + getSupportedCompressedTextureFormats: + WebGPURenderer.prototype.getSupportedCompressedTextureFormats, + hasSupportedCompressedFormats: + Renderer.prototype.hasSupportedCompressedFormats, + }; + const formats = stub.getSupportedCompressedTextureFormats(); + expect(formats.s3tc).not.toBeNull(); + expect(formats.bptc).not.toBeNull(); + expect(formats.etc2).toBeNull(); + expect(formats.astc).toBeNull(); + expect(formats.pvrtc).toBeNull(); + // the base hasSupportedCompressedFormats consumes this shape as-is + expect(stub.hasSupportedCompressedFormats(0x83f0)).toBe(true); + expect(stub.hasSupportedCompressedFormats(0x9274)).toBe(false); + // PVRTC never maps to a WebGPU format + expect(stub.hasSupportedCompressedFormats(0x8c00)).toBe(false); + }); + + it("etc2 support synthesizes the etc1 family (superset payload)", () => { + const stub = { + device: { features: new Set(["texture-compression-etc2"]) }, + getSupportedCompressedTextureFormats: + WebGPURenderer.prototype.getSupportedCompressedTextureFormats, + }; + const formats = stub.getSupportedCompressedTextureFormats(); + expect(formats.etc1).toEqual({ COMPRESSED_RGB_ETC1_WEBGL: 0x8d64 }); + expect(formats.s3tc).toBeNull(); + }); + + it("does not memoize the all-null table while the device is renegotiating", () => { + const stub = { + device: undefined, + getSupportedCompressedTextureFormats: + WebGPURenderer.prototype.getSupportedCompressedTextureFormats, + }; + const during = stub.getSupportedCompressedTextureFormats(); + expect(during.s3tc).toBeNull(); + expect(during.etc2).toBeNull(); + expect(during.astc).toBeNull(); + // a query during the renegotiation window must not lock the + // replacement device's families to null + expect(stub.supportedCompressedFormats).toBeUndefined(); + + stub.device = { features: new Set(["texture-compression-bc"]) }; + const after = stub.getSupportedCompressedTextureFormats(); + expect(after.s3tc).not.toBeNull(); + // with a device present the table memoizes as before + expect(stub.getSupportedCompressedTextureFormats()).toBe(after); + }); + + it("the store uploads compressed sources through writeTexture with mip storage", () => { + const created = []; + const writes = []; + const renderer = { + device: { + createTexture(descriptor) { + const texture = { + descriptor, + destroy() {}, + createView(viewDescriptor) { + return { texture: this, viewDescriptor }; + }, + }; + created.push(texture); + return texture; + }, + createSampler(descriptor) { + return { descriptor }; + }, + createBindGroup(descriptor) { + return { descriptor }; + }, + queue: { + writeTexture(dst, data, layout, size) { + writes.push({ dst, layout, size }); + }, + copyExternalImageToTexture() { + throw new Error("compressed sources must not use the image path"); + }, + }, + }, + frameId: 1, + retireTexture() {}, + cache: { + getUnit() { + return 0; + }, + peekAllUnits() { + return [0]; + }, + }, + pipelineCache: { materialLayout: {} }, + getDefaultTextureFilter() { + return "linear"; + }, + }; + const store = new WebGPUTextureStore(renderer); + const source = { + compressed: true, + format: 0x83f3, // DXT5 → bc3, 16 bytes per block + width: 8, + height: 8, + mipmaps: [ + { data: new Uint8Array(64), width: 8, height: 8 }, + { data: new Uint8Array(16), width: 4, height: 4 }, + ], + }; + const atlas = { + getTexture() { + return source; + }, + repeat: "no-repeat", + }; + + const bindGroup = store.getBinding(atlas); + expect(bindGroup).toBeDefined(); + expect(created).toHaveLength(1); + expect(created[0].descriptor.format).toBe("bc3-rgba-unorm"); + expect(created[0].descriptor.mipLevelCount).toBe(2); + // GL parity: the sampled view is restricted to mip 0 (the GL backend + // sets plain LINEAR/NEAREST min filters and never samples the chain) + expect(bindGroup.descriptor.entries[0].resource.viewDescriptor).toEqual({ + baseMipLevel: 0, + mipLevelCount: 1, + }); + // compressed formats cannot be render attachments + expect( + created[0].descriptor.usage & GPUTextureUsage.RENDER_ATTACHMENT, + ).toBe(0); + expect(writes).toHaveLength(2); + expect(writes[0].layout.bytesPerRow).toBe(32); + + // a second bind is fully resident — no re-upload + store.getBinding(atlas); + expect(writes).toHaveLength(2); + + // 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 + const imageAtlas = { + getTexture() { + return { width: 8, height: 8 }; + }, + repeat: "no-repeat", + }; + // the recreated rgba8unorm texture legitimately uploads via the + // image path again + renderer.device.queue.copyExternalImageToTexture = () => {}; + store.getBinding(imageAtlas); + expect(created).toHaveLength(2); + expect(created[1].descriptor.format).toBe("rgba8unorm"); + store.destroy(); + }); + + it("the store throws on a compressed format with no WebGPU mapping", () => { + const renderer = { + device: { + createTexture() { + throw new Error("must not create a texture for an unmapped format"); + }, + queue: {}, + }, + frameId: 1, + cache: { + getUnit() { + return 0; + }, + }, + }; + const store = new WebGPUTextureStore(renderer); + const atlas = { + getTexture() { + // PVRTC 4bpp RGB — a family with no WebGPU equivalent + return { compressed: true, format: 0x8c00, width: 8, height: 8 }; + }, + repeat: "no-repeat", + }; + expect(() => { + return store.getBinding(atlas); + }).toThrow(/unsupported compressed texture format 0x8c00/); + store.destroy(); + }); +}); diff --git a/packages/melonjs/tests/webgpu_effects_validate.spec.js b/packages/melonjs/tests/webgpu_effects_validate.spec.js new file mode 100644 index 0000000000..d458e4fde9 --- /dev/null +++ b/packages/melonjs/tests/webgpu_effects_validate.spec.js @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + BlurEffect, + ChromaticAberrationEffect, + ColorMatrixEffect, + DesaturateEffect, + DissolveEffect, + DropShadowEffect, + FlashEffect, + GlowEffect, + HologramEffect, + InvertEffect, + OutlineEffect, + PixelateEffect, + ScanlineEffect, + SepiaEffect, + ShineEffect, + TintPulseEffect, + VignetteEffect, + video, + WaveEffect, +} from "../src/index.js"; + +/** + * Device-gated validation of every built-in effect's WGSL twin: each body + * must parse into an enabled effect on the WebGPU renderer, and its + * scaffolded module must compile with ZERO errors on the real device + * (`getCompilationInfo` — WGSL validation issues never throw, they only + * surface here or as silent draw failures). CI runners without WebGPU + * skip visibly; run locally against a real adapter. + */ +describe("built-in effect WGSL twins compile on the device", () => { + const EFFECTS = { + BlurEffect, + ChromaticAberrationEffect, + ColorMatrixEffect, + DesaturateEffect, + DissolveEffect, + DropShadowEffect, + FlashEffect, + GlowEffect, + HologramEffect, + InvertEffect, + OutlineEffect, + PixelateEffect, + ScanlineEffect, + SepiaEffect, + ShineEffect, + TintPulseEffect, + VignetteEffect, + WaveEffect, + }; + + 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("every effect parses enabled and its module compiles clean", async (ctx) => { + if (!webgpuReady) { + ctx.skip("WebGPU not available in this environment"); + return; + } + const renderer = app.renderer; + const failures = []; + for (const [name, EffectClass] of Object.entries(EFFECTS)) { + const effect = new EffectClass(renderer); + if (effect.enabled !== true || !effect.wgslRealization?.valid) { + failures.push( + `${name}: did not realize (${effect.wgslRealization?.error ?? "no realization"})`, + ); + continue; + } + const module = renderer.device.createShaderModule({ + code: effect.wgslRealization.code, + }); + const info = await module.getCompilationInfo(); + for (const message of info.messages) { + if (message.type === "error") { + failures.push( + `${name}: ${message.message} (line ${message.lineNum})`, + ); + } + } + effect.destroy(); + } + expect(failures).toEqual([]); + }); +}); diff --git a/packages/melonjs/tests/webgpu_gradient_mask.spec.js b/packages/melonjs/tests/webgpu_gradient_mask.spec.js new file mode 100644 index 0000000000..aedc829eab --- /dev/null +++ b/packages/melonjs/tests/webgpu_gradient_mask.spec.js @@ -0,0 +1,168 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it } from "vitest"; +import { WebGPURenderer } from "../src/index.js"; + +/** + * The gradientMask state machine — the GL #gradientMask parity port — + * exercised through the real prototype over a recording stub, so the + * phase ordering (tag/mark → test → untag → restore), the stencil + * references, and the gradient handoff to fillRect are pinned without + * a GPU device. + */ +function createStub({ maskLevel = 0, maskVisibleRef = 0 } = {}) { + const log = []; + const stub = { + log, + currentGradient: { id: "grad" }, + maskLevel, + maskVisibleRef, + stencilMode: maskLevel > 0 ? "test" : "none", + renderPass: maskLevel > 0 ? { end() {} } : null, + currentBatcher: { + flush() { + log.push("flush"); + }, + }, + beginPass(options) { + log.push(`beginPass:${options?.stencilLoadOp ?? "load"}`); + this.renderPass = this.passObject; + }, + passObject: { + setStencilReference(ref) { + log.push(`ref:${ref}`); + }, + }, + ensurePass() { + log.push(`mode:${this.stencilMode}`); + return this.passObject; + }, + fillRect(x, y, w, h) { + log.push( + `fillRect:${x},${y},${w},${h}:${this.currentGradient ? "gradient" : "solid"}`, + ); + }, + }; + // the pass "end" used when breaking for the stencil clear + if (stub.renderPass) { + stub.renderPass = { + end() { + log.push("endPass"); + }, + }; + } + return stub; +} + +const gradientMask = WebGPURenderer.prototype.gradientMask; + +describe("WebGPURenderer.gradientMask (state machine)", () => { + it("outside a mask: clears the stencil, tags with ref 1, clips the gradient, restores none", () => { + const stub = createStub(); + const shape = () => { + stub.log.push( + `shape:${stub.stencilMode}:${stub.currentGradient ? "gradient" : "solid"}`, + ); + }; + + gradientMask.call(stub, shape, 5, 6, 70, 80); + + expect(stub.log).toEqual([ + "flush", + // stencil clear = pass break + "beginPass:clear", + // tag phase: shape drawn solid under "tag" with ref 1 + "mode:tag", + "ref:1", + "shape:tag:solid", + "flush", + // gradient rect clipped to the tagged pixels + "mode:test", + "ref:1", + "fillRect:5,6,70,80:gradient", + "flush", + // restore: stencil ignored again, reference back to the mask's + "mode:none", + "ref:0", + ]); + expect(stub.stencilMode).toBe("none"); + expect(stub.currentGradient).toEqual({ id: "grad" }); + }); + + it("inside a mask: marks visible pixels with the high bit, clips, strips the marker, re-installs the mask test", () => { + const stub = createStub({ maskLevel: 2, maskVisibleRef: 2 }); + const shape = () => { + stub.log.push( + `shape:${stub.stencilMode}:${stub.currentGradient ? "gradient" : "solid"}`, + ); + }; + + gradientMask.call(stub, shape, 0, 0, 10, 10); + + const markRef = 0x80 | 2; + expect(stub.log).toEqual([ + "flush", + // tag only pixels visible under the active mask (no stencil + // clear — the mask levels must survive) + "mode:mark", + `ref:${markRef}`, + "shape:mark:solid", + "flush", + // gradient clipped to the marker + "mode:test", + `ref:${markRef}`, + "fillRect:0,0,10,10:gradient", + "flush", + // strip the marker back to the visible value + "mode:mark", + "ref:2", + "shape:mark:solid", + "flush", + // the exact render test setMask had established + "mode:test", + "ref:2", + ]); + expect(stub.stencilMode).toBe("test"); + expect(stub.maskLevel).toBe(2); + expect(stub.currentGradient).toEqual({ id: "grad" }); + }); + + it("inside an INVERTED mask (visible ref 0): marks with the bare high bit, untags back to 0", () => { + const stub = createStub({ maskLevel: 1, maskVisibleRef: 0 }); + const shape = () => { + stub.log.push(`shape:${stub.stencilMode}`); + }; + + gradientMask.call(stub, shape, 0, 0, 10, 10); + + // visible pixels of an inverted mask hold stencil 0 — the marker is + // the high bit alone, and stripping it must write 0 back (not the + // mask level) + const markRef = 0x80; + expect(stub.log).toEqual([ + "flush", + "mode:mark", + `ref:${markRef}`, + "shape:mark", + "flush", + "mode:test", + `ref:${markRef}`, + "fillRect:0,0,10,10:gradient", + "flush", + "mode:mark", + "ref:0", + "shape:mark", + "flush", + "mode:test", + "ref:0", + ]); + expect(stub.stencilMode).toBe("test"); + expect(stub.currentGradient).toEqual({ id: "grad" }); + }); + + it("never breaks the pass when a mask is active (mask levels live in the stencil)", () => { + const stub = createStub({ maskLevel: 1, maskVisibleRef: 1 }); + gradientMask.call(stub, () => {}, 0, 0, 1, 1); + expect(stub.log).not.toContain("beginPass:clear"); + expect(stub.log).not.toContain("endPass"); + }); +}); diff --git a/packages/melonjs/tests/webgpu_lit_quads.spec.js b/packages/melonjs/tests/webgpu_lit_quads.spec.js new file mode 100644 index 0000000000..62451d6b85 --- /dev/null +++ b/packages/melonjs/tests/webgpu_lit_quads.spec.js @@ -0,0 +1,358 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Color, WebGPURenderer } from "../src/index.js"; +import { BLOCK_BYTES } from "../src/video/webgl/lighting/std140.ts"; +import WebGPULitQuadBatcher from "../src/video/webgpu/batchers/lit_quad_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * The WebGPU 2D lighting port on the mock renderer: the lit quad + * batcher's std140 snapshot-per-camera semantics, the combined + * color+normal material composition, version-stamped normal-map + * re-uploads, and drawLight's tint-packed fast-path draw. + */ +describe("WebGPU 2D lighting", () => { + describe("WebGPULitQuadBatcher (mock)", () => { + let renderer; + let lit; + + const packed = (count = 1) => { + return { + count, + positions: new Float32Array([100, 50, 120, 0.7, 0, 0, 0, 0]), + colors: new Float32Array([1, 0.5, 0.25, 0, 0, 0]), + heights: new Float32Array([9, 0]), + ambient: [0.1, 0.2, 0.3], + }; + }; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + lit = new WebGPULitQuadBatcher(renderer); + }); + + it("registers the lit family against the frozen quad layout", () => { + expect(lit.shaderKey).toMatch(/^effect:/); + // a second instance (device-loss re-init) reuses the module text + const again = new WebGPULitQuadBatcher(renderer); + expect(again.shaderKey).toBe(lit.shaderKey); + }); + + it("every setLightUniforms call owns its snapshot bytes (distinct dynamic offsets)", () => { + lit.setLightUniforms(packed()); + const first = lit.lightBinding; + lit.setLightUniforms(packed(0)); + const second = lit.lightBinding; + + // queue-write law: each camera's block gets a fresh arena region + expect(second.dynamicOffset).not.toBe(first.dynamicOffset); + // both writes upload the full std140 block + const writes = renderer.calls.writes; + expect(writes).toHaveLength(2); + expect(writes[0].size).toBe(BLOCK_BYTES); + // header float 0 = count, floats 4..6 = ambient; first light + // starts at float 8 + expect(writes[0].floats[0]).toBe(1); + expect(writes[0].floats[4]).toBeCloseTo(0.1); + expect(writes[0].floats[8]).toBe(100); + expect(writes[0].floats[15]).toBe(9); + expect(writes[1].floats[0]).toBe(0); + }); + + it("draws lit segments with the combined material and the light block", () => { + const uploads = []; + renderer.device.queue.copyExternalImageToTexture = (src, dst, size) => { + uploads.push({ source: src.source, size }); + }; + const atlas = { name: "colors" }; + const normalMap = { width: 64, height: 64 }; + + lit.setLightUniforms(packed()); + lit.addQuad( + atlas, + 0, + 0, + 32, + 32, + 0, + 0, + 1, + 1, + 0xffffffff, + false, + normalMap, + ); + lit.flush(); + + // one indexed quad through group 1 (combined) + group 2 (lights) + expect(renderer.calls.drawIndexed).toEqual([6]); + const byIndex = new Map( + renderer.calls.bindGroups.map((bind) => { + return [bind.index, bind]; + }), + ); + expect(byIndex.get(1).group.entries).toHaveLength(4); + expect(byIndex.get(2).dynamicOffsets).toEqual([ + lit.lightBinding.dynamicOffset, + ]); + // the normal map uploaded without premultiplication + expect(uploads).toHaveLength(1); + }); + + it("normal maps re-upload only when their version stamp advances", () => { + const uploads = []; + renderer.device.queue.copyExternalImageToTexture = (src) => { + uploads.push(src.source); + }; + const source = { width: 8, height: 8, version: 1 }; + + lit.residentNormalMap(source); + lit.residentNormalMap(source); + expect(uploads).toHaveLength(1); + + source.version = 2; + lit.residentNormalMap(source); + expect(uploads).toHaveLength(2); + }); + + it("stamps the light binding with the frame it was snapshotted in", () => { + expect(lit.hasCurrentLightBinding(renderer.frameId)).toBe(false); + lit.setLightUniforms(packed()); + expect(lit.hasCurrentLightBinding(renderer.frameId)).toBe(true); + // the binding points into the per-frame arena — it must read as + // absent once the frame advances past it + expect(lit.hasCurrentLightBinding(renderer.frameId + 1)).toBe(false); + }); + + it("a version bump on a map already sampled this frame gets a fresh texture", () => { + const retired = []; + renderer.retireTexture = (texture) => { + retired.push(texture); + }; + const source = { width: 8, height: 8, version: 1 }; + + const first = lit.residentNormalMap(source); + source.version = 2; + // same frame: an in-place re-upload would retroactively repaint + // draws already recorded against the old pixels + const second = lit.residentNormalMap(source); + expect(second.texture).not.toBe(first.texture); + expect(retired).toEqual([first.texture]); + + // across a frame boundary the resident texture re-uploads in place + renderer.frameId++; + source.version = 3; + const third = lit.residentNormalMap(source); + expect(third.texture).toBe(second.texture); + expect(retired).toHaveLength(1); + }); + + it("reset() retires the resident normal maps and drops every lit cache", () => { + const retired = []; + renderer.retireTexture = (texture) => { + retired.push(texture); + }; + lit.setLightUniforms(packed()); + lit.addQuad( + { name: "colors" }, + 0, + 0, + 32, + 32, + 0, + 0, + 1, + 1, + 0xffffffff, + false, + { + width: 8, + height: 8, + }, + ); + lit.flush(); + const resident = [...lit.normalTextures.values()][0].texture; + + lit.reset(); + + expect(retired).toContain(resident); + expect(lit.normalTextures.size).toBe(0); + expect(lit.litMaterials.size).toBe(0); + expect(lit.lightBindGroups.size).toBe(0); + expect(lit.lightBinding).toBeNull(); + expect(lit.hasCurrentLightBinding(renderer.frameId)).toBe(false); + }); + + it("clearMaterialCache drops the combined bind groups but keeps the resident maps", () => { + lit.setLightUniforms(packed()); + lit.addQuad( + { name: "colors" }, + 0, + 0, + 32, + 32, + 0, + 0, + 1, + 1, + 0xffffffff, + false, + { + width: 8, + height: 8, + }, + ); + lit.flush(); + expect(lit.litMaterials.size).toBe(1); + + lit.clearMaterialCache(); + + expect(lit.litMaterials.size).toBe(0); + // textures stay resident — only the sampler pairing is rebuilt + expect(lit.normalTextures.size).toBe(1); + }); + + it("a normal-map change flushes the pending segment", () => { + lit.setLightUniforms(packed()); + const atlas = { name: "colors" }; + lit.addQuad(atlas, 0, 0, 32, 32, 0, 0, 1, 1, 0xffffffff, false, { + width: 8, + height: 8, + }); + lit.addQuad(atlas, 32, 0, 32, 32, 0, 0, 1, 1, 0xffffffff, false, { + width: 8, + height: 8, + }); + lit.flush(); + // two different normal-map sources → two segments of one quad each + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + }); + }); + + describe("WebGPURenderer.drawImage (lit-gate dispatch)", () => { + const createStub = (overrides = {}) => { + const added = []; + const batcherNames = []; + const litBatcher = { + // a current-frame binding by default; tests stale it explicitly + binding: { frameId: 1 }, + hasCurrentLightBinding(frameId) { + return this.binding !== null && this.binding.frameId === frameId; + }, + }; + return { + added, + batcherNames, + litBatcher, + settings: { subPixel: false }, + batchers: new Map([["litQuad", litBatcher]]), + customShader: undefined, + activeLightCount: 1, + currentNormalMap: { width: 8, height: 8 }, + frameId: 1, + setBatcher(name) { + batcherNames.push(name); + this.currentBatcher = { + addQuad(...args) { + added.push(args); + }, + }; + return this.currentBatcher; + }, + cache: { + get() { + return { + getUVs() { + return [0, 0, 1, 1]; + }, + }; + }, + }, + currentTint: new Color(255, 255, 255, 1), + getGlobalAlpha() { + return 1; + }, + ...overrides, + }; + }; + const drawImage = WebGPURenderer.prototype.drawImage; + const image = { width: 32, height: 32 }; + + it("routes a normal-mapped sprite in a lit frame to the lit batcher", () => { + const stub = createStub(); + drawImage.call(stub, image, 0, 0); + expect(stub.batcherNames).toEqual(["litQuad"]); + // the paired normal map rides along as the last addQuad argument + expect(stub.added[0][11]).toBe(stub.currentNormalMap); + }); + + it("an active customShader wins over the lit gate (fast path, never silently dropped)", () => { + const stub = createStub({ customShader: { id: "effect" } }); + drawImage.call(stub, image, 0, 0); + expect(stub.batcherNames).toEqual(["quad"]); + expect(stub.added[0][11]).toBeUndefined(); + }); + + it("a stale (previous-frame) light binding disqualifies the lit path", () => { + const stub = createStub(); + stub.litBatcher.binding.frameId = 0; + drawImage.call(stub, image, 0, 0); + expect(stub.batcherNames).toEqual(["quad"]); + }); + + it("stays on the quad batcher without lights, a normal map, or a lit tier", () => { + const unlit = createStub({ activeLightCount: 0 }); + drawImage.call(unlit, image, 0, 0); + expect(unlit.batcherNames).toEqual(["quad"]); + + const unmapped = createStub({ currentNormalMap: null }); + drawImage.call(unmapped, image, 0, 0); + expect(unmapped.batcherNames).toEqual(["quad"]); + + const bare = createStub({ batchers: new Map() }); + drawImage.call(bare, image, 0, 0); + expect(bare.batcherNames).toEqual(["quad"]); + }); + }); + + describe("WebGPURenderer.drawLight (state machine)", () => { + it("adopts the radial effect for the quad, packing color+intensity into the tint, and restores the previous shader", () => { + const added = []; + const stub = { + customShader: { id: "sprite-effect" }, + shaderLanguage: "wgsl", + getLightAtlas: WebGPURenderer.prototype.getLightAtlas, + setBatcher() { + return { + addQuad(...args) { + added.push({ shader: stub.customShader, args }); + }, + }; + }, + }; + const light = { + pos: { x: 10, y: 20 }, + width: 100, + height: 80, + color: new Color(255, 128, 0), + }; + + // RadialGradientEffect construction inside drawLight needs a + // renderer with a shader language — the stub reports wgsl but has + // no device, so the effect stays inert; the state machine is what + // this pins + WebGPURenderer.prototype.drawLight.call(stub, light); + + expect(added).toHaveLength(1); + // the quad drew under the adopted light shader… + expect(added[0].shader).toBe(stub.lightShader); + // …and the previous customShader came back + expect(stub.customShader).toEqual({ id: "sprite-effect" }); + // tint packs the light's color and intensity + expect(added[0].args[9]).toBe(light.color.toUint32(1)); + // the shared 1×1 white atlas is reused across calls + WebGPURenderer.prototype.drawLight.call(stub, light); + expect(added[1].args[0]).toBe(added[0].args[0]); + }); + }); +}); diff --git a/packages/melonjs/tests/webgpu_pipeline.spec.js b/packages/melonjs/tests/webgpu_pipeline.spec.js index dd327f454d..e34b6e1938 100644 --- a/packages/melonjs/tests/webgpu_pipeline.spec.js +++ b/packages/melonjs/tests/webgpu_pipeline.spec.js @@ -24,6 +24,9 @@ function createMockDevice() { createBindGroupLayout(descriptor) { return { label: descriptor.label }; }, + createBindGroup(descriptor) { + return { label: descriptor.label }; + }, createShaderModule(descriptor) { return { label: descriptor.label }; }, @@ -228,6 +231,33 @@ describe("WebGPU pipeline (device-free units)", () => { expect(none.depthStencil.stencilWriteMask).toBe(0); }); + it("gradient-mask variants: tag replaces unconditionally, mark replaces behind the low-7-bit compare", () => { + const { cache } = makeCache(); + const descriptorFor = (stencilMode) => { + return cache.get("quad", "triangle-list", "normal", true, stencilMode) + .descriptor; + }; + + // tag: stamp the shape's pixels with the dynamic reference on a + // cleared stencil — replace (not increment) so shape overdraw + // (fan/earcut geometry) can never double-count + const tag = descriptorFor("tag"); + expect(tag.fragment.targets[0].writeMask).toBe(0); + expect(tag.depthStencil.stencilFront.compare).toBe("always"); + expect(tag.depthStencil.stencilFront.passOp).toBe("replace"); + expect(tag.depthStencil.stencilWriteMask).toBe(0xff); + + // mark: only pixels whose low 7 bits equal the reference's get + // the full reference written (mask levels never use the high + // bit, so the marker cannot collide with one) + const mark = descriptorFor("mark"); + expect(mark.fragment.targets[0].writeMask).toBe(0); + expect(mark.depthStencil.stencilFront.compare).toBe("equal"); + expect(mark.depthStencil.stencilFront.passOp).toBe("replace"); + expect(mark.depthStencil.stencilReadMask).toBe(0x7f); + expect(mark.depthStencil.stencilWriteMask).toBe(0xff); + }); + it("registered vertex layouts land in the descriptor with declaration-order locations", () => { const { cache } = makeCache(); const descriptor = cache.get( @@ -268,5 +298,61 @@ describe("WebGPU pipeline (device-free units)", () => { .fragment.targets[0].blend, ).toBeUndefined(); }); + + it("registerShader dedupes by module text: same body → same family + module", () => { + const { cache } = makeCache(); + const codeA = "fn a() {}"; + const keyA = cache.registerShader(codeA); + const keyAgain = cache.registerShader(codeA); + const keyB = cache.registerShader("fn b() {}"); + expect(keyAgain).toBe(keyA); + expect(keyB).not.toBe(keyA); + expect(cache.modules[keyA]).toBeDefined(); + }); + + it("registered families ride an aliased vertex layout through get()", () => { + const { cache } = makeCache(); + const key = cache.registerShader("fn c() {}", { + vertexLayoutKey: "quad", + }); + const descriptor = cache.get( + key, + "triangle-list", + "normal", + true, + ).descriptor; + // the frozen 28-byte quad layout, without re-registering it + expect(descriptor.vertex.buffers[0].arrayStride).toBe(28); + // same family + same state tuple → cached pipeline + expect(cache.get(key, "triangle-list", "normal", true)).toBe( + cache.get(key, "triangle-list", "normal", true), + ); + }); + + it("effect layouts are cached by shape signature", () => { + const { cache } = makeCache(); + const entries = [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: "uniform", hasDynamicOffset: true }, + }, + ]; + const a = cache.getEffectLayout("u16", entries); + const b = cache.getEffectLayout("u16", entries); + const c = cache.getEffectLayout("u32", entries); + expect(b).toBe(a); + expect(c).not.toBe(a); + }); + + it("exposes the shared empty group (reserved group-2 slot) and an epoch", () => { + const first = makeCache().cache; + const second = makeCache().cache; + expect(first.emptyLayout).toBeDefined(); + expect(first.emptyBindGroup).toBeDefined(); + // each construction (device loss) bumps the epoch — consumers + // lazily rebuild device-scoped state on mismatch + expect(second.epoch).toBeGreaterThan(first.epoch); + }); }); }); diff --git a/packages/melonjs/tests/webgpu_post_effect.spec.js b/packages/melonjs/tests/webgpu_post_effect.spec.js new file mode 100644 index 0000000000..fa7fc2c3c5 --- /dev/null +++ b/packages/melonjs/tests/webgpu_post_effect.spec.js @@ -0,0 +1,429 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ShaderEffect } from "../src/index.js"; +import { prepareEffectBinding } from "../src/video/webgpu/effect_binding.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * The WebGPU effect draw path on the mock renderer: lazy GPU build, the + * snapshot-per-bind uniform scheme (THE correctness invariant under the + * queue-write-before-draws ordering), bind-group caching/invalidation, + * and the blit's pipeline/bind-group recording. + */ +describe("WebGPU effect binding (mock renderer)", () => { + const BODY = ` +struct Fx { uStrength : f32, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(color.rgb * fx.uStrength, color.a); +} +`; + let renderer; + + function makeEffect(body = BODY) { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect({ shaderLanguage: "wgsl" }, { wgsl: body }); + warn.mockRestore(); + return effect; + } + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + }); + + it("builds device state lazily and registers one module per body text", () => { + const effect = makeEffect(); + const binding = prepareEffectBinding(renderer, effect); + expect(binding.key).toBe("effect:0"); + expect(binding.hasEffectGroup).toBe(true); + + // a clone shares the module (same text → same family key) + const clone = effect.clone(); + expect(prepareEffectBinding(renderer, clone).key).toBe("effect:0"); + }); + + it("each bind snapshots the mirror into a FRESH region (dynamic offsets differ)", () => { + const effect = makeEffect(); + effect.setUniform("uStrength", 0.25); + const first = prepareEffectBinding(renderer, effect); + + effect.setUniform("uStrength", 0.75); + const second = prepareEffectBinding(renderer, effect); + + // two binds, two regions — the first draw keeps its own bytes even + // though queue writes all land before any draw executes + expect(second.dynamicOffsets[0]).toBeGreaterThan(first.dynamicOffsets[0]); + expect(renderer.calls.writes[0].floats[0]).toBeCloseTo(0.25); + expect(renderer.calls.writes[1].floats[0]).toBeCloseTo(0.75); + // same page → the cached bind group is reused across binds + expect(second.bindGroup).toBe(first.bindGroup); + }); + + it("snapshot offsets honor the device's uniform-offset alignment", () => { + const effect = makeEffect(); + prepareEffectBinding(renderer, effect); + const second = prepareEffectBinding(renderer, effect); + expect(second.dynamicOffsets[0] % 256).toBe(0); + }); + + it("an effect with no group-3 declarations binds no effect group", () => { + const effect = makeEffect( + "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + ); + const binding = prepareEffectBinding(renderer, effect); + expect(binding.hasEffectGroup).toBe(false); + expect(binding.bindGroup).toBeNull(); + expect(renderer.calls.writes).toHaveLength(0); + }); + + it("a GLSL-only effect yields no binding (the blit falls back to a plain quad)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect( + { shaderLanguage: "wgsl" }, + "vec4 apply(vec4 c, vec2 uv) { return c; }", + ); + warn.mockRestore(); + expect(prepareEffectBinding(renderer, effect)).toBeNull(); + }); + + it("a device epoch bump rebuilds the GPU state lazily", () => { + const effect = makeEffect(); + prepareEffectBinding(renderer, effect); + const before = effect.wgslRealization.gpu; + + renderer.pipelineCache.epoch = 2; + prepareEffectBinding(renderer, effect); + expect(effect.wgslRealization.gpu).not.toBe(before); + expect(effect.wgslRealization.gpu.epoch).toBe(2); + }); + + it("noise_uv effects get a second dynamic offset for the ME block", () => { + const effect = makeEffect(` +struct Fx { uStrength : f32, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(color.rgb * fx.uStrength * noise_uv.x, color.a); +} +`); + effect._setNoiseUVRect(256, 128, 64, 32, 0, 0); + const binding = prepareEffectBinding(renderer, effect); + expect(binding.dynamicOffsets).toHaveLength(2); + // ME slice starts at the next aligned boundary after the struct + expect(binding.dynamicOffsets[1] - binding.dynamicOffsets[0]).toBe(256); + // the ME write carries size_obj/size_img + const meWrite = renderer.calls.writes[1]; + expect([...meWrite.floats.slice(0, 4)]).toEqual([64, 32, 256, 128]); + }); + + it("screen_texture binds the capture when present, the stub when not", () => { + const effect = makeEffect(` +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureSample(screen_texture, screen_sampler, screen_uv); +} +`); + const withoutCapture = prepareEffectBinding(renderer, effect); + const stubEntry = withoutCapture.bindGroup.entries.find((entry) => { + return entry.resource === renderer.stubView; + }); + expect(stubEntry).toBeDefined(); + + // a capture appears → the generation-keyed bind group rebuilds + renderer.captureTexture = { view: { capture: true }, generation: 1 }; + const withCapture = prepareEffectBinding(renderer, effect); + expect(withCapture.bindGroup).not.toBe(withoutCapture.bindGroup); + const captureEntry = withCapture.bindGroup.entries.find((entry) => { + return entry.resource === renderer.captureTexture.view; + }); + expect(captureEntry).toBeDefined(); + }); + + it("clamp-only and repeat-only screen-sampler shapes get DIFFERENT layouts", () => { + const clampOnly = makeEffect(` +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureSample(screen_texture, screen_sampler, screen_uv); +} +`); + const repeatOnly = makeEffect(` +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureSample(screen_texture, screen_sampler_repeat, screen_uv); +} +`); + prepareEffectBinding(renderer, clampOnly); + prepareEffectBinding(renderer, repeatOnly); + // a shared cached layout between the two shapes breaks whichever + // registers second (bind-group entries mismatch the layout) + expect(clampOnly.wgslRealization.gpu.effectLayout).not.toBe( + repeatOnly.wgslRealization.gpu.effectLayout, + ); + }); + + it("an async WGSL compilation failure disables the effect (never a permanent black screen)", async () => { + const effect = makeEffect(); + // simulate the device reporting errors for this module + const key = renderer.pipelineCache.registerShader( + effect.wgslRealization.code, + ); + renderer.pipelineCache.modules[key].getCompilationInfo = () => { + return Promise.resolve({ + messages: [{ type: "error", message: "bad expr", lineNum: 3 }], + }); + }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + prepareEffectBinding(renderer, effect); + await Promise.resolve(); + await Promise.resolve(); + expect(effect.enabled).toBe(false); + expect(effect.wgslRealization.valid).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("compilation failed"), + ); + warn.mockRestore(); + }); + + it("a capture reallocation re-keys screen_texture bind groups (unique generations)", async () => { + const { WebGPUFrameTexture } = await import( + "../src/video/webgpu/texture/frametexture.js" + ); + const stub = { + preferredFormat: "bgra8unorm", + retiredTextures: [], + commandEncoder: null, + retireTexture() {}, + device: { + createTexture() { + return { + destroy() {}, + createView() { + return {}; + }, + }; + }, + }, + }; + const first = new WebGPUFrameTexture(stub, 64, 64); + const second = new WebGPUFrameTexture(stub, 128, 128); + // each allocation must carry a distinct generation — a static value + // left bind groups pointing at a destroyed capture after a resize + expect(second.generation).not.toBe(first.generation); + + // and the binding actually rebuilds when the capture changes + const effect = makeEffect(` +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureSample(screen_texture, screen_sampler, screen_uv); +} +`); + renderer.captureTexture = first; + const before = prepareEffectBinding(renderer, effect); + renderer.captureTexture = second; + const after = prepareEffectBinding(renderer, effect); + expect(after.bindGroup).not.toBe(before.bindGroup); + }); + + it("setTexture sources upload once and re-key the bind group", () => { + const effect = makeEffect(` +@group(3) @binding(1) var uNoise : texture_2d; +@group(3) @binding(2) var uNoiseSampler : sampler; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return color * textureSample(uNoise, uNoiseSampler, uv); +} +`); + const image = { width: 8, height: 8 }; + effect.setTexture("uNoise", image, "repeat"); + const binding = prepareEffectBinding(renderer, effect); + const samplerEntry = binding.bindGroup.entries.find((entry) => { + return entry.binding === 2; + }); + expect(samplerEntry.resource).toEqual({ + filter: "linear", + repeat: "repeat", + }); + }); +}); + +describe("WebGPU blitTexture (mock renderer)", () => { + const BODY = ` +struct Fx { uStrength : f32, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(color.rgb * fx.uStrength, color.a); +} +`; + + it("records one effect-pipeline quad with groups 0-3 and unflipped UVs", async () => { + const { default: WebGPUQuadBatcherClass } = await import( + "../src/video/webgpu/batchers/quad_batcher.js" + ); + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcherClass(renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect({ shaderLanguage: "wgsl" }, { wgsl: BODY }); + warn.mockRestore(); + const source = { + getMaterialBindGroup() { + return { rt: true }; + }, + }; + + batcher.blitTexture(source, 0, 0, 320, 240, effect, false); + + // pipeline: the effect family with blending replaced + expect(renderer.calls.pipelineKeys.at(-1)).toBe( + "effect:0|triangle-list|none|true|none", + ); + expect(renderer.calls.drawIndexed).toEqual([6]); + // groups: 0 frame, 1 source material, 2 empty, 3 effect + const indices = renderer.calls.bindGroups.map((b) => { + return b.index; + }); + expect(indices).toEqual([0, 1, 2, 3]); + expect(renderer.calls.bindGroups[1].group).toEqual({ rt: true }); + expect(renderer.calls.bindGroups[2].group).toBe( + renderer.pipelineCache.emptyBindGroup, + ); + + // the last write is the quad: 4×28 bytes, v=0 at the top (no flip) + const quadWrite = renderer.calls.writes.at(-1); + expect(quadWrite.size).toBe(112); + // vertex 0 uv = (0,0); vertex 2 (x, y+h) uv = (0,1) + expect([quadWrite.floats[3], quadWrite.floats[4]]).toEqual([0, 0]); + expect([quadWrite.floats[17], quadWrite.floats[18]]).toEqual([0, 1]); + }); + + it("fast path: adopting customShader drains the pending batch, then draws per quad", async () => { + const { default: WebGPUQuadBatcherClass } = await import( + "../src/video/webgpu/batchers/quad_batcher.js" + ); + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcherClass(renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect({ shaderLanguage: "wgsl" }, { wgsl: BODY }); + warn.mockRestore(); + const atlas = { + getTexture() { + return { width: 64, height: 32 }; + }, + }; + + // two plain quads batch together... + batcher.addQuad(atlas, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlas, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([]); + + // ...the effect sprite adopts: pending batch drains under the DEFAULT + // pipeline, then the effect quad draws alone under the effect family + renderer.customShader = effect; + effect.setUniform("uStrength", 0.5); + batcher.addQuad(atlas, 16, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12, 6]); + expect(renderer.calls.pipelineKeys[0]).toContain("quad|"); + // blending KEPT — live compositing is the fast path's semantic + expect(renderer.calls.pipelineKeys[1]).toBe( + "effect:0|triangle-list|normal|true|none", + ); + + // a second effect sprite is its own draw with its own snapshot + effect.setUniform("uStrength", 0.9); + batcher.addQuad(atlas, 24, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12, 6, 6]); + const snapshots = renderer.calls.writes.filter((write) => { + return write.size === 16; + }); + expect( + snapshots.map((write) => { + return write.floats[0]; + }), + ).toEqual([0.5, expect.closeTo(0.9)]); + + // clearing customShader returns to plain batching + renderer.customShader = undefined; + batcher.addQuad(atlas, 32, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.flush(); + expect(renderer.calls.pipelineKeys.at(-1)).toContain("quad|"); + }); + + it("fast path: screen_texture effects capture the backdrop before each sprite", async () => { + const { default: WebGPUQuadBatcherClass } = await import( + "../src/video/webgpu/batchers/quad_batcher.js" + ); + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcherClass(renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: ` +fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureSample(screen_texture, screen_sampler, screen_uv); +} +`, + }, + ); + warn.mockRestore(); + const atlas = { + getTexture() { + return { width: 8, height: 8 }; + }, + }; + + renderer.customShader = effect; + batcher.addQuad(atlas, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlas, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + // one capture per sprite, each BEFORE its draw + expect(renderer.calls.captureFrames).toBe(2); + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + }); + + it("fast path: noise_uv effects get the sprite's frame rect per draw", async () => { + const { default: WebGPUQuadBatcherClass } = await import( + "../src/video/webgpu/batchers/quad_batcher.js" + ); + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcherClass(renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const effect = new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return vec4f(noise_uv, 0.0, color.a); }", + }, + ); + warn.mockRestore(); + const atlas = { + getTexture() { + return { width: 256, height: 128 }; + }, + }; + + renderer.customShader = effect; + batcher.addQuad(atlas, 0, 0, 64, 32, 0.5, 0.25, 0.75, 0.5, 0xffffffff); + // ME mirror carries [obj w, obj h, img w, img h, offset x, offset y] + expect([...effect.wgslRealization.meMirror.slice(0, 6)]).toEqual([ + 64, 32, 256, 128, 128, 32, + ]); + }); + + it("an effect without a WGSL realization composites as a plain blit", async () => { + const { default: WebGPUQuadBatcherClass } = await import( + "../src/video/webgpu/batchers/quad_batcher.js" + ); + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcherClass(renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const glslOnly = new ShaderEffect( + { shaderLanguage: "wgsl" }, + "vec4 apply(vec4 c, vec2 uv) { return c; }", + ); + warn.mockRestore(); + const source = { + getMaterialBindGroup() { + return { rt: true }; + }, + }; + + batcher.blitTexture(source, 0, 0, 100, 100, glslOnly, true); + // plain quad family, current blend kept — scene content is never lost + expect(renderer.calls.pipelineKeys.at(-1)).toBe( + "quad|triangle-list|normal|true|none", + ); + expect(renderer.calls.drawIndexed).toEqual([6]); + }); +}); diff --git a/packages/melonjs/tests/webgpu_post_effect_flow.spec.js b/packages/melonjs/tests/webgpu_post_effect_flow.spec.js new file mode 100644 index 0000000000..ca16378e5b --- /dev/null +++ b/packages/melonjs/tests/webgpu_post_effect_flow.spec.js @@ -0,0 +1,215 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Color, Matrix3d } from "../src/index.js"; +import RenderTargetPool from "../src/video/rendertarget/render_target_pool.js"; +import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; + +/** + * The pooled post-effect CONTROL FLOW, exercised through the REAL + * beginPostEffect/endPostEffect prototype methods on a bare renderer whose + * primitives (setRenderTarget/captureFrame/blitEffect/clipRect/…) record + * into a log. This pins the ordering laws the WebGL parity depends on: + * pool targets fetched BEFORE pool.end(), camera-vs-sprite capture points, + * ping-pong clears, the camera viewport scissor bracket, and the per-depth + * projection stack. + */ +describe("WebGPU post-effect control flow (recorded primitives)", () => { + let renderer; + let log; + + function fakeTarget(name) { + return { + name, + width: 320, + height: 200, + pendingClear: false, + resize(w, h) { + this.width = w; + this.height = h; + }, + clear() { + this.pendingClear = true; + }, + bind() {}, + unbind() {}, + destroy() {}, + getMaterialBindGroup() { + return { rt: this.name }; + }, + }; + } + + function makeEffect(overrides = {}) { + return { + enabled: true, + _screenTextureUniforms: [], + ...overrides, + }; + } + + function makeRenderable(effects, options = {}) { + return { + postEffects: effects, + _postEffectManaged: options.camera === true, + isDefault: options.isDefault === true, + screenX: options.screenX ?? 10, + screenY: options.screenY ?? 20, + width: options.width ?? 180, + height: options.height ?? 100, + }; + } + + beforeEach(() => { + log = []; + let targetCount = 0; + renderer = Object.create(WebGPURenderer.prototype); + Object.assign(renderer, { + customShader: undefined, + effectProjectionStack: [], + effectPassDepth: 0, + projectionMatrix: new Matrix3d(), + backgroundColor: new Color(16, 32, 48), + currentBatcher: null, + device: {}, + _renderTargetPool: new RenderTargetPool(() => { + return fakeTarget(`rt${targetCount++}`); + }), + getCanvas() { + return { width: 320, height: 200 }; + }, + save() { + log.push("save"); + }, + restore() { + log.push("restore"); + }, + setRenderTarget(target, options = {}) { + this.currentRenderTarget = target ?? null; + log.push( + `target:${target?.name ?? "canvas"}${options.clear ? ":clear" : ""}`, + ); + }, + captureFrame() { + log.push(`capture:${this.currentRenderTarget?.name ?? "canvas"}`); + }, + blitEffect(source, x, y, w, h, effect, keepBlend) { + log.push( + `blit:${source.name}→${effect.name}:keep=${keepBlend === true}`, + ); + }, + clipRect(x, y, w, h) { + log.push(`clip:${x},${y},${w},${h}`); + }, + disableScissor() { + log.push("descissor"); + }, + setGlobalAlpha() {}, + setBlendMode() {}, + pushFrameGlobals() { + log.push("pushGlobals"); + }, + }); + }); + + it("camera chain: capture-before-retarget, viewport clip, ping-pong clears, replace blit", () => { + const fx1 = makeEffect({ + name: "fx1", + _screenTextureUniforms: [{ name: "screen_texture" }], + }); + const fx2 = makeEffect({ name: "fx2" }); + const camera = makeRenderable([fx1, fx2], { camera: true }); + + expect(renderer.beginPostEffect(camera)).toBe(true); + expect(renderer.effectPassDepth).toBe(1); + log.push("---scene---"); + renderer.endPostEffect(camera); + + expect(log).toEqual([ + "save", + // camera offscreen pass opens cleared + "target:rt0:clear", + "descissor", + "---scene---", + // the camera "screen" is the still-active offscreen target + "capture:rt0", + // non-default camera: composite clipped to its viewport + "clip:10,20,180,100", + "target:canvas", + // ping-pong: intermediate blit into a CLEARED pool target, + // blending replaced; final blit onto the parent, still replaced + // for cameras (fully composited content) + "target:rt1:clear", + "blit:rt0→fx1:keep=false", + "target:canvas", + "blit:rt1→fx2:keep=false", + "descissor", + "restore", + "pushGlobals", + ]); + expect(renderer.effectPassDepth).toBe(0); + }); + + it("sprite chain: transparent clear, capture AFTER the parent retarget, final blit keeps blending", () => { + const fx = makeEffect({ + name: "fx", + _screenTextureUniforms: [{ name: "screen_texture" }], + }); + const fx2 = makeEffect({ name: "fx2" }); + const sprite = makeRenderable([fx, fx2]); + + renderer.beginPostEffect(sprite); + log.push("---sprite---"); + renderer.endPostEffect(sprite); + + // the sprite's "screen" is everything BEHIND it: captured from the + // parent (canvas), after the retarget + const captureIndex = log.indexOf("capture:canvas"); + const retargetIndex = log.indexOf("target:canvas"); + expect(captureIndex).toBeGreaterThan(retargetIndex); + // sprite offscreen pass clears (transparent — no bg value recorded) + expect(log[1]).toBe("target:rt0:clear"); + // the final blit composites WITH blending (transparent texels must + // not overwrite the scene) + expect(log.at(-3)).toBe("blit:rt1→fx2:keep=true"); + }); + + it("nesting: a sprite pass inside a camera pass restores the camera's projection slot", () => { + const cameraFx = [makeEffect({ name: "c1" }), makeEffect({ name: "c2" })]; + const spriteFx = [makeEffect({ name: "s1" }), makeEffect({ name: "s2" })]; + const camera = makeRenderable(cameraFx, { camera: true }); + const sprite = makeRenderable(spriteFx); + + renderer.projectionMatrix.translate(7, 0, 0); + const cameraProjection = renderer.projectionMatrix.clone(); + + renderer.beginPostEffect(camera); + renderer.projectionMatrix.identity(); + renderer.beginPostEffect(sprite); + expect(renderer.effectPassDepth).toBe(2); + renderer.endPostEffect(sprite); + expect(renderer.effectPassDepth).toBe(1); + renderer.endPostEffect(camera); + + expect(renderer.effectPassDepth).toBe(0); + // the OUTER slot restored the camera's projection, not the sprite's + expect(renderer.projectionMatrix.equals(cameraProjection)).toBe(true); + }); + + it("the single-effect non-camera case takes the fast path (no pool, no bracket)", () => { + const fx = makeEffect({ name: "solo" }); + const sprite = makeRenderable([fx]); + expect(renderer.beginPostEffect(sprite)).toBe(false); + expect(renderer.customShader).toBe(fx); + expect(log).toEqual([]); + renderer.endPostEffect(sprite); + expect(log).toEqual([]); + }); + + it("disabled effects are filtered before any pooling decision", () => { + const sprite = makeRenderable([ + makeEffect({ name: "off", enabled: false }), + ]); + expect(renderer.beginPostEffect(sprite)).toBe(false); + expect(renderer.customShader).toBeUndefined(); + }); +}); diff --git a/packages/melonjs/tests/webgpu_render_target.spec.js b/packages/melonjs/tests/webgpu_render_target.spec.js new file mode 100644 index 0000000000..8e79e3c3e2 --- /dev/null +++ b/packages/melonjs/tests/webgpu_render_target.spec.js @@ -0,0 +1,162 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import RenderTargetPool from "../src/video/rendertarget/render_target_pool.js"; +import WebGPURenderTarget from "../src/video/rendertarget/webgpurendertarget.js"; +import { WebGPUFrameTexture } from "../src/video/webgpu/texture/frametexture.js"; + +/** + * WebGPURenderTarget + the shared frame capture against a mock device: + * texture lifecycle under the recording model (retire-not-destroy while a + * frame records), generation-keyed bind-group invalidation, and the pool + * wiring the post-effect chain relies on. + */ +describe("WebGPURenderTarget (mock device)", () => { + let renderer; + let created; + + beforeEach(() => { + created = []; + renderer = { + preferredFormat: "bgra8unorm", + retiredTextures: [], + commandEncoder: null, + device: { + createTexture(descriptor) { + const texture = { + label: descriptor.label, + size: descriptor.size, + destroyed: false, + destroy() { + this.destroyed = true; + }, + createView() { + return { texture: this }; + }, + }; + created.push(texture); + return texture; + }, + createBindGroup(descriptor) { + return { entries: descriptor.entries }; + }, + }, + pipelineCache: { materialLayout: {} }, + textureStore: { + getSampler(filter, repeat) { + return { filter, repeat }; + }, + }, + retireTexture(texture) { + if (this.commandEncoder !== null) { + this.retiredTextures.push(texture); + } else { + texture.destroy(); + } + }, + setRenderTarget(target) { + this.currentRenderTarget = target; + }, + currentRenderTarget: null, + }; + }); + + it("creates a renderable+sampleable color texture in the canvas format", () => { + const rt = new WebGPURenderTarget(renderer, 320, 200); + expect(rt.width).toBe(320); + expect(created[0].size).toEqual([320, 200]); + expect(rt.colorView).toBeDefined(); + }); + + it("resize is a no-op at the same size, reallocates + bumps generation otherwise", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + const generation = rt.generation; + rt.resize(64, 64); + expect(created).toHaveLength(1); + expect(rt.generation).toBe(generation); + + rt.resize(128, 64); + expect(created).toHaveLength(2); + expect(rt.generation).toBe(generation + 1); + // no frame recording → the old texture is destroyed immediately + expect(created[0].destroyed).toBe(true); + }); + + it("mid-frame resize/destroy RETIRE the texture instead of destroying it", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + renderer.commandEncoder = {}; + rt.resize(128, 128); + expect(created[0].destroyed).toBe(false); + expect(renderer.retiredTextures).toContain(created[0]); + + rt.destroy(); + expect(created[1].destroyed).toBe(false); + expect(renderer.retiredTextures).toContain(created[1]); + expect(rt.texture).toBeNull(); + }); + + it("the material bind group is cached per generation", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + const first = rt.getMaterialBindGroup(); + expect(rt.getMaterialBindGroup()).toBe(first); + // the blit source samples linearly, clamped + expect(first.entries[1].resource).toEqual({ + filter: "linear", + repeat: "no-repeat", + }); + + rt.resize(32, 32); + expect(rt.getMaterialBindGroup()).not.toBe(first); + }); + + it("clear() defers to the next retarget (pendingClear flag)", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + rt.clear(); + expect(rt.pendingClear).toBe(true); + }); + + it("bind()/unbind() delegate to the renderer's retarget primitive", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + rt.bind(); + expect(renderer.currentRenderTarget).toBe(rt); + rt.unbind(); + expect(renderer.currentRenderTarget).toBeNull(); + }); + + it("getImageData throws with async guidance (WebGPU readback is async)", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + expect(() => { + return rt.getImageData(); + }).toThrow(/readPixels/); + }); + + it("the render-target pool composes with the WebGPU factory (camera 0/1, sprite 2/3)", () => { + const pool = new RenderTargetPool((w, h) => { + return new WebGPURenderTarget(renderer, w, h); + }); + const camera = pool.begin(true, 2, 100, 50); + expect(camera).toBeInstanceOf(WebGPURenderTarget); + expect(camera).toBe(pool.getCaptureTarget()); + expect(pool.getPingPongTarget()).not.toBe(camera); + + const sprite = pool.begin(false, 1, 100, 50); + expect(sprite).not.toBe(camera); + expect(pool.end()).toBe(camera); + expect(pool.end()).toBeNull(); + pool.destroy(); + }); + + it("the shared frame capture reallocates by size and retires safely", () => { + const capture = new WebGPUFrameTexture(renderer, 64, 64); + expect(capture.isGPUResident).toBe(true); + expect(capture.getTexture()).toBe(capture); + expect(created[0].label).toContain("capture"); + + renderer.commandEncoder = {}; + capture.destroy(); + expect(renderer.retiredTextures).toContain(created[0]); + // idempotent + expect(() => { + return capture.destroy(); + }).not.toThrow(); + }); +}); diff --git a/packages/melonjs/tests/webgpu_texture_store.spec.js b/packages/melonjs/tests/webgpu_texture_store.spec.js index 3946b27006..75db7b1435 100644 --- a/packages/melonjs/tests/webgpu_texture_store.spec.js +++ b/packages/melonjs/tests/webgpu_texture_store.spec.js @@ -78,6 +78,13 @@ describe("WebGPUTextureStore", () => { frameId: 1, commandEncoder: null, retiredTextures: [], + retireTexture(texture) { + if (this.commandEncoder !== null) { + this.retiredTextures.push(texture); + } else { + texture.destroy(); + } + }, cache: { getUnit(texture) { return texture.__unit; diff --git a/packages/melonjs/tests/webgpu_tmxlayer.spec.js b/packages/melonjs/tests/webgpu_tmxlayer.spec.js new file mode 100644 index 0000000000..43d0f24d90 --- /dev/null +++ b/packages/melonjs/tests/webgpu_tmxlayer.spec.js @@ -0,0 +1,162 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js"; +import OrthogonalTMXLayerGPURenderer from "../src/video/webgpu/renderers/tmxlayer/orthogonal.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * The WebGPU orthogonal TMX tile path on the mock renderer: index-texture + * version tracking, animation-lookup dirty semantics, per-tileset uniform + * snapshots (dynamic offsets), and the recorded draw shape — one indexed + * quad per tileset through the registered tmx shader family. + */ +describe("WebGPU orthogonal TMX layer renderer (mock)", () => { + let renderer; + let tmx; + + function makeLayer(options = {}) { + const cols = options.cols ?? 8; + const rows = options.rows ?? 4; + return { + cols, + rows, + tilewidth: 16, + tileheight: 16, + dataVersion: options.dataVersion ?? 1, + layerData: new Uint16Array(cols * rows * 2), + getOpacity() { + return options.opacity ?? 1; + }, + tint: null, + tilesets: { + tilesets: options.tilesets ?? [makeTileset()], + }, + }; + } + + function makeTileset(options = {}) { + return { + isCollection: false, + image: { width: 128, height: 64 }, + tilewidth: options.tilewidth ?? 16, + tileheight: options.tileheight ?? 16, + margin: 0, + spacing: 0, + firstgid: options.firstgid ?? 1, + lastgid: options.lastgid ?? 32, + isAnimated: options.animated === true, + animations: options.animations ?? new Map(), + texture: { name: `atlas-${options.firstgid ?? 1}` }, + }; + } + + const RECT = { pos: { x: 0, y: 0 }, width: 64, height: 32 }; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + renderer.setBatcher = () => { + renderer.currentBatcher ??= new WebGPUQuadBatcher(renderer); + return renderer.currentBatcher; + }; + tmx = new OrthogonalTMXLayerGPURenderer(renderer); + }); + + it("registers the shader family once with the quad vertex layout", () => { + expect(tmx.key).toMatch(/^effect:/); + // a second instance (device-loss rebuild) reuses the module text + const again = new OrthogonalTMXLayerGPURenderer(renderer); + expect(again.key).toBe(tmx.key); + }); + + it("uploads the GID index once per data version", () => { + const layer = makeLayer(); + tmx.draw(layer, RECT); + const uploads = renderer.calls.textureWrites.length; + expect(uploads).toBeGreaterThan(0); + + // same version → no re-upload + tmx.draw(layer, RECT); + expect(renderer.calls.textureWrites.length).toBe(uploads); + + // a setTile-style mutation bumps the version → one re-upload + layer.dataVersion = 2; + tmx.draw(layer, RECT); + expect(renderer.calls.textureWrites.length).toBe(uploads + 1); + }); + + it("records one indexed quad per tileset with its own dynamic offset", () => { + const layer = makeLayer({ + tilesets: [ + makeTileset({ firstgid: 1, lastgid: 16 }), + makeTileset({ firstgid: 17, lastgid: 32 }), + ], + }); + tmx.draw(layer, RECT); + + expect(renderer.calls.drawIndexed).toEqual([6, 6]); + // two group-3 binds with DIFFERENT snapshot offsets (each tileset + // pass owns its uniform bytes under queue-write ordering) + const tmxBinds = renderer.calls.bindGroups.filter((bind) => { + return bind.index === 3; + }); + expect(tmxBinds).toHaveLength(2); + expect(tmxBinds[0].dynamicOffsets[0]).not.toBe( + tmxBinds[1].dynamicOffsets[0], + ); + // each pass binds its tileset's atlas as the material + const materials = renderer.calls.bindGroups.filter((bind) => { + return bind.index === 1; + }); + expect(materials).toHaveLength(2); + }); + + it("animation lookups upload only when a frame advanced", () => { + const animations = new Map([[3, { cur: { tileid: 5 } }]]); + const layer = makeLayer({ + tilesets: [makeTileset({ animated: true, animations })], + }); + + tmx.draw(layer, RECT); + const uploads = renderer.calls.textureWrites.length; + + // same frame id → clean, no upload + tmx.draw(layer, RECT); + expect(renderer.calls.textureWrites.length).toBe(uploads); + + // the tileset advanced a frame → one lookup re-upload + animations.get(3).cur.tileid = 6; + tmx.draw(layer, RECT); + expect(renderer.calls.textureWrites.length).toBe(uploads + 1); + }); + + it("a visible rect fully outside the layer draws nothing", () => { + const layer = makeLayer(); + tmx.draw(layer, { + pos: { x: 10000, y: 10000 }, + width: 64, + height: 64, + }); + expect(renderer.calls.drawIndexed).toEqual([]); + }); + + it("reset retires every lookup texture and clears the caches", () => { + const retired = []; + renderer.retireTexture = (texture) => { + retired.push(texture); + }; + const layer = makeLayer({ + tilesets: [ + makeTileset({ + animated: true, + animations: new Map([[0, { cur: { tileid: 1 } }]]), + }), + ], + }); + tmx.draw(layer, RECT); + tmx.reset(); + // index texture + anim lookup both retired + expect(retired).toHaveLength(2); + expect(tmx.resources.size).toBe(0); + expect(tmx.animLookups.size).toBe(0); + }); +}); diff --git a/packages/melonjs/tests/webgpu_toframetexture.spec.js b/packages/melonjs/tests/webgpu_toframetexture.spec.js new file mode 100644 index 0000000000..1d6d5fa39d --- /dev/null +++ b/packages/melonjs/tests/webgpu_toframetexture.spec.js @@ -0,0 +1,200 @@ +import "./helpers/webgpu-globals.js"; +import { describe, expect, it } from "vitest"; +import { WebGPURenderer } from "../src/index.js"; +import WebGPUFrameTexture from "../src/video/webgpu/texture/frametexture.js"; + +/** + * The WebGPU toFrameTexture contract — target resolution (shared slot / + * caller-owned / refresh-in-place), region clamping with the bottom-left + * → top-left origin conversion, in-place realloc semantics, and the + * encoder-ordered copy — pinned through the real prototype over a + * recording stub, mirroring what toframetexture.spec.js pins for WebGL. + */ +function createStub({ width = 800, height = 600 } = {}) { + const copies = []; + const retired = []; + const stub = { + copies, + retired, + preferredFormat: "bgra8unorm", + device: { + createTexture(descriptor) { + return { + descriptor, + destroy() {}, + createView() { + return { texture: this }; + }, + }; + }, + createCommandEncoder() { + return { + copyTextureToTexture(src, dst, size) { + copies.push({ src, dst, size }); + }, + }; + }, + }, + commandEncoder: null, + renderPass: { + end() {}, + }, + currentBatcher: { flush() {} }, + currentRenderTarget: null, + frameTexture: { label: "canvas texture" }, + context: { + getCurrentTexture() { + return { label: "canvas texture" }; + }, + }, + captureTexture: undefined, + getTargetSize() { + return [width, height]; + }, + retireTexture(texture) { + retired.push(texture); + }, + }; + return stub; +} + +const toFrameTexture = WebGPURenderer.prototype.toFrameTexture; + +describe("WebGPURenderer.toFrameTexture (contract)", () => { + it("returns a GPU-resident capture sized to the frame", () => { + const stub = createStub(); + const frame = toFrameTexture.call(stub); + expect(frame).toBeInstanceOf(WebGPUFrameTexture); + expect(frame.isGPUResident).toBe(true); + expect(frame.width).toBe(800); + expect(frame.height).toBe(600); + expect(stub.copies).toHaveLength(1); + expect(stub.copies[0].size).toEqual([800, 600]); + expect(stub.copies[0].src.origin).toEqual([0, 0]); + }); + + it("parameterless calls reuse the shared slot in place (same object, same generation)", () => { + const stub = createStub(); + const first = toFrameTexture.call(stub); + const again = toFrameTexture.call(stub); + expect(again).toBe(first); + expect(again.generation).toBe(first.generation); + expect(stub.retired).toHaveLength(0); + expect(stub.copies).toHaveLength(2); + }); + + it("target: null mints an independent caller-owned capture", () => { + const stub = createStub(); + const shared = toFrameTexture.call(stub); + const owned = toFrameTexture.call(stub, { target: null }); + expect(owned).not.toBe(shared); + expect(stub.captureTexture).toBe(shared); + }); + + it("a prior capture as target refreshes it in place", () => { + const stub = createStub(); + const owned = toFrameTexture.call(stub, { target: null }); + const refreshed = toFrameTexture.call(stub, { target: owned }); + expect(refreshed).toBe(owned); + expect(stub.captureTexture).toBeUndefined(); + }); + + it("refreshing a destroyed caller-owned capture reallocates its backing", () => { + const stub = createStub(); + const owned = toFrameTexture.call(stub, { target: null }); + const oldGeneration = owned.generation; + owned.destroy(); + expect(owned.gpuTexture).toBeNull(); + + // a released backing must realloc (same object identity, advanced + // generation) — copying into a destroyed texture would reject the + // whole frame at submit + const refreshed = toFrameTexture.call(stub, { target: owned }); + expect(refreshed).toBe(owned); + expect(refreshed.gpuTexture).not.toBeNull(); + expect(refreshed.view).not.toBeNull(); + expect(refreshed.generation).toBeGreaterThan(oldGeneration); + // destroy() retired the old backing; the realloc had nothing to retire + expect(stub.retired).toHaveLength(1); + expect(stub.copies[1].dst.texture).toBe(refreshed.gpuTexture); + }); + + it("a size change reallocates in place: same object, advanced generation, old texture retired", () => { + const stub = createStub(); + const frame = toFrameTexture.call(stub); + const oldTexture = frame.gpuTexture; + const oldGeneration = frame.generation; + + stub.getTargetSize = () => { + return [400, 300]; + }; + const resized = toFrameTexture.call(stub); + expect(resized).toBe(frame); + expect(resized.width).toBe(400); + expect(resized.generation).toBeGreaterThan(oldGeneration); + expect(stub.retired).toEqual([oldTexture]); + }); + + it("rejects a non-capture target and a foreign renderer's capture", () => { + const stub = createStub(); + expect(() => { + return toFrameTexture.call(stub, { target: { isGPUResident: true } }); + }).toThrow(/must be a capture/); + + const other = createStub(); + const foreign = toFrameTexture.call(other); + expect(() => { + return toFrameTexture.call(stub, { target: foreign }); + }).toThrow(/different renderer/); + }); + + it("clamps out-of-range regions and converts the bottom-left origin to top-left", () => { + const stub = createStub(); + // bottom-left region (10, 20, 100x50) in a 600-tall frame → the + // copy's top-left origin y = 600 - 20 - 50 = 530 + toFrameTexture.call(stub, { + target: null, + region: { x: 10, y: 20, width: 100, height: 50 }, + }); + expect(stub.copies[0].src.origin).toEqual([10, 530]); + expect(stub.copies[0].size).toEqual([100, 50]); + + // origin clamped into the frame first, size clamped to what remains + toFrameTexture.call(stub, { + target: null, + region: { x: 10000, y: -50, width: 10000, height: 10000 }, + }); + const clamped = stub.copies[1]; + expect(clamped.src.origin[0]).toBe(799); + expect(clamped.size[0]).toBe(1); + expect(clamped.size[1]).toBe(600); + + // missing width/height = the rest of the frame from x/y + toFrameTexture.call(stub, { + target: null, + region: { x: 100, y: 100 }, + }); + expect(stub.copies[2].size).toEqual([700, 500]); + }); + + it("captures the active render target when one is set", () => { + const stub = createStub(); + stub.currentRenderTarget = { texture: { label: "offscreen" } }; + toFrameTexture.call(stub); + expect(stub.copies[0].src.texture.label).toBe("offscreen"); + }); + + it("captureFrame delegates to the shared-slot capture", () => { + const stub = createStub(); + stub.toFrameTexture = toFrameTexture; + const frame = WebGPURenderer.prototype.captureFrame.call(stub); + expect(frame).toBeInstanceOf(WebGPUFrameTexture); + expect(stub.captureTexture).toBe(frame); + }); + + it("returns null without a device", () => { + const stub = createStub(); + stub.device = undefined; + expect(toFrameTexture.call(stub)).toBeNull(); + }); +}); diff --git a/packages/melonjs/tests/wgsl_layout.spec.js b/packages/melonjs/tests/wgsl_layout.spec.js new file mode 100644 index 0000000000..40ca7de033 --- /dev/null +++ b/packages/melonjs/tests/wgsl_layout.spec.js @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + computeUniformLayout, + normalizeWGSLType, +} from "../src/video/effects/wgsl/layout.js"; + +/** + * Hand-computed WGSL uniform-address-space layouts. A mislaid offset is + * SILENT at runtime (the value lands in the wrong struct member), so every + * rule the calculator implements is pinned here explicitly — same + * discipline as the std140 light-block spec. + */ +describe("WGSL uniform layout calculator", () => { + function layoutOf(members) { + return computeUniformLayout( + members.map(([name, type]) => { + return { name, type }; + }), + ); + } + + it("scalars pack tightly, struct size rounds up to 16", () => { + const { map, size } = layoutOf([ + ["a", "f32"], + ["b", "f32"], + ["c", "i32"], + ]); + expect(map.get("a")).toMatchObject({ offset: 0, size: 4 }); + expect(map.get("b")).toMatchObject({ offset: 4, size: 4 }); + expect(map.get("c")).toMatchObject({ offset: 8, size: 4, type: "i32" }); + expect(size).toBe(16); + }); + + it("vec2 aligns to 8", () => { + const { map, size } = layoutOf([ + ["a", "f32"], + ["b", "vec2f"], + ]); + expect(map.get("b").offset).toBe(8); + expect(size).toBe(16); + }); + + it("vec3 aligns to 16 with 12 bytes of data — the classic trap", () => { + const { map, size } = layoutOf([ + ["a", "f32"], + ["b", "vec3f"], + ["c", "f32"], + ]); + expect(map.get("b")).toMatchObject({ offset: 16, size: 12 }); + // c packs into vec3's tail padding + expect(map.get("c").offset).toBe(28); + expect(size).toBe(32); + }); + + it("matrices: mat4x4f is 64 bytes, mat3x3f is 48 (16-byte column stride)", () => { + const { map, size } = layoutOf([ + ["v", "vec4f"], + ["m", "mat4x4f"], + ]); + expect(map.get("m")).toMatchObject({ offset: 16, size: 64 }); + expect(size).toBe(80); + expect(layoutOf([["m", "mat3x3f"]]).map.get("m").size).toBe(48); + }); + + it("array occupies 16·N with 16-byte alignment", () => { + const { map, size } = layoutOf([ + ["arr", "array"], + ["x", "f32"], + ]); + expect(map.get("arr")).toMatchObject({ offset: 0, size: 48 }); + expect(map.get("x").offset).toBe(48); + expect(size).toBe(64); + }); + + it("long-form spellings normalize onto the short forms", () => { + expect(normalizeWGSLType("vec3")).toBe("vec3f"); + expect(normalizeWGSLType("mat4x4")).toBe("mat4x4f"); + const { map } = layoutOf([["b", "vec3"]]); + expect(map.get("b")).toMatchObject({ offset: 0, size: 12, type: "vec3f" }); + }); + + it("an unsupported member type fails the whole layout (null), never guesses", () => { + expect( + layoutOf([ + ["ok", "f32"], + ["bad", "bool"], + ]), + ).toBeNull(); + expect(layoutOf([["bad", "array"]])).toBeNull(); + }); +}); diff --git a/packages/melonjs/tests/wgsl_parse.spec.js b/packages/melonjs/tests/wgsl_parse.spec.js new file mode 100644 index 0000000000..d7e134d39a --- /dev/null +++ b/packages/melonjs/tests/wgsl_parse.spec.js @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { parseWGSLBody } from "../src/video/effects/wgsl/parse.js"; + +/** + * The declaration-only WGSL body parser: what the engine learns from a + * body (uniform struct → setUniform map, texture/sampler pairs, builtin + * references) and — just as important — every malformed shape it must + * refuse. A refused body disables the effect (warn + inert); it must + * never produce a partially-parsed result the bind-group builder would + * mis-consume. + */ +describe("WGSL body parser", () => { + const VALID = ` + struct FxUniforms { + uStrength : f32, + uColor : vec3f, + }; + @group(3) @binding(0) var fx : FxUniforms; + + fn apply(color : vec4f, uv : vec2f) -> vec4f { + return vec4f(color.rgb * fx.uColor * fx.uStrength, color.a); + } + `; + + it("parses the uniform struct into a name → placement map", () => { + const parsed = parseWGSLBody(VALID); + expect(parsed.ok).toBe(true); + expect(parsed.uniformVar).toBe("fx"); + expect(parsed.layout.get("uStrength")).toMatchObject({ offset: 0 }); + expect(parsed.layout.get("uColor")).toMatchObject({ offset: 16, size: 12 }); + expect(parsed.structSize).toBe(32); + }); + + it("a body without a uniform struct parses with an empty layout", () => { + const parsed = parseWGSLBody( + "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + ); + expect(parsed.ok).toBe(true); + expect(parsed.layout.size).toBe(0); + expect(parsed.structSize).toBe(0); + }); + + it("collects texture/sampler pairs at explicit consecutive bindings", () => { + const parsed = parseWGSLBody(` + @group(3) @binding(1) var uNoise : texture_2d; + @group(3) @binding(2) var uNoiseSampler : sampler; + fn apply(color : vec4f, uv : vec2f) -> vec4f { + return color * textureSample(uNoise, uNoiseSampler, uv); + } + `); + expect(parsed.ok).toBe(true); + expect(parsed.textures).toEqual([ + { + name: "uNoise", + binding: 1, + samplerName: "uNoiseSampler", + samplerBinding: 2, + }, + ]); + expect(parsed.maxUserBinding).toBe(2); + }); + + it("detects builtin references, honoring the owns-declaration guard", () => { + const withBuiltins = parseWGSLBody(` + fn apply(color : vec4f, uv : vec2f) -> vec4f { + let scene = textureSample(screen_texture, screen_sampler, screen_uv); + return mix(color, scene, noise_uv.x); + } + `); + expect(withBuiltins.ok).toBe(true); + expect(withBuiltins.builtins).toMatchObject({ + screenTexture: true, + screenSamplerClamp: true, + screenUV: true, + noiseUV: true, + }); + + // a body declaring its own screen_uv keeps it user-managed + const own = parseWGSLBody(` + fn apply(color : vec4f, uv : vec2f) -> vec4f { + var screen_uv : vec2f = uv; + return vec4f(screen_uv, 0.0, color.a); + } + `); + expect(own.ok).toBe(true); + expect(own.builtins.screenUV).toBe(false); + }); + + it("array members parse (the comma inside angle brackets is not a separator)", () => { + const parsed = parseWGSLBody(` + struct Fx { + uPalette : array, + uStrength : f32, + }; + @group(3) @binding(0) var fx : Fx; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(true); + expect(parsed.layout.get("uPalette")).toMatchObject({ + offset: 0, + size: 64, + }); + expect(parsed.layout.get("uStrength").offset).toBe(64); + }); + + it("nested block comments are fully skipped (WGSL comments nest)", () => { + const parsed = parseWGSLBody(` + /* outer /* inner */ still a comment: + @group(3) @binding(0) var fake : NotReal; + */ + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(true); + expect(parsed.structSize).toBe(0); + }); + + it("declarations inside comments are ignored", () => { + const parsed = parseWGSLBody(` + // @group(3) @binding(0) var fake : NotReal; + /* struct NotReal { x : f32, }; uses screen_texture too */ + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(true); + expect(parsed.structSize).toBe(0); + expect(parsed.builtins.screenTexture).toBe(false); + }); + + describe("refused shapes (each disables the effect, never throws)", () => { + it("missing apply()", () => { + const parsed = parseWGSLBody("fn main() -> vec4f { return vec4f(); }"); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/fn apply/); + }); + + it("uniform var without its struct", () => { + const parsed = parseWGSLBody(` + @group(3) @binding(0) var fx : Missing; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/Missing/); + }); + + it("unsupported uniform member type", () => { + const parsed = parseWGSLBody(` + struct Fx { flag : bool, }; + @group(3) @binding(0) var fx : Fx; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/unsupported/); + }); + + it("texture without its adjacent sampler", () => { + const parsed = parseWGSLBody(` + @group(3) @binding(1) var uNoise : texture_2d; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/sampler at @binding\(2\)/); + }); + + it("orphan sampler", () => { + const parsed = parseWGSLBody(` + @group(3) @binding(1) var s : sampler; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/no texture/); + }); + + it("texture squatting on binding 0 (reserved for the uniform struct)", () => { + const parsed = parseWGSLBody(` + @group(3) @binding(0) var uNoise : texture_2d; + @group(3) @binding(1) var s : sampler; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(false); + }); + + it("screen_texture referenced without either builtin sampler", () => { + const parsed = parseWGSLBody(` + fn apply(color : vec4f, uv : vec2f) -> vec4f { + return textureLoad(screen_texture, vec2i(uv), 0); + } + `); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/screen_sampler/); + }); + + it("an unclassifiable group-3 declaration", () => { + const parsed = parseWGSLBody(` + @group(3) @binding(4) var big : array; + fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; } + `); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/binding\(4\)/); + }); + }); +}); diff --git a/packages/melonjs/tests/wgsl_scaffold.spec.js b/packages/melonjs/tests/wgsl_scaffold.spec.js new file mode 100644 index 0000000000..0878b55827 --- /dev/null +++ b/packages/melonjs/tests/wgsl_scaffold.spec.js @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { parseWGSLBody } from "../src/video/effects/wgsl/parse.js"; +import { + assignBuiltinBindings, + buildWGSLModule, +} from "../src/video/effects/wgsl/scaffold.js"; + +/** + * The assembled WGSL module around an effect body. Snapshots pin the full + * text per builtin permutation (the WGSL counterpart of the generated-GLSL + * golden); the explicit assertions pin the conventions that must never + * drift silently: the clip-z remap, the packed-color premultiply, y-down + * screen_uv, and collision-free builtin binding assignment. + */ +describe("WGSL effect scaffold", () => { + function moduleFor(body) { + const parsed = parseWGSLBody(body); + expect(parsed.ok).toBe(true); + return buildWGSLModule(body, parsed); + } + + const PLAIN = + "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }"; + + it("plain body: full module snapshot", () => { + expect(moduleFor(PLAIN).code).toMatchSnapshot(); + }); + + it("all builtins: full module snapshot", () => { + expect( + moduleFor(` +struct Fx { uStrength : f32, }; +@group(3) @binding(0) var fx : Fx; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let scene = textureSample(screen_texture, screen_sampler, screen_uv); + return mix(color, scene, fx.uStrength * noise_uv.x); +} +`).code, + ).toMatchSnapshot(); + }); + + it("carries the frozen cross-backend conventions", () => { + const code = moduleFor(PLAIN).code; + // GL-convention clip z remap — without it depth-carrying vertices clip away + expect(code).toContain("(clip.z + clip.w) * 0.5"); + // packed-ARGB premultiply (little-endian unorm8x4 arrives BGRA) + expect(code).toContain("aColor.bgr * aColor.a"); + // frozen 28-byte quad vertex layout, locations 0-3 + expect(code).toContain("@location(3) aTextureId : f32"); + // the blit source rides the material group + expect(code).toContain("@group(1) @binding(0) var uTexture"); + }); + + it("screen_uv is y-down (capture row 0 = screen top, no flip)", () => { + const code = moduleFor(` +fn apply(color : vec4f, uv : vec2f) -> vec4f { return vec4f(screen_uv, 0.0, color.a); } +`).code; + expect(code).toContain("0.5 - ndc.y * 0.5"); + }); + + it("builtin bindings are assigned above the highest user binding", () => { + const body = ` +struct Fx { uStrength : f32, }; +@group(3) @binding(0) var fx : Fx; +@group(3) @binding(1) var uNoise : texture_2d; +@group(3) @binding(2) var uNoiseSampler : sampler; +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let n = textureSample(uNoise, uNoiseSampler, noise_uv); + let scene = textureSample(screen_texture, screen_sampler_repeat, screen_uv); + return mix(color, scene, n.x * fx.uStrength); +} +`; + const parsed = parseWGSLBody(body); + const bindings = assignBuiltinBindings(parsed); + // user bindings occupy 0..2 → ME at 3, capture at 4, sampler at 5 + expect(bindings.me).toBe(3); + expect(bindings.screenTexture).toBe(4); + expect(bindings.screenSamplerRepeat).toBe(5); + expect(bindings.screenSamplerClamp).toBe(-1); + const code = buildWGSLModule(body, parsed).code; + expect(code).toContain("@group(3) @binding(3) var ME"); + expect(code).toContain("@group(3) @binding(4) var screen_texture"); + expect(code).toContain("@group(3) @binding(5) var screen_sampler_repeat"); + }); + + it("the user body is embedded verbatim", () => { + const body = ` +// a very specific comment that must survive +fn apply(color : vec4f, uv : vec2f) -> vec4f { return color * 0.5; } +`; + expect(moduleFor(body).code).toContain(body); + }); +});