Skip to content

Effects: add SquishEffect, a squash and stretch shader - #1755

Merged
obiot merged 1 commit into
masterfrom
feat/squish-effect
Oct 20, 2026
Merged

obiot merged 1 commit into
masterfrom
feat/squish-effect

Conversation

@obiot

@obiot obiot commented Oct 19, 2026

Copy link
Copy Markdown
Member

Adds SquishEffect, a squash and stretch shader, uses it in both platformer examples, and fixes a Tween bug the examples exposed.

SquishEffect

The middle of the sprite bulges out (setBulge(0.3)) or pinches in (setBulge(-0.3)), and its top and bottom rows stay put. On its own it's a jelly wobble. 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 about its feet, and set the bulge to 1 - scaleY. A stretched sprite then pinches in and a squashed one bulges out. Its doc page has the full example.

const squish = new SquishEffect(app.renderer);
player.addPostEffect(squish);
squish.setBulge(1 - scaleY); // with the height scaled to scaleY about the feet

Where the room for the bulge comes from

A bulge widens the sprite past its own frame, and the platformer's frames have only 0 to 5 px of margin. Without extra room the bulge is cut straight down both sides.

A shader with a vertex stage makes that room with VERTEX.x *= x_factor. A ShaderEffect body has no vertex stage: the quad batchers transform corners on the CPU, and the GPU vertex shader only projects them. So the vertex step runs in the batchers:

  • The effect sets _vertexScale. It's internal and 1 by default.
  • The batchers scale that effect's quad about its centre with scaleQuad. The UVs stay inside the frame. This happens in the WebGL quad batcher, the WebGL lit batcher and the WebGPU quad batcher.
  • The fragment narrows the texture back (p.x *= uSquishXFactor), then applies the bulge curve. It returns transparent outside the frame, so neighbouring atlas frames never bleed in.

The scale is 1 + bulge while the sprite bulges out and 1 otherwise. So the effect adds no extra fill at rest, and every other effect is untouched.

It's made for a sprite carrying it as its only effect. Chained with another effect, or on the camera, it squishes the whole frame. The bulge isn't part of the sprite's bounds, so culling and pointer events ignore it. On Canvas it disables itself like every other effect.

Tween: a stopped tween ran one more step

A tween stopped from inside the game's update still ran one more step in that frame, and fired onComplete if that step reached the end.

  • The game loop is itself a TICK listener, registered first.
  • The emitter calls a snapshot of its listener list, so a tween that unsubscribes from inside an update() is still called for that tick.

In the platformer, the landing replaced the jump's tween just as it was finishing. The old tween took its last step a moment later and snapped the player back to full height for one frame. Any game that stops or replaces a tween from an update() could have an onComplete fire after the stop.

_onTick now ignores a tween that isn't running. The emitter is unchanged: calling every listener in its snapshot is the usual emitter behaviour.

Examples

Both platformers stretch to 1.25 on take-off, including a mid-air jump, and squash on landing by up to 0.25 depending on the fall speed. Both ease back with Back.Out over 300 ms.

  • Built-in physics: a landing must actually stop the fall, so a double jump doesn't count as one.
  • Matter: a landing is detected with isGrounded().
  • Both: a jump on the frame you land wins over the squash.
  • Matter's transform: the adapter rewrites currentTransform on every step, for every body, before the entities update. So both examples apply the stretch from update() and let the tween animate a plain { scaleY }. The effects skill documents this.

Verification

  • Tests:
    • squish-effect.spec.js, 19 tests:
      • real WebGL pixels: bulge, pinch, the top row holding its width, no sampling past the frame, the lit path, and a Sprite through preDraw
      • the WebGPU quad batcher on the mock device
      • scaleQuad, the setter and clone()
    • The effect is added to the device-gated WebGPU compile check.
    • Two Tween tests fail on the old code: 1 extra step and 1 completion after stop().
    • 19 out of 19 deliberate breaks of the implementation are caught.
    • Full engine suite: 334 files, 8,130 passed, 10 skipped.
  • Real browsers, Chrome and Safari, WebGPU and WebGL:
    • Frames were captured one by one through jumps, double jumps and constant jump tapping. On all four targets, no frame squashes while rising, and the landing is drawn in full on both sides.
    • The WGSL body matches the GLSL one.
    • A 3D game and a 2.5D game not in the repo show no regression: 0 errors, 0 warnings, 0 shader compiles during play, and the same frame times as before.
  • Docs: the API docs build with no new warnings, and llms.txt lists the effect. The effects skill lists it with a usage section and two symptom rows. The 20.10.0 changelog has one entry under Added and one under Fixed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx
Copilot AI balanced review requested due to automatic review settings October 19, 2026 23:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 3210d5c into master Oct 20, 2026
6 checks passed
@obiot
obiot deleted the feat/squish-effect branch October 20, 2026 00:03
@obiot obiot mentioned this pull request Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants