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

이미지 픽셀 아트 변환 9가지 — 캔버스로 도트 만들기

이미지 픽셀 아트 변환은 사진의 색을 계산으로 다시 나눠 도트 그림으로 바꾸는 작업이다. 확대한 사진이 왜 색색의 네모로 바뀔까? 01번은 블록 값 하나로 그 세기를 정하고, 05번은 포인터 옆에서 그 블록을 더 키우며, 09번은 슬라이더 하나로 원본과 도트를 가른다.

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

아홉 개를 셋으로 나눠 보면 흐름이 보인다 — 01·06은 칸 크기 값이 도트의 굵기를 정하고, 02·03·04·07·08은 같은 칸 안에서 색을 어떻게 결정할지를 값 하나가 가르며, 05·09는 화면 위 위치(포인터 좌표·슬라이더 퍼센트)가 어느 버퍼를 보여줄지를 가른다. 아래 코드는 모두 run/415/demo 원본에서 그대로 옮겼고, 입력 이미지는 실제 사진이 아니라 seedRand 함수로 매번 같은 규칙으로 그려낸 도형 장면이다 — drawImage로 캔버스에 그린 뒤라면 자기 사진을 그대로 물려도 아래 아홉 계산이 똑같이 돈다.

01블록 크기 값 하나로 만드는 다운샘플

사진이 작은 정사각형 격자로 뭉개져 모자이크처럼 보인다. PIXEL_BLOCK_SIZE_PX 값만큼 칸을 나눠 칸 안 픽셀의 r·g·b 합을 센 뒤 평균색 하나로 칸 전체를 다시 칠하는 게 blockAverage다.

블록 평균 다운샘플
새 창에서 열기

01 · 블록 평균 다운샘플 — 측정 누적면적 39.534% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PIXEL_BLOCK_SIZE_PXblockAveragegetImageData
  function blockAverage(buf, w, h, blockPx, outBuf) {
    for (var by = 0; by < h; by += blockPx) {
      var bh = Math.min(blockPx, h - by);
      for (var bx = 0; bx < w; bx += blockPx) {
        var bw = Math.min(blockPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += buf[idx]; gs += buf[idx + 1]; bs += buf[idx + 2]; count += 1;
          }
        }
        var r = Math.round(rs / count), g = Math.round(gs / count), b = Math.round(bs / count);
        for (var y2 = 0; y2 < bh; y2 += 1) {
          for (var x2 = 0; x2 < bw; x2 += 1) {
            var idx2 = ((by + y2) * w + (bx + x2)) * 4;
            outBuf[idx2] = r; outBuf[idx2 + 1] = g; outBuf[idx2 + 2] = b; outBuf[idx2 + 3] = 255;
          }
        }
      }
    }
  }

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

02팔레트 색 수를 조절하는 색상 양자화

색이 정해진 개수만 남고 나머지 색은 전부 사라진다. PALETTE_COLOR_COUNT 값만큼 팔레트를 준비해 nearestPaletteColor가 픽셀 색과 각 팔레트 색 사이 거리제곱을 비교해 가장 가까운 색으로 quantizePalette가 버퍼 전체를 다시 쓴다.

팔레트 색상 양자화
새 창에서 열기

02 · 팔레트 색상 양자화 — 측정 누적면적 59.009% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PALETTE_COLOR_COUNTnearestPaletteColorquantizePalette
  function nearestPaletteColor(r, g, b, count) {
    var best = 0, bd = Infinity;
    for (var i = 0; i < count; i += 1) {
      var p = PALETTE_RGB[i];
      var dr = r - p[0], dg = g - p[1], db = b - p[2];
      var d = dr * dr + dg * dg + db * db;
      if (d < bd) { bd = d; best = i; }
    }
    return PALETTE_RGB[best];
  }

  function quantizePalette(buf, w, h, count, outBuf) {
    for (var i = 0; i < w * h; i += 1) {
      var idx = i * 4;
      var c = nearestPaletteColor(buf[idx], buf[idx + 1], buf[idx + 2], count);
      outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
    }

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

03오차를 이웃 화소로 퍼뜨리는 디더링

매끈한 그러데이션 대신 작은 점들이 흩뿌려진 판화처럼 보인다. DITHER_LEVELS 값으로 픽셀을 반올림하고 남는 오차를 diffuseError가 옆 칸·아랫칸에 7/16·3/16·5/16·1/16 비율로 나눠 퍼뜨려 점 배치만으로 명도 단계를 흉내낸다.

오차 확산 디더링
새 창에서 열기

03 · 오차 확산 디더링 — 측정 누적면적 75.294% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DITHER_LEVELSdiffuseErrorputImageData
  function diffuseError(buf, w, h, levels, outBuf) {
    var work = new Float32Array(w * h * 3);
    for (var i = 0, j = 0; i < w * h; i += 1, j += 3) {
      work[j] = buf[i * 4]; work[j + 1] = buf[i * 4 + 1]; work[j + 2] = buf[i * 4 + 2];
    }
    var step = 255 / (levels - 1);
    var rowStride = w * 3;
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 3;
        for (var c = 0; c < 3; c += 1) {
          var old = work[idx + c];
          var q = Math.max(0, Math.min(255, Math.round(Math.round(old / step) * step)));
          var err = old - q;
          work[idx + c] = q;
          if (x + 1 < w) work[idx + 3 + c] += err * 7 / 16;
          if (y + 1 < h) {
            if (x > 0) work[idx + rowStride - 3 + c] += err * 3 / 16;
            work[idx + rowStride + c] += err * 5 / 16;
            if (x + 1 < w) work[idx + rowStride + 3 + c] += err * 1 / 16;
          }
        }
      }
    }

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

04명도 구간을 나누는 포스터라이즈

그림자가 매끈하지 않고 몇 개의 색 층으로 뚝뚝 끊겨 보인다. POSTERIZE_BAND_COUNT 값으로 나눈 간격만큼 quantizeBand가 채널 값을 반올림해 같은 구간에 있는 색을 전부 한 색으로 뭉갠다.

명도 밴드 포스터라이즈
새 창에서 열기

04 · 명도 밴드 포스터라이즈 — 측정 누적면적 70.713% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

POSTERIZE_BAND_COUNTquantizeBandgetImageData
  function quantizeBand(buf, w, h, bands, outBuf) {
    var step = 255 / (bands - 1);
    for (var i = 0; i < w * h; i += 1) {
      var idx = i * 4;
      for (var c = 0; c < 3; c += 1) {
        var v = buf[idx + c];
        outBuf[idx + c] = Math.max(0, Math.min(255, Math.round(Math.round(v / step) * step)));
      }
      outBuf[idx + 3] = 255;
    }
  }

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

05포인터 반경 안쪽만 커지는 도트 렌즈

포인터를 올린 자리 근처만 도트가 크게 보이고 나머지는 원래 칸 크기 그대로다. PIXEL_LENS_RADIUS_PX 값을 반지름 삼아 lensCompose가 픽셀과 포인터 좌표 사이 거리제곱을 반경제곱과 비교해, 안쪽이면 큰 블록 버퍼를 바깥이면 작은 블록 버퍼를 그대로 옮겨 채운다.

포인터 반경 도트 렌즈
새 창에서 열기

05 · 포인터 반경 도트 렌즈 — 측정 누적면적 41.126% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PIXEL_LENS_RADIUS_PXlensComposeaddEventListener
  function lensCompose(bigBuf, smallBuf, w, h, px, py, radius, outBuf) {
    var r2 = radius * radius;
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 4;
        var dx = x - px, dy = y - py;
        var src = (dx * dx + dy * dy) < r2 ? bigBuf : smallBuf;
        outBuf[idx] = src[idx]; outBuf[idx + 1] = src[idx + 1]; outBuf[idx + 2] = src[idx + 2]; outBuf[idx + 3] = 255;
      }
    }
  }

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

06블록 크기가 단계로 줄어드는 도트 리빌

큰 덩어리로 시작한 사진이 몇 단계를 거치며 점점 선명해진다. REVEAL_STEPS 배열의 블록 크기를 stepIndexAt이 경과 시간을 STEP_DURATION_MS로 나눠 몇 번째 단계인지 고르고, 그 크기로 blockAverage를 다시 호출한다.

단계별 도트 리빌 전환
새 창에서 열기

06 · 단계별 도트 리빌 전환 — 측정 누적면적 56.008% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REVEAL_STEPSblockAveragerequestAnimationFrame
  var replayStart = 0;

  function stepIndexAt(sinceStart) {
    var cycleMs = REVEAL_STEPS.length * STEP_DURATION_MS;
    var pos = sinceStart % cycleMs;
    return Math.min(REVEAL_STEPS.length - 1, Math.floor(pos / STEP_DURATION_MS));
  }

  function renderFrame(t) {
    var sinceStart = t - replayStart;
    if (sinceStart < 0) sinceStart += LOOP_MS;
    var idx = stepIndexAt(sinceStart);
    blockAverage(baseBuf, CALC_W, CALC_H, REVEAL_STEPS[idx], outBuf);

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

07고정 팔레트 두 벌을 오가는 레트로 락

색이 8색 또는 16색짜리 고정된 팔레트로만 보여 옛 화면 같은 인상을 준다. RETRO_PALETTE_8·RETRO_PALETTE_16 배열 중 하나를 골라 lockRetroPalette가 칸 평균색을 nearestFromSet으로 그 팔레트 안 가장 가까운 색에 묶는다.

레트로 콘솔 팔레트 고정
새 창에서 열기

07 · 레트로 콘솔 팔레트 고정 — 측정 누적면적 33.972% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RETRO_PALETTE_8RETRO_PALETTE_16lockRetroPalette
  function lockRetroPalette(buf, w, h, blockPx, set, outBuf) {
    for (var by = 0; by < h; by += blockPx) {
      var bh = Math.min(blockPx, h - by);
      for (var bx = 0; bx < w; bx += blockPx) {
        var bw = Math.min(blockPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += buf[idx]; gs += buf[idx + 1]; bs += buf[idx + 2]; count += 1;
          }
        }
        var c = nearestFromSet(rs / count, gs / count, bs / count, set);
        for (var y2 = 0; y2 < bh; y2 += 1) {
          for (var x2 = 0; x2 < bw; x2 += 1) {
            var idx2 = ((by + y2) * w + (bx + x2)) * 4;
            outBuf[idx2] = c[0]; outBuf[idx2 + 1] = c[1]; outBuf[idx2 + 2] = c[2]; outBuf[idx2 + 3] = 255;
          }
        }
      }
    }

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

08밝기 값을 문자로 바꾸는 아스키 맵

사진이 사라지고 그 자리에 문자들이 사진 형태를 따라 배열된다. BRIGHTNESS_RAMP 문자열의 길이에 칸 평균 밝기(0~1)를 곱한 인덱스로 brightnessToChar가 문자 하나를 고르고 그 칸의 평균색 그대로 문자를 그린다.

밝기 아스키 문자 매핑
새 창에서 열기

08 · 밝기 아스키 문자 매핑 — 측정 누적면적 59.368% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BRIGHTNESS_RAMPbrightnessToCharfillText
  function brightnessToChar(baseBuf, w, h, cellPx, ramp, outCtx) {
    outCtx.fillStyle = '#0b0b10';
    outCtx.fillRect(0, 0, w, h);
    outCtx.font = Math.max(5, cellPx) + 'px monospace';
    outCtx.textBaseline = 'top';
    for (var by = 0; by < h; by += cellPx) {
      var bh = Math.min(cellPx, h - by);
      for (var bx = 0; bx < w; bx += cellPx) {
        var bw = Math.min(cellPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += baseBuf[idx]; gs += baseBuf[idx + 1]; bs += baseBuf[idx + 2]; count += 1;
          }
        }
        var r = rs / count, g = gs / count, b = bs / count;
        var lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
        var ci = Math.min(ramp.length - 1, Math.floor(lum * ramp.length));
        var ch = ramp.charAt(ci);
        outCtx.fillStyle = 'rgb(' + Math.round(r) + ',' + Math.round(g) + ',' + Math.round(b) + ')';
        outCtx.fillText(ch, bx, by);
      }
    }

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

09슬라이더로 가르는 원본과 도트 변환 비교

슬라이더를 기준으로 화면 한쪽엔 도트 결과가, 반대쪽엔 원본이 나란히 보인다. COMPARE_SLIDER_PERCENT 값으로 splitCompare가 화면 너비 기준 경계 좌표를 구해 그보다 왼쪽 픽셀은 변환 버퍼를, 오른쪽은 원본 버퍼를 그대로 채운다.

전후 비교 히어로 장면
새 창에서 열기

09 · 전후 비교 히어로 장면 — 측정 누적면적 10.295% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COMPARE_SLIDER_PERCENTsplitCompareaddEventListener
  function splitCompare(beforeBuf, afterBuf, w, h, percent, outBuf) {
    var threshold = Math.round((percent / 100) * w);
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 4;
        var src = x < threshold ? afterBuf : beforeBuf;
        outBuf[idx] = src[idx]; outBuf[idx + 1] = src[idx + 1]; outBuf[idx + 2] = src[idx + 2]; outBuf[idx + 3] = 255;
      }
    }

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

이 아홉 계산을 실제로 어디에 붙이나

01 블록 평균은 프로필 사진을 작은 도트 아이콘으로 줄일 때, 02 팔레트 양자화는 8비트 게임풍 배너를 만들 때, 03 오차 확산은 흑백 사진을 판화 느낌의 도트로 바꿀 때 쓴다. 04 명도 밴드는 일러스트 카드의 그림자 단계를 나눌 때, 07 팔레트 고정은 레트로 이벤트 페이지 히어로에, 08 문자 매핑은 소개 페이지 배경의 텍스트 아트에 값 하나로 얹힌다. 05 포인터 렌즈는 제품 사진 위 돋보기 인터랙션에, 06 단계별 리빌은 갤러리 이미지가 로딩되며 서서히 드러나는 연출에, 09 비교 슬라이더는 랜딩 페이지 히어로에서 원본과 도트 결과를 한 화면에 가른다. 사진 픽셀 아트 변환을 스마트폰으로 찍은 원본에 그대로 걸어도 같은 아홉 계산이 돌아가고, 일러스트 픽셀 아트 변환에는 02·07처럼 색을 미리 정한 팔레트로 묶는 쪽이 선을 살리기에 더 잘 맞는다.

측정.json 이 잡아낸 아홉 계산의 실제 변화량

24장을 캡처해 프레임 사이 픽셀 변화를 잰 값은 다음과 같다 — 오차 확산(03)이 누적면적 75.3%로 가장 넓게 바뀌고, 비교 슬라이더(09)는 화면 오른쪽 절반이 정지 원본이라 10.3%로 가장 좁다.

NN 이름 누적면적 % 강도 평균 x/23
01 다운샘플 39.5 42.5 15/23
02 색상 양자화 59.0 163.0 17/23
03 디더링 75.3 88.6 18/23
04 포스터라이즈 70.7 63.8 18/23
05 도트 렌즈 41.1 47.2 23/23
06 도트 리빌 56.0 45.6 15/23
07 레트로 락 34.0 91.1 16/23
08 아스키 맵 59.4 87.1 19/23
09 전후 비교 10.3 54.3 17/23

동작 줄임 설정을 켜도 계산이 멈추지 않는다 — 깨지는 지점

prefers-reduced-motion: reduce가 참이면 아홉 항목 모두 경과 시간 값을 한 지점에 고정해 화면은 멈춘 것처럼 보이지만, tick 함수의 requestAnimationFrame 호출 자체는 계속 돌아 매 프레임 160×100 픽셀 버퍼를 처음부터 다시 계산한다 — 그림은 그대로인데 계산만 계속 도는 셈이다. 지금 오프스크린 캔버스는 160×100(16,000픽셀)이라 이 정도는 가볍지만, 스마트폰으로 찍은 사진(예: 1200×800, 960,000픽셀)을 그대로 물리면 픽셀 수가 60배로 뛰어 03 오차 확산처럼 픽셀마다 이웃 값을 갱신하는 계산은 저사양 기기에서 프레임이 뚝뚝 끊긴다. 소스에서 이 오프스크린 크기(CALC_W·CALC_H)를 직접 사진 해상도로 올려보고 싶다면 895nmedw으로 zip을 열면 아홉 개의 index.html·style.scss 원본이 그대로 들어 있다.

이미지 픽셀 아트 변환은 이미지 픽셀 아트 변환 사이트와 뭐가 다른가?

검색하면 나오는 이미지 픽셀 아트 변환 사이트 대부분은 사진을 서버에 올리면 미리 정해둔 필터 중 하나를 골라 결과 파일만 돌려준다 — 값이 어떻게 계산되는지는 화면에 나오지 않는다. 여기 아홉 개는 서버 업로드 없이 브라우저 <canvas> 안에서 getImageData로 픽셀을 직접 읽고 PIXEL_BLOCK_SIZE_PX·PALETTE_COLOR_COUNT처럼 그 결과를 가르는 값 하나를 코드로 보여준다. 같은 픽셀 버퍼 계산을 색 채널 쪽에 쓴 결과는 css 이미지 필터 효과 9가지에서, 픽셀 대신 입자 좌표를 계산한 결과는 캔버스 파티클 엔진 9종에서, 사진을 자르는 계산은 이미지 크롭 에디터 UI 9종에서 각각 확인할 수 있다.

값 하나만 바꿔보는 변형 세 가지

같은 함수라도 상수 하나만 바꾸면 결과가 크게 달라진다.

이름 값 느낌
픽셀 블록 크기 값 조정 01 PIXEL_BLOCK_SIZE_PX 4 → 24 값을 4에서 24로 올리면 픽셀 블록이 커지면서 사진 속 형태가 점점 사각형 덩어리로 단순해진다.
포인터 렌즈 반경 값 조정 05 PIXEL_LENS_RADIUS_PX 40 → 160 40에서 160으로 넓히면 포인터가 지나가는 자리마다 확대된 도트가 차지하는 면적이 커진다.
비교 슬라이더 값 조정 09 COMPARE_SLIDER_PERCENT 20 → 80 20에서 80으로 옮기면 화면에서 원본이 차지하던 자리까지 도트 변환 결과가 밀고 들어온다.

동작 줄임과 키보드에서 아홉 계산이 남기는 접근성

prefers-reduced-motion이 걸리면 09개 항목 모두 시간에 따라 값이 바뀌는 애니메이션을 멈춘 프레임 하나로 고정한다. 01·02·04·09는 range 입력이나 버튼으로 값을 조절할 수 있어 키보드 화살표만으로도 픽셀 블록 크기·팔레트 색 수·밴드 단계·비교 위치를 바꿀 수 있고, 03·07·08은 애초에 조작 없이 자동으로만 돌아 키보드가 필요 없다. 다만 05 포인터 렌즈는 지금 마우스·터치 좌표에만 반응하고 06 단계 리빌은 포인터다운(클릭·탭) 한 번에만 반응해, 둘 다 키보드로 같은 결과를 낼 방법이 없다 — 포인터가 없는 환경에서는 렌즈가 커지는 자리를 확인할 수 없고 리빌을 다시 돌릴 수도 없는 한계를 그대로 남겨둔다.

getImageData 계산을 직접 만지기 전에 자주 걸리는 질문

픽셀 아트 변환 프로그램 없이 브라우저에서 바로 값을 만져볼 수 있나요?

네, 아홉 개 전부 서버로 사진을 보내는 방식이 아니라 브라우저 <canvas> 안에서 getImageData로 픽셀을 읽고 다시 그리는 계산만으로 끝난다. 위에서 연 zip의 index.html을 로컬에 그대로 두고 안쪽 상수 값만 바꾸면 결과가 바로 갱신된다.

160×100 계산을 실제 사진 해상도에 그대로 써도 느려지지 않나요?

지금은 오프스크린 캔버스가 160×100(16,000픽셀)이라 아홉 함수가 매 프레임 돌아도 가볍다. 사진을 실제 해상도(예: 1200×800)로 올리면 픽셀 수가 60배로 뛰어, 특히 03처럼 이웃 픽셀 값을 갱신하는 계산은 저사양 기기에서 버벅일 수 있어 오프스크린 크기를 먼저 낮춰 확인하는 편이 안전하다.

07의 팔레트는 특정 게임기 색을 그대로 옮긴 건가요?

아니다. RETRO_PALETTE_8·RETRO_PALETTE_16은 이 데모를 위해 직접 정의한 hex 값 배열일 뿐 어떤 콘솔이나 게임기 이름도 코드에 없다. 색 개수만 8·16으로 제한해 옛 화면 인상을 낸 것이라 원하는 팔레트로 자유롭게 바꿔도 된다.

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

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