UE5.8 PCG Graph 与属性系统
美术同学李明上周接到了一个高山草地的关卡任务。策划的需求写得清楚:玩家行进的小路两侧要稀疏分布灌木与野花,远离主路径的缓坡覆盖中等密度的草甸,悬崖边缘点缀大小不一的岩石。场景会在任务推进过程中动态扩展,因此这些元素不能靠手工摆放。更麻烦的是,每次加载都要保持一致的分布,QA 复现 bug 时不能因为随机性而看到另一片地形。
UE5.8 的 PCG 框架正好能解决这类问题。李明没有直接在地形上刷植被,而是先搭建了一张 PCG Graph,把地形采样、点生成、属性计算、过滤、变换和实例化拆成可复用的节点。整个流程跑下来,他在编辑器里拖动几个参数,就能得到一片符合设计意图的野外环境。
传统做法是在地形上手工绘制植被层,然后靠实例化工具刷出来。可这张地图有十几平方公里,手工刷一遍要两三天,后续改一次密度又要重刷。PCG Graph 把规则写成节点,改参数后自动重算,几分钟内就能看到全局效果。李明花了半天时间搭好 Graph 原型,后面的调整都变成了拖动滑块。李明一开始也担心 Graph 学习曲线陡峭,但真做起来发现,只要把每个步骤拆清楚,剩下的就是连连看。
PCG Graph 的整体结构
PCG Graph 是一张节点图,每个节点执行一个具体动作。输入通常是 Surface Sampler 或 Spline Sampler 这类采样器,输出是 Static Mesh Spawner 这类实例化节点。中间夹着属性设置、变换、布尔运算和修剪节点。节点之间用边连接,边上传输两类数据:点的集合和附加在点上的属性表。
李明把 Graph 分成四层。第一层读取地形与样条线,得到初始采样点。第二层给这些点写入密度、大小、颜色等属性。第三层用属性做过滤与变换,决定哪些点保留、哪些点删除。第四层把处理后的点转成 Static Mesh Instance,送进场景。
flowchart TD
subgraph input ["输入层"]
terrain["Terrain Sampler"]
spline["Spline Sampler"]
end
subgraph attribute ["属性层"]
attr_set["Attribute Set"]
end
subgraph process ["处理层"]
transform["Transform Points"]
filter["Attribute Filter"]
prune["Self Pruning"]
end
subgraph output ["输出层"]
mesh["Static Mesh Spawner"]
end
terrain --> attr_set
spline --> attr_set
attr_set --> transform
transform --> filter
filter --> prune
prune --> meshGraph 的执行顺序由依赖关系决定。每个节点只有收到上游数据后才会计算。节点内部是延迟求值的,不会一次性生成整个世界再裁剪,而是按边界框和密度逐步展开。这种设计让大山场景也能在编辑器里实时预览。
每个节点的输入端口和输出端口都有类型。点是核心类型,一组点加上属性表构成 Point Data。除此之外还有 Surface、Spline、Volume 等空间数据类型。Surface Sampler 接收 Landscape 或静态网格表面,输出按面积均匀分布的点。Spline Sampler 沿着样条线输出点,常用于道路、河流两侧。把这两种采样器的结果合并,就能得到覆盖整片区域又沿路有变化的初始点集。
Attribute Set 与属性类型
属性是 PCG 的核心。每个点除了位置、旋转、缩放,还可以携带任意数量的自定义属性。Attribute Set 节点专门用来给点附加这些属性。李明在最开始的采样点之后立刻接了一个 Attribute Set,给每个点写入 Density、RockScale、GrassColor 三个属性。
UE5.8 支持的属性类型包括浮点、整数、布尔、向量、旋转体、字符串、枚举等。布尔属性适合做开关,比如 bIsNearPath;向量属性可以存颜色或法线;旋转体属性能直接驱动点的朝向。属性可以是标量,也可以是数组,但数组节点在 Graph 里的使用频率不如标量高。
flowchart TD
A["属性来源"] --> B["Attribute Set"]
C["采样器输出"] --> B
B --> D["Float: Density"]
B --> E["Int: TypeId"]
B --> F["Bool: bIsCliff"]
B --> G["Vector: Color"]
B --> H["Rotator: Orientation"]
D --> I["下游节点"]
E --> I
F --> I
G --> I
H --> I李明用浮点属性控制密度。密度值本身不是最终实例数量,而是概率权重。一个点的 Density 为 1.0 表示大概率保留,Density 为 0.0 表示几乎会被剔除。这个值通常由距离、坡度、高度混合得到。远离道路的地方密度高,陡峭的悬崖上密度低。
在编辑器里设置属性时,先选中 Attribute Set 节点,在 Details 面板里添加条目。每个条目有名字、类型和来源。来源可以是常量,也可以是上游传入的属性。李明习惯给属性名加前缀,比如 Gen_ 表示生成阶段计算的属性,Vis_ 表示只在可视化调试时用的属性。命名一致后,Graph 变大也不容易搞混。
属性还可以通过表达式计算。Attribute Set 支持常量、输入属性引用和简单数学运算。李明写了一个表达式,把路径距离和坡度混合成岩石大小:
这里 和 是岩石最小和最大缩放, 是点到路径的距离, 是最大影响半径, 是地形坡度。表达式结果写回 RockScale 属性,供后面的 Transform Points 节点读取。
Transform Points
Transform Points 节点负责把输入点的位置、旋转、缩放按属性或随机值修改。李明用它来制造植被的不规则感。一片草甸如果所有草都朝同一个方向、同样高度,看起来会非常呆板。他给每个点加一个随机旋转和基于属性的高度缩放。
变换节点可以读取属性,也可以写入属性。李明把 RockScale 连到 Scale 输入,岩石大小就由前面计算好的属性决定。他还在 Rotation 里加了基于法线的倾斜,让岩石顺着坡面略微躺倒。地形法线通过 Surface Sampler 直接带入,不需要额外采样。
graph LR
A["输入点"] --> B["读取 RockScale"]
A --> C["读取 TerrainNormal"]
B --> D["Scale 调整"]
C --> E["Rotation 倾斜"]
D --> F["输出点"]
E --> FTransform Points 的另一个用处是做局部偏移。李明在悬崖岩石组里加了一个小的随机位置抖动,避免岩石排成整齐的网格。偏移量用 Perlin 噪声生成,噪声坐标来自点的世界位置,所以相同 Seed 下每次生成的抖动都一致。
Transform Points 的随机模式有几种。Uniform Random 在每个轴向上做完全随机,适合花朵朝向。Perlin Random 用噪声产生连续变化,适合草丛高度随地形起伏。Deterministic Random 则严格依赖 Seed,适合需要复现的场景。李明在草甸里混用了两种:底层草用噪声控制高度,顶部花朵用均匀随机控制旋转。
Difference / Union / Intersection
实际关卡里,不同区域经常互相重叠。路径周围要种草,但房屋地基下面不能长草;悬崖上要放岩石,但道路正上方不能掉石头。李明用 Difference、Union、Intersection 三个节点处理这些空间关系。
Difference 节点从 A 集合里移除与 B 集合重叠的部分。李明把整片草甸采样点连到 A,把房屋地基的体积采样点连到 B,得到的就是房屋周围干净的草地。Union 把两个集合合并,适合把山坡草甸和河边湿地合并成同一片植被层。Intersection 只保留两个集合的交集,李明用它筛选出同时满足高度足够和靠近悬崖两个条件的点,专门放大型岩石。
graph TD
A["A 集合"] --> D{Difference}
B["B 集合"] --> D
D --> E["A 中去掉 B"]
A --> U{Union}
B --> U
U --> V["A 与 B 合并"]
A --> I{Intersection}
B --> I
I --> J["A 与 B 重叠部分"]这三个节点执行的是空间布尔运算,运算依据是点的位置或边界框,而不是属性值。如果需要按属性过滤,应该用 Attribute Filter。李明一开始把 Difference 和 Filter 搞混,结果在房屋周围误删了一大批不该删的点。后来他区分清楚了:Difference 处理的是空间重叠,Filter 处理的是属性条件。
这三个节点都支持设置边界框容差。容差太小,两个集合交界处会留下锯齿状空洞;容差太大,会误删本不该影响的点。李明通常先用 50 厘米容差做第一次裁剪,再在关键区域补一些小范围的局部调整。Intersection 还有一个反向用法:把不要的区域和整体区域求交,得到的就是需要清理的部分,再和原始集合做 Difference,相当于反选删除。
Self Pruning 与 Density
Self Pruning 解决的是点与点之间的互相排斥。一片区域如果采样点过密,实例会大量重叠,既浪费性能又显得不自然。Self Pruning 节点以每个点为中心建立一个半径,半径内只保留一个点,其余的删除。
半径可以来自属性。李明给大型岩石设置较大的 Pruning Radius,给小野花设置较小的半径。这样岩石之间会保持较大间距,花朵则可以密集生长。半径值他用另一个 Attribute Set 预先算好,跟岩石大小正相关:
其中 是比例系数, 是岩石缩放, 是最小间距。这个公式保证了大石头周围有更大的空隙,小石头可以挨得更近。
Self Pruning 的执行顺序很重要。如果先做 Transform Points 改变点的大小,再做 Pruning,半径才能反映最终尺寸。反过来,先裁剪再缩放,可能出现缩放后岩石互相穿插的情况。李明把 Pruning 放在 Transform 之后、Mesh Spawner 之前。
Self Pruning 的计算量会随点数平方增长,所以不要在早期就执行。李明总是在过滤之后、实例化之前再加 Pruning,这样参与计算的点的数量已经大幅下降。另外,Pruning Radius 如果来自属性,要确保属性值在合理范围。他在 Attribute Set 里用 saturate 把半径限制在最小最大值之间,防止个别异常点产生巨大排斥范围。
Seed 控制与确定性
程序化生成最怕的是每次打开编辑器结果都不一样。策划验收时需要稳定的画面,QA 复现 bug 时也需要相同的分布。Seed 就是用来解决这个问题的。
PCG Graph 里几乎每个涉及随机的节点都有 Seed 输入。Seed 是一个整数,相同的 Seed 在相同输入下会产生完全相同的输出。李明把整个 Graph 的 Seed 暴露成一个公开变量,默认值 42。需要变换方案时,他改一个数字,整个场景就会重新洗牌。
sequenceDiagram
participant User as "用户"
participant Graph as "PCG Graph"
participant Sampler as "Sampler 节点"
participant Noise as "Noise 节点"
participant Prune as "Self Pruning"
participant Output as "Mesh Spawner"
User->>Graph: 设置 Seed = 42
Graph->>Sampler: 传递 Seed
Sampler->>Sampler: 初始化随机序列
Graph->>Noise: 传递 Seed + Offset
Noise->>Noise: 生成一致噪声
Graph->>Prune: 传递 Seed
Prune->>Prune: 确定保留点
Output->>User: 输出稳定实例李明还学会了给不同子系统分配不同的 Seed Offset。植被用一个 Offset,岩石用另一个 Offset,这样调整植被 Seed 时不会影响岩石分布。Seed 的传递是层级式的,父节点 Seed 加上局部 Offset 得到子节点 Seed,保证整体可控又不互相干扰。
UE5.8 的 PCG 内部用整数 Seed 初始化伪随机数生成器。常用的是 XorShift 或类似的确定性算法,给定 Seed 后产生的序列完全相同。这也意味着 Seed 的选取有技巧:用 0 或 1 容易在某些噪声采样里产生对称或重复 pattern,李明通常用策划生日、版本号或任意大质数作为 Seed。
属性传递与过滤
属性在 Graph 中不是孤立存在的,它们会沿着边向下游传递。下游节点可以读取上游节点写入的属性,也可以覆盖或新增属性。李明把颜色变化做成了一个属性传递链。
他在 Surface Sampler 之后拿到 Normal 和 Altitude。接着用一个 Attribute Set 计算 Slope 属性。然后用另一个 Attribute Set 根据坡度和高度混合出 GrassColor:低洼湿地偏绿,高处岩石偏黄褐。最后 Transform Points 读取 GrassColor,把它注入到实例的顶点色或材质参数里。
过滤节点 Attribute Filter 可以按属性条件剔除点。李明用它做了几层过滤:高度低于水面的点不要草,坡度超过 60 度的点不要花,路径 2 米内的点只保留低矮植被。每个 Filter 节点只负责一个简单条件,多个 Filter 串联起来就是复杂的筛选逻辑。
flowchart LR
A["Surface Sampler"] --> B["Altitude / Normal"]
B --> C["Attribute Set: Slope"]
C --> D["Attribute Set: GrassColor"]
D --> E["Filter: Altitude > WaterLevel"]
E --> F["Filter: Slope < 60"]
F --> G["Transform Points"]
G --> H["Mesh Spawner"]过滤时要注意短路逻辑。如果一个 Filter 已经把大部分点删除了,后面的 Filter 运算量会大幅减少。李明把最激进的过滤放在前面,比如先按高度删掉一半点,再计算复杂的颜色属性,可以节省不少编辑器帧时间。
属性名有作用域概念。上游节点写入的属性默认会向下游传递,直到被覆盖或删除。如果两个分支合并,同名属性以最后写入的为准。李明在分支汇合前会给属性加分支前缀,比如 Path_Density 和 Cliff_Density,合并后再用一个 Attribute Set 合成最终的 Density。这样可以避免不同系统互相覆盖。
调试与可视化
Graph 复杂以后,李明经常需要知道中间节点到底输出了什么。PCG 提供了几种调试手段。第一是直接在节点上点击 Inspect,查看输出点的数量和属性列表。第二是开启 Point Density 可视化,用颜色表示点的疏密。第三是临时把中间结果用 Static Mesh Spawner 渲染出来,看这一步生成的点长什么样。
可视化对属性调试尤其有用。李明把 Density 映射成灰度,GrassColor 映射成彩色,就能一眼看出哪些区域属性计算错了。他还发现,把 bIsNearPath 布尔属性显示成红绿两色,可以快速检查路径影响范围是否和策划要求一致。
性能方面,PCG Graph 右上角会显示每个节点的执行时间和输出点数量。李明通过这个数字找到了瓶颈:Attribute Filter 之前的表达式节点计算量最大,因为要对每个点做三角函数。他把表达式里的 sin 和 cos 简化成预计算的查表,节点耗时下降了一半。
一个常见的调试误区是直接看最终实例,发现问题后却很难定位是哪个节点引起的。李明的做法是从输出端往回查:先确认最后一个 Mesh Spawner 有没有收到点,再逐个向上检查每个中间节点。Inspect 面板会显示点的数量和属性直方图,哪一步点数骤减或某个属性全为零,问题往往就在那一步。
代码层面,李明把常用的属性计算封装成了一个自定义 PCG Element。这个 Element 接受输入点和若干参数,输出带 Density、Scale、Color 的点集合。自定义节点让 Graph 主图变得干净,也便于在多个关卡复用。
封装成 Element 之后,李明发现调试更方便了。因为输入输出固定,他可以在一个简单关卡里测试这个节点,确认无误后再挂到主 Graph 里。复用性也提高了,山地、森林、沙漠三个关卡都引用了同一个 Element,只是传入不同的参数。策划想调整某个关卡的岩石密度时,只需要改对应 Graph 里的常量,不需要动核心逻辑。
// 伪代码:自定义 PCG Element 的属性计算逻辑
void CalculateAttributes(FPCGContext& Context)
{
for (const FPCGPoint& Point : InputPoints)
{
float Distance = GetDistanceToPath(Point.Transform.GetLocation());
float Slope = ComputeSlope(Point.Transform.GetLocation());
float Density = FMath::Lerp(0.0f, 1.0f, Distance / MaxDistance) * (1.0f - Slope);
float Scale = FMath::Lerp(MinScale, MaxScale, Density);
FLinearColor Color = FMath::Lerp(LowColor, HighColor, Slope);
Point.SetAttributeFloat("Density", Density);
Point.SetAttributeFloat("RockScale", Scale);
Point.SetAttributeColor("GrassColor", Color);
}
}收尾
回到李明的高山草地场景,他最终用一张 PCG Graph 解决了动态扩展、区域控制和确定性三个问题。Graph 的四层结构让逻辑清晰,Attribute Set 把设计意图转成数据,Transform Points 和 Self Pruning 控制实例外观与分布,Difference 处理空间冲突,Seed 保证结果可复现。调试可视化让他在反复调整中快速定位问题。
对正在学习 UE5.8 PCG 的开发者来说,最关键的经验是:不要一开始就把所有逻辑塞进一个节点。把采样、属性、过滤、变换、实例化拆成独立步骤,每个节点只干一件事,Graph 才会好维护。属性系统是连接这些步骤的纽带,理解属性如何在节点间传递,就掌握了 PCG Graph 的核心。