three-fluid-fx is not a visual preset engine. It gives you a small fluid
field that can be sampled from three.js. The field is usually driven by pointer
movement, then consumed by an overlay, a distortion pass, or your own particle
system.
The smallest useful integration
Create a renderer, create the fluid simulation, attach pointer splats, and step the solver in the render loop.
import { WebGLRenderer, Timer } from 'three'
import { attachPointerSplats, FluidSimulation } from 'three-fluid-fx'
const renderer = new WebGLRenderer({ antialias: true })
const fluid = new FluidSimulation(renderer, {
profile: 'balanced',
splatRadius: 0.001,
splatForce: 6,
})
const detachPointer = attachPointerSplats(renderer.domElement, fluid)
const clock = new Timer()
renderer.setAnimationLoop(() => {
clock.update()
fluid.step(clock.getDelta())
// Render your scene, effect composer, custom shader, or particles here.
})
Keep resize explicit
The simulation uses internal render targets. Resize them with the canvas so the field keeps the same aspect ratio as the viewport.
function resize() {
const width = stage.clientWidth
const height = stage.clientHeight
const dpr = Math.min(window.devicePixelRatio || 1, 2)
renderer.setPixelRatio(dpr)
renderer.setSize(width, height, false)
fluid.resize(width, height)
}
resize()
window.addEventListener('resize', resize)
The profile controls the baseline render-target sizes. resize() reshapes
those targets to match the viewport aspect. It does not change the selected
profile.
What the library gives you
The main output is not a mesh. It is a set of textures in the GLSL pipeline and
matching TextureNodes in the TSL pipeline.
| Output | Channels | Use it for |
|---|---|---|
fluid.velocityTexture | .xy | Post-advection velocity. Use it for particle systems and downstream motion. |
fluid.velocityProjectedTexture | .xy | Projection-clean velocity before self-advection. Use it for cleaner velocity visualizations. |
fluid.densityTexture | .rg + .b | Display-space smeared flow in RG and density mask in B. Distortion and most overlays use this. |
fluid.dyeTexture | .rgb | Per-stroke color. Requires fluid.enableDye = true and dyeColor splats. |
For WebGPU/TSL, the names mirror the texture names:
fluid.velocityNode
fluid.densityNode
fluid.dyeNode
Use textures with WebGL materials and post-processing passes. Use nodes with
WebGPURenderer and RenderPipeline.
Pointer splats
Pointer splats are small impulses written into the velocity and density fields.
The helper is intentionally thin: it reads fluid.splatRadius and
fluid.splatForce every pointer event.
fluid.splatRadius = 0.0012
fluid.splatForce = 8
You can also add splats manually. Coordinates are normalized from 0 to 1.
fluid.addSplat(0.5, 0.5, 120, 40, {
radius: 0.001,
color: [120, 40, 1],
})
For colored ink effects, turn on the dye field and write dyeColor splats.
fluid.enableDye = true
attachPointerSplats(renderer.domElement, fluid, {
coloredStrokes: true,
})
Use colorize when the stroke color should be deterministic instead of random
HSV cycling.
attachPointerSplats(renderer.domElement, fluid, {
colorize(dx, dy) {
const mag = Math.min(Math.hypot(dx, dy) / 80, 1)
return [Math.abs(dx) * 0.003 * mag, 0.08 * mag, Math.abs(dy) * 0.003 * mag]
},
})
Parameters that change the look
You do not need to teach users the Stable Fluids algorithm. You do need to tell them which controls make a visual difference.
| Parameter | What it controls | How to tune it |
|---|---|---|
splatRadius | Brush size in normalized UV units. | Small values make sharp streaks. Larger values make soft blobs, water surfaces, and smoke. |
splatForce | Pointer motion gain before the splat is written into velocity. | Raise it for punchy smears and particles. Lower it for slow ink and glass. |
pressureIterations | How much projection cleanup runs each step. | Higher values look tighter and cost more. Lower values relax outward and feel softer. |
curlStrength + enableVorticity | Extra vortex energy added back into the field. | Use it for oil, smoke, chromatic, and ink. Disable it for clean trails. |
velocityDissipation | How long motion remains alive. | Closer to 1 means motion keeps pushing. Lower values calm the field quickly. |
densityDissipation | How long the visible density mask remains alive. | Closer to 1 gives long trails and persistent masks. Lower values make quick puffs. |
dyeDissipation | How long colored per-stroke dye remains alive. | Set higher than density for watercolor and ink whose color outlives the motion. |
bfecc | Sharper advection path. | Leave on for crisp trails. Turn off for softer diffusion when a style wants bloom. |
reflectWalls | Whether flow bounces from the screen edges. | Use true when you want energy to stay on screen. Use false when smoke or ink should drift away. |
Profiles
Profiles pick the texture sizes and the default pressure iteration count at construction time.
new FluidSimulation(renderer, { profile: 'performance' }) // weak GPUs, small embeds
new FluidSimulation(renderer, { profile: 'balanced' }) // default
new FluidSimulation(renderer, { profile: 'quality' }) // hero visuals, capture
Individual options still override the selected profile.
const fluid = new FluidSimulation(renderer, {
profile: 'balanced',
pressureIterations: 8,
})
Pick the render path
Use the same solver for every visual family.
// Overlay: add density, dye, or velocity color over the scene.
scene.rgb + color * density * intensity
// Distortion: use fluid flow to shift scene UVs.
texture2D(tDiffuse, uv - fluid.rg * intensity)
// Particles: sample velocity at each particle screen position.
particleAcceleration += velocityField.xy * flowStrength