纹理加载

纹理 (texture) 是 OpenGL 中不可或缺的一部分,虽然我们可以使用 RGB 去表达几乎所有颜色,但如果真的要亲手编写每个像素的 RGB 颜色,这实在是太繁琐了。纹理则可以大大提高效率,我们可以使用纹理去模拟真实世界,去存储数据信息等等。

纹理映射

在 3D 世界中,除了模型本身,最关键的就是纹理/材质了。这就涉及了一个关键技术:纹理映射(texture mapping)。简单来说,就是将一张图片根据一定规则贴在几何图形表面,这张图片就是纹理图像。光栅化之后每个像素的颜色,就取自这张图片相应的位置的颜色,组成纹理图像的像素又被称为纹素 (texels),每一个纹素的颜色都使用 RGB 或 RGBA 格式编码。

例图来自 pixel-anatomy

既然提到映射,自然是少不了坐标的。之前我们已经介绍过 Canvas 2D 和 WebGL 坐标的差异了,而纹理坐标和前两者又不一样:

假设我们想把这张图片铺满整个 WebGL 视窗,则需要映射坐标点:

我们依旧通过缓冲区对象同时保存顶点坐标和纹理坐标(对于 2D 平面可以只使用 xy 坐标而不是 xyz,减少传输数据量):

var verticesTexCoords = new Float32Array([
    // 顶点坐标,   纹理坐标
    -1.0,  1.0,   0.0, 1.0,
    -1.0, -1.0,   0.0, 0.0,
    1.0,  1.0,   1.0, 1.0,
    1.0, -1.0,   1.0, 0.0
])

然后在着色器代码中,获取传入的纹理坐标:

<!-- 定义顶点着色器 -->
<script id="vertexShader" type="x-shader/x-vertex">
    // 定义浮点数精度
    // highp:32 位浮点格式,适合用于顶点变换。性能最慢
    // mediump:16 位浮点格式,适用于纹理坐标。比 highp 大约快两倍
    // lowp:10 位的顶点格式,适合颜色,照明等高性能计算,速度大约是 highp 的 4 倍
    precision mediump float;

    attribute vec2 a_Position;
    attribute vec2 a_TexCoord;    // 纹理坐标
    varying vec2 v_TexCoord;      // 纹理坐标

    void main() {
        gl_Position = vec4(a_Position, 1.0, 1.0);
        v_TexCoord = a_TexCoord;
    }
</script>

<!-- 定义片元着色器 -->
<script id="fragmentShader" type="x-shader/x-fragment">
    precision mediump float;

    varying vec2 v_TexCoord;      // 获取纹理坐标

    void main() {
        gl_FragColor = vec4(v_TexCoord.xy, 1., 1.);    // 使用 vec3(xy,1) 坐标组成 rgb 颜色
    }
</script>

通过 v_TexCoord 我们可以得到 x、y 轴从 0~1 的取值,将 xyz 作为 rgb 的值,可以得到很好看的颜色渐变。

See the Pen 1. Load Texuture (part1) by ShaderFans (@shaderfans) on CodePen.

其实对于 2D 矩形纹理映射,还可以通过另外一种方式来生成坐标:

<!-- 定义顶点着色器 -->
<script id="vertexShader" type="x-shader/x-vertex">
    precision mediump float;
    attribute vec2 a_Position;

    void main() {
        gl_Position = vec4(a_Position, 1.0, 1.0);
        v_TexCoord = a_TexCoord;
    }
</script>

<!-- 定义片元着色器 -->
<script id="fragmentShader" type="x-shader/x-fragment">
    precision mediump float;
    vec2 resolution = vec2(500.0, 300.0);

    void main() {
        vec2 uv = gl_FragCoord.xy / resolution;
        gl_FragColor = vec4(uv, 1., 1.);
    }
</script>

不需要传递 a_TexCoord 纹理坐标,但需要传入画布的分辨率。gl_FragCoord 是 Shader 的内置变量,它的 x 和 y 值是当前窗口空间坐标 (Window-Space Coordinate)。如果我们的窗口是 800×600 的,那么 x 的范围就在 0~800,y 则是 0~600。通过除以宽高分辨率得到了 0~1 的坐标,相当于归一化。我们习惯给纹理坐标命名为 st 或者 uv

这种方式仅限于给 2D 矩形添加纹理,假设我们要给复杂的 3D 图形贴纹理,还是需要通过纹理坐标来实现。一般的 3D 建模软件都可以输出模型对应的纹理坐标,如下所示为文本编辑器打开的 3D 模型(.obj 格式),v 表示顶点,vt 表示纹理坐标:

......
g default
v 0.556468 -3.694208 -0.180808
v 0.473360 -3.694208 -0.343916
v 0.343916 -3.694208 -0.473360
.....
vt 0.000000 0.050000
vt 0.050000 0.050000
vt 0.100000 0.050000
.....

纹理配置

解决了坐标映射之后,就是加载纹理和配置纹理了。我们先看着色器要怎么写:

<!-- 顶点着色器没变化 -->
<!-- 定义片元着色器 -->
<script id="fragmentShader" type="x-shader/x-fragment">
    precision mediump float;

    varying vec2 v_TexCoord;
    uniform sampler2D u_Sampler;

    void main() {
        vec4 texColor = texture2D(u_Sampler, v_TexCoord);
        gl_FragColor = texColor;
    }
</script>

首先看 uniform sampler2D u_Sampler:Sampler(采样器)是 Shader 提供的可供纹理对象使用的内建数据,它通常是在片元着色器中内定义,被 uniform 修饰符修饰。Sampler2D 代表一个二维的纹理类型,同理还有 Sampler1D、Sampler3D 表示不同维度的纹理类型。

然后是vec4 texture2D(sampler2D sampler, vec2 coord) :这个函数返回的就是纹素,即 coord 二维坐标对应的图片的颜色值。在 OpenGL ES 3.0 之后(即 WebGL 2.0),使用 texture() 替代 texture2D(),为保证向后兼容,目前的 Demo 依旧采用旧的写法。

接下来是 JavaScript 代码,首先加载一张图片:

var image = new Image()
image.crossOrigin = "Anonymous"     // 解决图片跨域
image.src = 'https://shaderfans.com/images/figure1.jpg'

image.onload = function() {
	// 图片加载完成后才能继续往下操作
	// 可以采用 Promise 等方式解决多张图片加载的异步回调问题
}

加载完图片后,就是 WebGL 的纹理配置了:

// 创建纹理对象
var texture = gl.createTexture()
if (!texture) {
    console.log('Failed to create the texture object')
    return false
}

// 获取第纹理图的存储位置
var u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler')
if (!u_Sampler) {
    console.log('Failed to get the storage location of u_Sampler')
    return false
}

// 翻转图片 Y 轴
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);

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

// 绑定纹理对象
gl.bindTexture(gl.TEXTURE_2D, texture)

// 设置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
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)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)

// 将纹理传递给着色器
gl.uniform1i(u_Sampler, 0)

上面代码的信息量较多,一句一句来分析:

gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1):将图像进行 Y 轴反转。由于WebGL 纹理坐标系统中的 Y 轴的方向和 PNG、BMP、JPG 等格式图片的坐标系的 Y 轴方向相反(这些图片的坐标起始点都是在左上角)。因此,只有将图像的 Y 轴进行反转,才能够正确地将图像映射到图形上(可以注释这段语句看看效果)

gl.activeTexture(gl.TEXTURE0):激活 TEXTURE0 指定的纹理单元。分别存在 gl.TEXTURE0~7,最后的数字表示纹理单元的编号。系统支持的纹理单元个数取决于硬件和浏览器的 WebGL 实现,但在默认情况下,WebGL 至少支持 8 个纹理单元。

gl.bindTexture(gl.TEXTURE_2D, texture):把 TEXTURE_2D 类型的纹理绑定到 target 上。WebGL 通过纹理单元 (texture unit) 的机制来同时使用多个纹理。每个纹理单元都有一个单元编号来管理纹理图像。激活纹理单元后将其与纹理绑定。

gl.texParameteri(target, pname, param):设置纹理滤镜 (Texture Filter),处理当对象出现缩放时,纹理如何处理中间点或被压缩的点。有四个参数可以设置:

  • gl.TEXTURE_MAG_FILTER:放大方法,决定当画布大于纹理本身时,如何获取纹理颜色。
  • gl.TEXTURE_MIN_FILTER:缩小方法,决定当画布小于纹理本身时,如何获取纹理颜色。
  • gl.TEXTURE_WRAP_S:水平填充方法,如何对纹理图像左侧或右侧区域进行填充。
  • gl.TEXTURE_WRAP_T:垂直填充方法,如何对纹理图像上方和下方的区域进行填充。

通过下图可以形象地理解(蓝色表示渲染的大小),至于它们的取值则是不同的滤镜算法,这里暂时不深入:

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image):将 image 指定的图像分配给绑定到目标上的纹理对象,其他参数取值相对固定,这里暂不深入。

最终可以看到纹理被成功加载:

See the Pen 1. Load Texuture (part2) by ShaderFans (@shaderfans) on CodePen.

http://www.adriancourreges.com/blog/2017/05/09/beware-of-transparent-pixels/