@volter/blender-engine 0.1.131 → 0.1.133
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/blender-engine.mts +1 -0
- package/browser/protocol.ts +7 -1
- package/browser/runtime.ts +13 -6
- package/browser/session.py +105 -22
- package/browser/three/blender-graph-material.ts +2 -1
- package/browser/three/blender-island-random.ts +41 -0
- package/browser/three/blender-node-graph.ts +8 -0
- package/browser/three/blender-runtime-geometry.ts +23 -6
- package/browser/three/blender-runtime-view.ts +44 -2
- package/browser/three/presenter.ts +17 -0
- package/browser/volter_three.py +12 -6
- package/browser/worker.ts +8 -4
- package/package.json +1 -1
- package/wasm/BUNDLE.json +8 -8
- package/wasm/blender_browser.wasm.br +0 -0
package/browser/protocol.ts
CHANGED
|
@@ -78,7 +78,10 @@ export type WorkerRequest =
|
|
|
78
78
|
* bone, the LOCAL transform at every integer frame of the action's range,
|
|
79
79
|
* plus the scene's frame range/fps and the summary row's key columns.
|
|
80
80
|
* READ ONLY: keying, moving a key and setting a range are edits. */
|
|
81
|
-
| { id: number; op: 'action-clip'; object?: string; bake?: boolean }
|
|
81
|
+
| { 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' }
|
|
82
85
|
/** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
|
|
83
86
|
* Blender draws on no row of that type is refused by name. */
|
|
84
87
|
| { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
|
|
@@ -179,6 +182,9 @@ export interface FileListSnapshot {
|
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
export interface CaptureRequest {
|
|
185
|
+
/** Geometry was exported from the render engine's RENDER depsgraph. It belongs
|
|
186
|
+
* to this capture alone and must never replace the live viewport frame. */
|
|
187
|
+
evaluation?: 'render';
|
|
182
188
|
/** A viewport screenshot's bound, the longer side of a square frame.
|
|
183
189
|
* Absent on a render, which states its exact pixel dimensions instead. */
|
|
184
190
|
size?: number;
|
package/browser/runtime.ts
CHANGED
|
@@ -33,7 +33,7 @@ type Request = DistributiveOmit<WorkerRequest, 'id'>;
|
|
|
33
33
|
|
|
34
34
|
export interface BlenderRuntimeOptions {
|
|
35
35
|
/** Stage one verified mesh/image without replacing the displayed frame. */
|
|
36
|
-
stage?(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
|
|
36
|
+
stage?(part: { session: string; revision: number; evaluation?: 'render'; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
|
|
37
37
|
/** Operation-only diagnostics, never request payloads. The optional observer
|
|
38
38
|
* must not affect work, even if it throws. End calls balance overlapping work. */
|
|
39
39
|
work?(label: string): () => void;
|
|
@@ -423,15 +423,22 @@ export class BlenderRuntime {
|
|
|
423
423
|
* false` answers the header and the summary row's key columns without the
|
|
424
424
|
* sampled tracks, which is what a Timeline that only needs to DRAW asks
|
|
425
425
|
* for. */
|
|
426
|
-
async actionClip(options?: { object?: string; bake?: boolean }): Promise<BlenderActionClip> {
|
|
426
|
+
async actionClip(options?: { object?: string; bake?: boolean; action?: string }): Promise<BlenderActionClip> {
|
|
427
427
|
await this.#ready();
|
|
428
428
|
return (await this.#request({
|
|
429
429
|
op: 'action-clip',
|
|
430
430
|
...(options?.object === undefined ? {} : { object: options.object }),
|
|
431
431
|
...(options?.bake === undefined ? {} : { bake: options.bake }),
|
|
432
|
+
...(options?.action === undefined ? {} : { action: options.action }),
|
|
432
433
|
})) as BlenderActionClip;
|
|
433
434
|
}
|
|
434
435
|
|
|
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
|
+
|
|
435
442
|
/** Write ONE restriction column (the eye, the render camera, a collection's
|
|
436
443
|
* Exclude). A column Blender draws on no row of that type is refused by
|
|
437
444
|
* name rather than written somewhere near it. */
|
|
@@ -645,7 +652,7 @@ export class BlenderRuntime {
|
|
|
645
652
|
}
|
|
646
653
|
|
|
647
654
|
readonly #frameStream = new FrameStreamReader();
|
|
648
|
-
#stagedIdentity: { session: string; revision: number } | null = null;
|
|
655
|
+
#stagedIdentity: { session: string; revision: number; evaluation?: 'render' } | null = null;
|
|
649
656
|
#discardStagedFrame(): void {
|
|
650
657
|
this.#frameStream.reset();
|
|
651
658
|
const part = this.#stagedIdentity; this.#stagedIdentity = null;
|
|
@@ -661,13 +668,13 @@ export class BlenderRuntime {
|
|
|
661
668
|
try {
|
|
662
669
|
const decoded = await this.#frameStream.accept(reply.chunk);
|
|
663
670
|
if (decoded) {
|
|
664
|
-
const value = decoded.value as ({ op: 'stage'; session: string; revision: number } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
|
|
671
|
+
const value = decoded.value as ({ op: 'stage'; session: string; revision: number; evaluation?: 'render' } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
|
|
665
672
|
if (value.op === 'present') {
|
|
666
673
|
await this.#receive({ ...value, id: reply.id } as WorkerReply);
|
|
667
674
|
return;
|
|
668
675
|
}
|
|
669
676
|
if (value.op !== 'stage' || !this.#options.stage) throw new Error('The presenter does not support staged Blender frames');
|
|
670
|
-
this.#stagedIdentity = { session: value.session, revision: value.revision };
|
|
677
|
+
this.#stagedIdentity = { session: value.session, revision: value.revision, ...(value.evaluation ? { evaluation: value.evaluation } : {}) };
|
|
671
678
|
await this.#options.stage(value);
|
|
672
679
|
}
|
|
673
680
|
this.#worker.postMessage({ op: 'present-result', id: reply.id } satisfies WorkerRequest);
|
|
@@ -708,7 +715,7 @@ export class BlenderRuntime {
|
|
|
708
715
|
// true the moment the frame arrives. A presenter that refuses the frame
|
|
709
716
|
// has not un-modelled it.
|
|
710
717
|
const frame = reply.frame as { session?: unknown; revision?: unknown } | null;
|
|
711
|
-
if (typeof frame?.session === 'string' && typeof frame.revision === 'number')
|
|
718
|
+
if (reply.capture?.evaluation !== 'render' && typeof frame?.session === 'string' && typeof frame.revision === 'number')
|
|
712
719
|
this.#presented = { session: frame.session, revision: frame.revision };
|
|
713
720
|
const end = this.#work('presenting worker frame');
|
|
714
721
|
try {
|
package/browser/session.py
CHANGED
|
@@ -1085,7 +1085,10 @@ def material_graph(material):
|
|
|
1085
1085
|
if surface is None or surface.bl_idname not in _GRAPH_SURFACES:
|
|
1086
1086
|
return None
|
|
1087
1087
|
sockets = [surface.inputs[name] for name in _GRAPH_SURFACES[surface.bl_idname]]
|
|
1088
|
-
|
|
1088
|
+
# Emission reflects no light even when its inputs are constants. The
|
|
1089
|
+
# reduced MeshPhysicalMaterial otherwise adds diffuse/specular response
|
|
1090
|
+
# to it (including a black Emission used as an unlit control).
|
|
1091
|
+
if surface.bl_idname != "ShaderNodeEmission" and not any(_links_into(s) for s in sockets):
|
|
1089
1092
|
return None
|
|
1090
1093
|
# The door is quiet about a material whose graph ships, so a linked input
|
|
1091
1094
|
# the graph does not carry is named here.
|
|
@@ -1480,7 +1483,7 @@ class Session:
|
|
|
1480
1483
|
|
|
1481
1484
|
# -- the export
|
|
1482
1485
|
|
|
1483
|
-
def _export(self):
|
|
1486
|
+
def _export(self, depsgraph=None):
|
|
1484
1487
|
"""THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
|
|
1485
1488
|
|
|
1486
1489
|
One call evaluates the scene, walks the depsgraph's own update record
|
|
@@ -1497,14 +1500,14 @@ class Session:
|
|
|
1497
1500
|
bones and the active vertex group's weights (`_armatures`,
|
|
1498
1501
|
`_weights`). All per-scene, none of them geometry.
|
|
1499
1502
|
"""
|
|
1500
|
-
scene = bpy.context.scene
|
|
1503
|
+
scene = depsgraph.scene if depsgraph is not None else bpy.context.scene
|
|
1501
1504
|
graphs = material_graphs(scene)
|
|
1502
1505
|
_mark("export:graphs")
|
|
1503
1506
|
# An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
|
|
1504
1507
|
# (`overlay_empty.hh` `image_sync`).
|
|
1505
1508
|
empty_images = {obj.data.name for obj in scene.objects
|
|
1506
1509
|
if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
|
|
1507
|
-
options = {"session": self.session, "evaluate":
|
|
1510
|
+
options = {"session": self.session, "evaluate": depsgraph is None, "known": self._known, "instance_sources": True,
|
|
1508
1511
|
"graph_materials": sorted(graphs),
|
|
1509
1512
|
"graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
|
|
1510
1513
|
"graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
|
|
@@ -1563,7 +1566,7 @@ class Session:
|
|
|
1563
1566
|
# A MANUAL TEXTURE SPACE, which Generated coordinates map through; the
|
|
1564
1567
|
# automatic one is the evaluated bounds the presenter already holds.
|
|
1565
1568
|
if graphs:
|
|
1566
|
-
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
1569
|
+
depsgraph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1567
1570
|
# BY NAME THROUGH ONE MAP: `scene.objects.get` searches the collection linearly, so a
|
|
1568
1571
|
# lookup per row was quadratic -- measured on the Stoneguard file (11,702 rows): 11,343
|
|
1569
1572
|
# calls, 14.4 s of every present.
|
|
@@ -1598,7 +1601,8 @@ class Session:
|
|
|
1598
1601
|
"roughness": float(material.roughness),
|
|
1599
1602
|
"metallic": float(material.metallic),
|
|
1600
1603
|
}
|
|
1601
|
-
|
|
1604
|
+
graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1605
|
+
frame["instances"] = _depsgraph_placements(frame, graph)
|
|
1602
1606
|
frame["world"] = draw_world(scene)
|
|
1603
1607
|
frame["cameras"] = {
|
|
1604
1608
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
@@ -1610,7 +1614,7 @@ class Session:
|
|
|
1610
1614
|
render = scene.render
|
|
1611
1615
|
frame["camera_view"] = {
|
|
1612
1616
|
"scene_camera": scene.camera.name if scene.camera is not None else None,
|
|
1613
|
-
"view_layer_cameras": [o.name for o in
|
|
1617
|
+
"view_layer_cameras": [o.name for o in graph.view_layer.objects if o.type == "CAMERA"],
|
|
1614
1618
|
"aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
|
|
1615
1619
|
}
|
|
1616
1620
|
# THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
|
|
@@ -1620,7 +1624,7 @@ class Session:
|
|
|
1620
1624
|
frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
|
|
1621
1625
|
# THE OVERLAYS, after the door's frame stands: `_weights` reads the
|
|
1622
1626
|
# mesh's own revision out of it (see there), so it cannot run before.
|
|
1623
|
-
view_layer =
|
|
1627
|
+
view_layer = graph.view_layer
|
|
1624
1628
|
# THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
|
|
1625
1629
|
# both from the scene, so they come from here (`_subject_line`).
|
|
1626
1630
|
frame["subject"] = _subject_line(scene, view_layer)
|
|
@@ -1665,12 +1669,12 @@ class Session:
|
|
|
1665
1669
|
self._known.clear()
|
|
1666
1670
|
return self._present(capture)
|
|
1667
1671
|
|
|
1668
|
-
def _present(self, capture=None):
|
|
1672
|
+
def _present(self, capture=None, depsgraph=None):
|
|
1669
1673
|
known = dict(self._known)
|
|
1670
1674
|
_mark("present:before")
|
|
1671
|
-
frame = self._export()
|
|
1675
|
+
frame = self._export(depsgraph)
|
|
1672
1676
|
_mark("present:exported")
|
|
1673
|
-
pulled = self._pull(frame)
|
|
1677
|
+
pulled = self._pull(frame, render=depsgraph is not None)
|
|
1674
1678
|
_mark("present:pulled")
|
|
1675
1679
|
self._drop_unreachable_textures(frame)
|
|
1676
1680
|
# WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
|
|
@@ -1729,7 +1733,7 @@ class Session:
|
|
|
1729
1733
|
self._reconcile_with_presenter(answer, frame)
|
|
1730
1734
|
return answer
|
|
1731
1735
|
|
|
1732
|
-
def _pull(self, frame):
|
|
1736
|
+
def _pull(self, frame, render=False):
|
|
1733
1737
|
"""THE PULL, one datablock at a time, as Hydra's render delegate pulls each prim's data
|
|
1734
1738
|
during Sync after the scene index's notice (`HydraSceneIndex::populate`). The door named
|
|
1735
1739
|
the changed meshes and pictures as deferred; the worker first copies the frame's own
|
|
@@ -1743,9 +1747,9 @@ class Session:
|
|
|
1743
1747
|
anything was pulled; the worker then presents the frame with its pieces."""
|
|
1744
1748
|
meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
|
|
1745
1749
|
images = [name for name, image in frame["images"].items() if image.get("deferred")]
|
|
1746
|
-
if not meshes and not images:
|
|
1750
|
+
if not meshes and not images and not render:
|
|
1747
1751
|
return False
|
|
1748
|
-
_asked({"hold": frame})
|
|
1752
|
+
_asked({"hold": frame, **({"evaluation": "render"} if render else {})})
|
|
1749
1753
|
options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
|
|
1750
1754
|
for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
|
|
1751
1755
|
("image", images, _blender_web.export_image)):
|
|
@@ -1765,6 +1769,20 @@ class Session:
|
|
|
1765
1769
|
_asked({kind: key, "piece": piece[kind]})
|
|
1766
1770
|
return True
|
|
1767
1771
|
|
|
1772
|
+
def photograph(self, depsgraph, capture):
|
|
1773
|
+
"""Render evaluation owns different geometry and visibility than a viewport.
|
|
1774
|
+
Keep its native revisions, deferred pulls and presenter separate for the
|
|
1775
|
+
entire blocking capture, and restore the authoring export in finally."""
|
|
1776
|
+
if not hasattr(_blender_web, "begin_render_export"):
|
|
1777
|
+
raise RuntimeError("This Blender artifact has no render-evaluated export; update the artifact and its corresponding source")
|
|
1778
|
+
_blender_web.begin_render_export(depsgraph)
|
|
1779
|
+
try:
|
|
1780
|
+
rendering = Session()
|
|
1781
|
+
rendering.session = "%s:render:%d" % (self.session, time.time_ns())
|
|
1782
|
+
return rendering._present({**capture, "evaluation": "render"}, depsgraph)
|
|
1783
|
+
finally:
|
|
1784
|
+
_blender_web.end_render_export()
|
|
1785
|
+
|
|
1768
1786
|
def _send_encoded_image(self, key, deferred):
|
|
1769
1787
|
"""A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
|
|
1770
1788
|
|
|
@@ -2336,8 +2354,8 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2336
2354
|
# result is what a render leaves, and it holds scene-linear pixels.
|
|
2337
2355
|
if linear:
|
|
2338
2356
|
render["linear"] = True
|
|
2339
|
-
answer = SESSION.
|
|
2340
|
-
{"position": position, "target": target, "up": up, "render": render}
|
|
2357
|
+
answer = SESSION.photograph(
|
|
2358
|
+
depsgraph, {"position": position, "target": target, "up": up, "render": render}
|
|
2341
2359
|
)
|
|
2342
2360
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
2343
2361
|
raise RuntimeError("The renderer did not answer with a photograph")
|
|
@@ -4823,7 +4841,51 @@ def _summary_objects():
|
|
|
4823
4841
|
return out
|
|
4824
4842
|
|
|
4825
4843
|
|
|
4826
|
-
def
|
|
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
|
+
def rna_action_clip(object_name=None, bake=True, action_name=None):
|
|
4827
4889
|
"""ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
|
|
4828
4890
|
every integer frame of the action's own range.
|
|
4829
4891
|
|
|
@@ -4892,13 +4954,32 @@ def rna_action_clip(object_name=None, bake=True):
|
|
|
4892
4954
|
# when the subject below turns out to be nothing at all.
|
|
4893
4955
|
"summary": _summary_objects(),
|
|
4894
4956
|
}
|
|
4895
|
-
|
|
4957
|
+
adt = arm_obj.animation_data if arm_obj is not None else None
|
|
4958
|
+
assigned = adt.action if adt is not None else None
|
|
4959
|
+
if arm_obj is None or (assigned is None and not action_name):
|
|
4896
4960
|
header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
|
|
4897
4961
|
"and an empty summary row, which is Blender's own empty state.")
|
|
4898
4962
|
return header
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4963
|
+
action = assigned
|
|
4964
|
+
slot = getattr(adt, "action_slot", None) if adt is not None else None
|
|
4965
|
+
if action_name and (assigned is None or action_name != assigned.name):
|
|
4966
|
+
# A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
|
|
4967
|
+
# baked against this armature WITHOUT assigning it, so the file is untouched. Its curves
|
|
4968
|
+
# are its first channelbag's (no slot of this object chooses among them).
|
|
4969
|
+
action = bpy.data.actions.get(action_name)
|
|
4970
|
+
if action is None:
|
|
4971
|
+
raise ValueError("The engine holds no action named %r" % (action_name,))
|
|
4972
|
+
# THE SLOT FOR THIS ARMATURE, as Blender would pick it on assignment: the one whose
|
|
4973
|
+
# identifier names this object (`OB<name>`), else the only object slot. A layered action
|
|
4974
|
+
# with several object slots and none for this armature has no right answer; its first
|
|
4975
|
+
# object slot is used and said in the reason.
|
|
4976
|
+
slots = [one for one in getattr(action, "slots", ()) if getattr(one, "target_id_type", "OBJECT") == "OBJECT"]
|
|
4977
|
+
slot = next((one for one in slots if getattr(one, "identifier", "") == "OB" + arm_obj.name), None)
|
|
4978
|
+
if slot is None and slots:
|
|
4979
|
+
slot = slots[0]
|
|
4980
|
+
if len(slots) > 1:
|
|
4981
|
+
header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
|
|
4982
|
+
% (action.name, len(slots), arm_obj.name, getattr(slot, "identifier", "?")))
|
|
4902
4983
|
curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
|
|
4903
4984
|
header.update({
|
|
4904
4985
|
"action": action.name,
|
|
@@ -5433,7 +5514,9 @@ def _dispatch(request):
|
|
|
5433
5514
|
if op == "rig":
|
|
5434
5515
|
return rna_rig(request.get("object"))
|
|
5435
5516
|
if op == "action-clip":
|
|
5436
|
-
return rna_action_clip(request.get("object"), request.get("bake", True))
|
|
5517
|
+
return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"))
|
|
5518
|
+
if op == "armature-actions":
|
|
5519
|
+
return rna_armature_actions()
|
|
5437
5520
|
if op == "outliner":
|
|
5438
5521
|
return rna_outliner(request.get("selected"))
|
|
5439
5522
|
if op == "outliner-set":
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* built from the mesh's texture space (`orcoAttribute`).
|
|
15
15
|
*/
|
|
16
16
|
import * as THREE from 'three';
|
|
17
|
+
import {ISLAND_RANDOM_ATTRIBUTE} from './blender-island-random';
|
|
17
18
|
import {presenterChanged} from './blender-presenter-change';
|
|
18
19
|
import {attributeVarying, type CompiledGraph, rampTexture, uvVarying} from './blender-node-graph';
|
|
19
20
|
import {graphAttributeName} from './blender-runtime-geometry';
|
|
@@ -458,7 +459,7 @@ varying vec3 vBlenderObjectNormal;
|
|
|
458
459
|
varying vec3 vBlenderWorldPosition;
|
|
459
460
|
varying vec3 vBlenderWorldNormal;
|
|
460
461
|
varying vec3 vBlenderOrco;
|
|
461
|
-
${compiled.attributes.map(n =>
|
|
462
|
+
${compiled.attributes.map(n => `${n===ISLAND_RANDOM_ATTRIBUTE?'flat ':''}varying vec4 ${attributeVarying(n)};`).join('\n')}
|
|
462
463
|
${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
|
|
463
464
|
const attributes = compiled.attributes.map(n => `attribute vec4 ${graphAttributeName(n)};`).join('\n');
|
|
464
465
|
shader.vertexShader = `attribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* SPDX-FileCopyrightText: 2011-2022 Blender Foundation
|
|
2
|
+
* SPDX-License-Identifier: Apache-2.0 */
|
|
3
|
+
/** Cycles' attr_create_random_per_island, util/disjoint_set.h and
|
|
4
|
+
* util/hash.h (Blender Foundation, Apache-2.0). Connectivity uses the
|
|
5
|
+
* evaluated mesh's STORED edge order and union-by-rank representative,
|
|
6
|
+
* including loose edges. It is independent of draw/UV/normal splitting. */
|
|
7
|
+
export const ISLAND_RANDOM_ATTRIBUTE='.cycles.random_per_island';
|
|
8
|
+
|
|
9
|
+
function hashUint(value:number):number {
|
|
10
|
+
let a=(0xdeadbeef+17+value)>>>0,b=(0xdeadbeef+17)>>>0,c=b;
|
|
11
|
+
const rotate=(x:number,n:number)=>(x<<n)|(x>>>(32-n));
|
|
12
|
+
c=((c^b)-rotate(b,14))>>>0;a=((a^c)-rotate(c,11))>>>0;
|
|
13
|
+
b=((b^a)-rotate(a,25))>>>0;c=((c^b)-rotate(b,16))>>>0;
|
|
14
|
+
a=((a^c)-rotate(c,4))>>>0;b=((b^a)-rotate(a,14))>>>0;
|
|
15
|
+
c=((c^b)-rotate(b,24))>>>0;
|
|
16
|
+
// Native converts uint to float BEFORE the float reciprocal/multiply.
|
|
17
|
+
return Math.fround(Math.fround(c)*Math.fround(1/Math.fround(0xffffffff)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function cyclesIslandRandom(vertexCount:number,edges:Iterable<readonly [number,number]>):Float32Array {
|
|
21
|
+
const parents=Uint32Array.from({length:vertexCount},(_,i)=>i),ranks=new Uint8Array(vertexCount);
|
|
22
|
+
const find=(x:number):number=>{
|
|
23
|
+
let root=x;while(parents[root]!==root)root=parents[root]!;
|
|
24
|
+
while(parents[x]!==root){const parent=parents[x]!;parents[x]=root;x=parent;}
|
|
25
|
+
return root;
|
|
26
|
+
};
|
|
27
|
+
for(const [x,y] of edges) {
|
|
28
|
+
if(x<0 || y<0 || x>=vertexCount || y>=vertexCount || !Number.isInteger(x) || !Number.isInteger(y))
|
|
29
|
+
throw new Error('Island connectivity references a missing Blender vertex');
|
|
30
|
+
let a=find(x),b=find(y);if(a===b)continue;
|
|
31
|
+
if(ranks[a]!<ranks[b]!)[a,b]=[b,a];
|
|
32
|
+
parents[b]=a;if(ranks[a]===ranks[b])ranks[a]=ranks[a]!+1;
|
|
33
|
+
}
|
|
34
|
+
const result=new Float32Array(vertexCount);
|
|
35
|
+
for(let i=0;i<vertexCount;i++)result[i]=hashUint(find(i));
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function* storedMeshEdges(edges:Uint32Array):Iterable<readonly [number,number]> {
|
|
40
|
+
for(let i=0;i<edges.length;i+=2)yield [edges[i]!,edges[i+1]!];
|
|
41
|
+
}
|
|
@@ -25,6 +25,7 @@ import * as THREE from 'three';
|
|
|
25
25
|
import {z} from 'zod';
|
|
26
26
|
import {BLENDER_NODE_GLSL, BLENDER_NODE_GLSL_PRELUDE} from './blender-node-glsl.generated';
|
|
27
27
|
import {graphAttributeName} from './blender-runtime-geometry';
|
|
28
|
+
import {ISLAND_RANDOM_ATTRIBUTE} from './blender-island-random';
|
|
28
29
|
|
|
29
30
|
const scalar = z.number().finite();
|
|
30
31
|
const gpuType = z.enum(['float', 'vec2', 'vec3', 'vec4']);
|
|
@@ -436,6 +437,13 @@ class Compiler {
|
|
|
436
437
|
if (!node) throw new Error(`The graph links to ${key}, which it does not carry`);
|
|
437
438
|
if (node.type === 'ShaderNodeParticleInfo' && node.outputs[index]?.id !== 'Random')
|
|
438
439
|
throw new Error(`Particle Info output ${node.outputs[index]?.id ?? index} is not compiled by the presenter`);
|
|
440
|
+
// EEVEE's node_geometry returns zero here; reference rendering uses
|
|
441
|
+
// Cycles' evaluated per-island attribute instead. Keep it face constant.
|
|
442
|
+
if(node.type==='ShaderNodeNewGeometry' && node.outputs[index]?.id==='Random Per Island') {
|
|
443
|
+
this.s.attributes.add(ISLAND_RANDOM_ATTRIBUTE);
|
|
444
|
+
this.s.structure.push(['cycles-island',key,index]);
|
|
445
|
+
return convert(`${attributeVarying(ISLAND_RANDOM_ATTRIBUTE)}.x`,'float',to);
|
|
446
|
+
}
|
|
439
447
|
const outputs = this.node(key, node);
|
|
440
448
|
const output = outputs[index];
|
|
441
449
|
if (!output) throw new Error(`${key} has no output ${index}`);
|
|
@@ -19,6 +19,7 @@ import * as THREE from 'three';
|
|
|
19
19
|
import { customCornerNormals, type Normal } from './blender-corner-normals';
|
|
20
20
|
import { ATTRIBUTE_LAYOUT, type MeshColumns } from './blender-frame-columns';
|
|
21
21
|
import { triangulatePolygon } from './blender-triangulate';
|
|
22
|
+
import {cyclesIslandRandom,ISLAND_RANDOM_ATTRIBUTE,storedMeshEdges} from './blender-island-random';
|
|
22
23
|
|
|
23
24
|
type MeshData = {
|
|
24
25
|
v: [number, number, number][];
|
|
@@ -43,7 +44,8 @@ export interface DrawArrays {
|
|
|
43
44
|
uvLayers?: {name: string; data: Float32Array}[] | undefined;
|
|
44
45
|
/** The mesh's generic attribute layers a material's Attribute or Color
|
|
45
46
|
* Attribute node can read, four floats per drawn vertex as EEVEE loads them
|
|
46
|
-
* (`graphAttributeLayers`).
|
|
47
|
+
* (`graphAttributeLayers`). The derived Cycles island layer stores one
|
|
48
|
+
* scalar per drawn vertex rather than an authored float4. */
|
|
47
49
|
attributeLayers?: {name: string; data: Float32Array}[] | undefined;
|
|
48
50
|
/** Per drawn vertex, a deformed mesh's Generated coordinates (0..1), or
|
|
49
51
|
* null when the positions are undeformed and orco derives from them. */
|
|
@@ -275,7 +277,7 @@ export function graphAttributeName(name: string): string {
|
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
export function geometryFromDrawArrays(
|
|
278
|
-
arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array },
|
|
280
|
+
arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array | undefined },
|
|
279
281
|
): THREE.BufferGeometry {
|
|
280
282
|
const geometry = new THREE.BufferGeometry();
|
|
281
283
|
geometry.setAttribute('position', new THREE.BufferAttribute(arrays.positions, 3));
|
|
@@ -294,7 +296,11 @@ export function geometryFromDrawArrays(
|
|
|
294
296
|
}
|
|
295
297
|
geometry.userData['blenderUvChannels'] = channels;
|
|
296
298
|
for (const layer of arrays.attributeLayers ?? [])
|
|
297
|
-
|
|
299
|
+
// Engine-derived island values use one float, while ordinary Blender
|
|
300
|
+
// material attributes retain EEVEE's float4 storage. Snapshots preserve
|
|
301
|
+
// the same name/stride, without inventing an authored mesh attribute.
|
|
302
|
+
geometry.setAttribute(graphAttributeName(layer.name), new THREE.BufferAttribute(layer.data,
|
|
303
|
+
layer.name===ISLAND_RANDOM_ATTRIBUTE?1:4));
|
|
298
304
|
geometry.userData['blenderAttributes'] = (arrays.attributeLayers ?? []).map(layer => layer.name);
|
|
299
305
|
if (arrays.orco) {
|
|
300
306
|
geometry.setAttribute('blenderOrco', new THREE.BufferAttribute(arrays.orco, 3));
|
|
@@ -419,7 +425,7 @@ function drawNativeColumns(c: MeshColumns, hash: string): DrawArrays {
|
|
|
419
425
|
|
|
420
426
|
/** The worker's door: the mesh store's columns, drawn into buffers. */
|
|
421
427
|
export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays {
|
|
422
|
-
if (c.cornerTri && c.cornerNormal) return drawNativeColumns(c, hash);
|
|
428
|
+
if (c.cornerTri && c.cornerNormal) return withIslandRandom(drawNativeColumns(c, hash),c.co.length/3,storedMeshEdges(c.edge));
|
|
423
429
|
const nv = c.co.length / 3;
|
|
424
430
|
const maps = c.attributes.filter((a) => a.type === 'FLOAT2' && a.domain === 'CORNER');
|
|
425
431
|
const mapName = c.renderUv ?? c.activeUv ?? maps[0]?.name;
|
|
@@ -482,7 +488,15 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
|
|
|
482
488
|
attributeLayers: graphAttributeLayers(c.attributes, data => data as ArrayLike<number>),
|
|
483
489
|
};
|
|
484
490
|
if (map && ATTRIBUTE_LAYOUT.FLOAT2.size !== 2) throw new Error('FLOAT2 layout');
|
|
485
|
-
return {
|
|
491
|
+
return {...withIslandRandom(drawCore(view),nv,edges),hash};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function withIslandRandom<T extends Omit<DrawArrays,'hash'>>(arrays:T,vertexCount:number,
|
|
495
|
+
edges:Iterable<readonly [number,number]>):T {
|
|
496
|
+
const native=cyclesIslandRandom(vertexCount,edges);
|
|
497
|
+
const data=Float32Array.from(arrays.sourceVertex,vertex=>native[vertex]!);
|
|
498
|
+
arrays.attributeLayers=[...(arrays.attributeLayers??[]),{name:ISLAND_RANDOM_ATTRIBUTE,data}];
|
|
499
|
+
return arrays;
|
|
486
500
|
}
|
|
487
501
|
|
|
488
502
|
/** The boundary JSON door. */
|
|
@@ -546,5 +560,8 @@ export function drawRuntimeGeometry(data: MeshData): THREE.BufferGeometry {
|
|
|
546
560
|
attributeLayers: graphAttributeLayers(data.attributes ?? [],
|
|
547
561
|
values => (Array.from(values) as (number | number[])[]).flat()),
|
|
548
562
|
};
|
|
549
|
-
|
|
563
|
+
// Native frames always supply stored edges. Legacy JSON without that
|
|
564
|
+
// order retains its old unsupported zero input rather than guessing IDs.
|
|
565
|
+
const arrays=drawCore(view);
|
|
566
|
+
return geometryFromDrawArrays(view.edges?withIslandRandom(arrays,vertices.length,view.edges):arrays);
|
|
550
567
|
}
|
|
@@ -147,6 +147,8 @@ const drawArraysSchema = z
|
|
|
147
147
|
})
|
|
148
148
|
.strict(),
|
|
149
149
|
),
|
|
150
|
+
/** The draw's vertex map (`DrawArrays.sourceVertex`), kept by a copy that plays (skins bind over it). */
|
|
151
|
+
sourceVertex: z.instanceof(Uint32Array).optional(),
|
|
150
152
|
hash: z.string(),
|
|
151
153
|
})
|
|
152
154
|
.strict();
|
|
@@ -2248,7 +2250,7 @@ export class BlenderRuntimeView {
|
|
|
2248
2250
|
/** Everything this view holds, as ONE self-contained frame another view can be built from:
|
|
2249
2251
|
* every mesh's resident geometry and every resident image, with no reference to what a
|
|
2250
2252
|
* receiver already holds. */
|
|
2251
|
-
private fullFrame(): Frame {
|
|
2253
|
+
private fullFrame(options: { sourceVertices?: boolean } = {}): Frame {
|
|
2252
2254
|
const source = this.frame;
|
|
2253
2255
|
if (!source) throw new Error('Blender capture requires a presented frame');
|
|
2254
2256
|
// Inspection overlays are not render content. In particular weight-paint
|
|
@@ -2290,6 +2292,12 @@ export class BlenderRuntimeView {
|
|
|
2290
2292
|
count: group.count,
|
|
2291
2293
|
materialIndex: group.materialIndex ?? 0,
|
|
2292
2294
|
})),
|
|
2295
|
+
// A COPY THAT PLAYS keeps the draw's vertex map: Play binds skins over it (a Blender
|
|
2296
|
+
// vertex's weights expand onto its drawn vertices through it), where a render snapshot
|
|
2297
|
+
// needs none.
|
|
2298
|
+
...(options.sourceVertices && geometry.getAttribute('blenderVertex')
|
|
2299
|
+
? { sourceVertex: new Uint32Array((geometry.getAttribute('blenderVertex') as THREE.BufferAttribute).array) }
|
|
2300
|
+
: {}),
|
|
2293
2301
|
};
|
|
2294
2302
|
}
|
|
2295
2303
|
for (const [id, { signature }] of this.volumes)
|
|
@@ -2368,7 +2376,7 @@ export class BlenderRuntimeView {
|
|
|
2368
2376
|
*/
|
|
2369
2377
|
detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
|
|
2370
2378
|
const detached = new BlenderRuntimeView();
|
|
2371
|
-
if (this.frame) detached.applyFrame(this.fullFrame());
|
|
2379
|
+
if (this.frame) detached.applyFrame(this.fullFrame({ sourceVertices: true }));
|
|
2372
2380
|
let disposed = false;
|
|
2373
2381
|
return {
|
|
2374
2382
|
view: detached,
|
|
@@ -2421,6 +2429,40 @@ export class BlenderRuntimeView {
|
|
|
2421
2429
|
};
|
|
2422
2430
|
}
|
|
2423
2431
|
|
|
2432
|
+
/** Build a render-evaluated frame without ever publishing it to a viewport. */
|
|
2433
|
+
static forPhotograph(): BlenderRuntimeView {
|
|
2434
|
+
const view = new BlenderRuntimeView();
|
|
2435
|
+
view.photograph = true;
|
|
2436
|
+
return view;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/** Transfer this render-only view to its capture owner. Its resources are
|
|
2440
|
+
* already independent, so a second geometry/texture copy is unnecessary. */
|
|
2441
|
+
takeCaptureSnapshot(): ReturnType<BlenderRuntimeView['captureSnapshot']> {
|
|
2442
|
+
if (!this.photograph || !this.frame) throw new Error('A render-evaluated frame is required');
|
|
2443
|
+
const source = this.frame;
|
|
2444
|
+
const effect = this.createWorldVolumePass();
|
|
2445
|
+
let disposed = false;
|
|
2446
|
+
return {
|
|
2447
|
+
effect, root: this.root, session: source.session, revision: source.revision,
|
|
2448
|
+
prepare: camera => {
|
|
2449
|
+
if (disposed) throw new Error('Blender capture snapshot is disposed');
|
|
2450
|
+
return this.setRendered(true, camera);
|
|
2451
|
+
},
|
|
2452
|
+
prepareDraw: (renderer, camera) => {
|
|
2453
|
+
if (disposed) throw new Error('Blender capture snapshot is disposed');
|
|
2454
|
+
return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer });
|
|
2455
|
+
},
|
|
2456
|
+
dispose: () => {
|
|
2457
|
+
if (disposed) return;
|
|
2458
|
+
disposed = true;
|
|
2459
|
+
effect?.dispose();
|
|
2460
|
+
this.dispose();
|
|
2461
|
+
this.root.removeFromParent();
|
|
2462
|
+
},
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2424
2466
|
createWorldVolumePass(): WorldVolumePass | undefined {
|
|
2425
2467
|
const medium = worldMedium(this.frame?.world);
|
|
2426
2468
|
return medium ? new WorldVolumePass(medium) : undefined;
|
|
@@ -90,6 +90,22 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
|
|
|
90
90
|
frame: unknown,
|
|
91
91
|
description: unknown,
|
|
92
92
|
capture?: CaptureRequest,
|
|
93
|
+
): Promise<PresentAnswer> {
|
|
94
|
+
if (capture?.evaluation === 'render') {
|
|
95
|
+
const detached = BlenderRuntimeView.forPhotograph();
|
|
96
|
+
const captureScene = new THREE.Scene();
|
|
97
|
+
view.root.updateWorldMatrix(true, false);
|
|
98
|
+
detached.root.matrix.copy(view.root.matrixWorld);
|
|
99
|
+
captureScene.add(detached.root);
|
|
100
|
+
try { return await presentFrame(detached, captureScene, frame, description, capture); }
|
|
101
|
+
finally { detached.dispose(); detached.root.removeFromParent(); }
|
|
102
|
+
}
|
|
103
|
+
return presentFrame(view, scene, frame, description, capture);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function presentFrame(
|
|
107
|
+
view: BlenderRuntimeView, scene: THREE.Scene, frame: unknown, description: unknown,
|
|
108
|
+
capture?: CaptureRequest,
|
|
93
109
|
): Promise<PresentAnswer> {
|
|
94
110
|
const applied = view.applyFrame(frame) as { held?: unknown } | null | undefined;
|
|
95
111
|
const held =
|
|
@@ -169,6 +185,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
|
|
|
169
185
|
renderer.setClearColor(0, 0);
|
|
170
186
|
}
|
|
171
187
|
try {
|
|
188
|
+
view.prepareDraw(camera, { interactive: false, height, renderer });
|
|
172
189
|
renderer.render(scene, camera);
|
|
173
190
|
} finally {
|
|
174
191
|
scene.background = background;
|
package/browser/volter_three.py
CHANGED
|
@@ -212,7 +212,7 @@ def _world(scene):
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
|
|
215
|
-
def _export():
|
|
215
|
+
def _export(depsgraph):
|
|
216
216
|
"""The C++ door's frame, with what the door does not describe merged in.
|
|
217
217
|
|
|
218
218
|
`known` is ALWAYS EMPTY: this process keeps no record of what a presenter
|
|
@@ -221,7 +221,7 @@ def _export():
|
|
|
221
221
|
"""
|
|
222
222
|
options = {
|
|
223
223
|
"session": SESSION,
|
|
224
|
-
"evaluate":
|
|
224
|
+
"evaluate": False,
|
|
225
225
|
"known": {},
|
|
226
226
|
"buffer_path": EXPORT_BUFFER_PATH,
|
|
227
227
|
}
|
|
@@ -236,7 +236,7 @@ def _export():
|
|
|
236
236
|
_REVISION[0] += 1
|
|
237
237
|
frame["session"] = SESSION
|
|
238
238
|
frame["revision"] = _REVISION[0]
|
|
239
|
-
scene =
|
|
239
|
+
scene = depsgraph.scene
|
|
240
240
|
frame["world"] = _world(scene)
|
|
241
241
|
frame["volumes"] = {}
|
|
242
242
|
# THE INSPECTION OVERLAYS ARE THE EDITOR'S, and a render hides them anyway.
|
|
@@ -249,8 +249,14 @@ def _export():
|
|
|
249
249
|
return frame
|
|
250
250
|
|
|
251
251
|
|
|
252
|
-
def _present(capture):
|
|
253
|
-
|
|
252
|
+
def _present(depsgraph, capture):
|
|
253
|
+
if not hasattr(_blender_web, "begin_render_export"):
|
|
254
|
+
raise RuntimeError("This Blender artifact has no render-evaluated export")
|
|
255
|
+
_blender_web.begin_render_export(depsgraph)
|
|
256
|
+
try:
|
|
257
|
+
answer = ask({"frame": _export(depsgraph), "capture": {**capture, "evaluation": "render"}})
|
|
258
|
+
finally:
|
|
259
|
+
_blender_web.end_render_export()
|
|
254
260
|
if isinstance(answer, dict) and answer.get("error"):
|
|
255
261
|
raise RuntimeError(answer["error"])
|
|
256
262
|
return answer
|
|
@@ -334,7 +340,7 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
334
340
|
if linear:
|
|
335
341
|
render["linear"] = True
|
|
336
342
|
answer = _present(
|
|
337
|
-
{"position": position, "target": target, "up": up, "render": render}
|
|
343
|
+
depsgraph, {"position": position, "target": target, "up": up, "render": render}
|
|
338
344
|
)
|
|
339
345
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
340
346
|
raise RuntimeError("The renderer did not answer with a photograph")
|
package/browser/worker.ts
CHANGED
|
@@ -116,6 +116,7 @@ interface Copied {
|
|
|
116
116
|
description: unknown;
|
|
117
117
|
}
|
|
118
118
|
interface PendingFrame {
|
|
119
|
+
evaluation?: 'render';
|
|
119
120
|
session: string;
|
|
120
121
|
revision: number;
|
|
121
122
|
columns: Map<number, Copied>;
|
|
@@ -270,7 +271,7 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
270
271
|
const started = await startBlenderEngine({
|
|
271
272
|
project,
|
|
272
273
|
log,
|
|
273
|
-
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint }) => {
|
|
274
|
+
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint, evaluation }) => {
|
|
274
275
|
if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
|
|
275
276
|
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
276
277
|
// Save once after the whole command, never during a partial frame.
|
|
@@ -286,8 +287,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
286
287
|
// gets the whole frame at `present`.
|
|
287
288
|
if (hold !== undefined) {
|
|
288
289
|
const identity = hold as { session: string; revision: number };
|
|
289
|
-
pending = { session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
|
|
290
|
-
await streamToTab({ op: 'stage', session: identity.session, revision: identity.revision });
|
|
290
|
+
pending = { ...(evaluation ? { evaluation } : {}), session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
|
|
291
|
+
await streamToTab({ op: 'stage', evaluation, session: identity.session, revision: identity.revision });
|
|
291
292
|
return {};
|
|
292
293
|
}
|
|
293
294
|
if (mesh !== undefined || image !== undefined) {
|
|
@@ -312,7 +313,7 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
312
313
|
description: await describeFrame(arena, piece),
|
|
313
314
|
};
|
|
314
315
|
}
|
|
315
|
-
await streamToTab({ op: 'stage', session: pending.session, revision: pending.revision,
|
|
316
|
+
await streamToTab({ op: 'stage', evaluation: pending.evaluation, session: pending.session, revision: pending.revision,
|
|
316
317
|
...(mesh !== undefined ? { mesh } : { image }), piece: copied.typed });
|
|
317
318
|
// Keep only the manifest and digest; the presenter already built this
|
|
318
319
|
// resource, and the next pull may overwrite the engine arena.
|
|
@@ -805,7 +806,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
805
806
|
op: 'action-clip',
|
|
806
807
|
...(request.object === undefined ? {} : { object: request.object }),
|
|
807
808
|
...(request.bake === undefined ? {} : { bake: request.bake }),
|
|
809
|
+
...(request.action === undefined ? {} : { action: request.action }),
|
|
808
810
|
});
|
|
811
|
+
case 'armature-actions':
|
|
812
|
+
return ask({ op: 'armature-actions' });
|
|
809
813
|
case 'outliner-set':
|
|
810
814
|
return ask({
|
|
811
815
|
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": "5a3cb1250fc1fd7df59e3ff823949c06e52d9422",
|
|
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/5a3cb1250fc1fd7df59e3ff823949c06e52d9422/build_files/emscripten/RELEASE.md",
|
|
7
7
|
"factoryEngine": "BLENDER_EEVEE",
|
|
8
8
|
"files": {
|
|
9
9
|
"blender_browser.js": "raw",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"sha256": "d818e8d746bc62ebf810206ac564d2dc75dc645b3121f4a3919d13ecef11e419"
|
|
17
17
|
},
|
|
18
18
|
"blender_browser.wasm": {
|
|
19
|
-
"bytes":
|
|
20
|
-
"sha256": "
|
|
19
|
+
"bytes": 86351436,
|
|
20
|
+
"sha256": "d4c3a8002cb1686f3c5299e28bcbb356934bbe910f2485b2572032b115cf67bd"
|
|
21
21
|
},
|
|
22
22
|
"blender_browser.data": {
|
|
23
23
|
"bytes": 52699590,
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"sha256": "d818e8d746bc62ebf810206ac564d2dc75dc645b3121f4a3919d13ecef11e419"
|
|
31
31
|
},
|
|
32
32
|
"blender_browser.wasm.br": {
|
|
33
|
-
"bytes":
|
|
34
|
-
"sha256": "
|
|
33
|
+
"bytes": 18605311,
|
|
34
|
+
"sha256": "97e3bc2c772e9fd6da5a3e28086c1ee1dfd62b35ebdeb127a413b4bd5904031f"
|
|
35
35
|
},
|
|
36
36
|
"blender_browser.data.br": {
|
|
37
37
|
"bytes": 8056485,
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"sha256": "4085e9336266cf66f061d4d5c962a5929cc96d5732c746b5c13ea05cd104a75b"
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
|
-
"note": "
|
|
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.",
|
|
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.",
|
|
49
49
|
"preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
|
|
50
50
|
}
|
|
Binary file
|