@volter/blender-engine 0.1.129 → 0.1.131

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.
@@ -26,7 +26,7 @@ interface Binding {
26
26
  channels: Record<string, number>;
27
27
  borrowedFrom?: Binding;
28
28
  borrowedSource?: THREE.MeshPhysicalMaterial;
29
- objectInfoUniforms?: {color: THREE.IUniform<THREE.Vector4>; indexRandom: THREE.IUniform<THREE.Vector2>};
29
+ objectInfoUniforms?: {color: THREE.IUniform<THREE.Vector4>; indexRandom: THREE.IUniform<THREE.Vector2>; particleRandom: THREE.IUniform<number>};
30
30
  palette?: {indices: Int32Array; sources: readonly THREE.MeshPhysicalMaterial[]};
31
31
  }
32
32
  const bindings = new WeakMap<THREE.MeshPhysicalMaterial, Binding>();
@@ -46,7 +46,7 @@ function compatiblePaletteGraphs(sources: readonly THREE.MeshPhysicalMaterial[])
46
46
  if (sources.length !== 2 || sources.some(source => pendings.has(source))) return null;
47
47
  const graphs = sources.map(source => bindings.get(source)?.compiled);
48
48
  const graph = graphs[0];
49
- if (!graph || graph.objectInfo || failed.has(graph.key) || graphs.some(other => !other || other.key !== graph.key ||
49
+ if (!graph || graph.objectInfo || graph.particleInfo || failed.has(graph.key) || graphs.some(other => !other || other.key !== graph.key ||
50
50
  other.declarations !== graph.declarations || other.library !== graph.library ||
51
51
  JSON.stringify([other.uvs, other.attributes, other.outputs]) !== JSON.stringify([graph.uvs, graph.attributes, graph.outputs]))) return null;
52
52
  // Leave room for the physical material's environment and lighting samplers.
@@ -186,17 +186,20 @@ export function borrowGraphDrawBinding(source: THREE.MeshPhysicalMaterial, targe
186
186
  * belong to ONE object. A distinct material identity makes Three upload them
187
187
  * when it switches between objects sharing an authored material. */
188
188
  export function bindGraphObjectInfo(material: THREE.MeshPhysicalMaterial,
189
- info: {color: readonly number[]; index: number; random: number}): void {
189
+ info: {color: readonly number[]; index: number; random: number; particle_random?: number}): void {
190
190
  const binding=bindings.get(material);
191
- if (!binding?.compiled.objectInfo) return;
191
+ if (!binding || (!binding.compiled.objectInfo && !binding.compiled.particleInfo)) return;
192
+ if (binding.compiled.particleInfo && info.particle_random === undefined)
193
+ throw new Error('Particle Info Random input missing for Blender object');
192
194
  const values=binding.objectInfoUniforms ??= {
193
- color:{value:new THREE.Vector4()}, indexRandom:{value:new THREE.Vector2()},
195
+ color:{value:new THREE.Vector4()}, indexRandom:{value:new THREE.Vector2()}, particleRandom:{value:0},
194
196
  };
195
197
  if (binding.uniforms['blenderObjectColor']!==values.color) {
196
- binding.uniforms={...binding.uniforms,blenderObjectColor:values.color,blenderObjectIndexRandom:values.indexRandom};
198
+ binding.uniforms={...binding.uniforms,blenderObjectColor:values.color,blenderObjectIndexRandom:values.indexRandom, blenderParticleRandom:values.particleRandom};
197
199
  }
198
200
  values.color.value.fromArray(info.color);
199
201
  values.indexRandom.value.set(info.index,info.random);
202
+ if (info.particle_random !== undefined) values.particleRandom.value=info.particle_random;
200
203
  }
201
204
 
202
205
  /** A binding's values: uniforms, ramp tables and textures. */
@@ -49,7 +49,7 @@ export const GRAPH_NODE_TYPES = [
49
49
  'ShaderNodeGamma', 'ShaderNodeRGBToBW', 'ShaderNodeSeparateColor', 'ShaderNodeCombineColor',
50
50
  'ShaderNodeVectorRotate', 'ShaderNodeFresnel', 'ShaderNodeLayerWeight', 'ShaderNodeBump',
51
51
  'ShaderNodeNormalMap', 'ShaderNodeNewGeometry', 'ShaderNodeVertexColor', 'ShaderNodeAttribute',
52
- 'ShaderNodeRGBCurve', 'ShaderNodeVectorCurve', 'ShaderNodeFloatCurve', 'ShaderNodeObjectInfo',
52
+ 'ShaderNodeRGBCurve', 'ShaderNodeVectorCurve', 'ShaderNodeFloatCurve', 'ShaderNodeObjectInfo', 'ShaderNodeParticleInfo',
53
53
  ] as const;
54
54
 
55
55
  const nodeSchema = z.object({
@@ -145,6 +145,7 @@ export interface CompiledGraph {
145
145
  readonly attributes: readonly string[];
146
146
  /** Requires a draw identity with this object's native inputs. */
147
147
  readonly objectInfo: boolean;
148
+ readonly particleInfo: boolean;
148
149
  /** Which surface inputs the graph drives, each a GLSL global of `type`. */
149
150
  readonly outputs: Readonly<Record<string, {readonly global: string; readonly type: GpuType}>>;
150
151
  readonly surface: MaterialGraph['surface'];
@@ -433,6 +434,8 @@ class Compiler {
433
434
  const [key, index] = source.link;
434
435
  const node = this.graph.nodes[key];
435
436
  if (!node) throw new Error(`The graph links to ${key}, which it does not carry`);
437
+ if (node.type === 'ShaderNodeParticleInfo' && node.outputs[index]?.id !== 'Random')
438
+ throw new Error(`Particle Info output ${node.outputs[index]?.id ?? index} is not compiled by the presenter`);
436
439
  const outputs = this.node(key, node);
437
440
  const output = outputs[index];
438
441
  if (!output) throw new Error(`${key} has no output ${index}`);
@@ -489,6 +492,12 @@ class Compiler {
489
492
  body.push(`${outputs[0]!.name} = ${this.uniform(type, prop<number | number[]>(node, 'value'))};`);
490
493
  break;
491
494
  }
495
+ case 'ShaderNodeParticleInfo': {
496
+ // Cycles uses the originating particle's index hash, not Object Info
497
+ // Random. The exporter refuses other outputs until their data travels.
498
+ body.push(`${outputs[1]!.name} = blenderParticleRandom;`);
499
+ break;
500
+ }
492
501
  case 'ShaderNodeObjectInfo': {
493
502
  // node_shader_gpu_object_info / node_object_info. Location is the
494
503
  // object's origin, never the current fragment's world position.
@@ -959,6 +968,8 @@ class Compiler {
959
968
  const program = [
960
969
  ...(Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeObjectInfo')
961
970
  ? ['uniform vec4 blenderObjectColor;', 'uniform vec2 blenderObjectIndexRandom;'] : []),
971
+ ...(Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeParticleInfo')
972
+ ? ['uniform float blenderParticleRandom;'] : []),
962
973
  ...[...this.s.uniformTypes].map(([n, t]) => `uniform ${t} ${n};`),
963
974
  ...this.images.map(i => i.tiled
964
975
  ? `uniform highp sampler2DArray ${i.uniform};\nuniform highp sampler2D ${i.uniform}Map;`
@@ -980,6 +991,7 @@ class Compiler {
980
991
  uvs: [...this.s.uvs],
981
992
  attributes: [...this.s.attributes],
982
993
  objectInfo: Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeObjectInfo'),
994
+ particleInfo: Object.values(this.graph.nodes).some(n=>n.type==='ShaderNodeParticleInfo'),
983
995
  outputs,
984
996
  surface: this.graph.surface,
985
997
  closure: closure !== undefined,
@@ -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
  }
@@ -7,6 +7,7 @@ import {z} from 'zod';
7
7
  import {applyGraphShader, bindGraphDraw, bindPaletteGraph, borrowGraphDrawBinding, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
8
8
  import type {CompiledGraph} from './blender-node-graph';
9
9
  import {bindNamedUvChannels} from './blender-texture-samplers';
10
+ import {copyWorldVisibility,worldVisibilityShader,worldVisibilityUniforms} from './blender-world-visibility';
10
11
 
11
12
  const scalar = z.number().finite();
12
13
  const color = z.tuple([scalar, scalar, scalar]);
@@ -55,6 +56,7 @@ function graphShadow(material: THREE.MeshPhysicalMaterial): THREE.MeshPhysicalMa
55
56
  // which `copy` replaces with the material's own (it leaves hooks alone).
56
57
  applyPhysicalMaterial(shadow);
57
58
  shadow.copy(material);
59
+ copyWorldVisibility(material,shadow);
58
60
  const values = uniforms.get(shadow)!;
59
61
  if (held) {
60
62
  values.blenderCoatIor.value = held.blenderCoatIor.value;
@@ -149,6 +151,7 @@ export function syncPhysicalDrawMaterial(source: THREE.Material, target?: THREE.
149
151
  return null;
150
152
  }
151
153
  copy.copy(source);
154
+ copyWorldVisibility(source,copy);
152
155
  const held = uniforms.get(source)!;
153
156
  const values = uniforms.get(copy)!;
154
157
  values.blenderCoatIor.value = held.blenderCoatIor.value;
@@ -192,7 +195,7 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
192
195
  blenderWorldExtinction: {value: new THREE.Vector3()}};
193
196
  uniforms.set(material, values);
194
197
  const held = values;
195
- material.customProgramCacheKey = () => `blender-principled-physical-v5${graphProgramKey(material)}`;
198
+ material.customProgramCacheKey = () => `blender-principled-physical-v6${graphProgramKey(material)}`;
196
199
  material.onBeforeRender = (renderer, scene, camera, geometry, object) => {
197
200
  bindNamedUvChannels(material, geometry);
198
201
  bindGraphDraw(material, geometry, renderer, scene, camera, object, graphShadow);
@@ -210,6 +213,12 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
210
213
  .map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
211
214
  .join('\n') + '\n' + shader.vertexShader;
212
215
  Object.assign(shader.uniforms, held);
216
+ worldVisibilityUniforms(material,shader.uniforms);
217
+ shader.vertexShader='varying vec3 blenderWorldPosition;\n'+shader.vertexShader;
218
+ shader.vertexShader=shader.vertexShader.replace('#include <worldpos_vertex>',
219
+ '#include <worldpos_vertex>\n#if defined(USE_ENVMAP) || defined(USE_SHADOWMAP) || defined(USE_TRANSMISSION)\nblenderWorldPosition=worldPosition.xyz;\n#else\nblenderWorldPosition=(modelMatrix*vec4(transformed,1.)).xyz;\n#endif');
220
+ shader.fragmentShader=shader.fragmentShader.replace('#include <envmap_physical_pars_fragment>',
221
+ '#include <envmap_physical_pars_fragment>\n'+worldVisibilityShader);
213
222
  shader.fragmentShader = `uniform float blenderCoatIor;
214
223
  uniform vec3 blenderWorldExtinction;
215
224
  vec3 blenderInfiniteTransmission() { return vec3(
@@ -255,6 +264,10 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
255
264
  'rectAreaLight = rectAreaLights[ i ]; rectAreaLight.color *= exp(-blenderWorldExtinction * length(rectAreaLight.position-geometryPosition));')
256
265
  .replace('getAmbientLightIrradiance( ambientLightColor )', 'getAmbientLightIrradiance( ambientLightColor ) * blenderInfiniteTransmission()');
257
266
  const environment = THREE.ShaderChunk.lights_fragment_maps
267
+ .replace('getIBLIrradiance( geometryNormal )',
268
+ 'blenderWorldIrradiance( geometryNormal, getIBLIrradiance( geometryNormal ) )')
269
+ .replaceAll(/(getIBL(?:Radiance|AnisotropyRadiance)\([^;]+\))/g,
270
+ '$1 * blenderWorldReflectionVisibility(geometryViewDir,geometryNormal,material.roughness)')
258
271
  .replaceAll(/(getIBL(?:Irradiance|Radiance|AnisotropyRadiance)\([^;]+\))/g, '$1 * blenderInfiniteTransmission()');
259
272
  shader.fragmentShader = shader.fragmentShader.replace('#include <lights_physical_fragment>', physical)
260
273
  .replace('#include <lights_fragment_begin>', lighting)
@@ -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,14 @@ 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
+ lightingTexture():THREE.Texture|null {return this.environmentTexture??this.texture;}
728
+ readonly solarLights: THREE.DirectionalLight[] = [];
729
+ private readonly solarDirections = new Map<THREE.DirectionalLight, THREE.Vector3>();
730
+
731
+ updateSolarDirections(root: THREE.Object3D): void {
732
+ for (const [light, direction] of this.solarDirections)
733
+ light.position.copy(direction).transformDirection(root.matrixWorld);
734
+ }
710
735
  private pending: Promise<void> | null = null;
711
736
 
712
737
  /** Await this world's preparation, including a failure that settled before
@@ -772,7 +797,7 @@ export class WorldBackground {
772
797
  ];
773
798
  const missing = skies.filter((parameters) => !hasSkyTexture(parameters));
774
799
  if (missing.length === 0) {
775
- this.compose(scene, world, expression, camera);
800
+ this.compose(scene, world, expression, camera, root);
776
801
  return;
777
802
  }
778
803
  const pending = (async () => {
@@ -783,7 +808,7 @@ export class WorldBackground {
783
808
  // The world moved on (another apply, or a clear) while the worker ran:
784
809
  // composing now would paint a sky nobody asked for over the current one.
785
810
  if (this.generation !== generation || this.applied?.scene !== scene) return;
786
- this.compose(scene, world, expression, camera);
811
+ this.compose(scene, world, expression, camera, root);
787
812
  presenterChanged();
788
813
  })();
789
814
  this.pending = pending;
@@ -802,6 +827,7 @@ export class WorldBackground {
802
827
  world: WorldData,
803
828
  expression: WorldExpression,
804
829
  camera?: THREE.Camera,
830
+ root?: THREE.Object3D,
805
831
  ): void {
806
832
  this.texture = worldTexture(expression, world.strength, camera);
807
833
  if (usesWindowCoordinates(expression)) {
@@ -833,14 +859,37 @@ export class WorldBackground {
833
859
  // 0.30 behind a 0.95 backdrop -- and three keeps the two apart, so using
834
860
  // the backdrop here would light that scene three times too brightly.
835
861
  const { lighting } = world;
836
- this.environmentTexture = lighting
837
- ? worldTexture(lighting.shader ?? lighting.color, lighting.strength, camera)
862
+ const lightingExpression = lighting?.shader ?? lighting?.color ?? expression;
863
+ const solar = worldSolarIrradiance(lightingExpression);
864
+ this.environmentTexture = lighting || solar.length
865
+ ? worldTexture(solar.length ? withoutSolarDiscs(lightingExpression) : lightingExpression,
866
+ lighting?.strength ?? world.strength, camera)
838
867
  : null;
839
868
  scene.environment = this.environmentTexture ?? this.texture;
869
+ root?.updateMatrixWorld(true);
870
+ for (const {direction, irradiance} of solar) {
871
+ const light = new THREE.DirectionalLight(0xffffff, lighting?.strength ?? world.strength);
872
+ light.name = 'BlenderWorldSun';
873
+ light.color.setRGB(irradiance.x, irradiance.y, irradiance.z, THREE.LinearSRGBColorSpace);
874
+ light.position.copy(direction);
875
+ if (root) light.position.transformDirection(root.matrixWorld);
876
+ light.castShadow = true;
877
+ light.shadow.mapSize.set(2048, 2048);
878
+ scene.add(light, light.target);
879
+ this.solarLights.push(light);
880
+ this.solarDirections.set(light, direction.clone());
881
+ }
840
882
  }
841
883
 
842
884
  clear(): void {
843
885
  this.generation++;
886
+ for (const light of this.solarLights) {
887
+ light.removeFromParent();
888
+ light.target.removeFromParent();
889
+ light.dispose();
890
+ }
891
+ this.solarLights.length = 0;
892
+ this.solarDirections.clear();
844
893
  if (!this.applied) return;
845
894
  this.applied.scene.background = this.applied.background;
846
895
  this.applied.scene.environment = this.applied.environment;
@@ -58,6 +58,11 @@ export function fitModelDirectionalShadow(
58
58
  casters: THREE.Box3[],
59
59
  ): void {
60
60
  if (!light.castShadow || !receivers.length) return;
61
+ // A detached photograph fits before its first render. Newly composed suns
62
+ // and their targets have not yet passed through Scene.updateMatrixWorld;
63
+ // fitting in those stale axes produces a different map on the first draw.
64
+ light.updateWorldMatrix(true, false);
65
+ light.target.updateWorldMatrix(true, false);
61
66
  light.shadow.updateMatrices(light);
62
67
  const camera = light.shadow.camera;
63
68
  const view = camera.matrixWorldInverse;
@@ -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';
@@ -25,6 +26,7 @@ import {
25
26
  worldSchema,
26
27
  } from './blender-runtime-lighting';
27
28
  import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
29
+ import {BlenderWorldVisibility,bindWorldVisibility} from './blender-world-visibility';
28
30
  import { volumeMesh, volumeSchema } from './blender-runtime-volume';
29
31
  import { applyPhysicalMaterial, applyWorldExtinction, physicalMaterialSchema } from './blender-physical-material';
30
32
  import { prepareGraphGeometry, setMaterialGraph } from './blender-graph-material';
@@ -490,6 +492,13 @@ export const frameSchema = z
490
492
  meshes: z.record(z.string(), meshSchema),
491
493
  materials: z.record(z.string(), materialSchema),
492
494
  images: z.record(z.string(), frameImageSchema).default({}),
495
+ /** Evaluated draws borrowing geometry/materials from native source rows. */
496
+ instances: z.array(z.object({
497
+ id:z.string(),source:z.string(),owner:z.string().nullable(),
498
+ matrix:z.array(z.tuple([scalar,scalar,scalar,scalar])).length(4),
499
+ visible:z.boolean(),render_visible:z.boolean(),random:scalar,color:z.tuple([scalar,scalar,scalar,scalar]),
500
+ shadow_visible:z.boolean().optional(),particle_random:scalar.optional(),
501
+ }).strict()).optional(),
493
502
  objects: z.array(
494
503
  z
495
504
  .object({
@@ -519,15 +528,19 @@ export const frameSchema = z
519
528
  matrix: z.array(z.tuple([scalar, scalar, scalar, scalar])).length(4),
520
529
  visible: z.boolean(),
521
530
  render_visible: z.boolean().default(true),
531
+ shadow_visible: z.boolean().default(true),
522
532
  selected: z.boolean(),
523
533
  parent: z.string().nullable(),
534
+ instance_owner: z.string().nullable().optional(),
535
+ viewport_show_self:z.boolean().optional(),
536
+ render_show_self:z.boolean().optional(),
524
537
  /** A manual texture space (`[location, size]`), which Generated
525
538
  * coordinates map through; absent means Blender's automatic one,
526
539
  * the evaluated bounds (`blender-graph-material.ts`'s orco). */
527
540
  texspace: z.tuple([z.tuple([scalar, scalar, scalar]), z.tuple([scalar, scalar, scalar])]).optional(),
528
541
  default_color: z.string().optional(),
529
542
  object_info: z.object({
530
- color:z.tuple([scalar,scalar,scalar,scalar]),index:scalar,random:scalar,
543
+ color:z.tuple([scalar,scalar,scalar,scalar]),index:scalar,random:scalar,particle_random:scalar.optional(),
531
544
  }).strict().optional(),
532
545
  })
533
546
  .strict(),
@@ -669,6 +682,7 @@ export class BlenderRuntimeView {
669
682
  private readonly lights = new Map<string, THREE.Light>();
670
683
  private readonly lighting = new ViewportLighting();
671
684
  private readonly world = new WorldBackground();
685
+ private readonly worldVisibility=new BlenderWorldVisibility();
672
686
  /**
673
687
  * THE INSPECTION OVERLAYS (I4). They are NOT children of {@link root}, and
674
688
  * that is the whole design: an overlay is EDITOR FURNITURE, so its
@@ -701,10 +715,12 @@ export class BlenderRuntimeView {
701
715
  private readonly emptyExtrasRoot = new THREE.Group();
702
716
  private rendered = false;
703
717
  private sceneLights = false;
718
+ private readonly hiddenSurfaceLayers = new WeakMap<THREE.Object3D,number>();
704
719
  private shadowFit: {
705
720
  camera: THREE.Camera | undefined;
706
721
  cameraMatrix: THREE.Matrix4 | undefined;
707
722
  projection: THREE.Matrix4 | undefined;
723
+ solarLights: THREE.DirectionalLight[];
708
724
  objects: Map<THREE.Object3D, { matrix: THREE.Matrix4; visible: boolean }>;
709
725
  } | null = null;
710
726
  /** The camera the VIEWPORT is held in render lighting through (Blender's Rendered shading),
@@ -723,7 +739,7 @@ export class BlenderRuntimeView {
723
739
  * (`blender-workbench-material.ts`) over their viewport display. */
724
740
  private workbench = false;
725
741
  private readonly workbenchMaterials = new Map<string, THREE.Material>();
726
- private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72 });
742
+ private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72, side: THREE.DoubleSide });
727
743
  /** Base Color images, by image name -- ONE texture per image however many
728
744
  * materials read it, and the cache OWNS it: a material points at one and
729
745
  * never disposes it. Held with the size and revision the resident bytes
@@ -1319,6 +1335,13 @@ export class BlenderRuntimeView {
1319
1335
  // objects. Fit shadows for that draw, rather than the camera's pose
1320
1336
  // when Rendered shading was first entered. Captures use this path too.
1321
1337
  if (this.rendered) this.applyShadows(true, camera);
1338
+ this.worldVisibility.sync(this.objects.values(),this.world.lightingTexture(),this.root,this.rendered);
1339
+ for(const material of [...this.materials.values(),this.fallback])bindWorldVisibility(material,this.worldVisibility);
1340
+ for(const object of this.objects.values()) {
1341
+ const mesh=object as THREE.Mesh;if(!mesh.isMesh)continue;
1342
+ for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])bindWorldVisibility(material,this.worldVisibility);
1343
+ }
1344
+ if(options?.renderer)this.worldVisibility.prepare(options.renderer);
1322
1345
  const opaqueDone = performance.now();
1323
1346
  const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
1324
1347
  options?.interactive === true, options?.height ?? 0);
@@ -1418,6 +1441,9 @@ export class BlenderRuntimeView {
1418
1441
  await lightingReady();
1419
1442
  await Promise.all([...this.textures.values()].map((held) => held.ready));
1420
1443
  await this.world.ready();
1444
+ // The asynchronous sky composition creates solar lights. Fit their
1445
+ // shadows only after they exist, including the first detached capture.
1446
+ this.applyShadows(rendered, camera);
1421
1447
  }
1422
1448
  }
1423
1449
 
@@ -1440,9 +1466,12 @@ export class BlenderRuntimeView {
1440
1466
  */
1441
1467
  private applyShadows(rendered: boolean, camera?: THREE.Camera): void {
1442
1468
  this.root.updateMatrixWorld(true);
1469
+ this.world.updateSolarDirections(this.root);
1443
1470
  const fitted = this.shadowFit;
1444
1471
  if (rendered && fitted && fitted.camera === camera &&
1445
1472
  (!camera || (fitted.cameraMatrix?.equals(camera.matrixWorld) && fitted.projection?.equals(camera.projectionMatrix))) &&
1473
+ fitted.solarLights.length === this.world.solarLights.length &&
1474
+ fitted.solarLights.every((light, index) => light === this.world.solarLights[index]) &&
1446
1475
  fitted.objects.size === this.objects.size &&
1447
1476
  [...this.objects.values()].every(object => {
1448
1477
  const previous = fitted.objects.get(object);
@@ -1452,18 +1481,22 @@ export class BlenderRuntimeView {
1452
1481
  camera,
1453
1482
  cameraMatrix: camera?.matrixWorld.clone(),
1454
1483
  projection: camera?.projectionMatrix.clone(),
1484
+ solarLights: [...this.world.solarLights],
1455
1485
  objects: new Map([...this.objects.values()].map(object =>
1456
1486
  [object, { matrix: object.matrixWorld.clone(), visible: object.visible }])),
1457
1487
  } : null;
1458
1488
  const boxes: THREE.Box3[] = [];
1459
- for (const object of this.objects.values()) {
1489
+ const casters: THREE.Box3[] = [];
1490
+ const shadowVisibility = new Map(this.frame?.objects.map(row => [row.id, row.shadow_visible]) ?? []);
1491
+ for (const [id, object] of this.objects) {
1460
1492
  const mesh = object as THREE.Mesh;
1461
1493
  if (!mesh.isMesh) continue;
1462
- mesh.castShadow = rendered;
1494
+ mesh.castShadow = rendered && shadowVisibility.get(id) !== false;
1463
1495
  mesh.receiveShadow = rendered;
1464
1496
  if (rendered && mesh.visible) {
1465
1497
  const bounds = new THREE.Box3().setFromObject(mesh);
1466
1498
  if (!bounds.isEmpty()) boxes.push(bounds);
1499
+ if (!bounds.isEmpty() && mesh.castShadow) casters.push(bounds);
1467
1500
  }
1468
1501
  }
1469
1502
  if (!rendered || !boxes.length) return;
@@ -1477,9 +1510,9 @@ export class BlenderRuntimeView {
1477
1510
  const receivers = frustum
1478
1511
  ? boxes.flatMap((bounds) => visibleShadowReceivers(bounds, frustum))
1479
1512
  : [];
1480
- for (const light of this.lights.values()) {
1513
+ for (const light of [...this.lights.values(), ...this.world.solarLights]) {
1481
1514
  if ((light as THREE.DirectionalLight).isDirectionalLight && camera)
1482
- fitModelDirectionalShadow(light as THREE.DirectionalLight, receivers, boxes);
1515
+ fitModelDirectionalShadow(light as THREE.DirectionalLight, receivers, casters);
1483
1516
  else fitShadow(light, sphere.center, sphere.radius);
1484
1517
  }
1485
1518
  }
@@ -1580,11 +1613,26 @@ export class BlenderRuntimeView {
1580
1613
  }
1581
1614
 
1582
1615
  private applyVisibility(): void {
1616
+ // Restore canonical masks before suppressing an instancer's own surface.
1617
+ // Its geometry can be hidden while its placed children remain visible.
1618
+ this.instances.clear();
1619
+ this.transparentInstances.clear();
1583
1620
  const renderVisibility = this.rendered && (this.capturing || this.heldVisibility === 'render');
1584
1621
  for (const obj of this.frame?.objects ?? []) {
1585
1622
  const object = this.objects.get(obj.id);
1586
1623
  const visible = renderVisibility ? obj.render_visible : obj.visible;
1587
1624
  if (object) object.visible = visible;
1625
+ if ((object as THREE.Mesh | undefined)?.isMesh) {
1626
+ const showSelf = (renderVisibility ? obj.render_show_self : obj.viewport_show_self) ?? true;
1627
+ const previous = this.hiddenSurfaceLayers.get(object!);
1628
+ if (!showSelf) {
1629
+ if (previous === undefined) this.hiddenSurfaceLayers.set(object!,object!.layers.mask);
1630
+ object!.layers.mask = 0;
1631
+ } else if (previous !== undefined) {
1632
+ object!.layers.mask = previous;
1633
+ this.hiddenSurfaceLayers.delete(object!);
1634
+ }
1635
+ }
1588
1636
  if (!obj.light) continue;
1589
1637
  const light = this.lights.get(obj.light);
1590
1638
  if (light) light.visible = this.sceneLights && visible;
@@ -1646,7 +1694,7 @@ export class BlenderRuntimeView {
1646
1694
  // the session correct its own tables BEFORE it decides what to ship.
1647
1695
  const held =
1648
1696
  this.frame === null ? null : { session: this.frame.session, revision: this.frame.revision };
1649
- const next = frameSchema.parse(input);
1697
+ const next = expandDrawPlacements(frameSchema.parse(input));
1650
1698
  const staged = this.staged;
1651
1699
  if (staged && (staged.session !== next.session || staged.revision !== next.revision))
1652
1700
  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 +1897,9 @@ export class BlenderRuntimeView {
1849
1897
  });
1850
1898
  }
1851
1899
  for (const [id, data] of Object.entries(next.materials)) {
1852
- const material = this.materials.get(id) ?? new THREE.MeshPhysicalMaterial();
1900
+ // Cycles intersects and shades both faces, including an arcade viewed
1901
+ // from underneath. Three's front-face default drops those surfaces.
1902
+ const material = this.materials.get(id) ?? new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide });
1853
1903
  material.name = data.name;
1854
1904
  // A LINKED BASE COLOUR REPLACES THE SOCKET'S VALUE, it does not multiply
1855
1905
  // it. Python's `_reduce` fills `color` from the Base Color socket's
@@ -2181,7 +2231,7 @@ export class BlenderRuntimeView {
2181
2231
  }
2182
2232
 
2183
2233
  drawStatistics() {
2184
- return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2234
+ return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, worldVisibility:this.worldVisibility.diagnostics(), materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2185
2235
  }
2186
2236
 
2187
2237
  snapshot() {
@@ -2363,6 +2413,10 @@ export class BlenderRuntimeView {
2363
2413
  if (disposed) throw new Error('Blender capture snapshot is disposed');
2364
2414
  return detached.setRendered(true, camera);
2365
2415
  },
2416
+ prepareDraw:(renderer:THREE.WebGLRenderer,camera:THREE.Camera)=>{
2417
+ if(disposed)throw new Error('Blender capture snapshot is disposed');
2418
+ return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer});
2419
+ },
2366
2420
  dispose,
2367
2421
  };
2368
2422
  }
@@ -2411,6 +2465,7 @@ export class BlenderRuntimeView {
2411
2465
  }
2412
2466
 
2413
2467
  private clear() {
2468
+ this.worldVisibility.dispose();
2414
2469
  this.objectInfoMaterials.clear();
2415
2470
  this.materialRanges.clear();
2416
2471
  this.motionGeometry.clear();
@@ -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])]);