@volter/blender-engine 0.1.129 → 0.1.130

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.
@@ -140,17 +140,45 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
140
140
  }
141
141
 
142
142
  /**
143
- * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD. The wasm (86 MB decoded), the
144
- * `.data` package (53 MB) and the Essentials payload are immutable for a build,
145
- * and the page keeps them in Cache Storage under the digest the artifact door
146
- * reports, so a later open reads them at the browser's own speed instead of
147
- * from the editor's server. Measured in the browser substrate, where that
148
- * server runs in the tab: the three took 7.2 s per open (4.2 s for the wasm
149
- * alone), about half of it decoding their brotli. A file the door gives no
150
- * digest for is fetched as it always was.
143
+ * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD -- WHERE THE SERVER RUNS IN THE TAB. The wasm
144
+ * (86 MB decoded), the `.data` package (53 MB) and the Essentials payload are immutable for a
145
+ * build, and a page whose editor server runs in its own tab (browser-substrate; the door says so
146
+ * as `status.inTab`) keeps them in Cache Storage under the digest the door reports, so a later
147
+ * open reads them at the browser's own speed instead of from that server. Measured there: the
148
+ * three took 7.2 s per open (4.2 s for the wasm alone), about half of it decoding their brotli
149
+ * in the tab. A file the door gives no digest for is fetched as it always was.
150
+ *
151
+ * NOT WHERE THE SERVER IS A PROCESS ON THIS MACHINE. There the door answers from local disk over
152
+ * loopback with `no-cache` and an ETag, brotli as stored to a client that accepts it
153
+ * (`editor-blender/serving/blender-routes.ts`), so the browser's HTTP cache keeps the bytes as
154
+ * sent (~38 MB) and a repeat open is a 304 and the browser's own decode. Cache Storage there
155
+ * held a second, decoded copy (~144 MiB) per origin, and an editor origin is per project folder
156
+ * (`editor-<id>.localhost:<port>`, before 0.5.190 `127.0.0.1:<port>`): measured 2026-10-07 in
157
+ * one Chrome profile, 113 origins held ~16.3 GB. The HTTP cache is size-bounded and evicted by the browser; Cache Storage is
158
+ * kept until the origin deletes it. So such a page fetches through the HTTP cache and releases
159
+ * any copy an earlier build left in its origin ({@link releaseArtifactCache}).
160
+ *
161
+ * The cache was never made persistent (nothing here calls `navigator.storage.persist`), so it is
162
+ * best-effort storage Chrome may evict under storage pressure; it stays that way.
151
163
  */
152
164
  const ARTIFACT_CACHE = 'volter-blender-artifacts';
153
165
 
166
+ /**
167
+ * RELEASE THIS ORIGIN'S COPY, on every boot of a page that does not keep one. A page can only
168
+ * delete its own origin's storage: an origin nobody opens again is reclaimed by Chrome's own
169
+ * eviction, not here. Best-effort and off the boot's path: a Cache Storage that cannot answer
170
+ * (see {@link cachedArtifact}) costs one line of page output, never the open.
171
+ */
172
+ function releaseArtifactCache(note: (text: string) => void): void {
173
+ if (typeof caches === 'undefined') return;
174
+ void caches.delete(ARTIFACT_CACHE).then(
175
+ (released) => {
176
+ if (released) note(`Released Blender's artifact cache in this origin: this editor's server runs on this machine, so the browser's HTTP cache keeps the engine instead.`);
177
+ },
178
+ (error: unknown) => note(`Blender's artifact cache in this origin could not be released (${error instanceof Error ? error.message : String(error)}); Chrome may still evict it.`),
179
+ );
180
+ }
181
+
154
182
  /**
155
183
  * THE CACHE SPEEDS AN OPEN UP; IT NEVER DECIDES WHETHER BLENDER OPENS. A Cache Storage that cannot
156
184
  * open or answer (measured 2026-10-07, headless Chrome on Windows: `caches.open` threw "Unexpected
@@ -202,10 +230,14 @@ export async function startEmscriptenBlenderEngine(
202
230
  'every response). This worker reports crossOriginIsolated=false.',
203
231
  );
204
232
  const glueUrl = artifactUrl('blender_browser.js');
205
- const digests = status.digests ?? {};
206
- const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
207
233
  // Page output, not an editor-console condition: nothing in the project can resolve the browser's cache.
208
234
  const noteCache = (text: string) => options.log('log', text);
235
+ // Digests are what `cachedArtifact` keeps a file under; with none, every file goes through the
236
+ // HTTP cache. Only a server in this tab is worth a Cache Storage copy (see ARTIFACT_CACHE).
237
+ const keepsArtifacts = status.inTab === true;
238
+ if (!keepsArtifacts) releaseArtifactCache(noteCache);
239
+ const digests = keepsArtifacts ? (status.digests ?? {}) : {};
240
+ const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
209
241
  const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`), noteCache);
210
242
  // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
211
243
  // so it is read before the module starts; the wasm streams in beside it.
@@ -237,6 +237,9 @@ export interface BlenderArtifactStatus {
237
237
  missing: string[];
238
238
  /** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
239
239
  digests?: Record<string, string>;
240
+ /** Emscripten: the server answering runs in this tab (browser-substrate). Only then are the
241
+ * digests used to keep the files in Cache Storage; absent (an older server) means false. */
242
+ inTab?: boolean;
240
243
  /** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
241
244
  * requires for any non-blob URL. */
242
245
  integrity?: string;
package/browser/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { CaptureRequest, FileEntry, RuntimeStart } from './protocol';
1
+ export type { CaptureRequest, FileEntry, RuntimeStart, NativePreviewRequest, NativePreviewResult, NativeDocumentInfo, FileListSnapshot } from './protocol';
2
2
  export {
3
3
  BlenderRuntime,
4
4
  type BlenderRuntimeOptions,
@@ -12,6 +12,10 @@ export type WorkerRequest =
12
12
  * empty and is created by the first save. */
13
13
  | { id: number; op: 'start'; project: string; document?: string }
14
14
  | { id: number; op: 'execute'; code: string; history?: boolean; label?: string }
15
+ /** A native Cycles photograph, isolated from authoring/history and the
16
+ * Three frame exporter. This is a preview request, not a modeling edit. */
17
+ | { id: number; op: 'native-preview'; width: number; height: number; samples: number; camera?: string }
18
+ | { id: number; op: 'list-files-snapshot'; path: string }
15
19
  | { id: number; op: 'scene-info' }
16
20
  | { id: number; op: 'object-info'; name: string }
17
21
  | { id: number; op: 'screenshot-view'; maxSize: number }
@@ -139,6 +143,36 @@ export type NativeHistoryEntry = { reset: true } | {
139
143
  resource: string | null;
140
144
  };
141
145
 
146
+ export interface NativePreviewRequest {
147
+ width: number;
148
+ height: number;
149
+ samples: number;
150
+ camera?: string;
151
+ }
152
+
153
+ export interface NativePreviewResult {
154
+ path: string;
155
+ renderer: 'CYCLES';
156
+ session: string;
157
+ revision: number;
158
+ camera: string;
159
+ width: number;
160
+ height: number;
161
+ samples: number;
162
+ seconds: number;
163
+ }
164
+
165
+ export interface NativeDocumentInfo {
166
+ filepath: string;
167
+ session: string;
168
+ revision: number;
169
+ }
170
+
171
+ export interface FileListSnapshot {
172
+ entries: FileEntry[];
173
+ document: NativeDocumentInfo;
174
+ }
175
+
142
176
  export interface CaptureRequest {
143
177
  /** A viewport screenshot's bound, the longer side of a square frame.
144
178
  * Absent on a render, which states its exact pixel dimensions instead. */
@@ -173,6 +207,12 @@ export interface RenderRequest {
173
207
  height: number;
174
208
  /** The camera's VERTICAL field of view, in degrees. */
175
209
  fov: number;
210
+ /** Authored camera clipping and sensor shift; not viewport navigation. */
211
+ clipStart?: number;
212
+ clipEnd?: number;
213
+ shiftX?: number;
214
+ shiftY?: number;
215
+ horizontalFit?: boolean;
176
216
  /** The scene's own view transform, as the three.js tone mapping of the same
177
217
  * NAME (`bpy/_render_three.py`). A transform with no curve here never
178
218
  * reaches this point: Python refuses it. */
@@ -12,6 +12,9 @@ import type {
12
12
  WorkerReply,
13
13
  WorkerRequest,
14
14
  NativeHistoryEntry,
15
+ NativePreviewRequest,
16
+ NativePreviewResult,
17
+ FileListSnapshot,
15
18
  } from './protocol';
16
19
  import type {
17
20
  BlenderActionClip,
@@ -285,6 +288,13 @@ export class BlenderRuntime {
285
288
  return (await this.#request({ op: 'execute', code, history, label })) as string;
286
289
  }
287
290
 
291
+ /** Render in native Cycles without changing or re-exporting the document.
292
+ * The returned PNG is read through readFile; no pixels cross the JSON relay. */
293
+ async nativePreview(request: NativePreviewRequest): Promise<NativePreviewResult> {
294
+ await this.#ready();
295
+ return await this.#request({ op: 'native-preview', ...request }) as NativePreviewResult;
296
+ }
297
+
288
298
  async historyGesture(op: 'history-begin' | 'history-end'): Promise<void> {
289
299
  await this.#ready();
290
300
  await this.#request({ op });
@@ -466,6 +476,13 @@ export class BlenderRuntime {
466
476
  return (await this.#request({ op: 'list-files', path })) as FileEntry[];
467
477
  }
468
478
 
479
+ /** Files and their native document identity, without executing Python or
480
+ * exporting/saving the model. The worker serializes this with authoring. */
481
+ async listFilesSnapshot(path: string): Promise<FileListSnapshot> {
482
+ await this.#ready();
483
+ return await this.#request({ op: 'list-files-snapshot', path }) as FileListSnapshot;
484
+ }
485
+
469
486
  /** The session and revision of the last frame this runtime forwarded; null
470
487
  * before the first present. See {@link PresentedState}. */
471
488
  get presented(): PresentedState | null {
@@ -207,13 +207,8 @@ def _load_post(_arg):
207
207
  # The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
208
208
  # into the Main that just went away); this is the session's half.
209
209
  SESSION.forget()
210
- # A factory reset reloads every add-on (`bpy.utils.load_scripts(reload_scripts=True)`
211
- # runs inside `read_factory_settings`), and the Cycles add-on's own engine
212
- # class comes back under `CYCLES` while ours is gone -- measured in the tab:
213
- # after the battery's opening reset, `scene.render.engine = 'CYCLES'`
214
- # resolved to `cycles.CyclesRender`, every render was a real path trace
215
- # in wasm (35 s at 1280x720, 48 samples) and `_photograph` never ran. The
216
- # engine ids are taken again on every load.
210
+ # Factory reset reloads add-ons. Preserve native Cycles and re-register
211
+ # only the headless GPU presenters we own.
217
212
  ENGINES[:], UNAVAILABLE_ENGINES[:] = _register_engine()
218
213
  HISTORY.reset()
219
214
 
@@ -413,7 +408,9 @@ def _describe_world_socket(root_socket, camera_ray):
413
408
  vector = node.inputs.get("Vector")
414
409
  if vector is not None and vector.links:
415
410
  raise NotImplementedError("Linked World Sky Texture Vector")
416
- return {"kind": "sky", "sky_model": model, "sun_elevation": float(node.sun_elevation),
411
+ return {"kind": "sky", "sky_model": model, "sun_disc": bool(node.sun_disc),
412
+ "sun_size": float(node.sun_size), "sun_intensity": float(node.sun_intensity),
413
+ "sun_elevation": float(node.sun_elevation),
417
414
  "sun_rotation": float(node.sun_rotation), "altitude": float(node.altitude),
418
415
  "air_density": float(node.air_density), "aerosol_density": float(node.aerosol_density),
419
416
  "ozone_density": float(node.ozone_density)}
@@ -608,7 +605,7 @@ _GRAPH_NODES = frozenset((
608
605
  "ShaderNodeGamma", "ShaderNodeRGBToBW", "ShaderNodeSeparateColor", "ShaderNodeCombineColor",
609
606
  "ShaderNodeVectorRotate", "ShaderNodeFresnel", "ShaderNodeLayerWeight", "ShaderNodeBump",
610
607
  "ShaderNodeNormalMap", "ShaderNodeNewGeometry", "ShaderNodeVertexColor", "ShaderNodeAttribute",
611
- "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve", "ShaderNodeObjectInfo",
608
+ "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve", "ShaderNodeObjectInfo", "ShaderNodeParticleInfo",
612
609
  ))
613
610
  # The surfaces whose inputs map onto the presenter's standard material, and
614
611
  # the inputs of each the presenter reads from a graph.
@@ -937,6 +934,8 @@ class _MaterialGraph:
937
934
  return self.source(stack[:-1], outer_socket)
938
935
  if kind not in _GRAPH_NODES:
939
936
  raise _GraphRefusal("%s (%s) is not compiled by the presenter" % (node.name, kind))
937
+ if kind == "ShaderNodeParticleInfo" and socket.name != "Random":
938
+ raise _GraphRefusal("Particle Info output %s is not compiled by the presenter" % socket.name)
940
939
  refusal = _refusal(node)
941
940
  if refusal is not None:
942
941
  raise _GraphRefusal(refusal)
@@ -1164,6 +1163,91 @@ def _object_info_random(name):
1164
1163
  return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1165
1164
 
1166
1165
 
1166
+ def _particle_info_random(index=0):
1167
+ """Cycles Particle Info Random: hash_uint2(particle.index, 0).
1168
+
1169
+ Native sync_dupli_particle excludes child particles; they and objects
1170
+ without a particle record use the zero-index dummy KernelParticle.
1171
+ This is distinct from Object Info's placement random_id.
1172
+ """
1173
+ import struct
1174
+ mask = 0xffffffff
1175
+ a = (0xdeadbeef + 21 + index) & mask
1176
+ b = c = (0xdeadbeef + 21) & mask
1177
+ def rotate(x, bits):
1178
+ return ((x << bits) | (x >> (32 - bits))) & mask
1179
+ c = ((c ^ b) - rotate(b, 14)) & mask
1180
+ a = ((a ^ c) - rotate(c, 11)) & mask
1181
+ b = ((b ^ a) - rotate(a, 25)) & mask
1182
+ c = ((c ^ b) - rotate(b, 16)) & mask
1183
+ a = ((a ^ c) - rotate(c, 4)) & mask
1184
+ b = ((b ^ a) - rotate(a, 14)) & mask
1185
+ c = ((c ^ b) - rotate(b, 24)) & mask
1186
+ return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1187
+
1188
+
1189
+ def _depsgraph_placements(frame, depsgraph):
1190
+ """Placement metadata only; every vertex stays in the native C++ arena.
1191
+
1192
+ Keep this table separate from authoring objects. No realization, datablock
1193
+ edits, vertex arrays or generated scene objects.
1194
+ """
1195
+ import struct
1196
+ by_name = {row["name"]: row for row in frame["objects"]}
1197
+ for row in frame["objects"]:
1198
+ source = bpy.data.objects.get(row["name"])
1199
+ if source is not None:
1200
+ row["shadow_visible"] = bool(source.visible_shadow)
1201
+ # Frame-local native addresses are comparison tokens, not authoring IDs.
1202
+ # Consume them here so neither the presenter nor saved projects retain them.
1203
+ geometry = frame.pop("instance_geometry", {})
1204
+ placements = []
1205
+ for instance in depsgraph.object_instances:
1206
+ source = instance.object.original
1207
+ row = by_name.get(source.name)
1208
+ if not instance.is_instance:
1209
+ if row is not None:
1210
+ row["render_visible"] = row["render_visible"] and not source.hide_render
1211
+ row["viewport_show_self"] = bool(instance.show_self)
1212
+ row["render_show_self"] = not source.is_instancer or source.show_instancer_for_render
1213
+ continue
1214
+ if not instance.show_self:
1215
+ continue
1216
+ if (instance.instance_object is not None and instance.object.data != instance.instance_object.data and
1217
+ (instance.object.data is None or geometry.get(source.name) != instance.object.data.as_pointer())):
1218
+ raise NotImplementedError("Depsgraph instance geometry differs from its native source: %s" % source.name)
1219
+ if row is None or (source.type == "MESH" and row["mesh"] is None):
1220
+ raise NotImplementedError("Depsgraph instance source has no native exported geometry: %s" % source.name)
1221
+ if row["mesh"] is None:
1222
+ if source.type not in ("EMPTY", "CAMERA"):
1223
+ warn("Depsgraph instance %r of type %s is not drawn" % (source.name, source.type))
1224
+ continue
1225
+ owner = instance.parent.original if instance.parent is not None else None
1226
+ owner_row = by_name.get(owner.name) if owner is not None else None
1227
+ if owner is not None and owner_row is None:
1228
+ raise NotImplementedError("Depsgraph instance owner is not exported: %s" % owner.name)
1229
+ persistent = list(instance.persistent_id)
1230
+ particle_index = persistent[0]
1231
+ psys = instance.particle_system
1232
+ if psys is None or not 0 <= particle_index < len(psys.particles):
1233
+ particle_index = 0
1234
+ random = struct.unpack("f", struct.pack("f", (int(instance.random_id) & 0xffffffff) / 4294967296.0))[0]
1235
+ placements.append({
1236
+ "id": "@instance:" + json.dumps([owner.name if owner else None, source.name, persistent], separators=(",", ":"), ensure_ascii=False),
1237
+ "source": row["id"], "owner": owner_row["id"] if owner_row is not None else None,
1238
+ "matrix": [[float(v) for v in r] for r in instance.matrix_world],
1239
+ "visible": True,
1240
+ "render_visible": not source.hide_render and (owner is None or not owner.hide_render) and
1241
+ (owner_row is None or bool(owner_row["render_visible"])),
1242
+ # Cycles intersects source and instancer ray visibility.
1243
+ "shadow_visible": bool(source.visible_shadow) and (owner is None or bool(owner.visible_shadow)),
1244
+ "random": random,
1245
+ "particle_random": _particle_info_random(particle_index),
1246
+ "color": [float(v) for v in instance.object.color],
1247
+ })
1248
+ return placements
1249
+
1250
+
1167
1251
  def material_graphs(scene):
1168
1252
  """Every graph the scene's materials need, by material name."""
1169
1253
  _TREE_LINKS.clear()
@@ -1417,7 +1501,7 @@ class Session:
1417
1501
  # (`overlay_empty.hh` `image_sync`).
1418
1502
  empty_images = {obj.data.name for obj in scene.objects
1419
1503
  if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
1420
- options = {"session": self.session, "evaluate": True, "known": self._known,
1504
+ options = {"session": self.session, "evaluate": True, "known": self._known, "instance_sources": True,
1421
1505
  "graph_materials": sorted(graphs),
1422
1506
  "graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
1423
1507
  "graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
@@ -1490,6 +1574,7 @@ class Session:
1490
1574
  "color": [float(v) for v in obj.color],
1491
1575
  "index": float(obj.pass_index),
1492
1576
  "random": _object_info_random(obj.name),
1577
+ "particle_random": _particle_info_random(),
1493
1578
  }
1494
1579
  data = obj.evaluated_get(depsgraph).data if obj is not None else None
1495
1580
  if getattr(data, "use_auto_texspace", True) is False:
@@ -1510,6 +1595,7 @@ class Session:
1510
1595
  "roughness": float(material.roughness),
1511
1596
  "metallic": float(material.metallic),
1512
1597
  }
1598
+ frame["instances"] = _depsgraph_placements(frame, bpy.context.evaluated_depsgraph_get())
1513
1599
  frame["world"] = draw_world(scene)
1514
1600
  frame["cameras"] = {
1515
1601
  obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
@@ -2175,6 +2261,12 @@ def _photograph(depsgraph, width, height, linear=False):
2175
2261
  "width": int(width),
2176
2262
  "height": int(height),
2177
2263
  "fov": _vertical_extent(camera.data, int(width), int(height)),
2264
+ "clipStart": float(camera.data.clip_start),
2265
+ "clipEnd": float(camera.data.clip_end),
2266
+ "shiftX": float(camera.data.shift_x),
2267
+ "shiftY": float(camera.data.shift_y),
2268
+ "horizontalFit": camera.data.sensor_fit == "HORIZONTAL" or
2269
+ (camera.data.sensor_fit == "AUTO" and width >= height),
2178
2270
  "toneMapping": transform,
2179
2271
  "exposure": float(2.0 ** view.exposure),
2180
2272
  "gamma": float(view.gamma),
@@ -2239,10 +2331,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
2239
2331
  """The scene's renderer, so `write_still`, `save_render` and Render Result
2240
2332
  behave as Blender's own.
2241
2333
 
2242
- Registered under the three engine ids a script names. The port has real
2243
- Cycles compiled in, so the Cycles add-on's own engine is unregistered
2244
- first -- otherwise `scene.render.engine = 'CYCLES'` resolves to it and
2245
- starts a path trace nobody asked for inside the tab.
2334
+ Registered as VOLTER_THREE and for headless GPU-engine substitutes.
2335
+ CYCLES remains Blender's compiled CPU path tracer.
2246
2336
  """
2247
2337
 
2248
2338
  bl_idname = "VOLTER_THREE"
@@ -2372,22 +2462,21 @@ def _release_from_owning_addon(existing):
2372
2462
 
2373
2463
 
2374
2464
  def _register_engine():
2375
- """Take the three engine ids a script can name.
2376
-
2377
- Blender refuses two classes with one `bl_idname`, so the add-on's engine
2378
- class is unregistered first and the bundle's patched registration sets a
2379
- built-in type aside; `bpy.utils.register_class` on a subclass whose
2380
- `bl_idname` is `CYCLES` (or `BLENDER_EEVEE`, the factory default) then
2381
- makes `scene.render.engine` resolve here. `BLENDER_EEVEE` is the factory engine's id in 5.x (measured on the
2382
- oracle: the enum holds exactly that name; `BLENDER_EEVEE_NEXT` was 4.2's). Only the Cycles ENGINE CLASS goes: the add-on stays enabled, because
2383
- `scene.cycles` is the add-on's property group and a script sets `samples`,
2384
- `use_denoising` and the rest on it (disabling the add-on removed it, and
2385
- every workshop render died on `'Scene' object has no attribute 'cycles'`
2386
- before it reached the photograph).
2465
+ """Keep native Cycles and install the headless GPU presenters.
2466
+
2467
+ Native Cycles needs no browser GPU. Replacing its class with a Combined-only
2468
+ Three photograph drops evaluated instances, GI, shader closures and other
2469
+ compositor pass inputs. VOLTER_THREE is the explicit raster photograph;
2470
+ the live viewport and Play use Three independently of this engine choice.
2387
2471
  """
2388
2472
  made, unavailable = [], []
2473
+ cycles = _engine_class("CYCLES")
2474
+ if cycles is not None and not issubclass(cycles, VolterRenderEngine):
2475
+ made.append("CYCLES")
2476
+ else:
2477
+ # Do not report a raster substitute as an available native path tracer.
2478
+ unavailable.append("CYCLES")
2389
2479
  for identifier, label in (
2390
- ("CYCLES", "Cycles"),
2391
2480
  ("BLENDER_EEVEE", "EEVEE"),
2392
2481
  ("BLENDER_WORKBENCH", "Workbench"),
2393
2482
  ):
@@ -2414,7 +2503,7 @@ def _register_engine():
2414
2503
  # Upstream Blender refuses to let a Python engine take a BUILT-IN
2415
2504
  # id ("is built-in"); the shipped bundle carries a patch that sets
2416
2505
  # the built-in type aside instead, since headless has no GPU for it
2417
- # to render with, so all three ids are taken there (measured
2506
+ # to render with, so the two GPU ids are taken there (measured
2418
2507
  # 2026-09-17). A bundle without that patch keeps `BLENDER_EEVEE`
2419
2508
  # and `BLENDER_WORKBENCH`: that is a fact about the build, stated
2420
2509
  # in the start reply and refused by name at the render, never a
@@ -5167,8 +5256,63 @@ def _dispatch_request(request):
5167
5256
  }[op])
5168
5257
 
5169
5258
 
5259
+ def _native_preview(request):
5260
+ """Native Cycles in an owned scene copy, never in the authoring scene.
5261
+
5262
+ Preview settings, Render Result and temporary files are derived render
5263
+ resources. No history checkpoint, save, frame export or revision bump is
5264
+ owed by this operation. Remove the owned scene before another operation
5265
+ can serialize a project or enumerate its scene datablocks.
5266
+ """
5267
+ for key, maximum in (("width", 1024), ("height", 1024), ("samples", 512)):
5268
+ value = request.get(key)
5269
+ if type(value) is not int or not 1 <= value <= maximum:
5270
+ raise ValueError("Native preview %s must be an integer from 1 to %d" % (key, maximum))
5271
+ engine = _engine_class("CYCLES")
5272
+ if engine is None or issubclass(engine, VolterRenderEngine):
5273
+ raise RuntimeError("Native Cycles is unavailable; a Three photograph cannot answer this request")
5274
+ original = bpy.context.scene
5275
+ camera = original.objects.get(request["camera"]) if request.get("camera") else original.camera
5276
+ if camera is None or camera.type != "CAMERA":
5277
+ raise ValueError("Native preview needs a camera belonging to this scene")
5278
+ preview = original.copy()
5279
+ path = os.path.join(ROOT, "native-preview.png")
5280
+ try:
5281
+ preview.camera = camera
5282
+ preview.render.engine = "CYCLES"
5283
+ preview.render.resolution_x = request["width"]
5284
+ preview.render.resolution_y = request["height"]
5285
+ preview.render.resolution_percentage = 100
5286
+ # Rendered viewport shading precedes the compositor and uses its own
5287
+ # sampling budget; the authoring scene's settings remain untouched.
5288
+ preview.render.use_compositing = False
5289
+ preview.render.use_persistent_data = False
5290
+ preview.cycles.samples = request["samples"]
5291
+ preview.cycles.use_denoising = False
5292
+ preview.render.image_settings.file_format = "PNG"
5293
+ preview.render.image_settings.color_mode = "RGBA"
5294
+ preview.render.image_settings.color_depth = "8"
5295
+ preview.render.filepath = path
5296
+ if os.path.exists(path):
5297
+ os.unlink(path)
5298
+ start = time.perf_counter()
5299
+ result = bpy.ops.render.render(write_still=True, scene=preview.name, layer=bpy.context.view_layer.name)
5300
+ if "FINISHED" not in result or not os.path.isfile(path):
5301
+ raise RuntimeError("Native Cycles preview did not produce its PNG")
5302
+ return {"path": path, "renderer": "CYCLES", "session": SESSION.session,
5303
+ "revision": SESSION.revision, "camera": camera.name,
5304
+ "width": request["width"], "height": request["height"],
5305
+ "samples": request["samples"], "seconds": time.perf_counter() - start}
5306
+ finally:
5307
+ bpy.data.scenes.remove(preview, do_unlink=True)
5308
+
5309
+
5170
5310
  def _dispatch(request):
5171
5311
  op = request.get("op")
5312
+ if op == "native-preview":
5313
+ return _native_preview(request)
5314
+ if op == "document-info":
5315
+ return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
5172
5316
  if op == "execute":
5173
5317
  engine = bpy.context.scene.render.engine
5174
5318
  if engine in UNAVAILABLE_ENGINES and "render.render" in request["code"]:
@@ -0,0 +1,27 @@
1
+ /** Disposable draw records only. The native frame's authoring objects remain
2
+ * unchanged; followers receive these records without expanding them twice. */
3
+ type Placement = {
4
+ id:string;source:string;owner:string|null;matrix:number[][];
5
+ visible:boolean;render_visible:boolean;shadow_visible?:boolean | undefined;random:number;color:number[];particle_random?:number | undefined;
6
+ };
7
+ type DrawSource = {
8
+ id:string;name:string;parent:string|null;selected:boolean;
9
+ object_info?:{random:number;particle_random?:number | undefined} | undefined;
10
+ };
11
+ export function expandDrawPlacements<T extends {objects:DrawSource[];instances?:Placement[] | undefined}>(frame: T): T {
12
+ if (!frame.instances?.length) return frame;
13
+ const sources = new Map(frame.objects.map(row => [row.id,row]));
14
+ const draws = frame.instances.map(instance => {
15
+ const source = sources.get(instance.source), owner = instance.owner === null ? null : sources.get(instance.owner);
16
+ if (!source) throw new Error(`Missing native instance source ${instance.source}`);
17
+ if (instance.owner !== null && !owner) throw new Error(`Missing native instance owner ${instance.owner}`);
18
+ return {...source,id:instance.id,name:owner?.name ?? source.name,parent:instance.owner,
19
+ instance_owner:instance.owner,matrix:instance.matrix,visible:instance.visible,
20
+ viewport_show_self:true,render_show_self:true,
21
+ render_visible:instance.render_visible,selected:owner?.selected ?? false,
22
+ shadow_visible:instance.shadow_visible ?? true,
23
+ ...(source.object_info ? {object_info:{...source.object_info,random:instance.random,color:instance.color,particle_random:instance.particle_random}} : {})};
24
+ });
25
+ const {instances: _placements,...rest} = frame;
26
+ return {...rest,objects:[...frame.objects,...draws]} as T;
27
+ }
@@ -26,7 +26,7 @@ interface Binding {
26
26
  channels: Record<string, number>;
27
27
  borrowedFrom?: Binding;
28
28
  borrowedSource?: THREE.MeshPhysicalMaterial;
29
- objectInfoUniforms?: {color: THREE.IUniform<THREE.Vector4>; indexRandom: THREE.IUniform<THREE.Vector2>};
29
+ objectInfoUniforms?: {color: THREE.IUniform<THREE.Vector4>; indexRandom: THREE.IUniform<THREE.Vector2>; particleRandom: THREE.IUniform<number>};
30
30
  palette?: {indices: Int32Array; sources: readonly THREE.MeshPhysicalMaterial[]};
31
31
  }
32
32
  const bindings = new WeakMap<THREE.MeshPhysicalMaterial, Binding>();
@@ -46,7 +46,7 @@ function compatiblePaletteGraphs(sources: readonly THREE.MeshPhysicalMaterial[])
46
46
  if (sources.length !== 2 || sources.some(source => pendings.has(source))) return null;
47
47
  const graphs = sources.map(source => bindings.get(source)?.compiled);
48
48
  const graph = graphs[0];
49
- if (!graph || graph.objectInfo || failed.has(graph.key) || graphs.some(other => !other || other.key !== graph.key ||
49
+ if (!graph || graph.objectInfo || graph.particleInfo || failed.has(graph.key) || graphs.some(other => !other || other.key !== graph.key ||
50
50
  other.declarations !== graph.declarations || other.library !== graph.library ||
51
51
  JSON.stringify([other.uvs, other.attributes, other.outputs]) !== JSON.stringify([graph.uvs, graph.attributes, graph.outputs]))) return null;
52
52
  // Leave room for the physical material's environment and lighting samplers.
@@ -186,17 +186,20 @@ export function borrowGraphDrawBinding(source: THREE.MeshPhysicalMaterial, targe
186
186
  * belong to ONE object. A distinct material identity makes Three upload them
187
187
  * when it switches between objects sharing an authored material. */
188
188
  export function bindGraphObjectInfo(material: THREE.MeshPhysicalMaterial,
189
- info: {color: readonly number[]; index: number; random: number}): void {
189
+ info: {color: readonly number[]; index: number; random: number; particle_random?: number}): void {
190
190
  const binding=bindings.get(material);
191
- if (!binding?.compiled.objectInfo) return;
191
+ if (!binding || (!binding.compiled.objectInfo && !binding.compiled.particleInfo)) return;
192
+ if (binding.compiled.particleInfo && info.particle_random === undefined)
193
+ throw new Error('Particle Info Random input missing for Blender object');
192
194
  const values=binding.objectInfoUniforms ??= {
193
- color:{value:new THREE.Vector4()}, indexRandom:{value:new THREE.Vector2()},
195
+ color:{value:new THREE.Vector4()}, indexRandom:{value:new THREE.Vector2()}, particleRandom:{value:0},
194
196
  };
195
197
  if (binding.uniforms['blenderObjectColor']!==values.color) {
196
- binding.uniforms={...binding.uniforms,blenderObjectColor:values.color,blenderObjectIndexRandom:values.indexRandom};
198
+ binding.uniforms={...binding.uniforms,blenderObjectColor:values.color,blenderObjectIndexRandom:values.indexRandom, blenderParticleRandom:values.particleRandom};
197
199
  }
198
200
  values.color.value.fromArray(info.color);
199
201
  values.indexRandom.value.set(info.index,info.random);
202
+ if (info.particle_random !== undefined) values.particleRandom.value=info.particle_random;
200
203
  }
201
204
 
202
205
  /** A binding's values: uniforms, ramp tables and textures. */
@@ -49,7 +49,7 @@ export const GRAPH_NODE_TYPES = [
49
49
  'ShaderNodeGamma', 'ShaderNodeRGBToBW', 'ShaderNodeSeparateColor', 'ShaderNodeCombineColor',
50
50
  'ShaderNodeVectorRotate', 'ShaderNodeFresnel', 'ShaderNodeLayerWeight', 'ShaderNodeBump',
51
51
  'ShaderNodeNormalMap', 'ShaderNodeNewGeometry', 'ShaderNodeVertexColor', 'ShaderNodeAttribute',
52
- 'ShaderNodeRGBCurve', 'ShaderNodeVectorCurve', 'ShaderNodeFloatCurve', 'ShaderNodeObjectInfo',
52
+ 'ShaderNodeRGBCurve', 'ShaderNodeVectorCurve', 'ShaderNodeFloatCurve', 'ShaderNodeObjectInfo', 'ShaderNodeParticleInfo',
53
53
  ] as const;
54
54
 
55
55
  const nodeSchema = z.object({
@@ -145,6 +145,7 @@ export interface CompiledGraph {
145
145
  readonly attributes: readonly string[];
146
146
  /** Requires a draw identity with this object's native inputs. */
147
147
  readonly objectInfo: boolean;
148
+ readonly particleInfo: boolean;
148
149
  /** Which surface inputs the graph drives, each a GLSL global of `type`. */
149
150
  readonly outputs: Readonly<Record<string, {readonly global: string; readonly type: GpuType}>>;
150
151
  readonly surface: MaterialGraph['surface'];
@@ -433,6 +434,8 @@ class Compiler {
433
434
  const [key, index] = source.link;
434
435
  const node = this.graph.nodes[key];
435
436
  if (!node) throw new Error(`The graph links to ${key}, which it does not carry`);
437
+ if (node.type === 'ShaderNodeParticleInfo' && node.outputs[index]?.id !== 'Random')
438
+ throw new Error(`Particle Info output ${node.outputs[index]?.id ?? index} is not compiled by the presenter`);
436
439
  const outputs = this.node(key, node);
437
440
  const output = outputs[index];
438
441
  if (!output) throw new Error(`${key} has no output ${index}`);
@@ -489,6 +492,12 @@ class Compiler {
489
492
  body.push(`${outputs[0]!.name} = ${this.uniform(type, prop<number | number[]>(node, 'value'))};`);
490
493
  break;
491
494
  }
495
+ case 'ShaderNodeParticleInfo': {
496
+ // Cycles uses the originating particle's index hash, not Object Info
497
+ // Random. The exporter refuses other outputs until their data travels.
498
+ body.push(`${outputs[1]!.name} = blenderParticleRandom;`);
499
+ break;
500
+ }
492
501
  case 'ShaderNodeObjectInfo': {
493
502
  // node_shader_gpu_object_info / node_object_info. Location is the
494
503
  // object's origin, never the current fragment's world position.
@@ -959,6 +968,8 @@ class Compiler {
959
968
  const program = [
960
969
  ...(Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeObjectInfo')
961
970
  ? ['uniform vec4 blenderObjectColor;', 'uniform vec2 blenderObjectIndexRandom;'] : []),
971
+ ...(Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeParticleInfo')
972
+ ? ['uniform float blenderParticleRandom;'] : []),
962
973
  ...[...this.s.uniformTypes].map(([n, t]) => `uniform ${t} ${n};`),
963
974
  ...this.images.map(i => i.tiled
964
975
  ? `uniform highp sampler2DArray ${i.uniform};\nuniform highp sampler2D ${i.uniform}Map;`
@@ -980,6 +991,7 @@ class Compiler {
980
991
  uvs: [...this.s.uvs],
981
992
  attributes: [...this.s.attributes],
982
993
  objectInfo: Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeObjectInfo'),
994
+ particleInfo: Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeParticleInfo'),
983
995
  outputs,
984
996
  surface: this.graph.surface,
985
997
  closure: closure !== undefined,