@volter/blender-engine 0.1.130 → 0.1.132
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 +8 -0
- package/browser/runtime.ts +21 -6
- package/browser/session.py +99 -20
- 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-physical-material.ts +14 -1
- package/browser/three/blender-runtime-geometry.ts +22 -5
- package/browser/three/blender-runtime-lighting.ts +1 -0
- package/browser/three/blender-runtime-shadows.ts +5 -0
- package/browser/three/blender-runtime-view.ts +49 -1
- package/browser/three/blender-world-visibility.ts +264 -0
- package/browser/three/presenter.ts +17 -0
- package/browser/volter_three.py +12 -6
- package/browser/worker.ts +45 -6
- package/package.json +1 -1
- package/wasm/BUNDLE.json +8 -8
- package/wasm/blender_browser.wasm.br +0 -0
package/browser/protocol.ts
CHANGED
|
@@ -105,6 +105,11 @@ export type WorkerRequest =
|
|
|
105
105
|
export type WorkerReply =
|
|
106
106
|
| { op: 'frame-stream'; id: number; chunk: FrameChunk }
|
|
107
107
|
| { op: 'document-dirty'; dirty: boolean }
|
|
108
|
+
/** THE DOCUMENT FOLLOWED BLENDER: a script's `save_as_mainfile` (or `open_mainfile`) left
|
|
109
|
+
* another project `.blend` open, and the session's saves now go there
|
|
110
|
+
* (`session.py::follow_open_file`). Both paths are project-relative. Posted before the
|
|
111
|
+
* call's own answer, so the tab's handle already names the new file when the call returns. */
|
|
112
|
+
| { op: 'document-moved'; from: string; document: string }
|
|
108
113
|
| { op: 'history'; entries: NativeHistoryEntry[] }
|
|
109
114
|
| { id: number; result: unknown }
|
|
110
115
|
| { id: number; error: string }
|
|
@@ -174,6 +179,9 @@ export interface FileListSnapshot {
|
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
export interface CaptureRequest {
|
|
182
|
+
/** Geometry was exported from the render engine's RENDER depsgraph. It belongs
|
|
183
|
+
* to this capture alone and must never replace the live viewport frame. */
|
|
184
|
+
evaluation?: 'render';
|
|
177
185
|
/** A viewport screenshot's bound, the longer side of a square frame.
|
|
178
186
|
* Absent on a render, which states its exact pixel dimensions instead. */
|
|
179
187
|
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;
|
|
@@ -52,6 +52,11 @@ export interface BlenderRuntimeOptions {
|
|
|
52
52
|
capture?: CaptureRequest,
|
|
53
53
|
): Promise<PresentAnswer> | PresentAnswer;
|
|
54
54
|
log?(level: 'log' | 'warn' | 'error', text: string): void;
|
|
55
|
+
/** The session's document followed Blender to another project `.blend` (a script's Save As,
|
|
56
|
+
* `protocol.ts`'s `document-moved`). Called after {@link BlenderRuntime.document} already
|
|
57
|
+
* names the new file and before the call that moved it answers. An observer: a throw here
|
|
58
|
+
* changes nothing. */
|
|
59
|
+
documentMoved?(moved: { readonly from: string; readonly document: string }): void;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -237,7 +242,9 @@ export class BlenderRuntime {
|
|
|
237
242
|
return this.#project;
|
|
238
243
|
}
|
|
239
244
|
|
|
240
|
-
/** The
|
|
245
|
+
/** The document owned by this worker, including while boot is pending: the startup one, until
|
|
246
|
+
* a script's Save As leaves Blender holding another project `.blend` and the session follows
|
|
247
|
+
* it there (`session.py::follow_open_file`). */
|
|
241
248
|
get document(): string | null {
|
|
242
249
|
return this.#document;
|
|
243
250
|
}
|
|
@@ -638,7 +645,7 @@ export class BlenderRuntime {
|
|
|
638
645
|
}
|
|
639
646
|
|
|
640
647
|
readonly #frameStream = new FrameStreamReader();
|
|
641
|
-
#stagedIdentity: { session: string; revision: number } | null = null;
|
|
648
|
+
#stagedIdentity: { session: string; revision: number; evaluation?: 'render' } | null = null;
|
|
642
649
|
#discardStagedFrame(): void {
|
|
643
650
|
this.#frameStream.reset();
|
|
644
651
|
const part = this.#stagedIdentity; this.#stagedIdentity = null;
|
|
@@ -654,13 +661,13 @@ export class BlenderRuntime {
|
|
|
654
661
|
try {
|
|
655
662
|
const decoded = await this.#frameStream.accept(reply.chunk);
|
|
656
663
|
if (decoded) {
|
|
657
|
-
const value = decoded.value as ({ op: 'stage'; session: string; revision: number } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
|
|
664
|
+
const value = decoded.value as ({ op: 'stage'; session: string; revision: number; evaluation?: 'render' } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
|
|
658
665
|
if (value.op === 'present') {
|
|
659
666
|
await this.#receive({ ...value, id: reply.id } as WorkerReply);
|
|
660
667
|
return;
|
|
661
668
|
}
|
|
662
669
|
if (value.op !== 'stage' || !this.#options.stage) throw new Error('The presenter does not support staged Blender frames');
|
|
663
|
-
this.#stagedIdentity = { session: value.session, revision: value.revision };
|
|
670
|
+
this.#stagedIdentity = { session: value.session, revision: value.revision, ...(value.evaluation ? { evaluation: value.evaluation } : {}) };
|
|
664
671
|
await this.#options.stage(value);
|
|
665
672
|
}
|
|
666
673
|
this.#worker.postMessage({ op: 'present-result', id: reply.id } satisfies WorkerRequest);
|
|
@@ -674,6 +681,14 @@ export class BlenderRuntime {
|
|
|
674
681
|
this.#dirty = reply.dirty;
|
|
675
682
|
return;
|
|
676
683
|
}
|
|
684
|
+
if (reply.op === 'document-moved') {
|
|
685
|
+
// The worker already saves to the new file; this handle must claim it too, or the
|
|
686
|
+
// editor refuses the new file's own Model tab as a resource conflict.
|
|
687
|
+
this.#document = reply.document;
|
|
688
|
+
try { this.#options.documentMoved?.({ from: reply.from, document: reply.document }); }
|
|
689
|
+
catch { /* an observer cannot undo the move Blender made */ }
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
677
692
|
if (reply.op === 'history') {
|
|
678
693
|
this.#options.history?.(reply.entries);
|
|
679
694
|
return;
|
|
@@ -693,7 +708,7 @@ export class BlenderRuntime {
|
|
|
693
708
|
// true the moment the frame arrives. A presenter that refuses the frame
|
|
694
709
|
// has not un-modelled it.
|
|
695
710
|
const frame = reply.frame as { session?: unknown; revision?: unknown } | null;
|
|
696
|
-
if (typeof frame?.session === 'string' && typeof frame.revision === 'number')
|
|
711
|
+
if (reply.capture?.evaluation !== 'render' && typeof frame?.session === 'string' && typeof frame.revision === 'number')
|
|
697
712
|
this.#presented = { session: frame.session, revision: frame.revision };
|
|
698
713
|
const end = this.#work('presenting worker frame');
|
|
699
714
|
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.
|
|
@@ -1464,6 +1467,9 @@ class Session:
|
|
|
1464
1467
|
# Set by a present that left the document behind the model; cleared by
|
|
1465
1468
|
# the save. The tab reads it as `saveDue` beside the frame.
|
|
1466
1469
|
self.save_due = False
|
|
1470
|
+
# The OUTSIDE-THE-PROJECT file Blender was last found holding, once said
|
|
1471
|
+
# (`follow_open_file`): the refusal is told once per file, not per call.
|
|
1472
|
+
self._outside_file = None
|
|
1467
1473
|
# WHAT THE PRESENTER LAST REPORTED HOLDING, as an instrument: the
|
|
1468
1474
|
# `(session, revision)` it held BEFORE the last frame, None when it
|
|
1469
1475
|
# held nothing, and the string "unreported" for a presenter that does
|
|
@@ -1477,7 +1483,7 @@ class Session:
|
|
|
1477
1483
|
|
|
1478
1484
|
# -- the export
|
|
1479
1485
|
|
|
1480
|
-
def _export(self):
|
|
1486
|
+
def _export(self, depsgraph=None):
|
|
1481
1487
|
"""THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
|
|
1482
1488
|
|
|
1483
1489
|
One call evaluates the scene, walks the depsgraph's own update record
|
|
@@ -1494,14 +1500,14 @@ class Session:
|
|
|
1494
1500
|
bones and the active vertex group's weights (`_armatures`,
|
|
1495
1501
|
`_weights`). All per-scene, none of them geometry.
|
|
1496
1502
|
"""
|
|
1497
|
-
scene = bpy.context.scene
|
|
1503
|
+
scene = depsgraph.scene if depsgraph is not None else bpy.context.scene
|
|
1498
1504
|
graphs = material_graphs(scene)
|
|
1499
1505
|
_mark("export:graphs")
|
|
1500
1506
|
# An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
|
|
1501
1507
|
# (`overlay_empty.hh` `image_sync`).
|
|
1502
1508
|
empty_images = {obj.data.name for obj in scene.objects
|
|
1503
1509
|
if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
|
|
1504
|
-
options = {"session": self.session, "evaluate":
|
|
1510
|
+
options = {"session": self.session, "evaluate": depsgraph is None, "known": self._known, "instance_sources": True,
|
|
1505
1511
|
"graph_materials": sorted(graphs),
|
|
1506
1512
|
"graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
|
|
1507
1513
|
"graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
|
|
@@ -1560,7 +1566,7 @@ class Session:
|
|
|
1560
1566
|
# A MANUAL TEXTURE SPACE, which Generated coordinates map through; the
|
|
1561
1567
|
# automatic one is the evaluated bounds the presenter already holds.
|
|
1562
1568
|
if graphs:
|
|
1563
|
-
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
1569
|
+
depsgraph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1564
1570
|
# BY NAME THROUGH ONE MAP: `scene.objects.get` searches the collection linearly, so a
|
|
1565
1571
|
# lookup per row was quadratic -- measured on the Stoneguard file (11,702 rows): 11,343
|
|
1566
1572
|
# calls, 14.4 s of every present.
|
|
@@ -1595,7 +1601,8 @@ class Session:
|
|
|
1595
1601
|
"roughness": float(material.roughness),
|
|
1596
1602
|
"metallic": float(material.metallic),
|
|
1597
1603
|
}
|
|
1598
|
-
|
|
1604
|
+
graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1605
|
+
frame["instances"] = _depsgraph_placements(frame, graph)
|
|
1599
1606
|
frame["world"] = draw_world(scene)
|
|
1600
1607
|
frame["cameras"] = {
|
|
1601
1608
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
@@ -1607,7 +1614,7 @@ class Session:
|
|
|
1607
1614
|
render = scene.render
|
|
1608
1615
|
frame["camera_view"] = {
|
|
1609
1616
|
"scene_camera": scene.camera.name if scene.camera is not None else None,
|
|
1610
|
-
"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"],
|
|
1611
1618
|
"aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
|
|
1612
1619
|
}
|
|
1613
1620
|
# THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
|
|
@@ -1617,7 +1624,7 @@ class Session:
|
|
|
1617
1624
|
frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
|
|
1618
1625
|
# THE OVERLAYS, after the door's frame stands: `_weights` reads the
|
|
1619
1626
|
# mesh's own revision out of it (see there), so it cannot run before.
|
|
1620
|
-
view_layer =
|
|
1627
|
+
view_layer = graph.view_layer
|
|
1621
1628
|
# THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
|
|
1622
1629
|
# both from the scene, so they come from here (`_subject_line`).
|
|
1623
1630
|
frame["subject"] = _subject_line(scene, view_layer)
|
|
@@ -1662,12 +1669,12 @@ class Session:
|
|
|
1662
1669
|
self._known.clear()
|
|
1663
1670
|
return self._present(capture)
|
|
1664
1671
|
|
|
1665
|
-
def _present(self, capture=None):
|
|
1672
|
+
def _present(self, capture=None, depsgraph=None):
|
|
1666
1673
|
known = dict(self._known)
|
|
1667
1674
|
_mark("present:before")
|
|
1668
|
-
frame = self._export()
|
|
1675
|
+
frame = self._export(depsgraph)
|
|
1669
1676
|
_mark("present:exported")
|
|
1670
|
-
pulled = self._pull(frame)
|
|
1677
|
+
pulled = self._pull(frame, render=depsgraph is not None)
|
|
1671
1678
|
_mark("present:pulled")
|
|
1672
1679
|
self._drop_unreachable_textures(frame)
|
|
1673
1680
|
# WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
|
|
@@ -1726,7 +1733,7 @@ class Session:
|
|
|
1726
1733
|
self._reconcile_with_presenter(answer, frame)
|
|
1727
1734
|
return answer
|
|
1728
1735
|
|
|
1729
|
-
def _pull(self, frame):
|
|
1736
|
+
def _pull(self, frame, render=False):
|
|
1730
1737
|
"""THE PULL, one datablock at a time, as Hydra's render delegate pulls each prim's data
|
|
1731
1738
|
during Sync after the scene index's notice (`HydraSceneIndex::populate`). The door named
|
|
1732
1739
|
the changed meshes and pictures as deferred; the worker first copies the frame's own
|
|
@@ -1740,9 +1747,9 @@ class Session:
|
|
|
1740
1747
|
anything was pulled; the worker then presents the frame with its pieces."""
|
|
1741
1748
|
meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
|
|
1742
1749
|
images = [name for name, image in frame["images"].items() if image.get("deferred")]
|
|
1743
|
-
if not meshes and not images:
|
|
1750
|
+
if not meshes and not images and not render:
|
|
1744
1751
|
return False
|
|
1745
|
-
_asked({"hold": frame})
|
|
1752
|
+
_asked({"hold": frame, **({"evaluation": "render"} if render else {})})
|
|
1746
1753
|
options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
|
|
1747
1754
|
for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
|
|
1748
1755
|
("image", images, _blender_web.export_image)):
|
|
@@ -1762,6 +1769,20 @@ class Session:
|
|
|
1762
1769
|
_asked({kind: key, "piece": piece[kind]})
|
|
1763
1770
|
return True
|
|
1764
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
|
+
|
|
1765
1786
|
def _send_encoded_image(self, key, deferred):
|
|
1766
1787
|
"""A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
|
|
1767
1788
|
|
|
@@ -1964,10 +1985,11 @@ class Session:
|
|
|
1964
1985
|
def bind_document(self, relative_path):
|
|
1965
1986
|
"""Name the session's document and OPEN it when the project has one.
|
|
1966
1987
|
|
|
1967
|
-
One session holds one document. The path is stated at start and
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1988
|
+
One session holds one document. The path is stated at start and moves
|
|
1989
|
+
only where Blender itself moved: a script's own `save_as_mainfile` (or
|
|
1990
|
+
`open_mainfile`) to another `.blend` in the project retargets
|
|
1991
|
+
`bpy.data.filepath`, and `follow_open_file` moves the document with it
|
|
1992
|
+
after the call.
|
|
1971
1993
|
|
|
1972
1994
|
`open_mainfile` runs the session's `_load_post` like any other load
|
|
1973
1995
|
(the export door's revision table is reset, the render engine ids are
|
|
@@ -2011,6 +2033,58 @@ class Session:
|
|
|
2011
2033
|
if self.document is not None:
|
|
2012
2034
|
self.save_due = True
|
|
2013
2035
|
|
|
2036
|
+
def follow_open_file(self):
|
|
2037
|
+
"""FOLLOW BLENDER'S SAVE AS: the document is the file Blender has open.
|
|
2038
|
+
|
|
2039
|
+
Read after every `execute`, because a script is the one request that can
|
|
2040
|
+
change which file Blender holds. `save_as_mainfile(filepath=X)` writes X
|
|
2041
|
+
and leaves X OPEN -- `bpy.data.filepath` becomes X, and Blender's next
|
|
2042
|
+
Save goes there. The session used to keep saving to the file it was
|
|
2043
|
+
started on, so the model a script had just saved as X was written over
|
|
2044
|
+
the OLD file on the next idle save, and the editor refused X's own tab
|
|
2045
|
+
because the worker still claimed the old one. SEEN 2026-10-07: an
|
|
2046
|
+
agent built an obby through the Blender MCP, saved it as
|
|
2047
|
+
`src/models/obby.blend`, and its level landed in `src/models/cube.blend`
|
|
2048
|
+
instead -- so Play looked for `cube.play.ts`.
|
|
2049
|
+
|
|
2050
|
+
What does NOT move, by Blender's own rules rather than ours:
|
|
2051
|
+
`save_mainfile()` and a Save As to the same path leave the path where it
|
|
2052
|
+
is, and `save_as_mainfile(copy=True)` writes a copy and keeps the
|
|
2053
|
+
original open (which is also how `save_document` writes, so the
|
|
2054
|
+
session's own save never moves anything). `open_mainfile` does move it:
|
|
2055
|
+
Blender then holds that file, and saving the old path would write one
|
|
2056
|
+
file's model over another's.
|
|
2057
|
+
|
|
2058
|
+
Answers None when nothing moved, `{"moved": True, "from", "document"}`
|
|
2059
|
+
when the document followed Blender to another project `.blend`, and
|
|
2060
|
+
`{"moved": False, "from", "outside"}` -- once per file -- when Blender
|
|
2061
|
+
holds a file outside the project: the editor has no document there, so
|
|
2062
|
+
the session keeps its own and keeps saving to it.
|
|
2063
|
+
"""
|
|
2064
|
+
if self.document is None or not bpy.data.filepath:
|
|
2065
|
+
return None
|
|
2066
|
+
current = os.path.normpath(os.path.abspath(bpy.data.filepath))
|
|
2067
|
+
if current == os.path.normpath(self.document):
|
|
2068
|
+
self._outside_file = None
|
|
2069
|
+
return None
|
|
2070
|
+
root = os.path.normpath(self.project)
|
|
2071
|
+
relative = current[len(root) + 1:] if current.startswith(root + "/") else None
|
|
2072
|
+
# The same shape the worker and the server check (`worker.ts::isDocumentPath`).
|
|
2073
|
+
if (relative is not None and relative.endswith(".blend") and "\\" not in relative
|
|
2074
|
+
and all(part not in ("", ".", "..") for part in relative.split("/"))):
|
|
2075
|
+
previous = self.document_relative
|
|
2076
|
+
self.document = current
|
|
2077
|
+
self.document_relative = relative
|
|
2078
|
+
self._outside_file = None
|
|
2079
|
+
# The file Blender wrote sits only in the engine's filesystem until the
|
|
2080
|
+
# session's own save carries it to the project.
|
|
2081
|
+
self.save_due = True
|
|
2082
|
+
return {"moved": True, "from": previous, "document": relative}
|
|
2083
|
+
if current == self._outside_file:
|
|
2084
|
+
return None
|
|
2085
|
+
self._outside_file = current
|
|
2086
|
+
return {"moved": False, "from": self.document_relative, "outside": current}
|
|
2087
|
+
|
|
2014
2088
|
def save_document(self):
|
|
2015
2089
|
"""Write the document -- Blender's own format, by Blender's own operator.
|
|
2016
2090
|
|
|
@@ -2280,8 +2354,8 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2280
2354
|
# result is what a render leaves, and it holds scene-linear pixels.
|
|
2281
2355
|
if linear:
|
|
2282
2356
|
render["linear"] = True
|
|
2283
|
-
answer = SESSION.
|
|
2284
|
-
{"position": position, "target": target, "up": up, "render": render}
|
|
2357
|
+
answer = SESSION.photograph(
|
|
2358
|
+
depsgraph, {"position": position, "target": target, "up": up, "render": render}
|
|
2285
2359
|
)
|
|
2286
2360
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
2287
2361
|
raise RuntimeError("The renderer did not answer with a photograph")
|
|
@@ -5329,6 +5403,11 @@ def _dispatch(request):
|
|
|
5329
5403
|
HISTORY.changed()
|
|
5330
5404
|
SESSION.mark_changed()
|
|
5331
5405
|
answer = execute(request["code"])
|
|
5406
|
+
# Read before the present, so the save that present asks for goes to the
|
|
5407
|
+
# file Blender now holds (`Session.follow_open_file`).
|
|
5408
|
+
followed = SESSION.follow_open_file()
|
|
5409
|
+
if followed is not None:
|
|
5410
|
+
answer["document"] = followed
|
|
5332
5411
|
# Every mutation is presented, the rule: the Model
|
|
5333
5412
|
# document is what the agent is looking at.
|
|
5334
5413
|
try:
|
|
@@ -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}`);
|
|
@@ -7,6 +7,7 @@ import {z} from 'zod';
|
|
|
7
7
|
import {applyGraphShader, bindGraphDraw, bindPaletteGraph, borrowGraphDrawBinding, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
|
|
8
8
|
import type {CompiledGraph} from './blender-node-graph';
|
|
9
9
|
import {bindNamedUvChannels} from './blender-texture-samplers';
|
|
10
|
+
import {copyWorldVisibility,worldVisibilityShader,worldVisibilityUniforms} from './blender-world-visibility';
|
|
10
11
|
|
|
11
12
|
const scalar = z.number().finite();
|
|
12
13
|
const color = z.tuple([scalar, scalar, scalar]);
|
|
@@ -55,6 +56,7 @@ function graphShadow(material: THREE.MeshPhysicalMaterial): THREE.MeshPhysicalMa
|
|
|
55
56
|
// which `copy` replaces with the material's own (it leaves hooks alone).
|
|
56
57
|
applyPhysicalMaterial(shadow);
|
|
57
58
|
shadow.copy(material);
|
|
59
|
+
copyWorldVisibility(material,shadow);
|
|
58
60
|
const values = uniforms.get(shadow)!;
|
|
59
61
|
if (held) {
|
|
60
62
|
values.blenderCoatIor.value = held.blenderCoatIor.value;
|
|
@@ -149,6 +151,7 @@ export function syncPhysicalDrawMaterial(source: THREE.Material, target?: THREE.
|
|
|
149
151
|
return null;
|
|
150
152
|
}
|
|
151
153
|
copy.copy(source);
|
|
154
|
+
copyWorldVisibility(source,copy);
|
|
152
155
|
const held = uniforms.get(source)!;
|
|
153
156
|
const values = uniforms.get(copy)!;
|
|
154
157
|
values.blenderCoatIor.value = held.blenderCoatIor.value;
|
|
@@ -192,7 +195,7 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
192
195
|
blenderWorldExtinction: {value: new THREE.Vector3()}};
|
|
193
196
|
uniforms.set(material, values);
|
|
194
197
|
const held = values;
|
|
195
|
-
material.customProgramCacheKey = () => `blender-principled-physical-
|
|
198
|
+
material.customProgramCacheKey = () => `blender-principled-physical-v6${graphProgramKey(material)}`;
|
|
196
199
|
material.onBeforeRender = (renderer, scene, camera, geometry, object) => {
|
|
197
200
|
bindNamedUvChannels(material, geometry);
|
|
198
201
|
bindGraphDraw(material, geometry, renderer, scene, camera, object, graphShadow);
|
|
@@ -210,6 +213,12 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
210
213
|
.map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
|
|
211
214
|
.join('\n') + '\n' + shader.vertexShader;
|
|
212
215
|
Object.assign(shader.uniforms, held);
|
|
216
|
+
worldVisibilityUniforms(material,shader.uniforms);
|
|
217
|
+
shader.vertexShader='varying vec3 blenderWorldPosition;\n'+shader.vertexShader;
|
|
218
|
+
shader.vertexShader=shader.vertexShader.replace('#include <worldpos_vertex>',
|
|
219
|
+
'#include <worldpos_vertex>\n#if defined(USE_ENVMAP) || defined(USE_SHADOWMAP) || defined(USE_TRANSMISSION)\nblenderWorldPosition=worldPosition.xyz;\n#else\nblenderWorldPosition=(modelMatrix*vec4(transformed,1.)).xyz;\n#endif');
|
|
220
|
+
shader.fragmentShader=shader.fragmentShader.replace('#include <envmap_physical_pars_fragment>',
|
|
221
|
+
'#include <envmap_physical_pars_fragment>\n'+worldVisibilityShader);
|
|
213
222
|
shader.fragmentShader = `uniform float blenderCoatIor;
|
|
214
223
|
uniform vec3 blenderWorldExtinction;
|
|
215
224
|
vec3 blenderInfiniteTransmission() { return vec3(
|
|
@@ -255,6 +264,10 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
255
264
|
'rectAreaLight = rectAreaLights[ i ]; rectAreaLight.color *= exp(-blenderWorldExtinction * length(rectAreaLight.position-geometryPosition));')
|
|
256
265
|
.replace('getAmbientLightIrradiance( ambientLightColor )', 'getAmbientLightIrradiance( ambientLightColor ) * blenderInfiniteTransmission()');
|
|
257
266
|
const environment = THREE.ShaderChunk.lights_fragment_maps
|
|
267
|
+
.replace('getIBLIrradiance( geometryNormal )',
|
|
268
|
+
'blenderWorldIrradiance( geometryNormal, getIBLIrradiance( geometryNormal ) )')
|
|
269
|
+
.replaceAll(/(getIBL(?:Radiance|AnisotropyRadiance)\([^;]+\))/g,
|
|
270
|
+
'$1 * blenderWorldReflectionVisibility(geometryViewDir,geometryNormal,material.roughness)')
|
|
258
271
|
.replaceAll(/(getIBL(?:Irradiance|Radiance|AnisotropyRadiance)\([^;]+\))/g, '$1 * blenderInfiniteTransmission()');
|
|
259
272
|
shader.fragmentShader = shader.fragmentShader.replace('#include <lights_physical_fragment>', physical)
|
|
260
273
|
.replace('#include <lights_fragment_begin>', lighting)
|
|
@@ -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. */
|
|
@@ -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
|
}
|
|
@@ -724,6 +724,7 @@ export type WorldData = z.infer<typeof worldSchema>;
|
|
|
724
724
|
* Constants and linked colors use the same radiometric path. The environment
|
|
725
725
|
* samples the field at 256×128; features below a texel are filtered. */
|
|
726
726
|
export class WorldBackground {
|
|
727
|
+
lightingTexture():THREE.Texture|null {return this.environmentTexture??this.texture;}
|
|
727
728
|
readonly solarLights: THREE.DirectionalLight[] = [];
|
|
728
729
|
private readonly solarDirections = new Map<THREE.DirectionalLight, THREE.Vector3>();
|
|
729
730
|
|
|
@@ -58,6 +58,11 @@ export function fitModelDirectionalShadow(
|
|
|
58
58
|
casters: THREE.Box3[],
|
|
59
59
|
): void {
|
|
60
60
|
if (!light.castShadow || !receivers.length) return;
|
|
61
|
+
// A detached photograph fits before its first render. Newly composed suns
|
|
62
|
+
// and their targets have not yet passed through Scene.updateMatrixWorld;
|
|
63
|
+
// fitting in those stale axes produces a different map on the first draw.
|
|
64
|
+
light.updateWorldMatrix(true, false);
|
|
65
|
+
light.target.updateWorldMatrix(true, false);
|
|
61
66
|
light.shadow.updateMatrices(light);
|
|
62
67
|
const camera = light.shadow.camera;
|
|
63
68
|
const view = camera.matrixWorldInverse;
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
worldSchema,
|
|
27
27
|
} from './blender-runtime-lighting';
|
|
28
28
|
import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
|
|
29
|
+
import {BlenderWorldVisibility,bindWorldVisibility} from './blender-world-visibility';
|
|
29
30
|
import { volumeMesh, volumeSchema } from './blender-runtime-volume';
|
|
30
31
|
import { applyPhysicalMaterial, applyWorldExtinction, physicalMaterialSchema } from './blender-physical-material';
|
|
31
32
|
import { prepareGraphGeometry, setMaterialGraph } from './blender-graph-material';
|
|
@@ -681,6 +682,7 @@ export class BlenderRuntimeView {
|
|
|
681
682
|
private readonly lights = new Map<string, THREE.Light>();
|
|
682
683
|
private readonly lighting = new ViewportLighting();
|
|
683
684
|
private readonly world = new WorldBackground();
|
|
685
|
+
private readonly worldVisibility=new BlenderWorldVisibility();
|
|
684
686
|
/**
|
|
685
687
|
* THE INSPECTION OVERLAYS (I4). They are NOT children of {@link root}, and
|
|
686
688
|
* that is the whole design: an overlay is EDITOR FURNITURE, so its
|
|
@@ -1333,6 +1335,13 @@ export class BlenderRuntimeView {
|
|
|
1333
1335
|
// objects. Fit shadows for that draw, rather than the camera's pose
|
|
1334
1336
|
// when Rendered shading was first entered. Captures use this path too.
|
|
1335
1337
|
if (this.rendered) this.applyShadows(true, camera);
|
|
1338
|
+
this.worldVisibility.sync(this.objects.values(),this.world.lightingTexture(),this.root,this.rendered);
|
|
1339
|
+
for(const material of [...this.materials.values(),this.fallback])bindWorldVisibility(material,this.worldVisibility);
|
|
1340
|
+
for(const object of this.objects.values()) {
|
|
1341
|
+
const mesh=object as THREE.Mesh;if(!mesh.isMesh)continue;
|
|
1342
|
+
for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])bindWorldVisibility(material,this.worldVisibility);
|
|
1343
|
+
}
|
|
1344
|
+
if(options?.renderer)this.worldVisibility.prepare(options.renderer);
|
|
1336
1345
|
const opaqueDone = performance.now();
|
|
1337
1346
|
const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
|
|
1338
1347
|
options?.interactive === true, options?.height ?? 0);
|
|
@@ -2222,7 +2231,7 @@ export class BlenderRuntimeView {
|
|
|
2222
2231
|
}
|
|
2223
2232
|
|
|
2224
2233
|
drawStatistics() {
|
|
2225
|
-
return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
|
|
2234
|
+
return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, worldVisibility:this.worldVisibility.diagnostics(), materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
|
|
2226
2235
|
}
|
|
2227
2236
|
|
|
2228
2237
|
snapshot() {
|
|
@@ -2404,10 +2413,48 @@ export class BlenderRuntimeView {
|
|
|
2404
2413
|
if (disposed) throw new Error('Blender capture snapshot is disposed');
|
|
2405
2414
|
return detached.setRendered(true, camera);
|
|
2406
2415
|
},
|
|
2416
|
+
prepareDraw:(renderer:THREE.WebGLRenderer,camera:THREE.Camera)=>{
|
|
2417
|
+
if(disposed)throw new Error('Blender capture snapshot is disposed');
|
|
2418
|
+
return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer});
|
|
2419
|
+
},
|
|
2407
2420
|
dispose,
|
|
2408
2421
|
};
|
|
2409
2422
|
}
|
|
2410
2423
|
|
|
2424
|
+
/** Build a render-evaluated frame without ever publishing it to a viewport. */
|
|
2425
|
+
static forPhotograph(): BlenderRuntimeView {
|
|
2426
|
+
const view = new BlenderRuntimeView();
|
|
2427
|
+
view.photograph = true;
|
|
2428
|
+
return view;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/** Transfer this render-only view to its capture owner. Its resources are
|
|
2432
|
+
* already independent, so a second geometry/texture copy is unnecessary. */
|
|
2433
|
+
takeCaptureSnapshot(): ReturnType<BlenderRuntimeView['captureSnapshot']> {
|
|
2434
|
+
if (!this.photograph || !this.frame) throw new Error('A render-evaluated frame is required');
|
|
2435
|
+
const source = this.frame;
|
|
2436
|
+
const effect = this.createWorldVolumePass();
|
|
2437
|
+
let disposed = false;
|
|
2438
|
+
return {
|
|
2439
|
+
effect, root: this.root, session: source.session, revision: source.revision,
|
|
2440
|
+
prepare: camera => {
|
|
2441
|
+
if (disposed) throw new Error('Blender capture snapshot is disposed');
|
|
2442
|
+
return this.setRendered(true, camera);
|
|
2443
|
+
},
|
|
2444
|
+
prepareDraw: (renderer, camera) => {
|
|
2445
|
+
if (disposed) throw new Error('Blender capture snapshot is disposed');
|
|
2446
|
+
return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer });
|
|
2447
|
+
},
|
|
2448
|
+
dispose: () => {
|
|
2449
|
+
if (disposed) return;
|
|
2450
|
+
disposed = true;
|
|
2451
|
+
effect?.dispose();
|
|
2452
|
+
this.dispose();
|
|
2453
|
+
this.root.removeFromParent();
|
|
2454
|
+
},
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2411
2458
|
createWorldVolumePass(): WorldVolumePass | undefined {
|
|
2412
2459
|
const medium = worldMedium(this.frame?.world);
|
|
2413
2460
|
return medium ? new WorldVolumePass(medium) : undefined;
|
|
@@ -2452,6 +2499,7 @@ export class BlenderRuntimeView {
|
|
|
2452
2499
|
}
|
|
2453
2500
|
|
|
2454
2501
|
private clear() {
|
|
2502
|
+
this.worldVisibility.dispose();
|
|
2455
2503
|
this.objectInfoMaterials.clear();
|
|
2456
2504
|
this.materialRanges.clear();
|
|
2457
2505
|
this.motionGeometry.clear();
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Directional world-light visibility. One shared depth atlas replaces repeated
|
|
2
|
+
* per-pixel triangle traversal. Geometry/placements are derived draw resources;
|
|
3
|
+
* Blender data and the visible canvas are never changed. This is finite
|
|
4
|
+
* directional quadrature, not indirect light transport. */
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
|
|
7
|
+
const SAMPLE_COUNT=16, TILES=4, TILE_SIZE=512;
|
|
8
|
+
type Segment={mesh:THREE.Mesh; geometry:THREE.BufferGeometry; position:THREE.BufferAttribute|THREE.InterleavedBufferAttribute;
|
|
9
|
+
index:THREE.BufferAttribute|null; positionVersion:number; indexVersion:number; start:number; count:number;
|
|
10
|
+
offset:number; matrix:THREE.Matrix4; shown:boolean; bounds:THREE.Box3; dynamic:boolean};
|
|
11
|
+
const version=(attribute:THREE.BufferAttribute|THREE.InterleavedBufferAttribute)=>
|
|
12
|
+
'isInterleavedBufferAttribute' in attribute ? attribute.data.version : attribute.version;
|
|
13
|
+
function shown(mesh:THREE.Mesh):boolean {
|
|
14
|
+
if(!mesh.castShadow || !mesh.layers.isEnabled(0))return false;
|
|
15
|
+
for(let parent:THREE.Object3D|null=mesh;parent;parent=parent.parent)if(!parent.visible)return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class BlenderWorldVisibility {
|
|
20
|
+
readonly uniforms={
|
|
21
|
+
blenderWorldVisibilityEnabled:{value:false},
|
|
22
|
+
blenderWorldDepth:{value:null as THREE.Texture|null},
|
|
23
|
+
blenderWorldDynamicDepth:{value:null as THREE.Texture|null},
|
|
24
|
+
blenderWorldHasDynamicDepth:{value:false},
|
|
25
|
+
blenderWorldRadiance:{value:null as THREE.Texture|null},
|
|
26
|
+
blenderWorldDirections:{value:Array.from({length:SAMPLE_COUNT},(_,i)=>{
|
|
27
|
+
const z=1-2*(i+.5)/SAMPLE_COUNT,angle=i*2.399963229728653;
|
|
28
|
+
return new THREE.Vector3(Math.sqrt(1-z*z)*Math.cos(angle),z,Math.sqrt(1-z*z)*Math.sin(angle));
|
|
29
|
+
})},
|
|
30
|
+
blenderWorldMatrices:{value:Array.from({length:SAMPLE_COUNT},()=>new THREE.Matrix4())},
|
|
31
|
+
blenderWorldToNative:{value:new THREE.Matrix3()},
|
|
32
|
+
blenderWorldRayOffset:{value:1e-5},
|
|
33
|
+
};
|
|
34
|
+
private target:THREE.WebGLRenderTarget|null=null;
|
|
35
|
+
private dynamicTarget:THREE.WebGLRenderTarget|null=null;
|
|
36
|
+
private readonly dynamicScene=new THREE.Scene();
|
|
37
|
+
private readonly dynamicMeshes=new Map<THREE.Mesh,THREE.Mesh>();
|
|
38
|
+
private readonly scene=new THREE.Scene();
|
|
39
|
+
private readonly geometry=new THREE.BufferGeometry();
|
|
40
|
+
private readonly material=new THREE.MeshDepthMaterial({side:THREE.DoubleSide});
|
|
41
|
+
private readonly mesh=new THREE.Mesh(this.geometry,this.material);
|
|
42
|
+
private readonly camera=new THREE.OrthographicCamera();
|
|
43
|
+
private segments:Segment[]=[];
|
|
44
|
+
private dirty=true;
|
|
45
|
+
private staticDirty=true;
|
|
46
|
+
private dynamicDirty=false;
|
|
47
|
+
private fittedBounds=new THREE.Box3();
|
|
48
|
+
private passes=0;
|
|
49
|
+
private staticPasses=0;
|
|
50
|
+
private dynamicPasses=0;
|
|
51
|
+
constructor(){this.mesh.frustumCulled=false;this.scene.add(this.mesh);}
|
|
52
|
+
|
|
53
|
+
sync(objects:Iterable<THREE.Object3D>,world:THREE.Texture|null,root:THREE.Object3D,enabled:boolean):void {
|
|
54
|
+
this.uniforms.blenderWorldVisibilityEnabled.value=enabled && world!==null;
|
|
55
|
+
this.uniforms.blenderWorldRadiance.value=world;
|
|
56
|
+
this.uniforms.blenderWorldToNative.value.setFromMatrix4(root.matrixWorld).invert();
|
|
57
|
+
if(!enabled || !world)return;
|
|
58
|
+
const meshes=[...objects].filter((object):object is THREE.Mesh=>(object as THREE.Mesh).isMesh===true &&
|
|
59
|
+
!!(object as THREE.Mesh).geometry.getAttribute('position'));
|
|
60
|
+
const layoutChanged=meshes.length!==this.segments.length || meshes.some((mesh,i)=>{
|
|
61
|
+
const previous=this.segments[i],geometry=mesh.geometry;
|
|
62
|
+
return !previous || previous.mesh!==mesh || previous.geometry!==geometry ||
|
|
63
|
+
previous.position!==geometry.getAttribute('position') || previous.index!==geometry.index ||
|
|
64
|
+
previous.start!==geometry.drawRange.start || previous.count!==Math.min(geometry.drawRange.count,
|
|
65
|
+
(geometry.index?.count ?? geometry.getAttribute('position').count)-geometry.drawRange.start);
|
|
66
|
+
});
|
|
67
|
+
if(layoutChanged) {
|
|
68
|
+
for(const mesh of this.dynamicMeshes.values()){mesh.removeFromParent();mesh.geometry.dispose();}
|
|
69
|
+
this.dynamicMeshes.clear();this.dynamicDirty=true;this.fittedBounds.makeEmpty();
|
|
70
|
+
let offset=0;
|
|
71
|
+
this.segments=meshes.map(mesh=>{
|
|
72
|
+
const geometry=mesh.geometry,position=geometry.getAttribute('position'),index=geometry.index;
|
|
73
|
+
const start=geometry.drawRange.start,count=Math.max(0,Math.min(geometry.drawRange.count,(index?.count??position.count)-start));
|
|
74
|
+
const segment={mesh,geometry,position,index,positionVersion:-1,indexVersion:-1,start,count,
|
|
75
|
+
offset,matrix:new THREE.Matrix4(),shown:!shown(mesh),bounds:new THREE.Box3(),dynamic:false};
|
|
76
|
+
offset+=count;return segment;
|
|
77
|
+
});
|
|
78
|
+
this.geometry.setAttribute('position',new THREE.BufferAttribute(new Float32Array(offset*3),3));
|
|
79
|
+
this.dirty=true;this.staticDirty=true;
|
|
80
|
+
}
|
|
81
|
+
const attribute=this.geometry.getAttribute('position') as THREE.BufferAttribute|undefined;
|
|
82
|
+
if(!attribute)return;
|
|
83
|
+
const point=new THREE.Vector3();
|
|
84
|
+
for(const segment of this.segments) {
|
|
85
|
+
const visible=shown(segment.mesh),pv=version(segment.position),iv=segment.index?.version??0;
|
|
86
|
+
if(segment.shown===visible && segment.matrix.equals(segment.mesh.matrixWorld) &&
|
|
87
|
+
segment.positionVersion===pv && segment.indexVersion===iv)continue;
|
|
88
|
+
// Once an object moves/deforms, keep it in the small dynamic atlas. Its
|
|
89
|
+
// later poses must never redraw every static triangle in the environment.
|
|
90
|
+
if(!segment.dynamic && segment.positionVersion>=0 &&
|
|
91
|
+
(!segment.matrix.equals(segment.mesh.matrixWorld) || segment.positionVersion!==pv || segment.indexVersion!==iv)) {
|
|
92
|
+
const geometry=new THREE.BufferGeometry();
|
|
93
|
+
geometry.setAttribute('position',new THREE.BufferAttribute(new Float32Array(segment.count*3),3));
|
|
94
|
+
const mesh=new THREE.Mesh(geometry,this.material);mesh.frustumCulled=false;
|
|
95
|
+
this.dynamicScene.add(mesh);this.dynamicMeshes.set(segment.mesh,mesh);segment.dynamic=true;
|
|
96
|
+
(attribute.array as Float32Array).fill(0,segment.offset*3,(segment.offset+segment.count)*3);
|
|
97
|
+
attribute.addUpdateRange(segment.offset*3,segment.count*3);attribute.needsUpdate=true;this.staticDirty=true;
|
|
98
|
+
}
|
|
99
|
+
const dynamicMesh=this.dynamicMeshes.get(segment.mesh);
|
|
100
|
+
const written=(dynamicMesh?.geometry.getAttribute('position')??attribute) as THREE.BufferAttribute;
|
|
101
|
+
if(dynamicMesh)dynamicMesh.visible=visible;
|
|
102
|
+
segment.bounds.makeEmpty();
|
|
103
|
+
for(let i=0;i<segment.count;i++) {
|
|
104
|
+
if(visible) {
|
|
105
|
+
const vertex=segment.index?.getX(segment.start+i) ?? segment.start+i;
|
|
106
|
+
point.fromBufferAttribute(segment.position,vertex).applyMatrix4(segment.mesh.matrixWorld);
|
|
107
|
+
segment.bounds.expandByPoint(point);
|
|
108
|
+
} else point.set(0,0,0); // Degenerate the hidden range without changing its layout.
|
|
109
|
+
written.setXYZ(dynamicMesh?i:segment.offset+i,point.x,point.y,point.z);
|
|
110
|
+
}
|
|
111
|
+
written.addUpdateRange(dynamicMesh?0:segment.offset*3,segment.count*3);written.needsUpdate=true;
|
|
112
|
+
if(dynamicMesh)this.dynamicDirty=true;else this.staticDirty=true;
|
|
113
|
+
segment.shown=visible;segment.matrix.copy(segment.mesh.matrixWorld);
|
|
114
|
+
segment.positionVersion=pv;segment.indexVersion=iv;this.dirty=true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Draw the atlas only when a caster changes. All directional samples share
|
|
119
|
+
* one texture sampler and one merged depth draw, retaining offscreen casters. */
|
|
120
|
+
prepare(renderer:THREE.WebGLRenderer):void {
|
|
121
|
+
if(!this.uniforms.blenderWorldVisibilityEnabled.value || !this.dirty)return;
|
|
122
|
+
if(!this.target) {
|
|
123
|
+
const size=TILES*TILE_SIZE;
|
|
124
|
+
this.target=new THREE.WebGLRenderTarget(size,size,{depthBuffer:true,stencilBuffer:false});
|
|
125
|
+
this.target.depthTexture=new THREE.DepthTexture(size,size,THREE.UnsignedIntType);
|
|
126
|
+
this.target.depthTexture.minFilter=this.target.depthTexture.magFilter=THREE.NearestFilter;
|
|
127
|
+
this.uniforms.blenderWorldDepth.value=this.target.depthTexture;
|
|
128
|
+
}
|
|
129
|
+
if(this.dynamicMeshes.size && !this.dynamicTarget) {
|
|
130
|
+
const size=TILES*TILE_SIZE;
|
|
131
|
+
this.dynamicTarget=new THREE.WebGLRenderTarget(size,size,{depthBuffer:true,stencilBuffer:false});
|
|
132
|
+
this.dynamicTarget.depthTexture=new THREE.DepthTexture(size,size,THREE.UnsignedIntType);
|
|
133
|
+
this.dynamicTarget.depthTexture.minFilter=this.dynamicTarget.depthTexture.magFilter=THREE.NearestFilter;
|
|
134
|
+
this.uniforms.blenderWorldDynamicDepth.value=this.dynamicTarget.depthTexture;
|
|
135
|
+
}
|
|
136
|
+
this.uniforms.blenderWorldHasDynamicDepth.value=this.dynamicMeshes.size>0;
|
|
137
|
+
const bounds=this.segments.reduce((all,segment)=>all.union(segment.bounds),new THREE.Box3());
|
|
138
|
+
if(this.fittedBounds.isEmpty() || !this.fittedBounds.containsBox(bounds)) {
|
|
139
|
+
this.fittedBounds.union(bounds);this.staticDirty=true;
|
|
140
|
+
}
|
|
141
|
+
const sphere=this.fittedBounds.getBoundingSphere(new THREE.Sphere());
|
|
142
|
+
this.mesh.visible=(this.geometry.getAttribute('position')?.count??0)>0;
|
|
143
|
+
const radius=Math.max(sphere.radius,1e-3),distance=radius*2+1;
|
|
144
|
+
this.uniforms.blenderWorldRayOffset.value=radius*1e-5;
|
|
145
|
+
this.camera.left=-radius;this.camera.right=radius;this.camera.top=radius;this.camera.bottom=-radius;
|
|
146
|
+
this.camera.near=.001;this.camera.far=distance+radius*2;
|
|
147
|
+
this.camera.updateProjectionMatrix();
|
|
148
|
+
const target=renderer.getRenderTarget(),autoClear=renderer.autoClear,shadowMap=renderer.shadowMap.enabled;
|
|
149
|
+
try {
|
|
150
|
+
renderer.autoClear=false;renderer.shadowMap.enabled=false;
|
|
151
|
+
const targets:[THREE.WebGLRenderTarget,THREE.Scene,boolean][]=[[this.target,this.scene,this.staticDirty]];
|
|
152
|
+
if(this.dynamicTarget)targets.push([this.dynamicTarget,this.dynamicScene,this.dynamicDirty||this.staticDirty]);
|
|
153
|
+
for(const [target,,draw] of targets)if(draw) {
|
|
154
|
+
target.viewport.set(0,0,TILES*TILE_SIZE,TILES*TILE_SIZE);target.scissorTest=false;
|
|
155
|
+
renderer.setRenderTarget(target);renderer.clear(true,true,false);
|
|
156
|
+
}
|
|
157
|
+
for(let i=0;i<SAMPLE_COUNT;i++) {
|
|
158
|
+
const direction=this.uniforms.blenderWorldDirections.value[i]!;
|
|
159
|
+
this.camera.position.copy(sphere.center).addScaledVector(direction,distance);
|
|
160
|
+
this.camera.up.set(0,1,0);this.camera.lookAt(sphere.center);this.camera.updateMatrixWorld(true);
|
|
161
|
+
this.uniforms.blenderWorldMatrices.value[i]!.multiplyMatrices(this.camera.projectionMatrix,this.camera.matrixWorldInverse);
|
|
162
|
+
const x=(i%TILES)*TILE_SIZE,y=Math.floor(i/TILES)*TILE_SIZE;
|
|
163
|
+
// Render-target viewports use device pixels. Renderer.setViewport uses
|
|
164
|
+
// logical pixels even offscreen, so a HiDPI view would overlap the tiles.
|
|
165
|
+
for(const [target,scene,draw] of targets)if(draw) {
|
|
166
|
+
target.viewport.set(x,y,TILE_SIZE,TILE_SIZE);target.scissor.set(x,y,TILE_SIZE,TILE_SIZE);target.scissorTest=true;
|
|
167
|
+
renderer.setRenderTarget(target);renderer.render(scene,this.camera);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if(this.staticDirty){this.passes+=SAMPLE_COUNT;this.staticPasses+=SAMPLE_COUNT;}
|
|
171
|
+
if(this.dynamicTarget && (this.dynamicDirty||this.staticDirty)){this.passes+=SAMPLE_COUNT;this.dynamicPasses+=SAMPLE_COUNT;}
|
|
172
|
+
this.dirty=false;this.staticDirty=false;this.dynamicDirty=false;
|
|
173
|
+
} finally {
|
|
174
|
+
renderer.setRenderTarget(target);renderer.autoClear=autoClear;renderer.shadowMap.enabled=shadowMap;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
diagnostics(){return {enabled:this.uniforms.blenderWorldVisibilityEnabled.value,world:!!this.uniforms.blenderWorldRadiance.value,
|
|
178
|
+
depth:!!this.uniforms.blenderWorldDepth.value,passes:this.passes,staticPasses:this.staticPasses,dynamicPasses:this.dynamicPasses,
|
|
179
|
+
dynamicObjects:this.dynamicMeshes.size,vertices:this.geometry.getAttribute('position')?.count??0,
|
|
180
|
+
casters:this.segments.filter(segment=>segment.shown).length,dirty:this.dirty};}
|
|
181
|
+
dispose():void {
|
|
182
|
+
this.uniforms.blenderWorldVisibilityEnabled.value=false;
|
|
183
|
+
this.target?.dispose();this.target=null;this.uniforms.blenderWorldDepth.value=null;
|
|
184
|
+
this.dynamicTarget?.dispose();this.dynamicTarget=null;this.uniforms.blenderWorldDynamicDepth.value=null;
|
|
185
|
+
this.uniforms.blenderWorldHasDynamicDepth.value=false;
|
|
186
|
+
for(const mesh of this.dynamicMeshes.values()){mesh.removeFromParent();mesh.geometry.dispose();}this.dynamicMeshes.clear();
|
|
187
|
+
this.geometry.dispose();this.material.dispose();this.segments=[];
|
|
188
|
+
this.geometry.deleteAttribute('position');this.dirty=true;this.staticDirty=true;this.dynamicDirty=false;this.fittedBounds.makeEmpty();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bindings=new WeakMap<THREE.Material,BlenderWorldVisibility>();
|
|
193
|
+
const compiled=new WeakMap<THREE.Material,Set<THREE.WebGLProgramParametersWithUniforms['uniforms']>>();
|
|
194
|
+
const inactive=new BlenderWorldVisibility();
|
|
195
|
+
export function bindWorldVisibility(material:THREE.Material,visibility:BlenderWorldVisibility):void {
|
|
196
|
+
if(bindings.get(material)===visibility)return;
|
|
197
|
+
bindings.set(material,visibility);
|
|
198
|
+
for(const uniforms of compiled.get(material)??[])Object.assign(uniforms,visibility.uniforms);
|
|
199
|
+
}
|
|
200
|
+
export function copyWorldVisibility(source:THREE.Material,target:THREE.Material):void {
|
|
201
|
+
const visibility=bindings.get(source);if(visibility)bindWorldVisibility(target,visibility);
|
|
202
|
+
}
|
|
203
|
+
export function worldVisibilityUniforms(material:THREE.Material,uniforms:THREE.WebGLProgramParametersWithUniforms['uniforms']):void {
|
|
204
|
+
let held=compiled.get(material);if(!held){held=new Set();compiled.set(material,held);}held.add(uniforms);
|
|
205
|
+
Object.assign(uniforms,(bindings.get(material)??inactive).uniforms);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const worldVisibilityShader=/* glsl */`
|
|
209
|
+
varying vec3 blenderWorldPosition;
|
|
210
|
+
uniform bool blenderWorldVisibilityEnabled;
|
|
211
|
+
uniform sampler2D blenderWorldDepth, blenderWorldDynamicDepth, blenderWorldRadiance;
|
|
212
|
+
uniform bool blenderWorldHasDynamicDepth;
|
|
213
|
+
uniform vec3 blenderWorldDirections[16];
|
|
214
|
+
uniform mat4 blenderWorldMatrices[16];
|
|
215
|
+
uniform mat3 blenderWorldToNative;
|
|
216
|
+
uniform float blenderWorldRayOffset;
|
|
217
|
+
float blenderWorldVisible(int i, vec3 position, vec3 worldDx, vec3 worldDy) {
|
|
218
|
+
vec4 clip=blenderWorldMatrices[i]*vec4(position,1.);
|
|
219
|
+
vec3 point=clip.xyz/clip.w*.5+.5;
|
|
220
|
+
if(any(lessThan(point,vec3(0.))) || any(greaterThan(point,vec3(1.))))return 1.;
|
|
221
|
+
vec2 tile=vec2(float(i%4),float(i/4));
|
|
222
|
+
vec2 samplePoint=clamp((floor(point.xy*512.)+.5)/512.,vec2(.5/512.),vec2(1.-.5/512.));
|
|
223
|
+
vec2 uv=(tile+samplePoint)/4.;
|
|
224
|
+
float depth=texture2D(blenderWorldDepth,uv).x;
|
|
225
|
+
if(blenderWorldHasDynamicDepth)depth=min(depth,texture2D(blenderWorldDynamicDepth,uv).x);
|
|
226
|
+
// Compare the receiver plane AT THE DEPTH TEXEL, not at its nearby fragment.
|
|
227
|
+
// A fixed depth bias falsely shadows an unoccluded tilted plane; increasing
|
|
228
|
+
// that bias leaks through thin walls. Derivatives retain its actual slope.
|
|
229
|
+
vec3 dx=(blenderWorldMatrices[i]*vec4(worldDx,0.)).xyz*.5;
|
|
230
|
+
vec3 dy=(blenderWorldMatrices[i]*vec4(worldDy,0.)).xyz*.5;
|
|
231
|
+
float determinant=dx.x*dy.y-dx.y*dy.x;
|
|
232
|
+
vec2 gradient=abs(determinant)>1e-12 ? vec2(dx.z*dy.y-dy.z*dx.y,dy.z*dx.x-dx.z*dy.x)/determinant : vec2(0.);
|
|
233
|
+
float receiverDepth=point.z+dot(gradient,samplePoint-point.xy);
|
|
234
|
+
return receiverDepth<=depth+1e-6 ? 1. : 0.;
|
|
235
|
+
}
|
|
236
|
+
vec3 blenderWorldIrradiance(vec3 normal, vec3 fallbackIrradiance) {
|
|
237
|
+
if(!blenderWorldVisibilityEnabled)return fallbackIrradiance;
|
|
238
|
+
vec3 n=inverseTransformDirection(normal,viewMatrix),sum=vec3(0.);
|
|
239
|
+
float weights=0.;vec3 origin=blenderWorldPosition+n*blenderWorldRayOffset;
|
|
240
|
+
vec3 worldDx=dFdx(blenderWorldPosition),worldDy=dFdy(blenderWorldPosition);
|
|
241
|
+
for(int i=0;i<16;i++) {
|
|
242
|
+
vec3 direction=blenderWorldDirections[i];float weight=max(0.,dot(n,direction));weights+=weight;
|
|
243
|
+
if(weight>0. && blenderWorldVisible(i,origin,worldDx,worldDy)>0.) {
|
|
244
|
+
vec3 nativeDirection=normalize(blenderWorldToNative*direction);
|
|
245
|
+
vec2 uv=vec2(atan(-nativeDirection.y,nativeDirection.x)/6.28318530718+.5,
|
|
246
|
+
asin(clamp(nativeDirection.z,-1.,1.))/3.14159265359+.5);
|
|
247
|
+
sum+=texture2D(blenderWorldRadiance,uv).rgb*weight;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return sum*(3.14159265359/max(weights,1e-8));
|
|
251
|
+
}
|
|
252
|
+
float blenderWorldReflectionVisibility(vec3 viewDirection,vec3 normal,float roughness) {
|
|
253
|
+
if(!blenderWorldVisibilityEnabled)return 1.;
|
|
254
|
+
vec3 n=inverseTransformDirection(normal,viewMatrix),r=inverseTransformDirection(reflect(-viewDirection,normal),viewMatrix);
|
|
255
|
+
vec3 origin=blenderWorldPosition+n*blenderWorldRayOffset;
|
|
256
|
+
vec3 worldDx=dFdx(blenderWorldPosition),worldDy=dFdy(blenderWorldPosition);
|
|
257
|
+
float sum=0.,weights=0.;
|
|
258
|
+
for(int i=0;i<16;i++) {
|
|
259
|
+
float weight=pow(max(0.,dot(r,blenderWorldDirections[i])),mix(64.,1.,roughness));
|
|
260
|
+
weights+=weight;sum+=weight*blenderWorldVisible(i,origin,worldDx,worldDy);
|
|
261
|
+
}
|
|
262
|
+
return sum/max(weights,1e-8);
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
@@ -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.
|
|
@@ -577,6 +578,43 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
577
578
|
return failed;
|
|
578
579
|
}
|
|
579
580
|
|
|
581
|
+
/** What `session.py::follow_open_file` says after a script changed the file Blender holds. */
|
|
582
|
+
type FollowedDocument =
|
|
583
|
+
| { moved: true; from: string; document: string }
|
|
584
|
+
| { moved: false; from: string; outside: string };
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* THE DOCUMENT FOLLOWS BLENDER'S SAVE AS, and the script's caller is told in its own answer.
|
|
588
|
+
*
|
|
589
|
+
* A move retargets the session's saves here (the save this call ends with lands the new file
|
|
590
|
+
* in the project, which is where the editor's Model tab for it opens from) and tells the tab,
|
|
591
|
+
* whose runtime handle holds the same path. A file outside the project is refused: the editor
|
|
592
|
+
* has no document there, so the session keeps its own and keeps saving to it, and says so
|
|
593
|
+
* naming both files, since the script believes it is now working in the other one.
|
|
594
|
+
*/
|
|
595
|
+
function followDocument(followed: FollowedDocument | undefined): string {
|
|
596
|
+
if (!followed) return '';
|
|
597
|
+
if (!followed.moved) {
|
|
598
|
+
const line = `Blender now has ${followed.outside} open, which is outside the project, so the editor cannot ` +
|
|
599
|
+
`follow it there: this session's document stays ${followed.from}, and the model keeps saving to ` +
|
|
600
|
+
`${followed.from}. To move the model to another file, save it inside the project (src/models/<name>.blend).`;
|
|
601
|
+
log('warn', line);
|
|
602
|
+
return `\n\n${line}`;
|
|
603
|
+
}
|
|
604
|
+
if (!isDocumentPath(followed.document)) {
|
|
605
|
+
// The session checks the same shape, so this is a broken promise, not a refusal to explain.
|
|
606
|
+
throw new Error(`Blender moved its document to ${JSON.stringify(followed.document)}, which is not a project .blend path`);
|
|
607
|
+
}
|
|
608
|
+
documentPath = followed.document;
|
|
609
|
+
// Even a present that failed must not leave the new file only in the engine's filesystem.
|
|
610
|
+
setDocumentDirty(true);
|
|
611
|
+
post({ op: 'document-moved', from: followed.from, document: followed.document });
|
|
612
|
+
const line = `Blender now has ${followed.document} open (this code saved or opened it), so the editor follows it: ` +
|
|
613
|
+
`the model saves to ${followed.document} from now on, and ${followed.from} keeps what it last saved.`;
|
|
614
|
+
log('log', `@@VOLTER-DOCUMENT-MOVED ${JSON.stringify({ from: followed.from, document: followed.document })}`);
|
|
615
|
+
return `\n\n${line}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
580
618
|
/** The failures `stageProjectFiles` answered, as one paragraph a script's caller can read. */
|
|
581
619
|
function stagingNote(failed: readonly string[]): string {
|
|
582
620
|
return failed.length === 0 ? '' :
|
|
@@ -678,9 +716,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
678
716
|
const note = stagingNote(await stageProjectFiles(files, projectRoot));
|
|
679
717
|
const answer = await ask({ op: 'execute', code: request.code, history: request.history ?? true,
|
|
680
718
|
label: request.label ?? 'Blender Python' }) as {
|
|
681
|
-
error?: string; result: string;
|
|
719
|
+
error?: string; result: string; document?: FollowedDocument;
|
|
682
720
|
};
|
|
683
|
-
return (answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`) +
|
|
721
|
+
return (answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`) +
|
|
722
|
+
note + followDocument(answer.document);
|
|
684
723
|
}
|
|
685
724
|
case 'present':
|
|
686
725
|
// Straight through to `session.py`'s own `present` op — the worker adds
|
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
|