Three.js 的动画循环到底是谁在调我在浏览器里对照了两种写法Three.js 示例里最常见的动画循环大概长这样每帧更新一点状态再调用requestAnimationFrame()预约下一帧。换成renderer.setAnimationLoop()后代码看起来像是把循环交给了渲染器。我想确认的是这会不会真的启动另一套时钟停止时该取消哪一层两个 renderer 同时开始会不会让回调按倍数增长我把 r170 放进同一个 Chrome 标签页分别测原生requestAnimationFrame、一个WebGLRenderer.setAnimationLoop()再让两个 renderer 同时运行。每组丢掉 12 次预热回调再记录 120 个间隔。前两组平均值都是 16.67 ms两个 renderer 各收到 120 次回调。这个结果没有证明setAnimationLoop()更快也没证明渲染器内部多了一只计时器。源码给出的答案反而简单在 WebGL 后端这个循环最后还是沿着浏览器的动画帧回调继续。先把“循环”说清楚动画循环至少有三个容易混在一起的东西谁请求下一帧、每帧执行了什么工作、以及何时停止。它们不是同一回事。浏览器把时间戳交给回调不代表场景已经更新回调里执行renderer.render()才会发起 Three.js 渲染回调里不做任何工作只能说明调度发生了不能代表 GPU 完成了一帧复杂画面。本次实验用 Three.js r170、Chrome WebGL2页面标题里显示 SwiftShader。计时只记录回调收到的时间戳差值不测 GPU 耗时也不把平均间隔叫作帧率性能。两种调度先后采样两个 renderer 的回调则同时采样。为了避免首次着色器准备、页面启动和第一次回调污染数据每一轮先忽略 12 次回调再取 120 个间隔。采样对象记录数平均间隔中位数P95最小—最大原生requestAnimationFrame12016.67 ms16.70 ms16.70 ms16.6—16.8 ms一个 renderer 的setAnimationLoop12016.67 ms16.70 ms16.80 ms16.5—16.8 ms两个 renderer 同时运行各自120 / 12016.67 / 16.67 ms16.70 / 16.70 ms16.80 / 16.80 ms16.6—16.8 ms这些差值集中在约 16.7 ms并不是 Three.js 保证了 60 FPS。当前浏览器环境给出的回调节奏接近 60 Hz显示器刷新率、标签页是否可见、电源策略和浏览器调度都会改变实际回调。这里的 P95 只描述本次标签页不能外推到另一台机器。特别是我没有在每个采样回调里持续调用renderer.render()所以这不是三种渲染负载的性能比较。原生 rAF 和单 renderer 组是先后测的每一组结束才开始下一组只有双 renderer 组把两个循环同时挂上。采样回调里只存浏览器给的时间戳既不更新 Mesh也不发 draw call。页面里的三个 TorusKnot 在进入计时前各画过一次随后画面保持静止。这样刻意把“谁调度下一次回调”和“渲染工作有多重”分开否则测出来的间隔会混入几何、材质、驱动和 GPU 的负载。WebGL 后端的setAnimationLoopThree.js WebGLRenderer 文档把setAnimationLoop(callback)作为动画循环入口并建议优先用它来兼容可能出现的 XR 场景。传入回调时开始循环传入null停止循环。它让调用方不必自己维护requestAnimationFrame的递归和取消句柄但 API 名称本身并不承诺更高刷新率。在 r170 的 WebGL 路径里实际负责调度的实现位于WebGLAnimation.js。start()会检查当前是否已经在运行以及回调是否存在运行后调用requestAnimationFrame(onAnimationFrame)帧回调再执行用户函数并预约下一次。stop()会用保存的 request id 调cancelAnimationFrame()。这就是我说“WebGL 后端仍是一条 rAF 链”的依据不是从 API 名字猜出来的。再看同一版本的WebGLRenderer.js构造渲染器时会创建一个WebGLAnimation实例。公开的setAnimationLoop()是把 callback 存到onAnimationFrameCallback再调用底层实例的start()或stop()它不是向回调列表里追加一项。因此同一个 renderer 上后一次调用会替换回调而不是再生一条链。实验中的两个 renderer 则各有自己的动画实例各自收到了 120 次通知。浏览器仍在同一个页面调度这些通知这不等于创建了两只独立硬件时钟。图里有个容易漏掉的时序WebGLAnimation.onAnimationFrame()是先调用外层帧处理器再无条件请求下一次 rAF。WebGLRenderer的外层处理器在回调被设为null后会变成空操作但不改变内层已经进入的这次函数。如果在用户回调内部同步调用setAnimationLoop(null)stop()取消的 request id 已经对应当前执行中的帧等回调返回内层仍会再排一帧。后续空回调还会继续被内层安排。这个结论来自 r170 源码的调用顺序本实验没有把“回调内部同步 stop”作为测量组。可以把递归关系缩成下面几行。它展示的是调度关系不代表这几行就完成了场景绘制functiononAnimationFrame(time,frame){animationLoop(time,frame);requestIdcontext.requestAnimationFrame(onAnimationFrame);}正常动画把更新和渲染放进回调renderer.setAnimationLoop((time){mesh.rotation.ytime*0.001;renderer.render(scene,camera);});第二段是应用侧的使用示例循环行为由setAnimationLoop调用不是实验脚本里用来统计间隔的回调。时间戳来自浏览器不宜把它当作固定步长。若每帧写position.x 0.01运动速度会随回调频率变化按时间戳算增量或使用固定步长更新才能把“每次回调走一步”改成“经过多少时间走多少路”。例如已有update()、scene、camera时可以用两次回调的时间差推进状态。停掉再启动时重新初始化上一帧时间避免把暂停的 250 ms 误算成角色突然移动的一大步functionstartLoop(){letpreviousTimenull;renderer.setAnimationLoop((time){constdeltaSecondspreviousTimenull?0:(time-previousTime)/1000;previousTimetime;update(deltaSeconds);renderer.render(scene,camera);});}functionstopLoop(){renderer.setAnimationLoop(null);}如果update()驱动的是物理模拟变步长也未必合适可以累计deltaSeconds按固定小步更新再把剩余时间用于插值显示。这个选择属于应用的时间模型换成setAnimationLoop()不会自动替你处理。为什么没有手写递归原生写法需要自己续帧functiontick(time){times.push(time);if(times.lengthWARMUP_FRAMESSAMPLE_COUNT1){requestAnimationFrame(tick);}else{resolve(times.slice(WARMUP_FRAMES,WARMUP_FRAMESSAMPLE_COUNT1));}}requestAnimationFrame(tick);实验的原生组确实沿用这种递归但渲染器组没有另造一个 rAF 包装而是把收集函数交给renderer.setAnimationLoop(tick)。达到采样数量后我把setAnimationLoop(null)放到setTimeout(..., 0)中停止。这样当前动画回调先返回WebGLAnimation 排入下一次 rAF计时器再取消待处理的 request id。它不是“等一帧再停”计时器任务通常在下一次浏览器帧回调前执行。本测试驱动脚本明确这样做不能把这一时序细节泛化成所有应用都应延迟停止。源码还处理了重复 start动画已经在运行时再次启动不会再建立一条并行递归链。这是“多次调用setAnimationLoop不等于多条循环”的关键。我的实验另设两个独立的WebGLRenderer各自设一个回调它们各自拿到 120 次回调测得的平均间隔都为 16.67 ms。这个结果描述两个独立循环都能收到浏览器回调不代表把两个 renderer 合并会省 GPU也不代表两个 renderer 只占一份绘制成本。停止之后帧计数有没有继续涨暂停测试先收 45 次 renderer 回调调用setAnimationLoop(null)再等待实际测得的 250.3 ms。为了确认标签页并没有一起停摆暂停期间另开一个独立的原生 rAF 探针它收到了 15 次回调而 renderer 的动画回调计数增加了 0。恢复后同一个 renderer 回调又收到 45 次。这里的 0 不再只是“暂停分支里没走到计数代码”因为独立探针证明浏览器的动画帧仍在继续。探针只自增计数并续约不触碰 WebGL 场景它用来确认标签页仍响应原生 rAF不是绘制耗时测试。两条预约链都在同一页面主线程上运行因此它也不能说明 GPU 或显示管线在这段时间做了什么。这也说明“暂停动画”和“让对象不再渲染”不是一回事。setAnimationLoop(null)停的是该 renderer 注册的循环回调对象仍留在场景里材质和几何体也没有被释放。如果页面隐藏时只是想避免动画更新可以停循环如果另一个循环仍在调用 renderer或者你在别处安排了 rAF停止这个 renderer 的循环不会替你取消那些工作。资源释放要单独处理不能把 stop 当成 dispose。恢复后为什么没有一个 250 ms 的大间隔因为脚本把暂停前后的时间戳分成两个数组统计时先分别求相邻差值再合并。每段 45 个时间戳各自产生 44 个间隔合起来是 88 个。这个处理回答的是“停止前后各自的回调间隔是什么”不是“用户经历了多久的暂停”。如果把两边数组直接拼起来再做差边界会产生一个约 250 ms 的值那是暂停时长不是动画循环抖动。实验代码怎么复跑浏览器页在experiments/animation-loops-browser.htmlPlaywright 驱动在experiments/animation-loops-browser.mjs每次运行会写experiments/animation-loops-browser-results.json。运行前在frontend目录启动静态服务器确保 Chrome 可执行文件路径和项目的 Playwright 依赖存在python3-mhttp.server8766--bind127.0.0.1nodeexperiments/animation-loops-browser.mjs统计器保留了每个间隔而不只写下均值也检查 r170 修订号、各组样本量、暂停探针帧数和恢复后的计数。仪表盘是浏览器运行后的页面截图原始精确值在 JSON 中。直方图把间隔分成12、12–20、20–30、≥30 ms四档本次 120 个间隔都落在 12–20 ms。两种循环的平均值四舍五入后相同P95 只有约 0.1 ms 差异在单次、同一标签页的测量里不足以支撑哪一种更快的结论。运行脚本会等页面把animationLoopResults写入window再断言修订号、120 个样本、双 renderer 的计数、暂停窗口和恢复帧数任何一项不符合预期都会中止截图与 JSON 输出。原始 JSON 保存全部间隔遇到均值看起来异常时可以直接检查每个时间戳差值而不是依赖图表的四舍五入结果。脚本固定了 macOS Chrome 可执行文件路径换系统复跑时需要改成当地浏览器路径。这组结果的边界测试使用 Chrome 的 SwiftShader而非指定型号的独立 GPU计时对象是 JavaScript 回调之间的间隔不是renderer.render()的 CPU 用时、GPU 执行时间或端到端显示延迟。双 renderer 组同时预约了两条循环但采样回调只记录时间没有在每个样本里画完整场景。页面中用于截图的三个 torus knot 只在采样前绘制了一次不能拿图像推断持续渲染负载。这是一轮浏览器采样不是不同机器、不同刷新率的重复基准。120 个相邻间隔能描出这次页面的回调分布但样本来自同一标签页、同一段运行时间不能当成 120 次独立设备测试。尤其表中两种方式的平均值只差不到 0.001 ms显示到两位小数后完全一样P95 相差约 0.1 ms也可能随浏览器调度轻微变化。把这个差值写成 API 性能优势就超出了数据。另外这次没有覆盖 WebXR session。Three.js 的推荐理由之一正是统一动画循环接口能够配合 XR我只验证普通 WebGLRenderer 路径。WebGPU renderer 的实现也不应从WebGLAnimation.js推断。官方文档和 r170 WebGL 源码支持的结论很窄普通 WebGLRenderer 的回调由 WebGLAnimation 管理而该实现使用浏览器 rAF停止传 null 会停止这条循环。实测时序与这个结构相符但回调均值接近 16.67 ms 本身并不能证明源码机制。项目里如果只是普通 WebGL 动画直接用renderer.setAnimationLoop()通常少维护一层 rAF 句柄停止时也有清晰入口。需要精确控制固定步长、多个更新阶段或多 renderer 协调时可以自己写调度但要明确每条循环何时启动、暂停、恢复和销毁。至于性能测实际渲染工作不要从“用了哪种循环 API”推断。