@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,461 @@
1
+ /**
2
+ * Blender's MULTIPLE SCATTERING sky, transcribed from the pinned source.
3
+ *
4
+ * `intern/sky/source/sky_multiple_scattering.cpp` precomputes a 512x256 XYZ
5
+ * texture and `gpu_shader_material_tex_sky.glsl`'s `node_tex_sky_nishita` reads
6
+ * it back per direction; both halves are here because both are what a script
7
+ * sees through `ShaderNodeTexSky`.
8
+ *
9
+ * This is the MULTIPLE scattering model, not Nishita's single scattering. The
10
+ * courtyard's own recording is why: its seq 31 sets `sky_type = 'NISHITA'` and
11
+ * native REFUSES -- `enum "NISHITA" not found in ('SINGLE_SCATTERING',
12
+ * 'MULTIPLE_SCATTERING', 'PREETHAM', 'HOSEK_WILKIE')` -- so the sky it renders
13
+ * is the untouched default, which is MULTIPLE_SCATTERING.
14
+ *
15
+ * The constants and the arithmetic are the source's. The XYZ-to-scene-linear
16
+ * matrix is not in the source -- it comes from the colour configuration -- and
17
+ * was measured off the oracle by round-tripping an EXR through
18
+ * `Linear CIE-XYZ D65`.
19
+ */
20
+ import * as THREE from 'three';
21
+
22
+ const M_PI_2_F = 1.5707963267948966;
23
+ const M_2PI_F = 6.2831853071795864;
24
+ const M_1_PI_F = 0.3183098861837067;
25
+ const M_1_4PI_F = 0.0795774715459476;
26
+
27
+ const GROUND_ALBEDO = 0.3;
28
+ const PHASE_ISOTROPIC = M_1_4PI_F;
29
+ const RAYLEIGH_PHASE_SCALE = (3.0 / 16.0) * M_1_PI_F;
30
+ const G = 0.8;
31
+ const SQR_G = G * G;
32
+ const EARTH_RADIUS = 6371.0;
33
+ const ATMOSPHERE_THICKNESS = 100.0;
34
+ const ATMOSPHERE_RADIUS = EARTH_RADIUS + ATMOSPHERE_THICKNESS;
35
+ const TRANSMITTANCE_STEPS = 64;
36
+ const IN_SCATTERING_STEPS = 64;
37
+ const TRANSMITTANCE_RES_X = 256;
38
+ const TRANSMITTANCE_RES_Y = 64;
39
+
40
+ /** Sampled at 630, 560, 490 and 430 nm -- the four wavelengths the model uses. */
41
+ const SUN_SPECTRAL_IRRADIANCE = [1.679, 1.828, 1.986, 1.307];
42
+ const MOLECULAR_SCATTERING_COEFFICIENT_BASE = [6.605e-3, 1.067e-2, 1.842e-2, 3.156e-2];
43
+ const OZONE_ABSORPTION_CROSS_SECTION = [3.472e-25, 3.914e-25, 1.349e-25, 11.03e-27];
44
+ const OZONE_MEAN_DOBSON = 334.5;
45
+ const AEROSOL_ABSORPTION_CROSS_SECTION = [2.8722e-24, 4.6168e-24, 7.9706e-24, 1.3578e-23];
46
+ const AEROSOL_SCATTERING_CROSS_SECTION = [1.5908e-22, 1.7711e-22, 2.0942e-22, 2.4033e-22];
47
+ const AEROSOL_BASE_DENSITY = 1.3681e20;
48
+ const AEROSOL_BACKGROUND_DENSITY = 2e6;
49
+ const AEROSOL_HEIGHT_SCALE = 0.73;
50
+
51
+ const SPECTRAL_XYZ = [
52
+ [53.386917738564668023, 22.981337506691024754, 0.0],
53
+ [43.904844466369358263, 71.347795700053393866, 0.102506867965741307],
54
+ [1.6137278251608962005, 18.422960591455485011, 31.742921188390805758],
55
+ [20.762668673810577145, 2.3614213523314368527, 110.48009643252140334],
56
+ ];
57
+
58
+ /** `IMB_colormanagement_get_xyz_to_scene_linear()`, measured on the oracle by
59
+ * loading an EXR of the three basis vectors as `Linear CIE-XYZ D65`. */
60
+ const XYZ_TO_RGB = [
61
+ [3.2409698963165283, -1.5373831987380981, -0.49861079454421997],
62
+ [-0.969243586063385, 1.8759675025939941, 0.041555099189281464],
63
+ [0.05563009902834892, -0.20397700369358063, 1.056971549987793],
64
+ ];
65
+
66
+ const sqr = (a: number): number => a * a;
67
+ const saturate = (a: number): number => (a < 0 ? 0 : a > 1 ? 1 : a);
68
+ const mix = (x: number, y: number, a: number): number => x + a * (y - x);
69
+ const safeSqrt = (f: number): number => Math.sqrt(Math.max(f, 0));
70
+
71
+ /** `sun_direction(cos_theta)` -- in the model's own frame, azimuth zero. */
72
+ const sunDirection = (cosTheta: number): [number, number, number] => [
73
+ -Math.sqrt(1.0 - cosTheta * cosTheta),
74
+ 0.0,
75
+ cosTheta,
76
+ ];
77
+
78
+ function raySphereIntersection(
79
+ px: number,
80
+ py: number,
81
+ pz: number,
82
+ dx: number,
83
+ dy: number,
84
+ dz: number,
85
+ radius: number,
86
+ ): number {
87
+ const b = px * dx + py * dy + pz * dz;
88
+ const c = px * px + py * py + pz * pz - radius * radius;
89
+ if (c > 0 && b > 0) return -1;
90
+ const d = b * b - c;
91
+ if (d < 0) return -1;
92
+ return d >= b * b ? -b + Math.sqrt(d) : -b - Math.sqrt(d);
93
+ }
94
+
95
+ const molecularPhase = (cosTheta: number): number => RAYLEIGH_PHASE_SCALE * (1.0 + sqr(cosTheta));
96
+
97
+ function aerosolPhase(cosTheta: number): number {
98
+ const den = 1.0 + SQR_G + 2.0 * G * cosTheta;
99
+ return (M_1_4PI_F * (1.0 - SQR_G)) / (den * Math.sqrt(den));
100
+ }
101
+
102
+ const aerosolDensity = (h: number): number =>
103
+ AEROSOL_BASE_DENSITY *
104
+ (Math.exp(-h / AEROSOL_HEIGHT_SCALE) + AEROSOL_BACKGROUND_DENSITY / AEROSOL_BASE_DENSITY);
105
+
106
+ export interface SkyParameters {
107
+ sunElevation: number;
108
+ sunRotation: number;
109
+ altitude: number;
110
+ airDensity: number;
111
+ aerosolDensity: number;
112
+ ozoneDensity: number;
113
+ }
114
+
115
+ /** `sky_simplify_multiscatter_elevation_rotation` -- the sun is wrapped into
116
+ * the half of the sphere the texture covers, and the rotation carries the
117
+ * other half. */
118
+ function simplify(sunElevation: number, sunRotation: number): [number, number] {
119
+ let elevation = sunElevation % M_2PI_F;
120
+ if (Math.abs(elevation) >= Math.PI) elevation -= Math.sign(elevation) * 2.0 * Math.PI;
121
+ let rotation = sunRotation;
122
+ if (elevation >= M_PI_2_F || elevation <= -M_PI_2_F) {
123
+ elevation = Math.sign(elevation) * Math.PI - elevation;
124
+ rotation += Math.PI;
125
+ }
126
+ rotation = rotation % M_2PI_F;
127
+ if (rotation < 0) rotation += M_2PI_F;
128
+ rotation = M_2PI_F - rotation;
129
+ return [elevation, rotation];
130
+ }
131
+
132
+ class Atmosphere {
133
+ private readonly lut = new Float32Array(TRANSMITTANCE_RES_Y * TRANSMITTANCE_RES_X * 4);
134
+
135
+ constructor(
136
+ private readonly air: number,
137
+ private readonly aerosol: number,
138
+ private readonly ozone: number,
139
+ ) {}
140
+
141
+ /** `get_atmosphere_collision_coefficients`, into a caller's four slots. */
142
+ private coefficients(altitude: number, out: Float64Array): void {
143
+ const local = aerosolDensity(altitude) * this.aerosol;
144
+ const logH = Math.log(Math.max(altitude, 1e-4));
145
+ const ozoneDensity = 3.78547397e20 * Math.exp(-sqr(logH - 3.22261) * 5.55555555 - logH);
146
+ const molecular = Math.exp(-0.07771971 * altitude ** 1.16364243);
147
+ for (let i = 0; i < 4; i++) {
148
+ out[i] = AEROSOL_ABSORPTION_CROSS_SECTION[i]! * local; // aerosol absorption
149
+ out[4 + i] = AEROSOL_SCATTERING_CROSS_SECTION[i]! * local; // aerosol scattering
150
+ out[8 + i] =
151
+ OZONE_ABSORPTION_CROSS_SECTION[i]! * OZONE_MEAN_DOBSON * ozoneDensity * this.ozone;
152
+ out[12 + i] = MOLECULAR_SCATTERING_COEFFICIENT_BASE[i]! * molecular * this.air;
153
+ }
154
+ }
155
+
156
+ /** `get_transmittance`: the atmosphere from an altitude to the sun. */
157
+ transmittance(cosTheta: number, normalizedAltitude: number, out: Float64Array): void {
158
+ const [sx, sy, sz] = sunDirection(cosTheta);
159
+ const distance = mix(EARTH_RADIUS, ATMOSPHERE_RADIUS, normalizedAltitude);
160
+ const td = raySphereIntersection(0, 0, distance, sx, sy, sz, ATMOSPHERE_RADIUS);
161
+ const step = td / TRANSMITTANCE_STEPS;
162
+ const acc = [0, 0, 0, 0];
163
+ const slots = new Float64Array(16);
164
+ for (let s = 0; s < TRANSMITTANCE_STEPS; s++) {
165
+ const t = (s + 0.5) * step;
166
+ const x = sx * t,
167
+ y = sy * t,
168
+ z = distance + sz * t;
169
+ const altitude = Math.max(Math.hypot(x, y, z) - EARTH_RADIUS, 0);
170
+ this.coefficients(altitude, slots);
171
+ for (let i = 0; i < 4; i++) {
172
+ acc[i]! += (slots[i]! + slots[4 + i]! + slots[8 + i]! + slots[12 + i]!) * step;
173
+ }
174
+ }
175
+ for (let i = 0; i < 4; i++) out[i] = Math.exp(-acc[i]!);
176
+ }
177
+
178
+ precompute(): void {
179
+ const out = new Float64Array(4);
180
+ for (let y = 0; y < TRANSMITTANCE_RES_Y; y++) {
181
+ for (let x = 0; x < TRANSMITTANCE_RES_X; x++) {
182
+ const u = x / (TRANSMITTANCE_RES_X - 1);
183
+ const v = y / (TRANSMITTANCE_RES_Y - 1);
184
+ this.transmittance(u * 2.0 - 1.0, v, out);
185
+ const at = (y * TRANSMITTANCE_RES_X + x) * 4;
186
+ for (let i = 0; i < 4; i++) this.lut[at + i] = out[i]!;
187
+ }
188
+ }
189
+ }
190
+
191
+ private lookup(cosTheta: number, normalizedAltitude: number, out: Float64Array): void {
192
+ const x = (TRANSMITTANCE_RES_X - 1) * saturate(cosTheta * 0.5 + 0.5);
193
+ const y = (TRANSMITTANCE_RES_Y - 1) * saturate(normalizedAltitude);
194
+ const x1 = Math.trunc(x),
195
+ y1 = Math.trunc(y);
196
+ const x2 = Math.min(x1 + 1, TRANSMITTANCE_RES_X - 1);
197
+ const y2 = Math.min(y1 + 1, TRANSMITTANCE_RES_Y - 1);
198
+ const fx = x - x1,
199
+ fy = y - y1;
200
+ const a = (y1 * TRANSMITTANCE_RES_X + x1) * 4,
201
+ b = (y1 * TRANSMITTANCE_RES_X + x2) * 4;
202
+ const c = (y2 * TRANSMITTANCE_RES_X + x1) * 4,
203
+ d = (y2 * TRANSMITTANCE_RES_X + x2) * 4;
204
+ for (let i = 0; i < 4; i++) {
205
+ out[i] = mix(
206
+ mix(this.lut[a + i]!, this.lut[b + i]!, fx),
207
+ mix(this.lut[c + i]!, this.lut[d + i]!, fx),
208
+ fy,
209
+ );
210
+ }
211
+ }
212
+
213
+ private lookupAtGround(cosTheta: number, out: Float64Array): void {
214
+ const x = (TRANSMITTANCE_RES_X - 1) * saturate(cosTheta * 0.5 + 0.5);
215
+ const x1 = Math.trunc(x),
216
+ x2 = Math.min(x1 + 1, TRANSMITTANCE_RES_X - 1);
217
+ const fx = x - x1;
218
+ for (let i = 0; i < 4; i++) out[i] = mix(this.lut[x1 * 4 + i]!, this.lut[x2 * 4 + i]!, fx);
219
+ }
220
+
221
+ private lookupToSun(normalizedAltitude: number, out: Float64Array): void {
222
+ const y = (TRANSMITTANCE_RES_Y - 1) * saturate(normalizedAltitude);
223
+ const y1 = Math.trunc(y),
224
+ y2 = Math.min(y1 + 1, TRANSMITTANCE_RES_Y - 1);
225
+ const fy = y - y1;
226
+ const x = TRANSMITTANCE_RES_X - 1;
227
+ for (let i = 0; i < 4; i++) {
228
+ out[i] = mix(
229
+ this.lut[(y1 * TRANSMITTANCE_RES_X + x) * 4 + i]!,
230
+ this.lut[(y2 * TRANSMITTANCE_RES_X + x) * 4 + i]!,
231
+ fy,
232
+ );
233
+ }
234
+ }
235
+
236
+ /** `get_inscattering` along one ray, returned as spectral radiance. */
237
+ inscattering(
238
+ sun: [number, number, number],
239
+ ox: number,
240
+ oy: number,
241
+ oz: number,
242
+ dx: number,
243
+ dy: number,
244
+ dz: number,
245
+ td: number,
246
+ out: Float64Array,
247
+ ): void {
248
+ const cosTheta = -(dx * sun[0] + dy * sun[1] + dz * sun[2]);
249
+ const phaseM = molecularPhase(cosTheta);
250
+ const phaseA = aerosolPhase(cosTheta);
251
+ const dt = td / IN_SCATTERING_STEPS;
252
+ const slots = new Float64Array(16);
253
+ const toSun = new Float64Array(4);
254
+ const ms = new Float64Array(4);
255
+ const ground = new Float64Array(4);
256
+ const atZero = new Float64Array(4);
257
+ const atHeight = new Float64Array(4);
258
+ const L = [0, 0, 0, 0];
259
+ const transmittance = [1, 1, 1, 1];
260
+ for (let s = 0; s < IN_SCATTERING_STEPS; s++) {
261
+ const t = (s + 0.5) * dt;
262
+ const x = ox + dx * t,
263
+ y = oy + dy * t,
264
+ z = oz + dz * t;
265
+ const distance = Math.hypot(x, y, z);
266
+ const altitude = Math.max(distance - EARTH_RADIUS, 0);
267
+ const normalized = altitude / ATMOSPHERE_THICKNESS;
268
+ const sampleCos = (x * sun[0] + y * sun[1] + z * sun[2]) / distance;
269
+ this.coefficients(altitude, slots);
270
+ this.lookup(sampleCos, normalized, toSun);
271
+ // `lookup_multiscattering`: second-order bounce off the ground, plus the
272
+ // analytical fit for everything else in the atmosphere.
273
+ const omega = M_2PI_F * (1.0 - safeSqrt(1.0 - sqr(EARTH_RADIUS / distance)));
274
+ this.lookupAtGround(sampleCos, ground);
275
+ this.lookupToSun(0.0, atZero);
276
+ this.lookupToSun(normalized, atHeight);
277
+ const fit = 1.0 / (1.0 + 5.0 * Math.exp(-17.92 * sampleCos));
278
+ const MS_FIT = [0.217, 0.347, 0.594, 1.0];
279
+ for (let i = 0; i < 4; i++) {
280
+ const groundToSample = atZero[i]! / atHeight[i]!;
281
+ const lGround =
282
+ PHASE_ISOTROPIC *
283
+ omega *
284
+ (GROUND_ALBEDO * M_1_PI_F) *
285
+ ground[i]! *
286
+ groundToSample *
287
+ sampleCos;
288
+ ms[i] = 0.02 * MS_FIT[i]! * fit + lGround;
289
+ }
290
+ for (let i = 0; i < 4; i++) {
291
+ const extinction = slots[i]! + slots[4 + i]! + slots[8 + i]! + slots[12 + i]!;
292
+ const S =
293
+ SUN_SPECTRAL_IRRADIANCE[i]! *
294
+ (slots[12 + i]! * (phaseM * toSun[i]! + ms[i]!) +
295
+ slots[4 + i]! * (phaseA * toSun[i]! + ms[i]!));
296
+ const stepT = Math.exp(-dt * extinction);
297
+ // Energy-conserving analytical integration (Hillaire).
298
+ const cut = Math.max(extinction, 1e-7);
299
+ L[i]! += transmittance[i]! * ((S - S * stepT) / cut);
300
+ transmittance[i]! *= stepT;
301
+ }
302
+ }
303
+ for (let i = 0; i < 4; i++) out[i] = L[i]!;
304
+ }
305
+ }
306
+
307
+ const SKY_WIDTH = 512;
308
+ const SKY_HEIGHT = 256;
309
+
310
+ /** `SKY_multiple_scattering_precompute_texture`: the XYZ sky, mirrored in x. */
311
+ function precomputeTexture(p: SkyParameters, sunElevation: number): Float32Array {
312
+ const sky = new Atmosphere(p.airDensity, p.aerosolDensity, p.ozoneDensity);
313
+ sky.precompute();
314
+ const altitude = Math.min(Math.max(p.altitude, 1.0), 99999.0) / 1000.0;
315
+ const sun = sunDirection(Math.cos(M_PI_2_F - sunElevation));
316
+ const pixels = new Float32Array(SKY_WIDTH * SKY_HEIGHT * 3);
317
+ const half = SKY_WIDTH / 2;
318
+ const L = new Float64Array(4);
319
+ for (let y = 0; y < SKY_HEIGHT; y++) {
320
+ for (let x = 0; x < half; x++) {
321
+ const u = (x + 0.5) / SKY_WIDTH;
322
+ const v = (y + 0.5) / SKY_HEIGHT;
323
+ const azimuth = M_2PI_F * u;
324
+ // More texels near the horizon, where the detail is.
325
+ const l = v * 2.0 - 1.0;
326
+ const elev = Math.sign(l) * sqr(l) * M_PI_2_F;
327
+ const dx = Math.cos(elev) * Math.cos(azimuth);
328
+ const dy = Math.cos(elev) * Math.sin(azimuth);
329
+ const dz = Math.sin(elev);
330
+ const oz = EARTH_RADIUS + altitude;
331
+ const atmos = raySphereIntersection(0, 0, oz, dx, dy, dz, ATMOSPHERE_RADIUS);
332
+ const groundHit = raySphereIntersection(0, 0, oz, dx, dy, dz, EARTH_RADIUS);
333
+ const td = groundHit < 0 ? atmos : groundHit;
334
+ sky.inscattering(sun, 0, 0, oz, dx, dy, dz, td, L);
335
+ let X = 0,
336
+ Y = 0,
337
+ Z = 0;
338
+ for (let i = 0; i < 4; i++) {
339
+ X += SPECTRAL_XYZ[i]![0]! * L[i]!;
340
+ Y += SPECTRAL_XYZ[i]![1]! * L[i]!;
341
+ Z += SPECTRAL_XYZ[i]![2]! * L[i]!;
342
+ }
343
+ const at = (y * SKY_WIDTH + x) * 3;
344
+ pixels[at] = X;
345
+ pixels[at + 1] = Y;
346
+ pixels[at + 2] = Z;
347
+ const mirror = (y * SKY_WIDTH + (SKY_WIDTH - x - 1)) * 3;
348
+ pixels[mirror] = X;
349
+ pixels[mirror + 1] = Y;
350
+ pixels[mirror + 2] = Z;
351
+ }
352
+ }
353
+ return pixels;
354
+ }
355
+
356
+ /**
357
+ * THE 15-SECOND HALF, as a pure function of the parameters.
358
+ *
359
+ * `precomputeTexture` walks 512x256 texels with 64 in-scattering steps each and
360
+ * that is where a sky's cost lives; reading it back (below) is 32,768 bilinear
361
+ * lookups and costs milliseconds. Split out because the expensive half takes
362
+ * numbers and returns numbers -- no THREE, no DOM -- so a WORKER can run it and
363
+ * post the Float32Array back. See `contributions/sky-precompute-worker.ts`.
364
+ */
365
+ export function precomputeSkyTexture(p: SkyParameters): Float32Array {
366
+ const [elevation] = simplify(p.sunElevation, p.sunRotation);
367
+ return precomputeTexture(p, elevation);
368
+ }
369
+
370
+ /** The identity of a precomputed sky: every parameter the texture depends on.
371
+ * Rotation is NOT one of them -- `simplify` can fold half a turn of elevation
372
+ * into it, so it is folded here too and the texture keyed on what is left. */
373
+ export function skyTextureKey(p: SkyParameters): string {
374
+ const [elevation] = simplify(p.sunElevation, p.sunRotation);
375
+ return JSON.stringify([elevation, p.altitude, p.airDensity, p.aerosolDensity, p.ozoneDensity]);
376
+ }
377
+
378
+ /** Precomputed sky textures, however they were derived. `skyField` is
379
+ * synchronous and always will be -- a field over directions has no business
380
+ * being a promise -- so the ASYNC part is priming this map, and every caller
381
+ * of `skyField` then runs at lookup speed. A handful of entries covers the
382
+ * background/lighting pair a split world needs plus the last few edits. */
383
+ const SKY_TEXTURE_CACHE_LIMIT = 6;
384
+ const skyTextures = new Map<string, Float32Array>();
385
+
386
+ /** Whether `skyField` for these parameters would be free. A caller that needs
387
+ * to stay off the main thread asks this FIRST and primes what is missing. */
388
+ export function hasSkyTexture(p: SkyParameters): boolean {
389
+ return skyTextures.has(skyTextureKey(p));
390
+ }
391
+
392
+ /** Hand a derived texture in, from wherever it was derived. */
393
+ export function primeSkyTexture(p: SkyParameters, pixels: Float32Array): void {
394
+ skyTextures.set(skyTextureKey(p), pixels);
395
+ // Oldest out first: insertion order is the Map's own.
396
+ if (skyTextures.size > SKY_TEXTURE_CACHE_LIMIT) {
397
+ skyTextures.delete(skyTextures.keys().next().value as string);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * One Sky Texture node as a field over directions, in Blender's Z-up frame.
403
+ *
404
+ * The texture is built once per node and read the way
405
+ * `node_tex_sky_nishita` reads it: REPEAT in x (azimuth) and clamped in y,
406
+ * with the elevation's non-linear transform undone.
407
+ *
408
+ * Uses a primed texture when there is one and derives it INLINE when there is
409
+ * not. The inline path is the slow one on purpose: it is correct, and a caller
410
+ * who cares about the main thread primes the cache first.
411
+ */
412
+ export function skyField(p: SkyParameters): (direction: THREE.Vector3) => THREE.Vector3 {
413
+ const [elevation, rotation] = simplify(p.sunElevation, p.sunRotation);
414
+ const key = skyTextureKey(p);
415
+ let pixels = skyTextures.get(key);
416
+ if (pixels === undefined) {
417
+ pixels = precomputeTexture(p, elevation);
418
+ primeSkyTexture(p, pixels);
419
+ }
420
+ const colour = new THREE.Vector3();
421
+ return (direction) => {
422
+ const horizontal = Math.hypot(direction.x, direction.y);
423
+ const dirElevation = Math.atan2(direction.z, horizontal);
424
+ const phi = Math.atan2(direction.x, direction.y);
425
+ let u = (phi + Math.PI + rotation) / M_2PI_F;
426
+ u -= Math.floor(u); // REPEAT
427
+ const abs = Math.abs(dirElevation);
428
+ const v = saturate(Math.sqrt(abs / M_PI_2_F) * Math.sign(dirElevation) * 0.5 + 0.5);
429
+ const fx = u * SKY_WIDTH - 0.5;
430
+ const fy = v * SKY_HEIGHT - 0.5;
431
+ const x1 = Math.floor(fx),
432
+ y1 = Math.floor(fy);
433
+ const tx = fx - x1,
434
+ ty = fy - y1;
435
+ const wrap = (x: number): number => ((x % SKY_WIDTH) + SKY_WIDTH) % SKY_WIDTH;
436
+ const clamp = (y: number): number => (y < 0 ? 0 : y > SKY_HEIGHT - 1 ? SKY_HEIGHT - 1 : y);
437
+ const xa = wrap(x1),
438
+ xb = wrap(x1 + 1),
439
+ ya = clamp(y1),
440
+ yb = clamp(y1 + 1);
441
+ const xyz = [0, 0, 0];
442
+ for (let i = 0; i < 3; i++) {
443
+ const top = mix(
444
+ pixels[(ya * SKY_WIDTH + xa) * 3 + i]!,
445
+ pixels[(ya * SKY_WIDTH + xb) * 3 + i]!,
446
+ tx,
447
+ );
448
+ const bottom = mix(
449
+ pixels[(yb * SKY_WIDTH + xa) * 3 + i]!,
450
+ pixels[(yb * SKY_WIDTH + xb) * 3 + i]!,
451
+ tx,
452
+ );
453
+ xyz[i] = mix(top, bottom, ty);
454
+ }
455
+ return colour.set(
456
+ XYZ_TO_RGB[0]![0]! * xyz[0]! + XYZ_TO_RGB[0]![1]! * xyz[1]! + XYZ_TO_RGB[0]![2]! * xyz[2]!,
457
+ XYZ_TO_RGB[1]![0]! * xyz[0]! + XYZ_TO_RGB[1]![1]! * xyz[1]! + XYZ_TO_RGB[1]![2]! * xyz[2]!,
458
+ XYZ_TO_RGB[2]![0]! * xyz[0]! + XYZ_TO_RGB[2]![1]! * xyz[1]! + XYZ_TO_RGB[2]![2]! * xyz[2]!,
459
+ );
460
+ };
461
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Blender's `Standard` view transform, off a scene-linear frame.
3
+ *
4
+ * THE CONFIG SAYS WHAT THIS IS. In the pinned colour configuration
5
+ * (`release/datafiles/colormanagement/config.ocio`) the `Standard` view
6
+ * resolves to the `sRGB` display colour space, whose `from_display_reference`
7
+ * is a `ColorSpaceTransform` to `Linear Rec.709` followed by
8
+ * `ExponentWithLinearTransform {gamma: 2.4, offset: 0.055, direction: inverse}`.
9
+ * Blender's scene reference IS Linear Rec.709, so the colour-space leg is the
10
+ * identity and the whole transform is that one curve — the sRGB compound
11
+ * (piece-wise) encoding, with no LUT and nothing to load.
12
+ *
13
+ * THE CONSTANTS ARE DERIVED, NOT QUOTED. OCIO computes the break point and the
14
+ * linear slope from `gamma` and `offset` by requiring the two pieces to meet
15
+ * with a continuous derivative, which lands at 0.0030402 and 12.9232 — close
16
+ * to, and deliberately not, IEC 61966-2-1's rounded 0.0031308 and 12.92.
17
+ * Deriving them here keeps the two numbers honest against the two the config
18
+ * actually states.
19
+ *
20
+ * WHERE IT IS USED, and where it is not: a COMPOSITED frame, whose pixels are
21
+ * the compositor's scene-referred output and can no longer be photographed
22
+ * through three's own output encoding. An ordinary Standard render is still
23
+ * three's capture, unchanged — it is the same curve applied by the same
24
+ * renderer that drew the frame, and moving it here would change bytes for no
25
+ * reason.
26
+ *
27
+ * NEGATIVE INPUT: clamped. OCIO's default negative handling for this transform
28
+ * mirrors the curve about zero, which lands below zero and clips to byte 0 in
29
+ * `encodeDisplayFrame` — the same byte clamping to zero produces, so the two
30
+ * readings are indistinguishable in the PNG this writes.
31
+ */
32
+ import { encodeDisplayFrame } from './blender-display-lut';
33
+
34
+ const GAMMA = 2.4;
35
+ const OFFSET = 0.055;
36
+ /** The display-side break, where the power piece meets the linear one. */
37
+ const BREAK = OFFSET / (GAMMA - 1);
38
+ /** The linear piece's slope, from matching the power piece's derivative there. */
39
+ const SLOPE = 1 / ((GAMMA / (1 + OFFSET)) * ((BREAK + OFFSET) / (1 + OFFSET)) ** (GAMMA - 1));
40
+ /** The same break expressed on the scene-linear side. */
41
+ const LINEAR_BREAK = ((BREAK + OFFSET) / (1 + OFFSET)) ** GAMMA;
42
+
43
+ /** One scene-linear channel through the sRGB compound encoding. */
44
+ export function standardChannel(value: number): number {
45
+ if (!(value > 0)) return 0;
46
+ if (value < LINEAR_BREAK) return value * SLOPE;
47
+ return (1 + OFFSET) * value ** (1 / GAMMA) - OFFSET;
48
+ }
49
+
50
+ /** Scratch, as everything on the frame path is — see `blender-display-lut.ts`. */
51
+ const display: [number, number, number] = [0, 0, 0];
52
+
53
+ /** One scene-linear colour through Standard, to sRGB display.
54
+ * THE RETURNED TRIPLE IS SCRATCH — the next call overwrites it. */
55
+ export function standardDisplay(rgb: ArrayLike<number>): [number, number, number] {
56
+ display[0] = standardChannel(rgb[0]!);
57
+ display[1] = standardChannel(rgb[1]!);
58
+ display[2] = standardChannel(rgb[2]!);
59
+ return display;
60
+ }
61
+
62
+ export function standardEncodeFrame(
63
+ pixels: Uint16Array,
64
+ count: number,
65
+ exposure: number,
66
+ ): Uint8ClampedArray {
67
+ return encodeDisplayFrame(pixels, count, exposure, standardDisplay);
68
+ }