漫谈Unity渲染管线的流程化设计
本文目标读者:项目主程,引擎工程师,渲染工程师,技术美术等。本文难度:较高
本文将涉及到:图形学原理,渲染管线,游戏开发设计模式,项目代码管理等。
在之前的几篇文章中,我们分别实现了一套基础的GPU Driven Pipeline,一套完全手动的简易渲染管线。链接如下:
这些实现大多数看起来新奇有趣却缺乏实际在项目中落地的能力,与其称之为“管线”更不如说是一个简易的,实验性质的渲染器,而实际项目开发中,一定是需要非常成熟稳定,健壮且可扩展性很强的渲染框架才可以应付的了策划,美术,设计无休止的需求,这就要求开发团队制定一套宏观的渲染框架,来确保在团队开发中,一套渲染方案不至于影响游戏其他方面的程序工作,并且还能大大提高开发效率,调试效率,将项目的技术提升到新的层面。
自研管线适合什么样的平台?
笔者认为,自研管线对平台的重要性从高到低排序,应该是 手机>主机>PC
手游开发的特点非常适合自研渲染管线,首先,手机的功耗,运算等硬件标准,在游戏开发时显得非常敏感,一点性能消耗的提升,带来的将会是用户手机发热,顿卡甚至闪退,从而严重影响用户体验。这时就需要更加精确,可控的渲染,在调试过程中,用更快的效率锁定性能短板,并进行相应的优化。反之,如果是使用内置管线,调试的过程中有些时候即使看到性能短板,也只能望洋兴叹,无能为力。 手游对机型适配也有很高的要求,有些手机上,总会有奇妙的bug存在,而有时这种问题发生在引擎和硬件沟通的最底层。无论是性能优化,还是机型适配,使用内置管线唯一的解决方案就是拿到源码并联系Unity官方的技术人员以及手机制造方的技术人员。这对于小公司或独立开发团队来说,几乎是天方夜谭,对于大公司来说也意味着数日甚至数周的时间消耗。而使用自研管线,这个过程就会变得简单的多,如果仅仅是软件层面的问题,可以很快的定位出现问题的位置,寻找替代的解决方案,如果是硬件接口层面的问题,也可以更精准的把问题反馈给手机生产方,可谓事半功倍。
主机开发同样也需要一定程度的定制管线,虽然比起手机来说,性能宽松不少,但是在许多大场景绘制时,还是需要一些特殊的渲染技巧,如使用GPGPU驱动的渲染管线,或Async Compute做后处理等。当然,大多数这类需求,是可以通过一些封装的上层API解决的,但是对于有能力的团队,自研管线比起内置API来说,在可控性,灵活性方面依然是有优势的。
PC平台对自研管线的要求就不那么高了。如今PC平台的硬件发展趋于成熟,技术趋于稳定。光栅化似乎已经快要发展到巅峰,而光线追踪发展时间又太短,成本过高,一时难以被大众接受。PC端的大作渲染流程也相对固定,大多数都是GBuffer+Deferred Lighting或Forward+等多动态光源渲染方案,配上Lightmap, Image based light等,然后通过批量的后处理特效和Shader效果提高渲染质量。即使是管线中小小的不足对于运算能力近于恐怖的CPU和GPU来说,仿佛也不值一提。同时,要研发一套在PC上工作的高质量并且支持巨量特效,达到AAA渲染级别的自研管线,对开发能力不那么强劲的团队来说几乎是天方夜谭,成本一般会远高于收益。
应该怎样搭建一个渲染框架?
“一切抛开需求谈技术的行为,都是耍流氓”——沃·兹基硕德
技术的实现需要紧贴设计需求,笔者将根据自己的一些开发经验,浅谈一下在实际工作中,如何搭建推行技术方案,并切实解决项目中优化的痛点。
笔者在实习时,接手并成为某大厂的一个体育竞技类项目中的一名TA,这个项目已经发展到了后期,而其结构复杂度已经不允许新技术的引用,再加上一般大厂的研发效率实在令人发指,所以到最后实际也没能有机会开发并应用自研管线,但是这个项目表现出的性能问题和美术问题却是非常经典的,完全可以拿来作为反面教材分(peng)析(ji)一波,当然,考虑到商业机密问题,这里将不透露项目的具体名称或游戏截图。
在参加项目开发大概半个月时,开发组召开一个性能分析会议,只见负责优化的程序大哥,双手颤抖的指着PPT上居高不下且跳动频繁的CPU和GPU性能曲线,具体原因除UI,逻辑庞大以外,引擎版本较老(Unity 5.x),API版本较老(OPENGLES2)带来繁重的GC,渲染和逻辑更新的开销,并且游戏采用了帧同步,网络都在单线程运行,这对于手机CPU已经不富足的运算资源来说,无疑是雪上加霜。而负责图形方面的我则将重点放在了渲染方面。首先,一般体育竞技类的游戏,渲染需求主要在:场馆环境(场馆建筑,场内观众,工作人员),运动员(动作表现,外观着色),特效(获奖,进球,失败等特效表现)。那么这三个方面其实都可以使用自研的管线带来极高的性能提升。
首先是静态场景渲染,较古老的API每次DC都伴随着一次Render State的改变,也就是一次Set Pass,在之前的文章中,我们已经讲过了Set Pass Call对性能近乎毁灭性的打击,再加上手机CPU本来性能也很脆弱,因此我们必须尽可能通过合并来降低DC。但是降低DC的另一个问题就来了,由于Unity采用AABB Bounding Box计算剔除,合并模型毫无疑问会导致bounding box增大,这样就会有很多本来不会出现在屏幕上的三角面被渲染,徒增渲染面数。因此我们可以给每个合并的物体增加数个bounding box,并以此对每个bounding进行视锥体剔除运算,由于视锥体剔除运算量并不大,再加上现在许多新机型已经支持异步多线程,剔除操作完全可以进行异步处理,所以更精确的剔除+合并一般情况会带来不小的性能提升,这类剔除思想在之前的GPU Driven Pipeline的文章中提到过,其基本原理都是一样的。
对场景的操作主要降低了GPU顶点数的消耗,同时也通过合批优化了一定的CPU,而动画优化则会大幅度解放CPU。场边观众等现在市面上各种文章分享已经称得上烂大街了,顶点动画,gpu instance等这里也无需再提,而CPU Skinning一直是CPU消耗的一个很大的头。Unity提供的GPU Skin很不彻底,并没有解放CPU或内存,所以我们可以在管线中实现一套更彻底的GPU Skin来解放CPU,比如将模型骨骼矩阵的动画封装成贴图,可以使用贴图的RGBA通道,然后每3列储存一个float3x4矩阵(最后一行齐次在线性空间中没必要保存),对于不支持Alpha通道的机型也可以使用4列储存,然后将不同的动画储存在不同列,最后再将模型的顶点权重使用空闲的UV3和UV4输入即可(毕竟没有几个手游用实时GI#笑#),这样CPU端需要做的就是传入当前正在播放的动画和播放进度,再在Vertex Shader中根据传入的参数锁定UV,实现GPU Animation+GPU Skin,而本来对于CPU来说艰难的动画任务,GPU几乎是纹丝不动的,我们也将会在之后的文章中讲解GPU Skin和GPU Animation的实现。
特效实际上并没有特别多的优化空间,毕竟实打实的像素绘制,但是使用自研管线,可以更快的锁定性能短板,并根据策划与美术的需求,决定特效的重要程度,优先级,对重要的特效进行针对性优化,对不重要的特效寻找替代方案。
这么一套工作下来,保守估计整个游戏的渲染将会减少一半的性能消耗,这样的提升将会是非常巨大的,在手游渲染方面的开发,开发者们还有很长的路要走。
抛砖引玉:一套简单的渲染框架的设计
在完成对之前参与过的成(fan)熟(mian)项(jiao)目(cai)的分(peng)析(ji)之后,我们可以尝试实现一套可扩展性较高的,直观的流程式渲染管线。
所谓流程式,即低耦合度,可拆卸性强。那么一些面向过程式的开发思想在这里可以替代完全面向对象的开发思想。比如将一些共用数据统一初始化,并作为函数参数传递,将逻辑与数据做一些分离。
在本例中,我们将在PC平台实现一套Deferred Shading管线,因为Deferred比起Forward来说更加直观而且可以直接套用之前实现的GPU Driven Pipeline的调用,在本例中我们将着重表现架构的设计。
渲染一帧需要Render Target,我们首先准备一下Render Target的struct:
这里我们初始化了GBuffer的索引,并保留了贴图的引用位置,在之后的Rendering过程中分配Temporal贴图。除了贴图,渲染还需要一些数据,比如常量数据和变量,常量往往在游戏启动时就被初始化好,并不会在游戏运行过程中重复初始化或释放,所以这里需要定义一个准备常量的数据结构,比如这里把所有的固定参数数组都放在了这里,如主摄像机的裁面,顶点,以及阴影计算可能用到的定长数组,还有剔除需要用到的compute shader等:
由于是GPU Driven Pipeline,那么Compute Buffer的准备在所难免:
最后将所有需要用到的全局数据打包并准备在之后作为引用值传递:
有了数据结构以后,就可以开始搭建管线了,每一帧的渲染可以分成以下几部分:创建并清空Render Target,准备PipelineCommandData作为全局数据,并依次调用渲染事件,最后将渲染结果Blit到屏幕上,并清空释放用到的Render Target。代码如下:
我们在游戏中将其设为单例,然后再在每个摄像机上挂上脚本,使摄像机脚本主动调用该单例即可:
由于我们手动设置了摄像机渲染层为0并不执行任何清空操作,所以destination应该直接指向屏幕或Camera.targetTexture,这样,整个管线的渲染实际都在掌控之中了。
那么有了事件调用脚本,应该怎么编写最直观的事件呢?笔者认为,使用固定的层数,并通过Binary Sort将新添加的脚本根据其层数,添加到渲染任务队列里。Binary Sort使用基本的数据结构知识,编写一个检测重复层(保证层数唯一,防止出现意外)并插入排序的函数InsertTo(T value, System.Func<T,T,int> compareFunc),然后在事件脚本触发时将其添加到渲染队列中:
如图所示,队列只在触发enableInPipeline属性时,或脚本初始化,被删除时才会触发插入或删除操作,可以说将性能消耗降到了最低。
在Unity Editor里,直接把写好的继承PipelineEvent的类拽到一个GameObject上,至于是否决定将其设置为DontDestroyOnLoad,这就取决于项目的具体设计需求,比如是否要在不同的场景使用不同的管线:
比如这里简单的渲染了一张图,只添加了物体渲染,一盏太阳光的渲染以及一个天空盒的渲染,渲染出来的结果如下(依旧采用之前用过的运输船模型):
可以看到,效果完全和预想一样,管线先渲染了Geometry信息,然后计算了太阳光和阴影,最后将天空盒渲染到了屏幕上。
到这里一套基本的渲染流程就已经构建完成了,之后我们将会在这套管线中添加更多内容,如后处理效果的支持,GPU Skinning和动态物体的支持等。这些内容将会在之后的章节中谈到。
页:
[1]