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

메쉬 그라데이션 애니메이션 9가지 — 색점 보간부터 노이즈 비틀기까지 JS 계산 장면

메쉬 그라데이션은 색 몇 개를 번지게 하면 끝날 것 같지만, 그대로 섞으면 가운데가 탁해지고 어두운 면은 띠로 갈라진다 — 05번과 06번이 그 두 군데를 하나씩 막는다. 아래 아홉 장면은 캔버스의 픽셀을 자바스크립트로 하나씩 계산해 그렸고, 파일을 내려받아 붙이면 바로 움직인다.

메쉬 그라데이션 아홉 장면을 화면에서 바로 본다

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

메쉬 그라데이션 애니메이션 9가지 — 픽셀마다 계산한 색이 화면을 채운다

아홉 장면은 같은 틀 위에 서 있다. 캔버스 안쪽은 160×100 칸(CALC_W·CALC_H)이고, 프레임마다 createImageData 로 만든 ImageData 에 칸 16,000개의 색을 하나씩 적어 putImageData 로 올린 뒤 CSS 가 16:10 비율로 늘려 보여 준다. 칸 수를 160×100 에 묶어 두는 이유는 계산량이다. 그리기 칸이 늘면 프레임마다 도는 계산도 같은 비율로 늘기 때문이다.

그 위에서 아홉 장면은 색을 섞는 방법을 하나씩 바꾼다. 01 은 네 모서리 색의 쌍선형 보간이 가장 작은 그라데이션 메쉬라는 점을 보이고, 02 는 3×3 제어점으로 칸을 늘리며, 03 은 격자를 버리고 색점만 놓는다. 04 는 색을 읽는 좌표를 노이즈로 비틀고, 05 와 06 은 섞는 방식의 함정을 하나씩 고치는 비교 화면이다. 07 은 밝기를 높이로 읽어 광택을 얹고, 08 은 색상 각도를 돌리며, 09 는 03 의 색점 섞기에 가장자리 어둠과 알갱이를 얹어 제목 뒤 배경으로 묶는다.

시간은 requestAnimationFrame 의 타임스탬프를 16밀리초(FIXED_DT) 단위로 모아 초로 바꾸어 paint(t, d) 에 넘기고, 난수는 시드를 고정한 seedRand 만 쓴다. 라이브러리·WebGL·외부 주소가 없어서 같은 시각에는 언제나 같은 그림이 나온다. 같은 캔버스 방식으로 물방울·블랙홀을 그리는 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과와 사인 합성으로 색 띠를 만드는 무지개 그라데이션 9가지 — 캔버스 플라즈마 배경 만들기를 곁들여 보면 계산으로 색을 정하는 방식의 폭이 보인다.

기법별 코드로 보는 색 섞기

아래 조각은 데모 소스의 연속된 줄을 순서 그대로 잘라 붙였고, 완결된 파일은 압축 파일에 있다. 색 목록을 원형으로 읽는 pick(pal, pos, out) 은 pos 의 정수부로 색을 고르고 소수부만큼 다음 색으로 선형 보간하는 도우미라서, 01·02 의 색 순환이 이 함수에서 나온다(08 은 이 함수를 부르지 않고 HUE_SPEED 로 색상 각도를 직접 돌린다).

01네 모서리 색을 보간하는 기본 메쉬

CORNER_RATE(초당 2)에 맞춰 네 모서리 색이 분홍·라일락·하늘·민트 목록에서 시계 방향으로 한 칸씩 이웃 색으로 넘어가고, 모든 픽셀은 lerp 를 가로로 두 번, 세로로 한 번 불러 네 색의 가중 평균을 받는다. 가운데는 네 색이 같은 비중으로 섞인 값이라 모서리보다 탁해지기 쉽고, 이 쌍선형 틀이 나머지 장면의 뼈대가 된다. 측정은 누적면적 60.9%·평균 강도 15.9·23/23 프레임이다.

네 모서리 색을 보간하는 기본 메쉬
새 창에서 열기

01 · 네 모서리 색을 보간하는 기본 메쉬 — 측정 누적면적 60.93% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CORNER_RATECORNER_PALCALC_WCALC_H
      var CORNER_RATE = 2;
      var CORNER_PAL = [PAL[0], PAL[1], PAL[2], PAL[3]];
      var corners = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]];
      function paint(t, d) {
          // 왼쪽 위 → 오른쪽 위 → 오른쪽 아래 → 왼쪽 아래 순서로 색 목록의 이웃 칸을 하나씩 받는다
          pick(CORNER_PAL, t * CORNER_RATE + 0, corners[0]);
          pick(CORNER_PAL, t * CORNER_RATE + 1, corners[1]);
          pick(CORNER_PAL, t * CORNER_RATE + 2, corners[3]);
          pick(CORNER_PAL, t * CORNER_RATE + 3, corners[2]);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var r = lerp(lerp(corners[0][0], corners[1][0], u), lerp(corners[2][0], corners[3][0], u), v);
                  var g = lerp(lerp(corners[0][1], corners[1][1], u), lerp(corners[2][1], corners[3][1], u), v);
                  var b = lerp(lerp(corners[0][2], corners[1][2], u), lerp(corners[2][2], corners[3][2], u), v);
                  put(d, x, y, r, g, b);
              }
          }

023×3 제어점이 떠다니는 부드러운 메쉬

제어점 아홉 개가 NODE_RATE(초당 1.2)로 색 목록을 읽어 색을 바꾸고 SWAY(0.12)만큼 제자리 근처에서 흔들린다. 칸 안의 가중치는 smooth 를 거쳐 칸 경계에서 기울기가 0 이 되어 이음새의 꺾임이 줄고, 제어점이 움직인 만큼 읽는 자리를 거꾸로 밀어 장면이 따라 휜다. 측정은 누적면적 69.1%·평균 강도 16.5·23/23 프레임이다.

3×3 제어점이 떠다니는 부드러운 메쉬
새 창에서 열기

02 · 3×3 제어점이 떠다니는 부드러운 메쉬 — 측정 누적면적 69.099% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWAYNODE_RATECALC_WCALC_H
      function meshAt(u, v, out) {
          var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
          var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
          var fx = smooth(gx - ix), fy = smooth(gy - iy);
          var a = nodeCol[iy * 3 + ix], b = nodeCol[iy * 3 + ix + 1];
          var c = nodeCol[(iy + 1) * 3 + ix], e = nodeCol[(iy + 1) * 3 + ix + 1];
          out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
          out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
          out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
      }
      function paint(t, d) {
          for (var n = 0; n < 9; n += 1) {
              pick(PAL, n * 0.7 + t * NODE_RATE, nodeCol[n]);
              nodeDx[n] = SWAY * Math.sin(6.2832 * (t * 1.2 + n * 0.37));
              nodeDy[n] = SWAY * Math.cos(6.2832 * (t * 1.05 + n * 0.51));
          }
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              var gy = v * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var gx = u * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
                  var k0 = iy * 3 + ix, k1 = k0 + 1, k2 = k0 + 3, k3 = k0 + 4;
                  // 제어점이 움직인 만큼 읽는 자리를 거꾸로 민다 — 같은 부드러운 가중치로 변위를 섞는다
                  var du = lerp(lerp(nodeDx[k0], nodeDx[k1], fx), lerp(nodeDx[k2], nodeDx[k3], fx), fy);
                  var dv = lerp(lerp(nodeDy[k0], nodeDy[k1], fx), lerp(nodeDy[k2], nodeDy[k3], fx), fy);
                  meshAt(u - du, v - dv, meshTmp);
                  put(d, x, y, meshTmp[0], meshTmp[1], meshTmp[2]);
              }
          }
      }

03거리 역수로 색을 섞는 번짐 메쉬

여섯 색점이 리사주 궤도로 헤엄치고, 각 픽셀은 점까지 거리의 POWER 제곱에 반비례하는 무게 1 / (d^POWER + EPS) 로 여섯 색을 가중 평균한다. 격자 없이 점만 놓으면 되는 대신 픽셀마다 점 여섯 개의 거리와 Math.pow 를 부르므로 아홉 장면 가운데 가장 무겁다. 측정은 누적면적 70.2%·평균 강도 25.5·23/23 프레임이다.

거리 역수로 색을 섞는 번짐 메쉬
새 창에서 열기

03 · 거리 역수로 색을 섞는 번짐 메쉬 — 측정 누적면적 70.224% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

POWEREPSASPECTCALC_W
      var POWER = 2;
      var EPS = 0.002;
      var ASPECT = CALC_W / CALC_H;
      var SEED_FX = [0.31, 0.43, 0.37, 0.5, 0.27, 0.41];
      var SEED_FY = [0.39, 0.29, 0.47, 0.33, 0.45, 0.35];
      var SEED_PH = [0.0, 0.17, 0.33, 0.5, 0.67, 0.83];
      var dotX = [0, 0, 0, 0, 0, 0];
      var dotY = [0, 0, 0, 0, 0, 0];
      function paint(t, d) {
          for (var k = 0; k < 6; k += 1) {
              dotX[k] = (0.5 + 0.4 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
              dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.7));
          }
          var halfP = POWER / 2;
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 6; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + EPS);
                      wr += w * PAL[j][0];
                      wg += w * PAL[j][1];
                      wb += w * PAL[j][2];
                      ws += w;
                  }
                  put(d, x, y, wr / ws, wg / ws, wb / ws);
              }
          }
      }

04노이즈로 좌표를 비틀어 흐르는 메쉬

색을 읽기 전에 좌표 u·v 를 값 노이즈 두 겹(큰 물결과 잔물결)으로 WARP(0.3)만큼 밀고, 밀린 자리에서 고정된 3×3 격자 색을 읽는다. 색 자체는 그대로인데 가장자리가 연기처럼 휘어 흐르므로, 정적인 그라데이션을 살아 있는 배경으로 바꿀 때 쓴다. 측정은 누적면적 65.6%·평균 강도 18.1·23/23 프레임이다.

노이즈로 좌표를 비틀어 흐르는 메쉬
새 창에서 열기

04 · 노이즈로 좌표를 비틀어 흐르는 메쉬 — 측정 누적면적 65.601% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WARPLATTICECALC_WCALC_H
      function lat(ix, iy) {
          return lattice[(((iy % LATTICE) + LATTICE) % LATTICE) * LATTICE + (((ix % LATTICE) + LATTICE) % LATTICE)];
      }
      function vnoise(x, y) {
          var ix = Math.floor(x), iy = Math.floor(y);
          var fx = smooth(x - ix), fy = smooth(y - iy);
          return lerp(lerp(lat(ix, iy), lat(ix + 1, iy), fx), lerp(lat(ix, iy + 1), lat(ix + 1, iy + 1), fx), fy);
      }
      var warpTmp = [0, 0, 0];
      function paint(t, d) {
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  // 큰 물결 + 잔물결 두 겹
                  var wu = (vnoise(u * 2.2 + t * 0.7, v * 2.2 - t * 0.4) - 0.5) * 2 + (vnoise(u * 5 - t * 0.9 + 7.7, v * 5 + t * 0.6) - 0.5) * 0.7;
                  var wv = (vnoise(u * 2.2 - t * 0.5 + 3.1, v * 2.2 + t * 0.8 + 5.3) - 0.5) * 2 + (vnoise(u * 5 + t * 0.8 + 1.9, v * 5 - t * 0.7 + 9.1) - 0.5) * 0.7;
                  meshRead(u + WARP * wu, v + WARP * wv, warpTmp);
                  put(d, x, y, warpTmp[0], warpTmp[1], warpTmp[2]);
              }
          }
      }

05그냥 섞기와 밝기 보정 섞기를 나란히

같은 보라와 주황을 같은 비율 s 로 섞되, 왼쪽 절반은 값 그대로 lerp 하고 오른쪽 절반은 GAMMA(2.2) 제곱으로 밝기를 선형으로 바꿔 섞은 뒤 되돌린다. 0.9초 시점에 세로 전체를 평균 내면 띠 가운데 색이 왼쪽 191,122,155(밝기 146.4), 오른쪽 201,127,179(밝기 155.1)로 오른쪽이 더 밝고 푸른기가 남는다. 측정은 누적면적 64.5%·평균 강도 19.2·23/23 프레임이다.

그냥 섞기와 밝기 보정 섞기를 나란히
새 창에서 열기

05 · 그냥 섞기와 밝기 보정 섞기를 나란히 — 측정 누적면적 64.516% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAMMACOLOR_ACOLOR_BHALF
      var COLOR_A = [130, 90, 245];
      var COLOR_B = [255, 156, 60];
      var GAMMA = 2.2;
      var linA = [Math.pow(COLOR_A[0] / 255, GAMMA), Math.pow(COLOR_A[1] / 255, GAMMA), Math.pow(COLOR_A[2] / 255, GAMMA)];
      var linB = [Math.pow(COLOR_B[0] / 255, GAMMA), Math.pow(COLOR_B[1] / 255, GAMMA), Math.pow(COLOR_B[2] / 255, GAMMA)];
      var HALF = CALC_W / 2;
      function paint(t, d) {
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var right = x >= HALF;
                  var lx = (right ? x - HALF : x) / (HALF - 1);
                  // 두 색 사이의 비율 s — 양쪽 절반이 같은 값 s 를 쓴다
                  var s = smooth(lx + 0.22 * Math.sin(6.2832 * (v * 1.1 + t * 0.5)) + 0.1 * Math.sin(6.2832 * (v * 2.3 - t * 0.35)));
                  var r, g, b;
                  if (!right) {
                      r = lerp(COLOR_A[0], COLOR_B[0], s);
                      g = lerp(COLOR_A[1], COLOR_B[1], s);
                      b = lerp(COLOR_A[2], COLOR_B[2], s);
                  }
                  else {
                      r = 255 * Math.pow(lerp(linA[0], linB[0], s), 1 / GAMMA);
                      g = 255 * Math.pow(lerp(linA[1], linB[1], s), 1 / GAMMA);
                      b = 255 * Math.pow(lerp(linA[2], linB[2], s), 1 / GAMMA);
                  }
                  if (x === Math.floor(HALF) || x === Math.floor(HALF) - 1) {
                      r = 255;
                      g = 255;
                      b = 255;
                  }
                  put(d, x, y, r, g, b);
              }
          }
      }

06줄무늬를 지우는 알갱이 섞기

어두운 방사형 그라데이션을 STEP(10) 단위로 반올림해 일부러 띠를 만들고, 왼쪽 절반은 반올림 문턱을 0.5 로 고정하고 오른쪽 절반은 시드 2026 난수표로 문턱을 GRAIN 만큼 흔들어 띠의 경계를 흩는다. 문턱은 한 단계 안에서만 흔들리므로 결과 색이 단계 한 칸 넘게 어긋나지 않는다. 측정은 누적면적 62.0%·평균 강도 16.0·23/23 프레임이다.

줄무늬를 지우는 알갱이 섞기
새 창에서 열기

06 · 줄무늬를 지우는 알갱이 섞기 — 측정 누적면적 61.95% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEPGRAINNOISE_NDARKLIGHT
      var STEP = 10;
      var GRAIN = 1;
      var DARK = [30, 34, 66];
      var LIGHT = [112, 100, 170];
      var NOISE_N = CALC_W * CALC_H;
      var noiseTab = [];
      var rnd = seedRand(2026);
      for (var ni = 0; ni < NOISE_N; ni += 1) {
          noiseTab.push(rnd());
      }
      function paint(t, d) {
          var cx = 0.5 + 0.28 * Math.sin(6.2832 * t * 0.4);
          var cy = 0.5 + 0.2 * Math.cos(6.2832 * t * 0.31);
          var frame = Math.floor(t * 30);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var dx = (u - cx) * 1.6, dy = v - cy;
                  var k = 1 - clamp(Math.sqrt(dx * dx + dy * dy) * 1.05, 0, 1);
                  var right = x >= CALC_W / 2;
                  var n = noiseTab[(y * CALC_W + x + frame * 997) % NOISE_N];
                  // 반올림 문턱을 픽셀마다 0.5 ± GRAIN/2 단계 안에서 흔든다 (왼쪽은 문턱 0.5 고정)
                  var th = right ? 0.5 + (n - 0.5) * GRAIN : 0.5;
                  var ch = [0, 0, 0];
                  for (var c = 0; c < 3; c += 1) {
                      var val = lerp(DARK[c], LIGHT[c], k);
                      ch[c] = Math.floor(val / STEP + th) * STEP;
                  }
                  if (x === Math.floor(CALC_W / 2) || x === Math.floor(CALC_W / 2) - 1) {
                      ch = [236, 232, 255];
                  }
                  put(d, x, y, ch[0], ch[1], ch[2]);
              }
          }
      }

07밝기를 높이로 읽어 빛이 도는 메쉬

점 네 개의 역제곱 가중 메쉬 밝기를 제곱해 HEIGHT 만큼의 높이로 읽고, 이웃 픽셀의 높이 차이로 기울기와 법선을 구한 뒤 2.86초에 한 바퀴 도는 광원의 확산광과 흰색 반짝임(SHINE 24)을 더한다. 버튼이나 카드 표면에 광택을 얹을 때 어떤 값이 번쩍임을 만드는지 한 화면에서 볼 수 있다. 측정은 누적면적 71.2%·평균 강도 28.9·23/23 프레임이다.

밝기를 높이로 읽어 빛이 도는 메쉬
새 창에서 열기

07 · 밝기를 높이로 읽어 빛이 도는 메쉬 — 측정 누적면적 71.171% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HEIGHTSLOPE_GAINSHINESHEEN
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 4; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (dx * dx + dy * dy + 0.004);
                      wr += w * TONE[j][0];
                      wg += w * TONE[j][1];
                      wb += w * TONE[j][2];
                      ws += w;
                  }
                  var i = y * CALC_W + x;
                  colR[i] = wr / ws;
                  colG[i] = wg / ws;
                  colB[i] = wb / ws;
                  // 밝기(0~1)에서 가까운 점의 가중 비율을 한 번 더 제곱해 높이로 쓴다
                  var lum = (0.299 * colR[i] + 0.587 * colG[i] + 0.114 * colB[i]) / 255;
                  hgt[i] = lum * lum * HEIGHT * CALC_H;
              }
          }
          var ang = 6.2832 * t * 0.35;
          var lx = Math.cos(ang) * 0.7, ly = Math.sin(ang) * 0.7, lz = 0.6;
          var ln = Math.sqrt(lx * lx + ly * ly + lz * lz);
          lx /= ln;
          ly /= ln;
          lz /= ln;
          // 반사 벡터 대신 절반 벡터(광원 + 시선)로 반짝임을 잰다
          var hx = lx, hy = ly, hz = lz + 1;
          var hn = Math.sqrt(hx * hx + hy * hy + hz * hz);
          hx /= hn;
          hy /= hn;
          hz /= hn;
          for (var y2 = 0; y2 < CALC_H; y2 += 1) {
              var ya = Math.max(0, y2 - 1), yb = Math.min(CALC_H - 1, y2 + 1);
              for (var x2 = 0; x2 < CALC_W; x2 += 1) {
                  var xa = Math.max(0, x2 - 1), xb = Math.min(CALC_W - 1, x2 + 1);
                  var gx = (hgt[y2 * CALC_W + xa] - hgt[y2 * CALC_W + xb]) * SLOPE_GAIN * 10;
                  var gy = (hgt[ya * CALC_W + x2] - hgt[yb * CALC_W + x2]) * SLOPE_GAIN * 10;
                  var nn = Math.sqrt(gx * gx + gy * gy + 1);
                  var nx = gx / nn, ny = gy / nn, nz = 1 / nn;
                  var diff = clamp(nx * lx + ny * ly + nz * lz, 0, 1);
                  var spec = Math.pow(clamp(nx * hx + ny * hy + nz * hz, 0, 1), SHINE) * SHEEN;
                  var j2 = y2 * CALC_W + x2;
                  var shade = 0.72 + 0.4 * diff;
                  put(d, x2, y2, clamp(colR[j2] * shade + 255 * spec, 0, 255), clamp(colG[j2] * shade + 255 * spec, 0, 255), clamp(colB[j2] * shade + 255 * spec, 0, 255));
              }
          }

08색상 각도를 돌려 분위기가 바뀌는 메쉬

3×3 제어점 아홉 개의 색상 각도가 HUE_SPEED 에 따라 초당 66~174도씩, 일부는 반대 방향으로 돌고 밝기는 DAY_PERIOD(8초) 주기로 0.74~0.84 사이를 오르내린다. 소스에는 새벽·낮·노을·밤 같은 시간대 이름이 없고, 밝기 곡선 하나가 시간대에 따른 분위기 변화를 대신한다. 측정은 누적면적 62.6%·평균 강도 15.6·23/23 프레임이다.

색상 각도를 돌려 분위기가 바뀌는 메쉬
새 창에서 열기

08 · 색상 각도를 돌려 분위기가 바뀌는 메쉬 — 측정 누적면적 62.622% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SATURATIONDAY_PERIODBASE_HUEHUE_SPEED
      function paint(t, d) {
          var phase = 0.5 - 0.5 * Math.cos(6.2832 * t / DAY_PERIOD);
          var light = 0.84 - 0.1 * phase;
          for (var n = 0; n < 9; n += 1) {
              hslToRgb(BASE_HUE[n] + HUE_SPEED[n] * t, SATURATION, light, hueCol[n]);
          }
          for (var y = 0; y < CALC_H; y += 1) {
              var gy = (y / (CALC_H - 1)) * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
              for (var x = 0; x < CALC_W; x += 1) {
                  var gx = (x / (CALC_W - 1)) * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
                  var a = hueCol[iy * 3 + ix], b = hueCol[iy * 3 + ix + 1], c = hueCol[(iy + 1) * 3 + ix], e = hueCol[(iy + 1) * 3 + ix + 1];
                  put(d, x, y, lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy), lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy), lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy));
              }
          }
      }

09제목 뒤에 깔리는 메쉬 히어로

색점 다섯 개의 역거리 메쉬에 가장자리 어둠(VIGNETTE 0.22)과 시드 99 알갱이(GRAIN_AMOUNT 5)를 겹쳐 제목 뒤에 깔고, 제목은 캔버스 위 글자로 따로 얹는다. 60초를 0.1초 간격으로 훑은 제목 자리 배경과 글자색(#17141a)의 최악 대비는 7.55:1 이고, 파스텔 색만 쓰므로 4.5:1 위에 머문다. 측정은 누적면적 67.2%·평균 강도 23.1·23/23 프레임이다.

제목 뒤에 깔리는 메쉬 히어로
새 창에서 열기

09 · 제목 뒤에 깔리는 메쉬 히어로 — 측정 누적면적 67.238% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VIGNETTEGRAIN_AMOUNTPOWERNOISE_N
      function paint(t, d) {
          for (var k = 0; k < 5; k += 1) {
              dotX[k] = (0.5 + 0.42 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
              dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.6));
          }
          var halfP = POWER / 2;
          var frame = Math.floor(t * 30);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 5; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + 0.003);
                      wr += w * HERO_PAL[j][0];
                      wg += w * HERO_PAL[j][1];
                      wb += w * HERO_PAL[j][2];
                      ws += w;
                  }
                  // 가장자리 어둠 — 중심에서 멀수록 곱하는 값이 1 에서 1 - VIGNETTE 로 내려간다
                  var ex = (x / (CALC_W - 1) - 0.5) * 2, ey = (y / (CALC_H - 1) - 0.5) * 2;
                  var vig = 1 - VIGNETTE * smooth((Math.sqrt(ex * ex * 0.6 + ey * ey) - 0.55) / 0.9);
                  var grain = (noiseTab[(y * CALC_W + x + frame * 991) % NOISE_N] - 0.5) * GRAIN_AMOUNT;
                  put(d, x, y, clamp((wr / ws) * vig + grain, 0, 255), clamp((wg / ws) * vig + grain, 0, 255), clamp((wb / ws) * vig + grain, 0, 255));
              }
          }
      }

값이 장면을 바꾸는 곳 — 제어점 수·번짐 지수·알갱이 세기

장면의 인상을 바꾸는 값은 셋이다. 제어점이 얼마나 흔들리는지(02 의 SWAY), 색점 둘레가 얼마나 번지는지(03 의 POWER), 띠를 흩는 알갱이가 얼마나 센지(06 의 GRAIN)다. 같은 순간의 두 화면을 Node 에서 paint 에 상수만 바꿔 대 보면, SWAY 를 0.24 로 올리면 픽셀이 채널당 평균 5.9~6.6, 0.30 이면 8.9~9.8 달라지고, POWER 1 이면 화면 색 편차가 16~19 로 줄고 4 이면 34~38 로 커진다. 마지막 편차는 255 단계 값의 표준편차다.

색이 도는 속도 상수(CORNER_RATE 2·NODE_RATE 1.2·HUE_SPEED 66~174도)는 일부러 빠르게 잡혀 있다. 차분한 배경으로 쓰려면 이 값부터 낮춘다.

장면별 측정값은 아래 표에 모았다. 01·06·08 의 평균 강도는 15.9·16.0·15.6 으로 기준 15 에 가장 가깝고, 색이 이웃 색으로 부드럽게 넘어가는 면이라 프레임 사이 차이가 작다. 아홉 장면 모두 23프레임 전부에서 움직였다.

항목 누적면적 평균 강도 움직인 프레임
01 네 모서리 쌍선형 60.9% 15.9 23/23
02 3×3 제어점 69.1% 16.5 23/23
03 역거리 번짐 70.2% 25.5 23/23
04 노이즈 비틀기 65.6% 18.1 23/23
05 감마 섞기 비교 64.5% 19.2 23/23
06 알갱이 섞기 62.0% 16.0 23/23
07 높이와 광택 71.2% 28.9 23/23
08 색상 각도 순환 62.6% 15.6 23/23
09 제목 뒤 히어로 67.2% 23.1 23/23

어디서 깨지는가 — 해상도 증가·탁한 가운데·어두운 면의 띠

가운데가 탁해지는 경우

01 의 가운데는 네 색의 평균이라 모서리보다 탁하다. 05 가 그 정도를 숫자로 보여 주는데, 같은 두 색의 띠 가운데 밝기가 값 그대로 섞으면 146.4, 밝기를 선형으로 바꿔 섞으면 155.1 이다. 차이는 크지 않아서 한눈에 드러나는 변화를 기대하기는 어렵다. 05 의 보라·주황은 차이가 드러나도록 채도를 높게 잡은 색이라 이 편의 파스텔 팔레트와 다르다. 08 에서는 반대 방향으로 도는 제어점 사이가 보색 근처를 지날 때 RGB 로 섞이며 채도가 빠져 탁해진다.

어두운 면에 띠가 생기는 경우

06 의 STEP 10 은 실제 화면의 한 단계(255 가운데 1)가 아니라 띠가 눈에 보이도록 일부러 거칠게 잡은 값이다. 알갱이를 GRAIN 1 에서 1.5 로 올리면 8×8 상자 평균 오차가 0.32 에서 0.46 으로 도리어 커진다. 문턱이 한 단계 안에서만 흔들리는 구조라 1 을 넘는 알갱이는 그 범위를 벗어나기 때문이다.

읽는 자리가 가장자리 밖으로 나가는 경우

02 의 SWAY 나 04 의 WARP 를 키우면 읽는 자리가 0~1 밖으로 나가 clamp 가 가장자리 색을 그대로 늘려 버린다. 03 은 색점 바로 위에서 그 점 색이 거의 그대로 남고 POWER 가 클수록 뾰족하다. 07 은 밝기만 높이로 읽으므로 색이 달라도 밝기가 같으면 평평하게 보인다.

측정이 움직임을 놓치는 경우

헤드리스 렌더에서 프레임 사이 실제 간격은 약 130밀리초였다. 처음에 색 순환을 초당 0.6 으로 잡았더니 누적면적은 58% 가 나왔는데도 움직인 프레임이 0/23 이었다. 순환을 smooth 로 감쌌을 때도 색 칸 끝에서 멈춰 프레임이 비었기에 pick 을 선형 보간으로 바꾸고 속도를 올렸다.

해상도를 올리면 계산이 늘어난다

CALC_W·CALC_H 를 320×200 으로 키우면 칸 수가 4배가 된다. 03 은 픽셀마다 Math.pow 를 여섯 번 부르므로 부담이 가장 크게 불어난다. 상수를 직접 고쳐 가며 해상도를 비교하려면 아래 압축 파일을 비밀번호 w432zjum 으로 열면 된다.

변형 세 가지와 접근성

이름 값 느낌
제어점 흔들림 폭 키우기 02 SWAY 0.12 → 0.30 흔들림 폭을 0.12 에서 0.30 으로 키우면 같은 순간의 화면과 비교해 픽셀 값이 채널당 평균 8.9~9.8(255 중), 가장 크게는 56~75 달라져 색 경계가 더 크게 출렁이고, 칸 이음 근처에서 휘는 정도가 눈에 들어온다.
번짐 지수 바꾸기 03 POWER 2 → 4 지수를 2 에서 4 로 올리면 화면 전체 색 편차가 28 안팎에서 35 안팎으로 커져 색점 둘레가 또렷한 원에 가까워지고, 1 로 내리면 17 안팎으로 줄어 화면이 한 톤으로 흐려진다.
알갱이 세기 조정 06 GRAIN 1 → 0 알갱이 세기를 1 에서 0 으로 내리면 오른쪽 절반에서 같은 색이 이어지는 가로 길이가 평균 1.6 px 에서 5.6 px 로 늘어나, 알갱이가 없는 왼쪽 절반의 6.6 px 에 가까운 띠가 다시 나타난다.

prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 돌리지 않고, 장면마다 정해 둔 시각(REDUCE_MS, 700~1200밀리초)의 한 장면을 즉시 그린다. 이때 버튼은 「다음 장면」 이 되어 누를 때마다 REDUCE_STEP_MS 900밀리초만큼 시각을 넘겨 즉시 다시 그리고, 상태 줄(aria-live="polite")이 「한 장면씩 넘기는 중」 으로 알린다. 움직임을 켠 상태에서는 버튼이나 캔버스를 누르거나 캔버스에서 Enter·Space 를 눌러 멈춤과 재생을 바꾸고, 상태 줄이 「시연 중」 과 「일시정지됨」 으로 알린다. 캔버스에 tabindex 0 을 주고 멈춤 버튼을 button 요소로 만들어 두었으니 마우스 없이 키보드만으로도 멈추고 다시 돌린다.

메쉬 그라데이션은 CSS 만으로 만들 수 없는가?

비슷하게 흉내 낼 수는 있다. 방사형 그라데이션 몇 장을 겹쳐 위치를 옮기는 식이 대표적인데, 이 글의 계산은 칸마다 가중치를 정하고 색을 읽는 좌표를 비틀고 밝기를 보정하는 일이라 그 도구로는 한 겹 부족하다. css 배경 그라데이션 애니메이션만으로 충분한 장면은 CSS 배경 애니메이션 9종 — 한 줄 복붙으로에 모았고, 겹친 층이 깊이를 만드는 방식은 css 배경 애니메이션 효과 9가지 — 깊이 층 3D 장면에서 볼 수 있다.

쓰인 그리기 함수는 Canvas API 문서와 putImageData 문서에서 읽을 수 있다.

메쉬 그라데이션을 쓰기 전에 자주 묻는 질문

메시 그라데이션 배경을 내 첫 화면에 붙이려면 무엇부터 바꾸나?

09 를 열어 HERO_PAL 의 색점 다섯 개와 VIGNETTE 를 바꾸면 된다. 제목 자리 밑에 어두운 색이 지나가면 대비가 4.5:1 아래로 내려갈 수 있으니, 바꾼 뒤에는 60초쯤 돌려 글자가 읽히는지 본다.

외부 라이브러리나 이미지가 필요한가?

필요 없다. 아홉 장면 모두 캔버스 2d 와 한 파일의 스크립트로만 돌아가고, 외부 주소를 부르는 줄도 없어서 파일 하나만 옮겨도 장면이 그대로 재생된다.

장면이 끊기거나 가운데가 탁해 보이면 어디를 고치나?

끊기면 CALC_W·CALC_H 를 줄여 칸 수를 낮추고, 03 처럼 무거운 장면은 점 수부터 줄인다. 탁해 보이면 05 처럼 밝기를 선형으로 바꿔 섞거나 서로 반대 방향으로 도는 색을 줄인다.

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

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