@volter/blender-engine 0.1.129 → 0.1.131
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 +45 -0
- package/browser/runtime.ts +33 -1
- package/browser/session.py +237 -32
- 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-physical-material.ts +14 -1
- package/browser/three/blender-render-camera.ts +22 -0
- package/browser/three/blender-runtime-lighting.ts +56 -7
- package/browser/three/blender-runtime-shadows.ts +5 -0
- package/browser/three/blender-runtime-view.ts +64 -9
- package/browser/three/blender-single-scattering-sky.ts +20 -0
- package/browser/three/blender-sky.ts +26 -1
- package/browser/three/blender-world-visibility.ts +264 -0
- package/browser/three/world-field-sampler.ts +58 -1
- package/browser/volter_three.py +5 -1
- package/browser/worker.ts +47 -2
- 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 }
|
|
@@ -101,6 +105,11 @@ export type WorkerRequest =
|
|
|
101
105
|
export type WorkerReply =
|
|
102
106
|
| { op: 'frame-stream'; id: number; chunk: FrameChunk }
|
|
103
107
|
| { op: 'document-dirty'; dirty: boolean }
|
|
108
|
+
/** THE DOCUMENT FOLLOWED BLENDER: a script's `save_as_mainfile` (or `open_mainfile`) left
|
|
109
|
+
* another project `.blend` open, and the session's saves now go there
|
|
110
|
+
* (`session.py::follow_open_file`). Both paths are project-relative. Posted before the
|
|
111
|
+
* call's own answer, so the tab's handle already names the new file when the call returns. */
|
|
112
|
+
| { op: 'document-moved'; from: string; document: string }
|
|
104
113
|
| { op: 'history'; entries: NativeHistoryEntry[] }
|
|
105
114
|
| { id: number; result: unknown }
|
|
106
115
|
| { id: number; error: string }
|
|
@@ -139,6 +148,36 @@ export type NativeHistoryEntry = { reset: true } | {
|
|
|
139
148
|
resource: string | null;
|
|
140
149
|
};
|
|
141
150
|
|
|
151
|
+
export interface NativePreviewRequest {
|
|
152
|
+
width: number;
|
|
153
|
+
height: number;
|
|
154
|
+
samples: number;
|
|
155
|
+
camera?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface NativePreviewResult {
|
|
159
|
+
path: string;
|
|
160
|
+
renderer: 'CYCLES';
|
|
161
|
+
session: string;
|
|
162
|
+
revision: number;
|
|
163
|
+
camera: string;
|
|
164
|
+
width: number;
|
|
165
|
+
height: number;
|
|
166
|
+
samples: number;
|
|
167
|
+
seconds: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface NativeDocumentInfo {
|
|
171
|
+
filepath: string;
|
|
172
|
+
session: string;
|
|
173
|
+
revision: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface FileListSnapshot {
|
|
177
|
+
entries: FileEntry[];
|
|
178
|
+
document: NativeDocumentInfo;
|
|
179
|
+
}
|
|
180
|
+
|
|
142
181
|
export interface CaptureRequest {
|
|
143
182
|
/** A viewport screenshot's bound, the longer side of a square frame.
|
|
144
183
|
* Absent on a render, which states its exact pixel dimensions instead. */
|
|
@@ -173,6 +212,12 @@ export interface RenderRequest {
|
|
|
173
212
|
height: number;
|
|
174
213
|
/** The camera's VERTICAL field of view, in degrees. */
|
|
175
214
|
fov: number;
|
|
215
|
+
/** Authored camera clipping and sensor shift; not viewport navigation. */
|
|
216
|
+
clipStart?: number;
|
|
217
|
+
clipEnd?: number;
|
|
218
|
+
shiftX?: number;
|
|
219
|
+
shiftY?: number;
|
|
220
|
+
horizontalFit?: boolean;
|
|
176
221
|
/** The scene's own view transform, as the three.js tone mapping of the same
|
|
177
222
|
* NAME (`bpy/_render_three.py`). A transform with no curve here never
|
|
178
223
|
* 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,
|
|
@@ -49,6 +52,11 @@ export interface BlenderRuntimeOptions {
|
|
|
49
52
|
capture?: CaptureRequest,
|
|
50
53
|
): Promise<PresentAnswer> | PresentAnswer;
|
|
51
54
|
log?(level: 'log' | 'warn' | 'error', text: string): void;
|
|
55
|
+
/** The session's document followed Blender to another project `.blend` (a script's Save As,
|
|
56
|
+
* `protocol.ts`'s `document-moved`). Called after {@link BlenderRuntime.document} already
|
|
57
|
+
* names the new file and before the call that moved it answers. An observer: a throw here
|
|
58
|
+
* changes nothing. */
|
|
59
|
+
documentMoved?(moved: { readonly from: string; readonly document: string }): void;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -234,7 +242,9 @@ export class BlenderRuntime {
|
|
|
234
242
|
return this.#project;
|
|
235
243
|
}
|
|
236
244
|
|
|
237
|
-
/** The
|
|
245
|
+
/** The document owned by this worker, including while boot is pending: the startup one, until
|
|
246
|
+
* a script's Save As leaves Blender holding another project `.blend` and the session follows
|
|
247
|
+
* it there (`session.py::follow_open_file`). */
|
|
238
248
|
get document(): string | null {
|
|
239
249
|
return this.#document;
|
|
240
250
|
}
|
|
@@ -285,6 +295,13 @@ export class BlenderRuntime {
|
|
|
285
295
|
return (await this.#request({ op: 'execute', code, history, label })) as string;
|
|
286
296
|
}
|
|
287
297
|
|
|
298
|
+
/** Render in native Cycles without changing or re-exporting the document.
|
|
299
|
+
* The returned PNG is read through readFile; no pixels cross the JSON relay. */
|
|
300
|
+
async nativePreview(request: NativePreviewRequest): Promise<NativePreviewResult> {
|
|
301
|
+
await this.#ready();
|
|
302
|
+
return await this.#request({ op: 'native-preview', ...request }) as NativePreviewResult;
|
|
303
|
+
}
|
|
304
|
+
|
|
288
305
|
async historyGesture(op: 'history-begin' | 'history-end'): Promise<void> {
|
|
289
306
|
await this.#ready();
|
|
290
307
|
await this.#request({ op });
|
|
@@ -466,6 +483,13 @@ export class BlenderRuntime {
|
|
|
466
483
|
return (await this.#request({ op: 'list-files', path })) as FileEntry[];
|
|
467
484
|
}
|
|
468
485
|
|
|
486
|
+
/** Files and their native document identity, without executing Python or
|
|
487
|
+
* exporting/saving the model. The worker serializes this with authoring. */
|
|
488
|
+
async listFilesSnapshot(path: string): Promise<FileListSnapshot> {
|
|
489
|
+
await this.#ready();
|
|
490
|
+
return await this.#request({ op: 'list-files-snapshot', path }) as FileListSnapshot;
|
|
491
|
+
}
|
|
492
|
+
|
|
469
493
|
/** The session and revision of the last frame this runtime forwarded; null
|
|
470
494
|
* before the first present. See {@link PresentedState}. */
|
|
471
495
|
get presented(): PresentedState | null {
|
|
@@ -657,6 +681,14 @@ export class BlenderRuntime {
|
|
|
657
681
|
this.#dirty = reply.dirty;
|
|
658
682
|
return;
|
|
659
683
|
}
|
|
684
|
+
if (reply.op === 'document-moved') {
|
|
685
|
+
// The worker already saves to the new file; this handle must claim it too, or the
|
|
686
|
+
// editor refuses the new file's own Model tab as a resource conflict.
|
|
687
|
+
this.#document = reply.document;
|
|
688
|
+
try { this.#options.documentMoved?.({ from: reply.from, document: reply.document }); }
|
|
689
|
+
catch { /* an observer cannot undo the move Blender made */ }
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
660
692
|
if (reply.op === 'history') {
|
|
661
693
|
this.#options.history?.(reply.entries);
|
|
662
694
|
return;
|
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()
|
|
@@ -1380,6 +1464,9 @@ class Session:
|
|
|
1380
1464
|
# Set by a present that left the document behind the model; cleared by
|
|
1381
1465
|
# the save. The tab reads it as `saveDue` beside the frame.
|
|
1382
1466
|
self.save_due = False
|
|
1467
|
+
# The OUTSIDE-THE-PROJECT file Blender was last found holding, once said
|
|
1468
|
+
# (`follow_open_file`): the refusal is told once per file, not per call.
|
|
1469
|
+
self._outside_file = None
|
|
1383
1470
|
# WHAT THE PRESENTER LAST REPORTED HOLDING, as an instrument: the
|
|
1384
1471
|
# `(session, revision)` it held BEFORE the last frame, None when it
|
|
1385
1472
|
# held nothing, and the string "unreported" for a presenter that does
|
|
@@ -1417,7 +1504,7 @@ class Session:
|
|
|
1417
1504
|
# (`overlay_empty.hh` `image_sync`).
|
|
1418
1505
|
empty_images = {obj.data.name for obj in scene.objects
|
|
1419
1506
|
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,
|
|
1507
|
+
options = {"session": self.session, "evaluate": True, "known": self._known, "instance_sources": True,
|
|
1421
1508
|
"graph_materials": sorted(graphs),
|
|
1422
1509
|
"graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
|
|
1423
1510
|
"graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
|
|
@@ -1490,6 +1577,7 @@ class Session:
|
|
|
1490
1577
|
"color": [float(v) for v in obj.color],
|
|
1491
1578
|
"index": float(obj.pass_index),
|
|
1492
1579
|
"random": _object_info_random(obj.name),
|
|
1580
|
+
"particle_random": _particle_info_random(),
|
|
1493
1581
|
}
|
|
1494
1582
|
data = obj.evaluated_get(depsgraph).data if obj is not None else None
|
|
1495
1583
|
if getattr(data, "use_auto_texspace", True) is False:
|
|
@@ -1510,6 +1598,7 @@ class Session:
|
|
|
1510
1598
|
"roughness": float(material.roughness),
|
|
1511
1599
|
"metallic": float(material.metallic),
|
|
1512
1600
|
}
|
|
1601
|
+
frame["instances"] = _depsgraph_placements(frame, bpy.context.evaluated_depsgraph_get())
|
|
1513
1602
|
frame["world"] = draw_world(scene)
|
|
1514
1603
|
frame["cameras"] = {
|
|
1515
1604
|
obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
|
|
@@ -1878,10 +1967,11 @@ class Session:
|
|
|
1878
1967
|
def bind_document(self, relative_path):
|
|
1879
1968
|
"""Name the session's document and OPEN it when the project has one.
|
|
1880
1969
|
|
|
1881
|
-
One session holds one document. The path is stated at start and
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1970
|
+
One session holds one document. The path is stated at start and moves
|
|
1971
|
+
only where Blender itself moved: a script's own `save_as_mainfile` (or
|
|
1972
|
+
`open_mainfile`) to another `.blend` in the project retargets
|
|
1973
|
+
`bpy.data.filepath`, and `follow_open_file` moves the document with it
|
|
1974
|
+
after the call.
|
|
1885
1975
|
|
|
1886
1976
|
`open_mainfile` runs the session's `_load_post` like any other load
|
|
1887
1977
|
(the export door's revision table is reset, the render engine ids are
|
|
@@ -1925,6 +2015,58 @@ class Session:
|
|
|
1925
2015
|
if self.document is not None:
|
|
1926
2016
|
self.save_due = True
|
|
1927
2017
|
|
|
2018
|
+
def follow_open_file(self):
|
|
2019
|
+
"""FOLLOW BLENDER'S SAVE AS: the document is the file Blender has open.
|
|
2020
|
+
|
|
2021
|
+
Read after every `execute`, because a script is the one request that can
|
|
2022
|
+
change which file Blender holds. `save_as_mainfile(filepath=X)` writes X
|
|
2023
|
+
and leaves X OPEN -- `bpy.data.filepath` becomes X, and Blender's next
|
|
2024
|
+
Save goes there. The session used to keep saving to the file it was
|
|
2025
|
+
started on, so the model a script had just saved as X was written over
|
|
2026
|
+
the OLD file on the next idle save, and the editor refused X's own tab
|
|
2027
|
+
because the worker still claimed the old one. SEEN 2026-10-07: an
|
|
2028
|
+
agent built an obby through the Blender MCP, saved it as
|
|
2029
|
+
`src/models/obby.blend`, and its level landed in `src/models/cube.blend`
|
|
2030
|
+
instead -- so Play looked for `cube.play.ts`.
|
|
2031
|
+
|
|
2032
|
+
What does NOT move, by Blender's own rules rather than ours:
|
|
2033
|
+
`save_mainfile()` and a Save As to the same path leave the path where it
|
|
2034
|
+
is, and `save_as_mainfile(copy=True)` writes a copy and keeps the
|
|
2035
|
+
original open (which is also how `save_document` writes, so the
|
|
2036
|
+
session's own save never moves anything). `open_mainfile` does move it:
|
|
2037
|
+
Blender then holds that file, and saving the old path would write one
|
|
2038
|
+
file's model over another's.
|
|
2039
|
+
|
|
2040
|
+
Answers None when nothing moved, `{"moved": True, "from", "document"}`
|
|
2041
|
+
when the document followed Blender to another project `.blend`, and
|
|
2042
|
+
`{"moved": False, "from", "outside"}` -- once per file -- when Blender
|
|
2043
|
+
holds a file outside the project: the editor has no document there, so
|
|
2044
|
+
the session keeps its own and keeps saving to it.
|
|
2045
|
+
"""
|
|
2046
|
+
if self.document is None or not bpy.data.filepath:
|
|
2047
|
+
return None
|
|
2048
|
+
current = os.path.normpath(os.path.abspath(bpy.data.filepath))
|
|
2049
|
+
if current == os.path.normpath(self.document):
|
|
2050
|
+
self._outside_file = None
|
|
2051
|
+
return None
|
|
2052
|
+
root = os.path.normpath(self.project)
|
|
2053
|
+
relative = current[len(root) + 1:] if current.startswith(root + "/") else None
|
|
2054
|
+
# The same shape the worker and the server check (`worker.ts::isDocumentPath`).
|
|
2055
|
+
if (relative is not None and relative.endswith(".blend") and "\\" not in relative
|
|
2056
|
+
and all(part not in ("", ".", "..") for part in relative.split("/"))):
|
|
2057
|
+
previous = self.document_relative
|
|
2058
|
+
self.document = current
|
|
2059
|
+
self.document_relative = relative
|
|
2060
|
+
self._outside_file = None
|
|
2061
|
+
# The file Blender wrote sits only in the engine's filesystem until the
|
|
2062
|
+
# session's own save carries it to the project.
|
|
2063
|
+
self.save_due = True
|
|
2064
|
+
return {"moved": True, "from": previous, "document": relative}
|
|
2065
|
+
if current == self._outside_file:
|
|
2066
|
+
return None
|
|
2067
|
+
self._outside_file = current
|
|
2068
|
+
return {"moved": False, "from": self.document_relative, "outside": current}
|
|
2069
|
+
|
|
1928
2070
|
def save_document(self):
|
|
1929
2071
|
"""Write the document -- Blender's own format, by Blender's own operator.
|
|
1930
2072
|
|
|
@@ -2175,6 +2317,12 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2175
2317
|
"width": int(width),
|
|
2176
2318
|
"height": int(height),
|
|
2177
2319
|
"fov": _vertical_extent(camera.data, int(width), int(height)),
|
|
2320
|
+
"clipStart": float(camera.data.clip_start),
|
|
2321
|
+
"clipEnd": float(camera.data.clip_end),
|
|
2322
|
+
"shiftX": float(camera.data.shift_x),
|
|
2323
|
+
"shiftY": float(camera.data.shift_y),
|
|
2324
|
+
"horizontalFit": camera.data.sensor_fit == "HORIZONTAL" or
|
|
2325
|
+
(camera.data.sensor_fit == "AUTO" and width >= height),
|
|
2178
2326
|
"toneMapping": transform,
|
|
2179
2327
|
"exposure": float(2.0 ** view.exposure),
|
|
2180
2328
|
"gamma": float(view.gamma),
|
|
@@ -2239,10 +2387,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
|
|
|
2239
2387
|
"""The scene's renderer, so `write_still`, `save_render` and Render Result
|
|
2240
2388
|
behave as Blender's own.
|
|
2241
2389
|
|
|
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.
|
|
2390
|
+
Registered as VOLTER_THREE and for headless GPU-engine substitutes.
|
|
2391
|
+
CYCLES remains Blender's compiled CPU path tracer.
|
|
2246
2392
|
"""
|
|
2247
2393
|
|
|
2248
2394
|
bl_idname = "VOLTER_THREE"
|
|
@@ -2372,22 +2518,21 @@ def _release_from_owning_addon(existing):
|
|
|
2372
2518
|
|
|
2373
2519
|
|
|
2374
2520
|
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).
|
|
2521
|
+
"""Keep native Cycles and install the headless GPU presenters.
|
|
2522
|
+
|
|
2523
|
+
Native Cycles needs no browser GPU. Replacing its class with a Combined-only
|
|
2524
|
+
Three photograph drops evaluated instances, GI, shader closures and other
|
|
2525
|
+
compositor pass inputs. VOLTER_THREE is the explicit raster photograph;
|
|
2526
|
+
the live viewport and Play use Three independently of this engine choice.
|
|
2387
2527
|
"""
|
|
2388
2528
|
made, unavailable = [], []
|
|
2529
|
+
cycles = _engine_class("CYCLES")
|
|
2530
|
+
if cycles is not None and not issubclass(cycles, VolterRenderEngine):
|
|
2531
|
+
made.append("CYCLES")
|
|
2532
|
+
else:
|
|
2533
|
+
# Do not report a raster substitute as an available native path tracer.
|
|
2534
|
+
unavailable.append("CYCLES")
|
|
2389
2535
|
for identifier, label in (
|
|
2390
|
-
("CYCLES", "Cycles"),
|
|
2391
2536
|
("BLENDER_EEVEE", "EEVEE"),
|
|
2392
2537
|
("BLENDER_WORKBENCH", "Workbench"),
|
|
2393
2538
|
):
|
|
@@ -2414,7 +2559,7 @@ def _register_engine():
|
|
|
2414
2559
|
# Upstream Blender refuses to let a Python engine take a BUILT-IN
|
|
2415
2560
|
# id ("is built-in"); the shipped bundle carries a patch that sets
|
|
2416
2561
|
# the built-in type aside instead, since headless has no GPU for it
|
|
2417
|
-
# to render with, so
|
|
2562
|
+
# to render with, so the two GPU ids are taken there (measured
|
|
2418
2563
|
# 2026-09-17). A bundle without that patch keeps `BLENDER_EEVEE`
|
|
2419
2564
|
# and `BLENDER_WORKBENCH`: that is a fact about the build, stated
|
|
2420
2565
|
# in the start reply and refused by name at the render, never a
|
|
@@ -5167,8 +5312,63 @@ def _dispatch_request(request):
|
|
|
5167
5312
|
}[op])
|
|
5168
5313
|
|
|
5169
5314
|
|
|
5315
|
+
def _native_preview(request):
|
|
5316
|
+
"""Native Cycles in an owned scene copy, never in the authoring scene.
|
|
5317
|
+
|
|
5318
|
+
Preview settings, Render Result and temporary files are derived render
|
|
5319
|
+
resources. No history checkpoint, save, frame export or revision bump is
|
|
5320
|
+
owed by this operation. Remove the owned scene before another operation
|
|
5321
|
+
can serialize a project or enumerate its scene datablocks.
|
|
5322
|
+
"""
|
|
5323
|
+
for key, maximum in (("width", 1024), ("height", 1024), ("samples", 512)):
|
|
5324
|
+
value = request.get(key)
|
|
5325
|
+
if type(value) is not int or not 1 <= value <= maximum:
|
|
5326
|
+
raise ValueError("Native preview %s must be an integer from 1 to %d" % (key, maximum))
|
|
5327
|
+
engine = _engine_class("CYCLES")
|
|
5328
|
+
if engine is None or issubclass(engine, VolterRenderEngine):
|
|
5329
|
+
raise RuntimeError("Native Cycles is unavailable; a Three photograph cannot answer this request")
|
|
5330
|
+
original = bpy.context.scene
|
|
5331
|
+
camera = original.objects.get(request["camera"]) if request.get("camera") else original.camera
|
|
5332
|
+
if camera is None or camera.type != "CAMERA":
|
|
5333
|
+
raise ValueError("Native preview needs a camera belonging to this scene")
|
|
5334
|
+
preview = original.copy()
|
|
5335
|
+
path = os.path.join(ROOT, "native-preview.png")
|
|
5336
|
+
try:
|
|
5337
|
+
preview.camera = camera
|
|
5338
|
+
preview.render.engine = "CYCLES"
|
|
5339
|
+
preview.render.resolution_x = request["width"]
|
|
5340
|
+
preview.render.resolution_y = request["height"]
|
|
5341
|
+
preview.render.resolution_percentage = 100
|
|
5342
|
+
# Rendered viewport shading precedes the compositor and uses its own
|
|
5343
|
+
# sampling budget; the authoring scene's settings remain untouched.
|
|
5344
|
+
preview.render.use_compositing = False
|
|
5345
|
+
preview.render.use_persistent_data = False
|
|
5346
|
+
preview.cycles.samples = request["samples"]
|
|
5347
|
+
preview.cycles.use_denoising = False
|
|
5348
|
+
preview.render.image_settings.file_format = "PNG"
|
|
5349
|
+
preview.render.image_settings.color_mode = "RGBA"
|
|
5350
|
+
preview.render.image_settings.color_depth = "8"
|
|
5351
|
+
preview.render.filepath = path
|
|
5352
|
+
if os.path.exists(path):
|
|
5353
|
+
os.unlink(path)
|
|
5354
|
+
start = time.perf_counter()
|
|
5355
|
+
result = bpy.ops.render.render(write_still=True, scene=preview.name, layer=bpy.context.view_layer.name)
|
|
5356
|
+
if "FINISHED" not in result or not os.path.isfile(path):
|
|
5357
|
+
raise RuntimeError("Native Cycles preview did not produce its PNG")
|
|
5358
|
+
return {"path": path, "renderer": "CYCLES", "session": SESSION.session,
|
|
5359
|
+
"revision": SESSION.revision, "camera": camera.name,
|
|
5360
|
+
"width": request["width"], "height": request["height"],
|
|
5361
|
+
"samples": request["samples"], "seconds": time.perf_counter() - start}
|
|
5362
|
+
finally:
|
|
5363
|
+
bpy.data.scenes.remove(preview, do_unlink=True)
|
|
5364
|
+
|
|
5365
|
+
|
|
5170
5366
|
def _dispatch(request):
|
|
5171
5367
|
op = request.get("op")
|
|
5368
|
+
if op == "native-preview":
|
|
5369
|
+
return _native_preview(request)
|
|
5370
|
+
if op == "document-info":
|
|
5371
|
+
return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
|
|
5172
5372
|
if op == "execute":
|
|
5173
5373
|
engine = bpy.context.scene.render.engine
|
|
5174
5374
|
if engine in UNAVAILABLE_ENGINES and "render.render" in request["code"]:
|
|
@@ -5185,6 +5385,11 @@ def _dispatch(request):
|
|
|
5185
5385
|
HISTORY.changed()
|
|
5186
5386
|
SESSION.mark_changed()
|
|
5187
5387
|
answer = execute(request["code"])
|
|
5388
|
+
# Read before the present, so the save that present asks for goes to the
|
|
5389
|
+
# file Blender now holds (`Session.follow_open_file`).
|
|
5390
|
+
followed = SESSION.follow_open_file()
|
|
5391
|
+
if followed is not None:
|
|
5392
|
+
answer["document"] = followed
|
|
5188
5393
|
# Every mutation is presented, the rule: the Model
|
|
5189
5394
|
# document is what the agent is looking at.
|
|
5190
5395
|
try:
|
|
@@ -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
|
+
}
|