WebGL学习04-让图形动起来

1,440 阅读9分钟

动画概览

动画的基本原理是连续改变绘制内容,利用人眼的视觉暂留效应,产生画面在动的感觉。每次展示的绘制内容一般称之为帧(Frame),每秒帧的数目越多,人眼会感觉越流畅。一般而言动画能达到每秒30帧就足够了,为了追求更加流畅细腻的动画效果,有时候会将每秒帧数升到60。越高的帧数意味着留给每帧绘制的时间越短,所以为了让动画能够达到足够高的帧数,就必须优化好每一次绘制所消耗的时间。

WebGL动画

为了让WebGL绘制的内容动起来,可以通过以下两种办法

1. 直接修改顶点数据

例子完整项目代码,在chapter2section1-0目录下

在前面介绍上传顶点数据到GPU时,使用了如下API

gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

我们可以每一帧都向GPU传递不同的顶点数据,不过需要将gl.STATIC_DRAW改为gl.DYNAMIC_DRAW,这样可以让系统更好的安排顶点数据所在的内存位置。利用这一点在绘制三角形的例子基础上做一些变动,让三角形绕中心旋转。

核心思路就是每次绘制之前改变顶点数据,然后使用gl.bufferData更新GPU上的数据,绘制代码目前都在render方法里,需要增加如下代码

首先计算render调用时从App启动开始经过的时间elapsedTime,它的单位是ms

let now = (new Date()).getTime();
let delta = now - lastRenderTime;
lastRenderTime = now;

elapsedTime += delta;

根据经过的时间旋转三角形的点,这里利用了二维点的旋转计算公式

function rotatePoint(x: number, y: number, degree: number) {
    let rad = degree / 180 * Math.PI;
    let newX = x * Math.cos(rad) - y * Math.sin(rad);
    let newY = x * Math.sin(rad) + y * Math.cos(rad);
    return {
        x: newX,
        y: newY
    };
}

计算出每一帧对应的三个点坐标,使用bufferData上传到GPU,然后继续之前的绘制流程

// update vertex data
let vertices = [
    -0.5, -0.5, 0, // 左下角
    0.5, -0.5, 0, // 右下角
    0, 0.5, 0, // 中上
];

let rotateDegree = elapsedTime / 1000 * 30; // 每秒30度
let newPoint1 = rotatePoint(vertices[0], vertices[1], rotateDegree);
vertices[0] = newPoint1.x;
vertices[1] = newPoint1.y;
let newPoint2 = rotatePoint(vertices[3], vertices[4], rotateDegree);
vertices[3] = newPoint2.x;
vertices[4] = newPoint2.y;
let newPoint3 = rotatePoint(vertices[6], vertices[7], rotateDegree);
vertices[6] = newPoint3.x;
vertices[7] = newPoint3.y;

gl.bindBuffer(gl.ARRAY_BUFFER, vertexData);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);

....
// 之前的绘制流程

整体方案很直接,计算改变顶点数据 -> 上传顶点数据 -> GPU绘制,但是它有个缺点,每帧都要将顶点数据全量传递给GPU,这会导致CPU到GPU的IO消耗直接和顶点数目成正比,是一种不太优化的做法。

2. 使用Vertex Shader

例子完整项目代码,在chapter2section1-1目录下

在三角形旋转的例子中,核心操作是根据经过的时间对每个顶点坐标进行重新计算,Vertex Shader的基本原理就是对每个顶点进行重新计算,所以我们可以通过它来进行动画。在Vertex Shader中我们已经可以拿到每个顶点的坐标了,现在我们需要拿到程序运行的总时间,这就用到了一个新的概念uniformuniform相当与全局变量,所有的Vertex Shader程序实例都会共享这个变量值,我们可以设置一个程序运行总时间的uniform,让所有运行的Vertex Shader都能够拿到这个值。对之前的Vertex Shader做一些改动

attribute vec4 position;
uniform float elapsedTime;
void main() {
    float rad = radians(elapsedTime / 1000.0 * 30.0);
    float x = position.x * cos(rad) - position.y * sin(rad);
    float y = position.x * sin(rad) + position.y * cos(rad);
    gl_Position = vec4(x, y, position.z, position.w);
}

uniform float elapsedTime;表示申明一个名称为elapsedTimeuniform变量,类型是float。然后在Vertex Shader的主程序中使用坐标旋转的算法对传入的位置进行计算

float rad = radians(elapsedTime / 1000.0 * 30.0);
float x = position.x * cos(rad) - position.y * sin(rad);
float y = position.x * sin(rad) + position.y * cos(rad);

最后将新的位置作为输出

gl_Position = vec4(x, y, position.z, position.w);

上面的Shader代码使用了一些数据类型和计算函数,下面是Shader中支持的数据类型

类型说明
void无类型
bool布尔类型
int有符号整数
float浮点数
vec2, vec3, vec4包含2,3,4 个浮点数的向量,可以使用{x,y,z,w}或者{r,g,b,a}或者{s,t,p,q}访问数据,也可以使用xy,xyz这种组合来访问数据
bvec2, bvec3, bvec4包含2,3,4 个布尔值的向量
ivec2, ivec3, ivec4包含2,3,4 个整型值的向量
mat2, mat3, mat42x2, 3x3, 4x4 的矩阵,可以通过下标索引访问
sampler2D2D纹理类型,可以通过它访问纹理的像素
samplerCube3D纹理类型,可以通过它访问纹理的像素

除了sin正弦,cos余弦,radians角度转弧度,这些内置函数外,还有以下内置函数可以使用

T表示可以是 floatvec2vec3vec4任意一种类型

函数名说明
T radians(T degrees)角度转弧度
T degrees(T radians)弧度转角度
T sin(T angle)正弦
T cos(T angle)余弦
T tan(T angle)正切
T asin(T x)反正弦
T acos(T x)反余弦
T atan(T y, T x)反正切
T atan(T y/x)反正切
T pow(T x, T y)x的y次方
T exp(T x)e的x次方
T log(T x)x以10为底的对数
T exp2(T x)2的x次方
T log2(T x)x以2为底的对数
T sqrt(T x)平方根
T inversesqrt(T x)求解1/sqrt(x)
T abs(T x)求绝对值
T sign(T x)获取数字的符号,返回 -1.0, 0.0, 或者 1.0
T floor(T x)小于等于x的最大整数
T ceil(T x)大于等于x的最小整数
T fract(T x)x - floor(x),相当于x的小数部分
T mod(T x, T y)x对y求模
T mod(T x, float y)x对y求模
T min(T x, T y)最小值
T min(T x, float y)最小值
T max(T x, T y)最大值
T max(T x, float y)最大值
T clamp(T x, T minVal, T maxVal)x如果小于minVal,返回minVal,如果大于maxVal,返回maxVal
T clamp(T x, float minVal, float maxVal)x如果小于minVal,返回minVal,如果大于maxVal,返回maxVal
T mix(T x, T y, T a)按照a混合x和y,相当于 a * x + (1 - a) * y
T mix(T x, T y, float a)按照a混合x和y,相当于 a * x + (1 - a) * y
T step(T edge, T x)x小于edge,返回0,大于edge,返回1
T step(float edge, T x)x小于edge,返回0,大于edge,返回1
T smoothstep(T edge0, T edge1, T x)将返回值限定在edge0和edge1之间,并对返回的值进行平滑
T smoothstep(float edge0, float edge1, T x)将返回值限定在edge0和edge1之间,并对返回的值进行平滑
float length(T x)向量的长度
float distance(T p0, T p1)两个点的距离
float dot(T x, T y)向量的点乘
vec3 cross(vec3 x, vec3 y)向量的叉乘
T normalize(T x)向量长度规范化为1
T faceforward(T N, T I, T Nref)如何 dot(Nref, I) < 0(表示Nref和I夹角大于90度) 返回N, 否则返回 -N
T reflect(T I, T N)计算向量I以N为法线的反射向量,公式是 I - 2 * dot(N,I) * N
T refract(T I, T N, float eta)计算向量I以N为法线的折射向量,eta表示折射系数

接着在render方法中使用WebGL的API对uniform进行赋值,需要先获取uniform elapsedTime的位置,然后使用这个位置赋值

let elapsedTimeLoc = gl.getUniformLocation(program, "elapsedTime");
gl.uniform1f(elapsedTimeLoc, elapsedTime);

WebGL给uniform赋值的方法都是uniform{参数个数}{参数类型}的形式,参数个数一般从1到4,参数类型可以是f(float)i(int)等等,下面列举了一些uniform方法和Shader类型的对应关系

uniform赋值API参数类型Shader声明例子
uniform1f1个floatuniform float val;gl.uniform1f(uniformLoc, 1.0);
uniform1fvfloat数组uniform float val[3];gl.uniform1fv(uniformLoc, Float32List_of_data);
uniform2f2个floatuniform vec2 val;gl.uniform2f(uniformLoc, 1.0, 2.0);
uniform2fvvec2数组uniform vec2 val[3];gl.uniform2f(uniformLoc, Float32List_of_data); //需要提供2x3个Float数据
uniform3f3个floatuniform vec3 val;gl.uniform3f(uniformLoc, 1.0, 2.0, 3.0);
uniform4f4个floatuniform vec4 val;gl.uniform4f(uniformLoc, 1.0, 2.0, 3.0, 4.0);
uniform1i1个intuniform int val;gl.uniform1i(uniformLoc, 1);
uniform2i2个intuniform ivec2 val;gl.uniform2i(uniformLoc, 1, 2);
uniform3i3个intuniform ivec3 val;gl.uniform3i(uniformLoc, 1, 2, 3);
uniform4i4个intuniform ivec4 val;gl.uniform4i(uniformLoc, 1, 2, 4, 4);
uniformMatrix2fv2x2个floatuniform mat2 val;gl.uniformMatrix2fv(uniformLoc, false, Float32List_of_mat2);
uniformMatrix3fv3x3个floatuniform mat3 val;gl.uniformMatrix3fv(uniformLoc, false, Float32List_of_mat3);
uniformMatrix2fv4x4个floatuniform mat4 val;gl.uniformMatrix2fv(uniformLoc, false, Float32List_of_mat4);

uniform{1-4}{f/i}系列方法都有加v的变体,可以传递对应类型的数组,表格中就不一一列出了。

细谈顶点数据

例子完整项目代码,在chapter2section1-2目录下

下面详细的说一下顶点数据的相关知识,顶点数据不管多复杂,在JS代码中始终都是一个Float32List,之前的例子中只是使用了顶点的位置属性,现在给每个顶点增加一个颜色属性,看看应该如何去做。

假设使用rgb三个数据表示颜色,加上之前的位置数据,现在表示一个顶点需要6个float,一共三个顶点,所以需要 3 x 6 = 18个float,顶点数据就变成了下面这样

let vertices = [
    -0.5, -0.5, 0, 1, 0, 0, // 红色
    0.5, -0.5, 0, 0, 1, 0, // 绿色
    0, 0.5, 0, 0, 0, 1 // 蓝色
];

上传顶点数据给GPU还是之前的步骤

vertexData = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexData);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

Vertex Shader需要做一些修改来接受颜色这个顶点数据

attribute vec4 position;
attribute vec3 color;
varying vec3 frag_color;
uniform float elapsedTime;
void main() {
    float rad = radians(elapsedTime / 1000.0 * 30.0);
    float x = position.x * cos(rad) - position.y * sin(rad);
    float y = position.x * sin(rad) + position.y * cos(rad);
    frag_color = color;
    gl_Position = vec4(x, y, position.z, position.w);
}

主要增加了attribute vec3 color;attribute表示这个属性的值来自顶点数据

为了在Fragment Shader中使用顶点的颜色数据,使用了varying 属性来传递值,这个在介绍Fragment Shader时再细说

render方法中,使用WebGL的API告知Shader取哪些数据给color赋值

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 * 6, 0);

let colorLoc = gl.getAttribLocation(program, 'color');
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 4 * 6, 4 * 3);

这里再说一下vertexAttribPointer这个方法,前面三个参数很好理解,第一个表示program上属性的位置,第二第三表示属性包含3个Float类型数据,第四个参数如果是true,并且传递的是整型数据,那么在Shader中使用时,会被规范化成-1到1,或者0到1,这里使用的是float,设置为false即可,第五个参数你可以理解为一个完整的顶点数据占多少字节,一个float占4个字节,所以这里填写的是4 * 6,第六个参数表示属性的数据从一个完整顶点数据的第几个字节开始,现在一个顶点包含位置和颜色6个float数据,颜色数据从第四个float数据开始,所以要从一个完整顶点数据中取得颜色数据,要跳过3个float,也就是跳过4 * 3个字节取数据。

总结

这一节主要介绍了Vertex Shader如何使用uniform制作动画以及如何传递多个顶点属性,这两种是Vertex Shader和JS程序通讯的主要方式。想要了解更多的Vertex Shader API,可以访问这个页面WebGL API参考,这里列举了WebGL的所有API和Shader的所有API。

练习

可以通过下面的练习巩固对这一节知识的掌握

  1. 使用Vertex Shader实现三角形的缩放动画,三角形不停的放大和缩小
  2. 调整顶点数据结构为{r, g, b, x, y},在section1-2的例子基础上修改代码,达到之前一样的显示效果, 调整后顶点数据如下
let vertices = [
    1, 0, 0, -0.5, -0.5,// 左下角
    0, 1, 0, 0.5, -0.5,// 右下角
    0, 0, 1, 0, 0.5,// 中上
];