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