前言
在 Unity 中创建一片森林,看起来像是一个简单问题:
准备几棵树的 Prefab,然后在场景里复制很多份。
如果森林里只有几十棵树,这样做没有问题。
但如果是几千棵、几万棵树,直接 Instantiate 大量完整 GameObject 很快会带来性能问题:
- 场景中 GameObject 数量过多。
- Transform 更新成本增加。
- Renderer、Collider、脚本组件数量过多。
- 材质和 Mesh 如果没有复用,会占用大量内存。
- Draw Call 增多,渲染压力变大。
所以高级一点的做法不是"复制很多棵树",而是先分析一棵树中哪些数据可以共享,哪些数据必须每棵树独立保存。
这正好对应设计模式中的享元模式。
什么是享元模式
享元模式的核心思想是:
把大量对象中可以共享的部分抽出来,只保存一份;每个实例只保存自己独有的状态。
放到森林问题中,可以这样理解:
一棵树包含两类数据。
第一类是可以共享的数据:
- 树的 Mesh。
- 树皮材质。
- 树叶材质。
- 树的 Prefab。
- LOD 配置。
- 树种名称。
- 基础高度范围。
- 基础颜色范围。
这些数据对同一类树来说基本相同,没必要每棵树都复制一份。
第二类是每棵树独有的数据:
- 世界坐标。
- 旋转角度。
- 缩放比例。
- 随机颜色偏移。
- 当前是否被砍倒。
- 当前生命值。
- 是否进入交互范围。
这些数据每棵树都不一样,必须单独保存。
享元模式的重点就是:
text
共享数据:TreeDefinition
实例状态:TreeInstanceData
享元模式适不适合 Unity 森林
适合,但不能只靠享元模式。
享元模式解决的是数据复用和内存结构问题,不是完整的渲染优化方案。
Unity 中真正创建一片大森林,最优做法通常是组合方案:
text
享元模式
↓
共享树种数据,减少重复配置和重复资源
GPU Instancing / DrawMeshInstanced
↓
批量渲染大量相同 Mesh 和 Material
LODGroup / Impostor
↓
远处树降低模型复杂度
对象池
↓
只给玩家附近可交互的树创建 GameObject
空间分块
↓
只加载或渲染玩家附近区域
所以答案是:
可以用享元模式,但不要把享元模式当成唯一方案。它应该负责"树种共享数据",渲染和交互还要结合 Unity 的批处理、LOD、对象池和空间分块。
最优方案结构
推荐结构如下:
text
TreeDefinition
↓
共享树种数据
TreeInstanceData
↓
每棵树自己的位置、旋转、缩放、状态
ForestManager
↓
管理森林数据、生成实例矩阵、提交渲染
TreeInteractable
↓
只给玩家附近或需要交互的树创建 GameObject
第一步:定义共享树种数据
创建一个 ScriptableObject 保存树种数据。
csharp
using UnityEngine;
/// <summary>
/// 树种共享配置。
/// 同一类树共用一份 TreeDefinition,避免每棵树重复保存 Mesh、Material 和基础参数。
/// </summary>
[CreateAssetMenu(menuName = "Forest/Tree Definition")]
public class TreeDefinition : ScriptableObject
{
/// <summary>
/// 树种名称。
/// 主要用于编辑器识别和调试。
/// </summary>
public string treeName;
/// <summary>
/// 树的网格资源。
/// 大量树实例会共享这个 Mesh。
/// </summary>
public Mesh mesh;
/// <summary>
/// 树的材质资源。
/// 要支持 GPU Instancing,需要在材质上开启 Enable GPU Instancing。
/// </summary>
public Material material;
/// <summary>
/// 当前树种允许的最小缩放。
/// 用于生成森林时制造自然差异。
/// </summary>
public float minScale = 0.8f;
/// <summary>
/// 当前树种允许的最大缩放。
/// </summary>
public float maxScale = 1.3f;
/// <summary>
/// 当前树种在随机生成时的权重。
/// 数值越高,在森林中出现概率越大。
/// </summary>
public float weight = 1f;
}
这就是享元模式中的"享元对象"。
它只保存树种共享数据,不保存某一棵树的位置。
第二步:定义每棵树的实例数据
csharp
using UnityEngine;
/// <summary>
/// 单棵树的实例数据。
/// 这里只保存每棵树独有的轻量状态,不直接持有 Mesh、Material 等共享资源。
/// </summary>
public struct TreeInstanceData
{
/// <summary>
/// 当前树使用的树种下标。
/// 通过这个下标到 ForestManager 的 treeDefinitions 中找到共享 TreeDefinition。
/// </summary>
public int definitionIndex;
/// <summary>
/// 当前树的世界坐标。
/// </summary>
public Vector3 position;
/// <summary>
/// 当前树绕 Y 轴的旋转角度。
/// 森林树木通常只需要水平随机旋转。
/// </summary>
public float yRotation;
/// <summary>
/// 当前树的缩放。
/// </summary>
public float scale;
/// <summary>
/// 当前树是否已经被移除。
/// 例如被砍倒后,可以不再参与批量渲染。
/// </summary>
public bool removed;
/// <summary>
/// 根据位置、旋转、缩放生成渲染矩阵。
/// DrawMeshInstanced 会使用这个矩阵决定每棵树的显示位置。
/// </summary>
public Matrix4x4 GetMatrix()
{
Quaternion rotation = Quaternion.Euler(0f, yRotation, 0f);
Vector3 treeScale = Vector3.one * scale;
return Matrix4x4.TRS(position, rotation, treeScale);
}
}
这里的实例数据很轻。
一万棵树就是一万个 TreeInstanceData,而不是一万个完整 GameObject。
第三步:用 ForestManager 生成森林
csharp
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 森林管理器。
/// 负责生成树实例数据,并使用 GPU Instancing 批量绘制森林。
/// </summary>
public class ForestManager : MonoBehaviour
{
/// <summary>
/// 森林中可使用的树种。
/// 每种树是一份共享配置。
/// </summary>
[SerializeField] private TreeDefinition[] treeDefinitions;
/// <summary>
/// 生成树的数量。
/// 数量很大时不建议全部生成 GameObject,应使用实例数据和批量渲染。
/// </summary>
[SerializeField] private int treeCount = 5000;
/// <summary>
/// 森林生成范围。
/// x 表示宽度,y 表示长度。
/// </summary>
[SerializeField] private Vector2 forestSize = new Vector2(200f, 200f);
/// <summary>
/// 随机种子。
/// 固定种子可以保证每次运行生成同一片森林。
/// </summary>
[SerializeField] private int seed = 1001;
/// <summary>
/// 所有树实例的轻量数据。
/// </summary>
private readonly List<TreeInstanceData> treeInstances = new List<TreeInstanceData>();
/// <summary>
/// 每批 DrawMeshInstanced 最多支持 1023 个矩阵。
/// 这是 Unity API 的限制,所以渲染时需要分批提交。
/// </summary>
private const int BatchSize = 1023;
private void Start()
{
GenerateForest();
}
private void Update()
{
RenderForest();
}
/// <summary>
/// 生成森林实例数据。
/// 这里只生成位置、旋转、缩放和树种索引,不创建大量 GameObject。
/// </summary>
private void GenerateForest()
{
treeInstances.Clear();
Random.InitState(seed);
for (int i = 0; i < treeCount; i++)
{
int definitionIndex = GetRandomTreeDefinitionIndex();
TreeDefinition definition = treeDefinitions[definitionIndex];
Vector3 position = new Vector3(
Random.Range(-forestSize.x * 0.5f, forestSize.x * 0.5f),
0f,
Random.Range(-forestSize.y * 0.5f, forestSize.y * 0.5f)
);
TreeInstanceData instance = new TreeInstanceData
{
definitionIndex = definitionIndex,
position = position,
yRotation = Random.Range(0f, 360f),
scale = Random.Range(definition.minScale, definition.maxScale),
removed = false
};
treeInstances.Add(instance);
}
}
/// <summary>
/// 按树种权重随机选择树种。
/// 权重越大的树种,生成概率越高。
/// </summary>
private int GetRandomTreeDefinitionIndex()
{
float totalWeight = 0f;
for (int i = 0; i < treeDefinitions.Length; i++)
{
totalWeight += Mathf.Max(0f, treeDefinitions[i].weight);
}
float randomValue = Random.Range(0f, totalWeight);
float currentWeight = 0f;
for (int i = 0; i < treeDefinitions.Length; i++)
{
currentWeight += Mathf.Max(0f, treeDefinitions[i].weight);
if (randomValue <= currentWeight)
{
return i;
}
}
return 0;
}
/// <summary>
/// 批量渲染森林。
/// 按树种分组后提交 DrawMeshInstanced,让同一 Mesh 和 Material 的树合批绘制。
/// </summary>
private void RenderForest()
{
for (int definitionIndex = 0; definitionIndex < treeDefinitions.Length; definitionIndex++)
{
TreeDefinition definition = treeDefinitions[definitionIndex];
if (definition == null || definition.mesh == null || definition.material == null)
{
continue;
}
List<Matrix4x4> matrices = new List<Matrix4x4>(BatchSize);
for (int i = 0; i < treeInstances.Count; i++)
{
TreeInstanceData instance = treeInstances[i];
if (instance.removed || instance.definitionIndex != definitionIndex)
{
continue;
}
matrices.Add(instance.GetMatrix());
if (matrices.Count == BatchSize)
{
DrawBatch(definition, matrices);
matrices.Clear();
}
}
if (matrices.Count > 0)
{
DrawBatch(definition, matrices);
}
}
}
/// <summary>
/// 提交一批树的渲染。
/// </summary>
private void DrawBatch(TreeDefinition definition, List<Matrix4x4> matrices)
{
Graphics.DrawMeshInstanced(
definition.mesh,
0,
definition.material,
matrices
);
}
}
这套代码已经体现了享元模式:
text
TreeDefinition:共享资源
TreeInstanceData:独立状态
ForestManager:组合共享资源和实例状态
这个方案和直接 Instantiate 的区别
直接 Instantiate 的思路是:
text
一棵树 = 一个 GameObject + 一个 Transform + 一个 Renderer + 一套组件
享元模式加批量渲染的思路是:
text
树种资源 = 共享
单棵树 = 一份轻量数据
渲染 = 按树种批量提交
这能明显减少:
- GameObject 数量。
- Transform 数量。
- Renderer 数量。
- 组件更新成本。
- 内存占用。
- Draw Call。
可交互树怎么处理
真实游戏里,树不一定只是背景。
如果玩家可以砍树、采集、碰撞,不能所有树都只是矩阵数据。
但也不应该给所有树都创建完整 GameObject。
最优做法是:
text
远处树:只用 DrawMeshInstanced 渲染
近处树:从对象池取出 TreeInteractable GameObject
离开范围:回收到对象池,重新变回轻量实例
也就是说:
只有玩家附近需要交互的树才是真正的 GameObject。
这样既保留性能,又能满足交互需求。
TreeInteractable 示例
csharp
using UnityEngine;
/// <summary>
/// 可交互树对象。
/// 只用于玩家附近需要碰撞、砍伐或采集的树。
/// </summary>
public class TreeInteractable : MonoBehaviour
{
/// <summary>
/// 当前交互对象对应的树实例下标。
/// 用于交互结束后把状态写回 ForestManager。
/// </summary>
public int instanceIndex;
/// <summary>
/// 当前树的生命值。
/// 示例中只演示状态,实际项目可扩展砍树动画和掉落物。
/// </summary>
public float health = 100f;
/// <summary>
/// 受到伤害。
/// </summary>
public void TakeDamage(float damage)
{
health -= damage;
if (health <= 0f)
{
gameObject.SetActive(false);
}
}
}
注意:
交互树对象不应该重新保存 Mesh 和 Material,它仍然应该引用 TreeDefinition 里的共享资源。
是否一定要自己写 DrawMeshInstanced
不一定。
如果是开放世界、大规模自然场景,可以优先考虑 Unity 自带或成熟方案:
- Terrain Tree 系统。
- GPU Instancer。
- Vegetation Studio。
- DOTS Entities Graphics。
- 自定义
DrawMeshInstancedIndirect。
如果只是中小规模场景,或者你想学习架构设计,那么本文这种:
text
ScriptableObject 享元数据 + TreeInstanceData + DrawMeshInstanced
是最适合学习和落地的方案。
项目中的推荐落地顺序
不要一开始就做完整开放世界植被系统。
推荐按这个顺序实现:
- 先用
TreeDefinition管理共享树种数据。 - 用
TreeInstanceData保存每棵树的位置、旋转、缩放。 - 用
DrawMeshInstanced批量渲染不可交互树。 - 增加随机分布和树种权重。
- 增加玩家附近对象池,把近处树转成可交互 GameObject。
- 增加 LOD 或远处 Billboard。
- 最后再考虑分块加载和存档。
总结
在 Unity 中创建一片森林,可以使用享元模式,而且这是非常适合享元模式的场景。
但享元模式只解决"共享数据"和"实例状态"的拆分问题。
真正高性能的森林系统,还需要配合 Unity 的渲染优化策略。
最优解不是:
text
Instantiate 一万棵树
而是:
text
TreeDefinition 共享树种数据
TreeInstanceData 保存单棵树状态
Graphics.DrawMeshInstanced 批量渲染
对象池处理近处交互树
LOD / 分块处理大规模场景
这套方案既符合享元模式,也符合 Unity 实际性能优化方向。对于要创建大量重复但又有少量差异对象的场景,比如森林、草地、石头群、建筑装饰、敌人投影,这个思路都可以复用。