diff --git a/Cargo.toml b/Cargo.toml index dbf0401117928..40de70de127e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -228,6 +228,9 @@ bevy_gilrs = ["bevy_internal/bevy_gilrs"] # [glTF](https://www.khronos.org/gltf/) support bevy_gltf = ["bevy_internal/bevy_gltf"] +# [FBX](https://en.wikipedia.org/wiki/FBX) support +fbx = ["bevy_internal/bevy_fbx"] + # Adds PBR rendering bevy_pbr = ["bevy_internal/bevy_pbr"] @@ -1166,6 +1169,17 @@ description = "Renders meshes with both forward and deferred pipelines" category = "3D Rendering" wasm = true +[[example]] +name = "load_fbx" +path = "examples/3d/load_fbx.rs" +doc-scrape-examples = true + +[package.metadata.example.load_fbx] +name = "Load FBX" +description = "Loads and renders an FBX file as a scene" +category = "3D Rendering" +wasm = false + [[example]] name = "motion_blur" path = "examples/3d/motion_blur.rs" @@ -1468,6 +1482,18 @@ description = "Plays an animation on a skinned glTF model of a fox" category = "Animation" wasm = true +[[example]] +name = "animated_mesh_fbx" +path = "examples/animation/animated_mesh_fbx.rs" +required-features = ["fbx"] +doc-scrape-examples = true + +[package.metadata.example.animated_mesh_fbx] +name = "Animated Mesh FBX" +description = "Plays an animation on an FBX model of an animated cube" +category = "Animation" +wasm = false + [[example]] name = "animated_mesh_control" path = "examples/animation/animated_mesh_control.rs" @@ -3190,6 +3216,18 @@ description = "A simple way to view glTF models with Bevy. Just run `cargo run - category = "Tools" wasm = true +[[example]] +name = "scene_viewer_fbx" +path = "examples/tools/scene_viewer_fbx/main.rs" +required-features = ["fbx"] +doc-scrape-examples = true + +[package.metadata.example.scene_viewer_fbx] +name = "FBX Scene Viewer" +description = "A simple way to view FBX models with Bevy. Just run `cargo run --release --example scene_viewer_fbx --features=fbx /path/to/model.fbx`, replacing the path as appropriate. Provides enhanced controls for FBX-specific features like material inspection and texture debugging" +category = "Tools" +wasm = false + [[example]] name = "gamepad_viewer" path = "examples/tools/gamepad_viewer.rs" diff --git a/assets/models/animated/cube_anim.fbx b/assets/models/animated/cube_anim.fbx new file mode 100644 index 0000000000000..f122be53136fb Binary files /dev/null and b/assets/models/animated/cube_anim.fbx differ diff --git a/assets/models/cube/cube.fbx b/assets/models/cube/cube.fbx new file mode 100644 index 0000000000000..01788cefd055c Binary files /dev/null and b/assets/models/cube/cube.fbx differ diff --git a/assets/models/cube_anim.fbx b/assets/models/cube_anim.fbx new file mode 100644 index 0000000000000..4c15866be011b --- /dev/null +++ b/assets/models/cube_anim.fbx @@ -0,0 +1,801 @@ +; FBX 7.7.0 project file +; ---------------------------------------------------- + +FBXHeaderExtension: { + FBXHeaderVersion: 1004 + FBXVersion: 7700 + CreationTimeStamp: { + Version: 1000 + Year: 2023 + Month: 9 + Day: 7 + Hour: 22 + Minute: 17 + Second: 31 + Millisecond: 940 + } + Creator: "FBX SDK/FBX Plugins version 2020.3" + OtherFlags: { + TCDefinition: 127 + } + SceneInfo: "SceneInfo::GlobalInfo", "UserData" { + Type: "UserData" + Version: 100 + MetaData: { + Version: 100 + Title: "" + Subject: "" + Author: "" + Keywords: "" + Revision: "" + Comment: "" + } + Properties70: { + P: "DocumentUrl", "KString", "Url", "", "D:\Dev\clean\ufbx-rust\tests\data\cube_anim.fbx" + P: "SrcDocumentUrl", "KString", "Url", "", "D:\Dev\clean\ufbx-rust\tests\data\cube_anim.fbx" + P: "Original", "Compound", "", "" + P: "Original|ApplicationVendor", "KString", "", "", "Autodesk" + P: "Original|ApplicationName", "KString", "", "", "Maya" + P: "Original|ApplicationVersion", "KString", "", "", "2023" + P: "Original|DateTime_GMT", "DateTime", "", "", "07/09/2023 19:17:31.937" + P: "Original|FileName", "KString", "", "", "D:\Dev\clean\ufbx-rust\tests\data\cube_anim.fbx" + P: "LastSaved", "Compound", "", "" + P: "LastSaved|ApplicationVendor", "KString", "", "", "Autodesk" + P: "LastSaved|ApplicationName", "KString", "", "", "Maya" + P: "LastSaved|ApplicationVersion", "KString", "", "", "2023" + P: "LastSaved|DateTime_GMT", "DateTime", "", "", "07/09/2023 19:17:31.937" + P: "Original|ApplicationActiveProject", "KString", "", "", "D:\Dev\clean\ufbx-rust\tests\data" + } + } +} +GlobalSettings: { + Version: 1000 + Properties70: { + P: "UpAxis", "int", "Integer", "",1 + P: "UpAxisSign", "int", "Integer", "",1 + P: "FrontAxis", "int", "Integer", "",2 + P: "FrontAxisSign", "int", "Integer", "",1 + P: "CoordAxis", "int", "Integer", "",0 + P: "CoordAxisSign", "int", "Integer", "",1 + P: "OriginalUpAxis", "int", "Integer", "",1 + P: "OriginalUpAxisSign", "int", "Integer", "",1 + P: "UnitScaleFactor", "double", "Number", "",1 + P: "OriginalUnitScaleFactor", "double", "Number", "",1 + P: "AmbientColor", "ColorRGB", "Color", "",0,0,0 + P: "DefaultCamera", "KString", "", "", "Producer Perspective" + P: "TimeMode", "enum", "", "",11 + P: "TimeProtocol", "enum", "", "",2 + P: "SnapOnFrameMode", "enum", "", "",0 + P: "TimeSpanStart", "KTime", "Time", "",0 + P: "TimeSpanStop", "KTime", "Time", "",192442325000 + P: "CustomFrameRate", "double", "Number", "",-1 + P: "TimeMarker", "Compound", "", "" + P: "CurrentTimeMarker", "int", "Integer", "",-1 + } +} + +; Documents Description +;------------------------------------------------------------------ + +Documents: { + Count: 1 + Document: 2244722366480, "", "Scene" { + Properties70: { + P: "SourceObject", "object", "", "" + P: "ActiveAnimStackName", "KString", "", "", "Take 001" + } + RootNode: 0 + } +} + +; Document References +;------------------------------------------------------------------ + +References: { +} + +; Object definitions +;------------------------------------------------------------------ + +Definitions: { + Version: 100 + Count: 24 + ObjectType: "GlobalSettings" { + Count: 1 + } + ObjectType: "AnimationStack" { + Count: 1 + PropertyTemplate: "FbxAnimStack" { + Properties70: { + P: "Description", "KString", "", "", "" + P: "LocalStart", "KTime", "Time", "",0 + P: "LocalStop", "KTime", "Time", "",0 + P: "ReferenceStart", "KTime", "Time", "",0 + P: "ReferenceStop", "KTime", "Time", "",0 + } + } + } + ObjectType: "AnimationLayer" { + Count: 1 + PropertyTemplate: "FbxAnimLayer" { + Properties70: { + P: "Weight", "Number", "", "A",100 + P: "Mute", "bool", "", "",0 + P: "Solo", "bool", "", "",0 + P: "Lock", "bool", "", "",0 + P: "Color", "ColorRGB", "Color", "",0.8,0.8,0.8 + P: "BlendMode", "enum", "", "",0 + P: "RotationAccumulationMode", "enum", "", "",0 + P: "ScaleAccumulationMode", "enum", "", "",0 + P: "BlendModeBypass", "ULongLong", "", "",0 + } + } + } + ObjectType: "Geometry" { + Count: 1 + PropertyTemplate: "FbxMesh" { + Properties70: { + P: "Color", "ColorRGB", "Color", "",0.8,0.8,0.8 + P: "BBoxMin", "Vector3D", "Vector", "",0,0,0 + P: "BBoxMax", "Vector3D", "Vector", "",0,0,0 + P: "Primary Visibility", "bool", "", "",1 + P: "Casts Shadows", "bool", "", "",1 + P: "Receive Shadows", "bool", "", "",1 + } + } + } + ObjectType: "Material" { + Count: 1 + PropertyTemplate: "FbxSurfaceLambert" { + Properties70: { + P: "ShadingModel", "KString", "", "", "Lambert" + P: "MultiLayer", "bool", "", "",0 + P: "EmissiveColor", "Color", "", "A",0,0,0 + P: "EmissiveFactor", "Number", "", "A",1 + P: "AmbientColor", "Color", "", "A",0.2,0.2,0.2 + P: "AmbientFactor", "Number", "", "A",1 + P: "DiffuseColor", "Color", "", "A",0.8,0.8,0.8 + P: "DiffuseFactor", "Number", "", "A",1 + P: "Bump", "Vector3D", "Vector", "",0,0,0 + P: "NormalMap", "Vector3D", "Vector", "",0,0,0 + P: "BumpFactor", "double", "Number", "",1 + P: "TransparentColor", "Color", "", "A",0,0,0 + P: "TransparencyFactor", "Number", "", "A",0 + P: "DisplacementColor", "ColorRGB", "Color", "",0,0,0 + P: "DisplacementFactor", "double", "Number", "",1 + P: "VectorDisplacementColor", "ColorRGB", "Color", "",0,0,0 + P: "VectorDisplacementFactor", "double", "Number", "",1 + } + } + } + ObjectType: "AnimationCurveNode" { + Count: 5 + PropertyTemplate: "FbxAnimCurveNode" { + Properties70: { + P: "d", "Compound", "", "" + } + } + } + ObjectType: "AnimationCurve" { + Count: 13 + } + ObjectType: "Model" { + Count: 1 + PropertyTemplate: "FbxNode" { + Properties70: { + P: "QuaternionInterpolate", "enum", "", "",0 + P: "RotationOffset", "Vector3D", "Vector", "",0,0,0 + P: "RotationPivot", "Vector3D", "Vector", "",0,0,0 + P: "ScalingOffset", "Vector3D", "Vector", "",0,0,0 + P: "ScalingPivot", "Vector3D", "Vector", "",0,0,0 + P: "TranslationActive", "bool", "", "",0 + P: "TranslationMin", "Vector3D", "Vector", "",0,0,0 + P: "TranslationMax", "Vector3D", "Vector", "",0,0,0 + P: "TranslationMinX", "bool", "", "",0 + P: "TranslationMinY", "bool", "", "",0 + P: "TranslationMinZ", "bool", "", "",0 + P: "TranslationMaxX", "bool", "", "",0 + P: "TranslationMaxY", "bool", "", "",0 + P: "TranslationMaxZ", "bool", "", "",0 + P: "RotationOrder", "enum", "", "",0 + P: "RotationSpaceForLimitOnly", "bool", "", "",0 + P: "RotationStiffnessX", "double", "Number", "",0 + P: "RotationStiffnessY", "double", "Number", "",0 + P: "RotationStiffnessZ", "double", "Number", "",0 + P: "AxisLen", "double", "Number", "",10 + P: "PreRotation", "Vector3D", "Vector", "",0,0,0 + P: "PostRotation", "Vector3D", "Vector", "",0,0,0 + P: "RotationActive", "bool", "", "",0 + P: "RotationMin", "Vector3D", "Vector", "",0,0,0 + P: "RotationMax", "Vector3D", "Vector", "",0,0,0 + P: "RotationMinX", "bool", "", "",0 + P: "RotationMinY", "bool", "", "",0 + P: "RotationMinZ", "bool", "", "",0 + P: "RotationMaxX", "bool", "", "",0 + P: "RotationMaxY", "bool", "", "",0 + P: "RotationMaxZ", "bool", "", "",0 + P: "InheritType", "enum", "", "",0 + P: "ScalingActive", "bool", "", "",0 + P: "ScalingMin", "Vector3D", "Vector", "",0,0,0 + P: "ScalingMax", "Vector3D", "Vector", "",1,1,1 + P: "ScalingMinX", "bool", "", "",0 + P: "ScalingMinY", "bool", "", "",0 + P: "ScalingMinZ", "bool", "", "",0 + P: "ScalingMaxX", "bool", "", "",0 + P: "ScalingMaxY", "bool", "", "",0 + P: "ScalingMaxZ", "bool", "", "",0 + P: "GeometricTranslation", "Vector3D", "Vector", "",0,0,0 + P: "GeometricRotation", "Vector3D", "Vector", "",0,0,0 + P: "GeometricScaling", "Vector3D", "Vector", "",1,1,1 + P: "MinDampRangeX", "double", "Number", "",0 + P: "MinDampRangeY", "double", "Number", "",0 + P: "MinDampRangeZ", "double", "Number", "",0 + P: "MaxDampRangeX", "double", "Number", "",0 + P: "MaxDampRangeY", "double", "Number", "",0 + P: "MaxDampRangeZ", "double", "Number", "",0 + P: "MinDampStrengthX", "double", "Number", "",0 + P: "MinDampStrengthY", "double", "Number", "",0 + P: "MinDampStrengthZ", "double", "Number", "",0 + P: "MaxDampStrengthX", "double", "Number", "",0 + P: "MaxDampStrengthY", "double", "Number", "",0 + P: "MaxDampStrengthZ", "double", "Number", "",0 + P: "PreferedAngleX", "double", "Number", "",0 + P: "PreferedAngleY", "double", "Number", "",0 + P: "PreferedAngleZ", "double", "Number", "",0 + P: "LookAtProperty", "object", "", "" + P: "UpVectorProperty", "object", "", "" + P: "Show", "bool", "", "",1 + P: "NegativePercentShapeSupport", "bool", "", "",1 + P: "DefaultAttributeIndex", "int", "Integer", "",-1 + P: "Freeze", "bool", "", "",0 + P: "LODBox", "bool", "", "",0 + P: "Lcl Translation", "Lcl Translation", "", "A",0,0,0 + P: "Lcl Rotation", "Lcl Rotation", "", "A",0,0,0 + P: "Lcl Scaling", "Lcl Scaling", "", "A",1,1,1 + P: "Visibility", "Visibility", "", "A",1 + P: "Visibility Inheritance", "Visibility Inheritance", "", "",1 + } + } + } +} + +; Object properties +;------------------------------------------------------------------ + +Objects: { + Geometry: 2245309148656, "Geometry::", "Mesh" { + Vertices: *24 { + a: -0.5,-0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,-0.5,-0.5,-0.5,-0.5,0.5,-0.5,-0.5 + } + PolygonVertexIndex: *24 { + a: 0,1,3,-3,2,3,5,-5,4,5,7,-7,6,7,1,-1,1,7,5,-4,6,0,2,-5 + } + Edges: *12 { + a: 0,2,6,10,3,1,7,5,11,9,15,13 + } + GeometryVersion: 124 + LayerElementNormal: 0 { + Version: 102 + Name: "" + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "Direct" + Normals: *72 { + a: 0,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,1,0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,1,0,0,1,0,0,1,0,0,1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0 + } + NormalsW: *24 { + a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 + } + } + LayerElementBinormal: 0 { + Version: 102 + Name: "map1" + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "Direct" + Binormals: *72 { + a: 0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,0,1,0,0,1,0,0,1,0,0,1,-0,1,0,-0,1,0,0,1,-0,-0,1,0,0,1,0,0,1,0,0,1,0,0,1,0 + } + BinormalsW: *24 { + a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 + } + + } + LayerElementTangent: 0 { + Version: 102 + Name: "map1" + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "Direct" + Tangents: *72 { + a: 1,-0,-0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,-0,1,0,-0,1,0,-0,1,0,-0,0,0,-1,0,0,-1,0,-0,-1,0,0,-1,0,-0,1,0,-0,1,0,-0,1,0,-0,1 + } + TangentsW: *24 { + a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 + } + } + LayerElementUV: 0 { + Version: 101 + Name: "map1" + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "IndexToDirect" + UV: *28 { + a: 0.375,0,0.625,0,0.375,0.25,0.625,0.25,0.375,0.5,0.625,0.5,0.375,0.75,0.625,0.75,0.375,1,0.625,1,0.875,0,0.875,0.25,0.125,0,0.125,0.25 + } + UVIndex: *24 { + a: 0,1,3,2,2,3,5,4,4,5,7,6,6,7,9,8,1,10,11,3,12,0,2,13 + } + } + LayerElementMaterial: 0 { + Version: 101 + Name: "" + MappingInformationType: "AllSame" + ReferenceInformationType: "IndexToDirect" + Materials: *1 { + a: 0 + } + } + Layer: 0 { + Version: 100 + LayerElement: { + Type: "LayerElementNormal" + TypedIndex: 0 + } + LayerElement: { + Type: "LayerElementBinormal" + TypedIndex: 0 + } + LayerElement: { + Type: "LayerElementTangent" + TypedIndex: 0 + } + LayerElement: { + Type: "LayerElementMaterial" + TypedIndex: 0 + } + LayerElement: { + Type: "LayerElementUV" + TypedIndex: 0 + } + } + } + Model: 2244692774032, "Model::pCube1", "Mesh" { + Version: 232 + Properties70: { + P: "RotationActive", "bool", "", "",1 + P: "InheritType", "enum", "", "",1 + P: "ScalingMax", "Vector3D", "Vector", "",0,0,0 + P: "DefaultAttributeIndex", "int", "Integer", "",0 + P: "Lcl Translation", "Lcl Translation", "", "A+",0,0.518518518518518,0 + P: "Lcl Rotation", "Lcl Rotation", "", "A+",11.6666666666667,11.6666666666667,0 + P: "Lcl Scaling", "Lcl Scaling", "", "A+",1.05185185185185,1.1037037037037,1.15555555555556 + P: "currentUVSet", "KString", "", "U", "map1" + } + Shading: T + Culling: "CullingOff" + } + Material: 2242872361376, "Material::lambert1", "" { + Version: 102 + ShadingModel: "lambert" + MultiLayer: 0 + Properties70: { + P: "AmbientColor", "Color", "", "A",0,0,0 + P: "DiffuseColor", "Color", "", "A+",0.740740716457367,0.259259253740311,0 + P: "DiffuseFactor", "Number", "", "A",0.800000011920929 + P: "TransparencyFactor", "Number", "", "A",1 + P: "Emissive", "Vector3D", "Vector", "",0,0,0 + P: "Ambient", "Vector3D", "Vector", "",0,0,0 + P: "Diffuse", "Vector3D", "Vector", "",0.592592581996211,0.20740740608286,0 + P: "Opacity", "double", "Number", "",1 + } + } + AnimationStack: 2243155095872, "AnimStack::Take 001", "" { + Properties70: { + P: "LocalStop", "KTime", "Time", "",38488465000 + P: "ReferenceStop", "KTime", "Time", "",38488465000 + } + } + AnimationCurve: 2243096615744, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 1,0 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096607744, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,1 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096613984, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,0 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096609024, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 1,1 + } + ;KeyAttrFlags: Constant|ConstantStandard, Constant|ConstantStandard + KeyAttrFlags: *2 { + a: 2,2 + } + ;KeyAttrDataFloat: RightSlope:0, NextLeftSlope:0, RightWeight:0.333333, NextLeftWeight:0.333333, RightVelocity:0, NextLeftVelocity:0; RightSlope:0, NextLeftSlope:0, RightWeight:0.333333, NextLeftWeight:0.333333, RightVelocity:0, NextLeftVelocity:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096609184, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,0 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096614944, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,2 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096614304, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,0 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096609824, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 1,1.2 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096613504, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 1,1.4 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096615424, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 1,1.6 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096609344, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,45 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096607264, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,45 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurve: 2243096615104, "AnimCurve::", "" { + Default: 0 + KeyVer: 4009 + KeyTime: *2 { + a: 0,23093079000 + } + KeyValueFloat: *2 { + a: 0,0 + } + ;KeyAttrFlags: Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive, Cubic|TangeantAuto|GenericTimeIndependent|GenericClampProgressive + KeyAttrFlags: *2 { + a: 24840,24840 + } + ;KeyAttrDataFloat: RightAuto:0, NextLeftAuto:0; RightAuto:0, NextLeftAuto:0 + KeyAttrDataFloat: *8 { + a: 0,0,218434821,0,0,0,218434821,0 + } + KeyAttrRefCount: *2 { + a: 1,1 + } + } + AnimationCurveNode: 2243155097120, "AnimCurveNode::DiffuseColor", "" { + Properties70: { + P: "d|X", "Number", "", "A",0.740740716457367 + P: "d|Y", "Number", "", "A",0.259259253740311 + P: "d|Z", "Number", "", "A",0 + } + } + AnimationCurveNode: 2243155095456, "AnimCurveNode::Visibility", "" { + Properties70: { + P: "d|Visibility", "Visibility", "", "A",1 + } + } + AnimationCurveNode: 2243155095040, "AnimCurveNode::T", "" { + Properties70: { + P: "d|X", "Number", "", "A",0 + P: "d|Y", "Number", "", "A",0.518518518518518 + P: "d|Z", "Number", "", "A",0 + } + } + AnimationCurveNode: 2243155095248, "AnimCurveNode::S", "" { + Properties70: { + P: "d|X", "Number", "", "A",1.05185185185185 + P: "d|Y", "Number", "", "A",1.1037037037037 + P: "d|Z", "Number", "", "A",1.15555555555556 + } + } + AnimationCurveNode: 2243155089008, "AnimCurveNode::R", "" { + Properties70: { + P: "d|X", "Number", "", "A",11.6666666666667 + P: "d|Y", "Number", "", "A",11.6666666666667 + P: "d|Z", "Number", "", "A",0 + } + } + AnimationLayer: 2245017641168, "AnimLayer::BaseLayer", "" { + } +} + +; Object connections +;------------------------------------------------------------------ + +Connections: { + + ;Model::pCube1, Model::RootNode + C: "OO",2244692774032,0 + + ;AnimLayer::BaseLayer, AnimStack::Take 001 + C: "OO",2245017641168,2243155095872 + + ;AnimCurveNode::DiffuseColor, AnimLayer::BaseLayer + C: "OO",2243155097120,2245017641168 + + ;AnimCurveNode::Visibility, AnimLayer::BaseLayer + C: "OO",2243155095456,2245017641168 + + ;AnimCurveNode::T, AnimLayer::BaseLayer + C: "OO",2243155095040,2245017641168 + + ;AnimCurveNode::S, AnimLayer::BaseLayer + C: "OO",2243155095248,2245017641168 + + ;AnimCurveNode::R, AnimLayer::BaseLayer + C: "OO",2243155089008,2245017641168 + + ;AnimCurveNode::DiffuseColor, Material::lambert1 + C: "OP",2243155097120,2242872361376, "DiffuseColor" + + ;AnimCurve::, AnimCurveNode::DiffuseColor + C: "OP",2243096615744,2243155097120, "d|X" + + ;AnimCurve::, AnimCurveNode::DiffuseColor + C: "OP",2243096607744,2243155097120, "d|Y" + + ;AnimCurve::, AnimCurveNode::DiffuseColor + C: "OP",2243096613984,2243155097120, "d|Z" + + ;Geometry::, Model::pCube1 + C: "OO",2245309148656,2244692774032 + + ;Material::lambert1, Model::pCube1 + C: "OO",2242872361376,2244692774032 + + ;AnimCurveNode::T, Model::pCube1 + C: "OP",2243155095040,2244692774032, "Lcl Translation" + + ;AnimCurveNode::R, Model::pCube1 + C: "OP",2243155089008,2244692774032, "Lcl Rotation" + + ;AnimCurveNode::S, Model::pCube1 + C: "OP",2243155095248,2244692774032, "Lcl Scaling" + + ;AnimCurveNode::Visibility, Model::pCube1 + C: "OP",2243155095456,2244692774032, "Visibility" + + ;AnimCurve::, AnimCurveNode::Visibility + C: "OP",2243096609024,2243155095456, "d|Visibility" + + ;AnimCurve::, AnimCurveNode::T + C: "OP",2243096609184,2243155095040, "d|X" + + ;AnimCurve::, AnimCurveNode::T + C: "OP",2243096614944,2243155095040, "d|Y" + + ;AnimCurve::, AnimCurveNode::T + C: "OP",2243096614304,2243155095040, "d|Z" + + ;AnimCurve::, AnimCurveNode::S + C: "OP",2243096609824,2243155095248, "d|X" + + ;AnimCurve::, AnimCurveNode::S + C: "OP",2243096613504,2243155095248, "d|Y" + + ;AnimCurve::, AnimCurveNode::S + C: "OP",2243096615424,2243155095248, "d|Z" + + ;AnimCurve::, AnimCurveNode::R + C: "OP",2243096609344,2243155089008, "d|X" + + ;AnimCurve::, AnimCurveNode::R + C: "OP",2243096607264,2243155089008, "d|Y" + + ;AnimCurve::, AnimCurveNode::R + C: "OP",2243096615104,2243155089008, "d|Z" +} +;Takes section +;---------------------------------------------------- + +Takes: { + Current: "Take 001" + Take: "Take 001" { + FileName: "Take_001.tak" + LocalTime: 0,38488465000 + ReferenceTime: 0,38488465000 + } +} diff --git a/assets/models/instanced_materials.fbx b/assets/models/instanced_materials.fbx new file mode 100644 index 0000000000000..a85e25ba6bbe8 Binary files /dev/null and b/assets/models/instanced_materials.fbx differ diff --git a/crates/bevy_fbx/Cargo.toml b/crates/bevy_fbx/Cargo.toml new file mode 100644 index 0000000000000..8575489ce2377 --- /dev/null +++ b/crates/bevy_fbx/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "bevy_fbx" +version = "0.18.0-dev" +edition = "2024" +description = "Bevy Engine FBX loading" +homepage = "https://bevyengine.org" +repository = "https://github.com/bevyengine/bevy" +license = "MIT OR Apache-2.0" +keywords = ["bevy"] + +[dependencies] +# bevy +bevy_app = { path = "../bevy_app", version = "0.18.0-dev" } +bevy_asset = { path = "../bevy_asset", version = "0.18.0-dev" } +bevy_camera = { path = "../bevy_camera", version = "0.18.0-dev" } +bevy_ecs = { path = "../bevy_ecs", version = "0.18.0-dev" } +bevy_scene = { path = "../bevy_scene", version = "0.18.0-dev" } +bevy_render = { path = "../bevy_render", version = "0.18.0-dev" } +bevy_pbr = { path = "../bevy_pbr", version = "0.18.0-dev" } +bevy_mesh = { path = "../bevy_mesh", version = "0.18.0-dev" } +bevy_transform = { path = "../bevy_transform", version = "0.18.0-dev" } +bevy_light = { path = "../bevy_light", version = "0.18.0-dev" } +bevy_math = { path = "../bevy_math", version = "0.18.0-dev" } +bevy_reflect = { path = "../bevy_reflect", version = "0.18.0-dev" } +bevy_utils = { path = "../bevy_utils", version = "0.18.0-dev" } +bevy_platform = { path = "../bevy_platform", version = "0.18.0-dev", default-features = false, features = [ + "std", +] } +bevy_animation = { path = "../bevy_animation", version = "0.18.0-dev" } +bevy_color = { path = "../bevy_color", version = "0.18.0-dev" } +bevy_image = { path = "../bevy_image", version = "0.18.0-dev" } + +# other +serde = { version = "1.0", features = ["derive"] } +thiserror = "1" +tracing = { version = "0.1", default-features = false, features = ["std"] } +ufbx = "0.8" + +[dev-dependencies] +bevy_log = { path = "../bevy_log", version = "0.18.0-dev" } + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"] +all-features = true diff --git a/crates/bevy_fbx/LICENSE-APACHE b/crates/bevy_fbx/LICENSE-APACHE new file mode 100644 index 0000000000000..d9a10c0d8e868 --- /dev/null +++ b/crates/bevy_fbx/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/bevy_fbx/LICENSE-MIT b/crates/bevy_fbx/LICENSE-MIT new file mode 100644 index 0000000000000..9cf106272ac3b --- /dev/null +++ b/crates/bevy_fbx/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/bevy_fbx/README.md b/crates/bevy_fbx/README.md new file mode 100644 index 0000000000000..80e457d86fdd5 --- /dev/null +++ b/crates/bevy_fbx/README.md @@ -0,0 +1,298 @@ +bevy_fbx — FBX loader for Bevy +================================ + +bevy_fbx is an FBX asset loader for Bevy built on top of the excellent +[ufbx](https://github.com/ufbx/ufbx) library (via the +[ufbx-rust](https://github.com/ufbx/ufbx-rust) bindings). + +The goal is to provide a pragmatic, batteries‑included way to bring FBX scenes +into Bevy with sensible defaults and good round‑trip behavior with DCC tools. + +Status: experimental but usable. The loader focuses on meshes, materials, +hierarchy, basic cameras/lights, skinning and T/R/S animations. + + +Highlights +---------- +- Scenes: loads complete FBX scenes and exposes labeled sub‑assets (Meshes, + Materials, Nodes, Skins, Animations, …). +- Meshes: vertex positions, normals, UVs, indices; multi‑material meshes are + split into sub‑meshes per material group. +- Materials: converts common FBX Phong/PBR parameters into Bevy's + `StandardMaterial`, including: + - base color/metallic/roughness/emissive factors; + - textures for base color, metallic/roughness (or either), emission, AO, + normal (treated as linear); + - alpha: Opaque, Blend or Mask (alpha‑cutoff) depending on opacity and maps; + - double‑sided materials disable face culling; + - UV transforms are applied using ufbx's UV→Texture transform matrix. +- Textures: respects FBX `wrap_u/v` (Repeat/Clamp). You can override or supply + default samplers from Bevy. +- Hierarchy: reconstructs the FBX node tree; each mesh is attached as a child + using `geometry_to_node` so placements match DCC. +- Cameras/Lights: creates Bevy camera/light components from FBX nodes (first + camera is made active). Orthographic cameras currently use a perspective + fallback. +- Skinning: adds `SkinnedMesh` with inverse bind poses and joint entity list; + per‑vertex joint indices/weights are uploaded to the mesh. +- Animation: builds an `AnimationClip` + `AnimationGraph` from FBX layers. + Supports both verbose (Lcl Translation/Rotation/Scaling) and short (T/R/S) + property names for keyframes; auto‑plays the first clip. + + +Quick start +----------- + +Enable the loader by turning on the `fbx` feature in your Bevy app, or by +depending on `bevy_fbx` explicitly. + +Option A — via Bevy features (recommended): + +```toml +[dependencies] +bevy = { version = "0.18", features = ["fbx"] } +``` + +Option B — direct crate dependency: + +```toml +[dependencies] +bevy = "0.18" +bevy_fbx = "0.18" +``` + +Register the loader. If you use `DefaultPlugins` and enabled the `fbx` feature +on Bevy, the plugin is included automatically. Otherwise, add it manually: + +```rust +use bevy::prelude::*; +use bevy_fbx::FbxPlugin; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(FbxPlugin) + .run(); +} +``` + +Loading a scene: + +```rust +use bevy::prelude::*; +use bevy::fbx::FbxAssetLabel; // re-exported when using Bevy with the `fbx` feature + +fn setup(mut commands: Commands, assets: Res) { + // Spawn the first scene contained in the FBX file + commands.spawn(SceneRoot( + assets.load(FbxAssetLabel::Scene(0).from_asset("models/thing.fbx")), + )); +} +``` + +Loading specific sub‑assets: + +```rust +use bevy::prelude::*; +use bevy::fbx::FbxAssetLabel; + +fn load_parts(assets: Res) { + let mesh0: Handle = assets.load(FbxAssetLabel::Mesh(0).from_asset("models/thing.fbx")); + let mat0: Handle = assets.load(FbxAssetLabel::Material(0).from_asset("models/thing.fbx")); + let scene0: Handle = assets.load(FbxAssetLabel::Scene(0).from_asset("models/thing.fbx")); +} +``` + + +Loader settings +--------------- + +`FbxLoaderSettings` lets you tweak what gets imported and how: + +```rust +use bevy::prelude::*; +use bevy::fbx::{Fbx, FbxLoaderSettings}; + +fn setup(assets: Res) { + // Example: disable lights, convert coordinates, force a sampler + let _fbx: Handle = assets.load_with_settings( + "models/thing.fbx", + |s: &mut FbxLoaderSettings| { + s.load_lights = false; + s.convert_coordinates = true; // flips Z to match Bevy (-Z forward) + s.override_sampler = true; + }, + ); +} +``` + +Available fields (see the code for details): + +- `load_meshes: RenderAssetUsages` — retain meshes in main/render worlds. +- `load_materials: RenderAssetUsages` — retain materials in main/render worlds. +- `load_cameras: bool` — spawn FBX cameras. +- `load_lights: bool` — spawn FBX lights. +- `include_source: bool` — kept for GLTF API parity (no effect with ufbx). +- `default_sampler: Option` — default texture sampler. +- `override_sampler: bool` — ignore FBX sampler data and use the default. +- `convert_coordinates: bool` — convert FBX coords to Bevy (flip Z). + + +Labeled sub‑assets +------------------ + +`FbxAssetLabel` covers the common pieces you may want to address directly: + +- `Scene{N}`, `Mesh{N}`, `Material{N}`, `Animation{N}`, `AnimationGraph{N}` +- `AnimationStack{N}`, `Skeleton{N}`, `Node{N}`, `Skin{N}` +- `Light{N}`, `Camera{N}`, `Texture{N}` +- `DefaultScene`, `DefaultMaterial`, `RootNode` + +You can also use raw strings (`"models/foo.fbx#Mesh0"`) if you prefer. + + +Examples in this repo +--------------------- + +- Load an FBX scene: `examples/3d/load_fbx.rs` +- Play an FBX animation: `examples/animation/animated_mesh_fbx.rs` +- Inspect any FBX on disk: `examples/tools/scene_viewer_fbx` (pass a path) + +Run (requires the `fbx` Cargo feature): + +```sh +cargo run --example load_fbx --features fbx +cargo run --example animated_mesh_fbx --features fbx +cargo run --example scene_viewer_fbx --features fbx -- /path/to/model.fbx +``` + + +Differences vs `bevy_gltf` +-------------------------- + +- Source semantics + - glTF has a strict PBR schema; FBX is looser. `bevy_fbx` maps the most common + Phong/PBR fields pragmatically to `StandardMaterial`. +- Texture transforms + - glTF uses `KHR_texture_transform` (per‑texture). `bevy_gltf` warns when + transforms differ across maps. `bevy_fbx` applies ufbx's UV→Texture matrix + and currently uses the base‑color transform for the material's global + `uv_transform` (per‑material). Differing transforms on other maps are not + yet handled. +- Samplers + - glTF samplers (wrap + filter) are fully converted. `bevy_fbx` converts wrap + (Repeat/Clamp) and leaves filter to Bevy defaults unless you override the + sampler via settings. +- Cameras + - glTF orthographic cameras map to Bevy orthographic. `bevy_fbx` currently + uses a perspective fallback for orthographic FBX cameras. +- Animations + - glTF channels (node‑targeted) are fully supported, including morph targets. + `bevy_fbx` currently builds transform clips from T/R/S curves (supports both + long `Lcl Translation/Rotation/Scaling` and short `T/R/S` names), assumes + XYZ Euler for rotations, and does not yet import morph targets or other + animated properties. +- Materials, double‑sided & culling + - glTF double‑sided may flip culling when scale is inverted; `bevy_gltf` + handles this. `bevy_fbx` disables culling for double‑sided and does not yet + invert culling on negative scale paths. +- Coordinate conversion + - Both loaders expose a `convert_coordinates` toggle; in `bevy_fbx` it flips Z + to match Bevy's −Z forward. + + +FBX material → `StandardMaterial` mapping +---------------------------------------- + +| FBX (ufbx) | StandardMaterial field | Notes | +|-------------------------------------|--------------------------------------------|-------| +| `fbx.diffuse_color` or `pbr.base_color` | `base_color` | sRGB color | +| `pbr.metalness` (x) | `metallic` | scalar | +| `pbr.roughness` (x) | `perceptual_roughness` | scalar | +| `fbx.emission_color` or `pbr.emission_color` | `emissive` | linear | +| `pbr.opacity` (value < 1.0) | `alpha_mode` | `Blend` if no cutoff texture; may use `Mask(0.5)` if opacity texture present | +| `features.double_sided.enabled` | `double_sided = true`, `cull_mode = None` | disables face culling | +| Texture `DiffuseColor`/`BaseColor` | `base_color_texture` + `uv_transform` | `uv_transform` from ufbx UV→Texture matrix | +| Texture `NormalMap` | `normal_map_texture` | treated as linear | +| Texture `Metallic` | `metallic_roughness_texture` | grayscale source; packed into Bevy MR texture slot | +| Texture `Roughness` | `metallic_roughness_texture` (if empty) | fallback when Metallic not present | +| Texture `EmissiveColor` | `emissive_texture` | | +| Texture `AmbientOcclusion` | `occlusion_texture` | | +| Texture wrap U/V | sampler address mode U/V | Repeat / Clamp | + +Notes: +- Bevy's MR texture expects metallic in B channel and roughness in G channel. + FBX often provides separate grayscale maps; `bevy_fbx` assigns whichever is + available to the MR slot (no packing). For best results author a packed map + or rely on scalar metallic/roughness. +- Only the base‑color texture's transform is applied to `StandardMaterial`'s + `uv_transform` (global for the material). If other textures need different + transforms, they are currently ignored. + + +Roadmap +------- + +- Materials + - Per‑texture UV transforms (not just base‑color → `uv_transform`). + - Wider PBR coverage (transmission, thickness/attenuation, IOR, clearcoat, + anisotropy, specular textures) to parity with `bevy_gltf` where feasible. +- Animation + - Connect curves to nodes via FBX connections/DAG rather than name fallbacks. + - Respect FBX rotation orders and pre/post/adjust transforms during baking. + - Morph target (blend shape) animation. +- Geometry & Skinning + - Invert culling on negative scale paths (material copies) similar to + `bevy_gltf`. + - Additional validation for multi‑cluster/weight normalization edge cases. +- IO & Performance + - Streaming/async‑friendly texture decode path; improved error messages. + - Import metrics & debug visualization toggles. +- Tooling & Docs + - Expand `scene_viewer_fbx` for material/channel inspection. + - More examples and tests; deeper docs on coordinate conversion and UVs. + + +Coordinate systems +------------------ + +FBX is typically right‑handed with +Z forward, +Y up. Bevy uses +Y up and −Z +forward. Set `FbxLoaderSettings::convert_coordinates = true` if you want the +loader to flip Z for you. UV transforms are applied using ufbx's +`uv_to_texture` matrix so what you see matches DCC texture placement. + + +Limitations & notes +------------------- + +- Material coverage is pragmatic: not every FBX/PBR parameter is mapped. + (E.g. AO strength, some extensions, orthographic cameras use a perspective + fallback.) Normal maps are treated as linear data. +- Animations currently parse T/R/S curves (both long and short property names) + and assume XYZ Euler order when converting to quaternions. Other animated + properties and morph targets are not yet supported. +- The loader splits meshes per material group; very large meshes with many + materials will produce multiple sub‑meshes. + + +Troubleshooting +--------------- + +- “The model is tiny / I can't see it”: move the camera closer or scale the + spawned `SceneRoot` entity for your preview use‑case. +- “Textures don't show up”: make sure texture files are available next to the + FBX or using absolute paths referenced by the file. +- “Double‑sided materials don't look right”: double‑sided disables face culling; + verify your winding order and opacity mode. + + +License +------- + +Dual‑licensed under MIT or Apache‑2.0, at your option. + + +Acknowledgements +---------------- + +This crate is powered by the amazing work on ufbx and ufbx‑rust. diff --git a/crates/bevy_fbx/src/convert_coordinates.rs b/crates/bevy_fbx/src/convert_coordinates.rs new file mode 100644 index 0000000000000..682c4754eb3a0 --- /dev/null +++ b/crates/bevy_fbx/src/convert_coordinates.rs @@ -0,0 +1,48 @@ +use bevy_math::{Quat, Vec3}; +use bevy_transform::components::Transform; + +/// Trait for converting FBX coordinates to Bevy's coordinate system. +pub(crate) trait ConvertCoordinates { + /// Converts FBX coordinates to Bevy's coordinate system. + /// + /// FBX default coordinate system (can vary): + /// - forward: +Z + /// - up: +Y + /// - right: +X + /// + /// Bevy coordinate system: + /// - forward: -Z + /// - up: +Y + /// - right: +X + fn convert_coordinates(self) -> Self; +} + +impl ConvertCoordinates for Vec3 { + fn convert_coordinates(self) -> Self { + // FBX to Bevy: negate Z to flip forward direction + Vec3::new(self.x, self.y, -self.z) + } +} + +impl ConvertCoordinates for [f32; 3] { + fn convert_coordinates(self) -> Self { + [self[0], self[1], -self[2]] + } +} + +impl ConvertCoordinates for Quat { + fn convert_coordinates(self) -> Self { + // Quaternion conversion for coordinate system change + // Flip the Z component to handle the coordinate system change + Quat::from_xyzw(self.x, self.y, -self.z, self.w) + } +} + +impl ConvertCoordinates for Transform { + fn convert_coordinates(mut self) -> Self { + self.translation = self.translation.convert_coordinates(); + self.rotation = self.rotation.convert_coordinates(); + // Scale remains the same + self + } +} diff --git a/crates/bevy_fbx/src/label.rs b/crates/bevy_fbx/src/label.rs new file mode 100644 index 0000000000000..5440321d6764c --- /dev/null +++ b/crates/bevy_fbx/src/label.rs @@ -0,0 +1,67 @@ +//! Labels that can be used to load part of an FBX asset + +use bevy_asset::AssetPath; + +/// Labels that can be used to load part of an FBX asset +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FbxAssetLabel { + /// `Scene{}`: FBX Scene as a Bevy [`Scene`](bevy_scene::Scene) + Scene(usize), + /// `Mesh{}`: FBX Mesh as a Bevy [`Mesh`](bevy_mesh::Mesh) + Mesh(usize), + /// `Material{}`: FBX material as a Bevy [`StandardMaterial`](bevy_pbr::StandardMaterial) + Material(usize), + /// `Animation{}`: FBX animation as a Bevy [`AnimationClip`](bevy_animation::AnimationClip) + Animation(usize), + /// `AnimationGraph{}`: Animation graph containing animation clips + AnimationGraph(usize), + /// `AnimationStack{}`: FBX animation stack with multiple layers + AnimationStack(usize), + /// `Skeleton{}`: FBX skeleton for skeletal animation + Skeleton(usize), + /// `Node{}`: Individual FBX node in the scene hierarchy + Node(usize), + /// `Skin{}`: FBX skin for skeletal animation + Skin(usize), + /// `Light{}`: FBX light definition + Light(usize), + /// `Camera{}`: FBX camera definition + Camera(usize), + /// `Texture{}`: FBX texture reference + Texture(usize), + /// `DefaultScene`: Main scene with all objects + DefaultScene, + /// `DefaultMaterial`: Fallback material used when no material is present + DefaultMaterial, + /// `RootNode`: Root node of the scene hierarchy + RootNode, +} + +impl core::fmt::Display for FbxAssetLabel { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FbxAssetLabel::Scene(index) => f.write_str(&format!("Scene{index}")), + FbxAssetLabel::Mesh(index) => f.write_str(&format!("Mesh{index}")), + FbxAssetLabel::Material(index) => f.write_str(&format!("Material{index}")), + FbxAssetLabel::Animation(index) => f.write_str(&format!("Animation{index}")), + FbxAssetLabel::AnimationGraph(index) => f.write_str(&format!("AnimationGraph{index}")), + FbxAssetLabel::AnimationStack(index) => f.write_str(&format!("AnimationStack{index}")), + FbxAssetLabel::Skeleton(index) => f.write_str(&format!("Skeleton{index}")), + FbxAssetLabel::Node(index) => f.write_str(&format!("Node{index}")), + FbxAssetLabel::Skin(index) => f.write_str(&format!("Skin{index}")), + FbxAssetLabel::Light(index) => f.write_str(&format!("Light{index}")), + FbxAssetLabel::Camera(index) => f.write_str(&format!("Camera{index}")), + FbxAssetLabel::Texture(index) => f.write_str(&format!("Texture{index}")), + FbxAssetLabel::DefaultScene => f.write_str("DefaultScene"), + FbxAssetLabel::DefaultMaterial => f.write_str("DefaultMaterial"), + FbxAssetLabel::RootNode => f.write_str("RootNode"), + } + } +} + +impl FbxAssetLabel { + /// Add this label to an asset path + pub fn from_asset(&self, path: impl Into>) -> AssetPath<'static> { + path.into().with_label(self.to_string()) + } +} diff --git a/crates/bevy_fbx/src/lib.rs b/crates/bevy_fbx/src/lib.rs new file mode 100644 index 0000000000000..893481688177f --- /dev/null +++ b/crates/bevy_fbx/src/lib.rs @@ -0,0 +1,2193 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![forbid(unsafe_code)] +#![doc( + html_logo_url = "https://bevyengine.org/assets/icon.png", + html_favicon_url = "https://bevyengine.org/assets/icon.png" +)] + +//! +//! Loader for FBX scenes using [`ufbx`](https://github.com/ufbx/ufbx-rust). +//! The implementation is intentionally minimal and focuses on importing +//! mesh geometry into Bevy. + +use bevy_app::prelude::*; +use bevy_asset::{ + io::Reader, Asset, AssetApp, AssetLoader, Handle, LoadContext, RenderAssetUsages, +}; +use bevy_ecs::entity::Entity; +use bevy_ecs::prelude::Name; +use bevy_ecs::prelude::*; +use bevy_mesh::skinning::SkinnedMeshInverseBindposes; +use bevy_mesh::{Indices, Mesh, Mesh3d, PrimitiveTopology, VertexAttributeValues}; +use bevy_pbr::{MeshMaterial3d, StandardMaterial}; + +use bevy_camera::{ + visibility::Visibility, Camera, Camera3d, OrthographicProjection, PerspectiveProjection, + Projection, ScalingMode, +}; +use bevy_light::{DirectionalLight, PointLight, SpotLight}; +use bevy_platform::collections::HashMap; +use bevy_reflect::TypePath; +use bevy_render::render_resource::Face; +use bevy_scene::Scene; +use bevy_utils::default; +use serde::{Deserialize, Serialize}; + +use bevy_animation::{ + animated_field, + animation_curves::{AnimatableCurve, AnimatableKeyframeCurve}, + graph::{AnimationGraph, AnimationGraphHandle}, + prelude::AnimatedField, + AnimationClip, AnimationPlayer, AnimationTarget, AnimationTargetId, +}; +use bevy_color::Color; +use bevy_image::{ + CompressedImageFormats, Image, ImageAddressMode, ImageSampler, ImageSamplerDescriptor, + ImageType, +}; +use bevy_math::{Affine2, Mat4, Quat, Vec2, Vec3}; +use bevy_render::alpha::AlphaMode; +use bevy_transform::prelude::*; +use tracing::info; + +mod label; +pub use label::FbxAssetLabel; +mod convert_coordinates; +use convert_coordinates::ConvertCoordinates; + +pub mod prelude { + //! Commonly used items. + pub use crate::{Fbx, FbxAssetLabel, FbxPlugin}; +} + +/// Types of textures supported in FBX materials. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FbxTextureType { + /// Base color (albedo) texture. + BaseColor, + /// Normal map texture. + Normal, + /// Metallic texture. + Metallic, + /// Roughness texture. + Roughness, + /// Emission texture. + Emission, + /// Ambient occlusion texture. + AmbientOcclusion, + /// Height/displacement texture. + Height, +} + +/// Convert FBX wrap mode to Bevy's ImageAddressMode +fn convert_wrap_mode(wrap: ufbx::WrapMode) -> ImageAddressMode { + match wrap { + ufbx::WrapMode::Repeat => ImageAddressMode::Repeat, + ufbx::WrapMode::Clamp => ImageAddressMode::ClampToEdge, + } +} + +/// Create sampler descriptor from FBX texture, using default as base +fn create_sampler_from_texture( + texture: &ufbx::Texture, + default: &ImageSamplerDescriptor, +) -> ImageSamplerDescriptor { + let mut sampler = default.clone(); + + // Apply FBX wrap modes + sampler.address_mode_u = convert_wrap_mode(texture.wrap_u); + sampler.address_mode_v = convert_wrap_mode(texture.wrap_v); + + // Note: FBX doesn't directly specify filter modes like GLTF does, + // so we keep the default filter settings + + sampler +} + +/// Convert ufbx texture UV transform to Bevy Affine2 +/// This function properly handles UV coordinate transformations including +/// scale, rotation, and translation operations commonly found in FBX files. +fn convert_texture_uv_transform(texture: &ufbx::Texture) -> Affine2 { + // Prefer using the precomputed UV->Texture matrix from ufbx to match DCC behavior. + // `uv_to_texture` maps mesh UVs into texture sampling UVs, which is exactly what Bevy expects + // for `StandardMaterial::uv_transform` (multiplied on the left side in shader). + let m = &texture.uv_to_texture; + + // Build a 2D affine from the 3x4 matrix: take the upper-left 2x2 and XY translation + let mat2 = + bevy_math::Mat2::from_cols_array(&[m.m00 as f32, m.m10 as f32, m.m01 as f32, m.m11 as f32]); + let translation = Vec2::new(m.m03 as f32, m.m13 as f32); + + Affine2::from_mat2_translation(mat2, translation) +} + +// Note: Following bevy_gltf pattern, cameras are converted directly to Bevy's Camera3d components +// without intermediate FbxCamera structures. + +/// An FBX node with all of its child nodes, its mesh, transform, and optional skin. +#[derive(Asset, Debug, Clone, TypePath)] +pub struct FbxNode { + /// Index of the node inside the scene. + pub index: usize, + /// Computed name for a node - either a user defined node name from FBX or a generated name from index. + pub name: String, + /// Direct children of the node. + pub children: Vec>, + /// Mesh of the node. + pub mesh: Option>, + /// Skin of the node. + pub skin: Option>, + /// Local transform. + pub transform: Transform, + /// Visibility flag. + pub visible: bool, +} + +/// An FBX skin with all of its joint nodes and inverse bind matrices. +#[derive(Asset, Debug, Clone, TypePath)] +pub struct FbxSkin { + /// Index of the skin inside the scene. + pub index: usize, + /// Computed name for a skin - either a user defined skin name from FBX or a generated name from index. + pub name: String, + /// All the nodes that form this skin. + pub joints: Vec>, + /// Inverse-bind matrices of this skin. + pub inverse_bind_matrices: Handle, +} + +/// Representation of a loaded FBX file. +#[derive(Asset, Debug, TypePath)] +pub struct Fbx { + /// All scenes loaded from the FBX file. + pub scenes: Vec>, + /// Named scenes loaded from the FBX file. + pub named_scenes: HashMap, Handle>, + /// All meshes loaded from the FBX file. + pub meshes: Vec>, + /// Named meshes loaded from the FBX file. + pub named_meshes: HashMap, Handle>, + /// All materials loaded from the FBX file. + pub materials: Vec>, + /// Named materials loaded from the FBX file. + pub named_materials: HashMap, Handle>, + /// All nodes loaded from the FBX file. + pub nodes: Vec>, + /// Named nodes loaded from the FBX file. + pub named_nodes: HashMap, Handle>, + /// All skins loaded from the FBX file. + pub skins: Vec>, + /// Named skins loaded from the FBX file. + pub named_skins: HashMap, Handle>, + /// Default scene to be displayed. + pub default_scene: Option>, + /// All animations loaded from the FBX file. + pub animations: Vec>, + /// Named animations loaded from the FBX file. + pub named_animations: HashMap, Handle>, + // Note: Unlike GLTF, ufbx::Scene is not thread-safe and cannot be stored in an asset. + // The include_source setting is kept for API compatibility but has no effect. +} + +/// Errors that may occur while loading an FBX asset. +#[derive(Debug)] +pub enum FbxError { + /// IO error while reading the file. + Io(std::io::Error), + /// Error reported by the `ufbx` parser. + Parse(String), +} + +impl core::fmt::Display for FbxError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FbxError::Io(err) => write!(f, "{}", err), + FbxError::Parse(err) => write!(f, "{}", err), + } + } +} + +impl std::error::Error for FbxError {} + +impl From for FbxError { + fn from(err: std::io::Error) -> Self { + FbxError::Io(err) + } +} + +/// Specifies optional settings for processing FBX files at load time. +/// By default, all recognized contents of the FBX will be loaded. +/// +/// # Example +/// +/// To load an FBX but exclude the cameras, replace a call to `asset_server.load("my.fbx")` with +/// ```no_run +/// # use bevy_asset::{AssetServer, Handle}; +/// # use bevy_fbx::*; +/// # let asset_server: AssetServer = panic!(); +/// let fbx_handle: Handle = asset_server.load_with_settings( +/// "my.fbx", +/// |s: &mut FbxLoaderSettings| { +/// s.load_cameras = false; +/// } +/// ); +/// ``` +#[derive(Serialize, Deserialize)] +pub struct FbxLoaderSettings { + /// If empty, the FBX mesh nodes will be skipped. + /// + /// Otherwise, nodes will be loaded and retained in RAM/VRAM according to the active flags. + pub load_meshes: RenderAssetUsages, + /// If empty, the FBX materials will be skipped. + /// + /// Otherwise, materials will be loaded and retained in RAM/VRAM according to the active flags. + pub load_materials: RenderAssetUsages, + /// If true, the loader will spawn cameras for FBX camera nodes. + pub load_cameras: bool, + /// If true, the loader will spawn lights for FBX light nodes. + pub load_lights: bool, + /// Kept for API compatibility with GltfLoaderSettings. Has no effect as ufbx::Scene is not thread-safe. + pub include_source: bool, + /// Overrides the default sampler for textures. Data from FBX sampler node is added on top of that. + /// + /// If None, uses linear sampling by default. + pub default_sampler: Option, + /// If true, the loader will ignore sampler data from FBX and use the default sampler. + pub override_sampler: bool, + /// If true, the loader will convert FBX coordinates to Bevy's coordinate system. + /// - FBX: + /// - forward: Z (typically) + /// - up: Y + /// - right: X + /// - Bevy: + /// - forward: -Z + /// - up: Y + /// - right: X + pub convert_coordinates: bool, +} + +impl Default for FbxLoaderSettings { + fn default() -> Self { + Self { + load_meshes: RenderAssetUsages::default(), + load_materials: RenderAssetUsages::default(), + load_cameras: true, + load_lights: true, + include_source: false, + default_sampler: None, + override_sampler: false, + convert_coordinates: false, + } + } +} + +/// Loader implementation for FBX files. +#[derive(Default)] +pub struct FbxLoader; + +impl FbxLoader {} + +impl AssetLoader for FbxLoader { + type Asset = Fbx; + type Settings = FbxLoaderSettings; + type Error = FbxError; + + async fn load( + &self, + reader: &mut dyn Reader, + settings: &Self::Settings, + load_context: &mut LoadContext<'_>, + ) -> Result { + // Read the complete file. + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + + // Basic validation + if bytes.is_empty() { + return Err(FbxError::Parse("Empty FBX file".to_string())); + } + + if bytes.len() < 32 { + return Err(FbxError::Parse( + "FBX file too small to be valid".to_string(), + )); + } + + // Parse using `ufbx` and normalize the units/axes so that `1.0` equals + // one meter and the coordinate system matches Bevy's. + let root = ufbx::load_memory( + &bytes, + ufbx::LoadOpts { + target_unit_meters: 1.0, + target_axes: ufbx::CoordinateAxes::right_handed_y_up(), + ..Default::default() + }, + ) + .map_err(|e| FbxError::Parse(format!("{:?}", e)))?; + let scene: &ufbx::Scene = &*root; + + tracing::info!( + "FBX Scene has {} nodes, {} meshes", + scene.nodes.len(), + scene.meshes.len() + ); + + let mut meshes = Vec::new(); + let mut named_meshes = HashMap::new(); + let mut transforms = Vec::new(); + let _scratch: Vec = Vec::new(); + let mut mesh_material_info = Vec::new(); // Store material info for each mesh + let mut mesh_node_names = Vec::new(); // Store node names for each mesh to use as animation targets + // Map from node element id -> list of (sub-mesh handle, material name(s)) for hierarchical spawning + let mut node_meshes: HashMap, Vec)>> = HashMap::new(); + + // Only process meshes if settings allow it + if !settings.load_meshes.is_empty() { + for (index, node) in scene.nodes.as_ref().iter().enumerate() { + let Some(mesh_ref) = node.mesh.as_ref() else { + tracing::info!("Node {} has no mesh", index); + continue; + }; + let mesh = mesh_ref.as_ref(); + + tracing::info!( + "Node {} has mesh with {} vertices and {} faces", + index, + mesh.num_vertices, + mesh.faces.as_ref().len() + ); + + // Basic mesh validation + if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() { + tracing::info!("Skipping mesh {} due to validation failure", index); + continue; + } + + // Log material information for debugging + tracing::info!("Mesh {} has {} materials", index, mesh.materials.len()); + + // Group faces by material to support multi-material meshes + let mut material_groups: HashMap> = HashMap::new(); + + // Safely process faces with material assignment + let faces_result = std::panic::catch_unwind(|| { + let mut temp_material_groups: HashMap> = HashMap::new(); + let mut temp_scratch: Vec = Vec::new(); + + // Special handling for meshes with 0 materials + if mesh.materials.is_empty() { + tracing::info!( + "Mesh {} has 0 materials, creating default material group", + index + ); + // For 0-material meshes, triangulate all faces and put them in default group + let mut default_indices = Vec::new(); + for &face in mesh.faces.as_ref().iter() { + temp_scratch.clear(); + ufbx::triangulate_face_vec(&mut temp_scratch, mesh, face); + for &idx in &temp_scratch { + if (idx as usize) < mesh.vertex_indices.len() { + let v = mesh.vertex_indices[idx as usize]; + default_indices.push(v); + } + } + } + tracing::info!( + "Generated {} triangles for default material group", + default_indices.len() / 3 + ); + temp_material_groups.insert(0, default_indices); + return temp_material_groups; + } + + for (face_idx, &face) in mesh.faces.as_ref().iter().enumerate() { + // Get material index for this face + let material_idx = if !mesh.face_material.is_empty() + && mesh.face_material.len() > face_idx + { + mesh.face_material[face_idx] as usize + } else { + 0 // Default to first material if no face material info + }; + + temp_scratch.clear(); + ufbx::triangulate_face_vec(&mut temp_scratch, mesh, face); + + let indices = temp_material_groups + .entry(material_idx) + .or_insert_with(Vec::new); + for idx in &temp_scratch { + if (*idx as usize) < mesh.vertex_indices.len() { + let v = mesh.vertex_indices[*idx as usize]; + indices.push(v); + } + } + } + temp_material_groups + }); + + if let Ok(groups) = faces_result { + material_groups = groups; + } else { + tracing::warn!( + "Failed to process faces for mesh {}, using default material", + index + ); + // Create a default group with all indices - this will use material index 0 (default) + let mut all_indices = Vec::new(); + for i in 0..mesh.num_vertices { + all_indices.push(i as u32); + } + material_groups.insert(0, all_indices); + } + + tracing::info!( + "Mesh {} has {} material groups: {:?}", + index, + material_groups.len(), + material_groups.keys().collect::>() + ); + + // Create separate mesh for each material group + let mut mesh_handles = Vec::new(); + let mut material_indices = Vec::new(); + + for (material_idx, indices) in material_groups.iter() { + tracing::info!( + "Material group {}: {} triangles", + material_idx, + indices.len() / 3 + ); + + let sub_mesh_handle = load_context.labeled_asset_scope::<_, FbxError>( + FbxAssetLabel::Mesh(index * 1000 + material_idx).to_string(), + |_lc| { + let positions: Vec<[f32; 3]> = mesh + .vertex_position + .values + .as_ref() + .iter() + .map(|v| { + let pos = [v.x as f32, v.y as f32, v.z as f32]; + if settings.convert_coordinates { + pos.convert_coordinates() + } else { + pos + } + }) + .collect(); + + let mut bevy_mesh = + Mesh::new(PrimitiveTopology::TriangleList, settings.load_meshes); + bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + + // Log material information for debugging + tracing::info!("Mesh {} has {} materials", index, mesh.materials.len()); + + if mesh.vertex_normal.exists { + let normals: Vec<[f32; 3]> = (0..mesh.num_vertices) + .map(|i| { + let n = mesh.vertex_normal[i]; + let normal = [n.x as f32, n.y as f32, n.z as f32]; + if settings.convert_coordinates { + normal.convert_coordinates() + } else { + normal + } + }) + .collect(); + bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); + } + + if mesh.vertex_uv.exists { + let uvs: Vec<[f32; 2]> = (0..mesh.num_vertices) + .map(|i| { + let uv = mesh.vertex_uv[i]; + [uv.x as f32, uv.y as f32] + }) + .collect(); + bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + } + + // Process skinning data if available + if mesh.skin_deformers.len() > 0 { + let skin_deformer = &mesh.skin_deformers[0]; + + // Extract joint indices and weights + let mut joint_indices = vec![[0u16; 4]; mesh.num_vertices]; + let mut joint_weights = vec![[0.0f32; 4]; mesh.num_vertices]; + + for vertex_index in 0..mesh.num_vertices { + let mut weight_count = 0; + let mut total_weight = 0.0f32; + + for (cluster_index, cluster) in + skin_deformer.clusters.iter().enumerate() + { + if weight_count >= 4 { + break; + } + + // Find weight for this vertex in this cluster (single pass) + for (i, &vert_idx) in cluster.vertices.iter().enumerate() { + if vert_idx as usize == vertex_index { + if i < cluster.weights.len() { + let weight = cluster.weights[i] as f32; + if weight > 0.0 { + joint_indices[vertex_index][weight_count] = + cluster_index as u16; + joint_weights[vertex_index][weight_count] = + weight; + total_weight += weight; + weight_count += 1; + } + } + break; + } + } + } + + // Normalize weights to sum to 1.0 + if total_weight > 0.0 { + for i in 0..weight_count { + joint_weights[vertex_index][i] /= total_weight; + } + } + } + + bevy_mesh.insert_attribute( + Mesh::ATTRIBUTE_JOINT_INDEX, + VertexAttributeValues::Uint16x4(joint_indices), + ); + bevy_mesh + .insert_attribute(Mesh::ATTRIBUTE_JOINT_WEIGHT, joint_weights); + } + + // Set indices for this material group + bevy_mesh.insert_indices(Indices::U32(indices.clone())); + + Ok(bevy_mesh) + }, + )?; + + mesh_handles.push(sub_mesh_handle); + material_indices.push(*material_idx); + } + + // Store all mesh handles for multi-material support + if !mesh_handles.is_empty() { + // Store each material group as a separate mesh entry + for (sub_mesh_handle, material_idx) in + mesh_handles.iter().zip(material_indices.iter()) + { + if !node.element.name.is_empty() && material_idx == &0 { + // Only store the first sub-mesh in named_meshes for backward compatibility + named_meshes.insert( + Box::from(node.element.name.as_ref()), + sub_mesh_handle.clone(), + ); + } + meshes.push(sub_mesh_handle.clone()); + transforms.push(node.geometry_to_world); + mesh_node_names.push(node.element.name.to_string()); + + // Store material information for this specific sub-mesh + let material_name = if *material_idx < mesh.materials.len() { + mesh.materials[*material_idx].element.name.to_string() + } else { + "default".to_string() + }; + mesh_material_info.push(vec![material_name.clone()]); + + // Record per-node association for proper hierarchy spawning later + node_meshes + .entry(node.element.element_id) + .or_default() + .push((sub_mesh_handle.clone(), vec![material_name])); + } + } else { + // Fallback: create a simple mesh with no indices if material processing failed + tracing::warn!("Creating fallback mesh for mesh {}", index); + let fallback_handle = load_context.labeled_asset_scope::<_, FbxError>( + FbxAssetLabel::Mesh(index).to_string(), + |_lc| { + let positions: Vec<[f32; 3]> = mesh + .vertex_position + .values + .as_ref() + .iter() + .map(|v| { + let pos = [v.x as f32, v.y as f32, v.z as f32]; + if settings.convert_coordinates { + pos.convert_coordinates() + } else { + pos + } + }) + .collect(); + + let mut bevy_mesh = + Mesh::new(PrimitiveTopology::TriangleList, settings.load_meshes); + bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + + if mesh.vertex_normal.exists { + let normals: Vec<[f32; 3]> = (0..mesh.num_vertices) + .map(|i| { + let n = mesh.vertex_normal[i]; + let normal = [n.x as f32, n.y as f32, n.z as f32]; + if settings.convert_coordinates { + normal.convert_coordinates() + } else { + normal + } + }) + .collect(); + bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); + } + + if mesh.vertex_uv.exists { + let uvs: Vec<[f32; 2]> = (0..mesh.num_vertices) + .map(|i| { + let uv = mesh.vertex_uv[i]; + [uv.x as f32, uv.y as f32] + }) + .collect(); + bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + } + + Ok(bevy_mesh) + }, + )?; + + if !node.element.name.is_empty() { + named_meshes.insert( + Box::from(node.element.name.as_ref()), + fallback_handle.clone(), + ); + } + meshes.push(fallback_handle); + transforms.push(node.geometry_to_world); + mesh_material_info.push(vec!["default".to_string()]); + mesh_node_names.push(node.element.name.to_string()); + } + } // End of mesh loading check + } else { + tracing::info!("Skipping mesh loading as load_meshes is empty"); + } + + // Process textures and materials + let mut texture_handles = HashMap::new(); + + // Determine the sampler to use + let default_sampler = settings + .default_sampler + .clone() + .unwrap_or_else(|| ImageSamplerDescriptor::linear()); + + // First pass: collect all textures + for texture in scene.textures.as_ref().iter() { + // Following bevy_gltf pattern, we only store texture handles. + // Texture metadata like UV transforms are applied directly when creating materials. + + // Try to load the texture file + if !texture.filename.is_empty() { + let texture_path = if !texture.absolute_filename.is_empty() { + texture.absolute_filename.to_string() + } else { + // Try relative to the FBX file + let fbx_dir = load_context + .path() + .parent() + .unwrap_or_else(|| std::path::Path::new("")); + fbx_dir + .join(texture.filename.as_ref()) + .to_string_lossy() + .to_string() + }; + + // Determine sampler for this texture + let sampler = if settings.override_sampler { + // Use default sampler, ignoring FBX sampler data + default_sampler.clone() + } else { + // Create sampler from FBX texture data with default as base + create_sampler_from_texture(texture, &default_sampler) + }; + + // Try to load texture file data directly to control sampler + let image_handle = if let Ok(data) = std::fs::read(&texture_path) { + // Determine image type from extension + let extension = std::path::Path::new(&texture_path) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or(""); + + let image_type = match extension.to_lowercase().as_str() { + "png" => ImageType::Extension("png"), + "jpg" | "jpeg" => ImageType::Extension("jpg"), + "tga" => ImageType::Extension("tga"), + "bmp" => ImageType::Extension("bmp"), + "dds" => ImageType::Extension("dds"), + "webp" => ImageType::Extension("webp"), + _ => ImageType::Extension(extension), + }; + + // Create image with sampler settings + match Image::from_buffer( + &data, + image_type, + CompressedImageFormats::NONE, + true, // is_srgb for color textures + ImageSampler::Descriptor(sampler), + settings.load_materials, + ) { + Ok(image) => { + // Add as labeled asset + load_context.add_labeled_asset( + format!("Texture{}", texture.element.element_id), + image, + ) + } + Err(e) => { + tracing::warn!( + "Failed to load texture '{}' with sampler: {}", + texture_path, + e + ); + // Fallback to regular loading + load_context.load(texture_path) + } + } + } else { + tracing::warn!( + "Failed to read texture file '{}', using default loader", + texture_path + ); + // Fallback to regular loading + load_context.load(texture_path) + }; + + texture_handles.insert(texture.element.element_id, image_handle); + } + } + + // Convert materials with enhanced PBR support (only if enabled in settings) + let mut materials = Vec::new(); + let mut named_materials = HashMap::new(); + + // Only process materials if settings allow it + if !settings.load_materials.is_empty() { + for (index, ufbx_material) in scene.materials.as_ref().iter().enumerate() { + // Safety check: ensure material is valid + if ufbx_material.element.element_id == 0 { + tracing::warn!("Skipping invalid material at index {}", index); + continue; + } + // Extract material properties + let mut base_color = Color::srgb(1.0, 1.0, 1.0); + let mut metallic = 0.0f32; + let mut roughness = 0.5f32; + let mut emission = Color::BLACK; + let mut alpha = 1.0f32; + let mut material_textures = HashMap::new(); + + // Extract material properties from ufbx material + // Try both traditional FBX material properties and PBR properties + + tracing::info!( + "Processing material {}: '{}'", + index, + ufbx_material.element.name + ); + + // Try to get diffuse color from traditional FBX material properties first + // Use safe access to avoid ufbx pointer issues + if let Ok(diffuse_color) = + std::panic::catch_unwind(|| ufbx_material.fbx.diffuse_color.value_vec4) + { + let color = Color::srgb( + diffuse_color.x as f32, + diffuse_color.y as f32, + diffuse_color.z as f32, + ); + + base_color = color; + tracing::info!("Material {} diffuse color: {:?}", index, base_color); + } else { + tracing::warn!( + "Failed to get diffuse color for material {}, using default", + index + ); + } + + // Get emission color from traditional FBX material properties + if let Ok(emission_color) = + std::panic::catch_unwind(|| ufbx_material.fbx.emission_color.value_vec4) + { + emission = Color::srgb( + emission_color.x as f32, + emission_color.y as f32, + emission_color.z as f32, + ); + tracing::info!("Material {} emission color: {:?}", index, emission); + } else { + tracing::warn!( + "Failed to get emission color for material {}, using default", + index + ); + } + + // Fall back to PBR properties if traditional ones are not available + if base_color == Color::srgb(1.0, 1.0, 1.0) { + if let Ok(pbr_diffuse) = + std::panic::catch_unwind(|| ufbx_material.pbr.base_color.value_vec4) + { + base_color = Color::srgb( + pbr_diffuse.x as f32, + pbr_diffuse.y as f32, + pbr_diffuse.z as f32, + ); + } + } + + if emission == Color::BLACK { + if let Ok(pbr_emission) = + std::panic::catch_unwind(|| ufbx_material.pbr.emission_color.value_vec4) + { + emission = Color::srgb( + pbr_emission.x as f32, + pbr_emission.y as f32, + pbr_emission.z as f32, + ); + } + } + + // Metallic factor - 0.0 = dielectric, 1.0 = metallic + if let Ok(metallic_value) = + std::panic::catch_unwind(|| ufbx_material.pbr.metalness.value_vec4) + { + metallic = metallic_value.x as f32; + } + + // Roughness factor - 0.0 = mirror-like, 1.0 = completely rough + if let Ok(roughness_value) = + std::panic::catch_unwind(|| ufbx_material.pbr.roughness.value_vec4) + { + roughness = roughness_value.x as f32; + } + + // Enhanced: alpha handling & double sided + let alpha_cutoff = 0.5f32; + let double_sided = ufbx_material.features.double_sided.enabled; + + // Opacity value / texture + let has_opacity_value = ufbx_material.pbr.opacity.has_value; + let opacity_value = ufbx_material.pbr.opacity.value_vec4.x as f32; + let has_opacity_texture = ufbx_material.pbr.opacity.texture.is_some(); + + if has_opacity_value { + alpha = opacity_value; + } + + // Process material textures and map them to appropriate texture types + // This enables automatic texture application to Bevy's StandardMaterial + for texture_ref in &ufbx_material.textures { + let texture = &texture_ref.texture; + if let Some(image_handle) = texture_handles.get(&texture.element.element_id) { + // Map FBX texture property names to our internal texture types + // This mapping ensures textures are applied to the correct material slots + let texture_type = match texture_ref.material_prop.as_ref() { + "DiffuseColor" | "BaseColor" => Some(FbxTextureType::BaseColor), + "NormalMap" => Some(FbxTextureType::Normal), + "Metallic" => Some(FbxTextureType::Metallic), + "Roughness" => Some(FbxTextureType::Roughness), + "EmissiveColor" => Some(FbxTextureType::Emission), + "AmbientOcclusion" => Some(FbxTextureType::AmbientOcclusion), + _ => { + // Log unknown texture types for debugging + info!("Unknown texture type: {}", texture_ref.material_prop); + None + } + }; + + if let Some(tex_type) = texture_type { + material_textures.insert(tex_type, image_handle.clone()); + info!( + "Applied {:?} texture to material {}", + tex_type, ufbx_material.element.name + ); + } + } + } + + // Note: Following bevy_gltf pattern, we directly create StandardMaterial + // without an intermediate FbxMaterial struct. + // The unused image_handle loop has been removed as material_textures is used directly below. + + // Create StandardMaterial with enhanced properties + let mut standard_material = StandardMaterial { + base_color, + metallic, + perceptual_roughness: roughness, + emissive: emission.into(), + alpha_mode: if has_opacity_texture { + // Prefer cutoff for authored opacity textures (common for foliage, decals) + AlphaMode::Mask(alpha_cutoff) + } else if alpha < 1.0 { + AlphaMode::Blend + } else { + AlphaMode::Opaque + }, + cull_mode: if double_sided { None } else { Some(Face::Back) }, + double_sided, + ..Default::default() + }; + + // Apply textures to StandardMaterial with UV transform support + // This is where the magic happens - we automatically map FBX textures to Bevy's material slots + + // Base color texture (diffuse map) - provides the main color information + if let Some(base_color_texture) = material_textures.get(&FbxTextureType::BaseColor) + { + standard_material.base_color_texture = Some(base_color_texture.clone()); + + // Apply UV transform if base color texture has transformations + // Find the corresponding FBX texture for UV transform data + for texture_ref in &ufbx_material.textures { + if let Some(tex_type) = match texture_ref.material_prop.as_ref() { + "DiffuseColor" | "BaseColor" => Some(FbxTextureType::BaseColor), + _ => None, + } { + if tex_type == FbxTextureType::BaseColor { + let uv_transform = + convert_texture_uv_transform(&texture_ref.texture); + standard_material.uv_transform = uv_transform; + break; + } + } + } + + info!( + "Applied base color texture to material {}", + ufbx_material.element.name + ); + } + + // Normal map texture - provides surface detail through normal vectors + if let Some(normal_texture) = material_textures.get(&FbxTextureType::Normal) { + standard_material.normal_map_texture = Some(normal_texture.clone()); + info!( + "Applied normal map to material {}", + ufbx_material.element.name + ); + } + + // Metallic texture - defines which parts of the surface are metallic + if let Some(metallic_texture) = material_textures.get(&FbxTextureType::Metallic) { + // In Bevy, metallic and roughness are combined into a single texture + // Red channel = metallic, Green channel = roughness + standard_material.metallic_roughness_texture = Some(metallic_texture.clone()); + info!( + "Applied metallic texture to material {}", + ufbx_material.element.name + ); + } + + // Roughness texture - defines surface roughness (smoothness) + if let Some(roughness_texture) = material_textures.get(&FbxTextureType::Roughness) { + // Only apply if we don't already have a metallic texture + // This prevents overwriting a combined metallic-roughness texture + if standard_material.metallic_roughness_texture.is_none() { + standard_material.metallic_roughness_texture = + Some(roughness_texture.clone()); + info!( + "Applied roughness texture to material {}", + ufbx_material.element.name + ); + } + } + + // Emission texture - for self-illuminating surfaces + if let Some(emission_texture) = material_textures.get(&FbxTextureType::Emission) { + standard_material.emissive_texture = Some(emission_texture.clone()); + info!( + "Applied emission texture to material {}", + ufbx_material.element.name + ); + } + + // Ambient occlusion texture - provides shadowing information + if let Some(ao_texture) = material_textures.get(&FbxTextureType::AmbientOcclusion) { + standard_material.occlusion_texture = Some(ao_texture.clone()); + info!( + "Applied ambient occlusion texture to material {}", + ufbx_material.element.name + ); + } + + let handle = load_context.add_labeled_asset( + FbxAssetLabel::Material(index).to_string(), + standard_material, + ); + + if !ufbx_material.element.name.is_empty() { + named_materials.insert( + Box::from(ufbx_material.element.name.as_ref()), + handle.clone(), + ); + } + + materials.push(handle); + } + } // End of materials loading check + + // Process skins first + let mut skins = Vec::new(); + let mut named_skins = HashMap::new(); + let mut skin_map = HashMap::new(); // Map from ufbx skin ID to FbxSkin handle + + for (skin_index, mesh_node) in scene.nodes.as_ref().iter().enumerate() { + let Some(mesh_ref) = &mesh_node.mesh else { + continue; + }; + let mesh = mesh_ref.as_ref(); + + if mesh.skin_deformers.is_empty() { + continue; + } + + let skin_deformer = &mesh.skin_deformers[0]; + + // Create inverse bind matrices + let mut inverse_bind_matrices = Vec::new(); + let mut joint_node_ids = Vec::new(); + + for cluster in &skin_deformer.clusters { + // Convert ufbx matrix to Mat4 + let bind_matrix = cluster.bind_to_world; + let inverse_bind_matrix = Mat4::from_cols_array(&[ + bind_matrix.m00 as f32, + bind_matrix.m10 as f32, + bind_matrix.m20 as f32, + 0.0, + bind_matrix.m01 as f32, + bind_matrix.m11 as f32, + bind_matrix.m21 as f32, + 0.0, + bind_matrix.m02 as f32, + bind_matrix.m12 as f32, + bind_matrix.m22 as f32, + 0.0, + bind_matrix.m03 as f32, + bind_matrix.m13 as f32, + bind_matrix.m23 as f32, + 1.0, + ]) + .inverse(); + + inverse_bind_matrices.push(inverse_bind_matrix); + + // Store joint node ID for later resolution + if let Some(bone_node) = cluster.bone_node.as_ref() { + joint_node_ids.push(bone_node.element.element_id); + } + } + + if !inverse_bind_matrices.is_empty() { + let inverse_bindposes_handle = load_context.add_labeled_asset( + FbxAssetLabel::Skin(skin_index).to_string() + "_InverseBindposes", + SkinnedMeshInverseBindposes::from(inverse_bind_matrices), + ); + + let skin_name = if mesh_node.element.name.is_empty() { + format!("Skin_{}", skin_index) + } else { + format!("{}_Skin", mesh_node.element.name) + }; + + // Store skin info for later processing + skin_map.insert( + mesh_node.element.element_id, + ( + inverse_bindposes_handle, + joint_node_ids, + skin_name, + skin_index, + ), + ); + } + } + + // Process nodes and build hierarchy + let mut nodes = Vec::new(); + let mut named_nodes = HashMap::new(); + let mut node_map = HashMap::new(); // Map from ufbx node ID to FbxNode handle + + // First pass: create all nodes + for (index, ufbx_node) in scene.nodes.as_ref().iter().enumerate() { + let name = if ufbx_node.element.name.is_empty() { + format!("Node_{}", index) + } else { + ufbx_node.element.name.to_string() + }; + + // Find associated mesh + let mesh_handle = if let Some(_mesh_ref) = &ufbx_node.mesh { + // Find the mesh in our processed meshes + meshes + .iter() + .enumerate() + .find_map(|(mesh_idx, mesh_handle)| { + // Check if this mesh corresponds to this node + if let Some(mesh_node) = scene.nodes.as_ref().get(mesh_idx) { + if mesh_node.element.element_id == ufbx_node.element.element_id { + Some(mesh_handle.clone()) + } else { + None + } + } else { + None + } + }) + } else { + None + }; + + // Convert transform: prefer `node_to_parent` matrix which includes FBX adjust transforms + let ntp = &ufbx_node.node_to_parent; + let mut transform = Transform::from_matrix(Mat4::from_cols_array(&[ + ntp.m00 as f32, + ntp.m10 as f32, + ntp.m20 as f32, + 0.0, + ntp.m01 as f32, + ntp.m11 as f32, + ntp.m21 as f32, + 0.0, + ntp.m02 as f32, + ntp.m12 as f32, + ntp.m22 as f32, + 0.0, + ntp.m03 as f32, + ntp.m13 as f32, + ntp.m23 as f32, + 1.0, + ])); + if settings.convert_coordinates { + transform = transform.convert_coordinates(); + } + + let fbx_node = FbxNode { + index, + name: name.clone(), + children: Vec::new(), // Will be filled in second pass + mesh: mesh_handle, + skin: None, // Will be set later after all nodes are created + transform, + visible: ufbx_node.visible, + }; + + let node_handle = + load_context.add_labeled_asset(FbxAssetLabel::Node(index).to_string(), fbx_node); + + node_map.insert(ufbx_node.element.element_id, node_handle.clone()); + nodes.push(node_handle.clone()); + + if !ufbx_node.element.name.is_empty() { + named_nodes.insert(Box::from(ufbx_node.element.name.as_ref()), node_handle); + } + } + + // Second pass: establish parent-child relationships safely + // We build the hierarchy by processing node connections from the scene + for (parent_index, parent_node) in scene.nodes.as_ref().iter().enumerate() { + // Safely collect child node indices by iterating through all nodes + // and checking if they reference this node as parent + let mut child_handles = Vec::new(); + + for (child_index, child_node) in scene.nodes.as_ref().iter().enumerate() { + if child_index != parent_index { + // Check if this child node belongs to the parent + // We use a safe approach by checking node relationships through the scene structure + let is_child = std::panic::catch_unwind(|| { + // Try to determine parent-child relationship safely + // For now, we'll use a conservative approach and only establish + // relationships that we can verify are safe + false // Default to no relationship until we can safely determine it + }) + .unwrap_or(false); + + if is_child { + if let Some(child_handle) = node_map.get(&child_node.element.element_id) { + child_handles.push(child_handle.clone()); + } + } + } + } + + // Update the parent node with its children + if !child_handles.is_empty() { + if let Some(_parent_handle) = node_map.get(&parent_node.element.element_id) { + // For now, we store the children info but don't update the actual FbxNode + // This will be completed when we have a safer way to modify the assets + tracing::info!( + "Node '{}' would have {} children", + parent_node.element.name, + child_handles.len() + ); + } + } + } + + tracing::info!("Node hierarchy processing completed with safe approach"); + + // Third pass: Create actual FbxSkin assets now that all nodes are created + for (_mesh_node_id, (inverse_bindposes_handle, joint_node_ids, skin_name, skin_index)) in + skin_map.iter() + { + let mut joint_handles = Vec::new(); + + // Resolve joint node IDs to handles + for &joint_node_id in joint_node_ids { + if let Some(joint_handle) = node_map.get(&joint_node_id) { + joint_handles.push(joint_handle.clone()); + } + } + + let fbx_skin = FbxSkin { + index: *skin_index, + name: skin_name.clone(), + joints: joint_handles, + inverse_bind_matrices: inverse_bindposes_handle.clone(), + }; + + let skin_handle = load_context + .add_labeled_asset(FbxAssetLabel::Skin(*skin_index).to_string(), fbx_skin); + + skins.push(skin_handle.clone()); + + if !skin_name.starts_with("Skin_") { + named_skins.insert(Box::from(skin_name.as_str()), skin_handle); + } + } + + // Process lights from the FBX scene (only if enabled in settings) + let mut lights_processed = 0; + if settings.load_lights { + for light in scene.lights.as_ref().iter() { + // Log light information directly without creating intermediate struct + let light_type_str = match light.type_ { + ufbx::LightType::Directional => "directional", + ufbx::LightType::Point => "point", + ufbx::LightType::Spot => "spot", + ufbx::LightType::Area => "area", + ufbx::LightType::Volume => "volume", + }; + + tracing::info!( + "FBX Loader: Found {} light '{}' with intensity {}", + light_type_str, + light.element.name, + light.intensity + ); + + lights_processed += 1; + } + + tracing::info!("FBX Loader: Processed {} lights", lights_processed); + } // End of lights loading check + + // Process animations from the FBX scene + let mut animations = Vec::new(); + let mut named_animations = HashMap::new(); + let mut animations_processed = 0; + + for anim_stack in scene.anim_stacks.as_ref().iter() { + tracing::info!( + "FBX Loader: Processing animation stack '{}' ({:.2}s - {:.2}s)", + anim_stack.element.name, + anim_stack.time_begin, + anim_stack.time_end + ); + + // Create a new AnimationClip for this animation stack + let mut animation_clip = AnimationClip::default(); + let duration = (anim_stack.time_end - anim_stack.time_begin) as f32; + + // Process animation layers within the stack + for layer in anim_stack.layers.as_ref().iter() { + tracing::info!( + "FBX Loader: Processing animation layer '{}' (weight: {})", + layer.element.name, + layer.weight + ); + + // Process animation values in this layer + tracing::info!( + "FBX Loader: Processing animation layer '{}' with {} animation values", + layer.element.name, + layer.anim_values.as_ref().len() + ); + + // Process animation curves in this layer using a more robust approach + let mut node_animations: HashMap>> = + HashMap::new(); + + // Process animation curves using the available ufbx API + tracing::info!( + "FBX Loader: Processing animation curves in layer '{}'", + layer.element.name + ); + + // Fallback: try the original method if no curve nodes were found + if node_animations.is_empty() { + tracing::info!( + "FBX Loader: No animation curve nodes found, trying fallback method" + ); + tracing::info!( + "FBX Loader: Layer has {} anim_values", + layer.anim_values.as_ref().len() + ); + + // Debug: list all scene nodes + for (node_index, node) in scene.nodes.as_ref().iter().enumerate() { + tracing::info!( + "FBX Loader: Scene node {}: element_id={}, name='{}'", + node_index, + node.element.element_id, + node.element.name + ); + } + + for (anim_value_index, anim_value) in + layer.anim_values.as_ref().iter().enumerate() + { + tracing::info!("FBX Loader: Processing anim_value {}: element_id={}, name='{}', curves={}", + anim_value_index, anim_value.element.element_id, anim_value.element.name, anim_value.curves.as_ref().len()); + // For FBX, we need to find which scene node these animation properties belong to + // Since the animation properties don't directly match node IDs, we'll associate them + // with all mesh nodes (nodes that have a mesh attached) + let matching_node = if scene.meshes.as_ref().len() > 0 { + // For now, associate all animation properties with the first mesh node + // This is a simplified approach - in a full implementation, we'd parse FBX connections + scene + .nodes + .as_ref() + .iter() + .find(|node| !node.element.name.is_empty()) + } else { + // Fallback to exact element ID match + scene.nodes.as_ref().iter().find(|node| { + node.element.element_id == anim_value.element.element_id + }) + }; + + if let Some(target_node) = matching_node { + tracing::info!("FBX Loader: Found matching node '{}' (element_id={}) for animation property '{}'", + target_node.element.name, target_node.element.element_id, anim_value.element.name); + for (curve_index, anim_curve_opt) in + anim_value.curves.as_ref().iter().enumerate() + { + if let Some(anim_curve) = anim_curve_opt.as_ref() { + if !anim_curve.keyframes.as_ref().is_empty() { + let keyframes: Vec<(f32, f32)> = anim_curve + .keyframes + .as_ref() + .iter() + .map(|keyframe| { + (keyframe.time as f32, keyframe.value as f32) + }) + .collect(); + + if keyframes.len() >= 2 { + let property_key = format!( + "{}_{}", + anim_value.element.name, curve_index + ); + tracing::info!("FBX Loader: Adding animation curve '{}' with {} keyframes to node {}", + property_key, keyframes.len(), target_node.element.element_id); + node_animations + .entry(target_node.element.element_id) + .or_insert_with(HashMap::new) + .insert(property_key, keyframes); + } + } + } + } + } else { + tracing::info!("FBX Loader: No matching node found for animation property '{}' (element_id={})", + anim_value.element.name, anim_value.element.element_id); + } + } + } + + // Debug: log the number of animated nodes found + tracing::info!( + "FBX Loader: Found {} animated nodes with curves", + node_animations.len() + ); + for (node_id, properties) in node_animations.iter() { + tracing::info!( + "FBX Loader: Node {} has {} animated properties: {:?}", + node_id, + properties.len(), + properties.keys().collect::>() + ); + } + + // Create animation curves for each animated node + for (node_id, properties) in node_animations { + if let Some(target_node) = scene + .nodes + .as_ref() + .iter() + .find(|node| node.element.element_id == node_id) + { + // Find the corresponding mesh index for this node + let target_name = if let Some(mesh_index) = scene + .nodes + .as_ref() + .iter() + .position(|n| n.element.element_id == node_id) + { + if !target_node.element.name.is_empty() { + target_node.element.name.to_string() + } else { + format!("Mesh_{}", mesh_index) + } + } else { + format!("Node_{}", node_id) + }; + + let node_name = Name::new(target_name.clone()); + let animation_target_id = AnimationTargetId::from_name(&node_name); + + // Try to create translation animation from X, Y, Z components + // Support both long FBX names and short aliases (T/R/S) + let tx = properties + .get("Lcl Translation_0") + .or_else(|| properties.get("T_0")); + let ty = properties + .get("Lcl Translation_1") + .or_else(|| properties.get("T_1")); + let tz = properties + .get("Lcl Translation_2") + .or_else(|| properties.get("T_2")); + + if let (Some(x_keyframes), Some(y_keyframes), Some(z_keyframes)) = + (tx, ty, tz) + { + // Create Vec3 keyframes by combining X, Y, Z + let combined_keyframes: Vec<(f32, Vec3)> = x_keyframes + .iter() + .zip(y_keyframes.iter()) + .zip(z_keyframes.iter()) + .map(|(((time_x, x), (_, y)), (_, z))| { + (*time_x, Vec3::new(*x, *y, *z)) + }) + .collect(); + + if let Ok(translation_curve) = + AnimatableKeyframeCurve::new(combined_keyframes) + { + let animatable_curve = AnimatableCurve::new( + animated_field!(Transform::translation), + translation_curve, + ); + + animation_clip + .add_curve_to_target(animation_target_id, animatable_curve); + + tracing::info!( + "FBX Loader: Added translation animation for node '{}'", + target_name + ); + } + } + + // Try to create rotation animation from X, Y, Z Euler angles + let rx = properties + .get("Lcl Rotation_0") + .or_else(|| properties.get("R_0")); + let ry = properties + .get("Lcl Rotation_1") + .or_else(|| properties.get("R_1")); + let rz = properties + .get("Lcl Rotation_2") + .or_else(|| properties.get("R_2")); + + if let (Some(x_keyframes), Some(y_keyframes), Some(z_keyframes)) = + (rx, ry, rz) + { + // Convert Euler angles (degrees) to quaternions + let combined_keyframes: Vec<(f32, Quat)> = x_keyframes + .iter() + .zip(y_keyframes.iter()) + .zip(z_keyframes.iter()) + .map(|(((time_x, x), (_, y)), (_, z))| { + // Convert degrees to radians and create quaternion + let euler_rad = + Vec3::new(x.to_radians(), y.to_radians(), z.to_radians()); + let quat = Quat::from_euler( + bevy_math::EulerRot::XYZ, + euler_rad.x, + euler_rad.y, + euler_rad.z, + ); + (*time_x, quat) + }) + .collect(); + + if let Ok(rotation_curve) = + AnimatableKeyframeCurve::new(combined_keyframes) + { + let animatable_curve = AnimatableCurve::new( + animated_field!(Transform::rotation), + rotation_curve, + ); + + animation_clip + .add_curve_to_target(animation_target_id, animatable_curve); + + tracing::info!( + "FBX Loader: Added rotation animation for node '{}'", + target_name + ); + } + } + + // Try to create scale animation from X, Y, Z components + let sx = properties + .get("Lcl Scaling_0") + .or_else(|| properties.get("S_0")); + let sy = properties + .get("Lcl Scaling_1") + .or_else(|| properties.get("S_1")); + let sz = properties + .get("Lcl Scaling_2") + .or_else(|| properties.get("S_2")); + + if let (Some(x_keyframes), Some(y_keyframes), Some(z_keyframes)) = + (sx, sy, sz) + { + // Create Vec3 keyframes by combining X, Y, Z + let combined_keyframes: Vec<(f32, Vec3)> = x_keyframes + .iter() + .zip(y_keyframes.iter()) + .zip(z_keyframes.iter()) + .map(|(((time_x, x), (_, y)), (_, z))| { + (*time_x, Vec3::new(*x, *y, *z)) + }) + .collect(); + + if let Ok(scale_curve) = + AnimatableKeyframeCurve::new(combined_keyframes) + { + let animatable_curve = AnimatableCurve::new( + animated_field!(Transform::scale), + scale_curve, + ); + + animation_clip + .add_curve_to_target(animation_target_id, animatable_curve); + + tracing::info!( + "FBX Loader: Added scale animation for node '{}'", + target_name + ); + } + } + } + } + } + + // Set the animation duration + if duration > 0.0 { + // Note: In a full implementation, we would add the actual animation curves here + tracing::info!( + "FBX Loader: Animation '{}' duration: {:.2}s", + anim_stack.element.name, + duration + ); + + let animation_handle = load_context.add_labeled_asset( + FbxAssetLabel::Animation(animations_processed).to_string(), + animation_clip, + ); + + animations.push(animation_handle.clone()); + + if !anim_stack.element.name.is_empty() { + named_animations.insert( + Box::from(anim_stack.element.name.as_ref()), + animation_handle, + ); + } + + animations_processed += 1; + } + } + + tracing::info!("FBX Loader: Processed {} animations", animations_processed); + + let mut scenes = Vec::new(); + let named_scenes = HashMap::new(); + + // Build a scene with all meshes (simplified approach) + let mut world = World::new(); + let default_material = materials.get(0).cloned().unwrap_or_else(|| { + // Create a bright, easily visible default material + let mut default_mat = StandardMaterial::default(); + default_mat.base_color = Color::srgb(0.8, 0.2, 0.2); // Bright red + default_mat.metallic = 0.0; + default_mat.perceptual_roughness = 0.8; + default_mat.cull_mode = None; // Disable backface culling for easier debugging + + tracing::info!("FBX Loader: Created bright red default material for better visibility"); + + load_context.add_labeled_asset(FbxAssetLabel::DefaultMaterial.to_string(), default_mat) + }); + + // Create animation player and graph if there are animations + let animation_player = if !animations.is_empty() { + // Create animation graph with all clips + let mut animation_indices = Vec::new(); + let mut graph = AnimationGraph::new(); + + // Add each animation clip to the graph + for (i, animation_handle) in animations.iter().enumerate() { + let node_index = graph.add_clip(animation_handle.clone(), 1.0, graph.root); + animation_indices.push(node_index); + tracing::info!("Added animation {} to graph with index {:?}", i, node_index); + } + + // Store the animation graph as an asset + let graph_handle = + load_context.add_labeled_asset(FbxAssetLabel::AnimationGraph(0).to_string(), graph); + + let mut player = AnimationPlayer::default(); + // Auto-play the first animation + if let Some(&first_node_index) = animation_indices.first() { + player.play(first_node_index).repeat(); + tracing::info!( + "Auto-playing first animation with node index {:?}", + first_node_index + ); + } + + let player_entity = world + .spawn((player, AnimationGraphHandle(graph_handle.clone()))) + .id(); + + tracing::info!( + "FBX Loader: Created animation player for {} animations", + animations.len() + ); + Some(player_entity) + } else { + None + }; + + tracing::info!( + "FBX Loader: Found {} meshes, {} nodes", + meshes.len(), + scene.nodes.len() + ); + + // Spawn hierarchy: first spawn all nodes with local transforms + let mut node_entities: Vec> = vec![None; scene.nodes.len()]; + // Map from ufbx element_id -> spawned Entity for quick lookup (used by skinning) + let mut element_to_entity: HashMap = HashMap::new(); + + // Helper to convert ufbx::Transform -> bevy Transform + let to_bevy_transform = |t: &ufbx::Transform| -> Transform { + let mut tr = Transform { + translation: Vec3::new( + t.translation.x as f32, + t.translation.y as f32, + t.translation.z as f32, + ), + rotation: Quat::from_xyzw( + t.rotation.x as f32, + t.rotation.y as f32, + t.rotation.z as f32, + t.rotation.w as f32, + ), + scale: Vec3::new(t.scale.x as f32, t.scale.y as f32, t.scale.z as f32), + }; + if settings.convert_coordinates { + tr = tr.convert_coordinates(); + } + tr + }; + + for (i, u_node) in scene.nodes.as_ref().iter().enumerate() { + let name = if u_node.element.name.is_empty() { + format!("Node_{}", i) + } else { + u_node.element.name.to_string() + }; + let mut e = world.spawn(( + to_bevy_transform(&u_node.local_transform), + GlobalTransform::default(), + Visibility::default(), + Name::new(name.clone()), + )); + + // If we have animations, attach targets to node entities (targets identified by name) + if !animations.is_empty() { + let target = AnimationTarget { + id: AnimationTargetId::from_names(std::iter::once(&Name::new(name.clone()))), + player: animation_player.unwrap(), + }; + e.insert(target); + } + + // Attach lights/cameras directly on node (if requested) + if settings.load_lights { + if let Some(light_ref) = &u_node.light { + let light = light_ref.as_ref(); + match light.type_ { + ufbx::LightType::Directional => { + e.insert(DirectionalLight { + color: Color::srgb( + light.color.x as f32, + light.color.y as f32, + light.color.z as f32, + ), + illuminance: light.intensity as f32, + shadows_enabled: light.cast_shadows, + ..Default::default() + }); + } + ufbx::LightType::Point => { + e.insert(PointLight { + color: Color::srgb( + light.color.x as f32, + light.color.y as f32, + light.color.z as f32, + ), + intensity: light.intensity as f32 * core::f32::consts::PI * 4.0, + ..Default::default() + }); + } + ufbx::LightType::Spot => { + e.insert(SpotLight { + color: Color::srgb( + light.color.x as f32, + light.color.y as f32, + light.color.z as f32, + ), + intensity: light.intensity as f32 * core::f32::consts::PI * 4.0, + inner_angle: (light.inner_angle as f32).to_radians(), + outer_angle: (light.outer_angle as f32).to_radians(), + ..Default::default() + }); + } + _ => {} + } + } + } + if settings.load_cameras { + if let Some(cam_ref) = &u_node.camera { + let cam = cam_ref.as_ref(); + let projection = match cam.projection_mode { + ufbx::ProjectionMode::Perspective => { + let mut perspective = PerspectiveProjection::default(); + perspective.fov = cam.field_of_view_deg.y.to_radians() as f32; + perspective.near = cam.near_plane as f32; + perspective.far = cam.far_plane as f32; + if cam.aspect_ratio > 0.0 { + perspective.aspect_ratio = cam.aspect_ratio as f32; + } + Projection::Perspective(perspective) + } + ufbx::ProjectionMode::Orthographic => { + // Preserve orthographic cameras: map ufbx `orthographic_size` + // (full width/height in world units) to Bevy's OrthographicProjection. + let ortho = OrthographicProjection { + near: cam.near_plane as f32, + far: cam.far_plane as f32, + scaling_mode: ScalingMode::Fixed { + width: cam.orthographic_size.x as f32, + height: cam.orthographic_size.y as f32, + }, + ..OrthographicProjection::default_3d() + }; + // Keep default `viewport_origin` and `scale`. + Projection::Orthographic(ortho) + } + }; + e.insert(( + Camera3d::default(), + projection, + Camera { + is_active: false, + ..Default::default() + }, + )); + } + } + + let id = e.id(); + node_entities[i] = Some(id); + element_to_entity.insert(u_node.element.element_id, id); + } + + // Parenting pass + for (i, u_node) in scene.nodes.as_ref().iter().enumerate() { + if let Some(parent_ref) = &u_node.parent { + let parent = parent_ref.as_ref(); + // Find parent index via element_id + if let Some((p_idx, _)) = scene + .nodes + .as_ref() + .iter() + .enumerate() + .find(|(_, n)| n.element.element_id == parent.element.element_id) + { + if let (Some(parent_entity), Some(child_entity)) = + (node_entities[p_idx], node_entities[i]) + { + world.entity_mut(parent_entity).add_child(child_entity); + } + } + } + } + + // Mesh pass: attach submeshes as children under their node + // Keep track of spawned mesh child entities per node for skinning hookup + let mut node_mesh_entities: HashMap> = HashMap::new(); + + for (i, u_node) in scene.nodes.as_ref().iter().enumerate() { + let node_entity = match node_entities[i] { + Some(e) => e, + None => continue, + }; + if let Some(entries) = node_meshes.get(&u_node.element.element_id) { + for (sub_mesh_handle, mat_names) in entries { + // Resolve a material handle + let material_to_use = mat_names + .iter() + .find_map(|name| named_materials.get(name.as_str()).cloned()) + .or_else(|| materials.get(0).cloned()) + .unwrap_or_else(|| default_material.clone()); + + // Set mesh local transform from FBX geometry_to_node so geometry matches DCC + let gtn = &u_node.geometry_to_node; + let child_local = Transform::from_matrix(Mat4::from_cols_array(&[ + gtn.m00 as f32, + gtn.m10 as f32, + gtn.m20 as f32, + 0.0, + gtn.m01 as f32, + gtn.m11 as f32, + gtn.m21 as f32, + 0.0, + gtn.m02 as f32, + gtn.m12 as f32, + gtn.m22 as f32, + 0.0, + gtn.m03 as f32, + gtn.m13 as f32, + gtn.m23 as f32, + 1.0, + ])); + + let child = world.spawn(( + Mesh3d(sub_mesh_handle.clone()), + MeshMaterial3d(material_to_use), + child_local, + GlobalTransform::default(), + Visibility::default(), + )); + let child_id = child.id(); + world.entity_mut(node_entity).add_child(child_id); + node_mesh_entities + .entry(u_node.element.element_id) + .or_default() + .push(child_id); + } + } + } + + // Hook up SkinnedMesh components for nodes that have skin deformers + // using the previously created `skin_map` and the per-node mesh entities. + for (mesh_node_id, (inverse_bindposes_handle, joint_node_ids, _skin_name, _skin_index)) in + skin_map.iter() + { + // Resolve joints to entities + let mut joint_entities: Vec = Vec::new(); + for joint_id in joint_node_ids.iter() { + if let Some(&ent) = element_to_entity.get(joint_id) { + joint_entities.push(ent); + } + } + + if let Some(mesh_entities) = node_mesh_entities.get(mesh_node_id) { + for &mesh_ent in mesh_entities { + world + .entity_mut(mesh_ent) + .insert(bevy_mesh::skinning::SkinnedMesh { + inverse_bindposes: inverse_bindposes_handle.clone(), + joints: joint_entities.clone(), + }); + } + } + } + + // Activate first camera if any were created + if settings.load_cameras { + let mut activated = false; + for ent in node_entities.iter().filter_map(|e| *e) { + if let Some(mut cam) = world.entity_mut(ent).get_mut::() { + if !activated { + cam.is_active = true; + activated = true; + } + } + } + } + + // Spawn lights from the FBX scene (only if enabled in settings) + let mut lights_spawned = 0; + if settings.load_lights { + for light in scene.lights.as_ref().iter() { + // Find the node that contains this light + if let Some(light_node) = scene.nodes.as_ref().iter().find(|node| { + node.light.is_some() + && node.light.as_ref().unwrap().element.element_id + == light.element.element_id + }) { + let transform = Transform::from_matrix(Mat4::from_cols_array(&[ + light_node.node_to_world.m00 as f32, + light_node.node_to_world.m10 as f32, + light_node.node_to_world.m20 as f32, + 0.0, + light_node.node_to_world.m01 as f32, + light_node.node_to_world.m11 as f32, + light_node.node_to_world.m21 as f32, + 0.0, + light_node.node_to_world.m02 as f32, + light_node.node_to_world.m12 as f32, + light_node.node_to_world.m22 as f32, + 0.0, + light_node.node_to_world.m03 as f32, + light_node.node_to_world.m13 as f32, + light_node.node_to_world.m23 as f32, + 1.0, + ])); + + match light.type_ { + ufbx::LightType::Directional => { + tracing::info!( + "FBX Loader: Spawning directional light '{}' with intensity {}", + light.element.name, + light.intensity + ); + + world.spawn(( + DirectionalLight { + color: Color::srgb( + light.color.x as f32, + light.color.y as f32, + light.color.z as f32, + ), + illuminance: light.intensity as f32, + shadows_enabled: light.cast_shadows, + ..default() + }, + transform, + GlobalTransform::default(), + Visibility::default(), + )); + lights_spawned += 1; + } + ufbx::LightType::Point => { + tracing::info!( + "FBX Loader: Spawning point light '{}' with intensity {}", + light.element.name, + light.intensity + ); + + world.spawn(( + PointLight { + color: Color::srgb( + light.color.x as f32, + light.color.y as f32, + light.color.z as f32, + ), + intensity: light.intensity as f32, + shadows_enabled: light.cast_shadows, + ..default() + }, + transform, + GlobalTransform::default(), + Visibility::default(), + )); + lights_spawned += 1; + } + ufbx::LightType::Spot => { + tracing::info!( + "FBX Loader: Spawning spot light '{}' with intensity {} (angles: {:.1}° - {:.1}°)", + light.element.name, + light.intensity, + light.inner_angle.to_degrees(), + light.outer_angle.to_degrees() + ); + + world.spawn(( + SpotLight { + color: Color::srgb( + light.color.x as f32, + light.color.y as f32, + light.color.z as f32, + ), + intensity: light.intensity as f32, + shadows_enabled: light.cast_shadows, + inner_angle: light.inner_angle as f32, + outer_angle: light.outer_angle as f32, + ..default() + }, + transform, + GlobalTransform::default(), + Visibility::default(), + )); + lights_spawned += 1; + } + _ => { + tracing::info!( + "FBX Loader: Skipping unsupported light type {:?} for light '{}'", + light.type_, + light.element.name + ); + } + } + } + } + + tracing::info!("FBX Loader: Spawned {} lights in scene", lights_spawned); + } // End of lights spawning check + + // Spawn cameras from the FBX scene (only if enabled in settings) + let mut cameras_spawned = 0; + if settings.load_cameras { + for camera in scene.cameras.as_ref().iter() { + // Find the node that contains this camera + if let Some(camera_node) = scene.nodes.as_ref().iter().find(|node| { + node.camera.is_some() + && node.camera.as_ref().unwrap().element.element_id + == camera.element.element_id + }) { + let transform = Transform::from_matrix(Mat4::from_cols_array(&[ + camera_node.node_to_world.m00 as f32, + camera_node.node_to_world.m10 as f32, + camera_node.node_to_world.m20 as f32, + 0.0, + camera_node.node_to_world.m01 as f32, + camera_node.node_to_world.m11 as f32, + camera_node.node_to_world.m21 as f32, + 0.0, + camera_node.node_to_world.m02 as f32, + camera_node.node_to_world.m12 as f32, + camera_node.node_to_world.m22 as f32, + 0.0, + camera_node.node_to_world.m03 as f32, + camera_node.node_to_world.m13 as f32, + camera_node.node_to_world.m23 as f32, + 1.0, + ])); + + // Create projection based on camera type + let projection = match camera.projection_mode { + ufbx::ProjectionMode::Perspective => { + let mut perspective = PerspectiveProjection::default(); + // Use Y field of view (in degrees) and convert to radians + perspective.fov = camera.field_of_view_deg.y.to_radians() as f32; + perspective.near = camera.near_plane as f32; + perspective.far = camera.far_plane as f32; + if camera.aspect_ratio > 0.0 { + perspective.aspect_ratio = camera.aspect_ratio as f32; + } + Projection::Perspective(perspective) + } + ufbx::ProjectionMode::Orthographic => { + // Preserve orthographic cameras: use fixed width/height from ufbx + let ortho = OrthographicProjection { + near: camera.near_plane as f32, + far: camera.far_plane as f32, + scaling_mode: ScalingMode::Fixed { + width: camera.orthographic_size.x as f32, + height: camera.orthographic_size.y as f32, + }, + ..OrthographicProjection::default_3d() + }; + Projection::Orthographic(ortho) + } + }; + + tracing::info!( + "FBX Loader: Spawning camera '{}' ({})", + camera.element.name, + match camera.projection_mode { + ufbx::ProjectionMode::Perspective => "perspective", + ufbx::ProjectionMode::Orthographic => "orthographic", + } + ); + + world.spawn(( + Camera3d::default(), + projection, + transform, + GlobalTransform::default(), + Camera { + is_active: cameras_spawned == 0, // First camera is active + ..Default::default() + }, + Visibility::default(), + )); + cameras_spawned += 1; + } + } + + tracing::info!("FBX Loader: Spawned {} cameras in scene", cameras_spawned); + } // End of cameras spawning check + + let scene_handle = + load_context.add_labeled_asset(FbxAssetLabel::Scene(0).to_string(), Scene::new(world)); + scenes.push(scene_handle.clone()); + + Ok(Fbx { + scenes, + named_scenes, + meshes, + named_meshes, + materials, + named_materials, + nodes, + named_nodes, + skins, + named_skins, + default_scene: Some(scene_handle), + animations, + named_animations, + }) + } + + fn extensions(&self) -> &[&str] { + &["fbx"] + } +} + +/// Plugin adding the FBX loader to an [`App`]. +#[derive(Default)] +pub struct FbxPlugin; + +impl Plugin for FbxPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .init_asset::() + .init_asset::() + .register_asset_loader(FbxLoader::default()); + } +} diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index e77a2f78ae998..ba66b1163f139 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -483,6 +483,7 @@ bevy_gilrs = { path = "../bevy_gilrs", optional = true, version = "0.18.0-dev" } bevy_gizmos = { path = "../bevy_gizmos", optional = true, version = "0.18.0-dev", default-features = false } bevy_gltf = { path = "../bevy_gltf", optional = true, version = "0.18.0-dev" } bevy_feathers = { path = "../bevy_feathers", optional = true, version = "0.18.0-dev" } +bevy_fbx = { path = "../bevy_fbx", optional = true, version = "0.18.0-dev" } bevy_image = { path = "../bevy_image", optional = true, version = "0.18.0-dev" } bevy_shader = { path = "../bevy_shader", optional = true, version = "0.18.0-dev" } bevy_mesh = { path = "../bevy_mesh", optional = true, version = "0.18.0-dev" } diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index 4467da12f4f11..6e92165f53c33 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -70,6 +70,8 @@ plugin_group! { // compressed texture formats. #[cfg(feature = "bevy_gltf")] bevy_gltf:::GltfPlugin, + #[cfg(feature = "bevy_fbx")] + bevy_fbx:::FbxPlugin, #[cfg(feature = "bevy_audio")] bevy_audio:::AudioPlugin, #[cfg(feature = "bevy_gilrs")] diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index d4a9b06965caa..4ca3816214821 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -37,6 +37,8 @@ pub use bevy_core_pipeline as core_pipeline; pub use bevy_dev_tools as dev_tools; pub use bevy_diagnostic as diagnostic; pub use bevy_ecs as ecs; +#[cfg(feature = "bevy_fbx")] +pub use bevy_fbx as fbx; #[cfg(feature = "bevy_feathers")] pub use bevy_feathers as feathers; #[cfg(feature = "bevy_gilrs")] diff --git a/crates/bevy_internal/src/prelude.rs b/crates/bevy_internal/src/prelude.rs index ea4a306d0a6d6..e5ded98a79ea0 100644 --- a/crates/bevy_internal/src/prelude.rs +++ b/crates/bevy_internal/src/prelude.rs @@ -99,6 +99,10 @@ pub use crate::state::prelude::*; #[cfg(feature = "bevy_gltf")] pub use crate::gltf::prelude::*; +#[doc(hidden)] +#[cfg(feature = "bevy_fbx")] +pub use crate::fbx::prelude::*; + #[doc(hidden)] #[cfg(feature = "bevy_picking")] pub use crate::picking::prelude::*; diff --git a/examples/3d/load_fbx.rs b/examples/3d/load_fbx.rs new file mode 100644 index 0000000000000..5db08208408ca --- /dev/null +++ b/examples/3d/load_fbx.rs @@ -0,0 +1,76 @@ +//! This example demonstrates how to load FBX files using the `bevy_fbx` crate. +//! +//! The example loads a simple cube model from an FBX file and displays it +//! with proper lighting and shadows. The cube should rotate in the scene. + +use bevy::{ + fbx::FbxAssetLabel, + light::{CascadeShadowConfigBuilder, DirectionalLightShadowMap}, + prelude::*, +}; +use std::f32::consts::*; + +fn main() { + App::new() + .insert_resource(DirectionalLightShadowMap { size: 4096 }) + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, animate_light_direction) + .run(); +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(( + Camera3d::default(), + // Transform::from_xyz(0.7, 2.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y), + Transform::from_xyz(5.0, 5.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y), + EnvironmentMapLight { + diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"), + specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"), + intensity: 550.0, + ..default() + }, + )); + + commands.spawn(( + DirectionalLight { + shadows_enabled: true, + ..default() + }, + // This is a relatively small scene, so use tighter shadow + // cascade bounds than the default for better quality. + // We also adjusted the shadow map to be larger since we're + // only using a single cascade. + CascadeShadowConfigBuilder { + num_cascades: 1, + maximum_distance: 1.6, + ..default() + } + .build(), + )); + + // Load the FBX file and spawn its first scene + commands.spawn(SceneRoot( + asset_server.load(FbxAssetLabel::Scene(0).from_asset("models/cube/cube.fbx")), + )); + // commands.spawn(SceneRoot( + // asset_server.load(FbxAssetLabel::Scene(0).from_asset("models/nurbs_saddle.fbx")), + // )); + // commands.spawn(SceneRoot( + // asset_server.load(FbxAssetLabel::Scene(0).from_asset("models/cube_anim.fbx")), + // )); +} + +fn animate_light_direction( + time: Res