上车WebGL——用纹理画猫猫!

2,800 阅读15分钟

哈喽大家好啊,我是广州小井。好久没在掘金更文了,最近跑去学习 WebGL 了,然后打算把学习路径写成系列文章来分享给大家。

因为要更好的学习 WebGL 就需要更多的上手敲代码,所以案例和演示代码就很显得重要了。为此,笔者打算将案例、源码都统一放到 github 仓库,并且搞了个在线文档供大家学习参考~大家可以给笔者点个 star!文章更新不迷路!

img.png

本文的同步地址:

经过之前章节的学习,相信大家已经掌握了基本的二维几何图形绘制和着色器的基本工作原理,对 WebGL 已经有点熟悉了。那么这一节开始,我们开始学习 WegGL 中的纹理映射,let's go!

可能大家跟我一样,在还没学习到这一节的时候心里可能都会有个疑问,那就是虽然我们学 WebGL 的各种基础图形绘制、各种颜色控制,但如果我们要显示真实场景的图片,那我们应该怎样做呢?难道要自己把模型绘制出来,再上个色?比如说下面这张猫猫:

4.1.jpeg

其实真的有心去画,慢慢绘制各种基本图形加线段,控制好每个像素点的颜色...emmm...好像也不是不行,只是工作量巨大,并且可能没有什么意义。那遇到这样的问题,我们应该如何解决呢?这个时候,纹理图像就登场了!

本文的主要学习目标就是实现如下的示例程序,我们可以抢先体验一下:

什么是纹理图像

纹理图像是通过纹理映射的技术,将一张图贴到我们绘制的几何图形的表面上,这样我们就能在 WebGL 中使用真实的图片了,而这样的图形就是纹理图像。

其实纹理映射就是将图像的每一个像素点的颜色映射到我们绘制好的图形上。回顾前两小节的内容,在顶点着色器执行完后,还有图形装配和光栅化的步骤,而光栅化后我们得到的是一个充满片元的图形,最后片元着色器再进行逐片元操作对每个像素点涂上颜色。而这里,无非就是给光栅化后的每个片元涂上对应照片中的颜色。

4.2.png

如上图所示,纹理映射的基本工作方式就是这样,根据图片将光栅化后的对应位置的每个片元涂上对应的颜色。 所以,组成纹理图像的基本单位就是一个个像素,这里称之为——纹素,纹素的颜色值使用 RGB 或者 RGBA 的格式。

纹理坐标

上文提到了纹理映射要将像素点的颜色涂到对应位置的纹素上,那我们就需要对"位置"有个明确的认识,这就需要用到——纹理坐标。没错,又双叒叕是坐标~我们想一下,我们既然要贴图,那是贴半张、还是一张呢?贴到目标载体的左上角还是右下角呢?

纹理坐标就是图像上的坐标,我们通过它可以拿到纹素的颜色,它的坐标系统如图所示:

4.3.png

这一看,还是我们比较熟悉的坐标系呢,左下角为原点。大家可能注意到了,笔者在图片的顶点都标了坐标(尽管图片是个长方形的),不管长宽,范围都是 01。所以,这一点上我们的纹理坐标跟 WebGL 的坐标系统类似,范围就在 0-1 之间,并不依赖图片自身尺寸。(为了跟 xy 坐标系统区分开,纹理坐标命名采用 st 来命名

那有了纹理坐标后,我们就只需要将对应的纹理坐标贴到我们的 WebGL 系统的顶点坐标中就可以实现纹理图像了。我们可以通过下图来加深理解:

4.4.png

看到这里,相信你也知道了纹理映射的基本原理了,那接下来我们就进入实战,把文章开头的"猫猫"图贴到我们的 WebGL 图形的表面去!

第一张纹理图片

额,我觉得实战纹理图片这个部分跟之前学习 WebGL 缓冲区对象的时候有点像,也可以将整体实现拆分成几个步骤,然后也会用到一些新的 api。所以这里,我们跟之前一样,先以实战一张纹理图片为主,本文不用过度关注每个 api 的用法、参数,主要掌握好主流程才是关键。

然后我们在下一节的文章中再详细了解相关的 api 一些具体的用法,还有不同参数带来的不同效果。那我们本文就专注于如何实现第一张纹理图像吧!

1. 着色器代码实现

首先看看顶点着色器的代码实现。通过上文的了解我们知道目前我们需要两种类型的坐标,其中一个是顶点坐标,另外一个就是纹理坐标,所以我们顶点着色器的代码实现如下:

const vertexCode = `
  // 顶点坐标
  attribute vec4 a_Position;
  // 接收纹理坐标
  attribute vec2 a_TexCoord;
  // 向片元着色器传递纹理坐标
  varying vec2 v_TexCoord;

  void main () {
    gl_Position = a_Position;
    v_TexCoord = a_TexCoord;
  }
`

上述代码其实我们都相对比较熟悉了,通过两个 attribute 变量分别接收 顶点、纹理 坐标,再通过 varying 变量 将纹理坐标传递给片元着色器(经过上一小节的学习,我们知道这里的纹理坐标其实是经过 WebGL 系统内插后的坐标值,它并不完全等于传入顶点着色器时候的纹理坐标)。

那么接下来,就该轮到片元着色器了。这里,它需要根据每个片元的的纹理坐标,到图像对应的纹素上提取颜色值,再绘制到当前片元中。

const fragmentCode = `
  precision mediump float;
  // 接收纹理坐标
  varying vec2 v_TexCoord;
  // 取样器,这里可将其理解为纹理对象
  uniform sampler2D u_Sampler;

  void main () {
    // texture2D 用于抽取纹理图片中纹素的颜色
    gl_FragColor = texture2D(u_Sampler, v_TexCoord);
  }
`

在片元着色器的代码实现中,我们看到了两个相对比较陌生的东西,一个是 sampler2D 的变量类型,一个 texture2D 的内置函数。那么在这里,我们简单了解一下他们的作用:

  1. sampler2D。sampler 就是取样器(提取纹理图像的颜色),我们简单理解它就是纹素的颜色值
  2. texture2D。内置函数,可抽取纹素颜色。传入单元编号(纹理对象)、纹理坐标两个参数使用。

2. WebGL 基础代码

这里,我们快速带过一下基础的 WebGL 长方形的绘制代码实现。(基本都是之前章节提过的,不会所有都深入)

因为我们的纹理图像是需要"贴"到一个长方形的模型上,所以我们还是需要跟之前章节一样绘制一个模型。回顾 WebGL绘制基本图形 中,我们可以通过两个三角形来实现一个长方形,坐标如下:

4.5.png

这里不再详细演示了,我们注意一下 buffer 数据即可,它跟之前的有一点不一样,我们这次存放的是顶点坐标和纹理坐标:

// 前两个是顶点坐标,后两个是纹理坐标(图像取的是整张图,所以是0-1)
const verticesTexCoords = new Float32Array([
  -.5, .5,   0., 1., 
  -.5, -.5,   0., 0.,
  .5, .5,   1., 1.,
  .5,-.5,   1., 0.
])

然后我们再注意下步进参数偏移参数的设置即可:

// 设置顶点坐标
const a_Position = gl.getAttribLocation(program, 'a_Position')
// 注意步进参数设置
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0)
gl.enableVertexAttribArray(a_Position)

// 设置纹理坐标
const a_TexCoord = gl.getAttribLocation(program, 'a_TexCoord')
// 注意步进参数、偏移参数设置
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2)
gl.enableVertexAttribArray(a_TexCoord)

我们先来看一下模型的效果图(这里我暂时给图形涂上蓝色方便大家看):

image.png

我们要贴图的模型(长方形)已经造出来了,那接下来就让我们将图片贴到这个蓝色的长方形上面吧。

3. 实战纹理映射

首先第一步的准备工作,我们当然是要搞个图片了!这里我们直接通过 Image 对象来创建一个图片实例,并进行加载:

const img = new Image()
// 图片地址(注意不允许跨域)
img.src = '/public/images/third/4.1.jpeg'
img.onload = function () {
  // 一系列实现纹理映射的相关代码
}

这里我们要注意一个小点就是 WebGL 中不可以使用跨域的图片,这一点跟我们平时对 <img /> 标签、或者 Image 对象的理解会有些差异~如果我们在 WebGL 中使用了跨域的图片资源,浏览器将会出现如下的报错信息:

Uncaught DOMException Failed to execute 'texImage2D' on 'WebGLRenderingContext': The image element contains cross-origin data, and may not be loaded.

当然,我们也是可以通过设置 crossOrigin 属性来使用跨域的图片资源,具体操作可以参考 WebGL 跨域图像,因为不是本文的主要内容,我就不在这里进行展开了。


接下来,我们就分步骤具体看看图片加载完成后(onload回调)我们具体需要怎么做:

注意!如果有对某个 api 想详细了解的,大家可以点击外链到 MDN 中详细查看,本文以实现功能为主,不会详细展开!

1. 创建纹理对象

回顾缓冲区对象的使用,第一步其实跟这里是一样的,都是先要创建对象!这里我们通过 gl.createTexture 这个 api 来创建纹理对象:

const texture = gl.createTexture()

上述代码中,我们创建了一个纹理对象,用它来管理 WebGL 中的纹理图像。

2. 激活纹理单元

所谓纹理单元就是用来"管理"纹理图像的。我们每用一张图片,都要给他指定一个纹理单元。一般情况下,WebGL 中默认有 8 个纹理单元,从 gl.TEXTURE0 - gl.TEXTURE7

我们在使用纹理单元之前,首先要激活它,就是通过 gl.activeTexture 这个 api

// 参数就是待激活的纹理单元
gl.activeTexture(gl.TEXTURE0)

3. 绑定纹理对象

绑定...纹理对象?好像学缓冲区的时候也有绑定缓冲区对象这玩意...没错,就是这么相似,在使用纹理对象之前,我们也需要对其进行绑定。这一步我们依然可以跟缓冲区对象一样地理解:需要绑定纹理对象才能对其进行操作。

这一步,我们通过 gl.bindTexture 这个 api 来绑定纹理对象:

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

gl.bindTexture 的第一个参数 target ,它可以传递好些值如:gl.TEXTURE_2Dgl.TEXTURE_CUBE_MAPgl.TEXTURE_3D 等等,这里我们只需要 gl.TEXTURE_2D 即可,因为我们的图像也是一张 2D 的猫猫照片而已。

到这一步,WebGL 系统中关于纹理对象的状态可以理解成如下图片:

4.6.png

4. 配置纹理对象

这一步比较关键,因为我们需要设置纹理图像以什么样的方式映射到我们的模型中,是放大还是缩小、是否要重复等等

这里我们通过 gl.texParameteri 这个 api 通过设置不同的参数来进行配置。当我们点开这个 MDN 文档的时候我们可以发现,这个 api 所用到的参数有很多种配合使用的场景,但我们这里并不需要所有都一下子掌握,我们主要关注本文示例程序实现所需要的即可。

首先我们配置一个 gl.TEXTURE_MIN_FILTER 纹理缩小:

// 纹理缩小,使用 gl.LINEAR 计算距离中心像素最近的四个像素的平均值
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

接着再配置 gl.TEXTURE_WRAP_* 纹理水平、垂直 填充 :

// 填充方式都是 gl.CLAMP_TO_EDGE 边缘切割取值
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.LINEARgl.CLAMP_TO_EDGE 这些参数不太理解的也没关系,我们会在后面的学习中继续深入了解。这里我们仅需要知道我们通过配置这些参数就能实现我们的第一张纹理图像即可。

5. 分配纹理图像

配置好纹理对象的参数后,我们是不是该想到一件事!图片 onload 后我们好像从来都没有用到它呀!所以这一步,我们就需要将纹理图像给到我们的纹理对象去使用!

我们使用 gl.texImage2D 这个 api 来实现,然而...还是一如既往地一堆参数...

这里我们简单地过一下每个参数的简介,留个印象就好:

参数描述
target这个应该不用我介绍了,直接就 gl.TEXTURE_2D 它吧
level0 级是基本图像等级。这里我们传 0
internalformat图像的内部格式。如 RGB
format纹理数据的格式。需要和 internalformat 的值一致
type纹理数据的类型。gl.UNSIGNED_BYTE:每个颜色分量占 1 字节
pixels纹理的像素源,本文是 Image 对象

参数是真的有点多...不过大家不用有太多心智负担,还是那句话,本文只是初探,我们大概知道有这回事就行了。直接看看示例中的用法:

// 本文用的图片是 jpeg 格式,所以格式参数均为 gl.RGB
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img)

那么到这一步,我们在 JavaScript 创建的 Image 图片对象就已经传入到 WebGL 系统的纹理对象中了。

6. 纹理单元传递给片元着色器

经过上一步的处理,现在我们的纹理图像已经存在 WebGL 的纹理对象中了。那还记得前文提到的片元着色器代码中的取样器变量 u_Sampler 吗?这里,我们就将纹理对象(纹理单元)传递给 u_Sampler 变量。(注意这里为什么是用 uniform 变量,首先是因为其需要在片元着色器中使用,其次它只是被分配一个纹理对象而已,不需要像 varying 一样有内插的过程)

这一步,我们通过 gl.uniform1i 这个 api 来实现:

// 获取 uniform 变量地址
const u_Sampler = gl.getUniformLocation(program, 'u_Sampler')
// 将 gl.TEXTURE0(0号纹理单元) 分配给 u_Sampler 变量
gl.uniform1i(u_Sampler, 0)

这个 api 的使用相对简单一点,我们只需要注意第二个参数,它是一个数字,传入 0 代表将 gl.TEXTURE0 绑定的纹理对象分配到 u_Sampler 上。

7. 片元着色器抽取纹素颜色

这一步,就是我们片元着色器中用到的内置函数 texture2D,它会根据片元的坐标 (x, y) 从纹理图像中抽取出对应位置的像素颜色,然后将颜色绘制到当前的片元中。

对应到本文的着色器代码中,第一个参数 u_Sampler 为取样器,其实就是我们的纹理对象(我们将绑定好的纹理对象传入到 u_Sampler 变量中);第二个参数 v_TexCoord 就是从顶点着色器中传进来的(内插后的)坐标 (x, y)(我们从缓冲区数据中读到纹素坐标,经过 varying 内插后传入到片元着色器)。

gl_FragColor = texture2D(u_Sampler, v_TexCoord);

以上所有步骤,我们相关配置基本上就完成了,最后我们正常通过 gl.drawArrays 绘制我们的长方形,我们就能在 canvas 中看到我们的纹理图像了(也就是文章开头的示例程序)!直接看如下示例程序:

哈哈哈,图像居然是反的!这里我是故意的,上述介绍了纹理映射中的步骤中,其实我还隐藏了一步没有讲,那就是 Y 轴反转。当然,这一点放在最后讲也是为了加深大家的印象和理解。

首先来了解一下为什么我们需要 Y 轴反转这个操作,我们直接看下图即可:

4.7.png

由图可知,纹理坐标的原点是图片的左下角,而图片自身的坐标原点是左上角,所以我们不加入 Y 轴反转的时候,纹理坐标所取的纹素点其实跟原图在 Y 轴上是相反的!详情我们可以看看 百度百科-图像坐标系 的讲解~

所以!!我在上述示例程序中留了个交互功能,大家可以通过自行切换 Y 轴反转的状态来自行体验一下最终的纹理图像效果!

总结

本文的最后,跟大家一起回顾本文的主要内容。这里我们直接回顾本文的核心——纹理图像的实现过程

  1. 创建纹理对象 gl.createTexture
  2. 设置 Y 轴反转 gl.pixelStorei
  3. 激活纹理单元 gl.activeTexture
  4. 绑定纹理对象 gl.bindTexture(绑定后才能操作纹理对象)
  5. 配置纹理对象 gl.texParameteri
  6. 将纹理图像分配给纹理对象进行填充 gl.texImage2D(纹理图像 -> 纹理对象)
  7. 将纹理单元传递给片元着色器 gl.uniform1i(纹理对象传递给 u_Sampler 变量)
  8. 片元着色器抽取纹素颜色 内置函数 texture2D(通过取样器和片元坐标)

本文一下子提到了很多新的 api,然后整个纹理图像的实现过程步骤也比较多,所以我还是建议大家也自行操作一番,按照步骤自己敲敲代码~