animations-tweens
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAnimations and Tweens in Decentraland
Decentraland 中的动画与 Tween
When to Use Which Animation Approach
如何选择动画方案
| Need | Approach | When |
|---|---|---|
| Play animation baked into a .glb model | | Character walks, door opens, flag waves — any animation from Blender/Maya |
| Move/rotate/scale an entity smoothly | | Sliding doors, floating platforms, growing objects — procedural A-to-B motion |
| Chain multiple animations in sequence | | Patrol paths, multi-step doors, complex choreography |
| Continuous per-frame control | | Physics-like motion, following a target, custom easing, input-driven user control |
Decision flow:
- Does the .glb already have the animation? →
Animator - Simple move/rotate/scale between two values? →
Tween - Need frame-by-frame control or custom math? → System with
dt
| 需求 | 方案 | 适用场景 |
|---|---|---|
| 播放烘焙在 .glb 模型中的动画 | | 角色行走、门打开、旗帜飘动——来自 Blender/Maya 的任意动画 |
| 平滑地移动/旋转/缩放实体 | | 滑动门、漂浮平台、物体缩放——从 A 到 B 的程序化运动 |
| 按顺序串联多个动画 | | 巡逻路径、多阶段门、复杂编排 |
| 持续逐帧控制 | | 类物理运动、跟随目标、自定义缓动、输入驱动的用户控制 |
决策流程:
- .glb 已经包含所需动画?→
Animator - 只需在两个值之间简单移动/旋转/缩放?→
Tween - 需要逐帧控制或自定义数学计算?→ 使用带 的 System
dt
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 . Play a single animation with . Stop all with . Get a clip with . Blend animations by setting (0.0-1.0) on multiple states.
Animator.create(entity, { states: [{ clip: 'idle', playing: true, loop: true, speed: 1 }] })Animator.playSingleAnimation(entity, 'walk')Animator.stopAllAnimations(entity)Animator.getClip(entity, 'Walk')weight播放嵌入 .glb 模型中的动画。支持骨骼动画、对象动画和形态键(morph target)动画。形态键适用于面部表情、口型同步或形变。
使用 进行初始化。使用 播放单个动画。使用 停止所有动画。使用 获取 clip。通过在多个状态上设置 (0.0-1.0)来混合动画。
Animator.create(entity, { states: [{ clip: 'idle', playing: true, loop: true, speed: 1 }] })Animator.playSingleAnimation(entity, 'walk')Animator.stopAllAnimations(entity)Animator.getClip(entity, 'Walk')weightPITFALL: playSingleAnimation
silently no-ops on clips not in states
(programmatic Animator only)
playSingleAnimationstatesPITFALL:playSingleAnimation
对不在 states
中的 clip 会静默无操作(仅适用于以编程方式创建的 Animator)
playSingleAnimationstatesScope: this applies only when you author the entirely in code and call programmatically. In many practical workflows the clip list is already populated for you — see "When you don't need to register clips manually" below.
AnimatorAnimator.playSingleAnimation(entity, clipName)The narrow fact (verified — lines 35-46): returns and does nothing when is not present in the entity's array. The internal helper returns a null state, and exits with . There is no console warning.
@dcl/ecs/dist-cjs/components/extended/Animator.jsplaySingleAnimation(entity, clipName)falseclipNameAnimator.statesgetClipAndAnimatorplaySingleAnimationif (!animator || !state) return falseSo when you're building the Animator yourself in TypeScript and you intend to call with a clip name, that clip must already be in :
playSingleAnimationstates[]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 and lazily add missing states:
playSingleAnimationts
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
}适用范围: 此规则仅适用于完全通过代码创建 ,并以编程方式调用 的情况。在许多实际工作流中,clip 列表已经自动填充——参见下文“无需手动注册 clip 的情况”。
AnimatorAnimator.playSingleAnimation(entity, clipName)已验证的 narrow fact( 第 35-46 行):当 不存在于实体的 数组中时, 会返回 且不执行任何操作。内部的 helper 会返回 null state,随后 通过 退出。控制台不会输出警告。
@dcl/ecs/dist-cjs/components/extended/Animator.jsclipNameAnimator.statesplaySingleAnimation(entity, clipName)falsegetClipAndAnimatorplaySingleAnimationif (!animator || !state) return false因此,当你在 TypeScript 中自行创建 Animator,并打算使用 clip 名称调用 时,该 clip 必须已经存在于 中:
playSingleAnimationstates[]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”式易用体验,可以包装 ,并按需延迟添加缺失的状态:
playSingleAnimationts
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:
- with no
GltfContainerattached. 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 noAnimatorand the renderer autoplayedAnimatorstraight from the GLB. Use this when the model should just spawn already animating and you don't need to switch clips at runtime.die - Creator Hub Inspector placement. When you drop an asset into the Inspector, the composite is written with an whose
Animatorarray already includes every clip from the GLB. Creators using the Inspector never see the registration step because the editor does it.states - Asset packs / smart items. These ship with already populated by whoever authored the pack.
states
The "you have to pre-register" issue only bites the author-in-code + path. If a model is animating without any code-side call, you're on the autoplay path described above — that's expected.
playSingleAnimationAnimator.createRelated: if a model spawns playing the wrong clip (e.g. a death pose on what should be an idling NPC), the entity has no and the renderer picked a clip you didn't intend. Add an with the desired default clip set to to take control.
GltfContainerAnimatorAnimator.createplaying: trueSeveral common workflows populate the clip list for you,因此上述规则不适用于这些情况:
- 未附加 的
Animator。 渲染器会自动播放 GLB 中的某个 clip(实际观察到的是文件中烘焙的第一个/默认 clip)。曾在万圣节场景中亲自验证:小型幽灵没有GltfContainer,渲染器仍直接从 GLB 自动播放Animator。当模型只需在生成时自动播放,且运行时无需切换 clip 时,可以使用此方式。die - Creator Hub Inspector 放置资源。 将资源放入 Inspector 后,生成的 composite 会写入一个 ,其
Animator数组已经包含 GLB 中的每一个 clip。使用 Inspector 的创作者无需看到注册步骤,因为编辑器会自动完成。states - 资源包/智能道具。 这些内容在发布时,已经由包作者预先填充 。
states
“必须预先注册”的问题只会影响**通过代码创建 + **这条路径。如果模型在没有代码侧 调用的情况下仍会播放动画,那么它使用的是上文描述的自动播放路径,这是预期行为。
playSingleAnimationAnimator.create相关说明:如果 模型生成后播放了错误的 clip,例如本应处于待机状态的 NPC 却播放死亡姿态,那么实体没有 ,且渲染器选择了一个非预期 clip。添加 ,并将目标默认 clip 集合设为 ,即可接管控制。
GltfContainerAnimatorAnimator.createplaying: trueResting 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 but frozen with and , so it holds frame 0 — the closed/rest pose — while still being a controlled, actively-playing state:
playing: truespeed: 0loop: falsetypescript
// 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 (a normal / 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.
speed: 1, playing: trueplaySingleAnimationplayClip常见的 SDK6 → SDK7 移植问题:门、坟墓或棺材 GLB 的“关闭”姿态恰好是其打开/触发 clip 的第 0 帧。在 SDK7 下,渲染器加载后会自动播放 clip 并停留在最后一帧,因此模型生成时处于打开状态,而不是关闭状态。
已验证的修复(用户已在场景中确认): 保持一个 的状态,同时使用 和 将其冻结。这样可以将模型保持在第 0 帧,也就是关闭/静止姿态,同时该状态仍受控制并处于播放中:
playing: truespeed: 0loop: falsetypescript
// 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 设置为 (执行常规的 / 调用);若要关闭,则播放反向/关闭 clip。这是实际观察到的行为和可用修复。SDK 并未记录渲染器究竟自动播放哪个 clip 的精确内部机制,因此应将其视为渲染器行为,并针对每个模型进行验证。
speed: 1, playing: trueplaySingleAnimationplayClipBEST 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 directly (, , ) and that helper is invoked from a per-frame caller (an update, an /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 semantics ([[migrate-sdk6-to-sdk7]]).
Animator.statess.playings.loops.shouldResetengine.addSysteminputSystemnoLoop + revertToIdleWhy 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 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 (). 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.
Animator.getMutableOrNull()lww-element-set-component-definition.tsNote on . Verified against : is NOT idempotent — it writes on every state in a loop, then on the target. Calling it per-frame triggers the same unnecessary serialization overhead. Prefer to call from one-shot transitions (, state-machine edges) rather than from a system tick.
Animator.playSingleAnimation@dcl/ecs/src/components/extended/Animator.tsplaySingleAnimationplaying=false, shouldReset=trueplaying=true, shouldReset=<arg>playSingleAnimationonPointerDownFix — 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 on unchanged frames, eliminating the per-frame serialization overhead. The CRDT delta is only sent on actual clip transitions.
getMutableOrNull()SDK6-port subtlety — . An SDK6-style helper that takes and schedules a (from — never the native JS ) to revert to an idle clip must:
noLoop + revertToIdle(clipName, noLoop, durationSec)timers.setTimeout@dcl/sdk/ecssetTimeout- 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 before re-calling the helper with the idle clip — otherwise the short-circuit prevents the idle from being applied if
lastClipalready equals the idle name from a prior tick.lastClip
When to prefer . It's the canonical write: pauses all other states in one pass and accepts a argument (defaults to ). 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 semantics.
Animator.playSingleAnimationresetCursortruenoLoop + revertToIdle适用范围: 此规则适用于你自行编写 clip 切换 helper、直接修改 (、、),且该 helper 由逐帧调用方调用的场景。逐帧调用方可以是 更新、每个 tick 都会触发的 /raycast callback,或任何每帧运行的代码路径。编写这种 helper 最常见的原因,是移植使用延迟 clip 注册或 语义的 SDK6 场景([[migrate-sdk6-to-sdk7]])。
Animator.statess.playings.loops.shouldResetengine.addSysteminputSystemnoLoop + revertToIdle为什么这很重要。 一个朴素的 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 调用 ,从而将实体标记为 CRDT 层中的 dirty。随后,ECS 会将组件序列化为字节,并与上次发送的 snapshot()进行比较。由于这些值逐帧完全相同,CRDT 抑制层会静默丢弃更新,因此动画不会冻结。然而,这种逐帧序列化和字节比较属于不必要的开销,应当避免。
Animator.getMutableOrNull()lww-element-set-component-definition.ts关于 。 已对照 验证: 不是幂等操作。它会先在一次循环中为每个状态写入 ,然后为目标状态写入 。每帧调用它会产生相同的额外序列化开销。应优先在一次性转换(、状态机边沿)中调用 ,而不是在 system tick 中调用。
Animator.playSingleAnimation@dcl/ecs/src/components/extended/Animator.tsplaySingleAnimationplaying=false, shouldReset=trueplaying=true, shouldReset=<arg>onPointerDownplaySingleAnimation修复——记录最后应用的 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;
}短路处理可避免在未变化的帧中调用 ,从而消除逐帧序列化开销。CRDT delta 只会在实际发生 clip 转换时发送。
getMutableOrNull()SDK6 移植细节——。 如果 SDK6 风格的 helper 接收 ,并使用 (来自 ,绝不要使用原生 JS )调度恢复到 idle clip,则必须满足以下条件:
noLoop + revertToIdle(clipName, noLoop, durationSec)timers.setTimeout@dcl/sdk/ecssetTimeout- 再次调用同一 clip 时,例如玩家持续握住光束,导致每帧都请求 ,只刷新 timer(清除并重新调度),使 clip 在按住输入期间持续重播。不要重新修改
Hit。Animator.states - 当恢复到 idle 的 timer 触发时,先清除 ,再使用 idle clip 重新调用 helper;否则,如果先前某个 tick 中的
lastClip已经等于 idle 名称,短路处理会阻止 idle 状态生效。lastClip
何时优先使用 。 它是规范的写入方式:一次遍历即可暂停所有其他状态,并接受 参数(默认值为 )。它不是幂等操作,但适合在一次性事件触发点调用。自定义 helper 模式仅用于移植需要延迟 clip 注册(参见前述 PITFALL)或 语义的 SDK6 代码。
Animator.playSingleAnimationresetCursortruenoLoop + revertToIdleTweens (Code-Based Animation)
Tween(基于代码的动画)
Animate entity properties smoothly over time. Create with . Duration is in milliseconds (1000 = 1 second). An entity can only have one Tween component at a time.
Tween.create(entity, { mode: Tween.Mode.Move/Rotate/Scale({start, end}), duration, easingFunction })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?)- — simultaneous
Tween.setMoveRotateScale(entity, { position?, rotation?, scale?, duration })
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 is a stop-after time in milliseconds — or omitted = runs forever:
duration0- — speed in meters/sec
Tween.setMoveContinuous(entity, direction: Vector3, speed: number, duration?: number) - —
Tween.setRotateContinuous(entity, direction: Quaternion, speed: number, duration?: number)is degrees per second (a full 360° revolution takesspeedseconds). The360/speedquaternion contributes only its rotation axis (its imaginarydirectionpart, sign preserved); its rotation angle/magnitude is ignored —x,y,zandQuaternion.fromEulerDegrees(0, 45, 0)both spin around +Y, only(0, 90, 0)sets the rate. Negativespeedreverses direction.speed - — speed in UV units/sec
Tween.setTextureMoveContinuous(entity, direction: Vector2, speed: number, movementType?: TextureMovementType, duration?: number)
Texture scrolling (UV animation for waterfalls, conveyor belts):
- —
Tween.setTextureMove(entity, start: Vector2, end: Vector2, duration, movementType?: TextureMovementType, easing?)is the 5th param, easing 6thmovementType
Control: (pause), (reset). Toggle a running continuous tween by reading and flipping (verified in ). Remove a tween entirely with ; checks presence.
Tween.getMutable(entity).playing = false.currentTime = 0Tween.getMutableOrNull(entity).playing78,-4-tweensTween.deleteFrom(entity)Tween.has(entity)Retrigger / replace at runtime: use (and ) instead of when the entity may already have a tween — e.g. a platform re-triggered while still moving. Pass in the config to restart from the beginning in case it was mid-flight (verified in ).
Tween.createOrReplace(entity, {...})TweenSequence.createOrReplacecreatecurrentTime: 077,-5-tweens-moving-platformsMove from the CURRENT position / retarget mid-travel: pass as — 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. 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 helpers use , which re-triggers even with identical values) (verified in ).
Transform.get(entity).positionstartTween.setMove(entity, Transform.get(entity).position, target, duration, easing)set*createOrReplace79,-4-tween-following-cubePITFALL — omitting on Move does NOT mean "current position": an unset is treated as — the entity teleports to the scene origin before moving. A hardcoded stale likewise causes a visible teleport to that point before the motion begins. Always supply , and read when you mean "from wherever it is now" (verified in ).
startstart(0,0,0)startstartTransform.get(entity).position79,-4-tween-following-cubePITFALL — a CONSTANTLY changing target needs , not repeated : 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. 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 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 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 on a distance check, or pass the optional stop-after ), 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 (verified in , which switches between both modes live).
setMoveContinuoussetMoveTransform.get(entity).positionstartTween.setMoveContinuous(entity, direction, speed)Tween.deleteFromdurationsetMove79,-4-tween-following-cubeTween.Mode.MoveRotateScale({...})setMoveRotateScalepositionrotationscale78,-4-tweensTexture movement type ( / , and ): the param is (default, — scrolls the UV offset) or ( — animates the tiling). Import from . The texture must use for continuous scrolling to tile seamlessly (verified in , ).
setTextureMovesetTextureMoveContinuousTween.Mode.TextureMovemovementTypeTextureMovementType.TMT_OFFSET=0TextureMovementType.TMT_TILING=1TextureMovementType@dcl/sdk/ecswrapMode: TextureWrapMode.TWM_REPEAT0,3-texture-movement78,-4-tweens随着时间平滑地动画化实体属性。使用 创建。持续时间的单位是毫秒(1000 = 1 秒)。一个实体在同一时间只能拥有一个 Tween component。
Tween.create(entity, { mode: Tween.Mode.Move/Rotate/Scale({start, end}), duration, easingFunction })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(以恒定速度移动/旋转)。第三个参数是速度(单位/秒,沿方向向量应用),而不是持续时间。可选的最后一个参数 表示经过指定时间后停止,单位为毫秒;值为 或省略时表示永久运行:
duration0- ——速度单位为米/秒
Tween.setMoveContinuous(entity, direction: Vector3, speed: number, duration?: number) - ——
Tween.setRotateContinuous(entity, direction: Quaternion, speed: number, duration?: number)的单位为度/秒(完整旋转 360° 需要speed秒)。360/speed四元数仅影响旋转轴(即其 x、y、z 向量部分,并保留符号);其旋转角度/幅度会被忽略——direction和Quaternion.fromEulerDegrees(0, 45, 0)都会绕 +Y 轴旋转,只有(0, 90, 0)决定旋转速率。负数speed表示反向旋转。speed - ——速度单位为 UV 单位/秒
Tween.setTextureMoveContinuous(entity, direction: Vector2, speed: number, movementType?: TextureMovementType, duration?: number)
纹理滚动(用于瀑布、传送带的 UV 动画):
- ——
Tween.setTextureMove(entity, start: Vector2, end: Vector2, duration, movementType?: TextureMovementType, easing?)是第五个参数,movementType是第六个参数easing
控制: 表示暂停, 表示重置。通过读取 并翻转 ,可以切换正在运行的连续 Tween(已在 中验证)。使用 完全移除 Tween;使用 检查其是否存在。
Tween.getMutable(entity).playing = false.currentTime = 0Tween.getMutableOrNull(entity).playing78,-4-tweensTween.deleteFrom(entity)Tween.has(entity)运行时重新触发/替换:当实体可能已经拥有 Tween 时,例如平台仍在移动时再次触发,应使用 ( 同理),而不是 。如果 Tween 正在中途运行,可在配置中传入 ,使其从头重新开始(已在 中验证)。
Tween.createOrReplace(entity, {...})TweenSequence.createOrReplacecreatecurrentTime: 077,-5-tweens-moving-platforms从当前位置移动/在移动途中重新设定目标:将 作为 传入——Tween 处于活动状态时,引擎每帧都会将实体插值后的 Transform 写回场景,因此读取到的就是移动途中的实时位置。因此, 会从实体当前所在位置开始移动;在前一个 Tween 仍在运行时再次调用,可以平滑地将实体重新导向目标,不会发生跳变或瞬移( helper 使用 ,即使值完全相同也会重新触发)(已在 中验证)。
Transform.get(entity).positionstartTween.setMove(entity, Transform.get(entity).position, target, duration, easing)set*createOrReplace79,-4-tween-following-cubePITFALL——Move 中省略 并不表示“当前位置”:未设置的 会被视为 ,实体在开始移动前会瞬间传送到场景原点。硬编码的过期 同样会导致实体在运动开始前明显瞬移到该位置。始终应提供 ;如果需要“从实体当前所在位置开始”,请读取 (已在 中验证)。
startstart(0,0,0)startstartTransform.get(entity).position79,-4-tween-following-cubePITFALL——持续变化的目标需要使用 ,而不是重复调用 :如果每帧重新创建 Move Tween 来追踪移动目标,例如跟随玩家或追踪失控实体,实体会出现明显的卡顿抖动。 是渲染器最近通过 CRDT 写回的值,因此会比实体真实位置滞后约 1-3 帧;渲染器一收到新的 就会立即应用,导致每次重新瞄准时实体向后跳变。修正一次几乎不可见,每秒约 30 次就会产生明显抖动;提高重新瞄准阈值只会让抖动更加粗糙,因为问题出在替换频率上。此时应使用 :连续模式会从渲染器自身的实时 transform 获取起点,因此在运动途中替换它也不会产生跳变。需要注意:它没有终点(可在距离检查时自行使用 停止,或传入可选的停止时长 ),并且只有方向发生实质变化时才应重新瞄准,否则每帧都会发送 CRDT 更新。对于离散目标重定位(点击、航点),仍然可以使用 (已在 中验证,该场景会在两种模式之间实时切换)。
setMoveContinuoussetMoveTransform.get(entity).positionstartTween.setMoveContinuous(entity, direction, speed)Tween.deleteFromdurationsetMove79,-4-tween-following-cubeTween.Mode.MoveRotateScale({...})setMoveRotateScalepositionrotationscale78,-4-tweens纹理移动类型(适用于 、 和 ): 参数可以是 (默认值,——滚动 UV offset),也可以是 (——为平铺参数设置动画)。从 导入 。若要实现无缝平铺的连续滚动,纹理必须使用 (已在 、 中验证)。
setTextureMovesetTextureMoveContinuousTween.Mode.TextureMovemovementTypeTextureMovementType.TMT_OFFSET=0TextureMovementType.TMT_TILING=1@dcl/sdk/ecsTextureMovementTypewrapMode: TextureWrapMode.TWM_REPEAT0,3-texture-movement78,-4-tweensTween Sequences (Chained Animations)
TweenSequence(链式动画)
Chain with . Loop modes: (, loop from start), (, reverse at each end).
TweenSequence.create(entity, { sequence: [...tweenConfigs], loop })TweenLoop.TL_RESTART=0TweenLoop.TL_YOYO=1使用 串联动画。循环模式包括:(,从头循环)和 (,到达两端时反向播放)。
TweenSequence.create(entity, { sequence: [...tweenConfigs], loop })TweenLoop.TL_RESTART=0TweenLoop.TL_YOYO=1PATTERN: empty sequence loops the base Tween
PATTERN:空序列会使基础 Tween 循环
TweenSequence.create(entity, { sequence: [], loop: TweenLoop.TL_YOYO })TweensetMovesetRotatesetScalesetTextureMove77,-5-tweens-moving-platforms0,3-texture-movementTL_YOYOTL_RESTARTFor a multi-waypoint path, the base is the first leg and holds the remaining legs; each step's / must chain from the previous leg's end (verified in platform4).
Tweensequence[]startend77,-5-tweens-moving-platforms在已经拥有普通 的实体上执行 ,会让该基础 Tween 自身循环或 yoyo 播放,不需要任何序列步骤。这是让单个 /// 永久重复播放的惯用方式(已在 、 中验证)。往返运动使用 (例如平台上下浮动),每个周期硬重置使用 (例如滚动纹理、单向旋转)。
TweenTweenSequence.create(entity, { sequence: [], loop: TweenLoop.TL_YOYO })setMovesetRotatesetScalesetTextureMove77,-5-tweens-moving-platforms0,3-texture-movementTL_YOYOTL_RESTART对于多航点路径,基础 是第一段路径, 包含其余路径;每一步的 / 必须与上一段的终点衔接(已在 的 platform4 中验证)。
Tweensequence[]startend77,-5-tweens-moving-platformsDetecting Tween Completion
检测 Tween 完成
Use in an to check if a tween finished this frame.
tweenSystem.tweenCompleted(entity)engine.addSystem()在 中使用 ,检查 Tween 是否已在当前帧完成。
engine.addSystem()tweenSystem.tweenCompleted(entity)Easing Functions
缓动函数
Available from : , //, //, //, //, //, //, //, //, //, //.
EasingFunctionEF_LINEAREF_EASEINQUADEASEOUTQUADEASEQUADEF_EASEINSINEEASEOUTSINEEASESINEEF_EASEINEXPOEASEOUTEXPOEASEEXPOEF_EASEINELASTICEASEOUTELASTICEASEELASTICEF_EASEOUTBOUNCEEASEINBOUNCEEASEBOUNCEEF_EASEINBACKEASEOUTBACKEASEBACKEF_EASEINCUBICEASEOUTCUBICEASECUBICEF_EASEINQUARTEASEOUTQUARTEASEQUARTEF_EASEINQUINTEASEOUTQUINTEASEQUINTEF_EASEINCIRCEASEOUTCIRCEASECIRC可从 获取:、//、//、//、//、//、//、//、//、//、//。
EasingFunctionEF_LINEAREF_EASEINQUADEASEOUTQUADEASEQUADEF_EASEINSINEEASEOUTSINEEASESINEEF_EASEINEXPOEASEOUTEXPOEASEEXPOEF_EASEINELASTICEASEOUTELASTICEASEELASTICEF_EASEOUTBOUNCEEASEINBOUNCEEASEBOUNCEEF_EASEINBACKEASEOUTBACKEASEBACKEF_EASEINCUBICEASEOUTCUBICEASECUBICEF_EASEINQUARTEASEOUTQUARTEASEQUARTEF_EASEINQUINTEASEOUTQUINTEASEQUINTEF_EASEINCIRCEASEOUTCIRCEASECIRCCustom Animation Systems
自定义动画系统
For complex animations, create a system with and modify each frame. Use a custom component (e.g. ) to mark which entities need animating.
engine.addSystem((dt) => { ... })Transform.getMutable(entity)Spinner对于复杂动画,创建一个包含 的系统,并每帧修改 。可以使用自定义 component(例如 )标记需要执行动画的实体。
engine.addSystem((dt) => { ... })Transform.getMutable(entity)SpinnerTroubleshooting
故障排查
| Problem | Cause | Solution |
|---|---|---|
| GLTF animation not playing | Wrong clip name | Check exact clip names (case-sensitive) in a viewer |
| Clip name not in | Add the clip to |
| Model autoplays an unexpected animation on spawn | No | Add |
| Door/grave/lid spawns OPEN instead of closed | The renderer auto-plays the clip and holds its final (open) frame; the model's closed pose is frame 0 of that clip | Hold frame 0 with a |
| Unnecessary per-frame serialization overhead | Clip-switch helper calls | 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 effect | Missing | |
| Tween doesn't move | Same start and end | Verify values differ in |
| Entity teleports when a Move tween starts | | Pass |
| Tween plays once then stops | No loop | Add |
| Animation jitters | Creating Tween every frame | Only 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 名称(区分大小写) |
| 对于通过代码创建的 Animator,clip 名称不在 | 在 |
| 模型生成后自动播放了非预期动画 | 没有 | 添加 |
| 门/坟墓/盖子生成时处于打开状态而非关闭状态 | 渲染器自动播放 clip 并停留在最后一帧(打开状态);模型的关闭姿态是该 clip 的第 0 帧 | 使用 |
| 不必要的逐帧序列化开销 | Clip 切换 helper 在每个 tick 中以相同值调用 | 记录最后应用的 clip,并在未变化时进行短路处理;或者仅在状态转换时调用 helper——参见上文“对逐帧调用方触发的 clip 切换 helper 进行短路处理”最佳实践 |
| Animator 没有效果 | 缺少 | |
| Tween 没有移动 | | 确认 |
| Move Tween 开始时实体发生瞬移 | | 将 |
| Tween 播放一次后停止 | 没有循环 | 添加 |
| 动画发生抖动 | 每帧都在创建 Tween | 只创建一次 Tween,不要在 system 内部创建 |
有关完整代码示例(Animator 设置、所有 Tween 类型、序列、helper、纹理滚动),请参见 。
{baseDir}/references/animation-patterns.mdExample scenes
示例场景
Engine-team test scenes (ground-truth API usage):
- —
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-8-animator-tests-sharkwith two states +Animator; clicking cyclesweight/playSingleAnimation('swim')and demonstrates concurrent blend by also setting('bite').getClip('bite').playing = true - — every finite mode (
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/78,-4-tweens/setMove/setRotate/setScale/setTextureMove), continuous modes,setMoveRotateScale,createOrReplace, pause-toggle viadeleteFrom, and mixed-modegetMutableOrNull().playing.TweenSequence - —
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/77,-5-tweens-moving-platforms+ empty-sequenceTween.setMovefor bobbing platforms, multi-waypoint path viaTL_YOYO, andsequence[]retrigger from acreateOrReplace.TriggerArea - — a cube that chases the player, with a pad that switches live between
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/79,-4-tween-following-cube(smooth, the default) and re-creating asetMoveContinuoustween fromsetMoveon 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 showsTransform.get(entity).positionwithBillboardon floating labels.BM_Y - —
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,3-texture-movementwithsetTextureMovevsTMT_OFFSET, empty-sequence loop/yoyo,TMT_TILINGtiling.TWM_REPEAT - — custom per-frame spin system (
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/10,0-move-to-test+Quaternion.multiply) marking entities with afromAngleAxis(dt*speed, Up())component. (TheSpinnerportion belongs to restricted-actions, not tweens.)movePlayerTo
引擎团队测试场景(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).positionTween(可见抖动)之间实时切换。两种模式使用相同的重新瞄准触发条件和速度,因此差异可明确归因于 Tween 模式本身。该场景还展示了在浮动标签上使用带setMove的BM_Y。Billboard - ——使用
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,3-texture-movement与TMT_OFFSET的TMT_TILING、空序列循环/yoyo,以及setTextureMove平铺。TWM_REPEAT - ——自定义逐帧旋转 system(
https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/10,0-move-to-test+Quaternion.multiply),并使用fromAngleAxis(dt*speed, Up())component 标记实体。(Spinner部分属于 restricted-actions,而非 Tween。)movePlayerTo