[Unity]基于GPU Instance的大批量草地渲染
注1:这是2018年的文章,因为当时发文章的网站的账号被盗号封了,所以倒腾到这里。当时没有写完后半部分,所以没有vert, frag, compute shader的讲解,太久远记不得了,想了解的朋友只能自己看代码了。注2:使用built-in管线实现
<hr/>
受到陈嘉栋的《利用GPU实现无尽草地的实时渲染》启发,我的本科毕设实现了基于GPU实例化技术的大批量草地渲染程序,使无论远近每根草都有自己的独立模型。实现时感觉相关中文资料比较匮乏,希望这篇文章能帮到搜索过来的你。项目已上传至GitHub。
注意:假阴影,无风吹动画,无物理模拟(但因为每根草都是独立的所以可以用物理引擎做,引用的论文用bullet引擎实现了)
核心方法
除了陈嘉栋的文章,使用的方法还来自这两篇论文:
[*]Zengzhi Fan , Hongwei Li , Karl Hillesland , Bin Sheng. Simulation and rendering for millions of grass blades. Proceedings of the 19th Symposium on Interactive 3D Graphics and Games. 2015
[*]Kévin Boulanger , Sumanta Pattanaik , Kadi Bouatouch. Rendering grass terrains in real-time with dynamic lighting, ACM SIGGRAPH Sketches. 2006
将地面划分为一个个单元格(对应坐标系在下文中称为网格坐标),以格子为单位渲染小草丛,组成整体草原。小草丛的渲染方法则是预生成一个较长的包含草叶信息的数组(预生成草丛),在渲染每个格子时从中随机选取一段,见图1。
从实现来讲,
[*]生成“草丛候补”,它是一个数组,一个元素是一根草;
[*]把地面划分成一个个的格子,一个单元格上渲染一个模型(草丛)
[*]用程序化方法生成一个原始草丛。
[*]每帧渲染时调用gpu instance绘制的api,让草丛铺满视锥体。模型是第三步的原始草丛,通过视锥体剪裁确定需要渲染的单元格的数量和位置
下面来看具体步骤。
1.生成地形(TerrainBuilder类)
TerrainBuilder类有三个功能,一是使用BuildTerrain方法建立地表模型;二是做实际坐标与网格坐标的换算;三是储存高度图。
第一个功能。根据一张高度图(perlin noise比较合适)来创建地面网格。结果如下。
第二个功能。根据渲染方法,我们需要将地形划分网格,于是我们新定义一个二维的网格坐标系(Tile coordinate),网格坐标=实际坐标/网格尺寸。
第三个功能。目的是存储地形的高度值(y),xz不用可以储存,因为我们使用的网格坐标可以转换为实际坐标。它在系统初始化时(RealtimeLawnSystem.Awake())被传为shader全局变量terrainHeightTex。
2. 视锥体剪裁(FrustumCalculation类)
为了避免渲染多余草丛,需要进行视锥体剪裁,示意图如下。
思路是先获取相机视锥体信息(PrepareCamData方法),然后利用计算着色器算出在视锥体内的草丛(因为草丛与视锥体求交的计算有并行性的特点,适合交给GPU计算)(RunComputeShader方法)。简单理解计算着色器,就是开辟一张二维**“纹理”**(下称计算纹理),用计算着色器同时把每个像素都算一遍,只不过输入输出大多数情况下不是颜色,是其他数据。每个像素的计算的先后顺序是不确定的,它们之间应该是没有关联的,是为“并行性”。在本项目中,需要开辟的计算纹理的尺寸要能覆盖整个视锥体,就像图4,计算纹理盖住了视锥体。
下面介绍两个步骤
2.1 获取相机视锥体信息(PrepareCamData方法)
相机视锥体信息是每一帧都会更新的,所以在主循环(RealtimeLawnSystem.update)中调用。该方法做了三件事:
2.1.1 计算数据instanceBound
instanceBound是表示相机当前边界的Bounds类型对象,会在调用绘制函数(Graphics.DrawMeshInstancedIndirect)的时候用到。
以俯视视角,我们将视锥体近似成一个三角形(也是为了方便计算着色器中的求交运算),计算三个点的网格坐标储存在Vector3[] frustum中。第一个数据camBound在这之后计算出。
Vector3[] frustum = new Vector3;
#region 获取视锥体frustum
//这里默认透视而非正交
float halfFOV = (camera.fieldOfView * 0.5f) * Mathf.Deg2Rad;
float height = camera.farClipPlane * Mathf.Tan(halfFOV);
float width = height * camera.aspect;
Vector3 vec = camera.transform.position, widthDelta = camera.transform.right * width;
vec -= widthDelta;
vec += (camera.transform.forward * camera.farClipPlane);
frustum = camera.transform.position - camera.transform.forward * 5;
frustum = vec - camera.transform.right * 3;
frustum = vec + 2 * widthDelta + camera.transform.right * 6;
#endregion
注意到frustum向外层扩展了一圈(见图6虚线),这是为了应对有的格子能被部分看到但求交时被漏掉的情况。
2.1.2 计算第二个数据
另一个是为了计算着色器准备的储存四个数据的frustumSize,它的xy分量储存了计算纹理的大小、zw分量储存了它的起始位置,四个分量都是在网格坐标系下的。
该数据表示计算纹理的大小和位置,可以由第一个数据很简单地得出,但还需要做两个处理。第一个处理是纹理的大小,个人在这里将大小调整为向上取最近的2的n次幂(POT),类似unity导入贴图时的选项。后来查了下在现代GPU上这是非必要的(能跑计算着色器肯定是现代GPU了……)。
//将frustumTexSize向上取整(二进制),eg: 9→16
int digit = 0, newTexSizeX, newTexSizeY;
//处理x
newTexSizeX = maxIndex.x - minIndex.x + 1;
for (int i = 31; i >= 0; i--)
if (((newTexSizeX >> i) & 1) == 1) { digit = i + 1; break; }
newTexSizeX = 1 << digit;
//处理y
newTexSizeY = maxIndex.y - minIndex.y + 1;
for (int i = 31; i >= 0; i--)
if (((newTexSizeY >> i) & 1) == 1) { digit = i + 1; break; }
newTexSizeY = 1 << digit;
第二个处理是让计算纹理覆盖虚线框的视锥体,并且让这个纹理整个在地形里,否则会出现地形之外还有草丛被渲染的情况。这里主要是借助TerrainBuilder的GetTileIndex和GetConstrainedTileIndex实现的。前者只是简单的使用除法来转换,后者则是将数值限定在0到高/宽之间。
public Vector2Int GetTileIndex(Vector3 position) {
return new Vector2Int(Mathf.FloorToInt(position.x / PATCH_SIZE),
Mathf.FloorToInt(position.z / PATCH_SIZE));
}
public Vector2Int GetConstrainedTileIndex(int indexX, int indexZ) {
indexX = Mathf.Clamp(indexX, 0, heightMap.width / PATCH_SIZE - 2);
indexZ = Mathf.Clamp(indexZ, 0, heightMap.height / PATCH_SIZE - 2);
return new Vector2Int(indexX, indexZ);
}
2.1.3 将数据传入计算着色器
将视锥体三个角的网格坐标传入计算着色器,这里使用的是ComputeShader.SetVectorArray,塞入两个vector4,即8个位置。本来应该用ComputeShader.SetFloats的,但unity对SetFloats和SetInts的支持都有bug。下面这段代码的效果等于
Vector4[] frusIndex ={new Vector4(frustum1.x, frustum.y, frustum2.x, frustum2.y), new Vector4(frustum3.x, frustum3.y, 0, 0) };
Vector4[] frusIndex = { Vector4.zero, Vector4.zero };
for (int i = 0; i < 3; i++) {
Vector2Int t = tBuilder.GetTileIndex(frustum);
frusIndex += new Vector4(t.x * Mathf.Abs(i - 1), t.y * Mathf.Abs(i - 1),
t.x * (i % 2), t.y * (i % 2));
}
calcShader.SetVectorArray(Shader.PropertyToID(&#34;frustumPosIndex&#34;), frusIndex);
2.2 使用计算着色器(RunComputeShader方法)
这个方法很简单,调用ComputeShader.Dispatch方法,开始计算。值得讨论的是线程组的大小对性能的影响,这里笔者没有进一步研究。
public void RunComputeShader() {
calcShader.Dispatch(frustumKernel, texSize.x / threadGroupSize.x,
texSize.y / threadGroupSize.y, 1);
}
3. 生成草丛模型(GrassGenerator类)
用程序化方法生成一个原始草丛,即叶子没有位置、弯曲等特征,只有拓扑结构。什么是“只有拓扑结构”?就是“模型中的所有三角形是由哪些顶点构成的”是确定的,即这个第三步确定了顶点数,草丛模型的index buffer。
我们使用GPU实例化技术(GPU Instancing, 或Geometry Instancing)渲染草地。它是一种快速大批量渲染同一模型的技术,同时你还可以利用每个实例的ID(InstanceID)区分它们。在这里,我们会生成一个拓扑结构正确的草丛作为“模板”,让它被一遍遍“印刷”在屏幕上,同时通过InstanceID让它们看起来各不相同(比如位置)。下图是生成单个草丛的效果:
3.1 预生成数据(PregenerateGrassInfo())
首先设计下如何表达草叶,见下方。这里的密度索引是取值(0,1)的数据,作用是控制草地生长密度,之后会说到。
struct GrassData {
public float height, density;//草叶高度,密度索引
public Vector4 rootDir;//朝向
};
PregenerateGrassInfo函数首先随机生成上边设计的数据,然后将这个数组塞入buffer传给渲染草的vert/ frag shader。最后还需要传几个数据,如果设为全局变量表示计算着色器也要用到该数据。
public void PregenerateGrassInfo() {
Vector3Int startPosition = Vector3Int.zero;
System.Random random = new System.Random();
GrassData[] grassData = new GrassData;
//随机生成草根位置、方向、高度、密度索引
for (int i = 0; i < pregenerateGrassAmount; i++) {
float deltaX = (float)random.NextDouble();
float deltaZ = (float)random.NextDouble();
Vector3 root = new Vector3(deltaX * patchSize, 0, deltaZ * patchSize);
GrassData data = new GrassData(0.5f + 0.5f * (float)random.NextDouble(),
(float)random.NextDouble(),
new Vector4(root.x, root.y, root.z, (float)random.NextDouble()));
grassData = data;
}
grassBuffer = new ComputeBuffer(pregenerateGrassAmount, sizeof(float) * 6);//6=float+float+vector4
grassBuffer.SetData(grassData);
//send to gpu
grassMaterial.SetInt(&#34;_SectionCount&#34;, bladeSectionCount);//草叶分段,5段12顶点,6段14顶点
Shader.SetGlobalFloat(&#34;_TileSize&#34;, patchSize);
grassMaterial.SetBuffer(&#34;_patchData&#34;, grassBuffer);
}
3.2 草叶模型(generateGrassTile())
这部分设计完成,我们来设计模型。先生成草叶。生成草叶的方法基于陈嘉栋,但是我们不使用几何着色器,因为GPU实例化+几何着色器的效率很低,而且每根草叶的拓扑结构是一样的。单根草叶效果见图8。
草叶设计完成,我们来设计草丛。变量grassAmountPerTile是渲染时每个格子内的草叶数量,比如取64,每根草叶假设5段,则单根草叶12顶点,共需要12*64个顶点:
int bladeVertexCount = (bladeSectionCount + 1) * 2;
Vector3[] normals = new Vector3;
Vector3[] vertices = new Vector3;
Vector2[] uvs = new Vector2;
之后在for循环中填充数据。需要注意的是vertices的值。按设想的情况,草丛模型会用GPU实例化渲染,可以在shader中通过内置变量instanceID找到长长数组中属于自己的数据,从而决定渲染位置、高矮、朝向等等。现在我们有instanceID可以区分每个草丛,还需要别的索引区分当前顶点在草丛中的具体位置。这个数据我们选择存在顶点坐标中:x存储草叶索引(继续之前的例子,0~63)、y存储草叶内顶点索引(0~11,见图9)。之所以选择存在这里,是因为其他诸如法线、uv等信息都是有用的,而坐标信息会在着色器中改变。(感觉这个做法是整个毕设里为数不多的有用的东西了233)
//存储索引(generateGrassTile())
for (int i = 0; i < vertices.Length; i++) {
//赋予x坐标,为了使其作为索引在gpu中读取数组信息
vertices = new Vector3(i / bladeVertexCount, i % bladeVertexCount, 0);//0-63,0-11,0
normals = -Vector3.forward;
uvs = new Vector2(i % bladeVertexCount % 2,
((float)(i % bladeVertexCount / 2)) / bladeSectionCount);
}
result.vertices = vertices;
//使用索引(顶点着色器)
uint vertIndex = v.vertex.y;//0~11
uint bladeIndex = v.vertex.x + hdi.w;//0~63+0~1023-64
确定了Mesh的顶点坐标、法线、uv后还要确定绘制三角形的索引,不再赘述。图9中还提到了“起始索引”,决定在预生成草丛里选取哪一块数据。为了避免重复的观感,这应该是一个随机数,但又要求某位置的草丛的起始索引是不变的,否则这个草丛的草叶会很鬼畜,每一帧都不一样。所以我们等实际渲染时根据当前草丛的坐标作为随机种子,伪随机一下(具体代码在后边)
小结
上篇讲解了渲染的准备工作,即在CPU端的内容。地形高度存在一张纹理里,草丛数据存在buffer或者其他类型的变量里。
本文使用 Zhihu On VSCode 创作并发布
页:
[1]