在游戏引擎中最能表现 并发计划 头脑 的应用就非渲染线程莫属了 。把渲染逻辑从游戏线程中分离出来,单独放入一个工作线程里处理 惩罚 凸显了并发实行 的上风 。本来 的渲染逻辑都是在游戏逻辑后串行处理 惩罚 的 ,早期的游戏引擎也是这么计划 的,由于 它的布局 相对比力 简单 ,轻易 实现。关键是在上古时期,cpu还只有一个核 ,即便用了渲染线程也属于脱裤子发屁完全没有须要 。但是到了cpu的双核期间 ,这种环境 发生了明显 的变革 ,人们发现cpu单核的工作频率已经碰到 了瓶颈 ,再也不大概 进步 了,否则cpu就会直接烧掉 。从前 那种单靠cpu升级就能免费得到 的软件性能提拔 的期间 开始一去不复返,游戏行业也面对 着同样的题目 和挑衅 。
epic事先做出了改变 ,它应该是当时 做贸易 引擎的公司中第一个公开支持多线程渲染的厂商。当时 的贸易 引擎像quake,source等还在单核模式下苦苦挣扎 。有了多线程渲染的特性支持,cpu端处理 惩罚 渲染下令 所占用的帧时间仿佛一下子消散 不见了。这着实 就是我之前谈到过的并发盘算 的长处 ,渲染的任务 被时空折叠了,假如 你对并发尚有 什么疑问,发起 出门右转看一下我写的另一篇主题文章。由于渲染线程与主线程的任务 是瓜代 实行 的 ,也就是主线程负责游戏天下 的模仿 ,随后根据模仿 的结果 天生 渲染的指令,这些渲问鼎 令并不是在主线程里实行 的,而是被投递到了一个独立的工作线程里 ,这个工作线程维护着一个Ring Buffer,它会把Ring Buffer中由主线程提交的渲染下令 按照次序 逐一的提取出来并依次实行 。因此我们说这种实行 方式是并发的,渲染线程里实行 的渲染下令 着实 是上一帧游戏线程模仿 的结果 ,而在渲染线程处理 惩罚 渲染下令 的这段时间里,游戏线程又在模仿 下一帧的内容,云云 循环往复下去。这有点酷似CPU的指令多级流水线 ,只不外 如今 仅有两级而已。那为什么要利用 Ring Buffer呢?我们知道两个线程之间要举行 数据的交互,必须要确保数据的转达 是线程安全的,不能出现这个线程还没有写完 ,另一个线程就开始读取的环境 ,否则数据的完备 性就会缺失 。Ring Buffer可以或许 方便且低本钱 的办理 这类题目 ,它可以在不参加 互斥锁的环境 下让数据单向的活动 ,由于 我们知道在多线程渲染的框架里根本 都是游戏线程去天生 下令 ,而渲染线程只是一个斲丧 者,那么同步操纵 可以不必那么的复杂。但是要留意 一点,Ring Buffer即便利用 了Lockfree的计划 原则来构建 ,依然会有额外的访问开销,相对于非线程安全的队列来说还是 比力 大的。以是 在Unreal中,我们会发现它并不是将全部 原生的渲染下令 依次的压入Ring Buffer中 ,而是把渲染场景的操纵 当成一个同一 的渲染下令 去实行 。比方 渲染场景所必要 的渲染物件的Culling,渲染下令 的排序,渲染参数的添补 ,以及渲染下令 的实行 等步调 都会被放入同一个渲染下令 里。除此之外,其他的渲染下令 的计划 就单纯多了,不会像场景渲染下令 那样把很多 的操纵 复合在一起完成 ,比方 纹理数据的添补 ,顶点 和索引数据的写入等。
我们知道CryEngine也是按照这种思绪 计划 的,不外 跟Unreal有点渺小 的差别 ,CryEngine的Frustum Culling和Occlusion Culling是在主线程里处理 惩罚 的。但是事变 总有例外 ,像早期的Unity就不按套路出牌 。它仍旧 是把抽象的渲染下令 依次的由主线程压入到渲染线程的Ring Buffer中,然后再由渲染线程次序 实行 它们,这种做法着实 开销是比力 大的 ,由于 我说过Ring Buffer的添补 并非免费,而抽象的渲染下令 操纵 粒度相对较小,这就造成每帧里引擎都会频仍 的写入和读取Ring Buffer中的内容。Unity也意识到了这个题目 ,在厥后 的版本做了相应的改进,引入了所谓的Graphics Jobs的概念。引擎会把渲染下令 的构造工作放入到多少 个线程里去并行处理 惩罚 ,并为每一个线程创建一个独立的下令 队列 ,它们负责临时 存放那些新天生 的渲染下令 ,由于Buffer是相互 独立的,以是 也就没有了Thread Contention ,末了 再由主线程同一 把这些独立的下令 队列归并 到渲染线程里去实行 。一旦在Unity设置中开启了Graphics Jobs的特性,渲染下令 的处理 惩罚 性能就会得到显着 的提拔 。
对于Unity家的Graphics Jobs的功能,我还想做一些增补 阐明 。它并不美满 是 为了镌汰 访问Ring Buffer的次数 ,最重要 的目标 还是 和Unreal一样,盼望 办理 渲问鼎 令天生 迟钝 的题目 。为什么渲问鼎 令的构建会比力 费时呢?这里会有多种的缘故起因 存在,起首 最显着 的固然 是shader的绑定了,你大概 会问shader的绑定不就是设置一个shader对象的指针到runtime的装备 上下文里吗?这个操纵 能有啥斲丧 呀 ,但你要知道当代 引擎的材质体系 都很复杂,它可以支持各种各样差别 的渲染结果 ,而且还答应 外部做自由的扩展。以是 差别 的渲染对象大概 绑定了不尽雷同 的材质实例 ,这些材质实例之以是 结果 变革 多端,也是由于 材质实例里拥有差别 的shader变体。为了选择符合 shader变体,unity必要 在渲染线程里为每一个物体做搜刮 ,这些搜刮 还要思量 于差别 pass的keywords组合,因此查找shader变体的操纵 肯定会斲丧 肯定 的cpu时间 。
像早期的CryEngine,它会把险些 全部 的shader变体都放到同一个数据库里举行 管理。以是 每次为了找到等待 的shader变体 ,都必要 在运行时拼集 出一个超大的hash值,这个hash值指示了当前这个drawcall关联的材质想要开启的预编译宏设定。但即便是同一个材质差别 的pass也大概 选择差别 的shader变体,比方 depth pass和g-buffer pass就不会利用 同一个shader变体 。别的 为了支持前向光照 ,固然 最多只有4盏光源会影响同一个物体,可还是 会有很多 的分支组合,包罗 选择何种光照模子 ,是否要投射阴影等。以是 这个hash值就变得特别 的复杂 ,显然它拖慢了shader绑定的速率 ,你要知道一个drawcall可不但 是绑定一个shader。
除了shader变体的检索,渲染状态的设置同样会影响性能。该功能跟shader变体一样也要组合出一个hash值 ,根据这个hash值定位到差别 的渲染状态对象,假如 发现不存在就创建出一个新的,因此差别 的drawcall可以或许 共享雷同 的渲染状态对象 ,有效 的规避了部分 渲染状态的频仍 切换 。
unity尚有 一个比力 坑爹的题目 ,就是纹理资源的绑定也会有开销。由于 引擎内部为了纹理对象的访问安全,逼迫 纹理的操纵 都要通过纹理句柄来做 ,函数间转达 的也是这个句柄。本质上说这个句柄就是一个弱对象指针,当它必要 访问具体 的纹理指针时会根据句柄去搜刮 纹理对象库,如许 就不消 担心访问到野指针了 ,但搜刮 开销却不容小觑 。
按理来说对于纯静态的模子 ,也就是那些材质属性不会随着时间发生变革 ,且天下 空间位置也没有改变的物体,它们的constant buffer应该也是静态稳固 的 ,可以预先构建好,不必要 每帧都举行 更新。但是很多 引擎包罗 unity,还是 选择每帧对其举行 更新 ,这会让constant buffer里的数据被反复添补 。固然 看起来有点暴力,但这种方案也有长处 ,就是constant buffer的实例不会太多太分散 ,buffer绑定所造成的状态切换也因此镌汰 。
总体来说unreal的计划 轻微 公道 一些,它将constant buffer按照每帧的利用 频率分层级举行 管理,每帧都会变革 的shader全局参数放在一个专门的buffer里 ,比方 帧时间,摄像机位置,随机种子等。那些随着pass变革 的数据则是放到另一个buffer里 ,比方 视距阵,投影矩阵,由于 gbuffer和shadowmap,大概 reflectionmap ,这些pass它们的摄像机位置都不一样。而对于静态的材质参数数据只必要 构建一次就可以了,它会放入一个静态的constant buffer里,不必要 每帧举行 更新 ,那些每帧会发生变革 的数据则被存放在动态的buffer中 。由于引入了新的draw mesh pipeline,unreal会为primitive的属性创建 一个buffer同一 管理起来。primitive的相干 属性包罗 天下 空间的变更 矩阵,困绕 盒信息等 ,对于静态的primitive这些数据就不会每帧做更新了,而且还能做动态的drawcall batching,真是一石二鸟 。但是primitive的场景数据并非按照struct of array的模式做的内存布局 ,以是 在读取的性能上会有一些丧失 ,由于 cache轻易 miss 。
我想到一个汗青 故事,当年我们在移植d3d12的时间 就在dynamic constant buffer上栽了一个跟头。由于 Map的功能必要 应用程序本身 去实现 ,我们必须事先在upload heap中分配出一大块内存,然后每帧要用一个fence去追踪这个buffer的利用 环境 ,判定 这个buffer何时可以重用。否则假如 每次添补 buffer时都分配一个新的buffer,内存肯定吃不消。由于 gpu的任务 对于cpu都是异步的 ,以是 我们可以把fence作为一个信号指标,当轮询到这个fence处于已关照 状态时,就阐明 fence之前的全部 渲染下令 已经全部实行 完毕了 。那么与此同时它上面绑定的资源也就不会再被占用 ,应用程序又可以重新添补 这个buffer,并绑定到新的drawcall上。d3d11的map函数的内部实现根本 也是按照这个方式来计划 的,所谓动态资源的renaming。
讲了半天我还没有提及谁人 题目 ,大多数环境 下在d3d11里添补 constant buffer时我们会用到map_write,map_write_discard大概 是map_write_no_overwrite这些标记 ,因此它要求访问这个buffer时只管 是只写的 ,最好不要有读操纵 ,否则会有性能上的处罚 。之前我们对此不是那么器重 ,以是 在写代码时不会专门查抄 这类题目 。由于很多 的写操纵 是很潜伏 的 ,假如 只是粗看代码,一时半会很难反应过来,但是细致 分析过反汇编的代码后才意识到确实是先从buffer读出了之前的结果 ,颠末 盘算 后再写归去 的 ,却误以为只是一个纯粹的写操纵 。在用d3d11开辟 时我没有感觉这些读写过程会对性能有多大的伤害,但是在d3d12里却成为了一个明显 的题目 ,居然变成 了性能的热门 ,当时 看到vtune的数据时差点没把下巴给吓掉 。此中 的罪魁罪魁 就是d3d12的upload heap中的内存块利用 了page_writecombine的掩护 模式,它但是 反cache的,不会主动 的维持cache同等 性 ,以是 数据的读取会相称 慢,至少低一个数量 级以上。
关于shader绑定的服从 题目 有其他的办理 思绪 。unreal和cryengine不谋而合,由于必要 支持d3d12的接口 ,它们都抽象了一个雷同 于pso的对象布局 ,以是 即便是d3d11也有这种渲染状态聚合物的对应实现,只不外 末了 往装备 上下文设置时才转换成实际 分离的接口调用 。正由于 采取 了这种计划 方法 ,引擎就可以事先把全部 关联的shader变体都cache到这个对象里缓存起来,而不必要 每次应用时再对shader变体举行 搜刮 。固然 对象布局 也包罗 了其他的渲染状态,比方 光栅化状态,深度模板状态和肴杂 状态等。只不外 这种方式浪费了一些内存 ,属于用空间调换 了时间,但何乐而不为呢?!
由于硬件的遮挡测试必要 回读GPU的数据,以是 在Culling阶段Unity访问这些数据并不是那么的方便 。由于 它和CryEngine一样 ,Culling的处理 惩罚 都是在主线程里完成的,只不外 真正的盘算 会由主线程Dispatch到任务 线程里完成,但本质上这些任务 还是 由主线程控制发起的 ,主线程必要 确切的知道回读的GPU数据是否已经到达CPU端,假如 发现没有停当 ,那么Culling任务 就不能启动。为了克制 每帧中差别 的时间点在渲染线程里多次轮询Readback Buffer是否已经Ready ,显着 这种做法根本 上是不可取的。而且由于Unity的Culling逻辑和渲染线程是分离的,它不能直接访问渲染线程里的装备 上下文,以是 我们会在渲染线程每帧Present的背面 逼迫 做一次同步的Map ,使得Readback Buffer的Copy下令 (把数据从Default Heap传输到Readback Heap中)在这个时候 点必须实行 完毕,否则不停 做空等待 。当Map乐成 返回结果 ,就把这些数据拷贝到一个主线程可以或许 读取的Buffer中,等下一帧跟渲染线程与主线程同步时 ,主线程的逻辑就可以安全的从这个Buffer中读到GPU的数据了 。这个方法看起来有点暴力,但还是 行之有效 的。
这里轻微 做个细节上的增补 ,由于 Readback堆的内存利用 了Page_WriteBack这个模式 ,以是 它可以让Readback的数据区不管做读取还是 写入的操纵 都不会影响性能。之前我表明 过Upload堆的内存为什么不能去读取它的数据,Readback堆的内存特性跟它恰好 相反,它是Cache友爱 的 。但值得留意 的是 ,对于可以或许 包管 Cache同等 性的UMA(比方 某些集成显卡),Readback堆和Upload堆的Page属性都选择了writeback,换句话说就是upload堆也可以自由的读取数据 ,而没有性能上的处罚 。
为了克制 帧率的频仍 抖动,一样平常 GPU会缓冲最多三帧的渲染下令 用来做平滑。也就是当调用Present竣事 时,GPU并没有立即 完成当前帧的渲染工作 ,除非碰到 了V-Sync变乱 。这时假如 像我之前所说的那样,在Present函数的背面 直接对Readback Buffer做一次同步的Map,那么Driver就会立刻 将Copy操纵 之前的全部 的渲染下令 (大概 这时下令 还没有压入到硬件的Queue中,乃至 也没有完成Translate) ,也包罗 Copy本身 全部Flush给GPU,然后原地等待 GPU完成这些渲问鼎 令。可以想象这种蛮横 的同步操纵 冲破 了Driver的并发性,让GPU缓冲下令 和负载均衡 的好梦 刹时 幻灭 。但是雷同 遮挡查询结果 这种对时间周期比力 敏感的数据 ,假如 不及时 回读并应用,那么就会带来很大的副作用,引发高概率的False Positive和False Negative的题目 。以是 延缓Readback Buffer的读取并不实际 ,只能寄盼望 于拷贝点和回读点只管 离得远一点,由于 如许 就有富足 的时间留给数据的盘算 和拷贝,可以节流 Map时做空等待 的周期 ,但理论上再远也不会高出 一帧的时间。
为了继承 低落 每个渲染帧的表现 隔断 ,Unreal还会把渲染线程的渲染下令 发送到一个RHI Thread中。这个线程专门把抽象的渲染下令 翻译成图形API的具体 函数调用,从宏观上看相称 于做了一个三级流水线 ,分别处理 惩罚 逻辑模仿 ,渲染下令 天生 和渲染下令 翻译这三个独立的任务 。如同 我在另一篇文章里分析过的缘故起因 ,这种把帧处理 惩罚 流程分割成多少 个子步调 的方法着实 并没有镌汰 耽误 (我所谓的耽误 是指从玩家输入到对应的画面输出的时间隔断 ),而仅仅是提拔 了帧率 ,相称 于我的输入要比及 三帧以后才华 看到结果 ,反馈的时间长度依然没有发生变革 ,但是每帧画面的表现 隔断 确实变为了原来的三分之一左右。
曾记得我在做d3d12移植时也碰到 过雷同 的题目 ,当时 很奇怪 为什么用d3d12的api更换 了d3d11的api后性能没有发生太大的变革 。按理说d3d12的接口是很高效的,由于 它的实现相对比力 轻量,没有那么多繁琐的校验逻辑(由于d3d12失去了强大 的非常 掩护 ,以是 稍有不慎就很轻易 导致程序瓦解 ),而且我们还做了大量定制的优化。但颠末 多次压力测试,有个别时间 居然会掉队 于d3d11的性能 。当时 怎么也想不通 ,后经高人点拨,这才明白 原来driver会有一个专门的线程行止 理 惩罚 runtime发送过来的下令 ,包罗 将shader字节码转译成呆板 码也会有独立的线程负责。但是到了d3d12期间 ,driver的功能变得越发的单薄,很多 事变 都交由应用程序行止 理 惩罚 ,driver不再负责了。那些处理 惩罚 runtime下令 的线程也被取消,d3d12的runtime会直接操纵 driver的核心 函数 。题目 的缘故起因 找到后 ,办理 起来就有方向了,为了模仿 driver在d3d11中所做的举动 ,我们也弄了一个下令 队列 ,它就像RHI Thread那样变成 了异步并发的模式,以后 帧率得到了显着 的提拔 。
尚有 一个雷同 的事变 也能阐明 并发的长处 ,那就是把多个GPU串联在一起做的交错 帧渲染方法(Alternate Frame Rendering) ,每一个GPU就好像 是一级的流水线,比方 两个GPU在一起工作,那么第一个GPU大概 专门负责渲染奇数帧 ,而另一个GPU则负责渲染偶数帧,它们的处理 惩罚 是瓜代 举行 的,互不依靠 。固然 除了AFR的模式 ,着实 还存在一种叫做Split Frame Rendering的模式,SFR就是把多个GPU并行起来处理 惩罚 同一帧的数据,比方 GPU A处理 惩罚 屏幕的左上角,GPU B处理 惩罚 右上角等 。这里显而易见SFR才华 真正的在进步 帧率的同时去低落 耽误 ,但它的实现却比AFR复杂很多 ,任务 的分割和调治 极难处理 惩罚 ,每每 多个GPU的利用 率会七零八落 ,而且假如 必要 GPU之间传输一些中心 数据,还会给带宽带来额外的开销。
很多 人以为 之以是 要用并发来处理 惩罚 渲染逻辑是由于 GPU的盘算 独立于CPU,但我不这么以为 ,固然 这两个硬件所构成 的体系 确实是异构的。重要 的缘故起因 还是 由于每一帧的逻辑都偶然 序依靠 的,不能打乱次序 实行 ,必须先做完模仿 后 ,才华 举行 渲染 。那么对于这种长任务 就只有通过并发改造才华 进步 帧率,固然 中心 还可以将一些局部无时序关系的逻辑并行起来,比方 后文即将提到的并行渲染下令 处理 惩罚 的功能。
除了并发的优化本领 ,我们还可以利用 多任务 并行去加快 渲染流程。我之前也分析过,只有并行才华 真正救济 耽误 的题目 ,大多数游戏必要 是低耽误 ,快相应 ,而不是那些哄人 的高帧率 。但是并行的渲染功能根本 都必要 Graphics API的原生支持。由于 不管是unity还是 早期版本的unreal,它们的并行渲染架构都没有做到真正意义上多线程同时构建硬件的渲染下令 ,而只是一种近似模仿 ,并行处理 惩罚 的是引擎抽象封装的渲染下令 ,但即便云云 也比串行的过程快很多 ,由于 引擎的渲染下令 转换成runtime的函数尚有 很多 额外的工作要思量 ,比方 之条件 到的shader的搜刮 ,资源的绑定和buffer的添补 等任务 。
由于底层利用 的是high level的图形api,以是 根本没有办法在差别 的线程里同时访问runtime的装备 context ,除非给每一次的访问都加上一个互斥锁,才华 包管 它的线程安全性。但要是那样做的话会拔苗助长 ,由于锁的频仍 碰撞导致处理 惩罚 速率 变得更慢了 。opengl就是如许 计划 的 ,它不答应 差别 的线程调用它的接口,调用接口的线程必须和创建context的线程保持同等 。d3d11之以是 可以支持多线程的接口访问,是由于 它内部提供了线程安全的运行模式,估计也是通过加锁来防止临界资源的恶意竞争 ,以是 一样平常 环境 下大部分 引擎都只会选择单线程的模式。
为了更好的支持多线程渲染的应用开辟 ,d3d11还提供了一个耽误 context的机制,它答应 应用程序并行的网络 渲问鼎 令 ,这些指令会被临时 缓存在耽误 context里,末了 再把它们提交到立即 context里实行 ,耽误 context是没有办法本身 直接去实行 这些下令 的 。固然 deferred context也不是什么都不做 ,它也能实行 一些简单 的校验工作,别的 添补 动态的constant buffer同样没有题目 。d3d11的耽误 context的工作原理还是 与d3d12有较大的区别,由于 d3d11的耽误 context并不能在工作线程里完成硬件指令的翻译操纵 ,而是要等把它们放到立即 context里实行 时才会真正开始构建硬件的渲问鼎 令。以是 我推测 耽误 context记录 的还是 一些渲染下令 的中心 状态 。我们知道多个shadowmap的渲染着实 是可以并行的,由于 它们之前没有任何的逻辑耦合,别的 shadowmap的渲染和gbuffer也是没有依靠 的 ,reflectionmap同样也与它们没有辩论 ,以是 这些pass的渲染下令 网络 和实行 完全可以或许 并行起来。我记得最早利用 d3d11的耽误 context特性的游戏就是total war,这个游戏里有大量必要 渲染的脚色 和物件,假如 是串行的添补 渲染下令 帧率肯定会非常的低。
这里打个大概 不是那么得当 的比喻 ,雷同 高级语言的编译器,它会为每一个源码文件天生 一个对应的目标 文件,而这些Object文件内里 存放的只是些临时 的中心 结果 ,还必要 通过链接器将它们装配在一起并转换成呆板 码才华 运行 。由于中心 文件里有很多 的外部依靠 和引用,在单独编译这些文件时还没有办法全部确定,以是 举行 全局优化也要比及 代码链接的阶段。我们在d3d11的耽误 context里所做的api调用 ,着实 不外 是做了一些预处理 惩罚 和合法 性查抄 的工作,这与编译器天生 中心 文件的过程很相似,岂非 它不也是在做同词法和语法分析差不多的工作吗。
对于像d3d11这种高级图形api来说最重要 的题目 还是 状态的设置太零散 ,固然 人类明白 起来很公道 清楚 ,但硬件里的对应概念却是聚合在一起的原子操纵 。各种渲染状态,差别 阶段的shader绑定 ,primitive范例 ,顶点 布局 以及渲染目标 格式等,它们在硬件中是以pipeline state object的情势 存在的,是一个不可分割的团体 ,由于 上卑鄙 的数据转达 是环环相扣的,以是 必须通盘思量 ,比方 上游的输出属性要与卑鄙 的输入属性对应起来。尚有 就是游戏runtime的某些渲染状态设置大概 要转换成shader的一部分 内部代码 ,这个也得driver资助 。以是 应用程序设置到d3d11中的状态会被driver编译并打包天生 一个个相互 独立的pso,假如 状态聚集 里的状态全部一样则会共享同一个pso,因此driver还要负责查找雷同 的状态聚集 对应的pso ,以克制 重复创建。正是由于d3d11接口的这种计划 ,导致了耽误 context想把渲染状态直接翻译成硬件pso的目标 不能告竣 ,由于 如今 大概 有一些渲染状态是当前这个context不知道的 ,固然 这些状态已经在别的 的context被设置了 。我们知道opengl也是一种高级的图形api,新版本内里 有一个pipeline object的概念,但那只是把program聚合在一起 ,对于硬件管线来说并不完备 ,以是 依然要通过driver来做转换。由于d3d12的pipeline state object中,上卑鄙 差别 阶段的信息是全面的,因此driver可以针对差别 的硬件环境 做最优化的处理 惩罚 ,而且这些pso的呆板 码还能缓存起来,下次启动程序时可以直接从文件里读取,而不必每次都举行 构建。这种做法节流 了大量的运行时本钱 ,否则一旦出现大量pso的会合 构建就会引起游戏卡顿 。
这里还要夸大 一点,硬件里处理 惩罚 渲染下令 并不存在多个差别 的下令 实行 队列,着实 就只有一个。driver内部也是通过ring buffer举行 下令 的上传 ,然后再由gpu完成任务 的后续调治 。以是 不管是d3d12还是 d3d11都仅能靠同一个队列提交渲问鼎 令,即便它们可以并行的去构建这些下令 ,只不外 d3d12的runtime根本 不会资助 应用程序去校验渲染状态的有效 性 ,而是要求上层逻辑本身 包管 。别的 就是刚才说到的pso,它们只能由应用程序本身 维护和创建,如许 多个command list就可以独自完成硬件指令的编译和构建了 ,不必要 等全部放在一起后才华 开始做。再增补 一下,刚才提到渲染任务 队列只有一个着实 严格 来说也禁绝 确,硬件上会有三个独立的任务 队列,一个是处理 惩罚 3d的队列 ,它包罗 全部 范例 的下令 ,涵盖了光栅化,compute以及数据拷贝。第二个队列是专门负责处理 惩罚 compute下令 的 ,内里 也可以有拷贝下令 。而第三个队列则是专门实行 数据拷贝的任务 ,它的下令 范例 最单纯。这些队列之间的任务 假如 偶然 序依靠 ,那么就必要 通过barrier和fence举行 同步 ,以包管 数据访问的安全。上述这些功能也是d3d11所不具备的,很多 对硬件举动 的细节控制在d3d11看来根本 都是完全透明的,很多 信息的转达 也是笼统和暗昧 的 ,它只能依靠 driver息息相通 来完成指定的操纵 ,以是 我们才说这类的api属于高级api,雷同 于汇编语言和高级语言的区别 。
之前说到GPU有三个重要 的工作队列(分别对应三个硬件引擎) ,3d下令 队列是一个全能 的队列,内里 可以实行 任何范例 的下令 。d3d11由于不能控制装备 context利用 哪个下令 队列,以是 在性能优化上会有很多 的限定 。本质上d3d11的context都是基于3d引擎的,因此即便是创建再多的deferred context也于事无补 ,它们都不能并行实行 。我在cryengine源码中看到体系 为texture streaming专门创建了一个deferred context,想用它来负责纹理数据的上传和拷贝,而不盼望 这些操纵 影响到渲染绘制的下令 实行 。但估计实际 会事与愿违 ,如同 我分析的一样,这些上传和拷贝的下令 要是和其他的渲染任务 肴杂 在一起放在同一个3d下令 队列里实行 ,它肯定会妨碍渲染下令 的工作 ,即便这些渲染下令 并没有引用这些纹理。由于 3d下令 队列里的command list每帧都会举行 同步,要求之前放入队列的全部 下令 必须同时完成。但大多数环境 下,并不是全部纹理数据的上传和拷贝的下令 都必须在当前帧竣事 。着实 texture streaming的处理 惩罚 完全可以跨帧实行 ,对时效性要求没有渲问鼎 令那么高,耽误 几帧,乃至 几十帧都没有太大的题目 。不外 大概 driver会根据一些外部的提示来把这个专门处理 惩罚 纹理streaming的deferred context放入到copy引擎里实行 ,但那要看底层是否提供雷同 的支持了,这种事变 只能听其天然 ,由于 d3d11不是显式可控的。我推测 大概只有当DriverConcurrentCreates这个特性被驱动支持时,且在CreateTexture2D调用中就把纹理数据借由初始化参数传入函数 ,才华 让copy engine见效 。记得从前 我在为cryengine移植d3d12接口时就给texture streaming功能计划 了一个特别 的工作队列,这个队列里的操纵 会放入到copy引擎的下令 队列里实行 ,和渲染的下令 队列互不干扰 ,只是必要 通过设置fence和barrier举行 同步。
texture streaming中之以是 会有显存对显存拷贝的操纵 ,是由于 当新的mipmap流入时,原来分配的纹理对象里的mipmap数量 就会不敷 ,以是 必要 别的 创建一个拥有符合 mipmap数量 的纹理对象,这时旧纹理对象里的mipmap数据就可以直接拷贝到新的纹理对象中,而不必重新从主存上传到显存。毕竟 显存内的数据拷贝更加快 速 ,其他旧纹理不存在的mipmap数据则必要 从主存里读取,并通过upload heap的buffer上传到显存中 。别的 由于主存里的纹理数据布局 与GPU等待 的不一样,以是 还必要 对其做swizzle变更 (把row-major的布局 改成内部的特别 布局 ) ,这必要 额外的处理 惩罚 时间。显然纹理mipmap流出时也会履历 反向的过程,只是不消 再上传数据了,但新建和拷贝的操纵 必不可少。
对于主机平台着实 也并非肯定 要在上传的阶段实行 swizzle变更 ,由于 如今 的主机硬件体系 都采取 了uma的架构 ,既主存和显存共享一套内存单位 ,以是 在内存里的数据可以同时对gpu和cpu可见 。而且主机gpu端的纹理布局 也是确定的,不像桌面端那样必要 兼容差别 厂商的格式 ,关键很多 厂商出于保密的缘故起因 ,一样平常 不对外透露它的纹理内存布局 。可主机就不一样了,它是一个封闭的生态圈 ,以是 厂商会向开辟 者透露全部 须要 的硬件实现细节,因此你可以通过sdk的接口事先将纹理数据转换成gpu端的布局 格式,并生存 在文件里。等运行时加载到内存里后就不消 再举行 转换了 ,节流 了不少的时间和能耗。着实 桌面端的d3d12的sdk也提供了一个标准 的swizzle格式(Z-order curve),只不外 大部分 厂商都没有明白 声明这个标准 布局 就是它们硬件内部原生支持的格式 。以是 估计GPU还会对上传的纹理数据举行 重排,否则访问服从 就会变低 ,重排数据有利于提拔 cache掷中 率。
我心中不停 有一个迷惑 ,就是手机端也用的是uma的架构,但好像 硬件厂商并没有公开gpu端的纹理布局 格式,固然 也没有sdk可以对其举行 离线转换。很显然swizzle的操纵 是要斲丧 肯定 量的带宽的 ,那么手机端对于能耗这么敏感,按理说事先转换布局 有诸多的长处 ,这是一个稳赚不赔的交易 ,何乐而不为呢?!
之前系列文章中会有一些遗漏且不敷 之处,我会同一 在补遗的文章里做出额外的阐明 ,并把最新的一些思考 也记录 此中 ,盼望 对各人 的实践能有所开导 和资助 。
下面是关于multi-engine的一些新的观点和见解 。先前的形貌 大概 不是那么的严谨,这里算是与时俱进的做些增补 。请连合 早期的文章一起阅读,有了上下文 ,大概 明白 起来更轻易 一些 。
微软好像 意识到d3d11对于multi-engine的支持太过于简单 了,缺乏很多 显式的控制本领 。于是它在后期的版本中渐渐 对此举行 了多少 的加强 ,比方 新增了CreateDeferredContext3接口 ,它可以或许 创建一种新的Context,这个Context拥有把下令 Flush到差别 Engine的本领 。乃至 在ID3D11DeviceContext4这个接口中还参加 了Signal的功能,它可以在完成下令 处理 惩罚 后发信号给Fence 。是的,D3D11.3也能创建Fence了 ,如许 ImmediateContext和D3D12的CommandQueue就根本 可以等价视之。
当初我说过在d3d11中不方便将CommandList单独提交到CopyEngine大概 ComputeEngine中,引入新的Context和Device后(ID3D11DeviceContext3),这些题目 就迎刃而解了。比方 TextureStreaming可以在异步工作线程里把Copy操纵 Flush到D3D11_CONTEXT_TYPE_COPY的队列中 ,而并不肯定 非要在渲染线程里实行 。同时Query也能创建和实行 在差别 的ContextType上,有了这些特性的资助 ,我们就可以在主线程大概 渲染线程里 ,通过轮询大概 逼迫 等待 Event对象来断定先前的队列里的下令 是否已经处理 惩罚 竣事 。由于 这些Event会被插入到Copy下令 之后,只有Event对象收到完成关照 了,我们才华 放心的把这些纹理绑定到ImmediateContext中。否则假如 这些新建的纹理依然在Copy Engine中实行 ,同时它又被Shader访问到,那肯定会造成数据辩论 ,并引起体系 的非常 。
除了Copy Engine的异步化 ,我们还能利用 Compute Engine做一些异步的通用盘算 任务 。假如 能把一些盘算 任务 与当前在实行 的渲染下令 重叠起来,那么就可以充实 利用 GPU的处理 惩罚 单位 ,让它们的负载始终处于饱和的状态。比方 3D Engine在渲染Shadowmap时,大多数ALU单位 和纹理采样单位 是闲置的 ,它对ROP和Raster单位 的依靠 比力 强。于是我们可以将一些盘算 麋集 型的任务 放置到Compute Engine中,与Shadowmap的Pass同时实行 。显然此种安排对于进步 GPU处理 惩罚 单位 的利用 率是大有裨益的,而且还收缩 了每帧的实行 时间 ,这就是盘算 并发的好地方 在,只不外 它消除不了耽误 ,上一帧的盘算 结果 下一帧才华 应用。
之前谈到d3d提供了一个标准 的swizzle模式 ,这个模式是大概 有硬件支持的。通过查询D3D11_FEATURE_DATA_D3D11_OPTIONS2里的StandardSwizzle属性,可以得知该装备 是否支持标准 的重排模式 。而只要硬件支持,就可以或许 利用 CreateTexture2D1函数去创建一个满意 该模式的纹理对象出来。由于我们在CPU端可以事先对主行序的纹理数据按照StandardSwizzle的要求举行 重排 ,那么背面 利用 Staging纹理做拷贝时就不再必要 对数据举行 重排操纵 了,天然 节流 了Upload的实行 时间。
这里我还想借机辨析一下D3D11_QUERY_EVENT与D3D12中Fence的关系,由于 在D3D12中 ,假如 盼望 单独知道某个下令 是否已经处理 惩罚 完毕了,是很困难的 。而D3D11的D3D11_QUERY_EVENT,它可以被插入到Context上的恣意 一个下令 之后,通过GetData接口 ,你就能轻松判定 这个下令 及这个下令 之前的全部 下令 是否已经完成了。但是在D3D12中,你只能以一个Command List为最小单位 去查抄 下令 是否已经被实行 完毕,也就是说当一个大概 多少 个Command List被放入到Command Queue中实行 时 ,可以在调用完ExecuteCommandLists之后,通过Signal插入一个Fence。这个Fence可以或许 判定 传入ExecuteCommandLists的全部 Command List是否全部竣事 了 。假假想 用D3D12模仿 D3D11的D3D11_QUERY_EVENT,那只能把D3D11_QUERY_EVENT之前的下令 放入一个Command List ,它背面 的下令 又放入到另一个Command List,第一个Command List先用ExecuteCommandLists行止 理 惩罚 ,接着通过Signal插入Fence ,末了 才用ExecuteCommandLists实行 第二个Command List,如许 一来就能到达 D3D11的相似结果 了。但显着 上述方式是相称 贫苦 的,假如 插入的变乱 比力 多 ,那么Command List就会被切割得非常的琐屑 。不外 大概 D3D11的Runtime不会那样去计划 ,分割Command List的方法感觉有点愚笨,我推测 它是在每个Present之前且本帧全部 下令 调用竣事 之后才会放置一个Fence。因此即便Query发生在渲染下令 的中心 ,也会通逾期 待 刚才提及的Fence ,确认Present之前全部的Command都被实行 完成了,才让GetData有效 ,而不但 仅是Query之前的下令 实行 完毕后 。这属于一种常见的batch化的处理 惩罚 方法。
综上所述 ,Fence的同步粒度是很大的,它会关联到某些硬件的停止 上,不像ResourceBarrier ,ResourceBarrier可以做逐个下令 的同步,以是 非常 的轻量级。但是 ResourceBarrier并不支持CPU端访问,它是一个纯GPU的对象实体 。OS可以利用 Fence完成Command List的调治 ,由于 我们可以把Fence看作是一种依靠 关系的分边界 。当硬件不支持多Engine的Command Queue时,OS可以把差别 Command Queue中的Command List按照Fence规定的依靠 次序 平展归并 成单一的Command List,放入同一个Engine里去实行 。除了D3D11_QUERY_EVENT ,其他的D3D11的Query也都必要 利用 Fence才华 确认Readback的操纵 是否已经实行 完毕,过程是雷同 的 。
之前的文章里我说过,驱动是以异步并发的情势 去构建硬件下令 的。以是 Command List会被派发到另一个工作线程里做构建,然后比及 下一帧时再调用ExecuteCommandLists去实行 上一帧构建好了的Command List。当前帧的Command List的添补 和上一帧Command List的构建可以在时间线上重叠起来 ,因此Command List从添补 到被实行 至少要耽误 两帧 。
固然 d3d11的内部实现是一个黑盒子,但是我们能用d3d12的概念去做类比和分析,由于 d3d12已经代替 了原来驱动的部分 功能 ,而且 和硬件的底层布局 很靠近 了,以是 这个推测的过程应该八九不离十,大抵 是相仿的。
早条件 到D3D11引入了一个新的Signal函数 ,它能与Fence连合 在一起利用 。除此之外我还讲过Fence与原来的D3D11_QUERY_EVENT有很多 共通之处,按理说并不必要 加Fence这个看似冗余的新概念进来,由于 之前的功能已经完全够用了 。那到底是为什么呢?个人以为 是由于微软想让D3D11全面支持multi-adapter ,用这个新引入的fence就可以实现跨装备 的下令 同步。着实 先前版本的device类已经可以或许 创建跨装备 的资源,通过调用OpenSharedResource函数,在差别 的adapter中引用雷同 的资源。但是应用程序还必要 确定两个装备 在举行 数据互换 时 ,拷贝下令 何时竣事 。之前是没有什么本领 可以或许 告竣 这一目标 的 。由于 数据从装备 a拷贝到装备 b,只有跨装备 的fence才有本领 监控两方的操纵 是否都已经完成。这就有点像socket在跨进程 同步数据时所做的工作,不但 要相识 吸取 方的环境 ,也要清楚 发送方的状态 ,那样才华 确保数据完备 的到达远端。
泉源 知乎专栏:游戏开辟 杂谈