本文目录

[[toc]]

WebGL 简介

WebGL 中有两个重要概念: 顶点着色器 ( Vertex Shader) 、 片元着色器 ( Fragment Shader )

着色器用于 GPU 绘图,其中顶点着色器用于绘制图形的边框,片元着色器用于给图形染色,其基本工作流程如下:

// 创建 WebGL 上下文
const canvas = document.querySelector("#canvas");
const gl = canvas.getContext("webgl");

// 创建着色器 ( Shader )
const vertex = `
  attribute vec2 position;

  void main() {
    gl_PointSize = 1.0;
    gl_Position = vec4(position, 1.0, 1.0);
  }
`;

const fragment = `
  precision mediump float;

  void main()
  {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
`;

// 实例化着色器 ( Shader ) 对象
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);

// 连接到 Program
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

// 数据写入缓冲区
const points = new Float32Array([-1, -1, 0, 1, 1, -1]);
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

// GPU 读取缓冲区数据
const vPosition = gl.getAttribLocation(program, "position"); // 获取顶点着色器中的position变量的地址
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0); // 给变量设置长度和类型
gl.enableVertexAttribArray(vPosition); // 激活这个变量

// 开始着色绘制
gl.clear(gl.COLOR_BUFFER_BIT);

// 声明绘制的是三角形
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

WebGL 的基本单元

WebGL 的基本单元是图元,相当于 RGB 中的三原色的地位。

WebGL 支持的图元类型有 7 种,分别是:

  • gl.POINTS: 点
  • gl.LINES: 线段
  • gl.LINE_STRIP: 线条
  • gl.LINE_LOOP: 回路
  • gl.TRIANGLES: 三角形
  • gl.TRIANGLE_STRIP: 三角带
  • gl.TRIANGLE_FAN: 三角扇

坐标系

不同于 HTML 、 Canvas 、 CSS 的左上角为坐标原点 X 轴从左向右, Y 轴从上向下, WebGL 采用的是中心点为坐标原点,并且 Y 轴是从下向上。

由于坐标系不同,在 Canvas 绘制 WebGL 时常常遇到坐标系转换问题,一个不注意就容易导致 bug 。

所以在进行开发之前,往往会预先统一坐标系原点,对 Canvas 进行坐标系改造,使其原点也在画面中央:

const canvas = document.querySelector('canvas');
const ctx = canvas.ctx;

const ctxWidth = canvas.width;
const ctxHeight = canvas.height;

// 进行坐标系转换
// 处理原点位置
ctx.translate(ctxWidth / 2, ctxHeight / 2);
// 反转 Y 轴方向
ctx.scale(1, -1);

向量

向量即为 N 维空间中的有向线段,这里需要理清几个问题:

  • 向量可以任意平移,不改变这个向量: 说明了向量的原点是可以随意改变的,不影响向量及其计算。
    • 一般认为这是一维向量的扩展,比如 2 + 5 ,就是先走 2 单位,再走 5 单位。
  • 向量相加时,可以视为两个向量起点与终点相连,最终形成的从起点到终点的有向线段。
  • 向量乘以一个常数时,等于各个维度均会被放大常数倍。
  • 单位向量是某个维度(方向)上的最小向量,其在其他维度的投影长度为 0 ,即单位向量垂直于其他所有的面。
  • 由于单位向量存在,所以向量的各个坐标轴(维度)必然互相垂直,否则不存在单位向量。
  • 向量本质上是不同的单位向量进行缩放后,相加得出来的有向线段,其值受各个单位向量影响,只要单位向量改变,则向量值也会改变。

上述的坐标系变化,可以视为单位向量发生变化, Y 轴单位向量从 j 变为 -j 。

/**
 * 基础向量类
 */
class Vector {
  private x: number;
  private y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  /**
   * 向量长度
   */
  get length() {
    return Math.hypot(this.x, this.y);
  }

  /**
   * 向量与 X 轴夹角
   */
  get dir() {
    return Math.atan2(this.y, this.x);
  }

  /**
   * 向量旋转
   */
  rotate(dir: number) {
    const { x, y, length: len } = this;

    const cosDir = len * Math.cos(dir);
    const sinDir = len * Math.sin(dir);

    this.x = x * cosDir + y * -sinDir;
    this.y = x * sinDir + y + cosDir;
  }

  /**
   * 判断向量是否平行
   */
  parallel(v: Vector) {
    return this.x * v.y === this.y * v.x;
  }

  /**
   * 判断向量是否垂直
   */
  perpendicular(v: Vector) {
    return this.x * v.x + this.y * v.y === 0;
  }
}

const v = new Vector(3, 4)

// 向量的 x 可以根据 length 与 dir 计算
const x = v.length * Math.cos(v.dir);

// 向量的 y 可以根据 length 与 dir 计算
const y = v.length * Math.sin(v.dir);

矩阵

简单来说,矩阵通常代表一个线性变换。

const matrix = [
  [1, 1],
  [1, 0],
]

以列为单位,可以拆分为 [ matrix[0][0], matrix[1][0] ][ matrix[0][1], matrix[1][1] ] ,这就是线性变换后,向量的单位向量(基向量)。

基向量的变化也会让向量随之变换,比如有 [ x, y ] 的向量,进行 matrix 变换后,新的向量为:

const v1 = [
  x * matrix[0][0],
  x * matrix[1][0],
]

const v2 = [
  y * matrix[0][1],
  y * matrix[1][1],
]

// v = v1 + v2
const v = [
  x * matrix[0][0] + y * matrix[0][1],
  x * matrix[1][0] + y * matrix[1][1],
]

同理,矩阵相乘,就是先应用一个线性变换,再应用另一个线性变换,所以往往称为复合变换。

从计算角度上看,就是对于一对基向量,进行线性变换。

例如有以下矩阵:

// 剪切矩阵
const shearMatrix = [
  [1, 1],
  [0, 1],
]
// 旋转矩阵
const rotateMatrix = [
  [0, -1],
  [1, 0],
]

shearMatrix * rotateMatrix 变换过程如下:

// 矩阵进行左结合,即先进行 rotateMatrix 变换,再进行 shearMatrix 变换

// 记复合变换后的 X 轴单位向量为 i
// 记复合变换后的 Y 轴单位向量为 j

// 先计算 i
// const i = shearMatrix * [ rotateMatrix[0][0], rotateMatrix[1][0] ]

const ix = rotateMatrix[0][0]
const iy = rotateMatrix[1][0]

const i1 = [
  // 0 * 1 = 0
  ix * shearMatrix[0][0],
  // 0 * 0 = 0
  ix * shearMatrix[1][0],
]
const i2 = [
  // 1 * 1 = 1
  iy * shearMatrix[0][1],
  // 1 * 1 = 1
  iy * shearMatrix[1][1],
]

// i = i1 + i2
const i = [
  1,
  1,
]

// 再计算 j
// const j = shearMatrix * [ rotateMatrix[0][1], rotateMatrix[1][1] ]

const jx = rotateMatrix[0][0]
const jy = rotateMatrix[1][0]

const j1 = [
  // -1 * 1 = -1
  jx * shearMatrix[0][0],
  // -1 * 0 = 0
  jx * shearMatrix[1][0],
]
const j2 = [
  // 0 * 1 = 0
  jy * shearMatrix[0][1],
  // 0 * 1 = 0
  jy * shearMatrix[1][1],
]

// j = j1 + j2
const j = [
  -1,
  0,
]

// 最终复合后的矩阵为:
[
  [1, -1],
  [1, 0],
]

对于行列式来说,行列式就是求 i 与 j 围成的平行四边形的面积,以上述复合矩阵为例,可以视为 [1, 1][-1, 0] 围成的面积,为了方便计算面积,可以平移 [-1, 0][1, 0] ,所以向量围成的面积就是 1 * 1 = 1

当然,也有更加学术的计算方式,那就是计算 ad - bc ,也就是 1 * 0 - -1 * 1 = 0 - -1 = 1 ,通常我们会用这个式子进行计算,但是它并不好理解为什么这么算,所以在抽象领域更加倾向于声明其象征意义,而不是如何计算。

如果想要知道如何计算的话,可以参考如下图所示,通过大矩形面积,减去边缘的三角形、矩形的面积,就可以得到最终的算式。

严格意义来说,行列式是向量围成的面积这一说法并不准确,因为行列式可以为负数与 0 ,这里的负号主要表示 X/Y 发生了翻转。

以传统的平面直角坐标系为例, X 为从左向右, Y 为从下向上,通常我们会认为, X 在 Y 的左边(看正数轴围成的角度较小的角确定的方向),而行列式的符号正是表现了这一关系是否发生了翻转,即当行列式值为负数时,指 X 位于 Y 的右边,面积为行列式的绝对值。

当行列式的为 0 时,表示维度发生了坍缩,由 N 维坍缩到 N - k 维,具体坍缩了多少并不能直接从行列式值中看出,因为乘法的计算过程中只要有一个为 0 ,则值为 0 。