Fluid (ink) simulation

유체(잉크) 시뮬레이션

Faking the look of ink diffusing in water by layering warped noise, instead of solving real fluid equations.

Also known as: Domain warpingInk dye approximationFake fluid dynamics
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
camera.position.set(0, 0, 1);
camera.lookAt(0, 0, 0);

const fragmentShader = [
  'uniform float uTime;',
  'uniform vec2 uRes;',
  'varying vec2 vUv;',
  'float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }',
  'float noise(vec2 p) {',
  '  vec2 i = floor(p); vec2 f = fract(p);',
  '  float a = hash(i), b = hash(i + vec2(1.0,0.0)), c = hash(i + vec2(0.0,1.0)), d = hash(i + vec2(1.0,1.0));',
  '  vec2 u = f * f * (3.0 - 2.0 * f);',
  '  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);',
  '}',
  'float fbm(vec2 p) {',
  '  float v = 0.0; float amp = 0.5;',
  '  for (int i = 0; i < 5; i++) { v += amp * noise(p); p *= 2.02; amp *= 0.55; }',
  '  return v;',
  '}',
  'void main() {',
  '  vec2 uv = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0) * 2.2;',
  '  float t = uTime * 0.18;',
  '  vec2 q = vec2(fbm(uv + t), fbm(uv + vec2(3.1, 1.7) - t));',
  '  vec2 r = vec2(fbm(uv + 2.4 * q + vec2(1.3, 5.2) + 0.15 * t), fbm(uv + 2.4 * q + vec2(4.6, 2.1) - 0.1 * t));',
  '  float f = fbm(uv + 2.6 * r);',
  '  vec3 colA = vec3(0.05, 0.06, 0.18);',
  '  vec3 colB = vec3(0.85, 0.25, 0.55);',
  '  vec3 colC = vec3(0.15, 0.75, 0.85);',
  '  vec3 colD = vec3(0.95, 0.65, 0.2);',
  '  vec3 color = mix(colA, colB, clamp(f * 1.4, 0.0, 1.0));',
  '  color = mix(color, colC, clamp(length(q) * 0.9, 0.0, 1.0));',
  '  color = mix(color, colD, clamp(pow(max(r.x, 0.0), 3.0) * 1.4, 0.0, 1.0));',
  '  gl_FragColor = vec4(color, 1.0);',
  '}',
].join('\n');

const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 }, uRes: { value: new THREE.Vector2(1, 1) } },
  vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); }',
  fragmentShader,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
scene.add(mesh);

function resize() { renderer.setSize(innerWidth, innerHeight); mat.uniforms.uRes.value.set(innerWidth, innerHeight); }
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  mat.uniforms.uTime.value = t * 0.001;
  renderer.render(scene, camera);
});

A real fluid simulation (the Stable Fluids family) advects a velocity field and solves pressure for incompressibility every frame, bouncing between several render targets — roughly the gpgpu ping-pong pattern extended two or three times over. This demo takes a shortcut: a single fragment shader doing "domain warping" to fake a similar impression.

Domain warping doesn’t use fbm(coordinate) directly — it feeds that result back into the coordinate and computes fbm again, repeatedly: q = fbm(p), r = fbm(p + q), color = fbm(p + r). Each step means the noise itself perturbs the coordinates the next noise layer reads, so distortion compounds across layers into swirling, stretched, ink-like patterns. Letting p drift with time animates that pattern as continuous bleeding.

This approach costs just one GPU pixel-shader pass — no render targets, no advection — so it’s far cheaper, but it never actually reacts to obstacles or external forces (like a mouse drag) the way real fluid does. A genuinely interactive fluid needs Stable Fluids implemented with render-target ping-pong; this demo is the lighter, look-alike stand-in before that step.

When to use

Decorative "organically flowing" backgrounds — ambient backdrops, loading screens, marketing pages — where the fluid doesn’t need to react to input.