feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 (#315)
* feat(3d): FBX/GLTF/OBJ 加载器与骨骼动画支持 * chore: 更新 pnpm-lock.yaml * fix: 移除未使用的变量和方法 * fix: 修复 mesh-3d-editor tsconfig 引用路径 * fix: 修复正则表达式 ReDoS 漏洞
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<AssetType, IAssetLoader>();
|
||||
|
||||
/** Extension -> Loader map for precise loader selection */
|
||||
/** 扩展名 -> 加载器映射,用于精确选择加载器 */
|
||||
private readonly _extensionLoaders = new Map<string, IAssetLoader>();
|
||||
|
||||
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<string>();
|
||||
|
||||
// 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<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
2193
packages/asset-system/src/loaders/FBXLoader.ts
Normal file
2193
packages/asset-system/src/loaders/FBXLoader.ts
Normal file
File diff suppressed because it is too large
Load Diff
994
packages/asset-system/src/loaders/GLTFLoader.ts
Normal file
994
packages/asset-system/src/loaders/GLTFLoader.ts
Normal file
@@ -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<string, number>;
|
||||
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<IGLTFAsset> {
|
||||
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<IGLTFAsset> {
|
||||
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<ArrayBuffer[]> {
|
||||
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<IGLTFTextureInfo[]> {
|
||||
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<number, number>();
|
||||
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]
|
||||
};
|
||||
}
|
||||
}
|
||||
553
packages/asset-system/src/loaders/OBJLoader.ts
Normal file
553
packages/asset-system/src/loaders/OBJLoader.ts
Normal file
@@ -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<IGLTFAsset> {
|
||||
readonly supportedType = AssetType.Model3D;
|
||||
readonly supportedExtensions = ['.obj'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Parse OBJ content
|
||||
* 解析 OBJ 内容
|
||||
*/
|
||||
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IGLTFAsset> {
|
||||
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<string, number>();
|
||||
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<string>();
|
||||
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]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export const AssetType = {
|
||||
Texture: 'texture',
|
||||
/** 网格 */
|
||||
Mesh: 'mesh',
|
||||
/** 3D模型 (GLTF/GLB) | 3D Model */
|
||||
Model3D: 'model3d',
|
||||
/** 材质 */
|
||||
Material: 'material',
|
||||
/** 着色器 */
|
||||
|
||||
@@ -91,6 +91,9 @@ export const STANDARD_EXTERNALS = [
|
||||
'zustand',
|
||||
'immer',
|
||||
|
||||
// Tauri (由宿主应用提供) | Provided by host app
|
||||
/^@tauri-apps\//,
|
||||
|
||||
// 所有 @esengine 包
|
||||
/^@esengine\//,
|
||||
] as const;
|
||||
|
||||
@@ -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.
|
||||
* 销毁桥接并释放资源。
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export {
|
||||
RenderSystemToken,
|
||||
EngineIntegrationToken,
|
||||
EngineBridgeToken,
|
||||
// 新的单一职责服务令牌 | New single-responsibility service tokens
|
||||
TextureServiceToken,
|
||||
DynamicAtlasServiceToken,
|
||||
|
||||
@@ -54,3 +54,8 @@ export interface IEngineIntegration {
|
||||
|
||||
export const RenderSystemToken = createServiceToken<IRenderSystem>('renderSystem');
|
||||
export const EngineIntegrationToken = createServiceToken<IEngineIntegration>('engineIntegration');
|
||||
|
||||
// EngineBridge token - used by systems that need direct engine access
|
||||
// EngineBridge 令牌 - 供需要直接访问引擎的系统使用
|
||||
import type { EngineBridge } from './core/EngineBridge';
|
||||
export const EngineBridgeToken = createServiceToken<EngineBridge>('engineBridge');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -75,12 +75,30 @@ export class TauriAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
* 读取文件内容(文本)
|
||||
*/
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(二进制)
|
||||
* Read file content as binary ArrayBuffer
|
||||
*/
|
||||
static async readFileBinary(path: string): Promise<ArrayBuffer> {
|
||||
// Use Tauri read_file_as_base64 command which returns base64 encoded data
|
||||
// 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据
|
||||
const base64: string = await invoke<string>('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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
// Expanded model assets (for viewing sub-assets)
|
||||
// 展开的模型资产(用于查看子资产)
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
const [modelSubAssets, setModelSubAssets] = useState<Map<string, AssetItem[]>>(new Map());
|
||||
const [loadingModels, setLoadingModels] = useState<Set<string>>(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<AssetItem[]> => {
|
||||
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 <Folder size={size} className="asset-thumbnail-icon folder" />;
|
||||
}
|
||||
|
||||
// Handle sub-assets
|
||||
// 处理子资产
|
||||
if (asset.type === 'sub-asset') {
|
||||
switch (asset.subAssetType) {
|
||||
case 'mesh':
|
||||
return <Box size={size} className="asset-thumbnail-icon sub-asset mesh" />;
|
||||
case 'material':
|
||||
return <Palette size={size} className="asset-thumbnail-icon sub-asset material" />;
|
||||
case 'animation':
|
||||
return <Film size={size} className="asset-thumbnail-icon sub-asset animation" />;
|
||||
case 'skeleton':
|
||||
return <Bone size={size} className="asset-thumbnail-icon sub-asset skeleton" />;
|
||||
default:
|
||||
return <File size={size} className="asset-thumbnail-icon sub-asset" />;
|
||||
}
|
||||
}
|
||||
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ecs':
|
||||
@@ -1213,6 +1454,13 @@ export class ${className} {
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return <FileImage size={size} className="asset-thumbnail-icon image" />;
|
||||
// 3D Model files | 3D 模型文件
|
||||
case 'fbx':
|
||||
case 'obj':
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
case 'dae':
|
||||
return <Box size={size} className="asset-thumbnail-icon model3d" />;
|
||||
default:
|
||||
return <File size={size} className="asset-thumbnail-icon" />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
key={asset.path}
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''}`}
|
||||
className={`cb-asset-item ${selectedPaths.has(asset.path) ? 'selected' : ''} ${isDragOverAsset ? 'drag-over' : ''} ${isSubAsset ? 'sub-asset' : ''} ${isModelExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => 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 && (
|
||||
<button
|
||||
className={`cb-asset-expand-btn ${isModelExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => toggleModelExpand(asset.path, e)}
|
||||
title={isModelExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isModelLoading ? (
|
||||
<Loader2 size={12} className="spinning" />
|
||||
) : isModelExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="cb-asset-thumbnail">
|
||||
{getFileIcon(asset)}
|
||||
</div>
|
||||
|
||||
@@ -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<Entity | null>(null);
|
||||
const messageHubRef = useRef<MessageHub | null>(null);
|
||||
const commandManagerRef = useRef<CommandManager | null>(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);
|
||||
|
||||
@@ -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(() => {
|
||||
if (!isDragging) {
|
||||
setInputValue(String(value ?? 0));
|
||||
}, [value]);
|
||||
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<HTMLInputElement>) => {
|
||||
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}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="y"
|
||||
value={value?.y ?? 0}
|
||||
onChange={(v) => handleAxisChange('y', v)}
|
||||
onChangeCommit={onChangeCommit}
|
||||
suffix={suffix}
|
||||
/>
|
||||
<AxisInput
|
||||
axis="z"
|
||||
value={value?.z ?? 0}
|
||||
onChange={(v) => handleAxisChange('z', v)}
|
||||
onChangeCommit={onChangeCommit}
|
||||
suffix={suffix}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
<TransformRow
|
||||
label="Rotation"
|
||||
value={transform.rotation}
|
||||
onChange={handleRotationChange}
|
||||
onReset={() => handleRotationChange({ x: 0, y: 0, z: 0 })}
|
||||
onChangeCommit={handleRotationCommit}
|
||||
onReset={handleRotationReset}
|
||||
suffix="°"
|
||||
/>
|
||||
<TransformRow
|
||||
@@ -270,7 +346,8 @@ function TransformInspectorContent({ context }: { context: ComponentInspectorCon
|
||||
isLocked={isScaleLocked}
|
||||
onLockChange={setIsScaleLocked}
|
||||
onChange={handleScaleChange}
|
||||
onReset={() => handleScaleChange({ x: 1, y: 1, z: 1 })}
|
||||
onChangeCommit={handleScaleCommit}
|
||||
onReset={handleScaleReset}
|
||||
showDivider={false}
|
||||
/>
|
||||
<div className="tf-divider" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
48
packages/mesh-3d-editor/package.json
Normal file
48
packages/mesh-3d-editor/package.json
Normal file
@@ -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"
|
||||
}
|
||||
124
packages/mesh-3d-editor/src/MeshComponentInspector.css
Normal file
124
packages/mesh-3d-editor/src/MeshComponentInspector.css
Normal file
@@ -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;
|
||||
}
|
||||
202
packages/mesh-3d-editor/src/MeshComponentInspector.tsx
Normal file
202
packages/mesh-3d-editor/src/MeshComponentInspector.tsx
Normal file
@@ -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 (
|
||||
<div className="mesh-info-empty">
|
||||
<Info size={14} />
|
||||
<span>No model loaded</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const asset = mesh.meshAsset;
|
||||
const currentMesh = mesh.currentMesh;
|
||||
const totalMeshes = asset.meshes?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mesh-info-section">
|
||||
<div
|
||||
className="mesh-info-header"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="mesh-info-expand">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<Box size={14} />
|
||||
<span className="mesh-info-title">Mesh Info</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mesh-info-content">
|
||||
{/* Model name */}
|
||||
<div className="mesh-info-row">
|
||||
<label>Name</label>
|
||||
<span className="mesh-info-value">{asset.name || 'Unnamed'}</span>
|
||||
</div>
|
||||
|
||||
{/* Total meshes */}
|
||||
<div className="mesh-info-row">
|
||||
<label>Meshes</label>
|
||||
<span className="mesh-info-value">{totalMeshes}</span>
|
||||
</div>
|
||||
|
||||
{/* Current mesh details */}
|
||||
{currentMesh && (
|
||||
<>
|
||||
<div className="mesh-info-divider" />
|
||||
<div className="mesh-info-subtitle">Current Mesh ({mesh.meshIndex})</div>
|
||||
|
||||
<div className="mesh-info-row">
|
||||
<label>Mesh Name</label>
|
||||
<span className="mesh-info-value">{currentMesh.name || `Mesh ${mesh.meshIndex}`}</span>
|
||||
</div>
|
||||
|
||||
{currentMesh.vertices && (
|
||||
<div className="mesh-info-row">
|
||||
<label>Vertices</label>
|
||||
<span className="mesh-info-value">{Math.floor(currentMesh.vertices.length / 3).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentMesh.indices && (
|
||||
<div className="mesh-info-row">
|
||||
<label>Triangles</label>
|
||||
<span className="mesh-info-value">{Math.floor(currentMesh.indices.length / 3).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Materials */}
|
||||
{asset.materials && asset.materials.length > 0 && (
|
||||
<>
|
||||
<div className="mesh-info-divider" />
|
||||
<div className="mesh-info-subtitle">Materials ({asset.materials.length})</div>
|
||||
<div className="mesh-info-materials">
|
||||
{asset.materials.map((mat, i) => (
|
||||
<div key={i} className="mesh-info-material">
|
||||
<span className="mesh-info-material-index">{i}</span>
|
||||
<span className="mesh-info-material-name">{mat.name || `Material ${i}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bounds */}
|
||||
{asset.bounds && (
|
||||
<>
|
||||
<div className="mesh-info-divider" />
|
||||
<div className="mesh-info-subtitle">Bounds</div>
|
||||
<div className="mesh-info-row">
|
||||
<label>Min</label>
|
||||
<span className="mesh-info-value mesh-info-vec3">
|
||||
({asset.bounds.min[0].toFixed(2)}, {asset.bounds.min[1].toFixed(2)}, {asset.bounds.min[2].toFixed(2)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mesh-info-row">
|
||||
<label>Max</label>
|
||||
<span className="mesh-info-value mesh-info-vec3">
|
||||
({asset.bounds.max[0].toFixed(2)}, {asset.bounds.max[1].toFixed(2)}, {asset.bounds.max[2].toFixed(2)})
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>)[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 (
|
||||
<div className="mesh-component-inspector">
|
||||
{/* Mesh info display */}
|
||||
<MeshInfo mesh={mesh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh component inspector implementation.
|
||||
* 网格组件检查器实现。
|
||||
*
|
||||
* Uses 'append' mode to show mesh info after the default PropertyInspector.
|
||||
* 使用 'append' 模式在默认 PropertyInspector 后显示网格信息。
|
||||
*/
|
||||
export class MeshComponentInspector implements IComponentInspector<MeshComponent> {
|
||||
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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
498
packages/mesh-3d-editor/src/components/AnimationPreviewPanel.tsx
Normal file
498
packages/mesh-3d-editor/src/components/AnimationPreviewPanel.tsx
Normal file
@@ -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<ArrayBuffer | null> {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const base64: string = await invoke<string>('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<AnimationPreviewState>(initialState);
|
||||
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const lastTimeRef = useRef<number>(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<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
setTime(value);
|
||||
}, [setTime]);
|
||||
|
||||
const handleSpeedChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSpeed(parseFloat(e.target.value));
|
||||
}, [setSpeed]);
|
||||
|
||||
// Render loading state | 渲染加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animation-preview-panel loading">
|
||||
<RefreshCw className="spin" size={24} />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render empty state | 渲染空状态
|
||||
if (!asset) {
|
||||
return (
|
||||
<div className="animation-preview-panel empty">
|
||||
<Activity size={48} strokeWidth={1} />
|
||||
<p>No model loaded</p>
|
||||
<p className="hint">Double-click a model or animation in Content Browser</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="animation-preview-panel">
|
||||
{/* Header | 头部 */}
|
||||
<div className="panel-header">
|
||||
<span className="asset-name" title={assetPath ?? ''}>
|
||||
{assetPath?.split(/[\\/]/).pop() ?? 'Unknown'}
|
||||
</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setState(initialState)}
|
||||
title="Clear"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 3D Preview | 3D 预览 */}
|
||||
{hasMeshes && (
|
||||
<div className="preview-viewport">
|
||||
<ModelPreview3D
|
||||
asset={asset}
|
||||
animationClip={currentClip}
|
||||
currentTime={currentTime}
|
||||
width={280}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No mesh message | 无网格消息 */}
|
||||
{!hasMeshes && (
|
||||
<div className="no-mesh-message">
|
||||
<p>No mesh data in this file</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation selector | 动画选择器 */}
|
||||
{hasAnimations && (
|
||||
<div className="animation-selector">
|
||||
<label>Animation:</label>
|
||||
<div className="select-wrapper">
|
||||
<select
|
||||
value={selectedAnimationIndex}
|
||||
onChange={(e) => selectAnimation(parseInt(e.target.value))}
|
||||
>
|
||||
{animations.map((anim: IGLTFAnimationClip, index: number) => (
|
||||
<option key={index} value={index}>
|
||||
{anim.name || `Animation ${index}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Animation info | 动画信息 */}
|
||||
{currentClip && (
|
||||
<div className="animation-info">
|
||||
<div className="info-row">
|
||||
<Clock size={14} />
|
||||
<span>Duration: {formatTime(currentClip.duration)}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<Layers size={14} />
|
||||
<span>Channels: {currentClip.channels?.length ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline | 时间轴 */}
|
||||
{hasAnimations && (
|
||||
<div className="timeline-section">
|
||||
<div className="time-display">
|
||||
<span className="current-time">{formatTime(currentTime)}</span>
|
||||
<span className="separator">/</span>
|
||||
<span className="total-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
<div className="timeline-track">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration}
|
||||
step={0.01}
|
||||
value={currentTime}
|
||||
onChange={handleTimelineChange}
|
||||
className="timeline-slider"
|
||||
/>
|
||||
<div
|
||||
className="timeline-progress"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback controls | 播放控制 */}
|
||||
{hasAnimations && (
|
||||
<div className="playback-controls">
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={() => setTime(0)}
|
||||
title="Go to start"
|
||||
>
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
|
||||
{isPlaying ? (
|
||||
<button
|
||||
className="control-button primary"
|
||||
onClick={pause}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="control-button primary"
|
||||
onClick={play}
|
||||
title="Play"
|
||||
>
|
||||
<Play size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={stop}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="control-button"
|
||||
onClick={() => setTime(duration)}
|
||||
title="Go to end"
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options | 选项 */}
|
||||
{hasAnimations && (
|
||||
<div className="playback-options">
|
||||
<div className="option-row">
|
||||
<label>Speed:</label>
|
||||
<select value={speed} onChange={handleSpeedChange}>
|
||||
<option value={0.25}>0.25x</option>
|
||||
<option value={0.5}>0.5x</option>
|
||||
<option value={1}>1x</option>
|
||||
<option value={1.5}>1.5x</option>
|
||||
<option value={2}>2x</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="option-row">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={loop}
|
||||
onChange={(e) => setLoop(e.target.checked)}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No animations message | 无动画消息 */}
|
||||
{hasMeshes && !hasAnimations && (
|
||||
<div className="no-animations">
|
||||
<p>This model has no animations</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model info | 模型信息 */}
|
||||
<div className="model-info">
|
||||
<div className="section-title">Model Info</div>
|
||||
<div className="info-row">
|
||||
<span>Meshes: {asset.meshes?.length ?? 0}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span>Materials: {asset.materials?.length ?? 0}</span>
|
||||
</div>
|
||||
{asset.skeleton && (
|
||||
<div className="info-row">
|
||||
<span>Joints: {asset.skeleton.joints?.length ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnimationPreviewPanel;
|
||||
1130
packages/mesh-3d-editor/src/components/ModelPreview3D.tsx
Normal file
1130
packages/mesh-3d-editor/src/components/ModelPreview3D.tsx
Normal file
File diff suppressed because it is too large
Load Diff
221
packages/mesh-3d-editor/src/index.ts
Normal file
221
packages/mesh-3d-editor/src/index.ts
Normal file
@@ -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<void> {
|
||||
// 注册组件检查器 | 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<void> {
|
||||
// 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;
|
||||
377
packages/mesh-3d-editor/src/styles/AnimationPreviewPanel.css
Normal file
377
packages/mesh-3d-editor/src/styles/AnimationPreviewPanel.css
Normal file
@@ -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);
|
||||
}
|
||||
13
packages/mesh-3d-editor/tsconfig.build.json
Normal file
13
packages/mesh-3d-editor/tsconfig.build.json
Normal file
@@ -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"]
|
||||
}
|
||||
17
packages/mesh-3d-editor/tsconfig.json
Normal file
17
packages/mesh-3d-editor/tsconfig.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
7
packages/mesh-3d-editor/tsup.config.ts
Normal file
7
packages/mesh-3d-editor/tsup.config.ts
Normal file
@@ -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'
|
||||
});
|
||||
49
packages/mesh-3d/package.json
Normal file
49
packages/mesh-3d/package.json
Normal file
@@ -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"
|
||||
}
|
||||
341
packages/mesh-3d/src/Animation3DComponent.ts
Normal file
341
packages/mesh-3d/src/Animation3DComponent.ts
Normal file
@@ -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<string, number> = 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();
|
||||
}
|
||||
}
|
||||
115
packages/mesh-3d/src/Mesh3DRuntimeModule.ts
Normal file
115
packages/mesh-3d/src/Mesh3DRuntimeModule.ts
Normal file
@@ -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 };
|
||||
151
packages/mesh-3d/src/MeshComponent.ts
Normal file
151
packages/mesh-3d/src/MeshComponent.ts
Normal file
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
279
packages/mesh-3d/src/SkeletonComponent.ts
Normal file
279
packages/mesh-3d/src/SkeletonComponent.ts
Normal file
@@ -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<BoneTransform>): 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;
|
||||
}
|
||||
}
|
||||
275
packages/mesh-3d/src/animation/AnimationEvaluator.ts
Normal file
275
packages/mesh-3d/src/animation/AnimationEvaluator.ts
Normal file
@@ -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<number, EvaluatedValue> {
|
||||
const result = new Map<number, EvaluatedValue>();
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
39
packages/mesh-3d/src/index.ts
Normal file
39
packages/mesh-3d/src/index.ts
Normal file
@@ -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';
|
||||
112
packages/mesh-3d/src/systems/Animation3DSystem.ts
Normal file
112
packages/mesh-3d/src/systems/Animation3DSystem.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
124
packages/mesh-3d/src/systems/MeshAssetLoaderSystem.ts
Normal file
124
packages/mesh-3d/src/systems/MeshAssetLoaderSystem.ts
Normal file
@@ -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<string> = 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<void> {
|
||||
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<IGLTFAsset>(modelGuid);
|
||||
} else {
|
||||
result = await this.assetManager.loadAssetByPath<IGLTFAsset>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
304
packages/mesh-3d/src/systems/MeshRenderSystem.ts
Normal file
304
packages/mesh-3d/src/systems/MeshRenderSystem.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
186
packages/mesh-3d/src/systems/SkeletonBakingSystem.ts
Normal file
186
packages/mesh-3d/src/systems/SkeletonBakingSystem.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
13
packages/mesh-3d/src/tokens.ts
Normal file
13
packages/mesh-3d/src/tokens.ts
Normal file
@@ -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>('meshRenderSystem');
|
||||
12
packages/mesh-3d/tsconfig.build.json
Normal file
12
packages/mesh-3d/tsconfig.build.json
Normal file
@@ -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"]
|
||||
}
|
||||
16
packages/mesh-3d/tsconfig.json
Normal file
16
packages/mesh-3d/tsconfig.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
7
packages/mesh-3d/tsup.config.ts
Normal file
7
packages/mesh-3d/tsup.config.ts
Normal file
@@ -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'
|
||||
});
|
||||
92
pnpm-lock.yaml
generated
92
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
239
scripts/analyze-fbx.mjs
Normal file
239
scripts/analyze-fbx.mjs
Normal file
@@ -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!');
|
||||
256
scripts/check-anim-coverage.mjs
Normal file
256
scripts/check-anim-coverage.mjs
Normal file
@@ -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!');
|
||||
259
scripts/check-bone-hierarchy.mjs
Normal file
259
scripts/check-bone-hierarchy.mjs
Normal file
@@ -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!');
|
||||
183
scripts/check-prerotation.mjs
Normal file
183
scripts/check-prerotation.mjs
Normal file
@@ -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!');
|
||||
318
scripts/compare-ibm.mjs
Normal file
318
scripts/compare-ibm.mjs
Normal file
@@ -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!');
|
||||
355
scripts/compare-world-matrix.mjs
Normal file
355
scripts/compare-world-matrix.mjs
Normal file
@@ -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!');
|
||||
227
scripts/debug-channels.mjs
Normal file
227
scripts/debug-channels.mjs
Normal file
@@ -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!');
|
||||
328
scripts/debug-fbx-animation.mjs
Normal file
328
scripts/debug-fbx-animation.mjs
Normal file
@@ -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!');
|
||||
564
scripts/debug-runtime-anim.mjs
Normal file
564
scripts/debug-runtime-anim.mjs
Normal file
@@ -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!');
|
||||
68
scripts/simple-fbx-test.mjs
Normal file
68
scripts/simple-fbx-test.mjs
Normal file
@@ -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();
|
||||
143
scripts/test-animation-t0.mjs
Normal file
143
scripts/test-animation-t0.mjs
Normal file
@@ -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);
|
||||
309
scripts/test-animation-times.mjs
Normal file
309
scripts/test-animation-times.mjs
Normal file
@@ -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);
|
||||
741
scripts/test-fbx-animation.mjs
Normal file
741
scripts/test-fbx-animation.mjs
Normal file
@@ -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 ===`);
|
||||
199
scripts/test-fbxloader-bindpose.mjs
Normal file
199
scripts/test-fbxloader-bindpose.mjs
Normal file
@@ -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);
|
||||
806
scripts/test-full-pipeline.mjs
Normal file
806
scripts/test-full-pipeline.mjs
Normal file
@@ -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!');
|
||||
309
scripts/trace-fbxloader-output.mjs
Normal file
309
scripts/trace-fbxloader-output.mjs
Normal file
@@ -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!');
|
||||
377
scripts/verify-anim-t0.mjs
Normal file
377
scripts/verify-anim-t0.mjs
Normal file
@@ -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!');
|
||||
351
scripts/verify-animation-skeleton-mapping.mjs
Normal file
351
scripts/verify-animation-skeleton-mapping.mjs
Normal file
@@ -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!');
|
||||
388
scripts/verify-mesh-skinning.mjs
Normal file
388
scripts/verify-mesh-skinning.mjs
Normal file
@@ -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!');
|
||||
Reference in New Issue
Block a user