基础概念
1 基础概念
1.1 重要性
WebGL经常被当成3D API,做出炫酷的3D作评。 但WebGL仅仅是一个光栅化引擎,它可以绘制出点,线和三角形。 想要利用WebGL完成更复杂任务,需要组合使用点,线和三角形来实现。
mapbox
、three
、cesium
、three
等都是在不同的应用场景,对WebGL的代码进行了一定的实现与封装,暴露出来具有特殊功能的api,而当我们需要自定义功能,且不在这些库函数中可以找到时,我们就需要书写程序来自行实现。
WebGL探索系列目录
:传送门
1.2 使用的语言
WebGL在电脑的GPU中运行。所以需要使用能够在GPU上运行的代码。 这样的代码需要提供成对的方法。每对方法中一个叫顶点着色器, 另一个叫片段着色器,并且使用一种和C或C++类似的强类型的语言GLSL
。 (GL着色语言)。 每一对组合起来称作一个 program
(着色程序)。
1.3 顶点着色器与片源着色器
顶点着色器的作用是计算顶点的位置。根据计算出的一系列顶点位置,WebGL可以对点, 线和三角形在内的一些图元进行光栅化处理。当对这些图元进行光栅化处理时需要使用片段着色器方法。 片段着色器的作用是计算出当前绘制图元中每个像素的颜色值。
几乎整个WebGL API都是关于如何设置这些成对方法的状态值以及运行它们。 对于想要绘制的每一个对象,都需要先设置一系列状态值,然后通过调用 gl.drawArrays
或 gl.drawElements
运行一个着色方法对,使得你的着色器对能够在GPU上运行。
这些方法对所需的任何数据都需要发送到GPU,这里有着色器获取数据的4种方法。
1.3.1 属性(Attributes)和缓冲
缓冲是发送到GPU的一些二进制数据序列,通常情况下缓冲数据包括位置,法向量,纹理坐标,顶点颜色值等。 你可以存储任何数据。
属性用来指明怎么从缓冲中获取所需数据并将它提供给顶点着色器。 例如你可能在缓冲中用三个32位的浮点型数据存储一个位置值。 对于一个确切的属性你需要告诉它从哪个缓冲中获取数据,获取什么类型的数据(三个32位的浮点数据), 起始偏移值是多少,到下一个位置的字节数是多少。
缓冲不是随意读取的。事实上顶点着色器运行的次数是一个指定的确切数字, 每一次运行属性会从指定的缓冲中按照指定规则依次获取下一个值。
1.3.2 全局变量(Uniforms)
全局变量在着色程序运行前赋值,在运行过程中全局有效。
1.3.3 纹理(Textures)
纹理是一个数据序列,可以在着色程序运行中随意读取其中的数据。 大多数情况存放的是图像数据,但是纹理仅仅是数据序列, 你也可以随意存放除了颜色数据以外的其它数据。
1.3.4 可变量(Varyings)
可变量是一种顶点着色器给片段着色器传值的方式,依照渲染的图元是点, 线还是三角形,顶点着色器中设置的可变量会在片段着色器运行中获取不同的插值。
2 WebGL Hello World
在许多语言中,Hello World都似乎是一个很简单的操作,通常只需要一句打印进行打印即可,但是WebGL的Hello World可复杂的多。
WebGL只关心两个值:裁剪空间中的坐标值和颜色值,这两个值分别由两个着色器来提供,顶点着色器提供裁剪空间坐标值,片段着色器提供颜色值。
2.1 顶点着色器
// 一个属性值,将会从缓冲中获取数据
attribute vec4 a_position;
// 所有着色器都有一个main方法
void main() {
// gl_Position 是一个顶点着色器主要设置的变量
gl_Position = a_position;
}
以上就是GLSL
语言,使用attribute
定义了一个vec4
的变量a_position
,并在main
函数中赋值给webgl自带的gl_Position
变量,注意P是大写。
而GLSL代码并不能在js中运行,需要交给GPU去运行,所以将这些内容定义成一个字符串,最后将字符串交给GPU去运行。我们定义了a_position
变量,那么在js代码中就需要有获取这个变量,并给该变量赋值,这样就相当于给默认的变量gl_Position
赋值,这些方法后面都会学到。
2.2 片源着色器
// 片段着色器没有默认精度,所以我们需要设置一个精度
// mediump是一个不错的默认值,代表“medium precision”(中等精度)
precision mediump float;
void main() {
// gl_FragColor是一个片段着色器主要设置的变量
gl_FragColor = vec4(1, 0, 0.5, 1); // 返回“红紫色”
}
上方我们设置 gl_FragColor
为 1, 0, 0.5, 1
,其中1代表红色值,0代表绿色值, 0.5代表蓝色值,最后一个1表示阿尔法通道值。WebGL中的颜色值范围从 0 到 1 。
2.3 创建着色程序
有了两个着色器后,可以正式开始使用WebGL了。
首先需要一个HTML中的canvas(画布)对象。
<canvas id="canvas"></canvas>
随后使用js获取
const canvas = document.querySelector("#canvas");
创建一个WebGL渲染上下文(WebGLRenderingContext)
const gl = canvas.getContext("webgl");
if (!gl) {
// 你不能使用WebGL!
...
定义顶点着色器与片源着色器。
const vertexShaderSource = `
// 一个属性变量,将会从缓冲中获取数据
attribute vec4 a_position;
// 所有着色器都有一个main方法
void main() {
// gl_Position 是一个顶点着色器主要设置的变量
gl_Position = a_position;
}
`
const fragmentShaderSource = `
// 片段着色器没有默认精度,所以我们需要设置一个精度
// mediump是一个不错的默认值,代表“medium precision”(中等精度)
precision mediump float;
void main() {
// gl_FragColor是一个片段着色器主要设置的变量
gl_FragColor = vec4(1, 0, 0.5, 1); // 返回一个颜色
}
`
封装一个创建着色器的方法如下。
export function loadShader(gl, type, source) {
// 创建着色器的对象
const shader = gl.createShader(type);
if (shader == null) {
console.log('unable to create shader');
return null;
}
// 设置着色器的源码
gl.shaderSource(shader, source);
// 编译着色器
gl.compileShader(shader);
// 检查是否编译成功
const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!compiled) {
const error = gl.getShaderInfoLog(shader);
console.log('Failed to compile shader: ' + error);
gl.deleteShader(shader);
return null;
}
return shader;
}
此时可以用以上封装的方法创建两个着色器
// 创建着色器对象
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
然后我们将这两个着色器 link(链接)到一个 program(着色程序)
export function initShaders(gl, vshader, fshader) {
// 创建着色器对象
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
// 创建一个program对象
const program = gl.createProgram();
if (!program) {
return null;
}
// 将着色器添加到program中
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// 链接program对象
gl.linkProgram(program);
// 判断是否链接成功
const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
const error = gl.getProgramInfoLog(program);
console.log('Failed to link program: ' + error);
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
// 判断program是否创建成功
if (!program) {
console.log('Failed to create program');
return false;
}
// 使用指定的program作为当前渲染状态的一部分
gl.useProgram(program);
return program;
}
然后调用它
const program = initShaders(gl, vertexShaderSource, fragmentShaderSource)
2.4 创建缓冲区装载数据
已经在GPU上创建了一个GLSL着色程序,还需要给它提供数据。 WebGL的主要任务就是设置好状态并为GLSL着色程序提供数据。 在这个例子中GLSL着色程序的唯一输入是一个属性值a_position
。 首先从刚才创建的GLSL着色程序中找到这个属性值所在的位置。
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
寻找属性值位置(和全局属性位置)应该在初始化的时候完成,而不是在渲染循环中。
属性值从缓冲中获取数据,所以创建一个缓冲。
const positionBuffer = gl.createBuffer();
WebGL可以通过绑定点操控全局范围内的许多数据,可以把绑定点想象成一个WebGL内部的全局变量。 首先绑定一个数据源到绑定点,然后可以引用绑定点指向该数据源。用来绑定位置信息缓冲(下面的绑定点就是ARRAY_BUFFER
)。
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
现在需要通过绑定点向缓冲中存放数据。
// 三个二维点坐标
const positions = [
0, 0,
0, 0.5,
0.7, 0,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
这里完成了一系列事情,第一件事是我们有了一个JavaScript序列position
s 。 然而WebGL需要强类型数据,所以new Float32Array(positions)
创建了32位浮点型数据序列, 并从positions
中复制数据到序列中,然后gl.bufferData
复制这些数据到GPU的positionBuffer
对象上。 它最终传递到positionBuffer
上是因为在前一步中我们我们将它绑定到了ARRAY_BUFFER
(也就是绑定点)上。
最后一个参数gl.STATIC_DRAW
是提示WebGL我们将怎么使用这些数据。WebGL会根据提示做出一些优化。 gl.STATIC_DRAW
提示WebGL我们不会经常改变这些数据。
在此之上的代码是 初始化代码。这些代码在页面加载时只会运行一次。 接下来的代码是渲染代码,而这些代码将在我们每次要渲染或者绘制时执行。
2.4 渲染
在绘制之前我们应该调整画布(canvas)的尺寸以匹配它的显示尺寸。画布就像图片一样有两个尺寸。 一个是它拥有的实际像素个数,另一个是它显示的大小。CSS决定画布显示的大小。为了使画布的像素数和显示大小匹配,使用了方法来实现。
export function resizeCanvasToDisplaySize(canvas: any, multiplier?: number) {
multiplier = multiplier || 1;
const width = canvas.clientWidth * multiplier | 0;
const height = canvas.clientHeight * multiplier | 0;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
return true;
}
return false;
}
resizeCanvasToDisplaySize(gl.canvas);
这样如果嵌在页面中,它就会被拉伸以填满可用空间。
在渲染之前,要说明的是渲染的画布是canvas
,已经在html中定义了一个canvas
,在gl中存在一个canvas
,记录了画布的一些基本信息,利用该信息可以告诉WebGL怎样把提供的gl_Position
裁剪空间坐标对应到画布像素坐标, 通常我们也把画布像素坐标叫做屏幕空间。为了实现这个目的,我们只需要调用gl.viewport
方法并传递画布的当前尺寸。
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
这样就告诉WebGL裁剪空间的 -1 -> +1 分别对应到x轴的 0 -> gl.canvas.width 和y轴的 0 -> gl.canvas.height。
使用0, 0, 0, 0清空画布,分别对应 r, g, b, alpha (红,绿,蓝,阿尔法)值, 所以在这个例子中我们让画布变透明了。
// 清空画布
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
接下来我们需要告诉WebGL怎么从我们之前准备的缓冲中获取数据给着色器中的属性。 首先我们需要启用对应属性。
gl.enableVertexAttribArray(positionAttributeLocation);
然后指定从缓冲中读取数据的方式
// 告诉属性怎么从positionBuffer中读取数据 (ARRAY_BUFFER)
const size = 2; // 每次迭代运行提取两个单位数据
const type = gl.FLOAT; // 每个单位的数据类型是32位浮点型
const normalize = false; // 不需要归一化数据
const stride = 0; // 0 = 移动单位数量 * 每个单位占用内存(sizeof(type))
// 每次迭代运行运动多少内存到下一个数据开始点
const offset = 0; // 从缓冲起始位置开始读取
gl.vertexAttribPointer(
positionAttributeLocation, size, type, normalize, stride, offset)
一个隐藏信息是gl.vertexAttribPointer
是将属性绑定到当前的ARRAY_BUFFER
。 换句话说就是属性绑定到了positionBuffer
上。这也意味着现在利用绑定点随意将 ARRAY_BUFFER
绑定到其它数据上后,该属性依然从positionBuffer
上读取数据。
从GLSL
的顶点着色器中注意到a_position
属性的数据类型是vec4
,vec4
是一个有四个浮点数据的数据类型。在JavaScript中你可以把它想象成 a_position = {x: 0, y: 0, z: 0, w: 0}
。之前我们设置的size = 2
, 属性默认值是0, 0, 0, 1
,然后属性将会从缓冲中获取前两个值( x 和 y )。 z和w还是默认值 0 和 1 。
写到这里,准备工作才告一段落,可以开始正式的绘制了,使用gl.drawArrays
函数进行绘制,最常用的是绘制三角形,可以使用gl.TRIANGLES
绘制三角形。
const primitiveType = gl.TRIANGLES;
const offset = 0;
const count = 3;
gl.drawArrays(primitiveType, offset, count);
因为count = 3
,所以顶点着色器将运行三次。 第一次运行将会从位置缓冲中读取前两个值赋给属性值a_position.x
和a_position.y
。 第二次运行a_position.xy将会被赋予后两个值,最后一次运行将被赋予最后两个值。
因为我们设置primitiveType
(图元类型)为 gl.TRIANGLES
(三角形), 顶点着色器每运行三次WebGL将会根据三个gl_Position值绘制一个三角形, 不论我们的画布大小是多少,在裁剪空间中每个方向的坐标范围都是 -1 到 1 。
由于我们的顶点着色器仅仅是传递位置缓冲中的值给gl_Position
, 所以三角形在裁剪空间中的坐标如下。
0, 0,
0, 0.5,
0.7, 0,
WebGL将会把它们从裁剪空间转换到屏幕空间并在屏幕空间绘制一个三角形, 如果画布大小是400×300我们会得到类似以下的转换
裁剪空间 屏幕空间
0, 0 -> 200, 150
0, 0.5 -> 200, 225
0.7, 0 -> 340, 150
现在WebGL将渲染出这个三角形。绘制每个像素时WebGL都将调用我们的片段着色器。 我们的片段着色器只是简单设置gl_FragColor
为1, 0, 0.5, 1
, 由于画布的每个通道宽度为8位,这表示WebGL最终在画布上绘制[255, 0, 127, 255]
。
2.5完整代码与运行结果
附上整个过程的完整代码
webgl-utils文件
export function resizeCanvasToDisplaySize(canvas: any, multiplier?: number) {
multiplier = multiplier || 1;
const width = canvas.clientWidth * multiplier | 0;
const height = canvas.clientHeight * multiplier | 0;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
return true;
}
return false;
}
/**
* 着色器初始化
* @param gl WebGL2RenderingContext | WebGLRenderingContext
* @param vshader 顶点着色器
* @param fshader 片源着色器
* @return 创建program对象
*/
export function initShaders(gl: WebGL2RenderingContext | WebGLRenderingContext, vshader: string, fshader: string) {
// 创建着色器对象
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
// 创建一个program对象
const program = gl.createProgram();
if (!program) {
return null;
}
// 将着色器添加到program中
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// 链接program对象
gl.linkProgram(program);
// 判断是否链接成功
const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
const error = gl.getProgramInfoLog(program);
console.log('Failed to link program: ' + error);
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
// 判断program是否创建成功
if (!program) {
console.log('Failed to create program');
return false;
}
// 使用指定的program作为当前渲染状态的一部分
gl.useProgram(program);
return program;
}
具体的代码实现
<template>
<canvas id="canvas"></canvas>
</template>
<script setup lang="ts">
import { initShaders } from '@/utils/webgl/module/webgl-utils';
const vertexShaderSource = `
// 一个属性变量,将会从缓冲中获取数据
attribute vec4 a_position;
// 所有着色器都有一个main方法
void main() {
// gl_Position 是一个顶点着色器主要设置的变量
gl_Position = a_position;
}
`
const fragmentShaderSource = `
// 片段着色器没有默认精度,所以我们需要设置一个精度
// mediump是一个不错的默认值,代表“medium precision”(中等精度)
precision mediump float;
void main() {
// gl_FragColor是一个片段着色器主要设置的变量
gl_FragColor = vec4(1, 0, 0.5, 1); // 返回一个颜色
}
`
onMounted(() => {
const canvas = document.querySelector("#canvas") as HTMLCanvasElement
const gl = canvas.getContext("webgl")
if(!gl) return
const program = initShaders(gl, vertexShaderSource, fragmentShaderSource)
if(program) {
// 从顶点着色程序中找到a_position属性
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
// 创建一个缓冲区,并在其中放置三个二维剪辑空间点
const positionBuffer = gl.createBuffer();
// 将其绑定到ARRAY_BUFFER
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [
0, 0,
0, 0.5,
0.7, 0,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
// 告诉WebGL如何从剪辑空间转换为像素
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// 清空画布
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 开启a_position属性
gl.enableVertexAttribArray(positionAttributeLocation);
// 告诉属性怎么从positionBuffer中读取数据 (ARRAY_BUFFER)
const size = 2; // 每次迭代运行提取两个单位数据
const type = gl.FLOAT; // 每个单位的数据类型是32位浮点型
const normalize = false; // 不需要归一化数据
const stride = 0; // 0 = 移动单位数量 * 每个单位占用内存(sizeof(type))
// 每次迭代运行运动多少内存到下一个数据开始点
const offset1 = 0; // 从缓冲起始位置开始读取
gl.vertexAttribPointer(
positionAttributeLocation, size, type, normalize, stride, offset1)
// 绘制
const primitiveType = gl.TRIANGLES;
const offset2 = 0;
const count = 3;
gl.drawArrays(primitiveType, offset2, count);
}
})
</script>
<style scoped lang="scss">
canvas {
width: 100%;
height: 100%;
display: block;
}
</style>
运行结果,这里放两张图对比以下,上面一张是没有执行resizeCanvasToDisplaySize的,下面一张是执行了resizeCanvasToDisplaySize函数的,可以看到,提升很明显。
到此时为止,终于把WebGL一个完整的Hello World完成了,这时,我们发现,这样的Hello World真不是我们想象中那么简单的,里面有很多需要注意的点,也有很多很难理解的概念,还有一些超级复杂的函数,按部就班的步骤。