@volter/blender-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -0,0 +1,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
|
+
}
|