Yuyeyyy · Graphics

URP / 头发渲染

基于URP的头发渲染技术解析

返回文章

这是一个基于 URP (Universal Render Pipeline) 的头发渲染 Shader。它结合了 Kajiya-Kay 模型与 Marschner(透射)以及二次元/风格化渲染中常用的 Sphere Normal。

这个暂时只改了头发的 shader,皮肤和眼睛的还没有改,暂且忽略除头发的其他部分。

一、双 Pass 渲染架构

插片头发渲染要注意半透明排序问题。为了兼顾性能和正确的深度遮挡,该 Shader 采用双 Pass:

  • Pass 1(Opaque):负责渲染头发的"实心"部分。开启深度写入(ZWrite On),使用较高的 Alpha Cutoff(如 0.5)。这确保了头发有正确的遮挡关系。

    Name "Hair_Opaque"
    Tags { "LightMode" = "UniversalForward" }
    
    Cull Back
    ZWrite On
    ZTest LEqual
    Blend One Zero
    
  • Pass 2(Transparent):负责渲染发梢、边缘等"半透明"部分。关闭深度写入(ZWrite Off)。关键技巧在于使用了 ZTest Less。

    Name "Hair_Transparent"
    Tags { "LightMode" = "HairTransparent" }
    
    Cull Back
    ZWrite Off
    ZTest Less    // 关键:避免覆盖 Pass 1 已渲染区域
    Blend SrcAlpha OneMinusSrcAlpha, One OneMinusSrcAlpha
    

原理:Pass 2 只会渲染那些在 Pass 1 中因为 Alpha 低于阈值而被剔除(Clip)、且深度比当前深度缓冲更近(通常是背景)的像素。这避免了在实心区域重复渲染半透明层,既节省了性能又解决了由于深度写入导致的半透明错误。

// 不透明层
half4 HairFragOpaque(Varyings input) : SV_Target
{
    HairFragmentOutput o = ShadeHair(input);
    clip(o.alpha - _AlphaCutoff);    // 剔除半透明
    return half4(o.color, 1.0);
}

// 透明层
half4 HairFragTransparent(Varyings input) : SV_Target
{
    HairFragmentOutput o = ShadeHair(input);
    clip(o.alpha - 0.01);    // 仅剔除完全透明
    // 依靠 ZTest Less 避免重复渲染实心部分
    return half4(o.color, o.alpha);
}

贴图一般会给半透贴图或黑白遮罩贴图:

头发贴图:左为半透贴图,右为黑白遮罩

头发贴图。左侧为半透贴图,右侧为黑白遮罩。

在 Unity built-in 管线中 Unity 会按顺序把 Shader 里的 Pass 全跑一遍。但 URP 管线是一个单次遍历(Single-Pass)的管线,所以要配置一个 Render Feature,让 Render Feature 在透明渲染结束后,专门跑一趟,把 HairTransparent 画出来。

二、混合法线方案:将 Diffuse 和 Specular 的法线逻辑分离

  • Diffuse 使用 Sphere Normal(球形法线):为了避免每一根发丝的法线导致漫反射产生"脏"的噪点,Shader 在顶点阶段计算了一个包裹头部的平滑球形法线。这样漫反射的光照就像照在一个光滑的球体上,非常柔和整洁。

    float3 sphereNorm = float3(
        sin(worldPos.x + uv.x * 6.283) * _ThicknessScale,
        _ThicknessBias + 0.2,    // 略微向上的分量,模拟头顶隆起
        cos(worldPos.z + uv.y * 6.283) * _ThicknessScale
    );
    // 利用世界坐标 worldPos 和 UV 来构建一个径向的向量。
    // _ThicknessScale 控制这个虚拟球体
    
  • Specular 使用 Kajiya-Kay(切线方向):高光计算不使用法线,而是基于发丝的流向(切线 / Bitangent)。这保证了无论球形法线如何平滑,高光依然能准确地呈现在每一根发丝上,形成"天使环"。

    o.bitangentWS = normInputs.bitangentWS;
    

PS:一开始发现头发锯齿化严重首先想到的是提高摄像机的分辨率和加抗锯齿操作(什么 TAA / MSAA),但走了弯路(我是笨蛋)。真正的根源是单 pass 缺少边缘半透层。

三、光照计算

1. 漫反射

float NdotL = saturate(dot(diffuseNormal, lightDir));
float3 diffuseTotal = albedo * NdotL * aoFactor;

AO 压暗可增强体积感(0.2 vs 0.7 对比)。

2. 高光

头发在微观上是无数根细小的圆柱体(发丝)。光照在圆柱体上的反射不是一个点,而是一个圆锥体。

所以使用 KK,但 KK 显假。Kajiya-Kay 虽然解决了高光形状,但它本质上还是把头发当成不透明的细管子(像金属),不能模拟头发的透射,所以加上 Marschner 模型,模拟光线穿过头发产生的次表面散射,这样好多了。

不使用完整 Marschner 模型因为开销大了。

Kajiya-Kay:

sin⁡(θ)=1−(T⋅H)2\sin(\theta) = \sqrt{1 - (\mathbf{T} \cdot \mathbf{H})^2}

float sinTH = sqrt(1.0 - dotTH * dotTH);
return pow(max(0.0, sinTH), power) * scale;
// 当光照方向 H 与发丝方向 T 垂直时(即 T * H = 0),高光最强,形成了一圈光环

完整 Marschner:

  • R(反射):直接反射(对应主高光)。
  • TT(Transmission-Transmission):光线穿透发丝射出(对应背光透射)。
  • TRT(Transmission-Reflection-Transmission):光线射入,在内部反射一次再射出(对应次级彩色高光)。

本 Shader 用了简化版(用两层 Kajiya-Kay + 一层 Marschner 风格的 mars 项):

float dotTH = dot(T, H);
// ...
return pow(max(0.0, sinTH), power * 0.8) * scale * 0.5;
名称 模型 作用
spec1 Kajiya-Kay 头发表面白色的反光,表现发丝的光泽度,形成第一层光环
spec2 Kajiya-Kay 有颜色的次级反光(模拟内部反射 TRT 的简化版),让头发颜色更丰富,不单调
mars Marschner 模拟逆光下的通透感

高光偏移:

float shiftVal = shiftTex.r - 0.5;
float3 t1 = normalize(strandDir + (shiftVal + _PrimaryShift) * geometryNormal);
float3 t2 = normalize(strandDir + (shiftVal + _SecondaryShift) * geometryNormal);

最终合成:

float3 specTotal = (spec1 * _PrimaryColor.rgb) + (spec2 * _SecondaryColor.rgb) + (mars * _MarsColor.rgb);
return (diffuseTotal + specTotal) * lightColor * lightAttenuation;

参考

  • 恋与深空 渲染管线浅析(截帧&分享)
  • 毛发渲染总结 - Papalqi
  • 记录头发渲染优化(优化 pass 数量)
  • 角色渲染技术——毛发及其他
  • Hair strand render in RE Engine - RE 引擎的发丝渲染-上

PS:下一篇想总结一下皮肤的渲染。我做的皮肤鼻孔是亮的也是没招了 ⦁֊⦁꧞。前段时间吭哧吭哧投递简历,发现自己一堆的漏洞,基础补了好多了,也感谢一路上所有的朋友和大佬们的指导和建议。渲染不知道作品集以后再做什么了,打算完善好之前做的后就多看看工具那些了。。。

二编:关于头发穿插 / 透明的排序问题

给头发根据情况单独手动给值排 renderQueue:

// 两个透明物体的排序
// 计算物体深度(计算物体中心到相机的深度)来决定绘制的先后顺序
float depthA = dot(posA - cameraPos, cameraForward);
float depthB = dot(posB - cameraPos, cameraForward);

已知问题:b195 评论指出"补边在透明 queue 里画,会导致与其它透明物体深度错误"——如果特效透明也用同一较后的 queue 画,会与头发补边的 queue 冲突。我测试加了半透粒子确实会有这个问题,这个建议是对的,我欠考虑了,谢谢。

业界公认的难题:最好的方案应该是 MSAA buffer 开 a2c(alpha-to-coverage),直接把不透明的物体当透明的画就行了。但是 MSAA 除非在手机上只开一 pass 且所有后续 pass 都作为它的 subpass 才能行,否则 MSAA 的带宽直接爆炸;另外 MSAA 的 depth 无法 resolve,所以没法 prez,反正有很多坑,所以依然还是不算最好的方案。

编辑于 2026-03-17 · 江苏

评论