Unity 中如何创建一片森林:享元模式能不能用?应该怎么用?

前言

在 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

是最适合学习和落地的方案。

项目中的推荐落地顺序

不要一开始就做完整开放世界植被系统。

推荐按这个顺序实现:

  1. 先用 TreeDefinition 管理共享树种数据。
  2. TreeInstanceData 保存每棵树的位置、旋转、缩放。
  3. DrawMeshInstanced 批量渲染不可交互树。
  4. 增加随机分布和树种权重。
  5. 增加玩家附近对象池,把近处树转成可交互 GameObject。
  6. 增加 LOD 或远处 Billboard。
  7. 最后再考虑分块加载和存档。

总结

在 Unity 中创建一片森林,可以使用享元模式,而且这是非常适合享元模式的场景。

但享元模式只解决"共享数据"和"实例状态"的拆分问题。

真正高性能的森林系统,还需要配合 Unity 的渲染优化策略。

最优解不是:

text 复制代码
Instantiate 一万棵树

而是:

text 复制代码
TreeDefinition 共享树种数据
TreeInstanceData 保存单棵树状态
Graphics.DrawMeshInstanced 批量渲染
对象池处理近处交互树
LOD / 分块处理大规模场景

这套方案既符合享元模式,也符合 Unity 实际性能优化方向。对于要创建大量重复但又有少量差异对象的场景,比如森林、草地、石头群、建筑装饰、敌人投影,这个思路都可以复用。

相关推荐
め.11 小时前
定点数运算库
算法·unity
Duo1J14 小时前
【UE】Slate 编辑器工具开发03 - 节点编辑器 (EdGraph)
ue5·编辑器·游戏引擎·ue4
郝学胜-神的一滴15 小时前
[简化版 GAMES 104] 现代游戏引擎 02:拆解现代游戏引擎5+1层级架构,吃透引擎底层核心逻辑
c++·unity·架构·游戏引擎·图形渲染·unreal engine·系统设计
LONGZETECH17 小时前
新能源汽车充电设备装配与调试仿真教学软件 技术架构与核心实现解析
大数据·算法·3d·unity·架构·汽车
丁小未17 小时前
Unity 几种常见合批手段的要求
游戏·unity·合批·srpbatcher·动态合批·静态合批
旧物有情2 天前
游戏开发常用架构 #MVP,MVC
游戏·unity·架构·mvc
勇踏前人未索之境2 天前
Unity打包运行于鸿蒙手机
unity·智能手机·harmonyos
玖玥拾2 天前
Unity 3D 笔记(十一)UI 框架进阶:栈弹窗交互、BasePanel 基类虚方法、DoTween 界面动画
笔记·3d·unity