GRAPHICS × PRODUCTION
中EN

皮了个牛

↓

工程实践

Shell Fur 多通道材质系统

文章比较 DCC 烘焙 N 层壳模型与 GFurPRO 两种绒毛方案,提出在 MeshComponent 上以 Draw Instance 让 GPU 按 InstanceID 展开壳层、由材质控制层深和方向的多通道 Shell Fur 系统。内容完整覆盖 Fur 开关、材质与 Count Bias 的挂载数据结构,LOD 预生成、渲染提交、材质相关性、纹理流送、着色器编译标记、Per Section 层数取最小规则,以及 VertexColor 搭载切线空间毛发方向和 Paint Module 的美术工作流;同时说明失去逐层宏观造型能力、阴影轮廓不足、运行时开关成本和每级 LOD 预建 MID 等限制,并提出迁移到 GPU Scene Per Section Uniform、整合 Mesh 编辑器入口的后续方向。

2024-08-1424 分钟阅读
Shell Fur 多通道材质系统
AI 生成概念封面

壳层毛发(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 后面,让美术在一个地方配完所有东西。