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

纹理单元

撰写时间:2023-10-10

修订时间:2023-10-30

纹理单元的内存表示

switchTexUnit(); function switchTexUnit() { const {gl} = GLUHolder.glu; gl.activeTexture(gl.TEXTURE0); printActiveTexUnit(); printBindingTex(); gl.activeTexture(gl.TEXTURE1); printActiveTexUnit(); printBindingTex(); } function printActiveTexUnit() { const {gl} = GLUHolder.glu; let activeTexUnit = gl.getParameter(gl.ACTIVE_TEXTURE); console.log('active texture unit: ' + WebGLSpecUtils.glEnums.valueMap[activeTexUnit]); } function printBindingTex() { const {gl} = GLUHolder.glu; let texBinding2D = gl.getParameter(gl.TEXTURE_BINDING_2D); console.log('texture binding 2D : ' + texBinding2D); }

运行应用

浏览器Console输出:

active texture unit: TEXTURE0 texture binding 2D : [object WebGLTexture] active texture unit: TEXTURE1 texture binding 2D : null

说明了以下的结构:

纹理单元绑定目标纹理对象
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_MAP等4个绑定目标,用于绑定当前纹理单元下不同的目标。
  3. 每个纹理单元下可以有多个纹理对象,但该纹理单元下各个绑定目标,在某一时刻,均只能分别绑定至一个纹理对象。

根据上述规则,在使用纹理单元时,我们可有以下考虑:

  • 如果需要使用多个TEXTURE_2D的纹理对象,但它们的类型都相同,则可以只使用一个纹理单元,然后将当前目标切换绑定至不同的纹理对象即可。
  • 如果要使用两种不同类型的TEXTURE_2D,则必须使用不同的纹理单元。

何时使用一个纹理单元

如果多个纹理对象的绑定对象都相同,则可仅使用一个纹理单元。

下面例子中,有2个网格对象,它们的绑定对象均为TEXTURE_2D,则可只使用一个纹理单元。

客户端代码:

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

TextureMesh类声明如下:

class TextureMesh extends SoleColorMesh { constructor(vertices, texImg, soleColor, solidIndices, wireframeIndices) { super(vertices, soleColor, solidIndices, wireframeIndices); let texCoords = TextureUtils.GetTexCoords(); this.initTexVAO(texCoords); this.initTexture(texImg); this.renderMode = Mesh.RENDER_MODE.TEXTURE; } initTexVAO(texCoords) { let texVBO = this.glu.createTexVBO(texCoords); this.texVAO = this.glu.createVAO(VAO_TYPE.TEXTURE_VAO, this.verticesVBO, texVBO); this.texVAO.ibo = this.solidVAO.ibo; } initTexture(texImg) { const {gl, program} = this.glu; gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); this.texVAO.texture = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texVAO.texture); let imgWidth = texImg.pixelsPerRow; let imgHeight = texImg.content.length / 4 / imgWidth; gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, imgWidth, imgHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(texImg.content) ); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); gl.bindTexture(gl.TEXTURE_2D, null); } renderTexture() { const { gl, program } = this.glu; let vao = this.texVAO; gl.bindVertexArray(vao); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vao.ibo); gl.bindTexture(gl.TEXTURE_2D, vao.texture); gl.drawElements(gl.TRIANGLES, vao.ibo.indicesNum, gl.UNSIGNED_SHORT, 0); } }

可见,两个纹理对象只是纹理图像内容不同,但均绑定至TEXTURE_2D对象,此时,在创建及渲染纹理对象时,均可只使用一个纹理单元,然后在渲染时,分别绑定各自的纹理对象即可。

先渲染texMesh1时,其内存状态如下所示:

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

而在渲染texMesh2时,其内存状态如下所示:

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

运行应用

正方体的六面贴图

同理,尽管正方体共有6个面,面的数量较多,但它们同属于二维贴图,因此,整个程序仅使用一个纹理单元足可胜任。

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.createTexVBO(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();

相对于前面的章节,这里做了两个地方的改动。一是TextureMesh类继承自SolidMesh类,因为每个面可以有多种颜色。二是FaceMesh新增了faces属性,用于存储每个面的顶点、颜色、实体索引、线框索引等值,并且,每个面的顶点不再共享所有顶点,而是针对每个面的实际顶点进行了细分。

运行应用

正方体的上下面的贴图类型为Canvas 2D,前后为类型化数组,左右为图像。不同的类型,均可完美地整合到一起。

合并纹理单元格

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

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

  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轴上被分割为更多、更小的偏移值而已。

下面以代码实现。先在正方体上分别指定各个面要使用的纹理图片。

let wrapper = Geo.GenCube(1); let fontSize = 180; let texImgs = [ {canvas: createCanvas(800, 600, '和谐顺达', fontSize)}, // top, 0 {canvas: createCanvas(800, 600, 'Python', 250)}, // bottom, 1 {texFile: 'imgs/webgl-marble.png'}, // front, 2 {texFile: 'imgs/tex1.png'}, // back, 3 {texFile: 'imgs/webgl-marble.png'}, // left, 4 {texFile: 'imgs/tex1.png'} // right, 5 ];

正方体的前面与左面使用同一纹理图片,后面与右面使用同一纹理图片。根据上面的索引值,我们为其生成纹理的代码如下:

let texOpts = buildTexOpts(texImgs, [ [2, 4], [3, 5] ]); let mesh = new FaceMesh(wrapper.vertices, wrapper.faces, null, texOpts); scene.add(mesh); function buildTexOpts(texImgs, mergedFacesArr) { let mapCoords = {}; if (mergedFacesArr) { for(let mergedFaces of mergedFacesArr) { let coords = buildMergedFacesCoords(mergedFaces); for(let propName in coords) { mapCoords[propName] = coords[propName]; } } } let texOpts = []; let faceIndex = 0; for (let texImg of texImgs) { let texOpt = { texUnits: [texImg], isFlipY: false, texCoords: mapCoords[faceIndex] }; texOpts.push(texOpt); faceIndex++; } return texOpts; } function buildMergedFacesCoords(faceIndices, isHorizontal = true) { if (!faceIndices) { return {}; } let fragLen = 1.0 / faceIndices.length; let arr = []; for (let i = 0.0; i < 1.0; i += fragLen) { arr.push(i); } arr.push(1.0); let map = {}; let index = 0; while(arr.length >= 2) { let xStart = arr.shift(); let xEnd = arr[0]; let faceCoords = []; faceCoords.push(xStart, 0.0, xStart, 1.0, xEnd, 1.0, xEnd, 0.0); map[faceIndices[index]] = faceCoords; index++; } return map; }

运行应用

前左两个面共用一张贴图,后右两个面共用另一张贴图。上下两面均保留使用单独的贴图。

若将代码改为:

let texImgs = [ {canvas: createCanvas(800, 600, '和谐顺达', fontSize)}, // top, 0 {canvas: createCanvas(800, 600, 'Python', 250)}, // bottom, 1 {texFile: 'imgs/webgl-marble.png'}, // front, 2 {texFile: 'imgs/webgl-marble.png'}, // back, 3, modified {texFile: 'imgs/webgl-marble.png'}, // left, 4 {texFile: 'imgs/tex1.png'} // right, 5 ]; let texOpts = buildTexOpts(texImgs, [ [2, 4, 3] // modified ]);

则只有一个横跨3个纹理单元格的图像。读者可自行修改看看。

生成球体

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

客户端代码:

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, lats, longs) { 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个顶点。故此,尽管极圈中的每个面看似由三角形组成,实际上它们都是四边形。

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

let sphereWrapper = Geo.GenSphere(1, 2, 3);

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

对上面的代码,虽已经过精心优化,在初始化是还是感觉有些延迟。图像出来后,运行效果却非常顺畅。如果将所返回的数据创建SolidMesh类,则不会有太大问题。而如果用于创建FaceMesh类,则应用负担激增。这也意味着FaceMesh类有待于进一步的优化。目前在使用球体数据时,需始终注意这些细节。

相邻多面区域的贴图

在此节中,我们准备在球体相邻的多面上使用一个图像文件进行贴图,且允许随意指定X轴及Y轴上的重复次数。

先看效果

球体的水平及垂直方向上各有6个面。共使用了3张贴图。

app.doInInitMeshes((scene) => { let wrapper = Geo.GenSphere(1, 6, 6); let texOpts = []; for (let index = 0; index < wrapper.faces.length; index++) { texOpts.push({texUnits: [{texFile: 'imgs/leather.jpg'}]}); } for(let i = 6; i <= 11; i++) { texOpts[i] = {texUnits: [{texFile: 'imgs/tex1.png'}]}; } texOpts[21] = {texUnits: [{texFile: 'imgs/webgl-marble.png'}]}; texOpts[22] = {texUnits: [{texFile: 'imgs/webgl-marble.png'}]}; texOpts[27] = {texUnits: [{texFile: 'imgs/webgl-marble.png'}]}; texOpts[28] = {texUnits: [{texFile: 'imgs/webgl-marble.png'}]}; let mapCoords = buildMergedFacesCoords([21, 22, 27, 28], 2, 3, 2); for(let faceId in mapCoords) { texOpts[faceId].texCoords = mapCoords[faceId]; } let mesh = new FaceMesh(wrapper.vertices, wrapper.faces, null, texOpts); scene.add(mesh); });

先将球体所有的面都指定使用皮革贴图,然后,第6个到第11个面,即球体上面的一圈,使用布料贴图。而在第21、22、27、28面上使用WebGL图像贴图。这四个面是相邻的、构成了2行2列的区域。

贴图的精要在于灵活地指定贴图坐标。我们当然可以手工为这些面指定贴图坐标,但太累了,故buildMergedFacesCoords函数可予以代劳。

let mapCoords = buildMergedFacesCoords([21, 22, 27, 28], 2, 3, 2);

第一个参数是需要指定贴图坐标的多个相邻的面的索引值,第二个参数指定这些面每行有2个面,第三、第四个参数分别指定在X轴及Y轴上重复的次数。

所返回的mapCoords是一个对象字面符,具有下列的格式:

{ 21: [0, 0, 0, 1, 1.5, 1, 1.5, 0], 22: [...], 27: [...], 28: [...] }

该对象以面的索引值作为属性名称,该面的纹理坐标作为属性值。因此,代码:

for(let faceId in mapCoords) { texOpts[faceId].texCoords = mapCoords[faceId]; }

将各个面的索引坐标分别赋值于texOpts变量中的各个面。然后,再使用该变量创建FaceMesh的实例。

下面是buildMergedFacesCoords函数的代码:

function buildMergedFacesCoords(faceIndices, facesPerRow, xRepeat = 1, yRepeat = 1) { if (!faceIndices) { return {}; } let xMax = xRepeat * 1.0; let yMax = yRepeat * 1.0; let width = facesPerRow; let height = faceIndices.length / width; let rowStops = getStops(yMax, height); let colStops = getStops(xMax, width); function getStops(max, faceNum) { let fragLen = max / faceNum; let stops = []; for (let currEle = 1, i = 0.0; currEle <= faceNum; currEle++, i += fragLen) { stops.push(i); } stops.push(max); return stops; } let map = {}; let index = 0; while(rowStops.length >= 2) { let yStart = rowStops.shift(); let yEnd = rowStops[0]; let localColStops = colStops.slice(); while(localColStops.length >= 2) { let xStart = localColStops.shift(); let xEnd = localColStops[0]; let faceCoords = []; faceCoords.push(xStart, yStart, xStart, yEnd, xEnd, yEnd, xEnd, yStart); map[faceIndices[index]] = faceCoords; index++; } } return map; }

先以需要重复的次数作为纹理最大坐标值,再求出各个面在相应轴上的各个纹理刻度的坐标值(以变量rowStopscolStops来表示)。例如,若在X轴上的[0, 1]之间有2个面,则相应的面的纹理刻度值为:

[0.0, 0.5, 1.0]

若有3个面,则刻度值为:

[0.0, 0.333, 0.666, 1.0]

Y轴上也是同样的道理。求出这些刻度值后,将它们分别存储为各个面的纹理坐标即可。

有此函数的加持,我们可以非常灵活地决定,将特定的纹理图像贴至哪几个特定的面,且可以随意地决定在X轴及Y轴上如何独立地进行重复。

该函数最后两个参数使用了默认值,因此如果省略这两个参数:

let mapCoords = buildMergedFacesCoords([21, 22, 27, 28], 2);

将在这些面上粘帖整张纹理图像。

任意正多边形的贴图

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

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

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

运行应用

渲染出下面的图像:

Texture In Pologon

上面视口中所渲染出来的是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