FoliMesh
ブログ

Godot 4でFPSを落とさない森の作り方:MultiMesh、LOD、Visibility Range

Godot 4で軽い植生を作るための実践ガイド。ポリゴン予算、自動メッシュLODとVisibility Rangeの使い分け、チャンク分割したMultiMesh、葉の透過、影、オクルージョンカリング、そして効果の計測方法まで。

·読了目安 2 分#godot#optimization#lod#tutorial

Godotプロジェクトで最初に作った森は、たいてい見栄えがいいものです——中に入ってみるまでは。木を200本置いたところでフレームレートは半分になり、プロファイラーには数千のドローコールが並び、どの設定を変えればいいのか誰にも分からない。Godotフォーラムでも、まさにこの状況を報告したユーザーがいます。自作の草用LODシステムが、小さな区画だけで約10 FPS、ドローコール3,000超という結果になったそうです。

朗報もあります。広くて軽い森を作るための道具は、Godot 4にすべて揃っています。あとは正しく組み合わせるだけです。このガイドでは、重要な順に——予算、LOD、インスタンシング、葉、影、カリング——解説し、最後にチェックリストをまとめます。

森が重い理由

木はゲームの中でも特にコストの高いオブジェクトです。4つの面で同時に負荷をかけます。

  1. ドローコール。 木は最低でも2つのメッシュ(樹皮と葉)でできていて、マテリアルが異なることも多いです。別々の木が100本あれば、ほかに何も描かないうちから数百のドローコールになります。
  2. ポリゴンと頂点。 精細な木は簡単に40,000ポリゴンを超えます。主役の1本なら問題ありませんが、山の斜面一面となると致命的です。
  3. オーバードロー。 葉は透明ピクセルを含むカードです。GPUは見えない部分もシェーディングし、樹冠の全カードについてそれを何層にも重ねて行います。
  4. 影。 すべての木はシャドウマップにもう一度描画されます。シャドウカスケードごとに1回ずつ、つまり複数回描かれることもあります。

以下のテクニックは、それぞれこれらのコストの1つまたは複数に効きます。

ステップ1. 作る前に予算を決める

もっとも役に立つ習慣は、モデリングや生成の前に距離ごとに1本の木が使ってよいポリゴン数を決めておくことです。ゲームでそのまま使えるLODチェーンの例として、FoliMeshの実際のオークを見てみましょう。

レベル 距離(例) ポリゴン数
LOD0 0–30 m 14,490
LOD1 30–70 m 3,984
LOD2 70–150 m 1,154
インポスター 150 m + 2

大事なのは正確な数値ではなく、カーブの形です。各レベルは前のレベルよりおおよそ3〜4倍軽く、もっとも遠い木はほとんどコストがかかりません。モバイルでは全体を2〜3で割ってください。森の大部分は遠くにあるのだから、森の大部分は安くあるべきです。

同じオークのLOD0、LOD1、LOD2、LOD3 同じ木の4段階の詳細度:シルエットはそのままに、ポリゴン数が減っていきます。

ステップ2. LODの仕組みを選ぶ:自動メッシュLODか、Visibility Rangeか

Godot 4には2種類のLODシステムがあり、木に対して間違った方を選ぶとトラブルのもとになります。

自動メッシュLOD。 glTF、.blend、FBXのシーンをインポートすると、Godotはmeshoptimizerで各メッシュの簡略版を生成し、自動で切り替えます。公式ドキュメントによると、切り替えはノードのバウンディングボックス上でカメラにもっとも近い点と、誤差が何ピクセル分になるか(Rendering → Mesh LOD → LOD Change → Threshold Pixels、デフォルトは1ピクセル)で判断されます。各ノードにはLOD Biasもあります。

これは岩や建物、プロップのような連続した面にはよく効きます。しかし樹冠は別物で、数百枚の独立した葉カードの集まりです。汎用の簡略化はそうした形状を想定していないため、樹冠がスカスカになったり歪んだりすることがあります。ドキュメント自体も、手作りのLODがある場合は別の機能を使うよう勧めています。

Visibility Range(HLOD)。 こちらはLODメッシュを自分で用意し、それぞれがどの距離で表示されるかを指定します。すべてのGeometryInstance3Dには次のプロパティがあります。

  • visibility_range_begin / visibility_range_end — ノードが描画される距離の範囲(バウンディングボックスの中心までで計測);
  • visibility_range_begin_margin / visibility_range_end_margin — カメラがちょうど境界にいるときにレベルがちらつかないためのバッファ;
  • visibility_range_fade_mode — Disabled(瞬時に切り替え、最速)、SelfまたはDependencies(アルファフェード。遷移中は半透明描画が強制されるため、パフォーマンスを消費します)。

木にはVisibility Rangeが確実な選択です。LODは植生向けに作られ、距離は自分で制御でき、最後のレベルで影を切ったりインポスターに切り替えたりできます。1本の木の最小構成は次のとおりです。

# Tree.gd — 子ノード:LOD0、LOD1、LOD2(MeshInstance3D)
extends Node3D

@export var lod_starts := PackedFloat32Array([0.0, 30.0, 70.0])
@export var cull_distance := 150.0

func _ready() -> void:
	for i in get_child_count():
		var mi := get_child(i) as MeshInstance3D
		if mi == null:
			continue
		mi.visibility_range_begin = lod_starts[i]
		mi.visibility_range_end = lod_starts[i + 1] if i + 1 < lod_starts.size() else cull_distance
		mi.visibility_range_end_margin = 2.0
		mi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_DISABLED

切り替えが目立つなら、まず各LODのシルエットが揃っているか確認してください。どのフェード設定よりもそちらが重要です。

ステップ3. 大量の木:MultiMesh、ただしチャンクに分ける

数十本程度なら個別のノードで十分です。数千本になるとMultiMeshが必要です。1つのMultiMeshInstance3Dで、メッシュのコピーを何個でも1回のドローコールで描画できます。

ただし、ほぼ全員が驚く落とし穴があります。ドキュメントには、多数のメッシュを一度に描画するノード——MultiMeshInstance3Dとパーティクル——はすべてのインスタンスを同じLODレベルで描画すること、そしてカリングはノード全体に対してしか働かず、個々のインスタンスは除外できないことが書かれています。Godotのメンテナーもissue #76436でこれを認めています。レベル全体を1つのMultiMeshにすると、木ごとのLODも、視錐台カリングもオクルージョンカリングも効きません。

解決策はチャンクです。ワールドをグリッドに分割し、各セルにLODレベルごとのMultiMeshを持たせ、それぞれに独自のVisibility Rangeを設定します。遠いチャンクは軽いLOD、近いチャンクは精細なLODを表示し、カメラの後ろのチャンクは丸ごとカリングされます。

# ForestChunks.gd — Visibility Range付きのチャンク分割MultiMeshを構築
extends Node3D

@export var lod_meshes: Array[Mesh] = []   # LOD0、LOD1、LOD2、インポスター
@export var lod_starts := PackedFloat32Array([0.0, 35.0, 80.0, 160.0])
@export var cull_distance := 400.0
@export var chunk_size := 32.0

func build(transforms: Array[Transform3D]) -> void:
	var chunks := {}
	for t in transforms:
		var key := Vector2i(floori(t.origin.x / chunk_size), floori(t.origin.z / chunk_size))
		if not chunks.has(key):
			chunks[key] = []
		chunks[key].append(t)
	for key in chunks:
		_build_chunk(chunks[key])

func _build_chunk(items: Array) -> void:
	for i in lod_meshes.size():
		var mm := MultiMesh.new()
		mm.transform_format = MultiMesh.TRANSFORM_3D
		mm.mesh = lod_meshes[i]
		mm.instance_count = items.size()
		for k in items.size():
			mm.set_instance_transform(k, items[k])
		var node := MultiMeshInstance3D.new()
		node.multimesh = mm
		node.visibility_range_begin = lod_starts[i]
		node.visibility_range_end = lod_starts[i + 1] if i + 1 < lod_starts.size() else cull_distance
		node.visibility_range_end_margin = 4.0
		add_child(node)

実践的なルールは2つです。

  • チャンクサイズはLOD0の距離に近づける。 距離はチャンクの中心までで測られるため、100 mのチャンクだとプレイヤーのすぐ隣の木がLOD1で表示されてしまいます。LOD0が35 mまでなら、24〜32 mのチャンクがうまく機能します。
  • ただし小さすぎてもいけない。 チャンクが増えるごとにドローコールも増えます(LODごと、マテリアルごとに1つ)。計測しましょう。画面上に数百チャンク程度ならデスクトップでは通常問題ありませんが、モバイルではずっと少なくする必要があります。

MultiMeshにはコリジョンがありません。プレイヤーが届く場所にだけシンプルな形状を追加してください。幹ごとにカプセルか円柱を1つ、樹冠には決して付けないこと。

FoliMeshエディターのバイオームの森 数百本のインスタンス化された木:構造が正しければ、森が広がってもドローコールはほぼ横ばいです。

ステップ4. 葉:アルファブレンドではなくAlpha Scissor

多くの森がパフォーマンスを失うのは葉です。透過の設定を誤ると、美しさの多くも失われます。

  • AlphaではなくAlpha Scissor(カットアウト)を使う。 マテリアルでTransparency → Alpha Scissorを設定します。ブレンドするアルファはソートが必要になり、深度と影を壊し、数百枚のカードが重なり合う樹冠ではずっと重くなります。
  • Alpha to Coverageでエッジを滑らかに。 MSAAを有効にした状態でAlpha Antialiasing → Alpha Edge Clip(またはBlend)を設定すると、本物の透過なしで葉の柔らかいエッジが得られます。
  • カードの形でオーバードローを抑える。 丸い小枝を四角いカードで囲むと、多くのピクセルが無駄になります。葉の塊の形に切り抜いたカードなら、シェーディングする不可視ピクセルがはるかに少なくて済みます。ビューポートのDisplay Overdrawモードで確認しましょう。樹冠が真っ赤なら層が多すぎます。
  • 誰にも見えない部分は削る。 樹冠の奥深くにある葉の塊は外からは見えません。遠距離のLODでは真っ先に削るべきです。

ステップ5. 影:隠れた第二の森

影を落とす木はすべて、シャドウマップ用にもう一度レンダリングされます。次の3つの設定で節約の大部分が得られます。

  • 影の距離を制限する。 DirectionalLight3D → Directional Shadow → Max Distance。100〜200 mより先では、個々の木の影がなくなっても気づくプレイヤーはほとんどいません。
  • 遠距離のLODでは影を切る。 LOD2とインポスターでCast Shadow → Offを設定します(MultiMeshの場合は、そのレベルのMultiMeshInstance3Dで)。
  • 非力なハードではカスケードを減らす。 カスケードを4つから2つにしても見た目の違いはほぼなく、節約効果ははっきり出ることが多いです。

ステップ6. オクルージョンカリングと風

オクルージョンカリングは、丘、崖、建物、そして地形そのものといった大きなオブジェクトが森を隠すときに効果を発揮します。Project Settings → Rendering → Occlusion Cullingで有効にし、OccluderInstance3Dを追加してベイクします。木そのものは穴だらけで、オクルーダーとしては役に立たないので、その役目は地形に任せましょう。もっとも恩恵を受けるのはチャンク分割したMultiMeshで、隠れたチャンクは丸ごとスキップされます。

風は頂点シェーダーに置き、メッシュにあらかじめ計算しておいたウェイトを読み込むべきです。幹はほとんど動かず、太い枝はゆっくり揺れ、葉先は素早くはためく。これをシェーダー内でゼロから計算するのは高コストですが、頂点ごとに2つの数値を読むだけならほぼ無料です。

ステップ7. ターゲットのハードウェアで計測する

当てずっぽうで最適化してはいけません。必要なものはGodotにすべてあります。

  • Debugger → Monitors:フレームあたりのドローコール、オブジェクト数、プリミティブ(ポリゴン)数。
  • Debugger → Visual Profiler:CPUとGPUがフレームの各部分にどれだけ時間を使っているか。
  • Viewport → Display Overdraw / Wireframe:ピクセルとポリゴンがどこに使われているか。

変更のたびに前後で計測してください。開発用PCだけでなく、サポートしたい中でもっとも非力なデバイスで。

チェックリスト

  • すべての木に距離ごとのポリゴン予算があり、各レベルは3〜4倍軽い。
  • 木は独自LODとVisibility Rangeを使い、自動メッシュLODは岩とプロップにのみ使う。
  • 数千本の木を、LOD0の距離程度のサイズのチャンク分割MultiMeshにしている。
  • 葉はAlpha ScissorとAlpha to Coverageを使い、カードは葉の塊の形に沿っている。
  • 影の距離を制限し、遠距離のLODとインポスターは影を落とさない。
  • 地形と大きなオブジェクトからオクルージョンカリングをベイクしている。
  • 風は頂点ごとのウェイトを読む軽量な頂点シェーダーで実装している。
  • ドローコールとフレーム時間をターゲットデバイスで計測している。

FoliMeshの役割

私たちは自分たちの大規模ゲームのためにFoliMeshを作ったので、FoliMeshが生成する木は最初からこれらのルールに沿っています。Godot用パッケージはプロジェクトにそのまま放り込めるフォルダで、中身は次のとおりです。ポリゴン予算に合わせて生成されたLOD0、LOD1、LOD2…入りのGLB、各レベルのVisibility Rangeをスクリプトで設定するすぐ使える.tscnシーン、風シェーダー(幹のしなりと枝の揺れはメッシュの2番目のUVチャンネルに格納)、そしてコリジョン用に幹を囲むカプセル付きのStaticBody3D。ノーマルマップは最初からGodotが想定するOpenGL形式です。

インポート前に、無料のブラウザーツールでどんなモデルでもチェックできます。LODビューアー、ポリゴンカウンター、glTFバリデーターをどうぞ。木のツール全体を俯瞰したい方は、3D樹木ジェネレーターの比較をご覧ください。

FAQ

Godot 4はLODを自動で作ってくれますか? はい。インポートしたglTF、.blend、Collada、FBXのシーンについては、インポート時にメッシュLODを生成します。連続した面でもっともよく機能します。木の場合は、手作りまたは生成したLODをVisibility Rangeと組み合わせる方が、予測しやすい結果になります。

MultiMeshでLODが効かないのはなぜですか? GodotはMultiMeshInstance3D全体に対して1つのLODレベルを選びます。インスタンスをチャンクに分け、それぞれに独自のノードとVisibility Rangeを持たせてください。

Godotでは何本の木を描画できますか? それはエンジンではなく、木1本あたりの予算次第です。チャンク分割したMultiMesh、軽い遠距離LOD、インポスターを使えば、デスクトップのハードウェアで数万本の木を画面に表示するのも現実的です。ターゲットデバイスで計測してください。

葉にはAlpha ScissorとAlpha Hash、どちらを使うべきですか? 一般的なのはAlpha ScissorとAlpha to Coverageの組み合わせです。Alpha HashはMSAAなしでもエッジが柔らかくなりますが、ノイズが目立ち、TAAと組み合わせたときにもっともよく機能します。