线程 VR全景图片 自承式光缆 微信商家收款 powershell web static xampp sql server 视频教程 jmeter性能测试视频 jquery的点击事件 pytorch安装教程 jquery拼接字符串 oracle删除字段sql mysql小数用什么类型 java多行注释 ubuntu查看python版本 js控制台打印 destoon python类 python如何调用函数 python中import用法 java查看版本 java配置 javafloat java数组输出 java如何使用 java调用接口 java八种基本数据类型 java集合框架图 java文件复制 java环境下载 两表关联查询 java游戏编程 网络克隆 big5 催眠魔蛙 vs2003 视频解析软件 司司网吧
当前位置: 首页 > 学习教程  > 编程语言

再谈渲染引擎架构

2020/12/28 18:36:58 文章标签:

这几天写了一个DX12描述符管理系统,写着写着就思考到渲染层的架构上了。。。之前也总结过渲染层的架构,现在回头看发现思路很混乱。今天思路狂飙,对渲染层架构有了新的认识,因此想再写一篇文章,把这些思路记录下来。 …

这几天写了一个DX12描述符管理系统,写着写着就思考到渲染层的架构上了。。。之前也总结过渲染层的架构,现在回头看发现思路很混乱。今天思路狂飙,对渲染层架构有了新的认识,因此想再写一篇文章,把这些思路记录下来。

我之前写过很多遍game player的架构,对于什么是好的架构,我的理解是,一个易于多人维护及扩展的代码结构,就是好的架构。对于一个好的框架,感性的认识就是,当你添加一个新功能时,你可以很容易的找到我应该把这个功能添加在哪里,我会专注功能的实现,而不必担心会不会影响其他模块的功能,或被其他模块影响。也就是设计中讲的低耦合,高内聚,以及模块的原子性。

游戏客户端的框架和引擎架构相比,其实设计思想是一致的,只不过客户端的需求是各种游戏逻辑,而渲染引擎的需求是各种图形效果。从这里也可以看出客户端程序和引擎程序的区别了,客户端程序的根基是对业务逻辑掌握一套成熟的解决办法,而引擎程序是掌握各种渲染技术从而总结出一套高效的渲染管线。

这篇文章我会自底向上的去思考一个渲染引擎应该怎么去设计。让我们先思考一个场景是怎么渲染出来的?

简单的概括其实场景的渲染是由很多个shader以及shader的输入参数和输出参数组成的。我们可以把shader理解为一个函数,这个函数由可编程管线组成,也就是我们写的cs,vs,hs,gs,ps等等。shader的输入参数就是各种贴图以及Buffer,输出参数就是backbuffer,rendertarget或者是RWBuffer等等。简单理解渲染过程就是一遍一遍的执行这些shader函数。但是这些shdaer函数不是独立个体,它们之间是有依赖关系的,比如shadow map,我们需要先渲染一张基于方向光的深度图,再比如延迟渲染我们需要先渲染G-Buffer然后再去做光照渲染。换一种说法就是一个shader的输出参数可能是另一个shader的输入参数。因此我们需要对这些函数进行排序,排序的规则需要满足两点:

1.满足渲染的功能需求

2.满足渲染的性能需求

这里的排序就复杂了,要想知道如何排序,首先就要知道有哪些函数,每个函数的输入和输出是什么,也就是我们需要了解项目需要的每一个渲染效果,比如光影效果用什么实现,使用哪些后处理,使用什么抗锯齿效果,是否开启HDR ,是前向渲染还是延迟渲染巴拉巴拉一堆。让我们先忘记这些渲染效果,专注于底层的实现。

首先我们要确定一个shader函数是什么?对于DX12来说shader就是一个ID3DBlob,一个被编译好的二进制内存块。我们能说一个ID3DBlob就是一个函数么,答案是否定的。举个例子,比如半透物体和非半透物体可能使用了同一个shader但是它们的blend混合操作是不同的,因此它们应该算是两个函数(两个pass或者两个批次)。在DX12之前对于函数这个概念图形API是没有准确定义的,在DX12里就是ID3D12PipelineState,ID3D12PipelineState可以代表一个渲染函数,它包括了渲染所需的所有的渲染状态。DX12之前的API我们可以自己封装一个类似于ID3D12PipelineState的东西。这里就引入了一个需求,跨平台,跨不同版本的渲染API,即Render Hardware Interface (RHI)层。RHI层简单理解就是对函数以及函数的输入输出进行了一层封装,这里的函数指的是ID3D12PipelineState,输入输出指的是各种view以及resource。跨平台需要处理很多琐碎的事情,比如内存分配,多线程渲染以及各种图形API的实现等等。基本上RHI层与图形学关系不大,更多的是需要扎实的C++及各种图形API的掌握。让我们忘掉跨平台,将焦点集中在DX12上。

渲染函数(ID3D12PipelineState)

这里的渲染函数是指我们把渲染过程理解成一个函数执行的过程,这个函数的定义就是ID3D12PipelineState。让我们看一下一个渲染函数的定义包括哪些部分:

  1. 渲染函数的实体部分-shader,shader是函数的实体部分,我们可以选择使用哪些shader,比如vs,ps,cs等等。shader的编译是一个费时的过程,通常我们会将shader预先编译成二进制文件,然后保存起来,运行时直接加载二进制文件。unity shader cache就是干这个事的,打包很慢有很大原因就是在编译shader。随着渲染效果的膨胀,我们会遇到shader爆炸的问题,一般有两种处理方式,一种是使用预编译宏,我们会在一个shader中使用宏来区分各种功能,也就是unity中的shader变量,根据shader变量,一个shader可以有多个变种,这么做的好处是我们不必写很多重复的shader。但是每一个变种都是一个独立的shader,它不能合批次。另一种处理方式是在shader中使用条件判断,这个方案的好处是可以合并批次,但是过多的条件分支判断可能会影响shader的性能。这里就不扩展了,记得这里有个大坑。还有一个坑就是如何将各种shader语言统一编译。shader是渲染函数的实体部分,相当于函数的实现。它是一种资源,引擎会在预处理阶段把它编译成各种二进制文件,运行时加载这些二进制文件,将其设置到ID3D12PipelineState中。
  2. 渲染函数的全局变量-Render state,渲染状态对我们来说是黑盒操作,我们只需要按照需求进行设置,比如光栅化状态,混合状态,深度/模板缓存状态等等。这些全局变量在哪里设置呢?我们总不能把它写死到代码中吧,在unity的shader文件中我们可以看到这些渲染状态需要按照它的语法写到shader文件中,Dirext中的FX格式文件也是做这件事情的。这部分其实不难,引擎根据自己的设计将shader文件中的渲染状态以某种格式保存到中间文件即可,比如按照xml,json等格式存储这些状态,运行时按照格式读取设置即可。
  3. 渲染函数的形参-ID3D12RootSignature及D3D12_INPUT_LAYOUT_DESC,在DX12中根参数相当于渲染函数的形参,它告诉显卡这个函数需要绑定哪些资源。根参数可以通过代码设置,也可以写到shader文件中。如果通过代码设置,那么引擎就要预处理生成根参数的C++代码。如果写到shader文件中,那么只需要直接编译到shader的内存块中即可,但是这样可能会导致相同的根参数被多次包含,我们可以通过工具单独编译根参数的二进制文件,然后关联到shader中。根参数的声明关系到性能问题,比如改变频率高的资源可以使用根常量或者根描述符来定义,但是根参数的尺寸有限制,大量使用根常量和根描述符会很快耗尽根参数的容量,因此我们需要具体问题具体分期。引擎会在预编译阶段对所有shader进行分析,然后决策出最合理的根参数方案。顶点格式的布局也是渲染函数的形参,我们需要告诉显卡,顶点包含哪些属性,比如法线,顶点色等等。有的时候我们会不使用顶点缓存,而直接使用通用缓存来存储顶点属性,这样可以合并批次。另外如果顶点是cs动态生成的,是否还需要将顶点信息拷贝到默认堆中,这个我不确定,如果使用通用缓存,拷贝是不需要的。我猜测可能不需要拷贝,拷贝到默认堆中,只是为了增加显卡读取顶点的速度。
  4. 渲染函数的实参-贴图资源,mesh资源,shader需要的各种Buffer。渲染函数的实参基本上就是各种资源,比如贴图资源,模型资源,以及shader需要的各种Buffer。对于每一个实参都需要绑定一个gpu描述符(根常量,根描述符除外)。

Pass排序

pass之间是存在依赖关系的,这种依赖关系主要表现在共享资源上。我们可以使用一个有向无环图的拓扑排序来表示这种依赖关系。图的节点就是pass和资源,边代表读资源或写资源。但是有些Pass即使没有资源的依赖,还是会有先后顺序的依赖。比如先渲染非半透明的物体,再渲染半透明的物体。因此每一个节点还需要分配一个优先级,当节点的入度相同的时候根据优先级进行排序。这个算法在多写的时候会炸掉,比如PassA写资源R,然后PassB读,然后PassC再写R,PassD再读,自动排序怎么都不会得出A-R-B-R-C-R-D。这引入了一个问题Pass排序到底是需要自动排序,还是半自动排序,还是人工排序?这个问题暂时我无法回答,只有添加了很多功能后才能给出比较靠谱的答案,我目前的想法是能自动最好是自动,除非发现为了自动引入更多的问题。

资源管理

如果按照生命周期来分类资源,资源大概可以分为以下几类:

1.全局资源,类似于全局变量,也就是程序启动到结束这段时间,该资源一直存活,比如backbuffer。

2.Pass资源,类似于函数的静态变量,也就是FrameGraph中的边,这类资源和pass的生命周期一致,如果Pass存在,那么这个资源也存在。这里有一个问题,如果游戏一帧消耗的显存大于硬件能够提供的最大显存,那么有些pass资源可能就必须每帧创建和销毁了,但是这种情况我们应该避免。所以Pass资源的生命周期就等同于Pass。

3.局部资源,类似于函数的局部变量,这些资源会在pass调用前创建,pass调用后销毁。

如果Pass资源的生命周期和Pass一致,那么FrameGraph其实就不需要跟踪资源的生命周期了,只需要修改资源以及对资源进行状态转换(读写)。

DX12可以让我们自己来控制显存的使用,我们可以自己写一个显存管理,类似内存池,这样我们就可以降低显存的申请和释放时间,还可以根据项目的实际情况,精细化控制显存。

当一个资源被删除,它不会立即被释放。因为有可能下一帧还会继续使用。这里维护两个队列,一个是正在使用的资源队列,一个是待删除队列,待删除队列中的资源如果在删除之前又被引用,那么就会放入正在使用的资源队列中。当显存不足需要释放一些空间时,先释放那些长时间不用的显存。这里只是一种策略,具体怎么样还是要看项目。

实现资源管理,需要先实实现显存管理,内存管理,以及资源描述符管理这三个底层模块。

多线程渲染

首先多线程这个概念应该是针对cpu来说的,为了利用多核我们需要在cpu端使用多线程,但是渲染我觉得没必要使用多线程。

1.多核利用在逻辑层和物理层已经可以充分使用了,或者说ECS架构的job负责多线程的利用。

2.渲染其实是串行的,无论多少个线程去插入命令,gpu也会按照命令的顺序,一个一个执行(这里先不说异步shader)。

3.为了防止cpu等待gpu,通常我们会使用帧资源,使用多线程会增加这块实现的复杂性。

4.除非渲染逻辑在cpu端出现瓶颈,我们可以考虑使用多线程,但是渲染逻辑属于渲染么?

异步渲染

DX12引入了三个引擎,复制,计算,3D引擎,这三个引擎分别使用不同的队列,也就是说它们三个可以异步调用。以前的显卡每一个步骤都是串行工作的,而且不能够中断。这样就会造成一个严重的问题,显卡中的一个环节成为瓶颈,那么整个渲染过程都会受到影响。比如说PS阶段成为瓶颈,如果能够解放CS,让CS并行的去处理一些其它逻辑,这样就可以提高显卡的利用率。再具体一点说,比如后处理,以前我们必须要等待后处理结束后才能开始下一帧的渲染,但是后处理其实只使用了CS的硬件,其他功能的硬件这时候就会空闲比如顶点分配,这是一种浪费。现在,当一帧渲染结束后,我们可以立即渲染下一帧,然后异步调用CS来运行这一帧的后处理效果。这样就可以重复利用显卡资源。为了利用显卡的新特性,DX12引入了三个引擎,这样我们就可以更新粒度的去调用CPU。

 


本文链接: http://www.dtmao.cc/news_show_550046.shtml

附件下载

相关教程

    暂无相关的数据...

共有条评论 网友评论

验证码: 看不清楚?