Skip to content

Commit ab309b7

Browse files
obiotclaude
andcommitted
feat(texture): generalize the Texture2d contract + setTexture accepts assets directly
Two pieces of 19.9 polish that must land before the release freezes the new texture surface — groundwork for the 19.10 renderer.toFrameTexture() screen-capture feature designed with the maintainer: - Texture2d contract generalized while still unreleased: the class was documented as "owns a drawable image source" with getTexture(): HTMLCanvasElement | HTMLImageElement, but its own docs already anticipated GPU-backed sources (the WebGPU note). The contract now deliberately admits both: a new exported Texture2dSource type (drawables | TextureResource), so GPU-resident subclasses — frame captures, future WebGPU surfaces — fit without a 19.10 compat debate. TextureResource itself stays internal (type-only reference). - ShaderEffect.setTexture accepts a Texture2d asset directly and resolves it via getTexture() internally — retiring the documented `noise.getTexture()` dance: setTexture("uNoise", noise) now works. Raw drawables unchanged. JSDoc + class example updated. Red-first tests: a minimal Texture2d subclass and a NoiseTexture2d passed directly to setTexture, sampled end-to-end via readPixels — both red pre-change (the raw asset object reached createTexture2D). Full suite 4741 passed / 0 failed; tsc build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
1 parent b4ee713 commit ab309b7

4 files changed

Lines changed: 112 additions & 25 deletions

File tree

packages/melonjs/CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
### Added
66
- **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.
77
- **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.
8-
- **`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.
8+
- **`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. The contract deliberately admits both CPU-backed sources (a drawable canvas/image) and **GPU-resident** ones (a renderer texture resource that never leaves the GPU) — the shape a future WebGPU backend and screen-capture textures follow.
99
- **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.
1010
- **`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.
1111
- **`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).
12-
- **`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 <name>;` 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`).
12+
- **`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 <name>;` 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 new **Water Refraction** example (a perspective pool floor refracted through a GPU-scrolled seamless `NoiseTexture2d`).
1313
- **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).
1414
- **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.
1515

packages/melonjs/src/video/texture/texture2d.ts

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,52 @@
1+
import type { TextureResource } from "./resource.js";
2+
3+
/**
4+
* The backing source a {@link Texture2d} resolves to through
5+
* {@link Texture2d#getTexture}: a drawable (canvas/image) for CPU-backed
6+
* assets, or a renderer texture resource for GPU-resident ones — a resource
7+
* uploads/binds itself through the texture cache instead of being read back
8+
* to the CPU.
9+
*/
10+
export type Texture2dSource =
11+
| HTMLCanvasElement
12+
| HTMLImageElement
13+
| OffscreenCanvas
14+
| ImageBitmap
15+
| TextureResource;
16+
117
/**
2-
* Abstract base for a user-constructed 2D texture asset — an object that owns
3-
* a drawable image source and can be used anywhere the engine expects an
4-
* image: {@link Sprite#image}, {@link Sprite#normalMap}, an {@link ImageLayer},
5-
* or bound as a sampler uniform in a custom {@link GLShader}.
18+
* Abstract base for a 2D texture asset — an object that owns a texture
19+
* source and can be used anywhere the engine expects an image:
20+
* {@link Sprite#image}, {@link Sprite#normalMap}, an {@link ImageLayer}, or
21+
* bound as a sampler uniform in a custom shader (see
22+
* {@link ShaderEffect#setTexture}).
23+
*
24+
* A `Texture2d` is recognized via `instanceof` and resolved to its backing
25+
* source through {@link Texture2d#getTexture} — so passing the asset object
26+
* directly (`{ image: myTexture }`) works the same as passing a raw
27+
* `HTMLCanvasElement`. Raw DOM image/canvas sources and the loader's decoded
28+
* `CompressedImage` data are accepted too, but are not part of this class
29+
* hierarchy.
630
*
7-
* A `Texture2d` is recognized by the renderables via `instanceof` and resolved
8-
* to its backing canvas/image through {@link Texture2d#getTexture} — so passing
9-
* the asset object directly (`{ image: myTexture }`) works the same as passing a
10-
* raw `HTMLCanvasElement`. Raw DOM image/canvas sources and the loader's
11-
* decoded `CompressedImage` data are accepted too, but are not part of this
12-
* class hierarchy.
31+
* Most assets are CPU-backed and resolve to a drawable canvas/image.
32+
* Subclasses may also be **GPU-resident**, resolving to a renderer texture
33+
* resource that never leaves the GPU — the contract deliberately admits both
34+
* (a future WebGPU backend follows the same shape with a `GPUTexture`-backed
35+
* resource).
1336
*
1437
* Concrete implementations:
1538
* - {@link TextureAtlas} — packed multi-region sprite sheet
16-
*
17-
* A future WebGPU implementation would back {@link Texture2d#getTexture} with a
18-
* `GPUTexture`-wrapping surface rather than an `HTMLCanvasElement`.
1939
* @category Game Objects
2040
*/
2141
export default abstract class Texture2d {
2242
/**
23-
* Return the drawable source for this texture — assignable to
24-
* {@link Sprite#image}, {@link Sprite#normalMap}, an {@link ImageLayer}, or
25-
* bound as a sampler uniform in a custom shader.
26-
* @returns the backing canvas/image
43+
* Return the backing source for this texture — a drawable canvas/image
44+
* for CPU-backed assets, or a renderer texture resource for GPU-resident
45+
* ones. Assignable to {@link Sprite#image}, {@link Sprite#normalMap}, an
46+
* {@link ImageLayer}, or bound as a sampler uniform in a custom shader.
47+
* @returns the backing source
2748
*/
28-
abstract getTexture(): HTMLCanvasElement | HTMLImageElement;
49+
abstract getTexture(): Texture2dSource;
2950

3051
/**
3152
* Release any GPU/CPU resources held by this texture. The texture must not

packages/melonjs/src/video/webgl/shadereffect.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
off,
55
on,
66
} from "../../system/event.ts";
7+
import Texture2d from "../texture/texture2d.ts";
78
import GLShader from "./glshader.js";
89
import quadVertex from "./shaders/quad.vert";
910

@@ -223,10 +224,11 @@ export default class ShaderEffect {
223224
* texture-unit juggling.
224225
*
225226
* Declare the sampler in your fragment (`uniform sampler2D <name>;`) and pass
226-
* that name here. Any engine texture works — e.g. `noiseTexture.getTexture()`.
227-
* No-op in Canvas mode.
227+
* that name here. Any engine texture works — a {@link Texture2d} asset
228+
* (`NoiseTexture2d`, `TextureAtlas`, …) can be passed directly, or a raw
229+
* drawable source. No-op in Canvas mode.
228230
* @param {string} name - the `sampler2D` uniform name declared in the fragment
229-
* @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - the texture source
231+
* @param {Texture2d|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - the texture: an engine texture asset, or a raw drawable source
230232
* @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)
231233
* @returns {ShaderEffect} this effect for chaining
232234
* @example
@@ -239,12 +241,16 @@ export default class ShaderEffect {
239241
* vec2 flow = texture2D(uNoise, uv + uTime * 0.03).rg - 0.5;
240242
* return texture2D(uSampler, uv + flow * 0.02);
241243
* }`);
242-
* water.setTexture("uNoise", noise.getTexture(), "repeat");
244+
* water.setTexture("uNoise", noise, "repeat");
243245
* waterSprite.shader = water;
244246
* // each frame, in your Stage's update(dt):
245247
* water.setTime(me.timer.getTime() / 1000);
246248
*/
247249
setTexture(name, image, repeat = "no-repeat") {
250+
// accept a Texture2d asset directly — resolve it to its backing source
251+
if (image instanceof Texture2d) {
252+
image = image.getTexture();
253+
}
248254
if (this.enabled) {
249255
const existing = this._extraTextures.get(name);
250256
if (existing) {

packages/melonjs/tests/shadereffect-settexture.spec.js

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { beforeAll, describe, expect, it } from "vitest";
2-
import { boot, ShaderEffect, video, WebGLRenderer } from "../src/index.js";
2+
import {
3+
boot,
4+
NoiseTexture2d,
5+
ShaderEffect,
6+
Texture2d,
7+
video,
8+
WebGLRenderer,
9+
} from "../src/index.js";
310

411
/**
512
* `ShaderEffect.setTexture(name, image)` binds an extra `sampler2D` (a noise
@@ -87,6 +94,59 @@ describe("ShaderEffect.setTexture (extra sampler binding)", () => {
8794
extra.destroy();
8895
});
8996

97+
it("accepts a Texture2d asset directly, without the .getTexture() dance", (ctx) => {
98+
if (!isWebGL) {
99+
ctx.skip();
100+
return;
101+
}
102+
const blue = solidCanvas(0, 0, 255);
103+
104+
// a minimal Texture2d subclass wrapping a red canvas — the same
105+
// contract every texture asset (TextureAtlas, NoiseTexture2d…)
106+
// implements
107+
class CanvasTexture extends Texture2d {
108+
constructor(canvas) {
109+
super();
110+
this._canvas = canvas;
111+
}
112+
getTexture() {
113+
return this._canvas;
114+
}
115+
}
116+
117+
const effect = new ShaderEffect(
118+
renderer,
119+
"uniform sampler2D uExtra;\nvec4 apply(vec4 color, vec2 uv) { return texture2D(uExtra, uv); }",
120+
);
121+
// the asset object itself — not asset.getTexture()
122+
effect.setTexture("uExtra", new CanvasTexture(solidCanvas(255, 0, 0)));
123+
const sampled = drawCentrePixel(effect, blue);
124+
expect(sampled[0]).toBeGreaterThan(200); // red — asset resolved + sampled
125+
expect(sampled[2]).toBeLessThan(60);
126+
effect.destroy();
127+
});
128+
129+
it("accepts a NoiseTexture2d asset directly (the documented idiom, minus .getTexture())", (ctx) => {
130+
if (!isWebGL) {
131+
ctx.skip();
132+
return;
133+
}
134+
const blue = solidCanvas(0, 0, 255);
135+
const noise = new NoiseTexture2d({ width: SIZE, height: SIZE });
136+
const effect = new ShaderEffect(
137+
renderer,
138+
"uniform sampler2D uNoise;\nvec4 apply(vec4 color, vec2 uv) { return vec4(vec3(texture2D(uNoise, uv).r), 1.0); }",
139+
);
140+
effect.setTexture("uNoise", noise);
141+
const sampled = drawCentrePixel(effect, blue);
142+
// noise output is greyscale: not the blue source, and alpha intact —
143+
// proves the asset was resolved to its baked canvas and sampled
144+
expect(sampled[0]).toBe(sampled[2]); // grey: r === b
145+
expect(sampled[3]).toBe(255);
146+
effect.destroy();
147+
noise.destroy();
148+
});
149+
90150
it("uploads to a reserved unit, sets the sampler, caches, and frees on destroy", (ctx) => {
91151
if (!isWebGL) {
92152
ctx.skip();

0 commit comments

Comments
 (0)