mcpField
T
threejs-landscape

threejs-landscape

github.com/mengtoクリエイティブ・メディア7.1k1.2k 回インストール3 日前に更新
リポジトリ

threejs-landscape について

リアルタイム3D背景を作る開発者向けの、Three.jsによる風景制作スキルです。地形、草、石、空、星空の構成を扱い、再利用可能な地形サンプリングと距離に応じた描画手法に重点を置きます。

主な機能

  1. 01距離に応じて広がる極座標地形グリッド
  2. 02ドメインワープノイズと距離対応の稜線
  3. 03タイルテクスチャを使わない頂点カラー
  4. 04シェーダー駆動の草と石のインスタンス描画
  5. 05グラデーションの空、星、補間式の照明状態

活用シーン

  1. 013Dの被写体に風景背景を追加
  2. 02同じ地形に草や石を散布
  3. 03状態の急変を避けて時間帯を切り替え

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mengto/skills --skill threejs-landscape

インストール後、Claude Code で/threejs-landscapeを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

agents/
openai.yaml
demo/
index.html
PROMPT.md

詳細

Three.js の風景

背景の役割はひとつだけです。主題がどこかにいるように見せることです。ここでは、ひと目で風景だとわかり、その後は主題の邪魔にならないように、すべてを選んでいます。

雨、嵐、雪を重ねるには threejs-weather を、風景の中に立つ主題が建築物なら threejs-towers を使います。

極座標グリッドでカメラを囲む

正方形のハイトフィールドは作らないでください。望遠レンズが捉えるのは狭い扇形なので、正方形のグリッドでは三角形の大部分がカメラの後ろに配置されるうえ、地平線では解像度が足りなくなります。

カメラの真下を中心にした極座標グリッドでサンプリングし、遠ざかるほど間隔が広くなる放射状のリングを使います。

const AN = 900, RN = 52, R0 = 2.0, R1 = 700;   // angular, radial, near, far
for (let r = 0; r <= RN; r++) {
  const t = r / RN;
  const rad = R0 + (R1 - R0) * Math.pow(t, 2.4);   // dense near, sparse far
  for (let a = 0; a < AN; a++) {
    const th = a / AN * Math.PI * 2;
    push(Math.cos(th) * rad, landH(x, z), Math.sin(th) * rad);
  }
}

主題の近くにある2,000個の三角形は、均等に広がった20万個の三角形に勝ります。pow(t, 2.4) が要点です。各リングが、画面上でほぼ同じピクセル数を占めるようになります。

頂点の巻き順に注意してください。 極座標グリッドでは、すべての四角形を逆向きに巻いてしまい、地面越しに空を見上げることになりがちです。(a0, b1, b0, a0, a1, b1) の順にインデックスを指定し、ほかのものを作り始める前に、一度わざと地平線の下へ回り込んで確認してください。

オクターブを重ねる前に領域をワープする

単純な fBm は、しわくちゃの紙のように見えます。ノイズ場でサンプル位置をワープしてから評価することが、侵食された地形らしさに近づける最も大きな一歩です。

function landH(x, z) {
  const wx = x + fbm(x * 0.012, z * 0.012, 3) * 26;   // domain warp
  const wz = z + fbm(x * 0.012 + 41, z * 0.012 - 17, 3) * 26;
  let h = fbm(wx * 0.0075, wz * 0.0075, 5) * 34;      // broad landforms
  h += ridged(wx * 0.021, wz * 0.021, 3) * 9;         // ridge lines
  return h;
}

解析関数はメッシュから分離しておきます。草、石、霧など、散布するものはすべて同じ landH をサンプリングする必要があります。そうしないと、浮いたり沈んだりしてしまいます。

リッジレイヤーは距離に応じてスケーリングする必要があります。40ユニットで見栄えのするリッジの振幅も、600ユニット先では見えなくなります。そのため、遠方のリングでは振幅を大きくしないと、地平線が平坦になります。

テクスチャではなく、地面の状態に応じて色を付ける

タイル状の地面テクスチャは、いつでもその存在を主張します。代わりに、地形そのものの特性から頂点カラーを計算します。

const slope = 1 - normal.y;                      // steep = rock
const moist = smoothstep(-4, 6, -height);        // low = wet, green
const c = rock.clone()
  .lerp(grass, (1 - slope * 3.2) * (0.35 + moist * 0.65))
  .lerp(sand, Math.max(0, 0.5 - moist) * 0.6);

UV も継ぎ目も使わずに、崖は石のように、窪地は緑に、尾根は淡くなります。色に縞模様が出ないよう、非常に低周波のノイズを重ねておきます。

草:頂点シェーダーだけで形を作る、1本のリボン

10万本の草は、5セグメントのリボンからなる単一の InstancedMesh です。曲がり、傾き、先細り、突風はすべて頂点シェーダーで処理するため、風による CPU の負荷はありません。

const geo = new THREE.BufferGeometry();          // 11 verts: a strip + a tip
const mesh = new THREE.InstancedMesh(geo, mat, 104000);
mat.onBeforeCompile = sh => {
  Object.assign(sh.uniforms, grassUni);
  sh.vertexShader = `
    uniform float uTime, uWindAmp; uniform vec2 uWind;
    attribute vec4 aParams;                      // height, phase, tint, lean
    varying float vT; varying float vTint;
  ` + sh.vertexShader.replace('#include <begin_vertex>', `
    float gT = position.y;                       // 0 at root, 1 at tip
    float gBend = uRestBend + sin(uTime * 1.7 + aParams.y) * uWindAmp;
    vec2  gRib  = ... ;                          // sweep the blade along an arc
    vec3 transformed = vec3(gRib.x, gT * aParams.x, gRib.y);
  `);
};
mat.customProgramCacheKey = () => 'grass';       // or every instance recompiles

草のフィールドはカメラに追従させます。視点の周囲に草を並べた固定グリッドを保ち、フィールドを外側へ広げるのではなく、セルサイズに合わせてグリッドを移動させます。プレイヤーが端に到達することはなく、背後の草に負荷をかけることもありません。

フラグメントシェーダーで草の色をハードコードしておき、マテリアルの色で変えられると期待しないでください。 diffuseColor.rgb *= mix(base, tip, t) はマテリアルから渡された色に乗算するため、白いマテリアルに緑の定数を乗算すれば、結果はやはり緑です。積雪、季節、夜のカラーパレットなど、何かによって草の色を変える必要があるなら、このミックスに専用の uniform が必要です。デバッグ出力ではマテリアルが白だと表示され続けるため、原因を見つけるのに1時間かかることがあります。

同じ高さ関数から石を作る

傾斜を条件に棄却サンプリングで散布し、InstancedMesh に setMatrixAt を適用します。icosphere を溶接して平たくスケーリングすると、置かれているというより、地面に埋まっているように見えます。

geo.scale(1, 0.62, 1); geo.translate(0, 0.3, 0);   // sunk, not resting

サイズを3〜4種類にして数千個もあれば十分です。特に主題の近くで効果を発揮し、目測でスケールを比べる手がかりになります。

空:8×512 のキャンバスに6つの色を配置する

小さなキャンバスに垂直グラデーションを描き、裏面を表示する球体に貼り付けます。描き直しは非常に軽いため、遷移中なら毎フレーム実行できます。

const grd = ctx.createLinearGradient(0, 0, 0, 512);
[0, 0.30, 0.52, 0.68, 0.84, 1].forEach((s, i) => grd.addColorStop(s, cols[i]));
ctx.fillStyle = grd; ctx.fillRect(0, 0, 8, 512);
skyMat.map.needsUpdate = true;

色は6つが適切です。3つでは CSS のグラデーションになり、10個では調整しきれません。地平線の色は幾何学的な地平線より少し下に配置すると、霧の色と空が境目の線なしにつながります。

星:カメラが捉えられる帯域内に限定する

球体全体に散らばらせるのは誤りです。約10°のレンズでは画面に映るのは球体の約0.5%なので、星が1,200個あると画面上にはおよそ6個しか表示されず、不具合のように見えます。

  • カメラの仰角制限で実際に表示可能な帯域内に限定します。
  • 点のサイズを変えた3種類のサイズクラスを使い、明るさに変化をつけます。
  • Retinaディスプレイでは消えてしまうため、PointsMaterial.size を devicePixelRatio でスケーリングします。
  • 加算ブレンディングを使い、sizeAttenuation: false、fog: false、depthWrite: false に設定します。

3種類のサイズクラスで約39,000個の点を使うと、ノイズではなく星空として認識できる空になります。各クラスにつき描画コールは1回です。

時刻の変化は切り替えではなく補間です

各時刻を完全な状態として保存します。状態には、太陽の位置と色、半球光、環境光、補助光、リムライト、フォグの色と範囲、地面、草、空の6つの色設定、影の強さ、星の不透明度を含めます。そして、2つの状態の間を線形補間します。

function applyState(A, B, t) {
  key.position.setFromSpherical(lerpAngle(A.sun, B.sun, t));
  key.color.copy(A.sunC).clone().lerp(B.sunC, t);
  scene.fog.color.copy(A.fog).lerp(B.fog, t);
  for (let i = 0; i < 6; i++) CUR.sky[i].copy(A.sky[i]).lerp(B.sky[i], t);
  paintSky(CUR.sky);
}

ユーザーが遷移の途中で切り替えた場合は、最後のプリセットに切り替えるのではなく、現在補間中の状態を新しい A として固定します。そうしないと、せっかちなユーザーがクリックするたびに表示が飛びます。

天候はプリセットを増やすのではなく、常にこの状態に重ねる倍率として扱います。時刻が4種類、天候が4種類の場合、状態と修飾子はそれぞれ4つであり、16種類ではありません。

完了と判断する前に確認すること

  • 地平線より下まで一度オービットします。地面越しに空が見える場合、面の巻き順が逆になっています。
  • 遠方のリングを見ます。地形が平坦になっている場合、尾根の振幅が距離に応じてスケーリングされていません。
  • 遷移の途中で時刻を続けて2回切り替えます。表示が飛んではいけません。
  • Retinaディスプレイで確認します。DPRのバグは、星や細いジオメトリに最初に現れます。
  • 三角形の数ではなく、描画コールの数を確認します。描画コール18回で三角形が130万個なら120fpsで動作しますが、描画コール900回で三角形が20万個では動作しません。

コメント