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

텍스트 애니메이션 css 9종 — 글자를 캔버스 픽셀로 뜯어 다시 짓는 법

텍스트 애니메이션을 css 키프레임으로만 누르면 글자는 늘 통짜로만 움직입니다. 이 아홉 종은 다릅니다 — 글자를 통째로 옮기지 않고 픽셀로 샘플링해 다시 짓는다, 즉 fillText 로 그린 글자를 getImageData 로 되읽어 입자·점·선·원의 재료로 씁니다.

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

데모

순서는 화려함의 순서가 아니라 글자가 재료가 되는 순서입니다. 먼저 흩어진 입자가 글자로 뭉치는 기본형이 생기고(01), 샘플을 점·선·원 같은 다른 재료로 옮겨 적고(02·03·04), 띠·물결·모핑으로 글자 자체가 변형되고(05·06·07), 마지막에 밀도 재해석과 포인터 반응으로 관객과 얽힙니다(08·09). 노이즈로 지형을 계산하거나 레이를 쏘아 3D 를 그리는 게 아니라, 이미 그려진 글자를 다시 읽어 재료로 쓰는 것이 이 편의 축입니다.

아홉 개는 왜 이 순서인가

01 입자 재구성이 샘플링의 원형입니다. 오프스크린 캔버스에 fillText 로 글자를 그려 getImageData 로 알파 마스크를 뽑고, 각 입자에게 자기 목표 픽셀 좌표를 줘 스프링으로 모여들게 합니다. 랜딩 히어로 워드마크 등장에 씁니다. 이 원형 위에서 02 는 점, 03 은 선, 04 는 원으로 재료만 갈아 끼우고, 05~07 은 마스크를 뜯는 단위를 행·좌표·단어로 바꾸고, 08~09 는 읽는 법 자체를 밀도와 힘으로 바꿉니다.

항목별 코드

01입자 재구성 — 흩어진 먼지가 글자로 뭉치기

입자 재구성 — 흩어진 먼지가 글자로 뭉치기
새 창에서 열기

01 · 입자 재구성 — 흩어진 먼지가 글자로 뭉치기 — 측정 누적면적 30.128% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillTextgetImageDataarc

마스크는 어떻게 뽑고 입자는 어떻게 찾아가나

오프스크린 캔버스에 fillText 로 글자를 그려 getImageData 로 알파 마스크를 뽑고, 각 입자에게 자기 목표 픽셀 좌표를 줘 스프링으로 모여들게 하는, 랜딩 히어로 워드마크 등장에 쓰는 이 편 전체의 원형이 되는 판입니다.

// ── 1) 글자 → 알파 마스크 ────────────────────────────────────────
function buildMask(word) {
  var off = document.createElement('canvas');
  off.width = SW; off.height = SH;
  var oc = off.getContext('2d', { willReadFrequently: true });
  oc.textAlign = 'center'; oc.textBaseline = 'middle';
  var fs = 200;
  oc.font = '900 ' + fs + 'px Arial';
  var w = oc.measureText(word).width || 1;
  fs = Math.min(fs * ((SW - 8) / w), SH - 6);       // 폭·높이 안에서 제일 큰 글자
  oc.font = '900 ' + Math.round(fs) + 'px Arial';
  oc.fillStyle = '#fff';
  oc.fillText(word, SW / 2, SH / 2);
  return oc.getImageData(0, 0, SW, SH).data;
}
// ── 2) 마스크 → 목표점 목록 ─────────────────────────────────────
function samplePoints(data) {
  var pts = [];
  for (var y = 0; y < SH; y += STEP) {
    for (var x = 0; x < SW; x += STEP) {
      if (data[(y * SW + x) * 4 + 3] > 128) pts.push({ x: x, y: y });
    }
  }
  return pts;
}

산란과 뭉침을 오가는 스프링

var br = BASE + (1 - BASE) * k;                  // 산란 ↔ 뭉침 사이를 오간다
var cx = SW / 2, cy = SH / 2;
for (var i = 0; i < parts.length; i++) {
  var p = parts[i];
  var tx = cx + (p.tx - cx) * br, ty = cy + (p.ty - cy) * br;
  p.vx = (p.vx + (tx - p.x) * 0.014) * 0.86;
  p.vy = (p.vy + (ty - p.y) * 0.014) * 0.86;
  p.x += p.vx; p.y += p.vy;
}

measureText 로 폭을 재 폭·높이 안에서 제일 큰 글씨로 맞추고 흰색으로 찍으면 알파 채널이 글자의 모양 그대로 남으며 격자 간격 STEP(2px)로 걸어 알파가 128 을 넘는 칸의 좌표만 목표점으로 삼는 것이 모든 판의 기준이 되는 두 걸음입니다. 프레임마다 목표를 판 중심 기준 산란 비율로 밀었다 당겼다 하니 입자는 2초 주기로 먼지에서 글자로, 글자에서 숨쉬는 결로 오갑니다(실측: 누적면적 30.13퍼센트, 강도 평균 167.7, 23/23 프레임 움직임).

02격자 점 하프톤 — 샘플 간격이 만드는 글자

격자 점 하프톤 — 샘플 간격이 만드는 글자
새 창에서 열기

02 · 격자 점 하프톤 — 샘플 간격이 만드는 글자 — 측정 누적면적 36.418% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataarcrequestAnimationFrame

격자점마다 잉크 농도를 점 크기로 옮겨 적는다

알파 마스크를 일정 간격의 격자점에서만 읽어 점의 크기를 값에 비례하게 그리고 간격을 시간에 따라 좁혔다 넓혀 글자가 조밀해졌다 성겨지게 하는, 인쇄·출판 컨셉 브랜드 히어로에 어울리는 하프톤 인쇄판 원리를 그대로 옮긴 판입니다.

// 샘플 간격이 시간에 따라 4.2↔7.8px — 주기 2초. 판이 조밀해졌다 성겨진다
function stepAt(t) { return 6 + 1.8 * Math.sin(t * Math.PI); }
function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH, s = stepAt(t);
  var ox = Math.sin(t * 1.2) * 4.0, oy = Math.cos(t * 0.9) * 3.2;
  var rows = Math.ceil((SH + 8) / s) + 2, colsN = Math.ceil((SW + 8) / s) + 2;
  for (var gy = -2; gy < rows; gy++) {
    var y = gy * s + oy;
    for (var gx = -2; gx < colsN; gx++) {
      var x = gx * s + ((gy & 1) ? s * 0.5 : 0) + ox;  // 한 줄 걸러 반 칸 엇갈림
      var idx = ((gy + 8) * 131 + (gx + 8)) % phas.length; // 격자점 고유 위상
      var jx = x + Math.sin(t * 5.1 + phas[idx]) * 0.9;
      var jy = y + Math.cos(t * 4.7 + phas[idx] * 1.3) * 0.9;
      var xi = Math.round(jx), yi = Math.round(jy);
      if (xi < 0 || yi < 0 || xi >= SW || yi >= SH) continue;
      var a = mask[(yi * SW + xi) * 4 + 3] / 255;  // 이 칸의 잉크 농도
      var r = (0.17 + Math.pow(a, 1.2) * 0.86) * s * 0.52;
      ctx.globalAlpha = 0.30 + a * 0.70;
      ctx.fillStyle = cols[a > 0.66 ? 0 : (a > 0.4 ? 1 : 2)];
      ctx.beginPath(); ctx.arc(jx * sx, jy * sy, r * sx, 0, 6.2832); ctx.fill();
    }
  }
  ctx.globalAlpha = 1;
}

하프톤으로 읽으면 잉크가 남는다

간격이 매끈하게 미끄러지면 점이 격자 위를 떠다니므로 점마다 마이크로 흔들림을 더해 프레임마다 살짝 비껴 그립니다. 글자는 알파를 농도로 읽는 격자점에서만 점이 커지는 곳에서 떠오르고 빈 칸도 희미한 바탕점을 받아 판 전체가 인쇄물처럼 숨쉽니다(실측: 누적면적 36.42퍼센트, 23/23 프레임 움직임).

03선분 메시 — 이웃 샘플을 오가는 실

선분 메시 — 이웃 샘플을 오가는 실
새 창에서 열기

03 · 선분 메시 — 이웃 샘플을 오가는 실 — 측정 누적면적 30.981% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

moveTolineTogetImageData

가까운 이웃쌍만 실로 잇는다

격자 간격으로 뽑은 샘플점들에서 서로 가까운 이웃쌍만 골라 moveTo·lineTo 로 실을 긋고 각 선의 알파를 값에 따라 흔들어 글자가 실루엣으로 떠오르게 하는, 테크·AI 스타트업 히어로 배경에 쓰는 글자가 자료 구조처럼 보이는 판입니다.

function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH, wx = SW * (0.5 + 0.46 * Math.sin(t * 3.9)), wy = SH * (0.5 + 0.44 * Math.cos(t * 3.1));
  ctx.lineCap = 'round';
  for (var i = 0; i < pts.length; i++) {
    var p = pts[i];
    var nbs = [grid[gkey(p.gx + 1, p.gy)], grid[gkey(p.gx, p.gy + 1)],
               grid[gkey(p.gx + 1, p.gy + 1)], grid[gkey(p.gx + 1, p.gy - 1)]];
    for (var k = 0; k < nbs.length; k++) {
      var j = nbs[k];
      if (j === undefined || j <= i) continue;
      var q = pts[j], dx = q.x - p.x, dy = q.y - p.y;
      if (dx * dx + dy * dy > LINK * LINK) continue;
      var mx = (p.x + q.x) * 0.5, my = (p.y + q.y) * 0.5;
      var d = Math.sqrt((mx - wx) * (mx - wx) + (my - wy) * (my - wy));
      var al = p.a * q.a * (0.26 + 0.74 * Math.max(0, Math.cos(d * 0.11 - t * 2.6)));  // 마루가 지날 때만 밝다
      if (al < 0.05) continue;
      ctx.strokeStyle = al > 0.62 ? colA : (al > 0.32 ? colB : colC);
      ctx.globalAlpha = Math.min(1, 0.2 + al);
      ctx.lineWidth = (1.8 + al * 3.4) * sx;
      ctx.beginPath();
      ctx.moveTo(p.x * sx, p.y * sy); ctx.lineTo(q.x * sx, q.y * sy); ctx.stroke();
    }
  }
}

파동 마루가 실루엣을 깨운다

각 점에서 오른쪽·아래·대각선 이웃만 보니 같은 선이 두 번 그려지지 않고 격자 좌표로 조회하니 거리 계산조차 필요 없습니다. 파동의 원점이 판을 크게 돌아 마루가 지나가는 자리의 실만 밝아지고 굵어지니 글자가 한 번에 켜지는 게 아니라 쓸리며 떠오릅니다(실측: 누적면적 30.98퍼센트, 23/23 프레임 움직임).

04원 채우기 — 마스크 안에 최대한 많은 원

원 채우기 — 마스크 안에 최대한 많은 원
새 창에서 열기

04 · 원 채우기 — 마스크 안에 최대한 많은 원 — 측정 누적면적 32.033% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

arcgetImageDatafillText

지름을 줄여가며 겹치면 버린다

마스크 안에 지름을 줄여가며 원을 무작위로 놓아 겹치면 버리는 탐욕 채우기를 돌리고 씨앗 고정 덕에 같은 글자엔 항상 같은 배치가 나오는, 이벤트·발표 슬라이드 타이틀에 어울리는 완성 뒤에도 원들이 맥동하는 판입니다.

function packCircles(data, seed) {
  var rnd = mulberry32(seed), out = [], grid = [];   // 칸 -> 원 인덱스 목록
  function hits(x, y, r) { var cx = Math.floor(x / CELL), cy = Math.floor(y / CELL);
    for (var gy = cy - 1; gy <= cy + 1; gy++) {
      for (var gx = cx - 1; gx <= cx + 1; gx++) {
        if (gx < 0 || gy < 0 || gx >= GW || !grid[gy * GW + gx]) continue;
        var cell = grid[gy * GW + gx];
        for (var k = 0; k < cell.length; k++) {
          var c = out[cell[k]], dx = c.x - x, dy = c.y - y, rr = c.r + r + 0.25;
          if (dx * dx + dy * dy < rr * rr) return true;
        }
      }
    }
    return false;
  }
  for (var d = 0; d < DIAMS.length; d++) { var r = DIAMS[d] / 2;
    for (var i = 0; i < TRIES[d]; i++) {
      var x = rnd() * SW, y = rnd() * SH;
      if (data[((y | 0) * SW + (x | 0)) * 4 + 3] <= 128 || hits(x, y, r)) continue;  // 마스크 밖·겹침
      var gi = (y / CELL | 0) * GW + (x / CELL | 0);
      (grid[gi] || (grid[gi] = [])).push(out.length); out.push({ x: x, y: y, r: r, ph: rnd() * 6.2832, ci: pickColor(rnd()) });
    }
  }
  return out;
}

같은 씨앗은 같은 배치를 보장한다

지름 9.2px 부터 1.4px 까지 여덟 단계로 줄여가며 큰 원부터 채우니 굵은 획은 큰 원이, 가는 획 끝은 작은 원이 차지합니다. 충돌 조회를 12px 칸 격자로 나눠 이웃 아홉 칸만 보니 시도 수천 번에도 가볍고, 시도 순서가 전부 mulberry32(시드) 로 정해지므로 시드 7 을 다시 넣으면 같은 배치가 돌아옵니다(실측: 누적면적 32.03퍼센트, 강도 평균 167.9, 23/23 프레임 움직임).

05글리치 조각 — 가로 띠가 어긋나는 글자

글리치 조각 — 가로 띠가 어긋나는 글자
새 창에서 열기

05 · 글리치 조각 — 가로 띠가 어긋나는 글자 — 측정 누적면적 37.567% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

drawImagegetImageDatarequestAnimationFrame

행 단위로 뜯어 다른 오프셋에 옮겨 붙인다

샘플링한 각 가로 띠(행)를 독립 조각으로 떼어 낸 뒤 drawImage 로 다른 오프셋에 옮겨 붙여 띠마다 다른 타이밍에 튀어 글자가 찢어졌다 붙는, 게임·해킹 컨셉 오류 화면에 쓰는 판입니다.

function offsetAt(row, t) {
  var nz = rowNoise[row];
  var o = Math.sin(t * 2.1 * nz.sp + nz.n1) * 4.6 + Math.sin(t * 3.7 * nz.sp + nz.n2) * 2.2;
  var ph = (t % KICK) / KICK;                        // 킥 — 좁은 창(0.09s)에서 행 절반이 크게 튄다
  if (ph < 0.09 && (row * 2654435761 + seed) % 2 === 0) o += (nz.n1 > 3.14 ? 1 : -1) * 16 * Math.sin((ph / 0.09) * Math.PI);
  return o;
}
function paint(t) {
  ctx.clearRect(0, 0, W, H);
  if (!ink) return;
  var sx = W / SW, sy = H / SH;
  ctx.imageSmoothingEnabled = false;
  for (var row = 0; row < bands; row++) {
    var o = offsetAt(row, t), y = row * BH, aa = Math.abs(o);
    ctx.drawImage(ink, 0, y, SW, BH, o * sx, y * sy, SW * sx, BH * sy + 0.5);
    if (aa > 4.5) {                                  // 큰 어긋남에만 고스트 ±2.2px
      ctx.globalAlpha = Math.min((aa - 4.5) / 8, 1) * 0.6;
      ctx.drawImage(ink, 0, y, SW, BH, (o - 2.2) * sx, y * sy, SW * sx, BH * sy + 0.5);
      ctx.drawImage(ink, 0, y, SW, BH, (o + 2.2) * sx, y * sy, SW * sx, BH * sy + 0.5);
      ctx.globalAlpha = 1;
    }
  }
  ctx.imageSmoothingEnabled = true;
}

어긋남이 커야 고스트를 깐다

마스크를 오프스크린 잉크 판에 다시 그려 띠 단위로 뜯어 쓰니 잉크 판의 해당 행을 drawImage 로 찍을 때 오프셋만 바꾸면 그 행의 픽셀은 한 번도 다시 계산되지 않아 3px 짜리 행 서른여섯 개를 매 프레임 옮겨 붙여도 계산이 쌓이지 않습니다. 어긋남이 4.5px 을 넘는 순간에만 좌우로 2.2px 씩 벌린 고스트를 얹어 찢어지는 순간을 강조합니다(실측: 누적면적 37.57퍼센트, 23/23 프레임 움직임).

06노이즈 왜곡 — 물결에 씻기는 글자

노이즈 왜곡 — 물결에 씻기는 글자
새 창에서 열기

06 · 노이즈 왜곡 — 물결에 씻기는 글자 — 측정 누적면적 30.59% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataarcrequestAnimationFrame

목표점 자체를 물결치게 민다

샘플 좌표를 읽을 때마다 사인파 두 겹의 위상을 섞어 목표점 자체를 물결치게 밀고 입자가 따라가며 글자 전체가 열김처럼 일렁이는, 음악·페스티벌 랜딩 배경에 어울리는 잔상이 일렁임을 키우는 판입니다.

function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH;
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    // 느린 대굴곡 — 행·열마다 위상이 어긋난 큰 사인. 글자 전체가 물결에 씻기듯 휘청인다
    var swx = Math.sin(t * 0.95 + p.oy * 0.028) * 19;
    var swy = Math.cos(t * 0.75 + p.ox * 0.031) * 13;
    // 잔물결 — 목표점 자체를 물결친다. 진폭도 느리게 부풀었다 가라앉는다(파도의 결)
    var amp = AMP * (1 + 0.85 * Math.sin(t * 2.4 + p.ox * 0.02 + p.oy * 0.015));
    var tx = p.ox + swx + Math.sin(p.oy * F1 + t * 2.0) * Math.sin(p.ox * F2 - t * 1.4) * amp;
    var ty = p.oy + swy + Math.cos(p.ox * F2 * 1.15 + t * 1.7) * Math.sin(p.oy * F1 * 0.9 + t * 1.1) * amp * 0.72;
    p.x += (tx - p.x) * LERP;                       // lerp 추적 — 잔상이 일렁임을 키운다
    p.y += (ty - p.y) * LERP;
    ctx.fillStyle = cols[p.ci];
    ctx.beginPath();
    ctx.arc(p.x * sx, p.y * sy, p.sz * sx * 0.85, 0, 6.2832);
    ctx.fill();
  }
}

사인 두 겹의 곱이 물결의 결을 만든다

사인파 하나로 밀면 글자가 그냥 좌우로 흔들리지만, sin(y·F1+t) × sin(x·F2−t·0.7) 처럼 세로·가로 진행을 곱하면 마루와 골이 교차하는 지점마다 밀림의 방향이 바뀌어 물결 무늬가 됩니다. 진폭 자체도 느린 사인으로 부풀었다 가라앉으니 파도가 한결같지 않고, 입자가 목표를 LERP 0.14 로 느리게 쫓아가니 목표가 이미 다음 물결로 가 버린 자리에 잔상이 남습니다(실측: 누적면적 30.59퍼센트에 강도 평균 151.0 — 아홉 판 중 움직임 폭은 가장 좁지만 흔들림은 거센 축입니다).

07모핑 — 단어가 다른 단어로 바뀌기

모핑 — 단어가 다른 단어로 바뀌기
새 창에서 열기

07 · 모핑 — 단어가 다른 단어로 바뀌기 — 측정 누적면적 41.546% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillTextgetImageDataarc

목표 세트를 두 벌 만들어 바꿔치기한다

두 단어를 각각 샘플링해 목표 좌표 세트를 두 벌 만들고 입자 수를 많은 쪽에 맞춰 짝지은 뒤 목표를 바꿔치기해 단어가 흐르듯 바뀌는, 서비스 소개 롤링 카피에 쓰는 아홉 판 중 가장 넓게 움직이는 판입니다.

// ── 3) 두 단어를 샘플링해 입자 수는 많은 쪽에 맞춘다 ─────────────
function resample(nextWord, nextSeed) {
  var w = String(nextWord == null ? wordB : nextWord).trim().toUpperCase();
  if (w) wordB = w;                          // 빈 문자열이면 직접 값을 유지
  var s = Math.floor(Number(nextSeed));
  if (s >= 1) seed = s;
  var setA = samplePoints(buildMask(WORD_A));
  var setB = samplePoints(buildMask(wordB));
  var n = Math.max(setA.length, setB.length);  // 입자 수 = 많은 쪽
  var rnd = mulberry32(seed);
  parts = [];
  for (var i = 0; i < n; i++) {
    var a = setA[i % setA.length], b = setB[i % setB.length];
    var r1 = rnd(), r2 = rnd(), r3 = rnd(), r4 = rnd();
    parts.push({
      x: a.x, y: a.y,                        // 시작 = 첫 단어 모양
      ax: a.x, ay: a.y,                      // 목표 A — HELLO 쪽 좌표
      bx: b.x, by: b.y,                      // 목표 B — 입력 단어 쪽 좌표
      sz: 2.9 + r1 * 2.2, ph: r2 * 6.2832, ci: pickColor(r3),
      bl: (r4 - 0.5) * 26                    // 활꼴 휨(px) — 경로를 활으로 벌린다
    });
  }
}

좌표가 흐르므로 유령 프레임이 없다

// 모핑 믹스 — 사인으로 0..1..0 왕복. 2초 gif 안에 두 바퀴 돈다
var mix = 0.5 - 0.5 * Math.cos(t / CYCLE * 6.2832);
var ease = mix * mix * (3 - 2 * mix);               // smoothstep
for (var i = 0; i < parts.length; i++) {
  var p = parts[i];
  var dx = p.bx - p.ax, dy = p.by - p.ay;
  var tx = p.ax + dx * ease, ty = p.ay + dy * ease;
  var bow = Math.sin(ease * Math.PI);               // 출발·도착 0, 중간 최대
  tx += -dy * 0.35 * bow + p.bl * bow;              // 직교 방향 활 벌림
  ty +=  dx * 0.35 * bow + p.bl * 0.6 * bow;
  tx += Math.sin(t * 0.95 + p.ay * 0.028) * 12;     // 느린 대굴곡(면적의 주된 원천)
  ty += Math.cos(t * 0.75 + p.ax * 0.031) * 9;
  var ob = 0.55 + 0.45 * Math.sin(t * 1.9 + p.ph * 3.1);   // 호흡 궤도 크기
  tx += Math.cos(t * 1.35 + p.ph) * ORBIT * ob;
  ty += Math.sin(t * 1.15 + p.ph * 1.7) * ORBIT * ob * 0.8;
  p.x += (tx - p.x) * LERP;                         // lerp 추적
  p.y += (ty - p.y) * LERP;
}

두 단어의 알파를 겹쳐 크로스페이드하면 중간 순간에 두 글자가 반씩 섞인 유령 프레임이 지나가지만, 좌표 보간은 각 입자가 물리적으로 한 지점에만 있으니 유령이 생기지 않습니다. 입자 수가 적은 쪽 목표는 % 나머지로 재사용해 두 세트를 같은 입자에 짝짓고, 직교 방향 활 벌림이 이동 경로를 벌려 쓸고 지나가는 면적을 크게 만듭니다(실측: 누적면적 41.55퍼센트로 아홉 판 중 최대, 23/23 프레임 움직임).

08ASCII 밀도 — 밝기가 글자를 고르는 판

ASCII 밀도 — 밝기가 글자를 고르는 판
새 창에서 열기

08 · ASCII 밀도 — 밝기가 글자를 고르는 판 — 측정 누적면적 32.283% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillTextgetImageDatarequestAnimationFrame

칸 알파를 평균해 밀도로 읽는다

각 격자 칸의 알파 값을 밀도로 읽어 문자 계단 " .:-=+*#%@" 에서 한 글자를 골라 fillText 로 찍고 계단 기준을 시간에 따라 밀어 채워지는 순서가 바뀌는, 레트로 터미널·개발자 랜딩에 쓰는 판입니다.

// ── 2) 마스크 → 칸 밀도 격자 ────────────────────────────────────
function buildCells(word) {
  var data = buildMask(word);
  var cw = Math.floor(SW / CELL), ch = Math.floor(SH / CELL);
  var rnd = mulberry32(seed);
  var out = [];
  for (var cy = 0; cy < ch; cy++) {
    for (var cx = 0; cx < cw; cx++) {
      var sum = 0, n = 0;
      for (var py = cy * CELL; py < (cy + 1) * CELL; py++) {
        for (var px = cx * CELL; px < (cx + 1) * CELL; px++) {
          sum += data[(py * SW + px) * 4 + 3]; n++;
        }
      }
      var d = sum / (n * 255);                   // 0..1 밀도
      var r1 = rnd(), r2 = rnd();
      out.push({
        cx: cx, cy: cy, d: d,
        ci: d > 0.02 ? (r1 < 0.6 ? 0 : r1 < 0.78 ? 1 : r1 < 0.9 ? 2 : 3) : 0,
        ph: r2 * 6.2832                          // 주사 위상 — 시드가 정한다
      });
    }
  }
  return out;
}

계단을 밀면 채워지는 순서가 달라진다

// 계단 슬라이드 — 밀도→글자 대응이 시간에 따라 밀린다(등장 시퀀스)
var slide = (t / CYCLE) * LADDER.length;
for (var i = 0; i < cells.length; i++) {
  var c = cells[i];
  var bg = c.d <= 0.02;                    // 마스크 밖 칸
  if (bg && slBg <= 0) continue;
  var win = Math.max(0, Math.cos(Math.min(t * 2.6, Math.PI)));  // 등장 창
  var sl = slide * 0.28 * win + Math.sin(t * 5.3 + c.ph) * (LADDER.length + 1.5);
  var lv = (bg ? 0 : c.d) * (LADDER.length - 1) + (bg ? slBg : sl);
  var idx = Math.max(0, Math.min(LADDER.length - 1, Math.round(lv)));
  var ch = LADDER.charAt(idx);
  if (ch === ' ') continue;
  ctx.fillText(ch, (c.cx + 0.5) * CELL * sx, (c.cy + 0.5) * CELL * sy);
}

이진 문턱(안/밖)이 아니라 칸 안 픽셀 알파를 평균 내 0..1 밀도로 읽으니, 굵은 획 중앙은 짙은 #·@, 가장자리에 반쯤 걸친 칸은 옅은 .·: 를 받아 글자에 명암이 생깁니다. 계단의 기준점을 시간으로 밀면 같은 밀도가 다른 글자로 바뀌어 판 전체가 차오르는 결이 살아 움직입니다(실측: 누적면적 32.28퍼센트, 23/23 프레임 움직임).

09재집합 — 포인터가 헤치면 다시 뭉치는 글자

재집합 — 포인터가 헤치면 다시 뭉치는 글자
새 창에서 열기

09 · 재집합 — 포인터가 헤치면 다시 뭉치는 글자 — 측정 누적면적 35.76% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDataarcrequestAnimationFrame

복원 스프링과 밀어내는 힘의 줄다리기

입자마다 목표 픽셀로 돌아가는 복원력을 두고 포인터 반경 안에서는 밀어내는 힘이 이기게 해 손이 지나간 자리가 밀렸다 재집합하는, 인터랙티브 히어로·전시 설치에 쓰는 판입니다.

function step(dt) {
  // 가상 포인터 — 실제 포인터가 없을 때 사인으로 판을 가로질러 흐른다(자동 모드)
  var px, py;
  if (ptr.on) {
    px = ptr.x; py = ptr.y;
  } else {
    var w = tNow * 0.9;
    px = SW / 2 + Math.sin(w) * (SW * 0.36);
    py = SH / 2 + Math.sin(w * 2.3 + 1.2) * (SH * 0.30);
  }
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    var dx = p.x - px, dy = p.y - py;
    var d2 = dx * dx + dy * dy;
    var R = ptr.on ? RADIUS : AUTO_R;                // 자동 모드는 더 넓게 훑는다
    if (d2 < R * R && d2 > 0.01) {
      var d = Math.sqrt(d2), f = (1 - d / R) * PUSH;  // 가까울수록 세게 민다
      p.vx += (dx / d) * f; p.vy += (dy / d) * f;
    }
    p.vx += (p.ox - p.x) * SPRING;                   // 목표 복원
    p.vy += (p.oy - p.y) * SPRING;
    p.vx *= DAMP; p.vy *= DAMP;
    p.x += p.vx; p.y += p.vy;
  }
}

방출기가 아니라 목표점 복원이 핵심

같은 캔버스 입자 장면을 떠올릴 때 불꽃놀이의 방출기와 수명 축을 먼저 생각하게 되는데 이 판에는 둘 다 없고, 입자는 태어나지도 죽지도 않으며 처음 샘플링된 목표 픽셀을 평생의 집으로 기억하다가 포인터가 지나가면 잠시 밀리고 힘이 빠지면 SPRING 0.012 의 복원력으로 제 집으로 돌아옵니다. 포인터가 없을 때는 가상 포인터가 사인으로 판을 가로질러 글자를 스스로 헤칩니다(실측: 누적면적 35.76퍼센트, 강도 평균 176.2, 23/23 프레임 움직임).

텍스트 애니메이션 만들기는 어떻게 글자가 픽셀이 되나? — getImageData 샘플링

아홉 판 전부가 같은 샘플링 절차 하나를 반복해서 씁니다. 어떤 글리프 효과를 만들든 첫걸음은 똑같이 보이지 않는 캔버스에 글자를 그리고 다시 읽는 것입니다. 252×108 짜리 오프스크린 캔버스에 fillText 로 단어를 흰색으로 찍으면 잉크가 앉은 픽셀의 알파값이 255 에 가깝고 빈 곳은 0 이라, 네 번째 바이트만 보면 글자의 모양이 숫자 지도가 됩니다. getImageData 로 이 배열을 들고 나온 뒤가 갈림길입니다. 알파 128 을 넘는 칸의 좌표를 목표점 목록으로 만들면 입자가 될 수 있고(01·06·07·09), 격자점에서 값을 읽으면 점의 크기가 되고(02), 이웃쌍을 잇면 선이 되고(03), 안에 원을 채우면 타이포그래피 자체가 되고(04), 행 단위로 뜯으면 조각이 되고(05), 칸 평균을 내면 밀도가 됩니다(08). 외부 라이브러리도 글꼴 파일 다운로드도 없이 브라우저가 기본으로 갖고 있는 시스템 서체와 캔버스 2D 만으로 글자가 재료가 되는 것입니다. 찍힌 글자를 getImageData 로 되읽어 그 좌표를 입자·점·선·원의 목표로 되쓴다는 점이, 노이즈 함수로 지형을 계산하는 방식이나 레이를 쏘아 3D 장면을 그리는 방식과 다른 이 편의 자리입니다.

어디서 깨지는가 — 함정

초기 버전의 02 하프톤은 점 간격을 사인으로 밀 때 점들이 격자 위를 미끄러지듯 떠다니기만 해 글자가 인쇄판이 아니라 그물처럼 보였습니다. 격자점 좌표가 프레임마다 소수점 단위로 매끄럽게 변하니 점은 제자리에서 커지는 게 아니라 이웃 칸으로 흘러간 것입니다. 마이크로 흔들림을 점마다 시드 위상으로 더해 매 프레임 살짝 비껴 그리니 점은 자리를 지키면서도 살아 움직였습니다. 반대 방향의 함정도 있었습니다. 08 ASCII 밀도에서 칸 알파를 이진 문턱(0 아니면 1)으로 읽자 글자가 오려낸 도장처럼 평면적으로 변했고, 칸 안 픽셀을 전부 더해 평균 내는 밀도 읽기로 바꾸고서야 가장자리에 옅은 계단이 생겼습니다. 또한 fillText 는 글꼴이 로드되지 않은 순간에 fallback 서체로 그려질 수 있어, 첫 판에서 잠깐 fallback 이 뜬 뒤 실제 서체로 다시 샘플링되며 목표점이 순간적으로 수백 개 늘어나 입자가 튕겨 나간 적이 있습니다. 지금은 시스템 서체(Arial)만 쓰고 단어가 바뀔 때마다 목표점을 처음부터 다시 계산해 이 점프를 없앴습니다. 소스는 아홉 판 전부와 React 이식판까지 zip 하나에 있고, 압축을 풀려면 ex82wzp9 을 입력합니다.

변형 세 가지와 접근성

이름 바꾼 값 느낌 한 줄
샘플 간격 02 격자 점 6px → 3px 성기던 점 글자가 조밀해져 인쇄물처럼 보인다
문장 07 모핑 단어 HELLO → WELCOME 같은 입자가 다른 글자로 흘러간다
색 $glyph-sky → $glyph-peach 차가운 청록 입자가 따뜻한 복숭아 톤으로

접근성 처리도 아홉 판이 한 몸입니다. 모션 최소화 설정이 켜진 방문자에게는 rAF 루프를 아예 돌리지 않고 시드가 정한 정적 한 장만 그려 드립니다 — 01 은 입자가 목표에 붙은 완성 글자, 02 는 격자점이 찍힌 하프톤 판, 08 은 밀도 계단이 앉은 터미널 판입니다. 이 정적 프레임에서도 단어·시드 입력은 살아 있어 값을 바꾸면 즉시 다른 한 장으로 다시 그려지므로, 텍스트 애니메이션 효과가 꺼져도 글자라는 결과는 남습니다.

FAQ

이 데모는 입력창 문장을 실시간으로 다시 샘플링하나요?

합니다. 아홉 판 전부 입력창의 단어와 시드에 input 이벤트를 걸어 두었고, 글자가 바뀌면 buildMask 부터 다시 돌아 목표점을 전부 재계산합니다. 실측에서 01 은 기본 단어 GLYPH 의 목표점 1631개가 긴 단어에서 856개로, 07 모핑은 WAVE 2063개가 1110개로 순간적으로 다시 짜졌고, 같은 시드를 두 번 넣으면 같은 해시가 나오고 다른 시드(7 ↔ 91)를 넣으면 어긋나는 것까지 확인했습니다. 원 채우기와 모핑에서 특히 시드가 배치를 크게 바꾸니 두 값을 번갈아 넣어 보는 맛이 있습니다.

css 텍스트 애니메이션 효과 대신 캔버스를 쓰는 기준이 있나요?

글자가 통짜로 움직여도 되는 순간 — 이동·페이드·크기 변화 — 에는 css 키프레임이나 트랜지션이 여전히 가볍고 정확합니다. 캔버스가 필요한 때는 글자를 재료로 바꾸는 순간입니다. 입자가 픽셀에서 흩어지거나 하프톤 점이 조밀해지거나 단어 사이를 흐르는 텍스트 애니메이션 효과는 css 가 보지 못하는 글자 내부의 픽셀 지도가 필요하므로, 텍스트 애니메이션 사이트에서 이런 장면을 구현하는 경우 캔버스 샘플링이 정답에 가깝습니다.

소스 코드를 그대로 복사해도 되나요?

됩니다. 아홉 판의 바닐라 코드와 React 이식판이 각각 통째로 zip 안에 있어 소스를 그대로 붙여 넣으면 데모와 같은 화면이 뜹니다. 판마다 scss 맨 위에 놓인 파스텔 토큰 변수만 바꾸면 색이 갈리고, 단어와 시드는 데모 입력창 자리에 있는 소스 상단 기본값을 고쳐도 똑같이 바뀝니다.

더 읽을거리

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

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