1 纹理映射 TextureMapping

首先让我们一起来观察这样一张图:

1684499673566

无论是球上的图案,以及地板的木头纹理都呈现出了不同的颜色信息,那么回想在讲解 Blinn-Phong 反射模型的时候曾提到,一个点的颜色是由其漫反射系数决定的,反射什么颜色的光,人眼就能看见什么颜色。那么针对上面这幅图,难道要去针对每一个点自己去设定一个颜色吗?还是说有什么更方便的方法呢?那便是纹理映射了!

主要有两种方法可以将三维的空间坐标点转化为二维的纹理坐标点:Projector 和 UV Mapping

Projector

对于简单的集合体,通常用投影的方式:
1684499673628

倘若拥有从 3 维 World space 到 2 维 Texture space 的一个映射关系,那么只需要将每个点的颜色信息即漫反射系数存储在 2 维的 Texture 之上,每次利用光照模型进行计算的时候根据映射关系就能查到这个点的漫反射系数是多少,所有点计算完之后,结果就像最左边的 screen space 之中,整个 Texture 被贴在了模型之上。

一个纹理坐标使用的伪代码供参考:

1684499674200

简而言之就是对每个光栅化的屏幕坐标算出它的 uv 坐标 (利用三角形顶点重心坐标插值),再利用这个 uv 坐标去查询 texture 上的颜色,把这个颜色信息当作漫反射系数 Kd。

将矩形地图纹理均匀贴到球表面的投影函数称之为:Spherical 形式。此外还有 Plane, Cubic 和 Cylindrical,下图总结了四者差异(一张红绿相间的纹理贴到不同简单几何体的方式):
1684548373611

UV Mapping

对于更复杂的几何体贴图,往往需要用到 UV Mapping:用于将 3 维模型中的每个顶点与 2 维纹理坐标一一对应。 UV map 则需要建模师精心制作:

1684548373674

在实时渲染中,通常是将 uv 坐标保存在顶点信息中,在三角形内使用时,通过插值的方式得到每个片元具体的 uv 坐标,再从纹理中采样获得对应的值。但也有例外,比如在进行环境贴图 (Environment Mapping) 时,需要在渲染时根据投影方程再去确定每个点的纹理坐标以及纹理值

2 纹理过滤

在理解了纹理映射的基础之后,考虑如果纹理分辨率特别小或者纹理分辨率特别大会分别引起什么问题呢?

2.1 纹理分辨率过小

纹理过小的问题相对容易理解,想想我们把一张 100x100 的纹理贴图应用在一 500x500 的屏幕之上必然会导致走样失真,因为屏幕空间的几个像素点对应在纹理贴图的坐标上都是集中在一个纹素大小之内。那么如果仅仅是使用对应 (u, v) 坐标在采样贴图上距离最近的那个像素点,往往会造成严重的走样
1684499674257||300

黑点所在的方块表示纹理的一个纹素,红点表示屏幕空间的像素采样点,采样时最简单的办法是去选择离采样点临近的纹素(该方法称为最邻近采样 Nearest neighbor),那么图中四个采样点采样得到的颜色都是同一个纹素的颜色。

这种方法往往会产生严重的走样,接下来会介绍利用双线性插值的方法缓解这种走样现象。

2.2 双线性插值 (Bilinear Interpolation)

我们依然取上图的点作为例子,解释双线性插值。

第一步,取出离红色点最近的 4 个黑色顶点,分别算出,该红色点在水平及竖直方向偏移的比率 $s,t$,图示如下:

1684499674313||300

接着先利用 $s$,可以线性插值出如下图所示的 $u_0$,$u_1$ 点的颜色值
Pasted image 20230520102619
那么下一步相信读者也能猜到了,利用比例 $t$,颜色值 $u_0$,$u_1$ 插值出红色点的颜色值
Pasted image 20230520102532

如此这样利用两次线性插值,考虑到了 4 个纹素的颜色值,能够很好的缓解走样失真现象,并且计算速度较高。

(tips: 还有一种插值方法叫做双三次插值 (Bicubic), 取临近 16 个纹素,每次对 4 个纹素用三次插值,而不是线性插值,效果可能更好,但是计算速度很低不在这里具体讨论了)

最后以一张闫老师课上的例子看看这 3 种方法效果的对比
Pasted image 20230520102841|500

2.3 纹理分辨率过大

可能对于我们的第一直觉来说,纹理小确实会引发问题,但是纹理大那不是更好吗,为什么会引发问题呢?但事实是纹理过大所引发的走样甚至会更加严重。想象一张很大的地板,在上面铺满了重复的方格贴图,我们所期望看到的结果应该是这样的:

1684499674488||500

嗯,非常符合透视关系,不错,当然这只是一个参考。再来看看利用在第一章所提到的计算纹理颜色的伪代码来计算的结果呢:

1684499674550||500

近处锯齿!远处摩尔纹! 非常严重的走样现象,为什么会导致这样的一个现象呢?这里作者尝试给出自己的两种解释:

1 如开头所说,地板上铺满了重复的方格贴图,根据近大远小,远处的一张完整的贴图可能在屏幕空间中仅仅是几个像素的大小,那么必然屏幕空间的一个像素对应了纹理贴图上的一片范围的点,这其实就是纹理过大所导致的,直观来说想用一个点采样的结果代替纹理空间一片范围的颜色信息,必然会导致严重失真!(从信号的角度来说就是,采样频率过低无法还原信号原貌)

2 换一种想法,考虑离相机很远的一个三角形面,假设该三角形面真正在纹理贴图上对应的一片区域有 10 个像素点。但是由于透视的关系,距离很远的三角形面投影到近平面时可能只有 1 个或 2 个像素点的大小 (远远小于 10 个像素的原来大小),那么这 1 个或 2 个像素采样 texture 的结果就要代表原来这个三角形面 10 个像素点的颜色信息,自然会导致失真!
Pasted image 20230520103718

一种直观的解决方法就是超采样 Supersampling,如果一个像素点不足以代表一个区域的颜色信息,那么便把一个像素细分为更多个小的采样点不就可以解决这个问题了吗?对,确实是这样,可以看看如下图 512x 超采样的结果

1684499674687

效果虽称不上完美但也极大缓解了走样现象,但问题是什么?计算量太大了,一个像素点被分为了 512x512 个采样点,计算量几乎多出了 25 万倍!这显然不是所希望看到的,并且随着屏幕空间的点离相机距离更远,更多的 texels (纹理空间的像素) 会在屏幕像素的一个覆盖面积(footprint) 里面,会要更高的超采样频率。

那么另外一种想法,如果不去超采样,仅仅是求出每个屏幕像素所对应的覆盖面积(footprint) 里所有 texels 的颜色均值呢?这也就是接下来所要介绍的著名的 Mipmap 技术了!

2.3 多级渐远纹理 Mipmap

“MIP”来自于拉丁语 multum in parvo 的首字母,意思是“放置很多东西的小空间”

回顾一下屏幕像素在 Texture 空间里的覆盖面积(footprint) 的这张图:

1684499674811

正如上文所提,一个采样点的颜色信息不足以代表 “覆盖面积(footprint)” 里一个区域的颜色信息,如果可以求出这样一个区域里面所有颜色的均值,是不是就是一种可行的方法呢?没错我们的目标就是从点查询 (point query)迈向范围查询 (range query)。但依然存在一个问题,从上图不难看出,不同的屏幕像素所对应的覆盖面积(footprint) size 是不一样大小的,看下图这样一个例子:

1684499674849

远处圆圈里的覆盖面积(footprint) 必然比近处的要大,因此必须要准备不同 level 的范围查询才可以,而这正是 Mipmap。

Mipmap 允许范围查询 (快速,近似,正方形)
Pasted image 20230520105340

1684499674924

level 0 代表的是原始 texture,也是精度最高的纹理,随着 level 的提升,每提升一级将 4 个相邻像素点求均值合为一个像素点,因此越高的 level 也就代表了更大的覆盖面积(footprint) 的范围查询。接下来要做的就是根据屏幕像素的覆盖面积(footprint) 大小选定不同 level 的 texture,再进行点查询即可,而这其实就相当于在原始 texture 上进行了范围查询!

[!NOTE] Mipmap 的额外显存开销
原始图像开销:$128\times 128=16,384$
mipmap 开销:$128\times 128+64\times64+32\times32+16\times16+8\times8+4\times4+2\times2+1\times1=21845$
额外开销:$\frac5461{16384}\approx1/3$
即做 mipmap 比会增加原本图像 1/3 的额外存储量

Mipmap 除了能消除采样率过低带来的失真问题,还有一个重要的优点是节约显存带宽,注意是带宽而不是容量。Mipmap 实际消耗的显存大约增加了 1/3,但每次仅从需要的 Mipmap 级别进行读取,而不必每次都访问原始大小的纹理,因此可以节约带宽。

如何去确定使用哪个 level ?

利用屏幕像素的相邻像素点估算覆盖面积(footprint) 大小再确定 level D!如下图:
Pasted image 20230520111345
在屏幕空间中取当前像素点的右方和上方的两个相邻像素点 (4 个全取也可以),分别查询得到这 3 个点对应在 Texture space 的坐标,计算出当前像素点与右方像素点和上方像素点在 Texture space 的距离,二者取最大值,计算公式如图中所示,那么 level D 就是这个距离的 log2 值 (D = log2L) ! 这不难理解,读者可以具体取几个例子比如 L = 1,L = 2,L = 4,看看是否符合这样的计算即可。

但是这里 D 值算出来是一个连续值,并不是一个整数,有两种对应的方法:

  1. 四舍五入取得最近的那个 level D
    Pasted image 20230520111752
    我们会发现不同 level 边缘很硬,为了解决这个问题,自然想到了插值

  2. 利用 D 值在向下和向上取整的两个不同 level 进行三线性插值

1684499675056

[!NOTE] 三线性插值
所谓三线性插值,就是在向下取整的 D level 上进行一次双线性插值 (前文提过),再在 D+1 level 之上进行一次双线性插值,这二者数据再根据实际的连续 D 值在向下和向上取整的两个不同 level 之间的比例,再来一次线性插值,而这整体就是一个三线性插值了。 ^s6etys

Pasted image 20230520112104

好了!根据上述的方法算出屏幕上每一个像素点所对应的 Mipmap level,再进行三线性插值得到颜色值,是否就能很好的解决走样问题了呢?很遗憾,在本文的那个地板的例子之中,费了这么大力气依然不能完美解决,如下图结果:

1684499675124

远处的地板产生一种模糊的现象(Overblur)。该如何解决这个最后的问题呢——各向异性过滤。

2.4 各向异性过滤 Mipmap

各向异性的意思是在各个方向上的表现不相同。通过各向异性可以考虑不同的方向性。

产生 Overblur 的原因是因为,所采用的不同 level 的 Mipmap 默认的都是正方形区域的 Range Query,然而真实情况并不是如此,见下图:

1684499675236

可以看出不同 screen space 的像素点所对应的覆盖面积(footprint) 是不同的,有长方形,甚至是不规则图形,那么针对这种情况,有的所需要的是仅仅是水平方向的高 level,有的需要的仅仅是竖直方向上的高 level,因此这也就启发了各向异性的过滤:

1684499675290

从左往右看,宽度从原长逐渐缩小逼近 0
从上往下看,高度从原长逐渐缩小逼近 0

[!NOTE] 各向异性过滤显存开销
Pasted image 20230520144131
可以看出,额外 3 倍的显存开销

利用这样不同的贴图,更加精细的选择后结果就会明显好很多,基本解决了 overblur:

1684499675354||300

各向异性过滤只能解决水平或竖直的不同大小的矩形覆盖面积(footprint),并不能解决斜向的覆盖面积(footprint)。
Pasted image 20230520112744|323

解决方法:EWA 过滤
Pasted image 20230520112852|400
EWA 过滤就是把斜着的图形拆成很多个圆形去覆盖不规则形状,每一次就查询一个圆形,然后多次查询,自然就可以覆盖这个不规则的形状,得到好的结果。缺点是开销较大

附录:各向异性过滤实现步骤

(1)计算得到 UV 在 xy 方向上的偏导数 $du_{x},du_{y},dv_{x},dv_{y}$

(2)计算得到纹理坐标偏导数 $s=duwidth,t=dvheight$

(3)计算绘制图形在纹理空间的投影向量 r1 r2 d1 d2

$r1 = (s_{x},t_{x}),r2=(s_{y},t_{y})$

1684499683255

其中,为了后续计算简便,将向量的模进行近似

$|r1| \approx max(|s_{x}|,|t_{x}|)$

$|r2| \approx max(|s_{y}|,|t_{y}|)$

$|d1| \approx max(|s_{x}+s_{y}|,|t_{x}+t_{y}|)$

$|d1| \approx max(|s_{x}-s_{y}|,|t_{x}-t_{y}|)$

(3)计算使用的 MipmapLevel

$j=min(|r1|,|r2|,|d1|,|d2|)$

$l = log_{2}j$ ,此为采样使用的 MipmapLevel

$f=\frac{j}{2^l}-1$ ,此为三线性过滤使用的插值系数

(4)计算各向异性比例

$N = min(2^{[log_{2}\frac{max(|r1|,|r2|)}{j}]}, maxAniso)$ 这里的 maxAniso 就是设置开启的各向异性过滤级别,方括号代表取整

(4)在上面计算出的 MipmapLevel 下进行多次三线性采样,为此需要生成一系列采样坐标

$m = max(r1,r2)$ (这里使用的是向量 r1 r2)

$du = \frac{m.x}{2^{l}N},dv = \frac{m.y}{2^{l}N}$

$u_{n} = u + (\frac{n}{2} du),v_{n} = v+(\frac{n}{2}dv)$

其中 $n = -N+1,-N+3……-3,-1,1,3……N-3,N-1$

(5)将采样获得的颜色进行平均

一般来说,我们在游戏中最高可以开启 16x 的各向异性过滤,但是实际运算时会将我们设置的各向异性级别与计算得的 N 取较小值,因此并不是开启了 16x 就一定会执行 16 次采样。各向异性过滤相比双线性的性能消耗成倍增长,好在现代 GPU 上纹理采样已不是瓶颈,在平时游戏时我们可以放心开启此选项,帧数不足时优先关闭阴影、抗锯齿等特效。

3 纹理寻址模式

我们将 3 维空间坐标映射为了 2 维参数空间坐标 uv,此时得到的有可能是 $[0.25,0.3]$ 这种在 $[0,1]$ 范围内的值,但也有可能超出了 $[0,1]$ 范围。
可将经过常数插值或线性插值的纹理定义为一个返回向量值的函数 $T (u, v) =(r, g, b, a)$,即给定纹理坐标 $(u, v)∈[0, 1]^2$,则上述纹理函数 $T$ 将返回颜色 $(r, g, b, a)$。Direct3D 允许我们采用下列 4 种不同方式(即寻址模式,address mode)来扩充此函数的定义域(解决输入值超出定义域 $[0, 1]^2$ 这一问题):

  • 重复寻址模式 ( wrap):通过在坐标的每个整数点处重复绘制图像来拓充纹理函数。Pasted image 20230520151724|400

  • 边框颜色寻址模式 ( border color,也有译作边界颜色寻址模式):通过将每个不在范围 $[0,1]^2$ 内的坐标 $(u, v)$ 都映射为程序员指定的颜色而拓充纹理函数。Pasted image 20230520151813|400

  • 钳位寻址模式 ( clamp):钳位寻址模式通过将范围 $[0,1]^2$ 外的每个坐标 $(u, v)$ 都映射为颜色 $T (u_0, v_0)$ 来扩充纹理函数, 其中 $(u_0,v_0)$ 为范围 $[0,1]^2$ 内距离 $(u, v)$ 最近的点。Pasted image 20230520152004|400

  • 镜像寻址模式 ( mirror ):通过在坐标的每个整数点处绘制图像的镜像来扩充纹理函数 Pasted image 20230520152030|400

在 OpenGL 中称为纹理环绕方式:

环绕方式 描述
GL_REPEAT 对纹理的默认行为。重复纹理图像。
GL_MIRRORED_REPEAT 和 GL_REPEAT 一样,但每次重复图片是镜像放置的。
GL_CLAMP_TO_EDGE 纹理坐标会被约束在 0 到 1 之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
GL_CLAMP_TO_BORDER 超出的坐标为用户指定的边缘颜色。

当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。我们来看看这些纹理图像的例子:

texture_wrapping|img

4 纹理数据源

Pasted image 20230517230046

[!NOTE]
艺术家使用美术软件制作贴图并导出为 TGA、PNG 等格式,最后引擎自动将其转换为 DDS 格式。

DDS (DirectDraw 图面格式,DirectDraw Surface format,DDS) 是一种针对 GPU 专门设计的图像格式。满足用于 3D 图形开发的以下特征:

  1. mipmap
  2. GPU 能自行解压的压缩格式
  3. 纹理数组
  4. 立方体贴图(cubemap)
  5. 体积纹理(volume texture)

5 纹理应用

在这里,主要介绍纹理映射的应用:Material Map、Alpha Map、Bump Map、Normal Map、Relief Map、Displacement Map、Parallax Map、Textured Light、Shadow Map、Environment Map

我将这些应用分为六个方面:

控制着色信息、控制片元透明度、改变法线、改变表面结构、阴影贴图、环境贴图

一、控制着色信息

在之前 《基础着色模型》中,介绍了简单的 Blinn-Phong 着色模型,如下图所示:

1684548488321

可以看到,里面有许多需要调节的参数,如表面颜色、高光强弱、高光衰减等因子,这还只是简单的着色模型,如果用基于物理的着色(PBS)模型,参数会更加复杂。

虽然可以通过赋予顶点更多信息来改变这些参数,但是要做到亚三角形的细节,就需要用各种各样纹理,来对每个片元的着色参数进行调节,我将这些纹理映射方法统称为材质映射 (Material Map)

用得最多的是漫反射映射 (diffuse/albedo/base color map),比如经常用于模拟墙壁或者岩石:

1684548488374

此外还可以改变镜面反射的系数,如粗糙度 (决定高光衰减) 和高光反射系数

1684548488409

同时应用漫反射贴图和粗糙度贴图的效果如下:

1684548488482

二、控制片元透明度

纹理都是矩形的,但当我们需要实现 贴画 (decal) 或者 镂空 (cutout) 效果时,往往不想让纹理贴满整个表面,也就是说,一些地方的透明度要为 0.

如下图中的血迹的贴画和镂空的树:

1684548488520

此类纹理映射称为透明度映射 (Alpha Map),这种纹理通常有一个性质:texel 完全透明或者完全不透明 (其实也有半透明需要混合的情况,此处不考虑)

所以不需要把片元当作半透明进行混合,而是进行透明度测试 (Alpha Test),将透明度小于阈值的 texel 认为是完全透明,直接抛弃片元,否则为完全不透明

测试完成后,再用 z-buffer 算法 (可见前文 《透明度与混合》) 进行对所有完全不透明片元进行混合。

透明度测试的伪代码如下:

1684548488587

但是简单的透明度测试在使用 Mipmap 使会存在问题:如下图,第 0 级纹理连续四个 texel 的透明度为 [0.0, 1.0, 1.0, 0.0],第 1 级纹理就为 [0.5, 0.5]

假设我们的阈值设为 0.75,根据双线性插值,可知道第 0 级纹理中有 1.5/4 在测试中保留,但是在第 1 级纹理中由于 0.5<0.75,所以像素均被抛弃,如下图所示:

1684548488651

于是,在不同的 Mipmap 纹理等级中,经过透明度测试留下来的像素占比 (Coverage) 也不一样,会随着 d 增大,有效像素越来越少,如下图所示:

1684548488685

在一个镜头逐渐拉远时,可以看到树叶也在变少,到远距离时只能看到树干了:

1684548488721

一般解决的方法有两种:

1) 手动调节每级 Mipmap 的透明度,或者在 shader 里面根据纹理等级 d 对透明度进行缩放

2) 一般严重的 artifact 出现在 d 比较大的时候,所以就限制 d 的最大值

但这两种方法都只能是近似,不能很好解决问题,出现这个 artifact 的关键在于:不同 Mipmap,用同样的透明度阈值会得到不同的 Coverage(代表测试留存的像素比例)

Castano 提出了一种:保证 coverage 一致的情况下,自适应确定透明度阈值,并对原透明度缩放调整的方法,详见 [[1 前言]] ( http://the-witness.net/news/2010/09/computing-alpha-mipmaps/ )

其步骤为:

1) 先求出 d=0 纹理等级时,自定义的一个阈值 a_0 对应的 coverage = c_0,

2) 随后在 d=1,2,3… 等各级纹理中 coverage 与 c_0 相近的前提下,用二分查找求出不同等级的透明度阈值 a_d,此时理论上只需要对不同等级的纹理透明度施加不同的透明度阈值即可,但是现有管线的透明度测试中,只能用同一个透明度阈值。所以等效为:

3) 对各级纹理的透明度进行 a_0 / a_d 倍缩放,在透明度测试时,用 d=0 级处的透明度阈值 a_0 阈值即可。

此过程可用下图表示:

1684548488777

使用此算法处理前后的效果如下,可见处理后,中远距离的树叶没有消失:

1684548488894

除此之外,还有其他方法,考虑到本文的主旨是讲纹理映射的应用,所以关于透明度测试的一些问题就后面有机会再讲

另外在对 RGBA 值进行线性插值时,要注意先把 alpha 分量预乘到 RGB 分量上,再进行插值,比如要对很透明的绿色 (0,255,0,2) 和不透明的红色 (255,0,0,255) 进行插值,有两种情况:

1684548488931

可以发现,无预乘插值结果更偏绿,而预乘后插值结果更偏红。

比较实际的情况是:希望插值的结果偏向于不透明那边颜色的色调,所以通常而言,预乘后插值会比较合理

三、改变法线

用于改变表面法线的贴图统称为凹凸贴图 (Bump Mapping,虎书上面把 Bump Mapping 特指为后续的 height-field map,我此处沿用了 RTR 的说法,即 Bump Mapping 包含这一类技术,都是改变法向的技术)

为什么使用凹凸贴图呢?我们先来看三种细节层次:

1) 宏观特征 (Macro-Feature):往往是跨三角形的,主要用几何体来表征。比如一个人的头或者胳膊,都是用三角形构成的几何体

2) 微观特征 (Micro-Feature):往往是亚像素的,主要是用着色方程来表征。比如衣服的材质和皮肤的材质、粗糙度等不一样

3) 中观特征 (Meso-Feature):中观特征位于宏观和微观之间,往往是在一个三角形内跨像素的,比如衣服上的褶皱或者脸上的皱纹,这些特征用细小的三角形建模显得过于浪费了,于是就用凹凸贴图的方式表征。

我们知道,一个三角形就有一个法向量,凹凸贴图在于微调三角形内每个像素的法线,但只用于着色方程中计算光照,三角形的几何体并没有改变,仍然是平的。凹凸贴图的制作通常是从非常细节、非常多小三角形的模型中导出来

凹凸贴图的保存有几种方式:

1) 用一个分量保存高程图 (height-field),然后通过差分计算出法向量,称之为 height-field map

2) 用两个分量保存法向量在 (u, v) 两个方向的偏移 (b_u, b_v),称之为 offset map

3) 用三个分量直接保存新的法向量 (x, y, z),称之为 normal map

三者保存方式如下图所示:

1684548489128

对于第一种高程 (height-field),其纹理值高的地方对应 “高度” 也高,一般看起来是一张灰度图,如下图所示,用一张高程纹理来形成球面起伏的效果:

1684548489196

对应第二种偏移图 (offset map),这是 Blinn 在 1978 年提出来的,属于很老的技术,现在很少用,可参考 [[2 基本认知和应用]] ( https://www.microsoft.com/en-us/research/wp-content/uploads/1978/01/p286-blinn.pdf )

而第三种法向贴图 (normal map),应用最广泛。直接将法向量三个分量 (x, y, z) 保存在纹理的 (R, G, B) 三个通道,用 [0,255] 表示 [-1,1],所以在前面图中转化为减去了 128. 法向贴图及其效果可见下图,由于法向都与三角形面法向相近,所以 z 分量会比较大,贴图看上去往往是蓝色的:

1684548489233

此外,对于法向量所处的坐标空间也有一定选择:

1) 世界空间 (world space):法向量处于世界空间系,那么模型一旦旋转后就不能用了

2) 物体空间 (object space):模型旋转后贴图还能用,但是不能将贴图复用于同一模型的多个不同朝向的面

3) 切线空间 (tangent space):贴图可以复用于不同朝向的表面上,所以最常用

1684548489323

切线空间基一般是三个分量:法向基 n (normal)、切线基 t (tangent)、双切线基 b (bitangent),但为了节省存储,只在每个顶点保存 tb,然后叉乘计算 n

要注意的是:在计算光照时,要保证法向量和光线向量等都在同一个坐标空间。

若不在同一空间,乘以一个矩阵转换即可,比如将向量从物体空间转到切线空间的矩阵:

1684548489367

另外还有一个问题,凹凸贴图在多级纹理插值时会很困难,因为着色方程的表达式与法向量的关系往往不是线性的。

在线性插值时,比如由两个法向量 n1, n2 插值得到新的法向量 n,记此过程为:n = an1 + bn2,但在着色时,比如 Blinn-Phong 里的高光部分记为 c (n),不是线性运算,合并计算与分别计算的光照并不相等,即:

1684548489406

关于此问题的一个解决思路是:将着色方程的一些参数作为整体进行插值,把这个整体作为变量使得光照计算变为线性,而不仅仅是插值法向量。具体细节的话得在 PBS 里面才涉及到,此处就不深入了。

凹凸贴图的缺点在于:没有真正改变表面的几何凹凸性,所以在观察角度过于倾斜的时候,越会觉得表面像贴了一张纸上去,这是因为没有考虑由于高程带来的视差和遮挡

Parallax Mapping (视差贴图) 解决了这个问题

如下左图所示,当我们从相机观察到 p 点时,由于高程的存在,我们应该看到的是 p_ideal 处的着色,但实际上在凹凸贴图中用的是 p 点的着色信息,所以看起来不够真实:

1684548489450

视差映射由 Kaneko 提出 [[3 PBR 基本原理和实现]] ( https://www.gamedevs.org/uploads/detailed-shape-representation-with-parallax-mapping.pdf )

如上右图所示,所做的事情就是:根据 p 点的高程,以及 v 的方向,对 p 点偏移一段距离到达 p_adj,以接近 p_ideal

当高程变化比较平缓时,这个方法表现较好,但是当高程在 p 点处变化剧烈时或者 v 与表面法向的角度很大时,就有可能导致 p_adj 与 p_ideal 相差很远,如下图所示:

1684548489485

为了解决这个问题,Welsh 对此进行了改进 [[01 光学原理]] ( http://page.mi.fu-berlin.de/block/htw-lehre/wise2015_2016/bel_und_rend/skripte/welsh2004.pdf ):

为了防止 p 点偏移得过于厉害,Welsh 将偏移量限制在 h 范围内,此方法对于这种局部高程变化剧烈的砖墙图能够表现得很好,见下图:

1684548489529

即使这种方法解决了高程变化剧烈的情况,但还遗留一种遮挡的情况。

如下图所示,光线有时会被更高的其他点的高程所遮挡,但是上面并没有考虑这一点:

1684548489569

一种类似于 Ray Marching 的思想:从 p 点出发,向前检查有限数量的点,看这些点处的高程是否与光线相交,如果相交则取离交点最近的像素点,如 p_adj,并用此处的光照去替代 p 点的光照信息。

此外还有其他很丰富的方法,这一类技术统称为 地势贴图 (relief Mapping).

下图对比了凹凸贴图 (法线贴图)、视差贴图、地势贴图三种情况的效果:

1684548489601

Ray Marching 很有趣,是 ShaderToy 里面很常用的技巧,它和 Ray Tracing 很相似,都需要投射光线,区别在于 RM 没有实实在在的几何体,判断光线相交情况是用 Sphere Tracing 结合 Signed Distance Functions (SDF),之后有机会也会写一篇关于 RM 的文章

四、改变表面结构

第三部分中的凹凸贴图 (法线贴图)、视差贴图和地势贴图,都只是在计算光照时对法线或者其他光照方程的输入进行了微调,没有修改表面结构

此处所介绍的移位贴图 (Displacement Mapping) 则实际性地修改了表面结构,其主要依赖于渲染管线的曲面细分 (tessellation) 功能,即根据贴图中对三角形进行曲面细分,生成足够多更小的三角形,并对每个三角形的顶点位置都进行移位,实际上得到了更为精细的几何体。

可想而知,这种技术是效果最好的,但也是最复杂的。

Kalos 在 06 年有一篇综述 [[5]] ( https://www.researchgate.net/profile/Laszlo-Szirmay-Kalos/publication/220506016_Displacement_Mapping_on_the_GPU_-_State_of_the_Art/links/5aec48feaca2727bc003fdf9/Displacement-Mapping-on-the-GPU-State-of-the-Art.pdf ):

对比了各种技术,感觉讲得很不错,下图来源于此:

1684548489636

五、改变光源

纹理还可以用于改变光源,称之为 Textured Light

最简单的二维光源,类似投影仪的工作原理:把一张纹理放在聚光灯下投射过来,那么光照就带有各种图案:

1684548489675

除了二维纹理,用视锥体的方式进行投射之外,还可以用一维纹理保存光线的衰减系数,甚至用三维体纹理直接保存光在空间中的分布,但极其耗费内存。

六、阴影贴图

介绍阴影贴图之前,先回顾一下 z-buffer 算法:将距离相机最近的片元的深度保存起来:

1684548489757

如果我们把相机换成点光源,那么右边这张深度图,是否可以代表有光照片元的深度?且大于这个深度的片元都没有光照?

这就是阴影贴图的思想,考虑以下场景:

1684548489797

黄色区域为点光源光照示意范围,绿色区域为视锥体范围,很明显,红线处是阴影部分,实际操作时怎样确定这个红线位置呢?有以下步骤:

1) 第一次 pass,将相机放在光源位置,用 z-buffer 的方式存一张深度缓冲,称之为阴影贴图 (Shadow Map),并记录此时的投影变换矩阵 M

2) 第二次 pass,正式开始渲染场景,将相机放到正确的位置,对片元进行着色时,考察每个片元处是否有光照,方法为:用第一次 pass 里面的矩阵 M 将三维点 (Px, Py, Pz) 变换为二维坐标 (px, py) 和深度 pz,将 pz 与第一次 pass 里面存下来的阴影贴图对应点的深度 c (px, py) 进行对比,若 pz> c (px, py),则认为此片元无光照,否则有光照。

此过程如下图所示:

1684548489838

但此处的阴影贴图局限于只能生成点光源的硬阴影,对于其他光源或者软阴影的生成还需要其他方法,会在后面专门的阴影章节讲解。

七、环境贴图 (Environment Mapping)

相信一些人看过《楚门的世界》这部电影,里面的一个世界是被很大的一个几何体围起来的,环境贴图也是类似的做法:你所看到的四周比较远的东西不过是一整张贴图

环境贴图有多种保存形式:最常用的立方贴图 (Cubic Map)、经纬度贴图 (Latitude-Longitude Map)、球面贴图 (Sphere Map):

1684548489874

环境贴图主要有两个应用:

一是作为场景的背景颜色,比如这个场景有一些模型,但是模型后面不可能总是灰的或者黑的背景色,那么环境贴图就充当背景的颜色

二是作为镜面反射的颜色,比如场景中有一面镜子,你从镜子里看到的,实际是反射的背景的颜色,就好比你看到的镜子的颜色,其实就是镜子里面那个虚拟摄像机朝反射方向看过去的环境贴图的颜色,比如在 Forza 游戏中,赛车上的反射效果:

1684548489933

环境贴图有一个很重要的性质就是:贴图很远,以至于在获取贴图纹理值的时候,不需要考虑向量的位置,只需要考虑向量的方向 即可:

1684548489970

如图所示,尽管两个摄像机的位置不一样,但是视线方向是一致的,那么得到的环境贴图的纹理也是一样的。这种性质可以在计算光照时,不用判断向量到底与贴图的哪一点相交,只考虑方向

本文对一些常见的纹理映射的应用进行了大致讲解,每一种应用延伸出去都有很多东西可讲,受限于自身的水平和文章的篇幅,此处没有展开。如有错误,还请多多指教!

6 纹理压缩

一、什么是纹理压缩

  • 纹理压缩是

  • 为了解决内存、带宽问题,专为在计算机图形渲染系统中存储纹理而使用的图像压缩技术

  • 区分图片格式和纹理压缩格式

  • 概念上讲

  • 图片格式:

  • 是图片文件的存储格式,通常在硬盘、内存中存储,传输文件时使用

  • 例如:jpg、png、gif、bmp

  • 纹理压缩格式:

  • 显卡能直接进行采样纹理数据格式,通常在向显卡中加载纹理时才使用

  • 原理上讲

  • 图片格式:

  • 图片压缩格式是基于整张图片进行压缩,像素之间解码过程中存在依赖关系

  • 无法实现单个像素级的解析,发挥不了显卡的并行能力

  • 并且,无论什么格式在显卡解码后都是 RGBA 的纹理格式

  • 总结:无法减少显存的占用率,且需要 CPU 解压后才能被 GPU 读取,结果就是:增加了 CPU 的时间和带宽

  • 纹理压缩格式:

  • 基于块压缩,能够更快的读取像素所属字节块进行解压缩,以支持快速访问

  • “随机访问”:如果渲染一个物体时,需要在某个坐标上采样纹理,那么 GPU 只需要读取该像素所属固定大小字节块,对其进行解压即可。

  • 举例理解:

  • 如果拿到一张贴图,设置纹理压缩格式:

  • CPU 会按照我们设定的格式进行压缩,然后传递给 GPU 读取

  • 如果不设置纹理压缩格式,以图片格式进行:

  • CPU 也会进行压缩,但是会压缩为 RGBA32 格式,但其实这个格式是非常大的,并没有起到压缩的作用

二、为什么要使用纹理压缩

Pasted image 20221208232051

  • 实际上,使用纹理压缩的原因在上部分图片格式和纹理压缩格式区别的部分里已经讲了

  • 图片压缩格式下

  • 无法实现像素级解析,无法发挥 GPU 并行能力,无法减少显存的占用率

  • 需要在到 GPU 之间使用 CPU 解压缩,增加了 CPU 的时间和带宽

  • 纹理压缩格式下

  • GPU 可以直接读取贴图,不需要经过中间 CPU 解码/解压缩的步骤

  • 还支持“随机访问”

  • 总结一下就是:

  • 我们需要一种内存占用既小又能被 GPU 直接读取的格式(这种格式就是纹理压缩)

  • 总结

  • 纹理压缩相对正常图片格式,能够直接被 GPU 采样,发挥 GPU 强大的并行能力,且优化了带宽问题

三、常见的纹理压缩格式

Pasted image 20221208232125

黄字是我们常用的压缩格式

非纹理压缩格式

  • RGBA8888(RGBA32)

  • R、G、B、A 四个通道各占 8 位

  • 所以一个像素消耗:4 * 8 = 32 位(bit)= 4 字节(byte)

  • RGBA4444(RGBA16)

  • 四个通道各占 4 位内存

  • 一个像素消耗:4 * 4 = 16 位 = 2 字节

  • RGB888(RBG24)

  • 同理,一个像素消耗**:** 3 字节

  • RGB565 (RGB16)

  • 一个像素消耗2 字节

  • 总结

  • 带透明通道的,单通道可以是 4 位、8 位

  • 不带透明通道的,单通道可以是 8 位,或者在 RGB 分 16 位(565、绿通道多给 1 位)

1、DXTC 系列

DXTC 系列的纹理压缩格式来源于 S3 公司提出的 S3TC 算法

  • 基本思想

  • 把 4×4 的像素块压缩成一个 64 或 128 位的数据块

  • 优点

  • 创建了一个固定大小且独立的编码片段,没有共享查找表或其他依赖关系,简化了解码过程

DXT1(BC1)

  • 也叫作 BC1

  • 内容

  • 将 4×4 的像素块压缩成了一个 64 位的数据块,这个 64 位的数据块中包含:

  • 其中 32 位是:

  • 两个 16 位 RGB(RGB565)颜色。

  • (这两个 RGB 颜色是 4×4 像素块中的两个极端颜色值,然后通过线性插值计算出剩余的两个中间颜色)

  • 剩余的 32 位

  • 平均分配给了 16 个像素作为颜色值的索引值,每个像素占 2 位

  • 理解
    Pasted image 20221208232506
    Pasted image 20221208232510

  • 64 位分别为:

  • 其中 32 位是:蓝色的 A、B 两个 16 位 RGB 颜色,它们是极端颜色(格式为 RGB565)

  • 极端颜色通过线性插值得到的中间颜色:红色的 C、D

  • 另外 32 位是:16 个像素的颜色索引值,每个像素占 2 位(可能是 00 01 10 11,可以分别表示上边的 A、B、C、D 四种颜色)

  • 注意

  • 存储极端颜色的格式是 RGB565,也就是说绿(G)通道的精度比其他两个通道精度高一些

  • 这就是有些人说把信息放绿通道精度更高的原因

  • //补充:多出来的精度给绿通道是因为:人眼对绿色更敏感

  • DXT1 格式适用于不具有透明度信息或者具有一位透明度信息(表示完全透明 or 完全不透明)的贴图

  • 对于没有 Alpha 信息的贴图,压缩遵循上文

  • 对于有 Alpha 信息的贴图

  • 极端颜色插值时,中间颜色只有一个,另一个表示完全透明 or 完全不透明(例如上述例子中,C 为中间颜色,D 表示透明信息)

  • 每个像素索引时,极端颜色+中间颜色表示完全不透明,另外一个表示完全透明

  • DXT1 的压缩率

  • 参照对象:RGB24(DXT1 主要用于没有 Alpha 信息的贴图)

  • DXT1:

  • 总数据块为 64 位,16 个像素共用 =>一个像素 4 位

  • 所以压缩率为:24 / 4 = 6:1

DXT2/3(BC2)

  • 也叫作 BC2

  • 128 位

  • 颜色信息和 DXT1 是一样占用 64 位,多出 64 位用来增加 Alpha 信息

  • Alpha 信息并没有插值,只是单纯的为每一个像素多给 4 位信息,用来记录 Alpha 信息

  • 这样一来,每个像素就占 4+4=8 位,(0~3 表示透明信息,4-7 表示颜色信息)

  • 简单来说就是:

  • 相比 DXT1,多了 64 位用来存 Alpha 信息, 16 个像素,每个 4 位

  • DXT2 和 DXT3 的区别

  • DXT2 是已经完成了颜色与 Alpha 的混合,当透明度发生改变时,直接改变整体颜色值,不再单独进行复合

  • DXT3 的 Alpha 信息相对独立(分开压缩)

  • DXT2、3 的压缩率

  • 参照对象:RGBA(32 位)

  • 总数据块为 128 位,16 个像素共用 =>一个像素 8 位

  • 压缩率为:32 / 8 = 4:1

DXT4/5(BC3)

  • 也被称为 BC3

  • 128 位

  • 和 DXT2/3 的区别:

  • Alpha 信息是通过线性插值得来的:

  • 表示颜色信息的 64 位同上

  • 多出的 64 位:

  • 2 个 8 位的极端值

  • 每个像素 3 位的索引值(16*3)

  • DXT4 和 DXT5 的区别

  • 同 2 和 3

  • DXT4、5 的压缩率

  • 同为4:1

扩展知识

  • Unity 将贴图类型选为法线时,会采用 DXTnm 格式

  • 它基于 DXT5,会把法线贴图的 R 通道存入 A 通道,然后将 RB 通道清除为 1

  • 这样就可以把法线 xy 信息分别存入到 RGB 和 A 中进行压缩,来获得更高的精度

  • 最后再根据 xy 构建出 z 的信息

2、ATI 系列

ATI1/2(BC4、BC5)

  • ATI1,也被称为 BC4

  • 64 位

  • 每一个数据块中存储的是单个颜色通道的数据

  • 主要用于存储:高度图、光滑度贴图等单通道信息

  • ATI1 的压缩方式:

  • 和 DXT5 中,对于 Alpha 数据处理一样

  • ATI2, 也被称为 BC5

  • 128 位

  • 和 ATI1 的区别在于,它存储了两个颜色通道的数据

  • ATI2 的压缩方式:

  • 处理方式也是相同的,相当于存储了两个 ATI1 的数据块

  • 如果想要节省通道只存储法线 xy 通道时,就可以采用 BC5(ATI2)压缩格式

  • 优点:

  • 因为每个通道都会有自己的索引,会单独压缩,所以法线贴图的 xy 信息可以比 DXT1 中有更多保真度

  • 缺点:

  • 需要使用两倍内存,需要更多的带宽才能将纹理传递到着色器中

  • 压缩比:

  • ATI1:

  • 参照对象:单通道 8 位

  • 总数据块为 64 位,16 个像素,所以每个像素 4 位

  • 压缩比为:8 / 4 =2:1

  • ATI2:

  • 参照对象:两个通道 16 位

  • 总数据块为 128 位,16 个像素,所以每个像素 4 位

  • 压缩比为:16 / 8 = 2:1

  • //注:一些资料上显示 BC4 和 5 的压缩比为 4/1,查了很多还是没找到详细资料。这一块待定,日后搞清楚了回来修改

  • 弹幕有同学提出:维基百科中说,4:1的压缩比,是因为单个像素当作 32 位的大小来计算了

BC6/7

3、ETC 系列

  • DirectX 选择 DXTC 作为标准压缩格式,而 OpenGL 选择了爱立信研发的 ETC 格式

  • 几乎所有安卓设备都支持 ETC 格式,所以它在移动端应用广泛

  • 基本思想:

  • ETC 的方案同样将 4×4 的像素单元压缩成 64 位数据块,同时,将像素单元水平或竖直朝向分为两个区块,每个像素颜色等于基础颜色加上索引指向的亮度范围
    Pasted image 20221208234341

  • 总结:每个区块中有 12 位用来存储颜色信息(122),16 位存储其 8 个像素的索引(每个像素 2 位,162),4 位存储亮度索引(4*2)

ETC1

Pasted image 20221208234503

  • 亮度索引值:(上表,水平方向)

  • 每个区块的亮度索引值(3 位,0-7)会从 8 个亮度索引值中获取当前像素单元的亮度表

  • //注:课程里讲的是 4 位,0-15 个亮度索引值,但资料中显示的是 3 位索引值,表中也是,正确性存疑

  • 像素索引值(上表,竖直方向):

  • 每个像素的像素索引值(2 位,0-3)可以从亮度表的四个值中选取对应的亮度补充值

  • 最终的颜色 = 12 位基础颜色信息 + 亮度补偿值

  • 补充:

  • 原理:

  • 将 4×4 的像素块编码为 2×4 或者 4×2 像素的两个块

  • 每个块指定一个基色,每个像素的颜色铜鼓偶一个编码为相对于这个基色偏移的灰度值确定(上面提到的亮度)

  • 位数占比

  • 亮度索引 3 位*2

  • 像素索引 2 位*16

  • 基础颜色 12 位(4442,或者 555+333)2

  • flip1 位(控制水平或者竖直划分)*2

  • 总位数 = 32 + 216 + 122 + 12 = 64 位

  • //注:2 是因为有两个块,16 是因为有 16 个像素

  • 压缩率

  • 参照标准:RBG24

  • 总共有 64 个数据块,针对 16 个像素,也就是每个像素 4 位

  • 压缩比 = 24 / 4 = 6:1

  • 对于 ETC1 不支持 Alpha 通道的解决方案

  • 采用两张纹理混合的方式

  • ETC1 的适用情况

  • 长宽为 2 的幂次的贴图

  • 不适用于带透明通道的贴图

  • 适用于基本所有安卓设备

ETC2

  • TEC2 是 ETC1 的扩展,支持了 Alpha 通道(内存占用大于 ETC1)
  • 硬件要求 OpenGL ES3.0 和 OpenGL4.3 以上

4、ASTC

  • ASTC 是由 ARM 和 AMD 联合开发的纹理压缩格式

  • 优点:

  • 可以根据不同图片选择不同压缩率的算法

  • 图片长宽不需要是 2 的次幂

  • 同时支持 HDR 和 LDR

  • 缺点:

  • 兼容性不够完善

  • 解码时间较长

  • 无法在 iphone6 以下的设备运行

  • 基本思想:

  • 同样是基于块的压缩算法,与 BC7 类似

  • 数据块大小固定为 128 位

  • 块中的像素数量可变,从 4×4 到 12×12 像素都有

  • 每个数据块中存储了两个插值端点

  • 存储的不一定是颜色信息,也可能是 Layer 信息,这样可以用来对 Normal 或 Alpha 进行更好的压缩(根据贴图类型进行针对性压缩)

  • 块中的每个纹素,存储其对应插值点的权重值

  • 权重值数量可以少于纹素数量,可以通过插值得到每个纹素的权重值,再进行颜色计算

  • 128 位数据块中存储的信息:

  • 11 位,权重、高度信息、特殊块标识

  • 2 位,Part 数量

  • 4 位,16 中插值端点模式(LDR/HDR、RGB/RGBA)

  • 111 位,插值端点信息、纹素权重值、配置信息

5、PVRTC

  • PVRTC 是由 Imagination 公司专为 PowerVR 显卡设计的压缩格式(iphone、ipad,部分安卓机)

  • 不是基于块的算法,而是将图像分为了低频和高频信号

  • 低频信号由两张低分辨率图像 AB 组成

  • 高频信号则是一张记录了每个像素混合的权重值的全分辨率低精度的调制图像

  • 解码时,AB 图像经过双线性插值放大,然后根据调制图像权重进行混合

  • 压缩原理

  • 分为 4-bpp 和 2-bpp(bpp = Bit Per Pixel,即每个像素占的位数)

  • 4-bpp 为例:

  • 把 4×4 的像素单元压成一个 64 位数据块

  • 64 位数据块中包含了 A、B 两张图(在原图基础上压缩到 1/4 的低分辨率图像)

  • 不同模式下每个像素调制数据可以得到不同的混合值,根据这个混合值用 A 和 B 混合得出最终颜色值

  • 位数占比:

  • 32 位的调制数据(2*16)

  • 1 位的调制标志(也称为模式)

  • 15 位的颜色 A(554 或 4433),1 位颜色 A 的不透明标志

  • 14 位颜色 B(555 或 4443),1 位颜色 B 的不透明标志

  • 共计:32 +1 + 16 + 15 = 64 位

  • 压缩率

  • 以 RGB 为参照标准

  • 压缩率 = 24 / (64/16) = 6:1

  • 以 RGBA 为参照标准

  • 压缩率 = 32 / (64/16) = 8:1

  • 2-bpp

  • 把一个 8×4 的像素单元压成了 64 位数据块

四、总结

1、画质比较

  • RGBA > ASTC 4×4> ASTC6×6 > TEC2 ≈ ETC1
  • //注:画质较为主观,且不同的贴图针对不同压缩格式也不同,仅供参考

2、压缩比

  • DXT1 6:1
  • DXT2/3 4:1
  • DXT4/5 4:1
  • ATI1 4:1
  • ATI2 4:1
  • BC6 6:1
  • BC7 3:1
  • ETC1 6:1
  • PVRTC 6:1
  • ASTC 4:1~35.95:1

3、实际应用中的选择

PC

  • ① 低质量使用 DXT1 格式不支持 A 通道,使用 DXT5 格式支持 A 通道;
  • ② 高质量使用 BC7 格式,支持 A 通道;

安卓

  • ① 低质量使用 ETC1 格式,但不支持 A 通道;
  • ② 低质量使用 ETC2 格式,支持 A 通道,需要在 OpenGL ES 3.0/OpenGL 4.3 以上版本;
  • ③ 高质量使用 ASTC 格式,需要在 Android 5.0/OpenGL ES 3.1 以上版本;

IOS

  • ① 高质量使用 ASTC 格式,需要 Iphone6 以上版本;
  • ② 低质量使用 PVRTC2 格式,支持 Iphone6 以下版本;

补充

  • 实际手机端项目中,我们比较常用 ASTC(安卓和 IOS 通用)

  • 英伟达和 Unity 官方对于不同类型贴图给出了不同的压缩方案建议,感兴趣的同学可以看下:

  • Using ASTC Texture Compression for Game Assets | NVIDIA Developer

  • Unity - Manual: Recommended, default, and supported texture compression formats, by platform (unity3d. com)

五、其他补充

①常见分辨率及纹理压缩格式下的内存占比分析

7 各种 Map 的的实现

Map 都可翻译成贴图,对应实现的功能称为映射 Maping。
如法线贴图->法线映射

NormalMap

解析

使用法线贴图要先把坐标从切线空间转换到世界空间(TBN 矩阵)Pasted image 20221007190257 Pasted image 20221007190456
法线贴图通常存储为常规 RGB 图像,其中 RGB 分量分别对应于表面法线的 X,Y 和 Z 坐标。
法线的每个分量的值的范围是 [−1,1] ,而 RGB 分量的值的范围是 [0,1] 。所以,在将法线存储为 RGB 图像时,需要对每个分量做一个映射:

1
vec3rgb_normal = normal ∗ 0.5 + 0.5

这里要注意,将法线存储到法线贴图的过程中,需要进行上述操作。当我们从法线贴图中读取到法线数据后,需要进行上述变换的逆变换,即从 [0,1] 映射到 [−1,1] 。

1
normal = 2 * vec3rgb_normal - 1

Unity 内置函数 UnpackNormal,可以实现该功能(并且支持跨平台转换)。记得纹理类型改为 NormalMap。

切线空间保存切线的一个优点:可以压缩。因为切线空间的法线 z 方向总是正方向,因此可以仅存储 xy 方向,从而推到 z 方向(normal 是单位向量,用勾股定理由 xy 得出 z,取 z 为正的一个即可)。而模型空间的法线纹理方向各异,无法压缩。对应法线贴图在 Unity 中的压缩格式:

[!note]

  • 在非移动平台上,会把法线贴图转化为 DXRT5nm 格式。这个格式只有两个有效 GA 通道(就是上边说的只存 xy,推出 z) ,分别对应法线的 y、x 分量可以节省空间。
  • 在移动平台上,使用传统 RGB 通道。

Pasted image 20221023230557
Pasted image 20221023230559

  • 关于 normal. xy *= scale;的解释
  • 是对法线的扰动效果进行缩放

Pasted image 20221007191501
**切线空间中是右手法则!!!计算副切线时需要乘上 tangent w 是因为 unity 引擎历史遗留问题。

[!note]
转置矩阵:
设有矩阵 x,y。x 的转置矩阵为 xT,以下式子相等
mul(xT,y) = mul(y,x)

代码如下,主要区别在于采样后计算漫反射和高光都要使用 NormalMap 转换到世界空间后的坐标替代 world_normal。

Unity 中的法线纹理类型

Pasted image 20221206102034|300
把纹理类型设置为 Normal map 才能使用 UNity 的内置函数 UnpackNormal 来计算法线方向。
Create form Grayscale 适用于另一种凹凸映射的方法——高度图。整个复选框适用于从高度图生成法线纹理的。高度图本身记录的是相对高度,是一张灰度图,白色表示更高,黑色表示更低。当我们把一高度图导入 unity 后除了要设置 normal map 还要勾选这个,这样就可以把高度图和切线空间下的法线纹理同等对待了。Bumpiness 控制凹凸成都,Filtering 决定用哪种方式计算凹凸程度。

代码

Pasted image 20221206101713|300

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
Shader "Unlit/NormalMap"  
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_NormalMap("NormalMap",2D) = "white" {}
_Color("Color",Color) = (1,1,1,0)

[Header(BlinnPhong)]
[Space(15)]
_NormalMapScale("NormalMapScale",Float) = 1
_PowerValue("PowerValue", Float) = 4
_PowerScale("PowerScale",Float) = 1
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" "Queue" = "Geometry"}

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "AutoLight.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 nomral : NORMAL;
float4 tangent : TANGENT;
float4 vertexColor : COLOR;
};

struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normal : TEXCOORD1;
float3 tangent : TEXCOORD2;
float3 bitangent : TEXCOORD3;
float4 worldPostion : TEXCOORD4;
float3 localPosition : TEXCOORD5;
float3 localNormal : TEXCOORD6;
float4 vertexColor : TEXCOORD7;
};

sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _NormalMap;
float4 _NormalMap_ST;
float _NormalMapScale;
float _PowerValue, _PowerScale;

v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.normal = UnityObjectToWorldNormal(v.nomral);
o.tangent = UnityObjectToWorldDir(v.tangent);
o.bitangent = cross(o.normal, o.tangent) * v.tangent.w;
o.worldPostion = mul(unity_ObjectToWorld, v.vertex);
o.localPosition = v.vertex.xyz;
o.localNormal = v.nomral;
o.vertexColor = v.vertexColor;

return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 用 TBN 矩阵处理 NormalMap
float3 T = normalize (i.tangent);
float3 B = normalize(i.bitangent);
float3 N = normalize(i.normal);
float3x3 TBN = float3x3(T,B,N);

float3 NormalMap = UnpackNormal((tex2D(_NormalMap, i.uv)));

//控制凹凸程度
NormalMap.xy *= _NormalMapScale;
NormalMap.z = sqrt(1-saturate(dot(NormalMap.xy,NormalMap.xy)));

// 将NormalMap从切线空间转换到世界空间
float3 world_NormalMap = normalize(mul(NormalMap, TBN));

// 光照计算
float3 world_LightDir = normalize(UnityWorldSpaceLightDir(i.worldPostion));
float3 world_ViewPos = normalize(UnityWorldSpaceViewDir(i.worldPostion));
float3 world_HalfVector = normalize(world_LightDir + world_ViewPos);

float4 albedo = tex2D(_MainTex, i.uv);

// Diffuse
float Lambert = max(0, dot(world_NormalMap, world_LightDir)); //注意这里 法线方向 用 NormalMap 来替代
//Specular
float NH = dot(world_NormalMap, world_HalfVector); //注意这里 法线方向 用 NormalMap 来替代
float BlinnPhong = pow(max(0,NH) ,_PowerValue) * _PowerScale;

float4 Ambient = UNITY_LIGHTMODEL_AMBIENT;
float4 Diffuse = Lambert;
float4 Specular = BlinnPhong;
float4 finalColor = (Ambient + Diffuse) * albedo + Specular;
return finalColor;
}
ENDCG
}
}
}

凹凸控制部分的解释:

1
2
NormalMap.xy *= _NormalMapScale;  
NormalMap.z *= sqrt(1-saturate(dot(NormalMap.xy,NormalMap.xy)));

xy 分量乘上_NormalMapScale。因为 z 轴是原法线方向,所以可以想象一下,将向量的 xy 分量进行缩放后,它就更加朝着原来的偏移方向倾斜了,所以就变的更凹进或者更凸起。
因为 normal 需要是一个单位向量,所以变换了 xy 之后需要重新计算 z, 假设法线向量为 (x, y, z),它可以由 (x, y, 0)和 (0,0, z)两条边组成,既然法线向量是一条单位向量,这两条边构成的三角形又是直角三角形,那么其实只要知道一条边长,就可以用勾股定理计算出另一条边长。实际上法线纹理只存储了 xy 信息. 可以明白这句代码正是勾股定理。

CubeMap

Pasted image 20221007232013
对 NormalMap 章节代码进行修改,NormalMap 结合 CubeMap。Pasted image 20221007235616|300

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
Shader "Unlit/CubeMap"  
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_NormalMap("NormalMap",2D) = "white" {}
_CubeMap("CubeMap",Cube) = "white" {}
_Color("Color",Color) = (1,1,1,0)

[Header(BlinnPhong)]
[Space(15)]
_NormalMapScale("NormalMapScale",Float) = (1,1,1,0)
_PowerValue("PowerValue", Float) = 4
_PowerScale("PowerScale",Float) = 1
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" "Queue" = "Geometry"}

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "AutoLight.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 nomral : NORMAL;
float4 tangent : TANGENT;
float4 vertexColor : COLOR;
};

struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normal : TEXCOORD1;
float3 tangent : TEXCOORD2;
float3 bitangent : TEXCOORD3;
float4 worldPosition : TEXCOORD4;
float3 localPosition : TEXCOORD5;
float3 localNormal : TEXCOORD6;
float4 vertexColor : TEXCOORD7;
};

sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _NormalMap;
float4 _NormalMap_ST;
samplerCUBE _CubeMap;
float4 _NormalMapScale;
float _PowerValue, _PowerScale;

v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.normal = UnityObjectToWorldNormal(v.nomral);
o.tangent = UnityObjectToWorldDir(v.tangent);
o.bitangent = cross(o.normal, o.tangent) * v.tangent.w;
o.worldPosition = mul (unity_ObjectToWorld, v.vertex);
o.localPosition = v.vertex.xyz;
o.localNormal = v.nomral;
o.vertexColor = v.vertexColor;

return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 用 TBN 矩阵处理 NormalMap
float3 T = normalize(i.tangent);
float3 B = normalize(i.bitangent);
float3 N = normalize(i.normal);
float3 NormalMap = UnpackNormal((tex2D(_NormalMap, i.uv)));

//对NormalMap进行插值
//作用:float3(0,0,1)视为初始法线方向,可以修改_NormalMapScale的w分量对其插值,实现NomalMap影响CubeMap。
NormalMap = lerp(float3(0,0,1), NormalMap, _NormalMapScale.w);

float3x3 TBN = float3x3(T,B,N);
NormalMap *= _NormalMapScale;
// 将NormalMap从切线空间转换到世界空间
float3 world_NormalMap = normalize(mul(NormalMap, TBN));

float4 cubemap = texCUBE(_CubeMap, world_NormalMap);
return cubemap;
}
ENDCG
}
}
}

MatCap

https://github.com/ipud2/matcaps Pasted image 20221009103455 Pasted image 20221009104221
优点:省资源,快速模拟出材质
缺点:适合固定视角,360 视角查看时高光方向只能是一个方向。高光方向根据 matcap 图决定
代码在 normalMap 代码基础上进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_MatCap("MatCap",2D) = "white" {}

sampler2D _MatCap;
float4 _MatCap_ST;

//视角空间下的法线当做uv去采样MatCap
float3 N_ViewSpace = mul(unity_MatrixV, N);
//float3 N_ViewSpace = mul(Unity_WorldToCamera, N);

//使用NormalMap效果
//float3 N_ViewSpace = mul(unity_MatrixV, world_NormalMap);

float3 N01_ViewSpace = N_ViewSpace * 0.5 + 0.5;

float3 MatCap = tex2D(_MatCap,N01_ViewSpace.xy);
return float4(MatCap.xyz,1);

Parallax Map

视差贴图

Pasted image 20221009111354 Pasted image 20221009115044

光栅立体画效果:
原理:两层视差贴图 lerp 一下 Pasted image 20221009111726
Pasted image 20221009115007 parel (2)

地形

HeightMap 中的 uv 相当于 xz,存储的值相当于 y(高度)
Pasted image 20221009111512

FlowMap

(1)RG 通道记录的是 uv 流动的方向
(2)FlowMap 就是一张记录了2D 向量信息的纹理,纹理颜色通常用 RG 两个通道来表现。
Pasted image 20221010221753|300
我们定义了中间(128,128)的位置为静止状态,那么(0,0)表示左下方向,(255,255)表示右上方向。同时也有相对应的颜色值。默认 B 通道为 0,黄色(255,255,0)右上方向,(0,0,0)左下方向。我们人眼去理解 FlowMap 贴图,就可以这样理解。
但实际在 Unity shader 的应用里,颜色的范围是[0,1], 那么计算机采样的时候怎么知道(1,1)就可以代表右上呢?
需要再次将[0,1]范围映射到[-1,1], 那么在采样时,偏移的方向就可以通过加法直接跟原 UV 位置相加获得最终效果。即要进行一次重映射(代码第二行):映射到(-1,1)有四个象限,可以表示各个方向。
(3)绘制 FlowMap 可以使用软件 Pasted image 20221010222139
(3)周围黄色:uv 没有流动
尾部红色:R 值比较大,向 u 方向流动
左下角绿色:G 值比较大,向 v 方向流动

Pasted image 20221009205147

注意事项:

  1. FlowMap 是线性的,不需要勾选 sRGB
    Pasted image 20221009210426|300
  2. 把 FlowMap 贴图的压缩修改为无损压缩
    Pasted image 20221010221610|300
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    Shader "Unlit/FlowMap"  
    {
    Properties
    {
    _MainTex ("Texture", 2D) = "white" {}
    _FlowMap ("FlowMap", 2D) = "white" {}
    //_FlowSpeed控制流动速度,_FlowPower控制流动力度
    _FlowSpeed("FlowSpeed",float) = 1
    _FlowPower("FlowStrength",float) = 1
    }
    SubShader
    {
    Tags { "RenderType"="Opaque" }
    LOD 100

    Pass
    {
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #include "UnityCG.cginc"

    struct appdata
    {
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    };

    struct v2f
    {
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    };

    sampler2D _MainTex;
    float4 _MainTex_ST;
    sampler2D _FlowMap;
    float4 _FlowMap_ST;
    float _FlowSpeed;
    float _FlowPower;

    v2f vert (appdata v)
    {
    v2f o;
    o.uv = v.uv;
    o.vertex = UnityObjectToClipPos(v.vertex);
    return o;
    }

    fixed4 frag (v2f i) : SV_Target
    {
    float time01 = frac(_Time.y * _FlowSpeed);
    float2 flowMap = tex2D(_FlowMap, i.uv) * 2.0 - 1.0;
    float2 uv1 = i.uv - flowMap * _FlowPower * time01;
    float2 uv2 = i.uv - flowMap * _FlowPower * frac(time01+0.5);
    float4 color1 = tex2D(_MainTex,uv1);
    float4 color2 = tex2D(_MainTex,uv2);
    float4 finalColor = lerp(color1,color2,abs(time01-0.5)/0.5);

    return finalColor;
    }
    ENDCG
    }
    }
    }

RampMap

渐变纹理)特点:Pasted image 20221005233058
三阶明度,过渡卡硬 (卡硬:有过度,很硬 /卡死:没过度)-》卡硬不卡死
暗部细节通过色相变化,离暗部比较进的偏暖一点,离暗部比较远的偏冷一点。少用明度变化!

使用渐变纹理控制漫反射光照
渐变纹理贴图特点:v 轴纵向颜色相同
Pasted image 20230628163052
渐变纹理的映射非常简单,核心思想是在对贴图进行采样时,不使用的常规的模型的 uv 采样,而是自己构建一个 uv。
ramp 图实际是一维纹理(在纵轴方向不变),u 轴我们可以使用半兰伯特(因为半兰伯特范围为 $[0,1]$)。v 轴则随便指定一个 $[0,1]$ 内的范围的常数即可。

1
2
3
float HL = (0.5 * NdotL + 0.5); //半兰伯特
float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, float2(HL,0.5));
//float4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); //普通采样

需要注意的事,我们需要把渐变纹理的 WrapMode 设为 Clamp,防止对纹理边缘采样时由于浮点数精度而造成的问题。因为半兰伯特范围为 $[0,1]$,但可能会有 1.00001 这样的值出现,如果使用 Repeat 模式,则会舍弃整数部分,只保留小数,得到 0.00001,采样的 uv 位置就会出错。
Pasted image 20230628162914

8 凹凸映射 Bump Map

[[https://zhuanlan.zhihu.com/p/574361162#ref_4]]

[[图形学/4 书籍/《RTR3》/第六章 · 纹理贴图及相关技术/README#8.1 凹凸贴图 Bump Mapping]]
Pasted image 20221029194237
Pasted image 20221029194339

物体细节的尺度

在毛星云的这篇文章中,提到了三个尺度
![[4 法线分布函数#一、基于物理的渲染理念:从宏观表现到微观细节]]

**本科中,老师给出了描述一个物体绘制在屏幕上(表达物体的细节)的三个尺度

  • 宏观尺度

  • 特征可能覆盖很多个像素

  • 由顶点、三角形、其他几何图元表示

  • 例如:角色的四肢、头部

  • 中观尺度

  • 特征可能覆盖几个像素

  • 描述了宏观和微观尺度之间的特征

  • 包含的细节比较复杂,无法用单个三角形进行渲染,

  • 细节相对较大,可以被观察者看到几个像素以上的变化

  • 例如:人脸上的皱纹、肌肉的褶皱、砖头的缝隙

  • 微观尺度

  • 特征可能是一个像素

  • 通常在着色模型,写在像素着色器中,并且使用纹理贴图作为参数

  • 模拟了物体表面微观几何的相互作用

  • 例如:

  • 有光泽的物体表面是光滑的、漫反射的物体,在微观下表面是粗糙的

  • 角色的皮肤和衣服看起来也是不同的,因为使用了不同的着色模型/不同的参数

Bump Mapping

模拟中观尺度的常用方法之一,可以让观察者感受到比模型尺度更小的细节
法线贴图所带来的凹凸感是怎么来的?是改变了【光照信息】(NdotL)而来。我们视觉上认为,较亮的向光面与较暗的背光面会组成一个“立体”的事物。

基本思想

  • 在纹理中将尺度相关的信息编码进去
  • 着色过程中,用受到干扰的表面去代替真实的表面
  • 这样一来,表面就会有小尺度的细节

原理

  • 对物体表面贴图进行变化然后再进行光照计算的一种技术

  • 主要的原理是通过改变表面光照方程的法线,而不是表面的几何法线,或对每个待渲染的像素在计算照明之前都要加上一个从高度图中找到的扰动,来模拟凹凸不平的视觉特征

  • 例如:

  • 给法线分量添加噪音(法线映射贴图)

  • 在一个保存扰动值的纹理图中进行查找(视差映射、浮雕映射贴图)

  • 是一种提升物体真实感的有效方法,且不用提升额外的几何复杂度(不用改模型)

  • 列举一个使用法线贴图的效果
    Pasted image 20221029193834

  • 可以看到,使用了法线贴图的有了明显的立体感和细节

  • 对于中间的高亮部分,左边的都是均匀的,而右边即使是很高亮度的部分也能看到阴影

Normal Mapping

Pasted image 20221029194529

![[TA101#NormalMap]]

视差映射 Parallax Mapping

参考:[[zhihu.com)](https://zhuanlan.zhihu.com/p/574361162|【UnityShader】ParallaxMapping 视差映射(7) - 知乎 (zhihu.com)]]

原理

  • 视差贴图 Parallax Mapping,又称为 Offset Mapping
  • 主要为了赋予模型表面遮挡关系的细节。引入了一张高度图
  • 可以和法线贴图一起使用,来产生一些真实的效果
  • 高度图一般视为顶点位移来使用,此时需要三角形足够多,模型足够精细,否则看起来会有块状
  • 如果在有限的三角形面的情况下,怎么办?这就用到了视差映射技术
  • 视差映射技术
    Pasted image 20221030144850

    左:无视差右:基础视差

  • 核心:改变纹理坐标
  • 需要一张存储模型信息的高度图,利用模型表面高度信息来对纹理进行偏移(例如:低位置的信息被高位置的信息遮挡掉了,所以会采样更高的信息)

视差映射的实现

  • 和法线贴图一样,是欺骗眼睛的做法(只改变纹路,不增加三角形)

  • 我们的模型在切线空间下,所有的点都位于切线和副切线组成的平面内(图中 0.0 点),但实际上物体要有更丰富的细节。

  • 例如图中的情况

  • 如果不使用视差贴图,要计算当前视角下,片元 A 点(黄色)的信息,就是图中的 Ha

  • 实际使用视差贴图时,真实的情况应该是视线和 A 点延长线和物体的交点,也就是 B 点,相应的就是Hb
    Pasted image 20221029211242

  • 视差映射的具体算法:如何在知道 A 的 uv 值的情况下,算出 B 的 uv 值

  • 知道 AB 两者的偏移量即可

  • 偏移量的获得:用近似的方法去求解

  • 首先拿 A 的高度信息进行采样,得到物体表面距离水平面(0.0)的深度值 Ha。

  • 用深度值 Ha 和视线的三角关系算出物体上等比的偏移方向,算出近似的 B 点(可以看到图中近似点 B 和实际点 B 还是有挺大差距的,所以模拟度比较低)
    Pasted image 20221029211255
    Pasted image 20221029211302

    1
    2
    3
    4
    5
    6
    // uv高度图采样
    float height = tex2D(_HeightMap,i.uv).r;
    //根据高度图信息计算出uv偏移量
    float3 tangent_ViewDir = normalize(mul(TBN, world_ViewDir));
    float2 offsetUV = tangent_ViewDir.xy / tangent_ViewDir.z * height * _HeightScale;
    i.uv += offsetUV;

    得到偏移之后 B 点的 uv,再去对法线贴图进行采样、计算时,就不会采样 A 点了,而是 B 点

其中 viewDir.xy / viewDir.z 为什么能代表 uvOffset?见下图:
Pasted image 20221029233656

不同视线的 uv offset 应该不一样
Pasted image 20221029233719

  1. 以 90°角直视表面时,切线空间中的视图方向等于表面法线(0、0、1),因此不会发生位移。视角越浅,投影越大,位移效果越大。
  2. xy/z 之所以能得到正确的 offset,是由于定义高度差为 1,使用“相似三角形”原理而来。
    1 / offset = z / (x, y) => offset = xy/z
  • 理解:视差贴图是如何产生遮挡效果的

  • 当视线看到的是 A 点这样深度吗比较大的,那么视差贴图计算出的偏移值也是非常大的,这样 A 点最终被渲染出来的机会就比较小(偏移后就被采样到其他点上了)

  • 当视线看到 B 点这样深度比较小的点,计算出来的偏移就比较下,甚至原来点的附近,所以被采样的机会就比较大

  • 深度大的点很容易被深度小的点覆盖掉,这样就会表现出遮挡的效果

陡峭视差映射 Steep Parallax Mapping

Pasted image 20221029212354

  • 也是近似解,但比视差映射精确

基本思想
采用了光线步进(RayMarching)的方法。

  • 将物体表面分为若干层,从最顶端开始采样,每次沿着视角方向偏移一定的值
  • 如果当前采样的层数,大于实际采样的层数,就停止采样。
  • 例如图中 D 点,采样到 0.75 层,实际是 0.5 层,就停止采样,返回偏移坐标

陡视差映射的算法:(计算偏移点的过程)
定义步长、循环采样、判断高度信息、得到偏移量。

  • 首先对 A 点采样,得到深度大约为 0.8 的位置,而其对应视线深度为 0.0,不符合我们的基本思想,继续采样
  • 采样 B 点,深度为 1,视线深度为 0.25,不符合,继续采样
  • 采样 C 点,深度大约为 0.8,视线深度为 0.5,不符合,继续采样
  • 采样 D 点,采样深度为 0.5,视线深度约为 0.75,符合上述的条件,认为是比较合理的一个偏移点,就返回结果(return)。

陡视差的问题

  • 在于分层机制,如果
  • 分层多,性能开销就会大;
  • 分层小,渲染锯齿就比较明显。
  • 一种做法:可以根据视角 v 和法线 n 的角度限定采样层数
  • 锯齿问题会在浮雕贴图上做改善
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 陡峭视察映射 光线步进算法  
float2 SteepParallaxMapping(float2 uv, float3 tangent_viewDir)
{
float numLayers = 20; //最大步进次数, 步进次数越多,效果越好,开销也越大。
float layerHeight = 1 / numLayers; //单层步进高度
float2 deltaUV = tangent_viewDir.xy / tangent_viewDir.z * _HeightScale / numLayers; //单次uv偏移量

float currentLayerHeight = 0; //步进初始高度(视线深度)
float2 currentUV = uv; //当前UV
float2 offsetUV = float2(0, 0); //初始化偏移量
float currentHeightMapValue = tex2D(_HeightMap, currentUV).r; //采样初始高度信息(采样深度)

for(int i = 0;i<numLayers;i++)
{
if(currentLayerHeight > currentHeightMapValue)
{
return offsetUV;
}
offsetUV += deltaUV;
currentHeightMapValue = tex2D(_HeightMap, currentUV + offsetUV).r;
currentLayerHeight += layerHeight;
}
return offsetUV;
}

Pasted image 20221030144926

numLayers 不同时的采样结果(数值递增)

从上述图我们也能看出,采样次数较少的时候,结果有很明显的分层感。这也是 RayMarching 系特效的通病。

那么其他的 RayMarching 是怎么解决采样次数过少所导致的问题呢?一个屡试不爽的万精油解法就是——Jitter(抖动)

当我们想使用抖动时,需要确定 2 个东西:抖动的噪声,抖动噪声确定,抖动最大 step 次数。

浮雕映射 Relief Mapping

Pasted image 20221029212425
Pasted image 20221029212436

  • 浮雕映射一般用光线步进二分查找决定 uv 偏移量

  • 第一步:光线步进部分,和视差贴图一样

  • 后边:二分查找部分:通过光线步进找到合适的步进后,在此步进内使用二分查找来找到精确的偏移值(可能要查找多次,性能可以继续优化,由此提出改进方法 POM,见下一节)

  • 为什么不直接使用二分查找?

  • 会产生比较大的误差

  • 如果直接使用二分查找,在深度 0 和 1 的中间的 1 点,进一步为 2 点 -> 3 点 ->Q 点。但我们要的结果是 P 点,可以看到结果很明显是错误的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    // 浮雕贴图  
    float2 ReliefMapping(float2 uv, float3 tangent_viewDir)
    {
    float numLayers = 30; //最大步进次数
    float layerHeight = 1 / numLayers; //单层步进高度
    float2 deltaUV = tangent_viewDir.xy / tangent_viewDir.z * _HeightScale / numLayers; //单次uv偏移量

    float currentLayerHeight = 0; //步进初始高度(视线深度)
    float2 currentUV = uv; //当前UV
    float2 offsetUV = float2(0, 0); //初始化偏移量
    float currentHeightMapValue = tex2D(_HeightMap, currentUV).r; //采样初始高度信息(采样深度)

    // 光线步进
    for(int i = 0;i<numLayers;i++)
    {
    if(currentLayerHeight > currentHeightMapValue)
    {
    break;
    }
    offsetUV += deltaUV;
    currentHeightMapValue = tex2D(_HeightMap, currentUV + offsetUV).r;
    currentLayerHeight += layerHeight;
    }

    //二分查找(是否可以继续优化?)
    float2 P2 = uv + offsetUV; //步进停止点
    float2 P1 = P2 - deltaUV; //停止点的上一个点

    for(int j = 0; j < 20; j++)
    {
    float2 P = (P1 + P2) / 2; //中间点
    float P_LayerHeight = currentLayerHeight / 2; //该点视线深度为P2点的二分之一
    float P_HeightMapValue = tex2D(_HeightMap, P).r; //采样深度
    if(P_LayerHeight > P_HeightMapValue)
    {
    P2 = P;
    }
    else
    {
    P1 = P;
    }
    }
    return (P1 + P2) / 2 - uv;;
    }

视差闭塞映射(POM = Parallax Occlusion Mapping)

  • 相对于浮雕贴图,不同之处在于最后一步

  • 浮雕贴图是在确认最后步进之后进行二分查找(在迭代次数比较多的情况下,还是挺耗的)

  • 视差闭塞贴图是在最后步进的两端 uv 值进行采样(下图红色箭头),采样之后再对这两个结果进行插值,插值的结果作为 P 点最终的偏移值

  • Pasted image 20221029213350

  • 优点:

  • 相对于浮雕映射,性能更好(最后只做插值,而浮雕要做二分查找)

  • 相对于陡视差贴图,精确性更好

  • 要求:

  • 因为最后要做插值,所以要求表面是相对比较平滑/连续的,如果有莫名的凸起结果可能会出错

代码集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
Shader "Unlit/normalmap"  
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_NormalMap("NormalMap", 2D)= "gray" {}
_NormalScale("NormalScale",float) = 1
_HeightMap("_HeightMap", 2D) = "blue" {}
_HeightScale("HeightScale",float) = 1
_SpecularExp("SpecularExp",float) = 1
_SpecularScale("SpecularScale",float) = 1
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float4 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};

struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normal : TEXCOORD1;
float3 tangent : TEXCOORD2;
float3 bitangent : TEXCOORD3;
float4 worldPosion : TEXCOORD4;
};

sampler2D _MainTex;
sampler2D _NormalMap;
sampler2D _HeightMap;

float _NormalScale;
float _HeightScale;
float _SpecularExp;
float _SpecularScale;


// 陡峭视察映射光线步进函数
float2 SteepParallaxMapping(float2 uv, float3 tangent_viewDir)
{
float numLayers = 30; //最大步进次数
float layerHeight = 1 / numLayers; //单层步进高度
float2 deltaUV = tangent_viewDir.xy / tangent_viewDir.z * _HeightScale / numLayers; //单次uv偏移量

float currentLayerHeight = 0; //步进初始高度(视线深度)
float2 currentUV = uv; //当前UV
float2 offsetUV = float2(0, 0); //初始化偏移量
float currentHeightMapValue = tex2D(_HeightMap, currentUV).r; //采样初始高度信息(采样深度)

for (int i = 0; i<numLayers; i++)
{
if(currentLayerHeight > currentHeightMapValue)
{
return offsetUV;
}
offsetUV += deltaUV;
currentHeightMapValue = tex2D(_HeightMap, currentUV + offsetUV).r;
currentLayerHeight += layerHeight;
}
return offsetUV;
}

// 浮雕贴图
float2 ReliefMapping(float2 uv, float3 tangent_viewDir)
{
float numLayers = 30; //最大步进次数
float layerHeight = 1 / numLayers; //单层步进高度
float2 deltaUV = tangent_viewDir.xy / tangent_viewDir.z * _HeightScale / numLayers; //单次uv偏移量

float currentLayerHeight = 0; //步进初始高度(视线深度)
float2 currentUV = uv; //当前UV
float2 offsetUV = float2(0, 0); //初始化偏移量
float currentHeightMapValue = tex2D(_HeightMap, currentUV).r; //采样初始高度信息(采样深度)

// 光线步进
for(int i = 0;i<numLayers;i++)
{
if(currentLayerHeight > currentHeightMapValue)
{
break;
}
offsetUV += deltaUV;
currentHeightMapValue = tex2D(_HeightMap, currentUV + offsetUV).r;
currentLayerHeight += layerHeight;
}

//二分查找
float2 P2 = uv + offsetUV; //步进停止点
float2 P1 = P2 - deltaUV; //停止点的上一个点

for(int j = 0; j < 20; j++)
{
float2 P = (P1 + P2) / 2; //中间点
float P_LayerHeight = currentLayerHeight / 2; //该点视线深度为P2点的二分之一
float P_HeightMapValue = tex2D(_HeightMap, P).r; //采样深度
if(P_LayerHeight > P_HeightMapValue)
{
P2 = P;
}
else
{
P1 = P;
}
}
return (P1 + P2) / 2 - uv;;
}

v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.normal = UnityObjectToWorldNormal(v.normal);
o.tangent = UnityObjectToWorldDir(v.tangent);
o.bitangent = cross(o.normal, o.tangent) * v.tangent.w;
o.worldPosion = mul (unity_ObjectToWorld,v.vertex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float3 world_LightDir = normalize(UnityWorldSpaceLightDir(i.worldPosion));
float3 world_ViewDir = normalize(UnityWorldSpaceViewDir(i.worldPosion));
float3 world_HalfVector = normalize(world_LightDir + world_ViewDir);

//TBN
float3x3 TBN = float3x3 (i.tangent,normalize (i.bitangent),i.normal);

// NormalMap
float3 NormalMap = UnpackNormal((tex2D(_NormalMap, i.uv)));
fixed4 col = tex2D(_MainTex, i.uv);
NormalMap *= _NormalScale;
float3 world_NormalMap = normalize(mul(NormalMap,TBN));

// Parallax Mapping
/*
float height = tex2D(_HeightMap,i.uv).r;
float3 tangent_ViewDir = normalize(mul(TBN, world_ViewDir));
float2 offsetUV = tangent_ViewDir.xy / tangent_ViewDir.z * height * _HeightScale;
i.uv += offsetUV;
*/

// Steep Parallax Mapping
/*
float3 tangent_ViewDir = normalize(mul(TBN, world_ViewDir));
float2 offsetUV = SteepParallaxMapping(i.uv, tangent_ViewDir);
i.uv += offsetUV;
*/

//Relief Mapping
float3 tangent_ViewDir = normalize(mul(TBN, world_ViewDir));
float2 offsetUV = ReliefMapping(i.uv, tangent_ViewDir);
i.uv += offsetUV;


float3 Basecolor = tex2D(_MainTex, i.uv);
float3 Diffuse = dot(world_NormalMap,world_LightDir) * 0.5 + 0.5;
float3 Specular = pow(max(0, dot(world_NormalMap,world_HalfVector)), _SpecularExp) * _SpecularScale;
float3 FinalColor = (Diffuse + Specular) * Basecolor;
return float4(FinalColor,1);
}
ENDCG
}
}
}

9 FlowMap

什么是 FlowMap

1. FlowMap

Pasted image 20221030195248

  • 是 Valve 在 2010 年 GDC 中介绍的一种在求生之路 2 中用来实现水面流动效果的技术
  • 容易实现,且运算量较小(所以到现在还在被使用)
  • 使用了一张被称为 flowmap 的贴图(右图),来达到控制场景中水面流向的效果

2. FlowMap 的实质

Pasted image 20221030195419

发现左图和中图颜色不对应?下一小节会讲原因

  • 是一张记录了 2D 向量信息的纹理
  • 理解
  • 假设有一个 2D 平面,平面上每个点都对应一个向量,这个向量指向这个点接下来要运动的方向。
  • 我们通过颜色的色值(RG 两个通道)来记录这些向量的信息,就得到了一张 flowmap
  • 在 shader 中干扰 uv(偏移 uv),对纹理进行采样,这样就得到了一个模拟流动的效果

3. 回顾:UV 映射(纹理映射)

  • 对一个贴图进行纹理查找,就要用到 uv 坐标
  • 理解
  • 如图为 Unity 中的 uv 坐标,类似于 xy 轴,用此 uv 坐标查找右边贴图的颜色值,采样会得到和贴图一模一样的结果
    Pasted image 20221030195659
  • 如图,如果改变查找时的 uv 坐标,让每一列都有相同的 v 值,那么采样结果就是右图的条纹状的结果
    Pasted image 20221030195810
    如图,如果用同一个 uv 值采样的话,结果就会是同一的颜色, 下图采样(0,0)即左下角颜色
    Pasted image 20221030195841
  • 也就是说,uv 贴图上颜色相同的地方:意味着采样纹理时使用了同一位置

4. Flowmap 的原理

  • 通过所带有的向量场信息对 uv 进行了一个偏移,来干扰我们采样时候的这个过程。

  • 如图可以看到,经过 flowmap 发生偏移后,让原本正常的采样变成了一个扭曲的效果

  • 注意

  • 不同软件的不同 uv 坐标:UE4 与 Unity 相比,反转了绿通道,所以使用的 flowmap 也会发生变化。(根据不同的引擎需求进行调整)
    Pasted image 20221030195932

5. 为什么要使用 flowmap

Pasted image 20221030200104

FlowMap shader

1. 基本流程

    1. 采样 flowmap 获取向量场信息
    1. 用向量场信息,使采样贴图时的 uv 随时间变化
    1. 对同一贴图以半个周期的相位差采集两次,并线性插值,使贴图流动连续

2. 实现思路

目标:根据 flowmap 上的值,使纹理随时间偏移

  • 操作 1:最简单的 uv 随时间偏移的方法: uv - time

  • 关于为什么是相减

  • 先理解一下相加的情况:模型上的某个点(u,v)+(time,0)

  • 可以理解为随着 time 增加,采样到的像素越远

  • 这个效果在视觉上可以形容为:更远距离的像素偏移向这个点,也就是说和我们直观认识到的运算法则是相反的。

  • 用 uv 值作为向量时,是遵守运算法则的

  • uv 偏移并没有改变顶点位置,只是采样到了更远的像素

  • 操作 2:从 flowmap 获取流动方向

  • 从 flowmap 获取流动的方向,再乘 time,就可以达到让某个点根据 flowmap 流动的目的

  • flowmap 不能直接使用,先将 flowmap 上的色值从 $[0,1]$ 映射到方向向量的 $[-1,1]$

  • 操作 3:流动无缝循环,把偏移控制在一定的范围内(随着时间进行,变形太过夸张)
    Pasted image 20221030201326

  • 构造两个相位相差半个周期的波形函数
    Pasted image 20221030201416

  • 用相位差半个周期的两层采样进行加权混合,用纹理流动另一层采样来覆盖一个周期重新开始时的不自然情况

代码
Pasted image 20221030202010
Pasted image 20221030202017
Pasted image 20221030202025

3. 用 flowmap 修改法线贴图

Pasted image 20221030201822

Flowmap 的制作

1. Flowmap Painter

  • Flowmap Painter

  • 下载地址:http://teckartist.com/?page_id=107

  • 直接上手绘制即可,很简单

  • 可以使用反转 UV 通道选项,以便不同符合不同的引擎需求

  • 注意:使用 Flowmap Painter 绘制得到的贴图为线性空间下的颜色,不需要伽马校正。(Unity 里不用勾选 sRGB)
    UE 引擎烘焙时要勾选 File Red 选项。

3. 注意事项

  • flowmap 贴图的设置:
  • 要使用无压缩或高质量
  • 确认色彩空间