@driftengine/assets 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +9 -0
- package/dist/assetPath.d.ts +51 -0
- package/dist/assetPath.js +59 -0
- package/dist/coarseLevel.d.ts +94 -0
- package/dist/coarseLevel.js +606 -0
- package/dist/dds.d.ts +41 -0
- package/dist/dds.js +425 -0
- package/dist/drawKey.d.ts +64 -0
- package/dist/drawKey.js +42 -0
- package/dist/drftLoader.d.ts +420 -0
- package/dist/drftLoader.js +996 -0
- package/dist/drftTextures.d.ts +87 -0
- package/dist/drftTextures.js +138 -0
- package/dist/fbx.d.ts +86 -0
- package/dist/fbx.js +792 -0
- package/dist/fbxHarness.d.ts +68 -0
- package/dist/fbxHarness.js +185 -0
- package/dist/fbxInflate.d.ts +60 -0
- package/dist/fbxInflate.js +204 -0
- package/dist/fbxMaterial.d.ts +87 -0
- package/dist/fbxMaterial.js +345 -0
- package/dist/fbxRig.d.ts +130 -0
- package/dist/fbxRig.js +601 -0
- package/dist/gltf.d.ts +242 -0
- package/dist/gltf.js +904 -0
- package/dist/gltfAccessor.d.ts +2 -0
- package/dist/gltfAccessor.js +106 -0
- package/dist/gltfSkin.d.ts +32 -0
- package/dist/gltfSkin.js +144 -0
- package/dist/iesPhotometry.d.ts +44 -0
- package/dist/iesPhotometry.js +94 -0
- package/dist/imageInfo.d.ts +31 -0
- package/dist/imageInfo.js +119 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +132 -0
- package/dist/kn5.d.ts +158 -0
- package/dist/kn5.js +557 -0
- package/dist/loadProgress.d.ts +118 -0
- package/dist/loadProgress.js +1 -0
- package/dist/localise.d.ts +35 -0
- package/dist/localise.js +253 -0
- package/dist/obj.d.ts +54 -0
- package/dist/obj.js +335 -0
- package/dist/orient.d.ts +104 -0
- package/dist/orient.js +241 -0
- package/dist/radianceHdr.d.ts +36 -0
- package/dist/radianceHdr.js +172 -0
- package/dist/readModel.d.ts +153 -0
- package/dist/readModel.js +386 -0
- package/dist/recognise.d.ts +38 -0
- package/dist/recognise.js +97 -0
- package/dist/stl.d.ts +22 -0
- package/dist/stl.js +131 -0
- package/dist/tangentOrder.d.ts +24 -0
- package/dist/tangentOrder.js +31 -0
- package/dist/threemf.d.ts +46 -0
- package/dist/threemf.js +254 -0
- package/dist/uploadBudget.d.ts +82 -0
- package/dist/uploadBudget.js +86 -0
- package/dist/usd.d.ts +49 -0
- package/dist/usd.js +542 -0
- package/dist/weld.d.ts +44 -0
- package/dist/weld.js +237 -0
- package/dist/zip.d.ts +35 -0
- package/dist/zip.js +110 -0
- package/package.json +61 -0
- package/src/assetPath.ts +79 -0
- package/src/coarseLevel.ts +720 -0
- package/src/dds.ts +507 -0
- package/src/drawKey.ts +91 -0
- package/src/drftLoader.ts +1197 -0
- package/src/drftTextures.ts +153 -0
- package/src/fbx.ts +934 -0
- package/src/fbxHarness.ts +215 -0
- package/src/fbxInflate.ts +258 -0
- package/src/fbxMaterial.ts +410 -0
- package/src/fbxRig.ts +652 -0
- package/src/gltf.ts +1161 -0
- package/src/gltfAccessor.ts +127 -0
- package/src/gltfSkin.ts +210 -0
- package/src/iesPhotometry.ts +136 -0
- package/src/imageInfo.ts +130 -0
- package/src/index.ts +162 -0
- package/src/kn5.ts +705 -0
- package/src/loadProgress.ts +131 -0
- package/src/localise.ts +275 -0
- package/src/obj.ts +386 -0
- package/src/orient.ts +262 -0
- package/src/radianceHdr.ts +195 -0
- package/src/readModel.ts +515 -0
- package/src/recognise.ts +124 -0
- package/src/stl.ts +147 -0
- package/src/tangentOrder.ts +32 -0
- package/src/threemf.ts +303 -0
- package/src/uploadBudget.ts +90 -0
- package/src/usd.ts +642 -0
- package/src/weld.ts +264 -0
- package/src/zip.ts +130 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FBX materials and the textures they name. One responsibility: what a surface looks like.
|
|
3
|
+
*
|
|
4
|
+
* Split from `fbx.ts` because reading a record tree and interpreting a shading model are
|
|
5
|
+
* different jobs with different ways of being wrong. The container either parses or it does
|
|
6
|
+
* not; a material silently comes out the wrong colour.
|
|
7
|
+
*
|
|
8
|
+
* **This resolves nothing from disk.** A texture is reported as the string the file wrote,
|
|
9
|
+
* and turning that into bytes belongs to the caller, which is the only party that knows
|
|
10
|
+
* whether it is reading a folder, a zip or a URL. The same reasoning as the injected
|
|
11
|
+
* `Inflate` in `fbx.ts`, for the same reason: this module is part of an engine that runs in
|
|
12
|
+
* a browser and must not reach for a filesystem.
|
|
13
|
+
*
|
|
14
|
+
* **Why the declared path is the only one worth keeping.** These records carry an absolute
|
|
15
|
+
* `FileName` as well, and it is the author's own machine: the asset this was built against
|
|
16
|
+
* says `G:\SketchfabRipper_v1.18.0-b2\tools\blender-292\_sfTemp\textures\carbon.jpg`. That
|
|
17
|
+
* path is dead everywhere except one computer that no longer has the file either. The
|
|
18
|
+
* `RelativeFilename` beside it says `textures\carbon.jpg`, which is true relative to the
|
|
19
|
+
* model and is what every other reader uses.
|
|
20
|
+
*/
|
|
21
|
+
/** The engine's own defaults, for a property a material does not state. */
|
|
22
|
+
const DEFAULT_COLOR = [0.8, 0.8, 0.8];
|
|
23
|
+
const DEFAULT_ROUGHNESS = 0.4277;
|
|
24
|
+
/**
|
|
25
|
+
* What a surface with no material at all is shaded as.
|
|
26
|
+
*
|
|
27
|
+
* Exported so the geometry reader has no null to branch on. A geometry can genuinely reach
|
|
28
|
+
* the reader wearing nothing, and a fallback stated once here is better than the same three
|
|
29
|
+
* defaults spelled out at each use, which is how two of them end up disagreeing.
|
|
30
|
+
*/
|
|
31
|
+
export const FALLBACK_MATERIAL = {
|
|
32
|
+
name: '',
|
|
33
|
+
color: DEFAULT_COLOR,
|
|
34
|
+
specular: 0,
|
|
35
|
+
roughness: DEFAULT_ROUGHNESS,
|
|
36
|
+
emissive: 0,
|
|
37
|
+
opacity: 1,
|
|
38
|
+
reflectivity: 0,
|
|
39
|
+
normalMap: -1,
|
|
40
|
+
ormMap: -1,
|
|
41
|
+
emissiveMap: -1,
|
|
42
|
+
roughnessScale: 1,
|
|
43
|
+
metallicScale: 1,
|
|
44
|
+
occlusionStrength: 0,
|
|
45
|
+
cutout: 0,
|
|
46
|
+
emissiveColor: [-1, -1, -1],
|
|
47
|
+
albedo: -1,
|
|
48
|
+
};
|
|
49
|
+
/** A record's first property, when it is a string. */
|
|
50
|
+
function stringChild(node, name) {
|
|
51
|
+
const value = node.children.find((child) => child.name === name)?.properties[0];
|
|
52
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The bytes of an embedded image, if this video carries one.
|
|
56
|
+
*
|
|
57
|
+
* A `Content` record with nothing in it is common and means "this image lives outside the
|
|
58
|
+
* file" rather than "this image is empty", so a zero length payload has to read as absent.
|
|
59
|
+
* Treating it as present embeds a zero byte texture, which decodes to nothing and takes the
|
|
60
|
+
* place of the path that would have worked.
|
|
61
|
+
*/
|
|
62
|
+
function contentOf(video) {
|
|
63
|
+
const value = video.children.find((child) => child.name === 'Content')?.properties[0];
|
|
64
|
+
return value instanceof Uint8Array && value.length > 0 ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
function propertiesOf(node) {
|
|
67
|
+
const out = new Map();
|
|
68
|
+
for (const child of node.children) {
|
|
69
|
+
if (child.name !== 'Properties70')
|
|
70
|
+
continue;
|
|
71
|
+
for (const property of child.children) {
|
|
72
|
+
const name = property.properties[0];
|
|
73
|
+
if (typeof name === 'string')
|
|
74
|
+
out.set(name, property.properties.slice(4));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function numberAt(values, at, fallback) {
|
|
80
|
+
const value = values?.[at];
|
|
81
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
82
|
+
}
|
|
83
|
+
function triple(values, fallback, scale = 1) {
|
|
84
|
+
if (values === undefined)
|
|
85
|
+
return [fallback[0], fallback[1], fallback[2]];
|
|
86
|
+
return normaliseColor([
|
|
87
|
+
numberAt(values, 0, fallback[0]) * scale,
|
|
88
|
+
numberAt(values, 1, fallback[1]) * scale,
|
|
89
|
+
numberAt(values, 2, fallback[2]) * scale,
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A colour whose components exceed one is a fraction that lost its decimal point.
|
|
94
|
+
*
|
|
95
|
+
* Measured in the asset this was built against, which stores `silver` as
|
|
96
|
+
* `815686, 815686, 815686` and `copper` as `235294, 184314, 137255`. Those are not colours
|
|
97
|
+
* and they are not a scale factor either: they are `0.815686` and `0.235294, 0.184314,
|
|
98
|
+
* 0.137255` written as their fractional digits alone. Every one is six digits, which is what
|
|
99
|
+
* makes the repair well defined rather than a guess, and `calipers` proves the reading —
|
|
100
|
+
* it stores `8, 564706, 160784`, where the `8` is `000008` with its leading zeros lost the
|
|
101
|
+
* moment it was read as a number.
|
|
102
|
+
*
|
|
103
|
+
* **Each channel is divided by its own digit count**, because each is its own fraction. That
|
|
104
|
+
* is settled by the asset rather than argued: read this way the calipers come out
|
|
105
|
+
* `0.8, 0.564706, 0.160784`, a gold, and the vendor's own reference render shows gold brake
|
|
106
|
+
* calipers. A shared divisor taken from the largest channel was tried first, on the theory
|
|
107
|
+
* that the `8` was `000008` with its leading zeros lost, and it produces a green caliper.
|
|
108
|
+
* Looking at the reference is what decided it, which is the whole lesson of this pipeline.
|
|
109
|
+
*
|
|
110
|
+
* A component above one is never legitimate here: FBX's `DiffuseColor` is defined over nought
|
|
111
|
+
* to one, and this engine has no high dynamic range material input for it to mean instead. So
|
|
112
|
+
* the test cannot fire on a colour somebody meant.
|
|
113
|
+
*/
|
|
114
|
+
function normaliseColor(color) {
|
|
115
|
+
const digits = (value) => {
|
|
116
|
+
if (!(value > 1))
|
|
117
|
+
return value;
|
|
118
|
+
let divisor = 1;
|
|
119
|
+
while (value / divisor > 1)
|
|
120
|
+
divisor *= 10;
|
|
121
|
+
return value / divisor;
|
|
122
|
+
};
|
|
123
|
+
return [digits(color[0]), digits(color[1]), digits(color[2])];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A Phong exponent as a roughness.
|
|
127
|
+
*
|
|
128
|
+
* The two describe the same thing from opposite ends: the exponent says how *tightly* a
|
|
129
|
+
* highlight is gathered and rises without bound, roughness says how widely it is spread over
|
|
130
|
+
* nought to one. `sqrt(2 / (n + 2))` is the usual correspondence and it lands where intuition
|
|
131
|
+
* expects at both ends, with a mirror-like 1000 arriving at 0.04 and a flat 2 at 0.7.
|
|
132
|
+
*
|
|
133
|
+
* It is an approximation between two different shading models and is stated as one. What it
|
|
134
|
+
* must not do is default to zero: an unstated exponent read as a mirror makes every surface
|
|
135
|
+
* in an imported scene a black mirror, which reads as a lighting fault rather than a material
|
|
136
|
+
* one.
|
|
137
|
+
*/
|
|
138
|
+
/**
|
|
139
|
+
* How opaque a surface is, from whichever of the two spellings a writer used.
|
|
140
|
+
*
|
|
141
|
+
* They mean opposite things and that is the trap: `TransparencyFactor` is how *transparent*
|
|
142
|
+
* a surface is, so opacity is one minus it, while `Opacity` is already what it says. Reading
|
|
143
|
+
* one as the other turns every solid surface in an export invisible, and an invisible model
|
|
144
|
+
* reads as a failed load rather than as a material fault.
|
|
145
|
+
*
|
|
146
|
+
* Silence means opaque. Defaulting the other way would make every material in a file that
|
|
147
|
+
* omits both vanish, which is most files.
|
|
148
|
+
*/
|
|
149
|
+
function opacityOf(p) {
|
|
150
|
+
const clamp = (value) => Math.min(1, Math.max(0, value));
|
|
151
|
+
if (p.has('TransparencyFactor'))
|
|
152
|
+
return clamp(1 - numberAt(p.get('TransparencyFactor'), 0, 0));
|
|
153
|
+
if (p.has('Opacity'))
|
|
154
|
+
return clamp(numberAt(p.get('Opacity'), 0, 1));
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
function roughnessFromShininess(exponent) {
|
|
158
|
+
if (!(exponent > 0))
|
|
159
|
+
return DEFAULT_ROUGHNESS;
|
|
160
|
+
return Math.min(1, Math.max(0.02, Math.sqrt(2 / (exponent + 2))));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Materials, their textures, and which model wears which.
|
|
164
|
+
*
|
|
165
|
+
* Textures reach a material through an `OP` connection, which is FBX for "this object feeds
|
|
166
|
+
* that object's *named property*" — the property being `DiffuseColor`, `NormalMap`,
|
|
167
|
+
* `SpecularColor` and so on. Only the albedo is used, because it is the only one this
|
|
168
|
+
* renderer has a slot for; the rest are counted and reported rather than dropped in silence,
|
|
169
|
+
* so a model that looks flatter than it should says why.
|
|
170
|
+
*/
|
|
171
|
+
export function readMaterials(root, warnings) {
|
|
172
|
+
const materialNodes = new Map();
|
|
173
|
+
const textureNodes = new Map();
|
|
174
|
+
const videoNodes = new Map();
|
|
175
|
+
for (const objects of root.children.filter((child) => child.name === 'Objects')) {
|
|
176
|
+
for (const node of objects.children) {
|
|
177
|
+
const id = node.properties[0];
|
|
178
|
+
if (typeof id !== 'number')
|
|
179
|
+
continue;
|
|
180
|
+
if (node.name === 'Material')
|
|
181
|
+
materialNodes.set(id, node);
|
|
182
|
+
else if (node.name === 'Texture')
|
|
183
|
+
textureNodes.set(id, node);
|
|
184
|
+
else if (node.name === 'Video')
|
|
185
|
+
videoNodes.set(id, node);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/*
|
|
189
|
+
* Which video backs which texture, from the connection table.
|
|
190
|
+
*
|
|
191
|
+
* A `Media` property naming the video by string exists too and is what older writers use,
|
|
192
|
+
* so it is the fallback. The connection is preferred because a name is a name and an id is
|
|
193
|
+
* a fact: two videos may be called the same thing, and only one of them is connected.
|
|
194
|
+
*/
|
|
195
|
+
const videoOfTexture = new Map();
|
|
196
|
+
for (const connections of root.children.filter((child) => child.name === 'Connections')) {
|
|
197
|
+
for (const c of connections.children) {
|
|
198
|
+
const [kind, from, to] = c.properties;
|
|
199
|
+
if (kind !== 'OO' || typeof from !== 'number' || typeof to !== 'number')
|
|
200
|
+
continue;
|
|
201
|
+
const video = videoNodes.get(from);
|
|
202
|
+
if (video !== undefined && textureNodes.has(to))
|
|
203
|
+
videoOfTexture.set(to, video);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const [id, node] of textureNodes) {
|
|
207
|
+
if (videoOfTexture.has(id))
|
|
208
|
+
continue;
|
|
209
|
+
const media = stringChild(node, 'Media');
|
|
210
|
+
if (media === undefined)
|
|
211
|
+
continue;
|
|
212
|
+
for (const [, video] of videoNodes) {
|
|
213
|
+
if (String(video.properties[1] ?? '') === media) {
|
|
214
|
+
videoOfTexture.set(id, video);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/*
|
|
220
|
+
* Texture id to an index in the reference list, deduplicated by what it resolves to.
|
|
221
|
+
*
|
|
222
|
+
* **FBX embeds its images as often as it points at them**, and the two look nothing alike.
|
|
223
|
+
* An embedded texture's `RelativeFilename` is `*0`, a reference into the file's own video
|
|
224
|
+
* list rather than a path, and the bytes live in the matching `Video` record's `Content`.
|
|
225
|
+
* Treating that as a filename produces a hunt for a file called `*0`, which fails, and a
|
|
226
|
+
* model that draws untextured while the image it needs sits inside the file being read.
|
|
227
|
+
*/
|
|
228
|
+
const textures = [];
|
|
229
|
+
const candidates = new Map();
|
|
230
|
+
for (const [id, node] of textureNodes) {
|
|
231
|
+
const declared = stringChild(node, 'RelativeFilename') ?? stringChild(node, 'FileName') ?? '';
|
|
232
|
+
const video = videoOfTexture.get(id);
|
|
233
|
+
const content = video === undefined ? undefined : contentOf(video);
|
|
234
|
+
/* An embedded image names itself by its video's filename, because `*0` names nothing. */
|
|
235
|
+
const name = content !== undefined
|
|
236
|
+
? (stringChild(video, 'RelativeFilename') ??
|
|
237
|
+
stringChild(video, 'Filename') ??
|
|
238
|
+
declared)
|
|
239
|
+
: declared;
|
|
240
|
+
if (content === undefined && (name === '' || name.startsWith('*'))) {
|
|
241
|
+
warnings.push(name.startsWith('*')
|
|
242
|
+
? `texture "${name}" is embedded but its image is missing from the file; it will draw untextured`
|
|
243
|
+
: 'a texture record states no filename and was skipped');
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
/*
|
|
247
|
+
* Held as a candidate, not embedded yet.
|
|
248
|
+
*
|
|
249
|
+
* **A file names far more textures than this renderer can use.** One test character
|
|
250
|
+
* carries normal, roughness and metallic maps beside every colour map, and embedding all
|
|
251
|
+
* of them produced a 401 MB asset against the 94 MB the same model makes through OBJ —
|
|
252
|
+
* three quarters of it maps that were reported as ignored in the same run. Nothing was
|
|
253
|
+
* wrong with the ignoring; the list was simply built from every `Texture` record rather
|
|
254
|
+
* than from the ones a material actually reaches. So an entry earns its place below, when
|
|
255
|
+
* something binds it.
|
|
256
|
+
*/
|
|
257
|
+
candidates.set(id, {
|
|
258
|
+
reference: content === undefined ? { name } : { name, bytes: content },
|
|
259
|
+
key: content === undefined ? `path:${name}` : `embedded:${id}`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/* Which texture feeds which material, and through which of the material's properties. */
|
|
263
|
+
const albedoOfMaterial = new Map();
|
|
264
|
+
const ignoredSlots = new Map();
|
|
265
|
+
/** Allocate a slot for a texture the first time something actually binds it. */
|
|
266
|
+
const claim = new Map();
|
|
267
|
+
for (const connections of root.children.filter((child) => child.name === 'Connections')) {
|
|
268
|
+
for (const c of connections.children) {
|
|
269
|
+
const [kind, from, to, slot] = c.properties;
|
|
270
|
+
if (kind !== 'OP' || typeof from !== 'number' || typeof to !== 'number')
|
|
271
|
+
continue;
|
|
272
|
+
const candidate = candidates.get(from);
|
|
273
|
+
if (candidate === undefined || !materialNodes.has(to))
|
|
274
|
+
continue;
|
|
275
|
+
if (slot !== 'DiffuseColor') {
|
|
276
|
+
if (typeof slot === 'string')
|
|
277
|
+
ignoredSlots.set(slot, (ignoredSlots.get(slot) ?? 0) + 1);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
let at = claim.get(candidate.key);
|
|
281
|
+
if (at === undefined) {
|
|
282
|
+
at = textures.length;
|
|
283
|
+
claim.set(candidate.key, at);
|
|
284
|
+
textures.push(candidate.reference);
|
|
285
|
+
}
|
|
286
|
+
albedoOfMaterial.set(to, at);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const [slot, count] of ignoredSlots) {
|
|
290
|
+
warnings.push(`${count} ${slot} texture${count === 1 ? '' : 's'} ignored; this renderer has one texture slot, for colour`);
|
|
291
|
+
}
|
|
292
|
+
const byId = new Map();
|
|
293
|
+
for (const [id, node] of materialNodes) {
|
|
294
|
+
const p = propertiesOf(node);
|
|
295
|
+
const emissiveColor = triple(p.get('EmissiveColor'), [0, 0, 0], numberAt(p.get('EmissiveFactor'), 0, 1));
|
|
296
|
+
const emissive = Math.min(1, Math.max(emissiveColor[0], emissiveColor[1], emissiveColor[2]));
|
|
297
|
+
byId.set(id, {
|
|
298
|
+
name: typeof node.properties[1] === 'string'
|
|
299
|
+
? (node.properties[1].split('\u0000')[0] ?? '')
|
|
300
|
+
: '',
|
|
301
|
+
color: triple(p.get('DiffuseColor'), DEFAULT_COLOR, numberAt(p.get('DiffuseFactor'), 0, 1)),
|
|
302
|
+
specular: Math.min(1, Math.max(0, numberAt(p.get('SpecularFactor'), 0, 0))),
|
|
303
|
+
roughness: roughnessFromShininess(numberAt(p.get('ShininessExponent'), 0, numberAt(p.get('Shininess'), 0, 0))),
|
|
304
|
+
/*
|
|
305
|
+
* FBX states how transparent a surface is, so opacity is one minus it. Both
|
|
306
|
+
* spellings are read because writers disagree, and a file that says nothing is
|
|
307
|
+
* opaque rather than invisible: defaulting the other way would make every material
|
|
308
|
+
* in an export that omits it disappear.
|
|
309
|
+
*/
|
|
310
|
+
opacity: opacityOf(p),
|
|
311
|
+
/* FBX states this directly, and it is the property that decides whether paint reads as
|
|
312
|
+
paint. Read past until now, which is why an imported car looked flat. */
|
|
313
|
+
reflectivity: Math.min(1, Math.max(0, numberAt(p.get('ReflectionFactor'), 0, 0))),
|
|
314
|
+
normalMap: -1,
|
|
315
|
+
ormMap: -1,
|
|
316
|
+
emissiveMap: -1,
|
|
317
|
+
roughnessScale: 1,
|
|
318
|
+
metallicScale: 1,
|
|
319
|
+
occlusionStrength: 0,
|
|
320
|
+
cutout: 0,
|
|
321
|
+
emissive,
|
|
322
|
+
/* A negative component means "inherit the albedo", which is the engine's own default
|
|
323
|
+
and the right answer for a surface that names no emissive colour of its own. */
|
|
324
|
+
emissiveColor: emissive > 0 ? emissiveColor : [-1, -1, -1],
|
|
325
|
+
albedo: albedoOfMaterial.get(id) ?? -1,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/* Model to its materials, in file order, because that order is what an index means. */
|
|
329
|
+
const ofModel = new Map();
|
|
330
|
+
for (const connections of root.children.filter((child) => child.name === 'Connections')) {
|
|
331
|
+
for (const c of connections.children) {
|
|
332
|
+
const [kind, from, to] = c.properties;
|
|
333
|
+
if (kind !== 'OO' || typeof from !== 'number' || typeof to !== 'number')
|
|
334
|
+
continue;
|
|
335
|
+
if (!byId.has(from))
|
|
336
|
+
continue;
|
|
337
|
+
const list = ofModel.get(to);
|
|
338
|
+
if (list === undefined)
|
|
339
|
+
ofModel.set(to, [from]);
|
|
340
|
+
else
|
|
341
|
+
list.push(from);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { byId, ofModel, textures };
|
|
345
|
+
}
|
package/dist/fbxRig.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rig inside an FBX: a skeleton, what it is bound to, and the takes that move it.
|
|
3
|
+
*
|
|
4
|
+
* **Split from `fbx.ts` on the terms `fbxMaterial.ts` already set.** That file reads geometry and
|
|
5
|
+
* knows nothing about deformers; this one reads deformers and knows nothing about polygons. They
|
|
6
|
+
* meet over two small things — a control point's influences, and which model each joint is — and
|
|
7
|
+
* keeping them apart is what stops the mesh loop growing a second subject.
|
|
8
|
+
*
|
|
9
|
+
* **What FBX calls a skin is three record kinds and four connections.** A `Deformer` of subtype
|
|
10
|
+
* `Skin` hangs off a `Geometry`; a `Deformer` of subtype `Cluster` hangs off that skin, one per
|
|
11
|
+
* bone; and a `Model` of subtype `LimbNode` hangs off each cluster. The weights live on the
|
|
12
|
+
* cluster, keyed by control point, which is why they are read here and applied where the corners
|
|
13
|
+
* are walked.
|
|
14
|
+
*
|
|
15
|
+
* **The bind pose is two matrices and not one.** A cluster carries `TransformLink`, where the bone
|
|
16
|
+
* was when the mesh was bound, and `Transform`, where the *mesh* was. The engine's `inverseBind`
|
|
17
|
+
* takes a vertex from model space into the joint's bind space, so it is the inverse of the first
|
|
18
|
+
* times the second. Taking `TransformLink` alone is right exactly while the mesh sits at the
|
|
19
|
+
* origin with no rotation, which is what a hand-made test file does and what a bought character
|
|
20
|
+
* does not — the failure is a figure that is correctly posed and standing in the wrong place.
|
|
21
|
+
*
|
|
22
|
+
* **This is tier 2 and says so.** What it does not read: `PostRotation`, rotation orders other than
|
|
23
|
+
* the default, blend-shape channels driven by curves, and the `Take` records of pre-7000 files.
|
|
24
|
+
* Each is named where it is refused rather than silently approximated.
|
|
25
|
+
*/
|
|
26
|
+
import type { AnimationClip, Joint } from '@driftengine/drft';
|
|
27
|
+
import type { FbxNode } from './fbx.ts';
|
|
28
|
+
/**
|
|
29
|
+
* An object's own name, with the class the container tags it with taken off.
|
|
30
|
+
*
|
|
31
|
+
* **The two forms are not the same string, and only one of them looks like a separator.** ASCII FBX
|
|
32
|
+
* writes `Model::spine_01`, which is what a reader tends to be written against because it is what
|
|
33
|
+
* the specification's examples show. Binary FBX — which is the only form this reader accepts —
|
|
34
|
+
* writes `spine_01\0\x01Model`: the name first, then a null and a `\x01`, then the class. Knowing
|
|
35
|
+
* only the ASCII form left every joint of a bought character named
|
|
36
|
+
* `rp_nathan_animated_003_walking_spine_01\0\x01Model` and its take named `Take 001\0\x01AnimStack`.
|
|
37
|
+
*
|
|
38
|
+
* Nothing fails on a name, which is why it survived: `Joint.name` is what **retargeting matches
|
|
39
|
+
* on**, so a rig whose every joint carries a class suffix matches nothing, one subsystem away from
|
|
40
|
+
* the reader that wrote it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function bareName(raw: unknown, fallback: string): string;
|
|
43
|
+
/** Every `Objects` child, by id, with the class the record's third property names. */
|
|
44
|
+
export interface FbxObjects {
|
|
45
|
+
readonly byId: Map<number, FbxNode>;
|
|
46
|
+
/** child id to every parent it names, in file order. A bone names its cluster *and* its parent. */
|
|
47
|
+
readonly parentsOf: Map<number, number[]>;
|
|
48
|
+
/** `OP` connections: child id to the [parent, property] pairs it drives. */
|
|
49
|
+
readonly drives: Map<number, {
|
|
50
|
+
readonly parent: number;
|
|
51
|
+
readonly property: string;
|
|
52
|
+
}[]>;
|
|
53
|
+
}
|
|
54
|
+
export declare function indexObjects(root: FbxNode): FbxObjects;
|
|
55
|
+
/** A skin, as this reader hands it on: the skeleton, and the influences keyed by control point. */
|
|
56
|
+
export interface FbxSkin {
|
|
57
|
+
readonly joints: readonly Joint[];
|
|
58
|
+
readonly inverseBind: Float32Array;
|
|
59
|
+
/**
|
|
60
|
+
* Control point index to its influences, as [joint, weight] pairs.
|
|
61
|
+
*
|
|
62
|
+
* A map rather than a dense array because a cluster covers the control points it covers: a
|
|
63
|
+
* character's rig leaves nothing unweighted, and a prop bound to one bone leaves almost
|
|
64
|
+
* everything out of every cluster but one.
|
|
65
|
+
*/
|
|
66
|
+
readonly influences: Map<number, {
|
|
67
|
+
joint: number;
|
|
68
|
+
weight: number;
|
|
69
|
+
}[]>;
|
|
70
|
+
/** Model id of each joint, in the sorted order, so a curve can find the track it belongs to. */
|
|
71
|
+
readonly modelOfJoint: readonly number[];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The skin bound to one geometry, or null where nothing is.
|
|
75
|
+
*
|
|
76
|
+
* **The joints are sorted parents-first here**, because this is also the layer that can remap
|
|
77
|
+
* every index naming one — the mesh's own joint attribute is written from `influences` afterwards.
|
|
78
|
+
* `Skeleton` refuses an unsorted rig rather than sorting it, for exactly that reason.
|
|
79
|
+
*/
|
|
80
|
+
export declare function readSkin(objects: FbxObjects, geometryId: number,
|
|
81
|
+
/**
|
|
82
|
+
* Where the mesh this skin deforms sits, from the node graph.
|
|
83
|
+
*
|
|
84
|
+
* **This is the half a cluster's own `Transform` claims to be and cannot be trusted for.** The
|
|
85
|
+
* SDK documents `Transform` as the mesh's global transform at bind time, which makes it one
|
|
86
|
+
* matrix for the whole skin — and a bought character measured here has **sixty-eight of its
|
|
87
|
+
* sixty-nine weight-bearing clusters disagreeing about it**, each holding something close to the
|
|
88
|
+
* inverse of its own `TransformLink` instead. Composed as documented, the bone's frame goes in
|
|
89
|
+
* twice: the inverse bind for that character's hip came back as a pure translation of
|
|
90
|
+
* `(0, -94.4, +94.4)` where the joint is at `(0, 94.4, 0)`, so inverting it put every joint on a
|
|
91
|
+
* mirror of the skeleton that no geometry occupies.
|
|
92
|
+
*
|
|
93
|
+
* So the mesh's bind transform is taken from the one authority this reader already has for where
|
|
94
|
+
* anything is, which is the same walk every static mesh goes through. It is also what the other
|
|
95
|
+
* readers of this format settle on, and for the same reason.
|
|
96
|
+
*/
|
|
97
|
+
meshWorld: readonly number[], warnings: string[]): FbxSkin | null;
|
|
98
|
+
/**
|
|
99
|
+
* One control point's four influences, largest first, normalised, written into a caller's arrays.
|
|
100
|
+
*
|
|
101
|
+
* **Four is the shader's number and the file's is unbounded.** A rig authored with six influences
|
|
102
|
+
* on a shoulder is ordinary, so the four largest are kept and renormalised rather than the first
|
|
103
|
+
* four taken: dropping by *order* would discard whichever the exporter happened to write last,
|
|
104
|
+
* which on a shoulder is as likely to be the dominant one as not.
|
|
105
|
+
*
|
|
106
|
+
* Returns true where something was dropped or rescaled, so the caller can say so once for the mesh
|
|
107
|
+
* rather than once per vertex.
|
|
108
|
+
*/
|
|
109
|
+
export declare function writeInfluences(influences: readonly {
|
|
110
|
+
joint: number;
|
|
111
|
+
weight: number;
|
|
112
|
+
}[] | undefined, joints: Float32Array, weights: Float32Array, at: number): {
|
|
113
|
+
dropped: boolean;
|
|
114
|
+
rescaled: boolean;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Every take in the file, as clips over the joints a skin named.
|
|
118
|
+
*
|
|
119
|
+
* **A curve is per channel and a track is per joint**, so the three curves under one curve node are
|
|
120
|
+
* merged onto one timeline: the union of their key times, each channel read at that time. A key a
|
|
121
|
+
* channel does not have is held at its neighbour rather than interpolated, which is what a stepped
|
|
122
|
+
* read of a curve gives and is exact wherever the exporter wrote all three channels together —
|
|
123
|
+
* which is what every exporter does for a joint it is baking.
|
|
124
|
+
*
|
|
125
|
+
* **Rotation arrives as Euler degrees and leaves as a quaternion**, in the file's default XYZ
|
|
126
|
+
* order. A joint carrying a `PreRotation` or a non-default `RotationOrder` is named in a warning
|
|
127
|
+
* rather than approximated: both are silent when read wrongly, and a limb that is ninety degrees
|
|
128
|
+
* out reads as a broken clip rather than as an unread field.
|
|
129
|
+
*/
|
|
130
|
+
export declare function readClips(objects: FbxObjects, jointOfModel: ReadonlyMap<number, number>, warnings: string[]): AnimationClip[];
|