파도가 말려 들어가는 자리
모래톱에서 솟구쳐 둥근 터널로 말리는 파도와 모래사장에 스며드는 물결을 브라우저 안에서 실시간으로 그려내기. 천수 방정식, 정역학 복원, 아이코날 전파, 단조 스플라인, 해수 광학을 따라 「숨 쉬는 해변」을 처음부터 다시 만들어 봅니다.

밀려오고, 부서지고, 모래에 스며들고, 다시 바다로 돌아가는 물.
해변에 서서 밀려오는 파도를 가만히 바라보면, 물은 한 가지 법칙으로만 움직이지 않습니다. 먼바다에서는 완만한 너울이 서로 다른 각도로 겹치며 은빛 윤슬을 반짝이다가, 수심이 얕아지는 모래톱에 들어서면 파도의 앞면이 급격히 가팔라집니다. 어느 순간 파도 마루(crest)의 가장 높은 지점이 버티지 못하고 앞으로 고개를 숙이면, 물의 장막이 허공으로 둥글게 말리며 반투명한 비취색 터널(barrel)을 만듭니다. 그 얇은 물 지붕이 앞쪽 수면을 때리는 순간 수천 개의 물방울과 하얀 포말이 터져 나오고, 거품 섞인 얕은 물줄기(swash)가 해안선의 바위 틈을 감싸며 모래사장 위로 기어오릅니다. 물이 빠져나간 자리에는 젖은 모래의 얇은 거울 막이 1~2초 반짝이다 사그라들고, 모래 알갱이 틈에 스며든 어두운 습기만 한참 동안 남습니다.
숨 쉬는 해변 (The breathing shore)은 이 일련의 물리 현상을 미리 구워 둔 애니메이션 루프 없이, 브라우저 안의 실시간 유체 시뮬레이션과 광학 셰이더만으로 매 순간 새로 만들어 내는 실험입니다. 이 글에서는 외해의 파군 스펙트럼부터 천수 방정식, 한 지점에 높이 하나만 적는 수면 격자의 한계를 넘어 허공으로 둥글게 말리는 3차원 전복 쇄파(plunging breaker), Eikonal 도착 시각 완화, 이중 물질 좌표 거품 수송, 해수 광학, 그리고 ANGLE/D3D11의 셰이더 컴파일 병목을 깎아낸 시스템 엔지니어링까지 차례로 따라가며 직접 만져 볼 수 있는 실험실로 풀어 봅니다.
- 천수 도달 시간()과 비주기 2D 파군 스펙트럼 — 수심이 얕아질수록 파장이 압축되고 마루가 휘어지는 외해 입사파를 주기가 되풀이되지 않게 합성합니다.
- 천수 방정식과 '정지한 호수'의 역설(Lake at Rest) — 경사진 해변과 암초 위에서 수면 보존 재구성(Audusse et al., 2004)과 HLL 리만 해법으로 유령 해류 없이 밀물과 썰물을 적분합니다.
- 한 점에 높이 하나만 적는 격자를 넘어: 물질 좌표 단면과 5차 베지에 둥근 코 — 수면 격자로는 그릴 수 없는 말리는 파도 터널을 Fritsch–Carlson 단조 헤르미트 보간과 타원·5차 다항식 캡, 그리고 2단계 Newton 역투영으로 빚어냅니다.
- 1D Eikonal 도착 시각 완화와 필링(Peeling) 전파 — 857개 해안선 레인 위에서 파도가 한꺼번에 무너지지 않고 옆으로 찢어지듯 번져 나가는 도착 시각 장 을 풉니다.
- 이중 물질 좌표 이류와 3단계 거품·기포 동역학 — 4초 주기로 교차 갱신되는 두 장의 물질 좌표 맵, 와도(curl)를 제거한 순수 변형률, 그리고 30 Hz에서도 오버슈트하지 않는 기포 부력의 해석적 적분을 결합합니다.
- 화면 공간 굴절 이분 탐색과 Beer–Lambert 이중 광경로 — 12단계 선형 행진과 4단계 이분 탐색으로 굴절된 모래 바닥을 찾고, 얇은 물 지붕의 비취색 역광과 기울기 분산(slope-variance) 미세면 윤슬을 계산합니다.
- GLSL 호출 그래프 가지치기와 컴파일러 인라이닝 방어 — Windows ANGLE(
fxc)에서 수십 초 걸리던 콜드 셰이더 컴파일과 초기 에셋 로딩을 절반 이하로 줄인 브라우저 시스템 최적화 기법을 살펴봅니다.
전체 파이프라인 한눈에 보기
해변 하나를 실시간으로 움직이게 하려면 서로 다른 시간 척도와 공간 해상도를 가진 계산들을 분리해야 합니다. 외해의 큰 물결과 모래사장의 얇은 소상(swash)은 120 Hz의 2차원 천수 방정식 격자(768 × 1536)가 담당하고, 오두막 앞의 촘촘한 바위 지대는 부모 격자에서 경계 조건을 넘겨받아 360 Hz의 세밀한 부분 격자(256 × 1536)에서 따로 적분합니다. 그 위에서 파도가 말려 넘어가는 쇄파 전선과 거품 수송은 30 Hz로 갱신되며, 최종 화면은 매 프레임(60 Hz) 수면 캐시와 화면 공간 굴절·산란 패스를 거쳐 합성됩니다.
1. 먼바다에서 해변으로: 천수 도달 시간과 비주기 파군
깊은 바다에서 바람이 만든 파도는 수심이 파장에 비해 충분히 깊을 때 분산 관계 를 따르며, 파장이 긴 너울일수록 빠르게 달립니다. 하지만 파도가 완만하게 경사진 해변으로 다가와 수심 가 파장에 비해 얕아지면(), 가 되면서 파도의 위상 속도 는 오직 바닥 수심 의 제곱근에만 비례하게 됩니다.
해안선()에서 바다 쪽 거리 에 따라 수심이 평균 경사 로 깊어진다고 두면(), 외해에서 출발한 파도 위상이 거리 까지 도달하는 데 걸리는 천수 도달 시간(shoaling travel time) 는 의 정적분으로 얻어집니다.
이 의존성이 해변 파도의 기하학을 결정합니다. 첫째, 해안에 가까워질수록() 가 커지면서 파장이 촘촘하게 압축되고 뒤따라오는 물이 앞선 물을 추월하려 하며 파고가 솟아오릅니다(천수 변형, shoaling). 둘째, 비스듬한 각도 로 들어오는 파도는 해안에 먼저 닿은 쪽이 느려지면서 해안선과 나란한 방향으로 휘어집니다(파랑 굴절, wave refraction).
다만 를 수평선 끝까지 그대로 확장하면, 먼바다의 위상 변화율이 지나치게 완만해져 수평선 근처의 물결이 넓고 평평한 띠처럼 늘어집니다. 그래서 실제 구현(shore-wave-spectrum.ts)에서는 천수 압축을 해안에서 까지만 적용하고, 그 너머 외해()에서는 의 위상 속도 로 등속 전파되도록 연속으로 이어 붙입니다.
여기에 또 하나의 중요한 공학적 선택이 들어갑니다. 흔히 실시간 바다 렌더링에서 쓰는 고정 주파수 사인파 합()은 몇 초만 지켜봐도 같은 마루 무늬가 격자처럼 되풀이됩니다. seaIncident()는 이를 피하기 위해 세 개의 파군(family, 기준 입사각 )을 각각 서로소에 가까운 시간 창()으로 쪼개고, 도착하는 매 파군마다 고유한 해시 시드(seaHash)로 방향·주파수·폭을 새로 뽑습니다. 나아가 파군 내부에 5차 다항식 보간 노이즈(seaNoise)로 2차원 진폭 포락선(coverage)과 위상 굴곡(bend)을 함께 걸어, 직선 자로 그은 듯한 파도 줄무늬 대신 해안선을 따라 자연스럽게 끊어지고 굽이치는 유한한 파도 마루들을 만들어 냅니다.
float offshore = max(coast.x, 0.);
float travel = 2. * sqrt(min(offshore, 38.) / (9.81 * .065))
+ max(offshore - 38., 0.) / sqrt(9.81 * .065 * 38.);
// ...
vec2 packet = vec2(coast.x + time * 1.7, coast.y) / width;
float coverage = seaNoise(packet * vec2(.43, 1.) + vec2(seed, id * .73));
float bend = seaNoise(packet * vec2(.31, .83) + vec2(id * 1.37, seed + 11.));
float amplitude = familyWeight * (.28 + .48 * r) * smoothstep(.18, .78, coverage) * window;
float phase = omega * age + seaHash(vec2(id, seed + 47.)) * 6.283185 + 3.2 * (bend - .5);
float height = amplitude * sin(phase);
result += vec3(height, -height * direction);아래 실험실에서 파군을 하나씩 분리해 보거나, 천수 도달 시간()과 2D 노이즈 위상 굴곡을 껐다 켜 보면서 파도의 평면 무늬와 단면 파장이 어떻게 달라지는지 확인해 보세요.
2. 천수 방정식과 '정지한 호수'의 역설 (Well-Balanced SWE)
외해에서 도착한 파군(seaIncident)은 해안에서 약 떨어진 경계선에서 2차원 비선형 천수 방정식(Nonlinear Shallow-Water Equations, SWE) 격자로 넘겨집니다. 천수 방정식은 수심 와 수심 적분 유량(운동량) 에 대한 질량 및 운동량 보존 법칙입니다.
여기서 는 모래 바닥과 바위의 높이(지형)이고, 자유 수면의 절대 높이는 입니다. 식 자체는 고전적이지만, 이를 경사진 모래사장과 물 밖으로 솟은 바위 위에서 풀려고 하면 수치해석의 유명한 함정인 정지한 호수의 역설(Lake at Rest Paradox)과 마주치게 됩니다.
왜 가만히 있는 물에서 저절로 파도가 생길까?
바람도 파도도 없이 완전히 고요한 호수나 바닷가를 생각해 봅시다. 유속이 이고 물 표면이 수평()이면, 물은 영원히 정지해 있어야 합니다. 연속 방정식에서 이 정지 상태가 유지되는 이유는 압력 구배력 와 해저 경사 중력항 가 정확히 크기가 같고 부호가 반대여서:
으로 완벽히 상쇄되기 때문입니다. 그런데 유한체적법(Finite Volume Method)에서 좌우 셀 경계의 압력 플럭스 은 리만 해법(HLL 등)으로 비선형 계산하고, 우변의 바닥 경사 는 셀 중심 차분으로 따로 계산하면 두 항의 이산화 오차가 서로 맞지 않습니다. 특히 물이 얕아지며 마른 모래와 만나는 해안선()이나 수중 암초의 급경사 턱에서는 이 불일치가 매 타임스텝마다 가짜 가속도(phantom current)를 만들어 내어, 가만히 두어도 해안선에서 유령 파도가 끊임없이 솟구칩니다.
// 좌우 셀에서 복원된 바닥 높이 중 최댓값 zE를 기준으로 경계 수심 hEp, hEn을 재정의
float zE = max(zRp, zRn), hEp = max(0., hRp + zRp - zE), hEn = max(0., hRn + zRn - zE);
vec3 fE = fluxX(vec3(hEp, hEp * uRp), vec3(hEn, hEn * uRn));
// ...
// 경계 재구성과 셀 내부 수심 사이의 정역학 압력 보정
float sE = .5 * G * (hEp * hEp - hRp * hRp), sW = .5 * G * (hWn * hWn - hLn * hLn);
float sC = .5 * G * (hLn + hRp) * (zLn - zRp);
float hu = c.y - dt * ((fE.y - fW.y + sE - sW + sC) / d.x + (fN.y - fS.y) / d.y);아래 실험실에서 정지한 호수의 역설 모드를 선택해 Audusse 정역학 복원과 단순 경사 차분을 비교해 보세요. 단순 차분으로 바꾸는 순간, 아무 외력이 없는데도 해안선과 암초 턱에서 유령 해류와 파도가 솟구치는 모습을 볼 수 있습니다. 해안 소상 · 모래 침투 모드로 전환하면 투수성 모래 침투(sandSink)와 Manning 바닥 마찰()을 조절하며 파도를 직접 밀어 올려 볼 수 있습니다.
다중 격자 중첩(Grid Nesting): 144 m 해안과 12 m 바위 밭을 동시에 풀기
전체 해안선은 가로·세로 의 넓은 공간이지만, 해변 오른쪽 오두막 앞에는 지름 수십 센티미터짜리 바위 수십 개가 촘촘히 박혀 있습니다. 전체 해안을 바위 틈이 분해될 만큼 촘촘하게 나누려면 수천만 셀이 필요해 브라우저에서 120 Hz로 돌릴 수 없습니다.
그래서 shore-renderer.ts는 기상·해양 모델링에서 쓰는 1방향 다중 격자 중첩(One-Way Grid Nesting)을 사용합니다:
- 부모 해안 격자 (
768 × 1536, 120 Hz): 전체 해안을 풀며 큰 너울의 굴절과 전체 해안선 소상을 계산합니다. - 자식 암초 격자 (
256 × 1536, 360 Hz): 오두막 앞 바위 지대()만 4.7배 조밀한 해상도로 잘라내어, 가장자리 셀에서는 부모 격자의 를 경계 조건으로 주입받고 내부에서는 더 높은 Manning 조도 계수()와 바위 지형 으로 물줄기의 갈라짐과 처오름(runup)을 정밀하게 적분합니다. 이때 영구히 물에 잠기지 않는 높은 바위 꼭대기 셀(SHORE_DRY_ROCK)은 스텐실 텍스처 읽기를 아예 건너뛰어 GPU 대역폭을 아낍니다.

부모 해안 격자 (768×1536 · 120 Hz)

자식 암초 격자 (256×1536 · 360 Hz)
3. 한 점에 높이 하나만 적는 격자를 넘어: 물질 좌표 단면과 5차 베지에 둥근 코
천수 방정식은 2차원 격자 위의 각 위치 마다 수면의 높이 를 딱 하나만 기록합니다. 지도 위의 한 점에 숫자 하나만 적는 방식(1가 함수, single-valued function)이기 때문에, 파도가 아무리 가팔라져도 수직 절벽(shock/bore)까지만 될 수 있을 뿐 파도 끝(lip)이 앞쪽 허공으로 몸을 날려 그 아래에 빈 공기 터널(barrel)을 만드는 전복 쇄파(plunging breaker)는 이 격자만으로는 절대 그릴 수 없습니다. 말리는 파도 터널에서는 같은 수평 위치 에 수직선을 그었을 때 물 표면이 세 번(말리는 지붕 윗면, 지붕 아랫면 천장, 그리고 아래에서 받아 주는 수면) 나타나야 하기 때문입니다.
그렇다고 해서 브라우저 안에서 전체 해안선에 3차원 입자·볼륨 유체(VOF/FLIP)를 실시간으로 풀 수는 없습니다. 「숨 쉬는 해변」은 천수 방정식이 찾아낸 파도 전선 위에 물질 좌표(material coordinate) 로 매개변수화된 2차원 전복 단면을 얹어 이 한계를 돌파합니다.
왜 일반 3차 스플라인을 쓰면 파도 끝이 8자로 꼬일까?
단면 위에서 물질 좌표 는 파도 뒤쪽 등()에서 시작해 마루와 말리는 코()를 지나, 공기 터널 안쪽 벽()과 바닥(), 그리고 앞쪽 수면()으로 이어집니다. 한 점에 수면 높이 하나만 적는 격자에서는 가 독립변수였지만, 물질 좌표 단면에서는 와 가 둘 다 의 함수입니다. 따라서 가 증가할 때 가 앞으로 나아갔다가() 코를 돌아 뒤쪽으로 되돌아오는() 것이 완전히 자유롭습니다.
이때 12개의 기준 매듭점(knots) 사이를 일반 3차 스플라인(Catmull–Rom 등)으로 보간하면 치명적인 아티팩트가 생깁니다. 파도 코가 수직 접선을 지나 뒤로 꺾이는 구간과 앞쪽 수면에 평평하게 붙는 구간에서 다항식이 오버슈트(overshoot)를 일으켜, 물 표면이 자기 자신과 교차하는 작은 8자 루프(self-intersection loop)를 만들거나 수면 아래로 파고드는 것입니다.
shore-breaker-profile.ts는 이를 막기 위해 Fritsch–Carlson 단조 3차 헤르미트 보간(Monotonic Cubic Hermite Interpolation)을 사용합니다. 좌우 구간의 기울기 가 부호가 다르면(극값이면) 접선 기울기를 즉시 으로 잠그고, 부호가 같을 때는 산술 평균 대신 조화 평균(harmonic mean)으로 기울기를 제한합니다.
function ordinate(values: number[], u: number) {
let i = 0;
while (i < knots.length - 2 && u > knots[i + 1]) i++;
const slope = (j: number) => {
if (j === 0 || j === knots.length - 1) return 0;
const a = (values[j] - values[j - 1]) / (knots[j] - knots[j - 1]);
const b = (values[j + 1] - values[j]) / (knots[j + 1] - knots[j]);
return a * b <= 0 ? 0 : (2 * a * b) / (a + b);
};
const h = knots[i + 1] - knots[i], t = (u - knots[i]) / h;
return (2 * t ** 3 - 3 * t ** 2 + 1) * values[i] + (t ** 3 - 2 * t ** 2 + t) * h * slope(i)
+ (-2 * t ** 3 + 3 * t ** 2) * values[i + 1] + (t ** 3 - t ** 2) * h * slope(i + 1);
}타원-5차 베지에 둥근 코(sampleRoundedBreakerCap)와 Newton 역투영
단조 헤르미트 곡선은 자기 교차를 막아 주지만, 옆모습에서 가까이 들여다보면 코 끝(CAP_TIP = 0.61)에서 돌아 나오는 지붕이 얇은 종이 날개처럼 뾰족해 보이기 쉽습니다. 실제 파도의 코는 표면장력과 압력 분포 때문에 둥근 물기둥 다발(jet head) 모양을 띱니다.
그래서 shore-breaker-cap.ts는 전복 위상() 동안 코 주변 구간을 세 조각의 해석적 곡선으로 다시 빚습니다:
- 등에서 코 끝까지 (): 고전적인 원호 근사 상수 를 쓰는 3차 베지에 곡선으로, 윗면 전체를 매끄러운 사분 타원(quarter-ellipse) 궤적으로 돌립니다.
- 코 끝에서 안쪽 벽까지 (): 코 끝의 위치 , 수직 접선 , 그리고 2차 도함수(곡률) 까지 연속으로 이어받으면서 터널 천장을 아치형으로 둥글리는 5차(quintic) 베지에 곡선을 사용합니다.
- 안쪽 벽에서 바닥까지 (): 3차 헤르미트 곡선으로 앞쪽 수면(
CAP_FLOOR = 0.77)에 매끄럽게 착지합니다.
아래 실험실은 실제 작품 코드(shore-breaker-profile.ts, shore-breaker-cap.ts)를 그대로 호출해 단면을 그립니다. 위상 슬라이더 를 움직여 파도가 일어서서 원통 터널을 만들고 수면에 닿는 과정을 관찰하고, 보간 방식을 바꿔 일반 3차 스플라인이 왜 코 밑에서 루프를 만드는지 비교해 보세요.

물 본체만 (5차 베지에 둥근 코와 터널)

거품 켜짐 (전면 포말 + 착지 충격파)
4. 파도는 한꺼번에 무너지지 않는다: 1D Eikonal 도착 시각 완화
해변에서 파도가 부서질 때 마루 전체가 같은 시각에 동시에 무너지는 일은 거의 없습니다. 수심이 조금 더 얕은 모래톱이나 두 파군이 겹친 봉우리(nucleus)에서 먼저 쇄파가 시작되고, 그 부서지는 경계점(peel point)이 마루를 따라 양옆으로 빠르게 번져 나갑니다. 서퍼들이 파도의 사면을 타고 옆으로 미끄러질 수 있는 이유가 바로 이 필링(peeling) 전파 때문입니다.
이 현상을 격자 위에서 구현할 때 가장 흔히 저지르는 실수는 "옆 레인이 부서진 다음 프레임에 내 레인을 부서지게 하자"는 식의 이웃 프레임 지연(neighbor frame delay)입니다. 그렇게 하면 전파 속도가 물리량이 아니라 격자 간격 와 프레임레이트()에 종속되어 버리고, 마루를 따라 위상이 계단처럼 뚝뚝 끊어집니다.
shore-breaker-propagation.ts는 대신 857개의 해안선 레인 위에서 1차원 Eikonal 방정식(최단 도착 시각 방정식)을 풉니다.
여기서 는 휘어진 마루를 따라 잰 호장(arc length)이고, 국소 필링 속도 는 천수 파속 와 마루를 따른 파고 기울기 에 대한 위상 교차 속도를 블렌딩해 결정합니다:
이 도착 시각 장 가 주는 결정적인 이점은 아직 부서지지 않은 이웃 레인이 미래의 도착 시각을 미리 알 수 있다는 것입니다. 현재 시각이 일 때 레인 의 국소 경과 시간은 입니다:
- (도착 전 예고 구간): 아직 필링 경계가 도착하지 않았지만(), 가 로 다가오는 동안 위상 가 로 매끄럽게 올라가며 어깨(shoulder)가 미리 앞으로 기울어집니다.
- (전복 상승 구간): 위상이 을 지나며 코가 허공으로 솟아오릅니다.
- (중력 자유 낙하 구간): 코 끝의 높이가 중력 가속도 를 따르도록 10단계 이분 탐색으로 위상 을 역산합니다.
export function breakerArrivalTimes(lanes: readonly BreakerLane[]) {
const times = lanes.map((l) => l.seed ?? Infinity);
const cost = (a: BreakerLane, b: BreakerLane) =>
Math.hypot(a.x - b.x, a.z - b.z) * 0.5 * (1 / a.speed + 1 / b.speed);
for (let pass = 0; pass < lanes.length; pass++) {
let changed = false;
for (let i = 0; i < lanes.length; i++)
for (const j of [i - 1, i + 1]) {
if (j < 0 || j >= lanes.length || !connectedBreakerLanes(lanes[i], lanes[j])) continue;
const t = times[j] + cost(lanes[i], lanes[j]);
if (t < times[i]) { times[i] = t; changed = true; }
}
if (!changed) break;
}
return times;
}아래 실험실에서 두 지점 동시 발화, 마른 모래톱 단절, 경사 입사 등 시나리오를 바꾸며 도착 시각 곡선 와 56개 레인의 3차원 쇄파 리본이 어떻게 이어지는지 확인해 보세요.
5. 거품은 물과 다르게 움직인다: 이중 물질 좌표와 기포 부력
파도가 부서진 뒤 수면 위를 덮는 하얀 포말(whitewater)과 물속에 말려 들어간 공기 방울(entrained air bubbles)은 맑은 물 본체와는 전혀 다른 시간 척도로 진화합니다. shore-transport.ts와 shore-guided-whitewater.ts는 이를 세 가지 물리·기하학 기법으로 풀어냅니다.
1) 4초 주기로 교차 갱신되는 이중 물질 좌표 (Dual Material Maps)
거품 무늬를 고정된 월드 좌표 노이즈로 그리면 물이 흘러도 거품 결이 그 자리에 얼어붙어 보이고, 반대로 단일 UV 좌표를 유속 로 계속 이류(advection)시키면 몇 초 안에 격자가 엿가락처럼 한없이 늘어나 빗살 무늬 아티팩트가 생깁니다.
이를 해결하기 위해 2초 간격으로 어긋나게 리셋되는 두 장의 물질 좌표 맵(uMaterialA, uMaterialB)을 4초 주기로 동시에 이류시킵니다. 각 맵이 자신의 4초 수명 중 가장 깨끗한 중간 구간에서 최대 가중치를 갖도록 코사인 창 함수 로 블렌딩하고, 만약 급격한 쇄파 전단류 때문에 한쪽 맵의 야코비안(Jacobian) 신장률 이 임계치를 넘으면 아직 덜 늘어난 반대쪽 맵으로 즉시 가중치를 넘깁니다(stretchFallback).
2) 속도 구배 텐서에서 순수 회전(Vorticity) 걷어내기
천수 방정식의 연안류에는 바위 뒤나 쇄파 끝자락에서 강한 와도()가 포함됩니다. 이 회전 성분을 그대로 물질 좌표에 먹이면 거품 무늬가 태풍 눈처럼 둥글게 말리는 비현실적인 소용돌이 무늬가 생깁니다. 실제 얕은 해변의 부유 거품은 수평 소용돌이로 감기기보다 파도 진행 방향으로 찢어지고 압축되는 순수 전단·신장 변형(strain)이 지배적입니다.
그래서 shore-transport.ts는 속도 구배 텐서 에서 와도(curl)와 전단 변형률(strain)의 크기를 분리해, 회전이 변형보다 클 때만 순수 강체 회전 각속도 를 빼내고 순수 변형률 텐서(deformation)만 거품 결 방향에 반영합니다.
float turning(vec2 p){
vec4 g = velocityGradient(p);
float curl = g.z - g.y;
vec2 strain = vec2(g.x - g.w, g.y + g.z);
return .5 * sign(curl) * sqrt(max(0., curl * curl - dot(strain, strain)));
}
vec2 deformation(vec2 p){
vec4 g = velocityGradient(p);
return vec2(g.x - g.w, g.y + g.z);
}3) 30 Hz에서도 튀지 않는 기포 부력의 해석적 적분과 3단계 거품 ODE
파도 코가 수면을 때릴 때 물속으로 주입된 반지름 의 구형 공기 방울은 부력 와 항력 이 평형을 이루는 종단 상승 속도(terminal velocity) 로 떠오릅니다():
기포의 수직 속도가 물의 연직 운동 와 종단 부력 속도 의 합 를 향해 시상수 로 지수 수렴할 때, 30 Hz 패스()에서 단순 명시적 오일러(Explicit Euler) 적분을 쓰면 로 걸음이 꽤 커서 기포가 목표 속도를 지나쳐 진동하기 쉽습니다. shore-guided-whitewater.ts는 아예 선형 항력 미분방정식 의 엄밀해(exact analytical integral)를 속도(guidedBubbleVelocity)와 변위(guidedBubbleRise) 모두에 직접 사용합니다:
수면에 떠오른 거품은 다시 세 칸의 연립 감쇠 과정(fresh 두꺼운 백파 film 배수 중인 기포 그물망 remnant 모래 위에 남는 잔류 거품 줄)을 거치며 사라지고, 파도가 물러난 모래사장에서는 모래 공극 속 함수율()과 표면의 얇은 수막 거울()이 서로 다른 시간 상수로 마르며 해변 특유의 '숨 쉬는' 여운을 남깁니다.
6. 에메랄드빛 속살과 부서지는 윤슬: 해수 광학
물리 시뮬레이션이 물의 형태와 거품의 밀도를 결정했다면, 그것이 "진짜 바닷물"처럼 보이게 만드는 것은 빛과 물의 상호작용입니다. shore-shaders.ts와 shore-surface-glints.ts에는 세 가지 핵심 광학 모델이 들어 있습니다.
1) 화면 공간 굴절(Screen-Space Refraction) 12선형 + 4이분 탐색
얕은 해변에서는 물 표면 너머로 비치는 모래 물결과 잠긴 바위가 수면 법선에 따라 일렁이며 굴절되어야 합니다. 이를 위해 불투명한 해저 지형(모래사장, 바위, 오두막)을 먼저 오프스크린 색상·깊이 버퍼(uRefractionMap, uRefractionDepth)에 그린 뒤, 수면 픽셀에서 스넬의 법칙(Snell's law)으로 꺾인 굴절 광선을 화면 공간에서 추적합니다(refractedBed).
단순히 수면 아래 수직 깊이만큼만 오프셋을 주면 경사진 모래 바닥이나 바위 모서리에서 굴절 위치가 어긋나고, 무엇보다 물보다 카메라 쪽에 있는 전경 바위나 건물의 픽셀을 엉뚱하게 끌어오는 굴절 누수(foreground bleeding)가 생깁니다. refractedBed()는 굴절 광선을 따라 12단계 선형 행진(linear march)으로 광선이 해저 깊이 버퍼 아래로 처음 파고드는 구간 를 포위한 다음, 그 안에서 4단계 이분 탐색(binary search)으로 정확한 해저 교차점을 찾습니다. 이때 샘플의 깊이가 현재 수면 깊이보다 앞에 있으면(hit < waterDepth - 1e-4) 즉시 기각하여 전경 침범을 원천 차단합니다.
2) Beer–Lambert 파장별 흡수와 말리는 지붕의 이중 광경로 (layeredPath)
순수한 바닷물은 가시광선의 세 파장 대역 중 붉은빛()을 가장 빠르게 흡수하고, 초록빛과 푸른빛은 훨씬 깊이 통과시킵니다. 광선이 물속을 달린 광경로 길이를 라 할 때, Beer–Lambert 법칙에 따른 RGB 투과율 벡터 는 다음과 같습니다:
이 지수 감쇠 덕분에 광경로 가 수 센티미터인 해안선 끝자락에서는 모래의 따뜻한 금빛이 그대로 비치다가, 수심이 로 깊어지면 붉은 성분이 먼저 사라지며 청록색(emerald)으로, 더 먼바다에서는 깊은 남청색(teal)으로 자연스럽게 이어집니다.
그런데 전복 쇄파가 만드는 말리는 지붕(curling roof)에서는 한 가지 결정적인 광학적 차이가 발생합니다. 카메라는 높이 솟은 파도 지붕을 바라보지만, 그 지붕 아래는 바닥까지 꽉 찬 물기둥이 아니라 텅 빈 공기 터널입니다! 만약 굴절 광경로를 지붕 꼭대기에서 모래 바닥까지의 전체 높이차로 계산하면, 두께의 얇은 물 지붕이 짜리 깊은 물기둥처럼 취급되어 시커멓게 죽어 버립니다.
// 얇은 지붕(sheetThickness)과 공기 터널 아래의 잔류 수심(residualDepth)을 분리
float layeredPath = sheetThickness > 0.
? sheetThickness + (.22 + .78 * clamp(sheetContact, 0., 1.)) * max(0., residualDepth)
: columnDepth;
float path = mix(columnDepth, min(columnDepth, layeredPath), sheetMask)
/ max(.28, abs(view.y) + .35);
vec3 trans = exp(-vec3(.38, .14, .085) * path);이 layeredPath 분리와 태양 반대편(-SUN)에서 얇은 물 지붕을 뚫고 들어오는 역광 산란(subsurface backlighting)이 결합되어, 파도가 허공으로 말리는 순간 그 지붕 가장자리가 맑은 비취색 유리처럼 빛나게 됩니다.
3) Bruneton 기울기 분산 GGX와 월드 고정 미세면 윤슬 (surfaceGlints)
마지막으로 오후의 바다를 살아 숨 쉬게 하는 것은 햇빛이 물결 위에서 부서지는 윤슬(sunglint)입니다. 매끄러운 수면의 미세면 분포(GGX NDF)를 그대로 쓰면, 카메라에서 먼 수평선 근처에서는 픽셀 하나가 수 미터의 물결을 덮으면서 법선이 언더샘플링되어 자글거리는 흰색 모아레 줄무늬가 생기고, 반대로 가까운 물결에서는 반사가 플라스틱처럼 뭉툭해집니다.
이를 동시에 해결하기 위해 두 층의 반사 모델을 포갭니다:
- 연속 GGX 로브 + Bruneton(2010) 기울기 분산 안티에일리어싱: 화면 공간 미분 명령어
dFdx(normal)과dFdy(normal)로 픽셀 발자국 안의 법선 분산(normalVariance)을 실시간 측정해 거칠기 제곱에 더해 줍니다. 멀리 있는 수평선일수록 유효 거칠기가 자동으로 넓어져 앨리어싱 없이 은빛 수평선 광채로 녹아듭니다. - 이산 월드 고정 다이아몬드 미세면(
surfaceGlints): 가까운 바다에서는 세상을 격자로 나누고 각 셀마다 고유한 시드로 회전된 다이아몬드 모양의 미세 거울(facet)을 심어 둡니다. 이 미세면은 화면에 고정된 스파클 텍스처가 아니라 월드 좌표에 고정되어 있으며, 오직 그 위를 지나가는 파도의 법선과 태양·시선 반각 벡터(halfway = normalize(view + SUN))가 의 좁은 각도 안으로 완벽히 정렬되는 순간에만 눈부신 스파클을 터뜨립니다.

얇은 지붕 이중 광경로 (비취색 투과광)

역광 산란 + Mantaflow 파편 물방울
7. 38초 걸리던 셰이더 컴파일을 깎아내다: 브라우저 시스템 엔지니어링
아름다운 수식과 셰이더를 모두 작성하고 난 뒤, 가장 마지막에 부딪힌 거대한 벽은 물리도 수학도 아닌 Windows 브라우저의 콜드 셰이더 컴파일 시간이었습니다.
Chrome과 Edge를 비롯한 Windows 브라우저는 WebGL2의 GLSL 셰이더를 GPU 드라이버에 바로 넘기지 않고, ANGLE 계층을 통해 Direct3D 11용 HLSL 코드로 변환한 뒤 Microsoft의 구형 HLSL 컴파일러인 fxc 컴파일러(d3dcompiler_47.dll)로 최적화 컴파일을 수행합니다. 캐시가 비어 있는 첫 방문(Cold Start) 상태에서 프로파일러를 열어 보니, 첫 프레임이 뜨기까지 무려 38.6초 동안 CPU가 d3dcompiler_47.dll 안에 갇혀 있었습니다.
원인을 추적해 보니 두 가지 구조적 증폭이 fxc의 AST(추상 구문 트리) 크기를 기하급수적으로 불리고 있었습니다:
- 비활성 전처리기 분기 속 거대 해양 함수들의 잔류: 해저 모래 캡처 패스(
#define SHORE_GROUND_PASS)나 수면 캐시 히트 패스(#define SHORE_CACHED_SURFACE)에서는 무거운 3슬롯×857레인 쇄파 단면 평가 함수(profileSurface,projectBreakerCoordinate,seaIncident)가 실제로는 호출되지 않거나 상수 조건문if (false)뒤에 숨어 있었습니다. 하지만 공유 GLSL 문자열 안에 함수 정의가 남아 있다 보니 ANGLE과fxc가 매 프로그램마다 수천 줄의 도달 불능 함수들을 파싱하고 레지스터 할당을 시도하고 있었습니다. - 컴파일 타임 루프 언롤링과 4방향 법선 스텐실의 곱셈 폭발: 수면 법선을 구하기 위해
+x, -x, +z, -z네 지점에서 수면 높이 함수wave()를 개별 호출하고, 그 안의 착지 수면 계산(profileLanding)이 8회 고정 루프로 짜여 있다 보니,fxc가 루프를 강제로 언롤링(unrolling)하면서wave()profileSurface()projectBreakerCoordinate()호출 트리 전체를 배로 인라이닝 복제하고 있었습니다.
해법 1: trimShoreLocalShader() — AST 상수 폴딩과 BFS 도달 가능 그래프 가지치기
ANGLE에 GLSL 소스를 넘기기 직전, 자바스크립트에서 셰이더 문자열을 정제하는 경량 프리프로세서 겸 데드 코드 제거기(shore-local-shader.ts)를 직접 작성했습니다.
- 먼저 Three.js의
#include <...>청크를 전개하고, 우리가 아는 매크로(SHORE_CACHED_SURFACE,SHORE_GROUND_PASS등)에 대한#ifdef / #ifndef / #else / #endif블록을 실제로 잘라냅니다. - 이어서
const bool상수에 의존하는if (flag) ... else ...구문을 괄호 균형 파서로 찾아내어 살아남는 분기만 남깁니다(foldLiteralBranches). - 마지막으로 모든 GLSL 함수 정의의 본문에서 호출 그래프(call graph)를 추출하고,
main()에서 시작하는 너비 우선 탐색(BFS)으로 도달 가능한 함수 집합(reachable)을 구해 도달할 수 없는 함수 정의를 통째로 삭제합니다.
const names = new Set(definitions.map((fn) => fn.name));
const calls = (text: string) =>
Array.from(text.matchAll(/\b(\w+)\s*\(/g), (m) => m[1]).filter((name) => names.has(name));
// 전역 초기화 식과 main()에서 출발하는 너비 우선 탐색(BFS)
const reachable = new Set(["main", ...calls(globals)]), queue = [...reachable];
while (queue.length) {
const name = queue.pop()!;
for (const fn of definitions.filter((f) => f.name === name))
for (const dependency of calls(fn.body)) {
if (!reachable.has(dependency)) {
reachable.add(dependency);
queue.push(dependency);
}
}
}해법 2: 런타임 Uniform 루프로 fxc의 강제 인라이닝 차단하기
수면 캐시 패스와 바다 픽셀 셰이더에서 +x, -x, +z, -z로 네 번 펼쳐 써 두었던 중앙 차분 법선 계산과 8단계 이분 탐색을 런타임 uniform 변수(uNormalSteps = 4, uLandingSteps = 8)가 제어하는 단일 루프로 바꿨습니다. 컴파일러 입장에서는 반복 횟수가 런타임 uniform이므로 루프를 함부로 언롤링해 거대 함수를 4~8번 복제할 수 없게 됩니다. 그 결과 픽셀당 실행되는 GPU 연산량은 100% 동일하면서도, 바다 프래그먼트 셰이더의 fxc 네이티브 컴파일 시간은 11.2초에서 4.8초로 57% 단축되었습니다.
아래 실험실에서 매크로 플래그와 루프 모드를 토글하며 실제 trimShoreLocalShader()가 GLSL 호출 그래프를 어떻게 가지치기하는지 실험해 보세요.
const bool uUseGroundFastPath = true;
vec4 cachedSurfaceLookup(vec2 p) {
return vec4(p.x * 0.1, 0.0, 1.0, 0.0);
}
vec3 groundSandShade(vec2 p, float h) {
return vec3(0.82, 0.74, 0.56) * (1.0 - 0.15 * h);
}
void main() {
vec2 p = vec2(2.0, 4.0);
vec4 surf = cachedSurfaceLookup(p);
{
vec3 sand = groundSandShade(p, surf.x);
}
}8. 얼마나 빠르고 얼마나 정확한가
물리 솔버의 보존성, 컴파일러 최적화, 그리고 Brotli 사전 압축(.br) 및 React SSR preload()를 적용한 전후의 정량 검증 결과는 다음과 같습니다.
| 검증 항목 | 측정 지표 / 조건 | 기준선 (Before) | 개선 후 (After) | 비고 |
|---|---|---|---|---|
| 정지한 바다 보존 (Lake at Rest) | 200 스텝 후 | (단순 차분) | Audusse 정역학 복원 (RGBA32F) | |
| 정지한 바다 잔류 유속 | 200 스텝 후 | (단순 차분) | 해안선·암초 경계 유령 해류 소멸 | |
| 초기 에셋 전송 크기 | terrain.bin + breakup-reference.bin | (비압축) | (-68.6%) | 무손실 Brotli .br 사전 압축 |
| Worker 에셋 중복 다운로드 | 메인 Worker 캐시 공유 | 재전송 | (캐시 히트) | SSR preload() + 동일 URL fetch |
바다 셰이더 fxc 컴파일 | Windows D3D11 콜드 컴파일 | (-57.2%) | 런타임 uniform 스텐실·이분 루프 | |
| 전체 콜드 스타트 시간 | 캐시 없는 첫 방문 첫 프레임 | (-26.3%) | trimShoreLocalShader + 루프 억제 | |
| 웜 스타트 시간 (Warm Load) | 재방문 시 첫 프레임 도달 | (-52.1%) | 프리로드 + 셰이더 바이너리 캐시 |
마치며
파도 하나가 해변에 밀려와 부서지고 스며들기까지, 화면 뒤에서는 서로 다른 일곱 겹의 수학과 공학이 바통을 이어받습니다.
비주기 파군과 천수 도달 시간 Audusse 정역학 복원 천수 방정식(120 Hz / 360 Hz 중첩 격자) 단조 헤르미트·5차 베지에 전복 단면과 Newton 역투영 1D Eikonal 도착 시각 완화(30 Hz) 4초 교차 이중 물질 좌표와 해석적 기포 부력 화면 공간 굴절 이분 탐색과 Beer–Lambert 이중 광경로 GLSL 도달 가능 그래프 가지치기 픽셀
물리 법칙만으로 풀어야 할 곳(보존형 천수 방정식, 기포 부력 적분, Beer–Lambert 흡수)에서는 타협 없이 방정식을 풀고, 실시간 2차원 격자가 넘어설 수 없는 차원의 벽(말리는 3차원 쇄파 터널과 필링 전선) 앞에서는 물질 좌표 기하학과 Eikonal 도착 시각 장으로 다리를 놓는 것. 그 경계를 정직하게 설계할 때 브라우저 안의 작은 해변은 비로소 스스로 숨 쉬기 시작합니다. 지금 바로 숨 쉬는 해변 플레이그라운드에서 카메라 시점을 낮추고 파도의 속살과 윤슬을 직접 관찰해 보세요.
참고 자료
- Emmanuel Audusse, François Bouchut, Marie-Odile Bristeau, Ruwaida Klein, and Benoît Perthame, "A Fast and Stable Well-Balanced Scheme with Hydrostatic Reconstruction for Shallow Water Flows", SIAM Journal on Scientific Computing 25(6), 2050–2065 (2004).
- Frederick N. Fritsch and Ralph E. Carlson, "Monotone Piecewise Cubic Interpolation", SIAM Journal on Numerical Analysis 17(2), 238–246 (1980).
- Eric Bruneton, Fabrice Neyret, and Nicolas Holzschuch, "Real-time Realistic Ocean Lighting using Seamless Transitions from Geometry to BRDF", Computer Graphics Forum (Eurographics) 29(2), 487–496 (2010).
- Xavier Chermain, Basile Sauvage, Jean-Michel Dischler, and Carsten Dachsbacher, "Procedural Physically Based BRDF for Real-Time Rendering of Glints", Computer Graphics Forum (Pacific Graphics) 39(7), 243–253 (2020).
- Elek Tardif, "Water Rendering at Embark" (Crossing Wind-Packet Normals & Multi-Scale Water Surface Notes), 2022.
- Nils Thuerey and Tobias Pfaff, "Mantaflow — An Extensible Framework for Fluid Simulation", Blender FLIP Liquid Solver Reference.