FoliMesh
博客

在 Godot 4 中打造不掉帧的森林:MultiMesh、LOD 与可见范围

Godot 4 高性能植被实战指南:三角面预算、自动网格 LOD 与可见范围的取舍、分块 MultiMesh、树叶透明、阴影、遮挡剔除,以及如何测量效果。

·阅读约 3 分钟#godot#optimization#lod#tutorial

Godot 项目里的第一片森林通常看起来很棒——直到你走进去。种到两百棵树时帧率减半,性能分析器里显示几千个 draw call,却没人知道该改哪个设置。Godot 论坛上就有用户描述过这种情况:自制的草地 LOD 系统在一小块地面上跑出了约 10 FPS 和 3,000 多个 draw call。

好消息是:要做出又大又快的森林,Godot 4 需要的工具一应俱全,只是得用对组合方式。本文按重要程度依次讲解——预算、LOD、实例化、树叶、阴影、剔除——最后附上一份检查清单。

森林为什么慢

树是游戏中开销最大的物体之一。它同时在四个地方消耗性能:

  1. Draw call。 每棵树至少有两个网格(树皮和树叶),材质往往也不同。一百棵独立的树意味着在画其他东西之前就已经有几百个 draw call。
  2. 三角面和顶点。 一棵精细的树轻松超过 40,000 个三角面。一棵主角树没问题,满山坡都是就要命了。
  3. Overdraw(过度绘制)。 树叶是带透明像素的面片。GPU 仍然要为树冠中每一张面片逐层着色那些看不见的部分。
  4. 阴影。 每棵树还要再画一遍到阴影贴图里——有时要画好几遍,每个阴影级联一次。

下面每一种技巧都针对其中一项或几项开销。

第 1 步:动手之前先定预算

最有用的一个习惯,是在建模或生成之前就决定每棵树在每个距离上允许多少三角面。下面是 FoliMesh 中一棵真实的橡树,展示一条可直接用于游戏的 LOD 链是什么样子:

级别 距离(示例) 三角面
LOD0 0–30 m 14,490
LOD1 30–70 m 3,984
LOD2 70–150 m 1,154
Impostor 150 m + 2

重要的不是具体数字,而是曲线的形状:每一级大约比上一级轻 3–4 倍,最远处的树几乎不花成本。移动端则把所有数值再除以二到三。森林的大部分都在远处,所以森林的大部分都应该很便宜。

同一棵橡树的 LOD0、LOD1、LOD2 和 LOD3 同一棵树的四个细节层级:轮廓保持不变,三角面数量逐级下降。

第 2 步:选择 LOD 机制——自动网格 LOD 还是可见范围

Godot 4 提供两套不同的 LOD 系统,给树选错系统是常见的问题根源。

自动网格 LOD。 导入 glTF、.blend 或 FBX 场景时,Godot 会用 meshoptimizer 为每个网格生成简化版本,并自动在它们之间切换。根据官方文档,选择依据是节点包围盒上离相机最近的点,以及误差会覆盖多少像素(Rendering → Mesh LOD → LOD Change → Threshold Pixels,默认 1 像素)。每个节点还有一个 LOD Bias。

这对岩石、建筑和道具——也就是连续曲面——效果很好。树冠则不同:它由几百张独立的树叶面片组成。通用简化算法并不是为此设计的,结果可能是树冠变稀、变形。文档本身也建议:如果你有手工制作的 LOD,就使用另一种工具。

可见范围(HLOD)。 这里由你自己提供 LOD 网格,并告诉每个网格在什么距离可见。每个 GeometryInstance3D 都有:

  • visibility_range_begin / visibility_range_end —— 节点被绘制的距离区间(测量到其包围盒中心);
  • visibility_range_begin_margin / visibility_range_end_margin —— 缓冲区,避免相机正好停在边界时级别来回闪烁;
  • visibility_range_fade_mode —— Disabled(瞬间切换,最快)、Self 或 Dependencies(Alpha 渐变;过渡期间会强制透明渲染,有性能开销)。

对树来说,可见范围是更可靠的选择:LOD 是专为植被设计的,距离完全由你掌控,最后一级还可以关闭阴影或换成 Impostor。单棵树的最简配置:

# 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:一个 MultiMeshInstance3D 能在一次 draw call 中绘制任意数量的网格副本。

这里有个几乎让所有人意外的坑。文档写明,一次绘制大量网格的节点——MultiMeshInstance3D 和粒子——所有实例都使用同一个 LOD 级别,而且剔除只能针对整个节点,不能剔除单个实例。一位 Godot 维护者在 issue #76436 中确认:整个关卡只用一个 MultiMesh,就意味着没有逐棵树的 LOD,也没有视锥剔除和遮挡剔除。

解决方法是分块(chunk)。把世界划分成网格,为每个格子的每个 LOD 级别分别创建一个 MultiMesh,各自设定可见范围。远处的块显示轻量 LOD,近处的块显示精细 LOD,相机背后的块则被整个剔除。

# ForestChunks.gd — 构建带可见范围的分块 MultiMesh
extends Node3D

@export var lod_meshes: Array[Mesh] = []   # LOD0、LOD1、LOD2、Impostor
@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)

两条实用规则:

  • 块的尺寸接近 LOD0 距离。 距离是测量到块中心的,所以如果块有 100 m,紧挨着玩家的树也可能显示为 LOD1。LOD0 范围到 35 m 时,24–32 m 的块效果很好。
  • 也不要太小。 每个块都会增加 draw call(每个 LOD 每种材质各一个)。要实际测量:通常屏幕上有几百个块在桌面端还能接受,移动端则要少得多。

MultiMesh 没有碰撞。只在玩家能到达的地方添加简单形状——每根树干一个胶囊体或圆柱体,绝不要给树冠加。

FoliMesh 编辑器中的生物群系森林 数百棵实例化的树:结构正确,森林再扩大,draw call 数量也几乎不变。

第 4 步:树叶——用 Alpha Scissor,而不是 Alpha 混合

大多数森林的性能都损失在树叶上——如果透明设置不对,美观也会大打折扣。

  • 用 Alpha Scissor(镂空),而不是 Alpha。 在材质中设置 Transparency → Alpha Scissor。混合 Alpha 需要排序,会破坏深度和阴影,而在由几百张相互重叠的面片组成的树冠上,开销要大得多。
  • 用 Alpha to Coverage 柔化边缘。 开启 MSAA 后,设置 Alpha Antialiasing → Alpha Edge Clip(或 Blend),无需真正的透明就能得到柔和的叶片边缘。
  • 用面片形状对抗 Overdraw。 圆形小枝外面套一张方形面片会浪费大量像素。按叶簇形状裁切的面片需要着色的不可见像素少得多。用视口的 Display Overdraw 模式检查:树冠呈亮红色就说明层数太多。
  • 去掉没人看得见的部分。 树冠深处的叶簇从外面根本看不到;远处的 LOD 应该最先去掉它们。

第 5 步:阴影——隐藏的第二片森林

每棵投射阴影的树都要为阴影贴图再渲染一遍。以下三个设置能带来大部分收益:

  • 限制阴影距离。 DirectionalLight3D → Directional Shadow → Max Distance。超过 100–200 m,很少有玩家会注意到单棵树的阴影消失了。
  • 关闭远处 LOD 的阴影。 在 LOD2 和 Impostor 上设置 Cast Shadow → Off(对 MultiMesh 而言,设在对应级别的 MultiMeshInstance3D 上)。
  • 在低端硬件上减少级联数。 用两个级联代替四个,画面上往往看不出区别,性能却明显提升。

第 6 步:遮挡剔除与风

遮挡剔除(Occlusion Culling) 在有大型物体遮挡森林时很有用:山丘、悬崖、建筑,以及地形本身。在 Project Settings → Rendering → Occlusion Culling 中启用,添加 OccluderInstance3D 并烘焙。树本身是很差的遮挡体——到处是孔洞——所以让地形来做这件事。分块 MultiMesh 受益最大:整个被遮挡的块会被直接跳过。

风应该放在顶点着色器里,并从网格中读取预先计算好的权重:树干几乎不动,大树枝缓慢摇摆,叶尖快速颤动。在着色器里从头计算这些开销很大;每个顶点读取两个数字几乎没有成本。

第 7 步:在目标硬件上测量

永远不要盲目优化。Godot 提供了你需要的一切:

  • Debugger → Monitors:每帧的 draw call、物体数、图元(三角面)数。
  • Debugger → Visual Profiler:CPU 和 GPU 在帧的每个部分各花了多少时间。
  • Viewport → Display Overdraw / Wireframe:像素和三角面都花在了哪里。

每次修改前后都要测量,而且要在你打算支持的最弱设备上测——不能只在开发用的电脑上测。

检查清单

  • 每棵树在每个距离上都有三角面预算,每一级轻 3–4 倍。
  • 树使用自带 LOD 的可见范围;自动网格 LOD 只用于岩石和道具。
  • 成千上万的树被拆分为分块 MultiMesh,块的大小约等于 LOD0 距离。
  • 树叶使用 Alpha Scissor 加 Alpha to Coverage;面片贴合叶簇形状。
  • 阴影距离受限;远处 LOD 和 Impostor 不投射阴影。
  • 基于地形和大型物体烘焙了遮挡剔除。
  • 风是读取逐顶点权重的廉价顶点着色器。
  • 在目标设备上测量了 draw call 和帧时间。

FoliMesh 能帮上什么

我们为自己的大型游戏打造了 FoliMesh,所以它生成的树本身就遵循上述规则。Godot 资源包是一个可以直接拖进项目的文件夹:按你的三角面预算生成的包含 LOD0、LOD1、LOD2… 的 GLB,一个现成的 .tscn 场景(其脚本为每一级设置可见范围),一个风着色器(树干弯曲和树枝摇摆存储在网格的第二套 UV 通道中),以及一个用于碰撞、包裹树干的带胶囊体的 StaticBody3D。法线贴图已经是 Godot 所需的 OpenGL 格式。

导入前,你可以用我们的免费浏览器工具检查任意模型:LOD 查看器、多边形计数器和 glTF 验证器。想全面了解各类树木工具,请看我们的 3D 树木生成器对比。

常见问题

Godot 4 会自动生成 LOD 吗? 会——对于导入的 glTF、.blend、Collada 和 FBX 场景,它会在导入时生成网格 LOD。这在连续曲面上效果最好;对于树,手工制作或生成的 LOD 配合可见范围,结果更可控。

为什么我的 MultiMesh 用不了 LOD? Godot 会为整个 MultiMeshInstance3D 选择同一个 LOD 级别。把实例拆分成块,每个块使用各自的节点和可见范围。

Godot 能渲染多少棵树? 这取决于每棵树的预算,而不是引擎本身。使用分块 MultiMesh、廉价的远景 LOD 和 Impostor,在桌面硬件上同屏数万棵树是可行的;请在你的目标设备上实测。

树叶用 Alpha Scissor 还是 Alpha Hash? 通常选择 Alpha Scissor 加 Alpha to Coverage。Alpha Hash 在没有 MSAA 时边缘更柔和,但噪点明显,最好配合 TAA 使用。