首页
/ Three.js Timer 完全指南:以显式 update() 驱动的现代化计时器及其在动画与物理模拟中的实践

Three.js Timer 完全指南:以显式 update() 驱动的现代化计时器及其在动画与物理模拟中的实践

2026-09-08 23:29:38作者:柯茵沙

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()内部隐式推进 oldTimeelapsedTime 显式调用 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;

}

这里有三处关键实现细节值得展开:

  1. 页面隐藏时的短路逻辑:当已 connect(document)document.hidden === true 时,update() 直接置 _delta = 0,既不推进 _currentTime 也不累计 _elapsed。这意味着后台期间场景时间完全冻结,回到前台后也不会突然“追补”一大段 delta;
  2. 毫秒时间戳减去 _startTime:无论你传入 rAF 的 timestamp 还是走 performance.now(),两者都会先减去构造时记录的时间零点,得到与基准对齐的相对毫秒数;
  3. 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.jsexamples/jsm/physics/JoltPhysics.js:物理示例通过 import { Timer, ... } from 'three' 直接使用核心导出的 Timer 推进固定步长的物理模拟——这类场景正是 Timer 的典型用武之地,因为它允许在一个模拟步内先 update() 一次,再多次读取同一 delta 以驱动插值与刚体同步;
  • 核心构建入口 src/Three.Core.jsexport { 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();
    ...
}

迁移时的三个记忆要点:

  1. Timer 没有 autoStartrunningstart()stop() 这些概念,构造即可用,无需“启动”;
  2. 显式 update() 是你唯一需要关心的状态推进入口,务必保证每模拟步恰好调用一次,且在查询前调用;
  3. getDelta() / getElapsed() 的语义分别对应旧的 getDelta() / getElapsedTime(),返回单位同为秒,大多数业务代码只需调整调用节奏即可平滑迁移。

总结

Timer 通过“显式 update() + 查询与推进分离 + Page Visibility 集成 + 时间缩放”四项设计,解决了 Clock 在真实 Web 应用中暴露的隐式状态推进、读取副作用、后台大 delta 等长期问题。其完整实现仅约 180 行(含注释与工具函数),逻辑清晰、无外部依赖,可直接阅读 src/core/Timer.js 通读全貌;官方 API 参考见 docs/pages/Timer.html.md。在构建动画循环、固定步长物理模拟或多系统时间同步逻辑时,Timer 都是比 Clock 更稳妥的默认选择。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
521
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
392