前阵子我们项目终于把引擎升到了 5.8,这几个月我做得最多的一个工作,就是角色身上的 GPU 布料模拟优化。你要是做过大世界里的 NPC 群体,应该对这种场景很有共鸣:镜头扫过集市,十几个角色的披风、裙摆、旗帜同时在做模拟,CPU 占用看着不高,但 GPU 帧时老是压不下来;走近了看主角,裙摆的褶皱又出不来,跑两步就穿模,某些远景布料在镜头外还在白白烧性能。
我这个插件不是什么大改引擎的方案,它是在 UE5.8 自带布料系统之上包了一层优化工具,内部代号 GCS(GPU Cloth Solver)。它做的事情说到底就三件:一是把布料模拟的输入输出数据重新整理,让 GPU 不跑冤枉路;二是把求解迭代、约束执行、碰撞检测的分配方式,从"每块布料都一样"改成"按场景和距离动态决定";三是把模拟结果的回读时机从"每帧强制同步"改成"渲染、动画、模拟三者错峰交替"。对 TA 和客户端性能调优的同学来说,这套思路是可复现的,下面我会把原理、参数、踩坑记录都摊开讲。
1. GPU布料模拟的性能瓶颈到底在哪
1.1 一句话说清楚GPU求解怎么进行
UE5.8 的内置布料系统在 GPU 上走的是迭代投影的思路。每根布料的顶点初始位置来自骨骼动画,模拟阶段会把重力、风力、惯性等因素叠加进去,然后在一轮一轮迭代里把"不合适"的位置投影回约束允许的范围内。这些约束包括距离约束(顶点之间保持一定距离)、弯曲约束(三角形夹角满足刚度)、碰撞约束(顶点不能穿透碰撞体)和自碰撞约束(布料顶点不能互相穿透)。
这种求解方式和传统有限元方法差别很大:它是"先大概算、再逐次修正",所以迭代次数直接决定最终效果。迭代太少,约束没完全收敛,顶点容易偏离合理位置,表现出来就是布料发软、抖动、穿模;迭代太多,GPU 每次都要把全部约束表过一遍,时间几乎是线性上涨。
1.2 三个层面的瓶颈
很多人以为"GPU布料卡就是该拉高或拉低迭代次数",其实我只见过少数情况真出在单纯迭代上。大部分项目的性能瓶颈可以从三个层面看。
第一个是同步回读。GPU 模拟结束后,渲染、骨骼碰撞、甚至 CPU 侧逻辑都必须拿到最新的顶点位置。如果每个模拟帧都立刻回读,这个同步点会让 CPU 等待 GPU 执行完毕,帧时里就会出现一大块"空洞"。GPU 算得越快,等待反而越扎眼。
第二个是迭代质量。近景布料要的是收敛度,远景布料只要不那么粗暴穿透。传统做法是每块布料都用同样迭代次数,这样远场景的 GPU 成本远超需求,近景又不够用。
第三个是数据布局。布料系统的顶点、邻接表、约束列表在显存里的排列顺序,对 GPU 的 cache 命中影响极大。顶点和约束如果按编辑器里创建的顺序塞进去,三角形共享顶点会被多个线程组反复访问,同一份数据从显存里加载好几遍。
1.3 为什么说"迭代次数往上拉"不一定有效
我还专门做过一组统计:一件 2k 顶点、8k 约束的披风,基础迭代 8 次时 GPU 开销约 0.9ms,穿透三角形大约 4 个;拉到 16 次,GPU 开销涨到 1.8ms,穿透变成 0 个。表面看有效,但注意,近景渲染一个角色的整体 GPU 预算可能也就 2ms 上下,一个披风吃 1.8ms,整个场景根本没法接受。
问题的另一个角度是:当碰撞体本身就不够时,迭代次数再多也只是把顶点往错误的方向推得更努力。比如角色坐下时只有两个胶囊体当碰撞代理,裙摆顶点跟大腿、椅子根本没有碰撞源,那迭代 12 次和 16 次都不会有本质改善。这个点后面踩坑部分我还会展开。
2. 插件整体架构:我把求解流程拆成了四层
2.1 静态预计算层
GCS 在运行时加入了一套静态预计算,把每块布料的顶点分成三类:固定点(例如裙腰、肩带等基本完全被骨骼控制)、自由点(裙摆边缘、下摆)、混合点(权重在 0.5 到 0.85 之间,半跟随骨骼半物理)。
分类之后,插件会对顶点和约束做重排序:固定点排在一段连续区间,自由点排在后面,混合点插在中间;约束按类型分桶存储,距离约束、弯曲约束、自碰撞约束分开存,求解时只执行需要的那一桶。邻接关系既然只依赖初始拓扑,大部分缓冲就在编辑器阶段预生成,运行时只上传权重、速度这些轻量数据,不用每帧重建。
如果 Mesh 运行时有拓扑变化(比如撕布、修补),插件会检测到变动并触发布料重建,但这个逻辑必须做得非常保守,因为它会带来临时阻塞。我在项目里索性规定:可撕裂布料统一走另一个"低性能全局缓存"通道,平时不触发重建。
2.2 求解策略层
每一帧开始时,GCS 根据布料的世界空间包围盒大小、距离摄像机的远近和屏幕投影尺寸,给布料分配一个"质量档"。质量档决定三件事:迭代次数、是否使用自碰撞、是否允许位置压缩。
具体来说,近景质量档(比如屏幕占比大于 0.2)的基础迭代次数是 12 次,上限 16;中景档是 8 次;远景档是 4 次。迭代次数还允许动态浮动:上一帧求解完成后如果剩余位移量小于某个阈值(默认 0.01cm)且没有穿透,下一帧自动降一档;如果有穿透,下一帧升一档。这避免了全员拉满,也避免了布料相对静止时还在无意义迭代。
2.3 调度层
GCS 对每个布料实例的顶点做了一个重映射:把固定点、混合点、自由点按空间位置排序,并且让同一线程组内的顶点在空间上也尽量接近。为什么要这样做?因为 GPU 的 buffer 访问天然吃局部性:一个 wavefront 的 32 个线程如果访问的是 32 个在内存里挤在一起的顶点,延迟会低很多。
线程组大小我常用 256。256 能比较好地匹配常见 GPU 的 wavefront 和 cache line 安排,虽然不是每个硬件下的绝对最优,整体最稳定。另外,顶点排序后三角形邻接数据也更容易被缓存命中,因为同一线程组处理的顶点索引集中,读取邻接表时不会东一下西一下。
2.4 渲染协同层
最关键的是断开"模拟结果必须立刻回读"这一条时序。
GCS 默认把模拟结果延迟一帧给到渲染使用,同时用双缓冲的 PositionBuffer:模拟线程写 A 缓冲时,渲染线程读 B 缓冲,下一帧再交换。骨骼动画的输入也做了同样的双缓冲,让蒙皮和布料模拟可以并行执行,不需要互相等。
好处是主线程不会再被 GPU 完成信号卡住;代价是近景交互(比如布料被玩法物体顶起来)会有一个帧的延迟。对大部分玩法来说,这个延迟毫秒级,可接受;如果实在敏感,就只对玩家主控角色关掉延迟回读,其他人的布料全部延迟。
3. 关键参数与调优策略(可以直接抄的配置)
3.1 最核心的参数表
这里我把平时调得最多的参数整理成了一张表。不同引擎版本里的名字可能有差异,但解决的是同一批问题:迭代次数、子步数、位移限制、精度压缩。
| 参数 | 作用 | 我的建议 | 注意事项 |
|---|---|---|---|
| SolverIterations | 每帧每子步的基础迭代次数 | 基值 8,质量档动态 | 不是越大越好,先看碰撞源 |
| MaxSolverIterations | 迭代上限 | 16 | 超过 16 收益快速递减 |
| MinSolverIterations | 迭代下限 | 4 | 下限太低容易抖 |
| Substeps | 子步数 | 1 | 角色被高速抛掷时再开 2 |
| MaxVertexResolve | 单次投影位移上限 | 0.5cm | 防爆炸,太大不收敛 |
| PositionCompression | 顶点位置是否压缩为 FP16 | 远景开,近景关 | 精度不足会导致边缘闪烁 |
| CollisionThickness | 碰撞厚度 | 1.0 - 1.5cm | 不是越大越好,太大会浮空 |
| SelfCollision | 自碰撞开关 | 近景开,远景关 | 和距离约束执行顺序有关 |
| WindScale | 风力响应倍数 | 0.7 | 太大容易高频抖动 |
| ConvergenceThreshold | 收敛判断阈值 | 0.01cm | 用于判断是否可以降迭代 |
建议把每个参数单独改一次,观察 GPU time 和穿透数量两个指标,不要一股脑全调。否则出了问题你根本不知道是谁引起的。
3.2 不同场景配置案例
这里有三组可以直接抄的配置。
第一组,主角裙摆:迭代 12 到 16,Substeps 保持 1,自碰撞开启,CollisionThickness 设 1.2,PositionCompression 关,风 0.8。这个是近景特写用的,优先保证收敛度和褶皱质量。
第二组,集市 NPC 群体,大约二十个人:迭代 4 到 6,自碰撞关闭,位置压缩开启,碰撞厚度 1.4,风 0.7。这组要的是整体观感稳定,单件布料的细节不需要特别突出。
第三组,过场 CG 特写:迭代 16,Substeps 开 2,PositionCompression 关,自碰撞开启,风 1.0。过场镜头往往怼在布料上,物理精度要求最高,可以不惜性能。
实测下来,这种组合在稳定性和性能之间最平衡。如果贪心,给几十个 NPC 全开自碰撞,GPU 时间会直接翻倍,视觉提升却很小。
3.3 质量和性能的权衡逻辑
我自己总结的经验法则是:先解决"怎么穿",再解决"怎么贵",最后才管"怎么飘"。
穿透问题优先检查碰撞体覆盖范围和顶点位移上限,而不是迭代次数;价格问题优先看迭代次数和位置压缩;飘的问题多半是风和阻尼参数没搭配好,跟迭代次数关系不大。很多团队一上来就调迭代,结果把 GPU 成本抬上去,视觉问题依旧在,就是因为跳过了前面两个层面的排查。
4. 接入方法与实测结果
4.1 接入的完整步骤
接入这套流程不算复杂,但每一步都有细节。
- 把插件目录放到项目的 Plugins 下,启用后重启编辑器。
- 确认项目渲染 API 是 DX12 或 Vulkan,且计算着色器可用;老 API 路径不推荐。
- 打开项目的 Compute Shader 支持和对应的 GPU 调试检查选项。
- 在骨骼网格体资产上指定布料区域,区域的顶点分组交给插件接管。一定先处理 LOD,不同 LOD 顶点数不同,预计算缓存要一一对应。
- 为每个布料角色配置碰撞体代理。不要只放两个胶囊体了事,裙摆周边需要按坐姿、站姿分别增加代理。
- 在项目设置或 C++ 侧创建质量档配置资产,绑定到角色或者布料实例。
- 跑 PIE,用 GPU time 查看器确认计算 pass 的时间,再用穿透数检测工具记录每帧穿透三角面数量。
4.2 优化前后对比
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 集市 30 个 NPC,每件披风/裙摆 1.5k 顶点 | GPU 模拟 3.8ms,CPU 等待回读最长 6.4ms,帧时抖动明显 | GPU 模拟 1.9ms,CPU 等待 0.2ms,帧时平稳 |
| 主角裙摆 3.5k 顶点,开自碰撞 | GPU 模拟 1.6ms,CPU 等待 1.8ms | GPU 模拟 0.9ms,延迟 1 帧 |
要说明的是,这里的数字是在某一台中端桌面显卡加一台移动版独显的组合下测出来的。引擎版本、驱动、显卡型号不同,绝对数值会变,但结论一致:GPU 求解本身往往不是最大开销,回读同步和冗余迭代才是。
4.3 不同硬件和 API 下的行为差异
在 DX12 和 Vulkan 下,计算着色器调度稳定,双缓冲和延迟回读能正常生效。比较容易翻车的是移动端:部分移动 GPU 对计算队列支持不完整,回读带宽也低。我的建议是移动端优先用"每两帧回读一次"或者直接关闭自碰撞,不要贪。
多显卡笔记本这里有个大坑:系统可能把计算任务丢到核显上,而核显的调度路径和独显不一样。我们后来在插件启动检查里明确检测适配器,强制计算工作走独显;一旦检测到核显执行,就自动停用调度加速,回退到引擎默认行为,避免黑屏和驱动重置。
5. 踩坑记录:六个我在项目里真实碰到的问题
5.1 NPC裙子穿椅,加迭代治标不治本
某个 NPC 坐姿时裙摆和椅子严重穿透。一开始下意识把迭代拉到 16,GPU 时间涨了 0.6ms,穿透减少了一点但依旧明显。后来用调试渲染画出全部碰撞体,发现整个臀部区域只有两个大胶囊体,大腿和椅子都没有独立碰撞源。加了一个与凳子模型外轮廓对齐的凸包碰撞体后,用默认 8 次迭代就几乎零穿透。
这个案例给我的启示是:碰撞体覆盖范围是约束求解的上限,迭代只是在这个上限内逼近。你永远没法通过提高迭代次数来弥补缺失的碰撞源。
5.2 双显卡笔记本触发驱动重置
有段时间插件在笔记本上一进场景就黑屏,GPU 驱动报计算着色器访问越界。查了半天,发现系统把整个引擎进程切到了核显,核显上计算 buffer 的访问边界行为和独显不一致,触发重置。解决方式是启动阶段检查 DX 适配器名称,强制要求计算工作走独显,并在界面上提示用户切换。后来一旦检测到核显执行,就直接禁用加速调度,回退到引擎默认路径。
这个坑很像"不同食堂的厨房尺寸不一样,同一份菜谱不能原样照搬"。核显和独显在显存带宽、队列深度、Dispatch 粒度上的差异,是实打实存在的。
5.3 和粒子系统交互时布料突然消失
某场戏里布料上挂了粒子发射器做装饰,进入作用范围后,布料顶点瞬间变成 NaN,然后整块布料消失。定位后是两段代码在同时写同一个顶点 buffer:一段是粒子采样布料位置来生成装饰,另一段是布料模拟本身。时序上出现竞态,粒子系统读到的数据是半个位置向量。
解决方法是把粒子读取单独安排到模拟完成后、渲染开始前的一个独立 pass,并且用 buffer barrier 明确隔开。你可以理解成:同一个仓库不能有两辆叉车同时走同一条过道,要么排队,要么加一条单行道。
5.4 周期性 50ms 尖刺:LOD 切换触发布料重建
场景帧时间在平均 40fps 的情况下,每几百帧出现一次 50ms 的尖刺,非常规律。用 GPU timer 和 CPU profiler 都查不到异常,后来打开内存快照才发现是布料 LOD 切换时触发了预计算重建。重建逻辑在主线程上串行执行,几千个顶点加约束表重新分组,一下就卡掉几十毫秒。
修复办法是在距离阈值到达前提前预创建新的 LOD 缓存,切换时只换指针,不做计算。这个问题的关键是"不要在临界帧做重活",必须把重计算提前摊平。
5.5 近景布料边缘闪烁,关掉 PositionCompression 解决
主角特写时裙摆边缘有高频闪烁,拉到很近才看得出来。根因是顶点位置压缩成 FP16 后,相对骨骼锚点的偏移量越大,尾数精度越不足,边缘顶点出现微米级抖动。这些抖动本身很小,但在法线贴图和深度边缘上会被放大成闪烁。
解决办法不是全局关压缩,而是用距离或者屏幕占比判断:距离小于 5 米或者屏幕投影占比大于 0.15 时用全精度,远景继续压缩。
5.6 开自碰撞后布料爆炸飞出
有一版刚打开自碰撞,裙摆顶点在几帧内高速飞出,像是炸开了。排查后发现是距离约束和自碰撞约束在同一个迭代循环里的执行顺序不对:自碰撞约束修正后的位置,没来得及被距离约束拉回,顶点位移越积越大。解决方法是把自碰撞约束放到距离约束之后执行,并给 MaxVertexResolve 设置 0.5cm 硬上限,之后就炸不起来了。
| 问题现象 | 根因 | 处理方式 |
|---|---|---|
| 裙摆穿椅 | 碰撞源缺失 | 补碰撞代理,而不是拉迭代 |
| 笔记本驱动重置 | 核显跑计算着色器 | 强制切换独显并检查适配器 |
| 布料消失 | 粒子与模拟写同一 Buffer | 用独立 pass 和 barrier 隔离 |
| 周期性 50ms 卡顿 | LOD 切换重建邻接 | 提前预创建 LOD 缓存 |
| 边缘闪烁 | FP16 精度不足 | 近景全精度,远景压缩 |
| 自碰撞爆炸 | 约束顺序与位移上限 | 调整迭代顺序并设置硬上限 |
6. 这套插件的边界和适用场景
GCS 适合的场景很明确:大世界、NPC 数量多、近景和远景布料需求差异大的项目。这类项目里,动态质量档和延迟回读带来的收益非常可观,几十个角色的布料一起模拟时,帧时能稳下来。
但也不是所有项目都适合硬上这套。小于 500 顶点的简单布料,比如飘带、短披风,CPU 迭代反而更快,没必要引入 GPU 调度层;需要极高物理精度的布料,比如长时间与水流交互、被交互体高速挤压的布料,最好保留 CPU 模拟;强实时硬帧率项目,比如某些高要求对战玩法,延迟回读的那一帧可能不可接受,需要谨慎评估。
后续扩展方向也不少。比如把布料结果直接喂给 Niagara 做交互;用遮挡剔除结果跳过屏幕外布料的模拟;基于屏幕尺寸做更细的 LOD;把速度缓冲复用到运动模糊。这些都是在 GCS 的数据层之上可以继续生长的能力。
说实话,这些东西在 Demo 里很容易跑得不显山不露水,但真丢进一个几十万顶点 NPC 群的大世界,差别就是 20 帧和 40 帧。我最大的体会是:布料优化的第一步不是盯迭代次数,而是重新看待同步点。很多项目真正吃性能的不是"算布料"本身,而是等布料算完、不得不把它们拿回来的那一下。
最后再分享一个实用技巧:接入任何这类插件后,先关掉全局的固定子步,也关掉自碰撞,跑一遍基线;然后逐个打开新特性,每开一个就记录一次帧时和穿透数量。这样一旦出问题,你立刻知道是谁引起的,能省掉大量反复调参的泥潭时间。