@volter/editor-blender 0.1.133 → 0.1.135
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.
|
@@ -0,0 +1,236 @@
|
|
|
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.
|
|
4
|
+
*
|
|
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.
|
|
9
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
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.
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
import type { BlenderActionClip, BlenderRig } from '@volter/blender-engine/browser/rna';
|
|
22
|
+
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
|
+
}
|
|
41
|
+
|
|
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
|
+
}
|
|
68
|
+
|
|
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
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The game's door to its characters' clips (`ModelPlayContext.animate`). */
|
|
81
|
+
export interface PlayAnimation {
|
|
82
|
+
/** The clips an object can play: its own armature's, or the armature its rigged mesh is bound to. */
|
|
83
|
+
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). */
|
|
91
|
+
update(dt: number): void;
|
|
92
|
+
readonly warnings: readonly string[];
|
|
93
|
+
dispose(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
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>;
|
|
103
|
+
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;
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
}
|
|
125
|
+
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);
|
|
133
|
+
};
|
|
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 });
|
|
146
|
+
}
|
|
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.
|
|
150
|
+
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 };
|
|
158
|
+
}
|
|
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). */
|
|
161
|
+
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);
|
|
166
|
+
if (found) return found;
|
|
167
|
+
}
|
|
168
|
+
let found: Armature | null = null;
|
|
169
|
+
object.traverse((child) => { if (!found) found = objectArmature.get(child) ?? armatures.get(meshArmature.get(child) ?? '') ?? null; });
|
|
170
|
+
return found;
|
|
171
|
+
};
|
|
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;
|
|
184
|
+
}
|
|
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 };
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
warnings,
|
|
192
|
+
clips: (object) => [...(armatureOf(object)?.names ?? [])],
|
|
193
|
+
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 };
|
|
216
|
+
},
|
|
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;
|
|
223
|
+
},
|
|
224
|
+
// What was last set, so a script that sets the same action every update while it bakes asks once.
|
|
225
|
+
playing: (object) => {
|
|
226
|
+
const armature = armatureOf(object);
|
|
227
|
+
return armature?.wanted?.name ?? armature?.current?.name ?? null;
|
|
228
|
+
},
|
|
229
|
+
update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
|
|
230
|
+
dispose() {
|
|
231
|
+
disposed = true;
|
|
232
|
+
for (const armature of armatures.values()) armature.mixer.stopAllAction();
|
|
233
|
+
for (const rig of rigs) rig.skeleton.dispose();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -84,7 +84,7 @@ export interface SkinPresentation {
|
|
|
84
84
|
root: THREE.Object3D;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
interface BoundRig {
|
|
87
|
+
export interface BoundRig {
|
|
88
88
|
/** The MESH object's Blender name. */
|
|
89
89
|
readonly object: string;
|
|
90
90
|
readonly armature: string;
|
|
@@ -103,6 +103,150 @@ function rigSignature(rig: BlenderRigBinding, frame: number): string {
|
|
|
103
103
|
return [rig.object, rig.mesh, rig.armature, rig.vertexCount, rig.bones.length, frame].join('|');
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A BLENDER ACTION'S BAKED TRACKS as three.js keyframe tracks over `bones` (by Blender's bone
|
|
108
|
+
* name), addressed by bone UUID because three's track-name grammar splits on `.` and `hand.L`
|
|
109
|
+
* is an ordinary bone name. A track for a bone this binding lacks is named in `warnings`.
|
|
110
|
+
*/
|
|
111
|
+
export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>, warnings: string[]): THREE.KeyframeTrack[] {
|
|
112
|
+
const tracks: THREE.KeyframeTrack[] = [];
|
|
113
|
+
const missing = new Set<string>();
|
|
114
|
+
for (const track of clip.tracks) {
|
|
115
|
+
const bone = bones.get(track.bone);
|
|
116
|
+
if (!bone) {
|
|
117
|
+
missing.add(track.bone);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const times = float32Of(track.timeBase64);
|
|
121
|
+
const values = float32Of(track.valueBase64);
|
|
122
|
+
const path = `${bone.uuid}.${track.property}`;
|
|
123
|
+
tracks.push(
|
|
124
|
+
track.property === 'quaternion'
|
|
125
|
+
? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
|
|
126
|
+
: new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (missing.size) warnings.push(`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`);
|
|
130
|
+
return tracks;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* BIND ONE RIGGED MESH of a presentation: its drawn mesh becomes a `THREE.SkinnedMesh` over a
|
|
135
|
+
* `Skeleton` built from Blender's bones, in the pose the columns were exported at (so the bind
|
|
136
|
+
* is an identity there). Shared by the Timeline's director and Play's copy (`blender-play-skin.ts`).
|
|
137
|
+
*/
|
|
138
|
+
export function bindRig(
|
|
139
|
+
presentation: SkinPresentation,
|
|
140
|
+
rig: BlenderRigBinding,
|
|
141
|
+
frame: number,
|
|
142
|
+
warnings: string[],
|
|
143
|
+
): BoundRig | null {
|
|
144
|
+
const meshObject = presentation.objectForBlenderName(rig.object ?? '');
|
|
145
|
+
const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
|
|
146
|
+
if (!meshObject || !armatureObject) return null;
|
|
147
|
+
const source = meshObject as THREE.Mesh;
|
|
148
|
+
const geometry = source.geometry;
|
|
149
|
+
if (!geometry) return null;
|
|
150
|
+
const blenderVertex = geometry.getAttribute('blenderVertex');
|
|
151
|
+
if (!blenderVertex) {
|
|
152
|
+
warnings.push(
|
|
153
|
+
`"${rig.object}" cannot be skinned: its presented geometry carries no \`blenderVertex\` attribute, so a per-Blender-vertex weight cannot be expanded onto its drawn vertices (presented as ${(meshObject as THREE.Object3D).type} with ${Object.keys(geometry.attributes).join(', ') || 'no attributes'}).`,
|
|
154
|
+
);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (rig.vertexCount === 0) return null;
|
|
158
|
+
// THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
|
|
159
|
+
// ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
|
|
160
|
+
// where the weights live); a generative modifier — Subdivision, Mirror,
|
|
161
|
+
// Array — makes the EVALUATED mesh the export door ships a different
|
|
162
|
+
// vertex set, and a skin bound across that mismatch would weight the wrong
|
|
163
|
+
// vertices. Named, never silently drawn.
|
|
164
|
+
let highest = 0;
|
|
165
|
+
for (let i = 0; i < blenderVertex.count; i++)
|
|
166
|
+
highest = Math.max(highest, blenderVertex.getX(i));
|
|
167
|
+
if (highest >= rig.vertexCount) {
|
|
168
|
+
warnings.push(
|
|
169
|
+
`"${rig.object}" is not skinned here: its presented geometry references Blender vertex ${highest} while the mesh declares ${rig.vertexCount}, which is a generative modifier (Subdivision, Mirror, Array…) between the two. Blender's own viewport shows the evaluated result; this presenter shows the exported columns unskinned.`,
|
|
170
|
+
);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
|
|
174
|
+
const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
|
|
175
|
+
const drawn = blenderVertex.count;
|
|
176
|
+
const indices = new Uint16Array(drawn * 4);
|
|
177
|
+
const weights = new Float32Array(drawn * 4);
|
|
178
|
+
for (let i = 0; i < drawn; i++) {
|
|
179
|
+
const vertex = blenderVertex.getX(i);
|
|
180
|
+
for (let k = 0; k < 4; k++) {
|
|
181
|
+
indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
|
|
182
|
+
weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
|
|
186
|
+
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
|
|
187
|
+
|
|
188
|
+
const boneRoot = new THREE.Group();
|
|
189
|
+
boneRoot.name = `${rig.armature}:bones`;
|
|
190
|
+
const bones: THREE.Bone[] = [];
|
|
191
|
+
const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
|
|
192
|
+
const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
|
|
193
|
+
rig.bones.forEach((declared, index) => {
|
|
194
|
+
const bone = new THREE.Bone();
|
|
195
|
+
// THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
|
|
196
|
+
// arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
|
|
197
|
+
// therefore addresses bones by UUID rather than by name — three's
|
|
198
|
+
// `PropertyBinding` track-name grammar splits on `.`, and Blender's
|
|
199
|
+
// `hand.L` is an ordinary bone name.
|
|
200
|
+
bone.name = declared.name;
|
|
201
|
+
bones.push(bone);
|
|
202
|
+
const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
|
|
203
|
+
const local =
|
|
204
|
+
parentIndex === undefined
|
|
205
|
+
? armatureSpace[index]!.clone()
|
|
206
|
+
: armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
|
|
207
|
+
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
208
|
+
(parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
|
|
209
|
+
});
|
|
210
|
+
armatureObject.add(boneRoot);
|
|
211
|
+
|
|
212
|
+
const skinned = new THREE.SkinnedMesh(geometry, source.material);
|
|
213
|
+
skinned.name = source.name;
|
|
214
|
+
skinned.matrixAutoUpdate = false;
|
|
215
|
+
skinned.matrix.copy(source.matrix);
|
|
216
|
+
skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
|
|
217
|
+
skinned.userData = source.userData;
|
|
218
|
+
// A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
|
|
219
|
+
// bounding sphere from the bind pose, so a raised arm at frame 24 is
|
|
220
|
+
// culled while its bind-pose sphere is off screen — a picture that
|
|
221
|
+
// vanishes mid-scrub with no error anywhere.
|
|
222
|
+
skinned.frustumCulled = false;
|
|
223
|
+
presentation.replacePresentedObject(source, skinned);
|
|
224
|
+
// THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
|
|
225
|
+
// WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
|
|
226
|
+
// at this instant and `bindMatrix` is the mesh's.
|
|
227
|
+
presentation.root.updateMatrixWorld(true);
|
|
228
|
+
const skeleton = new THREE.Skeleton(bones);
|
|
229
|
+
skinned.bind(skeleton, skinned.matrixWorld.clone());
|
|
230
|
+
if (rig.constrainedBones?.length)
|
|
231
|
+
warnings.push(
|
|
232
|
+
`Bone constraints on ${rig.constrainedBones.join(', ')}: the clip is derived from the F-Curves alone, so those bones play their channels rather than Blender's solved pose.`,
|
|
233
|
+
);
|
|
234
|
+
if (rig.unmappedGroups?.length)
|
|
235
|
+
warnings.push(
|
|
236
|
+
`${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
|
|
237
|
+
);
|
|
238
|
+
return {
|
|
239
|
+
object: rig.object ?? '',
|
|
240
|
+
armature: rig.armature ?? '',
|
|
241
|
+
mesh: skinned,
|
|
242
|
+
boneRoot,
|
|
243
|
+
bones,
|
|
244
|
+
skeleton,
|
|
245
|
+
frame,
|
|
246
|
+
signature: rigSignature(rig, frame),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
106
250
|
/**
|
|
107
251
|
* THE ONE DIRECTOR, module-scoped for the reason `BlenderRuntimeView` itself
|
|
108
252
|
* is (`blender-runtime.document.tsx`): the Python session outlives workspace
|
|
@@ -112,6 +256,17 @@ function rigSignature(rig: BlenderRigBinding, frame: number): string {
|
|
|
112
256
|
*/
|
|
113
257
|
export class BlenderSkinDirector {
|
|
114
258
|
#rigs = new Map<string, BoundRig>();
|
|
259
|
+
/** Rigs that could not be bound, by mesh name, at the signature tried: not retried until the
|
|
260
|
+
* rig changes (a generative modifier, no vertex map), so a settle does not re-read and re-warn. */
|
|
261
|
+
#unbindable = new Map<string, string>();
|
|
262
|
+
|
|
263
|
+
/** What could not be bound, keyed by the rig AND what is presented for its mesh: the fix for a
|
|
264
|
+
* refusal (removing a generative modifier, showing a hidden object) changes the presentation,
|
|
265
|
+
* not the rig, and must be retried. */
|
|
266
|
+
static #tried(presentation: SkinPresentation, mesh: string, signature: string): string {
|
|
267
|
+
const object = presentation.objectForBlenderName(mesh) as THREE.Mesh | null;
|
|
268
|
+
return `${signature}|${object?.uuid ?? 'absent'}|${object?.geometry?.uuid ?? 'none'}`;
|
|
269
|
+
}
|
|
115
270
|
#mixer: THREE.AnimationMixer | null = null;
|
|
116
271
|
#action: THREE.AnimationAction | null = null;
|
|
117
272
|
#clip: BlenderActionClip | null = null;
|
|
@@ -235,6 +390,25 @@ export class BlenderSkinDirector {
|
|
|
235
390
|
}
|
|
236
391
|
|
|
237
392
|
/** Blender's scene clock exists even when the scene has no action. */
|
|
393
|
+
/** Whether a bind would change anything: nothing is bound yet, or a bound mesh is no longer the
|
|
394
|
+
* object presented for its name (the presenter replaced it with a new draw). Lets the model
|
|
395
|
+
* skip the rig read on the edits that touch no rig. */
|
|
396
|
+
stale(presentation: SkinPresentation): boolean {
|
|
397
|
+
// An unbindable rig whose presentation changed since it was tried is worth trying again.
|
|
398
|
+
for (const [mesh, tried] of this.#unbindable) {
|
|
399
|
+
const signature = tried.split('|').slice(0, -2).join('|');
|
|
400
|
+
if (BlenderSkinDirector.#tried(presentation, mesh, signature) !== tried) return true;
|
|
401
|
+
}
|
|
402
|
+
if (this.#rigs.size === 0) return this.#unbindable.size === 0;
|
|
403
|
+
for (const rig of this.#rigs.values()) {
|
|
404
|
+
if (presentation.objectForBlenderName(rig.object) !== rig.mesh) return true;
|
|
405
|
+
// A redraw that kept the object but replaced its geometry (a weight or mesh edit with the same
|
|
406
|
+
// vertex count) leaves the skinned mesh without its skin attributes.
|
|
407
|
+
if (!rig.mesh.geometry.getAttribute('skinIndex')) return true;
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
238
412
|
get playable(): boolean {
|
|
239
413
|
return this.#clip !== null && this.#transport !== null;
|
|
240
414
|
}
|
|
@@ -270,10 +444,38 @@ export class BlenderSkinDirector {
|
|
|
270
444
|
}
|
|
271
445
|
wanted.add(rig.object);
|
|
272
446
|
const signature = rigSignature(rig, answer.frame);
|
|
273
|
-
|
|
447
|
+
const held = this.#rigs.get(rig.object);
|
|
448
|
+
// Unchanged only while what it bound is still what is presented, skinned: a redraw that
|
|
449
|
+
// replaced the object or its geometry (same vertex count, same signature) is bound again.
|
|
450
|
+
if (held?.signature === signature && presentation.objectForBlenderName(rig.object) === held.mesh
|
|
451
|
+
&& held.mesh.geometry.getAttribute('skinIndex')) continue;
|
|
452
|
+
const tried = BlenderSkinDirector.#tried(presentation, rig.object, signature);
|
|
453
|
+
if (this.#unbindable.get(rig.object) === tried) continue;
|
|
274
454
|
const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
|
|
455
|
+
if (!bound) {
|
|
456
|
+
this.#unbindable.set(rig.object, tried);
|
|
457
|
+
if (held) {
|
|
458
|
+
held.skeleton.dispose();
|
|
459
|
+
held.boneRoot.removeFromParent();
|
|
460
|
+
// A skinned mesh still presented with no skeleton would hold its last pose; it draws as
|
|
461
|
+
// the plain mesh it is again.
|
|
462
|
+
if (presentation.objectForBlenderName(rig.object) === held.mesh) {
|
|
463
|
+
const plain = new THREE.Mesh(held.mesh.geometry, held.mesh.material);
|
|
464
|
+
plain.name = held.mesh.name;
|
|
465
|
+
plain.matrixAutoUpdate = false;
|
|
466
|
+
plain.matrix.copy(held.mesh.matrix);
|
|
467
|
+
plain.matrix.decompose(plain.position, plain.quaternion, plain.scale);
|
|
468
|
+
plain.userData = held.mesh.userData;
|
|
469
|
+
presentation.replacePresentedObject(held.mesh, plain);
|
|
470
|
+
}
|
|
471
|
+
this.#rigs.delete(rig.object);
|
|
472
|
+
changed = true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
275
475
|
if (bound) {
|
|
276
|
-
this.#
|
|
476
|
+
this.#unbindable.delete(rig.object);
|
|
477
|
+
held?.skeleton.dispose();
|
|
478
|
+
held?.boneRoot.removeFromParent();
|
|
277
479
|
this.#rigs.set(rig.object, bound);
|
|
278
480
|
changed = true;
|
|
279
481
|
}
|
|
@@ -318,116 +520,8 @@ export class BlenderSkinDirector {
|
|
|
318
520
|
this.#publish();
|
|
319
521
|
}
|
|
320
522
|
|
|
321
|
-
#bindOne(
|
|
322
|
-
presentation
|
|
323
|
-
rig: BlenderRigBinding,
|
|
324
|
-
frame: number,
|
|
325
|
-
warnings: string[],
|
|
326
|
-
): BoundRig | null {
|
|
327
|
-
const meshObject = presentation.objectForBlenderName(rig.object ?? '');
|
|
328
|
-
const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
|
|
329
|
-
if (!meshObject || !armatureObject) return null;
|
|
330
|
-
const source = meshObject as THREE.Mesh;
|
|
331
|
-
const geometry = source.geometry;
|
|
332
|
-
if (!geometry) return null;
|
|
333
|
-
const blenderVertex = geometry.getAttribute('blenderVertex');
|
|
334
|
-
if (!blenderVertex) {
|
|
335
|
-
warnings.push(
|
|
336
|
-
`"${rig.object}" cannot be skinned: its presented geometry carries no \`blenderVertex\` attribute, so a per-Blender-vertex weight cannot be expanded onto its drawn vertices.`,
|
|
337
|
-
);
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
if (rig.vertexCount === 0) return null;
|
|
341
|
-
// THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
|
|
342
|
-
// ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
|
|
343
|
-
// where the weights live); a generative modifier — Subdivision, Mirror,
|
|
344
|
-
// Array — makes the EVALUATED mesh the export door ships a different
|
|
345
|
-
// vertex set, and a skin bound across that mismatch would weight the wrong
|
|
346
|
-
// vertices. Named, never silently drawn.
|
|
347
|
-
let highest = 0;
|
|
348
|
-
for (let i = 0; i < blenderVertex.count; i++)
|
|
349
|
-
highest = Math.max(highest, blenderVertex.getX(i));
|
|
350
|
-
if (highest >= rig.vertexCount) {
|
|
351
|
-
warnings.push(
|
|
352
|
-
`"${rig.object}" is not skinned here: its presented geometry references Blender vertex ${highest} while the mesh declares ${rig.vertexCount}, which is a generative modifier (Subdivision, Mirror, Array…) between the two. Blender's own viewport shows the evaluated result; this presenter shows the exported columns unskinned.`,
|
|
353
|
-
);
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
|
|
357
|
-
const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
|
|
358
|
-
const drawn = blenderVertex.count;
|
|
359
|
-
const indices = new Uint16Array(drawn * 4);
|
|
360
|
-
const weights = new Float32Array(drawn * 4);
|
|
361
|
-
for (let i = 0; i < drawn; i++) {
|
|
362
|
-
const vertex = blenderVertex.getX(i);
|
|
363
|
-
for (let k = 0; k < 4; k++) {
|
|
364
|
-
indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
|
|
365
|
-
weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
|
|
369
|
-
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
|
|
370
|
-
|
|
371
|
-
const boneRoot = new THREE.Group();
|
|
372
|
-
boneRoot.name = `${rig.armature}:bones`;
|
|
373
|
-
const bones: THREE.Bone[] = [];
|
|
374
|
-
const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
|
|
375
|
-
const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
|
|
376
|
-
rig.bones.forEach((declared, index) => {
|
|
377
|
-
const bone = new THREE.Bone();
|
|
378
|
-
// THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
|
|
379
|
-
// arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
|
|
380
|
-
// therefore addresses bones by UUID rather than by name — three's
|
|
381
|
-
// `PropertyBinding` track-name grammar splits on `.`, and Blender's
|
|
382
|
-
// `hand.L` is an ordinary bone name.
|
|
383
|
-
bone.name = declared.name;
|
|
384
|
-
bones.push(bone);
|
|
385
|
-
const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
|
|
386
|
-
const local =
|
|
387
|
-
parentIndex === undefined
|
|
388
|
-
? armatureSpace[index]!.clone()
|
|
389
|
-
: armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
|
|
390
|
-
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
391
|
-
(parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
|
|
392
|
-
});
|
|
393
|
-
armatureObject.add(boneRoot);
|
|
394
|
-
|
|
395
|
-
const skinned = new THREE.SkinnedMesh(geometry, source.material);
|
|
396
|
-
skinned.name = source.name;
|
|
397
|
-
skinned.matrixAutoUpdate = false;
|
|
398
|
-
skinned.matrix.copy(source.matrix);
|
|
399
|
-
skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
|
|
400
|
-
skinned.userData = source.userData;
|
|
401
|
-
// A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
|
|
402
|
-
// bounding sphere from the bind pose, so a raised arm at frame 24 is
|
|
403
|
-
// culled while its bind-pose sphere is off screen — a picture that
|
|
404
|
-
// vanishes mid-scrub with no error anywhere.
|
|
405
|
-
skinned.frustumCulled = false;
|
|
406
|
-
presentation.replacePresentedObject(source, skinned);
|
|
407
|
-
// THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
|
|
408
|
-
// WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
|
|
409
|
-
// at this instant and `bindMatrix` is the mesh's.
|
|
410
|
-
presentation.root.updateMatrixWorld(true);
|
|
411
|
-
const skeleton = new THREE.Skeleton(bones);
|
|
412
|
-
skinned.bind(skeleton, skinned.matrixWorld.clone());
|
|
413
|
-
if (rig.constrainedBones?.length)
|
|
414
|
-
warnings.push(
|
|
415
|
-
`Bone constraints on ${rig.constrainedBones.join(', ')}: the clip is derived from the F-Curves alone, so those bones play their channels rather than Blender's solved pose.`,
|
|
416
|
-
);
|
|
417
|
-
if (rig.unmappedGroups?.length)
|
|
418
|
-
warnings.push(
|
|
419
|
-
`${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
|
|
420
|
-
);
|
|
421
|
-
return {
|
|
422
|
-
object: rig.object ?? '',
|
|
423
|
-
armature: rig.armature ?? '',
|
|
424
|
-
mesh: skinned,
|
|
425
|
-
boneRoot,
|
|
426
|
-
bones,
|
|
427
|
-
skeleton,
|
|
428
|
-
frame,
|
|
429
|
-
signature: rigSignature(rig, frame),
|
|
430
|
-
};
|
|
523
|
+
#bindOne(presentation: SkinPresentation, rig: BlenderRigBinding, frame: number, warnings: string[]): BoundRig | null {
|
|
524
|
+
return bindRig(presentation, rig, frame, warnings);
|
|
431
525
|
}
|
|
432
526
|
|
|
433
527
|
#loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
|
|
@@ -440,28 +534,7 @@ export class BlenderSkinDirector {
|
|
|
440
534
|
const byName = new Map<string, THREE.Bone>();
|
|
441
535
|
for (const rig of this.#rigs.values())
|
|
442
536
|
if (rig.armature === clip.armature) for (const bone of rig.bones) byName.set(bone.name, bone);
|
|
443
|
-
const tracks
|
|
444
|
-
const missing = new Set<string>();
|
|
445
|
-
for (const track of clip.tracks) {
|
|
446
|
-
const bone = byName.get(track.bone);
|
|
447
|
-
if (!bone) {
|
|
448
|
-
missing.add(track.bone);
|
|
449
|
-
continue;
|
|
450
|
-
}
|
|
451
|
-
const times = float32Of(track.timeBase64);
|
|
452
|
-
const values = float32Of(track.valueBase64);
|
|
453
|
-
// ADDRESSED BY UUID, not by name — see the bone-naming note above.
|
|
454
|
-
const path = `${bone.uuid}.${track.property}`;
|
|
455
|
-
tracks.push(
|
|
456
|
-
track.property === 'quaternion'
|
|
457
|
-
? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
|
|
458
|
-
: new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
if (missing.size)
|
|
462
|
-
warnings.push(
|
|
463
|
-
`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`,
|
|
464
|
-
);
|
|
537
|
+
const tracks = clipTracks(clip, byName, warnings);
|
|
465
538
|
if (!tracks.length) return;
|
|
466
539
|
const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
467
540
|
const mixer = new THREE.AnimationMixer(armature);
|
|
@@ -70,10 +70,15 @@ import {
|
|
|
70
70
|
openModelDocumentBlend,
|
|
71
71
|
modelDocumentMayOpen,
|
|
72
72
|
modelDocumentOwnsPresentation,
|
|
73
|
+
blenderActionClip,
|
|
74
|
+
blenderArmatureActions,
|
|
75
|
+
blenderRig,
|
|
76
|
+
blenderRnaVersion,
|
|
73
77
|
} from '../host/blender-runtime-host';
|
|
74
78
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
75
79
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
76
80
|
import { blenderSkin } from './blender-runtime-skin';
|
|
81
|
+
import { bindPlayAnimation, readPlayClipLibrary, type PlayAnimation } from './blender-play-skin';
|
|
77
82
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
78
83
|
import { noteModelDocument } from '../src/play-mode';
|
|
79
84
|
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
@@ -897,6 +902,7 @@ function BlenderViewportArea({
|
|
|
897
902
|
let preparing = false;
|
|
898
903
|
let stopScript: (() => void) | null = null;
|
|
899
904
|
let orbit: { enabled: boolean } | null = null;
|
|
905
|
+
let animation: PlayAnimation | null = null;
|
|
900
906
|
const start = (): void => {
|
|
901
907
|
if (stopScript || preparing || stopped) return;
|
|
902
908
|
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
@@ -904,9 +910,21 @@ function BlenderViewportArea({
|
|
|
904
910
|
preparing = true;
|
|
905
911
|
orbit = stage.rig().orbit;
|
|
906
912
|
orbit.enabled = false;
|
|
907
|
-
|
|
913
|
+
// THE COPY'S CHARACTERS ANIMATE: their skins and every clip they can play, read once for
|
|
914
|
+
// this run and bound to the copy (`blender-play-skin.ts`). A read that fails costs the game its
|
|
915
|
+
// animation, said in the console, never the game itself.
|
|
916
|
+
void Promise.all([
|
|
917
|
+
view.prepareRendered(stage.rig().drawCamera(), 'render'),
|
|
918
|
+
readPlayClipLibrary().catch((error: unknown) => {
|
|
919
|
+
editorHost().console.warn(`The game's animation could not be read: ${error instanceof Error ? error.message : String(error)}`, 'blender-play');
|
|
920
|
+
return null;
|
|
921
|
+
}),
|
|
922
|
+
]).then(([, library]) => {
|
|
908
923
|
if (stopped) return;
|
|
924
|
+
animation = library ? bindPlayAnimation(view, library) : null;
|
|
925
|
+
for (const warning of animation?.warnings ?? []) editorHost().console.warn(warning, 'blender-play');
|
|
909
926
|
stopScript = documentPlayExtension('model')?.run({
|
|
927
|
+
...(animation ? { animation } : {}),
|
|
910
928
|
documentId: modelId,
|
|
911
929
|
container: layers,
|
|
912
930
|
ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
|
|
@@ -964,6 +982,8 @@ function BlenderViewportArea({
|
|
|
964
982
|
stopPrepareFrames?.();
|
|
965
983
|
window.removeEventListener('keydown', onEscape, true);
|
|
966
984
|
stopScript?.();
|
|
985
|
+
animation?.dispose();
|
|
986
|
+
animation = null;
|
|
967
987
|
layers.remove();
|
|
968
988
|
if (orbit) orbit.enabled = true;
|
|
969
989
|
};
|
|
@@ -1035,6 +1055,33 @@ function BlenderViewportArea({
|
|
|
1035
1055
|
for (const { kind } of groups) stage?.setHelper(kind, null);
|
|
1036
1056
|
};
|
|
1037
1057
|
}, [documentId, view]);
|
|
1058
|
+
/**
|
|
1059
|
+
* THE MODEL'S SKINS AND ASSIGNED ACTION, AS BLENDER'S VIEWPORT SHOWS THEM: a rigged object is
|
|
1060
|
+
* presented skinned, whatever editors are open, and plays the action assigned to it. Bound on
|
|
1061
|
+
* every change of the file, a moment after it settles, because `rna_rig` walks every vertex of
|
|
1062
|
+
* every rigged mesh; a file with no armature pays only the cheap listing.
|
|
1063
|
+
*/
|
|
1064
|
+
const rnaVersion = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
|
|
1065
|
+
/** The armatures, their actions and what each is assigned, as last bound: an edit that changes
|
|
1066
|
+
* none of them and replaced no skinned mesh costs no rig read and no bake. */
|
|
1067
|
+
const boundListing = useRef<string | null>(null);
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
if (!documentId || !main) return;
|
|
1070
|
+
let live = true;
|
|
1071
|
+
const timer = setTimeout(() => {
|
|
1072
|
+
void (async () => {
|
|
1073
|
+
const listing = await blenderArmatureActions();
|
|
1074
|
+
if (!live || !listing?.armatures.length) return;
|
|
1075
|
+
const key = JSON.stringify([listing.armatures, listing.scene ?? null]);
|
|
1076
|
+
if (key === boundListing.current && !blenderSkin.stale(view)) return;
|
|
1077
|
+
await blenderSkin.bind(view, { rig: () => blenderRig(), clip: () => blenderActionClip() });
|
|
1078
|
+
boundListing.current = key;
|
|
1079
|
+
})().catch((thrown: unknown) => {
|
|
1080
|
+
if (live) editorHost().console.warn(`This file's rig could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1081
|
+
});
|
|
1082
|
+
}, 400);
|
|
1083
|
+
return () => { live = false; clearTimeout(timer); };
|
|
1084
|
+
}, [documentId, main, view, rnaVersion]);
|
|
1038
1085
|
/**
|
|
1039
1086
|
* ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
|
|
1040
1087
|
*
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
* you are looking.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
42
41
|
import type { StageTransportHandle } from '@volter/editor-sdk/host';
|
|
43
42
|
import { registerViewVerbs } from '@volter/editor-sdk/views';
|
|
44
43
|
import {
|
|
@@ -51,8 +50,6 @@ import {
|
|
|
51
50
|
useSyncExternalStore,
|
|
52
51
|
} from 'react';
|
|
53
52
|
import {
|
|
54
|
-
blenderActionClip,
|
|
55
|
-
blenderRig,
|
|
56
53
|
blenderRnaVersion,
|
|
57
54
|
subscribeBlenderRna,
|
|
58
55
|
} from '../host/blender-runtime-host';
|
|
@@ -392,7 +389,7 @@ function BlenderTimeline() {
|
|
|
392
389
|
// THE FRESHNESS IS THE RNA DOOR'S (ruling 3, 2026-09-19): every write and
|
|
393
390
|
// every presented frame bumps it, which is exactly when a rig or an action
|
|
394
391
|
// can have moved.
|
|
395
|
-
|
|
392
|
+
useSyncExternalStore(
|
|
396
393
|
subscribeBlenderRna,
|
|
397
394
|
blenderRnaVersion,
|
|
398
395
|
blenderRnaVersion,
|
|
@@ -404,30 +401,9 @@ function BlenderTimeline() {
|
|
|
404
401
|
const { transform, size, refusal, onlySelected } = timelineViewState();
|
|
405
402
|
const play = blenderSkin.state();
|
|
406
403
|
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// paying for both on each of a session's hundreds of `blender-execute`
|
|
411
|
-
// presents would be a round trip nobody asked for. A project with no
|
|
412
|
-
// Timeline open therefore presents exactly as it did before this unit, with
|
|
413
|
-
// ordinary `THREE.Mesh`es.
|
|
414
|
-
useEffect(() => {
|
|
415
|
-
let live = true;
|
|
416
|
-
void blenderSkin
|
|
417
|
-
.bind(blenderModelView, {
|
|
418
|
-
rig: () => blenderRig(),
|
|
419
|
-
clip: () => blenderActionClip(),
|
|
420
|
-
})
|
|
421
|
-
.catch((thrown: unknown) => {
|
|
422
|
-
if (!live) return;
|
|
423
|
-
refuseTimelineGesture(
|
|
424
|
-
`The Timeline could not read this file's rig: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
|
425
|
-
);
|
|
426
|
-
});
|
|
427
|
-
return () => {
|
|
428
|
-
live = false;
|
|
429
|
-
};
|
|
430
|
-
}, [rnaVersion]);
|
|
404
|
+
// The skins and the assigned action are the presented model's (`blender-runtime.document.tsx`
|
|
405
|
+
// binds them whatever is on screen, as Blender's viewport evaluates whatever editor is open);
|
|
406
|
+
// the Timeline only shows and drives their clock.
|
|
431
407
|
|
|
432
408
|
useEffect(() => {
|
|
433
409
|
const element = canvas.current;
|
|
@@ -43,7 +43,7 @@ import type {
|
|
|
43
43
|
BlenderUvLayout,
|
|
44
44
|
} from '@volter/blender-engine/browser/rna';
|
|
45
45
|
import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
46
|
-
import
|
|
46
|
+
import { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
47
47
|
import type { EditorCommandResult } from '@volter/editor-sdk/commands';
|
|
48
48
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
49
49
|
import { invokeViewVerb, type ViewVerbContribution } from '@volter/editor-sdk/views';
|
|
@@ -677,11 +677,19 @@ export async function blenderRig(options?: { object?: string }): Promise<Blender
|
|
|
677
677
|
export async function blenderActionClip(options?: {
|
|
678
678
|
object?: string;
|
|
679
679
|
bake?: boolean;
|
|
680
|
+
/** A named action baked against `object`'s armature, assigned or not. */
|
|
681
|
+
action?: string;
|
|
680
682
|
}): Promise<BlenderActionClip | null> {
|
|
681
683
|
if (!blenderSessionStarted()) return null;
|
|
682
684
|
return blenderRuntime().actionClip(options);
|
|
683
685
|
}
|
|
684
686
|
|
|
687
|
+
/** Every armature and the actions that can play on it, for Play's clip library. Never starts the engine. */
|
|
688
|
+
export async function blenderArmatureActions(): Promise<{ armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } } | null> {
|
|
689
|
+
if (!blenderSessionStarted()) return null;
|
|
690
|
+
return blenderRuntime().armatureActions();
|
|
691
|
+
}
|
|
692
|
+
|
|
685
693
|
export async function blenderOutlinerSet(
|
|
686
694
|
path: string,
|
|
687
695
|
column: string,
|
|
@@ -821,6 +829,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
821
829
|
}
|
|
822
830
|
|
|
823
831
|
interface RuntimeView {
|
|
832
|
+
readonly root: THREE.Group;
|
|
824
833
|
stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
|
|
825
834
|
applyFrame(frame: unknown): unknown;
|
|
826
835
|
snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
|
|
@@ -903,6 +912,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
|
903
912
|
typeof (value as RuntimeView).applyFrame === 'function' &&
|
|
904
913
|
typeof (value as RuntimeView).stageFrame === 'function' &&
|
|
905
914
|
typeof (value as RuntimeView).snapshot === 'function' &&
|
|
915
|
+
(value as RuntimeView).root?.isObject3D === true &&
|
|
906
916
|
typeof (value as RuntimeView).captureSnapshot === 'function' &&
|
|
907
917
|
typeof (value as RuntimeView).recordPresentation === 'function' &&
|
|
908
918
|
typeof (value as RuntimeView).recordPhotograph === 'function';
|
|
@@ -951,11 +961,20 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
951
961
|
captureLifetime = lifetime;
|
|
952
962
|
let photographing = false;
|
|
953
963
|
let streamedFrame: { view: RuntimeView; session: string; revision: number } | null = null;
|
|
964
|
+
let renderFrame: { view: BlenderRuntimeView; source: RuntimeView; sourceFrame: ReturnType<RuntimeView['snapshot']>; documentId: string; session: string; revision: number } | null = null;
|
|
965
|
+
const discardRenderFrame = () => {
|
|
966
|
+
const owned = renderFrame;
|
|
967
|
+
renderFrame = null;
|
|
968
|
+
owned?.view.dispose();
|
|
969
|
+
owned?.view.root.removeFromParent();
|
|
970
|
+
};
|
|
954
971
|
const discardStreamedFrame = () => {
|
|
955
972
|
const staged = streamedFrame;
|
|
956
973
|
streamedFrame = null;
|
|
957
974
|
if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
|
|
975
|
+
discardRenderFrame();
|
|
958
976
|
};
|
|
977
|
+
lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
|
|
959
978
|
runtime = new BlenderRuntime({
|
|
960
979
|
work: beginBlenderWork,
|
|
961
980
|
history: (entries) => {
|
|
@@ -997,6 +1016,20 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
997
1016
|
const view = await runtimeView();
|
|
998
1017
|
lifetime.signal.throwIfAborted();
|
|
999
1018
|
if (presentationDocumentId() !== documentId) throw new Error('Blender document changed during frame transfer');
|
|
1019
|
+
if (part.evaluation === 'render') {
|
|
1020
|
+
if (!renderFrame) {
|
|
1021
|
+
if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
|
|
1022
|
+
const detached = BlenderRuntimeView.forPhotograph();
|
|
1023
|
+
view.root.updateWorldMatrix(true, false);
|
|
1024
|
+
detached.root.matrix.copy(view.root.matrixWorld);
|
|
1025
|
+
renderFrame = { view: detached, source: view, sourceFrame: view.snapshot(), documentId, session: part.session, revision: part.revision };
|
|
1026
|
+
}
|
|
1027
|
+
if (renderFrame.source !== view || renderFrame.sourceFrame !== view.snapshot() || renderFrame.documentId !== documentId || renderFrame.session !== part.session || renderFrame.revision !== part.revision)
|
|
1028
|
+
throw new Error('Blender capture source changed during render transfer');
|
|
1029
|
+
renderFrame.view.stageFrame(part);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
|
|
1000
1033
|
if (streamedFrame && streamedFrame.view !== view)
|
|
1001
1034
|
throw new Error('Blender presenter changed during frame transfer');
|
|
1002
1035
|
view.stageFrame(part);
|
|
@@ -1027,6 +1060,12 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1027
1060
|
throw new Error('Blender document changed before its frame could be presented');
|
|
1028
1061
|
if (streamedFrame && streamedFrame.view !== view)
|
|
1029
1062
|
throw new Error('Blender presenter changed before its frame could be presented');
|
|
1063
|
+
const rendering = capture?.evaluation === 'render' ? renderFrame : null;
|
|
1064
|
+
if (capture?.evaluation === 'render' && !rendering)
|
|
1065
|
+
throw new Error('Render-evaluated frame arrived without its staged resources');
|
|
1066
|
+
if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
|
|
1067
|
+
throw new Error('Blender capture source changed before render presentation');
|
|
1068
|
+
const presenter = rendering?.view ?? view;
|
|
1030
1069
|
// WHAT THE PRESENTER HELD BEFORE THIS FRAME, carried back to the session
|
|
1031
1070
|
// beside whatever this present produced. The session only has a RECORD of
|
|
1032
1071
|
// what it sent; this view is the authority on what it actually holds, and
|
|
@@ -1036,7 +1075,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1036
1075
|
// all, rather than a wrong `null`.
|
|
1037
1076
|
const endApply = beginBlenderWork('applying frame to Model');
|
|
1038
1077
|
const applied = (() => {
|
|
1039
|
-
try { return
|
|
1078
|
+
try { return presenter.applyFrame(frame) as { held?: unknown } | null | undefined; }
|
|
1040
1079
|
finally { endApply(); }
|
|
1041
1080
|
})();
|
|
1042
1081
|
streamedFrame = null;
|
|
@@ -1049,7 +1088,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1049
1088
|
// THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
|
|
1050
1089
|
// a refused frame is not displayed and must not be described as if it
|
|
1051
1090
|
// were.
|
|
1052
|
-
|
|
1091
|
+
presenter.recordPresentation(description);
|
|
1053
1092
|
if (!capture) return answer(undefined);
|
|
1054
1093
|
lastCapture = capture;
|
|
1055
1094
|
if (!capture.render) return answer(undefined);
|
|
@@ -1091,7 +1130,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1091
1130
|
'A Blender render capture must carry the scene camera position, target and up',
|
|
1092
1131
|
);
|
|
1093
1132
|
if (photographing) throw new Error('A Blender render capture is already in progress');
|
|
1094
|
-
const snapshot = view.captureSnapshot();
|
|
1133
|
+
const snapshot = rendering ? rendering.view.takeCaptureSnapshot() : view.captureSnapshot();
|
|
1095
1134
|
photographing = true;
|
|
1096
1135
|
lifetime.signal.addEventListener('abort', snapshot.dispose, { once: true });
|
|
1097
1136
|
try {
|
|
@@ -1150,6 +1189,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1150
1189
|
photographing = false;
|
|
1151
1190
|
lifetime.signal.removeEventListener('abort', snapshot.dispose);
|
|
1152
1191
|
snapshot.dispose();
|
|
1192
|
+
if (rendering && renderFrame === rendering) renderFrame = null;
|
|
1153
1193
|
}
|
|
1154
1194
|
},
|
|
1155
1195
|
// The worker's OWN stdout and stderr, forwarded so a developer can read
|
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.135",
|
|
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.133",
|
|
76
|
+
"@volter/editor-threejs": "0.5.196",
|
|
77
77
|
"zod": "^4.3.6",
|
|
78
78
|
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@volter/editor-live": "0.5.
|
|
82
|
-
"@volter/editor-project": "0.5.
|
|
83
|
-
"@volter/editor-sdk": "0.5.
|
|
81
|
+
"@volter/editor-live": "0.5.196",
|
|
82
|
+
"@volter/editor-project": "0.5.196",
|
|
83
|
+
"@volter/editor-sdk": "0.5.196",
|
|
84
84
|
"react": "^19.0.0",
|
|
85
85
|
"react-dom": "^19.0.0",
|
|
86
86
|
"three": "^0.180.0",
|