Cocos Creator 次世代人物渲染实战:头发篇

作者:CocosEngine
2021-10-29
5 1 0

《Cocos Creator 次世代人物渲染实战:皮肤篇》中,我们主要聚焦皮肤渲染与次表面散射。而在本环节中,我们将主要聊一聊头发。

相较于皮肤,头发可能就更加令人挠头了:

在美术层面,我们需要以发束为单位处理大量的多边形并堆砌成各式各样发型的形态;

在渲染层面,我们需要让着色器使用 alpha 通道,剔除多边形上不需要的像素,呈现出单独纤细,整体密集的发丝形态。

头发模型的形态和细节的刻画,会由美术的同学帮助我们完成,如果你也有兴趣了解个中一二,可以参考大神 Adam Skutt 的角色头发建模行业标杆教程

确立目标

我们的目标是将一个标准的 PBR 角色头发美术资源导入 Cocos Creator 渲染呈现。美术资源包含了固有色贴图(包含 alpha 通道)、法线贴图和 AO 贴图。与皮肤篇一样,我们会基于 Cocos Creator 内置的标准 PBR 着色器制作自定义头发着色器。

需要注意的是,我们需要对美术资源做出一点小小的要求:固有色贴图(包含 alpha 通道)必须以单独 alpha 通道的.tga 格式存储。我们将会编写的着色器并不能正确渲染带有像素剔除的.png 格式图像。

奠定理论

首先我们仍然需要解答一个问题:什么样的效果能够让头发更真实?

观察上图,首先抓住人眼球的无疑是她头发上长条状的高光,进一步细看,我们可以注意到长条状的高光有两种颜色变化:一种是我们熟悉的偏白色高光颜色,强度也略高;另一种则是比头发固有色明度和饱和度略高的高光颜色。

在上图中我们可以看到两条看似相交的高光,这当然是他发型的梳理方式而决定的。这告诉我们头发的高光基本遵循每一根单独发丝的走向。在同一束头发中所有的发丝走向基本一致,所以他们的高光聚集在一起形成了条状。

在这个例子中,我们同样可以看到遵循发丝走向的两种颜色的高光,而且高光的位置似乎集中在发型弯曲的位置上。

综合起来,我们可以观察到的规律是:

  • 头发的高光呈带状,并遵循发丝的走向;
  • 头发有两条高光带,一条较强的偏白色高光带,一条偏向头发固有色的高光带;
  • 头发的高光带通常会出现在弯曲的部分。


我们知道,Specular 表述的是材质的反射光线,无论材质表面的粗糙度如何,Specular 光线传播的方向都可以从宏观上看作一个锥形,在这个锥形范围内光线的传播是平均的,这也是为什么我们在材质表面观察到的高光通常是一个圆形。这种光线传播的特性,物理上称之为各向同性(Isotropy),其涵义正如其字面意思:“在各个方向上一致的”。

然而在参考图中,头发上的高光并不是圆形的。高光只有在单个发丝上出现,而从发丝之间的横向来看,并没有产生高光的条件。整体来看,头发是一种只有垂直方向会产生高光,水平方向没有高光,垂直方向的高光密集排列在一起成带状的材质。这种在各个方向上不统一的特性,称之为各向异性(Anisotropy)。

除了头发之外,任何物理上由无数会产生高光的细丝密集组合成的材质,都会表现各向异性的高光特性,比如丝绸、大多数的晶体、抛光的木材、拉丝抛光处理的金属等。

实现各向异性

目前我们在游戏中看到的大多数头发各向异性渲染效果,都基于早在 1989 年发表的 Kajiya-Key 模型。那么,什么是 Kajiya-Key 模型?

既然头发的各向异性特征表现在高光上,那么,我们应该从 Specular 入手。

我们在皮肤篇中已经聊过的“N·L”方法,利用物体法线方向和光照方向可以让我们快速得出光照明暗关系的数值。既然高光同样和物体的表面特征和光照方向相关,那么我们是否可以使用同样的方法得出 Specular 呢?

当然,我们不能机械地把 Specular 理解为强度极高、范围极小的 Diffuse。高光除了收到光照方向的影响之外,还与我们观察的角度相关。因此我们引入一个光照方向(L)和观察方向(V)之和的半向量 H,套用“N·L”的方法用它与法线 N 求点积。高光的强度远高于 Diffuse 的强度,所以我们把强度参数作为点积的指数输出。由此,我们就得到了一个基本的计算 Specular 的公式:

然而,这个公式仅适用于各向同性的情况,对于头发的各向异性特征并没有考虑进去。

如下图所示,在一般情况下,我们的 Specular 公式求的是法线和半向量的点积。但是我们通过观察得知:头发的各向异性与发丝的走向相关,与头发整体的结构关系不大。因此,N 对我们来说失去了意义,我们需要的是表达发丝走向的向量 T

得到了 T 向量后,我们如法炮制继续套用“N·L”方法,这需要得到 T 在半向量上的投影。这个投影实际是 TN 夹角的正弦,而点积只能获得两者夹角的余弦。所幸的是,我们可以通过正余弦定理,通过换算得到正弦:

vec4  worldViewDir = cc_matView * vec4(0.0, 0.0, 1.0, 0.0) - vec4(v_position, 0.0);
vec4  worldHalfDir = normalize(normalize(cc_mainLitDir) + normalize(worldViewDir));
float THdot  = dot(normalize(T), normalize(worldHalfDir.xyz));
float sinTH =  sqrt(1.0 - pow(NHdot, 2.0));

 在上面的代码中,cc_matViewcc_mainLitDir 都已经在皮肤篇中出现过了,分别返回的是 View Matrix 和光源方向。

这些看上去都比较简单。那么问题是:我们如何得到向量 T

我们知道,物体的顶点存储了切线空间数据:以顶点法线方向为一轴,顶点切线(与顶点法线垂直,与表面平行)为另一轴,与顶点法线和切线都垂直的第三个向量为第三轴。其中顶点法线和顶点切线已经包含在网格的顶点数据当中了,模型的同学已经帮我们处理好了光滑组或软硬边(取决于美术同学用的是 3ds max 还是 Maya),并按照需求提供了法线贴图。第三个向量,通常被称为副切线向量(Bi-tangent,或 Bi-normal),我们可以根据它垂直于顶点法线和顶点切线的特性,用叉积计算得到它:

v_tangent  = normalize((matWorld * vec4(In.tangent.xyz, 0.0)).xyz);
v_bitangent =  cross(v_normal, v_tangent) * In.tangent.w;


其实在 Cocos Creator 的 PBR 着色器中,副切线向量已经为我们计算好了,我们可以通过 v_bitangent 使用它。


目前为止,我们已经把 Specular 与发丝的走向建立了联系,但是我们的 Specular 依然是模型的高光,看上去并不像头发。我们需要使用一张发丝的灰度图,作为一个数值权重来偏移副切线向量的方向,使我们的高光向发丝的方向拉伸,从而更像头发的形态。

首先我们需要知道的是:切线空间的数据是基于物体表面的切面的,而物体的表面又由法线方向决定。因此我们对副切线向量的偏移,一定是朝着法线的方向偏移。

接下来,我们需要一张发丝的灰度图作为拉伸的权重。这张灰度图可以是一张使用头发模型 UV 的贴图,或者一张四方连续的贴图。如果是后者,我们要为它写入相应的 UV Tiling 的功能。

vec2  anisotropyUV = v_uv * anisotropyTile.xy +  anisotropyTile.zw;
vec4 jitterMap  = texture(jitterTex, anisotropyUV);

我们声明了一个 vec4 参数 anisotropyTile,用它来实现 UV 的控制功能。v_uv 我们已经在皮肤篇中使用过了,返回的是顶点着色器传递的 UV 数据。

我们先把法线数据与权重灰度图相乘,将法线方向加以扰动,除此之外,我们还需要相加一个位移权重的自定义参数,这个参数的意义在后面会体现出来。让后将其与副切线向量相加。这与我们求得 H 向量的方法是一样的,归一后我们得到的就是副切线向量与扰动后法线方向向量的半向量。

求得我们的 T 向量之后,对其进行点积计算,换算为正弦,代入我们的简单 Specular 公式,各向异性的高光就的出来了。在这里我们还使用了 GLSL 的 smoothstep 函数,类似于 mix 函数它会把输入的参数投射到定义的最小值和最大值的区间中,并在两个极值之间生成一条平滑的过度曲线。

float  anisotropyIndex( float offset, float factor, float amt ) {
    vec3 jitterT = v_bitangent + (v_normal *  (offset + factor));
    float THdot = dot(normalize(jitterT),  normalize(worldHalfDir.xyz));
    float sinTH = sqrt(1.0 - pow(NHdot, 2.0));
    float atten = smoothstep(-1.0, 0.0, NHdot);
return pow(sinNH, amt) * atten;
}


现在,Specular 已经基本遵循发丝的走向了,但是我们的高光似乎太强烈了,这是因为我们并没有考虑头发自身相互遮蔽的问题。解决它非常容易,只要把头发的 AO 叠加到高光上即可。

float aoFactor  = mix(1.0, 0.0, pbr.x);

最后就是联动的环节了。回想一下我们观察的参考图,我们需要使用我们编写的各向异性函数生成两道高光带,还记得我们在扰动副切线向量时写入的位移参数吗?给两条高光带分别带入不同的位移参数值(hairSpecMOffset, hairSpecAOffset),他们就不会重叠在一起,并且你可以把高光移动到模型弯曲度较高的地方,获得更真实的效果。除此之外,他们分别有各自的强度参数(hairSpecMAmt, hairSpecAAmt)和颜色参数(hairSpecColor01, hairSpecColor02),我们还可以给一个总强度进行整体协调(hairSpecIntensity)。

vec4  hairSpec = clamp((anisotropyIndex(hairSpecMOffset, jitterMap,  hairSpecMAmt) * hairSpecColor01 * hairSpecIntensity  + anisotropyIndex(hairSpecAOffset, jitterMap, hairSpecAAmt) * hairSpecColor02 * s.albedo *  hairSpecIntensity) * aoFactor), 0.0, 1.51);

做到这一步,我们的各向异性高光的函数已经基本就绪了,然而我们又遇到了与皮肤篇中相似的问题:我们在哪个通道输出高光呢?

你可能已经想到可以利用我们的各向异性高光的函数调整 roughness 通道以达到控制 Specular 输出的目的,但这个结果并不是我们想要的。我们的函数返回的并不是高光的遮罩,而且我们并不希望在高光的部分看到如同镜面一般的反射。更何况,标准 PBR 的高光仍然存在,我们更加不希望看到各向同性和各向异性的高光同时出现。既然如此,最简单的办法是:把 roughness 设为常量 1,消除所有的各向同性高光,然后把各向异性高光的输出叠加在 albedo 通道上。

头发的呈现

我们的着色器已经编写完成了,但我们的工作还没有完成。如何使用这个着色器才能呈现最好的效果呢?

新建一个材质,赋予我们的各向异性着色器。将 Technique 设为 1-transparent

首先将各个贴图赋予到相应的通道上。法线贴图对应法线通道,AO 贴图对应 Occlusion 通道,固有色贴图对应 Albedo 通道。

开启 USE ALPHA TEST,使用 alpha 通道剔除不需要的像素。这里可以使用红通道或 alpha 通道调整剔除的阈值,令“抠图”更干净。

 做到这一步,头发该有的样子应该有了。然而你会发现,头发的前后关系似乎有点奇怪。你需要展开编辑器最下方的 PipelineStates 标签,在 DepthStencilState 标签下开启 DepthWrite,确认 DepthFunc 设为 Less

当然,在默认情况下,模型的背面是不会被渲染的。如果需要实现双面材质的效果,在 RasterizeState 下的 CullMode 设为 None 即可。

做到这里,我们在 Cocos Creator 中重现的经典 Kajiya-Key 模型头发着色器就基本完成了。