跳到主要内容

材质贴图

纹理(texture)是在3D渲染中常用的一种技术,在很多场景中都有它的身影。比如下面列出的这些场景:

  • 表面材质模拟:纹理可以模拟各种真实材质的表面特征,比如漫反射、高光、粗糙度、透明度

  • 环境光遮蔽:模拟物体表面因其他物体遮挡而产生的阴影和光照变化

  • 细节增强:利用纹理添加额外的细节,使3D模型更加逼真,比如皮肤纹理上的毛孔、皱纹等

  • 环境反射贴图:模拟生活当中带镜面的物体,它们会反射周围的环境。

为了便于理解,我们可以把纹理看作是一个2D图片(在某些特殊场景中,也可以是1D或者3D的),2D图片中包含的像素值,既可以是某种真实材质的颜色,也可以是其他表面特性,比如光照反射率、凹凸系数,从而为模型增加丰富的表面细节,使其看起来更加逼真。从本质上来说,纹理中每个位置都具备2D直角坐标系下的坐标参数,同时也具备了真实材质的表面特性,正是基于此,我们可以将纹理映射到某个任意形状的几何表面上,从而使得几何看起来更加真实生动。

定义纹理

可以在一个材质中定义8个纹理,它们分别是通过texture0, texture1, ... ,texture7来定义的。它们之间本无差异,更多的纹理只是可以帮助我们为材质添加更多的细节。

纹理有两个参数:

  • image:就是纹理贴图,它具备了三种不同的数据类型,可以是单张贴图TexImage2D,也可以是环境贴图TexImageCube,还可以是用于FBO的颜色缓冲区的JTextureBuffer

  • sampler:这个参数表征了纹理贴图的像素如何被映射在目标几何上,当没有专门指定时,会采用默认值Repeat。请参考本节的[Sampling](## Sampling)

下面这个例子中定义了一个带有单张纹理贴图的材质。

// 读取一个jpg图片
const image = await loadImage('ash_uvgrid.jpg');
// 利用这个图片定义了一个image2d类型的纹理贴图
const tex = img2d(image);
// 将上面定义的纹理对象与该材质绑定
const material: JMaterial = {
program: {
vertex: vert(vs),
fragment: frag(fs)
},
states: {
depth: Depth({ enabled: true }),
texture0: Texture(tex)
}
};

纹理坐标分布

纹理坐标代表了在纹理贴图中的某个位置,一般来说是2D数组。纹理坐标分布是指纹理坐标在几何顶点上的分布,也就是每个顶点上的纹理坐标数组,所以是一种顶点信息,利用_vertices定义。下面这个例子中,定义了一个三角形几何,我们为它定义了顶点位置坐标positions、法向normals,也定义了纹理坐标分布texCoords,也就是3个顶点上各自的纹理坐标。

const geometry: JGeometry = {
vertices: {
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]),
},
indices: Triangles(3)
};

如果继续使用上面定义的材质material,它包含了纹理贴图texture0(image),在该材质的面元着色器中编写了函数,该函数将贴图与纹理坐标结合起来,使得该贴图被铺在了这个三角形上。得到下面图片中看到的效果。

const fs = `#version 300 es
precision mediump float;

in vec3 v_Normal;
// 顶点的纹理坐标信息
in vec2 v_TexCoord;
// 与当前材质绑定的纹理贴图
uniform sampler2D texture0;

out vec4 fragColor;

float headLight(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 = headLight(v_Normal);
// 通过纹理贴图与纹理坐标分布,共同计算处几何每个像素点的颜色
fragColor = vec4(texture(texture0, v_TexCoord).rgb * lightIntensity, 1.0);
}`;
texture

Sampling

纹理贴图上的每个像素都有2D坐标(x,y),x,y的取值范围是0到1,采样(sampling)就是通过坐标获得纹理像素,然而顶点信息中的纹理坐标是任意浮点数,那么在将纹理像素映射到几何上时,往往会面临一些问题。

Filtering

当几何面很大而纹理的分辨率很低时,会导致最终几何上的贴图很模糊,反过来说,如果几何面很小而纹理的分辨率却很大时,则会造成像素过剩。同时产生不真实的感觉。在这两类情况下,需要设置放大或者缩小操作的纹理过滤方式,在我们库中就对应SamplerminFiltermagFilter两个参数,它们的可选类型见下表,这些选项决定了如何将纹理像素映射到纹理坐标。

filter类型描述
NEAREST邻近过滤,默认的方式,选择像素中心点最接近纹理坐标的那个像素。
LINEAR线性过滤,基于纹理坐标附近的纹理像素,计算出一个插值,近似出附近这个像素之间的颜色。当在放大映射时,线性过滤效果更好
NEAREST_MIPMAP_NEAREST多级渐远纹理过滤只适用于纹理被缩小的情况。这种方式是使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
LINEAR_MIPMAP_NEAREST使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
NEAREST_MIPMAP_LINEAR在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
LINEAR_MIPMAP_LINEAR在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样

Wrapping

当我们将几何顶点的纹理坐标设置在(0,0)到(1,1)范围之外时,范围外的纹理应该如何取值呢?在我们库中,SamplerwrapSwrapT参数分别用来设置在两个不同方向上的处理方式,它们的可选类型见下表。

wrap类型描述
REPEAT默认行为方式,重复纹理图像。
CLAMP_TO_EDGE纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
MIRRORED_REPEAT和REPEAT一样,但每次重复图片是镜像放置的。

接下来,我们通过一些例子看看不同的纹理贴图方式。

镜面环境贴图

环境贴图TexImageCube就是将环境看作一个立方体,6个面上有各自的贴图,形成一个完整的环境。当把它们全部映射在几何体上后,就会仿佛几何体置于一个环境中,并在其表面反射出环境的景色。

// 读取6个方向各自的jpg图片
const images = await loadCubeTexture(['posx.jpg', 'negx.jpg', 'posy.jpg', 'negy.jpg', 'posz.jpg', 'negz.jpg']);
// 利用这个6个图片定义了一个立方体纹理贴图
const tex = imgCube(image);
// 将上面定义的纹理对象与该材质绑定
const material: JMaterial = {
program: {
vertex: vert(vs),
fragment: frag(fs)
},
states: {
depth: Depth({ enabled: true }),
texture0: Texture(tex)
}
};

我们可以看到下面这样的效果:

缓冲区贴图

JTextureBuffer是存在于缓冲区的一张图片,我们在使用离屏渲染时,会将某个场景的绘制结果直接渲染到显存中的一块缓冲区,那么这个图片就是JTextureBuffer类型的图片。

TexImage2DTexImageCube不同的是,JTextureBuffer是用TextureBuffer来定义的。下面这个代码例子中大家可以了解一下如何将图片写到JTextureBuffer中,以及材质如何使用这个JTextureBuffer

// 定义渲染目标target,其中包含了JTextureBuffer对象
const target = {
color: TextureBuffer(),
depth: RenderBuffer()
};

// 将某个场景绘制到上面定义的渲染目标中,也就是保存在了缓冲区JTextureBuffer中
const targetPass = Pass({
background: targetBackground,
camera: targetCamera,
node: targetNode,
target: target
});

...

const material: JMaterial = {
program: {
vertex: vert(vs),
fragment: frag(fs)
},
states: {
depth: Depth({ enabled: true }),
// 将上面定义的JTextureBuffer对象与该材质绑定
texture0: Texture(target.color)
}
};

我们可以看到下面这样的效果,第一个几何的渲染结果,作为贴图,呈现在了一个四边形中。

texture