@volter/blender-engine 0.1.7 → 0.1.8
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-emscripten-engine.mts +57 -6
- package/browser/blender-engine.mts +2 -0
- package/browser/runtime.ts +5 -5
- package/browser/session.py +24 -15
- package/browser/three/blender-graph-material.ts +6 -2
- package/browser/three/blender-physical-material.ts +11 -4
- package/browser/worker.ts +3 -0
- package/package.json +1 -1
|
@@ -133,9 +133,37 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
|
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD. The wasm (86 MB decoded), the
|
|
138
|
+
* `.data` package (53 MB) and the Essentials payload are immutable for a build,
|
|
139
|
+
* and the page keeps them in Cache Storage under the digest the artifact door
|
|
140
|
+
* reports, so a later open reads them at the browser's own speed instead of
|
|
141
|
+
* from the editor's server. Measured in the browser substrate, where that
|
|
142
|
+
* server runs in the tab: the three took 7.2 s per open (4.2 s for the wasm
|
|
143
|
+
* alone), about half of it decoding their brotli. A file the door gives no
|
|
144
|
+
* digest for is fetched as it always was.
|
|
145
|
+
*/
|
|
146
|
+
const ARTIFACT_CACHE = 'volter-blender-artifacts';
|
|
147
|
+
|
|
148
|
+
async function cachedArtifact(file: string, digest: string | undefined): Promise<Response> {
|
|
149
|
+
const url = artifactUrl(file);
|
|
150
|
+
if (!digest || typeof caches === 'undefined') return fetch(url);
|
|
151
|
+
const key = `${url}?sha256=${digest}`;
|
|
152
|
+
const cache = await caches.open(ARTIFACT_CACHE);
|
|
153
|
+
const hit = await cache.match(key);
|
|
154
|
+
if (hit) return hit;
|
|
155
|
+
const response = await fetch(url);
|
|
156
|
+
if (!response.ok) return response;
|
|
157
|
+
// One build's bytes per file: an older build's are dropped as this one lands.
|
|
158
|
+
for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
|
|
159
|
+
const kept = new Response(await response.blob(), { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
|
|
160
|
+
await cache.put(key, kept.clone());
|
|
161
|
+
return kept;
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
export async function startEmscriptenBlenderEngine(
|
|
137
165
|
options: BlenderEngineOptions,
|
|
138
|
-
|
|
166
|
+
status: BlenderArtifactStatus,
|
|
139
167
|
): Promise<BlenderEngine> {
|
|
140
168
|
if (!self.crossOriginIsolated)
|
|
141
169
|
throw new Error(
|
|
@@ -144,7 +172,17 @@ export async function startEmscriptenBlenderEngine(
|
|
|
144
172
|
'every response). This worker reports crossOriginIsolated=false.',
|
|
145
173
|
);
|
|
146
174
|
const glueUrl = artifactUrl('blender_browser.js');
|
|
147
|
-
const
|
|
175
|
+
const digests = status.digests ?? {};
|
|
176
|
+
// The `.data` package is handed to the glue whole (`getPreloadedPackage`),
|
|
177
|
+
// so it is read before the module starts; the wasm streams in beside it.
|
|
178
|
+
const [factory, preloaded] = await Promise.all([
|
|
179
|
+
loadFactory(glueUrl),
|
|
180
|
+
cachedArtifact('blender_browser.data', digests['blender_browser.data']).then(async (response) => {
|
|
181
|
+
if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
|
|
182
|
+
return response.arrayBuffer();
|
|
183
|
+
}),
|
|
184
|
+
]);
|
|
185
|
+
let bootError: unknown = null;
|
|
148
186
|
const started = performance.now();
|
|
149
187
|
let readyLine: string | null = null;
|
|
150
188
|
// BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS.
|
|
@@ -164,6 +202,16 @@ export async function startEmscriptenBlenderEngine(
|
|
|
164
202
|
const module = await factory({
|
|
165
203
|
arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
|
|
166
204
|
locateFile: (file: string) => artifactUrl(file),
|
|
205
|
+
getPreloadedPackage: () => preloaded,
|
|
206
|
+
instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
|
|
207
|
+
void (async () => {
|
|
208
|
+
const response = await cachedArtifact('blender_browser.wasm', digests['blender_browser.wasm']);
|
|
209
|
+
if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
|
|
210
|
+
const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
|
|
211
|
+
receive(instance, compiled);
|
|
212
|
+
})().catch((error: unknown) => { bootError = error; });
|
|
213
|
+
return {};
|
|
214
|
+
},
|
|
167
215
|
// The pthreads load the SAME patched glue this worker just evaluated.
|
|
168
216
|
mainScriptUrlOrBlob: glueUrl,
|
|
169
217
|
print: (text: string) => say('log', text),
|
|
@@ -209,7 +257,10 @@ export async function startEmscriptenBlenderEngine(
|
|
|
209
257
|
});
|
|
210
258
|
// `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
|
|
211
259
|
// long before the session exists. The ready line is what says it does.
|
|
212
|
-
while (readyLine === null)
|
|
260
|
+
while (readyLine === null) {
|
|
261
|
+
if (bootError) throw bootError instanceof Error ? bootError : new Error(String(bootError));
|
|
262
|
+
await sleep(5);
|
|
263
|
+
}
|
|
213
264
|
const bootMs = performance.now() - started;
|
|
214
265
|
const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
|
|
215
266
|
|
|
@@ -268,7 +319,7 @@ export async function startEmscriptenBlenderEngine(
|
|
|
268
319
|
|
|
269
320
|
const files = moduleFiles(module);
|
|
270
321
|
FS.chmod('/bw/datafiles', 0o755);
|
|
271
|
-
await mountEssentials(files);
|
|
322
|
+
await mountEssentials(files, digests['essentials.bin']);
|
|
272
323
|
const { request } = openSessionChannel(files, options);
|
|
273
324
|
|
|
274
325
|
return {
|
|
@@ -293,9 +344,9 @@ export async function startEmscriptenBlenderEngine(
|
|
|
293
344
|
/** Assets are data, not a second engine. Ship them separately so a data update
|
|
294
345
|
* does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
|
|
295
346
|
* checked before anything enters Blender's filesystem. */
|
|
296
|
-
async function mountEssentials(files: BlenderFiles): Promise<void> {
|
|
347
|
+
async function mountEssentials(files: BlenderFiles, digest: string | undefined): Promise<void> {
|
|
297
348
|
const [indexResponse, payloadResponse] = await Promise.all([
|
|
298
|
-
fetch(artifactUrl('essentials.json')),
|
|
349
|
+
fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest),
|
|
299
350
|
]);
|
|
300
351
|
if (!indexResponse.ok || !payloadResponse.ok)
|
|
301
352
|
throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
|
|
@@ -179,6 +179,8 @@ export interface BlenderArtifactStatus {
|
|
|
179
179
|
encoded: Record<string, 'br'>;
|
|
180
180
|
/** Why it is unavailable, named. Empty when it is available. */
|
|
181
181
|
missing: string[];
|
|
182
|
+
/** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
|
|
183
|
+
digests?: Record<string, string>;
|
|
182
184
|
/** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
|
|
183
185
|
* requires for any non-blob URL. */
|
|
184
186
|
integrity?: string;
|
package/browser/runtime.ts
CHANGED
|
@@ -434,10 +434,10 @@ export class BlenderRuntime {
|
|
|
434
434
|
if (this.#terminated) return Promise.resolve();
|
|
435
435
|
if (this.#stopping) return this.#stopping;
|
|
436
436
|
this.#stopping = (async () => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
437
|
+
// A start that failed opened no document, so there is nothing to flush: its stale
|
|
438
|
+
// rejection must not refuse every later close.
|
|
439
|
+
const started = this.#started ? await this.#started.then(() => true, () => false) : false;
|
|
440
|
+
if (started) await this.#request({ op: 'flush-document' }, true);
|
|
441
441
|
this.terminate();
|
|
442
442
|
})().catch(error => {
|
|
443
443
|
this.#stopping = null;
|
|
@@ -470,7 +470,7 @@ export class BlenderRuntime {
|
|
|
470
470
|
* A `present` is not counted separately — the worker only presents from
|
|
471
471
|
* inside a call this meter is already holding open, and the page-side cost of
|
|
472
472
|
* displaying the frame is main-thread time, which is the long-task observer's
|
|
473
|
-
* subject (`
|
|
473
|
+
* subject (the host's `worker-call-metrics.ts`).
|
|
474
474
|
*/
|
|
475
475
|
metrics(now: number = performance.now()): BlenderCallMetrics {
|
|
476
476
|
let oldest: number | null = null;
|
package/browser/session.py
CHANGED
|
@@ -1343,16 +1343,15 @@ class Session:
|
|
|
1343
1343
|
# its value also depends on native undo initialization/checkpoints.
|
|
1344
1344
|
"dirty": bool(bpy.data.is_dirty),
|
|
1345
1345
|
}
|
|
1346
|
-
# A PRESENT
|
|
1347
|
-
#
|
|
1348
|
-
#
|
|
1349
|
-
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1352
|
-
#
|
|
1353
|
-
#
|
|
1354
|
-
|
|
1355
|
-
self.save_due = True
|
|
1346
|
+
# A PRESENT DOES NOT MAKE THE DOCUMENT STALE; a request that can change
|
|
1347
|
+
# the model does (`mark_changed`, from `dispatch`). The predicate was the
|
|
1348
|
+
# present itself, and a present also runs with no change behind it --
|
|
1349
|
+
# opening the document, rebinding its view. MEASURED 2026-09-24 on
|
|
1350
|
+
# `arena`: opening a Model document and doing nothing rewrote its
|
|
1351
|
+
# `.blend` with 556 bytes changed, so a file a person only looked at read
|
|
1352
|
+
# as modified. `columns`/`images` above stay the wrong question for the
|
|
1353
|
+
# same reason as before: a MOVE ships zero columns and a DELETION ships
|
|
1354
|
+
# nothing, and both come from a request `mark_changed` sees.
|
|
1356
1355
|
for key, mesh in frame["meshes"].items():
|
|
1357
1356
|
self._known["mesh:" + key] = mesh["revision"]
|
|
1358
1357
|
for name, image in frame["images"].items():
|
|
@@ -1540,6 +1539,12 @@ class Session:
|
|
|
1540
1539
|
"objects": len(bpy.data.objects),
|
|
1541
1540
|
"size": os.path.getsize(self.document)}
|
|
1542
1541
|
|
|
1542
|
+
def mark_changed(self):
|
|
1543
|
+
"""A request that can change the model ran: the next present carries
|
|
1544
|
+
`saveDue`, and the tab saves after an idle second."""
|
|
1545
|
+
if self.document is not None:
|
|
1546
|
+
self.save_due = True
|
|
1547
|
+
|
|
1543
1548
|
def save_document(self):
|
|
1544
1549
|
"""Write the document -- Blender's own format, by Blender's own operator.
|
|
1545
1550
|
|
|
@@ -1625,11 +1630,11 @@ class Session:
|
|
|
1625
1630
|
#
|
|
1626
1631
|
# It was briefly used to skip a redundant write, and it froze the
|
|
1627
1632
|
# document after its first save -- a reopened session could model all
|
|
1628
|
-
# day and never write again. The predicate is `
|
|
1629
|
-
#
|
|
1630
|
-
# script rewrites the file once), bounded by the tab's
|
|
1631
|
-
# debounce, and it cannot miss a change. The reading still
|
|
1632
|
-
# answer, as an instrument.
|
|
1633
|
+
# day and never write again. The predicate is `mark_changed`'s instead:
|
|
1634
|
+
# a request that can change the model ran. Coarser than a diff (a
|
|
1635
|
+
# read-only script rewrites the file once), bounded by the tab's
|
|
1636
|
+
# idle-second debounce, and it cannot miss a change. The reading still
|
|
1637
|
+
# rides in the answer, as an instrument.
|
|
1633
1638
|
dirty = bool(bpy.data.is_dirty)
|
|
1634
1639
|
existed = os.path.exists(self.document)
|
|
1635
1640
|
directory = os.path.dirname(self.document)
|
|
@@ -4640,6 +4645,7 @@ def dispatch(request):
|
|
|
4640
4645
|
if op == "history-step":
|
|
4641
4646
|
if request["direction"] not in ("undo", "redo"):
|
|
4642
4647
|
raise ValueError("Unknown history direction")
|
|
4648
|
+
SESSION.mark_changed()
|
|
4643
4649
|
return HISTORY.move(request["token"], request["direction"])
|
|
4644
4650
|
mutation = op in ("execute", "rna-set", "outliner-set") and request.get("history", True)
|
|
4645
4651
|
if not mutation:
|
|
@@ -4675,6 +4681,7 @@ def _dispatch(request):
|
|
|
4675
4681
|
# Arbitrary Python can partially mutate before throwing. Once execution
|
|
4676
4682
|
# starts it needs a checkpoint; capability refusals above do not.
|
|
4677
4683
|
HISTORY.changed()
|
|
4684
|
+
SESSION.mark_changed()
|
|
4678
4685
|
answer = execute(request["code"])
|
|
4679
4686
|
# Every mutation is presented, the rule: the Model
|
|
4680
4687
|
# document is what the agent is looking at.
|
|
@@ -4723,6 +4730,7 @@ def _dispatch(request):
|
|
|
4723
4730
|
if op == "outliner":
|
|
4724
4731
|
return rna_outliner(request.get("selected"))
|
|
4725
4732
|
if op == "outliner-set":
|
|
4733
|
+
SESSION.mark_changed()
|
|
4726
4734
|
answer = outliner_set(request["path"], request["column"], request["value"])
|
|
4727
4735
|
# A COLUMN IS A WRITE, and a write presents -- the same rule `rna-set`
|
|
4728
4736
|
# follows: hiding an object in the Outliner must change what the
|
|
@@ -4743,6 +4751,7 @@ def _dispatch(request):
|
|
|
4743
4751
|
"showing the state before it: " + repr(thrown))
|
|
4744
4752
|
return answer
|
|
4745
4753
|
if op == "rna-set":
|
|
4754
|
+
SESSION.mark_changed()
|
|
4746
4755
|
answer = rna_set(request["path"], request["property"], request["value"],
|
|
4747
4756
|
request.get("index"))
|
|
4748
4757
|
# A WRITE IS PRESENTED, exactly as `execute` presents: the Model
|
|
@@ -143,6 +143,11 @@ export function graphProgramKey(material: THREE.MeshPhysicalMaterial): string {
|
|
|
143
143
|
return `${binding.compiled.key}|${JSON.stringify(binding.channels)}`;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** The UV channels the material's graph reads on the current draw. */
|
|
147
|
+
export function graphUvChannels(material: THREE.MeshPhysicalMaterial): number[] {
|
|
148
|
+
return Object.values(bindings.get(material)?.channels ?? {});
|
|
149
|
+
}
|
|
150
|
+
|
|
146
151
|
/** The channel each named UV layer is in on `geometry` (a layer the mesh
|
|
147
152
|
* lacks reads zeros from channel 9, Blender's missing attribute). */
|
|
148
153
|
function channelsFor(compiled: CompiledGraph, geometry: THREE.BufferGeometry): Record<string, number> {
|
|
@@ -305,7 +310,6 @@ export function applyGraphShader(material: THREE.MeshPhysicalMaterial, shader: T
|
|
|
305
310
|
const {compiled, channels} = binding;
|
|
306
311
|
Object.assign(shader.uniforms, binding.uniforms, {blenderViewport: {value: viewport}});
|
|
307
312
|
const attribute = (channel: number) => (channel === 0 ? 'uv' : `uv${channel}`);
|
|
308
|
-
const guards = [1, 2, 3].map(i => `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif`).join('\n');
|
|
309
313
|
const varyings = `varying vec3 vBlenderObjectPosition;
|
|
310
314
|
varying vec3 vBlenderObjectNormal;
|
|
311
315
|
varying vec3 vBlenderWorldPosition;
|
|
@@ -314,7 +318,7 @@ varying vec3 vBlenderOrco;
|
|
|
314
318
|
${compiled.attributes.map(n => `varying vec4 ${attributeVarying(n)};`).join('\n')}
|
|
315
319
|
${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
|
|
316
320
|
const attributes = compiled.attributes.map(n => `attribute vec4 ${graphAttributeName(n)};`).join('\n');
|
|
317
|
-
shader.vertexShader =
|
|
321
|
+
shader.vertexShader = `attribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
|
|
318
322
|
'#include <project_vertex>',
|
|
319
323
|
`vBlenderObjectPosition = transformed;
|
|
320
324
|
vBlenderObjectNormal = objectNormal;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* that MeshPhysicalMaterial does not expose as properties. */
|
|
5
5
|
import * as THREE from 'three';
|
|
6
6
|
import {z} from 'zod';
|
|
7
|
-
import {applyGraphShader, bindGraphDraw, graphProgramKey, setGraphViewport} from './blender-graph-material';
|
|
7
|
+
import {applyGraphShader, bindGraphDraw, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
|
|
8
8
|
import {bindNamedUvChannels} from './blender-texture-samplers';
|
|
9
9
|
|
|
10
10
|
const scalar = z.number().finite();
|
|
@@ -85,9 +85,16 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
|
|
|
85
85
|
setGraphViewport(renderer);
|
|
86
86
|
};
|
|
87
87
|
material.onBeforeCompile = shader => {
|
|
88
|
-
// Three declares channels
|
|
89
|
-
//
|
|
90
|
-
|
|
88
|
+
// Three declares channels 1..3 only for its own maps, and none past 3.
|
|
89
|
+
// Blender has eight named UV maps beside the default channel (9 is the
|
|
90
|
+
// missing layer's zeros). Declare only the channels this draw reads: a
|
|
91
|
+
// driver may count every declared attribute against its sixteen, and
|
|
92
|
+
// six idle ones leave a graph with a few attributes no room.
|
|
93
|
+
const read = new Set([material.map, material.roughnessMap, material.normalMap]
|
|
94
|
+
.flatMap(texture => (texture ? [texture.channel] : [])).concat(graphUvChannels(material)));
|
|
95
|
+
shader.vertexShader = [...read].filter(i => i > 0).sort((a, b) => a - b)
|
|
96
|
+
.map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
|
|
97
|
+
.join('\n') + '\n' + shader.vertexShader;
|
|
91
98
|
Object.assign(shader.uniforms, held);
|
|
92
99
|
shader.fragmentShader = `uniform float blenderCoatIor;
|
|
93
100
|
uniform vec3 blenderWorldExtinction;
|
package/browser/worker.ts
CHANGED
|
@@ -458,6 +458,9 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
458
458
|
const ask = engine.request.bind(engine);
|
|
459
459
|
switch (request.op) {
|
|
460
460
|
case 'flush-document':
|
|
461
|
+
// A flush lands what is pending; a document nothing changed stays the
|
|
462
|
+
// file it was opened from.
|
|
463
|
+
if (!documentDirty) return { saved: false };
|
|
461
464
|
await saveDocument();
|
|
462
465
|
return { saved: true };
|
|
463
466
|
case 'history-begin':
|