다음에 올 것을 만들어가는 기술에 대한 심층 기사.

디더링의 기술: 1비트로 그리는 아름다운 이미지

흑백 픽셀만으로 명암을 표현하는 디더링 알고리즘을 Floyd-Steinberg부터 블루 노이즈까지 실제 구현과 함께 소개합니다.

흑백 디더링 점들이 모여 명암을 이루는 선장 초상화

루카스 포프(Lucas Pope)의 Return of the Obra Dinn은 역대 가장 시각적으로 인상적인 게임 중 하나인데, 사용하는 색은 딱 두 가지, 검정과 흰색뿐입니다. 픽셀당 1비트죠. 회색도, 그러데이션도, 안티앨리어싱도 없습니다. 모든 픽셀이 켜지거나 꺼질 뿐입니다. 그런데도 이 게임은 알아볼 수 있는 캐릭터, 원근감, 분위기 있는 조명을 갖춘 완전한 3D 세계를 그려냅니다. 전부 영리한 디더링 덕분이죠.

디더링은 흑백 픽셀을 특정 패턴으로 배치해서 눈이 중간 명암을 보게 속이는 기법입니다. 컴퓨터 그래픽의 초창기부터 있었던 기술인데, 요즘 게임 개발, 웹 디자인, 그리고 점점 중요해지는 e-ink 화면에 이미지를 띄우는 문제에서 다시 주목받고 있습니다. 그 뒤에 있는 알고리즘은 우아하고, 생각보다 종류도 다양하며, 쓸 데도 많습니다.

디더링이 통하는 이유

우리 눈은 개별 픽셀을 따로 처리하지 않고 영역 단위로 통합해서 인식합니다. 검정과 흰 픽셀이 번갈아 있는 패턴을 보면 점 하나하나가 보이지 않고 회색으로 보입니다. 흰 픽셀이 25%인 영역은 25% 회색처럼 보이고, 75%인 영역은 75% 회색처럼 보입니다. 즉 영역 안 흰 픽셀의 밀도가 체감 밝기를 결정하는 것입니다.

이건 신문의 하프톤 인쇄와 같은 원리입니다. 크기와 간격이 다른 점들이 순수한 검정 잉크를 흰 종이에 찍었는데도 연속적인 톤처럼 보이게 만들죠. 디더링은 점의 크기 대신 픽셀 패턴을 쓰는 디지털 버전이라고 보면 됩니다.

어려운 부분은 회색조를 만드는 것 자체가 아닙니다. 그건 간단하죠. 어려운 건 눈에 띄는 부작용 없이 만드는 겁니다. 패턴 사이의 급격한 전환(밴딩), 선처럼 보이는 방향성 편향, 디더링 패턴과 디스플레이 격자가 간섭해서 생기는 모아레 현상, 이런 것들을 피해야 합니다.

임계값 디더링: 단순한 접근

가장 단순한 디더링은 각 픽셀의 밝기를 임계값(보통 50%)과 비교하는 것입니다. 임계값보다 밝으면 흰색, 어두우면 검정색이 됩니다. 결과는 계조가 전혀 없는 고대비 실루엣입니다. 텍스트나 선화에는 쓸 만하지만, 사진이나 3D 렌더링에는 형편없습니다.

import numpy as np
from PIL import Image
def threshold_dither(image, threshold=128):
"""Binary threshold — no dithering at all, really."""
gray = np.array(image.convert('L'), dtype=float)
return Image.fromarray((gray > threshold).astype(np.uint8) * 255)

순서 디더링: 격자 위의 패턴

순서 디더링(ordered dithering)은 반복되는 타일 패턴에 따라 임계값을 달리하는 임계값 행렬(베이어 행렬이라고도 합니다)을 사용합니다. 모든 픽셀을 같은 임계값과 비교하는 대신, 각 픽셀의 위치에 따라 행렬에서 다른 값을 가져와 비교합니다.

def ordered_dither(image, matrix_size=4):
"""Ordered dithering with Bayer matrix."""
gray = np.array(image.convert('L'), dtype=float)
h, w = gray.shape
# 4×4 Bayer matrix (values 0-15, normalized to 0-255)
bayer_4x4 = np.array([
[ 0,  8,  2, 10],
[12,  4, 14,  6],
[ 3, 11,  1,  9],
[15,  7, 13,  5]
]) * (255 / 16)
# Tile the matrix across the image
threshold = np.tile(bayer_4x4, (h // 4 + 1, w // 4 + 1))[:h, :w]
return Image.fromarray((gray > threshold).astype(np.uint8) * 255)

순서 디더링은 한눈에 알아볼 수 있는 독특한 크로스해치 무늬를 만듭니다. 해상도가 낮으면 규칙적인 격자 구조가 그대로 보여서 기계적이고 레트로한 느낌이 나죠. 많은 맥락에서 이건 사실 미적인 선택입니다. 8×8 베이어 행렬의 질서 정연한 패턴은 독특한 느낌이 있어서, 많은 게임과 아티스트가 일부러 씁니다.

베이어 행렬은 임계값 단계마다 새 픽셀이 기존 픽셀과 최대한 멀리 떨어진 위치에 추가되도록 신중하게 만들어져 있습니다. 덕분에 뭉침이 최소화되고, 모든 밝기 단계에서 점이 가장 고르게 분포합니다. 또 매우 빠릅니다. 픽셀당 비교 한 번이면 되고 이웃 픽셀에 의존하지 않아서 실시간 렌더링에 잘 맞습니다.

오차 확산: Floyd-Steinberg와 그 친구들

오차 확산(error diffusion) 디더링은 완전히 다른 접근을 씁니다. 고정된 임계값 행렬 대신 픽셀을 순서대로 처리하면서, 원래 픽셀 값과 양자화된(검정 또는 흰색) 결과 사이의 차이, 즉 '오차'를 아직 처리되지 않은 이웃 픽셀에 나눠 줍니다.

def floyd_steinberg_dither(image):
"""Floyd-Steinberg error diffusion dithering."""
gray = np.array(image.convert('L'), dtype=float)
h, w = gray.shape
for y in range(h):
for x in range(w):
old_pixel = gray[y, x]
new_pixel = 255.0 if old_pixel > 128 else 0.0
gray[y, x] = new_pixel
error = old_pixel - new_pixel
# Distribute error to neighbors
# Floyd-Steinberg diffusion kernel:
#        * 7/16
#  3/16 5/16 1/16
if x + 1 < w:
gray[y, x + 1] += error * 7 / 16
if y + 1 < h:
if x - 1 >= 0:
gray[y + 1, x - 1] += error * 3 / 16
gray[y + 1, x] += error * 5 / 16
if x + 1 < w:
gray[y + 1, x + 1] += error * 1 / 16
return Image.fromarray(gray.astype(np.uint8))

Floyd-Steinberg 디더링은 순서 디더링보다 훨씬 자연스러운 결과를 냅니다. 픽셀 분포에 눈에 띄는 격자 무늬가 없고, 부드러운 그러데이션도 아주 잘 처리합니다. 점들이 유기적으로 뭉쳐서 사진 하프톤 인쇄에 더 가까운 느낌이 나죠.

단점은 순차적이라는 겁니다. 각 픽셀이 앞서 처리된 픽셀들의 오차에 의존하기 때문에 병렬화가 쉽지 않습니다. 그래서 순서 디더링보다 느리고, 셰이더 기반 렌더링 파이프라인에 넣기도 까다롭습니다. 미리 처리하는 이미지라면 상관없지만, 60fps로 돌아가는 실시간 3D 렌더링에서는 문제가 됩니다.

블루 노이즈 디더링: 두 장점의 결합

블루 노이즈 디더링은 여러 분야에서 현재 최고 수준의 기법입니다. 순서 디더링처럼 임계값 행렬을 쓰기 때문에 빠르고 병렬화도 됩니다. 다만 행렬 값을 블루 노이즈 분포가 나오도록 배치합니다. 점들이 무작위로 찍히지만 최소 간격 제약이 있어서, 화이트 노이즈의 뭉침과 순서 디더링의 격자 무늬를 둘 다 피합니다.

'블루 노이즈'라는 이름은 신호 처리에서 왔습니다. 블루 노이즈는 에너지의 대부분이 고주파에 있습니다. 파란 빛처럼요. 공간적으로 말하면, 패턴에 큰 규모의 구조(보이는 격자나 방향성 편향)가 없지만, 국소적으로는 점들이 고르게 떨어져 있다(뭉침도 빈틈도 없다)는 뜻입니다. 서로 어느 정도 최소 거리 이상 떨어져야 한다는 조건을 걸고 판에 다트를 던졌을 때 나오는 패턴과 같습니다.

좋은 블루 노이즈 임계값 행렬을 만드는 건 생각보다 어렵습니다. 주파수 스펙트럼이 원하는 프로파일과 맞을 때까지 반복하는 최적화 문제거든요. 하지만 일단 행렬을 만들어 두면 쓰는 건 베이어 디더링만큼 빠릅니다. 픽셀당 비교 한 번이면 됩니다.

Obra Dinn의 접근: 구면 매핑 디더링

Return of the Obra Dinn의 디더링은 3D 렌더링에만 있는 문제, 즉 시간적 안정성을 해결한다는 점에서 특히 영리합니다. 표준 디더링 패턴을 화면 공간에 적용하면 카메라를 움직일 때 패턴이 '흔들립니다'. 패턴은 화면에 고정된 채로 3D 지오메트리만 그 아래에서 움직이기 때문이죠. 이 때문에 아주 거슬리는 번쩍임이 생깁니다.

포프의 해법은 디더링 패턴을 화면 공간이 아니라 3D 공간에 매핑하는 것입니다. 디더링 패턴을 카메라를 감싸는 구에 투영한 뒤, 각 픽셀의 시선 방향을 기준으로 샘플링합니다. 카메라가 회전하면 패턴도 함께 회전하고, 객체가 월드 공간에서 움직여도 패턴은 화면 위치가 아니라 월드 공간 좌표에서 나오기 때문에 안정적으로 유지됩니다.

이 기법은 디더링이나 하프톤 효과를 쓰는 어떤 3D 애플리케이션에도 응용할 수 있습니다. 핵심은 이것입니다. 시간적 안정성을 원한다면 임계값이 매 프레임 바뀌는 값(화면 좌표)이 아니라, 안정적인 값(월드 위치, 시선 방향)의 함수여야 합니다.

레트로 미학을 넘어선 실제 활용

디더링은 레트로 게임 감성에만 쓰이는 게 아닙니다. 현대 개발에서도 실질적으로 쓰입니다.

  • E-ink 디스플레이. 전자책 리더기와 e-ink 사이니지는 회색 단계 표현력이 제한적입니다(보통 16단계). 이런 기기에 사진이나 그러데이션을 보여주려면 디더링이 필수입니다. 어떤 디더링 알고리즘을 고르느냐에 따라 가독성과 이미지 품질이 직접 달라집니다.
  • 파일 크기 줄이기. 1비트 디더링 이미지는 8비트 그레이스케일 이미지의 원본 크기 대비 8분의 1입니다. 장식용 이미지를 웹으로 배포할 때, 디더링된 PNG는 그레이스케일 대안보다 훨씬 작아질 수 있습니다. 일부 웹 디자이너는 미적인 이유와 성능상의 이유로 일부러 디더링 이미지를 씁니다.
  • 색상 양자화. 전체 컬러 이미지를 제한된 팔레트(GIF, 인덱스 PNG, 팔레트 제약이 있는 디스플레이)로 줄일 때, 디더링은 부드러운 그러데이션이 이산적인 색상 값으로 매핑되면서 생기는 밴딩을 막아줍니다. GIF 인코더가 색 공간에서 Floyd-Steinberg 디더링을 쓰는 이유가 이것입니다.
  • 렌더링에서의 그러데이션 밴딩. 채널당 8비트 같은 저정밀 렌더 타깃에서는 부드러운 그러데이션에 눈에 띄는 밴딩이 생길 수 있습니다. 화면 공간 디더링을 더하면(값에 ±0.5 정도만 더해도) 눈에 거슬리는 노이즈 없이 밴딩을 흩어 줄 수 있습니다. 요즘 대부분의 게임 엔진은 이걸 자동으로 합니다.
  • 열 프린터와 영수증 출력. 열 프린터는 본질적으로 1비트입니다. 점 하나하나가 가열되거나(검정) 가열되지 않거나(흰색) 둘 중 하나입니다. 디더링은 사진과 그래픽을 출력 가능한 형태로 바꿔 줍니다. 어떤 알고리즘을 쓰느냐에 따라 영수증 이미지를 알아볼 수 있을지, 그냥 얼룩이 될지가 갈립니다.

셰이더에서 디더링 구현하기

실시간 애플리케이션에서는 순서 디더링과 블루 노이즈 디더링을 프래그먼트 셰이더로 효율적으로 구현할 수 있습니다.

// GLSL fragment shader for Bayer 8x8 ordered dithering
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vTexCoord;
// Bayer 8x8 matrix lookup via bit manipulation (no texture needed)
float bayer8x8(vec2 pos) {
ivec2 p = ivec2(mod(pos, 8.0));
int index = p.x + p.y * 8;
// Bit-reversal trick for Bayer matrix values
int value = 0;
int x = p.x ^ p.y;
int y = p.y;
for (int i = 0; i < 3; i++) {
value = value * 4 + (x & 1) * 2 + (y & 1);
x >>= 1;
y >>= 1;
}
return float(value) / 64.0;
}
void main() {
vec4 color = texture2D(uTexture, vTexCoord);
float luminance = dot(color.rgb, vec3(0.299, 0.587, 0.114));
float threshold = bayer8x8(gl_FragCoord.xy);
float dithered = step(threshold, luminance);
gl_FragColor = vec4(vec3(dithered), 1.0);
}

블루 노이즈의 경우 보통 노이즈 텍스처를 미리 계산해 두고 셰이더에서 샘플링합니다. 텍스처는 타일링이 가능하므로, 64×64나 128×128 블루 노이즈 텍스처를 반복하면 화면 전체를 덮을 수 있습니다. 픽셀당 비용은 텍스처 샘플 한 번과 비교 한 번이라서, 최신 GPU에서는 사실상 공짜나 다름없습니다.

알맞은 알고리즘 고르기

각 디더링 방식에는 가장 좋은 선택이 되는 사용처가 있습니다.

  • 임계값: 텍스트, 선화, 명암 범위보다 디테일이 중요한 고대비 그래픽.
  • 순서(베이어): 의도적인 레트로/하프톤 감성을 원하는 실시간 렌더링. 빠르고, 병렬화가 가능하며, GPU에 친화적입니다.
  • Floyd-Steinberg: 품질이 가장 중요한 정적 이미지 처리. 사진이나 부드러운 그러데이션이 있는 이미지에 가장 좋습니다.
  • 블루 노이즈: 보이는 패턴 없이 자연스러운 결과를 원하는 실시간 렌더링. 현대 애플리케이션에서 가장 무난하고 좋은 선택입니다.
  • 구면/월드 공간: 시간적 안정성이 중요한 3D 렌더링. Obra Dinn 같은 1비트 3D 렌더링에 필수입니다.

'점을 배치해서 명암처럼 보이게 한다'는 개념은 단순하지만, 그 안의 구현 선택은 꽤 깊습니다. 알고리즘마다 품질, 속도, 시각적 성격 사이에서 서로 다른 트레이드오프를 택하고 있습니다. e-ink 디스플레이, 저대역폭 연결, 레트로 미학이 여전히 중요한 시대에, 이런 트레이드오프를 이해하는 건 그 어느 때보다 실용적입니다.