diff --git a/README.md b/README.md index bd967cd04..a40496a7f 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,8 @@ Examples * [Hello World](https://melonjs.github.io/melonJS/examples/#/hello-world) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/helloWorld)) * [Whac-A-Mole](https://melonjs.github.io/melonJS/examples/#/whac-a-mole) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/whac-a-mole)) * [Compressed Textures](https://melonjs.github.io/melonJS/examples/#/compressed-textures) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/compressedTextures)) -* [Water Refraction](https://melonjs.github.io/melonJS/examples/#/water-refraction) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/waterRefraction)) — a perspective pool floor rippling under the water: a static seamless `NoiseTexture2d` bound as an extra shader sampler via `ShaderEffect.setTexture` and scrolled on the GPU with `setTime`, with pointer swells, depth fog and animated caustics +* [Aquarium](https://melonjs.github.io/melonJS/examples/#/aquarium) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/aquarium)) — screen-space water refraction with `renderer.toFrameTexture()`: fish swim across a seabed, then a water surface captures the live frame on the GPU and re-samples it through a scrolling `NoiseTexture2d` flow map (the Godot `hint_screen_texture` / Unity `_CameraOpaqueTexture` pattern) +* [Heat Haze](https://melonjs.github.io/melonJS/examples/#/heat-haze) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/heatHaze)) — `renderer.toFrameTexture()` over a lit scene: normal-mapped tiles under a moving `Light2d`, distorted by a rising heat-haze that captures and ripples the lit frame * [3D Mesh](https://melonjs.github.io/melonJS/examples/#/mesh-3d) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/mesh3d)) * [3D Mesh Material](https://melonjs.github.io/melonJS/examples/#/mesh-3d-material) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/mesh3dMaterial)) * [AfterBurner Clone](https://melonjs.github.io/melonJS/examples/#/after-burner) ([source](https://github.com/melonjs/melonJS/tree/master/packages/examples/src/examples/afterBurner)) — `Camera3d` + 3D Mesh arcade shooter diff --git a/packages/examples/LICENSE.md b/packages/examples/LICENSE.md index cac19e573..9b5329a0a 100644 --- a/packages/examples/LICENSE.md +++ b/packages/examples/LICENSE.md @@ -26,6 +26,15 @@ SOFTWARE. ## Third-party assets +### `aquarium` example + +The texture atlas in `public/assets/aquarium/aquarium.webp` was contributed by +a melonJS user with their aquarium demo (adapted here to the `toFrameTexture` +API). It packs a top-down seabed background ("Free Top-Down Seabed Objects +Pixel Art"), a fish animation sheet, and a water-surface texture. Provided by +the contributor for use in the melonJS examples; refer to the original asset +packs for any upstream attribution terms. + ### `pool-matter` example Pool table backdrop and 16 numbered-ball sprites in diff --git a/packages/examples/public/assets/aquarium/aquarium.webp b/packages/examples/public/assets/aquarium/aquarium.webp new file mode 100644 index 000000000..92e150238 Binary files /dev/null and b/packages/examples/public/assets/aquarium/aquarium.webp differ diff --git a/packages/examples/src/examples/aquarium/ExampleAquarium.tsx b/packages/examples/src/examples/aquarium/ExampleAquarium.tsx new file mode 100644 index 000000000..3b33669a1 --- /dev/null +++ b/packages/examples/src/examples/aquarium/ExampleAquarium.tsx @@ -0,0 +1,329 @@ +/** + * melonJS — aquarium example (renderer.toFrameTexture()). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + * + * A user-contributed aquarium adapted to the 19.9 API: a seabed scene with + * swimming fish, seen through a rippling water surface. The surface renderable, + * drawn last, calls `renderer.toFrameTexture()` in its `draw()` to grab the + * frame rendered so far (seabed + fish) as a GPU-resident `Texture2d`, binds it + * to a custom `ShaderEffect` as an extra sampler (`uScene`), and refracts it + * through a scrolling `NoiseTexture2d` flow map — replacing the original's + * `readPixels` screen-capture with a zero-stall GPU copy. + * + * This is the industry-standard "screen texture" pattern (Godot + * `hint_screen_texture` / Unity `_CameraOpaqueTexture` / Three.js + * `copyFramebufferToTexture`). `toFrameTexture()` returns the public + * `Texture2d`, so it plugs straight into `setTexture()` and is re-captured every + * frame into the same shared slot — the shader samples the latest frame with no + * re-bind. + * + * Assets: TexturePacker atlas contributed with the original demo — a top-down + * seabed background, a 4-frame fish swim sheet, and a water texture. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + type Application, + loader, + NoiseTexture2d, + plugin, + type ShaderEffect, + Sprite, + Stage, + state, + video, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +const base = `${import.meta.env.BASE_URL}assets/aquarium/`; + +// Atlas region rects (TexturePacker frames in aquarium.webp, 1024²). +const REGION = { + poolWater: { x: 1, y: 1, w: 740, h: 494 }, + seabed: { x: 1, y: 497, w: 720, h: 480 }, + swim: { x: 873, y: 228, w: 128, h: 128 }, // 2×2 grid of 64px frames +}; + +// crop a sub-region of the atlas image into its own canvas, optionally scaled +// to a target size (so a full-viewport sprite's framewidth matches its image +// exactly → clean [0,1] UVs) +const crop = ( + img: CanvasImageSource, + r: { x: number; y: number; w: number; h: number }, + dw = r.w, + dh = r.h, +) => { + const c = document.createElement("canvas"); + c.width = dw; + c.height = dh; + const ctx = c.getContext("2d") as CanvasRenderingContext2D; + ctx.drawImage(img, r.x, r.y, r.w, r.h, 0, 0, dw, dh); + return c; +}; + +// The refraction fragment (GLSL ES 1.00). The surface quad fills the viewport, +// so its UV (top-left origin) spans the screen. We re-sample the CAPTURED scene +// (uScene) at that UV, displaced by a scrolling noise flow field, then modulate +// it with the water texture (uSampler) for a wet, caustic sheen. The capture is +// a framebuffer copy (Y-up), so Y is flipped once into `s`. +const WATER_FRAGMENT = ` +uniform sampler2D uScene; // captured frame, bound each draw via setTexture +uniform sampler2D uNoise; // static seamless flow map +uniform float uTime; // seconds (setTime) +uniform float uStrength; // ripple strength (slider) + +vec4 apply(vec4 color, vec2 uv) { + vec2 s = vec2(uv.x, 1.0 - uv.y); + + // two noise layers scrolling apart → a living flow field + vec2 f1 = texture2D(uNoise, s * 1.6 + vec2(uTime * 0.03, uTime * 0.05)).rg; + vec2 f2 = texture2D(uNoise, s * 2.7 - vec2(uTime * 0.04, uTime * 0.02)).rg; + vec2 flow = f1 + f2 - 1.0; + + // refract the captured scene at the displaced screen coord + vec3 scene = texture2D(uScene, clamp(s + flow * uStrength, 0.0, 1.0)).rgb; + + // the water texture (uSampler), gently scrolled, as a wet sheen over it + vec3 water = texture2D(uSampler, uv * 0.6 + flow * 0.02).rgb; + vec3 outc = scene * (0.75 + 0.5 * water); + + // caustic sparkle where the flow layers pinch together + float caustic = pow(max(f1.r * f2.g, 0.0), 3.0) * 1.2; + outc += vec3(0.10, 0.20, 0.24) * caustic; + return vec4(outc, 1.0); +} +`; + +// a fish that swims horizontally and turns around at the tank edges +class Fish extends Sprite { + private speed: number; + private minX: number; + private maxX: number; + private bobPhase: number; + private bobAmp: number; + private baseY: number; + + constructor( + x: number, + y: number, + sheet: HTMLCanvasElement, + speed: number, + scale: number, + bounds: { min: number; max: number }, + ) { + super(x, y, { image: sheet, framewidth: 64, frameheight: 64 }); + this.addAnimation("swim", [0, 1, 2, 3], 120); + this.setCurrentAnimation("swim"); + this.scale(scale, scale); + this.speed = speed; + this.minX = bounds.min; + this.maxX = bounds.max; + this.baseY = y; + this.bobPhase = x * 0.05; + this.bobAmp = 5 + Math.abs(speed) * 0.15; + // art faces left; flip when swimming right + this.flipX(speed > 0); + } + + update(dt: number) { + super.update(dt); + const s = dt / 1000; + this.pos.x += this.speed * s; + this.bobPhase += s * 2; + this.pos.y = this.baseY + Math.sin(this.bobPhase) * this.bobAmp; + if (this.pos.x < this.minX) { + this.pos.x = this.minX; + this.speed = Math.abs(this.speed); + this.flipX(true); + } else if (this.pos.x > this.maxX) { + this.pos.x = this.maxX; + this.speed = -Math.abs(this.speed); + this.flipX(false); + } + return true; + } +} + +// the water surface: drawn LAST, it captures the frame rendered so far and +// re-draws it refracted. A full-viewport sprite (framewidth/frameheight = the +// viewport, so it covers every pixel) whose draw() grabs the backdrop via +// toFrameTexture() and hands it to the shader as uScene. +class WaterSurface extends Sprite { + private effect: ShaderEffect; + + constructor( + w: number, + h: number, + waterTex: HTMLCanvasElement, + effect: ShaderEffect, + ) { + // the water texture stretched over the whole viewport is the quad's + // albedo (uSampler); the shader multiplies the captured scene by it + super(w / 2, h / 2, { + image: waterTex, + framewidth: w, + frameheight: h, + anchorPoint: { x: 0.5, y: 0.5 }, + }); + this.effect = effect; + this.shader = effect; + } + + draw(renderer: Parameters[0], viewport?: object) { + // capture everything drawn so far this frame — the opaque aquarium — + // as a GPU-resident texture, and hand it to the refraction shader. + // Re-captured every frame into the shared slot: the live-bound sampler + // picks up the latest frame with no re-bind. + const scene = renderer.toFrameTexture(); + this.effect.setTexture("uScene", scene); + // biome-ignore lint/suspicious/noExplicitAny: viewport shape varies by call site + super.draw(renderer, viewport as any); + } +} + +class PlayScreen extends Stage { + private elapsed = 0; + private effect!: ShaderEffect; + private panel?: HTMLDivElement; + private noise?: NoiseTexture2d; + + onResetEvent(app: Application) { + const w = app.viewport.width; + const h = app.viewport.height; + + const atlas = loader.getImage("aquariumAtlas") as HTMLImageElement; + // seabed + water pre-scaled to the viewport so a full-screen sprite's + // framewidth/frameheight matches its image (clean UVs, exact cover) + const seabed = crop(atlas, REGION.seabed, w, h); + const water = crop(atlas, REGION.poolWater, w, h); + const swimSheet = crop(atlas, REGION.swim); // natural 128×128 (2×2 of 64) + + // static seamless noise flow map (baked once; scrolled on the GPU) + const noise = new NoiseTexture2d({ + width: 256, + height: 256, + type: "simplex", + seed: 11, + frequency: 0.035, + octaves: 4, + gain: 0.5, + domainWarp: true, + domainWarpAmp: 8, + seamless: true, + }); + this.noise = noise; + + // the refraction effect, preloaded as a "shader" asset + this.effect = loader.getShader("aquariumWater") as ShaderEffect; + // pass the NoiseTexture2d asset directly — setTexture resolves it (19.9) + this.effect.setTexture("uNoise", noise, "repeat"); + this.effect.setUniform("uStrength", 0.013); + + // seabed backdrop (z 0), stretched to the viewport + const bg = new Sprite(w / 2, h / 2, { + image: seabed, + framewidth: w, + frameheight: h, + anchorPoint: { x: 0.5, y: 0.5 }, + }); + app.world.addChild(bg, 0); + + // a school of fish (z 1..) swimming at various depths and speeds + for (let i = 0; i < 6; i++) { + const dir = i % 2 === 0 ? 1 : -1; + const speed = dir * (34 + (i % 3) * 20); + const scale = 0.7 + (i % 3) * 0.25; + const y = 60 + ((i * 53) % (h - 130)); + const x = 60 + ((i * 101) % (w - 120)); + app.world.addChild( + new Fish(x, y, swimSheet, speed, scale, { min: 40, max: w - 40 }), + 1 + i, + ); + } + + // the water surface post-pass (z 100, above everything it refracts) + app.world.addChild(new WaterSurface(w, h, water, this.effect), 100); + + this.buildSlider(app); + } + + private buildSlider(app: Application) { + const panel = document.createElement("div"); + panel.style.cssText = + "position:absolute;top:60px;left:16px;z-index:1000;font-family:sans-serif;" + + "color:#e8f6fa;background:rgba(0,0,0,0.45);padding:8px 12px;border-radius:6px;"; + const label = document.createElement("div"); + label.textContent = "🐟 Water ripple"; + label.style.cssText = "font-size:12px;margin-bottom:6px;"; + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "0.06"; + slider.step = "0.002"; + slider.value = "0.013"; + slider.style.cssText = "width:190px;display:block;"; + slider.addEventListener("input", () => { + this.effect.setUniform("uStrength", Number.parseFloat(slider.value)); + }); + const hint = document.createElement("div"); + hint.textContent = "the fish are refracted through the captured frame"; + hint.style.cssText = "font-size:10px;margin-top:6px;opacity:0.7;"; + panel.appendChild(label); + panel.appendChild(slider); + panel.appendChild(hint); + const parent = app.renderer.getCanvas().parentElement; + if (parent) { + parent.style.position = "relative"; + parent.appendChild(panel); + } + this.panel = panel; + } + + update(dt: number) { + this.elapsed += dt / 1000; + this.effect.setTime(this.elapsed); + super.update(dt); + return true; // keep animating every frame + } + + onDestroyEvent() { + loader.unload({ name: "aquariumWater", type: "shader" }); + // free the baked NoiseTexture2d canvas (engine texture asset) + this.noise?.destroy(); + this.noise = undefined; + this.panel?.remove(); + } +} + +const createGame = () => { + video.init(728, 410, { + parent: "screen", + // fixed internal resolution scaled to fit (keeps viewport 728×410, so + // full-screen renderables cover it regardless of the container size) + scale: "auto", + // toFrameTexture + ShaderEffect are WebGL features + renderer: video.WEBGL, + antiAlias: true, + // render at sub-pixel positions so the slow-swimming fish glide smoothly + // instead of snapping pixel-to-pixel (default floors dx/dy to integers) + subPixel: true, + }); + + // register the debug plugin (hidden by default; press S to toggle) + plugin.register(DebugPanelPlugin, "debugPanel"); + + state.set(state.PLAY, new PlayScreen()); + + loader.preload( + [ + { name: "aquariumAtlas", type: "image", src: `${base}aquarium.webp` }, + { name: "aquariumWater", type: "shader", data: WATER_FRAGMENT }, + ], + () => { + state.change(state.PLAY); + }, + false, + ); +}; + +export const ExampleAquarium = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx b/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx new file mode 100644 index 000000000..0c321799e --- /dev/null +++ b/packages/examples/src/examples/heatHaze/ExampleHeatHaze.tsx @@ -0,0 +1,248 @@ +/** + * melonJS — heat-haze example (renderer.toFrameTexture() over a lit scene). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + * + * A grid of normal-mapped tiles lit by a moving `Light2d` (so the engine draws + * them through the LIT quad batcher), seen through a rising heat-haze that + * distorts the whole view. The haze surface, drawn last, captures the lit frame + * with `renderer.toFrameTexture()` and ripples it. + * + * This is the scene that stresses the lit-batcher / capture interaction: the + * lit batcher parks its normal maps in the TOP half of the texture-unit range, + * and `toFrameTexture` binds its capture to a scratch unit at the very top — + * the same units. The renderer invalidates that unit across every batcher after + * the copy, so the next lit frame re-binds the real normal maps; without that, + * a tile whose normal landed on the scratch unit would sample the captured + * frame AS its normal map and light up wrong. (See tests/toframetexture.spec.js + * "invalidates the scratch unit across all batchers ...".) + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + type Application, + Light2d, + loader, + NoiseTexture2d, + plugin, + type ShaderEffect, + Sprite, + Stage, + state, + video, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +// rising-heat distortion. Samples the captured scene (uScene) with a vertical +// wobble that scrolls upward and is stronger lower on screen. Screen space is +// the quad's own UV, Y-flipped for the Y-up framebuffer capture. +const HAZE_FRAGMENT = ` +uniform sampler2D uScene; // captured lit frame, bound each draw via setTexture +uniform sampler2D uNoise; // seamless flow map +uniform float uTime; +uniform float uStrength; + +vec4 apply(vec4 color, vec2 uv) { + vec2 s = vec2(uv.x, 1.0 - uv.y); + float n1 = texture2D(uNoise, vec2(s.x * 2.0, s.y * 1.6 - uTime * 0.18)).r; + float n2 = texture2D(uNoise, vec2(s.x * 3.3 + 0.5, s.y * 2.2 - uTime * 0.11)).r; + float wob = (n1 + n2 - 1.0) * uStrength * (1.15 - s.y); // stronger near the floor + vec2 d = clamp(s + vec2(wob, wob * 0.35), 0.0, 1.0); + return vec4(texture2D(uScene, 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) => { + const c = document.createElement("canvas"); + c.width = size; + c.height = size; + const ctx = c.getContext("2d") as CanvasRenderingContext2D; + ctx.fillStyle = `hsl(${hue}, 30%, 42%)`; + ctx.fillRect(0, 0, size, size); + const b = size * 0.12; + ctx.fillStyle = "rgba(255,255,255,0.18)"; + ctx.fillRect(b, b, size - 2 * b, size * 0.06); // top bevel + ctx.fillStyle = "rgba(0,0,0,0.25)"; + ctx.fillRect(b, size - b - size * 0.06, size - 2 * b, size * 0.06); // bottom bevel + ctx.strokeStyle = "rgba(0,0,0,0.35)"; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, size - 2, size - 2); + return c; +}; + +// the haze surface: full-viewport, drawn last; captures the lit frame and +// refracts it. framewidth/frameheight = viewport so the quad covers everything. +class HazeSurface extends Sprite { + private effect: ShaderEffect; + + constructor( + w: number, + h: number, + tex: HTMLCanvasElement, + effect: ShaderEffect, + ) { + super(w / 2, h / 2, { + image: tex, + framewidth: w, + frameheight: h, + anchorPoint: { x: 0.5, y: 0.5 }, + }); + this.effect = effect; + this.shader = effect; + } + + draw(renderer: Parameters[0], viewport?: object) { + const scene = renderer.toFrameTexture(); + this.effect.setTexture("uScene", scene); + // biome-ignore lint/suspicious/noExplicitAny: viewport shape varies by call site + super.draw(renderer, viewport as any); + } +} + +class PlayScreen extends Stage { + private elapsed = 0; + private light!: Light2d; + private effect!: ShaderEffect; + private w = 0; + private h = 0; + private noiseTextures: NoiseTexture2d[] = []; + + onResetEvent(app: Application) { + this.w = app.viewport.width; + this.h = app.viewport.height; + const w = this.w; + const h = this.h; + + // neutral ambient floor so unlit tile areas aren't pitch black + this.ambientLightingColor.setColor(70, 74, 90); + + // a viewport-sized white sheen texture for the haze quad's albedo + // (ignored by the shader, which samples the captured scene instead). + // Must match framewidth/frameheight so the quad's UVs span a clean [0,1] + // — a 1x1 image stretched via framewidth produces degenerate UVs. + const white = document.createElement("canvas"); + white.width = w; + white.height = h; + const wctx = white.getContext("2d") as CanvasRenderingContext2D; + wctx.fillStyle = "#fff"; + wctx.fillRect(0, 0, w, h); + + // Enough DISTINCT normal-mapped tiles to fill the lit batcher's normal-map + // units up to the top one — that top unit overlaps the units the capture + // and the haze effect's samplers bind, so this is the case the + // invalidation guards. Build each distinct (albedo, normal) ONCE and + // reuse it across repeated grid cells. + const maxTextures = app.renderer.maxTextures ?? 16; + const distinctTiles = Math.min(24, Math.ceil(maxTextures / 2) + 2); + const albedos: HTMLCanvasElement[] = []; + const normals: HTMLCanvasElement[] = []; + for (let v = 0; v < distinctTiles; v++) { + albedos.push(tileAlbedo(96, (v * 37) % 360)); + const n = new NoiseTexture2d({ + width: 96, + height: 96, + type: "simplex", + seed: 3 + v * 5, + frequency: 0.06 + (v % 4) * 0.01, + octaves: 3, + asNormalMap: true, + bumpStrength: 2.2, + }); + this.noiseTextures.push(n); + normals.push(n.getTexture() as HTMLCanvasElement); + } + + const cols = 6; + const rows = 4; + const tileW = w / cols; + const tileH = h / rows; + let k = 0; + for (let r = 0; r < rows; r++) { + for (let cx = 0; cx < cols; cx++) { + const variant = k % distinctTiles; + const tile = new Sprite(cx * tileW + tileW / 2, r * tileH + tileH / 2, { + image: albedos[variant], + normalMap: normals[variant], + framewidth: 96, + frameheight: 96, + anchorPoint: { x: 0.5, y: 0.5 }, + }); + tile.scale((tileW / 96) * 0.98, (tileH / 96) * 0.98); + app.world.addChild(tile, 1); + k++; + } + } + + // the moving light that reveals the normal-mapped relief + this.light = new Light2d(w / 2, h / 2, w * 0.55, w * 0.55, "#fff2d8", 2.4); + this.light.illuminationOnly = true; + app.world.addChild(this.light, 2); + + // the heat-haze post-pass (z 100, above the lit tiles it distorts) + const noise = new NoiseTexture2d({ + width: 256, + height: 256, + type: "simplex", + seed: 99, + frequency: 0.03, + octaves: 3, + seamless: true, + }); + this.noiseTextures.push(noise); + this.effect = loader.getShader("heatHaze") as ShaderEffect; + this.effect.setTexture("uNoise", noise, "repeat"); + this.effect.setUniform("uStrength", 0.02); + app.world.addChild(new HazeSurface(w, h, white, this.effect), 100); + } + + update(dt: number) { + this.elapsed += dt / 1000; + // orbit the light so the normal-mapped relief shifts every frame — makes + // any normal-map corruption from the capture obvious if the fix regressed + const t = this.elapsed; + this.light.pos.set( + this.w * (0.5 + 0.34 * Math.cos(t * 0.7)), + this.h * (0.5 + 0.3 * Math.sin(t * 0.9)), + 0, + ); + this.effect.setTime(this.elapsed); + super.update(dt); + return true; + } + + onDestroyEvent() { + loader.unload({ name: "heatHaze", type: "shader" }); + // free the baked NoiseTexture2d canvases (tile normals + haze flow map) + for (const n of this.noiseTextures) { + n.destroy(); + } + this.noiseTextures = []; + } +} + +const createGame = () => { + video.init(728, 410, { + parent: "screen", + scale: "auto", + // Light2d normal-map lighting + toFrameTexture are WebGL features + renderer: video.WEBGL, + antiAlias: true, + subPixel: true, + }); + + // register the debug plugin (hidden by default; press S to toggle) + plugin.register(DebugPanelPlugin, "debugPanel"); + + state.set(state.PLAY, new PlayScreen()); + + loader.preload( + [{ name: "heatHaze", type: "shader", data: HAZE_FRAGMENT }], + () => { + state.change(state.PLAY); + }, + false, + ); +}; + +export const ExampleHeatHaze = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/waterRefraction/ExampleWaterRefraction.tsx b/packages/examples/src/examples/waterRefraction/ExampleWaterRefraction.tsx deleted file mode 100644 index 46206eb5c..000000000 --- a/packages/examples/src/examples/waterRefraction/ExampleWaterRefraction.tsx +++ /dev/null @@ -1,269 +0,0 @@ -/** - * melonJS — water refraction example (ShaderEffect.setTexture). - * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. - * See `packages/examples/LICENSE.md` for full license + asset credits. - * - * A swimming pool seen at a perspective angle, its tiled floor rippling under - * the water — built with a single custom `ShaderEffect` that reads TWO textures: - * - `uSampler` — the sprite's own albedo (the seamless pool tiles), bound by the - * engine as usual. - * - `uNoise` — a *static* seamless `NoiseTexture2d`, bound as an **extra** - * sampler via `effect.setTexture("uNoise", noise.getTexture())`. - * - * The fragment projects each screen pixel onto a tilted floor plane (a "mode-7" - * perspective floor receding to a horizon), then distorts the floor UVs with two - * noise layers scrolled on the GPU (driven by `effect.setTime`). Because the - * distortion is constant in *floor* space, the ripples are automatically - * perspective-correct — fine and tight near the horizon, broad up close. A - * pointer swell, depth fog and animated caustics ride on top. No per-frame CPU - * noise re-bake, no `Light2d`; `setTexture` is what makes the second sampler a - * one-liner (see issue #1532). - * - * The fragment ships as a preloaded `"shader"` asset: compiled once at load - * time and retrieved as a shared, loader-owned `ShaderEffect` via - * `loader.getShader()` — freed with `loader.unload()` on stage teardown. - */ -import { - type Application, - input, - loader, - NoiseTexture2d, - type ShaderEffect, - Sprite, - Stage, - state, - video, -} from "melonjs"; -import { createExampleComponent } from "../utils"; - -// A GLSL ES 1.00 fragment (what ShaderEffect wraps). `apply(color, uv)` receives -// the pre-sampled/tinted sprite color and its texture coords; we ignore the flat -// color and re-sample `uSampler` at perspective-projected, refracted coords. -const REFRACTION_FRAGMENT = ` -uniform sampler2D uNoise; // extra texture, bound via setTexture -uniform float uTime; // seconds, fed via setTime -uniform float uAmount; // ripple strength (slider) -uniform float uCaustic; // caustic sparkle intensity -uniform vec2 uMouse; // pointer position in screen uv -uniform float uMouseStr; // decaying pointer impulse - -const float HORIZON = 0.30; // screen height of the far water edge -const float DEPTH = 0.22; // how fast the floor recedes past the horizon -const float TILES = 1.6; // pool-tile repeats per unit of floor depth - -vec4 apply(vec4 color, vec2 uv) { - // a radial swell that follows the pointer and fades over ~1s (screen space) - vec2 toMouse = uv - uMouse; - float md = length(toMouse); - float swell = uMouseStr * exp(-md * 11.0) * sin(md * 42.0 - uTime * 7.0); - vec2 suv = uv + (md > 0.001 ? toMouse / md : vec2(0.0)) * swell * 0.02; - - // above the waterline: distant water fading up to a faint horizon glow - if (suv.y <= HORIZON) { - float t = suv.y / HORIZON; // 0 at top .. 1 at the horizon - vec3 sky = mix(vec3(0.02, 0.10, 0.16), vec3(0.06, 0.30, 0.38), t); - sky += vec3(0.10, 0.16, 0.18) * pow(t, 6.0); - return vec4(sky, 1.0); - } - - // project the pixel onto the tilted floor plane (perspective / mode-7) - float depth = DEPTH / (suv.y - HORIZON); // grows toward the horizon - vec2 floorUV = vec2((suv.x - 0.5) * depth * 2.0, depth); - - // two noise layers scrolling in different directions refract the floor. - // a CONSTANT offset in floor space is what makes the ripples shrink with - // distance — no extra perspective math needed. - vec2 n1 = texture2D(uNoise, floorUV * 0.16 + vec2(uTime * 0.03, uTime * 0.06)).rg; - vec2 n2 = texture2D(uNoise, floorUV * 0.26 - vec2(uTime * 0.02, uTime * 0.05)).rg; - vec2 flow = n1 + n2 - 1.0; - - vec2 tileUV = floorUV * TILES + flow * uAmount; - vec4 base = texture2D(uSampler, fract(tileUV)); // seamless tiles → fract wraps - - // depth fog: fade toward the horizon into deep teal - float near = clamp((suv.y - HORIZON) / (1.0 - HORIZON), 0.0, 1.0); // 0 far .. 1 near - base.rgb = mix(vec3(0.03, 0.15, 0.20), base.rgb, near * 0.8 + 0.2); - - // caustics where the two flow layers overlap, brighter near the camera - float caustic = pow(max(n1.r * n2.g, 0.0), 3.0) * uCaustic * (near * 0.7 + 0.3); - return vec4(base.rgb + vec3(caustic), 1.0); -} -`; - -// paint a seamless swimming-pool tile texture: grout-coloured base with inset -// tiles, so the outer border is grout on all four edges → tiles cleanly under -// the shader's fract() wrapping. -const poolTiles = (size: number) => { - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; - - // grout base (also the seamless edge colour) - ctx.fillStyle = "#063a47"; - ctx.fillRect(0, 0, size, size); - - const tile = size / 4; // 4×4 tiles - for (let ry = 0; ry < 4; ry++) { - for (let rx = 0; rx < 4; rx++) { - ctx.fillStyle = (rx + ry) % 2 === 0 ? "#0d7086" : "#0a6076"; - ctx.fillRect(rx * tile + 3, ry * tile + 3, tile - 6, tile - 6); - // a soft top-left highlight on each tile for a wet, glossy read - ctx.fillStyle = "rgba(180, 230, 240, 0.06)"; - ctx.fillRect(rx * tile + 3, ry * tile + 3, tile - 6, 4); - } - } - return canvas; -}; - -class PlayScreen extends Stage { - private app!: Application; - private elapsed = 0; - private mouseStr = 0; - private effect!: ShaderEffect; - private panel?: HTMLDivElement; - - onResetEvent(app: Application) { - this.app = app; - const w = app.viewport.width; - const h = app.viewport.height; - - // the extra texture: a STATIC seamless noise field — baked once, never - // re-baked per frame (the shader scrolls it on the GPU instead) - const noise = new NoiseTexture2d({ - width: 256, - height: 256, - type: "simplex", - seed: 7, - frequency: 0.03, - octaves: 4, - gain: 0.55, - domainWarp: true, - domainWarpAmp: 6, - seamless: true, - }); - - // the refraction effect, preloaded as a "shader" asset — compiled once - // during loading; getShader returns the SHARED, loader-owned instance - this.effect = loader.getShader("refraction") as ShaderEffect; - // bind the noise as an extra sampler — "repeat" so the projected/scrolled - // floor UVs wrap seamlessly toward the horizon - this.effect.setTexture("uNoise", noise.getTexture(), "repeat"); - this.effect.setUniform("uAmount", 0.06); - this.effect.setUniform("uCaustic", 0.5); - this.effect.setUniform("uMouse", new Float32Array([0.5, 0.7])); - this.effect.setUniform("uMouseStr", 0); - - // a full-viewport sprite whose albedo is the pool tiles; the effect - // projects + refracts that texture into a perspective pool floor - const floor = new Sprite(w / 2, h / 2, { - image: poolTiles(256), - framewidth: w, - frameheight: h, - anchorPoint: { x: 0.5, y: 0.5 }, - }); - floor.shader = this.effect; - app.world.addChild(floor, 0); - - // touch the water → a swell that follows the pointer and decays - const setMouse = (gx: number, gy: number) => { - this.effect.setUniform("uMouse", new Float32Array([gx / w, gy / h])); - }; - input.registerPointerEvent("pointermove", app.viewport, (event) => { - setMouse(event.gameX, event.gameY); - this.mouseStr = Math.min(1, this.mouseStr + 0.35); - }); - input.registerPointerEvent("pointerdown", app.viewport, (event) => { - setMouse(event.gameX, event.gameY); - this.mouseStr = 1; - }); - - this.buildSlider(app); - } - - // a live "ripple strength" slider — writes the uAmount uniform directly - private buildSlider(app: Application) { - const panel = document.createElement("div"); - panel.style.cssText = - "position:absolute;top:60px;left:16px;z-index:1000;font-family:sans-serif;" + - "color:#e8f6fa;background:rgba(0,0,0,0.45);padding:8px 12px;border-radius:6px;"; - const label = document.createElement("div"); - label.textContent = "🌊 Ripple strength"; - label.style.cssText = "font-size:12px;margin-bottom:6px;"; - const slider = document.createElement("input"); - slider.type = "range"; - slider.min = "0"; - slider.max = "0.15"; - slider.step = "0.005"; - slider.value = "0.06"; - slider.style.cssText = "width:190px;display:block;"; - slider.addEventListener("input", () => { - this.effect.setUniform("uAmount", Number.parseFloat(slider.value)); - }); - const hint = document.createElement("div"); - hint.textContent = "move / click the pool to make waves"; - hint.style.cssText = "font-size:10px;margin-top:6px;opacity:0.7;"; - panel.appendChild(label); - panel.appendChild(slider); - panel.appendChild(hint); - const parent = app.renderer.getCanvas().parentElement; - if (parent) { - parent.style.position = "relative"; - parent.appendChild(panel); - } - this.panel = panel; - } - - update(dt: number) { - this.elapsed += dt / 1000; - // drive the shader clock — this is what animates the refraction, - // NOT a per-frame CPU noise re-bake - this.effect.setTime(this.elapsed); - // decay the pointer swell - this.mouseStr = Math.max(0, this.mouseStr - (dt / 1000) * 1.5); - this.effect.setUniform("uMouseStr", this.mouseStr); - super.update(dt); - // keep redrawing so the water animates every frame - return true; - } - - onDestroyEvent() { - input.releasePointerEvent("pointermove", this.app.viewport); - input.releasePointerEvent("pointerdown", this.app.viewport); - // the loader owns the shared shader — unloading is what frees it - // (the floor sprite's own cleanup skips it, since `shared` is true). - // NOTE: this example is single-stage, so its onDestroyEvent only fires - // on final teardown. In a multi-stage game, unload in your GAME's - // teardown instead — onDestroyEvent fires on every stage switch, and - // a re-entered stage would then getShader() a name that's gone. - loader.unload({ name: "refraction", type: "shader" }); - this.panel?.remove(); - } -} - -const createGame = () => { - video.init(728, 410, { - parent: "screen", - scaleMethod: "flex", - // ShaderEffect is a WebGL-only feature; under a Canvas fallback the - // custom shader is a no-op and the floor would render undistorted. - renderer: video.WEBGL, - // LINEAR filtering so the refracted floor + noise sample smoothly - antiAlias: true, - }); - - state.set(state.PLAY, new PlayScreen()); - - // ship the refraction shader as a preloaded "shader" asset: compiled once - // at load time (inline GLSL via `data` here — a `.frag` URL via `src` - // works the same) and retrieved ready-made with loader.getShader() - loader.preload( - [{ name: "refraction", type: "shader", data: REFRACTION_FRAGMENT }], - () => { - state.change(state.PLAY); - }, - false, - ); -}; - -export const ExampleWaterRefraction = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 7a52c9457..799416d49 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -98,9 +98,14 @@ const ExampleSpriteIlluminator = lazy(() => default: m.ExampleSpriteIlluminator, })), ); -const ExampleWaterRefraction = lazy(() => - import("./examples/waterRefraction/ExampleWaterRefraction").then((m) => ({ - default: m.ExampleWaterRefraction, +const ExampleAquarium = lazy(() => + import("./examples/aquarium/ExampleAquarium").then((m) => ({ + default: m.ExampleAquarium, + })), +); +const ExampleHeatHaze = lazy(() => + import("./examples/heatHaze/ExampleHeatHaze").then((m) => ({ + default: m.ExampleHeatHaze, })), ); const ExampleLineOfSight = lazy(() => @@ -362,12 +367,20 @@ const examples: { "Per-pixel sprite lighting from normal maps. Animated character + foreground prop tile lit by a moving cursor light, faithfully ported from CodeAndWeb's cocos2d-x demo.", }, { - component: , - label: "Water Refraction", - path: "water-refraction", - sourceDir: "waterRefraction", + component: , + label: "Aquarium", + path: "aquarium", + sourceDir: "aquarium", + description: + "Screen-space refraction via renderer.toFrameTexture(): swimming fish rippled through the live scene, captured on the GPU and distorted by a scrolling NoiseTexture2d.", + }, + { + component: , + label: "Heat Haze", + path: "heat-haze", + sourceDir: "heatHaze", description: - "GPU UV-refraction: a tiled pool floor rippled through a custom ShaderEffect that samples a second, static NoiseTexture2d bound via setTexture() — scrolled on the GPU (setTime), no per-frame CPU re-bake. Pointer swells, animated caustics, live ripple-strength slider.", + "renderer.toFrameTexture() over a LIT scene: normal-mapped tiles under a moving Light2d, distorted by a rising heat-haze that captures the lit frame.", }, { component: , diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 47572848f..a96f29ee2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,15 +3,16 @@ ## [19.9.0] (melonJS 2) - _unreleased_ ### Added +- **`renderer.toFrameTexture()` — GPU frame capture as a `Texture2d`** (closes [#1544](https://github.com/melonjs/melonJS/issues/1544)) — capture the current camera view / framebuffer into a `Texture2d` entirely on the GPU, for screen-space effects (water refraction, heat haze, frosted glass) that need to sample "what's on screen behind me". The fourth member of the `toDataURL` / `toBlob` / `toImageBitmap` family — "the current frame as X" — and the only one whose result never leaves the GPU: a `copyTexImage2D` of the bound framebuffer, ~10–100× cheaper than a `readPixels` round-trip and with zero CPU⇄GPU stall. The capture reflects the framebuffer **at the call site** (a `flush()` runs first — Godot `BackBufferCopy` placement), reads whichever framebuffer is bound (backbuffer or a camera post-effect FBO, so it works under `Camera2d` and `Camera3d`), and returns a `Texture2d` you feed straight to a custom effect: `effect.setTexture("uScene", renderer.toFrameTexture())`. `setTexture` now binds such a GPU-resident capture as a **live** handle — re-capturing each frame into the shared slot refreshes what the shader samples with no re-bind. Options: `target` (reuse/mint a caller-owned capture) and `region` (sub-region copy). Color only (RGB, opaque); the Canvas renderer ships an offscreen-canvas equivalent for family parity. See the new **Aquarium** example (fish rendered to the scene, refracted through a rippling water surface that samples the captured frame). - **BMFont XML font support for `BitmapText`** — bitmap fonts can now be loaded from the AngelCode BMFont **XML** flavour in addition to the text `.fnt` format (the serialisation is auto-detected). Many bitmap-font tools and asset packs (itch.io, dafont, …) export PNG + XML; those now load directly through the standard preloader (`{ type: "binary" }`), with no offline conversion step. The XML is parsed with a dependency-free regex parser (no `DOMParser`), so it also works outside the browser (Node / SSR) — unlike the DOM-based TMX XML path. - **Holes & compound paths in `Path2D` / SVG fills** (#1253) — a path may now contain multiple sub-paths (every `M` in an SVG string, or every `moveTo()` call, starts a new one). On `fill()` the first sub-path is the outer contour and each subsequent sub-path is treated as a hole, so donuts, rings, letter counters ("O", "A", "8") and other shapes with holes render correctly under both the WebGL renderer (earcut hole triangulation) and the Canvas renderer (native winding rule). Method semantics follow the standard [`Path2D`](https://developer.mozilla.org/en-US/docs/Web/API/Path2D) API; see the updated **SVG Shapes** example. -- **`Texture2d` base class for texture assets** — a new abstract base (`me.Texture2d`) for user-constructed texture objects that own a drawable source, exposed via `getTexture()`. `TextureAtlas` now extends it (no behavior change), and the renderables (`Sprite`, `Sprite3d`, `Mesh`) recognize any `Texture2d` by type and resolve it to its backing canvas/image — so a texture asset can be passed directly (`{ image: myTexture }`) just like a raw canvas. Raw DOM images/canvases and the loader's decoded `CompressedImage` data remain valid sources outside the class hierarchy. +- **`Texture2d` base class for texture assets** — a new abstract base (`me.Texture2d`) for user-constructed texture objects that own a texture source, exposed via `getTexture()`. `TextureAtlas` now extends it (no behavior change), and the renderables (`Sprite`, `Sprite3d`, `Mesh`) recognize any `Texture2d` by type and resolve it to its backing source — so a texture asset can be passed directly (`{ image: myTexture }`) just like a raw canvas. Raw DOM images/canvases and the loader's decoded `CompressedImage` data remain valid sources outside the class hierarchy. The contract deliberately admits both CPU-backed sources (a drawable canvas/image) and **GPU-resident** ones (an opaque backing that never leaves the GPU) — the shape a future WebGPU backend and screen-capture textures follow. - **Procedural noise: `Noise` + `NoiseTexture2d`** — `me.Noise` is a renderer-free coherent-noise generator (simplex / perlin / value / valueCubic / cellular, with fBm, ridged and ping-pong fractal layering, plus optional domain warp) that you can sample on the CPU via `getNoise2d` / `getNoise3d` for gameplay (heightmaps, terrain, spawn jitter) — fully deterministic per `seed`. `me.NoiseTexture2d` is a `Texture2d` that bakes a `Noise` field into a drawable canvas, with three output modes: grayscale, a `Gradient` **color ramp**, or a tangent-space **normal map** (`asNormalMap` + `bumpStrength`) for per-pixel lighting; optional `seamless` tiling (`seamlessBlendSkirt`); and **live animation** (`animated` + `speed` — sampled in 3D, advanced by `update(dt)`). Each re-bake bumps a content `version` stamped on the canvas; the WebGL lit pipeline re-uploads the normal-map texture only when that version changes (the three.js `Texture.needsUpdate` model — no renderer handle needed). Both work under the Canvas and WebGL backends. - **`shared` flag on `ShaderEffect` / `GLShader`** — set `effect.shared = true` on a post-processing shader that is reused across several renderables, so one renderable's cleanup (the `shader` setter, `removePostEffect`, `clearPostEffects`, or `destroy()`) doesn't free the GL program the others still use. Defaults to `false` (unchanged auto-destroy behavior); when set, you own the shader's lifecycle and call `destroy()` yourself. - **`ShaderEffect.setTime(seconds)`** — a convenience for the `uTime` shader-animation convention, now on the base class: call it once per frame to write a shader's `uniform float uTime` (e.g. to scroll a static texture's UVs, or pulse / wave), driven by whatever clock you choose. A no-op when the shader doesn't declare `uTime`, so it's safe on any effect. Manual by design — the engine never calls it, so animation stays opt-in. The built-in `Hologram` / `Shine` effects now inherit it (their identical `setTime` overrides were removed). -- **`ShaderEffect.setTexture(name, image[, repeat])`** (closes [#1532](https://github.com/melonjs/melonJS/issues/1532)) — bind an **extra** `sampler2D` to a custom post-effect, beyond the sprite/target it processes (`uSampler`) — a noise map, mask, gradient, or flow table. Declare `uniform sampler2D ;` in your fragment and pass any engine texture (e.g. `noiseTexture.getTexture()`); the engine uploads it once, caches it, and (re)binds it to a reserved texture unit each draw while pointing the sampler uniform at it — no raw WebGL texture-unit juggling. Works on both post-effect paths (single-effect sprite shader and the multi-effect / camera FBO blit). Enables UV-distortion water, dissolve masks, flow maps, palette lookups, etc. Purely additive — effects that never call it are unchanged. See the new **Water Refraction** example (a perspective pool floor refracted through a GPU-scrolled seamless `NoiseTexture2d`). +- **`ShaderEffect.setTexture(name, image[, repeat])`** (closes [#1532](https://github.com/melonjs/melonJS/issues/1532)) — bind an **extra** `sampler2D` to a custom post-effect, beyond the sprite/target it processes (`uSampler`) — a noise map, mask, gradient, or flow table. Declare `uniform sampler2D ;` in your fragment and pass any engine texture — a `Texture2d` asset (`NoiseTexture2d`, `TextureAtlas`, …) directly, or a raw drawable source; the engine uploads it once, caches it, and (re)binds it to a reserved texture unit each draw while pointing the sampler uniform at it — no raw WebGL texture-unit juggling. Works on both post-effect paths (single-effect sprite shader and the multi-effect / camera FBO blit). Enables UV-distortion water, dissolve masks, flow maps, palette lookups, etc. Purely additive — effects that never call it are unchanged. See the **Aquarium** example (a water surface refracting the live scene through a GPU-scrolled seamless `NoiseTexture2d`). - **Anchor-point presets + `Sprite3d` anchor support** (#1514) — `settings.anchorPoint` now accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` in addition to an `{x, y}` object, uniformly across `Sprite`, `Entity`, `Collectable`, `ImageLayer`, `Text`, `BitmapText` (and their subclasses), including as a plain-string Tiled property. `Sprite3d` gains anchoring through the same `settings.anchorPoint` key (centered default, same convention as `Sprite`): the anchor is baked into the quad's local vertices so it composes with billboarding, flips and trimmed/rotated atlas frames, never leaks into the renderer transform on either camera path, is mutable at runtime via `sprite3d.anchorPoint.set(...)` (automatic re-bake + cull-bounds update), and grows the frustum-cull bounds exactly so an anchored sprite can't pop out while still on screen. On the 2D classes invalid values (unknown preset, malformed object) keep their historical silent `(0, 0)` outcome for full backward compatibility, but now log a console warning; `Sprite3d` (a new surface) throws. See the updated billboard example (`anchorPoint: "bottom"` — feet on the ground plane). -- **Shader preloading: `"shader"` asset type + `loader.getShader()` + `ShaderEffect.clone()`** — custom shaders can now be preloaded like any other asset and are **compiled at load time** (the GLSL compile cost lands in the loading screen, and a compile error fails the load — `loader.load()` rejects with an error carrying the asset's name). The source is a GLSL fragment body following the `ShaderEffect` convention (`uniform` declarations + `vec4 apply(vec4 color, vec2 uv)`), loaded from a `src` URL / data: URI or inline via the `data` field (the inline-TMX convention): `{ name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }`. `loader.getShader(name)` returns the **shared, loader-owned instance** — the *same* object on every call, with `shared = true` so renderable cleanup never auto-destroys it; everything it is assigned to shares one set of uniform values, and it is freed only by `loader.unload()` / `loader.unloadAll()` (which destroy the GL program). For per-renderable uniform values, the new `ShaderEffect.clone()` compiles an independent, caller-owned copy: it copies the *recipe* (fragment source, precision, uniform values, `setTexture` bindings — with its own GL uploads) but never the *ownership* — the clone's `shared` flag is **always reset to `false`**, so it is auto-destroyed with the renderable it is assigned to unless explicitly re-shared. `GLShader.clone()` exists likewise for raw full-program (vertex + fragment) shaders, with the same recipe-not-ownership semantics. The **Water Refraction** example now ships its fragment as a preloaded shader asset. +- **Shader preloading: `"shader"` asset type + `loader.getShader()` + `ShaderEffect.clone()`** — custom shaders can now be preloaded like any other asset and are **compiled at load time** (the GLSL compile cost lands in the loading screen, and a compile error fails the load — `loader.load()` rejects with an error carrying the asset's name). The source is a GLSL fragment body following the `ShaderEffect` convention (`uniform` declarations + `vec4 apply(vec4 color, vec2 uv)`), loaded from a `src` URL / data: URI or inline via the `data` field (the inline-TMX convention): `{ name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }`. `loader.getShader(name)` returns the **shared, loader-owned instance** — the *same* object on every call, with `shared = true` so renderable cleanup never auto-destroys it; everything it is assigned to shares one set of uniform values, and it is freed only by `loader.unload()` / `loader.unloadAll()` (which destroy the GL program). For per-renderable uniform values, the new `ShaderEffect.clone()` compiles an independent, caller-owned copy: it copies the *recipe* (fragment source, precision, uniform values, `setTexture` bindings — with its own GL uploads) but never the *ownership* — the clone's `shared` flag is **always reset to `false`**, so it is auto-destroyed with the renderable it is assigned to unless explicitly re-shared. `GLShader.clone()` exists likewise for raw full-program (vertex + fragment) shaders, with the same recipe-not-ownership semantics. The **Aquarium** and **Heat Haze** examples ship their fragments as preloaded shader assets. ### Fixed - **`loader.unloadAll()` threw if any fontface was loaded, and never actually freed videos** — two copy-paste type strings: the video-cache sweep dispatched entries as `type: "json"` (the lookup missed, so video assets survived every `unloadAll`), and the font-cache sweep used `type: "font"` — an unknown type that hit the loader's `unload` error path and **aborted the whole `unloadAll` mid-way**. Both now route through their correct types (`"video"` / `"fontface"`). Also fixed an always-true `typeof typeof globalThis.document` guard in the fontface unload case. @@ -36,6 +37,7 @@ - **`Rect.right`/`Rect.bottom` returned the width/height when the edge sat exactly at coordinate 0** — the getters computed `this.left + w || w`, so an edge landing precisely on 0 (falsy) fell back to the size. Bounds and collision math around the origin came out wrong for such rectangles. - **`Rect.toPolygon()` handed out the rectangle's live vertices** — `Polygon.setVertices` stores a `Vector2d[]` by reference, so the "new" polygon shared the rect's actual points array: transforming or mutating the polygon silently corrupted the rectangle. The vertices are now cloned. - **`loader.load()` mutated the caller's asset descriptor, so retries double-prepended `baseURL`** — the resolved URL (fontface `url()` unwrap + `baseURL` prefix) was written back into `asset.src`; `loader.reload()` (which re-loads the same stored object) — or simply `load()`ing the same manifest entry twice — then fetched `base + base + src`. The URL is now resolved into a copy handed to the parser; the caller's object is never touched. +- **`ShaderEffect.setUniform()` silently dropped values while the effect was disabled** — including during a context-loss window, where the auto-disable gate defeated the very suspend-cache replay built for it (a uniform set mid-loss reverted to its pre-loss value after restore); a user-disabled effect lost values the same way. `setUniform` now always forwards to the inner shader (which correctly defers while suspended); `setTexture` likewise stores its binding regardless of the enabled state for the lazy upload on the next enabled draw, and `setTime` skips only while genuinely suspended. Canvas-mode stubs keep no-oping. - **`loader.unload()` of a never-loaded fontface threw a TypeError** — the fontface case was the only one without a membership guard, and `FontFaceSet.delete(undefined)` is a WebIDL type error rather than a `false` return. It now returns `false` like every other asset type. - **Re-preloading an audio manifest silently replaced each Howl and leaked the old one** — the audio parser was the only asset parser without an already-loaded guard, so returning to a stage that preloads (a common pattern) churned every clip's decoded buffers / HTML5 nodes. An already-loaded clip is now reported as cached (like every other asset type); `me.audio.unload()` first to genuinely reload one. - **Parallel audio loads shared a single retry counter** — three failures of one flaky file pushed *another* sound's first failure straight over the give-up threshold, throwing a spurious fatal load error (the default `stopOnAudioError`). Retries are now budgeted per sound. diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index 75c24233c..49a5d1bcc 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -9,6 +9,7 @@ import { import { Gradient } from "./../gradient.js"; import Renderer from "./../renderer.js"; import TextureCache from "./../texture/cache.js"; +import { CanvasFrameTexture } from "./../texture/frametexture.js"; /** * additional import for TypeScript @@ -18,6 +19,8 @@ import TextureCache from "./../texture/cache.js"; * @import {Line} from "./../../geometries/line.ts"; * @import {Ellipse} from "./../../geometries/ellipse.ts"; * @import {Matrix2d} from "../../math/matrix2d.ts"; + * @import {Bounds} from "../../physics/bounds.ts"; + * @import {default as Texture2d} from "../texture/texture2d.ts"; */ /** @@ -85,6 +88,98 @@ export default class CanvasRenderer extends Renderer { this._lightCache = undefined; } + /** + * Capture the current frame into a {@link Texture2d} — the Canvas-renderer + * counterpart of {@link WebGLRenderer#toFrameTexture}, keeping the + * `toDataURL` / `toBlob` / `toImageBitmap` family renderer-complete. Backed + * by an offscreen `` copy of the drawing buffer (Canvas rendering is + * immediate, so no flush is needed). Custom shaders don't run under the + * Canvas renderer, so the result is a plain drawable for CPU-side reuse + * rather than a live shader input. + * @param {object} [options] + * @param {Texture2d|null} [options.target] - controls the destination: omit + * for the shared renderer slot (default); pass a capture previously + * returned by this method to refresh it in place; pass `null` to mint a + * fresh, caller-owned capture (mirrors {@link WebGLRenderer#toFrameTexture}) + * @param {Bounds|{x: number, y: number, width: number, height: number}} [options.region] - capture + * only this sub-region (canvas pixels, top-left origin). A {@link Bounds} + * (or any `{x, y, width, height}`); from a {@link Rect} pass `rect.getBounds()`. + * @returns {Texture2d} a texture holding the captured frame + */ + toFrameTexture(options = {}) { + // a non-null `target` must be a capture this method previously returned + // (a CanvasFrameTexture), or the frame.canvas dereference below throws a + // confusing error — mirror the WebGLRenderer guard + if ( + typeof options.target !== "undefined" && + options.target !== null && + !(options.target instanceof CanvasFrameTexture) + ) { + throw new Error( + "CanvasRenderer.toFrameTexture: `target` must be a capture returned by this method", + ); + } + + const src = this.getCanvas(); + let x = 0; + let y = 0; + let w = src.width; + let h = src.height; + const region = options.region; + if (typeof region !== "undefined") { + const rx = region.x; + const ry = region.y; + // clamp the origin into the canvas first, then size to what remains + // (matches WebGLRenderer#toFrameTexture); missing width/height + // defaults to "the rest of the canvas from x/y" + x = Math.min(Math.max(0, Math.floor(rx || 0)), src.width - 1); + y = Math.min(Math.max(0, Math.floor(ry || 0)), src.height - 1); + const rw = Number.isFinite(region.width) + ? Math.ceil(region.width) + : src.width - x; + const rh = Number.isFinite(region.height) + ? Math.ceil(region.height) + : src.height - y; + w = Math.max(1, Math.min(src.width - x, rw)); + h = Math.max(1, Math.min(src.height - y, rh)); + } + + // no target → shared slot; target: null → mint owned; target: + // → refresh in place (mirrors WebGLRenderer#toFrameTexture) + const shared = typeof options.target === "undefined"; + let frame = shared + ? this._frameTexture + : options.target === null + ? undefined + : options.target; + if (typeof frame === "undefined") { + // mint a fresh capture (empty shared slot, or target: null). Route + // through the engine's canvas factory so it honors the same + // OffscreenCanvas capability gating as everything else. + frame = new CanvasFrameTexture(Renderer.createCanvas(w, h)); + if (shared) { + this._frameTexture = frame; + } + } else if (frame.width !== w || frame.height !== h) { + // resize the SAME capture in place (keep the object identity, like + // the WebGL variant) rather than returning a different instance + frame.canvas.width = w; + frame.canvas.height = h; + frame.width = w; + frame.height = h; + } + + const ctx = frame.canvas.getContext("2d"); + if (ctx === null) { + throw new Error( + "CanvasRenderer.toFrameTexture: 2D context unavailable on the capture canvas", + ); + } + ctx.clearRect(0, 0, w, h); + ctx.drawImage(src, x, y, w, h, 0, 0, w, h); + return frame; + } + /** * Reset the canvas transform to identity */ diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index a37265fea..b988ac50e 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -19,6 +19,7 @@ import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; * @import {Line} from "./../geometries/line.ts"; * @import {Ellipse} from "./../geometries/ellipse.ts"; * @import {Bounds} from "./../physics/bounds.ts"; + * @import {default as Texture2d} from "./texture/texture2d.ts"; */ /** @@ -1117,6 +1118,40 @@ export default class Renderer { toDataURL(type = "image/png", quality) { return this.renderTarget.toDataURL(type, quality); } + + /** + * Capture the current frame — everything drawn to the active framebuffer / + * canvas so far — into a {@link Texture2d}, on the GPU where possible (no + * `readPixels` round-trip). The fourth member of the {@link Renderer#toDataURL} + * / {@link Renderer#toBlob} / {@link Renderer#toImageBitmap} family — "the + * current frame as X" — and the only one whose result can stay GPU-resident, + * ready to feed a shader as a screen texture (water refraction, heat haze, + * frosted glass). + * + * Abstract on the base renderer: the concrete backends implement it — + * {@link WebGLRenderer#toFrameTexture} (a `copyTexImage2D` into a live GPU + * texture, bound as an extra sampler via {@link ShaderEffect#setTexture}) and + * {@link CanvasRenderer#toFrameTexture} (an offscreen-canvas copy, for family + * parity — custom shaders don't run under Canvas). + * @param {object} [options] + * @param {Texture2d|null} [options.target] - controls the destination: omit + * for the shared renderer slot (default); pass a capture previously + * returned by this method to refresh it in place; pass `null` to mint a + * fresh, caller-owned capture + * @param {Bounds|{x: number, y: number, width: number, height: number}} [options.region] - capture + * only this sub-region of the frame — a {@link Bounds} (or any + * `{x, y, width, height}`); from a {@link Rect} pass `rect.getBounds()`. + * NOTE the origin differs by backend: WebGL uses framebuffer coords + * (**bottom-left** origin), Canvas uses **top-left** — relevant under + * `video.AUTO`. + * @returns {Texture2d} a texture holding the captured frame + * @example + * effect.setTexture("uScene", renderer.toFrameTexture()); + */ + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + toFrameTexture(options) { + throw new Error("toFrameTexture() is not implemented by this renderer"); + } } // Backing field for `Renderer.getWhitePixel()` — declared outside the diff --git a/packages/melonjs/src/video/texture/frametexture.js b/packages/melonjs/src/video/texture/frametexture.js new file mode 100644 index 000000000..c7cf5385a --- /dev/null +++ b/packages/melonjs/src/video/texture/frametexture.js @@ -0,0 +1,96 @@ +import Texture2d from "./texture2d.ts"; + +/** + * A GPU-resident texture holding a captured frame — the value returned by + * {@link WebGLRenderer#toFrameTexture}. Its backing is a live `WebGLTexture` + * that the renderer refreshes **in place** on each capture, so a shader that + * has bound it once (via {@link ShaderEffect#setTexture}) keeps sampling the + * latest frame with no re-bind. + * + * Not part of the public class surface: obtain instances from + * `renderer.toFrameTexture()` and treat them as opaque {@link Texture2d} + * values. The `isGPUResident` flag is the discriminant `setTexture` uses to + * take its live-bind path (bind the existing handle) instead of uploading a + * static copy. + * @augments Texture2d + * @ignore + */ +export class FrameTexture extends Texture2d { + /** + * @param {WebGLRenderer} renderer - the owning renderer (for the GL context) + * @param {number} width - capture width in pixels + * @param {number} height - capture height in pixels + */ + constructor(renderer, width, height) { + super(); + this._renderer = renderer; + /** @type {number} */ + this.width = width; + /** @type {number} */ + this.height = height; + /** + * the live backing texture, (re)filled by `renderer.toFrameTexture` and + * bound (never re-uploaded) by consumers + * @type {WebGLTexture|null} + */ + this.glTexture = null; + /** + * marks this as a live GPU-resident source — see {@link ShaderEffect#setTexture} + * @type {boolean} + */ + this.isGPUResident = true; + } + + /** + * The opaque GPU-resident backing — itself. + * @returns {FrameTexture} + */ + getTexture() { + return this; + } + + /** + * Delete the backing GL texture. Invoked on the renderer-owned shared slot + * at context loss / renderer teardown, or by a caller that owns a `target` + * capture and is done with it. Idempotent. + */ + destroy() { + if (this.glTexture !== null) { + this._renderer.gl.deleteTexture(this.glTexture); + this.glTexture = null; + } + } +} + +/** + * The Canvas-renderer counterpart of {@link FrameTexture}: a captured frame + * backed by an offscreen `` copy of the drawing buffer, keeping the + * `toFrameTexture` family renderer-complete. A drawable source (not + * GPU-resident), so it flows through the normal image path; custom shaders + * don't run under the Canvas renderer, so this exists for API parity and + * CPU-side reuse (e.g. drawing the captured frame back onto the scene). + * @augments Texture2d + * @ignore + */ +export class CanvasFrameTexture extends Texture2d { + /** + * @param {HTMLCanvasElement|OffscreenCanvas} canvas - the backing copy + */ + constructor(canvas) { + super(); + /** @type {HTMLCanvasElement|OffscreenCanvas} */ + this.canvas = canvas; + /** @type {number} */ + this.width = canvas.width; + /** @type {number} */ + this.height = canvas.height; + } + + /** + * The backing canvas copy. + * @returns {HTMLCanvasElement|OffscreenCanvas} + */ + getTexture() { + return this.canvas; + } +} diff --git a/packages/melonjs/src/video/texture/texture2d.ts b/packages/melonjs/src/video/texture/texture2d.ts index cfe03bc94..bce214492 100644 --- a/packages/melonjs/src/video/texture/texture2d.ts +++ b/packages/melonjs/src/video/texture/texture2d.ts @@ -1,31 +1,80 @@ /** - * Abstract base for a user-constructed 2D texture asset — an object that owns - * a drawable image source and can be used anywhere the engine expects an - * image: {@link Sprite#image}, {@link Sprite#normalMap}, an {@link ImageLayer}, - * or bound as a sampler uniform in a custom {@link GLShader}. + * Compile-time-only brand key; no runtime value can hold it. + * @ignore + */ +declare const gpuResidentBrand: unique symbol; + +/** + * An opaque, GPU-resident texture backing — the source a GPU-resident + * {@link Texture2d} resolves to (for example the framebuffer copy + * {@link WebGLRenderer#toFrameTexture} produces). Such a backing uploads/binds + * itself through the texture cache instead of being read back to the CPU. Its + * concrete shape is an engine internal that may change between releases: + * obtain values from the engine and pass them back to the engine — do not + * construct or inspect them. * - * A `Texture2d` is recognized by the renderables via `instanceof` and resolved - * to its backing canvas/image through {@link Texture2d#getTexture} — so passing - * the asset object directly (`{ image: myTexture }`) works the same as passing a - * raw `HTMLCanvasElement`. Raw DOM image/canvas sources and the loader's - * decoded `CompressedImage` data are accepted too, but are not part of this - * class hierarchy. + * Nominally branded with a `unique symbol`, so only engine-provided values + * satisfy the type — a plain object is NOT assignable, keeping + * {@link Texture2dSource} (and {@link Texture2d#getTexture}) honestly typed. + */ +export interface GPUResidentTexture { + /** + * Phantom nominal brand — never present at runtime. + * @ignore + */ + readonly [gpuResidentBrand]: true; +} + +/** + * The backing source a {@link Texture2d} resolves to through + * {@link Texture2d#getTexture}: a drawable (canvas/image) for CPU-backed + * assets, or an opaque {@link GPUResidentTexture} for GPU-resident ones. + */ +export type Texture2dSource = + | HTMLCanvasElement + | HTMLImageElement + | OffscreenCanvas + | ImageBitmap + | GPUResidentTexture; + +/** + * Abstract base for a 2D texture asset — an object that owns a texture + * source and can be used anywhere the engine expects an image: + * {@link Sprite#image}, {@link Sprite#normalMap}, an {@link ImageLayer}, or + * bound as a sampler uniform in a custom shader (see + * {@link ShaderEffect#setTexture}). * - * Concrete implementations: - * - {@link TextureAtlas} — packed multi-region sprite sheet + * A `Texture2d` is recognized via `instanceof` and resolved to its backing + * source through {@link Texture2d#getTexture} — so passing the asset object + * directly (`{ image: myTexture }`) works the same as passing a raw + * `HTMLCanvasElement`. Raw DOM image/canvas sources and the loader's decoded + * `CompressedImage` data are accepted too, but are not part of this class + * hierarchy. + * + * Most assets are **CPU-backed** and resolve to a drawable canvas/image — those + * are the ones assignable to {@link Sprite#image} / {@link Sprite#normalMap} / + * an {@link ImageLayer} (which upload the drawable through the texture cache). + * Subclasses may also be **GPU-resident**, resolving to an opaque renderer + * backing that never leaves the GPU (e.g. the capture returned by + * `renderer.toFrameTexture()`). A GPU-resident texture is **not** a drawable + * source: use it as a custom-shader sampler via {@link ShaderEffect#setTexture} + * (which binds its live GL handle), NOT as `Sprite#image`. The contract admits + * both (a future WebGPU backend follows the same shape with a `GPUTexture`). * - * A future WebGPU implementation would back {@link Texture2d#getTexture} with a - * `GPUTexture`-wrapping surface rather than an `HTMLCanvasElement`. + * Concrete implementations: + * - {@link TextureAtlas} — packed multi-region sprite sheet (CPU-backed) * @category Game Objects */ export default abstract class Texture2d { /** - * Return the drawable source for this texture — assignable to - * {@link Sprite#image}, {@link Sprite#normalMap}, an {@link ImageLayer}, or - * bound as a sampler uniform in a custom shader. - * @returns the backing canvas/image + * Return the backing source for this texture — a drawable canvas/image for + * CPU-backed assets (assignable to {@link Sprite#image} / + * {@link Sprite#normalMap} / an {@link ImageLayer}), or an opaque + * GPU-resident backing for GPU-resident ones (bind those as a custom-shader + * sampler via {@link ShaderEffect#setTexture}; they are not drawable sources). + * @returns the backing source */ - abstract getTexture(): HTMLCanvasElement | HTMLImageElement; + abstract getTexture(): Texture2dSource; /** * Release any GPU/CPU resources held by this texture. The texture must not diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index f3ecdc7d3..91f763891 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -166,6 +166,43 @@ export default class LitQuadBatcher extends QuadBatcher { this.defaultShader.setUniform("uAmbient", [0, 0, 0]); } + /** + * Also drop the normal-map pairing when its paired unit is invalidated. + * Normal maps live at units `maxBatchTextures..2*maxBatchTextures-1` + * (indexed by the paired albedo unit), which overlap the top units that + * {@link WebGLRenderer#toFrameTexture} (its scratch unit) and + * {@link ShaderEffect#_prepareTextures} (its reserved extra samplers, + * counting DOWN from the top) bind directly. When one of those GL units is + * clobbered we must forget the pairing, or the next lit draw would assume + * the normal is still resident and skip re-binding it, sampling the + * clobbering texture as a normal map. + * @param {number} unit - the GL texture unit to invalidate + * @ignore + */ + invalidateUnit(unit) { + super.invalidateUnit(unit); + const n = unit - this.maxBatchTextures; + if (n >= 0 && n < this.maxBatchTextures) { + this.boundNormalMaps[n] = null; + this.boundNormalVersions[n] = -1; + } + } + + /** + * On a full texture-cache reset, also forget the paired normal-map + * bindings — the base handler only clears the color `boundTextures`, so a + * lit draw after a reset would otherwise assume a normal map is still bound + * (and skip re-binding it) while its GL unit may have been reused for a + * color texture. The `?.` guards a reset firing before `init` allocates the + * arrays (none does today; hardens against future init reordering). + * @ignore + */ + _onTextureCacheReset() { + super._onTextureCacheReset(); + this.boundNormalMaps?.fill(null); + this.boundNormalVersions?.fill(-1); + } + /** * Upload per-frame Light2d uniforms used by the lit fragment path. * Called once per camera per frame (before the world tree walk). diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index 460b7febf..a8360082c 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -52,15 +52,29 @@ export class MaterialBatcher extends Batcher { // `Batcher` directly and has no texture state, so it doesn't // need this. if (!this._onCacheReset) { + // delegate to an overridable method so subclasses that track extra + // per-unit bindings (lit normal maps) can drop those on a reset too this._onCacheReset = () => { - this.boundTextures.length = 0; - this.currentTextureUnit = -1; - this.currentSamplerUnit = -1; + this._onTextureCacheReset(); }; on(GPU_TEXTURE_CACHE_RESET, this._onCacheReset); } } + /** + * Drop every cached texture binding after a {@link GPU_TEXTURE_CACHE_RESET} + * (the shared texture cache reassigned units — our per-unit view is stale). + * Subclasses that pair extra samplers to units (lit normal maps) override to + * forget those too; without that they'd assume the extra texture is still + * resident and skip re-binding it after the reset. + * @ignore + */ + _onTextureCacheReset() { + this.boundTextures.length = 0; + this.currentTextureUnit = -1; + this.currentSamplerUnit = -1; + } + /** * Free resources used by the batcher. Currently unsubscribes the * texture-cache-reset listener so a discarded batcher doesn't keep @@ -349,6 +363,25 @@ export class MaterialBatcher extends Batcher { return unit; } + /** + * Forget whatever texture this batcher believes is bound to `unit`, so the + * next bind to it re-issues the GL bind. Used when a GL texture unit is + * clobbered OUTSIDE this batcher's own accounting — e.g. + * {@link WebGLRenderer#toFrameTexture}, which binds its capture to a scratch + * unit directly (not via the shared texture cache), so a different batcher's + * unit cache would otherwise skip a needed re-bind. Subclasses that pair + * extra samplers to the same unit (lit normal maps) override to drop those + * too. + * @param {number} unit - the GL texture unit to invalidate + * @ignore + */ + invalidateUnit(unit) { + delete this.boundTextures[unit]; + if (this.currentTextureUnit === unit) { + this.currentTextureUnit = -1; + } + } + /** * @ignore * @param {TextureAtlas|TextureResource} texture diff --git a/packages/melonjs/src/video/webgl/shadereffect.js b/packages/melonjs/src/video/webgl/shadereffect.js index 8250f36fa..dcf6747db 100644 --- a/packages/melonjs/src/video/webgl/shadereffect.js +++ b/packages/melonjs/src/video/webgl/shadereffect.js @@ -4,6 +4,7 @@ import { off, on, } from "../../system/event.ts"; +import Texture2d from "../texture/texture2d.ts"; import GLShader from "./glshader.js"; import quadVertex from "./shaders/quad.vert"; @@ -173,7 +174,13 @@ export default class ShaderEffect { * @param {object|Float32Array} value - the value to assign to that uniform */ setUniform(name, value) { - if (this.enabled) { + // forward whenever a live shader exists (WebGL mode): GLShader handles + // the suspended (context-lost) state by deferring to its uniform + // cache. Gating on `enabled` here silently dropped values set during + // 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) { this._shader.setUniform(name, value); } } @@ -208,7 +215,15 @@ export default class ShaderEffect { // would make setUniform throw), and not cached (so it stays correct // across a context-loss recompile). `enabled` is false while suspended // or destroyed, so the uniforms map is never null here. - if (this.enabled && typeof this._shader.uniforms.uTime !== "undefined") { + // skipped while suspended (uniforms === null mid-context-loss; a + // per-frame call self-heals on the next frame) and on Canvas stubs; + // a USER-disabled but live effect still takes the value + if ( + typeof this._shader !== "undefined" && + this.destroyed !== true && + !this._shader.suspended && + typeof this._shader.uniforms.uTime !== "undefined" + ) { this._shader.setUniform("uTime", seconds); } return this; @@ -223,10 +238,11 @@ export default class ShaderEffect { * texture-unit juggling. * * Declare the sampler in your fragment (`uniform sampler2D ;`) and pass - * that name here. Any engine texture works — e.g. `noiseTexture.getTexture()`. - * No-op in Canvas mode. + * that name here. Any engine texture works — a {@link Texture2d} asset + * (`NoiseTexture2d`, `TextureAtlas`, …) can be passed directly, or a raw + * drawable source. No-op in Canvas mode. * @param {string} name - the `sampler2D` uniform name declared in the fragment - * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - the texture source + * @param {Texture2d|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - the texture: an engine texture asset, or a raw drawable source * @param {"repeat"|"repeat-x"|"repeat-y"|"no-repeat"} [repeat="no-repeat"] - wrap mode; use `"repeat"` for a tiled/scrolled texture (power-of-two size under WebGL 1) * @returns {ShaderEffect} this effect for chaining * @example @@ -239,31 +255,56 @@ export default class ShaderEffect { * vec2 flow = texture2D(uNoise, uv + uTime * 0.03).rg - 0.5; * return texture2D(uSampler, uv + flow * 0.02); * }`); - * water.setTexture("uNoise", noise.getTexture(), "repeat"); + * water.setTexture("uNoise", noise, "repeat"); * waterSprite.shader = water; * // each frame, in your Stage's update(dt): * water.setTime(me.timer.getTime() / 1000); */ setTexture(name, image, repeat = "no-repeat") { - if (this.enabled) { - const existing = this._extraTextures.get(name); - if (existing) { - // release the previous GL texture + unit reservation before - // replacing the binding - if (existing.tex !== null) { - this._shader.gl.deleteTexture(existing.tex); - } - if (existing.unit !== undefined) { - this._renderer.cache.releaseUnit(existing.unit); - } - } - this._extraTextures.set(name, { - image, - repeat, - tex: null, - unit: undefined, - }); + // Canvas stub (no shader) and destroyed effects: keep the inert no-op + if (typeof this._shader === "undefined" || this.destroyed === true) { + return this; } + // A GPU-resident LIVE source (a frame capture from + // renderer.toFrameTexture): keep the wrapper and bind its live GL + // handle every draw — never upload a static copy — so re-capturing + // into the same slot each frame is picked up with no re-bind. The live + // path reads `.glTexture`, so require that handle to be present; + // otherwise fall through and unwrap like any static Texture2d asset, + // rather than take the live branch and silently never bind. + const live = + image instanceof Texture2d && + image.isGPUResident === true && + "glTexture" in image; + if (!live && image instanceof Texture2d) { + image = image.getTexture(); + } + // store the entry regardless of `enabled`: the GL work happens lazily + // in _prepareTextures on the next enabled draw, so a binding set while + // the effect is disabled — or mid-context-loss, where entries set + // during the window used to vanish permanently — survives intact. + const existing = this._extraTextures.get(name); + if (existing && existing.tex !== null) { + // drop the old STATIC GL texture we own so the new image re-uploads + // into the SAME reserved unit (kept below) on the next enabled draw. + // A live entry never owns a GL texture (tex stays null — the + // renderer/caller owns the handle), so this correctly skips it. + this._shader.gl.deleteTexture(existing.tex); + } + this._extraTextures.set(name, { + image, + repeat, + tex: null, + live, + // keep any unit already reserved for this sampler across the + // replace: releasing it here (then re-reserving lazily) would open + // a window — unbounded while the effect is disabled — in which the + // cache allocator could hand that high unit to a regular texture, + // reintroducing the collision reserveUnit() exists to prevent. The + // reservation is released only where the unit is truly invalid: + // context loss (_onContextLost) and destroy(). + unit: existing ? existing.unit : undefined, + }); return this; } @@ -305,24 +346,48 @@ export default class ShaderEffect { cache.reserveUnit(nextUnit); } nextUnit = entry.unit - 1; - if (entry.tex === null) { - batcher.createTexture2D( - entry.unit, - entry.image, - filter, - entry.repeat, - entry.image.width, - entry.image.height, - false, // premultipliedAlpha — keep raw texel values - false, // mipmap — not needed, and NPOT-unsafe under WebGL 1 - undefined, - false, // flush — the following draw flushes with everything bound - ); - entry.tex = batcher.boundTextures[entry.unit]; + let bound = false; + if (entry.live === true) { + // live GPU-resident source (frame capture): bind the current + // handle fresh each draw — the renderer refreshes it in place, + // so this samples the latest frame. Never uploaded, never freed + // by this effect. Skip binding if not captured yet (null). + const glTex = entry.image.glTexture; + if (glTex !== null && typeof glTex !== "undefined") { + batcher.bindTexture2D(glTex, entry.unit, false); + this._shader.setUniform(name, entry.unit); + bound = true; + } } else { - batcher.bindTexture2D(entry.tex, entry.unit, false); + if (entry.tex === null) { + batcher.createTexture2D( + entry.unit, + entry.image, + filter, + entry.repeat, + entry.image.width, + entry.image.height, + false, // premultipliedAlpha — keep raw texel values + false, // mipmap — not needed, and NPOT-unsafe under WebGL 1 + undefined, + false, // flush — the following draw flushes with everything bound + ); + entry.tex = batcher.boundTextures[entry.unit]; + } else { + batcher.bindTexture2D(entry.tex, entry.unit, false); + } + this._shader.setUniform(name, entry.unit); + bound = true; + } + // only when we actually bound: this sampler took a reserved high unit + // DIRECTLY (bypassing the shared texture cache), which overlaps the + // lit quad batcher's normal-map units — invalidate it on the OTHER + // batchers so a later lit draw re-binds its normal there instead of + // sampling this texture. A skipped live bind (no capture yet) clobbers + // nothing, so there's nothing to invalidate. + if (bound) { + batcher.renderer.invalidateTextureUnit(entry.unit, batcher); } - this._shader.setUniform(name, entry.unit); } } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index a052b6764..c9ac21cd8 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -17,6 +17,7 @@ import RenderTargetPool from "../rendertarget/render_target_pool.js"; import WebGLRenderTarget from "../rendertarget/webglrendertarget.js"; import { createAtlas, TextureAtlas } from "./../texture/atlas.js"; import TextureCache from "./../texture/cache.js"; +import { FrameTexture } from "./../texture/frametexture.js"; import { dashPath, dashSegments } from "../utils/dash.js"; import { generateJoinCircles, @@ -42,6 +43,7 @@ import { getMaxShaderPrecision } from "./utils/precision.js"; * @import {Matrix2d} from "../../math/matrix2d.ts"; * @import {Matrix3d} from "../../math/matrix3d.ts"; * @import {Batcher} from "./batchers/batcher.js"; + * @import {default as Texture2d} from "../texture/texture2d.ts"; */ // reusable constants for 2D→3D matrix operations @@ -464,6 +466,14 @@ export default class WebGLRenderer extends Renderer { this._lightAtlas = undefined; } + // the shared frame-capture texture (toFrameTexture) holds a GL texture + // tied to this context — drop it so the next capture reallocates. Safe + // on a still-valid context too (a resize/reset just re-creates it). + if (typeof this._frameTexture !== "undefined") { + this._frameTexture.destroy(); + this._frameTexture = undefined; + } + // Context-loss-only cleanup for the TMX GPU renderer: the cached // `GLShader` and per-layer GL textures reference the OLD context // and are invalid. On a regular `GAME_RESET` (context still @@ -802,6 +812,204 @@ export default class WebGLRenderer extends Renderer { return this._lightAtlas; } + /** + * Capture the current frame — everything drawn to the active framebuffer so + * far — into a {@link Texture2d}, entirely on the GPU (a `copyTexImage2D` on + * the first capture, then `copyTexSubImage2D` to refresh in place; no + * `readPixels` round-trip or pipeline stall). The fourth member of the + * {@link Renderer#toDataURL} / {@link Renderer#toBlob} / + * {@link Renderer#toImageBitmap} family — "the current frame as X" — and the + * only one whose result never leaves the GPU. + * + * The capture reflects the framebuffer **at the call site** (a `flush()` + * runs first), so call it right before drawing the surface that needs the + * backdrop — the Godot `BackBufferCopy` placement model. It reads whichever + * framebuffer is currently bound: the backbuffer normally, or a camera's + * post-effect FBO during that pass — so it works under both `Camera2d` and + * `Camera3d`. Under a perspective camera, "drawn so far" is draw order, not + * depth order: capture after the opaque scene, just before the refracting + * surface (same guidance as Unity `_CameraOpaqueTexture` / Godot screen + * texture). + * + * Feed the result straight into a custom post-effect: + * ```js + * effect.setTexture("uScene", renderer.toFrameTexture()); + * ``` + * {@link ShaderEffect#setTexture} binds the **live** handle, so re-capturing + * each frame into the shared slot refreshes what the shader samples without + * any re-bind. + * + * Color only, captured into an RGB texture (opaque — samples with alpha = 1); + * the framebuffer's depth is not captured. By default the result is a shared, + * renderer-owned texture valid until the next parameterless call — pass + * `options.target` for an independent, caller-owned capture (e.g. two + * captures alive in the same frame). + * @param {object} [options] + * @param {Texture2d|null} [options.target] - controls the destination: omit + * for the shared renderer slot (default); pass a capture previously + * returned by this method to refresh it in place; pass `null` to mint a + * fresh, caller-owned capture (for two independent captures in one frame — + * `destroy()` it yourself when done) + * @param {Bounds|{x: number, y: number, width: number, height: number}} [options.region] - capture + * only this sub-region (framebuffer pixels, bottom-left origin); a smaller + * region is a proportionally cheaper copy. Defaults to the whole framebuffer. + * A {@link Bounds} (or any `{x, y, width, height}`); from a {@link Rect} + * pass `rect.getBounds()`. + * @returns {Texture2d} a GPU-resident texture holding the captured frame + * @example + * // a water surface that refracts the scene rendered behind it + * draw(renderer) { + * const scene = renderer.toFrameTexture(); // grab the backdrop + * this.shader.setTexture("uScene", scene); // live-bound + * super.draw(renderer); // draw the rippling water + * } + */ + toFrameTexture(options = {}) { + const gl = this.gl; + const canvas = this.getCanvas(); + + // resolve the capture rect in framebuffer pixels (default: everything). + // a Bounds — or any object exposing numeric x/y/width/height (a Rect via + // `rect.getBounds()`). + let x = 0; + let y = 0; + let w = canvas.width; + let h = canvas.height; + const region = options.region; + if (typeof region !== "undefined") { + const rx = region.x; + const ry = region.y; + // clamp the ORIGIN into the framebuffer first, then size to what + // remains — an out-of-range x/y must not push x+w past the edge, or + // copyTex(Sub)Image2D throws INVALID_VALUE. Missing width/height + // defaults to "the rest of the framebuffer from x/y". + x = Math.min(Math.max(0, Math.floor(rx || 0)), canvas.width - 1); + y = Math.min(Math.max(0, Math.floor(ry || 0)), canvas.height - 1); + const rw = Number.isFinite(region.width) + ? Math.ceil(region.width) + : canvas.width - x; + const rh = Number.isFinite(region.height) + ? Math.ceil(region.height) + : canvas.height - y; + w = Math.max(1, Math.min(canvas.width - x, rw)); + h = Math.max(1, Math.min(canvas.height - y, rh)); + } + + // flush pending geometry so the capture holds everything drawn so far + this.flush(); + + // a non-null `target` must be a capture THIS renderer previously returned + // (a FrameTexture) — any other Texture2d has no `glTexture`, and a capture + // from a different renderer would delete textures on the wrong GL context + if (typeof options.target !== "undefined" && options.target !== null) { + if (!(options.target instanceof FrameTexture)) { + throw new Error( + "WebGLRenderer.toFrameTexture: `target` must be a capture returned by this method", + ); + } + if (options.target._renderer !== this) { + throw new Error( + "WebGLRenderer.toFrameTexture: `target` belongs to a different renderer", + ); + } + } + + // destination: + // - no `target` → the shared, renderer-owned slot (default; + // overwritten by the next parameterless call) + // - `target: null` → mint a fresh, caller-owned capture (for two + // independent captures alive in one frame) + // - `target: ` → refresh that caller-owned capture in place + const shared = typeof options.target === "undefined"; + let frame = shared + ? this._frameTexture + : options.target === null + ? undefined + : options.target; + + // (re)allocate when missing, resized, or the GL handle went stale (a + // context-loss/restore cycle deletes it — gl.isTexture catches that) + if ( + typeof frame === "undefined" || + frame.width !== w || + frame.height !== h || + frame.glTexture === null || + gl.isTexture(frame.glTexture) === false + ) { + if (typeof frame !== "undefined") { + frame.destroy(); + frame.width = w; + frame.height = h; + } else { + frame = new FrameTexture(this, w, h); + } + if (shared) { + this._frameTexture = frame; + } + } + + // Copy the bound framebuffer into the capture texture — the standard + // framebuffer→texture path (Three.js copyFramebufferToTexture uses it). + // An RGB internalformat is a valid subset of BOTH an alpha-less default + // framebuffer (melonJS creates the context with `alpha: transparent`, + // usually false) AND an RGBA camera FBO, so the copy never trips + // INVALID_OPERATION on a format mismatch, and the capture samples as an + // opaque backdrop (alpha = 1). Unlike a blitFramebuffer destination — + // which some drivers won't sample in the same frame — an RGB texture + // copied this way samples reliably everywhere. + const batcher = this.setBatcher("quad"); + const unit = batcher.maxBatchTextures - 1; + if (frame.glTexture === null) { + // first capture into this slot: copyTexImage2D allocates the RGB + // storage AND copies in one call + frame.glTexture = gl.createTexture(); + batcher.bindTexture2D(frame.glTexture, unit, false); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.copyTexImage2D(gl.TEXTURE_2D, 0, gl.RGB, x, y, w, h, 0); + } else { + // steady state: refresh the existing storage in place — no per-frame + // reallocation. Same size is guaranteed (a size change nulls + // glTexture above via the shared-slot realloc), so copyTexSubImage2D + // is valid and copies RGB from either an RGB or RGBA source. + batcher.bindTexture2D(frame.glTexture, unit, false); + gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, x, y, w, h); + } + + // the scratch bind clobbered GL `unit` OUTSIDE the shared texture-cache + // accounting (bound directly, not via allocateTextureUnit), so other + // batchers' unit caches don't know it changed — invalidate it on all of + // them (see invalidateTextureUnit). + this.invalidateTextureUnit(unit); + + return frame; + } + + /** + * Forget the cached binding for GL texture `unit` on every batcher (except + * `except`). Call this whenever a texture is bound to a unit **directly** — + * bypassing the shared texture cache and its `GPU_TEXTURE_CACHE_RESET` + * coordination — as {@link WebGLRenderer#toFrameTexture}'s capture copy and + * {@link ShaderEffect#_prepareTextures}'s reserved extra samplers both do. + * Those binds land on the TOP of the unit range, which overlaps the lit quad + * batcher's normal-map units; without invalidation a later lit draw would + * assume its normal map is still resident there and skip re-binding it, + * sampling the directly-bound texture as a normal map (wrong lighting). + * @param {number} unit - the GL texture unit that was clobbered + * @param {Batcher} [except] - a batcher to skip (the one that just bound the + * texture — its own cache is already accurate) + * @ignore + */ + invalidateTextureUnit(unit, except) { + for (const b of this.batchers.values()) { + if (b !== except) { + b.invalidateUnit?.(unit); + } + } + } + /** * Begin capturing rendering to an offscreen FBO for post-effect processing. * @param {Renderable} renderable - the renderable requesting post-effect processing diff --git a/packages/melonjs/tests/shader-canvas.spec.js b/packages/melonjs/tests/shader-canvas.spec.js index 6909f9fdb..da2c7d109 100644 --- a/packages/melonjs/tests/shader-canvas.spec.js +++ b/packages/melonjs/tests/shader-canvas.spec.js @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from "vitest"; -import { boot, loader, ShaderEffect, video } from "../src/index.js"; +import { boot, loader, ShaderEffect, Texture2d, video } from "../src/index.js"; /** * Shader assets under a CANVAS renderer (e.g. a video.AUTO fallback): @@ -26,11 +26,19 @@ describe("shader assets under the Canvas renderer", () => { expect(fx.enabled).toBe(false); // born disabled → filtered from post-effect passes expect(fx.shared).toBe(true); // still loader-owned - // every method no-ops without throwing + // every method no-ops without throwing — including a Texture2d asset + // passed directly: the Canvas stub has no `_shader`, so setTexture + // returns early before any unwrap (getTexture() is never called) + class CanvasTexture extends Texture2d { + getTexture() { + return document.createElement("canvas"); + } + } expect(() => { fx.setUniform("uIntensity", 1.0); fx.setTime(1.0); fx.setTexture("uNoise", document.createElement("canvas")); + fx.setTexture("uNoise2", new CanvasTexture()); }).not.toThrow(); // clone still works (the recipe is stored before the Canvas-mode diff --git a/packages/melonjs/tests/shadereffect-settexture.spec.js b/packages/melonjs/tests/shadereffect-settexture.spec.js index 17c1b527f..f9c84c185 100644 --- a/packages/melonjs/tests/shadereffect-settexture.spec.js +++ b/packages/melonjs/tests/shadereffect-settexture.spec.js @@ -1,5 +1,12 @@ import { beforeAll, describe, expect, it } from "vitest"; -import { boot, ShaderEffect, video, WebGLRenderer } from "../src/index.js"; +import { + boot, + NoiseTexture2d, + ShaderEffect, + Texture2d, + video, + WebGLRenderer, +} from "../src/index.js"; /** * `ShaderEffect.setTexture(name, image)` binds an extra `sampler2D` (a noise @@ -87,6 +94,59 @@ describe("ShaderEffect.setTexture (extra sampler binding)", () => { extra.destroy(); }); + it("accepts a Texture2d asset directly, without the .getTexture() dance", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const blue = solidCanvas(0, 0, 255); + + // a minimal Texture2d subclass wrapping a red canvas — the same + // contract every texture asset (TextureAtlas, NoiseTexture2d…) + // implements + class CanvasTexture extends Texture2d { + constructor(canvas) { + super(); + this._canvas = canvas; + } + getTexture() { + return this._canvas; + } + } + + const effect = new ShaderEffect( + renderer, + "uniform sampler2D uExtra;\nvec4 apply(vec4 color, vec2 uv) { return texture2D(uExtra, uv); }", + ); + // the asset object itself — not asset.getTexture() + effect.setTexture("uExtra", new CanvasTexture(solidCanvas(255, 0, 0))); + const sampled = drawCentrePixel(effect, blue); + expect(sampled[0]).toBeGreaterThan(200); // red — asset resolved + sampled + expect(sampled[2]).toBeLessThan(60); + effect.destroy(); + }); + + it("accepts a NoiseTexture2d asset directly (the documented idiom, minus .getTexture())", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const blue = solidCanvas(0, 0, 255); + const noise = new NoiseTexture2d({ width: SIZE, height: SIZE }); + const effect = new ShaderEffect( + renderer, + "uniform sampler2D uNoise;\nvec4 apply(vec4 color, vec2 uv) { return vec4(vec3(texture2D(uNoise, uv).r), 1.0); }", + ); + effect.setTexture("uNoise", noise); + const sampled = drawCentrePixel(effect, blue); + // noise output is greyscale: not the blue source, and alpha intact — + // proves the asset was resolved to its baked canvas and sampled + expect(sampled[0]).toBe(sampled[2]); // grey: r === b + expect(sampled[3]).toBe(255); + effect.destroy(); + noise.destroy(); + }); + it("uploads to a reserved unit, sets the sampler, caches, and frees on destroy", (ctx) => { if (!isWebGL) { ctx.skip(); @@ -160,4 +220,156 @@ describe("ShaderEffect.setTexture (extra sampler binding)", () => { fx.destroy(); expect(cache.reservedUnits.has(unit)).toBe(false); }); + + // #1543 review (Copilot): replacing a bound sampler must KEEP its reserved + // unit — releasing it (then re-reserving lazily) opens a window, unbounded + // while the effect is disabled, in which the allocator could hand that unit + // to a regular texture and reintroduce the collision reserveUnit() prevents. + it("keeps the reserved unit when a bound sampler is replaced while disabled", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const cache = renderer.cache; + cache.resetUnitAssignments(); + + const fx = new ShaderEffect( + renderer, + "uniform sampler2D uExtra;\nvec4 apply(vec4 color, vec2 uv) { return texture2D(uExtra, uv); }", + ); + fx.setTexture("uExtra", solidCanvas(255, 0, 0)); + + const batcher = renderer.setBatcher("quad"); + batcher.useShader(fx); + fx._prepareTextures(batcher); + + const unit = fx._extraTextures.get("uExtra").unit; + const firstTex = fx._extraTextures.get("uExtra").tex; + expect(unit).toBeTypeOf("number"); + expect(cache.reservedUnits.has(unit)).toBe(true); + + // disable, then swap the image for the same sampler name + fx.enabled = false; + fx.setTexture("uExtra", solidCanvas(0, 255, 0)); + + const replaced = fx._extraTextures.get("uExtra"); + // same reservation held across the replace (not dropped to undefined)… + expect(replaced.unit).toBe(unit); + expect(cache.reservedUnits.has(unit)).toBe(true); + // …old GL texture freed, new one pending a re-upload + expect(gl.isTexture(firstTex)).toBe(false); + expect(replaced.tex).toBe(null); + + // the allocator still refuses to hand that unit out while disabled + for (let u = 0; u < unit; u++) { + cache.allocateTextureUnit(); + } + expect(cache.allocateTextureUnit()).not.toBe(unit); + cache.resetUnitAssignments(); + + // re-enable → the new image uploads into the SAME unit + fx.enabled = true; + fx._prepareTextures(batcher); + expect(fx._extraTextures.get("uExtra").unit).toBe(unit); + expect(gl.isTexture(fx._extraTextures.get("uExtra").tex)).toBe(true); + + fx.destroy(); + expect(cache.reservedUnits.has(unit)).toBe(false); + }); +}); + +/** + * setUniform / setTexture must not silently drop values while the effect is + * disabled — whether by the user, or automatically during a context-loss + * window (where dropping defeats GLShader's suspend-cache replay: the value + * set mid-loss should survive the restore). Found by the 2026-07 GL-core + * audit; runs LAST in this file because losing the context disrupts GL state. + */ +describe("ShaderEffect value-setting while disabled (audit fix)", () => { + let renderer; + let gl; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(SIZE, SIZE, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + antiAlias: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + } + }); + + const tick = () => { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }; + + it("setUniform while USER-disabled applies once re-enabled", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const fx = new ShaderEffect( + renderer, + "uniform float uIntensity;\nvec4 apply(vec4 color, vec2 uv) { return color * uIntensity; }", + ); + fx.setUniform("uIntensity", 0.25); + fx.enabled = false; + fx.setUniform("uIntensity", 0.75); // must not be dropped + fx.enabled = true; + const loc = fx._shader.gl.getUniformLocation( + fx._shader.program, + "uIntensity", + ); + expect(fx._shader.gl.getUniform(fx._shader.program, loc)).toBeCloseTo( + 0.75, + 5, + ); + fx.destroy(); + }); + + it("setUniform + setTexture during a context-loss window survive the restore", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const ext = gl.getExtension("WEBGL_lose_context"); + if (!ext) { + ctx.skip(); + return; + } + const fx = new ShaderEffect( + renderer, + "uniform float uIntensity;\nuniform sampler2D uExtra;\nvec4 apply(vec4 color, vec2 uv) { return texture2D(uExtra, uv) * uIntensity; }", + ); + fx.setUniform("uIntensity", 0.25); + + ext.loseContext(); + await tick(); + expect(fx.enabled).toBe(false); // auto-disabled during the window + + // values set MID-LOSS must survive: setUniform defers to the + // GLShader suspend cache; setTexture stores its entry for the lazy + // _prepareTextures upload on the next enabled draw + fx.setUniform("uIntensity", 0.75); + fx.setTexture("uExtra", solidCanvas(255, 0, 0)); + + ext.restoreContext(); + await tick(); + await tick(); + expect(fx.enabled).toBe(true); + + const shader = fx._shader; + const loc = shader.gl.getUniformLocation(shader.program, "uIntensity"); + expect(shader.gl.getUniform(shader.program, loc)).toBeCloseTo(0.75, 5); + expect(fx._extraTextures.has("uExtra")).toBe(true); + fx.destroy(); + }); }); diff --git a/packages/melonjs/tests/toframetexture.spec.js b/packages/melonjs/tests/toframetexture.spec.js new file mode 100644 index 000000000..206844f1e --- /dev/null +++ b/packages/melonjs/tests/toframetexture.spec.js @@ -0,0 +1,727 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { + Bounds, + boot, + CanvasRenderer, + Rect, + ShaderEffect, + Texture2d, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * `renderer.toFrameTexture()` captures the current framebuffer into a + * {@link Texture2d} entirely on the GPU (copyTexImage2D to allocate, then + * copyTexSubImage2D to refresh — no readPixels round-trip), for screen-space + * effects (water refraction, heat haze, glass). The fourth member of the + * toDataURL / toBlob / toImageBitmap family; the only one that never leaves the + * GPU. Ticket #1544. + * + * Capture CONTENT is verified by attaching the returned texture to a scratch + * framebuffer and reading it back. The live-bind SAMPLING path (a shader + * reading the capture) is verified separately with a normally-uploaded texture: + * the headless software rasterizer used in CI does not reliably *sample* the + * RGB capture texture (it captures + reads back fine, and real GPUs sample it + * fine — see the Aquarium example), so the two concerns are checked + * independently. + */ +const SIZE = 32; + +describe("WebGLRenderer.toFrameTexture", () => { + let renderer; + let gl; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(SIZE, SIZE, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + antiAlias: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + } + }); + + // paint the whole scene a known color THROUGH the engine (drawImage of a + // solid canvas — the proven path the shadereffect specs use), then flush + const paintScene = (hex) => { + const c = document.createElement("canvas"); + c.width = SIZE; + c.height = SIZE; + const cx = c.getContext("2d"); + cx.fillStyle = hex; + cx.fillRect(0, 0, SIZE, SIZE); + renderer.save(); + renderer.drawImage(c, 0, 0, SIZE, SIZE, 0, 0, SIZE, SIZE); + renderer.flush(); + renderer.restore(); + }; + + // read a capture's content back directly: attach its GL texture to a scratch + // FBO (the RGB capture texture is colour-renderable) and readPixels — RGBA + // readback of an RGB texture returns alpha = 255 (opaque) + const readCapture = (frame, px = SIZE / 2, py = SIZE / 2) => { + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + frame.glTexture, + 0, + ); + const out = new Uint8Array(4); + if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) { + gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.deleteFramebuffer(fb); + return out; + }; + + it("returns a GPU-resident Texture2d sized to the framebuffer", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintScene("#ff0000"); + const frame = renderer.toFrameTexture(); + expect(frame).toBeInstanceOf(Texture2d); + expect(frame.isGPUResident).toBe(true); + expect(frame.width).toBe(SIZE); + expect(frame.height).toBe(SIZE); + expect(gl.isTexture(frame.glTexture)).toBe(true); + // getTexture() returns the opaque backing (itself) + expect(frame.getTexture()).toBe(frame); + }); + + it("captures the current framebuffer contents (GPU-side, opaque alpha)", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintScene("#ff0000"); // red + const frame = renderer.toFrameTexture(); + const px = readCapture(frame); + expect(px[0]).toBeGreaterThan(200); + expect(px[1]).toBeLessThan(60); + expect(px[2]).toBeLessThan(60); + expect(px[3]).toBe(255); // alpha-less source captured as opaque + }); + + it("reuses the shared slot (same object + handle), refreshed in place", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintScene("#00ff00"); + const a = renderer.toFrameTexture(); + const handleA = a.glTexture; + paintScene("#0000ff"); + const b = renderer.toFrameTexture(); + expect(b).toBe(a); // same shared texture object + expect(b.glTexture).toBe(handleA); // same GL handle, refreshed in place + // contents now reflect the SECOND capture (blue) + const px = readCapture(b); + expect(px[2]).toBeGreaterThan(200); + expect(px[0]).toBeLessThan(60); + }); + + it("mints (target:null) then refreshes an independent caller-owned capture", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintScene("#ff0000"); + const shared = renderer.toFrameTexture(); + // target: null → a fresh owned capture, distinct from the shared slot + paintScene("#00ff00"); + const owned = renderer.toFrameTexture({ target: null }); + expect(owned).not.toBe(shared); + expect(gl.isTexture(owned.glTexture)).toBe(true); + + // refreshing the SHARED slot must not disturb the owned one + paintScene("#0000ff"); + renderer.toFrameTexture(); + let px = readCapture(owned); + expect(px[1]).toBeGreaterThan(200); // still green + expect(px[2]).toBeLessThan(60); + + // refreshing the owned capture in place returns the SAME object + paintScene("#0000ff"); + const again = renderer.toFrameTexture({ target: owned }); + expect(again).toBe(owned); + px = readCapture(owned); + expect(px[2]).toBeGreaterThan(200); // now blue + + owned.destroy(); + }); + + // paint the framebuffer LEFT half red, RIGHT half blue at KNOWN pixel coords + // via scissored clears — bypasses the bare harness's centred projection (an + // engine drawImage lands offset), so region x maps 1:1 to framebuffer x + const paintHalves = () => { + renderer.flush(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, SIZE, SIZE); + gl.enable(gl.SCISSOR_TEST); + gl.scissor(0, 0, SIZE / 2, SIZE); + gl.clearColor(1, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.scissor(SIZE / 2, 0, SIZE / 2, SIZE); + gl.clearColor(0, 0, 1, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.SCISSOR_TEST); + }; + + it("captures the CORRECT sub-region offset (not just any painted pixels)", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintHalves(); + // a region fully in the LEFT half (x < SIZE/2) must read red; one in the + // RIGHT half, blue. If the x offset were ignored (always captured from 0), + // BOTH would read red — this distinguishes a correct offset from a full + // capture (and would also catch an x-mirror). + const left = renderer.toFrameTexture({ + target: null, + region: { x: 2, y: 2, width: 8, height: 8 }, + }); + const lpx = readCapture(left, 4, 4); + expect(lpx[0]).toBeGreaterThan(200); // red + expect(lpx[2]).toBeLessThan(60); + + const right = renderer.toFrameTexture({ + target: null, + region: { x: SIZE / 2 + 4, y: 2, width: 8, height: 8 }, + }); + const rpx = readCapture(right, 4, 4); + expect(rpx[2]).toBeGreaterThan(200); // blue + expect(rpx[0]).toBeLessThan(60); + + expect(left.width).toBe(8); + expect(right.width).toBe(8); + left.destroy(); + right.destroy(); + }); + + it("accepts a Bounds region", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintHalves(); + // a real Bounds over the LEFT half (x2 y2 w8 h8) + const bounds = new Bounds([ + { x: 2, y: 2 }, + { x: 10, y: 10 }, + ]); + const a = renderer.toFrameTexture({ target: null, region: bounds }); + expect(a.width).toBe(8); + expect(a.height).toBe(8); + expect(readCapture(a, 4, 4)[0]).toBeGreaterThan(200); // red (left) + a.destroy(); + }); + + // the documented migration path for a Rect region: pass rect.getBounds(). + // Adversarial: prove the Rect's ACTUAL position + size drive the capture — + // a right-half Rect must read blue, a left-half Rect red, each sized to the + // Rect (so a dropped offset or a wrong size can't pass). + it("captures the exact region a Rect describes, via rect.getBounds()", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintHalves(); // framebuffer: left half red, right half blue + + // sanity: getBounds() reflects the Rect's position + size (non-square, to + // catch a width/height swap) + const rightRect = new Rect(SIZE / 2 + 2, 4, 8, 6); + const rb = rightRect.getBounds(); + expect(rb.x).toBe(SIZE / 2 + 2); + expect(rb.y).toBe(4); + expect(rb.width).toBe(8); + expect(rb.height).toBe(6); + + // a Rect fully in the RIGHT half → blue, sized 8x6. If the x offset were + // dropped it would capture the left (red); a width/height swap → 6x8. + const right = renderer.toFrameTexture({ + target: null, + region: rightRect.getBounds(), + }); + expect(right.width).toBe(8); + expect(right.height).toBe(6); + const rpx = readCapture(right, 4, 3); + expect(rpx[2]).toBeGreaterThan(200); // blue + expect(rpx[0]).toBeLessThan(60); + right.destroy(); + + // the mirror case: a Rect fully in the LEFT half → red — proves the + // position (not a constant) selects what's captured + const left = renderer.toFrameTexture({ + target: null, + region: new Rect(2, 4, 8, 6).getBounds(), + }); + const lpx = readCapture(left, 4, 3); + expect(lpx[0]).toBeGreaterThan(200); // red + expect(lpx[2]).toBeLessThan(60); + left.destroy(); + }); + + it("reallocates when the backing handle goes stale (context-loss self-heal)", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintScene("#ff0000"); + const first = renderer.toFrameTexture(); + // simulate the post-context-loss state: the GL handle is dead + gl.deleteTexture(first.glTexture); + expect(gl.isTexture(first.glTexture)).toBe(false); + + paintScene("#00ff00"); + const healed = renderer.toFrameTexture(); + expect(healed).toBe(first); // same shared slot object… + expect(gl.isTexture(healed.glTexture)).toBe(true); // …with a fresh handle + const px = readCapture(healed); + expect(px[1]).toBeGreaterThan(200); // green + }); + + // the live-bind SAMPLING path: setTexture stores a live GPU-resident entry, + // and _prepareTextures binds its current GL handle each draw so a shader + // samples it. Verified with a normally-uploaded texture standing in as the + // capture's backing (the headless rasterizer can't sample blit textures). + it("setTexture live-binds a frame texture for a shader to sample", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + // upload a red texture the ordinary way, then reuse its GL handle + const redC = document.createElement("canvas"); + redC.width = SIZE; + redC.height = SIZE; + const rcx = redC.getContext("2d"); + rcx.fillStyle = "#ff0000"; + rcx.fillRect(0, 0, SIZE, SIZE); + const uploader = new ShaderEffect( + renderer, + "uniform sampler2D uX;\nvec4 apply(vec4 c, vec2 uv) { return texture2D(uX, uv); }", + ); + uploader.setTexture("uX", redC); + const blank = document.createElement("canvas"); + blank.width = SIZE; + blank.height = SIZE; + const drawEffect = (effect) => { + paintScene("#101010"); // sentinel so a failed sample is visible + renderer.save(); + renderer.customShader = effect; + renderer.drawImage(blank, 0, 0, SIZE, SIZE, 0, 0, SIZE, SIZE); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + const px = new Uint8Array(4); + gl.readPixels(SIZE / 2, SIZE / 2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + drawEffect(uploader); // triggers the createTexture2D upload + const redHandle = uploader._extraTextures.get("uX").tex; + + // stand a real FrameTexture up on that known-sampleable handle + const frame = renderer.toFrameTexture(); + frame.glTexture = redHandle; + + const effect = new ShaderEffect( + renderer, + "uniform sampler2D uScene;\nvec4 apply(vec4 c, vec2 uv) { return texture2D(uScene, uv); }", + ); + effect.setTexture("uScene", frame); + // the setTexture entry is a LIVE GPU-resident binding, not a static copy + const entry = effect._extraTextures.get("uScene"); + expect(entry.live).toBe(true); + expect(entry.tex).toBe(null); // never uploads its own copy + + const px = drawEffect(effect); + expect(px[0]).toBeGreaterThan(200); // red — sampled the live handle + expect(px[2]).toBeLessThan(60); + + effect.destroy(); + uploader.destroy(); + }); + + // toFrameTexture binds its capture to a scratch unit (the top of the quad + // batcher's range) directly, bypassing the shared texture-cache accounting. + // The LIT quad batcher parks normal maps in the top half of the unit range, + // so that scratch unit overlaps a normal-map slot — without invalidating it + // across batchers, a later lit draw would assume the normal is still resident + // and skip re-binding it, sampling the just-captured frame AS a normal map. + it("invalidates the scratch unit across all batchers so lighting survives a capture", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const quad = renderer.batchers.get("quad"); + const lit = renderer.batchers.get("litQuad"); + if (!quad || !lit) { + ctx.skip(); + return; + } + + // the exact unit toFrameTexture uses, and its paired lit normal-map slot + const scratch = quad.maxBatchTextures - 1; + const normalIndex = scratch - lit.maxBatchTextures; + const normalInRange = + normalIndex >= 0 && normalIndex < lit.maxBatchTextures; + + // seed the caches as a prior lit draw would: a colour texture the quad + // batcher thinks is at the scratch unit, and a normal map the lit batcher + // thinks is paired to it + quad.boundTextures[scratch] = { fake: "color" }; + if (normalInRange) { + lit.boundNormalMaps[normalIndex] = { fake: "normal" }; + lit.boundNormalVersions[normalIndex] = 7; + } + + paintScene("#ff0000"); + const frame = renderer.toFrameTexture(); + + // the stale color view of the scratch unit is cleared… + expect(quad.boundTextures[scratch]).toBeUndefined(); + // …and (the crux) the lit batcher forgot its normal pairing for that unit, + // so the next lit draw re-binds the real normal instead of the capture + if (normalInRange) { + expect(lit.boundNormalMaps[normalIndex]).toBe(null); + expect(lit.boundNormalVersions[normalIndex]).toBe(-1); + } + // and the capture itself is intact — both features coexist + expect(gl.isTexture(frame.glTexture)).toBe(true); + }); + + // adversarial: invalidateUnit must ONLY touch the normal slot for GL units in + // the normal range (top half), never for a low colour/albedo unit + it("invalidateUnit only drops the normal pairing for units in the normal range", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const lit = renderer.batchers.get("litQuad"); + if (!lit) { + ctx.skip(); + return; + } + // a LOW unit (0) is a colour/albedo unit — its clobber must NOT drop the + // normal at index 0 (which lives at GL unit maxBatchTextures + 0) + lit.boundTextures[0] = { fake: true }; + lit.boundNormalMaps[0] = { keep: true }; + lit.invalidateUnit(0); + expect(lit.boundTextures[0]).toBeUndefined(); // colour cleared + expect(lit.boundNormalMaps[0]).toEqual({ keep: true }); // normal untouched + + // the GL unit that DOES pair to normal index 0 must drop it + lit.invalidateUnit(lit.maxBatchTextures + 0); + expect(lit.boundNormalMaps[0]).toBe(null); + }); + + // the latent gap this surfaced: a full texture-cache reset (unit-pool wrap) + // must clear the lit normal-map cache too, not just colours + it("a texture-cache reset clears the lit normal-map cache, not just colors", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const lit = renderer.batchers.get("litQuad"); + if (!lit) { + ctx.skip(); + return; + } + lit.boundTextures[0] = { fake: "color" }; + lit.boundNormalMaps[0] = { fake: "normal" }; + lit.boundNormalVersions[0] = 9; + + renderer.cache.resetUnitAssignments(); // emits GPU_TEXTURE_CACHE_RESET + + expect(lit.boundTextures.length).toBe(0); // colours cleared (base handler) + expect(lit.boundNormalMaps[0]).toBe(null); // normals cleared (the gap fix) + expect(lit.boundNormalVersions[0]).toBe(-1); + }); + + // invalidateTextureUnit is the shared primitive: it must clear the unit on + // EVERY batcher except the one that just bound it. GPU-independent. + it("invalidateTextureUnit clears a unit on all batchers except the excluded one", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const quad = renderer.batchers.get("quad"); + const lit = renderer.batchers.get("litQuad"); + if (!quad || !lit) { + ctx.skip(); + return; + } + const glUnit = lit.maxBatchTextures + 1; // a real lit normal-map GL unit + lit.boundNormalMaps[1] = { fake: "normal" }; + quad.boundTextures[glUnit] = { fake: "color" }; + + // exclude the quad batcher → its binding is kept, the lit one is dropped + renderer.invalidateTextureUnit(glUnit, quad); + expect(lit.boundNormalMaps[1]).toBe(null); // lit invalidated + expect(quad.boundTextures[glUnit]).toEqual({ fake: "color" }); // quad kept + }); + + // the crux of the completeness fix: an effect with MULTIPLE extra samplers + // binds them to several top units (counting down) — ALL of which overlap lit + // normal-map units — so _prepareTextures must invalidate EVERY one, not just + // the scratch. Verified GPU-independently by spying on the shared primitive. + it("_prepareTextures invalidates every reserved sampler unit an effect binds", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const quad = renderer.batchers.get("quad"); + if (!quad) { + ctx.skip(); + return; + } + const mk = (col) => { + const c = document.createElement("canvas"); + c.width = SIZE; + c.height = SIZE; + const x = c.getContext("2d"); + x.fillStyle = col; + x.fillRect(0, 0, SIZE, SIZE); + return c; + }; + // TWO extra samplers → bound to units (maxBatchTextures-1) and (-2) + const effect = new ShaderEffect( + renderer, + "uniform sampler2D uA;\nuniform sampler2D uB;\nvec4 apply(vec4 c, vec2 uv) { return texture2D(uA, uv) + texture2D(uB, uv); }", + ); + effect.setTexture("uA", mk("#ff0000")); + effect.setTexture("uB", mk("#00ff00")); + + const spy = vi.spyOn(renderer, "invalidateTextureUnit"); + renderer.save(); + renderer.customShader = effect; + renderer.drawImage(mk("#000000"), 0, 0, SIZE, SIZE, 0, 0, SIZE, SIZE); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + + const u1 = quad.maxBatchTextures - 1; + const u2 = quad.maxBatchTextures - 2; + const calls = spy.mock.calls; + const units = calls.map((c) => { + return c[0]; + }); + // BOTH sampler units invalidated (the second one is the bug the review + // caught — the pre-fix code only invalidated the scratch unit) + expect(units).toContain(u1); + expect(units).toContain(u2); + // each excludes the drawing (quad) batcher, whose cache is already correct + const callForU2 = calls.find((c) => { + return c[0] === u2; + }); + expect(callForU2?.[1]).toBe(quad); + + spy.mockRestore(); + effect.destroy(); + }); + + it("rejects a target that is not a capture from this renderer", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + // not a FrameTexture at all + expect(() => { + renderer.toFrameTexture({ target: {} }); + }).toThrow(); + // a Texture2d but not a capture + class NotACapture extends Texture2d { + getTexture() { + return document.createElement("canvas"); + } + } + expect(() => { + renderer.toFrameTexture({ target: new NotACapture() }); + }).toThrow(/capture returned by this method/); + }); + + it("clamps an out-of-bounds region instead of erroring", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + paintScene("#ff0000"); + // x/y past the framebuffer + a size that would overflow — must not throw + // (INVALID_VALUE) and must still yield a valid capture + const frame = renderer.toFrameTexture({ + region: { x: SIZE + 100, y: SIZE + 100, width: SIZE, height: SIZE }, + }); + expect(gl.isTexture(frame.glTexture)).toBe(true); + expect(frame.width).toBeGreaterThanOrEqual(1); + expect(frame.width).toBeLessThanOrEqual(SIZE); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("rejects a capture that belongs to a DIFFERENT renderer", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + // a real capture from THIS renderer, re-owned by a fake renderer — the + // instanceof check passes but the ownership check must reject it (else + // frame.destroy() would delete a texture on the wrong GL context) + const owned = renderer.toFrameTexture({ target: null }); + owned._renderer = { gl: renderer.gl }; // a different object, not `renderer` + expect(() => { + renderer.toFrameTexture({ target: owned }); + }).toThrow(/different renderer/); + owned._renderer = renderer; // restore so destroy hits the right context + owned.destroy(); + }); + + it("treats a GPU-resident Texture2d WITHOUT a glTexture as a static asset", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + // a Texture2d that CLAIMS GPU-residency but exposes no glTexture handle: + // the live path reads .glTexture, so taking it would silently never bind. + // It must fall back to the static getTexture() unwrap instead. + const backing = document.createElement("canvas"); + backing.width = SIZE; + backing.height = SIZE; + class FakeGPUResident extends Texture2d { + constructor() { + super(); + this.isGPUResident = true; // no glTexture field + } + getTexture() { + return backing; + } + } + const effect = new ShaderEffect( + renderer, + "uniform sampler2D uX;\nvec4 apply(vec4 c, vec2 uv) { return texture2D(uX, uv); }", + ); + effect.setTexture("uX", new FakeGPUResident()); + const entry = effect._extraTextures.get("uX"); + expect(entry.live).toBe(false); // NOT taken as a live source + expect(entry.image).toBe(backing); // unwrapped to its drawable instead + effect.destroy(); + }); + + it("invalidateUnit is a no-op (no throw) for an out-of-range unit", (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + const lit = renderer.batchers.get("litQuad"); + if (!lit) { + ctx.skip(); + return; + } + // a unit far past the array bounds must neither throw nor corrupt state + expect(() => { + lit.invalidateUnit(9999); + renderer.invalidateTextureUnit(9999); + }).not.toThrow(); + expect(lit.boundNormalMaps.length).toBe(lit.maxBatchTextures); + }); +}); + +describe("CanvasRenderer.toFrameTexture", () => { + let renderer; + + beforeAll(() => { + boot(); + video.init(SIZE, SIZE, { parent: "screen", renderer: video.CANVAS }); + renderer = video.renderer; + expect(renderer).toBeInstanceOf(CanvasRenderer); + }); + + it("returns a Texture2d backed by a canvas copy of the frame", () => { + const ctx2d = renderer.getContext(); + ctx2d.setTransform(1, 0, 0, 1, 0, 0); + ctx2d.fillStyle = "#ff0000"; + ctx2d.fillRect(0, 0, SIZE, SIZE); + + const frame = renderer.toFrameTexture(); + expect(frame).toBeInstanceOf(Texture2d); + expect(frame.width).toBe(SIZE); + expect(frame.height).toBe(SIZE); + const backing = frame.getTexture(); + // a drawable canvas copy (not GPU-resident) + const bctx = backing.getContext("2d"); + const px = bctx.getImageData(SIZE / 2, SIZE / 2, 1, 1).data; + expect(px[0]).toBeGreaterThan(200); + expect(px[1]).toBeLessThan(60); + }); + + it("reuses the shared canvas slot across calls", () => { + const a = renderer.toFrameTexture(); + const b = renderer.toFrameTexture(); + expect(b).toBe(a); + }); + + it("rejects a target that is not a canvas capture", () => { + expect(() => { + renderer.toFrameTexture({ target: {} }); + }).toThrow(/capture returned by this method/); + }); + + it("captures the correct canvas sub-region and clamps out-of-bounds", () => { + const ctx2d = renderer.getContext(); + ctx2d.setTransform(1, 0, 0, 1, 0, 0); + ctx2d.fillStyle = "#ff0000"; // left half red + ctx2d.fillRect(0, 0, SIZE / 2, SIZE); + ctx2d.fillStyle = "#0000ff"; // right half blue + ctx2d.fillRect(SIZE / 2, 0, SIZE / 2, SIZE); + + // a RIGHT region must read blue — proves the x offset is honored + const right = renderer.toFrameTexture({ + target: null, + region: { x: SIZE - 8, y: 4, width: 6, height: 6 }, + }); + const px = right + .getTexture() + .getContext("2d") + .getImageData(3, 3, 1, 1).data; + expect(px[2]).toBeGreaterThan(200); // blue + expect(px[0]).toBeLessThan(60); + + // an out-of-bounds origin must clamp to a valid capture, not throw + expect(() => { + const oob = renderer.toFrameTexture({ + target: null, + region: { x: 9999, y: 9999, width: 50, height: 50 }, + }); + expect(oob.width).toBeGreaterThanOrEqual(1); + expect(oob.width).toBeLessThanOrEqual(SIZE); + }).not.toThrow(); + }); + + it("refreshes a canvas target IN PLACE on a size change (same object)", () => { + const a = renderer.toFrameTexture({ + target: null, + region: { x: 0, y: 0, width: 8, height: 8 }, + }); + expect(a.width).toBe(8); + // re-capture a DIFFERENT size into the same target — must keep the object + // identity (matches the WebGL variant), not return a new instance + const b = renderer.toFrameTexture({ + target: a, + region: { x: 0, y: 0, width: 16, height: 16 }, + }); + expect(b).toBe(a); + expect(a.width).toBe(16); + expect(a.canvas.width).toBe(16); + }); +});