今天要学习的是《漫威蜘蛛侠》在GDC 2024上分享的城市LOD实现策略,重点介绍了其LOD数据的生成,有如下的几个关键信息:
- 低级别的LOD数据是基于simplygon生成的,借用了减面跟remesh的能力,当然,中间做了很多细节的处理
- 远景模型分为两级LOD,中景的高精度与远景的低精度,高精度是通过减面算法得到,低精度则是通过remesh(类似于只保留一个内壳,减面粒度更狠)得到
- 低精度模型是常驻的,而145m以内会进入中高精度模型的加载范围,需要走streaming
- 场景采用DrawIndexIndirectMulti方式渲染,可以一次性绘制多个类别的多个实例,在4k场景下,PS5的消耗大约是1.4ms
- 玻璃(窗户)需要单独绘制,其加载距离跟绘制方式都走自己的独立一套,耗费可能高于建筑本身
- 部分小物件需要做远景展示,这类物件需要人工标注,且通过极致的合批算法来优化性能

先来看下建筑合并的基本介绍。

大部分建筑都是由若干小部件组成,比如图中所示的建筑由2k个部件组成,部件之间并不能保证联通(?)

这里列举了之前几部蜘蛛侠的城市远景生产管线:
- 美术同学手工在DCC软件中输出带有LOD的建筑模型
- 引擎中保存的城市区域(zone)信息跟上面的建筑模型数据一起输入到Houdini中,完成城市的PCG生成
- 输出的成熟数据通过引擎自带的Texture Capture工具来完成Atlas的合并与proxy模型或贴图的生成
整个管线由Houdini驱动,但是其中也会由较多的人力干预。
在145m之后,会切换成远景?

此前的工作流中存在一些不统一或者效率低下的地方,针对性的,新管线希望能够达到如下几个目标:
- 自动化:
1.1 减少手工环节的成本
1.2 在构建服务器上完成LOD的自动更新 - 提升整体的品质
2.1 分为两级LOD:中景采用高分辨率,远景采用低分辨率(近景呢?)
2.2 对于raytracing也是两级,不过中景采用的是中分辨率,远景采用低分辨率

为了提升中景的表现,需要将此前的tile方式改成新增的一级相对高分辨率的LOD

新管线的落地存在一些挑战或风险:
- 游戏基本上制作完成了,再来动管线需要慎重
- 现有的建筑采用的索引是16bit的,顶点数的约束,对新管线也提出了要求
- 对内存与磁盘存储空间的约束也会是一个风险

首先考虑的就是模型减面

Simplygon减面算法已经在项目组的使用中,不过之前用的是非远景的模型LOD减面,这个算法的使用需要经过几次迭代,有如下的几个特点:
- 其中有一个步骤是weld(拼接、焊接),即需要人工来完成减面后模型的拼接(?)
- 能够得到基本一致的模型跟拓扑结构
- 不支持非流形表面(non-manifold)
- 每个建筑的第一级LOD就是采用这个算法得到的(?)
简单来说,如果一个几何体可以被展开成一个连续的二维平面,即为Manifold,否则为non-manifold,参考几何】Manifold与QEM
![]()
下图中给出的建筑经过这个算法可以优化为原始建筑的1/10的面数。

Simplygon还有一个remesh的功能,这个功能可以生成一个全新的mesh:
- 与原始模型具有较高相似度
- 两者的模型、组件的连接关系并不相关
- 可以消除非流形的edge(边)
- 计算消耗较高,高模的计算花费时间可能需要超过几个小时
- 可以用来生成建筑的第二层远景LOD(面数是第二层的BVH LOD的两倍?)

减面功能对于高模有较好的效果,而Remeshing则对低模作用比较好,那中模该怎么得到呢?测试发现两种方案都不能得到令人满意的结果,最终的方法是(?):
- 通过Remesh来得到一个相对保守的目标
- 之后通过减面来得到令人满意的分辨率

这里的问题是,这种做法会跟之前列举的几项引擎的约束相冲突,主要体现在:
- 原始模型中法线的缺失在经过减面或者remesh之后会导致效果的异常
- Remesh得到的inner shell(?)
- 减面之后会存在裂缝
下面对这几个问题做仔细的介绍

此前的建筑模型的顶点数据是没有顶点法线,也没有法线贴图的,法线数据完全是运行时计算的,而这个导致的问题就在于在减面或者remesh之后就会遇到问题:
- 之前平整的区域,减面或remesh后可能不一定依然凭证
- 导致faceted的效果(?)
- 建筑会出现三角面片化的下次,如下图所示

部分未封闭的建筑经过remesh之后则变成了一个封闭的mesh。

减面会导致裂缝

三角面片模型的分析

基本信息

对流形的解释

Genus表示的是一个模型中包围出了多少个孔洞,可以通过下图中的公式计算得到:

对偶模型,将每个面片转换为一个顶点(放在面片中心),相邻的面片会存在一条边来连接对应的两个顶点,得到的mesh就是dual mesh。

离散弯曲度,用于衡量曲面的弯曲度,光滑的表面的曲度可能非零。
模型的曲度怎么定义?
- 分段定义
- 大部分区域的曲度可能是零或者无穷大
- 基于mesh所模拟的表面的曲度来定义

模型展开,可以通过这个来解决法线缺失的问题。

很多时候,着色的细节要比模型的轮廓要重要,尤其是对于远景物件,以及本身就是平整的表面而言。
基于上述结论,我们应该在减面的过程中尽可能的保留法线:
- 在减面的时候给法线更大的权重
- 在最后的时候舍弃(最终结果不需要法线?)
- 可以提升最终的结果
- 对remesh没啥作用
关键的方式是对减面后的模型进行展开,从而使得修正后的法线逼近我们想要的法线。

展开算法基本介绍:
- 对具有相似法线的面片进行聚类
- 对每个cluster计算一个目标的平面
- 将目标平面跟对应的顶点关联起来(每个顶点最多关联三个平面)
- 计算能够满足所有目标平面的新的顶点

对偶模型其实是一个无向图:
- 每个对偶顶点都对应于原始模型的一个面
- 每个对偶边都对应于原始模型中的一对相邻面
- 通过对这个图进行遍历就能找到相连面
在实际执行算法的时候,会将对偶模型用edge-face邻接map来存储:每个原始模型的边会存储与之关联的面的list
这种做法在保留遍历的便捷性的同时,还能将流形的数据非常自然的存储下来。

基于法线进行聚类,分三步:
- 选择某个cluster的种子面,通常会基于面积来选取,取最大的
- 对前面的邻接map进行广度优先遍历,放弃那些法线差异大的面,找到法线相近的面,塞入cluster
- 重复上述步骤,直到所有的面都处理完成

法线的比对:
- 永远是跟种子face进行比对
- 减面算法,原始顶点的法线数据可以保留
- remesh算法则直接使用目标平面的法线

通常是基于面积加权来求得目标平面,当然这里也可以做一些复杂的计算。

最后,每个cluster只能得到一个目标平面,同时,目标平面不能直接移动,否则会导致面片的割裂,要想移动,就需要同时移动顶点,最后每个顶点最多跟三个面片关联起来。

得到目标平面后,接下来就要计算对应的顶点了,通常有如下三种情况:
- 顶点只跟一个平面相关,那就将原始顶点投影到新的平面上即可
- 顶点与两个平面关联,需要按照图中计算
- 顶点与三个平面关联,也是按照图中计算

完成展开后,之前的结果就变得好多了

接下来看看怎么解决remesh的inne shell问题。

先给出基本的思路:先对面片进行分类,分为内部跟外部两类
只使用模型的局部数据是不足够的,比如对于内凹的模型而言,我们不能根据法线来判断内部部,还需要一些全局数据,但数据要从哪里获取呢?
这里给出的方案是通过flood filling算法(对偶模型)来得到相关数据,最终的目标是对于一个相互连接的组件,生成一个连续的inner face set。

分类算法思想介绍:通过随机发射的射线来计算(离线):
- 先找到一些内部顶点:在俯视角下,在模型的bounding box范围内,通过蒙特卡洛采样算法得到
- 再来完成第一轮的face set的归类:对上一步得到的顶点,对每个face投射一条射线,并判断当前face是否可以看见对应的顶点
- 最终通过flood-fill完成修复:得到一套连续的inner face跟一套连续的outer face
下面对每一步做展开介绍。

内部顶点要怎么得到呢?
对于每个采样点,基于这个点向水平方向投射四条射线(不考虑上下),只要其中有三条射线跟面片相交,就认为这个顶点是内部的。
通过蒙特卡洛算法按照上面的思路来摸索出inner face的大致区域

再来看怎么获取到inner face set。
对每个interior point,会朝每个inner shell face发射射线,为了识别屋顶,还需要朝上方投射设下。
之后基于命中结果来判断inner face,这里得到的结果不用十分精确。

flood-fill算法的主要实现思路就是对dual mesh数据进行遍历,最终得到一个inner shell跟一个outer shell。

最后还可以通过一个数值来对结果进行确认,最终的inner shell的面积占比大约是50%。

再来看下怎么解决减面后的模型破裂问题。

这里有一个观察是,remesh得到的inner shell是能够嵌入到原始模型的内部的,且没有裂缝。

这里的思路就是:
- 先减面
- 再remesh
- 将remesh结果叠加到减面结果上来修复裂缝

这里对上面思路的细节做了一些补充。

不过remesh的inner shell可能不一定存在,因为原始mesh可能是封闭的,这种情况需要做一下退化处理:
- 将remesh的结果沿着法线做一下收缩
- 对反面进行翻转
退化方案是有一些瑕疵的,因此优先backstop方案。

backstop方案对于一些非建筑的模型,就不太适用了,比如桥梁等模型。

这里给了一个算法来检测对应的模型是否适用于backstop方案。

再来看下bonus问题怎么解决,即实现法线贴图的自动检测。

整个城市有接近10k个大型物件,少数物件需要弯曲效果(需要法线数据),而人力的调整就非常低效了。

这里给了一个新的离散曲度的定义。

并基于上述定义来计算面片与cluster的曲度。

这里给出一个判断表面是否为曲面的算法:
- 对每个面片,计算其曲度跟视觉重要性
- 对dual mesh进行遍历,找到在一定曲度范围内的cluster face
- 对每个cluster,计算一些相关的其他属性
- 找到最重要的cluster,如果重要性高于阈值,就认为需要一个法线贴图。





最后将所有的环节放到构建机上


远景LOD的Streaming细节:
- 低精度的LOD是常驻的
- 145m以内需要相对高精度的模型,这些数据会走streaming
- RT BVH数据也同样需要Streaming

一些性能上的优化方案:
- 远景的大部分建筑基本上可以通过一个shader完成(类似于HLOD?)
- 通过DrawIndexIndirectMulti接口实现多个(类)建筑的一次性渲染
- 这个场景在4k下只需要1.4ms,1080P则只需要1ms(PS5)

蜘蛛侠中由于有Goo(一种粘稠物质)的存在,所以会破坏一个shader覆盖全场景的远景的预想。

建筑的窗户需要特殊的构造方式:
- 材质特殊
- 需要RT反射
- 需要RT的interior细节
- 其他的一些特殊渲染效果
这里的做法是将窗户的模型跟建筑分开来处理:
- 窗户使用最低级的常规LOD
- 材质的渲染距离会相对远一点,最少到600m,对于尺寸较大的建筑,这个距离还会更远
- 窗户的渲染消耗可能高过建筑本身,在4k上会去到2~5ms。










LOD的切换分为两类,一类是从常规模型的最低级LOD到远景模型的最高级LOD,第二种是远景模型内部的LOD切换。
常规模型到远景LOD,采用的是已有的算法
- 远景LOD是常驻的,需要先绘制
- 之后通过dither的方式添加常规模型的LOD
- 通过stencil来控制两者的显隐比例
远景LOD内部的切换,则是在shader上完成

这里对小物件做一下专门介绍:
- 部分小物件需要较远的视距,比如树木等
- 这部分小物件也不适合前面的远景物件生成算法
- 最终通过一套完全独立的方案来覆盖:
3.1 这类需要远景的小物件需要人工标注
3.2 在场景中是常驻的
3.3 渲染距离相对较远
3.4 通过extrem instancing方案来提升性能

网友评论