GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

グリッチ テキスト 9種 — 色の分離から行の裂けと画素のにじみまで

グリッチテキストは文字をどうやって壊すのでしょうか。02番は横の帯をハッシュでずらして行の裂けを作り、03番は明るい画素を引いてにじませ、09番は押した瞬間に強く崩れて一つの値で落ち着きます。グリッチ テキストの九つの場面は、単語を一度描いた画素の配列を時間表に合わせて動かし混ぜるコードで、デモと一緒に解きます。

グリッチテキストは文字をわざと壊して描くことではなく、文字の絵の画素を時間表に合わせて動かし混ぜる計算です — グリッチテキストを作るには、どの値をいつどれだけずらすかを決めることから始まります

自動再生 · タイルを押すとその項目へ · すべて1つの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は一つの判で差し替えが7回なので6/23で、05と07は波と帯が止まらないので23/23です。

01文字を赤・緑・青の三枚に分けてずらす

文字の縁に色のにじみを出す、いちばん少ないコードの方法です。紙の色から背景色を引いた値のうち一つのチャンネルだけを持つ文字の絵を三枚作り、lighter 合成で重ねると、重なった所は元の色に戻って縁だけが割れます。

文字を赤・緑・青の三枚に分けてずらす
新しいタブで開く

01 · 文字を赤・緑・青の三枚に分けてずらす — 計測 累積面積 18.401% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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フレームにあたり、入れ替わったマスは三つのチャンネルに割って描かれ、一つの判の差し替えは7回で、一回に替わるのは強さの段階で1・2・3マスです。

07走査線とにじみが縦に転がる画面

古い画面のような縞とぼかしを重ねる演出です。画面に ROLL_PX の3px間隔の暗い線を敷いて明るい文字は横へにじませ、太い帯が一本、毎秒130pxで上から下へ転がり落ちて、その中の行だけが大きくずれてにじみが三倍の長さになります。

走査線とにじみが縦に転がる画面
新しいタブで開く

07 · 走査線とにじみが縦に転がる画面 — 計測 累積面積 17.951% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 グリッチ テキストのようにプロパティだけで文字を動かす側は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秒のあいだは同じ帯が同じ位置を保つようにしました。

目を痛めない線

一つの判を30fpsの連続フレームで回し、隣り合うフレームの間で輝度差が10%以上になった画素の割合を測ると、最大は09の10.8%(押した最初のフレーム)・02の10.3%・04の7.2%で、5%を超えるフレームは02と09が7枚、04が15枚だけです。どのフレームも画面の25%を超えず、全体の平均輝度の変化はフレームあたり最大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のドキュメントが基準です。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。