一个图形框架主要分为两部分,描述画什么以及怎么画,前者往往抽象为场景,而后者就是我们要讨论的主题。
我们目前见到的绝大多数渲染框架,都主要集中在资源的角度来进行抽象,比如geometry, material,这些抽象为库的使用者减少和抹去了管理创建资源,以及渲染的实现的负担。但是这些库并没有在如何组织渲染流程上提供太多的抽象。在面对复杂的后处理,多个pass间的依赖关系时,这些依赖一般都是手工维护,rendertarget的复用也是需要手工维护,当这些依赖关系需要由多项配置决定时,整个事情就非常难做了,以至于变成工程上不可能的事情。
去年我在artgl里通过一些业余实践实现了一套渲染流程上的框架,称之为rendergraph: 用户通过一套声明式的api构建pass/target依赖的有向无环图,框架完成依赖解析,fbo重用,等工作。最后可以说解决的非常干净漂亮。这套体系经过定制修改目前已经集成于实际工程项目,一举解决了诸多维护性上的问题。
现在回想整个设计过程和解决方案,给我带来几个关键的认识:
尽可能从data flow的角度思考解决问题,而不是传统的control flow。
如果没有graph,所有的数据依赖处理,都是分散在各个pass的类里,由大量的配合外部配置项的if else所决定,通过控制流来决定一步一步该做什么,控制流的代码是写死的,写死在各个类的方法里。
在graph下,核心其实变成了数据流,api直接描述数据间的依赖关系,框架来组织什么数据在什么时候需要给谁。而原本的控制流即实际的依赖关系,或者说流程,是通过运行时计算出的,而不是靠控制流写死在代码里。
流程即是数据,数据即是流程
这个基本上就是代码即是数据,数据即是代码的一个小的体现。
前面说到 原本的控制流即实际的依赖关系,或者说流程,是通过运行时计算出的。这个计算结果简单说比如就是拓扑排序的结果,这个结果就包含了这个流程完整的内容,不多不少。就是一个array,就是数据。这个数据,这个实际的流程,我们也是直接cache的。对于任意一个能影响图结构的配置项,直接计算出流程或者从cache得到。将代码/算法/流程直接变成数据。
理论上如果我们知道会用到的配置组合,那么从这个数据直接生成流程的代码也是可行的,不过没什么意义。只是计算到底是编译期还是运行期的问题。
没有graph,执行流程是写死在代码里超集,通过配置和控制流代码来决定哪些部分真的要执行,直接造成了实现上的灾难
graph这个设计是通用做法,正常做法
我后来了解到很多桌面端的引擎,都采取了graph结构。我觉得他们不得不用,为什么,因为比如vulkan,要是不用图我觉得代码就没法写了。vulkan 是要你自己完全控制各个pass间的依赖的,这包括但不仅限于你要在target和pass依赖的点,各种数据的依赖点自己加semaphore,内存屏障,各种同步源语,不然你根本不知道什么时候gpu真正执行到哪个pass,不知道什么时候可以拿到正确的数据。
事实上,在有的游戏引擎中,整个流程,不仅是渲染部分也使用了graph的架构,rendergraph,解决的是渲染数据流的问题,而相同的思想用来解决gameplay部分的数据依赖完全是没有问题的,整个游戏引擎,整个世界更新逻辑就是一个超大的graph,渲染只是其中一部分。
基于graph,我们还能做更多优化
为了解决依赖问题,拓扑排序就够了。但是有了graph我们能做更多事情。
就 fbo 重用来说,目前很简单就是按需重用,没有主动优化。什么意思呢:一个graph,满足拓扑顺序的结果有很多,如果真的要好的fbo重用,我们应该找到这个解的集合中fbo并行量最小的一个。所以fbo重用就变成了一个图优化的问题。
又比如,现在对于每个effect,假设需要depth,那么我自己做图构建的时候,还是要手工的重用depth对应pass的node,比如从外层传入这个node。ok,事情变复杂了以后我怎么保证我能靠人肉充分复用某些pass的计算呢。从理想的角度,我希望我不要考虑这些事情,让框架自动找到可复用的pass计算。这种重用本质上是流程的重用,而本质上是另一个图优化的问题,如何识别和复用图中的子结构的问题。
那么state切换方面的优化是不是图的问题呢,我想过其实也使得,不过这个是scenegraph,就不展开了。
Rendering engines are becoming compilers
比如你手工的提取出可以复用的depth pass计算,和本质上你在一个函数里手工提出重复计算并无不同。而事实上编译器的确是能优化重复计算的,优化重复计算也是把代码转化成计算图解决图优化的问题。
如果说整个3d框架,渲染引擎,流程上本质就是生成,优化,执行一个数据流图,那和一个compiler后端有什么区别呢?
我现在搞了套很漂亮的api来做图构建,本质上也太低级,那似乎搞一套dsl也是没啥问题的,正好对应compiler前端,设计一套更高层次的流程描述出来?
其实如果将renderengine视为compiler的,那不是那种静态的AOT的,而是JIT的,意思是对于执行结果,其实是有feedback的,既然有feedback,那就可以做优化的。我认为render engine as complier最终极的方向,就是compiler能根据实际的场景数据执行情况,分析出场景特点,分析出绘制瓶颈,动态实现各种优化行为。比如配合其他子系统比如场景,动态优化调整输入。配合降级体系,自动挑选最优的效果降级方案,这种降级不是关掉某个pass那么简单,而是能实时获得底层gpu情况,精确的调整某些sample count,size这类。
当然这个脑洞目前很大,工程上做出来也很有挑战,我相信一些成熟引擎已经开始这个方向的转变了,很有意思。