Three.js Timer 完全指南:以显式 update() 驱动的现代化计时器及其在动画与物理模拟中的实践
Three.js 从 r183 开始引入 Timer 类(位于 src/core/Timer.js),并将其作为官方推荐的计时工具,替代此前长期使用的 Clock。本文以官方文档 Timer 参考 为骨架,结合源码实现与仓库内实际调用场景,系统讲解 Timer 的 API 设计动机、内部时间机制、Page Visibility 集成方式,以及如何在渲染循环与固定步长模拟中使用它——读完后你将理解为何要用显式 update() 取代隐式状态推进,并能直接迁移已有代码。
为什么需要 Timer:Clock 的设计缺陷与弃用背景
Timer 文档开宗明义:它是 Clock 的替代实现,目标是规避 Clock 长期积累下来的概念性缺陷。在 src/core/Clock.js 中可以看到,Clock 自 r183 起被打上 @deprecated 标记,构造函数中会直接输出弃用警告:
warn( 'Clock: This module has been deprecated. Please use THREE.Timer instead.' ); // @deprecated, r183
对比两份源码可以清晰还原 Clock 的缺陷根源,以及 Timer 对应的设计回应:
| 行为维度 | Clock(已弃用) | Timer(现行) |
|---|---|---|
| 状态更新方式 | 无 update();getDelta() / getElapsedTime() 在内部隐式推进 oldTime 与 elapsedTime |
显式调用 update() 一次性刷新内部状态 |
| 一帧内多次查询 | getDelta() 每次调用都会改变时钟状态,同一帧多次调用会得到不同甚至累计的值 |
只要每步只 update() 一次,之后反复调用 getDelta() / getElapsed() 结果恒定 |
| 副作用位置 | getElapsedTime() 内部会调用 getDelta(),读取操作同时写状态,语义混杂 |
读取与写入完全分离,查询是纯函数 |
| 页面后台切回 | 无任何处理,标签页切回瞬间可能产生超大 delta | 接入 Page Visibility API,页面隐藏期间 delta 归零,回到前台自动重置基准 |
| 启动控制 | 需要 autoStart / running / start() / stop() 等多重状态 |
无需启动状态,_startTime 在构造时以 performance.now() 建立,构造即可用 |
一句话总结差异:Clock 是“查询即推进”的隐式时钟,Timer 是“先 update() 后查询”的显式计时器。对于“一帧内需要把同一份 delta / elapsed 分发给多个子系统(动画、物理、音效、后处理)”的典型渲染循环,Timer 能保证所有消费者拿到完全一致的时间值。
快速上手:最小代码示例
构造 Timer 不需要任何参数,构造完成后即可在动画循环中使用:
const timer = new Timer();
timer.connect( document ); // 启用 Page Visibility API(可选但推荐)
function animate( timestamp ) {
// 每步更新一次内部状态,传入 rAF 回调的毫秒时间戳
timer.update( timestamp );
// 之后可任意多次查询,结果在本次模拟步内保持一致
const delta = timer.getDelta(); // 秒
const elapsed = timer.getElapsed(); // 秒
// ...更新动画 / 物理 / 相机等
requestAnimationFrame( animate );
}
requestAnimationFrame( animate );
这是 Timer 官方文档 中的标准用法骨架。注意 update() 返回 this、支持链式调用,例如 timer.update( timestamp ).getDelta() 也是合法的。
构造函数:new Timer()
new Timer()
构造时初始化全部内部状态。从 src/core/Timer.js 的源码可见其内部字段设计:
_previousTime与_currentTime:上一次与当前的时间基准(毫秒,相对_startTime的相对时间);_startTime:构造时通过performance.now()采样,作为整段时间轴的零点;_delta与_elapsed:内部以毫秒为单位存储的时间增量与累计时长;_timescale:时间缩放系数,默认1;_document与_pageVisibilityHandler:Page Visibility API 的文档引用与事件处理器,初始均为null。
一个值得注意的实现细节:getDelta() 与 getElapsed() 返回的是 _delta / 1000、_elapsed / 1000(源码 L80-L95),即内部按毫秒累积、对外按秒输出。因此内部 _delta 即使很小也不易丢失精度,而 API 表面保持与 Clock 一致的“秒”单位,迁移成本低。
方法与源码级原理解析
.connect( document : Document )
将 Timer 连接到指定 document,从而启用 Page Visibility API,避免应用处于后台(标签页被切换或浏览器被隐藏)时产生异常巨大的 delta 值。该调用并非使用 Timer 的必需步骤,但强烈建议在 Web 页面中启用。
源码中 connect() 的判定逻辑是(Timer.js L43-L57):
if ( document.hidden !== undefined ) {
this._pageVisibilityHandler = handleVisibilityChange.bind( this );
document.addEventListener( 'visibilitychange', this._pageVisibilityHandler, false );
}
它先探测 document.hidden 是否可用,再绑定 visibilitychange 监听器。内部定义的处理函数 handleVisibilityChange() 会在页面重新变为可见(document.hidden === false)时调用 reset()(Timer.js L178-L182),把时间基准重置到当前时刻。
.disconnect()
断开与 DOM 的关联,同时移除 visibilitychange 监听器、将 _pageVisibilityHandler 与 _document 置空(Timer.js L62-L73)。调用的场景通常是:不再需要后台保护(例如退出页面会话)或准备销毁实例。
.dispose()
释放所有内部资源,通常当 Timer 实例不再需要时调用。其实现极其简单——内部直接调用 disconnect()(Timer.js L140-L144),保证移除事件监听、切断 DOM 引用,避免内存泄漏。所以在实际使用中:只 connect() 而未 disconnect() 的实例,销毁前务必调用 dispose()。
.getDelta() : number
返回当前模拟步的时间增量,单位秒(内部为 _delta / 1000)。返回值只有在每次 update() 被调用后才会刷新,因此同一模拟步内多次调用结果完全一致。
.getElapsed() : number
返回自 _startTime(即构造时刻、或最后一次 reset() 之后)累计流逝的有效时间,单位秒。注意它与“墙钟时间”并不总是相等——它会受 _timescale 缩放,且页面隐藏期间不累计(见下文 update() 源码),因此更适合表达“模拟世界时间”。
.getTimescale() : number
返回当前时间缩放系数,即 _timescale 字段本身(Timer.js L102-L106)。
.setTimescale( timescale : number ) : Timer
设置时间缩放系数,该系数会在每次 update() 中缩放 delta 的计算结果(Timer.js L115-L121):
setTimescale( timescale ) {
this._timescale = timescale;
return this;
}
典型用途是慢动作与快进:
timer.setTimescale( 0.25 ); // 四分之一速度,慢动作
timer.setTimescale( 2.0 ); // 双倍速度
timer.setTimescale( 0 ); // 暂停(delta 恒为 0)
timer.setTimescale( 1 ); // 恢复正常
方法返回 this,可链式调用。值得强调的是:缩放作用在 update() 对 delta 的计算上(this._delta = ( this._currentTime - this._previousTime ) * this._timescale),因此 getDelta() 与 getElapsed() 会同步受缩放影响,模拟时间的“快慢”在读取侧完全透明。
.reset() : Timer
重置当前模拟步的时间计算。源码实现(Timer.js L128-L134)是:
reset() {
this._currentTime = performance.now() - this._startTime;
return this;
}
它把 _currentTime 直接拉平到“现在”,使下一次 update() 计算出的 delta 以重置点为基准,从而“抹掉”中间这段被跳过的时长。正如前面所述,reset() 是页面从后台切回前台时消除超大 delta 的关键一环。
.update( timestamp : number ) : Timer
Timer 的核心入口,负责推进内部状态。官方文档与 JSDoc 都明确约定:每个模拟步调用一次,且要在查询(getDelta() 等)之前调用。timestamp 参数为毫秒级当前时间,可直接使用 requestAnimationFrame 回调的参数;若省略,则内部自动用 performance.now() 获取。
update() 的完整实现逻辑如下(Timer.js L156-L174):
update( timestamp ) {
if ( this._pageVisibilityHandler !== null && this._document.hidden === true ) {
this._delta = 0;
} else {
this._previousTime = this._currentTime;
this._currentTime = ( timestamp !== undefined ? timestamp : performance.now() ) - this._startTime;
this._delta = ( this._currentTime - this._previousTime ) * this._timescale;
this._elapsed += this._delta; // _elapsed is the accumulation of all previous deltas
}
return this;
}
这里有三处关键实现细节值得展开:
- 页面隐藏时的短路逻辑:当已
connect(document)且document.hidden === true时,update()直接置_delta = 0,既不推进_currentTime也不累计_elapsed。这意味着后台期间场景时间完全冻结,回到前台后也不会突然“追补”一大段 delta; - 毫秒时间戳减去
_startTime:无论你传入 rAF 的timestamp还是走performance.now(),两者都会先减去构造时记录的时间零点,得到与基准对齐的相对毫秒数; - delta 缩放后再累计:
_elapsed += _delta处,_delta是已经乘过_timescale的结果,因此elapsed是所有已缩放 delta 的累加和——这也解释了为何“模拟时间”可能快于或慢于真实墙钟时间。
背景标签页保护:从机制到收益
许多 Web 3D 应用会遇到一个经典问题:用户把标签页切到后台再切回来时,浏览器为省电会暂停 rAF;恢复渲染的第一帧,若直接按墙钟时间差计算 delta,会得到如 30 秒、数分钟级别的瞬时增量,导致物理引擎“爆炸”、动画画面瞬间跳变。
Timer 对这个问题有两层防护(均在 connect() 后生效):
- 隐藏期间:
update()走入短路分支,_delta恒为 0,不会累积后台时间; - 重新可见瞬间:
visibilitychange触发handleVisibilityChange,当页面不再隐藏时立即reset(),把_currentTime重置到当前时刻。
两层机制配合后,即使 rAF 被浏览器暂停很久,恢复渲染时首个 delta 也只反映“恢复后第一帧”的正常间隔。这正是文档强调“避免应用不活跃时产生大 delta”的完整实现路径。
Timer 在仓库中的真实应用场景
Timer 并非孤立存在,它在当前仓库中已被多个模块作为内部时间源使用,是验证其 API 设计价值的活例证:
- AudioListener:内部创建
this._timer = new Timer(),用于为 Web Audio 的linearRampToValueAtTime()提供所需的时间增量(timeDelta),保证音频参数平滑变化与动画帧节奏一致; - examples/jsm/objects/Water2.js:水面材质的内部实现
new Timer()创建计时器,用于驱动水面流动相关动画的时间推进; - examples/jsm/physics/RapierPhysics.js 与 examples/jsm/physics/JoltPhysics.js:物理示例通过
import { Timer, ... } from 'three'直接使用核心导出的Timer推进固定步长的物理模拟——这类场景正是Timer的典型用武之地,因为它允许在一个模拟步内先update()一次,再多次读取同一 delta 以驱动插值与刚体同步; - 核心构建入口 src/Three.Core.js 中
export { Timer } from './core/Timer.js',即从three主包即可直接导入使用。
从这些调用点可以归纳出 Timer 的两个最佳实践场景:多系统共享同一帧时间(动画 + 物理 + 音频统一用一份 delta)与需要时间缩放 / 后台保护的仿真。
与 Clock 的迁移对照
若你正在把旧代码从 Clock 迁移到 Timer,可以直接对照以下替换模板:
// 旧:Clock(已弃用)
const clock = new Clock();
function animate() {
const delta = clock.getDelta(); // 隐式推进,一帧内多次调用结果不同
const elapsed = clock.getElapsedTime();
...
}
// 新:Timer
const timer = new Timer();
timer.connect( document );
function animate( timestamp ) {
timer.update( timestamp ); // 显式推进,一帧只调一次
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
...
}
迁移时的三个记忆要点:
Timer没有autoStart、running、start()、stop()这些概念,构造即可用,无需“启动”;- 显式
update()是你唯一需要关心的状态推进入口,务必保证每模拟步恰好调用一次,且在查询前调用; getDelta()/getElapsed()的语义分别对应旧的getDelta()/getElapsedTime(),返回单位同为秒,大多数业务代码只需调整调用节奏即可平滑迁移。
总结
Timer 通过“显式 update() + 查询与推进分离 + Page Visibility 集成 + 时间缩放”四项设计,解决了 Clock 在真实 Web 应用中暴露的隐式状态推进、读取副作用、后台大 delta 等长期问题。其完整实现仅约 180 行(含注释与工具函数),逻辑清晰、无外部依赖,可直接阅读 src/core/Timer.js 通读全貌;官方 API 参考见 docs/pages/Timer.html.md。在构建动画循环、固定步长物理模拟或多系统时间同步逻辑时,Timer 都是比 Clock 更稳妥的默认选择。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00