1. 项目概述当大地图遇上密集植被如果你做过开放世界或者大场景的游戏肯定对这个问题不陌生地图上种满了成千上万棵树、灌木丛和花草一运行游戏帧率直接掉到个位数编辑器卡得跟幻灯片一样。传统的做法比如给每个植被挂个MeshRenderer或者用GPU Instancing在植被数量爆炸时还是会遇到瓶颈。前者Draw Call上天后者在动态处理比如植被受击、季节变化时不够灵活。这个项目要解决的就是这个让无数开发者头疼的“大地图植被渲染”性能难题。核心思路很直接用Unity 2022 LTS及以上版本提供的BatchRendererGroup (BRG)API配合JobSystem进行高效的数据准备与更新将海量静态或半静态的植被渲染合并成极少的Draw Call。这不是简单的API调用教学而是从原理到实战带你构建一个能处理数万乃至数十万植被实例、支持视锥体剔除、支持动态数据更新如风动、生长的完整解决方案。我会把踩过的坑、参数调优的心得以及能直接拿去用的完整代码都分享出来。2. 核心方案选型为什么是BatchRendererGroup JobSystem在动手之前得先搞清楚我们为什么选这个组合拳。市面上处理大量相同模型渲染的方案不少各有优劣。GPU Instancing大家最熟悉。它通过一个Draw Call绘制多个相同网格和材质的物体性能提升显著。但它有个硬伤所有实例的材质属性通过MaterialPropertyBlock设置必须完全相同或者遵循严格的数组结构。如果你想动态地、逐实例地修改颜色、变换矩阵管理起来会变得复杂且在某些平台有数量限制。更重要的是其剔除和提交逻辑对开发者不够透明自定义控制能力较弱。Graphics.DrawMeshInstanced或Graphics.DrawMeshInstancedIndirect给了我们更多的控制权特别是Indirect版本配合ComputeBuffer可以实现GPU Driven的渲染管线。功能强大但上手门槛高需要手动管理缓冲区、参数缓冲并编写对应的Shader来读取实例数据对新手不友好。传统GameObject MeshRenderer最简单但性能最差。每个植被都是一个独立的GameObject产生独立的Draw Call和GameObject开销在数量面前毫无招架之力。而BatchRendererGroup (BRG)可以看作是Unity官方提供的一个更高级、更系统化的Instancing管理框架。它位于引擎渲染管线的更底层允许我们直接向SRP无论是URP还是HDRP提交大批量的渲染数据。它的优势在于极致的Draw Call合并BRG的核心目标就是将共享同一材质和网格的多个实例在CPU端就打包好最终生成最小的Draw Call批次。灵活的数据驱动渲染所需的数据变换矩阵、颜色、UV偏移等完全由我们通过NativeArray或ComputeBuffer提供。这意味着我们可以用JobSystem并行、高效地准备和更新这些数据。与渲染管线深度集成BRG直接对接SRP的批处理流程能更好地利用SRP Batcher如果材质兼容等优化并且其剔除回调OnPerformCulling让我们可以插入自定义的视锥体、遮挡剔除逻辑。内存与性能可控数据存储在非托管内存NativeArray或GPU内存ComputeBuffer中避免了托管内存的GC开销数据布局也更紧凑访问效率高。JobSystem在这里扮演了“数据工厂”的角色。植被的矩阵计算、LOD选择、动态效果如随风摇摆的每帧更新都是高度并行化的计算任务。用JobSystem来处理这些任务可以充分利用多核CPU避免在主线程上造成卡顿确保向BRG提交的数据是高效准备好的。所以BRG JobSystem的组合实际上是将“数据准备”JobSystem并行计算与“数据提交渲染”BRG高效批处理解耦并分别进行高度优化从而应对大地图植被这种极端场景。3. 系统架构与数据流设计在写第一行代码前我们需要规划好整个系统的数据流。理解数据如何流动比记住API调用更重要。整个系统可以分成几个核心模块管理器VegetationManager单例或场景根节点组件负责初始化BRG持有所有植被数据并每帧驱动Job执行和BRG更新。植被数据VegetationData这是一个结构体struct定义了单个植被实例的所有信息。它需要包含Matrix4x4 localToWorldMatrix 实例的变换矩阵。Vector3 position 世界坐标可用于剔除和排序。int meshLODIndex 当前使用的网格LOD索引。float windStrength 风效强度等自定义属性。这个结构体将存储在NativeArrayVegetationData中。网格与材质库存储植被可能用到的几种网格通常包含多个LOD级别的Mesh和对应的材质球。BRG批次是按“网格-材质”对来划分的。计算任务JobsUpdateTransformsJob 并行计算所有植被实例的变换矩阵。如果植被是静态的这个Job可能只在初始化时运行一次。CullingJob 并行执行视锥体剔除。每个Job线程处理一批实例判断其是否在相机视野内并输出一个可见实例的索引列表。WindAnimationJob 如果需要动态风效这个Job并行计算每棵草/树基于时间和位置的摆动偏移并更新到变换数据中。BatchRendererGroup 核心渲染组件。我们向其注册网格和材质并在OnPerformCulling回调中将可见实例的数据经过Job计算后的提交给它。一帧内的数据流可以概括为主线程触发 - JobSystem调度计算任务更新矩阵、剔除 - 任务完成数据就绪 - 在BRG的剔除回调中将可见实例数据批量提交给渲染管线 - GPU渲染。这种设计确保了计算密集型任务脱离主线程主线程只负责轻量的调度和提交工作从而保障帧率的稳定。4. 实战一步步构建植被渲染系统接下来我们进入实战环节。我会分步骤并附上关键代码。4.1 步骤一定义数据结构与初始化首先定义植被实例的数据结构。使用struct并实现IEquatable以便于比较。using Unity.Collections; using Unity.Mathematics; using UnityEngine; using UnityEngine.Rendering; public struct VegetationInstance : IEquatableVegetationInstance { public float3 position; // 世界空间位置 public quaternion rotation; // 旋转 public float3 scale; // 缩放 public float4 color; // 颜色 (RGBA) public float windOffset; // 用于风效的随机偏移 // 用于生成变换矩阵 public Matrix4x4 Matrix { get { return Matrix4x4.TRS(position, rotation, scale); } } public bool Equals(VegetationInstance other) { ... } public override int GetHashCode() { ... } }然后创建核心管理器VegetationRenderer。public class VegetationRenderer : MonoBehaviour { public Mesh mesh; // 植被网格 public Material material; // 植被材质 public int instanceCount 10000; // 初始实例数量 private BatchRendererGroup m_BRG; private NativeArrayVegetationInstance m_Instances; private NativeArrayMatrix4x4 m_Matrices; // 变换矩阵数组 private NativeArrayfloat4 m_Colors; // 颜色数组 private GraphicsBuffer m_InstanceDataBuffer; // GPU端数据缓冲区 private void Start() { InitializeBRG(); InitializeInstances(); ScheduleUpdateJobs(); } private void InitializeBRG() { // 创建BatchRendererGroup m_BRG new BatchRendererGroup(this.OnPerformCulling); // 将网格和材质注册到BRG得到一个BatchID var batchID m_BRG.AddBatch(mesh, 0, material, 0, ShadowCastingMode.On, true, false); // 这里需要保存batchID用于后续的数据关联 } private void InitializeInstances() { // 分配非托管内存数组 m_Instances new NativeArrayVegetationInstance(instanceCount, Allocator.Persistent); m_Matrices new NativeArrayMatrix4x4(instanceCount, Allocator.Persistent); m_Colors new NativeArrayfloat4(instanceCount, Allocator.Persistent); // 随机初始化实例位置和属性 for (int i 0; i instanceCount; i) { var instance new VegetationInstance(); instance.position new float3(Random.Range(-500f, 500f), 0f, Random.Range(-500f, 500f)); instance.rotation Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); instance.scale Vector3.one * Random.Range(0.8f, 1.2f); instance.color new float4(Random.Range(0.7f, 1f), Random.Range(0.7f, 1f), Random.Range(0.7f, 1f), 1f); instance.windOffset Random.Range(0f, 6.28f); m_Instances[i] instance; } // 创建GPU缓冲区 m_InstanceDataBuffer new GraphicsBuffer(GraphicsBuffer.Target.Structured, instanceCount, System.Runtime.InteropServices.Marshal.SizeOf(typeof(InstanceData))); // InstanceData是一个匹配Shader中结构化缓冲区的结构体 } }注意GraphicsBuffer是Unity较新版本中用于替代ComputeBuffer进行GPU数据交互的API它更通用。你需要定义一个与Shader中StructuredBuffer相匹配的struct。4.2 步骤二使用JobSystem并行更新数据假设我们需要每帧更新风效。创建一个Job来计算风的影响。using Unity.Burst; using Unity.Jobs; using Unity.Collections; [BurstCompile] // 使用Burst编译获得极致性能 public struct UpdateWindJob : IJobParallelFor { public NativeArrayVegetationInstance instances; public float time; public float windSpeed; public float windStrength; public void Execute(int index) { var instance instances[index]; // 简单的正弦波模拟风效影响旋转和位置偏移 float windWave math.sin(time * windSpeed instance.position.x * 0.01f instance.position.z * 0.01f instance.windOffset) * windStrength; // 计算新的旋转例如让树干轻微摇摆 var windRotation Quaternion.Euler(windWave * 5f, 0f, windWave * 3f); instance.rotation math.mul(instance.rotation, windRotation); // 也可以轻微影响位置模拟草被吹弯 instance.position.y math.sin(windWave) * 0.1f * instance.scale.y; instances[index] instance; } }在管理器中调度这个Jobprivate void Update() { // 1. 调度更新Job var updateWindJob new UpdateWindJob { instances m_Instances, time Time.time, windSpeed 1.0f, windStrength 0.5f }; JobHandle windJobHandle updateWindJob.Schedule(m_Instances.Length, 64); // 每64个实例一个任务块 // 2. 调度一个将VegetationInstance转换为Matrix4x4的Job var updateMatricesJob new UpdateMatricesJob { instances m_Instances, matrices m_Matrices }; JobHandle matrixJobHandle updateMatricesJob.Schedule(m_Instances.Length, 64, windJobHandle); // 依赖windJob // 3. 等待所有计算Job完成 matrixJobHandle.Complete(); // 4. 现在m_Matrices中的数据已经是最新的可以更新到GraphicsBuffer UpdateGPUBuffers(); }UpdateMatricesJob是一个简单的并行任务将每个实例的position, rotation, scale转换成矩阵。4.3 步骤三实现视锥体剔除Frustum Culling直接渲染数万个实例即使它们不在视野内也会浪费宝贵的带宽。我们需要在提交给GPU前进行剔除。我们可以在Job中并行进行视锥体剔除。这里需要一个更复杂的Job它输出一个“可见实例索引列表”。[BurstCompile] public struct FrustumCullJob : IJobParallelFor { [ReadOnly] public NativeArrayMatrix4x4 instanceMatrices; [ReadOnly] public Plane[] cameraFrustumPlanes; // 相机视锥体平面 public NativeArrayint.Concurrent visibleIndices; // 并发写入的可见索引列表 public NativeAtomicCounter.Concurrent counter; // 原子计数器用于安全地追加索引 public void Execute(int index) { // 获取实例的包围球这里简化处理使用位置和固定半径 // 实际上应该从矩阵或原始数据中获取更精确的包围盒。 Vector3 position instanceMatrices[index].GetPosition(); float radius 2.0f; // 假设的植被半径 // 判断包围球是否在视锥体内 if (GeometryUtility.TestPlanesAABB(cameraFrustumPlanes, new Bounds(position, Vector3.one * radius * 2))) { int idx counter.Increment() - 1; // 原子操作获取写入位置 visibleIndices[idx] index; } } }调度剔除Jobprivate void PerformCullingAndUpdateBRG(Camera camera) { // 1. 准备视锥体平面 Plane[] planes GeometryUtility.CalculateFrustumPlanes(camera); // 2. 分配可见索引列表 NativeArrayint visibleIndices new NativeArrayint(instanceCount, Allocator.TempJob); NativeAtomicCounter counter new NativeAtomicCounter(Allocator.TempJob); // 3. 调度剔除Job var cullJob new FrustumCullJob { instanceMatrices m_Matrices, cameraFrustumPlanes planes, visibleIndices visibleIndices, counter counter }; JobHandle cullHandle cullJob.Schedule(instanceCount, 64); cullHandle.Complete(); // 4. 获取可见数量 int visibleCount counter.Count; // 5. 将可见实例的数据矩阵、颜色从主数组收集到一个连续的临时数组中 // ... (这里需要另一个Job或循环来完成数据收集) // 6. 将收集好的可见数据上传到GraphicsBuffer // m_InstanceDataBuffer.SetData(visibleData, 0, 0, visibleCount); // 7. 清理临时NativeArray visibleIndices.Dispose(); counter.Dispose(); }关键点剔除Job的输出是一个稀疏的索引列表。我们需要一个“收集”Gather步骤将可见实例的矩阵和颜色数据从原始的m_Matrices和m_Colors中提取出来打包成一个新的、连续的数组再上传给GPU。这个收集过程也可以用一个IJobParallelFor配合NativeArray.Copy来实现但要注意线程安全。4.4 步骤四连接BatchRendererGroup与Shader这是最关键的一步告诉BRG如何获取渲染数据。首先在Shader中我们需要定义用于接收实例数据的结构化缓冲区。// 在Shader中例如在CGPROGRAM或HLSLPROGRAM上方 StructuredBufferfloat4x4 _InstanceMatrices; StructuredBufferfloat4 _InstanceColors;然后修改顶点着色器使用实例ID来索引这些缓冲区。v2f vert (appdata v, uint instanceID : SV_InstanceID) { v2f o; // 从缓冲区获取该实例的变换矩阵和颜色 float4x4 instanceMatrix _InstanceMatrices[instanceID]; float4 instanceColor _InstanceColors[instanceID]; // 应用实例变换 float4 worldPos mul(instanceMatrix, float4(v.vertex, 1.0)); o.vertex mul(UNITY_MATRIX_VP, worldPos); // ... 其他计算法线、UV等 o.color instanceColor; // 将实例颜色传递给片元着色器 return o; }最后在C#端我们需要在BRG的OnPerformCulling回调中设置这些缓冲区。private BatchCullingOutput OnPerformCulling(BatchRendererGroup rendererGroup, BatchCullingContext cullingContext) { // 1. 使用当前相机进行剔除计算得到visibleIndices和visibleCount PerformCullingAndUpdateBRG(cullingContext.camera); // 2. 创建BatchCullingOutput var output new BatchCullingOutput(); // 3. 创建BatchDrawRange和BatchDrawCommand // 一个BatchDrawCommand对应一个绘制调用 var drawCommand new BatchDrawCommand { visibleOffset 0, // 可见实例在缓冲区中的起始偏移 visibleCount visibleCount, // 可见实例数量 batchID m_BatchID, // 之前AddBatch返回的ID materialID material.GetInstanceID(), meshID mesh.GetInstanceID(), submeshIndex 0, splitVisibilityMask 0xff, flags BatchDrawCommandFlags.None, sortingPosition 0 }; // 4. 将绘制命令和范围填入output output.drawCommands new NativeArrayBatchDrawCommand(1, Allocator.Temp) { [0] drawCommand }; output.drawRanges new NativeArrayBatchDrawRange(1, Allocator.Temp) { [0] new BatchDrawRange { drawCommandsBegin 0, drawCommandsCount 1 } }; output.visibleInstances m_VisibleInstanceDataArray; // 这是一个包含visibleIndices的NativeArray output.instanceSortingPositions null; // 如果不需排序设为null // 5. 在提交前将GraphicsBuffer设置到Shader全局属性 Shader.SetGlobalBuffer(_InstanceMatrices, m_InstanceDataBuffer_Matrices); Shader.SetGlobalBuffer(_InstanceColors, m_InstanceDataBuffer_Colors); return output; }重要提示OnPerformCulling回调可能在多线程环境中被调用因此其中所有的操作都必须是线程安全的。我们创建的NativeArray需要使用Allocator.Temp。GraphicsBuffer.SetData操作本身是线程安全的但我们需要确保在调用它时数据已经由Job计算完毕通过JobHandle.Complete保证。4.5 步骤五内存管理与清理由于大量使用了非托管内存NativeArray和GPU资源GraphicsBuffer必须小心管理它们的生命周期避免内存泄漏。private void OnDestroy() { // 等待可能还在运行的Job完成 if (m_LastJobHandle.IsCompleted) m_LastJobHandle.Complete(); // 释放NativeArray if (m_Instances.IsCreated) m_Instances.Dispose(); if (m_Matrices.IsCreated) m_Matrices.Dispose(); if (m_Colors.IsCreated) m_Colors.Dispose(); // 释放GraphicsBuffer m_InstanceDataBuffer?.Dispose(); // 清理BatchRendererGroup m_BRG?.Dispose(); }5. 性能调优与避坑指南理论跑通只是第一步要让它在真实项目中稳定高效运行还需要大量调优。下面是我踩过坑后总结的经验。5.1 数据布局与内存访问优化问题Job执行慢可能是内存访问模式不佳。对策确保在Job中访问的数据是连续且对齐的。使用[ReadOnly]或[WriteOnly]标记NativeArray帮助Burst编译器优化。对于IJobParallelFor尽量让每个Job处理连续的内存块。避免在Job内部进行随机访问。5.2 批次合并与材质设置问题Draw Call并没有降到预期水平。对策BRG的批次合并基于BatchID。确保使用相同材质和网格的实例在同一个Batch中。检查材质是否启用了GPU Instancing并且其属性设置是兼容的。如果材质有不同的属性块它们可能无法合并。一个技巧是将不同的属性如颜色通过我们自己的StructuredBuffer传递而不是使用MaterialPropertyBlock这样材质本身仍然是“相同的”利于合批。5.3 剔除策略的平衡问题每帧的剔除计算本身成了性能瓶颈。对策分层级剔除Hierarchical Culling不要对所有10万个实例逐一进行精确视锥体检测。可以先将地图划分为网格Grid或四叉树Quadtree。首先剔除掉完全在视锥体外的格子只对可能可见的格子内的实例进行精细剔除。距离剔除Distance Culling在剔除Job中加入与相机距离的判断超过一定距离的实例直接剔除甚至可以按距离切换不同的LOD网格。异步剔除如果一帧内剔除计算量太大可以考虑将剔除工作分摊到多帧完成或者使用IJob而非IJobParallelFor在后台线程运行但要注意数据同步的延迟。5.4 Burst编译与数学精度问题Burst Job编译失败或运行结果有误。对策检查代码是否完全支持Burst。避免使用Burst不支持的托管类型或复杂控制流。在数学计算中尽量使用Unity.Mathematics命名空间下的float3,quaternion,math函数而不是Vector3和Mathf。前者是Burst友好的值类型性能更高。如果遇到精度问题尝试将float改为double但要注意性能开销和Burst兼容性。5.5 图形API与平台兼容性问题在WebGL或某些移动端平台不工作。对策GraphicsBuffer需要比较新的图形API支持。对于WebGL确保目标WebGL版本支持。在移动端它是广泛支持的。结构化缓冲区StructuredBuffer在Shader中的支持情况因平台和Shader Model而异。准备好备选方案比如如果不支持可以回退到将数据打包到纹理中通过tex2D读取。在OnPerformCulling中设置Shader.SetGlobalBuffer是有效的但要确保在所有相机渲染前调用。在SRP中通常没问题。6. 进阶LOD与视差优化一个完整的植被系统离不开LODLevel of Detail。实现思路在VegetationInstance结构体中增加一个int lodLevel字段。在剔除Job中不仅判断可见性还根据实例与相机的距离计算其LOD等级。在InitializeBRG时为同一个材质但不同的LOD网格通常是面数递减的多个Mesh注册多个Batch。每个Batch对应一个LOD级别的网格。在OnPerformCulling中根据实例的LOD等级将其索引和矩阵数据收集到对应LOD级别的可见实例列表中。为每个LOD级别创建独立的BatchDrawCommand和GraphicsBuffer或者在同一缓冲区中用偏移区分并提交。这样远处的植被使用低模渲染近处的使用高模在视觉质量损失最小的情况下大幅减少像素着色器的压力。7. 完整代码框架与资源由于篇幅限制无法将上万行的完整项目代码全部贴出。但上述步骤和代码块已经勾勒出了整个系统的骨架。这里提供一个更整合的VegetationRenderer类的简化框架以及关键资源设置。核心管理器框架using System.Collections.Generic; using Unity.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; using UnityEngine.Rendering; public class AdvancedVegetationRenderer : MonoBehaviour { [System.Serializable] public struct VegetationLOD { public Mesh mesh; public float distance; } public VegetationLOD[] lodSettings; public Material material; public int totalInstances 50000; public float cullingRadius 300f; private BatchRendererGroup m_BRG; private GraphicsBuffer m_InstanceDataBuffer; private NativeArrayMatrix4x4 m_InstanceMatrices; private NativeArrayfloat4 m_InstanceColors; // ... 其他NativeArray private Dictionaryint, BatchID m_LODToBatchID new Dictionaryint, BatchID(); private ListNativeArrayint m_VisibleIndicesPerLOD new ListNativeArrayint(); private void Start() { /* 初始化所有资源 */ } private void InitializeBRGAndBatches() { /* 为每个LOD网格注册Batch */ } private void InitializeInstances() { /* 生成初始实例数据 */ } private void Update() { /* 调度风效、矩阵更新Job */ } private JobHandle ScheduleCullingJobs(Camera camera, out NativeArrayint totalVisibleCountPerLOD) { /* 调度分层视锥剔除Job */ } private BatchCullingOutput OnPerformCulling(BatchRendererGroup brg, BatchCullingContext context) { /* 组织可见数据并返回绘制命令 */ } private void OnDestroy() { /* 清理所有资源 */ } // Burst Job 定义 [BurstCompile] private struct UpdateWindAndLODJob : IJobParallelFor { ... } [BurstCompile] private struct FrustumAndDistanceCullJob : IJobParallelFor { ... } }Shader示例关键部分// Properties块中可以不定义因为数据来自Buffer // _BaseColor等材质固有属性可以保留 CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float _Smoothness; CBUFFER_END StructuredBufferfloat4x4 _InstanceMatrices; StructuredBufferfloat4 _InstanceColors; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; uint instanceID : SV_InstanceID; // 关键获取实例ID }; v2f vert (appdata v) { v2f o; // 通过实例ID获取数据 float4x4 instanceMatrix _InstanceMatrices[v.instanceID]; float4 instanceColor _InstanceColors[v.instanceID]; float4 worldPos mul(instanceMatrix, float4(v.vertex.xyz, 1.0)); o.vertex TransformWorldToHClip(worldPos.xyz); o.worldNormal normalize(mul((float3x3)instanceMatrix, v.normal)); o.worldPos worldPos.xyz; o.uv v.uv; o.color instanceColor * _BaseColor; // 结合实例色和材质底色 return o; }材质设置创建一个Standard或URP/Lit着色器变体并添加上面的StructuredBuffer支持。在Unity编辑器中使用这个Shader创建一个材质。确保材质球上Enable GPU Instancing选项被勾选。虽然我们用了BRG但这个选项会影响底层合批逻辑。将材质赋给VegetationRenderer组件的material字段。从“为什么”到“怎么做”再到“怎么优化”这套基于BatchRendererGroup和JobSystem的植被渲染方案将渲染效率从GameObject的层面解放了出来。它要求你对Unity的数据流、多线程和底层渲染有更深的理解但带来的性能提升是质的飞跃。对于真正的大型开放世界项目这是必经之路。