@volter/blender-engine 0.1.132 → 0.1.134
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 +1 -7
- package/browser/rna.ts +0 -73
- package/browser/runtime.ts +3 -12
- package/browser/session.py +68 -36
- package/browser/three/blender-runtime-armature.ts +2 -0
- package/browser/three/blender-runtime-geometry.ts +1 -1
- package/browser/three/blender-runtime-skeleton.ts +204 -0
- package/browser/three/blender-runtime-view.ts +134 -8
- package/browser/worker.ts +1 -5
- 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
package/browser/protocol.ts
CHANGED
|
@@ -68,17 +68,11 @@ export type WorkerRequest =
|
|
|
68
68
|
* Given no `object`, the view layer's active object answers. READ ONLY:
|
|
69
69
|
* pinning, unwrapping and selecting are edits. */
|
|
70
70
|
| { id: number; op: 'uv-layout'; object?: string; uvLayer?: string }
|
|
71
|
-
/** ONE MESH'S SKIN BINDING (`session.py`'s `rna_rig`) — the armature's bones
|
|
72
|
-
* with their rest AND bind matrices, plus up to four weighted influences
|
|
73
|
-
* per Blender vertex as base64 typed-array bytes. The BIND pose is the
|
|
74
|
-
* pose the exported columns were evaluated at, which is what lets three.js
|
|
75
|
-
* own playback without Blender's frame ever moving. READ ONLY. */
|
|
76
|
-
| { id: number; op: 'rig'; object?: string }
|
|
77
71
|
/** ONE ACTION AS A THREE.JS CLIP (`session.py`'s `rna_action_clip`) — per
|
|
78
72
|
* bone, the LOCAL transform at every integer frame of the action's range,
|
|
79
73
|
* plus the scene's frame range/fps and the summary row's key columns.
|
|
80
74
|
* READ ONLY: keying, moving a key and setting a range are edits. */
|
|
81
|
-
| { id: number; op: 'action-clip'; object?: string; bake?: boolean }
|
|
75
|
+
| { id: number; op: 'action-clip'; object?: string; bake?: boolean; action?: string }
|
|
82
76
|
/** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
|
|
83
77
|
* Blender draws on no row of that type is refused by name. */
|
|
84
78
|
| { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
|
package/browser/rna.ts
CHANGED
|
@@ -527,79 +527,6 @@ export interface BlenderUvLayout {
|
|
|
527
527
|
} | null;
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
/**
|
|
531
|
-
* ONE MESH'S SKIN BINDING (`session.py`'s `rna_rig`) — what a
|
|
532
|
-
* `THREE.SkinnedMesh` needs, and nothing Blender does not carry.
|
|
533
|
-
*
|
|
534
|
-
* WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20). Blender
|
|
535
|
-
* holds the animation as DATA; three.js plays it. This door and
|
|
536
|
-
* {@link BlenderActionClip} are the whole of what crosses to make that true —
|
|
537
|
-
* once per rig, rather than a depsgraph evaluation and a re-export per frame.
|
|
538
|
-
*
|
|
539
|
-
* THE BIND POSE IS `pose`, NOT `rest`, and that is the load-bearing fact: the
|
|
540
|
-
* export door runs with `evaluate: True`, so the mesh columns the presenter
|
|
541
|
-
* holds are the mesh ALREADY DEFORMED at Blender's current frame. A skeleton
|
|
542
|
-
* bound in that same pose makes the skinning an identity there, so the picture
|
|
543
|
-
* at the bind frame is exactly the frame Blender presented — and Blender's
|
|
544
|
-
* `frame_current` never has to move again for playback.
|
|
545
|
-
*
|
|
546
|
-
* THE INDICES ARE BLENDER VERTEX INDICES. `DrawArrays.sourceVertex` maps a
|
|
547
|
-
* drawn vertex back to its Blender one, and the presenter expands these arrays
|
|
548
|
-
* through it (`blender-runtime-skin.ts`) exactly as I4's weight ramp does.
|
|
549
|
-
*/
|
|
550
|
-
export interface BlenderRigBone {
|
|
551
|
-
readonly name: string;
|
|
552
|
-
readonly parent: string | null;
|
|
553
|
-
/** `Bone.matrix_local` — the REST pose in the armature object's space,
|
|
554
|
-
* row-major as four rows. Diagnostic here; the bind is `pose`. */
|
|
555
|
-
readonly rest: readonly (readonly [number, number, number, number])[];
|
|
556
|
-
/** `PoseBone.matrix` (`pchan->pose_mat`), the same matrix I4's bone overlay
|
|
557
|
-
* draws — the BIND pose, in the armature object's space. */
|
|
558
|
-
readonly pose: readonly (readonly [number, number, number, number])[];
|
|
559
|
-
readonly length: number;
|
|
560
|
-
readonly deform: boolean;
|
|
561
|
-
readonly connected: boolean;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
export interface BlenderRigBinding {
|
|
565
|
-
readonly object: string | null;
|
|
566
|
-
readonly mesh?: string;
|
|
567
|
-
readonly armature: string | null;
|
|
568
|
-
readonly armatureData?: string;
|
|
569
|
-
readonly bones: readonly BlenderRigBone[];
|
|
570
|
-
readonly vertexCount: number;
|
|
571
|
-
/** `scene.frame_current` when the binding was read — the frame the exported
|
|
572
|
-
* columns, and therefore the bind pose, belong to. */
|
|
573
|
-
readonly frame?: number;
|
|
574
|
-
readonly mode?: string;
|
|
575
|
-
/** Bones whose pose is a SOLVER's rather than their channels'. The clip is
|
|
576
|
-
* derived from the F-Curves alone, so a constrained bone's playback cannot
|
|
577
|
-
* reproduce Blender's own answer; the view names them instead of drifting. */
|
|
578
|
-
readonly constrainedBones?: readonly string[];
|
|
579
|
-
/** Uint16, `vertexCount * 4`. */
|
|
580
|
-
readonly skinIndexBase64?: string;
|
|
581
|
-
/** Float32, `vertexCount * 4`, each vertex's four normalized to sum to one. */
|
|
582
|
-
readonly skinWeightBase64?: string;
|
|
583
|
-
readonly maxInfluences?: number;
|
|
584
|
-
readonly truncatedVertices?: number;
|
|
585
|
-
readonly unweightedVertices?: number;
|
|
586
|
-
readonly unmappedGroups?: readonly string[];
|
|
587
|
-
/** Why there is no binding, when there is none. Never a silent empty. */
|
|
588
|
-
readonly reason?: string;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** EVERY SKIN BINDING THE SCENE NEEDS, which is the PRESENTER's question: a
|
|
592
|
-
* presented frame carries every object at once and the presenter has to know
|
|
593
|
-
* which of its meshes are `THREE.SkinnedMesh`es before it builds them. With
|
|
594
|
-
* `named` set, the list is that one mesh. */
|
|
595
|
-
export interface BlenderRig {
|
|
596
|
-
/** `scene.frame_current` when the bindings were read — the frame the
|
|
597
|
-
* exported columns, and therefore every bind pose here, belong to. */
|
|
598
|
-
readonly frame: number;
|
|
599
|
-
readonly named: string | null;
|
|
600
|
-
readonly rigs: readonly BlenderRigBinding[];
|
|
601
|
-
}
|
|
602
|
-
|
|
603
530
|
/**
|
|
604
531
|
* ONE TRACK of {@link BlenderActionClip}: a bone's LOCAL `position`,
|
|
605
532
|
* `quaternion` or `scale` sampled at every integer frame.
|
package/browser/runtime.ts
CHANGED
|
@@ -21,7 +21,6 @@ import type {
|
|
|
21
21
|
BlenderNodeTree,
|
|
22
22
|
BlenderOutlinerTree,
|
|
23
23
|
BlenderOutlinerWrite,
|
|
24
|
-
BlenderRig,
|
|
25
24
|
BlenderRnaContext,
|
|
26
25
|
BlenderRnaView,
|
|
27
26
|
BlenderRnaWrite,
|
|
@@ -408,30 +407,22 @@ export class BlenderRuntime {
|
|
|
408
407
|
})) as BlenderUvLayout;
|
|
409
408
|
}
|
|
410
409
|
|
|
411
|
-
/** ONE MESH'S SKIN BINDING (`./rna.ts`, `BlenderRig`) — the armature's
|
|
412
|
-
* bones and the per-vertex influences a `THREE.SkinnedMesh` needs. Given no
|
|
413
|
-
* `object`, the view layer's ACTIVE mesh answers. */
|
|
414
|
-
async rig(options?: { object?: string }): Promise<BlenderRig> {
|
|
415
|
-
await this.#ready();
|
|
416
|
-
return (await this.#request({
|
|
417
|
-
op: 'rig',
|
|
418
|
-
...(options?.object === undefined ? {} : { object: options.object }),
|
|
419
|
-
})) as BlenderRig;
|
|
420
|
-
}
|
|
421
410
|
|
|
422
411
|
/** ONE ACTION AS A THREE.JS CLIP (`./rna.ts`, `BlenderActionClip`). `bake:
|
|
423
412
|
* false` answers the header and the summary row's key columns without the
|
|
424
413
|
* sampled tracks, which is what a Timeline that only needs to DRAW asks
|
|
425
414
|
* for. */
|
|
426
|
-
async actionClip(options?: { object?: string; bake?: boolean }): Promise<BlenderActionClip> {
|
|
415
|
+
async actionClip(options?: { object?: string; bake?: boolean; action?: string }): Promise<BlenderActionClip> {
|
|
427
416
|
await this.#ready();
|
|
428
417
|
return (await this.#request({
|
|
429
418
|
op: 'action-clip',
|
|
430
419
|
...(options?.object === undefined ? {} : { object: options.object }),
|
|
431
420
|
...(options?.bake === undefined ? {} : { bake: options.bake }),
|
|
421
|
+
...(options?.action === undefined ? {} : { action: options.action }),
|
|
432
422
|
})) as BlenderActionClip;
|
|
433
423
|
}
|
|
434
424
|
|
|
425
|
+
|
|
435
426
|
/** Write ONE restriction column (the eye, the render camera, a collection's
|
|
436
427
|
* Exclude). A column Blender draws on no row of that type is refused by
|
|
437
428
|
* name rather than written somewhere near it. */
|
package/browser/session.py
CHANGED
|
@@ -1372,6 +1372,12 @@ def _armatures(view_layer):
|
|
|
1372
1372
|
# `ARM_DRAW_MODE_*` switch, `:906-936`).
|
|
1373
1373
|
"mode": obj.mode,
|
|
1374
1374
|
"bones": _armature_bones(obj, locked),
|
|
1375
|
+
# THE ACTION IT PLAYS (`animation_data.action`): what the presenter's mixer plays on
|
|
1376
|
+
# this armature's skeleton, baked through the clip door at the revision the frame's
|
|
1377
|
+
# `actions` names for it.
|
|
1378
|
+
"action": (obj.animation_data.action.name
|
|
1379
|
+
if obj.animation_data is not None and obj.animation_data.action is not None
|
|
1380
|
+
else None),
|
|
1375
1381
|
}
|
|
1376
1382
|
return armatures
|
|
1377
1383
|
|
|
@@ -1642,6 +1648,9 @@ class Session:
|
|
|
1642
1648
|
"scale_length": float(scene.unit_settings.scale_length),
|
|
1643
1649
|
}
|
|
1644
1650
|
frame["armatures"] = _armatures(view_layer)
|
|
1651
|
+
# THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
|
|
1652
|
+
frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
|
|
1653
|
+
"fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
|
|
1645
1654
|
frame["weights"] = _weights(scene, view_layer, frame, self._known)
|
|
1646
1655
|
warnings = list(frame.get("warnings", ()))
|
|
1647
1656
|
warnings.extend(_WARNINGS)
|
|
@@ -4617,31 +4626,6 @@ def _matrix_rows(matrix):
|
|
|
4617
4626
|
return [[float(v) for v in row] for row in matrix]
|
|
4618
4627
|
|
|
4619
4628
|
|
|
4620
|
-
def rna_rig(object_name=None):
|
|
4621
|
-
"""EVERY SKIN BINDING THE SCENE NEEDS -- or one named mesh's.
|
|
4622
|
-
|
|
4623
|
-
A LIST, not a singleton, and that is what the PRESENTER's question is: a
|
|
4624
|
-
presented frame carries every object at once, and the presenter has to know
|
|
4625
|
-
which of its meshes are `THREE.SkinnedMesh`es before it builds them. Asking
|
|
4626
|
-
per object would be one round trip per mesh; asking by NAME stays available
|
|
4627
|
-
for a `cyclotron eval` that wants to read one.
|
|
4628
|
-
"""
|
|
4629
|
-
scene_frame = int(bpy.context.scene.frame_current)
|
|
4630
|
-
if object_name:
|
|
4631
|
-
obj = bpy.data.objects.get(object_name)
|
|
4632
|
-
if obj is None:
|
|
4633
|
-
raise ValueError("The engine holds no object named %r" % (object_name,))
|
|
4634
|
-
subjects = [obj]
|
|
4635
|
-
else:
|
|
4636
|
-
subjects = [obj for obj in bpy.context.view_layer.objects
|
|
4637
|
-
if obj.type == "MESH" and obj.data is not None
|
|
4638
|
-
and obj.find_armature() is not None]
|
|
4639
|
-
rigs = []
|
|
4640
|
-
for obj in subjects:
|
|
4641
|
-
_asked({"checkpoint": "rig-object"})
|
|
4642
|
-
rigs.append(_rig_of(obj))
|
|
4643
|
-
return {"frame": scene_frame, "named": object_name, "rigs": rigs}
|
|
4644
|
-
|
|
4645
4629
|
|
|
4646
4630
|
def _rig_of(obj):
|
|
4647
4631
|
"""ONE MESH'S SKIN BINDING: the armature's bones, and up to four weighted
|
|
@@ -4841,7 +4825,7 @@ def _summary_objects():
|
|
|
4841
4825
|
return out
|
|
4842
4826
|
|
|
4843
4827
|
|
|
4844
|
-
def rna_action_clip(object_name=None, bake=True):
|
|
4828
|
+
def rna_action_clip(object_name=None, bake=True, action_name=None):
|
|
4845
4829
|
"""ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
|
|
4846
4830
|
every integer frame of the action's own range.
|
|
4847
4831
|
|
|
@@ -4910,13 +4894,32 @@ def rna_action_clip(object_name=None, bake=True):
|
|
|
4910
4894
|
# when the subject below turns out to be nothing at all.
|
|
4911
4895
|
"summary": _summary_objects(),
|
|
4912
4896
|
}
|
|
4913
|
-
|
|
4897
|
+
adt = arm_obj.animation_data if arm_obj is not None else None
|
|
4898
|
+
assigned = adt.action if adt is not None else None
|
|
4899
|
+
if arm_obj is None or (assigned is None and not action_name):
|
|
4914
4900
|
header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
|
|
4915
4901
|
"and an empty summary row, which is Blender's own empty state.")
|
|
4916
4902
|
return header
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4903
|
+
action = assigned
|
|
4904
|
+
slot = getattr(adt, "action_slot", None) if adt is not None else None
|
|
4905
|
+
if action_name and (assigned is None or action_name != assigned.name):
|
|
4906
|
+
# A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
|
|
4907
|
+
# baked against this armature WITHOUT assigning it, so the file is untouched. Its curves
|
|
4908
|
+
# are its first channelbag's (no slot of this object chooses among them).
|
|
4909
|
+
action = bpy.data.actions.get(action_name)
|
|
4910
|
+
if action is None:
|
|
4911
|
+
raise ValueError("The engine holds no action named %r" % (action_name,))
|
|
4912
|
+
# THE SLOT FOR THIS ARMATURE, as Blender would pick it on assignment: the one whose
|
|
4913
|
+
# identifier names this object (`OB<name>`), else the only object slot. A layered action
|
|
4914
|
+
# with several object slots and none for this armature has no right answer; its first
|
|
4915
|
+
# object slot is used and said in the reason.
|
|
4916
|
+
slots = [one for one in getattr(action, "slots", ()) if getattr(one, "target_id_type", "OBJECT") == "OBJECT"]
|
|
4917
|
+
slot = next((one for one in slots if getattr(one, "identifier", "") == "OB" + arm_obj.name), None)
|
|
4918
|
+
if slot is None and slots:
|
|
4919
|
+
slot = slots[0]
|
|
4920
|
+
if len(slots) > 1:
|
|
4921
|
+
header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
|
|
4922
|
+
% (action.name, len(slots), arm_obj.name, getattr(slot, "identifier", "?")))
|
|
4920
4923
|
curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
|
|
4921
4924
|
header.update({
|
|
4922
4925
|
"action": action.name,
|
|
@@ -5185,13 +5188,44 @@ def rna_outliner(selected=None):
|
|
|
5185
5188
|
parent_row["children"].append(duplicate)
|
|
5186
5189
|
mine.append([duplicate, parent_row, parent_collection])
|
|
5187
5190
|
|
|
5191
|
+
# THE SELECTION KEEPS ITS ROWS PAST THE PAGE. A row is how the editor
|
|
5192
|
+
# addresses an object -- the viewport's outline and gizmo, the Properties
|
|
5193
|
+
# rail -- so a selected or active object, the caller's or the engine's, keeps
|
|
5194
|
+
# its row and every row above it however far down a long list it sits.
|
|
5195
|
+
# BOUNDED LIKE THE PAGE ITSELF: the active object always, then at most one
|
|
5196
|
+
# page of the selection, so selecting everything cannot turn one read back
|
|
5197
|
+
# into the whole tree the page exists to keep out (Canyon Comet, 1,493
|
|
5198
|
+
# objects selected: 1.77 MB a read uncapped, 142 KB capped).
|
|
5199
|
+
wanted = [active] if active is not None else []
|
|
5200
|
+
wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
|
|
5201
|
+
wanted += sorted(chosen)
|
|
5202
|
+
kept = set()
|
|
5203
|
+
for name in wanted:
|
|
5204
|
+
if len(kept) > _OUTLINER_PAGE:
|
|
5205
|
+
break
|
|
5206
|
+
kept.add(name)
|
|
5207
|
+
holding = set()
|
|
5208
|
+
|
|
5209
|
+
def hold(row):
|
|
5210
|
+
inside = row.get("struct") == "Object" and row.get("object") in kept
|
|
5211
|
+
for child in row.get("children", []):
|
|
5212
|
+
inside = hold(child) or inside
|
|
5213
|
+
if inside:
|
|
5214
|
+
holding.add(id(row))
|
|
5215
|
+
return inside
|
|
5216
|
+
|
|
5217
|
+
hold(scene_collection)
|
|
5218
|
+
|
|
5188
5219
|
# Cap the hierarchy's visible child lists, not the collection's flat input.
|
|
5189
5220
|
# Every parent participates in folding even when its child list needs a page.
|
|
5190
5221
|
def page_tree(row):
|
|
5191
|
-
|
|
5222
|
+
members = row.get("children", [])
|
|
5223
|
+
children, more = _outliner_page(members)
|
|
5224
|
+
beyond = [child for child in members[len(children):] if id(child) in holding]
|
|
5225
|
+
children = children + beyond
|
|
5192
5226
|
row["children"] = children
|
|
5193
|
-
if more:
|
|
5194
|
-
row["more"] = more
|
|
5227
|
+
if more - len(beyond):
|
|
5228
|
+
row["more"] = more - len(beyond)
|
|
5195
5229
|
for child in children:
|
|
5196
5230
|
page_tree(child)
|
|
5197
5231
|
|
|
@@ -5448,10 +5482,8 @@ def _dispatch(request):
|
|
|
5448
5482
|
# THE RIG AND CLIP DOORS (the Timeline): both READS, so neither presents
|
|
5449
5483
|
# and neither owes a derivation. There is deliberately no writer beside
|
|
5450
5484
|
# them -- keying, moving a key and setting a range are edits.
|
|
5451
|
-
if op == "rig":
|
|
5452
|
-
return rna_rig(request.get("object"))
|
|
5453
5485
|
if op == "action-clip":
|
|
5454
|
-
return rna_action_clip(request.get("object"), request.get("bake", True))
|
|
5486
|
+
return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"))
|
|
5455
5487
|
if op == "outliner":
|
|
5456
5488
|
return rna_outliner(request.get("selected"))
|
|
5457
5489
|
if op == "outliner-set":
|
|
@@ -61,6 +61,8 @@ export const armatureSchema = z
|
|
|
61
61
|
showInFront: z.boolean(),
|
|
62
62
|
mode: z.string(),
|
|
63
63
|
bones: z.array(boneSchema),
|
|
64
|
+
/** `animation_data.action`, the action it plays (`session.py` `_armatures`). */
|
|
65
|
+
action: z.string().nullable().optional(),
|
|
64
66
|
})
|
|
65
67
|
.strict();
|
|
66
68
|
|
|
@@ -277,7 +277,7 @@ export function graphAttributeName(name: string): string {
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
export function geometryFromDrawArrays(
|
|
280
|
-
arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array },
|
|
280
|
+
arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array | undefined },
|
|
281
281
|
): THREE.BufferGeometry {
|
|
282
282
|
const geometry = new THREE.BufferGeometry();
|
|
283
283
|
geometry.setAttribute('position', new THREE.BufferAttribute(arrays.positions, 3));
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
skinned.bind(new THREE.Skeleton(bones, inverses), mesh.matrixWorld.clone());
|
|
197
|
+
return skinned;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
dispose(): void {
|
|
201
|
+
for (const rig of this.#rigs.values()) rig.root.removeFromParent();
|
|
202
|
+
this.#rigs.clear();
|
|
203
|
+
}
|
|
204
|
+
}
|