UE5.8 程序化植被与 Foliage:沙漠到森林的生长规则
一个被植被埋掉的早晨
阿杰刚进公司就收到美术组的消息:测试关卡的草原在低端机上帧率只有 22 帧。他打开场景,发现一片半径五百米的草甸里塞了三十七万棵草实例。更离谱的是,草一直刷到玩家根本走不到的山坡背面,树下还长着跟开阔地一样高、一样密的草。
这片草原是他上周用传统 Foliage 模式手工刷的。刷的时候很爽,拖动笔刷哗啦一下就铺满。但优化时他就傻眼了。删哪棵?留哪棵?怎么保证近处密、远处稀?树下的草要不要去掉一半?这些问题没有规则,只有感觉。
老周在旁边看了一眼,说:"你要让场景自己知道草该长在哪。"
这句话把阿杰引向了 UE5.8 的 Procedural Foliage 编辑器。它不会去管每一棵草的具体坐标,只会问这块地能不能长、该长什么。规则对了,一片森林能自己长出来;规则错了,就是数字杂草灾难。这篇文章把我在几个项目里踩过的坑、试过的方案,整理成一套能用在工作流里的经验。
程序化植被与手工笔刷的分工
很多刚接触 UE5.8 的人会把 Procedural Foliage 当成更高级的 Foliage 笔刷。这种想法容易吃亏。传统 Foliage 笔刷的核心是控制:你告诉引擎每一棵草、每一棵树的具体位置。程序化植被的核心是约束。你告诉引擎哪些地方允许生长、哪些植物互相排斥、密度和尺寸怎么随环境变化,然后让系统去填空。
两种方式各有活法。 Foliage 模式适合雕琢关键构图区域,比如主路径两侧的迎客松、营地中央的标志枯树。程序化生成适合铺背景、中景和大面积的自然过渡,比如一整片针叶林的林缘线、河谷两侧的灌木带。
我在项目里一般是三七开:手工 Foliage 占三成,负责视觉焦点;程序化占七成,负责体量和氛围。这个比例是我根据项目经验定的。靠着它,我们把低端机帧率从 22 帧拉到了 55 帧。
Procedural Foliage 编辑器的核心结构
打开 Procedural Foliage 编辑器,你会看到两个关键概念:Foliage Type 和 Foliage Spawner。Foliage Type 是单一植物的档案,包含网格、材质、碰撞、LOD、阴影、风动参数等。Foliage Spawner 则是一张配方表,决定哪些 Foliage Type 参与生成、各自权重多少、遵循什么规则。
一个 Spawner 下面可以挂很多 Type。比如我们的温带森林 Spawner 就挂了五类:
- 优势乔木:欧洲云杉,数量权重最高,决定天际线。
- 伴生乔木:白桦树,权重低一些,增加颜色变化。
- 林下灌木:蓝莓、蕨类,填补树冠间隙。
- 地面草本:枯草和苔藓,覆盖率最大但单实例极轻。
- 倒木与枯枝:权重很低,用来打破地面的重复感。
下面是这套结构在编辑器里的关系示意。
flowchart TD
subgraph editor ["Procedural Foliage Editor"]
spawner["Foliage Spawner"]
type_a["Foliage Type: 云杉"]
type_b["Foliage Type: 白桦"]
type_c["Foliage Type: 蕨类"]
type_d["Foliage Type: 枯草"]
end
subgraph world ["World"]
landscape["Landscape"]
grass["Landscape Grass Type"]
pcg["PCG Graph"]
end
spawner --> type_a
spawner --> type_b
spawner --> type_c
spawner --> type_d
type_a --> landscape
type_b --> landscape
grass --> landscape
pcg --> landscapeSpawner 本身不会自动往场景里种东西。它更像一个蓝图资产,需要被拖到关卡里,或者用体积(Foliage Spawner Volume)框定范围才会生效。这个设计有个好处:同一张 Spawner 可以复用到多个关卡或同一个关卡的不同生物群落区,你只要改局部参数就能做出差异。
生物群落规则:从真实照片里提取约束
最让新手头疼的是规则怎么定。我的办法是去现场,或者至少找一组高质量参考照片。我们之前做一个沙漠峡谷项目,美术参考了美国西南部荒漠的航拍和地面照片。照片里出现了几个明确规律:
- 峡谷底部有季节性河道,河床边会长一溜梭梭树和灌木。
- 向阳的南坡植被稀疏,背阴的北坡相对密集。
- 巨石背风面会积累土壤,长出小型肉质植物。
- 沙丘顶部几乎没有植物,坡脚和凹陷处才有草丛。
这些规律就是规则。在 Procedural Foliage 里,我们用 Slope Angle、Height、Landscape Layer 把它们翻译出来。
Slope Angle 控制坡度。沙丘顶部坡度大,排除大型植物;坡脚平缓,允许灌木和草丛。Height 控制海拔。峡谷底部河道附近海拔低、湿度高,允许更多绿色植被;高处山脊干燥,只剩耐旱仙人掌。Landscape Layer 直接读取地形材质层,比如只在 Sand_Grass 层上长草,在 Rock_Dry 层上长仙人掌,在 Soil_Wet 层上长灌木。
规则评定的流程如下图所示。
flowchart TD
A["Landscape 采样"] --> B{坡度是否小于阈值}
B -->|"否"| C["排除大型植被"]
B -->|"是"| D{高度是否在允许区间}
D -->|"否"| E["排除喜湿植物"]
D -->|"是"| F["读取 Landscape Layer"]
F --> G{Layer 匹配}
G -->|Sand| H["生成草 / 仙人掌"]
G -->|Soil| I["生成灌木"]
G -->|Rock| J["生成苔藓 / 多肉"]
G -->|Water| K["不生成"]UE5.8 里每个 Foliage Type 都有一张详细的 Placement 参数面板。关键参数包括:
- Ground Slope Angle:生长允许的最小和最大坡度,单位度。
- Height Range:基于 Landscape 全局 Z 高度的最小和最大海拔。
- Landscape Layer:可以指定一个或多个地形图层,并用 Weight Range 限制图层权重区间。
- Collision Radius:该植物占位圆盘的半径,用来避免同类或异类植物重叠。
- Shade Radius:树荫影响范围,可以抑制喜光植物在树冠下生长。
- Scale X/Y/Z Min/Max:尺寸随机范围。
这里有个小坑:Collision Radius 和 Shade Radius 都以厘米为单位,而且默认值经常偏大。我第一次用的时候按默认值,结果一棵树周围两米内寸草不生,林下地皮秃得像被除草剂喷过。后来把 Shade Radius 降到树冠半径的 0.6 倍左右,林下植被才自然起来。
密度、缩放与旋转:用数学把混乱变规则
阿杰那个三十七万棵草的草原,问题不在草本身,而在密度没有距离衰减。玩家能看清的范围大概就半径三十米,再远的草只是色块。 UE5.8 的 Procedural Foliage 允许按 Foliage Type 设置 Initial Seed Density,控制每平方米尝试播种的种子数。但这个数字不是最终实例数,因为后面还要经过规则过滤和排斥计算。
真正决定视觉密度的,是几个参数的组合:
其中 是初始种子密度,、、 是各项地形条件通过概率, 和 是碰撞和遮罩重叠率。这个式子是我估算有效密度的经验模型,不是引擎内部公式。它提醒团队:调密度不能只拉一个滑条。
缩放控制更微妙。自然界的植物尺寸更接近对数正态分布,均匀分布会显得很假。也就是说,小苗很多,参天大树很少。在 Foliage Type 的 Scale 设置里,我把 Z 轴缩放最小值设到 0.6,最大值设到 1.4,并打开 Scale Variation 的曲线,让中小尺寸出现概率更高。这样同样的实例数量,看起来会比均匀缩放丰富得多。
旋转方面,我一般让 Yaw 全随机 0 到 360 度,Pitch 和 Roll 只做微小偏移,范围在 -5 到 5 度之间。 Pitch 和 Roll 太大的话,树会像被风吹倒一样歪。另外,Billboard 草的 Yaw 随机很重要,否则一片草会齐刷刷朝同一个方向,像军训队列。
下面这段代码展示了我们项目中自定义 foliage 生成后处理的一个简化逻辑,用来根据到相机的距离再做一次密度衰减。它挂在编辑器脚本里,生成完成后执行一次。
import unreal
editor = unreal.EditorLevelLibrary()
foliage_actors = editor.get_all_level_actors()
camera = editor.get_editor_player_camera_manager()
cam_pos = camera.get_camera_location()
for actor in foliage_actors:
if isinstance(actor, unreal.FoliageInstancedStaticMeshActor):
for comp in actor.get_components_by_class(unreal.InstancedStaticMeshComponent):
instances = comp.get_instance_count()
to_remove = []
for i in range(instances):
pos = comp.get_instance_transform(i).translation
dist = (pos - cam_pos).length()
# 超过 150m 的实例按距离概率剔除
if dist > 15000.0:
if (dist - 15000.0) / 30000.0 > unreal.random_unit_fraction():
to_remove.append(i)
comp.remove_instances(to_remove)这段脚本只有二十七行,但帮我们在一个开放世界原型里省掉了大约 18% 的远距离实例。实际项目里这段逻辑后来被挪到了 HLOD 和 Cull Distance Volume 里,运行时代价更小。
Landscape Grass Type:铺地被的最快路径
Procedural Foliage 擅长树、灌木、石头这类有体积、有遮挡、需要精确放置的对象。对于大面积低矮草本,比如草、苔藓、落叶层,用 Landscape Grass Type 更快。
Landscape Grass Type 是一种基于地形材质权重的自动铺草机制。你在地形材质里输出一张 Grass node,把不同草类型挂到不同 Landscape Layer。运行时引擎会根据相机距离和屏幕空间大小动态生成或销毁 Grass Instance。它的优势是极轻、极快、无需烘焙,而且只对视野内的区域生效。
我们项目里草的分层是这样的:
- 近处(0 到 8 米):高密度手动 Foliage 或 Procedural Foliage 生成的细节草,有风动和碰撞。
- 中处(8 到 40 米):Landscape Grass Type 自动铺的简模草,每平米实例数按相机距离递减。
- 远处(40 米以上):地形材质本身的颜色变化和法线细节已经足够,不再单独生成草实例。
这样三层下来,玩家眼前草很密,但总体实例数只有全刷方案的三分之一。
UE5.8 里 Landscape Grass Type 新增了一个让我喜欢的功能:可以在 Grass Type 里指定 Exclude Slope 和 Exclude Height,而且支持读取 Landscape Layer 的 mask。这意味着你不需要再为草单独写 PCG Graph,也能做出沙丘顶部不长草、河床边缘长芦苇的效果。
下面这张图对比了 Procedural Foliage 和 Landscape Grass Type 在项目里的职责边界。
graph LR
A["地形表面覆盖需求"] --> B{是否需要个体形态}
B -->|"是,树/石/灌木"| C["Procedural Foliage"]
B -->|"否,草/苔藓/落叶"| D["Landscape Grass Type"]
C --> E["预生成实例,支持碰撞与 LOD"]
D --> F["运行时动态生成,按距离剔除"]与 PCG 的对比与互补
UE5 的 PCG(Procedural Content Generation)插件这几年进步很大。很多团队会问:既然有 PCG,为什么还要用 Procedural Foliage?我的回答是,两者不在同一层。
Procedural Foliage 是引擎内置的专门化系统,已经为植物放置做了很多预设:排斥半径、坡度过滤、高度过滤、图层过滤、 shade 抑制、随机缩放旋转。你几乎不用连线,调参数就能出效果。
PCG 是通用图节点系统,理论上什么都能做,但你要自己实现植物放置的常用逻辑。它的优势在复杂结构:沿道路种树、按样条线分布电线杆、在建筑物周围生成碎石、根据河流网络生成河岸植被带。
我们在项目里的分工是:
- Procedural Foliage:自然区域的大面积植被基底。
- Landscape Grass Type:低矮地被的动态填充。
- PCG:规则结构、人工痕迹、关卡叙事相关的点缀,比如废弃公路两侧的野草、营地周围的轮胎和油桶、河岸边倒伏的枯木。
下面用序列图展示美术、程序和 PCG 节点一起工作时的典型流程。
sequenceDiagram
participant TA as "技术美术小林"
participant LD as "关卡阿杰"
participant PFE as "Procedural Foliage"
participant PCG as "PCG Graph"
participant LAND as Landscape
TA->>LAND: 确定地形 Layer 与生物群落分区
LD->>PFE: 配置 Foliage Type 与 Spawner
PFE->>LAND: 采样坡度、高度、Layer
PFE-->>LD: 生成初步植被分布
LD->>PCG: 传入道路、河流、建筑位置
PCG->>LAND: 读取地形与几何信息
PCG-->>LD: 输出结构型细节
LD->>PFE: 手工修正视觉焦点区域
TA->>PFE: 调整 LOD 与 Cull 参数季节变化与 Data Layers
有次策划提需求:同一个关卡要同时做春、夏、秋、冬四个版本,给玩家赛季活动用。如果用传统做法,复制四个关卡文件,任何修改都要同步四次,维护成本直接爆炸。
UE5.8 的 Data Layers 救了这件事。 Data Layer 允许你在同一个关卡里维护多个数据层,每层可以独立加载、卸载、编辑、烘焙。我们把四季植被做成了四个 Data Layer:
- DL_Spring:浅绿嫩叶、野花、湿润地表。
- DL_Summer:浓绿冠层、茂盛杂草、干燥土路。
- DL_Autumn:黄叶、落叶层、裸露树枝。
- DL_Winter:积雪覆盖、常绿针叶、枯枝。
每个 Data Layer 里只放差异部分。比如秋季层里替换了一部分树冠材质、增加了地面落叶实例、把草地颜色调暗。共同的地形、岩石、建筑保持在主关卡里不动。
运行时通过 C++ 或蓝图切换 Data Layer 可见性。下面是一段简化蓝图逻辑,只在玩家打开赛季面板时切换一次,避免每帧判断。
void ASeasonSwitcher::SwitchToLayer(const FName& LayerName)
{
UWorld* World = GetWorld();
if (!World) return;
AWorldDataLayers* DataLayers = World->GetWorldDataLayers();
if (!DataLayers) return;
const FActorDataLayer TargetLayer(LayerName);
DataLayers->SetDataLayerRuntimeState(TargetLayer, EDataLayerRuntimeState::Activated);
}Data Layer 切换时,植被实例是预生成好放在层里的,所以比运行时动态替换材质更稳。但它也有代价:四层意味着四份实例数据,包体体积会涨。我们的做法是只在重点活动区域做四季层,偏远背景共用夏季资源,通过材质变色简单模拟季节感。
性能优化:HLOD、LOD 与剔除
植被优化是个老话题,但在 UE5.8 里有些新东西值得注意。
LOD 是最基础的。每个 Foliage Type 可以设置四到五级 LOD,远距离用 billboard 或合并简模。关键不是 LOD 数量,而是切换距离。太近切换会看到明显 pop,太远切换浪费三角形。我们的经验是:第一级 LOD 覆盖 0 到 15 米,第二级 15 到 40 米,第三级 40 到 100 米,第四级 billboard 到 300 米。具体数值要根据摄像机高度和场景尺度微调。
HLOD(Hierarchical Level of Detail)对大面积森林很有效。它把远处的多棵实例合并成一张 impostor 或一个合并 mesh,大幅减少 draw call。 UE5.8 的 HLOD 支持 foliage 实例参与合并,但需要在 World Settings 里打开 HLOD 并生成集群。生成时间比较长,建议晚上跑。
剔除方面,Cull Distance Volume 和 foliage 自己的 Cull Distance 参数一起用。Cull Distance Volume 是关卡体积,可以按屏幕大小剔除实例。 foliage 的 Cull Distance 则是按距离硬切。两者结合:近处用屏幕大小判断,远处用距离硬切。
还有一个容易被忽略的参数:Cast Shadow。远景植被完全可以关掉动态阴影,靠级联阴影贴图的远距离部分或者烘焙的静态阴影来补。我们在低端机上把 80 米外的 foliage 阴影全关,帧率提了 6 到 8 帧。
风动也是性能热点。简单草可以用 World Position Offset 做正弦波动,复杂树需要 Pivot Painter 或 Niagara 风吹。风动 shader 要尽量早做 early-z,避免 overdraw 爆炸。我们对远景 billboard 直接关闭风动,反正玩家也看不出。
照片参考与工作流
前面提到的沙漠峡谷项目,我们收集参考照片的方式有点讲究。不会随便去 Pinterest 抓几张好看的图,会按生物群落区分类建立相册:
- 远景天际线:看树冠轮廓和林缘线。
- 中景群落:看植物搭配比例,几棵树配多少灌木。
- 近景地面:看地被层次,草、落叶、石块、裸露土壤的混合方式。
- 特殊地貌:河床、岩石背风面、沙丘坡脚等例外位置。
- 季节变化:同一场景春夏秋冬四组照片,用于 Data Layer 设计。
每张照片旁边会写几条笔记,比如南坡云杉密度比北坡低 40%,白桦多长在林缘和倒木附近。这些笔记最后会变成 Foliage Type 里的具体数字。
工作流上,我们是这样反复调整的:
- 地形确定后,先画一张生物群落分布图,用不同颜色标出森林、草原、沙漠、湿地。
- 每个生物群落区建一个 Foliage Spawner,挂上对应的 Foliage Type。
- 按参考照片调 Placement 参数,先生成一版,截图跟照片对比。
- 对比差异,调整密度、尺寸、坡度范围,再生成。
- 关键构图区域用手工 Foliage 修正。
- 跑性能测试,根据帧率和 overdraw 优化 LOD、阴影、剔除。
这个过程通常会重复三到五轮。程序化植被的好处在于规则确定后,可以快速把全局效果再打磨一轮。
几个容易踩的坑
说几个我实际遇到过的问题。
第一个坑是 Layer 权重精度。地形材质某个 Layer 权重看起来是 0.3,但 Procedural Foliage 采样时可能因为压缩或混合得到 0.28 或 0.32。如果你的 Weight Range 设成 0.3 到 1.0,就会看到边界上实例忽有忽无。解决办法是设成 0.25 到 1.0,留一点容差。
第二个坑是 Seed Density 单位。它表示的是每平方米尝试播种的数量,不是最终实例数。很多人会把它设得很高,以为这样能出更密的效果,结果大量种子在碰撞检测里被剔除,反而拖慢生成速度。有效做法是先设一个中等值,再调 Collision Radius 和 Shade Radius。
第三个坑是 Z Offset。植物根部如果没有贴合地形,会悬空或插进地里。大多数 Foliage Type 默认有自动贴合,但如果你的网格原点不在根部,或者地形有剧烈凹凸,就会出现树根悬空的情况。检查方法是开线框模式拉远看看脚底。
第四个坑是生成后再改 Landscape。地形一调,之前生成的实例可能全飞到天上或埋进地下。 UE5.8 里可以右键 Spawner 选择 Resimulate,但重算范围大的话要等很久。建议地形大体稳定后再大面积生成植被。
写在最后
程序化植被这件事,说到底是在用规则替代手工。规则可以来自照片、来自群落学知识、来自团队对场景气氛的判断。 UE5.8 的 Procedural Foliage 编辑器把这些规则可视化、参数化,让美术能调,让程序能控。
阿杰那个草原关卡最后没有删掉三十七万棵草。他只是把草原拆成三层:近景手工细节、中景程序化草、远景用地形材质带过。规则写好后,整个草原只需要维护十几个参数,赛季活动换 Data Layer 就能切季节。帧率稳定在了 58 帧,美术组也不用再一棵棵数草了。
如果你刚开始接触这套系统,我建议先做一个最小生物群落区:比如一片五十米乘五十米的沙丘,只放一种草和一种仙人掌。把坡度、高度、Layer 三个参数摸清楚,再逐步加入树、灌木、落叶。植被的复杂度是一点一点加上去的。规则清楚之后,沙漠和森林都会自己生长。