目标:按照恒定速度旋转的三角形。
动画基础
为了让一个三角形转动起来,你需要做的是:不断擦除和重绘三角形,并且在每次重绘时轻微地改变其角度。
下图显示了一个转动的三角形在t0、t1、t2、t3、t4时刻的情形。每一张都是静态的,但是你能看出每张图中三角形的角度略有不同。当年按照顺序快速连续地看到这些图的时候,你的大脑就会不自觉地对影响进行插值,形成流畅的动画,就像“翻书页”一样。当然,在绘制一个新的三角形之前,你还需要将上一个三角形擦除掉。在绘制之前,我们需要调用 gl.clear(),这条规则不管是对 2D 图形还是 3D 对象都适用。
为了生成动画,我们需要两个关键机制:
机制一:在t0、t1、t2、t3 等时刻反复调用同一个函数来绘制三角形。
机制二:每次绘制之前,清楚上次绘制的内容,并使三角形旋转相应的角度。
第二个机制的实现很简单,之前学到的知识已经足够应付了;而如何实现第一个机制是一个新的问题,让我们一步一步来研究示例程序的代码。
示例程序
该示例程序与前一个示例程序有以下三点区别:
- 由于程序需要反复地绘制三角形,所以我们早早就指定了背景色,而不是在进行绘制之前。记住,在 WebGL 中,设置好的背景色在重设之前一直有效。
- 实现了反复调用绘制函数的机制。
- 定义了 draw()函数以实现清除和绘制三角形的操作。
RotatingTriangle.js
//顶点着色器程序
var VSHADER_SOURCE =
'attribute vec4 a_Position;'+
'uniform mat4 u_ModelMatrix;'+
'void main(){'+
'gl_Position = u_ModelMatrix * a_Position;'+
'}';
//片元着色器程序
var FSHADER_SOURCE=
'void main(){'+
'gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);'+
'}';
//旋转速度(度/秒)
var ANGLE_STEP = 45.0;
function main() {
//获取canvas元素
var canvas = document.getElementById("webgl");
if(!canvas){
console.log("Failed to retrieve the <canvas> element");
return;
}
//获取WebGL绘图上下文
var gl = getWebGLContext(canvas);
if(!gl){
console.log("Failed to get the rendering context for WebGL");
return;
}
//初始化着色器
if(!initShaders(gl,VSHADER_SOURCE,FSHADER_SOURCE)){
console.log("Failed to initialize shaders.");
return;
}
//设置顶点位置
var n = initVertexBuffers(gl);
if (n < 0) {
console.log('Failed to set the positions of the vertices');
return;
}
//指定清空<canvas>颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0);
//获取 u_ModelMatrix 变量的存储位置
var u_ModelMatrix = gl.getUniformLocation(gl.program, 'u_ModelMatrix');
if(u_ModelMatrix < 0){
console.log("Failed to get the storage location of u_ModelMatrix");
return;
}
//三角形的当前旋转度
var currentAngle = 0.0;
//模型矩阵
var modelMatrix = new Matrix4();
//开始绘制三角形
var tick = function(){
currentAngle = animate(currentAngle); //更新旋转角
draw(gl, n, currentAngle, modelMatrix, u_ModelMatrix);
requestAnimationFrame(tick); //请求浏览器调用 tick
};
tick();
}
function initVertexBuffers(gl) {
var vertices = new Float32Array([
0.0, 0.5, -0.5, -0.5, 0.5, -0.5
]);
var n=3; //点的个数
//创建缓冲区对象
var vertexBuffer = gl.createBuffer();
if(!vertexBuffer){
console.log("Failed to create thie buffer object");
return -1;
}
//将缓冲区对象保存到目标上
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
//向缓存对象写入数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
if(a_Position < 0){
console.log("Failed to get the storage location of a_Position");
return -1;
}
//将缓冲区对象分配给a_Postion变量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
//连接a_Postion变量与分配给它的缓冲区对象
gl.enableVertexAttribArray(a_Position);
return n;
}
//记录上一次调用函数的时间
var g_last = Date.now();
function animate(angle){
//计算距离上一次调用经过多少时间
var now = Date.now();
var elapsed = now - g_last;
g_last = now;
//根据距离上次调用的时间,更新当前旋转角度
var newAngle = angle + (ANGLE_STEP * elapsed) / 1000.0;
return newAngle %= 360;
}
function draw(gl, n, currentAngle,modelMatrix,u_ModelMatrix){
//设置旋转矩阵
modelMatrix.setRotate(currentAngle, 0, 0, 1);
//将旋转矩阵传输给顶点着色器
gl.uniformMatrix4fv(u_ModelMatrix, false, modelMatrix.elements);
//清空<canvas>
gl.clear(gl.COLOR_BUFFER_BIT);
//绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, n);
}
在顶点着色器中,我们将变换矩阵和顶点坐标相乘,这与 RotatedTriangle.js 一样。JS 程序把旋转矩阵传给 uniform 变量 u_ModelMatrix。
'gl_Position = u_ModelMatrix * a_Position;'+
在JS程序中,我们定义了变量 ANGLE_STEP 表示三角戏每秒旋转的角度,单位是度/秒。
var ANGLE_STEP = 45.0;
虽然即将要进行多次绘制操作,但我们只需要制定一次背景色。同样,我们也只需要获取一次顶点着色器的 u_ModelMatrix 变量的存储地址,因为着色器中变量的存储地址也不会改变。
var u_ModelMatrix = gl.getUniformLocation(gl.program, 'u_ModelMatrix');
最后,利用 u_ModelMatrix 变量和 draw() 函数把三角形绘制出来。
变量 currentAngle中的初始值为0,这个变量表示,每次绘制时,三角形相对于初始状态被旋转的角度值。就像在前面一个示例程序中利用 ANGLE 变量来计算旋转矩阵一样。modelMatrix 是 Matrix4 类型的对象,表示当前的旋转矩阵, draw()函数利用它进行绘图。我们在draw()函数内部创建模型矩阵 modelMatrxi (而是在函数外部创建),这是因为,如果我们那样做,那么每次调用 draw()函数时都会创建一个 Matrxi4 对象,这会降低性能。所以,我们选择在draw()函数外部创建 modelMatrxi ,然后作为参数传给 draw()函数,这样在每次调用draw()函数时,只需要调用其含 se t前缀的方法重新计算,而不需要再用 new 运算符创建之。
tick()函数实现了上文提到的机制一,该函数将被反复调用,每次调用就会更新 currentAngle 并重新绘制三角形。在你完全了解它的实际运行机制之前,让我们先来看一下在 tick()函数中究竟发生了什么:
//三角形的当前旋转度
var currentAngle = 0.0;
//模型矩阵
var modelMatrix = new Matrix4();
//开始绘制三角形
var tick = function(){
currentAngle = animate(currentAngle); //更新旋转角
draw(gl, n, currentAngle, modelMatrix, u_ModelMatrix);
requestAnimationFrame(tick); //请求浏览器调用 tick
};
tick();
在 tick()函数中,我们调用了 animate()方法,更新了三角形的当前角度 currentAngle;之后,我们调用了 draw()函数,该函数将绘制三角形。
draw()函数将旋转矩阵传递给了顶点着色器中的 u_ModelMatrix 变量,它将三角形旋转了 currentAngle 度,然后再调用 gl.drawArrays()进行绘制。这部分代码看上去有些复杂,我们来仔细研究一下。
反复调用绘制函数(tick())
如前所述,为了使三角形动起来,你需要反复进行以下两步:
- 更新三角形的当前角度 currentTriangle;
- 调用绘制函数,根当前角度绘制三角形。
我们将上述两步操作写在一个匿名函数中,然后把这个匿名函数赋值给 tick 变量。在该函数中,我们将定义在 main()函数中的诸多局部变量作为参数传给了 draw()函数。
你可以使用上述的基本步骤来实现各种动画,这也是3D图形编程的关键技术。
当你调用 requestAnimationFrame()函数时,即是在告诉浏览器在将来的某个时间调用作为第一个参数的函数,那时 tick()将再次执行。稍后再来研究 requestAnimationFrame(),在此之前,让我们先弄清楚 tick()究竟是怎样把三角形画出来的。
按照指定的旋转角度绘制三角形(draw())
draw()函数接受以下五个参数。
- gl:绘制三角形的上下文。
- n:顶点个数。
- currentAngle:当前的旋转角度。
- modelMatrix:根据当前的旋转角度 currentAngle 计算出的旋转矩阵,存储在Matrix4对象中。
- u_ModelMatrix:顶点着色器中同名的 uniform 变量的存储位置,modelMatrix 变量将被传递至此处。
function draw(gl, n, currentAngle,modelMatrix,u_ModelMatrix){
//设置旋转矩阵
modelMatrix.setRotate(currentAngle, 0, 0, 1);
//将旋转矩阵传输给顶点着色器
gl.uniformMatrix4fv(u_ModelMatrix, false, modelMatrix.elements);
//清空<canvas>
gl.clear(gl.COLOR_BUFFER_BIT);
//绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, n);
}
首先使用setRotate()方法计算了旋转矩阵,将结果写入 modelMatrix。
modelMatrix.setRotate(currentAngle, 0, 0, 1);
然后,使用 gl.uniformMatrix4fv()将旋转矩阵传入顶点着色器。
gl.uniformMatrix4fv(u_ModelMatrix, false, modelMatrix.elements);
接着清除<canvas>,调用 gl.drawArrays()函数执行着色器并绘制三角形。
现在我们回到 requestAnimationFrame()函数。这个函数的作用是:对浏览器发出一次请求,请求在未来某个适当的时机调用 tick()函数方法。
请求再次被调用(requestAnimaitionFrame())
传统习惯来说,如果你想要JS重复执行某个指定的任务,你可以使用 setInterval()函数。
现在的浏览器支持多个标签页,每个标签页具有单独的JS运行环境,但是自 setInterval()函数诞生之初,浏览器还没有开始支持多标签页。所以在现代浏览器中,不管标签页是否被激活,其中的 setInterval()函数都会反复调用 func,如果标签页比较多,就会增加浏览器的负荷。所以后来没浏览器又引入了 requestAnimationFrame()方法,该方法只有当标签页处于激活状态时才会生效。requestAnimationFrame()时新引入的方法,还没有实现表转化。好在 Google 提供的 webgl_utils.js 库提供了该函数的定义并隐藏了浏览器间的差异。
使用这个函数的好处是可以避免在未激活的标签页上运行动画。注意,你无法指定重复调用的间隔;函数 func 会在浏览器需要网页的某个元素重绘时被调用。此外还需注意,在浏览器成功地调用了一次fun 后,想要再次调用它,就必须再次发起请求,因为前一次的请求已经结束(也就是说 resquestAnimationFrame 更像 setTimeOut 而不是 setTimeInterval,不会因为你发起一次请求,就不停地循环调用 func)。此外,在调用函数后,你需要发出下次调用的请求,因为上一次关于调用的请求在调用完成之后就结束了使命。在tick()函数的最后,我们请求再次调用 tick()函数,这样就可以循环反复地执行 tick()函数了:
requestAnimationFrame(tick); //请求浏览器调用 tick
如果你想取消请求,需要使用 cancelAnimationFrame()。
更新旋转角(animate())
最后来看看如何更新三角形当前的旋转角度。程序在变量 currentAngle中存储了三角形的当前旋转角度(即从初始位置算起,当前三角形旋转了多少度。我们很具当前的旋转角度计算旋转矩阵)。
我们利用animate()函数更新 cunrrentAngle变量。animate()方法接受参数 angle,表示前一个旋转角,函数返回当前的旋转角。
currentAngle = animate(currentAngle); //更新旋转角
draw(gl, n, currentAngle, modelMatrix, u_ModelMatrix);
...
//记录上一次调用函数的时间
var g_last = Date.now();
function animate(angle){
//计算距离上一次调用经过多少时间
var now = Date.now();
var elapsed = now - g_last;
g_last = now;
//根据距离上次调用的时间,更新当前旋转角度
var newAngle = angle + (ANGLE_STEP * elapsed) / 1000.0;
return newAngle %= 360;
}
更新旋转角的过程稍微有些复杂,具体原因我们通过下图来解释:
- t0 时刻调用 tick()时,将指定 draw()函数绘制三角形,然后请求下一次调用 tick()。
- t1 时刻调用 tick()时,将指定 draw()函数绘制三角形,然后请求下一次调用 tick()。
- t2 时刻调用 tick()时,将指定 draw()函数绘制三角形,然后请求下一次调用 tick()。
这里的问题是,调用函数的时刻 t0、t1、t2 之间的间隔不是固定的。我们知道,requestAnimationFrame() 只是请求浏览器在适当的时机调用参数函数,那么浏览器就会根据自身状态决定 t0、t1、t2 时刻,在不同的浏览器上,或者在同一个浏览器的不同状态下,都有所不同。总而言之,t1-t0 很可能不等于 t2-t1。
既然调用 tick()函数的间隔不恒定,那么每次调用时简单地向 currentAngle 加上一个固定的角度制(度/秒)就会导致不可控制的加速或减速的旋转效果。
为此,animate()的函数逻辑就得复杂一些:根据本次调用与上次调用之间的时间间隔来决定这一帧旋转角度比上一帧打出多少。为此,我们将上一次调用的时刻存储在 g_last 变量中,将这一次调用的时刻存储在 now 变量中,然后计算二者的插值,得到这一次调用距离上一次调用的时间间隔,并存储在 elapsed变量中,这一帧三角形旋转的角度就由 elapsed 决定。
var newAngle = angle + (ANGLE_STEP * elapsed) / 1000.0;
变量 g_last 和 now 都是 Date 对象的 now()方法的返回值,其单位是毫秒(1/1000秒)。所以,你想让三角形以 ANGLE_STEP(度/秒)来旋转,你还需要将 ANGLE_STEP乘以 elapsed/1000 来计算旋转角度。我们先将 ANGLE_STEP 与 elapsed 相乘,再除以1000,其结果是相同的。
最后,在返回这一帧的旋转角 newAngle 的同时,还需要保证它始终小于360度并返回结果。
结果:
旋转的,动的