Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b5a0ca6
fix(animation): normalize single-root clip binding paths
luzhuang Mar 29, 2026
df4dab8
fix(animation): add per-instance speed to AnimatorStatePlayData
luzhuang Mar 30, 2026
28f5e21
fix(loader): normalize gltf wrapper and skin roots
luzhuang Apr 15, 2026
234164e
refactor(animation): per-state PlayData handle with override-aware speed
luzhuang May 9, 2026
1cc59e8
refactor(animation): clipless-state safety + move PlayData out of int…
luzhuang May 9, 2026
296e5b9
test(animation): per-state PlayData handle and per-instance speed
luzhuang May 9, 2026
fa4ce1b
test(animation): refine per-instance speed regression tests
luzhuang May 9, 2026
8825d6b
fix(loader): _findSceneRootBone walk-up termination
luzhuang May 9, 2026
f58640a
fix(entity): findByPath prefers same-name child over self-prefix
luzhuang May 9, 2026
f2cbd10
fix(e2e): update findAnimatorState callers for new return type
luzhuang May 9, 2026
f297fe8
fix(animation): forbid crossFade to active state and reset orientatio…
luzhuang May 9, 2026
cb5cfd5
fix(entity): tighten findByPath self-prefix fallback
luzhuang May 9, 2026
d53b722
fix(animation): prettier formatting for crossFade alias guard
luzhuang May 9, 2026
bcf9611
docs(animation): update findAnimatorState examples for new API
luzhuang May 9, 2026
722cc2b
fix(e2e): null-guard findAnimatorState callers
luzhuang May 9, 2026
a2d3c2e
fix(loader): _findSceneRootBone only returns wrapper when joints span…
luzhuang May 9, 2026
7fab893
fix(animation): _preparePlay clears stale crossFade slot
luzhuang May 9, 2026
33a676b
fix(animation): crossFade early-return on missing state
luzhuang May 9, 2026
fd1da52
docs(animation): mark playState as @internal
luzhuang May 9, 2026
862ddda
fix(entity): findByPath fallback no longer crashes on null parent
luzhuang May 10, 2026
7462264
fix(animation): _getAnimatorStateInfo bounds-check layerIndex
luzhuang May 10, 2026
4edcd90
fix(animation): guard remaining-time math against zero playSpeed
luzhuang May 10, 2026
affa3ee
docs(loader): document Scene-before-Skin parse-order dependency
luzhuang May 10, 2026
b1e67f2
style(e2e): camelCase local transition variables in animator-stateMac…
luzhuang May 10, 2026
4ecfa0f
test(animation): add missing @ts-ignore on _animatorLayersData[99] ac…
luzhuang May 10, 2026
aa5132c
refactor(animation): align statePlayDataMap with Record + Object.crea…
luzhuang May 10, 2026
34efeaa
docs(animation): clarify pause vs resume in per-instance speed example
luzhuang May 10, 2026
55a96bf
docs(animation): mark self-crossFade no-op as intentional policy
luzhuang May 10, 2026
86a0bf1
perf(loader): _findSceneRootBone avoids Set allocations
luzhuang May 10, 2026
b6205bd
refactor(entity): _findChildByPathDown helper for self-prefix fallback
luzhuang May 10, 2026
e230a51
test(animation): state-machine self-transition is no-op
luzhuang May 10, 2026
36e2f40
test(animation): cover _updatePlayingState playSpeed===0 guard
luzhuang May 10, 2026
4ed7145
refactor(animation): underscore-prefix internal AnimatorStatePlayData…
luzhuang May 10, 2026
500657e
refactor(entity): findByPath polish from review feedback
luzhuang May 10, 2026
e396243
docs(animation): tighten self-cross-fade no-op comment per review
luzhuang May 10, 2026
cc481e1
fix(entity): _findChildByPathDown retries same-name siblings on deep …
luzhuang May 10, 2026
85b82d8
fix(animation): rebuild PlayData when AnimatorState identity changes
luzhuang May 10, 2026
6893ac2
fix(animation): findAnimatorState honors controller update flag
luzhuang May 10, 2026
8c5c9a3
fix(animation): rebuild AnimatorStateData when state identity changes
luzhuang May 10, 2026
4caa2bf
fix(animation): detach stateData listeners on _reset to prevent accum…
luzhuang May 10, 2026
ce40c43
fix(animation): _onDestroy reuses _reset to detach stateData listeners
luzhuang May 10, 2026
15d88f5
revert(entity): remove self-prefix fallback from findByPath
luzhuang May 11, 2026
9d296b0
refactor(animation): collapse getOrCreatePlayData guard with optional…
luzhuang May 11, 2026
5859036
refactor(animation): drop clearSpeedOverride, treat speed write as final
luzhuang May 11, 2026
eec99ad
refactor(animation): tighten AnimatorStateData lifecycle invariants
luzhuang May 11, 2026
aed9e10
docs(animation): document getCurrentAnimatorState nullable return
luzhuang May 11, 2026
88ff930
docs(loader): warn against Skin-await-Scene cycle next to Scene-befor…
luzhuang May 11, 2026
6230dca
refactor(loader): drop _findSceneRootBone, resolve skin rootBone via …
luzhuang May 11, 2026
31e711e
docs(loader): update Scene-before-Skin comment after LCA unification
luzhuang May 11, 2026
82026ee
refactor(animation): rename _speedOverride to _speed after dropping o…
luzhuang May 11, 2026
35a3147
refactor(animation): extract _resetIfControllerUpdated helper
luzhuang May 11, 2026
479a8be
docs(animation): tighten AnimatorStatePlayData class doc and explain …
luzhuang May 11, 2026
4457f62
docs(loader): explain why _sceneRoots[i] is written synchronously
luzhuang May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 35 additions & 13 deletions docs/en/animation/animator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,19 @@ animator.speed = 1;
If you only want to pause a specific `AnimatorState` , you can do so by setting its speed to 0.

```typescript
const state = animator.findAnimatorState("xxx");
state.speed = 0;
const playData = animator.findAnimatorState("xxx");
if (!playData) {
// State not found in any layer
return;
}

// Pause only this Animator instance's playback of the state.
playData.speed = 0;

// To resume later, just assign the desired speed again, e.g.:
// playData.speed = 1;
// or follow whatever the asset is currently configured to:
// playData.speed = playData.state.speed;
```

### Transition to Specified Animation State
Expand All @@ -130,26 +141,37 @@ animator.crossFade("OtherStateName", 0.3);

### Get Current Playing Animation State

You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once.
You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). The return type is `AnimatorState | null` — `null` is returned when the specified layer doesn't exist or no state is currently playing on it. Once you obtain a non-null state you can set its properties, such as changing the default loop playback to play once.

```typescript
const currentState = animator.getCurrentAnimatorState(0);
// Play once
currentState.wrapMode = WrapMode.Once;
// Loop playback
currentState.wrapMode = WrapMode.Loop;
if (currentState) {
// Play once
currentState.wrapMode = WrapMode.Once;
// Loop playback
currentState.wrapMode = WrapMode.Loop;
}
```

### Get Animation State

You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the specified `AnimatorState` . After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once.
You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the playback data for a specified `AnimatorState`. The return type is `AnimatorStatePlayData | null` (`null` when no layer contains a state with that name).

The returned `AnimatorStatePlayData` exposes two distinct surfaces:

- `playData.speed` — per-Animator playback speed. **Until written**, it live-binds to `state.speed`; once you write a value the instance owns its own speed, and later changes to `state.speed` no longer flow through. To follow the asset again, simply assign `playData.state.speed` back. Reading and writing only affects this `Animator` instance and won't affect other `Animator` instances sharing the same `AnimatorController`.
- `playData.state` — the underlying shared `AnimatorState` asset. Mutating fields like `wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller.

```typescript
const state = animator.findAnimatorState("xxx");
// Play once
state.wrapMode = WrapMode.Once;
// Loop playback
state.wrapMode = WrapMode.Loop;
const playData = animator.findAnimatorState("xxx");
if (!playData) {
// State not found in any layer
return;
}

// Mutating the shared AnimatorState asset (affects all Animators using this controller).
playData.state.wrapMode = WrapMode.Once;
playData.state.wrapMode = WrapMode.Loop;
```

### Animation Culling
Expand Down
48 changes: 35 additions & 13 deletions docs/zh/animation/animator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,19 @@ animator.speed = 1;
如果你只想针对某一个 `动画状态` 进行暂停,可以通过将它的速度设置为 0 来实现。

```typescript
const state = animator.findAnimatorState("xxx");
state.speed = 0;
const playData = animator.findAnimatorState("xxx");
if (!playData) {
// 任何一个动画层都没有该状态
return;
}

// 仅暂停当前 Animator 实例的该状态播放。
playData.speed = 0;

// 想要恢复时再写一次目标速度即可,例如:
// playData.speed = 1;
// 或者跟随当前 asset 配置:
// playData.speed = playData.state.speed;
```

### 过渡指定动画状态
Expand All @@ -135,26 +146,37 @@ animator.crossFade("OtherStateName", 0.3);

### 获取当前在播放的动画状态

你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。获取之后可以设置 `动画状态` 的属性,比如将默认的循环播放改为一次。
你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。返回类型为 `AnimatorState | null`,当指定 `动画层` 不存在或该层当前没有播放任何状态时返回 `null`。获取到非空状态后可以设置其属性,比如将默认的循环播放改为一次。

```typescript
const currentState = animator.getCurrentAnimatorState(0);
// 播放一次
currentState.wrapMode = WrapMode.Once;
// 循环播放
currentState.wrapMode = WrapMode.Loop;
if (currentState) {
// 播放一次
currentState.wrapMode = WrapMode.Once;
// 循环播放
currentState.wrapMode = WrapMode.Loop;
}
```

### 获取动画状态

你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState)  方法来获取指定名称的 `动画状态` 。获取之后可以设置动画状态的属性,比如将默认的循环播放改为一次。
你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState) 方法来获取指定名称 `动画状态` 的播放数据。返回类型为 `AnimatorStatePlayData | null`(当任何一个动画层都没有该状态时返回 `null`)。

返回的 `AnimatorStatePlayData` 提供两套语义不同的访问入口:

- `playData.speed`:每个 `Animator` 实例独立的播放速度。**未写入前**实时绑定到 `state.speed`,一旦写入即由该实例自行持有,之后修改 `state.speed` 不会再传递到此实例;想再次跟随 asset 时直接写回 `playData.state.speed` 即可。读写只影响当前 `Animator` 实例,不会影响其他共享同一 `AnimatorController` 的 `Animator` 实例。
- `playData.state`:底层共享的 `AnimatorState` 资产。对 `wrapMode` 等字段的修改会改变共享的 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。

```typescript
const state = animator.findAnimatorState("xxx");
// 播放一次
state.wrapMode = WrapMode.Once;
// 循环播放
state.wrapMode = WrapMode.Loop;
const playData = animator.findAnimatorState("xxx");
if (!playData) {
// 任何一个动画层都没有该状态
return;
}

// 修改共享的 AnimatorState 资产(影响所有使用该控制器的 Animator)
playData.state.wrapMode = WrapMode.Once;
playData.state.wrapMode = WrapMode.Loop;
```

### 动画裁剪
Expand Down
6 changes: 5 additions & 1 deletion e2e/case/animator-additive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const additivePoseNames = animations.filter((clip) => clip.name.includes("pose")).map((clip) => clip.name);

additivePoseNames.forEach((name) => {
const clip = animator.findAnimatorState(name).clip;
const playData = animator.findAnimatorState(name);
if (!playData) {
throw new Error(`Animator state not found: ${name}`);
}
const clip = playData.state.clip;
const newState = animatorStateMachine.addState(name);
newState.clipStartTime = 1;
newState.clip = clip;
Expand Down
5 changes: 4 additions & 1 deletion e2e/case/animator-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const animator = defaultSceneRoot.getComponent(Animator);

const state = animator.findAnimatorState("walk");
const clip = state.clip;
if (!state) {
throw new Error("Animator state not found: walk");
}
const clip = state.state.clip;

const event0 = new AnimationEvent();
event0.functionName = "event0";
Expand Down
6 changes: 5 additions & 1 deletion e2e/case/animator-play-backwards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const { defaultSceneRoot } = gltfResource;
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
animator.findAnimatorState("walk").speed = -1;
const walkState = animator.findAnimatorState("walk");
if (!walkState) {
throw new Error("Animator state not found: walk");
}
walkState.speed = -1;
animator.play("walk");
updateForE2E(engine);

Expand Down
51 changes: 28 additions & 23 deletions e2e/case/animator-stateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,67 +57,72 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const idleState = animator.findAnimatorState("idle");
const walkState = animator.findAnimatorState("walk");
const runState = animator.findAnimatorState("run");
if (!idleState || !walkState || !runState) {
throw new Error("Required animator states not found: idle/walk/run");
}
let idleToWalkTime = 0;
let walkToRunTime = 0;
let runToWalkTime = 0;
let walkToIdleTime = 0;

// handle idle state
const toWalkTransition = new AnimatorStateTransition();
toWalkTransition.destinationState = walkState;
toWalkTransition.destinationState = walkState.state;
toWalkTransition.duration = 0.2;
toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0);
idleState.addTransition(toWalkTransition);
idleState.state.addTransition(toWalkTransition);
idleToWalkTime =
//@ts-ignore
toWalkTransition.exitTime * idleState._getDuration() + toWalkTransition.duration * walkState._getDuration();
toWalkTransition.exitTime * idleState.state._getDuration() +
//@ts-ignore
toWalkTransition.duration * walkState.state._getDuration();

const exitTransition = idleState.addExitTransition();
const exitTransition = idleState.state.addExitTransition();
exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0);
// to walk state
const toRunTransition = new AnimatorStateTransition();
toRunTransition.destinationState = runState;
toRunTransition.destinationState = runState.state;
toRunTransition.duration = 0.3;
toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5);
walkState.addTransition(toRunTransition);
walkState.state.addTransition(toRunTransition);
walkToRunTime =
//@ts-ignore
(toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() +
(toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() +
//@ts-ignore
toRunTransition.duration * runState._getDuration();
toRunTransition.duration * runState.state._getDuration();
const toIdleTransition = new AnimatorStateTransition();
toIdleTransition.destinationState = idleState;
toIdleTransition.destinationState = idleState.state;
toIdleTransition.duration = 0.3;
toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0);
walkState.addTransition(toIdleTransition);
walkState.state.addTransition(toIdleTransition);
walkToIdleTime =
//@ts-ignore
(toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() +
(toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() +
//@ts-ignore
toIdleTransition.duration * idleState._getDuration();
toIdleTransition.duration * idleState.state._getDuration();

// to run state
const RunToWalkTransition = new AnimatorStateTransition();
RunToWalkTransition.destinationState = walkState;
RunToWalkTransition.duration = 0.3;
RunToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5);
runState.addTransition(RunToWalkTransition);
const runToWalkTransition = new AnimatorStateTransition();
runToWalkTransition.destinationState = walkState.state;
runToWalkTransition.duration = 0.3;
runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5);
runState.state.addTransition(runToWalkTransition);
runToWalkTime =
//@ts-ignore
(RunToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() +
(runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() +
//@ts-ignore
RunToWalkTransition.duration * walkState._getDuration();
runToWalkTransition.duration * walkState.state._getDuration();

stateMachine.addEntryStateTransition(idleState);
stateMachine.addEntryStateTransition(idleState.state);

const anyTransition = stateMachine.addAnyStateTransition(idleState);
const anyTransition = stateMachine.addAnyStateTransition(idleState.state);
anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0);
anyTransition.duration = 0.3;
let anyToIdleTime =
// @ts-ignore
(anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() +
(anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() +
// @ts-ignore
anyTransition.duration * idleState._getDuration();
anyTransition.duration * idleState.state._getDuration();

engine.time.maximumDeltaTime = 10000;
updateForE2E(engine, (idleToWalkTime + walkToRunTime) * 1000, 1);
Expand Down
5 changes: 4 additions & 1 deletion e2e/case/animator-stateMachineScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {

const animator = defaultSceneRoot.getComponent(Animator);
const state = animator.findAnimatorState("walk");
if (!state) {
throw new Error("Animator state not found: walk");
}

state.addStateMachineScript(
state.state.addStateMachineScript(
class extends StateMachineScript {
onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {
textRenderer.text = "0";
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,14 +369,13 @@ export class Entity extends EngineObject {
/**
* Find the entity by path.
* @param path - The path of the entity eg: /entity
* @returns The component which be found
* @returns The entity that was found
*/
findByPath(path: string): Entity {
const splits = path.split("/").filter(Boolean);
if (!splits.length) {
return this;
}

return Entity._findChildByName(this, 0, splits, 0);
}

Expand Down
Loading
Loading