毛发渲染(Fur Rendering)

毛发渲染(Fur Rendering)

实时渲染中,毛发的渲染可以看作一个单独的课题,毛发因为其数量多的原因,如果为每根毛发单独建模或渲染,会给CPU和GPU都带来很大的压力。因此,我们采用一些tricks,让毛发看起来是真实的,并且在实时渲染中可以接受的性能消耗之内来完成。
由于毛发渲染的方法有很多,这篇文章介绍的是多pass方式的毛发渲染,又叫做shell-based rendering。参考自📌bkenwright@xbdev.net的教程

毛发的特征

1、毛发数量众多、质地柔软
毛发由数量众多的细小圆柱体组成,并且毛发通常是柔软的,互相交叉重叠在一起。

[互相交叠的柔软毛发]

2、毛发自身互相产生阴影
毛发自身产生的阴影会投射到其他的毛发之上,由于毛发基本都是从根部到尖端由粗到细,因此越接近毛发的根部,阴影越强。在实时渲染中我们不会精确计算到毛发自身的投影,但是可以利用靠近根部的阴影强这一点来做一个模拟的AO。

[越靠近根部阴影越强]

3、毛发边缘透光、且颜色越淡透光越强
背光时,可以明显看出毛发的边缘部分能够透过一部分光线,并且毛发的颜色越浅,透光越强。在摄影中,逆光拍摄的时候可以明显看出这种现象。

[头发的边缘透光现象]

4、毛发的各向异性高光
与一般物体不同,毛发的表面有许多凸起、凹痕等,这让毛发并没有一个明显区域的高光,与之相反,毛发的高光更偏向各个方向均有一部分。

[显微镜下的毛发 + 高光]

但是
好在我们在实时渲染中可以效果较好、代价较低地模拟上述的毛发特征。这里引用《3D数学基础:图形与游戏开发(3D Math Primer For Graphics And Game Development)》中的“图形学第一定律”——

如果它看上去是对的,那么它就是对的(If it looks right, it is right)


毛发的渲染过程

多Pass渲染

本文中我们采用多pass渲染的方式来渲染毛发,即用多个pass,每个pass渲染一层,让多层叠加在一起产生毛发的效果。在每一层中,我们均将顶点沿法线方向挤出一小段距离,这样在多个pass的执行下,我们便得到了大量的层,每一层都是上一层沿法线方向的放大。

[沿法线挤出的多层示意]

通常,我们沿法线挤出后,所形成的新的层仍然是一个整体(即如果我们把一个平面挤出一次后,所得到的层仍然是一个平面),那按理说我们得到的仅仅是重叠在一起的多个层,并非是毛发。为了让这些层看起来像毛发,我们可以使用沃里噪声(Worley Noise)贴图,每层对其进行采样,采样值作为alpha逐层递减,由于沃里噪声形状的特性,我们便可以在每个pass中得到一系列逐层变细的面片,当层数足够多时,看上去就和毛发一样。

[沃里噪声]

以下是多pass渲染的一段简单结构示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha

Pass
{
CGPROGRAM

#pragma vertex vert
#pragma fragment frag

v2f vert(a2v v) { return Vert_fur(v, FUR_OFFSET); }
fixed4 frag(v2f i) : SV_Target { return Frag_fur(i); }

ENDCG
}
Pass {...}
Pass {...}
...
}

其中的FUR_OFFSET为层高,即我们每层挤出的距离,FUR_OFFSET的间隔越大,毛发越长,但是每一层的区别也看得更明显,更容易穿帮。因此总层数与层高需要在效果与性能之间通过调试来做取舍。


第一步:法线挤出与噪声采样

具体原理在上述多pass渲染节已经说明完毕,我们按照其方法来做毛发渲染的第一步——法线挤出和噪声采样
首先,在顶点着色器中,我们将每个顶点沿法线挤出,并计算主纹理和沃里噪声的tilling和offset,代码如下

1
2
3
4
v.vertex.xyz += v.normal * FUR_OFFSET * _FurLength;

o.uv.xy = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.uv * _NoiseTex_ST.xy + _NoiseTex_ST.zw;

片元着色器中,我们采样沃里噪声,并根据FUR_OFFSET的值来让alpha逐层缩小,代码如下:
1
2
float alphaOrigin = tex2D(_NoiseTex, i.uv.zw).r;
float alpha = clamp(alphaOrigin - FUR_OFFSET, 0.0, 1.0);

至此,我们已经得到了沿法线挤出的多层,并且沃里噪声采样的alpha值逐层递减,目前我们得到的“毛发”效果如下:

可见效果非常不尽如人意,中间部分几乎看不清,考虑到开头提到的毛发特征第二点,我们先来为其加上AO。


第二步:毛发的自投影(AO)

因为我们每一层(每一pass)均有一个FUR_OFFSET变量,所以我们想计算AO会变得非常方便。试想,毛发越接近根部,其阴影越强,因此我们只需要让根部的光线作用效果减弱即可,而FUR_OFFSET又恰好是从根部到尖端由小到大,我们便可以轻松计算如下。
顶点着色器中,我们增加了__AO参数,用来调整阴影效果,其代码如下:

1
half ao = pow(FUR_OFFSET, _AO) + 0.04;

我们将结果传入片段着色器,并用结果乘以该值如下:
1
result *= aoVal;

至此,我们用模拟的AO给毛发加入了自投影,得到的“毛发”效果如下:

可见仅通过简单的AO计算,我们已经能够将毛发的阴影效果表现出来了,但是可以发现,我们的毛发现在均沿着法线向外,看起来像“刺猬”,并不符合毛发的特征一,即并不柔软,因此我们接下来需要让毛发变得更加柔软。


第三步:得到柔软的毛发

为了让我们的毛发看起来不像“刺猬”,我们需要让毛发变软,这里我们通过UV偏移的方式来实现这一效果。
我们根据FUR_OFFSET,来对采样沃里噪声时的UV进行逐层偏移,相当于我们每一层采样时都相比上一层进行了一小段偏移,这样最终我们得到的毛发就会是弯曲的,也就达到了我们想要的柔软的效果。
为了方便调整效果,我定义了向量__UVOffset变量,其xy分量来对UV进行偏移,其z分量用来调整FUR_OFFSET带来的影响,公式如下:
$uvOffset = UVOffset.xy · FUROFFSET^{UVOffset.z} $
于是,我们在顶点着色器中,有:

1
2
float2 uvOffset = _UVOffset.xy * pow(FUR_OFFSET, _UVOffset.z) * 0.1;
o.uv.zw = v.uv * _NoiseTex_ST.xy + _NoiseTex_ST.zw + uvOffset;

至此,我们给毛发加入了UV偏移,得到了逐层弯曲的毛发,并且能够通过一个变量调整效果,结果如下:

目前,我们已经得到了毛发的几何形态,接下来,我们需要让光照对我们的毛发产生影响,以此来实现开头提到的毛发特征3~4,在光照上,首先我们从最经典最基础的漫反射开始。


第四步:光照——漫反射

漫反射仍然采用经典的Lambert算法,用法线和入射光方向的点乘作为漫反射的结果,这里我们对结果加上一个变量LightFilter,用来调整光的穿透程度,代码实现如下:

1
2
float NdotL = dot(worldNormal, worldLightDir);
float diff = saturate(NdotL + LightFilter);

事实上,当我们将上述结果直接用作漫反射结果时,得到的效果是很差的,因为其破坏了毛发尖端阴影更少,亮度更强的原则。
因此,这里我们正好可以利用FUR_OFFSET变量来改善这一结果,因为NdotL的结果范围是(-1, 1),所以我们加上范围为(0, 1)的FUR_OFFSET后,就得到了范围在(0, 2)的结果,我们用saturate来将结果限制在(0, 1)即可得到优化后的结果。代码实现如下:
1
2
float NdotL = dot(worldNormal, worldLightDir);
float diff = saturate(NdotL + LightFilter + FUR_OFFSET);

最终我们能够得到如下的结果(图中分别对比了加与不加FUR_OFFSET的效果),注意,这里展示的效果仅仅是将diff的数值可视化

[ 无FUR_OFFSET vs 加入FUR_OFFSET ]

在有了漫反射后,我们已经完成了光照的第一步,接下来我们来完成开篇所说的毛发特征第三点,这可以让毛发边缘能够透过一部分光线从而显得亮度更高


第五步:光照——边缘透光

为了使边缘能够透光,即让光线只影响物体边缘,那么自然想到的就是菲涅尔反射。关于菲涅尔反射的计算公式有很多,这里我使用了下面的公式:
$Fresnal = max(0, min(1, (FresScale) + (1 - FresScale) · (1 - dot(ViewDir, normal)^{FresPower} )))$
这里可以根据效果多尝试几种方法,都是没问题的。
在代码实现时,我们引入两个变量FresnelScale和FresnelPower来帮助我们调整效果,具体实现如下,在顶点着色器中:

1
2
half fresnel = 1 - _FresnelScale + _FresnelScale * pow(1 - dot(worldNormal, worldView), _FresnelPower);
fresnel = max(0, min(1, fresnel));

片元着色器中,我将结果加上了AO和FUR_OFFSET的影响,可以让边缘部分的亮度稍高一些,此外,还加入了__FresnelColor来调整透光部分的颜色,具体如下:
1
2
float fresnelVal = i.extraParam.w + aoVal * FUR_OFFSET * 0.1;
result += fresnelVal * _FresnelColor;

最终我们得到的结果如下,左右分别是有无AO和FUR_OFFSET的区别,注意,这里的效果仅仅是将fresnel值可视化的结果:

[ 无AO/FUR_OFFSET vs 加入AO/FUR_OFFSET ]

拥有边缘光之后,我们已经完成了开头介绍的毛发特征三,接下来我们来完成最后的特征四,即各向异性高光


第六步:光照——各向异性高光

与一般的高光不同,我们在计算毛发类的高光时采用各向异性高光,Blinn-Phong高光模型中我们使用了法线与向量H进行点乘计算(注:向量H指视角方向与入射光方向的中间向量),而在各向异性高光计算的时候,我们采用切线方向来代替法线进行计算,如下图

不难想象,对于圆柱体而言,其切线方向是不变的,均沿着毛发生长方向。值得一提的是,在图形学中,不同于唯一的法线,切线一般是由物体的UV方向来定义的,因此有些引擎中我们用来计算各向异性高光时不一定使用切线,而也有可能使用副切线进行计算,其中副切线可以由法线与切线的叉乘来求得。
有了我们所需要的数据之后,我们便可以将数据代入公式计算,这里我使用的是Kajiya-kay模型,其公式如下
$StrandSpecular = (\sqrt{1 - dot(Tangent, H)^2})^{exponent}$
其中
$H = normalize(ViewDir + LightDir)$
代码实现如下:

1
2
3
4
5
6
float StrandSpec(float3 T, float3 L, float3 V, float exponet)
{
float3 H = normalize(L + V);
float TdotH = dot(T, H);
return pow(sqrt(1 - TdotH * TdotH), exponet);
}

我们将高光计算结果可视化,便能够得到如下的结果,这一结果就是模拟了动漫中常见的头发上的天使环高光

注意,为了性能考虑,我们在毛发渲染的所有光照计算均在顶点着色器中完成,因此我们能在高光上看到明显的几何形状,并且毛发根部也被完整照亮了,这不是我们想要的效果,因此我们对该高光结果乘以FUR_OFFSET来弱化毛发根部的亮度,除此之外,我还加入了__StrandParam参数来方便调整高光效果,代码如下:

1
2
3
4
5
6
7
8
Properties
{
...
_StrandParam ("高光参数:X-环区域,Y-高光亮度", Vector) = (25.0, 1.0, 0, 0)
...
}

half strandSpec = StrandSpec(worldBiTan, worldLight, worldView, _StrandParam.x) * FUR_OFFSET * _StrandParam.y;

最终我们得到了如下结果(左图是我用Blinn-Phong高光模型的计算结果,可以明显看出其与各向异性高光的差别):

[BlinnPhong vs 各向异性 ]

第七步——合并所有效果

最后,我们把上面的效果全部合并,得到最终的毛发渲染结果。

首先,我们对纹理采样,获得物体固有色,并对得到的固有色与毛发颜色进行插值,得到一个基础的着色。
然后我们在该着色的基础上,加入漫反射与高光反射的影响,并且这里的高光我乘上了漫反射的结果,这是为了不让背光处出现天使环高光。
在上述光照的基础上,我们再加上边缘光,并且这里我为边缘光设定了一个可选颜色,让该颜色影响边缘光颜色。
最后,我们把结果乘以AO值,来加入自投影带来的阴影。
完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 采样+插值
fixed3 baseCol = tex2D(_MainTex, i.uv.xy).rgb;
baseCol = lerp(baseCol, _BaseColor, FUR_OFFSET * FUR_OFFSET);

// 光照
fixed3 result = (diff + diff * strandSpec) * baseCol * _LightColor0.rgb;
result += fresnelVal * _FresnelColor;

// AO
result *= aoVal;

return fixed4(result.rgb, alpha);

在将所有的计算结果合并后,我们就能得到毛发渲染的最终效果。

至此,我们完成了多pass方式的毛发渲染,但是该结果仍然有许多需要调整和优化的地方,譬如不同物体间毛发的穿插毛发的动态等。