@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,432 @@
1
+ /* SPDX-License-Identifier: GPL-2.0-or-later */
2
+ /** Blender mesh_normals.cc (fbe6228777e7): smooth fans and custom-normal
3
+ * reference spaces. Shared by modifier evaluation, viewport drawing and GLB.
4
+ * Custom normals retain Blender's INT16_2D representation in mesh attributes.
5
+ */
6
+
7
+ export type Normal = [number, number, number];
8
+
9
+ /** `normalize_v3` (`blenlib/intern/math_vector.cc`), returning the length it
10
+ * divided by; a zero-length vector stays zero. Inlined from the kit's
11
+ * `face-interpolation.ts` when this file became `@volter/editor-blender`'s — it is the
12
+ * one function of that module this drawing path used. Float width is part of
13
+ * the answer: native computes it in `float`, so every step is `Math.fround`. */
14
+ function normalize3(n: Normal): { unit: Normal; length: number } {
15
+ const w = Math.fround;
16
+ const d = w(w(w(n[0] * n[0]) + w(n[1] * n[1])) + w(n[2] * n[2]));
17
+ if (d > 1.0e-35) {
18
+ const length = w(Math.sqrt(d));
19
+ const mul = w(1 / length);
20
+ return { unit: [w(n[0] * mul), w(n[1] * mul), w(n[2] * mul)], length };
21
+ }
22
+ return { unit: [0, 0, 0], length: 0 };
23
+ }
24
+ export interface NormalMesh {
25
+ v: Normal[];
26
+ f: number[][];
27
+ s?: boolean[] | undefined;
28
+ edge_order?: [number, number][] | undefined;
29
+ face_edges?: number[][] | undefined;
30
+ sharp?: [number, number][] | undefined;
31
+ edge_sharp?: boolean[] | undefined;
32
+ }
33
+ const f = Math.fround;
34
+ const tau = f(2 * Math.PI),
35
+ threshold = f(1 - 1e-4);
36
+ export const dotNormal = (a: Normal, b: Normal): number =>
37
+ f(f(f(a[0] * b[0]) + f(a[1] * b[1])) + f(a[2] * b[2]));
38
+ export const addNormal = (a: Normal, b: Normal, weight = 1): Normal =>
39
+ a.map((x, i) => f(x + f(b[i]! * weight))) as Normal;
40
+ const sub = (a: Normal, b: Normal): Normal => a.map((x, i) => f(x - b[i]!)) as Normal;
41
+ export function unitNormal(a: Normal): Normal {
42
+ const length = f(Math.sqrt(dotNormal(a, a)));
43
+ return length > 0 ? (a.map((x) => f(x / length)) as Normal) : [0, 0, 0];
44
+ }
45
+ export function normalAngle(x: number): number {
46
+ const m = Math.abs(x) < 1 ? f(1 - f(1 - Math.abs(x))) : 1;
47
+ let p = f(f(0.077980478) + f(m * f(-0.02164095)));
48
+ p = f(f(-0.213300989) + f(m * p));
49
+ p = f(f(1.5707963267) + f(m * p));
50
+ const angle = f(f(Math.sqrt(f(1 - m))) * p);
51
+ return x < 0 ? f(f(Math.PI) - angle) : angle;
52
+ }
53
+ export function faceVector(points: Normal[]): Normal {
54
+ let last = points.at(-1)!;
55
+ const result: Normal = [0, 0, 0];
56
+ for (const point of points) {
57
+ for (let i = 0; i < 3; i++) {
58
+ const a = (i + 1) % 3,
59
+ b = (i + 2) % 3;
60
+ result[i] = f(result[i]! + f(f(last[a]! - point[a]!) * f(last[b]! + point[b]!)));
61
+ }
62
+ last = point;
63
+ }
64
+ return result;
65
+ }
66
+ export interface NormalCorner {
67
+ vertex: number;
68
+ face: number;
69
+ prev: number;
70
+ next: number;
71
+ edge: number;
72
+ }
73
+ export interface NormalSpace {
74
+ normal: Normal;
75
+ reference: Normal;
76
+ ortho: Normal;
77
+ alpha: number;
78
+ beta: number;
79
+ }
80
+ export interface NormalFans {
81
+ corners: NormalCorner[];
82
+ fans: number[][];
83
+ fanOf: number[];
84
+ spaces: NormalSpace[];
85
+ faceNormals: Normal[];
86
+ /** The fan's own auto normal, per fan — `normals_calc_corners`' answer, which
87
+ * is NOT `spaces[i].normal`: a space too aligned with its normal to be
88
+ * usable carries a zero `vec_lnor` while the fan's normal stands. This is
89
+ * what `Mesh::corner_normals` returns once a `custom_normal` layer exists. */
90
+ fanNormals: Normal[];
91
+ }
92
+
93
+ export function normalFans(mesh: NormalMesh): NormalFans {
94
+ const corners: NormalCorner[] = [],
95
+ faceNormals: Normal[] = [];
96
+ const key = (a: number, b: number) => (a < b ? `${a}:${b}` : `${b}:${a}`);
97
+ const edges = new Map((mesh.edge_order ?? []).map(([a, b], i) => [key(a, b), i]));
98
+ const sharpPairs = new Set((mesh.sharp ?? []).map(([a, b]) => key(a, b)));
99
+ const uses = new Map<number, number[]>();
100
+ for (const [face, vertices] of mesh.f.entries()) {
101
+ const start = corners.length;
102
+ const normal = normalize3(faceVector(vertices.map((v) => mesh.v[v]!))).unit as Normal;
103
+ faceNormals.push(normal.some(Boolean) ? normal : [0, 0, 1]);
104
+ for (let j = 0; j < vertices.length; j++) {
105
+ const pair = key(vertices[j]!, vertices[(j + 1) % vertices.length]!);
106
+ if (!edges.has(pair)) edges.set(pair, edges.size);
107
+ const edge = mesh.face_edges?.[face]?.[j] ?? edges.get(pair)!;
108
+ const c = corners.length;
109
+ corners.push({
110
+ vertex: vertices[j]!,
111
+ face,
112
+ edge,
113
+ prev: start + ((j + vertices.length - 1) % vertices.length),
114
+ next: start + ((j + 1) % vertices.length),
115
+ });
116
+ const rows = uses.get(edge) ?? [];
117
+ rows.push(c);
118
+ uses.set(edge, rows);
119
+ }
120
+ }
121
+ const nextFan = new Map<number, number>(),
122
+ prevFan = new Map<number, number>();
123
+ for (const [edge, rows] of uses) {
124
+ if (rows.length !== 2 || mesh.edge_sharp?.[edge]) continue;
125
+ const [a, b] = rows as [number, number],
126
+ ca = corners[a]!,
127
+ cb = corners[b]!;
128
+ if (!mesh.s?.[ca.face] || !mesh.s?.[cb.face]) continue;
129
+ if (ca.vertex !== corners[cb.next]!.vertex || cb.vertex !== corners[ca.next]!.vertex) continue;
130
+ if (!mesh.edge_sharp && sharpPairs.has(key(ca.vertex, cb.vertex))) continue;
131
+ prevFan.set(a, cb.next);
132
+ nextFan.set(cb.next, a);
133
+ prevFan.set(b, ca.next);
134
+ nextFan.set(ca.next, b);
135
+ }
136
+ const fans: number[][] = [],
137
+ fanOf: number[] = [],
138
+ spaces: NormalSpace[] = [],
139
+ fanNormals: Normal[] = [];
140
+ const direction = (c: number, which: 'prev' | 'next') =>
141
+ unitNormal(sub(mesh.v[corners[corners[c]![which]]!.vertex]!, mesh.v[corners[c]!.vertex]!));
142
+ for (let c = 0; c < corners.length; c++) {
143
+ if (fanOf[c] !== undefined) continue;
144
+ let start = c;
145
+ const visited = new Set([start]);
146
+ while (prevFan.has(start) && !visited.has(prevFan.get(start)!)) {
147
+ start = prevFan.get(start)!;
148
+ visited.add(start);
149
+ }
150
+ if (prevFan.has(start)) start = Math.min(...visited);
151
+ const fan = [start];
152
+ while (nextFan.has(fan.at(-1)!) && nextFan.get(fan.at(-1)!) !== start)
153
+ fan.push(nextFan.get(fan.at(-1)!)!);
154
+ let normal: Normal = [0, 0, 0];
155
+ if (fan.length === 1) normal = faceNormals[corners[start]!.face]!;
156
+ else {
157
+ for (const row of fan)
158
+ normal = addNormal(
159
+ normal,
160
+ faceNormals[corners[row]!.face]!,
161
+ normalAngle(dotNormal(direction(row, 'prev'), direction(row, 'next'))),
162
+ );
163
+ normal = unitNormal(normal);
164
+ }
165
+ const reference = direction(start, 'next'),
166
+ last = fan.at(-1)!;
167
+ const other = direction(last, 'prev');
168
+ const dtpRef = dotNormal(reference, normal),
169
+ dtpOther = dotNormal(other, normal);
170
+ const space: NormalSpace = {
171
+ normal,
172
+ reference: [0, 0, 0],
173
+ ortho: [0, 0, 0],
174
+ alpha: 0,
175
+ beta: 0,
176
+ };
177
+ if (Math.abs(dtpRef) < threshold && Math.abs(dtpOther) < threshold) {
178
+ const vectors = fan.length > 1 ? fan.map((row) => direction(row, 'next')) : [];
179
+ if (vectors.length && corners[corners[last]!.prev]!.edge !== corners[start]!.edge)
180
+ vectors.push(other);
181
+ space.alpha = vectors.length
182
+ ? f(
183
+ vectors.reduce((sum, v) => f(sum + normalAngle(dotNormal(v, normal))), 0) /
184
+ vectors.length,
185
+ )
186
+ : f(f(normalAngle(dtpRef) + normalAngle(dtpOther)) / 2);
187
+ space.reference = unitNormal(addNormal(reference, normal, -dtpRef));
188
+ const a = normal,
189
+ b = space.reference;
190
+ space.ortho = unitNormal([
191
+ f(f(a[1] * b[2]) - f(a[2] * b[1])),
192
+ f(f(a[2] * b[0]) - f(a[0] * b[2])),
193
+ f(f(a[0] * b[1]) - f(a[1] * b[0])),
194
+ ]);
195
+ const projected = unitNormal(addNormal(other, normal, -dtpOther));
196
+ const cosine = dotNormal(space.reference, projected);
197
+ const beta = normalAngle(cosine);
198
+ space.beta =
199
+ cosine < threshold ? (dotNormal(space.ortho, projected) < 0 ? f(tau - beta) : beta) : tau;
200
+ }
201
+ if (space.alpha === 0) space.normal = [0, 0, 0];
202
+ for (const row of fan) fanOf[row] = fans.length;
203
+ fans.push(fan);
204
+ spaces.push(space);
205
+ fanNormals.push(normal);
206
+ }
207
+ return { corners, fans, fanOf, spaces, faceNormals, fanNormals };
208
+ }
209
+
210
+ export function encodeNormal(space: NormalSpace, normal: Normal): [number, number] {
211
+ if (
212
+ !normal.some(Boolean) ||
213
+ normal.every((x, i) => Math.abs(x - space.normal[i]!) <= 1e-4) ||
214
+ space.alpha === 0
215
+ )
216
+ return [0, 0];
217
+ const short = (x: number) => Math.floor(f(f(x * 32767) + 0.5));
218
+ const anglePair = (angle: number, reference: number) =>
219
+ reference === 0
220
+ ? 0
221
+ : short(angle > reference ? f(-f(tau - angle) / f(tau - reference)) : f(angle / reference));
222
+ const cosAlpha = dotNormal(space.normal, normal);
223
+ const alpha = normalAngle(cosAlpha);
224
+ const vector = unitNormal(addNormal(normal, space.normal, -cosAlpha));
225
+ const cosBeta = dotNormal(space.reference, vector);
226
+ let beta = cosBeta < threshold ? normalAngle(cosBeta) : 0;
227
+ if (cosBeta < threshold && dotNormal(space.ortho, vector) < 0) beta = f(tau - beta);
228
+ return [anglePair(alpha, space.alpha), anglePair(beta, space.beta)];
229
+ }
230
+
231
+ export function decodeNormal(space: NormalSpace, pair: number[]): Normal {
232
+ if (!pair[0] || space.alpha === 0 || space.beta === 0) return space.normal;
233
+ const af = f(pair[0] / 32767),
234
+ bf = f(pair[1]! / 32767);
235
+ const alpha = f((af > 0 ? space.alpha : f(tau - space.alpha)) * af);
236
+ const beta = f((bf > 0 ? space.beta : f(tau - space.beta)) * bf);
237
+ let result = space.normal.map((x) => f(x * f(Math.cos(alpha)))) as Normal;
238
+ result = addNormal(result, space.reference, f(f(Math.sin(alpha)) * f(Math.cos(beta))));
239
+ if (bf !== 0) result = addNormal(result, space.ortho, f(f(Math.sin(alpha)) * f(Math.sin(beta))));
240
+ return result;
241
+ }
242
+
243
+ export interface CustomNormalsMesh extends NormalMesh {
244
+ /** Every face's corner edges; the sharp-edge marking names edges, not pairs. */
245
+ face_edges: number[][];
246
+ /** The `sharp_edge` column this starts from and returns, one per edge. */
247
+ edge_sharp: boolean[];
248
+ /** `bpy._mesh_normals.vertex_normals`. Read only by the VERTEX door's zero
249
+ * substitution, which stands a zero input on the vertex's own normal. */
250
+ vertexNormals?: ArrayLike<number> | undefined;
251
+ }
252
+
253
+ /** The two columns the setter writes: the `custom_normal` INT16_2D pair per
254
+ * corner, and the `sharp_edge` BOOLEAN flag per edge. */
255
+ export interface CustomNormals {
256
+ customNormal: Int16Array;
257
+ sharpEdge: Uint8Array;
258
+ }
259
+
260
+ /**
261
+ * `mesh_normals_corner_custom_set` (mesh_normals.cc:1409-1596), the pipeline
262
+ * behind `Mesh.normals_split_custom_set(_from_vertices)`. `normals` is three
263
+ * float32 per corner, or per VERTEX when `useVertices`; both are already
264
+ * clamped and normalized by the RNA layer.
265
+ *
266
+ * The steps, in native's order:
267
+ * 1. `normals_calc_corners` — the fans and their auto normals (`normalFans`);
268
+ * 2. ZERO SUBSTITUTION (:1443-1456): a zero input becomes the auto corner
269
+ * normal, or in the vertex door the vertex normal;
270
+ * 3. SHARP-EDGE SPLITTING (:1466-1543), corner door only: within a fan, a
271
+ * corner whose normal differs from the fan's running reference by
272
+ * `dot < LNOR_SPACE_TRIGO_THRESHOLD` marks an edge sharp. Never un-sharps;
273
+ * 4. a SECOND `normals_calc_corners` (:1544-1555), so the spaces match what
274
+ * was asked for;
275
+ * 5. FAN AVERAGING (:1583-1596): a fan of two or more corners is summed,
276
+ * divided by the corner count, encoded ONCE, and that one pair fills every
277
+ * corner of the fan. Only a fan below two is encoded per corner.
278
+ */
279
+ export function setCustomNormals(
280
+ mesh: CustomNormalsMesh,
281
+ normals: Float32Array,
282
+ useVertices: boolean,
283
+ ): CustomNormals {
284
+ const sharp = mesh.edge_sharp.slice();
285
+ let basis = normalFans({ ...mesh, edge_sharp: sharp });
286
+ const total = basis.corners.length;
287
+ const custom: Normal[] = Array.from({ length: normals.length / 3 }, (_, i) => [
288
+ normals[i * 3]!,
289
+ normals[i * 3 + 1]!,
290
+ normals[i * 3 + 2]!,
291
+ ]);
292
+ // 2. zero substitution, before anything else looks at the vectors.
293
+ if (useVertices) {
294
+ const vertex = mesh.vertexNormals;
295
+ if (!vertex) throw new Error('setCustomNormals: the vertex door needs vertexNormals');
296
+ for (const [i, value] of custom.entries())
297
+ if (!value.some(Boolean))
298
+ custom[i] = [vertex[i * 3]!, vertex[i * 3 + 1]!, vertex[i * 3 + 2]!];
299
+ } else {
300
+ for (const [i, value] of custom.entries())
301
+ if (!value.some(Boolean)) custom[i] = basis.fanNormals[basis.fanOf[i]!]!;
302
+ }
303
+ const at = (corner: number): Normal =>
304
+ custom[useVertices ? basis.corners[corner]!.vertex : corner]!;
305
+ // 3 + 4. splitting, then rebuild. The vertex door skips this entirely
306
+ // (`done_corners.fill(true)`, :1461).
307
+ if (!useVertices) {
308
+ // `sharp_edges[prev_edge == edge_prev ? prev_edge : edge] = true` (:1510-1517).
309
+ const markSharp = (corner: number, previous: number): void => {
310
+ const edge = basis.corners[corner]!.edge;
311
+ const edgePrev = basis.corners[basis.corners[corner]!.prev]!.edge;
312
+ const prevEdge = previous >= 0 ? basis.corners[previous]!.edge : -1;
313
+ sharp[prevEdge === edgePrev ? prevEdge : edge] = true;
314
+ };
315
+ let marked = false;
316
+ const seen = new Uint8Array(total);
317
+ for (let i = 0; i < total; i++) {
318
+ if (seen[i]) continue;
319
+ const fan = basis.fans[basis.fanOf[i]!]!;
320
+ for (const corner of fan) seen[corner] = 1;
321
+ if (fan.length < 2) continue;
322
+ let org: Normal | null = null,
323
+ previous = -1;
324
+ for (let k = fan.length - 1; k >= 0; k--) {
325
+ const corner = fan[k]!,
326
+ normal = at(corner);
327
+ if (org === null) org = normal;
328
+ else if (dotNormal(org, normal) < threshold) {
329
+ markSharp(corner, previous);
330
+ marked = true;
331
+ org = normal;
332
+ }
333
+ previous = corner;
334
+ }
335
+ const corner = fan.at(-1)!;
336
+ if (org !== null && dotNormal(org, at(corner)) < threshold) {
337
+ markSharp(corner, previous);
338
+ marked = true;
339
+ }
340
+ }
341
+ if (marked) basis = normalFans({ ...mesh, edge_sharp: sharp });
342
+ }
343
+ // 5. encode, averaging over each surviving fan.
344
+ const customNormal = new Int16Array(total * 2);
345
+ const done = new Uint8Array(total);
346
+ for (let i = 0; i < total; i++) {
347
+ if (done[i]) continue;
348
+ const index = basis.fanOf[i]!,
349
+ fan = basis.fans[index]!,
350
+ space = basis.spaces[index]!;
351
+ if (fan.length < 2) {
352
+ const pair = encodeNormal(space, at(i));
353
+ customNormal[i * 2] = pair[0];
354
+ customNormal[i * 2 + 1] = pair[1];
355
+ done[i] = 1;
356
+ continue;
357
+ }
358
+ let sum: Normal = [0, 0, 0];
359
+ for (const corner of fan) sum = addNormal(sum, at(corner));
360
+ const pair = encodeNormal(space, sum.map((x) => f(x / fan.length)) as Normal);
361
+ for (const corner of fan) {
362
+ customNormal[corner * 2] = pair[0];
363
+ customNormal[corner * 2 + 1] = pair[1];
364
+ done[corner] = 1;
365
+ }
366
+ }
367
+ return { customNormal, sharpEdge: Uint8Array.from(sharp, (x) => (x ? 1 : 0)) };
368
+ }
369
+
370
+ /**
371
+ * `Mesh::corner_normals`: one normal per corner, whatever the mesh carries.
372
+ *
373
+ * The decoded custom normals where a `custom_normal` layer exists, and the
374
+ * smooth fans' own auto normals where it does not — the split-normal answer,
375
+ * with sharp edges and flat faces already respected because they are what
376
+ * `normalFans` splits the fans on. ONE function so a reader of corner normals
377
+ * never has to know which of the two it is looking at; both halves are the
378
+ * same fans, computed once.
379
+ */
380
+ export function cornerNormals(
381
+ mesh: NormalMesh,
382
+ attribute?: Parameters<typeof customCornerNormals>[1],
383
+ ): Normal[] {
384
+ const custom = customCornerNormals(mesh, attribute);
385
+ if (custom) return custom;
386
+ const { corners, fanOf, fanNormals } = normalFans(mesh);
387
+ return corners.map((_, c) => fanNormals[fanOf[c]!]!);
388
+ }
389
+
390
+ /** Read custom attributes at the geometry boundary, preserving corner splits. */
391
+ export function customCornerNormals(
392
+ mesh: NormalMesh,
393
+ attribute:
394
+ | {
395
+ type: string;
396
+ domain: string;
397
+ data: unknown[];
398
+ }
399
+ | undefined,
400
+ ): Normal[] | null {
401
+ if (!attribute) return null;
402
+ const { corners, fans, spaces } = normalFans(mesh);
403
+ if (attribute.type === 'INT16_2D' && attribute.domain === 'CORNER') {
404
+ const result: Normal[] = new Array(corners.length);
405
+ for (const [i, fan] of fans.entries()) {
406
+ const sum = fan.reduce(
407
+ (total, c) => {
408
+ const pair = attribute.data[c] as number[];
409
+ return [total[0]! + pair[0]!, total[1]! + pair[1]!];
410
+ },
411
+ [0, 0],
412
+ );
413
+ const normal = decodeNormal(
414
+ spaces[i]!,
415
+ sum.map((x) => Math.trunc(x / fan.length)),
416
+ );
417
+ for (const c of fan) result[c] = normal;
418
+ }
419
+ return result;
420
+ }
421
+ if (attribute.type === 'FLOAT_VECTOR')
422
+ return corners.map((corner, c) => {
423
+ const row =
424
+ attribute.domain === 'POINT'
425
+ ? corner.vertex
426
+ : attribute.domain === 'FACE'
427
+ ? corner.face
428
+ : c;
429
+ return attribute.data[row] as Normal;
430
+ });
431
+ throw new Error(`Unsupported custom_normal attribute: ${attribute.type}/${attribute.domain}`);
432
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tetrahedral sampling of normalized, red-fastest RGB display tables.
3
+ *
4
+ * THIS FILE IS THE FRAME LOOP, so it allocates NOTHING per pixel. Measured at
5
+ * 1440x900: the previous shape returned a fresh array for each of the four
6
+ * tetrahedron corners and built its result with `.map`, and `encodeDisplayFrame`
7
+ * built a fresh `[r, g, b]` per pixel -- roughly ten allocations per pixel, 13M
8
+ * per frame, and about half the wall clock. Every buffer below is module-level
9
+ * scratch instead, which is why the returned triples are documented as SCRATCH:
10
+ * a caller reads or copies the result before the next call, and the frame loop
11
+ * does exactly that.
12
+ */
13
+
14
+ /** Scratch: c000, second, third and c111 as four RGB triples. */
15
+ const corner = new Float64Array(12);
16
+ const sampled: [number, number, number] = [0, 0, 0];
17
+ const frac = new Float64Array(3);
18
+ const frameInput = new Float64Array(3);
19
+
20
+ /** THE SIX TETRAHEDRA of a cube cell, one row each: the second corner's
21
+ * offsets, the third corner's offsets, then which of `[fr, fg, fb]` supplies
22
+ * w1, w2 and w3. The same walk the branch chain did -- a flat table keeps the
23
+ * choice out of the sampler's own nesting. */
24
+ // biome-ignore format: one tetrahedron per row is the whole point of the table
25
+ const TETRA = new Int8Array([
26
+ 1, 0, 0, 1, 1, 0, 0, 1, 2,
27
+ 1, 0, 0, 1, 0, 1, 0, 2, 1,
28
+ 0, 0, 1, 1, 0, 1, 2, 0, 1,
29
+ 0, 0, 1, 0, 1, 1, 2, 1, 0,
30
+ 0, 1, 0, 0, 1, 1, 1, 2, 0,
31
+ 0, 1, 0, 1, 1, 0, 1, 0, 2,
32
+ ]);
33
+
34
+ /** Which of the six the fractional position falls in -- the three fractions
35
+ * ordered largest first, as tetrahedral interpolation is defined. */
36
+ function tetraCase(fr: number, fg: number, fb: number): number {
37
+ if (fr > fg) {
38
+ if (fg > fb) return 0;
39
+ return fr > fb ? 1 : 2;
40
+ }
41
+ if (fb > fg) return 3;
42
+ if (fb > fr) return 4;
43
+ return 5;
44
+ }
45
+
46
+ /** Tetrahedral sampling of normalized, red-fastest RGB display tables.
47
+ * THE RETURNED TRIPLE IS SCRATCH -- the next call overwrites it. */
48
+ export function sampleDisplayLut(
49
+ lut: Uint16Array,
50
+ n: number,
51
+ r: number,
52
+ g: number,
53
+ b: number,
54
+ ): [number, number, number] {
55
+ const plane = n * n;
56
+ const x = Math.min(1, Math.max(0, r)) * (n - 1);
57
+ const y = Math.min(1, Math.max(0, g)) * (n - 1);
58
+ const z = Math.min(1, Math.max(0, b)) * (n - 1);
59
+ const ir = Math.min(Math.floor(x), n - 2);
60
+ const ig = Math.min(Math.floor(y), n - 2);
61
+ const ib = Math.min(Math.floor(z), n - 2);
62
+ const fr = x - ir;
63
+ const fg = y - ig;
64
+ const fb = z - ib;
65
+ const read = (slot: number, dr: number, dg: number, db: number): void => {
66
+ const at = (ir + dr + (ig + dg) * n + (ib + db) * plane) * 3;
67
+ corner[slot] = lut[at]! / 65535;
68
+ corner[slot + 1] = lut[at + 1]! / 65535;
69
+ corner[slot + 2] = lut[at + 2]! / 65535;
70
+ };
71
+ read(0, 0, 0, 0);
72
+ read(9, 1, 1, 1);
73
+ frac[0] = fr;
74
+ frac[1] = fg;
75
+ frac[2] = fb;
76
+ const t = tetraCase(fr, fg, fb) * 9;
77
+ read(3, TETRA[t]!, TETRA[t + 1]!, TETRA[t + 2]!);
78
+ read(6, TETRA[t + 3]!, TETRA[t + 4]!, TETRA[t + 5]!);
79
+ const w1 = frac[TETRA[t + 6]!]!;
80
+ const w2 = frac[TETRA[t + 7]!]!;
81
+ const w3 = frac[TETRA[t + 8]!]!;
82
+ for (let k = 0; k < 3; k++) {
83
+ const c000 = corner[k]!;
84
+ sampled[k] =
85
+ c000 +
86
+ w1 * (corner[3 + k]! - c000) +
87
+ w2 * (corner[6 + k]! - corner[3 + k]!) +
88
+ w3 * (corner[9 + k]! - corner[6 + k]!);
89
+ }
90
+ return sampled;
91
+ }
92
+
93
+ export function decodeHalf(bits: number): number {
94
+ const sign = bits & 0x8000 ? -1 : 1;
95
+ const exponent = (bits >> 10) & 0x1f;
96
+ const fraction = bits & 0x3ff;
97
+ if (exponent === 0) return sign * 2 ** -24 * fraction;
98
+ if (exponent === 31) return fraction ? Number.NaN : sign * Number.POSITIVE_INFINITY;
99
+ return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
100
+ }
101
+
102
+ /** Transform associated scene-linear half-float RGBA to straight display RGBA.
103
+ * Unassociate before the nonlinear view transform; keep the input frame intact
104
+ * for the compositor and EXR. Alpha remains ungraded data.
105
+ * `through` is handed a REUSED triple -- it must read its three values before
106
+ * returning, which every display chain here does. */
107
+ export function encodeDisplayFrame(
108
+ halfPixels: Uint16Array,
109
+ pixelCount: number,
110
+ exposure: number,
111
+ through: (rgb: ArrayLike<number>) => readonly [number, number, number],
112
+ ): Uint8ClampedArray {
113
+ const out = new Uint8ClampedArray(pixelCount * 4);
114
+ for (let i = 0; i < pixelCount; i++) {
115
+ const at = i * 4;
116
+ const alpha = decodeHalf(halfPixels[at + 3]!);
117
+ const scale = alpha === 0 ? exposure : exposure / alpha;
118
+ frameInput[0] = decodeHalf(halfPixels[at]!) * scale;
119
+ frameInput[1] = decodeHalf(halfPixels[at + 1]!) * scale;
120
+ frameInput[2] = decodeHalf(halfPixels[at + 2]!) * scale;
121
+ const display = through(frameInput);
122
+ out[at] = Math.round(display[0] * 255);
123
+ out[at + 1] = Math.round(display[1] * 255);
124
+ out[at + 2] = Math.round(display[2] * 255);
125
+ out[at + 3] = Math.round(Math.min(1, Math.max(0, alpha)) * 255);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Where a display table lives: beside this module, wherever this module is
132
+ * served from.
133
+ *
134
+ * THE `.lut` SUFFIX IS SPELLED IN THE TEMPLATE, NOT IN THE ARGUMENT, and that
135
+ * is the difference between a four-file glob and a whole-directory one. A
136
+ * bundler cannot see the value of `file`, so it resolves
137
+ * `new URL(`./${x}`, import.meta.url)` by globbing EVERY sibling -- measured
138
+ * 2026-09-20 on the release build: every `.ts` in this directory came out
139
+ * inlined as a base64 data URL beside the tables, 6.4 MB of module. With the
140
+ * suffix in the template the glob is `./*.lut` and only the four tables are
141
+ * reachable, which is the whole truth of what this function can return.
142
+ */
143
+ export function displayTableUrl(file: string): URL {
144
+ return new URL(`./${file.replace(/\.lut$/, '')}.lut`, import.meta.url);
145
+ }
@@ -0,0 +1,49 @@
1
+ import { encodeDisplayFrame, sampleDisplayLut } from './blender-display-lut';
2
+
3
+ /** Blender 5.2 fbe6228777e7 OCIO Filmic/sRGB: log allocation, highlight
4
+ * desaturation, then the display curve. filmic-srgb.lut contains the pinned
5
+ * filmic_desat_33.cube followed by its 4096-entry display curve, normalized
6
+ * to little-endian uint16. The table output is already display-encoded sRGB.
7
+ */
8
+ const SIZE = 33;
9
+ const CUBE_VALUES = SIZE ** 3 * 3;
10
+ const CURVE_SIZE = 4096;
11
+ const LOG_MIN = -12.473931188;
12
+ const LOG_SPAN = 25;
13
+
14
+ function curve(lut: Uint16Array, value: number): number {
15
+ const position = Math.min(1, Math.max(0, value)) * (CURVE_SIZE - 1);
16
+ const index = Math.min(Math.floor(position), CURVE_SIZE - 2);
17
+ const t = position - index;
18
+ return ((1 - t) * lut[CUBE_VALUES + index]! + t * lut[CUBE_VALUES + index + 1]!) / 65535;
19
+ }
20
+
21
+ /** Scratch, as everything on the frame path is -- see `blender-display-lut.ts`. */
22
+ const display: [number, number, number] = [0, 0, 0];
23
+
24
+ /** One scene-linear colour through Filmic, to sRGB display.
25
+ * THE RETURNED TRIPLE IS SCRATCH -- the next call overwrites it. */
26
+ export function filmicDisplay(lut: Uint16Array, rgb: ArrayLike<number>): [number, number, number] {
27
+ const desaturated = sampleDisplayLut(
28
+ lut,
29
+ SIZE,
30
+ (Math.log2(Math.max(rgb[0]!, 1e-10)) - LOG_MIN) / LOG_SPAN,
31
+ (Math.log2(Math.max(rgb[1]!, 1e-10)) - LOG_MIN) / LOG_SPAN,
32
+ (Math.log2(Math.max(rgb[2]!, 1e-10)) - LOG_MIN) / LOG_SPAN,
33
+ );
34
+ display[0] = curve(lut, desaturated[0] / 0.66);
35
+ display[1] = curve(lut, desaturated[1] / 0.66);
36
+ display[2] = curve(lut, desaturated[2] / 0.66);
37
+ return display;
38
+ }
39
+
40
+ export function filmicEncodeFrame(
41
+ lut: Uint16Array,
42
+ pixels: Uint16Array,
43
+ count: number,
44
+ exposure: number,
45
+ ): Uint8ClampedArray {
46
+ if (lut.length !== CUBE_VALUES + CURVE_SIZE)
47
+ throw new Error('Invalid Blender Filmic display table');
48
+ return encodeDisplayFrame(pixels, count, exposure, (rgb) => filmicDisplay(lut, rgb));
49
+ }