UE4 Marschner头发渲染实战:从塑料假发到次世代发丝

发布时间:2026/9/19 9:04:49
UE4 Marschner头发渲染实战:从塑料假发到次世代发丝 头发渲染一直是实时渲染里最容易被低估的一块。很多团队把角色皮肤打磨得很细结果一顶头发上去整个角色的质感直接掉一个档次——要么像塑料假发要么像一坨糊在一起的毛线。我在几个UE4项目里反复折腾过头发从最早的各向同性高光硬调到后来上Marschner模型中间踩的坑足够写一本小册子。这篇就把我用UE4实现Marschner头发渲染的完整思路摊开讲包括为什么要用这个模型、Shader里每一块在算什么、参数怎么调、以及那些文档里不会写的实测经验。不管你是刚接触角色渲染的新手还是已经写过自定义光照模型的老手应该都能从里面捞到点能直接用的东西。1. 为什么头发的光照模型不能直接用皮肤那套1.1 各向同性高光在头发上的翻车现场先说个最直观的现象。你拿一个标准的PBR材质球把粗糙度调低放到头发模型上会发现高光是一团圆的、均匀铺开的亮斑。但真实的头发不是这样——你去看任何一张真实头发的高光照片高光是一条沿着发丝方向拉长的、有明确走向的带状亮区。这个差异的根源在于头发是各向异性的它的微观结构是沿发丝方向排列的柱状纤维而不是皮肤那种随机分布的微表面。各向同性模型比如GGX的默认用法假设微表面法线在各个方向上均匀分布所以高光自然就是圆的。头发完全不符合这个假设。你硬用皮肤那套得到的就是一顶塑料假发。我早期项目里就干过这事美术同学看完直接说这头发像刚洗完没吹干其实问题不在贴图在光照模型选错了。1.2 头发纤维的三层光学结构要理解Marschner模型得先知道一根头发在光学上长什么样。一根头发大致可以看成三层同心圆柱角质层Cuticle最外层是一层层像鱼鳞一样叠起来的结构它决定了头发表面的反射也就是我们看到的R高光表面反射。皮质层Cortex中间主体含有黑色素光线穿透角质层后进入这里发生散射和吸收再透出来形成TT透射透射光——这是头发颜色和通透感的主要来源。髓质层Medulla最内层有些头发有有些没有它会产生额外的散射形成TRT二次透射也就是头发边缘那圈柔和的、偏色的光晕。Marschner在2003年的论文里把这三条光路分别建模这就是为什么它比单一高光模型真实得多。R负责锐利的高光带TT负责整体的发色和透光TRT负责边缘的柔和散射。三条光路叠加头发才有那种有层次、有深度的感觉。1.3 Marschner模型到底解决了什么问题一句话概括它把头发高光从一个圆斑变成了一条沿发丝走向的、带偏移和色散的亮带同时补上了透射和二次散射让头发在逆光、侧光下都有正确的表现。具体来说Marschner模型引入了几个关键概念切线方向T发丝的走向这是各向异性的核心所有计算都围绕它展开。方位角偏移R高光不是正好在镜面反射方向而是有一个沿切线方向的偏移这个偏移由角质层的倾斜角决定。纵向衰减沿发丝方向的高光衰减和垂直方向不一样用不同的指数控制。吸收与色散TT和TRT经过皮质层时会被黑色素吸收不同波长吸收率不同所以透射光会偏色通常是偏红棕。理解了这些你再看Shader代码就不会觉得是一堆莫名其妙的三角函数了。2. Marschner模型的数学拆解每一项到底在算什么2.1 坐标系建立与切线空间Marschner模型的计算全部建立在发丝局部坐标系上。对每个着色点我们需要TTangent发丝切线方向通常从顶点法线或者专门的切线贴图里拿。NNormal表面法线。VView视线方向。LLight光照方向。然后构造一个正交基把T作为主轴N和T叉乘得到副切线B再用T和B叉乘得到修正后的法线。所有角度都在这个空间里算。这里有个实操细节UE4里默认的Tangent是给法线贴图用的方向不一定和发丝走向一致。我一般会单独做一张发丝流向贴图Hair Flow Map把切线方向编码进贴图的RG通道在Shader里解码后作为T。这样美术可以自由控制发丝走向不用受模型UV的限制。2.2 纵向散射函数Mp纵向散射描述的是光线在发丝横截面上的行为也就是沿着发丝圆周方向的角度关系。核心公式是Mp(θi, θr) (1 / (2 * cos²θd)) * exp(-tan²θd / (2 * m²))其中θi是入射角θr是出射角θd (θr - θi) / 2m是纵向粗糙度。这个函数本质上是一个高斯分布描述的是光线在圆周方向上的扩散程度。m越大高光沿圆周方向越散m越小高光越集中。注意分母里的cos²θd这个项在θd接近90度时会爆炸实际实现里要clamp一下否则会出现亮斑。2.3 方位角散射函数Np方位角散射描述的是光线在发丝长度方向上的行为也就是沿发丝走向的角度关系。这一项是Marschner模型里最复杂的部分因为它要处理前面说的三条光路R、TT、TRT。对于R光路方位角散射是一个偏移的镜面反射Np_R(φi, φr) exp(-((φr - φi offset)²) / (2 * s²))offset就是前面说的方位角偏移由角质层倾斜角α决定。s是方位角粗糙度。对于TT和TRT还要考虑光线进入皮质层后的折射和吸收公式里会多出折射率η和吸收系数σa。这部分在实时渲染里通常会做简化用查表或者近似函数代替。2.4 吸收与透射TT和TRT的色散处理TT和TRT经过皮质层时光线会被黑色素吸收。吸收遵循比尔-朗伯定律T exp(-σa * d)其中σa是吸收系数和波长相关d是光在皮质层里走过的距离。不同波长的σa不同红光吸收少、蓝光吸收多所以透射光偏红棕——这就是为什么逆光下头发边缘会有一圈暖色的光晕。实时实现里我一般用一张吸收色贴图来近似把不同发色对应的吸收系数预计算进去。这样既省性能又能让美术直接调发色。3. UE4里怎么把Marschner塞进自定义光照3.1 用Custom Node还是改Shading Model在UE4里实现自定义头发光照有两条路Custom Node在材质里用Custom节点写HLSL把Marschner的计算塞进去然后和默认光照混合。优点是快、不用改引擎缺点是拿不到完整的光照信息多光源支持麻烦。自定义Shading Model改引擎的ShadingModels.ush加一个Hair的Shading Model。优点是能完整接入UE4的光照管线多光源、阴影、间接光都能用缺点是要改引擎源码打包和升级麻烦。我的建议是原型阶段用Custom Node快速验证正式项目用自定义Shading Model。原型阶段你只需要看效果对不对Custom Node足够正式项目里角色要接受多个光源、要投影、要参与GI必须走Shading Model。3.2 自定义Shading Model的接入步骤如果你决定走Shading Model这条路大致步骤如下在ShadingModels.ush里找到MSM_DefaultLit复制一份改成MSM_Hair。在FMaterialShadingModelField里注册新的Shading Model枚举。在材质的Shading Model下拉里就能选到Hair了。在MSM_Hair的DirectLighting分支里把默认的GGX替换成Marschner的计算。处理间接光头发对环境光的响应也要用Marschner不能直接用漫反射。这里有个坑UE4的Shading Model枚举是位掩码加新模型要注意位数不要和已有的冲突。我第一次加的时候没注意结果和ClearCoat撞了排查了半天。3.3 切线数据的准备与传递前面说了Marschner需要准确的切线方向。在UE4里传递切线数据有几种方式用顶点切线如果模型本身切线方向就是发丝走向直接用。但大多数头发模型的切线是给法线贴图用的方向不对。用切线贴图单独做一张贴图编码发丝流向在Shader里采样后解码。这是最灵活的方式推荐。用UV方向推导如果发丝走向和UV的某个方向一致可以直接从UV推导。但这种方法限制大不推荐。我一般用切线贴图把流向编码进RG通道范围0-1映射到-1到1B通道可以存一些额外信息比如发丝粗细或者层次。采样后在Shader里解码float2 flow Tex2DSample(HairFlowMap, UV).rg * 2 - 1; float3 T normalize(flow.x * Tangent flow.y * Bitangent);这样美术在贴图里画流向Shader里就能拿到正确的切线。4. Shader代码逐段解析从切线到最终颜色4.1 切线空间构造与角度计算先看坐标系构造。假设我们已经拿到了切线T、法线N、视线V、光照Lfloat3 T normalize(Tangent); float3 N normalize(Normal); float3 B normalize(cross(T, N)); N normalize(cross(B, T)); // 重新正交化 // 把L和V投影到切线空间 float3 L_local float3(dot(L, T), dot(L, N), dot(L, B)); float3 V_local float3(dot(V, T), dot(V, N), dot(V, B)); // 计算纵向角度 float theta_i acos(clamp(L_local.x, -1, 1)); float theta_r acos(clamp(V_local.x, -1, 1)); float theta_d (theta_r - theta_i) * 0.5; // 计算方位角 float phi_i atan2(L_local.z, L_local.y); float phi_r atan2(V_local.z, V_local.y); float phi_d (phi_r - phi_i) * 0.5;注意这里用的是atan2不是atan因为方位角要区分象限。另外acos和atan2都是比较贵的操作实际项目里可以考虑用近似或者预计算。4.2 R光路表面高光的计算R光路是三条里最简单的就是偏移的镜面反射float Mp_R exp(-tan²(theta_d) / (2 * m_R²)) / (2 * cos²(theta_d)); float Np_R exp(-sin²(phi_d - alpha_R) / (2 * s_R²)); float R Mp_R * Np_R * F; // F是菲涅尔项其中m_R是纵向粗糙度s_R是方位角粗糙度alpha_R是角质层倾斜角F是菲涅尔反射率。这里有个细节tan²(theta_d)在theta_d接近90度时会趋于无穷cos²(theta_d)会趋于0两者相除会出问题。实际实现里要clampfloat cos_theta_d max(cos(theta_d), 1e-4); float tan_theta_d sin(theta_d) / cos_theta_d; float Mp_R exp(-tan_theta_d * tan_theta_d / (2 * m_R * m_R)) / (2 * cos_theta_d * cos_theta_d);4.3 TT与TRT透射光路的近似实现TT和TRT的完整实现很复杂实时渲染里一般做简化。我的做法是// TT: 一次透射 float Mp_TT exp(-tan²(theta_d) / (2 * m_TT²)) / (2 * cos²(theta_d)); float Np_TT exp(-sin²(phi_d - alpha_TT) / (2 * s_TT²)); float TT Mp_TT * Np_TT * T_absorb; // T_absorb是吸收色 // TRT: 二次透射 float Mp_TRT exp(-tan²(theta_d) / (2 * m_TRT²)) / (2 * cos²(theta_d)); float Np_TRT exp(-sin²(phi_d - alpha_TRT) / (2 * s_TRT²)); float TRT Mp_TRT * Np_TRT * T_absorb * T_absorb; // 两次吸收T_absorb用一张贴图控制不同发色对应不同的吸收色。金发吸收少透射偏亮黄黑发吸收多透射很弱红发在红波段吸收少透射偏红。4.4 三条光路的合成与能量守恒最后把三条光路加起来float3 hairColor R * specularColor TT * diffuseColor TRT * scatterColor;这里要注意能量守恒R、TT、TRT的系数加起来不应该超过1否则头发会过曝。我一般会加一个归一化系数float totalWeight w_R w_TT w_TRT; hairColor / max(totalWeight, 1.0);另外R是表面反射应该用菲涅尔控制强度TT和TRT是次表面散射应该用漫反射色控制。这两者的比例决定了头发的油还是干——R占比高就油亮TT占比高就柔和。5. 参数调试那些让美术抓狂的数值到底怎么定5.1 粗糙度参数的物理意义与取值范围Marschner模型里有好几个粗糙度参数很容易搞混。我整理了一张表参数物理意义典型范围调大效果调小效果m_RR光路纵向粗糙度0.05-0.3高光沿圆周变散高光更集中s_RR光路方位角粗糙度0.1-0.5高光沿发丝变散高光更锐利m_TTTT纵向粗糙度0.1-0.4透射光更柔透射光更集中s_TTTT方位角粗糙度0.2-0.6透射光更散透射光更集中m_TRTTRT纵向粗糙度0.2-0.5二次散射更柔二次散射更集中s_TRTTRT方位角粗糙度0.3-0.8二次散射更散二次散射更集中实测下来m_R和s_R是最影响观感的两个参数。m_R控制高光带的宽度s_R控制高光带的长度。想要那种一根根发丝都有高光的效果s_R要小想要整片头发有一层柔光的效果s_R要大。5.2 角质层倾斜角对高光位置的影响alpha_R是角质层倾斜角它决定了R高光相对镜面反射方向的偏移量。真实头发的角质层倾斜角大约在2-5度但实际调的时候可以适当放大到5-10度让高光偏移更明显视觉上更有发丝感。这个参数有个反直觉的地方它不是越大越好。太大了高光会偏到奇怪的位置看起来像头发上贴了一条亮带。我一般从3度开始试根据发丝走向微调。5.3 吸收色与发色的对应关系吸收色T_absorb决定了透射光的颜色也就间接决定了发色。不同发色的典型吸收色金发吸收少透射偏亮黄T_absorb接近(1.0, 0.9, 0.7)棕发中等吸收透射偏棕T_absorb接近(0.8, 0.5, 0.3)黑发强吸收透射很弱T_absorb接近(0.3, 0.2, 0.15)红发红波段吸收少透射偏红T_absorb接近(0.9, 0.4, 0.2)注意这是透射色不是漫反射色。漫反射色是头发在环境光下的基础色透射色是逆光下的颜色两者要分开控制。6. 实测踩坑从塑料假发到次世代头发的排查链路6.1 高光变成一条死板的亮带现象头发上出现一条笔直的、没有衰减的亮带像贴上去的。排查过程先检查切线方向发现切线贴图没接对T方向是错的。修正后亮带方向对了但还是太硬。再检查m_R和s_R发现s_R设得太小0.05导致方位角衰减几乎没起作用。把s_R调到0.2后亮带两端有了自然的衰减。根因切线方向错误 方位角粗糙度过小。修复修正切线贴图接入s_R调到0.15-0.25之间。6.2 逆光下头发边缘出现彩色噪点现象逆光时头发边缘有一圈闪烁的彩色噪点。排查过程先怀疑是抗锯齿问题开了TAA还是有。然后检查TT和TRT的计算发现tan²(theta_d)在theta_d接近90度时数值爆炸产生了NaN。加了clamp后噪点消失。根因tan²(theta_d) / cos²(theta_d)在极端角度下数值不稳定。修复对cos(theta_d)做clamp最小值取1e-4。6.3 多光源下头发颜色发灰现象单个光源下头发正常多个光源下头发整体发灰、发暗。排查过程检查光照累加逻辑发现每个光源都独立计算了Marschner然后直接相加没有做能量归一化。多个光源叠加后总能量超过1被色调映射压暗了。根因缺少能量归一化。修复在光照累加后除以光源数量或者用加权平均而不是简单相加。6.4 头发在阴影里完全变黑现象头发进入阴影后直接变成纯黑没有环境光贡献。排查过程检查间接光处理发现自定义Shading Model里没有处理间接光环境光贡献是0。根因自定义Shading Model只处理了直接光没处理间接光。修复在Shading Model里加上间接光分支用Marschner的TT项近似环境光贡献或者用球谐光照做近似。7. 性能优化让Marschner在移动端也能跑7.1 计算量的主要来源与削减策略Marschner的计算量主要来自三角函数acos、atan2、sin、cos、tan每个着色点要算好几次。指数函数exp每个光路要算两次。纹理采样切线贴图、吸收色贴图、法线贴图。削减策略预计算角度把theta和phi的三角函数值预计算到顶点或者用查找表。近似exp用多项式近似代替exp精度损失可接受。合并纹理把切线、吸收色、粗糙度打包到一张贴图的不同通道。LOD降级远处头发用简化模型只算R光路。7.2 用查找表替代实时计算对于移动端我一般会把Marschner的核心计算预计算成一张2D查找表LUT横轴是theta_d纵轴是phi_d存R、TT、TRT的值。运行时只需要采样LUT不用实时算三角函数和指数。LUT的分辨率一般256x256就够太大浪费太小有块状感。生成LUT可以在编辑器里做也可以离线生成后导入。7.3 移动端的简化方案如果目标平台是移动端Marschner的完整实现可能太重。我的简化方案是只保留R光路和TT光路去掉TRT。用LUT替代实时计算。切线贴图和吸收色贴图合并成一张。粗糙度参数用常量不做逐像素控制。这样下来移动端也能跑到可接受的帧率视觉效果比默认PBR好很多。8. 从Marschner到更远头发渲染的进阶方向Marschner模型是2003年的成果后续还有很多改进。如果你已经把Marschner跑通了可以往这几个方向继续双散射模型Double Cylinder Model把头发建模成两个同心圆柱能更好地处理多次散射适合浅色头发。基于物理的多次散射用路径追踪预计算多次散射实时用近似函数拟合效果更真实。发丝级别的几何用Hair Strands而不是Hair Cards配合Marschner能得到电影级效果但性能开销大。各向异性环境光把Marschner扩展到环境光让头发在HDRI下也有正确表现。我个人在实际项目里的体会是Marschner是性价比最高的方案效果和性能的平衡点很好。再往上走收益递减明显除非是影视级项目否则没必要。把Marschner的参数调好、切线数据做准、间接光处理好已经能打败市面上大部分游戏的头发了。最后分享一个小技巧调试Marschner的时候把R、TT、TRT三条光路分别输出成红绿蓝三个通道能直观看到每条光路的贡献。哪条不对一眼就能看出来比盲调参数快得多。这个习惯我从第一次写头发Shader保持到现在强烈推荐你也试试。