[Unity]🌿🌿🌿🌿🌿🌿
-
本篇笔记包含草的常见制作方式介绍,以及对GitHub上一个教科书级仓库的分析与学习。
接上篇大批量物体渲染学习笔记,老是拿一堆方块做实验实在是太无聊了,所以在做遮挡剔除之前不如先来生草吧。
草的实现方式多种多样,网上也有很多相关文章教程,个人了解的有星形结构、广告牌、几何着色器等,每种方式各有优缺点。
星形结构
星形结构是相对便宜的一种方式,为了确保在不同观察角度下都能有较密集的效果,这种草的模型通常做成若干个面片相互穿插的样式,模型的顶点数较少,性能开销相对较小;缺点是从草的上方往下看时容易穿帮,可以通过增加插片面数改善。
不同LOD下的模型,越近面数越多,细节也越丰富:
使用商店中草模型的实现效果:
对于这种草的批量渲染,在大批量物体渲染学习笔记(二)中已经介绍过了,只需依葫芦画瓢修改草的Shader、配置好Renderer的各项参数即可:
LOD暂时没实现,以后慢慢填坑。
关于星形结构,更详细的介绍可以参考《GPU 精粹》:
广告牌
很多人在初学Unity时应该就接触到了广告牌(Billboarding)技术,粒子系统的默认渲染模式就是它。与星形结构相比,广告牌的思路更加直接:既然要兼顾每个角度的效果,那么干脆一直朝着相机。运用这种思路,让每颗草在渲染时都始终朝着相机方向,不同观察角度下都能有很好的效果。
GitHub上ColinLeung-NiloCat大神的这个仓库演示了通用渲染管线下广告牌草的实现:
https://github.com/ColinLeung-NiloCat/UnityURP-MobileDrawMeshInstancedIndirectExample
它的运行效果:
具体有多牛就不吹了,可以去看简介,这里主要记录对这个仓库的学习过程,根据个人需求有一些改动,但思路是差不多的。
整体实现思路:
- 在一个区域内,生成并记录大量草的位置。
- 在CPU侧对草进行粗粒度的剔除。
- 在GPU侧对草进行细粒度的剔除。
- 使用Graphics.DrawMeshInstancedIndirect渲染。
- 加一些特技。
虽说是广告牌草,但广告牌的实现并不是重点(因为比较简单),大批量渲染中如何优化性能更为重要,这里先侧重介绍仓库中的优化技巧,也就是上面提到的剔除步骤。
位置生成
生成方式多种多样,可以是随机生成,也可以是通过刷草工具手刷。我的实现方式是在一个区域内,以一定的密度(分辨率)对区域内的点采样柏林噪声,判断是否符合植被生成条件(采样值超过阈值),符合则记录当前点的坐标,并通过射线取得高度。使用这种方法生成的树木:
当然最简单的做法,直接在区域内随机就行了,只做demo可以不用搞这么花里胡哨。
视锥剔除
原仓库中,视锥剔除分为两步:粗粒度的CPU侧剔除与细粒度的GPU侧剔除。大致思路:
- 将整个渲染区域在逻辑上分为大小均等、顺序排列的若干块,每块包含若干颗草。
- 先对每个分块的包围盒做视锥剔除,记录下可见的分块,这一步在CPU侧进行。
- 再对可见分块中的草做视锥剔除,得到最终需要渲染的草,这一步在GPU侧进行。
这种方式有效地减轻了GPU的压力,并且通过调整分块的大小,可以让剔除工作更侧重于CPU或GPU。
分块
原仓库的分块并没有考虑到草的y轴,即只适用于平面情况,所以我重写了这部分。整个分块逻辑总结为一张图:
图中为xz平面,RenderBounds为渲染区域,Cell为分块,里面看起来像草的东西是草,数字是它们在数组中的下标,某些分块中可能存在没有草的情况。下面结合代码解释其具体含义。
首先需要获得所有草的位置与渲染区域(RenderBounds),同时为了兼容先前的星形结构,定义了这样一个渲染父类:
ObjectRenderer.cs
public abstract class ObjectRenderer : MonoBehaviour { public abstract void SetRenderBounds(Vector3 min, Vector3 max); public virtual void UpdatePositions(List<Vector3> list) { } }
这两个方法分别让外部传入渲染区域的边界(最小点与最大点)及更新要渲染的物体的位置。上一步中已经准备好了渲染区域,在外部调用SetRenderBounds方法设置;已经生成好了所有草的位置,则调用UpdatePositions将它们传入。
一般来说广告牌草只用到位置,星形草如果要旋转自身以贴合地面,则可以使用另一个方法来更新变换矩阵:
public virtual void UpdateTransforms(List<Matrix4x4> list) { }
新建一个GrassRenderer继承它,并实现这两个方法:
GrassRenderer.cs
public class GrassRanderer : ObjectRenderer { [SerializeField] private Vector3 cellSize; private Bounds renderBounds; private Vector3Int cellCount; public override void SetRenderBounds(Vector3 min, Vector3 max) { // 记录边界,用于渲染 renderBounds = new Bounds(); renderBounds.SetMinMax(min, max); // 根据渲染区域大小与分块大小计算出分块数量 cellCount = new Vector3Int(); cellCount.x = Mathf.CeilToInt(renderBounds.size.x / cellSize.x); cellCount.z = Mathf.CeilToInt(renderBounds.size.z / cellSize.z); } }
renderBounds为渲染区域,根据设定的分块大小将其划分,计算出分块(cell)数量。
然后准备分块,定义分块结构体:
private struct Cell { // 分块的包围盒 public Bounds bounds; // 分块中第一个物体在allPositions中的下标 public int index; // 分块中的物体数量 public int count; }
这里的index对应上面图中Cell中第一颗草的编号,count对应分块中草的数量。
在UpdatePositions方法中,外部传入了所有草的位置,将每个位置放到其所在分块中:
GrassRenderer.cs
// 所有分块 private Cell[] cells; // 排序后的所有草位置 private Vector3[] allPositions; public override void UpdatePositions(List<Vector3> list) { // ①将所有物体分块 // positionsInCell用来存放每个分块中包含的物体位置下标 List<int>[] positionsInCell = new List<int>[cellCount.x * cellCount.z]; for (int i = 0; i < list.Count; i++) { var pos = list[i]; int x = Mathf.FloorToInt(((pos.x - renderBounds.min.x) / renderBounds.size.x) * cellCount.x); x = Mathf.Min(cellCount.x - 1, x); int z = Mathf.FloorToInt(((pos.z - renderBounds.min.z) / renderBounds.size.z) * cellCount.z); z = Mathf.Min(cellCount.z - 1, z); var index = x + z * cellCount.x; if (positionsInCell[index] == null) positionsInCell[index] = new List<int>(); positionsInCell[index].Add(i); } ... }
然后按将所有位置排序成上图所示,并初始化所有分块数据:
GrassRenderer.cs
public override void UpdatePositions(List<Vector3> list) { // ①将所有物体分块 ... // ②按分块重新排序 // 排好序后的所有物体位置存放到新的数组中 allPositions = new Vector3[list.Count]; // 分块数组存放所有分块数据 cells = new Cell[cellCount.x * cellCount.z]; for (int i = 0, index = 0; i < positionsInCell.Length; i++) { cells[i] = new Cell(); cells[i].index = index; cells[i].count = 0; var positions = positionsInCell[i]; if (positions != null) { cells[i].count = positions.Count; Bounds bounds = new Bounds(list[positions[0]], Vector3.zero); for (int j = 0; j < positions.Count; j++, index++) { allPositions[index] = list[positions[j]]; bounds.Encapsulate(list[positions[j]]); } cells[i].bounds = bounds; } } // ③更新Buffer UpdateBuffers(); }
方法中最后一步是熟悉的更新Buffer,和之前笔记中的差不多,就不重复说明了,之后文章末尾会更新完整代码地址。
粗粒度剔除
分好块后,就可以在每一帧进行剔除工作了。Cell中记录了它的包围盒信息,使用Unity提供的API进行AABB测试,判断分块是否在视野内:
GrassRenderer.cs
private List<int> visibleCells = new List<int>(); private Plane[] cameraFrustumPlanes = new Plane[6]; private void LateUpdate() { if (cells == null || cells.Length == 0) return; // CPU侧粗粒度剔除 var cam = Camera.main; // 临时改变远裁剪平面,可以控制绘制距离 float cameraOriginalFarPlane = cam.farClipPlane; cam.farClipPlane = maxDrawDistance; GeometryUtility.CalculateFrustumPlanes(cam, cameraFrustumPlanes); cam.farClipPlane = cameraOriginalFarPlane; // AABB测试 visibleCells.Clear(); for (int i = 0; i < cells.Length; i++) { if (cells[i].count == 0) continue; if (GeometryUtility.TestPlanesAABB(cameraFrustumPlanes, cells[i].bounds)) visibleCells.Add(i); } }
细粒度剔除
得到当前可见的分块后,使用ComputeShader对可见分块中的每颗草做视锥剔除。上一篇笔记中提到,像草这种较小的物体,可以通过对它在裁剪空间下的齐次坐标进行判断来做剔除,这里采用的也是这种方法:
GrassCulling.compute
#pragma kernel CSMain float4x4 _VPMatrix; // VP矩阵 float _MaxDrawDistance; // 最大绘制距离 uint _StartOffset; // 物体位置的起始下标 StructuredBuffer<float3> _AllPositionsBuffer; AppendStructuredBuffer<uint> _VisibleIDsBuffer; [numthreads(64, 1, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { // 世界空间转换至裁剪空间 float4 absPosCS = abs(mul(_VPMatrix, float4(_AllPositionsBuffer[id.x + _StartOffset], 1.0))); // 进行判断,有一些与草大小相关的硬编码 if (absPosCS.z <= absPosCS.w && absPosCS.y <= absPosCS.w * 1.5 && absPosCS.x <= absPosCS.w * 1.1 && absPosCS.w <= _MaxDrawDistance) _VisibleIDsBuffer.Append(id.x + _StartOffset); }
剔除是按分块进行的,所以需要_StartOffset来指定当前是对哪个分块内的草做剔除,对应上面Cell结构体中的index。
调用ComputeShader得到剔除后的草并渲染:
GrassRenderer.cs
private ComputeShader compute; private void LateUpdate() { // CPU侧粗粒度剔除 ... // GPU侧细粒度剔除 var matrixVP = cam.projectionMatrix * cam.worldToCameraMatrix; visibleIDsBuffer.SetCounterValue(0); compute.SetMatrix("_VPMatrix", matrixVP); compute.SetFloat("_MaxDrawDistance", maxDrawDistance); for (int i = 0; i < visibleCells.Count; i++) { var cell = cells[visibleCells[i]]; var startOffset = cell.index; var jobLength = cell.count; // 如果下一个可见分块在内存上是连续的,则合并处理 while ((i < visibleCells.Count - 1) && (visibleCells[i + 1] == visibleCells[i] + 1)) { jobLength += cells[visibleCells[i]].count; i++; } compute.SetInt("_StartOffset", startOffset); compute.Dispatch(kernel, Mathf.CeilToInt(jobLength / 64f), 1, 1); } ComputeBuffer.CopyCount(visibleIDsBuffer, argsBuffer, sizeof(uint)); // 渲染 Graphics.DrawMeshInstancedIndirect(GetGrassMeshCache(), 0, instanceMaterial, renderBounds, argsBuffer); }
在Scene面板中可以看到明显的分块效果,白线是相机的视锥体:
这一篇先写到这里,由于原仓库的草Shader有许多硬编码与Magic number,牙口不好啃起来有些吃力,下一篇(如果有)来学习草的Shader、交互与风吹草浪。
关于开头提到的几何着色器(Geometry Shader)方式,推荐一篇文章:
https://roystan.net/articles/grass-shader
大致的思路是利用几何着色器生成草叶,通过曲面细分着色器丰富草的密度。