@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.
- package/contributions/blender-outliner-authoring.ts +33 -6
- package/contributions/blender-play-mixer.ts +256 -0
- package/contributions/blender-play-skin.ts +121 -189
- package/contributions/blender-runtime-skin.ts +67 -307
- package/contributions/blender-runtime.document.tsx +13 -35
- package/contributions/blender.command.ts +0 -1
- package/host/blender-runtime-host.ts +2 -27
- package/package.json +6 -6
|
@@ -1,236 +1,168 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ANIMATION IN
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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 {
|
|
24
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
33
|
+
/** The game's door to its characters' actions (`ModelPlayContext.setAction`, `blend`, `lookAt`). */
|
|
81
34
|
export interface PlayAnimation {
|
|
82
|
-
/** The
|
|
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
|
|
85
|
-
play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions):
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
/**
|
|
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
|
|
98
|
-
readonly
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
/**
|
|
114
|
-
export function
|
|
115
|
-
const warnings = [
|
|
116
|
-
const
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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 =
|
|
123
|
+
object.traverse((child) => { if (!found) found = own(child); });
|
|
170
124
|
return found;
|
|
171
125
|
};
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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) =>
|
|
143
|
+
clips: (object) => (armatureOf(object) ? actions : []),
|
|
193
144
|
play(object, clip, options = {}) {
|
|
194
|
-
const armature =
|
|
195
|
-
|
|
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
|
-
|
|
218
|
-
const armature =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
}
|