WebGL系列(2):基本图形绘制与变换

261 阅读5分钟

在前一篇# WebGL系列(1):初识WebGL中我们简单了解了WebGL是什么,以及如何使用WebGL进行简单点的绘制。既然我们知道了如何绘制点,那这篇文章就来介绍一下如何绘制有多个顶点的基本图形。

实际上,构成三维模型的基本单位是三角形。不论三维模型的形状多么复杂,其组成部分都是三角形,只不过越复杂的模型构成的三角形越多。那么我们首先来看看如何绘制一个三角形。

一、绘制三角形

如下代码,就是简单绘制一个三角形

// 初始化顶点缓冲区
function initVertexBuffer(gl) {
    const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5]);
    const n = 3;  // 点的个数
    
    // 创建缓冲区对象
    const vertexBuffer  =gl.createBuffer();
    if (!vertexBuffer) {
        console.log('创建缓冲区对象失败!');
        return -1;
    }

    // 将缓冲区对象绑定到目标
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    // 向缓冲区写入数据
    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

    // 获取 attribute 变量的存储位置
    const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
    // 判断是否获取成功
    if (a_Position < 0) {
        console.log('获取 a_Position 的存储位置失败!');
        return -1;
    }

    // 将缓冲区对象分配给 a_Position 变量
    gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);

    // 连接 a_Position 变量与分配给它的缓冲区对象
    gl.enableVertexAttribArray(a_Position);
    
    return n;

}

// 顶点着色器程序
const VSHADER_SOURCE = 
    'attribute vec4 a_Position;\n' +
    'void main() {\n' +
    '  gl_Position = a_Position;\n' +   // 设置顶点坐标
    '}\n';

const FSHADER_SOURCE = 
    'void main() {\n' +
    '  gl_FragColor = vec4(1.0, 0.0, 0.0, 0.0);\n' +  // 设置颜色
    '}\n';

function main() {
    // 获取canvas元素
    const canvas = document.getElementById('gl');
    // 获取WebGL绘图上下文
    const gl = canvas.getContext('webgl');
    // 确认WebGL支持性
    if (!gl) {
        console.log('浏览器不支持WebGL');
        return;
    }
    // 初始化着色器(可见第一篇文章中对该函数的定义)
    if(!initShader(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
        console.log('初始化着色器失败!');
        return;
    }

    // 设置顶点位置
    const n = initVertexBuffer(gl);
    if (n < 0) {
        console.log('设置顶点位置失败!');
        return;
    }

    // 设置canvas背景色
    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    // 情况canvas
    gl.clear(gl.COLOR_BUFFER_BIT);

    // 绘制一个点
    gl.drawArrays(gl.TRIANGLES  , 0, n);
}

image.png

如上代码,与单纯绘制顶点的不同在于drawArray() 函数的参数mode改变了。那么接下来就简单介绍一下drawArray()函数的参数 model 对应的几种不同的情况。

参数 mode基本图形描述
gl.POINTS一系列点,绘制在 v0,v1,v2 ...... 处
gl.LINES线段一系列单独的线段,绘制在(v0, v1), (v2, v3), (v4, v5)......处,如果点的个数是奇数,最后一个点将被忽略
gl.LINE_STRIP线条一系列连接的线段,被绘制在(v0, v1), (v1, v2), (v2, v3)......处,第i个点是第i-1(i>1)条线段的终点和第i条线段的起点
gl.LINE_LOOP回路一系列连接的线段。被绘制在(v0, v1), (v1, v2), (v2, v3)......(vn, v0)处,与 gl.LINE_STRIP 绘制的线条相比,增加了一条从最后一个点到第一个点的线段
gl.TRIANGLES三角形一系列单独的三角形,绘制在(v0, v1, v2), (v3, v4, v5)......处。如果点的个数不是 3 的倍数,那么最后剩下的一个或两个点会被忽略
gl.TRIANGLE_STRIP三角带一系列条带状的三角形,前三个点构成了第1个三角形,从第二个点开始的三个点构成了第二个三角形(该三角形与第一个三角形共享一条边)。以此类推,这些三角形绘制在(v0, v1, v2), (v2, v1, v3), (v2, v3, v4)......处。
gl.TRIANGLE_FAN三角扇一系列三角形组成的类似于扇形的图形。前三个点构成了第1个三角形,接下来的一个点和前一个三角形的最后一条百年组成接下来的一个三角形。这些三角形被绘制在(v0, v1, v2), (v0, v2, v3), (v0, v3, v4)...... 处

下面的例子,我们将展示下不同的mode的绘制情况:

  • gl.POINTS image.png

  • gl.LINES image.png

  • gl.LINE_STRIP image.png

  • gl.LINE_LOOP image.png

  • gl.TRIANGLES image.png

  • gl.TRIANGLE_STRIP image.png

  • gl.TRIANGLE_FAN image.png

注:上图中除了 gl.POINTS ,其余的几种模式下顶点都不会被绘制,这里是为了方便查看。

二、基本图形变换(移动、旋转、缩放)

一中我们知道了如何绘制基本的三角形,那么这一节我们来看看如何进行对图形进行一些简单的变换。

2.1 平移(Translate)

平移是最简单的变化,即沿着某个轴移动一段距离,例如将点P(x, y, z) 移动到 P(x', y', z'),在 X 轴、Y轴、Z轴 三个方向上平移的距离分别为 Tx, Ty, Tz。用数学公式表达如下:

x=x+Txy=y+Tyz=z+Tzx' = x + Tx\\ y' = y + Ty\\ z' = z + Tz

要想实现如上的等式,我们需要在着色器中为顶点坐标的每个分量加上一个常量即可。该操作是对所有顶点共同进行,所以我们可以用 uniform 变量传递平移距离。具体操作如下代码:

// 顶点着色器程序
const VSHADER_SOURCE = 
    'attribute vec4 a_Position;\n' +
    'uniform vec4 u_Translation;\n' +
    'void main() {\n' +
    '  gl_Position = a_Position + u_Translation;\n' +   // 设置顶点坐标
    '  gl_PointSize = 10.0;\n' +
    '}\n';

const FSHADER_SOURCE = 
    'void main() {\n' +
    '  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);\n' +  // 设置颜色
    '}\n';

function main() {
    ...

    // 设置顶点位置
    const n = initVertexBuffer(gl);
    if (n < 0) {
        console.log('设置顶点位置失败!');
        return;
    }

    // 将平移距离传输给顶点着色器‘
    const Tx = 0.25, Ty = 0.25, Tz = 0.0;
    const u_Translation = gl.getUniformLocation(gl.program, 'u_Translation');
    gl.uniform4f(u_Translation, Tx, Ty, Tz, 0.0)

    ...

    // 绘制三角形
    gl.drawArrays(gl.TRIANGLES, 0, n);
}

gl_Position = a_Position + u_Translation就是我们实现平移操作的关键,在上述代码中:

  • 首先将顶点坐标传递给 a_Position
  • 接着向 a_Position 加上 u_Translation
  • 结果赋值给 gl_Position

其中 a_Positionu_Translation 变量都是 vec4 类型的,使用 "+" 时,两个矢量的对应分量会被同时相加。这是 GLSL ES 提供的矢量相加运算。

2.2 旋转(Rotate)

一般在进行图形的旋转变换之前,我们需要确定三点:

  • 旋转轴
  • 旋转方向:顺时针或逆时针
  • 旋转角度

首先我们来看一下旋转方向是如何约定的:

逆时针:如果旋转角度为正值,观察者在Z轴正半轴某处,视线沿着Z轴负方向进行观察,那么看到的物体就是逆时针旋转的。这种情况也称作正旋转(positive rotation),可以通过右手旋转法则来确定。

右手旋转法则(right-hand-rule rotation):右手握拳,大拇指伸直并使其指向旋转轴的正方向,那么右手其余几个手指就指明了旋转的方向。

如2.1中的平移所示,那么如何使用数学公式来表示旋转操作呢?以下图的旋转为例:

绘图1.png

若想通过旋转角度 b 将 P 点旋转到 P' 点的位置,可按照如下公式实现:

x=rcosα;y=rsinαx = rcos\alpha;\\ y = rsin\alpha\\
x=rcos(α+β);y=rsin(α+β)x' = rcos(\alpha + \beta);\\ y' = rsin(\alpha + \beta)\\
x=r(cosαcosβsinαsinβ);y=r(sinαcosβ+cosαsinβ)x' = r(cos\alpha cos\beta - sin\alpha sin\beta);\\ y' = r(sin\alpha cos\beta + cos\alpha sin\beta)\\

最后可得:

x=xcosβysinβy=xsinβ+ycosβz=zx' = xcos\beta - ysin\beta\\ y' = xsin\beta + ycos\beta\\ z' = z

如上公式所示,我们只需要将 sinβsin\betacosβcos\beta 的值传递给顶点着色器即可。具体实现代码如下:

// 顶点着色器程序
const VSHADER_SOURCE = 
    'attribute vec4 a_Position;\n' +
    'uniform float u_CosB, u_SinB;\n' +
    'void main() {\n' +
    '  gl_Position.x = a_Position.x * u_CosB - a_Position.y*u_SinB;\n' +  
    '  gl_Position.y = a_Position.x * u_SinB + a_Position.y*u_CosB;\n' +  
    '  gl_Position.z = a_Position.z;\n' + 
    '  gl_Position.w = 1.0;;\n' +
    '}\n';

const FSHADER_SOURCE = 
    'void main() {\n' +
    '  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);\n' +  // 设置颜色
    '}\n';

function main() {
    ...

    // 将旋转图形所需的数据传输给顶点着色器
    const ANGLE = 90;  // 旋转的角度
    const radian = Math.PI * ANGLE / 180.0 // 将度数转换为弧度制
    const cosB = Math.cos(radian);
    const sinB = Math.sin(radian);
    // 获取 uniform 变量存储地址
    const u_CosB = gl.getUniformLocation(gl.program, 'u_CosB');
    const u_SinB = gl.getUniformLocation(gl.program, 'u_SinB');
    // 向 uniform 变量传递数据
    gl.uniform1f(u_CosB, cosB);
    gl.uniform1f(u_SinB, sinB);
   ...
}

image.png

2.3 缩放(Scale)

相对于平移和旋转来说,缩放较为简单,对应的公式如下所示:

x=Sxxy=Syyz=Szzx' = S_x * x\\ y' = S_y * y\\ z' = S_z * z

具体代码实现如下:

// 顶点着色器程序
const VSHADER_SOURCE = 
    'attribute vec4 a_Position;\n' +
    'uniform float u_Sx, u_Sy;\n' +
    'void main() {\n' +
    '  gl_Position.x = a_Position.x * u_Sx;\n' +  
    '  gl_Position.y = a_Position.y * u_Sy;\n' +  
    '  gl_Position.z = a_Position.z;\n' + 
    '  gl_Position.w = 1.0;;\n' +
    '}\n';

const FSHADER_SOURCE = 
    'void main() {\n' +
    '  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);\n' +  // 设置颜色
    '}\n';

function main() {
    ...

    // 获取 uniform 变量存储地址
    const u_Sx = gl.getUniformLocation(gl.program, 'u_Sx');
    const u_Sy = gl.getUniformLocation(gl.program, 'u_Sy');
    // 向 uniform 变量传递数据
    gl.uniform1f(u_Sx, 2.0);
    gl.uniform1f(u_Sy, 1.5);
    ...

image.png

2.4 变换矩阵

2.1 和 2.2中我们介绍了平移和旋转对应的数学公式。对于其中某个简单的变换来说,可以数学公式来实现。但是如果要实现某些复杂的变换时,使用数学公式就会变得很繁琐。例如我想先平移,再旋转,那么就需要叠加上述两个公式得到一个新的公式。以此类推,进行多种变换,就需要叠加多次公式,这显然不合理。对此,我们可以使用另外一个数学工具——变换矩阵来实现。

变换矩阵就是n维方阵,一般采用二维数组存储,具体变换如下公式:

[xyz]=[abcdefghi][xyz]\begin{bmatrix} x'\\ y'\\ z'\\ \end{bmatrix} = \begin{bmatrix} a&b&c\\ d&e&f\\ g&h&i\\ \end{bmatrix} * \begin{bmatrix} x\\ y\\ z\\ \end{bmatrix}
x=ax+by+czy=dx+ey+fzz=gx+hy+izx' = ax + by + cz\\ y' = dx + ey + fz\\ z' = gx + hy + iz

(1)旋转

如2.2所述,旋转对应的公式如下

x=xcosβysinβy=xsinβ+ycosβz=zx' = xcos\beta - ysin\beta\\ y' = xsin\beta + ycos\beta\\ z' = z

我们对此公式稍微修改一下:

x=xcosβ+(ysinβ)+0zy=xsinβ+ycosβ+0zz=0x+0y+zx' = xcos\beta + (-ysin\beta) + 0z\\ y' = xsin\beta + ycos\beta + 0z\\ z' = 0x + 0y + z

用矩阵的形式来表示如下:

[xyz]=[cosβysinβ0sinβycosβ0001][xyz]\begin{bmatrix} x'\\ y'\\ z'\\ \end{bmatrix} = \begin{bmatrix} cos\beta&-ysin\beta&0\\ sin\beta&ycos\beta&0\\ 0&0&1\\ \end{bmatrix} * \begin{bmatrix} x\\ y\\ z\\ \end{bmatrix}

上述公式中的矩阵可称为旋转变换矩阵

(2)平移

如2.1所述,平移对应的数学公式如下:

x=x+Txy=y+Tyz=z+Tzx' = x + Tx\\ y' = y + Ty\\ z' = z + Tz

对此,我们可以用一个4x4的矩阵来表示:

[xyz1]=[100Tx010Ty001Tz0001][xyz1]\begin{bmatrix} x'\\ y'\\ z'\\ 1 \end{bmatrix} = \begin{bmatrix} 1&0&0&T_x\\ 0&1&0&T_y\\ 0&0&1&T_z\\ 0&0&0&1\\ \end{bmatrix} * \begin{bmatrix} x\\ y\\ z\\ 1\\ \end{bmatrix}

那么上述矩阵可称为平移矩阵

(3)缩放

如2.1所述,缩放对应的数学公式如下:

x=Sxxy=Syyz=Szzx' = S_x * x\\ y' = S_y * y\\ z' = S_z * z

对此,我们可以用一个4x4的矩阵来表示:

[xyz1]=[Sx0000Sy0000Sz00001][xyz1]\begin{bmatrix} x'\\ y'\\ z'\\ 1 \end{bmatrix} = \begin{bmatrix} S_x&0&0&0\\ 0&S_y&0&0\\ 0&0&S_z&0\\ 0&0&0&1\\ \end{bmatrix} * \begin{bmatrix} x\\ y\\ z\\ 1\\ \end{bmatrix}

那么上述矩阵可称为缩放矩阵

(4)变换矩阵的使用

上述介绍了变换矩阵的原理,接下来我们就看看如何在程序中使用变换矩阵。以旋转为例:

// 顶点着色器程序
const VSHADER_SOURCE = 
    'attribute vec4 a_Position;\n' +
    'uniform mat4 u_xformMatrix;\n' +
    'void main() {\n' +
    '  gl_Position = u_xformMatrix * a_Position;\n' +  
    '}\n';

const FSHADER_SOURCE = 
    'void main() {\n' +
    '  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);\n' +  // 设置颜色
    '}\n';

function main() {
    ...

    // 创建旋转矩阵
    const ANGLE = 90;  // 旋转的角度
    const radian = Math.PI * ANGLE / 180.0 // 将度数转换为弧度制
    const cosB = Math.cos(radian);
    const sinB = Math.sin(radian);
    // 注意:WebGL中矩阵是列主序的
    const xformMatrix = new Float32Array([
        cosB, sinB, 0.0, 0.0,
        -sinB, cosB, 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        0.0, 0.0, 0.0, 1.0
    ])
    // 获取 uniform 变量存储地址
    const u_xformMatrix = gl.getUniformLocation(gl.program, 'u_xformMatrix');
    // 向 uniform 变量传递数据
    gl.uniformMatrix4fv(u_xformMatrix,false, xformMatrix);
   ...
}

注意:在WebGL中,矩阵元素是按列主序存储在数组中的,也就是一列一列存储的。

gl.uniformMatrix4fv(location, transpose, array):将array表示的4x4矩阵分配给由location指定的uniform变量。

  • 参数
    • location:uniform变量存储位置
    • transpose:在 WebGL 中必指定为 false
    • array:待传输的类型化数组,4x4矩阵按列主序存储在其中。
  • 返回值:无

三、复杂变换

2.4 中我们介绍了如何使用变换矩阵来实现简单的图形变换。那么如果我们想要实现先平移再旋转呢?其实,到这一步,想要实现这要的复杂变换并不难。

例如:某图形要先平移再旋转,对应的平移矩阵为A,旋转矩阵为B。

  1. 首先进行平移变换,假设平移后的顶点坐标为[xt,yt,zt][x_t, y_t, z_t]
[xtytzt]=A[xyz]\begin{bmatrix} x_t\\ y_t\\ z_t \end{bmatrix} =A * \begin{bmatrix} x\\ y\\ z \end{bmatrix}
  1. 再进行旋转变换,假设旋转变换后的顶点坐标为[x,y,z][x', y', z']
[xyz]=B[xtytzt]\begin{bmatrix} x'\\ y'\\ z' \end{bmatrix} =B * \begin{bmatrix} x_t\\ y_t\\ z_t \end{bmatrix}
  1. 那么合并公式可得:
[xyz]=BA[xyz]\begin{bmatrix} x'\\ y'\\ z' \end{bmatrix} =B * A * \begin{bmatrix} x\\ y\\ z \end{bmatrix}

那么复杂变换先平移再旋转对应的变换矩阵为BAB*A

注意:上述式子中矩阵相乘的次序非常重要,A*B 的结果不一定等于 B*A

以此类推,复杂变换对应的变换矩阵即为基础变换变换矩阵的乘积。但是,自行定义变换矩阵过于繁琐,建议大家将基础变换自行封装成函数使用,或者使用网上一些封装好的库。

如下代码,我们首先了一个先平移再旋转的变换:

// 顶点着色器程序
const VSHADER_SOURCE = 
    'attribute vec4 a_Position;\n' +
    'uniform mat4 u_TranslateMatrix;\n' +
    'uniform mat4 u_RotateMatrix;\n' +
    'void main() {\n' +
    '  gl_Position = u_RotateMatrix * u_TranslateMatrix * a_Position;\n' +  
    '}\n';

const FSHADER_SOURCE = 
    'void main() {\n' +
    '  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);\n' +  // 设置颜色
    '}\n';

function main() {
    ...

    // 创建平移矩阵
    const tx = 0.5, ty = 0.0, tz = 0;
    const translateMatrix = new Float32Array([
        1.0, 0.0, 0.0, 0.0,
        0.0, 1.0, 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        tx, ty, tz, 1.0
    ])

    // 创建旋转矩阵
    const ANGLE = 90;  // 旋转的角度
    const radian = Math.PI * ANGLE / 180.0 // 将度数转换为弧度制
    const cosB = Math.cos(radian);
    const sinB = Math.sin(radian);
    // 注意:WebGL中矩阵是列主序的
    const rotateMatrix = new Float32Array([
        cosB, sinB, 0.0, 0.0,
        -sinB, cosB, 0.0, 0.0,
        0.0, 0.0, 1.0, 0.0,
        0.0, 0.0, 0.0, 1.0
    ])
    // 获取 uniform 变量存储地址
    const u_TranslateMatrix = gl.getUniformLocation(gl.program, 'u_TranslateMatrix');
    gl.uniformMatrix4fv(u_TranslateMatrix,false, translateMatrix);
    // 获取 uniform 变量存储地址
    const u_RotateMatrix = gl.getUniformLocation(gl.program, 'u_RotateMatrix');
    gl.uniformMatrix4fv(u_RotateMatrix,false, rotateMatrix);
  ...
}

image.png

如果我们更改了变换矩阵相乘的顺序,结果会不同:

gl_Position = u_TranslateMatrix * u_RotateMatrix * a_Position;

image.png

本文到这就结束了,主要介绍了一下WebGL中基础图形的绘制,以及一些基础变换。

参考:

[1] 《WebGL编程指南》