@volter/editor-blender 0.1.135 → 0.1.137
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-skin.ts +285 -186
- package/contributions/blender-pose.ts +621 -0
- package/contributions/blender-runtime-skin.ts +221 -439
- package/contributions/blender-runtime.document.tsx +16 -35
- package/contributions/blender.command.ts +0 -1
- package/host/blender-runtime-host.ts +2 -27
- package/package.json +6 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE
|
|
2
|
+
* THE TIMELINE'S ANIMATION — three.js PLAYS Blender's animation; Blender holds it
|
|
3
3
|
* as DATA (owner rule, 2026-09-20: "we visualize with three.js, not Blender").
|
|
4
4
|
*
|
|
5
5
|
* ## Why this exists at all
|
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
* it and re-exports the mesh columns. That is the wrong architecture: it puts
|
|
9
9
|
* a WASM depsgraph evaluation and a full geometry round trip between the
|
|
10
10
|
* person's pointer and the picture, and it moves `scene.frame_current` sixty
|
|
11
|
-
* times a second under every bpy reader in the session. So
|
|
12
|
-
*
|
|
13
|
-
* (`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* times a second under every bpy reader in the session. So the skin travels in the
|
|
12
|
+
* frame itself: the export door ships each Armature-deformed mesh's weights per
|
|
13
|
+
* exported vertex (`bpy_web_export.cc` `write_skin`), the frame names every bone's
|
|
14
|
+
* rest, channels, constraints and pose and each armature's NLA stack, and the presenter
|
|
15
|
+
* builds the skeletons (`blender-runtime-skeleton.ts`). This director poses EVERY armature
|
|
16
|
+
* at the scene frame with Blender's own evaluation (`blender-pose.ts`): its NLA tracks and
|
|
17
|
+
* active action, blended as Blender blends them, and its Damped Track constraints. Every scrub
|
|
18
|
+
* and every played frame costs ZERO calls into Blender; an action is baked again exactly when
|
|
19
|
+
* the frame says its revision moved.
|
|
16
20
|
*
|
|
17
21
|
* ## The bind pose is the EXPORT pose, and that is the whole trick
|
|
18
22
|
*
|
|
@@ -24,227 +28,60 @@
|
|
|
24
28
|
* own evaluation of the same skin over the same columns. Nothing has to move
|
|
25
29
|
* Blender's frame, ever.
|
|
26
30
|
*
|
|
27
|
-
* ##
|
|
31
|
+
* ## The fidelity check, and what is not played
|
|
28
32
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* from the F-Curves alone.
|
|
33
|
+
* At Blender's own frame, standing still, the picture IS Blender's pose (the frame's), and the
|
|
34
|
+
* evaluator's answer for that frame is compared with it bone by bone (`poseDivergence`). A bone
|
|
35
|
+
* further off than a tenth of a degree is said in the console with what would explain it, so a
|
|
36
|
+
* difference between this Timeline (and a game, which uses the same evaluator) and Blender is
|
|
37
|
+
* never silent. What the evaluator does not play — a constraint other than Damped Track, a
|
|
38
|
+
* driver, non-default parenting — is named once as well. Anything Blender's depsgraph does to
|
|
39
|
+
* GEOMETRY per frame (shape keys, a Displace or Cloth modifier) does not follow the bones.
|
|
37
40
|
*/
|
|
38
41
|
|
|
39
|
-
import type {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from '@volter/blender-engine/browser/rna';
|
|
44
|
-
import type { StageTransportHandle } from '@volter/editor-sdk/host';
|
|
45
|
-
import * as THREE from 'three';
|
|
42
|
+
import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
|
|
43
|
+
import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
|
|
44
|
+
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
45
|
+
import { editorHost, type StageTransportHandle } from '@volter/editor-sdk/host';
|
|
46
46
|
import { blenderRnaSet } from '../host/blender-runtime-host';
|
|
47
|
+
import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const binary = atob(base64);
|
|
54
|
-
const out = new Uint8Array(binary.length);
|
|
55
|
-
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
function float32Of(base64: string): Float32Array {
|
|
59
|
-
const bytes = bytesOf(base64);
|
|
60
|
-
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
|
|
61
|
-
}
|
|
62
|
-
function uint16Of(base64: string): Uint16Array {
|
|
63
|
-
const bytes = bytesOf(base64);
|
|
64
|
-
return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** A row-major four-row matrix, as every matrix in the RNA doors crosses —
|
|
68
|
-
* `THREE.Matrix4.set` takes its arguments row-major too, so this is a spread
|
|
69
|
-
* and not a transpose. */
|
|
70
|
-
function matrixOf(rows: readonly (readonly [number, number, number, number])[]): THREE.Matrix4 {
|
|
71
|
-
return new THREE.Matrix4().set(...(rows.flat() as unknown as Parameters<THREE.Matrix4['set']>));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** What the presenter needs of the view to swap a Mesh for a SkinnedMesh and
|
|
75
|
-
* to hang bones off an armature. Deliberately three methods and not the view:
|
|
76
|
-
* this module has no business knowing about frames, materials or overlays. */
|
|
77
|
-
export interface SkinPresentation {
|
|
78
|
-
/** The presented `Object3D` for a Blender object NAME, or null. */
|
|
79
|
-
objectForBlenderName(name: string): THREE.Object3D | null;
|
|
80
|
-
/** Put `next` where `previous` was — same parent, same place in the object
|
|
81
|
-
* table — so the next frame's reuse check finds it. */
|
|
82
|
-
replacePresentedObject(previous: THREE.Object3D, next: THREE.Object3D): void;
|
|
83
|
-
/** The presented root, for the one forced `updateMatrixWorld` a bind needs. */
|
|
84
|
-
root: THREE.Object3D;
|
|
49
|
+
/** How the director reads the engine: the Timeline's subject (its keys, the scene's range) and
|
|
50
|
+
* one action baked for one armature. */
|
|
51
|
+
export interface SkinReader {
|
|
52
|
+
clip(armature: string): Promise<BlenderActionClip | null>;
|
|
53
|
+
bake(armature: string, action: string): Promise<BlenderActionClip | null>;
|
|
85
54
|
}
|
|
86
55
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
readonly signature: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function rigSignature(rig: BlenderRigBinding, frame: number): string {
|
|
103
|
-
return [rig.object, rig.mesh, rig.armature, rig.vertexCount, rig.bones.length, frame].join('|');
|
|
56
|
+
/** An armature's layers at a scene frame: its NLA, then its active action over them. */
|
|
57
|
+
export function sceneLayers(armature: BlenderArmature, frame: number,
|
|
58
|
+
clips: (action: string) => PoseClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
|
|
59
|
+
const stack = nlaLayers(armature.animation, frame, clips);
|
|
60
|
+
const action = armature.action ?? null;
|
|
61
|
+
if (action) {
|
|
62
|
+
const clip = clips(action);
|
|
63
|
+
if (clip === undefined) stack.waiting = true;
|
|
64
|
+
const layer = clip ? actionLayer(armature.animation, clip, frame, stack.evaluated) : null;
|
|
65
|
+
if (layer) stack.layers.push(layer);
|
|
66
|
+
}
|
|
67
|
+
return stack;
|
|
104
68
|
}
|
|
105
69
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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;
|
|
70
|
+
/** What to do about what plays only in Blender, by kind. */
|
|
71
|
+
export function remedy(unsupported: readonly string[]): string {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
if (unsupported.some((thing) => thing.includes(' constraint ') || thing.includes('drivers')))
|
|
74
|
+
out.push('Bake a constraint or driver into the action (Pose ▸ Animation ▸ Bake Action, Visual Keying), or aim with a Damped Track.');
|
|
75
|
+
if (unsupported.some((thing) => thing.includes('curves on ')))
|
|
76
|
+
out.push("Key the motion on a bone (the root bone) instead of the armature object, or move the object from the game.");
|
|
77
|
+
if (unsupported.some((thing) => thing.includes('interpolation') || thing.includes(' modifier')))
|
|
78
|
+
out.push('Use Bezier, Linear or Constant keys and the Cycles modifier only, or bake the curves (Key ▸ Bake Keyframes).');
|
|
79
|
+
return out.length ? ` ${out.join(' ')}` : '';
|
|
131
80
|
}
|
|
132
81
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
};
|
|
82
|
+
/** True when an armature has anything to evaluate: an action, or NLA strips. */
|
|
83
|
+
export function animated(armature: BlenderArmature): boolean {
|
|
84
|
+
return !!armature.action || !!armature.animation?.tracks.some((track) => track.strips.length);
|
|
248
85
|
}
|
|
249
86
|
|
|
250
87
|
/**
|
|
@@ -255,21 +92,17 @@ export function bindRig(
|
|
|
255
92
|
* copy of the playback state anywhere.
|
|
256
93
|
*/
|
|
257
94
|
export class BlenderSkinDirector {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
}
|
|
270
|
-
#mixer: THREE.AnimationMixer | null = null;
|
|
271
|
-
#action: THREE.AnimationAction | null = null;
|
|
95
|
+
/** The presented view whose skeletons are posed (`sync`). */
|
|
96
|
+
#view: BlenderRuntimeView | null = null;
|
|
97
|
+
#reader: SkinReader | null = null;
|
|
98
|
+
/** The Timeline's subject: the armature its keys and header describe, and what its header was
|
|
99
|
+
* read for. Read again only when that changes. */
|
|
100
|
+
#armature: string | null = null;
|
|
101
|
+
#clipKey: string | null = null;
|
|
272
102
|
#clip: BlenderActionClip | null = null;
|
|
103
|
+
/** Each posed armature's evaluator, and every action baked for it, at the revision baked. */
|
|
104
|
+
#poses = new Map<string, ArmaturePose>();
|
|
105
|
+
#baked = new Map<string, { revision: number | null; clip: PoseClip | null | undefined }>();
|
|
273
106
|
/** The frame the last SEEK asked for — see {@link frame}. */
|
|
274
107
|
#seeked: number | null = null;
|
|
275
108
|
/** The stage transport this skin is attached to, or null before `attachTo`.
|
|
@@ -279,6 +112,8 @@ export class BlenderSkinDirector {
|
|
|
279
112
|
#listeners = new Set<() => void>();
|
|
280
113
|
#version = 0;
|
|
281
114
|
#warnings: string[] = [];
|
|
115
|
+
/** What was already said in the console, so each finding is said once. */
|
|
116
|
+
#said = new Set<string>();
|
|
282
117
|
/** Engine calls this director has made, counted so a walk can prove that a
|
|
283
118
|
* scrub costs none (the acceptance the owner named). */
|
|
284
119
|
#engineCalls = 0;
|
|
@@ -301,9 +136,8 @@ export class BlenderSkinDirector {
|
|
|
301
136
|
for (const listener of [...this.#listeners]) listener();
|
|
302
137
|
}
|
|
303
138
|
|
|
304
|
-
/** The
|
|
305
|
-
*
|
|
306
|
-
* person or an agent ever names. */
|
|
139
|
+
/** The subject's facts plus the playhead, expressed in BLENDER FRAMES — which is the only
|
|
140
|
+
* number the Timeline draws, and the only one a person or an agent ever names. */
|
|
307
141
|
state(): {
|
|
308
142
|
frame: number;
|
|
309
143
|
start: number;
|
|
@@ -315,9 +149,8 @@ export class BlenderSkinDirector {
|
|
|
315
149
|
bones: number;
|
|
316
150
|
keyframes: readonly { frame: number; type: string; select: boolean }[];
|
|
317
151
|
/** Every animated object's own columns plus its SELECTION — what the
|
|
318
|
-
* summary row's `show_only_selected` filter chooses between.
|
|
319
|
-
*
|
|
320
|
-
* exactly as in Blender. */
|
|
152
|
+
* summary row's `show_only_selected` filter chooses between. What plays
|
|
153
|
+
* is every armature's stack, filter or no filter, exactly as in Blender. */
|
|
321
154
|
summary: readonly {
|
|
322
155
|
object: string;
|
|
323
156
|
action: string;
|
|
@@ -332,30 +165,27 @@ export class BlenderSkinDirector {
|
|
|
332
165
|
engineCalls: number;
|
|
333
166
|
/** BLENDER'S OWN `scene.frame_current`, as the clip door last read it —
|
|
334
167
|
* which is a different number from `frame` on purpose. `frame` is where
|
|
335
|
-
* the person is looking
|
|
336
|
-
*
|
|
337
|
-
*
|
|
168
|
+
* the person is looking; this is where bpy and the Properties rail are.
|
|
169
|
+
* They agree after a pause or a scrub-end, and they are deliberately
|
|
170
|
+
* allowed to differ in between. */
|
|
338
171
|
blenderFrame: number | null;
|
|
339
172
|
} {
|
|
340
173
|
const clip = this.#clip;
|
|
341
|
-
const start = clip?.frameStart ?? 1;
|
|
342
|
-
const end = clip?.frameEnd ?? 250;
|
|
343
|
-
const fps = clip?.fps ?? 24;
|
|
344
174
|
return {
|
|
345
175
|
frame: this.frame(),
|
|
346
|
-
start,
|
|
347
|
-
end,
|
|
348
|
-
fps,
|
|
176
|
+
start: clip?.frameStart ?? 1,
|
|
177
|
+
end: clip?.frameEnd ?? 250,
|
|
178
|
+
fps: clip?.fps ?? 24,
|
|
349
179
|
action: clip?.action ?? null,
|
|
350
180
|
object: clip?.object ?? null,
|
|
351
181
|
armature: clip?.armature ?? null,
|
|
352
|
-
bones:
|
|
182
|
+
bones: this.#armature ? this.#view?.skeletons.rig(this.#armature)?.bones.size ?? 0 : 0,
|
|
353
183
|
keyframes: clip?.keyframes ?? [],
|
|
354
184
|
summary: clip?.summary ?? [],
|
|
355
185
|
clipStart: clip?.clipStart ?? null,
|
|
356
186
|
clipEnd: clip?.clipEnd ?? null,
|
|
357
187
|
tracks: clip?.tracks.length ?? 0,
|
|
358
|
-
bound: [...this.#
|
|
188
|
+
bound: [...this.#poses.keys()],
|
|
359
189
|
warnings: this.#warnings,
|
|
360
190
|
engineCalls: this.#engineCalls,
|
|
361
191
|
blenderFrame: clip?.frameCurrent ?? null,
|
|
@@ -368,193 +198,167 @@ export class BlenderSkinDirector {
|
|
|
368
198
|
return this.#clip ? this.frame() : null;
|
|
369
199
|
}
|
|
370
200
|
|
|
371
|
-
/** The
|
|
372
|
-
* frame stands, which is Blender's answer for a file with no animation. */
|
|
201
|
+
/** The frame on screen: the last seek's, else Blender's own. */
|
|
373
202
|
frame(): number {
|
|
374
|
-
|
|
375
|
-
if (this.#seeked !== null) return this.#seeked;
|
|
376
|
-
if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
|
|
377
|
-
// A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
|
|
378
|
-
// `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
|
|
379
|
-
// `time === duration` and reads back as the FIRST — measured on the first
|
|
380
|
-
// walk, where `jump-end` answered frame 1 over a frame-48 pose. The
|
|
381
|
-
// wrapping is right for playback and wrong for a question, so a standing
|
|
382
|
-
// seek answers with the frame it was given and the play tick clears it.
|
|
383
|
-
// THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
|
|
384
|
-
// looping: `AnimationMixer.time` is monotonic and never wraps, while
|
|
385
|
-
// `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
|
|
386
|
-
// Measured on the first walk: two seconds of playback over a 47-frame clip
|
|
387
|
-
// read as frame 63.5 off the mixer, where the picture was correctly back
|
|
388
|
-
// near the start.
|
|
389
|
-
return clip.clipStart + this.#action.time * clip.fps;
|
|
203
|
+
return this.#seeked ?? this.#clip?.frameCurrent ?? 1;
|
|
390
204
|
}
|
|
391
205
|
|
|
392
206
|
/** 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
|
-
|
|
412
207
|
get playable(): boolean {
|
|
413
208
|
return this.#clip !== null && this.#transport !== null;
|
|
414
209
|
}
|
|
415
210
|
|
|
416
|
-
// ------------------------------------------------------------ the
|
|
211
|
+
// ------------------------------------------------------------ the frame
|
|
417
212
|
|
|
418
213
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* without the picture jumping back to the bind pose each time something
|
|
425
|
-
* unrelated moved.
|
|
214
|
+
* FOLLOW THE PRESENTED FRAME. Every armature the frame names is posed; the Timeline's subject is
|
|
215
|
+
* the active object's armature (itself, or the one its skin is bound to), else the one armature
|
|
216
|
+
* that has an action, and its header is read through the clip door when the frame says
|
|
217
|
+
* something about it moved. Actions are baked once per revision, the first time a frame needs
|
|
218
|
+
* them. Otherwise this costs nothing, which is what lets the model call it after every present.
|
|
426
219
|
*/
|
|
427
|
-
async
|
|
428
|
-
|
|
429
|
-
read
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
for (const
|
|
441
|
-
|
|
442
|
-
|
|
220
|
+
async sync(view: BlenderRuntimeView, read: SkinReader): Promise<void> {
|
|
221
|
+
this.#view = view;
|
|
222
|
+
this.#reader = read;
|
|
223
|
+
const facts = view.animationFacts();
|
|
224
|
+
const names = Object.keys(facts.armatures);
|
|
225
|
+
const active = facts.active;
|
|
226
|
+
const armature = active && facts.armatures[active] ? active
|
|
227
|
+
: (active ? facts.skinArmature(active) : null)
|
|
228
|
+
?? (names.filter((name) => facts.armatures[name]!.action).length === 1
|
|
229
|
+
? names.find((name) => facts.armatures[name]!.action)! : null);
|
|
230
|
+
// EVERY ARMATURE'S EVALUATOR, over the rig the presenter holds for it now.
|
|
231
|
+
for (const [name, pose] of [...this.#poses])
|
|
232
|
+
if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) this.#poses.delete(name);
|
|
233
|
+
for (const [name, entry] of Object.entries(facts.armatures)) {
|
|
234
|
+
const rig = view.skeletons.rig(name);
|
|
235
|
+
if (!rig || !animated(entry)) {
|
|
236
|
+
this.#poses.delete(name);
|
|
443
237
|
continue;
|
|
444
238
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
}
|
|
239
|
+
let pose = this.#poses.get(name);
|
|
240
|
+
if (!pose) this.#poses.set(name, pose = new ArmaturePose(rig));
|
|
241
|
+
pose.facts(entry);
|
|
242
|
+
}
|
|
243
|
+
// A BAKE IS GOOD FOR ITS ACTION'S REVISION; a new revision bakes it again when next needed.
|
|
244
|
+
for (const [key, entry] of [...this.#baked]) {
|
|
245
|
+
const action = key.slice(key.indexOf('\u0000') + 1);
|
|
246
|
+
if ((facts.actions[action] ?? null) !== entry.revision) this.#baked.delete(key);
|
|
247
|
+
}
|
|
248
|
+
const action = armature ? facts.armatures[armature]?.action ?? null : null;
|
|
249
|
+
const key = JSON.stringify([armature, action, action ? facts.actions[action] ?? null : null, facts.clock, facts.frame]);
|
|
250
|
+
if (key !== this.#clipKey) {
|
|
251
|
+
this.#clipKey = key;
|
|
252
|
+
this.#armature = armature;
|
|
253
|
+
let clip: BlenderActionClip | null = null;
|
|
254
|
+
if (armature) {
|
|
255
|
+
this.#engineCalls++;
|
|
256
|
+
clip = await read.clip(armature);
|
|
257
|
+
if (clip?.action && clip.armature) this.#baked.set(`${clip.armature}\u0000${clip.action}`, { revision: facts.actions[clip.action] ?? null, clip: poseClip(clip) });
|
|
474
258
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
259
|
+
const previous = this.#clip;
|
|
260
|
+
const rangeChanged = clip?.frameStart !== previous?.frameStart ||
|
|
261
|
+
clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
|
|
262
|
+
this.#clip = clip ?? null;
|
|
263
|
+
if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
|
|
264
|
+
const activeSubject = this.#transport?.snapshot().activeSubject;
|
|
265
|
+
if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
|
|
266
|
+
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
267
|
+
if (clip && !playing) {
|
|
268
|
+
this.#seeked = null;
|
|
269
|
+
if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
|
|
481
270
|
}
|
|
482
271
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
272
|
+
this.#pose();
|
|
273
|
+
this.#publish();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One action of one armature: baked, or being baked now (once), or null when it plays nothing. */
|
|
277
|
+
#clipOf(armature: string, action: string): PoseClip | null | undefined {
|
|
278
|
+
const key = `${armature}\u0000${action}`;
|
|
279
|
+
const held = this.#baked.get(key);
|
|
280
|
+
if (held) return held.clip;
|
|
281
|
+
const reader = this.#reader;
|
|
282
|
+
const revision = this.#view?.animationFacts().actions[action] ?? null;
|
|
283
|
+
if (!reader) return null;
|
|
284
|
+
const entry: { revision: number | null; clip: PoseClip | null | undefined } = { revision, clip: undefined };
|
|
285
|
+
this.#baked.set(key, entry);
|
|
490
286
|
this.#engineCalls++;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
clip
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
287
|
+
void reader.bake(armature, action).then((baked) => {
|
|
288
|
+
entry.clip = baked ? poseClip(baked) : null;
|
|
289
|
+
if (this.#baked.get(key) !== entry) return;
|
|
290
|
+
this.#pose();
|
|
291
|
+
this.#publish();
|
|
292
|
+
}, (error: unknown) => {
|
|
293
|
+
entry.clip = null;
|
|
294
|
+
this.#say(`bake:${key}`, `${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
295
|
+
});
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* POSE EVERY ARMATURE AT THE FRAME ON SCREEN. At Blender's own frame, standing still, the bones
|
|
301
|
+
* take Blender's pose (the frame's, exact) and the evaluator's answer is checked against it;
|
|
302
|
+
* anywhere else they take the evaluator's.
|
|
303
|
+
*/
|
|
304
|
+
#pose(): void {
|
|
305
|
+
const view = this.#view;
|
|
306
|
+
if (!view) return;
|
|
307
|
+
const facts = view.animationFacts();
|
|
308
|
+
const frame = this.frame();
|
|
309
|
+
// BLENDER'S OWN FRAME is the one the presented pose was evaluated at, not the bookmark: a pause
|
|
310
|
+
// writes the bookmark first and the frame evaluated there arrives after.
|
|
311
|
+
const atBlender = frame === facts.frame;
|
|
512
312
|
const playing = this.#transport?.snapshot().playbackState === 'playing';
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
313
|
+
const warnings: string[] = this.#clip?.reason ? [this.#clip.reason] : [];
|
|
314
|
+
const world = { object: (name: string) => view.objectForBlenderName(name) };
|
|
315
|
+
for (const [name, pose] of this.#poses) {
|
|
316
|
+
const armature = facts.armatures[name];
|
|
317
|
+
if (!armature) continue;
|
|
318
|
+
const unsupported = [...pose.unsupported(), ...this.#clipsOf(name).flatMap((clip) => clip.unsupported)];
|
|
319
|
+
if (unsupported.length) {
|
|
320
|
+
const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline or a game.${remedy(unsupported)}`;
|
|
321
|
+
warnings.push(said);
|
|
322
|
+
this.#say(`unsupported:${name}:${unsupported.join('|')}`, said);
|
|
323
|
+
}
|
|
324
|
+
const stack = sceneLayers(armature, frame, (action) => this.#clipOf(name, action));
|
|
325
|
+
for (const skipped of stack.skipped) this.#say(`skipped:${name}:${skipped}`, `${name}: ${skipped} plays only in Blender, not in the Timeline or a game.`);
|
|
326
|
+
if (stack.waiting) continue;
|
|
327
|
+
if (atBlender && !playing) {
|
|
328
|
+
view.skeletons.restorePose(name);
|
|
329
|
+
pose.rig.object.updateMatrixWorld(true);
|
|
330
|
+
this.#check(name, pose, armature, stack.layers, world, unsupported);
|
|
331
|
+
} else {
|
|
332
|
+
pose.apply(stack.layers, world);
|
|
333
|
+
}
|
|
518
334
|
}
|
|
519
335
|
this.#warnings = warnings;
|
|
520
|
-
this.#publish();
|
|
521
336
|
}
|
|
522
337
|
|
|
523
|
-
|
|
524
|
-
|
|
338
|
+
/** The clips baked so far for one armature. */
|
|
339
|
+
#clipsOf(armature: string): PoseClip[] {
|
|
340
|
+
const out: PoseClip[] = [];
|
|
341
|
+
for (const [key, entry] of this.#baked) if (key.startsWith(`${armature}`) && entry.clip) out.push(entry.clip);
|
|
342
|
+
return out;
|
|
525
343
|
}
|
|
526
344
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
if (!
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
);
|
|
544
|
-
action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
|
545
|
-
action.play();
|
|
546
|
-
this.#mixer = mixer;
|
|
547
|
-
this.#action = action;
|
|
548
|
-
// THE FRESH MIXER STARTS AT BLENDER'S OWN FRAME, not at zero, and that is
|
|
549
|
-
// the invariant this whole design rests on: the mesh columns were exported
|
|
550
|
-
// at `frameCurrent` and the skeleton was bound in that pose, so seeking
|
|
551
|
-
// anywhere else would make the first picture after a re-bind disagree with
|
|
552
|
-
// the geometry underneath it. Measured on the first walk: a pause wrote
|
|
553
|
-
// frame 16, the write presented, the present re-exported the mesh at 16
|
|
554
|
-
// and re-bound — and `setTime(0)` snapped the playhead back to frame 1
|
|
555
|
-
// over a frame-16 mesh.
|
|
556
|
-
this.#seek(clip.frameCurrent);
|
|
557
|
-
this.#refresh();
|
|
345
|
+
/** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose against Blender's. */
|
|
346
|
+
#check(name: string, pose: ArmaturePose, armature: BlenderArmature, layers: readonly PoseLayer[],
|
|
347
|
+
world: { object(name: string): ReturnType<BlenderRuntimeView['objectForBlenderName']> }, unsupported: readonly string[]): void {
|
|
348
|
+
const off = poseDivergence(pose.apply(layers, world, false), armature);
|
|
349
|
+
if (!off) return;
|
|
350
|
+
const frame = this.#clip?.frameCurrent ?? this.frame();
|
|
351
|
+
const why = unsupported.length
|
|
352
|
+
? `What Blender plays here and the evaluator does not: ${unsupported.join(', ')}.`
|
|
353
|
+
: 'Nothing the evaluator knows of explains it; this is a defect in the presenter, report it with the file.';
|
|
354
|
+
this.#say(`divergence:${name}:${frame}:${off.bone}:${Math.round(off.degrees)}`,
|
|
355
|
+
`${name} at frame ${frame}: bone "${off.bone}" is ${off.degrees.toFixed(1)}° and ${(off.offset * 100).toFixed(1)}% of its length off Blender's own pose, so the Timeline and a game show it differently from Blender. ${why}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#say(key: string, message: string): void {
|
|
359
|
+
if (this.#said.has(key)) return;
|
|
360
|
+
this.#said.add(key);
|
|
361
|
+
editorHost().console.warn(message, 'blender-animation');
|
|
558
362
|
}
|
|
559
363
|
|
|
560
364
|
// ------------------------------------------------------------ the playback
|
|
@@ -562,27 +366,8 @@ export class BlenderSkinDirector {
|
|
|
562
366
|
#seek(frame: number): void {
|
|
563
367
|
const clip = this.#clip;
|
|
564
368
|
if (!clip) return;
|
|
565
|
-
|
|
566
|
-
this.#
|
|
567
|
-
// An action supplies a pose at this scene time; it does not own whether
|
|
568
|
-
// the scene clock can move. Empty/static scenes still play and scrub.
|
|
569
|
-
if (!this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
|
|
570
|
-
// The TIME is nudged inside the clip so the last frame evaluates as the
|
|
571
|
-
// last frame rather than wrapping to the first; the number REPORTED is the
|
|
572
|
-
// one asked for (see `frame`).
|
|
573
|
-
const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
|
|
574
|
-
this.#mixer.setTime(Math.max(0, Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps)));
|
|
575
|
-
this.#refresh();
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** The bones moved; make the world matrices agree before the stage's next
|
|
579
|
-
* render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
|
|
580
|
-
* pass over a handful of nodes rather than a recomposition of the scene. */
|
|
581
|
-
#refresh(): void {
|
|
582
|
-
for (const rig of this.#rigs.values()) {
|
|
583
|
-
rig.boneRoot.updateMatrixWorld(true);
|
|
584
|
-
rig.skeleton.update();
|
|
585
|
-
}
|
|
369
|
+
this.#seeked = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
|
|
370
|
+
this.#pose();
|
|
586
371
|
}
|
|
587
372
|
|
|
588
373
|
/** The scene's name, for the one address the Timeline writes to. */
|
|
@@ -685,16 +470,13 @@ export class BlenderSkinDirector {
|
|
|
685
470
|
|
|
686
471
|
dispose(): void {
|
|
687
472
|
this.#detach?.();
|
|
688
|
-
this.#mixer?.stopAllAction();
|
|
689
|
-
this.#mixer = null;
|
|
690
|
-
this.#action = null;
|
|
691
473
|
this.#clip = null;
|
|
692
474
|
this.#seeked = null;
|
|
693
|
-
for (const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
this.#
|
|
475
|
+
for (const name of this.#poses.keys()) this.#view?.skeletons.restorePose(name);
|
|
476
|
+
this.#poses.clear();
|
|
477
|
+
this.#baked.clear();
|
|
478
|
+
this.#armature = null;
|
|
479
|
+
this.#clipKey = null;
|
|
698
480
|
}
|
|
699
481
|
}
|
|
700
482
|
|