计算机图形系统基础
想要学好 WebGL,我们必须先理解一些基本概念和原理,所以需要先了解计算机图形系统及绘图原理。
图形系统构成
- 输入设备
- CPU(Central Processing Unit):中央处理单元,负责逻辑计算。
- GPU(Graphics Processing Unit):图形处理单元,负责图形计算。
- 存储器
- 帧缓存(Frame Buffer):在绘图过程中,像素信息被存放于帧缓存中,帧缓存是一块内存地址。
- 输出设备
计算机绘制图形过程
- 数据经过 CPU 处理,成为具有特定结构的几何信息。
- 这些信息会被送到 GPU 中进行处理。
- 生成光栅信息,光栅(Raster),几乎所有的现代图形系统都是基于光栅来绘制图形的,光栅就是指构成图像的像素阵列。
- 光栅信息会输出到帧缓存中。
- 渲染到屏幕上。
这里主要做了两件事,一是对给定的数据结合绘图的场景要素(例如相机、光源、遮挡物体等等)进行计算,最终将图形变为屏幕空间的 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 来定义顶点等数据。以一个最简单的三角形为例,如下图所示:
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>
最终效果:
封装初始化和加载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 实现)
将下图的形状进行三角剖分:
<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>
最终效果: