@volter/blender-engine 0.1.37 → 0.1.39
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-engine.mts +18 -1
- package/browser/runtime.ts +12 -1
- package/browser/session-frame.mts +3 -3
- package/browser/session.py +50 -5
- package/browser/worker.ts +96 -10
- package/package.json +1 -1
|
@@ -114,7 +114,24 @@ export interface BlenderEngineOptions {
|
|
|
114
114
|
* `saveDue` rides BESIDE the frame (the frame's own schema is the
|
|
115
115
|
* presenter's): this present left the session's `.blend` behind the model,
|
|
116
116
|
* and whoever answers owes it a save once the session goes idle. */
|
|
117
|
-
ask(payload:
|
|
117
|
+
ask(payload: SessionAsk): Promise<unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* WHAT THE SESSION ASKS. A whole frame (`frame`), presented at once; or a frame in pieces
|
|
122
|
+
* (`session.py::_pull`): the frame whose changed meshes and pictures are deferred (`hold`), then
|
|
123
|
+
* each of them (`mesh` or `image`, with its `piece`), then the frame again with `present`, which is
|
|
124
|
+
* answered as a whole frame is.
|
|
125
|
+
*/
|
|
126
|
+
export interface SessionAsk {
|
|
127
|
+
frame?: unknown;
|
|
128
|
+
hold?: unknown;
|
|
129
|
+
mesh?: string;
|
|
130
|
+
image?: string;
|
|
131
|
+
piece?: unknown;
|
|
132
|
+
present?: boolean;
|
|
133
|
+
capture?: unknown;
|
|
134
|
+
saveDue?: boolean;
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
export interface BlenderEngine {
|
package/browser/runtime.ts
CHANGED
|
@@ -133,6 +133,10 @@ export interface BlenderCallMetrics {
|
|
|
133
133
|
readonly wasmMemoryMB: number | null;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** The engine's linear-memory ceiling, its link's `-sMAXIMUM_MEMORY` (4 GiB, wasm32's limit;
|
|
137
|
+
* `blender_web_headless.cmake`). JavaScript cannot read a memory's maximum back. */
|
|
138
|
+
const ENGINE_MEMORY_CEILING_MB = 4096;
|
|
139
|
+
|
|
136
140
|
export class BlenderRuntime {
|
|
137
141
|
readonly #worker: Worker;
|
|
138
142
|
readonly #options: BlenderRuntimeOptions;
|
|
@@ -187,8 +191,15 @@ export class BlenderRuntime {
|
|
|
187
191
|
// Error ("Uncaught [object Object]"), while the reason (an allocation that failed, the file
|
|
188
192
|
// it was reading) is the last thing Blender printed.
|
|
189
193
|
const last = this.#lastLines.length > 0 ? `\nBlender's last output:\n${this.#lastLines.join('\n')}` : '';
|
|
194
|
+
// OUT OF MEMORY IS SAID AS SUCH, from the heap the worker reported as the thread died.
|
|
195
|
+
const heapMB = this.#wasmBytes === null ? null : Math.round(this.#wasmBytes / 1048576);
|
|
196
|
+
const exhausted =
|
|
197
|
+
heapMB !== null && heapMB >= ENGINE_MEMORY_CEILING_MB - 16
|
|
198
|
+
? `Blender ran out of memory: its heap reached ${heapMB} MB of the in-tab engine's ` +
|
|
199
|
+
`${ENGINE_MEMORY_CEILING_MB} MB, and this scene needs more. `
|
|
200
|
+
: '';
|
|
190
201
|
const error = new Error(
|
|
191
|
-
|
|
202
|
+
`${exhausted}Blender worker failed: ${said || 'the worker script did not load'}${where}${last}`,
|
|
192
203
|
);
|
|
193
204
|
// The page console is the editor's ledger (`installEditorConsoleReporting`
|
|
194
205
|
// captures it, source-blind), and this package may import nothing of the
|
|
@@ -35,7 +35,7 @@ export interface ColumnDescriptor {
|
|
|
35
35
|
stride: number;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const
|
|
38
|
+
export const isColumnDescriptor = (value: unknown): value is ColumnDescriptor =>
|
|
39
39
|
typeof value === 'object' &&
|
|
40
40
|
value !== null &&
|
|
41
41
|
typeof (value as ColumnDescriptor).offset === 'number' &&
|
|
@@ -97,7 +97,7 @@ function readColumn(arena: Uint8Array, name: string, descriptor: ColumnDescripto
|
|
|
97
97
|
*/
|
|
98
98
|
export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown {
|
|
99
99
|
const walk = (value: unknown, key: string): unknown => {
|
|
100
|
-
if (
|
|
100
|
+
if (isColumnDescriptor(value)) {
|
|
101
101
|
const view = readColumn(arena, key, value);
|
|
102
102
|
return key === 'co' ? Float64Array.from(view) : view;
|
|
103
103
|
}
|
|
@@ -144,7 +144,7 @@ export interface ColumnDigest {
|
|
|
144
144
|
*/
|
|
145
145
|
export async function describeFrame(arena: Uint8Array, frame: unknown): Promise<unknown> {
|
|
146
146
|
const walk = async (value: unknown, key: string): Promise<unknown> => {
|
|
147
|
-
if (
|
|
147
|
+
if (isColumnDescriptor(value)) {
|
|
148
148
|
const { offset, length, dtype } = value;
|
|
149
149
|
if (offset < 0 || length < 0 || offset + length > arena.byteLength)
|
|
150
150
|
throw new Error(
|
package/browser/session.py
CHANGED
|
@@ -1373,6 +1373,11 @@ class Session:
|
|
|
1373
1373
|
# that can see the ask can read the file.
|
|
1374
1374
|
if EXPORT_BUFFER_PATH:
|
|
1375
1375
|
options["buffer_path"] = EXPORT_BUFFER_PATH
|
|
1376
|
+
# NOTICE, THEN PULL (`_present`): a changed mesh is named here and its columns are written
|
|
1377
|
+
# one mesh at a time after, so the engine never holds every changed mesh's columns at once.
|
|
1378
|
+
# An engine without the pull door ships them all in this call, as before.
|
|
1379
|
+
if hasattr(_blender_web, "export_mesh"):
|
|
1380
|
+
options["defer"] = True
|
|
1376
1381
|
frame = json.loads(_blender_web.export_frame(json.dumps(options)))
|
|
1377
1382
|
error = frame.get("error")
|
|
1378
1383
|
if error:
|
|
@@ -1499,13 +1504,15 @@ class Session:
|
|
|
1499
1504
|
def _present(self, capture=None):
|
|
1500
1505
|
known = dict(self._known)
|
|
1501
1506
|
frame = self._export()
|
|
1507
|
+
pulled = self._pull(frame)
|
|
1502
1508
|
self._drop_unreachable_textures(frame)
|
|
1503
1509
|
# WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
|
|
1504
1510
|
# whose columns crossed, and the ids that went as a reference. The
|
|
1505
1511
|
# export's whole revision rule is visible here and nowhere else.
|
|
1506
1512
|
self.last_shipped = {
|
|
1507
1513
|
"revision": self.revision,
|
|
1508
|
-
"columns": sorted(k for k, v in frame["meshes"].items()
|
|
1514
|
+
"columns": sorted(k for k, v in frame["meshes"].items()
|
|
1515
|
+
if "columns" in v or v.get("deferred")),
|
|
1509
1516
|
"unchanged": sorted(k for k, v in frame["meshes"].items() if v.get("unchanged")),
|
|
1510
1517
|
"images": sorted(frame["images"].keys()),
|
|
1511
1518
|
# How many COLUMN BYTES this frame actually shipped: a move ships
|
|
@@ -1535,6 +1542,8 @@ class Session:
|
|
|
1535
1542
|
self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
|
|
1536
1543
|
weights["digest"])
|
|
1537
1544
|
request = {"frame": frame}
|
|
1545
|
+
if pulled:
|
|
1546
|
+
request["present"] = True
|
|
1538
1547
|
if capture:
|
|
1539
1548
|
request["capture"] = capture
|
|
1540
1549
|
# BESIDE the frame, never inside it: the frame's schema is the
|
|
@@ -1553,6 +1562,38 @@ class Session:
|
|
|
1553
1562
|
self._reconcile_with_presenter(answer, frame)
|
|
1554
1563
|
return answer
|
|
1555
1564
|
|
|
1565
|
+
def _pull(self, frame):
|
|
1566
|
+
"""THE PULL, one datablock at a time, as Hydra's render delegate pulls each prim's data
|
|
1567
|
+
during Sync after the scene index's notice (`HydraSceneIndex::populate`). The door named
|
|
1568
|
+
the changed meshes and pictures as deferred; the worker first copies the frame's own
|
|
1569
|
+
columns out of the heap (`hold`), then each mesh's columns and each picture's pixels in
|
|
1570
|
+
turn, copying each out before the next is written (every pull frees the one before). The
|
|
1571
|
+
engine's peak is the scene plus one datablock's columns, not plus every changed one's.
|
|
1572
|
+
|
|
1573
|
+
The pieces come back into the frame as the door would have written them: a picture with no
|
|
1574
|
+
readable pixels leaves `images` with its warning, as it did when the frame carried it
|
|
1575
|
+
whole, so `_drop_unreachable_textures` reads the same frame either way. Answers whether
|
|
1576
|
+
anything was pulled; the worker then presents the frame with its pieces."""
|
|
1577
|
+
meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
|
|
1578
|
+
images = [name for name, image in frame["images"].items() if image.get("deferred")]
|
|
1579
|
+
if not meshes and not images:
|
|
1580
|
+
return False
|
|
1581
|
+
ask({"hold": frame})
|
|
1582
|
+
options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
|
|
1583
|
+
for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
|
|
1584
|
+
("image", images, _blender_web.export_image)):
|
|
1585
|
+
for key in keys:
|
|
1586
|
+
options["key"] = key
|
|
1587
|
+
piece = json.loads(door(json.dumps(options)))
|
|
1588
|
+
if piece.get("error"):
|
|
1589
|
+
raise RuntimeError("Blender export door: %s" % piece["error"])
|
|
1590
|
+
frame["warnings"].extend(piece.get("warnings", []))
|
|
1591
|
+
if piece[kind] is None:
|
|
1592
|
+
del frame["images"][key]
|
|
1593
|
+
continue
|
|
1594
|
+
ask({kind: key, "piece": piece[kind]})
|
|
1595
|
+
return True
|
|
1596
|
+
|
|
1556
1597
|
def _drop_unreachable_textures(self, frame):
|
|
1557
1598
|
"""A picture the export could not read is a WARNING, not a refusal.
|
|
1558
1599
|
|
|
@@ -2311,10 +2352,14 @@ class NativeHistory:
|
|
|
2311
2352
|
finally:
|
|
2312
2353
|
self.moving = False
|
|
2313
2354
|
self.cursor += -1 if direction == "undo" else 1
|
|
2314
|
-
#
|
|
2315
|
-
#
|
|
2316
|
-
|
|
2317
|
-
|
|
2355
|
+
# AN UNDO STEP IS AN ORDINARY UPDATE when Blender kept its datablocks and depsgraph:
|
|
2356
|
+
# memfile undo re-reads only the changed IDs and tags exactly those, so the next frame
|
|
2357
|
+
# re-ships what changed and the presenter keeps the rest (`session_undo`). A step that
|
|
2358
|
+
# replaced the depsgraph starts both revision caches over, as does an engine without
|
|
2359
|
+
# the door.
|
|
2360
|
+
if not hasattr(_blender_web, "session_undo") or _blender_web.session_undo():
|
|
2361
|
+
_blender_web.session_reset()
|
|
2362
|
+
SESSION.forget()
|
|
2318
2363
|
SESSION.present()
|
|
2319
2364
|
return {"moved": True}
|
|
2320
2365
|
|
package/browser/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
40
40
|
import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
|
|
41
|
-
import { columnsToTypedArrays, describeFrame } from './session-frame.mts';
|
|
41
|
+
import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
|
|
42
42
|
|
|
43
43
|
const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(reply);
|
|
44
44
|
const log = (level: 'log' | 'warn' | 'error', text: string) => post({ op: 'log', level, text });
|
|
@@ -123,6 +123,65 @@ function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
|
123
123
|
return result;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** A frame arriving in pieces (`session.py::_pull`), held until its `present`: the frame's own
|
|
127
|
+
* columns by their arena offset, and each deferred datablock copied as it came. */
|
|
128
|
+
interface Copied {
|
|
129
|
+
typed: unknown;
|
|
130
|
+
description: unknown;
|
|
131
|
+
}
|
|
132
|
+
interface PendingFrame {
|
|
133
|
+
columns: Map<number, Copied>;
|
|
134
|
+
meshes: Map<string, Copied>;
|
|
135
|
+
images: Map<string, Copied>;
|
|
136
|
+
}
|
|
137
|
+
let pending: PendingFrame | null = null;
|
|
138
|
+
|
|
139
|
+
/** Every column the held frame names, copied now and keyed by its offset (unique in its arena). */
|
|
140
|
+
async function copyColumns(arena: Uint8Array, frame: unknown): Promise<Map<number, Copied>> {
|
|
141
|
+
const columns = new Map<number, Copied>();
|
|
142
|
+
const walk = async (value: unknown, key: string): Promise<void> => {
|
|
143
|
+
if (isColumnDescriptor(value)) {
|
|
144
|
+
columns.set(value.offset, {
|
|
145
|
+
typed: (columnsToTypedArrays(arena, { [key]: value }) as Record<string, unknown>)[key],
|
|
146
|
+
description: (await describeFrame(arena, value)) as unknown,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) for (const entry of value) await walk(entry, key);
|
|
151
|
+
else if (typeof value === 'object' && value !== null)
|
|
152
|
+
for (const [name, held] of Object.entries(value)) await walk(held, name);
|
|
153
|
+
};
|
|
154
|
+
await walk(frame, '');
|
|
155
|
+
return columns;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The presented frame with its pieces put back: each column from the held copies, each deferred
|
|
159
|
+
* mesh and picture from its pulled piece. Both the typed frame and its record. */
|
|
160
|
+
function assemblePending(held: PendingFrame, frame: unknown): { typed: unknown; description: unknown } {
|
|
161
|
+
const build = (side: keyof Copied) => {
|
|
162
|
+
const walk = (value: unknown, path: readonly string[]): unknown => {
|
|
163
|
+
if (isColumnDescriptor(value)) {
|
|
164
|
+
const copied = held.columns.get(value.offset);
|
|
165
|
+
if (!copied) throw new Error(`Blender frame column ${path.join('.')}: not in the held frame`);
|
|
166
|
+
return copied[side];
|
|
167
|
+
}
|
|
168
|
+
if (Array.isArray(value)) return value.map((entry) => walk(entry, path));
|
|
169
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
170
|
+
if ((value as { deferred?: unknown }).deferred === true) {
|
|
171
|
+
const [group, name] = path.slice(-2);
|
|
172
|
+
const piece = (group === 'meshes' ? held.meshes : held.images).get(name as string);
|
|
173
|
+
if (!piece) throw new Error(`Blender frame ${group} ${name}: deferred and never pulled`);
|
|
174
|
+
return piece[side];
|
|
175
|
+
}
|
|
176
|
+
const out: Record<string, unknown> = {};
|
|
177
|
+
for (const [name, entry] of Object.entries(value)) out[name] = walk(entry, [...path, name]);
|
|
178
|
+
return out;
|
|
179
|
+
};
|
|
180
|
+
return walk(frame, []);
|
|
181
|
+
};
|
|
182
|
+
return { typed: build('typed'), description: build('description') };
|
|
183
|
+
}
|
|
184
|
+
|
|
126
185
|
function setDocumentDirty(dirty: boolean): void {
|
|
127
186
|
documentDirty = dirty;
|
|
128
187
|
post({ op: 'document-dirty', dirty });
|
|
@@ -182,22 +241,44 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
182
241
|
const started = await startBlenderEngine({
|
|
183
242
|
project,
|
|
184
243
|
log,
|
|
185
|
-
ask: async ({ frame, capture, saveDue }) => {
|
|
244
|
+
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue }) => {
|
|
186
245
|
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
187
246
|
// Save once after the whole command, never during a partial frame.
|
|
188
247
|
if (saveDue && documentPath !== null) setDocumentDirty(true);
|
|
189
|
-
// THE ARENA IS READ ONCE
|
|
248
|
+
// THE ARENA IS READ ONCE PER ASK, and both readers share those bytes: the
|
|
190
249
|
// typed arrays the tab draws from, and the record of what was sent
|
|
191
250
|
// (`describeFrame`). After the post the buffers are detached and the
|
|
192
|
-
// next
|
|
251
|
+
// next export call overwrites the arena -- on either skew -- so there
|
|
193
252
|
// is no later moment at which either could be taken.
|
|
194
253
|
const arena = await holder.engine.readArena();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
254
|
+
// A FRAME IN PIECES (`session.py::_pull`): its own columns and each deferred datablock are
|
|
255
|
+
// copied out of the heap as they come, because the next export call frees them; the tab
|
|
256
|
+
// gets the whole frame at `present`.
|
|
257
|
+
if (hold !== undefined) {
|
|
258
|
+
pending = { columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
if (mesh !== undefined || image !== undefined) {
|
|
262
|
+
if (!pending) throw new Error(`The Blender session sent ${mesh ?? image} before its frame`);
|
|
263
|
+
const copied = {
|
|
264
|
+
typed: columnsToTypedArrays(arena, piece),
|
|
265
|
+
description: await describeFrame(arena, piece),
|
|
266
|
+
};
|
|
267
|
+
if (mesh !== undefined) pending.meshes.set(mesh, copied);
|
|
268
|
+
else pending.images.set(image as string, copied);
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
271
|
+
let typed: unknown;
|
|
272
|
+
let description: unknown;
|
|
273
|
+
if (present) {
|
|
274
|
+
if (!pending) throw new Error('The Blender session presented a frame it never sent');
|
|
275
|
+
({ typed, description } = assemblePending(pending, frame));
|
|
276
|
+
pending = null;
|
|
277
|
+
} else {
|
|
278
|
+
typed = columnsToTypedArrays(arena, frame);
|
|
279
|
+
description = await describeFrame(arena, frame);
|
|
280
|
+
}
|
|
281
|
+
const answered = await presentToTab(typed, description, capture as CaptureRequest | undefined);
|
|
201
282
|
// THE CAPTURE IS THE ANSWER'S BODY, and `held` rides beside it: the
|
|
202
283
|
// session reads a photograph's own fields off this object
|
|
203
284
|
// (`session.py::_photograph`), and reads `held` to judge whether its
|
|
@@ -623,6 +704,11 @@ function reportMemory(): void {
|
|
|
623
704
|
if (bytes !== null) post({ op: 'memory', bytes });
|
|
624
705
|
}
|
|
625
706
|
|
|
707
|
+
// A BLENDER THREAD THAT DIES IS WEIGHED FIRST: its error reaches this scope before the page's
|
|
708
|
+
// `onerror`, so the heap it died at is posted ahead of the failure, and the page can say whether
|
|
709
|
+
// the engine ran out of memory (`runtime.ts`) instead of "Uncaught [object Object]".
|
|
710
|
+
self.addEventListener('error', reportMemory);
|
|
711
|
+
|
|
626
712
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
627
713
|
const request = event.data;
|
|
628
714
|
if (request.op === 'present-result') {
|