본문으로 건너뛰기
작업 노트로 돌아가기

바다를 코드로 만들기 three.js로 FFT 海洋 구현기

three.js(WebGPU + TSL)로 실시간 바다를 만들며 배웠습니다. 스펙트럴 합성에서 FFT까지, GPU 컴퓨트 최적화와 수중 표현, 그 사이의 삽질 기록입니다.

three.jsWebGPUFFT셰이더바다시뮬레이션

Photo by Free Nature Stock on StockSnap

three.js(WebGPU + TSL)로 실시간 바다를 만들면서 배운 것들을 정리했습니다. 결과 코드뿐 아니라 왜 그렇게 결정했는지, 어디서 삽질했는지까지 담은 학습 기록이에요.

이 글에서 다루는 것 / 선수지식

  • 다루는 것: 실시간 바다 표면(스펙트럴 → FFT), GPU 컴퓨트 셰이더 최적화, 부력, 수중 표현. three.js r184의 WebGPU + TSL(Three Shading Language) 기준.
  • 선수지식: three.js 기본, 셰이더 개념(정점/프래그먼트), 복소수·삼각함수 정도면 충분합니다. FFT를 몰라도 직관적으로 풀어 설명합니다.
  • 읽고 나면: "물 같은 물"이 어떤 원리에서 나오는지, GPU 컴퓨트로 무거운 연산을 어떻게 옮기고 최적화하는지, 그리고 레퍼런스를 따라 만들 때 무엇을 측정하고 무엇을 포기해야 하는지 감이 잡힙니다.

0. 시작: "왜 우리 바다는 호수 같지?"

목표는 단순했습니다. threejswaterpro.com 같은 바다를 우리 손으로 재현하기. 그런데 첫 결과물은 자꾸 "바다"가 아니라 "바람 부는 호수" 같았습니다. 표면이 통째로 미끄러지듯 움직이고 디테일을 넣으면 이번엔 모래알처럼 자글거렸죠. 이 글은 그 "호수"를 "바다"로 바꾸기까지의 기록입니다.

핵심 깨달음을 미리 말하면:

물의 매끈함은 "밴드제한된 사인파의 합"에서 나온다. 노이즈가 아니라 사인파를 더 더해야 한다. 그리고 그 사인파 수천 개를 한 번에 만드는 도구가 FFT다.

(그림: FFT 바다 파이프라인 — blog/fft-pipeline.svg, 에셋 미포함)

(영상: 완성된 바다 — blog/media/waves.mp4, 에셋 미포함)

1. 여정 한눈에 보기 (왜 단계마다 바뀌었나)

저는 이 "변천사"가 제일 재미있었습니다. 각 단계가 왜 막혔고 다음으로 넘어갔는지가 곧 스토리니까요.

단계시도결과 / 왜 넘어갔나
1Gerstner 파동(사인 8겹)정적임. 고정된 사인 합이 컨베이어벨트처럼 평행이동
2노이즈 디테일 노멀모래알 speckle. 노이즈 기울기는 본질적으로 거칠다
3밴드제한 스펙트럴(사인 ~48겹)매끈해짐! 디테일 = 노이즈가 아니라 더 많은 사인
4진짜 FFT 바다(Tessendorf)원본의 실제 기법. 스펙트럼 합성이 곧 매끈함의 정체
5Stockham 공유메모리 FFT17fps → 40fps. 병목은 텍셀이 아니라 디스패치 수
62캐스케이드 + whitecap + 부력 + 수중마무리 polish

메타 교훈: 최적화 전에 측정한다. 그리고 레퍼런스의 겉모습이 아니라 메커니즘을 맞춘다.

2. 바다 표면: 왜 FFT인가 (+ 노이즈가 실패한 이유)

물 표면은 결국 높이장 h(x,z,t)h(x, z, t)입니다. 만드는 방법 두 가지:

  • 사인 합 / Gerstner:
h=iAisin(kixωit+φi)h = \sum_i A_i \sin(\mathbf{k}_i \cdot \mathbf{x} - \omega_i t + \varphi_i)

사인은 매끈하니 결과도 매끈하고 법선도 해석적으로 깔끔합니다. 단, 고정된 사인 몇 개는 반복되는 행진 패턴처럼 보입니다.

  • FFT(Tessendorf): 바다 스펙트럼(파장별 에너지 분포)을 역FFT해서 수천 개의 파를 한 번에 만듭니다. 스펙트럼이 통계적(가우시안 노이즈로 시드)이라 반복되지 않고 물리적으로 그럴듯합니다.

🪤 삽질 #1: 모래알 표면. 미세 디테일을 mx_fractal_noise_float(프랙탈 노이즈)의 기울기로 넣었더니 표면이 사포처럼 자글거렸습니다. 노이즈는 고주파에 비주기적이라 그 기울기(법선)가 들쭉날쭉합니다. 해결: 노이즈를 빼고 사인파를 더 더한다. 사인의 합은 그 기울기도 매끈합니다.

3. FFT 바다 파이프라인 (src/water/fftWaves.js)

프레임마다 도는 흐름:

스펙트럼 h0(k) ──(최초 1회)──► 시간진화 h(k,t) ──► 역FFT ──► 높이/변위/법선 (공간 영역)

3.1 스펙트럼 h0(k)h_0(\mathbf{k}) (최초 1회)

텍셀 (x,y)(x,y)마다 파수벡터 k=(xN/2, yN/2)2π/L\mathbf{k} = (x - N/2,\ y - N/2)\cdot 2\pi/L (L = 패치 크기). Phillips 스펙트럼이 그 k\mathbf{k}의 에너지를 줍니다:

P(k)=Aexp ⁣(1/(kLwind)2)k4(k^w^)2exp(k2l2),Lwind=w2gP(\mathbf{k}) = A\,\frac{\exp\!\bigl(-1/(k\,L_\text{wind})^2\bigr)}{k^4}\,(\hat{\mathbf{k}} \cdot \hat{\mathbf{w}})^2\,\exp(-k^2 l^2), \qquad L_\text{wind} = \frac{w^2}{g} h0(k)=(ξr+iξi)P(k)/2,ξN(0,1)h_0(\mathbf{k}) = (\xi_r + i\,\xi_i)\sqrt{P(\mathbf{k})/2}, \qquad \xi \sim \mathcal{N}(0, 1)
  • 1/k41/k^4 → 긴 파가 지배. exp(1/(kLwind)2)\exp(-1/(kL_\text{wind})^2) → 바람보다 긴 파 억제. (k^w^)2(\hat{\mathbf{k}} \cdot \hat{\mathbf{w}})^2 → 바람 방향으로 에너지 집중. 끝의 exp(k2l2)\exp(-k^2 l^2)(ll=small=1.0) → 너무 잔 파(고주파)를 잘라내 모래알 노이즈를 막는 소파 억제항. 가우시안 노이즈가 매 인스턴스를 고유하게 만듭니다.
  • 🪤 노이즈는 반드시 Float32 (JS에서 Box–Muller → FloatType DataTexture). u8 노이즈를 쓰면 잘려서 깨집니다.

3.2 켤레대칭 (출력이 실수가 되려면)

실수 높이장은 h(k)=h(k)h(-\mathbf{k}) = \overline{h(\mathbf{k})}를 요구합니다. (h0(k), h0(k))\bigl(h_0(\mathbf{k}),\ \overline{h_0(-\mathbf{k})}\bigr)로 미리 패킹하되, 미러 텍셀은 ((Nx)modN, (Ny)modN)\bigl((N-x)\bmod N,\ (N-y)\bmod N\bigr). 🪤 이 미러 인덱스가 한 칸만 어긋나도 높이가 실수가 아니라 복소(노이즈)로 나옵니다.

3.3 시간진화 (매 프레임)

h(k,t)=h0(k)eiωt+h0(k)eiωt,ω=gkh(\mathbf{k}, t) = h_0(\mathbf{k})\,e^{i\omega t} + \overline{h_0(-\mathbf{k})}\,e^{-i\omega t}, \qquad \omega = \sqrt{g\,\lvert \mathbf{k} \rvert}

여기서 필요한 필드들의 스펙트럼을 뽑는데 실수 2개를 복소 1개로 패킹하는 트릭을 씁니다 (Z=F+iGZ = F + i\,G ⇒ 역FFT의 실수부 =f= f, 허수부 =g= g). 그래서 IFFT 3번으로:

  • (높이, 기울기X), (Dx, Dz), (기울기Z, 0)
  • 수평 변위(출렁임): Dx=ihkx/kD_x = i\,h\,k_x / \lvert \mathbf{k} \rvert, Dz=ihkz/kD_z = i\,h\,k_z / \lvert \mathbf{k} \rvert
  • 기울기(법선용): h=ihk\nabla h = i\,h\,\mathbf{k}

3.4 역FFT, 그리고 "화면 깨지는" 두 디테일

역FFT는 분리 가능한 2D 변환입니다. 행 전부 FFT, 그다음 열 전부 FFT. 마무리 두 가지를 빼먹으면 바로 망가집니다:

  • 🪤 (1)x+y(-1)^{x+y} ifftshift: 스펙트럼의 DC(0주파수)가 가운데 텍셀이라 공간 출력에 체커보드 부호 반전이 필요. 빼먹으면 → 체커보드/어긋난 표면.
  • 🪤 1/N21/N^2 정규화: IFFT 스케일. 빼먹으면 → 지오메트리가 N2N^2배(2562=65536256^2 = 65536) 폭발.

4. 성능: Stockham 공유메모리 FFT

처음 FFT는 17fps였습니다. URL 파라미터로 서브시스템을 끄며 측정했습니다 (?fft=0, ?reflect=0, ?shadow=1024, ?seg=96):

끈 것fps결론
baseline17
?fft=055FFT가 비용의 거의 전부
반사/그림자/세그먼트±1무관
?n=128 (FFT 절반)20거의 안 빨라짐 ← 결정적 단서

N을 줄여도 안 빨라졌다는 건 병목이 텍셀 연산이 아니라 컴퓨트 디스패치 개수라는 뜻이었습니다 (프레임당 ~56개: 2log2N2\log_2 N 버터플라이 × 3필드 + load/store/merge). 각 디스패치엔 고정 오버헤드 + 배리어가 붙죠.

해결: workgroup 공유메모리 Stockham FFT. 한 워크그룹이 한 행/열을 workgroupArray에 올려놓고 모든 log2N\log_2 N 단계를 공유메모리 안에서 workgroupBarrier()로 끝낸 뒤 한 번에 씁니다. 축당 단일 디스패치.

56 디스패치  →  8 디스패치
~17fps  →  ~40fps (헤드리스; 실기기 60+), 이제 N은 병목이 아님

Stockham을 고른 이유: 비트리버설도 트위들 텍스처도 필요 없습니다(출력 인터리브가 de-shuffle 역할). 그래서 workgroupArray 핑퐁에 딱 맞고 log2N\log_2 N이 컴파일타임 상수라 단계 루프를 JS로 언롤합니다.

블로그 팁: 이 표 하나가 이 글의 클라이맥스라고 생각해요. "N을 반으로 줄였는데 왜 안 빨라지지?"에서 "아, 디스패치가 문제였구나"로 넘어가는 순간에 저도 가장 크게 배웠습니다.

5. 부력: GPU 읽기 없이 (src/scene/Models.js)

배·부표가 보이는 파도를 타야 합니다. FFT 높이는 GPU StorageTexture에 있는데 매 프레임 CPU로 읽어오면 파이프라인이 멈춥니다.

결정: GPU와 같은 스펙트럼을 따르는 작은 CPU 모델을 둡니다. 최초에 같은 Phillips 스펙트럼·같은 노이즈로 가장 센 ~40개 모드를 뽑아 CPU에서 작은 사인 합을 계산합니다:

h(x,z)=k2h0(k)cos(kx+ωt+φ)buoyScaleh(x, z) = \sum_{\mathbf{k}} 2\lvert h_0(\mathbf{k}) \rvert \cos(\mathbf{k} \cdot \mathbf{x} + \omega t + \varphi)\cdot \text{buoyScale}

같은 k\mathbf{k}, ω\omega, 위상, 공유 uTime을 쓰니 배가 GPU가 그리는 그 너울을 탑니다.

🪤 삽질 #2: 5,900만 유닛 점프. buoyScaleN2N^2곱했더니(나눠야 했는데) 배가 5,900만 유닛 상공으로 솟구쳤습니다. IFFT 정규화(1/N21/N^2)와 짝을 맞춰야 합니다.

6. 캐스케이드 & whitecap (싸게 먹는 디테일)

  • 2 캐스케이드 (WaterMesh.js): FFT를 큰 너울(L=600)과 잔 디테일(L=90)로 두 번 인스턴스화해서 변위·기울기를 합산합니다. 타일 주기가 달라 반복 무늬가 가려집니다. Stockham이라 싸게 가능 (옛 ping-pong FFT면 2캐스케이드는 사실상 불가).
  • Jacobian whitecap: 부서지는 마루는 수평 변위가 접히는 곳. 표면 야코비안이 0 아래로 떨어집니다:
J=(1+Dxx)(1+Dzz)DxzDzxJ = \Bigl(1 + \frac{\partial D_x}{\partial x}\Bigr)\Bigl(1 + \frac{\partial D_z}{\partial z}\Bigr) - \frac{\partial D_x}{\partial z}\frac{\partial D_z}{\partial x}

이걸 변위 텍스처의 유한차분으로 프래그먼트에서(탭 4개, IFFT 추가 없음) 계산해 거품을 냅니다.

7. 수중: 깊이 기반 Beer–Lambert 흡수 (src/post/Post.js)

첫 수중 시도는 평면 청록 곱 + 하드 THREE.Fog이었는데 "유리천장 아래 밝은 씬" 같았지 잠긴 느낌이 아니었습니다.

진짜 단서(잠긴 느낌의 ~80%)는 거리 기반 흡수입니다. 물은 거리에 따라 빛을 지수적으로, 그것도 붉은색을 먼저 흡수해서 세상이 청록으로 물들고 멀리가 머크로 사라집니다.

dist = scenePass.getViewZNode().negate()   // 렌더된 지오메트리까지 거리(공짜)
T    = exp(−extinction · dist)              // 채널별 투과율, ext: R>G>B
color = mix(murk, scene, T)                 // 거리에 따라 머크로 페이드

핵심 API: PassNode.getViewZNode() 가 post 단계에서 씬 깊이를 공짜로 줍니다(패스가 이미 깊이를 렌더하니까). 추가로 해저 코스틱을 거리로 페이드, 부드러운 비네팅, 떠다니는 먼지 입자(Motes.js: 카메라 주변 셸의 가산 포인트 ~1300개, 수중에서만).

🪤 삽질 #3: 갓레이를 결국 뺐다. 화면스페이스 방사 블러로는 빛줄기가 안 보였습니다(진짜 갓레이는 차폐물 사이로 새는 빛의 대비가 필요). FPS도 깎였고(If 게이트가 텍스처 탭을 못 막음) 원본도 이 프리셋에선 갓레이를 끕니다. 언제 자를지 결정하는 것도 엔지니어링입니다.

8. 버그 & 함정 모음 (블로그의 꿀)

이 표가 아마 제일 공감 갈 대목일 거예요. "나도 저거 당했다" 싶은 순간들이니까요.

증상원인해결
FFT 표면이 통째로 안 보임/NaNDC 텍셀 k=(0,0)\mathbf{k}=(0,0)에서 normalize(k)=NaN → 전체 오염k\lvert k\rvert.max(1e-4)로 나누기
지오메트리 ×65536 폭발1/N21/N^2 정규화 누락최종 store에서 1회 적용
체커보드/어긋난 표면(1)x+y(-1)^{x+y} ifftshift 누락출력에서 1회 적용
배가 5,900만 유닛 점프buoyScaleN2N^2로 곱함(나눠야)buoyScale ≈ dispScale / N²
마루가 말려 올라감choppiness/변위 스케일 과다 → Gerstner 자기교차QkA1\sum Q\,k\,A \le 1 유지
모래알 표면노이즈 기울기 법선밴드제한 스펙트럴 사인
화면 전체 백화ACES 톤맵 숄더 클리핑(SkyMesh 광도 큼)노출↓ + 조명 ↑ (하늘 밝기와 표면 조명 분리)
물고기가 안 보임(씬엔 있음)Object3D.clone()스킨드 골격을 복제 안 함 → 붕괴SkeletonUtils.clone() + frustumCulled=false
갓레이가 수면 위에서도 FPS 깎음If(uniform)이 루프 내 텍스처 샘플을 못 건너뜀효과 제거

9. 의사결정 로그

결정선택이유기각한 대안
표면 모델FFT (+ 스펙트럴 폴백)원본의 실제 메커니즘; 비반복; 매끈노이즈 디테일(grainy), 순수 Gerstner(정적)
FFT 방식Stockham 공유메모리비트리버설 없음; 축당 1디스패치; 디스패치가 병목단계별 ping-pong(56), 트위들 텍스처 CT
저장소StorageTexture(float)머티리얼에서 샘플 쉬움; 깔끔한 핑퐁instancedArray 버퍼
스펙트럼Phillips항 적고 검증 쉬움JONSWAP/TMA(후속 업그레이드)
부력FFT 스펙트럼 매칭 CPU 모델GPU readback 멈춤 회피; 같은 너울프레임당 깊이 readback
whitecap프래그먼트 유한차분 야코비안추가 IFFT 없음; 쌈전용 미분 IFFT 필드
타일2 캐스케이드반복 깸; Stockham이라 쌈단일 패치(반복 보임)
수중깊이 기반 채널별 흡수잠긴 느낌의 지배 단서; 거의 공짜평면 틴트 + fog 벽
갓레이제거차폐 없으면 안 보임; 비쌈; 원본도 끔화면스페이스 방사 블러

10. 직접 돌려보기

npm install
npm run dev      # → http://127.0.0.1:5180

WebGPU 지원 브라우저(Chrome/Edge 113+). 수면 아래로 드래그하면 잠수, Preset 드롭다운으로 8개 분위기 전환. (WebGL에선 스펙트럴 바다로 자동 폴백)

참고문헌

  • Tessendorf, Simulating Ocean Water — FFT 海洋의 정전(Phillips, 분산, 출렁임, 거품).
  • GPU Gems 1, Ch.1 — Gerstner 해석적 법선.
  • WebTide / Popov72 GodotOceanWaves — 우리가 포팅한 FFT 컴퓨트 계보.
  • three.js 예제: objects/WaterMesh.js(TSL 물 관용구), gpgpu/BitonicSort.js(workgroupArray/workgroupBarrier/.compute(n,[size]) 패턴).