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

纹理参数

撰写时间:2023-09-24

修订时间:2023-11-11

在本章中,我们将通过一系列简单而有趣的例子来一步步探索纹理贴图的细节、原理与机制。这些内容,将为我们以后实现更强大、更灵活的纹理贴图功能打下坚实的基础。

使用类型化数组生成纹理

除了使用图像文件外,我们也可以通过类型化数组来直接生成纹理内容。

class TextureMesh extends SoleColorMesh { constructor(vertices, texCoords, soleColor, solidIndices, wireframeIndices) { super(vertices, soleColor, solidIndices, wireframeIndices); this.initTexVAO(texCoords); this.initTexture(); 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() { const {gl, program} = this.glu; gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); this.texVAO.texture = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texVAO.texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array( [ 0xFB, 0x80, 0x72, 0xFF, // red 0xB3, 0xDE, 0x69, 0xFF, // green 0x80, 0xB1, 0xD3, 0xFF, // blue 0xBC, 0x80, 0xBD, 0xFF // magenta ] )); 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() { this.glu.renderVAO(this.texVAO); } ... app.doInInitMeshes((scene) => { let texMesh = new TextureMesh( [ -0.5, 0.5, 0.0, // 0 -0.5, -0.5, 0.0, // 1 0.5, -0.5, 0.0, // 2 0.5, 0.5, 0.0 // 3 ], [ 0.0, 1.0, // 0 0.0, 0.0, // 1 1.0, 0.0, // 2 1.0, 1.0 // 3 ] ); scene.add(texMesh); }); ...

上面的类无需指定图像文件,因此构造方法中也无此信息。

WebGL 1.0texImage2D的原型如下:

voidtexImage2D
  • GLenumtarget
  • GLintlevel
  • GLintinternalformat
  • GLsizeiwidth
  • GLsizeiheight
  • GLintborder
  • GLenumformat
  • GLenumtype
  • [AllowShared] ArrayBufferView?pixels
target
指定目标纹理。可为TEXTURE_2D, TEXTURE_3D, TEXTURE_2D_ARRAYTEXTURE_CUBE_MAP
level
指定midmap纹理等级。
internalformat
指定纹理内部格式。
width
指定纹理图像的宽度。
height
指定纹理图像的高度。
border
此值须为0。
format
指定像素数据的格式。
type
指定像素数据的数据类型。
pixels
数据源。

其参数很多,组合也较多。但对于常用的RGBA格式来讲,我们只需注意将type指定为UNSIGNED_BYTE就行了。

对于代码:

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array( [ // first row 0xFF, 0x00, 0x00, 0xCC, // red 0x00, 0xFF, 0x00, 0xCC, // green // second row 0x00, 0x00, 0xFF, 0xCC, // blue 0xFF, 0xFF, 0x00, 0xCC // yellow ] ));

设置了2×2共4个像素的数据源,且使用十六进制来表示。数据源用Uint8Array的类型化数组。数组中每个元素均为一个像素的颜色成份,因此需用4个元素来表示1个像素。

数组是一维线性的,但表示的图像是有宽度及高度的二维数据。其转换规律是先取出由width所指定的像素数量,排完一行后,再排第二行,以此类推。

运行应用

我们注意到,尽管纹理图像的尺寸很小,只有4个像素,但代码:

let texMesh = new TextureMesh( [ -0.5, 0.5, 0.0, // 0 -0.5, -0.5, 0.0, // 1 0.5, -0.5, 0.0, // 2 0.5, 0.5, 0.0 // 3 ], [ 0.0, 1.0, // 0 0.0, 0.0, // 1 1.0, 0.0, // 2 1.0, 1.0 // 3 ] );

要求将整个纹理图像贴至整个四边型,因此纹理图像被按比例拉伸了。

关于纹理坐标

纹理数据在内存的存储中是一维的数组,本无宽高之说。

[1, 2, 3, 4]

但为与矩形的4个角对应,又必须具备这些特征:1、转换为二维;2、配之以二维坐标来定位。

第一,转换为二维。一维数组转换为二维的形式,最直观的做法就是,需先确定每一行中的元素数量(即列数),超过列数的元素则依序排到下一行。因此,通过转变为行列的关系,一维数组变成了二维的概念。同时,也具备了图像宽度与高度的概念:宽度为列数,高度为行数。

[ 1, 2, 3, 4 ]

第二,变为二维后,需给其确定坐标。看上面的代码,从内存结构上看,第二行的元素必须排在第一行的元素之后。从方向上,其坐标系则应为:

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

此坐标系与Canvas 2D的坐标系一致。这便是纹理坐标的默认坐标系。下面的代码可看出这一点:

gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array( [ 0xFB, 0x80, 0x72, 0xFF, // red 0xB3, 0xDE, 0x69, 0xFF, // green 0x80, 0xB1, 0xD3, 0xFF, // blue 0xBC, 0x80, 0xBD, 0xFF // magenta ] )); let texMesh = new TextureMesh( [ -0.5, 0.5, 0.0, // 0 -0.5, -0.5, 0.0, // 1 0.5, -0.5, 0.0, // 2 0.5, 0.5, 0.0 // 3 ], [ 0.0, 0.0, // 0 0.0, 1.0, // 1 1.0, 1.0, // 2 1.0, 0.0 // 3 ] );

运行应用

可见,若按此坐标系,则不应翻转Y轴。

而如果翻转Y轴:

gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

则坐标系定义在笛卡尔坐标系的第一象限内,Y轴的值越往上越大。

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

此时,代码中引用坐标系也应相应调整为:

let texMesh = new TextureMesh( ... [ 0.0, 1.0, // 0 0.0, 0.0, // 1 1.0, 0.0, // 2 1.0, 1.0 // 3 ] );

运行应用

加载图像文件作为纹理时,也是这个原理。

综上,总结如下:如果不翻转Y轴,则使用Canvas 2D的坐标系;如果翻转Y轴,则使用笛卡尔坐标系中第一象限的坐标。

我个人倾向于使用不翻转Y轴的纹理坐标系,该坐标系与Canvas 2D、图像文件的坐标系完全一致,且如实地反映了数组元素按正常顺序来排列的状态。

texture函数的细节

GLSLtexture函数的作用,是根据纹理坐标来查找纹理像素。

fragColor = texture(uSampler0, vTexCoords);

为加深对该函数的理解,我这里编写了一个函数以实现类似功能。

结合上一节的内容,该问题的本质就是如何根据二维坐标,准确地找到一维数组中对应的元素的问题。

首先,我们要确定二维数组中的行列的问题。

回想我们之前编写过的代码:

let width = 2; let height = 2; gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array( [ 0xFB, 0x80, 0x72, 0xFF, // red 0xB3, 0xDE, 0x69, 0xFF, // green 0x80, 0xB1, 0xD3, 0xFF, // blue 0xBC, 0x80, 0xBD, 0xFF // magenta ] ));

对于上面的widthheight,我们也可以分别指定为41

为简化算法,这里只演示如何查找单个元素。例如,对于下列的数组,如何根据坐标来查找特定的元素?

let texels = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 ]; let elementsPerRow = 5;

上面elementsPerRow指定了每行有5个元素。这样,列数也随之确定下来了。现在,假设我们需要查找纹理坐标为(0.2, 07)的元素。

具体代码如下:

let value = lookupTexel(texels, elementsPerRow, 0.2, 0.7); console.log(value); function lookupTexel(texels, elementsPerRow, x, y) { if (texels.length % elementsPerRow !== 0) { throw new Error(`Length of array is not times of ${elementsPerRow}!`); } let rows = texels.length / elementsPerRow; let colIndex = getIndex(x, elementsPerRow); let rowIndex = getIndex(y, rows); let index = rowIndex * elementsPerRow + colIndex; return texels[index]; function getIndex(axisPercent, elementsNum) { let resultIndex; if (elementsNum === 1) { resultIndex = 0; } else { let unitPercent = 1 / elementsNum; let percentArr = []; for (let index = 1; index < elementsNum; index++) { percentArr.push(index * unitPercent); } percentArr.push(1.0); let index = 0; for(let value of percentArr) { if (value === 1.0 || axisPercent < value) { resultIndex = index; break; } else { index++; } } } return resultIndex; } }

运行这段代码,将打印出12的值,即位于数组中第1列第2行(索引值从0开始计算)的元素的值。

修改各个参数,检查是否得到各个期待的值。

上面这段代码只演示了纹理坐标值域为[0.0, 1.0]的情况。但实际上,正如下面将谈到,我们也可以指定超出此范围的坐标值。

这一节仅在于了解texture函数的内部细节,这样,在以后如果需要修改着色器中的代码时,我们就可以做到游刃有余、轻松应对。

只贴纹理贴图的一部分

如果只选择右上角的区域作为贴图来源,则目标的整个区域都被贴为绿色。

let texMesh = new TextureMesh( [ -0.5, 0.5, 0.0, // 0 -0.5, -0.5, 0.0, // 1 0.5, -0.5, 0.0, // 2 0.5, 0.5, 0.0 // 3 ], [ 0.5, 0.0, // 0 0.5, 0.5, // 1 1.0, 0.5, // 2 1.0, 0.0 // 3 ] );

运行应用

自动计算纹理坐标

由于是否翻转Y轴导致坐标系不同,因此,在手工计算其中一部分区域的坐标值时很不方便。

我们可以设计一套算法,根据图像文件的坐标系,指定X轴与Y轴上的偏移值,并指定选取范围的长度与宽度,然后自动计算出纹理坐标值。其参数可用[0, 1]之间的小数点来表示百分比。

function getTexCoords(xOffset = 0.0, yOffset = 0.0, width = 1.0 - xOffset, height = 1.0 - yOffset, isFlipY = false) { let result = []; if (isFlipY) { let x1 = xOffset, y1 = 1.0 - yOffset; let x2 = x1, y2 = y1 - height; let x3 = x1 + width, y3 = y2; let x4 = x3, y4 = y1; result.push(x1, y1, x2, y2, x3, y3, x4, y4); } else { let x1 = xOffset, y1 = yOffset; let x2 = x1, y2 = y1 + height; let x3 = x1 + width, y3 = y2; let x4 = x3, y4 = y1; result.push(x1, y1, x2, y2, x3, y3, x4, y4); } return result; } let texCoords = getTexCoords(0.5, 0.0, 0.5, 0.5); let texMesh = new TextureMesh( [ -0.5, 0.5, 0.0, // 0 -0.5, -0.5, 0.0, // 1 0.5, -0.5, 0.0, // 2 0.5, 0.5, 0.0 // 3 ], texCoords );

如果省略参数xOffsetyOffset,则默认从取纹理图像的左上角开始选取。参数widthheight如果省略,则自动取至纹理图像的右下角。最后一个参数isFlipyY的值默认为false

运行应用

Geometries类有一个可以较好格式来打印纹理坐标的静态方法。

Geometries.PrintTexCoords(texCoords);

在Console中显示:

// ==================== Texture Coordinates Info ==================== // 0: (0.5, 0.0) // 1: (0.5, 0.5) // 2: (1.0, 0.5) // 3: (1.0, 0.0) // ------------------------------------------ // Total 4 groups, in forms of (s, t)

纹理何时出现重复情况

如果我们从左上角选取,且宽度与高度均为2.0

let texCoords = getTexCoords(0.0, 0.0, 2.0, 2.0);

运行应用

结果将在X轴上与Y轴上出现各重复一次的情况。此时纹理坐标值为:

// ==================== Texture Coordinates Info ==================== // 0: (0.0, 0.0) // 1: (0.0, 2.0) // 2: (2.0, 2.0) // 3: (2.0, 0.0) // ------------------------------------------ // Total 4 groups, in forms of (s, t)

经核查,计算所得出的纹理坐标值是正确的。但其结果超出了原来默认的纹理坐标系[0, 1]的范围。可见,纹理坐标系并未只是限制在[0, 1]的范围内,如果我们选取的范围超出了默认的范围,且未作任何处理,则会出现空白的纹理像素。而对这些空白的区域,GLSLtexture函数会根据:

gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

的设定,在X轴及Y轴上通过重复粘贴原来纹理图像的内容来填充这些空白的纹理像素区域。

因此,概括起来,就是只有当我们选择纹理图像的区域超出了纹理坐标系默认的范围,才会出现是否需要重复的情况。

同理,当我们选择纹理图像的区域超出了默认的范围,我们可以改变上面的设定,让纹理图像拉伸及镜像重复:

gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);

运行应用

上面的效果,就是在X轴上,原来的纹理图像的最右侧的内容将一直延伸至末尾,而在Y轴上,将原来的纹理图像进行翻转后再进行重复。

而对于一些独特的具备上下、左右都可通过镜像来重复的图片,完全使用MIRRORED_REPEAT可达到令人震撼的效果。例如,运行这个应用。如果不看代码,你可能想不到,其贴图内容仅为下面所示:

tex1.png布料或瓷砖纹理文件(通过截屏而创建)

该贴图位于最终效果的左上角,然后在水平方向及垂直方向上各镜像重复6次。

通过此程序我们看出,尽管渲染的图案非常复杂,但贴图技术使得程序运行速度非常快。这是因为:

  1. 我们只发送了一次渲染命令:gl.drawElements(gl.TRIANGLES...),且仅是渲染了两个三角形而已。发送drawElements等渲染命令是束缚一个WebGL应用效能的重要因素,这种命令越少,程序效能越高。
  2. GPU专门对三角形的渲染方式作了极大的优化。最先进的显卡一秒可渲染2700万个三角形。因此,上面渲染两个三角形的时间,几乎不费任何时间。
  3. Canvas 2D的逐语句运行来渲染图像不同,WebGL的贴图技术先将整个贴图内容复制到WebGL管理内存中,然后再在GPU中传送这块区域的内容。学过C语言的都知道,这种技术最高效(通过调用memcpy函数)。而加上内存优化机制,如果纹理图像的内容得以先复制到GPU中处理,则效能将大大提升。

由此可见,充分使用纹理贴图技术,可让WebGL应用插上强健的翅膀。

TEXTURE_MIN_FILTER

由于我们的纹理图像太小,因此不会出现TEXTURE_MIN_FILTER的情况。但在未使用midmap时,也得为其设置参数值:

gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); // or gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

其值为NEARESTLINEAR,其效果都一样。

如果不设置,则纹理图像的内容为黑色。但线框、实体等其他渲染方式均正常。

运行应用

因此,为避免出现黑屏情况,应为TEXTURE_MIN_FILTER设置默认值。

TEXTURE_MAG_FILTER

NEAREST

我们来看TEXTURE_MAG_FILTER的值为NEAREST时的情况。

initTexture() { 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.CLAMP_TO_EDGE); } app.doInInitMeshes((scene) => { let texCoords = getTexCoords(0, 0, 3, 3); });

运行应用

TEXTURE_MAG_FILTER的值为NEAREST时,屏幕上每个像素只取最近的一个颜色值,因此颜色之间的界限很清晰。

这种方式,适用于纹理像素比较简单、但需要较为清晰的界线的场合。例如,下面的代码渲染出国际象棋的棋盘。

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array( [ 0xFF, 0xFF, 0xFF, 0xFF, 0x33, 0x33, 0x33, 0xFF, 0x33, 0x33, 0x33, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF ] )); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 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); let texCoords = getTexCoords(0, 0, 4, 4);

运行应用

2×2的黑白相间的4个像素,在行与列上各重复4次即可。

对于上面的图像,如果我们不使用纹理技术,则需要构造许多的面,且要调用64次的drawElements方法,程序效能低下。

因此,当我们学习了新的技术后,解决问题的思路也应随之有所改变。

LINEAR

我们来看TEXTURE_MAG_FILTER的值为LINEAR时的情况。

initTexture() { gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); } app.doInInitMeshes((scene) => { let texCoords = getTexCoords(0, 0, 3, 3); });

运行应用

TEXTURE_MAG_FILTER的值为LINEAR时,屏幕上每个像素取最近的4个颜色值加权平均值,因此参与贴图的颜色变多,但由于相邻之间可选颜色太少,缺乏平缓过渡的空间,图像反倒变得模糊了。因此,这种方案适用于使用高分辨率的纹理图像的场合。

同时使用多个纹理单元

上面的代码均只使用了一个纹理单元。如果片断着色器中同时出现了多个采样器,则意味着我们需要使用多个纹理单元,例如下面的片断着色器代码:

#version 300 es precision mediump float; uniform sampler2D uSampler0; uniform sampler2D uSampler1; uniform bool uIsUseTexture; in vec4 vColor; in vec2 vTexCoords; out vec4 fragColor; void main() { if (uIsUseTexture) { fragColor = texture(uSampler0, vTexCoords) * texture(uSampler1, vTexCoords); } else { fragColor = vColor; } }

同时使用两个采样器来生成纹理图像。

下面看客户端代码。先将经常使用的生成纹理坐标的函数重构为一个名为TextureUtils类的静态方法。

class TextureUtils { static GetTexCoords(xOffset = 0.0, yOffset = 0.0, width = 1.0 - xOffset, height = 1.0 - yOffset, isFlipY = false) { let result = []; if (isFlipY) { let x1 = xOffset, y1 = 1.0 - yOffset; let x2 = x1, y2 = y1 - height; let x3 = x1 + width, y3 = y2; let x4 = x3, y4 = y1; result.push(x1, y1, x2, y2, x3, y3, x4, y4); } else { let x1 = xOffset, y1 = yOffset; let x2 = x1, y2 = y1 + height; let x3 = x1 + width, y3 = y2; let x4 = x3, y4 = y1; result.push(x1, y1, x2, y2, x3, y3, x4, y4); } return result; } }

初始化纹理网格对象。

app.doInInitMeshes((scene) => { const {gl, program} = GLUHolder.glu; let texMesh = new TextureMesh( getMeshNDC(), { texUnits: [ {texFile: 'imgs/webgl-marble.png'}, {texFile: 'imgs/tex1.png'} ] } ); scene.add(texMesh); }); 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类的实例,在该实例中,使用了两个纹理单元,每个纹理单元均使用各自的图像文件。

下面是TextureMesh类的代码。

class TextureMesh extends SoleColorMesh { constructor(vertices, texOpts, soleColor, solidIndices, wireframeIndices) { super(vertices, soleColor, solidIndices, wireframeIndices); const {gl, program} = this.glu; const { texUnits = [ { texImg: { content: [125, 125, 125, 255], pixelsPerRow: 1 } } ], isFlipY = false, texCoords = TextureUtils.GetTexCoords(0.0, 0.0, 1.0, 1.0, isFlipY), 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 }; } gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, texObj.texImg.pixelsPerRow, texObj.texImg.content.length / 4 / texObj.texImg.pixelsPerRow, 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); } }); } 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.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.bindTexture(gl.TEXTURE_2D, null); ViewportManager.instance.doOnCameraViewChanged(); }; } renderTexture() { this.glu.renderVAO(this.texVAO); } }

TextureMesh的构造方法中,参数texOpts对所有与纹理相关的参数进行了包装并进行了初始化设置。

并且,在initTexVAO方法中,将纹理单元的值也存进texVAOtexUnit属性中。

initTexVAO(texUnit, 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方法中,texImg表示类型化数组的纹理图像,如果客户端传进来的设置中没有此设置,则自动创建一个,以改善等待图像加载时的用户体验。

if (!texObj.texImg) { texObj.texImg = { content: [125, 125, 125, 255], pixelsPerRow: 1 }; } gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, texObj.texImg.pixelsPerRow, texObj.texImg.content.length / 4 / texObj.texImg.pixelsPerRow, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(texObj.texImg.content));

之后,如果存在texFile的属性值,则将图像文件的内容加载纹理中,并将TEXTURE_MAG_FILTER的值设置为LINEAR,以消除图像文件的毛边。

而在上一章中,我们已经看到,WebGLUtils类的renderVAO方法已经为此做好了准备:

renderVAO(vao, isWireframe = false) { const { gl, program } = this; let renderType = isWireframe ? gl.LINES : gl.TRIANGLES; gl.bindVertexArray(vao); if (vao.type === VAO_TYPE.SOLID_VAO || vao.type === VAO_TYPE.WIREFRAME_VAO) { gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vao.ibo); gl.drawElements(renderType, vao.ibo.indicesNum, gl.UNSIGNED_SHORT, U16B_SIZE * 0); } else if (vao.type === VAO_TYPE.TEXTURE_VAO) { gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vao.ibo); if (!vao.texUnits) { gl.bindTexture(gl.TEXTURE_2D, vao.texture); } else { vao.texUnits.forEach((texObj, index) => { gl.activeTexture(gl.TEXTURE0 + index); gl.uniform1i(program[`uSampler${index}`], index); gl.bindTexture(gl.TEXTURE_2D, texObj.texture); }); } gl.drawElements(renderType, vao.ibo.indicesNum, gl.UNSIGNED_SHORT, U16B_SIZE * 0); } else { gl.drawArrays(renderType, 0, vao.verticesNum); } }

在上述渲染方法中,对于每个纹理单元,激活它,设置相应的采样器,然后再绑定相应的纹理对象。

运行应用

在最后的贴图效果中,两个图像的颜色很完美地融合到一起。这正是片断着色器中使用两颜色相乘的效果:

fragColor = texture(uSampler0, vTexCoords) * texture(uSampler1, vTexCoords);

上面的代码显得很长,但全是默默地提供后台服务的代码。正是有了这些强健而灵活的基础性代码,我们的前台代码就得以最终简化为:

let texMesh = new TextureMesh( getMeshNDC(), { texUnits: [ {texFile: 'imgs/webgl-marble.png'}, {texFile: 'imgs/tex1.png'} ] } );

意为,使用两个纹理单元,各自带有其图像文件。没有多余的废话。

Mipmap

当较大的纹理图像要贴至较小的区域时,从提高应用程序的效能考虑,图像不会简单地缩小。因为图像尺寸的减小,不会导致图像内容所占用的内存空间的减少。因此,我们需要根据特定算法,从原来的纹理图像中合理地提取一部分特定的纹理像素来构建较小尺寸的纹理贴图。WebGL可以从原始的纹理图像中自动创建一系列相应尺寸的纹理图像。如下图所示。

MipmapMipmap示意图

每个纹理图像都有一系列不同级别的版本。最开始的级别,也就是原始的纹理图像的级别为0级(0 level),越往后,尺寸越小,宽度高度均变为上一级尺寸的一半,而级别越上升,如1级,2级,3级等等。当贴图区域变小时,WebGL会自动选择最恰当尺寸的纹理图像。正是由于存在这种动态管理机制,3D应用程序就能在绚丽与效率之间取得完美的平衡。

调用一个generateMipmap语句即可实现此目标。我们将该语句放在loadImageToTexture方法中进行调用:

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(); }; }

生成mipmap后,代码:

gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);

使得其渲染效果最佳。

运行应用

可以看出,即使我们将相机的镜头推远,图像变小,但纹理依旧很清晰。

改变纹理图像的内容

createTexture创建纹理对象,texImage2D为纹理对象分配内存空间且设置这些内存空间中的数据值。之后,我们还可以通过texSubImage2D方法修改纹理对象的一部分内存数据的值。

用类型化数组修改

首先,为TextureMesh增加一个可以修改纹理内容的方法modifyTexture

modifyTexture(width, height) { const {gl} = this.glu; gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texVAO.texUnits[0].texture); const blue = [0, 0, 125, 150]; let buffer = blue.repeat(width * height); gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(buffer) ); gl.bindTexture(gl.TEXTURE_2D, null); }

为简单起见,我们这里只使用一个纹理单元。modifyTexture方法将修改纹理图像中从0行、0列开始,列宽为参数width所指定的值,行高为参数height所指定的值的这一片区域的内容,统一用蓝色进行填充。

texSubImage2D方法将最后一个参数所指定的类型化数组的内容,填充到绑定到TEXTURE_2D的当前纹理对象中,从而达到改变纹理图像的目的。因此,该方法的重点在于如何确定一个目标区域的矩形。

下面是客户端的代码。

class TransformGroup { meshes = []; add(mesh) { this.meshes.push(mesh); } translate(vector) { this.meshes.forEach((mesh) => { mesh.translate(vector); }); } } app.doInInitMeshes((scene) => { const {gl, program} = GLUHolder.glu; let texMesh1 = new TextureMesh( getMeshNDC(), getTexOpts(false) ); texMesh1.translate([-0.5, 0.4, 0]); scene.add(texMesh1); let texMesh2 = new TextureMesh( getMeshNDC(), getTexOpts(false) ); texMesh2.modifyTexture(1, 1); texMesh2.translate([ 0.5, 0.4, 0]); scene.add(texMesh2); let texMesh3 = new TextureMesh( getMeshNDC(), getTexOpts(false) ); texMesh3.modifyTexture(2, 1); texMesh3.translate([-0.5, -0.4, 0]); scene.add(texMesh3); let texMesh4 = new TextureMesh( getMeshNDC(), getTexOpts(false) ); texMesh4.modifyTexture(2, 2); texMesh4.translate([ 0.5, -0.4, 0]); scene.add(texMesh4); let tg1 = new TransformGroup(); tg1.add(texMesh1); tg1.add(texMesh2); tg1.add(texMesh3); tg1.add(texMesh4); tg1.translate([-1.5, 0, 0]); let texMesh5 = new TextureMesh( getMeshNDC(), getTexOpts(true) ); texMesh5.translate([-0.5, 0.4, 0]); scene.add(texMesh5); let texMesh6 = new TextureMesh( getMeshNDC(), getTexOpts(true) ); texMesh6.modifyTexture(1, 1); texMesh6.translate([ 0.5, 0.4, 0]); scene.add(texMesh6); let texMesh7 = new TextureMesh( getMeshNDC(), getTexOpts(true) ); texMesh7.modifyTexture(2, 1); texMesh7.translate([-0.5, -0.4, 0]); scene.add(texMesh7); let texMesh8 = new TextureMesh( getMeshNDC(), getTexOpts(true) ); texMesh8.modifyTexture(2, 2); texMesh8.translate([ 0.5, -0.4, 0]); scene.add(texMesh8); let tg2 = new TransformGroup(); tg2.add(texMesh5); tg2.add(texMesh6); tg2.add(texMesh7); tg2.add(texMesh8); tg2.translate([1.5, 0, 0]); }); function getMeshNDC() { return [ -0.35, 0.25, 0.0, -0.35, -0.25, 0.0, 0.35, -0.25, 0.0, 0.35, 0.25, 0.0 ]; } function getTexOpts(isFlipY) { const red = [125, 0, 0, 150]; const green = [0, 125, 0, 150]; return { texUnits: [ { texImg: { content: [ ...red, ...red, ...red, ...green, ...green, ...green ], pixelsPerRow: 3 } } ], isFlipY: isFlipY }; }

先运行应用,看效果。

共有8个纹理,左右各4个。这里通过引入TransformGroup类,目的在于将左右两组的纹理图像各整合为一组后一并整体移动。

左边一组的4个纹理不翻转Y轴,故顶行为绿色;右边一组的4个纹理翻转Y轴,故顶行为红色。

两组纹理的左上角均是原始的纹理,右上角只修改一个纹理像素,左下角修改一行中的两个纹理像素,右下角修改两行两列的纹理像素。

可以看到,texImage2D方法受UNPACK_FLIP_Y_WEBGL的影响,但texSubImage2D方法则不管该常量设置为何值,均严格依照纹理坐标系,矩形的起始角位于左下角,向上向右扩大而形成选定区域。

因此,对于已经翻转Y轴的纹理图像,需在脑海中取消其翻转,然后再从左下角开始往上往右拉出选框,才能精准地定位要修改的区域。

用另一图像内容修改

本节中,我们将加载两张图片,第1张图片用作纹理对象的内容,用第2张图片的内容来替换纹理对象的部分内容。

因为需要同时等待两张图片都加载完毕后才更换纹理图像的内容,这里使用Promise来实现此目的(参见Promise一文)。

function getImgLoadPromise(url) { return new Promise((resolve) => { let image = new Image(); image.src = url; image.onload = (evt) => { resolve(evt.target); }; }); }

当图片加载完毕后,返回一个Promise对象。

loadImageToTexture(texObj, filter) { const {gl} = this.glu; let img1 = getImgLoadPromise('imgs/large-famous-places.jpg'); let img2 = getImgLoadPromise('imgs/webgl-marble.png'); Promise.all([img1, img2]).then((imgs) => { const [img1, img2] = imgs; gl.bindTexture(gl.TEXTURE_2D, texObj.texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img1); gl.texSubImage2D( gl.TEXTURE_2D, 0, 100, 100, img2.width, img2.height, gl.RGBA, gl.UNSIGNED_BYTE, img2 ); 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(); }); }

Promise.all方法在参数为数组的所有元素都加载完毕后,才调用then方法,因此,在该方法中,可放心地访问所有图像的内容。

在该方法的回调函数中,先使用:

const [img1, img2] = imgs;

将数组imgs解构为img1img2两个变量。然后,

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img1);

先将第一张图片设置为纹理图像的内容。接着,

gl.texSubImage2D( gl.TEXTURE_2D, 0, 100, 100, img2.width, img2.height, gl.RGBA, gl.UNSIGNED_BYTE, img2 );

将纹理图像的特定区域的内容更新为第二张图片的内容。

运行应用

texSubImage2D所指定的区域不能超出纹理图像的大小。

Canvas 2D为纹理来源

Canvas 2D可以成为纹理图像的来源。这个特性让人激动。它主要解决了以下3个方面的问题:

  1. WebGL渲染中文的问题:使用Canvas 2D渲染中文即可
  2. 纹理贴图匮乏的问题:使用Canvas 2D自己创建
  3. 与现有纹理贴图整合的问题:使用多个纹理单元,让Canvas控制现有纹理贴图的亮度

本例通过使用多个纹理单元,制作出一种文字镂空的贴图效果。

片断纹理代码:

#version 300 es precision mediump float; uniform sampler2D uSampler0; uniform sampler2D uSampler1; uniform bool uIsUseTexture; in vec4 vColor; in vec2 vTexCoords; out vec4 fragColor; void main() { if (uIsUseTexture) { fragColor = texture(uSampler0, vTexCoords) * texture(uSampler1, vTexCoords); } else { fragColor = vColor; } }

客户端代码:

app.doInInitMeshes((scene) => { let texMesh = new TextureMesh( getMeshNDC(), { texUnits: [ {canvas: createCanvas(800, 500, '遥遥领先', 190)}, {texFile: 'imgs/tex1.png'} ], texCoords: TextureUtils.GetTexCoords(0, 0, 1, 1) } ); scene.add(texMesh); }); 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 Helvetica`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#111'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'hsla(150, 100%, 80%, 100%)'; ctx.fillText(text, canvas.width / 2, canvas.height / 2); return canvas; }

createCanvas函数在我们需要时就凭空创建并返回一个Canvas的实例,并根据参数,渲染一段大小合适的文本。

TexTureMeshinitTexture方法增加了处理Canvas的机制:

initTexture(isFlipY, texUnits, filter) { ... if (texObj.canvas) { this.loadCanvasToTexture(texObj); } ... } 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); }

Canvas的实例直接作为texImage2D方法的数据源即可。

loadCanvasToTexture方法与loadImageToTexture方法相似,均生成了midmap,但与后者不同的是,它直接从内存中而不是从文件中读取数据,因此,无需处理图像文件的onload事件。

运行应用。修改createCanvas函数各个参数值,观察其对文字清晰度的影响。

最值得注意的地方是Canvas的清屏语句:

ctx.fillStyle = '#111'; ctx.fillRect(0, 0, canvas.width, canvas.height);

若将ctxfillStyle的值改为#000,则背景纹理全不可见。慢慢地调亮其值,背景纹理会逐渐增亮。通过调节这些细节,可以调制我们想要的效果。

readPixels

在渲染完成后,我们可以通过调用readPixels方法从帧缓冲区中读取像素,也就是屏幕上看到什么,我们就可以都读取出来。

WebGL 1.0readPixels方法与纹理没有直接的关系,它只能将读取到的结果存进一个ArrayBufferView对象中。而WebGL 2.0可以将结果保存进绑定至PIXEL_PACK_BUFFER目标的WebGLBuffer实例中。此节中研究WebGL 1.0的版本。

帧缓冲区是存储最后渲染结果的缓冲区,可想而知,要读取这样一个缓冲区的内容,需要处理的数据量超大,且需要考虑较多的细节问题。现结合笔者所使用的Mac电脑来举例说明。该电脑的显示器为Retina,其分辨率为5120 × 2880。

Computer Display

目标设定

因为数据量很大,因此我们需要人为地大幅缩减渲染数据。我们准备在整个屏幕中只输出一个垂直的绿色的直线,然后,再读取其内容。

app.doInInitMeshes((scene) => { let mesh = new WireframeMesh( // vertices [ -0.5, 0.1, 0.0, -0.5, -0.1, 0.0 ], [0, 1], // wireframeIndices [0, 255, 0, 255] // wireframeColor ); scene.add(mesh); });

先运行应用,以了解屏幕上有哪些内容。

确定结果缓冲区大小

在上面我们看到,我们使用下面的代码来表示一个像素的颜色:

const green = [0, 255, 0, 255];

数组中4个元素分别对应于RGBA的颜色成份。每个颜色成份值域为[0, 255],共计256位,因此需要用1字节(28 = 256)来存储每个颜色成份的位数。因此共需4个字节来存储一个颜色值。也即每个像素需要4个字节的存储空间。

帧缓冲区所能渲染的像素总数为:

let totalPixels = gl.drawingBufferWidth * gl.drawingBufferHeight;

注意,Retina显示器的devicePixelRatio为2,只会导致显示器相同面积下的像素增加,但不会改变每个像素均使用4个颜色成份来表示的特点。

故所有像素所面的存储空间为:

const RGBA_COMPS_SIZE = 4; let buffer = new Uint8Array(totalPixels * RGBA_COMPS_SIZE);

我的浏览器通常置于屏幕右侧,正好占一半的屏幕宽度,因此,drawingBufferWidth的值为2560drawingBufferHeight的值为1748(因打开了浏览器中Console终端,因而变小了)。故buffer中的像素总数为:

2560 × 1748 = 4474880

而所需字节总数为:

4474880 × 4 = 17899520

现在,将帧缓冲区所有像素都读入buffer变量中:

gl.readPixels(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight, gl.RGBA, gl.UNSIGNED_BYTE, buffer);

找出首个绿色像素的位置

在帧缓冲区中,绝大多数的黑色的背景颜色,这不是我们要绘制的绿色直线的像素。因此,我们需要将首个像素在数组中的索引值找出来。

let firstIndex = 0; for (let i = 0; i < buffer.length; i += RGBA_COMPS_SIZE) { if (buffer[i] !== 0.0 || buffer[i+1] !== 0.0 || buffer[i+2] !== 0.0) { firstIndex = i; break; } } console.log('first index: ' + firstIndex); console.log(buffer[firstIndex], buffer[firstIndex + 1], buffer[firstIndex + 2], buffer[firstIndex + 3]);

只要找到RGB颜色成份中有1个成份不为0,则找到了该位置,将该索引值赋值于firstIndex的,立即退出循环。然后,为验证,我们将该索引值后续4个元素都打印出来:

0 - 191 - 0 - 255

基于颜色混合的原因,绿色的颜色值变成了上面的值。但不管如何,这确实是第一个绿色像素在数组中所在的索引值。

求出首个绿色像素所在的行与列

let firstPixelIndex = firstIndex / RGBA_COMPS_SIZE; console.log('first Pixel Pos: ' + firstPixelIndex); let nRow = Math.floor(firstPixelIndex / gl.drawingBufferWidth); console.log('row: ' + nRow); let nCol = firstPixelIndex % (gl.drawingBufferWidth); console.log('col: ' + nCol);

firstPixelIndex除以RGBA_COMPS_SIZE,得出该像素是buffer数组中第几个像素,然后,再分别除以一行中的像素数量及求余,就可得出该像素在帧缓冲区所在的行与列。例如,第809行,第953列。

将算法打包为函数

上面,我们小心翼翼地求出第一个绿色像素在帧缓冲区中的位置,现在则可以将其打包为一个函数以备重复调用。

function getFirstPixelPos() { const {gl} = GLUHolder.glu; // drawingBufferWidth means pixels in each row // drawingBufferHeight means pixels in each col let totalPixels = gl.drawingBufferWidth * gl.drawingBufferHeight; const RGBA_COMPS_SIZE = 4; let buffer = new Uint8Array(totalPixels * RGBA_COMPS_SIZE); gl.readPixels(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight, gl.RGBA, gl.UNSIGNED_BYTE, buffer); let firstIndex = 0; for (let i = 0; i < buffer.length; i += RGBA_COMPS_SIZE) { if (buffer[i] !== 0.0 || buffer[i+1] !== 0.0 || buffer[i+2] !== 0.0) { firstIndex = i; break; } } let firstPixelIndex = firstIndex / RGBA_COMPS_SIZE; let nRow = Math.floor(firstPixelIndex / gl.drawingBufferWidth); let nCol = firstPixelIndex % (gl.drawingBufferWidth); return { x: nCol, y: nRow }; }

现在,根据所得到的位置,读取帧缓冲区中相应的矩形区域的值。

function readFromFrameBuffer(x, y, width, height) { const {gl} = GLUHolder.glu; const RGBA_COMPS_SIZE = 4; let buffer = new Uint8Array(width * height * RGBA_COMPS_SIZE); gl.readPixels(x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, buffer); console.log(buffer); } app.run(); let firstPixelPos = getFirstPixelPos(); readFromFrameBuffer(firstPixelPos.x, firstPixelPos.y, 1, 5);

上面的代码,从第一个绿色像素开始,打出列宽为1、行数为5的数据。检查这些数据,如果每4个成份都均为:

0 - 191 - 0 - 255

则说明我们已从茫茫大海中找到了我们所需的数据。

通过上面的例子,我们可以厘清帧缓冲区、像素、devicePixelRatio、颜色成份、数据的存储等概念以及它们之间的关系,从而达到最高效地查找并读取数据的目的。

参考资源

  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