@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.
Files changed (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. 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
+ }