@volter/blender-engine 0.1.17 → 0.1.19

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:
@@ -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.17",
5
+ "version": "0.1.19",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },