유체(잉크) 시뮬레이션

Fluid (ink) simulation

실제 유체 방정식 대신, 노이즈를 여러 겹 뒤틀어(domain warping) 잉크가 물에 번지는 느낌을 흉내 내는 기법.

다른 이름: 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);
});

진짜 유체 시뮬레이션(Stable Fluids 계열)은 매 프레임 속도장을 이류(advect)시키고, 압력을 풀어서(projection) 비압축 조건을 맞추는 과정을 렌더 타깃 여러 장을 오가며 반복해야 합니다 — gpgpu 항목의 핑퐁 구조를 두세 배로 확장한 것과 비슷합니다. 이 데모는 그 대신 프래그먼트 셰이더 한 장짜리 "도메인 워핑(domain warping)"으로 비슷한 인상만 만드는 근사치입니다.

도메인 워핑은 fbm(좌표)을 그대로 쓰지 않고, 그 결과를 다시 좌표에 더해 fbm을 한 번 더 계산하는 걸 반복합니다: q = fbm(p), r = fbm(p + q), color = fbm(p + r). 매번 "노이즈 자체가 다음 노이즈가 읽을 좌표를 흔든다"는 뜻이라, 층마다 왜곡이 누적되면서 소용돌이치고 늘어지는 잉크 같은 무늬가 생깁니다. 시간에 따라 p를 흘려보내면 그 무늬가 계속 번지는 것처럼 애니메이션됩니다.

이 접근은 GPU 한 번의 픽셀 셰이더 연산만으로 끝나 렌더 타깃도, 이류 계산도 필요 없어 훨씬 가볍지만, 실제 유체처럼 장애물에 부딪히거나 외력(마우스 드래그 등)에 반응하지는 않습니다. 진짜 인터랙티브 유체가 필요하면 Stable Fluids를 렌더 타깃 핑퐁으로 구현해야 하고, 이 데모는 그 전 단계의 "보기에만 그럴듯한" 대안입니다.

언제 쓰나

배경 앰비언스, 로딩 화면, 마케팅 페이지의 "유기적으로 흐르는" 배경처럼 인터랙션이 필요 없는 장식적 유체 느낌에.