news 2026/9/19 9:04:42

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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模型的计算全部建立在发丝局部坐标系上。对每个着色点,我们需要:

  • 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这条路,大致步骤如下:

  1. ShadingModels.ush里找到MSM_DefaultLit,复制一份改成MSM_Hair
  2. FMaterialShadingModelField里注册新的Shading Model枚举。
  3. 在材质的Shading Model下拉里就能选到Hair了。
  4. MSM_HairDirectLighting分支里,把默认的GGX替换成Marschner的计算。
  5. 处理间接光:头发对环境光的响应也要用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,因为方位角要区分象限。另外acosatan2都是比较贵的操作,实际项目里可以考虑用近似或者预计算。

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_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_Rs_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_Rs_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的计算量主要来自:

  • 三角函数:acosatan2sincostan,每个着色点要算好几次。
  • 指数函数: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保持到现在,强烈推荐你也试试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 9:03:38

Spring Boot微服务容器化部署实战指南

1. 从零开始构建Spring Boot微服务并容器化部署作为一名长期奋战在一线的Java开发者,我亲历了微服务架构从概念到落地的全过程。今天要分享的是一个看似简单但极具代表性的实战案例:将一个Spring Boot微服务打包成Docker镜像并运行。这个案例涵盖了从项目…

作者头像 李华
网站建设 2026/9/19 9:02:04

SMPTE 274M标准解析:1080p视频时序、SAV/EAV与YCbCr采样

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 9:01:36

Python代码规范PEP 8详解与自动化实践

1. 为什么代码风格规范如此重要我第一次参与团队协作开发时,曾因为随意使用Tab和空格混排的缩进方式,导致整个项目的自动构建直接报错。那次经历让我深刻意识到,代码风格规范绝不是可有可无的教条。Python作为一门强调可读性的语言&#xff0…

作者头像 李华
网站建设 2026/9/19 8:58:43

EDCS 2026:教育数字化与计算机科学前沿会议指南

1. 会议背景与核心价值粤港澳大湾区教育数字化与计算机科学国际学术会议(EDCS)已成功举办两届,逐渐成为区域内教育技术与计算机应用领域的重要交流平台。2026年第三届会议将聚焦数字化转型浪潮下教育模式革新与计算机技术融合的前沿议题。作为…

作者头像 李华
网站建设 2026/9/19 8:58:39

手机端侧大模型部署实战:从内存带宽到MoE架构的选型指南

1. 手机跑大模型这件事,先把预期拉回地面端侧大模型这个词这两年热度一直没降过,但真正动手在手机上部署过模型的人都知道,宣传和实际体验之间有一条不小的鸿沟。我前后在几台不同芯片方案的手机上折腾过端侧推理,从最早的量化小模…

作者头像 李华