头发渲染一直是实时渲染里最容易被低估的一块。很多团队把角色皮肤打磨得很细,结果一顶头发上去,整个角色的质感直接掉一个档次——要么像塑料假发,要么像一坨糊在一起的毛线。我在几个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模型的计算全部建立在发丝局部坐标系上。对每个着色点,我们需要:
- T(Tangent):发丝切线方向,通常从顶点法线或者专门的切线贴图里拿。
- N(Normal):表面法线。
- V(View):视线方向。
- L(Light):光照方向。
然后构造一个正交基:把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) / 2,m是纵向粗糙度。
这个函数本质上是一个高斯分布,描述的是光线在圆周方向上的扩散程度。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到1),B通道可以存一些额外信息比如发丝粗细或者层次。采样后在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、光照L:
float3 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,两者相除会出问题。实际实现里要clamp:
float 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_R | R光路纵向粗糙度 | 0.05-0.3 | 高光沿圆周变散 | 高光更集中 |
| s_R | R光路方位角粗糙度 | 0.1-0.5 | 高光沿发丝变散 | 高光更锐利 |
| m_TT | TT纵向粗糙度 | 0.1-0.4 | 透射光更柔 | 透射光更集中 |
| s_TT | TT方位角粗糙度 | 0.2-0.6 | 透射光更散 | 透射光更集中 |
| m_TRT | TRT纵向粗糙度 | 0.2-0.5 | 二次散射更柔 | 二次散射更集中 |
| s_TRT | TRT方位角粗糙度 | 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保持到现在,强烈推荐你也试试。