贴图
贴图
我们已经可以把模型使用光照模型着色了,展示成3d的模样,如下图:

但是我们平时看到一个牛的模型,我们会根据模型表面的颜色、质感会知道这个牛属于什么牛,如下图:

我们知道这个是一个奶牛。我们想要对一个模型做更多颜色表现,我们就需要对每个顶点做颜色的配置,我们最开始的图片设置的颜色就是所有顶点的颜色就是棕色。
我们就可以使用一个图片来存储每个顶点的颜色,然后我们在做着色时从那张图片中读取当前着色点的配置颜色。那张图片也就是我们常说的贴图,也就是模型颜色的配置表。当然现在的贴图也不一定是存储颜色信息,也可以存储如法线、反射、亮度等信息。
我们是一般是在做漫反射时,
贴图映射
那么我们是怎么把着色点和贴图联系起来的呢?这里就需要使用到uv坐标,然后映射关联起来。
那uv坐标是怎么得到的呢?这个是在美术人员建模的时候,在建模软件中利用纹理展开技术把纹理映射坐标(uv坐标)存储在每个顶点上。
通常,uv坐标使用一个二维变量(u,v)来表示,其中u是横坐标,v是纵坐标。u与v的值范围都是[0,1]。下图为uv坐标到图片的转化:

映射uv坐标都是存放在我们的fbx、obj文件中,这个是obj文件中的信息:
其中:
- v :顶点坐标
- vn:法线坐标
- vt:贴图坐标(uv坐标)
简单的贴图映射伪代码如下:
Color texture_lookup(Texture t, float u, float v) {
int i = round(u * t.width() - 0.5)
int j = round(v * t.height() - 0.5)
return t.get_pixel(i,j)
}
Color shade_surface_point(Surface s, Point p, Texture t) {
Vector normal = s.get_normal(p)
(u,v) = s.get_texcoord(p)
Color diffuse_color = texture_lookup(u,v)
// compute shading using diffuse_color and normal
// return shading result
}
那我们怎么是确定一个三角形的颜色呢?上面说到我们对每个顶点都做了颜色的配置,但是我们是把模型切分成了很多个三角形,我们在填颜色时是对三角形填颜色。
这里我们使用了三角形的重心坐标来对三个顶点的颜色做插值,得到这个三角形的颜色。关于重心坐标的信息在之前三角形模块中描述了三角形的重心计算方式
我们得到重心坐标 (alpha,beta,gamma) 后,就可以计算出重心的uv值,然后再做漫反射即可。简单的代码如下:
Vector2f interpolate(float alpha, float beta, float gamma, const Vector2f &vert1, const Vector2f &vert2, const Vector2f &vert3, float weight)
{
auto u = (alpha * vert1[0] + beta * vert2[0] + gamma * vert3[0]);
auto v = (alpha * vert1[1] + beta * vert2[1] + gamma * vert3[1]);
u /= weight;
v /= weight;
return Vector2f(u, v);
}
当然我们的贴图也可以重复利用,也就是说模型中的多个顶点可以对应贴图中一个顶点。
贴图采样偏移
当我们知道需要采样像素的整数坐标时,如何将整数坐标转化为UV坐标呢?什么是整数坐标呢?简单说就是几排几列。
纹理坐标(UV)通常被归一化为[0, 1]
范围。
纹理的每个像素在 UV 空间中占据一个区域。
例如,一个 4x4
的纹理,每个像素的宽度为 1/4 = 0.25
,高度为 1/4 = 0.25
。
第一个像素的中心位于 (0.125, 0.125)
,第二个像素的中心位于 (0.375, 0.125)
,依此类推。
为了确保采样点精确位于一个像素的中心,需要将UV值偏移半个像素的距离。所以计算UV时需要加上偏移值,偏移值的计算方式:
纹理中每个像素的宽度为 1 / TextureSize.x,高度为 1 / TextureSize.y。
半个像素的偏移量为:
水平方向:0.5 / TextureSize.x
垂直方向:0.5 / TextureSize.y
举例说明,假设:
纹理大小为
4x4
像素(TextureSize = (4, 4))
。每个像素的宽度和高度为
1/4 = 0.25
。半个像素的偏移量为
0.5 / 4 = 0.125
。
采样点计算:
如果不加偏移量,采样点
(0, 0)
会位于第一个像素的左上角,可能导致插值问题。加上偏移量后,采样点变为
(0 + 0.125, 0 + 0.125) = (0.125, 0.125)
,正好位于第一个像素的中心。
走样
现在我们可以把贴图和模型接合起来,并且使用漫反射渲染出来了。
这里有两个基本的概念:
- 像素:屏幕组成的基本单位
- 纹素:纹理(贴图)组成的基本单位
我们在这里把这两个简单的认为都是一小方块,我们对图片采样时,完美的情况下,就是一个像素就对应着一个纹素,像是铺地砖一样一个一个排出来。
但是在实际使用会出现一些问题:
- 高分辨率屏幕,展示小尺寸贴图:贴图放大
- 低分辨率屏幕,展示大尺寸贴图:贴图缩小
贴图尺寸小

在这个时候贴图就需要放大,意味着一个纹素会映射到多个像素,那么就会出现像素图片,出现一块一块的那种情况,如下图左边:

我们想要效果是中间的图片的效果,我们可以使用一个叫做双线性插值的方式来获得。右边使用的是双立方插值获得。
我们使用第一种采样(Nearest/Point)的示意图:

其中:
- 红色为屏幕像素点
- 黑色为纹素点
我们四个像素点进行采样时,会得到小数的纹素坐标,然后四舍五入都会得到中间纹素的颜色。所以会出现方块状。
我们使用双线性插值采样(Bilinear)的示意图:

我们以一个像素点(红色点)来说明,我们找到离这个像素点最近的4个纹素点
我们分别对这4个纹素点做横向和纵向的插值,过程如下:

这其中:
- 我们假定
到 占比 ,也就是横向的插值为 - 我们假定
到 占比 ,也就是纵向的插值为 - 我们先使用横向插值求到:
、 - 最后插值
、 就可以得到结果
双线性插值通常在合理的消耗下可以得到不错的效果。当然也有计算更多次的双立方插值等,我们可以看出效果其实是差不多。
简单的双线性插值实现:
Color tex_sample_bilinear(Texture t, float u, float v)
{
u_p = u * t.width - 0.5
v_p = v * t.height - 0.5
iu0 = floor(u_p); iu1 = iu0 + 1
iv0 = floor(v_p); iv1 = iv0 + 1
a_u = (iu1 - u_p); b_u = 1 - a_u
a_v = (iv1 - v_p); b_v = 1 - a_v
return a_u * a_v * t[iu0][iv0] + a_u * b_v * t[iu0][iv1] +
b_u * a_v * t[iu1][iv0] + b_u * b_v * t[iu1][iv1]
}
贴图尺寸大

在这个时候贴图就需要缩小,意味着多个纹素会映射一个像素。那就意味着一个像素中会有多个颜色杂糅在一起,如下图:

我们可以看到右边图的后面就是出现了这样的效果,也就是摩尔纹,同时可以发现前面部分发生了走样。为什么会出现这样的情况呢?
在上图左边图片,我们可以发现在越后面颜色会越密集,越前面颜色的变化要平稳一些。

那么我们就可以认为像素与纹素的关系是:
- 越前面,像素对应的纹素就越少,会出现一纹素对应多像素,如上图左边
- 越后面,像素对应的纹素就越多,会出现多纹素对应一像素,如上图右边
我们之前讲过解决走样,可以使用超采样。也就是增加多个采样点来感知变化。效果如下:

上图是把每个像素点增加512采样点做出的效果。但是这样做是非常消耗性能的。之前说过出现采样的原因是,图片的频率过快而采样的速度更不上。
Mipmap
我们可以换一个思维来解决这个问题,我们不进行对图片的采样,只是获得一个平均的范围就可以了。因为当一个像素覆盖了多个纹素时,为了反走样我们平均多个纹素到平滑。
但是当我们需要计算平均个个数非常大的时候,这样会非常的消耗,所以预先计算出平均值。预先计算叫做Mipmap技术,Mipmap是指根据一张纹理去生成一系列更小的纹理技术。如下图:

这其中:
原始图片叫做base level或者level 0,
level 1 :降低采样2倍,
level 2 :降低采样4倍,
·····
最后级得到 :
最终分层出来的效果,是一个三角锥的样子:

上面生成了那么多图,但是存储仅仅是多了
我们现在知道使用mipmap可以去查询一个纹素的平均,但是我们也不知道去哪一张mipmap中去查询。这里可以使用一个简单的方式:当前一个像素,映射到纹素集合的边长
其中:
表示纹素的宽度 表示层数
我们可以简单看看怎么计算

- 首先选择四个像素点(左图红点)
- 映射到纹理坐标上(右图红点)
- 求出右图红点边长
- 求出4个
,取最长的即可
这样我们在着色时,查询颜色时,就可以使用mipmap查询到颜色了。如下图:

这里颜色的不同就是mipmap的层级的不同,可以看到每个颜色值切换的非常深硬。这是因为我们mipmap的层数只有整数层。我们知道比如说2.8层的颜色,我们可以使用插值来计算,插值第2层到第3层即可。得到的结果:

综上所述,我们使用mipmap的过程:
- 求到当前像素对应总纹素边长
- 获取到对应层级
的mipmap - 这里对
求分值, 、 是 的上层与下层 - 然后分别对
、 求双线性插值得到 、 - 对
、 做最后的插值即可
简单的实现三线性插值如下:
Color mipmap_sample_trilinear(Texture mip[], float u, float v,matrix J)
{
L = max_column_norm(J)
D = log2(L)
d0 = floor(D); d1 = d0 + 1
a = d1 - d; b = 1 - a
c0 = tex_sample_bilinear(mip[d0], u, v)
c1 = tex_sample_bilinear(mip[d1], u, v)
return a * c0 + b * c1
}
这样的做法叫做三线性插值(Trilinear)。
但是使用mipmap也是会有一定的问题,如下图:

可以看到远处会很糊的颜色,这里我们就会看到最后一个策略(各项异性过滤)的效果会好于前两种。
因为mipmap只是预计算了正方形的颜色平均,但是我们在蒙皮时,可能是一个像素块映射到纹理上去,会是长条形的,如图:

可以看到后面的像素点映射到纹理上会是长条形的。所以说如果我们的mipmap也有长条形的样板的话,就会好一些,所以我们的各项异性过滤生成的贴图如下:

这其中:
- 斜对角线的缩小图是正方形mipmap
- 下边和右边的就是生成的长条形mipmap
常用贴图
我们已经知道在计算漫反射时我们可以把贴图颜色加入进去,这样就可以让我们的模型更加的直观。前面也说了贴图也就是一个配置表,那么我们就可以在这个配置表中配置我们需要的数据。我们把贴图分为很多类型,贴图中存放的数据代表的意义,这些都是我们会经常用的到贴图类型。
法线贴图和深度贴图 Normal Maps and Bump Maps
我们在计算漫反射、高光反射时,都会用到法线,我们每次去求每个着色点的法线是不是会很消耗。因为我们想可以把法线存放在贴图里。
我们最直接的想法就是就是直接使用模型空间的法线贴图,然后我们在平时修改了模型的表面过后,需要去重新生成法线贴图。这里我们使用了切线空间的法线贴图。
为什么使用切线空间:
- 自由度高。模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建它时的那个模型,而应用到其他模型上效果就完全错误。切线空间下的法线纹理记录的是相对法线信息。
- 可进行UV动画
- 可以重用法线纹理
- 可压缩。由于切线空间下的法线问题中法线的Z方向总是正方向,因此我们可以仅储存XY方向,而推导得到Z方向。
加上法线贴图和深度贴图的对比:

- a漫反射
- b高光计算使用了粗糙贴图
- c表面的法线使用了深度贴图图
凹凸贴图是一种为模型添加细节的贴图。凹凸贴图是一张灰度图,它保存了着色点与原始表面的高度差,以此来影响光照信息。

位移贴图 Displacement Maps
与深度图一样存储的高度信息,但是位移贴图会改变表面。最常见的用法:作为地形深度图。

阴影贴图 Shadow Maps
阴影贴图是一种使用阴影机制的技术纹理贴图从点光源获取阴影。
在确定遮挡关系的时候,z-buffer的深度值是相当于摄像头,也就是眼睛所见来确定物体是否被遮挡,这个很容易理解。眼睛看不见的地方,当然被挡住了。那么我们换个角度想想,阴影是如何产生的?
阴影其实就是光线看不见的地方。因此,如果我们把摄像机放到光源,那么光能看见的地方,亮度就会高;而光看不见的地方,就是阴影。所以,通过把摄像机放到光源处获得的每个像素点的深度值z-buffer保存起来得到一张类似于map的缓冲,就是Shadow Map。
当我们有了这一张Shadow Map以后,我们回到摄像机空间中,把每个点在光照空间的坐标求出来,获得每个点的深度值z,在fragment shader中通过光照模型渲染出来。

环境贴图 Environment Maps
用来表示环境光,不需要使用光源来自计算。

固体噪声 Solid Noise
获得噪声贴图通过随机每个点,这个就像电视机上的静态雪花。

流动贴图 Turbulence
许多自然纹理在同一纹理中包含多种特征尺寸。重复添加噪声方法在顶部。
