@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.
- package/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- 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
|
+
}
|