项目准备
本系列将全程采用TypeScript进行代码编写,你可以直接复制模板项目开始代码的编写。模板项目主要帮助你完成了以下工作
- 项目构建和运行的相关配置
- 对TypeScript的支持
- 创建Canvas,获取WebGL上下文并开启渲染循环
克隆 示例项目,复制根目录下的start project的文件夹到你喜欢的位置,可以任意更换文件夹名称。 复制项目后在根目录执行yarn install,然后运行yarn start可以在浏览器看到执行结果。src/index.ts是代码的总入口,你可以随意更改其中的代码来练习学习的内容。
下面简单看一下src/index.ts中的代码。
声明Canvas和WebGL上下文对象,WebGL上下文类似于2D绘制的上下文,上面挂载了WebGL的相关API
let gl: WebGLRenderingContext = null;
let canvas: HTMLCanvasElement = null;
创建Canvas并插入到DOM中,设置大小为整个窗口,然后获取WebGL上下文
// 准备WebGL的绘制上下文
function prepare() {
canvas = document.createElement("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.width = '' + window.innerWidth;
canvas.style.height = '' + window.innerHeight;
document.body.append(canvas);
window.onresize = function(evt: Event) {
console.log(evt);
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.width = '' + window.innerWidth;
canvas.style.height = '' + window.innerHeight;
};
gl = canvas.getContext("webgl");
}
使用requestAnimationFrame构建渲染循环。render在调用结束时通过requestAnimationFrame触发浏览器重绘,重绘时浏览器会再次调用render。每秒的最高循环次数和浏览器支持的最高帧率有关,一般来说可能是30或者60。
function render() {
// logic code
requestAnimationFrame(render);
}
在window.onload时启动上面的代码
window.onload = () => {
// 主流程
prepare();
render();
}
坐标系
在正式介绍本小节的WebGL项目之前,先来简单说下WebGL使用的坐标系,它的坐标原点在Canvas中间,x轴从左到右为正方向,y轴从下到上为正方向,x轴的范围从-1到1,y轴也是如此,大致如下图所示
不管是渲染2D图形还是3D图形,最终还是在2D坐标系中进行绘图。我们的3D坐标系只是抽象出来的坐标系,方便我们进行3D图形操作。后面会提到很多坐标系,我们会通过矩阵在这些坐标系中进行切换,但是不管怎么切换,最终都是要映射到2D坐标系中进行绘制的。
使用WebGL渲染一个三角形
接下来我们通过一个经典的三角形渲染的例子来理解WebGL的绘图流程,首先按照上一小节介绍的WebGL绘图流程逐步介绍相关的代码
Vertex Data
这一步准备顶点数据,并把它传递给GPU
var vertexData: WebGLBuffer;
// Prepare Vertex Data
let vertices = [
-0.5, -0.5, 0, // 左下角
0.5, -0.5, 0, // 右下角
0, 0.5, 0, // 中上
];
// 在GPU上为顶点数据开辟空间
vertexData = gl.createBuffer();
// 将上面开辟的空间进入编辑模式
gl.bindBuffer(gl.ARRAY_BUFFER, vertexData);
// 向上面开辟的空间写入数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
三角形有三个顶点,我们用三个数字表示一个顶点的坐标x,y,z,vertices包含9个数字,也就是3个顶点的坐标。vertexData是WebGLBuffer类型的变量,我们可以使用它向GPU传递顶点数据。在WebGL中,向GPU传递数据一般有三个基本步骤
vertexData = gl.createBuffer();,在GPU上创建一块数据缓冲区gl.bindBuffer(gl.ARRAY_BUFFER, vertexData),将数据缓冲区绑定到某个Target上,WebGL定义了很多Target,用来表明数据的用处,这里我们的数据缓冲区用于存放顶点数据,所以使用gl.ARRAY_BUFFERgl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);,向gl.ARRAY_BUFFER这个Target绑定的数据缓冲区写入数据
注意,这里上传数据使用new Float32Array是为了让每个数字都被转换成32位(4字节)宽度的浮点类型,因为GPU对数据所占字节数很敏感,我们所传的每个数据都必须严格按照它所规定的宽度传递。gl.STATIC_DRAW表示所传数据不会再更改,WebGL会根据你是否更改数据来优化数据放置的内存位置。
Vertex Shader
之前我们有说过,Shader就是跑在GPU上的程序,为了使用 Vertex Shader 和 Fragment Shader,我们需要对它们进行编译,链接,然后才能运行。 因为WebGL的Shader用的是C语言,所以这个过程和构建一个C语言程序基本类似。
首先我们定义Shader的源码,由于我们需要同时将Vertex Shader 和 Fragment Shader编译链接成一个GPU上的程序,所以这里将它们的源码全部列出
let vertexShaderCode =
"attribute vec4 position;"+
"void main() {" +
"gl_Position = position;" +
"}";
let fragmentShaderCode =
"void main() {"+
"gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);"+
"}";
这里Vertex Shader很简单,将你传入的顶点位置直接传递给下一步,gl_Position是保留关键字,表示你想给下一步传递的值。类型是vec4,包含x, y, z, w四个数值,x,y,z表示三个纬度的坐标,w是为了可以和矩阵相乘,增加的一个纬度,一般值为1。
接着就是将这两个源码编译成GPU程序
function compileShader(shaderSrc: string, shaderType: number): WebGLShader {
let shader = gl.createShader(shaderType);
gl.shaderSource(shader, shaderSrc);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.log('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
compileShader用于编译Shader的源码,你需要指定Shader的类型,是Vertex Shader 还是 Fragment Shader
function createProgram() {
program = gl.createProgram();
vertexShader = compileShader(vertexShaderCode, gl.VERTEX_SHADER);
fragmentShader = compileShader(fragmentShaderCode, gl.FRAGMENT_SHADER);
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.log('Unable to initialize the shader program: ' + gl.getProgramInfoLog(program));
}
return program;
}
createProgram将两个Shader的编译产物链接成GPU程序program,它的类型是WebGLProgram。当我们要使用这个GPU程序的时候,调用gl.useProgram(program)即可。
最后我们将第一步的顶点数据传递给Vertex Shader
首先指定使用上面的GPU程序
gl.useProgram(program);
绑定顶点数据的缓冲区到gl.ARRAY_BUFFER这个Target,这样后面的vertexAttribPointer才可以访问到这些数据
gl.bindBuffer(gl.ARRAY_BUFFER, vertexData);
激活Vertex Shader的position属性
let positionLoc = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionLoc);
设置position和顶点数据的对应方式
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 4 * 3, 0);
上面的代码表示每隔4 * 3(第5个变量)个字节从顶点数据取3(第2个变量)个Float(第3个变量)类型的数据作为Vertex Shader输入变量position,从第0(第6个变量)个字节开始取。之前有提到过,每个顶点都会经过一次Vertex Shader,这里Vertex Shader一共会被调用3次,position的值分别就是-0.5, -0.5, 0,0.5, -0.5, 0,0, 0.5, 0。
Primitive Assembly
一般来说,我们在调用draw开头的API时需要告知GPU我们希望以什么样的形式组装这些顶点,比如gl.drawArrays方法
gl.drawArrays(gl.TRIANGLES, 0, 3);
上面的代码表示使用第0,1,2三个顶点,构造一个三角形。gl.TRIANGLES模式下GPU每三个点构造一个三角形,如果你希望构造2个三角形,那么就需要6个顶点。除了这个模式,还有其他模式可以选择,这个在后面会介绍到。
Rasterization
这一步是GPU自动完成的,并不需要我们介入,GPU将上面输出的三角形进行光栅化,得到一系列的像素。
Fragment Shader
上一步产生的每个像素都会经过Fragment Shader来设置颜色。Fragment Shader的编译链接在Vertex Shader那一步已经介绍,我们通过gl.useProgram(program)就可以激活链接在其中的Fragment Shader了。例子中Fragment Shader只做了一件事情,将每个像素设置成为白色,我们通过设置gl_FragColor来设置像素颜色。它的类型也是vec4,每个分量取值范围从0到1,代表r,g,b,a,你可以自行更改数值来体验一下颜色的变化
void main() {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
Per-Fragment Operations
例子中我们还没有用到相关操作,等我们需要渲染3D物体的时候,我们就会用到诸如gl.enable(gl.DEPTH_TEST)这类的Per-Fragment Operation了。
Present
这一步WebGL帮助我们做了,前面的步骤会将像素写入到一块GPU内存区域(也就是FrameBuffer),然后WebGL通过双缓冲区交换将其显示在屏幕上。如果需要做离屏的绘图,对这一块就需要进行更深入的研究了。
补充
除了上述的主流程之外,例子中还有viewport,clearColor,clear三个方法,下面简单介绍一些它们的用处
viewport
指定绘制的区域,四个参数单位都是像素,分别是x,y,width,height。如果将width,height改为canvas宽高的一半,那么将会在左下角绘制一个完整的三角形,它的坐标系是左下角是(0, 0),右上角是(canvas.width, canvas.height)
gl.viewport(0,0,canvas.width/2,canvas.height/2);
你也可以利用viewport绘制四个三角形,对render中的代码做如下变动
gl.clearColor(0.2, 1, 1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
let drawSeq = () => {
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexData);
let positionLoc = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 4 * 3, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
gl.viewport(0,0,canvas.width/2,canvas.height/2);
drawSeq();
gl.viewport(canvas.width/2,0,canvas.width/2,canvas.height/2);
drawSeq();
gl.viewport(canvas.width/2,canvas.height/2,canvas.width/2,canvas.height/2);
drawSeq();
gl.viewport(0,canvas.height/2,canvas.width/2,canvas.height/2);
drawSeq();
clearColor
这个方法就是设置清空画布的颜色,参数是r,g,b,a,取值从0到1。
clear
这个方法会使用clearColor设置的颜色清空画布。
总结
本小节介绍了WebGL绘图流程的每一步所对应的代码,有些代码的编写方式和参数风格你可能感觉不适应,因为WebGL的API方法基本都是从OpenGL的C语言API 1:1迁移过来的。在后面的章节中会对这些API进行展开讲解,这里只做一个初步的展示。