@volter/editor-blender 0.1.143 → 0.1.145
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.
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* The file's own NLA strips play on the game's clock as scene frames, looping the scene's range as
|
|
21
21
|
* Blender's own playback does. Every
|
|
22
22
|
* clip is baked once per action, the first time it is wanted; until the clips a pose needs are
|
|
23
|
-
* baked, the armature keeps the pose it has.
|
|
23
|
+
* baked, the armature keeps the pose it has. The clips each character STARTS on are baked before
|
|
24
|
+
* the game starts (`prepare`), and a bake that could not be read is tried again, so a slow or
|
|
25
|
+
* failed read at Play's start does not leave a character frozen for the run.
|
|
24
26
|
*
|
|
25
27
|
* THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
|
|
26
28
|
* (`@volter/play`'s `play-script.ts`), so pause, step, speed and Restart hold for
|
|
@@ -74,6 +76,22 @@ export interface PlayAnimation {
|
|
|
74
76
|
track(object: THREE.Object3D, name: string, options: PlayTrackOptions | null): Answer;
|
|
75
77
|
constraint(object: THREE.Object3D, bone: string, name: string, options: PlayConstraintOptions): Answer;
|
|
76
78
|
update(dt: number): void;
|
|
79
|
+
/**
|
|
80
|
+
* THE LOADING PHASE: bakes every clip a character starts on (its assigned action and its NLA
|
|
81
|
+
* strips' actions) before the game's first update, so no character stands frozen while Blender
|
|
82
|
+
* answers. Resolves with what could not be loaded; clips the game asks for later still bake on
|
|
83
|
+
* first use.
|
|
84
|
+
*/
|
|
85
|
+
prepare(): Promise<{ readonly failed: readonly string[] }>;
|
|
86
|
+
/** The starting clips `prepare` is still waiting on Blender for, as `armature / action`. */
|
|
87
|
+
pending(): readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* THE CLIP LIBRARY, read in the background once the game runs: every action on each armature's
|
|
90
|
+
* NLA strips, muted tracks included (a game's clip library), most-shared first, one read at a
|
|
91
|
+
* time and only while no other read is waiting, so a clip the game asks for waits behind at most
|
|
92
|
+
* one. Without it the first switch to a library clip stood the character still for a read.
|
|
93
|
+
*/
|
|
94
|
+
prefetch(): void;
|
|
77
95
|
/** What each character is playing now, bottom layer first: the animation editors' live view. */
|
|
78
96
|
live(): readonly LiveArmature[];
|
|
79
97
|
/** The frames an action keys, per bone, for one armature (empty until it was baked). */
|
|
@@ -124,7 +142,10 @@ interface Armature {
|
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
/** The copy's animation. `bake` reads one action of one armature through the clip door. */
|
|
127
|
-
|
|
145
|
+
/** Reads that outlive one Play: a document's next Play reuses them (see `shared` below). */
|
|
146
|
+
export type PlayClipCache = Map<string, Promise<BlenderActionClip | null>>;
|
|
147
|
+
|
|
148
|
+
export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>, cache: PlayClipCache = new Map()): PlayAnimation {
|
|
128
149
|
const warnings: string[] = [];
|
|
129
150
|
const facts = view.animationFacts();
|
|
130
151
|
const actions = Object.keys(facts.actions);
|
|
@@ -141,17 +162,90 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
141
162
|
armature.failed.set(action, why);
|
|
142
163
|
warnings.push(`${armature.rig.armature} / ${action}: ${why}`);
|
|
143
164
|
};
|
|
165
|
+
/** Reads that failed (Blender busy, the call refused) per clip: tried again up to this many times. */
|
|
166
|
+
const READ_ATTEMPTS = 3;
|
|
167
|
+
const attempts = new Map<string, number>();
|
|
168
|
+
const baking = new Map<string, Promise<void>>();
|
|
169
|
+
/**
|
|
170
|
+
* ONE BAKE PER ACTION AND SKELETON. A bake depends on the action, the armature's bones and the
|
|
171
|
+
* action slot Blender picks for it (`rna_action_clip`), never on the armature otherwise; each
|
|
172
|
+
* read holds the Blender worker for about 1.5 s. Read per armature, fifteen enemies placed from
|
|
173
|
+
* one piece baked the same clip fifteen times, and an action the game switched one to waited
|
|
174
|
+
* behind all of them: measured on Heck Plungers, half the characters still unposed 20 s in with
|
|
175
|
+
* Blender idle. So armatures with the same bones share a read when the action has at most one
|
|
176
|
+
* object slot (`objectSlots`, from Blender): with several, which slot plays depends on the
|
|
177
|
+
* armature, and each armature reads its own.
|
|
178
|
+
*/
|
|
179
|
+
// KEPT ACROSS PLAYS by the caller (`cache`): keyed by the action's REVISION too, so an action
|
|
180
|
+
// edited between Plays is read again and an unchanged one is not read twice.
|
|
181
|
+
const shared = cache;
|
|
182
|
+
/** A clip whose slot Blender picked for its armature among several: never another armature's answer. */
|
|
183
|
+
const ownSlot = (clip: BlenderActionClip | null): boolean => !!clip && (clip.objectSlots ?? 2) > 1;
|
|
184
|
+
const readClip = (armature: Armature, action: string): Promise<BlenderActionClip | null> => {
|
|
185
|
+
const name = armature.rig.armature;
|
|
186
|
+
const skeleton = armature.facts.bones.map((bone) => bone.name).sort().join('\u0001');
|
|
187
|
+
const key = `${facts.actions[action] ?? ''}\u0000${action}\u0000${skeleton}`;
|
|
188
|
+
const known = shared.get(key);
|
|
189
|
+
if (known) return known.then((clip) => (ownSlot(clip) && clip!.armature !== name ? bake(name, action) : clip));
|
|
190
|
+
const read = bake(name, action).then((clip) => {
|
|
191
|
+
if (ownSlot(clip)) shared.delete(key);
|
|
192
|
+
return clip;
|
|
193
|
+
}, (error: unknown) => {
|
|
194
|
+
shared.delete(key);
|
|
195
|
+
throw error;
|
|
196
|
+
});
|
|
197
|
+
shared.set(key, read);
|
|
198
|
+
return read;
|
|
199
|
+
};
|
|
144
200
|
const clipOf = (armature: Armature, action: string): PoseClip | null | undefined => {
|
|
145
201
|
if (armature.clips.has(action)) return armature.clips.get(action);
|
|
146
|
-
armature
|
|
147
|
-
void bake(armature.rig.armature, action).then((baked) => {
|
|
148
|
-
const clip = baked ? poseClip(baked) : null;
|
|
149
|
-
for (const thing of clip?.unsupported ?? []) warnings.push(`${armature.rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
|
|
150
|
-
if (clip) armature.clips.set(action, clip);
|
|
151
|
-
else fail(armature, action, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
|
|
152
|
-
}, (error: unknown) => fail(armature, action, `it could not be read: ${error instanceof Error ? error.message : String(error)}`));
|
|
202
|
+
void bakeClip(armature, action);
|
|
153
203
|
return undefined;
|
|
154
204
|
};
|
|
205
|
+
const bakeClip = (armature: Armature, action: string): Promise<void> => {
|
|
206
|
+
const key = `${armature.rig.armature}\u0000${action}`;
|
|
207
|
+
const running = baking.get(key);
|
|
208
|
+
if (running) return running;
|
|
209
|
+
if (armature.clips.has(action) && armature.clips.get(action) !== undefined) return Promise.resolve();
|
|
210
|
+
armature.clips.set(action, undefined);
|
|
211
|
+
const done = readClip(armature, action).then((baked) => {
|
|
212
|
+
// A clip that cannot be posed is that clip's failure, named like the others; it never
|
|
213
|
+
// rejects the bake, so the loading phase that awaits it always settles.
|
|
214
|
+
try {
|
|
215
|
+
const clip = baked ? poseClip(baked) : null;
|
|
216
|
+
for (const thing of clip?.unsupported ?? []) warnings.push(`${armature.rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
|
|
217
|
+
if (clip) armature.clips.set(action, clip);
|
|
218
|
+
else fail(armature, action, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
fail(armature, action, `it could not be posed: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
+
}
|
|
222
|
+
}, (error: unknown) => {
|
|
223
|
+
const tried = (attempts.get(key) ?? 0) + 1;
|
|
224
|
+
attempts.set(key, tried);
|
|
225
|
+
const why = `it could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
226
|
+
// A READ THAT FAILED is not the clip's answer: the next pose asks again, up to READ_ATTEMPTS.
|
|
227
|
+
if (tried < READ_ATTEMPTS && !disposed) armature.clips.delete(action);
|
|
228
|
+
else fail(armature, action, why);
|
|
229
|
+
}).finally(() => baking.delete(key));
|
|
230
|
+
baking.set(key, done);
|
|
231
|
+
return done;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Each armature's assigned action and the strips' actions of the NLA tracks it plays (enabled as
|
|
236
|
+
* `layersOf` reads them: NLA on, under a solo only the soloed track, else the unmuted ones): the
|
|
237
|
+
* clips it starts on. A muted track's strips are a clip library, baked when the game asks.
|
|
238
|
+
*/
|
|
239
|
+
const startingClips = (): { armature: Armature; action: string }[] => [...armatures.values()].flatMap((armature) => {
|
|
240
|
+
const names = new Set<string>();
|
|
241
|
+
if (armature.facts.action) names.add(armature.facts.action);
|
|
242
|
+
const animation = armature.facts.animation;
|
|
243
|
+
const solo = animation?.tracks.some((track) => track.solo) ?? false;
|
|
244
|
+
for (const track of animation && animation.useNla ? animation.tracks : [])
|
|
245
|
+
if (solo ? track.solo : !track.mute)
|
|
246
|
+
for (const strip of track.strips) if (strip.action) names.add(strip.action);
|
|
247
|
+
return [...names].filter((name) => name in facts.actions).map((action) => ({ armature, action }));
|
|
248
|
+
});
|
|
155
249
|
|
|
156
250
|
for (const rig of view.skeletons.rigs()) {
|
|
157
251
|
const entry = facts.armatures[rig.armature];
|
|
@@ -331,6 +425,43 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
|
|
|
331
425
|
armature.constraints.set(key, { ...armature.constraints.get(key), ...options });
|
|
332
426
|
return ok(armature);
|
|
333
427
|
},
|
|
428
|
+
async prepare() {
|
|
429
|
+
const wanted = startingClips();
|
|
430
|
+
for (let round = 0; round < READ_ATTEMPTS && !disposed; round += 1) {
|
|
431
|
+
const open = wanted.filter(({ armature, action }) => armature.clips.get(action) === undefined);
|
|
432
|
+
if (open.length === 0) break;
|
|
433
|
+
await Promise.all(open.map(({ armature, action }) => bakeClip(armature, action)));
|
|
434
|
+
}
|
|
435
|
+
const failed = wanted
|
|
436
|
+
.filter(({ armature, action }) => armature.clips.get(action) == null && armature.failed.has(action) && !/animates none/.test(armature.failed.get(action) ?? ''))
|
|
437
|
+
.map(({ armature, action }) => `${armature.rig.armature} / ${action}: ${armature.failed.get(action)}`);
|
|
438
|
+
return { failed };
|
|
439
|
+
},
|
|
440
|
+
prefetch() {
|
|
441
|
+
const users = new Map<string, Armature[]>();
|
|
442
|
+
for (const armature of armatures.values()) {
|
|
443
|
+
const names = new Set<string>();
|
|
444
|
+
if (armature.facts.action) names.add(armature.facts.action);
|
|
445
|
+
for (const track of armature.facts.animation?.tracks ?? [])
|
|
446
|
+
for (const strip of track.strips) if (strip.action) names.add(strip.action);
|
|
447
|
+
for (const action of names) if (action in facts.actions) users.set(action, [...(users.get(action) ?? []), armature]);
|
|
448
|
+
}
|
|
449
|
+
const queue = [...users].sort((a, b) => b[1].length - a[1].length)
|
|
450
|
+
.flatMap(([action, list]) => list.map((armature) => ({ armature, action })));
|
|
451
|
+
void (async () => {
|
|
452
|
+
while (!disposed) {
|
|
453
|
+
if (baking.size > 0) { await Promise.allSettled([...baking.values()]); continue; }
|
|
454
|
+
const next = queue.find(({ armature, action }) => !armature.clips.has(action));
|
|
455
|
+
if (!next) return;
|
|
456
|
+
await bakeClip(next.armature, next.action);
|
|
457
|
+
}
|
|
458
|
+
})();
|
|
459
|
+
},
|
|
460
|
+
pending() {
|
|
461
|
+
return startingClips()
|
|
462
|
+
.filter(({ armature, action }) => armature.clips.get(action) === undefined && !armature.failed.has(action))
|
|
463
|
+
.map(({ armature, action }) => `${armature.rig.armature} / ${action}`);
|
|
464
|
+
},
|
|
334
465
|
update(dt) {
|
|
335
466
|
if (disposed) return;
|
|
336
467
|
time += dt;
|
|
@@ -77,7 +77,10 @@ import {
|
|
|
77
77
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
78
78
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
79
79
|
import { blenderSkin } from './blender-runtime-skin';
|
|
80
|
-
import { playAnimation, type PlayAnimation } from './blender-play-skin';
|
|
80
|
+
import { playAnimation, type PlayAnimation, type PlayClipCache } from './blender-play-skin';
|
|
81
|
+
|
|
82
|
+
/** Clip reads per model document, kept across Plays (`PlayClipCache`). */
|
|
83
|
+
const playClipCaches = new Map<string, PlayClipCache>();
|
|
81
84
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
82
85
|
import { noteModelDocument } from '../src/play-mode';
|
|
83
86
|
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/sdk/kit/document-play-extension';
|
|
@@ -912,9 +915,69 @@ function BlenderViewportArea({
|
|
|
912
915
|
// THE COPY'S CHARACTERS ANIMATE: its view bound their skins from the copied frame, and each
|
|
913
916
|
// armature plays the action the file assigns it until the game sets another
|
|
914
917
|
// (`blender-play-skin.ts`). Nothing is read before the game starts.
|
|
915
|
-
|
|
918
|
+
// A LOADING PHASE, said on screen: the game starts only once its stage is drawn and every
|
|
919
|
+
// character's starting clips are baked (`PlayAnimation.prepare`). Started before them, a
|
|
920
|
+
// character whose clip Blender had not answered yet stood frozen in its rest pose, and a
|
|
921
|
+
// clip whose read failed stayed frozen for the whole run. That is the suspected cause of
|
|
922
|
+
// "sometimes none of the animations work" (a busy Blender was measured to delay the clips).
|
|
923
|
+
// THE WAIT IS BOUNDED AND SAID: after LOADING_SAY the overlay names the clips still
|
|
924
|
+
// awaited; at LOADING_LIMIT the game starts without them and the console names them (each
|
|
925
|
+
// still plays once Blender answers). A thrown `prepare` is named too; the game always starts.
|
|
926
|
+
const loading = window.document.createElement('div');
|
|
927
|
+
loading.dataset['testid'] = 'model-play-loading';
|
|
928
|
+
loading.textContent = 'Loading…';
|
|
929
|
+
Object.assign(loading.style, {
|
|
930
|
+
position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
931
|
+
background: 'rgba(0, 0, 0, 0.35)', color: '#e6e6e6', font: '600 14px system-ui, sans-serif', pointerEvents: 'none',
|
|
932
|
+
});
|
|
933
|
+
layers.appendChild(loading);
|
|
934
|
+
void view.prepareRendered(stage.rig().drawCamera(), 'render').then(async () => {
|
|
916
935
|
if (stopped) return;
|
|
917
|
-
|
|
936
|
+
// No session answers null: a read that failed, asked again, not a clip that animates nothing.
|
|
937
|
+
// A SETUP THAT THROWS still starts the game, without animation and said so; left
|
|
938
|
+
// outside any catch it left the loading overlay up and the script never ran.
|
|
939
|
+
let cache = playClipCaches.get(modelId);
|
|
940
|
+
if (!cache) playClipCaches.set(modelId, cache = new Map());
|
|
941
|
+
let loaded: PlayAnimation | null = null;
|
|
942
|
+
try {
|
|
943
|
+
loaded = playAnimation(view, async (armature, action) => {
|
|
944
|
+
const clip = await blenderActionClip({ object: armature, action, summary: false });
|
|
945
|
+
if (clip === null) throw new Error("Blender's session is not started");
|
|
946
|
+
return clip;
|
|
947
|
+
}, cache);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
editorHost().console.error(`The game's animation could not be set up, so the game starts without it: ${error instanceof Error ? error.message : String(error)}`, 'blender-animation');
|
|
950
|
+
}
|
|
951
|
+
animation = loaded;
|
|
952
|
+
if (!loaded) {
|
|
953
|
+
loading.remove();
|
|
954
|
+
if (stopped) return;
|
|
955
|
+
}
|
|
956
|
+
if (loaded) {
|
|
957
|
+
const anim = loaded;
|
|
958
|
+
const LOADING_SAY = 3_000, LOADING_LIMIT = 20_000;
|
|
959
|
+
const clips = (names: readonly string[]): string => `${names.length} clip${names.length === 1 ? '' : 's'}`;
|
|
960
|
+
const say = window.setInterval(() => {
|
|
961
|
+
const pending = anim.pending();
|
|
962
|
+
if (pending.length > 0) loading.textContent = `Loading… waiting on Blender for ${clips(pending)}: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ` and ${pending.length - 6} more` : ''}`;
|
|
963
|
+
}, LOADING_SAY);
|
|
964
|
+
let limit = 0;
|
|
965
|
+
const outcome = await Promise.race([
|
|
966
|
+
anim.prepare().then(({ failed }) => ({ failed, waiting: [] as readonly string[] }), (error: unknown) => ({ failed: [`the loading phase failed: ${error instanceof Error ? error.message : String(error)}`], waiting: [] as readonly string[] })),
|
|
967
|
+
new Promise<{ failed: readonly string[]; waiting: readonly string[] }>((resolve) => {
|
|
968
|
+
limit = window.setTimeout(() => resolve({ failed: [], waiting: anim.pending() }), LOADING_LIMIT);
|
|
969
|
+
}),
|
|
970
|
+
]);
|
|
971
|
+
window.clearInterval(say);
|
|
972
|
+
window.clearTimeout(limit);
|
|
973
|
+
loading.remove();
|
|
974
|
+
if (stopped) return;
|
|
975
|
+
if (outcome.failed.length > 0)
|
|
976
|
+
editorHost().console.warn(`The game started without ${clips(outcome.failed)} it starts on: ${outcome.failed.join('; ')}`, 'blender-animation');
|
|
977
|
+
if (outcome.waiting.length > 0)
|
|
978
|
+
editorHost().console.warn(`The game started after ${LOADING_LIMIT / 1000} s still waiting on Blender for ${clips(outcome.waiting)}; each plays once Blender answers: ${outcome.waiting.join(', ')}`, 'blender-animation');
|
|
979
|
+
anim.prefetch();
|
|
980
|
+
}
|
|
918
981
|
setLiveAnimation(animation);
|
|
919
982
|
stopScript = documentPlayExtension('model')?.run({
|
|
920
983
|
...(animation ? { animation } : {}),
|
|
@@ -934,6 +997,7 @@ function BlenderViewportArea({
|
|
|
934
997
|
},
|
|
935
998
|
}) ?? null;
|
|
936
999
|
}, error => {
|
|
1000
|
+
loading.remove();
|
|
937
1001
|
if (stopped) return;
|
|
938
1002
|
const failure = `The game's stage could not be prepared for a rendered draw: ${error instanceof Error ? error.message : String(error)}`;
|
|
939
1003
|
editorHost().console.error(failure, 'model-play');
|
|
@@ -1061,7 +1125,7 @@ function BlenderViewportArea({
|
|
|
1061
1125
|
let live = true;
|
|
1062
1126
|
void blenderSkin.sync(view, {
|
|
1063
1127
|
clip: (armature) => blenderActionClip({ object: armature }),
|
|
1064
|
-
bake: (armature, action) => blenderActionClip({ object: armature, action }),
|
|
1128
|
+
bake: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
|
|
1065
1129
|
}).catch((thrown: unknown) => {
|
|
1066
1130
|
if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1067
1131
|
});
|
|
@@ -676,6 +676,8 @@ export async function blenderActionClip(options?: {
|
|
|
676
676
|
bake?: boolean;
|
|
677
677
|
/** A named action baked against `object`'s armature, assigned or not. */
|
|
678
678
|
action?: string;
|
|
679
|
+
/** `false` skips the Timeline's summary row, which a reader that only plays the clip never draws. */
|
|
680
|
+
summary?: boolean;
|
|
679
681
|
}): Promise<BlenderActionClip | null> {
|
|
680
682
|
if (!blenderSessionStarted()) return null;
|
|
681
683
|
return blenderRuntime().actionClip(options);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-blender",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only AND GPL-3.0-or-later",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.145",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -72,15 +72,15 @@
|
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
75
|
-
"@volter/blender-engine": "0.1.
|
|
76
|
-
"@volter/editor-threejs": "0.5.
|
|
75
|
+
"@volter/blender-engine": "0.1.143",
|
|
76
|
+
"@volter/editor-threejs": "0.5.206",
|
|
77
77
|
"zod": "^4.3.6",
|
|
78
78
|
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@volter/live": "0.5.
|
|
82
|
-
"@volter/project": "0.5.
|
|
83
|
-
"@volter/sdk": "0.5.
|
|
81
|
+
"@volter/live": "0.5.206",
|
|
82
|
+
"@volter/project": "0.5.206",
|
|
83
|
+
"@volter/sdk": "0.5.206",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
86
86
|
"three": "^0.180.0",
|