Unity高级知识点总结:性能优化与图形渲染进阶!

tech2022-09-30  61

A.性能优化相关知识

一、综合优化

1、降低屏幕分辨率尤其是在android平台对性能提升很大。可以有效缓解gpu的压力。

  我们在android上分辨率是实际的0.85左右。

2、做好资源异步加载,实现一个实例化队列,可以很大程度上减少卡顿。

3、做好超量的模型和特效屏蔽,可以有效减轻cpu压力。

4、善用工具。比如Unity Profiler、Snapdragon Profiler等,针对性的对性能瓶颈进行优化。

5、玩家头顶血条的HUD要使用3D的,而不是UGUI。否则同屏玩家数量很多的时候Mesh合并开销很大。

6、UI上使用TextMeshPro。可以很大程度上缓解UI打开卡顿的问题。描边、阴影开销很低。

7、控制帧率。现在高刷新率的手机非常多。不要直接使用VSyncCount控制帧率了。否则在120hz刷新率的手机上vSyncCount=1会有120fps的帧率。直接使用targetFrameRate=30来设置帧率。

 

二、优化的经验

(一) UI的性能优化

1、写一个UICollider而不是透明的Image,可以减少overdraw

2、小地图用shader实现指定位置的图片渲染,而不是RectMask2D,可以减少overdraw。因为RectMask2D是使用alpha=0来实现裁剪的。

3、小地图的玩家标识,聊天界面,都添加Canvas,目的是动静分离。

4、使用TextMeshPro。减少GC,减少字体生成的开销。字体放大缩小依然保持锐利清晰。

5、玩家头顶的HUD,比如血条和名字,使用3D的TextMeshPro和SpriteRenderer,目的是避免UGUI的mesh计算开销。MMO中的头顶血条可能会有同屏几百个人。

  血条的减少动画之前是Image和DOTween来实现的。后面修改为shader实现。(坐标计算)

6、战斗飘字,原先是DOTween来实现的,后面修改为直接在Update里面计算坐标。目的是减少DOTween动画初始化的GC开销。自己实现简化版的动画,性能也更好一些。

  战斗飘字还是用UGUI来实现。主要是因为这里还是美术给的艺术字。而且飘字只有玩家自己才会显示,数量不会很多。

7、实现一个高效率的 FindChlid 函数。因为在写UI的时候查询控件对象是非常常见的操作。如果不做优化,使用 gameObject.name == "xxx" 来做比对的话,可能会产生很多的GCAlloc。

8、使用SimpleAnimationComponent 来做UI动画,而不直接使用Animator。因为Animator在动画播放完毕之后,依然会Update。这会导致两个问题,一个是性能隐患,另外一个是被动画控制的控件无法再通过代码设置位置。在动画播放完毕之后再禁用动画组件,实现起来比较复杂。相比而言,直接使用SimpleAnimationComponent就要简单干净很多,也更加高效。

9、禁用不必要的raycastTarget。

10、实现一个UICircleImage来替代用Mask实现的圆形遮罩裁剪。一般64个点就可以形成圆形。外部再罩一个边框图,就可以消除边缘锯齿。

 

(二) 场景的性能优化

1、要勾选StaticBatch,但是不能滥用。有color、uv3的,顶点超过4000个以上的,数量超多,但是同屏显示不多的模型。这些都不应该勾选StaticBatch。否则会导致包体积明显增大。因为StaticBatch会把模型都build到场景的ab包内。

2、注意压缩纹理的使用。法线应该用etc2,ios下用astc6x6,法线需要更高的精度,要避免压缩纹理导致的失真。

3、避免模型和贴图勾选 readable选项。这个可以在模型和纹理导入的时候做设置。

4、光照贴图和shadowmask图的压缩纹理选项。ios下可以统一使用astc6x6。android下shadowmask图使用rgb16,否则阴影会有明显的模糊或者锯齿。lightmap图倒是没有太大限制,尽量使用压缩纹理即可。

(三) 战斗的性能优化

1、设计一套完善的屏蔽规则。保证玩家自己的模型和特效显示。

2、异步加载资源,模型、特效和声音。缓存池的使用。

3、同屏玩家数量很多的时候,屏蔽超额的模型、特效和技能流程。只保留技能数值逻辑即可。

  注意,屏蔽特效要以玩家为单位进行屏蔽。我们之前以特效为单位屏蔽会出现坐骑身上左翅膀有特效,右翅膀没有特效的情况。

4、更多的细节优化。比如lua到C#的调用。一些频繁调用的接口,能用简单类型做参数,就不要弄一个结构体出来。xlua有针对这里做NoGC的性能优化。

5、缓存,预加载特效。两个预加载时机,游戏初始化的时候,或者技能模板初始化的时候。

(四) 内存的性能优化

1、压缩纹理的使用。

2、合理的释放不必要的资源。引用计数。定期UnloadUnusedAssets

3、粒子系统缓存占用的内存。一个ParticleSystem占用8k内存。特效缓存的多了,可能会占用几十兆内存。

4、Shader Lab内存。变体多了,加载的shader多了。这块儿的内存占用可能会比较大。三四十兆都是有可能的。

5、Lua配置的内存优化

5.1、我们业务逻辑都是lua写的,所以配置直接导出为lua文件,而不再需要有额外的解析过程。

5.2、lua中(luajit)没有整型,所有类型都是double。所以与C++相比配置文件超级占内存。我们现在lua占用内存启动游戏后会有40兆左右。其中30兆都是配置文件所占用的内存。

5.3、导出的配置的格式是数组结构,不是key value结构。通过给每个条目设置metatable,业务层同样可以用key来访问对应的数据。这样可以节省一半以上的内存。

5.4、客户端不用的列不用导出。避免浪费。

5.5、超大的配置,可以导出为sqlite。这样只有需要的时候才会加载对应的数据,而不会把整个表格都加载进来。

5.6、C#用到的配置,直接导出为json。不要导出为lua又再设置给C#。

(五) 卡顿的性能优化

1、GCAlloc。可能发生GCAlloc的情景。比如实例化资源,new对象。以及gameObject.SetActive、闭包调用、gameObject.name读写等等。

2、通过Profiler查找热点是什么,针对性的进行优化。

3、不要频繁调用activeSelf=true。要先做好判断,只在需要以及必要的时候调用。

4、特效不用的时候,停掉ParticleSystem,然后把特效丢到很远的地方,而不是active。同样是减少开销大的接口调用。

5、像图片不显示可以把alpha=0,或者文字不显示可以直接设置一个空的字符串。而不是直接active禁用。这样也可以提升效率。

6、lua调用C#的时候尽可能减少字符串的传递。字符串在lua和C#的交互过程中不可避免的会产生多份内存开销,且有可能还会有编码转换的开销。比如lua中获取一个组件,可以用组件Type作为参数传递,而不是组件名。

7、注意字符串比较时的参数传递。一般都是Ordinal。使用Culture的参数会考虑国际化因素,性能较低。同理,可以实现一个 StartWithFast,简单进行字符比对。它会比C#默认的StartWith快很多。

8、使用同一的CoroutineManager而不是直接用MonoBehaviour的StartCoroutine,可以提高性能。

9、尽可能的减少MonoBehaviour的Update的调用。比如我们的Actor,都是通过ObjectManager来驱动Update。

10、Unity的Profiler不会跟踪主线程之外的GCAlloc分配。如果在子线程需要进行分析,可以考虑把子线程切换的主线程进行调试,或者使用功能BeginThreadProfiling API。

11、在每帧执行的代码中,尽可能避免闭包。减少匿名方法。这些可能会产生GCAlloc。

12、避免使用枚举作为字典的key。会有装箱操作。原因是enum为值类型,Dictionary实现会调用Object.getHashCode获取key的哈希代码,这里期望的是引用类型。

  可以考虑强制转换为int即可。或者实现一个 IEqualityComparer。

13、foreach 虽然在5.5的版本以上不再有因为装箱产生的GCalloc的开销。但是与for相比还是有性能差距。所以在调用不频繁的地方,可以使用foreach以提高可读性。频繁调用的地方还是推荐直接用for进行遍历。

14、慎用 mesh.vertices 等接口。它每次调用都会生成一个新的内存副本。同理 Input.touches类似。

15、Physics的接口中也会有对应的NonAlloc版本。

16、C#的string是不可变的,任何SubSting等操作都会产生新的副本。

(六) 一般优化

1、按ID寻址属性。比如 Animator、Shader都有对应的接口。Animator.StringToHash。Shader.PropertyToID。

2、使用非分配物理API。替换Physics.RaycastAll为Physics.RaycastNonAlloc等。

3、UnityEngine.Object == null 比纯C#对象判定成本要高很多。因为它要判定对象(可能是资源)是否存在,是否有被Destroy掉。

4、减少矢量和四元数的数学计算。控制运算顺序。

5、隐形颜色字符串转换的时候 (#RRGGBBAA),使用一个 ColorUtility 的API会更加高效,且可以避免GCAlloc。

6、尽可能避免使用 Find 或者 FindObjectOfType

7、尽可能减少 Camera.,main 的调用。它内部会调用 Object.FindObjectWithTag。在Start中进行缓存。

8、调试代码可以增加 [Conditional("DEBUG")] 这样的标签。防止开发版本的代码或者日志发布出去。频繁打Log会对性能有严重影响。

9、不要用 type[x, y] 这样的多维数组。性能很低。

10、Update统一放到管理器里面进行更新。而不要每个对象一个Update。

11、合理使用C#的委托。每次添加或者删除回调的时候,C#的委托都会执行回调列表的完整拷贝。所以不要在Update中进行委托的添加或者删除回调的操作。如果有频繁添加的需求,可以考虑使用List维护回调数组而不是委托。

12、手工编码的 String.StartsWithFast 会比内置方法快 10~100倍。

13、Vector3.zero 会返回一个新的Vector3对象。虽然因为其是值类型,远比引用类型性能要高。但是频繁调用的地方还是应该维护一个const的对象用来进行比对或者赋值。

14、更新材质属性的时候,使用MaterialPropertyBlock。性能更好。且可以避免实例化一份新的材质对象。

15、AlphaBlend会比AlphaTest性能要好一些。因为移动平台的GPU会有EarlyZ的优化,而AlphaTest会使EarlyZ无效。

16、低版本的Unity上使用的是PhysicX 2的版本,移动没有刚体的静态碰撞体会导致整个物理世界重建,会有较大的开销。在新版本的Unity里面已经升级到 PhysicX 3,解决了这个问题。

17、使用SimpleAnimationComponent来替代Unity的Animator来做UI动画。主要是Animator会一直更新,即便动画已经播放完毕了。这个一方面会有性能问题,另外一方面被动画驱动的控件无法再通过脚本移动。

18、统一封装 Time.deltaTime和 Time.realTimeSinceStartup。因为这个调用是有一定开销的,至少存在C#到C++的调用。统一在入口Update处调用并缓存。在大量业务逻辑调用,直接取缓存的值。

 

(七) Lua的性能优化

1、扩展Unity的API,简化.localPosition等调用的交互次数。

  尽可能的减少lua和C#的参数传递。比如通过扩展Unity接口,实现SetPosition直接给gameObject或者Component设置坐标,而不用 go.transform.position = xxx 这样设置坐标。

2、减少使用 os.date 函数。会有明显的内存分配。

3、在Update中频繁执行的代码,避免各种临时表的分配。

4、配表的内存优化。

5、使用 table.concat 组合字符串,而不是 .. 连接。因为字符串每次 .. 都会产生一份新的字符串内存。

6、尽可能避免数组、字典或者自定义类型的交互参数,这会产生大量的GCAlloc。如果是数组,可以考虑使用一个循环遍历调用。

7、尽可能避免lua到C#传递字符串参数。传递字符串操作不可避免的会有多份内存开销,C#需要把lua的字符串内存拷贝到托管堆中,这是性能低下且有GCAlloc的。

  比如我们GetComponent获取类型,就统一使用type(CS.XXX)这样的类型做参数,而没有使用字符串获取组件。无论从交互的角度考虑,还是在C#内部获取组件的效率考虑,都会获得更好的性能。

 

(八) Shader的性能优化经验

1、使用Mobile版本的shader。比如Particle/Additive中有ColorMask,这个在移动平台比较耗。

2、避免使用昂贵的数学函数。比如 pow exp log cos sin tan 等。

3、尽可能减少纹理采样数目。

4、如果有一些计算比较复杂,可以使用查找纹理(lut)

5、优先使用低精度的数字格式。优先使用half。在现代gpu上fixed等同于half。部分对精度有特殊需求的情况下才使用float。个别情况下,尤其是与法线相关的时候,使用half容易因为精度不足导致渲染结果错误,这个时候还是应该使用float。

(九) 资源规格

1、模型角色高模 6000+面,低模 3000面。顶点数不定,因为顶点存在共用的情况,所以顶点数量可能是面数的两倍,也可能比面数低。纹理使用1024大小的贴图。低模纹理大小减半。主角因为有高低模,所以不开mipmap。个别精度比较高的npc(有UI显示需求)需要开mipmap,否则场景中会有明显的闪烁。

2、场景的drawcall控制在200以下。个别场景因为美术需求,drawcall可能会更高一些,但是SetPassCall也要尽可能保证比较小(材质尽可能共用,可以有效减少SetPassCall)

3、场景的面数在100k~300k之间都是合理的数值。

4、UI的drawcall100以下,一般就是三四十常驻UI Drawcall。

(十) 性能优化的常用工具

1、Unity Profiler。deep profile。

2、Miku-LuaProfiler

3、Frame debugger

4、Snapdragon Profiler或者RenderDoc

5、Xcode的instrument

6、Intel GPA

 

B. 渲染相关知识

一、渲染管线

  输入3D模型,输出2D图片。主要分三个阶段。应用程序阶段、几何阶段、光栅阶段。

1、应用程序阶段:CPU负责。准备场景数据、Culling剔除、设置模型的渲染状态、渲染三角面。

2、几何阶段:GPU负责。顶点变换、光照、裁剪、投影、屏幕映射。简单理解为顶点着色器。

3、光栅化阶段:GPU负责。像素着色器。计算每个像素最终的颜色。

4、GPU流水线:

  4.0 应用程序阶段数据

-------------- 几何阶段

  4.1 顶点着色 (模型变换、视图变换、顶点着色)

  4.2 裁剪 (投影变换、裁剪)

  4.3 屏幕映射 (视口变换)

-------------- 光栅化阶段

  4.4 三角形设置和遍历

  4.5 像素着色

  4.6 混合 (Alpha测试、模板测试、深度测试、Alpha混合)

最终输出到屏幕(帧缓存)

5、如果没有使用AlphaTest。则移动的GPU大多会进行EarlyZ的优化,提前丢弃掉不需要绘制的片元。但是开启AlphaTest之后,就必须执行像素着色器才能知道是否要丢弃掉这个片元。ZTest就只能在像素着色器执行之后再执行。所以AlphaTest性能会比AlphaBlend低。

6、模板测试。比较参考值和模板缓冲区的模板值,决定是否舍弃该片元。

7、深度测试。ZTest。比较当前深度值与深度缓冲区中的深度值,决定是否舍弃该片元。

  通过ZWrite和ZTest的灵活运用,可以实现很多效果。比如某个特效一定要显示在所有模型之前。

8、Alpha Blend。

二、渲染管线、SRP、URP、HDRP

1、SRP。Unity提供了一套C#的API,可以定制渲染管线的内容。

2、URP。通用渲染管线。用于移动平台。

3、HDRP。高清渲染管线,用于主机平台。

三、PBR

1、核心理论是微表面原理。任何一个物体,都处理为无数细分的微平面,然后处理光线反射。

2、遵循能量守恒。即,入射光线能量等于反射光线能量。

3、达到的效果是更加真实的光照表现。且在各个灯光环境下表现都是合理的。极大简化了美术的制作难度,因为只要遵循统一的标准去做,结果就是对的。

4、PBR应该在线性空间制作。这个需要在项目初期就定好。

5、BRDF 双向反射分布函数。给定一个入射角度和观察角度,给出一个最终射向观察角度的光的强度系数。

  我们游戏中使用的是BRDF2。BRDF1开销比较大,BRDF2是lut,效果比较差。

6、直接光照。漫反射+高光(GGX)

7、间接光照(IBL,Image-Based Lighting)。Unity烘焙场景的时候会生成一个ReflectionProbe的CubeMap。通过textureCubeLod对这张cubemap的多个mipmap层级采样,进行差值计算,得出不同粗糙度下的反射。

8、Albedo图是物体基础颜色,不带光照信息

  Normal图是法线图,由高模烘焙出来。

  PAC图,rgb通道分别代表metallic、smoothness和emission。

9、金属工作流和反射工作流

10、高光抗锯齿。有可能我们在屏幕中看高模会有白色噪点闪烁的现象,这个可以用高光抗锯齿来解决。或者降低纹理精度。

 

四、功能的实现

1、平面阴影的推导

2、镜面反射的实现

用一个摄像机在正确的角度绘制场景和角色。输出的RenderTexture作为纹理叠加到目标区域,即可实现目标区域的镜面反射效果。

3、距离雾、高度雾、体积雾

正常Unity是距离雾,根据摄像机远近控制雾的浓度。有的时候我们需要实现根据高度控制浓度的高度雾。比如悬崖或者高山。

4、换色如何实现

5、贴花如何实现

6、流光如何实现

7、死亡溶解效果如何实现

8、角色被遮挡的xray效果如何实现

  主角两个pass。第二个pass被遮挡的时候(zTest的时候当前深度值大于自己)叠加到目标上。

  需要保证场景先画,然后再画主角,最后画其他玩家、怪物等。

9、角色残影效果如何实现

  BakeMesh。缓存。使用Graphic.DrawMesh绘制。也可以实例化GameObject,然后绑定上MeshFilter绘制。

10、屏幕空间扰动效果如何实现

  移动平台不要用GrabPass。开销很大。

  做后处理。特效渲染的时候输出扰动区域到一个纹理上,然后后处理根据这个区域做扰动效果。

 

五、其他

1、菲涅尔效应。边缘光。模拟物体边缘漏光。越靠近边缘的地方越亮。

  游戏中有两个菲涅尔效果。一个是PBR本身的,颜色跟灯光相关。另一个是定死颜色,在程序中控制,用来做高亮选择或者受击闪白这样的特效。

2、GammaSpace、LinnerSpace

  GammaSpace是为了早期为了应对晶体管显示器。

  在Gamma空间下,一旦亮度调高,白色就会爆亮。而在线性空间下,就可以很柔和的进行过度。

3、HDR、Tone Mapping

  HDR渲染出的亮度值可能会超出显示器最大亮度,也就是值会大于1。

  HDR常跟Bloom结合起来使用,HDR大于1的部分就是发光的部分,Bloom会做光晕的溢出。如果没有HDR,就无法很精确的控制哪些区域应该有光晕。效果就是一个全屏幕的泛光。

  为了将光照结果转换为显示器能够正常显示的LDR。就需要通过Tone Mapping将颜色值做个映射。

4、次表面散射(SSS)

5、Depth Buffer 深度缓冲区

6、Stencil Buffer 模板缓冲区

7、CommandBuffer

8、PostProcessing

9、Shadow Mask、Distance Shadow Mask

 

六、数学相关

1、向量点乘 dot 结果是一个值(标量)。

  两个向量点乘等于两个向量的模乘以向量夹角的余弦值。

  几何意义是一条边在另外一条边上的投影,再乘以另一条边的长度。

  两个向量点乘,可以得到两个向量的cosAB。通过这个可以判断两个向量的相似性。

  计算两个向量的夹角:<a,b>= arccos(a·b / (|a|·|b|))

  根据夹角可以判定敌人是在玩家身前(点乘的结果大于0)还是身后(点乘的结果小于0)。

2、向量叉乘 cross

  两个向量叉乘,结果还是个向量,向量垂直于两个向量平面。方向由选用左手坐标系还是右手坐标系而定。Unity是左手坐标系。

  通过叉乘判定a向量到b向量的最小转向角度。

  叉乘可以计算平面的法线向量。

3、总结,点乘可以判定目标是在我的前方还是后方,大于0在前方,小于0在后方

  叉乘可以判定目标是在我的左边还是右边,得到的向量的Y大于0在右方,小于0在左方。

3、矩阵乘法

4、欧拉角和四元数

5、Blinn-Phong的光照公式。L = 漫反射颜色 + 高光反射颜色

6、Unity是左手坐标系,x正方向向右,y正方向向上,z正方向向屏幕里。

 

七、实际项目经验

1、控制矩阵和向量的计算顺序。减少矩阵乘法。

2、尽可能避免if等分支选项。step并不能有效提升性能,跟if差不多。

  这里要说明的是,性能损耗最大的是branch的if。

3、尽可能减少 cos、tan、pow等数学函数。比如我们在模拟线性空间的计算的时候,使用 x*x替代了pow(x, 2.2)

4、尽可能减少纹理采样数量。

5、pow的参数必须是大于0的。如果参数小于0,可能在某些手机上存在问题,显示为黑色。应该使用 saturate 函数括一下,保证其为正数。shader兼容性问题。

图文无关

C. 图形渲染进阶知识

零、前言

  我并不是图形学专业,也不是做引擎开发的,对图形渲染的理解都是工作中零零碎碎学到的碎片化知识。我所理解的东西并不保证准确甚至可能有错误。最近在整理资料的过程中,对之前模糊的概念有了进一步的认识,所以在这里记录下来。期望能对还不了解这些概念的人以帮助。

  有很多东西其实是可以融会贯通的。比如我们常听人说AlphaTest性能低,如果更进一步了解Early-Z、HSR、TBDR之后,就可以理解为什么Unity要先渲染不透明物体,再渲染半透明物体。我们常听人说Drawcall影响性能,更进一步了解渲染管线,知道什么是Drawcall,什么是渲染状态,就会更进一步了解SetPassCalls是什么东东。

  理解本文中列举的几个关键知识点,也就可以对渲染优化有一定的概念了。回过头可以再找几篇显卡厂商的优化相关的文章做参考。

一、渲染管线

(一)、应用程序阶段:

1、粗粒度剔除,把不在屏幕内的模型剔除掉。

2、传递顶点等参数给GPU,即Drawcall。

(二)、几何阶段:顶点着色器

1、计算顶点的世界坐标

  float4 posWorld = mul(unity_ObjectToWorld, v.vertex);

2、得到切线空间到世界空间的转换矩阵

  o.tangentToWorldAndPackedData

3、计算顶点光照

  o.ambientOrLightmapUV = VertexGIForward(v, posWorld, normalWorld);

  场景是使用Lightmap。所以这个参数存放的是lightmap参数

  角色没有使用lightmap,一般都开启顶点光照。所以这里存放的是球谐光照结果。Light Probe。

 

总结,顶点着色器的工作就是根据输入的顶点坐标等参数,转换成世界坐标,求得切线空间到世界空间的转换矩阵,计算顶点光照、顶点雾等参数。输出结果给像素着色器使用。

(三)、光栅化阶段:像素着色器

1、把法线图取到的法线转换成世界法线

  s.normal = TangentToWorld(i, s.normal);

2、得到 albedo、spec和反射率

  half oneMinusReflectivity;   s.albedoColor = LIGHTING_SETUP(s, s.specColor, oneMinusReflectivity);

3、GI

3.1、设置光照。

o_gi.light = data.light; o_gi.light.color *= data.atten;

3.2、间接光的diffuse。静态物体是Lightmap,动态物体是球谐光照。

  Lightmap:

o_gi.indirect.diffuse = DecodeLightmap(UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy));

  or 球谐光照:

o_gi.indirect.diffuse = ShadeSHPerPixel(s.normal, data.ambient, data.worldPos);

3.3、间接光的specular。

  对天空球进行采样,根据粗糙度取到不同的mipmap的反射cube。光滑表面取到的图片精度较高,能反射清晰的天空球。粗糙的表面取到的精度比较低,反射看着就比较模糊。

half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflect(s.eyeVec.rgb, s.normal), perceptualRoughness * UNITY_SPECCUBE_LOD_STEPS) * (1-perceptualRoughness*0.5); o_gi.indirect.specular = DecodeHDR(rgbm, unity_SpecCube0_HDR);

3.4、我们额外做了一个把漫反射的lightmap合并到specular上的操作,防止金属丢失lightmap信息。因为按照计算,金属反馈高光比较强烈,但是完全丢失lightmap效果就不太好。

o_gi.indirect.specular += max(0, o_gi.indirect.diffuse - UNITY_LIGHTMODEL_AMBIENT.rgb * 0.1 - unity_IndirectSpecColor.rgb); //additive blend

4、PBL

4.1、GGX CookTorrance

half specularTerm = roughness / (s.eyeVec.a * (1.5h + roughness) * d);

4.2、Diffuse

half3 color = s.albedoColor * (gi.indirect.diffuse + gi.light.color * diffuseTerm);

4.3、高光

color += saturate(specularTerm * nl * gi.light.color * s.specColor);

4.4、菲涅尔

color += surfaceReduction * gi.indirect.specular * lerp(s.specColor, saturate(s.smoothness + (1 - oneMinusReflectivity)), fresnelTerm);

 

总结,像素着色器最终得到的就是一个颜色值,由输入的纹理+光照得来。光照分直接光和间接光。直接光由漫反射(Diffuse)和高光(Specular)组成。间接光由Lightmap、ReflectionProbe得来,这些图都是Unity烘焙场景时得到的,IBL说的就是间接光的处理。最后再加上PBR中常见的菲涅尔效果的计算。

(四)、延迟渲染(Deferred Rendering)和前向渲染(Forward Rendering)

1、延迟渲染会先进行顶点着色,把模型都渲染到屏幕上,然后再进行光照等复杂逻辑的计算。

好处是多光照模型下,性能很好,效果也很好。

2、延迟渲染一般分成两个步骤(2个Pass)

  第一个Pass渲染G-Buffer。正常渲染一遍场景,经过顶点着色器模型坐标变换和片段着色器,计算色彩(diffuse),法线(normal),高光(specular)、深度(depth)、阴影(shadow)等,并把结果缓存到内存中备用。

  第二个Pass利用G-Buffer贴图中的数据重构每个片段的位置信息并进行逐光源的光照计算。最后结果会叠加到光照计算结果和阴影等输出最终的像素颜色。

3、延迟渲染的好处是可以处理多光源的情况。缺点是无法正确处理半透明,且会消耗更多的显存带宽。

4、还有一种 Forward+ 渲染。思路是将屏幕划分为一个一个的块儿,控制每个块儿的影响灯光数量,从而减轻运算压力,让前向渲染也可以处理多光源的情况。不过这个貌似并没有被广泛应用。主流游戏引擎还都是延迟渲染+前向渲染。

5、在移动平台下,我们都是使用前向渲染。因为从需求来说,手机游戏一般只有一个实时方向光,几乎没有或者很少使用实时点光源,有实时动态光照的需求,一般都是使用LightProbe解决。这种情况下因为灯光数量很少,延迟渲染没有应用的意义。另外,移动平台设备基本上都是TBDR架构,这种情况下相当于硬件层面做了延迟渲染的活,我们在软件层面就更加没有使用延迟渲染的必要了。

二、线性空间与Gamma空间的理解

(一)、什么是Gamma校正 (Gamma Correction),为什么要进行Gamma矫正

1、早期CRT显示器,输入的电流和显示的亮度不是线性关系,波形是一个下弦曲线(Gamma2.2,即Pow2.2,颜色值会经过2.2的幂计算)。这个影响参数就是Gamma值。

  为了正确显示颜色,需要对输出给显示器的颜色进行Gamma校正(Gamma1/2.2,一个上弦曲线,Pow0.45)。

2、人眼睛对暗部的分辨程度要明显高于对亮度的分辨程度。进行Gamma校正,可以更好的保存有效颜色信息。

3、现代的液晶显示器虽然没有电流和亮度的问题。但是一方面为了兼容CRT显示器,另外一方面Gamma校正也有显示意义,所以现在的显示器都保留了Gamma校正。

4、如果将来科技进步了,人们以32位去保存颜色信息而不是现在的8位,那么Gamma校正就没有存在的价值了。

(二)、sRGB颜色标准

1、我们通过相机拍摄的图片,或者是ps保存的图片,默认都是以sRGB标准存储的,颜色经过Gamma0.45的校正。颜色值经过 Pow0.45 存储,显示器显示的时候会进行 Pow2.2,那么最终显示的结果就是真实的颜色值。

2、视觉表现上,Gamma0.45 会使图片变亮,Gamma2.2 会使图片变暗。

(三)、Gamma空间下的处理流程

1、输入的图片是sRGB标准的图片,Gamma0.45。

2、直接将采样结果带入光照公式计算。将结果写入FrameBuffer。

3、显示器经过 Gamma2.2,最终显示成正确的颜色。

(四)、线性空间下的处理流程

1、如果图片本身就是线性存储的,那么就直接采样即可。

  如果图片是sRGB存储的,则需要进行 Remove Gamma Correction(Pow 2.2),将颜色转换到线性空间。

  OpenGLES3.0有硬件指令支持,不需要对每个像素执行Pow操作,否则性能太低了。这也是为什么线性空间需要OpenGLES3.0的原因。

2、光照计算在线性空间进行。计算完的颜色进行 Gamma校正(Pow 0.45),转回Gamma空间。这里同样有硬件指令支持。

3、显示器经过 Gamma2.2,最终显示成正确的颜色。

(五)、为什么PBR都应该使用线性空间

1、各种光照公式都是在线性空间进行的。比如,1的亮度就应该是0.5的亮度的2倍。但是在Gamma空间下,图片是 Gamma0.45 处理的。这个系数被带入光照公式中,计算结果显然就不对了。表现结果是亮的地方很容易就曝光严重。

2、选择Gamma空间,则引擎不会对输入和输出做Gamma校正。而因为图片存储时都是经过Gamma校正的,所以光照计算带入了 Gamma0.45,最终得到的颜色就跟实际光照结果有偏差了。

3、选择线性空间,则光照计算都是在线性空间下计算的,输出的时候才进行Gamma校正转回Gamma空间。所以光照结果是准确的。

(六)、Unity中纹理 sRGB 选项

1、Gamma空间下,引擎不会对图片做Gamma校正,是否勾选sRGB选项无影响。

2、线性空间下,勾选sRGB选项,则代表图片是在Gamma空间存储的。引擎在加载这些图片的时候会先执行 Remove Gamma Correction 的操作,恢复到线性空间。

3、线性空间下,如果美术工作流程已经统一,图片输出的时候存储在线性空间,则不需要勾选 sRGB选项。反之,存储在Gamma空间的,则需要勾选。

4、类似的,shader中输入的颜色默认也会被认为是 sRGB 颜色,会自动进行 Remove Gamma Correction 的操作。而如果自己定义的 Float 参数也想 Remove Gamma Correction,则需要在参数前添加 [Gamma] 的前缀。

三、TBDR、Early-Z和渲染顺序

(一)、GPU架构

1、GPU的渲染架构有 IMR(Immediate Mode Rendering,主要用于PC平台),TBR(Tile-Based Rendering,主要用于移动平台)和TBDR(Tile-Based Deferred Rendering,针对TBR做了进一步优化,主要是PowerVR在用)。

2、大部分手机的GPU用的都是手机的System memory和一块容量很小的,带宽比System memory更高的专供OnChip memory。

  SRAM,GPU的OnChip memory。可以理解为小区的便利店。虽然比较小,但是访问速度很快。

  DRAM,显存,离GPU较远,可以理解为市中心的超市。比较大,但是访问速度慢。

  从GPU对FrameBuffer的访问,就相当于一辆货车大量的在家(GPU)和市中心超市(DRAM)之间往返运输。带宽的消耗和发热量之大,是手机上无法接受的。

3、对移动平台而言,功耗是第一位。因为功耗意味着发热量、耗电量、芯片大小等等。

  对功耗影响最大的是带宽。所以对移动芯片而言,第一考虑的不是渲染性能,而是如何通过缓存减少带宽消耗。即,减少对显存的访问。

(二)、IMR、TBR和TBDR

1、IMR(Immediate Mode Rendering),是立即渲染模式。每个像素渲染的时候直接访问DRAM,并写入到FrameBuffer上。PC平台的显卡都是IMR架构。

2、TBR(Tile-Based Rendering),基于Tile渲染。适合移动平台。

  为了减少GPU访问SystemMemory的次数(延迟高、功耗高),将屏幕分成一小块儿一小块儿,确保这一小块渲染所需要的绝大部分数据都能同时装进小小的OnChip memory,从而实现整个渲染大部分操作都可以在带宽较高的OnChip memory上完成。

  TBR是以牺牲执行效率为代价,换来功耗降低。在移动平台直接访问FrameBuffer会有很大的带宽开销,进而影响功耗。所以TBR把屏幕分成格子之后,每个格子可以访问SRAM(OnChip memory),一整块都访问好之后,再整理转移回DRAM。

3、TBDR(Tile-Based Deferred Rendering),基于Tile的延迟渲染。只对玩家能看到的像素做 pixel shader。通过HSR将不可见的点剔除掉。大幅减少对显存带宽的消耗。

  HSR,Hidden Surface Removal,隐藏面消除。在光栅化阶段真正开始前实现像素级裁剪。

4、TBDR,相对于IMR,多了一个FrameData的数据,里面包含了有效信息,可以进行剔除操作。

  TBR架构下,是不能来一个CommandBuffer就处理一个的,因为任何一个CommandBuffer都可能会影响到整个的FrameBuffer,如果来一个就画一个,那么GPU可能会在每一个DrawCall上都来回搬迁所有的Tile。所以TBR的策略一般是对于CPU过来的CommandBuffer,只对他们做VetexProcess,然后对VS产生的结果暂时保存。只有必须刷新整个FrameBuffer的时候(比如Swap Back and Front Buffer、glFluash、glReadPixles等等)才会真正的做光栅化。

  这个暂存的数据就是FrameData。FrameData是TBR特有的在GPU绘制时所需的存储数据。既然TBR是等待所有的FrameData数据一起绘制pixel,那么就可以实现Deffered Rendering。硬件巧妙的利用TBR的FrameData队列实现了一种延迟渲染。即,尽可能只渲染那些最终影响FrameBuffer的像素。

5、为什么PC平台不使用TBR?因为实际上直接对DRAM进行读写速度是最快的。TBR需要一块儿块儿的绘制然后往DRAM拷贝。可以简单理解为TBR是牺牲了执行效率,来解决更重要也更难处理的带宽功耗。

(三)、Early-Z和HSR

1、现代显卡(无论是IMR还是TBR),会在执行像素着色器之前,先进行一轮深度测试,就是EarlyZ。以避免会被深度测试剔除的片元,执行复杂的像素着色器的逻辑。这样可以减少显存带宽的消耗。

  如果没有Early-Z,那么很有可能屏幕上一个像素点会被渲染七八次,显存带宽消耗成倍的增加。

2、AlphaTest在像素着色器才会执行clip操作,才知道会不会剔除这个点。因此使用AlphaTest后,EarlyZ会无效。所以AlphaTest在移动平台性能比较低。

  discard或者clip会导致Early-Z Culling无效。

3、HSR,Hidden Surface Removal,隐面剔除。这个是PowerVR的专利技术,它可以在硬件层面对被遮挡的点进行剔除。它可以保证屏幕上的点不会被重复渲染,比Early-Z更加高效。

  支持HSR的设备(一般是iOS设备),可以不用对物体进行排序,因为HSR已经可以保证裁剪掉被遮挡的片元,也就不依赖Early-Z了。

  对不支持HSR的设备,还是需要对物体进行排序,以尽可能的利用好Early-Z。

4、HSR比Early-Z更加高效。

  一方面不用对物体排序了,省去了软件层面排序的CPU开销。

  另外一方面Early-Z无法有效保证一个点不会被重复渲染。比如一个超大的模型,软件层面排序应该是离摄像机较近的,应该先渲染,但是实际上它的很多点都是被遮挡的,应该被剔除掉。

(四)、渲染顺序

1、不透明物体,离摄像机近的先绘制,这样它会因为ZTest剔除掉后面的渲染内容,性能更好。

2、半透明物体,必须从后往前画,先画离摄像机远的物体,才能保证渲染结果正确。

3、shader非常复杂的模型,尽量后绘制。比如T4M的地表shader,采样了七八张图纹理,那么它最后绘制,更加容易被Z-Culling剔除掉。

4、不透明物体(2000)----AlphaTest物体(2450)----半透明物体(3000),这个渲染顺序有利于HSR或者Early-Z的优化。

  Alpha-Test 在不透明物体绘制完毕之后再绘制,可以避免Alpha-Test导致EarlyZ失效,最后绘制,至少不会影响到之前不透明物体的EarlyZ。

5、RenderQueue2500,区分了不透明和半透明。半透明物体(2500以上)永远在不透明物体(2500以下)后绘制。

  如果物体的RenderQueue在2500的同一侧,则sortingOrder优先级更高:

Camera Depth > Sorting Layer > Order In Layer > RenderQueue > 距离相机的距离

(五)、TBDR的相关优化

1、不使用FrameBuffer的时候及时Clear。在RenderTexture上,当不使用RT之前,调用一次Discard,可以在某些移动设备上提高性能。

2、每帧渲染之前尽量Clear。

3、对于大部分Android设备,预先进行的Early-Z pass可能可以大大减少overdraw。Early-Z发生在整个光栅化之前,因为它处理的是FrameData。

  对于iOS设备(PowerVR),因为它内部有硬件支持去处理FrameData(HSR),所以不需要Early-Z。

4、得益于TBR架构,Blending和MSAA效率很高。

5、避免大量的Drawcall和顶点。在PC上Drawcall和顶点数量对GPU没有太多严重影响。但是对于TBDR,DrawCall过多意味着FrameData数据过多,严重情况下可能会出现内存放不下的情况,这种情况下对FrameData的访问速度奇慢。

  所以对于移动设备,Drawcall不只影响CPU,还会影响到GPU。

  顶点数量在一定范围内影响不大,但是如果达到1M,那么可能就会触发上面提到的极端情况,会大幅影响性能。

6、PowerVR 使用 HSR 进行隐面剔除。不需要对不透明物体进行排序。

  其他Android设备上使用 Early-Z,需要对不透明物体进行排序。从前往后绘制,会更好的利用Early-Z。

四、Drawcall的理解

(一)、Drawcall和渲染状态(RenderState

1、Drawcall就是OpenGL或者DX中的渲染接口。提交顶点给GPU。

  Drawcall过多,CPU传递给GPU的渲染指令就越多。同时一个Drawcall通常还会面临数据拷贝、渲染状态设置等等。如果GPU压力越大,CPU就必须等待GPU执行完毕才能处理下一帧,所以CPU会空置。带来的影响是Profiler中会看到 Gfx.WaitForPresent 或 Graphics.PresentAndSync。

2、SetPassCalls,要渲染一个模型只处理顶点是不够的,还要有材质信息、纹理信息、材质中的开关状态等等。这些就是渲染状态。在渲染队列中,是渲染状态和渲染指令交替进行的。

  如果一批模型材质等都相同,那么就不需要设置渲染状态,只处理渲染指令即可。相对性能会提升很多。在Unity中的表现就是Drawcall很高,但是SetPassCalls比较低。

(二)、StaticBatch和DynamicBatch

1、合批的目的是减少Drawcall。合批是有对应的CPU和内存开销的。大多数时候减少Drawcall有利于提升整体性能。但是如果滥用合批,可能会导致CPU开销更大,产生负优化。

2、动态合批为了平衡CPU消耗和GPU性能优化,将实时合批条件限制在比较狭窄的范围内。静态合批则牺牲了大量的内存和带宽,以使得合批工作能够快速有效的进行。

(三)、GPU Instancing

1、GPU Instancing 没有动态合批那样对网格数量的限制,也没有静态网格那样需要这么大的内存,它很好的弥补了这两者的缺陷。

2、GPU Instancing 并不通过对网格的合并操作来减少Drawcall,GPU Instancing 的处理过程是只提交一个模型网格让GPU绘制很多个地方,这些不同地方绘制的网格可以对缩放大小,旋转角度和坐标有不一样的操作,材质球虽然相同但材质球属性可以不同。

3、GPU Instancing、动态合批、静态合批三者所擅长的各不相同,有互相弥补的地方,各自本身也存在着不同程度的限制和优缺点。从整体上来看,GPU Instancing 更适合同一个模型渲染多次的情况,而动态合批(Dynamic batching)更适合同一个材质球并且模型面数较少的情况,静态合批(Static batching)更适合当我们能容忍内存扩大的情况。

五、其他

(一)、PBR的金属工作流和反射工作流的区别

1、金属工作流和反射工作流的差异在于输入的纹理是什么。正常情况下都是用金属工作流。

1.1、金属工作流的纹理是金属度、光滑度信息。

Albedo Texture的rgb通道为albedo;a通道对应于Alpha;

其Diffuse Color需要通过以下方法来计算:

oneMinusReflectivity = unity_ColorSpaceDielectricSpec.a*(1-metallic); diffColor = albedo * oneMinusReflectivity;

其Specular Color需要通过以下方法来计算:

specColor = lerp (unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);

注意:unity_ColorSpaceDielectricSpec在unity内部的值为half4(0.04, 0.04, 0.04, 1.0 - 0.04) //linear space。

1.2、反射工作流的纹理是高光信息。

Albedo Texture的rgb通道对应光照运算方程中的diffuse color;a通道对应于Alpha;

Speculer Texture的rgb通道对应光照运算方程的F0;a通道对应光照运算方程的Smoothness;

其oneMinusReflectivity = 1-SpecularStrength(specColor);//SpecularStrength函数是来获取rgb通道最大值;

2、根据输入的 albedoColor和其他参数,获取实际的 diffuseColor、specColor和 oneMinusReflectivity。即diffuse颜色、高光颜色和反射率。用来输入到光照函数中。

(二)、Mipmap的理解

1、Mipmap的优点:

1.1、避免远处物体的锯齿或者闪烁感,效果表现更好。

1.2、Texture Cache,纹理采样的缓存命中率上升,减少对显存的直接访问,减少带宽io,进而提高渲染效率。

2、Mipmap的缺点:多消耗了1/3的内存,增大包体积。

3、渲染时会根据像素点的uv坐标计算,决定取哪个层级的mipmap。多层mipmap之间可以进行插值。所以同一个物体不同的像素点可以是不同的mipmap。也就是说mipmap层级的选择是像素级别的。

  个人理解,mipmap对带宽的优化体现在缓存命中率上,也就是因为不需要访问显存,从而减少了带宽的消耗。而不是因为只传递小图片导致带宽消耗降低。

4、很多时候不开Mipmap效果无法接受,所以对于3D视角的游戏而言,Mipmap是必开的。

5、2D的元素,比如UI图片,是不需要开Mipmap的。

6、Trilinear - 三线性过滤。很多图片尤其是法线,开了mipmap就要选择三线性过滤。默认选择线性过滤,纹理采样是4个点,而三线性过滤会采样8个点,在两级mipmap之间进行混合。如果不使用三线性过滤,可能会导致在视野远方,存在明显的一条分界线,其产生的原因就是分界线前后采样了不同层级的mipmap,导致显示结果产生明显差异。

  没有开启mipmap的图片,线性过滤和三线性过滤结果是一致的。三线性过滤是专门对mipmap进行优化的采样方式。

(三)、显存和显存带宽的理解

1、PC机,CPU和显卡是两个独立的硬件,内存和显存是完全独立的。而对于手机而言,CPU和GPU都在一个SOC上,共用一个物理内存。当然GPU会有逻辑上独立的内存空间,操作系统内核的内存管理不会处理这块儿内存区间。

2、显存带宽的主要消耗不是传输纹理上,只要显存(内存)足够,纹理是一直在显存中的,不存在传输开销。也就是说纹理填充率一般不会形成性能的瓶颈。

  显存带宽的开销主要在GPU对显存数据的访问上。开启Mipmap,纹理采样时Texture Cache(一个存储图片数据的只读cache,在shader processor附近,有高吞吐率和低延迟)容易命中。进而避免GPU对显存的访问,也就可以降低带宽开销。

3、补充下GPU的内存类型,主要是对各级缓存有一些概念。对缓存有一定的 了解,也就可以理解提高缓存命中为什么很重要。比如,减少贴图尺寸不仅仅可以优化内存占用,还会因此提高缓存命中率,进而提高渲染性能。Mipmap对性能的优化也体现在这里。

  PC机的GPU内存跟CPU很像,也是多级缓存机制。包括,Register,Shared Memory,Texture L1 Cache,Instruction Cache,L2 Cache,DRAM。各类存储器容量依次增大,相应的在芯片上的位置也离核心单元越来越远,同时访存延迟也越来越大。

  移动设备的GPU没有专用的显存,而是和CPU共享同一块儿物理内存,缓存机制也是一致的。不同之处在于,GPU上有一块儿On Chip Memory的缓存。这个硬件是TBDR的实现基础。

(四)、Unity下几种Lighting模式

1、Baked Indirect,仅预先计算间接光照,不执行阴影预计算。阴影完全实时生成。所以远处没有阴影,近处是ShadowMap的阴影。

2、Shadowmask,烘焙间接光和阴影。动态模型和场景可以受实时方向光影响。即实时方向光可以改变场景色调。

  当选择为 Distance Shandowmask 之后,阴影光照图可以与实时阴影按照Shadow Distance设置的距离进行混合。近处显示实时阴影,远处显示ShadowMask。实时阴影更加清晰,Shadowmask受精度影响,效果不如实时阴影好。

  Shadowmask模式会将实时光和烘焙的光混合,同样可以混合实时阴影和烘焙的阴影。那么它就需要知道哪里是阴影,这个就需要 ShadowMask 图。

3、Subtractive,烘焙直接光照、间接光照和阴影。改变方向光不能影响场景。在此模式下,参与实时计算的只有动态物体的实时光照和动态物体对静态物体的投影。阴影直接被烘焙在lightmap上,所以不需要ShadowMask图。

(五)、LUT,用贴图缓存中间计算结果。

参考洛城写的文章,因为很多人会对LUT存在误解或误用,所以copy了一段文字在这里。

1、很多时候,我们会把一些数学上的中间计算结果缓存到一张贴图里,这些贴图的数值本身不代表视觉信息,而是纯粹的数字。比如Marschner Hair Mode用LUT去存BRDF;UE4用LUT去存储PBR的环境光BRDF。

2、LUT带来的性能损耗有两点:

2.1、贴图本身是数值,所以只能用无损格式,无法压缩,所以bytes per pixel是比较大的,比一般贴图占用更多读取带宽。

2.2、对于贴图的采样是基于LUT的uv计算的,而相邻像素算出的uv通常都没有空间连续性,这就表示每次LUT的采样几乎都会导致cache miss,所以这类LUT比一般贴图的采样更慢。

3、结论:尽量使用拟合函数去代替LUT采样,对于Mobile GPU来说,永远不要尝试用LUT去优化一段shader;对于Desktop GPU来说,慎重考虑使用LUT。

 

来源知乎专栏:Unity开发启示录

最新回复(0)