This version keeps the integration small so the moving parts are visible. It uses the WebGPU / TSL path from `three-fluid-fx/tsl`, where the effect is built with RenderPipeline output nodes, TSL factories, and WGSL compute helpers.
Live demo
What this demo teaches
The fluid is not the picture; it is the stencil. The solver writes a screen-space texture every frame — density in one channel, the velocity vector in two more. Where the pointer paints density, the sealed layer gives way to the hidden one underneath; the velocity bends the boundary so the cut looks liquid instead of a hard circle. In this variant, the relevant fluid output is TextureNode fields: velocityNode, densityNode, and dyeNode when dye is enabled.
- Read the fluid output as data: density (.b) is a mask, velocity (.rg) is a 2D offset — not something you draw.
- Composite two cover-fitted layers with mix(base, reveal, mask).
- Confine the velocity distortion to a thin edge band so the boundary ripples while the revealed interior stays crisp.
- Get a glowing "wet rim" for free from the same band, and let the reveal heal back via density dissipation.
Implementation path
- Render the fluid, then composite fullscreen
The solver renders into a screen-space density/velocity texture. One fullscreen pass — a FullscreenPass + ShaderMaterial in WebGL, an output node in TSL — reads that texture together with the two image layers and writes the final pixel. No 3D scene is needed.
- Cover-fit both layers
Each image is mapped with a background-size: cover UV so it fills the canvas without stretching; base and reveal can even be different sizes (the full demo lets you upload your own). Pass the image and canvas sizes as uniforms and crop the overflow.
- Density → reveal mask
mask = smoothstep(lo, hi, density.b) turns the painted fluid into a 0..1 reveal amount; mix(base, reveal, mask) blends the sealed and hidden layers. Raising the lower threshold makes the reveal appear later, only under a denser stroke.
- Velocity → liquid edge + wet rim
An edge band — mask·(1−mask), peaking at the boundary — is the trick. Offset the reveal UV by velocity·edge so only the boundary refracts (the interior stays sharp), then add reveal·edge·rim for a bright meniscus that traces the stroke.
- Heal-back and resize
densityDissipation fades the painted density over time, so the reveal slowly closes on its own. On resize, keep the solver size, the view-size uniform and the renderer in sync.
- Minimal scope
It avoids GUI state and preset switching. Read it first when you need the smallest reliable version of the technique.
import { FluidSimulation } from 'three-fluid-fx/tsl'
import { texture, screenUV, smoothstep, mix, clamp, float } from 'three/tsl'
const fluid = new FluidSimulation(renderer, { splatRadius: 0.0175, densityDissipation: 0.985 })
attachPointerSplats(renderer.domElement, fluid)
const fl = texture(fluid.densityNode).sample(screenUV)
const mask = smoothstep(0.06, 0.32, fl.b) // reveal amount
const edge = mask.mul(float(1).sub(mask)).mul(4) // thin boundary band
const reveal = texture(revealTex).sample(clamp(imgUv.sub(fl.rg.mul(uEdge).mul(edge)), 0, 1))
pipeline.outputNode = mix(texture(baseTex).sample(imgUv), reveal, mask).add(
reveal.mul(edge).mul(uRim),
) Parameters to tune
| Parameter | What it controls | How to tune it |
|---|---|---|
splatRadius | Width of the reveal stroke (how much density each pointer move injects). | Larger uncovers a bolder swath; smaller paints a fine line. |
densityDissipation | How long the reveal lingers before it heals back. | Lower heals fast (a quick scrub); near 0.99 it stays open much longer. |
edge | Velocity refraction applied at the reveal boundary. | Gentle for a clean cut; higher for a watery, liquid boundary. |
rim | Brightness of the wet line where liquid meets the sealed area. | Raise for a glowing meniscus; set to zero to remove it. |
velocityDissipation | How long the flow keeps moving after a stroke. | Higher keeps it flowing like water; lower settles the reveal into the brush shape. |
Source landmarks
Start from examples/tsl/minimal/reveal-mask/main.ts. These are the parts worth reading first:
- Loading and cover-fitting the two layers (TextureLoader for images, VideoTexture for clips).
- The smoothstep that turns density.b into the 0..1 mask.
- The edge band that confines the velocity offset to the boundary and drives the rim.
- The full demo: the image-pair switcher and Upload controls. In TSL: the shared fluidReveal node and the LinearSRGB output that matches the WebGL look.
Production notes
- Keep the velocity offset weighted to the edge band, or the whole revealed area smears and becomes unreadable.
- Frame the two layers identically (same camera and size) so the reveal lines up — for video, render the pair from one animation/turntable.
- Tune densityDissipation for the feel: low for a wipe-and-restore scrub, high for a reveal that lingers.