@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,676 @@
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
+
25
+ import type { BlendStruct } from './blendData.ts';
26
+ import type { Mat4 } from './blendMatrix.ts';
27
+ import { eulerMatrix, compose, invert, multiply } from './blendMatrix.ts';
28
+
29
+ /** A channel of an image, as a socket reads it. */
30
+ export interface ChannelSource {
31
+ readonly image: BlendStruct;
32
+ /** `rgb` for a colour socket; one channel for a scalar socket. */
33
+ readonly channel: 'rgb' | 'r' | 'g' | 'b' | 'a';
34
+ }
35
+
36
+ /** A material's surface, channel by channel. Factors multiply the map where there is one. */
37
+ export interface BlendSurface {
38
+ readonly name: string;
39
+ readonly baseColor: [number, number, number];
40
+ readonly baseColorMap: ChannelSource | null;
41
+ readonly alpha: number;
42
+ readonly alphaMap: ChannelSource | null;
43
+ readonly alphaMode: 'OPAQUE' | 'BLEND' | 'MASK';
44
+ readonly alphaCutoff: number;
45
+ /** The colour attribute the base colour is multiplied by: a name, `''` for the render one. */
46
+ readonly vertexColor: string | null;
47
+ readonly metallic: number;
48
+ readonly metallicMap: ChannelSource | null;
49
+ readonly roughness: number;
50
+ readonly roughnessMap: ChannelSource | null;
51
+ readonly occlusionMap: ChannelSource | null;
52
+ readonly normalMap: ChannelSource | null;
53
+ readonly normalStrength: number;
54
+ readonly emission: [number, number, number];
55
+ readonly emissionStrength: number;
56
+ readonly emissionMap: ChannelSource | null;
57
+ readonly transmission: number;
58
+ readonly doubleSided: boolean;
59
+ /** The UV map the maps read, by name, or null for the render map. */
60
+ readonly uvMap: string | null;
61
+ /** A 3x3 affine transform of that map, column-major, in Blender's UV space, or null. */
62
+ readonly uvTransform: number[] | null;
63
+ readonly extras: Record<string, unknown> | null;
64
+ /**
65
+ * What glTF cannot say about this material by either route: a procedural texture, a Mix Shader,
66
+ * a math chain. Blender's exporter loses these too, so they are warnings and not a reason to ask it.
67
+ */
68
+ readonly approximations: readonly string[];
69
+ }
70
+
71
+ const NODE_DO_OUTPUT = 1 << 6;
72
+ const NODE_MUTED = 1 << 9;
73
+ const NODE_LINK_MUTED = 1 << 4;
74
+ const MA_BL_CULL_BACKFACE = 1 << 2;
75
+ const MA_BM_SOLID = 0;
76
+ const MA_BM_CLIP = 3;
77
+ const RENDER_BLENDED = 1;
78
+ const SOCK_FLOAT = 0;
79
+ const SOCK_RGBA = 2;
80
+ const MA_RAMP_MULT = 2;
81
+ /* Math node operations, by the number `custom1` stores. */
82
+ const MATH_SUBTRACT = 1;
83
+ const MATH_MULTIPLY = 2;
84
+ const MATH_ROUND = 14;
85
+ const MATH_LESS_THAN = 15;
86
+ const MATH_GREATER_THAN = 16;
87
+ /* Mapping node vector types. */
88
+ const MAPPING_POINT = 0;
89
+ const MAPPING_TEXTURE = 1;
90
+
91
+ interface Link {
92
+ readonly node: BlendStruct;
93
+ readonly socket: string;
94
+ }
95
+
96
+ const idname = (node: BlendStruct): string => node.string('idname');
97
+
98
+ /** A node graph, indexed for following links backwards from an input. */
99
+ class Graph {
100
+ readonly nodes: BlendStruct[];
101
+ private readonly into = new Map<string, Link>();
102
+
103
+ constructor(tree: BlendStruct) {
104
+ this.nodes = tree.list('nodes');
105
+ for (const link of tree.list('links')) {
106
+ if ((link.int('flag') & NODE_LINK_MUTED) !== 0) continue;
107
+ const to = link.deref('tonode');
108
+ const toSocket = link.deref('tosock');
109
+ const from = link.deref('fromnode');
110
+ const fromSocket = link.deref('fromsock');
111
+ if (to === null || toSocket === null || from === null || fromSocket === null) continue;
112
+ this.into.set(`${to.offset}:${toSocket.string('identifier')}`, {
113
+ node: from,
114
+ socket: fromSocket.string('identifier'),
115
+ });
116
+ }
117
+ }
118
+
119
+ /** The link into an input, walked back through reroutes, which forward their one input. */
120
+ private follow(link: Link | null): Link | null {
121
+ let current = link;
122
+ for (
123
+ let hops = 0;
124
+ current !== null && idname(current.node) === 'NodeReroute' && hops < 64;
125
+ hops++
126
+ ) {
127
+ current = this.into.get(`${current.node.offset}:Input`) ?? null;
128
+ }
129
+ return current;
130
+ }
131
+
132
+ input(
133
+ node: BlendStruct,
134
+ ...identifiers: string[]
135
+ ): { socket: BlendStruct | null; link: Link | null } {
136
+ for (const socket of node.list('inputs')) {
137
+ const id = socket.string('identifier');
138
+ if (!identifiers.includes(id) && !identifiers.includes(socket.string('name'))) continue;
139
+ return { socket, link: this.follow(this.into.get(`${node.offset}:${id}`) ?? null) };
140
+ }
141
+ return { socket: null, link: null };
142
+ }
143
+
144
+ /** The `index`-th input socket of a node, by position. */
145
+ at(node: BlendStruct, index: number): { socket: BlendStruct | null; link: Link | null } {
146
+ const socket = node.list('inputs')[index] ?? null;
147
+ if (socket === null) return { socket: null, link: null };
148
+ return {
149
+ socket,
150
+ link: this.follow(this.into.get(`${node.offset}:${socket.string('identifier')}`) ?? null),
151
+ };
152
+ }
153
+ }
154
+
155
+ function socketValue(socket: BlendStruct | null): number[] | null {
156
+ if (socket === null) return null;
157
+ const type = socket.int('type');
158
+ const value = socket.deref(
159
+ 'default_value',
160
+ type === SOCK_RGBA
161
+ ? 'bNodeSocketValueRGBA'
162
+ : type === SOCK_FLOAT
163
+ ? 'bNodeSocketValueFloat'
164
+ : 'bNodeSocketValueVector',
165
+ );
166
+ if (value === null) return null;
167
+ return type === SOCK_FLOAT ? [value.float('value')] : value.floats('value');
168
+ }
169
+
170
+ const scalarOf = (input: { socket: BlendStruct | null }, fallback: number): number =>
171
+ socketValue(input.socket)?.[0] ?? fallback;
172
+
173
+ /** The material's active output, the one Blender would render with EEVEE. */
174
+ function outputNode(graph: Graph): BlendStruct | null {
175
+ const outputs = graph.nodes.filter((node) => idname(node) === 'ShaderNodeOutputMaterial');
176
+ return (
177
+ outputs.find(
178
+ (node) => (node.int('flag') & NODE_DO_OUTPUT) !== 0 && node.int('custom1') !== 2,
179
+ ) ??
180
+ outputs.find((node) => node.int('custom1') !== 2) ??
181
+ null
182
+ );
183
+ }
184
+
185
+ /** A source traced back from a socket: an image channel times a factor, or a constant. */
186
+ interface Traced {
187
+ map: ChannelSource | null;
188
+ factor: number[] | null;
189
+ attribute: string | null;
190
+ }
191
+
192
+ class Reader {
193
+ readonly approximations: string[] = [];
194
+ uvMap: string | null = null;
195
+ uvTransform: number[] | null = null;
196
+ private uvSeen = false;
197
+
198
+ constructor(
199
+ private readonly graph: Graph,
200
+ private readonly name: string,
201
+ ) {}
202
+
203
+ note(what: string): void {
204
+ this.approximations.push(`material "${this.name}": ${what}`);
205
+ }
206
+
207
+ /** A colour socket: a constant, an image, an image times a constant, or a colour attribute. */
208
+ colour(node: BlendStruct, ids: string[], label: string): Traced {
209
+ const { socket, link } = this.graph.input(node, ...ids);
210
+ if (link === null) return { map: null, factor: socketValue(socket), attribute: null };
211
+ return this.traceColour(link, label);
212
+ }
213
+
214
+ traceColour(link: Link, label: string): Traced {
215
+ const kind = idname(link.node);
216
+ if (kind === 'ShaderNodeTexImage')
217
+ return {
218
+ map: this.image(link.node, link.socket === 'Alpha' ? 'a' : 'rgb'),
219
+ factor: null,
220
+ attribute: null,
221
+ };
222
+ if (kind === 'ShaderNodeVertexColor' || kind === 'ShaderNodeAttribute') {
223
+ const storage = link.node.blockOf('storage') === null ? null : link.node.deref('storage');
224
+ const layer =
225
+ storage?.has('layer_name') === true
226
+ ? storage.string('layer_name')
227
+ : storage?.has('name') === true
228
+ ? storage.string('name')
229
+ : '';
230
+ return { map: null, factor: null, attribute: layer };
231
+ }
232
+ if (kind === 'ShaderNodeMix' || kind === 'ShaderNodeMixRGB') {
233
+ /* The legacy node keeps its blend mode in `custom1`; its successor keeps mode and type in storage. */
234
+ const storage = kind === 'ShaderNodeMix' ? link.node.deref('storage', 'NodeShaderMix') : null;
235
+ const multiply =
236
+ kind === 'ShaderNodeMixRGB'
237
+ ? link.node.int('custom1') === MA_RAMP_MULT
238
+ : storage !== null &&
239
+ storage.int('data_type') === SOCK_RGBA &&
240
+ storage.int('blend_type') === MA_RAMP_MULT;
241
+ const fac = scalarOf(this.graph.input(link.node, 'Factor_Float', 'Fac'), 1);
242
+ if (multiply && fac === 1) {
243
+ const sides = [
244
+ this.graph.input(link.node, 'A_Color', 'Color1'),
245
+ this.graph.input(link.node, 'B_Color', 'Color2'),
246
+ ].map((side) =>
247
+ side.link === null
248
+ ? { map: null, factor: socketValue(side.socket), attribute: null }
249
+ : this.traceColour(side.link, label),
250
+ );
251
+ const maps = sides.filter((s) => s.map !== null);
252
+ if (maps.length > 1)
253
+ this.note(`its ${label} multiplies two images, and only the first is kept`);
254
+ const factors = sides.map((s) => s.factor).filter((f): f is number[] => f !== null);
255
+ const factor =
256
+ factors.length === 0 ? null : factors.reduce((a, b) => a.map((v, i) => v * (b[i] ?? 1)));
257
+ return {
258
+ map: maps[0]?.map ?? null,
259
+ factor,
260
+ attribute: sides.find((s) => s.attribute !== null)?.attribute ?? null,
261
+ };
262
+ }
263
+ }
264
+ this.note(
265
+ `its ${label} comes from ${kind}, which glTF cannot express, so the socket's own value is used`,
266
+ );
267
+ return { map: null, factor: null, attribute: null };
268
+ }
269
+
270
+ /** A scalar socket: a constant, or one channel of an image times a constant. */
271
+ scalar(
272
+ node: BlendStruct,
273
+ ids: string[],
274
+ label: string,
275
+ fallback: number,
276
+ ): { value: number; map: ChannelSource | null } {
277
+ const input = this.graph.input(node, ...ids);
278
+ if (input.link === null) return { value: scalarOf(input, fallback), map: null };
279
+ return this.traceScalar(input.link, label, fallback);
280
+ }
281
+
282
+ traceScalar(
283
+ link: Link,
284
+ label: string,
285
+ fallback: number,
286
+ ): { value: number; map: ChannelSource | null } {
287
+ const kind = idname(link.node);
288
+ if (kind === 'ShaderNodeSeparateColor' || kind === 'ShaderNodeSeparateRGB') {
289
+ const channel = ({ Red: 'r', R: 'r', Green: 'g', G: 'g', Blue: 'b', B: 'b' } as const)[
290
+ link.socket as 'Red'
291
+ ];
292
+ const source = this.graph.input(link.node, 'Color', 'Image').link;
293
+ if (
294
+ channel !== undefined &&
295
+ source !== null &&
296
+ idname(source.node) === 'ShaderNodeTexImage'
297
+ ) {
298
+ return { value: 1, map: this.image(source.node, channel) };
299
+ }
300
+ } else if (kind === 'ShaderNodeTexImage') {
301
+ /* A grey image straight into a scalar: its colour is read as one channel, the first. */
302
+ return { value: 1, map: this.image(link.node, link.socket === 'Alpha' ? 'a' : 'r') };
303
+ } else if (kind === 'ShaderNodeMath' && link.node.int('custom1') === MATH_MULTIPLY) {
304
+ const a = this.graph.at(link.node, 0);
305
+ const b = this.graph.at(link.node, 1);
306
+ const constant = a.link === null ? a : b.link === null ? b : null;
307
+ const other = constant === a ? b : a;
308
+ if (constant !== null && other.link !== null) {
309
+ const inner = this.traceScalar(other.link, label, fallback);
310
+ return { value: inner.value * scalarOf(constant, 1), map: inner.map };
311
+ }
312
+ if (a.link === null && b.link === null)
313
+ return { value: scalarOf(a, 1) * scalarOf(b, 1), map: null };
314
+ }
315
+ this.note(
316
+ `its ${label} comes from ${kind}, which glTF cannot express, so the socket's own value is used`,
317
+ );
318
+ return { value: fallback, map: null };
319
+ }
320
+
321
+ /** An image node's picture, and the UV map and transform it is placed by. */
322
+ image(node: BlendStruct, channel: ChannelSource['channel']): ChannelSource | null {
323
+ const image = node.deref('id');
324
+ if (image === null) return null;
325
+ const placement = this.placement(this.graph.input(node, 'Vector').link, image.idName());
326
+ if (!this.uvSeen) {
327
+ this.uvSeen = true;
328
+ this.uvMap = placement.map;
329
+ this.uvTransform = placement.transform;
330
+ } else if (
331
+ placement.map !== this.uvMap ||
332
+ JSON.stringify(placement.transform) !== JSON.stringify(this.uvTransform)
333
+ ) {
334
+ this.note(
335
+ `image "${image.idName()}" is placed differently from the material's other images, and glTF gives a material one placement`,
336
+ );
337
+ }
338
+ return { image, channel };
339
+ }
340
+
341
+ private placement(
342
+ link: Link | null,
343
+ image: string,
344
+ ): { map: string | null; transform: number[] | null } {
345
+ if (link === null) return { map: null, transform: null };
346
+ const kind = idname(link.node);
347
+ if (kind === 'ShaderNodeTexCoord' && link.socket === 'UV')
348
+ return { map: null, transform: null };
349
+ if (kind === 'ShaderNodeUVMap') {
350
+ const storage = link.node.deref('storage', 'NodeShaderUVMap');
351
+ const name = storage?.string('uv_map') ?? '';
352
+ return { map: name === '' ? null : name, transform: null };
353
+ }
354
+ if (kind === 'ShaderNodeMapping') {
355
+ const inner = this.placement(this.graph.input(link.node, 'Vector').link, image);
356
+ const vector = (id: string, fallback: number[]): number[] => {
357
+ const input = this.graph.input(link.node, id);
358
+ if (input.link !== null)
359
+ this.note(
360
+ `image "${image}" is placed by a Mapping whose ${id} is driven by a node, which is ignored`,
361
+ );
362
+ return socketValue(input.socket)?.slice(0, 3) ?? fallback;
363
+ };
364
+ const mode = link.node.int('custom1');
365
+ let matrix: Mat4 = compose(
366
+ vector('Location', [0, 0, 0]),
367
+ eulerMatrix(vector('Rotation', [0, 0, 0]), 1),
368
+ vector('Scale', [1, 1, 1]),
369
+ );
370
+ if (mode === MAPPING_TEXTURE) matrix = invert(matrix);
371
+ else if (mode !== MAPPING_POINT) {
372
+ this.note(
373
+ `image "${image}" is placed by a Mapping of a kind that only turns directions, which is ignored`,
374
+ );
375
+ return inner;
376
+ }
377
+ if (inner.transform !== null) matrix = multiply(matrix, from3(inner.transform));
378
+ return {
379
+ map: inner.map,
380
+ transform: [
381
+ matrix[0] as number,
382
+ matrix[1] as number,
383
+ 0,
384
+ matrix[4] as number,
385
+ matrix[5] as number,
386
+ 0,
387
+ matrix[12] as number,
388
+ matrix[13] as number,
389
+ 1,
390
+ ],
391
+ };
392
+ }
393
+ this.note(`image "${image}" is placed by ${kind}, which is ignored`);
394
+ return { map: null, transform: null };
395
+ }
396
+ }
397
+
398
+ /** A 3x3 UV affine as the 4x4 it is a slice of. */
399
+ function from3(t: number[]): Mat4 {
400
+ return [
401
+ t[0] as number,
402
+ t[1] as number,
403
+ 0,
404
+ 0,
405
+ t[3] as number,
406
+ t[4] as number,
407
+ 0,
408
+ 0,
409
+ 0,
410
+ 0,
411
+ 1,
412
+ 0,
413
+ t[6] as number,
414
+ t[7] as number,
415
+ 0,
416
+ 1,
417
+ ];
418
+ }
419
+
420
+ /** Read one material's surface. */
421
+ export function readBlendSurface(material: BlendStruct, version: number): BlendSurface {
422
+ const name = material.idName();
423
+ const blendFlag = material.has('blend_flag') ? material.int('blend_flag') : 0;
424
+ const surface: Mutable<BlendSurface> = {
425
+ name,
426
+ baseColor: [0.8, 0.8, 0.8],
427
+ baseColorMap: null,
428
+ alpha: 1,
429
+ alphaMap: null,
430
+ alphaMode: 'OPAQUE',
431
+ alphaCutoff: 0.5,
432
+ vertexColor: null,
433
+ metallic: 0,
434
+ metallicMap: null,
435
+ roughness: 0.5,
436
+ roughnessMap: null,
437
+ occlusionMap: null,
438
+ normalMap: null,
439
+ normalStrength: 1,
440
+ emission: [0, 0, 0],
441
+ emissionStrength: 1,
442
+ emissionMap: null,
443
+ transmission: 0,
444
+ doubleSided: (blendFlag & MA_BL_CULL_BACKFACE) === 0,
445
+ uvMap: null,
446
+ uvTransform: null,
447
+ extras: customProperties(material),
448
+ approximations: [],
449
+ };
450
+
451
+ const tree = material.has('nodetree') ? material.deref('nodetree') : null;
452
+ const useNodes = !material.has('use_nodes') || material.int('use_nodes') !== 0 || version >= 500;
453
+ if (tree === null || !useNodes) {
454
+ surface.baseColor = [material.float('r'), material.float('g'), material.float('b')];
455
+ surface.alpha = material.has('a') ? material.float('a') : 1;
456
+ if (material.has('metallic')) surface.metallic = material.float('metallic');
457
+ if (material.has('roughness')) surface.roughness = material.float('roughness');
458
+ return surface;
459
+ }
460
+
461
+ const graph = new Graph(tree);
462
+ const reader = new Reader(graph, name);
463
+ const output = outputNode(graph);
464
+ const surfaceLink = output === null ? null : graph.input(output, 'Surface').link;
465
+ if (surfaceLink === null) {
466
+ reader.note("no surface shader is connected, so it takes glTF's default");
467
+ return { ...surface, approximations: reader.approximations };
468
+ }
469
+ const shader = surfaceLink.node;
470
+ const kind = idname(shader);
471
+ if ((shader.int('flag') & NODE_MUTED) !== 0) reader.note(`its ${kind} is muted`);
472
+
473
+ if (kind === 'ShaderNodeBsdfPrincipled' || kind === 'ShaderNodeBsdfDiffuse') {
474
+ const base = reader.colour(shader, ['Base Color', 'Color'], 'Base Color');
475
+ const factor =
476
+ base.factor ??
477
+ (base.map !== null || base.attribute !== null ? [1, 1, 1, 1] : [0.8, 0.8, 0.8, 1]);
478
+ surface.baseColor = [factor[0] ?? 1, factor[1] ?? 1, factor[2] ?? 1];
479
+ surface.baseColorMap = base.map;
480
+ surface.vertexColor = base.attribute;
481
+
482
+ if (kind === 'ShaderNodeBsdfPrincipled') {
483
+ const metallic = reader.scalar(shader, ['Metallic'], 'Metallic', 0);
484
+ surface.metallic = metallic.value;
485
+ surface.metallicMap = metallic.map;
486
+ }
487
+ const roughness = reader.scalar(shader, ['Roughness'], 'Roughness', 0.5);
488
+ surface.roughness = roughness.value;
489
+ surface.roughnessMap = roughness.map;
490
+
491
+ const normal = graph.input(shader, 'Normal').link;
492
+ if (normal !== null) {
493
+ if (idname(normal.node) === 'ShaderNodeNormalMap') {
494
+ const map = reader.colour(normal.node, ['Color'], 'Normal Map');
495
+ surface.normalMap = map.map;
496
+ surface.normalStrength = scalarOf(graph.input(normal.node, 'Strength'), 1);
497
+ if (normal.node.int('custom1') !== 0)
498
+ reader.note('its normal map is not in tangent space, which glTF requires');
499
+ } else
500
+ reader.note(
501
+ `its Normal input comes from ${idname(normal.node)}, which glTF has no slot for`,
502
+ );
503
+ }
504
+
505
+ const emission = reader.colour(shader, ['Emission Color', 'Emission'], 'Emission');
506
+ const strength = graph.input(shader, 'Emission Strength');
507
+ surface.emissionStrength = strength.socket === null ? 1 : scalarOf(strength, 1);
508
+ if (strength.link !== null)
509
+ reader.note('its Emission Strength is driven by a node, so its value is used');
510
+ surface.emission = toRgb(emission.factor ?? (emission.map !== null ? [1, 1, 1] : [0, 0, 0]));
511
+ surface.emissionMap = emission.map;
512
+
513
+ const transmission = graph.input(shader, 'Transmission Weight', 'Transmission');
514
+ if (transmission.link !== null)
515
+ reader.note('its Transmission is driven by a node, so it is treated as opaque');
516
+ else surface.transmission = scalarOf(transmission, 0);
517
+
518
+ alphaOf(shader, graph, reader, material, version, surface);
519
+ occlusionOf(graph, reader, output as BlendStruct, surface);
520
+ } else if (kind === 'ShaderNodeEmission') {
521
+ const emission = reader.colour(shader, ['Color'], 'Emission');
522
+ surface.baseColor = [0, 0, 0];
523
+ surface.emission = toRgb(emission.factor ?? [1, 1, 1]);
524
+ surface.emissionMap = emission.map;
525
+ surface.emissionStrength = scalarOf(graph.input(shader, 'Strength'), 1);
526
+ } else {
527
+ reader.note(`its surface is a ${kind}, which glTF cannot express, so it takes glTF's default`);
528
+ }
529
+ surface.uvMap = reader.uvMap;
530
+ surface.uvTransform = reader.uvTransform;
531
+ surface.approximations = reader.approximations;
532
+ return surface;
533
+ }
534
+
535
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
536
+
537
+ const toRgb = (v: number[]): [number, number, number] => [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0];
538
+
539
+ /** Occlusion from the "glTF Material Output" group Blender's importer adds, wherever it is wired. */
540
+ function occlusionOf(
541
+ graph: Graph,
542
+ reader: Reader,
543
+ output: BlendStruct,
544
+ surface: Mutable<BlendSurface>,
545
+ ): void {
546
+ void output;
547
+ for (const node of graph.nodes) {
548
+ if (idname(node) !== 'ShaderNodeGroup') continue;
549
+ const tree = node.deref('id');
550
+ const groupName = tree?.idName() ?? '';
551
+ if (!groupName.startsWith('glTF Material Output') && !groupName.startsWith('glTF Settings'))
552
+ continue;
553
+ const input = graph.input(node, 'Occlusion', 'Socket_0');
554
+ if (input.link !== null)
555
+ surface.occlusionMap = reader.traceScalar(input.link, 'Occlusion', 1).map;
556
+ }
557
+ }
558
+
559
+ function alphaOf(
560
+ shader: BlendStruct,
561
+ graph: Graph,
562
+ reader: Reader,
563
+ material: BlendStruct,
564
+ version: number,
565
+ surface: Mutable<BlendSurface>,
566
+ ): void {
567
+ const alpha = graph.input(shader, 'Alpha');
568
+ let cutoff: number | null = null;
569
+ let source = alpha.link;
570
+ if (source !== null && idname(source.node) === 'ShaderNodeMath') {
571
+ const clip = alphaClip(source.node, graph);
572
+ if (clip !== null) {
573
+ cutoff = clip.cutoff;
574
+ source = clip.source;
575
+ }
576
+ }
577
+ if (source === null) surface.alpha = alpha.link === null ? scalarOf(alpha, 1) : 1;
578
+ else {
579
+ const traced = reader.traceScalar(source, 'Alpha', 1);
580
+ surface.alpha = traced.value;
581
+ surface.alphaMap = traced.map;
582
+ }
583
+ if (version < 402) {
584
+ /* Before 4.2 the mode was the material's own setting, and that is what Blender converts. */
585
+ const method = material.has('blend_method') ? material.int('blend_method') : MA_BM_SOLID;
586
+ if (method === MA_BM_SOLID) {
587
+ surface.alpha = 1;
588
+ surface.alphaMap = null;
589
+ return;
590
+ }
591
+ if (method === MA_BM_CLIP)
592
+ cutoff = material.has('alpha_threshold') ? material.float('alpha_threshold') : 0.5;
593
+ }
594
+ const varies = surface.alphaMap !== null || surface.alpha < 1;
595
+ if (!varies && cutoff === null) return;
596
+ const dithered =
597
+ version >= 402 &&
598
+ material.has('surface_render_method') &&
599
+ material.int('surface_render_method') !== RENDER_BLENDED;
600
+ if (cutoff !== null) {
601
+ surface.alphaMode = 'MASK';
602
+ surface.alphaCutoff = cutoff;
603
+ } else if (dithered && surface.alphaMap !== null) {
604
+ /*
605
+ * **A dithered surface with an alpha map is a cut-out**, where Blender's glTF exporter writes
606
+ * it as blended. EEVEE draws dithered alpha as a screen door — each pixel drawn fully or not at
607
+ * all — so the surface is opaque wherever it shows, which an alpha test at the middle is, and a
608
+ * blend is not: a facade whose mask cuts its gaps would be sorted, drawn over and seen through.
609
+ * A deliberate difference from the exporter, for what the surface looks like in Blender.
610
+ */
611
+ surface.alphaMode = 'MASK';
612
+ surface.alphaCutoff = 0.5;
613
+ } else {
614
+ surface.alphaMode = 'BLEND';
615
+ }
616
+ }
617
+
618
+ /**
619
+ * An alpha clip built from Math nodes, in the four shapes Blender's exporter knows: `round(x)`,
620
+ * `x > c`, `c < x`, and `1 - (x < c)`. Its cutoff and the link to `x`, or null.
621
+ */
622
+ function alphaClip(
623
+ node: BlendStruct,
624
+ graph: Graph,
625
+ ): { cutoff: number; source: Link | null } | null {
626
+ const operation = node.int('custom1');
627
+ const a = graph.at(node, 0);
628
+ const b = graph.at(node, 1);
629
+ if (operation === MATH_ROUND) return { cutoff: 0.5, source: a.link };
630
+ if (operation === MATH_GREATER_THAN && b.link === null)
631
+ return { cutoff: scalarOf(b, 0.5), source: a.link };
632
+ if (operation === MATH_LESS_THAN && a.link === null)
633
+ return { cutoff: scalarOf(a, 0.5), source: b.link };
634
+ if (
635
+ operation === MATH_SUBTRACT &&
636
+ a.link === null &&
637
+ scalarOf(a, 0) === 1 &&
638
+ b.link !== null &&
639
+ idname(b.link.node) === 'ShaderNodeMath'
640
+ ) {
641
+ const inner = b.link.node;
642
+ const op = inner.int('custom1');
643
+ const x = graph.at(inner, 0);
644
+ const y = graph.at(inner, 1);
645
+ if (op === MATH_LESS_THAN && x.link !== null && y.link === null)
646
+ return { cutoff: scalarOf(y, 0.5), source: x.link };
647
+ if (op === MATH_GREATER_THAN && x.link === null && y.link !== null)
648
+ return { cutoff: scalarOf(x, 0.5), source: y.link };
649
+ }
650
+ return null;
651
+ }
652
+
653
+ /** An ID's custom properties that are plain values, for glTF's `extras`. */
654
+ export function customProperties(id: BlendStruct): Record<string, unknown> | null {
655
+ const header = id.sub('id');
656
+ if (!header.has('properties')) return null;
657
+ const group = header.deref('properties', 'IDProperty');
658
+ if (group === null) return null;
659
+ const out: Record<string, unknown> = {};
660
+ for (const property of group.sub('data').list('group', 'IDProperty')) {
661
+ const name = property.string('name');
662
+ const type = property.int('type');
663
+ const data = property.sub('data');
664
+ /* IDP_STRING 0, IDP_INT 1, IDP_FLOAT 2, IDP_DOUBLE 8, IDP_BOOLEAN 10. */
665
+ if (type === 0) out[name] = property.file.text(data.ptr('pointer'), property.scope);
666
+ else if (type === 1) out[name] = data.int('val');
667
+ else if (type === 10) out[name] = data.int('val') !== 0;
668
+ else if (type === 2)
669
+ out[name] = new DataView(new Int32Array([data.int('val')]).buffer).getFloat32(0, true);
670
+ else if (type === 8) {
671
+ const bits = new Int32Array([data.int('val'), data.int('val2')]);
672
+ out[name] = new DataView(bits.buffer).getFloat64(0, true);
673
+ }
674
+ }
675
+ return Object.keys(out).length > 0 ? out : null;
676
+ }