@babylonjs/loaders 9.11.0 → 9.12.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/FBX/fbxFileLoader.d.ts +194 -0
- package/FBX/fbxFileLoader.js +2440 -0
- package/FBX/fbxFileLoader.js.map +1 -0
- package/FBX/fbxFileLoader.metadata.d.ts +11 -0
- package/FBX/fbxFileLoader.metadata.js +11 -0
- package/FBX/fbxFileLoader.metadata.js.map +1 -0
- package/FBX/index.d.ts +3 -0
- package/FBX/index.js +3 -0
- package/FBX/index.js.map +1 -0
- package/FBX/interpreter/animation.d.ts +122 -0
- package/FBX/interpreter/animation.js +648 -0
- package/FBX/interpreter/animation.js.map +1 -0
- package/FBX/interpreter/blendShapes.d.ts +44 -0
- package/FBX/interpreter/blendShapes.js +192 -0
- package/FBX/interpreter/blendShapes.js.map +1 -0
- package/FBX/interpreter/connections.d.ts +95 -0
- package/FBX/interpreter/connections.js +233 -0
- package/FBX/interpreter/connections.js.map +1 -0
- package/FBX/interpreter/fbxInterpreter.d.ts +149 -0
- package/FBX/interpreter/fbxInterpreter.js +496 -0
- package/FBX/interpreter/fbxInterpreter.js.map +1 -0
- package/FBX/interpreter/geometry.d.ts +55 -0
- package/FBX/interpreter/geometry.js +573 -0
- package/FBX/interpreter/geometry.js.map +1 -0
- package/FBX/interpreter/materials.d.ts +50 -0
- package/FBX/interpreter/materials.js +144 -0
- package/FBX/interpreter/materials.js.map +1 -0
- package/FBX/interpreter/propertyTemplates.d.ts +22 -0
- package/FBX/interpreter/propertyTemplates.js +125 -0
- package/FBX/interpreter/propertyTemplates.js.map +1 -0
- package/FBX/interpreter/rig.d.ts +20 -0
- package/FBX/interpreter/rig.js +259 -0
- package/FBX/interpreter/rig.js.map +1 -0
- package/FBX/interpreter/sceneDiagnostics.d.ts +14 -0
- package/FBX/interpreter/sceneDiagnostics.js +55 -0
- package/FBX/interpreter/sceneDiagnostics.js.map +1 -0
- package/FBX/interpreter/skeleton.d.ts +93 -0
- package/FBX/interpreter/skeleton.js +515 -0
- package/FBX/interpreter/skeleton.js.map +1 -0
- package/FBX/interpreter/transform.d.ts +21 -0
- package/FBX/interpreter/transform.js +92 -0
- package/FBX/interpreter/transform.js.map +1 -0
- package/FBX/parsers/fbxAsciiParser.d.ts +5 -0
- package/FBX/parsers/fbxAsciiParser.js +330 -0
- package/FBX/parsers/fbxAsciiParser.js.map +1 -0
- package/FBX/parsers/fbxBinaryParser.d.ts +6 -0
- package/FBX/parsers/fbxBinaryParser.js +255 -0
- package/FBX/parsers/fbxBinaryParser.js.map +1 -0
- package/FBX/parsers/zlibInflate.d.ts +7 -0
- package/FBX/parsers/zlibInflate.js +350 -0
- package/FBX/parsers/zlibInflate.js.map +1 -0
- package/FBX/types/fbxTypes.d.ts +54 -0
- package/FBX/types/fbxTypes.js +66 -0
- package/FBX/types/fbxTypes.js.map +1 -0
- package/SPLAT/gaussianSplattingStream.d.ts +341 -0
- package/SPLAT/gaussianSplattingStream.js +976 -0
- package/SPLAT/gaussianSplattingStream.js.map +1 -0
- package/SPLAT/gaussianSplattingWorkBuffer.d.ts +51 -0
- package/SPLAT/gaussianSplattingWorkBuffer.js +159 -0
- package/SPLAT/gaussianSplattingWorkBuffer.js.map +1 -0
- package/SPLAT/gaussianSplattingWorkBufferShaders.d.ts +25 -0
- package/SPLAT/gaussianSplattingWorkBufferShaders.js +255 -0
- package/SPLAT/gaussianSplattingWorkBufferShaders.js.map +1 -0
- package/SPLAT/index.d.ts +1 -0
- package/SPLAT/index.js +1 -0
- package/SPLAT/index.js.map +1 -1
- package/SPLAT/sog.js +18 -16
- package/SPLAT/sog.js.map +1 -1
- package/SPLAT/splatFileLoader.d.ts +8 -0
- package/SPLAT/splatFileLoader.js +49 -0
- package/SPLAT/splatFileLoader.js.map +1 -1
- package/dynamic.js +9 -0
- package/dynamic.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,2440 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import { RegisterSceneLoaderPlugin, } from "@babylonjs/core/Loading/sceneLoader.js";
|
|
3
|
+
import { Mesh } from "@babylonjs/core/Meshes/mesh.js";
|
|
4
|
+
import { SubMesh } from "@babylonjs/core/Meshes/subMesh.js";
|
|
5
|
+
import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData.js";
|
|
6
|
+
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js";
|
|
7
|
+
import { Material } from "@babylonjs/core/Materials/material.js";
|
|
8
|
+
import { MultiMaterial } from "@babylonjs/core/Materials/multiMaterial.js";
|
|
9
|
+
import { Texture } from "@babylonjs/core/Materials/Textures/texture.js";
|
|
10
|
+
import { Color3 } from "@babylonjs/core/Maths/math.color.js";
|
|
11
|
+
import { Vector3, Quaternion, Matrix } from "@babylonjs/core/Maths/math.vector.js";
|
|
12
|
+
import { TransformNode } from "@babylonjs/core/Meshes/transformNode.js";
|
|
13
|
+
import { Skeleton } from "@babylonjs/core/Bones/skeleton.js";
|
|
14
|
+
import { Bone } from "@babylonjs/core/Bones/bone.js";
|
|
15
|
+
import { Animation } from "@babylonjs/core/Animations/animation.js";
|
|
16
|
+
import { AnimationGroup } from "@babylonjs/core/Animations/animationGroup.js";
|
|
17
|
+
import { MorphTarget } from "@babylonjs/core/Morph/morphTarget.js";
|
|
18
|
+
import { MorphTargetManager } from "@babylonjs/core/Morph/morphTargetManager.js";
|
|
19
|
+
import { Camera } from "@babylonjs/core/Cameras/camera.js";
|
|
20
|
+
import { FreeCamera } from "@babylonjs/core/Cameras/freeCamera.js";
|
|
21
|
+
import { PointLight } from "@babylonjs/core/Lights/pointLight.js";
|
|
22
|
+
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight.js";
|
|
23
|
+
import { SpotLight } from "@babylonjs/core/Lights/spotLight.js";
|
|
24
|
+
import { AssetContainer } from "@babylonjs/core/assetContainer.js";
|
|
25
|
+
import { GetMimeType } from "@babylonjs/core/Misc/fileTools.js";
|
|
26
|
+
import { parseBinaryFBX } from "./parsers/fbxBinaryParser.js";
|
|
27
|
+
import { parseAsciiFBX } from "./parsers/fbxAsciiParser.js";
|
|
28
|
+
import { interpretFBX } from "./interpreter/fbxInterpreter.js";
|
|
29
|
+
import { sampleFBXCurveAtTime } from "./interpreter/animation.js";
|
|
30
|
+
import { computeFBXGeometricDeltaMatrix, computeFBXGeometricMatrix, computeFBXGeometricNormalMatrix, computeFBXLocalMatrix } from "./interpreter/transform.js";
|
|
31
|
+
import { FBXFileLoaderMetadata } from "./fbxFileLoader.metadata.js";
|
|
32
|
+
const FBX_ASCII_MAGIC = "; FBX";
|
|
33
|
+
const FBX_BINARY_MAGIC = "Kaydara FBX Binary";
|
|
34
|
+
const BIND_REST_SCALE_RATIO_THRESHOLD = 10;
|
|
35
|
+
/**
|
|
36
|
+
* FBX file loader plugin for Babylon.js.
|
|
37
|
+
* Pure TypeScript implementation — no Autodesk FBX SDK dependency.
|
|
38
|
+
*/
|
|
39
|
+
export class FBXFileLoader {
|
|
40
|
+
/**
|
|
41
|
+
* Creates a new FBX loader.
|
|
42
|
+
* @param options - Options controlling FBX loading behavior
|
|
43
|
+
*/
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
/**
|
|
46
|
+
* Defines the name of the plugin.
|
|
47
|
+
*/
|
|
48
|
+
this.name = FBXFileLoaderMetadata.name;
|
|
49
|
+
/**
|
|
50
|
+
* Defines the extension the plugin is able to load.
|
|
51
|
+
*/
|
|
52
|
+
this.extensions = FBXFileLoaderMetadata.extensions;
|
|
53
|
+
this._bindRestBones = new WeakSet();
|
|
54
|
+
this._sourceBonesBySkeleton = new WeakMap();
|
|
55
|
+
this._scaleCompensationHelpersBySkeleton = new WeakMap();
|
|
56
|
+
this._options = {
|
|
57
|
+
normalMapCoordinateSystem: options.normalMapCoordinateSystem ?? "y-up",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Creates an FBX loader plugin instance with options from SceneLoader.
|
|
62
|
+
* @param options - Scene loader plugin options
|
|
63
|
+
* @returns The configured FBX loader
|
|
64
|
+
*/
|
|
65
|
+
createPlugin(options) {
|
|
66
|
+
return new FBXFileLoader(options[FBXFileLoaderMetadata.name]);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Imports meshes from an FBX file and adds them to the scene.
|
|
70
|
+
* @param meshesNames - A string or array of mesh names to import, or null/undefined to import all meshes
|
|
71
|
+
* @param scene - The scene to add imported meshes to
|
|
72
|
+
* @param data - The FBX data to load
|
|
73
|
+
* @param rootUrl - Root URL used to resolve external resources
|
|
74
|
+
* @param _onProgress - Callback called while the file is loading
|
|
75
|
+
* @param _fileName - Name of the file being loaded
|
|
76
|
+
* @returns A promise containing the loaded meshes, particle systems, skeletons, animation groups, transform nodes, geometries, and lights
|
|
77
|
+
*/
|
|
78
|
+
async importMeshAsync(meshesNames, scene, data, rootUrl, _onProgress, _fileName) {
|
|
79
|
+
const doc = this._parse(data);
|
|
80
|
+
const fbxScene = interpretFBX(doc);
|
|
81
|
+
return this._buildScene(fbxScene, scene, rootUrl, meshesNames);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Loads all FBX content into the scene.
|
|
85
|
+
* @param scene - The scene to load the FBX content into
|
|
86
|
+
* @param data - The FBX data to load
|
|
87
|
+
* @param rootUrl - Root URL used to resolve external resources
|
|
88
|
+
* @param _onProgress - Callback called while the file is loading
|
|
89
|
+
* @param _fileName - Name of the file being loaded
|
|
90
|
+
* @returns A promise that resolves when loading is complete
|
|
91
|
+
*/
|
|
92
|
+
async loadAsync(scene, data, rootUrl, _onProgress, _fileName) {
|
|
93
|
+
const doc = this._parse(data);
|
|
94
|
+
const fbxScene = interpretFBX(doc);
|
|
95
|
+
this._buildScene(fbxScene, scene, rootUrl, null);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Loads all FBX content into an asset container.
|
|
99
|
+
* @param scene - The scene used to create the asset container
|
|
100
|
+
* @param data - The FBX data to load
|
|
101
|
+
* @param rootUrl - Root URL used to resolve external resources
|
|
102
|
+
* @param _onProgress - Callback called while the file is loading
|
|
103
|
+
* @param _fileName - Name of the file being loaded
|
|
104
|
+
* @returns A promise containing the loaded asset container
|
|
105
|
+
*/
|
|
106
|
+
async loadAssetContainerAsync(scene, data, rootUrl, _onProgress, _fileName) {
|
|
107
|
+
const doc = this._parse(data);
|
|
108
|
+
const fbxScene = interpretFBX(doc);
|
|
109
|
+
const container = new AssetContainer(scene);
|
|
110
|
+
// Build the scene into a temporary holder, then move results to container
|
|
111
|
+
const result = this._buildScene(fbxScene, scene, rootUrl, null);
|
|
112
|
+
for (const mesh of result.meshes) {
|
|
113
|
+
container.meshes.push(mesh);
|
|
114
|
+
}
|
|
115
|
+
for (const skeleton of result.skeletons) {
|
|
116
|
+
container.skeletons.push(skeleton);
|
|
117
|
+
}
|
|
118
|
+
for (const ag of result.animationGroups) {
|
|
119
|
+
container.animationGroups.push(ag);
|
|
120
|
+
}
|
|
121
|
+
for (const tn of result.transformNodes) {
|
|
122
|
+
container.transformNodes.push(tn);
|
|
123
|
+
}
|
|
124
|
+
for (const light of result.lights) {
|
|
125
|
+
container.lights.push(light);
|
|
126
|
+
}
|
|
127
|
+
for (const camera of result.cameras) {
|
|
128
|
+
container.cameras.push(camera);
|
|
129
|
+
}
|
|
130
|
+
for (const material of result.materials) {
|
|
131
|
+
this._addMaterialToContainer(material, container);
|
|
132
|
+
}
|
|
133
|
+
for (const texture of result.textures) {
|
|
134
|
+
this._addTextureToContainer(texture, container);
|
|
135
|
+
}
|
|
136
|
+
for (const mesh of result.meshes) {
|
|
137
|
+
this._addMaterialToContainer(mesh.material, container);
|
|
138
|
+
}
|
|
139
|
+
// Remove all added objects from the scene (container owns them)
|
|
140
|
+
this._setAssetContainer(container);
|
|
141
|
+
container.removeAllFromScene();
|
|
142
|
+
return container;
|
|
143
|
+
}
|
|
144
|
+
// ── Parsing ────────────────────────────────────────────────────────────
|
|
145
|
+
_parse(data) {
|
|
146
|
+
if (data instanceof ArrayBuffer) {
|
|
147
|
+
return this._parseFromArrayBuffer(data);
|
|
148
|
+
}
|
|
149
|
+
if (ArrayBuffer.isView(data)) {
|
|
150
|
+
const view = data;
|
|
151
|
+
const buffer = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
|
|
152
|
+
return this._parseFromArrayBuffer(buffer);
|
|
153
|
+
}
|
|
154
|
+
if (typeof data === "string") {
|
|
155
|
+
return parseAsciiFBX(data);
|
|
156
|
+
}
|
|
157
|
+
throw new Error("FBXFileLoader: unsupported data type");
|
|
158
|
+
}
|
|
159
|
+
_parseFromArrayBuffer(buffer) {
|
|
160
|
+
// Check magic bytes to determine binary vs ASCII
|
|
161
|
+
const headerBytes = new Uint8Array(buffer, 0, Math.min(21, buffer.byteLength));
|
|
162
|
+
const header = String.fromCharCode(...headerBytes);
|
|
163
|
+
if (header.startsWith(FBX_BINARY_MAGIC)) {
|
|
164
|
+
return parseBinaryFBX(buffer);
|
|
165
|
+
}
|
|
166
|
+
// Try ASCII
|
|
167
|
+
const text = new TextDecoder("utf-8").decode(buffer);
|
|
168
|
+
if (text.trimStart().startsWith(FBX_ASCII_MAGIC)) {
|
|
169
|
+
return parseAsciiFBX(text);
|
|
170
|
+
}
|
|
171
|
+
throw new Error("FBXFileLoader: unrecognized FBX format");
|
|
172
|
+
}
|
|
173
|
+
// ── Scene Building ─────────────────────────────────────────────────────
|
|
174
|
+
_buildScene(fbxScene, scene, rootUrl, meshesNames) {
|
|
175
|
+
const nameFilter = this._buildNameFilter(meshesNames);
|
|
176
|
+
// Create materials
|
|
177
|
+
const materialCache = new Map();
|
|
178
|
+
for (const matData of fbxScene.materials) {
|
|
179
|
+
const material = this._createMaterial(matData, scene, rootUrl);
|
|
180
|
+
materialCache.set(matData.id, material);
|
|
181
|
+
}
|
|
182
|
+
// Create one Babylon skeleton per resolved deformation rig.
|
|
183
|
+
const skeletons = [];
|
|
184
|
+
const skeletonByRigId = new Map();
|
|
185
|
+
const skeletonByGeometryId = new Map();
|
|
186
|
+
const skinByGeometryId = new Map();
|
|
187
|
+
const skinBindingByGeometryId = new Map();
|
|
188
|
+
const skinById = new Map();
|
|
189
|
+
for (const skin of fbxScene.skins) {
|
|
190
|
+
skinById.set(skin.id, skin);
|
|
191
|
+
}
|
|
192
|
+
for (const rig of fbxScene.rigs) {
|
|
193
|
+
const skeleton = this._createSkeleton(rig.id, rig.bones, scene);
|
|
194
|
+
skeletons.push(skeleton);
|
|
195
|
+
skeletonByRigId.set(rig.id, skeleton);
|
|
196
|
+
for (const binding of rig.skinBindings) {
|
|
197
|
+
const skin = skinById.get(binding.skinId);
|
|
198
|
+
if (!skin) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
skeletonByGeometryId.set(binding.geometryId, skeleton);
|
|
202
|
+
skinByGeometryId.set(binding.geometryId, skin);
|
|
203
|
+
skinBindingByGeometryId.set(binding.geometryId, binding);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Collect model data for animation sampling.
|
|
207
|
+
const modelIdToData = new Map();
|
|
208
|
+
const collectModelData = (models) => {
|
|
209
|
+
for (const m of models) {
|
|
210
|
+
modelIdToData.set(m.id, m);
|
|
211
|
+
collectModelData(m.children);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
collectModelData(fbxScene.rootModels);
|
|
215
|
+
const cullingConflictMaterialIds = FBXFileLoader._collectCullingConflictMaterialIds(fbxScene.rootModels);
|
|
216
|
+
const cullingMaterialCloneCache = new Map();
|
|
217
|
+
// Build the FBX hierarchy under the same handedness conversion root that
|
|
218
|
+
// Babylon's glTF loader uses when loading right-handed assets into a
|
|
219
|
+
// left-handed scene. If the FBX file declares a non-Y-up scene basis,
|
|
220
|
+
// add a child axis-conversion root so model/bind math stays in FBX space.
|
|
221
|
+
const rootNode = new TransformNode("__fbx_root__", scene);
|
|
222
|
+
if (!scene.useRightHandedSystem) {
|
|
223
|
+
rootNode.rotation.y = Math.PI;
|
|
224
|
+
rootNode.scaling.z = -1;
|
|
225
|
+
}
|
|
226
|
+
const meshes = [];
|
|
227
|
+
const transformNodes = [rootNode];
|
|
228
|
+
let assetRoot = rootNode;
|
|
229
|
+
const axisConversion = FBXFileLoader._computeFBXAxisConversionMatrix(fbxScene);
|
|
230
|
+
if (!axisConversion.equals(Matrix.Identity())) {
|
|
231
|
+
assetRoot = new TransformNode("__fbx_axis_conversion__", scene);
|
|
232
|
+
assetRoot.parent = rootNode;
|
|
233
|
+
FBXFileLoader._applyMatrixToTransform(assetRoot, axisConversion);
|
|
234
|
+
transformNodes.push(assetRoot);
|
|
235
|
+
}
|
|
236
|
+
const modelIdToNode = new Map();
|
|
237
|
+
const fbxWorldIdentity = Matrix.Identity();
|
|
238
|
+
for (const model of fbxScene.rootModels) {
|
|
239
|
+
this._buildModel(model, scene, assetRoot, assetRoot, fbxWorldIdentity, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
|
|
240
|
+
}
|
|
241
|
+
// Link non-skinned child meshes/nodes to their parent bones so they
|
|
242
|
+
// follow skeletal animation. Preserve their current world matrix when
|
|
243
|
+
// switching from the FBX model hierarchy to Babylon's bone parent.
|
|
244
|
+
for (const rig of fbxScene.rigs) {
|
|
245
|
+
const skeleton = skeletonByRigId.get(rig.id);
|
|
246
|
+
if (!skeleton) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const boneModelIds = new Set(rig.bones.map((b) => b.modelId));
|
|
250
|
+
const skinnedMesh = meshes.find((m) => m.skeleton === skeleton) ?? null;
|
|
251
|
+
const boneReferenceNode = skinnedMesh ?? rootNode;
|
|
252
|
+
for (const boneData of rig.bones) {
|
|
253
|
+
if (!boneData.isCluster) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const boneNode = modelIdToNode.get(boneData.modelId);
|
|
257
|
+
const bone = this._getSourceBone(skeleton, boneData.index);
|
|
258
|
+
if (!boneNode || !bone) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Find direct children of this bone's TransformNode that aren't bones themselves
|
|
262
|
+
for (const child of [...boneNode.getChildren()]) {
|
|
263
|
+
const childTransform = child;
|
|
264
|
+
// Check if this child is itself a bone — if so, skip it
|
|
265
|
+
let childIsBone = false;
|
|
266
|
+
for (const [modelId, node] of Array.from(modelIdToNode)) {
|
|
267
|
+
if (node === childTransform && boneModelIds.has(modelId)) {
|
|
268
|
+
childIsBone = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (!childIsBone) {
|
|
273
|
+
const childWorld = childTransform.computeWorldMatrix(true).clone();
|
|
274
|
+
const boneReferenceWorld = FBXFileLoader._getBoneReferenceWorldMatrix(skeleton, bone, boneReferenceNode, skinnedMesh);
|
|
275
|
+
const boneReferenceWorldInv = new Matrix();
|
|
276
|
+
boneReferenceWorld.invertToRef(boneReferenceWorldInv);
|
|
277
|
+
const childLocalToBone = childWorld.multiply(boneReferenceWorldInv);
|
|
278
|
+
childTransform.parent = null;
|
|
279
|
+
childTransform.attachToBone(bone, boneReferenceNode);
|
|
280
|
+
FBXFileLoader._applyMatrixToTransform(childTransform, childLocalToBone);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Apply blend shapes (morph targets) to meshes
|
|
286
|
+
if (fbxScene.blendShapes.length > 0) {
|
|
287
|
+
this._applyBlendShapes(fbxScene.blendShapes, meshes, scene);
|
|
288
|
+
}
|
|
289
|
+
// Create animation groups
|
|
290
|
+
const animationGroups = [];
|
|
291
|
+
for (const animStack of fbxScene.animations) {
|
|
292
|
+
const group = this._createAnimationGroup(animStack, fbxScene.rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes);
|
|
293
|
+
if (group) {
|
|
294
|
+
animationGroups.push(group);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Create cameras
|
|
298
|
+
const cameras = [];
|
|
299
|
+
for (const camData of fbxScene.cameras) {
|
|
300
|
+
const cam = this._createCamera(camData, modelIdToNode, scene);
|
|
301
|
+
if (cam) {
|
|
302
|
+
cameras.push(cam);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Create lights
|
|
306
|
+
const sceneLights = [];
|
|
307
|
+
for (const lightData of fbxScene.lights) {
|
|
308
|
+
const light = this._createLight(lightData, modelIdToNode, scene);
|
|
309
|
+
if (light) {
|
|
310
|
+
sceneLights.push(light);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
meshes,
|
|
315
|
+
particleSystems: [],
|
|
316
|
+
skeletons,
|
|
317
|
+
animationGroups,
|
|
318
|
+
transformNodes,
|
|
319
|
+
geometries: [],
|
|
320
|
+
lights: sceneLights,
|
|
321
|
+
spriteManagers: [],
|
|
322
|
+
materials: Array.from(materialCache.values()),
|
|
323
|
+
textures: Array.from(new Set(Array.from(materialCache.values()).flatMap((material) => material.getActiveTextures()))),
|
|
324
|
+
cameras,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
_addMaterialToContainer(material, container) {
|
|
328
|
+
if (!material) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (material instanceof MultiMaterial) {
|
|
332
|
+
if (!container.multiMaterials.includes(material)) {
|
|
333
|
+
container.multiMaterials.push(material);
|
|
334
|
+
}
|
|
335
|
+
for (const subMaterial of material.subMaterials) {
|
|
336
|
+
this._addMaterialToContainer(subMaterial, container);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (!container.materials.includes(material)) {
|
|
340
|
+
container.materials.push(material);
|
|
341
|
+
}
|
|
342
|
+
for (const texture of material.getActiveTextures()) {
|
|
343
|
+
this._addTextureToContainer(texture, container);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
_addTextureToContainer(texture, container) {
|
|
347
|
+
if (!container.textures.includes(texture)) {
|
|
348
|
+
container.textures.push(texture);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
_setAssetContainer(container) {
|
|
352
|
+
for (const asset of container.meshes) {
|
|
353
|
+
asset._parentContainer = container;
|
|
354
|
+
}
|
|
355
|
+
for (const asset of container.transformNodes) {
|
|
356
|
+
asset._parentContainer = container;
|
|
357
|
+
}
|
|
358
|
+
for (const asset of container.skeletons) {
|
|
359
|
+
asset._parentContainer = container;
|
|
360
|
+
}
|
|
361
|
+
for (const asset of container.animationGroups) {
|
|
362
|
+
asset._parentContainer = container;
|
|
363
|
+
}
|
|
364
|
+
for (const asset of container.lights) {
|
|
365
|
+
asset._parentContainer = container;
|
|
366
|
+
}
|
|
367
|
+
for (const asset of container.cameras) {
|
|
368
|
+
asset._parentContainer = container;
|
|
369
|
+
}
|
|
370
|
+
for (const asset of container.materials) {
|
|
371
|
+
asset._parentContainer = container;
|
|
372
|
+
}
|
|
373
|
+
for (const asset of container.multiMaterials) {
|
|
374
|
+
asset._parentContainer = container;
|
|
375
|
+
}
|
|
376
|
+
for (const asset of container.textures) {
|
|
377
|
+
asset._parentContainer = container;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
static _computeFBXAxisConversionMatrix(fbxScene) {
|
|
381
|
+
const basisRows = [
|
|
382
|
+
[0, 0, 0],
|
|
383
|
+
[0, 0, 0],
|
|
384
|
+
[0, 0, 0],
|
|
385
|
+
];
|
|
386
|
+
const assignAxis = (sourceAxis, sourceSign, targetAxis) => {
|
|
387
|
+
if (sourceAxis < 0 || sourceAxis > 2) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const row = [0, 0, 0];
|
|
391
|
+
row[targetAxis] = sourceSign >= 0 ? 1 : -1;
|
|
392
|
+
basisRows[sourceAxis] = row;
|
|
393
|
+
};
|
|
394
|
+
assignAxis(fbxScene.coordAxis, fbxScene.coordAxisSign, 0);
|
|
395
|
+
assignAxis(fbxScene.upAxis, fbxScene.upAxisSign, 1);
|
|
396
|
+
assignAxis(fbxScene.frontAxis, fbxScene.frontAxisSign, 2);
|
|
397
|
+
if (basisRows.some((row) => row.every((value) => value === 0))) {
|
|
398
|
+
return Matrix.Identity();
|
|
399
|
+
}
|
|
400
|
+
return Matrix.FromValues(basisRows[0][0], basisRows[0][1], basisRows[0][2], 0, basisRows[1][0], basisRows[1][1], basisRows[1][2], 0, basisRows[2][0], basisRows[2][1], basisRows[2][2], 0, 0, 0, 0, 1);
|
|
401
|
+
}
|
|
402
|
+
_buildModel(model, scene, parent, assetRoot, parentFBXWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache) {
|
|
403
|
+
const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model);
|
|
404
|
+
const fbxWorldMatrix = localMatrix.multiply(parentFBXWorldMatrix);
|
|
405
|
+
if (model.geometry && model.subType === "Mesh" && (!nameFilter || nameFilter(model.name))) {
|
|
406
|
+
// Create mesh
|
|
407
|
+
const skeleton = skeletonByGeometryId.get(model.geometry.id);
|
|
408
|
+
const skin = skinByGeometryId.get(model.geometry.id);
|
|
409
|
+
const skinBinding = skinBindingByGeometryId.get(model.geometry.id);
|
|
410
|
+
if (skeleton && skin) {
|
|
411
|
+
skeleton.needInitialSkinMatrix = true;
|
|
412
|
+
}
|
|
413
|
+
const mesh = this._createMesh(model, model.geometry, scene, skeleton, skin, skinBinding);
|
|
414
|
+
// For skinned meshes: keep bind/pose math in FBX space, but parent
|
|
415
|
+
// the rendered mesh under the same conversion root as non-skinned
|
|
416
|
+
// meshes. The pose matrix cancels the real FBX mesh transform only;
|
|
417
|
+
// the root handedness conversion remains applied once at render time.
|
|
418
|
+
if (skeleton && skin) {
|
|
419
|
+
const meshBindMatrix = skin.meshBindPoseMatrix ? Matrix.FromArray(skin.meshBindPoseMatrix) : fbxWorldMatrix;
|
|
420
|
+
mesh.parent = assetRoot;
|
|
421
|
+
FBXFileLoader._applyMatrixToTransform(mesh, meshBindMatrix);
|
|
422
|
+
mesh.computeWorldMatrix(true);
|
|
423
|
+
mesh.updatePoseMatrix(Matrix.Invert(meshBindMatrix));
|
|
424
|
+
mesh.alwaysSelectAsActiveMesh = true;
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
if (parent) {
|
|
428
|
+
mesh.parent = parent;
|
|
429
|
+
}
|
|
430
|
+
FBXFileLoader._applyFBXTransform(mesh, model);
|
|
431
|
+
}
|
|
432
|
+
// Apply material(s)
|
|
433
|
+
if (model.materials.length > 1 && model.geometry?.materialIndices) {
|
|
434
|
+
// Multi-material: create sub-meshes for each material
|
|
435
|
+
this._applyMultiMaterial(mesh, model, materialCache, scene, cullingConflictMaterialIds, cullingMaterialCloneCache);
|
|
436
|
+
}
|
|
437
|
+
else if (model.materials.length > 0) {
|
|
438
|
+
const mat = materialCache.get(model.materials[0].id);
|
|
439
|
+
if (mat) {
|
|
440
|
+
mesh.material = FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(model.materials[0].id));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (model.geometry?.colors) {
|
|
444
|
+
this._useUnmodulatedVertexColorMaterials(mesh, scene);
|
|
445
|
+
}
|
|
446
|
+
this._applyMaterialUVSetCoordinates(mesh.material, model.geometry);
|
|
447
|
+
meshes.push(mesh);
|
|
448
|
+
modelIdToNode.set(model.id, mesh);
|
|
449
|
+
FBXFileLoader._applyModelMetadata(mesh, model);
|
|
450
|
+
// Recurse children
|
|
451
|
+
for (const child of model.children) {
|
|
452
|
+
this._buildModel(child, scene, mesh, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
if (model.geometry && model.subType === "Mesh" && nameFilter && !FBXFileLoader._modelSubtreeMatchesNameFilter(model, nameFilter)) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// Transform node (Null type or no geometry)
|
|
460
|
+
const transformNode = new TransformNode(model.name, scene);
|
|
461
|
+
if (parent) {
|
|
462
|
+
transformNode.parent = parent;
|
|
463
|
+
}
|
|
464
|
+
// Apply full FBX transform chain
|
|
465
|
+
FBXFileLoader._applyFBXTransform(transformNode, model);
|
|
466
|
+
transformNodes.push(transformNode);
|
|
467
|
+
modelIdToNode.set(model.id, transformNode);
|
|
468
|
+
FBXFileLoader._applyModelMetadata(transformNode, model);
|
|
469
|
+
// Recurse children
|
|
470
|
+
for (const child of model.children) {
|
|
471
|
+
this._buildModel(child, scene, transformNode, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
static _modelSubtreeMatchesNameFilter(model, nameFilter) {
|
|
476
|
+
for (const child of model.children) {
|
|
477
|
+
if (child.geometry && child.subType === "Mesh" && nameFilter(child.name)) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (FBXFileLoader._modelSubtreeMatchesNameFilter(child, nameFilter)) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
static _applyModelMetadata(node, model) {
|
|
487
|
+
if (!model.customProperties && model.diagnostics.length === 0) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
node.metadata = {
|
|
491
|
+
...(node.metadata ?? {}),
|
|
492
|
+
...(model.customProperties ? { fbxCustomProperties: model.customProperties } : {}),
|
|
493
|
+
...(model.diagnostics.length > 0 ? { fbxDiagnostics: model.diagnostics } : {}),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
_createMesh(model, geomData, scene, skeleton, skin, skinBinding) {
|
|
497
|
+
const mesh = new Mesh(model.name, scene);
|
|
498
|
+
mesh.sideOrientation = scene.useRightHandedSystem ? Material.CounterClockWiseSideOrientation : Material.ClockWiseSideOrientation;
|
|
499
|
+
const vertexData = new VertexData();
|
|
500
|
+
// Convert Float64Array to Float32Array for Babylon
|
|
501
|
+
const positions = float64To32(geomData.positions);
|
|
502
|
+
const gt = model.geometricTranslation;
|
|
503
|
+
const gr = model.geometricRotation;
|
|
504
|
+
const gs = model.geometricScaling;
|
|
505
|
+
// Geometric transforms affect only this mesh's geometry, not children.
|
|
506
|
+
// Blender composes them as T * R * S; Babylon's row-vector equivalent is S * R * T.
|
|
507
|
+
const geometricPositionMatrix = FBXFileLoader._computeFBXGeometricMatrix(gt, gr, gs);
|
|
508
|
+
const geometricDeltaMatrix = FBXFileLoader._computeFBXGeometricDeltaMatrix(gr, gs);
|
|
509
|
+
const geometricNormalMatrix = FBXFileLoader._computeFBXGeometricNormalMatrix(gr, gs);
|
|
510
|
+
const hasGeometricPositionTransform = !geometricPositionMatrix.equals(Matrix.Identity());
|
|
511
|
+
const hasGeometricDeltaTransform = !geometricDeltaMatrix.equals(Matrix.Identity());
|
|
512
|
+
const hasGeometricNormalTransform = !geometricNormalMatrix.equals(Matrix.Identity());
|
|
513
|
+
if (hasGeometricPositionTransform) {
|
|
514
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
515
|
+
const v = Vector3.TransformCoordinates(new Vector3(positions[i], positions[i + 1], positions[i + 2]), geometricPositionMatrix);
|
|
516
|
+
positions[i] = v.x;
|
|
517
|
+
positions[i + 1] = v.y;
|
|
518
|
+
positions[i + 2] = v.z;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// For skinned meshes: do NOT bake mesh local transform into vertices.
|
|
522
|
+
// Vertices remain in their original mesh-local space, keeping the mesh data
|
|
523
|
+
// clean for retargeting. The mesh node carries its FBX transform as an
|
|
524
|
+
// initial pose, while TransformLink bind matrices handle skinning.
|
|
525
|
+
vertexData.positions = positions;
|
|
526
|
+
vertexData.indices = Array.from(geomData.indices);
|
|
527
|
+
let normals;
|
|
528
|
+
if (geomData.normals) {
|
|
529
|
+
normals = float64To32(geomData.normals);
|
|
530
|
+
if (hasGeometricNormalTransform) {
|
|
531
|
+
for (let i = 0; i < normals.length; i += 3) {
|
|
532
|
+
const n = Vector3.TransformNormal(new Vector3(normals[i], normals[i + 1], normals[i + 2]), geometricNormalMatrix);
|
|
533
|
+
if (n.lengthSquared() > 0) {
|
|
534
|
+
n.normalize();
|
|
535
|
+
}
|
|
536
|
+
normals[i] = n.x;
|
|
537
|
+
normals[i + 1] = n.y;
|
|
538
|
+
normals[i + 2] = n.z;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
vertexData.normals = normals;
|
|
542
|
+
}
|
|
543
|
+
if (geomData.uvs) {
|
|
544
|
+
vertexData.uvs = float64To32(geomData.uvs);
|
|
545
|
+
}
|
|
546
|
+
if (geomData.uvSets.length > 1) {
|
|
547
|
+
vertexData.uvs2 = float64To32(geomData.uvSets[1].data);
|
|
548
|
+
}
|
|
549
|
+
if (geomData.uvSets.length > 2) {
|
|
550
|
+
vertexData.uvs3 = float64To32(geomData.uvSets[2].data);
|
|
551
|
+
}
|
|
552
|
+
if (geomData.uvSets.length > 3) {
|
|
553
|
+
vertexData.uvs4 = float64To32(geomData.uvSets[3].data);
|
|
554
|
+
}
|
|
555
|
+
if (geomData.uvSets.length > 4) {
|
|
556
|
+
vertexData.uvs5 = float64To32(geomData.uvSets[4].data);
|
|
557
|
+
}
|
|
558
|
+
if (geomData.uvSets.length > 5) {
|
|
559
|
+
vertexData.uvs6 = float64To32(geomData.uvSets[5].data);
|
|
560
|
+
}
|
|
561
|
+
if (geomData.tangents) {
|
|
562
|
+
const tangents = float64To32(geomData.tangents);
|
|
563
|
+
if (hasGeometricNormalTransform) {
|
|
564
|
+
for (let i = 0; i < tangents.length; i += 4) {
|
|
565
|
+
const t = Vector3.TransformNormal(new Vector3(tangents[i], tangents[i + 1], tangents[i + 2]), geometricNormalMatrix);
|
|
566
|
+
if (t.lengthSquared() > 0) {
|
|
567
|
+
t.normalize();
|
|
568
|
+
}
|
|
569
|
+
tangents[i] = t.x;
|
|
570
|
+
tangents[i + 1] = t.y;
|
|
571
|
+
tangents[i + 2] = t.z;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
applyTangentHandednessScale(tangents, this._getNormalMapTangentHandednessScale());
|
|
575
|
+
vertexData.tangents = tangents;
|
|
576
|
+
}
|
|
577
|
+
else if (normals && vertexData.uvs) {
|
|
578
|
+
vertexData.tangents = generateTangents(positions, normals, vertexData.uvs, geomData.indices, this._getNormalMapTangentHandednessScale(), geomData.controlPointIndices, geomData.materialIndices);
|
|
579
|
+
}
|
|
580
|
+
if (geomData.colors) {
|
|
581
|
+
// Force alpha to 1.0 — FBX vertex color alpha is often unreliable
|
|
582
|
+
// (e.g. zeroed out by exporters) and would cause transparency sorting issues.
|
|
583
|
+
const colors = new Float32Array(geomData.colors.length);
|
|
584
|
+
for (let i = 0; i < colors.length; i += 4) {
|
|
585
|
+
colors[i] = geomData.colors[i];
|
|
586
|
+
colors[i + 1] = geomData.colors[i + 1];
|
|
587
|
+
colors[i + 2] = geomData.colors[i + 2];
|
|
588
|
+
colors[i + 3] = 1.0;
|
|
589
|
+
}
|
|
590
|
+
vertexData.colors = colors;
|
|
591
|
+
mesh.hasVertexAlpha = false;
|
|
592
|
+
}
|
|
593
|
+
// Apply bone weights if we have a skin
|
|
594
|
+
if (skeleton && skin) {
|
|
595
|
+
const { matricesIndices, matricesWeights, matricesIndicesExtra, matricesWeightsExtra, numBoneInfluencers } = this._buildSkinningData(geomData, skin, skinBinding);
|
|
596
|
+
vertexData.matricesIndices = matricesIndices;
|
|
597
|
+
vertexData.matricesWeights = matricesWeights;
|
|
598
|
+
if (matricesIndicesExtra && matricesWeightsExtra) {
|
|
599
|
+
vertexData.matricesIndicesExtra = matricesIndicesExtra;
|
|
600
|
+
vertexData.matricesWeightsExtra = matricesWeightsExtra;
|
|
601
|
+
}
|
|
602
|
+
mesh.numBoneInfluencers = numBoneInfluencers;
|
|
603
|
+
}
|
|
604
|
+
vertexData.applyToMesh(mesh);
|
|
605
|
+
// Store geometry metadata for blend shape matching
|
|
606
|
+
mesh.metadata = {
|
|
607
|
+
...(mesh.metadata ?? {}),
|
|
608
|
+
fbxGeometryId: geomData.id,
|
|
609
|
+
fbxControlPointIndices: geomData.controlPointIndices,
|
|
610
|
+
fbxGeometryDeltaMatrix: hasGeometricDeltaTransform ? geometricDeltaMatrix : null,
|
|
611
|
+
fbxGeometryNormalMatrix: hasGeometricNormalTransform ? geometricNormalMatrix : null,
|
|
612
|
+
// Back-compat for existing morph delta handling metadata.
|
|
613
|
+
fbxPreRotMatrix: hasGeometricDeltaTransform ? geometricDeltaMatrix : null,
|
|
614
|
+
};
|
|
615
|
+
if (skeleton) {
|
|
616
|
+
mesh.skeleton = skeleton;
|
|
617
|
+
}
|
|
618
|
+
return mesh;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Apply multi-material to a mesh by creating sub-meshes grouped by material index.
|
|
622
|
+
* Reorders the index buffer so that triangles sharing the same material are contiguous.
|
|
623
|
+
*/
|
|
624
|
+
_applyMultiMaterial(mesh, model, materialCache, scene, cullingConflictMaterialIds, cullingMaterialCloneCache) {
|
|
625
|
+
const matIndices = model.geometry.materialIndices;
|
|
626
|
+
const indices = mesh.getIndices();
|
|
627
|
+
if (!indices) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const triCount = indices.length / 3;
|
|
631
|
+
// Group triangles by material index
|
|
632
|
+
const groups = new Map(); // matIdx -> triangle indices
|
|
633
|
+
for (let ti = 0; ti < triCount; ti++) {
|
|
634
|
+
const matIdx = ti < matIndices.length ? matIndices[ti] : 0;
|
|
635
|
+
let group = groups.get(matIdx);
|
|
636
|
+
if (!group) {
|
|
637
|
+
group = [];
|
|
638
|
+
groups.set(matIdx, group);
|
|
639
|
+
}
|
|
640
|
+
group.push(ti);
|
|
641
|
+
}
|
|
642
|
+
// Sort group keys to ensure consistent ordering
|
|
643
|
+
const sortedMatIndices = Array.from(groups.keys()).sort((a, b) => a - b);
|
|
644
|
+
// Reorder index buffer so triangles are grouped by material
|
|
645
|
+
const newIndices = [];
|
|
646
|
+
const subMeshRanges = [];
|
|
647
|
+
for (const matIdx of sortedMatIndices) {
|
|
648
|
+
const tris = groups.get(matIdx);
|
|
649
|
+
const start = newIndices.length;
|
|
650
|
+
for (const ti of tris) {
|
|
651
|
+
newIndices.push(indices[ti * 3], indices[ti * 3 + 1], indices[ti * 3 + 2]);
|
|
652
|
+
}
|
|
653
|
+
subMeshRanges.push({ start, count: tris.length * 3, matIdx });
|
|
654
|
+
}
|
|
655
|
+
// Update the mesh's index buffer
|
|
656
|
+
mesh.setIndices(newIndices);
|
|
657
|
+
// Create MultiMaterial
|
|
658
|
+
const multiMat = new MultiMaterial(model.name + "_multi", scene);
|
|
659
|
+
for (const range of subMeshRanges) {
|
|
660
|
+
const fbxMat = model.materials[range.matIdx];
|
|
661
|
+
if (fbxMat) {
|
|
662
|
+
const mat = materialCache.get(fbxMat.id);
|
|
663
|
+
if (mat) {
|
|
664
|
+
multiMat.subMaterials.push(FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(fbxMat.id)));
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
multiMat.subMaterials.push(null);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
multiMat.subMaterials.push(null);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
mesh.material = multiMat;
|
|
675
|
+
// Clear existing sub-meshes and create new ones
|
|
676
|
+
mesh.subMeshes = [];
|
|
677
|
+
const vertexCount = mesh.getTotalVertices();
|
|
678
|
+
for (let i = 0; i < subMeshRanges.length; i++) {
|
|
679
|
+
const range = subMeshRanges[i];
|
|
680
|
+
new SubMesh(i, 0, vertexCount, range.start, range.count, mesh);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
static _collectCullingConflictMaterialIds(models) {
|
|
684
|
+
// Deliberately scan the full scene, not just name-filtered models. This
|
|
685
|
+
// can over-clone for filtered imports, but avoids shared culling state.
|
|
686
|
+
const usage = new Map();
|
|
687
|
+
const collect = (model) => {
|
|
688
|
+
for (const material of model.materials) {
|
|
689
|
+
const state = usage.get(material.id) ?? { cullingOff: false, cullingOn: false };
|
|
690
|
+
if (model.cullingOff) {
|
|
691
|
+
state.cullingOff = true;
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
state.cullingOn = true;
|
|
695
|
+
}
|
|
696
|
+
usage.set(material.id, state);
|
|
697
|
+
}
|
|
698
|
+
for (const child of model.children) {
|
|
699
|
+
collect(child);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
for (const model of models) {
|
|
703
|
+
collect(model);
|
|
704
|
+
}
|
|
705
|
+
const conflicts = new Set();
|
|
706
|
+
for (const [materialId, state] of Array.from(usage)) {
|
|
707
|
+
if (state.cullingOff && state.cullingOn) {
|
|
708
|
+
conflicts.add(materialId);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return conflicts;
|
|
712
|
+
}
|
|
713
|
+
static _getModelMaterial(material, model, cullingCloneCache, cloneCullingOffMaterial = true) {
|
|
714
|
+
if (!model.cullingOff || !material.backFaceCulling) {
|
|
715
|
+
return material;
|
|
716
|
+
}
|
|
717
|
+
if (!cloneCullingOffMaterial) {
|
|
718
|
+
material.backFaceCulling = false;
|
|
719
|
+
return material;
|
|
720
|
+
}
|
|
721
|
+
const cached = cullingCloneCache?.get(material);
|
|
722
|
+
if (cached) {
|
|
723
|
+
return cached;
|
|
724
|
+
}
|
|
725
|
+
const clone = material.clone(`${material.name}_CullingOff`);
|
|
726
|
+
clone.backFaceCulling = false;
|
|
727
|
+
cullingCloneCache?.set(material, clone);
|
|
728
|
+
return clone;
|
|
729
|
+
}
|
|
730
|
+
_applyMaterialUVSetCoordinates(material, geometry) {
|
|
731
|
+
if (!material) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (material instanceof MultiMaterial) {
|
|
735
|
+
for (const subMaterial of material.subMaterials) {
|
|
736
|
+
if (subMaterial instanceof StandardMaterial) {
|
|
737
|
+
this._applyStandardMaterialUVSetCoordinates(subMaterial, geometry);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (material instanceof StandardMaterial) {
|
|
743
|
+
this._applyStandardMaterialUVSetCoordinates(material, geometry);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
_applyStandardMaterialUVSetCoordinates(material, geometry) {
|
|
747
|
+
for (const texture of [
|
|
748
|
+
material.diffuseTexture,
|
|
749
|
+
material.bumpTexture,
|
|
750
|
+
material.emissiveTexture,
|
|
751
|
+
material.ambientTexture,
|
|
752
|
+
material.specularTexture,
|
|
753
|
+
material.opacityTexture,
|
|
754
|
+
material.reflectionTexture,
|
|
755
|
+
]) {
|
|
756
|
+
if (!texture) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const uvSetName = texture.metadata?.fbxUVSetName;
|
|
760
|
+
if (!uvSetName) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const uvSetIndex = geometry.uvSets.findIndex((uvSet) => uvSet.name === uvSetName);
|
|
764
|
+
if (uvSetIndex >= 0) {
|
|
765
|
+
texture.coordinatesIndex = uvSetIndex;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Babylon multiplies vertex colors by material diffuse color. Use per-mesh
|
|
771
|
+
* material clones so vertex-colored geometry can render unmodulated without
|
|
772
|
+
* changing shared materials used by non-vertex-colored meshes.
|
|
773
|
+
*/
|
|
774
|
+
_useUnmodulatedVertexColorMaterials(mesh, scene) {
|
|
775
|
+
const assignedMat = mesh.material;
|
|
776
|
+
if (!assignedMat) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (assignedMat instanceof StandardMaterial) {
|
|
780
|
+
if (!assignedMat.diffuseTexture) {
|
|
781
|
+
const clone = assignedMat.clone(`${assignedMat.name}_VertexColor`);
|
|
782
|
+
clone.diffuseColor = new Color3(1, 1, 1);
|
|
783
|
+
mesh.material = clone;
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (assignedMat instanceof MultiMaterial) {
|
|
788
|
+
const multiMat = new MultiMaterial(`${assignedMat.name}_VertexColor`, scene);
|
|
789
|
+
multiMat.subMaterials = assignedMat.subMaterials.map((sub) => {
|
|
790
|
+
if (sub instanceof StandardMaterial && !sub.diffuseTexture) {
|
|
791
|
+
const clone = sub.clone(`${sub.name}_VertexColor`);
|
|
792
|
+
clone.diffuseColor = new Color3(1, 1, 1);
|
|
793
|
+
return clone;
|
|
794
|
+
}
|
|
795
|
+
return sub;
|
|
796
|
+
});
|
|
797
|
+
mesh.material = multiMat;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Build per-polygon-vertex bone indices and weights from the control-point-based skin data.
|
|
802
|
+
* The geometry expands control points to per-polygon-vertex, so we need to look up
|
|
803
|
+
* each polygon-vertex's control point index.
|
|
804
|
+
*/
|
|
805
|
+
_buildSkinningData(geomData, skin, skinBinding) {
|
|
806
|
+
// The positions array is per-polygon-vertex (already expanded).
|
|
807
|
+
// We need to figure out the control point index for each polygon vertex.
|
|
808
|
+
// The geometry stores positions per polygon-vertex, so geomData.positions.length/3
|
|
809
|
+
// = number of polygon vertices. We stored control point indices during expansion,
|
|
810
|
+
// but they aren't exported. Instead, we can use the fact that skin data is indexed
|
|
811
|
+
// by control point, and the geometry's _controlPointIndices stores this mapping.
|
|
812
|
+
//
|
|
813
|
+
// Since we don't have direct access to the control point mapping from FBXGeometryData,
|
|
814
|
+
// we'll use the vertex positions to build the skinning buffer. But actually,
|
|
815
|
+
// we should extend geometry to export control point indices per polygon-vertex.
|
|
816
|
+
//
|
|
817
|
+
// For now, use the approach of matching positions to control points.
|
|
818
|
+
// Actually, let's look at this differently - the indices/weights in the skin
|
|
819
|
+
// are per control point. The geometry already expanded to per polygon-vertex
|
|
820
|
+
// with positions copied from control points. We need to know which control point
|
|
821
|
+
// each polygon-vertex came from.
|
|
822
|
+
//
|
|
823
|
+
// We'll use geomData.controlPointIndices if available.
|
|
824
|
+
const vertexCount = geomData.positions.length / 3;
|
|
825
|
+
const matricesIndices = new Float32Array(vertexCount * 4);
|
|
826
|
+
const matricesWeights = new Float32Array(vertexCount * 4);
|
|
827
|
+
let matricesIndicesExtra = null;
|
|
828
|
+
let matricesWeightsExtra = null;
|
|
829
|
+
let numBoneInfluencers = 0;
|
|
830
|
+
if (geomData.controlPointIndices) {
|
|
831
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
832
|
+
const cpIdx = geomData.controlPointIndices[i];
|
|
833
|
+
const boneIdx = skin.boneIndices[cpIdx] ?? [];
|
|
834
|
+
numBoneInfluencers = Math.max(numBoneInfluencers, Math.min(boneIdx.length, 8));
|
|
835
|
+
}
|
|
836
|
+
if (numBoneInfluencers > 4) {
|
|
837
|
+
matricesIndicesExtra = new Float32Array(vertexCount * 4);
|
|
838
|
+
matricesWeightsExtra = new Float32Array(vertexCount * 4);
|
|
839
|
+
}
|
|
840
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
841
|
+
const cpIdx = geomData.controlPointIndices[i];
|
|
842
|
+
const boneIdx = skin.boneIndices[cpIdx] ?? [];
|
|
843
|
+
const boneWts = skin.boneWeights[cpIdx] ?? [];
|
|
844
|
+
for (let j = 0; j < 8; j++) {
|
|
845
|
+
const indicesBuffer = j < 4 ? matricesIndices : matricesIndicesExtra;
|
|
846
|
+
const weightsBuffer = j < 4 ? matricesWeights : matricesWeightsExtra;
|
|
847
|
+
if (!indicesBuffer || !weightsBuffer) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const bufferIndex = i * 4 + (j % 4);
|
|
851
|
+
if (j < boneIdx.length) {
|
|
852
|
+
const skinBoneIndex = boneIdx[j];
|
|
853
|
+
const rigBoneIndex = skinBinding ? skinBinding.skinBoneIndexToRigBoneIndex[skinBoneIndex] : skinBoneIndex;
|
|
854
|
+
if (rigBoneIndex === undefined || rigBoneIndex < 0) {
|
|
855
|
+
throw new Error(`FBXFileLoader: missing rig bone mapping for skin bone index ${skinBoneIndex}`);
|
|
856
|
+
}
|
|
857
|
+
indicesBuffer[bufferIndex] = rigBoneIndex;
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
indicesBuffer[bufferIndex] = 0;
|
|
861
|
+
}
|
|
862
|
+
weightsBuffer[bufferIndex] = j < boneWts.length ? boneWts[j] : 0;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
matricesIndices,
|
|
868
|
+
matricesWeights,
|
|
869
|
+
matricesIndicesExtra,
|
|
870
|
+
matricesWeightsExtra,
|
|
871
|
+
numBoneInfluencers: Math.max(numBoneInfluencers, 1),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
_createMaterial(matData, scene, rootUrl) {
|
|
875
|
+
const material = new StandardMaterial(matData.name, scene);
|
|
876
|
+
const props = matData.properties;
|
|
877
|
+
const hasTexture = (...slots) => matData.textures.some((texture) => slots.includes(texture.propertyName));
|
|
878
|
+
if (matData.type === "Lambert") {
|
|
879
|
+
material.specularColor = Color3.Black();
|
|
880
|
+
}
|
|
881
|
+
if (props.diffuseColor) {
|
|
882
|
+
const diffuseFactor = hasTexture("DiffuseColor", "Diffuse") ? 1 : (props.diffuseFactor ?? 1);
|
|
883
|
+
material.diffuseColor = new Color3(props.diffuseColor[0] * diffuseFactor, props.diffuseColor[1] * diffuseFactor, props.diffuseColor[2] * diffuseFactor);
|
|
884
|
+
}
|
|
885
|
+
if (props.ambientColor) {
|
|
886
|
+
const ambientFactor = hasTexture("AmbientColor", "Ambient") ? 1 : (props.ambientFactor ?? 1);
|
|
887
|
+
material.ambientColor = new Color3(props.ambientColor[0] * ambientFactor, props.ambientColor[1] * ambientFactor, props.ambientColor[2] * ambientFactor);
|
|
888
|
+
}
|
|
889
|
+
if (matData.type === "Phong" && props.specularColor) {
|
|
890
|
+
const specularFactor = hasTexture("SpecularColor", "Specular", "Shininess", "ShininessExponent") ? 1 : (props.specularFactor ?? 1);
|
|
891
|
+
material.specularColor = new Color3(props.specularColor[0] * specularFactor, props.specularColor[1] * specularFactor, props.specularColor[2] * specularFactor);
|
|
892
|
+
}
|
|
893
|
+
if (props.emissiveColor) {
|
|
894
|
+
const emissiveFactor = hasTexture("EmissiveColor", "Emissive") ? 1 : (props.emissiveFactor ?? 1);
|
|
895
|
+
material.emissiveColor = new Color3(props.emissiveColor[0] * emissiveFactor, props.emissiveColor[1] * emissiveFactor, props.emissiveColor[2] * emissiveFactor);
|
|
896
|
+
}
|
|
897
|
+
if (props.opacity !== undefined) {
|
|
898
|
+
material.alpha = props.opacity;
|
|
899
|
+
}
|
|
900
|
+
else if (props.transparencyFactor !== undefined) {
|
|
901
|
+
material.alpha = 1 - props.transparencyFactor;
|
|
902
|
+
}
|
|
903
|
+
if (material.alpha < 1) {
|
|
904
|
+
material.transparencyMode = Material.MATERIAL_ALPHABLEND;
|
|
905
|
+
}
|
|
906
|
+
if (props.shininess !== undefined) {
|
|
907
|
+
material.specularPower = props.shininess;
|
|
908
|
+
}
|
|
909
|
+
// Apply textures
|
|
910
|
+
for (const tex of matData.textures) {
|
|
911
|
+
if (!FBXFileLoader._isSupportedMaterialTextureSlot(tex.propertyName)) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const texture = FBXFileLoader._createTexture(tex, scene, rootUrl, FBXFileLoader._isNormalMapTextureSlot(tex.propertyName));
|
|
915
|
+
if (!texture) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
switch (tex.propertyName) {
|
|
919
|
+
case "DiffuseColor":
|
|
920
|
+
material.diffuseTexture = texture;
|
|
921
|
+
// In FBX, a connected diffuse texture provides the color.
|
|
922
|
+
// Set diffuseColor to white so the texture isn't darkened by
|
|
923
|
+
// the material's base color (many FBX exports set it near-black).
|
|
924
|
+
material.diffuseColor = new Color3(1, 1, 1);
|
|
925
|
+
break;
|
|
926
|
+
case "NormalMap":
|
|
927
|
+
case "NormalMapTexture":
|
|
928
|
+
case "normalCamera":
|
|
929
|
+
material.bumpTexture = texture;
|
|
930
|
+
this._configureNormalTexture(texture, material);
|
|
931
|
+
break;
|
|
932
|
+
case "Bump":
|
|
933
|
+
case "BumpFactor":
|
|
934
|
+
material.bumpTexture = texture;
|
|
935
|
+
this._configureNormalTexture(texture, material);
|
|
936
|
+
break;
|
|
937
|
+
case "EmissiveColor":
|
|
938
|
+
material.emissiveTexture = texture;
|
|
939
|
+
break;
|
|
940
|
+
case "AmbientColor":
|
|
941
|
+
material.ambientTexture = texture;
|
|
942
|
+
break;
|
|
943
|
+
case "SpecularColor":
|
|
944
|
+
material.specularTexture = texture;
|
|
945
|
+
break;
|
|
946
|
+
case "TransparencyFactor":
|
|
947
|
+
case "TransparentColor":
|
|
948
|
+
material.opacityTexture = texture;
|
|
949
|
+
material.transparencyMode = Material.MATERIAL_ALPHATESTANDBLEND;
|
|
950
|
+
break;
|
|
951
|
+
case "ReflectionColor":
|
|
952
|
+
case "ReflectionFactor":
|
|
953
|
+
material.reflectionTexture = texture;
|
|
954
|
+
break;
|
|
955
|
+
case "DisplacementColor":
|
|
956
|
+
case "Displacement":
|
|
957
|
+
case "DisplacementFactor":
|
|
958
|
+
// StandardMaterial doesn't have a displacement slot natively;
|
|
959
|
+
// store for potential PBR conversion use
|
|
960
|
+
break;
|
|
961
|
+
case "ShininessExponent":
|
|
962
|
+
case "Shininess":
|
|
963
|
+
// Shininess map — no direct StandardMaterial slot
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
// Apply UV transforms
|
|
967
|
+
if (tex.uvTranslation) {
|
|
968
|
+
texture.uOffset = tex.uvTranslation[0];
|
|
969
|
+
texture.vOffset = tex.uvTranslation[1];
|
|
970
|
+
}
|
|
971
|
+
if (tex.uvScaling) {
|
|
972
|
+
texture.uScale = tex.uvScaling[0];
|
|
973
|
+
texture.vScale = tex.uvScaling[1];
|
|
974
|
+
}
|
|
975
|
+
if (tex.uvRotation !== undefined) {
|
|
976
|
+
texture.wAng = tex.uvRotation * (Math.PI / 180);
|
|
977
|
+
}
|
|
978
|
+
if (tex.uvSetIndex !== undefined) {
|
|
979
|
+
texture.coordinatesIndex = tex.uvSetIndex;
|
|
980
|
+
}
|
|
981
|
+
if (tex.uvSetName) {
|
|
982
|
+
texture.metadata = {
|
|
983
|
+
...(texture.metadata ?? {}),
|
|
984
|
+
fbxUVSetName: tex.uvSetName,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return material;
|
|
989
|
+
}
|
|
990
|
+
_configureNormalTexture(texture, material) {
|
|
991
|
+
texture.gammaSpace = false;
|
|
992
|
+
material.invertNormalMapX = false;
|
|
993
|
+
material.invertNormalMapY = this._options.normalMapCoordinateSystem === "y-down";
|
|
994
|
+
}
|
|
995
|
+
_getNormalMapTangentHandednessScale() {
|
|
996
|
+
return this._options.normalMapCoordinateSystem === "y-down" ? -1 : 1;
|
|
997
|
+
}
|
|
998
|
+
static _isSupportedMaterialTextureSlot(propertyName) {
|
|
999
|
+
switch (propertyName) {
|
|
1000
|
+
case "DiffuseColor":
|
|
1001
|
+
case "NormalMap":
|
|
1002
|
+
case "NormalMapTexture":
|
|
1003
|
+
case "normalCamera":
|
|
1004
|
+
case "Bump":
|
|
1005
|
+
case "BumpFactor":
|
|
1006
|
+
case "EmissiveColor":
|
|
1007
|
+
case "AmbientColor":
|
|
1008
|
+
case "SpecularColor":
|
|
1009
|
+
case "TransparencyFactor":
|
|
1010
|
+
case "TransparentColor":
|
|
1011
|
+
case "ReflectionColor":
|
|
1012
|
+
case "ReflectionFactor":
|
|
1013
|
+
case "DisplacementColor":
|
|
1014
|
+
case "Displacement":
|
|
1015
|
+
case "DisplacementFactor":
|
|
1016
|
+
case "ShininessExponent":
|
|
1017
|
+
case "Shininess":
|
|
1018
|
+
return true;
|
|
1019
|
+
default:
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
static _isNormalMapTextureSlot(propertyName) {
|
|
1024
|
+
switch (propertyName) {
|
|
1025
|
+
case "NormalMap":
|
|
1026
|
+
case "NormalMapTexture":
|
|
1027
|
+
case "normalCamera":
|
|
1028
|
+
case "Bump":
|
|
1029
|
+
case "BumpFactor":
|
|
1030
|
+
return true;
|
|
1031
|
+
default:
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
static _createTexture(tex, scene, rootUrl, isDataTexture) {
|
|
1036
|
+
const sourceName = FBXFileLoader._getTextureSourceName(tex);
|
|
1037
|
+
const creationOptions = FBXFileLoader._getTextureCreationOptions(sourceName, isDataTexture, tex.embeddedData);
|
|
1038
|
+
if (tex.embeddedData) {
|
|
1039
|
+
const texture = new Texture(null, scene, creationOptions);
|
|
1040
|
+
const embeddedTextureName = sourceName ?? `embeddedTexture_${tex.id.toString()}`;
|
|
1041
|
+
texture.updateURL(`data:fbx-embedded-texture/${encodeURIComponent(embeddedTextureName)}`, new Uint8Array(tex.embeddedData), undefined, creationOptions.forcedExtension);
|
|
1042
|
+
texture.name = embeddedTextureName;
|
|
1043
|
+
return texture;
|
|
1044
|
+
}
|
|
1045
|
+
const textureUrls = FBXFileLoader._getExternalTextureUrls(tex, rootUrl);
|
|
1046
|
+
const textureUrl = textureUrls.shift();
|
|
1047
|
+
if (!textureUrl) {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
return FBXFileLoader._createExternalTexture(textureUrl, textureUrls, scene, creationOptions);
|
|
1051
|
+
}
|
|
1052
|
+
static _createExternalTexture(texturePath, fallbackUrls, scene, creationOptions) {
|
|
1053
|
+
fallbackUrls.push(...FBXFileLoader._buildTextureFallbackUrls(texturePath));
|
|
1054
|
+
let fallbackIndex = 0;
|
|
1055
|
+
const texture = new Texture(texturePath, scene, {
|
|
1056
|
+
...creationOptions,
|
|
1057
|
+
onError: () => {
|
|
1058
|
+
const fallbackUrl = fallbackUrls[fallbackIndex++];
|
|
1059
|
+
if (fallbackUrl && texture.getScene()) {
|
|
1060
|
+
texture.updateURL(fallbackUrl, null, undefined, FBXFileLoader._getForcedExtension(fallbackUrl));
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
return texture;
|
|
1065
|
+
}
|
|
1066
|
+
static _buildTextureFallbackUrls(texturePath) {
|
|
1067
|
+
const slashIndex = Math.max(texturePath.lastIndexOf("/"), texturePath.lastIndexOf("\\"));
|
|
1068
|
+
const dotIndex = texturePath.lastIndexOf(".");
|
|
1069
|
+
if (dotIndex <= slashIndex) {
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
const basePath = texturePath.slice(0, dotIndex);
|
|
1073
|
+
const currentExtension = texturePath.slice(dotIndex + 1).toLowerCase();
|
|
1074
|
+
const extensionFallbacks = ["png", "jpg", "jpeg", "webp", "bmp", "tga"];
|
|
1075
|
+
return extensionFallbacks.filter((extension) => extension !== currentExtension).map((extension) => `${basePath}.${extension}`);
|
|
1076
|
+
}
|
|
1077
|
+
static _getTextureCreationOptions(sourceName, isDataTexture, embeddedData) {
|
|
1078
|
+
const mimeType = embeddedData ? (sourceName ? FBXFileLoader._getMimeType(sourceName) : "image/png") : undefined;
|
|
1079
|
+
return {
|
|
1080
|
+
buffer: embeddedData ? new Uint8Array(embeddedData) : undefined,
|
|
1081
|
+
forcedExtension: sourceName ? FBXFileLoader._getForcedExtension(sourceName, mimeType) : embeddedData ? ".png" : undefined,
|
|
1082
|
+
gammaSpace: !isDataTexture,
|
|
1083
|
+
mimeType,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
static _getExternalTextureUrls(tex, rootUrl) {
|
|
1087
|
+
const textureNames = [tex.relativeFileName, tex.fileName].filter((name) => !!name);
|
|
1088
|
+
const urls = [];
|
|
1089
|
+
for (const textureName of textureNames) {
|
|
1090
|
+
const normalized = textureName.replace(/\\/g, "/");
|
|
1091
|
+
if (FBXFileLoader._isSafeRelativeTexturePath(normalized)) {
|
|
1092
|
+
urls.push(rootUrl + normalized);
|
|
1093
|
+
}
|
|
1094
|
+
const basename = FBXFileLoader._getTextureSourceNameFromPath(normalized);
|
|
1095
|
+
if (basename) {
|
|
1096
|
+
urls.push(rootUrl + basename);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return Array.from(new Set(urls));
|
|
1100
|
+
}
|
|
1101
|
+
static _getTextureSourceName(tex) {
|
|
1102
|
+
const textureName = tex.relativeFileName || tex.fileName;
|
|
1103
|
+
if (!textureName) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
const normalized = textureName.replace(/\\/g, "/");
|
|
1107
|
+
return FBXFileLoader._getTextureSourceNameFromPath(normalized);
|
|
1108
|
+
}
|
|
1109
|
+
static _getTextureSourceNameFromPath(texturePath) {
|
|
1110
|
+
return texturePath.split("/").pop() ?? texturePath;
|
|
1111
|
+
}
|
|
1112
|
+
static _isSafeRelativeTexturePath(texturePath) {
|
|
1113
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(texturePath) || texturePath.startsWith("/") || texturePath.startsWith("//")) {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
return !texturePath.split("/").some((part) => part === "..");
|
|
1117
|
+
}
|
|
1118
|
+
static _getForcedExtension(fileName, mimeType) {
|
|
1119
|
+
const slashIndex = Math.max(fileName.lastIndexOf("/"), fileName.lastIndexOf("\\"));
|
|
1120
|
+
const dotIndex = fileName.lastIndexOf(".");
|
|
1121
|
+
if (dotIndex > slashIndex) {
|
|
1122
|
+
return fileName.slice(dotIndex).toLowerCase();
|
|
1123
|
+
}
|
|
1124
|
+
switch (mimeType) {
|
|
1125
|
+
case "image/png":
|
|
1126
|
+
return ".png";
|
|
1127
|
+
case "image/jpeg":
|
|
1128
|
+
return ".jpg";
|
|
1129
|
+
case "image/webp":
|
|
1130
|
+
return ".webp";
|
|
1131
|
+
case "image/bmp":
|
|
1132
|
+
return ".bmp";
|
|
1133
|
+
case "image/gif":
|
|
1134
|
+
return ".gif";
|
|
1135
|
+
case "image/x-tga":
|
|
1136
|
+
return ".tga";
|
|
1137
|
+
default:
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
static _getMimeType(fileName) {
|
|
1142
|
+
const mimeType = GetMimeType(fileName);
|
|
1143
|
+
if (mimeType) {
|
|
1144
|
+
return mimeType;
|
|
1145
|
+
}
|
|
1146
|
+
const extension = FBXFileLoader._getForcedExtension(fileName);
|
|
1147
|
+
switch (extension) {
|
|
1148
|
+
case ".tga":
|
|
1149
|
+
return "image/x-tga";
|
|
1150
|
+
case ".bmp":
|
|
1151
|
+
return "image/bmp";
|
|
1152
|
+
case ".gif":
|
|
1153
|
+
return "image/gif";
|
|
1154
|
+
default:
|
|
1155
|
+
return "image/png";
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Apply blend shape (morph target) deformers to meshes.
|
|
1160
|
+
* FBX Shape vertices are stored as absolute positions for sparse control points.
|
|
1161
|
+
* We compute deltas relative to the base mesh positions.
|
|
1162
|
+
*/
|
|
1163
|
+
_applyBlendShapes(blendShapes, meshes, scene) {
|
|
1164
|
+
// Build a map from geometry ID to mesh (using the mesh metadata we'll need to store)
|
|
1165
|
+
// The mesh's geometry ID is tracked through the model hierarchy during _buildModel.
|
|
1166
|
+
// We need to match blendShape.geometryId to the correct mesh.
|
|
1167
|
+
// Strategy: match by examining which meshes have positions matching the geometry.
|
|
1168
|
+
for (const bs of blendShapes) {
|
|
1169
|
+
// Find the mesh that uses this geometry
|
|
1170
|
+
const mesh = meshes.find((m) => {
|
|
1171
|
+
const geomId = m.metadata?.fbxGeometryId;
|
|
1172
|
+
return geomId === bs.geometryId;
|
|
1173
|
+
});
|
|
1174
|
+
if (!mesh) {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const morphTargetManager = new MorphTargetManager(scene);
|
|
1178
|
+
morphTargetManager.optimizeInfluencers = false;
|
|
1179
|
+
// Get preRotation matrix if the mesh had its positions baked
|
|
1180
|
+
const deltaMatrix = mesh.metadata?.fbxGeometryDeltaMatrix ??
|
|
1181
|
+
mesh.metadata?.fbxPreRotMatrix ??
|
|
1182
|
+
null;
|
|
1183
|
+
const normalMatrix = mesh.metadata?.fbxGeometryNormalMatrix ?? deltaMatrix;
|
|
1184
|
+
for (const channel of bs.channels) {
|
|
1185
|
+
// Get the control point indices for this mesh (stored as metadata)
|
|
1186
|
+
const cpIndices = mesh.metadata?.fbxControlPointIndices;
|
|
1187
|
+
if (!cpIndices) {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
const basePositions = mesh.getVerticesData("position");
|
|
1191
|
+
const baseNormals = mesh.getVerticesData("normal");
|
|
1192
|
+
if (!basePositions) {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
const initialInfluences = calculateBlendShapeInfluences(channel.deformPercent, channel.fullWeights, channel.shapes.length);
|
|
1196
|
+
const targetIndices = [];
|
|
1197
|
+
for (let shapeIndex = 0; shapeIndex < channel.shapes.length; shapeIndex++) {
|
|
1198
|
+
const shape = channel.shapes[shapeIndex];
|
|
1199
|
+
if (!shape) {
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
const targetData = buildMorphTargetData(shape, cpIndices, basePositions, baseNormals, deltaMatrix, normalMatrix);
|
|
1203
|
+
if (!targetData) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const targetName = channel.fullWeights && channel.shapes.length > 1 ? `${channel.name}_${channel.fullWeights[shapeIndex]}` : channel.name;
|
|
1207
|
+
const morphTarget = new MorphTarget(targetName, initialInfluences[shapeIndex] ?? 0, scene);
|
|
1208
|
+
morphTarget.setPositions(targetData.positions);
|
|
1209
|
+
if (targetData.normals) {
|
|
1210
|
+
morphTarget.setNormals(targetData.normals);
|
|
1211
|
+
}
|
|
1212
|
+
targetIndices.push(morphTargetManager.numTargets);
|
|
1213
|
+
morphTargetManager.addTarget(morphTarget);
|
|
1214
|
+
}
|
|
1215
|
+
if (targetIndices.length === 0) {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
// Store channel ID mapping on the mesh for animation targeting.
|
|
1219
|
+
// Keep the legacy single-target map for existing consumers and add
|
|
1220
|
+
// richer in-between metadata for FullWeights-aware animation baking.
|
|
1221
|
+
if (!mesh.metadata) {
|
|
1222
|
+
mesh.metadata = {};
|
|
1223
|
+
}
|
|
1224
|
+
if (!mesh.metadata.fbxBlendShapeChannelIds) {
|
|
1225
|
+
mesh.metadata.fbxBlendShapeChannelIds = new Map();
|
|
1226
|
+
}
|
|
1227
|
+
mesh.metadata.fbxBlendShapeChannelIds.set(channel.id, targetIndices[0]);
|
|
1228
|
+
if (!mesh.metadata.fbxBlendShapeChannelTargets) {
|
|
1229
|
+
mesh.metadata.fbxBlendShapeChannelTargets = new Map();
|
|
1230
|
+
}
|
|
1231
|
+
mesh.metadata.fbxBlendShapeChannelTargets.set(channel.id, {
|
|
1232
|
+
targetIndices,
|
|
1233
|
+
fullWeights: channel.fullWeights,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
if (morphTargetManager.numTargets > 0) {
|
|
1237
|
+
morphTargetManager.numMaxInfluencers = morphTargetManager.numTargets;
|
|
1238
|
+
mesh.morphTargetManager = morphTargetManager;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
_createCamera(camData, modelIdToNode, scene) {
|
|
1243
|
+
const parentNode = modelIdToNode.get(camData.modelId);
|
|
1244
|
+
const worldMatrix = parentNode ? parentNode.computeWorldMatrix(true) : Matrix.Identity();
|
|
1245
|
+
const position = Vector3.TransformCoordinates(Vector3.Zero(), worldMatrix);
|
|
1246
|
+
const camera = new FreeCamera(camData.name, position, scene);
|
|
1247
|
+
camera.fov = camData.fieldOfView * (Math.PI / 180);
|
|
1248
|
+
camera.minZ = camData.nearPlane;
|
|
1249
|
+
camera.maxZ = camData.farPlane;
|
|
1250
|
+
camera.metadata = {
|
|
1251
|
+
...(camera.metadata ?? {}),
|
|
1252
|
+
fbxCamera: {
|
|
1253
|
+
projectionType: camData.projectionType,
|
|
1254
|
+
focalLength: camData.focalLength,
|
|
1255
|
+
filmWidth: camData.filmWidth,
|
|
1256
|
+
filmHeight: camData.filmHeight,
|
|
1257
|
+
orthoZoom: camData.orthoZoom,
|
|
1258
|
+
roll: camData.roll,
|
|
1259
|
+
aspectRatio: camData.aspectRatio,
|
|
1260
|
+
unknownProperties: camData.unknownProperties,
|
|
1261
|
+
diagnostics: camData.diagnostics,
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
if (camData.projectionType === "orthographic") {
|
|
1265
|
+
const orthoHeight = camData.orthoZoom && camData.orthoZoom > 0 ? camData.orthoZoom : 1;
|
|
1266
|
+
const aspect = camData.aspectRatio > 0 ? camData.aspectRatio : 1;
|
|
1267
|
+
camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
|
|
1268
|
+
camera.orthoTop = orthoHeight / 2;
|
|
1269
|
+
camera.orthoBottom = -orthoHeight / 2;
|
|
1270
|
+
camera.orthoRight = (orthoHeight * aspect) / 2;
|
|
1271
|
+
camera.orthoLeft = -(orthoHeight * aspect) / 2;
|
|
1272
|
+
}
|
|
1273
|
+
// FBX cameras look down their local +X axis. Derive the world-space look-at target from the
|
|
1274
|
+
// node's world matrix using point transforms so the file's handedness conversion (the
|
|
1275
|
+
// left-handed root applies scaling.z = -1) is reproduced correctly. Transforming a direction
|
|
1276
|
+
// with the rotation alone would mirror it under that reflection and aim the camera wrongly.
|
|
1277
|
+
const target = Vector3.TransformCoordinates(new Vector3(1, 0, 0), worldMatrix);
|
|
1278
|
+
camera.setTarget(target);
|
|
1279
|
+
return camera;
|
|
1280
|
+
}
|
|
1281
|
+
_createLight(lightData, modelIdToNode, scene) {
|
|
1282
|
+
const parentNode = modelIdToNode.get(lightData.modelId);
|
|
1283
|
+
const worldMatrix = parentNode ? parentNode.computeWorldMatrix(true) : Matrix.Identity();
|
|
1284
|
+
const position = Vector3.TransformCoordinates(Vector3.Zero(), worldMatrix);
|
|
1285
|
+
const color = new Color3(lightData.color[0], lightData.color[1], lightData.color[2]);
|
|
1286
|
+
// FBX lights point down their local -Z axis. Derive the world-space direction from two points
|
|
1287
|
+
// transformed by the node's world matrix so the handedness conversion (the left-handed root
|
|
1288
|
+
// applies scaling.z = -1) is reproduced correctly; transforming the direction as a normal
|
|
1289
|
+
// would mirror it under that reflection and point the light the wrong way.
|
|
1290
|
+
const forwardPoint = Vector3.TransformCoordinates(new Vector3(0, 0, -1), worldMatrix);
|
|
1291
|
+
const direction = forwardPoint.subtract(position).normalize();
|
|
1292
|
+
let light;
|
|
1293
|
+
switch (lightData.lightType) {
|
|
1294
|
+
case 1: // Directional
|
|
1295
|
+
light = new DirectionalLight(lightData.name, direction, scene);
|
|
1296
|
+
light.diffuse = color;
|
|
1297
|
+
light.intensity = lightData.intensity;
|
|
1298
|
+
break;
|
|
1299
|
+
case 2: {
|
|
1300
|
+
// Spot
|
|
1301
|
+
const angle = lightData.coneAngle * (Math.PI / 180);
|
|
1302
|
+
light = new SpotLight(lightData.name, position, direction, angle, 2, scene);
|
|
1303
|
+
light.diffuse = color;
|
|
1304
|
+
light.intensity = lightData.intensity;
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
default: // Point (0)
|
|
1308
|
+
light = new PointLight(lightData.name, position, scene);
|
|
1309
|
+
light.diffuse = color;
|
|
1310
|
+
light.intensity = lightData.intensity;
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
light.metadata = {
|
|
1314
|
+
...(light.metadata ?? {}),
|
|
1315
|
+
fbxLight: {
|
|
1316
|
+
lightType: lightData.lightType,
|
|
1317
|
+
decayType: lightData.decayType,
|
|
1318
|
+
decayStart: lightData.decayStart,
|
|
1319
|
+
innerAngle: lightData.innerAngle,
|
|
1320
|
+
outerAngle: lightData.outerAngle,
|
|
1321
|
+
enableNearAttenuation: lightData.enableNearAttenuation,
|
|
1322
|
+
enableFarAttenuation: lightData.enableFarAttenuation,
|
|
1323
|
+
castShadows: lightData.castShadows,
|
|
1324
|
+
unknownProperties: lightData.unknownProperties,
|
|
1325
|
+
diagnostics: lightData.diagnostics,
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
return light;
|
|
1329
|
+
}
|
|
1330
|
+
_createSkeleton(skeletonId, bones, scene) {
|
|
1331
|
+
const skeleton = new Skeleton("Skeleton", `skeleton_${skeletonId}`, scene);
|
|
1332
|
+
const sourceBones = [];
|
|
1333
|
+
const scaleCompensationHelpers = new Map();
|
|
1334
|
+
const authoredLocalMatrices = [];
|
|
1335
|
+
const authoredAbsoluteMatrices = [];
|
|
1336
|
+
const authoredRuntimeLocalMatrices = [];
|
|
1337
|
+
// Compute authored Lcl matrices for bones that do not carry FBX bind data.
|
|
1338
|
+
for (let i = 0; i < bones.length; i++) {
|
|
1339
|
+
const boneData = bones[i];
|
|
1340
|
+
const authoredLocal = FBXFileLoader._computeFBXLocalMatrix(boneData.translation, boneData.rotation, boneData.scale, boneData.preRotation, boneData.postRotation, boneData.rotationPivot, boneData.scalingPivot, boneData.rotationOffset, boneData.scalingOffset, boneData.rotationOrder);
|
|
1341
|
+
authoredLocalMatrices[i] = authoredLocal;
|
|
1342
|
+
authoredRuntimeLocalMatrices[i] = FBXFileLoader._computeFBXRuntimeLocalMatrix(bones, authoredLocal, i);
|
|
1343
|
+
}
|
|
1344
|
+
authoredAbsoluteMatrices.push(...FBXFileLoader._computeFBXAbsoluteMatrices(bones, authoredRuntimeLocalMatrices));
|
|
1345
|
+
const absoluteBindMatrices = bones.map((boneData, index) => boneData.transformLinkMatrix
|
|
1346
|
+
? Matrix.FromArray(boneData.transformLinkMatrix)
|
|
1347
|
+
: boneData.modelBindPoseMatrix
|
|
1348
|
+
? Matrix.FromArray(boneData.modelBindPoseMatrix)
|
|
1349
|
+
: authoredAbsoluteMatrices[index]);
|
|
1350
|
+
const localBindMatrices = absoluteBindMatrices.map((absoluteBind, index) => {
|
|
1351
|
+
const parentIndex = bones[index].parentIndex;
|
|
1352
|
+
if (parentIndex < 0) {
|
|
1353
|
+
return absoluteBind;
|
|
1354
|
+
}
|
|
1355
|
+
const parentAbsoluteBindInv = new Matrix();
|
|
1356
|
+
absoluteBindMatrices[parentIndex].invertToRef(parentAbsoluteBindInv);
|
|
1357
|
+
return absoluteBind.multiply(parentAbsoluteBindInv);
|
|
1358
|
+
});
|
|
1359
|
+
const useBindAsRest = FBXFileLoader._shouldUseBindMatricesAsRest(bones, authoredLocalMatrices, localBindMatrices);
|
|
1360
|
+
// Most animation curves naturally target authored Lcl transforms. Use
|
|
1361
|
+
// bind matrices as live rest pose only for rigs with severe bind/local
|
|
1362
|
+
// scale disagreement, which otherwise produce invalid skin matrices.
|
|
1363
|
+
// Only bones with that scale disagreement need their animation curves
|
|
1364
|
+
// remapped into bind-rest space; ordinary child curves are already in
|
|
1365
|
+
// the expected local animation space.
|
|
1366
|
+
for (let i = 0; i < bones.length; i++) {
|
|
1367
|
+
let localMatrix = useBindAsRest ? localBindMatrices[i] : authoredRuntimeLocalMatrices[i];
|
|
1368
|
+
let parentBone = bones[i].parentIndex >= 0 ? sourceBones[bones[i].parentIndex] : null;
|
|
1369
|
+
if (!useBindAsRest && bones[i].inheritType === 2 && bones[i].parentIndex >= 0 && parentBone) {
|
|
1370
|
+
const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(authoredLocalMatrices[i], bones[bones[i].parentIndex].scale);
|
|
1371
|
+
const helper = new Bone(`${bones[i].name}__fbx_scaleCompensation`, skeleton, parentBone, split.helperLocalMatrix, split.helperLocalMatrix.clone(), Matrix.Identity(), -1);
|
|
1372
|
+
helper.metadata = {
|
|
1373
|
+
...(helper.metadata ?? {}),
|
|
1374
|
+
fbxScaleCompensationForBoneIndex: i,
|
|
1375
|
+
fbxScaleCompensationForBoneName: bones[i].name,
|
|
1376
|
+
};
|
|
1377
|
+
scaleCompensationHelpers.set(i, helper);
|
|
1378
|
+
parentBone = helper;
|
|
1379
|
+
localMatrix = split.boneLocalMatrix;
|
|
1380
|
+
}
|
|
1381
|
+
const bone = new Bone(bones[i].name, skeleton, parentBone, localMatrix, useBindAsRest ? localMatrix.clone() : null, useBindAsRest ? localMatrix.clone() : null, i);
|
|
1382
|
+
if (useBindAsRest && bones[i].isCluster && FBXFileLoader._getMaxScaleRatio(authoredLocalMatrices[i], localBindMatrices[i]) >= BIND_REST_SCALE_RATIO_THRESHOLD) {
|
|
1383
|
+
this._bindRestBones.add(bone);
|
|
1384
|
+
}
|
|
1385
|
+
sourceBones.push(bone);
|
|
1386
|
+
}
|
|
1387
|
+
this._sourceBonesBySkeleton.set(skeleton, sourceBones);
|
|
1388
|
+
this._scaleCompensationHelpersBySkeleton.set(skeleton, scaleCompensationHelpers);
|
|
1389
|
+
if (!useBindAsRest) {
|
|
1390
|
+
for (let i = 0; i < bones.length; i++) {
|
|
1391
|
+
const bone = sourceBones[i];
|
|
1392
|
+
bone.updateMatrix(localBindMatrices[i], false, false);
|
|
1393
|
+
}
|
|
1394
|
+
for (const helper of Array.from(scaleCompensationHelpers.values())) {
|
|
1395
|
+
helper.updateMatrix(Matrix.Identity(), false, false);
|
|
1396
|
+
}
|
|
1397
|
+
for (const bone of skeleton.bones) {
|
|
1398
|
+
if (!bone.getParent()) {
|
|
1399
|
+
bone._updateAbsoluteBindMatrices(undefined, true);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return skeleton;
|
|
1404
|
+
}
|
|
1405
|
+
_getSourceBone(skeleton, sourceIndex) {
|
|
1406
|
+
return this._sourceBonesBySkeleton.get(skeleton)?.[sourceIndex] ?? skeleton.bones[sourceIndex];
|
|
1407
|
+
}
|
|
1408
|
+
_getScaleCompensationHelper(skeleton, sourceIndex) {
|
|
1409
|
+
return this._scaleCompensationHelpersBySkeleton.get(skeleton)?.get(sourceIndex);
|
|
1410
|
+
}
|
|
1411
|
+
static _computeFBXAbsoluteMatrices(bones, localMatrices) {
|
|
1412
|
+
const absoluteMatrices = [];
|
|
1413
|
+
for (let i = 0; i < bones.length; i++) {
|
|
1414
|
+
const parentIndex = bones[i].parentIndex;
|
|
1415
|
+
if (parentIndex < 0) {
|
|
1416
|
+
absoluteMatrices[i] = localMatrices[i].clone();
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
absoluteMatrices[i] = localMatrices[i].multiply(absoluteMatrices[parentIndex]);
|
|
1420
|
+
}
|
|
1421
|
+
return absoluteMatrices;
|
|
1422
|
+
}
|
|
1423
|
+
static _computeFBXRuntimeLocalMatrix(bones, localMatrix, index, parentScaleOverride) {
|
|
1424
|
+
const parentIndex = bones[index].parentIndex;
|
|
1425
|
+
if (bones[index].inheritType !== 2 || parentIndex < 0) {
|
|
1426
|
+
return localMatrix;
|
|
1427
|
+
}
|
|
1428
|
+
const parentScale = parentScaleOverride ?? bones[parentIndex].scale;
|
|
1429
|
+
return FBXFileLoader._applyParentScaleCompensation(localMatrix, parentScale);
|
|
1430
|
+
}
|
|
1431
|
+
static _applyParentScaleCompensation(localMatrix, parentScale) {
|
|
1432
|
+
const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale);
|
|
1433
|
+
return split.boneLocalMatrix.multiply(split.helperLocalMatrix);
|
|
1434
|
+
}
|
|
1435
|
+
static _splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale) {
|
|
1436
|
+
const translation = localMatrix.getTranslation();
|
|
1437
|
+
const boneLocalMatrix = localMatrix.clone();
|
|
1438
|
+
boneLocalMatrix.setTranslation(Vector3.Zero());
|
|
1439
|
+
const helperLocalMatrix = Matrix.Compose(FBXFileLoader._getInverseScaleVector(parentScale), Quaternion.Identity(), translation);
|
|
1440
|
+
return { boneLocalMatrix, helperLocalMatrix };
|
|
1441
|
+
}
|
|
1442
|
+
static _safeInverseScale(value) {
|
|
1443
|
+
return Math.abs(value) > 1e-8 ? 1 / value : 1;
|
|
1444
|
+
}
|
|
1445
|
+
static _getInverseScaleVector(scale) {
|
|
1446
|
+
return new Vector3(FBXFileLoader._safeInverseScale(scale[0]), FBXFileLoader._safeInverseScale(scale[1]), FBXFileLoader._safeInverseScale(scale[2]));
|
|
1447
|
+
}
|
|
1448
|
+
static _shouldUseBindMatricesAsRest(bones, authoredLocalMatrices, localBindMatrices) {
|
|
1449
|
+
return bones.some((bone, index) => {
|
|
1450
|
+
if (!bone.isCluster) {
|
|
1451
|
+
return false;
|
|
1452
|
+
}
|
|
1453
|
+
return FBXFileLoader._getMaxScaleRatio(authoredLocalMatrices[index], localBindMatrices[index]) >= BIND_REST_SCALE_RATIO_THRESHOLD;
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
static _getMaxScaleRatio(a, b) {
|
|
1457
|
+
const scaleA = new Vector3();
|
|
1458
|
+
const rotationA = new Quaternion();
|
|
1459
|
+
const translationA = new Vector3();
|
|
1460
|
+
const scaleB = new Vector3();
|
|
1461
|
+
const rotationB = new Quaternion();
|
|
1462
|
+
const translationB = new Vector3();
|
|
1463
|
+
a.decompose(scaleA, rotationA, translationA);
|
|
1464
|
+
b.decompose(scaleB, rotationB, translationB);
|
|
1465
|
+
return Math.max(FBXFileLoader._getScaleRatio(scaleA.x, scaleB.x), FBXFileLoader._getScaleRatio(scaleA.y, scaleB.y), FBXFileLoader._getScaleRatio(scaleA.z, scaleB.z));
|
|
1466
|
+
}
|
|
1467
|
+
static _getScaleRatio(a, b) {
|
|
1468
|
+
const absA = Math.abs(a);
|
|
1469
|
+
const absB = Math.abs(b);
|
|
1470
|
+
if (absA < 1e-6 || absB < 1e-6) {
|
|
1471
|
+
return absA < 1e-6 && absB < 1e-6 ? 1 : Number.POSITIVE_INFINITY;
|
|
1472
|
+
}
|
|
1473
|
+
return Math.max(absA / absB, absB / absA);
|
|
1474
|
+
}
|
|
1475
|
+
static _computeFBXGeometricMatrix(translation, rotation, scale) {
|
|
1476
|
+
return computeFBXGeometricMatrix(translation, rotation, scale);
|
|
1477
|
+
}
|
|
1478
|
+
static _computeFBXGeometricDeltaMatrix(rotation, scale) {
|
|
1479
|
+
return computeFBXGeometricDeltaMatrix(rotation, scale);
|
|
1480
|
+
}
|
|
1481
|
+
static _computeFBXGeometricNormalMatrix(rotation, scale) {
|
|
1482
|
+
return computeFBXGeometricNormalMatrix(rotation, scale);
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Compute the full FBX local transform matrix:
|
|
1486
|
+
* M = T * Roff * Rp * Rpre * R * Rpost^-1 * Rp^-1 * Soff * Sp * S * Sp^-1
|
|
1487
|
+
*
|
|
1488
|
+
* In row-vector convention: v' = v * M
|
|
1489
|
+
*/
|
|
1490
|
+
static _computeFBXLocalMatrix(translation, rotation, scale, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, rotationOrder = 0) {
|
|
1491
|
+
return computeFBXLocalMatrix({
|
|
1492
|
+
translation,
|
|
1493
|
+
rotation,
|
|
1494
|
+
scale,
|
|
1495
|
+
preRotation,
|
|
1496
|
+
postRotation,
|
|
1497
|
+
rotationPivot,
|
|
1498
|
+
scalingPivot,
|
|
1499
|
+
rotationOffset,
|
|
1500
|
+
scalingOffset,
|
|
1501
|
+
rotationOrder,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Apply the FBX transform chain to a Babylon TransformNode or Mesh.
|
|
1506
|
+
* Decomposes the full local matrix into position/rotation/scale.
|
|
1507
|
+
*/
|
|
1508
|
+
static _applyFBXTransform(node, model) {
|
|
1509
|
+
const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model);
|
|
1510
|
+
// Decompose into TRS
|
|
1511
|
+
const s = new Vector3();
|
|
1512
|
+
const r = new Quaternion();
|
|
1513
|
+
const t = new Vector3();
|
|
1514
|
+
localMatrix.decompose(s, r, t);
|
|
1515
|
+
node.position = t;
|
|
1516
|
+
node.rotationQuaternion = r;
|
|
1517
|
+
node.scaling = s;
|
|
1518
|
+
}
|
|
1519
|
+
static _computeFBXModelLocalMatrix(model) {
|
|
1520
|
+
return FBXFileLoader._computeFBXLocalMatrix(model.translation, model.rotation, model.scale, model.preRotation, model.postRotation, model.rotationPivot, model.scalingPivot, model.rotationOffset, model.scalingOffset, model.rotationOrder);
|
|
1521
|
+
}
|
|
1522
|
+
static _getBoneReferenceWorldMatrix(skeleton, bone, referenceNode, skinnedMesh) {
|
|
1523
|
+
if (skinnedMesh) {
|
|
1524
|
+
skeleton.getTransformMatrices(skinnedMesh);
|
|
1525
|
+
}
|
|
1526
|
+
else {
|
|
1527
|
+
skeleton.prepare(true);
|
|
1528
|
+
}
|
|
1529
|
+
referenceNode.computeWorldMatrix(true);
|
|
1530
|
+
return bone.getFinalMatrix().multiply(referenceNode.getWorldMatrix());
|
|
1531
|
+
}
|
|
1532
|
+
static _applyMatrixToTransform(node, matrix) {
|
|
1533
|
+
const s = new Vector3();
|
|
1534
|
+
const r = new Quaternion();
|
|
1535
|
+
const t = new Vector3();
|
|
1536
|
+
matrix.decompose(s, r, t);
|
|
1537
|
+
node.position = t;
|
|
1538
|
+
node.rotationQuaternion = r;
|
|
1539
|
+
node.scaling = s;
|
|
1540
|
+
}
|
|
1541
|
+
_createAnimationGroup(animStack, rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes) {
|
|
1542
|
+
if (animStack.curveNodes.length === 0) {
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
const animGroup = new AnimationGroup(animStack.name, scene);
|
|
1546
|
+
// Build a map from model ID to resolved rig bones. A single FBX model ID
|
|
1547
|
+
// should only appear once per resolved rig, but keeping an array preserves
|
|
1548
|
+
// the previous animation fan-out behavior for any future duplicate rigs.
|
|
1549
|
+
const modelIdToBones = new Map();
|
|
1550
|
+
for (const rig of rigs) {
|
|
1551
|
+
const skeleton = skeletonByRigId.get(rig.id);
|
|
1552
|
+
if (!skeleton) {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
for (const boneData of rig.bones) {
|
|
1556
|
+
const bone = this._getSourceBone(skeleton, boneData.index);
|
|
1557
|
+
if (!bone) {
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
const bones = modelIdToBones.get(boneData.modelId);
|
|
1561
|
+
if (bones) {
|
|
1562
|
+
bones.push(bone);
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
modelIdToBones.set(boneData.modelId, [bone]);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
// Group curve nodes by target
|
|
1570
|
+
const boneCurves = new Map();
|
|
1571
|
+
const nonBoneCurves = new Map();
|
|
1572
|
+
const blendShapeCurves = [];
|
|
1573
|
+
for (const curveNode of animStack.curveNodes) {
|
|
1574
|
+
if (curveNode.type === "DeformPercent") {
|
|
1575
|
+
blendShapeCurves.push(curveNode);
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (modelIdToBones.has(curveNode.targetModelId)) {
|
|
1579
|
+
if (!boneCurves.has(curveNode.targetModelId)) {
|
|
1580
|
+
boneCurves.set(curveNode.targetModelId, []);
|
|
1581
|
+
}
|
|
1582
|
+
boneCurves.get(curveNode.targetModelId).push(curveNode);
|
|
1583
|
+
}
|
|
1584
|
+
else {
|
|
1585
|
+
if (!nonBoneCurves.has(curveNode.targetModelId)) {
|
|
1586
|
+
nonBoneCurves.set(curveNode.targetModelId, []);
|
|
1587
|
+
}
|
|
1588
|
+
nonBoneCurves.get(curveNode.targetModelId).push(curveNode);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// Process bone targets: compute full FBX local matrix per frame, decompose to TRS.
|
|
1592
|
+
// For bind-rest rigs, only the bones recorded in _bindRestBones need their
|
|
1593
|
+
// authored Lcl curves remapped onto the bind-rest local space.
|
|
1594
|
+
const inheritedRigModelIds = new Set();
|
|
1595
|
+
for (const rig of rigs) {
|
|
1596
|
+
const inheritType2ModelIds = new Set(rig.bones.filter((bone) => bone.inheritType === 2).map((bone) => bone.modelId));
|
|
1597
|
+
if (inheritType2ModelIds.size === 0) {
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
const skeleton = skeletonByRigId.get(rig.id);
|
|
1601
|
+
if (!skeleton) {
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (skeleton.bones.some((bone) => this._bindRestBones.has(bone))) {
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
for (const modelId of Array.from(inheritType2ModelIds)) {
|
|
1608
|
+
inheritedRigModelIds.add(modelId);
|
|
1609
|
+
}
|
|
1610
|
+
for (const { bone, animations } of this._buildInheritedRigBoneAnimations(rig, skeleton, boneCurves, modelIdToData, inheritType2ModelIds, animStack.startTime, animStack.stopTime)) {
|
|
1611
|
+
for (const animation of animations) {
|
|
1612
|
+
animGroup.addTargetedAnimation(animation, bone);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
for (const [targetId, curveNodes] of Array.from(boneCurves)) {
|
|
1617
|
+
if (inheritedRigModelIds.has(targetId)) {
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
const bones = modelIdToBones.get(targetId);
|
|
1621
|
+
const modelData = modelIdToData.get(targetId);
|
|
1622
|
+
if (!bones || bones.length === 0 || !modelData) {
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
for (const bone of bones) {
|
|
1626
|
+
const animations = this._buildBoneAnimations(curveNodes, bone.name, modelData, animStack.startTime, animStack.stopTime, this._bindRestBones.has(bone) ? bone.getBindMatrix() : undefined);
|
|
1627
|
+
for (const animation of animations) {
|
|
1628
|
+
animGroup.addTargetedAnimation(animation, bone);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// Process non-bone targets: bake full transform matrix per frame
|
|
1633
|
+
for (const [targetId, curveNodes] of Array.from(nonBoneCurves)) {
|
|
1634
|
+
const node = modelIdToNode.get(targetId);
|
|
1635
|
+
if (!node) {
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
const modelData = modelIdToData.get(targetId);
|
|
1639
|
+
if (!modelData) {
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
const animations = this._buildNodeAnimations(curveNodes, node.name, modelData, animStack.startTime, animStack.stopTime);
|
|
1643
|
+
for (const animation of animations) {
|
|
1644
|
+
animGroup.addTargetedAnimation(animation, node);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
// Process blend shape (morph target) animations
|
|
1648
|
+
for (const curveNode of blendShapeCurves) {
|
|
1649
|
+
const targetChannelId = curveNode.targetModelId;
|
|
1650
|
+
// Find the morph target with matching channel ID across all meshes
|
|
1651
|
+
let targetFound = false;
|
|
1652
|
+
for (const mesh of meshes) {
|
|
1653
|
+
if (!mesh.morphTargetManager || targetFound) {
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
const metadata = mesh.metadata;
|
|
1657
|
+
const channelTargets = metadata?.fbxBlendShapeChannelTargets;
|
|
1658
|
+
const targetInfo = channelTargets?.get(targetChannelId);
|
|
1659
|
+
if (targetInfo && curveNode.curves.length > 0) {
|
|
1660
|
+
const fps = 30;
|
|
1661
|
+
for (let shapeIndex = 0; shapeIndex < targetInfo.targetIndices.length; shapeIndex++) {
|
|
1662
|
+
const target = mesh.morphTargetManager.getTarget(targetInfo.targetIndices[shapeIndex]);
|
|
1663
|
+
if (!target) {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1667
|
+
const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => calculateBlendShapeInfluences(value, targetInfo.fullWeights, targetInfo.targetIndices.length)[shapeIndex] ?? 0);
|
|
1668
|
+
anim.setKeys(keys);
|
|
1669
|
+
animGroup.addTargetedAnimation(anim, target);
|
|
1670
|
+
}
|
|
1671
|
+
targetFound = true;
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
const channelMap = metadata?.fbxBlendShapeChannelIds;
|
|
1675
|
+
if (!channelMap) {
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
const targetIndex = channelMap.get(targetChannelId);
|
|
1679
|
+
if (targetIndex === undefined) {
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
const target = mesh.morphTargetManager.getTarget(targetIndex);
|
|
1683
|
+
if (target && curveNode.curves.length > 0) {
|
|
1684
|
+
const fps = 30;
|
|
1685
|
+
const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1686
|
+
const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => value / 100);
|
|
1687
|
+
anim.setKeys(keys);
|
|
1688
|
+
animGroup.addTargetedAnimation(anim, target);
|
|
1689
|
+
targetFound = true;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
// Normalize the animation group
|
|
1694
|
+
if (animGroup.targetedAnimations.length > 0) {
|
|
1695
|
+
animGroup.normalize(animStack.startTime * 30, animStack.stopTime * 30);
|
|
1696
|
+
return animGroup;
|
|
1697
|
+
}
|
|
1698
|
+
animGroup.dispose();
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
_buildInheritedRigBoneAnimations(rig, skeleton, boneCurves, modelIdToData, compensatedModelIds, startTime, stopTime) {
|
|
1702
|
+
const fps = 30;
|
|
1703
|
+
const sampledModelIds = new Set();
|
|
1704
|
+
for (let i = 0; i < rig.bones.length; i++) {
|
|
1705
|
+
if (!compensatedModelIds.has(rig.bones[i].modelId)) {
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
for (let parentIndex = i; parentIndex >= 0; parentIndex = rig.bones[parentIndex].parentIndex) {
|
|
1709
|
+
sampledModelIds.add(rig.bones[parentIndex].modelId);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
const rigCurveNodes = rig.bones.filter((bone) => sampledModelIds.has(bone.modelId)).flatMap((bone) => boneCurves.get(bone.modelId) ?? []);
|
|
1713
|
+
const times = collectAnimationSampleTimes(rigCurveNodes, fps, startTime, stopTime);
|
|
1714
|
+
if (times.length === 0) {
|
|
1715
|
+
return [];
|
|
1716
|
+
}
|
|
1717
|
+
const keysByBone = rig.bones.map(() => ({
|
|
1718
|
+
posKeys: [],
|
|
1719
|
+
rotKeys: [],
|
|
1720
|
+
sclKeys: [],
|
|
1721
|
+
prevQuat: null,
|
|
1722
|
+
}));
|
|
1723
|
+
const keysByHelper = rig.bones.map(() => ({
|
|
1724
|
+
posKeys: [],
|
|
1725
|
+
rotKeys: [],
|
|
1726
|
+
sclKeys: [],
|
|
1727
|
+
prevQuat: null,
|
|
1728
|
+
}));
|
|
1729
|
+
const restLocalInverses = rig.bones.map((boneData, index) => {
|
|
1730
|
+
const bone = this._getSourceBone(skeleton, index);
|
|
1731
|
+
const modelData = modelIdToData.get(boneData.modelId);
|
|
1732
|
+
if (!bone || !modelData || !this._bindRestBones.has(bone)) {
|
|
1733
|
+
return null;
|
|
1734
|
+
}
|
|
1735
|
+
const restLocalMatrix = FBXFileLoader._computeFBXModelLocalMatrix(modelData);
|
|
1736
|
+
const restLocalInverse = new Matrix();
|
|
1737
|
+
restLocalMatrix.invertToRef(restLocalInverse);
|
|
1738
|
+
return restLocalInverse;
|
|
1739
|
+
});
|
|
1740
|
+
for (const time of times) {
|
|
1741
|
+
const localMatrices = rig.bones.map((boneData, index) => {
|
|
1742
|
+
const modelData = modelIdToData.get(boneData.modelId);
|
|
1743
|
+
const curveNodes = boneCurves.get(boneData.modelId) ?? [];
|
|
1744
|
+
let localMatrix = modelData ? this._sampleModelLocalMatrix(modelData, curveNodes, time) : Matrix.Identity();
|
|
1745
|
+
const restLocalInverse = restLocalInverses[index];
|
|
1746
|
+
if (restLocalInverse) {
|
|
1747
|
+
const sourceBone = this._getSourceBone(skeleton, index);
|
|
1748
|
+
localMatrix = (sourceBone?.getBindMatrix() ?? Matrix.Identity()).multiply(restLocalInverse).multiply(localMatrix);
|
|
1749
|
+
}
|
|
1750
|
+
return localMatrix;
|
|
1751
|
+
});
|
|
1752
|
+
const sampledScales = rig.bones.map((boneData) => {
|
|
1753
|
+
const modelData = modelIdToData.get(boneData.modelId);
|
|
1754
|
+
const curveNodes = boneCurves.get(boneData.modelId) ?? [];
|
|
1755
|
+
return modelData ? this._sampleModelScale(modelData, curveNodes, time) : boneData.scale;
|
|
1756
|
+
});
|
|
1757
|
+
const frame = time * fps;
|
|
1758
|
+
for (let i = 0; i < localMatrices.length; i++) {
|
|
1759
|
+
if (!compensatedModelIds.has(rig.bones[i].modelId)) {
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1762
|
+
const parentIndex = rig.bones[i].parentIndex;
|
|
1763
|
+
const parentScale = parentIndex >= 0 ? sampledScales[parentIndex] : rig.bones[i].scale;
|
|
1764
|
+
const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrices[i], parentScale);
|
|
1765
|
+
FBXFileLoader._pushMatrixKeys(keysByBone[i], frame, split.boneLocalMatrix);
|
|
1766
|
+
FBXFileLoader._pushMatrixKeys(keysByHelper[i], frame, split.helperLocalMatrix);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const result = [];
|
|
1770
|
+
for (let i = 0; i < rig.bones.length; i++) {
|
|
1771
|
+
if (!compensatedModelIds.has(rig.bones[i].modelId)) {
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
const bone = this._getSourceBone(skeleton, i);
|
|
1775
|
+
if (!bone) {
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
const { posKeys, rotKeys, sclKeys } = keysByBone[i];
|
|
1779
|
+
const animations = [];
|
|
1780
|
+
if (!this._isVector3KeysConstant(posKeys)) {
|
|
1781
|
+
const posAnim = new Animation(`${bone.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1782
|
+
posAnim.setKeys(posKeys);
|
|
1783
|
+
animations.push(posAnim);
|
|
1784
|
+
}
|
|
1785
|
+
if (!areQuaternionKeysConstant(rotKeys)) {
|
|
1786
|
+
const rotAnim = new Animation(`${bone.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1787
|
+
rotAnim.setKeys(rotKeys);
|
|
1788
|
+
animations.push(rotAnim);
|
|
1789
|
+
}
|
|
1790
|
+
if (!this._isVector3KeysConstant(sclKeys)) {
|
|
1791
|
+
const sclAnim = new Animation(`${bone.name}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1792
|
+
sclAnim.setKeys(sclKeys);
|
|
1793
|
+
animations.push(sclAnim);
|
|
1794
|
+
}
|
|
1795
|
+
if (animations.length > 0) {
|
|
1796
|
+
result.push({ bone, animations });
|
|
1797
|
+
}
|
|
1798
|
+
const helper = this._getScaleCompensationHelper(skeleton, i);
|
|
1799
|
+
if (!helper) {
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
const helperAnimations = [];
|
|
1803
|
+
const { posKeys: helperPosKeys, rotKeys: helperRotKeys, sclKeys: helperSclKeys } = keysByHelper[i];
|
|
1804
|
+
if (!this._isVector3KeysConstant(helperPosKeys)) {
|
|
1805
|
+
const posAnim = new Animation(`${helper.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1806
|
+
posAnim.setKeys(helperPosKeys);
|
|
1807
|
+
helperAnimations.push(posAnim);
|
|
1808
|
+
}
|
|
1809
|
+
if (!areQuaternionKeysConstant(helperRotKeys)) {
|
|
1810
|
+
const rotAnim = new Animation(`${helper.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1811
|
+
rotAnim.setKeys(helperRotKeys);
|
|
1812
|
+
helperAnimations.push(rotAnim);
|
|
1813
|
+
}
|
|
1814
|
+
if (!this._isVector3KeysConstant(helperSclKeys)) {
|
|
1815
|
+
const sclAnim = new Animation(`${helper.name}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1816
|
+
sclAnim.setKeys(helperSclKeys);
|
|
1817
|
+
helperAnimations.push(sclAnim);
|
|
1818
|
+
}
|
|
1819
|
+
if (helperAnimations.length > 0) {
|
|
1820
|
+
result.push({ bone: helper, animations: helperAnimations });
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return result;
|
|
1824
|
+
}
|
|
1825
|
+
static _pushMatrixKeys(keySet, frame, matrix) {
|
|
1826
|
+
const s = new Vector3();
|
|
1827
|
+
const r = new Quaternion();
|
|
1828
|
+
const t = new Vector3();
|
|
1829
|
+
matrix.decompose(s, r, t);
|
|
1830
|
+
if (keySet.prevQuat && Quaternion.Dot(keySet.prevQuat, r) < 0) {
|
|
1831
|
+
r.scaleInPlace(-1);
|
|
1832
|
+
}
|
|
1833
|
+
keySet.prevQuat = r;
|
|
1834
|
+
keySet.posKeys.push({ frame, value: t });
|
|
1835
|
+
keySet.rotKeys.push({ frame, value: r });
|
|
1836
|
+
keySet.sclKeys.push({ frame, value: s });
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Build animations for a non-bone node, correctly handling pivots.
|
|
1840
|
+
* Computes the full FBX transform matrix at each keyframe and decomposes into TRS.
|
|
1841
|
+
*/
|
|
1842
|
+
_buildNodeAnimations(curveNodes, nodeName, modelData, startTime, stopTime) {
|
|
1843
|
+
const fps = 30;
|
|
1844
|
+
// Separate curves by type
|
|
1845
|
+
const tNode = curveNodes.find((cn) => cn.type === "T");
|
|
1846
|
+
const rNode = curveNodes.find((cn) => cn.type === "R");
|
|
1847
|
+
const sNode = curveNodes.find((cn) => cn.type === "S");
|
|
1848
|
+
const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
|
|
1849
|
+
if (times.length === 0) {
|
|
1850
|
+
return [];
|
|
1851
|
+
}
|
|
1852
|
+
// Get curve accessors
|
|
1853
|
+
const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
|
|
1854
|
+
const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
|
|
1855
|
+
const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
|
|
1856
|
+
const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
|
|
1857
|
+
const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
|
|
1858
|
+
const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
|
|
1859
|
+
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
|
|
1860
|
+
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
|
|
1861
|
+
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
|
|
1862
|
+
// Build keyframes by computing the full matrix at each time
|
|
1863
|
+
const posKeys = [];
|
|
1864
|
+
const rotKeys = [];
|
|
1865
|
+
const sclKeys = [];
|
|
1866
|
+
let prevQuat = null;
|
|
1867
|
+
for (const time of times) {
|
|
1868
|
+
const frame = time * fps;
|
|
1869
|
+
// Sample animated values, falling back to model's base values
|
|
1870
|
+
const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0];
|
|
1871
|
+
const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1];
|
|
1872
|
+
const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2];
|
|
1873
|
+
const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0];
|
|
1874
|
+
const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1];
|
|
1875
|
+
const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2];
|
|
1876
|
+
const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0];
|
|
1877
|
+
const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1];
|
|
1878
|
+
const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2];
|
|
1879
|
+
// Compute the full FBX local transform matrix with pivots
|
|
1880
|
+
const localMatrix = FBXFileLoader._computeFBXLocalMatrix([tx, ty, tz], [rx, ry, rz], [sx, sy, sz], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
|
|
1881
|
+
// Decompose into TRS
|
|
1882
|
+
const s = new Vector3();
|
|
1883
|
+
const r = new Quaternion();
|
|
1884
|
+
const t = new Vector3();
|
|
1885
|
+
localMatrix.decompose(s, r, t);
|
|
1886
|
+
// Ensure quaternion continuity
|
|
1887
|
+
if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) {
|
|
1888
|
+
r.scaleInPlace(-1);
|
|
1889
|
+
}
|
|
1890
|
+
prevQuat = r;
|
|
1891
|
+
posKeys.push({ frame, value: t });
|
|
1892
|
+
rotKeys.push({ frame, value: r });
|
|
1893
|
+
sclKeys.push({ frame, value: s });
|
|
1894
|
+
}
|
|
1895
|
+
const animations = [];
|
|
1896
|
+
// Only create position animation if it's not constant
|
|
1897
|
+
if (!this._isVector3KeysConstant(posKeys)) {
|
|
1898
|
+
const posAnim = new Animation(`${nodeName}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1899
|
+
posAnim.setKeys(posKeys);
|
|
1900
|
+
animations.push(posAnim);
|
|
1901
|
+
}
|
|
1902
|
+
// Always create rotation animation (if there are rotation curves)
|
|
1903
|
+
if (rNode) {
|
|
1904
|
+
const rotAnim = new Animation(`${nodeName}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1905
|
+
rotAnim.setKeys(rotKeys);
|
|
1906
|
+
animations.push(rotAnim);
|
|
1907
|
+
}
|
|
1908
|
+
// Only create scale animation if it's not constant
|
|
1909
|
+
if (!this._isVector3KeysConstant(sclKeys)) {
|
|
1910
|
+
const sclAnim = new Animation(`${nodeName}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
1911
|
+
sclAnim.setKeys(sclKeys);
|
|
1912
|
+
animations.push(sclAnim);
|
|
1913
|
+
}
|
|
1914
|
+
return animations;
|
|
1915
|
+
}
|
|
1916
|
+
_isVector3KeysConstant(keys) {
|
|
1917
|
+
if (keys.length < 2) {
|
|
1918
|
+
return true;
|
|
1919
|
+
}
|
|
1920
|
+
const first = keys[0].value;
|
|
1921
|
+
for (let i = 1; i < keys.length; i++) {
|
|
1922
|
+
const v = keys[i].value;
|
|
1923
|
+
if (Math.abs(v.x - first.x) > 0.0001 || Math.abs(v.y - first.y) > 0.0001 || Math.abs(v.z - first.z) > 0.0001) {
|
|
1924
|
+
return false;
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
return true;
|
|
1928
|
+
}
|
|
1929
|
+
_sampleModelLocalMatrix(modelData, curveNodes, time, scaleOverride) {
|
|
1930
|
+
const tNode = curveNodes.find((cn) => cn.type === "T");
|
|
1931
|
+
const rNode = curveNodes.find((cn) => cn.type === "R");
|
|
1932
|
+
const sNode = curveNodes.find((cn) => cn.type === "S");
|
|
1933
|
+
const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
|
|
1934
|
+
const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
|
|
1935
|
+
const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
|
|
1936
|
+
const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
|
|
1937
|
+
const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
|
|
1938
|
+
const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
|
|
1939
|
+
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
|
|
1940
|
+
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
|
|
1941
|
+
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
|
|
1942
|
+
return FBXFileLoader._computeFBXLocalMatrix([
|
|
1943
|
+
sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0],
|
|
1944
|
+
sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1],
|
|
1945
|
+
sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2],
|
|
1946
|
+
], [
|
|
1947
|
+
sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0],
|
|
1948
|
+
sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1],
|
|
1949
|
+
sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2],
|
|
1950
|
+
], scaleOverride ?? [
|
|
1951
|
+
sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0],
|
|
1952
|
+
sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1],
|
|
1953
|
+
sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2],
|
|
1954
|
+
], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
|
|
1955
|
+
}
|
|
1956
|
+
_sampleModelScale(modelData, curveNodes, time) {
|
|
1957
|
+
const sNode = curveNodes.find((cn) => cn.type === "S");
|
|
1958
|
+
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
|
|
1959
|
+
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
|
|
1960
|
+
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
|
|
1961
|
+
return [
|
|
1962
|
+
sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0],
|
|
1963
|
+
sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1],
|
|
1964
|
+
sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2],
|
|
1965
|
+
];
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Build matrix-baked bone animation from full FBX local transforms.
|
|
1969
|
+
* The bind matrix carries the skinning offset, so animation curves drive
|
|
1970
|
+
* the same FBX local transform chain as the source skeleton.
|
|
1971
|
+
*/
|
|
1972
|
+
_buildBoneAnimations(curveNodes, boneName, modelData, startTime, stopTime, bindLocalMatrix) {
|
|
1973
|
+
const fps = 30;
|
|
1974
|
+
// Separate curves by type
|
|
1975
|
+
const tNode = curveNodes.find((cn) => cn.type === "T");
|
|
1976
|
+
const rNode = curveNodes.find((cn) => cn.type === "R");
|
|
1977
|
+
const sNode = curveNodes.find((cn) => cn.type === "S");
|
|
1978
|
+
const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
|
|
1979
|
+
if (times.length === 0) {
|
|
1980
|
+
return [];
|
|
1981
|
+
}
|
|
1982
|
+
// Get curve accessors
|
|
1983
|
+
const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
|
|
1984
|
+
const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
|
|
1985
|
+
const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
|
|
1986
|
+
const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
|
|
1987
|
+
const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
|
|
1988
|
+
const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
|
|
1989
|
+
const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
|
|
1990
|
+
const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
|
|
1991
|
+
const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
|
|
1992
|
+
const posKeys = [];
|
|
1993
|
+
const rotKeys = [];
|
|
1994
|
+
const sclKeys = [];
|
|
1995
|
+
let prevQuat = null;
|
|
1996
|
+
let restLocalInverse = null;
|
|
1997
|
+
if (bindLocalMatrix) {
|
|
1998
|
+
const restLocalMatrix = FBXFileLoader._computeFBXLocalMatrix(modelData.translation, modelData.rotation, modelData.scale, modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
|
|
1999
|
+
restLocalInverse = new Matrix();
|
|
2000
|
+
restLocalMatrix.invertToRef(restLocalInverse);
|
|
2001
|
+
}
|
|
2002
|
+
for (const time of times) {
|
|
2003
|
+
const frame = time * fps;
|
|
2004
|
+
// Sample animated values, falling back to model's base values
|
|
2005
|
+
const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0];
|
|
2006
|
+
const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1];
|
|
2007
|
+
const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2];
|
|
2008
|
+
const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0];
|
|
2009
|
+
const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1];
|
|
2010
|
+
const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2];
|
|
2011
|
+
const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0];
|
|
2012
|
+
const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1];
|
|
2013
|
+
const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2];
|
|
2014
|
+
// Compute the full FBX local matrix from animated Lcl values
|
|
2015
|
+
const localMatrix = FBXFileLoader._computeFBXLocalMatrix([tx, ty, tz], [rx, ry, rz], [sx, sy, sz], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
|
|
2016
|
+
const correctedLocalMatrix = restLocalInverse && bindLocalMatrix ? bindLocalMatrix.multiply(restLocalInverse).multiply(localMatrix) : localMatrix;
|
|
2017
|
+
const s = new Vector3();
|
|
2018
|
+
const r = new Quaternion();
|
|
2019
|
+
const t = new Vector3();
|
|
2020
|
+
correctedLocalMatrix.decompose(s, r, t);
|
|
2021
|
+
if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) {
|
|
2022
|
+
r.scaleInPlace(-1);
|
|
2023
|
+
}
|
|
2024
|
+
prevQuat = r;
|
|
2025
|
+
posKeys.push({ frame, value: t });
|
|
2026
|
+
rotKeys.push({ frame, value: r });
|
|
2027
|
+
sclKeys.push({ frame, value: s });
|
|
2028
|
+
}
|
|
2029
|
+
const animations = [];
|
|
2030
|
+
if (!this._isVector3KeysConstant(posKeys)) {
|
|
2031
|
+
const posAnim = new Animation(`${boneName}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
2032
|
+
posAnim.setKeys(posKeys);
|
|
2033
|
+
animations.push(posAnim);
|
|
2034
|
+
}
|
|
2035
|
+
if (rNode) {
|
|
2036
|
+
const rotAnim = new Animation(`${boneName}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
2037
|
+
rotAnim.setKeys(rotKeys);
|
|
2038
|
+
animations.push(rotAnim);
|
|
2039
|
+
}
|
|
2040
|
+
if (!this._isVector3KeysConstant(sclKeys)) {
|
|
2041
|
+
const sclAnim = new Animation(`${boneName}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
|
|
2042
|
+
sclAnim.setKeys(sclKeys);
|
|
2043
|
+
animations.push(sclAnim);
|
|
2044
|
+
}
|
|
2045
|
+
return animations;
|
|
2046
|
+
}
|
|
2047
|
+
_buildNameFilter(meshesNames) {
|
|
2048
|
+
if (!meshesNames) {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
if (typeof meshesNames === "string") {
|
|
2052
|
+
if (meshesNames === "") {
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
return (name) => name === meshesNames;
|
|
2056
|
+
}
|
|
2057
|
+
if (meshesNames.length === 0) {
|
|
2058
|
+
return null;
|
|
2059
|
+
}
|
|
2060
|
+
const nameSet = new Set(meshesNames);
|
|
2061
|
+
return (name) => nameSet.has(name);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
function float64To32(arr) {
|
|
2065
|
+
const result = new Float32Array(arr.length);
|
|
2066
|
+
for (let i = 0; i < arr.length; i++) {
|
|
2067
|
+
result[i] = arr[i];
|
|
2068
|
+
}
|
|
2069
|
+
return result;
|
|
2070
|
+
}
|
|
2071
|
+
function applyTangentHandednessScale(tangents, scale) {
|
|
2072
|
+
if (scale === 1) {
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
for (let i = 3; i < tangents.length; i += 4) {
|
|
2076
|
+
tangents[i] *= scale;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
function generateTangents(positions, normals, uvs, indices, normalMapTangentHandednessScale = 1, controlPointIndices = null, materialIndices = null) {
|
|
2080
|
+
const vertexCount = positions.length / 3;
|
|
2081
|
+
const groups = new Map();
|
|
2082
|
+
const vertexGroupKeys = new Array(vertexCount).fill(null);
|
|
2083
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
2084
|
+
const materialIndex = materialIndices ? materialIndices[i / 3] : 0;
|
|
2085
|
+
const i1 = indices[i];
|
|
2086
|
+
const i2 = indices[i + 1];
|
|
2087
|
+
const i3 = indices[i + 2];
|
|
2088
|
+
const p1 = i1 * 3;
|
|
2089
|
+
const p2 = i2 * 3;
|
|
2090
|
+
const p3 = i3 * 3;
|
|
2091
|
+
const uv1 = i1 * 2;
|
|
2092
|
+
const uv2 = i2 * 2;
|
|
2093
|
+
const uv3 = i3 * 2;
|
|
2094
|
+
const x1 = positions[p2] - positions[p1];
|
|
2095
|
+
const x2 = positions[p3] - positions[p1];
|
|
2096
|
+
const y1 = positions[p2 + 1] - positions[p1 + 1];
|
|
2097
|
+
const y2 = positions[p3 + 1] - positions[p1 + 1];
|
|
2098
|
+
const z1 = positions[p2 + 2] - positions[p1 + 2];
|
|
2099
|
+
const z2 = positions[p3 + 2] - positions[p1 + 2];
|
|
2100
|
+
const s1 = uvs[uv2] - uvs[uv1];
|
|
2101
|
+
const s2 = uvs[uv3] - uvs[uv1];
|
|
2102
|
+
const t1 = uvs[uv2 + 1] - uvs[uv1 + 1];
|
|
2103
|
+
const t2 = uvs[uv3 + 1] - uvs[uv1 + 1];
|
|
2104
|
+
const denominator = s1 * t2 - s2 * t1;
|
|
2105
|
+
if (Math.abs(denominator) < 1e-8) {
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
const r = 1 / denominator;
|
|
2109
|
+
const sx = (t2 * x1 - t1 * x2) * r;
|
|
2110
|
+
const sy = (t2 * y1 - t1 * y2) * r;
|
|
2111
|
+
const sz = (t2 * z1 - t1 * z2) * r;
|
|
2112
|
+
const bx = (s1 * x2 - s2 * x1) * r;
|
|
2113
|
+
const by = (s1 * y2 - s2 * y1) * r;
|
|
2114
|
+
const bz = (s1 * z2 - s2 * z1) * r;
|
|
2115
|
+
accumulateTangentContribution(i1, i2, i3, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys);
|
|
2116
|
+
accumulateTangentContribution(i2, i3, i1, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys);
|
|
2117
|
+
accumulateTangentContribution(i3, i1, i2, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys);
|
|
2118
|
+
}
|
|
2119
|
+
const tangents = new Float32Array(vertexCount * 4);
|
|
2120
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
2121
|
+
const no = i * 3;
|
|
2122
|
+
const to = i * 4;
|
|
2123
|
+
const [nx, ny, nz] = normalizeVector(normals[no], normals[no + 1], normals[no + 2]);
|
|
2124
|
+
const group = vertexGroupKeys[i] ? groups.get(vertexGroupKeys[i]) : undefined;
|
|
2125
|
+
const tx = group?.tx ?? 0;
|
|
2126
|
+
const ty = group?.ty ?? 0;
|
|
2127
|
+
const tz = group?.tz ?? 0;
|
|
2128
|
+
const normalDotTangent = nx * tx + ny * ty + nz * tz;
|
|
2129
|
+
let ox = tx - nx * normalDotTangent;
|
|
2130
|
+
let oy = ty - ny * normalDotTangent;
|
|
2131
|
+
let oz = tz - nz * normalDotTangent;
|
|
2132
|
+
const tangentLength = Math.hypot(ox, oy, oz);
|
|
2133
|
+
if (tangentLength > 1e-8) {
|
|
2134
|
+
ox /= tangentLength;
|
|
2135
|
+
oy /= tangentLength;
|
|
2136
|
+
oz /= tangentLength;
|
|
2137
|
+
}
|
|
2138
|
+
else {
|
|
2139
|
+
[ox, oy, oz] = buildFallbackTangent(nx, ny, nz);
|
|
2140
|
+
}
|
|
2141
|
+
const bx = group?.bx ?? 0;
|
|
2142
|
+
const by = group?.by ?? 0;
|
|
2143
|
+
const bz = group?.bz ?? 0;
|
|
2144
|
+
const cx = ny * oz - nz * oy;
|
|
2145
|
+
const cy = nz * ox - nx * oz;
|
|
2146
|
+
const cz = nx * oy - ny * ox;
|
|
2147
|
+
const bitangentLength = Math.hypot(bx, by, bz);
|
|
2148
|
+
const handedness = bitangentLength > 1e-8 && cx * bx + cy * by + cz * bz < 0 ? -1 : 1;
|
|
2149
|
+
tangents[to] = ox;
|
|
2150
|
+
tangents[to + 1] = oy;
|
|
2151
|
+
tangents[to + 2] = oz;
|
|
2152
|
+
tangents[to + 3] = handedness * normalMapTangentHandednessScale;
|
|
2153
|
+
}
|
|
2154
|
+
return tangents;
|
|
2155
|
+
}
|
|
2156
|
+
function accumulateTangentContribution(vertexIndex, nextIndex, prevIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys) {
|
|
2157
|
+
const weight = computeCornerAngle(positions, vertexIndex, nextIndex, prevIndex);
|
|
2158
|
+
if (weight <= 1e-8) {
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
const key = buildTangentGroupKey(vertexIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex);
|
|
2162
|
+
let group = groups.get(key);
|
|
2163
|
+
if (!group) {
|
|
2164
|
+
group = { tx: 0, ty: 0, tz: 0, bx: 0, by: 0, bz: 0 };
|
|
2165
|
+
groups.set(key, group);
|
|
2166
|
+
}
|
|
2167
|
+
group.tx += tx * weight;
|
|
2168
|
+
group.ty += ty * weight;
|
|
2169
|
+
group.tz += tz * weight;
|
|
2170
|
+
group.bx += bx * weight;
|
|
2171
|
+
group.by += by * weight;
|
|
2172
|
+
group.bz += bz * weight;
|
|
2173
|
+
vertexGroupKeys[vertexIndex] ?? (vertexGroupKeys[vertexIndex] = key);
|
|
2174
|
+
}
|
|
2175
|
+
function buildTangentGroupKey(vertexIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex) {
|
|
2176
|
+
const po = vertexIndex * 3;
|
|
2177
|
+
const no = vertexIndex * 3;
|
|
2178
|
+
const uo = vertexIndex * 2;
|
|
2179
|
+
const [nx, ny, nz] = normalizeVector(normals[no], normals[no + 1], normals[no + 2]);
|
|
2180
|
+
const handedness = computeTangentHandedness(nx, ny, nz, tx, ty, tz, bx, by, bz);
|
|
2181
|
+
const positionKey = controlPointIndices
|
|
2182
|
+
? `cp:${controlPointIndices[vertexIndex]}`
|
|
2183
|
+
: `p:${quantizeTangentKey(positions[po])},${quantizeTangentKey(positions[po + 1])},${quantizeTangentKey(positions[po + 2])}`;
|
|
2184
|
+
return [
|
|
2185
|
+
positionKey,
|
|
2186
|
+
quantizeTangentKey(nx),
|
|
2187
|
+
quantizeTangentKey(ny),
|
|
2188
|
+
quantizeTangentKey(nz),
|
|
2189
|
+
quantizeTangentKey(uvs[uo]),
|
|
2190
|
+
quantizeTangentKey(uvs[uo + 1]),
|
|
2191
|
+
handedness,
|
|
2192
|
+
materialIndex,
|
|
2193
|
+
].join("|");
|
|
2194
|
+
}
|
|
2195
|
+
function computeTangentHandedness(nx, ny, nz, tx, ty, tz, bx, by, bz) {
|
|
2196
|
+
const cx = ny * tz - nz * ty;
|
|
2197
|
+
const cy = nz * tx - nx * tz;
|
|
2198
|
+
const cz = nx * ty - ny * tx;
|
|
2199
|
+
return cx * bx + cy * by + cz * bz < 0 ? -1 : 1;
|
|
2200
|
+
}
|
|
2201
|
+
function computeCornerAngle(positions, vertexIndex, nextIndex, prevIndex) {
|
|
2202
|
+
const vo = vertexIndex * 3;
|
|
2203
|
+
const no = nextIndex * 3;
|
|
2204
|
+
const po = prevIndex * 3;
|
|
2205
|
+
const ax = positions[no] - positions[vo];
|
|
2206
|
+
const ay = positions[no + 1] - positions[vo + 1];
|
|
2207
|
+
const az = positions[no + 2] - positions[vo + 2];
|
|
2208
|
+
const bx = positions[po] - positions[vo];
|
|
2209
|
+
const by = positions[po + 1] - positions[vo + 1];
|
|
2210
|
+
const bz = positions[po + 2] - positions[vo + 2];
|
|
2211
|
+
const aLength = Math.hypot(ax, ay, az);
|
|
2212
|
+
const bLength = Math.hypot(bx, by, bz);
|
|
2213
|
+
if (aLength <= 1e-8 || bLength <= 1e-8) {
|
|
2214
|
+
return 0;
|
|
2215
|
+
}
|
|
2216
|
+
const dot = (ax * bx + ay * by + az * bz) / (aLength * bLength);
|
|
2217
|
+
return Math.acos(Math.max(-1, Math.min(1, dot)));
|
|
2218
|
+
}
|
|
2219
|
+
function normalizeVector(x, y, z) {
|
|
2220
|
+
const length = Math.hypot(x, y, z);
|
|
2221
|
+
return length > 1e-8 ? [x / length, y / length, z / length] : [0, 0, 1];
|
|
2222
|
+
}
|
|
2223
|
+
function quantizeTangentKey(value) {
|
|
2224
|
+
const quantized = Math.round(value * 1e6);
|
|
2225
|
+
return Object.is(quantized, -0) ? 0 : quantized;
|
|
2226
|
+
}
|
|
2227
|
+
function buildFallbackTangent(nx, ny, nz) {
|
|
2228
|
+
const ax = Math.abs(nx) < 0.9 ? 1 : 0;
|
|
2229
|
+
const ay = ax === 1 ? 0 : 1;
|
|
2230
|
+
const dot = nx * ax + ny * ay;
|
|
2231
|
+
let tx = ax - nx * dot;
|
|
2232
|
+
let ty = ay - ny * dot;
|
|
2233
|
+
let tz = -nz * dot;
|
|
2234
|
+
const length = Math.hypot(tx, ty, tz);
|
|
2235
|
+
if (length <= 1e-8) {
|
|
2236
|
+
return [1, 0, 0];
|
|
2237
|
+
}
|
|
2238
|
+
tx /= length;
|
|
2239
|
+
ty /= length;
|
|
2240
|
+
tz /= length;
|
|
2241
|
+
return [tx, ty, tz];
|
|
2242
|
+
}
|
|
2243
|
+
function buildMorphTargetData(shape, cpIndices, basePositions, baseNormals, deltaMatrix, normalMatrix) {
|
|
2244
|
+
const vertexCount = basePositions.length / 3;
|
|
2245
|
+
const targetPositions = new Float32Array(vertexCount * 3);
|
|
2246
|
+
const hasNormals = shape.normals !== null && baseNormals !== null;
|
|
2247
|
+
const targetNormals = hasNormals ? new Float32Array(vertexCount * 3) : null;
|
|
2248
|
+
for (let i = 0; i < targetPositions.length; i++) {
|
|
2249
|
+
targetPositions[i] = basePositions[i];
|
|
2250
|
+
}
|
|
2251
|
+
if (targetNormals && baseNormals) {
|
|
2252
|
+
for (let i = 0; i < targetNormals.length; i++) {
|
|
2253
|
+
targetNormals[i] = baseNormals[i];
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
const cpToShapeIdx = new Map();
|
|
2257
|
+
for (let i = 0; i < shape.indices.length; i++) {
|
|
2258
|
+
cpToShapeIdx.set(shape.indices[i], i);
|
|
2259
|
+
}
|
|
2260
|
+
for (let vi = 0; vi < vertexCount; vi++) {
|
|
2261
|
+
const cpIdx = cpIndices[vi];
|
|
2262
|
+
const shapeIdx = cpToShapeIdx.get(cpIdx);
|
|
2263
|
+
if (shapeIdx === undefined) {
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
let dx = shape.vertices[shapeIdx * 3];
|
|
2267
|
+
let dy = shape.vertices[shapeIdx * 3 + 1];
|
|
2268
|
+
let dz = shape.vertices[shapeIdx * 3 + 2];
|
|
2269
|
+
if (deltaMatrix) {
|
|
2270
|
+
const rv = Vector3.TransformNormal(new Vector3(dx, dy, dz), deltaMatrix);
|
|
2271
|
+
dx = rv.x;
|
|
2272
|
+
dy = rv.y;
|
|
2273
|
+
dz = rv.z;
|
|
2274
|
+
}
|
|
2275
|
+
targetPositions[vi * 3] += dx;
|
|
2276
|
+
targetPositions[vi * 3 + 1] += dy;
|
|
2277
|
+
targetPositions[vi * 3 + 2] += dz;
|
|
2278
|
+
if (targetNormals && shape.normals) {
|
|
2279
|
+
let nx = shape.normals[shapeIdx * 3];
|
|
2280
|
+
let ny = shape.normals[shapeIdx * 3 + 1];
|
|
2281
|
+
let nz = shape.normals[shapeIdx * 3 + 2];
|
|
2282
|
+
if (normalMatrix) {
|
|
2283
|
+
const rn = Vector3.TransformNormal(new Vector3(nx, ny, nz), normalMatrix);
|
|
2284
|
+
if (rn.lengthSquared() > 0) {
|
|
2285
|
+
rn.normalize();
|
|
2286
|
+
}
|
|
2287
|
+
nx = rn.x;
|
|
2288
|
+
ny = rn.y;
|
|
2289
|
+
nz = rn.z;
|
|
2290
|
+
}
|
|
2291
|
+
targetNormals[vi * 3] += nx;
|
|
2292
|
+
targetNormals[vi * 3 + 1] += ny;
|
|
2293
|
+
targetNormals[vi * 3 + 2] += nz;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
return { positions: targetPositions, normals: targetNormals };
|
|
2297
|
+
}
|
|
2298
|
+
function calculateBlendShapeInfluences(deformPercent, fullWeights, shapeCount) {
|
|
2299
|
+
if (shapeCount <= 0) {
|
|
2300
|
+
return [];
|
|
2301
|
+
}
|
|
2302
|
+
if (!fullWeights || fullWeights.length !== shapeCount || shapeCount === 1) {
|
|
2303
|
+
const denominator = fullWeights?.[0] && fullWeights[0] !== 0 ? fullWeights[0] : 100;
|
|
2304
|
+
return [clamp01(deformPercent / denominator)];
|
|
2305
|
+
}
|
|
2306
|
+
const influences = new Array(shapeCount).fill(0);
|
|
2307
|
+
if (deformPercent <= fullWeights[0]) {
|
|
2308
|
+
influences[0] = fullWeights[0] === 0 ? (deformPercent <= 0 ? 1 : 0) : clamp01(deformPercent / fullWeights[0]);
|
|
2309
|
+
return influences;
|
|
2310
|
+
}
|
|
2311
|
+
for (let i = 1; i < fullWeights.length; i++) {
|
|
2312
|
+
const previousWeight = fullWeights[i - 1];
|
|
2313
|
+
const nextWeight = fullWeights[i];
|
|
2314
|
+
if (deformPercent > nextWeight) {
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
const range = nextWeight - previousWeight;
|
|
2318
|
+
if (Math.abs(range) < 1e-6) {
|
|
2319
|
+
influences[i] = 1;
|
|
2320
|
+
return influences;
|
|
2321
|
+
}
|
|
2322
|
+
const t = clamp01((deformPercent - previousWeight) / range);
|
|
2323
|
+
influences[i - 1] = 1 - t;
|
|
2324
|
+
influences[i] = t;
|
|
2325
|
+
return influences;
|
|
2326
|
+
}
|
|
2327
|
+
influences[shapeCount - 1] = 1;
|
|
2328
|
+
return influences;
|
|
2329
|
+
}
|
|
2330
|
+
function clamp01(value) {
|
|
2331
|
+
return Math.max(0, Math.min(1, value));
|
|
2332
|
+
}
|
|
2333
|
+
function collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime) {
|
|
2334
|
+
let minTime = Number.POSITIVE_INFINITY;
|
|
2335
|
+
let maxTime = Number.NEGATIVE_INFINITY;
|
|
2336
|
+
const sourceTimes = new Set();
|
|
2337
|
+
for (const curveNode of curveNodes) {
|
|
2338
|
+
for (const curve of curveNode.curves) {
|
|
2339
|
+
for (const key of curve.keys) {
|
|
2340
|
+
minTime = Math.min(minTime, key.time);
|
|
2341
|
+
maxTime = Math.max(maxTime, key.time);
|
|
2342
|
+
if (key.time >= startTime && key.time <= stopTime) {
|
|
2343
|
+
sourceTimes.add(key.time);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
if (!Number.isFinite(minTime) || !Number.isFinite(maxTime)) {
|
|
2349
|
+
return [];
|
|
2350
|
+
}
|
|
2351
|
+
const rangeStart = stopTime > startTime ? startTime : minTime;
|
|
2352
|
+
const rangeStop = stopTime > startTime ? stopTime : maxTime;
|
|
2353
|
+
const times = new Set([rangeStart, rangeStop, ...Array.from(sourceTimes)]);
|
|
2354
|
+
const startFrame = Math.ceil(rangeStart * fps);
|
|
2355
|
+
const stopFrame = Math.floor(rangeStop * fps);
|
|
2356
|
+
for (let frame = startFrame; frame <= stopFrame; frame++) {
|
|
2357
|
+
times.add(frame / fps);
|
|
2358
|
+
}
|
|
2359
|
+
return Array.from(times).sort((a, b) => a - b);
|
|
2360
|
+
}
|
|
2361
|
+
function areQuaternionKeysConstant(keys) {
|
|
2362
|
+
if (keys.length < 2) {
|
|
2363
|
+
return true;
|
|
2364
|
+
}
|
|
2365
|
+
const first = keys[0].value;
|
|
2366
|
+
for (let i = 1; i < keys.length; i++) {
|
|
2367
|
+
const value = keys[i].value;
|
|
2368
|
+
if (Math.abs(value.x - first.x) > 0.0001 || Math.abs(value.y - first.y) > 0.0001 || Math.abs(value.z - first.z) > 0.0001 || Math.abs(value.w - first.w) > 0.0001) {
|
|
2369
|
+
return false;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
return true;
|
|
2373
|
+
}
|
|
2374
|
+
RegisterSceneLoaderPlugin(new FBXFileLoader());
|
|
2375
|
+
function buildScalarAnimationKeys(curve, fps, startTime, stopTime, mapValue) {
|
|
2376
|
+
const range = getCurveSampleRange(curve, startTime, stopTime);
|
|
2377
|
+
const keys = curve.keys
|
|
2378
|
+
.filter((key) => key.time >= range.start && key.time <= range.stop)
|
|
2379
|
+
.map((key) => ({
|
|
2380
|
+
source: key,
|
|
2381
|
+
frame: key.time * fps,
|
|
2382
|
+
value: mapValue(key.value),
|
|
2383
|
+
}));
|
|
2384
|
+
if (!keys.some((key) => Math.abs(key.source.time - range.start) < 1e-6)) {
|
|
2385
|
+
keys.unshift({
|
|
2386
|
+
source: {
|
|
2387
|
+
time: range.start,
|
|
2388
|
+
value: sampleFBXCurveAtTime(curve, range.start) ?? 0,
|
|
2389
|
+
interpolation: "linear",
|
|
2390
|
+
},
|
|
2391
|
+
frame: range.start * fps,
|
|
2392
|
+
value: mapValue(sampleFBXCurveAtTime(curve, range.start) ?? 0),
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
if (!keys.some((key) => Math.abs(key.source.time - range.stop) < 1e-6)) {
|
|
2396
|
+
keys.push({
|
|
2397
|
+
source: {
|
|
2398
|
+
time: range.stop,
|
|
2399
|
+
value: sampleFBXCurveAtTime(curve, range.stop) ?? 0,
|
|
2400
|
+
interpolation: "linear",
|
|
2401
|
+
},
|
|
2402
|
+
frame: range.stop * fps,
|
|
2403
|
+
value: mapValue(sampleFBXCurveAtTime(curve, range.stop) ?? 0),
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
const animationKeys = keys.map((key) => ({
|
|
2407
|
+
frame: key.frame,
|
|
2408
|
+
value: key.value,
|
|
2409
|
+
}));
|
|
2410
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
2411
|
+
const key = keys[i].source;
|
|
2412
|
+
const nextAnimationKey = animationKeys[i + 1];
|
|
2413
|
+
if (key.interpolation === "constant") {
|
|
2414
|
+
animationKeys[i].interpolation = 1 /* AnimationKeyInterpolation.STEP */;
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
if (key.interpolation !== "cubic") {
|
|
2418
|
+
continue;
|
|
2419
|
+
}
|
|
2420
|
+
const nextKey = keys[i + 1].source;
|
|
2421
|
+
const duration = Math.max(nextKey.time - key.time, 1e-6);
|
|
2422
|
+
const linearSlope = (nextKey.value - key.value) / duration;
|
|
2423
|
+
animationKeys[i].outTangent = mapSlope(key.rightSlope ?? linearSlope, mapValue) / fps;
|
|
2424
|
+
nextAnimationKey.inTangent = mapSlope(key.nextLeftSlope ?? linearSlope, mapValue) / fps;
|
|
2425
|
+
}
|
|
2426
|
+
return animationKeys;
|
|
2427
|
+
}
|
|
2428
|
+
function mapSlope(slope, mapValue) {
|
|
2429
|
+
return mapValue(slope) - mapValue(0);
|
|
2430
|
+
}
|
|
2431
|
+
function getCurveSampleRange(curve, startTime, stopTime) {
|
|
2432
|
+
if (stopTime > startTime) {
|
|
2433
|
+
return { start: startTime, stop: stopTime };
|
|
2434
|
+
}
|
|
2435
|
+
return {
|
|
2436
|
+
start: curve.keys[0]?.time ?? 0,
|
|
2437
|
+
stop: curve.keys[curve.keys.length - 1]?.time ?? 0,
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
//# sourceMappingURL=fbxFileLoader.js.map
|