From 828ff969e1a4c6378eb2bcab9e6af867e6acd94d Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 23 Dec 2025 15:34:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(3d):=20FBX/GLTF/OBJ=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E9=AA=A8=E9=AA=BC=E5=8A=A8=E7=94=BB=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 * chore: 更新 pnpm-lock.yaml * fix: 移除未使用的变量和方法 * fix: 修复 mesh-3d-editor tsconfig 引用路径 * fix: 修复正则表达式 ReDoS 漏洞 --- packages/asset-system/package.json | 6 +- .../asset-system/src/core/AssetManager.ts | 6 +- packages/asset-system/src/index.ts | 5 + .../src/interfaces/IAssetLoader.ts | 249 ++ .../src/loaders/AssetLoaderFactory.ts | 96 +- .../asset-system/src/loaders/FBXLoader.ts | 2193 +++++++++++++++++ .../asset-system/src/loaders/GLTFLoader.ts | 994 ++++++++ .../asset-system/src/loaders/OBJLoader.ts | 553 +++++ packages/asset-system/src/types/AssetTypes.ts | 2 + packages/build-config/src/types.ts | 3 + .../src/core/EngineBridge.ts | 54 + packages/ecs-engine-bindgen/src/index.ts | 1 + packages/ecs-engine-bindgen/src/tokens.ts | 5 + .../src/wasm/es_engine.d.ts | 22 + packages/editor-app/package.json | 2 + packages/editor-app/src/api/tauri.ts | 20 +- .../src/app/managers/PluginInstaller.ts | 2 + .../src/components/ContentBrowser.tsx | 304 ++- .../editor-app/src/components/Viewport.tsx | 293 ++- .../TransformComponentInspector.tsx | 117 +- .../editor-app/src/services/EngineService.ts | 6 +- .../editor-app/src/styles/ContentBrowser.css | 127 + packages/engine/src/core/engine.rs | 142 +- packages/engine/src/lib.rs | 52 + packages/mesh-3d-editor/package.json | 48 + .../src/MeshComponentInspector.css | 124 + .../src/MeshComponentInspector.tsx | 202 ++ .../src/components/AnimationPreviewPanel.tsx | 498 ++++ .../src/components/ModelPreview3D.tsx | 1130 +++++++++ packages/mesh-3d-editor/src/index.ts | 221 ++ .../src/styles/AnimationPreviewPanel.css | 377 +++ packages/mesh-3d-editor/tsconfig.build.json | 13 + packages/mesh-3d-editor/tsconfig.json | 17 + packages/mesh-3d-editor/tsup.config.ts | 7 + packages/mesh-3d/package.json | 49 + packages/mesh-3d/src/Animation3DComponent.ts | 341 +++ packages/mesh-3d/src/Mesh3DRuntimeModule.ts | 115 + packages/mesh-3d/src/MeshComponent.ts | 151 ++ packages/mesh-3d/src/SkeletonComponent.ts | 279 +++ .../src/animation/AnimationEvaluator.ts | 275 +++ packages/mesh-3d/src/index.ts | 39 + .../mesh-3d/src/systems/Animation3DSystem.ts | 112 + .../src/systems/MeshAssetLoaderSystem.ts | 124 + .../mesh-3d/src/systems/MeshRenderSystem.ts | 304 +++ .../src/systems/SkeletonBakingSystem.ts | 186 ++ packages/mesh-3d/src/tokens.ts | 13 + packages/mesh-3d/tsconfig.build.json | 12 + packages/mesh-3d/tsconfig.json | 16 + packages/mesh-3d/tsup.config.ts | 7 + pnpm-lock.yaml | 92 + scripts/analyze-fbx.mjs | 239 ++ scripts/check-anim-coverage.mjs | 256 ++ scripts/check-bone-hierarchy.mjs | 259 ++ scripts/check-prerotation.mjs | 183 ++ scripts/compare-ibm.mjs | 318 +++ scripts/compare-world-matrix.mjs | 355 +++ scripts/debug-channels.mjs | 227 ++ scripts/debug-fbx-animation.mjs | 328 +++ scripts/debug-runtime-anim.mjs | 564 +++++ scripts/simple-fbx-test.mjs | 68 + scripts/test-animation-t0.mjs | 143 ++ scripts/test-animation-times.mjs | 309 +++ scripts/test-fbx-animation.mjs | 741 ++++++ scripts/test-fbxloader-bindpose.mjs | 199 ++ scripts/test-full-pipeline.mjs | 806 ++++++ scripts/trace-fbxloader-output.mjs | 309 +++ scripts/verify-anim-t0.mjs | 377 +++ scripts/verify-animation-skeleton-mapping.mjs | 351 +++ scripts/verify-mesh-skinning.mjs | 388 +++ 69 files changed, 16370 insertions(+), 56 deletions(-) create mode 100644 packages/asset-system/src/loaders/FBXLoader.ts create mode 100644 packages/asset-system/src/loaders/GLTFLoader.ts create mode 100644 packages/asset-system/src/loaders/OBJLoader.ts create mode 100644 packages/mesh-3d-editor/package.json create mode 100644 packages/mesh-3d-editor/src/MeshComponentInspector.css create mode 100644 packages/mesh-3d-editor/src/MeshComponentInspector.tsx create mode 100644 packages/mesh-3d-editor/src/components/AnimationPreviewPanel.tsx create mode 100644 packages/mesh-3d-editor/src/components/ModelPreview3D.tsx create mode 100644 packages/mesh-3d-editor/src/index.ts create mode 100644 packages/mesh-3d-editor/src/styles/AnimationPreviewPanel.css create mode 100644 packages/mesh-3d-editor/tsconfig.build.json create mode 100644 packages/mesh-3d-editor/tsconfig.json create mode 100644 packages/mesh-3d-editor/tsup.config.ts create mode 100644 packages/mesh-3d/package.json create mode 100644 packages/mesh-3d/src/Animation3DComponent.ts create mode 100644 packages/mesh-3d/src/Mesh3DRuntimeModule.ts create mode 100644 packages/mesh-3d/src/MeshComponent.ts create mode 100644 packages/mesh-3d/src/SkeletonComponent.ts create mode 100644 packages/mesh-3d/src/animation/AnimationEvaluator.ts create mode 100644 packages/mesh-3d/src/index.ts create mode 100644 packages/mesh-3d/src/systems/Animation3DSystem.ts create mode 100644 packages/mesh-3d/src/systems/MeshAssetLoaderSystem.ts create mode 100644 packages/mesh-3d/src/systems/MeshRenderSystem.ts create mode 100644 packages/mesh-3d/src/systems/SkeletonBakingSystem.ts create mode 100644 packages/mesh-3d/src/tokens.ts create mode 100644 packages/mesh-3d/tsconfig.build.json create mode 100644 packages/mesh-3d/tsconfig.json create mode 100644 packages/mesh-3d/tsup.config.ts create mode 100644 scripts/analyze-fbx.mjs create mode 100644 scripts/check-anim-coverage.mjs create mode 100644 scripts/check-bone-hierarchy.mjs create mode 100644 scripts/check-prerotation.mjs create mode 100644 scripts/compare-ibm.mjs create mode 100644 scripts/compare-world-matrix.mjs create mode 100644 scripts/debug-channels.mjs create mode 100644 scripts/debug-fbx-animation.mjs create mode 100644 scripts/debug-runtime-anim.mjs create mode 100644 scripts/simple-fbx-test.mjs create mode 100644 scripts/test-animation-t0.mjs create mode 100644 scripts/test-animation-times.mjs create mode 100644 scripts/test-fbx-animation.mjs create mode 100644 scripts/test-fbxloader-bindpose.mjs create mode 100644 scripts/test-full-pipeline.mjs create mode 100644 scripts/trace-fbxloader-output.mjs create mode 100644 scripts/verify-anim-t0.mjs create mode 100644 scripts/verify-animation-skeleton-mapping.mjs create mode 100644 scripts/verify-mesh-skinning.mjs diff --git a/packages/asset-system/package.json b/packages/asset-system/package.json index 8609784d..cb9c600b 100644 --- a/packages/asset-system/package.json +++ b/packages/asset-system/package.json @@ -30,9 +30,9 @@ "author": "yhh", "license": "MIT", "devDependencies": { + "@esengine/build-config": "workspace:*", "@esengine/ecs-framework": "workspace:*", "@esengine/engine-core": "workspace:*", - "@esengine/build-config": "workspace:*", "rimraf": "^5.0.0", "tsup": "^8.0.0", "typescript": "^5.8.3" @@ -44,5 +44,9 @@ "type": "git", "url": "https://github.com/esengine/esengine.git", "directory": "packages/asset-system" + }, + "dependencies": { + "@types/pako": "^2.0.4", + "pako": "^2.1.0" } } diff --git a/packages/asset-system/src/core/AssetManager.ts b/packages/asset-system/src/core/AssetManager.ts index 1271cc03..5512c115 100644 --- a/packages/asset-system/src/core/AssetManager.ts +++ b/packages/asset-system/src/core/AssetManager.ts @@ -176,7 +176,11 @@ export class AssetManager implements IAssetManager { } // 创建加载器 / Create loader - let loader = this._loaderFactory.createLoader(metadata.type); + // 优先使用基于路径的加载器选择,支持多个加载器对应同一资产类型 + // 例如 Model3D 类型支持 GLTF/FBX/OBJ,根据扩展名选择正确的加载器 + // Prefer path-based loader selection, supports multiple loaders for same asset type + // e.g., Model3D type supports GLTF/FBX/OBJ, selects correct loader by extension + let loader = this._loaderFactory.createLoaderForPath(metadata.path); // 如果没有找到 loader 且类型是 Custom,尝试重新解析类型 // If no loader found and type is Custom, try to re-resolve the type diff --git a/packages/asset-system/src/index.ts b/packages/asset-system/src/index.ts index 7d81d5af..47681e36 100644 --- a/packages/asset-system/src/index.ts +++ b/packages/asset-system/src/index.ts @@ -57,6 +57,11 @@ export { BinaryLoader } from './loaders/BinaryLoader'; export { AudioLoader } from './loaders/AudioLoader'; export { PrefabLoader } from './loaders/PrefabLoader'; +// 3D Model Loaders | 3D 模型加载器 +export { GLTFLoader } from './loaders/GLTFLoader'; +export { OBJLoader } from './loaders/OBJLoader'; +export { FBXLoader } from './loaders/FBXLoader'; + // Integration export { EngineIntegration } from './integration/EngineIntegration'; export type { ITextureEngineBridge, TextureLoadCallback } from './integration/EngineIntegration'; diff --git a/packages/asset-system/src/interfaces/IAssetLoader.ts b/packages/asset-system/src/interfaces/IAssetLoader.ts index d774552c..73a155f8 100644 --- a/packages/asset-system/src/interfaces/IAssetLoader.ts +++ b/packages/asset-system/src/interfaces/IAssetLoader.ts @@ -80,12 +80,29 @@ export interface IAssetLoaderFactory { */ createLoader(type: AssetType): IAssetLoader | null; + /** + * Create loader for a specific file path (selects by extension) + * 为特定文件路径创建加载器(按扩展名选择) + * + * This method is preferred over createLoader() when multiple loaders + * support the same asset type (e.g., Model3D with GLTF/OBJ/FBX). + * 当多个加载器支持相同资产类型时(如 Model3D 的 GLTF/OBJ/FBX), + * 优先使用此方法而非 createLoader()。 + */ + createLoaderForPath(path: string): IAssetLoader | null; + /** * Register custom loader * 注册自定义加载器 */ registerLoader(type: AssetType, loader: IAssetLoader): void; + /** + * Register a loader for a specific file extension + * 为特定文件扩展名注册加载器 + */ + registerExtensionLoader(extension: string, loader: IAssetLoader): void; + /** * Unregister loader * 注销加载器 @@ -365,3 +382,235 @@ export interface IBinaryAsset { /** MIME类型 / MIME type */ mimeType?: string; } + +// ===== GLTF/GLB 3D Model Types ===== +// ===== GLTF/GLB 3D 模型类型 ===== + +/** + * Bounding box interface + * 边界盒接口 + */ +export interface IBoundingBox { + /** 最小坐标 [x, y, z] | Minimum coordinates */ + min: [number, number, number]; + /** 最大坐标 [x, y, z] | Maximum coordinates */ + max: [number, number, number]; +} + +/** + * Extended mesh data with name and material reference + * 扩展的网格数据,包含名称和材质引用 + */ +export interface IMeshData extends IMeshAsset { + /** 网格名称 | Mesh name */ + name: string; + /** 引用的材质索引 | Referenced material index */ + materialIndex: number; + /** 顶点颜色(如果有)| Vertex colors if available */ + colors?: Float32Array; + + // ===== Skinning data for skeletal animation ===== + // ===== 骨骼动画蒙皮数据 ===== + + /** + * Joint indices per vertex (4 influences, GLTF JOINTS_0) + * 每顶点的关节索引(4 个影响,GLTF JOINTS_0) + * Format: [j0, j1, j2, j3] for each vertex + */ + joints?: Uint8Array | Uint16Array; + + /** + * Joint weights per vertex (4 influences, GLTF WEIGHTS_0) + * 每顶点的关节权重(4 个影响,GLTF WEIGHTS_0) + * Format: [w0, w1, w2, w3] for each vertex, should sum to 1.0 + */ + weights?: Float32Array; +} + +/** + * GLTF material definition + * GLTF 材质定义 + */ +export interface IGLTFMaterial { + /** 材质名称 | Material name */ + name: string; + /** 基础颜色 [r, g, b, a] | Base color factor */ + baseColorFactor: [number, number, number, number]; + /** 基础颜色纹理索引 | Base color texture index (-1 if none) */ + baseColorTextureIndex: number; + /** 金属度 (0-1) | Metallic factor */ + metallicFactor: number; + /** 粗糙度 (0-1) | Roughness factor */ + roughnessFactor: number; + /** 金属粗糙度纹理索引 | Metallic-roughness texture index */ + metallicRoughnessTextureIndex: number; + /** 法线纹理索引 | Normal texture index */ + normalTextureIndex: number; + /** 法线缩放 | Normal scale */ + normalScale: number; + /** 遮挡纹理索引 | Occlusion texture index */ + occlusionTextureIndex: number; + /** 遮挡强度 | Occlusion strength */ + occlusionStrength: number; + /** 自发光因子 [r, g, b] | Emissive factor */ + emissiveFactor: [number, number, number]; + /** 自发光纹理索引 | Emissive texture index */ + emissiveTextureIndex: number; + /** Alpha 模式 | Alpha mode */ + alphaMode: 'OPAQUE' | 'MASK' | 'BLEND'; + /** Alpha 剔除阈值 | Alpha cutoff */ + alphaCutoff: number; + /** 是否双面 | Double sided */ + doubleSided: boolean; +} + +/** + * GLTF texture info + * GLTF 纹理信息 + */ +export interface IGLTFTextureInfo { + /** 纹理名称 | Texture name */ + name?: string; + /** 图像数据(嵌入式)| Image data (embedded) */ + imageData?: ArrayBuffer; + /** 图像 MIME 类型 | Image MIME type */ + mimeType?: string; + /** 外部 URI(非嵌入)| External URI (non-embedded) */ + uri?: string; + /** 加载后的纹理资产 GUID | Loaded texture asset GUID */ + textureGuid?: AssetGUID; +} + +/** + * GLTF node (scene hierarchy) + * GLTF 节点(场景层级) + */ +export interface IGLTFNode { + /** 节点名称 | Node name */ + name: string; + /** 网格索引(可选)| Mesh index (optional) */ + meshIndex?: number; + /** 子节点索引列表 | Child node indices */ + children: number[]; + /** 变换信息 | Transform info */ + transform: { + /** 位置 [x, y, z] | Position */ + position: [number, number, number]; + /** 旋转四元数 [x, y, z, w] | Rotation quaternion */ + rotation: [number, number, number, number]; + /** 缩放 [x, y, z] | Scale */ + scale: [number, number, number]; + }; +} + +/** + * Animation channel target + * 动画通道目标 + */ +export interface IAnimationChannelTarget { + /** 目标节点索引 | Target node index */ + nodeIndex: number; + /** 目标属性 | Target property */ + path: 'translation' | 'rotation' | 'scale' | 'weights'; +} + +/** + * Animation sampler + * 动画采样器 + */ +export interface IAnimationSampler { + /** 输入时间数组 | Input time array */ + input: Float32Array; + /** 输出值数组 | Output values array */ + output: Float32Array; + /** 插值类型 | Interpolation type */ + interpolation: 'LINEAR' | 'STEP' | 'CUBICSPLINE'; +} + +/** + * Animation channel + * 动画通道 + */ +export interface IAnimationChannel { + /** 采样器索引 | Sampler index */ + samplerIndex: number; + /** 目标 | Target */ + target: IAnimationChannelTarget; +} + +/** + * Animation clip from GLTF + * GLTF 动画片段 + */ +export interface IGLTFAnimationClip { + /** 动画名称 | Animation name */ + name: string; + /** 动画时长(秒)| Duration in seconds */ + duration: number; + /** 采样器列表 | Sampler list */ + samplers: IAnimationSampler[]; + /** 通道列表 | Channel list */ + channels: IAnimationChannel[]; +} + +/** + * Skeleton joint + * 骨骼关节 + */ +export interface ISkeletonJoint { + /** 关节名称 | Joint name */ + name: string; + /** 节点索引 | Node index */ + nodeIndex: number; + /** 父关节索引(-1 表示根)| Parent joint index (-1 for root) */ + parentIndex: number; + /** 逆绑定矩阵 (4x4) | Inverse bind matrix */ + inverseBindMatrix: Float32Array; +} + +/** + * Skeleton data + * 骨骼数据 + */ +export interface ISkeletonData { + /** 关节列表 | Joint list */ + joints: ISkeletonJoint[]; + /** 根关节索引 | Root joint index */ + rootJointIndex: number; +} + +/** + * GLTF/GLB 3D model asset interface + * GLTF/GLB 3D 模型资产接口 + */ +export interface IGLTFAsset { + /** 模型名称 | Model name */ + name: string; + + /** 网格数据列表 | Mesh data list */ + meshes: IMeshData[]; + + /** 材质列表 | Material list */ + materials: IGLTFMaterial[]; + + /** 纹理信息列表 | Texture info list */ + textures: IGLTFTextureInfo[]; + + /** 场景层级节点 | Scene hierarchy nodes */ + nodes: IGLTFNode[]; + + /** 根节点索引列表 | Root node indices */ + rootNodes: number[]; + + /** 动画片段列表(可选)| Animation clips (optional) */ + animations?: IGLTFAnimationClip[]; + + /** 骨骼数据(可选)| Skeleton data (optional) */ + skeleton?: ISkeletonData; + + /** 整体边界盒 | Overall bounding box */ + bounds: IBoundingBox; + + /** 源文件路径 | Source file path */ + sourcePath?: string; +} diff --git a/packages/asset-system/src/loaders/AssetLoaderFactory.ts b/packages/asset-system/src/loaders/AssetLoaderFactory.ts index 70f75e6f..36241eba 100644 --- a/packages/asset-system/src/loaders/AssetLoaderFactory.ts +++ b/packages/asset-system/src/loaders/AssetLoaderFactory.ts @@ -11,14 +11,24 @@ import { TextLoader } from './TextLoader'; import { BinaryLoader } from './BinaryLoader'; import { AudioLoader } from './AudioLoader'; import { PrefabLoader } from './PrefabLoader'; +import { GLTFLoader } from './GLTFLoader'; +import { OBJLoader } from './OBJLoader'; +import { FBXLoader } from './FBXLoader'; /** * Asset loader factory * 资产加载器工厂 + * + * Supports multiple loaders per asset type (selected by file extension). + * 支持每种资产类型的多个加载器(按文件扩展名选择)。 */ export class AssetLoaderFactory implements IAssetLoaderFactory { private readonly _loaders = new Map(); + /** Extension -> Loader map for precise loader selection */ + /** 扩展名 -> 加载器映射,用于精确选择加载器 */ + private readonly _extensionLoaders = new Map(); + constructor() { // 注册默认加载器 / Register default loaders this.registerDefaultLoaders(); @@ -47,10 +57,35 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { // 预制体加载器 / Prefab loader this._loaders.set(AssetType.Prefab, new PrefabLoader()); + // 3D模型加载器 / 3D Model loaders + // Default is GLTF, but OBJ and FBX are also supported + // 默认是 GLTF,但也支持 OBJ 和 FBX + const gltfLoader = new GLTFLoader(); + const objLoader = new OBJLoader(); + const fbxLoader = new FBXLoader(); + + this._loaders.set(AssetType.Model3D, gltfLoader); + + // Register extension-specific loaders + // 注册特定扩展名的加载器 + this.registerExtensionLoader('.gltf', gltfLoader); + this.registerExtensionLoader('.glb', gltfLoader); + this.registerExtensionLoader('.obj', objLoader); + this.registerExtensionLoader('.fbx', fbxLoader); + // 注:Shader 和 Material 加载器由 material-system 模块注册 // Note: Shader and Material loaders are registered by material-system module } + /** + * Register a loader for a specific file extension + * 为特定文件扩展名注册加载器 + */ + registerExtensionLoader(extension: string, loader: IAssetLoader): void { + const ext = extension.toLowerCase().startsWith('.') ? extension.toLowerCase() : `.${extension.toLowerCase()}`; + this._extensionLoaders.set(ext, loader); + } + /** * Create loader for specific asset type * 为特定资产类型创建加载器 @@ -59,6 +94,38 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { return this._loaders.get(type) || null; } + /** + * Create loader for a specific file path (selects by extension) + * 为特定文件路径创建加载器(按扩展名选择) + * + * This method is preferred over createLoader() when multiple loaders + * support the same asset type (e.g., Model3D with GLTF/OBJ/FBX). + * 当多个加载器支持相同资产类型时(如 Model3D 的 GLTF/OBJ/FBX), + * 优先使用此方法而非 createLoader()。 + */ + createLoaderForPath(path: string): IAssetLoader | null { + const lastDot = path.lastIndexOf('.'); + if (lastDot !== -1) { + const ext = path.substring(lastDot).toLowerCase(); + + // First try extension-specific loader + // 首先尝试特定扩展名的加载器 + const extLoader = this._extensionLoaders.get(ext); + if (extLoader) { + return extLoader; + } + } + + // Fall back to type-based lookup + // 回退到基于类型的查找 + const type = this.getAssetTypeByPath(path); + if (type) { + return this.createLoader(type); + } + + return null; + } + /** * Register custom loader * 注册自定义加载器 @@ -92,6 +159,16 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { */ getAssetTypeByExtension(extension: string): AssetType | null { const ext = extension.toLowerCase(); + + // Check extension-specific loaders first + // 首先检查特定扩展名的加载器 + const extLoader = this._extensionLoaders.get(ext); + if (extLoader) { + return extLoader.supportedType; + } + + // Fall back to type-based loaders + // 回退到基于类型的加载器 for (const [type, loader] of this._loaders) { if (loader.supportedExtensions.some(e => e.toLowerCase() === ext)) { return type; @@ -159,14 +236,22 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { getAllSupportedExtensions(): string[] { const extensions = new Set(); + // From type-based loaders + // 从基于类型的加载器 for (const loader of this._loaders.values()) { for (const ext of loader.supportedExtensions) { - // 转换为 glob 模式 | Convert to glob pattern const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext; extensions.add(`*.${cleanExt}`); } } + // From extension-specific loaders + // 从特定扩展名的加载器 + for (const ext of this._extensionLoaders.keys()) { + const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext; + extensions.add(`*.${cleanExt}`); + } + return Array.from(extensions); } @@ -179,6 +264,8 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { getExtensionTypeMap(): Record { const map: Record = {}; + // From type-based loaders + // 从基于类型的加载器 for (const [type, loader] of this._loaders) { for (const ext of loader.supportedExtensions) { const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext; @@ -186,6 +273,13 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { } } + // From extension-specific loaders + // 从特定扩展名的加载器 + for (const [ext, loader] of this._extensionLoaders) { + const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext; + map[cleanExt.toLowerCase()] = loader.supportedType; + } + return map; } } diff --git a/packages/asset-system/src/loaders/FBXLoader.ts b/packages/asset-system/src/loaders/FBXLoader.ts new file mode 100644 index 00000000..c4104a31 --- /dev/null +++ b/packages/asset-system/src/loaders/FBXLoader.ts @@ -0,0 +1,2193 @@ +/** + * FBX model loader implementation + * FBX 模型加载器实现 + * + * Supports: + * - Binary FBX format (.fbx) - FBX 7.x + * - ASCII FBX format (.fbx) + * - Geometry extraction (vertices, normals, UVs, indices) + * - Multiple meshes/objects + * - Basic material references + * - Animation extraction (AnimationStack, AnimationLayer, AnimationCurve) + * - Skeleton/Bone data (Deformer) + * + * Note: FBX is a complex proprietary format. This loader focuses on + * extracting geometry and animation data for rendering. + * 注意:FBX 是复杂的专有格式。此加载器专注于提取用于渲染的几何和动画数据。 + */ + +import { AssetType } from '../types/AssetTypes'; +import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader'; +import type { + IAssetLoader, + IAssetParseContext, + IGLTFAsset, + IMeshData, + IGLTFMaterial, + IGLTFNode, + IBoundingBox, + IGLTFAnimationClip, + IAnimationSampler, + IAnimationChannel, + ISkeletonData, + ISkeletonJoint +} from '../interfaces/IAssetLoader'; +import pako from 'pako'; + +// ===== FBX Binary Format Structures ===== + +/** FBX binary header size */ +const FBX_HEADER_SIZE = 27; // Magic (21) + unknown (2) + version (4) + +/** FBX Node for binary parsing */ +interface FBXNode { + name: string; + properties: FBXProperty[]; + children: FBXNode[]; +} + +type FBXProperty = number | bigint | boolean | string | number[] | bigint[] | boolean[] | Uint8Array | Float32Array | Float64Array | Int32Array | BigInt64Array; + +/** Parsed FBX geometry data */ +interface FBXGeometry { + id: bigint; // Geometry ID | 几何体ID + name: string; + vertices: number[]; + indices: number[]; + normals?: number[]; + uvs?: number[]; + materialIds?: number[]; +} + +/** FBX model object */ +interface FBXModel { + id: bigint; + name: string; + geometryId?: bigint; + materialIds: bigint[]; + // Local transform | 本地变换 + position: [number, number, number]; + rotation: [number, number, number]; // Euler angles in degrees | 欧拉角(度) + scale: [number, number, number]; + // Pre-rotation (applied before Lcl Rotation) | 预旋转(在 Lcl Rotation 之前应用) + preRotation?: [number, number, number]; +} + +/** FBX material */ +interface FBXMaterial { + id: bigint; + name: string; + diffuseColor: [number, number, number]; + opacity: number; +} + +// ===== FBX Animation Structures ===== + +/** FBX time constant: 1 second = 46186158000 FbxTime units */ +const FBX_TIME_SECOND = 46186158000; + +/** FBX animation curve - single component (X, Y, Z, or W) */ +interface FBXAnimationCurve { + id: bigint; + name: string; + keyTimes: Float32Array; // 时间(秒) | Time in seconds + keyValues: Float32Array; // 值 | Values + componentIndex: number; // 0=X, 1=Y, 2=Z, 3=W +} + +/** FBX animation curve node - target property (translation/rotation/scale) */ +interface FBXAnimationCurveNode { + id: bigint; + name: string; + attribute: string; // "T" | "R" | "S" (Translation/Rotation/Scaling) + targetModelId: bigint; // 目标 Model 的 ID | Target Model ID + curves: FBXAnimationCurve[]; // X, Y, Z 曲线 | X, Y, Z curves +} + +/** FBX animation layer */ +interface FBXAnimationLayer { + id: bigint; + name: string; + curveNodes: FBXAnimationCurveNode[]; +} + +/** FBX animation stack (clip) */ +interface FBXAnimationStack { + id: bigint; + name: string; + layers: FBXAnimationLayer[]; +} + +/** FBX deformer (skeleton/skin) */ +interface FBXDeformer { + id: bigint; + name: string; + type: 'Skin' | 'Cluster'; + boneId?: bigint; // Cluster: 指向骨骼 Model | Cluster: points to bone Model + indexes?: number[]; // Cluster: 受影响的顶点索引 | Cluster: affected vertex indices + weights?: number[]; // Cluster: 顶点权重 | Cluster: vertex weights + transform?: Float32Array; // Cluster: 变换矩阵 | Cluster: transform matrix + transformLink?: Float32Array; // Cluster: 逆绑定矩阵 | Cluster: inverse bind matrix +} + +/** FBX connection - links objects together */ +interface FBXConnection { + type: string; // "OO" (object-object) or "OP" (object-property) + fromId: bigint; + toId: bigint; + property?: string; +} + +/** + * FBX model loader + * FBX 模型加载器 + */ +export class FBXLoader implements IAssetLoader { + readonly supportedType = AssetType.Model3D; + readonly supportedExtensions = ['.fbx']; + readonly contentType: AssetContentType = 'binary'; + + // Parsing state + private buffer: ArrayBuffer = new ArrayBuffer(0); + private view: DataView = new DataView(this.buffer); + private offset = 0; + private version = 0; + + /** + * Parse FBX content + * 解析 FBX 内容 + */ + async parse(content: IAssetContent, context: IAssetParseContext): Promise { + const buffer = content.binary; + if (!buffer) { + throw new Error('FBX loader requires binary content'); + } + + // Detect format (binary or ASCII) + // 检测格式(二进制或 ASCII) + // FBX binary header is "Kaydara FBX Binary \0" (21 bytes + null) + // FBX 二进制头是 "Kaydara FBX Binary \0"(21字节 + 空字节) + const headerBytes = new Uint8Array(buffer, 0, Math.min(21, buffer.byteLength)); + const headerString = String.fromCharCode(...headerBytes); + const isBinary = headerString.startsWith('Kaydara FBX Binary'); + + let geometries: FBXGeometry[]; + let models: FBXModel[]; + let materials: FBXMaterial[]; + let animStacks: FBXAnimationStack[] = []; + let deformers: FBXDeformer[] = []; + let connections: FBXConnection[] = []; + + if (isBinary) { + const result = this.parseBinary(buffer); + geometries = result.geometries; + models = result.models; + materials = result.materials; + animStacks = result.animStacks; + deformers = result.deformers; + connections = result.connections; + } else { + // Try ASCII parsing (no animation support for ASCII yet) + // 尝试 ASCII 解析(ASCII 格式暂不支持动画) + const text = new TextDecoder().decode(buffer); + const result = this.parseASCII(text); + geometries = result.geometries; + models = result.models; + materials = result.materials; + } + + // Build skeleton data FIRST to get cluster -> joint index mapping + // 先构建骨骼数据以获取簇->关节索引映射 + const clusterToJointIndex = new Map(); + const skeleton = this.buildSkeletonData(deformers, models, connections, clusterToJointIndex) ?? undefined; + + // Convert to mesh data with skinning (using the cluster mapping) + // 转换为带蒙皮的网格数据(使用簇映射) + const meshes = this.buildMeshes(geometries, deformers, connections, clusterToJointIndex); + + // Build material list + // 构建材质列表 + const gltfMaterials = this.buildMaterials(materials); + + // Build geometry ID to mesh index map | 构建几何体ID到网格索引的映射 + const geometryToMeshIndex = new Map(); + geometries.forEach((geom, index) => { + if (index < meshes.length) { + geometryToMeshIndex.set(geom.id, index); + } + }); + + // Build Model -> Geometry connection map | 构建模型->几何体连接映射 + const modelGeometryMap = new Map(); + for (const conn of connections) { + if (conn.type === 'OO') { + const geom = geometries.find(g => g.id === conn.fromId); + const model = models.find(m => m.id === conn.toId); + if (geom && model) { + modelGeometryMap.set(model.id, geom.id); + } + } + } + + // Build model ID to index map | 构建模型ID到索引的映射 + const modelIdToIndex = new Map(); + models.forEach((model, index) => { + modelIdToIndex.set(model.id, index); + }); + + // Build node hierarchy from MODELS (not meshes) to match animation indices + // 从模型(而非网格)构建节点层级以匹配动画索引 + const nodes: IGLTFNode[] = models.map(model => { + const geomId = modelGeometryMap.get(model.id); + const meshIndex = geomId !== undefined ? geometryToMeshIndex.get(geomId) : undefined; + + // Convert euler rotation (degrees) to quaternion | 将欧拉角(度)转换为四元数 + // FBX transform: finalRotation = PreRotation * LclRotation + // FBX 变换:finalRotation = PreRotation * LclRotation + let quat: [number, number, number, number]; + + if (model.preRotation) { + // Apply PreRotation before Lcl Rotation | 在 Lcl Rotation 之前应用 PreRotation + const preRx = model.preRotation[0] * Math.PI / 180; + const preRy = model.preRotation[1] * Math.PI / 180; + const preRz = model.preRotation[2] * Math.PI / 180; + const preQuat = this.eulerToQuaternion(preRx, preRy, preRz); + + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + const lclQuat = this.eulerToQuaternion(rx, ry, rz); + + // Combine: final = pre * lcl | 组合:final = pre * lcl + quat = this.multiplyQuaternion(preQuat, lclQuat); + } else { + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + quat = this.eulerToQuaternion(rx, ry, rz); + } + + return { + name: model.name, + meshIndex: meshIndex ?? -1, + children: [] as number[], + transform: { + position: model.position, + rotation: quat, + scale: model.scale + } + }; + }); + + // Build parent-child relationships from connections | 从连接构建父子关系 + for (const conn of connections) { + if (conn.type === 'OO') { + const childIdx = modelIdToIndex.get(conn.fromId); + const parentIdx = modelIdToIndex.get(conn.toId); + if (childIdx !== undefined && parentIdx !== undefined && childIdx !== parentIdx) { + // Add child to parent's children array | 将子节点添加到父节点的 children 数组 + if (!nodes[parentIdx].children.includes(childIdx)) { + nodes[parentIdx].children.push(childIdx); + } + } + } + } + + // Calculate overall bounds + // 计算总边界 + const bounds = this.calculateBounds(meshes); + + // Get model name from file path + // 从文件路径获取模型名称 + const pathParts = context.metadata.path.split(/[\\/]/); + const fileName = pathParts[pathParts.length - 1]; + const name = fileName.replace(/\.fbx$/i, ''); + + // Convert animation stacks to clips + // 将动画栈转换为动画片段 + const animations = this.convertToAnimationClips(animStacks, models); + + return { + name, + meshes, + materials: gltfMaterials, + textures: [], + nodes, + rootNodes: nodes.map((_, i) => i), + bounds, + sourcePath: context.metadata.path, + animations, + skeleton + }; + } + + /** + * Dispose FBX asset + * 释放 FBX 资产 + */ + dispose(asset: IGLTFAsset): void { + for (const mesh of asset.meshes) { + (mesh as { vertices: Float32Array | null }).vertices = null!; + (mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!; + } + asset.meshes.length = 0; + } + + // ===== Binary FBX Parsing ===== + + /** + * Parse binary FBX format + * 解析二进制 FBX 格式 + */ + private parseBinary(buffer: ArrayBuffer): { + geometries: FBXGeometry[]; + models: FBXModel[]; + materials: FBXMaterial[]; + animStacks: FBXAnimationStack[]; + deformers: FBXDeformer[]; + connections: FBXConnection[]; + } { + this.buffer = buffer; + this.view = new DataView(buffer); + this.offset = 0; + + // Read header + // 读取头部 + this.offset = 21; // Skip magic + this.offset += 2; // Skip unknown bytes + this.version = this.view.getUint32(this.offset, true); + this.offset = FBX_HEADER_SIZE; + + // Parse root nodes + // 解析根节点 + const nodes: FBXNode[] = []; + while (this.offset < buffer.byteLength) { + const node = this.parseNode(); + if (!node) break; + nodes.push(node); + } + + // Extract geometry and model data + // 提取几何和模型数据 + const geometries: FBXGeometry[] = []; + const models: FBXModel[] = []; + const materials: FBXMaterial[] = []; + + // Animation data + // 动画数据 + const animStacks: FBXAnimationStack[] = []; + const animLayers: FBXAnimationLayer[] = []; + const animCurveNodes: FBXAnimationCurveNode[] = []; + const animCurves: FBXAnimationCurve[] = []; + const deformers: FBXDeformer[] = []; + + // Find Objects node + // 查找 Objects 节点 + const objectsNode = nodes.find(n => n.name === 'Objects'); + if (objectsNode) { + for (const child of objectsNode.children) { + if (child.name === 'Geometry') { + const geom = this.extractGeometry(child); + if (geom) { + geometries.push(geom); + } + } else if (child.name === 'Model') { + const model = this.extractModel(child); + if (model) models.push(model); + } else if (child.name === 'Material') { + const mat = this.extractMaterial(child); + if (mat) materials.push(mat); + } else if (child.name === 'AnimationStack') { + const stack = this.extractAnimationStack(child); + if (stack) animStacks.push(stack); + } else if (child.name === 'AnimationLayer') { + const layer = this.extractAnimationLayer(child); + if (layer) animLayers.push(layer); + } else if (child.name === 'AnimationCurveNode') { + const curveNode = this.extractAnimationCurveNode(child); + if (curveNode) animCurveNodes.push(curveNode); + } else if (child.name === 'AnimationCurve') { + const curve = this.extractAnimationCurve(child); + if (curve) animCurves.push(curve); + } else if (child.name === 'Deformer') { + const deformer = this.extractDeformer(child); + if (deformer) deformers.push(deformer); + } + } + } + + // Parse connections + // 解析连接 + const connections = this.parseConnections(nodes); + + // Build animation hierarchy using connections + // 使用连接构建动画层级 + this.buildAnimationHierarchy( + animStacks, animLayers, animCurveNodes, animCurves, connections + ); + + return { geometries, models, materials, animStacks, deformers, connections }; + } + + /** + * Parse a single FBX node (binary format) + * 解析单个 FBX 节点(二进制格式) + */ + private parseNode(): FBXNode | null { + if (this.offset >= this.buffer.byteLength) return null; + + const is64Bit = this.version >= 7500; + + // Read node record + // 读取节点记录 + let endOffset: number; + let numProperties: number; + + if (is64Bit) { + endOffset = Number(this.view.getBigUint64(this.offset, true)); + numProperties = Number(this.view.getBigUint64(this.offset + 8, true)); + // propertyListLen not used, skip reading it + this.offset += 24; + } else { + endOffset = this.view.getUint32(this.offset, true); + numProperties = this.view.getUint32(this.offset + 4, true); + // propertyListLen not used, skip reading it + this.offset += 12; + } + + // Check for null node (end marker) + // 检查空节点(结束标记) + if (endOffset === 0) { + return null; + } + + // Read name + // 读取名称 + const nameLen = this.view.getUint8(this.offset); + this.offset += 1; + const nameBytes = new Uint8Array(this.buffer, this.offset, nameLen); + const name = String.fromCharCode(...nameBytes); + this.offset += nameLen; + + // Read properties + // 读取属性 + const properties: FBXProperty[] = []; + for (let i = 0; i < numProperties; i++) { + const prop = this.parseProperty(); + properties.push(prop); + } + + // Read child nodes + // 读取子节点 + const children: FBXNode[] = []; + while (this.offset < endOffset) { + // Check for null terminator + // 检查空终止符 + const nullCheck = is64Bit ? 13 : 13; + if (this.offset + nullCheck <= endOffset) { + const testOffset = is64Bit + ? Number(this.view.getBigUint64(this.offset, true)) + : this.view.getUint32(this.offset, true); + if (testOffset === 0) { + this.offset = endOffset; + break; + } + } + + const child = this.parseNode(); + if (!child) break; + children.push(child); + } + + this.offset = endOffset; + + return { name, properties, children }; + } + + /** + * Parse a single FBX property + * 解析单个 FBX 属性 + */ + private parseProperty(): FBXProperty { + const type = String.fromCharCode(this.view.getUint8(this.offset)); + this.offset += 1; + + switch (type) { + case 'C': // Bool + const boolVal = this.view.getUint8(this.offset) !== 0; + this.offset += 1; + return boolVal; + + case 'Y': // Int16 + const int16Val = this.view.getInt16(this.offset, true); + this.offset += 2; + return int16Val; + + case 'I': // Int32 + const int32Val = this.view.getInt32(this.offset, true); + this.offset += 4; + return int32Val; + + case 'L': // Int64 + const int64Val = this.view.getBigInt64(this.offset, true); + this.offset += 8; + return int64Val; + + case 'F': // Float + const floatVal = this.view.getFloat32(this.offset, true); + this.offset += 4; + return floatVal; + + case 'D': // Double + const doubleVal = this.view.getFloat64(this.offset, true); + this.offset += 8; + return doubleVal; + + case 'S': // String + case 'R': // Raw binary + const strLen = this.view.getUint32(this.offset, true); + this.offset += 4; + if (type === 'S') { + const strBytes = new Uint8Array(this.buffer, this.offset, strLen); + this.offset += strLen; + // FBX strings may contain null bytes + // FBX 字符串可能包含空字节 + let str = ''; + for (let i = 0; i < strLen; i++) { + if (strBytes[i] === 0) break; + str += String.fromCharCode(strBytes[i]); + } + return str; + } else { + const rawData = new Uint8Array(this.buffer, this.offset, strLen); + this.offset += strLen; + return rawData; + } + + case 'b': // Bool array + case 'c': // Bool array (alias) + return this.parseArrayProperty('bool'); + + case 'i': // Int32 array + return this.parseArrayProperty('int32'); + + case 'l': // Int64 array + return this.parseArrayProperty('int64'); + + case 'f': // Float array + return this.parseArrayProperty('float32'); + + case 'd': // Double array + return this.parseArrayProperty('float64'); + + default: + console.warn(`Unknown FBX property type: ${type}`); + return 0; + } + } + + /** + * Parse array property with potential compression + * 解析可能压缩的数组属性 + */ + private parseArrayProperty(elementType: 'bool' | 'int32' | 'int64' | 'float32' | 'float64'): number[] | bigint[] | Float32Array | Float64Array { + const arrayLength = this.view.getUint32(this.offset, true); + const encoding = this.view.getUint32(this.offset + 4, true); + const compressedLength = this.view.getUint32(this.offset + 8, true); + this.offset += 12; + + let data: ArrayBuffer; + + if (encoding === 1) { + // zlib compressed - decompress using built-in inflate + // zlib 压缩 - 使用内置 inflate 解压 + const compressedData = new Uint8Array(this.buffer, this.offset, compressedLength); + this.offset += compressedLength; + + // Calculate expected uncompressed size + // 计算预期的未压缩大小 + const elementSize = elementType === 'bool' ? 1 + : elementType === 'int32' || elementType === 'float32' ? 4 + : 8; + const expectedSize = arrayLength * elementSize; + + // Decompress + // 解压 + const decompressed = this.decompressZlib(compressedData, expectedSize); + // Copy to new ArrayBuffer to avoid SharedArrayBuffer issues + // 复制到新 ArrayBuffer 以避免 SharedArrayBuffer 问题 + data = new Uint8Array(decompressed).buffer; + } else { + // Uncompressed + // 未压缩 + const elementSize = elementType === 'bool' ? 1 + : elementType === 'int32' || elementType === 'float32' ? 4 + : 8; + const byteLength = arrayLength * elementSize; + data = this.buffer.slice(this.offset, this.offset + byteLength); + this.offset += byteLength; + } + + return this.convertToTypedArray(data, arrayLength, elementType); + } + + /** + * Convert ArrayBuffer to typed array based on element type + * 根据元素类型将 ArrayBuffer 转换为类型数组 + */ + private convertToTypedArray(data: ArrayBuffer, arrayLength: number, elementType: 'bool' | 'int32' | 'int64' | 'float32' | 'float64'): number[] | bigint[] | Float32Array | Float64Array { + switch (elementType) { + case 'bool': { + const view = new Uint8Array(data); + const bools: number[] = []; + for (let i = 0; i < arrayLength && i < view.length; i++) { + bools.push(view[i] !== 0 ? 1 : 0); + } + return bools; + } + + case 'int32': { + const int32View = new Int32Array(data); + return Array.from(int32View); + } + + case 'int64': { + const view = new DataView(data); + const int64s: bigint[] = []; + for (let i = 0; i < arrayLength; i++) { + int64s.push(view.getBigInt64(i * 8, true)); + } + return int64s; + } + + case 'float32': + return new Float32Array(data); + + case 'float64': + return new Float64Array(data); + } + } + + /** + * Decompress zlib data using pako + * 使用 pako 解压 zlib 数据 + */ + private decompressZlib(compressedData: Uint8Array, _expectedSize: number): Uint8Array { + try { + // pako.inflate handles zlib format automatically + // pako.inflate 自动处理 zlib 格式 + return pako.inflate(compressedData); + } catch (e) { + console.warn('[FBXLoader] Decompression error:', e); + return new Uint8Array(_expectedSize); + } + } + + /** + * Extract geometry from FBX Geometry node + * 从 FBX Geometry 节点提取几何数据 + */ + private extractGeometry(node: FBXNode): FBXGeometry | null { + // Get geometry ID and name + // 获取几何 ID 和名称 + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'Geometry'; + if (typeof nameProp === 'string') { + // FBX name format: "Name\x00\x01Geometry" + name = nameProp.split('\x00')[0] || name; + } + + // Find Vertices, PolygonVertexIndex, etc. + // 查找 Vertices、PolygonVertexIndex 等 + let vertices: number[] = []; + let indices: number[] = []; + let normals: number[] | undefined; + let uvs: number[] | undefined; + + for (const child of node.children) { + if (child.name === 'Vertices') { + const prop = child.properties[0]; + vertices = this.toNumberArray(prop); + } else if (child.name === 'PolygonVertexIndex') { + // FBX uses negative indices for polygon end markers + // FBX 使用负索引作为多边形结束标记 + const prop = child.properties[0]; + const polyIndices = this.toNumberArray(prop); + + // Convert polygon indices to triangles + // 将多边形索引转换为三角形 + indices = this.triangulatePolygons(polyIndices); + } else if (child.name === 'LayerElementNormal') { + normals = this.extractLayerElement(child, 'Normals'); + } else if (child.name === 'LayerElementUV') { + uvs = this.extractLayerElement(child, 'UV'); + } + } + + if (vertices.length === 0) return null; + + return { id, name, vertices, indices, normals, uvs }; + } + + /** + * Convert FBX property to number array + * 将 FBX 属性转换为数字数组 + * + * Handles all typed arrays (Int32Array, Float64Array, etc.) and regular arrays + * 处理所有类型数组(Int32Array、Float64Array 等)和普通数组 + */ + private toNumberArray(prop: FBXProperty): number[] { + if (prop instanceof Float64Array || prop instanceof Float32Array) { + return Array.from(prop); + } else if (prop instanceof Int32Array || prop instanceof Uint32Array) { + return Array.from(prop); + } else if (prop instanceof Uint8Array || prop instanceof Int8Array) { + return Array.from(prop); + } else if (prop instanceof Int16Array || prop instanceof Uint16Array) { + return Array.from(prop); + } else if (prop instanceof BigInt64Array) { + return Array.from(prop, v => Number(v)); + } else if (Array.isArray(prop)) { + return prop.map(Number); + } + return []; + } + + /** + * Extract layer element data (normals, UVs, etc.) + * 提取层元素数据(法线、UV 等) + */ + private extractLayerElement(node: FBXNode, dataName: string): number[] | undefined { + for (const child of node.children) { + if (child.name === dataName) { + const prop = child.properties[0]; + const arr = this.toNumberArray(prop); + if (arr.length > 0) { + return arr; + } + } + } + return undefined; + } + + /** + * Triangulate FBX polygon indices + * 三角化 FBX 多边形索引 + * + * FBX uses negative index to mark polygon end: + * [0, 1, -3] = triangle (0, 1, 2) + * [0, 1, 2, -4] = quad (0, 1, 2, 3) -> 2 triangles + */ + private triangulatePolygons(polyIndices: number[]): number[] { + const triangles: number[] = []; + const polygon: number[] = []; + + for (const idx of polyIndices) { + if (idx < 0) { + // End of polygon - convert negative to positive + // 多边形结束 - 将负数转为正数 + polygon.push(~idx); // Bitwise NOT to get actual index + + // Triangulate polygon (fan triangulation) + // 三角化多边形(扇形三角化) + for (let i = 1; i < polygon.length - 1; i++) { + triangles.push(polygon[0], polygon[i], polygon[i + 1]); + } + polygon.length = 0; + } else { + polygon.push(idx); + } + } + + return triangles; + } + + /** + * Extract model from FBX Model node + * 从 FBX Model 节点提取模型 + */ + private extractModel(node: FBXNode): FBXModel | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'Model'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + // Extract transform from Properties70 | 从 Properties70 提取变换 + let position: [number, number, number] = [0, 0, 0]; + let rotation: [number, number, number] = [0, 0, 0]; + let scale: [number, number, number] = [1, 1, 1]; + let preRotation: [number, number, number] | undefined; + + const props70 = node.children.find(c => c.name === 'Properties70'); + if (props70) { + for (const prop of props70.children) { + if (prop.name === 'P' && prop.properties.length >= 5) { + const propName = prop.properties[0] as string; + if (propName === 'Lcl Translation') { + position = [ + Number(prop.properties[4]) || 0, + Number(prop.properties[5]) || 0, + Number(prop.properties[6]) || 0 + ]; + } else if (propName === 'Lcl Rotation') { + rotation = [ + Number(prop.properties[4]) || 0, + Number(prop.properties[5]) || 0, + Number(prop.properties[6]) || 0 + ]; + } else if (propName === 'Lcl Scaling') { + scale = [ + Number(prop.properties[4]) || 1, + Number(prop.properties[5]) || 1, + Number(prop.properties[6]) || 1 + ]; + } else if (propName === 'PreRotation') { + // PreRotation is applied before Lcl Rotation + // PreRotation 在 Lcl Rotation 之前应用 + preRotation = [ + Number(prop.properties[4]) || 0, + Number(prop.properties[5]) || 0, + Number(prop.properties[6]) || 0 + ]; + } + } + } + } + + return { + id, + name, + materialIds: [], + position, + rotation, + scale, + preRotation + }; + } + + /** + * Extract material from FBX Material node + * 从 FBX Material 节点提取材质 + */ + private extractMaterial(node: FBXNode): FBXMaterial | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'Material'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + // Default values + // 默认值 + let diffuseColor: [number, number, number] = [0.8, 0.8, 0.8]; + let opacity = 1; + + // Find Properties70 node for material properties + // 查找 Properties70 节点获取材质属性 + const props70 = node.children.find(c => c.name === 'Properties70'); + if (props70) { + for (const prop of props70.children) { + if (prop.name === 'P' && prop.properties.length >= 5) { + const propName = prop.properties[0] as string; + if (propName === 'DiffuseColor') { + diffuseColor = [ + Number(prop.properties[4]) || 0.8, + Number(prop.properties[5]) || 0.8, + Number(prop.properties[6]) || 0.8 + ]; + } else if (propName === 'Opacity') { + opacity = Number(prop.properties[4]) || 1; + } + } + } + } + + return { id, name, diffuseColor, opacity }; + } + + // ===== Animation Extraction Methods ===== + + /** + * Extract AnimationStack from FBX node + * 从 FBX 节点提取 AnimationStack + */ + private extractAnimationStack(node: FBXNode): FBXAnimationStack | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'AnimationStack'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + return { id, name, layers: [] }; + } + + /** + * Extract AnimationLayer from FBX node + * 从 FBX 节点提取 AnimationLayer + */ + private extractAnimationLayer(node: FBXNode): FBXAnimationLayer | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'AnimationLayer'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + return { id, name, curveNodes: [] }; + } + + /** + * Extract AnimationCurveNode from FBX node + * 从 FBX 节点提取 AnimationCurveNode + */ + private extractAnimationCurveNode(node: FBXNode): FBXAnimationCurveNode | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'AnimationCurveNode'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + // Determine attribute type from name + // 从名称确定属性类型 + let attribute = 'T'; // Default to translation + if (name.includes('R') || name.toLowerCase().includes('rot')) { + attribute = 'R'; + } else if (name.includes('S') || name.toLowerCase().includes('scal')) { + attribute = 'S'; + } + + // Check Properties70 for d|X, d|Y, d|Z defaults (indicates the axis) + // 检查 Properties70 中的 d|X, d|Y, d|Z 默认值(表示轴向) + const props70 = node.children.find(c => c.name === 'Properties70'); + if (props70) { + for (const prop of props70.children) { + if (prop.name === 'P' && prop.properties.length >= 1) { + const propName = prop.properties[0] as string; + // Parse attribute name like "d|X", "d|Y", "d|Z" + // 解析属性名如 "d|X", "d|Y", "d|Z" + if (propName === 'd|X' || propName === 'd|Y' || propName === 'd|Z') { + // This is a valid curve node + break; + } + } + } + } + + return { + id, + name, + attribute, + targetModelId: BigInt(0), // Will be set by connections + curves: [] + }; + } + + /** + * Extract AnimationCurve from FBX node + * 从 FBX 节点提取 AnimationCurve + */ + private extractAnimationCurve(node: FBXNode): FBXAnimationCurve | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + let name = 'AnimationCurve'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + // Find KeyTime and KeyValueFloat + // 查找 KeyTime 和 KeyValueFloat + let keyTimesRaw: bigint[] = []; + let keyValuesRaw: number[] = []; + + for (const child of node.children) { + if (child.name === 'KeyTime') { + const prop = child.properties[0]; + if (prop instanceof BigInt64Array) { + keyTimesRaw = Array.from(prop); + } else if (Array.isArray(prop)) { + keyTimesRaw = prop.map(v => BigInt(v)); + } + } else if (child.name === 'KeyValueFloat') { + const prop = child.properties[0]; + keyValuesRaw = this.toNumberArray(prop); + } + } + + if (keyTimesRaw.length === 0 || keyValuesRaw.length === 0) { + return null; + } + + // Convert FBX time to seconds + // 将 FBX 时间转换为秒 + const keyTimes = new Float32Array(keyTimesRaw.length); + for (let i = 0; i < keyTimesRaw.length; i++) { + keyTimes[i] = Number(keyTimesRaw[i]) / FBX_TIME_SECOND; + } + + const keyValues = new Float32Array(keyValuesRaw); + + // Determine component index from name (d|X=0, d|Y=1, d|Z=2) + // 从名称确定分量索引 (d|X=0, d|Y=1, d|Z=2) + let componentIndex = 0; + if (name.includes('Y')) componentIndex = 1; + else if (name.includes('Z')) componentIndex = 2; + + return { id, name, keyTimes, keyValues, componentIndex }; + } + + /** + * Extract Deformer (Skin/Cluster) from FBX node + * 从 FBX 节点提取 Deformer(Skin/Cluster) + */ + private extractDeformer(node: FBXNode): FBXDeformer | null { + const id = node.properties[0] as bigint; + const nameProp = node.properties[1]; + const typeProp = node.properties[2]; + + let name = 'Deformer'; + if (typeof nameProp === 'string') { + name = nameProp.split('\x00')[0] || name; + } + + let type: 'Skin' | 'Cluster' = 'Skin'; + if (typeof typeProp === 'string') { + if (typeProp.includes('Cluster')) { + type = 'Cluster'; + } + } + + const deformer: FBXDeformer = { id, name, type }; + + // Extract cluster-specific data + // 提取 Cluster 特定数据 + if (type === 'Cluster') { + for (const child of node.children) { + if (child.name === 'Indexes') { + deformer.indexes = this.toNumberArray(child.properties[0]); + } else if (child.name === 'Weights') { + deformer.weights = this.toNumberArray(child.properties[0]); + } else if (child.name === 'Transform') { + const arr = this.toNumberArray(child.properties[0]); + if (arr.length >= 16) { + // FBX matrices are stored in column-major order, compatible with WebGL + // (verified by Three.js FBXLoader which uses Matrix4.fromArray directly) + // FBX 矩阵以列主序存储,与 WebGL 兼容 + deformer.transform = new Float32Array(arr.slice(0, 16)); + } + } else if (child.name === 'TransformLink') { + const arr = this.toNumberArray(child.properties[0]); + if (arr.length >= 16) { + // FBX matrices are stored in column-major order, compatible with WebGL + // (verified by Three.js FBXLoader which uses Matrix4.fromArray directly) + // FBX 矩阵以列主序存储,与 WebGL 兼容 + deformer.transformLink = new Float32Array(arr.slice(0, 16)); + } + } + } + } + + return deformer; + } + + /** + * Parse Connections section + * 解析 Connections 部分 + */ + private parseConnections(nodes: FBXNode[]): FBXConnection[] { + const connections: FBXConnection[] = []; + const connectionsNode = nodes.find(n => n.name === 'Connections'); + + if (!connectionsNode) return connections; + + for (const child of connectionsNode.children) { + if (child.name === 'C' && child.properties.length >= 3) { + const type = child.properties[0] as string; + const fromId = child.properties[1] as bigint; + const toId = child.properties[2] as bigint; + const property = child.properties.length > 3 ? child.properties[3] as string : undefined; + + connections.push({ type, fromId, toId, property }); + } + } + + return connections; + } + + /** + * Build animation hierarchy using connections + * 使用连接构建动画层级 + */ + private buildAnimationHierarchy( + stacks: FBXAnimationStack[], + layers: FBXAnimationLayer[], + curveNodes: FBXAnimationCurveNode[], + curves: FBXAnimationCurve[], + connections: FBXConnection[] + ): void { + // Build ID maps + // 构建 ID 映射 + const layerMap = new Map(layers.map(l => [l.id, l])); + const curveNodeMap = new Map(curveNodes.map(cn => [cn.id, cn])); + const curveMap = new Map(curves.map(c => [c.id, c])); + + // Process connections + // 处理连接 + for (const conn of connections) { + // Layer -> Stack connection + // 层 -> 栈连接 + const layer = layerMap.get(conn.fromId); + if (layer) { + for (const stack of stacks) { + if (stack.id === conn.toId) { + stack.layers.push(layer); + break; + } + } + continue; + } + + // CurveNode -> Layer connection + // 曲线节点 -> 层连接 + const curveNode = curveNodeMap.get(conn.fromId); + if (curveNode) { + const targetLayer = layerMap.get(conn.toId); + if (targetLayer) { + targetLayer.curveNodes.push(curveNode); + } else { + // CurveNode -> Model connection (sets target) + // 曲线节点 -> 模型连接(设置目标) + curveNode.targetModelId = conn.toId; + if (conn.property) { + // Property indicates the attribute type + // 属性表示属性类型 + if (conn.property.includes('Lcl Translation')) { + curveNode.attribute = 'T'; + } else if (conn.property.includes('Lcl Rotation')) { + curveNode.attribute = 'R'; + } else if (conn.property.includes('Lcl Scaling')) { + curveNode.attribute = 'S'; + } + } + } + continue; + } + + // Curve -> CurveNode connection + // 曲线 -> 曲线节点连接 + const curve = curveMap.get(conn.fromId); + if (curve) { + const targetCurveNode = curveNodeMap.get(conn.toId); + if (targetCurveNode) { + // Determine component index from property + // 从属性确定分量索引 + if (conn.property) { + if (conn.property === 'd|X') curve.componentIndex = 0; + else if (conn.property === 'd|Y') curve.componentIndex = 1; + else if (conn.property === 'd|Z') curve.componentIndex = 2; + } + targetCurveNode.curves.push(curve); + } + } + } + + } + + /** + * Convert FBX animation stacks to GLTF animation clips + * 将 FBX 动画栈转换为 GLTF 动画片段 + * + * IMPORTANT: FBX animation only stores Lcl Rotation, but the actual rotation + * is PreRotation * LclRotation. We must apply PreRotation to animation data. + * 重要:FBX 动画只存储 Lcl Rotation,但实际旋转是 PreRotation * LclRotation。 + * 我们必须将 PreRotation 应用到动画数据。 + */ + private convertToAnimationClips( + stacks: FBXAnimationStack[], + models: FBXModel[] + ): IGLTFAnimationClip[] { + const clips: IGLTFAnimationClip[] = []; + + // Build model ID to node index map and model ID to model map + // 构建模型 ID 到节点索引的映射,以及模型 ID 到模型的映射 + const modelToNodeIndex = new Map(); + const modelIdToModel = new Map(); + models.forEach((model, index) => { + modelToNodeIndex.set(model.id, index); + modelIdToModel.set(model.id, model); + }); + + for (const stack of stacks) { + // Merge all layers (for now, we just use the first layer's data) + // 合并所有层(目前,我们只使用第一层的数据) + const samplers: IAnimationSampler[] = []; + const channels: IAnimationChannel[] = []; + + for (const layer of stack.layers) { + for (const curveNode of layer.curveNodes) { + const nodeIndex = modelToNodeIndex.get(curveNode.targetModelId); + if (nodeIndex === undefined) continue; + + // Get the model for this animation target (for PreRotation) + // 获取此动画目标的模型(用于 PreRotation) + const targetModel = modelIdToModel.get(curveNode.targetModelId); + + // Merge X, Y, Z curves into a single sampler + // 将 X, Y, Z 曲线合并为单个采样器 + const xCurve = curveNode.curves.find(c => c.componentIndex === 0); + const yCurve = curveNode.curves.find(c => c.componentIndex === 1); + const zCurve = curveNode.curves.find(c => c.componentIndex === 2); + + if (!xCurve && !yCurve && !zCurve) continue; + + // Use the curve with most keyframes as reference + // 使用关键帧最多的曲线作为参考 + const refCurve = [xCurve, yCurve, zCurve] + .filter(c => c !== undefined) + .reduce((a, b) => (a!.keyTimes.length > b!.keyTimes.length ? a : b))!; + + const keyCount = refCurve.keyTimes.length; + + // Build input (time) and output (values) arrays + // 构建输入(时间)和输出(值)数组 + const input = refCurve.keyTimes; + let output: Float32Array; + + // Determine path and build output + // 确定路径并构建输出 + let path: 'translation' | 'rotation' | 'scale'; + if (curveNode.attribute === 'T') { + path = 'translation'; + output = new Float32Array(keyCount * 3); + for (let i = 0; i < keyCount; i++) { + const time = input[i]; + output[i * 3] = xCurve ? this.sampleCurveAtTime(xCurve, time) : 0; + output[i * 3 + 1] = yCurve ? this.sampleCurveAtTime(yCurve, time) : 0; + output[i * 3 + 2] = zCurve ? this.sampleCurveAtTime(zCurve, time) : 0; + } + } else if (curveNode.attribute === 'R') { + path = 'rotation'; + // Convert euler angles (degrees) to quaternions + // Apply PreRotation before Lcl Rotation: final = PreRotation * LclRotation + // 将欧拉角(度)转换为四元数 + // 在 Lcl Rotation 之前应用 PreRotation: final = PreRotation * LclRotation + output = new Float32Array(keyCount * 4); + + // Get PreRotation quaternion if available | 如果有 PreRotation 则获取其四元数 + let preRotQuat: [number, number, number, number] | null = null; + if (targetModel?.preRotation) { + const preRx = targetModel.preRotation[0] * Math.PI / 180; + const preRy = targetModel.preRotation[1] * Math.PI / 180; + const preRz = targetModel.preRotation[2] * Math.PI / 180; + preRotQuat = this.eulerToQuaternion(preRx, preRy, preRz); + } + + for (let i = 0; i < keyCount; i++) { + const time = input[i]; + // Sample each axis curve at this time | 在此时间采样每个轴曲线 + const rx = xCurve ? this.sampleCurveAtTime(xCurve, time) * Math.PI / 180 : 0; + const ry = yCurve ? this.sampleCurveAtTime(yCurve, time) * Math.PI / 180 : 0; + const rz = zCurve ? this.sampleCurveAtTime(zCurve, time) * Math.PI / 180 : 0; + const lclQuat = this.eulerToQuaternion(rx, ry, rz); + + // Apply PreRotation: final = preRotation * lclRotation + // 应用 PreRotation: final = preRotation * lclRotation + let finalQuat: [number, number, number, number]; + if (preRotQuat) { + finalQuat = this.multiplyQuaternion(preRotQuat, lclQuat); + } else { + finalQuat = lclQuat; + } + + output[i * 4] = finalQuat[0]; + output[i * 4 + 1] = finalQuat[1]; + output[i * 4 + 2] = finalQuat[2]; + output[i * 4 + 3] = finalQuat[3]; + } + } else { + path = 'scale'; + output = new Float32Array(keyCount * 3); + for (let i = 0; i < keyCount; i++) { + const time = input[i]; + output[i * 3] = xCurve ? this.sampleCurveAtTime(xCurve, time, 1) : 1; + output[i * 3 + 1] = yCurve ? this.sampleCurveAtTime(yCurve, time, 1) : 1; + output[i * 3 + 2] = zCurve ? this.sampleCurveAtTime(zCurve, time, 1) : 1; + } + } + + const samplerIndex = samplers.length; + samplers.push({ + input, + output, + interpolation: 'LINEAR' + }); + + channels.push({ + samplerIndex, + target: { + nodeIndex, + path + } + }); + } + } + + if (channels.length > 0) { + // Calculate duration from max time + // 从最大时间计算持续时间 + let duration = 0; + for (const sampler of samplers) { + const maxTime = sampler.input[sampler.input.length - 1]; + if (maxTime > duration) duration = maxTime; + } + + clips.push({ + name: stack.name, + duration, + samplers, + channels + }); + } + } + + return clips; + } + + /** + * Convert euler angles (radians) to quaternion + * 将欧拉角(弧度)转换为四元数 + * + * Uses XYZ intrinsic rotation order (equivalent to ZYX extrinsic). + * This matches the FBX Lcl Rotation convention used in this loader. + * 使用 XYZ intrinsic 旋转顺序(等价于 ZYX extrinsic)。 + * 这与本加载器中使用的 FBX Lcl Rotation 约定匹配。 + */ + private eulerToQuaternion(x: number, y: number, z: number): [number, number, number, number] { + const cx = Math.cos(x / 2); + const sx = Math.sin(x / 2); + const cy = Math.cos(y / 2); + const sy = Math.sin(y / 2); + const cz = Math.cos(z / 2); + const sz = Math.sin(z / 2); + + // XYZ intrinsic order (first X, then Y, then Z around local axes) + // XYZ intrinsic 顺序(先绕局部 X 轴,再绕局部 Y 轴,最后绕局部 Z 轴) + return [ + sx * cy * cz - cx * sy * sz, // x + cx * sy * cz + sx * cy * sz, // y + cx * cy * sz - sx * sy * cz, // z + cx * cy * cz + sx * sy * sz // w + ]; + } + + /** + * Multiply two quaternions (a * b) + * 两个四元数相乘 (a * b) + * + * Result represents rotation b followed by rotation a + * 结果表示先旋转 b 再旋转 a + */ + private multiplyQuaternion( + a: [number, number, number, number], + b: [number, number, number, number] + ): [number, number, number, number] { + const [ax, ay, az, aw] = a; + const [bx, by, bz, bw] = b; + + return [ + aw * bx + ax * bw + ay * bz - az * by, // x + aw * by - ax * bz + ay * bw + az * bx, // y + aw * bz + ax * by - ay * bx + az * bw, // z + aw * bw - ax * bx - ay * by - az * bz // w + ]; + } + + /** + * Sample animation curve at a specific time with linear interpolation + * 使用线性插值在特定时间采样动画曲线 + * + * @param curve - Animation curve to sample | 要采样的动画曲线 + * @param time - Time in seconds | 时间(秒) + * @param defaultValue - Default value if curve is empty | 曲线为空时的默认值 + * @returns Interpolated value at the given time | 给定时间的插值 + */ + private sampleCurveAtTime(curve: FBXAnimationCurve, time: number, defaultValue: number = 0): number { + const { keyTimes, keyValues } = curve; + + if (keyTimes.length === 0) return defaultValue; + if (keyTimes.length === 1) return keyValues[0]; + + // Clamp time to curve range | 将时间钳制到曲线范围 + if (time <= keyTimes[0]) return keyValues[0]; + if (time >= keyTimes[keyTimes.length - 1]) return keyValues[keyValues.length - 1]; + + // Find keyframe pair | 查找关键帧对 + let i0 = 0; + for (let i = 0; i < keyTimes.length - 1; i++) { + if (time >= keyTimes[i] && time <= keyTimes[i + 1]) { + i0 = i; + break; + } + } + const i1 = i0 + 1; + + // Linear interpolation | 线性插值 + const t0 = keyTimes[i0]; + const t1 = keyTimes[i1]; + const t = (time - t0) / (t1 - t0); + + return keyValues[i0] + (keyValues[i1] - keyValues[i0]) * t; + } + + /** + * Build skeleton data from deformers + * 从变形器构建骨骼数据 + * + * @param deformers - All deformers + * @param models - All models + * @param connections - All connections + * @param clusterToJointIndex - Output map from cluster ID to joint index for skinning + */ + private buildSkeletonData( + deformers: FBXDeformer[], + models: FBXModel[], + connections: FBXConnection[], + clusterToJointIndex?: Map + ): ISkeletonData | null { + // Find all Cluster deformers + // 查找所有 Cluster 变形器 + const clusters = deformers.filter(d => d.type === 'Cluster'); + if (clusters.length === 0) return null; + + // Build model ID to info map + // 构建模型 ID 到信息的映射 + const modelToIndex = new Map(); + const modelIdToModel = new Map(); + models.forEach((model, index) => { + modelToIndex.set(model.id, index); + modelIdToModel.set(model.id, model); + }); + + // Build parent relationships from connections + // 从连接构建父子关系 + const modelParentMap = new Map(); + for (const conn of connections) { + if (conn.type === 'OO') { + // fromId is child, toId is parent + // fromId 是子节点,toId 是父节点 + const childModel = modelIdToModel.get(conn.fromId); + const parentModel = modelIdToModel.get(conn.toId); + if (childModel && parentModel) { + modelParentMap.set(conn.fromId, conn.toId); + } + } + } + + // Find bone connections (Cluster -> Model) + // 查找骨骼连接 (Cluster -> Model) + const clusterToBone = new Map(); + for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) { + // fromId is the bone model + // fromId 是骨骼模型 + clusterToBone.set(cluster.id, conn.fromId); + } + } + } + + // Collect all bone model IDs + // 收集所有骨骼模型 ID + const boneModelIds = new Set(); + for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (boneModelId) { + boneModelIds.add(boneModelId); + } + } + + const joints: ISkeletonJoint[] = []; + const boneModelIdToJointIndex = new Map(); + + for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const nodeIndex = modelToIndex.get(boneModelId); + if (nodeIndex === undefined) continue; + + const model = modelIdToModel.get(boneModelId); + const name = model?.name || `Joint_${joints.length}`; + + const jointIndex = joints.length; + boneModelIdToJointIndex.set(boneModelId, jointIndex); + + // Store cluster ID -> joint index mapping for skinning | 存储簇ID到关节索引的映射用于蒙皮 + if (clusterToJointIndex) { + clusterToJointIndex.set(cluster.id, jointIndex); + } + + // FBX TransformLink is the bone's world matrix at bind pose + // inverseBindMatrix = inverse(TransformLink) + // FBX TransformLink 是骨骼在绑定姿势时的世界矩阵 + // inverseBindMatrix = inverse(TransformLink) + const inverseBindMatrix = cluster.transformLink + ? this.invertMatrix4(cluster.transformLink) + : this.createIdentityMatrix(); + + joints.push({ + name, + nodeIndex, + parentIndex: -1, // Will be set later + inverseBindMatrix + }); + } + + // Set parent indices + // 设置父索引 + for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const jointIndex = boneModelIdToJointIndex.get(boneModelId); + if (jointIndex === undefined) continue; + + // Find parent model that is also a bone + // 查找同时是骨骼的父模型 + let parentModelId = modelParentMap.get(boneModelId); + while (parentModelId) { + const parentJointIndex = boneModelIdToJointIndex.get(parentModelId); + if (parentJointIndex !== undefined) { + joints[jointIndex].parentIndex = parentJointIndex; + break; + } + parentModelId = modelParentMap.get(parentModelId); + } + } + + if (joints.length === 0) return null; + + // Find root joint (one with parentIndex === -1) + // 查找根关节(parentIndex === -1 的那个) + let rootJointIndex = 0; + for (let i = 0; i < joints.length; i++) { + if (joints[i].parentIndex === -1) { + rootJointIndex = i; + break; + } + } + + return { + joints, + rootJointIndex + }; + } + + /** + * Create identity matrix + * 创建单位矩阵 + */ + private createIdentityMatrix(): Float32Array { + const m = new Float32Array(16); + m[0] = 1; m[5] = 1; m[10] = 1; m[15] = 1; + return m; + } + + /** + * Invert a 4x4 matrix + * 求 4x4 矩阵的逆 + * + * Uses the adjugate method with cofactors + * 使用余子式的伴随矩阵方法 + */ + private invertMatrix4(m: Float32Array): Float32Array { + const out = new Float32Array(16); + const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; + const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; + const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; + const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; + + const b00 = m00 * m11 - m01 * m10; + const b01 = m00 * m12 - m02 * m10; + const b02 = m00 * m13 - m03 * m10; + const b03 = m01 * m12 - m02 * m11; + const b04 = m01 * m13 - m03 * m11; + const b05 = m02 * m13 - m03 * m12; + const b06 = m20 * m31 - m21 * m30; + const b07 = m20 * m32 - m22 * m30; + const b08 = m20 * m33 - m23 * m30; + const b09 = m21 * m32 - m22 * m31; + const b10 = m21 * m33 - m23 * m31; + const b11 = m22 * m33 - m23 * m32; + + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + + if (Math.abs(det) < 1e-8) { + // Matrix is singular, return identity | 矩阵奇异,返回单位矩阵 + return this.createIdentityMatrix(); + } + + det = 1.0 / det; + + out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; + out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; + out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; + out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; + out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; + out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; + out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; + out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; + out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; + out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; + out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; + out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; + out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; + out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; + out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; + out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; + + return out; + } + + // ===== ASCII FBX Parsing ===== + + /** + * Parse ASCII FBX format + * 解析 ASCII FBX 格式 + */ + private parseASCII(text: string): { geometries: FBXGeometry[]; models: FBXModel[]; materials: FBXMaterial[] } { + const geometries: FBXGeometry[] = []; + const models: FBXModel[] = []; + const materials: FBXMaterial[] = []; + + // Find Geometry sections using a safer approach + // 使用更安全的方法查找 Geometry 部分 + const geometryHeaderRegex = /Geometry:\s*(\d+),\s*"Geometry::([^"]*)",\s*"Mesh"\s*{/g; + let match; + + while ((match = geometryHeaderRegex.exec(text)) !== null) { + const name = match[2] || 'Geometry'; + // Find matching closing brace by counting braces + // 通过计数括号找到匹配的右括号 + const startIdx = match.index + match[0].length; + let braceCount = 1; + let endIdx = startIdx; + for (let i = startIdx; i < text.length && braceCount > 0; i++) { + if (text[i] === '{') braceCount++; + else if (text[i] === '}') braceCount--; + endIdx = i; + } + const content = text.slice(startIdx, endIdx); + + // Extract vertices + // 提取顶点 + const verticesMatch = content.match(/Vertices:\s*\*\d+\s*{\s*a:\s*([\d\s.,eE+-]+)/); + const vertices: number[] = []; + if (verticesMatch) { + const vertexStr = verticesMatch[1].replace(/\s+/g, ''); + for (const v of vertexStr.split(',')) { + const val = parseFloat(v); + if (!isNaN(val)) vertices.push(val); + } + } + + // Extract polygon indices + // 提取多边形索引 + const indicesMatch = content.match(/PolygonVertexIndex:\s*\*\d+\s*{\s*a:\s*([\d\s.,-]+)/); + let indices: number[] = []; + if (indicesMatch) { + const indexStr = indicesMatch[1].replace(/\s+/g, ''); + const polyIndices: number[] = []; + for (const i of indexStr.split(',')) { + const val = parseInt(i, 10); + if (!isNaN(val)) polyIndices.push(val); + } + indices = this.triangulatePolygons(polyIndices); + } + + // Extract normals + // 提取法线 + let normals: number[] | undefined; + const normalsMatch = content.match(/Normals:\s*\*\d+\s*{\s*a:\s*([\d\s.,eE+-]+)/); + if (normalsMatch) { + normals = []; + const normalStr = normalsMatch[1].replace(/\s+/g, ''); + for (const n of normalStr.split(',')) { + const val = parseFloat(n); + if (!isNaN(val)) normals.push(val); + } + } + + // Extract UVs + // 提取 UV + let uvs: number[] | undefined; + const uvsMatch = content.match(/UV:\s*\*\d+\s*{\s*a:\s*([\d\s.,eE+-]+)/); + if (uvsMatch) { + uvs = []; + const uvStr = uvsMatch[1].replace(/\s+/g, ''); + for (const u of uvStr.split(',')) { + const val = parseFloat(u); + if (!isNaN(val)) uvs.push(val); + } + } + + if (vertices.length > 0) { + // ASCII format doesn't have real IDs, use index | ASCII 格式没有真实 ID,使用索引 + geometries.push({ id: BigInt(geometries.length), name, vertices, indices, normals, uvs }); + } + } + + // Find Material sections + // 查找 Material 部分 + const materialRegex = /Material:\s*(\d+),\s*"Material::([^"]*)",\s*""\s*{/g; + while ((match = materialRegex.exec(text)) !== null) { + materials.push({ + id: BigInt(match[1]), + name: match[2] || 'Material', + diffuseColor: [0.8, 0.8, 0.8], + opacity: 1 + }); + } + + return { geometries, models, materials }; + } + + // ===== Mesh Building ===== + + /** + * Build mesh data from parsed geometries with optional skinning + * 从解析的几何数据构建网格数据(含可选蒙皮) + * + * @param geometries - All geometries + * @param deformers - All deformers + * @param connections - All connections + * @param clusterToJointIndex - Map from cluster ID to skeleton joint index + */ + private buildMeshes( + geometries: FBXGeometry[], + deformers: FBXDeformer[] = [], + connections: FBXConnection[] = [], + clusterToJointIndex: Map = new Map() + ): IMeshData[] { + const meshes: IMeshData[] = []; + + // Build geometry ID to skinning map | 构建几何体ID到蒙皮的映射 + const geometrySkinning = this.buildSkinningData(geometries, deformers, connections, clusterToJointIndex); + + for (const geom of geometries) { + const skinning = geometrySkinning.get(geom.id); + const mesh = this.buildMesh(geom, skinning); + if (mesh) meshes.push(mesh); + } + + // If no meshes, create an empty one + // 如果没有网格,创建一个空的 + if (meshes.length === 0) { + throw new Error('FBX file contains no valid geometry'); + } + + return meshes; + } + + /** + * Build skinning data for each geometry from deformers + * 从变形器构建每个几何体的蒙皮数据 + * + * @param geometries - All geometries + * @param deformers - All deformers + * @param connections - All connections + * @param clusterToJointIndex - Map from cluster ID to skeleton joint index + */ + private buildSkinningData( + geometries: FBXGeometry[], + deformers: FBXDeformer[], + connections: FBXConnection[], + clusterToJointIndex: Map + ): Map { + const result = new Map(); + + // Find Skin deformers and their clusters | 查找 Skin 变形器及其簇 + const skins = deformers.filter(d => d.type === 'Skin'); + const clusters = deformers.filter(d => d.type === 'Cluster'); + + if (skins.length === 0 || clusters.length === 0) { + return result; + } + + // Build Skin -> Clusters mapping | 构建 Skin -> Clusters 映射 + const skinClusters = new Map(); + for (const conn of connections) { + if (conn.type === 'OO') { + const skin = skins.find(s => s.id === conn.toId); + const cluster = clusters.find(c => c.id === conn.fromId); + if (skin && cluster) { + if (!skinClusters.has(skin.id)) { + skinClusters.set(skin.id, []); + } + skinClusters.get(skin.id)!.push(cluster); + } + } + } + + // Build Geometry -> Skin mapping | 构建 Geometry -> Skin 映射 + const geometrySkin = new Map(); + for (const conn of connections) { + if (conn.type === 'OO') { + const geom = geometries.find(g => g.id === conn.toId); + const skin = skins.find(s => s.id === conn.fromId); + if (geom && skin) { + geometrySkin.set(geom.id, skin.id); + } + } + } + + // For each geometry with skin, build per-vertex skinning data + // 为每个带蒙皮的几何体构建逐顶点蒙皮数据 + for (const [geomId, skinId] of geometrySkin) { + const geom = geometries.find(g => g.id === geomId); + const clusterList = skinClusters.get(skinId); + + if (!geom || !clusterList || clusterList.length === 0) continue; + + const vertexCount = geom.vertices.length / 3; + const joints = new Uint8Array(vertexCount * 4); + const weights = new Float32Array(vertexCount * 4); + + // Temporary storage for per-vertex influences | 每顶点影响的临时存储 + const vertexInfluences: Array> = []; + for (let i = 0; i < vertexCount; i++) { + vertexInfluences.push([]); + } + + // Collect influences from each cluster | 从每个簇收集影响 + for (const cluster of clusterList) { + if (!cluster.indexes || !cluster.weights) continue; + + // Use the correct joint index from skeleton | 使用骨骼的正确关节索引 + const jointIndex = clusterToJointIndex.get(cluster.id); + if (jointIndex === undefined) { + console.warn(`[FBXLoader] Cluster ${cluster.id} not found in skeleton`); + continue; + } + + for (let i = 0; i < cluster.indexes.length; i++) { + const vertexIndex = cluster.indexes[i]; + const weight = cluster.weights[i]; + if (vertexIndex < vertexCount && weight > 0.001) { + vertexInfluences[vertexIndex].push({ + joint: jointIndex, + weight + }); + } + } + } + + // Convert to fixed 4-influence format | 转换为固定的4影响格式 + for (let v = 0; v < vertexCount; v++) { + const influences = vertexInfluences[v]; + + // Sort by weight descending | 按权重降序排序 + influences.sort((a, b) => b.weight - a.weight); + + // Take top 4 influences | 取前4个影响 + let totalWeight = 0; + for (let i = 0; i < 4 && i < influences.length; i++) { + joints[v * 4 + i] = influences[i].joint; + weights[v * 4 + i] = influences[i].weight; + totalWeight += influences[i].weight; + } + + // Normalize weights | 归一化权重 + if (totalWeight > 0) { + for (let i = 0; i < 4; i++) { + weights[v * 4 + i] /= totalWeight; + } + } + } + + result.set(geomId, { joints, weights }); + console.log(`[FBXLoader] Built skinning for geometry: ${vertexCount} vertices, ${clusterList.length} clusters`); + } + + return result; + } + + /** + * Build a single mesh from FBX geometry with optional skinning + * 从 FBX 几何数据构建单个网格(含可选蒙皮) + */ + private buildMesh( + geom: FBXGeometry, + skinning?: { joints: Uint8Array; weights: Float32Array } + ): IMeshData | null { + if (geom.vertices.length === 0 || geom.indices.length === 0) { + return null; + } + + // FBX vertices are in "by control point" format + // Need to expand for indexed rendering + // FBX 顶点是"按控制点"格式,需要为索引渲染展开 + + const vertices = new Float32Array(geom.vertices); + const indices = new Uint32Array(geom.indices); + + // Handle normals + // 处理法线 + let normals: Float32Array; + if (geom.normals && geom.normals.length > 0) { + // Check if normals are per-vertex or per-polygon-vertex + // 检查法线是每顶点还是每多边形顶点 + if (geom.normals.length === geom.vertices.length) { + normals = new Float32Array(geom.normals); + } else { + // Need to map normals from polygon-vertex to vertex + // 需要将法线从多边形顶点映射到顶点 + normals = this.mapNormalsToVertices(geom.vertices.length / 3, geom.indices, geom.normals); + } + } else { + // Generate normals + // 生成法线 + normals = this.generateNormals(geom.vertices, geom.indices); + } + + // Handle UVs + // 处理 UV + let uvs: Float32Array; + if (geom.uvs && geom.uvs.length > 0) { + // UVs might be indexed differently, try to map + // UV 可能有不同的索引方式,尝试映射 + const vertexCount = geom.vertices.length / 3; + if (geom.uvs.length === vertexCount * 2) { + uvs = new Float32Array(geom.uvs); + } else { + // Create default UVs + // 创建默认 UV + uvs = new Float32Array(vertexCount * 2); + } + } else { + uvs = new Float32Array((geom.vertices.length / 3) * 2); + } + + // Calculate bounds + // 计算边界 + const bounds = this.calculateMeshBounds(geom.vertices); + + const mesh: IMeshData = { + name: geom.name, + vertices, + indices, + normals, + uvs, + bounds, + materialIndex: 0 + }; + + // Add skinning data if available | 如果有蒙皮数据则添加 + if (skinning) { + mesh.joints = skinning.joints; + mesh.weights = skinning.weights; + } + + return mesh; + } + + /** + * Map per-polygon-vertex normals to per-vertex normals + * 将每多边形顶点法线映射到每顶点法线 + */ + private mapNormalsToVertices(vertexCount: number, indices: number[], normals: number[]): Float32Array { + const vertexNormals = new Float32Array(vertexCount * 3); + const normalCounts = new Uint8Array(vertexCount); + + for (let i = 0; i < indices.length; i++) { + const vIdx = indices[i]; + const nBase = i * 3; + const vBase = vIdx * 3; + + if (nBase + 2 < normals.length) { + vertexNormals[vBase] += normals[nBase]; + vertexNormals[vBase + 1] += normals[nBase + 1]; + vertexNormals[vBase + 2] += normals[nBase + 2]; + normalCounts[vIdx]++; + } + } + + // Average and normalize + // 平均化和归一化 + for (let i = 0; i < vertexCount; i++) { + const count = normalCounts[i] || 1; + const base = i * 3; + const nx = vertexNormals[base] / count; + const ny = vertexNormals[base + 1] / count; + const nz = vertexNormals[base + 2] / count; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; + vertexNormals[base] = nx / len; + vertexNormals[base + 1] = ny / len; + vertexNormals[base + 2] = nz / len; + } + + return vertexNormals; + } + + /** + * Generate flat normals for mesh + * 为网格生成平面法线 + */ + private generateNormals(positions: number[], indices: number[]): Float32Array { + const normals = new Float32Array(positions.length); + + for (let i = 0; i < indices.length; i += 3) { + const i0 = indices[i] * 3; + const i1 = indices[i + 1] * 3; + const i2 = indices[i + 2] * 3; + + // Get triangle vertices + const v0x = positions[i0], v0y = positions[i0 + 1], v0z = positions[i0 + 2]; + const v1x = positions[i1], v1y = positions[i1 + 1], v1z = positions[i1 + 2]; + const v2x = positions[i2], v2y = positions[i2 + 1], v2z = positions[i2 + 2]; + + // Calculate edge vectors + const e1x = v1x - v0x, e1y = v1y - v0y, e1z = v1z - v0z; + const e2x = v2x - v0x, e2y = v2y - v0y, e2z = v2z - v0z; + + // Cross product + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + + // Add to vertex normals + normals[i0] += nx; normals[i0 + 1] += ny; normals[i0 + 2] += nz; + normals[i1] += nx; normals[i1 + 1] += ny; normals[i1 + 2] += nz; + normals[i2] += nx; normals[i2 + 1] += ny; normals[i2 + 2] += nz; + } + + // Normalize + for (let i = 0; i < normals.length; i += 3) { + const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2); + if (len > 0) { + normals[i] /= len; + normals[i + 1] /= len; + normals[i + 2] /= len; + } else { + normals[i + 1] = 1; // Default up + } + } + + return normals; + } + + /** + * Build materials from FBX materials + * 从 FBX 材质构建材质 + */ + private buildMaterials(fbxMaterials: FBXMaterial[]): IGLTFMaterial[] { + const materials: IGLTFMaterial[] = []; + + // Default material + // 默认材质 + materials.push({ + name: 'Default', + baseColorFactor: [0.8, 0.8, 0.8, 1], + baseColorTextureIndex: -1, + metallicFactor: 0, + roughnessFactor: 0.5, + metallicRoughnessTextureIndex: -1, + normalTextureIndex: -1, + normalScale: 1, + occlusionTextureIndex: -1, + occlusionStrength: 1, + emissiveFactor: [0, 0, 0], + emissiveTextureIndex: -1, + alphaMode: 'OPAQUE', + alphaCutoff: 0.5, + doubleSided: false + }); + + // Convert FBX materials + // 转换 FBX 材质 + for (const mat of fbxMaterials) { + materials.push({ + name: mat.name, + baseColorFactor: [...mat.diffuseColor, mat.opacity], + baseColorTextureIndex: -1, + metallicFactor: 0, + roughnessFactor: 0.5, + metallicRoughnessTextureIndex: -1, + normalTextureIndex: -1, + normalScale: 1, + occlusionTextureIndex: -1, + occlusionStrength: 1, + emissiveFactor: [0, 0, 0], + emissiveTextureIndex: -1, + alphaMode: mat.opacity < 1 ? 'BLEND' : 'OPAQUE', + alphaCutoff: 0.5, + doubleSided: false + }); + } + + return materials; + } + + /** + * Calculate mesh bounding box + * 计算网格边界盒 + */ + private calculateMeshBounds(positions: number[]): IBoundingBox { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + if (!isFinite(minX)) { + return { min: [0, 0, 0], max: [0, 0, 0] }; + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ] + }; + } + + /** + * Calculate combined bounds for all meshes + * 计算所有网格的组合边界 + */ + private calculateBounds(meshes: IMeshData[]): IBoundingBox { + if (meshes.length === 0) { + return { min: [0, 0, 0], max: [0, 0, 0] }; + } + + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (const mesh of meshes) { + minX = Math.min(minX, mesh.bounds.min[0]); + minY = Math.min(minY, mesh.bounds.min[1]); + minZ = Math.min(minZ, mesh.bounds.min[2]); + maxX = Math.max(maxX, mesh.bounds.max[0]); + maxY = Math.max(maxY, mesh.bounds.max[1]); + maxZ = Math.max(maxZ, mesh.bounds.max[2]); + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ] + }; + } +} diff --git a/packages/asset-system/src/loaders/GLTFLoader.ts b/packages/asset-system/src/loaders/GLTFLoader.ts new file mode 100644 index 00000000..01b3e2a3 --- /dev/null +++ b/packages/asset-system/src/loaders/GLTFLoader.ts @@ -0,0 +1,994 @@ +/** + * GLTF/GLB model loader implementation + * GLTF/GLB 模型加载器实现 + * + * Supports: + * - GLTF 2.0 (.gltf with external/embedded resources) + * - GLB (.glb binary format) + * - PBR materials + * - Scene hierarchy + * - Animations (basic) + * - Skinning (basic) + */ + +import { AssetType } from '../types/AssetTypes'; +import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader'; +import type { + IAssetLoader, + IAssetParseContext, + IGLTFAsset, + IMeshData, + IGLTFMaterial, + IGLTFTextureInfo, + IGLTFNode, + IGLTFAnimationClip, + IAnimationSampler, + IAnimationChannel, + IBoundingBox, + ISkeletonData, + ISkeletonJoint +} from '../interfaces/IAssetLoader'; + +// ===== GLTF JSON Schema Types ===== + +interface GLTFJson { + asset: { version: string; generator?: string }; + scene?: number; + scenes?: GLTFScene[]; + nodes?: GLTFNodeDef[]; + meshes?: GLTFMeshDef[]; + accessors?: GLTFAccessor[]; + bufferViews?: GLTFBufferView[]; + buffers?: GLTFBuffer[]; + materials?: GLTFMaterialDef[]; + textures?: GLTFTextureDef[]; + images?: GLTFImage[]; + samplers?: GLTFSampler[]; + animations?: GLTFAnimation[]; + skins?: GLTFSkin[]; +} + +interface GLTFScene { + name?: string; + nodes?: number[]; +} + +interface GLTFNodeDef { + name?: string; + mesh?: number; + children?: number[]; + translation?: [number, number, number]; + rotation?: [number, number, number, number]; + scale?: [number, number, number]; + matrix?: number[]; + skin?: number; +} + +interface GLTFMeshDef { + name?: string; + primitives: GLTFPrimitive[]; +} + +interface GLTFPrimitive { + attributes: Record; + indices?: number; + material?: number; + mode?: number; +} + +interface GLTFAccessor { + bufferView?: number; + byteOffset?: number; + componentType: number; + count: number; + type: string; + min?: number[]; + max?: number[]; + normalized?: boolean; +} + +interface GLTFBufferView { + buffer: number; + byteOffset?: number; + byteLength: number; + byteStride?: number; + target?: number; +} + +interface GLTFBuffer { + uri?: string; + byteLength: number; +} + +interface GLTFMaterialDef { + name?: string; + pbrMetallicRoughness?: { + baseColorFactor?: [number, number, number, number]; + baseColorTexture?: { index: number }; + metallicFactor?: number; + roughnessFactor?: number; + metallicRoughnessTexture?: { index: number }; + }; + normalTexture?: { index: number; scale?: number }; + occlusionTexture?: { index: number; strength?: number }; + emissiveFactor?: [number, number, number]; + emissiveTexture?: { index: number }; + alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND'; + alphaCutoff?: number; + doubleSided?: boolean; +} + +interface GLTFTextureDef { + source?: number; + sampler?: number; + name?: string; +} + +interface GLTFImage { + uri?: string; + mimeType?: string; + bufferView?: number; + name?: string; +} + +interface GLTFSampler { + magFilter?: number; + minFilter?: number; + wrapS?: number; + wrapT?: number; +} + +interface GLTFAnimation { + name?: string; + channels: GLTFAnimationChannel[]; + samplers: GLTFAnimationSampler[]; +} + +interface GLTFAnimationChannel { + sampler: number; + target: { + node?: number; + path: 'translation' | 'rotation' | 'scale' | 'weights'; + }; +} + +interface GLTFAnimationSampler { + input: number; + output: number; + interpolation?: 'LINEAR' | 'STEP' | 'CUBICSPLINE'; +} + +interface GLTFSkin { + name?: string; + inverseBindMatrices?: number; + skeleton?: number; + joints: number[]; +} + +// ===== Component Type Constants ===== +const COMPONENT_TYPE_BYTE = 5120; +const COMPONENT_TYPE_UNSIGNED_BYTE = 5121; +const COMPONENT_TYPE_SHORT = 5122; +const COMPONENT_TYPE_UNSIGNED_SHORT = 5123; +const COMPONENT_TYPE_UNSIGNED_INT = 5125; +const COMPONENT_TYPE_FLOAT = 5126; + +// ===== GLB Constants ===== +const GLB_MAGIC = 0x46546C67; // 'glTF' +const GLB_VERSION = 2; +const GLB_CHUNK_TYPE_JSON = 0x4E4F534A; // 'JSON' +const GLB_CHUNK_TYPE_BIN = 0x004E4942; // 'BIN\0' + +/** + * GLTF/GLB model loader + * GLTF/GLB 模型加载器 + */ +export class GLTFLoader implements IAssetLoader { + readonly supportedType = AssetType.Model3D; + readonly supportedExtensions = ['.gltf', '.glb']; + readonly contentType: AssetContentType = 'binary'; + + /** + * Parse GLTF/GLB content + * 解析 GLTF/GLB 内容 + */ + async parse(content: IAssetContent, context: IAssetParseContext): Promise { + const binary = content.binary; + if (!binary) { + throw new Error('GLTF loader requires binary content'); + } + + const isGLB = this.isGLB(binary); + let json: GLTFJson; + let binaryChunk: ArrayBuffer | null = null; + + if (isGLB) { + const glbData = this.parseGLB(binary); + json = glbData.json; + binaryChunk = glbData.binary; + } else { + // GLTF is JSON text + const decoder = new TextDecoder('utf-8'); + const text = decoder.decode(binary); + json = JSON.parse(text) as GLTFJson; + } + + // Validate GLTF version + if (!json.asset?.version?.startsWith('2.')) { + throw new Error(`Unsupported GLTF version: ${json.asset?.version}. Only GLTF 2.x is supported.`); + } + + // Load external buffers if needed + const buffers = await this.loadBuffers(json, binaryChunk, context); + + // Parse all components + const meshes = this.parseMeshes(json, buffers); + const materials = this.parseMaterials(json); + const textures = await this.parseTextures(json, buffers, context); + const nodes = this.parseNodes(json); + const rootNodes = this.getRootNodes(json); + const animations = this.parseAnimations(json, buffers); + const skeleton = this.parseSkeleton(json, buffers); + const bounds = this.calculateBounds(meshes); + + // Get model name from file path + const pathParts = context.metadata.path.split(/[\\/]/); + const fileName = pathParts[pathParts.length - 1]; + const name = fileName.replace(/\.(gltf|glb)$/i, ''); + + return { + name, + meshes, + materials, + textures, + nodes, + rootNodes, + animations: animations.length > 0 ? animations : undefined, + skeleton, + bounds, + sourcePath: context.metadata.path + }; + } + + /** + * Dispose GLTF asset + * 释放 GLTF 资产 + */ + dispose(asset: IGLTFAsset): void { + // Clear mesh data + for (const mesh of asset.meshes) { + (mesh as { vertices: Float32Array | null }).vertices = null!; + (mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!; + if (mesh.normals) (mesh as { normals: Float32Array | null }).normals = null; + if (mesh.uvs) (mesh as { uvs: Float32Array | null }).uvs = null; + if (mesh.colors) (mesh as { colors: Float32Array | null }).colors = null; + } + asset.meshes.length = 0; + asset.materials.length = 0; + asset.textures.length = 0; + asset.nodes.length = 0; + } + + // ===== Private Methods ===== + + /** + * Check if content is GLB format + */ + private isGLB(data: ArrayBuffer): boolean { + if (data.byteLength < 12) return false; + const view = new DataView(data); + return view.getUint32(0, true) === GLB_MAGIC; + } + + /** + * Parse GLB binary format + */ + private parseGLB(data: ArrayBuffer): { json: GLTFJson; binary: ArrayBuffer | null } { + const view = new DataView(data); + + // Header + const magic = view.getUint32(0, true); + const version = view.getUint32(4, true); + const length = view.getUint32(8, true); + + if (magic !== GLB_MAGIC) { + throw new Error('Invalid GLB magic number'); + } + if (version !== GLB_VERSION) { + throw new Error(`Unsupported GLB version: ${version}`); + } + if (length !== data.byteLength) { + throw new Error('GLB length mismatch'); + } + + let json: GLTFJson | null = null; + let binary: ArrayBuffer | null = null; + let offset = 12; + + // Parse chunks + while (offset < length) { + const chunkLength = view.getUint32(offset, true); + const chunkType = view.getUint32(offset + 4, true); + const chunkData = data.slice(offset + 8, offset + 8 + chunkLength); + + if (chunkType === GLB_CHUNK_TYPE_JSON) { + const decoder = new TextDecoder('utf-8'); + json = JSON.parse(decoder.decode(chunkData)) as GLTFJson; + } else if (chunkType === GLB_CHUNK_TYPE_BIN) { + binary = chunkData; + } + + offset += 8 + chunkLength; + } + + if (!json) { + throw new Error('GLB missing JSON chunk'); + } + + return { json, binary }; + } + + /** + * Load buffer data + */ + private async loadBuffers( + json: GLTFJson, + binaryChunk: ArrayBuffer | null, + _context: IAssetParseContext + ): Promise { + const buffers: ArrayBuffer[] = []; + + if (!json.buffers) return buffers; + + for (let i = 0; i < json.buffers.length; i++) { + const bufferDef = json.buffers[i]; + + if (!bufferDef.uri) { + // GLB embedded binary chunk + if (binaryChunk && i === 0) { + buffers.push(binaryChunk); + } else { + throw new Error(`Buffer ${i} has no URI and no binary chunk available`); + } + } else if (bufferDef.uri.startsWith('data:')) { + // Data URI + buffers.push(this.decodeDataUri(bufferDef.uri)); + } else { + // External file - not supported yet, would need asset loader context + throw new Error(`External buffer URIs not supported yet: ${bufferDef.uri}`); + } + } + + return buffers; + } + + /** + * Decode base64 data URI + */ + private decodeDataUri(uri: string): ArrayBuffer { + const match = uri.match(/^data:[^;]*;base64,(.*)$/); + if (!match) { + throw new Error('Invalid data URI format'); + } + + const base64 = match[1]; + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + } + + /** + * Get accessor data as typed array + */ + private getAccessorData( + json: GLTFJson, + buffers: ArrayBuffer[], + accessorIndex: number + ): { data: ArrayBufferView; count: number; componentCount: number } { + const accessor = json.accessors![accessorIndex]; + const bufferView = json.bufferViews![accessor.bufferView!]; + const buffer = buffers[bufferView.buffer]; + + const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0); + const componentCount = this.getComponentCount(accessor.type); + const elementCount = accessor.count * componentCount; + + let data: ArrayBufferView; + + switch (accessor.componentType) { + case COMPONENT_TYPE_BYTE: + data = new Int8Array(buffer, byteOffset, elementCount); + break; + case COMPONENT_TYPE_UNSIGNED_BYTE: + data = new Uint8Array(buffer, byteOffset, elementCount); + break; + case COMPONENT_TYPE_SHORT: + data = new Int16Array(buffer, byteOffset, elementCount); + break; + case COMPONENT_TYPE_UNSIGNED_SHORT: + data = new Uint16Array(buffer, byteOffset, elementCount); + break; + case COMPONENT_TYPE_UNSIGNED_INT: + data = new Uint32Array(buffer, byteOffset, elementCount); + break; + case COMPONENT_TYPE_FLOAT: + data = new Float32Array(buffer, byteOffset, elementCount); + break; + default: + throw new Error(`Unsupported component type: ${accessor.componentType}`); + } + + return { data, count: accessor.count, componentCount }; + } + + /** + * Get component count from accessor type + */ + private getComponentCount(type: string): number { + switch (type) { + case 'SCALAR': return 1; + case 'VEC2': return 2; + case 'VEC3': return 3; + case 'VEC4': return 4; + case 'MAT2': return 4; + case 'MAT3': return 9; + case 'MAT4': return 16; + default: + throw new Error(`Unknown accessor type: ${type}`); + } + } + + /** + * Parse all meshes + */ + private parseMeshes(json: GLTFJson, buffers: ArrayBuffer[]): IMeshData[] { + const meshes: IMeshData[] = []; + + if (!json.meshes) return meshes; + + for (const meshDef of json.meshes) { + for (const primitive of meshDef.primitives) { + // Only support triangles (mode 4 or undefined) + if (primitive.mode !== undefined && primitive.mode !== 4) { + console.warn('Skipping non-triangle primitive'); + continue; + } + + const mesh = this.parsePrimitive(json, buffers, primitive, meshDef.name || 'Mesh'); + meshes.push(mesh); + } + } + + return meshes; + } + + /** + * Parse a single primitive + */ + private parsePrimitive( + json: GLTFJson, + buffers: ArrayBuffer[], + primitive: GLTFPrimitive, + name: string + ): IMeshData { + // Position (required) + const positionAccessor = primitive.attributes['POSITION']; + if (positionAccessor === undefined) { + throw new Error('Mesh primitive missing POSITION attribute'); + } + const positionData = this.getAccessorData(json, buffers, positionAccessor); + const vertices = new Float32Array(positionData.data.buffer, (positionData.data as Float32Array).byteOffset, positionData.count * 3); + + // Indices (optional, generate sequential if missing) + let indices: Uint16Array | Uint32Array; + if (primitive.indices !== undefined) { + const indexData = this.getAccessorData(json, buffers, primitive.indices); + if (indexData.data instanceof Uint32Array) { + indices = indexData.data; + } else if (indexData.data instanceof Uint16Array) { + indices = indexData.data; + } else { + // Convert to Uint32Array + indices = new Uint32Array(indexData.count); + for (let i = 0; i < indexData.count; i++) { + indices[i] = (indexData.data as Uint8Array)[i]; + } + } + } else { + // Generate sequential indices + indices = new Uint32Array(positionData.count); + for (let i = 0; i < positionData.count; i++) { + indices[i] = i; + } + } + + // Normals (optional) + let normals: Float32Array | undefined; + const normalAccessor = primitive.attributes['NORMAL']; + if (normalAccessor !== undefined) { + const normalData = this.getAccessorData(json, buffers, normalAccessor); + normals = new Float32Array(normalData.data.buffer, (normalData.data as Float32Array).byteOffset, normalData.count * 3); + } + + // UVs (optional, TEXCOORD_0) + let uvs: Float32Array | undefined; + const uvAccessor = primitive.attributes['TEXCOORD_0']; + if (uvAccessor !== undefined) { + const uvData = this.getAccessorData(json, buffers, uvAccessor); + uvs = new Float32Array(uvData.data.buffer, (uvData.data as Float32Array).byteOffset, uvData.count * 2); + } + + // Vertex colors (optional, COLOR_0) + let colors: Float32Array | undefined; + const colorAccessor = primitive.attributes['COLOR_0']; + if (colorAccessor !== undefined) { + const colorData = this.getAccessorData(json, buffers, colorAccessor); + // Normalize if needed + if (colorData.data instanceof Float32Array) { + colors = colorData.data; + } else { + // Convert from normalized bytes + colors = new Float32Array(colorData.count * colorData.componentCount); + const source = colorData.data as Uint8Array; + for (let i = 0; i < source.length; i++) { + colors[i] = source[i] / 255; + } + } + } + + // Tangents (optional) + let tangents: Float32Array | undefined; + const tangentAccessor = primitive.attributes['TANGENT']; + if (tangentAccessor !== undefined) { + const tangentData = this.getAccessorData(json, buffers, tangentAccessor); + tangents = new Float32Array(tangentData.data.buffer, (tangentData.data as Float32Array).byteOffset, tangentData.count * 4); + } + + // Skinning: JOINTS_0 (bone indices per vertex) + // 蒙皮:JOINTS_0(每顶点的骨骼索引) + let joints: Uint8Array | Uint16Array | undefined; + const jointsAccessor = primitive.attributes['JOINTS_0']; + if (jointsAccessor !== undefined) { + const jointsData = this.getAccessorData(json, buffers, jointsAccessor); + if (jointsData.data instanceof Uint8Array) { + joints = new Uint8Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4); + } else if (jointsData.data instanceof Uint16Array) { + joints = new Uint16Array(jointsData.data.buffer, jointsData.data.byteOffset, jointsData.count * 4); + } + } + + // Skinning: WEIGHTS_0 (bone weights per vertex) + // 蒙皮:WEIGHTS_0(每顶点的骨骼权重) + let weights: Float32Array | undefined; + const weightsAccessor = primitive.attributes['WEIGHTS_0']; + if (weightsAccessor !== undefined) { + const weightsData = this.getAccessorData(json, buffers, weightsAccessor); + if (weightsData.data instanceof Float32Array) { + weights = new Float32Array(weightsData.data.buffer, weightsData.data.byteOffset, weightsData.count * 4); + } else if (weightsData.data instanceof Uint8Array) { + // Convert from normalized Uint8 to floats + weights = new Float32Array(weightsData.count * 4); + const source = weightsData.data; + for (let i = 0; i < source.length; i++) { + weights[i] = source[i] / 255; + } + } else if (weightsData.data instanceof Uint16Array) { + // Convert from normalized Uint16 to floats + weights = new Float32Array(weightsData.count * 4); + const source = weightsData.data; + for (let i = 0; i < source.length; i++) { + weights[i] = source[i] / 65535; + } + } + } + + // Calculate bounds + const bounds = this.calculateMeshBounds(vertices); + + return { + name, + vertices, + indices, + normals, + uvs, + tangents, + colors, + joints, + weights, + bounds, + materialIndex: primitive.material ?? -1 + }; + } + + /** + * Calculate mesh bounding box + */ + private calculateMeshBounds(vertices: Float32Array): IBoundingBox { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (let i = 0; i < vertices.length; i += 3) { + const x = vertices[i]; + const y = vertices[i + 1]; + const z = vertices[i + 2]; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ] + }; + } + + /** + * Parse all materials + */ + private parseMaterials(json: GLTFJson): IGLTFMaterial[] { + const materials: IGLTFMaterial[] = []; + + if (!json.materials) { + // Add default material + materials.push(this.createDefaultMaterial()); + return materials; + } + + for (const matDef of json.materials) { + const pbr = matDef.pbrMetallicRoughness || {}; + + materials.push({ + name: matDef.name || 'Material', + baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1], + baseColorTextureIndex: pbr.baseColorTexture?.index ?? -1, + metallicFactor: pbr.metallicFactor ?? 1, + roughnessFactor: pbr.roughnessFactor ?? 1, + metallicRoughnessTextureIndex: pbr.metallicRoughnessTexture?.index ?? -1, + normalTextureIndex: matDef.normalTexture?.index ?? -1, + normalScale: matDef.normalTexture?.scale ?? 1, + occlusionTextureIndex: matDef.occlusionTexture?.index ?? -1, + occlusionStrength: matDef.occlusionTexture?.strength ?? 1, + emissiveFactor: matDef.emissiveFactor || [0, 0, 0], + emissiveTextureIndex: matDef.emissiveTexture?.index ?? -1, + alphaMode: matDef.alphaMode || 'OPAQUE', + alphaCutoff: matDef.alphaCutoff ?? 0.5, + doubleSided: matDef.doubleSided ?? false + }); + } + + return materials; + } + + /** + * Create default material + */ + private createDefaultMaterial(): IGLTFMaterial { + return { + name: 'Default', + baseColorFactor: [0.8, 0.8, 0.8, 1], + baseColorTextureIndex: -1, + metallicFactor: 0, + roughnessFactor: 0.5, + metallicRoughnessTextureIndex: -1, + normalTextureIndex: -1, + normalScale: 1, + occlusionTextureIndex: -1, + occlusionStrength: 1, + emissiveFactor: [0, 0, 0], + emissiveTextureIndex: -1, + alphaMode: 'OPAQUE', + alphaCutoff: 0.5, + doubleSided: false + }; + } + + /** + * Parse textures + */ + private async parseTextures( + json: GLTFJson, + buffers: ArrayBuffer[], + _context: IAssetParseContext + ): Promise { + const textures: IGLTFTextureInfo[] = []; + + if (!json.textures || !json.images) return textures; + + for (const texDef of json.textures) { + if (texDef.source === undefined) { + textures.push({}); + continue; + } + + const imageDef = json.images[texDef.source]; + const textureInfo: IGLTFTextureInfo = { + name: imageDef.name || texDef.name + }; + + if (imageDef.bufferView !== undefined) { + // Embedded image + const bufferView = json.bufferViews![imageDef.bufferView]; + const buffer = buffers[bufferView.buffer]; + const byteOffset = bufferView.byteOffset || 0; + textureInfo.imageData = buffer.slice(byteOffset, byteOffset + bufferView.byteLength); + textureInfo.mimeType = imageDef.mimeType; + } else if (imageDef.uri) { + if (imageDef.uri.startsWith('data:')) { + // Data URI + textureInfo.imageData = this.decodeDataUri(imageDef.uri); + const mimeMatch = imageDef.uri.match(/^data:(.*?);/); + textureInfo.mimeType = mimeMatch?.[1]; + } else { + // External URI + textureInfo.uri = imageDef.uri; + } + } + + textures.push(textureInfo); + } + + return textures; + } + + /** + * Parse scene nodes + */ + private parseNodes(json: GLTFJson): IGLTFNode[] { + const nodes: IGLTFNode[] = []; + + if (!json.nodes) return nodes; + + for (const nodeDef of json.nodes) { + let position: [number, number, number] = [0, 0, 0]; + let rotation: [number, number, number, number] = [0, 0, 0, 1]; + let scale: [number, number, number] = [1, 1, 1]; + + if (nodeDef.matrix) { + // Decompose matrix + const m = nodeDef.matrix; + // Extract translation + position = [m[12], m[13], m[14]]; + // Extract scale + scale = [ + Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]), + Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]), + Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10]) + ]; + // Extract rotation (simplified, assumes no shear) + rotation = this.matrixToQuaternion(m, scale); + } else { + if (nodeDef.translation) { + position = nodeDef.translation; + } + if (nodeDef.rotation) { + rotation = nodeDef.rotation; + } + if (nodeDef.scale) { + scale = nodeDef.scale; + } + } + + nodes.push({ + name: nodeDef.name || 'Node', + meshIndex: nodeDef.mesh, + children: nodeDef.children || [], + transform: { position, rotation, scale } + }); + } + + return nodes; + } + + /** + * Extract quaternion from matrix + */ + private matrixToQuaternion(m: number[], scale: [number, number, number]): [number, number, number, number] { + // Normalize rotation matrix + const sx = scale[0], sy = scale[1], sz = scale[2]; + const m00 = m[0] / sx, m01 = m[4] / sy, m02 = m[8] / sz; + const m10 = m[1] / sx, m11 = m[5] / sy, m12 = m[9] / sz; + const m20 = m[2] / sx, m21 = m[6] / sy, m22 = m[10] / sz; + + const trace = m00 + m11 + m22; + let x: number, y: number, z: number, w: number; + + if (trace > 0) { + const s = 0.5 / Math.sqrt(trace + 1.0); + w = 0.25 / s; + x = (m21 - m12) * s; + y = (m02 - m20) * s; + z = (m10 - m01) * s; + } else if (m00 > m11 && m00 > m22) { + const s = 2.0 * Math.sqrt(1.0 + m00 - m11 - m22); + w = (m21 - m12) / s; + x = 0.25 * s; + y = (m01 + m10) / s; + z = (m02 + m20) / s; + } else if (m11 > m22) { + const s = 2.0 * Math.sqrt(1.0 + m11 - m00 - m22); + w = (m02 - m20) / s; + x = (m01 + m10) / s; + y = 0.25 * s; + z = (m12 + m21) / s; + } else { + const s = 2.0 * Math.sqrt(1.0 + m22 - m00 - m11); + w = (m10 - m01) / s; + x = (m02 + m20) / s; + y = (m12 + m21) / s; + z = 0.25 * s; + } + + return [x, y, z, w]; + } + + /** + * Get root node indices + */ + private getRootNodes(json: GLTFJson): number[] { + const sceneIndex = json.scene ?? 0; + const scene = json.scenes?.[sceneIndex]; + return scene?.nodes || []; + } + + /** + * Parse animations + */ + private parseAnimations(json: GLTFJson, buffers: ArrayBuffer[]): IGLTFAnimationClip[] { + const animations: IGLTFAnimationClip[] = []; + + if (!json.animations) return animations; + + for (const animDef of json.animations) { + const samplers: IAnimationSampler[] = []; + const channels: IAnimationChannel[] = []; + let duration = 0; + + // Parse samplers + for (const samplerDef of animDef.samplers) { + const inputData = this.getAccessorData(json, buffers, samplerDef.input); + const outputData = this.getAccessorData(json, buffers, samplerDef.output); + + const input = new Float32Array(inputData.data.buffer, (inputData.data as Float32Array).byteOffset, inputData.count); + const output = new Float32Array(outputData.data.buffer, (outputData.data as Float32Array).byteOffset, outputData.count * outputData.componentCount); + + // Update duration + if (input.length > 0) { + duration = Math.max(duration, input[input.length - 1]); + } + + samplers.push({ + input, + output, + interpolation: samplerDef.interpolation || 'LINEAR' + }); + } + + // Parse channels + for (const channelDef of animDef.channels) { + if (channelDef.target.node === undefined) continue; + + channels.push({ + samplerIndex: channelDef.sampler, + target: { + nodeIndex: channelDef.target.node, + path: channelDef.target.path + } + }); + } + + animations.push({ + name: animDef.name || 'Animation', + duration, + samplers, + channels + }); + } + + return animations; + } + + /** + * Parse skeleton/skin data + */ + private parseSkeleton(json: GLTFJson, buffers: ArrayBuffer[]): ISkeletonData | undefined { + if (!json.skins || json.skins.length === 0) return undefined; + + // Use first skin + const skin = json.skins[0]; + const joints: ISkeletonJoint[] = []; + + // Load inverse bind matrices + let inverseBindMatrices: Float32Array | null = null; + if (skin.inverseBindMatrices !== undefined) { + const ibmData = this.getAccessorData(json, buffers, skin.inverseBindMatrices); + inverseBindMatrices = new Float32Array(ibmData.data.buffer, (ibmData.data as Float32Array).byteOffset, ibmData.count * 16); + } + + // Build joint hierarchy + const jointIndexMap = new Map(); + for (let i = 0; i < skin.joints.length; i++) { + jointIndexMap.set(skin.joints[i], i); + } + + for (let i = 0; i < skin.joints.length; i++) { + const nodeIndex = skin.joints[i]; + const node = json.nodes![nodeIndex]; + + // Find parent + let parentIndex = -1; + for (const [idx, jointIdx] of jointIndexMap) { + if (jointIdx !== i) { + const parentNode = json.nodes![idx]; + if (parentNode.children?.includes(nodeIndex)) { + parentIndex = jointIdx; + break; + } + } + } + + const ibm = new Float32Array(16); + if (inverseBindMatrices) { + for (let j = 0; j < 16; j++) { + ibm[j] = inverseBindMatrices[i * 16 + j]; + } + } else { + // Identity matrix + ibm[0] = ibm[5] = ibm[10] = ibm[15] = 1; + } + + joints.push({ + name: node.name || `Joint_${i}`, + nodeIndex, + parentIndex, + inverseBindMatrix: ibm + }); + } + + // Find root joint + let rootJointIndex = 0; + for (let i = 0; i < joints.length; i++) { + if (joints[i].parentIndex === -1) { + rootJointIndex = i; + break; + } + } + + return { + joints, + rootJointIndex + }; + } + + /** + * Calculate combined bounds for all meshes + */ + private calculateBounds(meshes: IMeshData[]): IBoundingBox { + if (meshes.length === 0) { + return { min: [0, 0, 0], max: [0, 0, 0] }; + } + + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (const mesh of meshes) { + minX = Math.min(minX, mesh.bounds.min[0]); + minY = Math.min(minY, mesh.bounds.min[1]); + minZ = Math.min(minZ, mesh.bounds.min[2]); + maxX = Math.max(maxX, mesh.bounds.max[0]); + maxY = Math.max(maxY, mesh.bounds.max[1]); + maxZ = Math.max(maxZ, mesh.bounds.max[2]); + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ] + }; + } +} diff --git a/packages/asset-system/src/loaders/OBJLoader.ts b/packages/asset-system/src/loaders/OBJLoader.ts new file mode 100644 index 00000000..0063f425 --- /dev/null +++ b/packages/asset-system/src/loaders/OBJLoader.ts @@ -0,0 +1,553 @@ +/** + * OBJ model loader implementation + * OBJ 模型加载器实现 + * + * Supports: + * - Wavefront OBJ format (.obj) + * - Vertices, normals, texture coordinates + * - Triangular and quad faces (quads are triangulated) + * - Multiple objects/groups + * - MTL material references (materials loaded separately) + */ + +import { AssetType } from '../types/AssetTypes'; +import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader'; +import type { + IAssetLoader, + IAssetParseContext, + IGLTFAsset, + IMeshData, + IGLTFMaterial, + IGLTFNode, + IBoundingBox +} from '../interfaces/IAssetLoader'; + +/** + * Parsed OBJ data structure + * 解析后的 OBJ 数据结构 + */ +interface OBJParseResult { + positions: number[]; + normals: number[]; + uvs: number[]; + objects: OBJObject[]; + mtlLib?: string; +} + +interface OBJObject { + name: string; + material?: string; + faces: OBJFace[]; +} + +interface OBJFace { + vertices: OBJVertex[]; +} + +interface OBJVertex { + positionIndex: number; + uvIndex?: number; + normalIndex?: number; +} + +/** + * OBJ model loader + * OBJ 模型加载器 + */ +export class OBJLoader implements IAssetLoader { + readonly supportedType = AssetType.Model3D; + readonly supportedExtensions = ['.obj']; + readonly contentType: AssetContentType = 'text'; + + /** + * Parse OBJ content + * 解析 OBJ 内容 + */ + async parse(content: IAssetContent, context: IAssetParseContext): Promise { + const text = content.text; + if (!text) { + throw new Error('OBJ loader requires text content'); + } + + // Parse OBJ text + // 解析 OBJ 文本 + const objData = this.parseOBJ(text); + + // Convert to meshes + // 转换为网格 + const meshes = this.buildMeshes(objData); + + // Create default materials + // 创建默认材质 + const materials = this.buildMaterials(objData); + + // Build nodes (one per object) + // 构建节点(每个对象一个) + const nodes: IGLTFNode[] = meshes.map((mesh, index) => ({ + name: mesh.name, + meshIndex: index, + children: [], + transform: { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1] + } + })); + + // Calculate overall bounds + // 计算总边界 + const bounds = this.calculateBounds(meshes); + + // Get model name from file path + // 从文件路径获取模型名称 + const pathParts = context.metadata.path.split(/[\\/]/); + const fileName = pathParts[pathParts.length - 1]; + const name = fileName.replace(/\.obj$/i, ''); + + return { + name, + meshes, + materials, + textures: [], + nodes, + rootNodes: nodes.map((_, i) => i), + bounds, + sourcePath: context.metadata.path + }; + } + + /** + * Dispose OBJ asset + * 释放 OBJ 资产 + */ + dispose(asset: IGLTFAsset): void { + for (const mesh of asset.meshes) { + (mesh as { vertices: Float32Array | null }).vertices = null!; + (mesh as { indices: Uint16Array | Uint32Array | null }).indices = null!; + } + asset.meshes.length = 0; + } + + // ===== Private Methods ===== + + /** + * Parse OBJ text format + * 解析 OBJ 文本格式 + */ + private parseOBJ(text: string): OBJParseResult { + const lines = text.split('\n'); + + const positions: number[] = []; + const normals: number[] = []; + const uvs: number[] = []; + const objects: OBJObject[] = []; + + let currentObject: OBJObject = { name: 'default', faces: [] }; + let mtlLib: string | undefined; + + for (let lineNum = 0; lineNum < lines.length; lineNum++) { + const line = lines[lineNum].trim(); + + // Skip comments and empty lines + // 跳过注释和空行 + if (line.length === 0 || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + const keyword = parts[0]; + + switch (keyword) { + case 'v': // Vertex position + positions.push( + parseFloat(parts[1]) || 0, + parseFloat(parts[2]) || 0, + parseFloat(parts[3]) || 0 + ); + break; + + case 'vn': // Vertex normal + normals.push( + parseFloat(parts[1]) || 0, + parseFloat(parts[2]) || 0, + parseFloat(parts[3]) || 0 + ); + break; + + case 'vt': // Texture coordinate + uvs.push( + parseFloat(parts[1]) || 0, + parseFloat(parts[2]) || 0 + ); + break; + + case 'f': // Face + const face = this.parseFace(parts.slice(1)); + if (face.vertices.length >= 3) { + // Triangulate if more than 3 vertices (fan triangulation) + // 如果超过 3 个顶点则三角化(扇形三角化) + for (let i = 1; i < face.vertices.length - 1; i++) { + currentObject.faces.push({ + vertices: [ + face.vertices[0], + face.vertices[i], + face.vertices[i + 1] + ] + }); + } + } + break; + + case 'o': // Object name + case 'g': // Group name + if (currentObject.faces.length > 0) { + objects.push(currentObject); + } + currentObject = { + name: parts.slice(1).join(' ') || 'unnamed', + faces: [] + }; + break; + + case 'usemtl': // Material reference + // If current object has faces with different material, split it + // 如果当前对象有不同材质的面,则拆分 + if (currentObject.faces.length > 0 && currentObject.material) { + objects.push(currentObject); + currentObject = { + name: `${currentObject.name}_${parts[1]}`, + faces: [], + material: parts[1] + }; + } else { + currentObject.material = parts[1]; + } + break; + + case 'mtllib': // MTL library reference + mtlLib = parts[1]; + break; + + case 's': // Smoothing group (ignored) + case 'l': // Line (ignored) + break; + } + } + + // Push last object + // 推送最后一个对象 + if (currentObject.faces.length > 0) { + objects.push(currentObject); + } + + // If no objects were created, create one from default + // 如果没有创建对象,从默认创建一个 + if (objects.length === 0 && currentObject.faces.length === 0) { + throw new Error('OBJ file contains no geometry'); + } + + return { positions, normals, uvs, objects, mtlLib }; + } + + /** + * Parse a face definition + * 解析面定义 + * + * Format: v, v/vt, v/vt/vn, v//vn + */ + private parseFace(parts: string[]): OBJFace { + const vertices: OBJVertex[] = []; + + for (const part of parts) { + const indices = part.split('/'); + const vertex: OBJVertex = { + positionIndex: parseInt(indices[0], 10) - 1 // OBJ is 1-indexed + }; + + if (indices.length > 1 && indices[1]) { + vertex.uvIndex = parseInt(indices[1], 10) - 1; + } + + if (indices.length > 2 && indices[2]) { + vertex.normalIndex = parseInt(indices[2], 10) - 1; + } + + vertices.push(vertex); + } + + return { vertices }; + } + + /** + * Build mesh data from parsed OBJ + * 从解析的 OBJ 构建网格数据 + */ + private buildMeshes(objData: OBJParseResult): IMeshData[] { + const meshes: IMeshData[] = []; + + for (const obj of objData.objects) { + const mesh = this.buildMesh(obj, objData); + meshes.push(mesh); + } + + return meshes; + } + + /** + * Build a single mesh from OBJ object + * 从 OBJ 对象构建单个网格 + */ + private buildMesh(obj: OBJObject, objData: OBJParseResult): IMeshData { + // OBJ uses indexed vertices, but indices can reference different + // position/uv/normal combinations, so we need to expand + // OBJ 使用索引顶点,但索引可以引用不同的 position/uv/normal 组合,所以需要展开 + + const positions: number[] = []; + const normals: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + + // Map to track unique vertex combinations + // 用于跟踪唯一顶点组合的映射 + const vertexMap = new Map(); + let vertexIndex = 0; + + for (const face of obj.faces) { + const faceIndices: number[] = []; + + for (const vertex of face.vertices) { + // Create unique key for this vertex combination + // 为此顶点组合创建唯一键 + const key = `${vertex.positionIndex}/${vertex.uvIndex ?? ''}/${vertex.normalIndex ?? ''}`; + + let index = vertexMap.get(key); + if (index === undefined) { + // New unique vertex - add to arrays + // 新的唯一顶点 - 添加到数组 + index = vertexIndex++; + vertexMap.set(key, index); + + // Position + const pi = vertex.positionIndex * 3; + positions.push( + objData.positions[pi] ?? 0, + objData.positions[pi + 1] ?? 0, + objData.positions[pi + 2] ?? 0 + ); + + // UV + if (vertex.uvIndex !== undefined) { + const ui = vertex.uvIndex * 2; + uvs.push( + objData.uvs[ui] ?? 0, + 1 - (objData.uvs[ui + 1] ?? 0) // Flip V coordinate + ); + } else { + uvs.push(0, 0); + } + + // Normal + if (vertex.normalIndex !== undefined) { + const ni = vertex.normalIndex * 3; + normals.push( + objData.normals[ni] ?? 0, + objData.normals[ni + 1] ?? 0, + objData.normals[ni + 2] ?? 0 + ); + } else { + normals.push(0, 1, 0); // Default up normal + } + } + + faceIndices.push(index); + } + + // Add triangle indices + // 添加三角形索引 + if (faceIndices.length === 3) { + indices.push(faceIndices[0], faceIndices[1], faceIndices[2]); + } + } + + // Calculate bounds + // 计算边界 + const bounds = this.calculateMeshBounds(positions); + + // Generate normals if not provided + // 如果未提供法线则生成 + const hasValidNormals = objData.normals.length > 0; + const finalNormals = hasValidNormals + ? new Float32Array(normals) + : this.generateNormals(positions, indices); + + return { + name: obj.name, + vertices: new Float32Array(positions), + indices: new Uint32Array(indices), + normals: finalNormals, + uvs: new Float32Array(uvs), + bounds, + materialIndex: -1 // Material resolved by name + }; + } + + /** + * Generate flat normals for mesh + * 为网格生成平面法线 + */ + private generateNormals(positions: number[], indices: number[]): Float32Array { + const normals = new Float32Array(positions.length); + + for (let i = 0; i < indices.length; i += 3) { + const i0 = indices[i] * 3; + const i1 = indices[i + 1] * 3; + const i2 = indices[i + 2] * 3; + + // Get triangle vertices + const v0x = positions[i0], v0y = positions[i0 + 1], v0z = positions[i0 + 2]; + const v1x = positions[i1], v1y = positions[i1 + 1], v1z = positions[i1 + 2]; + const v2x = positions[i2], v2y = positions[i2 + 1], v2z = positions[i2 + 2]; + + // Calculate edge vectors + const e1x = v1x - v0x, e1y = v1y - v0y, e1z = v1z - v0z; + const e2x = v2x - v0x, e2y = v2y - v0y, e2z = v2z - v0z; + + // Cross product + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + + // Add to vertex normals (will be normalized later or kept as-is for flat shading) + normals[i0] += nx; normals[i0 + 1] += ny; normals[i0 + 2] += nz; + normals[i1] += nx; normals[i1 + 1] += ny; normals[i1 + 2] += nz; + normals[i2] += nx; normals[i2 + 1] += ny; normals[i2 + 2] += nz; + } + + // Normalize + for (let i = 0; i < normals.length; i += 3) { + const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2); + if (len > 0) { + normals[i] /= len; + normals[i + 1] /= len; + normals[i + 2] /= len; + } + } + + return normals; + } + + /** + * Build default materials + * 构建默认材质 + */ + private buildMaterials(objData: OBJParseResult): IGLTFMaterial[] { + // Create one default material per unique material name + // 为每个唯一的材质名称创建一个默认材质 + const materialNames = new Set(); + for (const obj of objData.objects) { + if (obj.material) { + materialNames.add(obj.material); + } + } + + const materials: IGLTFMaterial[] = []; + + // Default material + materials.push({ + name: 'Default', + baseColorFactor: [0.8, 0.8, 0.8, 1], + baseColorTextureIndex: -1, + metallicFactor: 0, + roughnessFactor: 0.5, + metallicRoughnessTextureIndex: -1, + normalTextureIndex: -1, + normalScale: 1, + occlusionTextureIndex: -1, + occlusionStrength: 1, + emissiveFactor: [0, 0, 0], + emissiveTextureIndex: -1, + alphaMode: 'OPAQUE', + alphaCutoff: 0.5, + doubleSided: false + }); + + // Named materials (with placeholder values) + for (const name of materialNames) { + materials.push({ + name, + baseColorFactor: [0.8, 0.8, 0.8, 1], + baseColorTextureIndex: -1, + metallicFactor: 0, + roughnessFactor: 0.5, + metallicRoughnessTextureIndex: -1, + normalTextureIndex: -1, + normalScale: 1, + occlusionTextureIndex: -1, + occlusionStrength: 1, + emissiveFactor: [0, 0, 0], + emissiveTextureIndex: -1, + alphaMode: 'OPAQUE', + alphaCutoff: 0.5, + doubleSided: false + }); + } + + return materials; + } + + /** + * Calculate mesh bounding box + * 计算网格边界盒 + */ + private calculateMeshBounds(positions: number[]): IBoundingBox { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + if (!isFinite(minX)) { + return { min: [0, 0, 0], max: [0, 0, 0] }; + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ] + }; + } + + /** + * Calculate combined bounds for all meshes + * 计算所有网格的组合边界 + */ + private calculateBounds(meshes: IMeshData[]): IBoundingBox { + if (meshes.length === 0) { + return { min: [0, 0, 0], max: [0, 0, 0] }; + } + + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (const mesh of meshes) { + minX = Math.min(minX, mesh.bounds.min[0]); + minY = Math.min(minY, mesh.bounds.min[1]); + minZ = Math.min(minZ, mesh.bounds.min[2]); + maxX = Math.max(maxX, mesh.bounds.max[0]); + maxY = Math.max(maxY, mesh.bounds.max[1]); + maxZ = Math.max(maxZ, mesh.bounds.max[2]); + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ] + }; + } +} diff --git a/packages/asset-system/src/types/AssetTypes.ts b/packages/asset-system/src/types/AssetTypes.ts index 614bcf96..c69256a2 100644 --- a/packages/asset-system/src/types/AssetTypes.ts +++ b/packages/asset-system/src/types/AssetTypes.ts @@ -52,6 +52,8 @@ export const AssetType = { Texture: 'texture', /** 网格 */ Mesh: 'mesh', + /** 3D模型 (GLTF/GLB) | 3D Model */ + Model3D: 'model3d', /** 材质 */ Material: 'material', /** 着色器 */ diff --git a/packages/build-config/src/types.ts b/packages/build-config/src/types.ts index 9ab86776..a291f7d5 100644 --- a/packages/build-config/src/types.ts +++ b/packages/build-config/src/types.ts @@ -91,6 +91,9 @@ export const STANDARD_EXTERNALS = [ 'zustand', 'immer', + // Tauri (由宿主应用提供) | Provided by host app + /^@tauri-apps\//, + // 所有 @esengine 包 /^@esengine\//, ] as const; diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts index f618f636..ef0f1613 100644 --- a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -1541,6 +1541,60 @@ export class EngineBridge implements ITextureEngineBridge, ITextureService, IDyn this.getEngine().render3D(); } + /** + * Submit a 3D mesh for rendering (with normals). + * 提交 3D 网格进行渲染(包含法线)。 + * + * The mesh will be rendered when render3D() or render() is called. + * 网格将在调用 render3D() 或 render() 时渲染。 + * + * @param vertices - Interleaved vertex data Float32Array: + * [x, y, z, u, v, r, g, b, a, nx, ny, nz] per vertex (12 floats) + * 交错顶点数据:每个顶点 12 个浮点数 + * @param indices - Triangle indices Uint32Array | 三角形索引 + * @param transform - 4x4 model transform matrix (column-major, 16 floats) + * 4x4 模型变换矩阵(列优先,16 个浮点数) + * @param materialId - Material ID (0 for default) | 材质 ID(0 为默认) + * @param textureId - Texture ID (0 for white) | 纹理 ID(0 为白色) + */ + submitMesh3D( + vertices: Float32Array, + indices: Uint32Array, + transform: Float32Array, + materialId: number, + textureId: number + ): void { + if (!this.initialized) return; + this.getEngine().submitMesh3D(vertices, indices, transform, materialId, textureId); + } + + /** + * Submit a simplified 3D mesh (without normals). + * 提交简化的 3D 网格(无法线)。 + * + * This is more efficient for meshes that don't need lighting calculations. + * 对于不需要光照计算的网格,这更高效。 + * + * @param vertices - Interleaved vertex data Float32Array: + * [x, y, z, u, v, r, g, b, a] per vertex (9 floats) + * 交错顶点数据:每个顶点 9 个浮点数 + * @param indices - Triangle indices Uint32Array | 三角形索引 + * @param transform - 4x4 model transform matrix (column-major, 16 floats) + * 4x4 模型变换矩阵(列优先,16 个浮点数) + * @param materialId - Material ID (0 for default) | 材质 ID(0 为默认) + * @param textureId - Texture ID (0 for white) | 纹理 ID(0 为白色) + */ + submitSimpleMesh3D( + vertices: Float32Array, + indices: Uint32Array, + transform: Float32Array, + materialId: number, + textureId: number + ): void { + if (!this.initialized) return; + this.getEngine().submitSimpleMesh3D(vertices, indices, transform, materialId, textureId); + } + /** * Dispose the bridge and release resources. * 销毁桥接并释放资源。 diff --git a/packages/ecs-engine-bindgen/src/index.ts b/packages/ecs-engine-bindgen/src/index.ts index c132896f..ad3bd9ce 100644 --- a/packages/ecs-engine-bindgen/src/index.ts +++ b/packages/ecs-engine-bindgen/src/index.ts @@ -9,6 +9,7 @@ export { RenderSystemToken, EngineIntegrationToken, + EngineBridgeToken, // 新的单一职责服务令牌 | New single-responsibility service tokens TextureServiceToken, DynamicAtlasServiceToken, diff --git a/packages/ecs-engine-bindgen/src/tokens.ts b/packages/ecs-engine-bindgen/src/tokens.ts index a439518f..ec5dd87f 100644 --- a/packages/ecs-engine-bindgen/src/tokens.ts +++ b/packages/ecs-engine-bindgen/src/tokens.ts @@ -54,3 +54,8 @@ export interface IEngineIntegration { export const RenderSystemToken = createServiceToken('renderSystem'); export const EngineIntegrationToken = createServiceToken('engineIntegration'); + +// EngineBridge token - used by systems that need direct engine access +// EngineBridge 令牌 - 供需要直接访问引擎的系统使用 +import type { EngineBridge } from './core/EngineBridge'; +export const EngineBridgeToken = createServiceToken('engineBridge'); diff --git a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts index 74879ef8..6203998f 100644 --- a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts +++ b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts @@ -669,6 +669,28 @@ export class GameEngine { * 获取画布高度。 */ readonly height: number; + /** + * Submit a 3D mesh for rendering. + * 提交 3D 网格进行渲染。 + * + * @param vertices - Interleaved vertex data [x,y,z, u,v, r,g,b,a, nx,ny,nz] * count + * @param indices - Triangle indices + * @param transform - 4x4 transformation matrix (column-major) + * @param material_id - Material ID + * @param texture_id - Texture ID + */ + submitMesh3D(vertices: Float32Array, indices: Uint32Array, transform: Float32Array, material_id: number, texture_id: number): void; + /** + * Submit a simple 3D mesh for rendering (without normals). + * 提交简单 3D 网格进行渲染(不含法线)。 + * + * @param vertices - Interleaved vertex data [x,y,z, u,v, r,g,b,a] * count + * @param indices - Triangle indices + * @param transform - 4x4 transformation matrix (column-major) + * @param material_id - Material ID + * @param texture_id - Texture ID + */ + submitSimpleMesh3D(vertices: Float32Array, indices: Uint32Array, transform: Float32Array, material_id: number, texture_id: number): void; } export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index db158d24..10cb9dca 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -32,6 +32,8 @@ "@esengine/engine-core": "workspace:*", "@esengine/material-editor": "workspace:*", "@esengine/material-system": "workspace:*", + "@esengine/mesh-3d": "workspace:*", + "@esengine/mesh-3d-editor": "workspace:*", "@esengine/particle": "workspace:*", "@esengine/particle-editor": "workspace:*", "@esengine/physics-rapier2d": "workspace:*", diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index c1899c7d..5320c488 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -75,12 +75,30 @@ export class TauriAPI { } /** - * 读取文件内容 + * 读取文件内容(文本) */ static async readFileContent(path: string): Promise { return await invoke('read_file_content', { path }); } + /** + * 读取文件内容(二进制) + * Read file content as binary ArrayBuffer + */ + static async readFileBinary(path: string): Promise { + // Use Tauri read_file_as_base64 command which returns base64 encoded data + // 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据 + const base64: string = await invoke('read_file_as_base64', { filePath: path }); + // Decode base64 to ArrayBuffer + // 将 base64 解码为 ArrayBuffer + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + } + /** * 列出目录内容 */ diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index a7efa7f0..961ecb1c 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -27,6 +27,7 @@ import { BlueprintPlugin } from '@esengine/blueprint-editor'; import { MaterialPlugin } from '@esengine/material-editor'; import { SpritePlugin } from '@esengine/sprite-editor'; import { ShaderEditorPlugin } from '@esengine/shader-editor'; +import { Mesh3DPlugin } from '@esengine/mesh-3d-editor'; // 纯运行时插件 | Runtime-only plugins import { CameraPlugin } from '@esengine/camera'; @@ -70,6 +71,7 @@ export class PluginInstaller { { name: 'BlueprintPlugin', plugin: BlueprintPlugin }, { name: 'MaterialPlugin', plugin: MaterialPlugin }, { name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin }, + { name: 'Mesh3DPlugin', plugin: Mesh3DPlugin }, ]; for (const { name, plugin } of modulePlugins) { diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index a1aabfcb..7125f5d4 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -41,10 +41,17 @@ import { AlertTriangle, X, FolderPlus, - Inbox + Inbox, + Box, + Bone, + Film, + Palette, + Loader2 } from 'lucide-react'; import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework'; import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate, EntityStoreService, SceneManagerService } from '@esengine/editor-core'; +import type { IGLTFAsset, IMeshData, IGLTFMaterial, IGLTFAnimationClip, IAssetContent, IAssetParseContext } from '@esengine/asset-system'; +import { FBXLoader, GLTFLoader } from '@esengine/asset-system'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { SettingsService } from '../services/SettingsService'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; @@ -54,10 +61,25 @@ import '../styles/ContentBrowser.css'; interface AssetItem { name: string; path: string; - type: 'file' | 'folder'; + type: 'file' | 'folder' | 'sub-asset'; extension?: string; size?: number; modified?: number; + // Sub-asset specific fields + // 子资产特定字段 + parentPath?: string; // Path to parent model file | 父模型文件路径 + subAssetType?: 'mesh' | 'material' | 'animation' | 'skeleton'; + subAssetIndex?: number; // Index within parent asset | 在父资产中的索引 +} + +/** + * Check if file extension is an expandable 3D model + * 检查文件扩展名是否是可展开的3D模型 + */ +function isExpandableModel(extension: string | undefined): boolean { + if (!extension) return false; + const ext = extension.toLowerCase(); + return ['fbx', 'gltf', 'glb', 'obj'].includes(ext); } interface FolderNode { @@ -159,6 +181,17 @@ function highlightSearchText(text: string, query: string): React.ReactNode { function getAssetTypeName(asset: AssetItem): string { if (asset.type === 'folder') return 'Folder'; + // Handle sub-assets | 处理子资产 + if (asset.type === 'sub-asset') { + switch (asset.subAssetType) { + case 'mesh': return 'Mesh'; + case 'material': return 'Material'; + case 'animation': return 'Animation'; + case 'skeleton': return 'Skeleton'; + default: return 'Sub-Asset'; + } + } + // Check for compound extensions first const name = asset.name.toLowerCase(); if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap'; @@ -180,6 +213,10 @@ function getAssetTypeName(asset: AssetItem): string { case 'prefab': return 'Prefab'; case 'mat': return 'Material'; case 'anim': return 'Animation'; + case 'fbx': + case 'gltf': + case 'glb': + case 'obj': return '3D Model'; default: return ext?.toUpperCase() || 'File'; } } @@ -251,6 +288,12 @@ export function ContentBrowser({ // Drag and drop state for file moving const [dragOverFolder, setDragOverFolder] = useState(null); + // Expanded model assets (for viewing sub-assets) + // 展开的模型资产(用于查看子资产) + const [expandedModels, setExpandedModels] = useState>(new Set()); + const [modelSubAssets, setModelSubAssets] = useState>(new Map()); + const [loadingModels, setLoadingModels] = useState>(new Set()); + // 初始化和监听插件安装事件以更新模板列表 // Initialize and listen for plugin installation events to update template list useEffect(() => { @@ -637,6 +680,172 @@ export class ${className} { } }, [currentPath, projectPath, loadAssets, buildFolderTree]); + /** + * Load sub-assets from a 3D model file + * 从3D模型文件加载子资产 + */ + const loadModelSubAssets = useCallback(async (modelPath: string): Promise => { + try { + const modelName = modelPath.split(/[\\/]/).pop() || 'Model'; + const ext = modelName.split('.').pop()?.toLowerCase() || ''; + + // Read file binary content + // 读取文件二进制内容 + const binaryData = await TauriAPI.readFileBinary(modelPath); + if (!binaryData || binaryData.byteLength === 0) { + console.warn('[ContentBrowser] Cannot read file:', modelPath); + return []; + } + + // Create minimal parse context (loaders don't need full metadata for basic parsing) + // 创建最小解析上下文(加载器只需要基本解析的路径信息) + const parseContext = { + metadata: { + path: modelPath, + name: modelName, + type: ext === 'fbx' ? 'model/fbx' : 'model/gltf', + guid: '', + size: binaryData.byteLength, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null + } as unknown as IAssetParseContext; + + // Create content object + // 创建内容对象 + const content: IAssetContent = { + type: 'binary', + binary: binaryData + }; + + // Select appropriate loader and parse + // 选择合适的加载器并解析 + let asset: IGLTFAsset; + if (ext === 'fbx') { + const loader = new FBXLoader(); + asset = await loader.parse(content, parseContext); + } else if (ext === 'gltf' || ext === 'glb') { + const loader = new GLTFLoader(); + asset = await loader.parse(content, parseContext); + } else { + console.warn('[ContentBrowser] Unsupported model format:', ext); + return []; + } + + const subAssets: AssetItem[] = []; + + // Add meshes + // 添加网格 + if (asset.meshes && asset.meshes.length > 0) { + asset.meshes.forEach((mesh: IMeshData, index: number) => { + subAssets.push({ + name: mesh.name || `Mesh_${index}`, + path: `${modelPath}#mesh:${index}`, + type: 'sub-asset', + parentPath: modelPath, + subAssetType: 'mesh', + subAssetIndex: index + }); + }); + } + + // Add materials + // 添加材质 + if (asset.materials && asset.materials.length > 0) { + asset.materials.forEach((material: IGLTFMaterial, index: number) => { + subAssets.push({ + name: material.name || `Material_${index}`, + path: `${modelPath}#material:${index}`, + type: 'sub-asset', + parentPath: modelPath, + subAssetType: 'material', + subAssetIndex: index + }); + }); + } + + // Add animations + // 添加动画 + if (asset.animations && asset.animations.length > 0) { + asset.animations.forEach((anim: IGLTFAnimationClip, index: number) => { + subAssets.push({ + name: anim.name || `Animation_${index}`, + path: `${modelPath}#animation:${index}`, + type: 'sub-asset', + parentPath: modelPath, + subAssetType: 'animation', + subAssetIndex: index + }); + }); + } + + // Add skeleton if present + // 添加骨骼(如果存在) + if (asset.skeleton) { + subAssets.push({ + name: `Skeleton (${asset.skeleton.joints.length} joints)`, + path: `${modelPath}#skeleton:0`, + type: 'sub-asset', + parentPath: modelPath, + subAssetType: 'skeleton', + subAssetIndex: 0 + }); + } + + console.log(`[ContentBrowser] Loaded sub-assets for ${modelName}:`); + console.log(` - Meshes: ${asset.meshes?.length ?? 0}`); + console.log(` - Materials: ${asset.materials?.length ?? 0}`); + console.log(` - Animations: ${asset.animations?.length ?? 0}`); + console.log(` - Skeleton: ${asset.skeleton ? 'yes' : 'no'}`); + console.log(` - Sub-assets total: ${subAssets.length}`); + return subAssets; + } catch (error) { + console.error('[ContentBrowser] Failed to load model sub-assets:', error); + return []; + } + }, []); + + /** + * Toggle model expansion + * 切换模型展开状态 + */ + const toggleModelExpand = useCallback(async (modelPath: string, e: React.MouseEvent) => { + e.stopPropagation(); + + const isExpanded = expandedModels.has(modelPath); + + if (isExpanded) { + // Collapse + // 折叠 + setExpandedModels(prev => { + const next = new Set(prev); + next.delete(modelPath); + return next; + }); + } else { + // Expand - load sub-assets if not already loaded + // 展开 - 如果尚未加载则加载子资产 + if (!modelSubAssets.has(modelPath)) { + setLoadingModels(prev => new Set(prev).add(modelPath)); + const subAssets = await loadModelSubAssets(modelPath); + setModelSubAssets(prev => new Map(prev).set(modelPath, subAssets)); + setLoadingModels(prev => { + const next = new Set(prev); + next.delete(modelPath); + return next; + }); + } + + setExpandedModels(prev => new Set(prev).add(modelPath)); + } + }, [expandedModels, modelSubAssets, loadModelSubAssets]); + // 点击外部关闭过滤器下拉菜单 | Close filter dropdown when clicking outside useEffect(() => { if (!showFilterDropdown) return; @@ -1031,6 +1240,21 @@ export class ${className} { setCurrentPath(asset.path); loadAssets(asset.path); setExpandedFolders(prev => new Set([...prev, asset.path])); + } else if (asset.type === 'sub-asset') { + // Handle sub-asset double click + // 处理子资产双击 + if (asset.subAssetType === 'animation' && asset.parentPath) { + // Open animation preview panel + // 打开动画预览面板 + messageHub?.publish('animation:preview', { + filePath: asset.parentPath, + animationIndex: asset.subAssetIndex ?? 0 + }); + console.log('[ContentBrowser] Opening animation preview:', asset.parentPath, 'index:', asset.subAssetIndex); + } + // Other sub-asset types can be handled here + // 其他子资产类型可以在这里处理 + return; } else { const ext = asset.extension?.toLowerCase(); console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene); @@ -1088,7 +1312,7 @@ export class ${className} { console.error('Failed to open file:', error); } } - }, [loadAssets, onOpenScene, fileActionRegistry, projectPath]); + }, [loadAssets, onOpenScene, fileActionRegistry, projectPath, messageHub]); // Handle context menu const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => { @@ -1194,6 +1418,23 @@ export class ${className} { return ; } + // Handle sub-assets + // 处理子资产 + if (asset.type === 'sub-asset') { + switch (asset.subAssetType) { + case 'mesh': + return ; + case 'material': + return ; + case 'animation': + return ; + case 'skeleton': + return ; + default: + return ; + } + } + const ext = asset.extension?.toLowerCase(); switch (ext) { case 'ecs': @@ -1213,6 +1454,13 @@ export class ${className} { case 'gif': case 'webp': return ; + // 3D Model files | 3D 模型文件 + case 'fbx': + case 'obj': + case 'gltf': + case 'glb': + case 'dae': + return ; default: return ; } @@ -1698,8 +1946,8 @@ export class ${className} { }); }, []); - // Filter assets by search and hidden extensions - // 按搜索词和隐藏扩展名过滤资产 + // Filter assets by search and hidden extensions, and inject sub-assets for expanded models + // 按搜索词和隐藏扩展名过滤资产,并为展开的模型注入子资产 const filteredAssets = useMemo(() => { let result = assets; @@ -1717,8 +1965,24 @@ export class ${className} { result = result.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase())); } + // Inject sub-assets for expanded models + // 为展开的模型注入子资产 + if (expandedModels.size > 0) { + const resultWithSubAssets: AssetItem[] = []; + for (const asset of result) { + resultWithSubAssets.push(asset); + // If this is an expanded model, add its sub-assets after it + // 如果这是一个展开的模型,在其后添加子资产 + if (asset.type === 'file' && isExpandableModel(asset.extension) && expandedModels.has(asset.path)) { + const subAssets = modelSubAssets.get(asset.path) || []; + resultWithSubAssets.push(...subAssets); + } + } + result = resultWithSubAssets; + } + return result; - }, [assets, hiddenExtensions, searchQuery]); + }, [assets, hiddenExtensions, searchQuery, expandedModels, modelSubAssets]); const breadcrumbs = getBreadcrumbs(); @@ -1994,18 +2258,26 @@ export class ${className} { ) : ( filteredAssets.map(asset => { const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path; + const isSubAsset = asset.type === 'sub-asset'; + const isExpandableFile = asset.type === 'file' && isExpandableModel(asset.extension); + const isModelExpanded = isExpandableFile && expandedModels.has(asset.path); + const isModelLoading = isExpandableFile && loadingModels.has(asset.path); return (
handleAssetClick(asset, e)} onDoubleClick={() => handleAssetDoubleClick(asset)} onContextMenu={(e) => { e.stopPropagation(); handleContextMenu(e, asset); }} - draggable + draggable={!isSubAsset} onDragStart={(e) => { + if (isSubAsset) { + e.preventDefault(); + return; + } e.dataTransfer.setData('asset-path', asset.path); e.dataTransfer.setData('text/plain', asset.path); // Add GUID for files @@ -2038,6 +2310,22 @@ export class ${className} { } }} > + {/* Expand button for 3D models | 3D模型的展开按钮 */} + {isExpandableFile && ( + + )}
{getFileIcon(asset)}
diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 11c2d086..d10422bd 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -23,6 +23,7 @@ import { QRCodeDialog } from './QRCodeDialog'; import { collectAssetReferences } from '@esengine/asset-system'; import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core'; import { ParticleSystemComponent } from '@esengine/particle'; +import { MeshComponent } from '@esengine/mesh-3d'; import type { ModuleManifest } from '../services/RuntimeResolver'; @@ -314,6 +315,12 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const orbitCameraRef = useRef(orbitCamera); const isOrbitingRef = useRef(false); const isPanningRef = useRef(false); + const isZoomingRef = useRef(false); + // Fly mode (right-click + WASD) | 飞行模式(右键 + WASD) + const isFlyModeRef = useRef(false); + const flyKeysRef = useRef({ w: false, a: false, s: false, d: false, q: false, e: false }); + const flySpeedRef = useRef(10); // units per second | 每秒单位数 + const altKeyRef = useRef(false); const selectedEntityRef = useRef(null); const messageHubRef = useRef(null); const commandManagerRef = useRef(null); @@ -490,23 +497,43 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport return; } - // 3D mode: orbit camera controls - // 3D 模式:轨道相机控制 + // 3D mode: Scene view camera controls + // 3D 模式:场景视图相机控制 + // - Alt + Left: Orbit | Alt + 左键:轨道旋转 + // - Alt + Middle / Middle: Pan | Alt + 中键 / 中键:平移 + // - Alt + Right: Zoom | Alt + 右键:缩放 + // - Right: Fly mode (+ WASD) | 右键:飞行模式(+ WASD) if (renderModeRef.current === '3D') { - if (e.button === 0) { - // Left button: orbit (rotate around target) - // 左键:轨道旋转(围绕目标旋转) + const isAlt = e.altKey; + + if (e.button === 0 && isAlt) { + // Alt + Left button: orbit (rotate around target) + // Alt + 左键:轨道旋转(围绕目标旋转) isOrbitingRef.current = true; lastMousePosRef.current = { x: e.clientX, y: e.clientY }; canvas.style.cursor = 'grabbing'; e.preventDefault(); - } else if (e.button === 1 || e.button === 2) { - // Middle/Right button: pan - // 中键/右键:平移 + } else if (e.button === 1 || (e.button === 1 && isAlt)) { + // Middle button (with or without Alt): pan + // 中键(有无 Alt):平移 isPanningRef.current = true; lastMousePosRef.current = { x: e.clientX, y: e.clientY }; canvas.style.cursor = 'move'; e.preventDefault(); + } else if (e.button === 2 && isAlt) { + // Alt + Right button: zoom + // Alt + 右键:缩放 + isZoomingRef.current = true; + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + canvas.style.cursor = 'ns-resize'; + e.preventDefault(); + } else if (e.button === 2 && !isAlt) { + // Right button (without Alt): fly mode + // 右键(无 Alt):飞行模式 + isFlyModeRef.current = true; + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + canvas.style.cursor = 'crosshair'; + e.preventDefault(); } return; } @@ -608,8 +635,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const deltaX = e.clientX - lastMousePosRef.current.x; const deltaY = e.clientY - lastMousePosRef.current.y; - // 3D mode: orbit camera controls - // 3D 模式:轨道相机控制 + // 3D mode: Scene view camera controls + // 3D 模式:场景视图相机控制 if (renderModeRef.current === '3D') { if (isOrbitingRef.current) { // Orbit: rotate around target @@ -619,31 +646,76 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const newYaw = prev.yaw + deltaX * orbitSensitivity; const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * orbitSensitivity)); const newOrbit = { ...prev, yaw: newYaw, pitch: newPitch }; - // Sync to engine in next tick - // 在下一帧同步到引擎 requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit)); return newOrbit; }); lastMousePosRef.current = { x: e.clientX, y: e.clientY }; } else if (isPanningRef.current) { - // Pan: move target point - // 平移:移动目标点 + // Pan: move target point (drag to move scene in same direction) + // 平移:移动目标点(拖拽方向与场景移动方向相同) const panSensitivity = 0.01; const orbit = orbitCameraRef.current; const yawRad = (orbit.yaw * Math.PI) / 180; + const pitchRad = (orbit.pitch * Math.PI) / 180; - // Calculate pan direction based on camera orientation - // 根据相机朝向计算平移方向 + // Calculate camera right and up vectors + // 计算相机的右向量和上向量 const rightX = Math.cos(yawRad); const rightZ = -Math.sin(yawRad); + // Up vector in world space (simplified) + const upY = Math.cos(pitchRad); setOrbitCamera((prev) => { const panScale = prev.distance * panSensitivity; + // 左右反转,上下保持原样 + // Invert horizontal, keep vertical as is const newOrbit = { ...prev, - targetX: prev.targetX - deltaX * rightX * panScale, - targetY: prev.targetY + deltaY * panScale, - targetZ: prev.targetZ - deltaX * rightZ * panScale + targetX: prev.targetX + deltaX * rightX * panScale, + targetY: prev.targetY + deltaY * upY * panScale, + targetZ: prev.targetZ + deltaX * rightZ * panScale + }; + requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit)); + return newOrbit; + }); + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + } else if (isZoomingRef.current) { + // Alt + Right: Zoom by moving distance + // Alt + 右键:通过改变距离来缩放 + const zoomSensitivity = 0.02; + setOrbitCamera((prev) => { + const newDistance = Math.max(0.5, Math.min(1000, prev.distance * (1 + deltaY * zoomSensitivity))); + const newOrbit = { ...prev, distance: newDistance }; + requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit)); + return newOrbit; + }); + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + } else if (isFlyModeRef.current) { + // Fly mode: first-person camera look (like FPS) + // 飞行模式:第一人称相机视角(类似 FPS) + const lookSensitivity = 0.2; + setOrbitCamera((prev) => { + const newYaw = prev.yaw + deltaX * lookSensitivity; + const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * lookSensitivity)); + // In fly mode, target moves with camera to maintain forward direction + // 在飞行模式下,目标点随相机移动以保持前进方向 + const pos = calculateOrbitCameraPosition({ ...prev, yaw: newYaw, pitch: newPitch }); + + // Calculate new target based on camera looking forward + // 根据相机前方计算新目标点 + const pitchRad = (newPitch * Math.PI) / 180; + const yawRad = (newYaw * Math.PI) / 180; + const forwardX = -Math.cos(pitchRad) * Math.sin(yawRad); + const forwardY = Math.sin(pitchRad); + const forwardZ = -Math.cos(pitchRad) * Math.cos(yawRad); + + const newOrbit = { + ...prev, + yaw: newYaw, + pitch: newPitch, + targetX: pos.x + forwardX * prev.distance, + targetY: pos.y + forwardY * prev.distance, + targetZ: pos.z + forwardZ * prev.distance }; requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit)); return newOrbit; @@ -749,8 +821,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport }; const handleMouseUp = () => { - // 3D mode: reset orbit/pan flags - // 3D 模式:重置轨道/平移标志 + // 3D mode: reset all camera control flags + // 3D 模式:重置所有相机控制标志 if (isOrbitingRef.current) { isOrbitingRef.current = false; canvas.style.cursor = 'grab'; @@ -759,6 +831,16 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport isPanningRef.current = false; canvas.style.cursor = 'grab'; } + if (isZoomingRef.current) { + isZoomingRef.current = false; + canvas.style.cursor = 'grab'; + } + if (isFlyModeRef.current) { + isFlyModeRef.current = false; + canvas.style.cursor = 'grab'; + // Reset fly keys | 重置飞行按键 + flyKeysRef.current = { w: false, a: false, s: false, d: false, q: false, e: false }; + } // 2D mode: original mouse up handling // 2D 模式:原始鼠标抬起处理 @@ -857,16 +939,146 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport setCamera2DZoom((prev) => Math.max(0.01, Math.min(100, prev * zoomFactor))); }; + // Keyboard event handlers for fly mode and focus + // 飞行模式和聚焦的键盘事件处理 + const handleKeyDown = (e: KeyboardEvent) => { + if (renderModeRef.current !== '3D') return; + if (playStateRef.current === 'playing') return; + + // WASD + QE for fly mode movement + // WASD + QE 飞行模式移动 + const key = e.key.toLowerCase(); + if (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'q' || key === 'e') { + flyKeysRef.current[key as keyof typeof flyKeysRef.current] = true; + } + + // F key: Focus on selected entity + // F 键:聚焦到选中实体 + if (key === 'f' && selectedEntityRef.current) { + const entity = selectedEntityRef.current; + const transform = entity.getComponent(TransformComponent); + if (transform) { + // Focus camera on entity position + // 聚焦相机到实体位置 + setOrbitCamera((prev) => { + const newOrbit = { + ...prev, + targetX: transform.position.x, + targetY: transform.position.y, + targetZ: transform.position.z, + distance: Math.max(5, prev.distance) // Ensure minimum distance + }; + requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit)); + return newOrbit; + }); + } + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'q' || key === 'e') { + flyKeysRef.current[key as keyof typeof flyKeysRef.current] = false; + } + }; + + // Fly mode animation loop + // 飞行模式动画循环 + let flyAnimationId: number | null = null; + let lastFlyTime = 0; + + const updateFlyMode = (timestamp: number) => { + if (!isFlyModeRef.current) { + flyAnimationId = null; + return; + } + + const deltaTime = lastFlyTime > 0 ? (timestamp - lastFlyTime) / 1000 : 0.016; + lastFlyTime = timestamp; + + const keys = flyKeysRef.current; + const speed = flySpeedRef.current * deltaTime; + + // Check if any movement key is pressed + // 检查是否有移动键被按下 + if (keys.w || keys.a || keys.s || keys.d || keys.q || keys.e) { + setOrbitCamera((prev) => { + const pitchRad = (prev.pitch * Math.PI) / 180; + const yawRad = (prev.yaw * Math.PI) / 180; + + // Calculate camera directions + // 计算相机方向 + const forwardX = -Math.cos(pitchRad) * Math.sin(yawRad); + const forwardY = Math.sin(pitchRad); + const forwardZ = -Math.cos(pitchRad) * Math.cos(yawRad); + const rightX = Math.cos(yawRad); + const rightZ = -Math.sin(yawRad); + + let moveX = 0, moveY = 0, moveZ = 0; + + // WASD movement (reversed A/D for intuitive scene navigation) + // WASD 移动(反转 A/D 以符合直觉的场景导航) + if (keys.w) { moveX += forwardX; moveY += forwardY; moveZ += forwardZ; } + if (keys.s) { moveX -= forwardX; moveY -= forwardY; moveZ -= forwardZ; } + if (keys.a) { moveX += rightX; moveZ += rightZ; } // Reversed: move scene right + if (keys.d) { moveX -= rightX; moveZ -= rightZ; } // Reversed: move scene left + // QE for up/down + if (keys.e) { moveY += 1; } + if (keys.q) { moveY -= 1; } + + // Calculate current camera position + const pos = calculateOrbitCameraPosition(prev); + + // Move both camera and target + // 同时移动相机和目标点 + const newOrbit = { + ...prev, + targetX: prev.targetX + moveX * speed, + targetY: prev.targetY + moveY * speed, + targetZ: prev.targetZ + moveZ * speed + }; + + requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit)); + return newOrbit; + }); + } + + flyAnimationId = requestAnimationFrame(updateFlyMode); + }; + + // Start fly mode loop when entering fly mode + // 进入飞行模式时启动飞行循环 + const startFlyLoop = () => { + if (flyAnimationId === null && isFlyModeRef.current) { + lastFlyTime = 0; + flyAnimationId = requestAnimationFrame(updateFlyMode); + } + }; + + // Check periodically if fly mode is active + // 定期检查飞行模式是否激活 + const flyCheckInterval = setInterval(() => { + if (isFlyModeRef.current && flyAnimationId === null) { + startFlyLoop(); + } + }, 100); + canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('wheel', handleWheel, { passive: false }); canvas.addEventListener('contextmenu', handleContextMenu); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); return () => { if (rafId !== null) { cancelAnimationFrame(rafId); } + if (flyAnimationId !== null) { + cancelAnimationFrame(flyAnimationId); + } + clearInterval(flyCheckInterval); window.removeEventListener('resize', resizeCanvas); resizeObserver.disconnect(); canvas.removeEventListener('mousedown', handleMouseDown); @@ -874,6 +1086,8 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport canvas.removeEventListener('contextmenu', handleContextMenu); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); }; }, []); @@ -1884,8 +2098,10 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport // Check for supported asset types | 检查支持的资产类型 const isPrefab = lowerPath.endsWith('.prefab'); const isFui = lowerPath.endsWith('.fui'); + const is3DModel = lowerPath.endsWith('.gltf') || lowerPath.endsWith('.glb') || + lowerPath.endsWith('.obj') || lowerPath.endsWith('.fbx'); - if (!isPrefab && !isFui) { + if (!isPrefab && !isFui && !is3DModel) { return; } @@ -1976,6 +2192,39 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport entityStore.selectEntity(entity); console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`); + } else if (is3DModel) { + // 处理 3D 模型文件 | Handle 3D model file + const filename = assetPath.split(/[/\\]/).pop() || '3D Mesh'; + const entityName = filename.replace(/\.(gltf|glb|obj|fbx)$/i, ''); + + // 生成唯一名称 | Generate unique name + const existingCount = entityStore.getAllEntities() + .filter((ent: Entity) => ent.name.startsWith(entityName)).length; + const finalName = existingCount > 0 ? `${entityName} ${existingCount + 1}` : entityName; + + // 创建实体 | Create entity + const entity = scene.createEntity(finalName); + + // 添加 TransformComponent | Add TransformComponent + const transform = new TransformComponent(); + transform.position.x = worldPos.x; + transform.position.y = worldPos.y; + entity.addComponent(transform); + + // 添加 MeshComponent | Add MeshComponent + const meshComponent = new MeshComponent(); + // 优先使用 GUID,如果没有则使用路径 + // Prefer GUID, fallback to path + meshComponent.modelGuid = assetGuid || assetPath; + entity.addComponent(meshComponent); + + // 注册并选中实体 | Register and select entity + entityStore.addEntity(entity); + messageHub.publish('entity:added', { entity }); + messageHub.publish('scene:modified', {}); + entityStore.selectEntity(entity); + + console.log(`[Viewport] 3D Mesh entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`); } } catch (error) { console.error('[Viewport] Failed to handle drop:', error); diff --git a/packages/editor-app/src/components/inspectors/component-inspectors/TransformComponentInspector.tsx b/packages/editor-app/src/components/inspectors/component-inspectors/TransformComponentInspector.tsx index 68b84e11..df068272 100644 --- a/packages/editor-app/src/components/inspectors/component-inspectors/TransformComponentInspector.tsx +++ b/packages/editor-app/src/components/inspectors/component-inspectors/TransformComponentInspector.tsx @@ -9,22 +9,28 @@ interface AxisInputProps { axis: 'x' | 'y' | 'z'; value: number; onChange: (value: number) => void; + onChangeCommit?: (value: number) => void; // 拖拽结束时调用 | Called when drag ends suffix?: string; } -function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) { +function AxisInput({ axis, value, onChange, onChangeCommit, suffix }: AxisInputProps) { const [isDragging, setIsDragging] = useState(false); const [inputValue, setInputValue] = useState(String(value ?? 0)); const dragStartRef = useRef({ x: 0, value: 0 }); + const currentValueRef = useRef(value ?? 0); // 跟踪当前值 | Track current value useEffect(() => { - setInputValue(String(value ?? 0)); - }, [value]); + if (!isDragging) { + setInputValue(String(value ?? 0)); + currentValueRef.current = value ?? 0; + } + }, [value, isDragging]); const handleBarMouseDown = (e: React.MouseEvent) => { e.preventDefault(); setIsDragging(true); dragStartRef.current = { x: e.clientX, value: value ?? 0 }; + currentValueRef.current = value ?? 0; }; useEffect(() => { @@ -35,11 +41,14 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) { const sensitivity = e.shiftKey ? 0.01 : e.ctrlKey ? 1 : 0.1; const newValue = dragStartRef.current.value + delta * sensitivity; const rounded = Math.round(newValue * 1000) / 1000; + currentValueRef.current = rounded; + setInputValue(String(rounded)); onChange(rounded); }; const handleMouseUp = () => { setIsDragging(false); + onChangeCommit?.(currentValueRef.current); // 拖拽结束时通知 | Notify when drag ends }; document.addEventListener('mousemove', handleMouseMove); @@ -49,7 +58,7 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - }, [isDragging, onChange]); + }, [isDragging, onChange, onChangeCommit]); const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value); @@ -108,6 +117,7 @@ interface TransformRowProps { isLocked?: boolean; onLockChange?: (locked: boolean) => void; onChange: (value: { x: number; y: number; z: number }) => void; + onChangeCommit?: () => void; // 拖拽结束时调用 | Called when drag ends onReset?: () => void; suffix?: string; showDivider?: boolean; @@ -120,26 +130,54 @@ function TransformRow({ isLocked = false, onLockChange, onChange, + onChangeCommit, onReset, suffix, showDivider = true }: TransformRowProps) { + // 使用 ref 来跟踪当前值,避免在拖拽过程中因重新渲染而丢失 + // Use ref to track current value, avoiding loss during drag re-renders + const currentValueRef = useRef({ x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 }); + + useEffect(() => { + currentValueRef.current = { x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 }; + }, [value?.x, value?.y, value?.z]); + const handleAxisChange = (axis: 'x' | 'y' | 'z', newValue: number) => { + // 使用 ref 中的当前值,确保即使在快速拖拽时也能正确读取 + // Use current value from ref to ensure correct reading during fast dragging + const currentX = currentValueRef.current.x; + const currentY = currentValueRef.current.y; + const currentZ = currentValueRef.current.z; + + let newVector: { x: number; y: number; z: number }; + if (isLocked && showLock) { - const oldVal = value[axis]; + const oldVal = axis === 'x' ? currentX : axis === 'y' ? currentY : currentZ; if (oldVal !== 0) { const ratio = newValue / oldVal; - onChange({ - x: axis === 'x' ? newValue : value.x * ratio, - y: axis === 'y' ? newValue : value.y * ratio, - z: axis === 'z' ? newValue : value.z * ratio - }); + newVector = { + x: axis === 'x' ? newValue : currentX * ratio, + y: axis === 'y' ? newValue : currentY * ratio, + z: axis === 'z' ? newValue : currentZ * ratio + }; } else { - onChange({ ...value, [axis]: newValue }); + newVector = { + x: axis === 'x' ? newValue : currentX, + y: axis === 'y' ? newValue : currentY, + z: axis === 'z' ? newValue : currentZ + }; } } else { - onChange({ ...value, [axis]: newValue }); + newVector = { + x: axis === 'x' ? newValue : currentX, + y: axis === 'y' ? newValue : currentY, + z: axis === 'z' ? newValue : currentZ + }; } + + currentValueRef.current = newVector; + onChange(newVector); }; return ( @@ -154,18 +192,21 @@ function TransformRow({ axis="x" value={value?.x ?? 0} onChange={(v) => handleAxisChange('x', v)} + onChangeCommit={onChangeCommit} suffix={suffix} /> handleAxisChange('y', v)} + onChangeCommit={onChangeCommit} suffix={suffix} /> handleAxisChange('z', v)} + onChangeCommit={onChangeCommit} suffix={suffix} />
@@ -230,21 +271,54 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon const [mobility, setMobility] = useState<'static' | 'stationary' | 'movable'>('static'); const [, forceUpdate] = useState({}); + // 拖拽过程中只更新 transform 值,不触发 UI 刷新 + // During dragging, only update transform value, don't trigger UI refresh const handlePositionChange = (value: { x: number; y: number; z: number }) => { transform.position = value; - context.onChange?.('position', value); - forceUpdate({}); }; const handleRotationChange = (value: { x: number; y: number; z: number }) => { transform.rotation = value; - context.onChange?.('rotation', value); - forceUpdate({}); }; const handleScaleChange = (value: { x: number; y: number; z: number }) => { transform.scale = value; - context.onChange?.('scale', value); + }; + + // 拖拽结束时通知外部并刷新 UI + // Notify external and refresh UI when drag ends + const handlePositionCommit = () => { + context.onChange?.('position', transform.position); + forceUpdate({}); + }; + + const handleRotationCommit = () => { + context.onChange?.('rotation', transform.rotation); + forceUpdate({}); + }; + + const handleScaleCommit = () => { + context.onChange?.('scale', transform.scale); + forceUpdate({}); + }; + + // Reset 操作立即生效 + // Reset operations take effect immediately + const handlePositionReset = () => { + transform.position = { x: 0, y: 0, z: 0 }; + context.onChange?.('position', transform.position); + forceUpdate({}); + }; + + const handleRotationReset = () => { + transform.rotation = { x: 0, y: 0, z: 0 }; + context.onChange?.('rotation', transform.rotation); + forceUpdate({}); + }; + + const handleScaleReset = () => { + transform.scale = { x: 1, y: 1, z: 1 }; + context.onChange?.('scale', transform.scale); forceUpdate({}); }; @@ -254,13 +328,15 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon label="Location" value={transform.position} onChange={handlePositionChange} - onReset={() => handlePositionChange({ x: 0, y: 0, z: 0 })} + onChangeCommit={handlePositionCommit} + onReset={handlePositionReset} /> handleRotationChange({ x: 0, y: 0, z: 0 })} + onChangeCommit={handleRotationCommit} + onReset={handleRotationReset} suffix="°" /> handleScaleChange({ x: 1, y: 1, z: 1 })} + onChangeCommit={handleScaleCommit} + onReset={handleScaleReset} showDivider={false} />
diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index ee785072..80060662 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -15,7 +15,8 @@ import { TextureServiceToken, DynamicAtlasServiceToken, CoordinateServiceToken, - RenderConfigServiceToken + RenderConfigServiceToken, + EngineBridgeToken } from '@esengine/ecs-engine-bindgen'; import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core'; import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite'; @@ -259,6 +260,9 @@ export class EngineService { // 创建服务注册表并注册核心服务 // Create service registry and register core services const services = new PluginServiceRegistry(); + // 注册 EngineBridge(供 MeshRenderSystem 等系统使用) + // Register EngineBridge (for systems like MeshRenderSystem) + services.register(EngineBridgeToken, this._runtime.bridge); // 使用单一职责接口注册 EngineBridge | Register EngineBridge with single-responsibility interfaces services.register(TextureServiceToken, this._runtime.bridge); services.register(DynamicAtlasServiceToken, this._runtime.bridge); diff --git a/packages/editor-app/src/styles/ContentBrowser.css b/packages/editor-app/src/styles/ContentBrowser.css index 1df0ef7e..c5f0cb86 100644 --- a/packages/editor-app/src/styles/ContentBrowser.css +++ b/packages/editor-app/src/styles/ContentBrowser.css @@ -736,6 +736,10 @@ color: #ec407a; } +.asset-thumbnail-icon.model3d { + color: #26a69a; +} + /* ==================== Status Bar ==================== */ .cb-status-bar { display: flex; @@ -878,3 +882,126 @@ .cb-asset-grid::-webkit-scrollbar-thumb:hover { background: #4e4e4e; } + +/* ==================== 3D Model Sub-Asset Expansion ==================== */ + +/* Expand button for expandable models */ +.cb-asset-expand-btn { + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: transparent; + border: none; + border-radius: 3px; + color: #888; + cursor: pointer; + z-index: 1; +} + +.cb-asset-expand-btn:hover { + background: #3c3c3c; + color: #fff; +} + +.cb-asset-expand-btn.expanded { + color: #3b82f6; +} + +/* Spinning animation for loading */ +.cb-asset-expand-btn .spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Grid view adjustments for expandable items */ +.cb-asset-grid.grid .cb-asset-item { + position: relative; +} + +.cb-asset-grid.grid .cb-asset-item .cb-asset-expand-btn { + left: 2px; + top: 2px; + transform: none; +} + +/* Sub-asset items in grid view */ +.cb-asset-grid.grid .cb-asset-item.sub-asset { + background: #252530; + border-left: 2px solid #3b82f6; + padding-left: 16px; +} + +.cb-asset-grid.grid .cb-asset-item.sub-asset:hover { + background: #2d2d38; +} + +.cb-asset-grid.grid .cb-asset-item.sub-asset.selected { + background: #0a4780; +} + +.cb-asset-grid.grid .cb-asset-item.sub-asset .cb-asset-thumbnail { + width: 48px; + height: 48px; + background: #202028; +} + +/* List view adjustments */ +.cb-asset-grid.list .cb-asset-item { + position: relative; +} + +.cb-asset-grid.list .cb-asset-item .cb-asset-expand-btn { + position: relative; + left: auto; + top: auto; + transform: none; + flex-shrink: 0; + margin-right: 4px; +} + +/* Sub-asset items in list view */ +.cb-asset-grid.list .cb-asset-item.sub-asset { + padding-left: 32px; + background: #252530; + border-left: 2px solid #3b82f6; +} + +.cb-asset-grid.list .cb-asset-item.sub-asset:hover { + background: #2d2d38; +} + +.cb-asset-grid.list .cb-asset-item.sub-asset.selected { + background: #0a4780; +} + +/* Sub-asset icon colors */ +.asset-thumbnail-icon.sub-asset.mesh { + color: #f59e0b; +} + +.asset-thumbnail-icon.sub-asset.material { + color: #8b5cf6; +} + +.asset-thumbnail-icon.sub-asset.animation { + color: #10b981; +} + +.asset-thumbnail-icon.sub-asset.skeleton { + color: #ef4444; +} + +/* Expanded model highlight */ +.cb-asset-item.expanded { + border-bottom: 1px solid #3b82f6; +} diff --git a/packages/engine/src/core/engine.rs b/packages/engine/src/core/engine.rs index 15a3090e..f4c2d886 100644 --- a/packages/engine/src/core/engine.rs +++ b/packages/engine/src/core/engine.rs @@ -9,7 +9,7 @@ use crate::backend::WebGL2Backend; use crate::input::InputManager; use crate::renderer::{ Renderer2D, Renderer3D, GridRenderer, Grid3DRenderer, GizmoRenderer, Gizmo3DRenderer, TransformMode, - ViewportManager, TextBatch, MeshBatch, Camera3D, ProjectionType, + ViewportManager, TextBatch, MeshBatch, ProjectionType, }; use crate::resource::TextureManager; use es_engine_shared::traits::backend::GraphicsBackend; @@ -1291,4 +1291,144 @@ impl Engine { } Ok(()) } + + /// Submit a 3D mesh for rendering (with normals). + /// 提交 3D 网格进行渲染(包含法线)。 + /// + /// # Arguments | 参数 + /// * `vertices` - Interleaved vertex data: [x, y, z, u, v, r, g, b, a, nx, ny, nz] per vertex + /// * `indices` - Triangle indices + /// * `transform` - 4x4 model transform matrix (column-major) + /// * `material_id` - Material ID (0 for default) + /// * `texture_id` - Texture ID + pub fn submit_mesh_3d( + &mut self, + vertices: &[f32], + indices: &[u32], + transform: &[f32], + material_id: u32, + texture_id: u32, + ) -> Result<()> { + use crate::renderer::batch::SimpleVertex3D; + + if self.renderer_3d.is_none() { + return Err(crate::core::error::EngineError::WebGLError( + "3D renderer not initialized. Call setRenderMode(1) first.".to_string() + )); + } + + // Parse transform matrix (column-major 4x4) + // 解析变换矩阵(列优先 4x4) + if transform.len() < 16 { + return Err(crate::core::error::EngineError::WebGLError( + "Transform matrix must have 16 elements".to_string() + )); + } + let mat = glam::Mat4::from_cols_array_2d(&[ + [transform[0], transform[1], transform[2], transform[3]], + [transform[4], transform[5], transform[6], transform[7]], + [transform[8], transform[9], transform[10], transform[11]], + [transform[12], transform[13], transform[14], transform[15]], + ]); + + // Parse vertices (12 floats per vertex: x,y,z, u,v, r,g,b,a, nx,ny,nz) + // 解析顶点(每个顶点 12 个浮点数) + // Note: We use SimpleVertex3D which doesn't have normals, so we skip nx,ny,nz + // 注意:我们使用 SimpleVertex3D 没有法线,所以跳过 nx,ny,nz + let vertex_stride = 12; + let vertex_count = vertices.len() / vertex_stride; + let mut simple_vertices = Vec::with_capacity(vertex_count); + + for i in 0..vertex_count { + let base = i * vertex_stride; + simple_vertices.push(SimpleVertex3D::new( + [vertices[base], vertices[base + 1], vertices[base + 2]], // position + [vertices[base + 3], vertices[base + 4]], // uv + [vertices[base + 5], vertices[base + 6], vertices[base + 7], vertices[base + 8]], // color + )); + } + + let submission = crate::renderer::MeshSubmission { + vertices: simple_vertices, + indices: indices.to_vec(), + transform: mat, + material_id, + texture_id, + }; + + if let Some(ref mut renderer) = self.renderer_3d { + renderer.submit_mesh(submission); + } + + Ok(()) + } + + /// Submit a simplified 3D mesh (without normals). + /// 提交简化的 3D 网格(无法线)。 + /// + /// # Arguments | 参数 + /// * `vertices` - Interleaved vertex data: [x, y, z, u, v, r, g, b, a] per vertex + /// * `indices` - Triangle indices + /// * `transform` - 4x4 model transform matrix + /// * `material_id` - Material ID + /// * `texture_id` - Texture ID + pub fn submit_simple_mesh_3d( + &mut self, + vertices: &[f32], + indices: &[u32], + transform: &[f32], + material_id: u32, + texture_id: u32, + ) -> Result<()> { + use crate::renderer::batch::SimpleVertex3D; + + if self.renderer_3d.is_none() { + return Err(crate::core::error::EngineError::WebGLError( + "3D renderer not initialized. Call setRenderMode(1) first.".to_string() + )); + } + + // Parse transform matrix + // 解析变换矩阵 + if transform.len() < 16 { + return Err(crate::core::error::EngineError::WebGLError( + "Transform matrix must have 16 elements".to_string() + )); + } + let mat = glam::Mat4::from_cols_array_2d(&[ + [transform[0], transform[1], transform[2], transform[3]], + [transform[4], transform[5], transform[6], transform[7]], + [transform[8], transform[9], transform[10], transform[11]], + [transform[12], transform[13], transform[14], transform[15]], + ]); + + // Parse vertices (9 floats per vertex: x,y,z, u,v, r,g,b,a) + // 解析顶点(每个顶点 9 个浮点数) + let vertex_stride = 9; + let vertex_count = vertices.len() / vertex_stride; + let mut simple_vertices = Vec::with_capacity(vertex_count); + + for i in 0..vertex_count { + let base = i * vertex_stride; + simple_vertices.push(SimpleVertex3D::new( + [vertices[base], vertices[base + 1], vertices[base + 2]], // position + [vertices[base + 3], vertices[base + 4]], // uv + [vertices[base + 5], vertices[base + 6], vertices[base + 7], vertices[base + 8]], // color + )); + } + + let submission = crate::renderer::MeshSubmission { + vertices: simple_vertices, + indices: indices.to_vec(), + transform: mat, + material_id, + texture_id, + }; + + if let Some(ref mut renderer) = self.renderer_3d { + renderer.submit_mesh(submission); + } + + Ok(()) + } } diff --git a/packages/engine/src/lib.rs b/packages/engine/src/lib.rs index cf9051d4..65d65a9b 100644 --- a/packages/engine/src/lib.rs +++ b/packages/engine/src/lib.rs @@ -1072,4 +1072,56 @@ impl GameEngine { .render_3d() .map_err(|e| JsValue::from_str(&e.to_string())) } + + /// Submit a 3D mesh for rendering. + /// 提交 3D 网格进行渲染。 + /// + /// The mesh will be rendered in the current frame when `render3D` is called. + /// 当调用 `render3D` 时,网格将在当前帧渲染。 + /// + /// # Arguments | 参数 + /// * `vertices` - Interleaved vertex data: [x, y, z, u, v, r, g, b, a, nx, ny, nz] per vertex + /// 交错顶点数据:每个顶点 [x, y, z, u, v, r, g, b, a, nx, ny, nz] + /// * `indices` - Triangle indices | 三角形索引 + /// * `transform` - 4x4 model transform matrix (column-major, 16 floats) + /// 4x4 模型变换矩阵(列优先,16 个浮点数) + /// * `material_id` - Material ID (0 for default) | 材质 ID(0 为默认) + /// * `texture_id` - Texture ID (0 for white) | 纹理 ID(0 为白色) + #[wasm_bindgen(js_name = submitMesh3D)] + pub fn submit_mesh_3d( + &mut self, + vertices: &[f32], + indices: &[u32], + transform: &[f32], + material_id: u32, + texture_id: u32, + ) -> std::result::Result<(), JsValue> { + self.engine + .submit_mesh_3d(vertices, indices, transform, material_id, texture_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Submit a simplified 3D mesh (without normals). + /// 提交简化的 3D 网格(无法线)。 + /// + /// # Arguments | 参数 + /// * `vertices` - Interleaved vertex data: [x, y, z, u, v, r, g, b, a] per vertex + /// 交错顶点数据:每个顶点 [x, y, z, u, v, r, g, b, a] + /// * `indices` - Triangle indices | 三角形索引 + /// * `transform` - 4x4 model transform matrix | 4x4 模型变换矩阵 + /// * `material_id` - Material ID | 材质 ID + /// * `texture_id` - Texture ID | 纹理 ID + #[wasm_bindgen(js_name = submitSimpleMesh3D)] + pub fn submit_simple_mesh_3d( + &mut self, + vertices: &[f32], + indices: &[u32], + transform: &[f32], + material_id: u32, + texture_id: u32, + ) -> std::result::Result<(), JsValue> { + self.engine + .submit_simple_mesh_3d(vertices, indices, transform, material_id, texture_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } } diff --git a/packages/mesh-3d-editor/package.json b/packages/mesh-3d-editor/package.json new file mode 100644 index 00000000..9953207d --- /dev/null +++ b/packages/mesh-3d-editor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@esengine/mesh-3d-editor", + "version": "1.0.0", + "description": "Editor components for 3D mesh system", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "build:watch": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rimraf dist" + }, + "devDependencies": { + "@esengine/ecs-framework": "workspace:*", + "@esengine/engine-core": "workspace:*", + "@esengine/mesh-3d": "workspace:*", + "@esengine/editor-core": "workspace:*", + "@esengine/asset-system": "workspace:*", + "@esengine/build-config": "workspace:*", + "@tauri-apps/api": "^2.5.0", + "react": "^18.3.1", + "@types/react": "^18.2.0", + "lucide-react": "^0.453.0", + "rimraf": "^5.0.5", + "tsup": "^8.0.0", + "typescript": "^5.3.3" + }, + "keywords": [ + "ecs", + "mesh", + "3d", + "editor", + "gltf" + ], + "author": "yhh", + "license": "MIT" +} diff --git a/packages/mesh-3d-editor/src/MeshComponentInspector.css b/packages/mesh-3d-editor/src/MeshComponentInspector.css new file mode 100644 index 00000000..aeb03589 --- /dev/null +++ b/packages/mesh-3d-editor/src/MeshComponentInspector.css @@ -0,0 +1,124 @@ +/** + * Mesh Component Inspector Styles. + * 网格组件检查器样式。 + */ + +.mesh-component-inspector { + margin-top: 8px; +} + +/* Mesh Info Section */ +.mesh-info-section { + background: var(--panel-bg, #1e1e1e); + border: 1px solid var(--border-color, #3c3c3c); + border-radius: 4px; + overflow: hidden; +} + +.mesh-info-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + background: var(--header-bg, #252526); + cursor: pointer; + user-select: none; +} + +.mesh-info-header:hover { + background: var(--header-hover-bg, #2a2a2a); +} + +.mesh-info-expand { + display: flex; + align-items: center; + color: var(--text-secondary, #888); +} + +.mesh-info-title { + font-size: 12px; + font-weight: 500; + color: var(--text-primary, #ccc); +} + +.mesh-info-content { + padding: 8px 10px; +} + +.mesh-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 11px; +} + +.mesh-info-row label { + color: var(--text-secondary, #888); +} + +.mesh-info-value { + color: var(--text-primary, #ccc); + font-family: monospace; +} + +.mesh-info-vec3 { + font-size: 10px; +} + +.mesh-info-divider { + height: 1px; + background: var(--border-color, #3c3c3c); + margin: 8px 0; +} + +.mesh-info-subtitle { + font-size: 11px; + font-weight: 500; + color: var(--text-primary, #ccc); + margin-bottom: 6px; +} + +/* Materials list */ +.mesh-info-materials { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mesh-info-material { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + background: var(--item-bg, #2d2d2d); + border-radius: 3px; + font-size: 11px; +} + +.mesh-info-material-index { + min-width: 20px; + padding: 2px 4px; + background: var(--badge-bg, #3c3c3c); + border-radius: 2px; + text-align: center; + color: var(--text-secondary, #888); +} + +.mesh-info-material-name { + color: var(--text-primary, #ccc); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Empty state */ +.mesh-info-empty { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + color: var(--text-secondary, #888); + font-size: 11px; + font-style: italic; +} diff --git a/packages/mesh-3d-editor/src/MeshComponentInspector.tsx b/packages/mesh-3d-editor/src/MeshComponentInspector.tsx new file mode 100644 index 00000000..6dd1ede7 --- /dev/null +++ b/packages/mesh-3d-editor/src/MeshComponentInspector.tsx @@ -0,0 +1,202 @@ +/** + * Mesh Component Inspector. + * 网格组件检查器。 + * + * Provides custom inspector UI for MeshComponent. + * 为 MeshComponent 提供自定义检查器 UI。 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework'; +import { IComponentInspector, ComponentInspectorContext, MessageHub } from '@esengine/editor-core'; +import { MeshComponent } from '@esengine/mesh-3d'; +import { ChevronDown, ChevronRight, Box, Info } from 'lucide-react'; +import './MeshComponentInspector.css'; + +/** + * Mesh info display props. + * 网格信息显示属性。 + */ +interface MeshInfoProps { + mesh: MeshComponent; +} + +/** + * Mesh info component. + * 网格信息组件。 + * + * Displays detailed mesh information when a model is loaded. + * 当模型加载后显示详细的网格信息。 + */ +function MeshInfo({ mesh }: MeshInfoProps) { + const [isExpanded, setIsExpanded] = useState(true); + + if (!mesh.meshAsset) { + return ( +
+ + No model loaded +
+ ); + } + + const asset = mesh.meshAsset; + const currentMesh = mesh.currentMesh; + const totalMeshes = asset.meshes?.length ?? 0; + + return ( +
+
setIsExpanded(!isExpanded)} + > + + {isExpanded ? : } + + + Mesh Info +
+ + {isExpanded && ( +
+ {/* Model name */} +
+ + {asset.name || 'Unnamed'} +
+ + {/* Total meshes */} +
+ + {totalMeshes} +
+ + {/* Current mesh details */} + {currentMesh && ( + <> +
+
Current Mesh ({mesh.meshIndex})
+ +
+ + {currentMesh.name || `Mesh ${mesh.meshIndex}`} +
+ + {currentMesh.vertices && ( +
+ + {Math.floor(currentMesh.vertices.length / 3).toLocaleString()} +
+ )} + + {currentMesh.indices && ( +
+ + {Math.floor(currentMesh.indices.length / 3).toLocaleString()} +
+ )} + + )} + + {/* Materials */} + {asset.materials && asset.materials.length > 0 && ( + <> +
+
Materials ({asset.materials.length})
+
+ {asset.materials.map((mat, i) => ( +
+ {i} + {mat.name || `Material ${i}`} +
+ ))} +
+ + )} + + {/* Bounds */} + {asset.bounds && ( + <> +
+
Bounds
+
+ + + ({asset.bounds.min[0].toFixed(2)}, {asset.bounds.min[1].toFixed(2)}, {asset.bounds.min[2].toFixed(2)}) + +
+
+ + + ({asset.bounds.max[0].toFixed(2)}, {asset.bounds.max[1].toFixed(2)}, {asset.bounds.max[2].toFixed(2)}) + +
+ + )} +
+ )} +
+ ); +} + +/** + * Mesh inspector content component. + * 网格检查器内容组件。 + */ +function MeshInspectorContent({ context }: { context: ComponentInspectorContext }) { + const mesh = context.component as MeshComponent; + const [, forceUpdate] = useState({}); + + // Force update when mesh index changes + // 当网格索引变化时强制更新 + useEffect(() => { + forceUpdate({}); + }, [mesh.meshIndex, mesh.modelGuid]); + + const handleChange = useCallback((propertyName: string, value: unknown) => { + (mesh as unknown as Record)[propertyName] = value; + context.onChange?.(propertyName, value); + forceUpdate({}); + + // Publish scene:modified + // 发布 scene:modified + const messageHub = Core.services.tryResolve(MessageHub); + if (messageHub) { + messageHub.publish('scene:modified', {}); + } + }, [mesh, context]); + + return ( +
+ {/* Mesh info display */} + +
+ ); +} + +/** + * Mesh component inspector implementation. + * 网格组件检查器实现。 + * + * Uses 'append' mode to show mesh info after the default PropertyInspector. + * 使用 'append' 模式在默认 PropertyInspector 后显示网格信息。 + */ +export class MeshComponentInspector implements IComponentInspector { + readonly id = 'mesh-component-inspector'; + readonly name = 'Mesh Component Inspector'; + readonly priority = 100; + readonly targetComponents = ['Mesh', 'MeshComponent']; + readonly renderMode = 'append' as const; + + canHandle(component: Component): component is MeshComponent { + const typeName = getComponentInstanceTypeName(component); + return typeName === 'Mesh' || typeName === 'MeshComponent'; + } + + render(context: ComponentInspectorContext): React.ReactElement { + return React.createElement(MeshInspectorContent, { + context, + key: `mesh-${context.version}` + }); + } +} diff --git a/packages/mesh-3d-editor/src/components/AnimationPreviewPanel.tsx b/packages/mesh-3d-editor/src/components/AnimationPreviewPanel.tsx new file mode 100644 index 00000000..52c59e7c --- /dev/null +++ b/packages/mesh-3d-editor/src/components/AnimationPreviewPanel.tsx @@ -0,0 +1,498 @@ +/** + * Animation Preview Panel + * 动画预览面板 + * + * Displays 3D model preview with animation playback controls. + * 显示 3D 模型预览和动画播放控制。 + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Core } from '@esengine/ecs-framework'; +import { MessageHub } from '@esengine/editor-core'; +import type { IAssetContent, IAssetParseContext, IGLTFAsset, IGLTFAnimationClip } from '@esengine/asset-system'; +import { FBXLoader, GLTFLoader } from '@esengine/asset-system'; +import { + Play, Pause, Square, SkipBack, SkipForward, + RefreshCw, Clock, Layers, Activity, ChevronDown, RotateCcw +} from 'lucide-react'; +import { ModelPreview3D } from './ModelPreview3D'; +import '../styles/AnimationPreviewPanel.css'; + +/** + * 格式化时间为 MM:SS.ms 格式 + * Format time to MM:SS.ms format + */ +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 100); + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; +} + +/** + * 读取二进制文件(Tauri 环境) + * Read binary file (Tauri environment) + */ +async function readFileBinary(path: string): Promise { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const base64: string = await invoke('read_file_as_base64', { filePath: path }); + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + } catch (error) { + console.error('[AnimationPreview] Failed to read file:', error); + return null; + } +} + +interface AnimationPreviewState { + asset: IGLTFAsset | null; + assetPath: string | null; + selectedAnimationIndex: number; + isPlaying: boolean; + currentTime: number; + speed: number; + loop: boolean; + isLoading: boolean; +} + +const initialState: AnimationPreviewState = { + asset: null, + assetPath: null, + selectedAnimationIndex: 0, + isPlaying: false, + currentTime: 0, + speed: 1.0, + loop: true, + isLoading: false, +}; + +export function AnimationPreviewPanel() { + const [state, setState] = useState(initialState); + + const animationFrameRef = useRef(0); + const lastTimeRef = useRef(0); + + const { + asset, + assetPath, + selectedAnimationIndex, + isPlaying, + currentTime, + speed, + loop, + isLoading, + } = state; + + const currentClip = asset?.animations?.[selectedAnimationIndex] ?? null; + + // Animation loop | 动画循环 + useEffect(() => { + if (!isPlaying || !asset) return; + + const clip = asset.animations?.[selectedAnimationIndex]; + if (!clip || clip.duration <= 0) return; + + const animate = (time: number) => { + if (lastTimeRef.current === 0) { + lastTimeRef.current = time; + } + + const deltaTime = (time - lastTimeRef.current) / 1000; + lastTimeRef.current = time; + + setState(prev => { + if (!prev.isPlaying) return prev; + + const clip = prev.asset?.animations?.[prev.selectedAnimationIndex]; + if (!clip || clip.duration <= 0) return prev; + + let newTime = prev.currentTime + deltaTime * prev.speed; + + if (newTime >= clip.duration) { + if (prev.loop) { + newTime = newTime % clip.duration; + } else { + return { ...prev, currentTime: clip.duration, isPlaying: false }; + } + } + + return { ...prev, currentTime: newTime }; + }); + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + lastTimeRef.current = 0; + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isPlaying, asset, selectedAnimationIndex, speed, loop]); + + // Load asset | 加载资产 + const loadAsset = useCallback(async (filePath: string) => { + setState(prev => ({ ...prev, isLoading: true })); + try { + const fileName = filePath.split(/[\\/]/).pop() || 'Model'; + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + + const binaryData = await readFileBinary(filePath); + if (!binaryData || binaryData.byteLength === 0) { + console.warn('[AnimationPreview] Cannot read file:', filePath); + setState(prev => ({ ...prev, isLoading: false })); + return; + } + + const parseContext = { + metadata: { + path: filePath, + name: fileName, + type: ext === 'fbx' ? 'model/fbx' : 'model/gltf', + guid: '', + size: binaryData.byteLength, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null + } as unknown as IAssetParseContext; + + const content: IAssetContent = { + type: 'binary', + binary: binaryData + }; + + let parsedAsset: IGLTFAsset; + if (ext === 'fbx') { + const loader = new FBXLoader(); + parsedAsset = await loader.parse(content, parseContext); + } else if (ext === 'gltf' || ext === 'glb') { + const loader = new GLTFLoader(); + parsedAsset = await loader.parse(content, parseContext); + } else { + console.warn('[AnimationPreview] Unsupported format:', ext); + setState(prev => ({ ...prev, isLoading: false })); + return; + } + + console.log(`[AnimationPreview] Loaded: ${parsedAsset.meshes?.length ?? 0} meshes, ${parsedAsset.animations?.length ?? 0} animations`); + setState({ + asset: parsedAsset, + assetPath: filePath, + selectedAnimationIndex: 0, + currentTime: 0, + isPlaying: false, + speed: 1.0, + loop: true, + isLoading: false, + }); + } catch (error) { + console.error('[AnimationPreview] Failed to load asset:', error); + setState(prev => ({ ...prev, isLoading: false })); + } + }, []); + + // Listen for animation preview requests | 监听动画预览请求 + useEffect(() => { + const messageHub = Core.services.tryResolve(MessageHub); + if (!messageHub) return; + + const unsubscribe = messageHub.subscribe('animation:preview', (data: { filePath: string; animationIndex?: number }) => { + loadAsset(data.filePath); + if (data.animationIndex !== undefined) { + setState(prev => ({ + ...prev, + selectedAnimationIndex: data.animationIndex!, + currentTime: 0, + isPlaying: false, + })); + } + }); + + return () => unsubscribe?.(); + }, [loadAsset]); + + // Action handlers | 操作处理器 + const selectAnimation = useCallback((index: number) => { + setState(prev => ({ + ...prev, + selectedAnimationIndex: index, + currentTime: 0, + isPlaying: false, + })); + }, []); + + const play = useCallback(() => { + setState(prev => ({ ...prev, isPlaying: true })); + }, []); + + const pause = useCallback(() => { + setState(prev => ({ ...prev, isPlaying: false })); + }, []); + + const stop = useCallback(() => { + setState(prev => ({ ...prev, isPlaying: false, currentTime: 0 })); + }, []); + + const setTime = useCallback((time: number) => { + setState(prev => { + const clip = prev.asset?.animations?.[prev.selectedAnimationIndex]; + if (clip) { + return { ...prev, currentTime: Math.max(0, Math.min(time, clip.duration)) }; + } + return prev; + }); + }, []); + + const setSpeed = useCallback((newSpeed: number) => { + setState(prev => ({ ...prev, speed: Math.max(0.1, Math.min(newSpeed, 5)) })); + }, []); + + const setLoop = useCallback((newLoop: boolean) => { + setState(prev => ({ ...prev, loop: newLoop })); + }, []); + + const handleTimelineChange = useCallback((e: React.ChangeEvent) => { + const value = parseFloat(e.target.value); + setTime(value); + }, [setTime]); + + const handleSpeedChange = useCallback((e: React.ChangeEvent) => { + setSpeed(parseFloat(e.target.value)); + }, [setSpeed]); + + // Render loading state | 渲染加载状态 + if (isLoading) { + return ( +
+ + Loading... +
+ ); + } + + // Render empty state | 渲染空状态 + if (!asset) { + return ( +
+ +

No model loaded

+

Double-click a model or animation in Content Browser

+
+ ); + } + + const animations = asset.animations ?? []; + const hasAnimations = animations.length > 0; + const hasMeshes = (asset.meshes?.length ?? 0) > 0; + const duration = currentClip?.duration ?? 0; + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( +
+ {/* Header | 头部 */} +
+ + {assetPath?.split(/[\\/]/).pop() ?? 'Unknown'} + + +
+ + {/* 3D Preview | 3D 预览 */} + {hasMeshes && ( +
+ +
+ )} + + {/* No mesh message | 无网格消息 */} + {!hasMeshes && ( +
+

No mesh data in this file

+
+ )} + + {/* Animation selector | 动画选择器 */} + {hasAnimations && ( +
+ +
+ + +
+
+ )} + + {/* Animation info | 动画信息 */} + {currentClip && ( +
+
+ + Duration: {formatTime(currentClip.duration)} +
+
+ + Channels: {currentClip.channels?.length ?? 0} +
+
+ )} + + {/* Timeline | 时间轴 */} + {hasAnimations && ( +
+
+ {formatTime(currentTime)} + / + {formatTime(duration)} +
+
+ +
+
+
+ )} + + {/* Playback controls | 播放控制 */} + {hasAnimations && ( +
+ + + {isPlaying ? ( + + ) : ( + + )} + + + + +
+ )} + + {/* Options | 选项 */} + {hasAnimations && ( +
+
+ + +
+
+ +
+
+ )} + + {/* No animations message | 无动画消息 */} + {hasMeshes && !hasAnimations && ( +
+

This model has no animations

+
+ )} + + {/* Model info | 模型信息 */} +
+
Model Info
+
+ Meshes: {asset.meshes?.length ?? 0} +
+
+ Materials: {asset.materials?.length ?? 0} +
+ {asset.skeleton && ( +
+ Joints: {asset.skeleton.joints?.length ?? 0} +
+ )} +
+
+ ); +} + +export default AnimationPreviewPanel; diff --git a/packages/mesh-3d-editor/src/components/ModelPreview3D.tsx b/packages/mesh-3d-editor/src/components/ModelPreview3D.tsx new file mode 100644 index 00000000..48fa7315 --- /dev/null +++ b/packages/mesh-3d-editor/src/components/ModelPreview3D.tsx @@ -0,0 +1,1130 @@ +/** + * 3D Model Preview Component + * 3D 模型预览组件 + * + * A lightweight WebGL-based 3D model renderer for animation preview. + * 基于 WebGL 的轻量级 3D 模型渲染器,用于动画预览。 + */ + +import React, { useRef, useEffect, useCallback, useState } from 'react'; +import type { + IGLTFAsset, + IGLTFAnimationClip, + IMeshData, + IAnimationSampler, + IAnimationChannel, + IGLTFNode, + ISkeletonData +} from '@esengine/asset-system'; + +interface ModelPreview3DProps { + /** GLTF/FBX asset data | GLTF/FBX 资产数据 */ + asset: IGLTFAsset; + /** Current animation clip | 当前动画片段 */ + animationClip?: IGLTFAnimationClip | null; + /** Current playback time in seconds | 当前播放时间(秒) */ + currentTime: number; + /** Preview size | 预览尺寸 */ + width?: number; + height?: number; +} + +/** Maximum bones supported | 支持的最大骨骼数 */ +const MAX_BONES = 128; + +/** Shader sources | 着色器源码 */ +const VERTEX_SHADER = ` + attribute vec3 aPosition; + attribute vec3 aNormal; + attribute vec2 aTexCoord; + + uniform mat4 uModelMatrix; + uniform mat4 uViewMatrix; + uniform mat4 uProjectionMatrix; + uniform mat3 uNormalMatrix; + + varying vec3 vNormal; + varying vec2 vTexCoord; + varying vec3 vPosition; + + void main() { + vec4 worldPosition = uModelMatrix * vec4(aPosition, 1.0); + vPosition = worldPosition.xyz; + vNormal = uNormalMatrix * aNormal; + vTexCoord = aTexCoord; + gl_Position = uProjectionMatrix * uViewMatrix * worldPosition; + } +`; + +/** Skinned mesh vertex shader with bone transforms | 带骨骼变换的蒙皮网格顶点着色器 */ +const SKINNED_VERTEX_SHADER = ` + attribute vec3 aPosition; + attribute vec3 aNormal; + attribute vec2 aTexCoord; + attribute vec4 aJoints; // Bone indices (4 influences per vertex) + attribute vec4 aWeights; // Bone weights (must sum to 1.0) + + uniform mat4 uModelMatrix; + uniform mat4 uViewMatrix; + uniform mat4 uProjectionMatrix; + uniform mat3 uNormalMatrix; + uniform mat4 uBoneMatrices[${MAX_BONES}]; + uniform bool uUseSkinning; + + varying vec3 vNormal; + varying vec2 vTexCoord; + varying vec3 vPosition; + + void main() { + vec4 skinnedPosition; + vec3 skinnedNormal; + + if (uUseSkinning && aWeights.x > 0.0) { + // Calculate skinned position and normal + mat4 skinMatrix = + uBoneMatrices[int(aJoints.x)] * aWeights.x + + uBoneMatrices[int(aJoints.y)] * aWeights.y + + uBoneMatrices[int(aJoints.z)] * aWeights.z + + uBoneMatrices[int(aJoints.w)] * aWeights.w; + + skinnedPosition = skinMatrix * vec4(aPosition, 1.0); + skinnedNormal = mat3(skinMatrix) * aNormal; + } else { + skinnedPosition = vec4(aPosition, 1.0); + skinnedNormal = aNormal; + } + + vec4 worldPosition = uModelMatrix * skinnedPosition; + vPosition = worldPosition.xyz; + vNormal = uNormalMatrix * skinnedNormal; + vTexCoord = aTexCoord; + gl_Position = uProjectionMatrix * uViewMatrix * worldPosition; + } +`; + +const FRAGMENT_SHADER = ` + precision mediump float; + + varying vec3 vNormal; + varying vec2 vTexCoord; + varying vec3 vPosition; + + uniform vec3 uLightDirection; + uniform vec3 uLightColor; + uniform vec3 uAmbientColor; + uniform vec4 uBaseColor; + uniform bool uHasTexture; + uniform sampler2D uTexture; + + void main() { + vec3 normal = normalize(vNormal); + float diffuse = max(dot(normal, -uLightDirection), 0.0); + + vec4 baseColor = uBaseColor; + if (uHasTexture) { + baseColor = texture2D(uTexture, vTexCoord); + } + + vec3 lighting = uAmbientColor + uLightColor * diffuse; + gl_FragColor = vec4(baseColor.rgb * lighting, baseColor.a); + } +`; + +/** Simple grid shader (no derivatives) | 简单网格着色器(无导数函数) */ +const GRID_VERTEX_SHADER = ` + attribute vec3 aPosition; + uniform mat4 uViewMatrix; + uniform mat4 uProjectionMatrix; + + void main() { + gl_Position = uProjectionMatrix * uViewMatrix * vec4(aPosition, 1.0); + } +`; + +const GRID_FRAGMENT_SHADER = ` + precision mediump float; + uniform vec4 uColor; + + void main() { + gl_FragColor = uColor; + } +`; + +/** + * Compile shader | 编译着色器 + */ +function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error('[ModelPreview3D] Shader compile error:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; +} + +/** + * Create shader program | 创建着色器程序 + */ +function createProgram(gl: WebGLRenderingContext, vertSrc: string, fragSrc: string): WebGLProgram | null { + const vertShader = compileShader(gl, gl.VERTEX_SHADER, vertSrc); + const fragShader = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc); + + if (!vertShader || !fragShader) return null; + + const program = gl.createProgram(); + if (!program) return null; + + gl.attachShader(program, vertShader); + gl.attachShader(program, fragShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error('[ModelPreview3D] Program link error:', gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + return null; + } + + gl.deleteShader(vertShader); + gl.deleteShader(fragShader); + + return program; +} + +/** + * Create perspective projection matrix | 创建透视投影矩阵 + */ +function perspective(fov: number, aspect: number, near: number, far: number): Float32Array { + const f = 1.0 / Math.tan(fov / 2); + const nf = 1 / (near - far); + + return new Float32Array([ + f / aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, (far + near) * nf, -1, + 0, 0, 2 * far * near * nf, 0 + ]); +} + +/** + * Create look-at view matrix | 创建观察矩阵 + */ +function lookAt(eye: number[], center: number[], up: number[]): Float32Array { + const zAxis = normalizeVec([eye[0] - center[0], eye[1] - center[1], eye[2] - center[2]]); + const xAxis = normalizeVec(cross(up, zAxis)); + const yAxis = cross(zAxis, xAxis); + + return new Float32Array([ + xAxis[0], yAxis[0], zAxis[0], 0, + xAxis[1], yAxis[1], zAxis[1], 0, + xAxis[2], yAxis[2], zAxis[2], 0, + -dot(xAxis, eye), -dot(yAxis, eye), -dot(zAxis, eye), 1 + ]); +} + +function normalizeVec(v: number[]): number[] { + const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + return len > 0 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 0, 0]; +} + +function cross(a: number[], b: number[]): number[] { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + ]; +} + +function dot(a: number[], b: number[]): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +/** + * Create identity matrix | 创建单位矩阵 + */ +function identity(): Float32Array { + return new Float32Array([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]); +} + +/** + * Spherical Linear Interpolation for quaternions + * 四元数球面线性插值 + * + * Properly handles the "shortest path" by checking dot product sign. + * 通过检查点积符号正确处理"最短路径"。 + */ +function slerpQuaternion(q0: number[], q1: number[], t: number): number[] { + let [x0, y0, z0, w0] = q0; + let [x1, y1, z1, w1] = q1; + + // Compute dot product | 计算点积 + let cosHalfTheta = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1; + + // If dot is negative, negate one quaternion to take shorter path + // 如果点积为负,取反一个四元数以走较短路径 + if (cosHalfTheta < 0) { + x1 = -x1; + y1 = -y1; + z1 = -z1; + w1 = -w1; + cosHalfTheta = -cosHalfTheta; + } + + // If quaternions are very close, use linear interpolation + // 如果四元数非常接近,使用线性插值 + const DOT_THRESHOLD = 0.9995; + if (cosHalfTheta > DOT_THRESHOLD) { + const result = [ + x0 + t * (x1 - x0), + y0 + t * (y1 - y0), + z0 + t * (z1 - z0), + w0 + t * (w1 - w0) + ]; + // Normalize | 归一化 + const len = Math.sqrt(result[0] ** 2 + result[1] ** 2 + result[2] ** 2 + result[3] ** 2); + return [result[0] / len, result[1] / len, result[2] / len, result[3] / len]; + } + + // SLERP formula | SLERP 公式 + const theta0 = Math.acos(cosHalfTheta); + const theta = theta0 * t; + const sinTheta = Math.sin(theta); + const sinTheta0 = Math.sin(theta0); + + const s0 = Math.cos(theta) - cosHalfTheta * sinTheta / sinTheta0; + const s1 = sinTheta / sinTheta0; + + return [ + s0 * x0 + s1 * x1, + s0 * y0 + s1 * y1, + s0 * z0 + s1 * z1, + s0 * w0 + s1 * w1 + ]; +} + +/** + * Extract normal matrix from model matrix | 从模型矩阵提取法线矩阵 + */ +function normalMatrix(modelMatrix: Float32Array): Float32Array { + return new Float32Array([ + modelMatrix[0], modelMatrix[1], modelMatrix[2], + modelMatrix[4], modelMatrix[5], modelMatrix[6], + modelMatrix[8], modelMatrix[9], modelMatrix[10] + ]); +} + +/** + * Calculate bounding box of meshes | 计算网格的包围盒 + * Note: IMeshData has vertices directly, not primitives + */ +function calculateBounds(meshes: IMeshData[]): { min: number[]; max: number[]; center: number[]; size: number } { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + for (const mesh of meshes) { + // IMeshData has vertices directly as Float32Array + const vertices = mesh.vertices; + if (!vertices || vertices.length === 0) continue; + + // Vertices are interleaved or separate - assume 3 floats per vertex + for (let i = 0; i < vertices.length; i += 3) { + const x = vertices[i]; + const y = vertices[i + 1]; + const z = vertices[i + 2]; + + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + minZ = Math.min(minZ, z); + maxZ = Math.max(maxZ, z); + } + } + + if (!isFinite(minX)) { + return { min: [0, 0, 0], max: [1, 1, 1], center: [0, 0.5, 0], size: 1 }; + } + + const center = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2]; + const size = Math.max(maxX - minX, maxY - minY, maxZ - minZ) || 1; + + return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ], center, size }; +} + +/** + * Sample animation at a given time | 在指定时间采样动画 + */ +function sampleAnimation( + clip: IGLTFAnimationClip, + time: number, + nodes: IGLTFNode[] +): Map { + const nodeTransforms = new Map(); + + for (const channel of clip.channels) { + const sampler = clip.samplers[channel.samplerIndex]; + if (!sampler) continue; + + const nodeIndex = channel.target.nodeIndex; + const path = channel.target.path; + + // Sample the animation + const value = sampleSampler(sampler, time, path); + if (!value) continue; + + let transform = nodeTransforms.get(nodeIndex); + if (!transform) { + transform = {}; + nodeTransforms.set(nodeIndex, transform); + } + + if (path === 'translation') { + transform.position = value; + } else if (path === 'rotation') { + transform.rotation = value; + } else if (path === 'scale') { + transform.scale = value; + } + } + + return nodeTransforms; +} + +/** + * Sample a single animation sampler | 采样单个动画采样器 + */ +function sampleSampler( + sampler: IAnimationSampler, + time: number, + path: string +): number[] | null { + const input = sampler.input; + const output = sampler.output; + + if (!input || !output || input.length === 0) return null; + + // Clamp time to animation range + const minTime = input[0]; + const maxTime = input[input.length - 1]; + time = Math.max(minTime, Math.min(maxTime, time)); + + // Find keyframes + let i0 = 0; + for (let i = 0; i < input.length - 1; i++) { + if (time >= input[i] && time <= input[i + 1]) { + i0 = i; + break; + } + if (time < input[i]) break; + i0 = i; + } + const i1 = Math.min(i0 + 1, input.length - 1); + + // Calculate interpolation factor + const t0 = input[i0]; + const t1 = input[i1]; + const t = t1 > t0 ? (time - t0) / (t1 - t0) : 0; + + // Get component count based on path + const componentCount = path === 'rotation' ? 4 : 3; + + // Handle rotation with SLERP (Spherical Linear Interpolation) + // 使用 SLERP(球面线性插值)处理旋转 + if (path === 'rotation') { + const q0 = [ + output[i0 * 4], output[i0 * 4 + 1], + output[i0 * 4 + 2], output[i0 * 4 + 3] + ]; + const q1 = [ + output[i1 * 4], output[i1 * 4 + 1], + output[i1 * 4 + 2], output[i1 * 4 + 3] + ]; + + if (sampler.interpolation === 'STEP') { + return q0; + } + + return slerpQuaternion(q0, q1, t); + } + + // Linear interpolation for position/scale + // 位置/缩放使用线性插值 + const result: number[] = []; + for (let c = 0; c < componentCount; c++) { + const v0 = output[i0 * componentCount + c]; + const v1 = output[i1 * componentCount + c]; + + if (sampler.interpolation === 'STEP') { + result.push(v0); + } else { + result.push(v0 + (v1 - v0) * t); + } + } + + return result; +} + +/** + * Create transformation matrix from position, rotation (quaternion), scale + * 从位置、旋转(四元数)、缩放创建变换矩阵 + */ +function createTransformMatrix( + position: number[], + rotation: number[], + scale: number[] +): Float32Array { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + + // Quaternion to rotation matrix + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); +} + +/** + * Multiply two 4x4 matrices | 4x4 矩阵乘法 + */ +function multiplyMatrices(a: Float32Array, b: Float32Array): Float32Array { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +/** + * Build skeleton hierarchy and calculate bone world matrices + * 构建骨骼层级并计算骨骼世界矩阵 + * + * @param skeleton - Skeleton data | 骨骼数据 + * @param nodes - Scene nodes | 场景节点 + * @param animTransforms - Animated transforms by node index | 按节点索引的动画变换 + * @returns Array of bone world matrices | 骨骼世界矩阵数组 + */ +function calculateBoneMatrices( + skeleton: ISkeletonData, + nodes: IGLTFNode[], + animTransforms: Map +): Float32Array[] { + const { joints } = skeleton; + const boneCount = joints.length; + + // Local transforms for each joint | 每个关节的局部变换 + const localMatrices = new Array(boneCount); + // World transforms for each joint | 每个关节的世界变换 + const worldMatrices = new Array(boneCount); + // Final skin matrices | 最终蒙皮矩阵 + const skinMatrices = new Array(boneCount); + + // Build processing order (parents before children) | 构建处理顺序(父节点在子节点之前) + const processed = new Set(); + const processingOrder: number[] = []; + + function addJoint(jointIndex: number) { + if (processed.has(jointIndex)) return; + + const joint = joints[jointIndex]; + // Process parent first | 先处理父节点 + if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { + addJoint(joint.parentIndex); + } + + processingOrder.push(jointIndex); + processed.add(jointIndex); + } + + for (let i = 0; i < boneCount; i++) { + addJoint(i); + } + + // Calculate transforms | 计算变换 + for (const jointIndex of processingOrder) { + const joint = joints[jointIndex]; + const node = nodes[joint.nodeIndex]; + + if (!node) { + localMatrices[jointIndex] = identity(); + worldMatrices[jointIndex] = identity(); + skinMatrices[jointIndex] = identity(); + continue; + } + + // Get animated or default transform | 获取动画或默认变换 + const animTransform = animTransforms.get(joint.nodeIndex); + const pos = animTransform?.position || node.transform.position; + const rot = animTransform?.rotation || node.transform.rotation; + const scl = animTransform?.scale || node.transform.scale; + + + // Create local matrix | 创建局部矩阵 + localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); + + // Calculate world matrix | 计算世界矩阵 + if (joint.parentIndex >= 0) { + worldMatrices[jointIndex] = multiplyMatrices( + worldMatrices[joint.parentIndex], + localMatrices[jointIndex] + ); + } else { + worldMatrices[jointIndex] = localMatrices[jointIndex]; + } + + // Calculate skin matrix = worldMatrix * inverseBindMatrix | 计算蒙皮矩阵 + skinMatrices[jointIndex] = multiplyMatrices( + worldMatrices[jointIndex], + joint.inverseBindMatrix + ); + } + + return skinMatrices; +} + +export const ModelPreview3D: React.FC = ({ + asset, + animationClip, + currentTime, + width = 280, + height = 200 +}) => { + const canvasRef = useRef(null); + const glRef = useRef(null); + const programRef = useRef(null); + const skinnedProgramRef = useRef(null); + const gridProgramRef = useRef(null); + const buffersRef = useRef>(new Map()); + const gridBufferRef = useRef(null); + const hasSkinningRef = useRef(false); + + // Camera state + const [cameraRotation, setCameraRotation] = useState({ theta: 0.5, phi: 0.4 }); + const [cameraDistance, setCameraDistance] = useState(3); + const isDraggingRef = useRef(false); + const lastMouseRef = useRef({ x: 0, y: 0 }); + const boundsRef = useRef<{ center: number[]; size: number }>({ center: [0, 0, 0], size: 1 }); + + // Initialize WebGL | 初始化 WebGL + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const gl = canvas.getContext('webgl', { + antialias: true, + alpha: true, + depth: true + }); + + if (!gl) { + console.error('[ModelPreview3D] WebGL not supported'); + return; + } + + glRef.current = gl; + + // Create shader programs + programRef.current = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER); + skinnedProgramRef.current = createProgram(gl, SKINNED_VERTEX_SHADER, FRAGMENT_SHADER); + gridProgramRef.current = createProgram(gl, GRID_VERTEX_SHADER, GRID_FRAGMENT_SHADER); + + // Create grid buffer - simple line grid + const gridSize = 5; + const gridStep = 1; + const gridVertices: number[] = []; + + for (let i = -gridSize; i <= gridSize; i += gridStep) { + // X axis lines + gridVertices.push(-gridSize, 0, i); + gridVertices.push(gridSize, 0, i); + // Z axis lines + gridVertices.push(i, 0, -gridSize); + gridVertices.push(i, 0, gridSize); + } + + const gridBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(gridVertices), gl.STATIC_DRAW); + gridBufferRef.current = gridBuffer; + + // Store grid line count + buffersRef.current.set('gridLineCount', (gridVertices.length / 3) as any); + + // Enable depth test and blending + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + return () => { + buffersRef.current.forEach((buffer) => { + if (typeof buffer === 'object') gl.deleteBuffer(buffer); + }); + buffersRef.current.clear(); + if (gridBufferRef.current) gl.deleteBuffer(gridBufferRef.current); + if (programRef.current) gl.deleteProgram(programRef.current); + if (skinnedProgramRef.current) gl.deleteProgram(skinnedProgramRef.current); + if (gridProgramRef.current) gl.deleteProgram(gridProgramRef.current); + }; + }, []); + + // Upload mesh data | 上传网格数据 + useEffect(() => { + const gl = glRef.current; + if (!gl || !asset.meshes || asset.meshes.length === 0) return; + + // Calculate bounds for camera framing + const bounds = calculateBounds(asset.meshes); + boundsRef.current = { center: bounds.center, size: bounds.size }; + setCameraDistance(bounds.size * 2.5); + + // Clear old mesh buffers (keep grid line count) + const gridLineCount = buffersRef.current.get('gridLineCount'); + buffersRef.current.forEach((buffer, key) => { + if (key !== 'gridLineCount' && typeof buffer === 'object') { + gl.deleteBuffer(buffer); + } + }); + buffersRef.current.clear(); + if (gridLineCount) buffersRef.current.set('gridLineCount', gridLineCount); + + // Create buffers for each mesh + // IMeshData has: vertices, indices, normals, uvs directly + let meshIndex = 0; + for (const mesh of asset.meshes) { + const vertices = mesh.vertices; + const normals = mesh.normals; + const uvs = mesh.uvs; + const indices = mesh.indices; + + if (!vertices || vertices.length === 0) continue; + + // Position buffer + const posBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + buffersRef.current.set(`pos_${meshIndex}`, posBuffer!); + + // Normal buffer + if (normals && normals.length > 0) { + const normBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer); + gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW); + buffersRef.current.set(`norm_${meshIndex}`, normBuffer!); + } + + // UV buffer + if (uvs && uvs.length > 0) { + const uvBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer); + gl.bufferData(gl.ARRAY_BUFFER, uvs, gl.STATIC_DRAW); + buffersRef.current.set(`uv_${meshIndex}`, uvBuffer!); + } + + // Index buffer + if (indices && indices.length > 0) { + const idxBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuffer); + // Handle both Uint16Array and Uint32Array + if (indices instanceof Uint32Array) { + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + buffersRef.current.set(`idxType_${meshIndex}`, gl.UNSIGNED_INT as any); + } else { + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + buffersRef.current.set(`idxType_${meshIndex}`, gl.UNSIGNED_SHORT as any); + } + buffersRef.current.set(`idx_${meshIndex}`, idxBuffer!); + buffersRef.current.set(`count_${meshIndex}`, indices.length as any); + } else { + buffersRef.current.set(`count_${meshIndex}`, (vertices.length / 3) as any); + } + + // Skinning: Joints buffer (bone indices per vertex) + // 蒙皮:关节缓冲区(每顶点的骨骼索引) + const joints = (mesh as any).joints; + if (joints && joints.length > 0) { + const jointsBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, jointsBuffer); + // Convert to float for WebGL 1.0 compatibility + const jointsFloat = new Float32Array(joints.length); + for (let j = 0; j < joints.length; j++) { + jointsFloat[j] = joints[j]; + } + gl.bufferData(gl.ARRAY_BUFFER, jointsFloat, gl.STATIC_DRAW); + buffersRef.current.set(`joints_${meshIndex}`, jointsBuffer!); + } + + // Skinning: Weights buffer (bone weights per vertex) + // 蒙皮:权重缓冲区(每顶点的骨骼权重) + const weights = (mesh as any).weights; + if (weights && weights.length > 0) { + const weightsBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, weightsBuffer); + gl.bufferData(gl.ARRAY_BUFFER, weights, gl.STATIC_DRAW); + buffersRef.current.set(`weights_${meshIndex}`, weightsBuffer!); + } + + meshIndex++; + } + + // Check if any mesh has skinning data | 检查是否有蒙皮数据 + hasSkinningRef.current = asset.meshes.some((m: any) => m.joints && m.weights); + const firstMesh = asset.meshes[0] as any; + + // Find unique joint indices used in mesh | 查找网格中使用的唯一关节索引 + let maxJointIndex = -1; + const usedJoints = new Set(); + if (firstMesh?.joints) { + for (let i = 0; i < firstMesh.joints.length; i++) { + const jIdx = firstMesh.joints[i]; + usedJoints.add(jIdx); + if (jIdx > maxJointIndex) maxJointIndex = jIdx; + } + } + + buffersRef.current.set('meshCount', meshIndex as any); + }, [asset.meshes]); + + // Render scene | 渲染场景 + useEffect(() => { + const gl = glRef.current; + const program = programRef.current; + const gridProgram = gridProgramRef.current; + if (!gl || !program || !gridProgram) return; + + const { center, size } = boundsRef.current; + + // Calculate camera position + const camX = center[0] + cameraDistance * Math.sin(cameraRotation.theta) * Math.cos(cameraRotation.phi); + const camY = center[1] + cameraDistance * Math.sin(cameraRotation.phi); + const camZ = center[2] + cameraDistance * Math.cos(cameraRotation.theta) * Math.cos(cameraRotation.phi); + + // Setup matrices + const projectionMatrix = perspective(Math.PI / 4, width / height, 0.1, size * 10); + const viewMatrix = lookAt([camX, camY, camZ], center, [0, 1, 0]); + let modelMatrix = identity(); + + // Calculate bone matrices for skeletal animation | 计算骨骼动画的骨骼矩阵 + let boneMatrices: Float32Array[] | null = null; + + if (animationClip && asset.nodes) { + const nodeTransforms = sampleAnimation(animationClip, currentTime, asset.nodes); + + // If we have skeleton data, calculate full bone matrices | 如果有骨骼数据,计算完整骨骼矩阵 + if (asset.skeleton && asset.skeleton.joints.length > 0) { + boneMatrices = calculateBoneMatrices(asset.skeleton, asset.nodes, nodeTransforms); + + // Fallback: if no GPU skinning, apply root bone transform to model + // 回退:如果没有 GPU 蒙皮,将根骨骼变换应用到模型 + if (!hasSkinningRef.current && boneMatrices.length > 0) { + // Find the hip/root bone - usually first few bones + // 查找臀部/根骨骼 - 通常是前几个骨骼 + const rootJoint = asset.skeleton.joints[asset.skeleton.rootJointIndex]; + if (rootJoint) { + const rootTransform = nodeTransforms.get(rootJoint.nodeIndex); + if (rootTransform) { + const pos = rootTransform.position || [0, 0, 0]; + const rot = rootTransform.rotation || [0, 0, 0, 1]; + const scl = rootTransform.scale || [1, 1, 1]; + modelMatrix = createTransformMatrix(pos, rot, scl); + } + } + } + + } else { + // For non-skeletal animation, build node hierarchy and apply transforms + // 对于非骨骼动画,构建节点层级并应用变换 + const nodeWorldMatrices = new Map(); + + // Calculate world matrices for all nodes | 计算所有节点的世界矩阵 + function calculateNodeWorldMatrix(nodeIndex: number): Float32Array { + if (nodeWorldMatrices.has(nodeIndex)) { + return nodeWorldMatrices.get(nodeIndex)!; + } + + const node = asset.nodes![nodeIndex]; + if (!node) { + const mat = identity(); + nodeWorldMatrices.set(nodeIndex, mat); + return mat; + } + + // Get animated or default transform | 获取动画或默认变换 + const animTransform = nodeTransforms.get(nodeIndex); + const pos = animTransform?.position || node.transform.position; + const rot = animTransform?.rotation || node.transform.rotation; + const scl = animTransform?.scale || node.transform.scale; + + const localMatrix = createTransformMatrix(pos, rot, scl); + + // Find parent node | 查找父节点 + let parentIndex = -1; + for (let i = 0; i < asset.nodes!.length; i++) { + if (asset.nodes![i].children.includes(nodeIndex)) { + parentIndex = i; + break; + } + } + + let worldMatrix: Float32Array; + if (parentIndex >= 0) { + const parentWorld = calculateNodeWorldMatrix(parentIndex); + worldMatrix = multiplyMatrices(parentWorld, localMatrix); + } else { + worldMatrix = localMatrix; + } + + nodeWorldMatrices.set(nodeIndex, worldMatrix); + return worldMatrix; + } + + // Find node with mesh and get its world matrix | 查找带网格的节点并获取其世界矩阵 + for (let i = 0; i < asset.nodes.length; i++) { + const node = asset.nodes[i]; + if (node.meshIndex !== undefined && node.meshIndex >= 0) { + modelMatrix = calculateNodeWorldMatrix(i); + break; + } + } + + } + } + + const normMatrix = normalMatrix(modelMatrix); + + // Clear + gl.viewport(0, 0, width, height); + gl.clearColor(0.15, 0.15, 0.18, 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + // Draw grid + gl.useProgram(gridProgram); + gl.uniformMatrix4fv(gl.getUniformLocation(gridProgram, 'uViewMatrix'), false, viewMatrix); + gl.uniformMatrix4fv(gl.getUniformLocation(gridProgram, 'uProjectionMatrix'), false, projectionMatrix); + gl.uniform4f(gl.getUniformLocation(gridProgram, 'uColor'), 0.4, 0.4, 0.4, 0.5); + + const gridPosLoc = gl.getAttribLocation(gridProgram, 'aPosition'); + gl.bindBuffer(gl.ARRAY_BUFFER, gridBufferRef.current); + gl.enableVertexAttribArray(gridPosLoc); + gl.vertexAttribPointer(gridPosLoc, 3, gl.FLOAT, false, 0, 0); + + const gridLineCount = buffersRef.current.get('gridLineCount') as unknown as number || 0; + gl.drawArrays(gl.LINES, 0, gridLineCount); + + // Select shader based on skinning | 根据蒙皮选择着色器 + const useSkinning = hasSkinningRef.current && boneMatrices && boneMatrices.length > 0; + const skinnedProgram = skinnedProgramRef.current; + const activeProgram = useSkinning && skinnedProgram ? skinnedProgram : program; + + gl.useProgram(activeProgram); + + // Set uniforms + gl.uniformMatrix4fv(gl.getUniformLocation(activeProgram, 'uModelMatrix'), false, modelMatrix); + gl.uniformMatrix4fv(gl.getUniformLocation(activeProgram, 'uViewMatrix'), false, viewMatrix); + gl.uniformMatrix4fv(gl.getUniformLocation(activeProgram, 'uProjectionMatrix'), false, projectionMatrix); + gl.uniformMatrix3fv(gl.getUniformLocation(activeProgram, 'uNormalMatrix'), false, normMatrix); + + // Lighting + gl.uniform3f(gl.getUniformLocation(activeProgram, 'uLightDirection'), 0.5, -0.7, 0.5); + gl.uniform3f(gl.getUniformLocation(activeProgram, 'uLightColor'), 0.8, 0.8, 0.8); + gl.uniform3f(gl.getUniformLocation(activeProgram, 'uAmbientColor'), 0.3, 0.3, 0.35); + gl.uniform4f(gl.getUniformLocation(activeProgram, 'uBaseColor'), 0.7, 0.7, 0.75, 1.0); + gl.uniform1i(gl.getUniformLocation(activeProgram, 'uHasTexture'), 0); + + // Upload bone matrices if skinning | 如果蒙皮则上传骨骼矩阵 + if (useSkinning && skinnedProgram) { + gl.uniform1i(gl.getUniformLocation(skinnedProgram, 'uUseSkinning'), 1); + + // Upload bone matrices (limited to MAX_BONES) + const boneCount = Math.min(boneMatrices!.length, MAX_BONES); + for (let b = 0; b < boneCount; b++) { + const loc = gl.getUniformLocation(skinnedProgram, `uBoneMatrices[${b}]`); + if (loc) { + gl.uniformMatrix4fv(loc, false, boneMatrices![b]); + } + } + } + + // Get attribute locations + const posLoc = gl.getAttribLocation(activeProgram, 'aPosition'); + const normLoc = gl.getAttribLocation(activeProgram, 'aNormal'); + const texLoc = gl.getAttribLocation(activeProgram, 'aTexCoord'); + const jointsLoc = useSkinning ? gl.getAttribLocation(activeProgram, 'aJoints') : -1; + const weightsLoc = useSkinning ? gl.getAttribLocation(activeProgram, 'aWeights') : -1; + + // Draw each mesh + const meshCount = buffersRef.current.get('meshCount') as unknown as number || 0; + for (let i = 0; i < meshCount; i++) { + const posBuffer = buffersRef.current.get(`pos_${i}`); + const normBuffer = buffersRef.current.get(`norm_${i}`); + const uvBuffer = buffersRef.current.get(`uv_${i}`); + const idxBuffer = buffersRef.current.get(`idx_${i}`); + const jointsBuffer = buffersRef.current.get(`joints_${i}`); + const weightsBuffer = buffersRef.current.get(`weights_${i}`); + const count = buffersRef.current.get(`count_${i}`) as unknown as number; + const idxType = buffersRef.current.get(`idxType_${i}`) as unknown as number; + + if (!posBuffer || typeof posBuffer !== 'object') continue; + + // Position + gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0); + + // Normal + if (normBuffer && typeof normBuffer === 'object' && normLoc >= 0) { + gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer); + gl.enableVertexAttribArray(normLoc); + gl.vertexAttribPointer(normLoc, 3, gl.FLOAT, false, 0, 0); + } else if (normLoc >= 0) { + gl.disableVertexAttribArray(normLoc); + gl.vertexAttrib3f(normLoc, 0, 1, 0); + } + + // TexCoord + if (uvBuffer && typeof uvBuffer === 'object' && texLoc >= 0) { + gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer); + gl.enableVertexAttribArray(texLoc); + gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0); + } else if (texLoc >= 0) { + gl.disableVertexAttribArray(texLoc); + gl.vertexAttrib2f(texLoc, 0, 0); + } + + // Skinning attributes | 蒙皮属性 + if (useSkinning && jointsLoc >= 0) { + if (jointsBuffer && typeof jointsBuffer === 'object') { + gl.bindBuffer(gl.ARRAY_BUFFER, jointsBuffer); + gl.enableVertexAttribArray(jointsLoc); + gl.vertexAttribPointer(jointsLoc, 4, gl.FLOAT, false, 0, 0); + } else { + gl.disableVertexAttribArray(jointsLoc); + gl.vertexAttrib4f(jointsLoc, 0, 0, 0, 0); + } + } + + if (useSkinning && weightsLoc >= 0) { + if (weightsBuffer && typeof weightsBuffer === 'object') { + gl.bindBuffer(gl.ARRAY_BUFFER, weightsBuffer); + gl.enableVertexAttribArray(weightsLoc); + gl.vertexAttribPointer(weightsLoc, 4, gl.FLOAT, false, 0, 0); + } else { + gl.disableVertexAttribArray(weightsLoc); + gl.vertexAttrib4f(weightsLoc, 0, 0, 0, 0); + } + } + + // Draw + if (idxBuffer && typeof idxBuffer === 'object') { + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuffer); + // Check for OES_element_index_uint extension for Uint32 indices + if (idxType === gl.UNSIGNED_INT) { + const ext = gl.getExtension('OES_element_index_uint'); + if (ext) { + gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_INT, 0); + } + } else { + gl.drawElements(gl.TRIANGLES, count, gl.UNSIGNED_SHORT, 0); + } + } else { + gl.drawArrays(gl.TRIANGLES, 0, count); + } + } + }, [asset.meshes, asset.nodes, asset.skeleton, cameraRotation, cameraDistance, width, height, currentTime, animationClip]); + + // Mouse handlers for camera orbit | 鼠标处理器用于相机旋转 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + isDraggingRef.current = true; + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDraggingRef.current) return; + + const dx = e.clientX - lastMouseRef.current.x; + const dy = e.clientY - lastMouseRef.current.y; + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + + setCameraRotation(prev => ({ + theta: prev.theta - dx * 0.01, + phi: Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, prev.phi + dy * 0.01)) + })); + }, []); + + const handleMouseUp = useCallback(() => { + isDraggingRef.current = false; + }, []); + + // Use non-passive wheel listener to prevent scroll propagation + // 使用非 passive 滚轮监听器以阻止滚动穿透 + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + setCameraDistance(prev => Math.max(0.5, Math.min(50, prev + e.deltaY * 0.01 * (prev * 0.5)))); + }; + + // Add with { passive: false } to allow preventDefault + canvas.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + canvas.removeEventListener('wheel', handleWheel); + }; + }, []); + + return ( +
+ +
+ 拖拽旋转 | 滚轮缩放 +
+
+ ); +}; + +export default ModelPreview3D; diff --git a/packages/mesh-3d-editor/src/index.ts b/packages/mesh-3d-editor/src/index.ts new file mode 100644 index 00000000..119b3550 --- /dev/null +++ b/packages/mesh-3d-editor/src/index.ts @@ -0,0 +1,221 @@ +/** + * @esengine/mesh-3d-editor + * + * Editor support for @esengine/mesh-3d - inspectors and entity templates + * 3D 网格编辑器支持 - 检视器和实体模板 + */ + +import React from 'react'; +import type { Entity, ServiceContainer } from '@esengine/ecs-framework'; +import { Core } from '@esengine/ecs-framework'; +import type { + IEditorModuleLoader, + EntityCreationTemplate, + IEditorPlugin, + ModuleManifest, + PanelDescriptor +} from '@esengine/editor-core'; +import { + EntityStoreService, + MessageHub, + EditorComponentRegistry, + ComponentInspectorRegistry, + PanelPosition +} from '@esengine/editor-core'; +import { TransformComponent } from '@esengine/engine-core'; + +// Runtime imports from @esengine/mesh-3d +import { + MeshComponent, + Animation3DComponent, + SkeletonComponent, + Mesh3DRuntimeModule +} from '@esengine/mesh-3d'; + +// Inspector +import { MeshComponentInspector } from './MeshComponentInspector'; + +// Panel +import { AnimationPreviewPanel } from './components/AnimationPreviewPanel'; + +// Export inspector and panel +export { MeshComponentInspector } from './MeshComponentInspector'; +export { AnimationPreviewPanel } from './components/AnimationPreviewPanel'; + +/** + * 3D 网格编辑器模块 + * Mesh 3D Editor Module + */ +export class Mesh3DEditorModule implements IEditorModuleLoader { + async install(services: ServiceContainer): Promise { + // 注册组件检查器 | Register component inspectors + const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry); + if (componentInspectorRegistry) { + componentInspectorRegistry.register(new MeshComponentInspector()); + } + + // 注册 Mesh 组件到编辑器组件注册表 | Register Mesh components to editor component registry + const componentRegistry = services.resolve(EditorComponentRegistry); + if (componentRegistry) { + const meshComponents = [ + { + name: 'Mesh', + type: MeshComponent, + category: 'components.category.rendering', + description: '3D mesh rendering component', + icon: 'Box' + } + ]; + + for (const comp of meshComponents) { + componentRegistry.register({ + name: comp.name, + type: comp.type, + category: comp.category, + description: comp.description, + icon: comp.icon + }); + } + + // Register animation components + // 注册动画组件 + componentRegistry.register({ + name: 'Animation3D', + type: Animation3DComponent, + category: 'components.category.animation', + description: '3D animation playback component', + icon: 'Play' + }); + + componentRegistry.register({ + name: 'Skeleton', + type: SkeletonComponent, + category: 'components.category.animation', + description: 'Skeleton component for skinned meshes', + icon: 'GitBranch' + }); + } + } + + async uninstall(): Promise { + // Nothing to cleanup + } + + /** + * 获取面板描述符 + * Get panel descriptors + */ + getPanels(): PanelDescriptor[] { + return [ + { + id: 'animation-preview', + title: 'Animation Preview', + titleKey: 'panel.animationPreview', + icon: 'Play', + position: PanelPosition.Right, + component: AnimationPreviewPanel, + defaultSize: 300, + resizable: true, + closable: true, + order: 150 + } + ]; + } + + getEntityCreationTemplates(): EntityCreationTemplate[] { + return [ + // 3D Mesh Entity + { + id: 'create-mesh-3d', + label: '3D Mesh', + icon: 'Box', + category: 'rendering', + order: 200, + create: (): number => { + return this.createMeshEntity('Mesh3D'); + } + } + ]; + } + + /** + * 创建 Mesh 实体的辅助方法 + * Helper method to create Mesh entity + */ + private createMeshEntity(baseName: string, configure?: (entity: Entity) => void): number { + const scene = Core.scene; + if (!scene) { + throw new Error('Scene not available'); + } + + const entityStore = Core.services.resolve(EntityStoreService); + const messageHub = Core.services.resolve(MessageHub); + + if (!entityStore || !messageHub) { + throw new Error('EntityStoreService or MessageHub not available'); + } + + const existingCount = entityStore.getAllEntities() + .filter((e: Entity) => e.name.startsWith(baseName)).length; + const entityName = existingCount > 0 ? `${baseName} ${existingCount + 1}` : baseName; + + const entity = scene.createEntity(entityName); + + // Add Transform component + const transform = new TransformComponent(); + entity.addComponent(transform); + + // Add Mesh component + const mesh = new MeshComponent(); + entity.addComponent(mesh); + + if (configure) { + configure(entity); + } + + entityStore.addEntity(entity); + messageHub.publish('entity:added', { entity }); + messageHub.publish('scene:modified', {}); + entityStore.selectEntity(entity); + + return entity.id; + } +} + +export const mesh3DEditorModule = new Mesh3DEditorModule(); + +/** + * Mesh3D 插件清单 + * Mesh3D Plugin Manifest + */ +const manifest: ModuleManifest = { + id: '@esengine/mesh-3d', + name: '@esengine/mesh-3d', + displayName: 'Mesh 3D', + version: '1.0.0', + description: '3D mesh rendering with GLTF/GLB/OBJ/FBX support', + category: 'Rendering', + icon: 'Box', + isCore: false, + defaultEnabled: true, + isEngineModule: true, + canContainContent: true, + dependencies: ['core', 'math', 'asset-system'], + exports: { + components: ['MeshComponent', 'Animation3DComponent', 'SkeletonComponent'], + systems: ['MeshRenderSystem', 'Animation3DSystem', 'SkeletonBakingSystem'] + }, + requiresWasm: true +}; + +/** + * 完整的 Mesh3D 插件(运行时 + 编辑器) + * Complete Mesh3D Plugin (runtime + editor) + */ +export const Mesh3DPlugin: IEditorPlugin = { + manifest, + runtimeModule: new Mesh3DRuntimeModule(), + editorModule: mesh3DEditorModule +}; + +export default mesh3DEditorModule; diff --git a/packages/mesh-3d-editor/src/styles/AnimationPreviewPanel.css b/packages/mesh-3d-editor/src/styles/AnimationPreviewPanel.css new file mode 100644 index 00000000..a9c3f669 --- /dev/null +++ b/packages/mesh-3d-editor/src/styles/AnimationPreviewPanel.css @@ -0,0 +1,377 @@ +/** + * Animation Preview Panel Styles + * 动画预览面板样式 + */ + +.animation-preview-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--panel-background, #1e1e1e); + color: var(--text-color, #cccccc); + font-size: 12px; + overflow-y: auto; +} + +.animation-preview-panel.loading, +.animation-preview-panel.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted, #888888); +} + +.animation-preview-panel.loading .spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.animation-preview-panel.empty p { + margin: 0; + text-align: center; +} + +.animation-preview-panel.empty .hint { + font-size: 11px; + color: var(--text-muted, #666666); +} + +/* Header | 头部 */ +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--header-background, #252526); + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.asset-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.icon-button { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + background: transparent; + border: none; + border-radius: 3px; + color: var(--text-muted, #888888); + cursor: pointer; +} + +.icon-button:hover { + background: var(--button-hover, #3c3c3c); + color: var(--text-color, #cccccc); +} + +/* 3D Preview Viewport | 3D 预览视口 */ +.preview-viewport { + padding: 8px; + background: #1a1a1e; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.preview-viewport canvas { + display: block; + border-radius: 4px; +} + +.model-preview-3d { + position: relative; +} + +/* No mesh message | 无网格消息 */ +.no-mesh-message { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--section-background, #2d2d2d); + border-bottom: 1px solid var(--border-color, #3c3c3c); + color: var(--text-muted, #666666); +} + +/* Animation selector | 动画选择器 */ +.animation-selector { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.animation-selector label { + flex-shrink: 0; + color: var(--text-muted, #888888); +} + +.select-wrapper { + flex: 1; + position: relative; +} + +.select-wrapper select { + width: 100%; + padding: 4px 24px 4px 8px; + background: var(--input-background, #3c3c3c); + border: 1px solid var(--border-color, #5c5c5c); + border-radius: 3px; + color: var(--text-color, #cccccc); + font-size: 12px; + appearance: none; + cursor: pointer; +} + +.select-wrapper select:hover { + border-color: var(--border-hover, #007acc); +} + +.select-wrapper svg { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--text-muted, #888888); +} + +/* Animation info | 动画信息 */ +.animation-info { + padding: 8px 12px; + background: var(--section-background, #2d2d2d); + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.info-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + color: var(--text-muted, #888888); +} + +.info-row svg { + flex-shrink: 0; +} + +/* Timeline | 时间轴 */ +.timeline-section { + padding: 12px; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.time-display { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 8px; + font-family: monospace; + font-size: 14px; +} + +.current-time { + color: var(--accent-color, #007acc); +} + +.separator { + color: var(--text-muted, #666666); +} + +.total-time { + color: var(--text-muted, #888888); +} + +.timeline-track { + position: relative; + height: 20px; + background: var(--track-background, #3c3c3c); + border-radius: 3px; + overflow: hidden; +} + +.timeline-slider { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + background: transparent; + cursor: pointer; + z-index: 2; + -webkit-appearance: none; + appearance: none; +} + +.timeline-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 20px; + background: var(--accent-color, #007acc); + border-radius: 2px; + cursor: grab; +} + +.timeline-slider::-webkit-slider-thumb:active { + cursor: grabbing; +} + +.timeline-slider::-moz-range-thumb { + width: 12px; + height: 20px; + background: var(--accent-color, #007acc); + border: none; + border-radius: 2px; + cursor: grab; +} + +.timeline-progress { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--progress-color, rgba(0, 122, 204, 0.3)); + pointer-events: none; + z-index: 1; +} + +/* Playback controls | 播放控制 */ +.playback-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.control-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--button-background, #3c3c3c); + border: 1px solid var(--border-color, #5c5c5c); + border-radius: 4px; + color: var(--text-color, #cccccc); + cursor: pointer; + transition: all 0.15s ease; +} + +.control-button:hover { + background: var(--button-hover, #4c4c4c); + border-color: var(--border-hover, #007acc); +} + +.control-button:active { + background: var(--button-active, #2c2c2c); +} + +.control-button.primary { + width: 40px; + height: 40px; + background: var(--accent-color, #007acc); + border-color: var(--accent-color, #007acc); + color: white; +} + +.control-button.primary:hover { + background: var(--accent-hover, #1e8ad2); + border-color: var(--accent-hover, #1e8ad2); +} + +/* Playback options | 播放选项 */ +.playback-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.option-row { + display: flex; + align-items: center; + gap: 6px; +} + +.option-row label { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-muted, #888888); + cursor: pointer; + font-size: 11px; +} + +.option-row select { + padding: 2px 6px; + background: var(--input-background, #3c3c3c); + border: 1px solid var(--border-color, #5c5c5c); + border-radius: 3px; + color: var(--text-color, #cccccc); + font-size: 11px; +} + +.option-row input[type="checkbox"] { + cursor: pointer; + margin: 0; +} + +/* No animations | 无动画 */ +.no-animations { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + color: var(--text-muted, #666666); + text-align: center; + font-style: italic; +} + +/* Model info | 模型信息 */ +.model-info { + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted, #888888); + margin-bottom: 6px; +} + +.model-info .info-row { + color: var(--text-muted, #888888); + font-size: 11px; +} + +/* Skeleton info | 骨骼信息 */ +.skeleton-info { + padding: 12px; + border-top: 1px solid var(--border-color, #3c3c3c); +} + +.skeleton-info .info-row { + color: var(--text-muted, #888888); +} diff --git a/packages/mesh-3d-editor/tsconfig.build.json b/packages/mesh-3d-editor/tsconfig.build.json new file mode 100644 index 00000000..29e209e8 --- /dev/null +++ b/packages/mesh-3d-editor/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/mesh-3d-editor/tsconfig.json b/packages/mesh-3d-editor/tsconfig.json new file mode 100644 index 00000000..67635b9e --- /dev/null +++ b/packages/mesh-3d-editor/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../core" }, + { "path": "../engine-core" }, + { "path": "../editor-core" }, + { "path": "../mesh-3d" } + ] +} diff --git a/packages/mesh-3d-editor/tsup.config.ts b/packages/mesh-3d-editor/tsup.config.ts new file mode 100644 index 00000000..b4f49f5d --- /dev/null +++ b/packages/mesh-3d-editor/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup'; +import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup'; + +export default defineConfig({ + ...editorOnlyPreset(), + tsconfig: 'tsconfig.build.json' +}); diff --git a/packages/mesh-3d/package.json b/packages/mesh-3d/package.json new file mode 100644 index 00000000..53b12828 --- /dev/null +++ b/packages/mesh-3d/package.json @@ -0,0 +1,49 @@ +{ + "name": "@esengine/mesh-3d", + "version": "1.0.0", + "description": "ECS-based 3D mesh rendering system with GLTF support", + "esengine": { + "plugin": true, + "pluginExport": "Mesh3DPlugin", + "category": "rendering", + "isEnginePlugin": true + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "build:watch": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rimraf dist" + }, + "devDependencies": { + "@esengine/ecs-framework": "workspace:*", + "@esengine/asset-system": "workspace:*", + "@esengine/engine-core": "workspace:*", + "@esengine/ecs-engine-bindgen": "workspace:*", + "@esengine/build-config": "workspace:*", + "rimraf": "^5.0.5", + "tsup": "^8.0.0", + "typescript": "^5.3.3" + }, + "keywords": [ + "ecs", + "mesh", + "3d", + "gltf", + "webgl" + ], + "author": "yhh", + "license": "MIT" +} diff --git a/packages/mesh-3d/src/Animation3DComponent.ts b/packages/mesh-3d/src/Animation3DComponent.ts new file mode 100644 index 00000000..d1548b34 --- /dev/null +++ b/packages/mesh-3d/src/Animation3DComponent.ts @@ -0,0 +1,341 @@ +/** + * Animation3DComponent - 3D animation playback component. + * Animation3DComponent - 3D 动画播放组件。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import type { IGLTFAnimationClip } from '@esengine/asset-system'; + +/** + * Animation play state. + * 动画播放状态。 + */ +export enum AnimationPlayState { + /** Stopped - not playing. | 停止 - 未播放。 */ + Stopped = 'stopped', + /** Playing forward. | 正向播放。 */ + Playing = 'playing', + /** Paused. | 暂停。 */ + Paused = 'paused' +} + +/** + * Animation wrap mode. + * 动画循环模式。 + */ +export enum AnimationWrapMode { + /** Play once and stop. | 播放一次后停止。 */ + Once = 'once', + /** Loop continuously. | 连续循环。 */ + Loop = 'loop', + /** Play forward then backward (ping-pong). | 往返播放。 */ + PingPong = 'pingpong', + /** Clamp to last frame. | 停在最后一帧。 */ + ClampForever = 'clampForever' +} + +/** + * 3D Animation component for playing skeletal/node animations. + * 用于播放骨骼/节点动画的 3D 动画组件。 + * + * Requires MeshComponent for animation data source. + * 需要 MeshComponent 作为动画数据来源。 + */ +@ECSComponent('Animation3D', { requires: ['Mesh'] }) +@Serializable({ version: 1, typeId: 'Animation3D' }) +export class Animation3DComponent extends Component { + /** + * 默认动画片段名称 + * Default animation clip name + */ + @Serialize() + @Property({ type: 'string', label: 'Default Clip' }) + public defaultClip: string = ''; + + /** + * 播放速度(1.0 = 正常速度) + * Playback speed (1.0 = normal speed) + */ + @Serialize() + @Property({ type: 'number', label: 'Speed', min: 0, max: 10 }) + public speed: number = 1.0; + + /** + * 循环模式 + * Wrap mode + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Wrap Mode', + options: ['once', 'loop', 'pingpong', 'clampForever'] + }) + public wrapMode: AnimationWrapMode = AnimationWrapMode.Loop; + + /** + * 是否启动时自动播放 + * Whether to auto-play on start + */ + @Serialize() + @Property({ type: 'boolean', label: 'Play On Awake' }) + public playOnAwake: boolean = true; + + // ===== Runtime State | 运行时状态 ===== + + /** + * 当前播放状态 + * Current play state + */ + private _playState: AnimationPlayState = AnimationPlayState.Stopped; + + /** + * 当前播放的动画片段 + * Currently playing animation clip + */ + private _currentClip: IGLTFAnimationClip | null = null; + + /** + * 当前播放时间(秒) + * Current playback time (seconds) + */ + private _currentTime: number = 0; + + /** + * 播放方向(1 = 正向,-1 = 反向) + * Playback direction (1 = forward, -1 = backward) + */ + private _direction: number = 1; + + /** + * 可用的动画片段列表 + * Available animation clips + */ + private _clips: IGLTFAnimationClip[] = []; + + /** + * 当前片段名称到索引的映射 + * Map of clip name to index + */ + private _clipNameToIndex: Map = new Map(); + + // ===== Public Getters | 公共获取器 ===== + + /** + * 获取当前播放状态 + * Get current play state + */ + public get playState(): AnimationPlayState { + return this._playState; + } + + /** + * 获取当前播放的动画片段 + * Get currently playing clip + */ + public get currentClip(): IGLTFAnimationClip | null { + return this._currentClip; + } + + /** + * 获取当前播放时间 + * Get current playback time + */ + public get currentTime(): number { + return this._currentTime; + } + + /** + * 获取当前片段的持续时间 + * Get duration of current clip + */ + public get duration(): number { + return this._currentClip?.duration ?? 0; + } + + /** + * 获取归一化时间(0-1) + * Get normalized time (0-1) + */ + public get normalizedTime(): number { + if (!this._currentClip || this._currentClip.duration <= 0) return 0; + return this._currentTime / this._currentClip.duration; + } + + /** + * 是否正在播放 + * Whether playing + */ + public get isPlaying(): boolean { + return this._playState === AnimationPlayState.Playing; + } + + /** + * 获取所有可用的动画片段 + * Get all available clips + */ + public get clips(): readonly IGLTFAnimationClip[] { + return this._clips; + } + + /** + * 获取所有动画片段名称 + * Get all clip names + */ + public get clipNames(): string[] { + return this._clips.map(c => c.name); + } + + // ===== Public Methods | 公共方法 ===== + + /** + * 设置动画片段列表(由 Animation3DSystem 调用) + * Set animation clips (called by Animation3DSystem) + */ + public setClips(clips: IGLTFAnimationClip[]): void { + this._clips = clips; + this._clipNameToIndex.clear(); + clips.forEach((clip, index) => { + this._clipNameToIndex.set(clip.name, index); + }); + } + + /** + * 播放动画 + * Play animation + * + * @param clipName - 动画片段名称,不指定则播放当前/默认片段 + */ + public play(clipName?: string): void { + const name = clipName ?? this.defaultClip ?? (this._clips[0]?.name ?? ''); + + if (!name) { + console.warn('[Animation3DComponent] No clip to play'); + return; + } + + const index = this._clipNameToIndex.get(name); + if (index === undefined) { + console.warn(`[Animation3DComponent] Clip not found: ${name}`); + return; + } + + this._currentClip = this._clips[index]; + this._currentTime = 0; + this._direction = 1; + this._playState = AnimationPlayState.Playing; + } + + /** + * 停止动画 + * Stop animation + */ + public stop(): void { + this._playState = AnimationPlayState.Stopped; + this._currentTime = 0; + } + + /** + * 暂停动画 + * Pause animation + */ + public pause(): void { + if (this._playState === AnimationPlayState.Playing) { + this._playState = AnimationPlayState.Paused; + } + } + + /** + * 恢复播放 + * Resume playback + */ + public resume(): void { + if (this._playState === AnimationPlayState.Paused) { + this._playState = AnimationPlayState.Playing; + } + } + + /** + * 设置播放时间 + * Set playback time + */ + public setTime(time: number): void { + this._currentTime = Math.max(0, Math.min(time, this.duration)); + } + + /** + * 设置归一化时间 + * Set normalized time + */ + public setNormalizedTime(t: number): void { + this._currentTime = Math.max(0, Math.min(t, 1)) * this.duration; + } + + /** + * 更新播放时间(由 Animation3DSystem 调用) + * Update playback time (called by Animation3DSystem) + * + * @param deltaTime - 时间增量(秒) + */ + public updateTime(deltaTime: number): void { + if (this._playState !== AnimationPlayState.Playing || !this._currentClip) { + return; + } + + const scaledDelta = deltaTime * this.speed * this._direction; + this._currentTime += scaledDelta; + + const duration = this._currentClip.duration; + if (duration <= 0) return; + + // Handle wrap mode + // 处理循环模式 + switch (this.wrapMode) { + case AnimationWrapMode.Once: + if (this._currentTime >= duration || this._currentTime < 0) { + this._currentTime = Math.max(0, Math.min(this._currentTime, duration)); + this._playState = AnimationPlayState.Stopped; + } + break; + + case AnimationWrapMode.Loop: + while (this._currentTime >= duration) { + this._currentTime -= duration; + } + while (this._currentTime < 0) { + this._currentTime += duration; + } + break; + + case AnimationWrapMode.PingPong: + if (this._currentTime >= duration) { + this._currentTime = duration - (this._currentTime - duration); + this._direction = -1; + } else if (this._currentTime < 0) { + this._currentTime = -this._currentTime; + this._direction = 1; + } + break; + + case AnimationWrapMode.ClampForever: + this._currentTime = Math.max(0, Math.min(this._currentTime, duration)); + break; + } + } + + /** + * 重置组件 + * Reset component + */ + reset(): void { + this.defaultClip = ''; + this.speed = 1.0; + this.wrapMode = AnimationWrapMode.Loop; + this.playOnAwake = true; + this._playState = AnimationPlayState.Stopped; + this._currentClip = null; + this._currentTime = 0; + this._direction = 1; + this._clips = []; + this._clipNameToIndex.clear(); + } +} diff --git a/packages/mesh-3d/src/Mesh3DRuntimeModule.ts b/packages/mesh-3d/src/Mesh3DRuntimeModule.ts new file mode 100644 index 00000000..13e71a16 --- /dev/null +++ b/packages/mesh-3d/src/Mesh3DRuntimeModule.ts @@ -0,0 +1,115 @@ +/** + * Mesh3D Runtime Module - Plugin for 3D mesh rendering. + * Mesh3D 运行时模块 - 3D 网格渲染插件。 + */ + +import type { IComponentRegistry, IScene } from '@esengine/ecs-framework'; +import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; +import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen'; +import { AssetManagerToken } from '@esengine/asset-system'; +import { MeshComponent } from './MeshComponent'; +import { Animation3DComponent } from './Animation3DComponent'; +import { SkeletonComponent } from './SkeletonComponent'; +import { MeshRenderSystem } from './systems/MeshRenderSystem'; +import { MeshAssetLoaderSystem } from './systems/MeshAssetLoaderSystem'; +import { Animation3DSystem } from './systems/Animation3DSystem'; +import { SkeletonBakingSystem } from './systems/SkeletonBakingSystem'; +import { MeshRenderSystemToken } from './tokens'; + +export type { SystemContext, ModuleManifest, IRuntimeModule, IRuntimePlugin }; + +// Re-export tokens +// 重新导出令牌 +export { MeshRenderSystemToken } from './tokens'; + +/** + * Runtime module for 3D mesh rendering. + * 3D 网格渲染的运行时模块。 + */ +class Mesh3DRuntimeModule implements IRuntimeModule { + registerComponents(registry: IComponentRegistry): void { + registry.register(MeshComponent); + registry.register(Animation3DComponent); + registry.register(SkeletonComponent); + } + + createSystems(scene: IScene, context: SystemContext): void { + // Get engine bridge from services + // 从服务获取引擎桥接 + const bridge = context.services.get(EngineBridgeToken) ?? null; + if (!bridge) { + console.warn('[Mesh3D] EngineBridge not found, MeshRenderSystem will be disabled'); + } + + // Get asset manager + // 获取资产管理器 + const assetManager = context.services.get(AssetManagerToken); + + // Create asset loader system + // 创建资产加载器系统 + const loaderSystem = new MeshAssetLoaderSystem(); + if (assetManager) { + loaderSystem.setAssetManager(assetManager); + } else { + console.warn('[Mesh3D] AssetManager not found, mesh loading will be disabled'); + } + scene.addSystem(loaderSystem); + + // Create animation system (runs before rendering to update bone transforms) + // 创建动画系统(在渲染前运行以更新骨骼变换) + const animationSystem = new Animation3DSystem(); + scene.addSystem(animationSystem); + + // Create skeleton baking system (computes final bone matrices) + // 创建骨骼烘焙系统(计算最终骨骼矩阵) + const skeletonSystem = new SkeletonBakingSystem(); + scene.addSystem(skeletonSystem); + + // Create render system with bridge + // 使用桥接创建渲染系统 + const renderSystem = new MeshRenderSystem(bridge); + + // Add to scene + // 添加到场景 + scene.addSystem(renderSystem); + + // Register service + // 注册服务 + context.services.register(MeshRenderSystemToken, renderSystem); + } +} + +/** + * Module manifest. + * 模块清单。 + */ +const manifest: ModuleManifest = { + id: 'mesh-3d', + name: '@esengine/mesh-3d', + displayName: 'Mesh 3D', + version: '1.0.0', + description: '3D mesh rendering with GLTF/GLB support', + category: 'Rendering', + icon: 'Box', + isCore: false, + defaultEnabled: true, + isEngineModule: true, + canContainContent: true, + dependencies: ['core', 'math', 'asset-system'], + exports: { + components: ['MeshComponent', 'Animation3DComponent', 'SkeletonComponent'] + }, + editorPackage: '@esengine/mesh-3d-editor', + requiresWasm: true +}; + +/** + * Mesh3D Plugin export. + * Mesh3D 插件导出。 + */ +export const Mesh3DPlugin: IRuntimePlugin = { + manifest, + runtimeModule: new Mesh3DRuntimeModule() +}; + +export { Mesh3DRuntimeModule }; diff --git a/packages/mesh-3d/src/MeshComponent.ts b/packages/mesh-3d/src/MeshComponent.ts new file mode 100644 index 00000000..d554ac4a --- /dev/null +++ b/packages/mesh-3d/src/MeshComponent.ts @@ -0,0 +1,151 @@ +/** + * MeshComponent - 3D mesh rendering component. + * MeshComponent - 3D 网格渲染组件。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { SortingLayers, type ISortable } from '@esengine/engine-core'; +import type { IGLTFAsset, IMeshData } from '@esengine/asset-system'; + +/** + * 3D Mesh component for rendering GLTF models. + * 用于渲染 GLTF 模型的 3D 网格组件。 + * + * Requires TransformComponent for positioning and MeshRenderSystem for rendering. + * 需要 TransformComponent 进行定位,MeshRenderSystem 进行渲染。 + */ +@ECSComponent('Mesh', { requires: ['Transform'] }) +@Serializable({ version: 1, typeId: 'Mesh' }) +export class MeshComponent extends Component implements ISortable { + /** + * 模型资产 GUID + * Model asset GUID + * + * Stores the unique identifier of the GLTF/GLB/OBJ/FBX model asset. + * 存储 GLTF/GLB/OBJ/FBX 模型资产的唯一标识符。 + */ + @Serialize() + @Property({ type: 'asset', label: 'Model', assetType: 'any', extensions: ['.gltf', '.glb', '.obj', '.fbx'] }) + public modelGuid: string = ''; + + /** + * 运行时网格数据(从资产加载) + * Runtime mesh data (loaded from asset) + */ + public meshAsset: IGLTFAsset | null = null; + + /** + * 当前活动的网格索引(用于多网格模型) + * Active mesh index (for multi-mesh models) + */ + @Serialize() + @Property({ type: 'integer', label: 'Mesh Index', min: 0 }) + public meshIndex: number = 0; + + /** + * 是否投射阴影 + * Whether to cast shadows + */ + @Serialize() + @Property({ type: 'boolean', label: 'Cast Shadows' }) + public castShadows: boolean = true; + + /** + * 是否接收阴影 + * Whether to receive shadows + */ + @Serialize() + @Property({ type: 'boolean', label: 'Receive Shadows' }) + public receiveShadows: boolean = true; + + /** + * 可见性 + * Visibility + */ + @Serialize() + @Property({ type: 'boolean', label: 'Visible' }) + public visible: boolean = true; + + /** + * 排序层(用于透明物体排序) + * Sorting layer (for transparent object sorting) + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Sorting Layer', + options: ['Background', 'Default', 'Foreground', 'WorldOverlay', 'UI', 'ScreenOverlay', 'Modal'] + }) + public sortingLayer: string = SortingLayers.Default; + + /** + * 层内排序顺序 + * Order in layer + */ + @Serialize() + @Property({ type: 'integer', label: 'Order In Layer' }) + public orderInLayer: number = 0; + + /** + * 材质覆盖 GUID 列表(可选) + * Material override GUIDs (optional) + */ + @Serialize() + public materialOverrides: string[] = []; + + /** + * 运行时材质 ID 列表 + * Runtime material IDs + */ + public runtimeMaterialIds: number[] = []; + + /** + * 运行时纹理 ID 列表 + * Runtime texture IDs + */ + public runtimeTextureIds: number[] = []; + + /** + * 资产是否已加载 + * Whether asset is loaded + */ + public get isLoaded(): boolean { + return this.meshAsset !== null; + } + + /** + * 获取当前网格数据 + * Get current mesh data + */ + public get currentMesh(): IMeshData | null { + if (!this.meshAsset || !this.meshAsset.meshes.length) return null; + const index = Math.min(this.meshIndex, this.meshAsset.meshes.length - 1); + return this.meshAsset.meshes[index]; + } + + /** + * 获取所有网格数据 + * Get all mesh data + */ + public get allMeshes(): IMeshData[] { + return this.meshAsset?.meshes ?? []; + } + + /** + * 重置组件 + * Reset component + */ + reset(): void { + this.modelGuid = ''; + this.meshAsset = null; + this.meshIndex = 0; + this.castShadows = true; + this.receiveShadows = true; + this.visible = true; + this.sortingLayer = 'Default'; + this.orderInLayer = 0; + this.materialOverrides = []; + this.runtimeMaterialIds = []; + this.runtimeTextureIds = []; + } +} diff --git a/packages/mesh-3d/src/SkeletonComponent.ts b/packages/mesh-3d/src/SkeletonComponent.ts new file mode 100644 index 00000000..3e9a9522 --- /dev/null +++ b/packages/mesh-3d/src/SkeletonComponent.ts @@ -0,0 +1,279 @@ +/** + * SkeletonComponent - 3D skeleton data component for skinned meshes. + * SkeletonComponent - 用于蒙皮网格的 3D 骨骼数据组件。 + */ + +import { Component, ECSComponent, Serializable } from '@esengine/ecs-framework'; +import type { ISkeletonData, ISkeletonJoint } from '@esengine/asset-system'; + +/** + * Local transform for a bone/joint. + * 骨骼/关节的局部变换。 + */ +export interface BoneTransform { + /** Position XYZ. | 位置 XYZ。 */ + position: [number, number, number]; + /** Rotation quaternion XYZW. | 旋转四元数 XYZW。 */ + rotation: [number, number, number, number]; + /** Scale XYZ. | 缩放 XYZ。 */ + scale: [number, number, number]; +} + +/** + * 3D Skeleton component for skeletal animation. + * 用于骨骼动画的 3D 骨骼组件。 + * + * Requires MeshComponent for skeleton data source. + * 需要 MeshComponent 作为骨骼数据来源。 + */ +@ECSComponent('Skeleton', { requires: ['Mesh', 'Animation3D'] }) +@Serializable({ version: 1, typeId: 'Skeleton' }) +export class SkeletonComponent extends Component { + // ===== Runtime Data | 运行时数据 ===== + + /** + * 骨骼数据(从 MeshAsset 加载) + * Skeleton data (loaded from MeshAsset) + */ + private _skeletonData: ISkeletonData | null = null; + + /** + * 烘烤的骨骼矩阵(输出给渲染器) + * Baked bone matrices (output for renderer) + * + * Each matrix is a 4x4 column-major matrix (16 floats). + * 每个矩阵是 4x4 列优先矩阵(16 个浮点数)。 + */ + private _boneMatrices: Float32Array = new Float32Array(0); + + /** + * 当前帧的骨骼局部变换 + * Current frame's bone local transforms + */ + private _boneTransforms: BoneTransform[] = []; + + /** + * 骨骼世界变换矩阵缓存 + * Bone world transform matrix cache + */ + private _worldMatrices: Float32Array = new Float32Array(0); + + /** + * 是否需要更新骨骼矩阵 + * Whether bone matrices need update + */ + private _dirty: boolean = true; + + // ===== Public Getters | 公共获取器 ===== + + /** + * 获取骨骼数据 + * Get skeleton data + */ + public get skeletonData(): ISkeletonData | null { + return this._skeletonData; + } + + /** + * 获取关节数量 + * Get joint count + */ + public get jointCount(): number { + return this._skeletonData?.joints.length ?? 0; + } + + /** + * 获取烘烤的骨骼矩阵 + * Get baked bone matrices + */ + public get boneMatrices(): Float32Array { + return this._boneMatrices; + } + + /** + * 获取骨骼局部变换 + * Get bone local transforms + */ + public get boneTransforms(): readonly BoneTransform[] { + return this._boneTransforms; + } + + /** + * 骨骼是否已加载 + * Whether skeleton is loaded + */ + public get isLoaded(): boolean { + return this._skeletonData !== null && this._skeletonData.joints.length > 0; + } + + /** + * 获取关节列表 + * Get joint list + */ + public get joints(): readonly ISkeletonJoint[] { + return this._skeletonData?.joints ?? []; + } + + // ===== Public Methods | 公共方法 ===== + + /** + * 设置骨骼数据(由系统调用) + * Set skeleton data (called by system) + */ + public setSkeletonData(data: ISkeletonData): void { + this._skeletonData = data; + + const jointCount = data.joints.length; + + // Initialize bone matrices (each joint has a 4x4 matrix = 16 floats) + // 初始化骨骼矩阵(每个关节有 4x4 矩阵 = 16 个浮点数) + this._boneMatrices = new Float32Array(jointCount * 16); + this._worldMatrices = new Float32Array(jointCount * 16); + + // Initialize bone transforms with identity + // 用单位变换初始化骨骼变换 + this._boneTransforms = []; + for (let i = 0; i < jointCount; i++) { + this._boneTransforms.push({ + position: [0, 0, 0], + rotation: [0, 0, 0, 1], // Identity quaternion + scale: [1, 1, 1] + }); + } + + // Initialize bone matrices to identity + // 将骨骼矩阵初始化为单位矩阵 + for (let i = 0; i < jointCount; i++) { + this.setIdentityMatrix(this._boneMatrices, i * 16); + this.setIdentityMatrix(this._worldMatrices, i * 16); + } + + this._dirty = true; + } + + /** + * 设置指定骨骼的局部变换 + * Set local transform for a bone + */ + public setBoneTransform(jointIndex: number, transform: Partial): void { + if (jointIndex < 0 || jointIndex >= this._boneTransforms.length) { + return; + } + + const bone = this._boneTransforms[jointIndex]; + if (transform.position) { + bone.position = [...transform.position]; + } + if (transform.rotation) { + bone.rotation = [...transform.rotation]; + } + if (transform.scale) { + bone.scale = [...transform.scale]; + } + + this._dirty = true; + } + + /** + * 标记骨骼矩阵需要更新 + * Mark bone matrices as dirty + */ + public markDirty(): void { + this._dirty = true; + } + + /** + * 检查是否需要更新 + * Check if update is needed + */ + public isDirty(): boolean { + return this._dirty; + } + + /** + * 清除脏标记(由系统在更新后调用) + * Clear dirty flag (called by system after update) + */ + public clearDirty(): void { + this._dirty = false; + } + + /** + * 获取指定骨骼的世界矩阵 + * Get world matrix for a bone + */ + public getWorldMatrix(jointIndex: number): Float32Array | null { + if (jointIndex < 0 || jointIndex >= this.jointCount) { + return null; + } + return this._worldMatrices.subarray(jointIndex * 16, (jointIndex + 1) * 16); + } + + /** + * 设置指定骨骼的世界矩阵(由 SkeletonBakingSystem 调用) + * Set world matrix for a bone (called by SkeletonBakingSystem) + */ + public setWorldMatrix(jointIndex: number, matrix: Float32Array): void { + if (jointIndex < 0 || jointIndex >= this.jointCount) { + return; + } + const offset = jointIndex * 16; + for (let i = 0; i < 16; i++) { + this._worldMatrices[offset + i] = matrix[i]; + } + } + + /** + * 设置指定骨骼的最终矩阵(由 SkeletonBakingSystem 调用) + * Set final matrix for a bone (called by SkeletonBakingSystem) + */ + public setFinalMatrix(jointIndex: number, matrix: Float32Array): void { + if (jointIndex < 0 || jointIndex >= this.jointCount) { + return; + } + const offset = jointIndex * 16; + for (let i = 0; i < 16; i++) { + this._boneMatrices[offset + i] = matrix[i]; + } + } + + /** + * 按名称查找骨骼索引 + * Find bone index by name + */ + public findBoneIndex(name: string): number { + if (!this._skeletonData) return -1; + + for (let i = 0; i < this._skeletonData.joints.length; i++) { + if (this._skeletonData.joints[i].name === name) { + return i; + } + } + return -1; + } + + /** + * 重置组件 + * Reset component + */ + reset(): void { + this._skeletonData = null; + this._boneMatrices = new Float32Array(0); + this._worldMatrices = new Float32Array(0); + this._boneTransforms = []; + this._dirty = true; + } + + // ===== Private Methods | 私有方法 ===== + + /** + * Set identity matrix at offset in array. + * 在数组的偏移位置设置单位矩阵。 + */ + private setIdentityMatrix(arr: Float32Array, offset: number): void { + arr[offset] = 1; arr[offset + 1] = 0; arr[offset + 2] = 0; arr[offset + 3] = 0; + arr[offset + 4] = 0; arr[offset + 5] = 1; arr[offset + 6] = 0; arr[offset + 7] = 0; + arr[offset + 8] = 0; arr[offset + 9] = 0; arr[offset + 10] = 1; arr[offset + 11] = 0; + arr[offset + 12] = 0; arr[offset + 13] = 0; arr[offset + 14] = 0; arr[offset + 15] = 1; + } +} diff --git a/packages/mesh-3d/src/animation/AnimationEvaluator.ts b/packages/mesh-3d/src/animation/AnimationEvaluator.ts new file mode 100644 index 00000000..2c717a38 --- /dev/null +++ b/packages/mesh-3d/src/animation/AnimationEvaluator.ts @@ -0,0 +1,275 @@ +/** + * AnimationEvaluator - Utility for evaluating animation clips. + * AnimationEvaluator - 动画片段评估工具。 + */ + +import type { IGLTFAnimationClip, IAnimationSampler, IAnimationChannel } from '@esengine/asset-system'; + +/** + * Animation channel target path types. + * 动画通道目标路径类型。 + */ +export type AnimationTargetPath = 'translation' | 'rotation' | 'scale' | 'weights'; + +/** + * Evaluated animation value. + * 评估后的动画值。 + */ +export interface EvaluatedValue { + /** Target path (translation, rotation, scale, weights). | 目标路径。 */ + path: AnimationTargetPath; + /** Evaluated value (vec3, quat, or morph weights). | 评估后的值。 */ + value: number[]; +} + +/** + * Animation clip evaluator. + * 动画片段评估器。 + * + * Samples animation channels at a given time and returns interpolated values. + * 在给定时间采样动画通道并返回插值后的值。 + */ +export class AnimationEvaluator { + /** + * Evaluate animation clip at a given time. + * 在给定时间评估动画片段。 + * + * @param clip - Animation clip to evaluate. | 要评估的动画片段。 + * @param time - Time in seconds. | 时间(秒)。 + * @returns Map of node index to evaluated values. | 节点索引到评估值的映射。 + */ + public evaluate(clip: IGLTFAnimationClip, time: number): Map { + const result = new Map(); + + // Clamp time to clip duration + // 将时间限制在片段持续时间内 + const sampleTime = Math.max(0, Math.min(time, clip.duration)); + + for (const channel of clip.channels) { + const sampler = clip.samplers[channel.samplerIndex]; + if (!sampler) continue; + + const value = this.sampleChannel(sampler, channel.target.path, sampleTime); + if (value) { + result.set(channel.target.nodeIndex, { + path: channel.target.path, + value + }); + } + } + + return result; + } + + /** + * Sample a single animation channel. + * 采样单个动画通道。 + */ + private sampleChannel( + sampler: IAnimationSampler, + path: AnimationTargetPath, + time: number + ): number[] | null { + const { input, output, interpolation } = sampler; + + if (!input || !output || input.length === 0) { + return null; + } + + // Find keyframe index + // 查找关键帧索引 + const frameIndex = this.findKeyframe(input, time); + + // Components per value + // 每个值的分量数 + // rotation = 4 (quaternion), translation/scale = 3 (vec3), weights = variable + // 旋转 = 4(四元数),平移/缩放 = 3(vec3),权重 = 可变 + let componentCount: number; + if (path === 'rotation') { + componentCount = 4; + } else if (path === 'weights') { + // For morph targets, infer from output length / input length + // 对于变形目标,从输出长度 / 输入长度推断 + componentCount = input.length > 0 ? Math.floor(output.length / input.length) : 1; + } else { + componentCount = 3; + } + + // Handle edge cases + // 处理边界情况 + if (frameIndex <= 0) { + return this.getOutputValue(output, 0, componentCount); + } + + if (frameIndex >= input.length) { + return this.getOutputValue(output, input.length - 1, componentCount); + } + + // Get surrounding keyframes + // 获取周围的关键帧 + const prevIndex = frameIndex - 1; + const nextIndex = frameIndex; + const prevTime = input[prevIndex]; + const nextTime = input[nextIndex]; + + // Calculate interpolation factor + // 计算插值因子 + const duration = nextTime - prevTime; + const t = duration > 0 ? (time - prevTime) / duration : 0; + + // Get values + // 获取值 + const prevValue = this.getOutputValue(output, prevIndex, componentCount); + const nextValue = this.getOutputValue(output, nextIndex, componentCount); + + if (!prevValue || !nextValue) { + return null; + } + + // Interpolate + // 插值 + switch (interpolation) { + case 'STEP': + return prevValue; + + case 'LINEAR': + if (path === 'rotation') { + return this.slerp(prevValue, nextValue, t); + } else { + // translation, scale, weights all use linear interpolation + // 平移、缩放、权重都使用线性插值 + return this.lerp(prevValue, nextValue, t); + } + + case 'CUBICSPLINE': + // For cubicspline, output has 3 values per keyframe: in-tangent, value, out-tangent + // 对于三次样条,输出每个关键帧有 3 个值:入切线、值、出切线 + // Simplified: just use linear for now + // 简化:暂时只使用线性 + if (path === 'rotation') { + return this.slerp(prevValue, nextValue, t); + } else { + return this.lerp(prevValue, nextValue, t); + } + + default: + return prevValue; + } + } + + /** + * Find keyframe index for given time using binary search. + * 使用二分查找为给定时间查找关键帧索引。 + * + * Returns the index of the first keyframe with time > input time. + * 返回第一个时间 > 输入时间的关键帧索引。 + */ + private findKeyframe(input: Float32Array, time: number): number { + let low = 0; + let high = input.length; + + while (low < high) { + const mid = (low + high) >>> 1; + if (input[mid] <= time) { + low = mid + 1; + } else { + high = mid; + } + } + + return low; + } + + /** + * Get output value at keyframe index. + * 获取关键帧索引处的输出值。 + */ + private getOutputValue(output: Float32Array, index: number, componentCount: number): number[] { + const offset = index * componentCount; + const result: number[] = []; + + for (let i = 0; i < componentCount; i++) { + result.push(output[offset + i] ?? 0); + } + + return result; + } + + /** + * Linear interpolation for vec3. + * vec3 的线性插值。 + */ + private lerp(a: number[], b: number[], t: number): number[] { + const result: number[] = []; + for (let i = 0; i < a.length; i++) { + result.push(a[i] + (b[i] - a[i]) * t); + } + return result; + } + + /** + * Spherical linear interpolation for quaternion. + * 四元数的球面线性插值。 + */ + private slerp(a: number[], b: number[], t: number): number[] { + // Normalize quaternions + // 归一化四元数 + const ax = a[0], ay = a[1], az = a[2], aw = a[3]; + let bx = b[0], by = b[1], bz = b[2], bw = b[3]; + + // Calculate angle between quaternions + // 计算四元数之间的角度 + let dot = ax * bx + ay * by + az * bz + aw * bw; + + // Negate b if dot product is negative (to take shorter path) + // 如果点积为负则取反 b(取较短路径) + if (dot < 0) { + bx = -bx; + by = -by; + bz = -bz; + bw = -bw; + dot = -dot; + } + + // If very close, use linear interpolation + // 如果非常接近,使用线性插值 + if (dot > 0.9995) { + return this.normalizeQuat([ + ax + (bx - ax) * t, + ay + (by - ay) * t, + az + (bz - az) * t, + aw + (bw - aw) * t + ]); + } + + // Calculate slerp + // 计算球面线性插值 + const theta0 = Math.acos(dot); + const theta = theta0 * t; + const sinTheta = Math.sin(theta); + const sinTheta0 = Math.sin(theta0); + + const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0; + const s1 = sinTheta / sinTheta0; + + return [ + ax * s0 + bx * s1, + ay * s0 + by * s1, + az * s0 + bz * s1, + aw * s0 + bw * s1 + ]; + } + + /** + * Normalize quaternion. + * 归一化四元数。 + */ + private normalizeQuat(q: number[]): number[] { + const len = Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + if (len === 0) { + return [0, 0, 0, 1]; + } + const inv = 1 / len; + return [q[0] * inv, q[1] * inv, q[2] * inv, q[3] * inv]; + } +} diff --git a/packages/mesh-3d/src/index.ts b/packages/mesh-3d/src/index.ts new file mode 100644 index 00000000..84ff4673 --- /dev/null +++ b/packages/mesh-3d/src/index.ts @@ -0,0 +1,39 @@ +/** + * @esengine/mesh-3d - 3D Mesh Rendering Module + * 3D 网格渲染模块 + * + * Provides components and systems for rendering GLTF/GLB 3D models. + * 提供用于渲染 GLTF/GLB 3D 模型的组件和系统。 + */ + +// Components +// 组件 +export { MeshComponent } from './MeshComponent'; +export { Animation3DComponent, AnimationPlayState, AnimationWrapMode } from './Animation3DComponent'; +export { SkeletonComponent, type BoneTransform } from './SkeletonComponent'; + +// Systems +// 系统 +export { MeshRenderSystem } from './systems/MeshRenderSystem'; +export { MeshAssetLoaderSystem } from './systems/MeshAssetLoaderSystem'; +export { Animation3DSystem } from './systems/Animation3DSystem'; +export { SkeletonBakingSystem } from './systems/SkeletonBakingSystem'; + +// Animation utilities +// 动画工具 +export { AnimationEvaluator } from './animation/AnimationEvaluator'; + +// Tokens +// 令牌 +export { MeshRenderSystemToken } from './tokens'; + +// Plugin +// 插件 +export { + Mesh3DPlugin, + Mesh3DRuntimeModule, + type SystemContext, + type ModuleManifest, + type IRuntimeModule, + type IRuntimePlugin +} from './Mesh3DRuntimeModule'; diff --git a/packages/mesh-3d/src/systems/Animation3DSystem.ts b/packages/mesh-3d/src/systems/Animation3DSystem.ts new file mode 100644 index 00000000..bc51916b --- /dev/null +++ b/packages/mesh-3d/src/systems/Animation3DSystem.ts @@ -0,0 +1,112 @@ +/** + * Animation3DSystem - System for updating 3D animations. + * Animation3DSystem - 3D 动画更新系统。 + */ + +import { EntitySystem, Matcher, ECSSystem, Entity, Time } from '@esengine/ecs-framework'; +import { Animation3DComponent } from '../Animation3DComponent'; +import { SkeletonComponent } from '../SkeletonComponent'; +import { MeshComponent } from '../MeshComponent'; +import { AnimationEvaluator } from '../animation/AnimationEvaluator'; + +/** + * System for updating 3D animation playback. + * 用于更新 3D 动画播放的系统。 + * + * Queries all entities with Animation3DComponent, + * updates animation time, and applies animation values to skeleton bones. + * 查询所有具有 Animation3DComponent 的实体, + * 更新动画时间,并将动画值应用到骨骼。 + */ +@ECSSystem('Animation3D', { updateOrder: 100 }) +export class Animation3DSystem extends EntitySystem { + private evaluator: AnimationEvaluator; + + constructor() { + super(Matcher.empty().all(Animation3DComponent).all(MeshComponent)); + this.evaluator = new AnimationEvaluator(); + } + + /** + * Process entities each frame. + * 每帧处理实体。 + */ + protected override process(entities: readonly Entity[]): void { + const deltaTime = Time.deltaTime; + + for (const entity of entities) { + if (!entity.enabled) continue; + this.updateEntity(entity, deltaTime); + } + } + + /** + * Update a single entity's animation. + * 更新单个实体的动画。 + */ + private updateEntity(entity: Entity, deltaTime: number): void { + const anim = entity.getComponent(Animation3DComponent); + const mesh = entity.getComponent(MeshComponent); + + if (!anim || !mesh) return; + + // Initialize animation clips from mesh asset if needed + // 如果需要,从网格资产初始化动画片段 + if (anim.clips.length === 0 && mesh.meshAsset?.animations) { + anim.setClips(mesh.meshAsset.animations); + + // Auto-play if configured + // 如果配置了自动播放 + if (anim.playOnAwake && anim.clips.length > 0) { + anim.play(); + } + } + + // Update animation time + // 更新动画时间 + anim.updateTime(deltaTime); + + // Apply animation to skeleton + // 将动画应用到骨骼 + if (anim.isPlaying && anim.currentClip) { + this.applyAnimation(entity, anim); + } + } + + /** + * Apply animation values to skeleton. + * 将动画值应用到骨骼。 + */ + private applyAnimation(entity: Entity, anim: Animation3DComponent): void { + const skeleton = entity.getComponent(SkeletonComponent); + const clip = anim.currentClip; + + if (!clip || !skeleton?.isLoaded) return; + + // Evaluate animation at current time + // 在当前时间评估动画 + const evaluatedValues = this.evaluator.evaluate(clip, anim.currentTime); + + // Apply values to skeleton bones + // 将值应用到骨骼 + for (const [nodeIndex, value] of evaluatedValues) { + if (value.path === 'translation') { + skeleton.setBoneTransform(nodeIndex, { + position: value.value as [number, number, number] + }); + } else if (value.path === 'rotation') { + skeleton.setBoneTransform(nodeIndex, { + rotation: value.value as [number, number, number, number] + }); + } else if (value.path === 'scale') { + skeleton.setBoneTransform(nodeIndex, { + scale: value.value as [number, number, number] + }); + } + } + + // Mark skeleton as dirty for matrix update + // 标记骨骼为脏以更新矩阵 + skeleton.markDirty(); + } +} diff --git a/packages/mesh-3d/src/systems/MeshAssetLoaderSystem.ts b/packages/mesh-3d/src/systems/MeshAssetLoaderSystem.ts new file mode 100644 index 00000000..f3d67628 --- /dev/null +++ b/packages/mesh-3d/src/systems/MeshAssetLoaderSystem.ts @@ -0,0 +1,124 @@ +/** + * MeshAssetLoaderSystem - System for loading mesh assets on demand. + * MeshAssetLoaderSystem - 按需加载网格资产的系统。 + */ + +import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework'; +import type { IAssetManager, IGLTFAsset } from '@esengine/asset-system'; +import { MeshComponent } from '../MeshComponent'; + +/** + * System for loading mesh assets when modelGuid changes. + * 当 modelGuid 变化时加载网格资产的系统。 + * + * This system monitors MeshComponents and loads their model assets + * when the modelGuid property is set and the asset isn't loaded yet. + * 此系统监视 MeshComponent 并在设置 modelGuid 属性且资产尚未加载时加载其模型资产。 + */ +@ECSSystem('MeshAssetLoader', { updateOrder: 50 }) +export class MeshAssetLoaderSystem extends EntitySystem { + private assetManager: IAssetManager | null = null; + private loadingSet: Set = new Set(); + + constructor() { + super(Matcher.empty().all(MeshComponent)); + } + + /** + * Set the asset manager for loading assets. + * 设置用于加载资产的资产管理器。 + */ + public setAssetManager(manager: IAssetManager): void { + this.assetManager = manager; + } + + /** + * Process entities each frame. + * 每帧处理实体。 + */ + protected override process(entities: readonly Entity[]): void { + for (const entity of entities) { + if (!entity.enabled) continue; + this.checkAndLoadAsset(entity); + } + } + + /** + * Check if a mesh component needs its asset loaded. + * 检查网格组件是否需要加载其资产。 + */ + private checkAndLoadAsset(entity: Entity): void { + const mesh = entity.getComponent(MeshComponent); + if (!mesh) return; + + // Skip if no modelGuid + // 如果没有 modelGuid 则跳过 + if (!mesh.modelGuid) return; + + // Skip if already loaded + // 如果已加载则跳过 + if (mesh.isLoaded) return; + + // Skip if already loading + // 如果正在加载则跳过 + const loadKey = `${entity.id}:${mesh.modelGuid}`; + if (this.loadingSet.has(loadKey)) return; + + // Start loading + // 开始加载 + this.loadingSet.add(loadKey); + this.loadMeshAsset(entity, mesh, loadKey); + } + + /** + * Load a mesh asset using the asset manager. + * 使用资产管理器加载网格资产。 + */ + private async loadMeshAsset(entity: Entity, mesh: MeshComponent, loadKey: string): Promise { + try { + if (!this.assetManager) { + console.warn('[MeshAssetLoaderSystem] No asset manager available'); + return; + } + + const modelGuid = mesh.modelGuid; + + // Try to load using asset manager + // 尝试使用资产管理器加载 + console.log(`[MeshAssetLoaderSystem] Loading: ${modelGuid}`); + + // Check if it's a GUID or a path + // 检查是否是 GUID 还是路径 + const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(modelGuid); + + let result; + if (isGuid) { + result = await this.assetManager.loadAsset(modelGuid); + } else { + result = await this.assetManager.loadAssetByPath(modelGuid); + } + + // Check if entity still exists and has the same modelGuid + // 检查实体是否仍然存在且 modelGuid 是否相同 + if (!entity.enabled || mesh.modelGuid !== modelGuid) { + return; + } + + // IAssetLoadResult contains: asset, handle, metadata, loadTime + // API throws on error, returns result directly on success + // IAssetLoadResult 包含:asset, handle, metadata, loadTime + // API 在错误时抛出异常,成功时直接返回结果 + if (result && result.asset) { + mesh.meshAsset = result.asset; + console.log(`[MeshAssetLoaderSystem] Loaded: ${modelGuid} (${result.asset.meshes?.length ?? 0} meshes)`); + } else { + console.warn(`[MeshAssetLoaderSystem] No asset returned for ${modelGuid}`); + } + + } catch (error) { + console.error(`[MeshAssetLoaderSystem] Failed to load ${mesh.modelGuid}:`, error); + } finally { + this.loadingSet.delete(loadKey); + } + } +} diff --git a/packages/mesh-3d/src/systems/MeshRenderSystem.ts b/packages/mesh-3d/src/systems/MeshRenderSystem.ts new file mode 100644 index 00000000..cd70876c --- /dev/null +++ b/packages/mesh-3d/src/systems/MeshRenderSystem.ts @@ -0,0 +1,304 @@ +/** + * MeshRenderSystem - System for rendering 3D meshes. + * MeshRenderSystem - 3D 网格渲染系统。 + */ + +import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework'; +import type { EngineBridge } from '@esengine/ecs-engine-bindgen'; +import { TransformComponent } from '@esengine/engine-core'; +import type { IMeshData } from '@esengine/asset-system'; +import { MeshComponent } from '../MeshComponent'; + +/** + * System for rendering 3D mesh components. + * 用于渲染 3D 网格组件的系统。 + * + * Queries all entities with MeshComponent and TransformComponent, + * builds interleaved vertex data, and submits to the Rust engine. + * 查询所有具有 MeshComponent 和 TransformComponent 的实体, + * 构建交错顶点数据并提交到 Rust 引擎。 + */ +@ECSSystem('MeshRender', { updateOrder: 900 }) +export class MeshRenderSystem extends EntitySystem { + private bridge: EngineBridge | null; + + // Reusable buffers for performance + // 可重用缓冲区以提高性能 + private vertexBuffer: Float32Array = new Float32Array(0); + private transformBuffer: Float32Array = new Float32Array(16); + + constructor(bridge: EngineBridge | null = null) { + super(Matcher.empty().all(MeshComponent).all(TransformComponent)); + this.bridge = bridge; + } + + /** + * Set the engine bridge (can be called after construction). + * 设置引擎桥接(可在构造后调用)。 + */ + public setEngineBridge(bridge: EngineBridge): void { + this.bridge = bridge; + } + + // 调试帧计数 | Debug frame counter + private _frameCount = 0; + private _lastLogTime = 0; + + /** + * Process entities each frame. + * 每帧处理实体。 + */ + protected override process(entities: readonly Entity[]): void { + this._frameCount++; + + if (!this.bridge) { + if (this._frameCount % 300 === 1) { + console.warn('[MeshRenderSystem] No bridge available'); + } + return; + } + + // Check if in 3D mode (mode 1 = 3D) + // 检查是否在 3D 模式 + const renderMode = this.bridge.getRenderMode(); + + // Debug: log mode and entity count periodically + // 调试:定期记录模式和实体数量 + const now = Date.now(); + if (now - this._lastLogTime > 3000) { + this._lastLogTime = now; + console.log(`[MeshRenderSystem] Mode: ${renderMode}, Entities: ${entities.length}`); + + // Log mesh status for each entity + // 记录每个实体的网格状态 + for (const entity of entities) { + const mesh = entity.getComponent(MeshComponent); + if (mesh) { + console.log(` - Entity ${entity.name}: modelGuid=${mesh.modelGuid?.substring(0, 8)}..., isLoaded=${mesh.isLoaded}, meshCount=${mesh.allMeshes.length}`); + } + } + } + + if (renderMode !== 1) { + // 2D mode, skip 3D rendering + // 2D 模式,跳过 3D 渲染 + return; + } + + for (const entity of entities) { + if (!entity.enabled) continue; + this.renderEntity(entity); + } + } + + // 调试:上次提交时间 | Debug: last submit time + private _lastSubmitLogTime = 0; + + /** + * Render a single entity's mesh. + * 渲染单个实体的网格。 + */ + private renderEntity(entity: Entity): void { + const mesh = entity.getComponent(MeshComponent); + const transform = entity.getComponent(TransformComponent); + + if (!mesh || !transform || !mesh.visible || !mesh.isLoaded) { + // Debug skip reason + // 调试跳过原因 + const now = Date.now(); + if (now - this._lastSubmitLogTime > 5000) { + this._lastSubmitLogTime = now; + const reason = !mesh ? 'no mesh' : + !transform ? 'no transform' : + !mesh.visible ? 'not visible' : + !mesh.isLoaded ? 'not loaded' : 'unknown'; + console.log(`[MeshRenderSystem] Skip ${entity.name}: ${reason}`); + } + return; + } + + // Get all meshes to render + // 获取所有要渲染的网格 + const meshesToRender = mesh.allMeshes; + if (meshesToRender.length === 0) { + console.log(`[MeshRenderSystem] Skip ${entity.name}: no meshes`); + return; + } + + // Build world transform matrix + // 构建世界变换矩阵 + this.buildTransformMatrix(transform); + + // Debug: log transform + // 调试:记录变换 + const now = Date.now(); + if (now - this._lastSubmitLogTime > 5000) { + this._lastSubmitLogTime = now; + const pos = transform.position; + console.log(`[MeshRenderSystem] Rendering ${entity.name}: ${meshesToRender.length} meshes`); + console.log(` Transform: pos(${pos.x?.toFixed(2) ?? 0}, ${pos.y?.toFixed(2) ?? 0}, ${pos.z?.toFixed(2) ?? 0})`); + } + + // Render each mesh + // 渲染每个网格 + for (let i = 0; i < meshesToRender.length; i++) { + const meshData = meshesToRender[i]; + if (!meshData) continue; + + // Build interleaved vertex data + // 构建交错顶点数据 + const vertexData = this.buildVertexData(meshData); + if (!vertexData) { + console.warn(`[MeshRenderSystem] Failed to build vertex data for mesh ${i}`); + continue; + } + + // Get material and texture IDs + // 获取材质和纹理 ID + const materialId = mesh.runtimeMaterialIds[i] ?? 0; + const textureId = mesh.runtimeTextureIds[i] ?? 0; + + // Debug: log submission + // 调试:记录提交 + if (now - this._lastSubmitLogTime < 100) { + console.log(` Submitting mesh ${i}: ${vertexData.length / 9} vertices, ${meshData.indices.length} indices`); + } + + // Submit to engine + // 提交到引擎 + try { + this.bridge!.submitSimpleMesh3D( + vertexData, + new Uint32Array(meshData.indices), + this.transformBuffer, + materialId, + textureId + ); + } catch (e) { + console.error(`[MeshRenderSystem] submitSimpleMesh3D failed:`, e); + } + } + } + + /** + * Build 4x4 transform matrix from TransformComponent. + * 从 TransformComponent 构建 4x4 变换矩阵。 + */ + private buildTransformMatrix(transform: TransformComponent): void { + // Get world position, rotation, scale with safe defaults + // 获取世界位置、旋转、缩放(带安全默认值) + const rawPos = transform.worldPosition; + const rawRot = transform.worldRotation; // Euler angles in degrees + const rawScl = transform.worldScale; + + // Safe extraction with defaults for 2D components + // 2D 组件的安全提取(带默认值) + const pos = { x: rawPos.x ?? 0, y: rawPos.y ?? 0, z: rawPos.z ?? 0 }; + const rot = { x: rawRot.x ?? 0, y: rawRot.y ?? 0, z: rawRot.z ?? 0 }; + const scl = { x: rawScl.x ?? 1, y: rawScl.y ?? 1, z: rawScl.z ?? 1 }; + + // Convert rotation to radians + // 将旋转转换为弧度 + const rx = (rot.x * Math.PI) / 180; + const ry = (rot.y * Math.PI) / 180; + const rz = (rot.z * Math.PI) / 180; + + // Build rotation matrix (ZYX order) + // 构建旋转矩阵(ZYX 顺序) + const cx = Math.cos(rx), sx = Math.sin(rx); + const cy = Math.cos(ry), sy = Math.sin(ry); + const cz = Math.cos(rz), sz = Math.sin(rz); + + // Combined rotation matrix + // 组合旋转矩阵 + const r00 = cy * cz; + const r01 = cy * sz; + const r02 = -sy; + const r10 = sx * sy * cz - cx * sz; + const r11 = sx * sy * sz + cx * cz; + const r12 = sx * cy; + const r20 = cx * sy * cz + sx * sz; + const r21 = cx * sy * sz - sx * cz; + const r22 = cx * cy; + + // Build column-major 4x4 matrix with scale and translation + // 构建带缩放和平移的列优先 4x4 矩阵 + const m = this.transformBuffer; + + // Column 0 + m[0] = r00 * scl.x; + m[1] = r10 * scl.x; + m[2] = r20 * scl.x; + m[3] = 0; + + // Column 1 + m[4] = r01 * scl.y; + m[5] = r11 * scl.y; + m[6] = r21 * scl.y; + m[7] = 0; + + // Column 2 + m[8] = r02 * scl.z; + m[9] = r12 * scl.z; + m[10] = r22 * scl.z; + m[11] = 0; + + // Column 3 (translation) + m[12] = pos.x; + m[13] = pos.y; + m[14] = pos.z; + m[15] = 1; + } + + /** + * Build interleaved vertex data for simple 3D mesh. + * 构建简化 3D 网格的交错顶点数据。 + * + * Format: [x, y, z, u, v, r, g, b, a] per vertex (9 floats) + * 格式:每个顶点 [x, y, z, u, v, r, g, b, a](9 个浮点数) + */ + private buildVertexData(meshData: IMeshData): Float32Array | null { + const vertices = meshData.vertices; + const uvs = meshData.uvs; + const colors = meshData.colors; + + if (!vertices || vertices.length === 0) return null; + + const vertexCount = vertices.length / 3; + const floatsPerVertex = 9; + const totalFloats = vertexCount * floatsPerVertex; + + // Resize buffer if needed + // 如果需要,调整缓冲区大小 + if (this.vertexBuffer.length < totalFloats) { + this.vertexBuffer = new Float32Array(totalFloats); + } + + const hasUVs = uvs && uvs.length >= vertexCount * 2; + const hasColors = colors && colors.length >= vertexCount * 4; + + for (let i = 0; i < vertexCount; i++) { + const vBase = i * 3; + const uvBase = i * 2; + const colorBase = i * 4; + const outBase = i * floatsPerVertex; + + // Position + this.vertexBuffer[outBase] = vertices[vBase]; + this.vertexBuffer[outBase + 1] = vertices[vBase + 1]; + this.vertexBuffer[outBase + 2] = vertices[vBase + 2]; + + // UV + this.vertexBuffer[outBase + 3] = hasUVs ? uvs![uvBase] : 0; + this.vertexBuffer[outBase + 4] = hasUVs ? uvs![uvBase + 1] : 0; + + // Color (RGBA) + this.vertexBuffer[outBase + 5] = hasColors ? colors![colorBase] : 1; + this.vertexBuffer[outBase + 6] = hasColors ? colors![colorBase + 1] : 1; + this.vertexBuffer[outBase + 7] = hasColors ? colors![colorBase + 2] : 1; + this.vertexBuffer[outBase + 8] = hasColors ? colors![colorBase + 3] : 1; + } + + return this.vertexBuffer.subarray(0, totalFloats); + } +} diff --git a/packages/mesh-3d/src/systems/SkeletonBakingSystem.ts b/packages/mesh-3d/src/systems/SkeletonBakingSystem.ts new file mode 100644 index 00000000..2b04b8df --- /dev/null +++ b/packages/mesh-3d/src/systems/SkeletonBakingSystem.ts @@ -0,0 +1,186 @@ +/** + * SkeletonBakingSystem - System for baking skeleton matrices. + * SkeletonBakingSystem - 骨骼矩阵烘焙系统。 + */ + +import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework'; +import { SkeletonComponent, type BoneTransform } from '../SkeletonComponent'; +import { MeshComponent } from '../MeshComponent'; + +/** + * System for computing skeleton bone matrices. + * 用于计算骨骼矩阵的系统。 + * + * Runs after Animation3DSystem to compute world matrices and final skinning matrices. + * 在 Animation3DSystem 之后运行,计算世界矩阵和最终蒙皮矩阵。 + */ +@ECSSystem('SkeletonBaking', { updateOrder: 110 }) +export class SkeletonBakingSystem extends EntitySystem { + // Temporary matrix for calculations + // 用于计算的临时矩阵 + private tempMatrix: Float32Array = new Float32Array(16); + private tempMatrix2: Float32Array = new Float32Array(16); + + constructor() { + super(Matcher.empty().all(SkeletonComponent).all(MeshComponent)); + } + + /** + * Process entities each frame. + * 每帧处理实体。 + */ + protected override process(entities: readonly Entity[]): void { + for (const entity of entities) { + if (!entity.enabled) continue; + this.updateEntity(entity); + } + } + + /** + * Update a single entity's skeleton matrices. + * 更新单个实体的骨骼矩阵。 + */ + private updateEntity(entity: Entity): void { + const skeleton = entity.getComponent(SkeletonComponent); + + if (!skeleton || !skeleton.isLoaded || !skeleton.isDirty()) { + return; + } + + const joints = skeleton.joints; + const boneTransforms = skeleton.boneTransforms; + + // Phase 1: Compute world matrices (parent-to-child order) + // 阶段1: 计算世界矩阵(父到子顺序) + for (let i = 0; i < joints.length; i++) { + const joint = joints[i]; + const localTransform = boneTransforms[i]; + + // Build local transform matrix + // 构建局部变换矩阵 + this.buildTransformMatrix(localTransform, this.tempMatrix); + + if (joint.parentIndex >= 0) { + // Multiply parent world matrix by local matrix + // 将父世界矩阵乘以局部矩阵 + const parentWorld = skeleton.getWorldMatrix(joint.parentIndex); + if (parentWorld) { + this.multiplyMatrices(parentWorld, this.tempMatrix, this.tempMatrix2); + skeleton.setWorldMatrix(i, this.tempMatrix2); + } else { + skeleton.setWorldMatrix(i, this.tempMatrix); + } + } else { + // Root bone - world matrix is local matrix + // 根骨骼 - 世界矩阵就是局部矩阵 + skeleton.setWorldMatrix(i, this.tempMatrix); + } + } + + // Phase 2: Compute final matrices (world * inverseBindMatrix) + // 阶段2: 计算最终矩阵(世界矩阵 * 逆绑定矩阵) + for (let i = 0; i < joints.length; i++) { + const joint = joints[i]; + const worldMatrix = skeleton.getWorldMatrix(i); + + if (worldMatrix && joint.inverseBindMatrix) { + this.multiplyMatrices(worldMatrix, joint.inverseBindMatrix, this.tempMatrix); + skeleton.setFinalMatrix(i, this.tempMatrix); + } + } + + // Clear dirty flag + // 清除脏标记 + skeleton.clearDirty(); + } + + /** + * Build 4x4 transform matrix from BoneTransform. + * 从 BoneTransform 构建 4x4 变换矩阵。 + */ + private buildTransformMatrix(transform: BoneTransform, out: Float32Array): void { + const [px, py, pz] = transform.position; + const [qx, qy, qz, qw] = transform.rotation; + const [sx, sy, sz] = transform.scale; + + // Build rotation matrix from quaternion + // 从四元数构建旋转矩阵 + const x2 = qx + qx; + const y2 = qy + qy; + const z2 = qz + qz; + const xx = qx * x2; + const xy = qx * y2; + const xz = qx * z2; + const yy = qy * y2; + const yz = qy * z2; + const zz = qz * z2; + const wx = qw * x2; + const wy = qw * y2; + const wz = qw * z2; + + // Column 0 (with scale) + out[0] = (1 - (yy + zz)) * sx; + out[1] = (xy + wz) * sx; + out[2] = (xz - wy) * sx; + out[3] = 0; + + // Column 1 (with scale) + out[4] = (xy - wz) * sy; + out[5] = (1 - (xx + zz)) * sy; + out[6] = (yz + wx) * sy; + out[7] = 0; + + // Column 2 (with scale) + out[8] = (xz + wy) * sz; + out[9] = (yz - wx) * sz; + out[10] = (1 - (xx + yy)) * sz; + out[11] = 0; + + // Column 3 (translation) + out[12] = px; + out[13] = py; + out[14] = pz; + out[15] = 1; + } + + /** + * Multiply two 4x4 matrices (column-major). + * 乘以两个 4x4 矩阵(列优先)。 + */ + private multiplyMatrices(a: Float32Array, b: Float32Array, out: Float32Array): void { + const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; + const a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; + const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; + const a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; + + let b0, b1, b2, b3; + + // Column 0 + b0 = b[0]; b1 = b[1]; b2 = b[2]; b3 = b[3]; + out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; + out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; + out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; + out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; + + // Column 1 + b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7]; + out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; + out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; + out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; + out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; + + // Column 2 + b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11]; + out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; + out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; + out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; + out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; + + // Column 3 + b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15]; + out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30; + out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31; + out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32; + out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33; + } +} diff --git a/packages/mesh-3d/src/tokens.ts b/packages/mesh-3d/src/tokens.ts new file mode 100644 index 00000000..53e5ae8b --- /dev/null +++ b/packages/mesh-3d/src/tokens.ts @@ -0,0 +1,13 @@ +/** + * Service tokens for mesh-3d module. + * mesh-3d 模块的服务令牌。 + */ + +import { createServiceToken } from '@esengine/ecs-framework'; +import type { MeshRenderSystem } from './systems/MeshRenderSystem'; + +/** + * Token for MeshRenderSystem service. + * MeshRenderSystem 服务的令牌。 + */ +export const MeshRenderSystemToken = createServiceToken('meshRenderSystem'); diff --git a/packages/mesh-3d/tsconfig.build.json b/packages/mesh-3d/tsconfig.build.json new file mode 100644 index 00000000..f39a0594 --- /dev/null +++ b/packages/mesh-3d/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/mesh-3d/tsconfig.json b/packages/mesh-3d/tsconfig.json new file mode 100644 index 00000000..144c4895 --- /dev/null +++ b/packages/mesh-3d/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../core" }, + { "path": "../asset-system" }, + { "path": "../engine-core" }, + { "path": "../ecs-engine-bindgen" } + ] +} diff --git a/packages/mesh-3d/tsup.config.ts b/packages/mesh-3d/tsup.config.ts new file mode 100644 index 00000000..f704a430 --- /dev/null +++ b/packages/mesh-3d/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup'; +import { runtimeOnlyPreset } from '../build-config/src/presets/plugin-tsup'; + +export default defineConfig({ + ...runtimeOnlyPreset(), + tsconfig: 'tsconfig.build.json' +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfa32cd3..75534f20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,13 @@ importers: version: 1.6.4(@algolia/client-search@5.44.0)(@types/node@20.19.25)(@types/react@18.3.27)(axios@1.13.2)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(terser@5.44.1)(typescript@5.9.3) packages/asset-system: + dependencies: + '@types/pako': + specifier: ^2.0.4 + version: 2.0.4 + pako: + specifier: ^2.1.0 + version: 2.1.0 devDependencies: '@esengine/build-config': specifier: workspace:* @@ -604,6 +611,12 @@ importers: '@esengine/material-system': specifier: workspace:* version: link:../material-system + '@esengine/mesh-3d': + specifier: workspace:* + version: link:../mesh-3d + '@esengine/mesh-3d-editor': + specifier: workspace:* + version: link:../mesh-3d-editor '@esengine/particle': specifier: workspace:* version: link:../particle @@ -1113,6 +1126,75 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/mesh-3d: + devDependencies: + '@esengine/asset-system': + specifier: workspace:* + version: link:../asset-system + '@esengine/build-config': + specifier: workspace:* + version: link:../build-config + '@esengine/ecs-engine-bindgen': + specifier: workspace:* + version: link:../ecs-engine-bindgen + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + '@esengine/engine-core': + specifier: workspace:* + version: link:../engine-core + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + + packages/mesh-3d-editor: + devDependencies: + '@esengine/asset-system': + specifier: workspace:* + version: link:../asset-system + '@esengine/build-config': + specifier: workspace:* + version: link:../build-config + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + '@esengine/editor-core': + specifier: workspace:* + version: link:../editor-core + '@esengine/engine-core': + specifier: workspace:* + version: link:../engine-core + '@esengine/mesh-3d': + specifier: workspace:* + version: link:../mesh-3d + '@tauri-apps/api': + specifier: ^2.5.0 + version: 2.9.0 + '@types/react': + specifier: ^18.2.0 + version: 18.3.27 + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages/node-editor: dependencies: tslib: @@ -4361,6 +4443,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -7542,6 +7627,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -12189,6 +12277,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/pako@2.0.4': {} + '@types/prop-types@15.7.15': {} '@types/qs@6.14.0': {} @@ -16109,6 +16199,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/scripts/analyze-fbx.mjs b/scripts/analyze-fbx.mjs new file mode 100644 index 00000000..b642d2d8 --- /dev/null +++ b/scripts/analyze-fbx.mjs @@ -0,0 +1,239 @@ +/** + * FBX Animation Analysis Script + * 分析 FBX 文件的动画数据 + */ + +import { readFileSync } from 'fs'; + +const FBX_TIME_SECOND = 46186158000n; + +// Read FBX file +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; +console.log(`Analyzing: ${filePath}`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + +// Check header +const magic = new TextDecoder().decode(buffer.slice(0, 21)); +console.log(`Header: "${magic}"`); + +const version = view.getUint32(23, true); +console.log(`FBX Version: ${version}`); + +// Simple FBX parser for animation data +let offset = 27; // After header + +function readNode(is64Bit) { + const startOffset = offset; + + let endOffset, numProperties, propertyListLen, nameLen; + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + // Read properties + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': // Int16 + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': // Bool + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': // Int32 + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': // Float + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': // Double + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': // Int64 + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': // String + case 'R': // Raw binary + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': // Float array + case 'd': // Double array + case 'l': // Long array + case 'i': // Int array + case 'b': // Bool array + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + // Uncompressed + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + // Compressed - skip for now + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + offset += compressedLen; + } + break; + default: + console.log(`Unknown type: ${typeCode} at offset ${offset - 1}`); + offset = propsEnd; + } + } + + // Read children + const children = []; + while (offset < endOffset) { + const child = readNode(is64Bit); + if (child) children.push(child); + else break; + } + + offset = endOffset; + + return { name, properties, children }; +} + +// Parse root nodes +const is64Bit = version >= 7500; +const rootNodes = []; + +while (offset < buffer.length - 100) { + const node = readNode(is64Bit); + if (node) { + rootNodes.push(node); + } else { + break; + } +} + +console.log(`Root nodes: ${rootNodes.map(n => n.name).join(', ')}`); + +// Find Objects node +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +if (!objectsNode) { + console.log('No Objects node found!'); + process.exit(1); +} + +// Find animation curves +const animCurves = objectsNode.children.filter(n => n.name === 'AnimationCurve'); +const animCurveNodes = objectsNode.children.filter(n => n.name === 'AnimationCurveNode'); + +console.log(`\nAnimation data:`); +console.log(` AnimationCurve nodes: ${animCurves.length}`); +console.log(` AnimationCurveNode nodes: ${animCurveNodes.length}`); + +// Analyze first few animation curves with actual data +console.log(`\nFirst 10 AnimationCurves with varying values:`); +let count = 0; +for (const curve of animCurves) { + if (count >= 10) break; + + // Find KeyTime and KeyValueFloat + let keyTimes = null; + let keyValues = null; + + for (const child of curve.children) { + if (child.name === 'KeyTime') { + keyTimes = child.properties[0]; + } else if (child.name === 'KeyValueFloat') { + keyValues = child.properties[0]; + } + } + + if (keyValues?.data) { + const values = keyValues.data; + const min = Math.min(...values); + const max = Math.max(...values); + + // Only show curves with varying values + if (Math.abs(max - min) > 0.001) { + const id = curve.properties[0]; + const name = curve.properties[1]?.split?.('\0')[0] || 'AnimationCurve'; + console.log(` Curve ${id}: ${values.length} keyframes, range: ${min.toFixed(4)} - ${max.toFixed(4)}`); + console.log(` First 5 values: ${values.slice(0, 5).map(v => v.toFixed(4)).join(', ')}`); + console.log(` Last 5 values: ${values.slice(-5).map(v => v.toFixed(4)).join(', ')}`); + count++; + } + } +} + +// Find Connections node +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); +if (connectionsNode) { + // Find connections with d|X, d|Y, d|Z properties + const curveConnections = connectionsNode.children.filter(c => { + const prop = c.properties[3]; + return prop === 'd|X' || prop === 'd|Y' || prop === 'd|Z'; + }); + console.log(`\nCurve connections (d|X/Y/Z): ${curveConnections.length}`); + + // Show first 10 + console.log(`First 10 curve connections:`); + for (let i = 0; i < Math.min(10, curveConnections.length); i++) { + const c = curveConnections[i]; + console.log(` ${c.properties[1]} -> ${c.properties[2]}, prop: ${c.properties[3]}`); + } +} + +// Find AnimationCurveNodes and their connections +console.log(`\nAnimationCurveNode analysis:`); +const curveNodesByAttr = { T: 0, R: 0, S: 0, other: 0 }; +for (const cn of animCurveNodes) { + const name = cn.properties[1]?.split?.('\0')[0] || ''; + if (name === 'T') curveNodesByAttr.T++; + else if (name === 'R') curveNodesByAttr.R++; + else if (name === 'S') curveNodesByAttr.S++; + else curveNodesByAttr.other++; +} +console.log(` Translation (T): ${curveNodesByAttr.T}`); +console.log(` Rotation (R): ${curveNodesByAttr.R}`); +console.log(` Scale (S): ${curveNodesByAttr.S}`); +console.log(` Other: ${curveNodesByAttr.other}`); + +console.log('\nDone!'); diff --git a/scripts/check-anim-coverage.mjs b/scripts/check-anim-coverage.mjs new file mode 100644 index 00000000..63fc5c5a --- /dev/null +++ b/scripts/check-anim-coverage.mjs @@ -0,0 +1,256 @@ +/** + * Check Animation Coverage + * 检查动画覆盖范围 + * + * Verify if animation provides data for all skeleton joints + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const FBX_TIME_SECOND = 46186158000n; +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Check Animation Coverage: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; + case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; + case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; + case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; + case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; + case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': case 'd': case 'l': case 'i': case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model' + })); + +// Parse Clusters +const clusters = objectsNode.children + .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Cluster' + })); + +// Build cluster to bone mapping +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) clusterToBone.set(cluster.id, conn.fromId); + } +} + +// Build model ID to index +const modelToIndex = new Map(); +models.forEach((m, i) => modelToIndex.set(m.id, i)); + +// Build skeleton joints +const joints = []; +const boneModelIds = new Set(); +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const nodeIndex = modelToIndex.get(boneModelId); + if (nodeIndex === undefined) continue; + + boneModelIds.add(boneModelId); + joints.push({ + name: models[nodeIndex].name, + nodeIndex, + boneModelId + }); +} + +console.log(`Skeleton joints: ${joints.length}`); +console.log(`Joint nodeIndices: ${[...new Set(joints.map(j => j.nodeIndex))].length} unique`); + +// Parse AnimationCurveNodes and find which models they target +const curveNodes = objectsNode.children + .filter(n => n.name === 'AnimationCurveNode') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || '' + })); + +// Build curveNode to model mapping (from OP connections) +const curveNodeToModel = new Map(); +for (const conn of connections) { + if (conn.type === 'OP' && conn.property?.includes('Lcl')) { + const cn = curveNodes.find(c => c.id === conn.fromId); + if (cn) { + curveNodeToModel.set(cn.id, { modelId: conn.toId, property: conn.property }); + } + } +} + +// Find which joints have animation +const jointsWithAnimation = new Set(); +const jointsWithTranslation = new Set(); +const jointsWithRotation = new Set(); +const jointsWithScale = new Set(); + +for (const [cnId, target] of curveNodeToModel) { + const nodeIndex = modelToIndex.get(target.modelId); + if (nodeIndex === undefined) continue; + + // Check if this node is a bone + const joint = joints.find(j => j.nodeIndex === nodeIndex); + if (joint) { + jointsWithAnimation.add(nodeIndex); + if (target.property.includes('Translation')) { + jointsWithTranslation.add(nodeIndex); + } else if (target.property.includes('Rotation')) { + jointsWithRotation.add(nodeIndex); + } else if (target.property.includes('Scaling')) { + jointsWithScale.add(nodeIndex); + } + } +} + +console.log(`\n=== ANIMATION COVERAGE ===`); +console.log(`Joints with ANY animation: ${jointsWithAnimation.size}/${joints.length}`); +console.log(`Joints with Translation: ${jointsWithTranslation.size}/${joints.length}`); +console.log(`Joints with Rotation: ${jointsWithRotation.size}/${joints.length}`); +console.log(`Joints with Scale: ${jointsWithScale.size}/${joints.length}`); + +const jointsWithoutAnimation = joints.filter(j => !jointsWithAnimation.has(j.nodeIndex)); +if (jointsWithoutAnimation.length > 0) { + console.log(`\n⚠️ Joints WITHOUT animation (${jointsWithoutAnimation.length}):`); + jointsWithoutAnimation.slice(0, 10).forEach(j => { + console.log(` nodeIndex=${j.nodeIndex}, name="${j.name}"`); + }); + + if (jointsWithoutAnimation.length > 10) { + console.log(` ... and ${jointsWithoutAnimation.length - 10} more`); + } + + console.log(`\nThese joints will fall back to node.transform, which may cause issues!`); +} else { + console.log(`\n✅ All joints have animation data!`); +} + +console.log('\nDone!'); diff --git a/scripts/check-bone-hierarchy.mjs b/scripts/check-bone-hierarchy.mjs new file mode 100644 index 00000000..750b4dfc --- /dev/null +++ b/scripts/check-bone-hierarchy.mjs @@ -0,0 +1,259 @@ +/** + * Check Bone Hierarchy + * 检查骨骼层级 + * + * Verify parent-child relationships for bones + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Check Bone Hierarchy: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; + case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; + case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; + case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; + case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; + case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': case 'd': case 'l': case 'i': case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model', + type: n.properties[2]?.split?.('\0')[0] || '' + })); + +// Build parent relationships from connections +const modelParent = new Map(); +const modelChildren = new Map(); + +for (const conn of connections) { + if (conn.type === 'OO') { + const fromModel = models.find(m => m.id === conn.fromId); + const toModel = models.find(m => m.id === conn.toId); + if (fromModel && toModel) { + modelParent.set(conn.fromId, conn.toId); + if (!modelChildren.has(conn.toId)) { + modelChildren.set(conn.toId, []); + } + modelChildren.get(conn.toId).push(conn.fromId); + } + } +} + +// Find Bone001 and trace its parents +const bone001 = models.find(m => m.name === 'Bone001'); +if (bone001) { + console.log(`Bone001 (id=${bone001.id}):`); + console.log(` type: "${bone001.type}"`); + + // Trace parent chain + let currentId = bone001.id; + let depth = 0; + while (currentId && depth < 10) { + const parentId = modelParent.get(currentId); + if (parentId) { + const parent = models.find(m => m.id === parentId); + console.log(` Parent [${depth}]: "${parent?.name}" (id=${parentId}, type="${parent?.type}")`); + } else { + console.log(` Parent [${depth}]: ROOT (no parent)`); + break; + } + currentId = parentId; + depth++; + } +} + +// Show first level hierarchy +console.log(`\n=== ROOT LEVEL MODELS ===`); +const rootModels = models.filter(m => !modelParent.has(m.id)); +rootModels.forEach(m => { + console.log(`"${m.name}" (type="${m.type}")`); + const children = modelChildren.get(m.id) || []; + children.slice(0, 5).forEach(cid => { + const child = models.find(m => m.id === cid); + console.log(` └── "${child?.name}" (type="${child?.type}")`); + }); + if (children.length > 5) { + console.log(` ... and ${children.length - 5} more children`); + } +}); + +// Check if Bone001's parent has a transform that's not identity +const bone001Parent = modelParent.get(bone001?.id); +if (bone001Parent) { + const parent = models.find(m => m.id === bone001Parent); + console.log(`\n=== BONE001'S PARENT DETAILS ===`); + console.log(`Parent: "${parent?.name}" (type="${parent?.type}")`); + + // Find this parent in FBX and get its transform + for (const n of objectsNode.children) { + if (n.name === 'Model' && n.properties[0] === bone001Parent) { + let position = [0, 0, 0]; + let rotation = [0, 0, 0]; + let scale = [1, 1, 1]; + let preRotation = null; + + for (const child of n.children) { + if (child.name === 'Properties70') { + for (const prop of child.children) { + if (prop.properties[0] === 'Lcl Translation') { + position = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } else if (prop.properties[0] === 'Lcl Rotation') { + rotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } else if (prop.properties[0] === 'Lcl Scaling') { + scale = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } else if (prop.properties[0] === 'PreRotation') { + preRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } + } + } + } + + console.log(` position: [${position.join(', ')}]`); + console.log(` rotation: [${rotation.join(', ')}]`); + console.log(` scale: [${scale.join(', ')}]`); + if (preRotation) { + console.log(` preRotation: [${preRotation.join(', ')}]`); + } + + // Check if parent has non-identity transform + const hasNonIdentityTransform = + position.some(v => Math.abs(v) > 0.001) || + rotation.some(v => Math.abs(v) > 0.001) || + scale.some(v => Math.abs(v - 1) > 0.001); + + if (hasNonIdentityTransform) { + console.log(`\n⚠️ Parent has non-identity transform!`); + console.log(`This transform MUST be included when calculating bone world matrices.`); + } else { + console.log(`\nParent has identity transform (no effect).`); + } + } + } +} + +console.log('\nDone!'); diff --git a/scripts/check-prerotation.mjs b/scripts/check-prerotation.mjs new file mode 100644 index 00000000..2f9c9638 --- /dev/null +++ b/scripts/check-prerotation.mjs @@ -0,0 +1,183 @@ +/** + * Check PreRotation in FBX + * 检查 FBX 中的 PreRotation + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Checking PreRotation: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; + case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; + case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; + case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; + case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; + case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': case 'd': case 'l': case 'i': case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); + +// Parse Models and check for PreRotation +const modelsWithPreRot = []; +const modelsWithoutPreRot = []; + +for (const n of objectsNode.children) { + if (n.name !== 'Model') continue; + + const modelName = n.properties[1]?.split?.('\0')[0] || 'Model'; + let hasPreRotation = false; + let preRotation = null; + let lclRotation = null; + + for (const child of n.children) { + if (child.name === 'Properties70') { + for (const prop of child.children) { + if (prop.properties[0] === 'PreRotation') { + hasPreRotation = true; + preRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } + if (prop.properties[0] === 'Lcl Rotation') { + lclRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } + } + } + } + + if (hasPreRotation) { + modelsWithPreRot.push({ name: modelName, preRotation, lclRotation }); + } else { + modelsWithoutPreRot.push({ name: modelName, lclRotation }); + } +} + +console.log(`Models WITH PreRotation: ${modelsWithPreRot.length}`); +console.log(`Models WITHOUT PreRotation: ${modelsWithoutPreRot.length}`); + +if (modelsWithPreRot.length > 0) { + console.log(`\nFirst 5 models with PreRotation:`); + modelsWithPreRot.slice(0, 5).forEach(m => { + console.log(` "${m.name}":`); + console.log(` PreRotation: [${m.preRotation.map(v => v.toFixed(2)).join(', ')}]`); + console.log(` LclRotation: [${m.lclRotation?.map(v => v.toFixed(2)).join(', ') || 'none'}]`); + }); +} + +// Check if bones have PreRotation (bones typically have "Bone" in name) +const boneModels = modelsWithPreRot.filter(m => m.name.includes('Bone')); +console.log(`\nBone models with PreRotation: ${boneModels.length}`); + +if (boneModels.length > 0) { + console.log(`\n⚠️ This FBX has bones with PreRotation!`); + console.log(`PreRotation MUST be applied when building world matrices.`); +} + +console.log('\nDone!'); diff --git a/scripts/compare-ibm.mjs b/scripts/compare-ibm.mjs new file mode 100644 index 00000000..6627b990 --- /dev/null +++ b/scripts/compare-ibm.mjs @@ -0,0 +1,318 @@ +/** + * Compare InverseBindMatrix calculation + * 比较逆绑定矩阵计算 + * + * This script compares the IBM calculated in test script vs FBXLoader's method + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Analyzing: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + const startOffset = offset; + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); + +// Find first Cluster deformer +const clusterNodes = objectsNode.children.filter(n => + n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster' +); + +console.log(`Found ${clusterNodes.length} clusters\n`); + +// Parse TransformLink from first cluster +const firstCluster = clusterNodes[0]; +const clusterName = firstCluster.properties[1]?.split?.('\0')[0] || 'Cluster'; + +console.log(`First cluster: "${clusterName}"`); + +// Find TransformLink child node +const transformLinkNode = firstCluster.children.find(c => c.name === 'TransformLink'); +if (!transformLinkNode) { + console.log('ERROR: No TransformLink found!'); + process.exit(1); +} + +const transformLinkData = transformLinkNode.properties[0]; +if (!transformLinkData?.data || transformLinkData.data.length !== 16) { + console.log('ERROR: TransformLink data is not 16 doubles!'); + console.log('Got:', transformLinkData); + process.exit(1); +} + +// FBX stores matrices in row-major order +// WebGL expects column-major order +const tlRaw = transformLinkData.data; +console.log('\n=== TransformLink Raw Data (FBX row-major) ==='); +console.log(`Row 0: ${tlRaw.slice(0, 4).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Row 1: ${tlRaw.slice(4, 8).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Row 2: ${tlRaw.slice(8, 12).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Row 3: ${tlRaw.slice(12, 16).map(v => v.toFixed(6)).join(', ')}`); + +// Convert to column-major for WebGL +const tlColMajor = new Float32Array([ + tlRaw[0], tlRaw[4], tlRaw[8], tlRaw[12], + tlRaw[1], tlRaw[5], tlRaw[9], tlRaw[13], + tlRaw[2], tlRaw[6], tlRaw[10], tlRaw[14], + tlRaw[3], tlRaw[7], tlRaw[11], tlRaw[15] +]); + +console.log('\n=== TransformLink (WebGL column-major) ==='); +console.log(`Col 0: ${Array.from(tlColMajor.slice(0, 4)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 1: ${Array.from(tlColMajor.slice(4, 8)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 2: ${Array.from(tlColMajor.slice(8, 12)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 3: ${Array.from(tlColMajor.slice(12, 16)).map(v => v.toFixed(6)).join(', ')}`); + +// Invert the matrix (this is what FBXLoader does) +function invertMatrix4(m) { + const out = new Float32Array(16); + const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; + const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; + const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; + const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; + + const b00 = m00 * m11 - m01 * m10; + const b01 = m00 * m12 - m02 * m10; + const b02 = m00 * m13 - m03 * m10; + const b03 = m01 * m12 - m02 * m11; + const b04 = m01 * m13 - m03 * m11; + const b05 = m02 * m13 - m03 * m12; + const b06 = m20 * m31 - m21 * m30; + const b07 = m20 * m32 - m22 * m30; + const b08 = m20 * m33 - m23 * m30; + const b09 = m21 * m32 - m22 * m31; + const b10 = m21 * m33 - m23 * m31; + const b11 = m22 * m33 - m23 * m32; + + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + + if (Math.abs(det) < 1e-8) { + console.log('WARNING: Matrix is singular!'); + return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + } + + det = 1.0 / det; + + out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; + out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; + out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; + out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; + out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; + out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; + out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; + out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; + out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; + out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; + out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; + out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; + out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; + out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; + out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; + out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; + + return out; +} + +// FBXLoader does: inverseBindMatrix = invertMatrix4(TransformLink) +// But does FBXLoader expect TransformLink in row-major or column-major? + +// Let's check what FBXLoader does with the raw TransformLink data +// Looking at FBXLoader.ts line 1045-1070, it reads TransformLink: +// cluster.transformLink = new Float32Array(transformLinkData.data); +// So it stores the raw FBX row-major data directly + +// Then at line 1707-1709: +// const inverseBindMatrix = cluster.transformLink +// ? this.invertMatrix4(cluster.transformLink) +// : this.createIdentityMatrix(); + +// The question is: does invertMatrix4 expect row-major or column-major input? +// Looking at the invertMatrix4 function, it uses standard column-major notation +// So if it receives row-major data, the result will be wrong! + +console.log('\n=== PROBLEM ANALYSIS ==='); +console.log('FBXLoader stores TransformLink as raw FBX data (row-major)'); +console.log('But invertMatrix4() expects column-major input (WebGL convention)'); +console.log('This mismatch could cause incorrect inverse bind matrices!\n'); + +// Test: invert the raw row-major data (what FBXLoader currently does) +const ibmWrong = invertMatrix4(new Float32Array(tlRaw)); +console.log('=== IBM from Row-Major Input (CURRENT - possibly wrong) ==='); +console.log(`Col 0: ${Array.from(ibmWrong.slice(0, 4)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 1: ${Array.from(ibmWrong.slice(4, 8)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 2: ${Array.from(ibmWrong.slice(8, 12)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 3: ${Array.from(ibmWrong.slice(12, 16)).map(v => v.toFixed(6)).join(', ')}`); + +// Test: invert the transposed (column-major) data (correct approach) +const ibmCorrect = invertMatrix4(tlColMajor); +console.log('\n=== IBM from Column-Major Input (CORRECT) ==='); +console.log(`Col 0: ${Array.from(ibmCorrect.slice(0, 4)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 1: ${Array.from(ibmCorrect.slice(4, 8)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 2: ${Array.from(ibmCorrect.slice(8, 12)).map(v => v.toFixed(6)).join(', ')}`); +console.log(`Col 3: ${Array.from(ibmCorrect.slice(12, 16)).map(v => v.toFixed(6)).join(', ')}`); + +// Verify by checking M * M^-1 = I +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +console.log('\n=== VERIFICATION: TransformLink * IBM should = Identity ==='); +const verify1 = multiplyMatrices(tlColMajor, ibmCorrect); +console.log('Using column-major TransformLink * correct IBM:'); +console.log(`Diagonal: ${verify1[0].toFixed(4)}, ${verify1[5].toFixed(4)}, ${verify1[10].toFixed(4)}, ${verify1[15].toFixed(4)}`); + +const verify2 = multiplyMatrices(new Float32Array(tlRaw), ibmWrong); +console.log('Using row-major TransformLink * wrong IBM:'); +console.log(`Diagonal: ${verify2[0].toFixed(4)}, ${verify2[5].toFixed(4)}, ${verify2[10].toFixed(4)}, ${verify2[15].toFixed(4)}`); + +console.log('\nDone!'); diff --git a/scripts/compare-world-matrix.mjs b/scripts/compare-world-matrix.mjs new file mode 100644 index 00000000..419bce2e --- /dev/null +++ b/scripts/compare-world-matrix.mjs @@ -0,0 +1,355 @@ +/** + * Compare TransformLink vs Calculated World Matrix + * 比较 TransformLink 和计算的世界矩阵 + * + * The issue: node.transform gives LOCAL transforms, but TransformLink is WORLD matrix. + * When we build worldMatrix from hierarchy, it might not equal TransformLink. + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Comparing World Matrix: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; + case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; + case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; + case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; + case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; + case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': case 'd': case 'l': case 'i': case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models with Lcl transforms +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => { + const position = [0, 0, 0]; + const rotation = [0, 0, 0]; + const scale = [1, 1, 1]; + + for (const child of n.children) { + if (child.name === 'Properties70') { + for (const prop of child.children) { + if (prop.properties[0] === 'Lcl Translation') { + position[0] = prop.properties[4]; + position[1] = prop.properties[5]; + position[2] = prop.properties[6]; + } else if (prop.properties[0] === 'Lcl Rotation') { + rotation[0] = prop.properties[4]; + rotation[1] = prop.properties[5]; + rotation[2] = prop.properties[6]; + } else if (prop.properties[0] === 'Lcl Scaling') { + scale[0] = prop.properties[4]; + scale[1] = prop.properties[5]; + scale[2] = prop.properties[6]; + } + } + } + } + + return { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model', + position, rotation, scale + }; + }); + +// Parse Clusters with TransformLink +const clusters = objectsNode.children + .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') + .map(n => { + const cluster = { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Cluster', + transformLink: null + }; + + for (const child of n.children) { + if (child.name === 'TransformLink') { + const data = child.properties[0]?.data; + if (data && data.length === 16) { + cluster.transformLink = new Float32Array(data); + } + } + } + + return cluster; + }); + +// Build mappings +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) clusterToBone.set(cluster.id, conn.fromId); + } +} + +const modelToIndex = new Map(); +const modelById = new Map(); +models.forEach((m, i) => { + modelToIndex.set(m.id, i); + modelById.set(m.id, m); +}); + +const modelParent = new Map(); +for (const conn of connections) { + if (conn.type === 'OO' && modelToIndex.has(conn.fromId) && modelToIndex.has(conn.toId)) { + modelParent.set(conn.fromId, conn.toId); + } +} + +// Euler to quaternion (XYZ intrinsic) +function eulerToQuaternion(x, y, z) { + const cx = Math.cos(x / 2), sx = Math.sin(x / 2); + const cy = Math.cos(y / 2), sy = Math.sin(y / 2); + const cz = Math.cos(z / 2), sz = Math.sin(z / 2); + return [ + sx * cy * cz - cx * sy * sz, + cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, + cx * cy * cz + sx * sy * sz + ]; +} + +function createTransformMatrix(position, rotation, scale) { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); +} + +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +function identity() { + return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); +} + +// Calculate world matrices from hierarchy +const worldMatrices = new Map(); + +function calculateWorldMatrix(modelId) { + if (worldMatrices.has(modelId)) return worldMatrices.get(modelId); + + const model = modelById.get(modelId); + if (!model) { + const mat = identity(); + worldMatrices.set(modelId, mat); + return mat; + } + + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + const quat = eulerToQuaternion(rx, ry, rz); + const localMatrix = createTransformMatrix(model.position, quat, model.scale); + + const parentId = modelParent.get(modelId); + let worldMatrix; + if (parentId) { + const parentWorld = calculateWorldMatrix(parentId); + worldMatrix = multiplyMatrices(parentWorld, localMatrix); + } else { + worldMatrix = localMatrix; + } + + worldMatrices.set(modelId, worldMatrix); + return worldMatrix; +} + +console.log(`=== Comparing TransformLink vs Calculated World Matrix ===\n`); + +let matchCount = 0; +let mismatchCount = 0; + +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId || !cluster.transformLink) continue; + + const model = modelById.get(boneModelId); + const calculatedWorld = calculateWorldMatrix(boneModelId); + const transformLink = cluster.transformLink; + + // Compare + let maxDiff = 0; + for (let i = 0; i < 16; i++) { + const diff = Math.abs(calculatedWorld[i] - transformLink[i]); + if (diff > maxDiff) maxDiff = diff; + } + + if (maxDiff < 0.01) { + matchCount++; + } else { + mismatchCount++; + if (mismatchCount <= 3) { + console.log(`❌ MISMATCH: "${model?.name}" (maxDiff=${maxDiff.toFixed(4)})`); + console.log(` TransformLink:`); + console.log(` [${transformLink.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${transformLink.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${transformLink.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${transformLink.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` Calculated World:`); + console.log(` [${calculatedWorld.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${calculatedWorld.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${calculatedWorld.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${calculatedWorld.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); + console.log(''); + } + } +} + +console.log(`\n=== RESULT ===`); +console.log(`Match: ${matchCount}`); +console.log(`Mismatch: ${mismatchCount}`); + +if (mismatchCount > 0) { + console.log(`\n⚠️ TransformLink does NOT match calculated world matrix!`); + console.log(`This means Lcl Translation/Rotation/Scale don't build to the bind pose.`); + console.log(`\nPossible reasons:`); + console.log(`1. Missing PreRotation in the transform calculation`); + console.log(`2. FBX hierarchy differs from the bone hierarchy`); + console.log(`3. Some bones have additional transforms not captured`); +} else { + console.log(`\n✅ All TransformLinks match calculated world matrices!`); +} + +console.log('\nDone!'); diff --git a/scripts/debug-channels.mjs b/scripts/debug-channels.mjs new file mode 100644 index 00000000..d65c72a3 --- /dev/null +++ b/scripts/debug-channels.mjs @@ -0,0 +1,227 @@ +/** + * Debug Animation Channels Building + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + const startOffset = offset; + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse AnimationCurveNodes +const animCurveNodes = objectsNode.children.filter(n => n.name === 'AnimationCurveNode'); + +console.log(`AnimationCurveNodes count: ${animCurveNodes.length}`); +console.log(`First 3 AnimationCurveNodes:`); +animCurveNodes.slice(0, 3).forEach((cn, i) => { + console.log(` [${i}] properties:`, cn.properties); + console.log(` id type: ${typeof cn.properties[0]}`); + console.log(` id value: ${cn.properties[0]}`); +}); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +console.log(`\nConnections count: ${connections.length}`); + +// Find OP connections with Lcl property +const lclConnections = connections.filter(c => c.type === 'OP' && c.property?.includes('Lcl')); +console.log(`OP connections with Lcl: ${lclConnections.length}`); +console.log(`First 3 Lcl connections:`); +lclConnections.slice(0, 3).forEach((c, i) => { + console.log(` [${i}] fromId=${c.fromId} (type: ${typeof c.fromId}), toId=${c.toId}, prop=${c.property}`); +}); + +// Check if any AnimationCurveNode id matches connection fromId +console.log(`\nChecking AnimationCurveNode ID matches:`); +const cnIds = new Set(animCurveNodes.map(cn => cn.properties[0])); +const lclFromIds = lclConnections.map(c => c.fromId); + +let matchCount = 0; +for (const fromId of lclFromIds) { + // Check different ID formats + const matchesDirect = cnIds.has(fromId); + const matchesBigInt = cnIds.has(BigInt(fromId)); + + if (matchesDirect || matchesBigInt) { + matchCount++; + } +} + +console.log(`Matches found: ${matchCount}/${lclConnections.length}`); + +// The issue might be that animCurveNodes doesn't have an 'id' property +// Let's check how we should reference them +console.log(`\nAnimationCurveNode structure check:`); +const firstCN = animCurveNodes[0]; +if (firstCN) { + console.log(` Has 'id' property: ${'id' in firstCN}`); + console.log(` properties[0] type: ${typeof firstCN.properties[0]}`); + console.log(` properties[0] value: ${firstCN.properties[0]}`); +} + +// The fix: we need to use cn.properties[0] as the ID, not cn.id +// Let's verify by creating a proper map +const curveNodeMap = new Map(); +for (const cn of animCurveNodes) { + curveNodeMap.set(cn.properties[0], cn); +} + +console.log(`\nBuilt curveNodeMap with ${curveNodeMap.size} entries`); + +// Now check matches +let matchCount2 = 0; +for (const conn of lclConnections) { + if (curveNodeMap.has(conn.fromId)) { + matchCount2++; + } +} +console.log(`Matches using proper lookup: ${matchCount2}/${lclConnections.length}`); + +console.log('\nDone!'); diff --git a/scripts/debug-fbx-animation.mjs b/scripts/debug-fbx-animation.mjs new file mode 100644 index 00000000..684c3ead --- /dev/null +++ b/scripts/debug-fbx-animation.mjs @@ -0,0 +1,328 @@ +/** + * FBX Animation-Skeleton Debug Script + * 调试 FBX 动画和骨骼的对应关系 + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const FBX_TIME_SECOND = 46186158000n; +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Analyzing: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + +// Parse FBX header +const version = view.getUint32(23, true); +console.log(`FBX Version: ${version}`); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + const startOffset = offset; + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + // Read properties + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + // Compressed - decompress with pako + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + // Read children + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +console.log(`Root nodes: ${rootNodes.map(n => n.name).join(', ')}\n`); + +// Find Objects and Connections +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +if (!objectsNode || !connectionsNode) { + console.log('Missing Objects or Connections node!'); + process.exit(1); +} + +// Parse all connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Find Models (bones are usually LimbNode type) +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model', + type: n.properties[2]?.split?.('\0')[0] || '' + })); + +console.log(`=== MODELS (${models.length}) ===`); +models.forEach((m, i) => { + console.log(` [${i}] ID=${m.id}, name="${m.name}", type="${m.type}"`); +}); + +// Find AnimationCurveNodes +const curveNodes = objectsNode.children + .filter(n => n.name === 'AnimationCurveNode') + .map(n => { + const id = n.properties[0]; + const name = n.properties[1]?.split?.('\0')[0] || ''; + return { id, name }; + }); + +console.log(`\n=== ANIMATION CURVE NODES (${curveNodes.length}) ===`); + +// Find which models each AnimationCurveNode targets +const curveNodeTargets = new Map(); +for (const conn of connections) { + if (conn.type === 'OP' && conn.property?.includes('Lcl')) { + // AnimationCurveNode -> Model connection + const curveNode = curveNodes.find(cn => cn.id === conn.fromId); + const model = models.find(m => m.id === conn.toId); + if (curveNode && model) { + const modelIndex = models.indexOf(model); + if (!curveNodeTargets.has(conn.toId)) { + curveNodeTargets.set(conn.toId, { + modelId: conn.toId, + modelIndex, + modelName: model.name, + properties: [] + }); + } + curveNodeTargets.get(conn.toId).properties.push({ + curveNodeId: curveNode.id, + curveNodeName: curveNode.name, + property: conn.property + }); + } + } +} + +console.log(`Animation targets ${curveNodeTargets.size} models:`); +for (const [modelId, info] of curveNodeTargets) { + console.log(` Model[${info.modelIndex}] "${info.modelName}" ID=${modelId}:`); + for (const p of info.properties) { + console.log(` - ${p.property} (CurveNode: ${p.curveNodeName})`); + } +} + +// Find Deformers (Clusters) +const clusters = objectsNode.children + .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Cluster' + })); + +console.log(`\n=== CLUSTERS (Skin Deformers) (${clusters.length}) ===`); + +// Find which models each Cluster is linked to (via Cluster -> Model connection) +const clusterToBone = new Map(); + +// First, let's see all connections involving clusters +console.log(`\nAll connections involving clusters (first 20):`); +let clusterConnCount = 0; +for (const conn of connections) { + const clusterAsFrom = clusters.find(c => c.id === conn.fromId); + const clusterAsTo = clusters.find(c => c.id === conn.toId); + if (clusterAsFrom || clusterAsTo) { + if (clusterConnCount < 20) { + const fromName = clusterAsFrom?.name || models.find(m => m.id === conn.fromId)?.name || `ID=${conn.fromId}`; + const toName = clusterAsTo?.name || models.find(m => m.id === conn.toId)?.name || `ID=${conn.toId}`; + console.log(` [${conn.type}] ${fromName} -> ${toName} (prop: ${conn.property || 'none'})`); + } + clusterConnCount++; + } +} +console.log(`Total cluster connections: ${clusterConnCount}`); + +// Try both directions for Cluster <-> Model connections +for (const conn of connections) { + if (conn.type === 'OO') { + // Cluster -> Model + const clusterFrom = clusters.find(c => c.id === conn.fromId); + const modelTo = models.find(m => m.id === conn.toId); + if (clusterFrom && modelTo) { + clusterToBone.set(clusterFrom.id, { + clusterId: clusterFrom.id, + clusterName: clusterFrom.name, + boneModelId: conn.toId, + boneModelIndex: models.indexOf(modelTo), + boneModelName: modelTo.name + }); + } + + // Model -> Cluster (reversed) + const modelFrom = models.find(m => m.id === conn.fromId); + const clusterTo = clusters.find(c => c.id === conn.toId); + if (modelFrom && clusterTo) { + clusterToBone.set(clusterTo.id, { + clusterId: clusterTo.id, + clusterName: clusterTo.name, + boneModelId: conn.fromId, + boneModelIndex: models.indexOf(modelFrom), + boneModelName: modelFrom.name + }); + } + } +} + +console.log(`Cluster -> Bone mappings (${clusterToBone.size}):`); +for (const [clusterId, info] of clusterToBone) { + const hasAnimation = curveNodeTargets.has(info.boneModelId); + console.log(` Cluster "${info.clusterName}" -> Model[${info.boneModelIndex}] "${info.boneModelName}" ${hasAnimation ? '✓ HAS ANIMATION' : '✗ NO ANIMATION'}`); +} + +// Summary +console.log(`\n=== SUMMARY ===`); +const animatedModels = [...curveNodeTargets.keys()]; +const boneModels = [...clusterToBone.values()].map(b => b.boneModelId); + +const bonesWithAnimation = boneModels.filter(id => curveNodeTargets.has(id)); +const bonesWithoutAnimation = boneModels.filter(id => !curveNodeTargets.has(id)); + +console.log(`Total animated models: ${animatedModels.length}`); +console.log(`Total bone models: ${boneModels.length}`); +console.log(`Bones WITH animation: ${bonesWithAnimation.length}`); +console.log(`Bones WITHOUT animation: ${bonesWithoutAnimation.length}`); + +if (bonesWithoutAnimation.length > 0) { + console.log(`\nBones missing animation:`); + for (const id of bonesWithoutAnimation.slice(0, 10)) { + const info = [...clusterToBone.values()].find(b => b.boneModelId === id); + console.log(` - Model[${info.boneModelIndex}] "${info.boneModelName}"`); + } + if (bonesWithoutAnimation.length > 10) { + console.log(` ... and ${bonesWithoutAnimation.length - 10} more`); + } +} + +console.log('\nDone!'); diff --git a/scripts/debug-runtime-anim.mjs b/scripts/debug-runtime-anim.mjs new file mode 100644 index 00000000..5d0d8048 --- /dev/null +++ b/scripts/debug-runtime-anim.mjs @@ -0,0 +1,564 @@ +/** + * Debug Runtime Animation Flow + * 调试运行时动画流程 + * + * This script mimics exactly what ModelPreview3D does when rendering + * and outputs detailed debug info at each step. + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const FBX_TIME_SECOND = 46186158000n; +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Debug Runtime Animation: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models with their transforms +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => { + const position = [0, 0, 0]; + const rotation = [0, 0, 0]; + const scale = [1, 1, 1]; + const preRotation = null; + + for (const child of n.children) { + if (child.name === 'Properties70') { + for (const prop of child.children) { + if (prop.properties[0] === 'Lcl Translation') { + position[0] = prop.properties[4]; + position[1] = prop.properties[5]; + position[2] = prop.properties[6]; + } else if (prop.properties[0] === 'Lcl Rotation') { + rotation[0] = prop.properties[4]; + rotation[1] = prop.properties[5]; + rotation[2] = prop.properties[6]; + } else if (prop.properties[0] === 'Lcl Scaling') { + scale[0] = prop.properties[4]; + scale[1] = prop.properties[5]; + scale[2] = prop.properties[6]; + } + } + } + } + + return { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model', + position, + rotation, + scale, + preRotation + }; + }); + +console.log(`Models: ${models.length}`); + +// Parse Clusters with TransformLink +const clusters = objectsNode.children + .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') + .map(n => { + const cluster = { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Cluster', + transformLink: null + }; + + for (const child of n.children) { + if (child.name === 'TransformLink') { + const data = child.properties[0]?.data; + if (data && data.length === 16) { + cluster.transformLink = new Float32Array(data); + } + } + } + + return cluster; + }); + +// Build cluster to bone mapping +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) { + clusterToBone.set(cluster.id, conn.fromId); + } + } +} + +// Build model ID to index +const modelToIndex = new Map(); +models.forEach((m, i) => modelToIndex.set(m.id, i)); + +// Build parent relationships +const modelParent = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + if (modelToIndex.has(conn.fromId) && modelToIndex.has(conn.toId)) { + modelParent.set(conn.fromId, conn.toId); + } + } +} + +// Euler to quaternion (XYZ intrinsic) +function eulerToQuaternion(x, y, z) { + const cx = Math.cos(x / 2), sx = Math.sin(x / 2); + const cy = Math.cos(y / 2), sy = Math.sin(y / 2); + const cz = Math.cos(z / 2), sz = Math.sin(z / 2); + return [ + sx * cy * cz - cx * sy * sz, + cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, + cx * cy * cz + sx * sy * sz + ]; +} + +// Create transform matrix from position, rotation (quaternion), scale +function createTransformMatrix(position, rotation, scale) { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); +} + +// Invert matrix +function invertMatrix4(m) { + const out = new Float32Array(16); + const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; + const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; + const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; + const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; + + const b00 = m00 * m11 - m01 * m10; + const b01 = m00 * m12 - m02 * m10; + const b02 = m00 * m13 - m03 * m10; + const b03 = m01 * m12 - m02 * m11; + const b04 = m01 * m13 - m03 * m11; + const b05 = m02 * m13 - m03 * m12; + const b06 = m20 * m31 - m21 * m30; + const b07 = m20 * m32 - m22 * m30; + const b08 = m20 * m33 - m23 * m30; + const b09 = m21 * m32 - m22 * m31; + const b10 = m21 * m33 - m23 * m31; + const b11 = m22 * m33 - m23 * m32; + + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + if (Math.abs(det) < 1e-8) return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); + + det = 1.0 / det; + + out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; + out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; + out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; + out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; + out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; + out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; + out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; + out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; + out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; + out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; + out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; + out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; + out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; + out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; + out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; + out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; + + return out; +} + +// Multiply matrices +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +function identity() { + return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); +} + +// Build skeleton (simulating FBXLoader.buildSkeletonData) +const joints = []; +const boneModelIdToJointIndex = new Map(); + +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const nodeIndex = modelToIndex.get(boneModelId); + if (nodeIndex === undefined) continue; + + const model = models[nodeIndex]; + const jointIndex = joints.length; + boneModelIdToJointIndex.set(boneModelId, jointIndex); + + const inverseBindMatrix = cluster.transformLink + ? invertMatrix4(cluster.transformLink) + : identity(); + + joints.push({ + name: model.name, + nodeIndex, + parentIndex: -1, + inverseBindMatrix + }); +} + +// Set parent indices +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const jointIndex = boneModelIdToJointIndex.get(boneModelId); + if (jointIndex === undefined) continue; + + let parentModelId = modelParent.get(boneModelId); + while (parentModelId) { + const parentJointIndex = boneModelIdToJointIndex.get(parentModelId); + if (parentJointIndex !== undefined) { + joints[jointIndex].parentIndex = parentJointIndex; + break; + } + parentModelId = modelParent.get(parentModelId); + } +} + +console.log(`Skeleton joints: ${joints.length}`); + +// Build nodes (simulating FBXLoader node building) +const nodes = models.map(model => { + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + const quat = eulerToQuaternion(rx, ry, rz); + + return { + name: model.name, + transform: { + position: model.position, + rotation: quat, + scale: model.scale + } + }; +}); + +console.log(`\n=== KEY DEBUG INFO ===`); + +// Check a specific joint +const jointToDebug = 0; +const joint = joints[jointToDebug]; +const node = nodes[joint.nodeIndex]; + +console.log(`\nJoint[${jointToDebug}] "${joint.name}":`); +console.log(` nodeIndex: ${joint.nodeIndex}`); +console.log(` parentIndex: ${joint.parentIndex}`); +console.log(` node.transform.position: [${node.transform.position.join(', ')}]`); +console.log(` node.transform.rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); +console.log(` node.transform.scale: [${node.transform.scale.join(', ')}]`); + +// Create local matrix from node transform +const localMatrix = createTransformMatrix( + node.transform.position, + node.transform.rotation, + node.transform.scale +); +console.log(`\n localMatrix (from node.transform):`); +console.log(` [${localMatrix.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${localMatrix.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${localMatrix.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${localMatrix.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); + +// Show inverseBindMatrix +console.log(`\n inverseBindMatrix:`); +console.log(` [${joint.inverseBindMatrix.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${joint.inverseBindMatrix.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${joint.inverseBindMatrix.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${joint.inverseBindMatrix.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); + +// Calculate skinMatrix = worldMatrix * inverseBindMatrix (for root, worldMatrix = localMatrix) +const skinMatrix = multiplyMatrices(localMatrix, joint.inverseBindMatrix); +console.log(`\n skinMatrix = worldMatrix * IBM (should be near identity at bind pose):`); +console.log(` [${skinMatrix.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${skinMatrix.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${skinMatrix.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); +console.log(` [${skinMatrix.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); + +// Check if skinMatrix is identity +function isNearIdentity(m, tol = 0.001) { + const id = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; + for (let i = 0; i < 16; i++) { + if (Math.abs(m[i] - id[i]) > tol) return false; + } + return true; +} + +console.log(`\n Is skinMatrix near identity? ${isNearIdentity(skinMatrix) ? 'YES ✅' : 'NO ❌'}`); + +// Now simulate what happens when no animation is playing +// In ModelPreview3D, when there's no animTransform for a joint, it uses node.transform +console.log(`\n=== SIMULATING ModelPreview3D calculateBoneMatrices (no animation) ===`); + +// This is what ModelPreview3D does: +// 1. For each joint, get animTransform or fall back to node.transform +// 2. Create localMatrix from pos/rot/scale +// 3. Calculate worldMatrix = parent.worldMatrix * localMatrix +// 4. Calculate skinMatrix = worldMatrix * inverseBindMatrix + +const worldMatrices = new Array(joints.length); +const skinMatrices = new Array(joints.length); + +// Build processing order +const processingOrder = []; +const processed = new Set(); + +function addJoint(jointIndex) { + if (processed.has(jointIndex)) return; + const joint = joints[jointIndex]; + if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { + addJoint(joint.parentIndex); + } + processingOrder.push(jointIndex); + processed.add(jointIndex); +} + +for (let i = 0; i < joints.length; i++) { + addJoint(i); +} + +for (const jointIndex of processingOrder) { + const joint = joints[jointIndex]; + const node = nodes[joint.nodeIndex]; + + const pos = node.transform.position; + const rot = node.transform.rotation; + const scl = node.transform.scale; + + const localMatrix = createTransformMatrix(pos, rot, scl); + + if (joint.parentIndex >= 0) { + worldMatrices[jointIndex] = multiplyMatrices(worldMatrices[joint.parentIndex], localMatrix); + } else { + worldMatrices[jointIndex] = localMatrix; + } + + skinMatrices[jointIndex] = multiplyMatrices(worldMatrices[jointIndex], joint.inverseBindMatrix); +} + +// Count how many are near identity +let identityCount = 0; +let maxDiff = 0; + +for (let i = 0; i < joints.length; i++) { + const sm = skinMatrices[i]; + const id = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; + let diff = 0; + for (let j = 0; j < 16; j++) { + diff = Math.max(diff, Math.abs(sm[j] - id[j])); + } + if (diff < 0.001) identityCount++; + if (diff > maxDiff) maxDiff = diff; +} + +console.log(`\nAt bind pose (no animation):`); +console.log(` Identity matrices: ${identityCount}/${joints.length}`); +console.log(` Max diff from identity: ${maxDiff.toFixed(6)}`); + +if (identityCount !== joints.length) { + console.log(`\n ⚠️ WARNING: Not all skin matrices are identity at bind pose!`); + console.log(` This suggests the node.transform doesn't match the TransformLink.`); + + // Show first non-identity matrix + for (let i = 0; i < joints.length; i++) { + const sm = skinMatrices[i]; + const id = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; + let diff = 0; + for (let j = 0; j < 16; j++) { + diff = Math.max(diff, Math.abs(sm[j] - id[j])); + } + if (diff >= 0.001) { + const joint = joints[i]; + const node = nodes[joint.nodeIndex]; + console.log(`\n First non-identity: Joint[${i}] "${joint.name}"`); + console.log(` node.transform: pos=[${node.transform.position.join(',')}]`); + console.log(` skinMatrix:`); + console.log(` [${sm.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${sm.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${sm.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); + console.log(` [${sm.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); + break; + } + } +} else { + console.log(` ✅ All skin matrices are identity - bind pose is correct!`); +} + +console.log('\nDone!'); diff --git a/scripts/simple-fbx-test.mjs b/scripts/simple-fbx-test.mjs new file mode 100644 index 00000000..bb2a644f --- /dev/null +++ b/scripts/simple-fbx-test.mjs @@ -0,0 +1,68 @@ +/** + * Simple FBX Test + * 简单 FBX 测试 + */ + +import { readFileSync } from 'fs'; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; +console.log(`Testing: ${filePath}`); + +async function main() { + // Dynamic import to handle the module + const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); + + const binaryData = readFileSync(filePath); + const loader = new FBXLoader(); + + const context = { + metadata: { + path: filePath, + name: filePath.split(/[\\/]/).pop(), + type: 'model/fbx', + guid: '', + size: binaryData.length, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null + }; + + const content = { + type: 'binary', + binary: binaryData.buffer + }; + + try { + const asset = await loader.parse(content, context); + + console.log(`\nMeshes: ${asset.meshes?.length || 0}`); + console.log(`Nodes: ${asset.nodes?.length || 0}`); + console.log(`Skeleton joints: ${asset.skeleton?.joints?.length || 0}`); + + if (asset.skeleton && asset.skeleton.joints.length > 0) { + console.log(`\nFirst 3 joints:`); + for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { + const joint = asset.skeleton.joints[i]; + const node = asset.nodes?.[joint.nodeIndex]; + console.log(` [${i}] "${joint.name}" nodeIndex=${joint.nodeIndex}`); + if (node) { + console.log(` position: [${node.transform.position.map(v => v.toFixed(2)).join(', ')}]`); + console.log(` rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); + } + } + } + + console.log('\nDone!'); + } catch (error) { + console.error('Error:', error.message); + console.error(error.stack); + } +} + +main(); diff --git a/scripts/test-animation-t0.mjs b/scripts/test-animation-t0.mjs new file mode 100644 index 00000000..b85b6069 --- /dev/null +++ b/scripts/test-animation-t0.mjs @@ -0,0 +1,143 @@ +/** + * Test Animation at t=0 + * 测试 t=0 时的动画值 + * + * Compare animation values at t=0 with node.transform + */ + +import { readFileSync } from 'fs'; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; +console.log(`Testing animation at t=0: ${filePath}\n`); + +async function main() { + const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); + + const binaryData = readFileSync(filePath); + const loader = new FBXLoader(); + + const context = { + metadata: { + path: filePath, + name: filePath.split(/[\\/]/).pop(), + type: 'model/fbx', + guid: '', + size: binaryData.length, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null + }; + + const content = { + type: 'binary', + binary: binaryData.buffer + }; + + const asset = await loader.parse(content, context); + + if (!asset.animations || asset.animations.length === 0) { + console.log('No animation data!'); + return; + } + + const clip = asset.animations[0]; + const nodes = asset.nodes; + const skeleton = asset.skeleton; + + console.log(`Animation: "${clip.name}", duration: ${clip.duration}s`); + console.log(`Channels: ${clip.channels.length}, Samplers: ${clip.samplers.length}`); + + // Sample animation at t=0 + function sampleAtT0(sampler, componentCount) { + if (!sampler.output || sampler.output.length === 0) return null; + const result = []; + for (let i = 0; i < componentCount; i++) { + result.push(sampler.output[i]); + } + return result; + } + + // Get animated transforms at t=0 + const animTransforms = new Map(); + for (const channel of clip.channels) { + const sampler = clip.samplers[channel.samplerIndex]; + if (!sampler) continue; + + const nodeIndex = channel.target.nodeIndex; + const path = channel.target.path; + + let value; + if (path === 'rotation') { + value = sampleAtT0(sampler, 4); + } else { + value = sampleAtT0(sampler, 3); + } + if (!value) continue; + + if (!animTransforms.has(nodeIndex)) { + animTransforms.set(nodeIndex, {}); + } + const t = animTransforms.get(nodeIndex); + if (path === 'translation') t.position = value; + else if (path === 'rotation') t.rotation = value; + else if (path === 'scale') t.scale = value; + } + + console.log(`\nAnimated node count at t=0: ${animTransforms.size}`); + + // Compare with node.transform for first few skeleton joints + if (skeleton) { + console.log(`\n=== COMPARING ANIMATION vs NODE.TRANSFORM ===\n`); + + let matchCount = 0; + let mismatchCount = 0; + const mismatches = []; + + for (let i = 0; i < skeleton.joints.length; i++) { + const joint = skeleton.joints[i]; + const node = nodes[joint.nodeIndex]; + const animT = animTransforms.get(joint.nodeIndex); + + if (!node || !animT) continue; + + // Compare rotation (most important) + const nodeRot = node.transform.rotation; + const animRot = animT.rotation; + + if (animRot) { + const rotMatch = nodeRot.every((v, idx) => Math.abs(v - animRot[idx]) < 0.001); + if (rotMatch) { + matchCount++; + } else { + mismatchCount++; + mismatches.push({ jointIndex: i, name: joint.name, nodeRot, animRot }); + } + } + } + + console.log(`Rotation matches: ${matchCount}/${matchCount + mismatchCount}`); + + if (mismatches.length > 0) { + console.log(`\n❌ MISMATCHES found!`); + console.log(`First 5 mismatches:`); + for (let i = 0; i < 5 && i < mismatches.length; i++) { + const m = mismatches[i]; + console.log(`\n Joint[${m.jointIndex}] "${m.name}":`); + console.log(` node.rotation: [${m.nodeRot.map(v => v.toFixed(4)).join(', ')}]`); + console.log(` anim.rotation: [${m.animRot.map(v => v.toFixed(4)).join(', ')}]`); + } + } else { + console.log(`\n✅ All rotations match at t=0!`); + } + } + + console.log('\nDone!'); +} + +main().catch(console.error); diff --git a/scripts/test-animation-times.mjs b/scripts/test-animation-times.mjs new file mode 100644 index 00000000..f42cbf83 --- /dev/null +++ b/scripts/test-animation-times.mjs @@ -0,0 +1,309 @@ +/** + * Test Animation at Different Times + * 测试不同时间点的动画 + * + * Verify animation is producing different bone matrices at different times + */ + +import { readFileSync } from 'fs'; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; +console.log(`Testing animation at different times: ${filePath}\n`); + +// Matrix math utilities +function createTransformMatrix(position, rotation, scale) { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); +} + +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +function identity() { + return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); +} + +function slerpQuaternion(q1, q2, t) { + let dot = q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3]; + if (dot < 0) { + q2 = [-q2[0], -q2[1], -q2[2], -q2[3]]; + dot = -dot; + } + if (dot > 0.9995) { + const result = [ + q1[0] + t * (q2[0] - q1[0]), + q1[1] + t * (q2[1] - q1[1]), + q1[2] + t * (q2[2] - q1[2]), + q1[3] + t * (q2[3] - q1[3]) + ]; + const len = Math.sqrt(result[0] * result[0] + result[1] * result[1] + result[2] * result[2] + result[3] * result[3]); + return [result[0] / len, result[1] / len, result[2] / len, result[3] / len]; + } + const theta0 = Math.acos(dot); + const theta = theta0 * t; + const sinTheta = Math.sin(theta); + const sinTheta0 = Math.sin(theta0); + const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0; + const s1 = sinTheta / sinTheta0; + return [ + s0 * q1[0] + s1 * q2[0], + s0 * q1[1] + s1 * q2[1], + s0 * q1[2] + s1 * q2[2], + s0 * q1[3] + s1 * q2[3] + ]; +} + +function sampleSampler(sampler, time, path) { + const input = sampler.input; + const output = sampler.output; + if (!input || !output || input.length === 0) return null; + + const minTime = input[0]; + const maxTime = input[input.length - 1]; + time = Math.max(minTime, Math.min(maxTime, time)); + + let i0 = 0; + for (let i = 0; i < input.length - 1; i++) { + if (time >= input[i] && time <= input[i + 1]) { + i0 = i; + break; + } + if (time < input[i]) break; + i0 = i; + } + const i1 = Math.min(i0 + 1, input.length - 1); + + const t0 = input[i0]; + const t1 = input[i1]; + const t = t1 > t0 ? (time - t0) / (t1 - t0) : 0; + + const componentCount = path === 'rotation' ? 4 : 3; + + if (path === 'rotation') { + const q0 = [output[i0 * 4], output[i0 * 4 + 1], output[i0 * 4 + 2], output[i0 * 4 + 3]]; + const q1 = [output[i1 * 4], output[i1 * 4 + 1], output[i1 * 4 + 2], output[i1 * 4 + 3]]; + return slerpQuaternion(q0, q1, t); + } + + const result = []; + for (let c = 0; c < componentCount; c++) { + const v0 = output[i0 * componentCount + c]; + const v1 = output[i1 * componentCount + c]; + result.push(v0 + (v1 - v0) * t); + } + return result; +} + +function sampleAnimation(clip, time, nodes) { + const nodeTransforms = new Map(); + for (const channel of clip.channels) { + const sampler = clip.samplers[channel.samplerIndex]; + if (!sampler) continue; + + const nodeIndex = channel.target.nodeIndex; + const path = channel.target.path; + const value = sampleSampler(sampler, time, path); + if (!value) continue; + + if (!nodeTransforms.has(nodeIndex)) { + nodeTransforms.set(nodeIndex, {}); + } + const t = nodeTransforms.get(nodeIndex); + if (path === 'translation') t.position = value; + else if (path === 'rotation') t.rotation = value; + else if (path === 'scale') t.scale = value; + } + return nodeTransforms; +} + +function calculateBoneMatrices(skeleton, nodes, animTransforms) { + const { joints } = skeleton; + const boneCount = joints.length; + const localMatrices = new Array(boneCount); + const worldMatrices = new Array(boneCount); + const skinMatrices = new Array(boneCount); + + const processed = new Set(); + const processingOrder = []; + + function addJoint(jointIndex) { + if (processed.has(jointIndex)) return; + const joint = joints[jointIndex]; + if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { + addJoint(joint.parentIndex); + } + processingOrder.push(jointIndex); + processed.add(jointIndex); + } + + for (let i = 0; i < boneCount; i++) addJoint(i); + + for (const jointIndex of processingOrder) { + const joint = joints[jointIndex]; + const node = nodes[joint.nodeIndex]; + + if (!node) { + localMatrices[jointIndex] = identity(); + worldMatrices[jointIndex] = identity(); + skinMatrices[jointIndex] = identity(); + continue; + } + + const animTransform = animTransforms.get(joint.nodeIndex); + const pos = animTransform?.position || node.transform.position; + const rot = animTransform?.rotation || node.transform.rotation; + const scl = animTransform?.scale || node.transform.scale; + + localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); + + if (joint.parentIndex >= 0) { + worldMatrices[jointIndex] = multiplyMatrices( + worldMatrices[joint.parentIndex], + localMatrices[jointIndex] + ); + } else { + worldMatrices[jointIndex] = localMatrices[jointIndex]; + } + + skinMatrices[jointIndex] = multiplyMatrices( + worldMatrices[jointIndex], + joint.inverseBindMatrix + ); + } + + return skinMatrices; +} + +function matrixDifference(a, b) { + let maxDiff = 0; + for (let i = 0; i < 16; i++) { + maxDiff = Math.max(maxDiff, Math.abs(a[i] - b[i])); + } + return maxDiff; +} + +async function main() { + const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); + + const binaryData = readFileSync(filePath); + const loader = new FBXLoader(); + + const context = { + metadata: { + path: filePath, + name: filePath.split(/[\\/]/).pop(), + type: 'model/fbx', + guid: '', + size: binaryData.length, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null + }; + + const content = { + type: 'binary', + binary: binaryData.buffer + }; + + const asset = await loader.parse(content, context); + + if (!asset.skeleton || !asset.animations?.length) { + console.log('No skeleton or animation data!'); + return; + } + + const clip = asset.animations[0]; + const nodes = asset.nodes; + const skeleton = asset.skeleton; + + console.log(`Animation: "${clip.name}", duration: ${clip.duration}s`); + console.log(`Joints: ${skeleton.joints.length}`); + + // Test at different times + const times = [0, clip.duration * 0.25, clip.duration * 0.5, clip.duration * 0.75, clip.duration]; + + let prevMatrices = null; + for (const time of times) { + const animTransforms = sampleAnimation(clip, time, nodes); + const skinMatrices = calculateBoneMatrices(skeleton, nodes, animTransforms); + + if (prevMatrices) { + // Count how many bones changed + let changedCount = 0; + let maxChange = 0; + for (let i = 0; i < skinMatrices.length; i++) { + const diff = matrixDifference(skinMatrices[i], prevMatrices[i]); + if (diff > 0.001) { + changedCount++; + maxChange = Math.max(maxChange, diff); + } + } + console.log(`t=${time.toFixed(2)}s: ${changedCount}/${skinMatrices.length} bones changed, maxChange=${maxChange.toFixed(4)}`); + } else { + // Check identity at t=0 + let identityCount = 0; + const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + for (const m of skinMatrices) { + let isId = true; + for (let i = 0; i < 16; i++) { + if (Math.abs(m[i] - id[i]) > 0.01) { + isId = false; + break; + } + } + if (isId) identityCount++; + } + console.log(`t=${time.toFixed(2)}s (bind pose): ${identityCount}/${skinMatrices.length} identity matrices`); + } + + prevMatrices = skinMatrices.map(m => new Float32Array(m)); + } + + // Show specific bone at different times + const testJointIndex = 5; // Pick a bone that should animate + const joint = skeleton.joints[testJointIndex]; + console.log(`\n=== Joint[${testJointIndex}] "${joint.name}" at different times ===`); + + for (const time of times) { + const animTransforms = sampleAnimation(clip, time, nodes); + const nodeTransform = animTransforms.get(joint.nodeIndex); + + if (nodeTransform?.rotation) { + const rot = nodeTransform.rotation; + console.log(`t=${time.toFixed(2)}s: rotation=[${rot.map(v => v.toFixed(4)).join(', ')}]`); + } else { + console.log(`t=${time.toFixed(2)}s: using node.transform (no animation data)`); + } + } + + console.log('\nDone!'); +} + +main().catch(console.error); diff --git a/scripts/test-fbx-animation.mjs b/scripts/test-fbx-animation.mjs new file mode 100644 index 00000000..50296027 --- /dev/null +++ b/scripts/test-fbx-animation.mjs @@ -0,0 +1,741 @@ +/** + * FBX Animation Pipeline Test Script + * 完整模拟 FBX 动画管线:解析 -> 采样 -> 骨骼矩阵计算 + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; +console.log(`=== FBX Animation Pipeline Test ===\n`); +console.log(`File: ${filePath}\n`); + +// ===== FBX Parser ===== +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset++]); + switch (typeCode) { + case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; + case 'C': properties.push(buffer[offset++] !== 0); break; + case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; + case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; + case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; + case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; + case 'S': case 'R': { + const len = view.getUint32(offset, true); offset += 4; + properties.push(typeCode === 'S' ? new TextDecoder().decode(buffer.slice(offset, offset + len)) : buffer.slice(offset, offset + len)); + offset += len; + break; + } + case 'f': case 'd': case 'l': case 'i': case 'b': { + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + let dataView = view; + let dataOffset = offset; + if (encoding === 1) { + const decompressed = pako.inflate(buffer.slice(offset, offset + compressedLen)); + dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + dataOffset = 0; + offset += compressedLen; + } else { + offset += arrayLen * elemSize; + } + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(dataOffset + i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(dataOffset + i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(dataOffset + i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(dataOffset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + break; + } + default: offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0]?.split?.('\0')[0] || c.properties[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models +const models = []; +const modelIdToIndex = new Map(); +for (const node of objectsNode.children) { + if (node.name === 'Model') { + const id = node.properties[0]; + const name = node.properties[1]?.split?.('\0')[0] || 'Model'; + const type = node.properties[2]?.split?.('\0')[0] || ''; + + // Parse properties + let position = [0, 0, 0], rotation = [0, 0, 0], scale = [1, 1, 1], preRotation = null; + const props70 = node.children.find(c => c.name === 'Properties70'); + if (props70) { + for (const p of props70.children) { + if (p.name === 'P') { + const propName = p.properties[0]?.split?.('\0')[0]; + if (propName === 'Lcl Translation') position = [p.properties[4], p.properties[5], p.properties[6]]; + else if (propName === 'Lcl Rotation') rotation = [p.properties[4], p.properties[5], p.properties[6]]; + else if (propName === 'Lcl Scaling') scale = [p.properties[4], p.properties[5], p.properties[6]]; + else if (propName === 'PreRotation') preRotation = [p.properties[4], p.properties[5], p.properties[6]]; + } + } + } + + modelIdToIndex.set(id, models.length); + models.push({ id, name, type, position, rotation, scale, preRotation }); + } +} + +// Parse Deformers (Clusters) +const clusters = []; +for (const node of objectsNode.children) { + if (node.name === 'Deformer' && node.properties[2]?.split?.('\0')[0] === 'Cluster') { + const id = node.properties[0]; + const name = node.properties[1]?.split?.('\0')[0] || 'Cluster'; + let transformLink = null; + for (const child of node.children) { + if (child.name === 'TransformLink') { + const arr = child.properties[0]?.data || child.properties[0]; + if (arr && arr.length === 16) { + transformLink = new Float32Array(arr); + } + } + } + clusters.push({ id, name, transformLink }); + } +} + +// Build cluster -> bone mapping +// In FBX, Model (bone) -> Cluster connection means the cluster deforms that bone +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + // Try: cluster is fromId, bone is toId + let cluster = clusters.find(c => c.id === conn.fromId); + let boneModel = cluster ? models.find(m => m.id === conn.toId) : null; + + // Also try: bone is fromId, cluster is toId (reversed) + if (!cluster || !boneModel) { + cluster = clusters.find(c => c.id === conn.toId); + boneModel = cluster ? models.find(m => m.id === conn.fromId) : null; + } + + if (cluster && boneModel && boneModel.type === 'LimbNode') { + clusterToBone.set(cluster.id, { + clusterId: cluster.id, + boneModelId: boneModel.id, + boneModelIndex: modelIdToIndex.get(boneModel.id), + boneName: boneModel.name + }); + } + } +} +console.log(`Cluster -> Bone mappings: ${clusterToBone.size}`); +if (clusterToBone.size === 0) { + console.log(`WARNING: No cluster-bone mappings found! Checking connection types...`); + // Debug: show some cluster-related connections + let count = 0; + for (const conn of connections) { + const isClusterFrom = clusters.some(c => c.id === conn.fromId); + const isClusterTo = clusters.some(c => c.id === conn.toId); + if (isClusterFrom || isClusterTo) { + if (count++ < 10) { + console.log(` [${conn.type}] ${conn.fromId} -> ${conn.toId} (prop: ${conn.property || 'none'})`); + } + } + } +} + +// Parse AnimationCurveNodes and Curves +const curveNodes = new Map(); +const curves = new Map(); + +for (const node of objectsNode.children) { + if (node.name === 'AnimationCurveNode') { + const id = node.properties[0]; + const name = node.properties[1]?.split?.('\0')[0] || ''; + curveNodes.set(id, { id, name, attribute: name, targetModelId: null, curves: [] }); + } + if (node.name === 'AnimationCurve') { + const id = node.properties[0]; + let keyTimes = [], keyValues = []; + for (const child of node.children) { + if (child.name === 'KeyTime') { + const arr = child.properties[0]?.data || child.properties[0]; + keyTimes = arr.map(t => Number(t) / 46186158000); + } + if (child.name === 'KeyValueFloat') { + keyValues = child.properties[0]?.data || child.properties[0]; + } + } + curves.set(id, { id, keyTimes, keyValues, componentIndex: 0 }); + } +} + +// Link curves to curveNodes and curveNodes to models +for (const conn of connections) { + if (conn.type === 'OP') { + const curveNode = curveNodes.get(conn.fromId); + if (curveNode && conn.property?.includes('Lcl')) { + curveNode.targetModelId = conn.toId; + if (conn.property.includes('Translation')) curveNode.attribute = 'T'; + else if (conn.property.includes('Rotation')) curveNode.attribute = 'R'; + else if (conn.property.includes('Scaling')) curveNode.attribute = 'S'; + } + } + if (conn.type === 'OP' || conn.type === 'OO') { + const curve = curves.get(conn.fromId); + const curveNode = curveNodes.get(conn.toId); + if (curve && curveNode) { + if (conn.property === 'd|X') curve.componentIndex = 0; + else if (conn.property === 'd|Y') curve.componentIndex = 1; + else if (conn.property === 'd|Z') curve.componentIndex = 2; + curveNode.curves.push(curve); + } + } +} + +// ===== Build Animation Clips ===== +function eulerToQuaternion(rx, ry, rz) { + const cx = Math.cos(rx / 2), sx = Math.sin(rx / 2); + const cy = Math.cos(ry / 2), sy = Math.sin(ry / 2); + const cz = Math.cos(rz / 2), sz = Math.sin(rz / 2); + return [ + sx * cy * cz - cx * sy * sz, + cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, + cx * cy * cz + sx * sy * sz + ]; +} + +function multiplyQuaternion(a, b) { + return [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2] + ]; +} + +function sampleCurve(curve, time) { + const { keyTimes, keyValues } = curve; + if (!keyTimes.length) return 0; + if (time <= keyTimes[0]) return keyValues[0]; + if (time >= keyTimes[keyTimes.length - 1]) return keyValues[keyValues.length - 1]; + + for (let i = 0; i < keyTimes.length - 1; i++) { + if (time >= keyTimes[i] && time <= keyTimes[i + 1]) { + const t = (time - keyTimes[i]) / (keyTimes[i + 1] - keyTimes[i]); + return keyValues[i] + (keyValues[i + 1] - keyValues[i]) * t; + } + } + return keyValues[keyValues.length - 1]; +} + +// Build animation samplers +const animationSamplers = []; +const animationChannels = []; + +for (const [id, cn] of curveNodes) { + if (!cn.targetModelId || cn.curves.length === 0) continue; + + const nodeIndex = modelIdToIndex.get(cn.targetModelId); + if (nodeIndex === undefined) continue; + + const xCurve = cn.curves.find(c => c.componentIndex === 0); + const yCurve = cn.curves.find(c => c.componentIndex === 1); + const zCurve = cn.curves.find(c => c.componentIndex === 2); + + const refCurve = [xCurve, yCurve, zCurve].filter(Boolean).reduce((a, b) => + a.keyTimes.length > b.keyTimes.length ? a : b); + + const keyCount = refCurve.keyTimes.length; + const input = refCurve.keyTimes; + + // Get model for PreRotation + const model = models[nodeIndex]; + let preRotQuat = null; + if (model?.preRotation) { + const [prx, pry, prz] = model.preRotation.map(v => v * Math.PI / 180); + preRotQuat = eulerToQuaternion(prx, pry, prz); + } + + let output, path; + if (cn.attribute === 'R') { + path = 'rotation'; + output = new Float32Array(keyCount * 4); + for (let i = 0; i < keyCount; i++) { + const t = input[i]; + const rx = (xCurve ? sampleCurve(xCurve, t) : 0) * Math.PI / 180; + const ry = (yCurve ? sampleCurve(yCurve, t) : 0) * Math.PI / 180; + const rz = (zCurve ? sampleCurve(zCurve, t) : 0) * Math.PI / 180; + let q = eulerToQuaternion(rx, ry, rz); + if (preRotQuat) q = multiplyQuaternion(preRotQuat, q); + output[i * 4] = q[0]; output[i * 4 + 1] = q[1]; + output[i * 4 + 2] = q[2]; output[i * 4 + 3] = q[3]; + } + } else if (cn.attribute === 'T') { + path = 'translation'; + output = new Float32Array(keyCount * 3); + for (let i = 0; i < keyCount; i++) { + const t = input[i]; + output[i * 3] = xCurve ? sampleCurve(xCurve, t) : 0; + output[i * 3 + 1] = yCurve ? sampleCurve(yCurve, t) : 0; + output[i * 3 + 2] = zCurve ? sampleCurve(zCurve, t) : 0; + } + } else { + path = 'scale'; + output = new Float32Array(keyCount * 3); + for (let i = 0; i < keyCount; i++) { + const t = input[i]; + output[i * 3] = xCurve ? sampleCurve(xCurve, t) : 1; + output[i * 3 + 1] = yCurve ? sampleCurve(yCurve, t) : 1; + output[i * 3 + 2] = zCurve ? sampleCurve(zCurve, t) : 1; + } + } + + const samplerIndex = animationSamplers.length; + animationSamplers.push({ input: Float32Array.from(input), output }); + animationChannels.push({ samplerIndex, target: { nodeIndex, path } }); +} + +const duration = Math.max(...animationSamplers.map(s => s.input[s.input.length - 1] || 0)); + +console.log(`=== Animation Data ===`); +console.log(`Channels: ${animationChannels.length}`); +console.log(`Duration: ${duration.toFixed(2)}s`); + +// ===== Build Skeleton ===== +const joints = []; +const boneModelIdToJointIndex = new Map(); + +for (const cluster of clusters) { + const boneInfo = clusterToBone.get(cluster.id); + if (!boneInfo) continue; + + const nodeIndex = boneInfo.boneModelIndex; + const model = models[nodeIndex]; + + const jointIndex = joints.length; + boneModelIdToJointIndex.set(boneInfo.boneModelId, jointIndex); + + // Invert TransformLink for inverseBindMatrix + let inverseBindMatrix = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); + if (cluster.transformLink) { + inverseBindMatrix = invertMatrix4(cluster.transformLink); + } + + joints.push({ + name: model?.name || `Joint_${jointIndex}`, + nodeIndex, + parentIndex: -1, + inverseBindMatrix + }); +} + +// Set parent indices +const modelParentMap = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const childIdx = modelIdToIndex.get(conn.fromId); + const parentIdx = modelIdToIndex.get(conn.toId); + if (childIdx !== undefined && parentIdx !== undefined) { + // fromId (child) -> toId (parent) + const childModel = models[childIdx]; + const parentModel = models[parentIdx]; + if (childModel && parentModel) { + modelParentMap.set(conn.fromId, conn.toId); + } + } + } +} + +for (let i = 0; i < joints.length; i++) { + const joint = joints[i]; + const boneModelId = [...boneModelIdToJointIndex.entries()].find(([k, v]) => v === i)?.[0]; + if (!boneModelId) continue; + + let parentModelId = modelParentMap.get(boneModelId); + while (parentModelId) { + const parentJointIdx = boneModelIdToJointIndex.get(parentModelId); + if (parentJointIdx !== undefined) { + joint.parentIndex = parentJointIdx; + break; + } + parentModelId = modelParentMap.get(parentModelId); + } +} + +console.log(`\n=== Skeleton ===`); +console.log(`Joints: ${joints.length}`); +console.log(`First 5 joints:`); +for (let i = 0; i < Math.min(5, joints.length); i++) { + const j = joints[i]; + console.log(` [${i}] "${j.name}" nodeIndex=${j.nodeIndex}, parent=${j.parentIndex}`); +} + +// Check animation channel targets vs skeleton joint nodeIndices +const animChannelNodeIndices = new Set(animationChannels.map(c => c.target.nodeIndex)); +const jointNodeIndices = new Set(joints.map(j => j.nodeIndex)); + +console.log(`\n=== Animation vs Skeleton Mapping ===`); +console.log(`Animation channel target nodes: ${animChannelNodeIndices.size}`); +console.log(`Skeleton joint nodes: ${jointNodeIndices.size}`); + +// Find intersection +const intersection = [...jointNodeIndices].filter(idx => animChannelNodeIndices.has(idx)); +console.log(`Joints with animation: ${intersection.length}/${joints.length}`); + +// Find joints without animation +const jointsWithoutAnim = joints.filter(j => !animChannelNodeIndices.has(j.nodeIndex)); +if (jointsWithoutAnim.length > 0) { + console.log(`Joints WITHOUT animation:`); + for (const j of jointsWithoutAnim.slice(0, 5)) { + console.log(` "${j.name}" nodeIndex=${j.nodeIndex}`); + } +} + +// ===== Test Animation Sampling ===== +console.log(`\n=== Animation Sampling Test ===`); + +function slerpQuaternion(q0, q1, t) { + let [x0, y0, z0, w0] = q0; + let [x1, y1, z1, w1] = q1; + let dot = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1; + if (dot < 0) { x1 = -x1; y1 = -y1; z1 = -z1; w1 = -w1; dot = -dot; } + if (dot > 0.9995) { + const r = [x0 + t * (x1 - x0), y0 + t * (y1 - y0), z0 + t * (z1 - z0), w0 + t * (w1 - w0)]; + const len = Math.sqrt(r[0]**2 + r[1]**2 + r[2]**2 + r[3]**2); + return [r[0]/len, r[1]/len, r[2]/len, r[3]/len]; + } + const theta0 = Math.acos(dot); + const theta = theta0 * t; + const sinTheta = Math.sin(theta); + const sinTheta0 = Math.sin(theta0); + const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0; + const s1 = sinTheta / sinTheta0; + return [s0*x0 + s1*x1, s0*y0 + s1*y1, s0*z0 + s1*z1, s0*w0 + s1*w1]; +} + +function sampleAnimation(time) { + const transforms = new Map(); + for (const channel of animationChannels) { + const sampler = animationSamplers[channel.samplerIndex]; + const { input, output } = sampler; + const { nodeIndex, path } = channel.target; + + // Find keyframes + let i0 = 0; + for (let i = 0; i < input.length - 1; i++) { + if (time >= input[i] && time <= input[i + 1]) { i0 = i; break; } + if (time < input[i]) break; + i0 = i; + } + const i1 = Math.min(i0 + 1, input.length - 1); + const t = input[i1] > input[i0] ? (time - input[i0]) / (input[i1] - input[i0]) : 0; + + let value; + if (path === 'rotation') { + const q0 = [output[i0*4], output[i0*4+1], output[i0*4+2], output[i0*4+3]]; + const q1 = [output[i1*4], output[i1*4+1], output[i1*4+2], output[i1*4+3]]; + value = slerpQuaternion(q0, q1, t); + } else { + const count = path === 'rotation' ? 4 : 3; + value = []; + for (let c = 0; c < count; c++) { + value.push(output[i0 * count + c] + (output[i1 * count + c] - output[i0 * count + c]) * t); + } + } + + if (!transforms.has(nodeIndex)) transforms.set(nodeIndex, {}); + transforms.get(nodeIndex)[path] = value; + } + return transforms; +} + +// Check animation data at different times +const testTimes = [0, 0.5, 1.0, 1.5, 2.0]; +for (const time of testTimes) { + const transforms = sampleAnimation(time); + + // Count how many joints have animation + let matchCount = 0; + for (const joint of joints) { + if (transforms.has(joint.nodeIndex)) matchCount++; + } + + console.log(`\nt=${time.toFixed(1)}s: ${transforms.size} node transforms, ${matchCount}/${joints.length} joints have animation`); + + // Sample first 3 joints + for (let i = 0; i < Math.min(3, joints.length); i++) { + const j = joints[i]; + const t = transforms.get(j.nodeIndex); + if (t) { + const pos = t.translation ? `[${t.translation.map(v => v.toFixed(2)).join(',')}]` : 'none'; + const rot = t.rotation ? `[${t.rotation.map(v => v.toFixed(3)).join(',')}]` : 'none'; + console.log(` Joint[${i}] "${j.name}": pos=${pos} rot=${rot}`); + } else { + console.log(` Joint[${i}] "${j.name}": NO ANIMATION DATA`); + } + } +} + +// ===== Check if animation changes over time ===== +console.log(`\n=== Animation Value Changes ===`); + +// Find a rotation channel and check value changes +const rotChannels = animationChannels.filter(c => c.target.path === 'rotation'); +console.log(`Rotation channels: ${rotChannels.length}`); + +if (rotChannels.length > 0) { + // Find one with varying values + for (const ch of rotChannels.slice(0, 5)) { + const sampler = animationSamplers[ch.samplerIndex]; + const firstQ = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]]; + const lastQ = [ + sampler.output[(sampler.input.length-1)*4], + sampler.output[(sampler.input.length-1)*4+1], + sampler.output[(sampler.input.length-1)*4+2], + sampler.output[(sampler.input.length-1)*4+3] + ]; + const diff = Math.abs(firstQ[0]-lastQ[0]) + Math.abs(firstQ[1]-lastQ[1]) + + Math.abs(firstQ[2]-lastQ[2]) + Math.abs(firstQ[3]-lastQ[3]); + const nodeIdx = ch.target.nodeIndex; + const model = models[nodeIdx]; + console.log(` Node[${nodeIdx}] "${model?.name}": ${sampler.input.length} keyframes, diff=${diff.toFixed(4)}`); + if (diff > 0.01) { + console.log(` First: [${firstQ.map(v=>v.toFixed(4)).join(', ')}]`); + console.log(` Last: [${lastQ.map(v=>v.toFixed(4)).join(', ')}]`); + } + } +} + +// ===== Calculate Bone Matrices ===== +console.log(`\n=== Bone Matrix Test ===`); + +// Test at t=0 (should be bind pose - identity matrices) +// 在 t=0 测试(应该是绑定姿势 - 单位矩阵) + +function createTransformMatrix(pos, rot, scale) { + const [qx, qy, qz, qw] = rot; + const [sx, sy, sz] = scale; + const xx = qx*qx, xy = qx*qy, xz = qx*qz, xw = qx*qw; + const yy = qy*qy, yz = qy*qz, yw = qy*qw; + const zz = qz*qz, zw = qz*qw; + return new Float32Array([ + (1 - 2*(yy+zz))*sx, 2*(xy+zw)*sx, 2*(xz-yw)*sx, 0, + 2*(xy-zw)*sy, (1 - 2*(xx+zz))*sy, 2*(yz+xw)*sy, 0, + 2*(xz+yw)*sz, 2*(yz-xw)*sz, (1 - 2*(xx+yy))*sz, 0, + pos[0], pos[1], pos[2], 1 + ]); +} + +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +function invertMatrix4(m) { + const out = new Float32Array(16); + const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; + const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; + const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; + const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; + + const b00 = m00*m11 - m01*m10, b01 = m00*m12 - m02*m10; + const b02 = m00*m13 - m03*m10, b03 = m01*m12 - m02*m11; + const b04 = m01*m13 - m03*m11, b05 = m02*m13 - m03*m12; + const b06 = m20*m31 - m21*m30, b07 = m20*m32 - m22*m30; + const b08 = m20*m33 - m23*m30, b09 = m21*m32 - m22*m31; + const b10 = m21*m33 - m23*m31, b11 = m22*m33 - m23*m32; + + let det = b00*b11 - b01*b10 + b02*b09 + b03*b08 - b04*b07 + b05*b06; + if (!det) return out; + det = 1.0 / det; + + out[0] = (m11*b11 - m12*b10 + m13*b09) * det; + out[1] = (m02*b10 - m01*b11 - m03*b09) * det; + out[2] = (m31*b05 - m32*b04 + m33*b03) * det; + out[3] = (m22*b04 - m21*b05 - m23*b03) * det; + out[4] = (m12*b08 - m10*b11 - m13*b07) * det; + out[5] = (m00*b11 - m02*b08 + m03*b07) * det; + out[6] = (m32*b02 - m30*b05 - m33*b01) * det; + out[7] = (m20*b05 - m22*b02 + m23*b01) * det; + out[8] = (m10*b10 - m11*b08 + m13*b06) * det; + out[9] = (m01*b08 - m00*b10 - m03*b06) * det; + out[10] = (m30*b04 - m31*b02 + m33*b00) * det; + out[11] = (m21*b02 - m20*b04 - m23*b00) * det; + out[12] = (m11*b07 - m10*b09 - m12*b06) * det; + out[13] = (m00*b09 - m01*b07 + m02*b06) * det; + out[14] = (m31*b01 - m30*b03 - m32*b00) * det; + out[15] = (m20*b03 - m21*b01 + m22*b00) * det; + return out; +} + +// Test multiple times including t=0 (bind pose) +const testTimesForMatrix = [0, 1.0, 7.5]; + +// Build node default transforms from models +const nodeTransforms = []; +for (const model of models) { + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + let quat = eulerToQuaternion(rx, ry, rz); + if (model.preRotation) { + const prx = model.preRotation[0] * Math.PI / 180; + const pry = model.preRotation[1] * Math.PI / 180; + const prz = model.preRotation[2] * Math.PI / 180; + const preQuat = eulerToQuaternion(prx, pry, prz); + quat = multiplyQuaternion(preQuat, quat); + } + nodeTransforms.push({ + position: model.position, + rotation: quat, + scale: model.scale + }); +} + +// Calculate bone matrices for different times +function calculateBoneMatrices(time) { + const transforms = sampleAnimation(time); + const localMatrices = [], worldMatrices = [], skinMatrices = []; + const processed = new Set(); + const processingOrder = []; + + function addJoint(idx) { + if (processed.has(idx)) return; + if (joints[idx].parentIndex >= 0 && !processed.has(joints[idx].parentIndex)) { + addJoint(joints[idx].parentIndex); + } + processingOrder.push(idx); + processed.add(idx); + } + for (let i = 0; i < joints.length; i++) addJoint(i); + + for (const jointIdx of processingOrder) { + const joint = joints[jointIdx]; + const nodeIdx = joint.nodeIndex; + const node = nodeTransforms[nodeIdx]; + + // Get animated or default transform + const animT = transforms.get(nodeIdx); + const pos = animT?.translation || node.position; + const rot = animT?.rotation || node.rotation; + const scl = animT?.scale || node.scale; + + localMatrices[jointIdx] = createTransformMatrix(pos, rot, scl); + + if (joint.parentIndex >= 0) { + worldMatrices[jointIdx] = multiplyMatrices(worldMatrices[joint.parentIndex], localMatrices[jointIdx]); + } else { + worldMatrices[jointIdx] = localMatrices[jointIdx]; + } + + skinMatrices[jointIdx] = multiplyMatrices(worldMatrices[jointIdx], joint.inverseBindMatrix); + } + + return skinMatrices; +} + +// Test at multiple times +for (const time of testTimesForMatrix) { + console.log(`\n--- t=${time.toFixed(1)}s ---`); + const skinMatrices = calculateBoneMatrices(time); + + // Check skin matrices - how many are NOT identity? + let nonIdentityCount = 0; + let maxDiff = 0; + for (let i = 0; i < skinMatrices.length; i++) { + const m = skinMatrices[i]; + const diff = Math.abs(m[0]-1) + Math.abs(m[5]-1) + Math.abs(m[10]-1) + Math.abs(m[15]-1) + + Math.abs(m[1]) + Math.abs(m[2]) + Math.abs(m[3]) + + Math.abs(m[4]) + Math.abs(m[6]) + Math.abs(m[7]) + + Math.abs(m[8]) + Math.abs(m[9]) + Math.abs(m[11]) + + Math.abs(m[12]) + Math.abs(m[13]) + Math.abs(m[14]); + if (diff > 0.001) { + nonIdentityCount++; + if (diff > maxDiff) maxDiff = diff; + } + } + + console.log(` Non-identity: ${nonIdentityCount}/${skinMatrices.length}, max diff: ${maxDiff.toFixed(4)}`); + if (time === 0) { + console.log(` (t=0 should have mostly identity matrices if bind pose is correct)`); + } + + // Show first 3 skin matrices + for (let i = 0; i < Math.min(3, skinMatrices.length); i++) { + const m = skinMatrices[i]; + console.log(` Joint[${i}] "${joints[i].name}":`); + console.log(` diagonal: [${m[0].toFixed(4)}, ${m[5].toFixed(4)}, ${m[10].toFixed(4)}, ${m[15].toFixed(4)}]`); + console.log(` translation: [${m[12].toFixed(4)}, ${m[13].toFixed(4)}, ${m[14].toFixed(4)}]`); + } +} + +console.log(`\n=== Done ===`); diff --git a/scripts/test-fbxloader-bindpose.mjs b/scripts/test-fbxloader-bindpose.mjs new file mode 100644 index 00000000..dfb7c686 --- /dev/null +++ b/scripts/test-fbxloader-bindpose.mjs @@ -0,0 +1,199 @@ +/** + * Test FBXLoader Bind Pose + * 测试 FBXLoader 绑定姿势 + * + * Verify: worldMatrix * inverseBindMatrix = Identity at bind pose + */ + +import { readFileSync } from 'fs'; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; +console.log(`Testing bind pose: ${filePath}\n`); + +// Matrix math utilities +function createTransformMatrix(position, rotation, scale) { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); +} + +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +function identity() { + return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); +} + +function isIdentity(m, tolerance = 0.01) { + const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + for (let i = 0; i < 16; i++) { + if (Math.abs(m[i] - id[i]) > tolerance) return false; + } + return true; +} + +function maxDiffFromIdentity(m) { + const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + let maxDiff = 0; + for (let i = 0; i < 16; i++) { + maxDiff = Math.max(maxDiff, Math.abs(m[i] - id[i])); + } + return maxDiff; +} + +async function main() { + const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); + + const binaryData = readFileSync(filePath); + const loader = new FBXLoader(); + + const context = { + metadata: { + path: filePath, + name: filePath.split(/[\\/]/).pop(), + type: 'model/fbx', + guid: '', + size: binaryData.length, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null + }; + + const content = { + type: 'binary', + binary: binaryData.buffer + }; + + const asset = await loader.parse(content, context); + + if (!asset.skeleton) { + console.log('No skeleton data!'); + return; + } + + const { joints, rootJointIndex } = asset.skeleton; + const nodes = asset.nodes; + + console.log(`Skeleton: ${joints.length} joints, rootJointIndex=${rootJointIndex}`); + + // Build parent index map (node hierarchy) + const nodeParentMap = new Map(); + for (const node of nodes) { + if (node.children) { + for (const childIdx of node.children) { + nodeParentMap.set(childIdx, nodes.indexOf(node)); + } + } + } + + // Calculate world matrices for each joint using node.transform hierarchy + const worldMatrices = new Array(joints.length); + + // Processing order: root first, then children + const processed = new Set(); + const processingOrder = []; + + function addJoint(jointIndex) { + if (processed.has(jointIndex)) return; + const joint = joints[jointIndex]; + if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { + addJoint(joint.parentIndex); + } + processingOrder.push(jointIndex); + processed.add(jointIndex); + } + + for (let i = 0; i < joints.length; i++) addJoint(i); + + for (const jointIndex of processingOrder) { + const joint = joints[jointIndex]; + const node = nodes[joint.nodeIndex]; + + if (!node) { + worldMatrices[jointIndex] = identity(); + continue; + } + + const { position, rotation, scale } = node.transform; + const localMatrix = createTransformMatrix(position, rotation, scale); + + if (joint.parentIndex >= 0) { + worldMatrices[jointIndex] = multiplyMatrices( + worldMatrices[joint.parentIndex], + localMatrix + ); + } else { + worldMatrices[jointIndex] = localMatrix; + } + } + + // Calculate skin matrices and check if they are identity + let identityCount = 0; + let nonIdentityJoints = []; + + for (let i = 0; i < joints.length; i++) { + const joint = joints[i]; + const skinMatrix = multiplyMatrices(worldMatrices[i], joint.inverseBindMatrix); + + if (isIdentity(skinMatrix)) { + identityCount++; + } else { + const diff = maxDiffFromIdentity(skinMatrix); + nonIdentityJoints.push({ index: i, name: joint.name, diff, skinMatrix }); + } + } + + console.log(`\n=== BIND POSE VERIFICATION ===`); + console.log(`Identity skin matrices: ${identityCount}/${joints.length}`); + + if (nonIdentityJoints.length > 0) { + console.log(`\n❌ NOT at bind pose! ${nonIdentityJoints.length} joints have non-identity skin matrices.`); + + // Show first 3 problematic joints + nonIdentityJoints.sort((a, b) => b.diff - a.diff); + console.log(`\nTop 3 worst joints:`); + for (let i = 0; i < 3 && i < nonIdentityJoints.length; i++) { + const { index, name, diff, skinMatrix } = nonIdentityJoints[i]; + console.log(` Joint[${index}] "${name}": maxDiff=${diff.toFixed(4)}`); + console.log(` skinMatrix diagonal: [${skinMatrix[0].toFixed(2)}, ${skinMatrix[5].toFixed(2)}, ${skinMatrix[10].toFixed(2)}, ${skinMatrix[15].toFixed(2)}]`); + console.log(` skinMatrix translation: [${skinMatrix[12].toFixed(2)}, ${skinMatrix[13].toFixed(2)}, ${skinMatrix[14].toFixed(2)}]`); + } + + console.log(`\n=== ANALYSIS ===`); + console.log(`The skin matrix should be Identity at bind pose (t=0).`); + console.log(`This means: worldMatrix * inverseBindMatrix = Identity`); + console.log(`If not identity, the mesh will appear deformed at rest.`); + } else { + console.log(`\n✅ All skin matrices are identity at bind pose!`); + } + + console.log('\nDone!'); +} + +main().catch(console.error); diff --git a/scripts/test-full-pipeline.mjs b/scripts/test-full-pipeline.mjs new file mode 100644 index 00000000..2840e15f --- /dev/null +++ b/scripts/test-full-pipeline.mjs @@ -0,0 +1,806 @@ +/** + * Test Full Animation Pipeline + * 测试完整的动画管道 + * + * This script exactly mimics what ModelPreview3D does: + * 1. Parse FBX data (like FBXLoader) + * 2. Sample animation (like sampleAnimation) + * 3. Calculate bone matrices (like calculateBoneMatrices) + * 4. Output visual verification data + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const FBX_TIME_SECOND = 46186158000n; +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Full Pipeline Test: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + const startOffset = offset; + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// ============ STEP 1: Parse Models (like FBXLoader) ============ + +// FBX uses XYZ Euler order (same as test-fbx-animation.mjs) +// FBX 使用 XYZ 欧拉角顺序 +function eulerToQuaternion(rx, ry, rz) { + const cx = Math.cos(rx / 2), sx = Math.sin(rx / 2); + const cy = Math.cos(ry / 2), sy = Math.sin(ry / 2); + const cz = Math.cos(rz / 2), sz = Math.sin(rz / 2); + return [ + sx * cy * cz - cx * sy * sz, + cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, + cx * cy * cz + sx * sy * sz + ]; +} + +function multiplyQuaternion(a, b) { + return [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2] + ]; +} + +const modelNodes = objectsNode.children.filter(n => n.name === 'Model'); +const models = modelNodes.map(n => { + const id = n.properties[0]; + const name = n.properties[1]?.split?.('\0')[0] || 'Model'; + const type = n.properties[2]?.split?.('\0')[0] || ''; + + let position = [0, 0, 0]; + let rotation = [0, 0, 0]; + let scale = [1, 1, 1]; + let preRotation = null; + + // Parse Properties70 + const props = n.children.find(c => c.name === 'Properties70'); + if (props) { + for (const p of props.children) { + if (p.name === 'P') { + const propName = p.properties[0]?.split?.('\0')[0]; + if (propName === 'Lcl Translation') { + position = [p.properties[4], p.properties[5], p.properties[6]]; + } else if (propName === 'Lcl Rotation') { + rotation = [p.properties[4], p.properties[5], p.properties[6]]; + } else if (propName === 'Lcl Scaling') { + scale = [p.properties[4], p.properties[5], p.properties[6]]; + } else if (propName === 'PreRotation') { + preRotation = [p.properties[4], p.properties[5], p.properties[6]]; + } + } + } + } + + return { id, name, type, position, rotation, scale, preRotation }; +}); + +const modelToIndex = new Map(); +models.forEach((m, i) => modelToIndex.set(m.id, i)); + +// Build nodes array (like FBXLoader line 244) +const nodes = models.map(model => { + let quat; + if (model.preRotation) { + const preRx = model.preRotation[0] * Math.PI / 180; + const preRy = model.preRotation[1] * Math.PI / 180; + const preRz = model.preRotation[2] * Math.PI / 180; + const preQuat = eulerToQuaternion(preRx, preRy, preRz); + + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + const lclQuat = eulerToQuaternion(rx, ry, rz); + + quat = multiplyQuaternion(preQuat, lclQuat); + } else { + const rx = model.rotation[0] * Math.PI / 180; + const ry = model.rotation[1] * Math.PI / 180; + const rz = model.rotation[2] * Math.PI / 180; + quat = eulerToQuaternion(rx, ry, rz); + } + + return { + name: model.name, + children: [], + transform: { + position: model.position, + rotation: quat, + scale: model.scale + } + }; +}); + +// Build parent-child relationships +for (const conn of connections) { + if (conn.type === 'OO') { + const childIdx = modelToIndex.get(conn.fromId); + const parentIdx = modelToIndex.get(conn.toId); + if (childIdx !== undefined && parentIdx !== undefined) { + nodes[parentIdx].children.push(childIdx); + } + } +} + +console.log(`Built ${nodes.length} nodes`); + +// ============ STEP 2: Parse Clusters and Build Skeleton ============ + +const clusterNodes = objectsNode.children.filter(n => + n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster' +); + +const clusters = clusterNodes.map(n => { + const id = n.properties[0]; + const name = n.properties[1]?.split?.('\0')[0] || 'Cluster'; + let transformLink = null; + + for (const child of n.children) { + if (child.name === 'TransformLink' && child.properties[0]?.data?.length === 16) { + // Store as Float32Array directly (like FBXLoader) + transformLink = new Float32Array(child.properties[0].data); + } + } + + return { id, name, transformLink }; +}); + +// Find cluster to bone connections +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) { + clusterToBone.set(cluster.id, conn.fromId); + } + } +} + +// Build skeleton (like FBXLoader buildSkeletonData) +function invertMatrix4(m) { + const out = new Float32Array(16); + const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; + const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; + const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; + const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; + + const b00 = m00 * m11 - m01 * m10; + const b01 = m00 * m12 - m02 * m10; + const b02 = m00 * m13 - m03 * m10; + const b03 = m01 * m12 - m02 * m11; + const b04 = m01 * m13 - m03 * m11; + const b05 = m02 * m13 - m03 * m12; + const b06 = m20 * m31 - m21 * m30; + const b07 = m20 * m32 - m22 * m30; + const b08 = m20 * m33 - m23 * m30; + const b09 = m21 * m32 - m22 * m31; + const b10 = m21 * m33 - m23 * m31; + const b11 = m22 * m33 - m23 * m32; + + let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + if (Math.abs(det) < 1e-8) { + return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + } + det = 1.0 / det; + + out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; + out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; + out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; + out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; + out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; + out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; + out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; + out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; + out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; + out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; + out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; + out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; + out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; + out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; + out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; + out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; + + return out; +} + +const joints = []; +const boneModelIdToJointIndex = new Map(); +const modelParentMap = new Map(); + +for (const conn of connections) { + if (conn.type === 'OO') { + const childModel = models.find(m => m.id === conn.fromId); + const parentModel = models.find(m => m.id === conn.toId); + if (childModel && parentModel) { + modelParentMap.set(conn.fromId, conn.toId); + } + } +} + +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const nodeIndex = modelToIndex.get(boneModelId); + if (nodeIndex === undefined) continue; + + const model = models[nodeIndex]; + const jointIndex = joints.length; + boneModelIdToJointIndex.set(boneModelId, jointIndex); + + const inverseBindMatrix = cluster.transformLink + ? invertMatrix4(cluster.transformLink) + : new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + + joints.push({ + name: model.name, + nodeIndex, + parentIndex: -1, + inverseBindMatrix + }); +} + +// Set parent indices +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const jointIndex = boneModelIdToJointIndex.get(boneModelId); + if (jointIndex === undefined) continue; + + let parentModelId = modelParentMap.get(boneModelId); + while (parentModelId) { + const parentJointIndex = boneModelIdToJointIndex.get(parentModelId); + if (parentJointIndex !== undefined) { + joints[jointIndex].parentIndex = parentJointIndex; + break; + } + parentModelId = modelParentMap.get(parentModelId); + } +} + +console.log(`Built ${joints.length} skeleton joints`); + +// ============ STEP 3: Parse Animation ============ + +const animCurves = objectsNode.children.filter(n => n.name === 'AnimationCurve'); +const animCurveNodes = objectsNode.children.filter(n => n.name === 'AnimationCurveNode'); + +// Map curve ID to curve data +const curveMap = new Map(); +for (const curve of animCurves) { + const id = curve.properties[0]; + let keyTimes = null; + let keyValues = null; + + for (const child of curve.children) { + if (child.name === 'KeyTime') { + const data = child.properties[0]?.data; + if (data) { + keyTimes = data.map(t => Number(t) / Number(FBX_TIME_SECOND)); + } + } else if (child.name === 'KeyValueFloat') { + keyValues = child.properties[0]?.data; + } + } + + if (keyTimes && keyValues) { + curveMap.set(id, { keyTimes, keyValues }); + } +} + +// Build curveNode map (ID is in properties[0], not .id) +const curveNodeMap = new Map(); +for (const cn of animCurveNodes) { + curveNodeMap.set(cn.properties[0], cn); +} + +// Map curveNode to model and build animation channels +const curveNodeToModel = new Map(); +const curveNodeToCurves = new Map(); + +for (const conn of connections) { + if (conn.type === 'OP') { + if (conn.property?.includes('Lcl')) { + const curveNode = curveNodeMap.get(conn.fromId); + if (curveNode) { + curveNodeToModel.set(conn.fromId, conn.toId); + } + } else if (conn.property === 'd|X' || conn.property === 'd|Y' || conn.property === 'd|Z') { + const curveNode = curveNodeMap.get(conn.toId); + if (curveNode) { + if (!curveNodeToCurves.has(conn.toId)) { + curveNodeToCurves.set(conn.toId, { x: null, y: null, z: null }); + } + const curves = curveNodeToCurves.get(conn.toId); + const curveData = curveMap.get(conn.fromId); + if (curveData) { + if (conn.property === 'd|X') curves.x = curveData; + if (conn.property === 'd|Y') curves.y = curveData; + if (conn.property === 'd|Z') curves.z = curveData; + } + } + } + } +} + +// Build animation channels +const channels = []; +const samplers = []; + +for (const cn of animCurveNodes) { + const cnId = cn.properties[0]; // ID is in properties[0] + const targetModelId = curveNodeToModel.get(cnId); + if (!targetModelId) continue; + + const nodeIndex = modelToIndex.get(targetModelId); + if (nodeIndex === undefined) continue; + + const targetModel = models[nodeIndex]; + const curves = curveNodeToCurves.get(cnId); + if (!curves) continue; + + // Attribute is in properties[1], but has null bytes + const attr = cn.properties[1]?.split?.('\0')[0]; + if (!attr) continue; + + const xCurve = curves.x; + const yCurve = curves.y; + const zCurve = curves.z; + + if (!xCurve && !yCurve && !zCurve) continue; + + const refCurve = xCurve || yCurve || zCurve; + const keyCount = refCurve.keyTimes.length; + const input = new Float32Array(refCurve.keyTimes); + + let output; + let path; + + if (attr === 'T') { + path = 'translation'; + output = new Float32Array(keyCount * 3); + for (let i = 0; i < keyCount; i++) { + output[i * 3] = xCurve?.keyValues[i] ?? 0; + output[i * 3 + 1] = yCurve?.keyValues[i] ?? 0; + output[i * 3 + 2] = zCurve?.keyValues[i] ?? 0; + } + } else if (attr === 'R') { + path = 'rotation'; + output = new Float32Array(keyCount * 4); + + let preRotQuat = null; + if (targetModel.preRotation) { + const preRx = targetModel.preRotation[0] * Math.PI / 180; + const preRy = targetModel.preRotation[1] * Math.PI / 180; + const preRz = targetModel.preRotation[2] * Math.PI / 180; + preRotQuat = eulerToQuaternion(preRx, preRy, preRz); + } + + for (let i = 0; i < keyCount; i++) { + const rx = (xCurve?.keyValues[i] ?? 0) * Math.PI / 180; + const ry = (yCurve?.keyValues[i] ?? 0) * Math.PI / 180; + const rz = (zCurve?.keyValues[i] ?? 0) * Math.PI / 180; + const lclQuat = eulerToQuaternion(rx, ry, rz); + + const finalQuat = preRotQuat + ? multiplyQuaternion(preRotQuat, lclQuat) + : lclQuat; + + output[i * 4] = finalQuat[0]; + output[i * 4 + 1] = finalQuat[1]; + output[i * 4 + 2] = finalQuat[2]; + output[i * 4 + 3] = finalQuat[3]; + } + } else if (attr === 'S') { + path = 'scale'; + output = new Float32Array(keyCount * 3); + for (let i = 0; i < keyCount; i++) { + output[i * 3] = xCurve?.keyValues[i] ?? 1; + output[i * 3 + 1] = yCurve?.keyValues[i] ?? 1; + output[i * 3 + 2] = zCurve?.keyValues[i] ?? 1; + } + } else { + continue; + } + + const samplerIndex = samplers.length; + samplers.push({ input, output, interpolation: 'LINEAR' }); + channels.push({ samplerIndex, target: { nodeIndex, path } }); +} + +console.log(`Built ${channels.length} animation channels`); + +// ============ STEP 4: Sample Animation (like ModelPreview3D sampleAnimation) ============ + +function slerpQuaternion(q0, q1, t) { + let [x0, y0, z0, w0] = q0; + let [x1, y1, z1, w1] = q1; + + let cosHalfTheta = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1; + + if (cosHalfTheta < 0) { + x1 = -x1; y1 = -y1; z1 = -z1; w1 = -w1; + cosHalfTheta = -cosHalfTheta; + } + + if (cosHalfTheta > 0.9995) { + const result = [ + x0 + t * (x1 - x0), y0 + t * (y1 - y0), + z0 + t * (z1 - z0), w0 + t * (w1 - w0) + ]; + const len = Math.sqrt(result[0]**2 + result[1]**2 + result[2]**2 + result[3]**2); + return [result[0]/len, result[1]/len, result[2]/len, result[3]/len]; + } + + const theta0 = Math.acos(cosHalfTheta); + const theta = theta0 * t; + const sinTheta = Math.sin(theta); + const sinTheta0 = Math.sin(theta0); + + const s0 = Math.cos(theta) - cosHalfTheta * sinTheta / sinTheta0; + const s1 = sinTheta / sinTheta0; + + return [s0 * x0 + s1 * x1, s0 * y0 + s1 * y1, s0 * z0 + s1 * z1, s0 * w0 + s1 * w1]; +} + +function sampleSampler(sampler, time, path) { + const input = sampler.input; + const output = sampler.output; + + if (!input || !output || input.length === 0) return null; + + const minTime = input[0]; + const maxTime = input[input.length - 1]; + time = Math.max(minTime, Math.min(maxTime, time)); + + let i0 = 0; + for (let i = 0; i < input.length - 1; i++) { + if (time >= input[i] && time <= input[i + 1]) { + i0 = i; + break; + } + if (time < input[i]) break; + i0 = i; + } + const i1 = Math.min(i0 + 1, input.length - 1); + + const t0 = input[i0]; + const t1 = input[i1]; + const t = t1 > t0 ? (time - t0) / (t1 - t0) : 0; + + const componentCount = path === 'rotation' ? 4 : 3; + + if (path === 'rotation') { + const q0 = [output[i0*4], output[i0*4+1], output[i0*4+2], output[i0*4+3]]; + const q1 = [output[i1*4], output[i1*4+1], output[i1*4+2], output[i1*4+3]]; + return slerpQuaternion(q0, q1, t); + } + + const result = []; + for (let c = 0; c < componentCount; c++) { + const v0 = output[i0 * componentCount + c]; + const v1 = output[i1 * componentCount + c]; + result.push(v0 + (v1 - v0) * t); + } + return result; +} + +function sampleAnimation(time) { + const nodeTransforms = new Map(); + + for (const channel of channels) { + const sampler = samplers[channel.samplerIndex]; + const nodeIndex = channel.target.nodeIndex; + const path = channel.target.path; + + const value = sampleSampler(sampler, time, path); + if (!value) continue; + + if (!nodeTransforms.has(nodeIndex)) { + nodeTransforms.set(nodeIndex, {}); + } + + const transform = nodeTransforms.get(nodeIndex); + if (path === 'translation') transform.position = value; + else if (path === 'rotation') transform.rotation = value; + else if (path === 'scale') transform.scale = value; + } + + return nodeTransforms; +} + +// ============ STEP 5: Calculate Bone Matrices (like ModelPreview3D) ============ + +function createTransformMatrix(position, rotation, scale) { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); +} + +function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; +} + +function calculateBoneMatrices(animTransforms) { + const boneCount = joints.length; + const localMatrices = new Array(boneCount); + const worldMatrices = new Array(boneCount); + const skinMatrices = new Array(boneCount); + + // Build processing order + const processed = new Set(); + const processingOrder = []; + + function addJoint(jointIndex) { + if (processed.has(jointIndex)) return; + const joint = joints[jointIndex]; + if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { + addJoint(joint.parentIndex); + } + processingOrder.push(jointIndex); + processed.add(jointIndex); + } + + for (let i = 0; i < boneCount; i++) { + addJoint(i); + } + + // Calculate transforms + for (const jointIndex of processingOrder) { + const joint = joints[jointIndex]; + const node = nodes[joint.nodeIndex]; + + if (!node) { + localMatrices[jointIndex] = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); + worldMatrices[jointIndex] = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); + skinMatrices[jointIndex] = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); + continue; + } + + const animTransform = animTransforms.get(joint.nodeIndex); + const pos = animTransform?.position || node.transform.position; + const rot = animTransform?.rotation || node.transform.rotation; + const scl = animTransform?.scale || node.transform.scale; + + localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); + + if (joint.parentIndex >= 0) { + worldMatrices[jointIndex] = multiplyMatrices( + worldMatrices[joint.parentIndex], + localMatrices[jointIndex] + ); + } else { + worldMatrices[jointIndex] = localMatrices[jointIndex]; + } + + skinMatrices[jointIndex] = multiplyMatrices( + worldMatrices[jointIndex], + joint.inverseBindMatrix + ); + } + + return skinMatrices; +} + +// ============ STEP 6: Test at different times ============ + +function isIdentity(m) { + const identity = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; + let maxDiff = 0; + for (let i = 0; i < 16; i++) { + maxDiff = Math.max(maxDiff, Math.abs(m[i] - identity[i])); + } + return { isIdentity: maxDiff < 0.001, maxDiff }; +} + +console.log('\n=== BONE MATRIX TEST ===\n'); + +for (const time of [0.0, 0.5, 1.0, 2.0]) { + const animTransforms = sampleAnimation(time); + const skinMatrices = calculateBoneMatrices(animTransforms); + + let identityCount = 0; + let maxDiff = 0; + + for (const m of skinMatrices) { + const check = isIdentity(m); + if (check.isIdentity) identityCount++; + if (check.maxDiff > maxDiff) maxDiff = check.maxDiff; + } + + console.log(`t=${time.toFixed(1)}s: Identity: ${identityCount}/${skinMatrices.length}, Max diff: ${maxDiff.toFixed(4)}`); + + // Show first non-identity matrix at t=1 + if (time === 1.0) { + for (let i = 0; i < skinMatrices.length; i++) { + const check = isIdentity(skinMatrices[i]); + if (!check.isIdentity) { + const m = skinMatrices[i]; + console.log(`\n First non-identity matrix (joint ${i} "${joints[i].name}"):`); + console.log(` Col 0: ${m[0].toFixed(4)}, ${m[1].toFixed(4)}, ${m[2].toFixed(4)}, ${m[3].toFixed(4)}`); + console.log(` Col 1: ${m[4].toFixed(4)}, ${m[5].toFixed(4)}, ${m[6].toFixed(4)}, ${m[7].toFixed(4)}`); + console.log(` Col 2: ${m[8].toFixed(4)}, ${m[9].toFixed(4)}, ${m[10].toFixed(4)}, ${m[11].toFixed(4)}`); + console.log(` Col 3: ${m[12].toFixed(4)}, ${m[13].toFixed(4)}, ${m[14].toFixed(4)}, ${m[15].toFixed(4)}`); + break; + } + } + } +} + +console.log('\n=== SUMMARY ==='); +console.log('This script exactly mimics FBXLoader + ModelPreview3D pipeline.'); +console.log('If t=0 shows identity matrices and t>0 shows non-identity,'); +console.log('the algorithm is correct and the issue is elsewhere (React, GPU, etc.).'); + +console.log('\nDone!'); diff --git a/scripts/trace-fbxloader-output.mjs b/scripts/trace-fbxloader-output.mjs new file mode 100644 index 00000000..c10aec17 --- /dev/null +++ b/scripts/trace-fbxloader-output.mjs @@ -0,0 +1,309 @@ +/** + * Trace FBXLoader Output + * 追踪 FBXLoader 输出 + * + * Load the FBX with actual FBXLoader and compare with expected values + */ + +import { readFileSync } from 'fs'; +import { FBXLoader } from '../packages/asset-system/dist/index.js'; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +// Suppress console.log temporarily to hide FBXLoader debug output +const originalLog = console.log; +let suppressLogs = true; +console.log = (...args) => { + if (!suppressLogs) originalLog(...args); +}; + +originalLog(`=== Trace FBXLoader Output: ${filePath} ===\n`); + +const binaryData = readFileSync(filePath); + +const loader = new FBXLoader(); + +const context = { + metadata: { + path: filePath, + name: filePath.split(/[\\/]/).pop(), + type: 'model/fbx', + guid: '', + size: binaryData.length, + hash: '', + dependencies: [], + lastModified: Date.now(), + importerVersion: '1.0.0', + labels: [], + tags: [], + version: 1 + }, + loadDependency: async () => null +}; + +const content = { + type: 'binary', + binary: binaryData.buffer +}; + +try { + const asset = await loader.parse(content, context); + + console.log(`Meshes: ${asset.meshes?.length || 0}`); + console.log(`Nodes: ${asset.nodes?.length || 0}`); + console.log(`Animations: ${asset.animations?.length || 0}`); + + if (asset.skeleton) { + console.log(`Skeleton joints: ${asset.skeleton.joints.length}`); + console.log(`Root joint index: ${asset.skeleton.rootJointIndex}`); + + // Check first few joints + console.log(`\nFirst 3 skeleton joints:`); + for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { + const joint = asset.skeleton.joints[i]; + console.log(` Joint[${i}] "${joint.name}":`); + console.log(` nodeIndex: ${joint.nodeIndex}`); + console.log(` parentIndex: ${joint.parentIndex}`); + + // Check inverseBindMatrix + const ibm = joint.inverseBindMatrix; + if (ibm) { + console.log(` inverseBindMatrix diagonal: [${ibm[0].toFixed(4)}, ${ibm[5].toFixed(4)}, ${ibm[10].toFixed(4)}, ${ibm[15].toFixed(4)}]`); + console.log(` inverseBindMatrix last row: [${ibm[12].toFixed(4)}, ${ibm[13].toFixed(4)}, ${ibm[14].toFixed(4)}, ${ibm[15].toFixed(4)}]`); + } + } + + // Check corresponding nodes + console.log(`\nCorresponding nodes:`); + for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { + const joint = asset.skeleton.joints[i]; + const node = asset.nodes?.[joint.nodeIndex]; + if (node) { + console.log(` Node[${joint.nodeIndex}] "${node.name}":`); + console.log(` position: [${node.transform.position.map(v => v.toFixed(4)).join(', ')}]`); + console.log(` rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); + console.log(` scale: [${node.transform.scale.map(v => v.toFixed(4)).join(', ')}]`); + } + } + } else { + console.log(`No skeleton data!`); + } + + // Check animation channels + if (asset.animations && asset.animations.length > 0) { + const clip = asset.animations[0]; + console.log(`\nAnimation "${clip.name}":`); + console.log(` Duration: ${clip.duration}s`); + console.log(` Channels: ${clip.channels.length}`); + console.log(` Samplers: ${clip.samplers.length}`); + + // Find channels targeting first few skeleton joints + if (asset.skeleton) { + console.log(`\nChannels for first 3 joints:`); + for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { + const joint = asset.skeleton.joints[i]; + const channels = clip.channels.filter(c => c.target.nodeIndex === joint.nodeIndex); + console.log(` Joint[${i}] nodeIndex=${joint.nodeIndex}: ${channels.length} channels`); + channels.forEach(c => { + const sampler = clip.samplers[c.samplerIndex]; + console.log(` - ${c.target.path}: ${sampler.input.length} keyframes, first value at t=0:`); + if (c.target.path === 'rotation') { + const q = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]]; + console.log(` quaternion: [${q.map(v => v.toFixed(4)).join(', ')}]`); + } else { + const v = [sampler.output[0], sampler.output[1], sampler.output[2]]; + console.log(` vec3: [${v.map(v => v.toFixed(4)).join(', ')}]`); + } + }); + } + } + } + + // Now test bone matrix calculation + if (asset.skeleton && asset.animations && asset.animations.length > 0) { + console.log(`\n=== TESTING BONE MATRIX CALCULATION ===`); + + const skeleton = asset.skeleton; + const nodes = asset.nodes; + const clip = asset.animations[0]; + + // Sample animation at t=0 + function sampleAnimation(clip, time) { + const nodeTransforms = new Map(); + + for (const channel of clip.channels) { + const sampler = clip.samplers[channel.samplerIndex]; + if (!sampler) continue; + + const nodeIndex = channel.target.nodeIndex; + const path = channel.target.path; + + // Get first keyframe value (t=0) + let value; + if (path === 'rotation') { + value = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]]; + } else { + value = [sampler.output[0], sampler.output[1], sampler.output[2]]; + } + + let transform = nodeTransforms.get(nodeIndex); + if (!transform) { + transform = {}; + nodeTransforms.set(nodeIndex, transform); + } + + if (path === 'translation') transform.position = value; + else if (path === 'rotation') transform.rotation = value; + else if (path === 'scale') transform.scale = value; + } + + return nodeTransforms; + } + + function createTransformMatrix(position, rotation, scale) { + const [qx, qy, qz, qw] = rotation; + const [sx, sy, sz] = scale; + const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; + const yy = qy * qy, yz = qy * qz, yw = qy * qw; + const zz = qz * qz, zw = qz * qw; + + return new Float32Array([ + (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, + 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, + 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, + position[0], position[1], position[2], 1 + ]); + } + + function multiplyMatrices(a, b) { + const result = new Float32Array(16); + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 4; col++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[row + k * 4] * b[k + col * 4]; + } + result[row + col * 4] = sum; + } + } + return result; + } + + function identity() { + return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + } + + const animTransforms = sampleAnimation(clip, 0); + console.log(`Sampled ${animTransforms.size} node transforms at t=0`); + + // Calculate bone matrices + const { joints } = skeleton; + const boneCount = joints.length; + const localMatrices = new Array(boneCount); + const worldMatrices = new Array(boneCount); + const skinMatrices = new Array(boneCount); + + // Build processing order + const processed = new Set(); + const processingOrder = []; + + function addJoint(jointIndex) { + if (processed.has(jointIndex)) return; + const joint = joints[jointIndex]; + if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { + addJoint(joint.parentIndex); + } + processingOrder.push(jointIndex); + processed.add(jointIndex); + } + + for (let i = 0; i < boneCount; i++) addJoint(i); + + for (const jointIndex of processingOrder) { + const joint = joints[jointIndex]; + const node = nodes[joint.nodeIndex]; + + if (!node) { + localMatrices[jointIndex] = identity(); + worldMatrices[jointIndex] = identity(); + skinMatrices[jointIndex] = identity(); + continue; + } + + // Get animated or default transform + const animTransform = animTransforms.get(joint.nodeIndex); + const pos = animTransform?.position || node.transform.position; + const rot = animTransform?.rotation || node.transform.rotation; + const scl = animTransform?.scale || node.transform.scale; + + localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); + + if (joint.parentIndex >= 0) { + worldMatrices[jointIndex] = multiplyMatrices( + worldMatrices[joint.parentIndex], + localMatrices[jointIndex] + ); + } else { + worldMatrices[jointIndex] = localMatrices[jointIndex]; + } + + skinMatrices[jointIndex] = multiplyMatrices( + worldMatrices[jointIndex], + joint.inverseBindMatrix + ); + } + + // Count identity matrices + let identityCount = 0; + let maxDiff = 0; + const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + for (let i = 0; i < boneCount; i++) { + const sm = skinMatrices[i]; + let diff = 0; + for (let j = 0; j < 16; j++) { + diff = Math.max(diff, Math.abs(sm[j] - id[j])); + } + if (diff < 0.001) identityCount++; + if (diff > maxDiff) maxDiff = diff; + } + + console.log(`\nAt t=0 with animation data:`); + console.log(` Identity matrices: ${identityCount}/${boneCount}`); + console.log(` Max diff from identity: ${maxDiff.toFixed(4)}`); + + if (identityCount !== boneCount) { + console.log(`\n⚠️ NOT all skin matrices are identity at bind pose!`); + + // Show first problematic joint + for (let i = 0; i < boneCount; i++) { + const sm = skinMatrices[i]; + let diff = 0; + for (let j = 0; j < 16; j++) { + diff = Math.max(diff, Math.abs(sm[j] - id[j])); + } + if (diff >= 0.001) { + const joint = joints[i]; + const node = nodes[joint.nodeIndex]; + const animT = animTransforms.get(joint.nodeIndex); + console.log(`\n First non-identity: Joint[${i}] "${joint.name}"`); + console.log(` nodeIndex: ${joint.nodeIndex}`); + console.log(` parentIndex: ${joint.parentIndex}`); + console.log(` animTransform exists: ${!!animT}`); + if (animT) { + console.log(` animTransform.rotation: [${animT.rotation?.map(v => v.toFixed(4)).join(', ') || 'null'}]`); + } + console.log(` node.transform.rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); + break; + } + } + } else { + console.log(`\n✅ All skin matrices are identity at bind pose!`); + } + } + +} catch (error) { + console.error('Error:', error); +} + +console.log('\nDone!'); diff --git a/scripts/verify-anim-t0.mjs b/scripts/verify-anim-t0.mjs new file mode 100644 index 00000000..48d6e5a4 --- /dev/null +++ b/scripts/verify-anim-t0.mjs @@ -0,0 +1,377 @@ +/** + * Verify Animation at t=0 + * 验证 t=0 时的动画值 + * + * Check if animation values at t=0 produce correct bind pose + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const FBX_TIME_SECOND = 46186158000n; +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Verify Animation at t=0: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; + case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; + case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; + case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; + case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; + case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': case 'd': case 'l': case 'i': case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models with PreRotation +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => { + const position = [0, 0, 0]; + const rotation = [0, 0, 0]; + const scale = [1, 1, 1]; + let preRotation = null; + + for (const child of n.children) { + if (child.name === 'Properties70') { + for (const prop of child.children) { + if (prop.properties[0] === 'Lcl Translation') { + position[0] = prop.properties[4]; + position[1] = prop.properties[5]; + position[2] = prop.properties[6]; + } else if (prop.properties[0] === 'Lcl Rotation') { + rotation[0] = prop.properties[4]; + rotation[1] = prop.properties[5]; + rotation[2] = prop.properties[6]; + } else if (prop.properties[0] === 'Lcl Scaling') { + scale[0] = prop.properties[4]; + scale[1] = prop.properties[5]; + scale[2] = prop.properties[6]; + } else if (prop.properties[0] === 'PreRotation') { + preRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; + } + } + } + } + + return { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model', + position, rotation, scale, preRotation + }; + }); + +const modelToIndex = new Map(); +const modelById = new Map(); +models.forEach((m, i) => { + modelToIndex.set(m.id, i); + modelById.set(m.id, m); +}); + +// Parse AnimationCurves +const animCurves = objectsNode.children + .filter(n => n.name === 'AnimationCurve') + .map(n => { + const keyTimeNode = n.children.find(c => c.name === 'KeyTime'); + const keyValueNode = n.children.find(c => c.name === 'KeyValueFloat'); + + const keyTimes = keyTimeNode?.properties[0]?.data?.map(t => Number(t) / Number(FBX_TIME_SECOND)) || []; + const keyValues = keyValueNode?.properties[0]?.data || []; + + return { + id: n.properties[0], + keyTimes, + keyValues + }; + }); + +// Parse AnimationCurveNodes +const curveNodes = objectsNode.children + .filter(n => n.name === 'AnimationCurveNode') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || '' + })); + +// Build curveNode to model mapping +const curveNodeToModel = new Map(); +for (const conn of connections) { + if (conn.type === 'OP' && conn.property?.includes('Lcl')) { + const cn = curveNodes.find(c => c.id === conn.fromId); + if (cn) { + curveNodeToModel.set(cn.id, { modelId: conn.toId, property: conn.property }); + } + } +} + +// Build curveNode to curves mapping +const curveNodeToCurves = new Map(); +for (const conn of connections) { + if (conn.type === 'OP' && (conn.property === 'd|X' || conn.property === 'd|Y' || conn.property === 'd|Z')) { + const curve = animCurves.find(c => c.id === conn.fromId); + const cn = curveNodes.find(c => c.id === conn.toId); + if (curve && cn) { + if (!curveNodeToCurves.has(cn.id)) { + curveNodeToCurves.set(cn.id, { x: null, y: null, z: null }); + } + const curves = curveNodeToCurves.get(cn.id); + if (conn.property === 'd|X') curves.x = curve; + if (conn.property === 'd|Y') curves.y = curve; + if (conn.property === 'd|Z') curves.z = curve; + } + } +} + +// Sample animation at t=0 +console.log(`=== SAMPLING ANIMATION AT t=0 ===\n`); + +function eulerToQuaternion(x, y, z) { + const cx = Math.cos(x / 2), sx = Math.sin(x / 2); + const cy = Math.cos(y / 2), sy = Math.sin(y / 2); + const cz = Math.cos(z / 2), sz = Math.sin(z / 2); + return [ + sx * cy * cz - cx * sy * sz, + cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, + cx * cy * cz + sx * sy * sz + ]; +} + +function multiplyQuaternion(a, b) { + const [ax, ay, az, aw] = a; + const [bx, by, bz, bw] = b; + return [ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz + ]; +} + +function sampleCurveAtT0(curve) { + if (!curve || !curve.keyValues || curve.keyValues.length === 0) return 0; + return curve.keyValues[0]; // Value at first keyframe (t=0) +} + +// For each curveNode, sample at t=0 +const sampledTransforms = new Map(); + +for (const [cnId, target] of curveNodeToModel) { + const nodeIndex = modelToIndex.get(target.modelId); + if (nodeIndex === undefined) continue; + + const curves = curveNodeToCurves.get(cnId); + if (!curves) continue; + + const model = modelById.get(target.modelId); + + if (!sampledTransforms.has(nodeIndex)) { + sampledTransforms.set(nodeIndex, { + position: null, + rotation: null, + scale: null + }); + } + const transform = sampledTransforms.get(nodeIndex); + + if (target.property.includes('Translation')) { + transform.position = [ + sampleCurveAtT0(curves.x), + sampleCurveAtT0(curves.y), + sampleCurveAtT0(curves.z) + ]; + } else if (target.property.includes('Rotation')) { + // Get rotation in degrees + const rx = sampleCurveAtT0(curves.x); + const ry = sampleCurveAtT0(curves.y); + const rz = sampleCurveAtT0(curves.z); + + // Convert to radians + const rxRad = rx * Math.PI / 180; + const ryRad = ry * Math.PI / 180; + const rzRad = rz * Math.PI / 180; + + // Apply PreRotation if model has it + let quat; + if (model?.preRotation) { + const preRx = model.preRotation[0] * Math.PI / 180; + const preRy = model.preRotation[1] * Math.PI / 180; + const preRz = model.preRotation[2] * Math.PI / 180; + const preQuat = eulerToQuaternion(preRx, preRy, preRz); + const lclQuat = eulerToQuaternion(rxRad, ryRad, rzRad); + quat = multiplyQuaternion(preQuat, lclQuat); + } else { + quat = eulerToQuaternion(rxRad, ryRad, rzRad); + } + + transform.rotation = quat; + } else if (target.property.includes('Scaling')) { + transform.scale = [ + sampleCurveAtT0(curves.x) || 1, + sampleCurveAtT0(curves.y) || 1, + sampleCurveAtT0(curves.z) || 1 + ]; + } +} + +// Compare with node.transform for first joint +const firstJointNodeIndex = 1; // Bone001 is at index 1 + +const sampledT = sampledTransforms.get(firstJointNodeIndex); +const model = models[firstJointNodeIndex]; + +console.log(`First bone: "${model.name}" (nodeIndex=${firstJointNodeIndex})`); +console.log(`\nnode.transform (from Lcl*):`); +console.log(` position: [${model.position.join(', ')}]`); +console.log(` rotation: [${model.rotation.join(', ')}] (degrees)`); +console.log(` scale: [${model.scale.join(', ')}]`); +if (model.preRotation) { + console.log(` preRotation: [${model.preRotation.join(', ')}] (degrees)`); +} + +console.log(`\nAnimation at t=0:`); +if (sampledT) { + console.log(` position: [${sampledT.position?.join(', ') || 'null'}]`); + console.log(` rotation: [${sampledT.rotation?.map(v => v.toFixed(4)).join(', ') || 'null'}]`); + console.log(` scale: [${sampledT.scale?.join(', ') || 'null'}]`); +} else { + console.log(` No animation data!`); +} + +// Now build quaternion from node.transform for comparison +const nodeRotRad = model.rotation.map(v => v * Math.PI / 180); +let nodeQuat; +if (model.preRotation) { + const preRad = model.preRotation.map(v => v * Math.PI / 180); + const preQuat = eulerToQuaternion(preRad[0], preRad[1], preRad[2]); + const lclQuat = eulerToQuaternion(nodeRotRad[0], nodeRotRad[1], nodeRotRad[2]); + nodeQuat = multiplyQuaternion(preQuat, lclQuat); +} else { + nodeQuat = eulerToQuaternion(nodeRotRad[0], nodeRotRad[1], nodeRotRad[2]); +} + +console.log(`\nnode.transform rotation as quaternion: [${nodeQuat.map(v => v.toFixed(4)).join(', ')}]`); +if (sampledT?.rotation) { + console.log(`animation rotation quaternion: [${sampledT.rotation.map(v => v.toFixed(4)).join(', ')}]`); + + // Check if they match + const match = nodeQuat.every((v, i) => Math.abs(v - sampledT.rotation[i]) < 0.001); + console.log(`\nDo they match? ${match ? 'YES ✅' : 'NO ❌'}`); +} + +console.log('\nDone!'); diff --git a/scripts/verify-animation-skeleton-mapping.mjs b/scripts/verify-animation-skeleton-mapping.mjs new file mode 100644 index 00000000..7527375e --- /dev/null +++ b/scripts/verify-animation-skeleton-mapping.mjs @@ -0,0 +1,351 @@ +/** + * Verify Animation-Skeleton Mapping + * 验证动画通道和骨骼关节的 nodeIndex 映射关系 + * + * This script simulates the exact data flow from FBXLoader to ModelPreview3D + * 此脚本模拟 FBXLoader 到 ModelPreview3D 的完整数据流 + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const FBX_TIME_SECOND = 46186158000n; +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Analyzing: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + const startOffset = offset; + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Models (this creates the 'models' array - same as in FBXLoader) +const models = objectsNode.children + .filter(n => n.name === 'Model') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Model', + type: n.properties[2]?.split?.('\0')[0] || '' + })); + +// Build modelToIndex (simulating FBXLoader line 237-240) +const modelToIndex = new Map(); +models.forEach((model, index) => { + modelToIndex.set(model.id, index); +}); + +console.log(`Total models: ${models.length}`); +console.log(`First 10 models:`); +models.slice(0, 10).forEach((m, i) => { + console.log(` [${i}] ID=${m.id}, name="${m.name}", type="${m.type}"`); +}); + +// Parse Clusters +const clusters = objectsNode.children + .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Cluster' + })); + +// Build cluster to bone mapping (simulating FBXLoader line 1658-1670) +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) { + clusterToBone.set(cluster.id, conn.fromId); + } + } +} + +// Build skeleton joints (simulating FBXLoader line 1682-1717) +const joints = []; +const boneModelIdToJointIndex = new Map(); + +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const nodeIndex = modelToIndex.get(boneModelId); + if (nodeIndex === undefined) continue; + + const model = models[nodeIndex]; + const jointIndex = joints.length; + boneModelIdToJointIndex.set(boneModelId, jointIndex); + + joints.push({ + name: model.name, + nodeIndex, // This is model index in models array + boneModelId + }); +} + +console.log(`\n=== SKELETON JOINTS (${joints.length}) ===`); +console.log(`First 10 joints:`); +joints.slice(0, 10).forEach((j, i) => { + console.log(` Joint[${i}] nodeIndex=${j.nodeIndex}, name="${j.name}"`); +}); + +// Parse AnimationCurveNodes +const curveNodes = objectsNode.children + .filter(n => n.name === 'AnimationCurveNode') + .map(n => ({ + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || '' + })); + +// Build animation channel targets (simulating FBXLoader line 1337-1443) +// For each curveNode, find which model it targets +const curveNodeToModel = new Map(); +for (const conn of connections) { + if (conn.type === 'OP' && conn.property?.includes('Lcl')) { + const curveNode = curveNodes.find(cn => cn.id === conn.fromId); + if (curveNode) { + curveNodeToModel.set(curveNode.id, conn.toId); + } + } +} + +// Build animation channels (simulating FBXLoader buildAnimations) +const animationChannels = []; +for (const curveNode of curveNodes) { + const targetModelId = curveNodeToModel.get(curveNode.id); + if (!targetModelId) continue; + + const nodeIndex = modelToIndex.get(targetModelId); + if (nodeIndex === undefined) continue; + + animationChannels.push({ + curveNodeName: curveNode.name, + targetModelId, + nodeIndex, // This should match joint.nodeIndex + targetModelName: models[nodeIndex]?.name + }); +} + +console.log(`\n=== ANIMATION CHANNELS (${animationChannels.length}) ===`); +const uniqueTargetIndices = [...new Set(animationChannels.map(c => c.nodeIndex))]; +console.log(`Unique target nodeIndices: ${uniqueTargetIndices.length}`); +console.log(`First 10 channel targets:`); +animationChannels.slice(0, 10).forEach((c, i) => { + console.log(` Channel[${i}] nodeIndex=${c.nodeIndex}, target="${c.targetModelName}", type="${c.curveNodeName}"`); +}); + +// NOW THE KEY CHECK: Do animation channel nodeIndices match joint nodeIndices? +console.log(`\n=== CRITICAL CHECK: Animation-Skeleton Mapping ===`); + +const jointNodeIndices = new Set(joints.map(j => j.nodeIndex)); +const animNodeIndices = new Set(animationChannels.map(c => c.nodeIndex)); + +console.log(`Skeleton joint nodeIndices: ${jointNodeIndices.size}`); +console.log(`Animation target nodeIndices: ${animNodeIndices.size}`); + +// Check intersection +const matchingIndices = [...jointNodeIndices].filter(idx => animNodeIndices.has(idx)); +const jointsWithoutAnim = [...jointNodeIndices].filter(idx => !animNodeIndices.has(idx)); +const animWithoutJoint = [...animNodeIndices].filter(idx => !jointNodeIndices.has(idx)); + +console.log(`\nJoints WITH matching animation: ${matchingIndices.length}/${joints.length}`); +console.log(`Joints WITHOUT animation: ${jointsWithoutAnim.length}`); +console.log(`Animation targets that are NOT joints: ${animWithoutJoint.length}`); + +if (jointsWithoutAnim.length > 0) { + console.log(`\n⚠️ WARNING: Some joints have no animation!`); + console.log(`Missing animation for joints:`); + jointsWithoutAnim.slice(0, 10).forEach(idx => { + const joint = joints.find(j => j.nodeIndex === idx); + console.log(` nodeIndex=${idx}, name="${joint?.name}"`); + }); +} + +if (animWithoutJoint.length > 0) { + console.log(`\nAnimation targets that are not skeleton joints:`); + animWithoutJoint.slice(0, 10).forEach(idx => { + const model = models[idx]; + console.log(` nodeIndex=${idx}, name="${model?.name}", type="${model?.type}"`); + }); +} + +// Simulate ModelPreview3D's sampleAnimation lookup +console.log(`\n=== SIMULATING ModelPreview3D LOOKUP ===`); +console.log(`When ModelPreview3D calls: animTransforms.get(joint.nodeIndex)`); + +// Create a mock animTransforms map (like sampleAnimation returns) +const mockAnimTransforms = new Map(); +for (const channel of animationChannels) { + if (!mockAnimTransforms.has(channel.nodeIndex)) { + mockAnimTransforms.set(channel.nodeIndex, { hasData: true }); + } +} + +let matchCount = 0; +let missCount = 0; +for (const joint of joints) { + if (mockAnimTransforms.has(joint.nodeIndex)) { + matchCount++; + } else { + missCount++; + if (missCount <= 5) { + console.log(` ❌ Joint "${joint.name}" (nodeIndex=${joint.nodeIndex}) has NO animation data!`); + } + } +} + +console.log(`\n✅ Joints with animation data: ${matchCount}/${joints.length}`); +console.log(`❌ Joints WITHOUT animation data: ${missCount}/${joints.length}`); + +if (missCount === 0) { + console.log(`\n🎉 All joints have matching animation data! The mapping is correct.`); + console.log(`The issue must be elsewhere in the pipeline.`); +} else { + console.log(`\n⚠️ PROBLEM FOUND: ${missCount} joints have no animation data!`); + console.log(`This explains why the animation doesn't work correctly.`); +} + +console.log('\nDone!'); diff --git a/scripts/verify-mesh-skinning.mjs b/scripts/verify-mesh-skinning.mjs new file mode 100644 index 00000000..3f2695ba --- /dev/null +++ b/scripts/verify-mesh-skinning.mjs @@ -0,0 +1,388 @@ +/** + * Verify Mesh Skinning Data + * 验证网格蒙皮数据 + * + * Check if joints/weights arrays in the mesh are correctly mapped + * to skeleton joint indices. + */ + +import { readFileSync } from 'fs'; +import pako from 'pako'; +const { inflate } = pako; + +const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; + +console.log(`=== Verifying Mesh Skinning Data: ${filePath} ===\n`); + +const buffer = readFileSync(filePath); +const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); +const version = view.getUint32(23, true); +const is64Bit = version >= 7500; + +let offset = 27; + +function readNode() { + let endOffset, numProperties, propertyListLen, nameLen; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + nameLen = view.getUint8(offset + 24); + offset += 25; + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + nameLen = view.getUint8(offset + 12); + offset += 13; + } + + if (endOffset === 0) return null; + + const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); + offset += nameLen; + + const properties = []; + const propsEnd = offset + propertyListLen; + + while (offset < propsEnd) { + const typeCode = String.fromCharCode(buffer[offset]); + offset++; + + switch (typeCode) { + case 'Y': + properties.push(view.getInt16(offset, true)); + offset += 2; + break; + case 'C': + properties.push(buffer[offset] !== 0); + offset += 1; + break; + case 'I': + properties.push(view.getInt32(offset, true)); + offset += 4; + break; + case 'F': + properties.push(view.getFloat32(offset, true)); + offset += 4; + break; + case 'D': + properties.push(view.getFloat64(offset, true)); + offset += 8; + break; + case 'L': + properties.push(view.getBigInt64(offset, true)); + offset += 8; + break; + case 'S': + case 'R': + const strLen = view.getUint32(offset, true); + offset += 4; + if (typeCode === 'S') { + properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); + } else { + properties.push(buffer.slice(offset, offset + strLen)); + } + offset += strLen; + break; + case 'f': + case 'd': + case 'l': + case 'i': + case 'b': + const arrayLen = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); + const compressedLen = view.getUint32(offset + 8, true); + offset += 12; + + if (encoding === 0) { + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); + else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); + else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); + else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + offset += arrayLen * elemSize; + } else { + const compData = buffer.slice(offset, offset + compressedLen); + try { + const decompressed = inflate(compData); + const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; + const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); + const arr = []; + for (let i = 0; i < arrayLen; i++) { + if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); + else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); + else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); + else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); + } + properties.push({ type: typeCode, data: arr }); + } catch (e) { + properties.push({ type: typeCode, compressed: true, len: arrayLen }); + } + offset += compressedLen; + } + break; + default: + offset = propsEnd; + } + } + + const children = []; + while (offset < endOffset) { + const child = readNode(); + if (child) children.push(child); + else break; + } + + offset = endOffset; + return { name, properties, children }; +} + +// Parse root nodes +const rootNodes = []; +while (offset < buffer.length - 100) { + const node = readNode(); + if (node) rootNodes.push(node); + else break; +} + +const objectsNode = rootNodes.find(n => n.name === 'Objects'); +const connectionsNode = rootNodes.find(n => n.name === 'Connections'); + +// Parse connections +const connections = connectionsNode.children.map(c => ({ + type: c.properties[0].split('\0')[0], + fromId: c.properties[1], + toId: c.properties[2], + property: c.properties[3]?.split?.('\0')[0] +})); + +// Parse Geometries +const geometries = objectsNode.children + .filter(n => n.name === 'Geometry') + .map(n => { + const verticesNode = n.children.find(c => c.name === 'Vertices'); + const vertices = verticesNode?.properties[0]?.data || []; + return { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || 'Geometry', + vertexCount: vertices.length / 3 + }; + }); + +console.log(`Found ${geometries.length} geometries`); +geometries.forEach(g => { + console.log(` Geometry: "${g.name}", ${g.vertexCount} vertices`); +}); + +// Parse Deformers (Skin and Cluster) +const deformers = objectsNode.children + .filter(n => n.name === 'Deformer') + .map(n => { + const deformer = { + id: n.properties[0], + name: n.properties[1]?.split?.('\0')[0] || '', + type: n.properties[2]?.split?.('\0')[0] || '' + }; + + if (deformer.type === 'Cluster') { + const indexesNode = n.children.find(c => c.name === 'Indexes'); + const weightsNode = n.children.find(c => c.name === 'Weights'); + deformer.indexes = indexesNode?.properties[0]?.data || []; + deformer.weights = weightsNode?.properties[0]?.data || []; + } + + return deformer; + }); + +const skins = deformers.filter(d => d.type === 'Skin'); +const clusters = deformers.filter(d => d.type === 'Cluster'); + +console.log(`\nFound ${skins.length} skins, ${clusters.length} clusters`); + +// Build cluster-to-skeleton-joint mapping (same as FBXLoader) +// First, find which bone each cluster is connected to +const clusterToBone = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const cluster = clusters.find(c => c.id === conn.toId); + if (cluster) { + clusterToBone.set(cluster.id, conn.fromId); + } + } +} + +// Build skeleton joints (same order as FBXLoader) +const joints = []; +const clusterToJointIndex = new Map(); + +for (const cluster of clusters) { + const boneModelId = clusterToBone.get(cluster.id); + if (!boneModelId) continue; + + const jointIndex = joints.length; + clusterToJointIndex.set(cluster.id, jointIndex); + joints.push({ + name: cluster.name, + clusterId: cluster.id, + boneModelId + }); +} + +console.log(`\nBuilt ${joints.length} skeleton joints`); +console.log(`First 5 joints:`); +joints.slice(0, 5).forEach((j, i) => { + console.log(` Joint[${i}] name="${j.name}"`); +}); + +// Build Skin -> Clusters mapping +const skinClusters = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const skin = skins.find(s => s.id === conn.toId); + const cluster = clusters.find(c => c.id === conn.fromId); + if (skin && cluster) { + if (!skinClusters.has(skin.id)) { + skinClusters.set(skin.id, []); + } + skinClusters.get(skin.id).push(cluster); + } + } +} + +// Build Geometry -> Skin mapping +const geometrySkin = new Map(); +for (const conn of connections) { + if (conn.type === 'OO') { + const geom = geometries.find(g => g.id === conn.toId); + const skin = skins.find(s => s.id === conn.fromId); + if (geom && skin) { + geometrySkin.set(geom.id, skin.id); + } + } +} + +// Now simulate buildSkinningData +console.log(`\n=== SIMULATING buildSkinningData ===`); + +for (const [geomId, skinId] of geometrySkin) { + const geom = geometries.find(g => g.id === geomId); + const clusterList = skinClusters.get(skinId); + + if (!geom || !clusterList || clusterList.length === 0) continue; + + console.log(`\nProcessing geometry "${geom.name}" with ${clusterList.length} clusters`); + + const vertexCount = geom.vertexCount; + const joints4 = new Uint8Array(vertexCount * 4); + const weights4 = new Float32Array(vertexCount * 4); + + // Temporary storage for per-vertex influences + const vertexInfluences = []; + for (let i = 0; i < vertexCount; i++) { + vertexInfluences.push([]); + } + + // Collect influences from each cluster + for (const cluster of clusterList) { + if (!cluster.indexes || !cluster.weights) continue; + + const jointIndex = clusterToJointIndex.get(cluster.id); + if (jointIndex === undefined) { + console.warn(` WARNING: Cluster ${cluster.id} not found in skeleton`); + continue; + } + + for (let i = 0; i < cluster.indexes.length; i++) { + const vertexIndex = cluster.indexes[i]; + const weight = cluster.weights[i]; + if (vertexIndex < vertexCount && weight > 0.001) { + vertexInfluences[vertexIndex].push({ + joint: jointIndex, + weight + }); + } + } + } + + // Convert to fixed 4-influence format and normalize + let maxJointIndex = 0; + let totalInfluences = 0; + let verticesWithInfluences = 0; + + for (let v = 0; v < vertexCount; v++) { + const influences = vertexInfluences[v]; + if (influences.length === 0) continue; + + verticesWithInfluences++; + totalInfluences += influences.length; + + // Sort by weight descending + influences.sort((a, b) => b.weight - a.weight); + + // Take top 4 influences + let totalWeight = 0; + for (let i = 0; i < 4 && i < influences.length; i++) { + joints4[v * 4 + i] = influences[i].joint; + weights4[v * 4 + i] = influences[i].weight; + totalWeight += influences[i].weight; + if (influences[i].joint > maxJointIndex) { + maxJointIndex = influences[i].joint; + } + } + + // Normalize weights + if (totalWeight > 0) { + for (let i = 0; i < 4; i++) { + weights4[v * 4 + i] /= totalWeight; + } + } + } + + console.log(` Vertices with skinning: ${verticesWithInfluences}/${vertexCount}`); + console.log(` Max joint index used: ${maxJointIndex}`); + console.log(` Total skeleton joints: ${joints.length}`); + console.log(` Avg influences per vertex: ${(totalInfluences / verticesWithInfluences).toFixed(2)}`); + + // Check if max joint index exceeds skeleton size + if (maxJointIndex >= joints.length) { + console.log(` ⚠️ ERROR: Max joint index (${maxJointIndex}) >= skeleton size (${joints.length})`); + } else { + console.log(` ✅ Joint indices are within valid range`); + } + + // Sample some vertex data + console.log(`\n Sample vertex skinning data (first 5 skinned vertices):`); + let sampleCount = 0; + for (let v = 0; v < vertexCount && sampleCount < 5; v++) { + const w0 = weights4[v * 4]; + if (w0 > 0) { + const j0 = joints4[v * 4]; + const j1 = joints4[v * 4 + 1]; + const j2 = joints4[v * 4 + 2]; + const j3 = joints4[v * 4 + 3]; + const w1 = weights4[v * 4 + 1]; + const w2 = weights4[v * 4 + 2]; + const w3 = weights4[v * 4 + 3]; + console.log(` Vertex[${v}]: joints=[${j0},${j1},${j2},${j3}], weights=[${w0.toFixed(3)},${w1.toFixed(3)},${w2.toFixed(3)},${w3.toFixed(3)}]`); + sampleCount++; + } + } + + // Check weight normalization + let badWeights = 0; + for (let v = 0; v < vertexCount; v++) { + const sum = weights4[v * 4] + weights4[v * 4 + 1] + weights4[v * 4 + 2] + weights4[v * 4 + 3]; + if (sum > 0 && Math.abs(sum - 1.0) > 0.01) { + badWeights++; + } + } + console.log(`\n Weight normalization check: ${badWeights} vertices with bad weights`); +} + +console.log('\nDone!');