animations-tweens

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Animations and Tweens in Decentraland

Decentraland 中的动画与 Tween

When to Use Which Animation Approach

如何选择动画方案

NeedApproachWhen
Play animation baked into a .glb model
Animator
Character walks, door opens, flag waves — any animation from Blender/Maya
Move/rotate/scale an entity smoothly
Tween
Sliding doors, floating platforms, growing objects — procedural A-to-B motion
Chain multiple animations in sequence
TweenSequence
Patrol paths, multi-step doors, complex choreography
Continuous per-frame control
engine.addSystem()
Physics-like motion, following a target, custom easing, input-driven user control
Decision flow:
  1. Does the .glb already have the animation? →
    Animator
  2. Simple move/rotate/scale between two values? →
    Tween
  3. Need frame-by-frame control or custom math? → System with
    dt
需求方案适用场景
播放烘焙在 .glb 模型中的动画
Animator
角色行走、门打开、旗帜飘动——来自 Blender/Maya 的任意动画
平滑地移动/旋转/缩放实体
Tween
滑动门、漂浮平台、物体缩放——从 A 到 B 的程序化运动
按顺序串联多个动画
TweenSequence
巡逻路径、多阶段门、复杂编排
持续逐帧控制
engine.addSystem()
类物理运动、跟随目标、自定义缓动、输入驱动的用户控制
决策流程:
  1. .glb 已经包含所需动画?→
    Animator
  2. 只需在两个值之间简单移动/旋转/缩放?→
    Tween
  3. 需要逐帧控制或自定义数学计算?→ 使用带
    dt
    的 System

GLTF Animations (Animator)

GLTF 动画(Animator)

Play animations embedded in .glb models. Supports skeletal animations, object animations, and shape key (morph target) animations. Shape keys are useful for facial expressions, lip sync, or deformations.
Set up with
Animator.create(entity, { states: [{ clip: 'idle', playing: true, loop: true, speed: 1 }] })
. Play a single animation with
Animator.playSingleAnimation(entity, 'walk')
. Stop all with
Animator.stopAllAnimations(entity)
. Get a clip with
Animator.getClip(entity, 'Walk')
. Blend animations by setting
weight
(0.0-1.0) on multiple states.
播放嵌入 .glb 模型中的动画。支持骨骼动画、对象动画和形态键(morph target)动画。形态键适用于面部表情、口型同步或形变。
使用
Animator.create(entity, { states: [{ clip: 'idle', playing: true, loop: true, speed: 1 }] })
进行初始化。使用
Animator.playSingleAnimation(entity, 'walk')
播放单个动画。使用
Animator.stopAllAnimations(entity)
停止所有动画。使用
Animator.getClip(entity, 'Walk')
获取 clip。通过在多个状态上设置
weight
(0.0-1.0)来混合动画。

PITFALL:
playSingleAnimation
silently no-ops on clips not in
states
(programmatic Animator only)

PITFALL:
playSingleAnimation
对不在
states
中的 clip 会静默无操作(仅适用于以编程方式创建的 Animator)

Scope: this applies only when you author the
Animator
entirely in code and call
Animator.playSingleAnimation(entity, clipName)
programmatically. In many practical workflows the clip list is already populated for you — see "When you don't need to register clips manually" below.
The narrow fact (verified —
@dcl/ecs/dist-cjs/components/extended/Animator.js
lines 35-46):
playSingleAnimation(entity, clipName)
returns
false
and does nothing when
clipName
is not present in the entity's
Animator.states
array. The internal
getClipAndAnimator
helper returns a null state, and
playSingleAnimation
exits with
if (!animator || !state) return false
. There is no console warning.
So when you're building the Animator yourself in TypeScript and you intend to call
playSingleAnimation
with a clip name
, that clip must already be in
states[]
:
ts
Animator.create(ghost, {
  states: [
    { clip: "Idle_001", playing: true, loop: true },
    { clip: "Idle_002", playing: false, loop: true },
    { clip: "Appear", playing: false, loop: false },
    { clip: "Death", playing: false, loop: false },
    { clip: "Hit", playing: false, loop: false },
  ],
});
If you want SDK6's "play any clip, no setup required" ergonomics, wrap
playSingleAnimation
and lazily add missing states:
ts
function playAnim(entity: Entity, clip: string, loop = false) {
  const a = Animator.getMutableOrNull(entity);
  if (!a) return;
  if (!a.states.find((s) => s.clip === clip)) {
    a.states.push({
      clip,
      playing: false,
      loop,
      shouldReset: true,
      speed: 1,
      weight: 1,
    });
  }
  Animator.playSingleAnimation(entity, clip, true);
  // playSingleAnimation already pauses all other states
}
适用范围: 此规则仅适用于完全通过代码创建
Animator
,并以编程方式调用
Animator.playSingleAnimation(entity, clipName)
的情况。在许多实际工作流中,clip 列表已经自动填充——参见下文“无需手动注册 clip 的情况”。
已验证的 narrow fact(
@dcl/ecs/dist-cjs/components/extended/Animator.js
第 35-46 行):当
clipName
不存在于实体的
Animator.states
数组中时,
playSingleAnimation(entity, clipName)
会返回
false
且不执行任何操作。内部的
getClipAndAnimator
helper 会返回 null state,随后
playSingleAnimation
通过
if (!animator || !state) return false
退出。控制台不会输出警告。
因此,当你在 TypeScript 中自行创建 Animator,并打算使用 clip 名称调用
playSingleAnimation
时
,该 clip 必须已经存在于
states[]
中:
ts
Animator.create(ghost, {
  states: [
    { clip: "Idle_001", playing: true, loop: true },
    { clip: "Idle_002", playing: false, loop: true },
    { clip: "Appear", playing: false, loop: false },
    { clip: "Death", playing: false, loop: false },
    { clip: "Hit", playing: false, loop: false },
  ],
});
如果希望获得 SDK6 的“play any clip, no setup required”式易用体验,可以包装
playSingleAnimation
,并按需延迟添加缺失的状态:
ts
function playAnim(entity: Entity, clip: string, loop = false) {
  const a = Animator.getMutableOrNull(entity);
  if (!a) return;
  if (!a.states.find((s) => s.clip === clip)) {
    a.states.push({
      clip,
      playing: false,
      loop,
      shouldReset: true,
      speed: 1,
      weight: 1,
    });
  }
  Animator.playSingleAnimation(entity, clip, true);
  // playSingleAnimation already pauses all other states
}

When you don't need to register clips manually

无需手动注册 clip 的情况

Several common workflows populate the clip list for you — the rule above does not apply in these cases:
  • GltfContainer
    with no
    Animator
    attached.
    The renderer autoplays one of the GLB's clips on its own (observed: the first/default clip baked into the file). Confirmed first-hand in a Halloween scene where small ghosts had no
    Animator
    and the renderer autoplayed
    die
    straight from the GLB. Use this when the model should just spawn already animating and you don't need to switch clips at runtime.
  • Creator Hub Inspector placement. When you drop an asset into the Inspector, the composite is written with an
    Animator
    whose
    states
    array already includes every clip from the GLB. Creators using the Inspector never see the registration step because the editor does it.
  • Asset packs / smart items. These ship with
    states
    already populated by whoever authored the pack.
The "you have to pre-register" issue only bites the author-in-code +
playSingleAnimation
path. If a model is animating without any code-side
Animator.create
call, you're on the autoplay path described above — that's expected.
Related: if a
GltfContainer
model spawns playing the wrong clip (e.g. a death pose on what should be an idling NPC), the entity has no
Animator
and the renderer picked a clip you didn't intend. Add an
Animator.create
with the desired default clip set to
playing: true
to take control.
Several common workflows populate the clip list for you,因此上述规则不适用于这些情况:
  • 未附加
    Animator
    的
    GltfContainer
    。
    渲染器会自动播放 GLB 中的某个 clip(实际观察到的是文件中烘焙的第一个/默认 clip)。曾在万圣节场景中亲自验证:小型幽灵没有
    Animator
    ,渲染器仍直接从 GLB 自动播放
    die
    。当模型只需在生成时自动播放,且运行时无需切换 clip 时,可以使用此方式。
  • Creator Hub Inspector 放置资源。 将资源放入 Inspector 后,生成的 composite 会写入一个
    Animator
    ,其
    states
    数组已经包含 GLB 中的每一个 clip。使用 Inspector 的创作者无需看到注册步骤,因为编辑器会自动完成。
  • 资源包/智能道具。 这些内容在发布时,已经由包作者预先填充
    states
    。
“必须预先注册”的问题只会影响**通过代码创建 +
playSingleAnimation
**这条路径。如果模型在没有代码侧
Animator.create
调用的情况下仍会播放动画,那么它使用的是上文描述的自动播放路径,这是预期行为。
相关说明:如果
GltfContainer
模型生成后播放了错误的 clip,例如本应处于待机状态的 NPC 却播放死亡姿态,那么实体没有
Animator
,且渲染器选择了一个非预期 clip。添加
Animator.create
,并将目标默认 clip 集合设为
playing: true
,即可接管控制。

Resting an animated model at its FIRST frame (start-closed doors / graves / lids)

将动画模型停在其第一帧(用于应从关闭状态开始的门/坟墓/盖子)

A common SDK6 → SDK7 port problem: a door/grave/coffin GLB whose "closed" pose is frame 0 of its open/trigger clip. Under SDK7 the renderer auto-plays a clip on load and holds its final frame, so the model spawns open instead of closed.
Verified fix (user-confirmed in-world): keep a state actively
playing: true
but frozen with
speed: 0
and
loop: false
, so it holds frame 0 — the closed/rest pose — while still being a controlled, actively-playing state:
typescript
// Hold `clip` at its first frame (closed pose), under your control.
export function holdFirstFrame(entity: Entity, clip: string) {
  if (!Animator.has(entity)) Animator.create(entity, { states: [] })
  const a = Animator.getMutable(entity)
  if (!a.states.some((s) => s.clip === clip)) a.states = [...a.states, { clip }]
  Animator.stopAllAnimations(entity)
  const state = Animator.getClipOrNull(entity, clip)
  if (state) { state.playing = true; state.loop = false; state.speed = 0 }
}
Then, to open, set the same clip to
speed: 1, playing: true
(a normal
playSingleAnimation
/
playClip
call); to close, play the reverse/close clip. This is the observed behavior + working fix; the precise internals of which clip the renderer auto-plays are not documented in the SDK — treat it as renderer behavior and verify per model.
常见的 SDK6 → SDK7 移植问题:门、坟墓或棺材 GLB 的“关闭”姿态恰好是其打开/触发 clip 的第 0 帧。在 SDK7 下,渲染器加载后会自动播放 clip 并停留在最后一帧,因此模型生成时处于打开状态,而不是关闭状态。
已验证的修复(用户已在场景中确认): 保持一个
playing: true
的状态,同时使用
speed: 0
和
loop: false
将其冻结。这样可以将模型保持在第 0 帧,也就是关闭/静止姿态,同时该状态仍受控制并处于播放中:
typescript
// Hold `clip` at its first frame (closed pose), under your control.
export function holdFirstFrame(entity: Entity, clip: string) {
  if (!Animator.has(entity)) Animator.create(entity, { states: [] })
  const a = Animator.getMutable(entity)
  if (!a.states.some((s) => s.clip === clip)) a.states = [...a.states, { clip }]
  Animator.stopAllAnimations(entity)
  const state = Animator.getClipOrNull(entity, clip)
  if (state) { state.playing = true; state.loop = false; state.speed = 0 }
}
随后,若要打开,将同一 clip 设置为
speed: 1, playing: true
(执行常规的
playSingleAnimation
/
playClip
调用);若要关闭,则播放反向/关闭 clip。这是实际观察到的行为和可用修复。SDK 并未记录渲染器究竟自动播放哪个 clip 的精确内部机制,因此应将其视为渲染器行为,并针对每个模型进行验证。

BEST PRACTICE: Short-circuit clip-switch helpers called from per-frame callers

BEST PRACTICE:对逐帧调用方触发的 clip 切换 helper 进行短路处理

Scope: this applies when you write your own clip-switch helper that mutates
Animator.states
directly (
s.playing
,
s.loop
,
s.shouldReset
) and that helper is invoked from a per-frame caller (an
engine.addSystem
update, an
inputSystem
/raycast callback that fires every tick, or any code path that runs each frame). The most common reason to write such a helper is porting an SDK6 scene that used lazy clip registration or
noLoop + revertToIdle
semantics ([[migrate-sdk6-to-sdk7]]).
Why it matters. A naive helper looks like this:
ts
function playClip(entity: Entity, name: string, loop: boolean) {
  const a = Animator.getMutableOrNull(entity);
  if (!a) return;
  for (const s of a.states) {
    if (s.clip === name) {
      s.playing = true;
      s.loop = loop;
      s.shouldReset = true; // <-- rewritten every tick if helper is called per-frame
    } else {
      s.playing = false;
    }
  }
}
Called every frame, this calls
Animator.getMutableOrNull()
each tick, which marks the entity dirty in the CRDT layer. The ECS then serializes the component to bytes and compares against the last-sent snapshot (
lww-element-set-component-definition.ts
). Since the values are identical frame-to-frame, the CRDT suppression layer silently drops the update — the animation will NOT freeze. However, the per-frame serialization and byte comparison is unnecessary overhead that should be avoided.
Note on
Animator.playSingleAnimation
.
Verified against
@dcl/ecs/src/components/extended/Animator.ts
:
playSingleAnimation
is NOT idempotent — it writes
playing=false, shouldReset=true
on every state in a loop, then
playing=true, shouldReset=<arg>
on the target. Calling it per-frame triggers the same unnecessary serialization overhead. Prefer to call
playSingleAnimation
from one-shot transitions (
onPointerDown
, state-machine edges) rather than from a system tick.
Fix — track the last-applied clip and short-circuit:
ts
type Anim = { entity: Entity; lastClip?: string };

function playClip(a: Anim, name: string, loop: boolean) {
  if (a.lastClip === name) return; // already applied — skip redundant serialization
  const an = Animator.getMutableOrNull(a.entity);
  if (!an) return;
  for (const s of an.states) {
    if (s.clip === name) {
      s.playing = true;
      s.loop = loop;
      s.shouldReset = true;
    } else {
      s.playing = false;
    }
  }
  a.lastClip = name;
}
The short-circuit avoids calling
getMutableOrNull()
on unchanged frames, eliminating the per-frame serialization overhead. The CRDT delta is only sent on actual clip transitions.
SDK6-port subtlety —
noLoop + revertToIdle
.
An SDK6-style helper that takes
(clipName, noLoop, durationSec)
and schedules a
timers.setTimeout
(from
@dcl/sdk/ecs
— never the native JS
setTimeout
) to revert to an idle clip must:
  • On a same-clip re-call (e.g. the player holds the beam and "Hit" keeps being requested every frame), only refresh the timer (clear + reschedule) so the clip keeps replaying for as long as input is held. Do not re-mutate
    Animator.states
    .
  • When the revert-to-idle timer fires, clear
    lastClip
    before re-calling the helper with the idle clip
    — otherwise the short-circuit prevents the idle from being applied if
    lastClip
    already equals the idle name from a prior tick.
When to prefer
Animator.playSingleAnimation
.
It's the canonical write: pauses all other states in one pass and accepts a
resetCursor
argument (defaults to
true
). It's not idempotent — but it's the right call site when the trigger is a one-shot event. The custom-helper pattern only exists when porting SDK6 code that needs lazy clip registration (see the previous PITFALL) or
noLoop + revertToIdle
semantics.
适用范围: 此规则适用于你自行编写 clip 切换 helper、直接修改
Animator.states
(
s.playing
、
s.loop
、
s.shouldReset
),且该 helper 由逐帧调用方调用的场景。逐帧调用方可以是
engine.addSystem
更新、每个 tick 都会触发的
inputSystem
/raycast callback,或任何每帧运行的代码路径。编写这种 helper 最常见的原因,是移植使用延迟 clip 注册或
noLoop + revertToIdle
语义的 SDK6 场景([[migrate-sdk6-to-sdk7]])。
为什么这很重要。 一个朴素的 helper 如下所示:
ts
function playClip(entity: Entity, name: string, loop: boolean) {
  const a = Animator.getMutableOrNull(entity);
  if (!a) return;
  for (const s of a.states) {
    if (s.clip === name) {
      s.playing = true;
      s.loop = loop;
      s.shouldReset = true; // <-- rewritten every tick if helper is called per-frame
    } else {
      s.playing = false;
    }
  }
}
每帧调用时,这会在每个 tick 调用
Animator.getMutableOrNull()
,从而将实体标记为 CRDT 层中的 dirty。随后,ECS 会将组件序列化为字节,并与上次发送的 snapshot(
lww-element-set-component-definition.ts
)进行比较。由于这些值逐帧完全相同,CRDT 抑制层会静默丢弃更新,因此动画不会冻结。然而,这种逐帧序列化和字节比较属于不必要的开销,应当避免。
关于
Animator.playSingleAnimation
。
已对照
@dcl/ecs/src/components/extended/Animator.ts
验证:
playSingleAnimation
不是幂等操作。它会先在一次循环中为每个状态写入
playing=false, shouldReset=true
,然后为目标状态写入
playing=true, shouldReset=<arg>
。每帧调用它会产生相同的额外序列化开销。应优先在一次性转换(
onPointerDown
、状态机边沿)中调用
playSingleAnimation
,而不是在 system tick 中调用。
修复——记录最后应用的 clip 并进行短路处理:
ts
type Anim = { entity: Entity; lastClip?: string };

function playClip(a: Anim, name: string, loop: boolean) {
  if (a.lastClip === name) return; // already applied — skip redundant serialization
  const an = Animator.getMutableOrNull(a.entity);
  if (!an) return;
  for (const s of an.states) {
    if (s.clip === name) {
      s.playing = true;
      s.loop = loop;
      s.shouldReset = true;
    } else {
      s.playing = false;
    }
  }
  a.lastClip = name;
}
短路处理可避免在未变化的帧中调用
getMutableOrNull()
,从而消除逐帧序列化开销。CRDT delta 只会在实际发生 clip 转换时发送。
SDK6 移植细节——
noLoop + revertToIdle
。
如果 SDK6 风格的 helper 接收
(clipName, noLoop, durationSec)
,并使用
timers.setTimeout
(来自
@dcl/sdk/ecs
,绝不要使用原生 JS
setTimeout
)调度恢复到 idle clip,则必须满足以下条件:
  • 再次调用同一 clip 时,例如玩家持续握住光束,导致每帧都请求
    Hit
    ,只刷新 timer(清除并重新调度),使 clip 在按住输入期间持续重播。不要重新修改
    Animator.states
    。
  • 当恢复到 idle 的 timer 触发时,先清除
    lastClip
    ,再使用 idle clip 重新调用 helper;否则,如果先前某个 tick 中的
    lastClip
    已经等于 idle 名称,短路处理会阻止 idle 状态生效。
何时优先使用
Animator.playSingleAnimation
。
它是规范的写入方式:一次遍历即可暂停所有其他状态,并接受
resetCursor
参数(默认值为
true
)。它不是幂等操作,但适合在一次性事件触发点调用。自定义 helper 模式仅用于移植需要延迟 clip 注册(参见前述 PITFALL)或
noLoop + revertToIdle
语义的 SDK6 代码。

Tweens (Code-Based Animation)

Tween(基于代码的动画)

Animate entity properties smoothly over time. Create with
Tween.create(entity, { mode: Tween.Mode.Move/Rotate/Scale({start, end}), duration, easingFunction })
. Duration is in milliseconds (1000 = 1 second). An entity can only have one Tween component at a time.
Helper methods (create or replace Tween directly):
  • Tween.setMove(entity, start, end, duration, easing?)
  • Tween.setRotate(entity, start, end, duration, easing?)
  • Tween.setScale(entity, start, end, duration, easing?)
  • Tween.setMoveRotateScale(entity, { position?, rotation?, scale?, duration })
    — simultaneous
Continuous tweens (move/rotate at a constant speed). Third arg is speed (units per second, applied along the direction vector), NOT a duration. Optional final arg
duration
is a stop-after time in milliseconds —
0
or omitted = runs forever:
  • Tween.setMoveContinuous(entity, direction: Vector3, speed: number, duration?: number)
    — speed in meters/sec
  • Tween.setRotateContinuous(entity, direction: Quaternion, speed: number, duration?: number)
    —
    speed
    is degrees per second (a full 360° revolution takes
    360/speed
    seconds). The
    direction
    quaternion contributes only its rotation axis (its imaginary
    x,y,z
    part, sign preserved); its rotation angle/magnitude is ignored —
    Quaternion.fromEulerDegrees(0, 45, 0)
    and
    (0, 90, 0)
    both spin around +Y, only
    speed
    sets the rate. Negative
    speed
    reverses direction.
  • Tween.setTextureMoveContinuous(entity, direction: Vector2, speed: number, movementType?: TextureMovementType, duration?: number)
    — speed in UV units/sec
Texture scrolling (UV animation for waterfalls, conveyor belts):
  • Tween.setTextureMove(entity, start: Vector2, end: Vector2, duration, movementType?: TextureMovementType, easing?)
    —
    movementType
    is the 5th param, easing 6th
Control:
Tween.getMutable(entity).playing = false
(pause),
.currentTime = 0
(reset). Toggle a running continuous tween by reading
Tween.getMutableOrNull(entity)
and flipping
.playing
(verified in
78,-4-tweens
). Remove a tween entirely with
Tween.deleteFrom(entity)
;
Tween.has(entity)
checks presence.
Retrigger / replace at runtime: use
Tween.createOrReplace(entity, {...})
(and
TweenSequence.createOrReplace
) instead of
create
when the entity may already have a tween — e.g. a platform re-triggered while still moving. Pass
currentTime: 0
in the config to restart from the beginning in case it was mid-flight (verified in
77,-5-tweens-moving-platforms
).
Move from the CURRENT position / retarget mid-travel: pass
Transform.get(entity).position
as
start
— the engine writes the tweened entity's interpolated Transform back to the scene every frame a tween is active, so that read is the live mid-flight position.
Tween.setMove(entity, Transform.get(entity).position, target, duration, easing)
therefore moves from wherever the entity currently is, and calling it again while a previous tween is still running smoothly redirects the entity mid-travel — no snap, no teleport (the
set*
helpers use
createOrReplace
, which re-triggers even with identical values) (verified in
79,-4-tween-following-cube
).
PITFALL — omitting
start
on Move does NOT mean "current position"
: an unset
start
is treated as
(0,0,0)
— the entity teleports to the scene origin before moving. A hardcoded stale
start
likewise causes a visible teleport to that point before the motion begins. Always supply
start
, and read
Transform.get(entity).position
when you mean "from wherever it is now" (verified in
79,-4-tween-following-cube
).
PITFALL — a CONSTANTLY changing target needs
setMoveContinuous
, not repeated
setMove
: re-creating a Move tween every frame to chase a moving target (following the player, homing at a runaway entity) makes the entity visibly stutter.
Transform.get(entity).position
is what the renderer last wrote back over CRDT, so it trails the entity's real position by ~1-3 frames; the renderer applies the new
start
the instant it arrives, snapping the entity backwards on every re-aim. One correction is invisible, ~30/second is jitter — and raising the re-aim threshold only makes the stutter coarser, since the replacement frequency is the problem. Use
Tween.setMoveContinuous(entity, direction, speed)
instead: continuous modes take their start from the renderer's own live transform, so replacing them mid-motion cannot snap. Caveats: it has no destination (stop it yourself with
Tween.deleteFrom
on a distance check, or pass the optional stop-after
duration
), and you should only re-aim when the direction has changed materially, or you send a CRDT update per frame. Discrete retargeting (clicks, waypoints) is still fine with
setMove
(verified in
79,-4-tween-following-cube
, which switches between both modes live).
Tween.Mode.MoveRotateScale({...})
and
setMoveRotateScale
accept a partial transform — supply only the axes you want (
position
/
rotation
/
scale
); at least one is required. Omitted axes are left unchanged (verified against SDK source +
78,-4-tweens
).
Texture movement type (
setTextureMove
/
setTextureMoveContinuous
, and
Tween.Mode.TextureMove
): the
movementType
param is
TextureMovementType.TMT_OFFSET
(default,
=0
— scrolls the UV offset) or
TextureMovementType.TMT_TILING
(
=1
— animates the tiling). Import
TextureMovementType
from
@dcl/sdk/ecs
. The texture must use
wrapMode: TextureWrapMode.TWM_REPEAT
for continuous scrolling to tile seamlessly (verified in
0,3-texture-movement
,
78,-4-tweens
).
随着时间平滑地动画化实体属性。使用
Tween.create(entity, { mode: Tween.Mode.Move/Rotate/Scale({start, end}), duration, easingFunction })
创建。持续时间的单位是毫秒(1000 = 1 秒)。一个实体在同一时间只能拥有一个 Tween component。
Helper 方法(直接创建或替换 Tween):
  • Tween.setMove(entity, start, end, duration, easing?)
  • Tween.setRotate(entity, start, end, duration, easing?)
  • Tween.setScale(entity, start, end, duration, easing?)
  • Tween.setMoveRotateScale(entity, { position?, rotation?, scale?, duration })
    ——同时执行
连续 Tween(以恒定速度移动/旋转)。第三个参数是速度(单位/秒,沿方向向量应用),而不是持续时间。可选的最后一个参数
duration
表示经过指定时间后停止,单位为毫秒;值为
0
或省略时表示永久运行:
  • Tween.setMoveContinuous(entity, direction: Vector3, speed: number, duration?: number)
    ——速度单位为米/秒
  • Tween.setRotateContinuous(entity, direction: Quaternion, speed: number, duration?: number)
    ——
    speed
    的单位为度/秒(完整旋转 360° 需要
    360/speed
    秒)。
    direction
    四元数仅影响旋转轴(即其 x、y、z 向量部分,并保留符号);其旋转角度/幅度会被忽略——
    Quaternion.fromEulerDegrees(0, 45, 0)
    和
    (0, 90, 0)
    都会绕 +Y 轴旋转,只有
    speed
    决定旋转速率。负数
    speed
    表示反向旋转。
  • Tween.setTextureMoveContinuous(entity, direction: Vector2, speed: number, movementType?: TextureMovementType, duration?: number)
    ——速度单位为 UV 单位/秒
纹理滚动(用于瀑布、传送带的 UV 动画):
  • Tween.setTextureMove(entity, start: Vector2, end: Vector2, duration, movementType?: TextureMovementType, easing?)
    ——
    movementType
    是第五个参数,
    easing
    是第六个参数
控制:
Tween.getMutable(entity).playing = false
表示暂停,
.currentTime = 0
表示重置。通过读取
Tween.getMutableOrNull(entity)
并翻转
.playing
,可以切换正在运行的连续 Tween(已在
78,-4-tweens
中验证)。使用
Tween.deleteFrom(entity)
完全移除 Tween;使用
Tween.has(entity)
检查其是否存在。
运行时重新触发/替换:当实体可能已经拥有 Tween 时,例如平台仍在移动时再次触发,应使用
Tween.createOrReplace(entity, {...})
(
TweenSequence.createOrReplace
同理),而不是
create
。如果 Tween 正在中途运行,可在配置中传入
currentTime: 0
,使其从头重新开始(已在
77,-5-tweens-moving-platforms
中验证)。
从当前位置移动/在移动途中重新设定目标:将
Transform.get(entity).position
作为
start
传入——Tween 处于活动状态时,引擎每帧都会将实体插值后的 Transform 写回场景,因此读取到的就是移动途中的实时位置。因此,
Tween.setMove(entity, Transform.get(entity).position, target, duration, easing)
会从实体当前所在位置开始移动;在前一个 Tween 仍在运行时再次调用,可以平滑地将实体重新导向目标,不会发生跳变或瞬移(
set*
helper 使用
createOrReplace
,即使值完全相同也会重新触发)(已在
79,-4-tween-following-cube
中验证)。
PITFALL——Move 中省略
start
并不表示“当前位置”
:未设置的
start
会被视为
(0,0,0)
,实体在开始移动前会瞬间传送到场景原点。硬编码的过期
start
同样会导致实体在运动开始前明显瞬移到该位置。始终应提供
start
;如果需要“从实体当前所在位置开始”,请读取
Transform.get(entity).position
(已在
79,-4-tween-following-cube
中验证)。
PITFALL——持续变化的目标需要使用
setMoveContinuous
,而不是重复调用
setMove
:如果每帧重新创建 Move Tween 来追踪移动目标,例如跟随玩家或追踪失控实体,实体会出现明显的卡顿抖动。
Transform.get(entity).position
是渲染器最近通过 CRDT 写回的值,因此会比实体真实位置滞后约 1-3 帧;渲染器一收到新的
start
就会立即应用,导致每次重新瞄准时实体向后跳变。修正一次几乎不可见,每秒约 30 次就会产生明显抖动;提高重新瞄准阈值只会让抖动更加粗糙,因为问题出在替换频率上。此时应使用
Tween.setMoveContinuous(entity, direction, speed)
:连续模式会从渲染器自身的实时 transform 获取起点,因此在运动途中替换它也不会产生跳变。需要注意:它没有终点(可在距离检查时自行使用
Tween.deleteFrom
停止,或传入可选的停止时长
duration
),并且只有方向发生实质变化时才应重新瞄准,否则每帧都会发送 CRDT 更新。对于离散目标重定位(点击、航点),仍然可以使用
setMove
(已在
79,-4-tween-following-cube
中验证,该场景会在两种模式之间实时切换)。
Tween.Mode.MoveRotateScale({...})
和
setMoveRotateScale
接受部分 transform——只需提供需要的轴(
position
/
rotation
/
scale
),至少需要提供一个。省略的轴保持不变(已对照 SDK 源码及
78,-4-tweens
验证)。
纹理移动类型(适用于
setTextureMove
、
setTextureMoveContinuous
和
Tween.Mode.TextureMove
):
movementType
参数可以是
TextureMovementType.TMT_OFFSET
(默认值,
=0
——滚动 UV offset),也可以是
TextureMovementType.TMT_TILING
(
=1
——为平铺参数设置动画)。从
@dcl/sdk/ecs
导入
TextureMovementType
。若要实现无缝平铺的连续滚动,纹理必须使用
wrapMode: TextureWrapMode.TWM_REPEAT
(已在
0,3-texture-movement
、
78,-4-tweens
中验证)。

Tween Sequences (Chained Animations)

TweenSequence(链式动画)

Chain with
TweenSequence.create(entity, { sequence: [...tweenConfigs], loop })
. Loop modes:
TweenLoop.TL_RESTART
(
=0
, loop from start),
TweenLoop.TL_YOYO
(
=1
, reverse at each end).
使用
TweenSequence.create(entity, { sequence: [...tweenConfigs], loop })
串联动画。循环模式包括:
TweenLoop.TL_RESTART
(
=0
,从头循环)和
TweenLoop.TL_YOYO
(
=1
,到达两端时反向播放)。

PATTERN: empty sequence loops the base Tween

PATTERN:空序列会使基础 Tween 循环

TweenSequence.create(entity, { sequence: [], loop: TweenLoop.TL_YOYO })
on an entity that already has a plain
Tween
makes that base tween itself loop/yoyo — no sequence steps needed. This is the idiomatic way to make a single
setMove
/
setRotate
/
setScale
/
setTextureMove
repeat forever (verified in
77,-5-tweens-moving-platforms
,
0,3-texture-movement
). Use
TL_YOYO
for back-and-forth (platform bobbing),
TL_RESTART
for a hard reset each cycle (scrolling texture, one-directional spin).
For a multi-waypoint path, the base
Tween
is the first leg and
sequence[]
holds the remaining legs; each step's
start
/
end
must chain from the previous leg's end (verified in
77,-5-tweens-moving-platforms
platform4).
在已经拥有普通
Tween
的实体上执行
TweenSequence.create(entity, { sequence: [], loop: TweenLoop.TL_YOYO })
,会让该基础 Tween 自身循环或 yoyo 播放,不需要任何序列步骤。这是让单个
setMove
/
setRotate
/
setScale
/
setTextureMove
永久重复播放的惯用方式(已在
77,-5-tweens-moving-platforms
、
0,3-texture-movement
中验证)。往返运动使用
TL_YOYO
(例如平台上下浮动),每个周期硬重置使用
TL_RESTART
(例如滚动纹理、单向旋转)。
对于多航点路径,基础
Tween
是第一段路径,
sequence[]
包含其余路径;每一步的
start
/
end
必须与上一段的终点衔接(已在
77,-5-tweens-moving-platforms
的 platform4 中验证)。

Detecting Tween Completion

检测 Tween 完成

Use
tweenSystem.tweenCompleted(entity)
in an
engine.addSystem()
to check if a tween finished this frame.
在
engine.addSystem()
中使用
tweenSystem.tweenCompleted(entity)
,检查 Tween 是否已在当前帧完成。

Easing Functions

缓动函数

Available from
EasingFunction
:
EF_LINEAR
,
EF_EASEINQUAD
/
EASEOUTQUAD
/
EASEQUAD
,
EF_EASEINSINE
/
EASEOUTSINE
/
EASESINE
,
EF_EASEINEXPO
/
EASEOUTEXPO
/
EASEEXPO
,
EF_EASEINELASTIC
/
EASEOUTELASTIC
/
EASEELASTIC
,
EF_EASEOUTBOUNCE
/
EASEINBOUNCE
/
EASEBOUNCE
,
EF_EASEINBACK
/
EASEOUTBACK
/
EASEBACK
,
EF_EASEINCUBIC
/
EASEOUTCUBIC
/
EASECUBIC
,
EF_EASEINQUART
/
EASEOUTQUART
/
EASEQUART
,
EF_EASEINQUINT
/
EASEOUTQUINT
/
EASEQUINT
,
EF_EASEINCIRC
/
EASEOUTCIRC
/
EASECIRC
.
可从
EasingFunction
获取:
EF_LINEAR
、
EF_EASEINQUAD
/
EASEOUTQUAD
/
EASEQUAD
、
EF_EASEINSINE
/
EASEOUTSINE
/
EASESINE
、
EF_EASEINEXPO
/
EASEOUTEXPO
/
EASEEXPO
、
EF_EASEINELASTIC
/
EASEOUTELASTIC
/
EASEELASTIC
、
EF_EASEOUTBOUNCE
/
EASEINBOUNCE
/
EASEBOUNCE
、
EF_EASEINBACK
/
EASEOUTBACK
/
EASEBACK
、
EF_EASEINCUBIC
/
EASEOUTCUBIC
/
EASECUBIC
、
EF_EASEINQUART
/
EASEOUTQUART
/
EASEQUART
、
EF_EASEINQUINT
/
EASEOUTQUINT
/
EASEQUINT
、
EF_EASEINCIRC
/
EASEOUTCIRC
/
EASECIRC
。

Custom Animation Systems

自定义动画系统

For complex animations, create a system with
engine.addSystem((dt) => { ... })
and modify
Transform.getMutable(entity)
each frame. Use a custom component (e.g.
Spinner
) to mark which entities need animating.
对于复杂动画,创建一个包含
engine.addSystem((dt) => { ... })
的系统,并每帧修改
Transform.getMutable(entity)
。可以使用自定义 component(例如
Spinner
)标记需要执行动画的实体。

Troubleshooting

故障排查

ProblemCauseSolution
GLTF animation not playingWrong clip nameCheck exact clip names (case-sensitive) in a viewer
playSingleAnimation
does nothing, returns
false
Clip name not in
Animator.states
for an Animator you built in code
Add the clip to
states[]
at
Animator.create
time, or use the lazy-add wrapper above. Only applies when you authored the Animator programmatically — Inspector / asset-pack Animators are already pre-populated
Model autoplays an unexpected animation on spawnNo
Animator
component —
GltfContainer
autoplays one clip from the .glb (the same mechanism that lets clip-less Inspector scenes animate without manual registration)
Add
Animator.create
with the intended default clip set to
playing: true
to take control
Door/grave/lid spawns OPEN instead of closedThe renderer auto-plays the clip and holds its final (open) frame; the model's closed pose is frame 0 of that clipHold frame 0 with a
playing: true, speed: 0, loop: false
state — see "Resting an animated model at its FIRST frame" above
Unnecessary per-frame serialization overheadClip-switch helper calls
getMutableOrNull
every tick with identical values (custom helper or
playSingleAnimation
called from a system / per-frame input callback)
Track the last-applied clip and short-circuit when unchanged, or only call the helper on state transitions — see "Short-circuit clip-switch helpers" best practice above
Animator has no effectMissing
GltfContainer
Animator
only works on entities with a loaded GLTF model
Tween doesn't moveSame start and endVerify values differ in
Tween.Mode.Move()
Entity teleports when a Move tween starts
start
doesn't match the entity's current position — omitted
start
=
(0,0,0)
(scene origin), hardcoded/stale
start
= wherever you wrote
Pass
Transform.get(entity).position
as
start
— it's the live position even while a previous tween is mid-flight
Tween plays once then stopsNo loopAdd
TweenSequence
with
loop: TweenLoop.TL_YOYO
Animation jittersCreating Tween every frameOnly create Tween once, not inside a system
For full code examples (Animator setup, all tween types, sequences, helpers, texture scrolling), see
{baseDir}/references/animation-patterns.md
.
问题原因解决方案
GLTF 动画没有播放clip 名称错误在查看器中检查准确的 clip 名称(区分大小写)
playSingleAnimation
不执行任何操作并返回
false
对于通过代码创建的 Animator,clip 名称不在
Animator.states
中
在
Animator.create
时将 clip 添加到
states[]
,或使用上文的延迟添加 wrapper。此规则仅适用于以编程方式创建的 Animator;Inspector/资源包中的 Animator 已经预先填充
模型生成后自动播放了非预期动画没有
Animator
component——
GltfContainer
会自动播放 .glb 中的某个 clip(这也是没有 clip 的 Inspector 场景无需手动注册也能播放动画的同一机制)
添加
Animator.create
,并将目标默认 clip 集合设为
playing: true
以接管控制
门/坟墓/盖子生成时处于打开状态而非关闭状态渲染器自动播放 clip 并停留在最后一帧(打开状态);模型的关闭姿态是该 clip 的第 0 帧使用
playing: true, speed: 0, loop: false
的状态保持第 0 帧——参见上文“将动画模型停在其第一帧”
不必要的逐帧序列化开销Clip 切换 helper 在每个 tick 中以相同值调用
getMutableOrNull
(包括自定义 helper,或从 system/逐帧 input callback 调用
playSingleAnimation
)
记录最后应用的 clip,并在未变化时进行短路处理;或者仅在状态转换时调用 helper——参见上文“对逐帧调用方触发的 clip 切换 helper 进行短路处理”最佳实践
Animator 没有效果缺少
GltfContainer
Animator
仅对已加载 GLTF 模型的实体有效
Tween 没有移动
start
和
end
相同
确认
Tween.Mode.Move()
中的值存在差异
Move Tween 开始时实体发生瞬移
start
与实体当前位置不一致——省略
start
意味着
(0,0,0)
(场景原点),硬编码或过期的
start
则表示你写入的位置
将
Transform.get(entity).position
作为
start
传入——即使前一个 Tween 正在中途运行,它也会返回实时位置
Tween 播放一次后停止没有循环添加
TweenSequence
,并设置
loop: TweenLoop.TL_YOYO
动画发生抖动每帧都在创建 Tween只创建一次 Tween,不要在 system 内部创建
有关完整代码示例(Animator 设置、所有 Tween 类型、序列、helper、纹理滚动),请参见
{baseDir}/references/animation-patterns.md
。

Example scenes

示例场景

Engine-team test scenes (ground-truth API usage):
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-8-animator-tests-shark
    —
    Animator
    with two states +
    weight
    ; clicking cycles
    playSingleAnimation('swim')
    /
    ('bite')
    and demonstrates concurrent blend by also setting
    getClip('bite').playing = true
    .
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/78,-4-tweens
    — every finite mode (
    setMove
    /
    setRotate
    /
    setScale
    /
    setTextureMove
    /
    setMoveRotateScale
    ), continuous modes,
    createOrReplace
    ,
    deleteFrom
    , pause-toggle via
    getMutableOrNull().playing
    , and mixed-mode
    TweenSequence
    .
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/77,-5-tweens-moving-platforms
    —
    Tween.setMove
    + empty-sequence
    TL_YOYO
    for bobbing platforms, multi-waypoint path via
    sequence[]
    , and
    createOrReplace
    retrigger from a
    TriggerArea
    .
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/79,-4-tween-following-cube
    — a cube that chases the player, with a pad that switches live between
    setMoveContinuous
    (smooth, the default) and re-creating a
    setMove
    tween from
    Transform.get(entity).position
    on every re-aim (visibly jittery). Both modes share the same re-aim trigger and speed, so the difference is attributable to the tween mode alone. Also shows
    Billboard
    with
    BM_Y
    on floating labels.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,3-texture-movement
    —
    setTextureMove
    with
    TMT_OFFSET
    vs
    TMT_TILING
    , empty-sequence loop/yoyo,
    TWM_REPEAT
    tiling.
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/10,0-move-to-test
    — custom per-frame spin system (
    Quaternion.multiply
    +
    fromAngleAxis(dt*speed, Up())
    ) marking entities with a
    Spinner
    component. (The
    movePlayerTo
    portion belongs to restricted-actions, not tweens.)
引擎团队测试场景(API 用法的真实基准):
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-8-animator-tests-shark
    ——包含两个状态和
    weight
    的
    Animator
    ;点击时在
    playSingleAnimation('swim')
    /
    ('bite')
    之间循环,并通过同时设置
    getClip('bite').playing = true
    演示并发混合。
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/78,-4-tweens
    ——涵盖所有有限时长模式(
    setMove
    /
    setRotate
    /
    setScale
    /
    setTextureMove
    /
    setMoveRotateScale
    )、连续模式、
    createOrReplace
    、
    deleteFrom
    、通过
    getMutableOrNull().playing
    切换暂停状态,以及混合模式的
    TweenSequence
    。
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/77,-5-tweens-moving-platforms
    ——使用
    Tween.setMove
    和空序列
    TL_YOYO
    实现浮动平台,通过
    sequence[]
    构建多航点路径,以及从
    TriggerArea
    重新触发
    createOrReplace
    。
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/79,-4-tween-following-cube
    ——一个追踪玩家的立方体,通过控制台在平滑的
    setMoveContinuous
    (默认方式)与每次重新瞄准时根据
    Transform.get(entity).position
    重新创建
    setMove
    Tween(可见抖动)之间实时切换。两种模式使用相同的重新瞄准触发条件和速度,因此差异可明确归因于 Tween 模式本身。该场景还展示了在浮动标签上使用带
    BM_Y
    的
    Billboard
    。
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,3-texture-movement
    ——使用
    TMT_OFFSET
    与
    TMT_TILING
    的
    setTextureMove
    、空序列循环/yoyo,以及
    TWM_REPEAT
    平铺。
  • https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/10,0-move-to-test
    ——自定义逐帧旋转 system(
    Quaternion.multiply
    +
    fromAngleAxis(dt*speed, Up())
    ),并使用
    Spinner
    component 标记实体。(
    movePlayerTo
    部分属于 restricted-actions,而非 Tween。)