💡 本系列文章收录于个人专栏 ShaderMyHead。
💡 本文案例可以在 Github 上进行演示。
一、法线和法线贴图
1.1 法线介绍
在计算机图形学中,法线(Normal) 是一个核心概念。它通常指的是一个垂直于物体表面的单位向量。
假设你有一张平坦的桌面,一支笔直立在桌面正中央,笔的方向就可以看作是桌面该点处的法线方向。
对于更复杂的曲面模型,模型上的每个面都会有一条垂直于自身的法线(如下图蓝线),用于定义该面「朝向」何处:
法线最重要的作用是计算光照 —— 根据光线方向与表面法线的夹角来计算光线在该表面的反射强度。
在计算机图形学中存在多种光照模型,其中最主流的「Lambert 漫反射模型」的计算就依赖于法线向量:
光照强度 = max(0, dot(光线的单位向量, 法线的单位向量))
在 Cocos Creator 着色器中我们可以将其封装为函数:
// 漫反射 (Lambert)
vec3 calcDiffuse(vec3 normal, vec3 lightDir, vec3 lightColor) {
float NdotL = max(dot(normal, lightDir), 0.0);
return lightColor * NdotL;
}
该模型的原理可简单概括为 —— 法线与光线方向越接近,该表面看起来就越亮,反之则越暗。
1.2 法线贴图介绍
在 3D 领域的游戏开发中,高精度的模型能够呈现丰富的表面细节,但过多的面数会产生极高的性能开销;而低多边形模型虽然性能友好,却会丢失模型的细腻表现。
既然法线可以用于表现模型上各面数的朝向,那类似 SDF 纹理的实现,如果可以将法线的信息以 2D 图形化的方式存储,就能在模型大幅简化的情况下同时保留多面数的细节。
而法线贴图(Normal Map) 正是这种设想的实现:
一张法线贴图,存储的并不是「颜色」,而是用 RGB 三个通道存储了法线向量「归一化」后的单位向量 (x, y, z)。
💡「归一化」表示法线向量除以自身长度之后的值(也是一个
vec3向量),可理解为「该向量长度为1时的表示」、「法线的单位向量」。💡 GLSL 提供了
normalize函数来计算指定法线向量的归一化向量,你可以在《附录 —— 2.4 计算相关》了解此函数。
由于法线单位向量的取值范围是 [-1, 1],而贴图的颜色范围是 [0, 1],所以要做一个映射计算:
也就是把 -1 ~ 1 映射到 0 ~ 1,做为 RGB 色值存起来做为法线纹理贴图。
常规而言,法线贴图是呈现蓝紫色调的:
图片引用自 Unity 官方文档
这是因为在切线空间下,大多数表面是朝上的(z 方向大),即法线单位向量接近 (0, 0, 1),将其映射到 RGB 后变为 (128, 128, 255),也就是蓝色偏紫色。
当表面稍微有凹凸时,x、y 会偏离 0,导致映射后的红绿通道有变化,但 z 还是接近 1(蓝色通道接近 255),所以整体呈现蓝紫色。
1.3 法线贴图的制作
你可以使用 Blender、SpriteIlluminator、Photoshop 等图形软件来制作某张图片的法线图。
其中 Photoshop 制作法线图的功能位于顶部菜单栏的「滤镜」-> 「3D」-> 「生成法线图」:
💡 自 Photoshop 22.2 版 (2021 年 2 月发布) 之后,Adobe 就逐步砍掉了 Photoshop 的 3D 功能。若想在 Photoshop 里制作 3D 纹理,需要安装 22.2 或更低一点的版本。
1.4 在着色器中的应用
漫反射
假设我们存在一张墙面纹理,以及一张墙面法线贴图:
我们可以将其传入 Cocos Creator 着色器中采样,来模仿漫反射光照的实现:
uniform UBO {
vec4 lightColor; // 光源颜色
vec2 lightPos; // 光源 UV 坐标(0-1),实际对应鼠标在墙面上的位置
};
uniform sampler2D normalMap; // 法线图
// 漫反射函数 (Lambert)
vec3 calcDiffuse(vec3 normal, vec3 lightDir, vec3 lightColor) {
float NdotL = max(dot(normal, lightDir), 0.0);
return lightColor * NdotL;
}
vec4 frag () {
// 原图
vec3 baseColor = texture(cc_spriteTexture, uv).rgb;
// 法线贴图采样与解码
vec3 n = texture(normalMap, uvCoord).rgb * 2.0 - 1.0;
// 法线再次归一化
n = normalize(n);
// n = normalize(mix(vec3(0.0, 0.0, 1.0), n, 0.5)); // 可调节法线方向强度
// 光源方向(光源在 UV 空间)
vec3 lightDir = normalize(vec3(lightPos() - uv, 0.1));
// 光照结果
vec3 lighting = calcDiffuse(n, lightDir, lightColor.rgb);
// 将主纹理颜色和光照结果混合
vec3 finalColor = baseColor * (0.3 + lighting);
return vec4(finalColor, 1.0);
}
其中第 19 行是对法线贴图的 RGB 进行逆向解码,得到当前像素点的法线单位向量;
第 21 行对法线单位向量再一次进行了归一化处理,这一步看似冗余,却很有必要 ——— 法线贴图在存储法线信息时,为了节省空间,通常并不会完全保证采样出来的 (r,g,b) 是单位长度的向量,可能会因为量化、插值、压缩(尤其是 DXT、ETC、ASTC 之类的纹理压缩格式)出现偏差,因此需要再次通过 normalize 方法严格确保其为单位向量。
此时执行效果如下,仅仅通过一张 2D 的法线贴图,就得到了一个高性能的 3D 凹凸光照效果:
镜面反射和 Toon 光照
漫反射光照只是传统的光照模型中的一种实现,它的明暗过渡非常柔和,也没有高光,只体现物体的体积感和受光面。
我们可以再试下另外两种有趣的光照模型 —— Blinn-Phong 镜面反射和 Toon 光照:
- Blinn-Phong 在漫反射的基础上加入镜面反射,会有明显的高光效果,且高光效果取决于光源方向、观察方向,以及表面光滑度(高光系数);
- Toon 光照会通过
step方法把光照的渐变打断,变成明显的分区(而不是漫反射那样的平滑过渡),会呈现一种卡通渲染的风格。
它们的函数实现如下:
// Blinn-Phong 镜面反射
vec3 calcBlinnPhong(vec3 normal, vec3 lightDir, vec3 viewDir, vec3 lightColor, float shininess) {
vec3 halfDir = normalize(lightDir + viewDir);
float NdotH = max(dot(normal, halfDir), 0.0);
float spec = pow(NdotH, shininess); // pow 为幂次方函数
return lightColor * spec;
}
// Toon 光照(卡通分段光)
vec3 calcToon(vec3 normal, vec3 lightDir, vec3 lightColor) {
float NdotL = max(dot(normal, lightDir), 0.0);
float stepVal = step(0.5, NdotL);
return lightColor * stepVal;
}
我们封装一个 applyReflection 方法,并新增 reflectMode、shininess 两个 uniform 参数,前者用于选择要使用的光照模型,后者表示镜面反射的高光系数:
// 根据模式选择光照
vec3 applyReflection(
vec3 normal,
vec3 lightDir,
vec3 viewDir,
vec3 lightColor,
float shininess
) {
if (reflectMode == 1) {
return calcDiffuse(normal, lightDir, lightColor);
}
else if (reflectMode == 2) {
return calcBlinnPhong(normal, lightDir, viewDir, lightColor, shininess);
}
else if (reflectMode == 3) {
return calcToon(normal, lightDir, lightColor);
}
return vec3(0.0);
}
vec4 frag () {
// 略...
// 视线方向(2D 可以认为是 (0,0,1))
vec3 viewDir = vec3(0.0, 0.0, 1.0);
// 光照结果
vec3 lighting = applyReflection(n, lightDir, viewDir, lightColor.rgb, shininess);
}
鉴于我们目前的游戏项目是纯 2D 的,故 Blinn-Phong 镜面反射模型函数所需的「视线方向」,默认为 vec3(0.0, 0.0, 1.0) 即可(即垂直于屏幕)。
执行效果:
二、高度贴图
2.1 介绍
法线贴图很好地记录了模型上各个面的方向,可以在光照计算中用来模拟细节凹凸感,但模型的几何形状并没有实际的变化。
例如前文的墙面光照案例,虽然使用法线贴图可以让墙面呈现凹凸感,但细看你会发现砖块之间的缝隙缺乏深度。
而高度贴图(Height Map)可以提供法线贴图所缺少的「深度」信息,可以在着色器中为立体的视觉效果进一步添砖加瓦:
图片引用自 Unity 官方文档
与法线贴图的蓝紫色调不同,高度图一般呈现为灰度图像,其 RGB 规则为:
- 黑色(
0.0)表示低处(最低点); - 白色(
1.0)表示高处(最高点); - 灰色(
0.0 ~ 1.0之间)表示中间高度。
你可以在 Photoshop 22.2(或更低一点的版本)中制作高度贴图,功能路径为「滤镜」-> 「3D」-> 「生成凹凸图」:
2.2 在着色器中的应用
我们在前文的墙面光照案例中,补充一张高度图:
接着新增一个 heightMap 采样器变量来绑定高度图,并通过一个 useHeight 参数来决定是否使用高度图:
uniform sampler2D heightMap; // 高度图
vec4 frag () {
// 如果启用高度图,使用高度值修改 UV
if (useHeight == 1) {
float height = texture(heightMap, uv).r; // 高度值区间 [0, 1]
// 获取视角方向的单位向量
vec2 viewDirXY = normalize(vec2(0.5, 0.5) - uv);
// 视差偏移计算
uv += viewDirXY * (height - 0.5) * 0.02;
}
vec3 baseColor = texture(cc_spriteTexture, uv).rgb;
// 略(无改动)...
return vec4(finalColor, 1.0);
}
其中第 6 行我们使用了高度贴图纹理的 R 通道来代表其灰阶值(即高度值)。
第 8 行则获得了一个从当前像素点指向了墙体中心(对应 UV 坐标 vec2(0.5, 0.5))的单位向量 viewDirXY,来模拟 3D 游戏中物体表面点到相机的方向。
viewDirXY 的作用有些类似于 Photoshop 中内阴影特效的「角度」的设定 —— 用来定义光或视线的来源方向,从而决定深度效果如何呈现:
第 10 行的代码是视差映射的核心,它利用上一步计算出的视角方向和当前像素的高度值,对纹理坐标进行偏移,从而模拟深度变化:
// 根据表面高度的不同和观看的角度,模拟纹理采样点的偏移
uv += viewDirXY * (height - 0.5) * 0.02; // 0.02 是强度因子
其中 height - 0.5 将高度的基准从 [0, 1] 转换到了 [-0.5, 0.5]:
0.0代表中间高度;- 负值表示比中间更凹陷;
- 正值表示比中间更凸起。
而 viewDirXY * (height - 0.5) 则获得了一个偏移方向:
- 如果表面是凸起的,
(height - 0.5)为正。偏移方向与视角方向viewDirXY相同。这意味着采样点会沿着视线方向向前移动,从而模拟出凸起部分「向前突出」,看到了它顶部的纹理。 - 如果表面是凹陷的,
(height - 0.5)为负。偏移方向与视角方向viewDirXY相反。这意味着采样点会逆着视线方向向后移动,从而模拟出凹陷部分「向下退缩」,看到了它更深处的纹理。
鉴于 height - 0.5 的计算结果对于 UV 坐标体系的 [0, 1] 区间而言,是一个很大的数值,因此我们使用了 0.02 来做为强度因子,将偏移量调整在一个合适的区间(强度因子越大,凹凸感会越明显,但失真也越大)。
此时启用高度图时,墙面砖块边缘会出现明显的深度效果:
💡 读者可以在线上演示页体验更多的光照细节。