壳层毛发(Shell Fur)靠把同一张网格沿法线偏移 N 次来叠出体积感。每层壳采样一张密度纹理做镂空,从侧面看就是一层层的毛。偏移的数学几行能写完,但要在引擎里把它做成一个完整可用的系统,需要回答的问题远比偏移公式多:层数怎么跟着 LOD 走,怎么让毛发只出现在角色该长毛的 Section 上,阴影和纹理流送怎么感知这些多出来的层,以及美术怎么在已有的材质槽和 LOD 面板上把这些配完。
在做这套系统之前,已有两种主流做法,但它们各自的代价在跨平台角色渲染的场景下不太能接受。
一、已有方案的问题
做这套系统之前用过两条路,都有各自的硬伤。
1.1 在 DCC 里烘 N 层壳模型
最直觉的做法。美术在 DCC 里把角色网格复制 N 份,逐层沿法线撑开、缩小、调色,N 层壳和本体一起蒙皮、一起导出、一起进引擎。后面动画蒙好皮,直接在引擎里使用。
造型上限很高,每一层壳的每个顶点位置都是手工可控的,额头的毛往左倒、背上的毛偏右、某一层比别的层长一截,这些全靠调顶点就能做到。
但层数烘死在资产里之后,后面所有和"运行时可变"相关的能力就全没了。远处角色减层得重做资产,画质档位切不了,LOD 联动做不到,动态层数控制更不可能。迭代也很痛苦:改毛长或者改方向要回 DCC,重新导出、重新蒙皮、重新导入。骨架稍微调一下,比如加一根骨骼或者改权重,N 层壳的蒙皮权重全要跟着重刷,N 层就是 N 倍的工作量。维护和迭代的线性成本是这条路最大的硬伤。
提交侧也是 N 倍膨胀:N 份顶点数据、N 个 Section、N 次 Draw Call。内存同理。
1.2 GFur(GFurPRO)
GFur 是一套完整度很高的壳层毛发方案。毛发造型、物理模拟、Spline 引导、LOD 分级全都做了,单看功能列表的话该有的都有。
核心做法是把每一层壳的顶点偏移烘成独立的 Vertex Element:
float3 FurOffset : ATTRIBUTE12;
每层壳有自己的顶点流,每个顶点带着自己的 FurOffset,顶点着色器直接把顶点推到该层该去的位置。造型自由度非常高,每一层可以 Sample 出不一样的 Pos,得到非常完美的 Fur 造型,方向、长度、噪声完全独立可控。
它还带了一整套毛发专用参数:ShellBias 做非线性壳分布(层数少时把壳往梢部推减少断层);MinScreenSize 控制毛发整体消隐距离;FurSplines 把 DCC 引导曲线带进引擎;物理部分用 ForceDistribution、Stiffness、Damping、MaxForce、ReferenceHairBias、HairLengthForceUniformity 描述受力行为。UGFurComponent 继承自 UMeshComponent,自己管 MasterPoseComponent 绑定、骨骼映射、Morph Target 同步,以及逐帧物理偏移。
问题在三个方面。
跨平台带宽与内存压力。每一层 Shell 都是独立的 Vertex Element。顶点数据随层数线性膨胀,8 层就是 8 份位置和偏移数据,每帧都要从顶点缓冲取。5 万顶点的角色、8 层壳,仅 FurOffset 一项就是 5万 × 8 × 12字节 ≈ 4.6MB 的额外顶点缓冲。场上 20 个角色,光毛发的顶点数据就接近 100MB。桌面上这笔开销还摊得开,移动端的带宽和显存都紧。
组件侵入性。UGFurComponent 和原有的 USkeletalMeshComponent 并行挂在 Actor 上,两套包围盒、两套可见性判定、两套组件 Tick。引擎的 LOD 切换、阴影投射、Batch 统计以组件为单位运作,对毛发组件要么重新实现要么绕过。
Art Friendly 不足。层数、LOD、ScreenSize、物理参数全部开在 GFur 组件面板上,和角色已有的材质编辑、LOD 设置分属两套工作流。LOD 相关的配置尤其不够直觉,美术要先找到这个组件、再理解它怎么算 LOD、怎么切层数。配错了排查起来也困难,毛发的显示问题可能出在 Grow Mesh 选错、MasterPose 没绑对、LOD 配置和主体不一致,这些都不在美术熟悉的面板上。
二、我们的方案
两条路背后是同一个假设:毛发的形状必须由几何承载。但壳层偏移只取决于法线方向和一个归一化的层深度,几何只需要画一份,N 层靠 Draw Instance 展开,形状控制交给材质。
2.1 核心思路
骨骼模型或静态模型上,相关 LOD 的不同 Slot 可以配置 Fur 开关、Fur 材质以及 Count Bias;每一层 LOD Setting 上可以配置 Fur Count。每一层壳由 NumInstances(Draw Instance)驱动,GPU 展开 N 次顶点着色,每次带一个不同的 SV_InstanceID,这就是层号。
所有层共享 VertexColor 里相同的 Tangent Fur Dir(切线空间毛发方向,美术用 Paint Module 刷上去)。材质里根据 InstanceID 对方向做权重,就能形成毛发朝某个方向弯曲生长的效果。代价是失去了每一层不一样的 Pos 形成的宏观造型细节。GFur 的逐层独立偏移能做到的精确造型,这套方案做不到。
接入方式是直接在骨骼模型组件或静态模型组件的 SceneProxy 中插入(类似 UE 5 的 Overlay Material 那样的附加数据),不引入新组件。美术在材质槽和 LOD 面板上就能配完,不需要学新组件;层数跟着 LOD 自动降;CPU 提交次数与层数脱钩。
2.2 挂载与数据结构
整套实现挂在 UMeshComponent 上,骨骼网格和静态网格共用同一套逻辑。总闸是网格资产上的 bSupportPerSectionMultipass,放在 LOD 设置分类里:
bool bEnablePerSectionMultipass; // 组件侧,由网格资产的 bSupportPerSectionMultipass 驱动
核心数据结构是一个 Map,Key 是美术在材质槽里配的那个原始父材质,Value 是按 LOD 索引的 MID 数组:
TMap<UMaterialInterface*, FMaterialArrayHandle> PerSectionMultipassMaterialMaps;
struct FMaterialArrayHandle
{
TArray<UMaterialInterface*> MaterialArray; // 下标是 LODIndex
};
用材质做 Key 而不是槽 index,因为槽的 index 会随增删重排而变,渲染侧的查找是从"当前 Section 用的材质"出发的,材质是稳定标识。每级 LOD 各一个 MID 是被层数的存储方式逼出来的:层数要随 LOD 变,层数变了材质里的参数就得跟着变,参数烘在 MID 上,所以每级 LOD 需要自己的 MID。
2.3 材质侧接口
毛发需要两样东西:一个用来画毛的材质,和一个层数。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = PerSectionMultipass)
int32 PerSectionMultipassDrawCount; // 期望层数
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = PerSectionMultipass)
UMaterialInterface* PerSectionMultipassMaterial; // 用来画毛发层的材质
挂在 UMaterialInterface 上,材质继承链能带着走。UMaterialInstance 先看有没有覆盖,没有就沿 Parent 上溯:
int32 UMaterialInstance::GetPerSectionMultipassDrawCount_Internal() const
{
if (bOverridePerSectionMultipassDrawCount)
return PerSectionMultipassDrawCount;
return Parent ? Parent->GetPerSectionMultipassDrawCount_Internal() : 0;
}
这意味着同一个母材质下,不同角色可以用不同的材质实例覆盖出不同的层数。
渲染线程拿不到 UMaterialInterface,只能拿 FMaterialRenderProxy,所以代理上也存了一份。同步发生在 UpdateMaterialRenderProxy 里,外层用 IsSupportShellFur 守门,没勾这个标记的材质不做同步。
2.4 预生成
层数逐 LOD 算出来,放在一个预生成步骤里。骨骼网格和静态网格各实现一份,逻辑几乎一样:
void USkinnedMeshComponent::CreatePerSectionMultipassData()
{
PerSectionMultipassMaterialMaps.Empty();
for (int32 MaterialIndex = 0; MaterialIndex < GetNumMaterials(); ++MaterialIndex)
{
if (UMaterialInterface* ParentMaterial = GetMaterial(MaterialIndex))
{
int32 DrawCount = ParentMaterial->GetPerSectionMultipassDrawCount_Internal();
if (ParentMaterial->GetPerSectionMultipassMaterial_Internal())
{
FMaterialArrayHandle Handle;
for (int32 LODIndex = 0; LODIndex < SkeletalMesh->GetLODNum(); ++LODIndex)
{
DrawCount = FMath::Min(DrawCount,
SkeletalMesh->GetLODInfo(LODIndex)->MaxShellFurLayerCount.Default);
if (DrawCount > 0)
{
UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(
ParentMaterial->GetPerSectionMultipassMaterial_Internal(), this);
MID->SetFlags(RF_Transient);
MID->SetScalarParameterValue(TEXT("G_LayerCount"), DrawCount);
MID->SetScalarParameterValue(TEXT("G_LayerStepSize"), 1.0f / DrawCount);
Handle.MaterialArray.Add(MID);
MID->AddToCluster(this, true);
}
}
PerSectionMultipassMaterialMaps.Add(ParentMaterial, Handle);
}
}
}
}
静态网格版本用 StaticMesh->RenderData->MaxShellFurLayerCount[LODIndex]。DrawCount 在循环里被持续收紧,后面的 LOD 只会比前面更少,层数随 LOD 单调不增。
MID 打了 RF_Transient 并 AddToCluster 挂到组件引用簇里,不做的话下一个 GC 周期就收走,表现是毛发画一阵停一阵。G_LayerCount 是总层数,材质拿它做归一化或逐层衰减;G_LayerStepSize = 1/N,注入端算好避免层数改了步进没跟上。
触发时机:BeginPlay(建数据 + 标脏)、CreateRenderState_Concurrent(编辑器里没有 BeginPlay,补一次,限定 Editor / EditorPreview 世界类型)、SetMaterial / SetSkeletalMesh(换了就重建,限定 Game / PIE)、OnUnregister / EndPlay(清空 Map)。编辑器和运行时分开处理,是因为同一个组件在两种世界类型下的生命周期不一样。
2.5 提交
代理构造时从组件拷一份 bEnablePerSectionMultipass 和 Map,渲染线程完全靠自己这份数据。毛发层的提交在 GetDynamicMeshElements 里紧跟原有 MeshBatch:
bool bCreateFurBatch = CVarSkeletalMeshPerSectionMultipass.GetValueOnRenderThread() == 1;
if (bEnablePerSectionMultipass && bCreateFurBatch)
{
FMaterialRenderProxy* Proxy = SectionElementInfo.Material->GetRenderProxy();
int32 DrawCount = FMath::Min(
Proxy->GetPerSectionMultipassDrawCount(),
MeshObject->SkeletalMeshLODInfo[LODIndex].MaxShellFurLayerCount.Default);
if (DrawCount > 0 && PerSectionMultipassMaterialMaps.Num() > 0
&& Proxy->GetPerSectionMultipassMaterial())
{
if (const FMaterialArrayHandle* Handle =
PerSectionMultipassMaterialMaps.Find(SectionElementInfo.Material))
{
if (const UMaterialInterface* FoundMID = Handle->MaterialArray[LODIndex])
{
FMeshBatch& FurBatch = Collector.AllocateMesh();
FurBatch = Mesh; // 顶点、索引原样复用
FurBatch.MaterialRenderProxy = FoundMID->GetRenderProxy();
FurBatch.CastShadow = false;
FurBatch.bSecondPass = false;
FurBatch.ReverseCulling =
IsLocalToWorldDeterminantNegative() || bBackFacePass;
FurBatch.bUseForMaterial = true;
FurBatch.Elements[0].NumInstances = DrawCount;
AddCollectorMesh(Collector, ViewIndex, FurBatch, LODIndex, SectionIndex);
}
}
}
}
FurBatch = Mesh 直接赋值,没有用 CreateBaseMeshBatch 从零拼,毛发层和原始 MeshBatch 用同一份几何,直接拷省掉一整轮填充。NumInstances = DrawCount 是整套做法的核心:一份 MeshBatch 在 GPU 上展开成 N 次顶点着色,CPU 侧只提交一次。层数从 4 变到 12 对 CPU 没有影响。
CastShadow = false,壳层进阴影贴图只会把同一份几何铺 N 遍,拿到的也不是正确的毛发剪影。bSecondPass = false 和引擎多材质通道机制隔离。bUseForMaterial = true 让流送和统计能看到这份 MeshBatch。ReverseCulling 处理角色镜像时 LocalToWorld 行列式为负导致的手性翻转。不写这一行,镜像角色的毛发层正反面会反。
静态网格走这条路有一个额外代价:bEnablePerSectionMultipass 为真时代理被标成 bDynamicRelevance,失去引擎对静态网格的按帧缓存路径。角色本来就是动态的所以无所谓;大批静态物体(草、装饰物)要长毛的话这笔账要先算。
2.6 着色器管线
材质上勾 bSupportShellFur 之后,标记经 FMaterialShaderParameters 带到顶点工厂编译环境:
OutEnvironment.SetDefine(TEXT("USE_SHELL_FUR"),
Parameters.MaterialParameters.bSupportShellFur == 1);
LocalVertexFactory 和 GPUSkinVertexFactory 各有一处。这是材质级的着色器排列,勾了的材质编译出来多一条取层号的路径,没勾的根本不存在。顶点输入里用宏控制:
#if USE_SHELL_FUR
uint InstanceId : SV_InstanceID;
#endif
材质图里通过 PerInstanceID 节点暴露层号:
float GetPerInstanceID(FMaterialVertexParameters Parameters)
{
#if USE_SHELL_FUR || USE_INSTANCING
return (float)Parameters.InstanceId;
#else
return -1;
#endif
}
同时挂在 USE_SHELL_FUR 和 USE_INSTANCING 下,壳层毛发用的就是实例化,两个条件是同一件事的两种来源。没有毛发时返回 -1 而不是 0:0 是合法层号,-1 让材质能区分"在第 0 层"和"没有层",同一套节点图两条路径都成立。只在顶点阶段可用,翻译器里发现别的频率调用会直接报错。
材质里的 World Position Offset:
Offset = normalize(FurDir) * (PerInstanceID * G_LayerStepSize * FurLength)
FurDir 来自顶点色,PerInstanceID 是层号,G_LayerStepSize 是 1/N,FurLength 是贴图或参数。偏移量逐层线性,要做别的分布在材质里对 PerInstanceID 做重映射,比如套幂曲线把层往梢部推,等价于 GFur 的 ShellBias。
这里有一个容易忘的陷阱:引擎构造骨骼网格代理时会逐 Section 校验 CheckMaterialUsage_Concurrent(MATUSAGE_SkeletalMesh),不过关就整段退回默认材质。毛发材质是独立一份,也要过这一关。如果本体材质合法但毛发材质没勾 Skeletal Mesh 用法,整个 Section 连本体一起变灰。带布料的 Section 还要过 MATUSAGE_Clothing。
2.7 层数的配置链
实际生效的层数是两处取最小:材质上的 PerSectionMultipassDrawCount(毛发规格:浓密体毛想要 12 层,胡须只要 4 层)和当前 LOD 的 MaxShellFurLayerCount(性能约束:这一级允许花多少)。另有一个 CVar 总开关只管开不开,不参与层数计算。
MaxShellFurLayerCount 挂在 LOD 数组上,类型是 FPerPlatformInt,每一级能按平台单独覆盖。静态网格自动生成:
MaxShellFurLayerCount[LODIndex].Default = 12 / (LODIndex + 1);
LOD0 给 12 层,LOD1 给 6,LOD2 给 4,LOD3 给 3。按倒数分配把预算花在近处,层数和视觉质量不是线性关系,12 降到 10 几乎看不出来,4 降到 3 断层很明显。
同一段代码里处理了 Mobile 的 MinLOD 补偿:移动端把 MinLOD 往上提省几何,层数如果直接按偏移后的索引取,同一视距下就会比桌面少。自动生成里做反向补偿,移动端选中第 Index 级 LOD 时取第 Index - MobileMinLOD 级的默认值。补偿前先清掉已有的 Mobile 平台覆盖再重算,函数幂等。整个函数被 bIsImporting 卡着,只在导入时跑,手调过的值不会被保存时覆盖。
骨骼网格走 LOD Group:FSkeletalMeshLODGroupSettings 里也有 MaxShellFurLayerCount,默认 12。bOverrideMaxShellFurLayerCount 勾上就用自己的值,不勾就跟随 LOD Group,同一类角色统一配,个别特殊处理时再单独开。FBX 导入给默认值 12,重新导入保留原值。
编辑器面板上,静态网格在 LOD 设置行里加了 Max ShellFur Layer Count(带平台覆盖),骨骼网格在 Persona 的 LOD 面板和 ScreenSize、LODHysteresis 排一起。总闸 Support PerSection Multipass 在资产 LOD 设置下。
2.8 方向与 Paint Module
壳层毛发只沿法线偏移的话看起来像毛刷。方向放在顶点色里。美术用 Paint Module 在模型上刷切线空间下的方向,写进 VertexColor 通道,所有层共享同一条基准方向。
选顶点色的理由:本来就要传的通道,不增加顶点缓冲大小也不增加布局元素;跟着蒙皮走,骨骼动了方向自动变形;FBX 带顶点色、引擎导入顶点色,导出链路成熟。另开 UV 通道或自定义顶点属性要动顶点布局,所有平台的顶点工厂、变体、压缩格式都要审一遍,不划算。
同一个顶点色通道里还能存长度和密度掩码,材质图读同一个顶点色、不同分量做不同的事。Paint Module 本身做读写顶点色、屏幕笔刷、法线朝向过滤(避免刷背面)、多人协作资产的保存合并。毛发方向是纯造型意图,用笔画出来比用参数描述快。
三、代价与失去的能力
GFur 的逐层独立顶点换来的是逐层宏观造型,每一层可以有不一样的 Pos。额头的毛整体往左倒,背上偏右,第三层比第二层长一截,某一层方向突然拐一下,这些都能烘进顶点流。
这套做法只有一份顶点数据,层与层之间的差异靠层号算:
方向 = 基准方向 × f(PerInstanceID)
f 是单调的。能做出"根部直、梢部弯"的渐变,以及整片毛朝同一个方向倒的整体走势。做不出逐层不同的宏观形状,层间只有标量差别没有方向自由度。具体效果上:一身毛往一个方向飘可以做,脖子和背上各自朝不同方向倒做不出来,得靠顶点色分区域刷不同方向来近似。
换来的是三件事:顶点数据不随层数增长,组件结构不动,层数可以按 LOD 和平台分档配。多数角色的体毛、绒毛、短毛用这套够了;需要逐层造型的长毛动物、造型复杂的幻想生物,GFur 那条路更合适。
如果将来要在这套方案里拿回逐层造型自由度,一条路是把逐层偏移做成贴图走纹理采样,代价是采样开销和一次额外的纹理流送。
四、管线侧的补充处理
除了核心的提交和着色器链路,还有几处管线侧的细节不处理会出问题。
4.1 材质相关性与纹理流送
GetMaterialRelevance 要把毛发的 MID 并进来,渲染器靠它判断物体走哪些通道。漏了 MID 会导致某些视角下缺光照。GetUsedMaterials 同理,纹理流送靠这个列表决定贴图驻留,漏了 MID 毛发贴图会被当成没人引用的资源。
4.2 运行时开关的代价
SetPerSectionMultipassState 留在基类上但实现是空的。运行时切换要重建 MID、重建渲染状态、重走材质代理注册,改成由 bSupportPerSectionMultipass 做配置期开关,资产级一次性成本,运行时开关每次都付。
4.3 阴影轮廓
毛发层不投影,角色阴影里不会有毛发轮廓。壳层剪影本来就很难从阴影贴图里得到正确结果,但如果角色极度依赖毛发剪影会有不一致。
4.4 CVar 与统计
r.PerSectionMultipass.SkeletalMeshes 0 = Off / 1 = On [默认]
r.PerSectionMultipass.StaticMeshes 0 = Off / 1 = On [默认]
ECVF_RenderThreadSafe,运行时随时切,做性能对比时同一场景切一次就能拿到差值。统计的语义注意:DrawCall 加 1(CPU 确实只提交一次),三角形计数加的是 Mesh.GetNumPrimitives() 没乘层数,GPU 侧实际是 N 倍。
五、已知限制
当前每级 LOD 预建一个 MID,这是"层数是材质参数"这个设计的直接后果。GPU Scene 里有按图元、按 Section 组织的附加 uniform 数据通道,如果层数走那条路就不需要预建 MID,少一批 MID 省内存和创建开销,层数可以按 Section 分别配,运行时改层数也不需要重建。这条路要动 GPU Scene 的数据布局,影响面比 Shell Fur 本身大。
另一个是面板入口:开关和附加材质现在分散在网格资产和材质资产两处,理想的状态是直接在 Mesh 编辑器的每个 Material Slot 后面跟开关和附加材质槽。
写在最后
代价很明确:失去了 GFur 那种逐层独立偏移的宏观造型能力。对多数角色的体毛、绒毛、短毛来说够用,需要逐层造型的长毛动物要另选载体。
后续有两件事想做。一是把层数从 MID 参数迁移到 GPU Scene 的 Per Section Uniform 数据上,彻底去掉每级 LOD 预建 MID 的环节,层数变成提交时顺手写入的一个值。二是把 Fur 开关和附加材质直接放到 Mesh 面板的 Material Slot 后面,让美术在一个地方配完所有东西。
