纹理参数
撰写时间:2023-09-24
修订时间:2026-05-30
在本章中,我们将通过一系列简单而有趣的例子来一步步探索纹理贴图的细节、原理与机制。这些内容,将为我们以后实现更强大、更灵活的纹理贴图功能打下坚实的基础。
使用类型化数组生成纹理
Uint8ClampedArray
上一章中,我们看到,除了使用图像文件外,也可以通过类型化数组来直接生成为纹理图像的内容。
变量rgbaChanels表示该数组是一个RGBA通道值。数组的4个元素分别代表r, g, b, a的通道值。因此rgbaChanels只代表了一个像素的颜色值。变量srcData以类型化数组Uint8Array的方式作为纹理图像的内容。
但使用Uint8Array作为纹理图像的内容有不易觉察的小问题:
当数据源rgbaChanels各通道的值不位于[0, 255]的范围内时,就会出问题。
Uint8Array是普通的8位无符号整数数组,范围[0, 255],溢出时会按类似于取模256的ToUint8机制处理。ECMAScript规范中ToUint8的具体步骤为:
- 将值转换为32位有符号整数(按ToInt32)。
- 将该整数视为无符号32位整数(取232的模)。
- 取结果的低32位(相当于取模0xFF)。
而Uint8ClampedArray是钳位
数组,同样存储[0, 255],但在赋值时:小于0的值变成0,大于255的值变成255,而非环绕。
可见,对于表示图像内容的数据,只需将其值钳制在[0, 255]的范围内即可,而无需进行不必要的额外数据转换。因此,对于纹理图像数据,应使用Uint8ClampedArray来存储。
实际上,Canvas的图像数据(ImageData)正是使用了Uint8ClampedArray来存储图像数据,具体参见getImageData。
类型化数组颜色表达方式
之前向顶点着色器的顶点属性aColor传递颜色数值时,其格式是[0, 1]区间的浮点数:
而gl.texImage2D方法要求颜色值位于[0, 255]的区间内:
我们也可以使用JavaScript的十六进制表示法来编写其值:
这种方式,可以很方便地分解、照抄一个使用十六进制表示法的颜色字符串。
可视化类型化数组颜色值
下面使用Uint8ClampedArray类作为纹理图像数据的容器,存储了4个像素的颜色值,并将该纹理图像数据可视化:
Uint8ClampedArray是一个一维数组,而作为纹理图像数据,则要求转换为有宽高属性值的二维数组。
Color Brewer 调色板
Color Brewer是Grpahviz使用的多组预定义好的调色板,具体可参见本站的2D全部调色板,及3D部分调色板。
下面,我们拟选用Color Brewer的spectral10这一组调色板的10种颜色来作为纹理图像内容并将其可视化。
ColorBrewer的spectral10是一个具有10个十六进制颜色值的数组。GLColors.ImgDataFromFullHexArray方法将它们转换为一个Uint8ClampedArray实例。visualize函数按所指定的宽度值及高度值,以2D形式予以可视化。
值得注意的是在第2次调用visualize函数时,要求按每行2个、共选3行共6个像素的格式来显示。显然,它是纹理图像来源中所有10个像素的子集。因为所选像素数量小于总数,因此我们可以指定从第几行像素开始选择。代码中通过实参startRow指定了从第2行开始选择。
在下节可看到,gl的texImage2D方法中的形参srcOffset与此相关。
使用 Color Brewer 调色板作为纹理图像内容
class TextureMesh extends SoleColorMesh {
constructor(vertices, texCoords) {
super(vertices);
this.initTexVAO(texCoords);
this.initTexture();
this.renderMode = Mesh.RENDER_MODE.TEXTURE;
}
initTexVAO(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;
}
initTexture() {
const {gl, program} = this.glu;
this.texVAO.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texVAO.texture);
let target = gl.TEXTURE_2D;
let level = 0;
let internalformat = gl.RGBA;
let width = 2;
let height = 3;
let border = 0;
let format = gl.RGBA;
let type = gl.UNSIGNED_BYTE;
let hexColors = ColorBrewer.spectral10;
let srcData = GLColors.ImgDataFromFullHexArray(hexColors);
let pixelIndex = 2;
const CHANELS_PER_PIXEL = 4;
let srcOffset = pixelIndex * CHANELS_PER_PIXEL * srcData.BYTES_PER_ELEMENT;
gl.texImage2D(target, level, internalformat, width, height, border, format, type, srcData, srcOffset);
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);
}
}
上面的TextureMesh类无需指定图像文件,因此构造方法中也无此信息。
texImage2D方法可将纹理对象的内容初始化为所类型化数组的内容。其原型在WebGL 2.0中定义如下:
undefinedtexImage2D
- GLenumtarget
- GLintlevel
- GLintinternalformat
- GLsizeiwidth
- GLsizeiheight
- GLintborder
- GLenumformat
- GLenumtype
- [AllowShared] ArrayBufferViewsrcData
- unsinged long longsrcOffset
- target
- 指定绑定的类型。可为TEXTURE_2D, TEXTURE_3D, TEXTURE_2D_ARRAY及TEXTURE_CUBE_MAP。
- level
- 指定mipmap纹理等级。
- internalformat
- 指定纹理内部格式。
- width
- 指定纹理图像的宽度。
- height
- 指定纹理图像的高度。
- border
- 此值须为0。
- format
- 指定像素数据的格式。
- type
- 指定像素数据的数据类型。
- srcData
- 类型为类型化数组的源数据。
- srcOffset
- 字节偏移值。可选。
参数srcData与参数type存在着直接对应的关系,如下表所示。
srcData与type对应关系表
| srcData | type |
| Int8Array | BYTE |
| Uint8Array | UNSIGNED_BYTE |
| Uint8ClampedArray | UNSIGNED_BYTE |
| Int16Array | SHORT |
| Uint16Array | UNSIGNED_SHORT |
| Uint16Array | UNSIGNED_SHORT_5_6_5 |
| Uint16Array | UNSIGNED_SHORT_5_5_5_1 |
| Uint16Array | UNSIGNED_SHORT_4_4_4_4 |
| Int32Array | INT |
| Uint32Array | UNSIGNED_INT |
| Uint32Array | UNSIGNED_INT_5_9_9_9_REV |
| Uint32Array | UNSIGNED_INT_2_10_10_10_REV |
| Uint32Array | UNSIGNED_INT_10F_11F_11F_REV |
| Uint32Array | UNSIGNED_INT_24_8 |
| Uint16Array | HALF_FLOAT |
| Float32Array | FLOAT |
对于代码:
let width = 2;
let height = 3;
let pixelIndex = 2;
const CHANELS_PER_PIXEL = 4;
let srcOffset = pixelIndex * CHANELS_PER_PIXEL * srcData.BYTES_PER_ELEMENT;
gl.texImage2D(target, level, internalformat, width, height, border, format, type, srcData, srcOffset);
参数srcData使用Uint8ClampedArray的类型化数组,共有10个像素的内容。要求按width值来排列每行,则每行2个像素,因此总的纹理图像来源宽为2,高为5。
而height只要求从中取出3行即可,则我们可以通过srcOffset来指定从第几个像素的字节偏移值开始。
运行程序texture-from-typedarray.html。
我们注意到,尽管纹理图像的尺寸很小,只有6个像素,但代码:
let texMesh = new TextureMesh(
[
-0.5, 0.5, 0.0, // V0, left-top
-0.5, -0.5, 0.0, // V1, left-bottom
0.5, -0.5, 0.0, // V2, right-bottom
0.5, 0.5, 0.0 // V3, right-top
],
[
0.0, 0.0, // 0
0.0, 1.0, // 1
1.0, 1.0, // 2
1.0, 0.0 // 3
]
);
将整个纹理图像映射整个四边型,因此纹理图像被按比例拉伸了。
纹理坐标系
利用纹理坐标值映射边角关系
现在,为简化问题,我们拟使用下面的4个像素作为纹理图像的内容:
WebGL定义了一个如下图所示的纹理坐标系:
纹理坐标系与Canvas 2D、图像文件的坐标系的朝向一致,位于笛卡尔坐标系的第四象限,X轴正半轴朝右,Y轴正半轴朝下。
但纹理坐标系又是一种NDC坐标系,X轴正半轴最大值为1.0,Y轴正半轴最大值为1.0。
现在,将纹理图像放进纹理坐标系中。
则我们可以使用(0, 0)、(0, 1)、(1, 1)及(1, 0)来分别引用图像内容的左上角、左下角、右下角及右上角。
现在,如果需将一个2D纹理图像完整地贴到一个四边形中,其实质就是将纹理坐标系的4个角分别映射至四边形的4个角,如下图所示。
而下面的代码正是完成上图所示的操作。
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8ClampedArray(
[
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, // V0, left top
-0.5, -0.5, 0.0, // V1, left bottom
0.5, -0.5, 0.0, // V2, right bottom
0.5, 0.5, 0.0 // V3, right top
],
[
0.0, 0.0, // left top
0.0, 1.0, // left bottom
1.0, 1.0, // right bottom
1.0, 0.0 // right top
]
);
运行应用。
翻转纹理坐标系的Y轴
有时,若因特殊需求而需要翻转Y轴时,可编写如下代码:
此时,纹理坐标系将因上述代码而在Y轴上进行了翻转,如下图所示:
上面的图像如实、机械地翻转了前面纹理坐标系的图像的Y轴,未对图像中的文本进行修正,因此,连文本都因翻转后而看得怪怪的。尽管如此,此时纹理坐标系左上角按逆时针方向至右上角的4个位置的坐标值分别为(0, 1)、(0, 0)、(1, 0)及(1, 1)。
现在,再将纹理图像放进上面经翻转后的纹理坐标系中以查看4个角的实际纹理坐标值:
因此,若不希望图像内容在进行纹理贴图时上下颠倒,则代码中引用的纹理坐标系也应相应调整为:
let texMesh = new TextureMesh(
//...
[
0.0, 1.0, // left top
0.0, 0.0, // left bottom
1.0, 0.0, // right bottom
1.0, 1.0 // right top
]
);
运行应用。效果应与上面例子的效果完全一样,第一行的像素颜色值应分别为红色及绿色。
加载图像文件作为纹理时,也是这个原理。
综上,当翻转Y轴时,按如下步骤来引用纹理坐标:
- 先翻转纹理坐标系。
- 将正常的图像放进翻转后的纹理坐标系中。
- 引用翻转后的纹理坐标系的新坐标值。
我个人倾向于使用不翻转Y轴的纹理坐标系,该坐标系与Canvas 2D、图像文件的坐标系完全一致,且如实地反映了数组元素按正常顺序来排列的状态。
如若有时发现纹理图像上下贴反了,则可参照本节的原理予以修正。
texture函数的细节
GLSL的texture函数的作用,是根据纹理坐标来查找纹理像素。
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 Uint8ClampedArray(
[
0xFB, 0x80, 0x72, 0xFF, // red
0xB3, 0xDE, 0x69, 0xFF, // green
0x80, 0xB1, 0xD3, 0xFF, // blue
0xBC, 0x80, 0xBD, 0xFF // magenta
]
));
对于上面的width及height,我们也可以分别指定为4和1。
为简化算法,这里只演示如何查找单个元素。例如,对于下列的数组,如何根据坐标值来查找特定的元素?
let texels = [
1, 2, 3, 4, 5,
6, 7, 8, 9, 10,
11, 12, 13, 14, 15
];
let elementsPerRow = 5;
上面elementsPerRow指定了每行有5个元素。这样,列数也随之确定下来了。现在,假设我们需要查找纹理坐标值为(0.2, 0.7)的元素。
具体代码如下:
let texels = [
1, 2, 3, 4, 5,
6, 7, 8, 9, 10,
11, 12, 13, 14, 15
];
let elementsPerRow = 5;
let value = lookupTexel(texels, elementsPerRow, 0.2, 0.7);
pc.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函数的内部细节,这样,在以后如果需要修改着色器中的代码时,我们就可以做到游刃有余、轻松应对。
只贴纹理贴图的一部分
如果只选择纹理图像右上角的区域作为贴图来源,第一步是先找出贴图范围内的4个端点位置的纹理坐标:
第二步是依序引用所找到的纹理坐标值:
let texMesh = new TextureMesh(
[
-0.5, 0.5, 0.0, // V0
-0.5, -0.5, 0.0, // V1
0.5, -0.5, 0.0, // V2
0.5, 0.5, 0.0 // V3
],
[
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]之间的小数点来表示百分比。
在后面可看到,TextureUtils类将陆续添加多种工具性质的方法。
客户端代码:
const { TextureUtils } = await import('./examples/js/esm/TextureUtils.js');
const { extend } = await import('/tutorials/webgl/js/GeoPC.js');
extend(pc);
let wholeTex = TextureUtils.GetTexCoords();
pc.logTexCoords('Texture coordinates (whole texture)', wholeTex);
let quad1Tex = TextureUtils.GetTexCoords(0.5, 0, 0.5, 0.5);
pc.logTexCoords('Texture coordinates (Quadrant 1)', quad1Tex);
let quad2Tex = TextureUtils.GetTexCoords(0, 0, 0.5, 0.5);
pc.logTexCoords('Texture coordinates (Quadrant 2)', quad2Tex);
let quad3Tex = TextureUtils.GetTexCoords(0, 0.5, 0.5, 0.5);
pc.logTexCoords('Texture coordinates (Quadrant 3)', quad3Tex);
let quad4Tex = TextureUtils.GetTexCoords(0.5, 0.5, 0.5, 0.5);
pc.logTexCoords('Texture coordinates (Quadrant 4)', quad4Tex);
如果省略参数xOffset及yOffset,则默认从取纹理图像的左上角开始选取。参数width及height如果省略,则自动取至纹理图像的右下角。最后一个参数isFlipY的值默认为false。
这里再次绘制默认纹理坐标系以检查上面各种纹理坐标值。
texture-calc-texcoords.html调用TextureUtils的GetTexCoords方法取出纹理图像第3象限范围内的各个纹理坐标值作为贴图内容,渲染结果为蓝色。
运行应用。
Geometries类有一个在控制台格式化打印纹理坐标值的静态方法。
Geometries.PrintTexCoords(texCoords);
控制台显示:
// ==================== Texture Coordinates Info ====================
// 0: (0.0, 0.5)
// 1: (0.0, 1.0)
// 2: (0.5, 1.0)
// 3: (0.5, 0.5)
// ------------------------------------------
// Total 4 groups, in forms of (s, t)
现在,纹理坐标已经可以自动自动生成。同样,鉴于经常需要编写生成一个正方形或矩形的顶点的代码,Geometries类为此提供了相应的方法:
客户端代码:
纹理何时需要重复
重复
如果我们从左上角选取,且宽度与高度均为2.0。
let texCoords = TextureUtils.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]的范围内,如果我们选取的范围超出了默认的范围,且未作任何处理,则会出现空白的纹理像素。而对这些空白的区域,GLSL的texture函数会根据:
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
的默认设定,在X轴及Y轴上通过重复粘贴原来纹理图像的内容来填充这些空白的纹理像素区域。
因此,概括起来,就是只有当我们选择纹理图像的区域超出了纹理坐标系默认的[0.0, 1.0]的范围,才会出现是否需要重复的情况。
由此可见,对于默认的坐标值范围为[0, 1]范围内的纹理坐标系,我们可称之为单位纹理坐标系。一个单位纹理坐标系对应于整个原始纹理图像。
当我们指定纹理坐标值范围为[0, 2]时,则将另行生成新的纹理图像,可称之为实际纹理图像。且实际纹理图像的内容在S轴及T轴上按上面代码的要求,重复粘帖原始纹理图像的内容,直至纹理坐标值超出所指定的范围为止。如下图所示:
最后,再将此实际纹理图像的内容粘帖至网格物体上。
因此,指定纹理坐标值的本质,就是确定新的实际纹理图像的大小及其内容的过程。最后使用实际纹理图像的内容作为纹理贴图的来源。
延伸及镜像重复
同理,当我们选择纹理图像的区域超出了纹理坐标系默认的范围,我们可以改变上面的设定,让纹理图像延伸及镜像重复:
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);
//...
let texCoords = TextureUtils.GetTexCoords(0.0, 0.0, 2.0, 2.0);
运行应用。
上面的效果,就是在纹理坐标系的S轴上,原来的纹理图像的最右侧像素的内容将一直延伸至末尾,而在T轴上,将原来的纹理图像进行翻转后再重复。
而对于一些独特的具备上下、左右都可通过镜像来重复的图片,完全使用MIRRORED_REPEAT可达到令人震撼的效果。例如,运行这个应用。如果不看代码,你可能想不到,其贴图内容仅为下面所示:
布料或瓷砖纹理文件(通过截屏而创建)
该贴图位于最终效果的左上角,然后在水平方向及垂直方向上各镜像重复6次。
类的实现代码如下:
客户端代码如下:
此应用有以下特点。
第一,因需要同时设置纹理坐标值及其他纹理参数,故将与纹理有关的构造参数均置于texOpts对象中,并且,TextureMesh的构造方法使用了带有默认值的参数解包技术。上面形参texOpts中的texImg属性就由默认值提供。
第二,FileTextureMesh继承于TextureMesh,从而将图像加载职能独立出来。
第三,客户端代码简明扼要。
框架集成的需求与概貌已经初现雏形。但后面仍需接触较多的新知识点。因此,框架集成永远都是渐进式的,永远都不会有终点。
通过此程序我们看出,尽管渲染的图案非常复杂,但纹理贴图技术使得程序运行速度非常快。这是因为:
- 我们只发送了一次渲染命令:
gl.drawElements(gl.TRIANGLES...),且仅是渲染了两个三角形而已。发送drawElements等渲染命令是束缚一个WebGL应用效能的重要因素,这种命令越少,程序效能越高。
- GPU专门对三角形的渲染方式作了极大的优化。最先进的显卡一秒可渲染2700万个三角形。因此,上面渲染两个三角形的时间,几乎不费任何时间。
- 与Canvas 2D的逐语句运行来渲染图像不同,WebGL的贴图技术先将整个贴图内容复制到WebGL管理内存中,然后再在GPU中传送这块区域的内容。学过C语言的都知道,这种技术最高效(通过调用memcpy函数)。而加上内存优化机制,如果纹理图像的内容得以先复制到GPU中处理,则效能将大大提升。
由此可见,充分使用纹理贴图技术,可让WebGL应用插上强健的翅膀。
TEXTURE_MAG_FILTER
当纹理图像尺寸小于网格物体尺寸时,将需要确定如何生成纹理插值。
上面图示,纹理图像只有2px × 2px共4个像素,设若网格物体的尺寸大小为100px × 100px时,显然,需要根据纹理图像的大小及其比例,重新生成网格物体的每个像素的颜色值。此过程称为自动生成纹理插值。
由于纹理图像尺寸小于网格物体尺寸,因此这种场合我们需要解决,在放大纹理贴图时如何生成纹理插值。
放大纹理贴图时生成纹理插值的方式只有2种:NEAREST或LINEAR(默认值)。
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 = TextureUtils.GetTexCoords(0, 0, 3, 3);
});
运行应用。
当为TEXTURE_MAG_FILTER指定NEAREST时,网格物体的每个像素的颜色值只取纹理坐标系中最靠近目标像素中心的1个纹理像素 (texture pixel,缩写为texel)的颜色值。
仍以上图为例,当需为纹理坐标值为(0.2, 0.3)的位置生成纹理插值时,该位置位于红色的纹理像素范围内,则该位置的颜色值取红色。同样,当需为纹理坐标值为(0.7, 0.4)的位置生成纹理插值时,该位置位于绿色的纹理像素范围内,则该位置的颜色值取绿色。
这种方式,适用于纹理像素比较简单、但需要较为清晰的颜色边界线的场合。例如,下面的代码渲染出国际象棋棋盘。
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8ClampedArray(
[
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 = TextureUtils.GetTexCoords(0, 0, 4, 4);
运行应用。
2 × 2的黑白相间的4个像素,在行与列上各重复4次即可。
对于上面的图像,如果我们不使用纹理技术,则需要构造许多的面,且要调用64次的drawElements方法,程序效能低下。
因此,当我们学习了新的技术后,解决问题的思路也应随之有所改变。
LINEAR
我们来看TEXTURE_MAG_FILTER的值为LINEAR时的情况。
下面使用一行两列共2个像素作为纹理图像内容。
initTexture() {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8ClampedArray(
[
0xFB, 0x80, 0x72, 0xFF, // red
0xB3, 0xDE, 0x69, 0xFF // green
]
));
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
}
app.doInInitMeshes((scene) => {
let texCoords = TextureUtils.GetTexCoords();
});
运行应用。渲染效果与下面使用CSS渐变的效果完全一致:
因为只有一行的像素,因此我们将注意力集中于如何为两列的2个像素生成纹理插值的情况。
先将一红一绿两像素平均分列在一行上。则红色占左边的50%的区域,绿色占右边的50%的区域。
取红色区域水平方向上的中心位置,则该点X轴的值为0.25,在该位置绘制所指定的红色。
同样,取绿色区域水平方向上的中心位置,则该点X轴的值为0.75,在该位置绘制所指定的绿色。
在上述两个点的位置范围内作线性渐变。CSS相应代码:
由于为TEXTURE_WRAP_S指定了REPEAT,则在X轴的值为-25%的位置环绕出现绿色,则-25%与25%之间再作一线性渐变。
同样,75%与125%之间再作一绿色到红色的线性渐变。但上面的代码不再直接指定125%的位置,而是通过手工计算这两种颜色的加权平均值,作为在100%的位置的颜色值。两种效果完全一致。
因此,当TEXTURE_MAG_FILTER的值为LINEAR时,实际上就是先将纹理图像对应地粘帖至网格物体上,然后,在各个纹理像素之间作线性渐变。各行之间的纹理像素之间也依此规律进行线性渐变。
texture-mag-filter-linear-2.html使用了两行两列的像素,且纹理坐标区域为[0, 3]:
运行应用。
texture-mag-filter-linear-checkboard.html的源代码与上面国际象棋棋盘的源代码几乎一致,只改了下面一行语句:
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
运行应用。
因此,善用纹理像素间的线性渐变,可制作出出奇制胜的效果。
TEXTURE_MIN_FILTER
当我们渲染一张图像文件的贴图,在近距离时,可能需要将贴图放大。此时由上一节中的放大纹理贴图应对。
缩小应用纹理贴图的问题
如果将镜头拉远,则纹理贴图的图像也应随之越来越小。下面使用SVG制作示意图以模拟此场景。
观察者位置位于左下角,望向远处的右上角。左下角贴图为原图大小,由于透视,右上角的贴图因位置拉远而变得非常小。
设若原始贴图的分辨率为100px × 100px。左下角的图像尺寸为100px × 100px,右上角的图像尺寸为5px × 5px。此时将带来两个问题。
第一,右上角的贴图已经变得很小,小得根本看不清钟点数字及秒针。此时,其贴图的分辨率依旧为100px × 100px,但仅是尺寸变小了。而分辨率未改变,其所占用的内存空间依旧不改变。既然在视觉上已经小得看不清了,我们仍有必要保持如此高的分辨率吗?因此,这导致了不必要的内存空间浪费。若能将分辨率下调为5px × 5px或相应的尺寸,则在有效缩减内存空间的同时,在视觉上不会有任何影响。
第二,由于原图分辨率为100px × 100px,则纹理像素数量为10000个。而右上角贴图,屏幕像素数量仅为5px × 5px。因此,将导致一个屏幕像素将覆盖多个纹理像素。若按正常的点采样或线性插值,一是许多屏幕像素的颜色值将被多次改写,造成计算资源的白白浪费。二是机械地堆砌最终的采样结果,将导致出现严重的锯齿、闪烁及摩尔纹 (Moiré pattern)。
Moiré Pattern
摩尔纹的本质就是过多的纹理像素扎堆密布在过小的空间内而出现的干扰条纹。最常见于当我们使用手机近距离拍摄电脑或电视屏幕时所得到的照片。
Mipmap
因此,当较大的纹理图像要贴至较小的区域时,我们需要根据特定算法,从原来的纹理图像中合理地提取一部分特定的纹理像素来构建较小尺寸的纹理贴图。通过Mipmap技术,WebGL可以从原始的纹理图像中自动创建一系列尺寸不断缩小的纹理图像。如下图所示。
Mipmap示意图
每个纹理图像都有一系列不同级别 (level) 的版本。最开始的级别,也就是原始的纹理图像的级别为0级 (level 0)。越往后,级别等级将自动上升,如变为1级,2级,3级等等。而随着级别的上升,所自动创建的纹理图像尺寸将越来越小,宽度高度均变为上一级尺寸的一半。当要贴图的屏幕区域变小时,WebGL会自动选择最恰当尺寸的纹理图像。正是由于存在这种动态管理机制,3D应用程序就能在绚丽与效率之间取得完美的平衡。
调用一个generateMipmap语句即可实现此目标。
客户端代码:
将纹理坐标值范围设定在[0, 3]范围内,选取一个图像文件,创建FileTextureMesh的一个实例。
TextureMesh的源代码:
当实例化TextureMesh类时,将texParams.minFilter及magFilter属性值均指定为LINEAR。
FileTextureMesh的源代码:
而在实例化FileTextureMesh时,在图像加载完毕后,先调用:
先生成第0级的纹理图像。再调用:
generateMipmap方法的作用是根据当前纹理的第0级的图像数据,自动生成一系列后续完整的mipmap层级(第1级、第2级,......等等)。因此,当调用generateMipmap方法时,必须确保当前绑定的纹理对象的第0级已经包含了有效的图像数据(尺寸、格式、像素数据都已确定)。因此,其调用次序必须放在texImage2D方法之后。
之后,代码:
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
将原来的TEXTURE_MIN_FILTER的值改为LINEAR_MIPMAP_LINEAR,使得其渲染效果在相应的层级下进行线性插值。
可为TEXTURE_MIN_FILTER设置的值有:
- NEAREST
- LINEAR
- NEAREST_MIPMAP_NEAREST
- NEAREST_MIPMAP_LINEAR
- LINEAR_MIPMAP_NEAREST
- LINEAR_MIPMAP_LINEAR
当未调用generateMipmap(未启用mipmap),可设置值为NEAREST或LINEAR。
当调用了generateMipmap(启用了mipmap),则每层内需指定过滤方式,且层与层之间也需指定过滤方式(当从一层切换至另一层时,是否需要平滑过滤?)。则常量名约定为:每层内过滤方式_MIPMAP_层级间的过滤方式。
TEXTURE_MIN_FILTER值组合
| 每层内 | 层级间 | 常量名 | 含义 | 是否默认值 |
| NEAREST | NEAREST | NEAREST_MIPMAP_NEAREST | 层内点采样,层级间点采样 | |
| LINEAR | NEAREST_MIPMAP_LINEAR | 层内点采样,层级间线性插值 | |
| LINEAR | NEAREST | LINEAR_MIPMAP_NEAREST | 层内线性插值,层级间点采样 | |
| LINEAR | LINEAR_MIPMAP_LINEAR | 层内线性插值,层级间线性插值 | ✓ |
运行应用。
可以看出,即使我们将相机的镜头推远,图像变小,但纹理图像依旧很清晰。
TEXTURE_MIN_FILTER的默认值
为保障应用程序的性能,以及取得最佳的渲染效果,WebGL的TEXTURE_MIN_FILTER的默认值为LINEAR_MIPMAP_LINEAR。这意味着WebGL总是期待开发人员默认使用mipmap。
但如果未使用mipmap,正如我们之前的大部分应用程序,我们需要将其默认值修改为:
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
// or
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
其值为NEAREST或LINEAR,其效果都一样。
如果不修改默认值,但又不调用generateMipmap方法,则纹理图像的内容为黑色。但线框、实体等其他渲染方式均正常。
运行应用。
因此,为避免出现黑屏情况,上一节在TextureMesh中将TEXTURE_MIN_FILTER的值先改为LINEAR,以确保不出现黑屏。然后,在FileTextureMesh中,待纹理图像加载完毕、填充纹理图像数据后,再将其值恢复为默认值LINEAR_MIPMAP_LINEAR。
同时使用多个纹理单元
上面的代码均只使用了一个纹理单元。如果片断着色器中同时出现了多个采样器,则意味着我们需要使用多个纹理单元,例如下面的片断着色器代码:
#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 vertices = Geo.GenSquareVertices(1.0);
let texOpts = {
texUnits: [
{texFile: 'imgs/webgl-marble.png'},
{texFile: 'imgs/tex1.png'}
]
};
let texMesh = new TextureMesh(vertices, texOpts);
scene.add(texMesh);
});
创建了一个TextureMesh类的实例,在该实例中,使用了两个纹理单元,每个纹理单元均使用各自的图像文件。
下面是TextureMesh类的代码。
在TextureMesh的构造方法中,对形参texOpts使用默认值进行了解构。
应注意的是,在解构出数组变量textUnits时,需根据不同的情况具体处理。
第一种情况是,如果客户端代码为:
则下面代码生效:
如此,textUnits的值为:
意为,只有一个纹理单元,该纹理单元将使用特定像素值来初始化纹理图像内容。
第二种情况,当客户端代码为:
客户端不关心初始化的像素值,但以对象数组的方式指定了两个纹理图像的来源。
此时,上面texUnits的值将覆盖TextureMesh构造方法中的默认值。从而造成每个对象仅有texFile属性值,但缺失initialPixels属性值。
故此,在构造方法中,下面代码检查并补上缺失的属性值:
由此,不管客户端如何调用,每个texUnit都会有initialPixels属性值;而如果客户端同时指定texFile属性值,则自动加上该属性值。从而确保每个texUnit的内部状态均被设置妥当。
当对象存在多重层级,我们在为其设置解构的默认值时应清楚此细节。
在initTexVAO方法中,将纹理单元的值也存进texVAO的texUnits属性中。
initTexVAO(texUnit, 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方法中,对于每个纹理单元,先使用初始化像素填充纹理图像内容,以改善等待图像加载时的用户体验。之后,如果存在texFile的属性值,则将图像文件的内容加载至纹理中,并应用上一节所谈到的Mipmap技术。
而在上一章中,我们已经看到,WebGLUtils类的renderVAO方法已经为此做好了准备:
renderVAO(vao) {
const { gl, program } = this;
if (!vao.ibo) {
throw new Error('Usage of IBO is strongly recommended.');
}
if (vao.type === VAO_TYPE.TEXTURE_VAO) {
gl.uniform1i(program.uIsUseTexture, true);
} else {
gl.uniform1i(program.uIsUseTexture, false);
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vao.ibo);
let renderType = vao.type === VAO_TYPE.WIREFRAME_VAO ? vao.ibo.wireframeRenderType : gl.TRIANGLES;
if (vao.type === VAO_TYPE.SOLID_VAO || vao.type === VAO_TYPE.WIREFRAME_VAO) {
gl.drawElements(renderType, vao.ibo.indicesNum, gl.UNSIGNED_SHORT, U16B_SIZE * 0);
return;
}
if (vao.type !== VAO_TYPE.TEXTURE_VAO) {
throw new Error('Unimplemented for VAO type: %d', vao.type);
}
if (!vao.texUnits) {
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(program[`uSampler0`], 0);
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);
}
在上述渲染方法中,如果形参vao存在texUnits属性,则说明客户端使用了多个纹理单元,对于每个纹理单元,激活它,设置相应的采样器,然后再绑定相应的纹理对象。
运行应用。
在最后的贴图效果中,两个图像的颜色很完美地融合到一起。这正是片断着色器中使用两颜色相乘的效果:
fragColor = texture(uSampler0, vTexCoords) * texture(uSampler1, vTexCoords);
上面的代码显得很长,但全是默默地提供后台服务的代码。正是有了这些强健而灵活的基础性代码,我们的客户端代码就得以最终简化为:
let vertices = Geo.GenSquareVertices(1.0);
let texOpts = {
texUnits: [
{texFile: 'imgs/webgl-marble.png'},
{texFile: 'imgs/tex1.png'}
]
};
let texMesh = new TextureMesh(vertices, texOpts);
意为,使用两个纹理单元,各自带有其图像文件。没有多余的废话。
改变纹理图像的内容
createTexture创建纹理对象,texImage2D为纹理对象分配内存空间且设置这些内存空间中的数据值。之后,我们还可以通过texSubImage2D方法修改纹理对象的一部分内存数据的值。
用类型化数组修改
首先,为TextureMesh增加一个可以修改纹理内容的方法modifyTexture。
modifyTexture(width, height) {
const {gl} = this.glu;
gl.bindTexture(gl.TEXTURE_2D, this.texVAO.texUnits[0].texture);
let xoffset = 1;
let yoffset = 0;
const blue = [0, 0, 125, 150];
let buffer = blue.repeat(width * height);
let srcData = new Uint8ClampedArray(buffer);
gl.texSubImage2D(
gl.TEXTURE_2D, 0,
xoffset, yoffset, width, height,
gl.RGBA, gl.UNSIGNED_BYTE, srcData, 0
);
gl.generateMipmap(gl.TEXTURE_2D);
gl.bindTexture(gl.TEXTURE_2D, null);
}
为简单起见,我们这里只使用一个纹理单元。modifyTexture方法将修改纹理图像中一个矩形区域内的像素值。该矩形从xoffset、yoffset的位置开始,矩形宽高值由参数width及height指定。所指定矩形区域的内容,统一使用蓝色进行填充。
texSubImage2D方法的原型与texImage2D方法的原型相似,但中间多了xoffset及yoffset两个参数,与参数width及height一起,用以构建一个矩形。将srcData所指定的类型化数组的内容,填充到绑定到TEXTURE_2D的当前纹理对象中,从而达到改变纹理图像的目的。因此,该方法的重点在于如何确定一个目标区域的矩形。
调用了texSubImage2D方法后,纹理图像内容已经改变,因此需再次调用generateMipmap方法重新生成mipmap。
下面是客户端的代码。
class TransformGroup {
meshes = [];
add(mesh) {
this.meshes.push(mesh);
}
translate(vector) {
this.meshes.forEach((mesh) => {
mesh.translate(vector);
});
}
}
app.doInInitMeshes((scene) => {
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 Geo.GenRectVertices(0.7, 0.5);
}
function getTexOpts(isFlipY) {
const red = [125, 0, 0, 150];
const green = [0, 125, 0, 150];
return {
texUnits: [
{
initialPixels: {
content: [
...red, ...red, ...red, ...red,
...green, ...green, ...green, ...green
],
pixelsPerRow: 4
}
}
],
isFlipY: isFlipY
};
}
先运行应用,看效果。
texSubImage2D
共有8个纹理,左右各4个。这里通过引入TransformGroup类,目的在于将左右两组的纹理图像各整合为一组后一并整体移动。
左边一组的4个纹理不翻转Y轴,故顶行为红色;右边一组的4个纹理翻转Y轴(但仍使用默认的纹理坐标值),故顶行为绿色。
在每一组的纹理中,左上角均是原始的纹理,右上角只修改一个纹理像素,左下角修改一行中的两个纹理像素,右下角修改两行两列的纹理像素。
在TextureMesh的modifyTexture方法中,变量xoffset的值均为1,变量yoffset的值均为0。因此我们看到,蓝色区域所代表的矩形均从第1列、第0行开始。
可见,在texSubImage2D方法中,参数xoffset, yoffset, width及height的值均以像素为单位。
用另一图像内容修改
本节中,我们将加载两张图片,第1张图片用作纹理对象的内容,用第2张图片的内容来替换纹理对象的部分内容。
客户端代码:
texFile是原始纹理图像文件,replaceFile是拟覆盖部分内容的纹理图像文件。
因为需要同时等待两张图片都加载完毕后才更换纹理图像的内容,这里使用Promise来实现此目的(参见Promise一文)。
function getImgLoadPromise(url) {
return new Promise((resolve) => {
let image = new Image();
image.src = url;
image.onload = (evt) => {
resolve(evt.target);
};
});
}
当图片加载完毕后,返回一个Promise对象。
TextureMesh加载纹理图像文件的代码如下:
loadImageToTexture(texUnit, texParams) {
const {gl} = this.glu;
let img1 = getImgLoadPromise(texUnit.texFile);
let img2 = getImgLoadPromise(texUnit.replaceFile);
Promise.all([img1, img2]).then((imgs) => {
const [img1, img2] = imgs;
gl.bindTexture(gl.TEXTURE_2D, texUnit.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, texParams.minFilter);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, texParams.magFilter);
gl.bindTexture(gl.TEXTURE_2D, null);
ViewportManager.instance.doOnCameraViewChanged();
});
}
Promise.all方法在参数为数组的所有元素都加载完毕后,才调用then方法,因此,在该方法中,可放心地访问所有图像的内容。
在该方法的回调函数中,先使用:
const [img1, img2] = imgs;
将数组imgs解构为img1及img2两个变量。然后,
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个方面的问题:
- 在WebGL渲染中文的问题:使用Canvas 2D渲染中文即可
- 纹理贴图匮乏的问题:使用Canvas 2D自己创建
- 与现有纹理贴图整合的问题:使用多个纹理单元,让Canvas 2D控制现有纹理贴图的亮度
本例通过使用多个纹理单元,制作出一种文字镂空的贴图效果。
片断着色器代码:
#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;
}
}
客户端代码:
createCanvas函数在我们需要时就凭空创建并返回一个Canvas的实例,并根据参数,在一个较黑的背景中渲染一段大小合适的文本。
这里需注意,上面代码只是创建了canvas实例,但没有将其添加到DOM树中(无此必要)。因此其clientWidth及clientHeight属性值均为0。所以我们直接让形参width及height乘以devicePixelRatio后,向canvas的width及height属性赋值。然后,调用ctx的scale方法,以获取高分辨率效果。而在ctx的fillRect及fillText方法中,却只能传入未放大的形参width及height。具体原因,详见Canvas 2D 概述。
TextureMesh的initTexture方法分别应对加载正常图像文件及加载Canvas内容:
initTexture(isFlipY, texUnits, texParams) {
//...
if (texUnit.texFile) {
this.loadImageToTexture(texUnit, texParams);
}
if (texUnit.canvas) {
this.loadCanvasToTexture(texUnit, texParams);
}
}
loadImageToTexture(texUnit, texParams) {
const {gl} = this.glu;
let image = new Image();
image.src = texUnit.texFile;
image.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texUnit.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, texParams.minFilter);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, texParams.magFilter);
gl.bindTexture(gl.TEXTURE_2D, null);
ViewportManager.instance.doOnCameraViewChanged();
};
}
loadCanvasToTexture(texUnit, texParams) {
const {gl} = this.glu;
gl.bindTexture(gl.TEXTURE_2D, texUnit.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texUnit.canvas);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, texParams.minFilter);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, texParams.magFilter);
gl.bindTexture(gl.TEXTURE_2D, null);
}
在loadCanvasToTexture方法中,将Canvas的实例直接作为texImage2D方法的数据源即可。
loadCanvasToTexture方法与loadImageToTexture方法相似,均生成了mipmap,但与后者不同的是,它直接从内存中而不是从文件中读取数据,因此,无需处理图像文件的onload事件。
运行应用。修改createCanvas函数各个参数值,观察其对文字清晰度的影响。
最值得注意的地方是Canvas的清屏语句:
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, width, height);
若将ctx的fillStyle的值改为#000
,则背景纹理全不可见(但文字镂空部分不受影响)。慢慢地调亮其值,背景纹理会逐渐增亮。实际上,这是蒙版的应用:白色将完全透出背景,黑色将完全覆盖背景,中间值则若隐若现。关于蒙版,详见SVG中的mask。通过调节这些细节,可以调制我们想要的效果。
readPixels
在渲染完成后,我们可以通过调用readPixels方法从帧缓冲区中读取像素,也就是屏幕上看到什么,我们就可以都读取出来。
WebGL 1.0的readPixels方法与纹理没有直接的关系,它只能将读取到的结果存进一个ArrayBufferView对象中。而WebGL 2.0可以将结果保存进绑定至PIXEL_PACK_BUFFER目标的WebGLBuffer实例中。此节中研究WebGL 1.0的版本。
帧缓冲区是存储最后渲染结果的缓冲区,可想而知,要读取这样一个缓冲区的内容,需要处理的数据量超大,且需要考虑较多的细节问题。现结合笔者所使用的 iMac 电脑来举例说明。该电脑的显示器为Retina,其最高分辨率为5120 × 2880。

目标设定
因为数据量很大,因此我们需要人为地大幅缩减渲染数据。我们准备在整个屏幕中只输出一个垂直的绿色的直线,然后,再读取其内容。
app.doInInitMeshes((scene) => {
let mesh = new WireframeMesh(
// vertices
[
-0.5, 0.1, 0.0,
-0.5, -0.1, 0.0
],
[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;
注意,若devicePixelRatio值为2,只会导致在内存中使用更多的数组元素来渲染为一个屏幕像素,但不会改变每个像素均使用4个颜色成份来表示的特点。
故所有像素所占的存储空间为:
const RGBA_COMPS_SIZE = 4;
let buffer = new Uint8ClampedArray(totalPixels * RGBA_COMPS_SIZE);
现在,将帧缓冲区所有像素都读入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.0,则找到了该位置,将该索引值赋值于firstIndex后,立即退出循环。然后,为验证,我们将该索引值后续4个元素值都打印出来:
0 - 191 - 0 - 255
因为WebGL默认开启抗锯齿(antialias: true)而导致颜色混合的原因,绿色的颜色值变成了上面的值。WebGLUtils的getContext方法代码为:
若将上面这行代码改为:
即初始化环境时关闭抗锯齿功能,将可看到原始的颜色值。
但不管如何,这确实是第一个绿色像素在数组中所在的索引值。
求出首个绿色像素所在的行与列
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 Uint8ClampedArray(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 Uint8ClampedArray(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、颜色成份、数据的存储等概念以及它们之间的关系,从而达到最高效地查找并读取数据的目的。
实际上,上面的代码只是找到了帧缓冲区中一部分有颜色值的地方,而非全部有颜色值的地方。因为一旦涉及到devicePixelRatio,情况将变成非常复杂。具体详见getImageData。