From 2ddb5797abbc7ae3ec03b16cd75d19f3fa90a879 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 20 Oct 2026 07:57:22 +0800 Subject: [PATCH] Effects: add SquishEffect, a squash and stretch shader SquishEffect bulges a sprite's middle out, or pinches it in, while its top and bottom rows stay where they are. Paired with a change of height (bulge = 1 - scaleY, the height scaled about the feet) it gives squash and stretch that keeps the sprite's volume. A bulge widens the sprite past its own frame, and a ShaderEffect body has no vertex stage: the quad batchers transform corners on the CPU and the GPU vertex shader only projects. So the vertex step runs in the batchers. An effect sets `_vertexScale` (internal, 1 by default), the batchers scale its quad about the centre with UVs unchanged (`scaleQuad`), and the fragment narrows the texture back (`p.x *= uSquishXFactor`), the same split as a shader that scales VERTEX in a vertex function. The scale is 1 + bulge while bulging out and 1 otherwise, so the effect costs no extra fill at rest and every other effect is untouched. The fragment returns transparent outside the frame, so neighbouring atlas frames never bleed in. Tween: a tween stopped while the game updates no longer runs one more step and fires onComplete in the same frame. The game loop is itself a TICK listener and the emitter walks a snapshot of its listeners, so a tween stopped from an update() was still called for that tick. Replacing the jump stretch with the landing squash hit it: the old tween finished one frame later and snapped the player back to full height. `_onTick` now ignores a tween that is not running; the emitter is unchanged. Both platformer examples (built-in physics and Matter) stretch on take-off and squash on landing, eased back with Back.Out. The stretch is applied from update() because the Matter adapter rewrites currentTransform on every step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- .../platformer-matter/entities/player.ts | 79 ++++- .../examples/platformer/entities/player.ts | 77 ++++- packages/melonjs/CHANGELOG.md | 4 + .../melonjs-effects-and-shaders/SKILL.md | 41 ++- packages/melonjs/src/index.ts | 2 + packages/melonjs/src/tweens/tween.ts | 6 + .../melonjs/src/video/effects/shadereffect.js | 15 + packages/melonjs/src/video/effects/squish.js | 189 ++++++++++ packages/melonjs/src/video/gpu/quadcorners.ts | 34 ++ .../video/webgl/batchers/lit_quad_batcher.js | 11 +- .../src/video/webgl/batchers/quad_batcher.js | 12 +- .../src/video/webgpu/batchers/quad_batcher.js | 12 +- packages/melonjs/tests/squish-effect.spec.js | 326 ++++++++++++++++++ packages/melonjs/tests/tween.spec.ts | 49 +++ .../tests/webgpu_effects_validate.spec.js | 2 + 15 files changed, 849 insertions(+), 10 deletions(-) create mode 100644 packages/melonjs/src/video/effects/squish.js create mode 100644 packages/melonjs/tests/squish-effect.spec.js diff --git a/packages/examples/src/examples/platformer-matter/entities/player.ts b/packages/examples/src/examples/platformer-matter/entities/player.ts index a86ece9b62..8e7e39a94b 100644 --- a/packages/examples/src/examples/platformer-matter/entities/player.ts +++ b/packages/examples/src/examples/platformer-matter/entities/player.ts @@ -14,6 +14,8 @@ import { Rect, type Renderable, Sprite, + SquishEffect, + Tween, Vector2d, } from "melonjs"; import { LAND_TOLERANCE, oneWayPlatforms } from "../createGame"; @@ -37,11 +39,22 @@ const WALK_FORCE = 0.012; // higher jump because matter's gravity decelerates more slowly than the // legacy fixed-step integrator on the way up. const JUMP_VEL = 18; +// landing faster than this is worth a squash +const LANDING_SPEED = 3; +// the most a landing squashes the player, at full falling speed +const MAX_SQUASH = 0.25; export class PlayerEntity extends Sprite { dying: boolean; jumping: boolean; multipleJump: number; + squish: SquishEffect | undefined; + // the height the player is stretched (above 1) or squashed (below 1) to + stretch = { scaleY: 1 }; + stretchTween: Tween | undefined; + // last frame's state, to tell a landing from standing + wasGrounded = true; + lastVelY = 0; // scratch vectors reused every frame. `body.applyForce(x, y)` and // `body.setVelocity(x, y)` take primitives so no scratch is needed // for those; only `body.getVelocity(out)` and `adapter.setPosition(v)` @@ -200,6 +213,44 @@ export class PlayerEntity extends Sprite { this.adapter = app.world.adapter; // set the viewport to follow this renderable on both axis, and enable damping app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1); + // the bulge that keeps a stretched or squashed player's volume is a + // shader effect, so on the GPU renderers only + if (this.squish === undefined && app.renderer.shaderLanguage !== null) { + this.squish = new SquishEffect(app.renderer); + this.addPostEffect(this.squish); + } + } + + /** + * stretch (above 1) or squash (below 1) the player to `scaleY` of its + * height, then ease back to rest with a single small overshoot + */ + stretchTo(scaleY: number) { + this.stretchTween?.stop(); + this.stretch.scaleY = scaleY; + this.stretchTween = new Tween(this.stretch) + .to({ scaleY: 1 }, { duration: 300, easing: Tween.Easing.Back.Out }) + .onComplete(() => { + this.stretchTween = undefined; + }) + .start(); + } + + /** + * scale the player's height about its feet, and bulge it the other way + * so it keeps its volume: pinched in when stretched, bulging out when + * squashed. Called from update(): the adapter rewrites the transform + * when it syncs the body, which happens before. + */ + applyStretch(scaleY: number) { + const feet = this.height * (1 - this.anchorPoint.y); + this.currentTransform + .identity() + .translate(0, feet) + .scale(1, scaleY) + .translate(0, -feet); + this.updateBounds(); + this.squish?.setBulge(1 - scaleY); } /** @@ -256,14 +307,18 @@ export class PlayerEntity extends Sprite { // (one true per press). Each press fires one full-strength impulse; // no hold-to-extend mechanic so behaviour matches the legacy SAT // platformer 1:1. + let jumped = false; if (input.isKeyPressed("jump")) { if (this.multipleJump <= 2) { + jumped = true; this.setCurrentAnimation("jump"); this.jumping = true; body.setVelocity(vel.x, -JUMP_VEL); this.multipleJump++; audio.stop("jump"); audio.play("jump", false); + // stretch on take-off + this.stretchTo(1.25); } } else if (grounded) { this.multipleJump = 1; @@ -287,6 +342,20 @@ export class PlayerEntity extends Sprite { this.setCurrentAnimation("stand"); } + // squash on landing, harder the faster we came down; a jump on the + // frame we land wins over the squash + if ( + !jumped && + grounded && + !this.wasGrounded && + this.lastVelY > LANDING_SPEED + ) { + this.stretchTo(1 - MAX_SQUASH * Math.min(this.lastVelY / MAX_VEL_Y, 1)); + } + this.wasGrounded = grounded; + this.lastVelY = vel.y; + this.applyStretch(this.stretch.scaleY); + // check if we fell into a hole if (!this.inViewport && this.getBounds().top > game.renderer.height) { const app = this.parentApp; @@ -300,8 +369,14 @@ export class PlayerEntity extends Sprite { return true; } - // check if we moved (an "idle" animation would definitely be cleaner) - if (vel.x !== 0 || vel.y !== 0 || this.isFlickering()) { + // check if we moved (an "idle" animation would definitely be cleaner), + // or are still easing back from a stretch + if ( + vel.x !== 0 || + vel.y !== 0 || + this.isFlickering() || + this.stretchTween !== undefined + ) { super.update(dt); return true; } diff --git a/packages/examples/src/examples/platformer/entities/player.ts b/packages/examples/src/examples/platformer/entities/player.ts index cca19b3249..e943d74e5c 100644 --- a/packages/examples/src/examples/platformer/entities/player.ts +++ b/packages/examples/src/examples/platformer/entities/player.ts @@ -12,13 +12,25 @@ import { level, Rect, Sprite, + SquishEffect, + Tween, timer, } from "melonjs"; import { gameState } from "../gameState"; +// falling faster than this, then stopping, is a landing worth a squash +const LANDING_SPEED = 3; +// the most a landing squashes the player, at full falling speed +const MAX_SQUASH = 0.25; + export class PlayerEntity extends Sprite { dying: boolean; multipleJump: number; + squish: SquishEffect | undefined; + // the height the player is stretched (above 1) or squashed (below 1) to + stretch = { scaleY: 1 }; + stretchTween: Tween | undefined; + lastVelY: number; constructor(x, y, settings) { // create the sprite using atlas animation frames @@ -65,6 +77,8 @@ export class PlayerEntity extends Sprite { this.multipleJump = 1; + this.lastVelY = 0; + // enable keyboard input.bindKey(input.KEY.LEFT, "left"); input.bindKey(input.KEY.RIGHT, "right"); @@ -157,6 +171,43 @@ export class PlayerEntity extends Sprite { const app = this.parentApp; // set the viewport to follow this renderable on both axis, and enable damping app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1); + // the bulge that keeps a stretched or squashed player's volume is a + // shader effect, so on the GPU renderers only + if (this.squish === undefined && app.renderer.shaderLanguage !== null) { + this.squish = new SquishEffect(app.renderer); + this.addPostEffect(this.squish); + } + } + + /** + * stretch (above 1) or squash (below 1) the player to `scaleY` of its + * height, then ease back to rest with a single small overshoot + */ + stretchTo(scaleY: number) { + this.stretchTween?.stop(); + this.stretch.scaleY = scaleY; + this.stretchTween = new Tween(this.stretch) + .to({ scaleY: 1 }, { duration: 300, easing: Tween.Easing.Back.Out }) + .onComplete(() => { + this.stretchTween = undefined; + }) + .start(); + } + + /** + * scale the player's height about its feet, and bulge it the other way + * so it keeps its volume: pinched in when stretched, bulging out when + * squashed. Called from update(), after the physics step. + */ + applyStretch(scaleY: number) { + const feet = this.height * (1 - this.anchorPoint.y); + this.currentTransform + .identity() + .translate(0, feet) + .scale(1, scaleY) + .translate(0, -feet); + this.updateBounds(); + this.squish?.setBulge(1 - scaleY); } /** @@ -177,14 +228,18 @@ export class PlayerEntity extends Sprite { this.flipX(false); } + let jumped = false; if (input.isKeyPressed("jump")) { this.setCurrentAnimation("jump"); this.body.jumping = true; if (this.multipleJump <= 2) { + jumped = true; // easy "math" for double jump this.body.force.y = -this.body.maxVel.y * this.multipleJump++; audio.stop("jump"); audio.play("jump", false); + // stretch on take-off + this.stretchTo(1.25); } } else { if (!this.body.falling && !this.body.jumping) { @@ -200,6 +255,18 @@ export class PlayerEntity extends Sprite { this.setCurrentAnimation("stand"); } + // squash on landing, harder the faster we came down. A landing stops the + // fall; a double jump turns it upward, which is not a landing, and a + // jump on the frame we land wins over the squash + const velY = this.body.vel.y; + if (!jumped && this.lastVelY > LANDING_SPEED && Math.abs(velY) < 1) { + this.stretchTo( + 1 - MAX_SQUASH * Math.min(this.lastVelY / this.body.maxVel.y, 1), + ); + } + this.lastVelY = velY; + this.applyStretch(this.stretch.scaleY); + // check if we fell into a hole if (!this.inViewport && this.getBounds().top > game.renderer.height) { const app = this.parentApp; @@ -213,8 +280,14 @@ export class PlayerEntity extends Sprite { return true; } - // check if we moved (an "idle" animation would definitely be cleaner) - if (this.body.vel.x !== 0 || this.body.vel.y !== 0 || this.isFlickering()) { + // check if we moved (an "idle" animation would definitely be cleaner), + // or are still wobbling from a squash + if ( + this.body.vel.x !== 0 || + this.body.vel.y !== 0 || + this.isFlickering() || + this.stretchTween !== undefined + ) { super.update(dt); return true; } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index cbe96d4456..7fb0a4febe 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -4,6 +4,9 @@ **Highlights:** a game no longer stutters the first time an effect, a blend mode or a new kind of object appears. Shaders, pipelines and render targets are now prepared when the object is added, or during `loader.preload()`, rather than in the frame that first draws it, where a single first draw was measured at up to 140ms. Dropping a batch of meshes no longer stalls the frame either. +### Added +- Effects: `SquishEffect` bulges a sprite's middle out, or pinches it in, while its top and bottom rows stay where they are. Paired with a change of height it gives squash and stretch that keeps the sprite's volume, and it draws past the sprite's own frame, so the art needs no transparent margin + ### Fixed - WebGL: releasing retained geometry no longer stalls the frame, a stutter whenever a scene dropped a batch of meshes. Teardown asked a blocking `is*` query about every buffer and vertex array before deleting it, a round trip per object, and now only asks once the context has been lost, the only case where the answer can be no - WebGL: a `ShaderEffect` hosted on a `Mesh` is compiled during `loader.preload()`, on every call including a later one that brings a level's own effects, rather than on the frame it first draws. Preloading the shader built its standalone program, but a mesh host splices the body into the mesh shader, which is a different program again, so a game that declared every effect in its manifest still stuttered the first time each one appeared @@ -16,6 +19,7 @@ - Sprite: a video the browser refuses to start, as it does before the user has interacted with the page, no longer logs an uncaught error on every frame. The refusal is handled once, and playback starts on the next click, touch or key press - WebGL: ETC1 compressed textures no longer draw black. They were uploaded with a call ETC1 does not accept, which left the texture empty; they now upload as ETC2, which reads the same data, or level by level when the browser only supports ETC1 - WebGPU: the smallest mip levels of a compressed texture (the 2×2 and 1×1 levels of a 4×4-block format) are no longer rejected and left empty. Each upload now covers whole blocks, as WebGPU requires +- Tween: a tween stopped while the game updates (replaced by a new one, say) no longer takes one more step and fires its `onComplete` in that same frame ## [20.9.0] (melonJS 2) - _2026-10-18_ diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index 9d0015ddee..d10555607e 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -110,14 +110,15 @@ reused across renderables. ## Built-in effects -Twenty-one presets, every one constructed as `new XEffect(renderer, options)` and +Twenty-two presets, every one constructed as `new XEffect(renderer, options)` and every one shipping both a GLSL and a WGSL body, so they all run on either GPU backend: `VignetteEffect`, `ScanlineEffect` (optional CRT curvature), `BloomEffect`, `GlowEffect`, `BlurEffect`, `PixelateEffect`, `ChromaticAberrationEffect`, `DropShadowEffect`, `FlashEffect`, `OutlineEffect`, `ColorMatrixEffect`, `DesaturateEffect`, `DissolveEffect`, `TintPulseEffect`, `WaveEffect`, `InvertEffect`, `SepiaEffect`, -`HologramEffect`, `ShineEffect`, `RadialGradientEffect`, `ToneMappingEffect`. +`HologramEffect`, `ShineEffect`, `RadialGradientEffect`, `SquishEffect`, +`ToneMappingEffect`. `RadialGradientEffect` is a procedural soft spot: solid at the centre of its host quad, fading linearly to transparent at the edge, and elliptical for free @@ -127,6 +128,40 @@ falloff assumes the quad samples a FULL texture rect, so pair it with a standalone image or `Renderer.getWhitePixel()` rather than a sprite that draws a sub-region of an atlas, where the centre would land off-quad. +`SquishEffect` bulges a sprite's middle out (`setBulge(0.3)`) or pinches it in +(`setBulge(-0.3)`), its top and bottom rows staying put. Bulging out widens the +sprite past its own frame, and the effect draws on a wider quad to make room, +so the art needs no transparent margin. On its own it is a jelly wobble; paired +with a change of height it is **squash and stretch that keeps the volume**: +scale the sprite to `scaleY` of its height about its feet and set the bulge to +`1 - scaleY`, so a stretched sprite pinches in and a squashed one bulges out. +Ease `scaleY` back to 1 with a short `Tween.Easing.Back.Out`; an elastic ease +keeps it trembling long after the landing. + +```js +const squish = new SquishEffect(app.renderer); +player.addPostEffect(squish); +// every frame, from the player's update() +const feet = player.height * (1 - player.anchorPoint.y); +player.currentTransform + .identity() + .translate(0, feet) + .scale(1, scaleY) + .translate(0, -feet); +squish.setBulge(1 - scaleY); +``` + +Apply that from the sprite's own `update()`, not from a tween's `onUpdate`. A +physics adapter that mirrors a body's angle (Matter does) rewrites +`currentTransform` on every step, before the renderables update, so a scale set +anywhere else is gone by the time the sprite is drawn. Let the tween animate a +plain `{ scaleY }` object and read it in `update()`. + +It is made for a sprite carrying it as its only effect: chained with another, +or on the camera, the frame it measures against is the whole capture, so it +squishes the whole frame. The bulge is outside the sprite's bounds, so culling +and pointer events ignore it. + **`BloomEffect` and `GlowEffect` are not the same thing**, and reaching for the wrong one is the usual disappointment here. `GlowEffect` draws a halo OUTSIDE a sprite's silhouette: its body returns the colour untouched wherever alpha is @@ -616,6 +651,8 @@ same question after construction. | shadow/smear offset flips on some draws | vertical UV offset not multiplied by `uUVYDir` | | frame rate collapses with many blended sprites | an *advanced* mode (overlay, darken, …) — each draw is a capture plus a composite | | animated shader never moves | `setTime` fed milliseconds, or the shader declares no `uTime` | +| squash and stretch only bulges, the height never changes, under Matter | the adapter rewrites `currentTransform` every step; set the scale from the sprite's `update()` | +| `SquishEffect` squishes the whole screen instead of the sprite | it is chained with another effect, or on the camera; give the sprite it alone | | syntax error in a file whose shader you just edited, pointing at a line of GLSL | a backtick in the shader body ended the template literal — most often one wrapped around an identifier in a comment | ## Related skills diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 047f187b86..a387b91676 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -84,6 +84,7 @@ import ScanlineEffect from "./video/effects/scanline.js"; import SepiaEffect from "./video/effects/sepia.js"; import ShaderEffect from "./video/effects/shadereffect.js"; import ShineEffect from "./video/effects/shine.js"; +import SquishEffect from "./video/effects/squish.js"; import TintPulseEffect from "./video/effects/tintPulse.js"; import ToneMappingEffect from "./video/effects/tonemap.js"; import VignetteEffect from "./video/effects/vignette.js"; @@ -278,6 +279,7 @@ export { ShineEffect, Sprite, Sprite3d, + SquishEffect, Stage, save, state, diff --git a/packages/melonjs/src/tweens/tween.ts b/packages/melonjs/src/tweens/tween.ts index cd8f15125d..89648c6444 100644 --- a/packages/melonjs/src/tweens/tween.ts +++ b/packages/melonjs/src/tweens/tween.ts @@ -265,6 +265,12 @@ export default class Tween { * @internal */ _onTick(timestamp: number) { + // Stopped during this tick, by a listener called before this one (the + // game loop is one): the emitter walks a snapshot of its listeners, so + // unsubscribing does not take a tween out of the tick in progress. + if (!this._isRunning) { + return; + } if (!this._isPaused || this.updateWhenPaused) { // compute delta from the raw RAF timestamp const dt = timestamp - this._lastTick; diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index a8f51d23ec..3d9e0aa336 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -227,6 +227,21 @@ export default class ShaderEffect { */ this._precision = precision; + /** + * The vertex stage of this effect, on the single-effect fast path: + * the quad batchers scale the quad it draws by `x` and `y` about its + * centre, UVs unchanged, as a shader's vertex function would with + * `VERTEX *= factor`. melonJS transforms quad corners on the CPU, so + * an effect body has no vertex stage of its own and asks for it here. + * An effect that scales must undo the stretch in its fragment, which + * leaves it room to draw past the sprite's own edges. 1 for every + * effect that stays inside its quad. + * @type {{x: number, y: number}} + * @ignore + * @internal + */ + this._vertexScale = { x: 1, y: 1 }; + // resolve the body matching this renderer's shading language: a bare // string keeps meaning GLSL (the historical signature), an object // carries one body per language diff --git a/packages/melonjs/src/video/effects/squish.js b/packages/melonjs/src/video/effects/squish.js new file mode 100644 index 0000000000..396762f400 --- /dev/null +++ b/packages/melonjs/src/video/effects/squish.js @@ -0,0 +1,189 @@ +import ShaderEffect from "./shadereffect.js"; + +/** + * additional import for TypeScript + * @import CanvasRenderer from "../canvas/canvas_renderer.js"; + * @import WebGLRenderer from "../webgl/webgl_renderer.js"; + * @import WebGPURenderer from "../webgpu/webgpu_renderer.js"; + * @import Renderer from "../renderer.js"; + */ +// the WGSL twin of the GLSL body below — same logic, same uniform +// names, picked by the ShaderEffect base per renderer.shaderLanguage +const wgslFragment = ` +struct SquishUniforms { + uSquishFrame : vec4f, + uSquishXFactor : f32, + uSquishBulge : f32, +}; +@group(3) @binding(0) var fx : SquishUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + // -1..1 across the quad: the sprite's frame, stretched by the vertex scale + var p = (uv - fx.uSquishFrame.xy) / fx.uSquishFrame.zw * 2.0 - 1.0; + // undo the stretch: the sprite keeps its width, in the middle of the + // wider quad, with room on both sides + p.x = p.x * fx.uSquishXFactor; + // each row widens by its share of the bulge: all of it across the + // middle, none at the top and bottom + p.x = p.x / max(1.0 + fx.uSquishBulge * sqrt(max(1.0 - p.y * p.y, 0.0)), 0.0001); + let local = (p + 1.0) * 0.5; + // sampled before the test, since textureSample needs uniform control flow + let texel = textureSample(uTexture, uSampler, fx.uSquishFrame.xy + clamp(local, vec2f(0.0), vec2f(1.0)) * fx.uSquishFrame.zw) * vColor; + let inside = all(local >= vec2f(0.0)) && all(local <= vec2f(1.0)); + return select(vec4f(0.0), texel, inside); +} +`; + +/** + * A shader effect that squishes a sprite: its middle bulges out, or pinches + * in, while its top and bottom rows stay where they are. + * + * Paired with a change of height, it gives squash and stretch that keeps the + * sprite's volume: scale the sprite to `scaleY` of its height and set the + * bulge to `1 - scaleY`, so a stretched sprite pinches in and a squashed one + * bulges out (see the example). + * + * A bulge widens the sprite past its own frame. Like a shader that scales + * `VERTEX` in its vertex function, the effect draws on a quad widened by + * `1 + bulge` and narrows the texture back in its fragment, so the sprite + * needs no transparent margin. Made for a sprite carrying it as its only + * effect: chained with other effects, or on a camera, it squishes the whole + * frame instead. The bulge is not part of the sprite's bounds, so it is not + * counted for culling or pointer events. + * @category Effects + * @see {@link Renderable#addPostEffect} for usage + * @example + * // bulge a sprite out by half its width across the middle + * const squish = new SquishEffect(renderer, { bulge: 0.5 }); + * mySprite.addPostEffect(squish); + * @example + * // squash and stretch a platformer character: stretch on take-off, + * // squash on landing, then ease back to rest + * const squish = new SquishEffect(renderer); + * player.addPostEffect(squish); + * + * // scale the height about the feet, and bulge the other way + * const stretch = (scaleY) => { + * const feet = player.height * (1 - player.anchorPoint.y); + * player.currentTransform + * .identity() + * .translate(0, feet) + * .scale(1, scaleY) + * .translate(0, -feet); + * squish.setBulge(1 - scaleY); + * }; + * + * const stretchTo = (scaleY) => { + * const state = { scaleY }; + * stretch(scaleY); + * new Tween(state) + * .to({ scaleY: 1 }, { duration: 300, easing: Tween.Easing.Back.Out }) + * .onUpdate(() => stretch(state.scaleY)) + * .start(); + * }; + * + * stretchTo(1.3); // jumping: 30% taller, pinched in + * stretchTo(0.75); // landing: 25% shorter, bulging out + */ +export default class SquishEffect extends ShaderEffect { + /** + * @param {Renderer} renderer - the current renderer instance + * @param {object} [options] - effect options + * @param {number} [options.bulge=0] - how far the middle bulges out (positive) or pinches in (negative), from -1 to 1 + */ + constructor(renderer, options = {}) { + super(renderer, { + glsl: ` + uniform vec4 uSquishFrame; + uniform float uSquishXFactor; + uniform float uSquishBulge; + vec4 apply(vec4 color, vec2 uv) { + // -1..1 across the quad: the sprite's frame, stretched by the vertex scale + vec2 p = (uv - uSquishFrame.xy) / uSquishFrame.zw * 2.0 - 1.0; + // undo the stretch: the sprite keeps its width, in the middle + // of the wider quad, with room on both sides + p.x *= uSquishXFactor; + // each row widens by its share of the bulge: all of it across + // the middle, none at the top and bottom + p.x /= max(1.0 + uSquishBulge * sqrt(max(1.0 - p.y * p.y, 0.0)), 0.0001); + vec2 local = (p + 1.0) * 0.5; + // transparent past the sprite: the room around it, or the + // indent of a pinch + if (local.x < 0.0 || local.x > 1.0 || local.y < 0.0 || local.y > 1.0) { + return vec4(0.0); + } + return texture2D(uSampler, uSquishFrame.xy + local * uSquishFrame.zw) * vColor; + } + `, + wgsl: wgslFragment, + }); + + /** + * the bulge as last set, kept for {@link SquishEffect#clone} + * @ignore + * @internal + */ + this._bulge = 0; + + /** + * the frame the bulge is measured against, reused on every draw + * (`setUniform` copies it out) + * @ignore + * @internal + */ + this._frame = new Float32Array([0, 0, 1, 1]); + + this.setUniform("uSquishFrame", this._frame); + this.setBulge(options.bulge ?? 0); + } + + /** + * set how far the middle of the sprite bulges out, or pinches in + * @param {number} bulge - from -1 to 1: positive bulges out, up to twice the width across the middle; negative pinches in, down to nothing at -1; 0 leaves the sprite as it is + */ + setBulge(bulge) { + const value = Math.min(Math.max(bulge, -1), 1); + this._bulge = value; + // the middle row is the widest, at (1 + bulge) times the width, so the + // quad is drawn that much wider; a pinch stays inside and needs no room + const xFactor = 1 + Math.max(value, 0); + this._vertexScale.x = xFactor; + this.setUniform("uSquishXFactor", xFactor); + this.setUniform("uSquishBulge", value); + } + + /** + * The batchers hand every effect the frame it draws through here (it is + * what `noise_uv` runs across), and the bulge is measured against the + * same frame: where the sprite sits in its atlas, in UV units. A render + * target blit hands over the whole target. + * @ignore + * @internal + */ + _setNoiseUVRect( + sourceWidth, + sourceHeight, + objectWidth, + objectHeight, + u0, + v0, + ) { + const frame = this._frame; + frame[0] = u0; + frame[1] = v0; + frame[2] = Math.max(Math.abs(objectWidth), 1) / Math.max(sourceWidth, 1); + frame[3] = Math.max(Math.abs(objectHeight), 1) / Math.max(sourceHeight, 1); + this.setUniform("uSquishFrame", frame); + } + + /** + * Create an independent copy of this effect, with the same bulge. + * @returns {SquishEffect} a new effect + */ + clone() { + if (this.destroyed) { + throw new Error("SquishEffect.clone: effect has been destroyed"); + } + return new SquishEffect(this._renderer, { bulge: this._bulge }); + } +} diff --git a/packages/melonjs/src/video/gpu/quadcorners.ts b/packages/melonjs/src/video/gpu/quadcorners.ts index 65d7025d85..f00550fe8f 100644 --- a/packages/melonjs/src/video/gpu/quadcorners.ts +++ b/packages/melonjs/src/video/gpu/quadcorners.ts @@ -53,3 +53,37 @@ export function transformQuadCorners( } return corners; } + +/** + * The scaled quad {@link scaleQuad} returns, shared like the corners. + * @ignore + * @internal + */ +const scaled = new Float64Array(4); + +/** + * Scale a quad's width by `sx` and its height by `sy` about its centre, + * leaving its UVs alone: the texture stretches across the larger quad. This + * is an effect's vertex stage (`ShaderEffect#_vertexScale`), the + * `VERTEX *= factor` a shader with a vertex function would write, run here + * because the quad batchers transform corners on the CPU. The effect's + * fragment undoes the stretch, which leaves it room to draw past the + * sprite's own edges. + * Returns the shared array, valid until the next call: `[x, y, w, h]`. + * @ignore + * @internal + */ +export function scaleQuad( + x: number, + y: number, + w: number, + h: number, + sx: number, + sy: number, +): Float64Array { + scaled[0] = x + (w * (1 - sx)) / 2; + scaled[1] = y + (h * (1 - sy)) / 2; + scaled[2] = w * sx; + scaled[3] = h * sy; + return scaled; +} 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 d4de015a39..5b1956f840 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -1,5 +1,5 @@ import { off, on, TEXTURE2D_DESTROYED } from "../../../system/event.ts"; -import { transformQuadCorners } from "../../gpu/quadcorners.ts"; +import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import UniformBlock from "../buffer/uniformblock.js"; import { MAX_LIGHTS } from "../lighting/constants.ts"; import { @@ -604,6 +604,15 @@ export default class LitQuadBatcher extends QuadBatcher { // direct atlas sampling: uv.y grows downward — see // QuadBatcher.addQuad this.currentShader._setUVYDir?.(1); + // the effect's vertex stage — see QuadBatcher.addQuad + const scale = this.currentShader._vertexScale; + if (scale !== undefined && (scale.x !== 1 || scale.y !== 1)) { + const q = scaleQuad(x, y, w, h, scale.x, scale.y); + x = q[0]; + y = q[1]; + w = q[2]; + h = q[3]; + } } let normalTextureId = -1; diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 90de479877..d623e1fc26 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -1,4 +1,4 @@ -import { transformQuadCorners } from "../../gpu/quadcorners.ts"; +import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import IndexBuffer from "../buffer/index.js"; import { buildMultiTextureFragment } from "./../shaders/multitexture.js"; import quadMultiVertex from "./../shaders/quad-multi.vert"; @@ -327,6 +327,16 @@ export default class QuadBatcher extends MaterialBatcher { // direct atlas sampling: uv.y grows downward (directional bodies // compensate on the pooled path, where captures are bottom-up) this.currentShader._setUVYDir?.(1); + // the effect's vertex stage, AFTER the frame rect above, which + // must stay the sprite's own + const scale = this.currentShader._vertexScale; + if (scale !== undefined && (scale.x !== 1 || scale.y !== 1)) { + const q = scaleQuad(x, y, w, h, scale.x, scale.y); + x = q[0]; + y = q[1]; + w = q[2]; + h = q[3]; + } } // Transform vertices. Stamp per-sprite depth onto z BEFORE diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index d089a2868e..6df2fd8e80 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -1,5 +1,5 @@ import IndexBuffer from "../../buffer/index.js"; -import { transformQuadCorners } from "../../gpu/quadcorners.ts"; +import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import { TextureSlotTable } from "../../gpu/textureslots.js"; import { prepareEffectBinding } from "../effect_binding.js"; import { MAX_QUAD_TEXTURES } from "../pipeline/cache.js"; @@ -210,7 +210,15 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { Math.min(v0, v1), ); - this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, 0); + // the effect's vertex stage, AFTER the frame rect above, which + // must stay the sprite's own + const scale = effect._vertexScale; + if (scale !== undefined && (scale.x !== 1 || scale.y !== 1)) { + const q = scaleQuad(x, y, w, h, scale.x, scale.y); + this.pushQuadVertices(q[0], q[1], q[2], q[3], u0, v0, u1, v1, tint, 0); + } else { + this.pushQuadVertices(x, y, w, h, u0, v0, u1, v1, tint, 0); + } // per-quad draw under the fast path: each sprite needs its own // capture state, noise rect and uniform snapshot (draw-time diff --git a/packages/melonjs/tests/squish-effect.spec.js b/packages/melonjs/tests/squish-effect.spec.js new file mode 100644 index 0000000000..ab5d9775a7 --- /dev/null +++ b/packages/melonjs/tests/squish-effect.spec.js @@ -0,0 +1,326 @@ +import "./helpers/webgpu-globals.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, ShaderEffect, Sprite, SquishEffect } from "../src/index.js"; +import { scaleQuad } from "../src/video/gpu/quadcorners.ts"; +import Renderer from "../src/video/renderer.js"; +import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * SquishEffect bulges a sprite's middle out, or pinches it in, while its top + * and bottom rows stay put. Bulging out widens the sprite PAST its own quad, + * so, like a shader scaling `VERTEX` in its vertex function, the effect asks + * the quad batchers to draw a wider quad (`_vertexScale`) and narrows the + * texture back in its fragment. It must never sample outside its frame while + * drawing in the room that leaves: past the frame is the next atlas frame. + * + * The pixel tests draw the RIGHT half of a sheet whose left half is blue, so + * any sample taken past the frame shows up as blue. + */ +describe("SquishEffect", () => { + describe("scaleQuad", () => { + it("scales the width about the centre", () => { + // 40 wide at x 10: 60 wide, still centred on x 30 + expect(Array.from(scaleQuad(10, 20, 40, 30, 1.5, 1))).toEqual([ + 0, 20, 60, 30, + ]); + }); + + it("scales the height about the centre", () => { + expect(Array.from(scaleQuad(0, 10, 8, 20, 1, 2))).toEqual([0, 0, 8, 40]); + }); + + it("leaves a quad alone at 1", () => { + expect(Array.from(scaleQuad(3, 4, 5, 6, 1, 1))).toEqual([3, 4, 5, 6]); + }); + }); + + describe("the bulge", () => { + // a renderer with no programmable pipeline: the effect stays an inert + // stub, but its JS side still runs + const stub = { shaderLanguage: null }; + const quiet = () => { + return vi.spyOn(console, "warn").mockImplementation(() => {}); + }; + + it("asks for a quad as wide as its widest row when it bulges out", () => { + const warn = quiet(); + const squish = new SquishEffect(stub, { bulge: 0.5 }); + warn.mockRestore(); + // the middle row is 1.5x wide + expect(squish._vertexScale).toEqual({ x: 1.5, y: 1 }); + squish.setBulge(1); + expect(squish._vertexScale.x).toBe(2); + }); + + it("keeps its quad when it pinches in, which stays inside", () => { + const warn = quiet(); + const squish = new SquishEffect(stub, { bulge: -0.5 }); + warn.mockRestore(); + expect(squish._vertexScale.x).toBe(1); + squish.setBulge(0); + expect(squish._vertexScale.x).toBe(1); + }); + + it("clamps to -1..1", () => { + const warn = quiet(); + const squish = new SquishEffect(stub); + warn.mockRestore(); + squish.setBulge(3); + expect(squish._bulge).toBe(1); + expect(squish._vertexScale.x).toBe(2); + squish.setBulge(-3); + expect(squish._bulge).toBe(-1); + }); + + it("clones as a SquishEffect with the same bulge", () => { + const warn = quiet(); + const squish = new SquishEffect(stub, { bulge: 0.4 }); + const copy = squish.clone(); + warn.mockRestore(); + expect(copy).toBeInstanceOf(SquishEffect); + expect(copy).not.toBe(squish); + expect(copy._bulge).toBe(0.4); + expect(copy._vertexScale.x).toBe(1.4); + }); + }); + + describe("on the WebGPU quad batcher (mock device)", () => { + const atlas = { + getTexture() { + return { width: 64, height: 32 }; + }, + }; + const quadOf = (renderer) => { + const f = renderer.calls.writes.at(-1).floats; + // corners: (x,y) (x+w,y) (x,y+h) (x+w,y+h), stride 7 floats + return { + left: f[0], + right: f[7], + top: f[1], + bottom: f[15], + u0: f[3], + u1: f[10], + }; + }; + const make = (bulge) => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + const squish = new SquishEffect({ shaderLanguage: "wgsl" }, { bulge }); + renderer.customShader = squish; + return { renderer, batcher, squish }; + }; + + it("compiles its WGSL body", () => { + const { squish } = make(0); + expect(squish.enabled).toBe(true); + expect(squish.wgslRealization.valid).toBe(true); + }); + + it("draws a wider quad when it bulges out, its UVs inside the frame", () => { + const { renderer, batcher } = make(0.5); + // the right half of a 64x32 sheet, drawn 32x32 at (16, 0) + batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); + const quad = quadOf(renderer); + // 1.5x as wide about the centre: x 8..56 + expect([quad.left, quad.right]).toEqual([8, 56]); + expect([quad.top, quad.bottom]).toEqual([0, 32]); + expect([quad.u0, quad.u1]).toEqual([0.5, 1]); + }); + + it("measures the bulge against the sprite's own frame, not the wider quad", () => { + const { batcher, squish } = make(0.5); + batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); + // u0 0.5, v0 0, and 32 px of a 64x32 sheet: 0.5 by 1 + expect(Array.from(squish._frame)).toEqual([0.5, 0, 0.5, 1]); + }); + + it("leaves every other effect's quad alone", () => { + const renderer = createMockWebGPURenderer(); + const batcher = new WebGPUQuadBatcher(renderer); + renderer.customShader = new ShaderEffect( + { shaderLanguage: "wgsl" }, + { + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + }, + ); + batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); + const quad = quadOf(renderer); + expect([quad.left, quad.right]).toEqual([16, 48]); + expect([quad.u0, quad.u1]).toEqual([0.5, 1]); + }); + + it("draws the quad as it is when it pinches in", () => { + const { renderer, batcher } = make(-0.5); + batcher.addQuad(atlas, 16, 0, 32, 32, 0.5, 0, 1, 1, 0xffffffff); + const quad = quadOf(renderer); + expect([quad.left, quad.right]).toEqual([16, 48]); + expect([quad.u0, quad.u1]).toEqual([0.5, 1]); + }); + }); + + describe("on WebGL, measured in pixels", () => { + const SIZE = 64; + const RED = [255, 0, 0]; + const BLUE = [0, 0, 255]; + let renderer; + let sheet; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // WebGL genuinely unavailable — tests skip below + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } + // 64x32: a blue frame on the left, the red frame drawn on the right + sheet = Renderer.createCanvas(64, 32); + const ctx = sheet.getContext("2d"); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(0, 0, 32, 32); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(32, 0, 32, 32); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + const pixel = (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], out[1], out[2]]; + }; + + /** the red frame, 32x32, centered: x 16..48, y 16..48 */ + const drawRed = (effect) => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = effect; + renderer.drawImage(sheet, 32, 0, 32, 32, 16, 16, 32, 32); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + }; + + // middle row y = 32; top row y = 16 (its pixel centre sits 1/32 below + // the edge, where the bulge is down to a quarter) + it("leaves the sprite as it is with no bulge", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer); + drawRed(squish); + expect(pixel(17, 32)).toEqual(RED); + expect(pixel(46, 32)).toEqual(RED); + expect(pixel(14, 32)).toEqual([0, 0, 0]); + expect(pixel(49, 32)).toEqual([0, 0, 0]); + squish.destroy(); + }); + + it("bulges the middle out past the sprite's own edges", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer, { bulge: 1 }); + drawRed(squish); + // twice the width across the middle: the whole canvas + expect(pixel(2, 32)).toEqual(RED); + expect(pixel(61, 32)).toEqual(RED); + squish.destroy(); + }); + + it("keeps the top row close to its width", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer, { bulge: 1 }); + drawRed(squish); + // 1.25x wide at the top row: x 12..52, so x 8 stays empty + expect(pixel(14, 16)).toEqual(RED); + expect(pixel(8, 16)).toEqual([0, 0, 0]); + expect(pixel(56, 16)).toEqual([0, 0, 0]); + squish.destroy(); + }); + + it("never samples past the frame while drawing in the room", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer, { bulge: 1 }); + drawRed(squish); + // the left room samples u < 0.5, the blue frame, unless the + // effect returns transparent there + for (const x of [0, 4, 8, 11]) { + expect(pixel(x, 16), `x ${x}`).not.toEqual(BLUE); + expect(pixel(x, 16), `x ${x}`).toEqual([0, 0, 0]); + } + squish.destroy(); + }); + + it("pinches the middle in", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer, { bulge: -0.5 }); + drawRed(squish); + // half the width across the middle: x 24..40 + expect(pixel(20, 32)).toEqual([0, 0, 0]); + expect(pixel(44, 32)).toEqual([0, 0, 0]); + expect(pixel(32, 32)).toEqual(RED); + // the top row barely moves + expect(pixel(18, 16)).toEqual(RED); + squish.destroy(); + }); + + it("squishes the lit path the same way", (ctx) => { + requireWebGL(ctx); + const squish = new SquishEffect(renderer, { bulge: 1 }); + const lights = renderer.activeLightCount; + const normals = renderer.currentNormalMap; + // a lit scene and a normal-mapped sprite: the lit quad batcher + renderer.activeLightCount = 1; + renderer.currentNormalMap = sheet; + try { + drawRed(squish); + expect(renderer.currentBatcher).toBe(renderer.batchers.get("litQuad")); + } finally { + renderer.activeLightCount = lights; + renderer.currentNormalMap = normals; + } + expect(pixel(2, 32)).toEqual(RED); + expect(pixel(8, 16)).toEqual([0, 0, 0]); + squish.destroy(); + }); + + it("squishes a Sprite carrying it", (ctx) => { + requireWebGL(ctx); + // through preDraw / postDraw, as a game draws it: the effect + // becomes the fast path's shader there, not by hand + const red = Renderer.createCanvas(32, 32); + const ctx2d = red.getContext("2d"); + ctx2d.fillStyle = "#ff0000"; + ctx2d.fillRect(0, 0, 32, 32); + const sprite = new Sprite(32, 32, { image: red }); + sprite.addPostEffect(new SquishEffect(renderer, { bulge: 1 })); + try { + renderer.clearColor("#000000ff"); + renderer.save(); + sprite.preDraw(renderer); + sprite.draw(renderer); + sprite.postDraw(renderer); + renderer.restore(); + renderer.flush(); + expect(pixel(2, 32)).toEqual(RED); + expect(pixel(8, 16)).toEqual([0, 0, 0]); + } finally { + sprite.destroy(); + } + }); + }); +}); diff --git a/packages/melonjs/tests/tween.spec.ts b/packages/melonjs/tests/tween.spec.ts index 547a666b7d..3f299e42d6 100644 --- a/packages/melonjs/tests/tween.spec.ts +++ b/packages/melonjs/tests/tween.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Tween } from "../src/index.js"; +import { emit, off, on, TICK } from "../src/system/event.ts"; import { Easing } from "../src/tweens/easing.js"; import { Interpolation } from "../src/tweens/interpolation.js"; @@ -128,6 +129,54 @@ describe("Tween", () => { tween.stop(); }).not.toThrow(); }); + + // The game loop is itself a TICK listener, and the emitter calls a + // snapshot of its listeners. A tween stopped from inside a game update + // (replaced by a new one, say) was still called for that same tick: it + // stepped once more, and fired onComplete if that step reached the end. + describe("stopped from a listener earlier in the same tick", () => { + let calls: { updates: number; completes: number }; + const stopIt = () => { + tween.stop(); + }; + + beforeEach(() => { + calls = { updates: 0, completes: 0 }; + // registered before the tween subscribes, as the game loop is + on(TICK, stopIt); + }); + + afterEach(() => { + off(TICK, stopIt); + }); + + const startTween = (duration: number) => { + tween + .to({ x: 100 }, { duration }) + .onUpdate(() => { + calls.updates++; + }) + .onComplete(() => { + calls.completes++; + }) + .start(); + }; + + it("does not step again", () => { + startTween(1000); + emit(TICK, tween._lastTick + 16); + expect(calls.updates).toBe(0); + expect(obj.x).toBe(0); + }); + + it("does not complete", () => { + startTween(100); + // a tick past the end: the step it must not take would finish it + emit(TICK, tween._lastTick + 150); + expect(calls.completes).toBe(0); + expect(obj.x).toBe(0); + }); + }); }); // --- update --- diff --git a/packages/melonjs/tests/webgpu_effects_validate.spec.js b/packages/melonjs/tests/webgpu_effects_validate.spec.js index d458e4fde9..d7e05a8b6d 100644 --- a/packages/melonjs/tests/webgpu_effects_validate.spec.js +++ b/packages/melonjs/tests/webgpu_effects_validate.spec.js @@ -16,6 +16,7 @@ import { ScanlineEffect, SepiaEffect, ShineEffect, + SquishEffect, TintPulseEffect, VignetteEffect, video, @@ -47,6 +48,7 @@ describe("built-in effect WGSL twins compile on the device", () => { ScanlineEffect, SepiaEffect, ShineEffect, + SquishEffect, TintPulseEffect, VignetteEffect, WaveEffect,