剖析虚幻渲染体系(14)- 延展篇:现代渲染引擎演变史Part 4(结果期)

目录

 

 

14.5 结果期(2016~2022)

时间晃荡一下来到了2016年之后,这时期的游戏引擎朝着影视级、更加物理、更高效的方向发展。且看后面分析。

14.5.1 图形API

14.5.1.1 DirectX

DirectX在2016年之后主要发力于DirectX 12及相关子本版的更新,包含ID3D12Device、Shading Model 6.x、光线追踪、VRS、根签名、资源、同步、调试、PSO、命令列表、HLSL等方面的新增或改进。具体可参见:DirectX - WikipediaWhat's new in Direct3D 12

14.5.1.2 OpenGL

OpenGL在2016年之后发布了4.6版本,新增或改进了SPIR-V、Multi-draw indirect、顶点着色器获取数据、统计和转换反馈溢出查询、各向异性过滤、夹紧多边形偏移、创建不报错的OpenGL上下文、原子计数器的更多操作、避免不必要的发散着色器调用等特性。

而OpenGL ES在此期间几乎纹丝不动。

14.5.1.3 Vulkan

在2016年及之后,Vulkan发布的版本、时间和特性如下表:

版本 时间 特性
Vulkan 1.3 2022-01 查询给定物理设备可用的扩展、Vulkan Profiles工具解决方案、改进多线程应用程序的验证层性能、新的扩展。
Vulkan 1.2 2020-01 时间轴信号量、形式化内存模型、线程同步和内存操作语义、描述符索引、多个着色器重用描述符布局、HLSL着色器的深入支持。
Vulkan 1.1 2018-03 subgroup、渲染和显示无法访问或复制的资源、多视图渲染、多GPU、16位内存访问、HLSL内存布局、YCbCr颜色格式、SPIR-V 1.3。

14.5.1.4 Metal

Metal作为Apple的自家图形API,2017年6月5日,苹果在WWDC上发布了第二个版本的Metal,由macOS High Sierra、iOS 11和tvOS 11支持。Metal 2不是Metal的单独API,由相同的硬件支持。Metal 2能够在Xcode中实现更高效的评测和调试,加速机器学习,降低CPU工作负载,支持macOS上的虚拟现实,尤其是Apple A11 GPU的特性。

在2020年WWDC上,苹果宣布将Mac迁移到Apple Silicon。使用Apple Silicon的Mac将采用Apple GPU,其功能集结合了之前在macOS和iOS上可用的功能,并将能够利用针对Apple GPU基于分块的延迟渲染(TBDR)架构定制的功能。

Metal旨在提供对GPU的低开销访问,命令预先编码,然后提交给GPU进行异步执行。应用程序控制何时等待执行完成,从而允许应用程序开发人员在GPU上执行命令时通过编码其它命令来增加吞吐量,或者通过显式等待GPU执行完成来节省电源。此外,命令编码与CPU无关,因此应用程序可以独立地对每个CPU线程的命令进行编码。渲染状态是预计算的,允许GPU驱动程序在执行命令之前提前知道如何配置和优化渲染管线。

Metal为应用程序开发人员提供了创建资源(缓冲区、纹理)的灵活性。可以在CPU、GPU或两者上分配资源,并提供更新和同步已分配资源的功能。Metal还可以在命令编码器的生命周期内强制执行资源的状态。在macOS上,Metal可以让应用程序开发人员自行决定要执行哪个GPU,可以选择CPU的低功耗集成GPU、分离式GPU(在某些MacBook和Mac上)或通过Thunderbolt连接的外部GPU。应用程序开发人员还可以优先选择在哪个GPU上执行GPU命令,并提供某个命令在哪个GPU上执行效率最高的建议。

 

14.5.2 硬件架构

在2016年之后,Nvidia先后发布了Pascal、Volta、Turing等架构的GPU,其中Turing架构配备了名为RT Core的专用光线追踪处理器,能够以高达每秒10 Giga Rays的速度对光线和声音在3D环境中的传播进行加速计算。Turing架构将实时光线追踪运算加速至上一代Pascal 架构的25倍,并可以以高出CPU30多倍的速度进行电影效果的最终帧渲染。

Turing架构的SM结构图。包含了用于光线追踪的RT Core和用于AI的Tensor Core。

Multi Adapter Integrated + Discrete GPUs由Intel呈现,阐述了2020年适用于集成和分离GPU的多适配器技术,包含集成+分离的机会、D3D12多适配器背景、实用非对称多GPU等。

集成图形的机会:许多游戏PC既有集成的GPU,也有离散的GPU,集成电路通常是空闲的,集成图形需要大量计算!而且Intel有一个扩展,可以帮助提高性能。然而,D3D12多适配器有许多缺陷,对于一类算法,有一种方法可以利用集成的GPU,通过适度的工程努力获得更高的性能。

D3D12多适配器支持,D3D支持多GPU的两种方式:

  • 链接显示适配器(LDA, Linked Display Adapter)。显示为带有多个节点的一个适配器(D3D设备),跨节点/在节点之间透明地复制或使用资源,通常是对称的,即同一个的GPU。
  • 显式多适配器。跨适配器共享资源有很多限制,可能是不对称的,这正是Intel正在做的。

多适配器方法:

  • 共享渲染:分割帧、交替帧、棋盘格,非对称GPU只有很低的投资回报率。
  • 后处理:CMAA、SSAO、相机效果…需要跨PCI总线两次。
  • 遮挡剔除、物理、人工智能。生产者-消费者,从渲染运行异步时效果更好。

集成图形存储器是系统存储器,iGPU可以使用跨适配器共享资源,几乎不受损害:

D3D12跨适配器的资源:资源由绑定到适配器的D3D设备分配,如何在适配器之间传输数据?共享的、跨适配器的资源,必须放置在跨适配器共享堆中。堆创建的步骤:调整数据大小,在任意设备上创建共享堆,创建句柄。

跨适配器资源创建:打开第二台设备上的句柄,对两个适配器都在跨适配器堆中创建放置的资源,使用相同的对齐方式和大小。

粒子案例中的异步计算:乒乓缓冲区保存源状态和目标状态,读取初始状态,计算下一个状态,呈现结果,通过在计算下一个状态时呈现前一个状态来并行化状态(异步计算),缓冲区计数与交换链长度匹配。

多适配器的添加复制阶段:想法是每个适配器都是乒乓缓冲区,形成并行管线:2个状态n的读取者,计算状态n+1,渲染状态n-1。

多适配器的Pong:每帧交换缓冲区。

资源分配:渲染缓冲区使用局部适配器堆(Default、Committed),适配器离散存储器,适配器首选布局。计算缓冲区使用跨适配器堆、放置的交叉适配器,CPU存储器、线性布局。

复制队列:关键想法是分离适配器上的复制队列,从系统内存复制到分离内存,集成内存是系统内存,所以这是一种逻辑安排,显式复制阶段松散的时序。

利用这种耦合+分离的多GPU架构,可以更加高效地处理资源创建、跨适配器同步、命令队列等操作。获得的结果在GpuView显示并行运行的阶段,两个例子,全屏应用,当3D占主导地位时,就有时间进行计算和复制,在某些情况下,复制可能占主导地位(帧时间>渲染+计算):

在以下情况下,此技术最适用:渲染GPU已饱和,纯生产者-消费者(数据只通过总线一次),任务可以完全卸载(无需协作),渲染不等待(管线有呼吸空间),最好是允许计算>1帧,许多异步计算任务都符合这种模式。请注意PCIe带宽:Gen3 x16是16GB/s,400万个粒子,每个粒子float4:64MB,16GB/64MB=256Hz最大帧速率,一些GPU/Config是x8:半带宽,尽可能降低数据传输大小!按使用情况拆分数据缓冲区具有性能优势。这个方案不仅适用于粒子,还可以是物理、网格变形、人工智能、阴影,许多异步计算任务都符合这种模式,检查英特尔集成图形!


14.5.3 引擎演变

14.5.3.1 综合演变

2016年,Towards Cinematic Quality, Anti-aliasing in Quantum Break谈及了影视级的质量和抗锯齿技术,比如间接照明、参与介质、几何体锯齿和镜面锯齿。

Light pre-pass就像完全延迟一样,想法是将照明与几何体分离,但与完全延迟不同的是,几何图形绘制两次。第1个Pass写入照明所需的一切数据,第2个Pass读取光源,并添加其它材质。

Light pre-pass的优势是用于照明的几何缓冲区占用少,易于在第2个几何通道上添加材质变化,通常在第2个几何通道中与MSAA结合。第2个几何通道决定使用什么样的照明采样,常用的方法是将当前几何图形与以前的几何图形进行比较,绘制几何缓冲区并选择最接*的匹配。当有匹配时,效果非常好,当没有好的样本可供选择时,就会出现问题。在头发和树叶着色器中,可以使用在alpha测试输出中使用棋盘格模式,但alpha测试输出上的棋盘格图案难以控制,要么在半分辨率照明下运行,要么开始丢失样本。多层的alpha测试让问题变得更加困难。

保存相关样本是需要解决的关键问题,希望可以使用4xMSAA几何缓冲区,但直接照明开销太大。文中给出的MSAA解决方案是使用几何分簇(Geometry Clustering),将几何缓冲区渲染到4xMSAA目标,将样本从4xMSAA减少到非MSAA(从4个样本减少到1个样本),在减少的样本上运行昂贵的着色,接着重建为4x MSAA,更多的几何样本(成本相对较小),更少的光照样本(计算繁重)。具体过程如下:

  • 构造亚像素偏移。

    • 计算16个样本的哈希值。32位值,其中16位法线值、8位深度、8位材质ID。

    • 选择初始的4个样本。首先在角落里选择四个样本,通过选择角落,最大限度地增加独特样本的数量。

    • 重新分配样本。对于每个选定的样本,测试是否存在重复,如果存在,用未选择的值替换重复项,确保避免不必要的重组,与屏幕保持一致是有道理的。

    • 保存亚像素偏移。每2x2像素区域有32位,每个MSAA样本有2位,提供最终2x2输出上的采样位置,像素着色器输出一半大小的360p图像。

  • 下采样。

    • 读取亚像素偏移。每个2x2的tile共享单个32位亚像素偏移,在普通像素着色器中运行全屏通道。输出是最终的几何缓冲区,将用于AO、SSR、GI和照明。
    • 先写入或*均值。输出第一个匹配项,*均值给出的结果稍好一些。

几何分簇和非MSAA的效果对比:

在处理第2个几何通道时,亚像素偏移提供每个MSAA采样使用哪个光照样本,读取基于2位偏移的光照样本。无需计算当前样本的几何特性,以便与几何缓冲区进行比较。在像素着色器中使用样本覆盖率(HLSL中的SV_Coverage),使用覆盖样本的*均值可以提高质量,但成本要高得多,文中使用firstbitlow和采样一次。

几何分簇、非MSAA和TAA的效果对比:

文中还涉及了时间抗锯齿和上采样。具有亚像素相机偏移的4帧,使用旋转的网格偏移。每帧将前三帧变换为当前投影,以尽可能减小帧间的增量,在单个计算着色器通道中共享夹紧的数据。对于TAA的夹紧,在当前帧计算相邻像素的最小/最大值,扩大了速度小的范围,以抑制亚像素偏移相机引起的闪烁。夹紧xyY并只扩展亮度,以便均衡锯齿和鬼影。对于TAA的上采样,组合4帧的结果,之前的帧已被重投影和夹紧,由于帧之间的采样是交错的,因此是上采样的良好位置,线性采样的效果出人意料地好。

另外,还大量使用了周期性噪点,因为TAA是4个连续帧的*均值。噪点过大可能会导致邻域夹紧出现问题,使用累积缓冲器降低噪点,在序列噪点中效果良好。

总之,具有4x MSAA的几何缓冲区,为720p图像提供了370万个分布良好的几何体样本。在使用较少样本计算照明时,需要进行高质量的下采样,以保留相关数据,下采样产生的亚像素偏移可用于其它效果。MSAA仍然与几何体锯齿相关,拥有更多更好的分布样本非常有意义。存储和重投影N个历史帧,为TAA和高端提供了一个良好的框架,输入过多变化的TAA会破坏系统,每帧都需要足够稳定。TAA将锯齿转换为鬼影,更紧的夹紧边界可能会导致闪烁。

D3D12 & Vulkan: Lessons Learned在DX12和Vulkan发布的一年后呈现的演讲,阐述了基于新生代图形API的引擎架构演变以及带来的影响。D3D11驱动程序经过了很好的优化,利用掌握的知识可以超越D3D11驱动程序,发明D3D12并不是为了在上面编写遗留API驱动程序。若将游戏引擎比作火箭,则DirectX 12和Vulkan可视作助推器:

在没有DX12和Vulkan的图形API之前的游戏引擎可视作是0~0.5阶段,没有加速器:

若融入DX12和Vulkan之后,有了一定的加速器,此时升级到了1.0阶段:

但此时的渲染器和图形API之间耦合过多,不够高级别或低级别,只能看作是有限加速的2.0:

将图形API下移,抽离出更底层的图形抽象层之后,则可以升级到3.0,获得了更大的助力器:

当时的游戏引擎正在过渡以支持Vulkana和D3D12,仍然需要D3D11支持,大多数引擎处于第1阶段和第2阶段之间。要充分利用所有API,需要进行大量思考,多队列支持需要额外的工作,需要兼容到D3D11,建议以D3D12/Vulkana为目标,并在D3D11上运行。

面向未来的设计,本文将指出常见的设计问题,把引擎准备好,将知识转化为更好的表现。

屏障控制:屏障是D3D12/Vulkan中的一个新概念,悲哀的事实是每个人理解错了。两个失败案例:太多或太宽导致性能差,缺失的屏障导致损坏(Corruption)。D3D11驱动在引擎下完成了屏障的这项工作——而且做得很好。到底什么是屏障?

  • 将目标渲染转换为纹理。可能需要解压(和缓存刷新),供应商和GPU次代之间会发生什么变化?可能是无操作,可能是等待空闲,可能是完全缓存刷新。
  • 将UAV转换成资源。如果做得不好,则需要产生刷新或等待空闲,如果操作正确,这些转换可以是免费的。

缺失的屏障:格式问题——GPU/特定于驱动程序的损坏,同步问题——依赖时间的损坏。

子资源(Subresource)需要单独跟踪,如下采样、阴影图集。如果转换所有的子资源,应该使用D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES,而不是逐个转换。对于放置资源和初始状态,使用前必须清除作为放置资源等创建的渲染目标,直接进入清除状态,不要从一些随机状态和转换开始。下图存在“基本状态”或冗余转换,即转换到目标状态然后恢复回基本状态(没有实际使用,只是转换回来了):

有意思的屏障:

  • ResourceBarrier(0, nullptr),即没什么变化,表明状态追踪做了错误的事情
  • 前一状态等于下一状态。发生的事情比你想象的要多——说不就行了。
  • 永远记住——驱动假设你做的是最佳的事情,而不是通过驱动自身的任何启发式!

不必追踪所有资源状态,99%的资源是不可变的——只读,找到过渡点——通道的结束时间,在此处批处理屏障,只转换必需的屏障。

屏障调试技巧:

  • 有一个写/读位。
  • 记录所有转换,Grep和spreadsheet是你的朋友,检查转换的数量和类型等。
  • 转换的数量应按可写资源的数量排序,再说一遍,grep和log是你的朋友,如果屏障数量超过9000,就有些可疑了!
  • 存在每件事都有一个屏障(barrier-everything)的模式,与前面描述的“最坏情况”模式相同,但仅供调试使用
  • 确保资源每帧至少处于已知状态一次,例如在帧结束/开始处,将所有事物转换为已知状态,从而解决TAA或阴影图集破坏等问题。

更好的是,给驱动一定时间处理转换,用D3D12中的“Split barrier”,Vulkan的vkCmdSetEvent+vkCmdWaitEvents。

屏障总结:确保转换所有需要的资源(但不是更多),进入能进入的最具体的状态,可以把不同的状态联合起来。

运行控制,如何为GPU喂食(feed,即发送任务),首先提交命令列表,每帧资源更新和跟踪时间。不要通过手动分配内核来限制并行性
,使用任务/作业系统,自动使用所有内核,需要特别注意高效的工作提交和资源同步。

上图发生了什么?疯狂的线程池,CPU任务在最后提交工作,任务边界成为CPU/GPU同步点,任务完成后控制命令列表。

上图:每个栅栏基本上都是在GPU上等待IDL(或多或少)。更好的做法:保护每帧资源,无论如何,都不太可能在命令列表的“中间帧”开启工作,,用一个栅栏保护多个资源。确保作业系统能做到这一点,尽可能多地批量提交,提前提交,使GPU始终处于繁忙状态。理想的提交如下:

使用多线程设计,恰当地了解工作量和调度:

渲染通道:为帧建立一个高层次的图,通过Vulkan的渲染通道和子渲染通道告诉渲染器,允许驱动选择最佳的调度。允许你很好地表达“don’t care”。

调试提示:可以选择在一次提交中提交所有命令列表,有助于解决时序问题,如果不可能,则需要帧内GPU/CPU的同步。可以选择等待任何命令列表,有助于上传/资源同步,有些资源被破坏了?更新前刷新GPU。

提交总结:以每帧粒度追踪资源,理解帧结构,线程化对于获得良好的CPU利用率至关重要。

Practical DirectX 12 - Programming Model and Hardware Capabilities主要阐述了DX12的最佳实践、硬件特性等内容。

DX12旨在实现最大的GPU和CPU性能,能够投入工程时间!引擎注意事项,需要IHV特定路径,如果做不到这一点,请使用DX11,应用程序替换部分驱动程序和运行时,不能指望同样的代码在计算机上运行良好,所有的控制台、PC都是一样的,考虑特定于架构的路径,注意英伟达和AMD的细节。

工作提交方式有多线程、命令列表、命令束(bundle)、命令队列等。

多线程处理在DX11驱动程序:渲染线程(生产者)、驱动程序线程(消费者),在DX12驱动程序:不会启动工作线程,通过CommandList接口直接构建命令缓冲区。确保引擎在所有的核心上都有扩展,任务图体系结构效果最好,一个提交命令列表的渲染线程,多个并行构建命令列表的工作线程。

命令列表可以在提交其它命令列表时构建命令列表,提交或呈现时不要闲着,允许重复使用命令列表,但应用程序负责停止并发使用。不要把工作分成太多的命令列表,目标是每帧15-30个命令列表、5-10个ExecuteCommandLists调用。每个ExecuteCommandLists都有固定的CPU开销,每个ExecuteCommandLists调用会触发一个刷新,所以,把命令列表批处理起来,尝试在每个ExecuteCommandList中放置至少200μs的GPU工作,最好是500μs,提交足够的工作以隐藏操作系统调度延迟,对ExecuteCommandLists的小调用完成得比OS调度器提交新调用的速度更快。例如下图:

高亮显示的ECL执行时间约20μs,操作系统需要约60μs来调度即将到来的工作,等于有40μs的空闲时间

命令束(bundle)是在帧中尽早提交工作的好方法,GPU上的束本身没有更快的速度,需要明智地使用它们!从调用命令列表继承状态–充分利用优势,但协调继承状态可能会有CPU或GPU成本。可以获得很好的CPU提升的是NVIDIA:重复同样的5+draw/dispatche用一个bundle,AMD:只有在CPU方面遇到困难时才使用bundle。

硬件状态包含管线状态对象(PSO)和根签名表(RST)。PSO对未使用的字段使用合理且一致的默认值,不允许驱动程序执行PSO编译,使用工作线程生成PSO,编译可能需要几百毫秒。在同一线程上编译类似的PSO,如具有不同混合状态的相同VS/PS,如果状态不影响着色器,将重用着色器编译,同时编译相同着色器的工作线程将等待第一次编译的结果。RST需要尽量保持小,按频率排列。

内存管理方面,需要谨慎和合理使用命令分配器、资源、驻留等操作。另外,还需要谨慎处理栅栏、屏障内同步操作。

GDC2017: D3D12 & Vulkan: Lessons learned是GDC2017上分享的DX12和Vulkan的学习课程。文中提到了引擎使用任务图的变革图例:

在屏障方面,手动操作不再有效,需要更高层次的抽象图,从第一天开始在Vulkan提供原生支持。着色器方面,着色器排列越来越少,Doom只有几百个,更多的游戏正在改变创作流程,以便更早地删减着色器变体,更多的高级工作(围绕编译器)正在进行。

引擎向更高级别的渲染发展,API的改进使其更易于使用,游戏受益!开放游戏/可伸缩性,可伸缩性还没有解决,游戏支持所有设置的新旧API,移动*台越来越重要,新的API似乎只是前进的道路。图形API的一种新方法需要ISV、IHV和标准机构之间的紧密合作,API随着游戏引擎的发展而发展,自发布以来,大量的变化让开发人员更轻松!

DirectX 12 Case Studies由NVidia呈现,讲述了DirectX12的相关技术和应用案例,文中涉及DX12的技术包含异步队列、内存管理、管线状态对象、着色器模型5.1资源绑定、多线程及其它。

对于异步队列,计算队列可以很好的跨供应商加速,*均5%的提升,异步工作负载主要与分辨率无关(针对1080p进行了调整),随着分辨率的增加,收益递减。

引擎使用3个复制队列,多个复制队列简化了引擎线程同步:

SM 5.1和/all_resources_bound着色器编译器标志将性能提高约1.0到1.5%,无需更改着色器代码,为纹理访问启用不太保守的代码生成。

Ubisoft的Anvil Next引擎:重新设计以充分利用DX12,最大限度地减少和批处理资源屏障,充分利用并行CMD列表录制,使用预编译渲染状态以最小化运行时工作,最小化内存占用,利用几个GPU队列。

Anvil Next引擎分组屏障图例。

Hitman渲染器处理完全动态的场景,例外情况是在水*负载期间产生反射和环境探针。使用分块延迟照明,前向光源使用单独的通道,用于剔除照明的门/室(入口/单元)系统。阴影用4个VSM级联,第4个是静态的,4~8个额外的阴影贴图。

对于CPU性能,代码可以像引擎代码一样进行分析和修复,设置了太多多余的描述符,确保只设置实际使用的描述符。次优的合批:最终批量处理资源转换和命令列表提交,最终通过多线程与最快的驱动程序相匹配。

当时的DX12内存管理也存在问题,DX12视频内存消耗过高(与DX11相比),DX11驱动程序非常擅长在视频内存之间移动内存。最终实现了一个分页(逐出)资源的系统,使用非常简单的LRU模型,很多工作都是出乎意料的,仍然不理想,因为有MakeResident的卡顿,在DX12上的性能更差,尤其是在低显存的GPU卡上。已实现的渲染目标内存重用系统(放置的资源),引入静态资源的子分配,为所有内容创建提交的资源将占用大量内存,PC上的内存节省不如控制台上的高,资源层(Resource tier)防止所有内存被用于各种资源。

DX12资源分配和围栏:需要一个超级快速的分配器来实现帧资源的无锁分配,在一个帧中有大量动态资源分配,如描述符、上传堆等。栅栏开销也很大,尝试将它们用于资源的细粒度重用,最终使用一个信号栅栏来同步所有的资源重用。

DX12状态管理:使用像素着色器对象存储PSO和其它状态,绝大多数像素着色器只有少量排列,可通过哈希访问的排列,已从状态管理中删除采样器状态对象,决定使用16个固定采样器状态。

为每个状态创建唯一的状态哈希,将所有状态块放入具有唯一ID的池中,状态块包括光栅化器状态、着色器等,使用块ID作为位来构造状态哈希。

DX12资源转换:实现了简化的资源转换系统,假定读取/SRV为默认状态,仅支持转换到RTV、UAV、DSV和转回,仅UAV屏障需要额外的转换。主渲染线程提交所有转换,主渲染线程还可以记录命令列表,并执行所有多线程同步,大大简化了代码。

3A游戏”的DX12内存管理:显式内存管理是实现出色且一致性能的关键,LRU资源管理战略有很长的路要走,在上次使用资源后,将其保存在内存中一段时间,只有当它被驱逐时才带进回收堆里。支持资源绑定tier 2,不使用时,表中的CB描述符必须解除绑定(设置为0),Nvidia驱动程序现在支持未清除的描述符,将CBV移动到根签名中以跳过解除绑定,当用作根CBV时,CBV只是一个GPU地址,无需调用CreateConstantBufferView()。确保对所有RST条目使用最佳着色器可见性标志,尽可能避免Avoid SHADER_VISIBILITY_ALL标记,在CPU内存中缓存RST状态以跳过冗余绑定,从而提高CPU性能。结果证明,应最小化RST的变化,使用整个帧的两种布局。对于屏障,最初的DX12路径有冗余屏障,屏障隐藏在抽象层中(自动触发),大部分时间都有效,对于特殊情况,引擎将切换到显式屏障管理,延迟屏障用于跳过进一步的冗余和合批屏障,将屏障添加到待处理列表中,等到最后一刻刷新列表,过滤掉冗余。

调试GPU:根据API(CPU)的错误代码检测到崩溃,在最后N帧命令中发生了崩溃…CPU调用堆栈很可能是个麻烦。


NVIDIA AFTERMATH可以提高GPU崩溃位置的准确性,使用命令流内联用户定义的标记,一旦到达每个标记,GPU就会发出信号,最后到达的标记表示GPU崩溃位置。

NVIDIA AFTERMATH帮助诊断GPU崩溃的新工具(标头+DLL),非常灵活/简单的API,当前与兼容DX11和DX12 UWP和/或Windows。它的局限性是需要NVIDIA GeForce驱动程序版本378.xx及以上,与D3D调试层不兼容。

Advanced Graphics Tech: Moving to DirectX 12: Lessons Learned也详细地阐述了育碧的Anvil Next引擎移植到DirectX12的具体过程、优化、技术、策略、经验教训等内容,诸如生产者消费者系统、调度图系统、屏障转换和优化、资源管理、资源依赖、内存重叠、资源同步、着色器、PSO等等内容。

总之,利用高级渲染过程知识优化API使用,高级生成系统节省了大量渲染工程师调试时间,粒度更小、基于blob的渲染接口,最大化CPU性能增益,避免重复工作,架构工作将使其它*台/API受益。利用DX12可获得约5%的GPU提升,15%~30%的CPU提升。

实现与DX11性能的对等是一项艰苦的工作,不要将性能视为最终目标,将努力视为通往:解锁异步计算、mGPU、SM6等功能,比以往任何时候都更接*与控制台的功能对等,改善引擎架构的机会,之后移植到其它等效的API要容易得多。

更多关于DX12和Vulkan的知识可参阅:剖析虚幻渲染体系(13)- RHI补充篇:现代图形API之奥义与指南


Developing The Northlight Engine: Lessons Learned说明了Northlight引擎开发的DX12移植的过程和经验教训。Northlight引擎的渲染管线:

  • G缓冲、速度、阴影通道(多线程)。
  • 全屏阴影。
  • 全屏照明。
  • 主要、透明通道(多线程)。
  • 后处理。

DX12特性清单包含描述符表、动态资源、管线状态对象、命令列表/分配器、资源转换、暂存资源、小型资源、mipmap生成、空资源、计数/追加缓冲区、查询等。下面对它们进行阐述。

  • 描述符表:是一个表包含任何着色器阶段可能使用的所有资源的描述符,每个draw调用都需要一个表,一旦在GPU上完成draw,就可以重复使用。
  • 动态资源:DX12中没有这种东西,需要自己管理版本控制/重命名/轮换,写一次(CPU),读一次(GPU)使用上传堆环缓冲区。
  • PSO:创建是有问题的部分,理想情况下,在出口管线中输出,在游戏开始时加载,在第一次接触它们时就创建了它们,CS PSO可以在CS加载下生成,约500个单独的图形PSO需要约200毫秒才能生成。包含根签名(资源布局)、着色器代码、顶点着色器输入布局(不是顶点/索引缓冲区)、基本类型、混合、光栅状态、MSAA模式、渲染目标和深度模板格式等。
  • 命令列表/分配器:DX11中的即时/延迟上下文,分配器拥有内存。
  • 资源转换:驱动不再跟踪使用情况,使用前必须手动转换至正确状态:着色器资源、渲染/深度目标、复制源/目的地、UAV、呈现等。
  • 暂存资源/更新子资源:没有动态资源,更频繁地使用暂存资源,按需从环形缓冲区或持久缓冲区,无更新子资源,不能依赖模拟的d3dx12.h的版本,无过渡纹理,通过过渡缓冲区进行模拟。
  • 小型资源:CreateCommittedResource以64kB的页面进行分配,不会为少量资源而运行,理想情况下,在碎片整理堆中对所有资源进行子分配,或特殊情况下的小型资源。
  • GenerateMips:DX12中没有这种东西,为其编写一个计算着色器,发现手动实现的性能优于DX11,但需要处理许多不同的情况(2D/3D/数组/颜色空间)。
  • 空资源:不能再绑定nullptr了,需要1D/2D/3D纹理、缓冲区、UAV纹理/缓冲区、CBV、采样器的空资源,可能需要将空绑定提升到抽象中更高的位置才能了解类型。
  • 计数/追加缓冲区:DX12中没有这种东西,有一个单独的计数缓冲区,可以原子增加。
  • 查询:另一个需要注意的容易忘记的方面,管理/轮询查询堆,合并解析(即合批在一起,避免多个调用),在完整的堆中回读。

从DX11移植至DX12,你现在是驱动了(成为真正的老司机),注意内存使用和性能,将优化重点放在瓶颈上,在单独的CPU和GPU时间线中思考。Northlight在移植过程中,对不同的对象操作如下:

  • 资源屏障。在主线程中自动执行资源转换:绑定RT时、在描述符表中设置资源、复制,其它异步渲染线程不允许转换,在执行命令列表之前,手动确保资源(主要是RT)处于正确的状态。不必要地滥用它们可能会扼杀GPU性能,但取决于硬件,仅在必要时使用UAV屏障,它们会迫使GPU在调度之间闲置(DX11样式)。

  • 绘制。遍历绘制,捕捉DX11样式集的调用,跟踪之前的值,如果PSO发生了变化,请将其标记为脏,如果已脏,在绘图时进行哈希,从映射表中获取PSO。仅在更改时设置索引/顶点缓冲区、RT/DS和描述符堆,集合在CPU上很便宜,但会导致硬件上下文滚动。PSO是只读、绑定和遗忘的,每次draw调用时旋转到空闲(GPU)描述符表中,在GPU上执行命令列表后重用描述符表。

  • 线程。没有区分即使/延迟上下文,在任何线程上记录命令列表,从一个线程提交。

    • 在池中保存描述符表管理器(处理表的旋转)、描述符表管理器GPU栅栏(让你知道何时可以重用表)、 命令列表(在GPU完成执行后可以重用)、命令分配器(可用于多个命令列表)

    • 初始化线程时获取描述符管理器、描述符管理器栅栏、命令分配器、命令列表。
    • 结束线程时释放CPU可重复使用的描述符管理器、命令分配器。
    • 执行命令列表之后释放GPU可重用的描述符管理器栅栏、命令列表。

总之,GPU性能:做正确的事情,匹配DX11,在所有架构上都很重要,搞砸GPU内存管理可能代价高昂。CPU性能:轻松超越DX11,但是,真的受到API开销的限制吗?实例化、LOD、良好的剔除使得驱动程序不会被绘制调用淹没。


Rendering 'Rainbow Six | Siege'是游戏《彩虹六号|围城》基于当前新一代渲染引擎的首次迭代,文献将重点介绍利用当前一代硬件才可能实现的计算的架构优化,以及新的棋盘渲染技术,该技术可以在不造成质量损失的情况下将渲染速度提高50%。

Siege的GPU帧的层次视图:几何体渲染*均花费5毫秒,大量使用剔除,缓存阴影,*均5毫秒用于照明(包括SSR),棋盘渲染相助,SSAO和SSR射线追踪以异步方式完成,后处理/其它全屏处理*均花费4ms。

CPU关键路径的层次视图:关键路径*均10毫秒,所有通道和任务都能够分叉和连接,以最小化关键路径,缓存阴影,不透明通道的最大线性长度为4ms,基于材质的绘制调用系统!

不透明物体的渲染管线如下:

阴影渲染:所有阴影都是基于缓存的,使用缓存的Hi Z进行剔除。太阳阴影以全分辨率完成,分离通道以释放VGPR压力,使用缓存阴影贴图的Hi Z表示法来减少每像素的工作量。局部光源以四分之一的分辨率进行解析,解析的结果存储在纹理数组中,光照积累时VGPR使用率较低,双边上采样。对于太阳、月亮的阴影,包含加载时生成的所有静态对象的阴影贴图。

通过混合级联和静态贴图来缩放阴影成本的能力,静态Hi Z阴影贴图始终用于动态对象剔除。在Xbox One上:第一级级联是完全动态的(6K分辨率不够),第二级和第三级级联仅渲染动态对象,并与静态阴影贴图混合,第四级由静态阴影贴图替代。

对于局部光源投影,最多处理8个可见阴影局部光源,流程如下:

在光照方面,在frustum上使用分簇结构:基于32x32像素的分块,Z指数分布,分层剔除光源体积以填充结构,被视为光源的局部立方体图,阴影、立方体图和遮挡板(gobo)位于纹理阵列中,延迟使用预先解析的阴影纹理数组,前向使用阴影深度缓冲区数组。

统一缓冲区(UNIFIED BUFFER):彩虹六号中的许多资源都位于某种统一缓冲区中,如统一顶点缓冲区、统一索引缓冲区、统一常数缓冲区、...。结构化缓冲区构建在自动生成代码的原始缓冲区之上:使用C++数据描述符处理GPU统一数据,传递给指定访问模式的元数据。统一常数缓冲区示例代码:

统一缓冲区的好处:完全控制数据布局,可以很容易地尝试不同的数据类型访问(AOS、SOA、u32数组的结构、…),自定义打包和对新数据类型的支持,高级API支持广播值,代码自动生成允许我们轻松地迁移到新的访问模式。

基于材质的绘制调用:几何和常数是统一的,然后绘制调用由以下内容定义:着色器、非统一资源(纹理等)、渲染状态(采样器状态、光栅状态),共享上述内容的元素被批处理在一起,不使用资源和状态子集的通道将进一步批处理在一起。

收集绘制调用:初始化时,每个子网格实例映射到3个批次:普通、阴影和可见性,批处理类型用于屏蔽非必要的数据,每个批次将对应一个MultiDrawIndexedIndirect命令。

每个子网格实例都有一个全局唯一的索引:用于获取所有数据的索引,需要多个间接寻址。

对于每个通道收集子网格实例索引到动态缓冲区:每个通道只映射到一个批次类型,多线程作业中的缓冲区填充(1.5ms线性)。添加了执行剔除的额外数据:MultiDrawIndexedIndirect条目、新索引缓冲区偏移量、附加剔除标志。

使用了性能剔除,定义了多种类型的剔除:级别1——子网格实例剔除,级别2:子网格块(chunck)剔除,级别3:子网格三角形剔除。




结果:

非核批的DC数量(共总) 合批的DC数量(VIS + GBUFFER + DECALS) 合批的DC数量(阴影) 裁剪效率
10537 412 64 73%

接下来聊棋盘渲染。

棋盘渲染的基本思想解决了锯齿问题,在一系列图像上进行实验,首先测试质量,对于大多数图像,使用棋盘模式时PSNR更好:视觉效果也更令人愉悦,从一开始,使用MSAA 2X的想法就开始流行起来。

上排是线性邻域插值,下排是棋盘邻域插值。

棋盘渲染实现:

  • 渲染到1/4大小(1/2宽 x 1/2高)分辨率,使用MSAA 2X,最终得到全分辨率图像的一半样本。
  • D3D MSAA 2x标准模式:2个颜色和Z样本。
  • 采样修饰符或SV_SampleIndex输入以强制渲染所有样本。
  • 每个样本都落在全屏渲染目标的精确像素中心。

棋盘渲染额外的好处:粒子效果可以很容易地按像素而不是按样本进行评估,可以在ESRAM中获得更多的东西!无需在着色器中修正渐变。

通过在每一帧中再次偏移投影矩阵可以改变模式,并不总是可以在PC上更改样本的位置。

填补空白处:为了重建下图未知像素P和Q的颜色,采样当前帧直接邻域线性Z、当前帧直接相邻颜色、历史颜色与Z。

历史颜色/Z:选择一个邻居作为运动速度:离相机最*的一个,以保持轮廓,使用运动速度,对之前解析的颜色进行采样。这样可以使用过滤,但会引入累积误差!用B、E、F为Q夹紧重投影的颜色,使用之前从运动计算的深度,计算一个信赖的值,用于向未夹紧的值混合。解析颜色:已有历史颜色、直接邻域的插值颜色,使用两个附加的权重计算最终的颜色:A、B、E、F与Q之间的最小差值和速度的大小。

完整的流程图如下,解决相当复杂的问题,内容有很多调整!消耗1.4ms,减少了8到10ms。

TAA可以和棋盘渲染集成,可以在同一个resolve着色器上运行,在子样本级别上完成,MSAA 4X风格在棋盘格图案顶部抖动,重投影颜色权重使用类似的逻辑,额外的信息(Unteething)可用于删除不良的棋盘模式。

分辨率会在图像中引入明显的锯齿图案,应用过滤来消除它们,该过滤可在5个水*或垂直相邻的像素上工作,将阈值d和二进制像素分别设置为0或1,如果它们在[0, d]或[1-d, 1]范围内,我们检测到01010或10101模式。

Optimizing the Graphics Pipeline With Compute由DICE的Frostbite引擎团队成员呈现,使用计算着色器优化图形管线,更具体地说,如何通过不渲染那么多三角形来快速渲染三角形。文中涉及的各种概念如下表:

概念 全称 翻译
VGT Vertex Grouper \ Tessellator 顶点分组器、曲面细分器
PA Primitive Assembly 图元装配
CP Command Processor 命令处理器
IA Input Assembly 输入装配
SE Shader Engine 着色器引擎
CU Compute Unit 计算单元
LDS Local Data Share 局部数据共享
HTILE Hi-Z Depth Compression 层级深度压缩
GCN Graphics Core Next AMD的图形核心称号
SGPR Scalar General-Purpose Register 标量通用寄存器
VGPR Vector General-Purpose Register 向量通用寄存器
ALU Arithmetic Logic Unit 算术逻辑单元
SPI Shader Processor Interpolator 着色处理插值器

当时的Frostbite引擎面临绘制调用过多(1000+)、图元填充率过高等问题。解决的机会有:

  • 在CPU上粗糙剔除,在GPU上精细剔除。

  • CPU和GPU之间的延迟会阻止优化。

  • GPU提交!

    • 深度感知剔除。缩小阴影边界\样本分布阴影贴图,剔除没有贡献的阴影投射者,从颜色通道中剔除隐藏物体。
    • VR的late-latch剔除。CPU提交保守的视锥体,GPU细化。
    • 三角形与分簇剔除。
  • 直接映射到图形管线。卸载外壳着色器工作,卸载整个细分管线!程序顶点动画(风、布料等),在多个过程和帧之间重用结果。

  • 间接映射到图形管线。边界体积生成,预处理蒙皮,顶点动画(Blend Shape),从GPU生成GPU工作,场景和能见度测定。

  • 将绘制当做数据!预构建,缓存和重用,在GPU上生成。

裁剪概览图:

左上:如图所示的场景,包含网格集合、特定视图、摄像机、灯等。右上:场景中的可配置网格子集,共享着色器和三角形带(顶点/索引)的批次内的网格,与DirectX 12 PSO的比例接*1:1(管线状态对象)。左下:代表一个索引的绘制调用(三角列表),有自己的顶点缓冲区、索引缓冲区、图元数量等。右下:波前处理的最佳三角形数,AMD GCN每个波前有64个线程,每个剔除线程处理1个三角形,工作项处理256个三角形。

剔除概览如下:

大致的过程和技术点包含将网格ID映射到多绘制ID、使用非交错顶点缓冲区、分簇剔除、绘制压缩、三角形剔除、朝向剔除、小图元剔除、视锥体剔除、深度剔除(深度分块、深度金字塔、HTILE、软光栅Z等)。

最终的剔除性能和效果如下:


The Rendering of INSIDE: Low Complexity, High Fidelity分享了当年爆火的一款解密探索类游戏Inside的渲染技术,包含用于实现大气外观的各种效果,例如局部阴影体积测量和强大的水渲染系统。通过对每个像素进行微调,将照明设计为完全独立的漫反射、镜面反射和反弹光实体,同时关注艺术家可接*的工具,意味着利用分析基于图元的环境光遮挡和屏幕空间反射。此外,还详细阐述如何通过适当使用抖动来消除分散注意力的瑕疵,避免艺术品的细微细节淹没在色带中。

Inside是一款暗黑解谜类游戏,可运行于安卓、ios等移动*台,是当年令笔者沉迷的一款游戏。笔者曾对它给予了很高的评价,有图为证:

该文涉及的内容比较多,包含雾与体积度量、HDR泛光、色带和抖动、投影贴花(定制照明、解析环境遮挡、屏幕空间反射)、水渲染、效果分解(吸引眼球)等。其中Inside使用了Light Prepass渲染,一帧概览如下:

雾原来是Inside艺术风格的核心,游戏中的许多初始场景实际上只是雾+剪影。以下是雾的组合效果:

从上到下:没有雾、线性雾、线性雾+发光。

作为大气散射的发光是非常宽的光晕,半个屏幕,向下采样,然后是多个模糊,因为只需要大范围的模糊。HDR发光是第二个发光通道,只对明亮发光的物体,遮罩对象的窄辉光仅发射材质(写入alpha通道)遮罩值将RGB重新映射为非线性强度,中间HDR值用\(x/(x+1)\)编码为[0:1]的定点数。

采样模式使用了发光过滤器[JIMENEZI4],下采样时13个样本模糊,上采样时9个样本模糊。后处理设置步骤和流程如下:

接下来聊体积光照。

体积照明用raymarch的相机光线,阴影贴图投影空间中背景深度的步长,每一步计算光照贡献:采样阴影图、cookie、衰减等。

使用了逐像素3个抖动的样本,蓝色噪声提供了良好的采样,而在采样不足的区域,回到噪声中寻找失真可以获得更少的样本。

半分辨率用于低频效果,是上采样时的深度感知模糊。步骤如下:

  • 清理前深度缓冲为零。
  • 通道1,半分辨率。正面深度。
  • 通道2,半分辨率。射线步进体积雾,输出光源强度(8位)+最大深度(24位)。
  • 通道3,全分辨率。对半透明对象排序,深度感知解析、上采样和模糊。

通道3的具体过程如下:

另外,使用方差尺寸模糊、4样本、深度感知样本等影响TAA、下采样,让TAA随着时间的推移进行集成。以下是几种不同抖动方法的对比:


在基础颜色通道,存在非常明显的颜色条带(色阶)的瑕疵:

为了解决上述问题,分别对光照进行抖动、最终通道引入完全均匀的噪点、半透明未引入噪点但使用特殊的混合模式,解决方案在每个通道后都会抖动,手动将所有中间渲染目标转换为srgb(pow2,没什么特别的),在较低的分辨率下抖动(用于模糊)会导致大于1像素的噪声,但幸好结果却不可见。

抖动和噪点不仅仅可用于颜色,还可用于法线!!

在定制光照方面,新增了反弹光照、AO贴花、阴影贴花等效果。反弹光被用于全局照明,几乎只是一个普通的兰伯特点光源,除了没有使用香草点积,而是使用滑块使其褪色,它被称为lambert扭曲(lambert wrap)或半lambert(half lambert),提供方向性更小、更*滑的结果。

de14.png)

不同强度的反弹光。

因为它们不是静态的,所以经常用它们来打开窗户和移动手电筒。Inside没有使用常规方法(制作一系列点来覆盖走廊或一个数组来填充房间),而是使用完整的变换矩阵,以获得非均匀的形状,更适合使用拉伸的药片和压扁按钮的盒子,而且更低开销,因为过绘制和重叠更少。

文中还涉及了AO贴花和阴影贴图。



从上到下:点AO、球体AO、盒子AO效果及实现方法。

从上到下:无阴影贴花、有阴影贴花、阴影贴花可视化。

对于SSR,采用了特殊的屏幕空间追踪方式:

从左到右:屏幕空间向上发射射线、让射线移动到包围盒最*的水*出口、继续向上追踪。

// SSR方向计算(常规版) // GPU的片元着色器 sDirProj = mul(projection, vec4(vDir + vPos, 1.0)); SDir = normalize(sDirProj.xyz / sDirProj.w - sPos);  // SSR方向计算(改进版) // CPU计算投影空间的方向,并设置成uniform变量 _DirProject = vec(viewportSize, nearClip / (nearClip - fraClip)); // GPU的片元着色器 sDir = vec3(vDir.xy - vRay.xy * vDir.z, vDir.z * rcpDepth) * _DirProject; 

为了避免阶梯式瑕疵,使用了随机采样(蓝色噪点 + TRAA)。

水体(水面和水下)使用了分层渲染,包含体积雾、半透明、折射、反射等效果:

水面的分层组合后的效果。

水下也和水面一样,具有相同的分层渲染,还增加位移边缘、外部或正面面、内部或背面面等层:

Inside的特效方也非常讲究,使用了很多技术和优化技巧。

在世界空间中将粒子纹理投射到粒子上,每个粒子上都有一个随机偏移,并向一个方向滚动。图中示例是向下的,因为是从盒子里出来的方向。

镜头眩光的采样方式。

各种水面效果的模拟。

Temporal Reprojection Anti-Aliasing in INSIDE也涉及了Inside,但专注点在TAA的应用和优化。

首先是一些基本的直觉,表面片元的局部区域可以在多个帧中保持可见,若观察者和被摄体之间的关系每一帧都发生变化,那个么光栅化也会发生变化,如果在时间上后退一步,那么可以使用这种变化来优化当前帧。

要将当前帧片段与前一帧的片元关联起来,可以在空间上进行,并进行重投影,依赖深度缓冲区信息,仅限于最接*的表面片元,但不总是可行,有时候数据根本就不存在。片元在任何时候都可能被遮挡或不被遮挡,因此很难准确后退,如果观看者和被摄者之间的关系从未改变,那么退一步就不会获得额外的信息…

第一步:抖动视锥。已经确定如果摄像机是静态的,然后就失去了信息,因此,渲染前的每一帧:从样本分布中获取texel偏移量,使用“偏移”计算投影偏移,使用“投影偏移”剪切*截头体。

第二步:对于每一个片元执行以下步骤:

静态场景的重投影:

  • 从当前片段p_uv开始。
  • 使用当前帧的深度和*截体参数重建世界空间p,lerp角射线,按线性深度缩放。
  • 将p重新投射到前一帧中:q_cs = mul(VP_prev’, p)q_uv = 0.5 * ( q_cs.xy / q_cs.w ) + 0.5
  • 采样历史:c_hist = sample( buf_history, q_uv

动态场景的重投影:

  • 对于动态场景,需要一个速度缓冲区
    • 在处理TAA之前需要专门的pass。
    • 使用静态重投影初始化摄像机运动:v = p_uv - q_uv
    • 在顶部渲染动态对象:v = compute_ssvel(p, q, VP, VP_prev’)
  • 重投影步骤变为读取和减法:v = sample(buf_velocity, p_uv)q_uv = p_uv - v

在TAA过程还需要处理重投影与边缘运动、约束历史样本、邻域夹紧等细节,最终融合,用约束的历史加权。

Inside的TAA 2.0将运动模糊添加到混合中:

带运动模糊回退的最终混合步骤:

  • 像以前一样更新历史缓冲区:rt _history = c _feedback。
  • 对于输出目标,与运动模糊输入混合。
    • c _motion = sample_motion ( buf _color , unjitter( p _uv ), v)
    • rt _output = lerp( c _motion , c _feedback , k_trust)
    • k_trust = invlerp( 15, 2, |v| )
  • 强制过渡到运动模糊(无历史记录!)寻找快速移动的片元。

关于选择一个好的样本分布,大量的尝试和错误,采取了实用的方法,头部靠*屏幕,放大高对比度区域,希望在收敛的质量和速度之间找到良好的*衡,启发式:侧滚游戏。

测试的一些序列:

实现总结:用halton(2, 3)的前16个样本抖动视锥,生成速度缓冲,摄像头运动+动态(手动标记),基于最*的(深度)片元的速度重投影,使用中心剪辑到RGB最小最大圆形3x3区域的邻域夹紧,运动模糊回退,当||v||大于2时生效,15时完全生效,但不适用于历史。

Temporal Antialiasing In Uncharted 4也分享了神秘海域4的时间抗锯齿。本文的焦点在于提供更详细的实现细节。

对于静态图片,设置输入和输出,运行全屏幕着色器,在历史和当前渲染纹理之间插值:

float3 currColor = currBuffer.Load(pos); float3 historyColor = historyBuffer.Load(pos); return lerp(historyColor, currColor, 0.05f); 

对于运动图像,不能在同一位置采样历史缓冲区,找出上一帧中当前帧像素的位置(如果有),需要处理屏幕外的像素和被遮挡的像素。需要先在GBuffer通道中生成全屏运动向量缓冲区(fp rg16):

// GBuffer VS posProj = posObj * matWvp; posLastProj = posLastObj * matLastWvp;  // GBuffer PS posNdc -= g_projOffset; posLastNdc -= g_projOffsetLast; float2 motionVector = (posLastNdc - posNdc) * float2(0.5f, -0.5f); 

对于程序化的动画对象(植物、毛发、水顶点运动等),需要对当前帧和最后帧执行两次,需要确定性,输入最后一帧的时间,产生完全相同的顶点位置,在纹理上滚动uv,即在表面uv空间(deltaU,deltaV)中滚动会导致屏幕空间(deltaX,deltaY)发生多少变化:

deltaU = ddx(U) * deltaX + ddy(U) * deltaY; deltaV = ddx(V) * deltaX + ddy(V) * deltaY; 

已解析的deltaX和deltaY以屏幕像素为单位,将它们转换为运动向量单位。对于反射,无法使用镜像的运动矢量,因为镜像像素在上一帧中通常反射不相同的东西。这个问题不难,但涉及很多步骤,仅适应于*面反射。一切都应该有运动矢量,但还有一些不受支持的:复杂纹理动画,如粒子烟、水流、云运动等;透明对象,如艺术家控制运动矢量不透明度。为什么不在TAA之后绘制呢?绘图顺序并不总是允许,任何跳过TAA的东西都会抖动,删除抖动还不够,因为使用了抖动深度缓冲进行测试。在TAA之后摆脱了问题:雨滴,弹痕,火花…除此之外,没有其它东西摆脱,一旦使用了TAA,就得一路走下去。将运动矢量缓冲区作为输入添加到TAA,使用运动矢量采样历史缓冲区:

float2 uvLast = uv + motionVectorBuffer.Sample(point, uv); float3 historyColor = historyBuffer.Sample(linear, uvLast); 

使用线性采样器的重要事项:点采样可能会落在不相关的像素上,线性采样不会完全丢失,但会导致模糊(稍后讨论)。

历史和当前颜色可能不匹配,由于遮挡/屏幕外而不存在、灯光变化、在边缘的不同侧面(投影抖动)等。需要夹紧历史颜色,使用Dust 514的夹紧方法,以当前帧像素为中心采样3x3邻域,计算每个rgb通道的最小/最大邻域:

float3 neighborMin, neighborMax; // calculate neighborMin, neighborMax by // iterating through 9 pixels in neighborhood historyColor = clamp(historyColor, neighborMin, neighborMax); 

对于不存在和灯光变化的情况,夹紧掉太不相同的历史颜色,3x3邻域确保夹紧不会影响边缘AA,对于边缘像素,边缘另一侧的历史不会被夹紧。是时候再次混合历史和当前颜色,这一次支持运动图像:

return lerp(historyColor, currColor, blendFactor /*0.05f*/); 

需要动态的blendFactor来*衡模糊和抖动。基于UE4方法:局部对比度较低时增加,当像素运动达到亚像素时减少,当历史接*夹紧时减少,结果还是有点模糊。为了解决模糊,需要一个全屏幕通道使用以下的邻域权重进行处理:

\[\begin{vmatrix} 0 & -1 & 0\\ -1 & 4 & -1\\ 0 & -1 & 0 \end{vmatrix} \]

return saturate(center + 4*center - up - down - left - right); 

TAA仍然存在一个问题:鬼影,夹紧应该可以防止它,但在某些领域似乎不起作用。鬼影发生的原因是包含高频和高强度的颜色变化,如多棱茂密的草、黑暗中被强光照亮的凹凸不*的表面。邻域最小/最大值变得巨大,使夹紧失效:

historyColor = clamp(historyColor, neighborMin, neighborMax);  // 将上面的原有代码改成以下代码 uint currStencil = stencilBuffer.Sample(point, uv); uint lastStencil = lastStencilBuffer.Sample(point, uvLast); blendFactor = (lastStencil & 0x18) == (currStencil & 0x18) ? blendFactor : 1.f; 

鬼影消失了,但新显示的像素看起来格外锐利和锯齿:

当blendFactor为1时返回高斯模糊颜色:

blendFactor = (lastStencil & 0x18) == (currStencil & 0x18) ? blendFactor : 1.0f;  float3 blurredCurrColor; // Gaussian blur currColor with 3x3 neighborhood  if (blendFactor == 1.0f)     return blurredCurrColor; 

其中高斯权重如下:

\[\begin{vmatrix} \cfrac{1}{16} & \cfrac{1}{8} & \cfrac{1}{16}\\ \cfrac{1}{8} & \cfrac{1}{4} & \cfrac{1}{8}\\ \cfrac{1}{16} & \cfrac{1}{8} & \cfrac{1}{16} \end{vmatrix} \]

在模板修复后,仍然有1像素厚的鬼影:

颜色历史是线性采样的,而模板历史是点采样的,边缘不匹配,解决方案是使模板缓冲区中的对象轮廓扩大1个像素。使用当前帧深度和模板缓冲作为输入创建全屏着色器,每个像素(p)将其深度与4个邻域像素(左上、右上、左下、右下)进行比较,忽视其它邻域像素。输出最接*深度的像素模板,将扩大的模板缓冲区添加到TAA输入,上一帧的模板应来自扩大版本,使用扩大版本进行模板测试,边缘检测使用未扩大版本。修复了模板标记对象边缘周围的轻微抖动,避免给草之类的东西标记,手动标记的一个原因,运动模糊也会隐藏鬼影。

对于屏幕外的像素,像素历史记录可能在上一帧离开屏幕,在着色器中检测并将blendFactor设置为1,与新发现的情况一致:没有历史,使用高斯模糊的当前颜色。

在1080p的PS4 GPU上,主着色器低于0.8ms,许多其它相关成本:运动矢量计算、锐化着色器(0.15毫秒)、扩大模板着色器(0.4ms)。TAA的其它收益:每像素采集多个样本的图形功能,将计算扩展到多个帧,使用TAA进行合并,将显示一个样本,但在多个样本中使用。

该文还谈及了将TAA用于RSM(Reflective Shadow Map,反射阴影图)的思想。将场景从手电筒透视渲染到256x256缓冲区,每个像素都被视为一个VPL(虚拟点光源),运行全屏着色器,其中每个像素由所有(65536)VPL照亮,但算法复杂度是O(n*m),成本太高了!神秘海域4将VPL缓冲区下采样至16x16,1/4宽x 1/4高的全屏着色器,但由此产生的结果过于模糊和模糊,VPL(256)不足导致失真。需要一个不那么暴力的解决方案,渲染分辨率无法承担,每像素随机采样16个VPL,相邻像素采样不同的16个VPL,每个64x64屏幕空间tile可以覆盖所有64k的VPL。使用PBR手册中描述的低差异序列,确保64x64的tile中没有两个像素使用相同的VPL,在64k中采样16次具有巨大的差异,导致严重的噪点。因此可引入时间采样,每个像素每帧采样不同的16个VPL,实际上有超过16个VPL,TAA将帧收敛到稳定图像,最佳部分–无需更改TAA着色器。

TAA还可用于屏幕空间反射、阴影模糊、屏幕空间环境遮挡、*相机透明度、LOD转换、一些头发透明。未解决的问题:不受支持的运动矢量,一些颗粒/水在没有TAA的情况下看起来更好;下采样和模糊不友好,无法让SSR在半分辨率内工作;TAA在较高的帧速率下工作得更好,收敛速度更快,瑕疵不那么明显。

Mixed Resolution Rendering in 'Skylanders: SuperChargers'讲述了游戏Skylanders: SuperChargers所使用的混合分辨率渲染的技术。首次尝试使用了单通道的混合分辨率,场景深度被下采样,然后根据低分辨率缓冲区进行光栅化,使用双边上采样对低分辨率渲染进行上采样,然后最终合成到场景缓冲区。

双边上采样的代码:

float4 vBilinearWeights = GetBilinearweights(vTexcoord);   float4 vSampleDepths = GetLowResolutionDepths(vrexcoord);  float vPixelDepth = GetHighResolutionDepth(vTexcoord);  float4 vDepthWeights = GetDepthsimilarity( vPixelDepth, vSampleDepths);   return vDepthWeights * vBilinearWeights; 

深度下采样时把最小值和最大值结合起来,因为它们处理的像素几乎是互斥的。事实证明,简单的事情有时也能奏效,只需在棋盘模式中交替使用最小值和最大值,这将为整个4x4像素块提供良好的深度表示。

Texture2D SourceDepthTexture; SamplerState PointSampler;  float main(float2 vTexcoord : TEXCOORD0, float2 vWindowPos : SV_Position) : SV_Target {     // Gather the 4 depth taps from the high resolution texture that cover this texel SourceDepthTexture.      float4 fDepthTaps = GatherRed(PointSampler, vTexcoord, 0);          // Identify the min and max depth out of the 4 taps       // NOTE:  It doesn't matter if your depth is negative or positive here       float fMaxDepth = max4( fDepthTaps.x, fDepthTaps.y, fDepthTaps.z, fDepthTaps.w);     float fMinDepth = min4( fDepthTaps.x, fDepthTaps.y, fDepthTaps.z, fDepthTaps.w);          // Classify the low resolution texel as either a max texel or min texel based on the window pos       return checkerboard( vWindowPos) > 0.5f ? fMaxDepth : fMinDepth; } 

由此产生的深度缓冲将高频深度不连续转化为棋盘格图案,正如从下图的拱门下的草叶中看到的那样。因此,需要一种评估它们的方法。

几种不同的深度下采样方法的像素误差值如下,可知min/max 方法误差最小:

原始参考和min/max的渲染对比:

双边上采样的模式如下:

然而第二次尝试有时结果看起来比决心更糟糕,样本是否有问题?是否使用点采样更佳?事实证明,在某些情况下,当将结果与一个简单的线性滤波进行比较时,如果没有深度权重,结果会出现一些奇怪的伪影,就可以判断出发生了什么。那么为什么会发生下图这种情况(双边过滤明显的锯齿)呢?这与双边上采样以及如何计算深度相似性有关。

深度相似度一般由下面的代码计算:

float4 GetDepthSimilarity( float fCenterDepth, float4 vSampleDepths) {     // fThreshold控制着深度相似度的强度     loat fScale = 1.0f / fThreshold;      float4 vDepthDifferences = abs(vSampleDepths - fCenterDepth);      return min(1.0f / (fScale * vDepthDifferences + fEpsilon) , 1.0f); } 

通常,通过确定高分辨率和低分辨率像素的深度差来计算深度相似性。该数字与阈值(fThreshold)一起用于确定深度是否相似。所以,如果选择一个小的阈值,最终会检测到假边缘(下图左)。类似地,如果阈值太大,则会丢失靠*的边(下图右)。

这是因为对双边加权使用了固定的深度偏差值。问题在于,随着表面逐渐退到屏幕中,单个像素步所代表的单位数增加了。因此,应该根据深度设置阈值。

文中改成使用*面被推回进场景的模型作为确定阈值的基础。给出单个像素的角度差和*面的斜率作为输入,以确保在前面保持线性混合。从而变成了一个简单的三角问题,可以得到结果,此外,还缩放该值以补偿这样一个事实——下采样时深度值可能来自2个像素之外。

改进后的阈值的效果和采样结果如下两图:


但是,下面是使用目标坡度阈值前后另一个问题的示例,图左所示得到的是水*过滤,而不是垂直过滤。

因此,第三次尝试有些影响看起来仍然很糟糕,有很多锯齿,透明模型更糟糕。为了减少边缘锯齿,采用了双通道方法方法:

对深度和颜色的边缘的检测采样了以下方法:

部分上采样步骤:

  • 第1个Pass:

    • 裁减边缘。
    • 在通道中设置模板位。
  • 第2个Pass:

    • 配置高分辨率模板(hi-stencil)。
    • 绘制全屏矩形以重新加载高分辨率模板。

第四次尝试得到的效果达到了发布水*,在360上运行的性能如下:

由于在游戏中使用了抖动衰减,在下图可以看到最小/最大缓冲区清楚地保持了正在淡出的树的抖动特性。

优势是有助于游戏的内容,屏幕外的渲染目标非常有用,性能可扩展。劣势是预乘的渲染目标,有限的混合模式,依赖高分辨率模板,最坏情况下的成本更高,高开销。

How we rethought driver abstraction谈及了如何更好地抽象和封装图形API。文中的抽象最初是基于DX9的轻量层,更新后与DX10匹配,抽象方法/调用,不是渲染过程本身。旧的渲染管线如下:

工作项(work item)是描述单个渲染工作所需的最少数据,如输入、输出、程序等,尽可能通用,不一定是GPU工作,完全独立。

资源可以是任何东西,是客户端代码的黑盒,可以在不同*台甚至不同运行有不同的实现,可能有实例,生命周期长,不一定存在于整个生命周期的内存中,资源的典型代表是纹理、着色器、模型等。实例是一种资源,是临时的,在客户端代码是黑盒,由与资源相同的代码处理,例如在当前帧中渲染的模型实例。句柄有设置(colour_handle, colour::red)、获取(colour_handle)等接口,在内部句柄是实例内存的偏移量,客户端访问资源和实例的唯一方法,如果给定实例或资源没有请求的参数,则为NOP,调试中有类型检测。

新的管线如下,增加了资源管理器,隔离开高级和低级渲染层,从而达到更好的解耦,并获得优化的机会:

总之,尽可能多地为底层代码提供上下文,不要在高层做出任何假设,保留所有选项,在不影响性能的情况下,不要害怕概括和抽象事物,API上的轻量级封装不再是最佳选择。

From Pixels to Reality – Thoughts on Next Game Engine谈及了2016年的游戏引擎的现状以及未来的趋势。2016年,主流游戏引擎的特点是多线程、多*台、现代图形API、延迟渲染、PBR、全局照明、动态环境及60FPS@1080P,当时不够好的点有锯齿、阴影、透明度、动画、加载时间、性能、内容制作等,新兴的技术包含基于社区的游戏、用户创建内容、移动端、VR/AR、电子竞技、广播等。电影和游戏的区别见下表:

电影 游戏
Reyes/Ray Tracing Direct 3D / OpenGL
物体空间着色 屏幕空间着色
大量可见性样本 少量可见性样本
照片级质量 吞吐量

在当时,REYES/Ray Tracing用于游戏的时机还不够成熟,因为基于GPU的REYES面临小三角形、场景复杂度、不够快等问题,而实时光追面临分辨率、多显示器、不够快等问题。更好的方法是借鉴和结合的思想。对象空间中的阴影=固有稳定,将可见性采样与着色分离=多速率,可见性缓冲区=减少内存和带宽。可能的渲染管线如下:

多速率可进一步地扩展到可见性样本、着色样本、照明样品、物理、人工智能、输入、光源传输更新等。在锯齿方面,镜面锯齿可用Lean Mapping[OlanoBaker 2010],阴影锯齿可用Frustum Traced Raster Shadows[WymanHoeltzleinLefohn15],半透明可用OIT。加载时间可用比zlib好2倍的压缩技术、程序化合成的SubstanceWang Tiles [Wang61] [Stam97] [Liyi04]。

云可支持巨大的世界、成千上万的玩家、微型客户端、内容制作、盒子里的工作室。亚马逊的云渲染GameLift架构图如下:

2018年的游戏引擎几乎没有锯齿、正确的空间、正确的频率、正确的位置、感知引导的“重要性”、程序化内容、云连接等。研究热点有程序化合成、压缩、三维扫描、感知科学、多速率渲染、动画、分布式的物理/人工智能/渲染等。

Building a Low-Fragmentation Memory System for 64-bit Games分享了游戏内的低碎片化的内存管理系统。旧的内存系统从PS3中移植而来,具有固定大小的内存池,模拟VRAM。存在的问题是浪费了很多内存,每个内存池都能应付最坏的情况,小型分配的开销,碎片化,无法支持纹理流。内存碎片是在小的非连续块中碎片化的堆,即使内存足够,分配也可能失败,由混合分配生命周期引起。

设计目标是低碎片、高利用率、简单配置、支持PlayStation®4操作系统和PC、支持高效的纹理流、全面的调试支持。

虚拟内存是在进程使用虚拟地址,是映射到物理地址的虚拟地址,CPU查找物理地址,需要操作系统和硬件支持。

虚拟内存可以减少内存碎片,碎片就是地址碎片,使用虚拟地址,虚拟地址空间大于物理地址空间,连续的虚拟内存在物理内存中不连续。内存页(Memory Page)在页面中映射,x64支持4kB和2MB页面,PlayStation4操作系统使用16kB(4x4kB)和2MB,GPU有更多的尺寸。2MB页面最快,16kB页面占用的内存更少,文中使用64kB(4x16kB页面),64kB是PlayStation 4 GPU的最小最佳尺寸,特殊情况也使用16kB。

洋葱总线(Onion Bus)和大蒜总线(Garlic Bus)都可以倍CPU和GPU访问,但它们的带宽不同,洋葱=快速CPU访问,大蒜=快速GPU访问。

文中的内存系统分割整个虚拟地址空间,按需映射物理内存,分配器模块管理自己的空间,每个模块都是专门的,分配器对象是系统的接口。

class Allocator { public:     virtual void* Allocate(size_t size, size_t align) = 0;     virtual void Deallocate(void* pMemory) = 0;     virtual size_t GetSize(void* pMemory) { return 0; }          const char* GetName(void) const; };  // 示例 void* GeneralAllocator::Allocate(size_t size, size_t align) {     if (SmallAllocator::Belongs(size, align))         return SmallAllocator::Allocate(size, align);     else if (m_mediumAllocator.Belongs(size, align))         return m_mediumAllocator.Allocate(size, align);     else if (LargeAllocator::Belongs(size, align))         return LargeAllocator::Allocate(size, m_mappingFlags);     else if (GiantAllocator::Belongs(size, align))         return GiantAllocator::Allocate(size, m_mappingFlags);          return nullptr; } 

虚拟地址空间:

小型分配模块:大多数分配小于等于64字节,约25万个分配,约25M。打包以防止碎片,大小相同的16kB页面,没有头信息。

小型分配模块的利弊,+轻量实现,+非常低的浪费,+利用灵活的内存,+快速,-难以检测的内存瓶颈。

大型分配模块保留巨大的虚拟地址空间(160GB),每个表被分成大小相等的插槽,按需映射和取消映射64kB页面,保证连续内存。

纹理流预留大的分配槽,四舍五入到最*的2的N次方,加载最小mip和64kB的最大值,按需映射和取消映射页面,无需复制或整理碎片。大型分配模块的利弊,+没有头信息,+简单的实现(大约200行代码),+没有碎片,-大小四舍五入到页面大小,-映射和取消映射内核调用相对较慢。

中型分配模块应对中等尺寸的分配,无头信息,除了大型和小型之外其它尺寸都在这里分配,非连续虚拟页,增长和收缩,传统的带头信息的双链表,不适合大蒜(GPU)的内存(与数据一起存储的头),Pow2自由列表。

无头分配模块(Headerless Allocation Module)用于GPU分配,中小型分配,哈希表查找。

分配器类型包含GeneralAllocator、VramAllocator、MappedAllocator、GpuScratchAllocator、FrameAllocator等。GPU暂存分配器(GpuScratchAllocator)是渲染器用于每帧分配,双缓冲,不需要释放分配,受原子保护。GpuScratchAllocator的优缺点:+没有头或账单,+没有碎片,+快速,-固定尺寸,-最糟糕的情况是对齐浪费空间。帧分配器(FrameAllocator),帧被推入和弹出,不需要释放内存,每个线程都是唯一的,适用于临时工作缓冲区。

#include <ls_common/memory/ScratchMem.h>  struct Elem {     … };      void ProcessElements(size_t numElements) {     ls::ScratchMem frame;     Elem* pElements = (Elem*)MM_ALLOC_P(&frame, sizeof(Elem) * numElements); } 

FrameAllocator的优缺点:+没有头或账单,+没有碎片,+没有同步,+快速,-小心指针传递!

线程安全:最低级别的互斥体,分配器实例不受保护,帧分配器没有锁,又好又简单。

性能不是重点,但仍然很重要,映射/取消映射速度慢,没有明显的区别,游戏期间不要太多分配,文件加载是一个瓶颈。内存的清理值有以下几种(memset的字节值,保持可读可记):

0xFA: Flexible memory allocated 0xFF: Flexible memory free 0xDA: Direct memory allocated 0xDF: Direction memoryfree 0xA1: Memory allocated 0xDE: Memory deallocated 

统计信息,追踪一切可能的事情,实时图表可用,通过自动测试记录。内存头保护,中型分配头中的空闲字节,检测内存瓶颈,但往往为时已晚。内存块哨兵,绕过正常分配器,每个分配都在自己的页面中,前后未映射的页面,写得太多/太少时崩溃。

总结:现代控制台具有丰富的虚拟内存支持,虚拟内存提供了许多选项,围绕分配模式设计内存系统,分析很重要,小型分配是一个良好的开端,模块化分配器使定制变得容易!调试功能非常重要!

The devil is in the details: idTech 666讲述了idTech引擎的渲染管线的渲染技术和性能优化。当时的idTech的渲染管线流程和性能数据如下:

其分簇光照系统衍生自“Clustered Deferred and Forward Shading” [Olson12]和“Practical Clustered Shading” [Person13],可工作于透明表面,不需要额外的通道或工作,独立于深度缓冲区,深度不连续处无误报。分簇光照步骤如下:

  • 体素化/光栅化处理。在CPU上完成,每个深度片1个作业。

  • 对数深度分布。扩大**面和远*面:\(\text{ZSlice}=\text{Near}_z\times \Big(\cfrac{\text{Far}_z}{\text{Near}_z}\Big)^\frac{\text{slice}}{\text{numSlices}}\)。

  • 对每个项目进行体素化,项目可以是灯光、环境探针或贴花,项目形状是OBB或*截体(投影者),由屏幕空间\(\min_{xy}\)、\(\max_{xy}\)和深度边界的光栅化来界定。

  • 在剪辑空间中进行细化。剪辑空间中的单元格是AABB,N个*面和单元格AABB,OBB是6个*面,*截体是5个*面,所有体积的代码都相同,使用SIMD。

    //Pseudo-code - 1 job per depth slice ( if any item ) for (y=MinY; y<MaxY; ++y)      {     for (x =MinX; x<MaxX; ++x)      {         intersects = N planes vs cell AABB         if (intersects)          {             Register item         }     }     } 

细节化世界的技术:虚拟纹理更新;反照率、镜面反射、*滑度、法线、HDR光照贴图,硬件sRGB支持,将Toksvig烘焙成*滑度,用于镜面抗锯齿;直接将UAV输出回读至最终分辨率;异步计算转码,成本几乎无关紧要;设计缺陷依然存在,例如反应性纹理流=纹理弹出;嵌入几何栅格化的贴花;实时替换到Mega纹理的压印(Stamping),更快的工作流程/更少的磁盘存储;法线贴图混合、所有通道的线性正确混合、mipmap/各向异性、透明度、排序、0个绘制调用、使用BC7的8k x 8k贴花图集;还有盒子投影、索引到贴花图集,由艺术家手工放置,包括混合设置,“混合层”的推广;每个视锥视图限制为4k,通常1k或更少可见;LOD化,艺术设置最大视距,玩家质量设置也会影响观看距离;研究动态不可变形几何体,将对象变换应用于贴花。

在光照方面,使用单一/统一照明代码路径,对于不透明通道、延迟、透明和解耦粒子照明,没有着色器排列,现在静态/连贯分支非常好——尽情使用它!对所有静态几何体使用相同的着色器,减少上下文切换。光照组件包含:漫反射间接照明——用于静态几何体的光照贴图,用于动态的辐照度体积,镜面间接照明——反射(环境探针、SSR、镜面遮挡),用于动态的灯光和阴影。

// Pseudocode ComputeLighting( inputs, outputs )  {     Read & Pack base textures      for each decal in cell      {         early out fragment check         Read textures         Blend results     }          for each light in cell      {         early out fragment check         Compute BRDF / Apply Shadows         Accumulate lighting     } } 

阴影被缓存/打包到图集中,PC用32位的8k x 8k的图集(高规格),控制台用16位的8k x 4k,基于距离的可变分辨率,时间切片也基于距离,静态几何优化网格。如果光源不移动,缓存静态几何体阴影贴图,如果frustum内部没有更新则跳过,如果没有更新,用缓存结果组合动态几何体,仍然可以使用动画(例如闪烁),艺术设置/质量设置会影响以上所有内容。索引到阴影截锥投影矩阵,所有灯光类型的PCF查找代码相同,减少VGPR压力,包括*行光级联,级联之间使用抖动,单级级联查找,尝试VSM及其导数,有一些瑕疵,从概念上讲,对前向渲染有很好的发展潜力,例如将过滤频率与光栅化分离。

光照时,注意VGPR压力,打包寿命长的数据,例如float4表示HDR颜色 <--> RGBE编码的uint,最小化寄存器生命周期,最小化嵌套循环/最坏情况路径,最小化分支,控制台上56个VGRS(PS4),由于编译器效率低下,在PC上更高(@AMD编译器团队,漂亮的plz修复程序-抛出性能)。未来使用半精度支持将有所帮助,Nvidia:使用UBOs/常量缓冲区(所需的分区缓冲区=更多/丑陋的代码),AMD:优先SSBO/UAV。

粗糙玻璃*似:最高mip为半分辨率,总共4个mip,高斯核(*似GGX瓣),基于表面*滑度的混合mips,折射传输限制为每帧2次,以提高性能,通过贴花实现表面参数化/变化。

对于后处理,通过以下方法优化了数据读取:非分歧运算的GCN标量单位,非常适合加速数据获取,节省VGRP,一致性分支,指令更少(SMEM:64字节,VMEM:16字节)。分簇着色用例,每个像素从其所属单元获取灯光/贴花,本质上是不同的,但值得分析。

大多数wavefront只能进入一个cell,附*的cell共享大部分内容,线程主要获取相同的数据。每线程单元数据获取不是最优的,不利用这种数据融合。合并单元格内容上可能的标量迭代,不要让所有线程独立地获取完全相同的数据。

利用访问模式:对于数据,每个单元格的项目(灯光/贴花)ID排序数组,同样的结构适用于灯光和贴花处理,每个线程可能访问不同的节点,每个线程在这些数组上独立迭代。标量加载用序列化迭代,计算所有线程中的最小项目ID值,ds_swizzle_b32/小型位置不一致,与所选索引匹配的线程的进程项,统一索引->标量指令,匹配的线程移动到下一个索引。

特殊路径:如果只接触一个单元格,则为快速路径,避免计算最小的物品ID,在GCN1和2上不便宜,一些额外的(次要的)标量获取和操作,序列化假定线程之间存在局部性,如果接触过多的单元格,速度会明显减慢,禁用粒子照明图集生成。

动态分辨率缩放:基于GPU负载的自适应分辨率,PS4上的比例大多为100%,Xbox上的比例更大,在同一目标中渲染,调整视口大小
侵入式:需要额外的着色器代码,OpenGL上的唯一选项。未来:重叠多个渲染目标,可能在控制台和Vulkan上,TAA可以收集不同分辨率的样本,异步计算中的上采样。

异步后处理:阴影和深度过程几乎不使用计算单位,固定的图形管线繁重。不透明通道也不是100%忙,可以与后处理重叠,在特效队列上的预乘Alpha的缓冲区中渲染GUI,计算队列上处理后处理/AA/upsample/compose UI,与第N+1帧的阴影/深度/不透明重叠,从计算队列中显示(如果可用),潜在更低的延迟。

此外,文中还涉及了GCN架构上的特定优化。未来的工作是解构频率的消耗以获得收益,改进纹理质量、全局照明、整体细节、工作流程等。

[Top five technologies to watch: 2017 to 2021](https://www.bizcommunity.com/Article/196/706/154305.html#:~:text= Top five technologies to watch%3A 2017 to,commonplace%2C while VR will remains niche. More)阐述了2017到2021的技术趋势和新兴的技术。五项技术将帮助我们获得洞察力:

最重要的是人工智能与认知技术。以下五大技术支持快速互联:

包含移动边缘计算、cloudlets、fog计算、工业互联网、SDN和OpenNFV。Edge computing将全面帮助人们,但需要更高层次的技术。

The Latest Graphics Technology in Remedy's Northlight Engine讨论了Remedy的Northlight引擎中渲染技术的引擎内实现和一些最新进展的结果,包含DirectX光线追踪、区域阴影、环境遮挡、反射、间接漫反射等。

实时光线追踪中的基于可见性的算法图例如下:

在AO上,SSAO和光追AO的对比如下:

而光追AO在不同的rpp上,效果也有所不同:

反射上,SSR和光追反射也有明显的区别:

对于间接漫反射,与AO类似,许多非相干光线,GI存储在稀疏网格体积中。基于静态几何图形和静态灯光集计算的辐照度,动态几何体可以接收光,但不影响计算的辐照度,动态几何没有贡献,三线性采样创建阶梯样式,薄几何体会导致光照泄漏,通过采样余弦分布上的辐射来收集照明,考虑丢失的几何图形。

我们还可以在几何体交点上采样照明,最终的光追各分量和组合效果如下:


总之,通过DXR轻松访问最先进的GPU光线跟踪,性能正在达到目标,易于不适合光栅化的原型化算法,可与现有低频结构相结合。

Advanced Graphics Techniques Tutorial: GPU-Based Clay Simulation and Ray-Tracing Tech in 'Claybook'阐述了Second Order公司的第一款游戏《Claybook》包含了大量创新技术,包括基于GPU的粘土和流体模拟器、完全可变形的世界和角色、有向距离场建模和光线跟踪视觉效果。并讨论这项技术的产生、优化和技巧才能在当前一代游戏机上用游戏的光线跟踪视觉效果和模拟达到60 fps,以及他们的非标准渲染器和模拟技术是如何集成在虚幻引擎4之上的。

有向距离场(SDF):SDF(P)=到P处最*表面的有符号距离,有解析距离函数和体积纹理两种形式。解析距离函数在场景demo中流行,巨大的着色器,很多数学知识,没有数据。体积纹理存储距离函数,三线性滤波器,Claybook将体积纹理与mip贴图结合使用。Claybook的世界SDF的分辨率=1024x1024x512,格式=8位有符号,大小=586 MB(5 mip级别),[-4,+4]体素的距离,256个值/8个体素,1/32体素精度,每mip级别最大步进(世界空间)翻倍。

在GPU上生成世界SDF的步骤:

  • 生成SDF笔刷网格。64x64x32的dispatch,4x4x4的线程组。

    • 在分块中心T处采样刷子体积。如果SDF>光栅分块边界+4个体素,则剔除。如果接受,原子添加+存储到GSM。
    • 通过GSM中的笔刷循环。细胞中心C处的样本[i],如果接受,存储到网格(线性),局部+全局原子压缩。

  • 生成调度坐标。64x64x32的调度,4x4x4的线程组。

    • 读取刷子网格单元。
    • 如果不是空的:原子加法(L+G)得到写索引,将单元格坐标写入缓冲区。

  • 生成Mip掩码。4x调度(mips),4x4x4的线程组。

    • 分组:加载1个更宽的体素网格L-1邻域,下采样1=0掩码并存储到GSM。
    • 将掩码放大1个体素(3x3x3)。
    • 掩码=0则写入网格单元坐标。
  • 在8x8x8的tile中生成0级(稀疏)。间接调度,8x8的线程组。

    • 分组:读取网格单元坐标(SV_GroupId)。
    • 从网格读取笔刷并存储到GSM。
    • 通过GSM中的笔刷循环,采样[i],执行exp*滑最小/最大操作。
    • 将体素写入WorldSDF的级别0。
  • 生成mips(稀疏)。4倍间接调度(mips),8x8的线程组。

    • 分组:加载更宽的L-1邻域的4个体素。2x2x2下采样(*均值)并在GSM中存储为\(12^3\),+-4体素带变成+-2体素阶(band)。
    • 分组:在GSM中运行3步eikonal方程(下图),扩展阶:2个体素变成4个体素。
    • 存储8x8x8邻域的中心。

球体追踪算法:

  • D = SDF(P)。
  • P += ray * D。
  • if (D < epsilon) break。

多层体纹理跟踪:

Loop     D = volume.SampleLevel(origin + ray*t, mip)     t += worldDistance(D, mip)          IF D == 1.0 -> mip += 2     IF D <= 0.25 -> mip -= 2; D -= halfVoxel     IF D < pixelConeWidth * t -> BREAK // 如果曲面位于像素内边界圆锥体内,则中断,获得完美的LOD! 

最后一步:球体追踪需要无限步才能收敛,假设我们碰到一个*面,三线性过滤=分段线性曲面,几何级数,使用最后两个样本,\(Step = D/(1-(D-D_{-1}))\)。

锥体追踪解析解:

粗糙锥体追踪Prepass:

光线追踪结果:锥形追踪跳过大面积的空白空间,大幅缩短步长,体积采样更多缓存局部性。Mip映射改善缓存的局部性,Log8数据缩放:100%、12.5%、1.6%、0.2%、...测量(1080p渲染),访问8MB数据(512MB),99.85%的缓存命中率。存在的问题有:过步进(Overstepping)、加载*衡等,它们有各自的缓解方案。

对于AO,在表面法线方向构造圆锥体,加上随机变化+时间累积,AO射线使用低SDF mip,更好的GPU缓存位置和更少的带宽,软远程AO。Claybook也使用UE4 SSAO,小规模(*)的环境遮挡。

上:SSAO;下:SSAO + RTAO。

软阴影球体跟踪:柔和的半影扩大阴影,沿光线步进SDF*似最大圆锥体覆盖率,Demoscene圆锥体覆盖*似:

c = min(c, light_size * SDF(P) / time); 

Claybook对软阴影的改进:三角测量最*距离,Demoscene=单个样本(最小),三角测量cur和prev样本,更少条带。抖动阴影光线,UE4时间累积,隐藏剩余的带状瑕疵,较宽的内半影。

改进前后对比:

光追的各项时间消耗:

SDF到网格的转换:双通道*似,多个三角形指向同一个粒子,首先需要生成粒子。输出用于PBD模拟器的线性粒子数组(表面)和三角形渲染的索引缓冲区。使用单个间接绘制调用绘制的所有网格。转成粒子使用64x64x64的dispatch、4x4x4的线程组,过程如下:

  • 分组:将\(6^3\)个SDF邻域加载到GSM。
  • 读取\(2^3\) GSM的邻域,如果在边缘内/外找到:
    • 将P移动到表面(梯度下降)。
    • 分配粒子id(L+G原子)。
    • 将P写入数组[id]。
    • 将粒子id写入\(64^3\)网格。

转成三角形使用64x64x64的dispatch、4x4x4的线程组,过程如下:

  • 分组:将\(6^3\)个SDF邻域加载到GSM。
  • 读取\(2^3\) GSM的邻域,如果找到XYZ边缘:
    • 每个XYZ边分配2倍三角形(L+G原子)。
    • 从\(64^3\)的id网格读取3倍的粒子id。
    • 将三角形写入索引缓冲区(3倍的粒子id)。

异步计算:

  • 将帧拆分为3个异步段。
    • 重叠UE4的GBuffer和阴影级联。
    • 重叠UE4的速度渲染和深度解压缩。
    • 重叠UE4的照明和后处理。
  • 工作立即提交。
    • 计算队列等待栅栏启动(x3)。
    • 主队列等待栅栏继续(x3)。

异步计算可以让fps提升19%+。

集成到UE4渲染器:

  • GBuffer组合。
    • 全屏PS组合光线追踪数据。
    • 采样材质贴图(自定义gather4过滤)。
    • 写入UE4的GBuffer+深度缓冲区(SV_Depth)。
  • 阴影遮蔽(shadow mask)组合。
    • 全屏PS到球体追踪阴影。
    • 写入UE4阴影遮罩缓冲区(使用alpha混合)。

UE4 RHI定制:

  • 在不进行隐式同步的情况下设置渲染目标。
    • 可以对重叠深度/颜色进行解压缩。
    • 可以将绘制重叠到多个RT(下图)。
  • 清除RT/buffer而不进行隐式同步。
  • 缺少异步计算功能。
    • 缓冲区/纹理复制并清除。
  • 计算着色器索引缓冲区写入。

UE4 RHI定制(额外):GPU->CPU缓冲区回读,UE4仅支持2d纹理回读而不停顿,其它readback API会让整个GPU陷入停顿,缓冲区可以有原始视图和类型化视图,宽原始写入=高效填充窄类型缓冲区。

UE4优化:允许间接分派/提取的重叠,允许清除和复制操作重叠,允许不同RT的绘制重叠,减少GPU缓存刷新和停顿(下图),优化的暂存缓冲区,快速清晰的改进。优化屏障和栅栏,优化纹理数组子资源屏障,更好的3d纹理GPU分块模式,改进的部分2d/3d纹理更新,5倍更快的直方图+眼睛适应着色器,4倍更快的离线CPU SDF生成器(烘焙)。

实现说明:物理数据存储在一个大的原始缓冲区中,宽加载4/Store4指令(16字节),位压缩:粒子位置:16位范数、粒子速度:fp16、粒子标志(活动、碰撞等)的位字段,基准工具:https://github.com/sebbbi/perftest。Groupshared内存是一个巨大的性能利器,SDF生成、网格生成、物理,重复加载相同数据时使用。标量加载是AMD在性能上的一大胜利,用例:常量索引原始缓冲区加载,用例:基于SV_GroupID的原始缓冲区加载,存储到SGPR的负载获得更好的占用率。

Entity-Component Systems & Data Oriented Design In Unity分享了Unity的ECS和面向数据设计的技术,包含面向对象设计、面向数据的设计、实体组件系统、实践案例等。OO的典型实现:类层次结构,虚拟函数,封装经常被违反,因为东西需要知道,“一次只做一件事”的方法,迟来的决定。简单OO组件系统:

// Component base class. Knows about the parent game object, and has some virtual methods. class Component { public:     Component() : m_GameObject(nullptr) {}     virtual ~Component() {}     virtual void Start() {}     virtual void Update(double time, float deltaTime) {}     const GameObject& GetGameObject() const { return *m_GameObject; }     GameObject& GetGameObject() { return *m_GameObject; }     void SetGameObject(GameObject& go) { m_GameObject = &go; } private:     GameObject* m_GameObject; };  class GameObject { public:     GameObject(const std::string&& name) : m_Name(name) { }     ~GameObject()      {          for (auto c : m_Components)              delete c;      }     // get a component of type T, or null if it does not exist on this game object     template<typename T> T* GetComponent()     {         for (auto i : m_Components)          {              T* c = dynamic_cast<T*>(i);              if (c != nullptr)                  return c;          }         return nullptr;     }     // add a new component to this game object     void AddComponent(Component* c)     {         c->SetGameObject(*this);          m_Components.emplace_back(c);     }     void Start()      {          for (auto c : m_Components)              c->Start();      }     void Update(double time, float deltaTime)      {          for (auto c : m_Components)              c->Update(time, deltaTime);      }      private:     std::string m_Name;     ComponentVector m_Components; };  // Utilities // Finds all components of given type in the whole scene template<typename T> static ComponentVector FindAllComponentsOfType() {     ComponentVector res;     for (auto go : s_Objects)     {         T* c = go->GetComponent<T>();         if (c != nullptr) res.emplace_back(c);     }     return res; } // Find one component of given type in the scene (returns first found one) template<typename T> static T* FindOfType() {     for (auto go : s_Objects)     {         T* c = go->GetComponent<T>();         if (c != nullptr) return c;     }     return nullptr; }  // various components  // 2D position: just x,y coordinates struct PositionComponent : public Component {     float x, y; }; // Sprite: color, sprite index (in the sprite atlas), and scale for rendering it struct SpriteComponent : public Component {     float colorR, colorG, colorB;     int spriteIndex;     float scale; };  // Move around with constant velocity. When reached world bounds, reflect back from them. struct MoveComponent : public Component {     float velx, vely;     WorldBoundsComponent* bounds;     MoveComponent(float minSpeed, float maxSpeed)     {         /* … */     }     virtual void Start() override     {         bounds = FindOfType<WorldBoundsComponent>();     }     virtual void Update(double time, float deltaTime) override     {         /* … */     } };  // components logic virtual void Update(double time, float deltaTime) override {     // get Position component on our game object     PositionComponent* pos = GetGameObject().GetComponent<PositionComponent>();     // update position based on movement velocity & delta time     pos->x += velx * deltaTime;     pos->y += vely * deltaTime;     // check against world bounds; put back onto bounds and mirror     // the velocity component to bounce back     if (pos->x < bounds->xMin) { velx = -velx; pos->x = bounds->xMin; }     if (pos->x > bounds->xMax) { velx = -velx; pos->x = bounds->xMax; }     if (pos->y < bounds->yMin) { vely = -vely; pos->y = bounds->yMin; }     if (pos->y > bounds->yMax) { vely = -vely; pos->y = bounds->yMax; } }  // game update loop void GameUpdate(sprite_data_t* data, double time, float deltaTime) {     // go through all objects     for (auto go : s_Objects)     {         // Update all their components         go->Update(time, deltaTime);         // For objects that have a Position & Sprite on them: write out         // their data into destination buffer that will be rendered later on.         PositionComponent* pos = go->GetComponent<PositionComponent>();         SpriteComponent* sprite = go->GetComponent<SpriteComponent>();         if (pos != nullptr && sprite != nullptr)         {             /* … emit data for sprite rendering … */         }     } } 

OO设计的问题:将代码放在哪里?游戏中的许多系统不属于“一个对象”,例如碰撞、损坏、AI:在2+个物体上工作。游戏中的“精灵避免泡泡”:把回避逻辑放在回避某事的事情上?把回避逻辑放在应该避免的事情上?其它地方?许多语言都是“单一分派”,有一些对象和方法可以使用它们,但我们需要的是“多次派遣”,回避系统对两组物体起作用。

OO设计的问题:很难知道什么是什么。有没有开过Unity项目并试图弄清楚它是如何工作的? “游戏逻辑”分散在数百万个组件中,没有概述。

OO设计的问题:“凌乱的基类”问题:

EntityType entityType() const override;  void init(World* world, EntityId entityId, EntityMode mode) override; void uninit() override;  Vec2F position() const override; Vec2F velocity() const override;  Vec2F mouthPosition() const override; Vec2F mouthOffset() const; Vec2F feetOffset() const; Vec2F headArmorOffset() const; Vec2F chestArmorOffset() const; Vec2F legsArmorOffset() const; Vec2F backArmorOffset() const;  // relative to current position RectF metaBoundBox() const override;  // relative to current position RectF collisionArea() const override;  void hitOther(EntityId targetEntityId, DamageRequest const& damageRequest) override; void damagedOther(DamageNotification const& damage) override;  List<DamageSource> damageSources() const override;  bool shouldDestroy() const override; void destroy(RenderCallback* renderCallback) override;  Maybe<EntityAnchorState> loungingIn() const override; bool lounge(EntityId loungeableEntityId, size_t anchorIndex); void stopLounging();  float health() const override; float maxHealth() const override; DamageBarType damageBar() const override; float healthPercentage() const;  float energy() const override; float maxEnergy() const; float energyPercentage() const; float energyRegenBlockPercent() const;  bool energyLocked() const override; bool fullEnergy() const override; bool consumeEnergy(float energy) override;  float foodPercentage() const;  float breath() const; float maxBreath() const;  void playEmote(HumanoidEmote emote) override;  bool canUseTool() const;  void beginPrimaryFire(); void beginAltFire();  void endPrimaryFire(); void endAltFire();  void beginTrigger(); void endTrigger();  ItemPtr primaryHandItem() const; ItemPtr altHandItem() const;  // … etc. 

OO设计的问题:性能。100万个精灵,20个泡泡:330ms游戏更新,470ms启动时间,分散在内存中的数据,虚拟函数调用。

OO设计的问题:内存使用。100万个精灵,20个泡泡:310MB内存使用率,每个组件都有指向游戏对象的指针,但很少有人需要它,每个组件都有一个指向虚拟函数表的指针,每个游戏对象/组件都是单独分配的。典型内存视图:

OO设计的问题:可优化。如何多线程化?如何跑在GPU?在很多OO设计中非常难,没有清晰的谁读取什么数据和写入什么数据。

OO设计的问题:可测试性。将如何为此编写测试?OO设计通常需要大量的设置/模拟/伪造来进行测试,创建对象层次结构、管理器、适配器、单例…

CPU性能趋势:

CPU-内存性能差距:

计算机中的延迟数据:

  • 从CPU一级缓存读取:0.5ns。
  • 分支预测失败:5ns。
  • 从CPU二级缓存读取:7ns。
  • 从RAM读取:100ns。
  • 从SSD读取:150000ns。
  • 从RAM读取1MB:250000ns。
  • 发送网络数据包CA->NL->CA:150000000ns。

代码和数据需要结合在一起吗?典型的OO将代码和数据放在一个类中,为什么呢?回忆“代码放在哪里”的问题:

// this? class ThingThatAvoids {     void AvoidOtherThing(ThingToAvoid* thing); }; // or this? class ThingToAvoid {     void MakeAvoidMe(ThingThatAvoids* who); };  // why not this instead? does not even need to be in a class void DoAvoidStuff(ThingThatAvoids* who, ThingToAvoid* whom); 

数据优先:“所有程序及其所有部分的目的都是将数据从一种形式转换为另一种形式”,“如果你不了解数据,你就不了解问题所在”——迈克·阿克顿。这是尼克劳斯·沃思1976年的一本经典著作。有人可能会说,“数据结构”也许应该是第一位的,请注意,它根本不谈论“对象”!

有一个,就有很多。你多久吃一次某样东西?在游戏中,最常见的情况是:有几件事,任何代码都可以在这里使用,事情太多了,必须小心性能。

virtual void Update(double time, float deltaTime) override {     /* move one thing */ }  // 将上面的改成下面  void UpdateAllMoves(size_t n, GameObject* objects, double time, float deltaTime) {     /* move all of them */ } 

面向数据的设计(DOD):理解数据解决这个问题所需的理想数据是什么?它是怎么布置的?谁读什么,谁写什么?数据中的模式是什么?常见情况下的设计,很少有“一”的东西,为什么你的代码一次只处理一件事?DOD相关资源:

传统的Unity GO/组件设置是ECS吗?传统的Unity设置使用组件,但不使用ECS。组件解决了“来自地狱的基类”问题的一部分,但不能解决其它问题:逻辑、数据和代码流难以推理,一次只对一件事执行逻辑(更新等),在一个类型/类中(“代码放在哪里”问题),内存/数据局部性不是很好,一堆虚调用和指针。

实体组件系统(ECS):实体只是一个标识符,有点像数据库中的“主键”?是的。组件就是数据。系统是在具有特定组件集的实体上工作的代码。

ECS/DOD样例:回忆一下简单的“游戏”:400行代码,100万个精灵,20个泡泡:330毫秒更新时间,470ms启动时间,310MB内存占用。

第1步:纠正愚蠢。GetComponent在GO each和每一次中搜索组件,我们可以找到一次并保存它!330毫秒→ 309ms。

GetComponent在Avoid组件的内部循环中,也缓存它。309ms → 78ms。

现在时间花在哪里?使用探查器(Mac上是Xcode Instruments):

让我们做一些系统:AvoidanceSystem,避免和避免现在这些组件几乎只是数据,系统知道它将操作的所有东西。

struct AvoidThisComponent : public Component {     float distance; }; // Objects with this component avoid objects with AvoidThis component. struct AvoidComponent : public Component {     virtual void Start() override; };  // Avoidance system works out interactions between objects that have AvoidThis and Avoid // components. Objects with Avoid component: // - when they get closer to AvoidThis than AvoidThis::distance, they bounce back, // - also they take sprite color from the object they just bumped into struct AvoidanceSystem {     // things to be avoided: distances to them, and their position components     std::vector<float> avoidDistanceList;     std::vector<PositionComponent*> avoidPositionList;     // objects that avoid: their position components     std::vector<PositionComponent*> objectList;          void UpdateSystem(double time, float deltaTime)     {         // go through all the objects         for (size_t io = 0, no = objectList.size(); io != no; ++io)         {             PositionComponent* myposition = objectList[io];             // check each thing in avoid list             for (size_t ia = 0, na = avoidPositionList.size(); ia != na; ++ia)             {                 float avDistance = avoidDistanceList[ia];                 PositionComponent* avoidposition = avoidPositionList[ia];                 // is our position closer to thing to avoid position than the avoid distance?                 if (DistanceSq(myposition, avoidposition) < avDistance * avDistance)                 {                        /* … */                 }             }         }     } }; 

以上是系统的逻辑代码。78ms → 69ms。类似的,让我们做一个移动系统:MoveSystem。

// Move around with constant velocity. When reached world bounds, reflect back from them. struct MoveComponent : public Component {     float velx, vely; };  struct MoveSystem {     WorldBoundsComponent* bounds;     std::vector<PositionComponent*> positionList;     std::vector<MoveComponent*> moveList;          void UpdateSystem(double time, float deltaTime)     {         // go through all the objects         for (size_t io = 0, no = positionList.size(); io != no; ++io)         {             PositionComponent* pos = positionList[io];             MoveComponent* move = moveList[io];             // update position based on movement velocity & delta time             pos->x += move->velx * deltaTime;             pos->y += move->vely * deltaTime;             // check against world bounds; put back onto bounds and mirror the velocity component to bounce back             if (pos->x < bounds->xMin) { move->velx = -move->velx; pos->x = bounds->xMin; }             if (pos->x > bounds->xMax) { move->velx = -move->velx; pos->x = bounds->xMax; }             if (pos->y < bounds->yMin) { move->vely = -move->vely; pos->y = bounds->yMin; }             if (pos->y > bounds->yMax) { move->vely = -move->vely; pos->y = bounds->yMax; }     } }; 

以上是移动系统的逻辑,69ms→ 83ms, 什么!!!???再次分析之:

迄今为止的经验教训:由于意想不到的原因,优化一个地方可能会让事情变得更慢,乱序的CPU、缓存、预取等等。C++RTTI(dynamic_cast)可以非常慢,我们在GameObject::GetComponent中使用它。

// get a component of type T, or null if it does not exist on this game object template<typename T> T* GetComponent() {     for (auto i : m_Components)      {          T* c = dynamic_cast<T*>(i);          if (c != nullptr)              return c;      }     return nullptr; } 

那就停止使用C++RTTI吧。如果有一个“类型”枚举,并且每个组件都存储了类型…83毫秒→ 54毫秒!!

enum ComponentType {     kCompPosition,     kCompSprite,     kCompWorldBounds,     kCompMove,     kCompAvoid,     kCompAvoidThis, }; // ... ComponentType m_Type;  // was: T* c = dynamic_cast<T*>(i); if (c != nullptr) return c; if (c->GetType() == T::kTypeId) return (T*)c; 

到目前为止,更新性能:提高6倍(330毫秒)→54毫秒)!内存使用:增加310MB→363MB,组件指针缓存,在每个组件中键入ID…代码行:400多行→500,让我们试着移除一些东西!

Avoid和AvoidThis组件,谁需要它们?没错,没有人,直接用AvoidanceSystem注册对象即可。54毫秒→ 46ms,363MB→325MB,500→455行。

实际上,谁需要组件层次结构?只需在GameObject中包含组件字段。46毫秒→43ms更新,398→112ms启动时间,325MB→218MB,455→350行。

// each object has data for all possible components, // as well as flags indicating which ones are actually present. struct GameObject {     GameObject(const std::string&& name)         : m_Name(name), m_HasPosition(0), m_HasSprite(0), m_HasWorldBounds(0), m_HasMove(0) { }     ~GameObject() {}          std::string m_Name;     // data for all components     PositionComponent m_Position;     SpriteComponent m_Sprite;     WorldBoundsComponent m_WorldBounds;     MoveComponent m_Move;     // flags for every component, indicating whether this object has it     int m_HasPosition : 1;     int m_HasSprite : 1;     int m_HasWorldBounds : 1;     int m_HasMove : 1; }; 

停止分配单个游戏对象,vector<GameObject*>vector<GameObject>,43ms更新,112→99ms启动,218MB→203MB。

典型布局是结构数组(AoS):一些对象,以及它们的数组。易于理解和管理,太好了…如果我们需要每个物体的所有数据。

// structure struct Object {     string name;     Vector3 position;     Quaternion rotation;     float speed;     float health; }; // array of structures vector<Object> allObjects; 

数据在内存中是什么样子的?

struct Object // 60 bytes: {     string name; // 24 bytes     Vector3 position; // 12 bytes     Quaternion rotation; // 16 bytes     float speed; // 4 bytes     float health; // 4 bytes }; 

如果我们不需要所有数据呢?如果我们的系统只需要物体的位置和速度…嘿,CPU,读第一个物体的位置(下图上)!当然,就在这里…让我帮你从内存中读取整个缓存行(下图下)!

如果系统只需要物体的位置和速度…却最终从内存中读取了一切,但每个对象只需要60个字节中的16个字节,多达74%的内存流量浪费!

翻转:数组结构(SoA)。每个数据成员都有单独的数组,数组需要保持同步, “对象”不再存在;通过索引访问的数据。

// structure of arrays struct Objects {     vector<string> names; // 24 bytes each     vector<Vector3> positions; // 12 bytes each     vector<Quaternion> rotations; // 16 bytes each     vector<float> speeds; // 4 bytes each     vector<float> healths; // 4 bytes each }; 

数据在内存中是什么样子的?

struct Objects {     vector<string> names; // 24 bytes each     vector<Vector3> positions; // 12 bytes each     vector<Quaternion> rotations; // 16 bytes each     vector<float> speeds; // 4 bytes each     vector<float> healths; // 4 bytes each }; 

在SoA中读取部分数据:如果我们的系统只需要物体的位置和速度…嘿,CPU,读读第一个物体的位置!(下图上)。当然,就在这里…让我帮你从内存中读取整个缓存行!(旁白)所以接下来4个对象的位置也被读入了CPU缓存(下图下)。

SoA数据布局转换:相当普遍, 不过要小心,不要做得过火!在某些情况下,单个数组的数量可能会适得其反,结构数组的结构(SoAo等)。

回到组件数据的SoA布局。不再是一个游戏对象类,只是一个EntityID。43毫秒→31ms更新,99→94ms启动,350→375行。

// ID of a game object is just an index into the scene array. typedef size_t EntityID;  // /* … */  // names of each object vector<string> m_Names; // data for all components vector<PositionComponent> m_Positions; vector<SpriteComponent> m_Sprites; vector<WorldBoundsComponent> m_WorldBounds; vector<MoveComponent> m_Moves; // bit flags for every component, indicating whether this object has it vector<int> m_Flags; 

结果:100万个精灵,20个泡泡:330ms→ 31ms的更新时间,快10倍!470ms→ 94ms的启动时间,快5倍!310MB→ 203MB内存使用率,节省了100MB!400→ 375行代码,代码甚至变得更小了!甚至都还没有开始线程化、SIMD…

Rendering Technology in 'Agents of Mayhem'分享了游戏Agents of Mayhem所采用的渲染技术,包含话题OIT、照明计算、全局照明等。

在OIT上,之前的半透明渲染是从后向前在CPU排序alpha,大量排序的alpha意味着CPU渲染效率低下,按“对象”排序,而不是按像素排序,排序跳变,低分辨率alpha不与高分辨率排序。解决方案:OIT?但许多OIT技术在GPU上效率低下。可以尝试加权混合OIT(Weighted Blended OIT,WBOIT)

加权混合OIT的好处是反向的CPU成本,现在可以按渲染状态(即材质/着色器)而不是深度对alpha进行排序,在GPU上高效,alpha着色器中添加了一些数学,简单的全屏合成步骤,低分辨率和高分辨率alpha无缝地排序,永远没有跳变,排序问题*滑过渡,不太复杂。缺点是到处都是神奇的数字,非常不透明的alpha表现不好,总是“错”的。加权混合OIT用加权*均代替有序混合:

加权函数(McGuire)的权重是“魔法”数据,权重高,覆盖率高,权重更接*物体本身。

自发光的Alpha是主要问题,可以考虑具有相同自发光Alpha值E的n层:

主要思路:积累更多信息,“相加性”≈ 添加层的数量,通过相加性放大加权*均值。新WBOIT的视觉摘要:

其中WBOIT的公式和可相加性描述如下:

新的组合方式:

加权函数与自发光:纯自发光Alpha的不透明度为零,在计算重量时必须包括自发光,必须允许权重为零。

实现:简单的2个RT的MRT设置。第2个MRT将*均值存储在R通道,可加性存储为Alpha通道,对alpha通道使用单独的混合控制。

在光照方面,大量(昂贵的)照明功能已实现,如多个照明模型(所有PBR)、PCF阴影、可变半影阴影(PCSS)、投影纹理、纹理发射器区域灯光、泛光灯、“逼真”的管状灯、方形或圆形聚光灯、黑色(负光)、光源裁减*面、光源遮挡和门户。光照泄露是熟悉的问题,标准的解决方案:

采用光照遮挡体,使用有限光剪裁*面:


工作原理是在遮挡体“阴影”视锥体体上剔除tile(下图左),列出需要对每个灯光进行逐像素检查的遮罩(下图右):


实现的流程图概览:

在GI方面,使用了LPV,改进了光照遮挡效果,在室内使用了固定的局部体积,质量更高,无需每帧注入和传播。以往的LPV光照遮挡存在粗糙离散化的光照溢出和几何缺失等问题:

不管怎样,艺术家们放置遮挡体也可以用于GI!

传播过程中的遮挡体:光照遮挡体注入体积时,存储为“轴向”遮挡(沿每个轴的遮挡量),在传播过程中遮挡光线,生成GI“阴影”(下图左)。针对4x4x4宏观单元格剔除的光照遮挡体,减少每个LPV单元格中考虑的一组遮挡体,应用过程中遮挡三线性采样的光照,消除来自粗糙网格的漏光(下图右):

Rebuilding Your Engine During Development: Lessons from 'Mafia III'分享了重构游戏Mafia III所用的引擎的过程、技术和经验教训。该引擎重构的目标是更容易使用的工具,能够处理大量数据,数据驱动,主要变化在于新世界编辑、新对象系统、构建系统、局部迭代、可视化脚本、中间件集成、物理、动画、导航、用户界面、音频等。

新的世界编辑器使用C#/WPF和DevExpress中的新工具,在新编辑器中集成旧编辑器插件,使用C++/CLI进行引擎通信,尽早让用户参与。

新对象系统包含资产和文件管理、继承和分组、赋予内容创作者权力等。资产和文件管理的目标是轻松跟踪依赖关系,支持二进制和文本格式,省力,按ID标识对象,前后兼容。使用C++反射进行序列化,对于工程师来说,公开数据非常简单,基于宏的内部框架,合理的前后兼容性,不需要版本控制系统,强大的代码数据依赖性。每个对象都有一个唯一的标识符,资产的自由移动,服务读取TOC和跟踪ID,易于查询依赖项。继承系统提高资产的可重用性,易于工程师们和内容创作者使用,超越一切的能力,在序列化代码中处理,基于反射和唯一ID,对可以修改的内容没有限制。结果很好,但是…在保存上与父节点相比,子节点是碎片化的。

重新保存依赖,但不知道如何在生产中修复,引入了一项“功能”来重新保存依赖,很难理解何时使用它。赋予内容创作者权力。合成对象的能力,将对象分组在一起,使用可视化语言编写对象级脚本。每个对象都有唯一的ID很好,基于比较的泛型继承系统,有子节点的父节点很棘手,赋予用户的权力超出了我们的预期,技术允许的一切都将被使用。

总之,升级到现代引擎,为新用户提供更快的学习曲线,编辑器的一致控制,在生产过程中部署并不有趣。


在过去的20年里,GPU厂商和游戏开发者都在努力追求“更好的帧速率”的圣杯。然而,人们对*滑度的问题知之甚少。The Elusive Frame Timing: A Case Study for Smoothness Over Speed探讨了一个非常奇特、不为人所知但相当重要的原因,即可怕的“微结巴”,即使在单个GPU的情况下也经常发生,也将讨论潜在问题、问题的历史以及解决问题的原型方法。在应用程序中,口吃(stuttering)的情况比完美情况“更快”?是的,详见下图:

Stutter的发生是因为游戏不知道它的显示速度有多快!为什么游戏“认为”它运行得比较慢?上世纪80/90年代,8位/16位纪元,是固定硬件,总是相同的时间没有问题,还记得不同的NTSC/PAL版本吗?到了90/00年代,软件渲染/图形加速器,开始计时和插值,但没有管线也没问题。那么,当今怎么了?

理论:“APl的/驱动的错“。真正的GPU负载对游戏是“隐藏”的,该“功能”可能是在21世纪初的“驱动基准测试大战”中引入的,由当时的Flush()和Finish()行为的改变,组合器也帮不上忙。试图弥补这一点的内部机制?这就是为什么很难找到它!无论如何,使用流水线硬件来发挥其潜力是不可避免的,“缓冲空闲时间”是可以有的,但我们需要知道!

错误时机的两面,首先是错误的时间反馈,心跳口吃的主要原因(但有时也有其它原因)。其次是错误的帧调度,是导致从“缓慢”恢复到“完美”时口吃的主要原因。建议,必须知道过去的画面持续了多久,异步统计,需要使用启发式,必须承认它并不完美,理想情况下,知道下一帧将持续多长时间,但这是不可能的。必须能够安排下一帧的显示时间,更快并不总是更好!一定知道还有多少时间剩余,实际上是最有问题的部分。老的逻辑算法通常是以下的模样:

frame_step = 16.67 ms    // (assuming 60fps as initial baseline) current_time = 0 while(running)     Simulate(frame_step) // calculate inputs/physics/animation... using this delta     RenderFrame()     current_time += frame_step     PresentFrame()        // scheduled by the driver/OS     frame_step = LengthOfThisFrame() // calculated by the game 

新的逻辑算法改成以下代码:

frame_step = 16.67 ms // (assuming 60fps as initial baseline) current_time = 0 pending_frames_queue = {} // (empty) frame_timing_history = {} while(running)     Simulate(frame_step) // calculate inputs/physics/animation... using this delta     RenderFrame()     current_time += frame_step     // current_time是新增的时间戳     current_frame_id = PresentFrame(current_time)     AddToList(pending_frames_queue, current_frame_id)     // 查询帧信息     QueryFrameInfos(pending_frames_queue,frame_timing_history)     // 帧时序启发式     frame_step = FrameTimingHeuristics(pending_frames_queue, frame_timing_history) 

上面的FrameTimingHeuristics()的内部结构:

  • 轮询所有在pending_frames_queue队列中已经可用的帧,将它们各自的计时记录到frame_timing_history中。
  • 如果看到任何一个未完成其时间表的帧,返回其长度以用于frame_step(这就是我们降低帧率的方式)。
  • 如果看到recovery_count_threshold连续帧都是早且它们的margin < recovery_margin_threshold,返回用于frame_step的长度(这就是我们提高帧率的方式)。

OpenGL+VDPAU原型:2015年8月在塔洛斯原则(The Talos Principle)中实现,作为概念证明。使用NV_present_video的OpenGL扩展,最初用于视频播放,因此具有计时功能。差不多了:合理安排未来的帧,获取过去帧的计时信息,但没有边缘信息,很难恢复。仅适用于Linux下OpenGL*台的某些NVIDIA板,应用不是很广,但证明了这一点。

Vulkan和VK_GOOGLE_display_timing:在塔洛斯原则和Serious Sam Fusion中实现,什么信息都有:安排未来的帧、过去帧的计时信息、有边缘信息(模棱两可?)。仅可用于安卓,Metal、DX12尚没有。

考虑因素:何时决定从缓慢恢复到完美?如何(以及是否?)当下降到慢速时,如何校正计时?我们能预测速度并做出完美的下降吗?如果可能的话,这将是“神圣的*滑”,可能会做得更好!VRR显示器呢?如果Vsync关闭了怎么办?这两个都是可行的,但还没有实现!

主观性:最终是一个感知的问题,也许有些人有不同的看法,不同的开发者会有不同的方法,向用户公开不同的选项?

Performance and Memory Post Mortem for Middle earth: Shadow分享了Monolith在《中土世界:战争的阴影》中实现30fps和内存的工程策略,主要分为性能优化和内存优化。

线程的同步原语:不再使用内核原语,转而使用轻量级原子自旋锁来保护原子数据访问,当需要上下文切换时,仍然使用内核原语,上下文切换的真正成本是缓存逐出。引入了一个轻量级的多读单写的原语,从多个线程读取物理模拟的状态就是一个例子。微软*台有一个超轻读写(SRW)锁原语,索尼也有类似的原语。可以使用两个原子自旋锁实例和一个原子计数器来跟踪读取数量,第一个原子自旋锁用作读取锁,第二个原子自旋锁用作写入锁。

线程的CPU集群:在控制台上的两个CPU集群之间分离工作负载,以利用共享的L2$。第一个CPU集群执行游戏逻辑,第二个CPU集群执行渲染逻辑。由于Shadow of War的复杂性增加,性能提高了10%,要求群集不同时接触相同的缓存行,黑魔法。

线程的整体方法:保持大量工作,尽可能多地保持单线程代码,降低初级工程师的上手门槛,降低并发带来的bug数量,需要更少的整体同步,通常会更好地使用CPU缓存。将整个系统移到后台线程,为大系统设置硬亲缘关系。用优先级较低的线程来填补CPU空闲,类似于异步计算的概念,但适用于CPU核心,例如文件I/O、流式传输和异步光线投射。

线程的管线:通过使用循环命令缓冲区,操作以管线的方式进行,管线将操作推向一个方向,然后这些命令缓冲区中的每一个都在一个运行在专用内核上的专用线程上执行,允许每个管线阶段获得每帧33.3ms的完整数据。

线程的动态帧调整:当管线化线程时,保证管线中的下一阶段不超过1帧(33.3ms)。理想情况下,所有管线阶段都是并行运行的,每个阶段之间有几毫秒的偏移。如果整个管线都被管线中的第一个阶段所约束也是真实的。如果整个系统受到最后一个阶段的约束,那么我们最终会显示几帧前模拟的帧,是由管线的伸缩性造成的,我们总是被v-sync的最后阶段所束缚。

输入延迟成为一个问题,因为输入是在模拟线程上评估的,比屏幕上显示的帧早200毫秒。解决方案是动态帧调整系统,该系统监控GPU上的显示队列,如果队列已满,那么将受GPU限制,当受GPU限制时,人为地暂停第一阶段,即SimulationThread,这样它所花费的时间比分配的时间稍多,例如Sleep(33.3 TimeOfSimulationThread + 1),会导致望远镜收缩。

在已知的最坏情况下,模拟线程现在下降到50毫秒:

渲染器的常量缓冲区:游戏到引擎抽象层之间的复制太多,每个常数被单独设置,然后组装成一个常数缓冲区,每次drawcall都会重新生成常量缓冲区。根据更新频率打破了常量缓冲区,访问渲染器中的常量会返回指向实际常量缓冲区的指针,通过访问器将常量缓冲区直接暴露给游戏。跟踪脏状态和帧代码,一旦发送到GPU,就会生成一个新副本,并清除脏状态,在GPU清除帧代码之前,内存不会被重用。绑定命名常量缓冲区只是设置指针,材质常量缓冲区在构建阶段烘焙到资产中。

以骨骼变换为例:

每个常量缓冲常数都是通过获取关于常量使用频率的启发式方法手动排序的,很少使用的常量和通常设置为0的常量被排序到常量缓冲区的底部。分配的常量缓冲区仅足以容纳已使用的常量和不为0的常量,大大降低了访问的内存量。在GPU上读取超过缓冲区末尾的数据只返回0,导致图形API错误信息,但可以抑制。缓存的常量缓冲区带有渲染节点,如果它们没有改变,可以在不同阶段重用,例如G-Buffer阶段和CSM阶段的骨骼。

渲染器的常规优化:切换到更快的第1方图形API,如具有快速语义/LCUE的D3D11.X。已删除的所有帧到帧的参考计数器,仅使用帧代码管理生命周期。缓存整个图形API状态,删除对第1方图形API的冗余状态更改。通过在缓存行边界上分配动态GPU内存并填充到缓存行的末尾,然后使用帧代码跟踪该内存以进行CPU访问,减少了CPU和GPU缓存刷新。动态CPU负载缩放,根据CPU负载推出LOD以降低网格数,暂停高mip流以降低CPU使用率、内存压力和物理页面映射的成本。在已知的最坏情况下,渲染线程现在降至45毫秒:

在内存方面,Shadow of War的一个巨大性能胜利是将所有分配切换到大型2MB页面,超过64KB的页面,性能提升20%。大页面减少了代价高昂的翻译查找缓冲区(TLB)丢失,在创建流程时预先分配所有大页面。虽然PC几乎总是受限于GPU,但它也在PC上实现了这一点。

在已知的最坏情况下,模拟线程现在下降到40毫秒,渲染线程现在下降到36毫秒:

开发构建:DLL用于改进工程迭代,所有项目都启用了增量链接,调试:一些工程师可以使用FastLink,可执行文件是加载和运行这些DLL的一个小存根。零散构建:DLL现在编译为LIBs,增量链接已禁用,可执行文件仍然是一个小存根,现在链接到LIBs中,将运行时性能提高约10%。LTCG在所有*台和所有项目上都是启用的,包括所有中间件,微软*台上的LTCG给了另外10%的改进,而PS4上的LTCG则提高了大约5%。PGO在所有*台上都已启用,PGO在所有*台上的性能都提高了约5%,确保在Microsoft*台上禁用COMDAT折叠,将抵消PGO带来的收益。在已知的最坏情况下,模拟线程现在下降到33毫秒,渲染线程现在下降到30毫秒:

GPU虚拟内存:现代GPU和CPU一样使用虚拟内存,物理内存不必是连续的,物理内存可以按页面粒度进行映射和取消映射,一个物理内存页可以映射到多个虚拟内存地址。

64K页:使用64KB页面的优点是它们比较大的页面小,并允许更大的共享和重用。使用64KB页面的缺点是,由于TLB未命中率增加,访问速度较慢。

Mipmap流:Shadow of Mordor不断地流加载mipmap,但从未卸下,主要是为了缩短加载时间。如果使用了纹理,其高mip会被流化。Shadow of War为了节省内存,需要流入和流出高级别的mipmap,使用了一个固定的mipmap内存池,高mip是纹理2D内存的66%。在构建时间,分析每个网格,并确定具有最大纹理密度的最大三角形,把它保存下来。在运行时,渲染网格时,使用CPU将该三角形投影到屏幕空间,并计算*似的mipmap值。

CPU系统由多个网格/材质控制,这些网格/材质可以每帧(64)进行分析,每个网格由一个帧代码控制,并由一个阻尼器控制,以避免颠簸。每秒测试1920个网格,mipmap分析的CPU成本固定为每帧0.1ms。高mips使用64KB页面池。这个页面池是在进程创建时预先分配的,高MIP被加载,直到池耗尽,所有支持高mip流的纹理都是在没有物理内存支持的情况下创建的。

以上做法为高mips使用内存池可节省约1.0 GiB的内存。

《战争阴影》使用了大量的纹理数组,如地形、角色模型、大多数结构、FX序列帧等。好处是Texture2Darray中的切片都在同一级别进行采样,这对于混合非常有用。与采样多个2D纹理时相比,通常更容易避免着色器中的分支来采样Texture2Darray。但存在的问题是填充(padding)、复制。为了避开填充,手动将64KB页面绑定到GPU实际读取的纹理部分,不支持使用物理内存进行布局填充。压缩MIP是小于64KB页面并共享64KB页面的MIP,它们总是由物理内存页支持。

通过在切片之间共享64KB物理内存页来解决复制问题,Texture2DArray仅包含对Texture2D切片的引用,在运行时,切片的物理页面被映射到Texture2Darray上,每个切片都是引用计数的,切片的物理内存在所有引用消失之前不会被释放。压缩的MIP比单个64KB页面小,并且保持重复。该解决方案还允许我们将切片用作常规纹理2D,并将其与内存中的Texture2DArray共享,构建时间也减少了,因为我们一次只需要做一个切片。

结论:避免重复*均节省了大约300M,视场景而定,避免填充允许艺术家创建非2的N次方的数组。

The Challenges of Rendering an Open World in Far Cry 5阐述了Far Cry 5开发世界的渲染技术。

对于水体渲染,深度多分辨率处理使用水的深度,好处是艺术家更喜欢水上的SSAO和SSS(屏幕空间阴影),由于阴影、大气散射和雾在水面上延迟,因此有优化。缺点是延迟的阴影出现在水面上,分块的灯光剔除不能正确剔除水下的灯光,但QC没有报告任何错误,所以这是一个很好的折衷方案。如果我们想看到水下的透明物体呢?最终FC5将透明通道一分为二:

for each transparent object     find water plane at XY location     if above water plane         render after water     else if below water plane         render before water     else // if intersecting water plane         render before and after water     end         end 

对于剔除,After water使用深度测试来裁剪水,Before water在顶点着色器中对水裁剪*面进行剔除。

在一天的时间周期方面,挑一个你喜欢的日子!太阳或月亮总是存在的,不断循环。计算今天和昨天太阳和月亮的位置,随着时间的推移,从今天的位置过渡到昨天的位置,所以明天12:00和今天12:00是一样的。

CalculateSunPosition( timeToday, latitude, longitude, &azimuthToday, &zenithToday ); CalculateSunPosition( timeYesterday, latitude, longitude, &azimuthYesterday, &zenithYesterday );  float dayBlendFactor = secondsFromMidnight / (24.0f * 60.0f * 60.0f); // seconds in a day float azimuth = LerpAnglesOnCircle(azimuthToday, azimuthYesterday, dayBlendFactor); float zenith = LerpAnglesOnCircle(zenithToday, zenithYesterday, dayBlendFactor); 

像计算满月一样计算月光,可见的月相仍在发生,将月亮光的方向改为来自月亮的发光部分,以防止类似这样的bug:

但存在的问题还有不少,无法支持夜间物理照明值的对比度范围,夹紧光照半径只会增加对比度范围,灯光艺术家将灯光调暗以支持夜晚,在一天中的其它时间导致不正确的行为。借鉴了电影使用照明设备来模拟月球照明的方法,解决方案:增加月亮的亮度,把月亮光源改成浅蓝色,用于模拟填充灯光并降低对比度的最小环境条件。明亮的月亮会冲淡黎明/黄昏,因为它与衰落的太阳竞争。

FC5还采用了局部色调映射,色调以不同的方式映射图像的不同区域,减少明亮区域,使其进入更小的动态范围。尝试了后处理局部色调映射,但存在光晕等瑕疵。新想法:主要问题是黑暗的内部和明亮的外部,手动标记屏幕上需要调整曝光的区域,如窗户、门等,我们称之为曝光门户。双面几何体,乘法混合使后面的颜色变暗或变亮,靠*相机时淡出。

曝光门户的局部色调映射。

还有一种是基于GI的局部色调映射。双边模糊用于局部曝光,区分屏幕上的以下区域:2D空间的闭合区域、3D空间的远距离区域,提供照明值的局部*均值,如果已经有了3D空间中照明值的局部*均值呢?事实上,有这些信息!这是全局照明系统,它存储来自局部灯光和太阳的间接照明、天空遮挡。算法:从当前场景曝光创建一个参考中灰色,计算当前像素处的*均照明亮度:天空照明加上间接照明并忽略所有直接光(包括太阳光),比较值并相应地调整像素照明(发生在所有照明着色器中)。

The Road toward Unified Rendering with Unity’s High Definition Render Pipeline分享了Unity的HDRP管线的实现过程和涉及的技术。

高清晰度渲染管线(HDRP)的设计目标是跨*台,如PC(DX11、DX12、Vulkan)XBox One、PS4、Mac,始终基于物理的渲染;统一照明,同样的照明功能可用于不透明、透明和体积;一致性照明,所有灯光类型都适用于所有材质和全局照明,尽可能避免双重照明/双重遮挡。在光照架构方面,延迟、前向、混合的对比如下:

也可以切换到所有都用前向渲染:

材质架构如下:

贴花的渲染架构如下:

HDRP的BRDF如下:

  • 光照着色器。HDRP中的材质ID:材质特征的位掩码,例如标准+半透明、标准+清漆+各向异性、标准+彩虹+次表面散射。
  • GBuffer约束。存储空间带来的独特的材质特性,如虹彩、各向异性和次表面散射/半透明。

标准着色器的GBuffer布局如下:

另外,HDRP支持各向异性、清漆、GGX多散射、彩虹(Iridescence)、次表面散射等效果:

在光照方面,支持LPV的全局光照:

硬件和支持实时光线跟踪的API的最新进展为新的图形功能让路,这些功能可以大幅提高最终帧的质量。Leveraging Real-Time Ray Tracing to build a Hybrid Game Engine将重点讨论在将实时光线跟踪功能(与现有图形API)集成到生产游戏引擎中时应考虑的问题,深入研究实时光线跟踪功能的实现细节,并提供开发过程中的经验教训。然后,还概述几种在现代渲染管线中利用实时光线跟踪的算法,例如反射和随机区域光阴影,切深入了解在生产引擎中实现商品消费类硬件快速性能所需的重要优化。

传统的渲染管线如下图上所示,其中蓝色部分和间接光无关,可以忽略。下图下的红色是和间接光相关的阶段。

对于下图的黄色步骤,解决方案是多次反弹或*似。接下来要看的是透明度,它似乎是光线追踪的一个很好的候选者,对吗?

事实证明,屏幕空间照明问题同样适用于透明材质(多维性、性能、过滤)。当前已经在探索SSS的体积解决方案,但没有正确的SSS体积解决方案。混合渲染管线的流程如下:

对于非直接光照,分裂和*似Karis 2013有助于减少方差,蓝色是预先计算的,使用光栅化或光线跟踪进行评估。

在RTX的渲染流程如下:

随机化的区域光渲染流程如下:

数据驱动架构是视频游戏中非常常见的概念,因为团队中的非编程部分需要轻松添加新功能。这种模式在每一个流行的游戏引擎中都得到了实现,它是增强内容创作者能力和释放他们创造力的关键技术。Content Fueled Gameplay Programming in 'Frostpunk'介绍了11 bit studios内部技术的数据驱动的游戏玩法架构,它如何影响Frostpunk的游戏玩法代码以及游戏的整体制作过程。

数据驱动架构:基于实体组件系统(ECS),灵活、易于扩展的配置,由设计和艺术团队创建的内容,程序员只提供工具,架构可以进行多次迭代和实验。没有数据驱动的体系结构,创建独特的社区建设者是不可能的。

RTTI类是运行时类型标识,可序列化为XML节点或二进制的C++类,编辑器中使用XML,游戏中使用二进制格式。模板是RTTI类定义游戏中对象的文件,由GUID识别,编辑器支持。

模板示例:实体模板-定义游戏中的对象类型、组件模板-定义组件、网格模板-定义网格并导入数据、UI样式-UI元素/UI屏幕定义。ECS实现示例如下:

在组件中保存的状态/数据,在系统中实现的逻辑,系统可以保持全球状态,ECS之外的一些功能(UI、输入…)。组件和组件模板是RTTI类,实体模板有一个组件模板列表,组件保留对其模板对象的引用,模板对象中保存的常量数据,组件对象中保存的可变数据。每个实体一个类型的组件,清晰的配置,更少的配置错误。每个组件类型一个系统,单一责任原则,简化架构。没有系统/组件类继承,每个组件类型规则一个系统的后果,可能的位掩码ID(下图),通过添加新组件类型扩展功能。

支持组件依赖和初始化顺序:RequireComponent<Type>AddAfterComponent<Type>。组件集:设计师定义的组件批次,包含定义最常用实体类型的组件,例如建筑物、公民、资源堆,可以嵌套,如果重复,则使用最高级别的组件定义。

class GeneratorComponent : public ComponentImpl<GeneratorComponent, GeneratorComponentTemplate> {     DECLARE_PROPERTIES(GeneratorComponent, ComponentBase)     {         DECLARE_ATTRIBUTES(Attr::RequireComponent<BuildingComponent>());         DECLARE_ATTRIBUTES(Attr::AddAfterComponent<HeaterComponent>());         DECLARE_PROPERTY(SteamGenerationUpkeep, 0);         DECLARE_PROPERTY(SteamPower, 0);                  ...     }          ...              Map<EntryLink<ResourceEntry>, int> SteamGenerationUpkeep;     float SteamPower = 0.0f;     friend class GeneratorSystem; }; 

架构预览:

《DirectX 12 Optimization Techniques in Capcom’s RE ENGINE》讲述了Capcom公司的RE引擎使用及优化DX12的技术。用到的工具包含RGP、RGA等。

RE Engine使用“中间绘图命令”,独立于*台的命令,允许程序员在没有*台知识的情况下编写绘图命令,对多*台开发有用,能够在多个线程上创建绘图命令,这些“中间绘图命令”在创建后进行排序,然后转换为API命令,使用优先级变量(uint 64位值)控制绘图顺序,允许用户自行决定批处理,用于控制UAVOverlap和异步调度的同步定时。

控制台优化适应PC的措施有使用MultiDraw进行遮挡剔除、UAVOverlap、Wave指令、深度界限测试,针对DirectX 12的优化有减少资源屏障、缓冲区更新、根签名、内存管理等。ExecuteIndirect总体而言,没有想象的那么多改善,但对于基于GPU的遮挡剔除的实现非常有用。基于GPU的遮挡剔除的实现可以用ExecuteIndirect和预测指令(Predication command)两种实现方式。ExecuteIndirect是4字节对齐,使用CountBuffer控制间接参数执行的次数,预测命令是8字节对齐,与控制台不兼容。使用“VisibleBuffer”管理可见性,实际上,它是RE引擎中的缓冲区,字节地址缓冲区,元素数等于场景中的最大网格数,每个元素包含每个网格的可见性,0xffff表示可见,0x0000表示不可见。可见性测试时,用EarlyZ绘制([earlydepthstencil]属性),将0xffff存储到VisibleBuffer中,尽量减少以波为单位的同一地址的写入[dorobot16]:

[earlydepthstencil] void PS_Culltest(OccludeeOutput I) {     // 减少以波为单位的同一地址的写入.     uint hash = WaveCompactValue(I.outputAddress);     [branch]     if (hash == 0)     {         RWCountBuffer.Store(I.outputAddress, 0xffff);     } } 

然后应用可见性测试结果:按网格应用绘图,使用MaxCommandCount指定绘图数量,VisibleBuffer作为CountBuffer,CountBuffer 0xffff:启用绘图(计数为MaxCommandCount),计数缓冲区0:禁用绘制。

void ExecuteIndirect(     ID3D12CommandSignature *pCommandSignature,     UINT MaxCommandCount,     ID3D12Resource *pArgumentBuffer,     UINT64 ArgumentBufferOffset,     ID3D12Resource *pCountBuffer,     UINT64 CountBufferOffset); 

还有另外一些改进的余地:有效针对道具和角色网格,剔除方法对较小的AABB单元有效,但对大网格无效,大网格始终可见,需要精细分割网格以获得更好的效果。可以自动细分大的网格,剪取256个三角形作为一批,每批由连续的间接参数组成,每批创建AABB。然而这种方法也存在一些问题:几乎所有绘制都低于768个索引,大量批次导致性能不佳(取决于硬件),如果相邻间接参数连续,则合并命令。

分区z-prepass(Partial Z-prepass):运行尽可能少的片段着色器,每个网格的Z-prepass都很昂贵,成本可以超过收益,将Z-prepass限制为相机附*的网格,重用自动划分模型。不同裁剪方法的性能对比:

但发现基于GPU的遮挡剔除无法获得明显的性能提升:

UAVOverlap:DirectX12无依赖的着色器可以并行执行,UAV屏障的依赖性不明确,不清楚是读还是写,如果每个批写入单独的位置,则可以并行地执行,如果WAW(write-after-write)危险是可以避免的。用于每次计算着色器dispatch的可控的UAV同步,通过禁用UAV的同步,使并行执行成为可能,在DirectX 11中,可以使用AGS和NVAPI引入等效函数。

// uavResourceSyncDisable就是禁用UAV的同步。 void dispatch(u32 threadGroupX, u32 threadGroupY, u32 threadGroupZ, bool uavResourceSyncDisable = false); void dispatchIndirect(Buffer& buffer, u32 alignedOffsetForArgs, bool uavResourceSyncDisable = false); 

启用UAVOverlap有略微的提升:

wave指令:着色器标量化可以提高线程并行工作的速度,用于照明、基于GPU的遮挡剔除、SSR…Wave Intrinsic通过消除不必要的同步来提高标量化的效率。在DirectX 11和DirectX 12中受支持,使用AGS内置着色器模型5.1,也可与Shader Model 6.0一起使用。

深度边界测试:夹紧深度达到特定深度范围,主要用于消除无关的像素着色器,可与DirectX 12(创作者更新)和DirectX 11.3一起使用,带有AGS和NVAPI的DirectX 11,在RE ENGINE中,它用于贴花和光束。处理贴花时,在深度测试失败的像素上运行,完全遮挡时,最好跳过处理,使用深度边界测试解决。

在没有对资源屏障进行优化的原始构建中,批量插入了资源屏障,在执行绘图命令之前,立即转换当前批次所需的资源屏障:

导致大量资源屏障,是基于GPU的遮挡剔除并没有显著提高性能的原因之一:

资源屏障较多的阶段运行效率不高:

减少资源屏障:通过考虑每个资源的子资源进行优化,很难通过所有中间绘图命令手动创建最佳资源屏障,困难表现在获得最大的GPU性能和保持无Bug。为命令分析添加pre-pass,自动计算资源屏障的位置,分析中间绘图命令,中间绘图命令按优先级排序,可以按时间顺序跟踪每个资源的绘图命令使用情况,通过改变优先级顺序,分析具有依赖性的批次可以轻松提高GPU的效率。压缩打包资源屏障的过程如下:

上:对不同时间点的Barrier向前搜寻前面资源的Barrier;中:找到这些Barrier的共同时间点;下:迁移后面Barrier到同一时间点,执行合批。

优势是不必对内部实现和缓存如此敏感,减少不必要的资源屏障。劣势是需要命令解析时间,但PC超级速!

在更新缓冲区时仍然存在效率低下的部分如下图:

DMA传输中驱动程序造成的大量资源屏障:

发生了什么事?图形队列上的缓冲区更新,复制缓冲区,如GPU粒子缓冲区更新和更新蒙皮矩阵,CopyBufferRegion作为DMA传输执行。在执行DMA传输时,强缓存刷新正在运行,一级缓存、二级缓存、K级缓存,批处理资源屏障没有任何影响!可能的解决方案是如果每帧只有一次更新,则使用CopyQueue进行更新,使用计算着色器进行更新,前提是使用了计算着色器。

StructuredBuffer<uint> fastCopySource; RWStructuredBuffer<uint> fastCopyTarget;  [numthreads(256,1,1)] void CS_FastCopy( uint groupID : SV_GroupID, uint threadID : SV_GroupThreadID ) {     fastCopyTarget[(groupID.x * 2 + 0)*256 + threadID.x] = fastCopySource[(groupID.x * 2 + 0)*256 + threadID.x];     fastCopyTarget[(groupID.x * 2 + 1)*256 + threadID.x] = fastCopySource[(groupID.x * 2 + 1)*256 + threadID.x]; } 

固定缓冲区更新的优化:通过上传堆更新所有常量缓冲区,更新同一固定缓冲区需要资源屏障和CopyBufferRegion(DMA传输),将新值存储到上传堆中,并获取上传堆偏移地址,使用ConstantBuffer的着色器只需要参考偏移地址,不再需要资源屏障和复制缓冲区。复制缓冲区缩减比较,成功消除了低效率:


根签名:DirectX12使用与DX11和控制台类似的RootSignature,在运行时确定,而不是在着色器构建时确定,为每个IHV提供定制优化,对于AMD,使用RootParameter作为表格,对于NVIDIA,使用RootParameter优化ConstantBuffer访问。

内存管理:在第一个实现中,内存回收从大约50%的内存使用率开始,相当保守,游戏过程中出现了许多尖峰。在《生化危机2》中,每次角色移动时,控制每个房间的装载和处理都会导致尖峰,甚至在加载暂停菜单的UI时发生。在内存耗尽之前不要逐出,防止微型逐出,当内存使用率超过90%时,未引用的内存将被逐出。

经过以上所有优化之后,性能得到了24%的提升(优化实属不易啊!!):

GPU Driven Rendering and Virtual Texturing in 'Trials Rising'介绍了Trials Rising团队在所有目标*台(PS4、Xbox One、Switch、PC)上实现60 FPS恒定性能的过程,这些*台的世界复杂性大大增加,包含GPU驱动的渲染实现及其与虚拟纹理的集成、主要创新、优化和性能结果的详细信息,致力于介绍任天堂交换机*台上GPU驱动的渲染可扩展性和技术效率的改进。

GPU驱动的渲染将可见性测试转移到GPU,直接在GPU上使用测试结果,GPU上的批处理实例,将不同网格的实例合并在一起,GPU能够感知场景状态,而不仅仅是通过frustrum测试的部分,GPU为自己提供渲染功能。

GPU数据结构:两个GPU缓冲区用于存储几何体(即顶点和索引池),一个大型GPU缓冲区,用于保存实例参数(即实例数据池),实例描述符表示的实例,一个GPU缓冲区,用于存储场景中所有描述符的数组,CPU和GPU主要使用此列表中的索引进行操作。实例描述符包含内部和外部数据,几乎是一个指针表,可以表示场景中的任何实例,和描述集非常接*:

CPU和GPU实例状态同步:CPU负责实例状态模拟并上传到GPU,GPU等待信号读取实例数据,直接的状态再生每一帧都不起作用,根据第一次实施结果,数据流量相当高,并且在未来有增加的趋势。数据传输改进:实例更新率不是恒定的,状态参数*均更新率不同,通常是稳定的,“固定”参数适用于特定实例类别,大量“闲置”组件,同步状态的最小值。

微型合批:写一个包含缓冲区偏移量和要写入的数据的简单列表,合批程序的顺序很重要:

GPU实例表合批:

池合批:

微型合批结果:延迟同步,所有GPU结构只有一个显式同步点,不与GPU争用以访问/修改缓冲区,合批数据收集显著提高了CPU性能,合批池数据散射影响GPU性能,在“脏”状态下的实例要少得多。

可变同步速率:使用可变动画速率的想法,基于“重要性”因素同步实例状态,保持确定性(用于回放、调试等),尽可能自动计算“重要性”,整体同步问题看起来与网络同步非常相似。

文中还使用异步计算来并行处理物理模拟:

虚拟纹理:一次访问所有纹理数据,通常比普通纹理流技术的内存使用和数据传输压力更低,极大地提高了GPU驱动渲染的“批处理”效率,替换某些*台/API可用的无绑定纹理。

VT使用了专用通道:使用专用摄像头查看VT页面请求通道,流预测、纹理预加载等,需要额外的视口剔除。光栅化几何体两次以生成页面请求RT,较小的渲染目标(1/4分辨率+抖动),存储页面请求(x坐标、y坐标、mip),分析CPU并生成加载请求。In place PR:用两个UAV缓冲区(Bloom过滤缓冲区、页面请求缓冲区)替换渲染目标,将PR过滤、排序等移动到GPU,并将其委托给GBuffer中的每个像素,允许从CPU上节省一些时间,对透明通道、alpha混合通道等的页面请求。Trials Rising“In最终使用了“In place PR”,这一变化使得每一个奇数帧(Xbox One计时)的CPU时间减少了约35毫秒,可能会导致额外的纹理加载延迟,必须加以考虑。半透明可以放置在Mega纹理中,很棒的着色器代码简化。虚拟纹理可伸缩性:每帧的页面请求决定了系统的“性能”,高度依赖于渲染分辨率和VT页面缓存大小,这两个参数都可以在“在线”和“离线”设置中控制。

GPU驱动的渲染可伸缩性:GPU管线的计算部分速度非常快,剔除和几何组合可根据GPU时钟和内存带宽/延迟进行缩放,光栅化基于分辨率和比例,GPU管线需要CPU/GPU的特定数据路径,实例同步对内存带宽的影响,具有少量实例开销的批处理比实际工作量要高。对低端*台的远距离对象使用更激进的消隐设置,根据CPU/GPU负载调整运行时的详细级别。

Snowdrop是一款AAA级游戏引擎,由育碧开发,为The Division及其续集提供动力。Efficient Rendering in 'The Division 2'阐述了The Division 2中使用的一些技巧,以在PC和控制台上实现最佳性能,如构造帧、异步计算、多线程、内部函数、命令列表提交等。

Snowdrop使用了异步计算和图形管线相结合的计算,管线总览如下(上半部分是图形管线,下半部分是异步计算):

整体管线使用CPU/渲染/GPU工作交错,尽早提交,频繁提交,没有渲染图或帧布局的先决信息,自动资源过渡跟踪,但可以选择不自动追踪。每帧50到60次提交,200次过渡/100次屏障,3到6k个绘制调用,3到6百万个图元,(一些供应商)提交的时间比构建即时命令列表的时间要多。渲染核心处理非命令列表操作,包含渲染状态/PSO和资源创建:缓冲区、像素存储、纹理、RT。。。管理渲染上下文,图形/计算/延迟/DMA。

更新缓冲区时,所有瞬态数据复制到上传缓冲区,然后复制到GPU局部缓冲区,着色器仅从GPU本地缓冲区读取!没有更快,而是可以获得更稳定的帧率。命令列表链接(Command list chaining)在一些控制台上可用,允许在记录命令列表时执行命令列表,将CPU和GPU停顿的风险降至最低,但在DirectX®12中不可用…解决方案:模仿!!!进入队列管理器,处理命令列表操作,如ExecuteCommandLists、关闭、重置,隐藏这些操作的CPU成本,有自己的工作线程和任务队列,实际上是一个自定义驱动程序线程。

队列管理器提交它可以提交的内容,原子追踪命令列表状态,如录制、打开,每个上下文一个队列,按优先顺序的俄罗斯转盘(Round robins)方式执行计算机、图形、DMA等。队列管理器细节:

原生和队列管理器对比:

队列管理器可以消除大多数CPU停顿,预测性地准备命令列表,避免命令列表创建/重置的停顿,消除多余的信号/等待。

异步计算的提交也由队列管理器处理,2种类型的计算工作负载:依赖于gfx状态、独立的,用于不需要很快完成的工作负载。异步计算示例:深度下采样和光源剔除、雾与体积计算、雨和雪的GPU粒子、天空/覆盖采样、草地/植被更新、阴影(可变半影预计算)、GI重新照明等。异步计算可能会让gfx管线停顿,可能会导致GPU使用不足,控制台上:限制异步计算占用,在PC上不支持D3D12_COMMAND_QUEUE_PRIORITY。

High Zombie Throughput in Modern Graphics讲述了Saber引擎的渲染管线(高级GPU工作流、着色概述、Vulkan优化)和僵尸渲染(特写和中档角色、远距飞机群、贴花)等内容。Saber引擎渲染管线的特点是GPU驱动的可见性系统、完整的深度prepass、前向+PBR着色、烘焙GI:RNM+主导方向、2帧延迟、多线程命令缓冲区记录。GPU工作流如下:

GPU可见性系统:基于“游戏的实用、动态可视性”[Hill11],简单高效,无需PVS/门户等,主摄像机:HZB遮挡剔除与手工遮挡器,PSSM拆分:仅是追究剔除,0.7ms(XBox One)的GPU预算,用于100k+对象的场景,CPU回读需要1帧延迟。

D3D11和Vulkan的渲染管线对比:

Vulkan vs D3D11:GPU时间最多减少10%,wave指令(GL_KHR_shader_subgroup),前向+照明/反射循环在着色过程中进行标量化,异步计算,半精度数学。无辅助驱动程序线程:渲染CPU总负载减少40%,由于MT命令缓冲区,CPU关键路径减少20%。渲染目标内存重叠使得内存减少30%,并支持动态分辨率。

僵尸渲染:每帧超过5k个可见僵尸,大多数都是非互动背景,300多个前景“真实”游戏内的实体/角色,只有50个僵尸大脑发育完全,实例被用来降低绘制调用压力,灵活的每实例可视化定制系统。自定义网格:3种基本原型/骨架(男/女/“大”男),每具骨骼约50根骨头,每个模型4个网格区域(腿/躯干/头/头发),每个区域2-5k个三角形,每个区域4-8个网格变体,总共70多种独特的网格组合,再加上10个独特的僵尸模型(化学制品、尖叫器等)。

定制颜色和染色口罩:每个网格区域(衬衫/牛仔裤/鞋子/头发):颜色掩蔽纹理(ARGB8纹理,低分辨率)、污渍掩蔽纹理(ARGB8纹理,低分辨率);共享污渍纹理集(血迹/雪/灰尘):反照率/法线/粗糙度纹理,所有纹理都是tile的,存储为纹理数组;每实例常数,如颜色:通道遮罩+纯色,着色:通道掩码+纹理数组索引。

僵尸渲染的实例化:从可见性系统中获取单个僵尸,将相同的网格收集到实例化桶中,一个区域中变化相同的网格进入一个桶,不同的LOD模型将分离存储桶。将累积的存储桶处理到渲染队列中,按可见性遮罩对桶进行排序(每个活动摄像头都有自己的位), 将具有相同可见性遮罩的网格分批次收集(最多30个网格),对于每个批次,使用每个实例数据填充连续常量缓冲区,为每个过程/摄像头(Z、SM、着色)每批生成1个绘图调用。

僵尸渲染的背景群体:超过5000个僵尸,非交互式:本质上只是一个GPU驱动的动画,共8种预焙变体:2种网格类型 x 4种独特外观,每个网格约400个三角形,灵感来源于The Technical Art of Uncharted 4” [Maximov16]。利用现有的草地渲染解决方案,添加纹理烘焙顶点动画,沿着预先建模的轨道移动。

Halcyon Architecture - Director's Cut分享了迷你游戏PICA PICA的渲染架构和相关技术。Halcyon的渲染概览如下:

渲染句柄:由句柄关联的资源,轻量级(64位),恒定时间查找,类型安全(即缓冲区与纹理),可以序列化或传输,次代之间安全,如双重删除、删除后使用。可以在同一过程中混合搭配后端,使得调试VK实现变得更加容易,DX12位于屏幕左半部分,VK位于屏幕右半部分。

渲染命令:指定的队列类型,规格验证,允许运行?例如利用计算,自动调度,它能跑哪?异步计算。

渲染图:帧图 -> 渲染图:没有“帧”的概念,全自动转换和分割屏障,单个实现,不考虑后端,从高级渲染命令流转换,渲染图中隐藏的API差异。对多GPU的支持,大多是隐式且自动的,可以指定计划策略。多个图在不同频率下的组合,相同的GPU:异步计算,mGPU:每个GPU的图形,核心外:服务器集群、远程流。

渲染图有两个阶段:图形构造(指定输入和输出,串行操作)和图形评估(高度并行化,记录高级渲染命令,自动屏障和过渡)。它还支持多GPU的渲染调度:

另外,Halcyon使用了虚拟多GPU的技术。大多数开发者只有一个GPU,不适用于2台GPU机器,对于3+GPU来说很少见,适用于展厅,最高可达11个,不适合常规开发。

Halcyon的着色器跨*台方案如下:

Not-So-Little Light: Bringing 'Destiny 2' to HDR Displays介绍了为支持《命运2》的HDR显示而采取的方法,探讨了如何改变“命运”渲染管线以支持这项新技术,包括将SDR创建的内容引入HDR世界所面临的挑战。还将介绍在《命运2》中实现高质量HDR输出所使用的技术,以保持命运的独特外观,并反思从工作中吸取的经验教训。

Destiny 2的渲染管线如下:

色调映射效果对比:

颜色分级使用的LUT是SDR,使用数学映射:我们总是想要线性输入,先是色调映射,然后转换。忽略变换,则是可逆的。

SDR Color = TonemapForSDR (Input Color); LUT Color = LutLookup (SDR Color); ... Transform = Input Color / max (SDR Color , 0.001); ... LUT Color *= Transform; 

当InputColor为0时,LutColor也为0。其中UI的渲染流程如下:

新的渲染管线如下:

EOTF是电-光的传输函数,从电压到光强度。OETF是光-电的传输函数,从光强度到电压。sRGB的EOTF如下:

BT.709 OETF:

技术陷阱:增强型HDMI,在着色器中小心使用饱和,fp16缓冲区表示负数,SDR空间中的AA,更深的黑色会加重屏幕噪音。文中采用了HDR的LUT,在用户界面中使用色度/亮度,利用ICtCp或其它,最后一步是色调映射。离线的HDR管线如下:

经验教训:内容验证,做好改变SDR管线的准备,尽可能长时间地维护HDR缓冲区,探索RGB的替代方案,使用光度单位,做一个比较工具。

6 Years of Optimizing World of Tanks: Making the Game a Great Experience on All Systems from Laptops to High End PCs分享了坦克世界的多年优化经验,包含并行渲染、并发渲染、坦克履带、Havok/AVX2、光线追踪阴影等。

优化Ultrabook - 阴影:使用共享视频内存正确检测英特尔硬件,静态对象的自适应阴影贴图(ASM),动态对象的级联阴影贴图(CSM)。

优化Ultrabook - 性能优化:动态分辨率根据性能的不同,场景以动态分辨率渲染,且不缩放,UI始终以完全分辨率渲染,PC游戏的首批实现之一,在游戏中允许稳定的30 fps帧速率。硬件特定优化(2015年),用于在英特尔GPU上进行植被渲染的两次模具写入,以获得最佳模板+clip()的性能。

2018年,引入了TBB,让引擎为现代多核CPU做好准备,第一个问题是选择一个好的作业系统。如何选择一个好的作业系统?WoT工程团队标准:易于使用,两种类型的并行:功能/任务和数据,功能丰富且强大,很好的支持。线程构建模块(TBB)是并行算法和数据结构、线程和同步,可扩展内存分配和任务调度,是一种仅限于库的解决方案,不依赖于特殊的编译器支持,支持C++、Windows、Linux、OS X、Android和其它操作系统。

WoT 1.0多线程渲染架构如下:



到了2018年,WoT使用了SIMD(AVX2、ISPC)等技术进一步加速并行,并且使用TBB来加速Havok的破坏系统。

到了2019年,WoT使用光线追踪来改进阴影的质量。

RT阴影的实现细节:

  • CPU端:两级加速结构。

    • BLAS BVH。适用于所有坦克网格,在网格加载期间构建一次,并上传至GPU,网格中的硬蒙皮部分拆分为多个静态BVH,跳过软蒙皮部分。
    • TLAS BVH。多线程,使用英特尔Embree和英特尔TBB,重建每一帧并上传到GPU。
  • GPU端:像素着色器或计算着色器。

    • 基于均匀锥分布的时间射线抖动。
    • BVH遍历和射线三角形交点。
    • 时间积累。
    • 降噪器(基于SVGF)。
    • 时间抗锯齿。

CPU BVH性能:CPU帧时间占2.5%,TBB线程,SSE 4.2(比原始WoT内部BVH builder快5.5倍),每帧更新多达5mb的GPU数据,高达72mb的静态GPU数据。相关优化:RT阴影只能由坦克投射,不支持alpha测试的几何体,BLAS的LOD,每像素1射线,如果出现以下情况便停止追踪光线像素:NdotL<=0、如果像素已被阴影贴图遮挡、距离摄像机超过300米。

Software-based Variable Rate Shading in Call of Duty: Modern Warfare涵盖了《使命召唤:现代战争》(2020)中使用的一种新型渲染管线。它可以实现高度可定制的基于软件的可变速率着色;以与图像频率匹配的分辨率渲染部分渲染目标的方法。与仅限于可用设备子集的基于硬件的解决方案不同,其基于软件的实现使得在广泛的消费类硬件上实现更高的质量和性能成为可能。文中介绍了设计过程、最终实现,以及对管线每个阶段的深入检查,包括预过程渲染、可变速率估计器、用于计算着色器内核的新颖且独特的像素打包方案,以及forward+渲染器中的最终帧渲染,还讨论了其它渲染器类型和作业类型中的潜在实现及前瞻性扩展,以更好地满足质量和性能范围内的许多不同用例。

2020年的不少硬件已经支持硬件可变速率着色(Variable Rate Shading,VRS),支持硬件中的可变速率着色,如DX12 Ultimate[DX19] [DX20],包含AMD-RDNA2 GPU、英特尔-11代APU、英伟达-图灵GPU等。

在不同的Tier支持不同的特性:

  • Tier 1:设置每次绘制的着色速率。
  • Tier 2:设置每个屏幕tile的着色率(和其它频率),最小为8x8像素的tile大小。

可以选择适合图像频率的着色速率[LEI19]。

由此催生了多分辨率渲染管线 [DRO17]:

在标记为低分辨率的材质上进行3x–3.8x性能伸缩,方差取决于渲染目标微分块的命中量、屏幕上全分辨率和低分辨率粒子之间的重叠。0.3ms–0.4ms的组合:向上采样/解析/重建过程,方差来自需要所有子样本的微分块的数量。可能因GPU MSAA效率而异,对某些高三角形透明图形上的quad占用的担忧。

COD团队以4xMSAA(½分辨率以匹配原始分辨率目标)渲染prepass,在4xMSAA中渲染不透明,每次绘图允许1s、2s、4s模式,发现对prepass渲染的巨大加速,与DX12 VRS Tier 1.0相匹配的灵活性。然而本次的实验结果是失败的:未达到IQ目标(性能模式下),未达到性能目标(最坏情况下无变化)。

原因是有132k幸存的三角形,886k幸存的像素,*均每个三角形有6.7个像素。

令人耳目一新的概念:光栅化和quad占用、MSAA与quad占用的交互、MSAA与内存的交互(交错渲染)、与MSAA和quad占用的模板交互。

光栅化和quad占用的关系。

quad占用和分辨率的关系。

quad占用和MSAA的关系。

quad占用和MSAA、模板的关系。

MSAA重组和交错渲染的关系。

COD团队的计划是把所有东西都设成白色,使用模板进行VRS遮蔽。早期研究表明,在最坏的情况下,负面的性能损失可以最小化,如聚合采样率、交错渲染、利用VRS遮蔽优化的CS/PostFX作业。与硬件VRS相比,软件VRS的主要优势是可在所有*台上使用,由于tile较小,因此可能具有更高的性能,由于较小的tile和隐含的重建方法,图像质量可能更高。软件VRS的管线如下:

常规设置:帧渲染时对绘制和CS作业考虑了4xMSA重组/反交错,VRS以像素quad粒度(2x2)运行,与在MSAA的tile大小(16x16)上工作的硬件实现不同。Swizzle图案是带旋转的方形,从VRS着色速率贴图手动创建模板遮罩。

VRS图像遮罩:逐quad粒度,仅基于非图像数据的2位掩码,用于图像着色速率估计和VRS渲染遮罩的生成。4种模式:单一样本、Delta H、Delta V、所有样本,在所有样本模式下都保留激发像素。

梯度检测:

// Image Base Gradient Codes #define VRS_IB_0   ( 0x0 )    // No grad #define VRS_IB_DH  ( 0x1 )    // Dir Hor #define VRS_IB_DV  ( 0x2 )    // Dir Ver #define VRS_IB_ALL ( 0x3 )    // All dirs #define VRS_IB_MASK( VRS_IB_ALL )  // Local Weber-Fechner JND k calculation float tileLuma = min3( colorM, colorNW,                   min3(colorSW, colorSE,                   min3(colorNE, colorW,                   min3( colorE, colorN, colorS ) ) ) ); float visiblityThreshold = vrsQualityThreshold * max(tileLuma, deviceBlack);  // Horizontal float dh = max( abs( colorW - colorM ), abs( colorE - colorM ) ); float ddh= max( abs( colorNW - colorNE ), abs( colorSW - colorSE ) ); dh = max( dh, ddh );  // Repeat for Vertical  // VH direction and delta result.vrsRate  = VRS_IB_0; result.vrsRate |= dh < visiblityThreshold ? 0 : VRS_IB_DH; result.vrsRate |= dv < visiblityThreshold ? 0 : VRS_IB_DV;  // Merge quads – promoting through individual directions to all directions result.vrsRate |= ddx( result.vrsRate ); result.vrsRate |= ddy( result.vrsRate ); 

梯度历史滚动(FIFO队列):保留4个重投影的帧。

// VRS history roll during gradient detection at quad resolution // Reprojection happens in main VRS tile shader. if(((dispatchThreadID.x | dispatchThreadID.y) & 0x1) == 0) {   uint vrsHistoryRate = vrsRateRWTex[dispatchThreadID >> 1];   vrsRateRWTex[dispatchThreadID >> 1] = (freshResults.vrsRate & 0x3) | ((vrsHistoryRate<<2) & 0xFC); } 

VRS渲染遮罩生成:

  • CS作业消费者:速度缓冲器,4帧VRS图像遮罩历史,带重投影和不一致拒绝[JIM17],来自prepass的FMask–生成VRS几何遮罩。

  • CS作业生产者:

    • 重新投影4帧VRS图像遮罩历史,8位–4 x 2位图像遮罩一次性重投影,稍后在梯度检测过程中旋转。
    • VRS渲染遮罩(逐quad),在最后4帧中,8位重新排序的FMask或2位图像掩码,如果VRS图像掩码3个样本且FMask3,则首选FMask,优化重几何体的不连续渲染,如alpha测试的草,FMask是按样本顺序重新排序的,以避免在采样时进行间接排序,仅用于解块(de-blocking)和重建。
    • 4XMSA模板更新到点画渲染模式。源于VRS渲染模板,直接写入重叠的texture2D的模板目标。
    • VRS波前缓冲区(每像素)。

此外,软件VRS的绘制过程涉及了prepass、Forward+等过程,而后续的图像重建涉及解块、解析等过程,文中还对CS作业进行了优化,包含*面迭代、wave压缩、太阳可见性作业。经过这些复杂的操作之后,在森林如此复杂的情况下,可以获得超过1ms的提升:

而在室内这种辐照度稍低的场景,收益更明显:

挑选软件VRS管线的部分:

For the Alliance! World of Warcraft and Intel Discuss an Optimized Azeroth分享了2020年的魔兽世界的渲染优化。

WOW对Direct X 12(和Metal)所做的工作包含多线程命令列表生成、异步管线创建、异步纹理上传,帧率从50提升到了80。此外还使用了硬件的VRS,减少像素着色器工作的硬件功能,其工作原理是对像素组而不是每像素进行像素着色器调用,可以认为是MSAA的扩展,类似于着色速率下的LOD。控制VRS的方法有:按绘制调用、通过屏幕空间遮罩、从顶点/几何体着色器。


屏幕空间的大三角形是VRS的最大受益者:

VRS的过渡可能相当不明显,2x1利用假定的16:9屏幕比例,实现更*滑的过渡:

使用VRS的最佳位置:查找重像素着色器和较低,视觉质量可能在感知上不明显,如地形、运动模糊、DOF、远处的物体等。VRS的优点是保留边缘/轮廓,与基于边缘的AA(如CMAA)配合良好,控制哪些系统应用了VRS,尤其适用于基于深度的文本效果,不需要放大通道。渲染比例优点是可以降低顶点和带宽成本,*滑的强度范围,如1920到3840之间的所有值。VRS的限制是如果相对屏幕空间的三角形密度较高,则效益最小,在粒子系统上使用VRS可能看起来有点笨重。

Elwynn Forrest场景的性能,1x1时为61FPS,2x2时是68FPS,4x4时是76FPS,在*衡视觉质量时,通常有5%到10%的提升。总之,按绘制调用的VRS非常容易集成(在DX12引擎中),可以无缝地动态打开,可以降低像素着色器的成本,同时将质量降低到最低。


随着新的强大GPU IP的发布,Imagination Technologies一直在与Roblox密切合作,研究低水*的性能改进,为玩家提供最佳的游戏体验。Rendering Roblox Vulkan Optimisations on PowerVR探索了Roblox游戏内的体素照明系统、EVSM阴影贴图实现、着色器优化,以及为PowerVR优化的Roblox渲染器的其它最新功能,还将阐述在移动设备上进行分析和优化的过程,并研究如何使用异步计算来提高PowerVR的性能。

Roblox的光照系统的目标是不要烘焙,每个物体都可以在任何时间点移动,不能完全禁用照明——对视觉和游戏性的影响很大,想要从内容中获得最小的“性能”影响,想要在低端笔记本电脑/手机上运行(D3D9/GL2/GLES2类),想要在高端笔记本电脑/手机上获得好看的照明效果。

照明系统的高级概述,已有的特征包含光源(太阳/月亮、天空、局部灯光),几何体和光源是动态的,所有光源都可以投射阴影,粗糙体素照明无处不在,产生高效柔和的照明,阴影贴图支持从中端到高端和高质量的太阳阴影。将来会引入前向+:高端、高品质的局部光源,更好的体素照明:更好的天空光。

照明系统的相位0是体素:大部分工作都是在CPU上完成的,体素化动态几何,计算每个体素的灯光影响,如阳光、天空光、累积局部光RGB,将信息上传到GPU(RGB:灯光 + 天空光 x 天空颜色,A:阳光),片元着色器中最终照明。管理CPU性能:体素网格被分割成块,只有少数块会每帧更新,固定质量,但更新可能“过时”,更新内核使用手工优化的SSE2/NEON。管理GPU性能:使用双线性过滤的单个3D纹理查找,GLES2使用2个2D纹理图谱查找模拟3D纹理查找,完全解耦的几何体与光源复杂度。

照明系统的相位1是更好的体素:保持系统的整体设计,HDR光源颜色使用RGBM编码以节省空间,单独存放天空光,更好地将sky集成到BRDF中,各向异性占用率,体素化器为每个体素保留3个轴向值,对内容兼容性至关重要,结果更接*阴影贴图/前向+。GLSL的优化如下:

// 分组标量算法 vec_1 = (float_1 * vec_2) * float_2;  -->  vec_1 = (float_1 * float_2) * vec_2;  // inversesqrt比sqrt开销更低 vec_1 = sqrt(vec_2);  -->  vec_1 = vec_2 * inversesqrt(vec_2);  // abs() neg() and clamp(…, 0.0, 1.0)可以免费 float_0 = max(float_1, 0.0);  -->  float_0 = clamp(float_1, 0.0, 1.0); vec_0 = vec_1 *vec_2 +vec_3;  -->  vec_0 = clamp(vec_1 *vec_2 +vec_3, 0.0, 1.0); 

使用的寄存器太多,导致单个cluster中处理的线程更少,导致利用率降低。允许PowerVR使用16位浮点,降低寄存器压力,增加占用率(高达100%)。对于PBR着色器,A系列的周期数减少9%,Rogue(Oppo Reno)的周期数减少12%,利用率提高33%。对于PBR+IBL着色器,A系列的周期数减少9%,Rogue(Oppo Reno)的周期数减少20%,利用率提高36%,使用PVRShaderEditorto分析周期数和汇编。

在PowerVR上分析Roblox。

照明系统的第2阶段是阴影图:体素阴影太粗糙,阴影图来救援!优良的品质,挑战是“柔和”阴影、重新渲染成本高、许多阴影投射光源,仅释放太阳阴影以简化。渲染阴影图:级联分块影贴图,1-4个级联,取决于质量水*,远级联被分割成块,以便能够阻止阴影更新;使用了CSM滚动,是级联阴影贴图渲染的加速技术;当需要更新级联时,只需在一个通道完成,如果多个tile被标记为脏,将在这些tile中重新渲染几何体,CPU进行逐块的视锥体剔除。灯光方向更改会使缓存无效,还没有找到解决这个问题的好办法。阴影柔和度:想要大范围的阴影贴图“柔和”,宽PCF内核在高分辨率下成本昂贵,由于透明的几何结构和*铺机性能,屏幕空间过滤不切实际,Roblox使用EVSM,将阴影渲染与采样完全解耦,但存在漏光…缩小Z范围!将阴影*截头体紧密贴合到接收几何体,渲染投射者几何体时的“*移(Pancaking)”(VkPipelineRasterizationStateCreateInfo::depthClampEnable),采样EVSM时过度变暗,接着使用标准的两通道模糊(水*和垂直)来模糊阴影图。移动高斯模糊进行计算?

可分离核高斯模糊使用两个1D过程执行2D高斯模糊,数学等效(秩1矩阵),2n的纹理获取,而不是\(n^2\)的纹理获取。

计算高斯模糊收集算法:PowerVR上的最佳工作组大小为32,处理8x8区域的8x4工作组大小,多次尝试循环着色器(此处为每线程2个纹素):

将包括周围区域在内的深度值读取到共享内存中,减少纹理获取的次数。

Morton顺序:使用VK_IMAGE_TILING_OPTIMAL创建的图像使用Morton排序进行寻址优化,通过加载对齐的texel区域(512位缓存行)提高缓存效率。

任务打包:在每个帧的两个队列之间交替提交命令,允许独立于帧的调度并增加任务打包!可能需要在两组资源之间交替使用。更多开发者推荐Imagination Tech Doc

使用计算的改进:5x5模糊的帧速率提高40%!(MeizuPro 7 Plus PowerVR 7XTP)

idTech 7: Rendering the Hellscape of Doom Eternal分享了idTech 7的渲染技术。idTech 7完全使用前向渲染,uber着色器仍然很少,更大的关卡和更复杂的环境,更多流加载,所有*台上的低级API,在相同分辨率的控制台上仍保持60 fps。

在Doom中使用了混合装箱(Hybrid Binning)[Olson12],面临的挑战是Doom中更大的场景,远处的小光源和贴花最终形成一个大簇,敌人有轻型装备,在战斗中,弹丸和冲击贴纸的动态体积更大,艺术家们想要放置更多的灯光和贴花,想要转移到GPU剔除以减少CPU负载,分簇与分块的新混合。

混合装箱(Hybrid Binning)的灵感来源于“改进的拼贴和集群渲染挑选”[Drobot2017],问题是某些场景中有数千个灯光或贴花,由于状态更改,硬件光栅tile装箱效率低下,预算非常紧张(<500us)。

计算着色器光栅化:为了简单起见,只装箱六面体,低分辨率,需要保守光栅化,许多边缘案例,艺术家们会找到它们,反向浮点深度以实现精度。Binned光栅化[Abrash2009]:设置和剔除、粗糙光栅、精细光栅,将位域解析为列表,这些过程都涉及很多细节,由于已经有不少章节阐述过此技术,故此处略过。

几何体细节:艺术家想要几何定义的小贴花,创作网格的一部分。挑战是几乎没有额外的帧预算,前向渲染器:无法在G缓冲区上混合,G缓冲混合太慢,即使切换到延迟。

来自网格UV的投影矩阵,世界空间->纹理空间,每个投影使用2x4的矩阵(8个浮点数=32字节),在磁盘上存储对象->纹理的空间,与模型矩阵相乘得到世界->纹理空间矩阵,每个实例的几何贴花都需要内存。将索引渲染到R8缓冲区:后深度通道,大于等于的深度比较,深度偏移以避免z-fighting,贴花需要共面,此通道要耗费50us。片元着色器中每个子网格投影列表的遍历:绑定到实例描述符集的列表,任意混合,因为没有G缓冲区传递。

限制:每个子网格最多254个投影,一个贴花可能需要多个投影,不能很好地做曲线几何,每个实例需要投影存储,目前每个关卡最多1000个贴花,装箱贴花始终位于顶部,每帧计算的动画几何体投影。

几何缓存:基于[Gneiting2014],从Alembic缓存编译,改进的压缩,预测帧,分层B帧,前向和后向运动预测,用于比特流压缩的Oodle Kraken,如果缓存相同,实例可以共享流数据块,可带动画的颜色和UV。

自动蒙皮:位置流的大数据速率降低[Kavan2010],骨骼矩阵像其它流一样被量化和压缩,仅适用于某些网格,仍然支持顶点动画。

3字节切线坐标系:为每个帧计算切线坐标系并流式传输,储存两个用于法线和切线的向量非常昂贵。想法:仅存储切线的法线+旋转,用叉积重构副切线,2字节用于八面体标准编码(对于顶点法线已足够好),1字节用于旋转。解码:需要确定的正交向量来旋转切线,法线与向量(如\((1,0,0)^T\))的粗糙叉积会导致奇点,而罗德里格斯的旋转公式[Euler1770]可计算出切线:

\[T = T_b \cos \alpha + (N \times T_b) \sin \alpha \]

因为向量是正交的,所以上一项被抵消。

此外,idTech 7还支持材质混合、GPU三角形剔除、GPU几何合并。其中几何合并的实现细节如下:

  • 所有资源都是全局可索引的。在单个全局池中分配的所有顶点缓冲区,与几何流一致,所有纹理和缓冲区描述符的全局数组。
  • 在几何体集中使用相同的PSO将最多256个可见网格分组。
  • 剔除着色器为每个几何体集生成自定义间接索引缓冲区。在每个32位索引中打包顶点ID和实例ID,每个几何体集一次绘制调用,在一次紧凑的绘制调用中渲染256个网格。
  • VS:使用实例ID+几何图形集ID检索实例数据。几何图形集ID作为内联常量发送,实例ID从索引值中提取,顶点缓冲区获取、实例描述符集等的偏移量。
  • 着色器编译器生成“可合并的”着色器变体。不同纹理/缓冲区获取的序列化,实例数据的附加间接寻址,SSBO获取顶点数据,而不是顶点属性。
  • 异步计算上的计算剔除/合并与阴影渲染并行。

从左到右:场景样例、网格、几何体集。

结果:通过三角形剔除+合并,在密集场景中节省高达5毫秒的GPU,在VS中几乎没有浪费,基本上摆脱了固定的功能瓶颈。类似的CPU节省:对深度和不透明通道重复使用相同的间接索引缓冲区,在CPU可见性通道期间完成的设置,已经令人尴尬地并行。对于已经在执行标量优化的代码非常有用,在排列数较低的情况下效果最好,无论如何,都尽可能地降低着色器计数。理论上的权衡:实例数据获取现在是不同的,通常发生在VS内部,最终难以察觉。

文中还涉及水体渲染、半透明表面等技术。

随着硬件的最新发展,在GPU上追踪光线的功能比以往任何时候都更容易实现,游戏引擎已经利用了这一点,将光线追踪效果(如反射、软阴影、环境遮挡或全局照明)集成到实时管线中,在可能的情况下替换屏幕空间中更*似的光线追踪效果。大多数(如果不是所有的话)效应都依赖于二维蒙特卡罗积分。沿着这条路,我们可以想象冒险进入更高的维度,添加更多间接反弹,或更多分布式效果(自由度、运动模糊),更接*完整的路径追踪。需要调整采样方案,以确保最佳利用通常有限的实时/交互式预算。From Ray to Path Tracing: Navigating through Dimensions便阐述了如何逼*上述的效果。

光线追踪从离线到实时的几个重要方面:

  • 明智地选择光线。采用蒙地卡罗积分法、方差、重要性采样、多重重要性采样。
  • 仔细选择你的(非)随机数。域扭曲、准随机序列、低差异、分层。
  • 把你的射线预算花在最有用的地方。自适应采样。
  • 理解并防止错误。强度夹紧、路径正则化。

蒙特卡罗快速回顾:

准蒙特卡罗(QMC):确定性、低差异序列/集合(Halton、Hammersley、Larcher-Pillichshammer)比随机的收敛速度更好,例如Sobol或(0-2)序列不需要知道样本数量,奇妙的分层特性。

见下图,我们有一个大面积的光在倾斜,漫反射地面上。相机正前方是一片薄玻璃片,折射率为1(因此完全透射)。这将是一个相当常见的场景的调试版本,其中场景的大部分(如果不是全部的话)都在一个窗口后面。

因为我们对光线有一个固定的分裂因子,索引也很容易跟踪:对于每个光采样计算,我们使用样本i到i+3。诚然,如果每个照明位置都与对应位置非常不同,就像康奈尔盒子的情况一样,它不会有太大的区别(但考虑到属性或我们的顺序,至少会同样好)。然而,如果位置更相似(或者甚至与我们当前场景中的位置完全相同)。。。

在地面上使用64个相机直接可见的光源样本。

下图是一个稍微不同的场景,使我们的采样预算更容易测试。场景中的薄玻璃片更粗糙,为了更好地欣赏这种粗糙的效果,对地面进行纹理处理。

若使用之前一样的采样方式,由粗糙玻璃产生的BSDF射线的相干度当然不如以前,由此获得了下图那样更加毛躁的图像:

我们遇到了一个像素之间相关性很差的情况,4D序列中的一些维度在一个像素内的相关性很差。为此,我们想查看4D点的不同2D投影,遵循Jarosz等人(在正交阵列论文中使用的可视化约定:可以在轴上看到对应于每个维度的索引,在数组的每个单元格中,将显示相应的2D切片,是尺寸(0,1)和(2,3)的2D切片,是在采样例程中使用的切片,它们其实是一样的。

现在让我们看看下图的“诊断”切片,(0,3)和(1,2)也是相同的。剩下的(0,2)和(1,3)相当于(0,0)和(1,1)。。。

实际上,需要使用高纬度的Sobol序列。有许多可能的Sobol序列,[Grünschloß]和[Joe 2008]的Sobol序列优化了低维2D投影的分层特性,用于低样本数量:

虽然样本数量少,但任何维度配对都会产生非常好的结果!在我们之前的各种填充尝试中,我们注意到的问题已经消失了。

如果我们在维度上上升,我们确实可以找到质量明显最差的2D切片(==>确保对被积函数的最重要部分使用最低维度):

额外的改进包括将Owen指令应用于所有维度,如前所述,它有助于打破序列特征的对齐模式,并提高收敛速度。由于这不是一个快速的过程(特别是对于实时),它可以预计算并存储大量样本==>这是HDRP中的操作,256D中有256个点。

锦上添花:屏幕空间中的蓝色噪点:

白噪点和蓝噪点的对比:

最终维度:当进入高维时,同时考虑所有维度,避免考虑递归的低维积分(即使这样开始更自然)。实时(预计算)选择序列:一种低差异采样器,将蒙特卡罗方差作为蓝色噪声分布在屏幕空间[Heitz 2019],渐进式多抖动样本序列[Christensen 2018]。*期在高维集合方面值得注意的工作:蒙特卡罗渲染的正交数组采样[Jarosz 2019]。实时路径跟追踪的未来?实时重构光照传输[Wyman 2020]。

Real-Time Samurai Cinema: Lighting, Atmosphere, and Tonemapping in Ghost of Tsushima阐述了游戏Ghost of Tsushima中所使用的光照、天空大气、色调映射等渲染技术。

光照:艺术方向呼唤“程式化现实主义”,光照模型是基于物理的,用物理上合理的值编写的材质,一些摄影测量,从动态天空和局部光照传输数据计算运行时的间接照明,基于物理的天空模型,用于天空、云、薄雾和雾粒子,使用自定义技术以HDR和色调图渲染,照明可能会偏离物理正确性,艺术家可以在全局范围内进行调整,以获得理想的外观。间接照明包含漫反射和镜面反射,大气体积照明包含天空与云彩、薄雾、粒子等,色调映射包含局部色调映射算子、自定义色调映射颜色空间和白*衡、Purkinje shift(微光视觉模拟)。

间接光的漫反射:Tsushima的大小、动态时间和天气需要一种新的方法:二次SH探针的规则网格,每个200m方形tile使用16x16x3,总共20x80个tile。四面体网格用于更复杂的情况,如城镇、村庄、农庄、城堡等。通过位置流入,覆盖常规网格,在交界处混合。

运行时重新照明:需要在运行时更新辐照度探针,离线阶段捕获天空可见度,编码为2度SH(单通道),不捕获辐照度。在运行时将天空投射到SH,将天空SH乘以天空可见度,与兰伯特余弦瓣卷积。

反弹的天空光:是可行的,但只提供直接的天空照明,无反射光。完全转移矩阵?内存太多(9倍),捕获时间太长。假设整个半球的天光是大致恒定的,用统一的白色天空照亮世界,捕捉“反弹天空可见度”。乘以*均天空颜色(从第0阶的SH开始)。

添加太阳/月亮反弹:在假想的地面和墙壁上反射光线,投影\(-\hat{r}_g\)和\(-\hat{r}_w\)到SH;窗口化以避免负波瓣,按RGB灯光强度缩放(由动态云阴影调制),乘以反弹的天空SH,并将其添加到之前的结果中。

方向性增强:2级SH的频率相对较低,间接照明在很多地方看起来都很*淡,沿线性SH最大值方向向三角形的Lerp,计算便宜,直接部分和反弹部分分别计算,在所有天气状态下有着25%的增强。

去振铃(deringing):不能保证最终辐照度在任何地方都是正的,对天空能见度应用固定的去振铃会降低方向性和保真度,相反,在运行时计算天空亮度和最终辐照度,一个牛顿迭代找到最小值,使用在CPU上计算的深度为3的二叉搜索树。

去振铃关闭(左)和开启(右)对比:

漏光解决方案:将四面体网格探针分为内部探针和外部探针,为表面指定一个0-1的内部遮罩\(w_{interior}\),几何体属性或着色器参数,顶点绘制,延迟贴花。按正常情况计算重心,然后乘以重量:\(w_{interior}\)用于内部探头内部,\(1-w_{interior}\)用于外部探头。重新规范化重心(如果全部为0,则使用原始重心)。

间接光的镜面反射:Seattle in Infamous使用了230个静态反射探头,已经在突破内存限制了。而Tsushima使用了235个,重新照明所需的额外数据,随需应变,每生物群落默认探针,实例化内部探针,增加了对嵌套探针的支持,一次最多128个重照明探针。

离线捕获的数据:反照率立方体图(BC1格式),法线+深度立方体贴图(BC6H格式),RG:八面体法线编码,深度(双曲线),所有立方体贴图256x256x6。

反射探针新照明:在异步计算中执行循环(每帧一个),用远距阴影图集着色阴影,每tile的阴影图,每个tile有128x128个纹素。间接照明用单SH样本,也用作反射探针亮度的标准化,使用过滤的重要性采样(filtered importance sampling)进行预过滤,使用GPURealTimeBC6H压缩至BC6H,减少内存占用并提高采样性能。

立方体图阴影追踪:远阴影图不足以对内部进行阴影处理,低阴影分辨率和LOD的组合。观察:深度立方体贴图有很多遮挡信息,重新照亮每个立方体贴图纹理时:求*行光线的交点,使用立方体贴图体积,交叉点处的采样深度,天空深度⇒ 未遮挡,使用4x4 PCF。相当粗糙,但又便宜又有效。

水*遮挡:解释由于法线贴图法线\(\hat{n}_m\)相对于顶点法线\(\hat{n}_v\)的倾斜而导致反射锥上基础几何体的遮挡,快速、*似、合理的结果,对于GGX粗糙度,包含能量分数的锥半角如下所示:

\[\tan \theta_c = \alpha \sqrt{\cfrac{u_e}{1-u_e}} \]

\[\begin{eqnarray} \theta_o &=& \min \Big(\theta_r + \theta_c - \cfrac{\pi}{2}, \ 2\theta_p \Big) \\ u_o &=& u_e \ \text{smoothstep}(0, \ 2 \theta _c,\ \theta_o) \end{eqnarray} \]

天空大气光使用了预计算的3D LUT,对Mie辐照度应用阴影,对瑞利辐照度应用环境遮挡,Haze使用AO的*均天空可见度,云渲染为抛物面纹理(paraboloid texture),每帧将当前太阳和月亮角度的3D LUT重采样为2D,使用立方滤波进行上采样,以避免日出和日落时出现锯齿,使以后的查找更便宜。此外,还自定义了瑞利颜色空间:

云体渲染为768x768抛物面纹理,用云密度来抗锯齿,对Mie散射使用Henyey-Greenstein相位函数,实现了体积雾度(局部光、抗锯齿)。此外,还阐述了粒子光照。

色调映射方面,在Infamous的游戏中,努力维持物理上合理的漫反射反照率值,创建了漫反射颜色参考图:

切换到主要基于照度的曝光系统,仅当高光亮度超过阈值时才使用亮度。处理动态范围时,的室内照明比较暗,天空驱动了大部分照明。人类视觉系统能够感知非常大的动态范围,双边过滤器可用于保持图像细节,同时降低整体对比度。

色调映射对颜色空间也进行了处理。渲染颜色中的每通道色调映射,空间裁剪饱和颜色为青色、品红色和黄色只应用每通道的“Reinhard”操作符:

\[c_i^{out} = \cfrac{c_i^{in}}{1+c_i^{in}} \]

尝试了部分颜色空间:ACES2065-1 (AP0)、ACEScg (AP1)、Rec.2020、DCI-P3,ACEScg是其中最好的,但仍然让红色过于偏向黄色,调整后的红色主x坐标(0.713⇒ 0.75)。


效果对比:

此外,色调映射还处理了白*衡、Purkinje Shift等效果。

Experimenting with Concurrent Binary Trees for Large Scale Terrain Rendering分享了Unity中使用并发二叉树进行大规模地形渲染的新技术的结果,包含并发二叉树的基础和在计算大规模地形几何体的自适应细分方面的好处,深入探讨将原始技术集成到Unity游戏引擎中的最新努力。下图的绿色节点是二叉树表示的叶节点,对应于想要为细分方案显示的三角形:

这是一个和归约树,存储在并发二叉树的所有其它级别。它逐步将位字段中设置为一个(或叶节点)的位的数量相加,直到根节点,提供了由位字段编码的二叉树中叶节点的总数。然后,通过遍历这个和归约树,提供了一种在忽略零的情况下循环设置为1的位的方法,或者换句话说,在叶节点上循环。回到实际例子中,编码的二叉树代表最长的边二等分,使用它为细分中的每个活动三角形分配一个线程。

初始化曲面细分:选择最大细分深度,CBT编码完全可能的二叉树,直到一个设定的深度。如果改变,重新编码CBT,初始化CBT缓冲区,无符号整数数组,和归约树,位字段打包为uint。

更新曲面细分:

渲染曲面细分:

同步更新渲染:

结论:CBT是一种具有低存储成本和良好性能的大规模地形几何绘制方法,高度可定制的LOD标准。


14.5.3.2 光影技术

Deferred Lighting in Uncharted 4分享了神秘海域4的延迟光照。

目标和动机是许多需要读取/修改材质数据的屏幕空间效果(粒子、贴花),以及更重要的SSR、立方体图、间接阴影等,无法避免保存材质数据。紧凑型GBuffer的在高密度几何物体下开销大,前向着色器中照明的复杂性增加了难以置信的寄存器压力,进一步拖累了几何通道的速度。

神秘海域4最终采用了完整的延迟(fully deferred)。GBuffer必须支持游戏中的所有材质,不过当时的硬件有很多内存和带宽。GBuffer是每像素16位无符号缓冲区,在生产过程中不断地在特性之间移动位,大量的视觉测试来确定各种特性需要多少位,大量使用GCN参数封装内部函数。

第三个可选GBuffer用于更复杂的材质,根据材质的类型对其进行不同的表达,使用可选GBuffer的材质包括布料、头发、皮肤和丝绸。GBuffer的表达是相互排斥的(即不能将布料和皮肤放在同一个像素中),此约束在材质编写管线中强制执行。如果材质不需要,则可选GBuffer既不写入也不读取。

问题是延迟着色器很快就会变得非常臃肿,因为必须支持皮肤、布料、植物、金属、头发等等,更不用说所有的光源类型。可以通过材质分类(classification)来改进:保存材质“ID”纹理,不是真正的材质ID,只是使用的着色器功能的位掩码,12位压缩为8位(通过考虑特征互斥性)。对于每个16x16的tile,使用整个tile的材质遮罩将其索引到查找表中,查找表是预计算的,拥有最简单的着色器,支持tile中的所有特性。

uint materialMask = DecompressMaterialMask(materialMaskBuffer.Load(int3(screenCoord, 0)));  uint orReducedMaskBits; ReduceMaterialMask(materialMask, groupIndex, orReducedMaskBits);  short shaderIndex = shaderTable[orReducedMaskBits]; 

原子地将tile坐标加入到该着色器将计算光照的tile列表,原子整数也是dispatchIndirect参数缓冲区的dispatch数量。

if (groupIndex == 0) {     uint tileIndex = AtomicIncrement(shaderGdsOffsets[permutationIndex]);     tileBuffers[shaderIndex][tileIndex] = groupId.x | (groupId.y << 16); } 

分类使得性能已经有了巨大的进步,以前也有文献曾使用过类似的技术。

还可以进一步优化。以布料着色器为例,对于所有像素都是布料的tile(即将布料材质掩码位设置为1),该分支所做的只是增加开销,因为它应该始终评估为真。创建另一个预计算表,当tile中的所有像素具有相同的材质遮罩时使用该表——“无分支”排列表。在分类过程中检查该情况,并使用适当的表,不仅删除了分支,还为全局编译器优化提供了机会。

// 之前 short shaderIndex = shaderTable[orReducedMaskBits];  // 之后 bool constantTileValue = IsTileConstantValue( … ); short shaderIndex = constantTileValue ? branchlessShaderTable[orReducedMaskBits] : shaderTable[orReducedMaskBits]; 

改进前(左)后(右)的对比。

在最坏情况下的性能改善昂贵的场景:4.0ms——无任何优化(“uber着色器”),3.4ms(-15%)——通过选择最佳着色器,2.7ms(-20%,整体-30%)——使用无分支着色器。*均而言,无分支着色器可以提供额外的10-20%的改进,而且成本很低,而选择最佳着色器*均可以提高20-30%。使得我们可以在不影响基本性能的情况下实现材质的复杂性和多样性,为一个着色器(例如丝绸着色器)添加复杂性不会影响游戏的其余部分。经过几次迭代之后接口实现依然干净透明,额外的好处是分类计算着色器在异步计算上运行——几乎不影响运行时。

还可以更进一步改进,根据光源类型分配不同的计算着色器,少数光源类型增加了大多数复杂性和成本。迭代很难,真正了解一位(bit)的价值,最终达到了一个良好的系统。越简单越好,可能会牺牲某些特性来获得轻微的性能提升。

接着聊镜面遮挡。立方体图不考虑局部遮挡,解决方案是有时在遮挡体内/周围添加更多立方体图,由于几何体的排列方式,不总是可行的,更不别说性能/内存成本了。在采样位置只能使用AO值,例如Frostbite的镜面遮挡,效果很好,但方向性呢?

为了获得更精确的遮挡,Uncharted 4将以某种方式用某种编码来遮挡镜面反射波瓣,该编码对采样点的遮挡方式和方向进行编码。

左:反射波瓣;右:最小遮挡圆锥——弯曲圆锥。

在离线处理过程中,用Bent Normals and Cones in Screen-Space中描述的方法生成弯曲圆锥,最小遮挡的方向定义为:

其中如果光线的长度小于一定距离,则\(d(\overrightarrow{v})\)为1,而角度:

对于反射圆锥,使用一个类似Drobot的匹配Phong/GGX lobe的想法,找到适合90%波瓣能量的圆锥。对于GGX,发现了一个简单匹配:

两个圆锥体相交,求出交点的立体角。

可以使用以下公式计算两个相交圆锥体的立体角,给定两个锥角\(\theta_1\)和\(\theta_2\)及它们之间的角\(\alpha\),交点的立体角在右边。

稍微改变变量,将\(\cos/ \sin \theta\)作为查找纹理的输入,将交点的立体角除以BRDF圆锥体的立体角\(\cfrac{1-\cos \theta_1}{2\pi}\),以获得遮挡的百分比。

float IntersectionSolidAngle(float cosTheta1, float cosTheta2, float cosAlpha) {     float sinAlpha = sqrt(clamp(1.f - cosAlpha*cosAlpha, 0.0001f, 1.f));      float tanGamma1 = (cosTheta2 - cosAlpha*cosTheta1) / (sinAlpha*cosTheta1);     float tanGamma2 = (cosTheta1 - cosAlpha*cosTheta2) / (sinAlpha*cosTheta2);      float sinGamma1 = tanGamma1 / sqrt(1 + tanGamma1*tanGamma1);     float sinGamma2 = tanGamma2 / sqrt(1 + tanGamma2*tanGamma2);      // Now we compute the solid angle of the first cone (This is divided by 2*kPi. That factor is taken into account in the texture)     float solidAngle1 = 1 - cosTheta1;      return (SegmentSolidAngleLookup(cosTheta1, sinGamma1) + SegmentSolidAngleLookup(cosTheta2, sinGamma2)) / solidAngle1; } 

当样本被严重遮挡时,返回到方向光照图的描述,保留能量,但反射细节丢失,大多数时候看起来都不错!这种方法相对昂贵,如果一个参数是固定的,函数就会简化。也许可以确定粗糙度参数,然后根据粗糙度调整最终结果。由于性能原因,它在某些级别上被禁用。不适用于动态对象(例如角色),可以使用前面提到的更简单的遮挡方法。不是基于物理的,但它解决了很多遮挡问题。可以向前推进,使用其它表示法进行更精确的遮挡,走向完全不同的方向。

Rendering Antialiased Shadows with Moment Shadow Mapping阐述了利用MSM(矩阴影图)来实现阴影抗锯齿的技术。

阴影图是实时渲染的基础技术,但由于分辨率有限,会带来严重的锯齿。以往的解决方案有PCF(百分比渐进过滤)、VSM(方差阴影图)等。PCF对每个像素执行样本过滤区域、阈值、过滤,消耗大。

对于深度分布,阴影是深度的函数,一步一个纹素,通常是单调的函数。

VSM存储\(z\)和\(z^2\),存在冗余数据,直到过滤,有2个矩,用下边界重建。

此外,还有存储傅里叶系数的CSM(Convolution shadow map,卷积阴影图),存储\(\exp(