@volter/blender-engine 0.1.15 → 0.1.16
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.
|
@@ -112,7 +112,8 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
|
|
|
112
112
|
const FS = module.FS;
|
|
113
113
|
return {
|
|
114
114
|
readFile: async (path) => FS.readFile(path),
|
|
115
|
-
|
|
115
|
+
// Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
|
|
116
|
+
writeFile: (path, data) => { FS.writeFile(path, data); return Promise.resolve(); },
|
|
116
117
|
mkdirTree: async (path) => FS.mkdirTree(path),
|
|
117
118
|
readdir: async (path) => FS.readdir(path),
|
|
118
119
|
stat: async (path) => {
|
|
@@ -90,6 +90,18 @@ export interface BlenderFiles {
|
|
|
90
90
|
/** Null when the path is not there. */
|
|
91
91
|
stat(path: string): Promise<BlenderFileStat | null>;
|
|
92
92
|
unlink(path: string): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Resolves at the next change to this filesystem or after `within` ms,
|
|
95
|
+
* whichever is first, when the engine can say so. The call loop waits on it
|
|
96
|
+
* in place of its poll's sleep, so an answer is read when it lands rather
|
|
97
|
+
* than at the next timer: a browser clamps a chain of `setTimeout(1)` to
|
|
98
|
+
* 4 ms, which was most of a no-op call's return leg (measured 2026-09-28:
|
|
99
|
+
* 6-7 ms of 8-10). Each wait is its own and is gone when it resolves; racing
|
|
100
|
+
* one shared promise against every poll's sleep kept a reaction per poll
|
|
101
|
+
* until the next change (review of d36c8004: 43 MB in an hour-long call).
|
|
102
|
+
* Without it the loop sleeps as before.
|
|
103
|
+
*/
|
|
104
|
+
changed?(within: number): Promise<void>;
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
export interface BlenderEngineOptions {
|
|
@@ -364,16 +376,28 @@ export function openSessionChannel(
|
|
|
364
376
|
} catch (error) {
|
|
365
377
|
answer = { error: error instanceof Error ? error.message : String(error) };
|
|
366
378
|
}
|
|
367
|
-
|
|
368
|
-
await
|
|
379
|
+
// One turn, `.done` second, as a request's two files (see `request`).
|
|
380
|
+
await Promise.all([
|
|
381
|
+
files.writeFile(`${SESSION_ROOT}/reply/${id}.json`, JSON.stringify(answer ?? null)),
|
|
382
|
+
files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1'),
|
|
383
|
+
]);
|
|
369
384
|
replied.add(id);
|
|
370
385
|
}
|
|
371
386
|
}
|
|
372
387
|
|
|
373
388
|
async function request(payload: Record<string, unknown>): Promise<unknown> {
|
|
374
389
|
const id = String(++sequence);
|
|
375
|
-
|
|
376
|
-
|
|
390
|
+
// BOTH WRITES BEGIN IN ONE TURN, `.done` second. Both engines write
|
|
391
|
+
// synchronously inside `writeFile` and throw from it, so the order is kept
|
|
392
|
+
// and a failed `.json` never leaves a `.done` behind; and the WALI
|
|
393
|
+
// engine carries one turn's writes to the program as one patch, where an
|
|
394
|
+
// await between them sent the request as two imports in series, the
|
|
395
|
+
// second waiting for the first's acknowledgement (measured 2026-09-28:
|
|
396
|
+
// most of a no-op call's 1.4-1.9 ms inbound leg).
|
|
397
|
+
await Promise.all([
|
|
398
|
+
files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload)),
|
|
399
|
+
files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1'),
|
|
400
|
+
]);
|
|
377
401
|
const began = performance.now();
|
|
378
402
|
for (;;) {
|
|
379
403
|
await serveAsks();
|
|
@@ -406,7 +430,8 @@ export function openSessionChannel(
|
|
|
406
430
|
'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
|
|
407
431
|
'(`VOLTER_BLENDER_FRESH_SESSION=1` for the battery harness).',
|
|
408
432
|
);
|
|
409
|
-
|
|
433
|
+
const delay = pollDelay(performance.now() - began);
|
|
434
|
+
await (files.changed ? files.changed(delay) : sleep(delay));
|
|
410
435
|
}
|
|
411
436
|
}
|
|
412
437
|
|
|
@@ -63,6 +63,10 @@ const RESOURCES = '/bw';
|
|
|
63
63
|
* session's own root, because it belongs to the session's lifetime. */
|
|
64
64
|
const ARENA_PATH = `${SESSION_ROOT}/frame.bin`;
|
|
65
65
|
|
|
66
|
+
/** The WALI pack's startup copies of the render override, by runtime-relative
|
|
67
|
+
* path; `stageRuntime` leaves them out. */
|
|
68
|
+
const PACK_RENDER_OVERRIDES = new Set(['scripts/startup/vgai_three.py', 'scripts/startup/volter_three.py']);
|
|
69
|
+
|
|
66
70
|
/** The host filesystem seam, in the only shape this file uses. */
|
|
67
71
|
interface BrowserFileSystemLike {
|
|
68
72
|
existsSync(path: string): boolean;
|
|
@@ -72,6 +76,8 @@ interface BrowserFileSystemLike {
|
|
|
72
76
|
mkdirSync(path: string, options?: { recursive?: boolean }): void;
|
|
73
77
|
readdirSync(path: string): string[];
|
|
74
78
|
unlinkSync(path: string): void;
|
|
79
|
+
/** A write or removal, the program's included (its patches land here). */
|
|
80
|
+
on?(event: 'change' | 'delete', listener: (path: string) => void): unknown;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
interface WaliProgram {
|
|
@@ -142,6 +148,17 @@ async function stageRuntime(
|
|
|
142
148
|
`Blender runtime ${entry.path}: bytes ${offset}..${offset + entry.size} lie outside ` +
|
|
143
149
|
`the ${blob.byteLength}-byte runtime blob`,
|
|
144
150
|
);
|
|
151
|
+
// THE PACK'S RENDER OVERRIDE IS NOT STAGED. It is the delivery for a bare
|
|
152
|
+
// `blender -b` with no session: at startup it asks a presenter at its own
|
|
153
|
+
// directory and waits two seconds for one. This session registers the same
|
|
154
|
+
// override itself (`session.py`, `VolterRenderEngine`) and answers through
|
|
155
|
+
// its own channel, so here the module could only wait out those two
|
|
156
|
+
// seconds (measured 2026-09-27: every boot printed "no presenter answered
|
|
157
|
+
// at /tmp/vgai-presenter within 2 s").
|
|
158
|
+
if (PACK_RENDER_OVERRIDES.has(entry.path)) {
|
|
159
|
+
offset += entry.size;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
145
162
|
const path = `${RESOURCES}/${entry.path}`;
|
|
146
163
|
filesystem.mkdirSync(path.slice(0, path.lastIndexOf('/')), { recursive: true });
|
|
147
164
|
filesystem.writeFileSync(path, blob.subarray(offset, offset + entry.size));
|
|
@@ -156,11 +173,82 @@ async function stageRuntime(
|
|
|
156
173
|
return { files: index.files.length, bytes: blob.byteLength, ms };
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
type LayerFromIndex = (
|
|
177
|
+
root: string,
|
|
178
|
+
files: RuntimeIndex['files'],
|
|
179
|
+
bytes: SharedArrayBuffer,
|
|
180
|
+
options: { mtimeMs: number; include?: (path: string) => boolean },
|
|
181
|
+
) => unknown;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Blender's runtime tree as a SHARED LAYER the program reads, never written
|
|
185
|
+
* into this page's filesystem (browser-substrate ADR-0044 §6): the blob is
|
|
186
|
+
* read once, straight into shared memory, and the index names where each file
|
|
187
|
+
* lies in it. Staged file by file, the tree was held twice -- in this page's
|
|
188
|
+
* filesystem and in the layer the substrate made of it for the program's
|
|
189
|
+
* workers -- and took 0.3-0.5 s of every boot (measured 2026-09-27). A
|
|
190
|
+
* substrate without the door (`sharedFileLayerFromIndex`) stages as before.
|
|
191
|
+
*/
|
|
192
|
+
async function sharedRuntime(
|
|
193
|
+
fromIndex: LayerFromIndex,
|
|
194
|
+
log: BlenderEngineOptions['log'],
|
|
195
|
+
): Promise<unknown> {
|
|
196
|
+
const began = performance.now();
|
|
197
|
+
const indexAnswer = await fetch(artifactUrl('runtime.idx'));
|
|
198
|
+
if (!indexAnswer.ok)
|
|
199
|
+
throw new Error(`${ARTIFACT_BASE}/runtime.idx answered ${indexAnswer.status}`);
|
|
200
|
+
const index = (await indexAnswer.json()) as RuntimeIndex;
|
|
201
|
+
const total = index.files.reduce((sum, file) => sum + file.size, 0);
|
|
202
|
+
const blobAnswer = await fetch(artifactUrl('runtime.bin'));
|
|
203
|
+
if (!blobAnswer.ok || !blobAnswer.body)
|
|
204
|
+
throw new Error(`${ARTIFACT_BASE}/runtime.bin answered ${blobAnswer.status}`);
|
|
205
|
+
const shared = new SharedArrayBuffer(total);
|
|
206
|
+
const bytes = new Uint8Array(shared);
|
|
207
|
+
let filled = 0;
|
|
208
|
+
const reader = blobAnswer.body.getReader();
|
|
209
|
+
for (;;) {
|
|
210
|
+
const { done, value } = await reader.read();
|
|
211
|
+
if (done) break;
|
|
212
|
+
// The index and the blob must agree to the byte, or Python reads truncated files.
|
|
213
|
+
if (filled + value.byteLength > total)
|
|
214
|
+
throw new Error(`Blender runtime blob is longer than the ${total} bytes its index accounts for`);
|
|
215
|
+
bytes.set(value, filled);
|
|
216
|
+
filled += value.byteLength;
|
|
217
|
+
}
|
|
218
|
+
if (filled !== total)
|
|
219
|
+
throw new Error(`Blender runtime index accounts for ${total} bytes; the blob served ${filled}`);
|
|
220
|
+
const layer = fromIndex(RESOURCES, index.files, shared, {
|
|
221
|
+
mtimeMs: Date.now(),
|
|
222
|
+
// THE PACK'S RENDER OVERRIDE IS LEFT OUT: it is the delivery for a bare
|
|
223
|
+
// `blender -b` with no session, and at startup it asks a presenter at its
|
|
224
|
+
// own directory and waits two seconds for one. This session registers the
|
|
225
|
+
// same override itself (`session.py`, `VolterRenderEngine`) and answers
|
|
226
|
+
// through its own channel, so here the module could only wait (measured
|
|
227
|
+
// 2026-09-27: every boot printed "no presenter answered ... within 2 s").
|
|
228
|
+
include: (path) => !PACK_RENDER_OVERRIDES.has(path),
|
|
229
|
+
});
|
|
230
|
+
log('log', `blender: shared Blender's ${index.files.length}-file runtime tree in ${Math.round(performance.now() - began)} ms`);
|
|
231
|
+
return layer;
|
|
232
|
+
}
|
|
233
|
+
|
|
159
234
|
/** The program's filesystem, as {@link BlenderFiles}. */
|
|
160
235
|
function programFiles(filesystem: BrowserFileSystemLike): BlenderFiles {
|
|
236
|
+
// One listener for the engine's life, and one waiter per wait: a change ends
|
|
237
|
+
// every wait in progress, a wait's own timeout ends only it, and either way
|
|
238
|
+
// it is forgotten.
|
|
239
|
+
const waiting = new Set<() => void>();
|
|
240
|
+
const wake = (): void => { for (const done of [...waiting]) done(); };
|
|
241
|
+
filesystem.on?.('change', wake);
|
|
242
|
+
filesystem.on?.('delete', wake);
|
|
161
243
|
return {
|
|
244
|
+
...(filesystem.on ? { changed: (within: number) => new Promise<void>((resolve) => {
|
|
245
|
+
const done = (): void => { clearTimeout(timer); waiting.delete(done); resolve(); };
|
|
246
|
+
const timer = setTimeout(done, within);
|
|
247
|
+
waiting.add(done);
|
|
248
|
+
}) } : {}),
|
|
162
249
|
readFile: (path) => filesystem.readFile(path),
|
|
163
|
-
|
|
250
|
+
// Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
|
|
251
|
+
writeFile: (path, data) => { filesystem.writeFileSync(path, data); return Promise.resolve(); },
|
|
164
252
|
mkdirTree: async (path) => filesystem.mkdirSync(path, { recursive: true }),
|
|
165
253
|
readdir: async (path) => filesystem.readdirSync(path),
|
|
166
254
|
stat: async (path) => {
|
|
@@ -183,9 +271,10 @@ export async function startWaliBlenderEngine(
|
|
|
183
271
|
status: BlenderArtifactStatus,
|
|
184
272
|
): Promise<BlenderEngine> {
|
|
185
273
|
const started = performance.now();
|
|
186
|
-
const [wali, runtime] = await Promise.all([
|
|
274
|
+
const [wali, runtime, layers] = await Promise.all([
|
|
187
275
|
substrate('browser-wali/worker-program.js'),
|
|
188
276
|
substrate('browser-runtime/browser-memory-filesystem.js'),
|
|
277
|
+
substrate('browser-wali/shared-file-layer.js').catch((): Record<string, unknown> => ({})),
|
|
189
278
|
]);
|
|
190
279
|
const WaliWorkerProgram = wali['BrowserWaliWorkerProgram'] as
|
|
191
280
|
| (new (
|
|
@@ -203,6 +292,15 @@ export async function startWaliBlenderEngine(
|
|
|
203
292
|
'does not know.',
|
|
204
293
|
);
|
|
205
294
|
|
|
295
|
+
// THE MODULE LOADS WHILE THE RUNTIME LAYER IS BUILT. Blender's 93 MB module
|
|
296
|
+
// was fetched and compiled by the program's worker only once the program
|
|
297
|
+
// ran, after the layer below: measured 2026-09-28, the layer 270-975 ms and
|
|
298
|
+
// then the module 200-250 ms warm, in series. The substrate's preload fills
|
|
299
|
+
// the cache the program's worker reads; a substrate without it, or a preload
|
|
300
|
+
// that fails, leaves the worker to load the module as before.
|
|
301
|
+
const preload = wali['preloadWaliModule'] as ((url: string, integrity: string) => Promise<void>) | undefined;
|
|
302
|
+
if (preload && status.integrity) void preload(artifactUrl('blender.wasm'), status.integrity).catch(() => undefined);
|
|
303
|
+
|
|
206
304
|
const filesystem = new MemoryFileSystem();
|
|
207
305
|
// `out` and `ask` are deliberately absent: `session.py` owns those two and
|
|
208
306
|
// makes them itself (the channel's ownership rule in `blender-engine.mts`).
|
|
@@ -217,7 +315,9 @@ export async function startWaliBlenderEngine(
|
|
|
217
315
|
options.project,
|
|
218
316
|
])
|
|
219
317
|
filesystem.mkdirSync(directory, { recursive: true });
|
|
220
|
-
|
|
318
|
+
const fromIndex = layers['sharedFileLayerFromIndex'] as LayerFromIndex | undefined;
|
|
319
|
+
const runtimeLayer = fromIndex ? await sharedRuntime(fromIndex, options.log) : undefined;
|
|
320
|
+
if (!runtimeLayer) await stageRuntime(filesystem, options.log);
|
|
221
321
|
filesystem.writeFileSync(SESSION_SCRIPT, sessionPython);
|
|
222
322
|
|
|
223
323
|
const workers = status.workers ?? { pool: 16, blender: 4 };
|
|
@@ -248,7 +348,23 @@ export async function startWaliBlenderEngine(
|
|
|
248
348
|
VOLTER_SESSION_ROOT: SESSION_ROOT,
|
|
249
349
|
// THE ARENA'S DOOR ON THIS SKEW. See `readArena`.
|
|
250
350
|
VOLTER_EXPORT_BUFFER_PATH: ARENA_PATH,
|
|
351
|
+
// OPENIMAGEIO'S POOL AT ONE THREAD, as Blender's own are (`-t 1`,
|
|
352
|
+
// BLENDER_WALI_WORKERS). Its default pool is built at the CPU count
|
|
353
|
+
// before `-t` reaches it: measured 2026-09-28, nine of the ten threads
|
|
354
|
+
// this Blender started were OpenImageIO's, idle for the session, each
|
|
355
|
+
// holding a pool worker (browser-substrate `programs/blender/5.2.0`
|
|
356
|
+
// README §(f)).
|
|
357
|
+
OPENIMAGEIO_THREADS: '1',
|
|
251
358
|
},
|
|
359
|
+
...(runtimeLayer ? { layers: [runtimeLayer] } : {}),
|
|
360
|
+
// BLENDER'S /tmp IS ITS OWN, as it is in the standalone skew's WasmFS:
|
|
361
|
+
// what it writes there never crosses to this page, so nothing waits for
|
|
362
|
+
// the page to take it (browser-substrate `privateRoots`, ADR-0046).
|
|
363
|
+
// Measured 2026-09-27: published, a `.blend` saved to /tmp cost 11 ms
|
|
364
|
+
// against the standalone skew's 2, most of it the page taking the file
|
|
365
|
+
// before Blender's next printed line. The session's channel and the
|
|
366
|
+
// project live elsewhere and still cross.
|
|
367
|
+
privateRoots: ['/tmp'],
|
|
252
368
|
threadPoolSize: workers.pool,
|
|
253
369
|
// Blender talks to nothing. The editor's own routes are this worker's, not
|
|
254
370
|
// the program's.
|
package/browser/session.py
CHANGED
|
@@ -1827,6 +1827,19 @@ def _drop(path):
|
|
|
1827
1827
|
_say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
|
|
1828
1828
|
|
|
1829
1829
|
|
|
1830
|
+
# HOW OFTEN TO LOOK, by how recently something happened. A present's answer
|
|
1831
|
+
# and the next call of a gesture are usually a few milliseconds away, and a
|
|
1832
|
+
# fixed 2 ms sleep put a whole sleep into each of them: a no-op execute spent
|
|
1833
|
+
# 6-7 ms of its 8-10 ms coming back, most of it the present's `ask` waiting out
|
|
1834
|
+
# sleeps on both skews (measured 2026-09-28). Brisk for 50 ms after activity,
|
|
1835
|
+
# then the old 2 ms, so an idle session lists its directory no more than before.
|
|
1836
|
+
_BRISK_SECONDS = 0.05
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _pause(since):
|
|
1840
|
+
time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
|
|
1841
|
+
|
|
1842
|
+
|
|
1830
1843
|
def ask(payload):
|
|
1831
1844
|
"""Block until the tab answers. The worker's own thread is free while this
|
|
1832
1845
|
waits; only the Blender pthread is held, which is where the operator is.
|
|
@@ -1843,8 +1856,9 @@ def ask(payload):
|
|
|
1843
1856
|
with open(os.path.join(ASK, name + ".done"), "w") as fh:
|
|
1844
1857
|
fh.write("1")
|
|
1845
1858
|
marker = os.path.join(REPLY, name + ".done")
|
|
1859
|
+
began = time.monotonic()
|
|
1846
1860
|
while not os.path.exists(marker):
|
|
1847
|
-
|
|
1861
|
+
_pause(began)
|
|
1848
1862
|
with open(os.path.join(REPLY, name + ".json")) as fh:
|
|
1849
1863
|
body = fh.read()
|
|
1850
1864
|
_drop(os.path.join(ASK, name + ".json"))
|
|
@@ -4994,6 +5008,7 @@ def _channel_loop():
|
|
|
4994
5008
|
# with the session.
|
|
4995
5009
|
seen = set()
|
|
4996
5010
|
unacked = []
|
|
5011
|
+
active = 0.0
|
|
4997
5012
|
while True:
|
|
4998
5013
|
# A FAILED LISTING IS NOT AN EMPTY ONE. This swallowed every OSError
|
|
4999
5014
|
# into "no requests", so the one failure that matters -- the process
|
|
@@ -5029,7 +5044,7 @@ def _channel_loop():
|
|
|
5029
5044
|
names = sorted((n for n in markers if n not in seen),
|
|
5030
5045
|
key=lambda n: int(n[: -len(".done")]))
|
|
5031
5046
|
if not names:
|
|
5032
|
-
|
|
5047
|
+
_pause(active)
|
|
5033
5048
|
continue
|
|
5034
5049
|
for marker in names:
|
|
5035
5050
|
seen.add(marker)
|
|
@@ -5047,6 +5062,8 @@ def _channel_loop():
|
|
|
5047
5062
|
with open(os.path.join(OUT, rid + ".done"), "w") as fh:
|
|
5048
5063
|
fh.write("1")
|
|
5049
5064
|
unacked.append(rid)
|
|
5065
|
+
# From the last answer: the page's next call of a gesture follows it.
|
|
5066
|
+
active = time.monotonic()
|
|
5050
5067
|
|
|
5051
5068
|
|
|
5052
5069
|
try:
|