@volter/blender-engine 0.1.128 → 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.
- package/browser/blender-emscripten-engine.mts +42 -10
- package/browser/blender-engine.mts +3 -0
- package/browser/index.ts +1 -1
- package/browser/protocol.ts +40 -0
- package/browser/runtime.ts +17 -0
- package/browser/session.py +172 -28
- package/browser/three/blender-depsgraph-placements.ts +27 -0
- package/browser/three/blender-graph-material.ts +9 -6
- package/browser/three/blender-node-graph.ts +13 -1
- package/browser/three/blender-object-info-materials.ts +20 -5
- package/browser/three/blender-render-camera.ts +22 -0
- package/browser/three/blender-runtime-lighting.ts +55 -7
- package/browser/three/blender-runtime-view.ts +49 -8
- package/browser/three/blender-single-scattering-sky.ts +20 -0
- package/browser/three/blender-sky.ts +26 -1
- package/browser/three/world-field-sampler.ts +58 -1
- package/browser/volter_three.py +5 -1
- package/browser/worker.ts +7 -0
- package/package.json +1 -1
- package/wasm/BUNDLE.json +15 -15
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -140,17 +140,45 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD
|
|
144
|
-
* `.data` package (53 MB) and the Essentials payload are immutable for a
|
|
145
|
-
* and
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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,
|
package/browser/protocol.ts
CHANGED
|
@@ -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. */
|
package/browser/runtime.ts
CHANGED
|
@@ -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 {
|
package/browser/session.py
CHANGED
|
@@ -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
|
-
#
|
|
211
|
-
#
|
|
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, "
|
|
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
|
|
2243
|
-
|
|
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
|
-
"""
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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
|
|
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
|
|
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,
|