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

이미지 물결 효과 9가지 — 캔버스로 계산하는 픽셀 파동

이미지 물결 효과는 사진 위에 진짜 물이 흐르는 것처럼 픽셀을 흔드는 캔버스 연산이다. 9번 히어로 장면은 이미 완성된 물결이다. 그 전 5번은 반사 웅덩이가 흔들리는 단계였고, 가장 처음 1번은 포인터 한 번이 동심원 하나를 그리는 것에서 시작했다.

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

9번 히어로 장면에서 거꾸로 감아보면 보이는 계산 층

아홉 개는 화면에 물결이 나타나는 방식이 저마다 다르다 — 01·04·06·09는 포인터나 키보드 입력이 직접 충격을 찍고, 02·05·08은 조작 없이 사인파만으로 계속 흔들리며, 03은 호버 반경이 시간에 비례해 자라고, 07은 가상 오디오 레벨이 진폭을 정한다. 모든 계산은 160×100(16,000픽셀) 오프스크린 캔버스 위에서 getImageData로 읽은 이미지를 매 프레임 다시 계산해 putImageData로 그리고, 화면에 보이는 크기는 drawImage가 확대할 뿐이다. 사진은 외부에서 가져오지 않고 seedRand로 만든 절차적 무늬이며, 아래 코드는 run/419/demo 원본 index.html에서 연속된 줄 그대로 옮겼다.

01포인터가 지나간 자리마다 높이 필드에 충격을 찍는 스플래시 물결

포인터가 지나간 자리에서 작은 파문이 퍼져나가 이미지 표면을 밀어낸 것처럼 보인다. splashAt이 그 좌표의 높이 값을 낮춰 놓으면 stepWave가 이웃 칸 평균과 이전 프레임 값을 섞는 파동식으로 매 프레임 그 충격을 사방으로 퍼뜨리고, RIPPLE_SPLASH_DECAY_MS(기본 400)로 정한 damping 값이 그 파문이 얼마 만에 잦아드는지를 가른다.

포인터로 튀기는 물결 파동
새 창에서 열기

01 · 포인터로 튀기는 물결 파동 — 측정 누적면적 15.95% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIPPLE_SPLASH_DECAY_MSstepWavevirtualPointerOrbit
  var RIPPLE_SPLASH_DECAY_MS = 400;
  var SPLASH_AMP = -520;
  var REFRACT_K = 0.9;

  var gridA = new Float32Array(CALC_W * CALC_H);
  var gridB = new Float32Array(CALC_W * CALC_H);
  var damping = Math.pow(0.02, 16 / RIPPLE_SPLASH_DECAY_MS);

  function cellIndex(x, y) { return y * CALC_W + x; }

  function splashAt(gx, gy) {
    gx = Math.round(gx); gy = Math.round(gy);
    for (var dy = -1; dy <= 1; dy += 1) {
      for (var dx = -1; dx <= 1; dx += 1) {
        var x = gx + dx, y = gy + dy;
        if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
          gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
        }
      }
    }
  }

  function stepWave() {
    for (var y = 1; y < CALC_H - 1; y += 1) {
      var row = y * CALC_W;

01 항목은 누적면적 15.9%·강도 평균 56.8·23프레임 중 23프레임에서 움직였다.

02조작 없이도 사인파 여러 겹이 표면을 흔드는 배경 물결

사용자가 아무것도 하지 않아도 이미지 표면 전체가 잔잔하게 계속 출렁인다. ambientHeight가 RIPPLE_AMBIENT_WAVE_COUNT(기본 2)개의 사인파를 저마다 다른 각도·주기·진폭으로 더해 높이 값을 만들고, 그 값의 이웃 칸 기울기를 굴절 오프셋 삼아 매 프레임 이미지를 다시 샘플링한다.

저절로 일렁이는 배경 물결
새 창에서 열기

02 · 저절로 일렁이는 배경 물결 — 측정 누적면적 66.05% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ambientHeightRIPPLE_AMBIENT_WAVE_COUNTREFRACT_K
  function ambientHeight(x, y, t) {
    var h = 0;
    for (var i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
      var ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
      var freq = 0.05 + i * 0.021;
      var speed = 0.0017 + i * 0.0009;
      var amp = 6.5 / (i + 1);
      var dir = (i % 2 === 0) ? 1 : -1;
      h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
    }
    return h;
  }

  function sampleBase(sx, sy) {
    sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
    sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
    var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
    return [baseBuf[i], baseBuf[i + 1], baseBuf[i + 2]];
  }

  function renderFrame(t) {
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var h0 = ambientHeight(x, y, t);

02 항목은 누적면적 66.0%·강도 평균 32.2·23프레임 중 23프레임에서 움직였다.

03호버 반경이 시간에 비례해 자라며 두 번째 사진을 드러내는 물결

호버하면 시작점에서 원 하나가 점점 커지고, 그 경계를 넘은 자리부터 밑에 깔린 두 번째 이미지가 드러난다. renderFrame이 경계에서 WAVE_BAND(기본 10) 폭만큼 sin 값으로 두 이미지를 섞고, 그 폭 안에서만 픽셀을 살짝 밀어 원이 아니라 물결 전선이 지나가는 것처럼 보이게 한다.

물결이 걷히며 다른 이미지가 드러나는 호버
새 창에서 열기

03 · 물결이 걷히며 다른 이미지가 드러나는 호버 — 측정 누적면적 66.526% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_BANDWAVE_FRONT_SPEEDvirtualHover
  function renderFrame(t) {
    var maxR = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var dx0 = x - origin.x, dy0 = y - origin.y;
        var dist = Math.sqrt(dx0 * dx0 + dy0 * dy0);
        var band = Math.sin(dist * 0.35 - t * 0.01);
        var mix = 1 - Math.max(0, Math.min(1, (dist - (radius - WAVE_BAND)) / WAVE_BAND));
        var rippled = dist + band * (mix > 0.02 && mix < 0.98 ? 2.4 : 0);
        var a = sampleAt(bufA, x + band * 0.6, y);
        var b = sampleAt(bufB, x - band * 0.6, y);
        var m = mix;
        outBuf[idx] = a[0] + (b[0] - a[0]) * m;
        outBuf[idx + 1] = a[1] + (b[1] - a[1]) * m;
        outBuf[idx + 2] = a[2] + (b[2] - a[2]) * m;
        outBuf[idx + 3] = 255;
        idx += 4;
        void rippled; void maxR;
      }
    }
  }

03 항목은 누적면적 66.5%·강도 평균 44.4·23프레임 중 23프레임에서 움직였다.

04포인터를 따라다니는 원형 영역만 픽셀을 휘게 하는 굴절 렌즈

포인터 주변 동그란 영역만 빛이 굴절된 렌즈를 통과한 것처럼 이미지가 휘어 보인다. 중심 거리 dist가 LENS_RADIUS(기본 26) 안에 들면 LENS_REFRACT_K(기본 1.7)를 곱한 반경 변위로 그 지점의 픽셀을 안쪽에서 바깥으로 밀어 샘플링하고, sin 파동 한 겹을 더해 렌즈 표면이 물처럼 미세하게 흔들리게 한다.

포인터를 따라다니는 굴절 렌즈
새 창에서 열기

04 · 포인터를 따라다니는 굴절 렌즈 — 측정 누적면적 21.081% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LENS_RADIUSLENS_REFRACT_KvirtualLens
  function renderFrame(t) {
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var dxp = x - lens.x, dyp = y - lens.y;
        var dist = Math.sqrt(dxp * dxp + dyp * dyp);
        var rgb;
        if (dist < LENS_RADIUS) {
          var norm = dist / LENS_RADIUS;
          var bulge = (1 - norm) * (1 - norm);
          var ripple = Math.sin(dist * 0.9 - t * 0.012) * 0.6 * (1 - norm);
          var factor = LENS_REFRACT_K * bulge + ripple;
          var ux = dist > 0.0001 ? dxp / dist : 0;
          var uy = dist > 0.0001 ? dyp / dist : 0;
          var sx = x - ux * factor * 4;
          var sy = y - uy * factor * 4;
          rgb = sampleBase(sx, sy);
          var shine = Math.max(-20, Math.min(50, (1 - norm) * 30 - ripple * 14));
          rgb = [
            Math.max(0, Math.min(255, rgb[0] + shine)),
            Math.max(0, Math.min(255, rgb[1] + shine)),
            Math.max(0, Math.min(255, rgb[2] + shine))
          ];
        } else {
          rgb = sampleBase(x, y);

04 항목은 누적면적 21.1%·강도 평균 22.5·23프레임 중 23프레임에서 움직였다.

05좌우 반전한 아래쪽 절반이 파형을 따라 흔들리는 반사 웅덩이

이미지 아래로 상하 반전된 반사본이 붙어 있고, 그 반사가 물에 뜬 것처럼 좌우로 흔들린다. reflectWave가 깊이 값에 따라 위상이 달라지는 sin 값을 계산해 REFLECT_SWAY_AMP(기본 6.5)만큼 x축 오프셋을 주고, 깊이가 깊어질수록 알파를 낮춰 반사가 아래로 갈수록 옅어지게 한다.

물에 비친 듯 흔들리는 반사 이미지
새 창에서 열기

05 · 물에 비친 듯 흔들리는 반사 이미지 — 측정 누적면적 25.191% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REFLECT_SWAY_AMPREFLECT_GLOW_AMPreflectWave
  function reflectWave(depth, t) {
    return Math.sin(depth * 0.5 - t * REFLECT_SWAY_SPEED);
  }

  function renderFrame(t) {
    var idx = 0;
    var span = CALC_H - HORIZON;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var rgb;
        var alpha = 1;
        var glow = 0;
        if (y < HORIZON) {
          rgb = sampleBase(x, y);
        } else {
          var depth = y - HORIZON;
          var srcY = HORIZON - depth;
          var frac = depth / span;
          var wave = reflectWave(depth, t);
          var offset = wave * REFLECT_SWAY_AMP * frac;
          rgb = sampleBase(x + offset, srcY);
          // 수면 하이라이트: x 오프셋과 같은 파형(wave)을 밝기 감쇠·스웨이 알파에도 그대로 전파한다.
          alpha = Math.max(0.15, 1 - frac * 0.75) * (0.9 + 0.1 * wave);
          glow = Math.max(0, wave) * REFLECT_GLOW_AMP * (1 - frac);

05 항목은 누적면적 25.2%·강도 평균 23.8·23프레임 중 23프레임에서 움직였다.

06좌우로 진행하는 파동 전선이 앞뒤 이미지를 바꿔치는 카드

카드를 뒤집으면 물결 하나가 화면을 가로질러 지나가며 그 자리부터 뒷면 이미지로 바뀐다. renderFrame이 진행률로 계산한 frontX 위치를 기준으로 FRONT_BAND(기본 9) 폭 안에서만 두 이미지를 섞고, 그 경계에 sin 오프셋을 더해 직선이 아니라 물결이 훑고 지나가는 모양을 만든다.

물결이 쓸고 지나가며 뒤집히는 카드
새 창에서 열기

06 · 물결이 쓸고 지나가며 뒤집히는 카드 — 측정 누적면적 63.749% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

AUTO_CYCLE_MSCARD_FLIP_MSFRONT_BAND
  function renderFrame(t) {
    var frontX = progress * (CALC_W + FRONT_BAND * 2) - FRONT_BAND;
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      var wobble = Math.sin(y * 0.45 + t * 0.01) * 3.2;
      var localFront = frontX + wobble;
      for (var x = 0; x < CALC_W; x += 1) {
        var rgb;
        var d = x - localFront;
        if (d < -FRONT_BAND) {
          rgb = sampleAt(bufBack, x, y);
        } else if (d > FRONT_BAND) {
          rgb = sampleAt(bufFront, x, y);
        } else {
          var m = (d + FRONT_BAND) / (FRONT_BAND * 2);
          var a = sampleAt(bufFront, x, y);
          var b = sampleAt(bufBack, x, y);
          rgb = [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
          var shine = (1 - Math.abs(m - 0.5) * 2) * 34;
          rgb = [rgb[0] + shine, rgb[1] + shine, rgb[2] + shine];

06 항목은 누적면적 63.7%·강도 평균 59.5·23프레임 중 23프레임에서 움직였다.

07가상 오디오 레벨이 커질수록 진폭이 함께 자라는 물결

소리가 커지는 순간마다 화면 가운데서 물결이 크게 퍼져나간다. virtualAudioLevel이 비트·펄스·플리커 세 성분을 합쳐 만든 가상 레벨 값에 RIPPLE_AUDIO_GAIN(기본 0.4)을 곱해 splashAt의 진폭으로 넘기고, 실제 마이크 입력이 아니라 이 절차적 값이 매 프레임 충격 크기를 정한다.

소리 크기만큼 커지는 물결
새 창에서 열기

07 · 소리 크기만큼 커지는 물결 — 측정 누적면적 19.934% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIPPLE_AUDIO_GAINBEAT_INTERVAL_MSvirtualAudioLevel
  function splashAt(gx, gy, amp) {
    gx = Math.round(gx); gy = Math.round(gy);
    for (var dy = -1; dy <= 1; dy += 1) {
      for (var dx = -1; dx <= 1; dx += 1) {
        var x = gx + dx, y = gy + dy;
        if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
          gridA[cellIndex(x, y)] += amp * (dx === 0 && dy === 0 ? 1 : 0.4);
        }
      }
    }
  }

  function stepWave() {
    for (var y = 1; y < CALC_H - 1; y += 1) {
      var row = y * CALC_W;
      for (var x = 1; x < CALC_W - 1; x += 1) {
        var i = row + x;
        var next = (gridA[i - 1] + gridA[i + 1] + gridA[i - CALC_W] + gridA[i + CALC_W]) / 2 - gridB[i];
        gridB[i] = next * damping;
      }
    }
    var t = gridA; gridA = gridB; gridB = t;
  }

07 항목은 누적면적 19.9%·강도 평균 33.3·23프레임 중 23프레임에서 움직였다.

08글자 모양으로 자른 영역 안에서만 출렁이는 이미지

글자 윤곽 안쪽에서만 이미지가 액체처럼 출렁이고, 바깥은 정적인 배경 그대로 남는다. fillText로 그린 글자의 알파 채널을 마스크로 저장해 두고, 그 마스크 값이 임계치를 넘는 픽셀에만 liquidHeight의 사인파 굴절을 적용해 매 프레임 그 영역만 다시 계산한다.

글자 안에서 출렁이는 이미지
새 창에서 열기

08 · 글자 안에서 출렁이는 이미지 — 측정 누적면적 17.21% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

liquidHeightRIPPLE_TEXT_FIT_RATIORIPPLE_TEXT_WAVE_COUNT
  function liquidHeight(x, y, t) {
    var h = 0;
    for (var i = 0; i < RIPPLE_TEXT_WAVE_COUNT; i += 1) {
      var freq = 0.09 + i * 0.05;
      var speed = 0.0064 + i * 0.0032;
      var amp = 8 / (i + 1);
      h += amp * Math.sin(x * freq + t * speed) * Math.cos(y * (freq * 0.7) - t * speed * 0.7);
    }
    return h;
  }

  var BG_R = 23, BG_G = 20, BG_B = 26;

  function renderFrame(t) {
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var mAlpha = maskBuf[idx + 3];
        if (mAlpha < 24) {
          outBuf[idx] = BG_R; outBuf[idx + 1] = BG_G; outBuf[idx + 2] = BG_B; outBuf[idx + 3] = 255;
        } else {
          var h0 = liquidHeight(x, y, t);
          var hx = liquidHeight(x + 1, y, t) - liquidHeight(x - 1, y, t);
          var hy = liquidHeight(x, y + 1, t) - liquidHeight(x, y - 1, t);

08 항목은 누적면적 17.2%·강도 평균 29.6·23프레임 중 23프레임에서 움직였다.

0901·02·05를 한 캔버스에 합친 히어로 물결 장면

포인터가 튀기는 파문과 배경 전체를 흔드는 잔물결, 아래쪽 반사 웅덩이가 한 화면 안에서 동시에 움직인다. upperRGB가 01의 높이 필드(gridA)와 02의 ambientHeight를 더한 기울기로 위쪽 픽셀을 굴절시키고, 05와 같은 방식으로 그 결과를 상하 반전해 아래쪽에 반사로 다시 그린다.

위 기법을 합친 히어로 물결 장면
새 창에서 열기

09 · 위 기법을 합친 히어로 물결 장면 — 측정 누적면적 61.751% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HERO_SPLASH_DECAY_MSupperRGBambientHeight
  function upperRGB(x, y, t) {
    var i = y * CALC_W + x;
    var hl = x > 0 ? gridA[i - 1] : gridA[i];
    var hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
    var hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
    var hd = y < HORIZON - 1 ? gridA[i + CALC_W] : gridA[i];
    var amb = ambientHeight(x, y, t);
    var ambX = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
    var ambY = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
    var dx = (hr - hl) * 0.85 + ambX * 1.1;
    var dy = (hd - hu) * 0.85 + ambY * 1.1;
    var rgb = sampleBase(x + dx, y + dy);
    var shine = Math.max(-30, Math.min(55, (hu - hd) * 0.5 + amb * 4));
    return [
      Math.max(0, Math.min(255, rgb[0] + shine)),
      Math.max(0, Math.min(255, rgb[1] + shine)),
      Math.max(0, Math.min(255, rgb[2] + shine))
    ];
  }

09 항목은 누적면적 61.8%·강도 평균 38.1·23프레임 중 23프레임에서 움직였다.

화면 캡처 24장이 남긴 아홉 물결의 실제 움직임 수치

24장의 캡처를 앞뒤로 견주어 픽셀이 바뀐 양을 뽑으면 아래 표가 나온다 — 03 호버 리빌이 누적면적 66.5%로 가장 넓게 바뀌고, 08 글자 속 리퀴드는 마스크 밖이 정적 배경으로 남아 17.2%로 가장 좁다.

NN 이름 누적면적 % 강도 평균 x/23
01 포인터 스플래시 15.9 56.8 23/23
02 배경 잔물결 66.0 32.2 23/23
03 호버 리빌 66.5 44.4 23/23
04 굴절 렌즈 21.1 22.5 23/23
05 반사 웅덩이 25.2 23.8 23/23
06 카드 플립 웨이브 63.7 59.5 23/23
07 오디오 반응 물결 19.9 33.3 23/23
08 글자 속 리퀴드 17.2 29.6 23/23
09 히어로 물결 장면 61.8 38.1 23/23

이 물결 계산을 실제로 붙이는 화면

01 포인터 스플래시와 04 굴절 렌즈는 제품 이미지 호버나 포트폴리오 프리뷰처럼 마우스가 지나갈 때 반응해야 하는 자리에 맞고, 02 배경 잔물결과 09 히어로 장면은 히어로 배경·로딩 화면처럼 계산만으로 계속 움직여야 하는 자리에 쓴다. 03 호버 리빌은 비교 이미지 카드나 상품 색상 변경에, 05 반사 웅덩이는 브랜드 로고 인트로에, 06 카드 플립 웨이브는 카드 상세 전환이나 이벤트 배너 교체에 붙인다. 07 오디오 반응 물결은 음악 앨범 커버나 팟캐스트 플레이어처럼 소리와 엮이는 화면에, 08 글자 속 리퀴드는 브랜드 타이포 히어로에 맞는다.

감쇠 시간·파형 개수·오디오 게인 값이 물결을 가르는 자리

01의 RIPPLE_SPLASH_DECAY_MS(기본 400)와 09의 HERO_SPLASH_DECAY_MS(기본 420)는 이름은 다르지만 같은 damping = Math.pow(0.02, 16 / 감쇠상수) 식에 그대로 들어가 한 프레임(16ms)마다 파형이 얼마나 줄어드는지를 정하고, 값이 작을수록 파문이 두어 프레임 만에 사그라든다. 02·09의 RIPPLE_AMBIENT_WAVE_COUNT(기본 2)는 ambientHeight가 더하는 사인파 겹 수 자체라 값을 늘리면 서로 다른 각도·주기의 파가 겹쳐 표면이 훨씬 복잡하게 흔들린다. 07의 RIPPLE_AUDIO_GAIN(기본 0.4)은 가상 레벨 값에 곱해져 splashAt으로 넘어가는 진폭을 정해, 값 하나만 올려도 같은 레벨에서 물결이 화면 가장자리까지 번진다. 이 세 값을 상수 하나만 바꿔도 같은 css 물결 애니메이션이라도 인상이 완전히 달라진다는 걸 아래 변형 표에서 확인할 수 있다.

동작 줄임을 켜도 계산이 멈추지 않는 값 하나, 그리고 깨지는 지점

prefers-reduced-motion: reduce가 참이면 01·03·04·06·07·09의 playing이 꺼져 tick의 stepWave·renderFrame 호출이 멈추고 화면은 마지막으로 계산된 프레임에서 그대로 굳는다. 대신 01·04·06·09는 keydown에서 Enter·Space나 화살표로 값을 바꾸면 그 순간만 다시 계산해 그리고, 07은 range 슬라이더를 조작할 때마다 if (reduce) 분기가 새 게인으로 스플래시를 한 번 더 찍어 정지 화면에서도 값 변화를 확인할 수 있다. e7jhekfs으로 zip을 열면 아홉 개의 index.html·style.scss 원본이 그대로 들어 있어 02·05·08 세 배경형은 조작 지점이 없는 대신 reduce 진입 시 elapsed를 900·700·600 같은 고정 시각으로 박아 파형이 절반쯤 자란 대표 프레임 한 장만 남긴다는 것도 소스에서 바로 확인할 수 있다. 지금은 CALC_W·CALC_H가 160×100(16,000픽셀)이라 getImageData·putImageData 왕복이 가볍지만, 이 값을 실제 화면 해상도로 그대로 올리면 픽셀 수가 수십~수백 배로 뛰어 저사양 기기에서는 한 프레임 계산이 16ms를 넘어 끊길 수 있다.

값 세 가지로 보는 변형, 그리고 reduced-motion·키보드 접근성

계산식은 그대로 두고 상수 하나만 건드려도 물결의 인상이 달라진다.

이름 값 느낌
물결 감쇠 시간 값 조정 01 RIPPLE_SPLASH_DECAY_MS 400 → 1400 값을 낮추면 파동이 한 번 튀고 곧장 잦아들어 클릭 피드백처럼 가볍고, 값을 올리면 앞선 파동이 채 가시기 전에 다음 파동이 겹쳐 화면이 계속 출렁인다.
배경 파형 개수 값 조정 02 RIPPLE_AMBIENT_WAVE_COUNT 2 → 6 값을 낮추면 사인파 두 겹만 남아 표면이 한 방향으로 밋밋하게 흐르고, 값을 올리면 여섯 겹이 교차해 잔물결 결이 훨씬 촘촘하고 불규칙해진다.
오디오 반응 게인 값 조정 07 RIPPLE_AUDIO_GAIN 0.4 → 1.6 값을 낮추면 가상 레벨이 최고조에 달해도 물결 진폭이 거의 안 자라고, 값을 올리면 레벨이 살짝만 올라가도 물결이 화면 가장자리까지 크게 번진다.

prefers-reduced-motion이 걸리면 위에서 본 대로 여섯 개는 재생이 꺼지고 나머지 셋은 대표 프레임 한 장으로 고정된다. 01·04·06·09는 wrap.tabIndex = 0으로 포커스를 받아 키보드만으로도 Enter·Space·화살표로 물결을 일으킬 수 있고, 03은 focus·blur 리스너가 호버 대신 반전을 열고 닫아 마우스 없이도 두 번째 이미지를 볼 수 있다. 07의 게인 슬라이더는 <input type="range">에 aria-label="반응 세기"가 달려 있어 방향키만으로 게인을 조절할 수 있지만, 02·05·08 세 배경형은 애초에 조작 지점이 없어 키보드 대안이 필요하지 않다.

css 물결 효과와 사진으로 찍은 물결은 뭐가 다른가?

CSS filter나 background-image만으로 만드는 사진 물결 효과는 정적인 왜곡 한 장을 고정하거나 기껏해야 배경 위치를 반복 이동시키는 수준이라, 픽셀이 실제로 밀리는 굴절까지는 표현하지 못한다. 여기 아홉 개는 시작할 때 getImageData로 사진 픽셀을 한 번 읽어 두고, 매 프레임 높이 필드·거리·마스크 계산으로 그 좌표를 다시 정해 ImageData 버퍼를 채운 뒤 putImageData로 다시 그려 넣어, 같은 css 물결 효과라 불러도 실제로는 값 하나가 파동 계산 전체를 다시 도는 방식이다. 정적인 장면 효과로 확장한 결과는 캔버스 특수효과 9종에서, 색·채도·질감을 픽셀 단위로 바꾸는 계산은 css 이미지 필터 효과 9종에서, 사용자가 직접 확대·이동으로 이미지를 다루는 쪽은 이미지 확대 뷰어 UI 9종에서 각각 확인할 수 있다.

물결 계산을 처음 만질 때 자주 묻는 것

이 데모에 쓰인 이미지는 실제 사진인가요?

아니다. 아홉 개 모두 seedRand로 만든 절차적 무늬를 drawPhotoBase가 그려 베이스로 쓰고, 외부 사진 파일은 하나도 부르지 않는다. 실제 사진에 적용하고 싶다면 drawPhotoBase 대신 ctx.drawImage로 자신의 이미지를 그린 뒤 그 픽셀을 getImageData로 읽으면 같은 계산이 그대로 작동한다.

css 물결 애니메이션을 이 캔버스 코드 없이 CSS만으로도 만들 수 있나요?

SVG feTurbulence나 radial-gradient 애니메이션으로 비슷한 흔들림 인상은 낼 수 있지만, 픽셀 좌표 자체를 밀어내는 굴절까지는 CSS 단독으로 재현되지 않는다. 여기 아홉 개는 높이 필드나 거리 계산으로 얻은 값을 실제 픽셀 오프셋으로 써서 getImageData·putImageData를 오가며 다시 그리기 때문에 CSS 필터보다 계산량은 크지만 표현할 수 있는 왜곡 폭이 훨씬 넓다.

07의 물결은 실제 마이크 소리에 반응하나요?

아니다. virtualAudioLevel이 비트·펄스·플리커 세 사인 성분을 합쳐 만든 가상 레벨 값으로 진폭을 정하고, 마이크나 AnalyserNode 같은 실제 오디오 입력은 쓰지 않는다. 진짜 소리에 반응하게 하려면 Web Audio API의 AnalyserNode.getByteFrequencyData 값을 virtualAudioLevel의 반환값 자리에 넣으면 되고, 게인 슬라이더(RIPPLE_AUDIO_GAIN)는 그 값을 그대로 재사용할 수 있다.

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

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