UE5.8 Nanite 代理、碰撞与位移:别让视觉盛宴变成物理噩梦

📑 目录

UE5.8 Nanite 代理、碰撞与位移:别让视觉盛宴变成物理噩梦

引子:老周的悬崖,和那个掉下去的玩家

老周是美术组长。上个月他拿着一组 Photogrammetry 扫描的悬崖资产来找我,脸上带着一种「我终于不用再雕 LOD」的释然。

那块悬崖源网格有两百三十万三角形。启用 Nanite 之后,远景是粗糙石块,近景能看到岩层纹理和细小裂缝。站在关卡里看,确实震撼。策划在旁边直点头:「玩家会沿着这条栈道走,到这里会触发剧情。」

两天后 QA 发了一张截图。角色站在栈道边缘,脚底下是空的,整个人正在做自由落体。

老周一脸懵:「我模型明明有地面啊。」

我打开 Static Mesh Editor,指着 Collision 栏说:「模型是有了,可物理引擎看不到。Nanite 渲染的是一套数据,碰撞用的是另一套。你只开了 Nanite,没给物理留门。」

这就是本文想聊的事。Nanite 解放了美术,但它在物理世界里是隐形的。要把视觉上的壮观变成玩家脚下的实地,中间隔着代理网格、碰撞体、复杂度设置、通道配置、物理资产、NavMesh,还有位移与顶点动画的一堆限制。


一、为什么 Nanite 网格不能直接当碰撞用

Nanite 的核心价值是「按需渲染三角形集群」。导入时,源网格被切分成最多 128 个三角形一组的 cluster,再构建成层级 DAG。运行时 GPU 根据屏幕大小和遮挡关系,决定画哪些 cluster、画多细。

这套机制对渲染极好,对物理却是灾难。

物理引擎需要稳定、连续、可查询的几何。它要回答的问题很简单:一个点在哪一侧?两个凸包是否相交?一条射线打到了哪个面?这些问题要求碰撞几何在内存里是完整的,边界是准确的,不会因为相机拉远就突然少掉一半三角形。

Nanite 的数据会流送、会简化、会按视角变化。同一个岩石,远看可能只有几百个三角形,近看是两百万。物理引擎不能容忍这种不确定性。所以它干脆不用 Nanite 数据做碰撞。

flowchart TD
    A["源网格 Source Mesh"] --> B{启用 Nanite}
    B --> C["Nanite Cluster DAG"]
    C --> D["GPU VisBuffer 渲染"]
    B --> E["保留源网格副本"]
    E --> F["生成碰撞网格"]
    F --> G["PhysX / Chaos 物理求解"]

上图里最关键的一点是:源网格被分成了两条路。一条进 Nanite 管线,用于画面;另一条进物理管线,用于碰撞、射线、NavMesh。两条路起点相同,但之后各走各的。渲染再漂亮,也不会自动反馈到物理。

我在团队里经常说一句话:Nanite 解决的是「看得见」的问题,物理解决的是「摸得着」的问题。两码事。


二、代理网格:Nanite 的「替身」

除了碰撞,还有一些系统也不直接消费 Nanite 数据。比如某些动态阴影路径、距离场、HLOD 代理、Lightmap(Nanite 本身不支持 lightmap,但 fallback 可以参与部分烘焙流程)。这些场景下,引擎会用到 Nanite 的 fallback mesh,也叫 proxy mesh,中文常叫代理网格。

代理网格是源网格的一个低精度版本。导入 Nanite 时,引擎默认会自动生成一个 fallback。你可以在 Static Mesh Editor 的 Nanite Settings 里调整:

  • Fallback Target:Auto / Triangle Percent / Relative Error;
  • Fallback Triangle Percent:保留百分之多少的源三角形;
  • Fallback Relative Error:允许的最大相对误差。

代理网格不是给物理用的。它的主要任务是当 Nanite 数据不可用、不支持或被显式禁用时,提供一个能画的网格。比如你在某个 Actor 上勾了 Disallow Nanite,或者目标平台不支持,就会回退到代理网格。

flowchart TD
    subgraph source ["源资产"]
        src["Static Mesh"]
    end
    subgraph nanite ["Nanite 管线"]
        builder["Nanite Builder"]
        dag["Cluster DAG"]
        proxy["Fallback Proxy"]
    end
    subgraph physics ["物理管线"]
        col["Collision Mesh"]
        phys["Physics Engine"]
    end
    subgraph render ["渲染管线"]
        vis["VisBuffer BasePass"]
    end
    src --> builder
    builder --> dag
    builder --> proxy
    src --> col
    col --> phys
    dag --> vis

这张图把几条数据流画清楚了。代理网格和碰撞网格都从源网格派生,但各自为政。碰撞网格通常比代理网格更粗,因为物理不需要视觉细节,只需要一个合理的壳。

老周第一次看到这图时问:「那我把 fallback 精度调高点,能不能直接当碰撞用?」答案是最好别。fallback 关注的是「看起来像」,碰撞关注的是「相交对」。两者的生成目标不同,混在一起只会埋下隐患。


三、生成碰撞网格的三条路子

Static Mesh Editor 的 Collision 菜单提供了几种生成碰撞的方式:

3.1 自动凸包

Collision > Auto Convex Collision 会用 V-HACD 算法把网格拆成一组凸包。你可以控制最大凸包数量、凸包顶点和凹陷度。对于形状不规则的岩石,这是最快的方法。

缺点也明显:凸包数量多了物理开销大;数量少了细节丢失。一块两百三十万面的悬崖,如果直接 auto convex,可能要几十上百个凸包才能贴近形状,运行时并不便宜。

3.2 简单图元

Collision > Add Box / Sphere / Simplified Convex 适合规则物体。箱子、门板、石柱用 box 或胶囊最划算。组合几个简单图元,往往比一个复杂凸包性能更好。

3.3 从 DCC 导入 UCX

最可控的方式是在 Maya / Blender / 3ds Max 里命名碰撞体为 UCX_模型名,随 FBX 一起导入。这样美术可以精确控制哪些地方要碰撞、哪些地方要留空。英雄级岩石、建筑结构我通常推荐这条路线。

对于老周的悬崖,我们的最终方案是:

  • 栈道和主体地面用 DCC 做的简模作为复杂碰撞;
  • 突出的石块用十几个手动凸包;
  • 远处不可达的山体用 box 粗略包围。

四、Collision Complexity:四个档位怎么选

Static Mesh Editor 的 Collision 分类下有个 Collision Complexity 选项,默认是 Project Default。它决定物理引擎在查询时怎么选形状。四个档位的区别如下:

档位含义适用场景
Project Default按项目物理设置,简单查询走 simple,复杂查询走 complex大多数静态物体
Simple And Complex同时保留两种形状,simple 用于常规物理,complex 用于复杂查询需要精确射线检测的静态物体
Use Simple Collision As Complex复杂查询也走 simple 形状,忽略三角网格对精度要求低、想省内存的物体
Use Complex Collision As Simple简单查询也走 complex 三角网格需要精确地面、但不能做物理模拟

这里有个常见误解:选了 Use Complex Collision As Simple,物体就能像传统 mesh 一样被物理推着滚。错了。复杂碰撞是三角网格,PhysX 不支持用三角网格做刚体模拟。你可以站在上面、可以射线打到它,但不能让它受重力影响翻滚。

简单碰撞和复杂碰撞的查询代价可以粗略写成:

Cquerykbroad+kshapeNconvex+ktriNtriangleC_{query} \approx k_{broad} + k_{shape} \cdot N_{convex} + k_{tri} \cdot N_{triangle}

NconvexN_{convex} 是简单碰撞的凸包数量,NtriangleN_{triangle} 是复杂碰撞的三角形数量。查询越频繁,kshapek_{shape}ktrik_{tri} 的影响越大。把两百三十万面的悬崖直接设成 Use Complex Collision As Simple,射线检测确实准,但每个 query 都要扫大量三角形,得不偿失。

我的习惯是:能走 simple 就不走 complex;必须走 complex 的地方,确保三角形数量经过控制,或者只在静态环境 ground 上用。


五、碰撞通道与 Physics Asset

5.1 自定义通道

项目里默认的通道(WorldStatic、WorldDynamic、Pawn、PhysicsBody 等)常常不够用。比如你需要一个只检测「可行走地面」的射线,而不想被装饰物、植被触发器干扰。

Project Settings > Engine - Collision 添加自定义 Object Channel 和 Trace Channel。假设新增一个 GroundTrace

#define ECC_GroundTrace ECC_GameTraceChannel1

UStaticMeshComponent* GroundMesh = Cast<UStaticMeshComponent>(...);
GroundMesh->SetCollisionProfileName(TEXT("BlockAllDynamic"));
GroundMesh->SetCollisionObjectType(ECC_WorldStatic);
GroundMesh->SetCollisionResponseToChannel(ECC_Pawn, ECR_Block);
GroundMesh->SetCollisionResponseToChannel(ECC_GroundTrace, ECR_Block);

FHitResult Hit;
FVector Start = GetActorLocation();
FVector End = Start + FVector(0.0f, 0.0f, -10000.0f);
FCollisionQueryParams Params(SCENE_QUERY_STAT(GroundTrace), false, this);
GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_GroundTrace, Params);

这段代码做两件事:一是把地面静态网格设置为只对 Pawn 和自定义 GroundTrace 通道产生 Block 响应;二是用 LineTraceSingleByChannel 向下打一条长射线,专门用来判断脚下是否有地。

通道设计要在项目早期定好。拖到后期再改,已放置的几千个 Actor 都要重新调预设,非常痛苦。

5.2 Physics Asset

角色和可动物体通常用 Skeletal Mesh,它们的碰撞由 Physics Asset 管理。Physic Body 一般用胶囊、球体、凸包组合,再加约束(Constraint)。

有个坑要避开:别给角色的 Physics Asset 用复杂碰撞。身体四肢用胶囊近似就行。我曾经见过一个项目给角色穿上扫描衣服的三角网格当物理碰撞,结果角色倒地时 PhysX 直接卡成 PPT。三角网格不能参与刚体模拟,这是物理引擎的硬限制。


六、位移与顶点动画:Nanite 的短板

Nanite 可以移动、旋转、缩放,甚至做非均匀缩放。但它不支持一般性的网格形变。

6.1 World Position Offset 能用,但有限制

材质里的 World Position Offset(WPO)在 Nanite 上是受支持的,但行为和传统网格不同。Nanite 会把使用 WPO 的 mesh 拆成更小的 cluster,每个 cluster 都有自己的 bounds,然后独立做剔除。

这意味着如果 WPO 位移幅度太大,cluster 的 bounds 会被撑开,远处看可能出现异常剔除,或者像素突然消失。 foliage 比较宽容,因为叶子本身有很多洞,自遮挡少。但一块实心的岩石如果被 WPO 顶出去半米,玩家就会看到石头边缘「缺一块」。

sequenceDiagram
    participant M as "材质 WPO / Displacement"
    participant N as "Nanite Cluster Builder"
    participant B as "Cluster Bounds"
    participant H as "HZB 遮挡剔除"
    M->>N: 提交最大偏移幅度
    N->>B: 扩展每个 Cluster 的 Bounds
    B->>H: 参与视锥与遮挡剔除
    H->>H: 位移过大时边界外像素被错误剔除

解决思路只有一个:把位移限制在可控范围内。别指望用 WPO 做大幅波动的水面或布料。

6.2 真正的顶点动画走不通

骨骼动画、Morph Target、Spline Mesh、逐实例顶点绘制,这些 Nanite 基本都不支持。如果你需要做水面、旗帜、藤蔓摆动、角色表情,请用传统网格,或者在 5.7+ 的实验性功能里谨慎尝试 Nanite Foliage / Nanite Skinning。

老周团队曾经想在 Nanite 岩石上做点「风化剥落」的小动画,用了 WPO 驱动顶点抖动。结果镜头拉远时岩石整片消失,因为 cluster bounds 被抖出视锥。最后改成在世界位置偏移里 clamp 偏移量,并且只在镜头很近时生效。


七、NavMesh:Nanite 不能替你走路

AI 能不能走,取决于 NavMesh。NavMesh 的生成依赖碰撞几何,而不是渲染几何。一个 Nanite 岩石再精致,如果没有碰撞,Recast 也不会在上面生成可行走区域。

对于 Nanite Landscape,情况稍微复杂。启用 Nanite 的地形依然保留了一份非 Nanite 数据,Recast 用这份数据生成 NavMesh。地形碰撞精度由 Landscape 的 Collision Mip Level 控制,0 最精确,5 最粗糙。

graph LR
    A["Nanite Landscape"] --> B["非 Nanite 地形数据"]
    B --> C["Landscape Collision"]
    C --> D["Recast NavMesh"]
    A --> E["Nanite 渲染"]
    D --> F["AI Controller 寻路"]

开放世界项目要特别注意 NavMesh 的内存和更新开销。大场景全图烘焙 NavMesh 可能吃掉几百 MB,而且运行时动态更新非常贵。配合 World Partition,用数据分层和局部 NavMesh 流送,比一张大图要靠谱。

老周的悬崖关卡在启用了 Landscape Nanite 之后,AI 全部站在原地发呆。查了一下午,发现是 Landscape 的 Collision Mip Level 被美术顺手改成了 5,地形碰撞比视觉地形低太多,NavMesh 以为脚下是平地,实际角色已经悬空。改成 2 之后恢复正常。


八、Runtime Virtual Texture 位移:给地面加细节的另一条路

如果位移只是为了给地面增加凹凸细节,不一定非要在几何层面折腾。Runtime Virtual Texture(RVT)可以把一张 displacement 纹理动态投影到地形上,再在地形材质里采样,输出到 Displacement 节点。

基本流程是:

  1. 创建 RVT Texture,Virtual Texture Content 设为 Displacement;
  2. 在 Landscape 材质里添加 Runtime Virtual Texture Sample,读取 displacement;
  3. 把采样结果连到 MakeMaterialAttributes 的 Displacement 输入;
  4. 开启材质的 Enable Tessellation,用 Nanite Tessellation 把位移变成真实几何起伏;
  5. 绘制 Landscape Layer,RVT 会按区域更新。

DefaultEngine.ini 里需要先打开开关:

[/Script/Engine.RendererSettings]
r.Nanite.AllowTessellation=1
r.Nanite.Tessellation=1

这里有几个坑要避开:

  • Nanite Landscape 和非 Nanite 数据会同时存在内存里,数据量翻倍;
  • RVT 采样依赖非 Nanite 地形数据,水渲染、Runtime Virtual Texture 等系统仍然要走老管线;
  • 重新绘制地形时如果 Nanite 正在重建,有概率崩溃。稳妥做法是画 layer 前临时关闭 Nanite,画完再重建。

RVT 位移的好处是:细节来自纹理,不需要把每块石头都雕进地形网格。坏处是:它只影响视觉和简单碰撞(如果 complex collision 用得细),对简单碰撞图元没有影响。所以栈道、台阶这些玩家必须精确站立的地方,还是要靠碰撞体解决。


九、团队故事:裂谷关卡的三天调试

老周的悬崖属于一个更大的裂谷关卡。我们花了三天才把物理和视觉对齐。

第一天,美术把两百三十万面的悬崖导进来,开了 Nanite,没管碰撞。玩家走到栈道尽头直接穿模坠落。QA 报了十七个穿模 bug。

第二天,程序用 Auto Convex Collision 生成了四十多个凸包。角色不掉下去了,但在凸包缝隙处会卡住,镜头也会轻微抖动。profiler 显示物理查询时间从 0.8ms 涨到 2.4ms。

第三天,我们重新分工:

  • 栈道和主体地面保留一份低模,作为复杂碰撞源;
  • 英雄级岩石用手动 UCX 凸包;
  • 远景山体用简单 box;
  • 碰撞复杂度统一用 Simple And Complex;
  • 新增 GroundTrace 通道,专门用于角色脚下检测;
  • Landscape Nanite 的 Collision Mip Level 固定为 2;
  • 地面细碎凹凸用 RVT displacement 表现,不再雕进地形。

改完之后,物理查询时间回到 0.9ms,穿模 bug 清零。老周看着屏幕说:「原来 Nanite 只负责好看,走路还得靠这些丑盒子。」

这句话我记了很久。


十、实操检查清单

给程序:

  • 所有 Nanite 静态网格都要检查是否有碰撞;
  • 根据物体用途选择 Collision Complexity;
  • 自定义 Trace Channel,避免所有射线都走 BlockAll;
  • 角色 Physics Asset 只用简单图元,禁用复杂碰撞;
  • stat physicsstat collision 监控开销;
  • 大场景规划 NavMesh 分区和流送。

给美术:

  • 英雄级物体尽量在 DCC 里做 UCX 碰撞体;
  • 复杂地面如需精确行走,提供一份低精度碰撞源 mesh;
  • 别让 Landscape Collision Mip Level 和视觉地形脱节;
  • 用 RVT displacement 表现细碎凹凸,但别指望它修正碰撞;
  • 大幅 WPO / 顶点动画请交给传统网格。

十一、几个容易踩的小坑

最后列几个我在项目里见过不止一次的低级错误。

开了 Nanite,忘了看碰撞。 美术导入高模后点一下 Enable Nanite,以为万事大吉。测试时角色穿墙,才发现 Static Mesh Editor 里 Collision 栏空空如也。建议团队定一条规则:导入 Nanite 资产后必须先做一次碰撞检查,再提交版本控制。

把复杂碰撞当成万能药。 有些同学遇到穿模就顺手改成 Use Complex Collision As Simple。短期问题消失,长期性能爆炸。复杂碰撞的查询开销随三角形数线性涨,只该用在真正需要精确形状的地方。

通道响应默认全 Block。 默认预设 BlockAll 很方便,但会让不必要的物体参与射线检测。自定义通道初期多花十分钟,后期能省下几个小时调试。

Landscape Nanite 和 Collision Mip Level 不匹配。 这是老周踩过的坑。视觉地形很陡,碰撞地形很平,AI 和玩家都会以为脚下有地。启用 Landscape Nanite 后,一定要打开 Player Collision 视图核对。

RVT displacement 和 Nanite 重建冲突。 边画 layer 边重建 Nanite,编辑器可能直接崩溃。养成习惯:大改地形前临时关掉 Nanite,改完再重建。


写在最后

Nanite 改变了美术做资产的方式,但它没有取消物理规则。渲染管线和物理管线仍然是两个世界,中间需要开发者手动搭桥。

这篇文章里反复出现的几个关键词,代理网格、碰撞网格、Collision Complexity、通道、Physics Asset、NavMesh、RVT,其实就是那座桥。桥搭得好,玩家既能享受两百万面岩石的视觉冲击,也能稳稳地站在上面,并感受到真实的空间层次。

桥搭不好,画面再震撼,也只是一个好看的空壳。

老周现在每个 Nanite 资产导入后都会先问一句:「碰撞做了吗?复杂碰撞还是简单碰撞?要走哪个通道?」这些问题听起来很程序,但美术越早参与,后面返工越少。

工具再聪明,也替代不了判断力。希望老周那三天的调试,能让你少花两天。


参考与延伸阅读