Unity 可编程渲染管线 实现内幕 -- Unite 2017 柳振东分享《可编程渲染管线》
关于Unite
Unite大会是由Unity举办的全球开发者大会,至今已有10年的历史。Unite现已成为游戏行业,VR/AR行业中最具有权威性和影响力的活动。
柳振东:稍微耽误了一点时间,前面几位同事讲得稍微有点长。我们直接开始了,大家好,我叫柳振东,我在Unity担任技术支持工程师。今天的题目看了有点疑惑,为什么是可编程渲染管线剖析,今天围绕着在渲染管线上要做的一个新的特性。这个东西本身是英文,被迫翻译成中文,就变成这个东西。其实不是传统里面的可编程渲染管线,而是Unity会对渲染管线做一些比较大的调整。
OK,在我们看调整之前,首先来看一看我们现有的管线的一个状况。现在我们的这条管线按照大家计算时间的方式来看,其实大概可以说3-7年的时间。尤其在这么长的时间里面,为了去支持所有的平台,Unity的宗旨之一也是这个。这个管线本身经历了很多次的修改,多次的修改也是没法保证它的一个性能最优或者跟上最新的渲染管线技术的发展。
另外一方面最主要也是因为Unity本身去考虑很多,各种各样的一个用户可能的需求,去从这个需求里面提取,去提供这样的方法,所以是越来越复杂,越来越庞大。这是我们能够提供给大家的东西,简单地来说,主要给大家两条固定的管线。5.0之后出来以后,standard shader大部分都是可以应用的,但是特定的一些需求需要一些定制。Unity提供的本身管线的设置,额外的还有command buffer,像这些东西在我们的manual里也是有很详细的记录。但是从根本上来说,我们现在的提供给大家的渲染管线是有一些本质上的问题。首先第一个从可配置性来说,Unity肯定有一个很大的感觉,整个Unity本身是一个很大的黑盒。你不知道它具体里面代码是怎么写的,到了渲染以后很多都是一头雾水。从根本上没有办法让用户去改变使用流程。很多时候大家实现一个特定的需求的时候,对我们提供的各种配置不停地try,可能最后也没有达到理想的效果。Unity这边有一些改进。
第二个从可发现性,跟前面的有一些类似。我们提供了选项,它本身组合的数量也会随着我们选项的数量增加越来越多。一方面用户很难达到想要做的一个效果,觉得应该是这样设置出来的效果,为什么没做。我们没办法cover所有的要求,因为总量太大了。对我们Unity开发而言,去发现bug也是越来越难了。在这方面渲染管线上的bug也会报得挺多的。
另外从灵活性来说,我们尝试变得更加地灵活,实际上这个东西多了以后,反而比较死了。比如说我在这条管线里面我用了这个,我在游戏里面做一些效果,可能有一些部分是不想做的。有一些东西不希望做上去,我不需要做哪些东西。有时候有一些操作是没法通过现在的方式去去掉这个逻辑的。有可能可以通过一些比较绕的方式去做到,但是也是一个比较纠结的问题。
前面说的这几个问题很大方面也是因为现在Unity已经在越来越广的领域被应用,单纯从游戏上来说已经有各种多样性。像这个就是一个非常高质量的远景的渲染,像这种整个游戏本身都是一个程序性生成的世界。然后这个可能是带有光照的2D游戏。这个需要一个非常正确的表面模型的表现,像PBR还有加上另外的一些渲染来体现。这个可能是3D风格化的游戏,可能是纯像素风格的游戏。Unity在foroce大家,你现在只能用现在的这两条管线。通用的方法其实很难去做到任何情况都是最佳的方案。最后一个不得不面对的问题,对于Unity的本身的开发而言,越来越多的特性要让我们架构本身变得非常地复杂。因为一般来说,我们开发基本上是在一年的某个时候发现我需要一个特性,我就把它写上去。这个代码就一直留在里面用,可能用了好几年。当然这个过程里面会一直对这些代码进行修改,就算是这样,整个render一块的代码,很多时候我们到现在已经变得无法对这个代码做一个大改。或者有一些bug我们知道,但是我们不能去修复它。因为它会影响相互之间的兼容会被打破,会导致尴尬。基于这样的一些问题,我们Unity在去年,其实前几年已经有这样的想法了,怎么样让现在的管线做一个转型,去有一个新的思路。
这个思路几年前我们基本上开发这边越来越感觉到我们管线开发代码维护是越来越难了。那时候参照了一些别的游戏公司设置的理念,对用户来说,不透明的这一块可能是保留一个非常小的内核。然后暴露出更多的API。我们首先本身有这样一套API,另外一方面,会提供一套内置的一套定制的渲染管线。这套渲染管线本身就是暴露在c#这一端。对它做一些修改,这个是我们总的一个想法。
然后对于这样一个更改,我们其实是有几个目标。针对我们以前碰到的问题,第一个就是新的这一套管线需要非常地精简。也就是说我们希望它是非常容易去测试,然后本身会cover到应该要覆盖的领域,不要去做不应该做的一些事情。在代码的设计上,这样也能保证我们在这一端的代码能够保证它经过单元测试,来避免更多的bug。这个其实也是非常重要的一点,以用户为中心。Unity本身让游戏大众化,其实现在对管线的做法是希望让渲染本身也更能够大众化。所以像今天说的,我们会让管线本身的代码在用户的项目空间里面存在,这样大家可以像一般的debugger去做,很多问题不会像以前一样不知道问题出现在哪里。只能通过提交bug到官方去等待解决。这样会提高开发者的工作效率,并且在bug的修复上有了更直接的一个方法。还有我们说管线本身是最优的,这个最优也是基于我们现在的管线,事实上是通用的,没办法针对性地做一些东西。没有办法去做定制的管线,这个管线只会针对平台本身的性能去做一些定制。
最后一个,我们要拒绝黑科技,让大家在用管线的时候尽量都是知道我们这些API。因为是暴露出来,比较清晰。没有像以前,出了什么问题都要去猜。这个也是很重要的一点。
OK,前面稍微有一些废话。其实我真正的题目是现在大家看到的这个Scriptable Render Pipeline。我们怎么去做这么一件事情呢?因为这个概念大家不知道它是什么样的东西。从高层上去看一根渲染管线要做什么东西。首先要决定要去渲染一些东西,接下来要对它做一些排序,要设置当前在GPU里面的状态,包括shader的参数。对于这些通用的这些过程里面会把它拆分成小的一些区,去提取出公共的操作。其实关于这一块的东西,我们现在Unity里面其实是有一个方法是大概的,有点类似于这样的做法。大家用过可能都会熟悉,就是Command Buffers这个东西。这里会稍微回顾一下,Command Buffers本身就是一系列打包了的一系列的渲染指令的指令集。这个指令集在现在的Unity管线集里面会被指定在一些点,会在不同的渲染阶段可以去插入到这个地方去执行这个命令。
举个非常简单的例子,这个代码就是Command Buffers,首先创建当前屏幕大小的RT,然后把当前活跃RT blit到这张RT。然后时间点是afterskybox。Command Buffers在我们的理念会讲解,里面提供了一套demo的例子。这个图是它第一个例子,它做了一个效果,它其实在渲染不透明物体的时候,像刚才的代码一样,去给玻璃做采样。另外,跟Unity另外一个特性Grab pass,可能要稍微提一提。给大家一个建议,大家如果在场景现在的项目里,就不要去它了,尽量用Command Buffers。以前的设计有一些不太好的地方,性能比较差。这个在新管线出来以后,这个就没掉了。如果大家有做这方面,完全可以用Command Buffers把它替代掉。
OK,回到Scriptable Render Pipeline,它本身是原来功能的延伸,可能会这么理解。但是不同的是,我们会从更高level上做它的抽象。在新的管线里面不再让用户一个一个去渲染东西,而是一系列的东西。这个设计会让我们的代码跟场景的复杂度无关,另外还有一些管理的功能。因为这个东西本身还在开发中,后面我会提到大概的一个时间。里面会提供一些更多的功能,因为宗旨是以用户为中心,会尽量地让用户比较方便。
说到这样的一个设置,一个重要的点我怎么去区分两边分布的代码。这个东西一出来,你可能会奇怪,为什么要分布在C++这边,如果不考虑性能,所有东西存在C #肯定是最好的。其实我们是有一个比较明确的切分,对于性能关键的代码,会让大家先在这边运行,并且是最多线程。C#这边是high level的操作。刚才前面说到了在这边负责什么东西,稍微列一下,是包括整个渲染管线下来的一套。先去culling,然后做一些排序,设置每一个要渲染的list里面要渲染的参数还要管理GPU本身的一些状态,再去提交当前整个list的渲染指令。现在在这边,本身是基于多线程的实线,主要是底层的一个实现。这个没什么好说的。
现在这个管线出来以后,给用户手上能拿到的其实就是一个用户可以去写的Scriptable Pipeline的框架。还有一套基于不同的平台,不同的应用领域的build-in pipeline,现在还没有最终定下来,最后我们提供的是什么样的Pipeline。但是其实是尽量在今年就推出一版public让大家去使用。举一个例子,因为大家其实做游戏这一行,肯定多多少少会接触过不管你以前是不是专门做图形的,你肯定也会接触过一些文章或者是一些论文讲它怎么样在游戏里面渲染,去做优化。很多时候会考虑到一些状态的share,考虑到一些跟它相关的东西。举个例子,有个人写了这效果的管线是怎么做渲染的。其实以后Pipeline去使用的时候,你可以知道有这样一个方法,你肯定想去增加一套效果去增加到我的管线里面去。把我们的Pipeline做一个修改,添加进你的东西,这样的效果是非常快的。或者完全就自己写一个,这也非常地简单。后面这里会讲到一个例子,怎么样去实质做这么一个Scriptable Pipeline。
其实写之前有一个概念跟大家说到,现在Scriptable Render Pipeline本身,这个代码要申请一个Project level asset,里面有关于我们这个Pipeline本身的一些设置,包括幕的设置,本身去控制这个Pipeline的这些东西。这个asset本身的instance就是你Pipeline的逻辑,还有你整个的GPU状态,也是它去call C++端的core。其实在这个里面,是打破了以前的一个很尴尬的局面。就是说现在我们这个管线里面,它并不能说,比如说现在这个管线有一点问题,可能在渲染的过程当中会突然想改一些东西。你现在唯一能够做到的是,你可能在camera的callback里面改一些渲染的东西,但是这样做是很有风险的。你很有可能接下来那些渲染的结果就是错的。因为你现在管线本身它所有的状态全部都是global的,它只有一套,很容易出来的结果是错的。这个东西是现在没有办法解决的问题。对于现在这一套,这个asset本身序列化的设置一旦有任何更改,会把现在的Pipeline instance会全部改掉。这听起来很简单,但其实你现在是做不到的。简单地做一个Render Pipeline并不多,首先是asset会有一个接口。然后里面两个函数,这边这一个是不用去管的,然后Pipeline本身也主要是两个。一个是IDisposable,这个也不需要去管。然后再Render 里面去写Pipeline的代码。
这里大家看一个比较简单的一个Pipeline。首先这是一个最简单的,这个Pipeline它其实没有去渲染任何的object,是最简单的。我在里面只做了一个清楚背景的颜色,一开始是Pipeline asset,主要是刚才第二个这个internal creat Pipeline,这是实际去写管线的代码。这里面有一个public的写的变量,实际在Scriptable上面,可以看到并没有什么东西。然后用到的是Command Buffers,然后给到这个颜色,最后去提交command。大家可以看一下,不太好操作,我把屏幕弄成这样。刚才其实我给它生成了一个,我们在setting里面把它拖进去,其实是对asset本身去更改颜色,对前一个Pipeline去生成新的Pipeline。这个方式其实我现在是通过在editor里面加上去,其实它出来的时候,你这个项目里面是可以有很多Pipeline asset,不是像我这样直接拖。因为这个功能还在开发的当中,现在这里就不好演示出这个东西。刚才其实我们在Command Buffer之后,还提交了一个Render的渲染结构,我们主要靠这个结构去构建我们渲染的指令,然后把它加到了content里面去,最后做一个submit。然后最后要做一个culliig,里面去指定一些culling的方式,是不是利用cullingParameter去提供一系列的参数,现在还没有定,以后还需要一些新的方式。方式就靠cullResults Cull然后再加上这个参数去得到一个context,可以根据shader去做一个过滤或者去做渲染的东西。
具体的渲染,我们会需要去用到一个DrawRenderSettings的东西,我们去做一个选择,包括这个sorting或者根据shaderpassName,去做一个Filter,最后去调的Draw。我们看下面一个,也是简单地去渲染一些不透明的物体。这个东西前面基本说是一样的,不一样的是我们Pipeline是这样的,基本上以后整个架构差不多,可能缺少了一个灯光的一些配置还有shadow的一些处理。如果你有自己要做的一些,也是在这里面加。我们有一个比较方便的,直接从camera迭代。这里主要要关注的是我们前面去列了一个setting,然后sorting里面是从远到近的排序方式,去做一个过滤。其实一直是对这个物体过滤下来去算。最下面我们再来提交上去。其实就是这么一个简单的操作。就是这样的一个,场景里面本来这里其实还有几个透明的物体。刚才还是还有一个sorting的参数,这个也是一系列的设计,有很多这样的选择。刚才选了commonopaque,已经把多个位掩码并起来了,基本上能够做到了。如果有数据需要,单纯去划。然后一般来说Transparent就是下面这里。
最后再去把Transparent的东西给它加上去。这个代码其实跟刚才基本一样,我们还是用了前面的setting。在我过滤的选择里面是用了Transparent,然后再去画。整一套都是一样的方法。我现在把它放上去,应该就会看到半透明的物体了。前面有两个半透明的物体。
简单的一个示范基本上是这样的一个方式能够去code我们的Pipeline。其实执行的时候,我们的command是怎么样标准的一个方式,它本身是一个延迟提交的方式。我把所有的command,包括Command Buffer,你的Draw,你的其他功能都是按照顺序排下来。在submit提交之后,每一个command作为一个job,实际上尽量地以一个command作为一个job去做。这里给大家看一个对比,就是我们内部现在是有一个实验做好了一个deferred的一条管线,里面其实就放了其中一条渲染管线,这个是用大家现在可以用到的Deferred里面去做的。可以看到时间线上有很多洞。这些东西其实主要也是因为我们现在的这条管线本身是比较通用的,基本上都是经过很长的时间去决定这一套命令要怎么去划。再丢到job里面去。它准备和执行的时间当中就有很多的gap。这是我们内部做的新的Deferred的Pipeline。它比较频繁一点。这样的话,很明显的一点就是我在大线程这边,我做signal的消耗会大大地减少,CPU的实际上的利用率也会提高。
这是我刚才说的东西,就是我们现有的Render Pipeline本身去准备的时间比较长了,它不是很好地能够利用好CPU。我们未来做的build in的Pipeline会让它有更好的分布,集中地去利用CPU的时间。关于Pipeline本身,其实大家是可以试用到的,就在5.6这个版本,大家在谷歌,其实百度直接搜Unity也可以搜。5.6有两个测试的版本,是可以用到其中一个特性,有几个demo在里面。大家感兴趣的话可以去尝试一下。现在沿用的API肯定会有一些变化,也会增加。并不保证以后大家拿到的可能是一样的。从时间上来说,这个特性实际上从去年就开始做了。我们预计是在今年能够有public的版本。我估计今年比较尾部的时候可能会有2017的某一个版本出来是public,有Render Pipeline给大家去使用。
今天给大家的介绍就到这里,谢谢大家!