Cocos Creator Shader 入门 ⒄ —— 法线贴图和高度贴图

721 阅读9分钟

💡 本系列文章收录于个人专栏 ShaderMyHead

💡 本文案例可以在 Github 上进行演示

一、法线和法线贴图

1.1 法线介绍

在计算机图形学中,法线(Normal) 是一个核心概念。它通常指的是一个垂直于物体表面的单位向量

假设你有一张平坦的桌面,一支笔直立在桌面正中央,笔的方向就可以看作是桌面该点处的法线方向

对于更复杂的曲面模型,模型上的每个面都会有一条垂直于自身的法线(如下图蓝线),用于定义该面「朝向」何处:

12.gif

法线最重要的作用是计算光照 —— 根据光线方向与表面法线的夹角来计算光线在该表面的反射强度。

在计算机图形学中存在多种光照模型,其中最主流的「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) 正是这种设想的实现:

image.png

一张法线贴图,存储的并不是「颜色」,而是用 RGB 三个通道存储了法线向量「归一化」后的单位向量 (x, y, z)。

💡「归一化」表示法线向量除以自身长度之后的值(也是一个 vec3 向量),可理解为「该向量长度为 1 时的表示」、「法线的单位向量」。

💡 GLSL 提供了 normalize 函数来计算指定法线向量的归一化向量,你可以在《附录 —— 2.4 计算相关》了解此函数。

由于法线单位向量的取值范围是 [-1, 1],而贴图的颜色范围是 [0, 1],所以要做一个映射计算:

RGB=Normal+12RGB = \frac{Normal + 1}{2}

也就是把 -1 ~ 1 映射到 0 ~ 1,做为 RGB 色值存起来做为法线纹理贴图。

常规而言,法线贴图是呈现蓝紫色调的:

image.png

图片引用自 Unity 官方文档

这是因为在切线空间下,大多数表面是朝上的(z 方向大),即法线单位向量接近 (0, 0, 1),将其映射到 RGB 后变为 (128, 128, 255),也就是蓝色偏紫色。

当表面稍微有凹凸时,xy 会偏离 0,导致映射后的红绿通道有变化,但 z 还是接近 1(蓝色通道接近 255),所以整体呈现蓝紫色。

1.3 法线贴图的制作

你可以使用 Blender、SpriteIlluminator、Photoshop 等图形软件来制作某张图片的法线图。

其中 Photoshop 制作法线图的功能位于顶部菜单栏的「滤镜」-> 「3D」-> 「生成法线图」:

image.png

💡 自 Photoshop 22.2 版 (2021 年 2 月发布) 之后,Adobe 就逐步砍掉了 Photoshop 的 3D 功能。若想在 Photoshop 里制作 3D 纹理,需要安装 22.2 或更低一点的版本。

1.4 在着色器中的应用

漫反射

假设我们存在一张墙面纹理,以及一张墙面法线贴图:

image.png

我们可以将其传入 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 凹凸光照效果:

11.gif

镜面反射和 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 方法,并新增 reflectModeshininess 两个 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) 即可(即垂直于屏幕)。

执行效果:

44.gif

二、高度贴图

2.1 介绍

法线贴图很好地记录了模型上各个面的方向,可以在光照计算中用来模拟细节凹凸感,但模型的几何形状并没有实际的变化。

例如前文的墙面光照案例,虽然使用法线贴图可以让墙面呈现凹凸感,但细看你会发现砖块之间的缝隙缺乏深度。

而高度贴图(Height Map)可以提供法线贴图所缺少的「深度」信息,可以在着色器中为立体的视觉效果进一步添砖加瓦:

image.png

image.png

图片引用自 Unity 官方文档

与法线贴图的蓝紫色调不同,高度图一般呈现为灰度图像,其 RGB 规则为:

  • 黑色(0.0)表示低处(最低点);
  • 白色(1.0)表示高处(最高点);
  • 灰色(0.0 ~ 1.0 之间)表示中间高度。

你可以在 Photoshop 22.2(或更低一点的版本)中制作高度贴图,功能路径为「滤镜」-> 「3D」-> 「生成凹凸图」:

image.png

2.2 在着色器中的应用

我们在前文的墙面光照案例中,补充一张高度图:

image.png

接着新增一个 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 中内阴影特效的「角度」的设定 —— 用来定义光或视线的来源方向,从而决定深度效果如何呈现:

ps767ps.gif

第 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 来做为强度因子,将偏移量调整在一个合适的区间(强度因子越大,凹凸感会越明显,但失真也越大)。

此时启用高度图时,墙面砖块边缘会出现明显的深度效果:

13.gif

💡 读者可以在线上演示页体验更多的光照细节。