three.js 中的 Clock 时间类:getDelta 与 getElapsedTime 的实现原理、用法及 Timer 迁移指南
本文以 three.js 官方 API 文档中的 Clock 参考文档 为主体,结合 src/core/Clock.js 的源码实现与 Clock 单元测试,完整讲解这个用于"记录时间"的核心类的构造参数、五个公开属性、四个方法的行为细节与边界情况,并说明为什么自 r183 起该模块已被标记弃用、以及如何平滑迁移到 Timer。读完本篇,你可以准确掌握在渲染循环中计算帧间隔(delta time)与累计时长(elapsed time)的正确方式,理解 autoStart/stop() 引发的常见"时钟不再走动"陷阱,并知道新代码应使用哪个替代类。
Clock 是做什么的:帧循环中的时间基准
在 3D 场景中,几乎所有"运动"——动画混合器推进、物体位移、相机控制器的阻尼——都需要一个时间量。three.js 的渲染惯例是 requestAnimationFrame 驱动的主循环:
renderer.setAnimationLoop( () => {
const delta = clock.getDelta(); // 本帧与上一帧的间隔(秒)
mixer.update( delta ); // 动画按帧间隔推进
// ...
} );
Clock 的职责就是为这个循环提供时间基准:以 performance.now() 为时钟源(毫秒精度),对外输出以秒为单位的"帧间隔"(getDelta())和"累计运行时长"(getElapsedTime())。官方文档将其定义为一句话——"Class for keeping track of time"(用于跟踪时间的类),它是 Three.Core.js 中导出的核心模块之一(第 109 行 export { Clock } from './core/Clock.js')。
需要注意一个重要的版本事实:自 r183 起 Clock 已被标记为弃用(deprecated)。Clock 类定义 上带有 @deprecated since r183 标注,且构造函数会直接调用 warn() 打印提示(Clock.js 第 61 行):
warn( 'Clock: This module has been deprecated. Please use THREE.Timer instead.' ); // @deprecated, r183
因此本篇的价值在于:一是完整读懂存量代码与文档中大量存在的 Clock 用法;二是理解其设计缺陷,为迁移到 Timer(官方文档 Timer 称其为 "an alternative to Clock with a different API design and behavior")提供依据。
构造函数与 autoStart 参数
官方文档给出的构造签名为:
new Clock( autoStart : boolean )
| 参数 | 说明 | 默认值 |
|---|---|---|
autoStart |
是否允许时钟在首次调用 getDelta() 时自动启动 |
true |
对照 构造函数源码,参数处理非常简单:
constructor( autoStart = true ) {
this.autoStart = autoStart;
this.startTime = 0;
this.oldTime = 0;
this.elapsedTime = 0;
this.running = false; // 注意:初始并不运行
warn( 'Clock: This module has been deprecated. ...' );
}
源码中有两个值得注意的细节,文档只字未提:
- 初始状态
running为false:文档表格中.running的默认值写作true,而实际代码初始化时是false。之所以"默认自动启动"成立,是因为autoStart为true时,首次getDelta()会触发隐式start()(见下文)。也就是说"默认true"描述的是等效行为,而非初始字段值。 - 构造即告警:实例化
Clock会立即触发一条弃用警告,这在示例项目批量迁移时会很醒目。
单元测试 Clock.tests.js 验证了这两种实例化方式均合法:new Clock() 与 new Clock( false ) 都可以成功创建对象。
五个公开属性逐项解析
官方文档列出了 5 个属性,以下结合 源码字段注释 逐一说明其语义与默认值:
.autoStart : boolean(默认 true)
若为 true,当 getDelta() 被首次调用而时钟尚未运行时,类会自动替你调用 start()。置为 false 则完全交由你手动控制 start()/stop() 的时机。
.elapsedTime : number(默认 0)
时钟累计运行总时长(秒)。它不是"从某一起点到现在"的墙钟时间,而是历次 getDelta() 差值的累加,因此时钟 stop() 期间不会增长。
.oldTime : number(默认 0)
记录最近一次调用 start()、getElapsedTime() 或 getDelta() 时的时间戳(毫秒)。它是计算帧间隔的"上一次读数"。
.running : boolean
时钟当前是否处于运行状态。stop() 后置 false,start() 后置 true。
.startTime : number(默认 0)
最近一次 start() 被调用时的时间戳(毫秒)。注意它只是"起点记录",并不参与 elapsedTime 的计算——elapsedTime 是靠差值累加维护的。
方法行为深挖:源码级调用链
.getDelta() : number
返回距上一次查询以来的时间差(秒)。这是渲染循环中最常用的方法。完整实现见 Clock.js 第 107–131 行:
getDelta() {
let diff = 0;
if ( this.autoStart && ! this.running ) {
this.start();
return 0; // 自动启动的这一帧,间隔返回 0
}
if ( this.running ) {
const newTime = performance.now();
diff = ( newTime - this.oldTime ) / 1000; // 毫秒 → 秒
this.oldTime = newTime;
this.elapsedTime += diff;
}
return diff; // 未运行时恒为 0
}
从源码结构看,这里有三个关键行为:
- 自动启动帧返回
0:autoStart生效的那一次调用只做start(),返回间隔为 0,避免第一帧出现一个巨大的或无意义的 delta; - 毫秒转秒:
performance.now()返回毫秒,/ 1000转为秒。这与文档"Returns the delta time in seconds"严格对应; - 停止时恒返回 0:
running为false且无法自动启动时,diff保持初始值0,同时oldTime/elapsedTime都不再变化。
.getElapsedTime() : number
返回累计运行时长(秒)。实现只有一行核心逻辑(Clock.js 第 95–100 行):
getElapsedTime() {
this.getDelta(); // 先推进一次内部状态
return this.elapsedTime;
}
这里隐含一个容易被忽略的事实:每次调用 getElapsedTime() 都会先执行一次 getDelta(),即它会刷新 oldTime 并累加 elapsedTime。因此连续两次 getElapsedTime() 之间只要隔了一段时间,返回值必然不同——这与 Timer.getElapsed()"同一帧内多次查询值不变"的设计形成鲜明对比,是 Clock 被评价为存在"概念性缺陷"(Timer 类注释原文)的原因之一。
.start()
重新开始计时:记录新的 startTime,把 oldTime 同步为该时刻,elapsedTime 归零,running 置 true(Clock.js 第 69–77 行)。文档说明:当 autoStart 为 true 时,该方法会在首次 getDelta() 时被类自动调用。
.stop()
停止时钟。实现(Clock.js 第 82–88 行):
stop() {
this.getElapsedTime(); // 先结算到当前时刻
this.running = false;
this.autoStart = false; // 关键:永久关闭自动启动
}
这里藏着 Clock 最著名的坑:stop() 会把 autoStart 永久改写为 false。之后即使你再次调用 getDelta(),时钟也不会自己醒来,除非你显式调用 start()。许多"暂停恢复后时间不动了"的 bug 都源于此——想恢复必须手动 clock.start(),而不是期待自动重启。
用单元测试验证行为
Clock 单元测试 通过 mock 掉 performance 对象(构造一个可控的假时钟 performance.next( delta ))来精确断言行为,值得作为"Clock 到底该怎么算时间"的标准答案:
const clock = new Clock( false );
clock.start();
performance.next( 123 );
assert.numEqual( clock.getElapsedTime(), 0.123, 'okay' ); // 123ms → 0.123s
performance.next( 100 );
assert.numEqual( clock.getElapsedTime(), 0.223, 'okay' ); // 累计 0.223s
clock.stop();
performance.next( 1000 );
assert.numEqual( clock.getElapsedTime(), 0.223, "don't update time if the clock was stopped" );
这段测试精确印证了三点:毫秒到秒的换算;elapsedTime 是累加量(0.123 + 0.1 = 0.223);stop() 之后即便真实时间流逝 1000ms,getElapsedTime() 也停留在 0.223——时钟停止期间不计入累计时长。
典型使用模式:在渲染循环中消费时间
在 r183 之前的代码(以及大量存量示例)中,Clock 的标准用法是:
import * as THREE from 'three';
const clock = new THREE.Clock();
renderer.setAnimationLoop( () => {
const delta = clock.getDelta(); // 秒
const elapsed = clock.getElapsedTime(); // 秒(累计)
mixer.update( delta );
// 例:让一个物体以恒定速度旋转,速度不依赖帧率
mesh.rotation.y = elapsed * Math.PI * 2;
} );
两个实践要点(均由上述源码行为直接推出):
getDelta()每帧只应调用一次:它每调用一次都会推进oldTime,同一帧内多处重复调用会互相"偷走"时间;- 暂停后恢复要显式
start():stop()已关闭autoStart,恢复动画前先clock.start()(注意start()会把elapsedTime归零,如果依赖累计时间需自行补偿)。
需要说明的是,当前仓库的 examples/ 目录中的示例已基本完成迁移。例如 webgl_loader_gltf.html 中使用的就是新类:
const timer = new THREE.Timer();
// ...
timer.update(); // 每帧先更新内部状态
mixer.update( timer.getDelta() );
Clock 的设计缺陷与 Timer 迁移路径
Timer 的源码注释 开宗明义地指出了迁移动机:Clock 在长期演进中暴露出"概念性缺陷"(conceptual flaws),Timer 用不同的 API 设计规避了这些问题。对照 Timer 实现,核心改进有两点:
| 对比维度 | Clock(r183 起弃用) | Timer |
|---|---|---|
| 时间状态更新方式 | 查询即更新:getDelta()/getElapsedTime() 每次调用都刷新内部状态,同一帧多次查询得到不同值 |
显式 update( timestamp ) 每帧推进一次,之后 getDelta()/getElapsed() 可多次调用且值稳定 |
| 页面不可见时的行为 | 切换标签页再回来会算出一个巨大 delta(可能让物理/动画"跳变") | 可调用 timer.connect( document ) 接入 Page Visibility API:隐藏时 delta 记 0,恢复可见时 reset() 重置基准,避免时间突刺 |
| 附加能力 | start()/stop()/autoStart 状态机 |
setTimescale( timescale ) 直接做时间缩放(慢放/加速)、reset()、dispose() |
Timer.update() 的实现(Timer.js 第 156–174 行)还能接收 requestAnimationFrame 回调传入的 timestamp 参数,省得自己调 performance.now():
renderer.setAnimationLoop( ( timestamp ) => {
timer.update( timestamp );
mixer.update( timer.getDelta() );
} );
迁移建议:
- 新代码一律用
Timer:new THREE.Timer()+ 每帧timer.update()+timer.getDelta(),需要防切页时间突刺时加timer.connect( document ); - 存量
Clock代码不必立即重写:它在 r183 及以后仍会正常工作(只是构造时打印警告),可结合项目维护节奏渐进替换; - 注意语义差异:
Clock.start()会清零elapsedTime,而Timer.reset()只重置当前步的 delta 基准;Clock的暂停/恢复对应关系是Timer的update()/不调用,行为模型完全不同,替换时逐场景核对暂停逻辑。
参考
- 官方 API 文档:Clock、Clock 页面、Timer
- 源码实现:src/core/Clock.js、src/core/Timer.js
- 行为验证:test/unit/src/core/Clock.tests.js
- 迁移示例:examples/webgl_loader_gltf.html(已使用
THREE.Timer)
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 StartedRust0629
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证件照制作算法。Python07
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