留在港口的小船最安全,亲爱的,但这不是造船的目的
—— 弗雷德里克巴克曼《焦虑的人》
这边来学习下Roblox在Siggraph2021上所做的技术分享。
1. 基本情况介绍
在深入技术细节之前,作者先对Roblox做了一个定义或者描述:
- roblox不是一个付费工具,不是一个具有统一大厅的虚拟世界
- roblox是一个社区,不对玩法、美术风格进行预设或限制,但是添加了一些基础规则,比如真实的物理模拟效果,第三人称视角等。
- 相比于Unity/UE,更偏向于Youtube、Instagram等内容创作与分享平台,更重视用户的创意与用户之间的链接关系
- 整个世界用高抽象层次的Primitives表示,游戏引擎负责完成底层的实现(模拟、渲染等)
Roblox公司的基本情况:
- 起源于2004年的DynBlocks,这是面向教育的软件
- 整个团队人员较为精简;
- 引擎最开始用的是G3D,后面切换成Orge,最后是自研引擎Bespoke
- 最开始的时候只有一个程序员,18年增加到4个人,21年增加到15个人,现在则是700多个人约77个团队
下面来看看相应的技术细节。
2. Roblox虚拟世界的技术细节
整个虚拟世界的组织形式类似于HTML DOM(树状、层次结构),使用Lua作为脚本语言。
虚拟世界包含多个Root Service(根节点),如上图所示:
- Workspace表示的是这个3D虚拟世界
- Lighting表示场景的环境光照,影响的是整个世界的效果,因此在逻辑上认为不属于这个物理世界的一部分
数据交互模式采用C/S模式,用RBXL表示我们说过的描述(静态)虚拟世界的类似HTML数据,Client不需要拿到这个数据,这个数据是服务器使用的,客户端拿到的数据是从服务器流式下发下来的(同样可以实现一定的安全控制,避免客户端控制游戏逻辑导致问题),但是不需要拿到所有的数据(比如有些数据是服务器专用的,比如一些远景数据会被替换成一些轻量级的voxel、imposter数据等)。
Lua脚本是在沙盒中执行的(安全考虑):
- 脚本默认是在服务端执行,每次对世界的变更都会复制到客户端
- ServerStorage物件不需要复制到客户端
- 这里也会有一些客户端本地执行的脚本,这些脚本会用于实现一些表现,这些数据不需要同步给其他客户端。
整个Roblox可以看成是Primitives构成的世界,Primitives对于Roblox而言,意味着如下的一些元素:
- Parts
通过使用Cubical Projected贴图(这个指的是立体贴图吗?)+贴花来实现(具体怎么实现,是说通过立体贴图来实现纹理效果吗?),支持在运行时的CSG操作(布尔运算),碰撞体同样也是通过CSG来实现? - Mesh Parts
带有UV的Parts,用来实现一些复杂的模型效果,碰撞体通过对多个凸多面体进行组合来得到(这个是动态拆解计算得到的吗?),支持运行时的自动LOD(不知道效果是否能令人满意)。
这个方案看起来有点复杂,在2006年首次上线,在2008年撤回,在2016年重新上线。
2021年增加了蒙皮模型。 - 其他部分
基于体素的地形方案,特效系统 - 材质部分
这里的材质不仅仅是影响视觉的效果材质,同时还有影响听觉与玩法的物理材质。
Roblox使用了这一种分布式计算方案,整个方案可以分成客户端、服务器以及云服务三部分。
服务器部分实际上是Linux版本的引擎(听起来类似于传统的游戏服务器,负责游戏玩法逻辑)
云服务则指的是一些脱离游戏玩法逻辑的服务,如面向开发者的数据分析、数据存储、资源商城服务等,其中包含影响在目前而言最为重要的一项服务,如资源服务。
说到资源,就需要介绍一下UGC的内容制作管线,首先,资源是不可更改的(Immutable),不过支持通过上传新版本的方式来实现一定的修改自由度(之所以不支持直接在原资源上进行修改,是因为原资源可能已经发布出去被其他的关卡引用了,修改的结果是不可控的,当然,可以通过对资源添加引用计数来判断是否可以修正或删除,不过这么做复杂度就会高很多)。
资源包括了贴图、模型、声音、动作、静态的CSG物体等,这里说的不可修改,其实就意味着资源可能会被审核,审核方案包含AI+人工两种(不知道具体怎么判定哪些需要人工,是不是AI进行粗审,人工对可疑内容进行复核,或者全部AI,在玩家申诉之后再走人工?),Bespoke引擎为每种资源都开发了对应的审核工具。
资源的传输逻辑为CDN+基于资源地址的存储方案,CDN的问题在于高延迟,高带宽消耗,不知道这里基于Amazon S3的资源地址存储方案具体指的是什么?
资源的生命周期包含如下的几个阶段:
- 上传,在这个过程中会进行资源的验证(进行一些数据规格的确认?)与归一化(指的是资源的LOD自动生成,或者其他参数的转换?),这个过程是在网页端完成的(听起来,数据上传是在服务器进行处理的)
- 审核
审核通过之后就意味着资源可以被外部使用了,在这个过程中也会做一些初始化的处理,比如生成缩略图等 - 客户端请求某个ID的资源
这时候会根据客户端所在的平台,选择最合适的一个可用版本进行下发。在必要的时候会进行延迟优化,这些优化包括如下的一些操作:
- 在服务器进行的贴图压缩
- PBR材质的转码(transcoding),如specular AA, packing, automatic micro-AO等
- 模型的处理,如LOD等
在这个过程中,可以随时根据需要为特定平台指定新的数据格式,并通过对某种类型的资源进行失效处理来触发数据的更新,另外,任何资源都可以随时退回到未处理前的版本,比如贴图对应的未处理版本为PNG而非DXTC等格式。(看起来Roblox中存储了数据的原始格式,在需要的时候就通过完成对应输出格式的烘焙并下发进行更新替换)
场景里的所有数据都是可以编辑与动态替换的:
- 资源上99.9%的属性都是可以修改的,且已经暴露给lua脚本,可以在运行时进行动态修正。
Roblox的Studio工具实际上可以看成是运行时版本上添加了一套UI界面,底层数据模型与技术方案是完全互通的。 - 任何资源默认都是带有物理效果的:服务端上会对所有物件进行物理计算,客户端则会对附近的物体进行物理计算
- 示例说明:
- Models实际上是一系列的Parts(部件)组合在一起的结果
- 玩家Avatar其实也是一系列的组件与物理组装得到的
渲染方案需要因此而考虑这个动态世界的设定所造成的影响:
- 不能使用实例化或引用等方案(因为属性会千差万别?但总归有很多数据是相同的吧),因此需要一套新的方案来优化DrawCall
- 不存在Transform的父子关系(剔除等优化逻辑会受到影响?)
这里没有介绍具体的做法,展示一下最终的结果: - Roblox Battle Royale场景中包含了5w个部件,每个都是可破碎的
- 在Townhall中,同时支持500个玩家的聚集
在设计层面,会在每个方面都考虑如何将技术细节隐藏起来,这样做有两个好处:
- 避免增加玩家的心智负担
- 技术升级对玩家来说是无感知的,操作习惯可以不需要改变就能实现效果与性能的提升
Roblox在技术上:
经历了15年的迭代升级,渲染API经历了从GLES2/DX9到如今的DX11/Vulkan/Metal等的跃迁。
在用户体验上,则秉持着一些高层次、通俗易理解的通用概念:
- 不需要人工参与或干预的优化设施:如不需要手动添加遮挡体,不需要手动放置Portal,不需要手动进行Lightmap/Probe的摆放与编辑等
- 可以接受一些暗示?(没太理解这句话,后面通过视频补齐一下)
- 不需要对一些技术参数进行调整,比如阴影贴图的bias,SSAO的采样数目等
遵循上面的一些设计原则,也为Roblox带来了一些副作用,总的来说就是添加了一些约束,不过在其带来的好处面前,这些不足可以忽略:
- 总的来说对于玩家来说,制作会变得更加简单
真实感渲染效果与技术、创造力等存在较多耦合,导致游戏制作相对困难 - 更加适合社交玩法,具有强semantics特性(?),在某个场景中制作输出的内容或脚本可以无缝迁移到其他场景中,且能正常工作。
下面来看下Roblox的引擎设计,这里放了一张在Roblox中制作输出的Cyberpunk 2077风格的世界效果图,大概是想说明Roblox的引擎能够实现3A品质的效果?
3. Roblox的引擎设计 - 反3A
总的来说,引擎设计由于如下的一些约束而变得困难:
- 不希望玩家对资源或内容进行手动优化
- 不希望将技术参数暴露给玩家进行调整
- 需要支持100k+级别的客户端接入,大部分的客户端来自于小朋友,因此性能比较拉胯
- 场景是完全动态的,预计算技术都不能使用
- 需要支持分布式计算:
- 低延迟、低功耗的计算放客户端完成
- 中等延迟、中等功耗的放服务端完成
- 高延迟高功耗的放云服务中完成
- 引擎团队人力有限
为了说明工作的复杂度,这里举了profiling功能为例,总结起来就是在大数据分析上存在技术挑战,在适配或调试工作上现有工具不够给力,还需要面对复杂的用户设备与设置与行为。
具体应对措施,大致的做法是从优化的思路转变为适配的思路,即针对不同设备进行分级处理。
- 合理的需求降级
- 客户端只需要保证不崩溃即可,可以在必要的时候降低玩法品质
- 最简单的方式:关闭一些重要程度相对较低的功能,或者采用fallback方案来取代高精方案
- 唯一的原则:不影响游戏玩法即可
- 尽可能的进行分帧处理
- 增量更新
- 阴影贴图、probe、voxel等的更新与计算都是增量进行的
- 即使是VB的创建也用上了。。
添加了一个FrameRateManager,根据帧率动态调整渲染参数:
- 动态调整分辨率
- 开关ShadowMap
- 调整LOD或裁剪距离等
这种方法的问题在于参数很难调整(在于设备众多、游戏种类众多?还是在于性能与效果的兼顾?),这里的做法是将某个游戏的参数配置直接缓存在本地(客户端,解决了什么问题呢?)
Roblox现在输出的品质在跟3A产品有很大差距,但是从长远看来,基于场景可以实时修改,添加更多细节,且底层技术可以不受玩家感知的进化,最终Roblox是能够在品质上不断提升,逼近甚至超越3A的
这里对前面的工作做了个总结,下面来看下引擎的一些实现细节。
4. 引擎实现细节
先来看下渲染架构,这里采用的是经典的Main+Render双线程模型
- 主线程负责完成数据的准备,场景更新
- 渲染线程负责将场景渲染出来
- 这个过程会与下一帧的模拟(主线程更新)并行执行,不需要考虑场景的动态变化(如果没理解错DM是Dynamic Modifcation的意思的话?)
- 根据主线程传入的数据,创建对应的渲染结构
- 开始实际的渲染提交逻辑
这里还采用了经典的线程池方案:
- 线程之间并没有构成一个Graph,但是一个任务可以触发一系列新的任务的创建
- 这个方案主要是为了将一个复杂工作分散到多帧来完成
这里想要表达的是什么呢?线程池方案的优点在于实现简单,且目前远没有达到其所能达到的优化的极致,且怀疑即使这个方案做到头,其优化的程度或者系统复杂度可能都不如直接使用Graph System(那为什么不用?)
逐步逐步看下从DataModel(主线程)到渲染(线程)是怎么交互的。
DataModel到Rendering,是通过混合式的事件驱动+主动Push方案+被动Pull来实现的
对于Part或MeshPart而言,DM中的所有对象都会继承自一种实例(instance)类型,这个类型的作用是为对象提供反射能力与事件响应能力。
GfxBinding是一个渲染接口,其作用为监听属性变化,并且完成child的添加或者移除逻辑(这里child指的应该是其管理的对象的一个描述)。
每个渲染子系统都是GfxBinding的一个实现,用来实现对其管理的所有DM对象的操作,通常来说这些操作不过是将一些渲染结构标记为无效,或者将标记无效这个事件存入队列等到合适时机取出执行。
对于2D/3D GUI对象来说,使用的是GfxGui接口,这个接口的作用是记录GUI虚拟机的指令。
先来看下,主线程这边是如何完成数据准备的。
- 对DM添加了只读锁
一些工作可以做成并行的(目前还没来得及),此外对于只读锁处理的工作而言,要尽可能的轻量 - 对于无效队列的处理
比如对于Parts/MeshParts,会获取其相关的一些改变(比如Transform数据),只是不了解这里的pull指的是什么? - 处理一些全局状态(如光照设置等)
这里采用的是直接拷贝覆盖的方法,没有做增量处理或者事件化处理
对于场景的渲染,采用的是基于cluster的加速策略。
- 避免每个物体触发一个drawcall的情况发生,希望drawcall跟物件数的比为100:1
- 会考虑将场景划分为多个cluster,这个划分是基于object type与API能力(渲染API吗?)来实现的?
- 这里的渲染会基于一个假设来进行:虽然场景中存在大量的动态物件,但是每一帧只有少量物件在发生变化
- lua处理的工作不需要太快,因此这里也没做批量更新或者层级更新
- 物理与动画更新需要较高频率,这些计算可以在内部进行加速
Roblox的Clustering系统主要有两个:
- Parts & MeshParts是基于空间来完成cluster划分的
- 在渲染的时候,则使用FastCluster或者InstanceCluster
当然除了上面的两个主要的之外,还有一些其他的: - 地形使用的是voxel,采用了smoothcluster方案,在地形之上的数据如植被则采用另一个“装饰”cluster
- 远景流式imposter采用的是modelcluster:不需要使用贴图,直接用顶点色,此外记录下那些具有相同属性的实例,并将数据进行重复利用?
- 3D GUI(类似于Atlas)采用了GUI Cluster,这些数据的渲染只是用基础光照
- 2D/3D GUI是,用的是顶点流(VertexStream),这个是啥?
FastClusters是一项在所有设备上都支持的特性,其基本原理是在运行时创建一个新的IB/VB,并将一大堆的几何数据塞入进去(动态合批)。
这里的一个要点在于如何考虑,哪些需要合并在一起,哪些不能:
- 基于地图网格划分(还会考虑一些物理约束)进行合并
- 小尺寸物件跟大尺寸物件分到不同的层级中(加载范围会有所区分)
另外一个问题在于,如果clustering需要频繁合并拆分的话,计算时间消耗会比较高,因此这里的做法是将之分帧完成,此外,为了降低这种操作的频率,还会考虑在一个cell中拆出静态跟动态的cluster。 - 动态cluster指的是会发生变化的cluster,这里提到了72个transform的上限约束,不知道想要表达的是什么?
- 此外,如果一些材质参数直接写入到顶点数据中,就可以进一步加大合批力度,减少drawcall
在合批方案中,有一些特殊的处理,比如角色就不参与到合批中,因为基本上每个角色都是与众不同的,合批是负优化的概率较高。
不过这里也做了一些优化,将角色身上的多个贴图合并成一个atlas,通过一个drawcall完成绘制。
接下来要做或者正在做的优化有:
- 对Atals进行压缩
- 通过软光栅标记出被用到的贴图的区域,并将这些区域拷贝出来放到Atlas中(?),支持3级Mipmap
- 通过OC方案来移除被clothing所遮挡的角色数据的绘制消耗。
InstanceCluster对硬件有一些要求:支持硬件实例化。
- 这个方案在部分场景中可能不能实现贪心合批,从而导致drawcall数不是最优的
- 在实际情况中这个并不会是一个问题,因为支持硬件实例化的设备通常能够应对更多的drawcall
- 每个instance数据尺寸为112字节(这个有什么讲究?)
在实际情况中,合批会需要根据具体情况来判断采用的策略(这两种做法背后的逻辑是?):
- 大尺寸的cluster(包含70+个parts)会采用静态上传instance数据到GPU的做法
- 小尺寸的cluster,会对单个part进行动态的聚合,这种做法对于半透物件尤其合适
Instance的好处是,内存消耗较少,可以绘制更广的视野。
这种方法在Mesh LOD上的处理是针对整个cluster进行LOD切分处理。
渲染线程主要通过两个结构完成抽象:
- GfxRender:负责绘制
- 对场景进行剔除,并对渲染队列进行排序,大部分的cluster都实现了对应的接口
- 一些其他的子系统会采用更直接的方式完成绘制,比如UI或者粒子等
- GfxCore:负责与硬件的API对接
- 所有的渲染都会经过这个模块完成与硬件的交互,包含了资源、技术方案、几何数据等的管理
GfxCore支持了众多的API,同时还实现了Roblox的Shader Compiler:
- Shader通过HLSL书写
- 存在少量的变种(通过硬编码实现)
- 后端支持HLSL、SPIR-V,Metal、GLSL等
下面看看光照与Shading部分的实现。
Shading的原则是:
- 尽可能的解耦
- Draws,整体路径为Clustering - Culling/Sorting - HW API
- Lighting来自Draws?
- 部分特性不是通用的,而是为更新的InstanceClusters专门定制的(为什么说这句话?)
- 不针对特定设备实现功能
- 无法控制游戏复杂性
- 即使一些高端设备也需要使用简单的渲染
- API不能考虑到所有的应用情景:一些低端设备说不定还能支持更现代化的API
这里介绍Lighting的几个显著特点:
- 支持Voxel Lighting
- 在CPU上,通过SIMD方式渐进计算
- 对边缘进行voxel处理(voxel chunks with skirts)?
- 用于局部光照,太阳阴影,天空遮挡
- 只有太阳光是解析式计算的
- 其他光源的diffuse使用的则是LPV方法(精度低?)
- 目前在实验GPU Voxel方法,还没发布
- Shadows
- 用来替换太阳光的voxel shadow
- 支持缓存的EVSM、CSM
- Forward+管线
- 替换掉所有的阴影,并且强制将所有的光源的光照计算都按照解析式来完成
分析式光照计算则准备分享如下两点:
- 所有的解析式光源都使用了PBR
- 自定义的一套接近于GGX的方案
- 使用球面高斯来逼近,以适配低端机的性能
- 按照品质从低到高来看,依次有如下的顺序:
- voxel light + 太阳的voxel shadow+基于voxel sky可见性的室内外环境光,基于顶点的光照
- 将光照移动到PS,添加光景贴图,室内外依然会使用voxel sky的光照
- 添加太阳的阴影贴图
- 添加F+光源,所有的光照与阴影贴图,voxel只用在环境光
- 添加动态的probe
最后对前面的内容做一个总结。
参考
[1]. The Rendering Architecture of Roblox - Slides
[2]. The Rendering Architecture of Roblox - Video
[3]. Angelo Pesce - Rendering the Metaverse across Space and Time