@volter/blender-engine 0.1.133 → 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 +0 -9
- package/browser/rna.ts +0 -73
- package/browser/runtime.ts +0 -16
- package/browser/session.py +43 -76
- package/browser/three/blender-runtime-armature.ts +2 -0
- package/browser/three/blender-runtime-skeleton.ts +204 -0
- package/browser/three/blender-runtime-view.ts +124 -6
- 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
package/browser/protocol.ts
CHANGED
|
@@ -68,20 +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
75
|
| { id: number; op: 'action-clip'; object?: string; bake?: boolean; action?: string }
|
|
82
|
-
/** EVERY ARMATURE AND THE ACTIONS THAT CAN PLAY ON IT (`session.py`'s `rna_armature_actions`):
|
|
83
|
-
* each action that animates one of its pose bones, assigned or not. READ ONLY. */
|
|
84
|
-
| { id: number; op: 'armature-actions' }
|
|
85
76
|
/** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
|
|
86
77
|
* Blender draws on no row of that type is refused by name. */
|
|
87
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,16 +407,6 @@ 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
|
|
@@ -433,11 +422,6 @@ export class BlenderRuntime {
|
|
|
433
422
|
})) as BlenderActionClip;
|
|
434
423
|
}
|
|
435
424
|
|
|
436
|
-
/** Every armature and the actions that can play on it, assigned or not (a game's clip library). */
|
|
437
|
-
async armatureActions(): Promise<{ armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } }> {
|
|
438
|
-
await this.#ready();
|
|
439
|
-
return (await this.#request({ op: 'armature-actions' })) as { armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } };
|
|
440
|
-
}
|
|
441
425
|
|
|
442
426
|
/** Write ONE restriction column (the eye, the render camera, a collection's
|
|
443
427
|
* Exclude). A column Blender draws on no row of that type is refused by
|
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,50 +4825,6 @@ def _summary_objects():
|
|
|
4841
4825
|
return out
|
|
4842
4826
|
|
|
4843
4827
|
|
|
4844
|
-
def rna_armature_actions():
|
|
4845
|
-
"""EVERY ARMATURE AND THE ACTIONS THAT CAN PLAY ON IT -- what a game picks its clips from.
|
|
4846
|
-
|
|
4847
|
-
An action can play on an armature when it animates at least one of that armature's pose bones
|
|
4848
|
-
(`pose.bones["<name>"]...` on a bone the armature has). Read off each action's F-Curves through
|
|
4849
|
-
`_action_channelbag`, every channelbag, so an action assigned to nothing (a clip library: Idle,
|
|
4850
|
-
Run, Attack beside each other in the file) is offered as well as the one each armature plays now.
|
|
4851
|
-
READ ONLY."""
|
|
4852
|
-
out = []
|
|
4853
|
-
for arm in bpy.data.objects:
|
|
4854
|
-
if arm.type != "ARMATURE" or arm.pose is None:
|
|
4855
|
-
continue
|
|
4856
|
-
bones = {pchan.name for pchan in arm.pose.bones}
|
|
4857
|
-
current = arm.animation_data.action.name if (arm.animation_data and arm.animation_data.action) else None
|
|
4858
|
-
names = []
|
|
4859
|
-
keys, total = 0, 0.0
|
|
4860
|
-
for action in bpy.data.actions:
|
|
4861
|
-
curves = []
|
|
4862
|
-
legacy = getattr(action, "fcurves", None)
|
|
4863
|
-
if legacy is not None:
|
|
4864
|
-
curves = list(legacy)
|
|
4865
|
-
else:
|
|
4866
|
-
for layer in getattr(action, "layers", ()):
|
|
4867
|
-
for strip in getattr(layer, "strips", ()):
|
|
4868
|
-
for bag in (getattr(strip, "channelbags", None) or ()):
|
|
4869
|
-
curves.extend(bag.fcurves)
|
|
4870
|
-
if any((m := _BONE_PATH.match(fc.data_path)) is not None and m.group(1) in bones for fc in curves):
|
|
4871
|
-
names.append(action.name)
|
|
4872
|
-
# A CHEAP SIGNATURE of the keys, so a reader can tell a key edit from no change
|
|
4873
|
-
# without baking: the count and the sum of every key's frame and value.
|
|
4874
|
-
for fc in curves:
|
|
4875
|
-
for key in fc.keyframe_points:
|
|
4876
|
-
keys += 1
|
|
4877
|
-
total += float(key.co[0]) * 7.0 + float(key.co[1])
|
|
4878
|
-
out.append({"armature": arm.name, "current": current, "actions": sorted(names),
|
|
4879
|
-
"keys": "%d:%.6f" % (keys, total)})
|
|
4880
|
-
scene = bpy.context.scene
|
|
4881
|
-
# THE SCENE'S CLOCK TOO: a reader that skips a re-bind on an unchanged listing must still see
|
|
4882
|
-
# the playhead and range move when an agent sets them.
|
|
4883
|
-
return {"armatures": out, "scene": {"frameCurrent": int(scene.frame_current),
|
|
4884
|
-
"frameStart": int(scene.frame_start), "frameEnd": int(scene.frame_end),
|
|
4885
|
-
"fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}}
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
4828
|
def rna_action_clip(object_name=None, bake=True, action_name=None):
|
|
4889
4829
|
"""ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
|
|
4890
4830
|
every integer frame of the action's own range.
|
|
@@ -5248,13 +5188,44 @@ def rna_outliner(selected=None):
|
|
|
5248
5188
|
parent_row["children"].append(duplicate)
|
|
5249
5189
|
mine.append([duplicate, parent_row, parent_collection])
|
|
5250
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
|
+
|
|
5251
5219
|
# Cap the hierarchy's visible child lists, not the collection's flat input.
|
|
5252
5220
|
# Every parent participates in folding even when its child list needs a page.
|
|
5253
5221
|
def page_tree(row):
|
|
5254
|
-
|
|
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
|
|
5255
5226
|
row["children"] = children
|
|
5256
|
-
if more:
|
|
5257
|
-
row["more"] = more
|
|
5227
|
+
if more - len(beyond):
|
|
5228
|
+
row["more"] = more - len(beyond)
|
|
5258
5229
|
for child in children:
|
|
5259
5230
|
page_tree(child)
|
|
5260
5231
|
|
|
@@ -5511,12 +5482,8 @@ def _dispatch(request):
|
|
|
5511
5482
|
# THE RIG AND CLIP DOORS (the Timeline): both READS, so neither presents
|
|
5512
5483
|
# and neither owes a derivation. There is deliberately no writer beside
|
|
5513
5484
|
# them -- keying, moving a key and setting a range are edits.
|
|
5514
|
-
if op == "rig":
|
|
5515
|
-
return rna_rig(request.get("object"))
|
|
5516
5485
|
if op == "action-clip":
|
|
5517
5486
|
return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"))
|
|
5518
|
-
if op == "armature-actions":
|
|
5519
|
-
return rna_armature_actions()
|
|
5520
5487
|
if op == "outliner":
|
|
5521
5488
|
return rna_outliner(request.get("selected"))
|
|
5522
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
|
|
|
@@ -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
|
+
}
|