timlly

 

 

14.4 开花期(2010~2015)

本章将阐述2010年度前期的引擎架构、渲染和相关模块的技术。

14.4.1 图形API

14.4.1.1 DirectX

在2010时代的前期,DirectX发布了11的两个小版本(11.1、11.2)和DirectX 12。

DirectX 11.1于2012年8月发布,新增的特性有:

DirectX 11.2于2013年10月发布,新增的特性有:

DirectX 12.0于2015年7月发布,新增的特性有Resource Binding、Tiled Resources (Texture2D)、Typed UAV Loads (additional formats)等。

DirectX 11的曲面细分阶段有Hull Sahder(壳着色器)、Tessellator(细分器)、Domain Shader(域着色器)。它们的作用如下:

  • Hull Sahder。有两个阶段:控制点阶段(每个控制点运行一次)和打包阶段(每个输入图元运行一次,返回细分因子)。
  • Tessellator。生成新的顶点,更高的细分因子意味着生成更多的三角形。
  • Domain Shader。每个顶点运行一次,用于每个顶点的表面计算,作为参数坐标传入的顶点数据。

DirectX 11曲面细分管线。

DirectX 11曲面细分的应用案例1。

DirectX 11曲面细分还可以应用于曲面参数化、贴花细分等:

Domain Shader开销很大,特别是小三角形效率更低,硬件希望三角形至少为8像素,优化方式是减少细分顶点的数量,以降低Domain Shader执行,Hull Shader可以调整细分因子。

利用DirectX 11曲面细分,还可以实现距离自适应细分(Distance Adaptive Tessellation)、位移自适应(Displacement Adaptive)、贴花细分的位移自适应以及基于Hull Shader的背面裁剪等技术。

此外,DirectX 11曲面细分的优化技术有:视锥体剔除、组合细分的绘图调用、方向自适应细分(使用dot(V, N) 查找轮廓块)
、减少Hull Shader的输入和输出数据、尝试将Domain Shader的工作转移到像素着色器(减少Domain Shader输出的数据)、使用Stream Out避免重复细分对象。

Direct3D 11 Performance Tips & Tricks谈及了DirectX 11的SM 5.0、资源和资源视图、多线程等方面的技术。其中SM 5.0的特点:

  • 使用Gather/GatherCmp()进行快速多通道纹理提取。

    • 使用较少数量的RT,同时仍然有效地获取,将深度存储到SSAO的FP16 alpha,使用Gather*() 获取alpha/深度的区域。
    • 仅在三个操作中获取4个RGB值,常用于图像后处理。

  • 使用“保守深度”为快速深度精灵保持提前深度拒绝有效。

    • 从PS输出SV_DepthGreater/LessEqual而不是SV_Depth。即使使用经过着色器修改的Z,也能保持提前深度剔除有效。
    • 硬件/驱动程序将强制执行合法行为。如果写入无效的深度值,它将被截取为光栅化值。

  • 使用EvaluateAttribute*()进行快速着色器AA,无需超级采样。

    • 在子像素位置调用EvaluateAttribute*(),用于程序化材质的更简单的着色器AA。
    • 输入SV_COVERAGE以计算每个覆盖子样本的颜色并写入*均颜色,图像质量比纯MSAA稍好。
    • MSAA alpha测试的输出SV_Coverage。此功能自DX 10.1以来一直存在,EvaluateAttribute*()使实现更简单,但要检查覆盖率的alpha是否已经满足需求,因为它应该更快。
  • UAV和原子:明智地使用PS散射、UAV和Interlocked*()操作。

  • 减少流输出通道。可寻址的流输出,单个通道输出最多4个流,所有流都可以有多个元素。

  • 使用几何着色器实例化编写更简单的代码。使用SV_SInstanceID代替循环索引。

  • 使用[earlydepthstencil]对PS强制进行早期深度模板测试。

    • 如果写入UAV或AppendBuffers,则可以显著提高速度。
    • 将[earlydepthstencil]放在像素着色器函数声明上方以启用它。

启用[earlydepthstencil]后,像素着色器将剔除UV之外的所有像素。

  • 使用众多新的内在函数来实现更快的着色器。

    • 快速的位操作:countbits()、reversebits()(FFT中需要)等。
    • 转换指令:f16to32() 、f32to16(),更快的打包/拆包。
    • 快速粗糙导数 (ddx/y_coarse)。
    • ...
  • 明智地使用子程序(subroutine)的动态着色器链接。

    • 子程序不是免费的,没有跨函数边界优化。
    • 仅对大型的子程序使用动态链接,避免使用大量小的子程序。

资源和资源视图的特点:

  • 减少内存大小和带宽以获得更高的性能。
    • BC6和BC7提供新功能,非常高的质量和HDR支持,所有静态纹理都应该是可压缩的。

  • 使用只读深度缓冲区以避免复制深度缓冲区。
    • Direct3D 11允许对仍绑定用于深度测试的深度缓冲区进行采样。
      • 如果深度是GBuffer的一部分,则对延迟光照很有用。
      • 适用于软粒子。
    • AMD:使用深度缓冲区作为SRV可能会触发解压缩步骤,尽可能晚地进行。

DX11的其它特性:

  • 免费线程资源创建。

    • 使用快速的异步资源创建,通常应该更快、更高并行。
    • 不要在使用资源的帧中销毁资源。销毁资源很可能会导致同步事件。
    • 避免创建、渲染、销毁的串行序列。
  • 显示列表(从延迟上下文创建的命令列表)。

    • 确保应用程序是多线程的。
    • 仅当命令构造是一个足够大的瓶颈时才使用显示列表。
    • 考虑显示列表来表达GPU命令构造中的并行性,避免细粒度的命令列表。
    • 驱动程序已经是多线程的。
  • 延迟上下文。

    • 在延迟上下文中,Map()和UpdateSubResource()将使用额外的内存。请记住,所有初始Map都需要使用DISCARD语义。
    • 请注意,在单核系统上,延迟上下文将比仅使用立即上下文慢。对于双核,最好只使用立即上下文。
    • 除非有显著的并行性,否则不要使用延迟上下文。
  • 其它。

    • 使用DrawIndirect进一步降低CPU开销。使用来自GPU写入缓冲区的参数启动实例化绘制调用/调度,可以使用GPU进行有限的场景遍历和剔除。
    • 使用Append/Consume缓冲区用于快速的流输出(stream out)。比GS更快,因为没有输入顺序约束,具有“无限”数据扩展的流输出。

14.4.1.2 OpenGL

在2010到2015年期间,OpenGL发布的版本和特性如下表:

版本 时间 特性
OpenGL 3.3 2010年3月 Mesa支持软件驱动程序SWR、软件管线和带有NV50的旧Nvidia卡。
OpenGL 4.0 2010年3月 对标Direct3D11。硬件支持:GeForce 400系列及更新版本、Radeon HD 5000系列及更新版本、英特尔Ivy Bridge处理器和高清显卡。
OpenGL 4.1 2010年7月 硬件支持:GeForce 400系列和更新版本、Radeon HD 5000系列和更新版本、英特尔Ivy Bridge处理器和高清显卡。GPU实现此规范的最小“最大纹理大小”为 16k×16k。
OpenGL 4.2 2011年8月 带原子计数器的着色器和加载-存储-原子读取-修改-写入操作到纹理;绘制从GPU顶点处理(包括曲面细分)捕获的多个数据实例,以使复杂对象能够有效地重新定位和复制;支持修改压缩纹理的任意子集,而无需将整个纹理重新下载到GPU以显著提高性能;部分硬件支持。
OpenGL 4.3 2012年8月 计算着色器、着色器存储缓冲区对象、图片格式参数查询、ETC2/EAC纹理压缩作为标准功能、完全兼容OpenGL ES 3.0 API、接收调试消息、纹理视图以不同方式解释纹理、提高安全性和稳健性。
OpenGL 4.4 2013年7月 强制缓冲区对象使用控制、对缓冲区对象的异步查询、更多界面变量布局控件在shader中的表达、同时高效绑定多个对象。
OpenGL 4.5 2014年8月 直接状态访问 (DSA) 、刷新控制、稳健性、OpenGL ES 3.1 API和着色器兼容性。

同期,OpenGL ES发布的版本和特性如下表:

版本 时间 特性
OpenGL ES 3.0 2012年8月 渲染管道增强:遮挡查询、变换反馈、实例化、MRT、ETC2/EAC作为标准;新版的GLSL ES着色语言,完全支持整数和32位浮点运算;增强纹理功能,包括保证支持浮点纹理、3D 纹理、深度纹理、顶点纹理、NPOT纹理、R/RG纹理、不可变纹理、2D阵列纹理、swizzles、LOD和mip级别钳制、加强的纹理和渲染缓冲区格式。
OpenGL ES 3.1 2014年3月 计算着色器、独立的顶点和片段着色器、间接绘图命令。
OpenGL ES 3.2 2015年8月 几何和曲面细分着色器、浮点渲染目标、ASTC、增强的混合、高级纹理目标:纹理缓冲区、多重采样2D数组和立方体贴图数组、调试和健壮性功能。

14.4.1.3 其它图形API

  • Mantle

Mantle由AMD最初于2013年开始与DICE合作开发,被设计为Direct3D和OpenGL的替代方案,主要用于个人计算机。它是针对3D视频游戏的低开销渲染API。

不久之后,AMD将Mantle API捐赠给了Khronos组织,后者将其开发为Vulkan API。简而言之,Vulkan的前身就是Mantle。2015年,Mantle的公共开发暂停,并在2019年完全停止,因为DirectX 12和源自Mantle的Vulkan越来越受欢迎。

  • Vulkan

2015年初,LunarG(由Valve资助)展示了一款Linux驱动程序,在HD 4000系列集成显卡上实现了Vulkan兼容性。

2015年8月,Google宣布未来版本的Android将支持Vulkan。2015年12月,Khronos集团宣布Vulkan规范的1.0版本已接*完成,将在符合标准的驱动程序可用时发布。

  • Metal

Metal是由Apple创建的低级、低开销的硬件加速3D图形和计算着色器API,在iOS 8中首次亮相,结合了类似于OpenGL和OpenCL的功能,旨在通过为iOS、iPadOS、macOS和tvOS上的应用程序提供对GPU硬件的低级访问来提高性能,可以与Vulkan和DirectX 12等其它*台上的低级API进行比较。

Metal自2014年6月起在搭载Apple A7或更高版本的iOS设备上可用,自2015年6月起在运行OS X El Capitan的Mac(2012 型号或更高版本)上可用。

14.4.2 硬件架构

PowerVR Graphics - Latest Developments and Future Plans阐述了2015年Imagination的PowerVR Rogue硬件架构和特性。PowerVR Rogue支持基于分块的延迟渲染器,建立在前五代技术的基础上,在2012年消费电子展上正式宣布,USC(Universal Shading Cluster,通用着色簇)是新的标量SIMD着色器内核,通用计算机是核心的最大特色,同时也非常适合传统的图形渲染。

该架构还支持延迟光栅化(Deferred rasterisation),不直接让GPU做任何像素着色,硬件支持完全延迟的光栅化和像素着色,光栅化是像素精确的。该技术被称作隐藏表面删除(Hidden Surface Removal,HSR)。

TBDR可以在渲染的所有阶段节省带宽,仅获取tile所需的几何图形,仅处理tile中的可见像素。高效处理,最大限度地利用可用的计算资源,尽可能利用硬件带宽。最大化核心效率,少激活USC以节省消耗。最小化带宽,减少纹理以省电,几何体提取和装箱通常超过每帧带宽的10%,为渲染的其它部分节省带宽。

Rogue USC是架构的搭建积木,全称统一着色簇,Rogue架构的基本构建块,成对布局,共享TPU(纹理处理单元),1、0.5和0.25的USC设计是特殊的,设计中的不同*衡,倾向于用在非游戏应用程序。

16宽的硬件,32宽的分支粒度,每个时钟运行半个任务/warp,标量SIMD,优化的ALU管线,混合使用F32、F16、整数、浮点特殊值、逻辑运算。可在IP核心中配置,F16路径有时是可选的,F16路径的性能在第一代之后显著提高。着色器中的性能:F32路径为双FMAD,F16路径下每个周期可以执行不同的操作,具体取决于着色器,不过,ISA可以使用反汇编编译器进行查询。

向量架构难以良好地编程,标量ALU好处多,虽然不是免费的午餐,但可以让性能更加可预测。


PowerVR Series6XT Rogue硬件架构。


PowerVR Series7XT Rogue硬件架构。

Series6XT到Series7XT:改变了架构的扩展方式,改进了USC,流线型ISA,新特性是硬件曲面细分、DX11兼容USC(主要是精度)、FP64。

PowerVR Graphics Wizard硬件架构,新增了光线追踪相关的单元和处理。

Wizard的3个独特功能:固定功能射线盒和射线三角形测试器,一致性驱动的任务形成与调度,流式场景层次生成器。相干引擎(Coherency Engine)可以让我们同时处理下图所示的所有光线:

PowerVR还提供了PVRTrace、PVRTune、PVRShaderEditor等开发和分析工具。

对于Rogue图形驱动程序,DDK(驱动程序开发工具包)发布流程:向PowerVR IP许可证持有人发布的参考驱动程序源代码,大约每6个月进行一次小修改,顶级客户尽早参与,DDK正式发布后不久产品中的驱动程序。

14.4.3 引擎演变

14.4.3.1 综合演变

2010前后的通用游戏引擎都具备场景管理器,包含所有对象、材质、灯光以及所有场景设置(例如分辨率、视角、抗锯齿级别等)的列表。为了避免加载已经加载的对象和纹理,可能还提供了纹理和对象缓存。其中材质可以从文件(自定义着色器)加载或动态生成(下图),材质属性可以从颜色和表面纹理等基本材质设置到具有多个纹理的视差映射等更高级的功能。

材质生成器是主要组成部分,可以根据用户定义的材质表面属性动态生成着色器,这些属性可以是用于精确定义表面属性的颜色或纹理。除了可以通过相加、相乘或混合来组合的多层颜色纹理外,还可以定义高级表面,包括凹凸、视差、环境和立方体贴图(静态或动态)。基于所有这些属性,材质生成器生成着色器,能够执行生成所需材质表面的命令。

着色器链接和编译后,默认顶点属性就可以绑定到材质,设置材质配置(通过uniform结构),着色器即可使用,其它公共变量(如变换矩阵)在统一块(uniform block)中定义并由所有着色器共享。

同时,HDR渲染管线也逐渐在游戏引擎中普及开来,以适应HDR照明的发展趋势,由于当时的显示设备大多依然是LDR,需要色调映射将HDR映射回LDR。(下图)

下图是基于物理照明模型和感知色调映射的虚拟环境HDR渲染管道的一种实现流程:

The Game Engine Space for Virtual Cultural Training: Requirements, Devices, Engines, Porting Strategies and a Future Outlook谈到了2010年的游戏*台功能的趋势及它们与文化建模的相关性和模拟,还有行业的未来发展,阐述了当时流行的游戏引擎的特点,并针对不同的设备和*台给予多维度的技术选型方案。

文中提到幻引擎 3 (UE3) 是最常用的商业引擎之一,部分原因是它的存在时间比大多数引擎都长,还因为Epic Games定期添加新功能和改进。在视觉保真度方面,虚幻引擎3与其他顶级3A游戏引擎不相上下,非常擅长在为文化培训而设计的3D虚拟环境中创建所需的真实感。

CryENGINE 3的优势是高保真图形、角色和开发工具方面的首要游戏引擎,纹理、材料、照明和动画的质量非常出色,强大的动画系统允许使用面部和全身动作捕捉,以及游戏内动画混合、同步、分层和重定向;用于环境创建、动画、材料编辑和脚本编写等的开发工具对用户友好且功能强大;具有高质量的人工智能和寻路系统,支持可视化脚本系统进行修改;对室外和室内环境都有很好的支持。

Gamebryo Lightspeed的优点是提供快速的应用程序开发框架,还支持处理声音、图形、物理和多人游戏的各种技术。Gamebryo的视觉保真度可与业内最好的引擎相媲美,曾被用于制作具有广阔景观和复杂面部细节的游戏,例如Fallout 3和Oblivion。

Unity3D的优点是一流的跨*台开发支持,强大的社区和最低的入门成本,简化了大多数工具,所有3D资源都可以以原始格式导入,从而消除了导入为引擎特定格式的任务,保留资产的原始格式允许非破坏性工作流程,从而显著提高管线效率。

以上引擎在跨*台与满足需求的排名如下图:

该文改给出了一种加速游戏开发工作流的管线:

Future graphics in games阐述了CryTek在2010年使用的渲染技术,并预测未来的图形趋势。文中对比了延迟光照、延迟着色及前向渲染在带宽和材质种类之间的对比:

文中还提到,渲染架构的突破并非易事,已由硬件供应商多次证明,尤其是最*多次尝试使用软件渲染器,围绕着庞大的基础设施的拖尾,需要多年开发经验的成果。图形架构将更加多样化,回到旧的好技术,如体素、微多边形等。

未来将给某些游戏打上烙印的替代品:基于点的渲染、光线追踪、像往常一样光栅化、微多边形,数据表示有稀疏体素八叉树(数据结构)、稀疏面元八叉树。

不得不说,当年(2010年)的这个预测真是准啊,截至当前(2022年),已经流行或逐渐流行的技术包含了基于点的渲染、光线追踪、微多边形及稀疏体素八叉树(数据结构)、稀疏面元八叉树。

稀疏体素八叉树(数据结构)的优点:数据结构是未来替代渲染的证明,非常适合独特的几何、纹理几何,纹理预算变得不那么相关,艺术自由成为现实,自然地适合自动LOD方案。缺点是基础设施和硬件都没有,稍微占用内存,非常适合光线追踪,但仍然太慢。

CryTek已经在生产中使用稀疏体素八叉树:在关卡导出期间用于烘焙几何体和纹理,存储在三角形分区的稀疏八叉树中,非常易于管理和流式传输几何和纹理,无需GPU计算(尽管有虚拟纹理),自动校正LOD构建,自适应几何和纹理细节(取决于游戏玩法)。每个级别的磁盘空间都很大!使用积极的纹理压缩,明智地烘焙,而不是整个世界。

感知驱动的图形有:基于PCF的软阴影、随机OIT、基于图像的反射、SSAO、大多数后处理、LPV、很多随机算法等,实时图形中的大多数都是假设,因为人类的感知有限。实时图形是感知驱动的,因为人的眼睛有一些特点:

  • 约350M(3.5亿)像素的空间分辨率,在这方面很难欺骗它。
  • 约24Hz的时间分辨率,非常低,给了技术操作的空间,当大于40Hz时,人眼就不会注意到闪烁。
  • 我们不会为另一台机器创建图像,我们的目标客户是人类。

欠采样/超采样的技术有:

  • 空间
    • 欠采样
      • 推断着色(Inferred shading)
    • 景深
      • 解耦采样
  • 时间
    • 时间抗锯齿
    • 运动模糊
  • 混合
    • 时空(spatio-temporal,后来的不少文献称为spatial-temporal)抗锯齿

存在混合渲染。没有灵丹妙药的渲染管线,即使是REYES也没有以原始形式用于电影。通常结合所有合适和有帮助的内容:光线追踪的反射和阴影(可能是三角形/点集/体素结构/等),体素以获得更好的场景表示(部分),屏幕空间接触效果(例如反射)等等。

*期的趋势是立体(stereoscopic)渲染,技术存在很长时间,因技术而流行,在游戏中也是如此,没有新概念,但类似于摄影艺术,一条黄金法则:不要让观众感到疲倦。Crysis 2已经具备一流的3D立体支持,使用深度直方图确定轴间距离。

CryENGINE 3中支持的立体渲染模式:强力立体渲染,带重投影的中央眼框,一只眼睛的实验性随机渲染。立体输出模式:浮雕(分色)、隔行扫描、水*联合图像、垂直联合图像、两台显示器。

为了找出当时的硬件架构的问题,使用小型综合测试(模拟 GPU 行为)模拟高度并行调度,拥有512个内核(也可以解释为共享缓存的插槽),32k个相同的小任务要执行,每个项目在一个核心上需要1个时钟(所以合成),在256到2048个线程的范围内,在总时间中考虑调度开销(任务投送、上下文切换、开销权重并不重要)。输出的几个重要参数的曲线如下图:

上图可以知道,总时间曲线可分为饱和阶段和并发并行阶段,拐点在调度开销从0爬升处。在饱和阶段总时间和执行时间不断下降,但到了并发并行阶段,调度开销随着线程数量增加而增加,总时间也随调度开销成类似的曲线增加。

另一个测试使用真正的GPU!渲染屏幕空间效果 (SSAO),带宽密集型像素着色器,每个项目在一个核心上需要1个时钟(所以合成),在5到40个线程的范围内,缓存污染在饱和状态之后立即导致峰值,时间渐*地达到更多线程的饱和性能:

调度开销是个问题,并行可扩展性,对于同质任务,饱和时达到最大值,异构工作负载如何?最小值的存在取决于调度对性能的影响,我们需要减少它,需要可配置的硬件调度程序,使用它可以实现类似GRAMPS的架构,光线追踪变得更快,SoL出现带宽瓶颈。

实际上,需要不同的原子,主要使用它来进行收集/分散操作,而必须可以处理浮点数!在大多数情况下,不需要结果:改进无回读的原子(即发即忘的概念),操作应该在内存控制器/智能内存端进行。需要一个数量级的图形原子性能。

未来的技术挑战:切换到可扩展的代码库,考虑并行和异步作业,多线程调度,更大的代码库、多个*台和API。未来的生产挑战:资产成本每年增加约50%,内容除了质量提高,还变得越来越“可互动”,考虑改进工具、管道和瓶颈以产生反作用,自动化源后端 ->资源编译器,工具越好,产出就越便宜和/或越好。

在效率上,可以降低数据精度,不需要像静止图片那样的高分辨率和清晰度,图形硬件应该挑战缓存不一致的工作负载。

总之,实时渲染管线改造指日可待,需要硬件改进,当前生产实时渲染技术的演变,准备新的表示和渲染管道,更好的并行开发基础设施,工具和创作管道需要现代化,考虑服务器端渲染:可能会彻底改变方向。感知驱动的实时图形是技术驱动力,避免图形技术中的恐怖谷。

DirectCompute Optimizations and Best Practices分享了DirectCompute的概念、特性、机制、使用及在NVIDIA GPU内的优化技巧。

DirectCompute(直接计算)适用于Windows Vista和Windows 7的微软标准GPU计算*台,在DX10和DX11硬件受支持,CUDA架构的另一个实现,和OpenCL、CUDA C是同级API。

DirectCompute允许通过计算着色器在CUDA GPU上进行通用计算,与Direct3D资源相互操作,包括所有纹理特征(立方体贴图、mip 贴图),类似于HLSL,在Windows上跨所有GPU供应商的统一API,保证跨不同硬件有相同的结果。

DirectCompute程序将并行工作分解为线程组,并调度多个线程组来解决一个问题。如下图,调度(Dispatch)是线程组的3D网格,数十万个线程;线程组(Thread Group)是线程的3D网格,数十或数百个线程;线程(Thread)是着色器的一次调用。

并行执行模型如下图,同一组中的线程并发运行,不同组中的线程可以同时运行。

内存合并(Memory Coalescing)是half-warp的协调读取(16个线程),是全局内存的连续区域:64字节 - 每个线程读取一个字:int、float、...,128 字节 - 每个线程读取一个双字:int2、float2、...,256 字节 – 每个线程读取一个四字:int4, float4, ...

内存合并的附加限制有区域的起始地址必须是区域大小的倍数,half-warp中的第k个线程必须访问块中的第k个元素,例外情况是并非所有线程都必须参与,比如预测访问、half-warp内的分歧。

读取浮点数的合并访问的示例如下图,上排是所有线程都参与,下排则不是:

读取浮点数的合并访问的示例如下图,上排是按线程排列访问,下排则不是,因为是未对齐的起始地址(不是64的倍数):

对于合并(Compute 1.2+的GPU),在10系列架构中大大改进了合并功能,硬件将half-warp内的地址组合成一个或多个对齐的段(32、64 或128字节),一个段内地址的所有线程都由一个内存事务处理,无论段内的顺序或对齐方式如何。下图显示了对于未对齐的起始地址(不是 64 的倍数),递归减少事务尺寸以最小化尺寸:

共享内存库寻址在无冲突和有冲突的图例如下:

2路冲突和9路冲突的图例如下:

什么是占用率(Occupancy)?GPU 通常同时运行1000到10000个线程,更高的占用率 = 更有效地利用硬件,在硬件中通过在任何给定时刻并发运行的warp(32个线程)执行的并行代码,线程指令按顺序执行,通过执行其它warp,可以在硬件中隐藏指令和内存的延迟。尽可能最大化占用率,占用率为1.0为最佳方案。

\[\text{占用率} = \cfrac{\text{驻留warp数}}{\text{最大可能的驻留warp数}} \]

一个或多个线程组驻留在单个着色器单元上,占用率受资源使用限制:线程组大小声明、线程组共享内存使用、线程组使用的寄存器数。示例:一个硬件着色器单元最多8个线程组、48KB总共享内存、最多1536个线程,以256个线程的线程组大小启动着色器并使用32KB的共享内存,导致每个硬件着色器单元只能运行1个线程组。此时受到共享内存的限制。

调度/线程组大小启发式:

  • 让线程组数 > 多处理器(multiprocessor)数。所有的多处理器至少有一个线程组来执行。
  • 线程组数/多处理器数 > 2。多个线程组可以在多处理器中同时运行,不在屏障处等待的线程组使硬件保持忙碌,视资源可用性而定 – 寄存器、共享内存。
  • 线程组 >100以扩展到未来的设备。以流水线方式执行的线程组,每次调度1000个组将跨多代扩展。
  • 线程/线程组数是warp尺寸的倍数。warp中的所有线程都在工作。

DirectCompute优化的用例之一是并行规约(Parallel Reduction),常见且重要的数据并行原语(例如,求一个数组的总和),易于在计算着色器中实现(更难做到正确)。文中介绍了7个不同的版本,展示了几个重要的优化策略。

每个线程块中使用的基于树的方法,需要能够使用多个线程块,处理非常大的数组,让GPU上的所有多处理器保持忙碌,每个线程块减少数组的一部分,但是如何在线程块之间传递部分结果呢?

着色器分解,通过将计算分解为多个调度来避免全局同步。

交叉寻址,会引入新问题——共享内存库冲突。

顺序寻址。

不同并行规约的算法性能对比。

文中还提到了多GPU并行,多个GPU可在单个系统中用于任务或数据并行GPU处理,主机显式管理每个GPU的I/O和工作负载,选择最佳分割以最小化GPU间通信(必须通过主机内存发生)。

多GPU和CPU的通讯模型,可以实现任务并行和数据并行。

内存合并(矩阵乘法),每次迭代,线程访问A中的相同元素,仅适用于CS 1.2+。

DirectCompute Performance on DX11 Hardware阐述了DirectCompute的特点、收益、优化及性能监测等方面的内容。

文中说到使用DirectCompute的原因有允许对GPU进行任意编程(通用编程、后处理操作等),更好地针对具有严重TEX或ALU瓶颈的PS,使用CS线程来划分工作并*衡着色器。虽然并不总是能战胜PS,但即便*衡良好的PS也不太可能被CS击败。

GPU是面向吞吐量的处理器,延迟由工作覆盖,需要提供足够的工作以提高效率,寻找细粒度的并行性,简单的映射效果最好(屏幕上的像素、模拟中的粒子)。如果有助于避免往返主机的数据交换,则在GPU上运行小型计算仍然是有利的,包含延迟的收益,例如为后续的内核启动或绘图调用准备参数,与DispatchIndirect()结合,无需CPU干预即可完成更多工作。

NVIDIA的GPU是标量,不需要显式矢量化,在大多数情况下不会有影响(但也有例外),将线程映射到标量数据元素。AMD的GPU是向量,向量化对性能至关重要,避免依赖标量指令,使用IHV工具检查ALU使用情况。

相比CS4.0,CS5.0的优势很多,如线程、线程组共享内存、原子灵活性等,利用CS5.0的功能通常运行更快。声明合适数量的线程组对性能至关重要:

numthreads(NUM_THREADS_X, NUM_THREADS_Y, 1)
void MyCSShader(...)
{
    (...)
}

总线程组大小应高于硬件的wavefront大小(大小因GPU而异,ATI 的最大为64,NV的为32),避免尺寸低于wavefront尺寸,避免numthreads(1,1,1) 这样的线程组,较大的值通常适用于各种GPU,使用低端GPU更好地扩展。

使用线程组时,尝试在组中的所有线程之间*均分配工作,动态流控制将为线程创建不同的工作流,意味着工作较少的线程将处于空闲状态,而其它线程仍处于忙碌状态:

[numthreads(groupthreads, 1, 1)]
void CSMain(uint3 Gid : SV_GroupID, uint3 Gtid: SV_GroupThreadID)
{
    (...)
    
    if (Gtid.x == 0)
    {
        // 这里的代码只针对一个线程执行!
    }
}

可以混合Compute和光栅化,减少Compute和Draw调用之间的转换(transition)次数,而转换通常会有很高的开销!举个具体的例子,假设有以下顺序的调用质量:

Compute A
Compute B
Compute C
Draw X
Draw Y
Draw Z

可以改成交叉地调用Compute和Draw:

Compute A
Draw X
Compute B
Draw Y
Compute C
Draw Z

对于无序访问视图(Unordered Access View,UAV),它并非严格意义上的DirectCompute资源,也可以与PS一起使用。无序访问支持分散的读写,分散访问 = 缓存丢弃,优先分组读/写(强烈建议),例如从/向float4(而不是float)读取/写入,但NVIDIA标量架构不会从中受益。对UAV的连续写入,如果不需要,请勿创建带有UAV标志的缓冲区或纹理,渲染操作后可能需要同步,仅在需要时使用D3D11_BIND_UNORDERED_ACCESS。避免将UAV用作便签本,使用TGSM(线程组共享内存)更佳。

带计数的UAV缓冲是Shader Model 5.0支持的特性,不支持纹理,在CreateUnorderedAccessView()中使用D3D11_BUFFER_UAV_FLAG_COUNTER标志。访问方式有uint IncrementCounter()uint DecrementCounter()
,比使用UINT32大小的R/W UAV实现手动计数器更快,因为避免了对UAV进行原子操作。但在NVIDIA硬件上,更倾向于使用Append buffer。

追加/使用缓冲区(Append/Consume buffer)用于将数据并行内核的输出序列化为数组,也可用于图形,例如延迟片元处理。需要小心使用,可能代价高昂,在API中引入序列化点,大的记录尺寸可以隐藏append操作的成本。

原子操作是不能被其它线程中断直到完成的操作,通常与UAV一起使用,原子操作由于同步需求会影响性能。仅在需要时使用它们,许多问题可以重铸为更有效的并行规约或扫描,带有反馈(feedback)的原子操作成本更高,例如:

Buffer->InterlockedAdd(uAddress, 1, Previous);

线程组共享内存(Thread Group Shared Memory,TGSM)是组内线程间共享的快速内存,不会跨线程组共享!例如:

groupshared float2 MyArray[16][32];

在Dispatch()调用之间不持久(亦即TGSM只能用于单次Dispatch),用于减少计算量,通过将相邻计算存储在TGSM中来使用它们(如后处理纹理指令)。

影响TGSM性能的因素主要有:

  • 访问模式。

I/O的bank(存储库?)数量有限,ATI和NVIDIA硬件上的32个bank,bank冲突会降低效率。32bank示例,每个地址为32位,bank按地址线性排列:

相距32个DWORD的TGSM地址使用相同的bank,从多个线程访问这些地址将产生bank冲突,将TGSM二维数组声明为MyArray[Y][X],并先增加X,再增加Y(如果X是32的倍数,则必不可少!),填充数组/结构以避免bank冲突会有所帮助,例如MyArray[16][33] 代替MyArray[16][32]

  • 尽可能减少访问。例如将数据打包成uint而不是float4(但请注意增加的ALU)。

  • 基本上每个TGSM地址都尝试读/写一次。复制到临时数组可以帮助避免重复访问。

  • 展开循环访问共享内存,帮助编译器隐藏延迟。

屏障(Barrier)为组内的所有线程添加同步点,如GroupMemoryBarrier()GroupMemoryBarrierWithGroupSync(),屏障过多会影响性能,如果工作未在线程之间*均分配,则尤其如此,当心使用许多屏障的算法。

最大化硬件占用。线程组不能跨多个着色器单元拆分,无论是进还是出,与像素工作不同,像素工作可以任意分割。影响占用的因素有线程组大小声明、声明的TGSM大小、使用的GPR数量,这些数字会影响可以实现的并行度,例如,硬件着色器单元的参数是最多8个线程组、32KB总共享内存、最多1024个线程,线程组大小为128个线程,需要24KB共享内存,每个着色器单元(128 个线程)只能运行1个线程组(错误)。

寄存器压力也会影响占用,但我们对此几乎没有控制权,依赖驱动做正确的事。需要调整和实验才能找到理想的*衡,但这种*衡因硬件而异!存储不同的预设以获得跨各种GPU的最佳性能。

DiRT2 DirectX 11 Technology介绍了游戏Colin McRae Dirt 2使用的DirectX 11的技术,包含移植到DX11、曲面细分、基于直接计算的HDAO及线程资源加载。文中提到了基于曲面细分的网格细节化、布料和水体模拟。

基于DX11曲面细分的布料。左边是原始的网格,右边是PN曲面细分+位移的网格。

HDAO(High Definition Ambient Occlusion)的工作原理和HBAO(Horizon Based Ambient Occlusion)基本一致,通过考虑环境光和环境而不仅仅是像素来解决SSAO像素深度测量带来的颗粒和噪声问题。主要缺点是需要更多的CPU和GPU处理能力。HBAO+提供了一种性能任务较少的光影采样算法,将AO的细节级别提高了一倍,运行速度提高了三倍。

HBAO的与以前的SSAO变体不同,使用了基于物理的算法,该算法与深度缓冲区采样*似积分,使HBAO能够生成更高质量的SSAO,增加每个像素的样本数量以及AO的定义、质量和可见性。出于性能原因,HBAO通常以半分辨率渲染,将AO像素数量减少四分之三,但是,以降低的分辨率渲染HBAO会导致在所有情况下都难以隐藏的闪烁。

如果使用PS方式的后处理,可能存在大量过采样,样本覆盖的区域是内核大小,然后对中心像素周围的一大堆纹素进行采样。文中尝试使用Computer Shader来加速。

首先是重叠地分块,使用LDS大幅降低纹理采样成本,将屏幕划分为tile以供线程组处理。内核大小决定重叠程度,下图显示了涉及LDS写的纹素采样区域、涉及LDS读/写的ALU PP计算区域和内核大小:

// 实现代码
// CS result texture
RWTexture2D<float> g_ResultTexture : register( u0 );
// LDS
groupshared float g_LDS[TEXELS_Y][TEXELS_X];

[numthreads( THREADS_X, THREADS_Y, 1 )]
void CS_PPEffect( uint3 Gid : SV_GroupID, uint3 GTid : SV_GroupThreadID )
{
    // Sample texel area based on group thread ID – store in LDS
    g_LDS[GTid.y][GTid.x] = fSample;
    // Enforce barrier to ensure all threads have written their
    // samples to the LDS
    GroupMemoryBarrierWithGroupSync();
    // Perform PP ALU on LDS data and write data out
    g_ResultTexture[u2ScreenPos.xy] = ComputePPEffect();
} 

基于CS的HDAO相比PS的性能,深度提升1.3倍,深度+法线提升3.6倍(测试环境Windows 7 64-bit, AMD Phenom II 3.0 GHz, 2 GB RAM, ATi HD5870, Catalyst 10.2):

另外,还可以使用DX11的GatherCmp() 来快速采样PCF阴影,实现更简单、统一。

DiRT2使用后台加载线程,放置在队列中的资源,在DX9模式下,资源在主线程上创建。在DX11模式下,资源在加载线程上创建,更简单、更快速的实现,加载时间明显加快,约快50%。

R-Trees -- Adapting out-of-core techniques to modern memory architectures介绍了R-Tree的特点、原理,展示了如何使它们适应内存使用,在缓存行为以及SIMD处理等方面获得重大优势。

R-Tree本质上就是一个AABB树,但有一些特定的属性和提前准备的大量工作。其节点是由大的固定大小的子AABB和指针组成的块,AABB用于存储在其父节点中的节点,给定访问模式是有意义的,有些松散(通常高达50%),减少拓扑更改的频率。

以2-3的R-Tree的构建为例(2-3是指子节点数量控制在2-3个,实际会使用更大的节点数量,如16-32的R-Tree):

R-Tree的优点是:

  • 缓存友好的数据布局。

  • 没有刚体细分的模式。

  • 更高的分支系数。

    • 更短的深度,更少的读取,每个节点内更多的工作。
  • 预读取(宽度优先遍历)

    • 堆栈(深度优先):当前节点可以更改下一个是哪个节点。
    • 队列:知道下一个是哪个节点,所以预取之。
  • 每个节点有很多子节点。

    • 展开测试以隐藏VMX延迟。
    • 隐藏预读取的延迟。
  • 可用于动态物体。

    • 即使物体移动,拓扑结构依然有效。需要传播任意的AABB更改给父节点。
    • 可能最终表现不佳,但是仍然是正确的。
    • 延迟重新插入,直到物体移动了较大的距离。调整AABB比重新插入要快得多。

总之,R-Tree是快速的基于块的AABB树,具有层次树的所有常规优点,对缓存和SIMD非常友好,不需要桶加载(bulk-loading),但需要大量的前期开发工作。

Streaming Massive Environments from 0 to 200 MPH介绍了赛车游戏Forza Motorsport 3中用于制作和渲染大型赛道环境的流程,从艺术到游戏的流程,以及渲染大量的高细节模型的一些关键技术。

Forza Motorsport 3的流式目标是以60fps渲染,包含赛道、8辆汽车和用户界面等模块,支持后期处理、反射、阴影、颗粒、滑道、人群,支持分屏、回放等。游戏拥有海量环境:100多条轨道,有些长达13英里,超过47000个模型和超过60000个纹理。典型的海量模型可视化层次:

现代内存模型如上图,速度依次提升但容量依次下降:磁盘/局部存储、压缩缓存、解压堆、GPU/CPU缓存、GPU/CPU。下面对这些存储类型加以说明。

在硬盘上,以zip包的形式存储,以zip格式存储一些额外的数据,但遵循基本格式,因此标准浏览工具仍然有效(资源管理器、WinZip 等),在存档中以LZX格式存储,每个轨道(track)有90-300MB。

从磁盘到压缩的缓存时,利用高速缓存块大小的快速IO,Block是zip中的一组文件,文件的总大小,直到达到块大小,通过单次读取检索该文件组。压缩缓存减少了查找,峰值达到15MB/s,*均10MB/秒,但查找需要100ms。

压缩缓存以LZX格式在内存中存储,按需流入和流出LRU的缓存块,约56MB,逐轨道调整块大小,但通常为1MB。

从压缩缓存到解压堆时,使用快速的*台特定解压,*均20MB/秒,解压堆实现时针对分配和释放操作的速度进行了优化,并且使用地址排序优先的良好分段特性。

解压堆准备好供GPU或CPU使用,每个分配连续且对齐,约194MB。

多级的纹理存储,每个纹理的三个视图:Top Mip是Mip 0,全分辨率纹理,Mip–Chain是Mip 1,下采样到1x1,小纹理则从32x32到1x1。此处针对*台的支持不需要重新定位纹理,因为Top Mip是流式传输的。

多级的几何存储,将不同的LOD视为不同的对象,以允许流式传输在更高的LOD没有贡献时转储它们,模型使用每个实例的变换和着色器数据进行实例化。

从内存到GPU/CPU缓存时,针对缓存友好渲染的CPU特定优化,高频操作具有扁*、高速缓存行大小的结构,CPU的L1/L2高速缓存,大量使用命令缓冲区以避免接触不必要的渲染数据。

GPU/CPU缓存根据着色器需求调整格式大小,GPU的顶点/纹理提取缓存(如顶点格式、流计数、纹理格式、大小、mip使用情况),使用*台特定的渲染控件来减少mip访问等。

对于预先计算的可见性,标准解决方案是给定场景在给定位置实际可见的内容,许多实现使用保守遮挡。而本文使用的变量包括遮挡(深度缓冲区拒绝)、LOD 选择、贡献拒绝(如果小于n像素,则不绘制模型)。

剔除的方式有:遮挡剔除——在视图中被其它物体阻挡的物体(下图红色方形)和贡献被剔除——对视图的贡献不足的物体(下图黄色圆圈)。

可以在运行时做到,LOD和贡献很容易,可以实现遮挡。最重要的是必须在运行时进行优化,或者根本不这样做,但意味着流式传输和渲染过多。可见性信息通常是大量数据,意味着需要接触大量数据,对缓存性能不利。Forza Motorsport 3的解决方案是不要将CPU/GPU花费在可以离线处理的工作负载上。

Forza Motorsport 3的轨道处理流程分为5个采样主要步骤:采样、拆分、构建、优化和运行。所有步骤都是全自动的,源自场景中的艺术检查,管线生成优化的游戏就绪轨道。

在优化步骤,为包裹创建缓存有效的序号,缩短查找距离并提高缓存命中率,使用“首次看到”的指标,走过区域并跟踪哪个区域首先使用模型或纹理,将所有模型组合在一起并按第一个区域排序,与纹理相同。

在运行步骤,创建区域增量,确定摄像机在可见空间中的位置,将摄像机位置映射到要加载的区域,当前加载的区域和要加载的区域的差异。根据区域增量创建资源增量,基本上是引用计数,整合工作以确保免费首次订购(这是为了帮助解决碎片化问题)。在尾部区域流出(免费)数据,在前导区域流入(分配、IO 和解压缩)数据。

运行时注意事项,重点领域包含工作顺序、堆效率、解压效率、磁盘效率,对于许多问题,任何解决方案都比什么都不做要好,确保层次结构的所有级别都得到解决。

管线的错误主要有:

  • 跳变。
    • 仅限于两个层级。
      • 延迟加载(通过限制每个区域保持在系统吞吐量内所需的数量进行调整)。
      • 可见性错误(通过进一步聚类对象或使抽样结果有偏差进行调整)。
    • 虽然这些调整有冲突。
    • 提供手动操作。
      • 几何偏差(影响采样结果)。
      • 纹理偏差(在优化期间影响纹理工作集中的位置)。
  • 再多的自动化也无法与不切实际的期望相抗衡。
    • 例如,所有模型都在单个区域中可见,意味着不会有任何用于纹理的空间。

A Dynamic Component Architecture for High Performance Gameplay详细介绍了系列游戏Resistance中使用的动态组件架构,以表示实体和系统行为的各个方面。该组件系统解决了传统游戏对象模型在高性能游戏中的几个弱点,特别是在多线程或多处理器环境中。动态组件从高效的内存池中按需分配和释放,系统提供了一个方便的框架,用于在不同的处理器(如SPU)上并行运行更新。该系统可以分层在传统游戏对象模型之上,因此代码库可以逐渐迁移到这种新架构。本文将讨论系统的动机、目标和实现细节。

以往的游戏对象组织层次比较单一、高深度和不*衡,内存上在编译期绑定数据,性能上缓存一致性差,架构上通过继承来获得能力,另外是使用习惯。

解决方案是通过在运行时组合组件来构建游戏对象,小块(Small chunk),表示数据变换。可以并行实现,无需重构现有代码,与组件共存。但是,动态组件不解决反射、序列化、数据构建、实例版本控制等问题。

动态组件系统的特点有:

  • 组件

组件是最初的特点,基础组件类有8字节的管理数据,从池中分配,每种具体类型一个池,“名册”索引实例,“分区”分隔已分配/空闲的实例。

  • 高性能

微量的恒定的时间操作,包含分配/免费、解析句柄、获取类型、类型实现(派生自),无实例复制。按类型更新(按池),缓存友好,促进异步更新(例如在SPU上),名册是分配实例的连续列表,分区名册是DMA列表。解析句柄具有微量的恒定的时间操作:索引到池中、比较生成、返回组件。

  • 动态

游戏对象的运行时组合,无包袱动态地改变行为,已分配的组件 == 正在使用,池大小 == 最大并发分配。

高频地alloc()free()alloc()包含可用性测试、从索引和生成构造句柄、增加名册分区、Component::Init()free()包含Component::Deinit()、交换名册索引与分区相邻索引、减少分区、增量生成。动态组件的释放接口如下:

// free the component from host's component chain                                                   
void DynamicComponent::Free( Type type, HostHandle host_handle, Chain& chain, ComponentHandle& component_handle );

结合下图,有一个给定组件类型的实例池,还有有一个名册,它是实例池中的索引数组。注意,池中的所有实例都属于同一类型,因此它们的大小相同。因此,名册索引处的值本身就是池中的索引。可以看到有一个分区值,它将代表已分配实例和空闲实例的名册索引分开。

现在将释放由第3个roster元素表示的组件,它当前在池中的索引为3:

要做的是交换第3个和第4个名册元素:

现在正在释放第4个roster元素,它代表池中的第3个实例。由于将要释放的元素的roster条目交换到分区相邻的roster的条目中,因此释放该实例所需要做的就是将分区的索引上移1个单位(减1),就是如此简单:

  • 系统

不是全有或全无!例如对话、脚本事件、投篮:无游戏对象。下面是动态组件系统的相关接口定义:

namespace DynamicComponent                                                                           
{                                                                                                    
    // Hosts' API                                                                                      
    Component*        Allocate                  ( Type type, HostHandle host_handle, Chain* chain, void* prius = NULL );                  
    Component*        ResolveHandle             ( Type type, ComponentHandle component_handle );       
    Component*        Get                       ( Type type, HostHandle host_handle, Chain chain );    
    Component*        GetComponentThatImplements( Type type, HostHandle host_handle, Chain chain );    
    Component**       GetComponents             ( Type type, HostHandle host_handle, Chain chain, u32& count );                           
    Component**       GetComponentsThatImplement( Type type, HostHandle host_handle, Chain chain, u32& count );                           
    void              Free                      ( Type type, HostHandle host_handle, Chain& chain, ComponentHandle& component_handle );                 
    void              FreeChain                 ( HostHandle host_handle, Chain& chain );              

#define COMPONENT_CAST(component, type) \                                                            
  ((type##Component*)ValidCast(component, DynamicComponent::type))                                   
  inline Component* ValidCast                 ( Component* component, Type type );                   

    // Systems' API                                                                                    
    Type*             GetTypesThatImplement     ( Type type, u32& count );                             
    bool              TypeImplements            ( Type type, Type interface );                         
    u32               GetNumAllocated           ( Type type );                                         
    Component**       GetComponents             ( Type type, u32& count );                             
    Component*        GetComponentsIndexed      ( Type type, u16*& indices, u32& count );              
    void              UpdateComponents          ( UpdateStage::Enum stage );                           
    void              Free                      ( Type type, ComponentHandle& component_handle );      
    UpdateStage::Enum GetCurrentUpdateStage     ( );                                                   
    u8                GetTypeUpdateStages       ( Type type );                                         
}

实现的过程涉及脚本事件、分配和初始化、异步更新等细节。

无独有偶,Entity Component Systems也谈及了Unity引擎ECS和作业系统的特点。

实体组件系统 (ECS) 是一种主要用于游戏和模拟的数据组织方式。实体(或游戏对象)是游戏中可以看到或与之交互的任何对象,例如玩家、敌人、障碍物、通电。组件是分配给实体的属性,例如附加到玩家实体,可以拥有健康、碰撞、变换和运动组件。系统是向组件添加功能的地方,即使用运动和变换组件,可以制作基本的运动系统。下图分别展示了实体、组件、系统管理器:

ECS模型包含纯粹(Pure)与混合(Hybrid)方式,两者对比如下:

Pure Hybrid
实体是新的游戏对象 包含Pure ECS的所有功能
没有更多的mono行为 包括将游戏对象转换为实体并将mono行为转换为组件的特殊辅助类
数据存储在组件中,逻辑存储在系统中
利用提供性能优势的新C#作业系统

Burst是Unity开发的一种新的数学感知编译器,可以生成高度优化的机器代码,充分利用正在编译的*台,完全自动化。需要做的就是将 Burst编译器包添加到项目中,然后确保C#作业标有Burst Compile属性。随后Unity将获取作业系统代码并将其编译为高度优化的机器代码,意味着用C++甚至C等语言编写逻辑可以直接在处理器上执行。Burst的优势是非常容易实现,不需要了解复杂的低级代码。与ECS相结合,大大提高了性能。

Unity作业系统让开发人员可以轻松安全地编写多线程代码,它通过创建作业而不是线程来做到这一点。作业表示系统可以作为一系列线程处理的工作单元,安排作业时,系统会将其放入一个特殊的队列中, 工作线程会将作业从队列中拉出并执行。工作线程是由作业系统管理的单个线程,它们在后台完成作业,因此不会中断主线程。

使用Unity作业系统的原因包括:它确保多线程代码是确定性的的。例如,作业系统将通过为每个逻辑CPU内核创建一个工作线程来尽量避免上下文切换,使得开发人员可以(在合理范围内)创建任意数量的作业,而不必担心它会如何影响CPU的性能。作业系统还有一个内置机制,用于以作业依赖的形式防止竞争条件。例如,如果作业A需要为作业B准备一些数据,则可以将其分配为作业B的依赖项,这样,作业A将始终首先运行,作业B将始终拥有正确的数据。

Unity作业系统允许应用层开发人员以安全、简单且完全由Unity管理的方式使用多线程。当它与Unity ECS和Burst Compiler结合使用时,会立即获得性能极佳的代码并进行优化。

Reflection for Tools Development探讨程序员在开发内容创作工具时遇到的常见模式。无论是用于动画、音频、图形还是游戏玩法,开发提供出色工作流程的工具并非易事,工具必须稳定,同时适应生产过程中的变化,必须与用C++编写的游戏集成,必须是特定于游戏的,但也必须是可重复使用的。Jeremy Walker分享了育碧温哥华使用的一个内部开发的框架,可以快速开发具有出色工作流程的工具。

在引擎开发过程中,经常遇到硬编码和数据驱动的系统的权限、选择和设计:


各个模块或子系统中有着错综复杂的联系、交互或依赖:

这些子模块部分可以直接选择硬编码:

然后分别将每个子系统的编辑、构建、序列化、渲染部分单独合并成一个模块组,形成整体引擎方法:

问题是如何*衡低开发成本和出色的工作流、适应变化和稳定工具、可重用的系统和游戏和特定类型的需求配对之间的关系呢?

解决方案是对于所有类型的内容:最大限度地降低开发出色工作流程的成本,在满足特定游戏需求的同时设计可重复使用的系统,开发能够适应生产过程中不断变化的稳定工具。软件包发布流程图如下:

单体软件包发布的问题:

可以采样解耦包:

锁步发布的问题:

可以采用反射系统来降低开销和复杂度。下面是硬编码、单体引擎、使用反射的解耦系统对比图,其中使用反射的解耦系统可以达到低开销、简单、高度可重用、出色的工作流等目标:

现在再转向阐述计算机语言的反射(Reflection)机制。下图是C++的编译流程和反射机制,其中反射在函数声明时收集信息,然后将收集到的信息绑定到函数定义:

对于混合语言的反射,流程相似但略有不同:

游戏脚本语言的反射:

游戏序列化的反射:

通用语言的反射规范:

C++反射的流行方法:宏(Macros)、代码解析器(Code Parser)、类型定义语言(Type Definition Language)。它们的示例代码如下:

// ----- 宏 -----

class SimpleVehicle : public Entity
{
public:
    DECLARE_TYPE();
     float    m_MaxSpeedKPH;
     void     Reset(bool useDefaults);
    float     GetMaxSpeedMPH() const;
    void    SetMaxSpeedMPH(float maxSpeedMPH);
};

//In a separate .CPP file:
DEFINE_TYPE(SimpleVehicle)
    BASE_CLASS(Entity)
    FIELD(“MaxSpeedKPH”, m_MaxSpeedKPH)
    METHOD(Reset)
    PROPERTY(GetMaxSpeedMPH, SetMaxSpeedMPH)
DEFINE_TYPE_END()
    
    
// ----- 代码解析器 -----
    
/// [Class]
class SimpleVehicle : public Entity
{
public:
    /// [Field(“MaxSpeedKPH”)]
    float    m_MaxSpeedKPH;
    /// [Method]
    void Reset(bool useDefaults);
    /// [Property]
    float     GetMaxSpeedMPH() const;
    /// [Property]
    void    SetMaxSpeedMPH(float maxSpeedMPH);
};

// ----- 类型定义语言 -----

class SimpleVehicle : Entity
{    
    float    MaxSpeedKPH;
    void     Reset(bool useDefaults);
    float     MaxSpeedMPH { get; set; }
};

以上三种C++反射方法的优缺点见下表:

C++反射方法 优点 缺点
没有外部工具 实现起来很尴尬,难以调试,运行时发现
代码解析器 更容易实现,编译期发现 缓慢的预构建步骤
类型定义语言 最容易实现,没有缓慢的预构建 不能反映现有的类

顺便提一下,UE的反射是以代码解析器为主结合宏为辅的混合方式。

导出游戏的类型定义的流程如下:

上面的示例代码中的类型SimpleVehicle经过代码解析器导出的Types.xml数据如下:

<type name=“SimpleVehicle”>
  <field name=“MaxSpeedKPH” type=“float”/>
  <method name=“Reset” returntype=“void”>
    <parameter name=“useDefaults” type=“bool”/>
  </method>
  <property name=“MaxSpeedMPH” type=“float” hasget=“true” hasset=“true”/>
</type>

导出工具的类型定义和游戏的稍有不同:

其中生成的C#代理类型如下:

[ProxyType(“SimpleVehicle”, 0x81c37132)]
public partial class SimpleVehicle : Entity
{
   public float MaxSpeedKPH
   { 
      get { return this.Instance.GetField(“MaxSpeedKPH”).Get<float>(); }
      set { this.Instance.GetField(“MaxSpeedKPH”).Set<float>(value); } }
   }
   public float MaxSpeedMPH { ... }
   public void Reset(bool useDefaults) { ... }
}

反射在工具中的主要用途有序列化、客户端-服务端远程处理、生成GUI:

客户端-服务器远程处理流程和步骤:

存在的问题和解决方法:

  • 类型定义不同步。检测类型校验和不匹配,及早发现问题,自动同步类型信息,数据自动迁移。
  • 与游戏紧密耦合的工具。避免过度使用生成的代理类,尽可能使用生成的UI,使用多态代理类。

  • 内存使用过多。基于使用情况的剥除类型的无用信息,自动检测未使用的反射类型。

反射的其它用途有多处理器架构的编组事件,在线客户端-服务器远程处理,保存游戏数据的序列化。

下图是育碧的内容框架:

获得良好的结果有快速工具开发,适用于所有类型内容的出色工作流程,具有改进的可重用性和对变化的弹性的解耦系统。

The Asset pipeline for Just Cause 2: Lessons learned概述了游戏Just Cause 2中使用的资产调节管线,包含分析管线需求和设计一个可以消除关键瓶颈、提供稳健环境并提高吞吐量的系统的过程,讨论了识别可以简化管线的关键底层系统的过程。在这些系统中,有一个独立于*台和语言的数据管理层、一个资产依赖解析器和使用Python的编译器脚本框架,还讨论了一种以受控方式处理新编译器部署的系统,基于这个新基础重建现有编译器管线的过程、好处、副作用、可维护性、稳健性,以及改进的反馈水*和监控管道统计数据的能力。Just Cause 1的工作流如下图:

这个工作流程有一些重大缺陷。首先,JustEdit的手动导出步骤太多,更糟糕的是,它导出了游戏就绪格式,一个实体的变化会影响很多位置或任务,因此需要再次重新导出所有资产。

到了Just Cause 2,内容创建者订购了许多新工具,编写了详细的设计文档,每个工具都分配了一个“客户”,程序员与客户保持密切沟通,客户负责测试工具并批准它,作为JustEdit插件的工具,WTL用于编辑器/插件中的GUI。

项目开始时有10个新工具!!代码没有在应该完成的90%-100%完成。工作流程中的缺陷,并非所有工具都按预期使用,易于编辑的内容在性能和内存方面不太适合游戏。设计文档按“原样”接受,它们本质上是内容创作者的愿望清单。

顾客不是开发团队的一员,沟通受到影响,经常计划全职从事其它任务,责任不明。插件导致了不稳定的C++接口,应该与网络通信一起去,过于复杂。图形用户界面:WTL和C++,WTL太简单了,而C++减慢了迭代速度。

C++中的编译器,使用C++编写脚本并不总是很方便,构建时间长,调整起来很尴尬,一些编译器包含所有游戏代码:非常特定于*台,如除了其它的事情,带了DirectX的Win32繁重,无法在没有桌面的自动构建器上运行。

程序员实现的东西略有不同,导致整体行为异常,打破依赖检查,编译器中的硬编码参数。文件格式不够稳定,如果出现问题,编译器可能会崩溃,或者更糟糕的是,损坏的数据进入了游戏。

不愿使用编译器,管线被认为有点魔法,缺乏文档,整体流程不容易概览,调试经常在游戏代码中完成的损坏数据,程序员已经设置了所有代码和数据,这有时会导致完成运行时修复,而不是修复编译器中的错误。

没有中心代码,集成时不使用中心代码意味着问题,修复丢失了,必须重新找到它们,内置编译器在Perforce中进行版本控制,需要时不会自动重建和使用,仍然可以使用旧的错误代码。

数据格式方面,有许多不同的文件类型(约30),许多格式都是基于xml的,将“属性名称”映射到“值”,格式对编辑器比对游戏更优化,未检测到读/写错误,没有版本号,无法记录正确的错误消息,很多东西要改进。

内容构建系统是有点粗略的解决方案,很慢的依赖检查,不完整的依赖树,时间戳不够好。由于行为中的错误和异常,用户不能100%信任它,没有信任意味着完全清理和完全重建。

为解决JC2的问题,提出了全新的目标:重建对管道的信任,增量构建,没有神奇的工作流程,易于配置,使用依赖检查,100% 准确率,简化开发,将通用代码移至中央存储库,减少创建工具时的周转时间。

重大决定,需要一个中央代码的构建/部署系统,内容构建系统也需要大力推动,需要一种简单易用但功能强大的通用数据格式,开发必须与 JC2 一起完成,需要致力于任务才能真正快速到达任何地方。

需要依赖解析器和部署系统,内部使用python编写,非常轻量级,处理包体:构建库 (.h + .lib/.so)、构建的可执行文件、捆绑的 python 脚本(或任何你想要的)、第三方库、可执行文件。将包部署到中央存储库,获取包到本地系统。

编码过程的巨大变化,代码库向更小的库发展,真正快速的编译时间,帮助跟踪版本冲突,允许非常快速的开发和测试。

用于代码和数据的轻量级构建系统,开源 (BSD),源代码约为80kb,依赖检查,多核支持,易于维护和扩展,替换内部的项目编译器系统,用Python编码。

雪崩数据格式,序列化框架:C/C++, Python 支持、Xml <-> 二进制支持,二进制文件的基于哈希的版本控制,错误处理,从Python和C/C++访问数据的能力实现了无缝的跨语言交换,跨*台支持,例如将数据从工具传递到控制台/从控制台传递数据。

ADF和Python使读取和写入数据变得非常容易,读取一个类型库,然后从xml格式加载源文件。接下来,更改第一个对象的名称,最后,以二进制格式(big endian)写回所有内容。下图是文件如何相互关联的示意图:

C++用于共享库,从Python加载,移除了对旧游戏代码的依赖,用标准代码替换内部代码,fopen() 而不是CFile(),降低复杂性,增加可移植性。脚本和逻辑用python,数据处理用Python或C++,例如使用C++读取/写入/处理Havok文件。

Python用于极端的周转时间,大量内置模块,经常使用optparse、ctypes、md5、numpy、cStringIO…许多模块都有用C++实现的后端,大多数编译器被完全重写,减少很多依赖,代码大小降至1/10。

依赖检查方面,使用Waf处理机制,扫描程序找到依赖项,Waf缓存结果,MD5校验和命令行参数,源/依赖文件内容,新的编译器路径将触发编译,使用Needy更新软件包时非常方便。

增量自动生成器,从版本控制同步,只构建必要的东西,全自动生成器,将增量构建与完整构建进行比较,发现依赖问题,查找数据格式中的错误。

2011年,Multi-Core Memory Management Technology in Mortal Kombat分享了在游戏真人快打中使用的多核内存管理技术。

《MK vs DC》主要使用了两个内存管理器:虚幻内存管理器 (FMalloc),引擎端资源,基于C/C++的内存管理。“游戏”内存管理器,游戏端资源,面向控制台。

Unreal内存管理器的限制有LibC++功能集,不支持多堆,不是原生线程安全/多核,非线程安全内存分配器受“全局锁”保护,“MK vs DC”在内部使用DLMalloc,某些操作会导致较长的卡顿。

游戏内存管理器的限制有不是线程安全的,不是“虚拟内存感知”,仅支持静态固定后备库,非常慢的O(N) 次操作,容易碎片化(。

全局锁定并非良策,未经过多核优化,所有操作都可能导致其它线程上的轻微停顿或上下文切换,某些操作可能会导致大系统范围的停顿,例如大型应用程序分配请求、堆后备存储分配、重新分配操作。

下图是全局锁定重新分配,线程1的重新分配会锁定内存块,导致线程2一直等到线程1完成内存分配之后才能进行锁定并执行内存操作:

良好粒度的锁定重新分配可以减少等待:

非阻塞的重新分配:

虚拟内存解决内存碎片:

多核下的内存操作默认情况下是线程安全的,尽可能无锁(且直接),需要时首选非阻塞锁:非排他锁(例如读写器)、细粒度锁定、条纹锁(Striped Locking)。也可以考虑单线程的高性能,无竞争的访问不会出现明显的性能损害。

新的内存管理器优化线程安全和多核,为游戏和虚幻引擎统一单独的内存管理器,支持具有额外功能的多个堆,提高性能(CPU 周期和内存使用效率),通用跟踪和调试实用程序。

并发堆具有最小的线程“串扰”,可以在单个堆上同时分配/释放多个线程(如果堆类型支持,大多数堆类型都可以!),后台存储和内部堆查询操作通常同时运行(使用无锁、条带化或读写器锁),Realloc()在复制发生时从不阻塞。简化的内存管理架构如下:

在实现堆时,Heap API使用虚函数,Backstore和OS Allocs的通用支持API,Global Free() “知道”返回的堆内存。易于制作不同的堆实现,直接操作系统堆,最佳拟合堆(使用红黑树),小块堆(无锁分配/无条纹),固定块堆(无锁 - 用于MK游戏对象)。

可以使用混合主堆,即主堆使用混合方法来处理分配:大型分配直接通过操作系统以最大程度地减少碎片(但在内部进行跟踪),中等分配进入最佳适合堆,小块分配由它们自己的堆处理。C++ new/delete和C malloc/free调用路由到主(混合)堆。

UE的内存管理就是使用这种混合主堆的方法,详见1.4.3 内存分配

下图显示了内存分配的占比中,小堆占约23%,中堆占约52%,大堆占约25%;而在分配次数上,小堆占约96.7%,中堆占约3.2%,大堆占约0.26%,可见在次数上,小堆占了绝大部分,管理好小堆的分配等操作至关重要!!

下图显示了不同大小的堆分配的次数,基本上越小的堆次数越多:

SBMM = 小块内存管理器,它的特点是非常低的线程争用,支持许多同时操作,分箱分配器(尺寸化的箱、Lock Striping = Lock Per Bin),(大部分是)无锁的Alloc(),后备缓存使用无锁分配的victim(Lockfree Lookaside cache for a Block’s Items,块项目的无锁后备缓存)块,快速的条纹锁定(Stripe-Locked)释放。SBMM的装箱过程如下图:

SBMM的内存布局如下图:

SBMM主要是无锁分配,LockFree freelist缓存“受害者”块的项目,为空时,获取Bin条带锁,并从下一个带有空闲项目的Block建立新的空闲列表,是一个非常快的操作,直到所有块都用尽为止,在这种罕见的情况下,必须从超级块中取出一个新块,并为这些项目初始化一个空闲列表, 如果所有的SuperBlock都用完,则从OS请求一个新的SuperBlock。

SBMM的释放本来是无锁的,但需要延迟GC,条纹锁定 == 轻松修剪(无延迟GC),查找块和箱的大小,快速锁定箱,推送内存项目并检查计数,如果需要修剪,拉取块,释放锁定,修剪,否则释放锁定,无竞争的案例与无锁的速度非常相似,条纹一如既往无竞争。

无锁的NR-Pool是用于MK内存系统中的简单控制结构:

Mega Meshes - Modeling, Rendering and Lighting a World Made of 100 Billion Polygons分享了用于研发游戏Milo and Kate(下图)的引擎在模型和管线、构建、压缩和流、虚拟纹理、实时GI等方面的内容。

与传统的环境模型管线不一样的是,本文的引擎使用了大型网格工具,管理具有数十亿个多边形的模型,支持多个用户,联合了DCC工具、VC和雕刻等工具,窗口可在可变的细节级别编辑,负责协调构建过程。

在物理内存中存储如此大的数据集是不现实的,几何数据分层存储,连续的细分关卡以差异数据存储,按需加载的关卡。过程见下面系列图:





分级存储的好处可以在低细分级别编辑世界的区域,并保留高频率的细节,可以改变地形的宏观地形而不丢失所有细节,只需修改较低的细分层,可以做出大比例的色调调整。为了解决多人编辑的关卡的缝隙,需要额外的步骤生成运行时网格:

稀疏虚拟纹理(Sparse Virtual Texture)的出现是为了解决高内存占用很多多的全局通道,需要良好的流/渲染系统,可以使用虚拟纹理,在纹理空间虚拟化。下面几幅图分别是Milo and Kate早期的虚拟纹理图集、大型纹理和mipmap分拆到tile、虚拟纹理的物理内存加载过程:



虚拟纹理的细节:数组中的多个虚拟纹理,16 * 32k * 32k的地址空间,128 x 128的tile,8:8位的UV tile地址,一些角色/道具的虚拟纹理,其余的则被划分到世界的不同区域,可以在关卡边界解除未使用的绑定,允许连续的流式世界,2048 x 4096 (28mb)的物理页面。

下图展示了编译巨型纹理的流程图:

渲染时,渲染线程、纹理缓存线程、GPU线程的交互图如下:

此外,该引擎在实现基于SH的GI的流程图如下:

Game Worlds from Polygon Soup: Visibility, Spatial Connectivity and Rendering描述了Halo系列游戏的虚拟世界内的Polygon Soup(无组织的多边形组)在可见性、空间链接及渲染方面的技术内容。

在虚拟环境方面,Halo应用了单元格和门户(portal)、防水壳几何形状、艺术家手动放置传送门、从壳几何构建BSP树、Floodfill BSP进入单元格、建立单元格连接等技术。这样的技术选型优点是统一的可见性/空间连接、精确的空间分解、内部/外部测试、非常适合带有自然门户的室内空间。缺点是手动门户化并非易事!、水密性对内容创作来说是痛苦的、强制进行早期设计决策、仅针对室内场景进行了优化。

下面依次是门户化、多边形组的图例:


Polygon Soup是只是一些多边形块聚在一起,非防水结构,没有手动门户,增量构建/快速迭代,允许后期设计更改。可用于细分场景、体素化细分体积、将体素分割成区域、构建区域之间的连接图、从体素区域构建简化的体积等。下面图是2D的寻路应用案例:

从左到右、上到下依次是:输入场景、体素化、可行走体素、距离场、分水岭变换、轮廓。

最终生成的导航网格。

上述图例是2D空间,实际在3D空间存在诸多问题,包含3D更难/更慢、过度分割(小区域)、对场景变化敏感、简化表示并非易事、能见度如何表达等。

解决方法是与Umbra协作,自动生成门户,增量/本地更新,基于CPU的解决方案,低延迟,相同的可见性和空间连通性解决方案,处理门和电梯,精确围绕用户放置的门户,快速运行时间/低内存占用。Umbra解决方案的流程如下:

总体分为将场景离散为体素、确定与输入几何相关的体素连通性、传播连接以查找连接的组件、确定本地连接组件之间的门户等步骤。

瓦片体素化的过程。

将体素转换成单元格和入口。

构建单元格和门户。

Halo Reach游戏循环的特点如下:

  • 粗粒度并行。
  • 线程上的系统。
  • 通过状态镜像显式同步。
  • 主要是手动*衡负载。

Halo Reach细粒度并行。

这个并行系统在帧结束时需要镜像(复制)整个游戏状态(Halo Reach约20MB),以便模拟线程可以自由地处理下一帧,渲染线程可以将当前帧提交给 GPU。镜像游戏状态的原因之一是在复制游戏状态之后计算可见性。复制20MB游戏状态的串行任务很耗时(约3ms),需要对整个游戏状态进行双重缓冲。 注意,此处的游戏状态仅指确定性游戏状态数据。

Halo Reach的并行系统在帧尾需要复制整个游戏状态。

下图展示了各个线程的普遍利用率:

有没办法改进这一痛点?

观察#1:Halo Reach不需要整个游戏状态来渲染。在Reach中,游戏状态提取发生在进行可见性计算之前,这就是为什么必须复制整个游戏状态,开销大(以毫秒和内存占用为单位)。可见性占据渲染线程上的大量CPU时间,然而,CPU时间未得到充分利用,未充分利用的硬件线程。但是实际上可以反转那个操作,仅将可见对象的数据复制到游戏状态之外,仅提取将要渲染的对象的数据。

更好的做法是根据可见性结果推动游戏提取和处理,仅提取可见对象的数据(静态和动态),无需双缓冲整个游戏状态,仅为可见对象的每帧瞬态缓冲游戏数据,更小的内存占用。

更好的负载*衡做法:首先将可见性计算拆分为每个视图的作业,包括玩家、阴影、反射视图的可见性计算,可见性作业可以具有视口到视口的依赖关系,可以重用一个可见性作业计算的结果作为另一个的输入。

减少输入延迟的做法:在游戏对象更新的同时错开可见性计算,在帧中尽早使用预测相机开始静态可见性,在对象更新之前执行。

改善CPU延迟:仅为可见对象运行昂贵的CPU渲染操作,只要确保在可见性之后运行它,仅渲染操作(蒙皮、布料模拟、多边形排序)——不会影响游戏玩法。改进游戏循环和并行方式后的运行情况如下:

收益是将游戏状态遍历与绘图分离,通过交错的可见性计算提高CPU利用率,渲染线程成为流线型内核处理器。下图是一个简单的小作业树:

Culling the Battlefield: Data Oriented Design in Practice分享了Battlefield 3中采用的面向数据的设计和实践。

以往的裁剪剔除方法已经有层次球体树、静态剔除树、动态剔除树等。

但Battlefield 3依然打算重构之,原因有动态剔除树缩放、子关卡、管线依赖、难以扩展、每个视锥体一个作业等。

新系统的要求是更好的缩放、可破坏、实时编辑、更简单的代码、子系统的统一等。

不能在这些系统上良好地工作的技术有:非局部数据、分支、寄存器类型之间的切换(LHS)、基于树的结构通常是分支繁重、要解决最重要的数据。可以在这些系统上良好地工作的技术有:局部数据、(SIMD) 计算能力、并行度。

新的裁剪剔除为了应对游戏场景中海量的物体(最多有约15000个),第一次尝试是仅使用并行蛮力,比旧剔除快3倍,1/5的代码大小,更容易进一步优化。线性数组规模大,可预测的数据,很少分支,充分利用计算能力,可以达到5倍的速度提升:

新的裁剪剔除通过简单的网格提高性能,就是一个AABB,分配给一个带有球体的“单元格”,单独的网格用于静态渲染、动态渲染、静态物理和动态物理。其数据数据布局如下:

增加物体时,可以从中获取数据的预分配的数组:

删除物体时,使用“交换技巧”,数据无需排序,只需与最后一个条目交换并减少计数。

渲染裁剪时,先看看渲染的数据:

struct EntityRenderCullInfo
{
    Handle entity;    // handle to the entity
    u16 visibleViews; // bits of which frustums that was visible
    u16 classId;      // type of mesh
    float screenArea; // at which screen area entity should be culled
};

裁剪节点代码如下:

while (1)
{
    uint blockIter = interlockedIncrement(currentBlockIndex) - 1;
    if (blockIter >= blockCount) break;
    u32 masks[EntityGridCell::Block::MaxCount] = {}, frustumMask = 1;
 
    block = gridCell->blocks[blockIter];
    foreach (frustum in frustums, frustumMask <<= 1)
    {
         for (i = 0; i < gridCell->blockCounts[blockIter]; ++i)
         {
             u32 inside = intersect(frustum, block->postition[i]);
             masks[i] |= frustumMask & inside;
        }
    }
    
    for (i = 0; i < gridCell->blockCounts[blockIter]; ++i)
    {
        // filter list here (if masks[i] is zero it should be skipped)
         // ...
    }
}

相交检测代码如下:

bool intersect(const Plane* frustumPlanes, Vec4 pos)
{
    float radius = pos.w;
    
    if (distance(frustumPlanes[Frustum::Far], pos) > radius)
    return false;
    if (distance(frustumPlanes[Frustum::Near], pos) > radius)
    return false;
    if (distance(frustumPlanes[Frustum::Right], pos) > radius)
    return false;
    if (distance(frustumPlanes[Frustum::Left], pos) > radius)
    return false;
    if (distance(frustumPlanes[Frustum::Upper], pos) > radius)
    return false;
    if (distance(frustumPlanes[Frustum::Lower], pos) > radius)
     return false;
    
    return true;
}

以上代码出现很多问题,如非局部数据和浮点分支:

该怎样改进?点积对SIMD不太友好,通常需要随机打乱数据才能得到结果,(x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1),将数据从AoS(Array of Struct,结构体数组)重新排列到SoA(Struct of Array,数组结构体):

现在只需要3个指令就可以完成4个dot!

新的相交性测试代码下每个循环两个*截头体与球体相交、4*3 个点积、9条指令,遍历所有视锥体并合并结果。

Vec posA_xxxx = vecShuffle<VecMask::_xxxx>(posA);
Vec posA_yyyy = vecShuffle<VecMask::_yyyy>(posA);
Vec posA_zzzz = vecShuffle<VecMask::_zzzz>(posA);
Vec posA_rrrr = vecShuffle<VecMask::_wwww>(posA);
// 4 dot products
dotA_0123 = vecMulAdd(posA_zzzz, pl_z0z1z2z3, pl_w0w1w2w3);
dotA_0123 = vecMulAdd(posA_yyyy, pl_y0y1y2y3, dotA_0123);
dotA_0123 = vecMulAdd(posA_xxxx, pl_x0x1x2x3, dotA_0123);

Vec posAB_xxxx = vecInsert<VecMask::_0011>(posA_xxxx, posB_xxxx);
Vec posAB_yyyy = vecInsert<VecMask::_0011>(posA_yyyy, posB_yyyy);
Vec posAB_zzzz = vecInsert<VecMask::_0011>(posA_zzzz, posB_zzzz);
Vec posAB_rrrr = vecInsert<VecMask::_0011>(posA_rrrr, posB_rrrr);
// 4 dot products
dotA45B45 = vecMulAdd(posAB_zzzz, pl_z4z5z4z5, pl_w4w5w4w5);
dotA45B45 = vecMulAdd(posAB_yyyy, pl_y4y5y4y5, dotA45B45);
dotA45B45 = vecMulAdd(posAB_xxxx, pl_x4x5x4x5, dotA45B45);

// Compare against radius
dotA_0123 = vecCmpGTMask(dotA_0123, posA_rrrr);
dotB_0123 = vecCmpGTMask(dotB_0123, posB_rrrr);
dotA45B45 = vecCmpGTMask(dotA45B45, posAB_rrrr);
Vec dotA45 = vecInsert<VecMask::_0011>(dotA45B45, zero);
Vec dotB45 = vecInsert<VecMask::_0011>(zero, dotA45B45);
// collect the results
Vec resA = vecOrx(dotA_0123);
Vec resB = vecOrx(dotB_0123);
resA = vecOr(resA, vecOrx(dotA45));
resB = vecOr(resB, vecOrx(dotB45));
// resA = inside or outside of frustum for point A, resB for point B
Vec rA = vecNotMask(resA);
Vec rB = vecNotMask(resB);
masksCurrent[0] |= frustumMask & rA;
masksCurrent[1] |= frustumMask & rB;

额外的剔除包含视锥体和AABB、将AABB投影到屏幕空间、软件遮挡。将AABB投影到屏幕空间时,计算屏幕空间中AABB的面积,如果面积小于设置就跳过它,由于FOV取距离不起作用。

软件遮挡已在Frostbite中使用了3年,跨*台,艺术家制作的遮挡物,主要用于地形。使用软件遮挡的原因有:想要去除CPU时间而不仅仅是GPU,尽早剔除,GPU查询由于落后于CPU很棘手,必须支持破坏系统,易于艺术家控制。具体做法是使用软件渲染将PS1风格的几何图形渲染到Z缓冲区,其中Z缓冲区是256x114的浮点。具体步骤包含遮挡三角形设置、地形三角设置、光栅化三角形、剔除。

上:场景遮挡体;下:光栅化遮挡体的结果。

并行裁剪作业图例如下:

以下是并行设置遮挡体三角形的图例:


Z缓冲区测试过程是计算对象的屏幕空间AABB,获取单个距离值,根据Z缓冲区测试正方形:

总之,准确和高性能的剔除至关重要,减少低级系统/渲染的压力,一切都与数据有关,简单的数据通常意味着简单的代码,充分了解目标硬件的特性、架构和参数。

Firaxis LORE And other uses of D3D11是系列游戏文明5(Civilization V)的引擎利用D3D11实现多线程绘制的架构改造和优化技巧。

文明5游戏截图。

早期目标是希望图像引擎能够“经得起时间的考验”,使用D3D11 Alpha建立引擎原生的D3D11架构,并向后兼容到DX9。

第一步:降低开销。着色器开始于Firaxis着色语言(FSL)的HLSL超集,编译到CPP和头文件,所有的着色器常量是映射到结构体,分组到包中所有的包具有相同的绑定,模型代码被模板化——FSL生成的头文件被绑定到模板代码中,结果是少量的代码,填写所需的着色,在性能分析几乎没有影响。

第二步:抽象渲染。仍然需要支持DX9,未来可能需要支持主机,可能需要写一个“驱动程序”,解决方案是让DX9看起来像DX11。

渲染封装层的特点:无状态渲染,比D3D简单得多;一个命令集可以包含一个要渲染的表面列表,每个表面都有一个shader常量负载;一个表面是一个由IB、VB、纹理、着色器定义等组成的不可变包(bundle),命令引用这些状态包之一;整帧被队列化,最小化每帧分配。只有5种命令:

  • COMMAND_RENDER_BATCHE
  • COMMAND_GENERATE_MIPS
  • COMMAND_RESOLVE_RENDERTEXTURE
  • COMMAND_COPY_RENDERTEXTURE
  • COMMAND_COPY_RESOURCE

渲染封装层架构图。

基于作业的多线程并行系统。

为什么要对整个帧执行排队?看起来像是额外的开销,但性能分析显示是净收益,内部命令设置超级便宜,只是一些内存拷贝,引擎缓存一致性要好得多,D3D驱动程序缓存一致性在一个巨大的转储中要好得多,提交时间占总CPU时间的百分比非常低,允许过滤冗余的D3D调用,即使在DX9中也很快。

实现优势:一旦掌握了“无状态”的概念,代码维护变得容易。几乎没有状态泄漏(闪烁的alpha、纹理等),因为渲染是分组的,每个任务之间很少或根本不需要通信,没有线程错误。

线程化的D3D11命令提交的问题:批量提交的驱动开销通常很高,但是,D3D11有多线程提交命令流不一定1:1映射到CommandList,文明5可以通过设置配置文件来改变它的提交方式。

线程化的D3D11命令提交。

总之,高吞吐量渲染是可能的,前提是:小心降低应用程序开销,基于作业、基于载重的渲染,过滤冗余状态和调用,使用D3D11命令列表,引擎可以在97%的情况下充分利用12个线程(无驱动)。

DirectX 11 Rendering in Battlefield 3分享了2011年的Frostbite 2引擎利用DirectX 11的特性,在引擎中实现诸多渲染特性,包含延迟渲染、分块渲染、立体3D渲染、各类抗锯齿和性能分析等等。

当时Frostbite 2面临的渲染选择有:切换到延迟着色,BF3中丰富的户外 + 室内 + 城市环境组合,想要更多的光源。为什么不用前向渲染?灯光剔除/着色器排列效率不高,昂贵且更难的贴花/破坏遮蔽。为什么不使用Light Pre-pass?CPU和GPU上的2倍几何通道太高开销,能够将BRDF推广到足够的几个变体,看到了基于分块的延迟着色的巨大潜力。

传统延迟照明/阴影的缺点是拥有大量大光源时的大量透支和ROP成本,在光照着色器中拥有多个逐像素材质的成本很高,MSAA照明可能很慢(不连贯,额外的BW)。

Frostbite 2的解决方案是使用基于分块的延迟渲染(Tile-based Deferred Shading)

1、将屏幕分成瓦片(tile),确定哪些灯影响哪些瓦片。

2、仅对像素应用可见光源。具有多个光源的自定义着色器,降低带宽和设置成本。

使用计算着色器的基于图块的延迟着色,主要用于解析光源(点光源、聚光灯、线光源),没有阴影,需要计算着色器5.0。可使用混合图形/计算着色管道:图形管线光栅化不透明表面的gbuffer,计算管道使用gbuffers、剔除灯光、计算光照并与着色相结合,图形管线在上面渲染透明表面。

计算着色器的第一步是设置输入输出数据,相关代码如下:

// 输入:gbuffers、深度缓冲区和灯光列表
Texture2D<float4> gbufferTexture0 : register(t0);
Texture2D<float4> gbufferTexture1 : register(t1);
Texture2D<float4> gbufferTexture2 : register(t2);
Texture2D<float4> depthTexture : register(t3);

// 输出:完全合成和点亮的 HDR 纹理
RWTexture2D<float4> outputTexture : register(u0);

// 每像素1个线程,16x16线程组
#define BLOCK_SIZE 16
[numthreads(BLOCK_SIZE,BLOCK_SIZE,1)]
void csMain(
   uint3 groupId          : SV_GroupID,
   uint3 groupThreadId    : SV_GroupThreadID,
   uint groupIndex        : SV_GroupIndex,
   uint3 dispatchThreadId : SV_DispatchThreadID)
{
    (...)
}

第二步是加载 gbuffers & depth,计算threadgroup/tile中的min & max z,在组共享变量上使用 InterlockedMin/Max,原子仅适用于整数,可以将float转换为int(z始终为+):

groupshared uint minDepthInt;
groupshared uint maxDepthInt;
// --- globals above, function below -------
float depth =
 depthTexture.Load(uint3(texCoord, 0)).r;
uint depthInt = asuint(depth);
minDepthInt = 0xFFFFFFFF;
maxDepthInt = 0;
GroupMemoryBarrierWithGroupSync();
InterlockedMin(minDepthInt, depthInt);
InterlockedMax(maxDepthInt, depthInt);
GroupMemoryBarrierWithGroupSync();
float minGroupDepth = asfloat(minDepthInt);
float maxGroupDepth = asfloat(maxDepthInt);

第三步是裁剪,确定每个tile的可见光源,针对*截头体剔除所有光源,输入全局的 灯光列表、截锥体和SW遮挡剔除,输出可见光源和可见光源索引列表。

struct Light 
{
    float3 pos; float sqrRadius;
    float3 color; float invSqrRadius;
};

int lightCount;
StructuredBuffer<Light> lights;
groupshared uint visibleLightCount = 0;
groupshared uint visibleLightIndices[1024];

// --- globals above, cont. function below ---
uint threadCount = BLOCK_SIZE*BLOCK_SIZE;
uint passCount = (lightCount+threadCount-1) / threadCount;
for (uint passIt = 0; passIt < passCount; ++passIt)
{
    uint lightIndex = passIt*threadCount + groupIndex;
    // prevent overrun by clamping to a last ”null” light
    lightIndex = min(lightIndex, lightCount);
    if (intersects(lights[lightIndex], tile))
    {
        uint offset;
        InterlockedAdd(visibleLightCount, 1, offset);
        visibleLightIndices[offset] = lightIndex;
    }
}

GroupMemoryBarrierWithGroupSync();

最后的步骤是对于每个像素,累积来自可见光的光照,从群组共享内存中的瓦片可见光索引列表中读取。结合光照和阴影反照率,输出为 MSAA HDR纹理,在顶部渲染透明表面。

float3 color = 0;
for (uint lightIt = 0; lightIt < visibleLightCount; ++lightIt)
{
    uint lightIndex = visibleLightIndices[lightIt];
    Light light = lights[lightIndex];
    color += diffuseAlbedo * evaluateLightDiffuse(light, gbuffer);
    color += specularAlbedo * evaluateLightSpecular(light, gbuffer);
}

对于带MSAA的计算着色器光照,只有边缘像素需要完整的每个样本照明,但是边缘的屏幕空间一致性很差,效率低。Compute Shader可以构建高效的连贯像素列表,评估每个像素的照明(样本0),确定像素是否需要按样本照明,如果是,添加到共享内存中的原子列表,当所有像素都完成后,同步,遍历并点亮样本1-3以获取列表中的像素。性能可以大幅提升!

在Frostbite 2的地形渲染中,使用实例化(instancing)进行优化。DX9风格的流实例化很好,但有限制,例如额外的顶点属性、GPU开销,不能(有效地)与蒙皮结合,主要用于微小的网格(粒子、树叶)。DX10/DX11带来了对着色器缓冲区对象的支持,顶点着色器可以访问SV_InstanceID,可以完全任意加载,不限于固定元素,可以支持每实例数组和其他数据结构。

实例化数据包含多种对象类型(刚体、蒙皮、复合网格)、多种物体照明类型(小型/动态:光照探针、大/静态:光照贴图),拥有的不同类型的实例数据:变换float4x3、蒙皮变换float4x3数组、SH光照探针float4x4、光照贴图UV缩放/偏移float4。可以将所有实例化数据打包到一个大缓冲区中!

// 实例化示例:变换矩阵+SH

Buffer<float4> instanceVectorBuffer : register(t0);

cbuffer a
{
    float g_startVector;
    float g_vectorsPerInstance;
}

VsOutput main(
    // ....
    uint instanceId : SV_InstanceId)
{
    uint worldMatrixVectorOffset = g_startVector + input.instanceId * g_vectorsPerInstance + 0;
    uint probeVectorOffset = g_startVector + input.instanceId * g_vectorsPerInstance + 3;
    float4 r0 = instanceVectorBuffer.Load(worldMatrixVectorOffset + 0);
    float4 r1 = instanceVectorBuffer.Load(worldMatrixVectorOffset + 1);
    float4 r2 = instanceVectorBuffer.Load(worldMatrixVectorOffset + 2);
    float4 lightProbeShR = instanceVectorBuffer.Load(probeVectorOffset + 0);
    float4 lightProbeShG = instanceVectorBuffer.Load(probeVectorOffset + 1);
    float4 lightProbeShB = instanceVectorBuffer.Load(probeVectorOffset + 2);
    float4 lightProbeShO = instanceVectorBuffer.Load(probeVectorOffset + 3);

    // ....
}

// 实例化示例:蒙皮

half4 weights = input.boneWeights;int4 indices = (int4)input.boneIndices;

float4 skinnedPos = mul(float4(pos,1), getSkinningMatrix(indices[0])).xyz * weights[0];
skinnedPos += mul(float4(pos,1), getSkinningMatrix(indices[1])).xyz * weights[1];
skinnedPos += mul(float4(pos,1), getSkinningMatrix(indices[2])).xyz * weights[2];
skinnedPos += mul(float4(pos,1), getSkinningMatrix(indices[3])).xyz * weights[3];

// ...
float4x3 getSkinningMatrix(uint boneIndex)
{
    uint vectorOffset = g_startVector + instanceId * g_vectorsPerInstance;
    vectorOffset += boneIndex*3;
    float4 r0 = instanceVectorBuffer.Load(vectorOffset + 0);
    float4 r1 = instanceVectorBuffer.Load(vectorOffset + 1);
    float4 r2 = instanceVectorBuffer.Load(vectorOffset + 2);
    return createMat4x3(r0, r1, r2);
}

实例化的好处在于每个对象类型而不是每个实例的单个绘制调用,对CPU的最小冲击以获得较大的CPU增益,蒙皮时实例化不会中断,更具确定性和更好的整体性能。最终结果通常是1500-2000次绘制调用,无论艺术家放置了多少对象实例!

DX11的关键特性包含通过将D3D调度扩展到更多内核来提高性能,减少帧延迟。为了利用此特性,每个硬件线程的DX11延迟上下文,渲染器为帧的每个渲染“层”构建我们想要执行的所有绘制调用的列表,将每一层的绘制调用拆分为约256个块,与延迟上下文并行调度块以生成,使用命令列表,呈现到即时上下文并执行命令列表。

当时仍然没有高性能的驱动程序,原因是大型驱动程序代码库需要时间来重构,IHV(Independent hardware vendor,独立硬件供应商)与微软的困境,重驱动线程与游戏线程冲突。运行原理是驱动程序不创建自己的任何处理线程,游戏将工作负载并行提交到多个延迟上下文,驱动程序确保几乎所有需要的处理都发生在延迟上下文的绘图调用上,游戏在即时上下文中调度命令列表,驱动程序使用它做的工作绝对最少。

对于资源流,即使使用具有大量内存的现代GPU,通常也需要资源流,不能要求1+GB显卡,BF3关卡拥有超过1 GB的纹理和网格,减少加载时间。但是在帧内创建和销毁DX资源从来都不是一件好事,可能导致非确定性和大型驱动程序/操作系统停止,在DX中一直是个问题。

已与Microsoft、Nvidia和AMD合作,以确保可以在DX11中对GPU资源进行无停顿的异步资源流处理,不希望CPU和GPU性能受到影响,关键基础是DX11的并发创建。

资源创建流程:流系统确定要加载的资源(纹理mipmap或网格LOD),将DX资源创建添加到单独的低优先级线程上的队列中,线程使用初始数据创建资源,发信号给流系统,资源已创建,游戏开始使用它。在驱动程序中启用异步无停顿DMA!资源销毁流程:流系统删除D3D资源,驱动程序使其在内部保持活动状态,直到使用它的GPU帧完成,没有停顿!

Secrets of CryENGINE 3 Graphics Technology分享了2011年的CryEngine 3的渲染技术,包含渲染管线、位置重建、覆盖缓冲区、延迟照明、阴影、屏幕空间技术、延迟技术、批量HDR后处理、立体渲染等。

文中提到Z缓冲区的注意事项,Z值以双曲线分布,在着色器中使用之前需要转换为线性空间:

// Constants
g_ProjRatio.xy = float2( zfar / (zfar-znear), znear / (znear-zfar) );
// HLSL function
float GetLinearDepth(float fDevDepth)
{ 
    return g_ProjRatio.y/(fDevDepth-g_ProjRatio.x);
}

问题是第一人称视图(FPV)对象,深度缓冲区可以用于防止FPV对象与场景的其余部分重叠,不同的 FOV 和*/远*面(艺术特定选择),不同的深度范围,以防止实际重叠,导致延迟技术不能100%用于此类对象。解决方案是修改深度重建功能,将硬件深度转换为线性深度,第一人称视图对象的不同深度比例,根据深度选择:

float GetLinearDepth(float fDevDepth) 
{
       float bNearDepth = step(fDevDepth, g_PS_DepthRangeThreshold);
     float2 ProjRatio.xy = lerp(g_PS_ProjRatio.xy, g_PS_NearestScaled.xy, bNearDepth);
     return  ProjRatio.y/(fDevDepth-ProjRatio.x);
}

从深度重建位置的思路:将VPOS从屏幕空间S直接线性变换到目标齐次空间W(阴影空间或世界空间),从屏幕剪辑空间到齐次矩阵的直接转换,VPOS是渲染延迟光量的最简单方法,s3D单独调整。

float4 HPos = (vStoWBasisZ + (vStoWBasisX*VPos.x)+(vStoWBasisY*VPos.y) ) * fSceneDepth;
HPos += vCamPos.xyzw;

覆盖缓冲区(Coverage Buffer)作为主要的遮挡剔除系统,本质上是低分辨率深度缓冲区,用于可见性Z测试的对象AABB/OBB的粗略CPU光栅化,在CPU上准备完全详细的C-Buffer太慢,巨大的计算成本,必须在软件中复制完整的渲染管线以获得C-Buffer的所有细节。

在CPU上回读前一帧的GPU深度缓冲区,G-Buffer通道后缩小GPU上的ZBuffer(最大过滤器),通过在单独的CPU线程中光栅化BBox来完成剔除。

左:正常场景;右:覆盖缓冲区。

覆盖缓冲区用于X360/PS3和DX11硬件,PC上的回读延迟较高但仍可接受(最多 4 帧),C-Buffer 大小在控制台上的孤岛危机 2 中限制为256x128。问题:前一帧/当前帧相机之间的不匹配,导致错误的可见性测试。

解决方案是对覆盖缓冲区重投影(Coverage Buffer Reprojection)。使用上一帧相机中的C-Buffer CPU重投影,重投影片元的点溅射。相机信息被编码到C-Buffer数据中,CPU回读和重新投影在单独的线程中,在SPU上约2毫秒,在带有矢量化代码的Xbox 360上约3-4毫秒。重投影后在C-Buffer内缝合孔,3x3的扩大通道,剩余的C-Buffer洞:假设对象是可见的。重投影大大提高了剔除效率,解决各种遮挡测试伪影,检测到无效区域,以更高的帧率更高效地工作。

覆盖缓冲区重投影,红色是重投影之后仍然存在的洞。

CryEngine 3的延迟光照包含环境光、环境探针、GI、SSDO、RLR、光源等。

CryEngine 3的延迟阴影对太阳使用了阴影遮蔽(Shadow mask),特殊渲染目标累积阴影遮挡,阴影遮罩在使用实际阴影之前将多种阴影技术相互叠加。点光源阴影直接渲染到光照缓冲区。

级联阴影从孤岛危机1开始使用,级联拆分方案:*似对数纹理像素密度分布,阴影截头体调整为保守地覆盖相机视图截头体,阴影截头体的方向在世界空间中是固定的。更多级联允许是由于更好地*似对数分布,提高了纹素密度,减少了粉刺并改进了更宽阴影范围的自阴影。对于每个级联,将阴影截头体捕捉到SM的纹理网格。

级联阴影的通道:以延迟方式渲染的级联/点光源的阴影通道,通过渲染截锥体在模板缓冲区中标记的潜在阴影接收区域,允许将更复杂的拆分为级联,在重叠区域中选择具有最高分辨率的级联,避免浪费阴影贴图空间。

级联阴影的缓存:并非所有级联都在单帧中更新,更新成本分布在多个框架中,性能原因(尤其是 PS3),允许更多的级联——更好的阴影贴图密度分布,缓存阴影贴图使用缓存阴影矩阵,远距离级联更新频率较低,最后级联使用 VSM 并与阴影蒙版相加混合,允许从巨大的遥远物体获得大半影。

点光源阴影:总是将泛光灯分成六个独立的投影器,每个投影器的阴影贴图单独缩放,基于阴影投影覆盖率,最终比例是对数阴影图密度分布函数的结果,使用覆盖率作为参数。大纹理图集,可在缩放后每帧打包所有阴影贴图,永久分配纹理图集以避免内存碎片,模板标记的接收区域。

实时局部反射(RLR):光栅化的反射很昂贵,通常是*面反射或立方体贴图,需要重新渲染场景,标准反射受限,无论是*面,立方体贴图的小区域,通常没有曲面,光线追踪直接反射,屏幕空间中的光线追踪以*似局部反射。

实时局部反射基本算法:计算每个像素的反射向量,使用延迟的法线和深度目标,沿反射向量Raymarch,采样深度并检查光线深度是否在场景深度的阈值内,如果命中,重新投影到前一帧的帧缓冲区和样本颜色,结果相对便宜,随处可见的局部反射(即使在复杂表面上),由于屏幕空间中的数据有限而导致大量问题案例。

实时局部反射实现技巧:非常有限的屏幕空间数据,与其破碎的倒影不如倒影,如果反射矢量面向查看器,则*滑淡出,因为在这种情况下没有可用数据,在屏幕边缘*滑淡出反射样本,将抖动添加到步长以隐藏明显的步长伪影,在孤岛危机 2 中采样的HDR颜色目标,基于表面光泽度的抖动或模糊。

接触阴影:首先生成遮挡信息,在SSAO过程中计算和存储弯曲法线 N',弯曲法线是*均未遮挡方向,需要干净的SSAO,没有任何自遮挡和相对较宽的半径。然后对于每一盏灯,像往常一样计算 N dot L和 N' dot L,通过遮挡量乘以两个点积之间的钳位差来衰减照明。

屏幕空间自阴影:无法承受每个角色的阴影贴图(内存),解决内存不足的问题是通过简单的技巧/*似:射线沿屏幕空间光矢量行进,所有角色的宏观自阴影细节。

角色的屏幕空间自阴影。

Bokeh DOF效果采用了另一种内核和权重:

Rendering in Cars 2是迪斯尼继Toy Story 3之后的又一次分享,介绍了游戏汽车总动员2的渲染技术,包含光照探针、HDR色彩精度、提前模板阴影剔除及PS3后期处理。

光照探针的目标是同时支持4个玩家,作用于所有世界几何体的光照贴图,匹配实时照明。

光照探针的处理过程是从空间中的一点捕捉光照,反弹光照,环境映射。反弹光照数据存储为球谐函数,3阶SH = 每个探针108字节,可以免费在直接照明中打包(pack)。

光照探针捕捉时,在GPU上渲染立方体贴图,另存为16F用于HDR,用于速度的图集,反弹照明(立方体贴图到SH投影)。

辐照度体积是带有一堆光照探针的体积,允许在世界范围内使用各种反射光,非常流行与光照贴图一起使用。

体积的选择,用于赛车游戏,每个世界2-5英里的轨道,大部分在外面,许多薄而弯曲的区域,覆盖不是必需的。

均匀的网格体积是盒子体积,可以旋转和缩放以适应任何地方,具有可变切片数量的框拆分(密度 x/y/z),沿着体积切片放置的光照探针。结构简单,易于实现,整个数据保存到一个连续的数组中,带有长方体相交测试的样本,可以通过偏移量访问每个探针,O(1) 的网格内采样,成本只花在体积,但浪费空间。

网格还需要处理过渡区域、无效的点、体积查找等。对于体积查找,有CPU和GPU两种方式,基于CPU:每个网格分配/混合最*的SH,将SH数据传递到GPU;基于GPU:逐像素或逐顶点,GPU上的采样探针。

着色器常量是逐实例的,在着色器中计算颜色,分解大物体,可以通过顶点颜色混合将网格分开,世界照明有同样问题。

对于体积的重叠,需要进行混合,但*滑混合重叠体积很复杂。由于要与全局探测器混合,不能只收集最*的点并*均照明,重叠的区域会产生难以隐藏的疯狂过渡。

可以采用时间*均的方法,将最后一帧的SH的%混合到当前的SH中(逐世界可调),三线性过滤替代品,避免混合第一帧。

无体积的探针非常适合道路反射,将环境映射探针分配给体积。分配环境贴图时,如果在体积内,则使用体积的环境贴图,否则,使用全局探针,基于衰落区域的切换,重叠体积通过共享立方体贴图避免跳变。

渲染直接光照时,将直接照明打包进探头中,可以评估SH中的照明并添加到反弹,没有额外的性能成本,取决于网格密度。

还提供了光照覆盖,如果在体积内,则添加定向和环境光,允许艺术家控制照明,包含二维体积、一维体积、区域光灯类型。

均匀网格简单快捷,使用很少的内存并且可以很好地适应4个玩家,与艺术家有很大的灵活性。

为了同时支持4个玩家操作,需要对GPU的管线进行优化,例如降低HDR渲染的成本,降低阴影成本,为4人分屏缩放阴影贴图,使用多分辨率、延迟渲染、阴影遮蔽等。

首先考虑的是渲染纹理的格式,下表是不同格式的具体说明:

下图是不同格式产生的颜色误差:

可见LogLuv的误差最低且稳定但消耗ALU,7e3较高但较稳定,RGBM高且不稳定!

利用以上特殊格式还可能造成图像的色阶问题:

造成这个问题的原因是HDR处理管线中,从场景渲染到Render Target时损失了精度:

可以将色调映射组件分为两部分,曝光校正和色调映射运算符将其缩放到 [0,1] 范围。它们是可分离的,可以在不同的时间完成。注意,曝光校正是用于Cars 2的,但色调映射运算不是。Cars 2使用Hable提到的基于ALU的电影色调映射算子。

将色调映射分拆之后的HDR管线如下:

获得的结果对比:

对动态物体,还需要从光照贴图接收阴影,进出阴影时只需要粗略的过渡。所以使用了低分辨率的阴影图:使用256x256阴影贴图,超级便宜(约0.1ms),使用简单的代理几何。绘制阴影图时,仅在两个级联中绘制动态对象,减少阴影距离,重投影伪影可接受,因为轨道位于2d*面上。

对于延迟的阴遮蔽,减少处理的像素数,以1/4尺寸渲染RT,采用双边滤波上采样到正常分辨率。

不可避免的伪影是边缘伪影,较低的分辨率,太明显而无法忽略。

提前模板裁剪(early stencil culling)在到达像素着色器之前剔除片元,支持PS3、360和现代PC显卡,PC是自动的,PS3和360手动控制,编写和测试之间的延迟。不过需要注意的是,当时的Early阶段的像素是4x4的像素块。

结合了提前模板裁剪的延迟阴影的过程如下:

1、以1/16分辨率渲染阴影。

使用有限过滤以1/16分辨率渲染阴影遮罩,需要扩大阴影边缘,因为高分辨率下的边缘与低分辨率下的边缘不同,扩大的宽度可以根据它覆盖边缘的程度来配置。

2、用1/16阴影遮蔽填充全分辨率的early stencil。

点采样1/16的RT,打开提前模板写入,如果它在扩张区域内,则texkill。

3、使用提前模板测试以全分辨率重新渲染阴影边缘。

打开提前模板测试,提前模板剔除先前通道中填充的像素,仅渲染约30%的像素。两个通道的双边模糊,保持开启提前模板测试,仅模糊约30%的像素。

双边模糊效果对比,右边是模糊后的效果。

对于边缘阴影下次,也可以使用提前模板测试解决。提前模板只是一个遮罩,扩大不覆盖模糊区域,仅发生在具有大范围扩张的极端特写镜头中。

阴影值为0或1,级联选择,大多数像素处于交叉双边滤波器中。渲染到精度有限的目标时,预曝光颜色非常有效,动态对象的低分辨率阴影贴图很便宜,延迟阴影遮罩渲染时间有效地减少了一半。

该文还详细描述了基于SPU的后处理管线和优化以及立体3D渲染的双摄像头渲染优化(如遮挡、视锥体合并)等。

立体3D渲染的遮挡体瑕疵优化。

DX11 Performance Gems详细阐述了DX11的高性能优化技术和建议,给出了案例不透明度映射实现和优化(曲面细分加速光照、GatherRed加速上采样、SV_SampleIndex改善AA、软粒子的只读深度等)。

DX11的延迟上下文是用于构建命令列表的类似设备的接口,DX11使用相同的ID3D11DeviceContext接口进行“即时”API调用,即时上下文是最终向GPU提交工作的唯一方法,通过ID3D11Device::GetImmediateContext()访问,ID3D11Device没有提交API。

DirectX11的多线程命令生成和提交机制。图中有两个带有延迟上下文的线程,它们各种记录和生成命令,生成的命令列表被线程间同步、整理/排序/缓冲,然后被渲染主线程提交给即时上下文,再由即时上下文提交给GPU。

DX11内部结构比较灵活的,DX11运行时有内置实现,但是驱动程序可以负责并使用自己的实现,例如命令列表可以构建在较低级别,将更多的CPU工作转移到提交线程上。延迟上下文的优化建议:

  • 尝试通过上下文/线程*衡工作负载。但提交工作量很少情况是可预测的,粒度有帮助(如果提交线程能够动态处理工作),如果可能,先做较重的提交工作量,每个内核约12 个CL(命令列表),每个CL约1ms是一个很好的目标。
  • 保证合理的命令列表大小。想一想命令列表中的绘制调用数量很像绘制调用中的三角形数量,即每个列表都有开销,约相当于几十个API调用。
  • 留一些空闲的CPU时间。让所有线程忙碌会导致CPU饱和并阻止来自线程渲染的服务线程(游戏引擎不要使用超过N-1个 CPU内核),“忙”包括忙等待(即轮询),始终为图形驱动程序留一个。
  • 留意内存!每个Map()调用都将内存与CL相关联,释放CL是释放内存的唯一方法,可以在2GB的虚拟地址空间中变得紧张!

文中使用DX11的案例是不透明度图。使用DX11曲面细分在DS中以中间“最佳点”速率计算光照,高频分量可以根据需要保持在每像素或每采样率,如不透明度、可见度。

PS、VS、DS光照的fps和效果对比如下:

自适应曲面细分提供两全其美,类VS计算频率,与屏幕像素频率的类PS关系(1:15 在这种情况下效果很好),适用于任何缓慢变化的着色结果(GI、其他体积算法)。主要瓶颈是曲面细分操作之后的填充率,所以将粒子渲染到低分辨率离屏缓冲区,显著优势,即使使用 曲面细分(GTX 560 Ti / HD 6950 为 1.2 倍至 1.5 倍)。但是,从低分辨率进行简单的双线性上采样会导致边缘出现伪影。

相反,使用最*深度上采样(nearest-depth up-sampling),概念上类似于交叉双边过滤,将高分辨率深度与相邻的低分辨率深度进行比较,在深度不连续处来自最*匹配邻居的样本(否则为双线性)。

最*深度上采样计算过程。

效果对比。

使用SM5的GatherRed()一次有效地获取2x2低分辨率深度邻域:

float4 zg = g_DepthTex.GatherRed(g_Sampler, UV);
float z00 = zg.w;    // w: floor(uv)
float z10 = zg.z;    // z: ceil(u), floor(v)
float z01 = zg.x;    // x: floor(u), ceil(v)
float z11 = zg.y;    // y: ceil(uv)

在每个样本运行时,最*深度的上采样与AA配合得很好,而且性能惊人!(FPS影响 < 5%)

float4 UpsamplePS( VS_OUTPUT In,
                   uint uSID : SV_SampleIndex // 样本序号
                 ) : SV_Target

软粒子(基于深度的Alpha渐变)需要从场景深度读取,对于DX11之前,意味着要么牺牲深度测试(以及任何相关的加速度)要么保持两个深度表面(以及所需的任何复制)。DX11的解决方案是使用D3D11_DSV_READ_ONLY_DEPTH声明的深度模板缓冲:

软粒子的步骤如下:

1、将不透明对象渲染到深度纹理。

2、使用深度测试渲染软粒子。

最终效果对比如下:

整体性能提升5到10倍,DX11曲面细分给了大部分贡献,但是以降低的分辨率进行渲染会降低填充率并让曲面细分发光,GatherRed()和RO DSV也节省了周期。

High Performance Post-Processing谈及了后处理的特点、瓶颈及优化技术,并提供了几个实战案例。

文中提到DX11的新资源类型:缓冲区/结构化缓冲区、无序访问视图 (UAV):RWTexture/RWBuffer,允许从PS和CS进行任意读写,“分散”的能力提供了新的机会,必须意识到危险和访问模式。

新增的DirectCompute在任意线程上运行的新着色器模式,将处理从图形管道的限制中解放出来,完全访问传统Direct3D资源。DispatchIndirect从设备缓冲区而不是从CPU中获取调度参数,让计算工作驱动计算!仍受CPU约束以发出DispatchIndirect调用,与Append缓冲区结合使用以生成动态工作负载时非常好。以下是DX11的内存类型和属性表:

内存容量 速度 可见性
全局内存(缓冲区、纹理、常量) 最长的延迟 全部线程
共享内存(groupshared) 单个线程组
局部内存(寄存器) 非常快 单个线程

对于线程间通信,组中的线程可以通过共享内存进行通信,线程执行不能依赖其它组!并非所有组同时执行,组可以在Dispatch中以任何顺序执行,组间依赖可能导致死锁,如果一个组依赖于另一个组的结果,建议将着色器拆分为多个调度。

对于数据危险和停顿,重新绑定用作UAV的资源可能会停止硬件以避免数据危害,必须确保所有写入完成,以便下次调度可以看到它们,驱动程序可能会重新排序不相关的Dispatch调用以隐藏此延迟。

上下文切换存在开销,必须注意上下文切换成本:图形和计算之间的惩罚切换,通常很少,除非反复刺激,连续(Back-to-Back)调度避免了这种情况,所以分组调用。

常见的陷阱有内存限制和计算限制。内存限制包含低效的访问模式、低效的格式、数据太多。计算限制包括分支(Divergent)线程、错误的指令组合、硬件利用率低。

DX11的内存架构有点特殊,引入了复杂的内存系统。缓存行为取决于访问模式,对于线性访问,缓冲区会更好地命中缓存,纹理更适用于组内更不可预测/更多的2D访问。

使用特殊的分层采样(Stratified Sampling)。下图是两个稀疏采样模式,分布在一个2x2的线程块中。左边的图案是一个简单的抖动,它从像素给定半径内的随机位置收集四个样本。每个样本仅使用随机径向偏移,导致相同访问的相邻像素之间的位置可能有很大差异。因此,虽然它们可能存在从相似邻域获取的某些局部性,但随机偏移量意味着并发访问不太可能命中纹理缓存中的同一块,从而增加了带宽需求。在右侧,在最大半径内看到类似的4-tap稀疏采样模式。但是,这种方法不是使用任意偏移,而是使用分层采样,使得所有线程中的访问对应于圆的同一个扇区,因此同时访问更有可能命中同一个缓存区域,并且可以合并。

分层采样示意图。左侧随机采样,可能导致命中率低下;右侧使用分层采样,使得所有线程中的访问对应于圆的同一个扇区,提示命中率,并且可以合并读写操作。

与纹理不同,缓冲区是线性内存,确保尽可能读取映射到缓冲区的二维数组的间距!

Buffer<float> srvInput;
[numthreads(128,1,1)]
void ReadCS(
  uint3 gID : SV_GroupID
  uint3 tID : SV_DispatchThreadID)
{
  float val;

  // 好: 沿间距读取。
  val = srvRead[128*gID.x + tID.x];
  // ... Use data ...

  // 不好: 不沿间距读取。
  val = srvRead[128*tID.x + gID.x];
  // ... Use data ...
}

理论上线程独立执行,实际上它们以并行的wavefront执行,在wavefront执行时,线程被“屏蔽”以用于未执行分支中的指令。不同的wavefront可以免费分支,wavefront大小取决于硬件(NV:32、AMD:64)。

分支的图例。该计算着色器被定义为一组由两个wavefront组成。第一种条件情况将每个wavefront分成两个交错的集合,使它们发散。因此,每个线程都执行两个分支,wavefront基本上是50%空闲。第二个条件导致线程组内的分支; 但是,在这种情况下,一个wavefront中的所有线程都采用一个分支,而第二个wavefront中的所有线程都采用另一个。 因此,每个wavefront只执行一个分支,没有空闲线程。

在PS中,线程被分组为2D样本簇,如果它们在图像中是连贯的,则分支是可以的,特别是如果它可以节省工作!

在PS中,分支可能会降低着色效率,但是否有分支并不重要,重要的是它们在一个小区域内分叉多少。事实上,如果分支允许减少昂贵的操作(例如使用更少的纹理样本),则可以大大提高着色器的性能。

为了提升利用率,创建足够的工作以使硬件饱和,几十个线程组差不多是甜蜜点;最大化每组的线程数,需要足够的时间来隐藏硬件中的延迟,256-512是一个很好的目标;尝试共享内存使用,更多共享内存 = 更少的组/处理器,当共享内存/线程增加时尝试使组更小。

计算线程可以通过“groupshared”内存进行通信和共享数据,预加载组中每个线程使用的数据(解包值、动态规划),节省带宽和计算,共享常见任务的工作量,计算集合的总和/最大值/等,比共享原子更有效。

预加载数据到组内共享内存的示例。可分离卷积:将内核的整个足迹读入共享内存,从共享缓冲区中获取值并乘以每个像素的内核,尽量少读取,效率更高!



几种在着色器中求和的算法和图例。性能从上到下依次提升。

文中还举了具体的例子加以说明后处理的优化技巧,包含SAT DOF、Scattered Bokeh DOF等。


在研究和工业中开发的大量渲染和图形应用程序都是基于场景图,传统的场景图封装了完整3D场景的层次结构,并结合了语义和渲染方面。Separating Semantics from Rendering:A Scene Graph based Architecture for Graphics Applications提出了场景图的语义和渲染部分的清晰分离,可获得一种普遍适用的图形应用程序架构,该架构松散地基于众所周知的模型视图控制器 (MVC) 设计模式,用于分离应用程序的用户界面和计算部分。还探索了这种新设计对各种渲染和建模任务的好处,例如渲染动态场景、大型场景的核外渲染、树木和植被的几何生成以及多视图渲染。最后,展示了在大型框架中使用该软件架构的过程中已经解决的一些实现细节,用于快速开发可视化和渲染应用程序。在传统设计中,应⽤程序可以维护对部分场景图的引⽤,以便动态修改场景图。

状态也可以直接存储在场景图中,使遍历变得复杂。

以上两种设计都会导致⼤型或复杂应⽤的缺陷。在第⼀种情况下,状态与场景图分离,场景图的层次结构不⼀定反映在应⽤程序中状态存储的⽅式上。为了克服这个问题,渲染场景图的结构可以在应⽤程序中部分实现并行结构,导致重复⼯作,或者不同的结构状态可能难以维护。如果场景图的不同部分之间存在依赖关系,则与第⼆种情况⼀样,在场景图中存储状态可能会导致复杂性显著增加。

通过将语义与渲染场景图完全分离并引⼊拆分场景图架构来寻求更完整的解决⽅案来解决这些问题:

  • 语义场景图(semantic scene graph):体现了⽤⼾建模的场景。在纯渲染应⽤程序中,此图在其初始创建后永远不会被修改。
  • 渲染场景图(rendering scene graph):传统意义上的场景图,⽣成显⽰场景所需的渲染操作序列,它的结构受所使⽤的渲染后端的影响。

⼀个典型的图形应⽤程序就像⼀个编译器,它将⼀个真实的或隐含的语义场景图作为输⼊,并为3D输出⽣成渲染场景图。在这个翻译操作期间,语义场景图的单个节点通常被翻译成渲染场景图的多个连接节点(下图)。

典型的图形应⽤程序通过转换真实或隐含语义场景图的节点来构建渲染场景图。

通过使用从语义场景图生成渲染场景图的相同技术,渲染场景图将成为小场景图片段的森林,这些片段在遍历语义场景图时根据需要动态生成(下图)。在某种程度上,对语义和渲染场景图的分离让人想起作为总线系统的场景图,然而,渲染场景图块的森林可以完全从语义场景图重建,并代表一个扩展的场景图或翻译版本。

在动态翻译期间,语义场景图被翻译成渲染场景图片段的森林。

为了使渲染场景图真正动态化,在转换步骤中引入状态,并允许遍历场景图来修改现有渲染场景图片段以反映新状态(下图)。已通过创建翻译规则字典来实现,该字典包含规则对象的创建者函数,这些规则对象包含翻译的当前状态。这样一个规则对象的每个构造函数都会构建一个渲染场景图片段,该片段对应于翻译后的语义场景图节点,并存储对它的引用。

每个语义场景图节点的动态翻译会创建一个包含当前翻译状态的规则对象,每个规则对象都包含对其渲染场景图片段的引用,这种结构可以看作是模型-视图-控制器(MVC)设计模式的一种变体。

文中还详细阐述了实现细节及应用案例,有兴趣的童鞋自行点击原文阅读。

Scaling the Pipeline分享了Frostbite 2引擎的资产管理及管线的伸缩。

Frostbite 2引擎的资产管线的目标是支持多站点协作(上海、欧洲、北美)、大型团队(在某些情况下有400多个工作人员)、多个VCS分支、许多目标*台(个人电脑、PS3、Xbox 360)、内容丰富的游戏。

Battlefield 3的规模达到500GB的原始DCC资产,80GB的原生Frostbite资产,10万个文件,约18GB目标数据 (PC),10万个单独的构建步骤 (PC),当时正在开发的游戏更大。

Frostbite引擎的资产管线如下图,采用结构化存储,以构建为中心,单一资产加载路径,始终在目标上预览,支持资产热插拔,直接调整路径,游戏中的一些显式实时编辑代码。

资产打包模型见下图:

  • 捆绑包(Bundle)。资产的线性流(通常),关卡、子关卡(流式传输),线性只读(推送)。
  • 数据块(Chunk)。免费的流数据块,纹理mips、电影、网格,随机访问(拉取)。
  • 超级捆绑包(Superbundle)是容器文件,存储捆绑包和数据块。一旦安装了超级捆绑包,里面的数据就可见了。

在开发过程中,布局存储在Avalanche Storage Service中,存储捆绑包和超级捆绑包的完整描述,存储为带有块引用的包,游戏/工具请求时(通过 HTTP)即时组装捆绑包,游戏不知道网络和磁盘构建的区别(单路径)。每次构建过程都会执行完整的打包逻辑,包括迭代构建!所以必须非常快。

资产管线目标:等待构建所花费的时间 = 浪费,优化引导时间(初始构建),构建吞吐量,优化反馈时间(迭代构建),大型游戏需要高度可扩展的解决方案,具有挑战性的!还有一点吃力不讨好的任务……如果人们注意到你的工作,可能是因为你弄坏了东西,或者太慢了!

以下是存储架构及其延迟、吞吐率:

存储类型 延迟 吞吐率
寄存器 < 1 ns -
缓存 < 10 ns > 100 G/s
内存 < 500 ns > 1 G/s
网络缓存 < 50 μs -
SSD < 200 μs > 200 M/s
HDD < 20 ms > 50 M/s

这是一个缓存层次结构,更大的缓存有助于提高性能,可用系统RAM用作缓存。不要忘记将大量内存放入工作站,它将减少I/O的影响,工作集适合免费RAM -> 好!如果工作集不适合系统缓存,性能就会下降,就像不在L1/L2/L3缓存中时CPU工作一样。

构建缓存实现:

  • 从构建输入生成的密钥。输入文件内容 (SHA1),其它状态(构建设置等),构建函数版本(“手动”哈希)。

  • 可缓存的构建函数分为两个阶段。第一阶段记录所有输入,第二阶段执行工作。

  • 构建调度程序。执行第一阶段,查询缓存,如果可用,使用结果——否则运行第二阶段。

构建模型是应用函数将源数据映射到目标:

\[\text{资产}_\text{目标}= f(\text{资产}_\text{来源}, \ \ ...) \]

目标:纯功能,无副作用!简单的并行性

资产数据库:在Avalanche存储服务中管理的数据,建立商店的类似实现,即日志结构化,由将数据“导入”到数据库的映射过程产生,非常像常规的数据构建过程!数据可以从原生格式文件导入... 或其它数据源(SQL、Excel 等),保存涉及将数据库资产“导出”回文件,即反向映射。

这种数据库的好处是在构建之前无需保存到磁盘(或签出);构建的快照隔离;用于创建多个会话的廉价分支,即并排预览相同的级别/对象/着色器,不同的设置;与构建系统紧密集成;快速同步,几秒钟即可启动并运行,延迟获取等等。

Cutting the Pipe: Achieving Sub-Second Iteration Times提出了一种迭代流程,以便快速响应需求迭代,提示生产力和质量,优化流水线延迟。文中提出的迭代流程如下图:

需要更快迭代时间的原因是提升生产力,降低等待构建的时间;提升质量,增加更多调整,在控制台上在游戏中测试的资产。

在编辑场景时,不用缓存,而是实时编辑,但实时编辑的有:游戏并不总是最好的编辑器,如果游戏数据是正在使用的二进制图像,版本控制很棘手,协同工作和合并变更也很棘手,适合编辑的数据格式没有最佳的运行时性能。

两全其美的快速迭代是快速游戏和快速工作流。快速游戏需具备二进制资源、就地装载、没有寻道时间,快速工作流程需要编译时间短,热重载,即时反馈。进攻战略是尽可能快地编译并用重新加载替换重启:

重新编译和重新加载所有数据 (>1 GB) 的速度永远不够快,必须分小块工作:将游戏数据视为个体资源的集合,每个资源都可以单独编译,然后在游戏运行时重新加载,按类型+名称标识,两者都是唯一的字符串标识符(经过哈希处理),名称来自路径,但可将其视为 ID(仅通过相等比较)。

编译资源时,每个资源都编译为特定于*台的运行时优化二进制块,由名称哈希标识。

加载资源时,资源被分组到用于加载的包中,包由后台线程流入,在开发过程中,资源存储在以哈希命名的单个文件中,对于最终版本,包中的文件捆绑在一起以进行线性加载。

重新加载资源:运行游戏侦听TCP/IP端口,消息是JSON结构,来自内部工具的典型命令,启用性能HUD,显示调试行,Lua REPL(读取-评估-打印-循环),重新加载资源,也用于所有的工具可视化。

重新加载资源的细节:加载新资源,根据类型通知游戏系统,指向新旧资源的指针,游戏系统决定做什么,如删除实例(声音)、停止和启动实例(粒子)、保留实例,更新它(纹理),销毁/卸载旧资源。

// 重新加载资源示例
if (type == unit_type) 
{
    for (unsigned j=0; j<app().worlds().size(); ++j)
    {
         app().worlds()[j].reload_units(old_resource, new_resource);
    }
}

void World::reload_units(UnitResource *old_ur, UnitResource *new_ur)
{
    for (unsigned i=0; i<_units.size(); ++i) 
    {
          if (_units[i]->resource() == old_ur)
             _units[i]->reload(new_ur);
    }
}

void Unit::reload(const UnitResource *ur)
{
    Matrix4x4 m = _scene_graph.world(0);
    destroy_objects();
    _resource = ur;
    create_objects(m);
}

存在的问题:将数据部署到控制台,处理大量资源,编译缓慢的资源,重新加载代码。其中部分问题解决如下:

  • 大资源。永远无法快速编译和加载非常大的资源 (>100 MB),找到合适的资源粒度,不要将所有关卡的几何图形放在一个文件中,在单独的文件中存放具有实体的几何图形,让关卡对象引用它使用的实体。

  • 缓慢的资源。冗长的编译使快速迭代变得不可能(光照贴图、导航网格等),将烘焙与编译分开,烘焙始终是一个明确的步骤:“立即制作光照贴图”(编辑器按钮),烘焙数据保存在源数据中并检入存储库,然后像往常一样编译(从原始纹理到*台压缩)。

  • 重新加载代码。重新加载是最棘手的资源,有四种代码(着色器(Cg、HLSL)、二进制流(可视化脚本)、Lua、C++),流和着色器被视为普遍资源,只是二进制数据。

实时重新加载LUA如下图:

重新加载C++代码:工具支持“重启Exe”,exe已重新加载,但仍然在同一位置看到相同的对象,只是使用新的引擎代码,状态由工具持有。没有达到<1s 的目标,但仍然非常有用,小尺寸的exe有所帮助。

快速编译图例:

还支持增量编译:查找自上次编译以来修改的所有源数据,确定依赖于这些文件的运行时数据,重新编译必要的部分,重要的是过程坚如磐石,信任来之不易,也容易失去,“完全重新编译是最安全的”。

依赖性是一个挑战。base.shader_source包括common.shader_source,如果common.shader_source改变需要重新编译,如果不读取每个文件,我们怎么能知道这一点?解决方案:编译数据库,存储以前运行的信息,在启动时打开,在关闭时保存更新。编译文件时,将其依赖项存储在数据库中,通过跟踪open_file()自动确定它们。

二进制版本也是个挑战。如果纹理资源的二进制格式发生变化,每个纹理都需要重新编译。解决方案是重用数据库:将每个编译资源的二进制版本存储在数据库中,检查数据编译器中的当前版本,如果不匹配,重新编译,为数据编译器和运行时使用相同的代码库(甚至相同的 exe),因此二进制版本始终保持同步。

启动和关闭编译器,仅在启动和关闭编译器进程上花费了几秒钟。解决方案:重用该过程!作为服务器运行,通过TCP/IP接收编译请求。

扫描源文件,可以是以下代码:

foreach (file in source)
    dest = destination_file(file)
    if mtime(file) > mtime(dest)
          compile(file)

这种方式很慢,检查每个项目文件的mtime,而且碎片化(视日期而定)。可尝试显式编译列表,工具发送一个它想要重新编译的文件列表,工具跟踪已更改的文件,纹理编辑器知道用户更改的所有纹理,快速但碎片化:在工具svn/git/hg update之外不起作用,在Photoshop中编辑的纹理,在文本编辑器中编辑的Lua文件。

解决方案:目录观察者。在服务器启动时执行完整扫描,初始扫描后,使用目录监视来检测更改,ReadDirectoryChangesW(...),无需进一步扫描,使用数据库避免脆弱性。将上次成功编译的mtime存储在数据库中,如果扫描期间mtime或文件大小不同 - 重新编译,如果目录观察程序通知更改 - 重新编译。但会引发条件竞争,可由下图的方式解决竞争:

对于依赖项,由于不破坏进程,可以将依赖数据库保存在内存中,只需要在服务器启动时从磁盘读取,可以将数据库作为后台进程保存到磁盘,当要求重新编译时,不必等待数据库被保存,稍后编译器空闲时保存。

最后处理:处理请求时唯一的磁盘访问是:编译修改后的文件,创建目录观察者“fence”文件,否则一切都发生在内存中。结果如下:


通用规则:

  • 考虑资源粒度。大小合理,适合单独编译/重新加载。
  • TCP/IP是你的朋友。优先通过网络做事而不是访问磁盘,将进程作为服务器运行以避免启动时间。
  • 使用数据库+目录观察器来跟踪文件系统状态。数据库还可以在编译器运行之间缓存其它信息,保留在内存中,在后台反映到磁盘。

Putting the Plane Together Midair谈到了游戏中AI行为树的常见几种设计模式。脚本、层级有限状态机、行为树的优缺点和特点:

类型 优点 缺点
脚本 完整和直接的游戏控制
可广泛应用于诸多模块和系统
难以调试和优化
需要设计师的大量工程专业知识
层级有限状态机 直观的设计师
不错的低级控制
难以扩展和重用
难以达到目标导向
行为树 利用脚本的强大功能和灵活性,使其成为设计师的简单视觉语言。
采用分层 FSM 的直观和反应能力,使其可重用和目标导向

行为树图例。

行为树的节点以下有几种类型:

  • 序列(Sequence):与(&&)。
  • 选择器(Selector):或(||)。
  • 装饰器(Decorator):FOR循环。
  • 条件(Condition):游戏状态检查。
  • 动作(Action):玩法互动。

实现行为树的设计模型有:

  • 组合(Composite)。用户定义的节点和行为树。

  • 轻量级(Flyweight)。使用相同行为树定义的多个AI角色。

  • 访问者(Visitor)。需要每个AI角色能够走同一棵树并保持自己的状态。

还存在事件驱动树(Event-Driven Tree),其目标是让设计师学习并成为单一语言的专家,快速执行基于更新的AI脚本和基于事件的关卡脚本。事件驱动树就像更新驱动树(Update-Driven Tree),只是它们只tick一次。

2012年,Accelerating Rendering Pipelines Using Bidirectional Iterative Reprojection描述了利用迭代重投影和双向重投影来加速渲染管线的技术。

当前的图形架构需要对每一帧进行强力渲染,因此它们不能很好地扩展到高帧速率。然而,由于时间相干性,附*的帧通常非常相似,通过重用相邻帧的渲染结果,可以在不执行光栅化和着色的情况下合成一个合理的帧。

帧间插值示意图。

涉及实时重投影的策略有:

  • 从目标视点栅格化场景并从源视点采样着色。(Nehab2007)
  • 使用每像素图元将现有帧扭曲到目标视点。(Mark1997)
  • 使用某种*似。(Andreev2010,Didyk2010)
  • 使用迭代搜索扭曲帧。(Yang2011, Bowles2012)

假设有一个使用渲染器生成的渲染帧,如何在给定渲染帧的情况下合成新帧?MV(运动向量)是渲染管线常见的衍生数据,提供从源帧到目标帧的映射。

迭代投影示意图。

基于图像的迭代重投影过程如下:

  • 通过方程知道每个像素的映射:

    \[p_{tgt} = p_{src} + V(p_{src}) \]

  • 在目标帧上运行GPU着色器:\(p_\text{tgt}\)已知,如何解析\(p_\text{src}\)

  • 可以迭代地解析:

  • 定点(Fixed Point)迭代。算法如下:

    • 选择一个起点:(例如\(

相关文章:

  • 2021-12-12
  • 2021-12-11
  • 2022-12-23
  • 2022-12-23
  • 2022-12-23
  • 2021-05-11
猜你喜欢
  • 2022-12-23
  • 2022-12-23
  • 2022-12-23
  • 2022-12-23
  • 2021-12-26
  • 2021-12-12
  • 2021-12-22
相关资源
相似解决方案