@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.
@@ -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
- _status: BlenderArtifactStatus,
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 factory = await loadFactory(glueUrl);
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) await sleep(5);
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')), fetch(artifactUrl('essentials.bin')),
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;
@@ -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
- if (this.#started) {
438
- await this.#started;
439
- await this.#request({ op: 'flush-document' }, true);
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 (`packages/editor/src/blender-tab-metrics.ts`).
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;
@@ -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 IS WHAT LEAVES THE DOCUMENT STALE, and the predicate is the
1347
- # present itself rather than what the frame shipped. `columns`/`images`
1348
- # above count BYTES THAT CROSSED, which is a different question: a MOVE
1349
- # ships zero columns (the comment above says so) and a DELETION ships
1350
- # nothing at all, yet both are changes a document that loses them is
1351
- # wrong. The cost this predicate could waste is bounded by the tab's
1352
- # one-save-per-idle-second debounce, and `save_document` still asks
1353
- # Blender whether anything actually changed before it writes.
1354
- if self.document is not None:
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 `_present`'s instead: a
1629
- # present is what leaves the document stale. Coarser (a read-only
1630
- # script rewrites the file once), bounded by the tab's idle-second
1631
- # debounce, and it cannot miss a change. The reading still rides in the
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 = `${guards}\nattribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
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 0..3. Blender has eight named UV maps in
89
- // addition to the default channel; only the used attributes survive GLSL.
90
- shader.vertexShader = [4, 5, 6, 7, 8, 9].map(i => `attribute vec2 uv${i};`).join('\n') + '\n' + shader.vertexShader;
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':
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.7",
5
+ "version": "0.1.8",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },