坐标系
WebGL 的坐标系不同于普通的 canvas。
首先我们来看一下 canvas2D 坐标系:
它的原点在左上角,X 轴正方向朝右,Y 轴正方向朝下,canvas 的长宽是按照像素大小进行绘制,1就代表1px。
下面再来看一下 WebGL 的坐标系:
WebGL 的原点是在整个画布的中心,X 轴的正方向朝右,Y 轴正方向朝上,Z 轴正方向朝屏幕外侧,WebGL 的大小表示也不一样,1 代表画布的宽、高或深度的一半。所以如果我们想要用像素来表示我们所画出来的元素的大小,那么就需要进行转换。
点
点的绘制比较简单,其核心 API 就是 gl.drawArrays(gl.POINTS, n, m);。
直接上代码,initShaders 代码见上一篇,下同。
<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';
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 = `
void main() {
gl_PointSize = 100.0;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
`;
const fragment = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
initShaders(gl, vertex, fragment);
// 声明底色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.POINTS, 0, 1);
});
</script>
<template>
<canvas></canvas>
</template>
多点绘制
其实和单点绘制区别不大,只是多了一个多点数据构建和传值的过程。
<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';
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 = 100.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 points = new Float32Array([
-0.5, -0.5,
0, 0.5,
0.5, -0.5,
]);
// 将定义好的数据写入 WebGL 的缓冲区
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
// 将缓冲区的数据读取到 GPU
// 获取顶点着色器中的position变量的地址
const vPosition = gl.getAttribLocation(program, 'a_Position');
// 给变量设置长度和类型
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
// 激活这个变量
gl.enableVertexAttribArray(vPosition);
// 声明底色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.POINTS, 0, 3);
});
</script>
<template>
<canvas></canvas>
</template>
线
线的绘制分为三种类型,分别对应三个API。
直接上代码:
<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';
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_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 points = new Float32Array([
-0.5, -0.5,
-0.5, 0.5,
0.5, -0.5,
0.5, 0.5,
]);
// 将定义好的数据写入 WebGL 的缓冲区
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
// 将缓冲区的数据读取到 GPU
// 获取顶点着色器中的position变量的地址
const vPosition = gl.getAttribLocation(program, 'a_Position');
// 给变量设置长度和类型
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
// 激活这个变量
gl.enableVertexAttribArray(vPosition);
// 声明底色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.LINES, 0, 4);
// gl.drawArrays(gl.LINE_STRIP, 0, 4);
// gl.drawArrays(gl.LINE_LOOP, 0, 4);
});
</script>
<template>
<canvas></canvas>
</template>
gl.LINES
gl.drawArrays(gl.LINES, n, m) 根据点绘制线,点与点两两相连。
效果如下图所示:
gl.LINE_STRIP
gl.drawArrays(gl.LINE_STRIP, n, m) 根据点绘制线,点与点依次相连。
效果如下图所示:
gl.LINE_LOOP
gl.drawArrays(gl.LINE_LOOP, n, m) 根据点绘制线,点与点依次相连,最后一个点与第一个点会形成闭合线段。
效果如下图所示:
面
WebGL 中对面的绘制其实就是画三角形,各种各样的三角形组合起来就变成了面甚至是有了3D的效果。
面的绘制方式比点和线要复杂一点,所以需要结合图文讲解。
gl.TRIANGLES
这是最简单的一种绘制方式,就是每三个点代表一个三角形,绘制 n 个三角形就需要 3n 个点。
绘制代码与上面绘制线的代码类似,修改一下点与绘制API即可:
// ......
const points = new Float32Array([
-0.5, -0.5,
-0.5, 0.5,
0.5, -0.5,
0.6, 0.5,
-0.4, 0.5,
0.6, -0.5,
]);
// ......
gl.drawArrays(gl.TRIANGLES, 0, 6);
效果如下图所示:
gl.TRIANGLE_STRIP
绘制三角带,三角带点位的绘制是有顺序的,还分正反。
正面:面向我们的面逆时针绘制。
反面:面向我们的面顺时针绘制。
三角带绘制的顺序如下:
- 第一个三角形:v0 -> v1 -> v2
- 第偶数个三角形:v2 -> v1 -> v3,以上一个三角形的第二条边的反方向 + 下一个点作为基础绘制
- 第奇数个三角形:v2 -> v3 -> v4,以上一个三角形的第三条边的反方向 + 下一个点作为基础绘制
关键代码如下:
// ......
const points = new Float32Array([
-0.5, -0.5,
-0.5, 0.5,
0.5, -0.5,
0.5, 0.5,
]);
// ......
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
效果如下图所示:
gl.TRIANGLE_FAN
绘制三角带,以上一个三角形的第三条边的反方向 + 下一个点为基础绘制。
关键代码如下:
// ......
const points = new Float32Array([
-0.5, -0.5,
-0.5, 0.5,
0.5, -0.5,
0.4, -0.8,
]);
// ......
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
效果如下图所示:
同步与异步绘制
这里的同步与异步指的是调用 API 绘制的顺序。
同步绘制指的就是多次绘制在一段同步代码中执行,这样的绘制结果只关乎于你调用了多少次绘制 API,每次绘制的结果都会被保留在画布上,呈现出来。
异步绘制指的多次绘制,在异步代码中分批执行了,这样去执行后一次会覆盖前一次绘制的结果,所以只会显示最后一次的内容。
比如:我们在屏幕中点击一次就在屏幕上多画一个点出来,每次点击的绘制显然是异步绘制的情况,所以我们在绘制的时候就需要对数据进行一些处理。专门用一个 points 数组来存储我们的点位数据,点一次就往数组里面加一个数据,绘制的时候遍历数组,没存在一个点就执行一次绘制 API,这个过程就是一个同步绘制的过程。
代码如下:
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { initShaders } from '../utils';
interface Color {
r: number;
g: number;
b: number;
a: number;
}
interface Point {
x: number;
y: number;
size: number;
color: Color;
}
const points = ref<Array<Point>>([{
x: 0,
y: 0,
size: 10.0,
color: {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
}
}]);
onMounted(() => {
const canvas = document.querySelector('canvas');
if (!canvas) {
return;
}
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext('webgl') as WebGLRenderingContext;
// 开启片元的颜色合成功能
gl.enable(gl.BLEND);
// 设置片元的合成方式
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA);
// 定义 attribute 变量,变量名 a_Position,类型 vec4
const vertex = `
attribute vec4 a_Position;
attribute float a_PointSize;
void main() {
gl_PointSize = a_PointSize;
gl_Position = a_Position;
}
`;
// 定义 uniform 变量,变量名 u_FragColor vec4
// precision mediump float 用于定义片元着色器的精度
const fragment = `
precision mediump float;
uniform vec4 u_FragColor;
void main() {
float dist = distance(gl_PointCoord, vec2(0.5, 0.5));
if (dist < 0.5) {
gl_FragColor = u_FragColor;
} else {
discard;
}
}
`;
const [res, program] = initShaders(gl, vertex, fragment);
if (!res) {
return
}
// 获取着色器中的 attribute 变量
const a_Position = gl.getAttribLocation(program, 'a_Position');
const a_PointSize = gl.getAttribLocation(program, 'a_PointSize');
// 获取 uniform 变量
const u_FragColor = gl.getUniformLocation(program, 'u_FragColor');
// 声明底色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.POINTS, 0, 1);
const render = () => {
gl.clear(gl.COLOR_BUFFER_BIT);
points.value.forEach(point => {
gl.vertexAttrib2f(a_Position, point.x, point.y);
gl.vertexAttrib1f(a_PointSize, point.size);
const { r, g, b, a } = point.color;
const arr = new Float32Array([r, g, b, a]);
gl.uniform4fv(u_FragColor, arr);
gl.drawArrays(gl.POINTS, 0, 1);
});
};
canvas.addEventListener('click', (event: MouseEvent) => {
const { clientX, clientY } = event;
const { left, top, height, width } = canvas.getBoundingClientRect();
const [cssX, cssY] = [clientX - left, clientY - top];
// WebGL 的坐标原点在画布中心,canvas 里面则是左上角
const [halfWidth, halfHeight] = [width / 2, height / 2];
// WebGL 里面的一个单位代表画布的宽或高的一半,canvas 里面则是代表一个像素
const [xBaseCenter, yBaseCenter] = [
cssX - halfWidth,
-(cssY - halfHeight), // WebGL Y 轴方向与 canvas 也是相反的
];
// 解决坐标基底差异
const [x, y] = [xBaseCenter / halfWidth, yBaseCenter / halfHeight];
points.value.push({
x,
y,
size: Math.random() * 30,
color: {
r: Math.random(),
g: Math.random(),
b: Math.random(),
a: Math.random(),
}
});
render();
})
});
</script>
<template>
<canvas></canvas>
</template>
纹理
WebGL 对图片素材是有严格要求的,图片的宽度和高度必须是 2 的 N 次幂,比如 16 x 16,32 x 32,64 x 64 等(否则无法直接使用)。实际上,不是这个尺寸的图片也能进行贴图,但是这样会使得贴图过程更复杂,从而影响性能,所以我们在提供图片素材的时候最好参照这个规范。
另外,图片坐标系与纹理坐标系是有差异的,图片坐标系其实就是 canvas2D 坐标系,纹理坐标系就是 WebGL 的坐标系。
下面是一些关键代码:
export function loadTexture(
gl: WebGLRenderingContext,
src: string,
attribute: WebGLUniformLocation,
callback?: () => void,
) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
gl.activeTexture(gl.TEXTURE0);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.uniform1i(attribute, 0);
if (callback) {
callback();
}
};
img.src = src;
}
<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders, loadTexture } from '../utils';
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 = `
precision mediump float;
attribute vec2 a_Position;
// 接收JavaScript传递过来的顶点 uv 坐标。
attribute vec2 a_Uv;
// 将接收的uv坐标传递给片元着色器
varying vec2 v_Uv;
void main() {
gl_Position = vec4(a_Position, 0, 1);
v_Uv = a_Uv;
}
`;
const fragment = `
precision mediump float;
// 接收顶点着色器传递过来的 uv 值。
varying vec2 v_Uv;
// 接收 JavaScript 传递过来的纹理
uniform sampler2D u_Texture;
void main() {
// 提取纹理对应uv坐标上的颜色,赋值给当前片元(像素)。
gl_FragColor = texture2D(u_Texture, vec2(v_Uv.x, v_Uv.y));
}
`;
const [res, program] = initShaders(gl, vertex, fragment);
if (!res) {
return
}
// 创建矩形顶点
const points = new Float32Array([
// 前两位是 WebGL 坐标,后面两位是 canvas 坐标
0, 0, 0, 1, // V0
0, 1, 0, 0, // V1
1, 1, 1, 0, // V2
0, 0, 0, 1, // V0
1, 1, 1, 0, // V2
1, 0, 1, 1, // V3
]);
// 将定义好的数据写入 WebGL 的缓冲区
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'a_Position');
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(aPosition);
const aUv = gl.getAttribLocation(program, 'a_Uv');
gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 16, 8);
gl.enableVertexAttribArray(aUv);
const uTexture = gl.getUniformLocation(program, 'u_Texture') as WebGLUniformLocation;
const render = () => {
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, points.length / 4);
};
loadTexture(gl, './top.png', uTexture, render);
});
</script>
<template>
<canvas></canvas>
</template>
效果如下图所示: