Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab309b7
feat(texture): generalize the Texture2d contract + setTexture accepts…
obiot Jul 8, 2026
472caec
test: Canvas-stub coverage for setTexture(Texture2d)
obiot Jul 8, 2026
9923a33
fix(shader): setUniform/setTexture no longer drop values while disabled
obiot Jul 8, 2026
0baac96
fix(shader): keep the destroy() no-op guard on the ungated setters
obiot Jul 8, 2026
912cd3b
refactor(texture): address PR #1543 review — opaque type, unit reuse,…
obiot Jul 8, 2026
7c764c6
feat(renderer): toFrameTexture() — GPU frame capture as a Texture2d (…
obiot Jul 8, 2026
cc00415
feat(examples): Aquarium — toFrameTexture screen-space refraction; fi…
obiot Jul 8, 2026
79af16b
feat(examples): port the contributor's aquarium to toFrameTexture (re…
obiot Jul 8, 2026
238b00d
refactor(toFrameTexture): address PR #1543 review round 2
obiot Jul 8, 2026
ce66be3
refactor(toFrameTexture): address PR #1543 review round 3
obiot Jul 8, 2026
c3b71c1
fix(examples): smooth fish movement in aquarium (subPixel rendering)
obiot Jul 8, 2026
36d70e5
fix(examples): soften the default water ripple strength in aquarium
obiot Jul 8, 2026
0f190bd
refactor(renderer): declare toFrameTexture on the base Renderer
obiot Jul 8, 2026
4e9a2c2
fix(webgl): toFrameTexture must not corrupt lit-batcher normal-map units
obiot Jul 9, 2026
01da107
fix(webgl): invalidate ALL directly-bound top units, not just the cap…
obiot Jul 9, 2026
d8ff819
refactor(toFrameTexture): PR review — target/region validation, docs,…
obiot Jul 9, 2026
abecc20
test(toFrameTexture): harden the added tests to be genuinely adversarial
obiot Jul 9, 2026
e6574b5
refactor(toFrameTexture): region accepts a Bounds, not a Rect
obiot Jul 9, 2026
e6838f7
test(toFrameTexture): dedicated adversarial rect.getBounds() region test
obiot Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/examples/LICENSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
324 changes: 324 additions & 0 deletions packages/examples/src/examples/aquarium/ExampleAquarium.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
/**
* 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<Sprite["draw"]>[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;

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",
Comment on lines +200 to +204
seed: 11,
frequency: 0.035,
octaves: 4,
gain: 0.5,
domainWarp: true,
domainWarpAmp: 8,
seamless: true,
});

// 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" });
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);
Loading
Loading