@driftengine/assets 4.6.1 → 4.7.1

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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/blend.d.ts +25 -0
  3. package/dist/blend.js +43 -0
  4. package/dist/blendAnimation.d.ts +45 -0
  5. package/dist/blendAnimation.js +294 -0
  6. package/dist/blendData.d.ts +114 -0
  7. package/dist/blendData.js +366 -0
  8. package/dist/blendFile.d.ts +69 -0
  9. package/dist/blendFile.js +182 -0
  10. package/dist/blendGeometry.d.ts +45 -0
  11. package/dist/blendGeometry.js +210 -0
  12. package/dist/blendGltf.d.ts +65 -0
  13. package/dist/blendGltf.js +545 -0
  14. package/dist/blendMaterial.d.ts +68 -0
  15. package/dist/blendMaterial.js +545 -0
  16. package/dist/blendMatrix.d.ts +28 -0
  17. package/dist/blendMatrix.js +245 -0
  18. package/dist/blendMesh.d.ts +70 -0
  19. package/dist/blendMesh.js +409 -0
  20. package/dist/blendModifiers.d.ts +25 -0
  21. package/dist/blendModifiers.js +83 -0
  22. package/dist/blendNormals.d.ts +44 -0
  23. package/dist/blendNormals.js +458 -0
  24. package/dist/blendPacked.d.ts +35 -0
  25. package/dist/blendPacked.js +82 -0
  26. package/dist/blendScene.d.ts +50 -0
  27. package/dist/blendScene.js +280 -0
  28. package/dist/gltf.js +8 -2
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +17 -0
  31. package/dist/readModel.d.ts +13 -1
  32. package/dist/readModel.js +43 -13
  33. package/package.json +4 -4
  34. package/src/blend.ts +57 -0
  35. package/src/blendAnimation.ts +328 -0
  36. package/src/blendData.ts +375 -0
  37. package/src/blendFile.ts +233 -0
  38. package/src/blendGeometry.ts +263 -0
  39. package/src/blendGltf.ts +621 -0
  40. package/src/blendMaterial.ts +676 -0
  41. package/src/blendMatrix.ts +257 -0
  42. package/src/blendMesh.ts +496 -0
  43. package/src/blendModifiers.ts +96 -0
  44. package/src/blendNormals.ts +481 -0
  45. package/src/blendPacked.ts +93 -0
  46. package/src/blendScene.ts +307 -0
  47. package/src/gltf.ts +14 -2
  48. package/src/index.ts +41 -0
  49. package/src/readModel.ts +58 -13
@@ -0,0 +1,545 @@
1
+ /**
2
+ * A `.blend` as a glTF document in memory, so that `readModel` reads it through the glTF reader.
3
+ *
4
+ * **Why glTF in the middle.** Blender's own exporter writes glTF, and the baker hands a file it
5
+ * cannot read directly to that exporter. Building the same document here means the two routes share
6
+ * every step after it — materials, textures, skins, lights, the flattening into world space — and
7
+ * so cannot drift apart on any of them. It also makes Blender's export of a fixture an independent
8
+ * check on this one: the two documents should say the same thing.
9
+ *
10
+ * **What it refuses** it lists in `refusals`, and `readBlend` declines the whole file: a modifier,
11
+ * a constraint, a rig, a curve. Half a scene is worse than an error, because nobody can see what is
12
+ * missing. **What it approximates** — a node graph glTF cannot hold, animation it does not read —
13
+ * it lists in `approximations` and draws anyway, as Blender's exporter would.
14
+ */
15
+ import { invert, multiply } from './blendMatrix.js';
16
+ import { readBlendMesh } from './blendMesh.js';
17
+ import { cornerNormals } from './blendNormals.js';
18
+ import { meshPrimitives } from './blendGeometry.js';
19
+ import { readBlendSurface } from './blendMaterial.js';
20
+ import { judgeModifiers } from './blendModifiers.js';
21
+ import { OB_ARMATURE, OB_LAMP, OB_MESH, placeObjects } from './blendScene.js';
22
+ /** Blender Z up to glTF Y up, and back: the basis change Blender's exporter applies. */
23
+ const Z_TO_Y = [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1];
24
+ const Y_TO_Z = [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1];
25
+ /** A light shines down its local -Z in Blender and in glTF; this keeps it shining the same way. */
26
+ const LIGHT_CORRECTION = [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1];
27
+ const WATTS_TO_LUMENS = 683;
28
+ /** `Light.mode`'s bit for a colour taken from a temperature, read off a fixture that sets it. */
29
+ const LA_USE_TEMPERATURE = 1 << 24;
30
+ const KEYBLOCK_MUTE = 1;
31
+ /** Accessors and buffer views over one growing binary chunk. */
32
+ class Builder {
33
+ doc;
34
+ chunks = [];
35
+ length = 0;
36
+ constructor(doc) {
37
+ this.doc = { ...doc, accessors: [], bufferViews: [] };
38
+ }
39
+ view(bytes) {
40
+ const pad = (4 - (this.length % 4)) % 4;
41
+ if (pad > 0)
42
+ this.push(new Uint8Array(pad));
43
+ const index = this.doc.bufferViews.length;
44
+ this.doc.bufferViews.push({ buffer: 0, byteOffset: this.length, byteLength: bytes.length });
45
+ this.push(bytes);
46
+ return index;
47
+ }
48
+ accessor(data, type) {
49
+ const width = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }[type];
50
+ const view = this.view(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
51
+ const index = this.doc.accessors.length;
52
+ this.doc.accessors.push({
53
+ bufferView: view,
54
+ componentType: data instanceof Uint32Array ? 5125 : 5126,
55
+ count: data.length / width,
56
+ type,
57
+ });
58
+ return index;
59
+ }
60
+ finish() {
61
+ const out = new Uint8Array(this.length);
62
+ let at = 0;
63
+ for (const chunk of this.chunks) {
64
+ out.set(chunk, at);
65
+ at += chunk.length;
66
+ }
67
+ this.doc.buffers = [{ byteLength: this.length }];
68
+ return out;
69
+ }
70
+ push(bytes) {
71
+ this.chunks.push(bytes);
72
+ this.length += bytes.length;
73
+ }
74
+ }
75
+ /** The bytes a packed image holds, or null where it lives in a file beside the `.blend`. */
76
+ export function packedImage(image) {
77
+ const packed = image.has('packedfiles')
78
+ ? image.list('packedfiles')[0]?.deref('packedfile')
79
+ : null;
80
+ const file = packed ?? (image.has('packedfile') ? image.deref('packedfile') : null);
81
+ if (file === null || file === undefined)
82
+ return null;
83
+ const data = file.raw('data');
84
+ return data === null ? null : data.subarray(0, file.int('size'));
85
+ }
86
+ /**
87
+ * Whether an image stores one channel: a grey PNG, or a JPEG of one component. An image that is not
88
+ * packed cannot be looked at here and is taken to be grey, which is what a scalar map usually is.
89
+ */
90
+ export function isGreyImage(image) {
91
+ const bytes = packedImage(image);
92
+ if (bytes === null)
93
+ return true;
94
+ if (bytes[0] === 0x89 && bytes[1] === 0x50)
95
+ return bytes[25] === 0 || bytes[25] === 4;
96
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
97
+ for (let at = 2; at + 9 < bytes.length;) {
98
+ if (bytes[at] !== 0xff)
99
+ return false;
100
+ const marker = bytes[at + 1];
101
+ const length = (bytes[at + 2] << 8) | bytes[at + 3];
102
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
103
+ return bytes[at + 9] === 1;
104
+ at += 2 + length;
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+ /** A packed image's media type, from its first bytes. */
110
+ export function imageMime(bytes) {
111
+ if (bytes[0] === 0x89 && bytes[1] === 0x50)
112
+ return 'image/png';
113
+ if (bytes[0] === 0xff && bytes[1] === 0xd8)
114
+ return 'image/jpeg';
115
+ if (bytes[0] === 0x52 && bytes[8] === 0x57 && bytes[9] === 0x45)
116
+ return 'image/webp';
117
+ return null;
118
+ }
119
+ /** Where an unpacked image lives, relative to the `.blend` when Blender wrote it that way. */
120
+ export function imagePath(image) {
121
+ const path = image.string('name');
122
+ return path.startsWith('//') ? path.slice(2) : path;
123
+ }
124
+ /** Build the document. */
125
+ export function blendToGltf(blend, options = {}) {
126
+ const refusals = [];
127
+ const approximations = [];
128
+ const warnings = [];
129
+ const notes = [];
130
+ const only = options.only;
131
+ const placed = only === undefined
132
+ ? placeObjects(blend, refusals, () => null)
133
+ : [
134
+ {
135
+ object: only,
136
+ name: only.idName(),
137
+ kind: only.int('type'),
138
+ world: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
139
+ parent: -1,
140
+ },
141
+ ];
142
+ const builder = new Builder({
143
+ asset: { version: '2.0', generator: 'DriftEngine .blend reader' },
144
+ });
145
+ const doc = builder.doc;
146
+ doc.nodes = [];
147
+ doc.meshes = [];
148
+ doc.materials = [];
149
+ doc.textures = [];
150
+ doc.images = [];
151
+ const lights = [];
152
+ const used = new Set();
153
+ const imageIndex = new Map();
154
+ const textureOf = (image) => {
155
+ const known = imageIndex.get(image.offset);
156
+ if (known !== undefined)
157
+ return known;
158
+ const bytes = packedImage(image);
159
+ const name = image.idName();
160
+ const source = image.int('source');
161
+ if (bytes === null && source === 4)
162
+ approximations.push(`image "${name}" is generated by Blender and not packed, so it is drawn without`);
163
+ if (source === 6)
164
+ approximations.push(`image "${name}" is tiled across UDIMs, and only its first tile is read`);
165
+ const entry = { name };
166
+ if (options.imageUri !== undefined)
167
+ entry.uri = options.imageUri(image);
168
+ else if (bytes !== null) {
169
+ const mime = imageMime(bytes);
170
+ if (mime === null)
171
+ approximations.push(`image "${name}" is in a format the engine does not decode, which Blender's exporter converts`);
172
+ entry.bufferView = builder.view(bytes);
173
+ entry.mimeType = mime ?? 'application/octet-stream';
174
+ }
175
+ else
176
+ entry.uri = imagePath(image).split('/').map(encodeURIComponent).join('/');
177
+ const index = doc.images.length;
178
+ doc.images.push(entry);
179
+ doc.textures.push({ source: index });
180
+ imageIndex.set(image.offset, index);
181
+ return index;
182
+ };
183
+ const surfaces = new Map();
184
+ const materialIndex = new Map();
185
+ const surfaceOf = (material) => {
186
+ let surface = surfaces.get(material.offset);
187
+ if (surface === undefined) {
188
+ surface = readBlendSurface(material, blend.header.version);
189
+ surfaces.set(material.offset, surface);
190
+ warnings.push(...surface.approximations);
191
+ }
192
+ return surface;
193
+ };
194
+ const gltfMaterial = (material) => {
195
+ const known = materialIndex.get(material.offset);
196
+ if (known !== undefined)
197
+ return known;
198
+ const json = surfaceToGltf(surfaceOf(material), textureOf, approximations);
199
+ const index = doc.materials.length;
200
+ doc.materials.push(json);
201
+ materialIndex.set(material.offset, index);
202
+ if (json.extensions !== undefined)
203
+ for (const name of Object.keys(json.extensions))
204
+ used.add(name);
205
+ return index;
206
+ };
207
+ const meshCache = new Map();
208
+ const worlds = placed.map((p) => {
209
+ const world = multiply(multiply(Z_TO_Y, p.world), Y_TO_Z);
210
+ return p.kind === OB_LAMP ? multiply(world, LIGHT_CORRECTION) : world;
211
+ });
212
+ placed.forEach((p, i) => {
213
+ const node = { name: p.name };
214
+ const parentWorld = p.parent >= 0 ? worlds[p.parent] : null;
215
+ node['matrix'] =
216
+ parentWorld === null ? worlds[i] : multiply(invert(parentWorld), worlds[i]);
217
+ if (p.kind === OB_ARMATURE)
218
+ refusals.push(`"${p.name}" is an armature, and the direct reader does not read rigs yet`);
219
+ if (p.object.has('adt') && p.object.ptr('adt') !== 0n)
220
+ approximations.push(`"${p.name}" is animated, and the direct reader draws the frame it was saved on`);
221
+ if (p.kind === OB_MESH) {
222
+ const mesh = meshOf(p, blend, refusals, warnings, gltfMaterial, builder, meshCache);
223
+ if (mesh !== null)
224
+ node['mesh'] = mesh;
225
+ }
226
+ else if (p.kind === OB_LAMP) {
227
+ const light = lightOf(p, warnings);
228
+ if (light !== null) {
229
+ node['extensions'] = { KHR_lights_punctual: { light: lights.length } };
230
+ lights.push(light);
231
+ }
232
+ }
233
+ doc.nodes.push(node);
234
+ });
235
+ placed.forEach((p, i) => {
236
+ if (p.parent < 0)
237
+ return;
238
+ const parent = doc.nodes[p.parent];
239
+ parent['children'] = [...(parent['children'] ?? []), i];
240
+ });
241
+ doc.scenes = [{ nodes: placed.map((p, i) => (p.parent < 0 ? i : -1)).filter((i) => i >= 0) }];
242
+ doc.scene = 0;
243
+ if (lights.length > 0) {
244
+ doc.extensions = { KHR_lights_punctual: { lights } };
245
+ used.add('KHR_lights_punctual');
246
+ }
247
+ if (used.size > 0)
248
+ doc.extensionsUsed = [...used];
249
+ notes.push(`blend: Blender ${blend.header.version}, ${placed.length} objects, ${doc.meshes.length} meshes, ${doc.materials.length} materials.`);
250
+ return {
251
+ doc,
252
+ binary: builder.finish(),
253
+ refusals: [...new Set(refusals)],
254
+ approximations: [...new Set(approximations)],
255
+ warnings: [...new Set(warnings)],
256
+ notes,
257
+ };
258
+ }
259
+ function meshOf(p, blend, refusals, approximations, gltfMaterial, builder, cache) {
260
+ const data = p.object.deref('data');
261
+ if (data === null)
262
+ return null;
263
+ const verdict = judgeModifiers(p.object);
264
+ refusals.push(...verdict.refusals);
265
+ if (verdict.refusals.length > 0)
266
+ return null;
267
+ const slots = materialSlots(p.object, data);
268
+ const key = `${data.offset}:${verdict.splitAngle}:${slots.map((s) => s?.offset ?? 0).join(',')}`;
269
+ const cached = cache.get(key);
270
+ if (cached !== undefined)
271
+ return cached;
272
+ const mesh = readBlendMesh(blend, data);
273
+ const normals = cornerNormals(mesh, verdict.splitAngle ?? mesh.autoSmooth);
274
+ const surfaces = slots.map((material) => material === null ? null : readBlendSurface(material, blend.header.version));
275
+ const primitives = meshPrimitives(mesh, normals, slots.length, (slot) => {
276
+ const surface = surfaces[slot] ?? null;
277
+ return {
278
+ vertexColor: surface?.vertexColor ?? null,
279
+ uvMap: surface?.uvMap ?? null,
280
+ uvTransform: surface?.uvTransform ?? null,
281
+ };
282
+ });
283
+ const keys = shapeKeys(data, mesh.positions.length / 3);
284
+ if (keys.some((k) => k.vgroup !== ''))
285
+ approximations.push(`mesh "${mesh.name}" has a shape key limited to a vertex group, which glTF cannot limit`);
286
+ const gltfPrimitives = primitives.map((primitive) => {
287
+ const attributes = {
288
+ POSITION: builder.accessor(primitive.positions, 'VEC3'),
289
+ NORMAL: builder.accessor(primitive.normals, 'VEC3'),
290
+ };
291
+ if (primitive.uvs !== null)
292
+ attributes['TEXCOORD_0'] = builder.accessor(primitive.uvs, 'VEC2');
293
+ if (primitive.colors !== null)
294
+ attributes['COLOR_0'] = builder.accessor(primitive.colors, 'VEC4');
295
+ const out = {
296
+ attributes,
297
+ indices: builder.accessor(primitive.indices, 'SCALAR'),
298
+ mode: 4,
299
+ };
300
+ const material = primitive.slot >= 0 ? slots[primitive.slot] : null;
301
+ if (material !== null && material !== undefined)
302
+ out['material'] = gltfMaterial(material);
303
+ if (keys.length > 0) {
304
+ out['targets'] = keys.map((key) => {
305
+ const delta = new Float32Array(primitive.positions.length);
306
+ const normalDelta = new Float32Array(primitive.positions.length);
307
+ const shaped = cornerNormals({ ...mesh, positions: key.positions }, verdict.splitAngle ?? mesh.autoSmooth);
308
+ for (let e = 0; e < primitive.sourceVertex.length; e++) {
309
+ const v = primitive.sourceVertex[e];
310
+ const c = primitive.sourceCorner[e];
311
+ const d = [0, 1, 2].map((k) => key.positions[v * 3 + k] - key.reference[v * 3 + k]);
312
+ delta.set([d[0], d[2], -d[1]], e * 3);
313
+ const n = [0, 1, 2].map((k) => shaped[c * 3 + k] - normals[c * 3 + k]);
314
+ normalDelta.set([n[0], n[2], -n[1]], e * 3);
315
+ }
316
+ return {
317
+ POSITION: builder.accessor(delta, 'VEC3'),
318
+ NORMAL: builder.accessor(normalDelta, 'VEC3'),
319
+ };
320
+ });
321
+ }
322
+ return out;
323
+ });
324
+ const meshes = builder.doc.meshes;
325
+ const entry = { name: mesh.name, primitives: gltfPrimitives };
326
+ if (keys.length > 0) {
327
+ entry['weights'] = keys.map((k) => k.weight);
328
+ entry['extras'] = { targetNames: keys.map((k) => k.name) };
329
+ }
330
+ meshes.push(entry);
331
+ cache.set(key, meshes.length - 1);
332
+ return meshes.length - 1;
333
+ }
334
+ /** An object's material slots: the object's own material where its slot says so, else the mesh's. */
335
+ export function materialSlots(object, mesh) {
336
+ const count = Math.max(object.int('totcol'), mesh.int('totcol'));
337
+ const own = object.int('totcol') > 0 ? object.pointers('mat', object.int('totcol')) : [];
338
+ const shared = mesh.int('totcol') > 0 ? mesh.pointers('mat', mesh.int('totcol')) : [];
339
+ const bits = object.int('totcol') > 0 ? object.raw('matbits') : null;
340
+ const out = [];
341
+ for (let i = 0; i < count; i++) {
342
+ const useObject = bits !== null && (bits[i] ?? 0) !== 0;
343
+ const address = useObject ? own[i] : shared[i];
344
+ out.push(address === undefined || address === 0n
345
+ ? null
346
+ : (useObject ? object : mesh).file.at(address, (useObject ? object : mesh).scope));
347
+ }
348
+ return out;
349
+ }
350
+ /** The mesh's shape keys other than its basis, each with the key it is relative to. */
351
+ function shapeKeys(mesh, verts) {
352
+ const key = mesh.deref('key');
353
+ if (key === null)
354
+ return [];
355
+ const blocks = key.list('block');
356
+ const read = (block) => {
357
+ const raw = block.raw('data');
358
+ const out = new Float32Array(verts * 3);
359
+ if (raw === null)
360
+ return out;
361
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
362
+ const le = block.file.header.littleEndian;
363
+ for (let i = 0; i < Math.min(verts * 3, raw.length / 4); i++)
364
+ out[i] = view.getFloat32(i * 4, le);
365
+ return out;
366
+ };
367
+ const basis = key.ptr('refkey');
368
+ const out = [];
369
+ const cache = new Map();
370
+ const data = (index) => {
371
+ let found = cache.get(index);
372
+ if (found === undefined)
373
+ cache.set(index, (found = read(blocks[index])));
374
+ return found;
375
+ };
376
+ blocks.forEach((block, index) => {
377
+ if (block.offset === key.file.at(basis, key.scope)?.offset)
378
+ return;
379
+ if ((block.int('flag') & KEYBLOCK_MUTE) !== 0)
380
+ return;
381
+ const relative = Math.max(0, Math.min(blocks.length - 1, block.int('relative')));
382
+ if (relative === index)
383
+ return;
384
+ out.push({
385
+ name: block.string('name'),
386
+ weight: block.float('curval'),
387
+ positions: data(index),
388
+ reference: data(relative),
389
+ vgroup: block.string('vgroup'),
390
+ });
391
+ });
392
+ return out;
393
+ }
394
+ function lightOf(p, warnings) {
395
+ const lamp = p.object.deref('data');
396
+ if (lamp === null)
397
+ return null;
398
+ const type = lamp.int('type');
399
+ const exposure = lamp.has('exposure') ? 2 ** lamp.float('exposure') : 1;
400
+ let color = [lamp.float('r'), lamp.float('g'), lamp.float('b')];
401
+ if (lamp.has('mode') &&
402
+ lamp.has('temperature') &&
403
+ (lamp.int('mode') & LA_USE_TEMPERATURE) !== 0) {
404
+ const warmth = blackbody(lamp.float('temperature'));
405
+ color = color.map((c, i) => c * warmth[i]);
406
+ }
407
+ if (type === 1)
408
+ return {
409
+ type: 'directional',
410
+ name: p.name,
411
+ color,
412
+ intensity: lamp.float('energy') * WATTS_TO_LUMENS * exposure,
413
+ };
414
+ const intensity = (lamp.float('energy') / (4 * Math.PI)) * WATTS_TO_LUMENS * exposure;
415
+ if (type === 0)
416
+ return { type: 'point', name: p.name, color, intensity };
417
+ if (type === 2) {
418
+ const outer = lamp.float('spotsize') / 2;
419
+ return {
420
+ type: 'spot',
421
+ name: p.name,
422
+ color,
423
+ intensity,
424
+ spot: { innerConeAngle: outer - outer * lamp.float('spotblend'), outerConeAngle: outer },
425
+ };
426
+ }
427
+ warnings.push(`light "${p.name}" is an area light, which glTF cannot express, so it is left out`);
428
+ return null;
429
+ }
430
+ /**
431
+ * The colour of a black body at `kelvin`, in linear Rec. 709, scaled to unit luminance: what
432
+ * Blender multiplies a light's colour by when the light takes its colour from a temperature.
433
+ *
434
+ * Planck's law through the CIE 1931 observer, by the multi-lobe fit of Wyman, Sloan and Shirley.
435
+ * **Within 1% of Blender's table, not equal to it**: Blender's is fitted per range to its own colour
436
+ * configuration, and at 3000 K reads (1.771, 0.844, 0.272) where this reads (1.752, 0.850, 0.271).
437
+ */
438
+ export function blackbody(kelvin) {
439
+ const lobe = (x, mean, left, right) => {
440
+ const t = (x - mean) * (x < mean ? left : right);
441
+ return Math.exp(-0.5 * t * t);
442
+ };
443
+ const h = 6.62607015e-34;
444
+ const c = 2.99792458e8;
445
+ const k = 1.380649e-23;
446
+ const t = Math.min(40000, Math.max(800, kelvin));
447
+ let x = 0;
448
+ let y = 0;
449
+ let z = 0;
450
+ for (let nm = 360; nm <= 830; nm++) {
451
+ const m = nm * 1e-9;
452
+ const radiance = (2 * h * c * c) / m ** 5 / (Math.exp((h * c) / (m * k * t)) - 1);
453
+ x +=
454
+ radiance *
455
+ (1.056 * lobe(nm, 599.8, 0.0264, 0.0323) +
456
+ 0.362 * lobe(nm, 442.0, 0.0624, 0.0374) -
457
+ 0.065 * lobe(nm, 501.1, 0.049, 0.0382));
458
+ y +=
459
+ radiance *
460
+ (0.821 * lobe(nm, 568.8, 0.0213, 0.0247) + 0.286 * lobe(nm, 530.9, 0.0613, 0.0322));
461
+ z +=
462
+ radiance *
463
+ (1.217 * lobe(nm, 437.0, 0.0845, 0.0278) + 0.681 * lobe(nm, 459.0, 0.0385, 0.0725));
464
+ }
465
+ const r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z;
466
+ const g = -0.969266 * x + 1.8760108 * y + 0.041556 * z;
467
+ const b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z;
468
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
469
+ return [r / luminance, g / luminance, b / luminance];
470
+ }
471
+ /** A surface as a glTF material, packing what glTF packs and naming what it cannot. */
472
+ function surfaceToGltf(surface, texture, approximations) {
473
+ const note = (what) => {
474
+ approximations.push(`material "${surface.name}": ${what}`);
475
+ };
476
+ const pbr = {
477
+ baseColorFactor: [...surface.baseColor, surface.alpha],
478
+ metallicFactor: surface.metallic,
479
+ roughnessFactor: surface.roughness,
480
+ };
481
+ const out = {
482
+ name: surface.name,
483
+ pbrMetallicRoughness: pbr,
484
+ };
485
+ if (surface.baseColorMap !== null)
486
+ pbr['baseColorTexture'] = { index: texture(surface.baseColorMap.image) };
487
+ if (surface.alphaMap !== null &&
488
+ (surface.alphaMap.channel !== 'a' ||
489
+ surface.alphaMap.image.offset !== surface.baseColorMap?.image.offset)) {
490
+ note('its alpha comes from a different image than its colour, which glTF cannot hold apart');
491
+ }
492
+ const packed = [surface.roughnessMap, surface.metallicMap].filter((m) => m !== null);
493
+ if (packed.length > 0) {
494
+ const image = packed[0].image;
495
+ if (packed.some((m) => m.image.offset !== image.offset))
496
+ note('its roughness and metallic come from two images, and glTF packs them into one');
497
+ /* A grey image has the same value in every channel, so which one a socket reads changes nothing. */
498
+ const grey = isGreyImage(image);
499
+ if (!grey && surface.roughnessMap !== null && surface.roughnessMap.channel !== 'g')
500
+ note(`its roughness is read from the ${surface.roughnessMap.channel} channel where glTF reads green`);
501
+ if (!grey && surface.metallicMap !== null && surface.metallicMap.channel !== 'b')
502
+ note(`its metallic is read from the ${surface.metallicMap.channel} channel where glTF reads blue`);
503
+ /* A map for one of the pair only: glTF multiplies the other's factor by the same image, so the
504
+ pair is exact only where that other factor is zero. */
505
+ if (surface.metallicMap === null && surface.metallic !== 0)
506
+ note('its roughness has a map and its metallic does not, and glTF multiplies one image into both');
507
+ pbr['metallicRoughnessTexture'] = { index: texture(image) };
508
+ }
509
+ if (surface.occlusionMap !== null)
510
+ out['occlusionTexture'] = { index: texture(surface.occlusionMap.image) };
511
+ if (surface.normalMap !== null)
512
+ out['normalTexture'] = {
513
+ index: texture(surface.normalMap.image),
514
+ scale: surface.normalStrength,
515
+ };
516
+ const emitted = surface.emission.map((c) => c * surface.emissionStrength);
517
+ if ((surface.emissionMap !== null || emitted.some((c) => c > 0)) &&
518
+ surface.emissionStrength > 0) {
519
+ if (Math.max(...emitted) > 1) {
520
+ out['emissiveFactor'] = [...surface.emission];
521
+ out.extensions = {
522
+ ...out.extensions,
523
+ KHR_materials_emissive_strength: { emissiveStrength: surface.emissionStrength },
524
+ };
525
+ }
526
+ else
527
+ out['emissiveFactor'] = emitted;
528
+ if (surface.emissionMap !== null)
529
+ out['emissiveTexture'] = { index: texture(surface.emissionMap.image) };
530
+ }
531
+ if (surface.transmission > 0)
532
+ out.extensions = {
533
+ ...out.extensions,
534
+ KHR_materials_transmission: { transmissionFactor: surface.transmission },
535
+ };
536
+ if (surface.alphaMode !== 'OPAQUE')
537
+ out['alphaMode'] = surface.alphaMode;
538
+ if (surface.alphaMode === 'MASK' && surface.alphaCutoff !== 0.5)
539
+ out['alphaCutoff'] = surface.alphaCutoff;
540
+ if (surface.doubleSided)
541
+ out['doubleSided'] = true;
542
+ if (surface.extras !== null)
543
+ out['extras'] = surface.extras;
544
+ return out;
545
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What a `.blend` material's node graph says each surface channel is: a value, or a channel of an
3
+ * image times a value.
4
+ *
5
+ * **One reading, two consumers.** `blendGltf.ts` turns a `BlendSurface` into a glTF material, the
6
+ * way Blender's glTF exporter would; a bake that composes its own images reads the same surface
7
+ * and can pack channels glTF cannot, such as roughness and metallic drawn from two images. So the
8
+ * graph is walked once, here, into channels, and what glTF can and cannot hold is decided later.
9
+ *
10
+ * **The patterns are the ones Blender itself writes**, because those are the graphs real files
11
+ * carry: Blender's glTF importer builds exactly this wiring — an image straight into a socket, a
12
+ * Separate Color between an image and roughness, metallic or occlusion, a Math Multiply for a
13
+ * factor, a Normal Map node, a Mapping node for a texture transform, and a "glTF Material Output"
14
+ * group for occlusion — and so does a person wiring a Principled BSDF by hand. Reroutes are
15
+ * walked through. A city assembled from imported assets used nothing else in 1,809 materials.
16
+ *
17
+ * **What it cannot read it names**, in `approximations`, and uses the socket's own value: a
18
+ * procedural texture, a Mix Shader, a math chain, a bump node.
19
+ *
20
+ * Old files mean what Blender makes of them now: a material saved before 4.2 has its blend mode
21
+ * from `blend_method`, which is what Blender converts on opening, and one with no node tree is its
22
+ * viewport colour, metallic and roughness.
23
+ */
24
+ import type { BlendStruct } from './blendData.ts';
25
+ /** A channel of an image, as a socket reads it. */
26
+ export interface ChannelSource {
27
+ readonly image: BlendStruct;
28
+ /** `rgb` for a colour socket; one channel for a scalar socket. */
29
+ readonly channel: 'rgb' | 'r' | 'g' | 'b' | 'a';
30
+ }
31
+ /** A material's surface, channel by channel. Factors multiply the map where there is one. */
32
+ export interface BlendSurface {
33
+ readonly name: string;
34
+ readonly baseColor: [number, number, number];
35
+ readonly baseColorMap: ChannelSource | null;
36
+ readonly alpha: number;
37
+ readonly alphaMap: ChannelSource | null;
38
+ readonly alphaMode: 'OPAQUE' | 'BLEND' | 'MASK';
39
+ readonly alphaCutoff: number;
40
+ /** The colour attribute the base colour is multiplied by: a name, `''` for the render one. */
41
+ readonly vertexColor: string | null;
42
+ readonly metallic: number;
43
+ readonly metallicMap: ChannelSource | null;
44
+ readonly roughness: number;
45
+ readonly roughnessMap: ChannelSource | null;
46
+ readonly occlusionMap: ChannelSource | null;
47
+ readonly normalMap: ChannelSource | null;
48
+ readonly normalStrength: number;
49
+ readonly emission: [number, number, number];
50
+ readonly emissionStrength: number;
51
+ readonly emissionMap: ChannelSource | null;
52
+ readonly transmission: number;
53
+ readonly doubleSided: boolean;
54
+ /** The UV map the maps read, by name, or null for the render map. */
55
+ readonly uvMap: string | null;
56
+ /** A 3x3 affine transform of that map, column-major, in Blender's UV space, or null. */
57
+ readonly uvTransform: number[] | null;
58
+ readonly extras: Record<string, unknown> | null;
59
+ /**
60
+ * What glTF cannot say about this material by either route: a procedural texture, a Mix Shader,
61
+ * a math chain. Blender's exporter loses these too, so they are warnings and not a reason to ask it.
62
+ */
63
+ readonly approximations: readonly string[];
64
+ }
65
+ /** Read one material's surface. */
66
+ export declare function readBlendSurface(material: BlendStruct, version: number): BlendSurface;
67
+ /** An ID's custom properties that are plain values, for glTF's `extras`. */
68
+ export declare function customProperties(id: BlendStruct): Record<string, unknown> | null;