From 0b861080a9abd88016642757fe0ca3bdbef10207 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 29 Oct 2026 21:47:54 +0800 Subject: [PATCH] Effects: a chain measures, places and clips as one effect does A renderable drawn through two or more effects (or one, on a UIPanel, ProgressBar or Trail, which set postEffectNeedsCapture) is drawn into a capture the size of the canvas, and its effects run over that. Four things went wrong there: - Widths, offsets and sizes (glow, outline, blur, drop shadow, chromatic aberration, pixelate, bloom) were measured in the capture's pixels, the screen's, where one effect measures in the sprite's own: at 0.13 scale, adding a second effect made an outline about 8 times thicker. Each pass is now handed the size of the renderable's pixels on the target (Renderer#_effectScaleOf), and measures in those. - Under a camera's zoom (or one drawn away from the screen's corner), the passes placed patterns (vignette, gradient, scanline curve, noise_uv) by the renderable's transform alone, though a pass runs in the target's pixels, which the projection puts it on: they were misplaced or missing. The chain's space now maps through the projection too (Renderer#_drawnTransform), as does the pixel size. - The capture drops the clip it is drawn under, and the composite drew before the clip was back, so a minimap's sprites with effects spilled over the main view, and out of a clipping container. The composite now draws inside the clip the pass began under (_resumeSavedClip). - WebGPU read a capture through a fixed linear sampler, WebGL through whatever sampler the last draw left on the unit. Both now read it at the game's texture filter, as a sprite reads its own texture, so a pixel-art game stays crisp on WebGPU too. Camera effects keep measuring in screen pixels, and an effect given a textureSize keeps it. The sprite example's two chained looks are re-tuned for the new units and look as they did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- .../src/examples/sprite/ExampleSprite.tsx | 6 +- packages/melonjs/CHANGELOG.md | 3 + .../melonjs-effects-and-shaders/SKILL.md | 17 +- packages/melonjs/src/video/effects/bloom.js | 5 +- packages/melonjs/src/video/effects/blur.js | 2 +- .../src/video/effects/chromaticAberration.js | 2 +- .../melonjs/src/video/effects/dropShadow.js | 2 +- packages/melonjs/src/video/effects/glow.js | 2 +- .../melonjs/src/video/effects/local_space.js | 5 +- packages/melonjs/src/video/effects/outline.js | 2 +- .../melonjs/src/video/effects/pixelate.js | 2 +- .../melonjs/src/video/effects/shadereffect.js | 12 +- packages/melonjs/src/video/renderer.js | 178 +++++++++++-- .../video/rendertarget/webgpurendertarget.js | 22 +- .../video/webgl/batchers/lit_quad_batcher.js | 9 +- .../src/video/webgl/batchers/quad_batcher.js | 9 +- .../melonjs/src/video/webgl/webgl_renderer.js | 35 +++ .../src/video/webgpu/batchers/quad_batcher.js | 9 +- .../src/video/webgpu/webgpu_renderer.js | 22 ++ .../melonjs/tests/effect-chain-clip.spec.js | 151 +++++++++++ .../melonjs/tests/effect-local-space.spec.js | 122 ++++++++- .../melonjs/tests/effect-texture-size.spec.js | 250 ++++++++++++++++++ .../tests/webgpu_post_effect_flow.spec.js | 53 ++++ .../tests/webgpu_render_target.spec.js | 23 +- 24 files changed, 883 insertions(+), 60 deletions(-) create mode 100644 packages/melonjs/tests/effect-chain-clip.spec.js diff --git a/packages/examples/src/examples/sprite/ExampleSprite.tsx b/packages/examples/src/examples/sprite/ExampleSprite.tsx index 2b3d5ac9a..d7fcc62fb 100644 --- a/packages/examples/src/examples/sprite/ExampleSprite.tsx +++ b/packages/examples/src/examples/sprite/ExampleSprite.tsx @@ -97,12 +97,12 @@ class Showcase extends Stage { // two effects: drawn apart and composited, at the opacity it // fades to like any other sprite minion.addPostEffect( - new OutlineEffect(app.renderer, { color: [1, 0.9, 0.85], width: 2 }), + new OutlineEffect(app.renderer, { color: [1, 0.9, 0.85], width: 15 }), ); minion.addPostEffect( new GlowEffect(app.renderer, { color: [1.0, 0.5, 0.4], - width: 8, + width: 60, intensity: 1.5, }), ); @@ -162,7 +162,7 @@ class Showcase extends Stage { width: 9, intensity: 3, }); - const outline = new OutlineEffect(r, { color: [1, 1, 1], width: 5 }); + const outline = new OutlineEffect(r, { color: [1, 1, 1], width: 9 }); const shine = new ShineEffect(r, { color: [1.0, 0.95, 0.75], speed: 0.6, diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index a92ba62f7..80c0251e7 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -22,6 +22,9 @@ - State: a `state.change()` asked for while a transition covers the screen is made by that transition, to the last state asked for and with its arguments, as without a transition ([#1787](https://github.com/melonjs/melonJS/issues/1787)). It put up a second cover, and the first stayed on the camera for good, the screen black over a game still running; a change to a state with no transition of its own is still made at once, and takes the cover down - Effects: the built-in effects that place a pattern place it on the sprite itself, cut from an atlas or with other effects after it, and `SwayEffect` measures its strength across the sprite ([#1407](https://github.com/melonjs/melonJS/issues/1407)). On an atlas sprite, `SwayEffect` and an anchored `WaveEffect` moved its roots and `VignetteEffect`, `RadialGradientEffect` and `ScanlineEffect`'s curve landed on the atlas; with two or more effects, every pattern, `noise_uv` and `ShineEffect`'s sweep included, sat on the canvas and slid with the camera - Effects: a renderable drawn through two or more effects keeps its opacity, its own and its containers', on WebGL and WebGPU, as does a `UIPanel`, `ProgressBar` or `Trail` with one ([#1791](https://github.com/melonjs/melonJS/issues/1791)). Their effects were composited at full opacity, so `setOpacity()` or a fade changed nothing while the effects were on +- Effects: through two or more effects (or one, on a `UIPanel`, `ProgressBar` or `Trail`), a glow, outline, blur, drop shadow, chromatic aberration, pixelate or bloom measures its width, offset or size in the renderable's own pixels, as it does alone, at any scale and under a camera's zoom. It measured in the screen's pixels there, so adding a second effect made an outline on a sprite drawn at 0.13 about 8 times thicker; a game that tuned such widths on a scaled renderable should check them, and an effect given a `textureSize` keeps it +- Effects: a renderable drawn through two or more effects (or one, on a `UIPanel`, `ProgressBar` or `Trail`) stays inside the clip it is drawn under, such as a minimap or split-screen camera's view or a clipping container. Its effects were composited over the whole canvas, so a minimap's sprites with effects spilled over the main view +- Effects: on WebGPU, an effect reads what it captured (two or more effects on a renderable, or any on a camera) at the game's texture filter, as on WebGL, so under `"nearest"` (or `antiAlias` off) a width or an offset that falls between two pixels stays crisp. WebGPU always blended there ## [20.11.0] (melonJS 2) - _2026-10-27_ diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index 3ac49e054..c0af4a313 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -154,15 +154,28 @@ sprite. **Their widths, offsets and sizes are in pixels of the texture they draw, with nothing to pass.** Each of those effects takes the size of what it draws: the sprite's whole texture (the atlas image, not the frame), the screen for a -camera's effect, a mesh's texture. Do not pass `textureSize` to get this: a +camera's effect, a mesh's texture. With other effects after it, it still +measures in the sprite's own pixels, at any scale and under a camera's zoom +(on a container, in the container's own), so adding a second effect never +changes how wide the first comes out. Do not pass `textureSize` to get this: a `textureSize` (or `setTextureSize()`) fixes the size instead, for a width meant in some other texture's pixels. ```js -// 2 pixels of the sprite's art, whatever the size of its atlas +// 2 pixels of the sprite's art, whatever the size of its atlas, its scale, +// the camera's zoom, or the effects after it sprite.addPostEffect(new OutlineEffect(app.renderer, { width: 2 })); +sprite.addPostEffect(new GlowEffect(app.renderer, { width: 6 })); ``` +With two or more effects, the sprite is drawn into a capture and each effect +reads that, at the game's texture filter, as a sprite reads its own texture: +under `"nearest"` (or `antiAlias` off) a width that lands between two screen +pixels, such as 2 pixels of a sprite drawn at 0.3, stays crisp, and under +`"linear"` it blends, on WebGL and WebGPU alike. The result lands inside the +clip the sprite is drawn under, a minimap camera's view or a clipping +container, as with one effect. + `SwayEffect` leans a sprite or a layer from side to side, as if in a breeze: the top sways most and everything from its `anchor` row down stays put, so a tree's trunk or a reed's root stays planted. It suits anything planted at its bottom; diff --git a/packages/melonjs/src/video/effects/bloom.js b/packages/melonjs/src/video/effects/bloom.js index 5706de2e3..77c3daca4 100644 --- a/packages/melonjs/src/video/effects/bloom.js +++ b/packages/melonjs/src/video/effects/bloom.js @@ -138,7 +138,8 @@ ${tapList("vec2f", (at) => { * sprite's silhouette and does nothing at all on an opaque frame. * * Its reach is in texels of what it filters: the screen for a camera's - * effect, the sprite's texture on a sprite, so it follows a resize by itself. + * effect, the sprite's texture on a sprite (alone or with other effects, at + * any scale or zoom), so it follows a resize by itself. * @category Effects * @see {@link Renderable#addPostEffect} for usage * @example @@ -158,7 +159,7 @@ export default class BloomEffect extends ShaderEffect { * @param {number} [options.threshold=0.75] - luminance a pixel must pass before it blooms, `0` (everything blooms) to `1` (only white does) * @param {number} [options.intensity=1.0] - how much of the gathered light is added back * @param {number} [options.radius=2.0] - halo reach, in texels - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its reach is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its reach is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { glsl: glslFragment, wgsl: wgslFragment }); diff --git a/packages/melonjs/src/video/effects/blur.js b/packages/melonjs/src/video/effects/blur.js index e7b4e68c9..c73bda580 100644 --- a/packages/melonjs/src/video/effects/blur.js +++ b/packages/melonjs/src/video/effects/blur.js @@ -49,7 +49,7 @@ export default class BlurEffect extends ShaderEffect { * @param {Renderer} renderer - the current renderer instance * @param {object} [options] - effect options * @param {number} [options.strength=1.0] - blur strength in texels - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its strength is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its strength is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { diff --git a/packages/melonjs/src/video/effects/chromaticAberration.js b/packages/melonjs/src/video/effects/chromaticAberration.js index a23ff02dc..239fc42bf 100644 --- a/packages/melonjs/src/video/effects/chromaticAberration.js +++ b/packages/melonjs/src/video/effects/chromaticAberration.js @@ -45,7 +45,7 @@ export default class ChromaticAberrationEffect extends ShaderEffect { * @param {Renderer} renderer - the current renderer instance * @param {object} [options] - effect options * @param {number} [options.offset=3.0] - channel offset in texels - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its offset is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its offset is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { diff --git a/packages/melonjs/src/video/effects/dropShadow.js b/packages/melonjs/src/video/effects/dropShadow.js index 5fc47e5d9..0241a371b 100644 --- a/packages/melonjs/src/video/effects/dropShadow.js +++ b/packages/melonjs/src/video/effects/dropShadow.js @@ -72,7 +72,7 @@ export default class DropShadowEffect extends ShaderEffect { * @param {number} [options.offsetY=2.0] - shadow y offset in texels * @param {number[]} [options.color=[0.0, 0.0, 0.0]] - shadow color as [r, g, b] or [r, g, b, a] (0.0–1.0); the alpha scales `opacity` * @param {number} [options.opacity=0.5] - shadow opacity (0.0–1.0) - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its offset is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its offset is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { diff --git a/packages/melonjs/src/video/effects/glow.js b/packages/melonjs/src/video/effects/glow.js index 05d0aa862..640076202 100644 --- a/packages/melonjs/src/video/effects/glow.js +++ b/packages/melonjs/src/video/effects/glow.js @@ -64,7 +64,7 @@ export default class GlowEffect extends ShaderEffect { * @param {number[]} [options.color=[1.0, 1.0, 1.0]] - glow color as [r, g, b] or [r, g, b, a] (0.0–1.0); the alpha is the halo's opacity * @param {number} [options.width=3.0] - glow spread in pixels * @param {number} [options.intensity=1.0] - glow brightness multiplier - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its spread is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its spread is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { diff --git a/packages/melonjs/src/video/effects/local_space.js b/packages/melonjs/src/video/effects/local_space.js index 045987a9c..93dcb8cf5 100644 --- a/packages/melonjs/src/video/effects/local_space.js +++ b/packages/melonjs/src/video/effects/local_space.js @@ -31,8 +31,9 @@ * renderable even when it is drawn in several quads. `ME_art` stays `uv`, * and `ME_to_uv` turns a local displacement into one of `uv`. * - 2, a pass of a renderable's effect chain: `ME_local` the same way, over - * the capture, and `ME_art` the renderable's frame in its texture - * (`ME_art_rect`, origin and size) at that point. `noise_uv` is + * the capture (whose pixels the camera's projection, its zoom included, + * put the renderable on), and `ME_art` the renderable's frame in its + * texture (`ME_art_rect`, origin and size) at that point. `noise_uv` is * `ME_local` too, so it runs across the renderable as it does when the * renderable is drawn with its one effect. * diff --git a/packages/melonjs/src/video/effects/outline.js b/packages/melonjs/src/video/effects/outline.js index a0c9a26a9..2e11b7b1f 100644 --- a/packages/melonjs/src/video/effects/outline.js +++ b/packages/melonjs/src/video/effects/outline.js @@ -69,7 +69,7 @@ export default class OutlineEffect extends ShaderEffect { * @param {object} [options] - effect options * @param {number[]} [options.color=[1.0, 1.0, 1.0]] - outline color as [r, g, b] or [r, g, b, a] (0.0–1.0); the alpha is the outline's opacity * @param {number} [options.width=1.0] - outline width in pixels - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its width is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its width is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { diff --git a/packages/melonjs/src/video/effects/pixelate.js b/packages/melonjs/src/video/effects/pixelate.js index ed069dd6b..6d383ed5f 100644 --- a/packages/melonjs/src/video/effects/pixelate.js +++ b/packages/melonjs/src/video/effects/pixelate.js @@ -41,7 +41,7 @@ export default class PixelateEffect extends ShaderEffect { * @param {Renderer} renderer - the current renderer instance * @param {object} [options] - effect options * @param {number} [options.size=4.0] - pixel size in texels (higher = more pixelated) - * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its pixel size is measured in; by default the texture it draws (a sprite's own, the screen for a camera's effect) + * @param {number[]} [options.textureSize] - the size, `[width, height]`, of the texture its pixel size is measured in; by default the texture it draws: a sprite's own, alone or with other effects and at any scale or zoom, the screen for a camera's effect */ constructor(renderer, options = {}) { super(renderer, { diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index 48da21392..590b7126c 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -778,15 +778,23 @@ export default class ShaderEffect { /** * For a pass over a whole texture (a render target blit), which draws * its quad with no frame: `frameTexel()` then samples as it always did, - * clamped by the texture's own wrap mode. + * clamped by the texture's own wrap mode. An effect measuring in texels + * (see `_initTextureSize`) measures in the texture's pixels, or, on a + * pass of a renderable's chain, in the renderable's own (a sprite's + * texels), as it does on the renderable drawn with it alone. * @param {number} [width] - the texture's width, in pixels * @param {number} [height] - the texture's height, in pixels + * @param {number[]|null} [scale=null] - on a pass of a renderable's chain, how many of the texture's pixels each pixel of the renderable covers, across and down (see `Renderer#_effectScaleOf`) * @ignore * @internal */ - _clearFrameRect(width, height) { + _clearFrameRect(width, height, scale = null) { this._writeFrameHalf(0, 0); if (width !== undefined && height !== undefined) { + if (scale !== null) { + width /= scale[0]; + height /= scale[1]; + } this._setSourceSize(width, height); } } diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 005fe7292..0396af248 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -13,6 +13,10 @@ import RenderState from "./renderstate.js"; import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js"; import { is2dTransform, pixelSnap } from "./utils/pixel_snap.js"; +// scratch: the transform a renderable is drawn through (see +// Renderer#_drawnTransform), `[a, b, c, d, e, f]` +const drawnTransform = new Float64Array(6); + /** * @import RenderTargetPool from "./rendertarget/render_target_pool.js"; * @import Application from "../application/application.ts"; @@ -127,6 +131,26 @@ export default class Renderer { */ this._chainSpace = null; + /** + * How many of the target's pixels each pixel of a renderable whose + * chain is under way covers, across and down (see + * `_setEffectSpace`); keyed by the renderable. + * @type {WeakMap} + * @ignore + * @internal + */ + this._effectScales = new WeakMap(); + + /** + * How many of the target's pixels each pixel of the renderable whose + * chain is being composited covers, for the blits of that chain to + * measure its effects in; `null` otherwise. + * @type {number[]|null} + * @ignore + * @internal + */ + this._chainScale = null; + /** * Whether an effect pass's capture runs bottom-up (WebGL's * framebuffers), which the space of a chain's passes accounts for. @@ -1200,7 +1224,8 @@ export default class Renderer { * * Drawn with its one effect, the effect is told now, for the draws that * follow; with a chain, its passes are, as each one is composited (see - * `_endEffectSpace`). A renderable this cannot place (no finite size, a + * `_endEffectSpace`), along with the size of its pixels on the target + * (see `_effectScaleOf`). A renderable this cannot place (no finite size, a * perspective view, a transform that is not 2D) leaves its effects in * the space `uv` always was. * @param {Renderable} renderable - the renderable about to draw @@ -1227,6 +1252,10 @@ export default class Renderer { if (space !== null) { this._effectSpaces.set(renderable, space); } + const scale = this._effectScaleOf(renderable); + if (scale !== null) { + this._effectScales.set(renderable, scale); + } } /** @@ -1249,17 +1278,23 @@ export default class Renderer { /** * A chain is about to be composited: its blits hand its effects where - * the renderable is (`_chainSpace`), until `_clearChainSpace`. A - * camera's chain has no renderable space. + * the renderable is (`_chainSpace`) and the size of its pixels + * (`_chainScale`), until `_clearChainSpace`. A camera's chain has + * neither: its effects measure in the screen's pixels. * @param {Renderable} renderable - the renderable whose chain it is * @ignore * @internal */ _beginChainSpace(renderable) { - this._chainSpace = renderable._postEffectManaged - ? null - : (this._effectSpaces.get(renderable) ?? null); + const own = renderable._postEffectManaged !== true; + this._chainSpace = own + ? (this._effectSpaces.get(renderable) ?? null) + : null; + this._chainScale = own + ? (this._effectScales.get(renderable) ?? null) + : null; this._effectSpaces.delete(renderable); + this._effectScales.delete(renderable); } /** @@ -1269,6 +1304,106 @@ export default class Renderer { */ _clearChainSpace() { this._chainSpace = null; + this._chainScale = null; + } + + /** + * The transform the renderable about to draw is drawn through, as + * `[a, b, c, d, e, f]` (x' = a x + c y + e, y' = b x + d y + f): its + * transform, to the vertices it draws, or with `toTarget`, on to the + * target's pixels (from its top-left corner) through the projection as + * well: a camera's zoom, and where the camera sits on the screen. + * `null` when it is not drawn onto a pixel grid (a perspective view, a + * 3D transform) or is squashed to no area at all. The array is reused. + * @param {boolean} toTarget - on to the target's pixels + * @returns {Float64Array|null} the transform + * @ignore + * @internal + */ + _drawnTransform(toTarget) { + // a perspective view (Camera3d) has no pixel grid to be placed on + const p = this.projectionMatrix.val; + if (p[11] !== 0) { + return null; + } + // target x and y must come from the 2D part alone: a 3D view + // matrix mixes the depth in + const v = this.currentTransform.val; + if (v[8] !== 0 || v[9] !== 0) { + return null; + } + let a = v[0]; + let b = v[1]; + let c = v[4]; + let d = v[5]; + let e = v[12]; + let f = v[13]; + if (toTarget) { + // clip space to the target's pixels, y down + const canvas = this.getCanvas(); + const hw = (canvas.width || 1) / 2; + const hh = (canvas.height || 1) / 2; + const pa = hw * p[0]; + const pc = hw * p[4]; + const pb = -hh * p[1]; + const pd = -hh * p[5]; + const pe = hw * (p[12] + 1); + const pf = hh * (1 - p[13]); + const ta = pa * a + pc * b; + const tb = pb * a + pd * b; + const tc = pa * c + pc * d; + const td = pb * c + pd * d; + const te = pa * e + pc * f + pe; + f = pb * e + pd * f + pf; + a = ta; + b = tb; + c = tc; + d = td; + e = te; + } + if (Math.abs(a * d - b * c) < 1e-12) { + return null; + } + const out = drawnTransform; + out[0] = a; + out[1] = b; + out[2] = c; + out[3] = d; + out[4] = e; + out[5] = f; + return out; + } + + /** + * How many of the target's pixels each pixel of the renderable about to + * draw covers, across and down the target: what a pass of its chain + * measures an effect's width, offset or size in (see + * `ShaderEffect#_clearFrameRect`), so they come out in its own pixels + * (a sprite's texels) as they do with one effect, at any scale or zoom. + * `null` when it cannot be measured (see `_drawnTransform`). + * @param {Renderable} renderable - the renderable + * @returns {number[]|null} `[across, down]`, reused per renderable + * @ignore + * @internal + */ + _effectScaleOf(renderable) { + const m = this._drawnTransform(true); + if (m === null) { + return null; + } + let scale = renderable._effectScale; + if (scale === undefined) { + scale = [1, 1]; + /** + * @ignore + * @internal + */ + renderable._effectScale = scale; + } + // the length of a pixel of its own, along each axis of the target + scale[0] = Math.hypot(m[0], m[2]); + scale[1] = Math.hypot(m[1], m[3]); + return scale; } /** @@ -1289,26 +1424,19 @@ export default class Renderer { if (!Number.isFinite(w) || !Number.isFinite(h) || w === 0 || h === 0) { return null; } - // a perspective view (Camera3d) has no pixel grid to be placed on - if (this.projectionMatrix.val[11] !== 0) { + // with its one effect, it is placed by the vertices it draws, which + // the projection has not touched yet; a pass runs over the target + const m = this._drawnTransform(mode === LOCAL_MODE.chain); + if (m === null) { return null; } - // target x and y must come from the 2D part alone: a 3D view - // matrix mixes the depth in - const v = this.currentTransform.val; - if (v[8] !== 0 || v[9] !== 0) { - return null; - } - const a = v[0]; - const b = v[1]; - const c = v[4]; - const d = v[5]; - const e = v[12]; - const f = v[13]; + const a = m[0]; + const b = m[1]; + const c = m[2]; + const d = m[3]; + const e = m[4]; + const f = m[5]; const det = a * d - b * c; - if (Math.abs(det) < 1e-12) { - return null; - } // the frame drawn, for a sprite: its texture, and where in it const frame = renderable.current; const image = renderable.image; @@ -1368,8 +1496,8 @@ export default class Renderer { toUV[2] = (-h * sin) / texW; toUV[3] = (h * cos) / texH; } else { - // across the renderable, in the capture: the transform, scaled - // to the capture (bottom-up on WebGL) + // across the renderable, in the capture: its transform onto the + // target, scaled to the capture (bottom-up on WebGL) const canvas = this.getCanvas(); const W = canvas.width || 1; const H = canvas.height || 1; diff --git a/packages/melonjs/src/video/rendertarget/webgpurendertarget.js b/packages/melonjs/src/video/rendertarget/webgpurendertarget.js index a664c571b..8317f3f9b 100644 --- a/packages/melonjs/src/video/rendertarget/webgpurendertarget.js +++ b/packages/melonjs/src/video/rendertarget/webgpurendertarget.js @@ -96,10 +96,12 @@ export default class WebGPURenderTarget extends RenderTarget { * @type {number} */ this.generation = 0; - // lazy (view + linear clamp sampler) pairing against the material - // layout — what a blit binds at group 1 to sample this target + // lazy (view + clamp sampler) pairing against the material layout — + // what a blit binds at group 1 to sample this target this.materialBindGroup = null; this.materialBindGroupGeneration = -1; + // the texture filter that bind group samples at + this.materialBindGroupFilter = null; // consumed by the renderer as a colorLoadOp "clear" on next retarget this.pendingClear = false; @@ -181,19 +183,22 @@ export default class WebGPURenderTarget extends RenderTarget { } /** - * The group-1 (material) bind group sampling this target with a linear - * clamp sampler — what the effect blit binds as its source. Rebuilt - * lazily when the backing texture was reallocated. + * The group-1 (material) bind group sampling this target with a clamp + * sampler at the game's texture filter, as a sprite samples its own + * texture — what the effect blit binds as its source. Rebuilt lazily + * when the backing texture was reallocated or the filter changed. * @returns {GPUBindGroup} the bind group * @ignore * @internal */ getMaterialBindGroup() { + const renderer = this.renderer; + const filter = renderer.getDefaultTextureFilter(); if ( this.materialBindGroup === null || - this.materialBindGroupGeneration !== this.generation + this.materialBindGroupGeneration !== this.generation || + this.materialBindGroupFilter !== filter ) { - const renderer = this.renderer; this.materialBindGroup = renderer.device.createBindGroup({ label: "melonJS render-target material", layout: renderer.pipelineCache.materialLayout, @@ -201,11 +206,12 @@ export default class WebGPURenderTarget extends RenderTarget { { binding: 0, resource: this.colorView }, { binding: 1, - resource: renderer.textureStore.getSampler("linear", "no-repeat"), + resource: renderer.textureStore.getSampler(filter, "no-repeat"), }, ], }); this.materialBindGroupGeneration = this.generation; + this.materialBindGroupFilter = filter; } return this.materialBindGroup; } 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 86fa48471..ac135ee98 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -717,13 +717,18 @@ export default class LitQuadBatcher extends QuadBatcher { gl.bindTexture(gl.TEXTURE_2D, source); this.currentTextureUnit = 0; this.boundTextures[0] = source; + // read at the game's texture filter, as a sprite reads its own + // texture, not through the sampler the last draw left on the unit + const samplers = this.renderer.samplerCache; + samplers.bind(0, samplers.get(this.renderer._glTextureFilter())); shader.setUniform("uSampler", 0); // `noise_uv` builtin: a blit is a full-frame quad — identity rect shader._setNoiseUVRect?.(width, height, width, height, 0, 0); // and the whole texture is the frame: sampling stays unbounded. The - // texture is the pass's render target, the size of the quad - shader._clearFrameRect?.(width, height); + // texture is the pass's render target, the size of the quad, and a + // renderable's chain measures in the renderable's own pixels + shader._clearFrameRect?.(width, height, this.renderer._chainScale); // bottom-up capture FBO: uv.y grows upward inside apply() — see // QuadBatcher.blitTexture diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 90ce07f04..c2d56d725 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -268,13 +268,18 @@ export default class QuadBatcher extends MaterialBatcher { gl.bindTexture(gl.TEXTURE_2D, source); this.currentTextureUnit = 0; this.boundTextures[0] = source; + // read at the game's texture filter, as a sprite reads its own + // texture, not through the sampler the last draw left on the unit + const samplers = this.renderer.samplerCache; + samplers.bind(0, samplers.get(this.renderer._glTextureFilter())); shader.setUniform("uSampler", 0); // `noise_uv` builtin: a blit is a full-frame quad — identity rect shader._setNoiseUVRect?.(width, height, width, height, 0, 0); // and the whole texture is the frame: sampling stays unbounded. The - // texture is the pass's render target, the size of the quad - shader._clearFrameRect?.(width, height); + // texture is the pass's render target, the size of the quad, and a + // renderable's chain measures in the renderable's own pixels + shader._clearFrameRect?.(width, height, this.renderer._chainScale); // the blit samples a bottom-up capture FBO (the Y-flipped UVs // below): uv.y grows UPWARD inside apply(), so directional bodies diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 5a23523a1..5d61f40b6 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -2234,6 +2234,9 @@ export default class WebGLRenderer extends Renderer { ? 1 : this._effectAlphaStack[this._effectPassDepth - 1]; if (effects.length === 1) { + if (!isCamera) { + this._resumeSavedClip(); + } this.blitEffect(rt1.texture, 0, 0, w, h, effects[0], keepBlend, alpha); } else { // multi-pass: ping-pong between two render targets @@ -2256,6 +2259,9 @@ export default class WebGLRenderer extends Renderer { parentRT.bind(); } this.setViewport(0, 0, w, h); + if (!isCamera) { + this._resumeSavedClip(); + } this.blitEffect( src.texture, @@ -2592,6 +2598,35 @@ export default class WebGLRenderer extends Renderer { this.currentScissor[3] = sh; } + /** + * Put back the clip a renderable's effect pass was begun under, for its + * composite to land inside it: a camera's view on the screen, a + * clipping container. The pass drops it (see `beginPostEffect`) to + * capture the whole canvas, and the `restore()` that ends the pass + * would only put it back once the composite has drawn. + * @ignore + * @internal + */ + _resumeSavedClip() { + const saved = this.renderState.peekScissor(); + if (saved === null) { + return; + } + const gl = this.gl; + const canvas = this.getCanvas(); + this.flush(); + gl.enable(gl.SCISSOR_TEST); + this._scissorActive = true; + this.currentScissor.set(saved); + // screen-space coords (see `clipRect`), with GL's bottom-left y-flip + gl.scissor( + saved[0], + canvas.height - saved[3] - saved[1], + saved[2], + saved[3], + ); + } + /** * Disable the scissor test, allowing rendering to the full viewport. */ diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index f6bbfd20b..7677e9987 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -575,8 +575,13 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // the uniform snapshot below effect?._setNoiseUVRect?.(w, h, w, h, 0, 0); // and the whole texture is the frame: sampling stays unbounded. The - // texture is the pass's render target, the size of the quad - effect?._clearFrameRect?.(source.width ?? w, source.height ?? h); + // texture is the pass's render target, the size of the quad, and a + // renderable's chain measures in the renderable's own pixels + effect?._clearFrameRect?.( + source.width ?? w, + source.height ?? h, + renderer._chainScale, + ); // a pass of a renderable's chain: where the renderable is, for the // effects that place a pattern on it (local_space.js) effect?._setLocalSpace?.(renderer._chainSpace); diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 7595ab8ed..2363b1cdf 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1944,6 +1944,9 @@ export default class WebGPURenderer extends Renderer { ? 1 : this.effectAlphaStack[this.effectPassDepth - 1]; if (effects.length === 1) { + if (!isCamera) { + this._resumeSavedClip(); + } this.blitEffect(rt1, 0, 0, w, h, effects[0], keepBlend, alpha); } else { // multi-pass: ping-pong between the two pool targets @@ -1957,6 +1960,9 @@ export default class WebGPURenderer extends Renderer { dst = tmp; } this.setRenderTarget(parentRT); + if (!isCamera) { + this._resumeSavedClip(); + } this.blitEffect( src, 0, @@ -3510,6 +3516,22 @@ export default class WebGPURenderer extends Renderer { this.setScissorRect(sx, sy, sw, sh); } + /** + * Put back the clip a renderable's effect pass was begun under, for its + * composite to land inside it: a camera's view on the screen, a + * clipping container. The pass drops it (see `beginPostEffect`) to + * capture the whole canvas, and the `restore()` that ends the pass + * would only put it back once the composite has drawn. + * @ignore + * @internal + */ + _resumeSavedClip() { + const saved = this.renderState.peekScissor(); + if (saved !== null) { + this.setScissorRect(saved[0], saved[1], saved[2], saved[3]); + } + } + /** * Clamp a screen-space scissor box to the canvas and make it the * active scissor (shared tail of {@link WebGPURenderer#clipRect} and diff --git a/packages/melonjs/tests/effect-chain-clip.spec.js b/packages/melonjs/tests/effect-chain-clip.spec.js new file mode 100644 index 000000000..76635e166 --- /dev/null +++ b/packages/melonjs/tests/effect-chain-clip.spec.js @@ -0,0 +1,151 @@ +/** + * A renderable drawn through a chain of effects lands inside the clip it is + * drawn under: a camera's view on the screen (a minimap, a split screen), + * or a clipping container. + * + * With two effects or more (or one, on a renderable that sets + * `postEffectNeedsCapture`), the renderable is drawn into a capture the size + * of the canvas, so its pass drops the clip, and the chain's last blit + * composites the result onto what is behind it. That composite drew before + * the clip was put back, so the whole capture landed: a minimap's chained + * sprites spilled over the main view. With one effect drawn straight on it + * was always clipped. + * + * Read in pixels on WebGL: a white sprite over black, clipped to the left + * half of the screen, is black on the right half however it is drawn. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { boot, Container, ShaderEffect, Sprite } from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; +import WebGLRenderer from "../src/video/webgl/webgl_renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +const SIZE = 64; + +describe("a chain of effects composites inside its clip (WebGL)", () => { + let renderer; + let gl; + let white; + + beforeAll(async () => { + boot(); + renderer = await getWebGLRenderer(SIZE, SIZE); + if (renderer instanceof WebGLRenderer) { + gl = renderer.gl; + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } else { + renderer = undefined; + } + white = Renderer.createCanvas(SIZE, SIZE); + const ctx = white.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, SIZE, SIZE); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + /** an effect drawing what it is given */ + const passThrough = () => { + return new ShaderEffect(renderer, { + glsl: "vec4 apply(vec4 color, vec2 uv) { return color; }", + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + }); + }; + + /** a white square over the whole screen, through `effects` pass-throughs */ + const square = (effects) => { + const sprite = new Sprite(SIZE / 2, SIZE / 2, { image: white }); + for (let i = 0; i < effects; i++) { + sprite.addPostEffect(passThrough()); + } + return sprite; + }; + + /** + * draw a renderable over black, with `clip` applied first, and read a + * pixel in the left half and one in the right half + */ + const halves = (renderable, clip) => { + renderer.backgroundColor.parseCSS("#000000"); + renderer.clear(); + renderer.save(); + renderer.resetTransform(); + clip?.(); + renderable.preDraw(renderer); + renderable.draw(renderer); + renderable.postDraw(renderer); + renderer.restore(); + renderer.flush(); + const read = (x) => { + const px = new Uint8Array(4); + gl.readPixels(x, SIZE / 2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px[0]; + }; + gl.finish(); + return [read(SIZE / 4), read((SIZE * 3) / 4)]; + }; + + // the left half of the screen, as a minimap camera clips to its view + const leftHalf = () => { + renderer.clipRect(0, 0, SIZE / 2, SIZE); + }; + + it.for([0, 1, 2, 3])( + "a sprite through %i effect(s) stays inside the clip it is drawn under", + (effects, ctx) => { + requireWebGL(ctx); + expect(halves(square(effects), leftHalf)).toEqual([255, 0]); + }, + ); + + it("so does one effect drawn into a target of its own", (ctx) => { + requireWebGL(ctx); + // as a UIPanel, a ProgressBar or a Trail draws its single effect + const sprite = square(1); + sprite.postEffectNeedsCapture = true; + expect(halves(sprite, leftHalf)).toEqual([255, 0]); + }); + + it("inside a clipping container", (ctx) => { + requireWebGL(ctx); + const container = new Container(0, 0, SIZE / 2, SIZE); + container.clipping = true; + const sprite = square(2); + container.addChild(sprite); + // drawn as a container draws what an update found in view + sprite.inViewport = true; + expect(halves(container)).toEqual([255, 0]); + }); + + it("and the clip is back as it was once it is drawn", (ctx) => { + requireWebGL(ctx); + renderer.save(); + renderer.resetTransform(); + leftHalf(); + const sprite = square(2); + sprite.preDraw(renderer); + sprite.draw(renderer); + sprite.postDraw(renderer); + expect(renderer._scissorActive).toBe(true); + expect(Array.from(renderer.currentScissor)).toEqual([0, 0, SIZE / 2, SIZE]); + renderer.restore(); + expect(renderer._scissorActive).toBe(false); + }); + + it("unclipped, it covers the screen", (ctx) => { + requireWebGL(ctx); + expect(halves(square(2))).toEqual([255, 255]); + }); +}); diff --git a/packages/melonjs/tests/effect-local-space.spec.js b/packages/melonjs/tests/effect-local-space.spec.js index bce96827b..bc327e036 100644 --- a/packages/melonjs/tests/effect-local-space.spec.js +++ b/packages/melonjs/tests/effect-local-space.spec.js @@ -22,6 +22,7 @@ import { boot, DissolveEffect, HologramEffect, + Matrix3d, OutlineEffect, RadialGradientEffect, ScanlineEffect, @@ -166,13 +167,18 @@ describe("effects place their pattern on the renderable (WebGL)", () => { }; /** the sprite: the art with its own texture, or cut from the atlas */ - const sprite = ({ fromAtlas = false, angle = 0, flip = false } = {}) => { + const sprite = ({ + fromAtlas = false, + angle = 0, + flip = false, + scale = SCALE, + } = {}) => { const s = fromAtlas ? atlas.createSpriteFromName("art") : new Sprite(0, 0, { image: own }); s.pos.x = CENTRE; s.pos.y = CENTRE; - s.scale(SCALE); + s.scale(scale); if (angle !== 0) { s.rotate(angle); } @@ -493,6 +499,46 @@ describe("effects place their pattern on the renderable (WebGL)", () => { }); }); + // a zoomed camera (or one drawn away from the screen's corner) scales + // the world through its projection, not the renderable's transform: a + // chain's passes run in the target's pixels, so they go through it too + describe("two effects, under a camera's zoom", () => { + /** draw with a camera zoomed in twice on the middle of the screen */ + const zoomed = (draw) => { + const saved = renderer.projectionMatrix.clone(); + const half = SIZE / 4; + renderer.setProjection( + new Matrix3d().ortho( + CENTRE - half, + CENTRE + half, + CENTRE + half, + CENTRE - half, + -1000, + 1000, + ), + ); + try { + return draw(); + } finally { + renderer.setProjection(saved); + } + }; + const ALL = { ...EFFECTS, ...TEXTURE_PATTERNS }; + + it.for(Object.keys(ALL))("%s draws as it does alone", (name, ctx) => { + requireWebGL(ctx); + const make = ALL[name]; + // at its own size, zoomed to the same span on the screen + const alone = zoomed(() => { + return drawn({ scale: 1 }, make()); + }); + const chained = zoomed(() => { + return drawn({ scale: 1 }, make(), passThrough()); + }); + expect(differing(alone, chained)).toBeLessThan(0.01); + }); + }); + describe("unchanged where nothing was wrong", () => { it.for(Object.keys({ ...EFFECTS, ...TEXTURE_PATTERNS }))( "%s on a sprite with its own texture draws as the effect always drew", @@ -713,6 +759,57 @@ describe("effects place their pattern on the renderable (WebGL)", () => { renderer.restore(); }); + it("in a chain, maps them from the target's pixels, through a camera's zoom", (ctx) => { + requireWebGL(ctx); + const s = sprite({ angle: 0.7, flip: true }); + const saved = renderer.projectionMatrix.clone(); + // zoomed in twice, the view's corner at (20, 10) + renderer.setProjection( + new Matrix3d().ortho(20, 20 + SIZE / 2, 10 + SIZE / 2, 10, -1000, 1000), + ); + renderer.save(); + renderer.resetTransform(); + try { + s.addPostEffect(EFFECTS.Vignette()); + s.addPostEffect(passThrough()); + s.preDraw(renderer); + const space = s._effectSpace; + expect(space.mode).toBe(LOCAL_MODE.chain); + // where the corners of the art land on the target, in pixels + const m = renderer.currentTransform.val; + const toTarget = (x, y) => { + const wx = m[0] * x + m[4] * y + m[12]; + const wy = m[1] * x + m[5] * y + m[13]; + return [(wx - 20) * 2, (wy - 10) * 2]; + }; + const local = ([x, y]) => { + return [ + space.localX[0] * x + space.localX[1] * y + space.localX[2], + space.localY[0] * x + space.localY[1] * y + space.localY[2], + ]; + }; + const corners = [ + [s.pos.x, s.pos.y, 0, 0], + [s.pos.x + ART, s.pos.y, 1, 0], + [s.pos.x, s.pos.y + ART, 0, 1], + [s.pos.x + ART, s.pos.y + ART, 1, 1], + ]; + for (const [x, y, u, v] of corners) { + const [lu, lv] = local(toTarget(x, y)); + expect(lu).toBeCloseTo(u, 5); + expect(lv).toBeCloseTo(v, 5); + } + // and its pixels are twice its size there, scaled and zoomed + const scale = renderer._effectScales.get(s); + expect(scale[0]).toBeCloseTo(SCALE * 2, 5); + expect(scale[1]).toBeCloseTo(SCALE * 2, 5); + s.postDraw(renderer); + } finally { + renderer.restore(); + renderer.setProjection(saved); + } + }); + it("leaves a renderable it cannot place in the space uv always was", (ctx) => { requireWebGL(ctx); // no finite size: nothing to be 0..1 across @@ -737,14 +834,33 @@ describe("effects place their pattern on the renderable (WebGL)", () => { expect(fx._shader._uniformCache.ME_mode).toBe(LOCAL_MODE.uv); }); - it("gives a camera's chain no renderable space", () => { + it("gives a camera's chain no renderable space, nor its pixel size", () => { const bare = Object.create(Renderer.prototype); bare._effectSpaces = new WeakMap(); + bare._effectScales = new WeakMap(); bare._chainSpace = { mode: LOCAL_MODE.chain }; + bare._chainScale = [2, 2]; const camera = { _postEffectManaged: true }; bare._effectSpaces.set(camera, { mode: LOCAL_MODE.chain }); + bare._effectScales.set(camera, [2, 2]); bare._beginChainSpace(camera); expect(bare._chainSpace).toBe(null); + expect(bare._chainScale).toBe(null); + }); + + it("hands a chain the size of its renderable's pixels, until it is done", () => { + const bare = Object.create(Renderer.prototype); + bare._effectSpaces = new WeakMap(); + bare._effectScales = new WeakMap(); + const sprite = {}; + const scale = [3, 0.5]; + bare._effectScales.set(sprite, scale); + bare._beginChainSpace(sprite); + expect(bare._chainScale).toBe(scale); + // taken once: a later chain of the same renderable is measured anew + expect(bare._effectScales.has(sprite)).toBe(false); + bare._clearChainSpace(); + expect(bare._chainScale).toBe(null); }); }); diff --git a/packages/melonjs/tests/effect-texture-size.spec.js b/packages/melonjs/tests/effect-texture-size.spec.js index 66c32b156..24d0e83cb 100644 --- a/packages/melonjs/tests/effect-texture-size.spec.js +++ b/packages/melonjs/tests/effect-texture-size.spec.js @@ -27,13 +27,16 @@ import { boot, ChromaticAberrationEffect, Color, + Container, DropShadowEffect, GlowEffect, + Matrix3d, Mesh, OutlineEffect, PixelateEffect, Renderable, ShaderEffect, + Sprite, state, video, } from "../src/index.js"; @@ -376,6 +379,230 @@ describe("effects measure in the texels of the texture they draw (WebGL)", () => mesh.destroy(); } }); + + // With two or more effects, a renderable is drawn into a capture the size + // of the screen, and its passes read that: they measured in the screen's + // pixels, so the same outline came out thinner or thicker than with one + // effect at any scale but 1, or under a camera's zoom + describe("through a chain of effects", () => { + // a 48 x 48 texture with a 16 px square in the middle: room around it + // for an outline drawn with one effect, which stays inside the sprite + const ART = () => { + return image(48, 48, 16, 16, 16); + }; + + /** an effect drawing what it is given: a second one makes a chain */ + const passThrough = () => { + return new ShaderEffect(renderer, { + glsl: "vec4 apply(vec4 color, vec2 uv) { return color; }", + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + }); + }; + + /** draw a renderable as its container would, over black */ + const paint = (renderable, beforeComposite) => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.resetTransform(); + renderable.preDraw(renderer); + renderable.draw(renderer); + beforeComposite?.(); + renderable.postDraw(renderer); + renderer.restore(); + renderer.flush(); + }; + + /** + * the white pixels across the middle row and down the middle column: + * the outline on both sides of the square + */ + const ring = () => { + const gl = renderer.gl; + const row = new Uint8Array(SIZE * 4); + const column = new Uint8Array(SIZE * 4); + gl.readPixels(0, SIZE / 2, SIZE, 1, gl.RGBA, gl.UNSIGNED_BYTE, row); + gl.readPixels(SIZE / 2, 0, 1, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, column); + const white = (pixels) => { + let n = 0; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i] > 200 && pixels[i + 1] > 200 && pixels[i + 2] > 200) { + n++; + } + } + return n; + }; + return [white(row), white(column)]; + }; + + /** + * The outline a sprite gets, through that one effect and then through + * it and a pass-through. `place` sets the sprite up and returns what + * wears the effects: the sprite, or a container holding it. + */ + const outlines = (place) => { + const result = []; + for (const chained of [false, true]) { + const sprite = new Sprite(SIZE / 2, SIZE / 2, { image: ART() }); + const worn = place(sprite); + worn.addPostEffect( + new OutlineEffect(renderer, { color: [1, 1, 1], width: 2 }), + ); + if (chained) { + worn.addPostEffect(passThrough()); + } + paint(worn); + result.push(ring()); + worn.destroy(); + } + return result; + }; + + it("an outline on a scaled sprite is as wide as with one effect", (ctx) => { + requireWebGL(ctx); + const [alone, chained] = outlines((sprite) => { + sprite.scale(2); + return sprite; + }); + // 2 texels at twice the size: 4 pixels each side + expect(alone).toEqual([8, 8]); + expect(chained).toEqual(alone); + }); + + it("each way at its own scale, across and down", (ctx) => { + requireWebGL(ctx); + const [alone, chained] = outlines((sprite) => { + sprite.scale(2, 1); + return sprite; + }); + expect(alone).toEqual([8, 4]); + expect(chained).toEqual(alone); + }); + + it("turned a quarter, across and down swap", (ctx) => { + requireWebGL(ctx); + const [alone, chained] = outlines((sprite) => { + sprite.scale(2, 1); + sprite.rotate(Math.PI / 2); + return sprite; + }); + expect(alone[0]).not.toBe(alone[1]); + expect(chained).toEqual(alone); + }); + + it("under a camera's zoom", (ctx) => { + requireWebGL(ctx); + const saved = renderer.projectionMatrix.clone(); + // a camera zoomed in twice on the middle of the screen + renderer.setProjection( + new Matrix3d().ortho(40, 120, 120, 40, -1000, 1000), + ); + try { + const [alone, chained] = outlines((sprite) => { + return sprite; + }); + expect(alone).toEqual([8, 8]); + expect(chained).toEqual(alone); + } finally { + renderer.setProjection(saved); + } + }); + + it("on a container, in the container's own pixels", (ctx) => { + requireWebGL(ctx); + // a container of no size of its own, as most are, twice the size + // (with one effect, a container's is lost to its first child: a + // chain is how a container wears an effect) + const [, chained] = outlines((sprite) => { + const container = new Container(); + container.scale(2); + sprite.pos.set(SIZE / 4, SIZE / 4); + container.addChild(sprite); + // drawn as a container draws what an update found in view + sprite.inViewport = true; + return container; + }); + // 2 of its pixels at twice the size: 4 pixels each side + expect(chained).toEqual([8, 8]); + }); + + it("keeps a textureSize the game gives", (ctx) => { + requireWebGL(ctx); + const outline = new OutlineEffect(renderer, { + color: [1, 1, 1], + width: 2, + textureSize: [256, 256], + }); + const sprite = new Sprite(SIZE / 2, SIZE / 2, { image: ART() }); + sprite.scale(2); + sprite.addPostEffect(outline); + sprite.addPostEffect(passThrough()); + try { + paint(sprite); + expect(programSize(outline._shader.program)).toEqual([256, 256]); + } finally { + sprite.destroy(); + } + }); + + it("reads what it captured at the game's texture filter", (ctx) => { + requireWebGL(ctx); + const gl = renderer.gl; + // columns of white and black, a pixel each + const stripes = Renderer.createCanvas(32, 32); + const c2d = stripes.getContext("2d"); + c2d.fillStyle = "#ffffff"; + for (let x = 0; x < 32; x += 2) { + c2d.fillRect(x, 0, 1, 32); + } + // read half a pixel to the right: on the edge between two + // columns, a blend of both read linearly, one of them read nearest + const middle = (filter, leftOver) => { + renderer.setTextureFilter(filter); + const shift = new ShaderEffect( + renderer, + ` + uniform float uHalf; + vec4 apply(vec4 color, vec2 uv) { + return texture2D(uSampler, uv + vec2(uHalf, 0.0)); + } + `, + ); + shift.setUniform("uHalf", 0.5 / SIZE); + const sprite = new Sprite(SIZE / 2, SIZE / 2, { image: stripes }); + sprite.addPostEffect(shift); + sprite.addPostEffect(passThrough()); + try { + // what the last draw before the composite left on the unit: + // the other filter + paint(sprite, () => { + renderer.samplerCache.bind(0, renderer.samplerCache.get(leftOver)); + }); + const out = new Uint8Array(4); + gl.readPixels( + SIZE / 2, + SIZE / 2, + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + out, + ); + return out[0]; + } finally { + sprite.destroy(); + } + }; + const mode = renderer.settings.textureFilter; + try { + const crisp = middle("nearest", gl.LINEAR); + expect(crisp < 30 || crisp > 225).toBe(true); + const smooth = middle("linear", gl.NEAREST); + expect(Math.abs(smooth - 128)).toBeLessThan(30); + } finally { + renderer.setTextureFilter(mode); + } + }); + }); }); describe("a camera's effect measures in the screen's texels (WebGL)", () => { @@ -535,6 +762,29 @@ describe("effects measure in the texels of the texture they draw (WebGPU, mock r expect(sizeOf(bloom)).toEqual([120, 90]); }); + it("a pass of a renderable's chain measures in the renderable's own pixels", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + const outline = quiet(() => { + return new OutlineEffect({ shaderLanguage: "wgsl" }); + }); + const source = { + width: 300, + height: 200, + getMaterialBindGroup() { + return { rt: true }; + }, + }; + // each of its pixels covers 2 of the target's across, half of one down + renderer._chainScale = [2, 0.5]; + batcher.blitTexture(source, 0, 0, 300, 200, outline, false); + expect(sizeOf(outline)).toEqual([150, 400]); + // a camera's chain: the target's own + renderer._chainScale = null; + batcher.blitTexture(source, 0, 0, 300, 200, outline, false); + expect(sizeOf(outline)).toEqual([300, 200]); + }); + it("keeps a textureSize the game gives", () => { const renderer = createMockWebGPURenderer(); const batcher = new WebGPUQuadBatcher(renderer); diff --git a/packages/melonjs/tests/webgpu_post_effect_flow.spec.js b/packages/melonjs/tests/webgpu_post_effect_flow.spec.js index 56aba757d..3a2fff401 100644 --- a/packages/melonjs/tests/webgpu_post_effect_flow.spec.js +++ b/packages/melonjs/tests/webgpu_post_effect_flow.spec.js @@ -79,7 +79,9 @@ describe("WebGPU post-effect control flow (recorded primitives)", () => { // where each renderable with a chain is, for its passes // (Renderer#_setEffectSpace) _effectSpaces: new WeakMap(), + _effectScales: new WeakMap(), _chainSpace: null, + _chainScale: null, projectionMatrix: new Matrix3d(), backgroundColor: new Color(16, 32, 48), currentBatcher: null, @@ -114,6 +116,16 @@ describe("WebGPU post-effect control flow (recorded primitives)", () => { clipRect(x, y, w, h) { log.push(`clip:${x},${y},${w},${h}`); }, + // the clip saved by the pass's own save(): none by default + renderState: { + saved: null, + peekScissor() { + return this.saved; + }, + }, + setScissorRect(x, y, w, h) { + log.push(`scissor:${x},${y},${w},${h}`); + }, disableScissor() { log.push("descissor"); }, @@ -201,6 +213,47 @@ describe("WebGPU post-effect control flow (recorded primitives)", () => { expect(alphas).toEqual(["a@1", "b@1", "c@0.3"]); }); + it("a sprite chain composites inside the clip it began under, its passes whole", () => { + const sprite = makeRenderable([ + makeEffect({ name: "a" }), + makeEffect({ name: "b" }), + ]); + // drawn inside a clip: a minimap camera's view, a clipping container + renderer.renderState.saved = [5, 6, 70, 80]; + renderer.beginPostEffect(sprite); + renderer.endPostEffect(sprite); + const clip = log.indexOf("scissor:5,6,70,80"); + // after the pass between the two effects, right before the composite + expect(clip).toBeGreaterThan(log.indexOf("blit:rt0→a:keep=false")); + expect(log[clip + 1]).toBe("blit:rt1→b:keep=true"); + }); + + it("so does one effect drawn into a target of its own", () => { + const panel = makeRenderable([makeEffect({ name: "solo" })]); + panel.postEffectNeedsCapture = true; + renderer.renderState.saved = [5, 6, 70, 80]; + renderer.beginPostEffect(panel); + renderer.endPostEffect(panel); + const clip = log.indexOf("scissor:5,6,70,80"); + expect(clip).toBeGreaterThan(-1); + expect(log[clip + 1]).toBe("blit:rt0→solo:keep=true"); + }); + + it("a camera's chain clips to its own view, not to what it began under", () => { + const camera = makeRenderable( + [makeEffect({ name: "c1" }), makeEffect({ name: "c2" })], + { camera: true }, + ); + renderer.renderState.saved = [5, 6, 70, 80]; + renderer.beginPostEffect(camera); + renderer.endPostEffect(camera); + expect( + log.some((entry) => { + return entry.startsWith("scissor:"); + }), + ).toBe(false); + }); + it("a camera's chain composites whole, whatever the alpha it began under", () => { const camera = makeRenderable( [makeEffect({ name: "c1" }), makeEffect({ name: "c2" })], diff --git a/packages/melonjs/tests/webgpu_render_target.spec.js b/packages/melonjs/tests/webgpu_render_target.spec.js index d8618c0ec..54bf471d1 100644 --- a/packages/melonjs/tests/webgpu_render_target.spec.js +++ b/packages/melonjs/tests/webgpu_render_target.spec.js @@ -43,6 +43,11 @@ describe("WebGPURenderTarget (mock device)", () => { }, }, pipelineCache: { materialLayout: {} }, + // the game's texture filter + filter: "linear", + getDefaultTextureFilter() { + return this.filter; + }, textureStore: { getSampler(filter, repeat) { return { filter, repeat }; @@ -100,7 +105,7 @@ describe("WebGPURenderTarget (mock device)", () => { const rt = new WebGPURenderTarget(renderer, 64, 64); const first = rt.getMaterialBindGroup(); expect(rt.getMaterialBindGroup()).toBe(first); - // the blit source samples linearly, clamped + // the blit source samples at the game's texture filter, clamped expect(first.entries[1].resource).toEqual({ filter: "linear", repeat: "no-repeat", @@ -110,6 +115,22 @@ describe("WebGPURenderTarget (mock device)", () => { expect(rt.getMaterialBindGroup()).not.toBe(first); }); + it("samples at the game's texture filter, as a sprite samples its texture", () => { + const rt = new WebGPURenderTarget(renderer, 64, 64); + renderer.filter = "nearest"; + const crisp = rt.getMaterialBindGroup(); + expect(crisp.entries[1].resource).toEqual({ + filter: "nearest", + repeat: "no-repeat", + }); + expect(rt.getMaterialBindGroup()).toBe(crisp); + // the game switches filter: the next blit samples at the new one + renderer.filter = "linear"; + const smooth = rt.getMaterialBindGroup(); + expect(smooth).not.toBe(crisp); + expect(smooth.entries[1].resource.filter).toBe("linear"); + }); + it("clear() defers to the next retarget (pendingClear flag)", () => { const rt = new WebGPURenderTarget(renderer, 64, 64); rt.clear();