Skip to content

Commit c325c92

Browse files
committed
fix(soba): unwrap afterNextRender in injectAnimations
BREAKING CHANGE: This is considered a breaking change because of timing logic has changed. `injectAnimations` should NOT control when the `AnimationAction` is ready or clipped. Previously, this was naively controlled by `afterNextRender` and the examples have been _unfortunately_ working. The consumers can migrate to the new behavior with the following decision making guidance: - If the object referenced in the `animations` (returned by `injectGLTF`) are **statically** available on the template (i.e: not under any control flow or structural directive), then there's no need to change anything. - If the object referenced in the `animations` are **dynamically** available on the template (i.e: under control flow or structural directive; usually an `*args`), then the `object` parameter should ONLY be resolved when this object is ready/available. Alternatively, if any other timing function can be used to ensure the animation object is ready (i.e: `afterNextRender`), then that can be used instead.
1 parent c52b925 commit c325c92

File tree

3 files changed

+83
-62
lines changed

3 files changed

+83
-62
lines changed

apps/kitchen-sink/src/app/soba/basic/experience.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import {
55
Directive,
66
ElementRef,
77
Signal,
8+
computed,
89
effect,
910
inject,
1011
input,
1112
signal,
13+
viewChild,
1214
} from '@angular/core';
1315
import { NgtArgs } from 'angular-three';
1416
import { NgtpBloom, NgtpEffectComposer, NgtpGlitch } from 'angular-three-postprocessing';
@@ -31,16 +33,23 @@ type BotGLTF = GLTF & {
3133
@Directive({ selector: '[animations]', standalone: true })
3234
export class BotAnimations {
3335
animations = input.required<NgtsAnimation>();
34-
host = inject<ElementRef<Group>>(ElementRef);
35-
animationsApi = injectAnimations(this.animations, this.host);
36+
referenceRef = input.required<ElementRef<Bone> | undefined>();
3637

3738
constructor() {
39+
const groupRef = inject<ElementRef<Group>>(ElementRef);
40+
const host = computed(() => (this.referenceRef() ? groupRef : null));
41+
42+
// NOTE: the consumer controls the timing of injectAnimations. It's not afterNextRender anymore
43+
// but when the reference is resolved which in this particular case, it is the Bone mixamorigHips
44+
// that the animations are referring to.
45+
const animationsApi = injectAnimations(this.animations, host);
3846
effect((onCleanup) => {
39-
if (this.animationsApi.ready()) {
47+
if (animationsApi.ready()) {
4048
const actionName = selectedAction();
41-
this.animationsApi.actions[actionName]?.reset().fadeIn(0.5).play();
49+
// animationsApi.actions[actionName];
50+
animationsApi.actions[actionName]?.reset().fadeIn(0.5).play();
4251
onCleanup(() => {
43-
this.animationsApi.actions[actionName]?.fadeOut(0.5);
52+
animationsApi.actions[actionName]?.fadeOut(0.5);
4453
});
4554
}
4655
});
@@ -54,9 +63,9 @@ export class BotAnimations {
5463
<ngt-group [position]="[0, -1, 0]">
5564
<ngt-grid-helper *args="[10, 20]" />
5665
@if (gltf(); as gltf) {
57-
<ngt-group [dispose]="null" [animations]="gltf">
66+
<ngt-group [dispose]="null" [animations]="gltf" [referenceRef]="boneRef()">
5867
<ngt-group [rotation]="[Math.PI / 2, 0, 0]" [scale]="0.01">
59-
<ngt-primitive *args="[gltf.nodes.mixamorigHips]" />
68+
<ngt-primitive #bone *args="[gltf.nodes.mixamorigHips]" />
6069
<ngt-skinned-mesh [geometry]="gltf.nodes.YB_Body.geometry" [skeleton]="gltf.nodes.YB_Body.skeleton">
6170
<ngt-mesh-matcap-material [matcap]="matcapBody.texture()" />
6271
</ngt-skinned-mesh>
@@ -78,6 +87,8 @@ export class Bot {
7887
gltf = injectGLTF(() => './ybot.glb') as Signal<BotGLTF | null>;
7988
matcapBody = injectMatcapTexture(() => '293534_B2BFC5_738289_8A9AA7');
8089
matcapJoints = injectMatcapTexture(() => '3A2412_A78B5F_705434_836C47');
90+
91+
boneRef = viewChild<ElementRef<Bone>>('bone');
8192
}
8293

8394
@Component({

libs/soba/misc/src/lib/animations.ts

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { ElementRef, Injector, afterNextRender, computed, isSignal, signal, untracked } from '@angular/core';
1+
import { computed, effect, ElementRef, Injector, isSignal, signal, untracked } from '@angular/core';
22
import { injectBeforeRender, resolveRef } from 'angular-three';
33
import { assertInjector } from 'ngxtension/assert-injector';
4-
import { injectAutoEffect } from 'ngxtension/auto-effect';
54
import { AnimationAction, AnimationClip, AnimationMixer, Object3D } from 'three';
65

76
type NgtsAnimationApi<T extends AnimationClip> = {
@@ -24,10 +23,11 @@ export function injectAnimations<TAnimation extends AnimationClip>(
2423
{ injector }: { injector?: Injector } = {},
2524
) {
2625
return assertInjector(injectAnimations, injector, () => {
27-
const autoEffect = injectAutoEffect();
28-
2926
const mixer = new AnimationMixer(null!);
30-
injectBeforeRender(({ delta }) => mixer.update(delta));
27+
injectBeforeRender(({ delta }) => {
28+
if (!mixer.getRoot()) return;
29+
mixer.update(delta);
30+
});
3131

3232
let cached = {} as Record<string, AnimationAction>;
3333
const actions = {} as NgtsAnimationApi<TAnimation>['actions'];
@@ -44,51 +44,51 @@ export function injectAnimations<TAnimation extends AnimationClip>(
4444

4545
const ready = signal(false);
4646

47-
afterNextRender(() => {
48-
autoEffect(() => {
49-
const obj = actualObject() as Object3D | undefined;
50-
if (!obj) return;
51-
Object.assign(mixer, { _root: obj });
52-
53-
const maybeAnimationClips = animations();
54-
if (!maybeAnimationClips) return;
55-
56-
const animationClips = Array.isArray(maybeAnimationClips)
57-
? maybeAnimationClips
58-
: maybeAnimationClips.animations;
59-
60-
for (let i = 0; i < animationClips.length; i++) {
61-
const clip = animationClips[i];
62-
63-
names.push(clip.name);
64-
clips.push(clip);
65-
66-
if (!actions[clip.name as TAnimation['name']]) {
67-
Object.defineProperty(actions, clip.name, {
68-
enumerable: true,
69-
get: () => {
70-
return cached[clip.name] || (cached[clip.name] = mixer.clipAction(clip, obj));
71-
},
72-
});
73-
}
74-
}
47+
effect((onCleanup) => {
48+
const obj = actualObject() as Object3D | undefined;
49+
if (!obj) return;
50+
Object.assign(mixer, { _root: obj });
7551

76-
untracked(() => {
77-
if (!ready()) {
78-
ready.set(true);
79-
}
80-
});
52+
const maybeAnimationClips = animations();
53+
if (!maybeAnimationClips) return;
54+
55+
const animationClips = Array.isArray(maybeAnimationClips) ? maybeAnimationClips : maybeAnimationClips.animations;
56+
57+
for (let i = 0; i < animationClips.length; i++) {
58+
const clip = animationClips[i];
59+
60+
names.push(clip.name);
61+
clips.push(clip);
62+
63+
if (!actions[clip.name as TAnimation['name']]) {
64+
Object.defineProperty(actions, clip.name, {
65+
enumerable: true,
66+
get: () => {
67+
if (!cached[clip.name]) {
68+
cached[clip.name] = mixer.clipAction(clip, obj);
69+
}
8170

82-
return () => {
83-
// clear cached
84-
cached = {};
85-
// stop all actions
86-
mixer.stopAllAction();
87-
// uncache actions
88-
Object.values(actions).forEach((action) => {
89-
mixer.uncacheAction(action as AnimationClip, obj);
71+
return cached[clip.name];
72+
},
9073
});
91-
};
74+
}
75+
}
76+
77+
untracked(() => {
78+
if (!ready()) {
79+
ready.set(true);
80+
}
81+
});
82+
83+
onCleanup(() => {
84+
// clear cached
85+
cached = {};
86+
// stop all actions
87+
mixer.stopAllAction();
88+
// uncache actions
89+
Object.values(actions).forEach((action) => {
90+
mixer.uncacheAction(action as AnimationClip, obj);
91+
});
9292
});
9393
});
9494

libs/soba/src/misc/animations.stories.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
Directive,
66
ElementRef,
77
Signal,
8+
computed,
89
effect,
910
inject,
1011
input,
12+
viewChild,
1113
} from '@angular/core';
1214
import { NgtArgs } from 'angular-three';
1315
import { injectGLTF } from 'angular-three-soba/loaders';
@@ -23,19 +25,25 @@ type BotGLTF = GLTF & {
2325
};
2426

2527
@Directive({ selector: '[animations]', standalone: true })
26-
class BotAnimations {
28+
export class BotAnimations {
2729
animations = input.required<NgtsAnimation>();
2830
animation = input('Strut');
29-
host = inject<ElementRef<Group>>(ElementRef);
30-
animationsApi = injectAnimations(this.animations, this.host);
31+
referenceRef = input.required<ElementRef<Bone> | undefined>();
3132

3233
constructor() {
34+
const groupRef = inject<ElementRef<Group>>(ElementRef);
35+
const host = computed(() => (this.referenceRef() ? groupRef : null));
36+
37+
// NOTE: the consumer controls the timing of injectAnimations. It's not afterNextRender anymore
38+
// but when the reference is resolved which in this particular case, it is the Bone mixamorigHips
39+
// that the animations are referring to.
40+
const animationsApi = injectAnimations(this.animations, host);
3341
effect((onCleanup) => {
34-
if (this.animationsApi.ready()) {
42+
if (animationsApi.ready()) {
3543
const actionName = this.animation();
36-
this.animationsApi.actions[actionName]?.reset().fadeIn(0.5).play();
44+
animationsApi.actions[actionName]?.reset().fadeIn(0.5).play();
3745
onCleanup(() => {
38-
this.animationsApi.actions[actionName]?.fadeOut(0.5);
46+
animationsApi.actions[actionName]?.fadeOut(0.5);
3947
});
4048
}
4149
});
@@ -48,9 +56,9 @@ class BotAnimations {
4856
<ngt-group [position]="[0, -1, 0]">
4957
<ngt-grid-helper *args="[10, 20]" />
5058
@if (gltf(); as gltf) {
51-
<ngt-group [dispose]="null" [animations]="gltf" [animation]="animation()">
59+
<ngt-group [dispose]="null" [animations]="gltf" [animation]="animation()" [referenceRef]="boneRef()">
5260
<ngt-group [rotation]="[Math.PI / 2, 0, 0]" [scale]="0.01">
53-
<ngt-primitive *args="[gltf.nodes.mixamorigHips]" />
61+
<ngt-primitive #bone *args="[gltf.nodes.mixamorigHips]" />
5462
<ngt-skinned-mesh [geometry]="gltf.nodes.YB_Body.geometry" [skeleton]="gltf.nodes.YB_Body.skeleton">
5563
<ngt-mesh-matcap-material [matcap]="matcapBody.texture()" />
5664
</ngt-skinned-mesh>
@@ -74,6 +82,8 @@ class DefaultAnimationsStory {
7482
gltf = injectGLTF(() => './ybot.glb') as Signal<BotGLTF | null>;
7583
matcapBody = injectMatcapTexture(() => '293534_B2BFC5_738289_8A9AA7');
7684
matcapJoints = injectMatcapTexture(() => '3A2412_A78B5F_705434_836C47');
85+
86+
boneRef = viewChild<ElementRef<Bone>>('bone');
7787
}
7888

7989
export default {

0 commit comments

Comments
 (0)