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

落ち葉 アニメーション 9種 — 揺れ・風・色づき・積もる山まで

落ち葉 アニメーションをページに敷くとき、最初につまずくのは落ち方です。葉をまっすぐ下に落とすと小石のように見えます。そこで01番は傾きから揚力を生んで横へすべらせ、05番は揺れがたまった付け根を切り、06番は斜面が急になった落ち葉の山を崩します。九つの場面は写真を使わず、葉の一枚一枚を計算で描いています。

枝を離れた葉が地面に横たわるまで — 風・光・色・山をそれぞれ計算した場面

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

九つの場面は、72点の輪郭と数本の葉脈から葉を2Dキャンバスに描いていて、写真や画像ファイルは一枚も使いません。乱数の種を固定しているので、開くたびに同じ配置と同じ風がひとりでに再生されます。盤に手を置くと別の道が開き、01はポインターから80px以内に160px/sの横風を、03は70px以内に220px/sの突風を起こし、07ではほうきが決めておいた道を外れてポインターについてきます。キャンバスの座標は480×300のままで、実際の画素数は画面の画素比を上限2まで掛けた値になります。

秋キャンペーンのファーストビューと季節イベントのバナー

秋キャンペーンのランディングの最初の画面には、09がいちばん合います。手前の層の葉は大きくくっきり、奥の層の葉は小さくぼけて落ちるので、上に見出しを載せると文字の後ろに奥行きが生まれます。横に細い季節イベントのバナーには、回りながら明るさを変える02の八枚が収まります。落ち葉 css で探してここに来た方は、スタイルシートが受け持つのは舞台の大きさと枠だけで、葉そのものはキャンバスの上で計算していることを先に知っておいてください。やわらかい背景が欲しいなら、三枚がジグザグに降りるだけの01がいちばん静かです。

ページ下の飾りから天気の画面まで

ページ下部の季節の飾りには06が向いています。葉が降りるたびに盛り土が育ち、わきが急になると横へ崩れるので、ページの下の細い帯一本で秋の場面がひとつ完結します。天気アプリの風の画面には突風の帯が通る03、清掃サービスの紹介にはほうきの07、秋のあいさつカードには葉を一枚ずつ落とす05、イベントの始まりにはつむじ風で葉を巻き上げる08、季節で表情を変える商品紹介には一枚の葉が色づく04を置けます。

紅葉 アニメーション で葉がまっすぐ落ちると、なぜ小石のように不自然に見えるのか?

葉は薄くて広いので、落ちるあいだ体がどれだけ傾いているかに応じて、空気から横向きの力を受けます。まっすぐ下へ落とすアニメーションにはこの横の力がなく、重い物と同じ道筋を速さだけ変えてなぞることになります。CSSのパーティクル背景の記事ではキーフレームで決めた道を粒が進み、落ちる文字の記事では文字を葉のように風で揺らしましたが、ここの01は傾きから力を求めるので、道筋が計算の結果として出てきます。

傾きから生まれる横すべり

迎え角 α は、葉の面と向かってくる空気のなす角です。揚力は |sin α·cos α| に比例するので 45° でいちばん大きく、葉が平らに寝たときと刃のように縦に立ったときには 0 になります。傾いた葉はすべりながら速さを増し、揚力は u² を掛けるので速いほど強くなります。01の三枚では揚力の最大が 997・994・934、迎え角は最大 88〜90° まで振れます。

すべりを揺れに変える、傾きを戻す回転の力

すべるだけなら、葉は片側へ流れていってしまいます。01は傾きの sin に RIGHTING_K 14 を掛けた回転の力で葉を水平へ引き戻し、SPIN_DAMP 1.5 で回転を弱めます。戻る途中で sin α の符号が変わると揚力も逆を向き、横の速さの向きが 4〜5回入れ替わってジグザグになります。揚力の係数を 0.6 に下げると横の振れ幅は ±1.6〜5.5px に縮んでほぼまっすぐ落ち、まっすぐの落下がなぜ不自然なのかを反対側から見せてくれます。

傾きは揚力に、風はノイズに、色は葉脈からの距離に — 葉一枚を動かす計算

九つの場面は、四つの計算の束を分け合っています。揺れの束は01・05・09、付け根のばねは05・09、風は03・05・09、山は06・09が使い、02・04・07・08は自分のコードだけで動きます。どの束もその瞬間の葉の状態に反応するので、前もって決めた道をなぞる動きとはそこが違います。「落ち葉降らせる」で検索してたどり着いた方は、揺れと風の二つの束、それに04自身の計算を数字で追ってみてください。

揚力は sin 2α に、抵抗は sin²α に従う

抵抗は AIR_K 0.075 × u² × (0.1 + sin²α) で、葉が空気を正面から受けるほど大きくなります。揚力は同じ u² に LEAF_LIFT_GAIN 1.6 と |sin α·cos α| を掛けます。|sin α·cos α| は ½·|sin 2α| と等しいので、揚力の山は抵抗がいちばん大きくなる 90° の半分、45° に来ます。抵抗は空気の速さと逆向き、揚力はそれに垂直で、01の抵抗の最大は 482・480・456 と揚力の最大のおよそ半分です。

場所ごとに違う風と、横切っていく帯

03の風はなめらかなノイズで、整数の格子に値を置いて三次の曲線でつなぎます。WIND_SCALE が 0.007 なので格子一つを渡るのに計算上 1/0.007 ≈ 143px かかり、WIND_DRIFT 0.4 が時間とともに風の模様を横へずらします。横の風は基本の 34 にノイズ × 46 を足したもので、突風の帯の中心は 時刻 × GUST_SPEED を「盤の幅 + 帯の幅6つ分 + 間隔 420」で割った余りに沿って動くため、左から右へ何度も横切ります。シャボン玉の記事の漂いもノイズの風に乗りますが、この場面が違うのは帯のほうで、画面を横切る前線がその中の葉だけを押します。

葉脈からの距離が色づく順番を決める

04は葉の中のピクセルごとに、いちばん近い葉脈の線分までの距離を測って最大値で割り、0〜1 にそろえます。そのうえで 0.66 × 距離 + 0.34 × まだらのノイズを、そのピクセルが色づく順番にします。順番の値は 0.01〜0.881 に広がり、下がっていくしきい値がそれを越えていくので、ふちが先に越えて先に色を変えます。ジェネラティブアートの記事は描きながら葉脈を伸ばしましたが、この場面は葉脈を先に固定し、そこまでの距離だけを使います。

落ち葉 アニメーション 9種 — 揚力の揺れ・三軸の回転・突風の帯・色づき・付け根の切れ・崩れる山・ほうきの風・つむじ風・秋の木

デモはどれも 2秒動かして 24枚をとらえ、となり合う二枚の差 23 個から変化した面積と強さを求めました。盤全体に 140枚が流れる03が 71.5% でいちばん広く、六つの付け根が揺れてから葉を落とす05が 8.5% でいちばん狭くなっています。06だけが 22/23 で、ほかの八つは 23/23 のフレームで画面が変わりました。

場面ごとの変化面積・動いたフレーム・強さ

場面 変化した面積(累積) 動いたフレーム 強さの平均
01 揚力の揺れ 13.5% 23/23 91.5
02 三軸の回転 29.3% 23/23 70.0
03 突風の帯 71.5% 23/23 83.6
04 色づき 22.3% 23/23 29.1
05 付け根の切れ 8.5% 23/23 67.5
06 崩れる山 12.9% 22/23 84.6
07 ほうきの風 31.2% 23/23 54.2
08 つむじ風 20.8% 23/23 51.2
09 秋の木 34.9% 23/23 69.1

オーロラ背景の記事では光の粉が速度の場に乗って流れましたが、08のつむじ風も同じく速度の場から動きを作ります。違うのは、中心のまるごとの回転・外側の弱まり・上へ引く風を足して、葉を立体のらせんに持ち上げるところです。

01傾きで横へすべりながら揺れる葉

三枚の葉が別々の傾きから出発し、迎え角から生まれた揚力に横へ押され、傾きを戻す回転の力に引き戻されながらジグザグに降りてきます。下の flutterStep は葉の速さから風を引いて空気の速さを出し、それを葉の面の向きと法線の向きに分けて抵抗と揚力を別々に求めていて、一枚目の葉は二つの力を矢印で描きます。元の関数をそのまま動かすと、三枚は出発点から左へ 26.6〜28.9px・右へ 22.6〜34.2px 振れ、3,872〜4,912ms に地面に着きます。

傾きで横へすべりながら揺れる葉
新しいタブで開く

01 · 傾きで横へすべりながら揺れる葉 — 計測 累積面積 13.519% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LEAF_LIFT_GAINRIGHTING_KTWIST_GAIN
    function flutterStep(f, windX, windY, dt, liftGain) {
        const rx = f.vx - windX;
        const ry = f.vy - windY;
        const u2 = rx * rx + ry * ry;
        const u = Math.sqrt(u2);
        let ax = 0;
        let ay = GRAVITY;
        let torque = -RIGHTING_K * Math.sin(f.tilt);
        f.lift = 0;
        f.drag = 0;
        if (u > 1e-6) {
            const tx = Math.cos(f.tilt);
            const ty = Math.sin(f.tilt);
            const nx = -ty;
            const ny = tx;
            const cosA = (rx * tx + ry * ty) / u;
            const sinA = (rx * nx + ry * ny) / u;
            f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
            const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
            const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);

02三つの軸で裏返り、表と裏の明るさが変わる葉

八枚の葉がそれぞれ三つの軸で回り、遠近の倍率 FOCAL_LENGTH / (FOCAL_LENGTH + z) で縮んだり大きくなったりし、裏返って法線の z が正になると、自分の色とクリームを半分ずつ混ぜた裏の色に切り替わります。下の部分は輪郭の72点を画面へ移したあと、見えている面の法線と LIGHT_DIR の内積に 0.68 を掛けて 0.32 を足した値を明るさにしていて、光は左上の手前から来ます。6秒のあいだに葉は一枚ごとに表と裏を 6〜8回入れ替え、遠近の倍率は 0.757〜1.232 を行き来します。

三つの軸で裏返り、表と裏の明るさが変わる葉
新しいタブで開く

02 · 三つの軸で裏返り、表と裏の明るさが変わる葉 — 計測 累積面積 29.32% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOCAL_LENGTHLIGHT_DIRTUMBLE_RATES
        function toScreen(u, v) {
            const q = apply(m, u * half, v * half, 0);
            const z = cz + q[2];
            const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
            return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
        }
        for (let i = 0; i < outline.length; i += 1) {
            pts.push(toScreen(outline[i][0], outline[i][1]));
        }
        const veins = VEINS[p.kind].map(function (s) { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
        depth = cz;
        const n = apply(m, 0, 0, -1);
        // 法線の z が負なら表面が目のほうを向く。見えている面の法線で光と内積をとる
        const front = n[2] < 0;
        const s = front ? 1 : -1;
        const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
        const base = front ? p.color : backColor(p.color);
        const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
        const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
        return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };

03突風の帯が通り過ぎて葉の群れをさらう風

140枚の葉がなめらかなノイズで決まる場所ごとの風に従い、桃色の突風の帯が通るあいだだけ、その中の葉が強く押されて一方へさらわれます。gustWeight は帯の中心で 1、GUST_WIDTH の 64px 離れると 1/e になる釣り鐘形の幅で、windAt はそれに GUST_GAIN 300 を掛けて横の風に足し、縦の向きでは葉を少し持ち上げます。盤の中ほど x 200 に置いた試験の葉は帯の中に 1,232ms とどまり、横の速さが 275px/s まで上がって 2.8秒で 329px 進みます(突風がなければ 61.7px)。

突風の帯が通り過ぎて葉の群れをさらう風
新しいタブで開く

03 · 突風の帯が通り過ぎて葉の群れをさらう風 — 計測 累積面積 71.459% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GUST_SPEEDGUST_WIDTHWIND_COUPLING
    // 釣り鐘形の幅 — 帯の中心で 1、GUST_WIDTH だけ離れると 1/e
    function gustWeight(x, center) {
        const k = (x - center) / GUST_WIDTH;
        return Math.exp(-k * k);
    }
    // 位置 (x, y)・時刻 t(秒)の風(px/s) — out[0] は横、out[1] は縦(下が +)
    function windAt(x, y, t, center, out) {
        const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
        const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
        const g = gustWeight(x, center);
        out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
        // 突風の中は少し上へ持ち上げる
        out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
    }

04葉脈から遠いふちから色づく紅葉

五つに裂けた葉一枚が 4,200ms かけて、葉脈からいちばん遠いふちから緑・黄・橙・赤へと色づいていき、まだらのノイズが境目をざらつかせます。下の paintField はフレームごとにしきい値 th = 1 − p·(1 + RAMP_WIDTH) を一つだけ動かし、前もって求めた各ピクセルの順番の値と比べて段階の色を選びます。葉の中の 35,352 ピクセルのうち最初に赤くなりきるのは 1,796ms、最後は 4,371ms で、葉脈に近い 1,338 ピクセルは順番の平均が 0.18 なので遅れて色づきます。

葉脈から遠いふちから色づく紅葉
新しいタブで開く

04 · 葉脈から遠いふちから色づく紅葉 — 計測 累積面積 22.259% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RAMP_WIDTHBLOTCH_MIXFIELD_SCALE
    function paintField(p) {
        if (!fieldCv || !fieldImg) {
            return;
        }
        const d = fieldImg.data;
        const th = 1 - p * (1 + RAMP_WIDTH);
        for (let i = 0; i < insideIdx.length; i += 1) {
            const s = clamp((turnOrder[i] - th) / RAMP_WIDTH, 0, 1);
            const c = mixRGB(rampColor(s), INK, 0.34 * veinNear[i]);
            const o = insideIdx[i] * 4;
            d[o] = c[0];
            d[o + 1] = c[1];
            d[o + 2] = c[2];
            d[o + 3] = 255;
        }
        rampCompares += insideIdx.length;
        lastCompares = insideIdx.length;
        const g = fieldCv.getContext('2d');
        if (g) {
            g.putImageData(fieldImg, 0, 0);
        }
    }

05揺れがたまると付け根が切れて落ちる葉

枝から六枚の葉がぶら下がって突風の帯が左から右へ通るたびに順に曲がり、付け根のまわりの輪は限界に対する疲れの割合で満ちて、80% を超えるとメープルの色に変わります。stemStep は硬さ STEM_K 70・減衰 STEM_DAMP 2.2 の回転のばねを積分して曲がった角度の二乗を疲れとしてため、それが STEM_LIMITS にあるその付け根の限界を越えると、葉はそのときの速さのまま離れて01の flutterStep で落ちます。x の順に付け根が切れる時刻は 1,120・7,280・1,728・2,128・2,304・8,240ms で、最初の突風で四枚、次の突風で二枚が落ちます。

揺れがたまると付け根が切れて落ちる葉
新しいタブで開く

05 · 揺れがたまると付け根が切れて落ちる葉 — 計測 累積面積 8.544% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEM_KFATIGUE_RATESTEM_LIMITS
    function stemStep(s, windX, dt) {
        if (s.snapped) {
            return false;
        }
        const bend = s.ang - s.rest;
        const torque = -Math.sin(s.ang) * windX * STEM_WIND;
        s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
        s.ang += s.angVel * dt;
        s.fatigue += bend * bend * FATIGUE_RATE * dt;
        if (s.fatigue >= s.limit) {
            s.snapped = true;
            return true;
        }
        return false;
    }

06斜面が急になると崩れる落ち葉の山

地面を 12px のマス 40個に分け、最初から 160枚が積もっているところへ x 236 のあたりに毎秒 80枚ずつ 440枚が降りそそぎ、てっぺんの両側には限界の傾きを示す点線が引かれます。下は heapRelax の本体で、マスごとに(高さの差 × LEAF_THICK 2.6)÷ マスの幅が限界を越えるたびにいちばん上の葉を急なほうのとなりへ移し、移った葉は 260ms かけて元の場所から新しい場所へすべって見えます。降りそそぎが終わると 600枚は 1,571回の崩れを経て高さ 124.8px・幅 300px の盛り土になり、残ったとなりとの傾きの最大は 0.867 と、限界 0.9 のすぐ下に収まります。

斜面が急になると崩れる落ち葉の山
新しいタブで開く

06 · 斜面が急になると崩れる落ち葉の山 — 計測 累積面積 12.862% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REPOSE_SLOPECELL_WPOUR_RATE
                const h = hp.cols[c].length;
                let best = -1;
                let bestDrop = 0;
                for (let k = 0; k < 2; k += 1) {
                    const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
                    if (nb < 0 || nb >= n) {
                        continue;
                    }
                    const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
                    if (drop > slope && drop > bestDrop) {
                        best = nb;
                        bestDrop = drop;
                    }
                }
                if (best < 0) {
                    continue;
                }
                const leaf = hp.cols[c].pop();
                leaf.fromX = heapSlotX(hp, c, leaf);
                leaf.fromY = heapSlotY(hp, h - 1);
                leaf.slideAge = 0;
                hp.cols[best].push(leaf);
                moved += 1;
                changed = true;

07ほうきの風が通って落ち葉を掃き散らす庭

上から見た庭を 150枚の葉が覆い、ほうきが二度曲がる 530px の道を 170px/s で進んで、66px 以内の葉を押しのけます。重み w = (1 − d/R)² は半径の半分で 0.25 に下がり、押す力は外向き 0.55 と進む向き 0.45 を混ぜたもので、進む向きと外向きの外積によって横から打たれた葉ほど強く回ります。2秒で 59枚、道の終わりまでに 102枚が打たれ、打たれた葉は平均 34.6px 動いて、回転の速さは最大 9.2rad/s になります。

ほうきの風が通って落ち葉を掃き散らす庭
新しいタブで開く

07 · ほうきの風が通って落ち葉を掃き散らす庭 — 計測 累積面積 31.231% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BROOM_RADIUSPUSH_GAINSPIN_GAIN
        for (let i = 0; i < yard.length; i += 1) {
            const lf = yard[i];
            const dx = lf.x - broomX;
            const dy = lf.y - broomY;
            const d = Math.hypot(dx, dy);
            if (d < BROOM_RADIUS && mv > 0.01) {
                const w = (1 - d / BROOM_RADIUS) * (1 - d / BROOM_RADIUS);
                const ax = d > 0.01 ? dx / d : headX;
                const ay = d > 0.01 ? dy / d : headY;
                // 押す向き = 外向きとほうきの進む向きを混ぜたもの
                const jx = (0.55 * ax + 0.45 * headX) * w * PUSH_GAIN * dt;
                const jy = (0.55 * ay + 0.45 * headY) * w * PUSH_GAIN * dt;
                lf.vx += jx;
                lf.vy += jy;
                // 進む向きと外向きの外積 — 横腹を打たれた葉ほど強く回る
                lf.spin += (headX * ay - headY * ax) * w * PUSH_GAIN * SPIN_GAIN * dt;
                lf.hop = Math.min(1, lf.hop + w * 4 * dt);
                if (!lf.hit) {
                    lf.hit = true;
                    hits += 1;
                }
            }

08つむじ風に巻かれてらせんに舞い上がり散る葉

120枚の葉がつむじ風の中心へ引き寄せられてらせんに舞い上がり、1,600ms の弱まりのあいだに外へ散って降ります。tangential は半径 30 の内側で 4.2·r のまるごとの回転、外側で 4.2·30²/r なので速さは境目でちょうど最大の 126px/s になり、上昇気流 UPDRAFT 420 には exp(−r/70) と (1 − 高さ/LOFT_TOP) が掛かるため、高さ 230 に近づくほど弱まります。2秒の時点で 63枚、3,696ms には 120枚すべてが浮かび、すべてが降りたあとには平均半径が 96.8px から 122.6px に広がります。

つむじ風に巻かれてらせんに舞い上がり散る葉
新しいタブで開く

08 · つむじ風に巻かれてらせんに舞い上がり散る葉 — 計測 累積面積 20.78% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CORE_RADIUSUPDRAFTINFLOW
    function tangential(r) { return r < CORE_RADIUS ? SPIN_RATE * r : SPIN_RATE * CORE_RADIUS * CORE_RADIUS / r; }
    function step(dt) {
        t08 += dt * 1000;
        strength = swirlStrength(t08);
        const s = strength;
        for (let i = 0; i < swirlers.length; i += 1) {
            const p = swirlers[i];
            const onGround = p.h <= 0 && p.vh <= 0;
            // 地面に寝た葉は風が十分に強いときだけ動く
            const grip = onGround ? clamp((s - 0.15) / 0.3, 0, 1) : 1;
            const vt = tangential(p.r) * s * grip;
            p.th += vt / Math.max(p.r, 4) * dt;
            const lift = UPDRAFT * s * Math.exp(-p.r / LIFT_REACH) * clamp(1 - p.h / LOFT_TOP, 0, 1);
            p.vh += (lift - SWIRL_SINK) * dt * 2;
            p.vh -= p.vh * 1.4 * dt;
            p.h += p.vh * dt;
            if (p.h < 0) {
                p.h = 0;
                p.vh = 0;
            }

09奥行きごとに大きさとぼけが変わる秋の木の場面

葉は手前・中・奥の三層に分かれて、大きさは 1・0.62・0.38、ぼかしは 0・0.5・1.6、速さは 1・0.72・0.45 になり、木から切れた葉は風に乗って山まで降りて、空いた場所には新しい葉が掛けられます。下の growTree は種を 47990 に固定した再帰の二股分かれで、深さ 6・開き 0.44rad・長さの比 0.74 から枝 63本と先端 32個ができ、木は画面の外で一度だけ描くので 750フレーム後も treeDraws は 1 のままです。2秒で 6枚、12秒で 14枚が切れ、そのとき山には 84枚が積もっています。

奥行きごとに大きさとぼけが変わる秋の木の場面
新しいタブで開く

09 · 奥行きごとに大きさとぼけが変わる秋の木の場面 — 計測 累積面積 34.949% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LAYER_SCALELAYER_BLURBRANCH_DEPTH
    function growTree() {
        segs = [];
        tips = [];
        const rng = makeRng(47990);
        function branch(x, y, ang, len, depth, w) {
            const x1 = x + Math.cos(ang) * len;
            const y1 = y + Math.sin(ang) * len;
            segs.push({ x0: x, y0: y, x1: x1, y1: y1, w: w });
            if (depth >= BRANCH_DEPTH) {
                tips.push([x1, y1]);
                return;
            }
            const j = (rng() - 0.5) * 0.18;
            branch(x1, y1, ang - BRANCH_SPREAD + j, len * BRANCH_SHRINK, depth + 1, w * 0.68);
            branch(x1, y1, ang + BRANCH_SPREAD + j, len * BRANCH_SHRINK * 0.94, depth + 1, w * 0.68);
        }
        branch(TREE_X, TREE_GROUND, -Math.PI / 2, TRUNK_LEN, 1, 12);
    }

揚力を弱く、突風を速く、山の斜面を低く — 数字一つで変わる秋の風

どの行も、ソースの定数を一つだけ変えて同じ関数をもう一度動かした結果です。真ん中の行は見直す価値があり、突風をほぼ倍に速めると葉はかえって押されにくくなります。帯の中に乗っていられる時間が 1,232ms から 368ms に縮むからです。

名前 変えた値 感じ
揚力を弱く 01 LEAF_LIFT_GAIN 1.6 → 0.6 揚力の最大は 934〜997 から 54〜160 に下がり、横の振れ幅は左 26.6〜28.9px・右 22.6〜34.2px から ±1.6〜5.5px に縮んで、葉はほぼまっすぐ落ちます。着地は 3,872〜4,912ms から 3,776〜4,560ms とわずかしか動きません
突風を速く 03 GUST_SPEED 220 → 420 盤の中ほどの試験の葉は最高 275px/s ではなく 177px/s にとどまり、2.8秒の移動は 329px から 195.7px に減ります。帯が戻る間隔は 5,836ms から 3,057ms に縮み、一度に帯の中にいる葉は最大 104枚から 61枚になります
山の斜面を低く 06 REPOSE_SLOPE 0.9 → 0.4 斜面は 42° から 21.8° に寝て、600枚のてっぺんは 124.8px から 62.4px と半分になり、幅 300px だった山が盤の端の 480px まで広がります。崩れは 1,571回から 3,416回と倍以上に増えます

葉が数百枚に増えたとき・タブから戻った直後に跳ぶ葉・高精細な画面での色づけ — 落ち葉が壊れやすい場面

葉が数百枚に増えたとき

葉ごとに輪郭の72点を経路につなぎ、葉脈まで引く作業を一歩ごとに繰り返すと、03だけで 2秒(16ms の歩み 125回)に 17,500回の描画になります。02・04を除く七つの場面は、葉の形 3 × 色 5 の 15枚のスプライトを最初に一度だけ画面の外で描き、そのあとは drawImage で新しい位置・倍率・回転・縦のつぶれを付けて押すだけです。その2秒に押した回数は03が 17,500、06が 28,190、07が 18,750、08が 15,000、09が 17,375 で、測ったこの五つの場面では、スプライトを新しく描いた回数はどれも 0 でした(09はぼかしの段階を含めて 35枚)。葉の数にも上限があり、03の 140枚は盤を出ると反対の端へ戻されて 2秒で 42回使い回され、09は落ちた葉の場所に新しい葉を掛けます。葉の数の上限とスプライトの使い回し、大きさを固定した距離の場を入れたままのプレーン JavaScript の九つのソースは、コンポーネント版と一緒に一つの zip にまとめてあり、fxr3gktp を入力すると開きます。

タブから戻った直後に跳ぶ葉

タブを隠して戻ると、最初のフレームと前のフレームの時刻の差が数秒に広がることがあります。下の frame はその空白を DT_MAX の 100ms までしか蓄えに足さず、1フレームに 16ms の歩みを最大 6回だけ回します。

    function frame(ts) {
        if (paused || !alive) {
            rafId = 0;
            return;
        }
        if (lastTs < 0) {
            lastTs = ts;
        }
        // dt の上限 — 1フレームに DT_MAX までしか積まない。隠れていたタブが戻って間隔が数秒あっても葉が瞬間移動しないように
        acc += Math.min(ts - lastTs, DT_MAX);
        lastTs = ts;
        let n = 0;
        while (acc >= FIXED_DT && n < MAX_STEPS) {
            clock += FIXED_DT;
            if (clock >= loopLength()) {
                clock = 0;
                build();
            }
            step(STEP_S);
            acc -= FIXED_DT;
            n += 1;
        }
        render();
        rafId = window.requestAnimationFrame(frame);
    }

16ms 間隔で 40フレームを流したあと、5秒空いたフレームを一つ入れてみます。上限があればそのフレームが計算するのは 96ms だけで、01の一枚目の葉は 7px 動きます。上限がないと 5,008ms を一度に回すので葉は 220.4px 瞬間移動し、三枚とももう地面に着いています。06はもっと大きく崩れ、上限があればそのフレームで降らせるのは 8枚・着地は 0枚ですが、上限がなければ 401枚を降らせて 331枚が着地し、1,069回の崩れが一つのフレームに詰め込まれます。そのためこの場面は、隠れたときに visibilitychange でループを止め、戻ったら startLoop が時計の基準を取り直します。

高精細な画面での色づけ

04の距離の場をキャンバスの画素に合わせると、480×300 に比 2 を掛けて 576,000 ピクセルになり、固定した 300×244 の盤(73,200 ピクセル)の、計算上 576,000/73,200 ≈ 7.9倍です。この盤は場の大きさを画面の比から切り離し、葉の中の 35,352 ピクセル × 葉脈 5本 = 176,760回の距離計算を、下の二重の繰り返しで buildField の中で一度だけ行います(作り直しても fieldBuilds は 1 のままです)。

        for (let y = 0; y < FIELD_H; y += 1) {
            for (let x = 0; x < FIELD_W; x += 1) {
                if (!insidePoly(x + 0.5, y + 0.5, outline)) {
                    continue;
                }
                let best = 1e9;
                for (let s = 0; s < veins.length; s += 1) {
                    distanceChecks += 1;
                    const d = segDist(x + 0.5, y + 0.5, veins[s][0], veins[s][1]);
                    if (d < best) {
                        best = d;
                    }
                }
                idx.push(y * FIELD_W + x);
                dist.push(best);
                if (best > maxD) {
                    maxD = best;
                }
            }
        }

そのあと毎フレーム行うのは、先の paintField のとおり 35,352回のしきい値の比較だけなので、距離を毎フレーム測り直す 176,760回と比べて回数は五分の一です。

動きを減らす設定では着地した葉で止める — 一時停止ボタンと場面説明のアクセシビリティ

場面を言葉で伝える説明

九つのキャンバスにはどれも role="img" と、その場面を言葉にした aria-label が付いているので、スクリーンリーダーは絵の代わりに文を読み上げます。その文は文言ファイルに三つの言語で入っています。

キーボードでも押せる一時停止

「一時停止」は aria-pressed を持つネイティブの <button> なので、Tab で届き、Enter か Space で押せます。止めているあいだは背景が濃いインクの色に、文字がクリームの色に反転し、フォーカスが来ると :focus-visible の 2px の輪郭が出ます。

動きを減らす設定で残るもの

prefers-reduced-motion が reduce のとき、九つの場面は一時停止の状態で始まり、ループなしで最後の一枚だけを描きます。01は軌跡を残して着地した三枚、02は地面に寝かせた八枚、03は床の帯に休む 140枚、04は色づき終えた葉、05は地面に落ちた六枚、06は崩れが止まったあとの山、07はほうきが道の終わりまで通ったあとの庭、08は渦が収まって地面に降りた葉、09は 6秒動かした山と、漂っていた葉を寝かせた場面です。再生を押せば動きは最初から始まり、ページを開いたまま設定を変えてもそれを読み取ります。

秋のページ背景や季節のバナーに落ち葉を敷くときのよくある質問

落ち葉 動画 素材の代わりにこのコードを使うと何が変わりますか

この盤は開くたびに固定した種から同じ場面を計算し直し、ポインターが来ると01と03は風を足して、07のほうきはそのあとを追います。動きを減らす設定を読んで止まった一枚を見せ、一時停止ボタンもあるので、同じフレームを繰り返す映像より画面ごとの事情に合わせやすいと考えられます。

葉をもっと多く散らしても画面は持ちこたえますか

02・04を除く場面では葉一枚の描画がスプライトを一回押すだけなので、測った五つの場面は2秒で 15,000〜28,190回押しても新しく描いたスプライトは 0 のままです。ただ押す回数は葉の数とともに増えるので、03のように数を固定し、盤を出た葉を反対側から使い回すのが安全な道と考えられます。

葉の色を自分のサイトに合わせて変えるにはどこを触ればいいですか

五色の葉は LEAF_COLORS にある五つの RGB 配列で、02だけは backColor がそれぞれをクリームと半分ずつ混ぜて裏の色を作ります。スプライトを押す場面では、スプライトは形・色・ぼかしを鍵にして最初に使うときに一度だけ描かれるので、新しい色を足しても手間は変わりません。04の色づきの色だけは、緑からメープルまでの四段の TURN_RAMP に別に置かれているので、そこを単独で直します。

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

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