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

캔버스 바다 애니메이션 9종 — 겹친 정현파로 파도장을 계산한다

바다 애니메이션은 사진이나 gif를 반복 재생하는 장면이 아니라 격자 칸마다 물결 높이를 실시간으로 다시 계산해서 그리는 화면입니다. 이 아홉 장면은 겹친 정현파로 파도장을 계산한다는 원리 하나 위에서 물빛과 거품, 반사광, 소용돌이까지 갈라집니다.

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

아홉 개는 왜 이 순서인가

첫 장면은 몸통입니다. 파장·방향·진폭이 다른 정현파 여섯을 합쳐 수면 격자 하나를 계산하고, 나머지 여덟은 전부 그 격자 위에 파생값 한 겹을 얹은 결과입니다. 물색을 수심으로 읽으면 02, 높이가 문턱을 넘는 칸만 골라내면 03, 기울기와 태양 벡터를 내적하면 04가 됩니다. 05는 03의 거품을 좌표가 아니라 격자 셀로 추적하고, 06은 같은 기울기를 굴절 오프셋으로 쓰고, 07·08은 그 격자 자체에 국소 회전과 전역 엔벨로프를 얹어 소용돌이와 밀려오는 파도를 만듭니다. 09는 마지막으로 거리에 따라 진폭과 색을 눌러 원경을 안개에 묻습니다.

몸통은 하나, 파생은 여덟

01의 hgt 배열(칸마다 계산한 높이)과 fieldcalc 함수는 아홉 항목 전부에서 거의 그대로 재사용됩니다. 달라지는 건 그 높이를 읽어 무엇을 그리느냐뿐입니다 — 색이면 수면, 문턱이면 거품, 기울기면 반사광입니다.

근경에서 원경으로 — 계산이 쌓이는 순서

01~06은 화면 전체가 같은 시점에서 보는 수면이고, 07~08은 그 수면 위에 국소적인 사건(소용돌이·밀려오는 파도)을 얹으며, 09는 마지막으로 카메라를 뒤로 물려 수평선까지 담습니다. 소재를 고를 때도 이 순서를 그대로 따라가면 계산 하나를 배울 때마다 다음 장면이 자연스럽게 풀립니다.

01정현파 합성 수면 — Gerstner 근사 파도장

파장·방향·진폭이 다른 정현파 여섯을 겹쳐 수면 격자에 합성하고 마루가 앞으로 처지는 Gerstner 처짐까지 넣은, 리조트·크루즈 랜딩 히어로용 기본 장면입니다(실측: 누적면적 60.795%, 23/23 프레임 움직임).

정현파 합성 수면 — Gerstner 근사 파도장
새 창에서 열기

01 · 정현파 합성 수면 — Gerstner 근사 파도장 — 측정 누적면적 60.795% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ImageDataupscalerequestAnimationFrame

높이는 사인, 처짐은 코사인으로 함께 계산

function fieldcalc(t, wstr, wang) {
  var i = 0;
  for (var y = 0; y < ROWS; y++) {
    for (var x = 0; x < COLS; x++, i++) {
      var h = 0, d = 0;
      for (var w2 = 0; w2 < NW; w2++) {
        var WV = WAVES[w2];
        var da = wang * 0.5;
        var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
        var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
        var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
        var a = WV.amp * wstr;
        h += a * Math.sin(ph);           // 높이 — 정현파 여섯의 합
        d += Q * a * Math.cos(ph) * dx;  // Gerstner 수평 처짐
      }
      hgt[i] = h;
      disp[i] = d;
    }
  }
}

측정값에서 코사인 항을 빼면 물이 위아래로만 흔들려 실내 수영장 인상이 되지만, 넣으면 마루가 진행 방향으로 쏠려 파도가 됩니다 — 포인터로 이동 벡터를 실측해 바람 방향·세기 목표를 바꾸고 화살표 키로도 같은 값을 조절합니다.

02깊이별 감쇠 — 얕은 물빛 단면

수면 격자의 높이를 수심으로 읽어 픽셀마다 물색을 지수 감쇠로 섞는, 수영장·다이빙·여행 예약 배경용 장면입니다(실측: 누적면적 54.177%, 강도 평균 54.2로 아홉 편 중 최고).

깊이별 감쇠 — 얕은 물빛 단면
새 창에서 열기

02 · 깊이별 감쇠 — 얕은 물빛 단면 — 측정 누적면적 54.177% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

createImageDataputImageDataexp

선형이 아니라 지수로 물빛을 삼키는 이유

function render(attenK) {
  var o = out.data;
  var baseline = RH * 0.4;   // 수면 중심선(버퍼 px)
  var ampPx = RH * 0.34;     // 수면 진폭(버퍼 px)
  for (var cx = 0; cx < COLS; cx++) {
    var surfaceY = baseline + surf[cx] * ampPx;
    if (surfaceY < 0) surfaceY = 0; else if (surfaceY > RH - 2) surfaceY = RH - 2;
    for (var sx = 0; sx < STEP; sx++) {
      var px = cx * STEP + sx;
      for (var y = 0; y < RH; y++) {
        var base = (y * RW + px) * 4;
        if (y < surfaceY) {
          o[base] = 0; o[base + 1] = 0; o[base + 2] = 0; o[base + 3] = 0;
          continue;
        }
        var d = y - surfaceY;
        var u = 1 - Math.exp(-attenK * d);   // 0(얕음)..1(깊음)
        if (u < 0) u = 0; else if (u > 1) u = 1;
      }
    }
  }
}

지수 감쇠는 얕은 몇 픽셀에서 색이 급하게 바뀌고 깊어질수록 변화가 느려지는, 실제 물이 빛을 삼키는 곡선과 같은 형태입니다 — 물 위는 투명하게 비워 카드 배경이 하늘 노릇을 하고, 바람이 세질수록 attenK가 커져 색을 더 빨리 삼킵니다.

03크레스트 거품 — 꼭대기 흰 물줄기

합성 높이가 진폭 문턱을 넘는 칸만 크림색으로 칠해 부서지는 흰 물꼭대기를 표시하는, 서핑·해양 스포츠 콘텐츠용 장면입니다(실측: 누적면적 60.08%, 프레임당 최대 면적 22.649%로 아홉 편 중 07(25.676%) 다음으로 큽니다).

크레스트 거품 — 꼭대기 흰 물줄기
새 창에서 열기

03 · 크레스트 거품 — 꼭대기 흰 물줄기 — 측정 누적면적 60.08% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

thresholdImageDatawind

문턱이 바람에 반비례하도록 잡은 함수

// whitecap — h 가 cut 을 넘으면 크림. 가장자리는 smoothstep, 꼭대기는 FOAM2 로 두 단
var f = smoothstep(cut - FOAM_EDGE * eff, cut + FOAM_EDGE * eff, h);
if (f > 0) {
  var top = smoothstep(cut + FOAM_EDGE * eff * 2.2, cut + eff * 0.42, h);
  var fr = RF[0] * (1 - top) + RF2[0] * top;
  var fg = RF[1] * (1 - top) + RF2[1] * top;
  var fb = RF[2] * (1 - top) + RF2[2] * top;
  r = r * (1 - f) + fr * f;
  g = g * (1 - f) + fg * f;
  b = b * (1 - f) + fb * f;
}
// 문턱은 바람에 반비례 — 0.82(약한 바람) → 0.55(강한 바람)
function thresholdFor(w) {
  return 0.82 - 0.27 * Math.max(0, Math.min(1, (w - 0.35) / (2.1 - 0.35)));
}

가장자리를 smoothstep으로 부드럽게 두지 않으면 문턱 선이 계단처럼 깨지고, 꼭대기 전용 색(FOAM2) 없이는 흰색이 한 톤으로만 퍼져 부서진 느낌이 안 삽니다.

04반사 하이라이트 — 태양 글리터

수면 격자의 기울기와 태양 방향의 내적을 제곱해 찰나의 빛무리만 찍는, 일출·야경 해변 캠페인용 장면입니다(실측: 누적면적 41.906%, 강도 평균 43.0).

반사 하이라이트 — 태양 글리터
새 창에서 열기

04 · 반사 하이라이트 — 태양 글리터 — 측정 누적면적 41.906% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

dotpowImageData

법선 내적을 제곱해 반짝이를 좁히는 계산

var lx = Math.cos(sunAng) * (1 - sunZ), ly = Math.sin(sunAng) * (1 - sunZ), lz = sunZ + 0.3;
var llen = Math.sqrt(lx * lx + ly * ly + lz * lz);
lx /= llen; ly /= llen; lz /= llen;
// 중앙차분 기울기 — 법선(nx, ny, nz) 근사
var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
var nx = -gx * 5.2, ny = -gy * 5.2, nz = 1;
var nlen = Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= nlen; ny /= nlen; nz /= nlen;
var d = nx * lx + ny * ly + nz * lz;
if (d < 0) d = 0;
var spec = Math.pow(d, SHINY);   // dot(N,L)^shininess — 좁고 날카로운 반짝이

내적값을 그대로 쓰면 화면이 하얗게 번지지만 제곱(SHINY)을 거칠수록 빛이 점으로 모이는데, 포인터 위치가 태양 방위·높이(sunZ)가 되어 태양을 화면 안에서 직접 옮길 수 있습니다.

05물거품 파티클 — 격자 셀 기반 파편

크레스트에서 떨어진 거품을 좌표가 아니라 태어난 격자 칸으로 수명·위치를 계산해 흩리는, 여름 이벤트·해변 페스티벌용 장면입니다(실측: 누적면적 60.21%, 강도 평균 21.0).

물거품 파티클 — 격자 셀 기반 파편
새 창에서 열기

05 · 물거품 파티클 — 격자 셀 기반 파편 — 측정 누적면적 60.21% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

grid-cellparticleslifecycle

좌표 난수 대신 격자 셀로 거품을 옮기는 어드벡션

// advect — 위상 흐름 반대 방향(이전 위치)에서 밀도를 끌어와 이 셀에 놓는다
var sx = x - flowX, sy = y - flowY;
var ix = Math.round(sx), iy = Math.round(sy);
if (ix < 0) ix = 0; else if (ix >= COLS) ix = COLS - 1;
if (iy < 0) iy = 0; else if (iy >= ROWS) iy = ROWS - 1;
var carried = dens[iy * COLS + ix] * decay;
// spawn — 크레스트 셀만 결정적 난수로 발생 판정(같은 셀·같은 프레임은 같은 판정)
if (hgt[idx] > cut) {
  var r = rngSpawn();
  if (r < spawnRate) carried = Math.min(1, carried + 0.55 + r * 0.4);
}
densNext[idx] = carried;

좌표를 난수로 다시 뽑으면 바람이 잦아들 때 거품이 순간 사라지지만, 격자 셀에 붙여 감쇠(decay)를 따라 옅어지게 하면 자연스럽게 스며듭니다.

06수면 굴절 근사 — 흔들리는 물결무늬

수면 격자의 기울기를 밑그림을 읽을 자리 오프셋으로 바꿔 원본을 당겨 오는, 수족관·스파·제품 워터 룩용 굴절 근사 장면입니다(실측: 누적면적 41.565%, 강도 평균 22.9).

수면 굴절 근사 — 흔들리는 물결무늬
새 창에서 열기

06 · 수면 굴절 근사 — 흔들리는 물결무늬 — 측정 누적면적 41.565% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataoffsetgradient

기울기 발산으로 코스틱 강도를 재는 계산

var gx = (hgt[row + x1] - hgt[row + x0]) / Math.max(1, x1 - x0);
var gy = (hgt[y1 * COLS + x] - hgt[y0 * COLS + x]) / Math.max(1, y1 - y0);
// 발산 근사 — 이웃 기울기와의 차이를 코스틱 강도로 쓴다
var gxr = x1 < COLS - 1 ? (hgt[row + Math.min(COLS - 1, x1 + 1)] - hgt[row + x1]) : gx;
var gyd = y1 < ROWS - 1 ? (hgt[Math.min(ROWS - 1, y1 + 1) * COLS + x] - hgt[y1 * COLS + x]) : gy;
var div = Math.abs((gxr - gx) + (gyd - gy)) * 90;
if (div > 1) div = 1;
var offx = gx * REFRACT, offy = gy * REFRACT;
var bx0 = x * STEP + STEP / 2, by0 = y * STEP + STEP / 2;
var c = tileColor(bx0 + offx, by0 + offy);   // 오프셋 만큼 당겨온 바닥색

굴절 오프셋 배율(REFRACT)을 올릴수록 바닥무늬가 더 세게 휘어지지만 너무 키우면 좌표가 튀어 타일이 끊기고, 기울기 발산이 큰 칸에는 민트빛을 가산해 빛이 모이는 코스틱까지 얹었습니다.

07파도 소용돌이 — 이안류 소용돌이 패치

파도장에 국소 회전을 얹어 파도가 뭉치며 빠져나가는 이안류를 표현하는, 해양 안전·교육 콘텐츠용 장면입니다(실측: 누적면적 60.735%, 프레임당 최대 면적 25.676%로 아홉 편 중 최대).

파도 소용돌이 — 이안류 소용돌이 패치
새 창에서 열기

07 · 파도 소용돌이 — 이안류 소용돌이 패치 — 측정 누적면적 60.735% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotationfalloffImageData

거리 감쇠를 제곱해 회전을 중심에 가두는 계산

function fieldcalc(t, wstr, wang, cx, cy, sw) {
  var i = 0;
  for (var y = 0; y < ROWS; y++) {
    for (var x = 0; x < COLS; x++, i++) {
      var dxv = x - cx, dyv = y - cy;
      var r = Math.sqrt(dxv * dxv + dyv * dyv);
      var ang = Math.atan2(dyv, dxv);
      var fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
      fall *= fall;                    // 감쇠를 가속 — 중심만 또렷하게
      var twist = SWIRL * sw * fall * ang;
      // 이 회전(twist)을 파도 위상에 더해 소용돌이 근처만 물결이 휘어지게 한다
    }
  }
}

감쇠를 한 번만 곱하면 소용돌이 가장자리까지 물결이 넓게 휘어 어색하지만 제곱하면 중심 몇 칸만 또렷하게 돌아가고, 포인터 좌표가 곧 소용돌이 중심(cx, cy)이라 화면 어디든 끌고 다닐 수 있습니다.

08해일 스윕 — 밀려오는 파도 벽

파도장의 진폭을 거리·시간의 함수로 부풀려 물 덩어리가 수평선을 향해 밀려오는 서지를 만드는, 재난·다큐 오프닝용 장면입니다(실측: 누적면적 60.026%, 강도 평균 37.9).

해일 스윕 — 밀려오는 파도 벽
새 창에서 열기

08 · 해일 스윕 — 밀려오는 파도 벽 — 측정 누적면적 60.026% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

envelopeamplitudetiming

마루는 부풀리고 골은 덜 가라앉히는 비대칭 엔벨로프

// 엔벨로프 — 아래(근접)에서 위(수평선)로 이동하는 느린 파형
var om = SURGE_OM * (0.55 + wind * 0.75);
var yn = y / ROWS;
var sw = Math.sin(yn * Math.PI * 2 / SURGE_LAM - t * om + 0.7);
var crest = sw > 0 ? sw * sw : 0;                 // 밀려오는 물벽(양의 반파만)
var dip = sw < 0 ? -sw * sw * 0.42 : 0;           // 지나간 뒤 가라앉는 썰물
var e = 1 + crest * SURGE_GAIN * (0.55 + wind * 0.5) - dip;
env[y] = e;
// 엔벨로프가 진폭을 부풀리고 바깥으로도 살짝 민다 — 물이 위로 볼록해진다
hgt[i] = h * e + (e - 1) * 0.34;

마루와 골에 같은 계수를 걸면 물이 똑같이 오르내리는 호흡에 그치지만, 골의 가라앉음을 0.42배로 줄이면 한쪽으로 밀려오는 힘이 느껴지고 바람이 세질수록 서지 주기(om)도 빨라집니다.

09수평선 안개 — 원경 뿌옇게 묻기

픽셀의 수평선 거리에 비례해 물색을 하늘빛과 섞고 진폭도 눌러 원경을 뿌옇게 묻히는, 여행·명상·무드 앱용 장면입니다(실측: 누적면적 34.095% — 화면 절반이 하늘이라 낮은 게 정상).

수평선 안개 — 원경 뿌옇게 묻기
새 창에서 열기

09 · 수평선 안개 — 원경 뿌옇게 묻기 — 측정 누적면적 34.095% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lerpaerialImageData

거리 하나로 주파수·진폭·색을 함께 누르는 계산

function fieldcalc(t, wstr, wang, horizonRow) {
  var i = 0;
  for (var y = 0; y < ROWS; y++) {
    if (y < horizonRow) { i += COLS; continue; }   // 수평선 위는 하늘 — 계산 없이 건너뛴다
    var dist = ROWS > horizonRow ? (y - horizonRow) / (ROWS - horizonRow) : 1;
    if (dist > 1) dist = 1; else if (dist < 0) dist = 0;
    var freqMul = 1 + (1 - dist) * 2.4;   // 가까울수록 파장이 좁아 보인다
    var ampMul = 0.15 + dist * 0.85;      // 멀수록 진폭을 눌러 안개에 묻는다
    for (var x = 0; x < COLS; x++, i++) {
      // 본계산은 01과 같고 freqMul·ampMul 만 곱해 넣는다
    }
  }
}

억지로 파도 면적을 키우면 수평선 위(하늘)까지 물결을 그려 넣는 부자연스러운 장면이 되지만, 진폭 누름과 색 혼합을 같은 거리값으로 묶으면 원경이 스스로 가라앉고 포인터 세로 위치가 수평선 높이(horizonRow) 목표가 됩니다.

정현파를 겹치면 어떻게 바다가 되나?

정현파 하나만 그리면 규칙적인 물결무늬지, 바다로 보이지 않습니다. 바다처럼 보이려면 파장·방향·속도가 서로 다른 정현파 여러 개를 같은 격자 위에 겹쳐 더해야 마루와 골이 불규칙하게 어긋나면서 자연스러운 표면이 됩니다. 01의 WAVES 배열은 여섯 개의 파를 만들면서 파장은 34에서 시작해 매번 0.55씩 곱해 짧아지고, 진폭은 인덱스가 커질수록 0.58/(i*0.62+1)로 작아집니다 — 큰 파도가 기본 형태를 잡고 작은 파도가 그 위에 잔물결을 얹는 구조입니다. 실제 파도의 겹침을 물리적으로 더 흉내 내려면 사인 함수 자체를 기울이는 Gerstner 처짐이 필요한데, 이 css 파도 애니메이션 구현에서는 각 파의 방향 벡터(dirX)에 코사인 항을 곱해 마루 근처의 물이 진행 방향으로 살짝 쏠리게 만듭니다 — 정점이 뾰족하고 골이 완만한 실제 파도의 비대칭을 근사하는 값입니다.

왜 사인이 아니라 사인+코사인 쌍인가

높이는 Math.sin(ph)로 계산하지만 수평 처짐은 Math.cos(ph)로 계산합니다. 사인과 코사인은 위상이 90도 어긋나 있어서, 높이가 가장 높은 지점(사인이 최대)에서 코사인은 0에 가깝고, 높이가 중간인 지점에서 코사인이 최대가 됩니다. 이 어긋남 덕분에 마루는 위로 솟으면서 앞으로 쏠리고 골은 옆으로만 넓어져, 밋밋한 사인파 겹침보다 훨씬 파도 모션 그래픽다운 형태가 나옵니다.

파장이 짧아질수록 진폭도 줄여야 하는 이유

파장을 계속 줄이기만 하면 짧은 파가 화면을 촘촘한 줄무늬로 덮어 버립니다. 01에서 인덱스가 커질수록(파장이 짧아질수록) 진폭도 함께 줄이는 이유가 여기 있습니다 — 실제 바다에서도 짧은 파(잔물결)는 얕게, 긴 파(너울)는 깊게 움직입니다. 이 비율을 반대로 하면 화면이 지글거리는 노이즈처럼 보이고, 비율을 너무 크게 벌리면 잔물결이 안 보여 표면이 밋밋해집니다.

여섯 개면 충분한가, 더 필요한가

파를 늘릴수록 표면은 더 복잡해지지만 픽셀마다 도는 안쪽 반복이 그만큼 늘어 프레임 계산량이 선형으로 늘어납니다. 01은 여섯 개에서 형태와 속도의 균형을 맞췄고, 09처럼 화면 대부분이 안개에 눌리는 장면은 파 개수를 늘려도 눈에 띄는 차이가 크지 않아 그대로 재사용합니다. 파 개수는 변형 절에서 다루는 첫 번째 조절점이기도 합니다.

어디서 깨지는가 — 15.2%에서 60.8%까지 고친 네 곳

01을 처음 렌더링했을 때 누적면적이 15.2%밖에 나오지 않았습니다. 코드는 에러 없이 돌아갔고 화면에 물결도 보였는데, 측정값을 보고서야 대부분의 프레임에서 화면이 거의 정지해 있었다는 걸 알았습니다. 원인은 하나가 아니라 네 곳에 겹쳐 있었고, 그중 하나를 고치고 나서야 나머지가 드러났습니다.

첫 번째 — STEP 블록을 순차 인덱스로 채운 자리

저해상 격자(가로세로 STEP칸씩 묶은 블록)를 실제 버퍼에 펴 올릴 때, 처음에는 루프를 도는 순서대로 카운터를 하나씩 증가시켜 주소를 잡았습니다. 격자 칸 순서와 버퍼의 실제 행·열 순서가 어긋나 있는 걸 몰랐던 겁니다. 화면 하단으로 갈수록 물결이 갈라지고 밀리는 모습으로 나타났는데, 세로로 몇 줄만 어긋나도 눈에는 크게 튀어 보입니다.

두 번째 — sass 가 rgb 를 퍼센트로 뱉는 자리

두 번째는 색이 안 나오는 문제였습니다. sass 의 mix() 함수가 색을 rgb(52.9%, 78.1%, 69%)처럼 퍼센트 단위로 컴파일했는데, 색을 배열로 바꾸는 hex2rgb 함수는 헥스 문자열만 읽도록 짜여 있었습니다. 매치가 안 되면 예외를 던지지 않고 조용히 검정(0,0,0)을 돌려줬기 때문에, 콘솔에는 아무 에러도 안 뜨고 화면만 까맣게 죽었습니다. 정규식으로 퍼센트 형태도 인식해 값에 2.55를 곱하는 변환을 넣고서야 색이 살아났습니다.

세 번째 — 공간 주파수에 이미 있는 2π를 위상에서 또 곱한 자리

세 번째는 파장 계산이었습니다. 공간 주파수를 freq = 2π/λ로 정의해 두었는데, 위상을 계산하는 자리에서 습관적으로 계수를 한 번 더 곱해 넣었습니다. 결과적으로 실제 파장이 격자 도메인보다 훨씬 길어져 화면 안에서는 파도가 거의 평평한 경사로만 보였습니다. 도메인 크기와 파장의 비율을 맞추는 스케일 계수(0.62)를 위상 계산에 곱해 넣어야 격자 안에 여러 마루와 골이 들어갑니다.

네 번째 — 색 램프 끝점이 한 번도 안 나온 자리

가장 크게 면적을 끌어올린 건 네 번째였습니다. 합성 높이는 대략 -0.75에서 0.75 사이로 나오는데, 이 값을 그대로 0~1 색 램프 좌표로 옮기면 실제로는 0.15~0.85 구간에만 값이 몰려 램프 양 끝의 색(가장 밝은 마루 색과 가장 깊은 골 색)이 화면에 단 한 번도 등장하지 않았습니다. 색이 중간 톤으로만 뭉쳐 있으니 파도가 움직여도 명암 차이가 작아서 측정 스크립트가 "변화 없음"으로 잡은 프레임이 수두룩했던 겁니다. 정규화 스케일을 h * 1.24 + 0.5로 넓혀 클램프가 양 끝까지 닿도록 고치자 누적면적이 60.795%까지 올라왔습니다. 이 수리 경험을 그대로 옮기면 — 애니메이션이 "느리다"거나 "안 움직인다"는 인상은 프레임 수가 아니라 값의 범위가 색 램프 끝에 닿지 않아서인 경우가 흔합니다.

수리 순서와 근거는 이 편의 zip 안에도 정리해 두었고, 압축을 풀 때 입력하는 암호는 jy92b5ca 입니다.

변형 세 가지와 접근성

같은 파도장 계산 위에서 세 값만 바꿔도 전혀 다른 무드가 나옵니다.

변형 바꾸는 값 결과
파 개수 축소 NW를 6에서 3으로 물결이 성겨져 잔잔한 호수에 가까운 표면
크레스트 문턱 완화 thresholdFor의 상한을 0.82에서 0.55로 거품이 점이 아니라 밴드로 넓게 퍼짐
팔레트 교체 $ocean-mint를 라일락 계열로 노을 지는 바다 무드로 전환

접근성은 움직이는 화면인 만큼 세 가지를 기본으로 챙겼습니다. prefers-reduced-motion: reduce가 감지되면 루프를 멈추고 still() 함수로 계산한 정지 프레임 한 장만 그려 물결이 있는 화면임은 보여주되 움직임은 없앱니다. 캔버스에는 tabindex를 주고 포커스가 들어오면 화살표 키로 바람 세기와 방향을 조절할 수 있게 해, 마우스 없이도 상호작용이 끊기지 않습니다. 캔버스 요소에는 aria-label로 장면을 설명해 스크린 리더 사용자도 이 캔버스 바다 애니메이션이 무엇을 그리는 화면인지 알 수 있습니다.

FAQ

파도 모션 그래픽을 만들 때 격자 해상도는 얼마나 낮춰도 되나요?

이 아홉 항목은 전부 저해상 격자에서 계산한 뒤 drawImage로 확대해 올리는 방식을 씁니다. 격자를 실제 픽셀 수만큼 촘촘하게 잡으면 계산량이 급격히 늘어나는데, 물결처럼 큰 덩어리로 움직이는 표면은 격자를 성기게 잡아 STEP 블록 단위로 확대해도 눈에 거슬리지 않습니다. 격자를 얼마나 낮출 수 있는지는 화면 크기와 파장 길이에 따라 다르므로, 확대 배율을 2배·4배로 바꿔 가며 눈으로 확인하는 편이 안전합니다.

움직이는 파도 배경 화면을 페이지 히어로에 넣어도 성능에 문제없나요?

캔버스 하나로 렌더링하고 라이브러리를 쓰지 않기 때문에 무게 자체는 가볍지만, 페이지 최상단에 놓일 때는 prefers-reduced-motion을 반드시 존중하고 탭이 백그라운드로 갈 때 requestAnimationFrame 루프를 멈추는 처리를 더하는 게 좋습니다. 이 아홉 장면 모두 prefers-reduced-motion 대응은 기본으로 넣어 뒀지만, 탭 백그라운드 정지는 브라우저의 rAF 쓰로틀링에 맡겨져 있으니 실서비스에 옮길 때는 visibilitychange로 직접 끊는 처리를 더하는 편이 안전합니다.

아홉 항목을 리액트 프로젝트에 그대로 옮길 수 있나요?

가능합니다. 각 항목은 캔버스 하나와 requestAnimationFrame 루프, 순수 함수 몇 개로만 이루어져 있어 useEffect 안에서 캔버스 ref를 잡고 같은 계산 함수를 그대로 옮기면 됩니다. zip 안에는 바닐라 버전과 함께 리액트로 포팅한 버전도 들어 있어 컴포넌트 구조를 참고할 수 있습니다.

더 읽을거리

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

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