@volter/blender-engine 0.1.0
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/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scene's own lights, and the Model document's SOLID-MODE STUDIO.
|
|
3
|
+
*
|
|
4
|
+
* TWO LIGHTING STATES, the same split Blender makes between its solid viewport
|
|
5
|
+
* and a render. Modeling is lit by Blender's four view-locked studio lights
|
|
6
|
+
* ({@link ViewportLighting}), so a model reads the same however it is orbited
|
|
7
|
+
* while it is being built; a RENDER is lit by the scene's own lights, because
|
|
8
|
+
* that is what the script placed them for. `setRendered` switches between
|
|
9
|
+
* them, and a render capture is the only caller that asks for the second.
|
|
10
|
+
*
|
|
11
|
+
* The studio lives here rather than in the standard viewport dressing because
|
|
12
|
+
* it is BLENDER's, not the editor's: the dressing's key is one warm light in
|
|
13
|
+
* the world, and Solid mode's is four in view space with no world light at
|
|
14
|
+
* all. The Model document turns the dressing's key AND its IBL off and hands
|
|
15
|
+
* this group over as its `dressing.viewLocked` instead.
|
|
16
|
+
*
|
|
17
|
+
* UNITS. The frame states Blender's own values — watts, radians, metres — and
|
|
18
|
+
* the conversion to three.js's photometric intensities happens here, derived
|
|
19
|
+
* rather than tuned:
|
|
20
|
+
*
|
|
21
|
+
* - POINT and SPOT. Blender spreads `energy` watts over the whole sphere, so
|
|
22
|
+
* irradiance at distance d is P/(4πd²); three.js gives I/d². Hence I = P/4π.
|
|
23
|
+
* - SUN. `energy` is already an irradiance (W/m²) and a directional light's
|
|
24
|
+
* intensity is the same quantity, so it passes through.
|
|
25
|
+
* - AREA. P watts leave an area A into a hemisphere, so the radiance is
|
|
26
|
+
* P/(Aπ), which is what `RectAreaLight.intensity` means. A DISK or ELLIPSE
|
|
27
|
+
* has no rectangular equivalent, so it becomes the rectangle of EQUAL AREA:
|
|
28
|
+
* the total emission is right and the shape of a soft shadow's edge is not.
|
|
29
|
+
*
|
|
30
|
+
* SHADOWS follow `use_shadow`, the same flag Cycles reads. A shadow MAP is not
|
|
31
|
+
* a traced shadow: its softness comes from filtering a depth buffer rather than
|
|
32
|
+
* from the light's physical size, so `shadow_soft_size` and a sun's `angle`
|
|
33
|
+
* still reach the frame and still change nothing. A RectAreaLight casts none at
|
|
34
|
+
* all — three.js has no shadow for it — and that is left as it is rather than
|
|
35
|
+
* faked with a substitute light the scene never declared.
|
|
36
|
+
*/
|
|
37
|
+
import * as THREE from 'three';
|
|
38
|
+
import { z } from 'zod';
|
|
39
|
+
import type { GradientType } from './blender-gradient-texture';
|
|
40
|
+
import { hasSkyTexture, primeSkyTexture, type SkyParameters } from './blender-sky';
|
|
41
|
+
import { precomputeSkyOffThread } from './sky-worker';
|
|
42
|
+
// ONE sampler, shared with the worker that runs it off the main thread.
|
|
43
|
+
import {
|
|
44
|
+
collectSkyParameters,
|
|
45
|
+
sampleWorldField,
|
|
46
|
+
sampleWorldScreen,
|
|
47
|
+
usesWindowCoordinates,
|
|
48
|
+
worldField,
|
|
49
|
+
} from './world-field-sampler';
|
|
50
|
+
import { type WorldMathOperation, worldMath } from './world-math';
|
|
51
|
+
|
|
52
|
+
const scalar = z.number().finite();
|
|
53
|
+
|
|
54
|
+
export const lightSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
type: z.enum(['POINT', 'SUN', 'SPOT', 'AREA']),
|
|
57
|
+
color: z.tuple([scalar, scalar, scalar]),
|
|
58
|
+
energy: scalar,
|
|
59
|
+
use_shadow: z.boolean().default(true),
|
|
60
|
+
radius: scalar.optional(),
|
|
61
|
+
spot_size: scalar.optional(),
|
|
62
|
+
spot_blend: scalar.optional(),
|
|
63
|
+
shape: z.enum(['SQUARE', 'RECTANGLE', 'DISK', 'ELLIPSE']).optional(),
|
|
64
|
+
size: scalar.optional(),
|
|
65
|
+
size_y: scalar.optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict();
|
|
68
|
+
|
|
69
|
+
export type LightData = z.infer<typeof lightSchema>;
|
|
70
|
+
|
|
71
|
+
let rectAreaPromise: Promise<void> | undefined;
|
|
72
|
+
/** `RectAreaLight` needs its BRDF lookup tables uploaded once per process, and
|
|
73
|
+
* the module that uploads them is loaded on demand — an area light is the
|
|
74
|
+
* only thing that wants it, and most models have none. */
|
|
75
|
+
function prepareRectArea(): Promise<void> {
|
|
76
|
+
rectAreaPromise ??= import('three/examples/jsm/lights/RectAreaLightUniformsLib.js').then(
|
|
77
|
+
({ RectAreaLightUniformsLib }) => {
|
|
78
|
+
RectAreaLightUniformsLib.init();
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
return rectAreaPromise;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves once every light built so far can actually be drawn.
|
|
86
|
+
*
|
|
87
|
+
* A render is a present followed immediately by ONE photograph, so a table
|
|
88
|
+
* still in flight is a black area light in the only frame anyone sees. The
|
|
89
|
+
* render path awaits this before it photographs; nothing else needs to.
|
|
90
|
+
*/
|
|
91
|
+
export function lightingReady(): Promise<void> {
|
|
92
|
+
return rectAreaPromise ?? Promise.resolve();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const areaExtent = (data: LightData): [number, number] => {
|
|
96
|
+
const size = data.size ?? 0;
|
|
97
|
+
const other = data.size_y ?? size;
|
|
98
|
+
if (data.shape === 'RECTANGLE') return [size, other];
|
|
99
|
+
if (data.shape === 'DISK') {
|
|
100
|
+
const side = Math.sqrt((Math.PI * size * size) / 4);
|
|
101
|
+
return [side, side];
|
|
102
|
+
}
|
|
103
|
+
if (data.shape === 'ELLIPSE') {
|
|
104
|
+
const scale = Math.sqrt(Math.PI / 4);
|
|
105
|
+
return [size * scale, other * scale];
|
|
106
|
+
}
|
|
107
|
+
return [size, size];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The three.js light for one Blender light datablock, or `null` when the
|
|
111
|
+
* existing light is already the right kind and was updated in place. */
|
|
112
|
+
export function buildLight(data: LightData, existing: THREE.Light | null): THREE.Light {
|
|
113
|
+
const reuse = <T extends THREE.Light>(
|
|
114
|
+
ctor: new () => T,
|
|
115
|
+
is: (light: THREE.Light) => boolean,
|
|
116
|
+
): T => (existing && is(existing) ? (existing as T) : new ctor());
|
|
117
|
+
if (data.type === 'SUN') {
|
|
118
|
+
const light = reuse(
|
|
119
|
+
THREE.DirectionalLight,
|
|
120
|
+
(l) => (l as THREE.DirectionalLight).isDirectionalLight,
|
|
121
|
+
);
|
|
122
|
+
light.intensity = data.energy;
|
|
123
|
+
light.color.setRGB(data.color[0], data.color[1], data.color[2], THREE.LinearSRGBColorSpace);
|
|
124
|
+
light.castShadow = data.use_shadow;
|
|
125
|
+
return light;
|
|
126
|
+
}
|
|
127
|
+
if (data.type === 'POINT') {
|
|
128
|
+
const light = reuse(THREE.PointLight, (l) => (l as THREE.PointLight).isPointLight);
|
|
129
|
+
light.intensity = data.energy / (4 * Math.PI);
|
|
130
|
+
light.distance = 0;
|
|
131
|
+
light.decay = 2;
|
|
132
|
+
light.color.setRGB(data.color[0], data.color[1], data.color[2], THREE.LinearSRGBColorSpace);
|
|
133
|
+
light.castShadow = data.use_shadow;
|
|
134
|
+
return light;
|
|
135
|
+
}
|
|
136
|
+
if (data.type === 'SPOT') {
|
|
137
|
+
const light = reuse(THREE.SpotLight, (l) => (l as THREE.SpotLight).isSpotLight);
|
|
138
|
+
light.intensity = data.energy / (4 * Math.PI);
|
|
139
|
+
light.distance = 0;
|
|
140
|
+
light.decay = 2;
|
|
141
|
+
// Blender states the FULL cone angle; three.js states the half angle.
|
|
142
|
+
light.angle = (data.spot_size ?? 0) / 2;
|
|
143
|
+
light.penumbra = data.spot_blend ?? 0;
|
|
144
|
+
light.color.setRGB(data.color[0], data.color[1], data.color[2], THREE.LinearSRGBColorSpace);
|
|
145
|
+
light.castShadow = data.use_shadow;
|
|
146
|
+
return light;
|
|
147
|
+
}
|
|
148
|
+
void prepareRectArea();
|
|
149
|
+
const light = reuse(THREE.RectAreaLight, (l) => (l as THREE.RectAreaLight).isRectAreaLight);
|
|
150
|
+
const [width, height] = areaExtent(data);
|
|
151
|
+
light.width = width;
|
|
152
|
+
light.height = height;
|
|
153
|
+
light.intensity = data.energy / (Math.max(width * height, Number.MIN_VALUE) * Math.PI);
|
|
154
|
+
light.color.setRGB(data.color[0], data.color[1], data.color[2], THREE.LinearSRGBColorSpace);
|
|
155
|
+
// A RectAreaLight casts no shadow in three.js; `use_shadow` has nowhere to go.
|
|
156
|
+
return light;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Point a light's shadow camera at the model.
|
|
161
|
+
*
|
|
162
|
+
* Every default here would be wrong: a directional light's shadow camera is an
|
|
163
|
+
* orthographic box two units wide, and a point light's frustum ends at 500. So
|
|
164
|
+
* the model's bounding sphere IS the frustum — the box is the sphere's radius
|
|
165
|
+
* on each side, and the depth range is the light's own distance to the centre
|
|
166
|
+
* plus or minus that radius. Nothing is tuned; it is the geometry.
|
|
167
|
+
*/
|
|
168
|
+
export function fitShadow(light: THREE.Light, centre: THREE.Vector3, radius: number): void {
|
|
169
|
+
if (!light.castShadow || radius <= 0) return;
|
|
170
|
+
const position = light.getWorldPosition(new THREE.Vector3());
|
|
171
|
+
const distance = position.distanceTo(centre);
|
|
172
|
+
const shadow = (light as THREE.Light & { shadow?: THREE.LightShadow }).shadow;
|
|
173
|
+
if (!shadow) return;
|
|
174
|
+
const camera = shadow.camera as THREE.OrthographicCamera & THREE.PerspectiveCamera;
|
|
175
|
+
camera.near = Math.max(radius * 1e-3, distance - radius);
|
|
176
|
+
camera.far = distance + radius;
|
|
177
|
+
if ((light as THREE.DirectionalLight).isDirectionalLight) {
|
|
178
|
+
camera.left = -radius;
|
|
179
|
+
camera.right = radius;
|
|
180
|
+
camera.top = radius;
|
|
181
|
+
camera.bottom = -radius;
|
|
182
|
+
}
|
|
183
|
+
camera.updateProjectionMatrix();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A Blender light aims down its own -Z; a three.js directional/spot/area
|
|
187
|
+
* light aims at a target object. The target rides one unit down that axis. */
|
|
188
|
+
export function aimLight(light: THREE.Light, node: THREE.Object3D): void {
|
|
189
|
+
// The parent owns the authored transform; directional/spot defaults are offset.
|
|
190
|
+
light.position.set(0, 0, 0);
|
|
191
|
+
const aimed = light as THREE.Light & { target?: THREE.Object3D };
|
|
192
|
+
if ((light as THREE.RectAreaLight).isRectAreaLight) {
|
|
193
|
+
// A RectAreaLight has no target: it emits along its own -Z already.
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!aimed.target) return;
|
|
197
|
+
if (aimed.target.parent !== node) {
|
|
198
|
+
aimed.target.position.set(0, 0, -1);
|
|
199
|
+
node.add(aimed.target);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* BLENDER'S FOUR SOLID-MODE STUDIO LIGHTS, read back from the box's own
|
|
205
|
+
* Blender 5.2.0 LTS with `-b --factory-startup`
|
|
206
|
+
* (`preferences.system.solid_lights`, `studio_light 'Default'`,
|
|
207
|
+
* `light_ambient (0, 0, 0)`).
|
|
208
|
+
*
|
|
209
|
+
* `direction` is in VIEW space and points TOWARD the light — Blender's view
|
|
210
|
+
* space is x right, y up, z toward the viewer, the same handedness as a
|
|
211
|
+
* three.js camera's own space, so each vector transfers as-is into a light
|
|
212
|
+
* parented to the camera. `smooth` is the wrap factor; see
|
|
213
|
+
* {@link ViewportLighting} for how it becomes three's lambert.
|
|
214
|
+
*
|
|
215
|
+
* `specular_color` is deliberately NOT carried: three's own BRDF produces the
|
|
216
|
+
* material's highlight from the same light colour, and a second colour for it
|
|
217
|
+
* would be a second specular model beside the one already shading the model.
|
|
218
|
+
*/
|
|
219
|
+
const SOLID_LIGHTS: readonly {
|
|
220
|
+
readonly direction: readonly [number, number, number];
|
|
221
|
+
readonly diffuse: readonly [number, number, number];
|
|
222
|
+
readonly smooth: number;
|
|
223
|
+
}[] = [
|
|
224
|
+
{
|
|
225
|
+
direction: [-0.352546, 0.170931, -0.920051],
|
|
226
|
+
diffuse: [0.033103, 0.033103, 0.033103],
|
|
227
|
+
smooth: 0.52662,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
direction: [-0.408163, 0.346939, 0.844415],
|
|
231
|
+
diffuse: [0.521083, 0.538226, 0.538226],
|
|
232
|
+
smooth: 0.0,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
direction: [0.521739, 0.826087, 0.212999],
|
|
236
|
+
diffuse: [0.038403, 0.034357, 0.04953],
|
|
237
|
+
smooth: 0.478261,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
direction: [0.624519, -0.562067, -0.542269],
|
|
241
|
+
diffuse: [0.090838, 0.08208, 0.072255],
|
|
242
|
+
smooth: 0.2,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* THE ONE CALIBRATED NUMBER in the studio, and it is one gain over all four
|
|
248
|
+
* lights — their RELATIVE strengths, directions and wraps are Blender's own
|
|
249
|
+
* and nothing here tunes them apart.
|
|
250
|
+
*
|
|
251
|
+
* It absorbs two things this side cannot state exactly. First, the workbench's
|
|
252
|
+
* own normalization: Blender's shader is `Σ diffuseᵢ · wrap(N·Lᵢ)` times the
|
|
253
|
+
* viewport colour PLUS a specular term over `brdf_approx`, and the measured
|
|
254
|
+
* frame comes out at ~0.88 of the diffuse sum alone. Second, the stage's tone
|
|
255
|
+
* curve: the editor renders through ACES (`StageHost`), Blender's viewport
|
|
256
|
+
* through AgX, and near middle grey the two differ by a near-constant factor
|
|
257
|
+
* (checked both ways: with one gain ACES lands the three faces within 4 levels
|
|
258
|
+
* and AgX within 3, so the curve is NOT what row 1's 13-vs-30 spread was made
|
|
259
|
+
* of and the stage keeps its own operator).
|
|
260
|
+
*
|
|
261
|
+
* THE ROUNDS, on a `model-editor create` scaffold driven through `vgai
|
|
262
|
+
* screenshot editor`, 30x30 means (std 0 — every patch inside one flat face)
|
|
263
|
+
* of the factory cube's three visible faces against Blender 5.2's own frame,
|
|
264
|
+
* (141,143,145) top / (129,131,131) left / (111,112,113) front:
|
|
265
|
+
*
|
|
266
|
+
* 0.74, ACES -> (148,150,151) (138,140,140) (108,110,110) worst 9
|
|
267
|
+
* 0.70, ACES -> (144,146,147) (134,136,136) (105,106,106) worst 7, spread 39
|
|
268
|
+
* 0.69, AgX -> (138,139,139) (131,132,133) (112,113,113) worst 6, spread 26
|
|
269
|
+
*
|
|
270
|
+
* Blender's own spread is 30. The ACES rounds are what says the tone curve
|
|
271
|
+
* was carrying the rest of row 1's defect: at ANY gain the operator holds the
|
|
272
|
+
* shading range 30% wider than Blender's, which is why the document names its
|
|
273
|
+
* own (`ToolViewportDressing.toneMapping`).
|
|
274
|
+
*
|
|
275
|
+
* WHAT IS LEFT, measured rather than guessed: the top face reads 3 to 6
|
|
276
|
+
* levels dark and slightly warm. Blender's workbench couples its specular to
|
|
277
|
+
* its diffuse — the same cube with `show_specular_highlight` off renders
|
|
278
|
+
* BRIGHTER, (146,148,149)/(136,138,138)/(114,115,116) against
|
|
279
|
+
* (142,144,145)/(130,132,132)/(112,113,113) with it on — and three's BRDF has
|
|
280
|
+
* no such coupling, nor a second colour per light to carry
|
|
281
|
+
* `specular_color`. Closing that is a shader of our own, not a light rig.
|
|
282
|
+
*/
|
|
283
|
+
const SOLID_STUDIO_GAIN = 0.69;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* SOLID SHADING IS BLENDER'S: four VIEW-LOCKED studio lights, no world light.
|
|
287
|
+
*
|
|
288
|
+
* Blender's Solid mode does not light a model with the world — there is no
|
|
289
|
+
* IBL and no ambient in it at all. It lights it with the four lights above,
|
|
290
|
+
* stated in VIEW space, so they turn with the camera and a model keeps the
|
|
291
|
+
* same read however it is orbited. The stage does the turning: this group is
|
|
292
|
+
* handed over as the Model document's `dressing.viewLocked` and the stage
|
|
293
|
+
* hangs it off the camera it draws with (`StageHost.tsx`); nothing here knows
|
|
294
|
+
* which camera that is, and nothing here is in the model's own frame.
|
|
295
|
+
*
|
|
296
|
+
* THE SHADING MODEL, and exactly where it is and is not Blender's. Workbench
|
|
297
|
+
* accumulates `Σ diffuseᵢ · wrap(N·Lᵢ, smoothᵢ)` times the material's viewport
|
|
298
|
+
* colour, with
|
|
299
|
+
*
|
|
300
|
+
* wrap(NL, w) = max((NL + w) / (1 + w)², 0)
|
|
301
|
+
*
|
|
302
|
+
* (`workbench_world_light_lib.glsl`). three has no wrapped lambert, but the
|
|
303
|
+
* wrap SPLITS exactly into a lambert term and a CONSTANT:
|
|
304
|
+
*
|
|
305
|
+
* (NL + w) / (1 + w)² = NL / (1 + w)² + w / (1 + w)²
|
|
306
|
+
*
|
|
307
|
+
* so each light is a `DirectionalLight` at `π·G/(1+w)²` and the constant half
|
|
308
|
+
* of all four is one `AmbientLight` at `π·G·Σ wᵢ/(1+wᵢ)²`. That ambient is
|
|
309
|
+
* the STUDIO LIGHTS' OWN wrap, not a world ambient: Blender's `light_ambient`
|
|
310
|
+
* is (0,0,0) and stays unread, and the term goes to zero the moment a light's
|
|
311
|
+
* `smooth` does. The one place the two models disagree is the far dark side —
|
|
312
|
+
* Blender's wrap reaches zero at N·L = −w while a clamped lambert plus a
|
|
313
|
+
* constant holds that constant all the way round — so a fully unlit face
|
|
314
|
+
* reads a few levels light. Nothing else is approximated and nothing is tuned
|
|
315
|
+
* per light.
|
|
316
|
+
*
|
|
317
|
+
* TWO LIGHTING STATES, the same split Blender makes between its solid viewport
|
|
318
|
+
* and a render. Modeling is lit by this studio; a RENDER is lit by the scene's
|
|
319
|
+
* own lights, because that is what the script placed them for. `setRendered`
|
|
320
|
+
* switches between them, and a render capture is the only caller that asks for
|
|
321
|
+
* the second.
|
|
322
|
+
*/
|
|
323
|
+
export class ViewportLighting {
|
|
324
|
+
/** The view-locked group: the stage parents THIS to the camera it draws
|
|
325
|
+
* with, and owns the teardown of that parenting. */
|
|
326
|
+
readonly group = new THREE.Group();
|
|
327
|
+
private readonly lights: THREE.Light[] = [];
|
|
328
|
+
|
|
329
|
+
constructor() {
|
|
330
|
+
this.group.name = 'BlenderSolidStudio';
|
|
331
|
+
// What every directional light aims at: the camera's own origin, so a
|
|
332
|
+
// light standing along `direction` shines back down it. It rides in the
|
|
333
|
+
// same group, which is what keeps the whole studio ONE object to parent.
|
|
334
|
+
const target = new THREE.Object3D();
|
|
335
|
+
target.name = 'BlenderSolidStudioTarget';
|
|
336
|
+
this.group.add(target);
|
|
337
|
+
const fill = new THREE.Color(0, 0, 0);
|
|
338
|
+
for (const [index, light] of SOLID_LIGHTS.entries()) {
|
|
339
|
+
const falloff = (1 + light.smooth) ** 2;
|
|
340
|
+
const direct = new THREE.DirectionalLight(0xffffff, (Math.PI * SOLID_STUDIO_GAIN) / falloff);
|
|
341
|
+
direct.name = `BlenderSolidLight${index}`;
|
|
342
|
+
direct.color.setRGB(
|
|
343
|
+
light.diffuse[0],
|
|
344
|
+
light.diffuse[1],
|
|
345
|
+
light.diffuse[2],
|
|
346
|
+
THREE.LinearSRGBColorSpace,
|
|
347
|
+
);
|
|
348
|
+
// Unit length is all that matters — a directional light has no falloff —
|
|
349
|
+
// and the shared target is what makes the aim the camera's own axis.
|
|
350
|
+
direct.position.set(light.direction[0], light.direction[1], light.direction[2]);
|
|
351
|
+
direct.target = target;
|
|
352
|
+
this.group.add(direct);
|
|
353
|
+
this.lights.push(direct);
|
|
354
|
+
fill.r += (light.diffuse[0] * light.smooth) / falloff;
|
|
355
|
+
fill.g += (light.diffuse[1] * light.smooth) / falloff;
|
|
356
|
+
fill.b += (light.diffuse[2] * light.smooth) / falloff;
|
|
357
|
+
}
|
|
358
|
+
const ambient = new THREE.AmbientLight(0xffffff, Math.PI * SOLID_STUDIO_GAIN);
|
|
359
|
+
ambient.name = 'BlenderSolidWrapFill';
|
|
360
|
+
ambient.color.copy(fill);
|
|
361
|
+
this.group.add(ambient);
|
|
362
|
+
this.lights.push(ambient);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A render is lit by the scene alone; modeling is lit by the studio alone. */
|
|
366
|
+
setRendered(rendered: boolean): void {
|
|
367
|
+
this.group.visible = !rendered;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
dispose(): void {
|
|
371
|
+
for (const light of this.lights) light.dispose();
|
|
372
|
+
this.group.clear();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
type WorldVector = [number, number, number];
|
|
377
|
+
export type WorldExpression =
|
|
378
|
+
| number
|
|
379
|
+
| WorldVector
|
|
380
|
+
| { kind: 'direction' }
|
|
381
|
+
| { kind: 'window' }
|
|
382
|
+
| {
|
|
383
|
+
kind: 'gradient';
|
|
384
|
+
gradient_type: GradientType;
|
|
385
|
+
output: 'Fac' | 'Color';
|
|
386
|
+
vector: WorldExpression;
|
|
387
|
+
}
|
|
388
|
+
| { kind: 'to_float'; source_type: 'RGBA' | 'VECTOR'; value: WorldExpression }
|
|
389
|
+
| { kind: 'math'; operation: WorldMathOperation; clamp: boolean; inputs: WorldExpression[] }
|
|
390
|
+
| {
|
|
391
|
+
kind: 'mix_color';
|
|
392
|
+
factor: WorldExpression;
|
|
393
|
+
a: WorldExpression;
|
|
394
|
+
b: WorldExpression;
|
|
395
|
+
clamp_factor: boolean;
|
|
396
|
+
clamp_result: boolean;
|
|
397
|
+
}
|
|
398
|
+
| {
|
|
399
|
+
kind: 'mapping';
|
|
400
|
+
vector: WorldExpression;
|
|
401
|
+
location: WorldVector;
|
|
402
|
+
rotation: WorldVector;
|
|
403
|
+
scale: WorldVector;
|
|
404
|
+
}
|
|
405
|
+
| { kind: 'separate'; vector: WorldExpression; axis: number }
|
|
406
|
+
| { kind: 'ramp'; factor: WorldExpression; colors: WorldVector[]; interpolate: boolean }
|
|
407
|
+
| {
|
|
408
|
+
kind: 'map_range';
|
|
409
|
+
interpolation: 'LINEAR' | 'STEPPED' | 'SMOOTHSTEP' | 'SMOOTHERSTEP';
|
|
410
|
+
clamp: boolean;
|
|
411
|
+
value: WorldExpression;
|
|
412
|
+
from_min: WorldExpression;
|
|
413
|
+
from_max: WorldExpression;
|
|
414
|
+
to_min: WorldExpression;
|
|
415
|
+
to_max: WorldExpression;
|
|
416
|
+
steps: WorldExpression;
|
|
417
|
+
}
|
|
418
|
+
| {
|
|
419
|
+
kind: 'sky';
|
|
420
|
+
sun_elevation: number;
|
|
421
|
+
sun_rotation: number;
|
|
422
|
+
altitude: number;
|
|
423
|
+
air_density: number;
|
|
424
|
+
aerosol_density: number;
|
|
425
|
+
ozone_density: number;
|
|
426
|
+
};
|
|
427
|
+
const worldVector = z.tuple([scalar, scalar, scalar]);
|
|
428
|
+
const worldExpression: z.ZodType<WorldExpression> = z.lazy(() =>
|
|
429
|
+
z.union([
|
|
430
|
+
scalar,
|
|
431
|
+
worldVector,
|
|
432
|
+
z.object({ kind: z.literal('direction') }).strict(),
|
|
433
|
+
z.object({ kind: z.literal('window') }).strict(),
|
|
434
|
+
z
|
|
435
|
+
.object({
|
|
436
|
+
kind: z.literal('gradient'),
|
|
437
|
+
gradient_type: z.enum([
|
|
438
|
+
'LINEAR',
|
|
439
|
+
'QUADRATIC',
|
|
440
|
+
'EASING',
|
|
441
|
+
'DIAGONAL',
|
|
442
|
+
'SPHERICAL',
|
|
443
|
+
'QUADRATIC_SPHERE',
|
|
444
|
+
'RADIAL',
|
|
445
|
+
]),
|
|
446
|
+
output: z.enum(['Fac', 'Color']),
|
|
447
|
+
vector: worldExpression,
|
|
448
|
+
})
|
|
449
|
+
.strict(),
|
|
450
|
+
z
|
|
451
|
+
.object({
|
|
452
|
+
kind: z.literal('to_float'),
|
|
453
|
+
source_type: z.enum(['RGBA', 'VECTOR']),
|
|
454
|
+
value: worldExpression,
|
|
455
|
+
})
|
|
456
|
+
.strict(),
|
|
457
|
+
z
|
|
458
|
+
.object({
|
|
459
|
+
kind: z.literal('math'),
|
|
460
|
+
operation: z.enum(Object.keys(worldMath) as [WorldMathOperation, ...WorldMathOperation[]]),
|
|
461
|
+
clamp: z.boolean(),
|
|
462
|
+
inputs: z.array(worldExpression).min(1).max(3),
|
|
463
|
+
})
|
|
464
|
+
.strict(),
|
|
465
|
+
z
|
|
466
|
+
.object({
|
|
467
|
+
kind: z.literal('mix_color'),
|
|
468
|
+
factor: worldExpression,
|
|
469
|
+
a: worldExpression,
|
|
470
|
+
b: worldExpression,
|
|
471
|
+
clamp_factor: z.boolean(),
|
|
472
|
+
clamp_result: z.boolean(),
|
|
473
|
+
})
|
|
474
|
+
.strict(),
|
|
475
|
+
z
|
|
476
|
+
.object({
|
|
477
|
+
kind: z.literal('mapping'),
|
|
478
|
+
vector: worldExpression,
|
|
479
|
+
location: worldVector,
|
|
480
|
+
rotation: worldVector,
|
|
481
|
+
scale: worldVector,
|
|
482
|
+
})
|
|
483
|
+
.strict(),
|
|
484
|
+
z
|
|
485
|
+
.object({
|
|
486
|
+
kind: z.literal('separate'),
|
|
487
|
+
vector: worldExpression,
|
|
488
|
+
axis: z.number().int().min(0).max(2),
|
|
489
|
+
})
|
|
490
|
+
.strict(),
|
|
491
|
+
z
|
|
492
|
+
.object({
|
|
493
|
+
kind: z.literal('ramp'),
|
|
494
|
+
factor: worldExpression,
|
|
495
|
+
colors: z.array(worldVector).length(257),
|
|
496
|
+
interpolate: z.boolean(),
|
|
497
|
+
})
|
|
498
|
+
.strict(),
|
|
499
|
+
z
|
|
500
|
+
.object({
|
|
501
|
+
kind: z.literal('map_range'),
|
|
502
|
+
interpolation: z.enum(['LINEAR', 'STEPPED', 'SMOOTHSTEP', 'SMOOTHERSTEP']),
|
|
503
|
+
clamp: z.boolean(),
|
|
504
|
+
value: worldExpression,
|
|
505
|
+
from_min: worldExpression,
|
|
506
|
+
from_max: worldExpression,
|
|
507
|
+
to_min: worldExpression,
|
|
508
|
+
to_max: worldExpression,
|
|
509
|
+
steps: worldExpression,
|
|
510
|
+
})
|
|
511
|
+
.strict(),
|
|
512
|
+
z
|
|
513
|
+
.object({
|
|
514
|
+
kind: z.literal('sky'),
|
|
515
|
+
sun_elevation: scalar,
|
|
516
|
+
sun_rotation: scalar,
|
|
517
|
+
altitude: scalar,
|
|
518
|
+
air_density: scalar,
|
|
519
|
+
aerosol_density: scalar,
|
|
520
|
+
ozone_density: scalar,
|
|
521
|
+
})
|
|
522
|
+
.strict(),
|
|
523
|
+
]),
|
|
524
|
+
);
|
|
525
|
+
const worldRadiance = z.object({
|
|
526
|
+
color: worldVector,
|
|
527
|
+
strength: scalar,
|
|
528
|
+
shader: worldExpression.optional(),
|
|
529
|
+
});
|
|
530
|
+
export const worldSchema = worldRadiance
|
|
531
|
+
.extend({
|
|
532
|
+
/** The world that LIGHTS the scene, present only when Blender's Light Path
|
|
533
|
+
* `Is Camera Ray` split it from the world the camera photographs -- one
|
|
534
|
+
* sky at two strengths is how a scene gets a bright backdrop and
|
|
535
|
+
* restrained fill. Absent when a single Background serves both. */
|
|
536
|
+
lighting: worldRadiance.strict().optional(),
|
|
537
|
+
})
|
|
538
|
+
.strict();
|
|
539
|
+
|
|
540
|
+
/** Compile once; the texture loop only evaluates directions in Blender's Z-up basis. */
|
|
541
|
+
|
|
542
|
+
/** Sampled world radiance, by the description that produced it.
|
|
543
|
+
*
|
|
544
|
+
* Sampling is 32,768 texels and, for a SKY, each texel runs the
|
|
545
|
+
* multiple-scattering model -- synchronously, on the page's main thread.
|
|
546
|
+
* MEASURED: a capture of a scene with a sky world cost 15.4s against 0.03s for
|
|
547
|
+
* the same scene with a plain background, and three identical captures in a row
|
|
548
|
+
* each paid it in full. That is why the page froze solid during a replay and
|
|
549
|
+
* why the photograph was SLOWER than the Cycles path trace it is compared
|
|
550
|
+
* against (15.4s vs 7.6s) -- for a screenshot.
|
|
551
|
+
*
|
|
552
|
+
* The DATA is cached rather than the texture, because `clear()` disposes
|
|
553
|
+
* textures and a disposed texture must never be handed out again; a Uint16Array
|
|
554
|
+
* survives disposal and uploading it again is free. Keyed by the description
|
|
555
|
+
* itself, so any change to the sky (or to strength) rebuilds and an unchanged
|
|
556
|
+
* one never does. A handful of entries covers the background/lighting pair a
|
|
557
|
+
* split world needs without letting the map grow with the session. */
|
|
558
|
+
const WORLD_FIELD_CACHE_LIMIT = 4;
|
|
559
|
+
const worldFieldCache = new Map<string, Float32Array>();
|
|
560
|
+
|
|
561
|
+
/** Sky derivations still running. `worldReady()` is what a RENDER awaits, the
|
|
562
|
+
* same way it already awaits `lightingReady()` and `texturesReady()` -- a
|
|
563
|
+
* photograph gets one chance and must not catch a half-built sky. */
|
|
564
|
+
const pendingWorld = new Set<Promise<void>>();
|
|
565
|
+
|
|
566
|
+
export async function worldReady(): Promise<void> {
|
|
567
|
+
while (pendingWorld.size > 0) await Promise.all([...pendingWorld]);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function worldTexture(
|
|
571
|
+
expression: WorldExpression,
|
|
572
|
+
strength: number,
|
|
573
|
+
camera?: THREE.Camera,
|
|
574
|
+
): THREE.DataTexture {
|
|
575
|
+
const width = 256,
|
|
576
|
+
height = 128;
|
|
577
|
+
// KEYED ON THE EXPRESSION ALONE. Strength is a multiplier applied per texel
|
|
578
|
+
// below, so it cannot change what the field SAMPLES -- only what they are
|
|
579
|
+
// scaled by. Keying on it too made a strength-only change re-derive the whole
|
|
580
|
+
// sky, and made a world split by `Is Camera Ray` (one sky, two strengths)
|
|
581
|
+
// build that sky TWICE where once would do.
|
|
582
|
+
const windowCoordinates = usesWindowCoordinates(expression);
|
|
583
|
+
if (windowCoordinates && !camera)
|
|
584
|
+
throw new Error('World Window coordinates need a render camera');
|
|
585
|
+
camera?.updateMatrixWorld();
|
|
586
|
+
const key = JSON.stringify([
|
|
587
|
+
expression,
|
|
588
|
+
windowCoordinates ? [camera!.matrixWorld.elements, camera!.projectionMatrix.elements] : null,
|
|
589
|
+
]);
|
|
590
|
+
const cached = worldFieldCache.get(key);
|
|
591
|
+
if (cached !== undefined) return worldDataTexture(cached, strength, width, height);
|
|
592
|
+
// SYNCHRONOUS, AND THAT IS THE POINT. The expensive half -- each Sky Texture's
|
|
593
|
+
// 512x256 multiple-scattering precompute -- has already been derived in the
|
|
594
|
+
// worker and primed into `blender-sky`'s cache by the time anything calls
|
|
595
|
+
// this; see `WorldBackground.apply`. What is left here is 32,768 bilinear
|
|
596
|
+
// lookups, which is milliseconds.
|
|
597
|
+
//
|
|
598
|
+
// It stays synchronous because the alternative is what shipped once and was
|
|
599
|
+
// taken back out (#6609): the texture handed out EMPTY and filled when the
|
|
600
|
+
// worker answered, with the capture racing the fill. Measured on pixels, a
|
|
601
|
+
// fresh sky rendered to 716 bytes and moving the sun produced the
|
|
602
|
+
// byte-identical empty image. A render gets one chance. Nothing here ever
|
|
603
|
+
// hands out a texture it has not already filled; the waiting happens one level
|
|
604
|
+
// up, where `pendingWorld` can hold the capture.
|
|
605
|
+
const windowPoint = new THREE.Vector3();
|
|
606
|
+
const projected = new THREE.Vector4();
|
|
607
|
+
const projection = camera
|
|
608
|
+
? new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
|
|
609
|
+
: null;
|
|
610
|
+
const cameraPosition = camera?.getWorldPosition(new THREE.Vector3());
|
|
611
|
+
const projectWindow = windowCoordinates
|
|
612
|
+
? (direction: THREE.Vector3) => {
|
|
613
|
+
// Cycles tex_coord.h: a perspective world direction is relative to the
|
|
614
|
+
// camera; an orthographic non-camera ray projects its world direction.
|
|
615
|
+
windowPoint.set(direction.x, direction.z, -direction.y);
|
|
616
|
+
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera)
|
|
617
|
+
windowPoint.add(cameraPosition!);
|
|
618
|
+
projected.set(windowPoint.x, windowPoint.y, windowPoint.z, 1).applyMatrix4(projection!);
|
|
619
|
+
// Cycles transform_perspective returns zero for a zero denominator.
|
|
620
|
+
if (projected.w === 0) return windowPoint.set(0, 0, 0);
|
|
621
|
+
return windowPoint.set(
|
|
622
|
+
(projected.x / projected.w) * 0.5 + 0.5,
|
|
623
|
+
(projected.y / projected.w) * 0.5 + 0.5,
|
|
624
|
+
0,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
: undefined;
|
|
628
|
+
const samples = sampleWorldField(expression, width, height, projectWindow);
|
|
629
|
+
worldFieldCache.set(key, samples);
|
|
630
|
+
// Oldest out first: insertion order is the Map's own.
|
|
631
|
+
if (worldFieldCache.size > WORLD_FIELD_CACHE_LIMIT) {
|
|
632
|
+
worldFieldCache.delete(worldFieldCache.keys().next().value as string);
|
|
633
|
+
}
|
|
634
|
+
return worldDataTexture(samples, strength, width, height);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** One sampled field, scaled, as a texture. The samples are UNSCALED and shared
|
|
638
|
+
* between textures; the multiply and the half-float conversion happen here, so
|
|
639
|
+
* two strengths over one sky cost one derivation and two cheap passes. */
|
|
640
|
+
/** Scale and convert, in one pass. The multiply happens BEFORE the half-float
|
|
641
|
+
* conversion, or a dim fill branch would quantise against a bright backdrop's
|
|
642
|
+
* range. */
|
|
643
|
+
function writeScaled(
|
|
644
|
+
data: Uint16Array,
|
|
645
|
+
samples: Float32Array,
|
|
646
|
+
strength: number,
|
|
647
|
+
texels: number,
|
|
648
|
+
): void {
|
|
649
|
+
const alpha = THREE.DataUtils.toHalfFloat(1);
|
|
650
|
+
for (let i = 0; i < texels; i++) {
|
|
651
|
+
data[i * 4] = THREE.DataUtils.toHalfFloat(samples[i * 3]! * strength);
|
|
652
|
+
data[i * 4 + 1] = THREE.DataUtils.toHalfFloat(samples[i * 3 + 1]! * strength);
|
|
653
|
+
data[i * 4 + 2] = THREE.DataUtils.toHalfFloat(samples[i * 3 + 2]! * strength);
|
|
654
|
+
data[i * 4 + 3] = alpha;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function worldDataTexture(
|
|
659
|
+
samples: Float32Array,
|
|
660
|
+
strength: number,
|
|
661
|
+
width: number,
|
|
662
|
+
height: number,
|
|
663
|
+
): THREE.DataTexture {
|
|
664
|
+
const data = new Uint16Array(width * height * 4);
|
|
665
|
+
writeScaled(data, samples, strength, width * height);
|
|
666
|
+
const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.HalfFloatType);
|
|
667
|
+
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
668
|
+
texture.colorSpace = THREE.LinearSRGBColorSpace;
|
|
669
|
+
texture.minFilter = THREE.LinearFilter;
|
|
670
|
+
texture.magFilter = THREE.LinearFilter;
|
|
671
|
+
texture.needsUpdate = true;
|
|
672
|
+
return texture;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export type WorldData = z.infer<typeof worldSchema>;
|
|
676
|
+
|
|
677
|
+
/** World radiance supplies both the background and image-based lighting.
|
|
678
|
+
* Constants and linked colors use the same radiometric path. The environment
|
|
679
|
+
* samples the field at 256×128; features below a texel are filtered. */
|
|
680
|
+
export class WorldBackground {
|
|
681
|
+
private pending: Promise<void> | null = null;
|
|
682
|
+
|
|
683
|
+
/** Await this world's preparation, including a failure that settled before
|
|
684
|
+
* the caller got here. Another presenter's sky is not this capture's work. */
|
|
685
|
+
async ready(): Promise<void> {
|
|
686
|
+
await this.pending;
|
|
687
|
+
}
|
|
688
|
+
private texture: THREE.DataTexture | null = null;
|
|
689
|
+
/** The lighting branch's own radiance, when the world was split. */
|
|
690
|
+
private environmentTexture: THREE.DataTexture | null = null;
|
|
691
|
+
private screenTexture: THREE.DataTexture | null = null;
|
|
692
|
+
private applied: {
|
|
693
|
+
scene: THREE.Scene;
|
|
694
|
+
background: THREE.Scene['background'];
|
|
695
|
+
environment: THREE.Scene['environment'];
|
|
696
|
+
} | null = null;
|
|
697
|
+
|
|
698
|
+
/** The Scene the model hangs in, or null before it is mounted. */
|
|
699
|
+
private static sceneOf(root: THREE.Object3D): THREE.Scene | null {
|
|
700
|
+
let node: THREE.Object3D | null = root;
|
|
701
|
+
while (node) {
|
|
702
|
+
if ((node as THREE.Scene).isScene) return node as THREE.Scene;
|
|
703
|
+
node = node.parent;
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Bumped by every `apply`/`clear`, so a sky that arrives from the worker
|
|
709
|
+
* after the world moved on knows it is stale and composes nothing. */
|
|
710
|
+
private generation = 0;
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* THE ASYNC SEAM, and the only one.
|
|
714
|
+
*
|
|
715
|
+
* Every Sky Texture in this world is derived in a worker BEFORE anything is
|
|
716
|
+
* composed. When all of them are already in `blender-sky`'s cache the whole
|
|
717
|
+
* apply runs synchronously, exactly as it did inline. When any is missing,
|
|
718
|
+
* composition is DEFERRED behind the worker and the promise is registered in
|
|
719
|
+
* `pendingWorld` SYNCHRONOUSLY, before this method returns -- which is what
|
|
720
|
+
* makes it impossible for a caller to reach `worldReady()` in between.
|
|
721
|
+
* `BlenderRuntimeView.setRendered` awaits `worldReady()` AFTER the applies for
|
|
722
|
+
* this reason, and that ordering must not move.
|
|
723
|
+
*
|
|
724
|
+
* The failure this is shaped against (#6609) was a texture handed out empty
|
|
725
|
+
* and filled later. Here nothing is handed out early at all: until the sky
|
|
726
|
+
* exists, `scene.background` is simply not assigned, and the capture that
|
|
727
|
+
* awaits `worldReady()` cannot run until it is.
|
|
728
|
+
*/
|
|
729
|
+
apply(root: THREE.Object3D, world: WorldData | null, camera?: THREE.Camera): void {
|
|
730
|
+
this.clear();
|
|
731
|
+
this.pending = null;
|
|
732
|
+
if (!world) return;
|
|
733
|
+
const scene = WorldBackground.sceneOf(root);
|
|
734
|
+
if (!scene) return;
|
|
735
|
+
this.applied = { scene, background: scene.background, environment: scene.environment };
|
|
736
|
+
const expression = world.shader ?? world.color;
|
|
737
|
+
const generation = this.generation;
|
|
738
|
+
const skies: SkyParameters[] = [
|
|
739
|
+
...collectSkyParameters(expression),
|
|
740
|
+
...(world.lighting
|
|
741
|
+
? collectSkyParameters(world.lighting.shader ?? world.lighting.color)
|
|
742
|
+
: []),
|
|
743
|
+
];
|
|
744
|
+
const missing = skies.filter((parameters) => !hasSkyTexture(parameters));
|
|
745
|
+
if (missing.length === 0) {
|
|
746
|
+
this.compose(scene, world, expression, camera);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const pending = (async () => {
|
|
750
|
+
const derived = await Promise.all(missing.map(precomputeSkyOffThread));
|
|
751
|
+
missing.forEach((parameters, index) => {
|
|
752
|
+
primeSkyTexture(parameters, derived[index]!);
|
|
753
|
+
});
|
|
754
|
+
// The world moved on (another apply, or a clear) while the worker ran:
|
|
755
|
+
// composing now would paint a sky nobody asked for over the current one.
|
|
756
|
+
if (this.generation !== generation || this.applied?.scene !== scene) return;
|
|
757
|
+
this.compose(scene, world, expression, camera);
|
|
758
|
+
})();
|
|
759
|
+
this.pending = pending;
|
|
760
|
+
pendingWorld.add(pending);
|
|
761
|
+
// Both arms, or a rejection here is an unhandled one; `worldReady()` still
|
|
762
|
+
// awaits `pending` itself, so the failure reaches the capture LOUDLY rather
|
|
763
|
+
// than leaving it to photograph a scene with no sky.
|
|
764
|
+
void pending.then(
|
|
765
|
+
() => pendingWorld.delete(pending),
|
|
766
|
+
() => pendingWorld.delete(pending),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private compose(
|
|
771
|
+
scene: THREE.Scene,
|
|
772
|
+
world: WorldData,
|
|
773
|
+
expression: WorldExpression,
|
|
774
|
+
camera?: THREE.Camera,
|
|
775
|
+
): void {
|
|
776
|
+
this.texture = worldTexture(expression, world.strength, camera);
|
|
777
|
+
if (usesWindowCoordinates(expression)) {
|
|
778
|
+
if (!camera) throw new Error('World Window coordinates need a render camera');
|
|
779
|
+
this.screenTexture = worldDataTexture(
|
|
780
|
+
sampleWorldScreen(expression, 512, 512, camera),
|
|
781
|
+
world.strength,
|
|
782
|
+
512,
|
|
783
|
+
512,
|
|
784
|
+
);
|
|
785
|
+
this.screenTexture.mapping = THREE.UVMapping;
|
|
786
|
+
scene.background = this.screenTexture;
|
|
787
|
+
} else if (camera && (camera as THREE.OrthographicCamera).isOrthographicCamera) {
|
|
788
|
+
// Orthographic rays are parallel: every background pixel samples the
|
|
789
|
+
// same world direction. Three's unit skybox cannot fill this projection.
|
|
790
|
+
const direction = camera.getWorldDirection(new THREE.Vector3());
|
|
791
|
+
direction.set(direction.x, -direction.z, direction.y);
|
|
792
|
+
const value = worldField(world.shader ?? world.color)(direction);
|
|
793
|
+
scene.background =
|
|
794
|
+
typeof value === 'number'
|
|
795
|
+
? new THREE.Color().setRGB(value, value, value).multiplyScalar(world.strength)
|
|
796
|
+
: new THREE.Color().setRGB(value.x, value.y, value.z).multiplyScalar(world.strength);
|
|
797
|
+
} else {
|
|
798
|
+
scene.background = this.texture;
|
|
799
|
+
}
|
|
800
|
+
// Replace the document environment so the model is not lit twice. When
|
|
801
|
+
// `Is Camera Ray` split the world, what LIGHTS the scene is a different
|
|
802
|
+
// radiance from what the camera photographs -- `19-tram-stop` lights at
|
|
803
|
+
// 0.30 behind a 0.95 backdrop -- and three keeps the two apart, so using
|
|
804
|
+
// the backdrop here would light that scene three times too brightly.
|
|
805
|
+
const { lighting } = world;
|
|
806
|
+
this.environmentTexture = lighting
|
|
807
|
+
? worldTexture(lighting.shader ?? lighting.color, lighting.strength, camera)
|
|
808
|
+
: null;
|
|
809
|
+
scene.environment = this.environmentTexture ?? this.texture;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
clear(): void {
|
|
813
|
+
this.generation++;
|
|
814
|
+
if (!this.applied) return;
|
|
815
|
+
this.applied.scene.background = this.applied.background;
|
|
816
|
+
this.applied.scene.environment = this.applied.environment;
|
|
817
|
+
this.applied = null;
|
|
818
|
+
this.texture?.dispose();
|
|
819
|
+
this.texture = null;
|
|
820
|
+
this.environmentTexture?.dispose();
|
|
821
|
+
this.environmentTexture = null;
|
|
822
|
+
this.screenTexture?.dispose();
|
|
823
|
+
this.screenTexture = null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
dispose(): void {
|
|
827
|
+
this.clear();
|
|
828
|
+
}
|
|
829
|
+
}
|