Web编程技术营地
研究、演示、创新

纹理单元

撰写时间:2023-10-10

修订时间:2026-06-13

纹理单元的内部状态

纹理单元与绑定目标的关系

运行应用

说明了纹理单元、绑定目标与纹理对象有以下的对应关系:

纹理单元绑定目标纹理对象
TEXTURE0TEXTURE_2DtexObj0
TEXTURE_2D_ARRAYnull
TEXTURE_3DtexObj1
TEXTURE_CUBE_MAPnull
texObj2
texObj3
TEXTURE1TEXTURE_2Dnull
TEXTURE_2D_ARRAYnull
TEXTURE_3Dnull
TEXTURE_CUBE_MAPnull
texObj4
.........
TEXTURE31TEXTURE_2Dnull
TEXTURE_2D_ARRAYnull
TEXTURE_3Dnull
TEXTURE_CUBE_MAPnull

具体规则如下:

  1. 纹理单元共有32个,序号从TEXTURE0TEXTURE31
  2. 每个纹理单元都各有TEXTURE_2DTEXTURE_2D_ARRAYTEXTURE_3DTEXTURE_CUBE_MAP4个绑定目标,用于绑定当前纹理单元下不同的目标。并且,这些绑定目标,在每个纹理单元下,其数量只能有1个。

    这类似于数据库中,TEXTURE0TEXTURE_2D组成了一个复合键,TEXTURE0TEXTURE_2D_ARRAY组成了一个复合键。同理,TEXTURE1TEXTURE_2D组成了一个复合键,TEXTURE1TEXTURE_2D_ARRAY组成了一个复合键。

  3. 每个纹理单元下可以有多个纹理对象,但该纹理单元下各个绑定目标,在某一时刻,均只能分别绑定至一个纹理对象。

多纹理单元应用的内部流程

如果在片断着色器中,同时出现了2个以上的采样器,这是应用程序同时使用多纹理单元的表征:

上面,在渲染每个顶点时,均需要同时使用2个采样器来获取不同颜色值,并将这2种颜色值相乘后,才得到最后的顶点颜色值。

客户端代码:

TextureMesh的代码中,将texUnits作为属性名保存进texVAO中:

WebGLUtilsrenderVAO方法中:

激活TEXTURE0TEXTURE12个纹理单元,将uSampler0指向TEXTURE0,将uSampler1指向TEXTURE1。并且将当前绑定至TEXTURE_2D的对象的纹理,分别绑定至两个不同的纹理对象。此时WebGL内部状态如下:

纹理单元绑定目标纹理对象
TEXTURE0TEXTURE_2DtexObj0.texture
TEXTURE1TEXTURE_2DtexObj1.texture

而后,当发起渲染命令:

注意,上面代码先激活多个纹理单元并将当前激活纹理下的TEXTURE_2D绑定至不同的纹理对象,而drawElements方法只须调用一次,这意味着,bindVertexArray方法能记住上述WebGL的内部状态。

片断着色器中的代码:

将导致分别从texObj0.texturetexObj1.texture这两个纹理对象中进行颜色值采样。

可见,仅当片断着色器同时出现了2个以上的采样器时才需要使用多纹理单元;否则,使用默认的TEXTURE0纹理单元即可。

一个纹理单元绑定多个纹理对象的内部状态

我们之前的应用程序,绝大部分都是只使用一个纹理单元,但将TEXTURE_2D先后绑定至多个不同的纹理对象。

片断着色器代码:

客户端代码:

app.doInInitMeshes((scene) => { let texMesh1 = new TextureMesh( Geo.GenSquareVertices(1.0), { content: [0xFB, 0x80, 0x72, 0xFF], pixelsPerRow: 1 } ); texMesh1.translate([-1.0, 0.0, 0.0]); scene.add(texMesh1); let texMesh2 = new TextureMesh( Geo.GenSquareVertices(1.0), { content: [0xB3, 0xDE, 0x69, 0xFF], pixelsPerRow: 1 } ); texMesh2.translate([ 1.0, 0.0, 0.0]); scene.add(texMesh2); });

WebGLUtilsrenderVAO方法中:

先渲染texMesh1时,WebGL内部状态如下所示:

纹理单元绑定目标纹理对象
TEXTURE0TEXTURE_2DtexMesh1.texVAO.texture
TEXTURE_2D_ARRAYnull
TEXTURE_3Dnull
TEXTURE_CUBE_MAPnull
texMesh2.texVAO.texture

而在渲染texMesh2时,WebGL内部状态如下所示:

纹理单元绑定目标纹理对象
TEXTURE0TEXTURE_2DtexMesh2.texVAO.texture
TEXTURE_2D_ARRAYnull
TEXTURE_3Dnull
TEXTURE_CUBE_MAPnull
texMesh1.texVAO.texture

运行应用

本节小结

是否需要使用多纹理单元,由片断着色器中是否同时出现多个采样器来决定。

activeTexture方法,只在渲染时才需要调用,初始化各个纹理对象时无需调用。

WebGLUtilsrenderVAO方法根据其参数vao是否存在texUnits属性来判断是否使用多纹理单元。因此,在客户端中,最简便的方法是使用一个名为texUnits的数组对象来封装不同纹理单元的各种参数,然后将该对象保存为vaotexUnits属性值即可。

FaceTextureMesh的实现

WebGL所支持的纹理单元最大数量只有32个,因此,若非需要在片断着色器同时使用多个采样器来进行颜色值融合,最好不要使用多纹理单元。

下面,我们来实现一个基于FaceMeshFaceTextureMesh,并为各个面指定不同的纹理对象。

方案一

思路

当需要渲染一个VAO时,需编写下面最基本的渲染代码:

而若需要从第二个纹理对象中采样时,需再次调用以下代码:

而仅有一个纹理单元意味着,此时片断着色器中将只有一个采样器,只能从一个纹理对象中进行采样。我们可以重新绑定纹理,但代价是调用了2次的drawElements方法。

从设计上,我们可以简单地让一个FaceTextureMesh持有多个TextureMesh的实例,但如果在面数较多时,这将加重应用负担。我们需在保障最小开销的前提下,尽可能提高应用效能。

对于有多面的一个SolidMesh来讲,在渲染时,我们可以直接指定各个面的不同颜色值。而与此不同,基于纹理的应用是间接地进行颜色采样,必须提供纹理对象而非颜色值。

此时,解决方案长出两个岔道。

第一种解决方案是将不同来源的多个纹理对象打包为一个较大的纹理对象,然后通过构建不同的纹理坐标的方式,从较大的纹理对象中采样出不同来源的颜色值。但纹理图像来源较多时,这种方案不可取。

第二种解决方案是让FaceTextureMesh持有多个VAOs:

对于仅能使用一个纹理单元而又需要间接采样的需求来讲,让一个FaceTextureMesh持有多个VAOs是从多种纹理图像采样的较小代价,比让其持有多个TextureMesh的代价要小得多。

后面可看到,通过应用设计模式,我们可以设计出更精妙的解决方案。此处暂且按下不表。这里先按第二种解决方案来实现。

代码实现

运行应用。先看效果:一个网格物体在水平方向上有2个面,分别从两个类型化数组的纹理图像中进行采样。

客户端代码:

两个面,分别指定了索引值、纹理图像内容及纹理参数。

下面是FaceTextureMesh的代码。

构造器代码:

先从形参paramFaces数组中收集、合并各数组元素的indices为:

将此值作为参数调用父构造器方法,用以构建一个FaceMesh。此时,faceMesh对象将有faces属性值:

这些值不同于上面形参paramFaces中的值的原因在于,faceMesh已根据参数中所指定的面索引值,重新生成了为将2个面渲染为不同颜色值所必需的8个顶点,而不是原来的仅6个顶点。上面的indices属性值引用的是新生成顶点的索引值。

为区分开来,上面代码以paramFaces, paramFacesIndices的命名方式显示它们是形参中的值。

initTexVAO方法代码:

代码对形参paramFaces进行遍历,以取出它们的texImgtexParams属性值,用以为每个面分别构建一个faceTexVAO及其纹理对象。

基于上面所谈到的原因,唯独不再使用paramFaces中的indices属性值,转而使用实例对象中的indices属性值。

genTexture方法的代码:

根据在客户端中所指定的纹理图像内容及纹理参数,生成纹理对象并返回。

initTexVAO方法执行完毕后,faceMesh的状态为:

renderTexture方法代码与上面所述完全一致,再次列出如下:

方案二

思路

方案一对于每个面,都创建了一个VAO,在面数多时,仍有点占服务器内存。

就像SolidMesh只有一个solidVAO属性,我们可以为FaceTextureMesh只创建一个texVAO属性,但将渲染每个面时所需的IBO及纹理对象都置于FaceTextureMeshfaces属性之下。

同时,也将每个面的纹理坐标置于FaceTextureMeshfaces属性之下,这样,当查看faces属性值时,每个面的基本情况都囊括其中。以后在涉及到光照时,每个面的法线向量的数据信息也都统一存储于其中。

FaceTextureMeshfaces属性值只占用客户端内存,不占用服务器内存,因此,在应用程序初始化完毕后并运行时,不会影响应用性能。

代码实现

客户端代码:

作为变化,faces[0]texCoords纹理坐标属性值,而faces[1]没有。

FaceTextureMeshinitTexVAO方法:

先将参数中的texCoords属性值(若未指定则自动生成)置于faceMeshfaces属性下面。

而后,创建一个全局的texVAO,涵盖了所有的顶点坐标及纹理坐标值。

随后,为各个面创建相应的ibotexture,也统一置于faceMeshfaces属性下面。

WebGLUtils原有一个createIBO方法,其代码为:

该方法将所创建的ibo置于vao之下,而这里的ibos分属于各个面,故上面另外编写了createIBO函数。

faceMesh初始化完毕后,其状态如下:

故此,FaceTextureMeshrenderTexture方法代码为:

运行应用

这种方式,在仅使用一个texVAO的情况下,又能为各个面分别指定不同的纹理图像,从而兼顾了效率及机动,可适用于面数较多的情况。

方案三

思路

观察上面的renderTexture函数:

目前来讲,因客户端传入的是类型化数组作为纹理内容,其内容较小,占用内存较少,因此为各个面分别创建各自的纹理对象应没问题。

但为每个面均各自创建一个IBO就有点奢侈了。如果一个网格物体有1000个面,则仅就此网格物体,就将创建1000IBOs并进驻到GPU中由其调度。

有没有办法只创建一个IBO,将所有顶点索引值都打包进其中,而在渲染每个面时只渲染其中的一个子集?gldrawElements方法早就为此而设计,我们可以指定要取用的索引值数量、并指定从哪个字节偏移处开始渲染。对此特性,不用白不用。

代码实现

只需修改两个地方。

修改initTexVAO方法的内容。

除删除掉上面屏蔽的一行代码外,上述代码与之前对比,保持不变。

剩下的代码修改如下:

在生成所有顶点的索引值时,及时将在渲染每个面所需的索引元素数量及其开始的字节偏移值记录在iboFaces中,最终,该变量将成为ibofaces属性值。

faceMesh初始化完毕后,其状态如下:

只有一个全局的ibo属性,其类型为WebGLBuffer,但我们将每次调用drawElements方法时所需的参数,以数组的方式存储进ibofaces属性值中。

renderTexture方法修改如下:

运行应用

这样修改后,当网格物体的面数较多时,服务器内存空间仅增加了相应的索引值整数,服务器内存压力得以骤降。

检查一下,还有什么问题吗?还有不少的问题,其中较大的问题是:

为每个面都创建了一个纹理对象。设若有10个面,其中有5个面使用同一纹理对象,则上面的代码不分青红皂白创建了5个纹理对象,严重浪费了服务器的内存空间。因此,这里需引入纹理缓存机制。

此外,gl.texImage2D导致向服务器同步上传纹理对象。若有1000个面,该行代码将让CPU停下手中其他的活,专干此活,从而导致主线程严重阻塞。外观表现是,在Chrome中,虽能显示初始类型化数组纹理,但浏览器标签页图标转圈不停,无法响应用户鼠标、键盘操控。而在Safari中,用户体验更差,屏幕直接一片空白,直至此工作完成为止。若使用上面所谈到的纹理缓存机制,因真正需要创建的纹理对象的数量大幅减少,则也能有效地解决此问题。

此处先提出问题,留待下面具体实例中根据实际情况具体解决。

本节小结

FaceMesh在使用起来非常方便,但需考虑的问题较多,如需重新生成顶点数组、自动为每个面生成随机颜色、尽量减少内存的占用以提高应用效能等问题。因此,不能让其仅是简单地聚合多个SolidMesh实例。

与上一章中的实现相比,为最大化地提高效能,本章的FaceMesh摒弃了聚合多个网格物体的理念,进行了全新的改写。

FaceTextureMesh更需额外考虑如何在只使用一个纹理单元的情况下,为各个面指定不同的纹理图像来源。同样,从应用效能考虑,也不能让其简单地聚合多个TextureMesh实例。相反,我们基本上是从最底层重新编写了其代码。

当依此而设计出FaceTextureMesh类后,客户端代码将非常直观,且易于扩展。例如,后面我们将在其基础上扩展为让一个立方体的6个面拥有不同的纹理贴图,并让相邻的多个面共同使用同一纹理贴图。甚至,为一个圆球的众多面贴上不同的纹理贴图。

可以说,为最大化地优化FaceMeshFaceTextureMesh的性能,与它们所能带来的便利性、可扩展性相比,无论花多少时间都在所不惜。

立方体的六面贴图

本节通过两种方式,实现一个立方体的贴图。

简单粗暴的实现

整体代码:

class TextureMesh extends SolidMesh { constructor(vertices, texOpts, colors, solidIndices, wireframeIndices) { super(vertices, colors, solidIndices, wireframeIndices); const {gl, program} = GLUHolder.glu; const { texUnits = [ { texImg: { content: [125, 125, 125, 255], pixelsPerRow: 1 } } ], isFlipY = false, texCoords = TextureUtils.GetTexCoords(0, 0, 1, 1), filter = { mag: gl.NEAREST, wrapS: gl.REPEAT, wrapT: gl.REPEAT } } = texOpts ??= {}; this.initTexVAO(texUnits, texCoords); this.initTexture(isFlipY, texUnits, filter); this.renderMode = Mesh.RENDER_MODE.TEXTURE; } initTexVAO(texUnits, texCoords) { let texVBO = this.glu.createTex2DVBO(texCoords); this.texVAO = this.glu.createVAO(VAO_TYPE.TEXTURE_VAO, this.verticesVBO, texVBO); this.texVAO.ibo = this.solidVAO.ibo; this.texVAO.texUnits = texUnits; } initTexture(isFlipY, texUnits, filter) { const {gl, program} = this.glu; gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, isFlipY); texUnits.forEach((texObj, index) => { gl.activeTexture(gl.TEXTURE0 + index); texObj.texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texObj.texture); if (!texObj.texImg) { texObj.texImg = { content: [125, 125, 125, 255], pixelsPerRow: 1 }; } let imgWidth = texObj.texImg.pixelsPerRow; let imgHeight = texObj.texImg.content.length / 4 / imgWidth; gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, imgWidth, imgHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(texObj.texImg.content) ); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter.mag); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, filter.wrapS); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, filter.wrapT); gl.bindTexture(gl.TEXTURE_2D, null); if (texObj.texFile) { this.loadImageToTexture(texObj); } if (texObj.canvas) { this.loadCanvasToTexture(texObj); } }); } loadImageToTexture(texObj) { const {gl} = this.glu; let image = new Image(); image.src = texObj.texFile; image.onload = () => { gl.bindTexture(gl.TEXTURE_2D, texObj.texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.generateMipmap(gl.TEXTURE_2D); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.bindTexture(gl.TEXTURE_2D, null); ViewportManager.instance.doOnCameraViewChanged(); }; } loadCanvasToTexture(texObj) { const {gl} = this.glu; gl.bindTexture(gl.TEXTURE_2D, texObj.texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texObj.canvas); gl.generateMipmap(gl.TEXTURE_2D); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.bindTexture(gl.TEXTURE_2D, null); } renderTexture() { this.glu.renderVAO(this.texVAO); } } class FaceMesh extends CompositeMesh { constructor(vertices, facesIndices, facesColors, texImgs) { super(); this.faces = []; this.vertices = vertices; this.vertices2D = Geo.To2DArrays(vertices); if (facesIndices === null || facesIndices === undefined) { facesIndices = []; facesIndices.push(Geo.GetIndicesFromVertices(vertices)); } let currFaceIndex = 0; for(let faceIndices of facesIndices) { let faceObj = { vertices: [], colors: [], solidIndices: [], wireframeIndices: [] }; for (let indexInOneFace of faceIndices) { let vertex = Geo.GetVertexFromIndex(vertices, indexInOneFace); faceObj.vertices.push(vertex[0], vertex[1], vertex[2]); } let verticesIndices = Geo.GetIndicesFromVertices(faceObj.vertices); faceObj.solidIndices = Geo.GetCCWTriangleIndices(verticesIndices); faceObj.wireframeIndices = Geo.GetLinesIndices(verticesIndices, true); let faceColor = this.getFaceColor(facesColors, currFaceIndex); faceObj.colors = faceColor.repeat(faceObj.vertices.length / 3); this.faces.push(faceObj); let mesh; if (texImgs) { let texOpts = { texUnits: [ texImgs[currFaceIndex] ] }; mesh = new TextureMesh(faceObj.vertices, texOpts, faceObj.colors, faceObj.solidIndices, faceObj.wireframeIndices); } else { mesh = new SolidMesh(faceObj.vertices, faceObj.colors, faceObj.solidIndices, faceObj.wireframeIndices); } this.childMeshes.push(mesh); currFaceIndex++; } this.renderMode = Mesh.RENDER_MODE.SOLID_WIREFRAME; } getFaceColor(facesColors, currFaceIndex) { if (facesColors === null || facesColors === undefined) { return GLColors.GetRandomSofterRGB(); } else { let color = facesColors[currFaceIndex]; if (color === undefined) { return GLColors.GetRandomSofterRGB(); } return color; } } } let app = new WebGLApp(); app.doInInitViewports((cameraManager, viewportManager) => { cameraManager.eyeDist = 8; viewportManager.useLayout(VIEWPORT_LAYOUT.Single); viewportManager.activeViewport.camera.setAzimuth(30); viewportManager.activeViewport.camera.setElevation(25); }); app.doInInitMeshes((scene) => { let wrapper = Geo.GenCube(1); let texImgs = [ {canvas: createCanvas(800, 600, '元亨利贞', 180)}, {canvas: createCanvas(800, 600, 'Python', 250)}, { texImg: { content: [ 0xFB, 0x80, 0x72, 0xFF, 0xB3, 0xDE, 0x69, 0xFF, 0x80, 0xB1, 0xD3, 0xFF, 0xBC, 0x80, 0xBD, 0xFF ], pixelsPerRow: 2 } }, { texImg: { content: [ 0x33, 0x33, 0x33, 0xFF, 0xCC, 0xCC, 0xCC, 0xFF, 0xCC, 0xCC, 0xCC, 0xFF, 0x33, 0x33, 0x33, 0xFF ], pixelsPerRow: 2 } }, {texFile: 'imgs/webgl-marble.png'}, {texFile: 'imgs/tex1.png'} ]; let mesh = new FaceMesh(wrapper.vertices, wrapper.faces, null, texImgs); scene.add(mesh); }); function createCanvas(width, height, text, fontSize) { let canvas = document.createElement('canvas'); let ctx = canvas.getContext('2d'); canvas.width = width; canvas.height = height; ctx.font = `${fontSize}px Impact`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#4E6E9A'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#E1C076'; ctx.fillText(text, canvas.width / 2, canvas.height / 2); return canvas; } app.run();

运行应用

尽管应用效果看起来很不错,但最主要的问题在于,一个立方体网格物体,聚合了6TextureMeshSolidMesh,占用了大量的服务器内存空间。

精细的实现

运行应用。先看效果。6个面均有贴图。但每个面均有各不相同的纹理设置。

下面是客户端代码:

与前面不同的是,在faceTexOpts变量中,每个面均有一个faceID属性,用于明确指定为哪个面设置纹理。这是因为立方体的几何图像信息是由Geometries所自动生成,而非我们自己手工逐一指定各个面的索引值。

顶面使用类型化数组作为纹理图像内容,未显式设置纹理参数。则默认使用NEAREST进行采样,确保像素界限清晰。

前面也是使用类型化数组作为纹理图像内容,但设置了纹理参数及纹理坐标,则均覆盖了默认设置,以允许对类型化数组的纹理图像进行线性采样。

后面使用Canvas 2D作为纹理图像内容,同时通过设置纹理坐标,形成行列各重复3次的效果。

剩下的左面、右面、底面均使用图像文件作为纹理图像内容,未设置其他参数。对于这种类型的纹理图像,均统一使用线性采样,以确保清晰的图像效果。

左面及底面先后使用了同一个图像文件,应用程序一是对图像内容进行了缓存,确保不同的图像文件只加载一次,以提高程序效能。二是缓存内容为Promise,这样可确保先后引用同一缓存对象的代码,均只在图像文件加载完毕后才统一运行后续代码。

faceMesh初始化完毕后,其状态如下:

fileTexturesCache确保只为不同的纹理文件名生成唯一的纹理对象,并且所生成的纹理对象集中存储在faces.textures中。

ibo属性存储了渲染每个面时所需的数据信息,其中各个面的textureID是上面faces.textures数组中相应的索引值。这种数据结构,有效建立起了纹理对象的缓存机制。

FaceTextureMesh类代码如下:

initTexVAO的代码较长,主要是在遍历各个面时做了以下3件事:

一是若不为特定的面进行纹理设置,则默认使用一个2 × 2的代表国际象棋棋盘的类型化数组作为纹理图像内容。屏蔽掉特定面的设置值,将可看到此点。

二是为各个面配置如下的初始状态:

其中textureID是初始化类型化数组的纹理对象。

三是各个面的配置选项中如果存在texImg, canvastexFile属性值,则为相应的面创建相应的纹理对象,并将所返回的纹理对象的id值更新至上面的状态。

对于texImggenTypedArrayTexture方法根据客户端传进的纹理参数进行设置。

对于canvastexFile,因为它们都是图像文件,因此genImgFileTexture方法直接以线性的纹理参数进行设置。

texImgcanvas均属于在内存中直接创建纹理图像,不存在延迟加载图像的问题,流程完全落于主线程内,因此无需刷新视图。

texFile需加载图像,getTexIDPromise方法通过缓存机制,返回一个经解析后可得到textureIDPromise实例。

经此3步骤,已为渲染做好了状态准备。

还有最后一个问题,对于配置了texFile的各个面,何时须刷新视图的问题。每处理完一个面即重新刷新视图,将严重降低应用性能。而如果不刷新视图,在应用运行后,只有当用户进行鼠标或键盘操作时才会将客户端所配置的纹理图像刷新显示出来,有点智障。

我们可以充分利用Promise的特性,仅在所有的Promise实例都得到解析后,才一次性地刷新视图:

可见,getTexIDPromise一并解决了缓存、迟延加载、一次性刷新等3大问题。

本实现具有以下特性:

  1. 为每个未配置的面指定默认类型化数组的纹理图像
  2. 可以设置自定义类型化数组的纹理图像
  3. 支持Canvas 2D, SVGHTMLImageElement纹理图像来源
  4. 支持纹理图像缓存,每张图像将只会加载并向服务器上传一次
  5. 非类型化数组的纹理图像加载完毕后,自动替换之前的类型化数组的纹理图像

如此,不仅客户端代码灵活,也完美地对付了网格物体面数可能过多的问题。例如下面将要看到的球体。尽管球体的面数多,但如果整体只需一张纹理图像(例如,地球贴图),则初始化速度大为提升。

本节的代码,基本上涵盖了之前各章节的相关内容。FaceTextureMesh已集成进应用框架的Mesh.js中。

createCanvas函数代码如下:

将一张纹理图像平铺至邻近的多个面

上面的程序,每个面都使用了一个独立的纹理图像。但有时,我们希望将一张纹理图像贴至两个邻近的面上。在这一节中,我们先讨论合并水平方向上的邻近的多个面的情况。

我们已经知道,每个面都应有一组纹理坐标。而对于多个面要共用一幅纹理图像,需满足以下两个要求:

  1. 每个面使用的纹理图像都一样。
  2. 有多少个面,就将纹理图片切成多少块,而每个面的纹理坐标也随之而切成其中的一部分。

例如,一个单独的纹理坐标系默认为:

(0, 0) (1, 0) -----------------> | | | | | | ⋁ (0, 1) (1, 1)

取逆时针的坐标值为:

[ 0.0, 0.0, // 0 0.0, 1.0, // 1 1.0, 1.0, // 2 1.0, 0.0 // 3 ]

而如果要将一张纹理图像贴至水平方面的两个面上,则这两个面的纹理坐标应被切为:

[ 0.0, 0.0, // 0 0.0, 1.0, // 1 0.5, 1.0, // 2 0.5, 0.0 // 3 ], [ 0.5, 0.0, // 0 0.5, 1.0, // 1 1.0, 1.0, // 2 1.0, 0.0 // 3 ],

第一组纹理坐标至X轴上的0.5截止,第二组纹理坐标在X轴上则从0.5开始,至1.0结束。这样着色器在取样时,对于第一组坐标,取图像左边的内容;而对于第二组坐标,则取图像右边的内容。而由于两个邻近的面都使用同一张图片,将这两张切割的图片并排放在一起,视觉上得以还原成一张完整的图像。

明白此原理,我们可以手工指定两个相邻面的纹理坐标值:

运行应用。纹理图像从立方体的前面一直至贴至其左面。

3个以上的面同贴一张纹理图像的原理一样,只不过每组纹理坐标值在X轴上被分割为更多、更小的偏移值而已。

但与其在各个面中分别指定所共享的纹理图像并手工计算它们的纹理坐标值,我们可以集中指定并自动计算纹理坐标值。

客户端代码:

运行应用。左边的立方体水平平铺纹理图像,前面与左面水平平铺一张,后面与右面水平平铺一张。右边的立方体垂直平铺纹理图像,顶面与前面垂直平铺一张,底面与后面垂直平铺一张。黑白对角的纹理图像,属于默认生成的类型化数组纹理图像。

TiledFaceTextureMesh代码:

在为每个面添加默认的纹理配置之前,先将tiledFacesOpts的配置信息拆分至各个面中。

根据tiledFacesOpts中的direction属性值决定是水平分割还是垂直分割,分别调用TextureUtils的相应方法。

如果段数为诸如37等质数时,商无法整除,最后一段的宽度或高度偏小,从而导致自动裁剪一小部分图像内容。为此,在最后一段时,宽度值或高度值从总值中减去当前的偏移值,从而避免此问题。

在得到这些自动计算的纹理坐标后,再配置到faceTexOpts中。从而形成以下状态:

重新回归至上一节的状态,可以按上一节所述步骤为各个面生成其他配置值了。

生成球体

球体有众多的面,可用来演示贴图的应用情况。

客户端代码:

app.doInInitMeshes((scene) => { let sphereWrapper = Geo.GenSphere(1, 12, 12); let mesh = new FaceMesh(sphereWrapper.vertices, sphereWrapper.faces); scene.add(mesh); });

主要功能由Geometries类来实现。

/* * lats: vertical latitude segments, 纬度, at least 2 * longs: horizontal longitude segments, 经度, at least 3 */ static GenSphere(radius = 0.5, lats = 12, longs = 12) { let vertices = []; let faces = []; const HORIZONTAL_ROTATE_RADIAN = Math.PI / 2; let hozEnd = Math.PI * 2 + HORIZONTAL_ROTATE_RADIAN; let vertRadianStep = Math.PI / lats; let hozRadianStep = Math.PI * 2 / longs; let vertexIndex = 0; let indicesInRows = []; function genVerticesInLatitude(verticalRadian) { let y = Math.cos(verticalRadian) * radius; let currRadius = Math.sin(verticalRadian) * radius; let vertices = []; let hozRadian = HORIZONTAL_ROTATE_RADIAN; let hozVerticesNum = longs; let indicesPerRow = []; for (let currVertex = 1; currVertex <= hozVerticesNum; currVertex++) { let x = Math.cos(hozRadian) * currRadius; let z = Math.sin(hozRadian) * currRadius; vertices.push(x, y, -z); hozRadian += hozRadianStep; indicesPerRow.push(vertexIndex++); } indicesPerRow.push(indicesPerRow[0]); indicesInRows.push(indicesPerRow); return vertices; } function getFaceIndices(indicesInRows) { let result = []; let faceIndex = 0; while (faceIndex < indicesInRows.length - 1) { let top = indicesInRows[faceIndex]; let bottom = indicesInRows[faceIndex + 1]; let verIndex = 0; while (verIndex < top.length - 1) { result.push([top[verIndex], bottom[verIndex], bottom[verIndex + 1], top[verIndex + 1]]); verIndex++; } faceIndex++; } return result; } let radian = 0; let vertCirclesNum = lats + 1; for (let currVertCircle = 1; currVertCircle <= vertCirclesNum; currVertCircle++) { let circleVertices = genVerticesInLatitude(radian); vertices.push(circleVertices); radian += vertRadianStep; } vertices = vertices.flat(); faces = getFaceIndices(indicesInRows); return { vertices: vertices, faces: faces }; }

我们之前已经学过如何在一个水平面上生成一个圆,而球体的算法就是在垂直纬度上生成相应半径的圆,然后将这些顶点缝起来即可。

运行应用

为适用于贴图,球体的每个面都是四边形。就连南级、北极上的顶点,实际上也是由多个相同坐标顶点的组成。例如,对于经线数量为12的球体,要求在每个纬度的水平方向需有12个面,因此,南北级的顶点也各有12个顶点,只不过这12个顶点的位置都完全一样而坍塌为1个顶点。故此,尽管极圈中的每个面看似由三角形组成,实际上它们都是四边形。

球体很容易产生非常多的面,很容易消耗内存,降低应用性能。生成最小的球体的代码为:

let radius = 1.0; let lats = 2; let longs = 3; let sphereWrapper = Geo.GenSphere(radius, lats, longs);

将生成一个上下相扣的金字塔。但它确实不像圆。随着经纬线数量的增多,该几何体就越来越贴近于圆。经纬线数量各为6时,开始像圆,球体面数为36。经纬线数量各为12时,比较圆滑,但面数激增到144。即面数成几何倍数增长。因此应尽量谨慎地控制面数。

球体贴图

球体贴图有点违背直觉。在现实世界中,我们怎么可能将一个长方形的图像完全无缝地贴至一个球体上?现实操作是,我们是在一个球体上绘图,而不是贴图。

虽然现实生活中不行,但通过简单的加减乘除,我们却可以在计算机上实现完美的球体贴图。

在此节中,我们从最简单的球体贴图开始,逐步实现一个完整的地球贴图应用。

球体结构及单面贴图

客户端代码:

运行应用。左上视口为线框模式,左下视口为实例与线框模式,右边为纹理模式。

在垂直方向上,每条经线共分4段,因此共有5条纬线(两个极点的纬线坍塌为一个点);在水平方向上,每条纬线被划分为4段,也即整个圆球有4条经线。因此,每两层纬线间均有4个面。

取第569104个面,为它们均手工指定了一个相同的纹理图像贴图。由于未显式指定纹理坐标,每个面均使用默认的纹理坐标,因此,每个面均完整地贴上整张纹理图像贴图。

FaceTextureMesh在初始化完毕后的状态如下:

所有纹理先统一置于facestextures属性下,以供ibo.faces.textureID属性值引用。

将纹理图像贴至4个相邻的面

客户端代码:

运行应用

通过为4个相邻的面分别指定手工分割后的纹理坐标值,则一张完整的纹理图像被贴至4个相邻的面上。

通过计算取面并自动生成纹理配置对象

球体的面数较多,直接通过faceID来指定面不是易事。

运行应用。纹理图像平铺了第1条纬线下面的所有的面。

第一步,自动取出相应的一组面:

从第1条纬线开始,选出该纬线下面的4个相邻的面。

getSphereFacesIDsAtLat函数中的形参faceNums如果等于球体经线的数量,如例子所示,则取出该纬线下所有的面。

第二步,为所选出的各个面配置纹理参数,并用以创建FaceTextureMesh的实例。

genFaceTexOpts函数将一个完全的纹理坐标系拆分至所指定的各个面中,且构建返回faceTexOpts

形参facesIDs可为多行的面,因此形参facesPerRow指定每行中的面数。函数内部据此计算出具体的行数。

调用完该函数后,faceTexOpts的状态如下:

若将代码:

改为:

则纹理图像平铺至中间两层所有的面。运行应用

虽然仍存在图像粗糙及纹理图像尚未完全覆盖球体所有的面,但已经可以看出,球体贴图的本质,就是将球体的各个面的纹理坐标值,在一个默认纹理坐标系范围内,依序拆分为更小的纹理坐标值的问题。

因此,我们可以制作出一个地球贴图的应用了。将客户端代码修改如下:

将经线与纬线数量增加至32,以增加球体圆滑度;为球体所有的面生成对应的局部纹理坐标值;将纹理图像指定为一张较高分辨率的地球贴图。

运行应用

对于经线与纬线数量均为32的球体,其面数激增到1024个。

应用运行中,按W键查看线框情况,按D键查看面的情况,就可清晰地看到渲染量有多大。按X键返回纹理渲染模式。

因此遍历各个面所花的时间成为应用的主要瓶颈。故而只应在最后的一面设置了正确的纹理图像id后,才刷新视图。上面谈到,FaceTextureMesh早就为此做好了充分的准备:

每当生成一个必要的新纹理对象,将textureIDPromise添加进数组textureIDPromises中。且当其解析完毕后,仅执行一条属性值的更新语句。而在所有的textureIDPromise都解析完毕后,才刷新一次视图。

如此编写代码,因面数过多所带来的额外负面影响微乎其微,应用效能大幅提升,没有任何延滞的感觉。且初始化完毕后,无论是旋转、放大、平移视图,均没有任何卡顿。

将经纬线数量均改为64,虽面数冲至4096也无任何问题。

球体贴图的极点褶皱问题

上节中的地球贴图确实很完美,但我们被蒙蔽了一些细节。不是所有的图像文件都适合于充当球体贴图。如果将贴图文件换成其它的文件,不管贴图内容是什么,将会看到南北两极存在明显的极点褶皱。

上节使用的贴图文件内容如下:

Earth Map

设想一下,现在,用这张平面图去包裹住一个篮球。地图的顶部(北极)和底部(南极),在物理上原本是两条线,但到了球体上,它们必须各自被压缩成一个点。这样,顶边和底边的所有像素,都被强行挤到了极点这一个点上,自然会产生严重的挤压和褶皱。

上面自动计算纹理坐标的算法是北级与南极这两行线上的点都分别集中向地图的左上角与左下角收敛。

但为何实际渲染效果看不出有极点褶皱?

这是因为地球贴图的顶部与底部正好有一大片纯白的像素。当这两行所有像素都向各自目标收敛时,这部分区域内的每个像素的上下左右相邻的其他像素均与它长得完全一样。要在白色极圈内找出一个白色的点,谈何容易?因此白色褶皱消失在冰天雪地里了。

因此要消除褶皱的方法之一,就是使用类似于这里的球形全景图,或称等距圆柱投影图。这种图像在上下边缘将被无限压缩成点,因此其图像内容有着特殊的要求:上下边缘必须是纯色或极简纹理。不能有可被容易识别的具体图案。例如,如果图像的顶部或底部边缘正好是一张人脸或是一栋建筑物,则效果惨不忍睹。

消除褶皱的方法之二,是使用立方体贴图,即将球体展开为十字形或长条形的6张方形图,对应于立方体的6个面。因为每个面都是平面,所以贴到球体上时,极点(现在出现在立方体的某个角点)就不会有挤压问题。它没有特殊的图像内容要求,但需要专门的渲染流程支持。

还有另外一种较为少用的方法,就是先将图像上位于极点位置的像素沿径向按比例向外反向拉伸。这样当这些像素收敛时,正好还原了原来的图像内容。但唯有比较无聊的人才会去做这种事情。

任意正多边形的贴图

上面的例子,要求多边形为四边形,以对应纹理图像的4个角的坐标。但如果是三角形、八边形,甚至是任意的多边形呢?

任意多边形,其顶点非常随意,因此无法通过固定的算法确定其贴图坐标。这里讨论如何在任意正多边形上进行贴图。

第一步,确定能覆盖任意多边形的最小正方形。这里取五边形为例。

运行应用

渲染出下面的图像:

Texture In Polygon

上面视口中所渲染出来的是3个作为贴图坐标的正方形的由来,下面视口渲染出它们的实际贴图效果。

第一个正方形是多边形内接圆的内接正方形,从下面的视口可看出,其贴图不能覆盖多边形的所有区域。

第二个正方形是多边形外接圆的内接正方形,其覆盖多边形的范围比第一个贴图大,但仍有留白。

第三个正方形是多边形外接圆的外接正方形,正好完全覆盖了多边形。我们应取多边形外接圆的外接正方形作为纹理贴图的坐标。

第二步,求出多边形的各个顶点在该正方形的纹理坐标。

现在,我们准备将落于正多边形范围之外的纹理图像裁剪掉。其实质就是求出各个顶点的纹理坐标,其代码如下:

function calcPolygonCoords(radius, sidesNum) { let polygonVertices = Geo.GenRegularPolygonVertices(radius, sidesNum, false); let sideLen = 2 * radius; let texOrgX = -radius; let texOrgY = -radius; let result = []; for (let index = 0; index < polygonVertices.length; index += 3) { let texX = (polygonVertices[index] - texOrgX) / sideLen; let texY = (-texOrgY - polygonVertices[index+1]) / sideLen; result.push(texX, texY); } return result; }

然后,用这个包含了各个顶点纹理坐标的数组来创建TextureMesh的实例并添加到场景中。

let polygonVertices = Geo.GenRegularPolygonVertices(radius, sidesNum, false); let texCoords = calcPolygonCoords(radius, sidesNum); let texOpt = { texUnits: [{texFile: 'imgs/tex1.png'}], texCoords: texCoords }; let textureMesh = new TextureMesh(polygonVertices, texOpt); scene.add(textureMesh);

运行应用

试着改变多边形的边数,观察其是否按任意多边形的边数来截取纹理图像。

将图片分割为多个单独的纹理

上一节中,我们通过在一张纹理图像上分别精准计算多个纹理坐标而得出一块连续的纹理图像区域。

现在,让我们更进一步,如果我们希望仅将这块区域进行重复贴图,但其他区域不要参与进来。

例如,运行canvas-subimage.html。上图是一张包含了世界各地的6个著名地标的图像。我们希望从这张大图中仅选出一个地标来,并进行重复贴图。

如果只是简单地将纹理坐标进行扩展,将无法实现我们的目标。扩展纹理坐标后,将不可避免地将其他区域也圈进来了。根据原因在于原始纹理图像的内容太多了。

解决方法是从这张大图中截取一块区域,作为纹理图像来源,然后再通过扩展纹理坐标进行重复贴图。

在上面的应用中,我们已在泰姬陵上面进行了框选,框选结果自动显示在下面的小图像中。

Image Sub Region

答案已藏在上面这个应用的源代码中:

const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const image = new Image(); image.src = 'imgs/large-famous-places.jpg'; image.onload = () => { const GADDING = 100; let rect = { x: 330, y: 240, width: 250, height: 250 }; canvas.width = image.width; canvas.height = image.height + GADDING * 2 + rect.height; ctx.drawImage(image, 0, 0, image.width, image.height); const imageData = ctx.getImageData(rect.x, rect.y, rect.width, rect.height); ctx.strokeStyle = 'gray'; ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); ctx.putImageData(imageData, (canvas.width - rect.width) / 2, image.height + GADDING); };

代码只涉及到Canvas 2D技术。先绘制出整张图像,再获取长方形内的图像内容,再将其在新位置重新绘制出来。

最关键的代码是:

ctx.drawImage(image, 0, 0, image.width, image.height); const imageData = ctx.getImageData(rect.x, rect.y, rect.width, rect.height);

imageData包含widthheight属性,对应于所获取到的图像的宽与高,也包含data属性,为图像的内容,数据类型是类型化数组,该格式与我们之前所学过的使用类型数组生成纹理一节中的格式完全相同。

回到WebGL技术上。将TextureMeshloadImageToTexture方法修改如下:

loadImageToTexture(texObj) { const {gl} = this.glu; let image = new Image(); image.src = texObj.texFile; image.onload = () => { gl.bindTexture(gl.TEXTURE_2D, texObj.texture); if (texObj.selectRect) { let imageData = CanvasUtils.GetCanvasImgData(image, texObj.selectRect); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, imageData.width, imageData.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, imageData.data ); } else { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); } gl.generateMipmap(gl.TEXTURE_2D); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.bindTexture(gl.TEXTURE_2D, null); ViewportManager.instance.doOnCameraViewChanged(); }; }

在参数中如果有selectRect属性,则通过CanvasUtilsGetCanvasImgData方法来获得框选的图像内容:

export default class CanvasUtils { ... static GetCanvasImgData(image, selRect) { let canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0, image.width, image.height); const imageData = ctx.getImageData(selRect.x, selRect.y, selRect.width, selRect.height); return imageData; } ... }

再将框选的图像内容作为整个纹理图像的内容填充纹理对象。而如果没有selectRect属性,则像之前一样,将整个图像文件的内容填充纹理对象。

新建一个ImageTextureMesh类:

export class ImageTextureMesh extends TextureMesh { constructor(texOpts, width = 1.0, height = 1.0) { let vertices = Geo.GenRectVertices(width, height); super(vertices, texOpts); } }

GeometriesGenRectVertices方法简单地返回一个矩形顶点数组:

static GenRectVertices(width, height) { let halfWidth = width / 2; let halfHeight = height / 2; return [ -halfWidth, halfHeight, 0.0, -halfWidth, -halfHeight, 0.0, halfWidth, -halfHeight, 0.0, halfWidth, halfHeight, 0.0 ]; }

客户端的代码如下:

app.doInInitMeshes((scene) => { const {gl} = GLUHolder.glu; var texOpts = {texUnits: [{texFile: 'imgs/large-famous-places.jpg'}]}; let srcMesh = new ImageTextureMesh(texOpts, 2, 2); srcMesh.translate([0.0, 2.5, 0.0]); scene.add(srcMesh); var texOpts = { texUnits: [ { texFile: 'imgs/large-famous-places.jpg', selectRect: {x: 10, y: 280, width: 235, height: 200} } ] }; let dstMesh1 = new ImageTextureMesh(texOpts); dstMesh1.translate([-1, 0, 0.0]); scene.add(dstMesh1); var texOpts = { texUnits: [ { texFile: 'imgs/large-famous-places.jpg', selectRect: {x: 30, y: 10, width: 155, height: 250} } ], texCoords: TextureUtils.GetTexCoords(0, 0, 2, 2), filter: { mag: gl.NEAREST, wrapS: gl.REPEAT, wrapT: gl.REPEAT } }; let dstMesh2 = new ImageTextureMesh(texOpts, 2, 2); dstMesh2.translate([ 1.0, 0, 0.0]); scene.add(dstMesh2); var texOpts = { texUnits: [ { texFile: 'imgs/large-famous-places.jpg', selectRect: {x: 330, y: 240, width: 250, height: 250} } ], texCoords: TextureUtils.GetTexCoords(0, 0, 3, 1) }; let dstMesh3 = new ImageTextureMesh(texOpts, 5, 2); dstMesh3.translate([0, -2.5, 0.0]); scene.add(dstMesh3); });

运行应用

最上面一行是源图,代码中无须为其指定框选范围,因此纹理图像为整张图像的内容。中间及下面均仅各选取了一个地标所在的区域,并且根据需要,指定是否需要重复及重复几次。

至于如何得出框选范围,可借助上面的简单而实用的canvas-subimage.html,通过调节矩形数值,直观地得出结果。

从这个程序也看出,为应对各种情况,纹理参数非常多,因此,将纹理参数打包为一个对象确有必要。

参考资源

  1. WebGL 1.0 Specification
  2. WebGL 2.0 Specification
  3. OpenGL ES 3.0 Reference Pages
  4. WebgGL Textures
  5. WebgGL State Diagram
  6. OpenGL Wiki: Texture Storage
  7. OpenGL Wiki: Array Texture
  8. OpenGL Wiki: Pixel Transfer