UE5.8 PCG Surface Sampler、Point Sampler 与 Mesh Spawner 实战

📑 目录

老周最近在折腾一个新关卡。项目设定是一片山谷,玩家从林间小道进入,穿过开满野花的缓坡,最后到达河滩。美术同学给了几组资产:几簇草丛、三种小花、几块风化岩石,还有岩石旁边散落的小石子。关卡尺寸不小,手工摆放显然不现实,老周决定用 UE5.8 的 PCG 管线把这套东西串起来。

他面对的问题很具体。草地要铺满整片山坡,但不能长在水里,也不能长在坡度太陡的崖壁上。小径两侧要有野花,花要沿着样条线分布,越靠近路中心越稀疏,越往外越密。岩石旁边要长出小石子,石子的朝向得贴着地面,密度随距离衰减。这些需求拆开看,恰好对应 PCG 里三个最常用的节点:Surface Sampler、Point Sampler 和 Mesh Spawner。

下面我把整个搭建过程拆开讲。为了方便理解,我会用同一套山谷场景做例子。

一、Surface Sampler:从地形表面长出来的点

Surface Sampler 的输入通常是一块 Static Mesh 或者 Landscape。它会在这个表面上按面积均匀撒点,输出一堆带有位置、法线、切线等属性的点。这些点本身还看不见,只是后续生成实例的候选位置。

老周的第一步就是在整个 Landscape 上撒一层基础点。他把 Landscape 拖到 PCG 图表里,连到 Surface Sampler。Sampler 的 Density 参数直接决定每平方米的点数。这个数值不是越大越好,后面我们会算一笔账。

flowchart TD
    landscape --> surface_sampler
    surface_sampler --> point_filter
    point_filter --> mesh_spawner
    mesh_spawner --> output

这张架构图就是老周第一阶段想做的事情。Landscape 进去,Surface Sampler 撒点,Point Filter 把水里和陡坡上的点剔掉,Mesh Spawner 把草实例化出来。

Surface Sampler 最核心的输出属性是 Normal。草贴不贴地、花歪不歪,全看这个法线。UE5.8 里这个节点还加了 Slope Angle 过滤,可以直接在采样阶段就把太陡的面去掉,省得后面再写表达式。老周把 Max Slope Angle 设成 45 度,水面以下的部分用 Height Filter 去掉。

采样密度这块,老周算了算。整片地形大约 2 平方公里,也就是 2×1062 \times 10^6 平方米。如果每平方米撒 1 个点,总点数就是 200 万。草类资产用 HISM,200 万个实例在高端机上能跑,但中端机就危险了。所以他先设成每平方米 0.4 个点,后续再用 Point Sampler 做局部加密。

这里有个容易踩坑的地方。Surface Sampler 的 Density 是“每平方米期望点数”,但实际输出会受随机抖动影响。如果想让分布更规则,可以把 Distribution Type 从 Random 改成 Poisson Disk,避免点挤在一起。Poisson Disk 的半径 rr 和期望密度 λ\lambda 满足近似关系:

r0.55λr \approx \frac{0.55}{\sqrt{\lambda}}

λ=0.4\lambda = 0.4 时,rr 大概 0.87 米。这个间距对草丛来说刚好,不会出现两簇草完全重叠的穿帮。

二、Point Sampler:在点云上继续做文章

Surface Sampler 解决的是“从哪里生”的问题。Point Sampler 解决的是“怎么选”的问题。它接收一组点,然后按规则筛选、加密或稀疏化。

老周的第二个需求是沿小径种花。他先在场景里画了一条 Spline,作为小路的中线。Spline 本身不是面,所以先用 Spline Sampler 沿着线生成一系列点,再把这些点喂给 Point Sampler 做径向扩展。

flowchart TD
    A["Spline 路径"] --> B["Spline Sampler 沿线取点"]
    B --> C["Point Sampler 径向扰动"]
    C --> D["按距离衰减密度"]
    D --> E["Mesh Spawner 生成野花实例"]
    E --> F["输出到 HISM"]

这个流程图就是小径野花的逻辑。Point Sampler 在这里干了两件事:一是把线上的点向两侧偏移,二是根据偏移距离调整最终保留概率。

Point Sampler 里有个参数叫 Distribution Type。老周用的是 Grid Jitter:先在局部网格里均匀布点,再对每个点加一个随机偏移。这样既能保证覆盖度,又不会显得太机械。Jitter 的幅度他设成网格边长的 0.6 倍,太大会出现明显空档,太小又像棋盘格。

密度衰减他用的是 Attribute Filter 配合 Distance to Spline 这个属性。PCG 里每个点都可以携带自定义属性,Distance 就是其中一个。表达式很简单:

Pkeep=max(0,1ddmax)P_{keep} = \max(0, 1 - \frac{d}{d_{max}})

dd 是点到样条线的距离,dmaxd_{max} 是老周想种花的最大范围,比如 3 米。靠近路中心的位置 PkeepP_{keep} 接近 0,点被大量剔除;3 米外全部不要。这样自然形成一条花边带。

sequenceDiagram
    participant S as "Spline Actor"
    participant SS as "Spline Sampler"
    participant PS as "Point Sampler"
    participant AF as "Attribute Filter"
    participant MS as "Mesh Spawner"
    S->>SS: 提供曲线几何
    SS->>PS: 输出线上点 + Distance 属性
    PS->>PS: 径向偏移 + 抖动
    PS->>AF: 按 Distance 过滤
    AF->>MS: 保留符合密度衰减的点
    MS->>MS: 生成野花 HISM 实例

这段序列图展示了数据流。Spline 只负责提供几何,真正的密度控制交给 Point Sampler 和 Attribute Filter。老周一开始把密度衰减直接写在 Spline Sampler 里,结果想复用到另一条路的时候得重连一堆线。后来他统一把衰减逻辑抽出来,做成一个子图表,换场景直接拖进去用。

三、Mesh Spawner:把点变成看得见的东西

Mesh Spawner 是 PCG 管线的末端。它接收点,输出 Static Mesh 实例。UE5.8 默认走 Hierarchical Instanced Static Mesh,也就是 HISM,同一 meshes 的实例会被合并绘制。

老周把前面两层点分别连到不同的 Mesh Spawner。草地一个,野花一个,小石子一个。每个 Spawner 可以配置多个 Mesh Entry,每个 Entry 对应一种静态网格和权重。

graph LR
    A["Surface Sampler 地形点"] --> B["Mesh Spawner: 草地"]
    C["Spline + Point Sampler 花边点"] --> D["Mesh Spawner: 野花"]
    E["Mesh Filter 岩石周围点"] --> F["Mesh Spawner: 小石子"]
    B --> G["HISM Grass Cluster"]
    D --> H["HISM Wildflower"]
    F --> I["HISM Small Rocks"]

这个依赖图说明三种生成物是并行的,互不阻塞。老周一开始把所有东西塞进一个 Mesh Spawner,后来发现调试很乱。分开之后,草地调密度不会影响花,花调权重也不会影响石子。

Mesh Spawner 里有一个选项叫 Apply Local Normal。勾选之后,实例的 Up Vector 会对齐点的法线。草丛用这个很合适,坡上的草会顺着坡长。但老周发现岩石旁的小石子不能无脑勾这个选项。小石子是扁平的,如果完全贴地,在陡坡上会立不住。他单独给小石子加了一个 Random Rotation,只在 Roll 和 Pitch 上给 15 度以内的偏移,Yaw 保持 0 到 360 度随机。这样石子既贴地,又有一点自然的翻滚感。

每个 Mesh Entry 还有 Min/Max Scale。老周设草丛的 Scale 在 0.8 到 1.3 之间,野花在 0.9 到 1.1 之间。差距不能太大,不然同一张贴图在不同尺度下会露馅。小草和大草应该拆成两个 Entry,而不是靠极端缩放硬撑。

四、密度控制与对齐法线

密度控制是老周花最多时间调的部分。PCG 的密度不是单一参数,而是一整条链路:采样密度、过滤概率、距离衰减、生物群落遮罩,全部相乘之后才是最终实例数。

他做了一个简单的汇总公式。假设 Surface Sampler 输出的基础密度是 λ0\lambda_0,Slope Filter 保留概率是 PsP_s,Height Filter 保留概率是 PhP_h,Biome Mask 权重是 wbw_b,那么最终期望密度 λfinal\lambda_{final} 可以写成:

λfinal=λ0PsPhwb\lambda_{final} = \lambda_0 \cdot P_s \cdot P_h \cdot w_b

这个公式很粗糙,因为每个 Filter 都可能不是简单概率,而是连续权重。但它能帮老周快速估算总量。比如基础密度 0.4,Slope Filter 保留 60%,Height Filter 保留 80%,Biome Mask 平均权重 0.5,那最终每平方米就是 0.4×0.6×0.8×0.5=0.0960.4 \times 0.6 \times 0.8 \times 0.5 = 0.096 个点。整片地形 200 万平方米,总实例数约 19 万。这个数字对 HISM 来说很轻松。

法线对齐是另一个关键点。Surface Sampler 输出的 Normal 是地形表面的法线。Mesh Spawner 的 Apply Local Normal 会把实例的 Z 轴转到这个法线方向。但在某些情况下,比如悬崖面,法线变化剧烈,草会出现明显弯曲。老周加了一个 Dot Product 检查,把法线和世界 Up Vector 的点积小于 0.7 的点直接剔除。也就是说,坡度超过 45 度的地方不种草。

他对齐法线的做法是:先用 Surface Sampler 拿到 Normal,再用 Transform Points 节点把实例的 Rotation 重新计算一遍。Rotate from Vector 节点可以把 Up Vector 从 (0,0,1)(0,0,1) 转到当前法线 n\vec{n}。这个变换矩阵 RR 满足:

R(001)=nR \cdot \begin{pmatrix} 0 \\ 0 \\ 1 \end{pmatrix} = \vec{n}

UE5.8 的 PCG 已经把这个封装在 Mesh Spawner 的 Apply Local Normal 里了,一般不需要手动算。但如果想在法线对齐的基础上再加一个随机倾斜,就得自己拼 Rotation 节点。

五、随机旋转与缩放

自然界没有两片完全一样的草。 Mesh Spawner 的 Random Rotation 和 Random Scale 就是用来打破重复的。老周的做法是:草地的 Yaw 给 0 到 360 度,Pitch 和 Roll 给 ±8 度;野花的 Yaw 给 0 到 360 度,Pitch 和 Roll 给 ±12 度,因为花茎更软;小石子的 Yaw 0 到 360,Pitch 和 Roll ±15 度。

缩放方面,他更喜欢用非均匀缩放。草的长度可以稍微拉长,宽度保持接近 1。比如 Scale 的 XYZ 分别给 (0.91.1,0.91.1,1.01.3)(0.9\sim1.1, 0.9\sim1.1, 1.0\sim1.3)。这样草会显得有高矮变化,但不会忽胖忽瘦。

有个细节老周踩过。他一开始把 Random Rotation 的三个轴都设成 0 到 360,结果草丛像被龙卷风扫过。后来他把 Pitch 和 Roll 限制在很小的范围,只让 Yaw 自由旋转,画面才稳下来。这个原则对大部分贴地植被都适用:Yaw 随便转,其他轴要克制。

六、生物群落规则

山谷不是均匀的一块。靠河的地方湿润,草更绿更密;向阳的坡地干燥,草发黄变矮;岩石区几乎没有草,只有碎石。老周用 Biome Mask 来控制这些差异。

他在地形材质里画了四张遮罩:River、Slope、Rock、Meadow。每张遮罩输出一个 0 到 1 的权重。PCG 图表里用 Get Surface Properties 或者 Sample Texture 节点读取这些权重,然后作为属性附加到点上。

flowchart LR
    A["地形材质 Biome Mask"] --> B["Get Surface Properties"]
    B --> C["属性: RiverWeight"]
    B --> D["属性: SlopeWeight"]
    B --> E["属性: RockWeight"]
    C --> F["密度乘数"]
    D --> G["草高缩放"]
    E --> H["替换为碎石"]

河流权重高的地方,草密度乘 1.5,草的 Scale Z 也乘 1.2,显得茂盛。岩石权重高的地方,草密度直接乘 0,同时启用另一个 Mesh Spawner 生成碎石。坡地权重高的地方,草的 Scale Y 压扁一点,模拟风干效果。

老周在这里用了一个小技巧。他没有把 Biome Mask 直接连到 Density,而是先把它存成点的属性,再用 Attribute Filter 做乘法。这样同一个 Mask 可以驱动多个不同的 Mesh Spawner,维护起来方便。比如 RiverWeight 同时影响草的密度和花的密度,但两者的系数不同。

七、与 Landscape Grass Type 的集成

UE 本身的 Landscape Grass Type 也是在地形上撒草,但它更轻量,走的是 Grass System,支持 GPU Instance Culling。PCG 生成的是 HISM,功能更灵活,但开销也更大。老周的选择是:远景、大面积、不需要复杂规则的地方用 Landscape Grass Type;近景、小径、岩石周围需要精细控制的地方用 PCG。

PCG 可以把结果输出到 Landscape Grass Type。做法是在 PCG 图表末尾加一个 To Grass Type 节点,把点转成 Grass Output。这样 PCG 负责控制生成规则,Grass System 负责高效渲染。

老周的实际做法是混合管线。山坡大面积草地用 Landscape Grass Type,由地形材质的 Grass Layer 自动驱动。小径两侧的花、岩石旁的石子、河边加密的小草则用 PCG Mesh Spawner。两者在镜头里看起来是一体的,玩家分辨不出边界。

要把 PCG 输出到 Grass Type,需要注意一点:Grass Type 只认位置,不认 Rotation 和 Scale 的复杂随机。如果你的资产需要精细朝向,还是得用 HISM。老周的小石子就是 HISM,因为石子必须贴地且随机翻滚,Grass Type 做不到。

八、性能考虑

老周的关卡目标平台是中高端 PC。他把性能预算拆成三块:草地、野花、小石子。每块都有明确的实例数上限。

HISM 的绘制调用很低,但内存和视锥剔除压力依然存在。实例太多时,遍历 HISM 的 Instance List 也会变慢。老周用了几个办法:

  1. Cull Distance。每个 Mesh Spawner 都设了 Max Draw Distance。草丛 3000 米,野花 800 米,小石子 150 米。太远的东西看不见,没必要生成。
  2. LOD。草和花都做了三级 LOD,远处用面数极低的 Billboards。
  3. 分块加载。整个山谷切成 500 米 × 500 米的 Cell,PCG 图表用 World Partition 配合 Streaming。玩家走到哪,才生成哪。
  4. 实例上限。老周在图表里用 Clamp 节点把每类实例总数限制住。草地总实例不超过 80 万,野花不超过 20 万,小石子不超过 10 万。

代码层面,他写了一个简单的 Python 脚本在生成后统计实例数:

import unreal

actors = unreal.EditorLevelLibrary.get_all_level_actors()
for actor in actors:
    if actor.get_class().get_name() == 'HierarchicalInstancedStaticMeshComponent':
        count = actor.get_instance_count()
        print(f"{actor.get_name()}: {count} instances")

这段代码不超过 50 行,跑一遍就能知道各类实例数量有没有超预算。老周把它放进了项目工具菜单,每次调完 PCG 参数就点一下。

性能优化的核心就一句话:别让看不见的东西消耗资源。PCG 的强大在于它能生成海量细节,但关卡设计师得主动告诉它哪些细节值得保留。

另一个容易忽略的开销是属性计算。PCG 每个点都带一堆属性,比如 Position、Normal、Color、Density、Distance、BiomeWeight。属性越多,节点之间的数据拷贝越重。老周的原则是:能不用属性就不用属性,非用不可的时候尽早剔除不需要的点。Point Filter 放在 Attribute 计算前面,能减少后续节点的计算量。

他还建议把 PCG 图表拆成多个子图表。草地、野花、石子各自一个子图表,主图表只负责把 Landscape 和 Spline 分发给它们。这样改一个子图表不会触发其他子图表的重新生成,编译速度快很多。项目后期老周的草地子图表调了几十版,野花和石子基本没受影响。他把最常用的参数暴露成 Public Variables,策划可以直接在 Details 面板里改密度和范围,不用进图表。

调试与反复调整

老周调 PCG 时有一套自己的流程。他先在世界大纲里创建一个空的 PCG Actor,把图表挂上去,然后放一个 Preview 相机在玩家路径上。每次改完参数,他不急着跑全图,而是框选一片 100 米 × 100 米的区域做局部生成。UE5.8 的 PCG Component 支持按 Bounds 生成,这对反复调整很关键。

他发现一个问题:Surface Sampler 的点在地形接缝处偶尔会沉到地下。原因是 Landscape 的 LOD 在远处简化,Surface Sampler 采到的法线和高精度编辑时略有偏差。解决办法是把 Sample Mesh 的 LOD Level 锁到 0,或者干脆用一张手工烘焙的高度图做采样。老周选了后者,因为山谷地形后续还会调整,烘焙一次能管很久。

调试 Point Sampler 时,他习惯先关闭 Mesh Spawner,只看点的分布。PCG 图表里可以临时加一个 Debug Render 节点,把点画成彩色小球。颜色按 Distance 属性映射,离样条线近的是红色,远的是蓝色。老周一眼就能看出衰减曲线是否顺滑。如果衰减太陡,花边会像刀切一样整齐;太缓,花又会漫到路中间。

他还遇到过 Mesh Spawner 的实例朝向错乱。勾选 Apply Local Normal 后,草在平地正常,但在陡坡出现 90 度翻转。原因是某些点的法线被 Normalize 后方向刚好和世界 Z 轴垂直,Rotate from Vector 的轴对齐在这种情况下不稳定。老周的修复办法是先用法线和世界 Up 做点积,小于 0.2 的点直接丢弃。这个阈值比 Slope Filter 更严格,专门处理那些法线突变的异常点。

结语

老周的山谷关卡最后跑在了目标帧率上。草铺满缓坡,花沿着小径自然分布,岩石旁散落着小石子。整个场景没有一处是手工摆的,但看起来并不机械。

Surface Sampler、Point Sampler 和 Mesh Spawner 这三件套,本质上是在回答三个问题:在哪里生、选哪些点、长什么东西。把它们拆清楚,再用 Biome Mask 和 Filter 做层次控制,就能搭出一套可维护的程序化生成管线。UE5.8 在这些节点上做了不少易用性改进,比如 Slope Angle 过滤、Apply Local Normal、更稳定的 Poisson Disk,省了不少过去需要手写的表达式。

如果你也在做类似需求,我的建议是先从小块区域开始。别一上来就铺满整张地图,先调好一个 50 米 × 50 米的测试区,确认密度、法线、随机旋转都顺眼之后,再扩大范围。PCG 参数一旦失控,实例数会指数级上涨,早发现早省钱。老周后来把这套流程写进了项目 wiki,新人接手时不用再从头踩一遍坑。这套笔记现在成了组里 PCG 入门的必读材料。