GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)

유체 시뮬레이션은 물이나 연기의 실제 나비에-스토크스 방정식을 그대로 푸는 게 아니라, 격자 위에 놓인 밀도·속도 배열을 매 프레임 이류하고 확산시키고 감쇠시켜 흐름을 흉내 내는 계산이다. 이 편은 라이브러리 없이 격자 한 장으로 캔버스 9종을 만든다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 왜 이 순서인가

이 시리즈의 축은 html5 canvas 애니메이션 9종의 픽셀 단위 굴절·투영이나 파티클 효과 9종의 입자 하나하나가 아니다. 매 프레임 Float32Array 격자 하나에 속도(u, v)와 밀도(d)를 저장해 두고, 그 격자 전체를 이류시킨 뒤 확산시키고 감쇠시키는 오일러식 안정 유체(Stable Fluids) 계산이 이 편의 정체다. 입자를 하나씩 쌓아 올리는 것도 아니고 파동방정식으로 표면 높이를 굴절시키는 것도 아니라, 칸마다 숫자 두세 개를 반복해서 갱신하는 것뿐이다.

01은 그 반복의 뼈대(후방 이류)만 보여주고, 02는 이웃과 값을 섞는 확산 한 단계를 떼어 보여준다. 03은 격자 근사가 깎아버린 회전을 계산으로 되돌리고, 04·05는 01의 뼈대에 각각 클릭 주입과 부력을 얹은 응용이다. 06은 초기 조건 하나만 심고 감쇠를 관찰하는 반대 방향의 실험이고, 07은 채널을 셋으로 늘린 변형, 08은 사람 손이 직접 힘을 넣는 인터랙션, 09는 계산 결과가 아니라 그 결과를 화면에 어떻게 확대해 그리느냐의 문제다. 순서대로 보면 유체 시뮬레이션 하나가 몇 개의 독립된 부품(이류·확산·와도·경계)으로 쪼개진다는 것이 드러난다.

01안정 유체 이류

무엇이 보이나

속도장이 자기 자신을 옮기는 자기 이류와 밀도를 옮기는 물질 이류가 한 화면에서 겹쳐 돈다 — 지금 칸이 이전 프레임의 어디서 왔는지 속도를 따라 거슬러 올라가 샘플링하는 후방 이류(backtrace)가, 시간 걸음이 커도 값이 발산하지 않게 하는 안정성의 전부다. 히어로 배경의 천천히 도는 유체에 쓴다.

안정 유체 이류
새 창에서 열기

01 · 안정 유체 이류 — 측정 누적면적 11.342% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Float32ArrayMath.hypotrequestAnimationFrame

코드에서 볼 곳

렌더 실측은 누적면적 11.342% · 강도 155.3 · 움직인 프레임 23/23이다.

function advect(b, dt) {
  var src = b === 'u' ? u0 : (b === 'v' ? v0 : d0);
  var dst = b === 'u' ? u : (b === 'v' ? v : d);
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      var x = i - dt * N * u0[IX(i, j)];
      var y = j - dt * N * v0[IX(i, j)];
      if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
      var i0 = x | 0; var i1 = i0 + 1;
      if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
      var j0 = y | 0; var j1 = j0 + 1;
      var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
      dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
                      s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
    }
  }
}

02확산 번짐

무엇이 보이나

셀마다 이웃 넷과 값을 조금씩 섞는 확산 한 단계가 잉크가 물에 번지는 스무딩이다 — 확산 계수를 크게 하면 데칼코마니처럼 부드럽게 퍼지고 0에 가까우면 경계가 선명하게 남는다. 물감 배경·수채 질감 섹션에 쓴다.

확산 번짐
새 창에서 열기

02 · 확산 번짐 — 측정 누적면적 45.362% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Float32ArrayputImageData

코드에서 볼 곳

렌더 실측은 누적면적 45.362% · 강도 31.5 · 움직인 프레임 23/23이다.

function diffuse(dt) {
  var a = dt * DIFF * N * N;
  for (var k = 0; k < ITER; k++) {
    for (var j = 1; j <= N; j++) {
      for (var i = 1; i <= N; i++) {
        d[IX(i, j)] = (d0[IX(i, j)] + a * (d[IX(i - 1, j)] + d[IX(i + 1, j)] + d[IX(i, j - 1)] + d[IX(i, j + 1)])) / (1 + 4 * a);
      }
    }
    bounds();
  }
}

03컬링 소용돌이

무엇이 보이나

속도장의 컬(회전 성분)을 중심차분으로 구하고 그 크기의 기울기 방향으로 힘을 되먹이는 보텍시티 강화가 눌려 죽던 연기를 다시 살린다 — 저해상 격자가 수치 점성으로 깎아버린 작은 소용돌이를 계산 한 단계로 돌려놓는다. 대형 배너의 살아 있는 연기에 쓴다.

컬링 소용돌이
새 창에서 열기

03 · 컬링 소용돌이 — 측정 누적면적 7.641% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Float32ArrayMath.hypot

코드에서 볼 곳

렌더 실측은 누적면적 7.641% · 강도 115.1 · 움직인 프레임 23/23이다.

function calcCurl() {
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      curl[IX(i, j)] = 0.5 * ((v0[IX(i + 1, j)] - v0[IX(i - 1, j)]) - (u0[IX(i, j + 1)] - u0[IX(i, j - 1)]));
    }
  }
}

function confine(dt) {
  for (var j = 2; j < N; j++) {
    for (var i = 2; i < N; i++) {
      var idx = IX(i, j);
      var gx = (Math.abs(curl[IX(i + 1, j)]) - Math.abs(curl[IX(i - 1, j)])) * 0.5;
      var gy = (Math.abs(curl[IX(i, j + 1)]) - Math.abs(curl[IX(i, j - 1)])) * 0.5;
      var len = Math.sqrt(gx * gx + gy * gy) + EPS;
      gx /= len; gy /= len;
      var c = curl[idx];
      u0[idx] += gx * c * VORT * dt;
      v0[idx] += gy * c * VORT * dt;
    }
  }
}

04잉크 번짐

무엇이 보이나

클릭한 자리에 밀도 한 덩어리를 심고 확산은 낮게 잡은 채 이류만 남기면, 종이 위 잉크가 젖은 경계를 그리며 자란다 — 밀도가 진한 코어일수록 곱셈 감쇠로 천천히 움직여 옅은 경계만 계속 퍼지는 번짐 단계가 보인다. 01과 같은 격자·advect 코드를 공유하며 카드 hover 잉크 배경에 쓴다.

잉크 번짐
새 창에서 열기

04 · 잉크 번짐 — 측정 누적면적 13.76% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.exppointerdown

코드에서 볼 곳

렌더 실측은 누적면적 13.76% · 강도 46.8 · 움직인 프레임 23/23이다.

for (var k = 0; k < SIZE; k++) {
  var decay = 1 - Math.min(0.7, d[k] * 0.32);
  u[k] *= decay; v[k] *= decay;
}
u0.set(u); v0.set(v); d0.set(d);
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);
diffuse('d', 0.0009, dt); // 확산은 아주 낮게 — 이류가 경계를 만드는 주역
bounds();
for (var m = 0; m < SIZE; m++) { d[m] *= 0.9985; u[m] *= 0.985; v[m] *= 0.985; }

05연기 상승

무엇이 보이나

밀도에 비례하는 부력을 속도장에 매 프레임 더하고 결정적 가우시안 노이즈로 옆을 살짝 흔들면 아래에서 피어오르는 연기 기둥이 된다 — 하단 고정 좌표 세 곳이 번갈아 점화해 기둥 여러 개가 동시에 피어오른다. 다크 모드 푸터의 연기 배경에 쓴다.

연기 상승
새 창에서 열기

05 · 연기 상승 — 측정 누적면적 47.947% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFrameMath.sin

코드에서 볼 곳

렌더 실측은 누적면적 47.947% · 강도 57.1 · 움직인 프레임 23/23이다.

// 부력 — 밀도가 진한 칸일수록 위로(v 마이너스) 밀어올린다. 온도장 없는 근사의 핵심 한 줄.
for (var k = 0; k < SIZE; k++) v0[k] -= Math.min(1.6, d0[k]) * BUOY * dt;
// 옆흔들림 — 결정적 가우시안 노이즈로 매 걸음 u 를 살짝 흔든다.
var h = Math.sin(seedT * 1.7) * 3.1;
for (var j = 1; j <= N; j++) {
  for (var i = 1; i <= N; i++) {
    var idx2 = IX(i, j);
    var sw = gnoise(i * 0.31 + h, j * 0.27 - h * 0.6) + gnoise(i * 0.13 - h * 0.4, j * 0.19 + h * 0.8) * 0.5;
    u0[idx2] += sw * WIND * dt * Math.min(1, d0[idx2] * 0.9);
  }
}
advect('u', dt); u0.set(u);
advect('v', dt); v0.set(v);
advect('d', dt);

06소용돌이 감쇠

무엇이 보이나

초기화 시점에 랭킨 소용돌이 속도장(중심은 강체 회전, 바깥은 거리에 반비례)을 딱 한 번 심고, 이후엔 약한 확산과 매 프레임 곱셈 감쇠만 걸어 스스로 흐려져 사라지게 둔다 — 감쇠율 한 값이 회전이 얼마나 오래 남는지를 정한다. 로딩 대기 화면의 느린 회오리에 쓴다.

소용돌이 감쇠
새 창에서 열기

06 · 소용돌이 감쇠 — 측정 누적면적 61.031% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.powFloat32Array

코드에서 볼 곳

렌더 실측은 누적면적 61.031% · 강도 26.8 · 움직인 프레임 20/23이다.

function seedVortex(cx, cy, dir) {
  u.fill(0); v.fill(0); d.fill(0);
  u0.fill(0); v0.fill(0); d0.fill(0);
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      var dx = i - cx, dy = j - cy;
      var r = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      var vTheta = r <= R_CORE ? OMEGA * r : OMEGA * (R_CORE * R_CORE) / r;
      var idx = IX(i, j);
      u[idx] = dir * (-dy / r) * vTheta;
      v[idx] = dir * (dx / r) * vTheta;
      var ring = Math.exp(-Math.pow((r - R_CORE * 1.15) / (R_CORE * 0.85), 2));
      d[idx] = ring * 1.4;
    }
  }
}

07염료 혼합

무엇이 보이나

밀도 채널 세 개(RGB)를 각각 독립적으로 이류·확산시키면 두 색이 물처럼 섞이며 중간색이 부드럽게 파생된다 — 색을 미리 섞어 그리는 게 아니라 필드 계산의 결과로 섞인다. 브랜드 두 색을 섞는 히어로에 쓴다.

염료 혼합
새 창에서 열기

07 · 염료 혼합 — 측정 누적면적 43.042% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

putImageDataFloat32Array

코드에서 볼 곳

렌더 실측은 누적면적 43.042% · 강도 21.6 · 움직인 프레임 23/23이다.

function step(dt) {
  velSource();
  u0.set(u); v0.set(v);
  advect(u, u0, dt); u0.set(u);
  advect(v, v0, dt); v0.set(v);
  velBounds();

  r0.set(r); g0.set(g); b0.set(b);
  inject(seedT);
  diffuse(r, r0, 0.09); diffuse(g, g0, 0.09); diffuse(b, b0, 0.09);
  r0.set(r); g0.set(g); b0.set(b);
  advect(r, r0, dt); advect(g, g0, dt); advect(b, b0, dt);
  dyeBounds(r); dyeBounds(g); dyeBounds(b);
  for (var k = 0; k < SIZE; k++) { r[k] *= 0.996; g[k] *= 0.996; b[k] *= 0.996; }

08포인터 휘젓기

무엇이 보이나

pointermove 의 이전·현재 위치 차이를 속도장에 지수 반경으로 스플랫하면 커서가 유체를 실제로 저어 휘젓는 만큼 물감 자국이 남는다 — 이동 속도가 빠를수록 힘이 세게 들어간다. 놀면서 만지는 갤러리 인트로에 쓴다.

포인터 휘젓기
새 창에서 열기

08 · 포인터 휘젓기 — 측정 누적면적 51.135% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermoverequestAnimationFrame

코드에서 볼 곳

렌더 실측은 누적면적 51.135% · 강도 62.1 · 움직인 프레임 23/23이다.

function splat(gx, gy, fx, fy, amt) {
  var rad = 7;
  for (var j = -rad; j <= rad; j++) {
    for (var i = -rad; i <= rad; i++) {
      var q = (i * i + j * j) / (rad * rad);
      if (q > 1) continue;
      var gi = gx + i, gj = gy + j;
      if (gi < 1 || gi > N || gj < 1 || gj > N) continue;
      var f = Math.exp(-q * 1.8) * amt;
      var idx = IX(gi, gj);
      u[idx] += fx * f; v[idx] += fy * f;
      d[idx] = Math.min(2.4, d[idx] + f * 0.62);
    }
  }
}

09밀도장 렌더링

무엇이 보이나

시뮬레이션이 들고 있는 데이터는 32×32 숫자 배열 하나뿐이라, 그 값을 화면 해상도로 어떻게 확대해 그리느냐에 따라 같은 데이터가 픽셀 블록으로도 매끈한 그라디언트로도 보인다 — 최근접 이웃과 쌍선형 보간을 나란히 두고 비교한다. 저해상도 계산을 고해상도로 보여주는 비교 데모에 쓴다.

밀도장 렌더링
새 창에서 열기

09 · 밀도장 렌더링 — 측정 누적면적 54.627% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

createImageDataimageSmoothingEnabled

코드에서 볼 곳

렌더 실측은 누적면적 54.627% · 강도 40.0 · 움직인 프레임 23/23이다.

function render() {
  paintSource();
  var w = cvA.width, h = cvA.height;
  ctxA.imageSmoothingEnabled = false;
  ctxA.clearRect(0, 0, w, h);
  ctxA.drawImage(src, 0, 0, w, h);
  ctxB.imageSmoothingEnabled = true;
  ctxB.imageSmoothingQuality = 'high';
  ctxB.clearRect(0, 0, w, h);
  ctxB.drawImage(src, 0, 0, w, h);
}

유체 시뮬레이션 프로그램은 격자 위에서 무엇을 계산하나?

세 단계만 반복한다

9종 전부가 매 프레임 같은 세 단계를 돈다 — 힘을 더한다(부력·클릭·컬 강화), 이류시킨다(속도를 따라 값을 거슬러 샘플링한다), 확산·감쇠시킨다(이웃과 섞고 곱셈으로 줄인다). 순서를 바꾸거나 한 단계를 빼면 01~09 중 다른 항목이 나온다는 것이 이 시리즈의 핵심이다.

왜 격자인가

입자 하나하나의 위치를 쌓는 방식과 달리, 격자는 칸 수가 고정돼 있어 값이 아무리 오래 쌓여도 계산량이 늘지 않는다. 32×32 라는 작은 크기 하나로 9가지 서로 다른 장면을 만들 수 있는 이유이기도 하다.

파이썬 유체 시뮬레이션과 무엇이 다른가

같은 이류·확산·감쇠 절차를 파이썬 유체 시뮬레이션 코드로 옮겨도 결과는 똑같다 — 언어가 아니라 격자 배열과 반복문이 이 계산의 본질이기 때문이다. 이 편이 JS 로 쓰인 것은 브라우저에서 바로 돌아가야 하기 때문일 뿐, 무료 유체 시뮬레이션 예제를 다른 환경에서 재현하고 싶다면 advect·diffuse 두 함수만 옮기면 된다.

어디서 깨지는가 — 함정

압력 투영을 생략했다

이 9종은 전부 속도장의 발산을 0으로 만들어 부피를 보존하는(비압축성) 포아송 방정식, 즉 압력 투영(pressure projection) 단계를 풀지 않는다. 원래의 안정 유체 알고리즘이라면 이류 다음에 이 계산이 들어가야 하는데, 여기서는 그 자리를 감쇠 계수 하나로 때웠다. 그래서 08 포인터 휘젓기처럼 힘을 세게, 빠르게 여러 번 넣으면 밀도가 한 칸에 국소적으로 폭발적으로 쌓이는 순간이 생긴다.

유체 시뮬레이션 프로그램이라 부르기엔 근사가 많다

압력 투영이 빠졌다는 것은 이 데모들이 실제 유체 시뮬레이션 프로그램이 아니라 "그럴듯해 보이는" 시각적 근사라는 뜻이다. 실무에서 물리적으로 정확한 유체가 필요하다면 이 9종이 아니라 SPH 나 격자 기반 솔버(예: Verlet 물리엔진 9종 같은 위치기반 적분 방식)를 따로 찾아야 한다. 2zk3kwsz 안의 소스에는 그 생략을 주석으로 표시해 뒀으니, 실제 프로젝트에 쓰기 전에 먼저 읽어 보길 권한다.

변형 세 가지와 접근성

확산 계수를 올리면

02의 확산 계수를 0.02에서 0.30으로 올리면 잉크가 배수처럼 넓게 번지고 경계가 물러진다. 반복 이완 횟수는 그대로 두고 계수만 바꾸는 것으로 충분하다.

소용돌이 강화를 키우면

03의 컬 강화(VORT)를 12에서 30으로 올리면 연기 가장자리에 잔물결 소용돌이가 늘어난다. 값을 지나치게 키우면 격자가 흔들려 보이므로 30 근처가 안전선이다.

색을 바꾸면

$subject-blue 를 $subject-mint 로 바꾸면 차가운 잉크가 상큼한 민트 염료로 바뀐다. 계산 로직은 그대로고 팔레트 변수만 바뀐다.

접근성이 남기는 정지 프레임

9종 전부 prefers-reduced-motion 이 감지되면 시뮬레이션을 40스텝만 미리 돌려, 흐름이 이미 자리 잡은 정지 프레임 한 장만 그리고 멈춘다. 이후에도 포인터로 클릭하거나 움직이면 그 결과를 한 프레임씩 갱신해 보여준다. 어느 쪽이든 화면이 완전히 빈 채로 남지는 않는다.

FAQ

유체 시뮬레이션은 물리 엔진과 다른가?

다르다. 물리 엔진은 대개 강체나 입자 사이의 충돌·제약을 계산하고, 이 편의 유체 시뮬레이션은 격자 위 밀도·속도 필드를 이류·확산시키는 오일러식 계산이다. 계산 대상 자체가 다르다.

이 데모를 실제 프로젝트에 그대로 쓸 수 있나?

시각 효과로는 그대로 쓸 수 있지만, 압력 투영을 생략한 근사이므로 물리적으로 정확한 유체가 필요한 시뮬레이션(공학용 CFD 등)에는 맞지 않는다. 배경·인터랙션 장식 용도에 맞춰져 있다.

격자 크기(N)를 키우면 더 사실적인가?

세밀해지긴 하지만 계산량이 N의 제곱으로 늘어 60fps 를 유지하기 어려워진다. 이 9종은 32×32 로 캔버스 애니메이션에 필요한 만큼만 세밀하게 잡은 값이다.


참고: MDN getImageData · MDN putImageData · MDN Float32Array · MDN requestAnimationFrame · MDN pointermove · MDN prefers-reduced-motion · Wikipedia: Fluid simulation

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.