Cocos Creator 次世代人物渲染实战:眼睛篇

作者:CocosEngine
2021-11-10
3 1 0

[ 皮肤篇头发篇 ]

眼睛往往是项目中腾挪空间较大的资产:它可以很简单,用一张贴图即可;它也可以非常复杂,美术大神会手动雕刻虹膜的每一条沟壑。作为通俗的“灵魂的窗口”,即便是风格化的卡通美术项目,眼睛的重要性也不容忽视。在关于眼睛的美术资产制作流程,可以参考这篇文档

确立目标

与皮肤篇和头发篇一样,我们将基于 Cocos Creator 的 PBR 流程实现引擎中的眼球渲染效果。

我们的美术资源包括一张表现“眼白”(学名是巩膜)部分的颜色贴图,一张表现“眼眸”(学名是巩膜)部分的颜色贴图,一张法线贴图和一张 MatCap 贴图。其中,虹膜圆形的边缘用虹膜贴图的 alpha 通道表达。除此之外,我们还需要一些小技巧来表现眼珠在眼眶中的遮蔽关系,这将会在后文中详说。

奠定理论

眼睛的结构需要我们关注哪些点呢?我们仍然需要求教于参考图:

  • 虹膜直径大约等于整个眼球的半径;
  • 瞳孔的直径大约等于虹膜的半径;
  • 眼球并不是正球体,在虹膜前方又突起的液泡结构;

首先我们需要了解的是:眼球不是一个正球形,在虹膜的正前方位置有一个圆形的突起。这是因为虹膜正前方有一个液泡的结构,而整个眼球又包裹在透明的巩膜里,所以眼球是一个整体流线型,在正前方有小突起的球体。这些细节,美术的同学会进行表现。

综合来说,虹膜将会是我们的核心,我们需要重点处理虹膜和巩膜、瞳孔以及它正前方的液泡的关系。

UV 的处理和归一化

在头发篇中,我们已经聊到了 UV 数据和其他类型的数据一样,可以对它进行算数运算。我们熟悉的 UV Tiling 的功能就是通过用 UV 乘以一个常量实现的。对于虹膜贴图,我们也可以采用相同的处理:

vec2 offsetUV = v_uv * irisSize;

我们新建了一个浮点参数 irisSize,并让他与 UV 数据直接相乘。结果和 UV Tiling 是一样的:虹膜贴图在 UV 上的比例缩小了(在 irisSize 取值大于 1 的情况下),并且在 UV 空出来的部分叠加上了同样的虹膜贴图。

当然,我们的眼珠只需要一个虹膜。我们既希望利用常量相乘的办法缩放 UV,又不需要贴图的叠加,只需要在贴图的属性中将 Wrap Mode 设为 clamp-to-edge 即可。

叠加消除了,我们又遇到了新的问题:贴图似乎缩放到了左下方的角落里。我们需要对坐标系做归一化处理,让我们在缩放 UV 的同时,贴图可以保持在 UDIM 正中心。

vec2 offsetUV = (v_uv - 0.5) * irisSize +  0.5;

我们的虹膜大小和位置已经差不多了,下面我们需要将虹膜向后“推”进眼球里,以表现液泡和虹膜的前后关系。我们可以使用视差贴图的方法实现这个效果。

视差贴图

如上图所示,灰色平面代表物体的基本网格平面,在此基础上物体有突起的表面结构,用红色曲线表示。当我们以上图 V 向量的方向观察物体时,我们理应观察到红色曲线上的 B 点,当突起的表面结构不存在时,我们则会观察到基本网格上的 A 点。换言之:我们需要 A 点上的网格数据,去实现高度在 B 点的渲染效果。

我们知道,高度贴图(Height Map)表达的是物体切线空间的高度数据。也就是说,A 点的切线空间高度数值(H(A))是可以通过贴图获得的。但是 B 点呢?我们通常会以 A 点的切线空间高度作为数值权重,以观察向量 V 的反方向(从片元指向摄像机)进行缩放,就可以大致得到 B 的位置坐标。这样的计算当然不能做到完全精准,但效果是我们可以接受的。

方法有了,我们需要做的第一步是获得从片元指向摄像机的向量,并将其转化到切线空间当中:

vec3  worldDirPosToCam = normalize(cc_cameraPos.xyz - v_position);
vec3  tangentDirPosToCam = vec3(dot(worldDirPosToCam, v_tangent),  dot(worldDirPosToCam, v_bitangent), dot(worldDirPosToCam, v_normal));

我们可以利用得到的切线空间向量,对 UV 进行偏移,以偏移后的 UV 坐标读取切线空间的高度信息。这样我们就在 A 点得到了 B 点的高度输出:

vec2  parallaxUV(vec3 V, vec2 uv, float iniHeight, float scalar){
  vec2 delta = V.xy / V.z * iniHeight *  scalar;
  return uv - delta;
}

上面的代码需要带入四个参数:V 为我们刚求得的切线空间中的从片元指向摄像机的向量,uv 为物体的原 uv(即我们已经在皮肤篇和头发篇中使用过的“v_uv”),scalar 为自定义的权重参数,iniHeight 是片元的原高度数据,这个数据应该由一张贴图提供。在我们的着色器中,我们只需要用视差贴图做一些简单的像素偏移,因此没有准备专门的高度贴图,我们可以用颜色贴图的任意一个通道,或者直接使用一个常量 0.5 作为代替。

得到了视差贴图的函数,我们就可以把它用在虹膜上面了。

vec2 offsetUV =  (v_uv - 0.5) * irisSize + 0.5;
vec4  irisTex = texture(irisMap, offsetUV);
vec2 irispUV =  parallaxUV( tangentDirPosToCam, offsetUV, irisTex.r, parallaxScale );
vec3 irisColor  = SRGBToLinear(texture(irisMap, irispUV).xyz);

我们可以用之前的缩放归一后的 UV 得到有视差效果的虹膜 UV,用这套新 UV 赋予我们的虹膜贴图,得到的结果应该类似下图:

如图所示,随着权重数值的变化,我们的虹膜贴图应该能够沿着法线方向向前“推”或向后“缩”,同时我们也发现,我们目前的视差贴图只能达到一种近似的效果,随着权重数值增大,视差的效果也会越来越失真。因此我们在使用它时,需要注意将数值控制在比较低的范围内。

完成虹膜

虹膜的处理已经差不多了,下面我们需要处理一下瞳孔。

完成了虹膜的视差,我们如法炮制,对我们得到的视差 UV 做归一化处理。区别在于,这次我们将 UV 归一化,这相当于将所有的 UV 塌陷到归一化坐标的原点上。使用这个 UV 采样贴图,得到像素向坐标中心拉伸的效果。

接下来,就是制作一个遮罩将虹膜和瞳孔混合在一起了。

vec2  pupilpUV = normalize(irispUV - 0.5) + 0.5;
float  pupilIndex = (1.0 - length(v_uv - 0.5) * 2.0 * irisSize) * (0.8 * pupilSize);
vec2 irisUV =  mix(irispUV, pupilpUV, pupilIndex);
vec3 irisColor  = SRGBToLinear(texture(irisMap, irisUV).xyz) * irisColor.xyz;

通过自定义参数 irisSizepupilSize,我们可以分别控制虹膜和瞳孔的大小。我们也可以为虹膜贴图自定义一个偏转的颜色 irisColor,快速制作出不同颜色的眼眸。

下面我们可以把虹膜贴到眼球上了。眼球的基本材质使用巩膜贴图,我们只需要把虹膜的部分叠加在上面即可。虹膜贴图的边缘部分是用 alpha 通道的渐变完成的,我们可以用指数运算控制渐变的曲线强度,从而控制虹膜边缘的硬度:

vec3  scleraTex = SRGBToLinear(texture(scleraMap, v_uv).xyz);
float  irisEdgeIndex = clamp(pow(irisTex.a, irisEdge), 0.0, 1.0);
vec3 eyeBase =  mix(scleraTex, irisColor, irisEdgeIndex) * irisColor.xyz;

目前眼球的固有颜色信息已经得到了。但是我们的眼球看上去和直接贴了一张颜色贴图没有什么区别。下面我们需要做的是:为眼球赋予“神”。

MatCap 贴图

所谓有“神”的眼睛,可以简单概括为“有高光和/或有反光的眼睛”。如参考图所示,上面两张参考图中的眼睛显得更加生动和有活力,而下面两张则看上去非常死板,好似无机物。

然而,游戏中角色的眼睛并不是总能恰好反射环境中的光照,当环境有某些特定的需求或者从某些特定的角度观察时,眼睛很有可能没有足够的高光或反光。更何况,眼睛固然重要,但毕竟是一个较小的反射面,为此专门进行反射的光照计算似乎有点得不偿失。一个常见的折中办法是:把高光和反光作为贴图,永久地“贴”在眼睛表面。这样无论任何环境和角度,角色的眼睛里永远有星辰大海。


所谓 MatCap 贴图,顾名思义,是一张把整个材质(“Mat”-erial)的特性捕捉(“Cap”-ture)到像素内的贴图。MatCap 贴图通常绘制的是一个球体,着色器会根据球体上的明暗面、高光和反射,为整个材质绘制明暗关系和高反光。美术的同学应该对 MatCap 并不陌生——ZBrush 中用于渲染动辄上百万个多边形的材质正是使用 MatCap 着色器。因此 MatCap 有着效率极高,又足够能表现明暗关系和质感的优点。同时,MatCap 的缺点也是显而易见的:无论从哪个角度观察,MatCap 材质的明暗关系和高反光永远一成不变。

在我们的着色器中实现 MatCap 材质也非常简单:我们知道 MatCap 的特性是它永远正对观察方向,既然如此,得到一套永远正对摄像机的 UV,用它来采样 MatCap 贴图即可。我们知道,法线数据表达的是物体表面片元正对的方向,因此把法线数据转换到视图空间,只取 XY 轴数据,就能得到我们想要的 UV

vec4 matCapUV =  (cc_matView * vec4(v_normal, 0.0)) * 0.5 + 0.5;
vec4 matCapUV =  (cc_matView * vec4(v_normal, 0.0)) * 0.5 + 0.5;

确定了 UV,剩下的工作就水到渠成了:

vec3  matCapColor = SRGBToLinear(texture(reflecMap, matCapUV.xy).xyz) * reflecAmt;
vec3 eyeColor =  eyeBase + matCapColor;

我们的着色器已经编写完成了,让我们来看看效果:

按理来说,我们该参考的图都参考了,该考虑的变量都考虑了,该做的工作都做了,但这白森森的眼神,还是直接营造出一种纸人既视感。尤其是从较远距离观察的时候,白的发亮的眼珠更是莫名惊悚。

这是因为:眼珠和身体的其他部位一样,应该相互产生遮蔽的关系。我们的眼珠是单独制作的,所以和眼皮没有暗部遮蔽,因此在整张脸上特别出挑。这也是角色渲染的一个常见问题:我们对人脸都太熟悉不过了,以至于人脸上如果出现异于常理的现象都会触发本能的警觉。而且当其他的部分越趋近于真实时,这种恐怖感越严重。

如果是一个静态的部位,这个问题非常好解决:烘培一张 AO 即可。但是对于角色来说,绝大多数的角色眼球是需要骨骼动画的,直接把 AO 烘培在眼球上显然不可取。我们需要做的是在眼球的模型前方再新建一个遮蔽的模型,给它赋予一个 AO 的透明贴图,单独作为 AO 保留在模型上。这个模型除了 AO 将不会起任何其他作用,因此只需要给予一个基本的 Unlit 材质,也不会消耗额外资源。这种做法,也是包括 UE4 在内的许多引擎选择的做法。     增加了 AO 之后,我们角色的眼神柔和了许多,眼球和眼眶的衔接也更自然了。

结语

我们对人物渲染的探索到这里就可以成功收官了。在我们试图解答一个个渲染问题的过程中,我们获得的不仅是皮肤、头发和眼睛,同时也包括了:

  • 了解方形模糊和高斯模糊的原理,并实现高效率的模糊效果;
  • 探索人类皮肤的 Diffuse Profile 并用代码重现现实观测的数据;
  • 了解次表面散射和各向异性高光的逻辑和原理;
  • 学习纵横行业 30 余年的 Kajiya-Kay 模型;
  • 尝试视差贴图的渲染方法;
  • 学习和使用 MatCap 贴图;

虽然我们只是在 Cocos Creator 现有的着色器上进行修改,但相信你也已经发现:在 Cocos Creator 着色器的基础上编写自己的着色器,不仅省去了大量 GLSL 基础工作,而且可以一步到位地获得 PBR 的基础渲染效果。在一个稳固的基础上,我们可以自由发挥,尝试各种各样的方法和模型, 实现丰富多样的渲染需求。