[Next.js #14] Next.js + R3F で 5万本の草原を生成する – InstancedMesh + GPUシェーダー

はじめに

Three.js or R3Fで実装したいネタをAIとアイデア出ししていく過程で、目に留まったキーワード「草」。

2日前の夕方に挫折したという内容を別の記事でも書いたのですが、実はこの「草」の実装。

翌日の昨日、実装したのは、今まで、Next.js + Reactを使い続けてきたのに、リアクトのメインでもある「UI」を全くといっていい程実装してこなかったので、 R3Fのオブジェクトに付随&追従するUIを作成してメニューをクリックすると、モデルチェンジをする実装をしてます。

でも、この実装も一筋縄ではいかず、PMXはファイルが複数に分散されているので、それらを外部ファイルから読み込んでまとめて適用させる方法をAIが思いつかず、諦めかけた時に、 カードゲームの実装で、ZIPのassetを実装してるので、それを使えないかと提案して、試行錯誤した結果、完成してます。

今日は、2日前に挫折した、外部データやテクスチャを一切使わず、コードとシェーダー、ノイズ処理のみで、草を実装した備忘録メモです。

動画:

今日のノイズ記事:

1. 目的

✔ 1. 草を大量生成する

1万〜5万本規模の草を描画しても CPUが悲鳴を上げない構造を作る。

→ InstancedMesh を使い、描画を1回のドローコールに集約。


✔ 2. GPUで揺らす

風のアニメーションを JavaScriptで毎フレーム更新しない。

→ VertexShader 内で時間を使い揺らす。 → CPUは「時間」を渡すだけ。


✔ 3. 地面をノイズでリアル化

単色のプレーンではなく、

  • FBM(Fractal Brownian Motion)で地形を生成
  • マクロ + ミクロノイズを合成
  • 疑似法線で簡易ライティング

WebGLだけで地面の質感を作る。


✔ 4. UIでリアルタイム制御

  • 風の強さ
  • 風速
  • 草の高さ
  • 密度

これらを Leva UI で操作可能にし、 “実験可能な環境” にする。

完成品ではなく、 パラメータ空間を触れる状態を作ることが重要。

2. 草の設計思想

なぜ InstancedMesh なのか?

草を 10,000 本描画すると仮定する。

通常の mesh を 10,000 個置いた場合

for (let i = 0; i < 10000; i++) {
  scene.add(new THREE.Mesh(geometry, material))
}

問題は描画そのものではない。

Draw Call(描画命令)が 10,000 回発生すること

GPUは速い。 しかし「命令の回数」には弱い。

  • CPU → GPU に毎回描画命令を送る
  • そのたびにステート変更が発生
  • これがボトルネックになる

結果:

CPUが先に死ぬ


InstancedMesh の場合

<instancedMesh args={[geometry, material, count]} />
  • Geometry は 1つ
  • Material も 1つ
  • Draw Call も 1回

違うのは 行列(instanceMatrix)だけ

つまり:

「同じ形を、違う場所に置くだけなら GPUにまとめてやらせろ」

これが Instancing の本質。


さらに重要な点

草は:

  • 形は同じ
  • 色もほぼ同じ
  • 違うのは位置と回転だけ

これは InstancedMesh に完全に適合するオブジェクト。

設計段階で

「これはインスタンス化できるか?」

と考えるのが、 大量描画時代の設計思考。

3. 草ジオメトリの作り方

先細りブレード

草はただの PlaneGeometry ではない。

そのままだと「板」になる。 必要なのは ブレード(葉)構造


① 根元を原点に揃える

geo.translate(0, 0.5, 0);

PlaneGeometry は中央原点。

しかし草の場合は:

  • 根元が地面
  • 上に向かって伸びる

そのため、ジオメトリを 上方向に 0.5 移動 させる。

結果:

y=0 が「根元」になる

これが後のスケール処理・風揺れ処理を楽にする。

設計的にかなり重要。


② 上に行くほど細くする(テーパー)

const taper = 1.0 - y;
pos.setX(i, pos.getX(i) * taper);

ここがキモ。

  • y=0(根元) → taper=1.0 → 太い
  • y=1(先端) → taper=0.0 → 細い

つまり:

X軸方向を高さに応じて縮めている

これで「板」から「葉」になる。


なぜCPUでやるのか?

この処理は:

  • 一度だけ
  • geometry生成時

だからCPUでOK。

風のように毎フレーム変わるものはGPUへ。 変わらない構造はCPUで事前計算。

ここも設計思想。


まとめ

草は単なる板ではない。

  • 原点設計
  • 形状テーパー
  • 一度だけCPU処理

この3つで「それっぽさ」が出る。

4. GPU揺れの本質

float wind = sin(uTime * uWindSpeed + worldPos.x * 0.3);
pos.x += wind * uWindStrength * pow(pos.y, 2.0);

一見ただの sin だが、設計はちゃんとしている。


① なぜ worldPos を使うのか?

vec3 worldPos = (instanceMatrix * vec4(position,1.0)).xyz;

もし position.x を使うとどうなるか?

→ 全ての草が同じタイミングで揺れる。

それは「同期振動」。 自然界では起きない。

worldPos.x を使うことで:

  • 空間上の位置に依存した位相差が生まれる
  • 横方向に風の波が流れる

つまり:

風が「時間」ではなく「空間」を持つ

ここが重要。


② なぜ pow(pos.y, 2.0) なのか?

pow(pos.y, 2.0)
  • pos.y ≒ 0(根元) → 0
  • pos.y ≒ 1(先端) → 1

さらに二乗しているので:

  • 根元はほぼ動かない
  • 上部だけ大きく動く

これで:

地面から生えている物体になる

これを入れないと「ゴム板」になる。


③ なぜGPUでやるのか?

10000本 × 6頂点 × 毎フレーム更新

CPUでやったら即死。

頂点シェーダーなら:

  • 全頂点を並列計算
  • コストはほぼ一定

だから:

揺れは必ずGPUにやらせる


ここが本質

この書き方は地味だが正しい。

  • 空間位相
  • 根元固定
  • GPU並列処理

最低限の物理的説得力がある。

5. 高さをGPUで変える理由

草の高さを変えたいとき、 直感的にはこう考える:

PlaneGeometry を作り直せばいいのでは?

しかし、それをやると何が起きるか。


CPUで geometry を再生成すると

  • useMemo が再実行される
  • InstancedMesh が再バインドされる
  • インスタンス再配置が走る
  • GPUバッファが再構築される

つまり:

UIスライダーを動かすたびに、草原を作り直すことになる。

10000本規模では明確に重い。


正解:GPUでスケール

pos.y *= uBladeHeight;

これだけ。

何が起きているか?

  • ジオメトリはそのまま
  • インスタンスもそのまま
  • 毎フレーム頂点だけ変形

つまり:

データ構造は固定し、見た目だけ変える

これはGPU設計の基本思想。


なぜこれが強いのか?

CPU側:

  • 配列管理
  • オブジェクト管理
  • GC
  • 再配置

GPU側:

  • 並列頂点変形
  • メモリは固定

圧倒的にGPUが有利。


設計の本質

この実装はこういう思想になっている:

  • 草の「構造」は固定
  • 草の「振る舞い」はuniformで制御

つまり:

形は静的、動きは動的

この分離ができているのが良い設計。

6. 地面のリアル化

FBM(Fractal Brownian Motion)

float macro = fbm(uv * 6.0);
float micro = fbm(uv * 40.0) * 0.15;
  • macro → 大きなうねり(地形の骨格)
  • micro → 表面のザラつき(質感)

なぜレイヤー分離が重要なのか?

自然界は単一スケールでは存在しない。

  • 山には山脈スケールがある
  • その上に岩スケールがある
  • さらに砂粒スケールがある

これを数式でやっているだけ。


ダメな例

float h = fbm(uv * 20.0);

これだけだと:

  • 全体構造が弱い
  • スケール感がない
  • “ノイズっぽい板”になる

正しい構造

float height = macro + micro;

役割を分ける。

  • マクロ → 世界観
  • ミクロ → 解像感

この分離があると一気に自然になる。


さらに一段上の話

これは実は:

FBMの中でやっていることを、外側でもやっている

という構造。

つまり:

  • ノイズ内部でも多層
  • シーン設計でも多層

同じ思想。


重要な視点

ノイズは「ランダム」ではない。

スケール構造を設計する技術

これが理解できていると、 地形も草も雲も同じロジックで組める。

7. 疑似ライティング

vec3 dx = dFdx(vWorldPos);
vec3 dy = dFdy(vWorldPos);
vec3 normal = normalize(cross(dx, dy));

これは見た目以上に強い処理。


何をやっているのか?

dFdx / dFdy

画面上でのピクセル単位の微分

つまり:

  • x方向にどれだけ位置が変化しているか
  • y方向にどれだけ位置が変化しているか

を取得している。

それを cross() しているということは、

その場で“法線ベクトルを再構築している”

ということ。


なぜこれが重要か?

地形はシェーダー内で変位している。

CPU側にはその形状変化が存在しない。

だから普通にライトを当てても正しい陰影にならない。

そこで:

GPU上で擬似的に法線を再計算する

これをやっている。


これを入れると何が変わる?

  • 平面感が消える
  • 起伏が“物体”として見える
  • 安っぽさが一段階消える

ノイズ+色だけだと「模様」。

ノイズ+法線だと「地形」。


なぜ“中級以上”なのか?

普通は:

  • normalMapを使う
  • 既存ライトに任せる

ここでは:

数学で地形の傾きを求めている

つまりこれは:

  • 微分
  • ベクトル外積
  • 正規化

をリアルタイムでやっている。

地味だけど、完全にグラフィックスの本筋。


一言でまとめるなら

ノイズで高さを作り 微分で傾きを求め 光で立体にする

これがプロシージャル地形の基本三段構造。

まとめ

5万本の草を描いても、CPUはほぼ何もしていない。 動いているのはGPUである。

これが WebGL の本質。


今回やったことは3つ。

  • 🌿 InstancedMesh で描画をGPUに丸投げ
  • 🌬 sin × worldPos で空間的な風を作成
  • 🌍 FBM + 微分で地面に立体感を付与

重要なのは「草」ではない。

重要なのは:

計算をCPUからGPUへ移したこと


この実装で分かること

  • メッシュは“形”ではない
  • ノイズは“模様”ではない
  • シェーダーは“装飾”ではない

すべて「数式」で出来ている。


そして本質

草はポリゴンではない。 風は物理演算ではない。 地面はテクスチャではない。

全部、数式。


次の一歩

ここまで来たなら、次は:

  • 草の色をノイズでランダム化
  • 風向きをノイズベクトル場にする
  • 地面の法線をGrassにも反映する
  • 影を入れる
  • LODを入れる