news 2026/10/15 19:06:57

WebGL 动画入门:用 requestAnimationFrame 写出帧率无关(Frame Rate Independent)动画

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WebGL 动画入门:用 requestAnimationFrame 写出帧率无关(Frame Rate Independent)动画
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

本篇技术指南基于 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(帧间隔时间)驱动动画

让动画帧率无关的方法并不复杂,只有两步:

  1. 测量两帧之间实际经过的时间(deltaTime);
  2. 用 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 中逐行查看):

  1. 画布自适应:调用webglUtils.resizeCanvasToDisplaySize(gl.canvas)(定义于 webgl/resources/webgl-utils.js),将 canvas 的像素尺寸同步为 CSS 显示尺寸;
  2. 视口设置:gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
  3. 清屏:gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT),同时清掉颜色与深度缓冲;
  4. 开启深度测试与背面剔除:gl.enable(gl.DEPTH_TEST)、gl.enable(gl.CULL_FACE);
  5. 绑定顶点数据:通过gl.vertexAttribPointer配置位置与颜色两个 attribute;
  6. 组装矩阵:用m4.perspective、m4.translate、m4.xRotate/m4.yRotate/m4.zRotate、m4.scale依次叠加得到最终变换矩阵(这些函数定义于 webgl/resources/m4.js 及同文件后续小节),再经gl.uniformMatrix4fv传入着色器;
  7. 提交绘制: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 动画讲解了三个要点:

  1. 动画 = 随时间改变状态 + 重新绘制,渲染循环由requestAnimationFrame驱动;
  2. 不要用rotationSpeed / 60.0这类帧率假设,它会让动画速度随设备帧率漂移;正确做法是接收requestAnimationFrame的时间参数,换算成秒并计算deltaTime,再以「每秒单位数」的速度乘以deltaTime更新状态;
  3. setInterval/setTimeout不适合做渲染循环,因为它们与屏幕刷新不同步、且后台也会继续执行,requestAnimationFrame是正确且唯一的渲染循环入口。

掌握了帧率无关动画,下一步就可以继续学习 WebGL 纹理(Textures),为旋转的字母 F 贴上材质,让 3D 场景进入更丰富的视觉阶段。

  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 19:06:02

代币化资产的基准利率怎么定?从链上利率预言机到浮动利率产品的技术路径

代币化固定收益产品——代币化国债、代币化货币市场基金、代币化私募信贷——的定价和收益分配依赖基准利率。传统金融中&#xff0c;基准利率由中央银行或基准管理机构发布&#xff1b;链上环境中&#xff0c;基准利率需要通过预言机传递到智能合约。这一看似简单的数据传递&a…

作者头像 李华
网站建设 2026/10/15 19:05:18

地图编辑器设计核心:语义建模、场景契约与工业级交互实现

1. 项目概述&#xff1a;为什么一个“地图编辑器”值得从零开始重做&#xff1f;“地图编辑器开发&#xff08;一&#xff09;”这个标题看起来平平无奇&#xff0c;甚至有点像某门课程作业的章节编号。但如果你在游戏开发、GIS系统集成、工业数字孪生平台或教育类交互软件团队…

作者头像 李华