简介
本文的目标是在屏幕上以流畅的帧速率绘制 100 万个动画字母。使用新型 GPU 应该可以轻松完成此任务。每个字母由两个纹理三角形组成,因此我们只需要每帧渲染 200 万个三角形。
如果您是传统 JavaScript 动画背景的开发者,这一切听起来都像是疯狂之举。现在,您肯定不想使用 JavaScript 每帧更新 200 万个三角形。但幸运的是,我们有 WebGL,它让我们能够充分利用新型 GPU 的强大性能。借助现代 GPU 和一些着色器魔法,两百万个动画三角形完全可行。
编写高效的 WebGL 代码
编写高效的 WebGL 代码需要具备一定的思维方式。使用 WebGL 绘图的常用方法是为每个对象设置 uniform、缓冲区和着色器,然后调用以绘制对象。在绘制少量对象时,这种绘制方式可行。如需绘制大量对象,您应尽量减少 WebGL 状态更改的数量。首先,使用相同的着色器依次绘制所有对象,这样您就不必在对象之间更改着色器。对于像粒子这样的简单对象,您可以将多个对象捆绑到一个缓冲区中,并使用 JavaScript 对其进行修改。这样一来,您只需重新上传顶点缓冲区,而无需更改每个单个粒子的着色器 uniform。
不过,要想实现极快的速度,您需要将大部分计算推送到着色器。我正在尝试解决这个问题。使用着色器为 100 万个字母添加动画效果。
本文中的代码使用了 Three.js 库,该库可抽象出编写 WebGL 代码时所有繁琐的样板代码。使用 Three.js 时,您只需编写几行代码,而无需编写数百行 WebGL 状态设置和错误处理代码。您还可以轻松从 Three.js 中使用 WebGL 着色器系统。
使用单个绘制调用绘制多个对象
以下是一个小型伪代码示例,展示了如何使用单个绘制调用绘制多个对象。传统的方法是一次绘制一个对象,如下所示:
for (var i=0; i<objects.length; i++) {
// each added object requires a separate WebGL draw call
scene.add(createNewObject(objects[i]));
}
renderer.render(scene, camera);
但上述方法需要为每个对象分别进行一次绘制调用。如需一次绘制多个对象,您可以将这些对象打包到单个几何图形中,然后只需进行一次绘制调用即可:
var geo = new THREE.Geometry();
for (var i=0; i<objects.length; i++) {
// bundle the objects into a single geometry
// so that they can be drawn with a single draw call
addObjectToGeometry(geo, objects[i]);
}
// GOOD! Only one object to add to the scene!
scene.add(new THREE.Mesh(geo, material));
renderer.render(scene, camera);
好的,现在您已经了解了基本原理,接下来我们继续编写演示程序,并开始为这百万个字母添加动画效果!
设置几何图形和纹理
首先,我将创建一个包含字母位图纹理的纹理。我将使用 2D 画布来实现此目的。生成的纹理包含我要绘制的所有字母。下一步是创建一个缓冲区,并将字母精灵图片表的纹理坐标添加到该缓冲区。虽然这是设置字母的一种简单直接的方法,但由于它会为每个顶点使用两个浮点值来表示纹理坐标,因此有点浪费。更简短的方法(留作读者练习)是将字母索引和角索引打包到一个数字中,并在顶点着色器中将其转换回纹理坐标。
下面是使用 Canvas 2D 构建字母纹理的方法:
var fontSize = 16;
// The square letter texture will have 16 * 16 = 256 letters, enough for all 8-bit characters.
var lettersPerSide = 16;
var c = document.createElement('canvas');
c.width = c.height = fontSize*lettersPerSide;
var ctx = c.getContext('2d');
ctx.font = fontSize+'px Monospace';
// This is a magic number for aligning the letters on rows. YMMV.
var yOffset = -0.25;
// Draw all the letters to the canvas.
for (var i=0,y=0; y<lettersPerSide; y++) {
for (var x=0; x<lettersPerSide; x++,i++) {
var ch = String.fromCharCode(i);
ctx.fillText(ch, x*fontSize, yOffset*fontSize+(y+1)*fontSize);
}
}
// Create a texture from the letter canvas.
var tex = new THREE.Texture(c);
// Tell Three.js not to flip the texture.
tex.flipY = false;
// And tell Three.js that it needs to update the texture.
tex.needsUpdate = true;
我还将三角形数组上传到 GPU。这些顶点由顶点着色器用于将字母放置在屏幕上。顶点会设置为文本中的字母位置,这样,如果您按原样渲染三角形数组,则会获得文本的基本布局渲染。
为图书创建几何图形:
var geo = new THREE.Geometry();
var i=0, x=0, line=0;
for (i=0; i<BOOK.length; i++) {
var code = BOOK.charCodeAt(i); // This is the character code for the current letter.
if (code > lettersPerSide * lettersPerSide) {
code = 0; // Clamp character codes to letter map size.
}
var cx = code % lettersPerSide; // Cx is the x-index of the letter in the map.
var cy = Math.floor(code / lettersPerSide); // Cy is the y-index of the letter in the map.
// Add letter vertices to the geometry.
var v,t;
geo.vertices.push(
new THREE.Vector3( x*1.1+0.05, line*1.1+0.05, 0 ),
new THREE.Vector3( x*1.1+1.05, line*1.1+0.05, 0 ),
new THREE.Vector3( x*1.1+1.05, line*1.1+1.05, 0 ),
new THREE.Vector3( x*1.1+0.05, line*1.1+1.05, 0 )
);
// Create faces for the letter.
var face = new THREE.Face3(i*4+0, i*4+1, i*4+2);
geo.faces.push(face);
face = new THREE.Face3(i*4+0, i*4+2, i*4+3);
geo.faces.push(face);
// Compute texture coordinates for the letters.
var tx = cx/lettersPerSide,
ty = cy/lettersPerSide,
off = 1/lettersPerSide;
var sz = lettersPerSide*fontSize;
geo.faceVertexUvs[0].push([
new THREE.Vector2( tx, ty+off ),
new THREE.Vector2( tx+off, ty+off ),
new THREE.Vector2( tx+off, ty )
]);
geo.faceVertexUvs[0].push([
new THREE.Vector2( tx, ty+off ),
new THREE.Vector2( tx+off, ty ),
new THREE.Vector2( tx, ty )
]);
// On newline, move to the line below and move the cursor to the start of the line.
// Otherwise move the cursor to the right.
if (code == 10) {
line--;
x=0;
} else {
x++;
}
}
用于为字母添加动画效果的顶点着色器
使用简单的顶点着色器,我可以获得文本的平面视图。一点也不花哨。运行良好,但如果我想为其添加动画,则需要使用 JavaScript 进行动画处理。在为涉及的 600 万个顶点添加动画时,JavaScript 的速度会比较慢,尤其是在您希望在每个帧上都执行此操作时。或许有更快的方法。
没错,我们可以进行程序化动画。这意味着,我们将在顶点着色器中执行所有位置和旋转计算。现在,我无需运行任何 JavaScript 即可更新顶点的位置。顶点着色器的运行速度非常快,即使每帧有 100 万个三角形单独进行动画处理,也能获得流畅的帧速率。为了解决单个三角形的问题,我会向下舍入顶点坐标,以便将字母四边形的所有四个点映射到单个唯一坐标。现在,我可以使用此坐标为相关字母设置动画参数。
为了能够成功向下舍入坐标,两个不同字母对应的坐标不能重叠。最简单的方法是使用方形字母四边形,并在字母与其右侧的字母和上方的线条之间留出小偏移。例如,您可以为字母使用宽度和高度为 0.5,并将字母对齐到整数坐标。现在,当您向下舍入任何字母顶点的坐标时,都会得到该字母的左下角坐标。
为了更好地了解动画顶点着色器,我将先介绍一个简单的常规顶点着色器。当您将 3D 模型绘制到屏幕上时,通常会出现这种情况。模型的顶点会通过一对转换矩阵进行转换,以将每个 3D 顶点投影到 2D 屏幕上。每当由其中三个顶点定义的三角形位于视口内时,fragment 着色器都会处理其覆盖的像素,以对其着色。无论如何,下面是简单的顶点着色器:
varying float vUv;
void main() {
// modelViewMatrix, position and projectionMatrix are magical
// attributes that Three.js defines for us.
// Transform current vertex by the modelViewMatrix
// (bundled model world position & camera world position matrix).
vec4 mvPosition = modelViewMatrix * position;
// Project camera-space vertex to screen coordinates
// using the camera's projection matrix.
vec4 p = projectionMatrix * mvPosition;
// uv is another magical attribute from Three.js.
// We're passing it to the fragment shader unchanged.
vUv = uv;
gl_Position = p;
}
现在,动画顶点着色器。它与简单的顶点着色器执行的操作基本相同,但有一个小变化。它不仅会仅通过转换矩阵来转换每个顶点,还会应用与时间相关的动画转换。为了让每个字母的动画效果略有不同,动画顶点着色器还会根据字母的坐标修改动画。它看起来会比简单的顶点着色器复杂得多,因为它确实更复杂。
uniform float uTime;
uniform float uEffectAmount;
varying float vZ;
varying vec2 vUv;
// rotateAngleAxisMatrix returns the mat3 rotation matrix
// for given angle and axis.
mat3 rotateAngleAxisMatrix(float angle, vec3 axis) {
float c = cos(angle);
float s = sin(angle);
float t = 1.0 - c;
axis = normalize(axis);
float x = axis.x, y = axis.y, z = axis.z;
return mat3(
t*x*x + c, t*x*y + s*z, t*x*z - s*y,
t*x*y - s*z, t*y*y + c, t*y*z + s*x,
t*x*z + s*y, t*y*z - s*x, t*z*z + c
);
}
// rotateAngleAxis rotates a vec3 over the given axis by the given angle and
// returns the rotated vector.
vec3 rotateAngleAxis(float angle, vec3 axis, vec3 v) {
return rotateAngleAxisMatrix(angle, axis) * v;
}
void main() {
// Compute the index of the letter (assuming 80-character max line length).
float idx = floor(position.y/1.1)*80.0 + floor(position.x/1.1);
// Round down the vertex coords to find the bottom-left corner point of the letter.
vec3 corner = vec3(floor(position.x/1.1)*1.1, floor(position.y/1.1)*1.1, 0.0);
// Find the midpoint of the letter.
vec3 mid = corner + vec3(0.5, 0.5, 0.0);
// Rotate the letter around its midpoint by an angle and axis dependent on
// the letter's index and the current time.
vec3 rpos = rotateAngleAxis(idx+uTime,
vec3(mod(idx,16.0), -8.0+mod(idx,15.0), 1.0), position - mid) + mid;
// uEffectAmount controls the amount of animation applied to the letter.
// uEffectAmount ranges from 0.0 to 1.0.
float effectAmount = uEffectAmount;
vec4 fpos = vec4( mix(position,rpos,effectAmount), 1.0 );
fpos.x += -35.0;
// Apply spinning motion to individual letters.
fpos.z += ((sin(idx+uTime*2.0)))*4.2*effectAmount;
fpos.y += ((cos(idx+uTime*2.0)))*4.2*effectAmount;
vec4 mvPosition = modelViewMatrix * fpos;
// Apply wavy motion to the entire text.
mvPosition.y += 10.0*sin(uTime*0.5+mvPosition.x/25.0)*effectAmount;
mvPosition.x -= 10.0*cos(uTime*0.5+mvPosition.y/25.0)*effectAmount;
vec4 p = projectionMatrix * mvPosition;
// Pass texture coordinates and the vertex z-coordinate to the fragment shader.
vUv = uv;
vZ = p.z;
// Send the final vertex position to WebGL.
gl_Position = p;
}
如需使用顶点着色器,我使用了 THREE.ShaderMaterial
,这是一种材质类型,可让您使用自定义着色器并为其指定 uniform。下面是我在演示中使用 THREE.ShaderMaterial 的方式:
// First, set up uniforms for the shader.
var uniforms = {
// map contains the letter map texture.
map: { type: "t", value: 1, texture: tex },
// uTime is the urrent time.
uTime: { type: "f", value: 1.0 },
// uEffectAmount controls the amount of animation applied to the letters.
uEffectAmount: { type: "f", value: 0.0 }
};
// Next, set up the THREE.ShaderMaterial.
var shaderMaterial = new THREE.ShaderMaterial({
uniforms: uniforms,
// I have my shaders inside HTML elements like
// <script id="vertex" type="text/x-glsl-vert">... shaderSource ... <script>
// The below gets the contents of the vertex shader script element.
vertexShader: document.querySelector('#vertex').textContent,
// The fragment shader is a bit special as well, drawing a rotating
// rainbow gradient.
fragmentShader: document.querySelector('#fragment').textContent
});
// I set depthTest to false so that the letters don't occlude each other.
shaderMaterial.depthTest = false;
在每帧动画中,我都会更新着色器 uniform:
// I'm controlling the uniforms through a proxy control object.
// The reason I'm using a proxy control object is to
// have different value ranges for the controls and the uniforms.
var controller = {
effectAmount: 0
};
// I'm using <a href="http://code.google.com/p/dat-gui/">DAT.GUI</a> to do a quick & easy GUI for the demo.
var gui = new dat.GUI();
gui.add(controller, 'effectAmount', 0, 100);
var animate = function(t) {
uniforms.uTime.value += 0.05;
uniforms.uEffectAmount.value = controller.effectAmount/100;
bookModel.position.y += 0.03;
renderer.render(scene, camera);
requestAnimationFrame(animate, renderer.domElement);
};
animate(Date.now());
就这样,基于着色器的动画就完成了。这看起来很复杂,但它实际上只会根据当前时间和每个字母的索引,以某种方式移动字母。如果性能不是问题,您可以在 JavaScript 中运行此逻辑。但是,当动画对象达到数万个时,JavaScript 就不再是一个可行的解决方案。
剩余问题
现在存在一个问题,就是 JavaScript 不知道粒子的位置。如果您确实需要知道粒子的具体位置,可以在 JavaScript 中复制顶点着色器逻辑,并在每次需要顶点位置时使用 Web Worker 重新计算顶点位置。这样,渲染线程就不必等待计算,您可以继续以流畅的帧速率进行动画处理。
如需更可控的动画,您可以使用渲染到纹理功能,在 JavaScript 提供的两组位置之间进行动画处理。首先,将当前位置渲染到纹理,然后以动画形式移动到 JavaScript 提供的单独纹理中定义的位置。这样做的好处在于,您可以每帧更新 JavaScript 提供的少数位置,同时仍可继续使用顶点着色器对位置进行补间,从而每帧为所有字母呈现动画效果。
另一个问题是,256 个字符对于非 ASCII 文本来说太少了。如果将纹理贴图大小推送到 4096x4096,同时将字体大小减小到 8px,则可以将整个 UCS-2 字符集放入纹理贴图中。不过,8 像素的字号不太容易辨认。如需使用更大的字号,您可以为字体使用多个纹理。如需查看示例,请参阅此精灵图集演示。另一个有帮助的方法是,只创建文本中使用的字母。
摘要
在本文中,我向您介绍了如何使用 Three.js 实现基于顶点着色器的动画演示。此演示在 2010 款 MacBook Air 上实时呈现 100 万个字母的动画效果。该实现将整本图书打包到单个几何图形对象中,以便高效绘制。我们通过确定哪些顶点属于哪个字母,并根据图书文本中字母的索引为顶点添加动画效果,从而为每个字母添加了动画效果。