- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
本篇技术指南基于 webgl-fundamentals 仓库的 WebGL - Animação 课程(对应英文原版 WebGL - Animation)整理而成。文章围绕「在 WebGL 中如何让物体动起来」这一核心问题展开,重点讲解 JavaScript 动画的基本循环、
requestAnimationFrame的正确用法,以及最容易被忽视的帧率无关(frame rate independent)动画原理。读完本文,你将掌握一套可复用的「时间增量(deltaTime)驱动动画」模板,能够写出在 30 FPS 旧手机与 240 FPS 高刷显示器上表现一致的 WebGL 动画,并理解为什么setInterval/setTimeout不适合做渲染循环。
课程脉络与前置知识
这篇动画课程是 webgl-fundamentals 教程系列的延续:
- 系列起点:WebGL 基础(Fundamentals),介绍了 WebGL 的整体工作方式(缓冲区、着色器、绘制调用);
- 上一篇:3D 相机(Cameras),讲解了透视投影矩阵与相机视角变换;
- 下一篇:纹理(Textures),介绍如何在着色器中应用纹理。
动画这一课承上启下:它复用了此前「旋转 3D 字母 F」的示例,只改变绘制逻辑中的一小段,就完成了从「静态场景」到「旋转动画」的跨越。因此建议先读完上述前置课程再深入本文。
动画的本质:随时间改变,然后重新绘制
在 WebGL 中做动画,其实并没有任何「WebGL 专属」的魔法。动画的本质(在 JavaScript 中同样适用)只有一句话:
随时间推移改变某些东西,然后重新绘制。
以系列中经典的「3D 字母 F」为例,我们可以在原来静态示例的基础上,加入两个变量和一个绘制循环:
var fieldOfViewRadians = degToRad(60); var rotationSpeed = 1.2; requestAnimationFrame(drawScene); // 绘制场景。 function drawScene() { // 每一帧让旋转角度增加一点点。 rotation[1] += rotationSpeed / 60.0; ... // 其余绘制代码(清屏、绑定缓冲、设置矩阵、drawArrays 等) // 下一帧再次调用 drawScene,形成循环。 requestAnimationFrame(drawScene); }这段代码的运行效果就是 webgl-animation-not-frame-rate-independent.html 演示的内容:一个持续绕 Y 轴旋转的 3D 字母 F。它的骨架——「先请求一帧 → 修改状态 → 绘制 → 再请求下一帧」——是所有 WebGL 动画共用的基础循环。
一个隐蔽的错误:rotationSpeed / 60.0的假设
上面的代码看起来合理,但隐藏着一个微妙的问题:
rotation[1] += rotationSpeed / 60.0; // 不要这样做之所以除以60.0,是因为我们假设浏览器每秒会回调requestAnimationFrame60 次。这在多数常见环境下确实成立,但这个假设并非总是有效:
- 用户可能在使用老旧智能手机之类的低性能设备;
- 用户可能在后台运行着某个占用资源的重型程序;
- 浏览器可能因为各种原因无法以 60 FPS 显示帧;
- 反过来,高刷新率设备(例如 90 Hz 的 CRT 显示器、120/240 Hz 的现代屏幕)会带来超过60 次/秒的回调。
无论帧率偏高还是偏低,rotationSpeed / 60.0都意味着「每帧固定前进 1/60 秒的量」。帧率降到 30 时,动画速度就变成预期的一半;帧率升到 120 时,动画速度又变成两倍。动画速度被绑死在了帧率上,这是很多 Web 动画「在不同设备上快慢不一」的根本原因。
源码对照:反例的完整逻辑
仓库中的反例 webgl-animation-not-frame-rate-independent.html 完整保留了上述写法,其绘制函数核心如下(对应文件 L106-L117):
function drawScene() { // Every frame increase the rotation a little. rotation[1] += rotationSpeed / 60.0; // don't do this ... requestAnimationFrame(drawScene); }注意这个反例里drawScene甚至没有接收requestAnimationFrame传入的时间参数——它根本不知道两帧之间隔了多久,自然也无法补偿帧率波动。
直观理解问题:三个「F」的对照实验
为了把「帧率依赖 vs 帧率无关」的差异可视化,仓库专门提供了一个对照演示:webgl-animation-frame-rate-issues.html。
该页面同时绘制三个 3D 字母 F,希望它们以相同的角速度旋转:
| 位置 | 行为 | 是否帧率无关 |
|---|---|---|
| 中间 | 全速运行,是帧率无关的实现 | 是 |
| 左侧 | 模拟浏览器只能以 1/8 帧率运行,但使用rotationSpeed / 60 | 否 |
| 右侧 | 同样模拟 1/8 帧率,但使用rotationSpeed * deltaTime | 是 |
关键差异在于:左侧的 F 没有考虑帧率可能变慢,于是它的旋转速度跟不上中间那个「全速」的 F;右侧的 F 虽然同样跑在 1/8 的帧率上,却能始终与中间的 F 保持同步旋转。
从源码看,这个演示的实现思路很清晰(对应文件 L167-L188):
// Draw 3 models. for (var x = 0; x < 3; ++x) { // 中间的 F 每帧都推进;左右两侧只每 8 帧推进一次,模拟慢帧率。 var advance = (x === 1) || (frameCount & 7) === 0; var rotationToAdd = 0; if (advance) { var deltaTime = now - thens[x]; thens[x] = now; switch (x) { case 0: // 每帧增加一个常量(帧率依赖) rotationToAdd = rotationSpeed / 60; break; case 1: // 每帧按 deltaTime 增加(帧率无关) case 2: rotationToAdd = rotationSpeed * deltaTime; break; } } modelRotations[x] += rotationToAdd; ... }左侧的 F(case 0)走的是rotationSpeed / 60分支,右侧与中间(case 1/2)走的是rotationSpeed * deltaTime分支,再用frameCount & 7模拟低帧率。这个例子是理解帧率无关动画的绝佳可视化教材。
正确做法:用 deltaTime(帧间隔时间)驱动动画
让动画帧率无关的方法并不复杂,只有两步:
- 测量两帧之间实际经过的时间(deltaTime);
- 用 deltaTime 决定这一帧应该让物体移动多少。
第一步:获取时间
幸运的是,requestAnimationFrame在回调我们的函数时,会把「页面加载以来经过的时间」作为参数传进来(单位是毫秒)。
为了方便计算,我们通常希望时间以秒为单位。由于requestAnimationFrame传入的是毫秒(千分之一秒),需要乘以0.001完成换算。
第二步:计算帧间隔 deltaTime
var then = 0; requestAnimationFrame(drawScene); // 绘制场景。 function drawScene(now) { // 把时间从毫秒换算成秒。 now *= 0.001; // 用当前时间减去上一帧的时间,得到帧间隔。 var deltaTime = now - then; // 记住当前时间,供下一帧使用。 then = now; ... }这里then的初始值为 0,第一帧的deltaTime会包含从页面加载到第一帧的时间,但随后每帧之间的差值都会回归到真实间隔,对长期动画无实质影响。
第三步:以「每秒单位数」定义速度
拿到以秒为单位的deltaTime之后,我们所有的计算都可以表述为「每秒想让物体发生多少单位的变化」。
在本例中,rotationSpeed = 1.2表示每秒旋转 1.2 弧度。1.2 弧度约等于 1/5 圈,也就是说字母 F 大约每 5 秒完整转一圈——无论帧率是 30、60 还是 240,这一物理速度恒定不变:
rotation[1] += rotationSpeed * deltaTime;这就是帧率无关动画的核心公式:速度 × 时间 = 位移。它把「每帧加多少」变成了「每帧按真实流逝时间加多少」,彻底解耦了动画速度与帧率。
完整示例:webgl-animation.html
采用上述正确写法的完整实现见 webgl-animation.html,其绘制函数(对应文件 L108-L117)与反例的区别一目了然:
function drawScene(now) { // Convert to seconds now *= 0.001; // Subtract the previous time from the current time var deltaTime = now - then; // Remember the current time for the next frame. then = now; // Every frame increase the rotation a little. rotation[1] += rotationSpeed * deltaTime; ... }在普通设备上,你可能看不出它和页首反例的差别;但只要帧率不是 60,两者的速度差异就会立刻显现。如果不做帧率无关处理,总会有一些用户得到与你预期完全不同的体验。
其余绘制管线一览
示例中除了动画核心逻辑,还展示了完整的每帧绘制管线(同样可以在 webgl-animation.html 中逐行查看):
- 画布自适应:调用
webglUtils.resizeCanvasToDisplaySize(gl.canvas)(定义于 webgl/resources/webgl-utils.js),将 canvas 的像素尺寸同步为 CSS 显示尺寸; - 视口设置:
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); - 清屏:
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT),同时清掉颜色与深度缓冲; - 开启深度测试与背面剔除:
gl.enable(gl.DEPTH_TEST)、gl.enable(gl.CULL_FACE); - 绑定顶点数据:通过
gl.vertexAttribPointer配置位置与颜色两个 attribute; - 组装矩阵:用
m4.perspective、m4.translate、m4.xRotate/m4.yRotate/m4.zRotate、m4.scale依次叠加得到最终变换矩阵(这些函数定义于 webgl/resources/m4.js 及同文件后续小节),再经gl.uniformMatrix4fv传入着色器; - 提交绘制:
gl.drawArrays(gl.TRIANGLES, 0, 16 * 6),其中16 * 6对应字母 F 的 16 个面 × 每面 2 个三角形 × 每三角形 3 个顶点。
顶点与片段着色器仍然沿用课程系列的「颜色随顶点传入」方案(v_colorvarying),动画只发生在 CPU 侧的rotation状态上,GPU 每帧看到的只是「新的矩阵」——这正是 WebGL 动画的常规架构:CPU 更新状态,GPU 按新状态重绘。
为什么不要用 setInterval / setTimeout 做动画
很多有 JavaScript 动画经验的读者,第一反应可能是用setInterval或setTimeout定期调用绘制函数。课程明确给出了警示(见英文原版文档底部 webgl-animation.md 的 bottombar):不要用它们做动画,原因有两方面。
问题一:与浏览器渲染完全脱节
setInterval和setTimeout与「浏览器何时绘制新帧」没有任何关系。它们只是到点就执行回调,不与用户的显示刷新同步。如果你按「每秒 60 帧」的假设去调度,而用户机器实际跑在别的帧率上,动画就会与屏幕刷新失步(out of sync),造成卡顿或撕裂观感。
问题二:浏览器不知道你的意图,后台也照跑
浏览器无法判断你用setInterval/setTimeout的目的是什么。例如,即使页面不可见(不是前台标签页),这些定时器依然会触发你的代码。如果你是在定时检查新邮件或新消息,几秒一次完全没问题;但如果你试图在 WebGL 中每帧绘制 1000 个对象,那么一个用户根本看不到的隐藏标签页,就会持续消耗 GPU 资源,等效于对用户机器发起一次小型的拒绝服务(DoS)攻击。
requestAnimationFrame 同时解决这两个问题
- 它会在恰好与屏幕刷新同步的时机调用你的函数,让动画与显示完美对齐;
- 它只在标签页可见时调用,页面隐藏时自动暂停,从根源上避免后台空耗资源。
这正是把渲染循环交给requestAnimationFrame而不是定时器的原因。
从仓库源码看这套动画的教学与工程组织
作为教程仓库,webgl-fundamentals 把动画示例组织得很有层次,值得学习:
- 示例 HTML 全部集中在 webgl/ 目录 下,每个示例是一个自包含的 HTML 文件,动画相关的三个文件(
webgl-animation.html、webgl-animation-not-frame-rate-independent.html、webgl-animation-frame-rate-issues.html)并列存放,方便读者对照「错误写法 → 问题可视化 → 正确写法」; - 公用工具独立成库:着色器编译链接、画布自适应等样板代码收敛在 webgl/resources/webgl-utils.js(
createProgramFromScripts、resizeCanvasToDisplaySize),矩阵运算收敛在 webgl/resources/m4.js,示例代码只保留与本课主题直接相关的部分; - 课程由构建系统生成:文档以 Markdown 形式存放于 webgl/lessons/(含英文与 pt-br 等多语言版本),构建配置见 Gruntfile.js,其中
buildlesson任务负责把webgl*.md结合模板渲染成 HTML 课程页面;package.json提供了npm run build、npm run watch、npm start(在out/目录启动本地服务器)等脚本,方便在本地构建并预览整套课程。
想动手验证本文内容,可以按以下步骤在本地查看示例:
npm install npm run build npm start然后在浏览器中打开构建产物out/webgl/webgl-animation.html、out/webgl/webgl-animation-frame-rate-issues.html,并用 DevTools 的 CPU 降速(如 6× slowdown)模拟低帧率,即可直观看到帧率依赖与帧率无关动画的行为差异。
小结与下一步
本文围绕 WebGL 动画讲解了三个要点:
- 动画 = 随时间改变状态 + 重新绘制,渲染循环由
requestAnimationFrame驱动; - 不要用
rotationSpeed / 60.0这类帧率假设,它会让动画速度随设备帧率漂移;正确做法是接收requestAnimationFrame的时间参数,换算成秒并计算deltaTime,再以「每秒单位数」的速度乘以deltaTime更新状态; setInterval/setTimeout不适合做渲染循环,因为它们与屏幕刷新不同步、且后台也会继续执行,requestAnimationFrame是正确且唯一的渲染循环入口。
掌握了帧率无关动画,下一步就可以继续学习 WebGL 纹理(Textures),为旋转的字母 F 贴上材质,让 3D 场景进入更丰富的视觉阶段。
- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
相关推荐
webgl-fundamentals 实战:WebGL 动画与帧率无关(Frame Rate Independent)动画的正确实现
webgl fundamentals 实战:WebGL 动画与帧率无关(Frame Rate Independent)动画的正确实现 本篇文章是 webgl f
图形学webgl-fundamentals 教程:用 requestAnimationFrame 实现帧率无关的 WebGL 动画
webgl fundamentals 教程:用 requestAnimationFrame 实现帧率无关的 WebGL 动画 本文是 webgl fundame
图形学WebGL 动画入门:用 requestAnimationFrame 实现帧率独立的旋转动画
WebGL 动画入门:用 requestAnimationFrame 实现帧率独立的旋转动画 导读 本教程源自 webgl fundamentals 开源仓库的
图形学
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考