使用 Three.js 制作一百万个字母的动画
我在本文中的目标是以平滑的帧速率在屏幕上绘制一百万个动画字母。 使用现代 GPU 应该可以完成这项任务。 每个字母由两个带纹理的三角形组成,所以我们只讨论每帧 200 万个三角形。
如果您来自传统的 JavaScript 动画背景,那么这一切听起来很疯狂。 每帧更新 200 万个三角形绝对不是你今天想用 JavaScript 做的事情。 但幸运的是,我们有 WebGL ,它让我们可以利用现代 GPU 的强大功能。 使用现代 GPU 和一些着色器魔法,200 万个动画三角形是完全可行的。
编写高效的 WebGL 代码
编写高效的 WebGL 代码需要一定的心态。 使用 WebGL 绘制的常用方法是为每个对象设置制服、缓冲区和着色器,然后调用绘制对象。 这种绘制方式适用于绘制少量对象。 要绘制大量对象,您应该尽量减少 WebGL 状态更改的数量。 首先,使用相同的着色器依次绘制所有对象,这样您就不必在对象之间更改着色器。 对于像粒子这样的简单对象,您可以将多个对象捆绑到一个缓冲区中并使用 JavaScript 对其进行编辑。 这样你只需要重新上传顶点缓冲区,而不用为每个粒子改变着色器制服。
但是要真正快速进行,您需要将大部分计算推送到着色器。 这就是我在这里尝试做的。 使用着色器为一百万个字母设置动画。
文章的代码使用 Three.js 库,它从编写 WebGL 代码中抽象出所有繁琐的样板文件。 不必编写数百行 WebGL 状态设置和错误处理,使用 Three.js,您只需要编写几行代码。 从 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 中制作动画。 JavaScript 对涉及的 600 万个顶点进行动画处理有点慢,特别是如果你想在每一帧上都这样做。 也许有更快的方法。
为什么是的,我们可以做程序动画。 这意味着我们在顶点着色器中进行所有位置和旋转数学运算。 现在我不需要运行任何 JavaScript 来更新顶点的位置。 顶点着色器运行速度非常快,即使每一帧都对一百万个三角形进行单独动画处理,我仍能获得平滑的帧速率。 为了处理单个三角形,我将顶点坐标四舍五入,以便字母四边形的所有四个点都映射到一个唯一的坐标。 现在我可以使用这个坐标来设置相关字母的动画参数。
为了能够成功舍入坐标,来自两个不同字母的坐标不能重叠。 最简单的方法是使用方形字母四边形,将字母与其右侧的字母及其上方的线隔开一个小的偏移量。 例如,您可以为字母使用 0.5 的宽度和高度,并在整数坐标上对齐字母。 现在,当您向下舍入任何字母顶点的坐标时,您将获得字母的左下角坐标。
向下舍入顶点坐标以找到字母的左上角。
为了更好地理解动画顶点着色器,我将首先介绍一个简单的普通顶点着色器。 这是在屏幕上绘制 3D 模型时通常会发生的情况。 模型的顶点通过一对变换矩阵进行变换,以将每个 3D 顶点投影到 2D 屏幕上。 每当由这些顶点中的三个定义的三角形落在视口内时,它所覆盖的像素都会由片段着色器处理以对其进行着色。 无论如何,这是简单的顶点着色器:
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 统一 ,这是一种材质类型,可让您使用自定义着色器并为它们指定 。 这是我在演示中使用 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 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;
在每个动画帧上,我都会更新着色器制服:
// 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 DAT.GUI 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 文本来说太少了。 如果将纹理贴图大小推到 4096×4096,同时将字体大小减小到 8px,则可以将整个 UCS-2 字符集放入纹理贴图中。 但是,8px 的字体大小不是很容易阅读。 要获得更大的字体大小,您可以为字体使用多个纹理。 有关 请参阅此 示例 精灵图集演示 , 。 另一件有帮助的事情是只创建文本中使用的字母。
概括
在本文中,我引导您使用 Three.js 实现了一个基于顶点着色器的动画演示。 该演示在 2010 款 MacBook Air 上实时制作了 100 万个字母的动画。 该实现将整本书捆绑到一个几何对象中,以实现高效绘图。 通过找出哪些顶点属于哪个字母并根据书籍文本中字母的索引对顶点进行动画处理,可以对各个字母进行动画处理。
参考
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: WebGL 基础
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论