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

css starfield 9パターン — 星が奥行きに沿って流れる宇宙の背景

css starfield を点数個の CSS アニメーションだけで作ると奥行きがなく速さも一定で平らになる — 01番・02番・05番が遠近投影・層ごとの移動量・指数曲線で解く。九つの場面はすべてキャンバスに直接描いた星なので、圧縮ファイル一つを開けば動きます。

九つの星の背景をこのページで飛んでみる

自動再生 · タイルを押すとその項目へ · すべて1つのzip

css starfield 9パターン — 奥行き・回転・屈折を計算して星が生きる

まず断っておくと、この記事の九つの場面は CSS のキーフレームではなく、キャンバス2D と JavaScript で描いた星です。CSS が受け持つのは480×300のキャンバスの枠、キャプションとボタンの置き場、フォーカスの輪郭、touch-action: none までです。星が何百個も、それぞれ自分の距離で割った位置に打たれて初めて奥行きが出るので、その割り算はキーフレームに書き込むより、フレームごとにコードで計算するほうが筋に合います。

九つのファイルは start() を共通にしていて、16ミリ秒(FIXED_DT)刻みで scene.step() を呼び、ポインターが2.4秒(IDLE_TICKS の150)届かないと、場面ごとに決めた自動の曲線へ戻ります。乱数は Math.random ではなく種を持つ lcg なので、再読み込みしても星は同じ位置に並び、種は場面の番号に沿って450101から450909まで振ってあります。

場面 受ける入力 核になる計算
01 ワープの筋 ポインター=消失点、タップ=速さの噴出 奥行き z で割る投影
02 三層の視差 ポインター=視差の向き、タップ=流れの反転 層の深さで割った移動量
03 ノイズの夜空 ポインターの横=ノイズの原点、タップ=種に1を足す 二重の値ノイズ
04 色温度 ポインター=消失点、タップ=回転方向の反転 視線角のコサイン
05 ジャンプ タップ=ジャンプを最初から 2の冪乗の加速曲線
06 天球 ポインター=首振り、タップ=60°回す yaw・pitch の回転行列
07 星座 ポインター=星一つ、タップ=速さの引き直し 格子マスの隣接探索
08 流れ星 タップ=ポインターの位置から一発 位置の履歴と重力
09 重力レンズ ポインター=レンズの中心、タップ=質量を1.9倍 点質量のレンズ方程式

05はポインターの動きを読まず、08はタップしたときのポインターの位置だけを使います。Math.random・Date・fetch といった呼び出しはソースを grep すると0件で、ライブラリ・CDN・WebGL もないため、星は外部のスクリプトなしで描かれます。

css starfield background を技法別のコードで見る

コード片は各場面の index.html から取った連続する行で、一片が25行を超えることはありません。最初から最後まで通したファイル全体は、後ろに出てくる圧縮ファイルに入っています。

01近づくほど長く伸びるワープの星の筋

星ごとに (x, y, z) を持たせ、画面の位置は消失点の vx・vy に x·FOV÷z を足して決めます。筋は今の z と speed·STREAK_LEN だけ奥の z という二つの投影点を結んだ線なので速さがそのまま長さになり、z が Z_NEAR の24を割るか画面の外へ80px はみ出した星は Z_FAR の1000へ置き直されます。測定は累積面積57.2%、動いたフレームは23/23です。

近づくほど長く伸びるワープの星の筋
新しいタブで開く

01 · 近づくほど長く伸びるワープの星の筋 — 計測 累積面積 57.23% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOVSPEEDSTREAK_LENSTAR_COUNT
                for (let i = 0; i < stars.length; i += 1) {
                    const s = stars[i];
                    s.z -= speed;
                    const sx = vx + s.x * FOV / s.z;
                    const sy = vy + s.y * FOV / s.z;
                    if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
                        place(s, true);
                    }
                }
                for (let i = 0; i < stars.length; i += 1) {
                    const s = stars[i];
                    const k = 1 - s.z / Z_FAR;
                    const zb = Math.min(s.z + speed * STREAK_LEN, Z_FAR * 1.3);
                    ctx.strokeStyle = rgba(s.c, 0.25 + 0.75 * k);
                    ctx.lineWidth = 0.6 + 2.6 * k * k;
                    ctx.beginPath();
                    ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
                    ctx.lineTo(vx + s.x * FOV / s.z, vy + s.y * FOV / s.z);
                    ctx.stroke();
                }

02奥行きの層ごとに違う速さで流れる三重の星

層の移動量は px·PARALLAX÷深さ + drift÷深さ で、深さが4・2・1の三層なので、ポインターを端まで押すと手前の層は70px、いちばん奥の層は17.5px ずれます。半径は DEPTH_SCALE÷深さ^0.8、明るさは 0.35 + 0.65÷√深さ で小さく暗くなり、にじみの光は深さ1の層にだけ付きます。測定は累積面積52.6%、動いたフレームは23/23です。

奥行きの層ごとに違う速さで流れる三重の星
新しいタブで開く

02 · 奥行きの層ごとに違う速さで流れる三重の星 — 計測 累積面積 52.597% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LAYER_COUNTPARALLAXDRIFTDEPTH_SCALE
                for (let li = 0; li < LAYER_COUNT; li += 1) {
                    const d = LAYER_DEPTH[li];
                    const ox = px * PARALLAX / d + drift / d;
                    const oy = py * PARALLAX * 0.6 / d;
                    const ms = mists[li];
                    for (let i = 0; i < ms.length; i += 1) {
                        glow(ctx, wrap(ms[i].x - ox, W + 160) - 80, wrap(ms[i].y - oy, H + 120) - 60, ms[i].r, ms[i].c, 0.07 + 0.05 / d);
                    }
                    const dots = layers[li];
                    const alpha = 0.35 + 0.65 / Math.sqrt(d);
                    for (let i = 0; i < dots.length; i += 1) {
                        const s = dots[i];
                        const sx = wrap(s.x - ox, W);
                        const sy = wrap(s.y - oy, H);
                        if (d === 1) {
                            glow(ctx, sx, sy, s.r * 4, s.c, 0.3);
                        }
                        ctx.fillStyle = rgba(s.c, alpha);
                        ctx.beginPath();
                        ctx.arc(sx, sy, s.r, 0, PI2);
                        ctx.fill();
                    }
                }

03ノイズの場が星ごとに違う瞬きを与える夜空

星360個の明るさは、ゆっくりした値ノイズ(座標に NOISE_SCALE の0.011を掛けたもの)と速い値ノイズ(座標に0.5を掛けたもの)を0.65対0.35で混ぜ、CONTRAST の2.6乗にした値なので、近い星はまとまって、遠い星は別々に瞬きます。奥の霧は60×38ピクセルの画像を同じノイズで埋めて480×300へ引き伸ばしたもので、九つのうちこの場面だけが createImageData を使います。測定は累積面積69.4%、動いたフレームは23/23です。

ノイズの場が星ごとに違う瞬きを与える夜空
新しいタブで開く

03 · ノイズの場が星ごとに違う瞬きを与える夜空 — 計測 累積面積 69.392% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOISE_SCALETWINKLE_SPEEDCONTRASTSEED
    // 格子点の値 — 整数二つとシードを混ぜて 0 以上 1 未満にする
    function hash2(ix, iy, seed) {
        let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263) + Math.imul(seed | 0, 1442695041);
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
    }
    // 二次元の値ノイズ — 四つの格子点をなめらかに補間する
    function vnoise(x, y, seed) {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = smooth(x - ix);
        const fy = smooth(y - iy);
        return lerp(lerp(hash2(ix, iy, seed), hash2(ix + 1, iy, seed), fx), lerp(hash2(ix, iy + 1, seed), hash2(ix + 1, iy + 1, seed), fx), fy);
    }
                    // 遅い場(隣が似る)と速い場(星ごとに違う)を混ぜて明るさを決める
                    const slow = vnoise(s.x * NOISE_SCALE + ox * 0.05, s.y * NOISE_SCALE + phase, seed);
                    const fast = vnoise(s.x * 0.5 + phase * 7, s.y * 0.5, seed + 1);
                    const v = slow * 0.65 + fast * 0.35;
                    const b = clamp(Math.pow(v * 1.6, CONTRAST), 0, 1);

04速さで青に寄り、縁で橙に伸びる星の光

消失点から画面上で距離 r にある星の視線角のコサイン FOV÷√(r²+FOV²) を色温度 k に入れるので、中央は青白く、離れるほど橙へ傾きます。k は COLOR_TEMP の0.5を起点に SHIFT_GAIN·(cosA − CONE_COS)·(0.5 + 速度比) だけ動き、速度比が正弦曲線で上下するため、偏りも呼吸するように変わります。測定は累積面積62.4%、動いたフレームは23/23です。

速さで青に寄り、縁で橙に伸びる星の光
新しいタブで開く

04 · 速さで青に寄り、縁で橙に伸びる星の光 — 計測 累積面積 62.376% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COLOR_TEMPSHIFT_GAINHEADINGCONE_COS
    // 0 は橙、0.5 は白、1 は青白
    function temperature(k) {
        return k < 0.5 ? mix(WARM, WHITE, k * 2) : mix(WHITE, COOL, (k - 0.5) * 2);
    }
                    const sx = vx + s.x * FOV / s.z;
                    const sy = vy + s.y * FOV / s.z;
                    const r = Math.hypot(sx - vx, sy - vy);
                    // 視線と星の方向のあいだの角度のコサイン — 消失点から遠いほど小さい
                    const cosA = FOV / Math.hypot(r, FOV);
                    const k = clamp(COLOR_TEMP + SHIFT_GAIN * (cosA - CONE_COS) * (0.5 + ratio), 0, 1);
                    const c = temperature(k);

05止まった状態から光速へ飛び込むジャンプ曲線

一周期は待機18・加速72・閃光24・到着56ティックの合計170ティック(2.72秒)で、加速区間の速さは BASE_SPEED から MAX_SPEED まで (2^(JUMP_EASE·p) − 1)÷(2^JUMP_EASE − 1) の割合で上がります。筋の長さも同じ speed から出し、閃光の透明度も FLASH·(1 − q)² という一本の式に結ばれています。測定は累積面積75.4%、動いたフレームは23/23です。

止まった状態から光速へ飛び込むジャンプ曲線
新しいタブで開く

05 · 止まった状態から光速へ飛び込むジャンプ曲線 — 計測 累積面積 75.433% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

JUMP_EASEMAX_SPEEDFLASHHOLD
    // t ティック時点の速さ。上がるときだけ 2 の冪乗の曲線で、残りはなめらかな復帰
    function speedAt(t) {
        if (t < HOLD) {
            return BASE_SPEED;
        }
        if (t < HOLD + RAMP) {
            const p = (t - HOLD) / RAMP;
            return BASE_SPEED + (MAX_SPEED - BASE_SPEED) * (Math.pow(2, JUMP_EASE * p) - 1) / (Math.pow(2, JUMP_EASE) - 1);
        }
        if (t < HOLD + RAMP + FLASH_TICKS) {
            const q = (t - HOLD - RAMP) / FLASH_TICKS;
            return lerp(MAX_SPEED, BASE_SPEED, smooth(q));
        }
        return BASE_SPEED;
    }
    function flashAt(t) {
        const q = (t - HOLD - RAMP) / FLASH_TICKS;
        if (q < 0 || q > 1) {
            return 0;
        }
        return FLASH * (1 - q) * (1 - q);
    }

06球面上の星を回転行列で回して見る空のドーム

星900個を球面上に均等に、空を横切る明るい帯の星520個を傾いた大円の周りに集めておき、project が yaw の行列と pitch の行列を順に掛けてから W/2 + FOV·x÷z で点を打ちます。z が NEAR_CUT の0.08以下の点は視野の外なので描かず、緯線5本と経線10本は点だけで描いて、回る向きが見えるようにしています。測定は累積面積56.9%、動いたフレームは23/23です。

球面上の星を回転行列で回して見る空のドーム
新しいタブで開く

06 · 球面上の星を回転行列で回して見る空のドーム — 計測 累積面積 56.859% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

YAW_RATEPITCHFOVSTAR_COUNT
        function project(x, y, z, cy, sy, cp, sp) {
            const x1 = x * cy + z * sy;
            const z1 = -x * sy + z * cy;
            const y2 = y * cp - z1 * sp;
            const z2 = y * sp + z1 * cp;
            return [W / 2 + FOV * x1 / z2, H / 2 - FOV * y2 / z2, z2];
        }
                for (let i = 0; i < stars.length; i += 1) {
                    const s = stars[i];
                    const p = project(s.x, s.y, s.z, cyw, syw, cp, sp);
                    if (p[2] <= NEAR_CUT || p[0] < -6 || p[0] > W + 6 || p[1] < -6 || p[1] > H + 6) {
                        continue;
                    }
                    const r = 0.5 + 1.9 * s.m * s.m * s.m + (i < STAR_COUNT ? 0.3 : 0);
                    const a = 0.35 + 0.65 * clamp(p[2] * 1.2, 0, 1);
                    if (r > 1.5) {
                        glow(ctx, p[0], p[1], r * 5, s.c, 0.35 * a);
                    }
                    ctx.fillStyle = rgba(s.c, a);
                    ctx.beginPath();
                    ctx.arc(p[0], p[1], r, 0, PI2);
                    ctx.fill();
                }

07近い星同士が線でつながる星座

星110個とポインターになる星一つを、GRID_CELL(LINK_RADIUS と同じ72px)のマスへ番号で入れておき、各星は自分のマスを囲む3×3マスだけを調べて、距離が72未満の相手に線を引きます。線の透明度は LINE_ALPHA·(1 − d÷LINK_RADIUS)^1.3 で、一つの星につく線は3本(ポインターは6本)までです。測定は累積面積56.2%、動いたフレームは23/23です。

近い星同士が線でつながる星座
新しいタブで開く

07 · 近い星同士が線でつながる星座 — 計測 累積面積 56.17% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LINK_RADIUSMAX_LINKSGRID_CELLLINE_ALPHA
                for (let i = 0; i < nodes.length; i += 1) {
                    nodes[i].n = 0;
                    const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
                    const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
                    cells[cy * cols + cx].push(i);
                }
                    for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
                        for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
                            const list = cells[gy * cols + gx];
                            for (let q = 0; q < list.length; q += 1) {
                                const j = list[q];
                                const b = nodes[j];
                                if (j <= i || a.n >= cap) {
                                    continue;
                                }
                                const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
                                if (b.n >= capB) {
                                    continue;
                                }
                                const d = Math.hypot(a.x - b.x, a.y - b.y);
                                if (d >= LINK_RADIUS) {
                                    continue;
                                }
                                a.n += 1;
                                b.n += 1;
                                const k = 1 - d / LINK_RADIUS;
                                ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));

08尾が薄れながら曲がって落ちる流れ星

流れ星はティックごとに位置を履歴配列の先頭へ入れ、TRAIL_LEN の44個の節までを残して、節の透明度は (1 − 年齢)^1.6、太さは 6·(1 − 年齢) + 0.8 です。縦の速さに毎ティック GRAVITY の0.06を足して経路を曲げ、背景を透明度0.8でしか塗り直さないので残光が残ります。測定は累積面積24.5%、動いたフレームは23/23です。

尾が薄れながら曲がって落ちる流れ星
新しいタブで開く

08 · 尾が薄れながら曲がって落ちる流れ星 — 計測 累積面積 24.526% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAIL_LENMETEOR_RATEANGLEGRAVITY
                for (let i = meteors.length - 1; i >= 0; i -= 1) {
                    const m = meteors[i];
                    if (m.live) {
                        m.hist.unshift(m.x, m.y);
                        m.vy += GRAVITY;
                        m.x += m.vx;
                        m.y += m.vy;
                        if (m.x > W + 70 || m.y > H + 70) {
                            m.live = false;
                        }
                    }
                    else {
                        m.hist.pop();
                        m.hist.pop();
                    }
                    if (m.hist.length > TRAIL_LEN * 2) {
                        m.hist.length = TRAIL_LEN * 2;
                    }
                    if (!m.live && m.hist.length === 0) {
                        meteors.splice(i, 1);
                    }
                }
                    for (let k = 0; k < n; k += 1) {
                        const age = k / TRAIL_LEN;
                        const a = Math.pow(1 - age, 1.6);
                        const c = mix(HEAD, TAIL, age);
                        const hx = m.hist[k * 2];
                        const hy = m.hist[k * 2 + 1];
                        ctx.strokeStyle = rgba(c, 0.22 * a);
                        ctx.lineWidth = (7 * (1 - age) + 1) * 2.2;
                        ctx.beginPath();
                        ctx.moveTo(px, py);
                        ctx.lineTo(hx, hy);
                        ctx.stroke();
                        ctx.strokeStyle = rgba(c, a);
                        ctx.lineWidth = 6 * (1 - age) + 0.8;
                        ctx.beginPath();
                        ctx.moveTo(px, py);
                        ctx.lineTo(hx, hy);
                        ctx.stroke();
                        px = hx;
                        py = hy;
                    }

09ブラックホールの近くで星の光が輪に曲がる重力レンズ

星がレンズの中心から b 離れているとき、像は (b + √(b²+4M))÷2 と反対側の (√(b²+4M) − b)÷2 の二か所に結ばれ、LENS_MASS が2600なら b が0の星は半径√2600の約51px の輪に広がります。背景の絵には触れず星の座標だけを動かし、SOFTEN の5は b が0のとき倍率の式が0で割られるのを防ぎます。測定は累積面積30.9%、動いたフレームは23/23です。

ブラックホールの近くで星の光が輪に曲がる重力レンズ
新しいタブで開く

09 · ブラックホールの近くで星の光が輪に曲がる重力レンズ — 計測 累積面積 30.934% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LENS_MASSRING_RADIUSFALLOFFSOFTEN
        function image(ctx, bx, by, r, c, m, alpha) {
            const b = Math.hypot(bx, by);
            const bs = Math.sqrt(b * b + SOFTEN * SOFTEN);
            const root = Math.sqrt(bs * bs + 4 * m);
            const thP = (bs + root) / 2;
            const thM = (root - bs) / 2;
            const u = bs / Math.sqrt(m);
            const mag = (u * u + 2) / (2 * u * Math.sqrt(u * u + 4));
            const muP = clamp(mag + 0.5, 1, 9);
            const muM = clamp(mag - 0.5, 0, 9);
            const ux = b > 0.001 ? bx / b : 1;
            const uy = b > 0.001 ? by / b : 0;
            const ang = Math.atan2(uy, ux) + Math.PI / 2;
            const stretchP = clamp(thP / bs, 1, 6);
            ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muP, FALLOFF) * 0.8, 0.3, 1));
            ctx.beginPath();
            ctx.ellipse(cx + ux * thP, cy + uy * thP, r * stretchP, r * 0.8, ang, 0, PI2);
            ctx.fill();
            if (muM > 0.04) {
                const stretchM = clamp(thM / bs, 0.4, 6);
                ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muM, FALLOFF) * 0.8, 0, 0.9));
                ctx.beginPath();
                ctx.ellipse(cx - ux * thM, cy - uy * thM, r * Math.max(stretchM, 0.8), r * 0.7, ang, 0, PI2);
                ctx.fill();
            }
        }

ヒーローとローディングとつながりの背景のどこに星を敷くか

場面ごとに合う置き場所が違います。ランディングの最初の画面やヒーローには01のワープと06の天球が合い、ポインターやスクロールに合わせて奥行きを感じさせたい背景には02の三層の視差、止まった夜空に息を吹き込みたいときは03、宇宙やSFの色づかいを合わせたいときは04です。ローディングやイントロの切り替えには05のジャンプが170ティックの一周期で回るので、HOLD と RAMP を読み込みの時間に合わせて伸ばせばよく、ネットワークやつながりを語る背景は07、催しや雰囲気の背景は08、画面の中で視線を集めたい一点は09が受け持ちます。

star field animation を背景に敷くときは、場面ごとに一フレームでする仕事が違う点を先に見ます。06は星1420個と緯線・経線の点690個で、一フレームに project を2110回呼び、03は vnoise を霧の2280ピクセルに3回ずつ、星360個に2回ずつ呼ぶので7560回になります。この記事ではフレームの時間を測っていないので、本文の後ろに敷く場面は自分の画面で先に動かしてみてください。

同じサイトの別のキャンバス記事と重なる部分もあります。星どうしを引き合わせる側は 銀河 シミュレーション 9種 — ぶつかり合う星が渦巻腕と潮汐尾を作る へ続き、この記事の09は星を動かさずレンズだけを計算します。html5 canvas アニメーション9種 — 水滴とブラックホール の01と07は getImageData でピクセルを読みますが、ここの九つのファイルにはその呼び出しが0件で、動かすのは星の座標だけです。スクロールに沿って流れる粒の背景は パーティクル 背景 9選 — スクロール値で流れが変わるキャンバスシーン が扱います。

値が速さの感じを変えるところ — 速さ・つなぐ半径・レンズの質量

値を一つ変えるだけで場面が変わる所が三か所あります。01の SPEED、07の LINK_RADIUS、09の LENS_MASS で、下の数字は同じソースを偽の2Dコンテキストで200ティック回したあとの一フレームから数えた、ノードのシミュレーションで測った値です。ブラウザーの画面で直接測った数値とは区別して読んでください。

速さを二倍にすると筋は二倍以上に伸びる

SPEED が12のとき筋の線分は280本で、平均の長さは5.0px です。24に上げても線分は280本のままで、平均の長さは11.4px になり、速さが二倍のとき筋は二倍を超えて伸びます。

つなぐ半径を広げても線の数はほとんど増えない

LINK_RADIUS が72のとき引かれた線は158本で、平均の長さは47.3px です。110に広げると線は161本でほぼ同じまま、平均の長さだけが80.7px へ伸び、星ごとに3本という MAX_LINKS の上限が数を押さえていると分かります。

質量が二倍なら輪の半径は約1.41倍

LENS_MASS が2600のとき輪の半径は51px で、描かれた像は454個です。5200に上げると半径は72px、像は510個になり、半径が √M なので質量が二倍で約1.41倍になる式と合います。

レンダリングで測った九つの場面の値は、次の表にまとめました。動いたフレームは九つとも23/23で、累積面積がいちばん小さいのは08の24.5%と09の30.9%、いちばん大きいのは05の75.4%と03の69.4%です。

項目 累積面積 平均の強度 動いたフレーム
01 ワープの筋 57.2% 47.3 23/23
02 三層の視差 52.6% 49.8 23/23
03 ノイズの夜空 69.4% 23.0 23/23
04 色温度 62.4% 35.4 23/23
05 ジャンプ曲線 75.4% 65.1 23/23
06 天球 56.9% 50.1 23/23
07 星座 56.2% 41.8 23/23
08 流れ星 24.5% 68.4 23/23
09 重力レンズ 30.9% 83.1 23/23

どこで壊れるか — 星の数が多いとき・狭い画面・動きを減らす設定

近づいた星の筋が画面を横切る

01・04・05は星が近づくほど x·FOV÷z が大きくなります。01で SPREAD が760の星が Z_NEAR の24まで来ると、計算上は消失点から約6017px 外に打たれますが、画面の外へ80px はみ出した星を place(s, true) で戻す条件がそれを防いでいます。その条件と Z_NEAR の検査を外すと、線一本が一フレームを食うことがあります。04と05にも同じ見張りがあり、Z_NEAR は22と20、はみ出しの許容は80pxと100pxです。

層を押し込むと星が反対側から飛び出す

02は移動量が深さでしか割られないので、PARALLAX を大きくすると手前の層の星が一度に W を越え、wrap で反対側から現れます。流れの向きを反転するとき時間に掛けると跳びが出るので、累積変数 drift に dir を掛けて足しています。

種を替えると空が一フレームで塗り替わる

03の tap は seed に1を足し、ハッシュをまるごと替えるので、隣り合う種の空は似ません(補間ではありません)。霧は60×38を引き伸ばしたもので、devicePixelRatio が2だと縦横とも約16倍に伸びてにじんで見え、毎フレーム createImageData を一度呼びます。

視線角を画面上の距離で近似しているので縁までの話になる

04は r が240の縁でも、コサインは0.53(約58°)までしか下がりません。視線から90°を超える向きは扱わない近似なので、色の偏りは画面の縁までに収まります。コサインが0.72(CONE_COS)になるのは消失点から約145px の地点で、そこが白い光です。

ジャンプを押すと閃光の途中でも切れる

05は JUMP_EASE が6で p が0.5のとき、上がる幅の11%((2³−1)÷(2⁶−1) = 0.111)しか上がらず、速さは約9.2で、最大の70には遠いままです。そのため上がる区間の大半は止まって見え、終盤で急に上がります。tap はどの区間でも t を HOLD へ戻すので、閃光の最中に押すと突然切れます。

広角なので縁の星が伸びる

06の FOV 150は、縁で星が伸びて見える広角です。一周は2π÷0.012で約524ティック(8.4秒)、yaw は積み上がる値なので、放っておくと大きな数の三角関数を呼び続けます。動きを減らした静止画面では、点の格子がうっすら見えます。

線が残る側が番号で決まる

07は星の番号の順に先に線を引いた側が MAX_LINKS の枠を埋めるので、星が動くとどの線が残るかが番号に左右され、線が瞬くように入れ替わることがあります。GRID_CELL を LINK_RADIUS より小さくすると、隣のマスの外にいる相手を見落とします。

残光が真っ黒に戻らない

08は背景を透明度0.8でしか塗り重ねないので、暗い残像が8ビットの丸めの限界で残ります。尾を unshift で先頭へ入れる配列なので TRAIL_LEN を大きくするとティックごとのコピーが増え、丸い線の端が重なって尾が数珠のように見えます。

タップで質量が増えても中心の穴は変わらない

09の中心の暗い穴は定数 RING_RADIUS の0.3倍(約15px)で描かれるため、タップで質量が1.9倍になっても穴は同じ大きさのままで、輪だけが育ちます。RING_RADIUS は Math.sqrt(LENS_MASS) の定数なので、ソースの LENS_MASS を書き換えると穴も一緒に大きくなります。輪が育つと外側の像が元の位置から遠ざかり、縁の星が画面の外へ出ていきます。

狭い画面と自分で確かめる方法

キャンバスは aspect-ratio の480 / 300で幅だけが縮み、340px 以下ではキャプション・ボタン・状態の文字が10px に小さくなります。ここまでの境界は、ソースの定数を一つずつ変えていけば自分で探せます。圧縮ファイルはパスワード 2yqn4mvf を入れると開くので、中の index.html で SPREAD・PARALLAX・LINK_RADIUS・LENS_MASS の値を書き換えて試してください。

変形三つと、動きを減らす設定のアクセシビリティ

名前 値 感じ
速さを変える 01 SPEED 12 → 24 筋の平均の長さが5.0px から11.4px に伸び、中心の星が一瞬で縁へ広がります。
つなぐ半径を変える 07 LINK_RADIUS 72 → 110 線は158本から161本とほぼ同じなのに、平均の長さが47.3px から80.7px へ伸びて遠い星ともつながります。
レンズの質量を変える 09 LENS_MASS 2600 → 5200 輪の半径が51px から72px へ育ち、描かれた像は454個から510個に増えて、星の光が中心からいっそう遠くへ押されます。

表の数字は三行とも、ブラウザーではなくノードのシミュレーションで測った値です。

prefers-reduced-motion が有効なとき、九つのファイルはどれも requestAnimationFrame のループを始めず、各ファイルが自分の場面を、始まるときに advance(1400) で88ティック先へ進めてから一枚だけ描きます。ボタンは「次の場面」に変わり、押すたびに REDUCE_STEP_MS の900ミリ秒(56ティック)ぶん進めて一度描き、ポインター・キー・タップの入力は refresh() が6ティック(96ミリ秒)回して、すぐ画面に反映します。

キャンバスは tabindex が0でフォーカスを受け、Enter と Space は tap()、矢印キーは仮想ポインターを KEY_STEP の14px ずつ動かします。この六つのキーだけが preventDefault を呼び、Tab を含む残りのキーはそのまま通ります。状態の文字は、場面が決めた文言が変わったときだけ aria-live="polite" の領域を書き換えます。

css starfield animation は CSS キーフレームだけで足りるか?

点が数個その場で瞬くか、一方向に流れるだけなら足ります。星が近づいてくる感じ、層ごとに違う速さ、球の上で回る空は、星ごとに別々の値を計算しなければならず、そこが星の背景を計算へ渡す分かれ目です。そこから先は javascript starfield animation の領域で、枠だけを CSS が持ち、星はコードが描くことになります。

境目の基準は、星が自分の距離や隣を知る必要があるかどうかです。星が互いの位置を知らなくてよい飾りなら CSS で足り、01のように星ごとに z で割る、07のように近い星を探して結ぶという場面ならコードが合います。この記事のコードは経過時間を16ミリ秒のティックに区切って進めるので、描画が遅れても星の動きは経過時間に沿い、一度に取り戻すのは最大160ミリ秒ぶんまでです。

それでも CSS は枠を受け持ちます。この記事の style.scss はキャンバスを aspect-ratio で整え、touch-action: none でポインターのドラッグがスクロールへ漏れないようにしていて、場面の中の星はすべてキャンバスの内側で計算されます。

javascript starfield を作る前によくある質問

星の背景でまず変える値はどれですか?

01なら SPEED と STAR_COUNT です。速さの感じが足りなければ SPEED から上げ、筋が長すぎれば STREAK_LEN を減らします。この二つは独立しているので、一つずつ変えるとどちらの影響かが見えます。星の数の定数は場面ごとに STAR_TOTAL・NODE_COUNT などと名前が違いますが、数字一つで印象が変わり、減らせば画面も軽くなります。

星の色はどこで変えますか?

STAR_RGBS の五つの値(青みの白・杏・ライラック・ミント・ピンク)と背景の BG で選びます。04は WARM・WHITE・COOL の三つを別に使い、08は HEAD・TAIL で尾の色を決めます。濃い色へ替えるときは BG との明るさの差を一緒に見て、07の線のように二つの星の色を混ぜる所は、両方を揃えて替えないとずれます。

ライブラリや画像は必要ですか?

要りません。九つのファイルは外部のアドレスを呼ばず、星をすべてキャンバス2Dで描くので、ファイル一つを移すだけで動きます。

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

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