@volter/blender-engine 0.1.127 → 0.1.129

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.
@@ -1,16 +1,13 @@
1
1
  /**
2
- * Blender's MULTIPLE SCATTERING sky, transcribed from the pinned source.
2
+ * Blender's scattering skies, transcribed from the pinned source.
3
3
  *
4
4
  * `intern/sky/source/sky_multiple_scattering.cpp` precomputes a 512x256 XYZ
5
5
  * texture and `gpu_shader_material_tex_sky.glsl`'s `node_tex_sky_nishita` reads
6
6
  * it back per direction; both halves are here because both are what a script
7
- * sees through `ShaderNodeTexSky`.
8
- *
9
- * This is the MULTIPLE scattering model, not Nishita's single scattering. The
10
- * courtyard's own recording is why: its seq 31 sets `sky_type = 'NISHITA'` and
11
- * native REFUSES -- `enum "NISHITA" not found in ('SINGLE_SCATTERING',
12
- * 'MULTIPLE_SCATTERING', 'PREETHAM', 'HOSEK_WILKIE')` -- so the sky it renders
13
- * is the untouched default, which is MULTIPLE_SCATTERING.
7
+ * sees through `ShaderNodeTexSky`. The single-scattering atmosphere is in
8
+ * `blender-single-scattering-sky.ts`; model selection, caching and the shared
9
+ * GPU lookup convention live here. Each model keeps its own native elevation
10
+ * and azimuth conventions.
14
11
  *
15
12
  * The constants and the arithmetic are the source's. The XYZ-to-scene-linear
16
13
  * matrix is not in the source -- it comes from the colour configuration -- and
@@ -18,6 +15,7 @@
18
15
  * `Linear CIE-XYZ D65`.
19
16
  */
20
17
  import * as THREE from 'three';
18
+ import {precomputeSingleScatteringSky} from './blender-single-scattering-sky';
21
19
 
22
20
  const M_PI_2_F = 1.5707963267948966;
23
21
  const M_2PI_F = 6.2831853071795864;
@@ -104,6 +102,8 @@ const aerosolDensity = (h: number): number =>
104
102
  (Math.exp(-h / AEROSOL_HEIGHT_SCALE) + AEROSOL_BACKGROUND_DENSITY / AEROSOL_BASE_DENSITY);
105
103
 
106
104
  export interface SkyParameters {
105
+ /** Omitted in older frames, whose only supported model was multiple scattering. */
106
+ model?: 'SINGLE_SCATTERING' | 'MULTIPLE_SCATTERING';
107
107
  sunElevation: number;
108
108
  sunRotation: number;
109
109
  altitude: number;
@@ -363,16 +363,17 @@ function precomputeTexture(p: SkyParameters, sunElevation: number): Float32Array
363
363
  * post the Float32Array back. See `contributions/sky-precompute-worker.ts`.
364
364
  */
365
365
  export function precomputeSkyTexture(p: SkyParameters): Float32Array {
366
+ if (p.model === 'SINGLE_SCATTERING') return precomputeSingleScatteringSky(p);
366
367
  const [elevation] = simplify(p.sunElevation, p.sunRotation);
367
368
  return precomputeTexture(p, elevation);
368
369
  }
369
370
 
370
- /** The identity of a precomputed sky: every parameter the texture depends on.
371
- * Rotation is NOT one of them -- `simplify` can fold half a turn of elevation
372
- * into it, so it is folded here too and the texture keyed on what is left. */
371
+ /** Texture identity: model, native elevation, altitude and three densities.
372
+ * Rotation only changes the lookup. Multiple scattering folds elevation;
373
+ * single scattering keeps it as supplied to Blender's precompute. */
373
374
  export function skyTextureKey(p: SkyParameters): string {
374
- const [elevation] = simplify(p.sunElevation, p.sunRotation);
375
- return JSON.stringify([elevation, p.altitude, p.airDensity, p.aerosolDensity, p.ozoneDensity]);
375
+ const elevation = p.model === 'SINGLE_SCATTERING' ? p.sunElevation : simplify(p.sunElevation, p.sunRotation)[0];
376
+ return JSON.stringify([p.model ?? 'MULTIPLE_SCATTERING', elevation, p.altitude, p.airDensity, p.aerosolDensity, p.ozoneDensity]);
376
377
  }
377
378
 
378
379
  /** Precomputed sky textures, however they were derived. `skyField` is
@@ -410,11 +411,14 @@ export function primeSkyTexture(p: SkyParameters, pixels: Float32Array): void {
410
411
  * who cares about the main thread primes the cache first.
411
412
  */
412
413
  export function skyField(p: SkyParameters): (direction: THREE.Vector3) => THREE.Vector3 {
413
- const [elevation, rotation] = simplify(p.sunElevation, p.sunRotation);
414
+ // Single scattering only wraps azimuth; it does not fold sun elevation.
415
+ const rotation = p.model === 'SINGLE_SCATTERING'
416
+ ? M_2PI_F - ((p.sunRotation % M_2PI_F + M_2PI_F) % M_2PI_F)
417
+ : simplify(p.sunElevation, p.sunRotation)[1];
414
418
  const key = skyTextureKey(p);
415
419
  let pixels = skyTextures.get(key);
416
420
  if (pixels === undefined) {
417
- pixels = precomputeTexture(p, elevation);
421
+ pixels = precomputeSkyTexture(p);
418
422
  primeSkyTexture(p, pixels);
419
423
  }
420
424
  const colour = new THREE.Vector3();
@@ -1,3 +1,4 @@
1
+ import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from './blender-display-transform';
1
2
  /**
2
3
  * THE PRESENTER FOR A HOST WITHOUT A STAGE (WS-AC, cut 1b).
3
4
  *
@@ -15,16 +16,12 @@
15
16
  * What it does NOT do, on purpose: overlays (modeling chrome, hidden for a
16
17
  * render anyway), selection outlines, the editor's supersampled `captureImage`
17
18
  * for the Standard transform. Every transform here goes through the linear
18
- * capture and the same encoders the editor uses for AgX and Filmic.
19
+ * capture and the same OCIO GPU display processors the editor uses.
19
20
  */
20
21
  import * as THREE from 'three';
21
- import type { CaptureRequest, RenderRequest } from '../protocol';
22
+ import type { CaptureRequest } from '../protocol';
22
23
  import type { PresentAnswer } from '../runtime';
23
- import { AGX_LOOK_TABLES, agxEncodeFrame } from './blender-agx';
24
- import { displayTableUrl } from './blender-display-lut';
25
- import { filmicEncodeFrame } from './blender-filmic';
26
24
  import { BlenderRuntimeView } from './blender-runtime-view';
27
- import { standardEncodeFrame } from './blender-standard';
28
25
 
29
26
  export interface PresenterOptions {
30
27
  /** The canvas to render into; an OffscreenCanvas of 1x1 when absent. */
@@ -45,35 +42,6 @@ export interface Presenter {
45
42
 
46
43
  const MAXIMUM_EDGE = 2048;
47
44
 
48
- const displayTables = new Map<string, Promise<Uint16Array>>();
49
- function displayTable(file: string): Promise<Uint16Array> {
50
- let pending = displayTables.get(file);
51
- if (!pending) {
52
- pending = fetch(displayTableUrl(file)).then(async (response) => {
53
- if (!response.ok)
54
- throw new Error(`Blender display table ${file}: ${response.status} ${response.statusText}`);
55
- return new Uint16Array(await response.arrayBuffer());
56
- });
57
- displayTables.set(file, pending);
58
- }
59
- return pending;
60
- }
61
-
62
- function displayTableFor(render: RenderRequest): string | null {
63
- const look = render.look ?? 'None';
64
- if (render.toneMapping === 'none') return null;
65
- if (render.toneMapping === 'filmic') return 'filmic-srgb.lut';
66
- if (render.toneMapping !== 'agx')
67
- throw new Error(
68
- `Blender's Khronos PBR Neutral view transform has no scene-linear implementation in the ` +
69
- `browser (implemented: Standard, AgX, Filmic)`,
70
- );
71
- if (look === 'None') return 'agx-base-srgb.lut';
72
- const file = AGX_LOOK_TABLES[look as keyof typeof AGX_LOOK_TABLES];
73
- if (file === undefined) throw new Error(`Blender display transform has no table for look ${look}`);
74
- return file;
75
- }
76
-
77
45
  async function pngBase64(bytes: Uint8Array | Uint8ClampedArray, width: number, height: number): Promise<string> {
78
46
  // The linear capture is bottom-up (GL); the image is top-down.
79
47
  const stride = width * 4;
@@ -178,18 +146,11 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
178
146
  render: { width, height, fov: render.fov, orthographic: render.orthographic },
179
147
  });
180
148
 
181
- const tableFile = displayTableFor(render);
182
- const table = tableFile ? await displayTable(tableFile) : null;
183
- const mappings: Record<string, THREE.ToneMapping> = {
184
- none: THREE.NoToneMapping,
185
- agx: THREE.AgXToneMapping,
186
- neutral: THREE.NeutralToneMapping,
187
- filmic: THREE.NoToneMapping,
188
- };
149
+ const displayTransform = await createBlenderDisplayTransform(blenderDisplaySettingsForRender(render));
189
150
  const previousMapping = renderer.toneMapping;
190
151
  const previousExposure = renderer.toneMappingExposure;
191
- renderer.toneMapping = mappings[render.toneMapping] ?? THREE.AgXToneMapping;
192
- renderer.toneMappingExposure = render.exposure;
152
+ renderer.toneMapping = THREE.NoToneMapping;
153
+ renderer.toneMappingExposure = 1;
193
154
  const sceneTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
194
155
  let effect: ReturnType<BlenderRuntimeView['createWorldVolumePass']>;
195
156
  try {
@@ -217,16 +178,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
217
178
  renderer.readRenderTargetPixels(resolved, 0, 0, width, height, pixels);
218
179
  renderer.setRenderTarget(null);
219
180
  }
220
- const count = width * height;
221
- const bytes =
222
- render.toneMapping === 'none'
223
- ? standardEncodeFrame(pixels, count, render.exposure)
224
- : render.toneMapping === 'filmic'
225
- ? filmicEncodeFrame(table!, pixels, count, render.exposure)
226
- : agxEncodeFrame(table!, pixels, count, {
227
- exposure: render.exposure,
228
- composedLook: (render.look ?? 'None') !== 'None',
229
- });
181
+ const bytes = displayTransform.encodeFrame(renderer, pixels, width, height);
230
182
  const photograph: Record<string, unknown> = {
231
183
  base64: await pngBase64(bytes, width, height),
232
184
  mimeType: 'image/png',
@@ -245,6 +197,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
245
197
  } finally {
246
198
  effect?.dispose();
247
199
  sceneTarget.dispose();
200
+ displayTransform.dispose();
248
201
  renderer.toneMapping = previousMapping;
249
202
  renderer.toneMappingExposure = previousExposure;
250
203
  await view.setRendered(false);
@@ -100,6 +100,7 @@ export function worldField(
100
100
  // Built ONCE per node: the model precomputes a 512x256 texture, and
101
101
  // rebuilding it per direction would be a 65,536-pixel job per texel.
102
102
  const field = skyField({
103
+ ...(expression.sky_model === undefined ? {} : {model: expression.sky_model}),
103
104
  sunElevation: expression.sun_elevation,
104
105
  sunRotation: expression.sun_rotation,
105
106
  altitude: expression.altitude,
@@ -253,6 +254,7 @@ export function collectSkyParameters(expression: WorldExpression): SkyParameters
253
254
  switch (node.kind) {
254
255
  case 'sky':
255
256
  found.push({
257
+ ...(node.sky_model === undefined ? {} : {model: node.sky_model}),
256
258
  sunElevation: node.sun_elevation,
257
259
  sunRotation: node.sun_rotation,
258
260
  altitude: node.altitude,
@@ -318,13 +318,12 @@ def _photograph(depsgraph, width, height, linear=False):
318
318
  "fov": _vertical_extent(camera.data, int(width), int(height)),
319
319
  "toneMapping": transform,
320
320
  "exposure": float(2.0 ** view.exposure),
321
+ "gamma": float(view.gamma),
321
322
  "orthographic": camera.data.type == "ORTHO",
322
323
  "transparent": bool(scene.render.film_transparent),
323
324
  }
324
- # The renderer keys its baked look tables by the config's FULL name
325
- # (`AgX - Medium High Contrast`), and `AgX - Base Contrast` is the AgX base
326
- # view itself -- the same table as no look at all.
327
- if look not in ("None", "AgX - Base Contrast"):
325
+ # Preserve the full look name for the native OCIO display processor.
326
+ if look != "None":
328
327
  render["look"] = look
329
328
  # The capture's scene-referred frame rides along when asked for: a render
330
329
  # result is what a render leaves, and it holds scene-linear pixels.
@@ -412,7 +411,7 @@ def _fill_result(engine, result, answer, png, width, height):
412
411
  view transform over them wherever it shows or saves one (`write_still`, the
413
412
  Image Editor), as it does over Cycles'. The photograph's PNG is the display
414
413
  image -- the transform already ran -- so loading it put every render
415
- through AgX or Filmic twice. Measured 2026-09-28 by the model editor's
414
+ through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
416
415
  render view: a background 53 against the viewport's 59, a shadow's blue 2
417
416
  against 7. The same capture's linear frame goes in instead: half floats,
418
417
  RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
package/browser/worker.ts CHANGED
@@ -351,7 +351,13 @@ async function startBlender(project: string, document?: string): Promise<unknown
351
351
  // be `open_mainfile` on the document — which lives on the host's disk and is
352
352
  // not in the engine's filesystem until it is staged. Every other call stages
353
353
  // on its way in (`execute`); start had nothing to open before it did.
354
- if (document) await stageProjectFiles(started.files, project);
354
+ if (document) {
355
+ // The document itself not reaching the engine is a refusal, not a footnote: the open below
356
+ // would fail on a missing file (or open a stale copy) without saying why.
357
+ const failed = await stageProjectFiles(started.files, project);
358
+ const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
359
+ if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
360
+ }
355
361
  await loadCheckpoint?.("project-imported");
356
362
  const banner = (await started.request({
357
363
  op: 'start',
@@ -459,10 +465,11 @@ function say(line: string): void {
459
465
  log('error', line);
460
466
  }
461
467
 
462
- async function projectIndex(project: string): Promise<ProjectFile[] | null> {
463
- const unreadable = (reason: string): null => {
464
- say(`The project's files are not readable from Python: ${reason}`);
465
- return null;
468
+ async function projectIndex(project: string): Promise<ProjectFile[] | string> {
469
+ const unreadable = (reason: string): string => {
470
+ const line = `The project's files are not readable from Python: ${reason}`;
471
+ say(line);
472
+ return line;
466
473
  };
467
474
  let answer: Response;
468
475
  try {
@@ -515,9 +522,20 @@ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
515
522
  return info ? `${info.size}:${info.mtimeMs}` : '';
516
523
  }
517
524
 
518
- async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<void> {
525
+ /**
526
+ * Copy the project's changed files into the engine, and answer what could NOT be copied.
527
+ *
528
+ * THE ANSWER IS THE POINT (2026-10-06 audit: files that failed to copy were "reported to the
529
+ * console only"). `say` writes the page console once per line, which nobody running a script
530
+ * reads; a script that then opens or loads the missing file fails, or worse succeeds on a
531
+ * stale copy, with no word of why. So every call returns its failures, and the callers put
532
+ * them where the caller of THAT is looking: `execute` appends them to the script's answer, and
533
+ * `start` refuses when the document itself is among them.
534
+ */
535
+ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<string[]> {
519
536
  const files = await projectIndex(project);
520
- if (!files) return;
537
+ if (typeof files === 'string') return [files];
538
+ const failed: string[] = [];
521
539
  const present = new Set(files.map((file) => `${project}/${file.path}`));
522
540
  for (const path of [...staged.keys()]) {
523
541
  if (present.has(path)) continue;
@@ -539,6 +557,7 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
539
557
  );
540
558
  if (!answer.ok) {
541
559
  say(`${file.path}: HTTP ${answer.status}`);
560
+ failed.push(`${file.path} (the session answered HTTP ${answer.status})`);
542
561
  continue;
543
562
  }
544
563
  // The index can precede an append or rewrite. Stream the response's own size,
@@ -555,20 +574,34 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
555
574
  }
556
575
  staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
557
576
  }
577
+ return failed;
578
+ }
579
+
580
+ /** The failures `stageProjectFiles` answered, as one paragraph a script's caller can read. */
581
+ function stagingNote(failed: readonly string[]): string {
582
+ return failed.length === 0 ? '' :
583
+ `\n\nThese project files could not be copied into Blender, so this code did not see their current contents: ${failed.join('; ')}`;
558
584
  }
559
585
 
560
586
  /** Everything under `root` this SESSION owns -- what the transport mirrors out.
561
587
  * A host file staged in and NEVER WRITTEN is the host's and is not output;
562
588
  * one Python has written over since it was staged is this session's output,
563
589
  * the same as a path it created (see {@link staged}). */
564
- async function listSessionFiles(files: BlenderFiles, root: string): Promise<FileEntry[]> {
590
+ async function listSessionFiles(files: BlenderFiles, hostRoot: string): Promise<FileEntry[]> {
591
+ // A host root reaches the engine in the engine's spelling, as the project itself does.
592
+ const root = projectMount(hostRoot);
565
593
  const out: FileEntry[] = [];
594
+ // A root the engine has no directory at holds no output yet, and that is an honest empty
595
+ // listing. A directory that exists and cannot be read is NOT: until 2026-10-06 every readdir
596
+ // failure was answered as "no files", which is how a Windows root the engine never had (the
597
+ // host's `C:\…` instead of `/C:/…`) reported success while nothing reached disk.
598
+ if ((await files.stat(root)) === null) return out;
566
599
  const walk = async (dir: string): Promise<void> => {
567
600
  let names: string[];
568
601
  try {
569
602
  names = await files.readdir(dir);
570
- } catch {
571
- return;
603
+ } catch (error) {
604
+ throw new Error(`Blender's files under ${dir} could not be listed, so its outputs there cannot be written back: ${String(error)}`);
572
605
  }
573
606
  for (const name of names) {
574
607
  if (name === '.' || name === '..') continue;
@@ -641,13 +674,13 @@ async function handle(request: WorkerRequest): Promise<unknown> {
641
674
  return ask({ op: 'history-step', token: request.token, direction: request.direction });
642
675
  case 'execute':
643
676
  // Code about to run may open a file the host wrote since the last call.
644
- await stageProjectFiles(files, projectRoot);
645
677
  {
678
+ const note = stagingNote(await stageProjectFiles(files, projectRoot));
646
679
  const answer = await ask({ op: 'execute', code: request.code, history: request.history ?? true,
647
680
  label: request.label ?? 'Blender Python' }) as {
648
681
  error?: string; result: string;
649
682
  };
650
- return answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`;
683
+ return (answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`) + note;
651
684
  }
652
685
  case 'present':
653
686
  // Straight through to `session.py`'s own `present` op — the worker adds
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.127",
5
+ "version": "0.1.129",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },