@volter/blender-engine 0.1.127 → 0.1.129

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.
@@ -151,7 +151,13 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
151
151
  */
152
152
  const ARTIFACT_CACHE = 'volter-blender-artifacts';
153
153
 
154
- async function cachedArtifact(file: string, digest: string | undefined, checkpoint: () => Promise<void>): Promise<Response> {
154
+ /**
155
+ * THE CACHE SPEEDS AN OPEN UP; IT NEVER DECIDES WHETHER BLENDER OPENS. A Cache Storage that cannot
156
+ * open or answer (measured 2026-10-07, headless Chrome on Windows: `caches.open` threw "Unexpected
157
+ * internal error", and the model failed to open on it) means the artifact is fetched from the editor
158
+ * as it would be with no digest; bytes that cannot be kept are used anyway. `note` says which, per artifact, as page output (not an unresolved console entry).
159
+ */
160
+ async function cachedArtifact(file: string, digest: string | undefined, checkpoint: () => Promise<void>, note: (text: string) => void): Promise<Response> {
155
161
  const bounded = (response: Response): Response => response.ok && response.body
156
162
  ? new Response(checkpointStream(response.body, checkpoint), { status: response.status, headers: response.headers })
157
163
  : response;
@@ -159,18 +165,29 @@ async function cachedArtifact(file: string, digest: string | undefined, checkpoi
159
165
  const fetchArtifact = () => startupOperation(url, 'fetch artifact', () => fetch(url));
160
166
  if (!digest || typeof caches === 'undefined') return bounded(await fetchArtifact());
161
167
  const key = `${url}?sha256=${digest}`;
162
- const cache = await startupOperation(url, 'open artifact cache', () => caches.open(ARTIFACT_CACHE));
163
- const hit = await startupOperation(url, 'read artifact cache', () => cache.match(key));
164
- if (hit) return bounded(hit);
168
+ const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));
169
+ const opened = await startupOperation(url, 'open artifact cache', () => caches.open(ARTIFACT_CACHE))
170
+ .then(async (cache) => ({ cache, hit: await startupOperation(url, 'read artifact cache', () => cache.match(key)) }))
171
+ .catch((error: unknown) => {
172
+ note(`Blender's artifact cache is unavailable (${reason(error)}); ${file} is fetched from the editor instead.`);
173
+ return null;
174
+ });
175
+ if (!opened) return bounded(await fetchArtifact());
176
+ if (opened.hit) return bounded(opened.hit);
177
+ const { cache } = opened;
165
178
  const response = bounded(await fetchArtifact());
166
179
  if (!response.ok) return response;
167
- // One build's bytes per file: an older build's are dropped as this one lands.
168
- await startupOperation(url, 'prune previous artifact cache entries', async () => {
169
- for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
170
- });
171
180
  const body = await startupOperation(url, 'read artifact body for cache', () => response.blob());
172
181
  const kept = new Response(body, { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
173
- await startupOperation(url, 'write artifact cache', () => cache.put(key, kept.clone()));
182
+ try {
183
+ // One build's bytes per file: an older build's are dropped as this one lands.
184
+ await startupOperation(url, 'prune previous artifact cache entries', async () => {
185
+ for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
186
+ });
187
+ await startupOperation(url, 'write artifact cache', () => cache.put(key, kept.clone()));
188
+ } catch (error) {
189
+ note(`Blender's artifact cache could not keep ${file} (${reason(error)}); the next open fetches it again.`);
190
+ }
174
191
  return kept;
175
192
  }
176
193
 
@@ -187,7 +204,9 @@ export async function startEmscriptenBlenderEngine(
187
204
  const glueUrl = artifactUrl('blender_browser.js');
188
205
  const digests = status.digests ?? {};
189
206
  const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
190
- const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`));
207
+ // Page output, not an editor-console condition: nothing in the project can resolve the browser's cache.
208
+ const noteCache = (text: string) => options.log('log', text);
209
+ const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`), noteCache);
191
210
  // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
192
211
  // so it is read before the module starts; the wasm streams in beside it.
193
212
  const [factory, preloaded] = await Promise.all([
@@ -354,7 +373,7 @@ export async function startEmscriptenBlenderEngine(
354
373
  const files = moduleFiles(module);
355
374
  FS.chmod('/bw/datafiles', 0o755);
356
375
  await checkpoint('python-ready');
357
- await mountEssentials(files, digests['essentials.bin'], checkpoint);
376
+ await mountEssentials(files, digests['essentials.bin'], checkpoint, noteCache);
358
377
  const { request } = openSessionChannel(files, options);
359
378
 
360
379
  return {
@@ -381,11 +400,11 @@ export async function startEmscriptenBlenderEngine(
381
400
  /** Assets are data, not a second engine. Ship them separately so a data update
382
401
  * does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
383
402
  * checked before anything enters Blender's filesystem. */
384
- async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>): Promise<void> {
403
+ async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>, noteCache: (text: string) => void): Promise<void> {
385
404
  const indexUrl = artifactUrl('essentials.json');
386
405
  const payloadUrl = artifactUrl('essentials.bin');
387
406
  const [indexResponse, payloadResponse] = await Promise.all([
388
- startupOperation(indexUrl, 'fetch Essentials index', () => fetch(indexUrl)), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin')),
407
+ startupOperation(indexUrl, 'fetch Essentials index', () => fetch(indexUrl)), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin'), noteCache),
389
408
  ]);
390
409
  if (!indexResponse.ok || !payloadResponse.ok)
391
410
  throw new Error(`Blender Essentials assets are missing (${indexUrl}: HTTP ${indexResponse.status}; ${payloadUrl}: HTTP ${payloadResponse.status})`);
@@ -172,7 +172,7 @@ export interface BlenderEngine {
172
172
  readonly bootMs: number;
173
173
  /** The engine's memory in bytes, now. Linear memory on the standalone skew;
174
174
  * null where the skew cannot see the module's memory from the host, which
175
- * is the WALI case and is why `volter-model-editor status` prints "unreported" rather than
175
+ * is the WALI case and is why `cyclotron status` prints "unreported" rather than
176
176
  * a zero. */
177
177
  memoryBytes(): number | null;
178
178
  /** Bytes of packed `.data` payload handed back after boot, or null when the
@@ -361,7 +361,7 @@ export function openSessionChannel(
361
361
  *
362
362
  * The loop below waits for `out/<id>.done` and nothing else. When the
363
363
  * program behind the directory DIES, that file is never written, so the
364
- * call waits forever: `volter-model-editor status` reports it IN FLIGHT for as long as
364
+ * call waits forever: `cyclotron status` reports it IN FLIGHT for as long as
365
365
  * anyone looks, `blender-start` keeps answering from the host's cached
366
366
  * banner, and the harness has no answer to time out against. MEASURED
367
367
  * 2026-09-19 three times on the WALI skew -- 507 s, 621 s and 964 s of a
@@ -574,7 +574,7 @@ export async function startWaliBlenderEngine(
574
574
  readArena: () => filesystem.readFile(ARENA_PATH),
575
575
  bootMs,
576
576
  // The module's memory is the program worker's and no door reports it.
577
- // Null rather than zero: `volter-model-editor status` prints "unreported", which is true,
577
+ // Null rather than zero: `cyclotron status` prints "unreported", which is true,
578
578
  // where a zero would be a measurement that was never taken.
579
579
  memoryBytes: () => null,
580
580
  // There is no packed payload on this skew: Blender's runtime tree is
@@ -181,6 +181,8 @@ export interface RenderRequest {
181
181
  * `'None'` means the base transform; anything else is a look the view holds
182
182
  * a baked table for, because the renderer refuses the rest by name. */
183
183
  look?: string;
184
+ /** Display gamma, applied after the view transform; alpha is unaffected. */
185
+ gamma?: number;
184
186
  /** Hand back the SCENE-REFERRED half-float frame too. An EXR is written
185
187
  * from it, and so is the COMPOSITOR's input. */
186
188
  linear?: boolean;
@@ -216,7 +216,7 @@ export class BlenderRuntime {
216
216
  // The page console is the editor's ledger (`installEditorConsoleReporting`
217
217
  // captures it, source-blind), and this package may import nothing of the
218
218
  // editor to say it any other way. Without this line the failure reached
219
- // a toast and `volter-model-editor console` read 0/0 while no Model document could open
219
+ // a toast and `cyclotron console` read 0/0 while no Model document could open
220
220
  // (measured 2026-09-20 from a registry install).
221
221
  console.error(error.message);
222
222
  for (const id of [...this.#pending.keys()]) this.#settled(id);
@@ -407,13 +407,13 @@ def _describe_world_socket(root_socket, camera_ray):
407
407
  return float(default) if isinstance(default, (float, int)) else [float(c) for c in list(default)[:3]]
408
408
  if kind == "ShaderNodeTexSky" and socket == "Color":
409
409
  model = node.sky_type
410
- if model != "MULTIPLE_SCATTERING":
410
+ if model not in ("SINGLE_SCATTERING", "MULTIPLE_SCATTERING"):
411
411
  raise NotImplementedError(
412
- "World Sky Texture: sky_type %r is not implemented (MULTIPLE_SCATTERING is)" % model)
412
+ "World Sky Texture: sky_type %r is not implemented (SINGLE_SCATTERING and MULTIPLE_SCATTERING are)" % model)
413
413
  vector = node.inputs.get("Vector")
414
414
  if vector is not None and vector.links:
415
415
  raise NotImplementedError("Linked World Sky Texture Vector")
416
- return {"kind": "sky", "sun_elevation": float(node.sun_elevation),
416
+ return {"kind": "sky", "sky_model": model, "sun_elevation": float(node.sun_elevation),
417
417
  "sun_rotation": float(node.sun_rotation), "altitude": float(node.altitude),
418
418
  "air_density": float(node.air_density), "aerosol_density": float(node.aerosol_density),
419
419
  "ozone_density": float(node.ozone_density)}
@@ -608,7 +608,7 @@ _GRAPH_NODES = frozenset((
608
608
  "ShaderNodeGamma", "ShaderNodeRGBToBW", "ShaderNodeSeparateColor", "ShaderNodeCombineColor",
609
609
  "ShaderNodeVectorRotate", "ShaderNodeFresnel", "ShaderNodeLayerWeight", "ShaderNodeBump",
610
610
  "ShaderNodeNormalMap", "ShaderNodeNewGeometry", "ShaderNodeVertexColor", "ShaderNodeAttribute",
611
- "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve",
611
+ "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve", "ShaderNodeObjectInfo",
612
612
  ))
613
613
  # The surfaces whose inputs map onto the presenter's standard material, and
614
614
  # the inputs of each the presenter reads from a graph.
@@ -959,6 +959,10 @@ class _MaterialGraph:
959
959
  if node.bl_idname in _GENERATED_BY_DEFAULT and not bool(_links_into(node.inputs[0])):
960
960
  self.generated = True
961
961
  props = _node_properties(node)
962
+ if node.bl_idname == "ShaderNodeObjectInfo":
963
+ # node_shader_gpu_object_info: the index belongs to this material,
964
+ # even when the node itself is inside a shared node group.
965
+ props["material_index"] = float(self.material.pass_index)
962
966
  if props.get("image"):
963
967
  self.images.add(props["image"])
964
968
  return {
@@ -1133,6 +1137,33 @@ def _mixed_graph(material, stack, surface):
1133
1137
  }
1134
1138
 
1135
1139
 
1140
+ def _object_info_random(name):
1141
+ """Ordinary-object Random, shared by EEVEE and Cycles: hash_string then
1142
+ Jenkins hash_uint2(name_hash, 0), converted to float32 * 2**-32.
1143
+ Instances must use depsgraph.random_id instead; their export is separate.
1144
+ See draw_handle.hh::random and cycles/blender/object.cpp.
1145
+ """
1146
+ import struct
1147
+
1148
+ mask = 0xffffffff
1149
+ h = 0
1150
+ # Blender's platform flags specify -funsigned-char (native and WASM).
1151
+ for byte in name.encode("utf8"):
1152
+ h = (h * 37 + byte) & mask
1153
+ a = (0xdeadbeef + 21 + h) & mask
1154
+ b = c = (0xdeadbeef + 21) & mask
1155
+ def rotate(x, bits):
1156
+ return ((x << bits) | (x >> (32 - bits))) & mask
1157
+ c = ((c ^ b) - rotate(b, 14)) & mask
1158
+ a = ((a ^ c) - rotate(c, 11)) & mask
1159
+ b = ((b ^ a) - rotate(a, 25)) & mask
1160
+ c = ((c ^ b) - rotate(b, 16)) & mask
1161
+ a = ((a ^ c) - rotate(c, 4)) & mask
1162
+ b = ((b ^ a) - rotate(a, 14)) & mask
1163
+ c = ((c ^ b) - rotate(b, 24)) & mask
1164
+ return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1165
+
1166
+
1136
1167
  def material_graphs(scene):
1137
1168
  """Every graph the scene's materials need, by material name."""
1138
1169
  _TREE_LINKS.clear()
@@ -1454,6 +1485,12 @@ class Session:
1454
1485
  if not any(m in graphs for m in row["materials"] if m is not None):
1455
1486
  continue
1456
1487
  obj = by_name.get(row["name"])
1488
+ if obj is not None:
1489
+ row["object_info"] = {
1490
+ "color": [float(v) for v in obj.color],
1491
+ "index": float(obj.pass_index),
1492
+ "random": _object_info_random(obj.name),
1493
+ }
1457
1494
  data = obj.evaluated_get(depsgraph).data if obj is not None else None
1458
1495
  if getattr(data, "use_auto_texspace", True) is False:
1459
1496
  row["texspace"] = [[float(v) for v in data.texspace_location],
@@ -2013,7 +2050,7 @@ _ASK_SEQUENCE = [0]
2013
2050
  def _drop(path):
2014
2051
  """Remove one of THIS side's own channel files. A failure here means the
2015
2052
  ownership rule was broken by the other side, so it is named rather than
2016
- swallowed -- and named where `volter-model-editor console` reads it, not into a log."""
2053
+ swallowed -- and named where `cyclotron console` reads it, not into a log."""
2017
2054
  try:
2018
2055
  os.unlink(path)
2019
2056
  except OSError as error:
@@ -2140,15 +2177,12 @@ def _photograph(depsgraph, width, height, linear=False):
2140
2177
  "fov": _vertical_extent(camera.data, int(width), int(height)),
2141
2178
  "toneMapping": transform,
2142
2179
  "exposure": float(2.0 ** view.exposure),
2180
+ "gamma": float(view.gamma),
2143
2181
  "orthographic": camera.data.type == "ORTHO",
2144
2182
  "transparent": bool(scene.render.film_transparent),
2145
2183
  }
2146
- # The renderer keys its baked look tables by the config's FULL name
2147
- # (`AgX - Medium High Contrast`), and `AgX - Base Contrast` is the AgX base
2148
- # view itself -- the same table as no look at all. Send the full name for a
2149
- # non-identity look and nothing for the identity; a look with no table is
2150
- # refused BY NAME on the other side (`blender-runtime-host.ts`).
2151
- if look not in ("None", "AgX - Base Contrast"):
2184
+ # Preserve the full look name: the native OCIO display processor owns it.
2185
+ if look != "None":
2152
2186
  render["look"] = look
2153
2187
  # The capture's scene-referred frame rides along when asked for: a render
2154
2188
  # result is what a render leaves, and it holds scene-linear pixels.
@@ -2215,6 +2249,20 @@ class VolterRenderEngine(bpy.types.RenderEngine):
2215
2249
  bl_label = "three.js"
2216
2250
  bl_use_preview = False
2217
2251
 
2252
+ def view_update(self, context, depsgraph):
2253
+ """The tab presents the depsgraph; Blender owns the shading selection."""
2254
+ pass
2255
+
2256
+ def view_draw(self, context, depsgraph):
2257
+ """Advertise viewport rendering to RNA without asking headless Blender for a GPU.
2258
+
2259
+ `rna_SpaceView3DShading_type_itemf` only offers RENDERED for a Python
2260
+ engine with `view_draw`. The Three presenter draws that viewport; this
2261
+ callback is never its drawing path. Without it, opening a Cycles file
2262
+ makes the ordinary Rendered control reject its own enum value.
2263
+ """
2264
+ pass
2265
+
2218
2266
  def render(self, depsgraph):
2219
2267
  scene = depsgraph.scene
2220
2268
  scale = scene.render.resolution_percentage / 100.0
@@ -2238,7 +2286,7 @@ def _fill_result(engine, result, answer, png, width, height):
2238
2286
  view transform over them wherever it shows or saves one (`write_still`, the
2239
2287
  Image Editor), as it does over Cycles'. The photograph's PNG is the display
2240
2288
  image -- the transform already ran -- so loading it put every render
2241
- through AgX or Filmic twice. Measured 2026-09-28 by the model editor's
2289
+ through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
2242
2290
  render view: a background 53 against the viewport's 59, a shadow's blue 2
2243
2291
  against 7. The same capture's linear frame goes in instead: half floats,
2244
2292
  RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
@@ -4155,7 +4203,7 @@ def _node_row(node):
4155
4203
 
4156
4204
 
4157
4205
  def _node_tree_of(path, material):
4158
- """WHICH TREE. Either an explicit RNA address (so a `volter-model-editor eval` can open a
4206
+ """WHICH TREE. Either an explicit RNA address (so a `cyclotron eval` can open a
4159
4207
  world's or a group's tree with the engine's own spelling), or a material by
4160
4208
  name, or -- given neither -- the active object's active material, which is
4161
4209
  what Blender's own Shading header resolves (`space_node.py:89-93`,
@@ -4413,7 +4461,7 @@ def rna_rig(object_name=None):
4413
4461
  presented frame carries every object at once, and the presenter has to know
4414
4462
  which of its meshes are `THREE.SkinnedMesh`es before it builds them. Asking
4415
4463
  per object would be one round trip per mesh; asking by NAME stays available
4416
- for a `volter-model-editor eval` that wants to read one.
4464
+ for a `cyclotron eval` that wants to read one.
4417
4465
  """
4418
4466
  scene_frame = int(bpy.context.scene.frame_current)
4419
4467
  if object_name:
@@ -5150,7 +5198,7 @@ def _dispatch(request):
5150
5198
  # to the console and nothing else), so it logged at `log` level and
5151
5199
  # reached no counter -- measured 2026-09-19 (I4), when a throw in
5152
5200
  # the overlay walk froze the viewport through a dozen successful
5153
- # `blender-execute` calls with `volter-model-editor console` silent throughout.
5201
+ # `blender-execute` calls with `cyclotron console` silent throughout.
5154
5202
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5155
5203
  "showing the state before it: " + repr(thrown))
5156
5204
  return answer
@@ -5200,7 +5248,7 @@ def _dispatch(request):
5200
5248
  # to the console and nothing else), so it logged at `log` level and
5201
5249
  # reached no counter -- measured 2026-09-19 (I4), when a throw in
5202
5250
  # the overlay walk froze the viewport through a dozen successful
5203
- # `blender-execute` calls with `volter-model-editor console` silent throughout.
5251
+ # `blender-execute` calls with `cyclotron console` silent throughout.
5204
5252
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5205
5253
  "showing the state before it: " + repr(thrown))
5206
5254
  return answer
@@ -5223,7 +5271,7 @@ def _dispatch(request):
5223
5271
  # to the console and nothing else), so it logged at `log` level and
5224
5272
  # reached no counter -- measured 2026-09-19 (I4), when a throw in
5225
5273
  # the overlay walk froze the viewport through a dozen successful
5226
- # `blender-execute` calls with `volter-model-editor console` silent throughout.
5274
+ # `blender-execute` calls with `cyclotron console` silent throughout.
5227
5275
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5228
5276
  "showing the state before it: " + repr(thrown))
5229
5277
  return answer
@@ -0,0 +1,45 @@
1
+ Blender display processors
2
+ ==========================
3
+
4
+ blender-display-shaders.json and blender-display-shaders.lut are generated
5
+ using OpenColorIO 2.5.0 from Blender's color-management configuration.
6
+ The pinned configuration is publicly available at:
7
+ https://github.com/volter-ai/blender/blob/071e080a21c09e07f2dac8593dca2f9a566e591e/release/datafiles/colormanagement/config.ocio
8
+
9
+ Configuration SHA-256:
10
+ 47a7d83e79c1d21f49ba6c505efe311da723471688c614b5c366e1da7eb8ea3a
11
+
12
+ Filmic configuration: Troy James Sobotka, with the contributors named in
13
+ config.ocio. AgX: based on Troy Sobotka's AgX, further developed by Zijun Eary
14
+ Zhou, Mark Faderbauer and Sakari Kapanen. The generated operations and table
15
+ payload preserve the source configuration; Blender source/license information
16
+ is available at that repository's release/license/ directory.
17
+
18
+ OpenColorIO's license for the shader generator follows.
19
+
20
+ Copyright Contributors to the OpenColorIO Project.
21
+
22
+ Redistribution and use in source and binary forms, with or without
23
+ modification, are permitted provided that the following conditions are
24
+ met:
25
+
26
+ * Redistributions of source code must retain the above copyright
27
+ notice, this list of conditions and the following disclaimer.
28
+ * Redistributions in binary form must reproduce the above copyright
29
+ notice, this list of conditions and the following disclaimer in the
30
+ documentation and/or other materials provided with the distribution.
31
+ * Neither the name of the copyright holder nor the names of its
32
+ contributors may be used to endorse or promote products derived from
33
+ this software without specific prior written permission.
34
+
35
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
36
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
37
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
38
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
39
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
40
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
41
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
42
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
43
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
44
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
45
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.