WebGL 基础 - 从计算机图形系统到三角剖分

1,295 阅读5分钟

计算机图形系统基础

想要学好 WebGL,我们必须先理解一些基本概念和原理,所以需要先了解计算机图形系统及绘图原理。

图形系统构成

  • 输入设备
  • CPU(Central Processing Unit):中央处理单元,负责逻辑计算。
  • GPU(Graphics Processing Unit):图形处理单元,负责图形计算。
  • 存储器
  • 帧缓存(Frame Buffer):在绘图过程中,像素信息被存放于帧缓存中,帧缓存是一块内存地址。
  • 输出设备

image.png

计算机绘制图形过程

  1. 数据经过 CPU 处理,成为具有特定结构的几何信息。
  2. 这些信息会被送到 GPU 中进行处理。
  3. 生成光栅信息,光栅(Raster),几乎所有的现代图形系统都是基于光栅来绘制图形的,光栅就是指构成图像的像素阵列。
  4. 光栅信息会输出到帧缓存中。
  5. 渲染到屏幕上。

image.png

这里主要做了两件事,一是对给定的数据结合绘图的场景要素(例如相机、光源、遮挡物体等等)进行计算,最终将图形变为屏幕空间的 2D 坐标。二是为屏幕空间的每个像素(像素(Pixel):一个像素对应图像上的一个点,它通常保存图像上的某个具体位置的颜色等信息)点进行着色,把最终完成的图形输出到显示设备上。这整个过程是一步一步进行的,前一步的输出就是后一步的输入,所以我们也把这个过程叫做渲染管线(RenderPipelines)。

GPU 的优势:一张图片由成千上万个像素组成,那么上述的渲染管线就会有成千上万个。CPU 虽然强大,但是对于处理成千上万个渲染管线这样的小任务还是 GPU 更合适,因为 GPU 的处理单元一般要多得多。

WebGL 的绘图过程——完成一个最简单的三角形的绘制

Step1 创建 WebGL 上下文

const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl') as WebGLRenderingContext;

Step2 创建 WebGL 程序(WebGL Program)

  • 这里需要用到 GLSL 语言,类 C,GPU 能识别 GLSL,不能识别 JS。

  • 创建着色器(Shader):

    顶点着色器(Vertex Shader):可以改变顶点的信息(顶点的坐标、法线、材质等),从而改变我们绘制出来的图形的形状或者大小等。

    片元着色器(Fragment Shader):负责处理图形的像素信息,处理像素的过程是并行的。也就是说,无论有多少个像素点,片元着色器都可以同时处理。

  • 顶点处理完成之后,WebGL 就会根据顶点和绘图模式指定的图元(图形单元:点、线、三角形),计算出需要着色的像素点,然后对它们执行片元着色器程序。简单来说,就是对指定图元中的像素点着色。

  • WebGL 从顶点着色器和图元提取像素点给片元着色器执行代码的过程,就是我们前面说的生成光栅信息的过程,我们也叫它光栅化过程。所以,片元着色器的作用,就是处理光栅化后的像素信息。

const vertex = `
  attribute vec2 position;
  void main() {
    gl_PointSize = 1.0;
    gl_Position = vec4(position, 1.0, 1.0);
  }
`;
const vertexShader = gl.createShader(gl.VERTEX_SHADER) as WebGLShader;
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);

const fragment = `
  precision mediump float;
  void main()
  {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }    
`;
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) as WebGLRenderingContext;
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);

const program = gl.createProgram() as WebGLRenderingContext;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

Step3 将数据存入缓冲区

使用 ArrayBuffer 来定义顶点等数据。以一个最简单的三角形为例,如下图所示:

image.png

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);

Step4 将缓冲区数据读取到 GPU

这一步就是要把上一步创建的数据搞到 GPU 中。

const vPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);

Step5 GPU 执行 WebGL 程序,输出结果

执行着色器程序。

gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

完整代码(Vue3.2)

<script setup lang="ts">
import { onMounted } from 'vue'

onMounted(() => {
  const canvas = document.querySelector('canvas') as HTMLCanvasElement;

  // 1、创建 WebGL 上下文
  const gl = canvas.getContext('webgl') as WebGLRenderingContext;

  // 2、创建顶点着色器
  // attribute 表示声明变量,vec2 是变量的类型
  // 所以 position 是一个二维变量
  const vertex = `
    attribute vec2 position;
    void main() {
      gl_PointSize = 1.0;
      gl_Position = vec4(position, 1.0, 1.0);
    }
  `;
  const vertexShader = gl.createShader(gl.VERTEX_SHADER) as WebGLShader;
  gl.shaderSource(vertexShader, vertex);
  gl.compileShader(vertexShader);

  // 3、创建片元着色器
  // precision 精度限定符,mediump 是满足片段着色语言的最低要求,其对于范围和精度的要求必须不低于lowp并且不高于highp
  const fragment = `
    precision mediump float;
    void main()
    {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }    
  `;
  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) as WebGLRenderingContext;
  gl.shaderSource(fragmentShader, fragment);
  gl.compileShader(fragmentShader);

  // 4、创建 WebGLProgram 对象
  const program = gl.createProgram() as WebGLRenderingContext;
  // 将这两个 shader 关联到这个 WebGL 程序上
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  // 将 WebGLProgram 对象链接到 WebGL 上下文对象上
  gl.linkProgram(program);
  // 通过 useProgram 选择启用这个 WebGLProgram 对象
  gl.useProgram(program);

  // 创建三角形顶点
  const points = new Float32Array([
    -1, -1,
    0, 1,
    1, -1,
  ]);

  // 5、将定义好的数据写入 WebGL 的缓冲区
  const bufferId = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

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

  // 7、执行着色器程序完成绘制
  // 将当前画布的内容清除
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);
});
</script>
<template>
  <canvas width="300" height="300"></canvas>
</template>

最终效果:

image.png

封装初始化和加载Shader

export function initShaders(
  gl: WebGLRenderingContext,
  vertexSource: string,
  fragmentSource: string,
): [true, WebGLProgram] | [false, null] {
  // 创建 WebGLProgram 对象
  const program = gl.createProgram();

  const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexSource);

  const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentSource);

  if (program && vertexShader && fragmentShader) {
    // 将这两个 shader 关联到这个 WebGL 程序上
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    // 将 WebGLProgram 对象链接到 WebGL 上下文对象上
    gl.linkProgram(program);
    // 通过 useProgram 选择启用这个 WebGLProgram 对象
    gl.useProgram(program);

    return [true, program];
  }

  return [false, null];
}

export function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);

  if (shader) {
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
  }

  return shader;
}

三角剖分 Triangulation

在 WebGL 中,无法直接绘制和填充多边形,所以我们只能用三角形这种基本图元来实现多边形的绘制。因此,在 WebGL 中填充多边形的第一步,就是将多边形分割成多个三角形。

这种将多边形分割成若干个三角形的操作,在图形学中叫做三角剖分(Triangulation)。

这是图形学和代数拓扑学中一个非常重要的基本操作,也有很多不同的实现算法。对简单多边形尤其是凸多边形的三角剖分比较简单,而复杂多边形由于有边的相交和面积重叠区域,所以相对困难许多。

三角剖分的理论知识,其真正的数据原理是比较复杂的,属于是深水区了,想要了解原理可以移步此处

3D 的三角剖分又被称为网格化(Meshing)。

Github 上有一些比较成熟的三角剖分库,我们可以直接使用:

三角剖分案例(earcut 实现)

将下图的形状进行三角剖分:

image.png

<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';
import earcut from 'earcut';

onMounted(() => {
  const canvas = document.querySelector('canvas');
  if (!canvas) {
    return;
  }

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const gl = canvas.getContext('webgl') as WebGLRenderingContext;

  const vertex = `
    attribute vec4 a_Position;
    void main() {
      // 画点时需要给点加一个大小
      gl_PointSize = 10.0;
      gl_Position = a_Position;
    } 
  `;

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

  const [res, program] = initShaders(gl, vertex, fragment);

  if (!res) {
    return
  }

  // 多边形顶点
  const vertices = [
    [-0.7, 0.5],
    [-0.4, 0.3],
    [-0.25, 0.71],
    [-0.1, 0.56],
    [-0.1, 0.13],
    [0.4, 0.21],
    [0, -0.6],
    [-0.3, -0.3],
    [-0.6, -0.3],
    [-0.45, 0.0],
  ];

  const points = vertices.flat();
  const triangles = earcut(points);
  console.log(points, triangles);

  const position = new Float32Array(points);
  const cells = new Uint16Array(triangles);

  // 将定义好的数据写入 WebGL 的缓冲区
  const bufferId = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
  gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);

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

  const cellsBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cellsBuffer);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, cells, gl.STATIC_DRAW);

  // 声明底色
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawElements(gl.LINE_STRIP, cells.length, gl.UNSIGNED_SHORT, 0);
  // gl.drawElements(gl.TRIANGLES, cells.length, gl.UNSIGNED_SHORT, 0);
  // 画出点
  gl.drawArrays(gl.POINTS, 0, 10);
});
</script>
  
<template>
  <canvas></canvas>
</template>

最终效果:

image.png