@volter/blender-engine 0.1.56 → 0.1.58
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 +9 -5
- package/browser/protocol.ts +1 -0
- package/browser/pull-job.mts +58 -0
- package/browser/runtime.ts +10 -1
- package/browser/session.py +15 -2
- package/browser/worker.ts +24 -5
- package/package.json +1 -1
- package/wasm/BUNDLE.json +8 -9
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -127,6 +127,7 @@ export interface BlenderEngineOptions {
|
|
|
127
127
|
* answered as a whole frame is.
|
|
128
128
|
*/
|
|
129
129
|
export interface SessionAsk {
|
|
130
|
+
checkpoint?: string;
|
|
130
131
|
frame?: unknown;
|
|
131
132
|
hold?: unknown;
|
|
132
133
|
mesh?: string;
|
|
@@ -382,15 +383,16 @@ export function openSessionChannel(
|
|
|
382
383
|
/** Answer any `ask` the session has raised, and retire the replies it has
|
|
383
384
|
* taken. Called from the poll loop, so a present raised inside a call is
|
|
384
385
|
* served while that call is outstanding. */
|
|
385
|
-
async function serveAsks(): Promise<
|
|
386
|
+
async function serveAsks(): Promise<boolean> {
|
|
386
387
|
let names: string[];
|
|
387
388
|
try {
|
|
388
389
|
names = await files.readdir(`${SESSION_ROOT}/ask`);
|
|
389
390
|
} catch {
|
|
390
|
-
return;
|
|
391
|
+
return false;
|
|
391
392
|
}
|
|
392
393
|
const raised = new Set(names.filter((name) => name.endsWith('.done')));
|
|
393
394
|
await retireReplies(raised);
|
|
395
|
+
let progressed = false;
|
|
394
396
|
for (const name of raised) {
|
|
395
397
|
const id = name.slice(0, -'.done'.length);
|
|
396
398
|
if (Number(id) <= asking) continue;
|
|
@@ -410,7 +412,9 @@ export function openSessionChannel(
|
|
|
410
412
|
files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1'),
|
|
411
413
|
]);
|
|
412
414
|
replied.add(id);
|
|
415
|
+
progressed = true;
|
|
413
416
|
}
|
|
417
|
+
return progressed;
|
|
414
418
|
}
|
|
415
419
|
|
|
416
420
|
async function request(payload: Record<string, unknown>): Promise<unknown> {
|
|
@@ -426,9 +430,9 @@ export function openSessionChannel(
|
|
|
426
430
|
files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload)),
|
|
427
431
|
files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1'),
|
|
428
432
|
]);
|
|
429
|
-
|
|
433
|
+
let lastProgress = performance.now();
|
|
430
434
|
for (;;) {
|
|
431
|
-
await serveAsks();
|
|
435
|
+
if (await serveAsks()) lastProgress = performance.now();
|
|
432
436
|
if (await files.stat(`${SESSION_ROOT}/out/${id}.done`)) {
|
|
433
437
|
const body = JSON.parse(
|
|
434
438
|
decoder.decode(await files.readFile(`${SESSION_ROOT}/out/${id}.json`)),
|
|
@@ -458,7 +462,7 @@ export function openSessionChannel(
|
|
|
458
462
|
'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
|
|
459
463
|
'(`VOLTER_BLENDER_FRESH_SESSION=1` for the battery harness).',
|
|
460
464
|
);
|
|
461
|
-
const delay = pollDelay(performance.now() -
|
|
465
|
+
const delay = pollDelay(performance.now() - lastProgress);
|
|
462
466
|
await (files.changed ? files.changed(delay) : sleep(delay));
|
|
463
467
|
}
|
|
464
468
|
}
|
package/browser/protocol.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { FrameChunk } from './frame-stream.mts';
|
|
|
3
3
|
* worker; the model lives in the worker's Python and nowhere else. */
|
|
4
4
|
|
|
5
5
|
export type WorkerRequest =
|
|
6
|
+
| { id: number; op: 'load-next'; token: string }
|
|
6
7
|
| { id: number; op: 'flush-document' }
|
|
7
8
|
| { id: number; op: 'history-begin' | 'history-end' }
|
|
8
9
|
| { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** A cooperative job advances only when its owner asks for its next unit.
|
|
2
|
+
* Checkpoints park the producer; they do not renew a timer while work runs. */
|
|
3
|
+
export type PullResult<T> = { load: 'continue'; token: string; phase: string } | { load: 'done'; value: T };
|
|
4
|
+
export class PullJob<T> {
|
|
5
|
+
private started = false;
|
|
6
|
+
private finished = false;
|
|
7
|
+
private sequence = 0;
|
|
8
|
+
private expected: string | null = null;
|
|
9
|
+
private resume: (() => void) | null = null;
|
|
10
|
+
private waiter: { resolve: (value: PullResult<T>) => void; reject: (error: unknown) => void } | null = null;
|
|
11
|
+
constructor(private readonly work: (checkpoint: (phase: string) => Promise<void>) => Promise<T>) {}
|
|
12
|
+
step(token?: string): Promise<PullResult<T>> {
|
|
13
|
+
if (this.finished || this.waiter || (this.started ? token !== this.expected || !this.resume : token !== undefined))
|
|
14
|
+
return Promise.reject(new Error('Invalid or replayed load continuation'));
|
|
15
|
+
const answer = new Promise<PullResult<T>>((resolve, reject) => { this.waiter = { resolve, reject }; });
|
|
16
|
+
if (!this.started) {
|
|
17
|
+
this.started = true;
|
|
18
|
+
void Promise.resolve().then(() => this.work(phase => new Promise<void>(resolve => {
|
|
19
|
+
this.resume = resolve;
|
|
20
|
+
this.expected = `load:${++this.sequence}`;
|
|
21
|
+
const waiter = this.waiter!; this.waiter = null;
|
|
22
|
+
waiter.resolve({ load: 'continue', token: this.expected, phase });
|
|
23
|
+
}))).then(value => {
|
|
24
|
+
this.finished = true;
|
|
25
|
+
const waiter = this.waiter!; this.waiter = null;
|
|
26
|
+
waiter.resolve({ load: 'done', value });
|
|
27
|
+
}, error => {
|
|
28
|
+
this.finished = true;
|
|
29
|
+
const waiter = this.waiter!; this.waiter = null;
|
|
30
|
+
waiter.reject(error);
|
|
31
|
+
});
|
|
32
|
+
} else {
|
|
33
|
+
const resume = this.resume!; this.resume = null; this.expected = null; resume();
|
|
34
|
+
}
|
|
35
|
+
return answer;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A streamed import consumes at most one MiB before yielding its producer. */
|
|
40
|
+
export function checkpointStream(body: ReadableStream<Uint8Array>, checkpoint: () => Promise<void>): ReadableStream<Uint8Array> {
|
|
41
|
+
const reader = body.getReader();
|
|
42
|
+
let held: Uint8Array | undefined, offset = 0;
|
|
43
|
+
return new ReadableStream<Uint8Array>({
|
|
44
|
+
async pull(controller) {
|
|
45
|
+
try {
|
|
46
|
+
if (!held || offset === held.length) {
|
|
47
|
+
const next = await reader.read();
|
|
48
|
+
if (next.done) { reader.releaseLock(); controller.close(); return; }
|
|
49
|
+
held = next.value; offset = 0;
|
|
50
|
+
}
|
|
51
|
+
await checkpoint();
|
|
52
|
+
const end = Math.min(held.length, offset + 1024 * 1024);
|
|
53
|
+
controller.enqueue(held.subarray(offset, end)); offset = end;
|
|
54
|
+
} catch (error) { await reader.cancel(error).catch(() => {}); controller.error(error); }
|
|
55
|
+
},
|
|
56
|
+
async cancel(reason) { await reader.cancel(reason); reader.releaseLock(); },
|
|
57
|
+
}, { highWaterMark: 0 });
|
|
58
|
+
}
|
package/browser/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PullResult } from './pull-job.mts';
|
|
1
2
|
import { FrameStreamReader } from './frame-stream.mts';
|
|
2
3
|
/**
|
|
3
4
|
* The tab-side handle on a Blender session: spawns the worker, forwards the
|
|
@@ -253,7 +254,7 @@ export class BlenderRuntime {
|
|
|
253
254
|
);
|
|
254
255
|
this.#project = project;
|
|
255
256
|
this.#document ??= document ?? 'models/model.blend';
|
|
256
|
-
this.#started ??= this.#
|
|
257
|
+
this.#started ??= this.#load({
|
|
257
258
|
op: 'start',
|
|
258
259
|
project,
|
|
259
260
|
...(document === undefined ? {} : { document }),
|
|
@@ -261,6 +262,14 @@ export class BlenderRuntime {
|
|
|
261
262
|
return this.#started;
|
|
262
263
|
}
|
|
263
264
|
|
|
265
|
+
async #load(request: Extract<Request, { op: 'start' }>): Promise<RuntimeStart> {
|
|
266
|
+
let result = await this.#request(request) as PullResult<RuntimeStart>;
|
|
267
|
+
while (result?.load === 'continue') {
|
|
268
|
+
result = await this.#request({ op: 'load-next', token: result.token }) as PullResult<RuntimeStart>;
|
|
269
|
+
}
|
|
270
|
+
return result?.load === 'done' ? result.value : result as unknown as RuntimeStart;
|
|
271
|
+
}
|
|
272
|
+
|
|
264
273
|
#ready(): Promise<RuntimeStart> {
|
|
265
274
|
if (this.#project === null)
|
|
266
275
|
return Promise.reject(
|
package/browser/session.py
CHANGED
|
@@ -1402,7 +1402,11 @@ class Session:
|
|
|
1402
1402
|
options["defer"] = True
|
|
1403
1403
|
if hasattr(_blender_web, "memory_reset_peak"):
|
|
1404
1404
|
_blender_web.memory_reset_peak()
|
|
1405
|
-
|
|
1405
|
+
if hasattr(_blender_web, "export_frame_chunked"):
|
|
1406
|
+
exported = _blender_web.export_frame_chunked(json.dumps(options),
|
|
1407
|
+
lambda: _asked({"checkpoint": "native-export"}))
|
|
1408
|
+
else:
|
|
1409
|
+
exported = _blender_web.export_frame(json.dumps(options))
|
|
1406
1410
|
_mark("export:door")
|
|
1407
1411
|
frame = json.loads(exported)
|
|
1408
1412
|
del exported
|
|
@@ -1847,9 +1851,18 @@ class Session:
|
|
|
1847
1851
|
self.document = os.path.join(self.project, relative_path)
|
|
1848
1852
|
if not os.path.exists(self.document):
|
|
1849
1853
|
return {"document": relative_path, "opened": False}
|
|
1854
|
+
_asked({"checkpoint": "document-open"})
|
|
1850
1855
|
_mark("open:before")
|
|
1851
|
-
|
|
1856
|
+
checkpointed_read = hasattr(_blender_web, "set_read_checkpoint")
|
|
1857
|
+
if checkpointed_read:
|
|
1858
|
+
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
1859
|
+
try:
|
|
1860
|
+
bpy.ops.wm.open_mainfile(filepath=self.document)
|
|
1861
|
+
finally:
|
|
1862
|
+
if checkpointed_read:
|
|
1863
|
+
_blender_web.set_read_checkpoint(None)
|
|
1852
1864
|
_mark("open:after")
|
|
1865
|
+
_asked({"checkpoint": "document-opened"})
|
|
1853
1866
|
size = os.path.getsize(self.document)
|
|
1854
1867
|
# THE STAGED COPY GOES ONCE BLENDER HAS READ IT. It sits in the engine's heap (WasmFS keeps
|
|
1855
1868
|
# file data in linear memory) and nothing reads it again: the load took everything into
|
package/browser/worker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PullJob, checkpointStream } from './pull-job.mts';
|
|
1
2
|
import { sendFrameValue } from './frame-stream.mts';
|
|
2
3
|
/**
|
|
3
4
|
* THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
@@ -69,7 +70,8 @@ const pendingPresents = new Map<
|
|
|
69
70
|
>();
|
|
70
71
|
|
|
71
72
|
async function streamToTab(value: unknown): Promise<PresentAnswer> {
|
|
72
|
-
return await sendFrameValue(value, chunk => {
|
|
73
|
+
return await sendFrameValue(value, async chunk => {
|
|
74
|
+
await loadCheckpoint?.("frame-transfer");
|
|
73
75
|
const id = ++presentId;
|
|
74
76
|
return new Promise<PresentAnswer>((resolve, reject) => {
|
|
75
77
|
pendingPresents.set(id, { resolve, reject });
|
|
@@ -85,6 +87,8 @@ function presentToTab(frame: unknown, description: unknown, capture?: CaptureReq
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
let engine: BlenderEngine | null = null;
|
|
90
|
+
let loadJob: PullJob<unknown> | null = null;
|
|
91
|
+
let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
|
|
88
92
|
|
|
89
93
|
// ---- The session's document.
|
|
90
94
|
//
|
|
@@ -263,7 +267,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
263
267
|
const started = await startBlenderEngine({
|
|
264
268
|
project,
|
|
265
269
|
log,
|
|
266
|
-
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue }) => {
|
|
270
|
+
ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint }) => {
|
|
271
|
+
if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
|
|
267
272
|
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
268
273
|
// Save once after the whole command, never during a partial frame.
|
|
269
274
|
if (saveDue && documentPath !== null) setDocumentDirty(true);
|
|
@@ -337,12 +342,14 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
337
342
|
},
|
|
338
343
|
});
|
|
339
344
|
holder.engine = started;
|
|
345
|
+
await loadCheckpoint?.("engine-ready");
|
|
340
346
|
engine = started;
|
|
341
347
|
// THE PROJECT'S FILES BEFORE THE SESSION'S FIRST ACT, because that act may
|
|
342
348
|
// be `open_mainfile` on the document — which lives on the host's disk and is
|
|
343
349
|
// not in the engine's filesystem until it is staged. Every other call stages
|
|
344
350
|
// on its way in (`execute`); start had nothing to open before it did.
|
|
345
351
|
if (document) await stageProjectFiles(started.files, project);
|
|
352
|
+
await loadCheckpoint?.("project-imported");
|
|
346
353
|
const banner = (await started.request({
|
|
347
354
|
op: 'start',
|
|
348
355
|
project,
|
|
@@ -526,7 +533,7 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
526
533
|
const dir = path.slice(0, path.lastIndexOf('/'));
|
|
527
534
|
if (dir) await files_.mkdirTree(dir);
|
|
528
535
|
if (files_.writeFileStream && answer.body) {
|
|
529
|
-
await files_.writeFileStream(path, answer.body, file.size);
|
|
536
|
+
await files_.writeFileStream(path, loadCheckpoint ? checkpointStream(answer.body, () => loadCheckpoint!("file-import")) : answer.body, file.size);
|
|
530
537
|
} else {
|
|
531
538
|
await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
|
|
532
539
|
}
|
|
@@ -567,8 +574,19 @@ async function listSessionFiles(files: BlenderFiles, root: string): Promise<File
|
|
|
567
574
|
|
|
568
575
|
async function handle(request: WorkerRequest): Promise<unknown> {
|
|
569
576
|
switch (request.op) {
|
|
570
|
-
case 'start':
|
|
571
|
-
|
|
577
|
+
case 'start': {
|
|
578
|
+
if (loadJob) throw new Error('The Blender load has already started');
|
|
579
|
+
loadJob = new PullJob(async checkpoint => {
|
|
580
|
+
loadCheckpoint = checkpoint;
|
|
581
|
+
const began = performance.now();
|
|
582
|
+
try { return await start(request.project, request.document); }
|
|
583
|
+
finally { loadCheckpoint = null; log('log', `@@VOLTER-LOAD totalMs=${Math.round(performance.now() - began)}`); }
|
|
584
|
+
});
|
|
585
|
+
return loadJob.step();
|
|
586
|
+
}
|
|
587
|
+
case 'load-next':
|
|
588
|
+
if (!loadJob) throw new Error('No Blender load to continue');
|
|
589
|
+
return loadJob.step(request.token);
|
|
572
590
|
case 'present-result': {
|
|
573
591
|
const pending = pendingPresents.get(request.id);
|
|
574
592
|
pendingPresents.delete(request.id);
|
|
@@ -582,6 +600,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
582
600
|
return undefined;
|
|
583
601
|
}
|
|
584
602
|
}
|
|
603
|
+
if (loadCheckpoint) throw new Error('The Blender load must finish before another command');
|
|
585
604
|
if (!session || !projectRoot || !engine) throw new Error('The Blender session has not started');
|
|
586
605
|
const files = engine.files;
|
|
587
606
|
/** The session channel itself, for the requests whose answer is already the
|
package/package.json
CHANGED
package/wasm/BUNDLE.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"blender": "5.2.0 LTS",
|
|
3
|
-
"source": "
|
|
4
|
-
"reviewedSource": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
|
|
3
|
+
"source": "54e4af619a285c6d76c56ef960dc99d45aca5084",
|
|
5
4
|
"upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
|
|
6
5
|
"sourceRepository": "https://github.com/volter-ai/blender",
|
|
7
|
-
"sourceRecipe": "https://github.com/volter-ai/blender/blob/
|
|
6
|
+
"sourceRecipe": "https://github.com/volter-ai/blender/blob/54e4af619a285c6d76c56ef960dc99d45aca5084/build_files/emscripten/RELEASE.md",
|
|
8
7
|
"factoryEngine": "BLENDER_EEVEE",
|
|
9
8
|
"files": {
|
|
10
9
|
"blender_browser.js": "raw",
|
|
@@ -17,8 +16,8 @@
|
|
|
17
16
|
"sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
|
|
18
17
|
},
|
|
19
18
|
"blender_browser.wasm": {
|
|
20
|
-
"bytes":
|
|
21
|
-
"sha256": "
|
|
19
|
+
"bytes": 86332914,
|
|
20
|
+
"sha256": "15c4b7072ebc655418849da79a6fefed8bff26774e7446ade8d3a54bd6ad7a61"
|
|
22
21
|
},
|
|
23
22
|
"blender_browser.data": {
|
|
24
23
|
"bytes": 52698568,
|
|
@@ -31,8 +30,8 @@
|
|
|
31
30
|
"sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
|
|
32
31
|
},
|
|
33
32
|
"blender_browser.wasm.br": {
|
|
34
|
-
"bytes":
|
|
35
|
-
"sha256": "
|
|
33
|
+
"bytes": 18601239,
|
|
34
|
+
"sha256": "8cd29a87fcce046c292c52cc62523fe0ebbd1db8f05b19503d56311ae12c84c5"
|
|
36
35
|
},
|
|
37
36
|
"blender_browser.data.br": {
|
|
38
37
|
"bytes": 8056563,
|
|
@@ -43,9 +42,9 @@
|
|
|
43
42
|
"sha256": "4085e9336266cf66f061d4d5c962a5929cc96d5732c746b5c13ea05cd104a75b"
|
|
44
43
|
}
|
|
45
44
|
},
|
|
46
|
-
"note": "
|
|
45
|
+
"note": "Stoneguard c7/c14/c15 engine. Emscripten 6.0.5, Release -O3 objects and -O2 link. Portable native arm64 matrix reciprocal plus fused generic OpenSubdiv stencil and patch accumulation; bodice four-stage comparison 28/28 arrays exact. Full-scene comparison and memory scope recorded in provenance/stoneguard-memory.json. Historical battery absent; no full-battery or hosted acceptance claim.",
|
|
47
46
|
"callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
|
|
48
47
|
"engineIds": "The existing session registers Python render engines under CYCLES, BLENDER_EEVEE and BLENDER_WORKBENCH; the headless build permits built-in IDs to be shadowed.",
|
|
49
|
-
"exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation.",
|
|
48
|
+
"exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed.",
|
|
50
49
|
"preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
|
|
51
50
|
}
|
|
Binary file
|