@volter/blender-engine 0.1.0

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.
Files changed (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. package/wasm/blender_browser.wasm.br +0 -0
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Blender engine
2
+
3
+ Read this when building or changing the Blender worker or its Three.js frame
4
+ presenter. This package contains Blender itself, compiled to WebAssembly, its
5
+ worker, Python session, message protocol and tab-side handle. It is independent
6
+ of the editor host and game runtime.
7
+
8
+ The package is GPL-3.0-or-later. Preserve [LICENSE](LICENSE), including all
9
+ upstream notices. No repository-wide license overrides this package's license.
10
+
11
+ ## Build and check
12
+
13
+ From the repository root, install the locked dependencies and run:
14
+
15
+ ```sh
16
+ npm ci
17
+ npm run typecheck
18
+ npm run build:blender-three
19
+ ```
20
+
21
+ Run these commands through the active World when working in the Volter
22
+ development environment. The browser typecheck has no monorepo aliases and no
23
+ Node ambient types. The presenter build emits its standalone module and sky
24
+ worker under this package's `dist/release/` directory.
25
+
26
+ The checked-in WASM files are prebuilt. Their exact upstream source, recipe and
27
+ artifact hashes are in [wasm/BUNDLE.json](wasm/BUNDLE.json). Building the presenter
28
+ does not rebuild or verify Blender itself. The corresponding Blender source
29
+ remains in the separate `volter-ai/blender` fork.
30
+
31
+ ## Distribution
32
+
33
+ The corresponding source is public at `volter-ai/blender`, and
34
+ `wasm/BUNDLE.json` pins its snapshot, release and artifact hashes. Publication
35
+ still requires the repository release checks; a successful local build alone is
36
+ not sufficient.
37
+
38
+ [The import record](../../provenance/blender-engine.json) identifies the original
39
+ source revision and hashes. Runtime, protocol and artifact bytes are preserved;
40
+ legacy internal wire markers and Blender build identifiers are intentionally not
41
+ renamed independently of their producers and consumers. Package identity and
42
+ build configuration use the public repository.
43
+
44
+ Benchmark/oracle tools remain at the pinned source revision until their inputs
45
+ and execution dependencies are migrated. Their old results are not verification
46
+ of a new editor release. The original editor still uses its existing package;
47
+ consumer cutover is tracked in the repository's `WORK.md`; do not create a
48
+ second independently edited implementation.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The STANDALONE skew: Blender in this worker's own WebAssembly module.
3
+ *
4
+ * `blender-engine.mts` is the seam and says how the skew is chosen; this is
5
+ * one of its two implementations, and the one `@volter/editor-blender` ships. It has no
6
+ * in-house dependency of any kind and reaches nothing but the editor's own
7
+ * routes.
8
+ *
9
+ * THREE THINGS THE BUILD REQUIRES, each measured rather than assumed:
10
+ *
11
+ * 1. CROSS-ORIGIN ISOLATION. The build is `-sPROXY_TO_PTHREAD` with pthreads,
12
+ * so it needs `SharedArrayBuffer`. The editor's dev server already sets
13
+ * `Cross-Origin-Opener-Policy: same-origin` and
14
+ * `Cross-Origin-Embedder-Policy: credentialless` ahead of every route
15
+ * (`packages/editor/server/dev.ts`) — `credentialless` rather than
16
+ * `require-corp` precisely so cross-origin images and iframes keep loading.
17
+ * A worker without `crossOriginIsolated` is refused BY NAME here rather
18
+ * than left to fail inside the runtime.
19
+ *
20
+ * 2. THE GLUE IS PATCHED SERVER-SIDE, not here, because the runtime spawns its
21
+ * pthreads by loading that same URL as a classic worker
22
+ * (`Module.mainScriptUrlOrBlob`). See `blender-wasm-artifact.ts`.
23
+ *
24
+ * 3. FILES ARE STAGED WITH `FS_createDataFile`, before the runtime starts.
25
+ * This is a WasmFS build: `Module.FS` exists and works from this thread,
26
+ * but its preload directories come out non-writable, which is why
27
+ * `session.py` chmods its own root as its first act.
28
+ */
29
+
30
+ import {
31
+ artifactUrl,
32
+ type BlenderArtifactStatus,
33
+ type BlenderEngine,
34
+ type BlenderEngineOptions,
35
+ type BlenderFiles,
36
+ openSessionChannel,
37
+ PAGE_OWNED_DIRECTORIES,
38
+ SESSION_ROOT,
39
+ SESSION_SCRIPT,
40
+ sleep,
41
+ } from './blender-engine.mts';
42
+ import sessionPython from './session.py?raw';
43
+
44
+ /** What the module factory is, in the only shape this file uses. */
45
+ interface BlenderModule {
46
+ FS: {
47
+ readFile(path: string): Uint8Array;
48
+ writeFile(path: string, data: string | Uint8Array): void;
49
+ mkdirTree(path: string): void;
50
+ readdir(path: string): string[];
51
+ unlink(path: string): void;
52
+ stat(path: string): { size: number; mode?: number; mtime?: Date | number };
53
+ chmod(path: string, mode: number): void;
54
+ };
55
+ ENV: Record<string, string>;
56
+ /** The export door's arena, read by `readArena`. */
57
+ HEAPU8: Uint8Array;
58
+ /**
59
+ * Hand back the packed `.data` payload, once. Linked in by the bundle's
60
+ * `--post-js` (the spike's `recipe/release-preloaded-file-data.js`, whose
61
+ * header carries the measurement); absent means the module predates it,
62
+ * which is why the call below is guarded and says so.
63
+ */
64
+ releasePreloadedFileData?(): { files: number; bytes: number };
65
+ _blender_web_export_buffer(): number;
66
+ _blender_web_export_buffer_size(): number;
67
+ FS_createPath(parent: string, name: string, canRead: boolean, canWrite: boolean): void;
68
+ FS_createDataFile(
69
+ parent: string,
70
+ name: string,
71
+ data: Uint8Array,
72
+ canRead: boolean,
73
+ canWrite: boolean,
74
+ canOwn: boolean,
75
+ ): void;
76
+ }
77
+
78
+ type BlenderModuleFactory = (options: Record<string, unknown>) => Promise<BlenderModule>;
79
+
80
+ /** The glue, evaluated in this worker. It is a UMD bundle, so it is given the
81
+ * `module`/`exports` pair it looks for and hands back the factory. */
82
+ async function loadFactory(glueUrl: string): Promise<BlenderModuleFactory> {
83
+ const source = await fetch(glueUrl);
84
+ if (!source.ok) throw new Error(`${glueUrl}: HTTP ${source.status}`);
85
+ const text = await source.text();
86
+ const container = { exports: {} as { default?: BlenderModuleFactory } };
87
+ const evaluate = new Function(
88
+ 'module',
89
+ 'exports',
90
+ 'define',
91
+ `${text}\nreturn typeof createBlenderModule === 'function' ? createBlenderModule : module.exports;`,
92
+ ) as (module: unknown, exports: unknown, define: unknown) => BlenderModuleFactory;
93
+ const factory = evaluate(container, container.exports, undefined);
94
+ if (typeof factory !== 'function')
95
+ throw new Error('blender_browser.js did not produce a module factory');
96
+ return factory;
97
+ }
98
+
99
+ const utf8 = new TextEncoder();
100
+
101
+ function mkdirp(module: BlenderModule, directory: string): void {
102
+ let current = '';
103
+ for (const part of directory.split('/').filter(Boolean)) {
104
+ module.FS_createPath(current || '/', part, true, true);
105
+ current += `/${part}`;
106
+ }
107
+ }
108
+
109
+ /** `Module.FS`, as {@link BlenderFiles}. Every call is synchronous underneath;
110
+ * the promises are the seam's, for the skew that cannot be synchronous. */
111
+ function moduleFiles(module: BlenderModule): BlenderFiles {
112
+ const FS = module.FS;
113
+ return {
114
+ readFile: async (path) => FS.readFile(path),
115
+ writeFile: async (path, data) => FS.writeFile(path, data),
116
+ mkdirTree: async (path) => FS.mkdirTree(path),
117
+ readdir: async (path) => FS.readdir(path),
118
+ stat: async (path) => {
119
+ let info: { size: number; mode?: number; mtime?: Date | number };
120
+ try {
121
+ info = FS.stat(path);
122
+ } catch {
123
+ return null;
124
+ }
125
+ const mtime = info.mtime;
126
+ return {
127
+ size: info.size,
128
+ mode: info.mode ?? 0,
129
+ mtimeMs: typeof mtime === 'number' ? mtime : (mtime?.getTime() ?? 0),
130
+ };
131
+ },
132
+ unlink: async (path) => FS.unlink(path),
133
+ };
134
+ }
135
+
136
+ export async function startEmscriptenBlenderEngine(
137
+ options: BlenderEngineOptions,
138
+ _status: BlenderArtifactStatus,
139
+ ): Promise<BlenderEngine> {
140
+ if (!self.crossOriginIsolated)
141
+ throw new Error(
142
+ 'Headless Blender needs SharedArrayBuffer, so the editor page has to be cross-origin ' +
143
+ 'isolated (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy on ' +
144
+ 'every response). This worker reports crossOriginIsolated=false.',
145
+ );
146
+ const glueUrl = artifactUrl('blender_browser.js');
147
+ const factory = await loadFactory(glueUrl);
148
+ const started = performance.now();
149
+ let readyLine: string | null = null;
150
+ // BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS.
151
+ // Measured on the first live run: routing `printErr` at `error` put every
152
+ // ordinary startup line -- the WebGPU preinit notice, a Python
153
+ // DeprecationWarning, the ready banner itself -- into the session's
154
+ // unresolved console set, which is the set an agent must drive to zero. The
155
+ // editor's console door is for conditions somebody must resolve, so only the
156
+ // session's OWN named conditions go there.
157
+ const say = (_level: 'log' | 'error', text: string) => {
158
+ if (text.startsWith('@@VGAI-READY ')) readyLine = text.slice('@@VGAI-READY '.length);
159
+ options.log(
160
+ text.startsWith('@@VGAI-WARN') || text.startsWith('@@VGAI-ERROR') ? 'error' : 'log',
161
+ text,
162
+ );
163
+ };
164
+ const module = await factory({
165
+ arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
166
+ locateFile: (file: string) => artifactUrl(file),
167
+ // The pthreads load the SAME patched glue this worker just evaluated.
168
+ mainScriptUrlOrBlob: glueUrl,
169
+ print: (text: string) => say('log', text),
170
+ printErr: (text: string) => say('error', text),
171
+ preRun: [
172
+ (mod: BlenderModule) => {
173
+ mod.ENV ??= {};
174
+ Object.assign(mod.ENV, {
175
+ BLENDER_SYSTEM_RESOURCES: '/bw',
176
+ BLENDER_SYSTEM_PYTHON: '/bw/python',
177
+ BLENDER_SYSTEM_SCRIPTS: '/bw/scripts',
178
+ BLENDER_SYSTEM_DATAFILES: '/bw/datafiles',
179
+ VGAI_SESSION_ROOT: SESSION_ROOT,
180
+ HOME: '/root',
181
+ TMPDIR: '/tmp',
182
+ });
183
+ // VGAI_EXPORT_BUFFER_PATH IS DELIBERATELY UNSET. This engine reads the
184
+ // arena off `HEAPU8`; asking the door to also write it to a file would
185
+ // cost this skew a megabyte-scale write per present for bytes it
186
+ // already has (`session.py`, EXPORT_BUFFER_PATH).
187
+ // `in` and `reply` are this page's under the channel's ownership rule
188
+ // (`blender-engine.mts`); `session.py` makes `out` and `ask`, which
189
+ // are its own.
190
+ for (const directory of [
191
+ '/tmp',
192
+ '/root',
193
+ '/work',
194
+ SESSION_ROOT,
195
+ ...PAGE_OWNED_DIRECTORIES,
196
+ options.project,
197
+ ])
198
+ mkdirp(mod, directory);
199
+ mod.FS_createDataFile(
200
+ SESSION_ROOT,
201
+ 'session.py',
202
+ utf8.encode(sessionPython),
203
+ true,
204
+ true,
205
+ true,
206
+ );
207
+ },
208
+ ],
209
+ });
210
+ // `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
211
+ // long before the session exists. The ready line is what says it does.
212
+ while (readyLine === null) await sleep(5);
213
+ const bootMs = performance.now() - started;
214
+ const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
215
+
216
+ // THE PAYLOAD EXISTS TWICE UNTIL THIS CALL. `--preload-file` reads the whole
217
+ // `.data` package into one ArrayBuffer, WasmFS copies every file out of it
218
+ // into the wasm heap during its own init, and then emscripten keeps the
219
+ // subarrays forever in `wasmFSPreloadedFiles` -- so a booted session holds
220
+ // ~55 MB of JS-side bytes that are already in linear memory and will never
221
+ // be read again. Measured 2026-09-18: Node's `arrayBuffers` sat at 55 MB
222
+ // from `factory resolved` through every later call, and two forced GCs did
223
+ // not move it. Here is the one moment it is safe to drop: WasmFS flushed
224
+ // long before the ready line, and anything staged after this takes
225
+ // `FS_createDataFile`'s post-flush branch, which never consults the array.
226
+ let releasedPayloadBytes: number | null = null;
227
+ if (typeof module.releasePreloadedFileData === 'function') {
228
+ const released = module.releasePreloadedFileData();
229
+ releasedPayloadBytes = released.bytes;
230
+ options.log(
231
+ 'log',
232
+ `blender: released the preloaded ${(released.bytes / 1048576).toFixed(0)} MB ` +
233
+ `payload (${released.files} files); WasmFS already holds it`,
234
+ );
235
+ } else {
236
+ options.log(
237
+ 'error',
238
+ 'blender: this bundle has no releasePreloadedFileData, so the ~55 MB packed ' +
239
+ '.data payload stays resident beside the copy WasmFS made of it — relink with ' +
240
+ "the recipe's --post-js (recipe/release-preloaded-file-data.js).",
241
+ );
242
+ }
243
+
244
+ const FS = module.FS;
245
+
246
+ // A DIRECTORY MADE BY THE PRELOAD DOOR IS NOT WRITABLE. `FS_createPath`
247
+ // ignores its `canWrite` argument in this build, so the project root and
248
+ // everything above it come out read-only and the first `mkdirTree` under
249
+ // them raises `ErrnoError` -- measured on the first live round trip
250
+ // (2026-09-17: `stageProjectFiles` threw before any project file was
251
+ // staged). The session chmods its own root from Python for the same reason;
252
+ // this is the other half, for the paths Python never touches.
253
+ let climb = '';
254
+ for (const part of `${options.project}`.split('/').filter(Boolean)) {
255
+ climb += `/${part}`;
256
+ try {
257
+ FS.chmod(climb, 0o777);
258
+ } catch {
259
+ /* a path the module does not hold is not this loop's business */
260
+ }
261
+ }
262
+ for (const directory of ['/work', '/tmp', '/root', SESSION_ROOT, ...PAGE_OWNED_DIRECTORIES])
263
+ try {
264
+ FS.chmod(directory, 0o777);
265
+ } catch {
266
+ /* the session has already taken the ones it owns */
267
+ }
268
+
269
+ const files = moduleFiles(module);
270
+ const { request } = openSessionChannel(files, options);
271
+
272
+ return {
273
+ skew: 'emscripten',
274
+ banner,
275
+ files,
276
+ request,
277
+ // A VIEW, NOT A COPY. The arena is wasm linear memory and under
278
+ // `-sPROXY_TO_PTHREAD` that memory is SHARED, so `session-frame.mts`
279
+ // slices every column it reads out of here; this call itself is free.
280
+ readArena: async () =>
281
+ module.HEAPU8.subarray(
282
+ module._blender_web_export_buffer(),
283
+ module._blender_web_export_buffer() + module._blender_web_export_buffer_size(),
284
+ ),
285
+ bootMs,
286
+ memoryBytes: () => module.HEAPU8.length,
287
+ releasedPayloadBytes,
288
+ };
289
+ }