threejs-weather について
Three.js シーンに雨、嵐、雪などの効果を実装するスキルです。カメラに追従するパーティクル領域や共有する天候状態に加え、落雷、積雪、濡れた地面、ループする環境音を扱います。
主な機能
- 01カメラ追従型の天候パーティクル領域
- 02描画範囲による密度制御
- 03雨と嵐の状態スケーリング
- 04稲妻の閃光と遅れて届く雷鳴
- 05降雪と積雪の蓄積
- 06濡れた地面と環境音の手法
活用シーン
- 01Three.js シーンへの雨や嵐の追加
- 02強度が変化する吹雪の構築
- 03積雪と融雪の表現
- 04濡れた地面と天候の環境音の作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mengto/ skills --skill threejs-weatherインストール後、Claude Code で/threejs-weatherを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamldemo/index.htmlPROMPT.md詳細
Three.js Weather
天候表現には、パーティクルがカメラにまったく映らない場合と、それぞれの状態が独立したシステムになっていて何も共有しない場合の、2つの問題があります。どちらも避けられます。
降り積もる地面には threejs-landscape、降りかかる対象には threejs-towers を組み合わせてください。
視錐台内にボリュームを固定する
雨のシステムを作るとき、最初に原点を囲むワールドサイズのパーティクルボックスを用意すると、何も見えなくなります。望遠レンズではカメラが狭い円錐状の範囲を捉えるため、雨粒のほとんどが画面外に出てしまいます。
小さなボリュームを作り、カメラの正面でカメラと同じ向きになるように配置します。
const WX_W = 17, WX_D = 50, WX_TOP = 40; // narrow, deep, tall
function anchor(o) {
const fx = -Math.sin(camAz), fz = -Math.cos(camAz);
o.position.set(cam.position.x + fx * 46, 0, cam.position.z + fz * 46);
o.rotation.y = camAz;
}
毎フレーム動かすため、frustumCulled = false に設定してください。バウンディングスフィアが邪魔になります。
Float32BufferAttribute は配列をコピーする
これで午後が丸ごとつぶれることがあります。属性は渡されたデータのコピーを取るため、参照を保持していた配列は GPU が読み込む配列とは別物です。
const attr = new THREE.Float32BufferAttribute(pos, 3);
attr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position', attr);
return { pts, pos: attr.array, n }; // keep the attribute's array
症状:すべて正しいのに何も動かず、エラーもどこにも出ません。
密度は新しいバッファーではなく、描画範囲で調整する
最大数、つまり激しい嵐や猛吹雪に必要な分を一度だけ確保し、描画する数を減らして密度を下げます。
RAIN.geo.setDrawRange(0, Math.round(RAIN.n * density));
嵐でも、小雨と同じメモリー量で済みます。ほとんどの雨粒を表示しなくなるだけです。天候が変わるたびにバッファーを再確保すると、画面を見ている最中にちょうど引っかかりが発生します。
嵐は雨に傾きを加えたもの
2つ目のパーティクルシステムを書かないでください。雨の状態を使い、すべての調整値を強めます。
| 雨 | 嵐 | |
|---|---|---|
| 描画する雨粒 | プールの60% | 100% |
| 落下速度 | ×1.0 | ×1.42 |
| 傾き | 2.4 | 速度²に応じて増加 |
| 太陽光 | 30% | 14% |
| 霧の遠端 | ×0.52 | ×0.38 |
傾きは速度より速く増やす必要があります。slant = base * v * v とすることで、「速い雨」ではなく風を感じさせられます。
雷光は独立したライトにする
フラッシュのために時刻状態を変更しないでください。ほかの処理が触れないライトを追加すれば、空がどのような状態でも稲妻を光らせられます。
const bolt = new THREE.DirectionalLight(0xe8eeff, 0);
一度の落雷では、リーダーと2、3回の帰還雷撃など、複数のフラッシュが発生します。それぞれを指数関数的に減衰させます。
function strike() {
const near = Math.random(); // 0 distant … 1 close
const s = 0.42 + near * 0.58;
pulses = [{ t: 0, a: s }];
let tt = 0;
for (let i = 0, n = 1 + (Math.random() * 2.6 | 0); i < n; i++) {
tt += 0.05 + Math.random() * 0.14;
pulses.push({ t: tt, a: s * (0.30 + Math.random() * 0.65) });
}
setTimeout(() => sfx(near > 0.5 ? 'thunder_near' : 'thunder_far',
{ gain: 0.30 + near * 0.60 }),
(0.32 + (1 - near) * 2.7) * 1000); // sound arrives late
}
function step(dt) {
let v = 0;
for (const p of pulses) if (age >= p.t) v += p.a * Math.exp(-(age - p.t) / 0.085);
bolt.intensity = v * 3.2;
skyMat.color.setScalar(1 + v * 0.80); // multiplies the sky map
flashEl.style.opacity = (v * 0.15).toFixed(3);
}
特に重要なのは2点です。雷鳴は距離に応じて遅れ、時間がかかるほど音が小さくなります。この相関があることで、落雷が遠くに感じられます。また、DOM のフラッシュは文字の上ではなく、文字の下に置きます。画面全体を覆う白いオーバーレイを文字の上に置くとページが白く飛んでしまうため、キャンバスのすぐ上に配置し、不透明度を約0.18以下にしてください。
落雷のたびにライトの位置を変えてください。常に同じ側から光るフラッシュは、2回目を過ぎると不自然に見えます。
猛吹雪は状態ではなく、ゆっくり変化するエンベロープ
雪は一定の速度で降り続けるわけではありません。小康状態、強まり、吹き荒れ、弱まりというサイクルを動かし、それに合わせて雪の状態を変化させます。
const BLIZ = [12, 6, 15, 8]; // seconds
function blizzardAt(t) {
let u = t % TOTAL; if (u < 0) u += TOTAL;
if (u < BLIZ[0]) return 0;
... // smoothstep in, hold, out
}
吹き荒れる間は、雪片のプールを増やし、雪片を大きくし、横向きに吹き流し、霧を近づけます。雪の状態を抜けるときは、フェーズの時計をゼロにリセットしてください。そうしないと、雪の状態になった途端にホワイトアウトの途中から始まってしまいます。負の開始オフセットが別のフェーズに回り込み、原因を見つけにくい不具合のように見えます。
降っている雪と積もった雪は別の変数
これを忘れがちです。すでに積もった雪の量を独立したゆっくり動くタイマーで追跡します。雪が降る間は増加させ、天候が変わったら溶けて減少させます。
snowPack += (target - snowPack) * (target > snowPack ? dt / 24 : dt / 13);
その後は、雪が降り続ける間、地面や石、草を白く覆い続けさせます。雪の中に到着した直後と、雪の中に1分間立っていた後とでは、見た目が違うはずです。
表面の色がシェーダー内で混合されている場合(インスタンス化された草ではよくあります)、マテリアルの色はその部分に反映されません。その色の混合専用のユニフォームを用意し、まず葉先に雪を積もらせてから、下へ広げていきます。
gBase = mix(gBase, vec3(0.60, 0.65, 0.72), uSnow * 0.86);
gTip = mix(gTip, vec3(0.90, 0.94, 1.00), uSnow);
ゆっくり変化する値が動いたときだけ再ライティングする
シーンの再ライティングは無料ではありません。また、猛吹雪の強さと積雪量は連続的に変化します。実行頻度を制限してください。
const look = blizK + snowPack;
if (Math.abs(look - lastLook) > 0.006) { lastLook = look; refreshLook(); }
これなら毎秒60回ではなく、数回だけ実行されます。画面を見ていても違いはわかりません。
濡れた地面
効果に対してコストの低いものを3つ、順に挙げます。粗さを下げて金属度を少し上げ、地面に空が映り込むようにします。くぼみに、平面の半透明な水たまりデカールを追加します。次に、寿命の短い水しぶきの輪のスプライトをインスタンス化し、雨の強さに応じて生成します。雨のテクスチャではなく、降っている雨に見せるのは水しぶきです。
継ぎ目なくループする環境音
生成した音声は、きれいにループしません。末尾を先頭に重ねてイコールパワーのクロスフェードを適用し、重なった部分を削除します。すると、クリップの終わりが始まりと同じになります。
for i in range(f):
t = i / f
out[i] = a[i] * sin(t * pi / 2) + a[n - f + i] * cos(t * pi / 2)
クリップをフェードイン、フェードアウトさせると、ループのたびに無音になります。誰もが気づきますが、誰も口にはしません。
天候用のゲインバスを音楽用とは別に用意し、吹雪は別のクリップへのクロスフェードではなく、ベースの環境音に重ねて徐々に大きくなる第2レイヤーとして再生します。嵐の途中で急に切り替わると、すぐにわかります。
完了とする前に確認すること
- 1滴の雨粒を、生成されてから地面に着くまで見届けてください。見つからない場合は、ボリュームが視錐台の外にあります。
- 雪の中に2分間とどまってください。最後には地面が目に見えて白くなっているはずです。また、少なくとも1度は吹雪が到来し、通り過ぎるのを見ているはずです。
- 晴天から雪の中へ5回入ってください。毎回、穏やかな状態から始まる必要があります。
- 嵐の中に1分間立ってください。落雷はさまざまな方向から発生し、雷鳴が稲光と同時に鳴ってはいけません。
- 嵐を動かした状態でプロファイリングしてください。パーティクルの負荷は小さいですが、有効化を忘れた再ライティングはそうではありません。