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:
YHH
2025-12-23 15:34:01 +08:00
committed by GitHub
parent 49dd6a91c6
commit 828ff969e1
69 changed files with 16370 additions and 56 deletions

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View 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]
};
}
}

View 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]
};
}
}

View File

@@ -52,6 +52,8 @@ export const AssetType = {
Texture: 'texture',
/** 网格 */
Mesh: 'mesh',
/** 3D模型 (GLTF/GLB) | 3D Model */
Model3D: 'model3d',
/** 材质 */
Material: 'material',
/** 着色器 */

View File

@@ -91,6 +91,9 @@ export const STANDARD_EXTERNALS = [
'zustand',
'immer',
// Tauri (由宿主应用提供) | Provided by host app
/^@tauri-apps\//,
// 所有 @esengine 包
/^@esengine\//,
] as const;

View File

@@ -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) | 材质 ID0 为默认)
* @param textureId - Texture ID (0 for white) | 纹理 ID0 为白色)
*/
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) | 材质 ID0 为默认)
* @param textureId - Texture ID (0 for white) | 纹理 ID0 为白色)
*/
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.
* 销毁桥接并释放资源。

View File

@@ -9,6 +9,7 @@
export {
RenderSystemToken,
EngineIntegrationToken,
EngineBridgeToken,
// 新的单一职责服务令牌 | New single-responsibility service tokens
TextureServiceToken,
DynamicAtlasServiceToken,

View File

@@ -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');

View File

@@ -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;

View File

@@ -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:*",

View File

@@ -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;
}
/**
* 列出目录内容
*/

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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);

View File

@@ -9,22 +9,28 @@ interface AxisInputProps {
axis: 'x' | 'y' | 'z';
value: number;
onChange: (value: number) => void;
onChangeCommit?: (value: number) => void; // 拖拽结束时调用 | Called when drag ends
suffix?: string;
}
function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
function AxisInput({ axis, value, onChange, onChangeCommit, suffix }: AxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [inputValue, setInputValue] = useState(String(value ?? 0));
const dragStartRef = useRef({ x: 0, value: 0 });
const currentValueRef = useRef(value ?? 0); // 跟踪当前值 | Track current value
useEffect(() => {
setInputValue(String(value ?? 0));
}, [value]);
if (!isDragging) {
setInputValue(String(value ?? 0));
currentValueRef.current = value ?? 0;
}
}, [value, isDragging]);
const handleBarMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
currentValueRef.current = value ?? 0;
};
useEffect(() => {
@@ -35,11 +41,14 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
const sensitivity = e.shiftKey ? 0.01 : e.ctrlKey ? 1 : 0.1;
const newValue = dragStartRef.current.value + delta * sensitivity;
const rounded = Math.round(newValue * 1000) / 1000;
currentValueRef.current = rounded;
setInputValue(String(rounded));
onChange(rounded);
};
const handleMouseUp = () => {
setIsDragging(false);
onChangeCommit?.(currentValueRef.current); // 拖拽结束时通知 | Notify when drag ends
};
document.addEventListener('mousemove', handleMouseMove);
@@ -49,7 +58,7 @@ function AxisInput({ axis, value, onChange, suffix }: AxisInputProps) {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange]);
}, [isDragging, onChange, onChangeCommit]);
const handleInputChange = (e: React.ChangeEvent<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" />

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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(())
}
}

View File

@@ -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) | 材质 ID0 为默认)
/// * `texture_id` - Texture ID (0 for white) | 纹理 ID0 为白色)
#[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()))
}
}

View 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"
}

View 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;
}

View 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}`
});
}
}

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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);
}

View 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"]
}

View 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" }
]
}

View 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'
});

View 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"
}

View 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();
}
}

View 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 };

View 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 = [];
}
}

View 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;
}
}

View 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四元数平移/缩放 = 3vec3权重 = 可变
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];
}
}

View 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';

View 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();
}
}

View 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);
}
}
}

View 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);
}
}

View 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;
}
}

View 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');

View 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"]
}

View 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" }
]
}

View 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
View File

@@ -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
View 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!');

View 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!');

View 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!');

View 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
View 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!');

View 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
View 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!');

View 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!');

View 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!');

View 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();

View 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);

View 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);

View 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 ===`);

View 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);

View 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!');

View 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
View 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!');

View 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!');

View 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!');