@volter/blender-engine 0.1.129 → 0.1.130

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.
@@ -6,6 +6,7 @@ export interface BlenderObjectInfo {
6
6
  readonly color: readonly number[];
7
7
  readonly index: number;
8
8
  readonly random: number;
9
+ readonly particle_random?: number;
9
10
  }
10
11
  type Draw = {source: THREE.MeshPhysicalMaterial; mesh: THREE.Mesh; material: THREE.MeshPhysicalMaterial; version:number};
11
12
 
@@ -18,17 +19,24 @@ export class BlenderObjectInfoMaterials {
18
19
  private readonly draws=new Map<string,Draw>();
19
20
  private readonly sources=new Map<THREE.MeshPhysicalMaterial,string|null>();
20
21
  private used=new Set<string>();
22
+ private readonly particleInputs=new Map<THREE.Mesh,number>();
21
23
 
22
- begin(): void {this.used.clear();this.sources.clear();}
24
+ begin(): void {this.used.clear();this.sources.clear();this.particleInputs.clear();}
23
25
 
24
26
  get(source: THREE.Material, mesh: THREE.Mesh): THREE.Material {
25
27
  if (!(source instanceof THREE.MeshPhysicalMaterial)) return source;
26
28
  const graph=materialGraph(source);
27
29
  this.sources.set(source,graph?.key??null);
28
- if (!graph?.objectInfo) return source;
30
+ if (!graph || (!graph.objectInfo && !graph.particleInfo)) return source;
29
31
  const info=mesh.userData['blenderObjectInfo'] as BlenderObjectInfo|undefined;
30
32
  if (!info) throw new Error(`Object Info inputs missing for Blender object ${mesh.name}`);
31
- const key=`${mesh.uuid}:${source.uuid}`;
33
+ // Particle Random alone is a draw-uniform value, not a position input.
34
+ // Identical geometry/values (especially Cycles' default child record) share one
35
+ // material identity. Object Info still needs each object's model matrix.
36
+ if (graph.particleInfo && info.particle_random === undefined)
37
+ throw new Error(`Particle Info Random input missing for Blender object ${mesh.name}`);
38
+ if (!graph.objectInfo) this.particleInputs.set(mesh,info.particle_random!);
39
+ const key=`${graph.objectInfo ? mesh.uuid : `particle:${mesh.geometry.uuid}:${info.particle_random}`}:${source.uuid}`;
32
40
  this.used.add(key);
33
41
  let draw=this.draws.get(key);
34
42
  if (!draw) {
@@ -37,6 +45,9 @@ export class BlenderObjectInfoMaterials {
37
45
  draw={source,mesh,material,version:source.version};
38
46
  this.draws.set(key,draw);
39
47
  }
48
+ // Refresh follows a mesh that still wears this shared input, including
49
+ // when its former representative was edited or removed.
50
+ draw.mesh=mesh;
40
51
  this.sync(draw,info);
41
52
  return draw.material;
42
53
  }
@@ -49,7 +60,11 @@ export class BlenderObjectInfoMaterials {
49
60
 
50
61
  /** A pending authored graph can finish without another native export. */
51
62
  needsRemap(): boolean {
52
- return [...this.sources].some(([source,key])=>(materialGraph(source)?.key??null)!==key);
63
+ for (const [source,key] of this.sources)
64
+ if ((materialGraph(source)?.key??null)!==key) return true;
65
+ for (const [mesh,value] of this.particleInputs)
66
+ if ((mesh.userData['blenderObjectInfo'] as BlenderObjectInfo|undefined)?.particle_random!==value) return true;
67
+ return false;
53
68
  }
54
69
 
55
70
  refresh(): void {
@@ -70,6 +85,6 @@ export class BlenderObjectInfoMaterials {
70
85
 
71
86
  clear(): void {
72
87
  for(const draw of this.draws.values()) draw.material.dispose();
73
- this.draws.clear();this.sources.clear();this.used.clear();
88
+ this.draws.clear();this.sources.clear();this.used.clear();this.particleInputs.clear();
74
89
  }
75
90
  }
@@ -0,0 +1,22 @@
1
+ import * as THREE from 'three';
2
+ import type {RenderRequest} from '../protocol';
3
+
4
+ /** Render through the authored camera, including its deliberate clipping.
5
+ * Navigation's automatically fitted clipping does not describe a render. */
6
+ export function blenderRenderCamera(render: RenderRequest, documentScale = 1): THREE.PerspectiveCamera | THREE.OrthographicCamera {
7
+ const aspect = render.width / render.height;
8
+ const near = (render.clipStart ?? .1) * documentScale;
9
+ const far = (render.clipEnd ?? 2000) * documentScale;
10
+ const camera = render.orthographic
11
+ ? new THREE.OrthographicCamera(-render.fov * documentScale * aspect / 2,
12
+ render.fov * documentScale * aspect / 2, render.fov * documentScale / 2,
13
+ -render.fov * documentScale / 2, near, far)
14
+ : new THREE.PerspectiveCamera(render.fov, aspect, near, far);
15
+ // Blender's shift uses the fitted sensor dimension for BOTH axes.
16
+ // View offset survives projection updates and capture camera cloning.
17
+ const fitted = (render.horizontalFit ?? aspect >= 1) ? render.width : render.height;
18
+ camera.setViewOffset(render.width, render.height,
19
+ (render.shiftX ?? 0) * fitted, -(render.shiftY ?? 0) * fitted,
20
+ render.width, render.height);
21
+ return camera;
22
+ }
@@ -47,6 +47,8 @@ import {
47
47
  sampleWorldScreen,
48
48
  usesWindowCoordinates,
49
49
  worldField,
50
+ worldSolarIrradiance,
51
+ withoutSolarDiscs,
50
52
  } from './world-field-sampler';
51
53
  import { type WorldMathOperation, worldMath } from './world-math';
52
54
 
@@ -436,6 +438,9 @@ export type WorldExpression =
436
438
  | {
437
439
  kind: 'sky';
438
440
  sky_model?: 'SINGLE_SCATTERING' | 'MULTIPLE_SCATTERING' | undefined;
441
+ sun_disc?: boolean | undefined;
442
+ sun_size?: number | undefined;
443
+ sun_intensity?: number | undefined;
439
444
  sun_elevation: number;
440
445
  sun_rotation: number;
441
446
  altitude: number;
@@ -533,6 +538,9 @@ const worldExpression: z.ZodType<WorldExpression> = z.lazy(() =>
533
538
  .object({
534
539
  kind: z.literal('sky'),
535
540
  sky_model: z.enum(['SINGLE_SCATTERING', 'MULTIPLE_SCATTERING']).optional(),
541
+ sun_disc: z.boolean().optional(),
542
+ sun_size: scalar.optional(),
543
+ sun_intensity: scalar.optional(),
536
544
  sun_elevation: scalar,
537
545
  sun_rotation: scalar,
538
546
  altitude: scalar,
@@ -690,9 +698,18 @@ function worldDataTexture(
690
698
  width: number,
691
699
  height: number,
692
700
  ): THREE.DataTexture {
693
- const data = new Uint16Array(width * height * 4);
694
- writeScaled(data, samples, strength, width * height);
695
- const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.HalfFloatType);
701
+ // Solar radiance can exceed half-float's 65504 limit even at native default
702
+ // intensity. Keep those backgrounds in float32 instead of clipping the field.
703
+ const fullFloat = samples.some(value => Math.abs(value * strength) > 65504);
704
+ const data = fullFloat ? new Float32Array(width * height * 4) : new Uint16Array(width * height * 4);
705
+ if (data instanceof Float32Array) {
706
+ for (let i = 0; i < width * height; i++) {
707
+ for (let c = 0; c < 3; c++) data[i * 4 + c] = samples[i * 3 + c]! * strength;
708
+ data[i * 4 + 3] = 1;
709
+ }
710
+ } else writeScaled(data, samples, strength, width * height);
711
+ const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat,
712
+ fullFloat ? THREE.FloatType : THREE.HalfFloatType);
696
713
  texture.mapping = THREE.EquirectangularReflectionMapping;
697
714
  texture.colorSpace = THREE.LinearSRGBColorSpace;
698
715
  texture.minFilter = THREE.LinearFilter;
@@ -707,6 +724,13 @@ export type WorldData = z.infer<typeof worldSchema>;
707
724
  * Constants and linked colors use the same radiometric path. The environment
708
725
  * samples the field at 256×128; features below a texel are filtered. */
709
726
  export class WorldBackground {
727
+ readonly solarLights: THREE.DirectionalLight[] = [];
728
+ private readonly solarDirections = new Map<THREE.DirectionalLight, THREE.Vector3>();
729
+
730
+ updateSolarDirections(root: THREE.Object3D): void {
731
+ for (const [light, direction] of this.solarDirections)
732
+ light.position.copy(direction).transformDirection(root.matrixWorld);
733
+ }
710
734
  private pending: Promise<void> | null = null;
711
735
 
712
736
  /** Await this world's preparation, including a failure that settled before
@@ -772,7 +796,7 @@ export class WorldBackground {
772
796
  ];
773
797
  const missing = skies.filter((parameters) => !hasSkyTexture(parameters));
774
798
  if (missing.length === 0) {
775
- this.compose(scene, world, expression, camera);
799
+ this.compose(scene, world, expression, camera, root);
776
800
  return;
777
801
  }
778
802
  const pending = (async () => {
@@ -783,7 +807,7 @@ export class WorldBackground {
783
807
  // The world moved on (another apply, or a clear) while the worker ran:
784
808
  // composing now would paint a sky nobody asked for over the current one.
785
809
  if (this.generation !== generation || this.applied?.scene !== scene) return;
786
- this.compose(scene, world, expression, camera);
810
+ this.compose(scene, world, expression, camera, root);
787
811
  presenterChanged();
788
812
  })();
789
813
  this.pending = pending;
@@ -802,6 +826,7 @@ export class WorldBackground {
802
826
  world: WorldData,
803
827
  expression: WorldExpression,
804
828
  camera?: THREE.Camera,
829
+ root?: THREE.Object3D,
805
830
  ): void {
806
831
  this.texture = worldTexture(expression, world.strength, camera);
807
832
  if (usesWindowCoordinates(expression)) {
@@ -833,14 +858,37 @@ export class WorldBackground {
833
858
  // 0.30 behind a 0.95 backdrop -- and three keeps the two apart, so using
834
859
  // the backdrop here would light that scene three times too brightly.
835
860
  const { lighting } = world;
836
- this.environmentTexture = lighting
837
- ? worldTexture(lighting.shader ?? lighting.color, lighting.strength, camera)
861
+ const lightingExpression = lighting?.shader ?? lighting?.color ?? expression;
862
+ const solar = worldSolarIrradiance(lightingExpression);
863
+ this.environmentTexture = lighting || solar.length
864
+ ? worldTexture(solar.length ? withoutSolarDiscs(lightingExpression) : lightingExpression,
865
+ lighting?.strength ?? world.strength, camera)
838
866
  : null;
839
867
  scene.environment = this.environmentTexture ?? this.texture;
868
+ root?.updateMatrixWorld(true);
869
+ for (const {direction, irradiance} of solar) {
870
+ const light = new THREE.DirectionalLight(0xffffff, lighting?.strength ?? world.strength);
871
+ light.name = 'BlenderWorldSun';
872
+ light.color.setRGB(irradiance.x, irradiance.y, irradiance.z, THREE.LinearSRGBColorSpace);
873
+ light.position.copy(direction);
874
+ if (root) light.position.transformDirection(root.matrixWorld);
875
+ light.castShadow = true;
876
+ light.shadow.mapSize.set(2048, 2048);
877
+ scene.add(light, light.target);
878
+ this.solarLights.push(light);
879
+ this.solarDirections.set(light, direction.clone());
880
+ }
840
881
  }
841
882
 
842
883
  clear(): void {
843
884
  this.generation++;
885
+ for (const light of this.solarLights) {
886
+ light.removeFromParent();
887
+ light.target.removeFromParent();
888
+ light.dispose();
889
+ }
890
+ this.solarLights.length = 0;
891
+ this.solarDirections.clear();
844
892
  if (!this.applied) return;
845
893
  this.applied.scene.background = this.applied.background;
846
894
  this.applied.scene.environment = this.applied.environment;
@@ -2,6 +2,7 @@
2
2
  * No modeling operations or source builds run here. Stable datablock addresses
3
3
  * retain objects/resources; changed meshes replace only their draw geometry.
4
4
  */
5
+ import {expandDrawPlacements} from './blender-depsgraph-placements';
5
6
  import * as THREE from 'three';
6
7
  import { z } from 'zod';
7
8
  import { onPresenterChange, presenterChanged } from './blender-presenter-change';
@@ -490,6 +491,13 @@ export const frameSchema = z
490
491
  meshes: z.record(z.string(), meshSchema),
491
492
  materials: z.record(z.string(), materialSchema),
492
493
  images: z.record(z.string(), frameImageSchema).default({}),
494
+ /** Evaluated draws borrowing geometry/materials from native source rows. */
495
+ instances: z.array(z.object({
496
+ id:z.string(),source:z.string(),owner:z.string().nullable(),
497
+ matrix:z.array(z.tuple([scalar,scalar,scalar,scalar])).length(4),
498
+ visible:z.boolean(),render_visible:z.boolean(),random:scalar,color:z.tuple([scalar,scalar,scalar,scalar]),
499
+ shadow_visible:z.boolean().optional(),particle_random:scalar.optional(),
500
+ }).strict()).optional(),
493
501
  objects: z.array(
494
502
  z
495
503
  .object({
@@ -519,15 +527,19 @@ export const frameSchema = z
519
527
  matrix: z.array(z.tuple([scalar, scalar, scalar, scalar])).length(4),
520
528
  visible: z.boolean(),
521
529
  render_visible: z.boolean().default(true),
530
+ shadow_visible: z.boolean().default(true),
522
531
  selected: z.boolean(),
523
532
  parent: z.string().nullable(),
533
+ instance_owner: z.string().nullable().optional(),
534
+ viewport_show_self:z.boolean().optional(),
535
+ render_show_self:z.boolean().optional(),
524
536
  /** A manual texture space (`[location, size]`), which Generated
525
537
  * coordinates map through; absent means Blender's automatic one,
526
538
  * the evaluated bounds (`blender-graph-material.ts`'s orco). */
527
539
  texspace: z.tuple([z.tuple([scalar, scalar, scalar]), z.tuple([scalar, scalar, scalar])]).optional(),
528
540
  default_color: z.string().optional(),
529
541
  object_info: z.object({
530
- color:z.tuple([scalar,scalar,scalar,scalar]),index:scalar,random:scalar,
542
+ color:z.tuple([scalar,scalar,scalar,scalar]),index:scalar,random:scalar,particle_random:scalar.optional(),
531
543
  }).strict().optional(),
532
544
  })
533
545
  .strict(),
@@ -701,10 +713,12 @@ export class BlenderRuntimeView {
701
713
  private readonly emptyExtrasRoot = new THREE.Group();
702
714
  private rendered = false;
703
715
  private sceneLights = false;
716
+ private readonly hiddenSurfaceLayers = new WeakMap<THREE.Object3D,number>();
704
717
  private shadowFit: {
705
718
  camera: THREE.Camera | undefined;
706
719
  cameraMatrix: THREE.Matrix4 | undefined;
707
720
  projection: THREE.Matrix4 | undefined;
721
+ solarLights: THREE.DirectionalLight[];
708
722
  objects: Map<THREE.Object3D, { matrix: THREE.Matrix4; visible: boolean }>;
709
723
  } | null = null;
710
724
  /** The camera the VIEWPORT is held in render lighting through (Blender's Rendered shading),
@@ -723,7 +737,7 @@ export class BlenderRuntimeView {
723
737
  * (`blender-workbench-material.ts`) over their viewport display. */
724
738
  private workbench = false;
725
739
  private readonly workbenchMaterials = new Map<string, THREE.Material>();
726
- private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72 });
740
+ private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72, side: THREE.DoubleSide });
727
741
  /** Base Color images, by image name -- ONE texture per image however many
728
742
  * materials read it, and the cache OWNS it: a material points at one and
729
743
  * never disposes it. Held with the size and revision the resident bytes
@@ -1418,6 +1432,9 @@ export class BlenderRuntimeView {
1418
1432
  await lightingReady();
1419
1433
  await Promise.all([...this.textures.values()].map((held) => held.ready));
1420
1434
  await this.world.ready();
1435
+ // The asynchronous sky composition creates solar lights. Fit their
1436
+ // shadows only after they exist, including the first detached capture.
1437
+ this.applyShadows(rendered, camera);
1421
1438
  }
1422
1439
  }
1423
1440
 
@@ -1440,9 +1457,12 @@ export class BlenderRuntimeView {
1440
1457
  */
1441
1458
  private applyShadows(rendered: boolean, camera?: THREE.Camera): void {
1442
1459
  this.root.updateMatrixWorld(true);
1460
+ this.world.updateSolarDirections(this.root);
1443
1461
  const fitted = this.shadowFit;
1444
1462
  if (rendered && fitted && fitted.camera === camera &&
1445
1463
  (!camera || (fitted.cameraMatrix?.equals(camera.matrixWorld) && fitted.projection?.equals(camera.projectionMatrix))) &&
1464
+ fitted.solarLights.length === this.world.solarLights.length &&
1465
+ fitted.solarLights.every((light, index) => light === this.world.solarLights[index]) &&
1446
1466
  fitted.objects.size === this.objects.size &&
1447
1467
  [...this.objects.values()].every(object => {
1448
1468
  const previous = fitted.objects.get(object);
@@ -1452,18 +1472,22 @@ export class BlenderRuntimeView {
1452
1472
  camera,
1453
1473
  cameraMatrix: camera?.matrixWorld.clone(),
1454
1474
  projection: camera?.projectionMatrix.clone(),
1475
+ solarLights: [...this.world.solarLights],
1455
1476
  objects: new Map([...this.objects.values()].map(object =>
1456
1477
  [object, { matrix: object.matrixWorld.clone(), visible: object.visible }])),
1457
1478
  } : null;
1458
1479
  const boxes: THREE.Box3[] = [];
1459
- for (const object of this.objects.values()) {
1480
+ const casters: THREE.Box3[] = [];
1481
+ const shadowVisibility = new Map(this.frame?.objects.map(row => [row.id, row.shadow_visible]) ?? []);
1482
+ for (const [id, object] of this.objects) {
1460
1483
  const mesh = object as THREE.Mesh;
1461
1484
  if (!mesh.isMesh) continue;
1462
- mesh.castShadow = rendered;
1485
+ mesh.castShadow = rendered && shadowVisibility.get(id) !== false;
1463
1486
  mesh.receiveShadow = rendered;
1464
1487
  if (rendered && mesh.visible) {
1465
1488
  const bounds = new THREE.Box3().setFromObject(mesh);
1466
1489
  if (!bounds.isEmpty()) boxes.push(bounds);
1490
+ if (!bounds.isEmpty() && mesh.castShadow) casters.push(bounds);
1467
1491
  }
1468
1492
  }
1469
1493
  if (!rendered || !boxes.length) return;
@@ -1477,9 +1501,9 @@ export class BlenderRuntimeView {
1477
1501
  const receivers = frustum
1478
1502
  ? boxes.flatMap((bounds) => visibleShadowReceivers(bounds, frustum))
1479
1503
  : [];
1480
- for (const light of this.lights.values()) {
1504
+ for (const light of [...this.lights.values(), ...this.world.solarLights]) {
1481
1505
  if ((light as THREE.DirectionalLight).isDirectionalLight && camera)
1482
- fitModelDirectionalShadow(light as THREE.DirectionalLight, receivers, boxes);
1506
+ fitModelDirectionalShadow(light as THREE.DirectionalLight, receivers, casters);
1483
1507
  else fitShadow(light, sphere.center, sphere.radius);
1484
1508
  }
1485
1509
  }
@@ -1580,11 +1604,26 @@ export class BlenderRuntimeView {
1580
1604
  }
1581
1605
 
1582
1606
  private applyVisibility(): void {
1607
+ // Restore canonical masks before suppressing an instancer's own surface.
1608
+ // Its geometry can be hidden while its placed children remain visible.
1609
+ this.instances.clear();
1610
+ this.transparentInstances.clear();
1583
1611
  const renderVisibility = this.rendered && (this.capturing || this.heldVisibility === 'render');
1584
1612
  for (const obj of this.frame?.objects ?? []) {
1585
1613
  const object = this.objects.get(obj.id);
1586
1614
  const visible = renderVisibility ? obj.render_visible : obj.visible;
1587
1615
  if (object) object.visible = visible;
1616
+ if ((object as THREE.Mesh | undefined)?.isMesh) {
1617
+ const showSelf = (renderVisibility ? obj.render_show_self : obj.viewport_show_self) ?? true;
1618
+ const previous = this.hiddenSurfaceLayers.get(object!);
1619
+ if (!showSelf) {
1620
+ if (previous === undefined) this.hiddenSurfaceLayers.set(object!,object!.layers.mask);
1621
+ object!.layers.mask = 0;
1622
+ } else if (previous !== undefined) {
1623
+ object!.layers.mask = previous;
1624
+ this.hiddenSurfaceLayers.delete(object!);
1625
+ }
1626
+ }
1588
1627
  if (!obj.light) continue;
1589
1628
  const light = this.lights.get(obj.light);
1590
1629
  if (light) light.visible = this.sceneLights && visible;
@@ -1646,7 +1685,7 @@ export class BlenderRuntimeView {
1646
1685
  // the session correct its own tables BEFORE it decides what to ship.
1647
1686
  const held =
1648
1687
  this.frame === null ? null : { session: this.frame.session, revision: this.frame.revision };
1649
- const next = frameSchema.parse(input);
1688
+ const next = expandDrawPlacements(frameSchema.parse(input));
1650
1689
  const staged = this.staged;
1651
1690
  if (staged && (staged.session !== next.session || staged.revision !== next.revision))
1652
1691
  throw new Error(`Blender frame manifest does not match its staged revision: staged ${JSON.stringify([staged.session, staged.revision])}, manifest ${JSON.stringify([next.session, next.revision])}`);
@@ -1849,7 +1888,9 @@ export class BlenderRuntimeView {
1849
1888
  });
1850
1889
  }
1851
1890
  for (const [id, data] of Object.entries(next.materials)) {
1852
- const material = this.materials.get(id) ?? new THREE.MeshPhysicalMaterial();
1891
+ // Cycles intersects and shades both faces, including an arcade viewed
1892
+ // from underneath. Three's front-face default drops those surfaces.
1893
+ const material = this.materials.get(id) ?? new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide });
1853
1894
  material.name = data.name;
1854
1895
  // A LINKED BASE COLOUR REPLACES THE SOCKET'S VALUE, it does not multiply
1855
1896
  // it. Python's `_reduce` fills `color` from the Base Color socket's
@@ -71,6 +71,26 @@ function spectrumToXyz(spectrum: Float32Array): V {
71
71
  return [f(x * 20), f(y * 20), f(z * 20)];
72
72
  }
73
73
 
74
+ /** SKY_single_scattering_precompute_sun: native spectral radiance at the
75
+ * bottom and top of the solar disc, before intensity and limb darkening. */
76
+ export function precomputeSingleScatteringSun(p: SkyParameters, angularDiameter: number): [V, V] {
77
+ const diameter = f(angularDiameter), half = f(diameter / 2);
78
+ const solidAngle = f(TWO_PI * f(1 - f(Math.cos(half))));
79
+ if (!(solidAngle > 0) || f(p.sunElevation + half) <= 0) return [[0, 0, 0], [0, 0, 0]];
80
+ const origin: V = [0, 0, f(EARTH_RADIUS + Math.min(59999, Math.max(1, f(p.altitude))))];
81
+ const pixel = (elevation: number): V => {
82
+ const depth = opticalDepth(origin, direction(Math.max(0, elevation), 0));
83
+ const spectrum = new Float32Array(21);
84
+ for (let i = 0; i < 21; i++) {
85
+ const extinction = f(f(f(RAYLEIGH_COEFF[i]! * depth[0]) * f(p.airDensity)) +
86
+ f(f(f(f(1.11 * MIE_COEFF) * depth[1])) * f(p.aerosolDensity)));
87
+ spectrum[i] = f(f(IRRADIANCE[i]! * f(Math.exp(-extinction))) / solidAngle);
88
+ }
89
+ return spectrumToXyz(spectrum);
90
+ };
91
+ return [pixel(f(f(p.sunElevation) - half)), pixel(f(f(p.sunElevation) + half))];
92
+ }
93
+
74
94
  function inscattering(ray: V, sun: V, origin: V, factors: V): V {
75
95
  const end = add(origin, scale(ray, atmosphereDistance(origin, ray)));
76
96
  const rayLength = length([f(end[0] - origin[0]), f(end[1] - origin[1]), f(end[2] - origin[2])]);
@@ -15,7 +15,7 @@
15
15
  * `Linear CIE-XYZ D65`.
16
16
  */
17
17
  import * as THREE from 'three';
18
- import {precomputeSingleScatteringSky} from './blender-single-scattering-sky';
18
+ import {precomputeSingleScatteringSky, precomputeSingleScatteringSun} from './blender-single-scattering-sky';
19
19
 
20
20
  const M_PI_2_F = 1.5707963267948966;
21
21
  const M_2PI_F = 6.2831853071795864;
@@ -104,6 +104,9 @@ const aerosolDensity = (h: number): number =>
104
104
  export interface SkyParameters {
105
105
  /** Omitted in older frames, whose only supported model was multiple scattering. */
106
106
  model?: 'SINGLE_SCATTERING' | 'MULTIPLE_SCATTERING';
107
+ sunDisc?: boolean | undefined;
108
+ sunSize?: number | undefined;
109
+ sunIntensity?: number | undefined;
107
110
  sunElevation: number;
108
111
  sunRotation: number;
109
112
  altitude: number;
@@ -399,6 +402,14 @@ export function primeSkyTexture(p: SkyParameters, pixels: Float32Array): void {
399
402
  }
400
403
  }
401
404
 
405
+ /** Cycles' Z-up solar direction: rotation zero points along +Y. */
406
+ export function skySunDirection(p: Pick<SkyParameters, 'sunElevation' | 'sunRotation'>): THREE.Vector3 {
407
+ // sky_radiance_nishita uses spherical_to_direction(elevation - PI/2,
408
+ // (2PI - rotation) - PI/2), after SkyTextureNode::simplify_settings.
409
+ return new THREE.Vector3(Math.cos(p.sunElevation) * Math.sin(p.sunRotation),
410
+ Math.cos(p.sunElevation) * Math.cos(p.sunRotation), Math.sin(p.sunElevation));
411
+ }
412
+
402
413
  /**
403
414
  * One Sky Texture node as a field over directions, in Blender's Z-up frame.
404
415
  *
@@ -422,6 +433,12 @@ export function skyField(p: SkyParameters): (direction: THREE.Vector3) => THREE.
422
433
  primeSkyTexture(p, pixels);
423
434
  }
424
435
  const colour = new THREE.Vector3();
436
+ const diameter = p.sunSize ?? 0;
437
+ const solar = p.model === 'SINGLE_SCATTERING' && p.sunDisc && diameter > 0
438
+ ? precomputeSingleScatteringSun(p, diameter) : null;
439
+ const sun = skySunDirection(p);
440
+ const earthAngle = -Math.acos(6360000 / (6360000 + Math.min(59999, Math.max(1, p.altitude))));
441
+ const cross = new THREE.Vector3();
425
442
  return (direction) => {
426
443
  const horizontal = Math.hypot(direction.x, direction.y);
427
444
  const dirElevation = Math.atan2(direction.z, horizontal);
@@ -456,6 +473,14 @@ export function skyField(p: SkyParameters): (direction: THREE.Vector3) => THREE.
456
473
  );
457
474
  xyz[i] = mix(top, bottom, ty);
458
475
  }
476
+ if (solar && dirElevation > earthAngle) {
477
+ const angle = Math.atan2(cross.crossVectors(direction, sun).length(), direction.dot(sun));
478
+ if (angle < diameter / 2) {
479
+ const limb = 1 - .6 * (1 - Math.sqrt(Math.max(0, 1 - (angle / (diameter / 2)) ** 2)));
480
+ const t = (dirElevation - p.sunElevation) / diameter + .5;
481
+ for (let i = 0; i < 3; i++) xyz[i]! += mix(solar[0][i]!, solar[1][i]!, t) * (p.sunIntensity ?? 1) * limb;
482
+ }
483
+ }
459
484
  return colour.set(
460
485
  XYZ_TO_RGB[0]![0]! * xyz[0]! + XYZ_TO_RGB[0]![1]! * xyz[1]! + XYZ_TO_RGB[0]![2]! * xyz[2]!,
461
486
  XYZ_TO_RGB[1]![0]! * xyz[0]! + XYZ_TO_RGB[1]![1]! * xyz[1]! + XYZ_TO_RGB[1]![2]! * xyz[2]!,
@@ -18,7 +18,7 @@ import { gradientTexture } from './blender-gradient-texture';
18
18
  // Type-only, so this module keeps NO runtime edge back to the contribution
19
19
  // that uses it -- which is what lets a worker import it alone.
20
20
  import type { WorldExpression } from './blender-runtime-lighting';
21
- import { type SkyParameters, skyField } from './blender-sky';
21
+ import { type SkyParameters, skyField, skySunDirection } from './blender-sky';
22
22
  import { worldMath } from './world-math';
23
23
 
24
24
  export function worldField(
@@ -101,6 +101,9 @@ export function worldField(
101
101
  // rebuilding it per direction would be a 65,536-pixel job per texel.
102
102
  const field = skyField({
103
103
  ...(expression.sky_model === undefined ? {} : {model: expression.sky_model}),
104
+ ...(expression.sun_disc === undefined ? {} : {sunDisc: expression.sun_disc}),
105
+ ...(expression.sun_size === undefined ? {} : {sunSize: expression.sun_size}),
106
+ ...(expression.sun_intensity === undefined ? {} : {sunIntensity: expression.sun_intensity}),
104
107
  sunElevation: expression.sun_elevation,
105
108
  sunRotation: expression.sun_rotation,
106
109
  altitude: expression.altitude,
@@ -255,6 +258,9 @@ export function collectSkyParameters(expression: WorldExpression): SkyParameters
255
258
  case 'sky':
256
259
  found.push({
257
260
  ...(node.sky_model === undefined ? {} : {model: node.sky_model}),
261
+ ...(node.sun_disc === undefined ? {} : {sunDisc: node.sun_disc}),
262
+ ...(node.sun_size === undefined ? {} : {sunSize: node.sun_size}),
263
+ ...(node.sun_intensity === undefined ? {} : {sunIntensity: node.sun_intensity}),
258
264
  sunElevation: node.sun_elevation,
259
265
  sunRotation: node.sun_rotation,
260
266
  altitude: node.altitude,
@@ -298,6 +304,57 @@ export function collectSkyParameters(expression: WorldExpression): SkyParameters
298
304
  return found;
299
305
  }
300
306
 
307
+ /** Keep the broad sky in IBL and integrate its small bright disc separately.
308
+ * A 256-wide environment map cannot resolve a one-degree sun reliably, and
309
+ * IBL has no geometric shadow visibility. A directional shadow light carries
310
+ * the disc's integrated irradiance; it is an angular-size approximation,
311
+ * rather than a replacement for Cycles' finite-disc path sampling. */
312
+ export function withoutSolarDiscs(expression: WorldExpression): WorldExpression {
313
+ return JSON.parse(JSON.stringify(expression), (_key, value) =>
314
+ value?.kind === 'sky' && value.sky_model === 'SINGLE_SCATTERING'
315
+ ? {...value, sun_disc: false} : value) as WorldExpression;
316
+ }
317
+
318
+ export function worldSolarIrradiance(expression: WorldExpression): {direction: THREE.Vector3; irradiance: THREE.Vector3}[] {
319
+ const suns = collectSkyParameters(expression).filter(p => p.model === 'SINGLE_SCATTERING' &&
320
+ p.sunDisc && (p.sunSize ?? 0) > 0 && (p.sunIntensity ?? 1) > 0);
321
+ if (!suns.length) return [];
322
+ // Window-dependent world shaders need ray origins as well as directions;
323
+ // there is no position-independent directional light for that field.
324
+ if (usesWindowCoordinates(expression)) return [];
325
+ const diffuse = worldField(withoutSolarDiscs(expression));
326
+ const unique = [...new Map(suns.map(p => [JSON.stringify([p.sunElevation, p.sunRotation, p.sunSize]), p])).values()];
327
+ return unique.map(p => {
328
+ // Other discs must not be counted again when they overlap this one's
329
+ // integration domain. Equal domains are one group, including mixed nodes.
330
+ const complete = worldField(JSON.parse(JSON.stringify(expression), (_key, value) =>
331
+ value?.kind === 'sky' && value.sky_model === 'SINGLE_SCATTERING' &&
332
+ (value.sun_elevation !== p.sunElevation || value.sun_rotation !== p.sunRotation || value.sun_size !== p.sunSize)
333
+ ? {...value,sun_disc:false} : value) as WorldExpression);
334
+ const direction = skySunDirection(p);
335
+ const u = new THREE.Vector3().crossVectors(direction, Math.abs(direction.z) < .9
336
+ ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0)).normalize();
337
+ const v = new THREE.Vector3().crossVectors(direction, u);
338
+ const irradiance = new THREE.Vector3(), ray = new THREE.Vector3(), delta = new THREE.Vector3();
339
+ const solidAngle = 2 * Math.PI * (1 - Math.cos(p.sunSize! / 2));
340
+ const radial = 32, azimuthal = 32;
341
+ for (let i = 0; i < radial; i++) {
342
+ const cos = 1 - (i + .5) / radial * (1 - Math.cos(p.sunSize! / 2));
343
+ const sin = Math.sqrt(1 - cos * cos);
344
+ for (let j = 0; j < azimuthal; j++) {
345
+ const phi = (j + .5) / azimuthal * 2 * Math.PI;
346
+ ray.copy(direction).multiplyScalar(cos).addScaledVector(u, sin * Math.cos(phi)).addScaledVector(v, sin * Math.sin(phi));
347
+ const all = complete(ray);
348
+ if (typeof all === 'number') delta.setScalar(all); else delta.copy(all);
349
+ const base = diffuse(ray);
350
+ if (typeof base === 'number') delta.addScalar(-base); else delta.sub(base);
351
+ irradiance.addScaledVector(delta, cos * solidAngle / (radial * azimuthal));
352
+ }
353
+ }
354
+ return {direction, irradiance};
355
+ });
356
+ }
357
+
301
358
  /** Whether this expression depends on the camera's normalized image coordinates. */
302
359
  export function usesWindowCoordinates(expression: WorldExpression): boolean {
303
360
  if (typeof expression === 'number' || Array.isArray(expression)) return false;
@@ -225,7 +225,11 @@ def _export():
225
225
  "known": {},
226
226
  "buffer_path": EXPORT_BUFFER_PATH,
227
227
  }
228
- frame = json.loads(_blender_web.export_frame(json.dumps(options)))
228
+ # The native door reads UTF-8 and does not decode `\\uXXXX` escapes (it
229
+ # drops the backslash), so the request goes unescaped, as session.py's door
230
+ # calls send theirs: a non-ASCII presenter root in `buffer_path` would
231
+ # otherwise reach the door misspelled.
232
+ frame = json.loads(_blender_web.export_frame(json.dumps(options, ensure_ascii=False)))
229
233
  error = frame.get("error")
230
234
  if error:
231
235
  raise RuntimeError("Blender export door: %s" % error)
package/browser/worker.ts CHANGED
@@ -686,6 +686,13 @@ async function handle(request: WorkerRequest): Promise<unknown> {
686
686
  // Straight through to `session.py`'s own `present` op — the worker adds
687
687
  // nothing, and a capture-less present answers `{ presented, revision }`.
688
688
  return pullWork('present', () => ask({ op: 'present' }));
689
+ case 'native-preview':
690
+ return ask({ op: 'native-preview', width: request.width, height: request.height,
691
+ samples: request.samples, ...(request.camera === undefined ? {} : { camera: request.camera }) });
692
+ case 'list-files-snapshot': {
693
+ const document = await ask({ op: 'document-info' });
694
+ return { entries: await listSessionFiles(files, request.path), document };
695
+ }
689
696
  case 'scene-info':
690
697
  return session.sceneInfo();
691
698
  case 'object-info':
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.129",
5
+ "version": "0.1.130",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },