材质贴图
纹理(texture)是在3D渲染中常用的一种技术,在很多场景中都有它的身影。比如下面列出的这些场景:
-
表面材质模拟:纹理可以模拟各种真实材质的表面特征,比如漫反射、高光、粗糙度、透明度
-
环境光遮蔽:模拟物体表面因其他物体遮挡而产生的阴影和光照变化
-
细节增强:利用纹理添加额外的细节,使3D模型更加逼真,比如皮肤纹理上的毛孔、皱纹等
-
环境反射贴图:模拟生活当中带镜面的物体,它们会反射周围的环境。
为了便于理解,我们可以把纹理看作是一个2D图片(在某些特殊场景中,也可以是1D或者3D的),2D图片中包含的像素值,既可以是某种真实材质的颜色,也可以是其他表面特性,比如光照反射率、凹凸系数,从而为模型增加丰富的表面细节,使其看起来更加逼真。从本质上来说,纹理中每个位置都具备2D直角坐标系下的坐标参数,同时也具备了真实材质的表面特性,正是基于此,我们可以将纹理映射到某个任意形状的几何表面上,从而使得几何看起来更加真实生动。
定义纹理
可以在一个材质中定义8个纹理,它们分别是通过_texture0, _texture1, ... ,_texture7来定义的。它们之间本无差异,更多的纹理只是可以帮助我们为材质添加更多的细节。
纹理有两个参数:
-
image:就是纹理贴图,它具备了三种不同的数据类型,可以是单张贴图image2D,也可以是环境贴图imageCube,还可以是一段缓冲imageBuf。
-
sampler:这个参数表征了纹理贴图的像素如何被映射在目标几何上,当没有专门指定时,会采用默认值。请参考本节的[Sampling](## Sampling)
下面这个例子中定义了一个带有单张纹理贴图的材质。
// 读取一个jpg图片
unsigned char* imageData = stbi_load("./ash_uvgrid.jpg", &width, &height, &nrChannels, 4);
// 利用这个图片定义了一个image2d类型的纹理贴图image
auto image = _image2d(width, height, &imageData[0]);
auto material = _material(
_program(
vertexSource, fragmentSource
),
nullptr,
_states(
{
_depth(true),
// 将上面定义的纹理贴图与该材质绑定
_texture0(image)
}
)
);
纹理坐标分布
纹理坐标代表了在纹理贴图中的某个位置,一般来说是2D数组。纹理坐标分布是指纹理坐标在几何顶点上的分布,也就是每个顶点上的纹理坐标数组,所以是一种顶点信息,利用_vertices定义。下面这个例子中,定义了一个三角形几何,我们为它定义了顶点位置坐标positions、法向normals,也定义了纹理坐标分布texCoords,也就是3个顶点上各自的纹理坐标。
auto geometry = _geometry(
{
{"positions", _vertices({-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0})},
{"normals", _vertices({0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1})},
{"texCoords", _vertices({0, 0, 1, 0, 0.5, 1})},
},
_triangles(3)
);
如果继续使用上面定义的材质material,它包含了纹理贴图texture0(image),在该材质的面元着色器中编写了函数,该函数将贴图与纹理坐标结合起来,使得该贴图被铺在了这个三角形上。得到下面图片中看到的效果。
std::string fragmentSource = R"(
#version 330 core
out vec4 fragColor;
in vec3 v_Normal;
// 顶点的纹理坐标信息
in vec2 v_TexCoord;
// 与当前材质绑定的纹理贴图
uniform sampler2D texture0;
float phong(vec3 normal) {
float nDotV = abs(normalize(normal).z);
return float(0.4) + nDotV * float(0.5) + float(0.15) * pow(nDotV, float(20.0));
}
void main()
{
float lightIntensity = phong(v_Normal);
// 通过纹理贴图与纹理坐标分布,共同计算处几何每个像素点的颜色
fragColor = vec4(texture(texture0, v_TexCoord).rgb * lightIntensity, 1.0);
}
)";
Sampling
纹理贴图上的每个像素都有2D坐标(x,y),x,y的取值范围是0到1,采样(sampling)就是通过坐标获得纹理像素,然而顶点信息中的纹理坐标是任意浮点数,那么在将纹理像素映射到几何上时,往往会面临一些问题。
Filtering
当几何面很大而纹理的分辨率很低时,会导致最终几何上的贴图很模糊,反过来说,如果几何面很小而纹理的分辨率却很大时,则会造成 像素过剩。同时产生不真实的感觉。在这两类情况下,需要设置放大或者缩小操作的纹理过滤方式,在我们库中就对应Sampler的minFilter和magFilter两个参数,它们的可选类型见下表,这些选项决定了如何将纹理像素映射到纹理坐标。
| filter类型 | 描述 |
|---|---|
| NEAREST | 邻近过滤,默认的方式,选择像素中心点最接近纹理坐标的那个像素。 |
| LINEAR | 线性过滤,基于纹理坐标附近的纹理像素,计算出一个插值,近似出附近这个像素之间的颜色。当在放大映射时,线性过滤效果更好 |
| NEAREST_MIPMAP_NEAREST | 多级渐远纹理过滤只适用于纹理被缩小的情况。这种方式是使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样 |
| LINEAR_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样 |
| NEAREST_MIPMAP_LINEAR | 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样 |
| LINEAR_MIPMAP_LINEAR | 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样 |
Wrapping
当我们将几何顶点的纹理坐标设置在(0,0)到(1,1)范围之外时,范围 外的纹理应该如何取值呢?在我们库中,Sampler的wrapS和wrapT参数分别用来设置在两个不同方向上的处理方式,它们的可选类型见下表。
| wrap类型 | 描述 |
|---|---|
| REPEAT | 默认行为方式,重复纹理图像。 |
| CLAMP_TO_EDGE | 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
| MIRRORED_REPEAT | 和REPEAT一样,但每次重复图片是镜像放置的。 |
接下来,我们通过一些例子看看不同的纹理贴图方式。
漫反射贴图
这个例子中,我们利用image2D实现漫反射贴图。
在这个例子中,我们会将这张图片贴在几何体上,您可以观察到,这个图片被重复多次覆盖在不同的区域。
// 读取一个jpg图片
unsigned char* imageData = stbi_load("./ash_uvgrid.jpg", &width, &height, &nrChannels, 4);
// 利用这个图片定义了一个image2d类型的纹理贴图image
auto image = _image2d(width, height, &imageData[0]);
auto material = _material(
_program(
vertexSource, fragmentSource
),
nullptr,
_states(
{
_depth(true),
// 将上面定义的纹理贴图与该材质绑定
_texture0(image)
}
)
);
我们可以看到下面这样的效果:
镜面环境贴图
imageCube又可以成为环境贴图,就是将环境看作一个立方体,6个面上有各自的贴图,形成一个完整的环境。当把它们全部映射在几何体上后,就会仿佛几何体置于一个环境中,并在其表面反射出环境的景色。
// 读取6个方向各自的jpg图片
unsigned char* imageData0 = stbi_load("./posx.jpg", &width, &height, &nrChannels, 4);
Image2D image0(width, height, &imageData0[0]);
unsigned char* imageData1 = stbi_load("./negx.jpg", &width, &height, &nrChannels, 4);
Image2D image1(width, height, &imageData1[0]);
unsigned char* imageData2 = stbi_load("./posy.jpg", &width, &height, &nrChannels, 4);
Image2D image2(width, height, &imageData2[0]);
unsigned char* imageData3 = stbi_load("./negy.jpg", &width, &height, &nrChannels, 4);
Image2D image3(width, height, &imageData3[0]);
unsigned char* imageData4 = stbi_load("./posz.jpg", &width, &height, &nrChannels, 4);
Image2D image4(width, height, &imageData4[0]);
unsigned char* imageData5 = stbi_load("./negz.jpg", &width, &height, &nrChannels, 4);
Image2D image5(width, height, &imageData5[0]);
// 将6个图片组织成一个imageCube对象
auto image = _imageCube({ image0, image1, image2, image3, image4, image5 });
auto material = _material(
_program(
vertexSource, fragmentSource
),
nullptr,
_states(
{
_depth(true),
// 将上面定义的imageCube对象与该材质绑定
_texture0(image)
}
)
);
我们可以看到下面这样的效果:

缓冲区贴图
imageBuffer是存在于缓冲区的一张图片,我们在使用离屏渲染时,会将某个场景的绘制结果直接渲染到显存中的一块缓冲区,那么这个图片就是imageBuffer类型的图片。
与image2d和imageCube不同的是,imageBuffer是用_colorBuf()来定义的。下面这个代码例子中大家可以了解一下如何将图片写到imageBuffer中,以及材质如何使用这个imageBuffer。
// 开辟一个imageBuffer
auto image = _colorBuf();
// 定义渲染目标target,包含了上面定义的缓冲区
auto target = _target({ image });
// 将某个场景绘制到上面定义的渲染目标中,也就是保存在了缓冲区image中
auto targetPass = _pass(targetNode, targetBackground, targetCamera, target);
auto material = _material(
_program(
vertexSource, fragmentSource
),
nullptr,
_states(
{
_depth(true),
// 将上面定义的imageBuffer对象与该材质绑定
_texture0(image)
}
)
);
我们可以看到下面这样的效果,第一个几何的渲染结果,作为贴图,呈现在了一个四边形中。