@volter/editor-blender 0.1.135 → 0.1.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,236 +1,168 @@
1
1
  /**
2
- * ANIMATION IN PLAY: the same skin and clips the Timeline plays (`blender-runtime-skin.ts`), bound
3
- * to Play's detached copy and driven by the game.
2
+ * ANIMATION IN A GAME: actions played on the skeletons of the game's copy of the model.
4
3
  *
5
- * Play runs a game on a copy of the model (`BlenderRuntimeView.detach`). Until this module the copy
6
- * was built without its skins, so every rigged character was a frozen mesh in a game while the same
7
- * character played in Movie. Nothing here is a second animation system: the rigs are bound by
8
- * `bindRig` and the clips built by `clipTracks`, the Timeline's own code, over the copy's graph.
4
+ * The copy binds its own skins (`BlenderRuntimeView.detach` keeps each skinned mesh's weights and
5
+ * the frame's armatures, and its view's `skeletons` builds and binds them exactly as the editing
6
+ * view does), so nothing here binds or reads a rig. What a game plays is Blender's: each armature
7
+ * starts on the action the file assigns it (`animation_data.action`), and a script sets another by
8
+ * name (`play.setAction`), as Blender's own assignment does. Any action in the file may be set; one
9
+ * that animates none of an armature's bones refuses, with the reason, once.
9
10
  *
10
- * WHAT A GAME PLAYS is any action that animates an armature's bones, assigned or not, so a file can
11
- * hold a clip library (Idle, Run, Attack, Die side by side) and a game chooses among them by name.
12
- * Each armature has its own `THREE.AnimationMixer`; a play crossfades from what played before.
11
+ * A CLIP IS BAKED ONCE PER ACTION, through the clip door, the first time it is wanted. The copy's
12
+ * frame is the frame it was detached from, so its revisions hold for the run. Until a bake lands
13
+ * the armature keeps what it showed before (at first, the pose Blender exported).
13
14
  *
14
- * THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by the `dt` each `update` is handed
15
- * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
16
- * animation exactly as they do for the script. Nothing advances on the page's own time.
15
+ * LAYERS, BLENDS AND LOOK-AT: each armature has a layered mixer (`blender-play-mixer.ts`), so a
16
+ * game plays actions on parts of a body, at weights, and turns a bone toward what only it knows.
17
17
  *
18
- * READ ONCE PER PLAY: the rig and every clip are read from the engine when Play starts (`rna_rig`
19
- * walks every vertex of every rigged mesh, a clip bakes every bone per frame), never per frame.
18
+ * THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by each update's `dt`
19
+ * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
20
+ * animation exactly as they do for the script.
20
21
  */
21
- import type { BlenderActionClip, BlenderRig } from '@volter/blender-engine/browser/rna';
22
+ import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
23
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
24
+ import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
22
25
  import * as THREE from 'three';
23
- import { blenderActionClip, blenderArmatureActions, blenderRig } from '../host/blender-runtime-host';
24
- import { bindRig, clipTracks, type BoundRig, type SkinPresentation } from './blender-runtime-skin';
25
-
26
- /** What Play reads from the engine: the rigs, every armature's action names, and the assigned
27
- * actions baked. Any other action is baked the first time a game sets it (`bake`). */
28
- export interface PlayClipLibrary {
29
- readonly rig: BlenderRig | null;
30
- /** Armature name -> the actions that can play on it. */
31
- readonly names: ReadonlyMap<string, readonly string[]>;
32
- /** Armature name -> the action assigned to it in the file (`animation_data.action`), which it
33
- * plays from the start of a run, as Blender would. */
34
- readonly assigned: ReadonlyMap<string, string>;
35
- /** Armature name -> its baked clips by action name (the assigned ones at first). */
36
- readonly clips: ReadonlyMap<string, ReadonlyMap<string, BlenderActionClip>>;
37
- /** Bake one more action for an armature; null, with the reason in `warnings`, when it cannot play. */
38
- bake(armature: string, action: string): Promise<BlenderActionClip | null>;
39
- readonly warnings: string[];
40
- }
26
+ import { clipTracks } from './blender-runtime-skin';
27
+ import { LayeredMixer, type LayerOptions, type LookAtOptions } from './blender-play-mixer';
41
28
 
42
- /** Read the rigs and the assigned clips a game starts with. Empty when the file has no armature. */
43
- export async function readPlayClipLibrary(): Promise<PlayClipLibrary> {
44
- const warnings: string[] = [];
45
- const [rig, listing] = await Promise.all([blenderRig(), blenderArmatureActions()]);
46
- const names = new Map<string, readonly string[]>();
47
- const assigned = new Map<string, string>();
48
- const clips = new Map<string, Map<string, BlenderActionClip>>();
49
- const bake = async (armature: string, action: string): Promise<BlenderActionClip | null> => {
50
- try {
51
- const clip = await blenderActionClip({ object: armature, action });
52
- if (clip?.reason) warnings.push(`${armature} / ${action}: ${clip.reason}`);
53
- if (clip?.tracks.length && clip.clipStart !== undefined && clip.clipEnd !== undefined) return clip;
54
- } catch (error) {
55
- warnings.push(`${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
56
- }
57
- return null;
58
- };
59
- await Promise.all((listing?.armatures ?? []).map(async ({ armature, actions, current }) => {
60
- names.set(armature, actions);
61
- if (!current) return;
62
- assigned.set(armature, current);
63
- const clip = await bake(armature, current);
64
- if (clip) clips.set(armature, new Map([[current, clip]]));
65
- }));
66
- return { rig, names, assigned, clips, bake, warnings };
67
- }
29
+ export type PlayAnimateOptions = LayerOptions;
68
30
 
69
- export interface PlayAnimateOptions {
70
- /** Repeat until something else plays (default true). */
71
- readonly loop?: boolean;
72
- /** Seconds to crossfade from what this armature played before (default 0.2). */
73
- readonly fade?: number;
74
- /** Playback rate, 1 is the clip's own speed. */
75
- readonly speed?: number;
76
- /** Start again from the first frame when this clip is already playing (default false). */
77
- readonly restart?: boolean;
78
- }
31
+ type Answer = { ok: true; armature: string } | { ok: false; why: string };
79
32
 
80
- /** The game's door to its characters' clips (`ModelPlayContext.animate`). */
33
+ /** The game's door to its characters' actions (`ModelPlayContext.setAction`, `blend`, `lookAt`). */
81
34
  export interface PlayAnimation {
82
- /** The clips an object can play: its own armature's, or the armature its rigged mesh is bound to. */
35
+ /** The actions in the file, which an object's armature may be set to play. */
83
36
  clips(object: THREE.Object3D): readonly string[];
84
- /** Play a clip on an object's armature; the reason when it cannot. */
85
- play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): { ok: true; armature: string } | { ok: false; why: string };
86
- /** Fade out whatever the object's armature plays. */
87
- stop(object: THREE.Object3D, fade?: number): void;
88
- /** The clip the object's armature plays now, or null. */
89
- playing(object: THREE.Object3D): string | null;
90
- /** Advance every mixer by one update's `dt` (the game's clock). */
37
+ /** Play one action on a layer of an object's armature; the reason when it cannot. */
38
+ play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): Answer;
39
+ /** Set a layer's actions at weights. */
40
+ blend(object: THREE.Object3D, weights: Readonly<Record<string, number>>, options?: PlayAnimateOptions): Answer;
41
+ /** Fade out what a layer plays. */
42
+ stop(object: THREE.Object3D, fade?: number, layer?: string): void;
43
+ /** The action that weighs most on a layer, as last set, or null. */
44
+ playing(object: THREE.Object3D, layer?: string): string | null;
45
+ /** Turn one of an object's bones toward a point (or object) every update, until `null`. */
46
+ lookAt(object: THREE.Object3D, bone: string, target: THREE.Vector3 | THREE.Object3D | null, options?: LookAtOptions): Answer;
91
47
  update(dt: number): void;
92
48
  readonly warnings: readonly string[];
93
49
  dispose(): void;
94
50
  }
95
51
 
96
52
  interface Armature {
97
- readonly name: string;
98
- readonly object: THREE.Object3D;
99
- readonly mixer: THREE.AnimationMixer;
100
- /** Every action that can play on it, baked or not. */
101
- readonly names: readonly string[];
102
- readonly bones: ReadonlyMap<string, THREE.Bone>;
53
+ readonly rig: ArmatureRig;
54
+ readonly mixer: LayeredMixer;
103
55
  readonly clips: Map<string, THREE.AnimationClip>;
104
- /** Bakes in flight, by action name. */
105
- readonly baking: Map<string, Promise<void>>;
106
- /** Actions whose bake gave nothing to play: asked once, never re-baked every update. */
107
- readonly failed: Set<string>;
108
- current: { readonly name: string; readonly action: THREE.AnimationAction } | null;
109
- /** The action most recently set, which a bake in flight starts when it lands. */
110
- wanted: { readonly name: string; readonly options: PlayAnimateOptions } | null;
56
+ readonly baking: Set<string>;
57
+ /** Actions with nothing this armature can play: asked once, never re-baked every update. */
58
+ readonly failed: Map<string, string>;
111
59
  }
112
60
 
113
- /** Bind the library's rigs and clips to Play's copy. */
114
- export function bindPlayAnimation(presentation: SkinPresentation, library: PlayClipLibrary): PlayAnimation {
115
- const warnings = [...library.warnings];
116
- const rigs: BoundRig[] = [];
117
- for (const rig of library.rig?.rigs ?? []) {
118
- if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
119
- if (rig.reason) warnings.push(rig.reason);
120
- continue;
121
- }
122
- const bound = bindRig(presentation, rig, library.rig!.frame, warnings);
123
- if (bound) rigs.push(bound);
124
- }
61
+ /** The copy's animation. `bake` reads one action of one armature through the clip door. */
62
+ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>): PlayAnimation {
63
+ const warnings: string[] = [];
64
+ const facts = view.animationFacts();
65
+ const actions = Object.keys(facts.actions);
125
66
  const armatures = new Map<string, Armature>();
126
- /** A mesh's presented object → the armature its skin is bound to. */
127
- const meshArmature = new Map<THREE.Object3D, string>();
128
- for (const rig of rigs) meshArmature.set(rig.mesh, rig.armature);
129
- const toClip = (action: string, clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>): THREE.AnimationClip | null => {
130
- const tracks = clipTracks(clip, bones, warnings);
131
- if (!tracks.length) return null;
132
- return new THREE.AnimationClip(action, clip.duration ?? (clip.clipEnd! - clip.clipStart!) / clip.fps, tracks);
67
+ let disposed = false;
68
+
69
+ const refuse = (armature: Armature, clip: string, why: string): void => {
70
+ armature.failed.set(clip, why);
71
+ warnings.push(`${armature.rig.armature} / ${clip}: ${why}`);
133
72
  };
134
- for (const [name, actions] of library.names) {
135
- const object = presentation.objectForBlenderName(name);
136
- if (!object || !actions.length) continue;
137
- const bones = new Map<string, THREE.Bone>();
138
- for (const rig of rigs) if (rig.armature === name) for (const bone of rig.bones) bones.set(bone.name, bone);
139
- if (!bones.size) { warnings.push(`${name} has actions but no skinned mesh is bound to it, so they would move nothing.`); continue; }
140
- const clips = new Map<string, THREE.AnimationClip>();
141
- for (const [action, clip] of library.clips.get(name) ?? []) {
142
- const built = toClip(action, clip, bones);
143
- if (built) clips.set(action, built);
144
- }
145
- armatures.set(name, { name, object, mixer: new THREE.AnimationMixer(object), names: actions, bones, clips, baking: new Map(), failed: new Set(), current: null, wanted: null });
73
+
74
+ /** The clip the mixer asks for: baked, or baked now (once) and given on a later update. */
75
+ const clipFor = (armature: Armature, clip: string): THREE.AnimationClip | null => {
76
+ const held = armature.clips.get(clip);
77
+ if (held || disposed || armature.failed.has(clip) || armature.baking.has(clip)) return held ?? null;
78
+ armature.baking.add(clip);
79
+ void bake(armature.rig.armature, clip).then((baked) => {
80
+ armature.baking.delete(clip);
81
+ const tracks = baked && baked.clipStart !== undefined && baked.clipEnd !== undefined
82
+ ? clipTracks(baked, armature.rig.bones, warnings) : [];
83
+ if (!baked || !tracks.length) {
84
+ refuse(armature, clip, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
85
+ return;
86
+ }
87
+ const duration = baked.duration ?? (baked.clipEnd! - baked.clipStart!) / baked.fps;
88
+ armature.clips.set(clip, new THREE.AnimationClip(clip, duration, tracks));
89
+ }, (error: unknown) => {
90
+ armature.baking.delete(clip);
91
+ refuse(armature, clip, `it could not be read: ${error instanceof Error ? error.message : String(error)}`);
92
+ });
93
+ return null;
94
+ };
95
+
96
+ for (const rig of view.skeletons.rigs()) {
97
+ const armature: Armature = {
98
+ rig, clips: new Map(), baking: new Set(), failed: new Map(),
99
+ mixer: new LayeredMixer(rig.object, rig.bones, (clip) => clipFor(armature, clip)),
100
+ };
101
+ armatures.set(rig.armature, armature);
146
102
  }
147
- const objectArmature = new Map<THREE.Object3D, Armature>();
148
- for (const armature of armatures.values()) objectArmature.set(armature.object, armature);
149
- // EACH ARMATURE PLAYS ITS ASSIGNED ACTION from the first update, as Blender's viewport does.
103
+
104
+ // EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
150
105
  for (const armature of armatures.values()) {
151
- const name = library.assigned.get(armature.name);
152
- const source = name ? armature.clips.get(name) : undefined;
153
- if (!name || !source) continue;
154
- const action = armature.mixer.clipAction(source);
155
- action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
156
- action.play();
157
- armature.current = { name, action };
106
+ const assigned = facts.armatures[armature.rig.armature]?.action;
107
+ if (assigned) armature.mixer.set(assigned, { fade: 0 });
158
108
  }
159
- /** The armature that animates `object`: itself, the one its skin is bound to, or the nearest
160
- * armature among its ancestors and descendants (a character's root empty holds its armature). */
109
+
110
+ /** The armature that animates `object`: its own, the one its skin is bound to, or the nearest
111
+ * armature among its ancestors and descendants (a character's root holds its armature). */
112
+ const own = (candidate: THREE.Object3D): Armature | null => {
113
+ for (const armature of armatures.values()) if (armature.rig.object === candidate) return armature;
114
+ const skin = (candidate as THREE.Mesh).geometry?.userData['blenderSkin'] as { armature: string } | undefined;
115
+ return skin ? armatures.get(skin.armature) ?? null : null;
116
+ };
161
117
  const armatureOf = (object: THREE.Object3D): Armature | null => {
162
- const own = objectArmature.get(object) ?? armatures.get(meshArmature.get(object) ?? '');
163
- if (own) return own;
164
- for (let parent = object.parent; parent; parent = parent.parent) {
165
- const found = objectArmature.get(parent);
118
+ for (let at: THREE.Object3D | null = object; at; at = at.parent) {
119
+ const found = own(at);
166
120
  if (found) return found;
167
121
  }
168
122
  let found: Armature | null = null;
169
- object.traverse((child) => { if (!found) found = objectArmature.get(child) ?? armatures.get(meshArmature.get(child) ?? '') ?? null; });
123
+ object.traverse((child) => { if (!found) found = own(child); });
170
124
  return found;
171
125
  };
172
- let disposed = false;
173
- /** Crossfade an armature to a baked action. */
174
- const start = (armature: Armature, clip: string, options: PlayAnimateOptions): void => {
175
- const source = armature.clips.get(clip)!;
176
- const fade = Math.max(0, options.fade ?? 0.2);
177
- const action = armature.mixer.clipAction(source);
178
- action.setLoop(options.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Number.POSITIVE_INFINITY);
179
- action.clampWhenFinished = options.loop === false;
180
- action.setEffectiveTimeScale(options.speed ?? 1);
181
- if (armature.current?.name === clip) {
182
- if (options.restart) action.reset().play();
183
- return;
126
+
127
+ /** The armature, after checking every action named is one it can play. */
128
+ const resolve = (object: THREE.Object3D, clips: readonly string[]): Armature | { why: string } => {
129
+ const armature = armatureOf(object);
130
+ if (!armature) return { why: `${object.name || 'this object'} has no armature` };
131
+ for (const clip of clips) {
132
+ if (!(clip in facts.actions)) return { why: `the file has no action "${clip}"` };
133
+ const failed = armature.failed.get(clip);
134
+ if (failed) return { why: failed };
184
135
  }
185
- action.reset().setEffectiveWeight(1).play();
186
- if (armature.current && fade > 0) armature.current.action.crossFadeTo(action, fade, false);
187
- else armature.current?.action.stop();
188
- armature.current = { name: clip, action };
136
+ return armature;
189
137
  };
138
+ const answer = (armature: Armature, refused: string | null): Answer =>
139
+ refused ? { ok: false, why: refused } : { ok: true, armature: armature.rig.armature };
140
+
190
141
  return {
191
142
  warnings,
192
- clips: (object) => [...(armatureOf(object)?.names ?? [])],
143
+ clips: (object) => (armatureOf(object) ? actions : []),
193
144
  play(object, clip, options = {}) {
194
- const armature = armatureOf(object);
195
- if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature with actions` };
196
- if (!armature.names.includes(clip)) return { ok: false, why: `${armature.name} has no action "${clip}"; it has ${armature.names.join(', ')}` };
197
- if (armature.failed.has(clip)) return { ok: false, why: `${armature.name}'s action "${clip}" has nothing this armature can play (see the console)` };
198
- armature.wanted = { name: clip, options };
199
- if (!armature.clips.has(clip)) {
200
- // FIRST USE: baked now, and started when it lands if it is still the one wanted.
201
- if (!armature.baking.has(clip))
202
- armature.baking.set(clip, library.bake(armature.name, clip).then((baked) => {
203
- armature.baking.delete(clip);
204
- const built = baked ? toClip(clip, baked, armature.bones) : null;
205
- if (built) armature.clips.set(clip, built);
206
- else {
207
- armature.failed.add(clip);
208
- if (armature.wanted?.name === clip) armature.wanted = null;
209
- }
210
- if (built && !disposed && armature.wanted?.name === clip) start(armature, clip, armature.wanted.options);
211
- }));
212
- return { ok: true, armature: armature.name };
213
- }
214
- start(armature, clip, options);
215
- return { ok: true, armature: armature.name };
145
+ const armature = resolve(object, [clip]);
146
+ return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.set(clip, options));
216
147
  },
217
- stop(object, fade = 0.2) {
218
- const armature = armatureOf(object);
219
- if (armature) armature.wanted = null;
220
- if (!armature?.current) return;
221
- if (fade > 0) armature.current.action.fadeOut(fade); else armature.current.action.stop();
222
- armature.current = null;
148
+ blend(object, weights, options = {}) {
149
+ const armature = resolve(object, Object.keys(weights));
150
+ return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.blend(weights, options));
151
+ },
152
+ stop(object, fade = 0.2, layer) {
153
+ armatureOf(object)?.mixer.stop({ fade, ...(layer ? { layer } : {}) });
223
154
  },
224
- // What was last set, so a script that sets the same action every update while it bakes asks once.
225
- playing: (object) => {
155
+ // What was last set, so a script setting the same action every update while it bakes asks once.
156
+ playing: (object, layer) => armatureOf(object)?.mixer.playing(layer) ?? null,
157
+ lookAt(object, bone, target, options = {}) {
226
158
  const armature = armatureOf(object);
227
- return armature?.wanted?.name ?? armature?.current?.name ?? null;
159
+ if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature` };
160
+ return answer(armature, armature.mixer.lookAt(bone, target, options));
228
161
  },
229
162
  update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
230
163
  dispose() {
231
164
  disposed = true;
232
- for (const armature of armatures.values()) armature.mixer.stopAllAction();
233
- for (const rig of rigs) rig.skeleton.dispose();
165
+ for (const armature of armatures.values()) armature.mixer.dispose();
234
166
  },
235
167
  };
236
168
  }