
바다를 코드로 만들기 three.js로 FFT 海洋 구현기
three.js(WebGPU + TSL)로 실시간 바다를 만들며 배웠습니다. 스펙트럴 합성에서 FFT까지, GPU 컴퓨트 최적화와 수중 표현, 그 사이의 삽질 기록입니다.
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. 여정 한눈에 보기 (왜 단계마다 바뀌었나)
저는 이 "변천사"가 제일 재미있었습니다. 각 단계가 왜 막혔고 다음으로 넘어갔는지가 곧 스토리니까요.
| 단계 | 시도 | 결과 / 왜 넘어갔나 |
|---|---|---|
| 1 | Gerstner 파동(사인 8겹) | 정적임. 고정된 사인 합이 컨베이어벨트처럼 평행이동 |
| 2 | 노이즈 디테일 노멀 | 모래알 speckle. 노이즈 기울기는 본질적으로 거칠다 |
| 3 | 밴드제한 스펙트럴(사인 ~48겹) | 매끈해짐! 디테일 = 노이즈가 아니라 더 많은 사인 |
| 4 | 진짜 FFT 바다(Tessendorf) | 원본의 실제 기법. 스펙트럼 합성이 곧 매끈함의 정체 |
| 5 | Stockham 공유메모리 FFT | 17fps → 40fps. 병목은 텍셀이 아니라 디스패치 수 |
| 6 | 2캐스케이드 + whitecap + 부력 + 수중 | 마무리 polish |
메타 교훈: 최적화 전에 측정한다. 그리고 레퍼런스의 겉모습이 아니라 메커니즘을 맞춘다.
2. 바다 표면: 왜 FFT인가 (+ 노이즈가 실패한 이유)
물 표면은 결국 높이장 입니다. 만드는 방법 두 가지:
- 사인 합 / Gerstner:
사인은 매끈하니 결과도 매끈하고 법선도 해석적으로 깔끔합니다. 단, 고정된 사인 몇 개는 반복되는 행진 패턴처럼 보입니다.
- FFT(Tessendorf): 바다 스펙트럼(파장별 에너지 분포)을 역FFT해서 수천 개의 파를 한 번에 만듭니다. 스펙트럼이 통계적(가우시안 노이즈로 시드)이라 반복되지 않고 물리적으로 그럴듯합니다.
🪤 삽질 #1: 모래알 표면. 미세 디테일을
mx_fractal_noise_float(프랙탈 노이즈)의 기울기로 넣었더니 표면이 사포처럼 자글거렸습니다. 노이즈는 고주파에 비주기적이라 그 기울기(법선)가 들쭉날쭉합니다. 해결: 노이즈를 빼고 사인파를 더 더한다. 사인의 합은 그 기울기도 매끈합니다.
3. FFT 바다 파이프라인 (src/water/fftWaves.js)
프레임마다 도는 흐름:
스펙트럼 h0(k) ──(최초 1회)──► 시간진화 h(k,t) ──► 역FFT ──► 높이/변위/법선 (공간 영역)3.1 스펙트럼 (최초 1회)
텍셀 마다 파수벡터 (L = 패치 크기). Phillips 스펙트럼이 그 의 에너지를 줍니다:
- → 긴 파가 지배. → 바람보다 긴 파 억제. → 바람 방향으로 에너지 집중. 끝의 (=
small=1.0) → 너무 잔 파(고주파)를 잘라내 모래알 노이즈를 막는 소파 억제항. 가우시안 노이즈가 매 인스턴스를 고유하게 만듭니다. - 🪤 노이즈는 반드시 Float32 (JS에서 Box–Muller →
FloatTypeDataTexture). u8 노이즈를 쓰면 잘려서 깨집니다.
3.2 켤레대칭 (출력이 실수가 되려면)
실수 높이장은 를 요구합니다. 로 미리 패킹하되, 미러 텍셀은 . 🪤 이 미러 인덱스가 한 칸만 어긋나도 높이가 실수가 아니라 복소(노이즈)로 나옵니다.
3.3 시간진화 (매 프레임)
여기서 필요한 필드들의 스펙트럼을 뽑는데 실수 2개를 복소 1개로 패킹하는 트릭을 씁니다 ( ⇒ 역FFT의 실수부 , 허수부 ). 그래서 IFFT 3번으로:
- (높이, 기울기X), (Dx, Dz), (기울기Z, 0)
- 수평 변위(출렁임): ,
- 기울기(법선용):
3.4 역FFT, 그리고 "화면 깨지는" 두 디테일
역FFT는 분리 가능한 2D 변환입니다. 행 전부 FFT, 그다음 열 전부 FFT. 마무리 두 가지를 빼먹으면 바로 망가집니다:
- 🪤 ifftshift: 스펙트럼의 DC(0주파수)가 가운데 텍셀이라 공간 출력에 체커보드 부호 반전이 필요. 빼먹으면 → 체커보드/어긋난 표면.
- 🪤 정규화: IFFT 스케일. 빼먹으면 → 지오메트리가 배() 폭발.
4. 성능: Stockham 공유메모리 FFT
처음 FFT는 17fps였습니다. URL 파라미터로 서브시스템을 끄며 측정했습니다 (?fft=0, ?reflect=0, ?shadow=1024, ?seg=96):
| 끈 것 | fps | 결론 |
|---|---|---|
| baseline | 17 | |
?fft=0 | 55 | FFT가 비용의 거의 전부 |
| 반사/그림자/세그먼트 | ±1 | 무관 |
?n=128 (FFT 절반) | 20 | 거의 안 빨라짐 ← 결정적 단서 |
N을 줄여도 안 빨라졌다는 건 병목이 텍셀 연산이 아니라 컴퓨트 디스패치 개수라는 뜻이었습니다 (프레임당 ~56개: 버터플라이 × 3필드 + load/store/merge). 각 디스패치엔 고정 오버헤드 + 배리어가 붙죠.
해결: workgroup 공유메모리 Stockham FFT. 한 워크그룹이 한 행/열을 workgroupArray에 올려놓고 모든 단계를 공유메모리 안에서 workgroupBarrier()로 끝낸 뒤 한 번에 씁니다. 축당 단일 디스패치.
56 디스패치 → 8 디스패치
~17fps → ~40fps (헤드리스; 실기기 60+), 이제 N은 병목이 아님Stockham을 고른 이유: 비트리버설도 트위들 텍스처도 필요 없습니다(출력 인터리브가 de-shuffle 역할). 그래서 workgroupArray 핑퐁에 딱 맞고 이 컴파일타임 상수라 단계 루프를 JS로 언롤합니다.
블로그 팁: 이 표 하나가 이 글의 클라이맥스라고 생각해요. "N을 반으로 줄였는데 왜 안 빨라지지?"에서 "아, 디스패치가 문제였구나"로 넘어가는 순간에 저도 가장 크게 배웠습니다.
5. 부력: GPU 읽기 없이 (src/scene/Models.js)
배·부표가 보이는 파도를 타야 합니다. FFT 높이는 GPU StorageTexture에 있는데 매 프레임 CPU로 읽어오면 파이프라인이 멈춥니다.
결정: GPU와 같은 스펙트럼을 따르는 작은 CPU 모델을 둡니다. 최초에 같은 Phillips 스펙트럼·같은 노이즈로 가장 센 ~40개 모드를 뽑아 CPU에서 작은 사인 합을 계산합니다:
같은 , , 위상, 공유 uTime을 쓰니 배가 GPU가 그리는 그 너울을 탑니다.
🪤 삽질 #2: 5,900만 유닛 점프.
buoyScale을 로 곱했더니(나눠야 했는데) 배가 5,900만 유닛 상공으로 솟구쳤습니다. IFFT 정규화()와 짝을 맞춰야 합니다.
6. 캐스케이드 & whitecap (싸게 먹는 디테일)
- 2 캐스케이드 (
WaterMesh.js): FFT를 큰 너울(L=600)과 잔 디테일(L=90)로 두 번 인스턴스화해서 변위·기울기를 합산합니다. 타일 주기가 달라 반복 무늬가 가려집니다. Stockham이라 싸게 가능 (옛 ping-pong FFT면 2캐스케이드는 사실상 불가). - Jacobian whitecap: 부서지는 마루는 수평 변위가 접히는 곳. 표면 야코비안이 0 아래로 떨어집니다:
이걸 변위 텍스처의 유한차분으로 프래그먼트에서(탭 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 표면이 통째로 안 보임/NaN | DC 텍셀 에서 normalize(k)=NaN → 전체 오염 | .max(1e-4)로 나누기 |
| 지오메트리 ×65536 폭발 | 정규화 누락 | 최종 store에서 1회 적용 |
| 체커보드/어긋난 표면 | ifftshift 누락 | 출력에서 1회 적용 |
| 배가 5,900만 유닛 점프 | buoyScale을 로 곱함(나눠야) | buoyScale ≈ dispScale / N² |
| 마루가 말려 올라감 | choppiness/변위 스케일 과다 → Gerstner 자기교차 | 유지 |
| 모래알 표면 | 노이즈 기울기 법선 | 밴드제한 스펙트럴 사인 |
| 화면 전체 백화 | 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:5180WebGPU 지원 브라우저(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])패턴).