falling-leaves について
2D Canvasや3Dシーンで、葉と認識できる落下表現を作るデザインスキルです。回転、空気に沿う横移動、奥行きのレイヤー、ライフサイクル、モーション軽減への対応を扱います。
主な機能
- 01葉がエッジになる回転
- 02回転角に連動する横滑り
- 03表裏で異なるスプライト
- 04葉ごとの動きのばらつき
- 05画面に応じた再配置と密度
- 06ライフサイクルとモーション軽減対応
活用シーン
- 012D Canvasで葉と分かる落下表現を作成
- 023Dシーンにレイヤー状の落葉を追加
- 03画面サイズに合わせて葉の密度を調整
- 04モーション軽減用の静止画を構成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mengto/ skills --skill falling-leavesインストール後、Claude Code で/falling-leavesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamldemo/index.htmlPROMPT.md詳細
舞い落ちる葉
舞い落ちるものが葉に見えるようにします。汎用的な粒子が漂う領域を作りたい場合は、ambient-section-particles を使用してください。形を葉だとはっきり認識できる必要がある場合は、こちらを使用してください。
まず、ひらひらと回転する動きを作る
葉を長軸を中心に回転させ、表を見せた後、真横を向いて薄くなり、反対側で再び広がるようにします。ほとんど見えなくなる瞬間を、目は「葉」と認識します。どれほど絵がよくできていても、画面の平面上でしか回転しないスプライトは、紙吹雪やコイン、紙切れのように見えてしまいます。
2Dキャンバスでは、タンブルをゼロをまたぐ水平方向のスケールで表現します。
ctx.save();
ctx.translate(l.x, l.y);
ctx.rotate(l.roll); // long axis drifting in-plane
ctx.scale(Math.cos(l.spin), 1); // the tumble: cos crosses 0, edge-on
ctx.globalAlpha = l.alpha;
ctx.drawImage(sprite, -w / 2, -h / 2, w, h);
ctx.restore();
1つではなく、2つの軸を動かします。roll は画面内で葉を回転させ、spin は画面の平面を横切るように葉を回転させます。葉ごとにそれぞれ異なる速度を設定します。同じ速度にすると、どれほど滑らかにイージングしても、動きが機械的に見えてしまいます。
3Dでは、ポイントスプライトではなく四角形をインスタンス化します。ポイントスプライトは常にカメラの方を向き、カメラに背を向けられないため、真横を向くこともありません。この制約ひとつで、実装全体の方針が決まります。
横滑りとタンブルを連動させる
タンブルと同じ角度を使い、位相を90度ずらして横方向の動きを制御します。葉は空気を切るように真横を向いたときに横滑りし、表面を平らに向けたときに動きが止まります。
l.x += Math.sin(l.spin) * l.slip * dt; // fastest when cos(spin) ≈ 0
l.y += l.fall * dt;
x に独立したサイン波を加えないでください。風のように見えたり、イージングの不具合のように見えたりします。この連動には項を1つ追加するだけで済み、軌道に空気力学的な印象を与えます。
表裏の両方を用意する
色ごとに表裏2種類のスプライトを用意し、タンブルの符号に応じて切り替えます。
const img = Math.cos(l.spin) < 0 ? sprite.back : sprite.face;
裏面は表面よりもくすんだ、淡い色にします。そうしないと、葉は回転する平面的な切り抜きのように見えます。表裏の色を変えることで、表と裏のある立体物に見えます。これは、このシステムで最も手軽にリアリティを高める方法です。
葉ごとにすべてのパラメーターを変える
葉の生成時に、落下速度、タンブル速度、ロール速度、横滑り量、位相、スケール、不透明度をランダム化します。どれか1つでも全体で共有すると、葉の群れではなく、画面を流れ落ちるテクスチャになってしまいます。目はおよそ2秒で共通のリズムを見つけます。
葉をどこから再出現させるかを決める
実際に画面上に表示される葉の数は、これで決まります。
2Dの場合、ビューポートをまたいで再配置します。葉が画面下端を通過したら、新しいランダムな x 座標で画面上端から再出現させます。風で片側が空にならないよう、x 座標も折り返します。
3Dの場合、カメラの周囲ではなく、カメラの前方で再配置します。カメラを中心とした帯状の領域では、体積のほとんどが視錐台の背後や横に位置します。画角36°のカメラでは、葉を数百枚配置しても、フレーム内に入るのは十数枚程度です。代わりに、カメラの視線に沿って下方に配置した円盤内へ落とせば、同じ数でも何倍も多く表示されます。
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
const cx = camera.position.x + fwd.x * AHEAD;
const cz = camera.position.z + fwd.z * AHEAD;
const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * SPREAD;
l.x = cx + Math.cos(a) * r; l.z = cz + Math.sin(a) * r; l.y = camera.position.y + 16;
カメラが自身の天候領域の外へ移動した場合に備えて、奥側での折り返しも用意し、何もないところから突然現れて見えないよう、霧の範囲より十分外側に設定します。
数ではなく帯の面積で密度を決める
画面上の密度は、枚数 ÷ 帯の面積で決まります。枚数を増やす前に、帯を狭めてください。
- 再配置範囲の半径を半分にすると、同じ枚数でも画面上の密度は4倍になります。
- 枚数を2倍にすると、画面上の密度が同じだけ増える場合でも、描画コストは2倍になります。
設定済みの枚数をそのまま使うのではなく、ビューポートの面積に応じて調整してください。そうしないと、デスクトップではそよぐように見える設定が、スマートフォンでは吹雪のようになってしまいます。
const k = clamp(Math.sqrt((W * H) / (1440 * 900)), .5, 1.3);
const n = Math.round(authored * layerShare * k);
奥行きを出すためにレイヤーを重ねる
それぞれスケール、速度、不透明度、ぼかしを変えたレイヤーを2〜3層使います。
| レイヤー | スケール | 落下速度 | 不透明度 | 備考 |
|---|---|---|---|---|
| 遠景 | 0.3–0.5 | 遅い | 0.22–0.40 | 最初に描画し、コンテンツの背後に配置できます |
| 中景 | 0.5–0.85 | 中程度 | 0.46–0.78 | エフェクトの中心となる層です |
| 近景 | 1.05–1.9 | 速い | 0.50–0.82 | 枚数は少なめにし、必要に応じてぼかして、コンテンツの前に描画します |
近景のレイヤーは、文字の手前を横切らせます。この交差が奥行きの手がかりになります。そこに置く葉は、カーテンのようにならないよう2〜3枚にします。
色と光の扱い
- 各葉には、濃いオックスブラッドから朱色、落ち着いた琥珀色までの小さな色の範囲からサンプルし、葉ごとに彩度を変えます。すべての葉が同じ赤色だと、すぐに見抜かれます。
- 3D の葉にはアルファブレンドではなくアルファテストを使います。これにより、フレームごとの深度ソートを行わなくても、どの角度でも正しくソートされます。
- エミッシブな赤色は、トーンマッピング後の合成でピンク色になります。 暗いシーンで葉を自己発光させる場合は、緑と青をゼロにします(
0x8c1410ではなく0x780200)。そうしないと、秋の景色全体がキャンディーのような色合いになります。
コストを予算内に収める
葉ごとの更新コストは無視できます。数百枚の葉に対する三角関数の計算は、CPU プロファイラーのサンプリング下限を超えるほどではありません。コストがかかるのは塗りつぶしと描画です。
- 2D キャンバス:葉ごとに
drawImageを1回呼び出します。描画する最大サイズでスプライトを一度だけ事前レンダリングします。毎フレーム葉のパスを引き直してはいけません。 - 3D:1つの
InstancedMeshを使い、行列は共有のMatrix4に合成します。スクラッチ用のMatrix4/Quaternion/Euler/Vector3はモジュールスコープに配置します。 - アルファテストされた葉はアーリー Z が使えないため、三角形の数から想定されるよりもピクセルあたりのコストが高くなります。大きな葉を少数配置するより、小さな葉を多く配置してください。
不可視の処理を止める
document.hiddenのときと、セクションがビューポートから外れたとき(IntersectionObserver)は一時停止します。再開時にlastTimeをリセットし、再開後の最初のフレームで停止時間全体が積分されないようにします。dtを約1/30秒に制限し、処理が停止しても葉が瞬間移動しないようにします。- デバイスのピクセル比を2に制限します。
- スクリプト実行時に一度だけ計測するのではなく、ルート要素の
ResizeObserverからサイズを取得します。ページのレイアウトが後から行われると、待っていたリサイズイベントはすでに発火済みのため、キャンバスが永久に0×0のままになります。 - ビューポートがゼロの場合に備えてビルド処理を保護します。一度だけ実行して設定を行うコントロールは、最初のレイアウトより前にビルダーを呼び出すため、対処しないとすべての葉が原点に重なって生成されます。
読者に配慮する
prefers-reduced-motion: reduceの場合は、構図を整えた静止フレームを1つ描画し、アニメーションは行いません。葉を単に非表示にしてはいけません。葉を含めて構図が設計されているためです。コントロールを変更した際には静止画を再描画し、コントロールが引き続き機能するようにします。
検証
- タンブルで葉が真横を向き、回転するたびに葉が目に見えて細くなって消える
- スリップは独立したサイン波ではなく、タンブル角度によって駆動される
- 表面と裏面で見た目が異なる
- すべてのパラメーターが葉ごとに変化する
- 葉の枚数を増やす前に、構図が許す範囲でリサイクル帯をできるだけ狭くする
- 3D:カメラの周囲ではなく、カメラの手前にリサイクルする
- 3D:エミッシブな赤色では緑と青がゼロになっている
- 枚数がビューポートの面積に応じて変化し、読み出し値が実際に生成された枚数を示す
- 非表示時または画面外では一時停止する。
dtが制限され、DPR が2に制限されている - モーション軽減設定時に、意図して設計された静止フレームが表示される
- 390px と 1440px でコンソールにエラーがない