拒绝黯淡无神!用 Cocos Creator 给眼睛一点「灵魂」
眼睛往往是项目中腾挪空间较大的资产:它可以很简单,用一张贴图即可;它也可以非常复杂,美术大神会手动雕刻虹膜的每一条沟壑。作为通俗的“灵魂的窗口”,即便是风格化的卡通美术项目,眼睛的重要性也不容忽视。关于眼睛的美术资产制作流程,可以参考这篇文档:
Alfred Roettinger / Realtime Eye:
https://texturing.xyz/pages/alfred-roettinger-realtime-eye
确立目标
与之前一样,我们将基于 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;
通过自定义参数 irisSize 和 pupilSize,我们可以分别控制虹膜和瞳孔的大小。我们也可以为虹膜贴图自定义一个偏转的颜色 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 的特性是它永远正对观察方向,既然如此,得到一套永远正对摄像机的 UV,用它来采样 MatCap 贴图即可。我们知道,法线数据表达的是物体表面片元正对的方向,因此把法线数据转换到视图空间,只取 X 和 Y 轴数据,就能得到我们想要的 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 的基础渲染效果。
在一个稳固的基础上,我们可以自由发挥,尝试各种各样的方法和模型, 实现丰富多样的渲染需求。如果有人物渲染方面的问题或是其他想要交流的内容,都欢迎扫描下方二维码添加江船长的个人微信,一起交流吧↓
>> 江船长个人微信
📢📢Cocos Star Meetings「杭州站」报名开启!11月6日(本周六)下午14:30,我们在杭州网易大厦二期等待各位!独立游戏开发者 阿信OL、「Star Writer」异名、每日给力主程陈炫烨为大家准备了满满游戏开发干货,还有 Cocos 生态总监大表姐、网易易盾李鹤仙空降现场!活动限额80人,赶快点击文末【阅读原文】或扫码报名吧↓