@volter/blender-engine 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.
- package/browser/protocol.ts +0 -9
- package/browser/rna.ts +29 -86
- package/browser/runtime.ts +0 -16
- package/browser/session.py +471 -190
- package/browser/three/blender-runtime-armature.ts +74 -0
- package/browser/three/blender-runtime-skeleton.ts +206 -0
- package/browser/three/blender-runtime-view.ts +132 -7
- package/browser/worker.ts +0 -7
- package/package.json +1 -1
- package/wasm/BUNDLE.json +11 -11
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -33,6 +33,7 @@ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js
|
|
|
33
33
|
import { z } from 'zod';
|
|
34
34
|
|
|
35
35
|
const scalar = z.number().finite();
|
|
36
|
+
const matrixSchema = z.array(z.tuple([scalar, scalar, scalar, scalar])).length(4);
|
|
36
37
|
|
|
37
38
|
/** ONE BONE, as `session.py`'s `_armature_bones` answers it. */
|
|
38
39
|
export const boneSchema = z
|
|
@@ -49,9 +50,76 @@ export const boneSchema = z
|
|
|
49
50
|
select: z.boolean(),
|
|
50
51
|
active: z.boolean(),
|
|
51
52
|
lockedWeight: z.boolean(),
|
|
53
|
+
/** `Bone.matrix_local`, the rest matrix in the armature's space. */
|
|
54
|
+
rest: matrixSchema.optional(),
|
|
55
|
+
/** The channels' own values: what a channel no action animates keeps. */
|
|
56
|
+
channels: z.object({
|
|
57
|
+
mode: z.string(),
|
|
58
|
+
location: z.array(scalar).length(3),
|
|
59
|
+
rotation_quaternion: z.array(scalar).length(4),
|
|
60
|
+
rotation_euler: z.array(scalar).length(3),
|
|
61
|
+
rotation_axis_angle: z.array(scalar).length(4),
|
|
62
|
+
scale: z.array(scalar).length(3),
|
|
63
|
+
}).strict().optional(),
|
|
64
|
+
/** Parenting flags that are not Blender's default, by name. */
|
|
65
|
+
inherit: z.array(z.string()).optional(),
|
|
66
|
+
/** The bone's constraint stack, in order. */
|
|
67
|
+
constraints: z.array(z.object({
|
|
68
|
+
name: z.string(),
|
|
69
|
+
type: z.string(),
|
|
70
|
+
enabled: z.boolean(),
|
|
71
|
+
influence: scalar,
|
|
72
|
+
target: z.string().optional(),
|
|
73
|
+
subtarget: z.string().optional(),
|
|
74
|
+
trackAxis: z.string().optional(),
|
|
75
|
+
headTail: scalar.optional(),
|
|
76
|
+
}).strict()).optional(),
|
|
52
77
|
})
|
|
53
78
|
.strict();
|
|
54
79
|
|
|
80
|
+
/** One NLA strip, as `session.py`'s `_armature_animation` answers it. */
|
|
81
|
+
export const nlaStripSchema = z.object({
|
|
82
|
+
name: z.string(),
|
|
83
|
+
type: z.string(),
|
|
84
|
+
action: z.string().nullable(),
|
|
85
|
+
start: scalar,
|
|
86
|
+
end: scalar,
|
|
87
|
+
actionStart: scalar,
|
|
88
|
+
actionEnd: scalar,
|
|
89
|
+
scale: scalar,
|
|
90
|
+
repeat: scalar,
|
|
91
|
+
influence: scalar,
|
|
92
|
+
animatedInfluence: z.boolean(),
|
|
93
|
+
/** The keyed influence's F-Curve, when `animatedInfluence`. */
|
|
94
|
+
influenceCurve: z.object({
|
|
95
|
+
extrapolation: z.string(),
|
|
96
|
+
interpolation: z.array(z.number()),
|
|
97
|
+
keysBase64: z.string(),
|
|
98
|
+
cycles: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
|
|
99
|
+
unsupported: z.array(z.string()).optional(),
|
|
100
|
+
}).strict().optional(),
|
|
101
|
+
animatedTime: z.boolean(),
|
|
102
|
+
blendIn: scalar,
|
|
103
|
+
blendOut: scalar,
|
|
104
|
+
blendType: z.string(),
|
|
105
|
+
extrapolation: z.string(),
|
|
106
|
+
reversed: z.boolean(),
|
|
107
|
+
mute: z.boolean(),
|
|
108
|
+
}).strict();
|
|
109
|
+
|
|
110
|
+
/** An armature's animation stack: NLA tracks bottom to top, then the active action over them. */
|
|
111
|
+
export const armatureAnimationSchema = z.object({
|
|
112
|
+
useNla: z.boolean(),
|
|
113
|
+
influence: scalar,
|
|
114
|
+
blendType: z.string(),
|
|
115
|
+
extrapolation: z.string(),
|
|
116
|
+
/** The NLA is in tweak mode (`use_tweak_mode`), which the presenter does not play. */
|
|
117
|
+
tweak: z.boolean().optional(),
|
|
118
|
+
tracks: z.array(z.object({ name: z.string(), mute: z.boolean(), solo: z.boolean(), strips: z.array(nlaStripSchema) }).strict()),
|
|
119
|
+
/** Bones whose channels a driver sets. */
|
|
120
|
+
drivers: z.array(z.string()).optional(),
|
|
121
|
+
}).strict();
|
|
122
|
+
|
|
55
123
|
export const armatureSchema = z
|
|
56
124
|
.object({
|
|
57
125
|
object: z.string(),
|
|
@@ -61,10 +129,16 @@ export const armatureSchema = z
|
|
|
61
129
|
showInFront: z.boolean(),
|
|
62
130
|
mode: z.string(),
|
|
63
131
|
bones: z.array(boneSchema),
|
|
132
|
+
/** `animation_data.action`, the action it plays (`session.py` `_armatures`). */
|
|
133
|
+
action: z.string().nullable().optional(),
|
|
134
|
+
animation: armatureAnimationSchema.optional(),
|
|
64
135
|
})
|
|
65
136
|
.strict();
|
|
66
137
|
|
|
67
138
|
export type BlenderArmature = z.infer<typeof armatureSchema>;
|
|
139
|
+
export type BlenderArmatureBone = z.infer<typeof boneSchema>;
|
|
140
|
+
export type BlenderArmatureAnimation = z.infer<typeof armatureAnimationSchema>;
|
|
141
|
+
export type BlenderNlaStrip = z.infer<typeof nlaStripSchema>;
|
|
68
142
|
type Bone = z.infer<typeof boneSchema>;
|
|
69
143
|
|
|
70
144
|
/**
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SKELETONS A FRAME DESCRIBES, and the skinned meshes bound to them.
|
|
3
|
+
*
|
|
4
|
+
* WHERE THE SKIN COMES FROM: the export door itself (`bpy_web_export.cc`, `write_skin`). Every mesh
|
|
5
|
+
* an Armature modifier deforms arrives with a `skin` block: the armature's name, the mesh's vertex
|
|
6
|
+
* group names and, per EXPORTED vertex, its four strongest groups and their normalized weights. The
|
|
7
|
+
* frame's `armatures` already name every bone with its parent and its pose matrix
|
|
8
|
+
* (`PoseBone.matrix`, armature space). So one frame holds everything a skin needs, from one
|
|
9
|
+
* evaluation, and nothing here reads the engine or guesses whether what it holds is stale: a weight
|
|
10
|
+
* edit, a modifier added or removed, a new pose, each re-exports the mesh under a new revision (the
|
|
11
|
+
* door's skin digest moves it) and the skin is rebuilt with the geometry.
|
|
12
|
+
*
|
|
13
|
+
* THE BIND IS THE EXPORTED POSE. The exported vertices are already deformed, at the pose the frame
|
|
14
|
+
* names, so each bone's inverse is that pose's inverse: at Blender's own pose the skinning is an
|
|
15
|
+
* identity and the picture is exactly the one Blender evaluated. A clip played on the bones
|
|
16
|
+
* (`AnimationMixer` over `rig().bones`) moves the mesh from there. The inverses come from the
|
|
17
|
+
* frame's matrices, never from the bones' current transforms, so a bind taken while a clip is
|
|
18
|
+
* playing is still right.
|
|
19
|
+
*
|
|
20
|
+
* AN UNWEIGHTED VERTEX stays where it is, as in Blender: the door gives it the index one past the
|
|
21
|
+
* groups with weight 1, and that index is a bone at the armature's origin that never moves. A group
|
|
22
|
+
* no bone is named for weights nothing (it is that same still bone), as it deforms nothing in Blender.
|
|
23
|
+
*/
|
|
24
|
+
import * as THREE from 'three';
|
|
25
|
+
import type { BlenderArmature } from './blender-runtime-armature';
|
|
26
|
+
|
|
27
|
+
/** The `skin` block of an exported mesh, as the door writes it (typed arrays by the worker). */
|
|
28
|
+
export interface FrameSkin {
|
|
29
|
+
readonly armature: string;
|
|
30
|
+
readonly groups: readonly string[];
|
|
31
|
+
readonly columns: { readonly joints: Uint16Array; readonly weights: Float32Array };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ArmatureRig {
|
|
35
|
+
/** The armature this rig belongs to, by Blender object name. */
|
|
36
|
+
readonly armature: string;
|
|
37
|
+
/** The armature's presented object, which the bones hang under. */
|
|
38
|
+
readonly object: THREE.Object3D;
|
|
39
|
+
/** Every bone by Blender's own bone name (unsanitized: a game finds them by those names). */
|
|
40
|
+
readonly bones: ReadonlyMap<string, THREE.Bone>;
|
|
41
|
+
/** The action the file assigns this armature (`animation_data.action`), if any. */
|
|
42
|
+
readonly action: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Rig extends ArmatureRig {
|
|
46
|
+
readonly root: THREE.Group;
|
|
47
|
+
readonly still: THREE.Bone;
|
|
48
|
+
/** Bone names and parents: a change rebuilds the bones. */
|
|
49
|
+
readonly structure: string;
|
|
50
|
+
/** The pose matrices last applied, armature space, by bone name. */
|
|
51
|
+
pose: Map<string, THREE.Matrix4>;
|
|
52
|
+
poseKey: string;
|
|
53
|
+
action: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const matrixOf = (rows: readonly (readonly number[])[]): THREE.Matrix4 =>
|
|
57
|
+
new THREE.Matrix4().set(...(rows.flat() as Parameters<THREE.Matrix4['set']>));
|
|
58
|
+
|
|
59
|
+
/** Expand an exported mesh's per-vertex skin onto its DRAWN vertices (`blenderVertex`, the draw's
|
|
60
|
+
* map back to Blender's vertices), as the geometry's `skinIndex` and `skinWeight`. */
|
|
61
|
+
export function skinGeometry(geometry: THREE.BufferGeometry, skin: FrameSkin): void {
|
|
62
|
+
const source = geometry.getAttribute('blenderVertex');
|
|
63
|
+
if (!source) throw new Error(`A skinned mesh of ${skin.armature} was drawn without its vertex map`);
|
|
64
|
+
const drawn = source.count;
|
|
65
|
+
const indices = new Uint16Array(drawn * 4);
|
|
66
|
+
const weights = new Float32Array(drawn * 4);
|
|
67
|
+
for (let i = 0; i < drawn; i++) {
|
|
68
|
+
const vertex = source.getX(i);
|
|
69
|
+
indices.set(skin.columns.joints.subarray(vertex * 4, vertex * 4 + 4), i * 4);
|
|
70
|
+
weights.set(skin.columns.weights.subarray(vertex * 4, vertex * 4 + 4), i * 4);
|
|
71
|
+
}
|
|
72
|
+
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
|
|
73
|
+
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
|
|
74
|
+
geometry.userData['blenderSkin'] = { armature: skin.armature, groups: [...skin.groups] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class RuntimeSkeletons {
|
|
78
|
+
#rigs = new Map<string, Rig>();
|
|
79
|
+
|
|
80
|
+
/** The rig of one armature, or null when the frame names none by that name. */
|
|
81
|
+
rig(armature: string): ArmatureRig | null {
|
|
82
|
+
return this.#rigs.get(armature) ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
rigs(): readonly ArmatureRig[] {
|
|
86
|
+
return [...this.#rigs.values()];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bring the rigs to this frame: build an armature's bones when it is new or its bones changed,
|
|
91
|
+
* and pose them as Blender posed them WHEN THAT POSE CHANGED, so a clip a reader is playing is
|
|
92
|
+
* not reset by a frame that says nothing new about the pose.
|
|
93
|
+
*/
|
|
94
|
+
apply(armatures: Readonly<Record<string, BlenderArmature>>,
|
|
95
|
+
objectFor: (name: string) => THREE.Object3D | null): void {
|
|
96
|
+
for (const [name, rig] of [...this.#rigs]) {
|
|
97
|
+
const armature = armatures[name];
|
|
98
|
+
const object = objectFor(name);
|
|
99
|
+
if (!armature || !object || object !== rig.object) {
|
|
100
|
+
rig.root.removeFromParent();
|
|
101
|
+
this.#rigs.delete(name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const [name, armature] of Object.entries(armatures)) {
|
|
105
|
+
const object = objectFor(name);
|
|
106
|
+
if (!object) continue;
|
|
107
|
+
const structure = armature.bones.map((bone) => `${bone.name}\u0000${bone.parent ?? ''}`).join('\u0001');
|
|
108
|
+
let rig = this.#rigs.get(name);
|
|
109
|
+
if (rig && rig.structure !== structure) {
|
|
110
|
+
rig.root.removeFromParent();
|
|
111
|
+
rig = undefined;
|
|
112
|
+
}
|
|
113
|
+
if (!rig) {
|
|
114
|
+
const root = new THREE.Group();
|
|
115
|
+
root.name = `${name}:bones`;
|
|
116
|
+
const still = new THREE.Bone();
|
|
117
|
+
still.name = `${name}:still`;
|
|
118
|
+
root.add(still);
|
|
119
|
+
const bones = new Map<string, THREE.Bone>();
|
|
120
|
+
for (const declared of armature.bones) {
|
|
121
|
+
const bone = new THREE.Bone();
|
|
122
|
+
bone.name = declared.name;
|
|
123
|
+
bones.set(declared.name, bone);
|
|
124
|
+
}
|
|
125
|
+
for (const declared of armature.bones) {
|
|
126
|
+
const parent = declared.parent === null ? undefined : bones.get(declared.parent);
|
|
127
|
+
(parent ?? root).add(bones.get(declared.name)!);
|
|
128
|
+
}
|
|
129
|
+
object.add(root);
|
|
130
|
+
rig = { armature: name, object, root, still, bones, structure, pose: new Map(), poseKey: '', action: null };
|
|
131
|
+
this.#rigs.set(name, rig);
|
|
132
|
+
}
|
|
133
|
+
rig.action = armature.action ?? null;
|
|
134
|
+
const poseKey = armature.bones.map((bone) => bone.matrix.flat().join(',')).join(';');
|
|
135
|
+
if (poseKey !== rig.poseKey) {
|
|
136
|
+
rig.poseKey = poseKey;
|
|
137
|
+
rig.pose = new Map(armature.bones.map((bone) => [bone.name, matrixOf(bone.matrix)]));
|
|
138
|
+
RuntimeSkeletons.#pose(rig, armature);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Set every bone's local transform from the frame's armature-space pose matrices. */
|
|
144
|
+
static #pose(rig: Rig, armature: BlenderArmature): void {
|
|
145
|
+
for (const declared of armature.bones) {
|
|
146
|
+
const own = rig.pose.get(declared.name)!;
|
|
147
|
+
const parent = declared.parent === null ? undefined : rig.pose.get(declared.parent);
|
|
148
|
+
const local = parent ? parent.clone().invert().multiply(own) : own.clone();
|
|
149
|
+
const bone = rig.bones.get(declared.name)!;
|
|
150
|
+
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Put Blender's pose back on an armature's bones, after a reader that played a clip lets go. */
|
|
155
|
+
restorePose(armature: string): void {
|
|
156
|
+
const rig = this.#rigs.get(armature);
|
|
157
|
+
if (!rig) return;
|
|
158
|
+
for (const [name, own] of rig.pose) {
|
|
159
|
+
const bone = rig.bones.get(name)!;
|
|
160
|
+
const parentBone = bone.parent instanceof THREE.Bone && bone.parent !== rig.still ? bone.parent : null;
|
|
161
|
+
const parent = parentBone ? rig.pose.get(parentBone.name) : undefined;
|
|
162
|
+
const local = parent ? parent.clone().invert().multiply(own) : own.clone();
|
|
163
|
+
local.decompose(bone.position, bone.quaternion, bone.scale);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A mesh object's skinned form: a `THREE.SkinnedMesh` over its geometry, bound to its armature's
|
|
169
|
+
* rig at the pose the frame was exported in. Null when its geometry carries no skin or its
|
|
170
|
+
* armature is not in the frame (it then draws as the plain, exported mesh: Blender's own pose).
|
|
171
|
+
* The world matrices must be current (`root.updateMatrixWorld(true)`) before this is called.
|
|
172
|
+
*/
|
|
173
|
+
bind(mesh: THREE.SkinnedMesh | THREE.Mesh): THREE.SkinnedMesh | null {
|
|
174
|
+
const skin = mesh.geometry.userData['blenderSkin'] as { armature: string; groups: string[] } | undefined;
|
|
175
|
+
const rig = skin ? this.#rigs.get(skin.armature) : undefined;
|
|
176
|
+
if (!skin || !rig) return null;
|
|
177
|
+
const skinned = (mesh as THREE.SkinnedMesh).isSkinnedMesh ? mesh as THREE.SkinnedMesh
|
|
178
|
+
: new THREE.SkinnedMesh(mesh.geometry, mesh.material);
|
|
179
|
+
if (skinned !== mesh) {
|
|
180
|
+
skinned.name = mesh.name;
|
|
181
|
+
skinned.matrixAutoUpdate = mesh.matrixAutoUpdate;
|
|
182
|
+
skinned.matrix.copy(mesh.matrix);
|
|
183
|
+
skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
|
|
184
|
+
skinned.userData = mesh.userData;
|
|
185
|
+
// A skin moves past its bind bounds: three computes them from the bind pose.
|
|
186
|
+
skinned.frustumCulled = false;
|
|
187
|
+
}
|
|
188
|
+
const armatureWorld = rig.object.matrixWorld;
|
|
189
|
+
const bones = [...skin.groups.map((group) => rig.bones.get(group) ?? rig.still), rig.still];
|
|
190
|
+
const inverses = bones.map((bone) => {
|
|
191
|
+
const pose = bone === rig.still ? undefined : rig.pose.get(bone.name);
|
|
192
|
+
const world = pose ? armatureWorld.clone().multiply(pose) : armatureWorld.clone();
|
|
193
|
+
return world.invert();
|
|
194
|
+
});
|
|
195
|
+
mesh.updateWorldMatrix(true, false);
|
|
196
|
+
// A re-export rebinds the same mesh: its old skeleton's bone texture goes with it.
|
|
197
|
+
(skinned.skeleton as THREE.Skeleton | undefined)?.dispose();
|
|
198
|
+
skinned.bind(new THREE.Skeleton(bones, inverses), mesh.matrixWorld.clone());
|
|
199
|
+
return skinned;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
dispose(): void {
|
|
203
|
+
for (const rig of this.#rigs.values()) rig.root.removeFromParent();
|
|
204
|
+
this.#rigs.clear();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -7,7 +7,8 @@ import * as THREE from 'three';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { onPresenterChange, presenterChanged } from './blender-presenter-change';
|
|
9
9
|
import { bytesFromBase64 } from './blender-base64';
|
|
10
|
-
import { ArmatureOverlay, armatureSchema } from './blender-runtime-armature';
|
|
10
|
+
import { ArmatureOverlay, armatureSchema, type BlenderArmature } from './blender-runtime-armature';
|
|
11
|
+
import { type FrameSkin, RuntimeSkeletons, skinGeometry } from './blender-runtime-skeleton';
|
|
11
12
|
import { UNKNOWN_GEOMETRY, UNKNOWN_IMAGE } from './blender-runtime-frame';
|
|
12
13
|
import {
|
|
13
14
|
drawArraysFromColumns,
|
|
@@ -129,6 +130,38 @@ const attributeSchema = z.discriminatedUnion('type', [
|
|
|
129
130
|
z.object({ ...attributeFields, type: z.literal('BOOLEAN'), data: z.array(z.boolean()) }).strict(),
|
|
130
131
|
z.object({ ...attributeFields, type: z.literal('STRING'), data: z.array(z.string()) }).strict(),
|
|
131
132
|
]);
|
|
133
|
+
/** The door's skin block (`bpy_web_export.cc` `write_skin`): per EXPORTED vertex, four deform
|
|
134
|
+
* groups and their weights, for the armature that deforms the mesh. */
|
|
135
|
+
const frameSkinSchema = z
|
|
136
|
+
.object({
|
|
137
|
+
armature: z.string(),
|
|
138
|
+
groups: z.array(z.string()),
|
|
139
|
+
columns: z.object({ joints: z.instanceof(Uint16Array), weights: z.instanceof(Float32Array) }).strict(),
|
|
140
|
+
})
|
|
141
|
+
.strict();
|
|
142
|
+
/** The same skin per DRAWN vertex, as a copy of a presented frame carries it (`fullFrame`). */
|
|
143
|
+
const drawnSkinSchema = z
|
|
144
|
+
.object({
|
|
145
|
+
armature: z.string(),
|
|
146
|
+
groups: z.array(z.string()),
|
|
147
|
+
index: z.instanceof(Uint16Array),
|
|
148
|
+
weight: z.instanceof(Float32Array),
|
|
149
|
+
})
|
|
150
|
+
.strict();
|
|
151
|
+
/** An exported mesh's geometry with its skin laid over its drawn vertices (`skinGeometry`). */
|
|
152
|
+
function withFrameSkin(geometry: THREE.BufferGeometry, skin: FrameSkin | undefined): THREE.BufferGeometry {
|
|
153
|
+
if (skin) skinGeometry(geometry, skin);
|
|
154
|
+
return geometry;
|
|
155
|
+
}
|
|
156
|
+
/** A drawn mesh's geometry with the per-drawn-vertex skin a copy carried (`fullFrame`). */
|
|
157
|
+
function withDrawnSkin(geometry: THREE.BufferGeometry,
|
|
158
|
+
skin: { armature: string; groups: string[]; index: Uint16Array; weight: Float32Array } | undefined): THREE.BufferGeometry {
|
|
159
|
+
if (!skin) return geometry;
|
|
160
|
+
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(skin.index, 4));
|
|
161
|
+
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(skin.weight, 4));
|
|
162
|
+
geometry.userData['blenderSkin'] = { armature: skin.armature, groups: [...skin.groups] };
|
|
163
|
+
return geometry;
|
|
164
|
+
}
|
|
132
165
|
const drawArraysSchema = z
|
|
133
166
|
.object({
|
|
134
167
|
positions: z.instanceof(Float32Array),
|
|
@@ -149,6 +182,7 @@ const drawArraysSchema = z
|
|
|
149
182
|
),
|
|
150
183
|
/** The draw's vertex map (`DrawArrays.sourceVertex`), kept by a copy that plays (skins bind over it). */
|
|
151
184
|
sourceVertex: z.instanceof(Uint32Array).optional(),
|
|
185
|
+
skin: drawnSkinSchema.optional(),
|
|
152
186
|
hash: z.string(),
|
|
153
187
|
})
|
|
154
188
|
.strict();
|
|
@@ -224,6 +258,7 @@ const columnAttributeSchema = z
|
|
|
224
258
|
.strict();
|
|
225
259
|
const exportedMeshSchema = z
|
|
226
260
|
.object({
|
|
261
|
+
skin: frameSkinSchema.optional(),
|
|
227
262
|
columns: columnsSchema,
|
|
228
263
|
attributes: z.array(columnAttributeSchema),
|
|
229
264
|
activeUv: z.string().nullable(),
|
|
@@ -567,6 +602,13 @@ export const frameSchema = z
|
|
|
567
602
|
* `show_in_front`. `blender-runtime-armature.ts` is the drawing.
|
|
568
603
|
*/
|
|
569
604
|
armatures: z.record(z.string(), armatureSchema).default({}),
|
|
605
|
+
/** Every action and its revision (`bpy_web_export.cc`): a clip is baked again exactly when its
|
|
606
|
+
* action's revision moves. */
|
|
607
|
+
actions: z.record(z.string(), z.number().int().nonnegative()).default({}),
|
|
608
|
+
/** The scene's frame range and rate (`session.py`), beside `frame`. */
|
|
609
|
+
clock: z.object({ start: z.number().int(), end: z.number().int(), fps: z.number().finite().positive() }).strict().optional(),
|
|
610
|
+
/** Actions with no user and no fake user, which the next save drops. */
|
|
611
|
+
unkeptActions: z.array(z.string()).default([]),
|
|
570
612
|
/** The active object's active vertex group, per vertex
|
|
571
613
|
* (`blender-runtime-weights.ts`); null when nothing is painted. */
|
|
572
614
|
weights: weightsSchema.nullable().default(null),
|
|
@@ -663,8 +705,24 @@ export interface PhotographRecord {
|
|
|
663
705
|
render: { width: number; height: number; fov: number; orthographic: boolean };
|
|
664
706
|
}
|
|
665
707
|
|
|
708
|
+
/** A skinned mesh as the plain mesh it is again (its geometry kept), when its skin went away. */
|
|
709
|
+
function plainMesh(skinned: THREE.Mesh): THREE.Mesh {
|
|
710
|
+
const plain = new THREE.Mesh(skinned.geometry, skinned.material);
|
|
711
|
+
plain.name = skinned.name;
|
|
712
|
+
plain.matrixAutoUpdate = skinned.matrixAutoUpdate;
|
|
713
|
+
plain.matrix.copy(skinned.matrix);
|
|
714
|
+
plain.matrix.decompose(plain.position, plain.quaternion, plain.scale);
|
|
715
|
+
plain.userData = skinned.userData;
|
|
716
|
+
return plain;
|
|
717
|
+
}
|
|
718
|
+
|
|
666
719
|
export class BlenderRuntimeView {
|
|
667
720
|
readonly root = new THREE.Group();
|
|
721
|
+
/** Every armature's bones as the frame poses them, and the skins bound to them
|
|
722
|
+
* (`blender-runtime-skeleton.ts`). A reader plays clips on `skeletons.rig(name).bones`. */
|
|
723
|
+
readonly skeletons = new RuntimeSkeletons();
|
|
724
|
+
/** The geometry and rig each skinned mesh was bound with, so it is bound again only when either changes. */
|
|
725
|
+
private skinBinds = new WeakMap<THREE.Object3D, { geometry: THREE.BufferGeometry; rig: object }>();
|
|
668
726
|
private readonly instances = new BlenderRuntimeInstances(this.root);
|
|
669
727
|
private readonly materialRanges = new BlenderMaterialRanges();
|
|
670
728
|
private readonly transparentInstances = new BlenderTransparentInstances(this.root);
|
|
@@ -1061,6 +1119,40 @@ export class BlenderRuntimeView {
|
|
|
1061
1119
|
* needs it — its rows come from the engine by name, and every one that our
|
|
1062
1120
|
* viewport can select has to find its way back to a three object through
|
|
1063
1121
|
* the frame's own table, never through `object.name`. */
|
|
1122
|
+
/**
|
|
1123
|
+
* WHAT THE LAST FRAME SAYS ABOUT ANIMATION, for a reader that plays clips on `skeletons`: the
|
|
1124
|
+
* scene's current frame and clock, every action's revision, each armature's assigned action, the
|
|
1125
|
+
* active object, and which armature a mesh object's skin is bound to. Read off the frame the view
|
|
1126
|
+
* holds, never the engine.
|
|
1127
|
+
*/
|
|
1128
|
+
animationFacts(): {
|
|
1129
|
+
frame: number;
|
|
1130
|
+
clock: { start: number; end: number; fps: number } | null;
|
|
1131
|
+
/** Actions the next save drops: no user and no fake user. */
|
|
1132
|
+
unkept: readonly string[];
|
|
1133
|
+
active: string | null;
|
|
1134
|
+
actions: Readonly<Record<string, number>>;
|
|
1135
|
+
/** Each armature as the frame describes it: bones (rest, channels, constraints, Blender's own
|
|
1136
|
+
* pose), its action and its NLA stack. */
|
|
1137
|
+
armatures: Readonly<Record<string, BlenderArmature>>;
|
|
1138
|
+
skinArmature(object: string): string | null;
|
|
1139
|
+
} {
|
|
1140
|
+
const frame = this.frame;
|
|
1141
|
+
return {
|
|
1142
|
+
frame: frame?.frame ?? 0,
|
|
1143
|
+
clock: frame?.clock ?? null,
|
|
1144
|
+
unkept: frame?.unkeptActions ?? [],
|
|
1145
|
+
active: frame?.active ?? null,
|
|
1146
|
+
actions: frame?.actions ?? {},
|
|
1147
|
+
armatures: frame?.armatures ?? {},
|
|
1148
|
+
skinArmature: (name) => {
|
|
1149
|
+
const object = this.objectForBlenderName(name) as THREE.Mesh | null;
|
|
1150
|
+
const skin = object?.geometry?.userData['blenderSkin'] as { armature: string } | undefined;
|
|
1151
|
+
return skin?.armature ?? null;
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1064
1156
|
objectForBlenderName(name: string): THREE.Object3D | null {
|
|
1065
1157
|
const entry = this.frame?.objects.find((o) => o.name === name);
|
|
1066
1158
|
return entry === undefined ? null : (this.objects.get(entry.id) ?? null);
|
|
@@ -1671,9 +1763,11 @@ export class BlenderRuntimeView {
|
|
|
1671
1763
|
const data = exportedMeshSchema.parse(part.piece);
|
|
1672
1764
|
if (staged.meshes.has(part.mesh)) throw new Error('Duplicate Blender mesh piece');
|
|
1673
1765
|
const signature = `revision:${data.revision}`;
|
|
1674
|
-
|
|
1766
|
+
const geometry = geometryFromDrawArrays(drawArraysFromColumns({
|
|
1675
1767
|
...data.columns, attributes: data.attributes as never, activeUv: data.activeUv, renderUv: data.renderUv,
|
|
1676
|
-
}, signature))
|
|
1768
|
+
}, signature));
|
|
1769
|
+
if (data.skin) skinGeometry(geometry, data.skin);
|
|
1770
|
+
staged.meshes.set(part.mesh, { signature, geometry });
|
|
1677
1771
|
} else if (part.image !== undefined) {
|
|
1678
1772
|
if (staged.images.has(part.image)) throw new Error('Duplicate Blender image piece');
|
|
1679
1773
|
staged.images.set(part.image, frameImageSchema.parse(part.piece));
|
|
@@ -1791,7 +1885,7 @@ export class BlenderRuntimeView {
|
|
|
1791
1885
|
prepared.set(id, {
|
|
1792
1886
|
signature,
|
|
1793
1887
|
geometry: exported
|
|
1794
|
-
? geometryFromDrawArrays(
|
|
1888
|
+
? withFrameSkin(geometryFromDrawArrays(
|
|
1795
1889
|
drawArraysFromColumns(
|
|
1796
1890
|
{
|
|
1797
1891
|
...data.columns,
|
|
@@ -1801,9 +1895,9 @@ export class BlenderRuntimeView {
|
|
|
1801
1895
|
},
|
|
1802
1896
|
signature,
|
|
1803
1897
|
),
|
|
1804
|
-
)
|
|
1898
|
+
), data.skin)
|
|
1805
1899
|
: drawn
|
|
1806
|
-
? geometryFromDrawArrays(data)
|
|
1900
|
+
? withDrawnSkin(geometryFromDrawArrays(data), data.skin)
|
|
1807
1901
|
: drawRuntimeGeometry(data),
|
|
1808
1902
|
});
|
|
1809
1903
|
}
|
|
@@ -2180,6 +2274,26 @@ export class BlenderRuntimeView {
|
|
|
2180
2274
|
this.lights.delete(id);
|
|
2181
2275
|
}
|
|
2182
2276
|
this.root.updateMatrixWorld(true);
|
|
2277
|
+
// THE SKINS, once the graph stands (a bind reads world matrices): every armature's bones as
|
|
2278
|
+
// Blender posed them, and every mesh whose exported geometry carries a skin bound to them. A
|
|
2279
|
+
// mesh is bound again only when its geometry or its rig was rebuilt; one whose geometry lost
|
|
2280
|
+
// its skin draws as the plain mesh it is.
|
|
2281
|
+
const named = new Map(next.objects.map((obj) => [obj.name, this.objects.get(obj.id) ?? null]));
|
|
2282
|
+
this.skeletons.apply(next.armatures, (name) => named.get(name) ?? null);
|
|
2283
|
+
for (const obj of next.objects) {
|
|
2284
|
+
if (obj.mesh === null || obj.volume) continue;
|
|
2285
|
+
const object = this.objects.get(obj.id) as THREE.Mesh | undefined;
|
|
2286
|
+
if (!object?.isMesh) continue;
|
|
2287
|
+
const skin = object.geometry.userData['blenderSkin'] as { armature: string } | undefined;
|
|
2288
|
+
const rig = skin ? this.skeletons.rig(skin.armature) : null;
|
|
2289
|
+
const held = this.skinBinds.get(object);
|
|
2290
|
+
if (rig && held?.geometry === object.geometry && held.rig === rig) continue;
|
|
2291
|
+
const bound = rig ? this.skeletons.bind(object) : null;
|
|
2292
|
+
const replacement = bound ?? ((object as THREE.SkinnedMesh).isSkinnedMesh ? plainMesh(object) : null);
|
|
2293
|
+
if (replacement && replacement !== object) this.replacePresentedObject(object, replacement);
|
|
2294
|
+
if (bound && rig) this.skinBinds.set(bound, { geometry: bound.geometry, rig });
|
|
2295
|
+
}
|
|
2296
|
+
this.root.updateMatrixWorld(true);
|
|
2183
2297
|
// Keep the frame's identity and object table, not its geometry payloads:
|
|
2184
2298
|
// a presented mesh is already resident as its BufferGeometry, and the
|
|
2185
2299
|
// nested number arrays it arrived as cost ~15x their JSON (the blower's
|
|
@@ -2256,7 +2370,8 @@ export class BlenderRuntimeView {
|
|
|
2256
2370
|
// Inspection overlays are not render content. In particular weight-paint
|
|
2257
2371
|
// overlays reference source vertices, which the evaluated draw no longer
|
|
2258
2372
|
// needs and a render snapshot deliberately does not copy.
|
|
2259
|
-
|
|
2373
|
+
// A copy that plays keeps the armatures its skins bind to; a render snapshot draws no bones.
|
|
2374
|
+
const frame: Frame = structuredClone({ ...source, images: {}, armatures: options.sourceVertices ? source.armatures : {}, weights: null });
|
|
2260
2375
|
for (const [id, { signature, geometry }] of this.meshes) {
|
|
2261
2376
|
const attribute = (name: string): Float32Array<ArrayBuffer> | null => {
|
|
2262
2377
|
const value = geometry.getAttribute(name);
|
|
@@ -2298,6 +2413,14 @@ export class BlenderRuntimeView {
|
|
|
2298
2413
|
...(options.sourceVertices && geometry.getAttribute('blenderVertex')
|
|
2299
2414
|
? { sourceVertex: new Uint32Array((geometry.getAttribute('blenderVertex') as THREE.BufferAttribute).array) }
|
|
2300
2415
|
: {}),
|
|
2416
|
+
// A COPY THAT PLAYS keeps its skin too, per drawn vertex, so its own skeletons bind it.
|
|
2417
|
+
...(options.sourceVertices && geometry.userData['blenderSkin'] && geometry.getAttribute('skinIndex')
|
|
2418
|
+
? { skin: {
|
|
2419
|
+
...(geometry.userData['blenderSkin'] as { armature: string; groups: string[] }),
|
|
2420
|
+
index: new Uint16Array((geometry.getAttribute('skinIndex') as THREE.BufferAttribute).array),
|
|
2421
|
+
weight: new Float32Array((geometry.getAttribute('skinWeight') as THREE.BufferAttribute).array),
|
|
2422
|
+
} }
|
|
2423
|
+
: {}),
|
|
2301
2424
|
};
|
|
2302
2425
|
}
|
|
2303
2426
|
for (const [id, { signature }] of this.volumes)
|
|
@@ -2507,6 +2630,8 @@ export class BlenderRuntimeView {
|
|
|
2507
2630
|
}
|
|
2508
2631
|
|
|
2509
2632
|
private clear() {
|
|
2633
|
+
this.skeletons.dispose();
|
|
2634
|
+
this.skinBinds = new WeakMap();
|
|
2510
2635
|
this.worldVisibility.dispose();
|
|
2511
2636
|
this.objectInfoMaterials.clear();
|
|
2512
2637
|
this.materialRanges.clear();
|
package/browser/worker.ts
CHANGED
|
@@ -796,11 +796,6 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
796
796
|
// reason is a measured defect: I4's Collection tab simply did not stand
|
|
797
797
|
// because `collection` crossed `runtime.ts` and `session.py` and never
|
|
798
798
|
// this switch, with no error anywhere.
|
|
799
|
-
case 'rig':
|
|
800
|
-
return pullWork('rig', () => ask({
|
|
801
|
-
op: 'rig',
|
|
802
|
-
...(request.object === undefined ? {} : { object: request.object }),
|
|
803
|
-
}));
|
|
804
799
|
case 'action-clip':
|
|
805
800
|
return ask({
|
|
806
801
|
op: 'action-clip',
|
|
@@ -808,8 +803,6 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
808
803
|
...(request.bake === undefined ? {} : { bake: request.bake }),
|
|
809
804
|
...(request.action === undefined ? {} : { action: request.action }),
|
|
810
805
|
});
|
|
811
|
-
case 'armature-actions':
|
|
812
|
-
return ask({ op: 'armature-actions' });
|
|
813
806
|
case 'outliner-set':
|
|
814
807
|
return ask({
|
|
815
808
|
op: 'outliner-set',
|
package/package.json
CHANGED
package/wasm/BUNDLE.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"blender": "5.2.0 LTS",
|
|
3
|
-
"source": "
|
|
3
|
+
"source": "49cb2710c66715ba61fbf65cc1cc818b1f49d73a",
|
|
4
4
|
"upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
|
|
5
5
|
"sourceRepository": "https://github.com/volter-ai/blender",
|
|
6
|
-
"sourceRecipe": "https://github.com/volter-ai/blender/blob/
|
|
6
|
+
"sourceRecipe": "https://github.com/volter-ai/blender/blob/49cb2710c66715ba61fbf65cc1cc818b1f49d73a/build_files/emscripten/RELEASE.md",
|
|
7
7
|
"factoryEngine": "BLENDER_EEVEE",
|
|
8
8
|
"files": {
|
|
9
9
|
"blender_browser.js": "raw",
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
},
|
|
13
13
|
"rawFiles": {
|
|
14
14
|
"blender_browser.js": {
|
|
15
|
-
"bytes":
|
|
16
|
-
"sha256": "
|
|
15
|
+
"bytes": 408218,
|
|
16
|
+
"sha256": "bcdbcb3ff18110c095b5170d70a0d0f0b33687f86c14c8bc10c979d613ed11c7"
|
|
17
17
|
},
|
|
18
18
|
"blender_browser.wasm": {
|
|
19
|
-
"bytes":
|
|
20
|
-
"sha256": "
|
|
19
|
+
"bytes": 86366810,
|
|
20
|
+
"sha256": "56752d31e837d314eb489ffb031915e003ec9fade944657690ae097ed7c1e947"
|
|
21
21
|
},
|
|
22
22
|
"blender_browser.data": {
|
|
23
23
|
"bytes": 52699590,
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
},
|
|
27
27
|
"shippedFiles": {
|
|
28
28
|
"blender_browser.js": {
|
|
29
|
-
"bytes":
|
|
30
|
-
"sha256": "
|
|
29
|
+
"bytes": 408218,
|
|
30
|
+
"sha256": "bcdbcb3ff18110c095b5170d70a0d0f0b33687f86c14c8bc10c979d613ed11c7"
|
|
31
31
|
},
|
|
32
32
|
"blender_browser.wasm.br": {
|
|
33
|
-
"bytes":
|
|
34
|
-
"sha256": "
|
|
33
|
+
"bytes": 18248590,
|
|
34
|
+
"sha256": "54cb053de1d77030ba19345eaca38d16d1971bbb50f3de6624709f8b2f97f4e9"
|
|
35
35
|
},
|
|
36
36
|
"blender_browser.data.br": {
|
|
37
37
|
"bytes": 8056485,
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
"note": "Emscripten 6.0.5, Release -O3 objects and -O2 link, retained public build recipe and dependency licenses. Scoped render-evaluated export preserves render-only geometry and render-specific modifier results without replacing the viewport export state. Prior geometry identity and memory fixes remain. Full-scene Three versus Cycles rendering parity remains incomplete.",
|
|
46
46
|
"callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
|
|
47
47
|
"engineIds": "The session preserves the compiled native CYCLES engine. VOLTER_THREE selects the explicit raster presenter; BLENDER_EEVEE and BLENDER_WORKBENCH remain presentation engines.",
|
|
48
|
-
"exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available. Optional instance_sources exports stable evaluated collection and particle source meshes, including excluded library meshes and converted curves. Frame-local instance_geometry tokens verify Python placement metadata against the exported geometry and are consumed before presentation. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. Scoped begin_render_export/end_render_export reads the render engine depsgraph and isolates viewport revision, undo, deferred resource and image watcher state. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces.",
|
|
48
|
+
"exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available. Optional instance_sources exports stable evaluated collection and particle source meshes, including excluded library meshes and converted curves. Frame-local instance_geometry tokens verify Python placement metadata against the exported geometry and are consumed before presentation. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. Scoped begin_render_export/end_render_export reads the render engine depsgraph and isolates viewport revision, undo, deferred resource and image watcher state. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. A frame ships each armature-deformed mesh's skin with its geometry (write_skin: four joints and weights per drawn vertex by the armature's deform bones, keyed by skin_digest so it is re-sent only when the binding changes), every action in the file with its revision (actions), and each armature's assigned action.",
|
|
49
49
|
"preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
|
|
50
50
|
}
|