From f00c6566101cbbade8f0ac7dc0d59da854e5cace Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 26 Oct 2026 10:41:02 +0800 Subject: [PATCH] Effects: measure in pixels of the texture drawn, no textureSize needed Glow, outline, blur, drop shadow, chromatic aberration, pixelate and bloom measure their widths, offsets and sizes in texels, and assumed a 256x256 texture (the screen for an outline or a bloom) unless given `textureSize`. On any other texture they came out thinner or thicker than their docs say. They now take the size of the texture they draw: the sprite's texture, the render target of a camera's pass, a mesh's texture. The size is only rewritten when it changes, so sprites from one texture still share a draw. An explicit `textureSize`, `setTextureSize()` or `setUniform("uTextureSize")` still wins, a clone keeps its original's behaviour, and a custom effect declaring its own `uTextureSize` is never touched. No new API. The Shader Effects example drops its hand-passed sizes (identical frames on WebGL and WebGPU) and adds Shine, Squish, Color Matrix and Bloom. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- .../shaderEffects/ExampleShaderEffects.tsx | 80 ++- packages/examples/src/main.tsx | 2 +- packages/melonjs/CHANGELOG.md | 1 + .../melonjs-effects-and-shaders/SKILL.md | 20 +- packages/melonjs/src/video/effects/bloom.js | 29 +- packages/melonjs/src/video/effects/blur.js | 9 +- .../src/video/effects/chromaticAberration.js | 9 +- .../melonjs/src/video/effects/dropShadow.js | 9 +- packages/melonjs/src/video/effects/glow.js | 9 +- packages/melonjs/src/video/effects/outline.js | 12 +- .../melonjs/src/video/effects/pixelate.js | 9 +- .../melonjs/src/video/effects/shadereffect.js | 93 ++- .../video/webgl/batchers/lit_quad_batcher.js | 5 +- .../src/video/webgl/batchers/quad_batcher.js | 5 +- .../melonjs/src/video/webgl/webgl_renderer.js | 9 + .../src/video/webgpu/batchers/quad_batcher.js | 5 +- .../src/video/webgpu/webgpu_renderer.js | 10 + .../melonjs/tests/effect-texture-size.spec.js | 594 ++++++++++++++++++ .../melonjs/tests/webgpu_warm_keys.spec.js | 8 +- 19 files changed, 849 insertions(+), 69 deletions(-) create mode 100644 packages/melonjs/tests/effect-texture-size.spec.js diff --git a/packages/examples/src/examples/shaderEffects/ExampleShaderEffects.tsx b/packages/examples/src/examples/shaderEffects/ExampleShaderEffects.tsx index 90e86d2e60..4ba973676c 100644 --- a/packages/examples/src/examples/shaderEffects/ExampleShaderEffects.tsx +++ b/packages/examples/src/examples/shaderEffects/ExampleShaderEffects.tsx @@ -5,8 +5,10 @@ */ import { Application, + BloomEffect, BlurEffect, ChromaticAberrationEffect, + ColorMatrixEffect, DesaturateEffect, DissolveEffect, DropShadowEffect, @@ -20,7 +22,9 @@ import { PixelateEffect, ScanlineEffect, SepiaEffect, + ShineEffect, Sprite, + SquishEffect, Stage, state, Text, @@ -32,8 +36,13 @@ import { import { createExampleComponent } from "../utils"; import monsterImg from "./assets/monster.png"; +// five rows of four +const WIDTH = 1218; +const HEIGHT = 702; +const SPRITE_SCALE = 0.25; + const createGame = async () => { - const app = new Application(1218, 562, { + const app = new Application(WIDTH, HEIGHT, { parent: "screen", scale: "auto", backgroundColor: "#202020", @@ -53,10 +62,7 @@ const createGame = async () => { class EffectsScene extends Stage { override onResetEvent() { const r = app.renderer; - const img = loader.getImage("monster"); - if (!img) return; - const tw = img.width; - const th = img.height; + if (!loader.getImage("monster")) return; const names = [ "Original", @@ -75,18 +81,26 @@ const createGame = async () => { "Invert", "Sepia", "Hologram", + "Shine", + "Squish", + "Color Matrix", + "Bloom", ]; const cols = 4; const rows = Math.ceil(names.length / cols); - const cellW = 1218 / cols; - const cellH = 562 / rows; + const cellW = WIDTH / cols; + const cellH = HEIGHT / rows; let flashEffect: FlashEffect | null = null; let glowEffect: GlowEffect | null = null; let dissolveEffect: DissolveEffect | null = null; let pulseEffect: TintPulseEffect | null = null; let waveEffect: WaveEffect | null = null; let hologramEffect: HologramEffect | null = null; + let shineEffect: ShineEffect | null = null; + let squishEffect: SquishEffect | null = null; + let squishSprite: Sprite | null = null; + let matrixEffect: ColorMatrixEffect | null = null; for (let i = 0; i < names.length; i++) { const col = i % cols; @@ -95,7 +109,7 @@ const createGame = async () => { const cy = cellH * row + cellH / 2; const sprite = new Sprite(cx, cy - 10, { image: "monster" }); - sprite.scale(0.25); + sprite.scale(SPRITE_SCALE); app.world.addChild(sprite); // apply effect based on index @@ -109,14 +123,12 @@ const createGame = async () => { color: [1, 1, 0], width: 2, }); - (sprite.shader as OutlineEffect).setTextureSize(tw, th); break; case 3: glowEffect = new GlowEffect(r, { color: [0.3, 0.6, 1], width: 10, intensity: 2.5, - textureSize: [tw, th], }); sprite.shader = glowEffect; break; @@ -126,19 +138,16 @@ const createGame = async () => { case 5: sprite.shader = new PixelateEffect(r, { size: 6, - textureSize: [tw, th], }); break; case 6: sprite.shader = new BlurEffect(r, { strength: 5, - textureSize: [tw, th], }); break; case 7: sprite.shader = new ChromaticAberrationEffect(r, { offset: 5, - textureSize: [tw, th], }); break; case 8: @@ -150,7 +159,6 @@ const createGame = async () => { offsetX: 6, offsetY: 6, opacity: 0.7, - textureSize: [tw, th], }); break; case 10: @@ -186,6 +194,30 @@ const createGame = async () => { hologramEffect = new HologramEffect(r); sprite.shader = hologramEffect; break; + case 16: + shineEffect = new ShineEffect(r, { + color: [1.0, 0.95, 0.7], + width: 0.18, + intensity: 0.7, + }); + sprite.shader = shineEffect; + break; + case 17: + squishEffect = new SquishEffect(r); + squishSprite = sprite; + sprite.shader = squishEffect; + break; + case 18: + matrixEffect = new ColorMatrixEffect(r); + sprite.shader = matrixEffect; + break; + case 19: + sprite.shader = new BloomEffect(r, { + threshold: 0.3, + intensity: 2.2, + radius: 6, + }); + break; } app.world.addChild( @@ -232,6 +264,26 @@ const createGame = async () => { if (hologramEffect) { hologramEffect.setTime(t); } + if (shineEffect) { + shineEffect.setTime(t); + } + if (squishEffect && squishSprite) { + // squash and stretch about the feet, keeping the volume: a + // taller sprite pinches in, a shorter one bulges out + const scaleY = 1 + 0.2 * Math.sin(t * 4); + const feet = squishSprite.height * (1 - squishSprite.anchorPoint.y); + squishSprite.currentTransform + .identity() + .scale(SPRITE_SCALE) + .translate(0, feet) + .scale(1, scaleY) + .translate(0, -feet); + squishEffect.setBulge(1 - scaleY); + } + if (matrixEffect) { + // the hue turns around the color wheel + matrixEffect.reset().hueRotate(t); + } }); } } diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 0030b9c3e4..fc416c190a 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -622,7 +622,7 @@ const examples: { path: "shader-effects", sourceDir: "shaderEffects", description: - "Showcase of all 16 built-in ShaderEffect presets for per-sprite visual effects (WebGL).", + "Showcase of the built-in ShaderEffect presets for per-sprite visual effects.", }, { component: , diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 322208d36a..a9480cb7dd 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -36,6 +36,7 @@ - Physics: the builtin `raycast` measures a body from the frame its renderable draws in, as collisions have since 20.7. On a body whose anchor is not its top left corner, such as a `Sprite`'s default centre, a ray hit half the body's size away from where it collides, or missed it - Camera: a zoomed camera follows its target, stays within its bounds and centres on `focusOn` in world units, and `localToWorld` and `worldToLocal` take its zoom and its place on the screen (`screenX`, `screenY`) into account. Zoomed out, it kept the target off centre and scrolled past its bounds, zoomed in it stopped short of them, and a pointer on a zoomed camera, or on one drawn in a corner of the screen, hit the world somewhere else than it pointed - Effects: a glow, outline, blur, drop shadow, chromatic aberration, wave, squish, pixelate or bloom on a sprite cut from an atlas or a tileset keeps to the sprite's own frame. Along the frame's edges it picked up the neighbouring frames, and drew a glow or a line from pixels that are not part of the sprite +- Effects: a glow, outline, blur, drop shadow, chromatic aberration, pixelate or bloom measures its width, offset or size in pixels of the texture it draws, as documented, with no `textureSize` to pass: a sprite's texture, the screen for a camera's effect, a mesh's texture. Each assumed a 256x256 texture (the screen for an outline or a bloom), so on any other texture it came out thinner or thicker than asked; a game that tuned its widths around that should check them, and an effect given a `textureSize` keeps it - Tiled: a tile layer drawn with smooth filtering, scaled or off the pixel grid, shows no seams between its tiles on WebGL and WebGPU. Along each tile's edge, filtering blended in the next tile of the tileset, or the spacing around it - Tiled: a pre-rendered tile layer holds every tile it draws, on hexagonal, staggered and oblique maps and with tiles larger than their cell or offset from it, and shows the tiles `setTile` and `clearTile` change. It was baked at an orthogonal layer's size, which cut off the last column of a hexagonal map, what an oblique map draws left of or above its origin and the parts of large tiles reaching past the edge; `setTile` left it as it was, and `clearTile` erased a rectangle placed as on an orthogonal map, cutting into the tiles around it - Tiled: an orthogonal tile layer with an opacity below 1, or inside a faded container, is drawn faded on WebGL and WebGPU. Its colors were drawn at full strength over what lies behind it, as bright as an opaque layer or brighter, and the container's fade was ignored diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index 48949122b7..4d64cb74d1 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -136,8 +136,19 @@ pixel keep to the sprite's own frame.** `GlowEffect`, `OutlineEffect`, `SwayEffect`, `PixelateEffect` and `BloomEffect` read the neighbouring frames as transparent, as if the sprite were drawn on its own, so they never show along its edges and the atlas needs no padding for them. Past the texture's own -edge (a standalone image, or the outside of an atlas) nothing changes. Their `textureSize` is still the size of the whole atlas image: it is -what turns a width in texels into a distance in the texture. +edge (a standalone image, or the outside of an atlas) nothing changes. + +**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 +`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 +sprite.addPostEffect(new OutlineEffect(app.renderer, { width: 2 })); +``` `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 @@ -217,9 +228,8 @@ app.viewport.addPostEffect( ); ``` -Its tap offsets are in texels, so it takes its size from the renderer at -construction; call `setTextureSize(w, h)` from an `event.ONRESIZE` handler, or -when applying it to something smaller than the frame. +Its `radius` is in texels of what it filters, the screen on a camera, and it +follows a resize by itself. **`ToneMappingEffect` goes after it, and goes last.** Bloom spreads the bright parts; the tone map is what stops the sum of the two clipping flat at white, diff --git a/packages/melonjs/src/video/effects/bloom.js b/packages/melonjs/src/video/effects/bloom.js index 1a3ad68bab..5706de2e33 100644 --- a/packages/melonjs/src/video/effects/bloom.js +++ b/packages/melonjs/src/video/effects/bloom.js @@ -137,10 +137,8 @@ ${tapList("vec2f", (at) => { * Not to be confused with {@link GlowEffect}, which draws an outline OUTSIDE a * sprite's silhouette and does nothing at all on an opaque frame. * - * `textureSize` must match what is being filtered, since the tap offsets are - * in texels; it defaults to the renderer's own size, which is what a camera - * effect wants. Call {@link BloomEffect#setTextureSize} from an - * {@link event.ONRESIZE} handler, or after applying it to something smaller. + * 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. * @category Effects * @see {@link Renderable#addPostEffect} for usage * @example @@ -160,7 +158,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] - dimensions of what is being filtered, `[width, height]`; defaults to the renderer's size + * @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) */ constructor(renderer, options = {}) { super(renderer, { glsl: glslFragment, wgsl: wgslFragment }); @@ -168,18 +166,15 @@ export default class BloomEffect extends ShaderEffect { this.threshold = options.threshold ?? 0.75; this.intensity = options.intensity ?? 1.0; this.radius = options.radius ?? 2.0; - // A camera effect filters the frame, so the frame's size is the useful - // default. `BlurEffect` predates this and defaults to 256x256, which - // is why its own docs have to tell you to set it. - const texSize = options.textureSize ?? [ - renderer?.width ?? 256, - renderer?.height ?? 256, - ]; this.setUniform("uThreshold", this.threshold); this.setUniform("uIntensity", this.intensity); this.setUniform("uRadius", this.radius); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its reach is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [ + renderer?.width ?? 256, + renderer?.height ?? 256, + ]); } /** @@ -210,10 +205,10 @@ export default class BloomEffect extends ShaderEffect { } /** - * set the dimensions of what is being filtered. The tap offsets are in - * texels, so a stale size makes the halo the wrong width after a resize. - * @param {number} width - width in pixels - * @param {number} height - height in pixels + * Measure the effect's reach in the texels of a texture of this size, + * rather than of the texture it draws (the default) + * @param {number} width - texture width in pixels + * @param {number} height - texture height in pixels */ setTextureSize(width, height) { this.setUniform("uTextureSize", new Float32Array([width, height])); diff --git a/packages/melonjs/src/video/effects/blur.js b/packages/melonjs/src/video/effects/blur.js index ea35c5cb01..e7b4e68c90 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=[256, 256]] - texture dimensions [width, height] + * @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) */ constructor(renderer, options = {}) { super(renderer, { @@ -77,10 +77,10 @@ export default class BlurEffect extends ShaderEffect { }); this.strength = options.strength ?? 1.0; - const texSize = options.textureSize ?? [256, 256]; this.setUniform("uBlurStrength", this.strength); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its strength is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [256, 256]); } /** @@ -93,7 +93,8 @@ export default class BlurEffect extends ShaderEffect { } /** - * set the texture size + * Measure the effect's strength in the texels of a texture of this size, + * rather than of the texture it draws (the default) * @param {number} width - texture width in pixels * @param {number} height - texture height in pixels */ diff --git a/packages/melonjs/src/video/effects/chromaticAberration.js b/packages/melonjs/src/video/effects/chromaticAberration.js index 3bef33f75c..a23ff02dcc 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=[256, 256]] - texture dimensions [width, height] + * @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) */ constructor(renderer, options = {}) { super(renderer, { @@ -66,10 +66,10 @@ export default class ChromaticAberrationEffect extends ShaderEffect { }); this.offset = options.offset ?? 3.0; - const texSize = options.textureSize ?? [256, 256]; this.setUniform("uOffset", this.offset); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its offset is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [256, 256]); } /** @@ -82,7 +82,8 @@ export default class ChromaticAberrationEffect extends ShaderEffect { } /** - * set the texture size + * Measure the effect's offset in the texels of a texture of this size, + * rather than of the texture it draws (the default) * @param {number} width - texture width in pixels * @param {number} height - texture height in pixels */ diff --git a/packages/melonjs/src/video/effects/dropShadow.js b/packages/melonjs/src/video/effects/dropShadow.js index f5ab1b6548..5fc47e5d98 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=[256, 256]] - texture dimensions [width, height] + * @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) */ constructor(renderer, options = {}) { super(renderer, { @@ -105,14 +105,14 @@ export default class DropShadowEffect extends ShaderEffect { wgsl: wgslFragment, }); - const texSize = options.textureSize ?? [256, 256]; this.setUniform( "uShadowOffset", new Float32Array([options.offsetX ?? 2.0, options.offsetY ?? 2.0]), ); this.setUniform("uShadowColor", rgba(options.color ?? [0.0, 0.0, 0.0])); this.setUniform("uShadowOpacity", options.opacity ?? 0.5); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its offset is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [256, 256]); // uv.y grows downward until a renderer path says otherwise this.setUniform("uUVYDir", 1.0); } @@ -135,7 +135,8 @@ export default class DropShadowEffect extends ShaderEffect { } /** - * set the texture size + * Measure the effect's offset in the texels of a texture of this size, + * rather than of the texture it draws (the default) * @param {number} width - texture width in pixels * @param {number} height - texture height in pixels */ diff --git a/packages/melonjs/src/video/effects/glow.js b/packages/melonjs/src/video/effects/glow.js index 5f0826e95a..05d0aa8626 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=[256, 256]] - texture dimensions [width, height] + * @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) */ constructor(renderer, options = {}) { super(renderer, { @@ -99,13 +99,13 @@ export default class GlowEffect extends ShaderEffect { const color = options.color ?? [1.0, 1.0, 1.0]; const width = options.width ?? 3.0; - const texSize = options.textureSize ?? [256, 256]; this.intensity = options.intensity ?? 1.0; this.setUniform("uGlowColor", rgba(color)); this.setUniform("uGlowWidth", width); this.setUniform("uGlowIntensity", this.intensity); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its spread is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [256, 256]); } /** @@ -126,7 +126,8 @@ export default class GlowEffect extends ShaderEffect { } /** - * set the texture size + * Measure the effect's spread in the texels of a texture of this size, + * rather than of the texture it draws (the default) * @param {number} width - texture width in pixels * @param {number} height - texture height in pixels */ diff --git a/packages/melonjs/src/video/effects/outline.js b/packages/melonjs/src/video/effects/outline.js index a600e26d9d..a0c9a26a9f 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] - texture dimensions [width, height] (defaults to renderer size) + * @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) */ constructor(renderer, options = {}) { super(renderer, { @@ -106,11 +106,14 @@ export default class OutlineEffect extends ShaderEffect { const color = options.color ?? [1.0, 1.0, 1.0]; const width = options.width ?? 1.0; - const texSize = options.textureSize ?? [renderer.width, renderer.height]; this.setUniform("uOutlineColor", rgba(color)); this.setUniform("uOutlineWidth", width); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its width is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [ + renderer.width, + renderer.height, + ]); } /** @@ -130,7 +133,8 @@ export default class OutlineEffect extends ShaderEffect { } /** - * set the texture size for accurate outline width calculation + * Measure the effect's width in the texels of a texture of this size, + * rather than of the texture it draws (the default) * @param {number} width - texture width in pixels * @param {number} height - texture height in pixels */ diff --git a/packages/melonjs/src/video/effects/pixelate.js b/packages/melonjs/src/video/effects/pixelate.js index 4e0a803784..ed069dd6b8 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=[256, 256]] - texture dimensions [width, height] + * @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) */ constructor(renderer, options = {}) { super(renderer, { @@ -59,10 +59,10 @@ export default class PixelateEffect extends ShaderEffect { }); this.size = options.size ?? 4.0; - const texSize = options.textureSize ?? [256, 256]; this.setUniform("uPixelSize", this.size); - this.setUniform("uTextureSize", new Float32Array(texSize)); + // its pixel size is in texels: of the texture it draws, unless given one + this._initTextureSize(options.textureSize, [256, 256]); } /** @@ -75,7 +75,8 @@ export default class PixelateEffect extends ShaderEffect { } /** - * set the texture size + * Measure the effect's pixel size in the texels of a texture of this size, + * rather than of the texture it draws (the default) * @param {number} width - texture width in pixels * @param {number} height - texture height in pixels */ diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index 47d6f4d993..fce6d38d0d 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -493,6 +493,22 @@ export default class ShaderEffect { if (this.destroyed === true) { return; } + // a size the game gives replaces the one taken from the texture drawn + // (see `_initTextureSize`) + if (name === "uTextureSize") { + this._sourceSize = undefined; + } + this._applyUniform(name, value); + } + + /** + * `setUniform` without its side effects: record the value and apply it + * @param {string} name - the uniform's name + * @param {*} value - its value + * @ignore + * @internal + */ + _applyUniform(name, value) { // Recorded as well as applied. The GLSL branch writes straight into // its own compiled program, which is all a quad ever needs — but a // mesh hosts this effect as a DIFFERENT, spliced program, and that one @@ -632,17 +648,76 @@ export default class ShaderEffect { 0.5 / Math.max(sourceWidth, 1), 0.5 / Math.max(sourceHeight, 1), ); + this._setSourceSize(sourceWidth, sourceHeight); } /** * 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. + * @param {number} [width] - the texture's width, in pixels + * @param {number} [height] - the texture's height, in pixels * @ignore * @internal */ - _clearFrameRect() { + _clearFrameRect(width, height) { this._writeFrameHalf(0, 0); + if (width !== undefined && height !== undefined) { + this._setSourceSize(width, height); + } + } + + /** + * Make the body's `uTextureSize` the size of the texture it draws: a + * built-in effect whose offsets are in texels (a glow, an outline, a + * blur...) calls it from its constructor. With `size` (the effect's + * `textureSize` option), that size is used instead, as it is once the + * game sets one later (`setTextureSize()`, or + * `setUniform("uTextureSize")`). A body of the game's own is never + * given one. + * @param {number[]} [size] - the size the game gave, `[width, height]` + * @param {number[]} fallback - the size until the effect first draws + * @ignore + * @internal + */ + _initTextureSize(size, fallback) { + if (size !== undefined) { + this.setUniform("uTextureSize", new Float32Array(size)); + return; + } + /** + * the size of the texture drawn, given to the body as + * `uTextureSize`; undefined once the game sets the size itself + * @ignore + * @internal + */ + this._sourceSize = new Float32Array(fallback); + this._applyUniform("uTextureSize", this._sourceSize); + } + + /** + * Hand the size of the texture about to draw to an effect that takes it + * (see `_initTextureSize`): the sprite's texture, the render target a + * pass reads, the mesh's texture. Writes only when it changes, so the + * sprites of one texture keep sharing a draw. + * @param {number} width - the texture's width, in pixels + * @param {number} height - the texture's height, in pixels + * @ignore + * @internal + */ + _setSourceSize(width, height) { + const size = this._sourceSize; + if (size === undefined || this.destroyed === true) { + return; + } + width = Math.max(width, 1); + height = Math.max(height, 1); + if (size[0] === width && size[1] === height) { + return; + } + size[0] = width; + size[1] = height; + this._applyUniform("uTextureSize", size); } /** @@ -1073,6 +1148,7 @@ export default class ShaderEffect { for (const [name, entry] of this._extraTextures) { copy.setTexture(name, entry.image, entry.repeat); } + copy._copyTextureSize(this); return copy; } // Canvas-mode effects are inert stubs — nothing further to copy @@ -1098,10 +1174,25 @@ export default class ShaderEffect { for (const [name, entry] of this._extraTextures) { copy.setTexture(name, entry.image, entry.repeat); } + copy._copyTextureSize(this); } return copy; } + /** + * a clone takes the size of the texture it draws as its original does + * (see `_initTextureSize`) + * @param {ShaderEffect} source - the effect cloned + * @ignore + * @internal + */ + _copyTextureSize(source) { + if (source._sourceSize !== undefined) { + this._sourceSize = new Float32Array(source._sourceSize); + this._applyUniform("uTextureSize", this._sourceSize); + } + } + // the GL-program pass-throughs below additionally guard on `_shader`: // a WGSL-realized effect is `enabled` without ever owning a GL program 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 ce4df71498..8c8dd70c2d 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -713,8 +713,9 @@ export default class LitQuadBatcher extends QuadBatcher { // `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 - shader._clearFrameRect?.(); + // 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); // 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 37665d48dd..1f4767a0a2 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -264,8 +264,9 @@ export default class QuadBatcher extends MaterialBatcher { // `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 - shader._clearFrameRect?.(); + // 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); // 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 e9d472b586..d65893c281 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -2981,6 +2981,15 @@ export default class WebGLRenderer extends Renderer { : null; let hostedShader = spliced !== null && spliced !== undefined; if (hostedShader) { + // an effect measuring in texels counts the mesh's (before binding + // the spliced program: this writes the effect's own) + const image = mesh.texture?.getTexture(); + if (image !== undefined) { + this.customShader._setSourceSize?.( + image.width || image.videoWidth || 1, + image.height || image.videoHeight || 1, + ); + } this.currentBatcher.useShader(spliced); this.currentBatcher.replayEffectUniforms(this.customShader, spliced); } else if (this.customShader != null && this.customShader.program != null) { diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index 5bae279472..fe2fd52b05 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -566,8 +566,9 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // identity noise_uv rect for the fullscreen quad (GL parity), BEFORE // the uniform snapshot below effect?._setNoiseUVRect?.(w, h, w, h, 0, 0); - // and the whole texture is the frame: sampling stays unbounded - effect?._clearFrameRect?.(); + // 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); const binding = effect ? prepareEffectBinding(renderer, effect) : null; const pass = renderer.ensurePass(); diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index d8a694aa90..c11721710b 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -2720,6 +2720,16 @@ export default class WebGPURenderer extends Renderer { ); } batcher.hostedEffect = instanced ? null : hostedEffect; + // an effect measuring in texels counts the mesh's + if (batcher.hostedEffect !== null) { + const image = mesh.texture?.getTexture(); + if (image !== undefined) { + hostedEffect._setSourceSize?.( + image.width || image.videoWidth || 1, + image.height || image.videoHeight || 1, + ); + } + } // per-mesh culling is a pipeline axis here. Retained geometry keeps // its authored winding, so the axis bridge's reflection (which diff --git a/packages/melonjs/tests/effect-texture-size.spec.js b/packages/melonjs/tests/effect-texture-size.spec.js new file mode 100644 index 0000000000..66c32b156b --- /dev/null +++ b/packages/melonjs/tests/effect-texture-size.spec.js @@ -0,0 +1,594 @@ +/** + * The effects whose widths, offsets and sizes are in texels (glow, outline, + * blur, drop shadow, chromatic aberration, pixelate, bloom) take the size of + * the texture they draw, as their `uTextureSize`: the sprite's texture, the + * render target a camera's pass reads, the mesh's texture. They assumed a + * 256x256 texture (the screen for an outline or a bloom), so on any other + * one a width came out thinner or thicker than asked. + * + * A size the game gives (the `textureSize` option, `setTextureSize()`, + * `setUniform("uTextureSize")`) is kept, and a ShaderEffect of the game's own + * that declares a `uTextureSize` is never given one. + */ +import "./helpers/webgpu-globals.js"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + BloomEffect, + BlurEffect, + boot, + ChromaticAberrationEffect, + Color, + DropShadowEffect, + GlowEffect, + Mesh, + OutlineEffect, + PixelateEffect, + Renderable, + ShaderEffect, + state, + video, +} from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; +import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js"; +import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +const EFFECTS = [ + [ + "GlowEffect", + (r, o) => { + return new GlowEffect(r, o); + }, + ], + [ + "OutlineEffect", + (r, o) => { + return new OutlineEffect(r, o); + }, + ], + [ + "BlurEffect", + (r, o) => { + return new BlurEffect(r, o); + }, + ], + [ + "DropShadowEffect", + (r, o) => { + return new DropShadowEffect(r, o); + }, + ], + [ + "ChromaticAberrationEffect", + (r, o) => { + return new ChromaticAberrationEffect(r, o); + }, + ], + [ + "PixelateEffect", + (r, o) => { + return new PixelateEffect(r, o); + }, + ], + [ + "BloomEffect", + (r, o) => { + return new BloomEffect(r, o); + }, + ], +]; + +// an image `w` x `h`, transparent, with an opaque red square of `side` +// pixels at (x, y) +const image = (w, h, x = 0, y = 0, side = 0) => { + const canvas = Renderer.createCanvas(w, h); + if (side > 0) { + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(x, y, side, side); + } + return canvas; +}; + +describe("effects measure in the texels of the texture they draw (WebGL)", () => { + const SIZE = 160; + let renderer; + let drawCalls = 0; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // no WebGL here: the tests below mark themselves skipped + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + const gl = renderer.gl; + const drawElements = gl.drawElements.bind(gl); + gl.drawElements = (...args) => { + drawCalls++; + return drawElements(...args); + }; + } + }); + + afterEach(() => { + if (renderer) { + renderer.customShader = undefined; + renderer.flush(); + } + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + // the `uTextureSize` a GL program holds + const programSize = (program) => { + const gl = renderer.gl; + return Array.from( + gl.getUniform(program, gl.getUniformLocation(program, "uTextureSize")), + ); + }; + + // draw the whole of each image 1:1 through `effect`, over black + const draw = (effect, items) => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = effect; + for (const [img, x, y] of items) { + renderer.drawImage( + img, + 0, + 0, + img.width, + img.height, + x, + y, + img.width, + img.height, + ); + } + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + }; + + const isWhite = (x, y) => { + const gl = renderer.gl; + const out = new Uint8Array(4); + gl.readPixels(x, SIZE - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + return out[0] > 200 && out[1] > 200 && out[2] > 200; + }; + + // how many white pixels in a row from (x, y), stepping by (dx, dy) + const run = (x, y, dx, dy) => { + let n = 0; + while (n < 20 && isWhite(x + dx * n, y + dy * n)) { + n++; + } + return n; + }; + + // a 96 x 48 texture with a 16 px square at (40, 16), and a 48 x 96 one + // with it at (16, 40): neither 256 x 256 + const WIDE = () => { + return image(96, 48, 40, 16, 16); + }; + const TALL = () => { + return image(48, 96, 16, 40, 16); + }; + + it.each(EFFECTS)( + "%s takes the size of each texture it draws", + (_name, make, ctx) => { + requireWebGL(ctx); + const effect = make(renderer); + try { + draw(effect, [[WIDE(), 0, 0]]); + expect(programSize(effect._shader.program)).toEqual([96, 48]); + draw(effect, [[TALL(), 0, 0]]); + expect(programSize(effect._shader.program)).toEqual([48, 96]); + // and keeps the record a mesh hosting it replays + expect(Array.from(effect._uniformValues.get("uTextureSize"))).toEqual([ + 48, 96, + ]); + } finally { + effect.destroy(); + } + }, + ); + + it("an outline is as many pixels wide as asked, on any texture", (ctx) => { + requireWebGL(ctx); + const outline = new OutlineEffect(renderer, { color: [1, 1, 1], width: 3 }); + try { + draw(outline, [[WIDE(), 8, 8]]); + // the square covers x 48 to 63 and y 24 to 39 on the screen + expect(run(64, 32, 1, 0)).toBe(3); + expect(run(47, 32, -1, 0)).toBe(3); + expect(run(56, 40, 0, 1)).toBe(3); + expect(run(56, 23, 0, -1)).toBe(3); + } finally { + outline.destroy(); + } + }); + + it("sprites from two textures, one after the other: each its own width", (ctx) => { + requireWebGL(ctx); + const outline = new OutlineEffect(renderer, { color: [1, 1, 1], width: 3 }); + try { + draw(outline, [ + [WIDE(), 8, 8], + [TALL(), 100, 40], + ]); + // the wide one's square: x 48 to 63, y 24 to 39 + expect(run(64, 32, 1, 0)).toBe(3); + expect(run(56, 40, 0, 1)).toBe(3); + // the tall one's: x 116 to 131, y 80 to 95 + expect(run(132, 88, 1, 0)).toBe(3); + expect(run(124, 96, 0, 1)).toBe(3); + } finally { + outline.destroy(); + } + }); + + it("sprites from one texture keep sharing a draw", (ctx) => { + requireWebGL(ctx); + const outline = new OutlineEffect(renderer, { color: [1, 1, 1], width: 3 }); + const wide = WIDE(); + try { + // once, so the size is already the texture's + draw(outline, [[wide, 0, 0]]); + drawCalls = 0; + draw(outline, [ + [wide, 0, 0], + [wide, 0, 60], + [wide, 60, 100], + ]); + expect(drawCalls).toBe(1); + } finally { + outline.destroy(); + } + }); + + it("keeps a textureSize the game gives", (ctx) => { + requireWebGL(ctx); + const outline = new OutlineEffect(renderer, { + color: [1, 1, 1], + width: 3, + textureSize: [256, 256], + }); + try { + draw(outline, [[WIDE(), 8, 8]]); + expect(programSize(outline._shader.program)).toEqual([256, 256]); + // 3 texels of a 256 wide texture are about one of this 96 wide one + expect(run(64, 32, 1, 0)).toBe(1); + } finally { + outline.destroy(); + } + }); + + it("keeps a size set with setTextureSize() or setUniform()", (ctx) => { + requireWebGL(ctx); + const glow = new GlowEffect(renderer); + const blur = new BlurEffect(renderer); + try { + draw(glow, [[WIDE(), 0, 0]]); + expect(programSize(glow._shader.program)).toEqual([96, 48]); + glow.setTextureSize(200, 100); + draw(glow, [[TALL(), 0, 0]]); + expect(programSize(glow._shader.program)).toEqual([200, 100]); + + blur.setUniform("uTextureSize", [50, 70]); + draw(blur, [[TALL(), 0, 0]]); + expect(programSize(blur._shader.program)).toEqual([50, 70]); + } finally { + glow.destroy(); + blur.destroy(); + } + }); + + it("never gives one to an effect of the game's own", (ctx) => { + requireWebGL(ctx); + const own = new ShaderEffect( + renderer, + ` + uniform vec2 uTextureSize; + vec4 apply(vec4 color, vec2 uv) { + return color * step(1.0, uTextureSize.x); + } + `, + ); + try { + own.setUniform("uTextureSize", [7, 9]); + draw(own, [[WIDE(), 0, 0]]); + expect(programSize(own._shader.program)).toEqual([7, 9]); + } finally { + own.destroy(); + } + }); + + it("a clone takes the size of what it draws, or keeps the one given", (ctx) => { + requireWebGL(ctx); + const glow = new GlowEffect(renderer); + const fixed = new GlowEffect(renderer, { textureSize: [300, 30] }); + const copy = glow.clone(); + const fixedCopy = fixed.clone(); + try { + draw(copy, [[WIDE(), 0, 0]]); + expect(programSize(copy._shader.program)).toEqual([96, 48]); + draw(fixedCopy, [[WIDE(), 0, 0]]); + expect(programSize(fixedCopy._shader.program)).toEqual([300, 30]); + } finally { + for (const effect of [glow, fixed, copy, fixedCopy]) { + effect.destroy(); + } + } + }); + + it("on a mesh, takes the size of the mesh's texture", (ctx) => { + requireWebGL(ctx); + const outline = new OutlineEffect(renderer, { color: [1, 1, 1] }); + const mesh = new Mesh(0, 0, { + vertices: [-8, -8, 0, 8, -8, 0, 8, 8, 0, -8, 8, 0], + uvs: [0, 0, 1, 0, 1, 1, 0, 1], + indices: [0, 1, 2, 0, 2, 3], + width: 16, + height: 16, + texture: image(32, 16, 0, 0, 8), + }); + try { + renderer.customShader = outline; + renderer.drawMesh(mesh); + const spliced = renderer.currentBatcher.effectShaderFor(outline); + expect(spliced).not.toBeNull(); + expect(programSize(spliced.program)).toEqual([32, 16]); + renderer.customShader = undefined; + // back on a sprite, the sprite's texture again + draw(outline, [[WIDE(), 0, 0]]); + expect(programSize(outline._shader.program)).toEqual([96, 48]); + } finally { + renderer.customShader = undefined; + outline.destroy(); + mesh.destroy(); + } + }); +}); + +describe("a camera's effect measures in the screen's texels (WebGL)", () => { + let app; + + /** a horizontal ramp over the whole view, one shade per pixel column */ + class Ramp extends Renderable { + constructor() { + super(0, 0, 100, 100); + this.anchorPoint.set(0, 0); + } + draw(renderer) { + for (let x = 0; x < 100; x++) { + renderer.setColor(new Color(x * 2, x * 2, x * 2)); + renderer.fillRect(x, 0, 1, 100); + } + } + } + + beforeAll(async () => { + boot(); + app = new Application(100, 100, { + parent: "screen", + renderer: video.WEBGL, + scale: "1.0", + antiAlias: false, + failIfMajorPerformanceCaveat: false, + }); + await app.init(); + }); + afterEach(() => { + app.world.reset(); + }); + afterAll(() => { + return app?.destroy(); + }); + + const frame = () => { + app.world.update(0); + app.repaint(); + app.draw(); + }; + + const programSize = (effect) => { + const gl = app.renderer.gl; + const program = effect._shader.program; + return Array.from( + gl.getUniform(program, gl.getUniformLocation(program, "uTextureSize")), + ); + }; + + it("pixelates in blocks of screen pixels", () => { + const camera = state.current().cameras.get("default"); + const pixelate = new PixelateEffect(app.renderer, { size: 10 }); + camera.addPostEffect(pixelate); + try { + app.world.addChild(new Ramp(), 0); + frame(); + const canvas = app.renderer.getCanvas(); + expect(programSize(pixelate)).toEqual([canvas.width, canvas.height]); + const gl = app.renderer.gl; + const shade = (x) => { + const out = new Uint8Array(4); + gl.readPixels(x, 50, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + return out[0]; + }; + // one block covers columns 10 to 19, the next starts at 20 + expect(shade(11)).toBe(shade(18)); + expect(shade(21)).not.toBe(shade(18)); + } finally { + camera.removePostEffect(pixelate); + } + }); +}); + +describe("effects measure in the texels of the texture they draw (WebGPU, mock renderer)", () => { + const quiet = (make) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + return make(); + } finally { + warn.mockRestore(); + } + }; + const sizeOf = (effect) => { + return Array.from(effect.wgslRealization.values.get("uTextureSize")); + }; + const texture = (width, height) => { + return { + getTexture() { + return { width, height }; + }, + }; + }; + + it.each(EFFECTS)( + "%s takes the size of each texture it draws", + (_name, make) => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + const effect = quiet(() => { + return make({ shaderLanguage: "wgsl" }); + }); + renderer.customShader = effect; + batcher.addQuad(texture(96, 48), 0, 0, 16, 16, 0, 0, 1, 1, 0xffffffff); + expect(sizeOf(effect)).toEqual([96, 48]); + batcher.addQuad(texture(40, 120), 0, 0, 16, 16, 0, 0, 1, 1, 0xffffffff); + expect(sizeOf(effect)).toEqual([40, 120]); + }, + ); + + it("the sprites queued under one size draw with it", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + const glow = quiet(() => { + return new GlowEffect({ shaderLanguage: "wgsl" }); + }); + renderer.customShader = glow; + const atlas = texture(96, 48); + batcher.addQuad(atlas, 0, 0, 16, 16, 0, 0, 0.5, 1, 0xffffffff); + batcher.addQuad(atlas, 20, 0, 16, 16, 0.5, 0, 1, 1, 0xffffffff); + // one texture: still queued together + expect(renderer.calls.drawIndexed).toEqual([]); + batcher.addQuad(texture(40, 120), 40, 0, 16, 16, 0, 0, 1, 1, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([12]); + // the two queued drew with their own size: `uTextureSize` follows the + // colour (4 floats) and the two scalars, at float 6 + const write = renderer.calls.writes.find((w) => { + return w.floats.length >= 8 && w.floats[6] === 96; + }); + expect(write).toBeDefined(); + expect(write.floats[7]).toBe(48); + }); + + it("a pass over a render target takes its size", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + const bloom = quiet(() => { + return new BloomEffect({ shaderLanguage: "wgsl" }); + }); + const source = { + width: 300, + height: 200, + getMaterialBindGroup() { + return { rt: true }; + }, + }; + batcher.blitTexture(source, 0, 0, 300, 200, bloom, false); + expect(sizeOf(bloom)).toEqual([300, 200]); + // a source with no size of its own: the size of the quad + const bare = { + getMaterialBindGroup() { + return { rt: true }; + }, + }; + batcher.blitTexture(bare, 0, 0, 120, 90, bloom, false); + expect(sizeOf(bloom)).toEqual([120, 90]); + }); + + it("keeps a textureSize the game gives", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + const outline = quiet(() => { + return new OutlineEffect( + { shaderLanguage: "wgsl" }, + { textureSize: [256, 256] }, + ); + }); + renderer.customShader = outline; + batcher.addQuad(texture(96, 48), 0, 0, 16, 16, 0, 0, 1, 1, 0xffffffff); + expect(sizeOf(outline)).toEqual([256, 256]); + }); + + it("on a mesh, takes the size of the mesh's texture", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUMeshBatcher(renderer); + const stub = { + batchers: new Map([["mesh", batcher]]), + currentBatcher: batcher, + setBatcher(name) { + return this.batchers.get(name); + }, + currentTint: new Color(255, 255, 255, 1), + getGlobalAlpha() { + return 1; + }, + customShader: undefined, + meshDepthActive: false, + meshEffectWarned: false, + pendingDepthClear: false, + }; + // the one of the seven a WGSL mesh can host: the others read the + // sprite's vertex colour + const bloom = quiet(() => { + return new BloomEffect({ shaderLanguage: "wgsl" }); + }); + stub.customShader = bloom; + WebGPURenderer.prototype.drawMesh.call(stub, { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + vertexCount: 4, + texture: { id: "atlas", ...texture(32, 16) }, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + lit: false, + cullBackFaces: false, + rightHanded: false, + }); + // hosted, not refused + expect(stub.meshEffectWarned).toBe(false); + expect(sizeOf(bloom)).toEqual([32, 16]); + }); +}); diff --git a/packages/melonjs/tests/webgpu_warm_keys.spec.js b/packages/melonjs/tests/webgpu_warm_keys.spec.js index 0ab0a19379..ff4dc1199b 100644 --- a/packages/melonjs/tests/webgpu_warm_keys.spec.js +++ b/packages/melonjs/tests/webgpu_warm_keys.spec.js @@ -169,7 +169,13 @@ function makeMesh(overrides = {}) { _indicesOriginal: new Uint16Array([0, 1, 2, 0, 2, 3]), _geometryVersion: 0, vertexCount: 4, - texture: { id: "atlas" }, + // a mesh's texture is a TextureAtlas, which hands out its image + texture: { + id: "atlas", + getTexture() { + return { width: 64, height: 64 }; + }, + }, alphaCutoff: 0, lit: false, cullBackFaces: true,