three.jsで木と森を描く:LOD、インスタンシング、KTX2、風——実践ガイド
フレームレートもダウンロードサイズも犠牲にせず、three.jsのWebゲームに本物の森を入れる方法。ゲーム向けGLB、圧縮、THREE.LOD、InstancedMeshとBatchedMesh、葉のカットアウト、影、軽量な風シェーダーまで。

ブラウザー向けに作られるゲームはますます増えています。そしてそのコードの多くはAIアシスタントと一緒に書かれ、AIアシスタントはデフォルトでthree.jsを選びます。たとえばVibe Jam 2026では、ダウンロードなしで始められるブラウザーゲームが求められ、three.jsが推奨されていました。こうしたゲームも遅かれ早かれ森を欲しがります。そして森こそが、60 FPSを15に、5 MBのページを150 MBに変えてしまう最初の要因になりがちです。
Webはもっとも制約の厳しいプラットフォームです。非力なノートPCやスマートフォン、ブラウザーと共有するGPU、そしてプレイヤーが何かを目にする前に、1メガバイト残らずダウンロードしなければなりません。このガイドでは、こうした制約に耐える植生の作り方を、最新のthree.js向けコード付きで一歩ずつ解説します。
Webの予算
コードを書く前に上限を決めましょう。以下はミドルレンジのノートPCやスマートフォンを想定した出発点で、絶対のルールではありません。自分のシーンで計測してください。
| 項目 | 快適な目標値 |
|---|---|
| フレームあたりのドローコール | 多くても数百 |
| フレームあたりのポリゴン数 | 50万〜200万 |
| 木1本、近距離レベル | 5,000〜15,000ポリゴン |
| 木1本、遠距離レベル | 1,500ポリゴン未満、インポスターなら2 |
| 森全体のダウンロード量 | 数十MBではなく数MB |
鍵となる考え方はどのエンジンでも同じです。**森の大部分は遠くにあるのだから、森の大部分は安くなければならない。**各詳細度レベルは、前のレベルよりおおよそ3〜4倍軽くするべきです。
1本の木、4段階の詳細度:シルエットはそのままに、コストが下がっていきます。
ステップ1. ゲーム向けのGLBから始める
ゲーム用の木は、ただきれいなメッシュというだけではありません。レンダリングのコードを1行でも書く前に、ファイルに次のものがあるか確認しましょう。
- シルエットが揃ったLODチェーン(理想的には最遠レベル用のインポスターも);
- 何千枚もの個別の葉でも、ぎっしり詰まった「ブロッコリー」の塊でもない、葉の塊をまとめたカード;
- 幹の根元にあるピボットと、メートル単位の実寸スケール;
- シンプルなコリジョン——樹冠ではなく幹にカプセル;
- 頂点ごとに格納された風のウェイト。
これらはすべて、無料のブラウザーツールで確認できます。ポリゴンカウンター、LODビューアー、glTFバリデーターをどうぞ。
ステップ2. ファイルを小さくする:meshoptとKTX2
2種類の圧縮で、一般的な木のサイズは数分の一になります。
- ジオメトリ——meshopt(またはDraco)圧縮。
- テクスチャ——KTX2(Basis Universal)。PNGやJPEGと違い、KTX2はビデオメモリ上でも圧縮されたままなので、ダウンロード量とVRAMの両方を節約できます。
ブラウザー上なら当社のglTFコンプレッサーとKTX2コンバーターで、コマンドラインならglTF Transformで処理できます。
npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
(CLIでのKTX2圧縮にはKTX-Softwareのインストールが必要です。--texture-compress webpならなくても動きます。)
three.jsで圧縮済みファイルを読み込む方法:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync('/models/oak.min.glb');
ステップ3. 木が数本なら:THREE.LOD
プレイヤーの近くにある数本の木——村の広場や主役の1本——なら、組み込みのTHREE.LODで十分です。カメラまでの距離に応じて子オブジェクトを1つ表示し、第3引数は境界でのちらつきを防ぐヒステリシスです。
const lod = new THREE.LOD();
lod.addLevel(lod0, 0);
lod.addLevel(lod1, 30, 0.1);
lod.addLevel(lod2, 70, 0.1);
lod.addLevel(impostor, 150, 0.1);
lod.position.set(x, 0, z);
scene.add(lod);
THREE.LODはレンダラーがシーンを描画するときに自動で更新されます。限界は、木が1本ずつ別オブジェクトのままだという点です。樹皮と葉でそれぞれドローコールが発生します。木が数十本を超えたら、インスタンシングが必要です。
ステップ4. 大量の木:インスタンシングをチャンク単位で
InstancedMeshは、1つのメッシュの何千ものコピーを1回のドローコールで描画します。しかしthree.jsはこれを1つのオブジェクトとして扱います。カリングは丸ごとで、LODもありません。500 m先やカメラの後ろにあるインスタンスも含め、すべて同じ詳細度で描画されます。
解決策はGodotやUnityと同じです。ワールドをチャンクに分割し、チャンクごと、LODレベルごと、マテリアルごとにInstancedMeshを1つ作ります。そうすれば各チャンクが距離に応じてレベルを選べます。
const CHUNK = 32; // メートル——LOD0の距離に近い値
const STARTS = [0, 35, 80, 160]; // 各LODの開始距離
const CULL = 400;
function buildChunk(trees, lodMeshes) {
// lodMeshes[i] = [barkMesh, leavesMesh](GLBから取得)
const levels = lodMeshes.map((parts) => parts.map((src) => {
const im = new THREE.InstancedMesh(src.geometry, src.material, trees.length);
trees.forEach((m, k) => im.setMatrixAt(k, m));
im.computeBoundingSphere();
return im;
}));
const group = new THREE.Group();
levels.flat().forEach((im) => group.add(im));
group.userData.levels = levels;
group.userData.center = centerOf(trees); // THREE.Vector3
return group;
}
function updateChunk(group, camera) {
const d = camera.position.distanceTo(group.userData.center);
group.userData.levels.forEach((parts, i) => {
const visible = d >= STARTS[i] && d < (STARTS[i + 1] ?? CULL);
parts.forEach((im) => { im.visible = visible; });
});
}
updateChunkは毎フレームではなく、各チャンクについて1秒に数回呼べば十分です。距離はゆっくりしか変わりません。チャンクのサイズは重要です。距離はチャンクの中心までで測られるため、LOD0の範囲よりずっと大きなチャンクだと、プレイヤーのすぐ隣の木が粗く表示されてしまいます。
BatchedMeshはより新しい選択肢です。1つのマテリアルで多数の異なるジオメトリを描画し、オブジェクトごとに個別にカリングし(perObjectFrustumCulledはデフォルトで有効)、setGeometryIdAtで任意のインスタンスのジオメトリを差し替えられます。これはまさにLODの切り替えそのものです。
const bark = new THREE.BatchedMesh(maxTrees, maxVerts, maxIndices, barkMaterial);
const lodIds = barkLods.map((g) => bark.addGeometry(g));
for (const t of trees) {
t.id = bark.addInstance(lodIds[0]);
bark.setMatrixAt(t.id, t.matrix);
}
// 1秒に数回:
for (const t of trees) {
const d = camera.position.distanceTo(t.position);
const lod = STARTS.findLastIndex((s) => d >= s);
bark.setGeometryIdAt(t.id, lodIds[lod]);
}
BatchedMeshはマテリアル(樹皮、葉)ごとに1つ使います。既成のソリューションを使いたいなら、コミュニティライブラリの@three.ez/instanced-mesh(インスタンシングにインスタンス単位のカリングとLODを追加)と@three.ez/batched-mesh-extensions(BatchedMesh用のLOD)がこの作業を引き受けてくれます。
もう1つ近道があります。ファイルがglTF拡張のEXT_mesh_gpu_instancingを使っていれば、GLTFLoaderがInstancedMeshオブジェクトを自動で作ってくれます。森まるごとを1ファイルにできるわけです。
インスタンス化された木の森:木の本数が増えても、ドローコールはほとんど増えません。
ステップ5. 葉:透過ではなくカットアウト
Webで重い森の多くは、葉が原因です。
transparent: trueではなくalphaTestを使う。 透過マテリアルはソートとブレンドが必要で、深度を書き込まず、オーバードローを何倍にも増やします。カットアウトのほうがはるかに軽量です。
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // レンダラーがantialias: trueのときエッジが柔らかくなる
- カードの形に気を配る。 丸い葉の塊を四角いカードで囲むと、見えないピクセルを大量にシェーディングすることになります。塊のシルエットに合わせて切り抜いたカードなら、フィルレートを大きく節約できます。そしてスマートフォンでは、フィルレートこそが本当のボトルネックであることが多いのです。
- 非力なデバイスでは解像度を下げる。 高DPIのスマートフォンでは、
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))が植生にとって単独で最大の改善になることがよくあります。
ステップ6. 影と風
影は森をもう一度描画します。シャドウカメラはプレイヤーの周囲に絞り込み、shadow.mapSizeは控えめにし、影を落とすのは近距離のレベルだけにしましょう。
lod2Leaves.castShadow = false;
impostor.castShadow = false;
風は頂点シェーダーで処理すべきです。モデルが頂点ごとに風のウェイトを持っていれば——たとえば2番目のUVチャンネルのxに幹のしなり、yに枝の揺れ——onBeforeCompileで標準マテリアルに動きを加えられます。
const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // GLBのTEXCOORD_1
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = time;
shader.vertexShader = shader.vertexShader
.replace('#include <common>', '#include <common>\nattribute vec2 wind;\nuniform float uTime;')
.replace('#include <begin_vertex>', `#include <begin_vertex>
float sway = sin(uTime * 1.3 + position.y * 0.2);
transformed.x += (sway * wind.x * 0.25 + sin(uTime * 2.7 + position.x) * wind.y * 0.1);
transformed.z += sway * wind.x * 0.1;`);
};
// レンダーループ内で:
time.value = clock.getElapsedTime();
属性を独自の名前でコピーしているのは、three.jsとの衝突を避けるためです。マテリアルが2番目のUVセットを使うと、three.jsは自分でuv1を宣言します。
ステップ7. 計測する
renderer.info.render.callsとrenderer.info.render.triangles——フレームあたりのドローコールとポリゴン数;- ブラウザーのPerformanceパネル——ボトルネックがCPUかGPUか;
- 実機のミドルレンジのスマートフォン——開発マシンだけでなく。
変更のたびに前後で計測しましょう。1つの修正(葉のカットアウト、ピクセル比、影の距離)が、ほかのすべてを合わせたより大きな効果を生むこともよくあります。
チェックリスト
- GLBにLODチェーン、葉の塊のカード、根元のピボット、コリジョン、風のウェイトがある。
- ジオメトリはmeshopt、テクスチャはKTX2で圧縮している。
- 近くの数本には
THREE.LOD、森にはチャンク分割したInstancedMeshまたはBatchedMeshを使う。 - 葉は
transparentではなくalphaTestとalphaToCoverageを使う。 - 影は近距離レベルのみ、シャドウカメラは絞り込んでいる。
- スマートフォンではピクセル比に上限を設けている。
- ドローコール、ポリゴン数、フレーム時間を実機で計測している。
FoliMeshの役割
FoliMeshは、ブラウザーで動作し、ゲームの予算を軸に設計された樹木・自然ジェネレーターです。レベルごとにポリゴン予算を設定すると、LOD0〜LOD3と最遠距離用のクロスビルボードを含むGLB、葉の塊の形に切り抜いた葉カード、根元のピボット、TEXCOORD_1に格納された風のウェイト(three.jsではuv1として読み込まれます)、そしてノードのextras(three.jsではobject.userData)に入ったLOD距離とコリジョンカプセルを書き出します。森全体をEXT_mesh_gpu_instancing付きの1つのGLBとして書き出すこともでき、GLTFLoaderがそれをインスタンス化されたメッシュに変換します。
FoliMeshとほかのツールの比較は、3D樹木ジェネレーターの比較をご覧ください。Godotで開発している方は、Godot 4で軽い森を作る方法をどうぞ。
FAQ
three.jsにLODはありますか?
はい、THREE.LODが距離に応じて子オブジェクトを切り替えます。ただしInstancedMesh内の個々のインスタンスには効きません。それにはチャンク、setGeometryIdAtを使ったBatchedMesh、または@three.ez/instanced-meshのようなライブラリを使ってください。
InstancedMeshとBatchedMesh、どちらを使うべきですか?
1つのメッシュを大量にコピーするなら、InstancedMeshがもっともシンプルで高速です。オブジェクト単位のカリングやLODが欲しい場合、または1つのマテリアルで多くの異なるメッシュを描きたい場合はBatchedMeshが向いています。
木の葉が黒くなったり、ソートがおかしくなったりするのはなぜですか?
たいていはマテリアルがtransparent: trueになっているからです。alphaTestとDoubleSideに切り替えれば、ソートの問題は消え、パフォーマンスも向上します。
木にKTX2を使う価値はありますか? あります。樹皮と葉のテクスチャは、ダウンロード量でもビデオメモリでも最大の割合を占めることが多く、KTX2はその両方を削減します。

