@volter/blender-engine 0.1.18 → 0.1.20
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/session.py
CHANGED
|
@@ -1898,7 +1898,7 @@ def _vertical_extent(cam, width, height):
|
|
|
1898
1898
|
return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
|
|
1899
1899
|
|
|
1900
1900
|
|
|
1901
|
-
def _photograph(depsgraph, width, height):
|
|
1901
|
+
def _photograph(depsgraph, width, height, linear=False):
|
|
1902
1902
|
"""three.js IS the renderer: the render is a photograph of the scene the
|
|
1903
1903
|
engine holds, taken through the scene's own camera at `scene.render`'s
|
|
1904
1904
|
exact resolution. `bpy.ops.render.render` semantics are the contract."""
|
|
@@ -1946,13 +1946,17 @@ def _photograph(depsgraph, width, height):
|
|
|
1946
1946
|
# refused BY NAME on the other side (`blender-runtime-host.ts`).
|
|
1947
1947
|
if look not in ("None", "AgX - Base Contrast"):
|
|
1948
1948
|
render["look"] = look
|
|
1949
|
+
# The capture's scene-referred frame rides along when asked for: a render
|
|
1950
|
+
# result is what a render leaves, and it holds scene-linear pixels.
|
|
1951
|
+
if linear:
|
|
1952
|
+
render["linear"] = True
|
|
1949
1953
|
answer = SESSION.present(
|
|
1950
1954
|
{"position": position, "target": target, "up": up, "render": render}
|
|
1951
1955
|
)
|
|
1952
1956
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
1953
1957
|
raise RuntimeError("The renderer did not answer with a photograph")
|
|
1954
1958
|
_assert_photographed_from(answer.get("camera"), position, target, up)
|
|
1955
|
-
return base64.b64decode(answer["base64"]), render
|
|
1959
|
+
return base64.b64decode(answer["base64"]), render, answer
|
|
1956
1960
|
|
|
1957
1961
|
|
|
1958
1962
|
def _assert_photographed_from(reported, position, target, up):
|
|
@@ -2013,19 +2017,54 @@ class VolterRenderEngine(bpy.types.RenderEngine):
|
|
|
2013
2017
|
width = max(1, int(scene.render.resolution_x * scale))
|
|
2014
2018
|
height = max(1, int(scene.render.resolution_y * scale))
|
|
2015
2019
|
try:
|
|
2016
|
-
png, _render = _photograph(depsgraph, width, height)
|
|
2020
|
+
png, _render, answer = _photograph(depsgraph, width, height, linear=True)
|
|
2017
2021
|
except Exception as error: # noqa: BLE001 - reported to Blender as a render error
|
|
2018
2022
|
self.report({"ERROR"}, str(error))
|
|
2019
2023
|
return
|
|
2020
|
-
path = os.path.join(ROOT, "render-%d.png" % int(time.time() * 1000))
|
|
2021
|
-
with open(path, "wb") as fh:
|
|
2022
|
-
fh.write(png)
|
|
2023
2024
|
result = self.begin_result(0, 0, width, height)
|
|
2024
2025
|
try:
|
|
2025
|
-
result
|
|
2026
|
-
except (RuntimeError, AttributeError) as error:
|
|
2026
|
+
_fill_result(self, result, answer, png, width, height)
|
|
2027
|
+
except (RuntimeError, AttributeError, ValueError) as error:
|
|
2027
2028
|
self.report({"ERROR"}, "Render result could not take the photograph: %s" % error)
|
|
2028
2029
|
self.end_result(result)
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _fill_result(engine, result, answer, png, width, height):
|
|
2033
|
+
"""THE RENDER RESULT HOLDS SCENE-LINEAR PIXELS, and Blender runs the scene's
|
|
2034
|
+
view transform over them wherever it shows or saves one (`write_still`, the
|
|
2035
|
+
Image Editor), as it does over Cycles'. The photograph's PNG is the display
|
|
2036
|
+
image -- the transform already ran -- so loading it put every render
|
|
2037
|
+
through AgX or Filmic twice. Measured 2026-09-28 by the model editor's
|
|
2038
|
+
render view: a background 53 against the viewport's 59, a shadow's blue 2
|
|
2039
|
+
against 7. The same capture's linear frame goes in instead: half floats,
|
|
2040
|
+
RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
|
|
2041
|
+
0.05, 0.5 and 4.0 then save under AgX as 8, 58, 170 and 234 (the PNG
|
|
2042
|
+
path saved 3, 53, 160 and 190), and under Standard as 15, 64, 188 and 255,
|
|
2043
|
+
as the PNG path did.
|
|
2044
|
+
|
|
2045
|
+
A renderer that sends no linear frame gets the old path, and says so."""
|
|
2046
|
+
linear = answer.get("linearBase64") if isinstance(answer, dict) else None
|
|
2047
|
+
if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
|
|
2048
|
+
import numpy
|
|
2049
|
+
|
|
2050
|
+
pixels = numpy.frombuffer(base64.b64decode(linear), dtype=numpy.float16)
|
|
2051
|
+
if pixels.size != width * height * 4:
|
|
2052
|
+
raise ValueError(
|
|
2053
|
+
"the linear frame holds %d values for a %dx%d render" % (pixels.size, width, height)
|
|
2054
|
+
)
|
|
2055
|
+
result.layers[0].passes["Combined"].rect.foreach_set(pixels.astype(numpy.float32))
|
|
2056
|
+
return
|
|
2057
|
+
engine.report(
|
|
2058
|
+
{"WARNING"},
|
|
2059
|
+
"The renderer sent no scene-linear frame; the render result is its display image, "
|
|
2060
|
+
"which the view transform will run over again",
|
|
2061
|
+
)
|
|
2062
|
+
path = os.path.join(ROOT, "render-%d.png" % int(time.time() * 1000))
|
|
2063
|
+
with open(path, "wb") as fh:
|
|
2064
|
+
fh.write(png)
|
|
2065
|
+
try:
|
|
2066
|
+
result.layers[0].load_from_file(path)
|
|
2067
|
+
finally:
|
|
2029
2068
|
try:
|
|
2030
2069
|
os.unlink(path)
|
|
2031
2070
|
except OSError:
|
|
@@ -744,6 +744,8 @@ export class BlenderRuntimeView {
|
|
|
744
744
|
* showing". Fired after the frame is applied, so a listener that reads
|
|
745
745
|
* the graph sees the new one. */
|
|
746
746
|
private readonly frameListeners = new Set<() => void>();
|
|
747
|
+
/** The views drawing this one's frames in a second area ({@link follow}). */
|
|
748
|
+
private readonly followers = new Set<BlenderRuntimeView>();
|
|
747
749
|
|
|
748
750
|
/** Subscribe to frames. Returns the unsubscribe. */
|
|
749
751
|
subscribeFrames(listener: () => void): () => void {
|
|
@@ -1867,6 +1869,15 @@ export class BlenderRuntimeView {
|
|
|
1867
1869
|
// The model moved: whoever is READING the engine (the Properties sections
|
|
1868
1870
|
// through the RNA door) re-reads now, with the new graph already standing.
|
|
1869
1871
|
for (const listener of [...this.frameListeners]) listener();
|
|
1872
|
+
// EVERY FOLLOWER TAKES THE SAME FRAME, and one that cannot (it lacks geometry the frame only
|
|
1873
|
+
// references) is rebuilt from everything this view now holds.
|
|
1874
|
+
for (const follower of this.followers) {
|
|
1875
|
+
try {
|
|
1876
|
+
follower.applyFrame(input);
|
|
1877
|
+
} catch {
|
|
1878
|
+
follower.applyFrame(this.fullFrame());
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1870
1881
|
return { ...this.inspect(), held };
|
|
1871
1882
|
}
|
|
1872
1883
|
|
|
@@ -1898,7 +1909,10 @@ export class BlenderRuntimeView {
|
|
|
1898
1909
|
* disposable geometry cross this boundary; delta-only frames are sufficient.
|
|
1899
1910
|
* The caller owns this snapshot and must dispose it on every outcome.
|
|
1900
1911
|
*/
|
|
1901
|
-
|
|
1912
|
+
/** Everything this view holds, as ONE self-contained frame another view can be built from:
|
|
1913
|
+
* every mesh's resident geometry and every resident image, with no reference to what a
|
|
1914
|
+
* receiver already holds. */
|
|
1915
|
+
private fullFrame(): Frame {
|
|
1902
1916
|
const source = this.frame;
|
|
1903
1917
|
if (!source) throw new Error('Blender capture requires a presented frame');
|
|
1904
1918
|
// Inspection overlays are not render content. In particular weight-paint
|
|
@@ -1976,6 +1990,37 @@ export class BlenderRuntimeView {
|
|
|
1976
1990
|
};
|
|
1977
1991
|
}
|
|
1978
1992
|
}
|
|
1993
|
+
return frame;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* A SECOND VIEW OF THE SAME MODEL, for a second area drawing it (Blender's split 3D viewport):
|
|
1998
|
+
* its own graph, because a three.js object has one parent and each area's stage holds its own
|
|
1999
|
+
* root, built from everything this view holds and then handed every frame this one applies, so
|
|
2000
|
+
* the two show one revision. Each view keeps its own shading (Solid in one, Rendered in the
|
|
2001
|
+
* other), its own camera view and its own overlays. Inspection overlays (bones, weights) and a
|
|
2002
|
+
* Timeline pose stay this view's: they are not in a frame another view can be built from.
|
|
2003
|
+
*/
|
|
2004
|
+
follow(): { readonly view: BlenderRuntimeView; dispose(): void } {
|
|
2005
|
+
const follower = new BlenderRuntimeView();
|
|
2006
|
+
if (this.frame) follower.applyFrame(this.fullFrame());
|
|
2007
|
+
this.followers.add(follower);
|
|
2008
|
+
let disposed = false;
|
|
2009
|
+
return {
|
|
2010
|
+
view: follower,
|
|
2011
|
+
dispose: () => {
|
|
2012
|
+
if (disposed) return;
|
|
2013
|
+
disposed = true;
|
|
2014
|
+
this.followers.delete(follower);
|
|
2015
|
+
follower.dispose();
|
|
2016
|
+
follower.root.removeFromParent();
|
|
2017
|
+
},
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
captureSnapshot() {
|
|
2022
|
+
const frame = this.fullFrame();
|
|
2023
|
+
const source = this.frame!;
|
|
1979
2024
|
const effect = this.createWorldVolumePass();
|
|
1980
2025
|
const detached = new BlenderRuntimeView();
|
|
1981
2026
|
detached.photograph = true;
|
package/browser/volter_three.py
CHANGED
|
@@ -280,7 +280,7 @@ def _vertical_extent(cam, width, height):
|
|
|
280
280
|
return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
|
|
281
281
|
|
|
282
282
|
|
|
283
|
-
def _photograph(depsgraph, width, height):
|
|
283
|
+
def _photograph(depsgraph, width, height, linear=False):
|
|
284
284
|
"""three.js IS the renderer: the render is a photograph of the scene the
|
|
285
285
|
engine holds, taken through the scene's own camera at `scene.render`'s
|
|
286
286
|
exact resolution. `bpy.ops.render.render` semantics are the contract."""
|
|
@@ -326,13 +326,17 @@ def _photograph(depsgraph, width, height):
|
|
|
326
326
|
# view itself -- the same table as no look at all.
|
|
327
327
|
if look not in ("None", "AgX - Base Contrast"):
|
|
328
328
|
render["look"] = look
|
|
329
|
+
# The capture's scene-referred frame rides along when asked for: a render
|
|
330
|
+
# result is what a render leaves, and it holds scene-linear pixels.
|
|
331
|
+
if linear:
|
|
332
|
+
render["linear"] = True
|
|
329
333
|
answer = _present(
|
|
330
334
|
{"position": position, "target": target, "up": up, "render": render}
|
|
331
335
|
)
|
|
332
336
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
333
337
|
raise RuntimeError("The renderer did not answer with a photograph")
|
|
334
338
|
_assert_photographed_from(answer.get("camera"), position, target, up)
|
|
335
|
-
return base64.b64decode(answer["base64"]), render
|
|
339
|
+
return base64.b64decode(answer["base64"]), render, answer
|
|
336
340
|
|
|
337
341
|
|
|
338
342
|
def _assert_photographed_from(reported, position, target, up):
|
|
@@ -391,19 +395,54 @@ class VolterRenderEngine(bpy.types.RenderEngine):
|
|
|
391
395
|
width = max(1, int(scene.render.resolution_x * scale))
|
|
392
396
|
height = max(1, int(scene.render.resolution_y * scale))
|
|
393
397
|
try:
|
|
394
|
-
png, _render = _photograph(depsgraph, width, height)
|
|
398
|
+
png, _render, answer = _photograph(depsgraph, width, height, linear=True)
|
|
395
399
|
except Exception as error: # noqa: BLE001 - reported to Blender as a render error
|
|
396
400
|
self.report({"ERROR"}, str(error))
|
|
397
401
|
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
402
|
result = self.begin_result(0, 0, width, height)
|
|
402
403
|
try:
|
|
403
|
-
result
|
|
404
|
-
except (RuntimeError, AttributeError) as error:
|
|
404
|
+
_fill_result(self, result, answer, png, width, height)
|
|
405
|
+
except (RuntimeError, AttributeError, ValueError) as error:
|
|
405
406
|
self.report({"ERROR"}, "Render result could not take the photograph: %s" % error)
|
|
406
407
|
self.end_result(result)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _fill_result(engine, result, answer, png, width, height):
|
|
411
|
+
"""THE RENDER RESULT HOLDS SCENE-LINEAR PIXELS, and Blender runs the scene's
|
|
412
|
+
view transform over them wherever it shows or saves one (`write_still`, the
|
|
413
|
+
Image Editor), as it does over Cycles'. The photograph's PNG is the display
|
|
414
|
+
image -- the transform already ran -- so loading it put every render
|
|
415
|
+
through AgX or Filmic twice. Measured 2026-09-28 by the model editor's
|
|
416
|
+
render view: a background 53 against the viewport's 59, a shadow's blue 2
|
|
417
|
+
against 7. The same capture's linear frame goes in instead: half floats,
|
|
418
|
+
RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
|
|
419
|
+
0.05, 0.5 and 4.0 then save under AgX as 8, 58, 170 and 234 (the PNG
|
|
420
|
+
path saved 3, 53, 160 and 190), and under Standard as 15, 64, 188 and 255,
|
|
421
|
+
as the PNG path did.
|
|
422
|
+
|
|
423
|
+
A renderer that sends no linear frame gets the old path, and says so."""
|
|
424
|
+
linear = answer.get("linearBase64") if isinstance(answer, dict) else None
|
|
425
|
+
if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
|
|
426
|
+
import numpy
|
|
427
|
+
|
|
428
|
+
pixels = numpy.frombuffer(base64.b64decode(linear), dtype=numpy.float16)
|
|
429
|
+
if pixels.size != width * height * 4:
|
|
430
|
+
raise ValueError(
|
|
431
|
+
"the linear frame holds %d values for a %dx%d render" % (pixels.size, width, height)
|
|
432
|
+
)
|
|
433
|
+
result.layers[0].passes["Combined"].rect.foreach_set(pixels.astype(numpy.float32))
|
|
434
|
+
return
|
|
435
|
+
engine.report(
|
|
436
|
+
{"WARNING"},
|
|
437
|
+
"The renderer sent no scene-linear frame; the render result is its display image, "
|
|
438
|
+
"which the view transform will run over again",
|
|
439
|
+
)
|
|
440
|
+
path = os.path.join(ROOT, "render-%d.png" % int(time.time() * 1000))
|
|
441
|
+
with open(path, "wb") as fh:
|
|
442
|
+
fh.write(png)
|
|
443
|
+
try:
|
|
444
|
+
result.layers[0].load_from_file(path)
|
|
445
|
+
finally:
|
|
407
446
|
try:
|
|
408
447
|
os.unlink(path)
|
|
409
448
|
except OSError:
|