@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.
- package/browser/blender-emscripten-engine.mts +42 -10
- package/browser/blender-engine.mts +3 -0
- package/browser/index.ts +1 -1
- package/browser/protocol.ts +40 -0
- package/browser/runtime.ts +17 -0
- package/browser/session.py +172 -28
- package/browser/three/blender-depsgraph-placements.ts +27 -0
- package/browser/three/blender-graph-material.ts +9 -6
- package/browser/three/blender-node-graph.ts +13 -1
- package/browser/three/blender-object-info-materials.ts +20 -5
- package/browser/three/blender-render-camera.ts +22 -0
- package/browser/three/blender-runtime-lighting.ts +55 -7
- package/browser/three/blender-runtime-view.ts +49 -8
- package/browser/three/blender-single-scattering-sky.ts +20 -0
- package/browser/three/blender-sky.ts +26 -1
- package/browser/three/world-field-sampler.ts +58 -1
- package/browser/volter_three.py +5 -1
- package/browser/worker.ts +7 -0
- package/package.json +1 -1
- package/wasm/BUNDLE.json +15 -15
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
const
|
|
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
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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;
|
package/browser/volter_three.py
CHANGED
|
@@ -225,7 +225,11 @@ def _export():
|
|
|
225
225
|
"known": {},
|
|
226
226
|
"buffer_path": EXPORT_BUFFER_PATH,
|
|
227
227
|
}
|
|
228
|
-
|
|
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':
|