@volter/blender-engine 0.1.0
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/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""OUR BLENDER RENDERS WITH THREE.JS -- the ask, shipped INSIDE the Blender.
|
|
2
|
+
|
|
3
|
+
THE RENDER OVERRIDE IS NOT THE EDITOR'S. `session.py` is the editor tab's
|
|
4
|
+
long-lived session and carries this same override for the session it owns;
|
|
5
|
+
this file is the other delivery of it -- a Blender STARTUP module that ships in
|
|
6
|
+
the pack's resource tree (`scripts/startup/vgai_three.py`), so a bare
|
|
7
|
+
`blender -b ... -a` on ANY surface renders with three.js too. Blender imports
|
|
8
|
+
every module in `scripts/startup` and calls its `register()`.
|
|
9
|
+
|
|
10
|
+
THE ASK SHIPS WITH THE BLENDER; THE ANSWER DOES NOT. What renders is a
|
|
11
|
+
PRESENTER the host attached to this process (`browser/three/attach-presenter.ts`
|
|
12
|
+
around `browser/three/presenter.ts`): a canvas, the frame translated to
|
|
13
|
+
three.js, a camera, the display transform. This file only asks. The channel is
|
|
14
|
+
the directory protocol `session.py`'s header states, with one writer per
|
|
15
|
+
directory:
|
|
16
|
+
|
|
17
|
+
<root>/ask/ THIS PROGRAM writes and unlinks; the host only reads.
|
|
18
|
+
<root>/reply/ the HOST writes and unlinks; this program only reads.
|
|
19
|
+
<root>/arena.bin THIS PROGRAM writes (the export door does); host reads.
|
|
20
|
+
|
|
21
|
+
NO PRESENTER, NO OVERRIDE. `register()` asks `{"op": "hello"}` and waits two
|
|
22
|
+
seconds. Answered, it takes the engine ids and every render is a photograph.
|
|
23
|
+
Unanswered, it prints ONE line naming the root it waited at and registers
|
|
24
|
+
nothing at all, so Blender renders with Cycles -- correct, slow, and never
|
|
25
|
+
silent.
|
|
26
|
+
|
|
27
|
+
THE ENGINE IS TAKEN ON `load_post`, NOT IN `register()`, and that is a
|
|
28
|
+
measurement. Blender's `bpy.utils.load_scripts` registers every startup module
|
|
29
|
+
BEFORE it enables the add-ons, so at `register()` time `cycles.CyclesRender`
|
|
30
|
+
does not exist yet and taking the `CYCLES` id here would be overwritten by the
|
|
31
|
+
add-on moments later. Measured on the native 5.2.0 LTS oracle
|
|
32
|
+
(`blender -b --python-expr ...` with this module's probe in
|
|
33
|
+
`BLENDER_USER_SCRIPTS`): at `register()` `CYCLES` is unregistered, and the
|
|
34
|
+
FIRST `load_post` -- which Blender fires for the startup file itself, before
|
|
35
|
+
any `--python-expr` runs -- sees `<class 'cycles.CyclesRender'>`. So the
|
|
36
|
+
handler is where the ids are taken, and it runs again after every file load
|
|
37
|
+
because a load re-enables the add-ons and hands `CYCLES` back.
|
|
38
|
+
|
|
39
|
+
WHAT THIS FILE DOES NOT CARRY, deliberately: the editor session's incremental
|
|
40
|
+
geometry record (every frame ships in full -- `known` is always empty, so no
|
|
41
|
+
presenter can ever be asked about geometry it does not hold), its document,
|
|
42
|
+
its overlays, and its world SHADER reducer. A world here is a CONSTANT
|
|
43
|
+
background or nothing, named as a warning; see `_world`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import base64
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import sys
|
|
50
|
+
import time
|
|
51
|
+
|
|
52
|
+
import bpy
|
|
53
|
+
|
|
54
|
+
# THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
|
|
55
|
+
# evaluates the scene and writes every column into a side arena, naming each as
|
|
56
|
+
# `{offset, length, dtype, count, stride}` in a small JSON frame.
|
|
57
|
+
import _blender_web
|
|
58
|
+
|
|
59
|
+
ROOT = os.environ.get("VGAI_PRESENTER_ROOT", "/tmp/vgai-presenter")
|
|
60
|
+
ASK = os.path.join(ROOT, "ask")
|
|
61
|
+
REPLY = os.path.join(ROOT, "reply")
|
|
62
|
+
# WHERE THE EXPORT DOOR LEAVES THE ARENA. The host cannot reach this program's
|
|
63
|
+
# linear memory, so the arena crosses as a file -- the door writes it when
|
|
64
|
+
# `buffer_path` is set, and the host reads it beside the ask.
|
|
65
|
+
EXPORT_BUFFER_PATH = os.environ.get(
|
|
66
|
+
"VGAI_EXPORT_BUFFER_PATH", os.path.join(ROOT, "arena.bin")
|
|
67
|
+
)
|
|
68
|
+
# THE FRAME'S IDENTITY IS A PAIR, `(session, revision)`, and the presenter
|
|
69
|
+
# refuses a revision that went backwards under a name it already holds. One
|
|
70
|
+
# process is one session; the revision only ever counts up within it.
|
|
71
|
+
#
|
|
72
|
+
# THE PID IS NOT THE NAME, and that is measured rather than cautious. A
|
|
73
|
+
# presenter outlives the Blenders it serves, so two processes' names must
|
|
74
|
+
# differ -- and in the browser the guest's pids START OVER: the second
|
|
75
|
+
# `blender -b ... -a` on one page came up as the same pid, its revision
|
|
76
|
+
# restarted at 1 under a name the view already held at 24, and every render in
|
|
77
|
+
# it died on "The runtime frame is older than the displayed model" (measured
|
|
78
|
+
# 2026-09-20 in the substrate tab; the first run on a freshly loaded page
|
|
79
|
+
# always worked, which is what hid it). The start time is what makes the name
|
|
80
|
+
# this process's own.
|
|
81
|
+
SESSION = "vgai-three:%d-%d" % (os.getpid(), time.time_ns())
|
|
82
|
+
HELLO_TIMEOUT_SECONDS = 2.0
|
|
83
|
+
POLL_SECONDS = 0.002
|
|
84
|
+
|
|
85
|
+
_SEQUENCE = [0]
|
|
86
|
+
_REVISION = [0]
|
|
87
|
+
_WARNED = set()
|
|
88
|
+
_WARNINGS = []
|
|
89
|
+
def _say(line):
|
|
90
|
+
"""One line on Blender's own stderr, resolved at CALL TIME.
|
|
91
|
+
|
|
92
|
+
WHERE THIS LINE COMES OUT, and it is not where a reader looks first.
|
|
93
|
+
MEASURED 2026-09-20 in the substrate tab with the whole command redirected
|
|
94
|
+
(`blender ... > log 2>&1`): Python's `sys.stderr` in this build is NOT the
|
|
95
|
+
process's fd 2. `print(..., file=sys.stderr)` and
|
|
96
|
+
`sys.stderr.buffer.write(...)` BOTH reached the terminal and NEITHER
|
|
97
|
+
reached the file, while `print()` to stdout reached the file and not the
|
|
98
|
+
terminal. So the one line this module ever prints -- the one that says
|
|
99
|
+
Cycles is about to render -- is read on the TERMINAL; a reader who greps
|
|
100
|
+
only a redirect will conclude the fallback is silent when it is not. That
|
|
101
|
+
reading cost an hour and a wrong diagnosis; it is written here so it costs
|
|
102
|
+
nobody else one.
|
|
103
|
+
|
|
104
|
+
The stream is taken at call time rather than captured at import because a
|
|
105
|
+
startup module is imported while Blender is still standing its streams up
|
|
106
|
+
and nothing here needs the captured one. Both spellings were measured to
|
|
107
|
+
work; this is the plainer.
|
|
108
|
+
"""
|
|
109
|
+
print(line, file=sys.stderr or sys.stdout, flush=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def warn(what):
|
|
113
|
+
"""A capability this render cannot reach, named ONCE and never raised."""
|
|
114
|
+
if what in _WARNED:
|
|
115
|
+
return
|
|
116
|
+
_WARNED.add(what)
|
|
117
|
+
_WARNINGS.append(what)
|
|
118
|
+
_say("@@VGAI-WARN " + what)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _drop(path):
|
|
122
|
+
try:
|
|
123
|
+
os.unlink(path)
|
|
124
|
+
except OSError as error:
|
|
125
|
+
_say("@@VGAI-WARN vgai_three could not remove its own %s: %r" % (path, error))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _prepare_directories():
|
|
129
|
+
for directory in (ROOT, ASK, REPLY):
|
|
130
|
+
try:
|
|
131
|
+
os.makedirs(directory, exist_ok=True)
|
|
132
|
+
except OSError as error:
|
|
133
|
+
_say("@@VGAI-ERROR vgai_three cannot create %s: %r" % (directory, error))
|
|
134
|
+
try:
|
|
135
|
+
os.chmod(directory, 0o777)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ask(payload, timeout=None):
|
|
141
|
+
"""Block until the host answers. `None` when a bounded wait ran out.
|
|
142
|
+
|
|
143
|
+
We write `ask/<n>.json` + `ask/<n>.done`, read `reply/<n>.json` once
|
|
144
|
+
`reply/<n>.done` appears, and remove only our own two files; their
|
|
145
|
+
disappearance is what tells the host it may retire the reply it wrote.
|
|
146
|
+
`.done` goes last, both times.
|
|
147
|
+
"""
|
|
148
|
+
name = str(_SEQUENCE[0])
|
|
149
|
+
_SEQUENCE[0] += 1
|
|
150
|
+
with open(os.path.join(ASK, name + ".json"), "w") as fh:
|
|
151
|
+
fh.write(json.dumps(payload))
|
|
152
|
+
with open(os.path.join(ASK, name + ".done"), "w") as fh:
|
|
153
|
+
fh.write("1")
|
|
154
|
+
marker = os.path.join(REPLY, name + ".done")
|
|
155
|
+
deadline = None if timeout is None else time.time() + timeout
|
|
156
|
+
while not os.path.exists(marker):
|
|
157
|
+
if deadline is not None and time.time() >= deadline:
|
|
158
|
+
_drop(os.path.join(ASK, name + ".json"))
|
|
159
|
+
_drop(os.path.join(ASK, name + ".done"))
|
|
160
|
+
return None
|
|
161
|
+
time.sleep(POLL_SECONDS)
|
|
162
|
+
with open(os.path.join(REPLY, name + ".json")) as fh:
|
|
163
|
+
body = fh.read()
|
|
164
|
+
_drop(os.path.join(ASK, name + ".json"))
|
|
165
|
+
_drop(os.path.join(ASK, name + ".done"))
|
|
166
|
+
return json.loads(body) if body else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------- the frame
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _world(scene):
|
|
173
|
+
"""The world as radiance, for the CONSTANT cases and no others.
|
|
174
|
+
|
|
175
|
+
The editor session reduces Blender's world node graph to the presenter's
|
|
176
|
+
expression grammar (sky textures, ramps, `Is Camera Ray` splits); that
|
|
177
|
+
reducer is two hundred lines and belongs to the session that ships with
|
|
178
|
+
the editor. Here a world is a constant colour and strength -- Blender's
|
|
179
|
+
own factory world is exactly that -- and anything else is named as a
|
|
180
|
+
warning and left out, so the model stays lit by its lamps and the picture
|
|
181
|
+
is never quietly wrong about which door failed.
|
|
182
|
+
"""
|
|
183
|
+
world = scene.world
|
|
184
|
+
if world is None:
|
|
185
|
+
return None
|
|
186
|
+
if world.node_tree is None:
|
|
187
|
+
return {"color": [float(c) for c in list(world.color)[:3]], "strength": 1.0}
|
|
188
|
+
outputs = [
|
|
189
|
+
node
|
|
190
|
+
for node in world.node_tree.nodes
|
|
191
|
+
if node.bl_idname == "ShaderNodeOutputWorld" and node.is_active_output
|
|
192
|
+
]
|
|
193
|
+
links = list(outputs[0].inputs["Surface"].links) if len(outputs) == 1 else []
|
|
194
|
+
background = links[0].from_node if len(links) == 1 else None
|
|
195
|
+
if (
|
|
196
|
+
background is None
|
|
197
|
+
or background.bl_idname != "ShaderNodeBackground"
|
|
198
|
+
or background.inputs["Color"].is_linked
|
|
199
|
+
or background.inputs["Strength"].is_linked
|
|
200
|
+
):
|
|
201
|
+
warn(
|
|
202
|
+
"world: this Blender's render override reduces a CONSTANT world only (one "
|
|
203
|
+
"Background node with unlinked Color and Strength). This world's graph is "
|
|
204
|
+
"outside that, so the photograph is taken without a world; the editor's "
|
|
205
|
+
"own session (`session.py`) is what carries the full reducer."
|
|
206
|
+
)
|
|
207
|
+
return None
|
|
208
|
+
color = background.inputs["Color"].default_value
|
|
209
|
+
return {
|
|
210
|
+
"color": [float(c) for c in list(color)[:3]],
|
|
211
|
+
"strength": float(background.inputs["Strength"].default_value),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _export():
|
|
216
|
+
"""The C++ door's frame, with what the door does not describe merged in.
|
|
217
|
+
|
|
218
|
+
`known` is ALWAYS EMPTY: this process keeps no record of what a presenter
|
|
219
|
+
holds, so every frame ships its columns in full and the presenter's
|
|
220
|
+
unknown-geometry refusal can never fire.
|
|
221
|
+
"""
|
|
222
|
+
options = {
|
|
223
|
+
"session": SESSION,
|
|
224
|
+
"evaluate": True,
|
|
225
|
+
"known": {},
|
|
226
|
+
"buffer_path": EXPORT_BUFFER_PATH,
|
|
227
|
+
}
|
|
228
|
+
frame = json.loads(_blender_web.export_frame(json.dumps(options)))
|
|
229
|
+
error = frame.get("error")
|
|
230
|
+
if error:
|
|
231
|
+
raise RuntimeError("Blender export door: %s" % error)
|
|
232
|
+
_REVISION[0] += 1
|
|
233
|
+
frame["session"] = SESSION
|
|
234
|
+
frame["revision"] = _REVISION[0]
|
|
235
|
+
scene = bpy.context.scene
|
|
236
|
+
frame["world"] = _world(scene)
|
|
237
|
+
frame["volumes"] = {}
|
|
238
|
+
# THE INSPECTION OVERLAYS ARE THE EDITOR'S, and a render hides them anyway.
|
|
239
|
+
frame["armatures"] = {}
|
|
240
|
+
frame["weights"] = None
|
|
241
|
+
warnings = list(frame.get("warnings", ()))
|
|
242
|
+
warnings.extend(_WARNINGS)
|
|
243
|
+
del _WARNINGS[:]
|
|
244
|
+
frame["warnings"] = warnings
|
|
245
|
+
return frame
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _present(capture):
|
|
249
|
+
answer = ask({"frame": _export(), "capture": capture})
|
|
250
|
+
if isinstance(answer, dict) and answer.get("error"):
|
|
251
|
+
raise RuntimeError(answer["error"])
|
|
252
|
+
return answer
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------- the render engine
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _vertical_extent(cam, width, height):
|
|
259
|
+
"""The photograph's vertical field of view in degrees (or, for an
|
|
260
|
+
orthographic camera, its vertical extent in Blender units), framed the way
|
|
261
|
+
Blender frames a render: `BKE_camera_params_compute_viewplane`.
|
|
262
|
+
|
|
263
|
+
Blender's `sensor_fit` says which sensor dimension spans which image
|
|
264
|
+
dimension. HORIZONTAL: `sensor_width` spans the image width, and the
|
|
265
|
+
vertical extent follows from the aspect. VERTICAL: `sensor_height` spans the
|
|
266
|
+
height. AUTO: `sensor_width` spans the LARGER image dimension. Pixel aspect
|
|
267
|
+
is not applied; the presenter renders square pixels."""
|
|
268
|
+
import math
|
|
269
|
+
landscape = width >= height
|
|
270
|
+
fit = cam.sensor_fit
|
|
271
|
+
if fit == "VERTICAL" or (fit == "AUTO" and not landscape):
|
|
272
|
+
extent = float(cam.sensor_height if fit == "VERTICAL" else cam.sensor_width)
|
|
273
|
+
else:
|
|
274
|
+
extent = float(cam.sensor_width) * height / max(width, 1)
|
|
275
|
+
if cam.type == "ORTHO":
|
|
276
|
+
# `ortho_scale` spans the same dimension `sensor_fit` names.
|
|
277
|
+
if fit == "VERTICAL" or (fit == "AUTO" and not landscape):
|
|
278
|
+
return float(cam.ortho_scale)
|
|
279
|
+
return float(cam.ortho_scale) * height / max(width, 1)
|
|
280
|
+
return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _photograph(depsgraph, width, height):
|
|
284
|
+
"""three.js IS the renderer: the render is a photograph of the scene the
|
|
285
|
+
engine holds, taken through the scene's own camera at `scene.render`'s
|
|
286
|
+
exact resolution. `bpy.ops.render.render` semantics are the contract."""
|
|
287
|
+
scene = depsgraph.scene if hasattr(depsgraph, "scene") else bpy.context.scene
|
|
288
|
+
camera = scene.camera
|
|
289
|
+
if camera is None:
|
|
290
|
+
raise RuntimeError("The scene has no camera, so there is nothing to render")
|
|
291
|
+
matrix = camera.matrix_world
|
|
292
|
+
rotation = matrix.to_quaternion()
|
|
293
|
+
vector = __import__("mathutils").Vector
|
|
294
|
+
position = [float(v) for v in matrix.translation]
|
|
295
|
+
forward = rotation @ vector((0.0, 0.0, -1.0))
|
|
296
|
+
target = [position[i] + float(forward[i]) for i in range(3)]
|
|
297
|
+
# THE CAMERA'S ROLL, which position and target cannot express. A top-down
|
|
298
|
+
# render is the everyday case: looking straight down, "up" is a free choice
|
|
299
|
+
# and the photograph is wrong by an arbitrary rotation without it. All three
|
|
300
|
+
# go out in BLENDER'S frame; the presenter converts them through the model
|
|
301
|
+
# root's matrix and reports back through its inverse.
|
|
302
|
+
up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
|
|
303
|
+
view = scene.view_settings
|
|
304
|
+
look = getattr(view, "look", "None") or "None"
|
|
305
|
+
transform = {
|
|
306
|
+
"Standard": "none",
|
|
307
|
+
"AgX": "agx",
|
|
308
|
+
"Filmic": "filmic",
|
|
309
|
+
"Khronos PBR Neutral": "neutral",
|
|
310
|
+
}.get(view.view_transform)
|
|
311
|
+
if transform is None:
|
|
312
|
+
raise RuntimeError(
|
|
313
|
+
"Blender view transform %s has no curve in the renderer" % view.view_transform
|
|
314
|
+
)
|
|
315
|
+
render = {
|
|
316
|
+
"width": int(width),
|
|
317
|
+
"height": int(height),
|
|
318
|
+
"fov": _vertical_extent(camera.data, int(width), int(height)),
|
|
319
|
+
"toneMapping": transform,
|
|
320
|
+
"exposure": float(2.0 ** view.exposure),
|
|
321
|
+
"orthographic": camera.data.type == "ORTHO",
|
|
322
|
+
"transparent": bool(scene.render.film_transparent),
|
|
323
|
+
}
|
|
324
|
+
# The renderer keys its baked look tables by the config's FULL name
|
|
325
|
+
# (`AgX - Medium High Contrast`), and `AgX - Base Contrast` is the AgX base
|
|
326
|
+
# view itself -- the same table as no look at all.
|
|
327
|
+
if look not in ("None", "AgX - Base Contrast"):
|
|
328
|
+
render["look"] = look
|
|
329
|
+
answer = _present(
|
|
330
|
+
{"position": position, "target": target, "up": up, "render": render}
|
|
331
|
+
)
|
|
332
|
+
if not isinstance(answer, dict) or "base64" not in answer:
|
|
333
|
+
raise RuntimeError("The renderer did not answer with a photograph")
|
|
334
|
+
_assert_photographed_from(answer.get("camera"), position, target, up)
|
|
335
|
+
return base64.b64decode(answer["base64"]), render
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _assert_photographed_from(reported, position, target, up):
|
|
339
|
+
"""The photograph answers with the pose it ACTUALLY used, back in Blender's
|
|
340
|
+
frame, and it must be the pose that was sent -- exactly, with no tolerance.
|
|
341
|
+
|
|
342
|
+
This is an instrument, not a nicety. A render's image is allowed to differ
|
|
343
|
+
from Cycles (three.js is the renderer), so a camera placed in the wrong
|
|
344
|
+
frame produces a picture nobody can call wrong: the lane shipped for its
|
|
345
|
+
whole life photographing the model's underside from below the floor because
|
|
346
|
+
Blender's Z-up numbers were read as three.js world space.
|
|
347
|
+
|
|
348
|
+
The check is exact because the conversion is exact: the model root's world
|
|
349
|
+
matrix is a signed axis permutation, so a vector through it and its inverse
|
|
350
|
+
is bit-identical. A mismatch is the conversion being wrong, never the check
|
|
351
|
+
being too strict -- do not widen it.
|
|
352
|
+
"""
|
|
353
|
+
sent = {"position": position, "target": target, "up": up}
|
|
354
|
+
if not isinstance(reported, dict):
|
|
355
|
+
raise RuntimeError(
|
|
356
|
+
"The renderer did not say which camera it photographed from, so the "
|
|
357
|
+
"render cannot be attributed to the scene camera %r" % (sent,)
|
|
358
|
+
)
|
|
359
|
+
for key in ("position", "target", "up"):
|
|
360
|
+
got = reported.get(key)
|
|
361
|
+
if not isinstance(got, (list, tuple)) or len(got) != 3:
|
|
362
|
+
raise RuntimeError(
|
|
363
|
+
"The renderer reported no %s for the camera it photographed from" % key
|
|
364
|
+
)
|
|
365
|
+
if [float(v) for v in got] != [float(v) for v in sent[key]]:
|
|
366
|
+
raise RuntimeError(
|
|
367
|
+
"The photograph was taken from a different camera than the scene's: "
|
|
368
|
+
"%s was sent as %r and came back as %r. The Blender-to-document "
|
|
369
|
+
"frame conversion in the presenter is wrong."
|
|
370
|
+
% (key, sent[key], list(got))
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class VgaiRenderEngine(bpy.types.RenderEngine):
|
|
375
|
+
"""The scene's renderer, so `write_still`, `save_render` and Render Result
|
|
376
|
+
behave as Blender's own.
|
|
377
|
+
|
|
378
|
+
Registered under the three engine ids a script names. The port has real
|
|
379
|
+
Cycles compiled in, so the Cycles add-on's own engine is unregistered
|
|
380
|
+
first -- otherwise `scene.render.engine = 'CYCLES'` resolves to it and
|
|
381
|
+
starts a path trace nobody asked for.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
bl_idname = "VGAI_THREE"
|
|
385
|
+
bl_label = "three.js"
|
|
386
|
+
bl_use_preview = False
|
|
387
|
+
|
|
388
|
+
def render(self, depsgraph):
|
|
389
|
+
scene = depsgraph.scene
|
|
390
|
+
scale = scene.render.resolution_percentage / 100.0
|
|
391
|
+
width = max(1, int(scene.render.resolution_x * scale))
|
|
392
|
+
height = max(1, int(scene.render.resolution_y * scale))
|
|
393
|
+
try:
|
|
394
|
+
png, _render = _photograph(depsgraph, width, height)
|
|
395
|
+
except Exception as error: # noqa: BLE001 - reported to Blender as a render error
|
|
396
|
+
self.report({"ERROR"}, str(error))
|
|
397
|
+
return
|
|
398
|
+
path = os.path.join(ROOT, "render-%d.png" % int(time.time() * 1000))
|
|
399
|
+
with open(path, "wb") as fh:
|
|
400
|
+
fh.write(png)
|
|
401
|
+
result = self.begin_result(0, 0, width, height)
|
|
402
|
+
try:
|
|
403
|
+
result.layers[0].load_from_file(path)
|
|
404
|
+
except (RuntimeError, AttributeError) as error:
|
|
405
|
+
self.report({"ERROR"}, "Render result could not take the photograph: %s" % error)
|
|
406
|
+
self.end_result(result)
|
|
407
|
+
try:
|
|
408
|
+
os.unlink(path)
|
|
409
|
+
except OSError:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _engine_class(identifier):
|
|
414
|
+
"""The registered RenderEngine class holding `identifier` as its `bl_idname`.
|
|
415
|
+
|
|
416
|
+
A Python-registered engine is exposed on `bpy.types` under its CLASS name
|
|
417
|
+
(`bpy.types.CyclesRender`), never under its id, so `bpy.types.CYCLES` is
|
|
418
|
+
always absent. The subclass tree is the one true registry."""
|
|
419
|
+
pending = list(bpy.types.RenderEngine.__subclasses__())
|
|
420
|
+
while pending:
|
|
421
|
+
cls = pending.pop()
|
|
422
|
+
if getattr(cls, "bl_idname", None) == identifier and getattr(cls, "is_registered", False):
|
|
423
|
+
return cls
|
|
424
|
+
pending.extend(cls.__subclasses__())
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _release_from_owning_addon(existing):
|
|
429
|
+
"""Take the class out of its OWNING ADD-ON's registration list as well.
|
|
430
|
+
|
|
431
|
+
`unregister_class(existing)` is only half the gesture. The add-on that
|
|
432
|
+
registered the class still holds it in its module-level `classes`, and
|
|
433
|
+
Blender tears every add-on down again on `read_factory_settings` and on
|
|
434
|
+
each `open_mainfile` -- so the add-on's own `unregister()` calls
|
|
435
|
+
`unregister_class` on a class that is already gone and raises
|
|
436
|
+
`RuntimeError: missing bl_rna attribute`. `addon_utils.disable` catches
|
|
437
|
+
that and prints a full traceback, and the remainder of that add-on's
|
|
438
|
+
`unregister()` never runs.
|
|
439
|
+
|
|
440
|
+
Dropping the class from `classes` makes the add-on's next
|
|
441
|
+
`register()`/`unregister()` pair agree with what this module actually did,
|
|
442
|
+
which is what keeps `scene.cycles` (the reason the add-on stays enabled at
|
|
443
|
+
all) reachable after a factory reset.
|
|
444
|
+
"""
|
|
445
|
+
module = sys.modules.get(getattr(existing, "__module__", ""))
|
|
446
|
+
classes = getattr(module, "classes", None)
|
|
447
|
+
if isinstance(classes, tuple):
|
|
448
|
+
if existing in classes:
|
|
449
|
+
module.classes = tuple(cls for cls in classes if cls is not existing)
|
|
450
|
+
elif isinstance(classes, list):
|
|
451
|
+
while existing in classes:
|
|
452
|
+
classes.remove(existing)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _register_engine():
|
|
456
|
+
"""Take the three engine ids a script can name.
|
|
457
|
+
|
|
458
|
+
Blender refuses two classes with one `bl_idname`, so the add-on's engine
|
|
459
|
+
class is unregistered first and the bundle's patched registration sets a
|
|
460
|
+
built-in type aside; `bpy.utils.register_class` on a subclass whose
|
|
461
|
+
`bl_idname` is `CYCLES` (or `BLENDER_EEVEE`, the factory default) then
|
|
462
|
+
makes `scene.render.engine` resolve here. Only the Cycles ENGINE CLASS
|
|
463
|
+
goes: the add-on stays enabled, because `scene.cycles` is the add-on's
|
|
464
|
+
property group and a script sets `samples`, `use_denoising` and the rest
|
|
465
|
+
on it.
|
|
466
|
+
"""
|
|
467
|
+
made, unavailable = [], []
|
|
468
|
+
for identifier, label in (
|
|
469
|
+
("CYCLES", "Cycles"),
|
|
470
|
+
("BLENDER_EEVEE", "EEVEE"),
|
|
471
|
+
("BLENDER_WORKBENCH", "Workbench"),
|
|
472
|
+
):
|
|
473
|
+
existing = _engine_class(identifier)
|
|
474
|
+
if existing is not None and issubclass(existing, VgaiRenderEngine):
|
|
475
|
+
made.append(identifier)
|
|
476
|
+
continue
|
|
477
|
+
if existing is not None:
|
|
478
|
+
try:
|
|
479
|
+
bpy.utils.unregister_class(existing)
|
|
480
|
+
except Exception: # noqa: BLE001
|
|
481
|
+
pass
|
|
482
|
+
else:
|
|
483
|
+
_release_from_owning_addon(existing)
|
|
484
|
+
engine = type(
|
|
485
|
+
"Vgai" + identifier.title().replace("_", ""),
|
|
486
|
+
(VgaiRenderEngine,),
|
|
487
|
+
{"bl_idname": identifier, "bl_label": label, "bl_use_preview": False},
|
|
488
|
+
)
|
|
489
|
+
try:
|
|
490
|
+
bpy.utils.register_class(engine)
|
|
491
|
+
made.append(identifier)
|
|
492
|
+
except Exception: # noqa: BLE001
|
|
493
|
+
# Upstream Blender refuses to let a Python engine take a BUILT-IN
|
|
494
|
+
# id ("is built-in"); the shipped bundle carries a patch that sets
|
|
495
|
+
# the built-in type aside instead. A bundle without that patch
|
|
496
|
+
# keeps `BLENDER_EEVEE` and `BLENDER_WORKBENCH`: a fact about the
|
|
497
|
+
# build, not a warning on every boot.
|
|
498
|
+
unavailable.append(identifier)
|
|
499
|
+
# `VGAI_THREE` is the engine under its own name, registered ONCE: this runs
|
|
500
|
+
# again after every file load (the ids have to be retaken), and Blender
|
|
501
|
+
# refuses a class it already holds -- which is not a capability anyone
|
|
502
|
+
# lost, so it is not a warning.
|
|
503
|
+
if _engine_class(VgaiRenderEngine.bl_idname) is None:
|
|
504
|
+
try:
|
|
505
|
+
bpy.utils.register_class(VgaiRenderEngine)
|
|
506
|
+
except Exception as error: # noqa: BLE001
|
|
507
|
+
warn("could not register the three.js engine: %s" % error)
|
|
508
|
+
if _engine_class(VgaiRenderEngine.bl_idname) is not None:
|
|
509
|
+
made.append(VgaiRenderEngine.bl_idname)
|
|
510
|
+
return made, unavailable
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ---------------------------------------------------------------- registration
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _adopt():
|
|
517
|
+
"""Take the engine ids and point every scene at ours."""
|
|
518
|
+
_register_engine()
|
|
519
|
+
for scene in bpy.data.scenes:
|
|
520
|
+
try:
|
|
521
|
+
scene.render.engine = "VGAI_THREE"
|
|
522
|
+
except (TypeError, AttributeError) as error:
|
|
523
|
+
warn("scene %s kept its own render engine: %s" % (scene.name, error))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@bpy.app.handlers.persistent
|
|
527
|
+
def _load_post(_arg):
|
|
528
|
+
# A file load re-enables every add-on, and the Cycles add-on's engine class
|
|
529
|
+
# comes back under `CYCLES` while ours is gone. The ids are taken again on
|
|
530
|
+
# every load -- and the FIRST of these, for the startup file, is where they
|
|
531
|
+
# are taken at all (see this module's header).
|
|
532
|
+
_adopt()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def register():
|
|
536
|
+
_prepare_directories()
|
|
537
|
+
if ask({"op": "hello"}, timeout=HELLO_TIMEOUT_SECONDS) is None:
|
|
538
|
+
_say(
|
|
539
|
+
"vgai: rendering with Cycles — no presenter answered at %s within 2 s"
|
|
540
|
+
% ROOT
|
|
541
|
+
)
|
|
542
|
+
return
|
|
543
|
+
bpy.app.handlers.load_post.append(_load_post)
|
|
544
|
+
# BELT AND BRACES, AND NEITHER IS A GUESS. Startup modules register before
|
|
545
|
+
# the add-ons are enabled, so ordinarily `CYCLES` is absent here and the
|
|
546
|
+
# first `load_post` is what takes it; a host that imports this module after
|
|
547
|
+
# the add-ons are up finds it present and adopts immediately.
|
|
548
|
+
if _engine_class("CYCLES") is not None:
|
|
549
|
+
_adopt()
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def unregister():
|
|
553
|
+
if _load_post in bpy.app.handlers.load_post:
|
|
554
|
+
bpy.app.handlers.load_post.remove(_load_post)
|