@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.
@@ -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.layers[0].load_from_file(path)
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
- captureSnapshot() {
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;
@@ -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.layers[0].load_from_file(path)
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:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.18",
5
+ "version": "0.1.20",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },