前言

其实这篇文章的内容一直是在我的学习计划中的,但是由于种种事情而耽误了,最近正好有空,可以静下心来好好学习下,这篇文章应该是我2021年渲染学习的一个句号,剩下的一个季度要把精力放到技能编辑器的完善和状态帧同步的实现上。

学习对象依旧是Colin大神的仓库,这次的效果最为震撼和炫酷——基于GPU Instance的草地渲染

image-20210907010135213

本文章主要涉及到的技术内容为:

  • GPU Instance的底层原理
  • GPU Instance API
  • 基于GPU Instance的草地渲染实现
  • 草地渲染内容拓展

GPU Instance的底层原理

简单概括一句话就是:传递一个对象的Mesh,指定其绘制次数和材质,Unity就会为我们在GPU的统一/常量缓冲区开辟好必要的缓冲区,然后以我们指定的材质对Mesh进行我们指定次数的渲染,这样就可以达成一次Drawcall绘制海量对象的目的。

好处在于:

  • 传统渲染方式(无合批情形):绘制多少个对象就要整理和传递多少次数据,其中整理和传递数据的过程消耗极大,多数为性能瓶颈
  • GPU Instance:只用从CPU往GPU传递一次数据,大大提高了渲染效率。

对于Unity的GPU Instance来说,从数据处理的角度其实也可以分为两类:

  • 第一种是使用了 支持并启用了GPU Instance的Shader 的材质的物体在进行渲染时(例如我们通过Gameobject.Instantiate实例化了100w个正方体),Unity会对所有渲染对象进行特殊处理,为所有的渲染目标在GPU的常量缓冲区(Constant Buffer中)准备各种缓冲区(顶点数据缓冲区,材质数据缓冲区,transform矩阵数据缓冲区等)
  • 第二种是我们自己调用GPU Instance API进行实例绘制,那么Unity只会根据我们所传递的参数为其准备顶点缓冲区,材质数据缓冲区,对于矩阵数据缓冲区或者其他自定义数据是不提供的,也就需要我们自己通过ComputeBuffer来传递这些数据,然后在Shader中根据instanceId进行处理。例如我们使用GPU Instance API绘制100w个三角形,那么Unity会控制GPU后端为我们准备一个能容纳300w个顶点的缓冲区和一个材质数据缓冲区。

GPU Instance API

GPU Instance API有两种,第一种是Graphic提供的,第二种是CommandBuffer提供的,两者只是提供的接口不同,在效率上没有什么差别,毕竟就算我们直接用CommandBuffer API也只是Unity提供给我们的上层封装,而不是真正的GPU后端里的CommandBuffer。

Colin大神项目里使用了Graphic的API,对于CommandBuffer的GPU Instance API,知乎也有一篇文章使用到了——URP渲染管线 - GPUInstance绘制草地,关于API的更多详细内容,参见Graphics.DrawMeshInstancedIndirectCommandBuffer.DrawMeshInstancedProcedural 官方文档。

我对于DrawMeshInstanced和DrawMeshInstancedIndirect这两个API的区别理解就是:

  • DrawMeshInstanced会受限于Unity内部的预先定义,比如单次DrawMeshInstanced调用最多绘制1023实例,单次Drawcall最多绘制500个实例(也就意味着一次DrawMeshInstanced可能会有多个Drawcall),但可以一定程度上享受Unity内部基建(将单次DrawMeshInstanced绘制的所有实例作为一个剔除组对待,但是并不支持单个实例的剔除和排序来提高透明度测试和深度测试的性能),所以这个“一定程度上的Unity基建”已经是相当鸡肋了,不要也罢
  • DrawMeshInstancedIndirect自由度和性能上限更高,没有DrawMeshInstanced那么多限制,但也意味着放弃享用了Unity的一些内部基建(同上鸡肋),需要自己处理LOD和裁剪

所以一般都选用DrawMeshInstancedIndirect来进行开发。

基于GPU Instance的草地渲染实现

渲染流程

基于Compute Shader的裁剪

对于Compute Shader相关内容可移步:URP下屏幕空间平面反射(ScreenSpacePlanarReflection)学习笔记,这里只放一张使用示意图:

image-20210907010205874

基于GPU Instance的原理——我们只把一个物体的顶点数据传递到了GPU侧,然后通过instance id和不同的变换矩阵,在vs和ps中绘制出多个对象,虽然只有一次Drawcall调用,但是渲染后端内部处理的时候会将这一个物体的顶点数据使用vs处理多次(我们想要绘制出的实例数量),那么对于在视角中完全不可见的实例,对它进行的vs处理就是不必要的,但是我们自己使用GPU Instance控制绘制的实例没有办法享受应用阶段的裁剪红利(一般由引擎提供粗粒度的,以对象为单位的裁剪),也就引申出GPU Instance需要做的裁剪工作

虽然最后在齐次裁剪空间的裁剪会帮我们把不必要的片元给裁剪掉,但是我们自己提前手动做视锥体裁剪的话可以减少很多顶点操作,对于超多数量的实例,性能提升的幅度一想便知。

项目进行了两次裁剪:

  1. 第一次是纯CPU端的裁剪,通过对每棵草进行分块,然后对分出的块使用摄像机的视锥体进行AABB测试,在视锥体内的才会加入待渲染的草地块列表
  2. 第二次是基于Compute Shader的纯GPU的裁剪,将传递进来的一块或多块草地区域中的每棵草通过VP矩阵变换到齐次裁剪空间进行手动裁剪,通过测试的才会加入最终的待渲染列表

所以对于分块的大小设置是会直接影响到是CPU Heavy还是GPU Heavy:如果每个分块的大小越大,则在CPU裁剪粒度越大,则CPU压力越小,GPU压力越大,反之则反之

对于渲染目标的裁剪,相关实现技术有很多,可以参见:【游戏场景剔除】剔除算法综述

草地广告牌

我们知道经典的广告牌算法:将相机从世界空间转换到模型空间来构造一个旋转矩阵,变换物体的顶点,让它看起来像是一直朝着摄像机

图自Shader入门精要

但是在这里算法似乎有些不同?

其实还是经典广告牌算法,之所以可以如此简化是因为在这个草地渲染中,是由于每棵草的锚点世界坐标已知,所以可以直接通过偏移的形式换算出每个顶点的世界坐标,也就是说,一开始的每棵草的模型空间顶点就被当成了世界空间顶点,从而可以直接套用观察矩阵的旋转信息来实现广告牌效果,最后加上其锚点世界坐标(或者乘以一个只包含位移的世界变换矩阵)即可得出这个顶点真正的世界坐标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//rotation(make grass LookAt() camera just like a billboard)
//=========================================
float3 cameraTransformRightWS = UNITY_MATRIX_V[0].xyz;
//UNITY_MATRIX_V[0].xyz == world space camera Right unit vector
float3 cameraTransformUpWS = UNITY_MATRIX_V[1].xyz;
//UNITY_MATRIX_V[1].xyz == world space camera Up unit vector
float3 cameraTransformForwardWS = -UNITY_MATRIX_V[2].xyz;
//UNITY_MATRIX_V[2].xyz == -1 * world space camera Forward unit vector

//Expand Billboard (billboard Left+right)
float3 positionOS = IN.positionOS.x * cameraTransformRightWS;
//random width from posXZ, min 0.1
//Expand Billboard (billboard Up)
positionOS += IN.positionOS.y * cameraTransformUpWS;

float3 positionWS = positionOS + perGrassPivotPosWS;
OUT.positionCS = TransformWorldToHClip(positionWS);

草地交互

核心为GrassBendingRTPrePass,利用TrailRender组件,将自动淡出的运行轨迹渲染到一张RT上,作为草地交互的信息源

这个渲染时序为:

1
m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingPrePasses;

可通过FrameDebug查看渲染结果

image-20210907010228334

随后即可在Shader中根据这个RT进行草地交互,这里采取的方案是直接将影响到的草尖位置下移来模拟草被压下去的效果,需要注意的是需要自己构造用于采样上面那个RT的UV坐标,即每棵草锚点世界空间位置减去整个草地的中心位置然后除以整个草地的大小

1
2
3
4
5
6
7
8
9
10
11
12
//get "is grass stepped" data(bending) from RT
float2 grassBendingUV = ((perGrassPivotPosWS.xz - _PivotPosWS.xz) / _BoundSize) * 0.5 + 0.5;
//claculate where is this grass inside bound (can optimize to 2 MAD)
float stepped = tex2Dlod(_GrassBendingRT, float4(grassBendingUV, 0, 0)).x;

//bending by RT (hard code)
float3 bendDir = cameraTransformForwardWS;
bendDir.xz *= 0.5; //make grass shorter when bending, looks better
bendDir.y = min(-0.5, bendDir.y);
//prevent grass become too long if camera forward is / near parallel to ground
positionOS = lerp(positionOS.xyz + bendDir * positionOS.y / -bendDir.y, positionOS.xyz,
stepped * 0.95 + 0.05); //don't fully bend, will produce ZFighting

如果是单纯的顶点位置下移难免有些僵硬,那么再配合上风场的模拟使草倾倒,看起来就非常自然了

风场模拟

三波风浪循环作用,只影响草的草尖部分,最后将风浪影响的偏移量叠加到顶点位置上即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//wind animation (biilboard Left Right direction only sin wave)            
float wind = 0;
wind += (sin(
_Time.y * _WindAFrequency + perGrassPivotPosWS.x * _WindATiling.x + perGrassPivotPosWS.z *
_WindATiling.y) * _WindAWrap.x + _WindAWrap.y) * _WindAIntensity; //windA
wind += (sin(
_Time.y * _WindBFrequency + perGrassPivotPosWS.x * _WindBTiling.x + perGrassPivotPosWS.z *
_WindBTiling.y) * _WindBWrap.x + _WindBWrap.y) * _WindBIntensity; //windB
wind += (sin(
_Time.y * _WindCFrequency + perGrassPivotPosWS.x * _WindCTiling.x + perGrassPivotPosWS.z *
_WindCTiling.y) * _WindCWrap.x + _WindCWrap.y) * _WindCIntensity; //windC
wind *= IN.positionOS.y; //wind only affect top region, don't affect root region
float3 windOffset = cameraTransformRightWS * wind; //swing using billboard left right direction
positionWS.xyz += windOffset;

支持光照和阴影

URP在光照的处理上与Built-In管线有很大不同

  • Built-In:每个光源会遍历一次所有的Pass,如果一个场景中待渲染物体有m个Pass,n个光源,就会有m*n次Drawcall,在这种类型的Drawcall过程中渲染数据和渲染状态的改变对性能的打击是毁灭性的
  • URP:每个Pass中遍历所有的光源,一次渲染搞定所有光源

显而易见的,会节约大量的Drawcall,拥有更好的性能

首先添加一些关键词,理由在注释里也说清楚了

  • 在URP C#侧源码中会对Shader中的关键字进行判断,如果定义不合法就会被裁剪,详情可参见ShaderPreprocessor.cs中的StripUnused函数
  • 如果不添加这些关键词,就无法正常调用Core.hlsl和Lighting.hlsl中所定义的字段和函数(因为他们内部对关键字是否定义做了判断)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// -------------------------------------
// Universal Render Pipeline keywords
// When doing custom shaders you most often want to copy and paste these #pragmas
// These multi_compile variants are stripped from the build depending on:
// 1) Settings in the URP Asset assigned in the GraphicsSettings at build time
// e.g If you disabled AdditionalLights in the asset then all _ADDITIONA_LIGHTS variants
// will be stripped from build
// 2) Invalid combinations are stripped. e.g variants with _MAIN_LIGHT_SHADOWS_CASCADE
// but not _MAIN_LIGHT_SHADOWS are invalid and therefore stripped.
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile _ _SHADOWS_SOFT
// -------------------------------------

其中对于光照函数,可以参见Lighting.hlsl,对于阴影函数,参见Shadows.hlsl,可以顺藤摸瓜得到所有关键字定义的缘由

随后计算平行光和额外光源他们的光照与阴影即可,光照模型选择了经典的半兰伯特作为漫反射方案,逐顶点的高光反射作为高光方案(更加详尽的光照内容在下面的注释中)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
half3 ApplySingleDirectLight(Light light, half3 N, half3 V, half3 albedo, half positionOSY)
{
half3 H = normalize(light.direction + V);
//direct diffuse
half directDiffuse = dot(N, light.direction) * 0.5 + 0.5; //half lambert, to fake grass SSS
//direct specular
float directSpecular = saturate(dot(N,H));
//pow(directSpecular,8)
directSpecular *= directSpecular;
directSpecular *= directSpecular;
directSpecular *= directSpecular;
//directSpecular *= directSpecular; //enable this line = change to pow(directSpecular,16)
//add direct directSpecular to result
directSpecular *= 0.1 * positionOSY;//only apply directSpecular to grass's top area, to simulate grass AO
half3 lighting = light.color * (light.shadowAttenuation * light.distanceAttenuation);
half3 result = (albedo * directDiffuse + directSpecular) * lighting;
return result;
}

-----------------------------------PS------------------------------------------

Light mainLight;
#if _MAIN_LIGHT_SHADOWS
mainLight = GetMainLight(TransformWorldToShadowCoord(pos
#else
mainLight = GetMainLight();
#endif
half3 randomAddToN = (_RandomNormal * sin(perGrassPivotPosWS.x * 82.32523 + perGrassPivotPosWS.z) + wind
* -0.25) * cameraTransformRightWS; //random normal per grass
//default grass's normal is pointing 100% upward in world space, it is an important but simple grass normal trick
//-apply random to normal else lighting is too uniform
//-apply cameraTransformForwardWS to normal because grass is billboard
half3 N = normalize(half3(0, 1, 0) + randomAddToN - cameraTransformForwardWS * 0.5);
half3 V = viewWS / ViewWSLength;
half3 baseColor = tex2Dlod(_BaseColorTexture,
float4(TRANSFORM_TEX(positionWS.xz, _BaseColorTexture), 0, 0)) * _BaseColor;
//sample mip 0 only
half3 albedo = lerp(_GroundColor, baseColor, IN.positionOS.y);
//(计算球谐光照,因为场景中的光照探针)
half3 lightingResult = SampleSH(0) * albedo;
//main direct light
lightingResult += ApplySingleDirectLight(mainLight, N, V, albedo, positionOS.y);
mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));
#if _ADDITIONAL_LIGHTS
// Returns the amount of lights affecting the object being renderer.
// These lights are culled per-object in the forward renderer
int additionalLightsCount = GetAdditionalLightsCount();
for (int i = 0; i < additionalLightsCount; ++i)
{
// Similar to GetMainLight, but it takes a for-loop index. This figures out the
// per-object light index and samples the light buffer accordingly to initialized the
// Light struct. If _ADDITIONAL_LIGHT_SHADOWS is defined it will also compute shadows.
Light light = GetAdditionalLight(i, positionWS);
// Same functions used to shade the main light.
lightingResult += ApplySingleDirectLight(light, N, V, albedo, positionOS.y);
}
#endif

这里还有一个小Trick,高光部分只影响了草尖部分,而非全部的顶点,因为如果全部顶点都计算高光,那么在就会过曝(右边图片的白色草区域,由于视角方向与光照方向取平均归一化后基本与法线方向一致导致点乘过大,也就是高光系数太高所以会过曝),只影响草尖部分还可以利用VS到PS的线性插值来获得更好的光照效果(自动渐变)

左边为高光只作用草尖效果,右边为全部顶点都计算高光的效果

拓展

这样的一个草地系统其实已经很完善了,只是应用在工业生产中还需要进行算法改进以及提供一些其他的基础设施,算法部分的改进方向(四叉树扫寻优化)在原项目中已做了注释,此处不表

这部分主要说一下基础建设

草地编辑器

工业化生产中我们会有草地编辑器的需求,类似Unity自带的地形系统的刷草,刷树功能,顺着这个项目的思路来的话,问题的关键有两点:

  1. 如何使用笔刷刷出世界坐标,并且需要应对带有起伏的地形
  2. 如何多样化植物类型,比如同时支持草和花(下面统称为细节对象)

对于第一点,可以直接利用Unity自带的地形系统(Terrain)作为数据编辑和导出的工具,这里可以简略说一下Terrain的工作原理:

  • 高度图(Height Map)用于记录地形的起伏
  • 控制图(Control Map)用于记录地表混合信息
  • 灰度图(Grayscale Map)用于记录草,花,岩石等细节对象在单个纹素所对应的数量

在地形编辑器中刷好草之后,我们可以通过采样高度图和TerrainData.GetDetailLayer API的方式来获取草的世界坐标,随后进行渲染即可

也可以自己参照着Terrain编辑器自己专门写一个刷草编辑器,可控性更高,刷草编辑器的核心就是射线检测计算坐标 + 笔刷贴图采样计算影响到的UV,下面给出两个参考链接

对于第二点,有了第一点的铺垫,其解决方案也呼之欲出了:因为Unity的TerrainData没有提供获取细节对象类型的API,所以需要自己写一个刷草编辑器,将数据存储到两张灰度图即可,第一张用于记录草的高度(纹素的UV坐标即为草的X-Z坐标),第二张用于记录细节对象的类型(是花还是草)

更丰富的草地交互

现在项目中的草地交互是胶囊体走过的地方草地会被压下去,其实类似的还有人物挥剑,释放强力技能造成的冲击波从而导致草地被压下去的效果,都可以通过在这个RT上进行操作的方式实现,但有些游戏类型可能交互的需求不尽于此:比如割草和烧草

如果是常规的Gameobject草,我们可以通过碰撞检测来设置草的状态数据,但我们使用了GPU Instance来绘制草是取不到具体草对象的,所以需要寻找别的方案

割草

如果是一刀下去连根拔起,那大可以直接清空灰度图那部分纹素的信息,但是为了追求更自然的表现,一般都是要留草根的,这里其实思路和上面的草地交互RT差不多,区别是不会自动恢复,也就是说RT的影响是永久性的,所以需要新开一张RT用于记录割草的信息

烧草

烧草更复杂一些,但核心仍然是RT,由这个RT来表示烧草的位置,并且在Shader中对这个RT进行采样,如果被影响到了就需要进行烧毁的特效表现

具体算法就类似于很多游戏中的 “搜寻物品” 世界扫描线效果,可以参见 Unity3D Shader:死亡搁浅扫描效果

参考