@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,181 @@
1
+ /**
2
+ * `triangulate` — how a BMesh FACE becomes triangles, and the one place that
3
+ * decision is made.
4
+ *
5
+ * A `BMesh` face is an n-gon; a `BufferGeometry` is triangles. The conversion
6
+ * used to be a naive fan (`0,1,2`, `0,2,3`, `0,3,4`, …), which is correct for
7
+ * a CONVEX polygon and silently wrong for a concave one: the fan's later
8
+ * triangles fold back over the polygon's own reflex corner, so they overlap
9
+ * each other and face BACKWARDS. That renders as a hole. It shipped as one —
10
+ * the treasure chest's crescent lid end-cap (docs/BLENDER-PARITY.md §Driver
11
+ * ladder, "Chest-measured gaps") — and, worse, `validate()` had nothing to say
12
+ * about it, because every count and every winding on the BMesh side was
13
+ * perfectly sound. The defect only existed in the export.
14
+ *
15
+ * So the export's triangulation is now a real one, and it is the SAME
16
+ * function `validate` checks, which is what closes the gap: there is no way
17
+ * for the exporter to fan something the validator did not look at.
18
+ *
19
+ * THE RUNG. Ear clipping is not hand-rolled here: `THREE.ShapeUtils
20
+ * .triangulateShape` is three's own Earcut and ships in the dependency the
21
+ * kit already has. This module supplies only the two things Earcut cannot do
22
+ * for a 3D face — pick the plane to flatten onto, and decide whether the
23
+ * result is trustworthy.
24
+ *
25
+ * THE FAN IS STILL THE FAST PATH, and deliberately so. Every face is fanned
26
+ * FIRST and the fan is kept whenever it is sound, so every convex face — which
27
+ * is nearly all of them, quads included — exports with byte-identical indices
28
+ * to before this module existed. Earcut runs only where the fan actually
29
+ * fails. That is not an optimization: it is what lets an existing model be
30
+ * rebuilt through the fixed kit and change ONLY where it was broken.
31
+ */
32
+
33
+ import * as THREE from 'three';
34
+
35
+ /** One triangle, as indices into the polygon's own corner order. */
36
+ export type TriangleIndices = readonly [number, number, number];
37
+
38
+ /** What `triangulatePolygon` decided, and whether to trust it. */
39
+ export interface PolygonTriangulation {
40
+ /** The triangles, in the polygon's own corner-index space and its own
41
+ * winding. Always `n − 2` of them when `sound`; possibly fewer, or
42
+ * overlapping, when not. */
43
+ readonly triangles: readonly TriangleIndices[];
44
+ /** Whether this is a genuine PARTITION of the polygon: `n − 2` triangles,
45
+ * every one wound with the polygon, and their absolute areas summing to
46
+ * the polygon's own. Overlap or a fold-back makes the sum exceed the
47
+ * polygon area, so the two conditions together are exactly the property
48
+ * that fails when a triangulation renders as a hole.
49
+ *
50
+ * FALSE is a real defect and stays visible rather than being smoothed
51
+ * over: it means the polygon is self-intersecting or so badly warped that
52
+ * no flattening of it is simple, and neither the fan nor Earcut can fix
53
+ * that. `validate` reports it as `overlappingTriangulation`. */
54
+ readonly sound: boolean;
55
+ /** Which path produced `triangles` — `'fan'` is the untouched legacy
56
+ * layout, `'earcut'` is `THREE.ShapeUtils.triangulateShape`. */
57
+ readonly method: 'fan' | 'earcut';
58
+ }
59
+
60
+ /** Relative slack on the area-sum test, in units of the polygon's own area.
61
+ * Generous enough that float noise on a big polygon never fires it, tight
62
+ * enough that a single folded-back ear (which doubles some sub-area) always
63
+ * does. */
64
+ const AREA_TOLERANCE = 1e-6;
65
+
66
+ /** The polygon's Newell area-weighted normal — the standard best-fit plane
67
+ * for a possibly non-planar face, and the one whose length is twice the
68
+ * projected area. */
69
+ function newellNormal(points: readonly THREE.Vector3[]): THREE.Vector3 {
70
+ const n = new THREE.Vector3();
71
+ for (let i = 0; i < points.length; i++) {
72
+ const a = points[i] as THREE.Vector3;
73
+ const b = points[(i + 1) % points.length] as THREE.Vector3;
74
+ n.x += (a.y - b.y) * (a.z + b.z);
75
+ n.y += (a.z - b.z) * (a.x + b.x);
76
+ n.z += (a.x - b.x) * (a.y + b.y);
77
+ }
78
+ return n.multiplyScalar(0.5);
79
+ }
80
+
81
+ /** Twice the signed area of a 2D triangle (positive = counter-clockwise). */
82
+ const cross2 = (a: THREE.Vector2, b: THREE.Vector2, c: THREE.Vector2): number =>
83
+ (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
84
+
85
+ /** The consecutive fan from corner 0 — the layout every export used before
86
+ * this module, reproduced exactly so a sound fan stays byte-identical. */
87
+ const fanOf = (n: number): TriangleIndices[] =>
88
+ Array.from({ length: n - 2 }, (_, i) => [0, i + 1, i + 2] as const);
89
+
90
+ /** Twice the signed area of the projected polygon (shoelace). */
91
+ function polygonArea2(flat: readonly THREE.Vector2[]): number {
92
+ let sum = 0;
93
+ for (let i = 0; i < flat.length; i++) {
94
+ const a = flat[i] as THREE.Vector2;
95
+ const b = flat[(i + 1) % flat.length] as THREE.Vector2;
96
+ sum += a.x * b.y - b.x * a.y;
97
+ }
98
+ return sum;
99
+ }
100
+
101
+ /** Is `triangles` a partition of the projected polygon — right count, all
102
+ * wound with it, and no area double-covered? See `PolygonTriangulation
103
+ * .sound`. */
104
+ function isSound(
105
+ triangles: readonly TriangleIndices[],
106
+ flat: readonly THREE.Vector2[],
107
+ area2: number,
108
+ ): boolean {
109
+ if (triangles.length !== flat.length - 2) return false;
110
+ if (!(Math.abs(area2) > 0)) return false;
111
+ const sign = Math.sign(area2);
112
+ let covered = 0;
113
+ for (const [i, j, k] of triangles) {
114
+ const t = cross2(flat[i] as THREE.Vector2, flat[j] as THREE.Vector2, flat[k] as THREE.Vector2);
115
+ // A sliver of exactly zero area is harmless (it covers nothing and hides
116
+ // nothing); a triangle wound AGAINST the polygon is the fold-back.
117
+ if (t * sign < 0) return false;
118
+ covered += Math.abs(t);
119
+ }
120
+ return Math.abs(covered - Math.abs(area2)) <= AREA_TOLERANCE * Math.abs(area2);
121
+ }
122
+
123
+ /**
124
+ * Triangulate one polygon given its corner positions in order.
125
+ *
126
+ * The polygon is flattened onto its own Newell plane with a right-handed
127
+ * basis, so the projection keeps the polygon's winding and the resulting
128
+ * triangles come back in the corner-index space the caller passed in. Under 3
129
+ * corners there is nothing to do; at exactly 3 the single triangle IS the
130
+ * polygon.
131
+ *
132
+ * A degenerate face — zero Newell normal, i.e. no plane to flatten onto — is
133
+ * reported as an unsound fan rather than throwing: `validate` already has
134
+ * `degenerateFace` for that condition and says it better, and an exporter
135
+ * that threw here would refuse a mesh it could still write.
136
+ */
137
+ export function triangulatePolygon(points: readonly THREE.Vector3[]): PolygonTriangulation {
138
+ const n = points.length;
139
+ if (n < 3) return { triangles: [], sound: false, method: 'fan' };
140
+ if (n === 3) return { triangles: [[0, 1, 2]], sound: true, method: 'fan' };
141
+
142
+ const normal = newellNormal(points);
143
+ const length = normal.length();
144
+ if (!(length > 0) || !Number.isFinite(length)) {
145
+ return { triangles: fanOf(n), sound: false, method: 'fan' };
146
+ }
147
+ const w = normal.clone().divideScalar(length);
148
+ // Any vector not parallel to w gives a usable in-plane axis.
149
+ const seed = Math.abs(w.x) < 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
150
+ const u = new THREE.Vector3().crossVectors(seed, w).normalize();
151
+ const v = new THREE.Vector3().crossVectors(w, u);
152
+ const origin = points[0] as THREE.Vector3;
153
+ const flat = points.map((p) => {
154
+ const d = p.clone().sub(origin);
155
+ return new THREE.Vector2(d.dot(u), d.dot(v));
156
+ });
157
+ const area2 = polygonArea2(flat);
158
+
159
+ const fan = fanOf(n);
160
+ if (isSound(fan, flat, area2)) return { triangles: fan, sound: true, method: 'fan' };
161
+
162
+ // The fan folded back. Hand the flattened contour to three's own Earcut.
163
+ let earcut: TriangleIndices[] = [];
164
+ try {
165
+ earcut = THREE.ShapeUtils.triangulateShape(flat as THREE.Vector2[], []).map(
166
+ (t) => [t[0] as number, t[1] as number, t[2] as number] as const,
167
+ );
168
+ } catch {
169
+ // Earcut refuses a contour it cannot read at all; the fan is still the
170
+ // honest thing to hand back, flagged unsound.
171
+ return { triangles: fan, sound: false, method: 'fan' };
172
+ }
173
+ if (isSound(earcut, flat, area2)) return { triangles: earcut, sound: true, method: 'earcut' };
174
+ // Earcut produced something, but not a partition: keep it (it is still
175
+ // closer than the fan) and let `validate` name the face.
176
+ return {
177
+ triangles: earcut.length > 0 ? earcut : fan,
178
+ sound: false,
179
+ method: earcut.length > 0 ? 'earcut' : 'fan',
180
+ };
181
+ }
Binary file
@@ -0,0 +1,265 @@
1
+ /**
2
+ * THE PRESENTER FOR A HOST WITHOUT A STAGE (WS-AC, cut 1b).
3
+ *
4
+ * "Our Blender renders with three.js" is a translation (`./blender-runtime-view`
5
+ * and the modules beside it) plus one act: point a camera at the translated
6
+ * scene, render into a scene-linear target, read the pixels, run Blender's
7
+ * display transform, answer. The editor performs that act with its own stage
8
+ * and renderer (`packages/blender/host/blender-runtime-host.ts`), which is why
9
+ * the editor never imports THIS file. A host that has no renderer — a bare
10
+ * page with `@volter/browser-wali`, an Emscripten page, the substrate
11
+ * workbench — attaches one of these to the Blender it started, and the guest's
12
+ * `bpy.ops.render.render` comes back as a three.js photograph exactly as it
13
+ * does in the editor.
14
+ *
15
+ * What it does NOT do, on purpose: overlays (modeling chrome, hidden for a
16
+ * render anyway), selection outlines, the editor's supersampled `captureImage`
17
+ * for the Standard transform. Every transform here goes through the linear
18
+ * capture and the same encoders the editor uses for AgX and Filmic.
19
+ */
20
+ import * as THREE from 'three';
21
+ import type { CaptureRequest, RenderRequest } from '../protocol';
22
+ import type { PresentAnswer } from '../runtime';
23
+ import { AGX_LOOK_TABLES, agxEncodeFrame } from './blender-agx';
24
+ import { displayTableUrl } from './blender-display-lut';
25
+ import { filmicEncodeFrame } from './blender-filmic';
26
+ import { BlenderRuntimeView } from './blender-runtime-view';
27
+ import { standardEncodeFrame } from './blender-standard';
28
+
29
+ export interface PresenterOptions {
30
+ /** The canvas to render into; an OffscreenCanvas of 1x1 when absent. */
31
+ canvas?: HTMLCanvasElement | OffscreenCanvas;
32
+ /** The photograph camera's clipping; the editor's stage camera values. */
33
+ near?: number;
34
+ far?: number;
35
+ }
36
+
37
+ export interface Presenter {
38
+ /** The translated scene, for a host that also wants to SHOW it. */
39
+ readonly view: BlenderRuntimeView;
40
+ readonly scene: THREE.Scene;
41
+ /** `BlenderRuntimeOptions.present`, answered here. */
42
+ present(frame: unknown, description: unknown, capture?: CaptureRequest): Promise<PresentAnswer>;
43
+ dispose(): void;
44
+ }
45
+
46
+ const MAXIMUM_EDGE = 2048;
47
+
48
+ const displayTables = new Map<string, Promise<Uint16Array>>();
49
+ function displayTable(file: string): Promise<Uint16Array> {
50
+ let pending = displayTables.get(file);
51
+ if (!pending) {
52
+ pending = fetch(displayTableUrl(file)).then(async (response) => {
53
+ if (!response.ok)
54
+ throw new Error(`Blender display table ${file}: ${response.status} ${response.statusText}`);
55
+ return new Uint16Array(await response.arrayBuffer());
56
+ });
57
+ displayTables.set(file, pending);
58
+ }
59
+ return pending;
60
+ }
61
+
62
+ function displayTableFor(render: RenderRequest): string | null {
63
+ const look = render.look ?? 'None';
64
+ if (render.toneMapping === 'none') return null;
65
+ if (render.toneMapping === 'filmic') return 'filmic-srgb.lut';
66
+ if (render.toneMapping !== 'agx')
67
+ throw new Error(
68
+ `Blender's Khronos PBR Neutral view transform has no scene-linear implementation in the ` +
69
+ `browser (implemented: Standard, AgX, Filmic)`,
70
+ );
71
+ if (look === 'None') return 'agx-base-srgb.lut';
72
+ const file = AGX_LOOK_TABLES[look as keyof typeof AGX_LOOK_TABLES];
73
+ if (file === undefined) throw new Error(`Blender display transform has no table for look ${look}`);
74
+ return file;
75
+ }
76
+
77
+ async function pngBase64(bytes: Uint8Array | Uint8ClampedArray, width: number, height: number): Promise<string> {
78
+ // The linear capture is bottom-up (GL); the image is top-down.
79
+ const stride = width * 4;
80
+ const image = new Uint8ClampedArray(width * height * 4);
81
+ for (let y = 0; y < height; y++) {
82
+ const source = (height - 1 - y) * stride;
83
+ image.set(bytes.subarray(source, source + stride), y * stride);
84
+ }
85
+ const data = new ImageData(image, width, height);
86
+ if (typeof document !== 'undefined') {
87
+ const canvas = document.createElement('canvas');
88
+ canvas.width = width;
89
+ canvas.height = height;
90
+ const context = canvas.getContext('2d');
91
+ if (!context) throw new Error('Blender display transform could not create its image canvas');
92
+ context.putImageData(data, 0, 0);
93
+ const dataUrl = canvas.toDataURL('image/png');
94
+ return dataUrl.slice(dataUrl.indexOf(',') + 1);
95
+ }
96
+ const canvas = new OffscreenCanvas(width, height);
97
+ const context = canvas.getContext('2d');
98
+ if (!context) throw new Error('Blender display transform could not create its image canvas');
99
+ context.putImageData(data, 0, 0);
100
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
101
+ const raw = new Uint8Array(await blob.arrayBuffer());
102
+ let binary = '';
103
+ for (let i = 0; i < raw.length; i += 0x8000)
104
+ binary += String.fromCharCode(...raw.subarray(i, i + 0x8000));
105
+ return btoa(binary);
106
+ }
107
+
108
+ export function createPresenter(options: PresenterOptions = {}): Presenter {
109
+ const view = new BlenderRuntimeView();
110
+ const scene = new THREE.Scene();
111
+ scene.add(view.root);
112
+ const canvas = options.canvas ?? new OffscreenCanvas(1, 1);
113
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
114
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
115
+ renderer.shadowMap.enabled = true;
116
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
117
+ renderer.setPixelRatio(1);
118
+ const near = options.near ?? 0.1;
119
+ const far = options.far ?? 1000;
120
+
121
+ async function present(
122
+ frame: unknown,
123
+ description: unknown,
124
+ capture?: CaptureRequest,
125
+ ): Promise<PresentAnswer> {
126
+ const applied = view.applyFrame(frame) as { held?: unknown } | null | undefined;
127
+ const held =
128
+ typeof applied === 'object' && applied !== null && 'held' in applied
129
+ ? (applied.held as { session: string; revision: number } | null)
130
+ : null;
131
+ view.recordPresentation(description);
132
+ const answer = (photograph?: unknown): PresentAnswer => ({
133
+ ...(photograph === undefined ? {} : { capture: photograph }),
134
+ held,
135
+ });
136
+ const render = capture?.render;
137
+ if (!render) return answer();
138
+ const { position, target, up } = capture;
139
+ if (!position || !target || !up)
140
+ throw new Error('A Blender render capture must carry the scene camera position, target and up');
141
+
142
+ // THE PHOTOGRAPH HAS ITS OWN CAMERA, placed from Blender's frame through the
143
+ // model root's Z-up -> Y-up matrix and reported back through its inverse —
144
+ // exactly as the editor's host does, because `session.py` asserts the echo.
145
+ view.root.updateMatrixWorld(true);
146
+ const toDocument = view.root.matrixWorld;
147
+ const toBlender = new THREE.Matrix4().copy(toDocument).invert();
148
+ const documentBasis = new THREE.Matrix3().setFromMatrix4(toDocument);
149
+ const blenderBasis = new THREE.Matrix3().setFromMatrix4(toBlender);
150
+ const eye = new THREE.Vector3(position[0], position[1], position[2]).applyMatrix4(toDocument);
151
+ const focus = new THREE.Vector3(target[0], target[1], target[2]).applyMatrix4(toDocument);
152
+ const upward = new THREE.Vector3(up[0], up[1], up[2]).applyMatrix3(documentBasis);
153
+ const width = Math.min(MAXIMUM_EDGE, Math.round(render.width));
154
+ const height = Math.min(MAXIMUM_EDGE, Math.round(render.height));
155
+ const aspect = width / height;
156
+ const camera: THREE.Camera = render.orthographic
157
+ ? new THREE.OrthographicCamera(
158
+ (-render.fov / 2) * aspect,
159
+ (render.fov / 2) * aspect,
160
+ render.fov / 2,
161
+ -render.fov / 2,
162
+ near,
163
+ far,
164
+ )
165
+ : new THREE.PerspectiveCamera(render.fov, aspect, near, far);
166
+ camera.up.copy(upward);
167
+ camera.position.copy(eye);
168
+ camera.lookAt(focus);
169
+ camera.updateMatrixWorld(true);
170
+ const photographedFrom = {
171
+ position: eye.clone().applyMatrix4(toBlender).toArray(),
172
+ target: focus.clone().applyMatrix4(toBlender).toArray(),
173
+ up: upward.clone().applyMatrix3(blenderBasis).toArray(),
174
+ };
175
+ view.recordPhotograph({
176
+ sent: { position, target, up },
177
+ photographed: photographedFrom,
178
+ render: { width, height, fov: render.fov, orthographic: render.orthographic },
179
+ });
180
+
181
+ const tableFile = displayTableFor(render);
182
+ const table = tableFile ? await displayTable(tableFile) : null;
183
+ const mappings: Record<string, THREE.ToneMapping> = {
184
+ none: THREE.NoToneMapping,
185
+ agx: THREE.AgXToneMapping,
186
+ neutral: THREE.NeutralToneMapping,
187
+ filmic: THREE.NoToneMapping,
188
+ };
189
+ const previousMapping = renderer.toneMapping;
190
+ const previousExposure = renderer.toneMappingExposure;
191
+ renderer.toneMapping = mappings[render.toneMapping] ?? THREE.AgXToneMapping;
192
+ renderer.toneMappingExposure = render.exposure;
193
+ const sceneTarget = new THREE.WebGLRenderTarget(width, height, { type: THREE.HalfFloatType });
194
+ try {
195
+ await view.setRendered(true, camera);
196
+ let pixels: Uint16Array;
197
+ if (render.linearInput) {
198
+ pixels = halfFloatFrame(render.linearInput.base64);
199
+ } else {
200
+ renderer.setSize(width, height, false);
201
+ renderer.setRenderTarget(sceneTarget);
202
+ const background = scene.background;
203
+ if (render.transparent) {
204
+ scene.background = null;
205
+ renderer.setClearColor(0, 0);
206
+ }
207
+ try {
208
+ renderer.render(scene, camera);
209
+ } finally {
210
+ scene.background = background;
211
+ }
212
+ pixels = new Uint16Array(width * height * 4);
213
+ renderer.readRenderTargetPixels(sceneTarget, 0, 0, width, height, pixels);
214
+ renderer.setRenderTarget(null);
215
+ }
216
+ const count = width * height;
217
+ const bytes =
218
+ render.toneMapping === 'none'
219
+ ? standardEncodeFrame(pixels, count, render.exposure)
220
+ : render.toneMapping === 'filmic'
221
+ ? filmicEncodeFrame(table!, pixels, count, render.exposure)
222
+ : agxEncodeFrame(table!, pixels, count, {
223
+ exposure: render.exposure,
224
+ composedLook: (render.look ?? 'None') !== 'None',
225
+ });
226
+ const photograph: Record<string, unknown> = {
227
+ base64: await pngBase64(bytes, width, height),
228
+ mimeType: 'image/png',
229
+ camera: photographedFrom,
230
+ };
231
+ if (render.linear === true) {
232
+ const raw = new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength);
233
+ let binary = '';
234
+ for (let i = 0; i < raw.length; i += 0x8000)
235
+ binary += String.fromCharCode(...raw.subarray(i, i + 0x8000));
236
+ photograph['linearBase64'] = btoa(binary);
237
+ photograph['linearWidth'] = width;
238
+ photograph['linearHeight'] = height;
239
+ }
240
+ return answer(photograph);
241
+ } finally {
242
+ sceneTarget.dispose();
243
+ renderer.toneMapping = previousMapping;
244
+ renderer.toneMappingExposure = previousExposure;
245
+ await view.setRendered(false);
246
+ }
247
+ }
248
+
249
+ return {
250
+ view,
251
+ scene,
252
+ present,
253
+ dispose() {
254
+ renderer.dispose();
255
+ },
256
+ };
257
+ }
258
+
259
+ /** A composited frame's scene-linear half floats, as `linearInput` carries them. */
260
+ function halfFloatFrame(base64: string): Uint16Array {
261
+ const binary = atob(base64);
262
+ const bytes = new Uint8Array(binary.length);
263
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
264
+ return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
265
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * THE TRANSLATION, AS OUR BLENDER'S RELEASE SHIPS IT (WS-AC, cut 4).
3
+ *
4
+ * "Our Blender renders with three.js" is one implementation, built once at
5
+ * release time out of this package's source into ONE self-contained ES module
6
+ * (`blender-three.mjs`, three inlined, the display tables beside it) and
7
+ * delivered wherever the wasm is delivered. A host fetches it from the
8
+ * artifact base next to `blender.wasm`, imports it, and attaches ONE presenter
9
+ * to the Blender it started:
10
+ *
11
+ * const { createPresenter, attachPresenter } = await import(url);
12
+ * const detach = attachPresenter(filesystem, "/tmp/vgai-presenter", createPresenter());
13
+ *
14
+ * NO HOST BUNDLES THIS. The module is GPL because it is part of the Blender
15
+ * release; a host loads it the way it loads the wasm -- by URL, under a pinned
16
+ * hash -- and its own build graph never touches this package. The editor is
17
+ * the one consumer that does NOT load this file: it imports the same source
18
+ * with its own three and wraps its own document around it, because two copies
19
+ * of three on one page is the thing this arrangement exists to prevent.
20
+ *
21
+ * `vite.release.config.ts` beside this package's manifest is the build;
22
+ * `npm run build:blender-three -w @volter/blender-engine` runs it.
23
+ */
24
+ export { attachPresenter } from './attach-presenter';
25
+ export type { AttachablePresenter, PresenterFileSystem } from './attach-presenter';
26
+ export { createPresenter } from './presenter';
27
+ export type { Presenter, PresenterOptions } from './presenter';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The sky precompute, in a Worker.
3
+ *
4
+ * `precomputeSkyTexture` is 512x256 texels at 64 in-scattering steps each and
5
+ * MEASURED 15.4 seconds. On the page's thread that is not "slow", it is a FROZEN
6
+ * EDITOR -- the owner saw exactly that, and a spinner could not have helped
7
+ * because a blocked main thread cannot paint one.
8
+ *
9
+ * This module is the whole worker: parameters in, one Float32Array out,
10
+ * TRANSFERRED rather than copied. It names no DOM and no renderer, which is what
11
+ * lets it be a worker at all; `blender-sky.ts` keeps the arithmetic.
12
+ *
13
+ * WHAT MAKES THIS DIFFERENT FROM THE ATTEMPT THAT WAS REVERTED (#6609): nothing
14
+ * here hands out a half-built texture. The worker answers with NUMBERS, the
15
+ * caller primes `blender-sky`'s texture cache with them, and only then does
16
+ * anything build a texture -- and `pendingWorld`/`worldReady()` hold the capture
17
+ * until it has. See `sky-worker.ts` and `WorldBackground.apply`.
18
+ */
19
+ import { precomputeSkyTexture, type SkyParameters } from './blender-sky';
20
+
21
+ export interface SkyWorkerRequest {
22
+ id: number;
23
+ parameters: SkyParameters;
24
+ }
25
+
26
+ export interface SkyWorkerResponse {
27
+ id: number;
28
+ pixels?: Float32Array;
29
+ error?: string;
30
+ }
31
+
32
+ const scope = self as unknown as {
33
+ onmessage: ((event: MessageEvent<SkyWorkerRequest>) => void) | null;
34
+ postMessage: (message: SkyWorkerResponse, transfer?: Transferable[]) => void;
35
+ };
36
+
37
+ scope.onmessage = (event) => {
38
+ const { id, parameters } = event.data;
39
+ try {
40
+ const pixels = precomputeSkyTexture(parameters);
41
+ scope.postMessage({ id, pixels }, [pixels.buffer]);
42
+ } catch (error) {
43
+ scope.postMessage({ id, error: error instanceof Error ? error.message : String(error) });
44
+ }
45
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The page's side of the sky worker: one long-lived worker, one promise per sky.
3
+ *
4
+ * WHY A CLIENT MODULE AND NOT A `new Worker` AT THE CALL SITE: the worker is
5
+ * built once and reused, because constructing one costs a module graph fetch and
6
+ * a sky world re-derives on every sun edit. Requests are keyed by id so several
7
+ * skies (a world split by `Is Camera Ray` has two) ride one worker.
8
+ *
9
+ * The type-only import of the worker module is LOAD-BEARING in two ways. It is
10
+ * the contract for what crosses the boundary, and it is also how the probe
11
+ * project's capability sync finds the worker file at all: that sync follows
12
+ * `from './x'` spellings, and `new URL('./x.ts', import.meta.url)` is not one.
13
+ * Delete the import and the worker silently stops being copied.
14
+ */
15
+ import { precomputeSkyTexture, type SkyParameters } from './blender-sky';
16
+ import type { SkyWorkerRequest, SkyWorkerResponse } from './sky-precompute-worker';
17
+
18
+ let worker: Worker | null = null;
19
+ let workerUnavailable = false;
20
+ let nextId = 0;
21
+ const waiting = new Map<
22
+ number,
23
+ { resolve: (pixels: Float32Array) => void; reject: (error: Error) => void }
24
+ >();
25
+
26
+ function ensureWorker(): Worker | null {
27
+ if (worker || workerUnavailable) return worker;
28
+ if (typeof Worker === 'undefined') {
29
+ workerUnavailable = true;
30
+ return null;
31
+ }
32
+ try {
33
+ worker = new Worker(new URL('./sky-precompute-worker.ts', import.meta.url), {
34
+ type: 'module',
35
+ });
36
+ } catch {
37
+ workerUnavailable = true;
38
+ return null;
39
+ }
40
+ worker.onmessage = (event: MessageEvent<SkyWorkerResponse>) => {
41
+ const { id, pixels, error } = event.data;
42
+ const pending = waiting.get(id);
43
+ if (!pending) return;
44
+ waiting.delete(id);
45
+ if (pixels) pending.resolve(pixels);
46
+ else pending.reject(new Error(error ?? 'sky worker returned nothing'));
47
+ };
48
+ // A worker that dies takes every outstanding sky with it. Failing them LOUDLY
49
+ // is the point: `worldReady()` propagates, so a capture reports the error
50
+ // rather than photographing a scene with no sky in it.
51
+ worker.onerror = (event) => {
52
+ const failure = new Error(`sky worker failed: ${event.message || 'unknown error'}`);
53
+ for (const pending of waiting.values()) pending.reject(failure);
54
+ waiting.clear();
55
+ worker?.terminate();
56
+ worker = null;
57
+ workerUnavailable = true;
58
+ };
59
+ return worker;
60
+ }
61
+
62
+ /**
63
+ * One sky's 512x256 texture, derived OFF the main thread.
64
+ *
65
+ * Falls back to deriving it inline when there is no Worker (Node, a test, a
66
+ * blocked construction). That fallback blocks -- which is the whole complaint --
67
+ * but it is CORRECT, and correct-and-slow is the trade this lane already made
68
+ * once by hand.
69
+ */
70
+ export function precomputeSkyOffThread(parameters: SkyParameters): Promise<Float32Array> {
71
+ const active = ensureWorker();
72
+ if (!active) return Promise.resolve(precomputeSkyTexture(parameters));
73
+ const id = nextId++;
74
+ const request: SkyWorkerRequest = { id, parameters };
75
+ return new Promise<Float32Array>((resolve, reject) => {
76
+ waiting.set(id, { resolve, reject });
77
+ active.postMessage(request);
78
+ });
79
+ }