更新于 

Kajiya-Kay头发渲染

本文在讲解之前,请您先仔细阅读

《头发着色原理》 理解Kajiya-Kay的基本推导,同时需要掌握Unity的基础使用方法,对渲染管线有基本了解后再根据本文进行实验。非常感谢 《Hair Rendering and Shading》 提供的基于Kajiya-Kay模型头发渲染的伪代码。

实验材料

本实验我们将实现一个红色头发的少女,同时要实现基本的主高光与次高光和各向异型材料的特性,为了使得渲染效果更加真实,我们还将根据NormalMap进行法线采样贴图,因此下面的网盘中提供了一个基本的少女模型以及5张纹理图片:

下面简单介绍一下五张图片的作用:

纹理贴图用来模拟头发效果,不提供颜色,透明度等贡献,采样贴图后可以得到一个类似于头发的蓝图。

使用上面的纹理贴图后虽然可以得到一个类似于头发的效果,但是由于会repeat贴图因此头发会显得非常规则,但是实际上头发丝之间应该是不规则重叠状,因此我们需要实现一个扰动,这样也可以使得最终的天使环是一个不规则的圆环更加真实。

多重扰动,可以实现更好的渲染效果,同时次高光的扰动将使用这张纹理图进行采样。

头发灰度渐变实现纵向的渐变效果从而得到更加真实的头发效果,采样后主要是最终体现在color的alpha值上。

由于头发建模不可能真的一根一根头发丝构建,因此实际上细看是有多个mesh组成,但是这在高光处会体现的非常不真实,因此这里引入发现贴图来加深头发丝之间的凹凸从而给观察者一种“许多头发丝”的假象。

重要原理讲解

Sin的计算

在Kajiya-Kay模型中,我们使用了切线来代替法线进行计算,这样可以节省许多开销,同时公式也变成了求解sin值,但是我们知道使用三角函数计算计算量仍然较为复杂,在实时渲染中开销能省则省,因此我们这里对Sin的公式进行一个变形,以下面为例:

sin(T,H)specularity =1dot(T,H)2specularity \begin{aligned} & \sin (T, H)^{\text {specularity }}= \sqrt{1-\operatorname{dot}(T, H)^2}^{\text {specularity }} \end{aligned}

这里我们对求解高光的计算公式进行了一个简单的转换,这样的好处是cos可以通过点乘进行计算,而点乘即可将三角函数计算转换为了坐标之间简单的加乘运算从而减少开销。

高光模拟

前面我们讲解过高光的计算公式原理,这里我们又进一步分为了主次高光,这是为了模拟真实的效果,参考上图我们可以很明显有一个天使环即各向异性的特点,但是细看可以发现其实高光的颜色并不相同,可以拆分为多个过渡的颜色其中主高光颜色更靠近发梢同时颜色较浅,但是次高光更靠近发根同时贡献了更多的头发底色,因此我们可以用两个相近的“天使环”去模拟。

高光偏移

从上图也可以看出其实头发的高光并不是严格规则的,在边缘部分是有类似于“锯齿状”的,这是因为头发之间相互不规则重叠导致的,因此我们需要通过设置偏移参数以及采样偏移纹理图进行一个头发高光之间的扰动,为了实现高光沿着头发的延伸方向进行偏移,我们可以沿着发现的方向略微移动切线,假设T是一个从头发根部指向头发尖端的切线,我们可以将其进行向法线方向或者逆法线方向移动切线,具体移动幅度可以根据shift值决定,正shift推动将高光移向根部,负shift推动将高光移向头发尖端。具体的偏移量shift可以一部分采样于扰动纹理,同时一部分来自我们设置的偏移量。(上图看成是三维图更容易理解偏移原理)

边缘消融效果

为了实现头发高光与周边底色的渐变效果,我们可以设置一个参数值用来控制消融效果,使用smoothstep即可轻松实现,具体原理可以参考下文:

NromalMap的采样

在采样发现时,如果仅仅是二维物体采样二维发现贴图那么很简单,因为物体的所有面都朝向一个发现,但是对于一个具有多个面或者多个不同朝向的三维物体进行法线贴图时则需要进一步处理,因此我们实际开发中一般只会有一张二维法线纹理图,而我们需要将其贴到朝向不同的mesh上,解决办法很简单,我们只需要实现一个切线空间与世界空间下的转换即可,这里有两种思路:

  1. 将世界空间下的光线向量,法线向量等转换到切线空间下从而与某个顶点采样后得到的切线空间下的法线进行着色公式的计算得到最终的光照效果。
  2. 将二维NromalMap中采样得到的某点切线空间下的法线转换到世界坐标系下再进一步计算,这里我青睐于这种方法,因为这样可以直接得到世界坐标系下的发现向量方便进行后面的所有计算操作。

这个内容涉及到TBN矩阵可以参考下面文章进一步理解其原理:

这里说一下我对UV空间,切向空间,局部模型空间,世界空间的浅显理解,所谓UV空间就是一个二维贴图的两个正交坐标轴(U轴其实就对应世界坐标系下的切线方向),由于每一个三维坐标都与UV下的坐标一一映射,因此实际上XYZ坐标系可以看成是关于UV的函数,而切向空间是一个以某一个顶点为原点重新根据U和V求得的N,T和B组成的坐标系,法线N方向不变,其中T表示切线可以用x,y,z对u的梯度求得,B可由法线N与T叉乘得到,因此如果这个顶点的法线就是和其所在的网格垂直,那么求得的T和B一般也在网格上,但是如果这个顶点的法线是差值平均求得的,那么可能求得的B和T不在面上。而局部模型空间就是以模型中心为原点建立的XYZ坐标系,世界空间坐标系就是以世界坐标原点建立的XYZ坐标系

而我们在进行法线贴图采样时一般采样得到的都是切线空间下的法线向量,(其实就是根据三维坐标xyz转换为二维采样坐标uv去采样三通道值再转换得到切线空间下的N),我们需要进一步将其转换为世界坐标系,此时就是使用TBN(世界坐标系下的T,BN三个向量罗列组成)矩阵了。下面是一个非常易懂的切线空间与世界空间的关系图:

采样到底用切线还是副切线?

这个问题困惑了我好久,首先副切线又名副法线,实际上就是根据法线与切线叉乘得到的,那么它们之间的关系是什么呢?首先可以参考下文进行了解:

总之我们要知道切线总是与U方向相同(可以理解成二维图片中从左下角为原点,向右延伸的方向),那么这里我们采样肯定是要按照从上到下沿着头发丝的方向采样,因此这里我们需要用副切线进行采样,也就是说具体是用切线还是副切线采样需要根据具体图片的情况分析,这我们的纹理图都是头发丝纵向的,那么我们又需要沿着头发丝采样,因此需要沿着V方向也即副切线方向进行采样。同时这里还要注意一个非常重要的问题,即副切线的计算中叉乘使用的向量需要是世界坐标系下的顶点法线与顶点切线,因此我们首先需要将向量从(模型)局部坐标系转换到世界坐标系下。

Shader代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Custom/Kajiya-Kay"
{
Properties{
// 漫反射颜色,决定头发主色色调
_DiffuseColor("Hair Diffuse Color",Color)= (1,1,1,1)
// 主高光颜色
_SpecularColor_1("Hair Primary Specular Color",Color)=(1,1,1,1)
// 次高光颜色
_SpecularColor_2("Hair Second Specular Color",Color)=(1,1,1,1)
// 头发贴图模拟头发丝
_BaseTex("BaseTexture",2D) = "white" {}
// 高光偏移纹理
_PrimaryShiftTex("PrimaryShiftTexture",2D)="white" {}
_SecondShiftTex("SecondShiftTexture",2D)="white" {}
// 透明渐变纹理
_AlphaTex("AlphaTexture",2D) = "white" {}
// 头发高光消融效果
_SpecularWidth("Specular Width",Range(0,1))=1
// 头发高光收敛
_Specularity_1("Hair Primary Specularity",range(0,300))=20
_Specularity_2("Hair Second Specularity",range(0,300))=20
// 头发高光强度
_SpecularScale("Hair Specular Scale",Range(0,2))=0.381
// 高光偏移量
_PrimaryShift("Primary Shift",Range(-5,5))=0
_SecondShift("Second Shift",Range(-5,5))=0
// 发现贴图
_NormalMap("Nromal Map",2D)="white" {}
// 设置凹凸程度
_BumpScale("Hair Bump Scale",Range(0,10))=2
}


SubShader
{
LOD 200
Cull off
Pass{
CGPROGRAM

// 使用顶点与片元着色器
#pragma vertex vert
#pragma fragment frag

// 需要引得库
#include "UnityCG.cginc"
#include "Lighting.cginc"


struct appdata{
// 顶点坐标,采样坐标,法线,切线
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};

struct v2f{
// 采样坐标,裁剪顶点坐标(即MVP视口变换后的),法线,副切线,世界顶点坐标,切线空间到世界空间的转换矩阵
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 normal : TEXCOORD1;
float3 bitangent : TEXCOORD2;
float3 pos : TEXCOORD3;
// 变换矩阵
float4 TtoW0 : TEXCOORD4;
float4 TtoW1 : TEXCOORD5;
float4 TtoW2 : TEXCOORD6;
};

sampler2D _BaseTex;
float4 _BaseTex_ST;
sampler2D _AlphaTex;
float4 _AlphaTex_ST;
sampler2D _PrimaryShiftTex;
float4 _PrimaryShiftTex_ST;
sampler2D _SecondShiftTex;
float4 _SecondShiftTex_ST;
sampler2D _NormalMap;
float4 _NormalMap_ST;

float4 _DiffuseColor;
float4 _SpecularColor_1;
float4 _SpecularColor_2;
fixed _SpecularWidth;
fixed _SpecularScale;
fixed _PrimaryShift;
fixed _SecondShift;
fixed _Specularity_1;
fixed _Specularity_2;
fixed _BumpScale;

v2f vert(appdata v){
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
float3 worldPos=mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal=UnityObjectToWorldNormal(v.normal);
float3 worldTangent=UnityWorldToObjectDir(v.tangent).xyz;
float3 worldBitangent=cross(worldTangent,worldNormal)*v.tangent.w;
o.pos = worldPos;
o.normal=UnityObjectToWorldNormal(v.normal);
o.bitangent =cross(v.normal,v.tangent.xyz)*v.tangent.w*unity_WorldTransformParams.w;
o.TtoW0 = float4(worldTangent.x,worldBitangent.x,worldNormal.x,worldPos.x);
o.TtoW1 = float4(worldTangent.y,worldBitangent.y,worldNormal.y,worldPos.y);
o.TtoW2 = float4(worldTangent.z,worldBitangent.z,worldNormal.z,worldPos.z);
return o;
}

half3 ShiftedTangent(float3 t,float3 n,float shift){
// 偏移量,沿着头发丝方向
return normalize(t+shift*n);
}

float StrandSpecular(float3 t,float3 v,float3 l,int exponent){
// 使用半程向量与沿着头发丝的切线计算反射
float3 h=normalize(l+v);
float dotTH=dot(t,h);
float sinTH=sqrt(1.0-dotTH*dotTH);
// 边缘消融效果
float dirAtten=smoothstep(-_SpecularWidth,0,dotTH);
// 同时还收到收敛指数,高光强度模拟吸收
return dirAtten*pow(sinTH,exponent)*_SpecularScale;
}

float3 HairSpecular(float3 t,float3 n,float3 l,float3 v,float2 uv){
// 计算主高光与次高光的偏移量
float shiftTex_1=tex2D(_PrimaryShiftTex,uv*_PrimaryShiftTex_ST.xy+_PrimaryShiftTex_ST.zw)-0.5;
float3 t1=ShiftedTangent(t,n,_PrimaryShift+shiftTex_1);
//
float3 specular1=_SpecularColor_1*StrandSpecular(t1,v,l,_Specularity_1);

float shiftTex_2=tex2D(_SecondShiftTex,uv*_SecondShiftTex_ST.xy+_SecondShiftTex_ST.zw)-0.5;
float3 t2=ShiftedTangent(t,n,_SecondShift+shiftTex_2);
float3 specular2=_SpecularColor_2*StrandSpecular(t2,v,l,_Specularity_2);

// 两者相加即为高光结果
return specular1+specular2;

}
float3 HairDiffuse(float3 t,float3 l,float2 uv){
// 同样需要使用切线与光线的sin计算
float cosTL=dot(t,l);
float sinTL=sqrt(1.0-cosTL*cosTL);
return tex2D(_BaseTex,uv).rgb*_DiffuseColor*sinTL;
}

float HairAlpha(float2 uv){
// 读取灰度
return tex2D(_AlphaTex,uv);
}

float3 getNormal(float4 TtoW0,float4 TtoW1,float4 TtoW2,float2 uv){
// 采样发现,这里注意必须是设置为NromalMap
float4 packedNormal = tex2D(_NormalMap,uv);
//图片没有设置成normal map
//float33 tangentNormal;
//tangentNormal.xy = (packedNormal.xy * 2 - 1)*_BumpScale;
//tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
// 解码得到采样后的切线坐标系下的发现
float3 tangentNormal=UnpackNormal(packedNormal);
tangentNormal.xy*=_BumpScale;
// 需要转换为世界坐标系下的发现
float3 worldNormal = normalize(float3(dot(TtoW0.xyz,tangentNormal),dot(TtoW1.xyz,tangentNormal),dot(TtoW2.xyz,tangentNormal)));
return worldNormal;

}
fixed4 frag(v2f i) : SV_TARGET{
float3 l=normalize(UnityWorldSpaceLightDir(i.pos));
float3 v=normalize(UnityWorldSpaceViewDir(i.pos));
float3 n=getNormal(i.TtoW0,i.TtoW1,i.TtoW2,i.uv);
float3 b=normalize(i.bitangent);
float3 diff=HairDiffuse(b,l,i.uv);
float3 spec=HairSpecular(b,n,l,v,i.uv);
float4 col=float4(_LightColor0*(spec+diff),HairAlpha(i.uv));
return col;
}
ENDCG
}
}
FallBack "Diffuse"
}

导入模型后,我们对头发进行编辑,并调节参数,这里给出我最终实验使用的参数:

参数 取值
Specular Width 0.809
Hair Primary Specularity 285
Hair Second Specularity 204
Hair Specular Scale 0.01
Primary Shift -1.01
Second Shift -0.19
Hair Bump Scale 0.54
Hair Diffuse Color (Red/Gold) #A43932/#5F3F23
Hair Primary Specular Color(Red/Gold) #CCB4B4/#E8B674
Hair Second Specular Color (Red/Gold) #D4948D/#AA825E

要注意前文说过主高光更靠近发尖端,次高光更靠近发根,同时shift越大越靠近发根,因此这里PrimaryShift>SecondShift​,同时主高光颜色要浅于次高光颜色。

最终效果图