UE 毛发系统 Groom:从物理模型到引擎实现

先讲头发的物理模型(离散化、约束、碰撞、时间基准),再落到 UE 5.6 的 HairStrands 实现:资产与形变路径、Niagara 求解、专用渲染 pass 与调参地图。

这篇讲 UE 5.6 里的毛发系统(Groom)。它分成两半:前半 回答"头发这东西到底怎么变成一个能算的东西",尽量不假设你懂图形学或物理;后半 回答"UE 具体把它写成了哪些代码、调哪些参数",这部分会大量出现类名和函数名,看不懂可以先跳过,不影响读懂前半。

前言

游戏里的头发,常见的做法是贴图造假:在头皮上贴几片带头发图案的透明卡片,看起来像有头发,但它不是一根一根的。UE 的 Groom 是另一条路——它把每一根头发当成一根真的细线来算,所以它能做到真的甩动、互相碰撞、透光。

代价就是:贵。贵到需要一整套专门的流程,而这篇就是把这套流程从"它是什么"讲到"它怎么算出来的"。

太长不看(TL;DR)

给没时间通读的你,三句话先记住:

  • 是什么:Groom 把每一根头发当成一根会动的细线逐根算(真发丝),而不是在头皮上贴带头发图案的卡片(发片);换来的是真的甩动、碰撞、透光,代价是贵、需要专门管线。
  • 上手只盯三个层级:资产定基线 → 组件覆盖(总开关不开就不生效)→ 求解器组件 / CVar 最外层钳制。想调某个效果,先查"改哪里才算数"的参数地图。
  • 最容易"没反应":三条驱动路(物理求解 / Deformer / 蒙皮绑定)或插值数据不满足时会静默失效——不报错、停在默认姿态。看到"毛发不动",先查这三条路的条件,而不是到处找 bug(见5.2 节)。

阅读说明(版本 / 读者 / 结构):本文以 UE 5.6 的 HairStrands 插件与渲染器源码为基准。默认读者是完全没接触过毛发系统的开发者或者美术创作者:前半(第 04 章)只讲物理原理,不依赖任何引擎知识;下篇(第 511 章)讲 UE 具体实现与上手,其中标注"源码细节,可跳过“的部分,看不懂类名不影响理解。想直接上手,跳到第 9 章;想先看全文骨架,看下面的目录。

名词解释

文中会出现一批固定叫法。先在这里各给一句定义,读到生词可以回来查:

术语英文解释
节点(珠子)Node / point头发上被采样的一个点,记位置与朝向,是求解时最小的更新单位。
边(绳子)Edge / segment相邻两个节点之间的一段,记长度、方向与相对旋转;三类形变约束都作用在它上面。
控制线Guides承载形变与模拟的稀疏曲线,是模拟和绑定实际作用的对象,数量少。
可见发丝Strands由控制线插值展开、最终画出来的发丝,数量远多于控制线。
发组Hair Group资产里配置的最小单元;同组的各项设置按组索引一一对齐。
模拟空间Simulation space求解采用的参考空间(通常是某根骨骼的局部空间),头发在其中被计算。
朝向Orientation / frame附在节点上的一组参考方向(或用四元数表示),用来区分"弯曲"与"整体旋转”,并让扭转可定义。
约束Constraint一条"满足时为零"的几何规则(如距离不变、夹角不变);求解器按梯度把违反量逐次拉回。
柔度Compliance约束的软硬程度;值越大越软,每次修正推得越少。
拉伸 / 弯曲 / 扭转Stretch / Bend / Twist一根发丝仅有的三种形变,刚度数量级相差很大,必须分别建模。
子步Substep把一帧时间切成几段分别求解;影响大位移与高速段是否跟手。
迭代Iteration每段内把约束反复求解的次数;影响头发"软还是硬"。
体素化Voxelization把毛发密度写进三维网格,用于近似自碰撞,或供间接光与阴影使用。
深阴影Deep Shadow(DOM)沿光线方向分层记录透射量的阴影表示,用来表现"一大把头发会透光"。
曲线页Curve page流送与显存分配的最小粒度,按固定曲线数对齐。
发卡Card用一片带头发图案的纹理近似一簇发丝的画法。
平行移动框架Parallel transport frame用相邻切线之间的最小旋转逐节点传播朝向,避免沿头发累积出虚假扭转。
Darboux 向量Darboux vector描述相邻两朝向之间相对旋转的向量,同时携带弯曲与扭转。
双覆盖Double cover四元数 q 与 -q 表示同一旋转,取解时需取一致的一个,否则会在符号翻转处产生假弯曲。
位置动力学PBD / XPBD用"约束投影"而非弹簧力求解的范式,UE 的求解器基于它。
Cosserat 杆Cosserat rod用朝向(而非只有位置)描述细杆的力学模型,能同时表达弯曲与扭转。

目录(TOC)


第 0 章 入门:头发难在哪

一句话:头发难做,是因为它"又多、又细、又软";UE 的选择是老老实实一根一根算,再用一堆工程手段把成本压下来。

0.1 头发为什么难做

把头发和普通物体(比如一个箱子)比一下,难点有三个:

  1. 多。一头长发动辄十几万根。箱子只有一个,头发有十几万个"独立的东西"都是软的。
  2. 细。一根头发比屏幕上的一个像素还细。也就是说,用普通的"画三角形"的方式,很多头发根本挤不进一个像素里,会互相盖住、闪烁。
  3. 软。它不是刚体,每一根会甩、会相互纠缠、会被风带着动、会透光(不然一大把头发会像一堵黑墙)。

这三点决定了:头发既不能用"画普通模型"的办法算,也不能用"算一个刚体"的办法算,需要一套专门的东西。

0.2 常见的两种做法

做法思路优点缺点
发片 / 发卡在头皮上贴几片带头发图案的透明卡片便宜、能大量用不是真头发,动起来假,近看穿帮
真发丝(UE Groom 走的这条)每根头发当成一根会动的细线,逐根算真实、能甩能碰撞贵,需要专门管线

UE 的 Groom 属于后者。它并不排斥前者:同一条头发也可以按远近切换成"发卡",甚至用一片纹理来画(后面第 8 章讲)。

0.3 一根头发在引擎里长什么样

要"算"头发,第一步是把它变成能存、能改的东西。做法是:把一根连续的长头发,切成有限的一小段一小段,每一段的接头处记两件事——位置和朝向。

可以把一根头发想象成一根串珠手链:

  • 珠子 = 头发上的采样点,它记住自己"在哪"(位置)。
  • 珠子之间的绳子 = 一段头发,它记住相邻两颗珠子的"距离、方向、相对角度"。
  • 每颗珠子上还别着一个小箭头(朝向),用来记录"这段头发是扭着的还是没扭"。

发根那颗珠子被钉在头皮上(跟着头动),发梢那颗珠子自由(可以乱飞)。头发怎么动,就变成了"这串珠子怎么动"。

0.4 整条流水线:三步

从"美术做好的头发"到"屏幕上会动的头发",一共就三步:

  • 第一步产出的是"静止姿态"的头发(还没受力时的样子),它决定了后面所有计算"往哪儿弹回去"。
  • 第二步是这篇前半(第 1~4 章)的主题:一根头发怎么算。
  • 第三步在第 8 章详述:这么细的东西怎么画。

本章小结:头发难在"多、细、软",所以不能当普通模型画,也不能当刚体算。UE 的做法是把每根头发切成有限段,每段接头处记"位置 + 朝向",于是"头发怎么动"被转化为"这串珠子怎么动"。整条流水线只有三步:准备数据、每帧计算、画出来。


第 1 章 把一根头发变成能算的东西

一句话:一根头发只会三种变形(拉伸、弯曲、扭转);要算它,就得把它切成有限段、给每颗珠子补上一个"朝向",再配上"重量"。

1.1 头发只会三种变形

不管你多用力扯头发,它只会发生三件事:

变形白话有多难发生对观感的影响
拉伸(沿长度方向拉长 / 压短)把头发当橡皮筋拉几乎拉不动(头发几乎不可压缩)拉长了就露馅,所以规则要定得"很硬"
弯曲(横着折)把头发弯成一个弧形很容易,随便一甩就弯决定头发是蓬松还是贴头
扭转(绕着自身的轴拧)拧毛巾很难看出来,但影响很大决定头发弯完之后"往哪边弯",从而改变高光走向

关键点:这三件事的"软硬程度"差了非常多个数量级。头发几乎拉不动(拉伸很硬),却很容易弯(弯曲很软)。所以"用一根弹簧来代表头发"是不行的——那根弹簧要么被拉长(违反"拉不动"),要么硬得像钢丝(弯不起来)。

正确做法是分开建模:拉伸、弯曲、扭转各算各的。[7]

1.2 把头发切成有限的段

真实的头发是一条连续曲线,上面有无数个点,计算机没法一个个处理。必须把它切成有限段。[1]

切完之后,一根头发由三样东西描述:

  • 珠子(节点):记位置和朝向,是"可以被改"的最小单位。
  • 绳子(边):记相邻两颗珠子之间的距离、方向、相对角度,三种变形规则都作用在它上面。
  • 静止姿态(rest pose):还没受力时的样子,是所有规则"弹回去"的目标——就像橡皮筋松手后要回到原长。

代价:段数决定精度。段切得少,头发就表达不出细腻的层次(看起来一根根是直的);段切得多,效果好但更贵。这个"每根头发切几段"是后面最常调的旋钮之一。

1.3 为什么每颗珠子还必须记"朝向"

只记位置不够。举个反例:这两种情况仅靠位置无法区分:

只看两端位置,这两件事长得一模一样:

  弯了:   o        o        o   (中间那颗偏离了直线)
  转了:   o        o        o   (整根被扳到了另一个方向)

两者都表现为"端点位置变了"。
  • 如果只有位置,“这根头发弯了"和"整根头发被转了个角度"分不出来。
  • 一旦每颗珠子带上"朝向”(那个小箭头),两件事就能区分了:弯是"箭头和绳子不一致",转是"箭头自己也一起转了、和绳子仍然一致"。

正因为有了朝向,弯曲和扭转才有可定义的对象。[7]

1.4 朝向怎么沿着头发一路传下去

这里有个陷阱。假设你从头到尾给每颗珠子都"凭切线随便定一个朝向"——朝向里包含一个"绕头发自身转了多少"的分量,这个分量是自由的、没有唯一答案。结果就是:朝向会沿着头发一路越拧越麻花,最后算出来的头发是"扭成麻花"的。

解决办法叫平行移动框架:每次只按"相邻两段之间最小的那个旋转"去更新朝向,绝不额外引入绕自身的转动,于是不会累积出虚假的扭转。[5]

它有两种用法:

  • 一开始初始化时:必须从头(发根)到尾(发梢)一根一根顺着传,保证整根头发上的朝向是连贯的。
  • 每帧计算时:只按相邻珠子局部修正,这样才可以并行(每颗珠子各算各的,不用等别人)。

1.5 每颗珠子有多重

最后一件事:给每颗珠子配上"重量"和"转动惯量"(可以理解成"转起来有多费劲")。

为什么需要?因为规则被违反时,要决定"谁动多少"。直觉是:轻的动得多,重的动得少。发根那颗珠子通常"很重"(几乎不动),发梢那颗"很轻"(到处乱飞)。引擎用的正是这个直觉——按"重量/转动惯量的倒数"来分配修正量。

注意有两套,别混:

  • 管位置的规则,看重量(质量)。
  • 管朝向的规则,看转动惯量。

在 UE 里,这两套量由数据接口的 ComputeNodeMass 与 ComputeNodeInertia 提供(第 7 章细讲)。

本章小结:一根头发只会拉伸、弯曲、扭转三种变形,而且三者软硬差距极大,必须分开算。要能算,就把它切成有限段:珠子记位置和朝向,绳子记两颗珠子之间的关系,静止姿态是弹回的目标。朝向是必须的,否则"弯了"和"整根转了"无法区分;朝向的传播要用平行移动框架,避免沿头发累积出虚假的扭转(初始化时串行传、每帧并行局部修正)。最后给珠子配上重量和转动惯量,让规则被违反时按"谁轻谁多动"来分配修正。


第 2 章 让头发动起来:不"用力",而是"拉回去"

一句话:与其给珠子加力(容易算炸),不如先规定"头发本来应该是什么样",每帧把偏离的地方按比例拉回去。这就是 UE 求解器全部规则的共同做法。

2.1 为什么不直接"加力"

最直觉的想法是:把每段头发当成一根弹簧,受力就伸长/弯曲,算出力再让珠子动。

但弹簧有个麻烦:“有多硬"和"每帧算多长"是绑死的。弹簧越硬,就越需要把每帧切得非常非常小来算,否则会算飞(数值发散)。而头发恰恰需要"拉伸极硬、弯曲极软”——硬的那部分会把整根头发逼到用极小步长,成本爆炸。[3]

2.2 换个思路:写成"规则",再把违反的地方拉回去

换个问法:不问"力有多大",而问"本来应该是什么样,现在差了多少,把差的那部分补回去"。

这就是约束的思路。一条约束就是一句"应该满足的话",比如:

"这两颗珠子之间的距离,应该等于静止时的距离。"

如果现在被拉长了 3 厘米,那就把这两颗珠子往回推一点,让距离缩短一些——不是一次推到位,而是每次推掉一部分。一帧里把这条规则重复推很多遍(叫"迭代"),结果就越来越接近满足。[3]

这种"先把位置预测出来、再按规则反复拉回去"的做法,英文叫 PBD(位置动力学);UE 用的是它的改进版 XPBD。

2.3 一条规则上要写清楚的三件事

要写清楚的白话影响
规则是什么“距离应该等于静止长度”决定头发长什么样
有多软叫柔度(compliance):软的就少推点,硬的就多推点决定头发是硬挺还是绵软
影响谁由参与珠子的"重量/转动惯量的倒数"决定轻的动得多,重的动得少

其中"有多软"这一项,UE 会先把它除以"每步时长的平方"再用。为什么要这么绕?为了让同一套参数在不同帧率下含义一致——不想出现"帧率越高头发越硬"这种事。[4]

但要澄清一个常见误解:这样做并不代表"结果和帧率无关"。因为每帧迭代次数是固定的,步长变大时,同样的迭代次数收敛得更不充分,头发还是会变软、变滞后(第 4 章展开)。

2.4 拉伸、弯曲、扭转其实是同一件事

回到第 1 章那三种变形。实现上它们是同一个公式,区别只在"这条规则管几颗珠子":

规则管几颗珠子规则在说
拉伸相邻两颗你俩的距离别变
弯曲隔一颗,管三颗你这个弯的幅度别超
扭转隔两颗,管四颗你俩的"箭头"别相对拧太多

珠子越"轻"、规则越"软",这条规则被违反时就越容易拉回来。UE 里这是同一个函数用不同参数(NodeOffset)实现的,第 7 章细讲。[7]

2.5 “箭头"的两种记法

因为有了朝向(箭头),“弯曲"就有两种写法:

  • 写法 A(看方向):把绳子方向拉回"父珠子的箭头转过静止角度后的方向”。简单、稳,弹簧一族用这种。
  • 写法 B(看相对旋转):直接规定"相邻两颗珠子的箭头之间应该转多少”。它一次就把弯曲和扭转都管住了,是"杆"一族的用法;麻烦在于:一个旋转用两个相反的四个数字(四元数)都能表示,取解时要挑对,否则会在某个角度突然翻车(俗称"双覆盖"问题)。[6][7]

UE 两套求解路线正好对应这两种(弹簧族 vs 杆族),第 7 章细讲。

2.6 阻尼:让它别抖

阻尼(damping)像"泡在水里"的阻力。它不改变头发最终该停在哪,只影响"多快停下来"。

  • 阻尼太小:头发来回弹、抖。
  • 阻尼太大:头发变得迟钝、像泡在糖浆里。

对毛发来说,合适的阻尼是"看着不抖、也不像泡酱里"——这取决于迭代次数和子步数(第 4 章)。

2.7 发根硬、发梢软

真实头发是渐变的:发根粗硬、发梢细软。实现里不是用一个常数,而是沿头发逐点采样成一张表——从头到尾,刚度/摩擦/粗细都按位置给不同值。

这张表的密度 = 每根头发切了几段。段数不够时,渐变会变成一格一格的阶梯——这就是"怎么调都不够自然"时最该看的旋钮。

本章小结:直接给头发加弹簧力不可行,因为"刚度"和"步长"绑在一起,硬的部分会把步长逼到极小。UE 改用"约束":先规定头发应该满足的规则,每帧把违反的部分按比例拉回去,迭代多次逼近。一条规则要写清楚"规则是什么、有多软(柔度)、影响谁(重量/惯量)"。拉伸、弯曲、扭转其实是同一个公式,区别只在管几颗珠子;朝向有两种记法(看方向的、看相对旋转的),后者同时携带弯曲与扭转但要处理"四元数二义"。阻尼不改变终点,只影响多快稳住;刚度参数沿头发渐变,由"切几段"决定其分辨率。


第 3 章 头发怎么和世界打交道

一句话:头发会被角色"甩"起来,是因为它是在跟着角色一起动的坐标系里被算的;而碰撞和风都用了更省事、但有代价的近似。

3.1 撞到身体

头发撞到头皮/身体时,要处理两件事:

  • 别穿进去:一旦珠子跑到身体里面,就把它推出来。
  • 擦过去的摩擦:贴着身体滑动时,按摩擦决定"黏住不动"还是"滑过去"。

实现上有两种:硬约束(直接推出去,简单但可能抖)和软约束(允许暂时穿一点点,换来更顺滑,代价是需要多迭代几下)。UE 两种都有(第 7 章)。[6]

3.2 头发和头发之间

十万根头发两两判断"撞没撞"是不可能的(组合数太大)。省事的做法是体素化:把所有头发的密度灌进一个三维网格(就像把头发塞进一堆小格子里),格子之间互相"排斥",近似出头发互相推开的效果。

代价要说清楚:这种做法不产生具体的接触点和接触法线,所以它没有"逐点的摩擦"这回事;能分辨的最小间距由网格粗细决定——太粗会粘成一坨,太细又贵。UE 用一个速度网格 + 一个压力网格来做(第 7 章)。

3.3 风

把每一小段头发当成一根很细的圆柱,风对它的阻力是两项相加:

阻力 = 与速度成正比的部分(慢速时主导)
     + 与速度平方成正比的部分(快速时主导)

迎风面的大小,取决于"风的方向和头发方向的夹角"——正对着风时最粗,顺着风时最细。这也是为什么"甩得越快,阻力增长得越不成比例":快速时是平方项在起作用。

3.4 角色动,头发为什么会被甩

头发是在一个跟着角色一起动的坐标系里算的。于是角色本身的速度、转动、加速,就变成了作用在头发上的一股"假力"(就像公交车急刹车时你会往前倾)。

  • 好处:头发默认跟着角色走,不会因为角色一停就整体"留在原地"。
  • 前提:角色每帧的加速必须被正确地喂进去,否则头发会莫名其妙往某个方向飘。

要注意:喂进去的是每帧算出来的速度(用相邻两帧位置相减得到),不是动画师设计的速度曲线——所以它和帧率有关(第 4 章)。UE 里由组件的"线/角速度比例"和"瞬移阈值"控制(第 7 章)。

3.5 为什么一模拟头发就往下掉

美术摆好的头发姿态,通常不是"刚好被重力托住"的姿态。你一开模拟,重力立刻把它往下拽,头发就塌下来了,和美术想要的不一样。

常见解法:开模拟前,先让重力和头发自己达成一次平衡,得到一个"自然下垂"的姿态;然后把它和美术原始姿态按一个系数混合,当作新的"静止姿态"。说白了,就是把"往下掉"这件事本身定义成新的静止样子。UE 里对应"重力预载"(第 7 章)。

本章小结:头发是在跟随角色的坐标系里被算的,所以角色的运动变成一股"惯性假力",头发才会被甩起来;喂进去的是逐帧差分的速度,因此与帧率相关。碰撞分两条:和身体碰撞有"推出 + 摩擦",头发之间只用体素化近似排斥——不产生接触点和法线,也就没有逐点摩擦,能分辨的间距由网格粗细决定。风把每小段头发当细圆柱,阻力是"线性项 + 平方项",迎风面积随风与头发的夹角变化。静止姿态与重力天然矛盾,解法是先解一次重力平衡,再和原始姿态按系数混合,把"下垂"定义成新的静止姿态。


第 4 章 每一帧到底发生什么

一句话:一帧 = 先按力把珠子推一下 + 反复把规则拉回去 + 收尾;“换个帧率,头发手感就变了"的根子在"每步多长”,不在迭代本身。

4.1 一帧的三段

三段各司其职:第一段负责"顺着力动",第二段负责"别违反规则",第三段负责"收干净"。第 7 章会看到 UE 里这三段分别对应哪些函数。[3]

4.2 两个不同的旋钮:子步 与 迭代

它们常常被混为一谈,其实管的是两件不同的事:

旋钮白话主要影响成本
子步数把这一帧切成几段分别算甩得厉害、动得快时准不准(跟不跟手)与段数成正比
迭代数每段里把规则反复解几遍头发"软还是硬"(规则拉得牢不牢)与遍数成正比

一句话:子步管时间切多细,迭代管规则解多透。少子步会在快速甩动时"跟不上、穿透";少迭代会让头发整体发软、拉不住。

4.3 为什么换个帧率,手感就变了

这里要分清三个时间量:

  1. 一帧多长(delta):机器跑得快就短,卡一下就长。
  2. 每帧算一次:求解器每帧被调用一次,拿到的就是这一帧的 delta。
  3. 每一步多长:delta 会被一个上限拦一下(太长就截断),再按子步数平均分。

由此得到三个结论:

  • 超长帧会被截断:比如切关卡卡了半秒,求解不会让头发"瞬移半秒",而是让这一帧的模拟变慢。表现是"头发跟不上角色"——这是为了稳定付出的代价。
  • 改帧率 = 改刚度:因为每步长度直接来自 delta,帧率高时步长小、收敛充分(靠近设定的硬度);帧率低时步长大、收敛不足(更软更滞后)。这就是"同一份头发在不同机器上手感不一样"的来源。
  • 按"帧数"计数的东西会随帧率变:比如重置后有一段"启动缓冲",它是按帧数算的,60 帧下这段真实时长就是 30 帧下的一半。

成熟求解器的通常做法是两层补偿:位移大的帧自动多切几段(动态子步)+ 迭代数跟着步长放大,让等效手感不随帧率漂。UE 提供了 r.HairStrands.EnableAdaptiveSubsteps 等开关,但插件里没有做固定步长(第 7 章)。[4]

4.4 现象对参数

这张表是全篇"调参直觉"的来源:

你看到的大概是哪里的事先动哪个
整体发软、拉不住迭代不够,或规则太软迭代数、刚度/缩放曲线
抖、高频颤子步不够、阻尼太小、步子太大子步数、阻尼
穿模(穿过身体/自己)碰撞没开,或自碰撞网格太粗碰撞开关、网格粗细
像塑料、不蓬松弯曲太硬,或沿头发曲线太平弯曲刚度、弯曲缩放曲线
高速甩动跟不上子步不够,或 delta 被截断子步数、帧率

4.5 为什么这类计算适合显卡并行

规则之间会互相牵扯(同一颗珠子被好几条规则用到),所以不能无脑并行。做法叫分组着色:把"不抢同一颗珠子"的规则放进同一组,组里可以同时算,组与组之间排队。

一条规则管几颗珠子,就能分成几组——比如管相邻两颗的(拉伸)按 x % 2 交替,管三颗的(弯曲)按 (x / 2) % 2 交替,管四颗的(扭转)按 (x / 3) % 2 交替。这三个式子的取值都只有 0/1,所以三种约束实际都是两色(红/黑)交替,差别只在取模步长;经典红黑着色就是前者的特例。UE 的实现与此一致(第 7 章)。

还要记住一点:分组的顺序会影响结果——这就是"同样的参数,不同求解器结果略有差异"的原因之一。

本章小结:一帧分三段——按力预测、反复拉回规则、收尾修正。子步与迭代是两个不同的旋钮:子步把一帧切成几段(管大位移准不准),迭代把规则反复解几遍(管软硬)。手感随帧率变化的根子在"每步多长":delta 先被上限截断、再按子步均分,所以低帧率会让模拟变慢变软,“改帧率等于改刚度”;按帧计数的机制(如重置后的启动窗口)也会随帧率改变真实时长。规则之间互相牵扯,需要按"管几颗珠子"做分组着色,组内并行、组间排队,而且分组顺序本身影响结果。


下篇 · UE 是怎么把它实现的(第 5~11 章)

下篇把前 4 章的每一条道理,对应到 UE 的具体类名 / 函数名 / 资产字段。看不懂类名没关系:每章开头都有一句"白话导读"和一个"一句话"结论;标注"源码细节,可跳过“的部分跳过去也不影响理解。真正要动手的人重点看第 9 章(上手地图)和第 11 章(控制台速查)。

第 5 章 系统组成:UE 把它拆成了几块

一句话:UE 把这件事分成"准备数据、计算、画出来"三块,谁管哪份数据分得很清楚;其中"计算"这块的流程是写在 Niagara 资产里的,不在 C++ 里。

白话导读:前 4 章讲的是"物理上应该怎么算”,不依赖任何引擎。从这章起进入 UE 的具体实现,会出现大量类名(如 UGroomAsset)。看不懂类名就先看每节的白话部分,它们讲的是同样的道理。

5.1 三块与一条数据流

毛发插件叫 Groom,位于 Engine/Plugins/Runtime/HairStrands,内部又拆成 7 个模块,各管一摊:

模块管什么
HairStrandsCore资产、组件、绑定、每组实例、渲染代理、Niagara 数据接口
HairStrandsSolver求解组件(负责划分曲线、出设置)
HairStrandsDeformer用 DeformerGraph / Optimus 驱动形变
HairStrandsDataflow编辑期的 Dataflow 节点
HairStrandsEditor资产菜单、导入器、细节面板
HairStrandsRuntime高速缓存流送管理器的注册
HairCardGeneratorFramework从网格生成发卡的框架

而"画出来"这部分不在插件里,在引擎渲染器的 Engine/Source/Runtime/Renderer/Private/HairStrands。

各层各自拥有什么(源码细节,可跳过):

层类(文件)拥有什么
资产UGroomAsset(Source/HairStrandsCore/Public/GroomAsset.h)控制线/可见发丝/发卡/网格、插值、物理、渲染、LOD、材质
绑定UGroomBindingAsset(.../GroomBindingAsset.h)发根到网格三角形的预烘焙投影与权重
组件UGroomComponent(.../GroomComponent.h)资产/绑定/物理资产引用、Deformer、模拟设置、每组实例
每组实例FHairGroupInstance(.../GroomInstance.h)每组的 Rest / Deformed / Interpolation 等 GPU 资源
求解器UGroomSolverComponent(Source/HairStrandsSolver/Public/GroomSolverComponent.h)Deformer 实例、动态曲线划分、关联的 Groom 组件
渲染FHairStrandsSceneProxy(GroomComponent.cpp 内)与 Engine/Source/Runtime/Renderer/Public/HairStrandsInterface.h跨模块交接的 FHairStrandsInstance / FHairGroupPublicData

5.2 头发"怎么动"的三条路

一个发组怎么动,UE 提供了三条路,优先级从高到低、不是叠加:

  • 物理求解:真正会自己算头发怎么动的那条。由资产开关和组件覆盖共同决定(IsSimulationEnable)。
  • Deformer:不是物理解算,而是用一张"骨骼驱动图"来摆头发;资产开关是 FHairSolverSettings::bEnableDeformation。
  • 蒙皮绑定:默认路径。头发被"焊"在身体网格上,靠网格变形跟着动。绑定类型由 FHairLODSettings::BindingType 决定(NoneBinding / Rigid / Skinning,默认 Skinning)。

最容易踩的坑是:这三条路判定不满足时不会报错,而是"毛发不动 / 停在默认姿态"。看到"毛发没反应",先查这三条路的条件,而不是到处找 bug。

5.3 为什么把求解放在 Niagara 上

好处很直接:用 Niagara 就能在 GPU 上算、能通过"数据接口"把资产里的参数和网格喂进去、还能白拿它现成的调度和子步框架。

代价也很直接:求解的"流程编排"(先算什么、循环几次、怎么分支)写在了 Niagara 系统资产里,不在 C++ 代码里。于是:

  • 调试时在 C++ 里看不到"它到底迭代了几次",只能看资产配置;
  • 流程随二进制资产走,跨版本升级和版本管理比纯代码更麻烦。

这也是"同一个求解器组件,在不同项目里表现不一样"的根源。

5.4 三层时序

顺序由 tick group 推导,保证"在骨骼更新之后、渲染之前"发生。所谓"同一帧内",指的是渲染读到的是本帧算完的结果。

5.5 前半的量,在后半的哪里(对照表)

前 4 章的量后面对应的东西详见
珠子 / 绳子 / 朝向数据接口里的节点位置、边方向、节点朝向1.2 节 / 7.2 节
重量 / 转动惯量求解 shader 里的等效质量项1.5 节 / 7.2 节
柔度(有多软)资产里的弯曲/拉伸刚度与沿头发缩放曲线2.3 节 / 7.4 节
拉伸 / 弯曲 / 扭转同一族函数,按"管几颗珠子"区分2.4 节 / 7.2 节
“箭头"的两种记法弹簧族 / 杆族两条求解路线2.5 节 / 7.2 节
发根硬发梢软逐点参数表(32 个采样点)2.7 节 / 7.4 节
自碰撞体素近似速度网格 + 压力网格(同帧 ping-pong)3.2 节 / 7.3 节
风(线性 + 平方 + 迎风)外力计算入口3.3 节 / 7.2 节
惯性假力模拟空间 + 线/角速度比例 + 瞬移阈值3.4 节 / 7.4 节
重力预载按系数在当前/平衡姿态间插值3.5 节 / 7.4 节
子步 / 迭代资产与组件上的子步数、迭代数4.2 节 / 7.7 节
每步多长delta 截断后按子步均分4.3 节 / 7.7 节

本章小结:UE 把毛发分成"准备数据、计算、画出来"三块,按"谁拥有哪份数据"划边界。毛发怎么动有物理求解、Deformer、蒙皮绑定三条路,它们不是叠加关系,条件不满足时是静默失效而非报错。求解之所以放在 Niagara 上,是为了 GPU 计算、数据接口和现成的调度框架,代价是"流程编排"落在资产里而不在代码里。执行跨游戏线程、Niagara、GPU 三层,顺序由 tick group 决定。


第 6 章 数据从哪来:Groom 资产

一句话:屏幕上十万根头发,真正存下来、真正参与计算的只有少量"骨架”;其余是照着骨架"长"出来的,配置则以"发组"为单位。

白话导读:这章回答"这些头发是从哪来的、怎么存的"。如果你只想搞懂原理,记住一句就够:存的是少量骨架,画的是大量填充。

6.1 两级数据:控制线与可见发丝

  • 控制线(Guides):真正参与计算的少量"骨架头发"。
  • 可见发丝(Strands):屏幕上实际画出来的那一大把,每一根都记着"我最像哪根骨架、像多少",由骨架插值"长"出来。

两者的数量比就是"一根骨架撑起几根真发"。要注意的坑:插值数据一旦缺失,不会报错,而是让形变相关功能整体失效(头发停在默认姿态)——这是最容易被误判的故障。

6.2 发组:配置的最小单位

头发在资产里按"发组"组织(比如"刘海"、“后脑"各是一组)。每种配置都是一张数组,按组的编号一一对齐(第 0 张对应第 0 组,以此类推)(源码细节,可跳过):

数组装什么
HairGroupsInfo每组有多少曲线、多少控制线、最长多长
HairGroupsRendering材质槽名 + 几何/阴影/高级渲染设置
HairGroupsPhysics求解、外力、约束、发丝参数
HairGroupsInterpolation抽稀与插值设置
HairGroupsLOD每组的 LOD 设置
HairGroupsCards / HairGroupsMeshes发卡 / 网格源描述
HairGroupsMaterials材质槽(材质 + 名称)
HairGroupsPlatformData真正存几何的那份 Bulk Data

改组数会让这些对齐关系和绑定一起失效,这是修改资产时最容易出事的地方。

6.3 绑定:把发根钉到网格上

头发要跟着身体动,就得知道"每根发根落在身体网格的哪个位置”。UGroomBindingAsset 就是把这件事提前算好存下来:每根发根落在哪个三角形上、用哪几个顶点按什么权重插值(源码细节,可跳过:RootToUniqueTriangleIndexBuffer / RootBarycentricBuffer / MeshInterpolationWeightsBuffer 等,封装在 FHairStrandsRootBulkData)。

两件事必须知道:

  • 不提供绑定资产时,投影会在运行时现算,启动时有一笔较大的 GPU 开销——表现就是"第一次看到角色卡一下"。
  • 绑定资产里存的是源网格的三角形编号,所以跨骨架复用会出问题:绑定与网格的 LOD 数量不一致时会直接判定不兼容(回退成未绑定、毛发不动),而 LOD 数量一致时不会报错、却会给出错误的形变。

6.4 LOD 与"第一次进游戏卡一下"

LOD(远近不同精度)有三种模式:

模式行为
Default跟随项目设置
Manual用你手工设的每组 LOD
Auto按屏幕占比自动增减,忽略你手设的 LOD

另外,资产里的设置会被**编译成一份"派生数据"缓存在磁盘上。改动设置会改变它的"钥匙",从而触发重新生成——这就是“改完设置第一次进游戏卡一下”**的原因:它在现生成 PostLoad / PostEditChangeProperty 时触发重建。

本章小结:控制线是参与计算的少量骨架,可见发丝是由它插值长出来的一大把;插值数据缺失不会报错,而是让形变功能整体失效。配置以"发组"为单位、按编号对齐,改组数会连带失效。绑定资产把"发根落在网格哪个三角形、怎么插值"提前烘焙好,省掉运行时投影,代价是跨骨架复用会出错。LOD 有 Default / Manual / Auto 三种,Auto 会忽略手工设置;派生数据的重建就是"改完设置第一次进游戏卡顿"的来源。


第 7 章 怎么算:求解实现

一句话:求解器组件自己不算,它只负责"划分要算哪些头发、把设置准备好",真正的计算发生在 Niagara 系统图和 GPU 上。

白话导读:这章把第 2~4 章的每一条道理,对应到 UE 的具体类与函数。看不懂函数名就只看每节的结论句。

7.1 谁出设置、谁真正执行

这是最容易搞错的一点:UGroomSolverComponent 不是执行者。

做的事谁来做
准备设置(参数、覆盖)UGroomComponent::SimulationSettings + 资产里的 HairGroupsPhysics
划分"这帧要算哪些头发"UGroomSolverComponent::SelectDynamicCurves
把任务入队UGroomSolverComponent::SendRenderDynamicData_Concurrent
真正计算Niagara 系统(StableSpringsSystem / StableRodsSystem / 自定义)

它的 TickComponent 里只有"标脏 + 划分曲线",没有任何解算代码。走哪套求解系统由枚举 EGroomNiagaraSolvers 决定。

7.2 前 4 章的公式落在哪(源码细节,可跳过)

前 4 章的量对应函数(Niagara 数据接口 UNiagaraDataInterfaceHairStrands)
珠子位置 / 朝向ComputeNodePosition / ComputeNodeOrientation
重量 / 转动惯量ComputeNodeMass / ComputeNodeInertia
绳子(长度/方向/旋转)ComputeEdgeLength / ComputeEdgeDirection / ComputeEdgeRotation
静止姿态ComputeRestPosition / ComputeRestOrientation
预测(按力动一下)AdvectNodePosition / AdvectNodeOrientation
拉伸 / 弯曲 / 扭转Setup/Solve/Project{Stretch,Bend,Twist}SpringMaterial
杆族(相对旋转)Setup/Solve/Project{Stretch,Bend}RodMaterial
硬 / 软碰撞Solve/ProjectHardCollisionConstraint、Setup/Solve/ProjectSoftCollisionConstraint
风ComputeAirDragForce
重力与预载GetGravityVector / GetGravityPreloading

规则本身写在 Engine/Plugins/Runtime/HairStrands/Shaders/Private/ 下,统一用 Setup -> Solve -> Project(准备 -> 解 -> 收尾)三段命名:

文件提供什么
NiagaraHookeSpringMaterial.ush弹簧族(方向路线)
NiagaraCosseratRodMaterial.ush杆族(相对旋转路线)
NiagaraAngularSpringMaterial.ush角度弹簧族
NiagaraElasticRodMaterial.ush弹性杆(Darboux)族
NiagaraStaticCollisionConstraint.ush碰撞
NiagaraStrandsExternalForce.ush风
NiagaraDataInterfaceHairStrandsTemplate.ush把上面这些包装成数据接口入口

第 2 章说的"同一条规则管几颗珠子",在这里就是参数 NodeOffset:

NodeOffset = 0  ->  拉伸(相邻两颗)
NodeOffset = 1  ->  弯曲(管三颗)
NodeOffset = 2  ->  扭转(管四颗)

与 4.5 节的"分组着色"一致:拉伸按 x % 2、弯曲按 (x / 2) % 2、扭转按 (x / 3) % 2——三个式子的取值都只有 0/1,因此三者都是两色(红/黑)交替,区别只在取模步长。

“有多软”(柔度)不是资产上的一个字段,而是在准备阶段算出来的:

柔度 = 4.0 / (杨氏模量 * pi * 杆半径 * 静止长度 * 杆半径 * dt^2)

式中"杆半径"在源码里对应的参数名是 RodThickness(它被填进截面积公式中 R 这一项,即 面积 = pi * R^2 里的 R)。

那个 dt^2 就是 2.3 节说的"按步长平方归一化"。

7.3 自碰撞网格长什么样

自碰撞用两个数据接口:速度网格(UNiagaraDataInterfaceVelocityGrid)和压力网格(UNiagaraDataInterfacePressureGrid)。

它们都用同帧 ping-pong:一边读一边写会读到"写坏一半"的数据,所以准备了两块缓冲(CurrentGridBuffer / DestinationGridBuffer),写完之后要 Swap() 一下才能读。分辨率由 GridDimension 控制,通道数是固定的(速度网格 6 个、压力网格 18 个)。分辨率越高,能分开的头发越细,显存也按分辨率的三次方增长。

7.4 参数的三个来源与覆盖边界

参数可以来自三个地方,“改哪里才算数"要先分清楚:

  1. 资产(HairGroupsPhysics):基线值。
  2. 组件覆盖(UGroomComponent::SimulationSettings):只有当"覆盖总开关"打开时才参与。
  3. 求解器组件(FGroomSolverSettings)与 CVar:更外层的控制。

资产里能调的参数(源码细节,可跳过):

结构字段
FHairStretchConstraintStretchStiffness / StretchScale / SolveStretch / ProjectStretch / StretchDamping
FHairBendConstraintBendStiffness / BendScale / SolveBend / ProjectBend / BendDamping
FHairCollisionConstraintStaticFriction / KineticFriction / GridDimension / CollisionRadius / RadiusScale 等
FHairStrandsParametersStrandsSize / StrandsDensity / StrandsThickness / ThicknessScale 等
FHairExternalForcesGravityVector / AirDrag / AirVelocity

“发根硬发梢软"这张沿头发分布的表,对应数据接口里的 ParamsScale:

ParamsScale:32 * 4 个数
  四条曲线:拉伸缩放 / 弯曲缩放 / 半径 / 粗细
  以 VertexCoord = i / (StrandsSize - 1) 逐点采样
  每条曲线最多 32 个采样点

关键上限:每条曲线最多 32 个采样点;而"每根头发切几段”(StrandsSize)的可选值本身只有 2/4/8/16/32,上限正好也是 32。也就是说采样点数与段数一一对应、上限同为 32,精度因此被封死在 32 点——这是 2.7 节说的"怎么调都不够自然"的原因之一。

另外,同名参数可能分属不同路径:资产上的 SubSteps / IterationCount、数据接口里的 SubSteps(默认 5)/ IterationCount(默认 20)、组件上的 NumSubSteps(默认 2)/ NumSolverIterations(默认 5)是三套不同的东西,不能互相推导。

7.5 按距离决定"这帧算多少”

SelectDynamicCurves 按"离镜头多远"决定这帧要算多少根控制线:

距离比例 = clamp((距离 - 最近距离) / (最远距离 - 最近距离))
要算的控制线数 = (1 - 距离比例) * 总控制线数

一个反直觉点:划分的结果和帧率无关,但划分本身的消耗和帧率成正比——每帧都要重算一遍,距离没变结果一样,CPU 时间照花。

还要区分:“算多少”(模拟 LOD)和"画多少"(渲染 LOD)不是一回事。

7.6 生命周期与重置

阶段入口
创建 / 注册UGroomSolverComponent::OnRegister、CreateRenderState_Concurrent
每帧推进TickComponent -> SendRenderDynamicData_Concurrent
关联 GroomAddGroomComponent / RemoveGroomComponent
释放OnUnregister / DestroyRenderState_Concurrent

重置会在三种情况发生:初始化、瞬移(位移超过 TeleportDistance)、不可见/隐藏;数据接口还会在"网格资产被换掉"时触发重置。

重置后有一段按帧计数的"启动缓冲"(上限由 r.HairStrands.SimulationMaxDelay 控制,默认 4 帧)。因为按帧计数,这段的真实时长会随帧率变化(4.3 节)。

7.7 一轮求解的完整时序

哪些机制会随帧率变化:

机制随帧率变吗说明
delta 截断是超长帧被截断,模拟变慢
每步时长 dt是等于截断后的 delta 除以子步数
迭代顺序 / 分组否由曲线结构决定
划分结果否但它的开销随帧率增加
重置启动缓冲是按帧计数,真实时长随帧率变
固定步长没有实现插件里没找到固定步长;设置项提示的 “24 fps” 在源码里没有对应实现,以源码为准

本章小结:求解器组件只负责"出设置 + 划分曲线",真正的计算在 Niagara 与 GPU 上。前 4 章的每一条道理都能落到具体函数:三类规则在同一族 shader 里靠 NodeOffset 区分;柔度由杨氏模量、杆半径、静止长度和步长派生(dt 平方在分母);“发根硬发梢软"由最多 32 个采样点的逐点表承载,所以精度上限就是"每根切几段”。自碰撞用读写型数据接口做同帧 ping-pong,写完必须交换缓冲才能读。参数有资产、组件覆盖、求解器组件三层,“总开关没开"和"这个字段本来就不给覆盖"是两种不同的没生效。按距离裁剪的是"算多少”,且开销随帧率增长;重置后的启动缓冲按帧计数,时长随帧率变。


第 8 章 怎么画:这么细的东西怎么显示

一句话:头发比像素还细,直接按普通方式画会互相盖住、闪烁;所以 UE 换了个顺序——先搞清楚哪些像素上有头发,再只给这些像素上色。

白话导读:前面都在讲"算"。这章讲"画"。只要记住"先找哪些像素有头发、再只算这些像素"这一句,剩下都是细节。

8.1 三种画法(可切换)

画法怎么画真实度成本开关
曲线真的一根根画最高最高r.HairStrands.Strands(默认 1)
发卡用贴了头发图案的片中中r.HairStrands.Cards(默认 1,需启动时开)
网格直接当普通模型画低低r.HairStrands.Meshes(默认 1,需启动时开)

坑:强制某一种画法时,如果资产里没有那类数据,会什么都不显示(不是报错)。r.HairStrands.UseCardsInsteadOfStrands 的提示就明确说了这点。

8.1.1 Groom(发丝)与 Cards(发卡)之间怎么用 LOD 切换

三种画法不只"能手动强制切",更常规的用法是把它们做成一套 LOD 降级链:近处/主角用真发丝(Strands,最真最贵),拉远后自动换成发卡(Cards,中等),更远再换网格(Meshes,最省)。“怎么切"由 Groom 的 LOD 系统驱动:

  1. 每个 LOD 等级可指定一种画法:LOD 0 用 Strands、LOD 1 用 Cards、LOD 2 用 Meshes——画法本身就是 LOD 的一部分(9.5.2 节 的 Visible Geometry Type 就是在选当前 LOD 用哪种几何)。
  2. 靠"屏幕占比"触发:相机拉远,头发在屏幕上占的面积变小,一旦跌破该等级的 Screen Size 阈值,就自动降到下一级(Strands→Cards→Meshes)。LOD Mode(Default / Manual / Auto)+ Auto LOD Bias 决定这套判定是自动还是手写;Minimum LOD 钳制"最低允许降到哪一级”(9.5.2 节)。
  3. 资产必须有那级的数据:想切到 Cards,资产里必须先 Add Card asset;想切到 Meshes,必须先 Add Mesh asset。缺数据的等级一旦被选中,会什么都不显示——这是"远处头发突然没了"最常见的根因(9.5.4 节 / 9.5.5 节)。
  4. 渲染 LOD 与模拟 LOD 分开:换画法(渲染 LOD)降的是"怎么画";而"算多少根"由独立的模拟 LOD 控制(SelectDynamicCurves,7.5 节)。Cards/Meshes 通常不参与逐根求解,所以远处头发连物理也一起省了。

一句话:Groom↔Card 的切换 = “LOD 等级里把画法降级 + 屏幕占比触发 + 资产得有那级数据”。要启用,先确认资产里建了 Cards/Meshes 数据,再在 LOD 面板把对应等级的 Visible Geometry Type 设好、调 Screen Size 阈值;别直接强制 r.HairStrands.UseCardsInsteadOfStrands——那是不看距离的硬切换,没数据照样黑屏。

8.2 一条专用的流水线

毛发走的是自己的一套流程,入口在 Engine/Source/Runtime/Renderer/Private/HairStrands:

两个关键设计:

  • 先可见性、再着色:先把"哪个像素有头发、有几层"整理成一份很紧凑的清单,然后只给清单里的东西算光照。这样光的计算量正比于"真正看得见的头发",而不是"画了多少段"。
  • 按小块聚合(tile):把屏幕切成 8x8 的小块,只挑"里面有头发的块"去处理,没有头发的块直接跳过。这是毛发在大屏幕上还能跑得动的关键。

8.3 光照怎么打

头发和普通物体不一样,它会透光——一大把头发不是一堵黑墙。所以光照分三路:

光怎么处理
环境光 / 间接光用上面那份 3D 体素(密度网格)去"穿一遍",估算被头发挡住了多少
直射光的阴影用"深阴影":不是简单记"挡住/没挡住",而是沿光线分层记每层透过去多少
头发内部多次反弹提前算好一张表(LUT),运行时查表,省掉实时计算

头发的散射模型(光打到一根头发上往哪散)在源码里以 R / TT / TRT 等分量 + “双重散射"命名,本质是经典头发散射模型加近似,对应参考 8。

8.4 远近与"头发突然冒出来”

  • 远处的头发会按"页"流送(一页固定装多少根),加载/卸载的粒度正是"页"。所以不看细节时,一旦发生流送,表现是头发突然出现或突然消失,不是渐显。
  • CPU 侧还有两个开关决定"数据用完就扔"和"异步加载"(ReleaseAfterUse / AsyncLoading),它们是在内存和"第一次进场景卡顿"之间做取舍。

8.5 光追与抗锯齿

  • 光追:毛发可以参与光追(默认开,且需要资产主动勾选)。两个调优项是"每段拆成几个小包围盒"(拆得越多光追越快、构建越慢)和"光追时把头发半径缩放多少"。
  • 抗锯齿:毛发太细,会闪。可见性那一步会额外产出一张"遮罩"和"速度",交给 TAA/TSR 这套时序抗锯齿去处理,避免边缘闪烁和拖影。

8.6 几个"看起来像 bug"的开关

开关默认看起来像什么 bug
r.HairStrands.BoundMode0设为 1 时用网格包围盒判断可见性;头发大幅甩动时会被整块裁掉(像"毛发突然没了")
r.HairStrands.ComposeAfterTranslucency1控制毛发和半透明/景深的叠加顺序;设错会出现覆盖顺序错乱
r.HairStrands.UseCardsInsteadOfStrands0强制发卡;资产没发卡数据时什么都不显示
r.HairStrands.WriteGBufferData1毛发写不写 GBuffer;影响依赖 GBuffer 的后续效果

另有一个"说明和实现不一致"的例子:r.HairStrands.SwapType 的提示写 Default:1,实际默认是 2。遇到这种一律以源码为准。

本章小结:毛发比像素细,所以不走普通画法:先算可见性、按 8x8 小块聚合、只给"有头发的块"着色、最后合成。这个顺序就是它的性能设计——tile 聚合是关键。画法有曲线、发卡、网格三种,缺数据时会什么都不显示。光照分三路(体素间接光、深阴影直射光、LUT 多次散射),因为头发会透光。流送以"页"为粒度,表现是突然出现/消失。光追有两个调优项,细几何的抗锯齿靠可见性产出的遮罩交给 TAA/TSR。


第 9 章 上手:改哪里、会出什么问题、多贵

一句话:同一个效果往往在三个地方都能改,先弄清"改哪里才生效";再记住成本大致由哪些量相乘决定。

白话导读:这章是给真正动手的人看的"地图",遇到问题可以直接查表。

9.0 第一次上手:最小路径与最易踩的坑

如果你是第一次接 Groom,按下面 5 步走完,就能看到一个能动的、不出错的毛发(每步对应正文出处):

  1. 导入资产并烘焙绑定:把头发导成 UGroomAsset,并为角色网格烘焙一份 UGroomBindingAsset。不烘焙,首次显示会现算投影、卡一下(6.3 节)。
  2. 挂组件:给角色加 UGroomComponent,把资产和绑定填进去(5.1 节)。
  3. 确认"三条路"之一成立:开物理求解(IsSimulationEnable),或开 Deformer,或走蒙皮绑定。三条都不满足时毛发会静默停在默认姿态,不报错——这是新手"毛发没反应"的头号原因(5.2 节)。
  4. 开模拟后先做重力预载:否则头发会被重力直接拉塌,和美术摆的姿态不一致(3.5 节)。
  5. 看效果再按表调:软 / 抖 / 穿模 / 像塑料,分别对应哪个旋钮,见4.4 节的现象对参数表;“改哪里才算数"见下文的参数地图。

最易踩的 5 个坑(都不会报错,靠查条件定位):

现象根因定位
毛发完全不动 / 停在默认姿态三条驱动路都不满足,或插值数据缺失5.2 节 / 6.1 节
毛发不动或形变错乱绑定资产跨骨架复用 / 与网格不匹配6.3 节
强制发卡/网格后什么都不显示资产里没有那类数据8.1 节
改了组件参数却没变化覆盖总开关没开7.4 节 / 9.1 节
改完设置第一次进游戏卡一下派生数据在重新生成,是正常现象6.4 节

9.1 改哪里才算数(参数地图)

你想调的资产组件覆盖求解器组件全局开关
拉伸/弯曲刚度、沿头发曲线HairGroupsPhysics需"覆盖总开关"打开——
摩擦、自碰撞网格粗细HairGroupsPhysics同上——
子步数 / 迭代数资产里的求解设置—NumSubSteps(2) / NumSolverIterations(5)r.HairStrands.EnableAdaptiveSubsteps
算多少根(按距离)——MinLODDistance / MaxLODDistancer.HairStrands.MaxSimulatedLOD
总开关组的求解开关bOverrideSettings + bEnableSimulation—r.HairStrands.Simulation(1)

判据:资产是基线;只有"覆盖总开关"打开,组件上的值才会参与;CVar 在最外层统一钳制。

9.2 常见故障:约束 + 触发条件 + 引擎行为

写成"在什么前提下,出现什么条件,引擎会怎样”,方便直接对照:

类别前提触发条件结果
设置无效要覆盖生效覆盖总开关没开组件值被忽略,用资产的
设置无效字段可覆盖这个字段本来就不给覆盖开了总开关也没用
设置无效要能形变插值数据缺失形变功能整体失效,不报错
没反应绑定要匹配绑定资产与网格不匹配/跨骨架复用毛发不动或形变错乱
没反应三条路要满足求解/Deformer 判定都不满足停在默认姿态
观感不对迭代要够迭代不足整体发软、拉不住
观感不对网格要够细自碰撞网格太粗头发互相穿插/粘连
观感不对包围盒要够大BoundMode=1 且甩动大被错误裁掉
随时间变步长来自帧帧率低变软、变滞后
随时间变按帧计数帧率变化重置缓冲的真实时长改变

9.3 成本大概怎么算

求解侧 约等于 头发根数 x 每根切几段 x 迭代次数 x 子步数 x 每秒帧数
渲染侧 约等于 头发段数 x 屏幕占用(涉及的 tile 数)

两侧的瓶颈通常不同:求解吃"头发规模 + 迭代",渲染吃"屏幕覆盖率"。想加效果时,先判断瓶颈在哪一侧,再决定加曲线/段数(更细)还是加迭代/子步(更稳)。

9.4 出问题先看哪

入口看什么
r.HairStrands.Dump / .GroomAsset / .GroomComponent打印资产/组件里到底有什么
r.HairStrands.ViewMode切换各种调试视图(LOD 着色、控制线、朝向、AO、深阴影 等)
r.HairStrands.BindingValidation检查绑定是否匹配
r.HairStrands.DeepShadow.DebugMode看深阴影
r.HairStrands.PlotBsdf看头发散射函数

注意"字段在、但提示与行为不一致 / 存疑"的情况(如 SimulationFrameRate 存在但未被使用、SubSteps 的 “24 fps” 提示无固定步长实现、r.HairStrands.SwapType 提示与默认疑似不一致;以下均基于 UE 5.6 源码观察,需在高版本复核):这类清单统一汇总在11.2 节,遇到一律以你所用版本的源码和资产为准,别被提示误导。

9.5 界面上的选项逐一说明(附全部面板截图)

GroomAsset 编辑器各面板都藏着性能选项。决定"这套毛发整体贵不贵"的规模类选项(发量、每根段数、控制线数、碰撞网格、求解密度)在 Strands / Interpolation / LOD / Physics(StrandsParameters) ;决定"每帧怎么算得更准更省"的在 Physics(迭代 / 子步 / 约束开关)。 下文按面板逐一说明每个字段,⚠=影响运行性能(影响不分大小都写入,并给出相对量级:高 / 中 / 低——量级是按 9.3 节 成本模型推导的相对排序,非官方数字)。截图取自 UE 5.6 的 MetaHuman Groom 示例资产。

9.5.1 Strands(发丝渲染)——发量规模类

Strands 面板

字段(英文 · 译文)作用性能影响
⚠ Pt/Curve(每根节点数)每根头发的曲线节点/细分数量高:决定发丝几何细分,直接进"每根段数"成本项
⚠ Hair Shadow Density(发丝阴影密度)阴影采样密度中:越高阴影越贵
⚠ Use Hair Raytracing Geometry(使用光追几何)是否用真实发丝做光追中:光追开关
⚠ Voxelize(体素化)自遮挡/体积化预计算中:构建期成本(8.5 节)
⚠ Use Stable Rasterization(稳定光栅化)光栅化稳定性中:渲染路径成本
⚠ Scatter Scene Lighting(散射场景光照)发丝间散射光照中:光照成本
⚠ Raytracing Radius Scale(光追半径缩放)光追射线半径低-中:光追相关
Hair Root Scale / Hair Tip Scale(发根/发梢宽度比例)根部/梢部粗细⚠(间接)低:越粗光栅面积越大
Max. Width(最大宽度)发丝宽度上限⚠(间接)低:同上

9.5.2 LOD(细节等级)——算多少 / 画多少

LOD 面板

字段(英文 · 译文)作用性能影响
⚠ Minimum LOD / Hair LOD(最小 LOD)最低允许的 LOD 等级,设定"再怎么降也保底多少"高:决定最低渲染/模拟规模
⚠ Curve Decimation / Vertex Decimation(曲线/顶点抽稀)LOD 时的抽稀比例中-高:降采样后规模
⚠ LOD Mode(Global / Auto)+ Auto LOD Bias(自动偏移)是否启用自动 LOD 系统中:开关
⚠ Visible Geometry Type(可见几何类型:Strands)用哪种画法显示中:Strands 最贵,Cards/Meshes 更省(8.1 节)
⚠ Simulation(RBF Interpolation)模拟是否随 LOD 降精度中
⚠ Angular Threshold(角度阈值)/ Screen Size(屏幕尺寸)LOD 切换判定条件低
Binding Type(绑定类型:Skinning)绑定方式低-中
Thickness Scale(厚度缩放)观感低

9.5.3 Interpolation(控制线 / 插值)——求解骨架规模

Interpolation 面板

字段(英文 · 译文)作用性能影响
⚠ Imported Guides(导入控制线数,Min / Max)参与求解的"骨架"曲线数量高:直接决定物理求解规模(6.1 节 / 9.3 节)
⚠ Curve Decimation / Vertex Decimation(抽稀)控制线降采样中-高
⚠ Interpolation Quality(High / Parametric / Smooth / Transform)从控制线插值出全头发的质量中:High 最贵
⚠ Generated guide density(生成控制线密度)自动生成控制线的密度中
⚠ Rigged guide num curves/points(绑定控制线数/点数)绑定驱动用的控制线规模中
⚠ Enable Guide-Cache Support(启用控制线缓存)控制线结果缓存中:内存/带宽
⚠ Hair Interpolation / RBF Interpolation插值算法选择低-中
Interpolation Distance(插值距离)插值影响半径低
Randomize Guide / Use Unique Guide控制线随机化/独立化低(观感/一致性)
Guide Type / Hair Interpolation Type / Rigged Skeletal Mesh控制线来源与类型低

9.5.4 Cards(发卡)

Cards 面板

字段(英文 · 译文)作用性能影响
⚠ Add Card asset(添加发卡资产)是否提供发卡数据;有则运行时可用发卡画法中:发卡通常比 Strands 曲线便宜(8.1 节)

9.5.5 Meshes(网格)

Meshes 面板

字段(英文 · 译文)作用性能影响
⚠ Add Mesh asset(添加网格资产)是否提供网格数据;有则可用网格画法中:网格通常是最便宜的画法(8.1 节)

9.5.6 Dataflow(数据流)

Dataflow 面板

字段(英文 · 译文)作用性能影响
Dataflow Asset / Dataflow Terminal(数据流资产 / 终端)资产生成管线(GroomAssetTerminal)构建期成本,运行时无直接开销

9.5.7 Material(材质)

Material 面板

字段(英文 · 译文)作用性能影响
Material Slots(材质槽)绑定渲染材质⚠(间接)低:着色复杂度由材质决定

9.5.8 Binding(绑定)

Binding 面板

字段(英文 · 译文)作用性能影响
⚠ Bindings(绑定资产,如 GB_Hair_MyGroom)烘焙的绑定/投影资产中(启动/首次显示):不烘焙则每次现算投影会卡(6.3 节)

9.5.9 Physics(物理)· 上:求解器与外力

Physics 上半面板

字段(英文 · 译文)分组作用性能影响
⚠ Sub Steps(子步数)SolverSettings每帧切成几段分别算,成本 ≈ 段数 × 每段计算高
⚠ Iteration Count(迭代数)SolverSettings每段内把规则反复解几遍,成本 ≈ 遍数 × 每遍计算高
⚠ Enable Niagara Simulation(启用 Niagara 模拟)SolverSettings是否物理求解高(开关)
⚠ Enable Deformer Simulation(启用 Deformer 模拟)SolverSettings是否走 Deformer 驱动路中(开关)
⚠ Niagara Solver(Niagara 求解器:StableSpringsSystem)SolverSettings选求解系统中(Springs 轻 / Rods 重)
Gravity Preloading(重力预载)SolverSettings一次性预计算平衡姿态低
Gravity Vector(重力向量)/ Air Drag(空气阻力)/ Air Velocity(风速)ExternalForces重力与风,外力项低(观感为主)
Force Visible(强制可见)SolverSettings强制显示无性能意义

9.5.10 Physics(物理)· 下:约束与求解参数

Physics 下半面板

字段(英文 · 译文)分组作用性能影响
⚠ Solve Collision / Project Collision(求解/投影碰撞)CollisionConstraint碰撞约束阶段开关;碰撞是最贵的一类约束中-高
⚠ Solve Bend / Project Bend(求解/投影弯曲)BendConstraint弯曲约束阶段开关中
⚠ Solve Stretch / Project Stretch(求解/投影拉伸)StretchConstraint拉伸约束阶段开关中
⚠ Grid Dimension(碰撞网格分辨率)CollisionConstraint自碰撞网格;显存与计算 ≈ 按分辨率三次方增长高(7.3 节 / 3.2 节)
⚠ Strands Size(求解每根点数)StrandsParameters物理求解用每根头发节点数高:求解规模核心
⚠ Strands Density(求解密度)StrandsParameters物理求解用头发密度/根数高:求解规模核心
⚠ Collision Radius(碰撞半径)/ Radius Scale(半径缩放)CollisionConstraint碰撞半径;越大参与碰撞的对越多中
⚠ Strands Viscosity(粘度)CollisionConstraint求解阻尼项低-中
Bend/Stretch Stiffness + Stiffness Scale(刚度 + 缩放)Bend/StretchConstraint软硬/蓬松;刚度太高会收敛不足、需加迭代⚠(间接)低
Bend/Stretch Damping(阻尼)Bend/StretchConstraint防抖低
Static Friction(静摩擦)/ Kinetic Friction(动摩擦)CollisionConstraint摩擦行为低
Strands Smoothing / Strands Thickness / Thickness ScaleStrandsParameters平滑/粗细观感低

9.5.11 Preview settings(编辑器预览)——非运行时

编辑器预览设置

字段(英文 · 译文)作用性能影响
Lighting / Post Processing / Rendering Features(Path Tracing 等)仅编辑器预览窗口的照明与后期只影响预览帧率,不进入运行时资产

调参直觉(更新):想大幅降本,先在规模类动手——降 Strands Pt/Curve、Interpolation Imported Guides、Physics Strands Size/Density、Collision Grid Dimension,或切 LOD / Cards / Meshes 画法;想每帧更省,动 Physics 的 Sub Steps / Iteration Count / Solve·Project 开关。观感旋钮(刚度、阻尼、摩擦、重力、风、粗细)几乎不省钱。标注"(推断)“的是按计算模型推导、非官方明示的结论;标注”(间接)“的表示通过影响别的东西(如收敛、光栅面积)间接产生成本。截图均取自示例资产,字段默认值随资产不同。

本章小结:同一效果可在资产、组件覆盖、求解器组件、CVar 多处调,“改哪里生效"靠参数地图:资产定基线,覆盖总开关打开后组件值才生效。故障可统一写成"前提 + 触发条件 + 结果”:设置无效来自总开关/字段范围/插值缺失,没反应来自绑定不匹配或三条路都不满足,观感与时间异常来自迭代不足、网格过粗、包围盒过小或帧率变化。成本大致是"求解侧相乘"与"渲染侧相乘”,加效果前先定位瓶颈在哪一侧。


第 10 章 和别的方案比:Groom 与 gFur

一句话:UE Groom 是"真发丝 + 逐根物理求解"的重路线,gFur 是"发壳 + 骨骼级近似"的轻路线;一个追求"动得对",一个追求"看起来像、还很省"。

白话导读:这章把两条路线并排比。结论先给:它们不是替代关系,而是互补——远景/群体用 gFur,近景/主角用 Groom。下面分设计、性能、工程三块看。

对照对象是市场插件 gFur(GiM,自述 “Shell based fur plugin”,官方支持 UE 5.0~5.4、另有 UE 4.27 版;本地源码在 Source/GFur、Source/GFurEditor、Shaders/Private)10。

10.1 设计对比

维度UE GroomgFur
方案类型真曲线毛发(strand-based)发壳毛发(shell-based)
几何数据控制线 + 可见发丝,逐根独立曲线GrowMesh 顶点沿法线复制成 N 层壳(层数 1~128),顶点多带一个 FurOffset 与几组 UV
引导控制线(Guides)承载形变与模拟可选 FurSplines:可导入 ABC、程序生成,或用引导网格
梳理导入 groom,再在编辑器里梳理/调物理编辑器笔刷直接改 splines(长度/弯曲/簇/扭转/噪声/放松/增删)
驱动路径三条路:物理求解 / Deformer / 蒙皮骨骼运动 -> 顶点偏移(自定义 vertex factory),另支持 Morph Target
动态(“物理”)逐根约束求解(XPBD / Cosserat 杆),带朝向,能表达弯曲与扭转骨骼级近似:每帧算骨骼的线/角速度与偏移,沿发丝按分布曲线施加
自碰撞有(体素速度/压力网格近似)无
扭转显式表达无法表达
LOD三种几何表示(曲线/发卡/网格)+ 曲线 LOD + 按页流送每级壳层数 + 切 GrowMesh 的 LOD + 每级可单独关物理
光照 / 阴影 / 光追专用 pass:体素间接光 + 深阴影 + 多次散射表 + 原生光追走常规材质渲染,靠材质近似;无专用透光、深阴影、光追
参数面大:资产 / 组件 / 求解器组件 / CVar 四级小:组件 + 材质为主

10.2 性能对比

维度UE GroomgFur
动态成本量级曲线数 x 每根段数 x 迭代数 x 子步数 x 帧率骨骼数 x 顶点数(约等于壳层数 x GrowMesh 顶点数)
渲染成本量级曲线段数 x 屏幕占用(tile 数)壳层数 x 顶点数 x 覆盖像素
主要瓶颈求解吃曲线规模与迭代;渲染吃屏幕覆盖率几何量主导(层数 x 顶点数),层数是最直接的旋钮
时间性步长来自帧 delta(截断后按子步均分),对帧率敏感每帧一次、由骨骼运动驱动;稳定,但没有"求解收敛"这回事
缩放手段降曲线/段数、降迭代/子步、按距离裁剪、切发卡降层数、切 LOD、关物理、用最小屏幕尺寸让它消失
平台跨平台(Windows / macOS / Linux / 主机等)开发平台仅 Windows(Win32/Win64);目标平台 Win64(DX11)、PS4、Xbox One(可经 whitelist 扩展 Switch)

读法:Groom 的成本是"多项相乘"(曲线 x 段数 x 迭代 x 子步 x 帧率),gFur 的成本主要压在"几何量"(层数 x 顶点数)。让 gFur 变便宜,第一件事就是减层数;让 Groom 变便宜,要先判断瓶颈在求解侧还是渲染侧(见第 9 章的成本模型)。

10.3 工程与集成对比

维度UE GroomgFur
集成步骤导入 groom、烘焙绑定、配置 Niagara 系统加组件 + 指定 GrowMesh + 套材质
依赖Niagara / ComputeFramework / Chaos / Dataflow / Deformer仅 Core / Engine / RHI 等基础模块
与对方的关系可接收 gFur 导出的 groom可把 splines 导出给 UE Groom(示例内容自带 UE_Hair 资产)

10.4 怎么选

你的场景建议
主角近景,要真实甩动、透光、光追用 Groom
大量个体 / 远景 / 毛发团块,要省用 gFur
既要近景真实又要群体便宜混用:远景发壳、近景 Groom

设计侧重一句话:gFur 侧重"看起来像"——把钱花在分层观感(壳偏置、噪声、材质光照)上,动态用骨骼级近似换,参数故意做得很少、谁都能接;Groom 侧重"动得对"——把钱花在逐根约束、时间基准、自碰撞和光照/阴影/光追的正确性上,代价是集成复杂、参数面庞大。

本章小结:两条路线的分野在"几何表示"与"动态粒度":gFur 用发壳 + 骨骼级近似,换来极低的集成与性能成本,代价是无法表达扭转与自碰撞、不参与透光/深阴影/光追、且仅限 Windows 开发(目标平台覆盖 Win64 与主机);Groom 用真曲线 + 逐根求解,换来物理正确与光照可扩展,代价是成本高、参数面大、集成重。二者互补:gFur 能把 groom 导出给 UE Groom,而 UE 自身也有发卡/网格这类廉价表示(8.1 节),所以"远景发壳、近景曲线"可以并存。


第 11 章 附录

11.1 源码文件索引

层次位置(相对引擎根)说明
资产与形变Engine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Public/(GroomAsset.h、GroomComponent.h、GroomInstance.h、GroomAssetPhysics.h、GroomAssetInterpolation.h)资产、组件、每组实例、物理与插值设置
绑定.../HairStrandsCore/Public/GroomBindingAsset.h、HairStrandsDatas.h、GroomBindingBuilder.h发根投影、权重、构建器
求解.../HairStrandsSolver/Public/GroomSolverComponent.h求解器组件、动态曲线划分
求解数据接口.../HairStrandsCore/Public/Niagara/(NiagaraDataInterfaceHairStrands.h、...VelocityGrid.h、...PressureGrid.h)参数与网格资源交给 Niagara
求解 shaderEngine/Plugins/Runtime/HairStrands/Shaders/Private/约束族、碰撞、外力
形变(Deformer).../HairStrandsDeformer/Public/(DeformerGroom*)Optimus / DeformerGraph 读写通道
渲染Engine/Source/Runtime/Renderer/Private/HairStrands/、.../Public/HairStrandsInterface.h可见性、tile、体素化、深阴影、合成
渲染 shaderEngine/Shaders/Private/HairStrands/光栅、散射、深透射率

11.2 控制台速查

分组代表变量默认作用
几何表示r.HairStrands.Strands / .Cards / .Meshes1/1/1三种画法开关(后两个需启动时开)
模拟r.HairStrands.Simulation / .EnableAdaptiveSubsteps / .SimulationMaxDelay / .MaxSimulatedLOD1/0/4/-1总开关、自适应子步、重置缓冲、模拟 LOD 上限
阴影与照明r.HairStrands.DeepShadow.Resolution / .KernelType / .DensityScale、r.HairStrands.SkyLighting2048/2/2/1深阴影分辨率与核、密度补偿、天光
可见性与光栅r.HairStrands.Visibility.ComputeRaster / .MSAA.SamplePerPixel / .PPLL、r.HairStrands.RasterizationScale0/8/0/0.5光栅路径与采样、吸附光栅缩放
LOD 与流送r.HairStrands.MinLOD / .AutoLOD.Bias / .Streaming.CurvePage / .StreamOutThreshold0/0.1/2048/2LOD 钳制与偏置、按页流送
内存r.HairStrands.Strands.BulkData.ReleaseAfterUse / .AsyncLoading / .Validation0/-1/1用完释放、异步加载、加载校验
光追r.HairStrands.Raytracing / .RaytracingProceduralSplits / .RaytracingRadiusScale1/4/0光追开关与两个调优项
体素化r.HairStrands.Voxelization / .DensityScale / .Virtual.VoxelPageResolution1/2.0/32体素化总开关、密度、页分辨率
包围盒r.HairStrands.BoundMode0运行时包围盒模式(0/1/2)
构建期r.HairStrands.ClusterBuilder.MaxVoxelResolution / .HairGroupBuilder.MaxVoxelResolution256/64聚类与组索引构建分辨率
调试r.HairStrands.ViewMode / .Dump / .BindingValidation—/—/0视图模式、数据打印、绑定校验

基于 UE 5.6 源码观察、待高版本复核的"字段/提示与行为不一致"情况(以下结论只对当前所见 5.6 成立,不保证高版本仍然如此,作者会在更高版本复核):FGroomSolverSettings::SimulationFrameRate——字段存在,但全项目搜索仅命中声明与 UHT 反射生成、无业务引用,蓝图亦搜不到,判为"未被使用";FHairSolverSettings::SubSteps——提示写 “solver calls done at 24 fps”,但源码里没有固定 24fps 步长的实现(求解仍按帧 delta),字段本身有效;r.HairStrands.SwapType——提示写 Default:1,实际默认值疑似为 2,待源码复核。

11.3 参考

  • [1]《计算机图形学基础》(Fundamentals of Computer Graphics, 5th ed.):曲线表示, 1.2 节。
  • [2]《计算机图形学基础》:计算机动画, 4.1 节。
  • [3] Position Based Dynamics(2007): 2.1 节 / 4.1 节。
  • [4] XPBD: Position-Based Simulation of Compliant Constrained Dynamics(2016): 2.2 节 / 2.3 节 / 4.3 节。
  • [5] There Is More Than One Way to Frame a Curve(1975): 1.4 节。
  • [6] Strands: Interactive Simulation of Thin Solids using Cosserat Models(2002): 2.5 节 / 3.1 节。
  • [7] Discrete Elastic Rods(2008): 1.2 节 / 2.4 节。
  • [8] Light Scattering from Human Hair Fibers(2003): 8.3 节。
  • [9] UE 5.6 引擎源码(HairStrands 插件、渲染器毛发 pass 集、着色器目录):以文件名 / 类名 / 函数名定位。
  • [10] gFur(GiM,Shell based fur plugin,官方支持 UE 5.0~5.4、另有 UE 4.27 版)。