This version keeps the integration small so the moving parts are visible. It uses the WebGL / GLSL path from `three-fluid-fx`, where the effect is built with EffectComposer passes, ShaderMaterial uniforms, and WebGL render targets.
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 Texture objects: velocityTexture, densityTexture, and dyeTexture 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, FullscreenPass, FULLSCREEN_VERTEX } from 'three-fluid-fx'
const fluid = new FluidSimulation(renderer, { splatRadius: 0.0175, densityDissipation: 0.985 })
attachPointerSplats(renderer.domElement, fluid)
// density.b is the reveal mask, velocity.rg ripples the edge
const composite = new ShaderMaterial({
vertexShader: FULLSCREEN_VERTEX,
fragmentShader: /* glsl */ `
vec4 fl = texture2D(tFluid, vUv);
float mask = smoothstep(0.06, 0.32, fl.b);
float edge = mask * (1.0 - mask) * 4.0; // thin boundary band
vec3 base = texture2D(tBase, imgUv).rgb;
vec3 reveal = texture2D(tReveal, imgUv - fl.rg * uEdge * edge).rgb;
gl_FragColor = vec4(mix(base, reveal, mask) + reveal * edge * uRim, 1.0);
`,
uniforms: { tBase, tReveal, tFluid: { value: fluid.densityTexture }, uEdge, uRim },
})
const pass = new FullscreenPass(composite) 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/glsl/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.