Yuyeyyy · Graphics

Hair

Kajiya-Kay & "类 Marschner" 头发高光

返回文章

Unity 双 Pass 头发渲染 Shader 技术文档

基于两份原始技术笔记整理:HairShader 双 Pass 设计思路 + Kajiya-Kay 与类 Marschner 方案对比。全文涵盖架构设计、光照模型推导、完整代码与工程实践。

1. 概述

头发是实时渲染中较难处理的一类材质。本章先分析其两个核心挑战,再总览本 HairShader 的总体设计方案,实现细节由第 2~4 章展开。

1.1 头发渲染的核心挑战

1.1.1 多向高光问题:头发纤维的圆柱各向异性导致传统 Blinn-Phong 高光失效,需要沿切线方向的特殊高光模型

头发纤维近似细长圆柱,表面法线沿周向连续变化,高光因此不集中于一点,而是沿发丝走向拉伸成高光带,并随观察角度呈现多层、多方向的反光(原始笔记称之为「多向高光」)。传统 Blinn-Phong 模型以法线与半角向量点积的幂次 (NH)n(N \cdot H)^n 计算高光,只能得到各向同性的圆形光斑。Kajiya-Kay(1989)将头发纤维视为不透明细圆柱,指出圆柱法线分布在垂直于切线 TT 的圆上,高光强度因此退化为切线 TT 与半角 HH 夹角正弦的幂次 sin(T,H)exp\sin(T,H)^{exp}。因此 Shader 需采用以切线为核心的各向异性高光模型,并以切线偏移模拟发丝鳞片(约 3° 倾斜角)造成的高光迁移。

1.1.2 发丝质感与半透明问题:发丝间隙透光、边缘细碎发丝的半透明感与深度缓冲正确性之间的冲突

真实发型边缘有大量细碎发丝,发丝间隙允许光线穿透,呈现半透明质感。整束按不透明渲染会让边缘生硬、丢失「丝感」;整体开启 Alpha 混合(Blend)则需关闭深度写入(ZWrite Off),导致头发层叠处及与背后物体间的深度排序错误。此即原始笔记所称「主体不透明 + 边缘透明」的渲染冲突:深度正确性要求写入深度并裁剪低 Alpha 像素,半透明质感却要求保留原始 Alpha 与背景混合,二者在同一渲染状态下无法兼得,须在 Pass 架构层面拆分解决。

1.2 本 Shader 的设计方案总览

针对上述两类挑战,该 HairShader 采用双 Pass 架构加三种高光模型协同的方案。

1.2.1 双 Pass 架构:OPAQUE Pass 保证主体毛发深度正确 + TRANSPARENT Pass 模拟边缘半透明

方案的第一支柱是将渲染拆分为 OPAQUE 与 TRANSPARENT 两个 Pass。OPAQUE Pass 开启 ZWrite、关闭 Blend,并以 clip(texColor.a - _AlphaCutoff) 丢弃 Alpha 低于阈值 _AlphaCutoff 的像素,只渲染主体毛发,建立正确的深度基础,避免后方物体穿透头发;TRANSPARENT Pass 关闭 ZWrite、采用 Blend SrcAlpha OneMinusSrcAlpha 并注释掉 clip,保留 _HairTex 原始 Alpha,让边缘细碎发丝与背景混合,模拟发丝间隙透光,弥补裁剪丢失的细节。两个 Pass 的顶点着色器(vert)、光照模型函数与颜色合成逻辑完全相同,差异仅在渲染状态与 Alpha 处理,第 2 章将逐项对比两者的渲染状态设置。

1.2.2 三种高光模型协同:Kajiya-Kay 双层主高光 + 简化 Marschner 细碎反光 + 环境高光

方案的第二支柱是高光计算集成三种模型,覆盖头发的不同反光特性,分工如下表。

高光模型 核心作用 关键实现
Kajiya-Kay 双层主高光(主高光 + 次高光) shiftTangent 按 _PrimaryShift/_SecondaryShift 偏移副切线 worldBinormal,取 sin(T,H)\sin(T,H) 幂次并以 smoothstep 做方向衰减,叠加 spec1 + spec2
简化 Marschner 发丝边缘细碎反光,增强「丝感」 省略原模型 R/TT/TRT 分量,以 pow(sinTH, _MarschnerPower) 单一幂次项拟合,无方向衰减、形态更柔和
环境高光 模拟环境反射(天空、周围物体反光) 视线反射向量 R = reflect(-V, N) 与副切线的夹角 sinTR 计算反射强度

三种高光按「(Kajiya-Kay 高光 + Marschner 高光) × 光源颜色 × 衰减 + 环境高光」合成。第 3 章梳理完整光照计算流程,第 4 章逐一推导三种模型的数学形式与代码实现。

2. 整体架构:双 Pass 渲染策略

第 1 章概述了头发渲染的两大核心挑战与「双 Pass + 三种高光模型」的总体方案,本章展开方案的第一支柱——双 Pass 架构。该 HairShader 在同一个 SubShader 中放置两个 Pass:名为 OPAQUE 的不透明 Pass 处理主体毛发,名为 TRANSPARENT 的透明 Pass 处理边缘与细碎发丝。两者共享同一套顶点着色器与光照代码,差异只体现在渲染状态与 Alpha 处理上。本章先解释拆分两个 Pass 的动机,再逐一分析两个 Pass 的状态配置,最后说明两者在代码层面的统一性。

2.1 为什么需要两个 Pass

2.1.1 头发「主体不透明 + 边缘透明」的渲染冲突:单一 Pass 无法同时满足深度写入与 Alpha 混合

头发纹理的 Alpha 通道天然包含两类像素:Alpha 接近 1 的主体毛发,以及 Alpha 介于 0 与 1 之间的边缘、发丝间隙。这两类像素对渲染管线提出了互相矛盾的要求。

主体毛发需要写入深度(ZWrite On)。只有头发主体的深度进入深度缓冲,位于头发后方的场景物体才会被正确遮挡,不会出现「穿透头发看到后脑勺之外物体」的错误。而边缘发丝需要 Alpha 混合(Blend SrcAlpha OneMinusSrcAlpha),让半透明像素按 Alpha 值与已绘制的背景融合,才能表现发丝间隙的透光感。

矛盾在于:深度写入与 Alpha 混合无法在单一 Pass 中安全共存。GPU 的 Alpha 混合依赖「先远后近」的绘制顺序才能得到正确结果,而透明物体的三角面排序在交叉、穿插时无法保证严格正确;一旦半透明像素同时写入深度,后绘制的透明片元会被先前写入的深度提前剔除(深度测试失败),混合结果随绘制顺序变化,产生可见的排序错误与斑块。反之,若整体关闭深度写入,则主体毛发失去遮挡能力,深度关系彻底失效。因此单一 Pass 无论选择哪种状态组合,都只能满足一半需求,拆分为两个 Pass 各司其职是必然方案。

2.2 两个 Pass 的渲染状态与 Alpha 处理差异

2.2.1 Pass 1(OPAQUE):ZWrite On + Blend Off + clip(AlphaCutoff) 裁剪,只保留主体毛发

第一个 Pass 的状态声明如下:

Name "OPAQUE"
Tags { "LightMode" = "ForwardBase" }
ZWrite On
Blend Off
Cull Back

ZWrite On 使该 Pass 的像素深度写入深度缓冲,Blend Off 关闭混合,像素颜色直接覆盖帧缓冲,Cull Back 剔除背面。片元着色器在采样头发纹理后立即执行 Alpha 裁剪:

fixed4 texColor = tex2D(_HairTex, i.uv);
clip(texColor.a - _AlphaCutoff);

clip(x) 在参数小于 0 时丢弃该片元。Alpha 低于 _AlphaCutoff(属性默认 0.5)的边缘像素被整体裁剪,只有 Alpha 达标的主体毛发进入后续光照计算并写入深度。由于保留下来的像素都被视为不透明(该 Pass 最终输出 fixed4(diffuse + specular, 1.0)),写入深度是安全的:不透明渲染不依赖绘制顺序,彻底回避了 2.1 节所述的排序问题。该 Pass 以牺牲边缘细节为代价,建立了整个头发的深度基础。

2.2.2 Pass 2(TRANSPARENT):ZWrite Off + Blend SrcAlpha OneMinusSrcAlpha,保留原始 Alpha 做边缘混合

第二个 Pass 的状态声明对应透明渲染:

Name "TRANSPARENT"
Tags { "LightMode" = "ForwardBase" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Back

ZWrite Off 关闭深度写入(深度测试仍然开启),避免半透明像素污染深度缓冲;Blend SrcAlpha OneMinusSrcAlpha 按「源 Alpha × 源色 + (1 − 源 Alpha) × 目标色」的公式与背景融合。片元着色器中 clip 一行被注释掉:

// Alpha裁剪 (透明部分不裁剪)
// clip(texColor.a - _AlphaCutoff);

不裁剪任何像素,纹理原始 Alpha 被保留到最终输出 fixed4(diffuse + specular, texColor.a),Alpha 较低的边缘与发丝由此参与混合,模拟半透明质感。深度上它依赖 Pass 1 建立的基础:头发主体区域已有深度,Pass 2 的片元在自身深度处通过测试后与 Pass 1 的结果叠加,主体处 Alpha 高、视觉上仍以不透明颜色为主,边缘处 Alpha 低、自然透出背景,弥补了 Pass 1 裁剪丢失的细节。

2.2.3 对比表:渲染状态 / Alpha 处理 / 核心作用 三维度对比两个 Pass

对比维度 Pass 1(OPAQUE,不透明 Pass) Pass 2(TRANSPARENT,透明 Pass)
渲染状态 ZWrite On(写入深度缓冲);Blend Off(关闭混合,直接覆盖像素);Cull Back ZWrite Off(不写入深度缓冲);Blend SrcAlpha OneMinusSrcAlpha(按 Alpha 值与背景融合);Cull Back
Alpha 处理 clip(texColor.a - _AlphaCutoff):Alpha 低于 _AlphaCutoff 的像素直接丢弃,只保留不透明的主体毛发,避免主体毛发与背景混合导致深度错误;输出 Alpha 固定为 1.0 注释掉 clip:不裁剪任何像素,保留 _HairTex 的原始 Alpha 值,让 Alpha 较低的边缘/发丝与背景混合,模拟头发半透明感(如发丝间隙透光);输出 Alpha 为 texColor.a
核心作用 渲染头发的「主体部分」,保证深度缓冲正确(避免后面的物体穿透头发),是整个头发渲染的「深度基础」 渲染头发的「边缘/细碎发丝」,通过 Alpha 混合模拟半透明质感,弥补 Pass 1 裁剪后丢失的细节,让头发更自然

2.3 两个 Pass 的统一性

2.3.1 顶点着色器、光照模型函数、颜色合成逻辑完全一致,差异仅在渲染状态与 Alpha 处理

两个 Pass 的 CGPROGRAM 段在代码层面几乎逐行相同:顶点着色器 vert 同样完成模型空间到世界空间的向量转换,输出 worldNormalworldTangentworldBinormal(头发高光计算的核心方向)以及视角与光源方向所需的 worldPos;光照模型函数 shiftTangenthairStrandSpecular(Kajiya-Kay)、marschnerSpecular(简化 Marschner)、environmentSpecular(环境高光)在两个 Pass 中定义完全一致;片元着色器的颜色合成同样遵循「漫反射 = 纹理颜色 ×(直接光漫反射 + 间接光漫反射)」「高光 = Kajiya-Kay 高光 + Marschner 高光 + 环境高光」的公式,并同样应用 UNITY_APPLY_FOG 雾效。两个 Pass 的全部差异只落在两处:Pass 头部声明的渲染状态,以及片元着色器中 clip 是否生效、输出颜色的 Alpha 分量取 1.0 还是 texColor.a。这种「同根同源」的设计保证了两个 Pass 渲染出的主体与边缘在光照表现上无缝衔接,不会因光照差异产生颜色断层。光照计算的完整流程将在第 3 章展开。

2.3.2 渲染队列与 LightMode 设置:Transparent 队列 + ForwardBase

SubShader 级别的 Tags 设置为 "RenderType"="Transparent""Queue"="Transparent",即整个 Shader 被归入 Transparent 渲染队列,在所有不透明物体之后绘制——这是 Pass 2 的 Alpha 混合能拿到正确背景颜色的前提。两个 Pass 的 LightMode 均设为 ForwardBase,意味着它们都工作在前向渲染路径中,处理主平行光(含光照衰减 atten)与球谐环境光,并各自启用 multi_compile_fwdbasemulti_compile_fog 两个着色器变体。尽管 OPAQUE Pass 渲染的是不透明主体,它并未使用 Geometry 队列,而是随 SubShader 统一排在 Transparent 队列中执行;由于透明队列内部按从前往后排序绘制,且 Pass 1 先写入深度、Pass 2 随后混合,两者的相对执行顺序在同一物体的两个 Pass 间由声明顺序保证,从而维持「先立深度、再做混合」的架构前提。此外 Shader 末尾指定 FallBack "Transparent/Cutout/VertexLit",在不支持该 Shader 的硬件上退化为裁剪式透明顶点光照 Shader。架构分工与渲染状态明确后,第 3 章将展开两个 Pass 共享的光照计算流程。

3. 光照计算流程

第 2 章介绍了双 Pass 架构与渲染状态,本章梳理两个 Pass 共享的光照计算流程。两个 Pass 的顶点着色器(vert)、光照模型函数与颜色合成逻辑完全相同,均围绕头发「多向高光」与「发丝质感」的光照特性设计。整个流程分四步推进:基础向量准备、纹理采样、漫反射计算、颜色合成与雾效。本章按此顺序展开;三种高光模型(Kajiya-Kay、Marschner 与环境高光)的具体实现属于第 4 章内容,本章仅在颜色合成处引用其结果。

3.1 基础向量准备

光照计算要求所有方向向量位于同一坐标系。该 Shader 选择世界空间作为统一计算空间:顶点着色器负责将模型空间的顶点属性变换到世界空间并写入插值寄存器(worldNormal、worldTangent、worldBinormal 分别占用 TEXCOORD2/3/4),片元着色器接收插值结果后重新归一化,得到光照所需的全部方向向量。

3.1.1 世界空间向量转换:worldNormal / worldTangent / worldBinormal(头发高光的核心方向)/ worldViewDir / worldLightDir

vert 函数中的变换代码如下:

v2f vert (appdata v)
{
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _HairTex);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
    o.worldBinormal = cross(o.worldNormal, o.worldTangent) * v.tangent.w;
    
    UNITY_TRANSFER_FOG(o,o.pos);
    TRANSFER_VERTEX_TO_FRAGMENT(o);
    return o;
}

逐行解析关键变换:

  • UnityObjectToWorldNormal(v.normal):将法线变换到世界空间,内部使用逆转置矩阵,保证非均匀缩放(如头发模型沿轴向拉伸)下法线方向仍然正确。
  • UnityObjectToWorldDir(v.tangent.xyz):将切线方向变换到世界空间,只做旋转不做平移。
  • cross(o.worldNormal, o.worldTangent) * v.tangent.w:由法线与切线的叉积构造副切线,手性处理见 3.1.2。
  • TRANSFER_VERTEX_TO_FRAGMENT(o):传递光照衰减(阴影)采样所需的插值数据,供片元阶段的 atten 计算使用。

frag 函数开头对插值向量重新归一化,并补齐视角方向与光照方向:

// 基础向量
float3 worldNormal = normalize(i.worldNormal);
float3 worldTangent = normalize(i.worldTangent);
float3 worldBinormal = normalize(i.worldBinormal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

三角形内的线性插值会改变向量长度,因此三个插值向量必须在片元阶段重新归一化,否则 NdotL、sinTH 等点积类计算会产生亮度误差。UnityWorldSpaceViewDir 由世界坐标计算指向相机的方向;UnityWorldSpaceLightDir 对方向光返回恒定的光照方向,对点光/聚光返回逐像素指向光源的向量,二者均需归一化后使用。

五个向量在后续计算中的分工如下:

向量 来源 在光照计算中的作用
worldNormal 法线变换 + 归一化 漫反射 NdotL、球谐环境光求值、环境高光反射向量
worldTangent 切线变换 + 归一化 与法线叉积构造副切线
worldBinormal 叉积构造 + 归一化 头发高光的核心方向,即 Kajiya-Kay 模型中的纤维切向 T
worldViewDir UnityWorldSpaceViewDir 半角向量 H 与反射向量 R 的计算
worldLightDir UnityWorldSpaceLightDir Lambert 漫反射与各项高光的方向输入

需要说明的是,本实现将 worldBinormal 作为发丝走向(即高光模型中的 T),这取决于头发模型的 UV 展开方式:切线空间基向量中与贴图 UV 流向一致的那一个充当纤维方向。偏移与高光计算全部作用于该向量,因此它被称为头发高光的核心方向。

3.1.2 副切线的构造:cross(worldNormal, worldTangent) * tangent.w 的手性意义

o.worldBinormal = cross(o.worldNormal, o.worldTangent) * v.tangent.w;

副切线(Binormal)由法线与切线的叉积得到,三者构成切线空间的正交基。乘法因子 v.tangent.w 处理的是手性(handedness)问题:Unity 中顶点切线的 w 分量取值为 +1 或 -1,标识该顶点处切线空间是右手系还是左手系。当模型使用镜像 UV(例如左右对称的头发贴图共用一半纹理)时,镜像部分的切线空间手性翻转,w = -1 会把叉积结果取反,使副切线始终指向真实的发丝流向。对头发渲染而言,worldBinormal 直接决定高光带的位置,若忽略手性因子,镜像区域的高光方向会发生反转,出现左右不对称的错误反光。

3.2 纹理采样

3.2.1 _HairTex:头发颜色纹理,提供基础颜色与 Alpha

// 采样纹理
fixed4 texColor = tex2D(_HairTex, i.uv);

_HairTex 是头发颜色纹理:rgb 通道提供基础颜色,在 3.3.3 中作为反照率(albedo)调制光照结果;Alpha 通道记录发丝覆盖率,在两个 Pass 中用途不同——OPAQUE Pass 用它执行 clip 裁剪,TRANSPARENT Pass 用它作为混合权重输出(见 3.4.2)。采样用的 UV 在 vert 中经 TRANSFORM_TEX(v.uv, _HairTex) 处理,支持材质面板上的 Tiling/Offset 设置。

3.2.2 _SpecularShift:高光偏移纹理,r 通道减 0.5 后作为 ±0.5 随机扰动,模拟发丝不规则感

float shiftTex = tex2D(_SpecularShift, i.uv).r - 0.5;

_SpecularShift 是高光偏移纹理,采样后只取 r 通道。纹理值域为 [0,1][0,1],减 0.5 后映射到 [0.5,0.5][-0.5, 0.5],作为逐像素随机扰动分别叠加到 _PrimaryShift 与 _SecondaryShift 上,参与 shiftTangent 的切线偏移(详见第 4 章)。其意义在于:若所有像素使用相同的偏移量,高光带会排列得过于整齐;引入逐像素扰动后,相邻位置的高光偏移量产生差异,模拟真实头发中发丝走向的不规则感。

3.3 漫反射计算

漫反射部分采用「直接光 + 间接光」组合的方式,还原头发受光的基础明暗。相关代码如下:

// 光照计算
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

// 直接光漫反射 (兰伯特)
float NdotL = saturate(dot(worldNormal, worldLightDir));
fixed3 directDiffuse = _LightColor0.rgb * _DiffuseColor.rgb * NdotL * atten;

// 间接光漫反射 (球谐函数)
fixed3 ambient = ShadeSH9(float4(worldNormal, 1)) * _DiffuseColor.rgb;

3.3.1 直接光漫反射:Lambert 模型 NdotL × _LightColor0 × atten(光照衰减处理点光/聚光)

直接光使用 Lambert 模型:先计算 NdotL=saturate(NL)NdotL = saturate(N \cdot L),即法线与光照方向夹角余弦截断到 [0,1][0,1],saturate 避免背光面出现负值污染后续加法。直射贡献再乘以三项:_LightColor0 是当前主光源颜色(ForwardBase 路径下为场景中最亮的逐像素光源);_DiffuseColor 是材质暴露的漫反射颜色参数;atten 是光照衰减因子,由 UNITY_LIGHT_ATTENUATION 宏计算——对方向光它主要承载阴影衰减,对点光额外包含距离平方衰减,对聚光还包含锥角衰减。通过该宏,同一份代码无需分支即可兼容三种光源类型。

3.3.2 间接光漫反射:ShadeSH9 球谐函数计算环境光,避免阴影处过暗

ShadeSH9(float4(worldNormal, 1)) 使用球谐函数(Spherical Harmonics,SH)对环境光求值:Unity 将场景环境光(环境探针或光照设置中的 Ambient)预先编码为 9 个球谐系数,运行时按法线方向解码出该方向上的环境辐照度,w 分量传 1 表示按方向采样。间接光项同样乘以 _DiffuseColor,保证与直接光部分色调一致。加入该项后,背光面与阴影区域仍保留基础亮度,避免头发在阴影处过于暗沉,使整体光照过渡自然。

3.3.3 最终漫反射 = 纹理颜色 ×(直接光 + 间接光)

直接光与间接光先相加得到总辐照,再由 _HairTex 的纹理颜色作为反照率进行调制:

diffuse=texColor.rgb×(directDiffuse+ambient)diffuse = texColor.rgb \times (directDiffuse + ambient)

纹理颜色放在括号外相乘,意味着它同时调制直射与环境两个分量,保证发丝纹理细节在背光区域依然可见。

3.4 颜色合成与雾效

3.4.1 diffuse + specular 叠加,UNITY_APPLY_FOG 应用雾效

// 最终颜色合成
fixed3 diffuse = texColor.rgb * (directDiffuse + ambient);
fixed3 specular = kajiyaSpec + marschnerSpec + envSpec;
fixed4 col = fixed4(diffuse + specular, 1.0);

UNITY_APPLY_FOG(i.fogCoord, col);
return col;

specular 由 Kajiya-Kay 双层高光、Marschner 高光与环境高光三项相加得到,三项的推导与参数见第 4 章,此处只引用其合成结果。颜色合成是线性叠加:漫反射提供基础明暗与色调,高光叠加发丝反光。UNITY_APPLY_FOG(i.fogCoord, col) 按雾坐标将像素颜色向雾色混合,雾坐标在 vert 中由 UNITY_TRANSFER_FOG 写入,整套雾效代码由 #pragma multi_compile_fog 编译指令启用,使头发与场景其余部分的雾效表现一致。

3.4.2 两 Pass 唯一差异点:OPAQUE 输出 Alpha=1.0,TRANSPARENT 输出 texColor.a

光照流程至此在两个 Pass 中完全相同,唯一的代码差异出现在输出 Alpha 与 clip 行。OPAQUE Pass 保留裁剪并强制不透明输出:

// Alpha裁剪
clip(texColor.a - _AlphaCutoff);
...
fixed4 col = fixed4(diffuse + specular, 1.0);

TRANSPARENT Pass 注释掉 clip 行,保留纹理原始 Alpha 输出:

// Alpha裁剪 (透明部分不裁剪)
// clip(texColor.a - _AlphaCutoff);
...
fixed4 col = fixed4(diffuse + specular, texColor.a);

OPAQUE Pass 先用 clip 丢弃 Alpha 低于 _AlphaCutoff 的像素,再以 Alpha=1.0 输出,配合 ZWrite On 保证主体毛发的深度正确;TRANSPARENT Pass 不裁剪任何像素,将 _HairTex 的原始 Alpha 作为混合权重输出,配合 Blend SrcAlpha OneMinusSrcAlpha 让 Alpha 较低的边缘发丝与背景融合,模拟发丝间隙的透光效果。两个 Pass 共享同一份光照结果,仅在覆盖率处理上分工,渲染状态层面的完整对比见第 2 章。至此光照流程的漫反射与颜色合成部分已完整,三种高光模型的数学推导与代码实现由第 4 章展开。

4. 高光模型详解

第 3 章完成了基础向量准备、纹理采样与漫反射计算,得到头发受光的基础明暗。本章进入光照计算中的高光计算环节,这是整个 HairShader 的核心。头发高光的关键在于「多向性」:不同观察角度下头发会呈现多层、多方向的反光,单一高光项无法覆盖这种特性。为此,Shader 集成了 3 种高光模型:Kajiya-Kay 模型负责双层主高光,简化 Marschner 模型负责发丝细碎反光,环境高光负责模拟环境反射,三者按固定规则叠加。

需要先明确一个承接关系:第 3 章在顶点着色器中由 cross(o.worldNormal, o.worldTangent) * v.tangent.w 计算出的 worldBinormal(世界副切线),在本章的所有高光模型中都作为切线 T 使用,即 Kajiya-Kay 模型中「沿发丝走向」的方向;本章四个高光相关函数的切线入参全部由它(或其偏移结果)充当。

4.1 Kajiya-Kay 模型:头发主高光

4.1.1 模型原理

Kajiya-Kay 模型(1989 年提出)的核心假设是把头发纤维当作「不透明的细圆柱」,高光来自圆柱表面的反射,其关键数学处理是切线在光照平面上的投影。设头发切线方向为 T$(沿发丝走向),光照方向为 $L,观察方向为 VV,半角向量 H=normalize(L+V)H = \mathrm{normalize}(L + V),则 Kajiya-Kay 的 specular 公式为:

specular=sin(T,H)expspecular = \sin(T, H)^{exp}

其中 sin(T,H)\sin(T, H) 是切线 TT 与半角 HH 夹角的正弦,展开为:

sin(T,H)=1(TH)2\sin(T, H) = \sqrt{1 - (T \cdot H)^2}

该公式的几何含义是:把 TT 投影到由 LLVV 张成的「光照平面」上,得到一个切线分量,specular 强度随这个分量与 HH 的夹角余弦的高次幂衰减;指数 expexp 控制高光锐度,通常取 8~32。从推导细节看,圆柱表面的 specular 本质是「法线沿圆柱周向变化」:圆柱法线分布在垂直于切线的圆上,把 HH 投影到垂直 TT 的平面可得到一个「有效法线」,specular 即 NHN \cdot H 的幂;但由于圆柱法线沿周向均匀分布,最终结果退化为 sin(T,H)\sin(T, H) 而不是 cos\cos。这解释了公式中为何出现正弦:当 HHTT 接近垂直(即 HH 落在发丝横截面所在平面内)时 sin(T,H)\sin(T, H) 最大、高光最强,这与「高光带垂直于发丝走向」的直观观察一致。

从渲染方程的角度,Kajiya-Kay 模型的核心是为毛发纤维定义了一个简化的双向散射分布函数(BSDF,Bidirectional Scattering Distribution Function),其简化形式如下(忽略吸收和折射率细节):

f(l,v,t)=fsingle(l,v,t)+fmulti(l,v,t)f(\mathbf{l}, \mathbf{v}, \mathbf{t}) = f_{single}(\mathbf{l}, \mathbf{v}, \mathbf{t}) + f_{multi}(\mathbf{l}, \mathbf{v}, \mathbf{t})

式中三个向量的定义为:毛发切线向量 t\mathbf{t} 沿毛发纤维的生长方向(如头发丝的轴向),决定毛发的「朝向」;入射光向量 l\mathbf{l} 从光源指向纤维表面;出射光向量 v\mathbf{v} 从纤维表面指向观察者(相机)。$f_{single}$ 为单次散射项,$f_{multi}$ 为多次散射项;本 Shader 的hairStrandSpecular函数实现的是其中的单次散射高光项。

4.1.2 hairStrandSpecular 函数逐行解析

hairStrandSpecular是 Kajiya-Kay 模型的直接代码化,原始代码如下:

// Kajiya-Key高光模型
float hairStrandSpecular(float3 T, float3 V, float3 L, float specPower)
{
    // 1. 计算半角向量H(V+L归一化):对应Kajiya-Key模型中“光与视角的对称方向”
    float3 H = normalize(V + L);
    
    // 2. 计算切线T与半角H的点积:描述H与毛发流向的对齐程度
    float HdotT = dot(T, H);
    
    // 3. 计算sin(θ):θ是T与H的夹角,对应Kajiya-Key模型中“高光方向偏离度”
    // 原理:sin²θ + cos²θ = 1 → sinθ = sqrt(1 - cos²θ)(cosθ即HdotT)
    float sinTH = sqrt(1.0 - HdotT * HdotT);
    
    // 4. 方向衰减:仅保留“半角H位于切线T一侧”的高光(避免反向高光)
    // smoothstep(-_SpecularWidth, 0.0, HdotT):HdotT < -_SpecularWidth时衰减为0,0时为1
    float dirAtten = smoothstep(-_SpecularWidth, 0.0, HdotT);
    
    // 5. 最终高光强度:方向衰减 × (sinθ)^specPower(specPower控制高光锐度)
    // 对应Kajiya-Key模型“单次散射高光项”:k_s · sin^nθ
    return dirAtten * pow(sinTH, specPower);
}

逐行解析如下:

  • 函数签名:输入切线 T、视线方向 V、光照方向 L 与锐度指数 specPower(对应属性_specPower,Range 0~50,默认 20),输出标量高光强度。
  • 第 1 行H = normalize(V + L):计算半角向量。镜面反射在「法线恰好平分 L 与 V 夹角」时最强,因此 H 代表光与视角的对称方向,后续只需考察有效法线与 H 的对齐程度。
  • 第 2 行HdotT = dot(T, H):T 与 H 均为单位向量,点积即两者夹角的余弦 cosθ\cos\theta,描述 H 与毛发流向的对齐程度。
  • 第 3 行sinTH = sqrt(1.0 - HdotT * HdotT):由 sin2θ+cos2θ=1\sin^2\theta + \cos^2\theta = 1 求出 sinθ\sin\theta,对应模型中的「高光方向偏离度」。工程上需注意,浮点误差可能使 HdotT 略微超出 [1,1][-1, 1],导致根号内出现极小负值;本 Shader 未做钳制,更稳妥的变体写法是sqrt(max(0.0, 1.0 - HdotT * HdotT))
  • 第 4 行dirAtten = smoothstep(-_SpecularWidth, 0.0, HdotT):方向衰减,是相较于教科书公式多出的一项,详见 4.1.3。
  • 第 5 行return dirAtten * pow(sinTH, specPower):方向衰减乘以 sinnθ\sin^n\theta,对应 Kajiya-Kay 模型的单次散射高光项 $k_s \cdot \sin^n\theta$;specPower 越大,高光带越窄、越集中。

4.1.3 方向衰减 dirAtten

方向衰减来自smoothstep(-_SpecularWidth, 0.0, HdotT)smoothstep(edge0, edge1, x)的行为是:$x \le edge0$ 时返回 0,$x \ge edge1$ 时返回 1,中间做平滑的 Hermite 插值。代入本处参数后,当 $T \cdot H \ge 0$(半角向量位于切线正侧)时 dirAtten 为 1,高光完整保留;当 $T \cdot H \le -$_SpecularWidth时衰减为 0,高光被完全剔除;两者之间平滑过渡,过渡带宽度由_SpecularWidth(Range 0~1,默认 0.5)控制。

这一项的必要性在于 sin(T,H)\sin(T, H) 的对称性:$\sin\theta$ 由 1(TH)21 - (T \cdot H)^2 得到,对 THT \cdot H 的正负号不敏感。如果不乘方向衰减,逆光一侧(H 位于切线反侧)会出现与向光侧完全对称的高光,这在视觉上是不合理的。dirAtten 把高光限制在毛发「朝向光」的一侧,避免了反向(逆光侧)高光。

4.1.4 代码逻辑与模型原理对照表

代码逻辑 对应 Kajiya-Kay 模型原理 作用
半角向量 光与视角的对称方向 高光最亮的方向
sinTH 高光方向与毛发流向的偏离度 偏离越近,sinTH 越小,高光越弱
pow(sinTH, specPower) 高光锐度控制(n 为 specPower) 锐度越高,高光越集中(如直发)
dirAtten(方向衰减) 限制高光仅出现在毛发「朝向光」的一侧 避免反向(逆光侧)出现不合理高光

4.2 切线偏移与双层高光

4.2.1 shiftTangent 函数

单层 Kajiya-Kay 高光只有一条高光带,而真实头发常见主、次两条高光。产生双层高光的前提是先把切线偏移出两个方向,偏移由shiftTangent完成:

// 切线偏移函数:T(原始切线) + shift(偏移量)× N(法线)→ 新切线(归一化)
float3 shiftTangent(float3 T, float3 N, float shift)
{
    return normalize(T + shift * N);
}

函数将切线 T 沿法线 N 方向抬升 shift 后再归一化,几何上等效于把切线在「切线-法线平面」内旋转一个小角度。其物理含义是:真实头发的高光不会严格沿发丝走向出现,因为头发表面的毛鳞片存在约 3° 的倾斜角,使反射方向整体偏移。shift 大于 0 时高光往发根方向偏(鳞片朝发根倾斜),小于 0 时往发梢偏,这是 Kajiya-Kay 模型上做「双高光」(primary + secondary)的常用技巧。

4.2.2 双层偏移切线 t1/t2

在 frag 函数中,shiftTangent被调用两次,生成主、次两条偏移切线:

// 在frag函数中调用:生成两层偏移切线
float shiftTex = tex2D(_SpecularShift, i.uv).r - 0.5; // 纹理扰动:-0.5~0.5的随机偏移
float3 t1 = shiftTangent(worldBinormal, worldNormal, _PrimaryShift + shiftTex); // 主切线(带纹理扰动)
float3 t2 = shiftTangent(worldBinormal, worldNormal, _SecondaryShift + shiftTex); // 次切线(带纹理扰动)

这里有两个叠加的偏移来源。其一是常量参数:_PrimaryShift(Range -44,默认 0.0)与_SecondaryShift(Range -44,默认 0.5)分别控制主、次切线的偏移量,两者取不同值才能让两条高光带错开。其二是纹理扰动:采样第 3 章介绍过的_SpecularShift(高光偏移纹理),将其 r 通道从 [0,1][0, 1] 重映射到 [0.5,0.5][-0.5, 0.5],作为逐像素的随机偏移量同时叠加到两层 shift 上,打破高光带的规整感,模拟发丝位置的不规则。

4.2.3 双层高光叠加

两条偏移切线分别代入hairStrandSpecular,并乘以各自的颜色后叠加:

// 计算主高光(t1切线 + 主颜色)
fixed3 spec1 = _PrimaryColor.rgb * hairStrandSpecular(t1, worldViewDir, worldLightDir, _specPower);
// 计算次高光(t2切线 + 次颜色)
fixed3 spec2 = _SecondaryColor.rgb * hairStrandSpecular(t2, worldViewDir, worldLightDir, _specPower);
// 总Kajiya-Key高光:(主+次) × 强度缩放 × 光源颜色 × 光照衰减
fixed3 kajiyaSpec = (spec1 + spec2) * _SpecularScale * _LightColor0.rgb * atten;

_PrimaryColor_SecondaryColor分别为主、次高光着色,常见调色惯例是主高光带一点发色(染色高光)、次高光偏白(模拟发梢反光)。两者相加后再依次乘以三项:_SpecularScale(Range 0~1,默认 0.3)做整体强度缩放;_LightColor0.rgb让高光受光源颜色调制,例如暖色光源下高光随之偏暖;atten把第 3 章由UNITY_LIGHT_ATTENUATION得到的光照衰减作用于高光,使点光源、聚光灯的距离衰减对高光同样生效。这一双层结构对应 Kajiya-Kay 模型的「多纤维集合高光」,还原头发常见的「主高光 + 次高光」效果。

4.3 Marschner 简化模型:发丝细碎反光

4.3.1 原 Marschner 模型三分量

完整的 Marschner 模型把头发视为透明的椭圆截面纤维,将散射拆分为 3 个更精确的分量:R(表面反射,光线在纤维外表面直接反射)、TT(两次透射,光线穿入纤维后从另一侧穿出)、TRT(透射-反射-透射,光线进入纤维内部经一次内反射后穿出)。三个分量对应不同的光路,高光位置、颜色和形态各不相同,合起来能表达发丝丰富的光学现象,但代价是需要处理菲涅尔项、椭圆截面几何与纵向散射,计算复杂度远高于 Kajiya-Kay。

4.3.2 本 Shader 的简化

本 Shader 不追求完整的 Marschner 物理模型,而是对其核心视觉效果做简化拟合:省略 R/TT/TRT 三个分量,用单一高光项pow(sinTH, _MarschnerPower)拟合发丝边缘的细碎反光,让高光更有「丝感」。与 Kajiya-Kay 路径相比,该高光项不乘方向衰减,逆光侧也不会被截断,形态更柔和、分布更宽,适合作为双层主高光之外的细节补充。_MarschnerPower(Range 0100,默认 30)控制锐度,_MarschnerScale(Range 02,默认 0.5)控制整体强度。

4.3.3 marschnerSpecular 代码解析及与 Kajiya-Kay 函数的差异

// Marschner高光模型 (简化拟合)
float marschnerSpecular(float3 T, float3 V, float3 L, float power)
{
    // 1. 同样计算半角向量H(与Kajiya-Key一致,描述光与视角的对称方向)
    float3 H = normalize(L + V);
    
    // 2. 计算切线T与半角H的点积(与Kajiya-Key一致)
    float TdotH = dot(T, H);
    
    // 3. 计算sin(θ)(与Kajiya-Key一致,描述偏离度)
    float sinTH = sqrt(1.0 - TdotH * TdotH);
    
    // 4. 最终高光强度:仅保留(sinθ)^power(无方向衰减,更柔和)
    // 简化点:省略原Marschner的R/TT/TRT分量,用单一幂次模拟“细碎反光”
    return pow(sinTH, power);
}

该函数的输入输出与hairStrandSpecular完全一致(T/V/L 加幂次参数,返回 float 强度),前三行计算也逐行相同:半角向量 H、点积 TdotH(即 HdotT)、由 sin2θ+cos2θ=1\sin^2\theta + \cos^2\theta = 1 求 sinTH。差异集中在最后一步与调用方式上:其一,marschnerSpecular直接返回pow(sinTH, power),没有smoothstep方向衰减,因此高光更柔和、分布更宽;其二,幂次参数独立为_MarschnerPower,与 Kajiya-Kay 路径的_specPower互不影响,可分别调节;其三,Kajiya-Kay 路径使用偏移后的 t1/t2 并叠加双层颜色,而marschnerSpecular直接使用未偏移的 worldBinormal 做单色输出。从数学形式看,marschnerSpecular就是去掉方向衰减的hairStrandSpecular,二者共享同一套 sinTH 几何。

4.4 环境高光

4.4.1 实现

第三种高光不依赖光源方向,而是模拟环境反射,由environmentSpecular实现:

// 环境高光拟合
float environmentSpecular(float3 N, float3 V, float3 T, float power)
{
    float3 R = reflect(-V, N);
    float TdotR = dot(T, R);
    float sinTR = sqrt(1.0 - TdotR * TdotR);
    return pow(sinTR, power);
}

第一行用内置函数reflect计算视线关于法线的镜面反射向量 R=reflect(V,N)R = \mathrm{reflect}(-V, N),该方向近似代表「环境中能被该点看到的反射方向」(如天空或周围物体所在方向);第二、三行沿用与 Kajiya-Kay 相同的几何,计算 R 与切线 T 夹角的正弦 $\sin(T, R) = \sqrt{1 - (T \cdot R)^2}$;第四行取 power 次幂得到环境高光强度。frag 中的调用为environmentSpecular(worldNormal, worldViewDir, worldBinormal, _EnvSpecPower),即 T 仍然是发丝切线 worldBinormal——这意味着环境反射同样是各向异性的:只有当反射方向 R 与发丝走向接近垂直时反光才强,环境高光因此沿发丝形成带状分布,而非圆形光斑。

4.4.2 作用

环境高光用于模拟天空、周围物体在头发上的反射,让头发随环境变化呈现不同反光,增强真实感。从合成方式看,envSpec只乘以_EnvSpecColor_EnvSpecScale,不乘_LightColor0atten,因此它与主光源完全无关:即使头发处于主光源照不到的区域,环境反光依然存在,这与真实场景中头发始终带有环境反光的观察一致。_EnvSpecPower(Range 050,默认 10)控制反光锐度,_EnvSpecScale(Range 02,默认 0.2)控制强度。

4.5 高光合成

4.5.1 最终高光的合成方式

三种高光在 frag 末尾按固定规则合成。Marschner 高光与环境高光的调用及最终求和代码如下:

// Marschner高光
fixed3 marschnerSpec = _MarschnerColor.rgb * 
    marschnerSpecular(worldBinormal, worldViewDir, worldLightDir, _MarschnerPower) *
    _MarschnerScale * _LightColor0.rgb * atten;

// 环境高光
fixed3 envSpec = _EnvSpecColor.rgb * 
    environmentSpecular(worldNormal, worldViewDir, worldBinormal, _EnvSpecPower) *
    _EnvSpecScale;

// 最终颜色合成
fixed3 specular = kajiyaSpec + marschnerSpec + envSpec;

整体规则可概括为:最终高光 =(Kajiya-Kay 高光 + Marschner 高光)× 光源颜色 × 衰减 + 环境高光。其中「× 光源颜色 × 衰减」已分别包含在kajiyaSpec(见 4.2.3)与marschnerSpec的计算内部,envSpec不参与光源调制。这一规则确保高光既受光源影响,也受环境影响:前两项提供随主光源移动的定向高光,第三项提供随视角与环境变化的反射底色。

4.5.2 三种高光的分工对比表

高光类型 核心作用 实现逻辑 是否受光源颜色/衰减调制
Kajiya-Kay(双层) 模拟头发主高光(双层反光) 偏移切线 t1/t2,pow(sinTH, _specPower) 加方向衰减,双层颜色叠加
简化 Marschner 增强高光细节(发丝细碎反光) 基于 worldBinormal 的 pow(sinTH, _MarschnerPower),无方向衰减,更柔和
环境高光 模拟环境反射(如天空/周围物体反光) reflect(-V, N) 与切线夹角正弦的 pow(sinTR, _EnvSpecPower)

三种高光叠加后与第 3 章的漫反射颜色相加,再经雾效处理,即得到单个 Pass 的最终像素颜色;两个 Pass 的光照逻辑完全一致,差异仅在于 Alpha 处理与渲染状态。本章聚焦本 Shader 内部三种高光模型的实现与合成规则;在此基础上,第 5 章将进一步把 Kajiya-Kay 方案与「类 Marschner(方位角函数拟合)」方案做成本对比与切换实践,讨论何时应从 Kajiya-Kay 双高光升级到类 Marschner 的 R+TT 双高光。

5. Kajiya-Kay 与类 Marschner 方案对比及工程实践

第 4 章讨论了本 Shader 内部三种高光的实现,本章跳出具体实现做方案对比:先深化 Kajiya-Kay 的数学推导并给出带 wrap diffuse(包裹式漫反射)的完整参考实现,再介绍用方位角函数拟合 M 项的「类 Marschner」方案,最后给出 URP 移动端成本对比、切换踩坑与选型建议。本章代码来自独立笔记的参考实现而非本 Shader 源码,差异处会标明。

5.1 Kajiya-Kay 的数学推导深化

5.1.1 圆柱假设:法线沿圆柱周向均匀分布,specular 从 N·H 幂次退化为 sin(T,H) 幂次

第 4 章从几何直觉上说明了 specular 为何取 sin(T,H)\sin(T,H) 的幂次,本节补完推导。Kajiya-Kay(1989)把头发纤维视为不透明的细圆柱:圆柱面任一点的法线 NN 必垂直于切线 TT,全部法线分布在垂直于 TT 的单位圆上且沿周向均匀。对 Blinn-Phong 型高光,反射最强的条件是法线与半角向量 HH 对齐;法线遍布整个周向圆,因此无论 HH 指向何方,圆周上总存在最接近 HH 的「最优法线」,即 HH 在垂直 TT 平面上的投影归一化:

Neff=normalize(H(TH)T)N_{eff} = \mathrm{normalize}(H - (T \cdot H)\,T)

它与 HH 的点积为:

NeffH=1(TH)21(TH)2=1(TH)2=sin(T,H)N_{eff} \cdot H = \frac{1 - (T \cdot H)^2}{\sqrt{1 - (T \cdot H)^2}} = \sqrt{1 - (T \cdot H)^2} = \sin(T, H)

于是 (NH)exp(N \cdot H)^{exp} 在圆柱假设下退化为 sin(T,H)exp\sin(T, H)^{exp},且与方位角无关——随法线周向变化的 NHN \cdot H 被「周向均匀分布」吸收掉了,这就是公式取正弦而非余弦的代数原因。其代码化即笔记2给出的KajiyaKaySpecular

float KajiyaKaySpecular(float3 T, float3 H, float exponent)
{
    float TdotH = dot(T, H);
    float sinTH = sqrt(max(0.0, 1.0 - TdotH * TdotH));
    return pow(sinTH, exponent);
}

逐行解析:dot(T, H)求出夹角余弦 \cos\theta$;下一行由 $\sin^2\theta + \cos^2\theta = 1sinθ\sin\theta,其中max(0.0, ...)钳制浮点误差导致的根号内负值,比第 4 章本 Shader 的写法更稳妥;pow(sinTH, exponent)用幂次控制高光锐度,常用 8~32。

5.1.2 切线偏移方向约定:_PrimaryShift>0 高光往发根偏(鳞片朝发根倾斜),<0 往发梢偏

真实头发的高光不会严格沿发丝走向出现:头发表面的毛鳞片(cuticle)存在约 3° 倾斜角,使反射锥整体偏移。工程做法是把切线沿法线抬一个小角度:

float3 shiftedT = normalize(T + N * _PrimaryShift);

约定为:_PrimaryShift > 0时高光往发根偏(鳞片朝发根倾斜),< 0时往发梢偏;隐含前提是切线 TT 从发根指向发梢,否则正负含义整体反转。第 4 章的shiftTangent与此式同构,区别仅在于第 4 章叠加了_SpecularShift纹理扰动,这里用纯常量。该约定也是 5.4.1 踩坑第 2 条的基础。

5.1.3 完整 KK 着色实现 HairShading_KK:双高光 + wrap diffuse(saturate(NdotL*0.5+0.5),避免背光面全黑)

笔记2给出了一份完整的 Kajiya-Kay 着色参考实现HairShading_KK,包含双高光与漫反射的完整合成:

half3 HairShading_KK(float3 N, float3 T, float3 L, float3 V, half3 baseColor,
                     half shift, half primaryExp, half secondaryExp,
                     half3 specColor1, half3 specColor2)
{
    float3 H = normalize(L + V);

    // 切线偏移:primary 和 secondary 用不同 shift
    float3 Tp = normalize(T + N * shift);
    float3 Ts = normalize(T + N * (-shift * 0.5));

    // 双高光
    float spec1 = KajiyaKaySpecular(Tp, H, primaryExp);
    float spec2 = KajiyaKaySpecular(Ts, H, secondaryExp);

    // diffuse:Kajiya-Kay 用的是 wrapped diffuse,避免背光面全黑
    float NdotL = dot(N, L);
    float diffuse = saturate(NdotL * 0.5 + 0.5);  // wrap diffuse

    half3 color = baseColor * diffuse
                + specColor1 * spec1
                + specColor2 * spec2;
    return color;
}

逐段解析。双切线偏移:primary 偏移shift,secondary 偏移-shift * 0.5——次高光反向且幅度减半,两条高光带错开,近似 Marschner 的「M 线分离」;与本 Shader 的_PrimaryShift/_SecondaryShift双独立参数不同,此处次高光偏移由主偏移派生,不易出错。双高光求值:两切线分别代入KajiyaKaySpecular,幂次独立;调色惯例是specColor1带发色、specColor2偏白(模拟发梢反光)。wrap diffusesaturate(NdotL * 0.5 + 0.5)把点积从 [1,1][-1, 1] 重映射到 [0,1][0, 1],背光面保留平缓渐变而不截断为 0——头发并非不透明实体,背光面不该全黑。

需要明确:HairShading_KK与第 4 章的hairStrandSpecular不是同一段代码:高光核相同(都是 sin(T,H)\sin(T,H) 幂次),但本 Shader 多一项smoothstep方向衰减,漫反射走「兰伯特直接光 + ShadeSH9 球谐间接光」而非 wrap diffuse,合成时另乘_LightColor0atten。前者是「教科书式最小完整版」,本 Shader 是「工程扩展版」。

5.2 类 Marschner 简化方案:方位角函数拟合 M 项

5.2.1 完整 Marschner 的复杂度来源:菲涅尔 + 椭圆截面几何 + 纵向高斯

第 4 章已介绍过完整 Marschner 的 R(表面反射)、TT(两次透射)、TRT(透射-反射-透射)三分量结构。其复杂度集中在三处:菲涅尔项(每个分量按入射角求解)、椭圆截面几何(方位角光路沿椭圆截面求解)、纵向高斯(每个分量在纵向上是一个高斯瓣)。最重的是 M 项(纵向散射函数),依赖入射与出射方位角的关系,需真实物理积分才能求准。本 Shader 的marschnerSpecular把这一切压成单幂次拟合,只保留形态;「类 Marschner」再进一步:保留 M 项结构,用方位角函数拟合替代真实物理积分。

5.2.2 M 项高斯拟合:M(θr,θi) = exp(-(θr-θr0)²/(2β²)),中心角 2α+θi 与鳞片倾斜关系

M 项描述纵向平面上反射方向相对发丝切线的偏角 θr\theta_r 与入射偏角 θi\theta_i 的关系,Marschner 用高斯拟合纵向散射:

M(θr,θi)=exp((θrθr0)22β2)M(\theta_r, \theta_i) = \exp\left(-\frac{(\theta_r - \theta_{r0})^2}{2\beta^2}\right)

其中 θr0\theta_{r0} 是反射中心角,$\beta$ 是粗糙度(高斯瓣宽度)。中心角与鳞片倾斜直接相关:设鳞片倾斜角为 α\alpha,一次反射(R 分量)的反射锥中心相对镜面方向再偏 2α2\alpha,即 θr0=2α+θi\theta_{r0} = 2\alpha + \theta_i。这等价于「把 Kajiya-Kay 的 power 项换成高斯」:KK 用幂函数压出高光带,类 Marschner 用高斯瓣,衰减尾部更自然,且中心角随鳞片倾斜显式偏移,不必像 KK 那样靠移动切线间接实现。

5.2.3 工业界简化实现(Scheuermann / d'Eon):sinThetaI/sinThetaR、M_R/M_TT、N_R/N_TT 方位角拟合、Schlick 菲涅尔

笔记2摘录了一套来自 Scheuermann / d'Eon 工业实现的类 Marschner 拟合代码,原样保留如下:

// 纵向角
float cosThetaI = dot(T, L);
float cosThetaR = dot(T, V);
float sinThetaI = sqrt(1 - cosThetaI * cosThetaI);
float sinThetaR = sqrt(1 - cosThetaR * cosThetaR);

// 中心反射角:鳞片倾斜 α,入射角 θi → 反射中心 θr = 2α + θi(一次反射)
float centerAngle = 2.0 * _CuticleTilt + cosThetaI; // 简化:用 cos 近似

// 高斯 M 项
float M_R = Gaussian(sinThetaR - centerAngle, _BetaR);
float M_TT = Gaussian(sinThetaR + cosThetaI, _BetaTT); // TT 项中心在另一侧

// 方位角项 N:用拟合的 cos 函数,避免完整 Fresnel 积分
float N_R  = lerp(0.25, 1.0, saturate(cosThetaR));   // R 项方位角衰减
float N_TT = 0.5;                                     // TT 项近似均匀

逐段解析。纵向角cosThetaI/cosThetaR是 L、V 与切线的夹角余弦,两个 sin 值充当纵向偏角的代理量。中心反射角centerAngle对应 θr0=2α+θi\theta_{r0} = 2\alpha + \theta_i,注释标明用 cos 值近似角度本身,省去反三角函数。高斯 M 项Gaussian(x, beta) = exp(-x*x / (2*beta*beta))M_RcenterAngle为中心,M_TT中心取负号,即 TT 高光中心在另一侧——透射光路的几何特征。方位角项 NN_R拟合 R 项随方位角的衰减(正对最强,背向保留 0.25 下限),N_TT取常数 0.5 近似均匀,避开完整 Marschner 在方位角方向的菲涅尔积分。

R、TT 两个分量的完整 specular 由 M 项、N 项与菲涅尔三项相乘:

float spec_R = M_R * N_R * Fresnel_R(cosThetaI);
float spec_TT = M_TT * N_TT * Fresnel_TT(cosThetaI);

菲涅尔用 Schlick 近似($F = F_0 + (1 - F_0)(1 - \cos\theta)^5$):Fresnel_R正面(cosThetaI ≈ 1)约 0.05,掠射增强;Fresnel_TT掠射衰减——透射光路要求光先进纤维再穿出,掠射时光大多被表面反射走,即「光进得去才出得来」。整套拟合在移动端跑得动,效果接近 Marschner 的 R + TT 双高光,但 TT 的发梢透光比真 Marschner 弱。

5.3 成本对比

5.3.1 URP 移动端实测对比表:KK 双高光 ~25 ALU / 类 Marschner(R+TT) ~60 ALU / 完整 Marschner ~150+ ALU

笔记2在 URP(通用渲染管线,Universal Render Pipeline)移动端 GLES 3.2 环境下实测了三种方案的逐像素成本,数据原样保留如下:

方案 ALU/像素 贴图采样 效果
Kajiya-Kay 双高光 ~25 1(发色图) 过得去,高光位置对,形态偏窄
类 Marschner (R+TT) ~60 1 接近真 Marschner,TT 透光稍弱
完整 Marschner ~150+ 1~2 最好,移动端掉帧

ALU(算术逻辑单元,Arithmetic Logic Unit)消耗大致反映着色器的计算指令压力:Kajiya-Kay 只要一个 power,成本极低;类 Marschner 多出 TT 项与高斯拟合,ALU 约翻倍但移动端仍可接受;完整 Marschner 超 150 ALU,移动端掉帧,不具备落地价值。

5.3.2 效果权衡分析:KK 无 TT 透光背光面偏灰靠 wrap diffuse 补;类 Marschner TT 透光自然但 β 调大会糊

成本差异背后是效果取向的差异。Kajiya-Kay 的高光形态固定为一条窄带,位置正确但缺少层次;更关键的是没有 TT 分量,背光面发黑,只能靠 wrap diffuse 托起亮度,而 wrap diffuse 不带颜色倾向,补出的背光面偏灰。类 Marschner 的 TT 项让发丝边缘在逆光时透出背景侧光色,质感明显好一档;代价是参数更敏感,_BetaTT调大会糊(比例见 5.4.1 第 5 条)。取舍可概括为:KK 用最低 ALU 买「位置正确的高光」,类 Marschner 用约 2.4 倍 ALU 买「透光与质感」。

5.4 切换踩坑与选型建议

5.4.1 六大踩坑点:切线方向约定(flow map/顶点色)、双高光 shift 方向、N·V 符号、gamma/线性空间调参、背光透光 β 比例(TT 的 β 取 R 的 2~3 倍)、高 exp 高光 MSAA 闪烁

笔记2记录了在 Kajiya-Kay 与类 Marschner 之间切换时的六条踩坑,逐条展开如下。

1. 切线方向约定。 两个模型都要求切线 TT 严格沿发丝走向,但 Unity 顶点数据里的tangent是给法线贴图用的,方向由 UV 展开决定,不一定是发丝走向。解法有两种:用 flow map(流向图)重建切线,或在顶点色里预存发丝方向。这是最容易翻车的环节——切线错了高光带方向整体错乱,还容易被误判为「参数没调好」。

2. 双高光 shift 方向。 primary shift 取正、secondary shift 取负,两条高光带才会朝发根、发梢两侧错开;同号则叠在一起,看不出「M 线分离」。5.1.3 的参考实现把次高光偏移写死为-shift * 0.5,正是从参数层面杜绝此错。

3. N·V 符号。 类 Marschner 的 TT 项在背光面增强,判断「背光」依赖观察方向符号,必须保证 VV 朝向相机时 NVN \cdot V 为正,别被 URP 的GetWorldSpaceNormalizeViewDir的符号约定坑到。

4. gamma/线性空间调参。 exp应在线性空间下调;预览若工作在 sRGB,高光偏亮,按偏亮画面调出的参数放进线性管线就偏暗。切换两套方案时expβ量纲不同,不能照搬,必须重新调。

5. 背光透光 β 比例。 Kajiya-Kay 没有 TT 项,背光面靠 wrap diffuse 补亮,颜色偏灰;类 Marschner 的 TT 能透出背景色,但_BetaTT调大会糊。经验比例是 TT 的β取 R 的 2~3 倍:透射光路经纤维内部两次折射,散射天然比表面反射宽。

6. 高 exp 高光的 MSAA 闪烁。exp的 Kajiya-Kay 高光在 MSAA(多重采样抗锯齿,Multi-Sample Anti-Aliasing)下会闪烁:sinTH在发丝边缘跳变,幂次越高跳变越尖锐。类 Marschner 的高斯形态衰减平滑,天然抗锯齿——这是切换后一个意外的好处。

5.4.2 选型建议:移动端 KK 双高光打底 → 质感升级换类 Marschner R+TT → 完整 Marschner 留给 PC/主机

综合成本、效果与踩坑成本,笔记2给出的分平台选型策略是:移动端先用 Kajiya-Kay 双高光打底,需要质感升级再换类 Marschner 的 R+TT,完整 Marschner 留给 PC/主机。三个档位的对应关系如下:

目标平台/档位 推荐方案 取舍理由
移动端基础档 Kajiya-Kay 双高光 ~25 ALU/像素,高光位置正确,成本最低,调参简单
移动端质感档 类 Marschner (R+TT) ~60 ALU/像素,TT 透光自然,高斯形态抗锯齿
PC/主机档 完整 Marschner ~150+ ALU/像素,R/TT/TRT 三分量完整,效果上限最高

回到本 Shader:它采用「Kajiya-Kay 双层高光 + 单幂次简化 Marschner + 环境高光」的组合,定位上属于移动端基础档的加强版。按本章路径升级,下一步是把marschnerSpecular替换为 5.2.3 的 M/N/菲涅尔三项结构以获得真正的 TT 透光;继续向上则需在 PC/主机平台引入完整 Marschner 三分量求解。「双 Pass 保证体量与层次、光照模型按平台分档」的组合,构成本文档技术主线的完整闭环。本 Shader 的完整代码与全部参数取值汇总于第 6 章,便于工程落地时直接查阅。

6. 完整 Shader 代码与参数手册

本章为手册章:6.1 节给出全部可调参数的速查表,6.2 节收录可直接编译的完整 Shader 代码,6.3 节对关键实现细节作定位式说明。读者可仅凭本章将代码复制到 Unity 工程中使用;渲染状态、光照流程、高光模型的原理推导分别详见第 2、3、4 章,本章不再重复。

6.1 Properties 参数总表

Properties 块共声明 17 个参数,按功能分为四组:基础纹理与漫反射(_HairTex_SpecularShift_DiffuseColor_AlphaCutoff)、Kajiya-Kay 双层高光(_PrimaryColor_PrimaryShift_SecondaryColor_SecondaryShift_specPower_SpecularWidth_SpecularScale)、Marschner 高光(_MarschnerColor_MarschnerPower_MarschnerScale)与环境高光(_EnvSpecColor_EnvSpecPower_EnvSpecScale)。材质 Inspector 中显示的名称(如 "Specular1Color")与代码变量名并不一致,调参与脚本赋值时应以下表变量名为准。

6.1.1 全部 17 个参数的名称 / 类型 / 默认值 / 取值范围 / 作用 / 调参建议(表格)

参数名 类型 默认值 范围 作用 调参建议
_HairTex 2D 纹理 "white" 头发颜色纹理;frag 采样得到基础颜色 texColor,其 Alpha 通道决定主体毛发与边缘发丝的划分 使用带 Alpha 通道的发丝纹理;Alpha 分布直接决定 Pass 1 裁剪后保留的主体范围,与 _AlphaCutoff 联动调整
_SpecularShift 2D 纹理 "white" 高光偏移纹理;采样 .r 通道减 0.5 得到 -0.5~0.5 的扰动量 shiftTex,叠加到两层切线偏移上 用灰度噪声纹理模拟发丝不规则感;注意 0.5 纯灰才等价于无扰动,纯白会产生恒定的 +0.5 偏移
_DiffuseColor Color (0.0, 0.0, 0.0, 0.0) 漫反射颜色,同时乘入直接光漫反射(兰伯特)与球谐间接光 默认值为黑色,直接使用会导致漫反射全黑,需先按目标发色提亮
_AlphaCutoff Range 0.5 0~1 Pass 1 的 Alpha 裁剪阈值,texColor.a 低于该值的像素被丢弃 调大则主体范围缩小、更多像素改由透明 Pass 表现;按发丝纹理的 Alpha 分布微调
_PrimaryColor Color (0.0, 0.0, 0.0, 0.0) Kajiya-Kay 主高光(第一层,t1 切线)颜色 默认黑色即关闭主高光,需提亮后生效;具体色值按美术效果微调
_PrimaryShift Range 0.0 -4~4 主高光的副切线偏移量,决定主高光带相对纤维表面的位置 按美术效果微调
_SecondaryColor Color (0.0, 0.0, 0.0, 0.0) Kajiya-Kay 次高光(第二层,t2 切线)颜色 默认黑色即关闭次高光;按美术效果微调
_SecondaryShift Range 0.5 -4~4 次高光的副切线偏移量;默认 0.5 与主高光(默认 0.0)错开,形成双层反光 _PrimaryShift 保持不同取值,使两层高光分离
_specPower Range 20 0~50 Kajiya-Kay 高光锐度,即 pow(sinTH, _specPower) 的幂次,主、次两层共用 取值越高高光越集中(如直发);调低则高光带变宽
_SpecularWidth Range 0.5 0~1 Kajiya-Kay 方向衰减宽度,即 smoothstep(-_SpecularWidth, 0.0, HdotT) 过渡带的宽度 调大过渡带更宽、高光更柔和;调小更接近硬截止,只保留朝向光一侧的高光
_SpecularScale Range 0.3 0~1 Kajiya-Kay 双层高光的整体强度缩放,乘在 (spec1 + spec2) 上 控制双层高光总强度;按美术效果微调
_MarschnerColor Color (1,1,1,1) Marschner 高光颜色 按美术效果微调
_MarschnerPower Range 30 0~100 Marschner 高光锐度,即 pow(sinTH, _MarschnerPower) 的幂次 幂次越高细碎反光越集中、「丝感」越强;调低则更柔和
_MarschnerScale Range 0.5 0~2 Marschner 高光强度缩放 按美术效果微调
_EnvSpecColor Color (0.5,0.5,0.5,1) 环境高光颜色 按美术效果微调
_EnvSpecPower Range 10 0~50 环境高光锐度,即 pow(sinTR, _EnvSpecPower) 的幂次 按美术效果微调
_EnvSpecScale Range 0.2 0~2 环境高光强度缩放;该项不乘光源颜色与 atten,贡献仅由颜色与本缩放控制 按美术效果微调

需要单独指出三个颜色参数的默认值问题:_DiffuseColor_PrimaryColor_SecondaryColor 的默认值均为全黑 (0.0, 0.0, 0.0, 0.0)。按代码中的颜色合成方式,新建材质直接使用默认值渲染时,漫反射项与 Kajiya-Kay 双层高光均无贡献,画面只剩 Marschner 高光与环境高光;首次调参应先为这三项赋予非零颜色,再调整强度类参数。

6.2 完整 Shader 代码

以下代码逐字摘录自原始笔记,共 375 行,未删减任何 Pass、未改动任何一行(含笔记原有的行内注释)。

6.2.1 完整 HairShader 代码(双 Pass,逐段注释)

将本小节代码整体保存为 HairShader.shader 放入 Unity 工程的 Assets 目录即可编译使用。

Shader "HairShader"
{
    Properties
    {
        _HairTex("Texture", 2D) = "white" {}
        _SpecularShift("Hair Shifted Texture", 2D) = "white" {}

        _DiffuseColor("DiffuseColor", Color) = (0.0, 0.0, 0.0, 0.0)
        _AlphaCutoff("Alpha Cutoff", Range(0,1)) = 0.5

        _PrimaryColor("Specular1Color", Color) = (0.0, 0.0, 0.0, 0.0)
        _PrimaryShift("PrimaryShift", Range(-4, 4)) = 0.0
        _SecondaryColor("Specular2Color", Color) = (0.0, 0.0, 0.0, 0.0)
        _SecondaryShift("SecondaryShift", Range(-4, 4)) = 0.5
        
        _specPower("SpecularPower", Range(0, 50)) = 20
        _SpecularWidth("SpecularWidth", Range(0, 1)) = 0.5
        _SpecularScale("SpecularScale", Range(0, 1)) = 0.3
        
        // Marschner高光参数
        _MarschnerColor("Marschner Color", Color) = (1,1,1,1)
        _MarschnerPower("Marschner Power", Range(0,100)) = 30
        _MarschnerScale("Marschner Scale", Range(0,2)) = 0.5
        
        // 环境高光参数
        _EnvSpecColor("Environment Spec Color", Color) = (0.5,0.5,0.5,1)
        _EnvSpecPower("Environment Spec Power", Range(0,50)) = 10
        _EnvSpecScale("Environment Spec Scale", Range(0,2)) = 0.2
    }
    SubShader
    {
        Tags { 
            "RenderType"="Transparent" 
            "Queue"="Transparent"
        }
        LOD 100

        // 不透明部分Pass (深度写入开启)
        Pass
        {
            Name "OPAQUE"
            Tags { "LightMode" = "ForwardBase" }
            ZWrite On
            Blend Off
            Cull Back
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            #pragma multi_compile_fwdbase

            #include "UnityCG.cginc"
            #include "Lighting.cginc"
            #include "AutoLight.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal: NORMAL;
                float4 tangent: TANGENT;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD1;
                float3 worldNormal : TEXCOORD2;
                float3 worldTangent : TEXCOORD3;
                float3 worldBinormal : TEXCOORD4;
                UNITY_FOG_COORDS(5)
                LIGHTING_COORDS(6,7)
            };

            sampler2D _HairTex;
            float4 _HairTex_ST;
            sampler2D _SpecularShift;
            float4 _SpecularShift_ST;
            
            float4 _DiffuseColor;
            float _AlphaCutoff;
            
            float4 _PrimaryColor;
            float _PrimaryShift;
            float4 _SecondaryColor;
            float _SecondaryShift;
            
            float _specPower;
            float _SpecularWidth;
            float _SpecularScale;
            
            float4 _MarschnerColor;
            float _MarschnerPower;
            float _MarschnerScale;
            
            float4 _EnvSpecColor;
            float _EnvSpecPower;
            float _EnvSpecScale;

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _HairTex);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
                o.worldBinormal = cross(o.worldNormal, o.worldTangent) * v.tangent.w;
                
                UNITY_TRANSFER_FOG(o,o.pos);
                TRANSFER_VERTEX_TO_FRAGMENT(o);
                return o;
            }
            
            // 计算切线偏移
            float3 shiftTangent(float3 T, float3 N, float shift)
            {
                return normalize(T + shift * N);
            }
            
            // Kajiya-Key高光模型
            float hairStrandSpecular(float3 T, float3 V, float3 L, float specPower)
            {
                float3 H = normalize(V + L);
                float HdotT = dot(T, H);
                float sinTH = sqrt(1.0 - HdotT * HdotT);
                float dirAtten = smoothstep(-_SpecularWidth, 0.0, HdotT);
                return dirAtten * pow(sinTH, specPower);
            }
            
            // Marschner高光模型 (简化拟合)
            float marschnerSpecular(float3 T, float3 V, float3 L, float power)
            {
                float3 H = normalize(L + V);
                float TdotH = dot(T, H);
                float sinTH = sqrt(1.0 - TdotH * TdotH);
                return pow(sinTH, power);
            }
            
            // 环境高光拟合
            float environmentSpecular(float3 N, float3 V, float3 T, float power)
            {
                float3 R = reflect(-V, N);
                float TdotR = dot(T, R);
                float sinTR = sqrt(1.0 - TdotR * TdotR);
                return pow(sinTR, power);
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // 基础向量
                float3 worldNormal = normalize(i.worldNormal);
                float3 worldTangent = normalize(i.worldTangent);
                float3 worldBinormal = normalize(i.worldBinormal);
                float3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
                float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
                
                // 采样纹理
                fixed4 texColor = tex2D(_HairTex, i.uv);
                
                // Alpha裁剪
                clip(texColor.a - _AlphaCutoff);
                
                // 光照计算
                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                
                // 直接光漫反射 (兰伯特)
                float NdotL = saturate(dot(worldNormal, worldLightDir));
                fixed3 directDiffuse = _LightColor0.rgb * _DiffuseColor.rgb * NdotL * atten;
                
                // 间接光漫反射 (球谐函数)
                fixed3 ambient = ShadeSH9(float4(worldNormal, 1)) * _DiffuseColor.rgb;
                
                // Kajiya-Key高光
                float shiftTex = tex2D(_SpecularShift, i.uv).r - 0.5;
                float3 t1 = shiftTangent(worldBinormal, worldNormal, _PrimaryShift + shiftTex);
                float3 t2 = shiftTangent(worldBinormal, worldNormal, _SecondaryShift + shiftTex);
                
                fixed3 spec1 = _PrimaryColor.rgb * hairStrandSpecular(t1, worldViewDir, worldLightDir, _specPower);
                fixed3 spec2 = _SecondaryColor.rgb * hairStrandSpecular(t2, worldViewDir, worldLightDir, _specPower);
                fixed3 kajiyaSpec = (spec1 + spec2) * _SpecularScale * _LightColor0.rgb * atten;
                
                // Marschner高光
                fixed3 marschnerSpec = _MarschnerColor.rgb * 
                    marschnerSpecular(worldBinormal, worldViewDir, worldLightDir, _MarschnerPower) *
                    _MarschnerScale * _LightColor0.rgb * atten;
                
                // 环境高光
                fixed3 envSpec = _EnvSpecColor.rgb * 
                    environmentSpecular(worldNormal, worldViewDir, worldBinormal, _EnvSpecPower) *
                    _EnvSpecScale;
                
                // 最终颜色合成
                fixed3 diffuse = texColor.rgb * (directDiffuse + ambient);
                fixed3 specular = kajiyaSpec + marschnerSpec + envSpec;
                fixed4 col = fixed4(diffuse + specular, 1.0);
                
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }

        // 透明部分Pass (Alpha混合)
        Pass
        {
            Name "TRANSPARENT"
            Tags { "LightMode" = "ForwardBase" }
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            Cull Back
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            #pragma multi_compile_fwdbase

            #include "UnityCG.cginc"
            #include "Lighting.cginc"
            #include "AutoLight.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal: NORMAL;
                float4 tangent: TANGENT;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD1;
                float3 worldNormal : TEXCOORD2;
                float3 worldTangent : TEXCOORD3;
                float3 worldBinormal : TEXCOORD4;
                UNITY_FOG_COORDS(5)
                LIGHTING_COORDS(6,7)
            };

            sampler2D _HairTex;
            float4 _HairTex_ST;
            sampler2D _SpecularShift;
            float4 _SpecularShift_ST;
            
            float4 _DiffuseColor;
            float _AlphaCutoff;
            
            float4 _PrimaryColor;
            float _PrimaryShift;
            float4 _SecondaryColor;
            float _SecondaryShift;
            
            float _specPower;
            float _SpecularWidth;
            float _SpecularScale;
            
            float4 _MarschnerColor;
            float _MarschnerPower;
            float _MarschnerScale;
            
            float4 _EnvSpecColor;
            float _EnvSpecPower;
            float _EnvSpecScale;

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _HairTex);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
                o.worldBinormal = cross(o.worldNormal, o.worldTangent) * v.tangent.w;
                
                UNITY_TRANSFER_FOG(o,o.pos);
                TRANSFER_VERTEX_TO_FRAGMENT(o);
                return o;
            }
            
            // 计算切线偏移
            float3 shiftTangent(float3 T, float3 N, float shift)
            {
                return normalize(T + shift * N);
            }
            
            // Kajiya-Key高光模型
            float hairStrandSpecular(float3 T, float3 V, float3 L, float specPower)
            {
                float3 H = normalize(V + L);
                float HdotT = dot(T, H);
                float sinTH = sqrt(1.0 - HdotT * HdotT);
                float dirAtten = smoothstep(-_SpecularWidth, 0.0, HdotT);
                return dirAtten * pow(sinTH, specPower);
            }
            
            // Marschner高光模型 (简化拟合)
            float marschnerSpecular(float3 T, float3 V, float3 L, float power)
            {
                float3 H = normalize(L + V);
                float TdotH = dot(T, H);
                float sinTH = sqrt(1.0 - TdotH * TdotH);
                return pow(sinTH, power);
            }
            
            // 环境高光拟合
            float environmentSpecular(float3 N, float3 V, float3 T, float power)
            {
                float3 R = reflect(-V, N);
                float TdotR = dot(T, R);
                float sinTR = sqrt(1.0 - TdotR * TdotR);
                return pow(sinTR, power);
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // 基础向量
                float3 worldNormal = normalize(i.worldNormal);
                float3 worldTangent = normalize(i.worldTangent);
                float3 worldBinormal = normalize(i.worldBinormal);
                float3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
                float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
                
                // 采样纹理
                fixed4 texColor = tex2D(_HairTex, i.uv);
                
                // Alpha裁剪 (透明部分不裁剪)
                // clip(texColor.a - _AlphaCutoff);
                
                // 光照计算
                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                
                // 直接光漫反射 (兰伯特)
                float NdotL = saturate(dot(worldNormal, worldLightDir));
                fixed3 directDiffuse = _LightColor0.rgb * _DiffuseColor.rgb * NdotL * atten;
                
                // 间接光漫反射 (球谐函数)
                fixed3 ambient = ShadeSH9(float4(worldNormal, 1)) * _DiffuseColor.rgb;
                
                // Kajiya-Key高光
                float shiftTex = tex2D(_SpecularShift, i.uv).r - 0.5;
                float3 t1 = shiftTangent(worldBinormal, worldNormal, _PrimaryShift + shiftTex);
                float3 t2 = shiftTangent(worldBinormal, worldNormal, _SecondaryShift + shiftTex);
                
                fixed3 spec1 = _PrimaryColor.rgb * hairStrandSpecular(t1, worldViewDir, worldLightDir, _specPower);
                fixed3 spec2 = _SecondaryColor.rgb * hairStrandSpecular(t2, worldViewDir, worldLightDir, _specPower);
                fixed3 kajiyaSpec = (spec1 + spec2) * _SpecularScale * _LightColor0.rgb * atten;
                
                // Marschner高光
                fixed3 marschnerSpec = _MarschnerColor.rgb * 
                    marschnerSpecular(worldBinormal, worldViewDir, worldLightDir, _MarschnerPower) *
                    _MarschnerScale * _LightColor0.rgb * atten;
                
                // 环境高光
                fixed3 envSpec = _EnvSpecColor.rgb * 
                    environmentSpecular(worldNormal, worldViewDir, worldBinormal, _EnvSpecPower) *
                    _EnvSpecScale;
                
                // 最终颜色合成 (保留原始透明度)
                fixed3 diffuse = texColor.rgb * (directDiffuse + ambient);
                fixed3 specular = kajiyaSpec + marschnerSpec + envSpec;
                fixed4 col = fixed4(diffuse + specular, texColor.a);
                
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
    FallBack "Transparent/Cutout/VertexLit"
}

代码整体结构为「一个 Properties 块 + 一个 SubShader(含两个 Pass)+ 一行 FallBack」,各段职责如下(行号以代码块内 1-based 计)。

Properties 块(第 3–29 行)。 声明 6.1.1 表中的 17 个参数;这些声明只负责在材质面板暴露接口,CGPROGRAM 内部还需用同名变量再声明一次(两个 Pass 内各有一份完整的 uniform 声明区),纹理参数额外配套 _ST 后缀变量供 UV 变换宏使用。

SubShader 头部(第 30–36 行)。 Tags 指定渲染类型与渲染队列,LOD 100 设定细节等级,语义详见 6.3.2。

Pass "OPAQUE"(第 38–204 行)。 不透明主体 Pass:Pass 头部设定 ZWrite OnBlend OffCull Back,Tags 指定 LightMode = ForwardBase;CGPROGRAM(第 47–203 行)内依次为编译指令与 cginc 包含、appdata/v2f 结构体、uniform 声明区、顶点着色器 vert(第 102 行起)、四个工具函数 shiftTangent、hairStrandSpecular、marschnerSpecular、environmentSpecular(第 118–149 行),以及片元着色器 frag(第 151 行起)。vert 的细节见 6.3.1,四个函数与光照合成的逐行推导见第 3、4 章。

Pass "TRANSPARENT"(第 206–372 行)。 透明边缘 Pass:CGPROGRAM 内容与 Pass "OPAQUE" 逐行相同,仅 Pass 头部渲染状态与 frag 内两处语句不同,差异的精确定位见 6.3.3。

FallBack(第 374 行)。 降级路径声明,语义详见 6.3.2。

6.3 关键实现细节说明

6.3.1 顶点着色器:向量变换与 LIGHTING_COORDS / FOG 宏

两个 Pass 的 vert 函数完全相同,由六行赋值语句与两个传递宏组成,逐条说明其作用。

o.pos = UnityObjectToClipPos(v.vertex) 将模型空间顶点变换到裁剪空间,等价于 mul(UNITY_MATRIX_MVP, v.vertex),其结果写入 v2f 的 SV_POSITION 语义。o.uv = TRANSFORM_TEX(v.uv, _HairTex) 应用材质面板 Tiling/Offset(即 _HairTex_ST)对 UV 做缩放偏移;需要注意,_SpecularShift 在 frag 中复用同一组 i.uv 采样,其自身声明的 _SpecularShift_ST 并未参与采样,两张纹理因此始终共用同一套 UV 变换。

o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz 输出世界空间位置,供片元阶段计算视线方向、光线方向与光照衰减。o.worldNormal = UnityObjectToWorldNormal(v.normal) 把法线变换到世界空间,内部使用模型矩阵的逆转置,可在非均匀缩放下保持法线方向正确;o.worldTangent = UnityObjectToWorldDir(v.tangent.xyz) 则把切线按普通方向向量变换到世界空间。

o.worldBinormal = cross(o.worldNormal, o.worldTangent) * v.tangent.w 由法线与切线叉乘构造副切线(binormal)。副切线是头发纤维生长方向的近似,即第 4 章全部高光模型的核心方向 T。v.tangent.w 取值为 ±1,是 Unity 按 UV 展开手性写入的修正因子:镜像 UV(负手性)顶点上的叉乘方向需要翻转,乘上 w 后副切线在整个模型上保持一致的朝向。

两个宏负责「声明—填充」配套的插值数据。v2f 中的 UNITY_FOG_COORDS(5)LIGHTING_COORDS(6,7) 分别把雾效因子、光照衰减与阴影采样数据声明为 TEXCOORD5、TEXCOORD6/7 语义,编号与 worldBinormal 占用的 TEXCOORD4 连续衔接,避免插值寄存器语义冲突。vert 末尾的 UNITY_TRANSFER_FOG(o, o.pos) 按裁剪空间位置计算雾效插值因子,与编译指令 #pragma multi_compile_fog 及 frag 末尾的 UNITY_APPLY_FOG 配套使用;TRANSFER_VERTEX_TO_FRAGMENT(o) 在顶点阶段预计算阴影与衰减所需的采样数据,与 frag 中的 UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos) 配对——后者输出的标量 atten 被乘入直接光漫反射、Kajiya-Kay 高光与 Marschner 高光,环境高光不乘 atten(衰减流程详见第 3 章)。

6.3.2 SubShader Tags 与 FallBack "Transparent/Cutout/VertexLit" 的意义

"RenderType"="Transparent" 是着色器分类标签,供 Shader Replacement、相机替换渲染等功能按类别识别物体,本身不改变本 Shader 的渲染行为。"Queue"="Transparent" 将渲染队列设为 Transparent(对应队列值 3000),使头发在所有不透明物体(Geometry 队列,2000)与 AlphaTest 物体(2450)之后绘制:此时背景与不透明场景的颜色、深度均已就绪,Pass "TRANSPARENT" 的 Alpha 混合才能与正确的背景叠加。队列与双 Pass 状态的整体配合详见第 2 章。

LOD 100 是 SubShader 的细节等级标记。运行侧可通过 Shader.globalMaximumLOD 全局压低可用 LOD:当该上限低于 100 时本 SubShader 被整体跳过,渲染落入 FallBack;100 属于较低等级,常规项目设置下不会触发跳过。

FallBack "Transparent/Cutout/VertexLit" 声明降级路径:当 SubShader 因硬件能力或 LOD 限制不可用时,改用 Unity 内置的 Transparent/Cutout/VertexLit(固定管线风格的顶点光照加 Alpha 裁剪 Shader)渲染。FallBack 的另一项作用是补全阴影投射:本 Shader 的两个 Pass 均为 ForwardBase,未包含 LightMode 为 ShadowCaster 的 Pass,Unity 会沿 FallBack 链查找内置 Shader 的阴影投射 Pass,使头发在阴影贴图中仍有投影。原始笔记未对 FallBack 作实测说明,本条为标准语义解释。

6.3.3 两 Pass 代码差异点定位:clip 行与最终 Alpha 输出

对两个 Pass 的 CGPROGRAM 逐行比对,代码体内(不含 Pass 头部渲染状态)仅有两处差异,其余内容——vert、四个工具函数、frag 中漫反射与三种高光的全部计算——逐行相同。

第一处为 frag 的 Alpha 裁剪行(代码块第 164 行与第 331–332 行)。Pass "OPAQUE" 中 clip(texColor.a - _AlphaCutoff); 是有效语句,纹理 Alpha 低于 _AlphaCutoff 的像素被直接丢弃,只保留不透明主体毛发;Pass "TRANSPARENT" 中同一行被注释(// clip(texColor.a - _AlphaCutoff);),不裁剪任何像素,_HairTex 的原始 Alpha 被完整保留到输出。

第二处为 frag 末尾的 Alpha 输出(代码块第 198 行与第 366 行)。Pass "OPAQUE" 输出 fixed4(diffuse + specular, 1.0),Alpha 强制为 1,配合 Blend Off 直接覆盖帧缓冲像素;Pass "TRANSPARENT" 输出 fixed4(diffuse + specular, texColor.a),保留纹理 Alpha,配合 Blend SrcAlpha OneMinusSrcAlpha 与已绘制背景按比例混合。

两处代码差异连同 Pass 头部的渲染状态差异,汇总定位如下表。

差异位置 Pass "OPAQUE" Pass "TRANSPARENT"
渲染状态(Pass 头部) ZWrite OnBlend Off ZWrite OffBlend SrcAlpha OneMinusSrcAlpha
frag 中 clip 行 clip(texColor.a - _AlphaCutoff);(有效语句) // clip(texColor.a - _AlphaCutoff);(已注释)
frag 末尾 Alpha 输出 fixed4(diffuse + specular, 1.0) fixed4(diffuse + specular, texColor.a)

这一「同一份光照代码、两种 Alpha 策略」的结构正是第 2 章双 Pass 方案在代码层面的落点:Pass 1 以「深度写入 + Alpha 裁剪」保证主体毛发深度正确,Pass 2 以「关闭深度写入 + Alpha 混合」补回边缘发丝的半透明细节;两 Pass 光照计算逐行一致,保证主体与边缘叠加处不会出现光照断层。第 7 章附录补充了 AO 贴图运用、Dithering 与 Jittering 辨析等扩展笔记及参考资料。

7. 附录:扩展技术笔记

本章收纳原始笔记末尾的扩展阅读笔记:AO 贴图在头发渲染中的运用、Dithering 与 Jittering 的辨析,以及参考资料来源。

7.1 AO 贴图在头发渲染中的运用

7.1.1 遮挡贴图灰度约定(白=完全间接光照,黑=无间接光照)

遮挡贴图(Occlusion Map)是灰度图像,其中以白色表示应接受完全间接光照的区域,以黑色表示没有间接光照。使用时将该灰度乘入间接光项(如本文的 ShadeSH9 环境光贡献),使发丝相互遮挡处与发根的间接光被压暗。

7.1.2 头发渲染技巧组合:AO 兜底压暗、切线空间 halo、球形法线骗厚度、双 Pass 保体积层次

原始笔记将头发渲染的技巧分工概括为:「AO 兜底压暗,切线空间负责 halo,球形法线骗厚度,双 pass 保体积又保层次,点光 halo 负责氛围感」。即:AO 贴图压制间接光下限,提供稳定的明暗基底;沿切线方向的各向异性高光(Kajiya-Kay 项)形成发丝 halo 轮廓光;法线向球形法线插值,使片状发卡呈现近似圆柱体的受光,伪造体积厚度;不透明 Pass 写入深度保体积、透明 Pass 混合边缘保层次,即本文实现的方案;点光源在发丝高光上形成的光晕负责烘托氛围。

7.2 Dithering 与 Jittering

Dithering 与 Jittering 是两个容易混淆的术语,核心区别在于:Dithering 主要解决量化误差(幅度上的噪声),Jittering 主要解决采样/离散化误差(时间或空间上的周期性伪影)

7.2.1 Dithering:量化误差的噪声化(幅度维度)

Dithering 在量化前人为添加低幅值噪声,将幅度分辨率不足导致的失真(量化误差、条带效应 banding)转化为随机噪声。游戏渲染中,dithering 常用在不透明物体的假半透效果中(按噪声图案逐像素 clip 丢弃像素),避免了半透明排序的视觉瑕疵和 overdraw 开销。常见算法包括有序抖动(Ordered dithering,如 Bayer 矩阵)、误差扩散(Error diffusion,如 Floyd–Steinberg)与噪声整形(Noise shaping)。

7.2.2 Jittering:采样点的时间/空间偏移

Jittering 在时间或空间采样点上施加随机/确定性偏移,打破周期性采样带来的规律性伪影(如混叠、莫尔条纹、锯齿),将其转化为易被滤波器去除的高频噪声。典型应用是抗锯齿中的亚像素抖动,以及 TAA/超分辨率中的像素抖动(pixel jitter),用于累积多帧信息。常见实现是对采样位置添加均匀或高斯分布的随机偏移,或使用蓝噪声、泊松盘采样避免样本聚集。

7.3 参考资料

7.3.1 p271-kajiya.pdf(Kajiya-Kay 原始论文)

Kajiya-Kay 各向异性高光模型的原始论文,本文 hairStrandSpecular 函数即对其中单次散射高光项的复现。

7.3.2 知乎《毛发渲染方程》《游戏中的Dithering与Jittering》

原始笔记记录的在线阅读材料,标题按笔记原文列出:

  • (4 封私信 / 16 条消息) 毛发渲染方程 - 知乎
  • (21 条消息) 游戏中的Dithering与Jittering - 知乎

评论