仓库源文站点原文

WebGL 纹理详解

Buffer(数据缓冲区)与 Texture(纹理)是 WebGL 程序的两大数据来源。Buffer 可以通过 ArrayBuffer 或更语义化的 TypedArray 来构造;而 Texture 在大多数情况下,是通过 Image 对象来构造的。在构造和使用 Texture 的过程中,需要确定很多选项来以不同的方式构造 Texture;这些选项之间有着各种各样的关系,或互相依赖,或互相排斥,或互相影响。最近,我又重新梳理了一遍我所用到的 WebGL 纹理各种参数的影响,稍作整理,以防遗忘。

为此,我专门编写了一个 Demo,点击下方链接下载。Demo 页的右上角有一个使用 dat.GUI 生成的控件,其中列举了影响纹理的一些选项。这篇文章将逐个讨论这些选项的作用和相互关系。

<a href="https://xieguanglei.oss-cn-hangzhou.aliyuncs.com/blog-post/2018-12-4/webgl-texture.zip">点此下载示例代码</a>

Wrap

在 JavaScript 中,创建纹理的基本流程大约如下所示:

// 激活纹理单元
gl.activeTexture(...);

// 创建和绑定
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

// 参数设置
gl.texParameteri(...);
gl.texParameteri(...);

// 填充纹理的内容
gl.texImage2D(..., image);

// 通过纹理单元将纹理传送给着色器程序
gl.uniform1i(...);

然后,在着色器中,使用一个坐标 (x,y) 从纹理上取色。

vec4 color = texture2D(texture, vec2(x, y));

通常,从纹理上取色的坐标 x 和 y 的值均在 0~1 之间。Wrap 配置项规定了当取色坐标的坐标取值在 (0, 1) 之外(即试图从纹理图片之外取色)时,应该如何取色。Wrap 有两种:切割(CLAMP)和重复(REPEAT)。我们可以操作上面的 Demo,调整 scale 的值使得纹理图片缩小一些,然后切换 Wrap 配置项为 CLAMP 或 REPEAT,可以发现:当选项置为 CLAMP 时,从纹理外部取色会落到对应的纹理的边缘,比如 texture2D(texture, vec2(2.2, 0.5)) 的值会等于 texture2D(texture, vec2(1.0, 0.5));而选项置为 REPEAT 时,纹理会「平铺」开来,从外部取色会把取色坐标按1取模,映射到纹理内部,比如 texture2D(texture, vec2(2.2, 0.5)) 的值会等于 texture2D(texture, vec2(0.2, 0.5))

切换 Wrap 配置项的代码如下:

// CLAMP
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

// REPEAT
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

下图是 REPEAT 的情况:

repeat

下图是 CLAMP 的情况:

clamp

有一点需要注意的是,REPEAT 模式对纹理图片的尺寸有要求,宽度和高度必须为 2 的整数次幂,如 32x32 或 1024x256 的图片都是符合要求的,但 500x512 或 300x300 是不符合的。我们可以将控件中的 size 从 512 改成 300,这时 Demo 将加载这张图片的一个尺寸为 300x300 的替代品作为纹理。如果 Wrap 为 CLAMP,我们会发现稍微纹理模糊了一些,但是如果 Wrap 为 REPEAT,则会报警并黑屏。

FlipY

FlipY 是 WebGL 的一个全局配置项(准确地说,它的名称是 UNPACK_FLIP_Y_WEBGL),在本文的范畴中,它主要影响了 texImage2D() 方法的行为。

按照 OpenGL 的惯例,当着色器调用 texture2D(t, st) 方法从纹理中取色时,传入的取色坐标 st 的原点是左下角。换言之,如果传入的坐标是 (0, 0),那么 OpenGL 期望取到的是左下角的那个像素的颜色。但是,由于在 Web 上图片数据的存储是从左上角开始的,传入坐标 (0,0) 时,实际上会取到左上角坐标的值。如果我们设置了 FlipY 配置项,那么在向纹理中加载数据的时候,就会对数据作一次翻转,使纹理坐标原点变为左下角。

开启 FlipY 的代码是:

gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

在 Demo 中,默认情况下 FlipY 配置项是勾选的,如果你勾选取消,会发现纹理图片被翻转了。下图是取消 FlipY 配置后的情况。

cancel-flipY

MIN_FILTER 和 MAG_FILTER

一个纹理是由离散的数据组成的,比如一个 2x2 的纹理是由 4 个像素组成的,使用 (0,0)、(0, 1) 等四个坐标去纹理上取样,自然可以取到对应的像素颜色;但是,如果使用非整数坐标到这个纹理上去取色。比如,当这个纹理被「拉近」之后,在屏幕上占据了 4x4 一共 16 个像素,那么就会使用 (0.33,0) 之类的坐标去取值,如何根据离散的 4 个像素颜色去计算 (0.33,0) 处的颜色,就取决于参数 MAG_FILTER。在 WebGL 中设置这两个项的代码如下所示:

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(
    gl.TEXTURE_2D, 
    gl.TEXTURE_MIN_FILTER, 
    gl.LINEAR_MIPMAP_LINEAR
);

MAG_FILTER 有两个可选项,NEAREST 和 LINEAR。顾名思义,NEAREST 就是去取距离当前坐标最近的那个像素的颜色,而 LINEAR 则会根据距离当前坐标最近的 4 个点去内插计算出一个数值,如图所示。

MAG_FILTER

显然 NEAREST 的运行速度更快,但 LINEAR 的效果更好。使用 NEAREST 时,当纹理被拉得比较近,颗粒感会比较明显,而使用 LINEAR 则会顺滑一些。你可以切换右上角空间上的 MAG_FILTER 选项,然后拖动 scale 滑块将纹理放大,体会一下两者的差别。

下图是 MAG_FILTER 为 NEAREST 的效果:

MAG_FILTER_NEAREST

下图是 MAG_FILTER 为 LINEAR 的效果:

MAG_FILTER_LINEAR

MAG_FILTER 作用于将纹理拉近/放大的情形,而当纹理远离/缩小的时候,起作用的是 MIN_FILTER。MIN_FILTER 有以下 6 个可选配置项:

前两个配置项和 MAG_FILTER 的含义和作用是完全一样的。但问题是,当纹理被缩小时,原纹理中并不是每一个像素周围都会落上采样点,这就导致了某些像素,完全没有参与纹理的计算,新纹理丢失了一些信息。假设一种极端的情况,就是一个纹理彻底缩小为了一个点,那么这个点的值应当是纹理上所有像素颜色的平均值,这才比较合理。但是 NEAREST 只会从纹理中取一个点,而 LINEAR 也只是从纹理中取了四个点计算了一下而已。这时候,就该用上 MIPMAP 了。

Mipmap 和 MIN_FILTER

为了在纹理缩小也获得比较好的效果,需要按照采样密度,选择一定数量(通常大于 LINEAR 的 4 个,极端情况下为原纹理上所有像素)的像素进行计算。实时进行计算的开销是很大的,所有有一种称为 MIPMAP(金字塔)的技术。在纹理创建之初,就为纹理创建好 MIPMAP,比如对 512x512 的纹理,依次建立 256x256(称为 1 级 Mipmap)、128x128(称为 2 级 Mipmap) 乃至 2x2、1x1 的纹理。实时渲染时,根据采样密度选择其中的某一级纹理,以此避免运行时的大量计算。

Mipmap

显而易见的是,使用 MIPMAP 同样需要纹理尺寸为 2 的整数次幂。

WebGL 中,可以通过调用以下方法来生成 mipmap。

gl.generateMipmap(gl.TEXTURE_2D);

我们将控制面板中的 generateMipmap 打开,并把 scale 调到一个较小的值(比如 0.5),然后切换 MIN_FILTER 的各种设置,就可以观察到 mipmap 的效果了。

下图是 MIN_FILTER 为 LINEAR 的效果(NEAREST 效果是类似的):

MIN_FILTER_LINEAR

下图是 MIN_FILTER 为 LINEAR_MIPMAP_NEAREST 的效果(LINEAR_MIPMAP_LINEAR 效果是类似的):

MIN_FILTER_MIPMAP

可以看到当采用 MIPMAP 时,纹理平滑了很多(特别是头发部分)。

MIN_FILTER 的默认值是 LINEAR_MIPMAP_NEAREST。在 XXX_MIPMAP_XXX 的格式中,前一个 XXX 表示在单个 MIPMAP 中取色的方式,与单独的 LINEAR 或 NEAREST 类似,而后一个 XXX 表示,随着采样密度连续变化(通常是因为缩放因子连续变化)时,是否在多层 MIPMAP 之间内插。使用 MIPMAP 时,后一个 LINEAR 比较重要,只要后者是 LINEAR,前者的意义其实并不特别大,所以默认选项 NEAREST_MIPMAP_LINEAR 也是最常用的。

自定义 Mipmap

我们可以使用 gl.generateMipmap() 方法来根据原始纹理生成一套 mipmap,这种生成的方式是默认的。但是实际上,我们还有一种更灵活的方式来自定义 Mipmap,那就是直接传入另一张图片。比如,这里我们使用的是一张 512x512 的 lena 图,调用 gl.generateMipmap() 会生成 256x256、128x128 直至 1x1 等一系列图片。但是,如果我们手头正好有一张 128x128 尺寸的图片,我们就可以强制指定这张图片作为原始纹理的 1 级 Mipmap。

Custom Mipmap

自定义 Mipmap 纹理的代码如下所示:

// 首先要调用此方法
// 如果在 texImage2D 后调用,则定制的 mipmap 会被覆盖
gl.generateTexture(gl.TEXTURE_2D);

gl.texImage2D(
    gl.TEXTURE_2D,
    1,  // 就是这个参数指定几级 Mipmap
    gl.RGBA,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    image // 尺寸必须严格符合指定级别 Mipmap 应有的尺寸
);

当我们指定好自定义的 Mipmap 纹理后(即勾选 customMipmap),同时勾选 generateMipmap,并且保证 MIN_FILTER 在 XXX_MIPMAP_XXX 的配置上,此时拖动 scale 滑块,会发现在缩小过程中的某个过程,纹理会变成自定义图片。当 MIN_FILTER 在 XXX_MIPMAP_NEAREST 时,纹理是「突变」的;而当 MIN_FILTER 位于 XXX_MINMAP_LINEAR 时,纹理是「渐变」的,这也印证了前面关于 XXX_MIPMAP_XXX 的解释。

下图是 MIN_FILTER 为 LINEAR_MIPMAP_NEAREST 时缩放的效果:

CustomMipmap1

下图是 MIN_FILTER 为 LINEAR_MIPMAP_LINEAR 时缩放的效果:

CustomMipmap2

SRGB 扩展

RGB 颜色空间是为人眼设计的。但是人眼感光强度和真实光线的物理强度并不是线性关系,这是人眼的生理结构所决定的。比如,在人眼看来,屏幕上显示的白色 #FFFFFF 的亮度,是半灰色 #888888 亮度的 2 倍,但是两者真实的光线物理强度(直射下单位面积上受光照的功率)并不是 2 倍关系。所以,当着色器需要基于一些物理规律来计算颜色时,直接取纹理的颜色没有意义,需要作一次转换。

一个经验是,将人眼感知的颜色归一化到 (0,1) 之间,然后取 2.2 次方幂,得到的结果与光线物理强度是呈线性关系的。如下所示,横轴是 RGB 色彩空间,蓝色直线是人眼的感光曲线,红色曲线是光线物理强度曲线。

SRGB

SRGB 扩展所做的就是这件事,在用户调用 texImage2D 向纹理中传输数据时,将纹理的每个像素颜色取了自己的 2.2 次方幂。开启 SRGB 扩展的代码如下所示:

const SRGBExtension = gl.getExtension("EXT_SRGB");

gl.texImage2D(
    gl.TEXTURE_2D, 
    0, 
    // 接下来两个参数原本是 gl.RGBA
    SRGBExtension.SRGB_ALPHA_EXT, 
    SRGBExtension.SRGB_ALPHA_EXT, 
    gl.UNSIGNED_BYTE, 
    image
);

在控件中勾选 SRGB,会发现纹理变暗了很多,因为颜色作为 [0,1] 之间的数值,取了自己的 2.2 次方幂,自然变小了。其实,如果不使用 SRGB 扩展,在着色器中也可以模拟。我们可以取消勾选 SRGB 选项,然后将 postProcess(后处理)项置为 c^2.2,画面也同样变暗了。postProcess 是这个 Demo 埋在 Shader 中的一个普通 uniform 变量,为最后颜色输出增加一个可选的环节(取自己的 2.2 次方幂,或者取自己的 1/2.2 次方幂)。

着色器中有关 postProcess 的代码如下:

if(uPostProcess == 1){
    gl_FragColor = vec4(pow(gl_FragColor.rgb, vec3(1.0/2.2)), 1.0);
}else if(uPostProcess == 2){
    gl_FragColor = vec4(pow(gl_FragColor.rgb, vec3(2.2)), 1.0);
}

下图是 SRGB 模式的效果,postProcess 选择 c^2.2 能达到同样的效果。

SRGB

当然,我们也可以勾选 SRGB,然后在 postProcess 中选择取 1/2.2 次方幂,这样输出的颜色和最初的几乎一样。SRGB 扩展将纹理颜色值压低,后处理又把输出的颜色值调亮,两者相互抵消。这正是 PBR 渲染的常用做法,先把纹理颜色从人眼颜色空间的纹理压低成物理空间,然后进行一系列物理规律运算,最后输出时再调亮成人眼颜色空间。

下图是 SRGB + postProcess c^1/2.2 的效果,和不使用 SRGB 和 postProcess 的效果基本一致:

SRGB_PostProcess

Tex_Lod 扩展

之前说过,从 Mipmap 中的哪一级纹理取色,取决于在此纹理上采样的密度。但是有了 Tex_Lod 扩展,可以不受此限制,在着色器中直接编码指定从某一级纹理取色。

开启 Tex_Lod 扩展:

gl.getExtension("EXT_shader_texture_lod");

着色器中也需要加入开启 #extension 宏:

#extension GL_EXT_shader_texture_lod: enable

在着色器中,可调用 texture2DLodExt 函数来从指定级别的 Mipmap 上取色。

gl_FragColor = texture2DLodEXT(uTexture, st, uExtLodLevel);

在右上角的控制器中,确保 Mipmap 相关的选项都打开,然后打开 extLod,拖动 extLodLevel 滑块,可见虽然纹理本身没有在缩放,但是纹理似乎变模糊了,此时实际上就是取到级别较高的 Mipmap 中去了,因为 Mipmap 的级别越高,像素数量越少,在尺寸不变的情况下,就会变模糊了。

下图是 extLod 开启后,在不依赖纹理的缩放导致采样密度变化的情况下,直接手动编码来从不同级别的 Mipmap 上取色的情况:

extLod

小结

至此,完成了上述所有配置项的讨论。

(完)