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

css dither 9가지 — 움직이는 화면을 점의 배치로 그리는 디더링

css dither 는 색을 두 가지만 남기는데 왜 그라디언트가 오히려 부드러워 보일까 — 01번 임계 행렬과 02번 오차 확산이 그 답이다.

아홉 가지 디더를 화면에서 바로 돌려 본다

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

css dither 9가지 — 임계값·오차·법선을 계산해야 점이 그림이 된다

장면마다 160×100 칸짜리 작은 격자에 점 하나씩 색을 정해 넣고, 그 격자를 3배로 늘려 무대에 올린다. 늘릴 때 imageSmoothingEnabled 를 끄고 CSS 에도 image-rendering: pixelated 를 걸어 두어서 점이 번지지 않고 네모로 남는다. 무대 폭은 min(92vw, calc(160vh - 12.8vw)) 이고, 렌더 캡처 창에서는 가로 약 419px 로 찍혔다.

디더링이 하는 일은 밝기를 몇 단계로 줄이되, 줄이면서 생기는 오차가 한곳에 뭉쳐 띠가 되지 않게 흩는 것이다. 흩는 길은 둘이다. 하나는 칸마다 미리 정한 임계값과 밝기를 견주는 길이고(01·04·05·06·07·08·09), 다른 하나는 반올림에서 남은 오차를 이웃 칸에 넘기는 길이다(02·03). 아홉 장면은 이 두 길을 먼저 맨 얼굴로 보이고(01·02), 구 하나에 얹고(03), 전환·물결·입체·팔레트·글자·포인터 반응에 옮겨 가는 순서로 놓았다.

어디서 시작할지는 쓰임으로 고르면 된다. 로딩 배경이면 01, 달 같은 구 한 장이면 03, 페이지 전환이면 04, 클릭에 반응하는 물 소재 히어로면 05, 커서 둘레만 밝아지는 첫 화면이면 09 부터 열면 된다. 같은 캔버스 픽셀 계산을 색 그라디언트로 풀면 무지개 그라데이션 9가지 — 캔버스 플라즈마 배경 만들기가 되고, 이 편은 같은 밝기장을 두 색과 네 색으로 깎는 쪽이다.

css dither effect 를 기법별 코드로 본다

아래 조각은 장면마다 index.html 의 <script> 안에서 이어진 줄을 그대로 가져왔고, 한 조각은 25줄을 넘지 않는다. css image dithering 이라는 이름으로 모이는 움직임도 따지고 보면 이 안의 두 길, 곧 임계값을 견주는 계산과 오차를 넘기는 계산 위에 서 있다.

01임계 행렬로 줄무늬 그라디언트를 두 색으로 찍는 정렬 디더

점마다 사인 줄무늬의 밝기에 행렬 임계값을 더해 1 이상이면 켜고 아니면 끈다. 행렬은 2×2 에서 시작해 두 배씩 키워 만들고 점 (x, y) 는 행렬의 (y % n)·n + (x % n) 번째 값을 쓴다. 렌더 측정은 누적면적 74.8%, 평균 강도 182.8, 움직인 프레임 23/23 이다.

베이어 임계 행렬로 움직이는 그라디언트를 두 색으로 찍는 정렬 디더
새 창에서 열기

01 · 베이어 임계 행렬로 움직이는 그라디언트를 두 색으로 찍는 정렬 디더 — 측정 누적면적 74.844% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BAYER_SIZELEVELSSWEEP_SPEEDTHRESHOLD_BIAS
    // 베이어 임계 행렬 — n 은 2 의 거듭제곱. 값은 0 초과 1 미만으로 맞춘다
    function makeBayer(n) {
        let size = 2;
        let m = [0, 2, 3, 1];
        const quad = [0, 2, 3, 1];
        while (size < n) {
            const s2 = size * 2;
            const next = [];
            for (let y = 0; y < s2; y += 1) {
                for (let x = 0; x < s2; x += 1) {
                    const q = (y >= size ? 2 : 0) + (x >= size ? 1 : 0);
                    next.push(4 * m[(y % size) * size + (x % size)] + quad[q]);
                }
            }
            m = next;
            size = s2;
        }
        const out = new Float32Array(n * n);
        for (let i = 0; i < out.length; i += 1) {
            out[i] = (m[i] + 0.5) / (n * n);
        }
        return out;
    }

02오차를 이웃 점으로 흘려보내 사인 플라즈마를 그리는 오차 확산

사인 네 개를 합친 밝기장에서 점마다 0.5 보다 크면 켜고, 반올림으로 남은 오차를 옆 7/16·아래 5/16·아래 뒤쪽 3/16·아래 앞쪽 1/16 로 이웃에 나눠 준다. 홀수 줄은 오른쪽에서 왼쪽으로 가는 SERPENTINE 방식이라 앞쪽 방향이 줄마다 뒤집히고, 두 색만으로 면을 채우는 히어로 배경에 어울린다. 평균 강도 165.7 에 누적면적은 74.8% 였고 23/23 프레임이 움직였다.

오차를 이웃 픽셀로 흘려보내 사인 플라즈마를 그리는 오차 확산
새 창에서 열기

02 · 오차를 이웃 픽셀로 흘려보내 사인 플라즈마를 그리는 오차 확산 — 측정 누적면적 74.85% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DIFFUSEFIELD_FREQPHASE_SPEEDSERPENTINE
                    const dir = (SERPENTINE === 1 && (y & 1) === 1) ? -1 : 1;
                    for (let k = 0; k < GW; k += 1) {
                        const x = dir > 0 ? k : GW - 1 - k;
                        const i = y * GW + x;
                        const val = field(x, y, ph, f) + err[i];
                        const on = val > 0.5 ? 1 : 0;
                        buf.px[i] = on === 1 ? light : dark;
                        // Floyd-Steinberg — 옆 7/16, 아래 5/16, 아래 양옆 3/16 과 1/16
                        const e = (val - on) * carry;
                        const hasNext = x + dir >= 0 && x + dir < GW;
                        const hasPrev = x - dir >= 0 && x - dir < GW;
                        if (hasNext) {
                            err[i + dir] += e * 7 / 16;
                        }
                        if (y + 1 < GH) {
                            if (hasPrev) {
                                err[i + GW - dir] += e * 3 / 16;
                            }
                            err[i + GW] += e * 5 / 16;
                            if (hasNext) {
                                err[i + GW + dir] += e * 1 / 16;
                            }
                        }
                    }

03광원이 한 바퀴 돌며 달의 위상을 점으로 만드는 구체 셰이딩

구 표면의 법선과 광원 방향의 내적이 밝기가 되고, 그 값을 Atkinson 방식으로 찍는다. 이 방식은 여섯 이웃에게 오차의 1/8 씩만 주어 4분의 1 을 버리므로 어두운 쪽과 밝은 쪽이 깨끗한 색으로 굳고, 광원 위상이 점의 밀도로 바뀐다. 누적면적 30.4%, 평균 강도 188.2 에 움직인 프레임은 22/23 이었다.

광원이 한 바퀴 돌며 달의 위상을 점으로 만드는 구체 셰이딩
새 창에서 열기

03 · 광원이 한 바퀴 돌며 달의 위상을 점으로 만드는 구체 셰이딩 — 측정 누적면적 30.397% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIGHT_SPEEDSPHERE_RAMBIENTATKINSON_KEEP
                            const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
                            // 법선과 광원 방향의 내적이 곧 밝기
                            v = clamp(Math.max(0, nx * lx + ny * ly + nz * lz) + AMBIENT, 0, 1);
                        }
                        else {
                            v = HALO_GAIN * lit * Math.exp(-(r - SPHERE_R) / HALO_W);
                            if (sx.has(i) && r > SPHERE_R + 4) {
                                v = Math.max(v, 0.9);
                            }
                        }
                        const val = v + err[i];
                        const on = val > 0.5 ? 1 : 0;
                        buf.px[i] = on === 1 ? light : dark;
                        // Atkinson — 여섯 이웃이 오차의 1/8 씩만 받고 나머지는 버린다
                        const e = (val - on) * ATKINSON_KEEP;
                        addErr(i + 1, x + 1, y, e);
                        addErr(i + 2, x + 2, y, e);
                        if (y + 1 < GH) {
                            addErr(i + GW - 1, x - 1, y + 1, e);
                            addErr(i + GW, x, y + 1, e);
                            addErr(i + GW + 1, x + 1, y + 1, e);
                        }
                        if (y + 2 < GH) {
                            addErr(i + 2 * GW, x, y + 2, e);
                        }

04화면이 점으로 녹아 다음 장면으로 넘어가는 노이즈 전환

점마다 좌표만으로 정해지는 0~1 의사 난수 임계값이 진행도 p 보다 작으면 줄무늬 장면을, 아니면 고리 장면을 그린다. 임계값이 고르게 퍼져 있어 p 가 오르는 만큼 점이 흩어져 바뀌므로 페이지 전환이나 슬라이드 넘김에 쓸 수 있다. 누적면적은 아홉 중 가장 넓은 75.3% 이고 평균 강도 111.8, 23/23 이다.

화면이 점으로 녹아 다음 장면으로 넘어가는 인터리브 노이즈 전환
새 창에서 열기

04 · 화면이 점으로 녹아 다음 장면으로 넘어가는 인터리브 노이즈 전환 — 측정 누적면적 75.253% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOISE_ANOISE_BFADE_PERIODHOLD_FRAMES
                        // 인터리브 그라디언트 노이즈 — 좌표만으로 정해지는 0~1 값
                        const thr = frac(52.9829189 * frac(NOISE_A * x + NOISE_B * y));
                        if (thr < p) {
                            const s = Math.sin((x * 0.7 + y * 0.7) * STRIPE_FREQ + t * 0.1);
                            buf.px[y * GW + x] = mixPack(STRIPE_DARK, STRIPE_LIGHT, smooth(s * 1.5 + 0.5));
                        }
                        else {
                            const s = Math.sin(Math.hypot(x - GW * 0.35, y - GH * 0.55) * RING_FREQ - t * 0.12);
                            buf.px[y * GW + x] = mixPack(ringLo, ringHi, smooth(s * 1.5 + 0.5));
                        }

05파동 방정식으로 퍼지는 물결을 네 단계 명암으로 찍는 디더 연못

높이장을 이웃 네 칸의 합과 이전 프레임으로 갱신해 물결을 퍼뜨리고, 높이를 네 단계 톤으로 나눠 8×8 베이어 행렬로 찍는다. 포인터를 움직이면 9틱마다 물방울이 떨어지고 누르면 큰 물방울이 떨어져 클릭 반응형 배경에 맞는다. 누적면적 63.0% 에 강도 84.6 이고 23/23 프레임이 움직였다.

파동 방정식으로 퍼지는 물결을 네 단계 명암으로 찍는 디더 연못
새 창에서 열기

05 · 파동 방정식으로 퍼지는 물결을 네 단계 명암으로 찍는 디더 연못 — 측정 누적면적 62.954% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DAMPINGWAVE_CLEVELSDROP_EVERY
        // 파동 방정식 한 걸음 — 이웃 합과 이전 프레임으로 다음 높이를 구한다
        function simulate() {
            for (let y = 1; y < GH - 1; y += 1) {
                for (let x = 1; x < GW - 1; x += 1) {
                    const i = y * GW + x;
                    const lap = h[i - 1] + h[i + 1] + h[i - GW] + h[i + GW] - 4 * h[i];
                    hn[i] = (2 * h[i] - hp[i] + WAVE_C * lap) * DAMPING;
                }
            }
            const old = hp;
            hp = h;
            h = hn;
            hn = old;
        }

06회전하는 입체의 면 밝기를 점 밀도로 바꾸는 3D 디더 큐브

정육면체의 면 중심과 법선을 회전시켜 카메라를 향한 면만 남기고, 꼭짓점을 x·FOCAL/(z+CAM_DIST) 로 투영해 면마다 광원과의 각도로 밝기를 정한 뒤 베이어 임계값과 견줘 점으로 채운다. 볼록한 입체라 보이는 면끼리 겹치지 않아 깊이 정렬이 필요 없고, 로고 둘레에 두는 회전 장식에 맞는다. 렌더 측정은 누적면적 26.4%, 평균 강도 147.3, 움직인 프레임 23/23 이다.

회전 입체의 면 밝기를 점 밀도로 바꾸는 3D 디더 큐브
새 창에서 열기

06 · 회전 입체의 면 밝기를 점 밀도로 바꾸는 3D 디더 큐브 — 측정 누적면적 26.445% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROT_SPEEDLIGHT_DIRCAM_DISTFACE_SHADE
            const n = rot(f.n, a, b);
            const c = rot([f.n[0] * SIDE, f.n[1] * SIDE, f.n[2] * SIDE], a, b);
            // 카메라(0, 0, -CAM_DIST)를 향한 면만 그린다
            const toCam = n[0] * (0 - c[0]) + n[1] * (0 - c[1]) + n[2] * (-CAM_DIST - c[2]);
            if (toCam <= 0) {
                return;
            }
            const signs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
            const pts = [];
            for (let k = 0; k < 4; k += 1) {
                const s = signs[k];
                const corner = rot([
                    (f.n[0] + s[0] * f.u[0] + s[1] * f.v[0]) * SIDE,
                    (f.n[1] + s[0] * f.u[1] + s[1] * f.v[1]) * SIDE,
                    (f.n[2] + s[0] * f.u[2] + s[1] * f.v[2]) * SIDE
                ], a, b);
                const k2 = FOCAL / (corner[2] + CAM_DIST);
                pts.push([GW / 2 + corner[0] * k2, GH / 2 - corner[1] * k2]);
            }
            const lam = Math.max(0, n[0] * lx + n[1] * ly + n[2] * lz);
            const shade = FACE_SHADE + (1 - FACE_SHADE) * lam;

07네 가지 색으로 하늘 그라디언트를 양자화하는 팔레트 디더 노을

하늘 그라디언트와 해의 후광, 바다 반사를 만든 다음 픽셀마다 베이어 값으로 색을 조금씩 어긋나게 하고 4색 팔레트 중 가장 가까운 색을 고른다. 어긋나는 폭이 DITHER_AMP 라서 색 경계가 띠 대신 점으로 이어 보이므로 저해상도 감성의 랜딩 배경에 쓴다. 누적면적 44.5%, 평균 강도 79.9 에 23/23 프레임이 움직였다.

네 가지 색으로 하늘 그라디언트를 양자화하는 팔레트 디더 노을
새 창에서 열기

07 · 네 가지 색으로 하늘 그라디언트를 양자화하는 팔레트 디더 노을 — 측정 누적면적 44.511% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DITHER_AMPPALETTE_SIZEHORIZON_YSUN_SPEED
        // 보정한 색에서 가장 가까운 팔레트 색의 번호
        function nearest(r, g, b) {
            let best = 0;
            let bestD = 1e9;
            for (let k = 0; k < pal.length; k += 1) {
                const dr = r - pal[k][0];
                const dg = g - pal[k][1];
                const db = b - pal[k][2];
                const d = dr * dr + dg * dg + db * db;
                if (d < bestD) {
                    bestD = d;
                    best = k;
                }
            }
            return best;
        }

08글자가 점으로 흩어졌다 다시 모이는 타이포 디더

글자를 보이지 않는 캔버스에 그려 getImageData 로 한 번 읽고 3×3 평균으로 번지게 한 밝기 마스크를 만든다. 매 프레임 이 마스크를 시간에 따라 움직이는 임계값과 견주어 왼쪽부터 점 단위로 지우고 되살리므로 타이틀이나 로고가 등장하는 연출에 맞는다. 누적면적 21.9% 로 아홉 중 가장 좁고 평균 강도는 136.9 이며 23/23 프레임에서 움직임이 잡혔다.

글자가 점으로 흩어졌다 다시 모이는 타이포 디더
새 창에서 열기

08 · 글자가 점으로 흩어졌다 다시 모이는 타이포 디더 — 측정 누적면적 21.874% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DISSOLVE_SPEEDFONT_PXMASK_BLURBAYER_SIZE
        const mask = new Float32Array(GW * GH);
        const off = document.createElement('canvas');
        off.width = GW;
        off.height = GH;
        const octx = off.getContext('2d');
        if (octx) {
            octx.fillStyle = '#000000';
            octx.fillRect(0, 0, GW, GH);
            octx.fillStyle = '#ffffff';
            octx.font = 'bold ' + FONT_PX + 'px sans-serif';
            octx.textAlign = 'center';
            octx.textBaseline = 'middle';
            octx.fillText(WORD, GW / 2, GH / 2);
            const data = octx.getImageData(0, 0, GW, GH).data;
            const raw = new Float32Array(GW * GH);
            for (let i = 0; i < raw.length; i += 1) {
                raw[i] = data[i * 4] / 255;
            }

09포인터 둘레만 촘촘해지는 빛무리 디더 히어로

노이즈로 흐르는 배경 밝기에 포인터와의 거리에 따른 가우시안 값을 더하고 이를 베이어 임계값과 견주어 커서 둘레만 점이 빽빽해진다. 포인터 좌표를 LERP 로 늦게 따라가므로 빛무리가 미끄러지듯 움직이고 히어로의 포인트 장면으로 쓰기 좋다. 누적면적은 50.6%, 평균 강도는 91.9 이고 23/23 프레임이 움직였다.

포인터 둘레만 촘촘해지는 빛무리 디더 히어로
새 창에서 열기

09 · 포인터 둘레만 촘촘해지는 빛무리 디더 히어로 — 측정 누적면적 50.6% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HALO_RHALO_GAINNOISE_SCALELERP
                const breathe = 1 + 0.12 * Math.sin(t * 0.07) + 0.6 * smooth(pulse / PULSE_TICKS);
                const sigma = (HALO_R / 1.6) * breathe;
                const inv = 1 / (2 * sigma * sigma);
                for (let y = 0; y < GH; y += 1) {
                    for (let x = 0; x < GW; x += 1) {
                        const nx = x * NOISE_SCALE + t * 0.012;
                        const ny = y * NOISE_SCALE - t * 0.007;
                        const n = vnoise(nx, ny, NOISE_SEED) * 0.65 + vnoise(nx * 2.1 + 7, ny * 2.1 + 3, NOISE_SEED) * 0.35;
                        const dx = x - hx;
                        const dy = y - hy;
                        const halo = Math.exp(-(dx * dx + dy * dy) * inv);
                        const v = 0.05 + 0.5 * n + halo * HALO_GAIN;
                        const i = y * GW + x;
                        buf.px[i] = v > matrix[(y & 7) * 8 + (x & 7)] ? tint[Math.min(31, Math.floor(halo * 31))] : dark;

값이 점무늬를 바꾸는 곳 — 행렬 크기·오차 비율·물결 감쇠

값 하나로 점무늬가 눈에 띄게 달라지는 곳은 셋이다. 아래 숫자는 브라우저 렌더가 아니라 소스의 같은 계산을 노드의 가짜 DOM 에서 직접 돌려 센 것이라, 밑의 측정 표와는 따로 읽어야 한다.

행렬을 4 에서 8 로 키우면 점 수의 종류가 17 에서 44 로 는다

01 의 BAYER_SIZE 를 4 에서 8 로 바꾸고 30틱 뒤 한 프레임을 보면, 행렬 크기만 한 타일마다 켜진 점 수의 종류가 17가지에서 44가지로 늘어난다. 이론상 가능한 종류는 17 에서 65 로 늘고, 켜진 점의 비율은 0.520 에서 0.523 으로 거의 같다. 밝기는 그대로인데 결이 곱게 풀리고 대신 반복 주기가 4칸에서 8칸으로 넓어진다.

오차 전달 비율을 줄이면 켜진 점이 뭉친다

02 의 DIFFUSE 를 1.0 에서 0.75 로 낮추면 같은 가로 줄에서 켜진 점이 이어지는 평균 길이가 세 프레임 평균으로 1.88칸에서 2.11칸이 된다. 0.5 에서는 2.67칸이고 0 이면 36.3칸의 띠로 굳는다. 켜진 비율은 0.529 에서 0.533 으로 거의 변하지 않는다.

감쇠를 올리면 큰 물결이 화면에 오래 남는다

05 의 DAMPING 을 0.985 에서 0.995 로 올리면, 가장 어두운 단계나 가장 밝은 단계로 찍힌 픽셀의 비율이 8프레임 평균으로 6.0% 에서 27.1% 가 된다. 물결의 마루와 골이 사라지지 않고 쌓이는 셈이다.

아홉 장면을 실제로 렌더해서 잰 값은 아래 표에 모았다. 가장 넓게 움직인 쪽은 04 의 75.3% 이고 가장 좁은 쪽은 08 의 21.9% 이며, 03 만 움직인 프레임이 22/23 으로 한 프레임이 비었고 그 원인은 확인하지 않았다.

항목 누적면적 평균 강도 움직인 프레임
01 정렬 디더 74.8% 182.8 23/23
02 오차 확산 74.8% 165.7 23/23
03 구체 셰이딩 30.4% 188.2 22/23
04 노이즈 전환 75.3% 111.8 23/23
05 디더 연못 63.0% 84.6 23/23
06 3D 큐브 26.4% 147.3 23/23
07 팔레트 노을 44.5% 79.9 23/23
08 타이포 21.9% 136.9 23/23
09 빛무리 50.6% 91.9 23/23

어디서 깨지는가 — 해상도가 높을 때·좁은 화면·움직임 줄이기 설정

해상도가 높아도 점 격자는 160×100 이다

화면 해상도를 올려도 계산하는 칸은 160×100 으로 같고, 캔버스 안쪽 해상도만 devicePixelRatio 를 따라가되 최대 2 로 잘린다. 대신 09 는 칸마다 vnoise 두 번과 지수 함수 한 번을 16,000칸에 돌려 다른 장면보다 한 프레임의 계산이 무겁다. 격자를 키우면 계산이 그만큼 늘어나는데 키운 값으로 재 보지는 않았다.

오차 확산은 순차라 병렬로 못 쪼갠다

02 는 한 점의 결과가 앞 점이 넘긴 오차에 달려 있어서 칸을 동시에 처리할 수 없고, 프레임마다 err.fill(0) 으로 비운 뒤 16,000칸을 처음부터 다시 돈다. 프레임 사이에 오차를 이어받지 않으므로 밝기장이 움직이면 점 배치가 통째로 바뀐다. 격자를 작게 잡고 3배로 늘리는 것이 이 때문이다.

Atkinson 은 오차의 4분의 1 을 버린다

03 에서 광원이 정면일 때 켜진 점은 3,975개이고 31틱 뒤에는 1,251개, 63틱 뒤 위상이 반대편에 닿으면 15개다. 그믐에도 몇 점이 남는 것은 구 바깥에 놓은 배경 별 26개 중 구에서 멀리 있는 것들이 켜지기 때문이다. 오차를 4분의 1 버리는 값을 치르고 어두운 쪽과 밝은 쪽이 순수한 색으로 더 잘 굳는 것이 이 방식의 특징이다.

전환 임계값에는 시간 항이 없다

04 의 임계값은 좌표만 쓰므로 같은 p 에서는 늘 같은 점이 골라진다. thr < p 인 점의 비율은 p 를 0.25·0.5·0.75 로 두면 0.250·0.500·0.750 으로 p 와 같다. 줄무늬 쪽으로 고른 점이 가로로 이어지는 최대 길이는 p 0.25 에서 1칸, 0.5 에서 2칸, 0.75 에서 6칸이라 어느 쪽도 큰 덩어리로 뭉치지 않고 흩어진다.

파동 계수를 올리면 계산이 발산한다

05 의 WAVE_C 를 0.4 에서 0.6 으로 올리면 같은 8프레임 측정에서 극단 단계의 비율이 0.929 가 되고 톤 종류가 5 로 늘어, 네 단계 밖의 값이 섞인다. 가장자리는 계산하지 않고 0 으로 두며, 감쇠는 틱당 두 번 곱해지므로 실제 틱 감쇠는 DAMPING 의 제곱이다.

큐브 상수를 키우면 격자 밖으로 잘린다

06 의 이전 상수(FOCAL 125·CAM_DIST 3.8·SIDE 0.85)에서는 테두리 픽셀의 경계 상자가 98×97 이라 격자 위쪽에 닿아 잘렸다. 지금 값(FOCAL 112·CAM_DIST 4.2·SIDE 0.8)은 71×75 이고 125틱 동안 격자 안에 머문다. toCam 이 0 이하인 면은 건너뛰므로 보이는 면은 한 번에 최대 세 개다.

디더 진폭이 0 이면 경계가 띠로 굳는다

07 에서 세로로 이웃한 두 픽셀의 색이 다른 횟수가 진폭 0.4 일 때 10,921 이고 DITHER_AMP 를 0 으로 두면 1,523 이다. 7배쯤 줄어든다는 뜻이고, 디더가 왜 필요한지 보여 주는 숫자다. 팔레트가 4색이라 픽셀마다 거리 계산도 네 번 돈다.

글자 폭은 기기의 글꼴이 정한다

08 은 bold 38px sans-serif 로 글자를 그리고 measureText 로 폭을 재지 않는다. 기기마다 기본 글꼴이 다르면 글자 폭이 달라질 수 있고 격자 가로 160 을 넘으면 양끝이 잘릴 수도 있는데, 다른 글꼴로 돌려 보지는 않았다. 캔버스를 얻지 못하면 마스크가 0 이라 글자가 나오지 않는다.

좁은 화면에서는 글자만 줄고 비율은 그대로다

폭 340px 이하에서는 캡션·버튼·상태 글자가 10px 로 줄고 캔버스 비율 480:300 은 유지된다. 무대 폭이 뷰포트에 비례하므로 폰에서는 점 하나가 그만큼 작게 보인다.

직접 값을 바꿔 보며 경계를 확인하고 싶다면 압축을 풀어야 한다. 압축을 여는 암호는 ee8ur62p 이고, 안에는 아홉 장면의 index.html·style.scss 와 리액트 판이 함께 들어 있다.

변형 세 가지와 접근성

이름 값 느낌
행렬 크기 바꾸기 01 BAYER_SIZE 4 → 8 타일마다 켜진 점 수의 종류가 17가지에서 44가지로 늘어 결이 곱게 풀리고 켜진 비율은 0.520 에서 0.523 으로 같다.
오차 전달 비율 바꾸기 02 DIFFUSE 1.0 → 0.75 켜진 점이 가로로 이어지는 평균이 1.88칸에서 2.11칸이 되어 점이 조금 뭉친다.
물결 감쇠 바꾸기 05 DAMPING 0.985 → 0.995 극단 단계 픽셀이 평균 6.0% 에서 27.1% 로 늘어 물결이 오래 남는다.

세 변형의 숫자는 위 값 절과 같은 방식으로 노드에서 소스를 돌려 센 값이다.

조작은 아홉 장면이 같은 틀을 쓴다. 장면마다 <button> 이 하나 있고 캔버스는 tabindex="0" 이라 Tab 으로 닿으며, Enter·Space 는 장면의 누르기 동작을, 방향키는 가상 포인터를 14 단위씩 옮긴다. preventDefault 는 이 여섯 키에서만 불리고 Tab 을 포함한 나머지 키는 그대로 지나간다. 상태 문구는 aria-live="polite" 인 <p> 에 담기고 문구가 바뀔 때만 고쳐 쓴다.

prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고 몇 틱을 미리 돌린 정지 한 장만 그린다. 점무늬는 정지한 채로도 그대로 보이고, 버튼은 멈춤 대신 다음 장면 이 되어 누를 때마다 900ms 분량만큼 진행한다. 포인터나 키 입력이 들어오면 6틱을 진행해 곧바로 한 번 그리므로 입력이 화면에 바로 반영된다.

css dither filter 만으로 충분한가?

css dither filter 라는 말에서 속성 한 줄을 기대하기 쉽지만, 이 편의 아홉 장면은 캔버스에 putImageData 로 점을 찍는 방식이라 CSS 속성이 하는 일은 확대된 점이 번지지 않게 하는 image-rendering: pixelated 와 무대 크기 정도다. 점마다 임계값을 견주거나 오차를 흘려보내는 계산은 스크립트에서 일어난다.

그래서 이미 있는 사진이나 요소 위에 덮는 효과가 필요한지, 아니면 밝기장을 직접 계산해서 점으로 만들 수 있는지를 먼저 가르는 것이 좋다. 후자라면 이 아홉 장면이 출발점이 되고, 같은 캔버스 계산을 3D 장면으로 가져가고 싶으면 레이 마칭 9종 — 캔버스 픽셀 계산으로 그리는 3D 장면 (WebGL 없이)가, 불꽃을 파티클로 그리고 싶으면 캔버스 불 애니메이션 9종 — 시드 고정 파티클로 그리는 불꽃 효과가 이어진다. 캔버스 쓰는 법은 MDN 의 putImageData 문서에 정리되어 있다.

css dithering gradient 를 가져다 쓰기 전에 자주 묻는 질문

css image dithering 을 내 사진에 쓰려면 어디를 바꾸나?

아홉 장면은 사진을 읽지 않고 수식으로 만든 밝기장을 쓴다. 다만 08 이 캔버스에 그린 그림을 getImageData 로 읽어 밝기 배열로 쓰는 자리라서, 사진 한 장을 같은 방식으로 읽으면 그 값이 밝기가 된다. 사진으로 바꿔 돌려 보지는 않았다.

격자를 160×100 보다 크게 잡아도 되나?

계산량은 칸 수에 비례해 늘어난다. 02 는 프레임마다 16,000칸을 순서대로 처음부터 다시 돌고 09 는 칸마다 노이즈를 두 번 계산하므로 무거운 쪽은 이 둘이고, 키운 값으로 재 보지는 않았다.

라이브러리나 WebGL 이 필요한가?

필요 없다. 아홉 파일 모두 라이브러리·CDN·WebGL 이 없고 Math.random 도 쓰지 않는다. 난수가 필요한 03(lcg(453303))과 05(lcg(453505))만 시드를 고정한 lcg 를 부르고, 04 는 좌표 해시를, 09 는 vnoise(NOISE_SEED 9)를 쓰므로 같은 틱이면 같은 프레임이 나온다. lcg·hash2·vnoise·makeBayer 는 아홉 파일에 모두 정의돼 있지만 쓰는 장면에서만 호출된다.

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

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