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

글리치 텍스트 효과 9종 — 색 채널 분리부터 줄 찢김과 픽셀 번짐까지

글리치 텍스트는 글자를 어떻게 깨뜨릴까 — 02번은 가로 띠를 해시로 밀어 줄 찢김을 만들고, 03번은 밝은 픽셀을 끌어 번지게 하며, 09번은 누르는 순간 세게 깨졌다 한 값으로 잦아든다.

글리치 텍스트는 글자를 일부러 깨뜨려 그리는 효과가 아니라, 글자 그림의 픽셀을 시간표에 맞춰 옮기고 섞는 계산이다 — 글리치 텍스트 만들기는 어떤 값을 어느 순간에 얼마나 어긋나게 할지 정하는 데서 시작한다

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

아홉 장면의 바탕은 하나다. 480x300 캔버스에 단어 하나를 오프스크린 캔버스에 그려 getImageData 로 읽어 화소 배열 base 로 들고 있고, 매 프레임은 이 배열을 읽어 결과를 out 에 쓴 뒤 putImageData 로 올린다. 라이브러리·WebGL·CDN 은 쓰지 않고 아홉 파일 모두 난수·날짜·외부 주소 호출이 0건이라 같은 입력이 늘 같은 그림을 낸다.

흔들림의 씨앗은 난수가 아니라 해시다

난수를 쓰면 매 프레임마다 찢기는 띠가 달라져 화면이 끝없이 떨린다. 아홉 장면은 hash2(a, b) 하나로 갈랐는데 정수 곱과 시프트로 0 이상 1 미만 값을 만들고 같은 입력은 늘 같은 값을 돌려주므로, 어느 띠가 어느 칸에서 찢길지가 시간 칸마다 정해진다.

    // 해시 — 같은 (a, b) 에는 늘 같은 0 이상 1 미만 값. 난수 함수는 쓰지 않는다
    function hash2(a, b) {
        let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) | 0;
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        h = h ^ (h >>> 16);
        return (h >>> 0) / 4294967296;
    }
    // 1차원 값 잡음 — 정수 칸마다 해시 값이 있고 그 사이를 부드럽게 잇는다
    function noise1(x, seed) {
        const i = Math.floor(x);
        const f = x - i;
        const u = f * f * (3 - 2 * f);
        return lerp(hash2(i, seed), hash2(i + 1, seed), u);
    }

글자가 있는 행만 계산한다

loadWord 는 단어를 그린 뒤 화소 배열을 한 번 훑어 배경과 차이가 24를 넘는 행 범위 rowTop·rowBot 을 찾는다. 02 는 행 95~192, 03 은 109~193 을 계산하므로 300행 가운데 85행만 돌고 나머지 행은 건드리지 않는다.

    // 원본 화소 버퍼 — 글자를 한 번 그려 읽어 둔 판. 모든 글리치는 이 base 를 읽어 out 에 쓴다
    let base = new Uint8ClampedArray(W * H * 4);
    let rowTop = 0;
    let rowBot = H - 1;
    const img = wkCtx.createImageData(W, H);
    const out = img.data;
    function loadWord(paint) {
        wordCtx.fillStyle = rgba(BGC, 1);
        wordCtx.fillRect(0, 0, W, H);
        paint(wordCtx);
        base = wordCtx.getImageData(0, 0, W, H).data;
        // 글자가 있는 행 범위 — 글리치 계산은 이 행만 돈다
        let top = -1;
        let bot = -1;
        for (let y = 0; y < H; y += 1) {
            let hit = false;
            for (let x = 0; x < W && !hit; x += 2) {
                const p = (y * W + x) * 4;
                if (Math.abs(base[p] - BGC[0]) + Math.abs(base[p + 1] - BGC[1]) + Math.abs(base[p + 2] - BGC[2]) > 24) {
                    hit = true;
                }
            }
            if (hit) {
                if (top < 0) {
                    top = y;
                }
                bot = y;
            }
        }
        rowTop = Math.max(0, top - 2);
        rowBot = Math.min(H - 1, bot + 2);
    }

시간표만으로 돌아가는 자동 시연

장면은 입력 없이 시간표로만 움직이고 첫 변화는 0.25초에 둔다. 무대를 누르거나 방향키를 누르면 세기 단계가 한 칸 올라가고, 탭이 숨으면 루프를 멈췄다가 돌아오면 긴 첫 프레임을 버려 몇 초가 한 프레임에 몰리지 않게 한다.

글리치 텍스트 효과 9종 — 색 채널 분리·줄 단위 찢김·밝기 기준 픽셀 번짐·격자 블록 버스트·색마다 다른 물결·글자 바꿔치기·주사선 굴림·바이트 구간 손상·누르면 깨지는 세기

아홉 장면이 이 순서로 놓인 이유와 렌더 측정값

01 은 화소 배열을 건드리지 않고 세 장을 포개는 가장 가벼운 방법이라 맨 앞이고, 02·03 은 띠와 행 단위로 배열을 옮기는 기본 두 축이다. 04·08 은 화면을 덮어 쓰는 찢김이고 05·07 은 채널·번짐으로 번지며, 06 은 모양을 바꾸고 09 는 깨짐 자체의 세기를 다룬다.

장면 누적면적 움직인 프레임 강도 평균
01 색 채널 분리 18.4% 22/23 91.3
02 줄 찢김 9.5% 7/23 153.8
03 픽셀 번짐 4.9% 11/23 38.3
04 블록 버스트 15.8% 8/23 150.9
05 색수차 파동 13.4% 23/23 157.2
06 글자 바꿔치기 3.8% 6/23 149.9
07 주사 번짐 롤 18.0% 23/23 124.5
08 바이트 손상 14.9% 16/23 119.6
09 트리거 감쇠 14.2% 12/23 78.6

면적이 가장 넓은 장면은 01 의 18.4% 이고 06 은 3.8% 로 가장 좁다. 02 는 시간 칸이 바뀔 때만 찢기니 움직인 프레임이 7/23 에 그치고 06 은 한 판에 바꿈이 일곱 번이라 6/23 이며, 05·07 은 물결과 띠가 멈추지 않아 23/23 이다.

01글자를 붉은·초록·푸른 세 장으로 떼어 어긋나게

글자 가장자리에 색이 번지는 느낌을 가장 적은 코드로 얻는 방법이다. 종이색에서 바탕색을 뺀 값 가운데 한 채널만 가진 글자 장을 세 개 만들어 lighter 합성으로 포개면 겹친 곳은 정확히 원래 색으로 돌아오고 가장자리만 색이 갈라진다.

글자를 붉은·초록·푸른 세 장으로 떼어 어긋나게
새 창에서 열기

01 · 글자를 붉은·초록·푸른 세 장으로 떼어 어긋나게 — 측정 누적면적 18.401% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPLIT_PXlightertint
    // 한 장 = 종이색에서 바탕색을 뺀 값 중 한 채널만 가진 글자 — 세 장을 더하면 정확히 원래 종이색이 된다
    function makeTint(ch) {
        const c = document.createElement('canvas');
        c.width = W;
        c.height = H;
        const x = c.getContext('2d');
        const tint = [0, 0, 0];
        tint[ch] = PAPER[ch] - BGC[ch];
        if (x) {
            paintWord(x, WORD, 150, rgba(tint, 1), 0, 0);
        }
        return c;
    }
        ctx.globalCompositeOperation = 'lighter';
        for (let ch = 0; ch < 3; ch += 1) {
            const a = spin + ch * TAU / 3;
            ctx.drawImage(tints[ch], Math.round(Math.cos(a) * splitNow), Math.round(Math.sin(a) * splitNow * 0.6));
        }
        ctx.globalCompositeOperation = 'source-over';

세 장은 SPLIT_PX 6 에 세기를 곱한 만큼 120도씩 다른 방향으로 밀리는데 가운데 행의 획 폭이 대부분 27~32px 인 것에 견주면 세 장 사이 거리는 세기 1 에서 10.4px 로 획보다 좁다.

02가로 띠가 줄마다 어긋나는 줄 찢김

라인마다 다른 시점으로 어긋나는 화면 찢김을 재현하는 장면이다. 글자 그림을 SLICE_H 5px 높이 띠로 자르고 띠마다 해시로 좌우를 밀어 다시 붙이는데, 밀림은 시간 칸이 0.16초마다 바뀔 때만 새로 뽑으니 계속 흔들리지 않고 끊겨 튄다.

가로 띠가 줄마다 어긋나는 줄 찢김
새 창에서 열기

02 · 가로 띠가 줄마다 어긋나는 줄 찢김 — 측정 누적면적 9.508% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLICE_HhashdrawImage
    // 시간 칸 k 에서 찢길 띠와 밀림 — 이웃 띠가 함께 찢기도록 잡음으로 고르고, 얼마나 밀지는 띠마다 해시로 정한다
    function tearBands(k, gain, sliceH) {
        const list = [];
        const b0 = Math.floor(rowTop / sliceH);
        const b1 = Math.floor(rowBot / sliceH);
        for (let b = b0; b <= b1; b += 1) {
            if (noise1(b * sliceH / 30 + k * 0.9, 17) < 1 - 0.5 * Math.min(1, gain)) {
                continue;
            }
            const off = Math.round((hash2(b, k) - 0.5) * 2 * TEAR_AMP * gain);
            if (off !== 0) {
                list.push({ b: b, off: off });
            }
        }
        return list;
    }
            const list = tearBands(Math.floor(sceneT / STEP_S), gain, SLICE_H);
            for (let i = 0; i < list.length; i += 1) {
                const y = list[i].b * SLICE_H;
                ctx.fillStyle = rgba(BGC, 1);
                ctx.fillRect(0, y, W, SLICE_H);
                ctx.drawImage(wordCv, 0, y, W, SLICE_H, list[i].off, y, W, SLICE_H);
                tornCount += 1;
                tornMax = Math.max(tornMax, Math.abs(list[i].off));
            }

찢길 띠를 고르는 것은 hash2 가 아니라 이웃 띠가 함께 답하는 noise1 이라 찢김이 줄무늬로 뭉치고, 밀림 상한은 TEAR_AMP 30px 이며 한 칸에 찢기는 띠는 200칸 평균 9.3개로 46% 다.

03밝은 픽셀이 한쪽으로 끌려 번지는 픽셀 정렬

밝기 기준으로 픽셀을 끌어 번지게 하는 효과로, 속성 트윈이 아니라 화소 배열을 직접 계산한다. 행마다 밝기가 LUMA_CUT 120 을 넘는 연속 구간을 찾아 밝기 오름차순으로 정렬하고, 구간 끝의 색을 오른쪽으로 길게 끌어 칠한다.

밝은 픽셀이 한쪽으로 끌려 번지는 픽셀 정렬
새 창에서 열기

03 · 밝은 픽셀이 한쪽으로 끌려 번지는 픽셀 정렬 — 측정 누적면적 4.937% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LUMA_CUTgetImageDatasmear
    function sortRun(o, a, b) {
        const px = [];
        for (let x = a; x < b; x += 1) {
            const p = o + x * 4;
            px.push([lumaAt(p), base[p], base[p + 1], base[p + 2]]);
        }
        px.sort(function (u, v) { return u[0] - v[0]; });
        for (let i = 0; i < px.length; i += 1) {
            const p = o + (a + i) * 4;
            out[p] = px[i][1];
            out[p + 1] = px[i][2];
            out[p + 2] = px[i][3];
        }
    }

어느 행이 끌릴지는 행 쌍의 해시로 고정이라 세기가 오르내릴 때 같은 행이 같은 자리에서 길어졌다 줄어들며, 밝기 120 을 넘는 화소는 18,196개에 끌리는 행은 85행 가운데 48행, 세기 1 의 끌림은 평균 75.1px 최대 119px 다.

04격자 블록이 튀어 나가는 순간 버스트

일정 간격으로 화면 조각이 어긋나는 깨짐 연출이다. 화면을 BLOCK_PX 16px 격자로 나눠 칸을 해시로 골라 다른 칸의 화소를 복사해 덮는데, 칸이 바뀌는 시각은 버스트 간격표 0.0·0.25·0.55·0.9·1.3초를 따르고 그 사이에는 깨끗한 글자로 쉰다.

격자 블록이 튀어 나가는 순간 버스트
새 창에서 열기

04 · 격자 블록이 튀어 나가는 순간 버스트 — 측정 누적면적 15.841% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLOCK_PXbursthash
    function burstStep() {
        const since = sceneT - trigT;
        if (since < 0 || since >= BURST_END_S) {
            return -1;
        }
        let j = 0;
        for (let i = 0; i < BURST_AT.length; i += 1) {
            if (since >= BURST_AT[i]) {
                j = i;
            }
        }
        return j;
    }
    // 버스트 칸 j 에서 덮일 칸 — 글자가 있는 행의 칸 중 해시가 frac 미만인 것만, 어디서 읽을지도 해시로 정한다
    function pickBlocks(j, n, frac) {
        const list = [];
        const cy0 = Math.floor(rowTop / BLOCK_PX);
        const cy1 = Math.floor(rowBot / BLOCK_PX);
        for (let cy = cy0; cy <= cy1; cy += 1) {
            for (let cx = 0; cx < COLS; cx += 1) {
                const id = cy * COLS + cx;
                if (hash2(id, j * 97 + n * 13) >= frac) {
                    continue;
                }
                const sx = cx * BLOCK_PX + (Math.floor(hash2(id, j + 5) * 13) - 6) * BLOCK_PX;
                const sy = cy * BLOCK_PX + (Math.floor(hash2(id, j + 8) * 5) - 2) * BLOCK_PX;
                // 네 칸 중 한 칸쯤은 붉은 채널을 더해 분홍 기가 돈다
                list.push({ cx: cx, cy: cy, sx: sx, sy: sy, add: hash2(id, j + 11) < 0.25 ? 60 : 0 });
            }
        }
        return list;
    }

읽을 자리는 원래 칸에서 가로 ±6칸 세로 ±2칸 안이고 네 칸 중 한 칸쯤 붉은 채널에 60 을 더해 분홍 기가 돌며, 한 번에 덮는 칸 비율은 세기 단계에 따라 0.12 에서 0.408 까지 오르고 한 판에 터지는 횟수는 네 번이다.

05사인 물결에 색마다 위상이 다른 색수차

렌즈 느낌의 번짐을 글자에 얹을 때 쓰는 장면이다. 행마다 사인 곡선으로 가로 밀림을 만들되 붉은·초록·푸른 채널의 위상을 WAVE_PHASE 0.7 씩 어긋나게 두면 세 채널이 같은 물결을 조금씩 늦게 타서 렌즈를 지난 듯한 색 갈라짐이 번진다.

사인 물결에 색마다 위상이 다른 색수차
새 창에서 열기

05 · 사인 물결에 색마다 위상이 다른 색수차 — 측정 누적면적 13.426% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_PHASEWAVE_AMPrenderRows
        const amp = WAVE_AMP * Math.min(1.5, gainNow());
        gapNow = 0;
        if (amp > 0.05) {
            for (let y = rowTop; y <= rowBot; y += 1) {
                const ph = y * TAU / WAVE_LEN + sceneT * WAVE_RAD_S;
                dxR[y] = amp * Math.sin(ph);
                dxG[y] = amp * Math.sin(ph + WAVE_PHASE);
                dxB[y] = amp * Math.sin(ph + 2 * WAVE_PHASE);
                gapNow = Math.max(gapNow, Math.abs(dxR[y] - dxB[y]));
            }
            renderRows(rowTop, rowBot, dxR, dxG, dxB);
        }
    // 한 행을 채널마다 다른 가로 어긋남으로 읽어 tmp(길이 W×3)에 쓴다 — 어긋남이 소수여도 이웃 두 화소를 섞는다
    function rowSample(y, dR, dG, dB, tmp) {
        const o = y * W * 4;
        const d = [dR, dG, dB];
        for (let c = 0; c < 3; c += 1) {
            for (let x = 0; x < W; x += 1) {
                let sx = x - d[c];
                if (sx < 0) {
                    sx = 0;
                }
                else if (sx > W - 1.001) {
                    sx = W - 1.001;
                }
                const i0 = Math.floor(sx);
                const f = sx - i0;
                const p = o + i0 * 4 + c;
                tmp[x * 3 + c] = base[p] * (1 - f) + base[p + 4] * f;
            }
        }
    }

rowSample 은 어긋남이 소수여도 이웃 두 화소를 섞어 읽으므로 채널이 갈라지면서 부드럽게 번지며, 물결 한 주기는 64행에 시간으로 0.9초이고 세기 1 에서 붉은 채널과 푸른 채널의 최대 간격은 14.2px 다.

06글자 하나씩 비슷한 모양으로 바뀌는 깜박임

문자를 비슷한 모양으로 갈아 끼우는 변환 효과로, 변환기가 하는 일을 그림으로 보인다. 글자마다 SWAP_SET 에서 닮은 모양을 해시로 골라 정해진 시각에만 바꿔 그리는데 칸 폭이 CELL_W 66px 로 고정이라 줄 길이가 변하지 않는다.

글자 하나씩 비슷한 모양으로 바뀌는 깜박임
새 창에서 열기

06 · 글자 하나씩 비슷한 모양으로 바뀌는 깜박임 — 측정 누적면적 3.829% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWAP_SETFLICKER_MSfixedCell
    // 한 번의 바꿔치기 — 글자를 고르는 건 해시, 몇 칸을 바꿀지는 세기 단계(1·2·3칸)
    function swapNow() {
        eventN += 1;
        lastSwapT = sceneT;
        const n = setting();
        for (let k = 0; k < n; k += 1) {
            const cell = Math.floor(hash2(eventN, k + 3) * WORD.length);
            const set = SWAP_SET[WORD[cell]];
            swapGlyph[cell] = set[Math.floor(hash2(eventN, k + 41) * set.length)];
            swapAt[cell] = sceneT;
        }
    }
    function fixedCell(i, c, ox, oy, fillStyle) {
        const swapped = sceneT - swapAt[i] < FLICKER_MS / 1000 && swapGlyph[i] !== '';
        c.font = '900 ' + WORD_PX + 'px ' + WORD_FONT;
        c.textAlign = 'center';
        c.textBaseline = 'middle';
        c.fillStyle = fillStyle;
        c.fillText(swapped ? swapGlyph[i] : WORD[i], ROW_X + i * CELL_W + ox, H / 2 + 6 + oy);
    }

바뀐 글자는 FLICKER_MS 120 뒤 제자리로 돌아오는데 60fps 화면에서 7.2프레임이고, 바뀐 칸은 세 채널로 갈라 그리며 한 판에 바꿈은 일곱 번에 세기 단계마다 한 번에 1·2·3칸이 바뀐다.

07주사선과 번짐이 위아래로 굴러가는 화면

오래된 화면 같은 줄무늬와 흐림을 얹는 연출이다. 화면에 ROLL_PX 3px 간격의 어두운 줄을 깔고 밝은 글자는 옆으로 번지게 한 뒤, 굵은 띠 하나가 위에서 아래로 초속 130px 로 굴러 내려가며 그 안의 행만 크게 어긋나고 번짐이 세 배로 길어진다.

주사선과 번짐이 위아래로 굴러가는 화면
새 창에서 열기

07 · 주사선과 번짐이 위아래로 굴러가는 화면 — 측정 누적면적 17.951% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROLL_PXBLEED_PXBAND_H
    function roll() {
        bandOn = true;
        bandY = -BAND_H;
    }
    function update(dt) {
        if (!bandOn) {
            return;
        }
        bandY += BAND_SPEED * dt;
        if (bandY > H) {
            bandOn = false;
        }
    }
        for (let y = rowTop; y <= rowBot; y += 1) {
            // 띠 안의 행은 사인 가중치 w 만큼 가로로 어긋나고 번짐 길이가 길어진다
            const u = (y - bandY) / BAND_H;
            const w = !still && bandOn && u > 0 && u < 1 ? Math.sin(Math.PI * u) : 0;
            if (w > 0.05) {
                bandRows += 1;
            }
            const shift = w * 20 * LVL_MUL[lvl] * Math.sin(y * 0.4 + sceneT * 18);
            dxAll[y] = shift;
            rowSample(y, shift - 3 * w, shift, shift + 3 * w, rowTmp);
            const bleed = bleedBase + Math.round(w * bleedBase * 2);

띠는 화면의 21.3% 를 차지하고 지나가는 시간은 2.8초인데 글자 행 위는 1.28초라 그 사이만 읽기 어렵고, 어두운 줄은 3행마다 한 줄씩 0.66 배로 어둡게 하며 번짐은 왼쪽 칸들의 평균이 지금 값보다 밝을 때만 그 값으로 올라간다.

08픽셀 버퍼의 구간이 다른 곳으로 덮이는 데이터 손상

파일이 깨진 듯한 색 줄무늬 질감을 낼 때 쓰는 장면이다. 화소 버퍼를 한 줄짜리 바이트 열로 보고 COPY_LEN 1200바이트 구간을 다른 위치에서 copyWithin 으로 복사해 덮은 뒤, 일부 구간은 한 채널만 255 로 채운다.

픽셀 버퍼의 구간이 다른 곳으로 덮이는 데이터 손상
새 창에서 열기

08 · 픽셀 버퍼의 구간이 다른 곳으로 덮이는 데이터 손상 — 측정 누적면적 14.923% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COPY_LENUint8ClampedArraybyteRange
    // 바이트 구간 하나 — dst 에서 시작하는 COPY_LEN 바이트를 src 에서 읽은 값으로 덮고, ch 채널(0 붉은·1 초록·2 푸른)을 255 로 채운다
    function byteRange(dst, src, ch, flood) {
        bytes.copyWithin(dst, src, src + COPY_LEN);
        if (!flood) {
            return;
        }
        for (let p = dst + ch; p < dst + COPY_LEN; p += 4) {
            bytes[p] = 255;
        }
    }
            const k = Math.floor(sceneT / STEP_S);
            const rowBytes = W * 4;
            const rows = rowBot - rowTop + 1;
            const n = Math.max(1, Math.round(4 * Math.min(1.5, gain)));
            for (let r = 0; r < n; r += 1) {
                const row = rowTop + Math.floor(hash2(r, k * 7 + 1) * rows);
                const dst = row * rowBytes + Math.floor(hash2(r, k + 90) * (rowBytes - COPY_LEN));
                // 읽는 자리는 가까운 위 아래 행이고, 바이트가 0~3 칸 어긋나면 채널이 서로 밀린다
                const rowAt = clamp(row + Math.floor(hash2(r, k + 33) * 11) - 5, rowTop, rowBot);
                const src = clamp(rowAt * rowBytes + Math.floor(hash2(r, k + 60) * (rowBytes - COPY_LEN)) + Math.floor(hash2(r, k + 71) * 4), 0, H * rowBytes - COPY_LEN);
                // 같은 구간을 아래로 2~7행 이어 덮어 가로로 끊긴 띠가 된다
                const tall = 2 + Math.floor(hash2(r, k + 5) * 6);
                const flood = hash2(r, k + 17) < 0.5;

한 행은 1,920바이트라 1200바이트는 그 가운데 62.5% 로 이어 덮는 행이 2~7행이면 가로로 끊긴 굵은 띠가 되고, 구간 자리는 0.15초마다 해시로 정해진 곳으로만 움직이며 구간 수는 세기 단계에 따라 2·4·6곳이다.

09누르는 순간 세게 깨졌다 잦아드는 세기

클릭할 때만 번쩍 깨지는 버튼·제목에 붙이는 방식이다. 평소에는 깨끗한 글자이고 누르거나 시간표의 신호가 오면 깨짐 세기가 1 에서 시작해 1/30초마다 DECAY 0.92 배씩 줄어들다 TRIGGER_MIN 0.05 아래로 내려가면 원본으로 돌아간다.

누르는 순간 세게 깨졌다 잦아드는 세기
새 창에서 열기

09 · 누르는 순간 세게 깨졌다 잦아드는 세기 — 측정 누적면적 14.229% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DECAYTRIGGER_MINstrength
    function update(dt) {
        if (strength <= 0) {
            return;
        }
        acc += dt;
        while (acc >= TICK_S && strength > 0) {
            acc -= TICK_S;
            strength *= DECAY;
            if (strength < TRIGGER_MIN) {
                strength = 0;
            }
        }
    }
            for (let y = rowTop; y <= rowBot; y += 1) {
                const b = Math.floor(y / 5);
                // 세기가 클수록 더 많은 띠가 밀리고, 밀리는 폭도 세기에 비례한다
                const moved = hash2(b, k + hitN * 31) < Math.min(1, strength * 0.7 * m);
                const dx = moved ? (hash2(b, k + 7) - 0.5) * 2 * SHIFT_AMP * strength * m : 0;
                dxR[y] = dx - SPLIT_AMP * strength * m;
                dxG[y] = dx;
                dxB[y] = dx + SPLIT_AMP * strength * m;
            }
            renderRows(rowTop, rowBot, dxR, dxG, dxB);

띠 밀림과 색 갈라짐을 세기 값 하나에 묶었기 때문에 깨짐이 잦아들면서 두 효과가 같은 속도로 사라지며, 세기가 0 이 되기까지는 36틱 1.2초에 반감기는 0.277초, 0.5초 뒤 남은 세기는 0.286 이다.

글리치 텍스트를 코드로 만들면 글자 하나는 어떤 값 몇 개로 깨진 화면이 될까?

단어 하나는 화소 배열 하나이고 장면마다 그 배열을 읽는 값이 하나씩 더 얹힌다. 03·04·05·07·08·09 는 base 를 읽어 out 에 계산 결과를 쓰고 01·02·06 은 drawImage 와 lighter 합성으로 그린다.

한 장면을 움직이는 값들

값 만들어지는 곳 하는 일
base loadWord 가 단어를 그려 읽은 화소 배열 아홉 장면이 읽는 원본 그림
hash2(a, b) 정수 곱·시프트 띠·칸·행이 어디서 깨질지 정하는 씨앗
SPLIT_PX·SLICE_H·LUMA_CUT 01·02·03 세 장 사이 거리·띠 높이·끌림 밝기 기준
DECAY·TRIGGER_MIN 09 세기가 잦아드는 배율과 끊는 아랫값
LVL_MUL 세기 단계 0.5·1·1.7 누를 때마다 모든 장면의 세기를 곱하는 값

같은 화소를 다른 렌즈로 읽는 이웃

글리치 전환 편은 화면이 넘어가는 사이에 끼는 짧은 깨짐을 다루고 이 편은 글자 하나가 계속 깨졌다 붙는 시간표를 다룬다. 글자의 화소를 샘플해 모양을 다시 세우는 계산은 글자 픽셀 샘플링 타이포 편이 이어서 풀고, 속성만으로 글자를 움직이는 쪽은 CSS 텍스트 애니메이션 편에 남겨 둔다.

글자를 한 번 그리고, 그 픽셀 배열에서 띠나 칸을 해시로 골라 옮기고, 색 채널을 따로 얹는 — 글리치 텍스트 한 장면이 그려지는 순서

한 프레임이 지나가는 네 걸음

  1. loadWord 가 단어를 오프스크린 캔버스에 그려 base 로 읽고 글자가 있는 행 범위를 찾는다.
  2. 시간 칸 번호를 구한다. Math.floor(sceneT / STEP_S) 로 같은 칸 안에서는 같은 해시 입력이 된다.
  3. 장면마다 띠·칸·행을 골라 밀림·복사·정렬을 계산해 out 에 쓰거나 drawImage 로 그린다.
  4. putImageData 로 올리고 아래 눈금 hud 가 상수 이름과 세기 단계가 곱해진 설정값을 그린다.

이 글의 계산이 처음이라면 hash2 와 loadWord 두 함수부터 읽으면 나머지 장면이 모두 이 두 가지 위에 얹힌 것임이 보인다.

세기 단계 하나로 장면 전체가 달라진다

무대를 누르면 LVL_MUL 이 0.5·1·1.7 을 순환하는데 이 값이 장면의 상수에 곱해진다. 01 의 세 장 사이 거리는 최고점에서 단계마다 6.6·13.2·22.4px 로 벌어지고 04 의 덮는 칸 비율은 0.12·0.24·0.408 로 오르므로, 값을 바꾸는 곳은 한 곳이지만 화면은 통째로 세 배까지 달라진다.

글리치 텍스트 사이트처럼 실제 화면에 붙일 때 — 라이브러리 없이 만들기·제목에 얹는 법·장면이 바뀌는 시점

라이브러리 없이 한 캔버스에 붙이기

index.html 하나 안에 캔버스·일시정지 버튼·상태 줄이 있고 계산은 getImageData 배열과 정수 해시만 쓴다. 글리치 효과를 라이브러리로 얻으려는 사람보다 웹 제목에 직접 붙이려는 사람을 위한 코드라서 장면 시계와 깨짐 계산 두 부분만 떼어 가면 된다.

제목에 얹을 때는 원문을 숨기지 않는다

실제 제목·버튼에서는 누름·호버·로딩 끝 같은 이벤트가 오는 순간의 시각을 저장해 두고 매 프레임 경과 시간으로 세기를 다시 계산해야 프레임 간격에 기대 값을 곱하는 일이 없다. DOM 글자를 캔버스에 옮겨 그린 뒤 같은 계산을 쓰되 원문 DOM 글자는 숨기지 않고 캔버스를 aria-hidden 장식으로 겹치는 편이 안전하다.

장면이 바뀌는 시점은 시간표 한 곳에

데모의 깨짐 시각은 시간표의 (시각, 할 일) 목록이 정하는데 02 처럼 켜고 끄는 목록으로도 쓰고 04·09 처럼 그 순간 신호를 보내는 방식으로도 쓴다. 오류 상태 표현이나 로딩 연출에 붙일 때는 이 시간표 자리에 이벤트를 넣으면 장면과 읽어 주는 문장이 어긋나지 않는다.

글리치 텍스트가 깨지는 지점 — 높은 화면에서 화소 배열이 커져 느려지는 일·해시가 매 프레임 바뀌어 눈이 아픈 깜박임·글자가 읽히지 않는 구간이 길어지는 일

화면이 높으면 계산이 제곱으로 늘어난다

화소 수는 가로에 세로를 곱하므로 480x300 의 144,000 화소가 960x600 이면 576,000 개로 네 배다. loadWord 가 찾은 글자 행만 돌면 03 의 계산 행이 85행으로 줄지만 캔버스를 통째로 2배로 키우는 구조라면 배열 복사부터 무거워진다. 계산은 480x300 에서 하고 devicePixelRatio 배율은 그려진 판을 키우는 데만 쓰는 것이 이 소스의 답이다.

시간 칸이 없으면 매 프레임 다른 띠가 찢긴다

밀림을 띠 번호만으로 해시하면 장면 시계가 조금만 흘러도 입력이 바뀌어 매 프레임 다른 띠가 찢긴다. 이 편은 시간 칸 번호를 해시에 섞어 0.16초 동안은 같은 띠가 같은 자리를 유지하게 했고, 이웃 프레임 밝기 차 10% 를 넘는 화소가 화면의 25% 를 넘지 않는지가 화면을 아프게 하지 않는 선이다.

눈을 아프게 하지 않는 선

한 판을 30fps 연속 프레임으로 돌려 인접 프레임 사이 휘도차가 10% 이상인 화소 비율을 재면 최대는 09 의 10.8%(누른 첫 프레임)·02 의 10.3%·04 의 7.2% 이고, 5% 를 넘는 프레임은 02·09 에서 7장 04 에서 15장뿐이다. 전체 평균 휘도 변화는 프레임당 최대 2.9 로 좁아서 번쩍임이 아픈 수준으로 커지지 않는다.

글자가 읽히지 않는 구간이 길어지면 효과가 아니라 오류이다

07 은 띠가 글자 위를 지나는 1.28초 동안만 읽기 어렵고 09 는 1.2초 만에 잦아든다. 깨짐 세기를 계속 높게 두면 관객은 효과를 보는 대신 글자를 기다리게 되므로, 깨끗한 구간과 깨진 구간의 비율을 시간표로 정해 두는 것이 이 편의 설계다.

아홉 장면의 완성 소스와 React 판은 아래 zip 에 들어 있고, 풀 때 ejerp5wd 을 입력하면 열린다.

색 갈라짐 폭과 띠 높이와 끌림 기준을 바꾸면 느낌은 얼마나 달라질까 — SPLIT_PX·SLICE_H·LUMA_CUT 을 바꿔 본 차이

이름 바꾼 값 느낌
색 갈라짐을 더 벌려 01 SPLIT_PX 6 → 16 세 겹 사이가 10.4px 에서 27.7px 로 벌어져 획 굵기(27~32px)와 비슷해지고, 낮추면 가장자리만 살짝 물든다
띠를 더 가늘게 02 SLICE_H 5 → 2 글자가 걸친 띠가 20개에서 50개로 늘고 한 순간 찢기는 띠도 평균 9.3개에서 23.1개로 늘어 가는 물결처럼 보이며, 키우면 굵은 층이 계단처럼 어긋난다
끌림 기준을 낮춰 03 LUMA_CUT 120 → 60 밝기 111 쯤인 그림자 글자까지 끌려 대상 화소가 18,196개에서 24,314개로 34% 늘고, 올리면 위쪽의 가장 밝은 획만 길게 늘어난다

값을 바꾸기 전에 볼 것

01 의 세 겹 사이 거리는 SPLIT_PX 에 √3 을 곱한 값이라 16 으로 올려도 겹침 폭이 획 두 개를 넘지 않고, 02 는 띠를 가늘게 해도 찢기는 비율 46% 와 밀림 상한 30px 는 그대로라 결만 가늘어진다. 03 은 기준을 내리면 그림자 겹까지 끌려 화면이 한층 흐려지는 방향이므로 깨끗한 글자와의 대비가 줄어드는 값을 감수해야 한다.

움직임 줄이기에서는 깨끗한 글자 한 장 — 장면 이름과 지금 깨짐 세기를 문장으로 읽어 주는 접근성

움직임 줄이기 설정이면 원문 한 장만 그린다

prefers-reduced-motion 이 켜지면 루프를 시작하지 않고 글리치 없는 원문 단어 한 장을 그린다. 설정을 페이지가 열린 채 바꿔도 change 리스너가 다시 읽어 바로 한 장으로 바뀐다.

깨진 글자를 낭독하지 않는다

캔버스 aria-label 에는 깨진 그림이 아니라 원문 단어와 장면 설명이 들어 있다. 화면 상태 줄은 숫자가 프레임마다 바뀌어도 aria-live 가 늘 off 로 읽히지 않고, speakLine 은 role="status" 문단에 쓰되 모드가 바뀌는 깨짐 시작·끝과 사람이 누르거나 멈추거나 처음부터를 눌렀을 때만 문장을 낸다.

키보드로 멈추고 처음부터 다시 본다

무대는 role="application" 에 tabindex 가 0 이라 포커스를 받으면 Enter·Space·P 가 일시정지, R 이 처음부터다. 오른쪽·위 방향키·T·무대 누르기는 한 번 깨뜨리며 세기 단계를 한 칸 올리고, 누르고 있으면 눌린 만큼만 깨진 뒤 풀린다.

숨은 탭에서는 멈춘다

탭이 숨으면 루프를 멈추고 돌아오면 첫 프레임의 시간 차이를 버려 DT_MAX 0.05초 상한을 넘는 한 프레임이 생기지 않는다.

글리치 텍스트 FAQ — 라이브러리 없이 만들기·글자 변환기와의 차이·웹에 올릴 때 필요한 것

글리치 텍스트 만들기를 라이브러리 없이 할 수 있나요

할 수 있다. 캔버스 2D 의 getImageData·putImageData·drawImage 와 requestAnimationFrame 만으로 아홉 장면이 돌아가고 외부 스크립트는 없다.

글리치 텍스트 변환기와 이 코드는 다른 건가요

변환기는 글자를 닮은 기호로 바꿔 복사하게 내주는 물건이고 이 코드는 글자 그림의 화소를 시간표에 맞춰 옮겨 화면에서 깨지게 하는 물건이다. 06 이 변환기가 하는 바꿔치기를 그림으로 보여 주는 장면이고 나머지 여덟은 화소 단위로 깨뜨리는 장면이다.

웹에 올릴 때 필요한 것이 있나요

스크립트는 index.html 한 파일 안에 들어 있고 fetch 나 외부 주소 호출이 없어 따로 준비할 서버 쪽 일은 없다. 계산은 480x300 화소에서 하고 devicePixelRatio 배율은 상한 2 로 잘라 쓰므로 배율이 높은 기기에서 화소 계산이 늘지 않는다.

화소 배열 읽기는 MDN 의 getImageData 문서, 합성 모드는 globalCompositeOperation 문서, 애니메이션 루프는 requestAnimationFrame 문서, 움직임 줄이기는 prefers-reduced-motion 문서가 기준이다.

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

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