@volter/blender-engine 0.1.49 → 0.1.51

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.
@@ -27,6 +27,7 @@
27
27
  * `session.py` chmods its own root as its first act.
28
28
  */
29
29
 
30
+ import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
30
31
  import {
31
32
  artifactUrl,
32
33
  type BlenderArtifactStatus,
@@ -44,7 +45,7 @@ import sessionPython from './session.py?raw';
44
45
 
45
46
  /** What the module factory is, in the only shape this file uses. */
46
47
  interface BlenderModule {
47
- FS: {
48
+ FS: StreamFileSystem & {
48
49
  readFile(path: string): Uint8Array;
49
50
  writeFile(path: string, data: string | Uint8Array): void;
50
51
  mkdirTree(path: string): void;
@@ -115,6 +116,7 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
115
116
  readFile: async (path) => FS.readFile(path),
116
117
  // Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
117
118
  writeFile: (path, data) => { FS.writeFile(path, data); return Promise.resolve(); },
119
+ writeFileStream: (path, body, size) => writeStreamedFile(FS, path, body, size),
118
120
  mkdirTree: async (path) => FS.mkdirTree(path),
119
121
  readdir: async (path) => FS.readdir(path),
120
122
  stat: async (path) => {
@@ -198,6 +200,21 @@ export async function startEmscriptenBlenderEngine(
198
200
  if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
199
201
  options.log(sessionLevel(text), text);
200
202
  };
203
+ // THE HEAP ITSELF, not a view of it. Blender runs on a pthread, so when its heap grows there
204
+ // this worker's `module.HEAPU8` keeps the length it had until the glue next refreshes it -- and
205
+ // no export the door calls does. MEASURED 2026-09-29 on the Stoneguard scene from
206
+ // game-benchmarks main: a mesh's columns at 1.14 GB were read through a 433 MB view, and the
207
+ // pull failed "outside the export arena". The glue builds its memory with `new
208
+ // WebAssembly.Memory` and exports no handle to it, so that constructor is observed while the
209
+ // factory runs; every read below takes a fresh view of `memory.buffer`.
210
+ const Memory = WebAssembly.Memory;
211
+ let memory: WebAssembly.Memory | null = null;
212
+ (WebAssembly as { Memory: unknown }).Memory = function (descriptor: WebAssembly.MemoryDescriptor) {
213
+ const made = new Memory(descriptor);
214
+ memory ??= made;
215
+ return made;
216
+ } as unknown as typeof WebAssembly.Memory;
217
+ const heap = (): Uint8Array => (memory ? new Uint8Array(memory.buffer) : module.HEAPU8);
201
218
  const module = await factory({
202
219
  arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
203
220
  locateFile: (file: string) => artifactUrl(file),
@@ -253,6 +270,8 @@ export async function startEmscriptenBlenderEngine(
253
270
  );
254
271
  },
255
272
  ],
273
+ }).finally(() => {
274
+ (WebAssembly as { Memory: unknown }).Memory = Memory;
256
275
  });
257
276
  // `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
258
277
  // long before the session exists. The ready line is what says it does.
@@ -329,13 +348,15 @@ export async function startEmscriptenBlenderEngine(
329
348
  // A VIEW, NOT A COPY. The arena is wasm linear memory and under
330
349
  // `-sPROXY_TO_PTHREAD` that memory is SHARED, so `session-frame.mts`
331
350
  // slices every column it reads out of here; this call itself is free.
332
- readArena: async () =>
333
- module.HEAPU8.subarray(
334
- module._blender_web_export_buffer(),
335
- module._blender_web_export_buffer() + module._blender_web_export_buffer_size(),
336
- ),
351
+ // A DIRECT ARENA (address 0) IS THE WHOLE HEAP, at its length now: the size the door reports
352
+ // is `emscripten_get_heap_size()`, which is Blender's pthread's own view of the heap and is as
353
+ // stale as this worker's was (measured: 431 MB while a column lay at 2.0 GB).
354
+ readArena: async () => {
355
+ const base = module._blender_web_export_buffer();
356
+ return base === 0 ? heap() : heap().subarray(base, base + module._blender_web_export_buffer_size());
357
+ },
337
358
  bootMs,
338
- memoryBytes: () => module.HEAPU8.length,
359
+ memoryBytes: () => heap().length,
339
360
  releasedPayloadBytes,
340
361
  };
341
362
  }
@@ -85,6 +85,9 @@ export interface BlenderFileStat {
85
85
  export interface BlenderFiles {
86
86
  readFile(path: string): Promise<Uint8Array>;
87
87
  writeFile(path: string, data: string | Uint8Array): Promise<void>;
88
+ /** Stream a known-size file into the engine without a second whole-file JS buffer.
89
+ * A failed transfer leaves the previous file intact. */
90
+ writeFileStream?(path: string, body: ReadableStream<Uint8Array>, size: number): Promise<void>;
88
91
  mkdirTree(path: string): Promise<void>;
89
92
  readdir(path: string): Promise<string[]>;
90
93
  /** Null when the path is not there. */
@@ -0,0 +1,19 @@
1
+ /** Buffers at every depth of a frame move to the presenter exactly once. */
2
+ export function frameTransferBuffers(frame: unknown): ArrayBuffer[] {
3
+ const buffers = new Set<ArrayBuffer>();
4
+ const visited = new Set<object>();
5
+ const walk = (value: unknown): void => {
6
+ if (value === null || typeof value !== 'object' || visited.has(value)) return;
7
+ visited.add(value);
8
+ if (value instanceof ArrayBuffer) {
9
+ buffers.add(value);
10
+ } else if (ArrayBuffer.isView(value)) {
11
+ // Shared Wasm memory cannot be transferred; arena readers make owned copies.
12
+ if (value.buffer instanceof ArrayBuffer) buffers.add(value.buffer);
13
+ } else {
14
+ for (const child of Object.values(value)) walk(child);
15
+ }
16
+ };
17
+ walk(frame);
18
+ return [...buffers];
19
+ }
@@ -162,6 +162,8 @@ def _mark(label):
162
162
  MEMORY_MARKS.append([label] + [round(reading.get(key, 0) / 1048576)
163
163
  for key in ("heap", "mallocInUse", "mallocFree", "guarded", "guardedPeak")])
164
164
  del MEMORY_MARKS[:-64]
165
+ # Also said, so an engine that dies of memory names in its last output the stage it reached.
166
+ _say("@@VOLTER-MEMORY %s heap=%d inUse=%d free=%d" % tuple(MEMORY_MARKS[-1][:4]))
165
167
  _real_stderr = sys.stderr
166
168
 
167
169
 
@@ -1611,7 +1613,7 @@ class Session:
1611
1613
  images = [name for name, image in frame["images"].items() if image.get("deferred")]
1612
1614
  if not meshes and not images:
1613
1615
  return False
1614
- ask({"hold": frame})
1616
+ _asked({"hold": frame})
1615
1617
  options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
1616
1618
  for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
1617
1619
  ("image", images, _blender_web.export_image)):
@@ -1626,7 +1628,7 @@ class Session:
1626
1628
  if piece[kind] is None:
1627
1629
  del frame["images"][key]
1628
1630
  continue
1629
- ask({kind: key, "piece": piece[kind]})
1631
+ _asked({kind: key, "piece": piece[kind]})
1630
1632
  return True
1631
1633
 
1632
1634
  def _send_encoded_image(self, key, deferred):
@@ -1666,7 +1668,7 @@ class Session:
1666
1668
  with open(path, "wb") as fh:
1667
1669
  fh.write(data)
1668
1670
  try:
1669
- ask({"image": key, "piece": {
1671
+ _asked({"image": key, "piece": {
1670
1672
  "revision": deferred["revision"],
1671
1673
  "mime": mime,
1672
1674
  "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
@@ -2014,6 +2016,17 @@ def _pause(since):
2014
2016
  time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
2015
2017
 
2016
2018
 
2019
+ def _asked(payload):
2020
+ """`ask`, failing as the tab failed: a piece the worker could not take comes back as
2021
+ `{"error": ...}`, and a pull that read past it presented a frame missing that piece, refused
2022
+ later under another name ("deferred and never pulled")."""
2023
+ answer = ask(payload)
2024
+ if isinstance(answer, dict) and answer.get("error"):
2025
+ raise RuntimeError("The tab refused %s: %s" % (
2026
+ next((key for key in ("mesh", "image", "hold") if key in payload), "an ask"), answer["error"]))
2027
+ return answer
2028
+
2029
+
2017
2030
  def ask(payload):
2018
2031
  """Block until the tab answers. The worker's own thread is free while this
2019
2032
  waits; only the Blender pthread is held, which is where the operator is.
@@ -0,0 +1,54 @@
1
+ /** File imports publish only after every declared byte arrived. WasmFS stores the one final
2
+ * backing allocation; each write and its temporary Wasm copy are bounded to one MiB. */
3
+ export interface StreamFileSystem {
4
+ open(path: string, flags: string): { fd: number };
5
+ close(stream: { fd: number }): void;
6
+ write(stream: { fd: number }, data: Uint8Array, offset: number, length: number, position: number): number;
7
+ truncate(path: string, length: number): void;
8
+ rename(from: string, to: string): void;
9
+ unlink(path: string): void;
10
+ }
11
+
12
+ export async function writeStreamedFile(
13
+ FS: StreamFileSystem,
14
+ path: string,
15
+ body: ReadableStream<Uint8Array>,
16
+ size: number,
17
+ ): Promise<void> {
18
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error(`${path}: invalid file size ${size}`);
19
+ const temporary = `${path}.volter-stage-${crypto.randomUUID()}`;
20
+ const reader = body.getReader();
21
+ let stream: { fd: number } | undefined;
22
+ let complete = false;
23
+ try {
24
+ stream = FS.open(temporary, 'w');
25
+ // Reserve the exact backing once: growing WasmFS's vector while importing can otherwise
26
+ // hold both the old and the new allocation in the non-shrinking Wasm heap.
27
+ FS.truncate(temporary, size);
28
+ let position = 0;
29
+ for (;;) {
30
+ const { done, value } = await reader.read();
31
+ if (done) break;
32
+ if (position + value.byteLength > size) throw new Error(`${path}: file grew during import`);
33
+ for (let offset = 0; offset < value.byteLength;) {
34
+ const length = Math.min(1024 * 1024, value.byteLength - offset);
35
+ const written = FS.write(stream, value, offset, length, position);
36
+ if (written <= 0 || written > length) throw new Error(`${path}: invalid file write (${written})`);
37
+ offset += written;
38
+ position += written;
39
+ }
40
+ }
41
+ if (position !== size) throw new Error(`${path}: expected ${size} bytes, received ${position}`);
42
+ FS.close(stream);
43
+ stream = undefined;
44
+ FS.rename(temporary, path);
45
+ complete = true;
46
+ } finally {
47
+ if (!complete) await reader.cancel().catch(() => {});
48
+ reader.releaseLock();
49
+ if (stream) FS.close(stream);
50
+ if (!complete) {
51
+ try { FS.unlink(temporary); } catch { /* open may have failed before creating the file */ }
52
+ }
53
+ }
54
+ }
package/browser/worker.ts CHANGED
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
41
41
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
42
+ import { frameTransferBuffers } from './frame-transfer.mts';
42
43
  import { documentChunks } from './document-chunks.mts';
43
44
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
44
45
 
@@ -75,35 +76,24 @@ function presentToTab(
75
76
  const id = ++presentId;
76
77
  // A drawn mesh's buffers move to the tab rather than being copied: the
77
78
  // frame is the tab's from here on.
78
- const transfer: ArrayBuffer[] = [];
79
- // A picture travels the same way and for the same reason: an entry in the
80
- // frame's `images` carries a raw RGBA raster the tab uploads into a
81
- // `DataTexture` (`blender-runtime-view.ts`), once per image per revision.
82
- const parts = frame as {
83
- meshes?: Record<string, unknown>;
84
- images?: Record<string, unknown>;
85
- } | null;
86
- for (const carrier of [parts?.meshes ?? {}, parts?.images ?? {}])
87
- for (const held of Object.values(carrier))
88
- for (const value of Object.values(held as Record<string, unknown>))
89
- if (
90
- ArrayBuffer.isView(value) &&
91
- value.buffer instanceof ArrayBuffer &&
92
- !transfer.includes(value.buffer)
93
- )
94
- transfer.push(value.buffer);
79
+ const transfer = frameTransferBuffers(frame);
95
80
  return new Promise((resolve, reject) => {
96
81
  pendingPresents.set(id, { resolve, reject });
97
- (self as unknown as Worker).postMessage(
98
- {
99
- op: 'present',
100
- id,
101
- frame,
102
- description,
103
- ...(capture ? { capture } : {}),
104
- } satisfies WorkerReply,
105
- transfer,
106
- );
82
+ try {
83
+ (self as unknown as Worker).postMessage(
84
+ {
85
+ op: 'present',
86
+ id,
87
+ frame,
88
+ description,
89
+ ...(capture ? { capture } : {}),
90
+ } satisfies WorkerReply,
91
+ transfer,
92
+ );
93
+ } catch (error) {
94
+ pendingPresents.delete(id);
95
+ reject(error);
96
+ }
107
97
  });
108
98
  }
109
99
 
@@ -535,7 +525,11 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
535
525
  }
536
526
  const dir = path.slice(0, path.lastIndexOf('/'));
537
527
  if (dir) await files_.mkdirTree(dir);
538
- await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
528
+ if (files_.writeFileStream && answer.body) {
529
+ await files_.writeFileStream(path, answer.body, file.size);
530
+ } else {
531
+ await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
532
+ }
539
533
  staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
540
534
  }
541
535
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.49",
5
+ "version": "0.1.51",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
package/wasm/BUNDLE.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "blender": "5.2.0 LTS",
3
- "source": "1b2547991d6841a6ea4c1394b8d648086b9af6c9",
4
- "reviewedSource": "1b2547991d6841a6ea4c1394b8d648086b9af6c9",
3
+ "source": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
4
+ "reviewedSource": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
5
5
  "upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
6
6
  "sourceRepository": "https://github.com/volter-ai/blender",
7
- "sourceRecipe": "https://github.com/volter-ai/blender/blob/1b2547991d6841a6ea4c1394b8d648086b9af6c9/build_files/emscripten/RELEASE.md",
8
- "sourceRelease": "https://github.com/volter-ai/blender/releases/tag/blender-5.2.0-wasm.4",
9
- "verification": "https://github.com/volter-ai/blender/releases/download/blender-5.2.0-wasm.4/VERIFICATION.json",
7
+ "sourceRecipe": "https://github.com/volter-ai/blender/blob/e5d7eec67572dfc7c73f8f4d3f601d03442546f9/build_files/emscripten/RELEASE.md",
10
8
  "factoryEngine": "BLENDER_EEVEE",
11
9
  "files": {
12
10
  "blender_browser.js": "raw",
@@ -19,8 +17,8 @@
19
17
  "sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
20
18
  },
21
19
  "blender_browser.wasm": {
22
- "bytes": 86318165,
23
- "sha256": "722b181cec94f1bdf2f088e0ae92f04ffa5a79c267bf5b04af7260c16e34eb0f"
20
+ "bytes": 86317655,
21
+ "sha256": "0ba94ac8388de893bcec4da70516aea69128addaa490ef27f17ca06262ee6546"
24
22
  },
25
23
  "blender_browser.data": {
26
24
  "bytes": 52698568,
@@ -33,8 +31,8 @@
33
31
  "sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
34
32
  },
35
33
  "blender_browser.wasm.br": {
36
- "bytes": 18597884,
37
- "sha256": "55ef6f9d63a4c8afa7e7aec4388d1f8c462f5294fcbba8bfbbabe5c41a13f8f9"
34
+ "bytes": 18596913,
35
+ "sha256": "64af23954e1911ffcb879f69a9e8c9bbd83b5531939b586228f44fcea8211e3b"
38
36
  },
39
37
  "blender_browser.data.br": {
40
38
  "bytes": 8056563,
@@ -45,9 +43,9 @@
45
43
  "sha256": "4085e9336266cf66f061d4d5c962a5929cc96d5732c746b5c13ea05cd104a75b"
46
44
  }
47
45
  },
48
- "note": "Official Blender v5.2.0 plus our headless wasm changes and our existing export/session integration. Dependencies are rebuilt from pinned official sources. No third-party Blender port commits or build scripts are inputs. PROXY_TO_PTHREAD, WasmFS, 512 MiB initial / 4 GiB maximum memory, twelve-worker pool. Build information is disabled. The source release includes the exact source tree, materialized startup.blend, dependency archives, pure Python wheels, and notices.",
46
+ "note": "Unreleased Stoneguard candidate. Clean source build at the named commit; Release -O3 objects and -O2 link, Emscripten 6.0.5. Focused geometry regression matches shipped engine across original, vertex edit and subdivision-level edit. Full historical battery is absent from current editor checkout; no full-battery claim.",
49
47
  "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.",
50
48
  "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.",
51
- "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.",
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.",
52
50
  "preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
53
51
  }
Binary file