如何优化 AI 生成的 3D 场景,让它在游戏里跑得动
生成的场景和手工搭建的场景,卡顿的原因不一样。先测量,再依次处理材质、实例化、LOD、贴图和剔除。
一个智能体花十一分钟搭出来的街区,常常在一台能以 90 帧跑商业开放世界游戏的机器上只有 20 帧。人的第一反应是删三角面。这几乎总是错误的第一步:生成的场景很少卡在三角面上,它们卡在渲染器不得不停下来切换状态的次数上。
生成场景有自己的失败方式
无论这片场景是怎么来的——基于素材库的城市生成器、程序化工具,还是像 Cuberta 这样由智能体驱动真实编辑器、导出带纹理的 GLB 或 FBX——导入进来的东西往往长成同一副样子:
- 几百个唯一材质,而关卡美术只会用十二个。
- 每个物件都是独立网格。两百四十根一模一样的路灯,进来是 240 个资产,而不是同一个网格的 240 个实例。
- 完全没有 LOD 链。同一个网格在 5 米和 300 米处渲染的是同一份数据。
- 纹理内存被平均分配。你永远走不到 40 米以内的屋脊上贴着 2048 的贴图,因为分辨率是按物件分配的,不是按屏幕尺寸分配的。
- 重叠和共面几何。道路标线正好落在路面平面上,路缘陷进人行道,墙体和邻居共用面。
- 没有任何遮挡结构。没有标记静态,没有指定遮挡体,室内没有房间体积。
这些都不是 bug。当软件优化的目标是「布局正确」而不是「怎样提交给 GPU」时,结果就长这样。每一条都有对应的解法,而且顺序很重要:第一个修复会改变本来用来论证第二个修复的那份测量数据。
先测量,再动手
用毫秒工作,不要用帧率:60 帧是 16.7 毫秒,120 帧是 8.3 毫秒;毫秒可以相减,帧率不行。然后看这几个计数器。
| 计数器 | 它衡量什么 | 生成场景的典型症状 |
|---|---|---|
| Batches / draw calls | CPU 每帧构建了多少次独立提交 | 几乎等于渲染器数量:完全没有发生批处理 |
| SetPass calls | 渲染状态、纹理和着色器被重新绑定的频率 | 与批次数相差不到几个百分点,跟着材质数量走 |
| 三角面 | 几何吞吐 | 桌面端通常没事,移动端通常就是它 |
| 纹理内存 | 贴图占用的常驻显存 | 以 GB 计:表现为卡顿和流送弹出,而非平均帧时间偏高 |
| 线程与 GPU 时间 | 谁在等谁 | 主线程吃满,GPU 闲着 |
到底谁在等谁
最快的诊断只需要改一个设置:把渲染分辨率减半。如果帧时间明显下降,说明你是 GPU 受限,因为你刚刚减少了像素。如果几乎没变,你就是 CPU 受限;对生成内容来说,这几乎总是意味着 draw call 的提交开销。性能分析器给出的答案更精确:在 Unity 里,主线程停在 Gfx.WaitForPresentOnGfxThread 是在等 GPU,渲染线程停在 Gfx.WaitForCommands 是在等主线程;在 Unreal 里,stat unit 把 Game、Draw 和 GPU 并排列出,三者中最大的那个就是你的预算。
顺序为什么重要
把材质合并掉,批次数可能下降一个数量级。原本看起来 CPU 受限的街区,现在变成在阴影深度上 GPU 受限,而你本来打算做的 LOD 工作,要么突然变成正确的工作,要么突然不需要了。做一项,再测一次,然后用新数据决定下一项。
材质、图集与实例化
批处理为什么依赖共享材质
draw call 贵,不是贵在它画东西,而是贵在它之前必须改变的那些东西:绑定新纹理、上传新的常量缓冲、有时还要把另一个着色器塞进管线状态。共用材质的两个物件可以合并成一次提交;材质不同的两个物件不行,无论网格多么一致。这就是为什么各个引擎的每一条批处理路径,前置条件里都写着「同一材质」。
有一个细节值得知道。Unity 的 SRP Batcher 是按着色器变体分组,而不是按材质:每个材质的属性被预先上传到常量缓冲里,所以一百个共用同一个 lit 着色器的材质仍然能批到一起。它省掉的是重建材质数据的 CPU 开销,省不掉纹理绑定,也省不掉纹理内存——需要控制在个位数的于是变成了着色器变体数量。Unreal 走的是另一条路,靠 instanced static mesh 和 Nanite 簇。引擎细节各有专文,Unity 那一侧可以看这篇。
怎样合并一座生成的小镇
按覆盖面积排序材质,不要按数量排序。调色板会迅速收敛:沥青、路缘、人行道、砖、抹灰、混凝土、玻璃、瓦、金属、油漆木、植被。十到十四组能覆盖一个住宅区。然后为每一组选方法:图集把不同的 albedo 烘到一张图上并重映射 UV,适合在屏幕上始终很小的唯一道具;平铺加 trim 用共享的重复材质替换唯一展开,再用一张 trim sheet 处理边缘,适合立面、道路和人行道。
道具用图集,建筑用平铺。表面一旦超过约四米,唯一纹素就不再负担得起:一面 12 米的立面按每米 512 像素计算,需要一张 6144 像素宽的贴图,没有人会为它买单。
最便宜的修复在上游。用一份包含十个具名材质的明确调色板重建一个街区,成本低于事后合并 300 个材质;而在一个你能实时看着它搭建、可以在视口里选中任何东西、可以撤销任意一步的编辑器里,这种修正只需要几秒。
实例化,以及什么才算合格
实例化用一次提交画出同一个网格的许多份副本,每份带自己的变换。条件很严格:同一个网格、同一个材质、以及为此编译的着色器。颜色不同只有在差异被搬进 per-instance 数据、而不是搬进第二个材质时才算合格。Unity 单批的上限是 1023 个实例,若着色器需要每实例的逆矩阵则是 511 个——统一缩放的 pragma 可以去掉它,间接绘制则完全解除上限。
生成的街区是很好的候选,因为生成器本来就在所有护柱、长椅和树上重复使用同一个源物件。真正毁掉它的是导出这一步把每一次摆放都写成独立网格,并把变换烘进顶点里。你的 240 根路灯到底是 240 个资产还是 240 个实例,往往是你能对一次导出提出的性价比最高的问题。
LOD、impostor,以及一个街区级的策略
你不会想手工写 400 条 LOD 链。把每个网格按「三角面数 × 实例数 × 典型屏幕覆盖」排序,让排序决定投入:前二十名做人工检查过的链,其余用自动两级减面,或者干脆不做。一个可用的起步策略:LOD0 完整精度,LOD1 约一半三角面,LOD2 约五分之一,再远就是 billboard 或什么都不画。切换点用屏幕相对尺寸而不是米来设——Unity 的 LOD Group 和 Unreal 的 LOD screen size 都是这么工作的——因为为 1080p、60 度视野调好的距离,在这两者任何一个改变时就不对了。
城市的外圈正是 impostor 值回票价的地方:一个烘好的 billboard 代表整个街区。Unreal 的 World Partition 会把它构建成分层 HLOD,最远的层被压成合并代理和 impostor 面片。UE5 里 Nanite 让不透明刚性几何的逐网格 LOD 问题基本消失,但它不是万能的掩护:masked 材质的开销接近其完整不透明面积,很小的网格会掉到像素阈值以下,而成千上万个微小 Nanite 网格的每实例开销是实打实的。它也不会替你减少材质数量。详见Unreal 那篇。
纹理预算是算术,不是品味
在玩家能靠到的最近距离上,一张贴图需要的纹素绝不会超过它在屏幕上覆盖的像素。超出的部分是你付了钱、而 mipmap 又扔掉的内存。
BC7 每纹素 8 位,一张 2048 见方的贴图是 4 MB,加上 mipmap 的 33% 大约 5.3 MB,BC1 再减半。假设一次导入给了你 180 个唯一材质,每个都有 albedo、法线和一张打包的粗糙度-金属度-遮蔽图,全是 2048:那就是 540 张贴图,大约 2.8 GB。合并到十四组就变成 42 张、约 220 MB,其中一半是屋顶和高层,可以降到 1024 而不会有人察觉。
给整个街区定一个纹素密度,让分辨率从表面面积推导出来。每米 512 像素是环境资产常见的标准,1024 留给镜头会贴近的表面。然后和屏幕对账:40 米外、屏幕高度上占 60 像素的物件,采样的大约是 2048 贴图的第 5 级 mip,也就是说最上面四级 mip 存在的意义就是被跳过。流送会减轻这个罪名,但包体大小、烘焙时间和首次进入时的卡顿仍然真实。
剔除,以及生成的室内为什么需要单元格
视锥与距离
视锥剔除自动运行、成本低廉,而当你站在一条笔直大道的尽头、整个街区都在你面前时,它什么也做不了——那恰好就是大家给生成城市截图时用的机位。距离剔除是最便宜的实打实的收益:Unreal 有 Cull Distance Volumes 把物件尺寸映射到剔除距离,Godot 在每个 geometry instance 上有 visibility range,Unity 有相机的分层剔除距离。垃圾桶、招牌和护柱可以在 40 到 80 米之间消失而没人注意,那是成千上万次提交。
遮挡
遮挡问的是更难的问题:什么被什么挡住了。Unreal 默认使用 GPU 遮挡——硬件遮挡查询,加上采样场景深度 mip 链的层级 Z-buffer 路径,后者刻意偏保守,用剔除更少换取更便宜的测试;对性能较弱的平台还有 precomputed visibility。Unity 用 Umbra 烘焙:离线把场景体素化,组织成单元格和门户,运行时查询一份低分辨率的深度层级。Godot 4 的遮挡剔除默认关闭,需要在渲染设置里打开,然后烘焙或手工摆放遮挡体。这三者都需要知道哪些几何是静态的、哪些是合格的遮挡体,而刚导入的场景两样标记都没有:把建筑、地形和道路标为静态,同时不让小道具进入遮挡体集合,这几分钟的工作能把整套系统激活。
室内需要单元格或门户
生成的室内会以一种很具体的方式失败:墙体是独立的盒子,而相邻的墙常常并没有真正合拢。一条玩家永远看不见的 2 厘米缝隙,对烘焙出来的遮挡体来说就是个洞:房间不再遮挡任何东西,只要你还在这个街区,整栋楼的家具就每帧都会被提交。为俯视图搭的房间还经常根本没有天花板,那是同一个问题的垂直版本。要么把外壳封死,要么干脆不管可见的墙体,直接放几个刻意比房间略大的盒形遮挡体,然后在每个门洞放一个门户,让关上的门剔除掉背后的一切。只在里面才看得到的室内,往往更适合做成单独流送的子关卡。
几何卫生:三种值得追猎的缺陷
永远不会被看到的面
用盒子生成,得到的就是封闭的盒子。八栋联排住宅会把十四个墙面埋在邻居里,地面平面在每栋建筑下方继续延伸,家具还有底面。这些三角面即使深度测试丢掉了它们产生的每一个像素,仍然要在 GPU 上做顶点变换;它们会撑大光照贴图的 UV 打包;还会把遮挡烘焙搞坏,因为你给烘焙器喂了藏在其他几何内部的几何。
共面表面
道路标线正好在路面平面上,地毯正好在地板高度上,海报正好在墙面深度上。两个处于同一深度的表面会随相机移动而闪烁,而且先在远处出现,因为那里的深度精度最稀薄。按优先级:把上层表面偏移 1 到 5 毫米;真正属于贴花的东西用引擎的贴花系统;depth bias 放最后,因为在掠射角下它可能把表面推穿墙体。顺手检查近裁剪面:两公里的城市配 0.01 米的近平面,会把远处的精度饿死。
没人要求的细节
一根屏幕上只占 12 像素的护柱,用了 64 段的圆柱。一面平墙因为生成器按网格工作而被切成网格。一个灯罩用了 1000 个三角面。一个三角面应该换来轮廓或者明暗渐变;两样都换不来,它就是纯粹的开销。护柱 8 到 12 边就够,墙只要两个三角面,除非它要形变或者用顶点光照。
按这个顺序执行
- 做一次性能分析,把数字写下来。把分辨率减半,判断是谁在等谁。
- 删掉看不见的东西——被埋住的面、建筑下方的地面、可玩范围之外的几何。
- 合并材质到一份具名调色板,并把着色器变体控制在个位数。
- 道具进图集,建筑改平铺,然后重新导出。
- 再测一次。瓶颈大概率已经转移,清单剩下的部分应该围绕它重排。
- 把所有重复物件实例化,并确认导出保住了实例化。
- 给排名前二十的网格加 LOD,外圈用 impostor 或 HLOD。
- 做一遍纹理分辨率梳理,依据是真实的屏幕覆盖,不是物件的「重要性」。
- 设置静态标记、遮挡体、距离剔除,以及室内的单元格或门户。
- 烘焙光照并做最后一次测量,在你打算支持的最弱硬件上。
移动端和 VR 的更紧数字
一体式 VR 会压缩每一项预算。Meta 对 Quest 设备的指引大约落在 50 到 150 次 draw call,下限 72 Hz,也就是 13.9 毫秒内要把场景渲染两遍,每只眼睛一遍。那里的三角面预算是几十万级而不是百万级,所以几何卫生不再是可选项。移动 GPU 是基于图块的,这改变了问题的形状:透明和重绘的代价高得不成比例,所以一个塞满 alpha 混合植被面片和玻璃立面的场景,会在三角面数量开始要紧之前很久就先垮掉。能转成 alpha test 或不透明的就转,玻璃改用带反射立方体贴图的不透明着色器,用 ASTC,并且在争论哪些贴图真的需要之前,先把所有分辨率减半。
这些都不华丽,也没有一样是 AI 专属的。专属的是失败画像:生成的场景在每一个时刻都有一个占主导的缺陷,通常是材质数量;按上面的顺序处理,意味着你的精力花在真正吃掉帧数的那个缺陷上,而不是最容易被看见的那个。