UE5 / Tooling
FoliageBaker:植被风动烘焙器的字段设计、Nanite 约束与四次迭代
植被风动这块做了挺久,中途返工过几次。这篇把 FoliageBaker(自己写的 UE5 烘焙插件)的设计定下来:字段为什么这么定、数据往哪儿塞、Nanite 有哪些硬约束、弯曲模型迭代了四版为什么最后退回 v3,以及现在还在查的 WPO 症状。
一、要解决的问题
植被风动的本质需求只有一句话:让枝叶绕着自己的根部转,而不是整体平移。
引擎自带的方案(SpeedTree 风轴、Pivot Painter)能用,但在我们的场景下有两个问题:
- 数据不可控——想给草加一个自定义的刚度分布、想让 AO 走独立通道,改不动
- 插片草不好处理——叶片是几个 quad,用常规灌木算法套上去,动起来像整片纸在飘
所以决定自己烘:离线把每根枝、每片叶子需要的生长方向、根部位置、风动权重算好,写进顶点属性,运行时在 WPO 里直接用。烘焙器的职责就是把几何信息变成着色器能一口气读完的数据。
二、三种模式与两类草变体
| 模式 | 处理对象 | 关键差异 |
|---|---|---|
grass |
草 | 整体一株烘焙,不逐叶拆 |
bush |
灌木 | 逐枝逐叶,带层级 |
tree |
树 | 枝干 → 叶的层级最深 |
草单独拎出来是因为它量大,逐叶烘焙不现实。草里面又分两类变体:
- leaf-grass(插片草):叶片是插片,用灌木算法去适配它——把它当成"极矮的小灌木"
- Reed-grass(芦苇类):细长、弯曲幅度大,走独立参数
还有个开关叫 Grass Self-Root:草的 root 用自身底部而不是共享地面点。不开的时候整簇草会像一块板一起摆,开了之后每株有独立的根部,摆动相位就散开了。
三、烘焙字段:每个字段都是被问题逼出来的
| 字段 | 含义 | 为什么需要 |
|---|---|---|
LeafDirOS |
单位向量,叶片的生长方向(Object Space) | 旋转轴不能再用世界 X/Y——叶片朝向各异,必须有各自的轴 |
LeafWeight |
0→1,根到尖的风动权重 | 根部不动、尖端动得多,这是"弯"而不是"移"的来源 |
LeafPivot |
叶片根部(旋转中心)位置 | 绕根部转,绕错点就是平移 |
LocalObjectBoundsMin/Max |
修正后的局部包围盒 | 不修正会被剔除——见第五节 |
NaniteUnsafe |
标记该资产不适合走 Nanite | 有些植被走 Nanite 反而出问题,需要显式标出来 |
权重为什么用"归一化 Z 高度",不用测地距离
最初想的是沿叶片测地距离(从根沿表面走到当前点)算归一化,物理上更准。实际一跑就发现不行:UV 缝会破坏测地距离的连续性——接缝两侧的测地距离算出来不一样,权重在缝上跳变,风一吹就看到一道明显的分界线。
改成归一化 Z 高度 + pow:
理由很实际:
- Z 高度是逐顶点独立计算的,不依赖拓扑连通性,UV 缝影响不到它
- 大部分植被的生长方向就是大致朝上,Z 高度和"离根多远"强相关
p是可调的硬度曲线——p > 1时根部更硬、尖端更软,一根参数的改动就能调手感
代价是:对横向生长很厉害的植物(比如匍匐的枝条)Z 高度会失真。这种资产单独标 NaniteUnsafe 走另一套处理。
四、数据布局:往哪儿塞
烘焙出来的一堆数据,得塞进有限的顶点属性里。当前的分配:
| 载体 | 存什么 | 说明 |
|---|---|---|
| UV2 / UV3 | 风轴相关数据(Compact 编码) | 解码后得到旋转轴 |
| VertexColor.R | 叶片 / 枝干区分或辅助权重 | |
| VertexColor.G | BranchWeight |
枝干层级权重,与叶片权重分开算 |
| VertexColor.B | 辅助(相位 / 随机) | |
| VertexColor.A | dead channel,别用 | 曾经拿来存 AO,后来作废 |
AO Target UV Channel .x |
AO | AO 现在走独立的 AO 目标 UV 通道 |
两点要记住:
- AO 曾经存过
VertexColor.a,后来清理掉了。原因是顶点色通道太挤,AO 又需要独立精度和独立 UV 采样,混在一起既浪费又容易在导入时被美术的 DCC 工具改掉。现在 AO 走专门的 AO Target UV Channel。 - 默认 UV 通道配置是 UV2 → UV4。改这个默认值之前先确认材质和导入器两侧同步改,否则会出现"烘焙写进 UV2、材质去 UV4 读"的静默错误——这种错误不会报错,只会表现出"风不动"。
五、Nanite 的三条硬约束
植被 + Nanite 的组合,有三条是查过才知道的:
1. UV 编码上限 32768(不是 16384)。
Nanite 的 UV codec 是 uint24 布局,极值上限 。UV 值从 32769 开始就会编码失败。这个数我一开始按 16384 记,导致一批资产"检查通过"但在引擎里就是有问题。
2. welding tolerance 0.01 cm 会把单株植物拆成多个簇。
BuildLeafClusters 用 0.01 cm 的焊接容差做顶点合并,对植被这种高密度小尺度几何,结果是一株植物被拆成好几簇。后果很隐蔽:簇边界两侧的风动相位不连续——看上去就是"同一株草,一半在动一半不动"。排查时如果只盯着材质和 WPO 代码,永远找不到原因。
3. LocalObjectBoundsMin/Max 必须修正。
WPO 会把顶点推出原始包围盒。包围盒不修正的话,摄像机稍微一动,枝叶就整体消失(被 instancing 剔除判定为不可见)。烘焙时把变形后的极值写回 LocalObjectBoundsMin/Max,这是必做项不是优化项。
六、弯曲模型:v1 到 v4,最后退回 v3
这块返工最多,四个版本依次是:
| 版本 | 做法 | 结果 |
|---|---|---|
| v1 | 逐顶点旋转(每个顶点自己转) | 有平移感,不像弯曲——因为各顶点绕各自的点转,没有累积 |
| v2 | 累积 Rodrigues 旋转 | 弯曲感对了,但书写复杂、分支处累积方向难控 |
| v3 | 用 LeafDirOS 驱动旋转轴 |
✅ 表现稳定,成为当前版本 |
| v4 | v3 + 迎风对齐 + 刚度 mask + clump(成簇) | ❌ 回退 |
v3 的核心
绕轴旋转用 Rodrigues 公式,轴来自烘焙好的生长方向:
其中 是旋转轴(由 LeafDirOS 与风向叉乘得到),$\theta$ 正比于 LeafWeight 与风力。权重为 0 的根部 ,自然不动。
v4 为什么回退
v4 加了三个听起来都对的东西:迎风对齐(叶片转向迎风面)、刚度 mask(局部调硬)、clump(邻近叶片成簇同步)。单看每一项都合理,加在一起的问题是:
- 参数耦合严重——调迎风强度会改变 clump 的观感,调刚度又会影响迎风,调参变成三维搜索
- 迎风对齐在风向快速变化时产生高频抖动,而 clump 又把抖动同步放大到一整簇
- 复杂度带来的收益撑不起维护成本:美术调不出稳定的手感,每次改动都要重新过一遍所有植被
最后退回 v3。这次回退的教训不是"v4 的功能不好",而是"没验证前一版就叠新功能"——v3 的问题还没完全查清(见下节)就往上加东西,结果分不清哪些抖动是 v3 固有的、哪些是 v4 引入的。
七、还在查的:WPO 的三个症状
v3 当前有三个未解决的症状,按"现象 → 假设 → 验证方法"记:
| 症状 | 假设 | 验证方法 |
|---|---|---|
| 放射拉丝(顶点沿径向被拉出丝状) | 权重在局部异常放大,或旋转轴退化($k$ 接近零向量时公式不稳定) | 逐顶点 dump 与 的模长,看是否有离群值 |
| 卡顿不连贯 | 相位量在簇边界不连续(与 Nanite 拆簇同源),或时间项精度不够 | 关掉风动、只给固定角度,看是否还卡;再逐簇打印相位 |
| 拉伸(看起来被拉长) | 纯旋转不该产生长度变化——如果变了,说明混进了平移分量 | 弧长守恒采样:变形前后沿叶片随机采样相邻点对,比较距离 |
第三条给了个很实用的判据:纯弯曲是等距变换,不应改变弧长。随机采样若干点对,统计 变形前后的比值,理想情况应该全部接近 1.0。有明显偏离就说明代码里混进了非旋转分量——这个检查比"看着像不像"可靠得多。
排查顺序我固定为:先确认数据(烘焙出来的字段对不对)→ 再确认变换(是不是纯旋转)→ 最后才是调参数。不要一上来就调参,参数能调出"看起来还行",但根因还在。
八、工程上的几条铁律
插件维护过程中定下的规矩,每条都对应一次踩坑:
- 新增设置必须三处同步:
Settings.h声明、Settings.cpp的Reset()、以及Dialog.cpp的 UI。漏任何一处都会出现"UI 上改了但不生效"或"默认值和 UI 显示不一致"。 - Unity Build 下 static 函数必须唯一命名。Unity Build 把所有 cpp 合并编译,
static void Rotate()这种名字会撞。统一加前缀,比如WindBend_*。 SAFE_PAYLOAD = 1。烘焙写入时做安全载荷检查,宁可慢一点也不要写出越界数据——越界数据的表现是随机的,极难定位。LocalObjectBoundsMin/Max必修(第五节第 3 条),这不是可选项。
小结
FoliageBaker 做下来最大的体会是:烘焙器的难点不在烘焙,在于"数据布局的前后一致性"。字段定义、UV 通道、顶点色分配、Nanite 约束、材质读取端,任何一处不同步,表现都是"风不动"或者"动得不对",而且不报错。
所以我现在的做法是:任何一处改动,都在烘焙器里加一条自检输出,把实际写入的通道和值打出来。肉眼比对一遍,比事后靠猜快得多。