二维纹理贴图
撰写时间:2023-09-21
修订时间:2025-09-28
二维纹理贴图,就是将一张二维的图片的内容贴至网格物体上。本章中,我们先实现最简单的贴图功能,即将一张图片贴至一个四边形的网格对象上,以此了解如何在WebGL实现最基本的贴图功能。
着色器
顶点着色器
与之前的顶点着色器不同的是,该着色器多了一个aTexCoords的顶点属性,用以接收从客户端传来的每一个顶点的纹理坐标值。纹理坐标值的格式诸如:
与顶点属性aColor一样,aTexCoords需要传输到片断着色器中。因此,中间的过程通过变量vTexCoords来传输。
片断着色器
我们先来看一下使用纹理贴图的片断着色器最基本的代码:
变量uSampler0代表GLSL着色语言中的一种采样器,我们将其类型声明为可适用于图像文件采样的sampler2D的二维采样器。我们以后会从客户端中将采样器的值传给该变量。代码:
调用GLSL的texture函数,通过uSampler0采样器,从图像文件中取出位于纹理坐标值为vTexCoords的地方的颜色值。我们无从知道texture函数内部如何实现的细节,也无需关心,只需知道其功能就行。代码:
将采样得到的颜色值赋值于fragColor,从而可以在帧缓冲区中输出该顶点的颜色值。
注意,这是基于顶点的操作。对于每个纹理坐标值,采样器将从所映射的图像中自动取出一个相应的颜色值。因此,基于纹理的应用程序,最核心的问题,就是第一,先将纹理坐标与纹理图像的4个角映射好,第二,开发者传入顶点的纹理坐标,WebGL自动根据纹理坐标来确定顶点颜色。
这就是当我们需要渲染为纹理贴图时片断着色器的基本代码。我们注意到,这与我们之前的直接渲染为特定颜色值的代码不一样:
这就导致一个问题,如果只使用采样器的代码,则之前我们基于vColor而实现的颜色值都无法正常得到渲染。
例如,如果场景中有两个网格物体,一个为纹理网格物体,另一个为非纹理网格物体,则上面的代码只有纹理网格物体得到渲染,而非纹理网格物体只负责向vColor传送颜色值,但vColor又未向fragColor赋值,因此,其颜色值将变为0值,从而不会得到渲染。因此,为兼顾这两种情况,我们将上面的代码改写如下:
当渲染每个顶点时,我们将从客户端向片断着色器的变量uIsUseTexture传入值,告知片断着色器应否使用采样器来采样。如果不需要,则像之前一样,颜色值取自vColor即可。
我们之前的着色器代码都很简单,都只有简单的赋值语句。但上面的代码提醒了我们,GLSL是一种编程语言,类似于C语言,自然也支持诸如条件语句等基本语言要素。在以后的学习过程中,我们将有机会接触到更多的GLSL相关知识。
WebGLUtils类
本章使用文件名称为WebGLUtils-v12.js的WebGLUtils类,该类作了一些必要的变动。
构造方法
在构造方法中,唯一的一个参数opts类型为对象,使用默认值予以解包。对应于上节中所修改的着色器代码,默认的uniforms属性增加了uIsUseTexture
,默认的vertexAttribs属性增加了aTexCoords
。
uSampler0
为何不在里面?主要原因是WebGL允许多个纹理单元,也允许多个采样器,在下面的例子中,我们将会看到,会同时出现多个采样器,这意味着该片断着色器将会出现诸如:
的代码。因此,如果将固定数量的采样器变量固定到uniforms中,将不能灵活地应对这种情况。为此,我们在WebGLUtils类的loadShader方法中,将根据片断着色器代码中的采样器的个数,动态地添加到uniforms属性中。
对于片断着色器的源代码,如果出现了uSampler0
, uSampler1
等采样器的变量名称,则将这些变量名称提取出来,以数组的方式添加到着色器实例中并返回。
然后,在initProgram方法中,先与uniforms原来的值合并后,再将这些值存进program属性值中。
这样,我们只管编写各种各样的着色器的代码,而无需再操心将它们一一加入。
创建纹理VBO方法
增加一个专门创建纹理VBO的方法:
VAO增加type属性
createVAO方法修改如下:
该方法新增了vaoType参数,并存储至vao的type属性中。在下面的renderVAO方法中使用了该属性值。
渲染纹理图像的代码除了需调用gl的bindVertexArray方法及bindBuffer方法外,还需额外调用其bindTexture方法。因此,我们通过vao的type属性值予以辨别。
该方法中,我们还分别应对了只有一个纹理单元及具有多个纹理单元时的不同情况。对于只有一个纹理单元时,因为默认情况下只有第0个纹理单元被激活,因此,我们直接调用bindTexture方法就行。而如果使用了多个纹理单元,则需要分别激活相应的纹理单元、指定相应的采样器后,再调用bindTexture方法。
在下面我们将会看到,这样改动之后,客户端代码更加简洁,我们的关注点得以始终放在新知识点上。
TextureMesh类
在应用客户端2d-textures.html文件中,新增TextureMesh类,让其继承于SoleColorMesh类。先从全局上查看该类的完整代码。
我们将纹理贴图的功能浓缩到TextureMesh类中实现。TextureMesh类继承于SoleColorMesh类的原因是,在视口中,对于TextureMesh网格物体,我们既可以渲染为纹理图像,也可以渲染为单一颜色的网格物体,以此增加应用程序的趣味性及实用性。
TextureMesh类的构造方法的参数看似很多,但后面的soleColor, solidIndices, wireframeIndices这3个参数都是可以省略的,省略时由构架自动生成这些数据。
实现该类的总体思路是,将渲染纹理网格物体的相关状态集中于texVAO中,然后,声明一个renderTexture方法,最后交付WebGLUtils类的renderVAO来干脆利落地完成渲染功能:
initTexVAO方法
之前版本的VAO与VBO及IBO的关系图如下:

原来的VAO包含顶点VBO、颜色VBO,也即当渲染每个顶点时,都需要向着色器的aPosition及aColor两个顶点属性同时传送数据。而作为纹理贴图的VAO,则应包含顶点VBO及纹理坐标VBO。
相对于solidVAO属性,texVAO属性与其共享网格对象的verticesVBO属性及solidVAO的ibo属性,也即顶点坐标一样,顶点索引值也一样。
initTexture方法
initTexture方法是纹理贴图应用的核心所在,WebGL中与纹理贴图相关的API均集中于此。
纹理坐标系
纹理坐标系示意图如下。
纹理坐标系(摘自WebGL Programming Guide)
对于任意一张图片,其左上角的纹理坐标是(0.0, 1.0),左下角的纹理坐标是(0.0, 0.0),右下角的纹理坐标是(1.0, 0.0),右上角的纹理坐标是(1.0, 1.0)。
在下面我们将看到,我们这样初始化一个TextureMesh类:
第一个参数是在四边形的4个顶点在NDC坐标系(参见:标准化设备坐标系)中的坐标值,我们以逆时针方向,依序从左上角、左下角、右下角、右上角的次序声明了这4个顶点的位置。第二个参数是纹理坐标系中的坐标值,照样从左上角、左下角、右下角、右上角的次序声明了这组坐标值,因此这两组坐标系的坐标值就一一对应起来了。有了这种映射关系,WebGL就能从图像中处于纹理坐标范围内的内容相对应地贴至纹理网络对象的4个角上,且贴图中间的内容则会自动生成。
然而图像文件也自有一套坐标系统,其Y轴的值是从上往下而增长,与纹理坐标系统中的Y轴的值是从下往上而增长的特点正好相反。因此,代码:
先将图像的Y轴坐标系翻转后再复制其图像内容,从而保证了两组坐标系的朝向一致。
激活纹理单元
WebGL使用纹理单元(texture unit)来分别保存各组纹理状态,或者说,后续的纹理操作只会影响到当前激活的纹理单元。这些纹理操作方法包括:bindTexture, texImage, texParameter,以及对这些状态的查询。
因此,我们应先激活一个纹理单元后再继续后续的纹理操作。代码:
激活第0个纹理单元。上面的代码也可省略,因为默认情况下第0个纹理单元会自动被激活。WebGL最多可支持TEXTURE0至TEXTURE31共计32个纹理单元。
激活纹理单元后,在渲染时,应使用相应序号的纹理采样器。代码
向片断着色器中的uSampler0属性传入0
值,表示使用相应的第0个纹理采样器。注意,激活了第几个纹理单元,就应使用第几个采样器。同样,对于默认所激活的第0个纹理单元,此行代码也可省略。
创建纹理对象
图像文件存在于磁盘中,读取其内容后,WebGL需要将其内容存于由其管理的内存中,因此,我们需创建WebGLTexture类的实例以存储图像文件内容。
上面的代码创建一个纹理对象,并存储在texVAO的texture属性中。此时,该对象的尺寸及内容尚未确定,或者说,尚未分配内存空间。接着,将纹理对象绑定到表示二维纹理的TEXTURE_2D中,并向其填充一个宽高各为1像素的内容,最后,解除绑定。
这里向其填充1个像素的内容的原因是,此时我们尚未加载图像,且加载图像需要一定的时间。因此,很有可能在图像加载完毕之前,后面的渲染指令就先执行到了。这种情况下,先将纹理对象的内容填充为1个像素的灰色区域,在图像加载完毕之前先渲染此颜色值,以避免突然出现全黑的一片区域。
gl的texImage2D有两个版本,这里使用的是第一个版本。该版本允许手工指定纹理对象的宽度及高度,并以类型化数组的内容来填充纹理对象。很明显,该版本适用于应对图像文件加载完毕之前的情况。
设置纹理参数
texParameterf方法用以设置相应的纹理参数。第一个参数指定要为所激活的纹理单元中的哪种纹理设置,其值为TEXTURE_2D或TEXTURE_CUBE_MAP。由于本章的应用实现的是二维纹理贴图,因此这里该参数的值应为TEXTURE_2D。
第二个参数指定在特定场合下,应选用第三个参数指定的值。TEXTURE_MIN_FILTER及TEXTURE_MAG_FILTER适用于纹理像素缩放的情况,而TEXTURE_WRAP_S及TEXTURE_WRAP_T适用于是否重复贴图的情况。
先看纹理像素缩放的情况。
纹理像素缩放(摘自OpenGL Programming Guide第9版)
当第二个参数为TEXTURE_MIN_FILTER时,表示当一个纹理像素(texel)(源)大于屏幕上的一个像素(目标)时,应如何缩小纹理像素来贴图。此时,其值共有6种,前2种用于不使用midmap的情况,后4种用于使用midmap的情况。Midmap贴图在下一章谈到。
- NEAREST:取最靠近目标像素中心的1个纹理像素。
- LINEAR:取最靠近目标像素中心的4个纹理像素的加权平均值(weighted average)。
当第二个参数为TEXTURE_MAG_FILTER时,表示当一个纹理像素(texel)(源)小于或等于屏幕上的一个像素(目标)时,应如何放大纹理像素来贴图。此时,其值只有上面所列出的两种。
LINEAR可有效地避免出现毛边的情况,但同时会加重计算负担,因此须均衡考虑。
当第二个参数为TEXTURE_WRAP_S或TEXTURE_WRAP_T时,表示是否需要在X轴或Y轴上重复。
- REPEAT:重复。
- MIRRORED_REPEAT:镜像重复。
- CLAMP_TO_EDGE:拉伸而不重复。
这里只需初步了解需要编写这些代码就行了,下面的章节中,我们将通过实例详细分析这些代码的作用。
加载图像内容
上面的代码加载图像文件的内容,在加载完毕后,响应其onload事件,将图像文件的内容复制到纹理对象中。最后,调用ViewportManager的doOnCameraViewChanged方法以在视口中重新渲染整个场景。
这里调用了gl的texImage2D的第二个版本。该版本使用图像文件的内容作为填充来源,由于图像文件的宽度与高度是确定的,因此该版本无需指定纹理对象的宽度与高度。
主程序
其代码如下所示。
本应用程序的几个网格对象中,只有位于屏幕中间的是纹理网格对象,其余不是。这里将它们与纹理网格对象一并渲染,主要是要解决在渲染时,当我们按下切换渲染方式的快捷键,各个网格对象应如何正确地处理的问题。
首先,在Viewport类中,将场景中的网格渲染方式修改为渲染为纹理。
其次,在Controls类中,增加切换为纹理渲染方式的快捷键。
之后,在Mesh.js文件中,修改AbstractMesh类中关于渲染的一组方法。
最主要是在renderInViewport方法中,在调用相应的渲染方法之前,先将片断着色器的变量uIsUseTexture设置为相应的值,确保着色器程序正确选择纹理与非纹理的不同渲染方式。
暗藏机关的是下面的3个方法:
这里要应对的情况是,当用户按下快捷键X,要求将场景中所有网格物体都渲染为纹理方式时,非纹理物体原本就没有renderTexture方法,因此会因无法响应而造成程序出错。
因此,上面的代码为所有的AbstractMesh类都声明了一个虚拟的renderTexture方法,而在其方法体中,却是实际调用了原来就具备的renderSolidWireframe方法,即渲染为实体加线框方式。而在子类TextureMesh类中,其所声明的renderTexture方法会覆盖父类的虚拟方法,从而不会受到影响。
同理,对于纯线框的WireframeMesh来讲,如果被要求渲染为纹理或实体方式,则会运行上面的虚拟的renderSolid方法,其方法体内却实际渲染为线框模式。
通过这样的处理方式,所有的网格物体均能正确响应不同的渲染方式的要求。
运行应用。分别按下D, S, W及X快捷键,观察各个网格物体的渲染方式是否按预期方式得到渲染。按H快捷键,在隐藏框线地板时,看是否会影响到其他网格物体的正常渲染。
按快捷键4,使用4个视口,并在每个视口中独立使用一种渲染模式。
本章小结
为实现本章中例子,应用框架的各个类均做了一些相应的改动。其中的一些修改,并不直接涉及纹理贴图,但要在应用框架中正常使用纹理贴图却又是必须的,属于系统集成范畴。当新增功能一旦成功地集成进应用框架后,再修改、完善、升级具体的类,就显得相对容易多了。并且,通过充分利用应用框架中的其他功能作为辅助手段,还可以帮助我们从不同的角度来查看新技能的运用效果。
注意到本章中的TextureMesh类并未立即集成到Mesh类中,这是因为该类在以后可能要做适当的修改,因此暂不着急集成。
