はじめに
Three.js or R3Fで実装したいネタをAIとアイデア出ししていく過程で、目に留まったキーワード「草」。
2日前の夕方に挫折したという内容を別の記事でも書いたのですが、実はこの「草」の実装。
翌日の昨日、実装したのは、今まで、Next.js + Reactを使い続けてきたのに、リアクトのメインでもある「UI」を全くといっていい程実装してこなかったので、 R3Fのオブジェクトに付随&追従するUIを作成してメニューをクリックすると、モデルチェンジをする実装をしてます。
[Next.js #14] PMXモデルを“動的にキャラチェンジ”する:File→BlobURL→MMDLoader の完全実装
Next.js + React Three Fiber で PMX(MMDモデル)をブラウザから動的読み込みし、歩行アニメ・地形 Raycast・3D UI メニューと統合するキャラチェンジ機能の解説。BlobURL と MMDLoader の相性問 …
https://humanxai.info/posts/nextjs-14-pmx-dynamic-loader/でも、この実装も一筋縄ではいかず、PMXはファイルが複数に分散されているので、それらを外部ファイルから読み込んでまとめて適用させる方法をAIが思いつかず、諦めかけた時に、 カードゲームの実装で、ZIPのassetを実装してるので、それを使えないかと提案して、試行錯誤した結果、完成してます。
[PIECE BY PIECE] 記憶のカケラ:失われたピースを集める神経衰弱ゲーム
「記憶のカケラ」は、失われた記憶を取り戻すためにピースを集める神経衰弱ゲーム。ひとつひとつのピースがあなたの記憶を呼び覚まし、挑戦する度に新しい発見が待っています。
https://humanxai.info/featured/project2/今日は、2日前に挫折した、外部データやテクスチャを一切使わず、コードとシェーダー、ノイズ処理のみで、草を実装した備忘録メモです。
動画:
Next.js + R3Fで5万本の草をGPUで揺らす #r3f #react #nextjs #javascript#threejs #GLSL
Next.js + React Three Fiber で5万本の草原をGPUシェーダーで描画。InstancedMeshでドローコールを削減し、頂点シェーダーで風アニメーションを実装。地面はFBMノイズで生成、疑似法線ライティングまで入れています。▼使用技術Next.js 14 React 19React Th...
https://youtube.com/shorts/JsifnAg8qi0?feature=share今日のノイズ記事:
[Noise 入門 #06] Domain Warping — 座標をねじると世界が壊れる
Noise 入門シリーズ第6回。Domain Warping(ドメインワーピング)の仕組みを、座標変形という視点から図解で解説。通常ノイズとの違い、FBMとの組み合わせ、strengthパラメータの効果、3D/時間拡張まで体系的に整理します。
https://humanxai.info/posts/noise-intro-06-domain-warping/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を入れる
💬 コメント