diff --git a/Axiom/Assets/AssimpImporter.cpp b/Axiom/Assets/AssimpImporter.cpp new file mode 100644 index 0000000..25db0dc --- /dev/null +++ b/Axiom/Assets/AssimpImporter.cpp @@ -0,0 +1,161 @@ +#include "AssimpImporter.h" +#include "MeshAsset.h" +#include "Core/Log.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace Axiom::Assets { +namespace { + +MeshData ConvertMesh(const aiMesh *AiMesh) { + MeshData Data; + Data.Vertices.reserve(AiMesh->mNumVertices); + + glm::vec3 BoundsMin{ 1e30f}; + glm::vec3 BoundsMax{-1e30f}; + + for (unsigned i = 0; i < AiMesh->mNumVertices; ++i) { + MeshVertex V{}; + V.Position = {AiMesh->mVertices[i].x, AiMesh->mVertices[i].y, + AiMesh->mVertices[i].z, 1.0f}; + if (AiMesh->HasNormals()) { + V.Normal = {AiMesh->mNormals[i].x, AiMesh->mNormals[i].y, + AiMesh->mNormals[i].z, 0.0f}; + } else { + V.Normal = {0.0f, 1.0f, 0.0f, 0.0f}; + } + if (AiMesh->HasTextureCoords(0)) { + V.TexCoord = {AiMesh->mTextureCoords[0][i].x, + AiMesh->mTextureCoords[0][i].y}; + } + BoundsMin = glm::min(BoundsMin, glm::vec3{V.Position}); + BoundsMax = glm::max(BoundsMax, glm::vec3{V.Position}); + Data.Vertices.push_back(V); + } + + Data.Indices.reserve(AiMesh->mNumFaces * 3); + for (unsigned i = 0; i < AiMesh->mNumFaces; ++i) { + const aiFace &Face = AiMesh->mFaces[i]; + for (unsigned j = 0; j < Face.mNumIndices; ++j) + Data.Indices.push_back(Face.mIndices[j]); + } + + Data.BoundsMin = BoundsMin; + Data.BoundsMax = BoundsMax; + return Data; +} + +MaterialInstanceRef ConvertMaterial(const aiScene *Scene, unsigned MatIndex, + const std::filesystem::path &AssetDir) { + auto Mat = std::make_shared(); + if (MatIndex >= Scene->mNumMaterials) + return Mat; + + const aiMaterial *AiMat = Scene->mMaterials[MatIndex]; + + aiColor4D Diffuse(1.0f, 1.0f, 1.0f, 1.0f); + if (AiMat->Get(AI_MATKEY_COLOR_DIFFUSE, Diffuse) == AI_SUCCESS) + Mat->BaseColorFactor = {Diffuse.r, Diffuse.g, Diffuse.b, Diffuse.a}; + + float Metallic = 0.0f; + float Roughness = 0.5f; + AiMat->Get(AI_MATKEY_METALLIC_FACTOR, Metallic); + AiMat->Get(AI_MATKEY_ROUGHNESS_FACTOR, Roughness); + Mat->Metallic = Metallic; + Mat->Roughness = Roughness; + + aiString TexPath; + if (AiMat->GetTexture(aiTextureType_DIFFUSE, 0, &TexPath) == AI_SUCCESS) { + const char *RawPath = TexPath.C_Str(); + if (RawPath[0] == '*') { + // Embedded compressed texture (FBX) + int Idx = std::atoi(RawPath + 1); + if (Idx >= 0 && Idx < static_cast(Scene->mNumTextures)) { + const aiTexture *Tex = Scene->mTextures[Idx]; + if (Tex->mHeight == 0) { + Mat->BaseColorTexture = LoadTextureFromMemory( + reinterpret_cast(Tex->pcData), + static_cast(Tex->mWidth), RawPath); + } + } + } else { + const auto FullPath = AssetDir / RawPath; + auto Loaded = LoadTextureFromFile(FullPath); + if (Loaded) { + Mat->BaseColorTexture = Loaded; + Mat->TextureAssetPath = RawPath; // relative, as stored in the source file + } + } + } + + return Mat; +} + +glm::mat4 ToGlm(const aiMatrix4x4 &M) { + // assimp is row-major; glm is column-major + return glm::transpose(glm::mat4{ + M.a1, M.a2, M.a3, M.a4, + M.b1, M.b2, M.b3, M.b4, + M.c1, M.c2, M.c3, M.c4, + M.d1, M.d2, M.d3, M.d4, + }); +} + +void VisitNode(const aiScene *Scene, const aiNode *Node, const glm::mat4 &Parent, + const std::filesystem::path &AssetDir, MeshSceneData &Out) { + const glm::mat4 World = Parent * ToGlm(Node->mTransformation); + for (unsigned i = 0; i < Node->mNumMeshes; ++i) { + const aiMesh *AiMesh = Scene->mMeshes[Node->mMeshes[i]]; + MeshSceneData::MeshInstanceData Inst; + Inst.Name = AiMesh->mName.length > 0 ? AiMesh->mName.C_Str() + : Node->mName.C_Str(); + Inst.Mesh = ConvertMesh(AiMesh); + Inst.Material = ConvertMaterial(Scene, AiMesh->mMaterialIndex, AssetDir); + Inst.Transform = World; + Out.Instances.push_back(std::move(Inst)); + } + for (unsigned i = 0; i < Node->mNumChildren; ++i) + VisitNode(Scene, Node->mChildren[i], World, AssetDir, Out); +} + +} // namespace + +std::optional +AssimpImporter::Import(const std::filesystem::path &Path) { + Assimp::Importer Importer; + const aiScene *Scene = Importer.ReadFile( + Path.string(), + aiProcess_Triangulate | aiProcess_GenSmoothNormals | + aiProcess_FlipUVs | aiProcess_JoinIdenticalVertices | + aiProcess_SortByPType); + + if (!Scene || !Scene->mRootNode || + (Scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE)) { + A_CORE_WARN("AssimpImporter: failed to load '{}': {}", Path.string(), + Importer.GetErrorString()); + return std::nullopt; + } + + MeshSceneData Result; + VisitNode(Scene, Scene->mRootNode, glm::mat4{1.0f}, Path.parent_path(), + Result); + + if (Result.Instances.empty()) { + A_CORE_WARN("AssimpImporter: no renderable meshes found in '{}'", + Path.string()); + return std::nullopt; + } + + return Result; +} + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/AssimpImporter.h b/Axiom/Assets/AssimpImporter.h new file mode 100644 index 0000000..3eb2b68 --- /dev/null +++ b/Axiom/Assets/AssimpImporter.h @@ -0,0 +1,12 @@ +#pragma once + +#include "IAssetImporter.h" + +namespace Axiom::Assets { + +class AssimpImporter final : public IAssetImporter { +public: + std::optional Import(const std::filesystem::path &Path) override; +}; + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/IAssetImporter.h b/Axiom/Assets/IAssetImporter.h new file mode 100644 index 0000000..18dc440 --- /dev/null +++ b/Axiom/Assets/IAssetImporter.h @@ -0,0 +1,16 @@ +#pragma once + +#include "Renderer/Mesh.h" + +#include +#include + +namespace Axiom::Assets { + +class IAssetImporter { +public: + virtual ~IAssetImporter() = default; + virtual std::optional Import(const std::filesystem::path &Path) = 0; +}; + +} // namespace Axiom::Assets diff --git a/Axiom/Assets/IAssetSource.cpp b/Axiom/Assets/IAssetSource.cpp index 9fb8085..6f71dae 100644 --- a/Axiom/Assets/IAssetSource.cpp +++ b/Axiom/Assets/IAssetSource.cpp @@ -6,7 +6,7 @@ namespace { AssetKind KindFromExtension(const std::filesystem::path &Path) { auto ext = Path.extension().string(); - if (ext == ".glb" || ext == ".gltf") + if (ext == ".glb" || ext == ".gltf" || ext == ".fbx" || ext == ".obj") return AssetKind::Mesh; return AssetKind::Texture; } @@ -15,8 +15,9 @@ AssetId IdFromRelPath(const std::filesystem::path &RelPath) { return AssetId{std::hash{}(RelPath.string())}; } -constexpr std::string_view kContentExtensions[] = {".glb", ".gltf", ".png", - ".jpg", ".jpeg"}; +constexpr std::string_view kContentExtensions[] = {".glb", ".gltf", ".fbx", + ".obj", ".png", ".jpg", + ".jpeg"}; bool IsContentFile(const std::filesystem::path &Path) { auto ext = Path.extension().string(); diff --git a/Axiom/Assets/MeshAsset.cpp b/Axiom/Assets/MeshAsset.cpp index 88c69c0..860fdd0 100644 --- a/Axiom/Assets/MeshAsset.cpp +++ b/Axiom/Assets/MeshAsset.cpp @@ -1,4 +1,5 @@ #include "Assets/MeshAsset.h" +#include "Assets/AssimpImporter.h" #include "Core/Log.h" @@ -375,6 +376,12 @@ void AppendNodeMeshes(const fastgltf::Asset &Asset, size_t NodeIndex, } // namespace std::optional LoadBasicMeshAsset(const std::filesystem::path &Path) { + const std::string Ext = ToLowerCopy(Path.extension().string()); + if (Ext == ".fbx" || Ext == ".obj") { + AssimpImporter Importer; + return Importer.Import(Path); + } + fastgltf::GltfDataBuffer Buffer; if (!Buffer.loadFromFile(Path)) { A_CORE_ERROR("Failed to open mesh asset: {0}", Path.string()); @@ -419,9 +426,25 @@ std::optional LoadBasicMeshAsset(const std::filesystem::path &Pat std::vector MaterialCache(ParsedAsset.materials.size()); MaterialInstanceRef FallbackMaterial = std::make_shared(); + auto MakeMaterialWithFactors = + [](const fastgltf::Material &Mat, + TextureSourceDataRef Texture) -> MaterialInstanceRef { + auto Ref = std::make_shared(); + Ref->BaseColorTexture = std::move(Texture); + const auto &Pbr = Mat.pbrData; + Ref->BaseColorFactor = glm::vec4( + static_cast(Pbr.baseColorFactor[0]), + static_cast(Pbr.baseColorFactor[1]), + static_cast(Pbr.baseColorFactor[2]), + static_cast(Pbr.baseColorFactor[3])); + Ref->Metallic = static_cast(Pbr.metallicFactor); + Ref->Roughness = static_cast(Pbr.roughnessFactor); + return Ref; + }; + auto ResolveMaterial = [&](const fastgltf::Primitive &Primitive, bool HasTexCoord0) -> MaterialInstanceRef { - if (!HasTexCoord0 || !Primitive.materialIndex.has_value() || + if (!Primitive.materialIndex.has_value() || *Primitive.materialIndex >= ParsedAsset.materials.size()) { return FallbackMaterial; } @@ -432,23 +455,28 @@ std::optional LoadBasicMeshAsset(const std::filesystem::path &Pat } const auto &Material = ParsedAsset.materials[MaterialIndex]; - if (!Material.pbrData.baseColorTexture.has_value() || + + // No texture (or no UV) — still carry PBR color factors. + if (!HasTexCoord0 || !Material.pbrData.baseColorTexture.has_value() || Material.pbrData.baseColorTexture->texCoordIndex != 0) { - MaterialCache[MaterialIndex] = FallbackMaterial; - return FallbackMaterial; + auto Ref = MakeMaterialWithFactors(Material, nullptr); + MaterialCache[MaterialIndex] = Ref; + return Ref; } const size_t TextureIndex = Material.pbrData.baseColorTexture->textureIndex; if (TextureIndex >= ParsedAsset.textures.size()) { - MaterialCache[MaterialIndex] = FallbackMaterial; - return FallbackMaterial; + auto Ref = MakeMaterialWithFactors(Material, nullptr); + MaterialCache[MaterialIndex] = Ref; + return Ref; } const auto &Texture = ParsedAsset.textures[TextureIndex]; if (!Texture.imageIndex.has_value() || *Texture.imageIndex >= ParsedAsset.images.size()) { - MaterialCache[MaterialIndex] = FallbackMaterial; - return FallbackMaterial; + auto Ref = MakeMaterialWithFactors(Material, nullptr); + MaterialCache[MaterialIndex] = Ref; + return Ref; } const size_t ImageIndex = *Texture.imageIndex; @@ -458,12 +486,12 @@ std::optional LoadBasicMeshAsset(const std::filesystem::path &Pat } if (!ImageCache[ImageIndex] || !ImageCache[ImageIndex]->IsValid()) { - MaterialCache[MaterialIndex] = FallbackMaterial; - return FallbackMaterial; + auto Ref = MakeMaterialWithFactors(Material, nullptr); + MaterialCache[MaterialIndex] = Ref; + return Ref; } - auto MaterialRef = std::make_shared(); - MaterialRef->BaseColorTexture = ImageCache[ImageIndex]; + auto MaterialRef = MakeMaterialWithFactors(Material, ImageCache[ImageIndex]); MaterialCache[MaterialIndex] = MaterialRef; return MaterialRef; }; @@ -483,4 +511,15 @@ std::optional LoadBasicMeshAsset(const std::filesystem::path &Pat return SceneData; } + +TextureSourceDataRef LoadTextureFromFile(const std::filesystem::path &Path) { + return DecodeTextureFromFile(Path); +} + +TextureSourceDataRef LoadTextureFromMemory(const unsigned char *Bytes, + int Length, + const std::string &DebugName) { + return DecodeTextureFromMemory( + reinterpret_cast(Bytes), Length, DebugName); +} } // namespace Axiom::Assets diff --git a/Axiom/Assets/MeshAsset.h b/Axiom/Assets/MeshAsset.h index f9f06b0..d4c893d 100644 --- a/Axiom/Assets/MeshAsset.h +++ b/Axiom/Assets/MeshAsset.h @@ -1,5 +1,6 @@ #pragma once +#include "Renderer/Material.h" #include "Renderer/Mesh.h" #include @@ -7,4 +8,7 @@ namespace Axiom::Assets { std::optional LoadBasicMeshAsset(const std::filesystem::path &Path); -} +TextureSourceDataRef LoadTextureFromFile(const std::filesystem::path &Path); +TextureSourceDataRef LoadTextureFromMemory(const unsigned char *Bytes, int Length, + const std::string &DebugName); +} // namespace Axiom::Assets diff --git a/Axiom/Assets/SceneFile.cpp b/Axiom/Assets/SceneFile.cpp index ca0f3af..bc2582e 100644 --- a/Axiom/Assets/SceneFile.cpp +++ b/Axiom/Assets/SceneFile.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #ifndef AXIOM_CONTENT_DIR #define AXIOM_CONTENT_DIR "Content" @@ -77,6 +78,14 @@ void SerializeSceneItemsFlat( bool SaveSceneToFile(const std::filesystem::path &Path, const EditorSceneState &Scene) { + // Build per-object asset path lookup from MeshInstances. + std::unordered_map AssetPathByObjectId; + for (const auto &Inst : Scene.MeshInstances) { + if (!Inst.AssetRelativePath.empty()) { + AssetPathByObjectId[Inst.ObjectId] = Inst.AssetRelativePath; + } + } + std::ostringstream Out; Out << "{\n"; Out << " \"version\": 1,\n"; @@ -108,6 +117,20 @@ bool SaveSceneToFile(const std::filesystem::path &Path, if (Details.ScriptClass.has_value()) { Out << ",\"scriptClass\":" << EscStr(*Details.ScriptClass); } + if (Details.Kind == EditorSceneItemKind::Mesh) { + const auto AssetIt = AssetPathByObjectId.find(Id); + if (AssetIt != AssetPathByObjectId.end()) { + Out << ",\"assetRelativePath\":" << EscStr(AssetIt->second); + } + if (Details.Material.has_value() && Details.Material->TextureAssetPath.has_value()) { + Out << ",\"textureAssetPath\":" << EscStr(*Details.Material->TextureAssetPath); + } + } + if (Details.Light.has_value()) { + Out << ",\"lightColor\":" << SerializeVec3(Details.Light->Color) + << ",\"lightIntensity\":" << Details.Light->Intensity + << ",\"lightDirection\":" << SerializeVec3(Details.Light->Direction); + } Out << "}"; } Out << "\n ],\n"; @@ -326,6 +349,9 @@ LoadSceneFromFile(const std::filesystem::path &Path) { bool TransformReadOnly{true}; std::optional Transform; std::optional ScriptClass; + std::string AssetRelativePath; + std::string TextureAssetPath; + std::optional Light; }; std::string MeshAsset; @@ -393,6 +419,29 @@ LoadSceneFromFile(const std::filesystem::path &Path) { if (P.Peek() == 'n') { P.ParseNull(); } else { auto V = P.ParseString(); if (V) Data.ScriptClass = *V; } return true; } + if (K == "assetRelativePath") { + auto V = P.ParseString(); if (V) Data.AssetRelativePath = *V; return true; + } + if (K == "textureAssetPath") { + P.SkipWs(); + if (P.Peek() == 'n') { P.ParseNull(); } else { auto V = P.ParseString(); if (V) Data.TextureAssetPath = *V; } + return true; + } + if (K == "lightColor") { + auto V = P.ParseVec3(); + if (V) { if (!Data.Light) Data.Light = EditorLightProperties{}; Data.Light->Color = *V; } + return true; + } + if (K == "lightIntensity") { + auto V = P.ParseNumber(); + if (V) { if (!Data.Light) Data.Light = EditorLightProperties{}; Data.Light->Intensity = static_cast(*V); } + return true; + } + if (K == "lightDirection") { + auto V = P.ParseVec3(); + if (V) { if (!Data.Light) Data.Light = EditorLightProperties{}; Data.Light->Direction = *V; } + return true; + } return false; }); if (!ObjId.empty()) Objects[ObjId] = std::move(Data); @@ -463,10 +512,68 @@ LoadSceneFromFile(const std::filesystem::path &Path) { Details.TransformReadOnly = Data.TransformReadOnly; Details.Transform = Data.Transform; Details.ScriptClass = Data.ScriptClass; + Details.Light = Data.Light; State.ObjectDetailsById[Id] = std::move(Details); } - // --- Stage 4: reload mesh instances from disk --- + // --- Stage 4a: load per-object explicit asset assignments --- + // Objects saved with assetRelativePath (from SetMeshAssetCommand) are loaded + // individually. Track which objectIds are handled so Stage 4b skips them. + std::unordered_set LoadedByAssetPath; + for (const auto &[ObjId, Data] : Objects) { + if (Data.Kind != EditorSceneItemKind::Mesh || Data.AssetRelativePath.empty()) continue; + const auto FullPath = + std::filesystem::path(AXIOM_CONTENT_DIR) / Data.AssetRelativePath; + const auto SceneData = LoadBasicMeshAsset(FullPath); + if (!SceneData.has_value() || SceneData->Instances.empty()) { + A_CORE_WARN("SceneFile: failed to load asset '{}' for object '{}'", + Data.AssetRelativePath, ObjId); + continue; + } + glm::mat4 Transform = SceneData->Instances[0].Transform; + const auto DetailsIt = State.ObjectDetailsById.find(ObjId); + if (DetailsIt != State.ObjectDetailsById.end() && + DetailsIt->second.Transform.has_value()) { + const auto &T = *DetailsIt->second.Transform; + glm::mat4 M(1.0f); + M = glm::translate(M, T.Location); + M = glm::rotate(M, glm::radians(T.RotationDegrees.y), {0,1,0}); + M = glm::rotate(M, glm::radians(T.RotationDegrees.x), {1,0,0}); + M = glm::rotate(M, glm::radians(T.RotationDegrees.z), {0,0,1}); + M = glm::scale(M, T.Scale); + Transform = M; + } + auto Material = SceneData->Instances[0].Material; + if (!Data.TextureAssetPath.empty()) { + const auto TexPath = + std::filesystem::path(AXIOM_CONTENT_DIR) / Data.TextureAssetPath; + auto Tex = LoadTextureFromFile(TexPath); + if (Tex) { + if (!Material) Material = std::make_shared(); + Material->BaseColorTexture = std::move(Tex); + Material->TextureAssetPath = Data.TextureAssetPath; + } + } + State.MeshInstances.push_back({ + .ObjectId = ObjId, + .Mesh = SceneData->Instances[0].Mesh, + .Material = std::move(Material), + .RenderPath = MeshRenderPath::Graphics, + .Transform = Transform, + .AssetRelativePath = Data.AssetRelativePath, + }); + // Propagate textureAssetPath into ObjectDetails so inspector shows it. + { + const auto DetailsIt = State.ObjectDetailsById.find(ObjId); + if (DetailsIt != State.ObjectDetailsById.end() && !Data.TextureAssetPath.empty()) { + if (!DetailsIt->second.Material) DetailsIt->second.Material = EditorMaterialProperties{}; + DetailsIt->second.Material->TextureAssetPath = Data.TextureAssetPath; + } + } + LoadedByAssetPath.insert(ObjId); + } + + // --- Stage 4b: reload remaining mesh instances from the global mesh asset --- if (!MeshAsset.empty() && !MeshNameToObjectId.empty()) { const auto MeshPath = std::filesystem::path(AXIOM_CONTENT_DIR) / MeshAsset; @@ -476,6 +583,7 @@ LoadSceneFromFile(const std::filesystem::path &Path) { const auto It = MeshNameToObjectId.find(Instance.Name); if (It == MeshNameToObjectId.end()) continue; const auto &ObjId = It->second; + if (LoadedByAssetPath.count(ObjId)) continue; // already loaded in Stage 4a glm::mat4 Transform = Instance.Transform; const auto DetailsIt = State.ObjectDetailsById.find(ObjId); diff --git a/Axiom/CMakeLists.txt b/Axiom/CMakeLists.txt index 4e2cbc8..6d1ae37 100644 --- a/Axiom/CMakeLists.txt +++ b/Axiom/CMakeLists.txt @@ -1,6 +1,7 @@ set(ENGINE_SOURCES Scripting/ScriptHost.cpp Scripting/InternalCalls.cpp + Assets/AssimpImporter.cpp Assets/IAssetSource.cpp Assets/MeshAsset.cpp Assets/SceneFile.cpp @@ -286,9 +287,9 @@ target_include_directories(AxiomCore PUBLIC "${Vulkan_INCLUDE_DIRS}" ) -target_link_libraries(AxiomCore PUBLIC +target_link_libraries(AxiomCore PUBLIC glfw Vulkan::Vulkan fastgltf glm - spdlog + spdlog assimp ) target_compile_definitions(AxiomCore PUBLIC diff --git a/Axiom/Renderer/Material.h b/Axiom/Renderer/Material.h index 500f64c..75b32b7 100644 --- a/Axiom/Renderer/Material.h +++ b/Axiom/Renderer/Material.h @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include namespace Axiom { @@ -22,6 +24,12 @@ using TextureSourceDataRef = std::shared_ptr; struct MaterialInstance { TextureSourceDataRef BaseColorTexture; + glm::vec4 BaseColorFactor{1.0f}; + float Metallic{0.0f}; + float Roughness{0.5f}; + // Content-relative path of the standalone texture assigned via + // SetMaterialTextureCommand; empty if the texture came from the mesh asset. + std::string TextureAssetPath; }; using MaterialInstanceRef = std::shared_ptr; diff --git a/Axiom/Renderer/RenderCommand.cpp b/Axiom/Renderer/RenderCommand.cpp index a660bde..1404cd3 100644 --- a/Axiom/Renderer/RenderCommand.cpp +++ b/Axiom/Renderer/RenderCommand.cpp @@ -26,5 +26,11 @@ void RenderCommand::SetGizmoOverlay(const GizmoOverlayData &Gizmo) { } } +void RenderCommand::SetSun(const DirectionalLight &Light) { + if (s_ActiveScene) { + s_ActiveScene->Sun = Light; + } +} + void RenderCommand::EndScene() { s_ActiveScene = nullptr; } } // namespace Axiom diff --git a/Axiom/Renderer/RenderCommand.h b/Axiom/Renderer/RenderCommand.h index fcbc7ed..c463a29 100644 --- a/Axiom/Renderer/RenderCommand.h +++ b/Axiom/Renderer/RenderCommand.h @@ -11,6 +11,7 @@ class RenderCommand { static void SetCamera(const Camera &Camera); static void Submit(const RenderMeshSubmission &Submission); static void SetGizmoOverlay(const GizmoOverlayData &Gizmo); + static void SetSun(const DirectionalLight &Light); static void EndScene(); private: diff --git a/Axiom/Renderer/RenderScene.cpp b/Axiom/Renderer/RenderScene.cpp index c6212e7..0f65cf3 100644 --- a/Axiom/Renderer/RenderScene.cpp +++ b/Axiom/Renderer/RenderScene.cpp @@ -6,5 +6,6 @@ void RenderScene::Reset() { BackgroundColor = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f); Submissions.clear(); GizmoOverlay.reset(); + Sun.reset(); } } // namespace Axiom diff --git a/Axiom/Renderer/RenderScene.h b/Axiom/Renderer/RenderScene.h index d286be7..2b5ea03 100644 --- a/Axiom/Renderer/RenderScene.h +++ b/Axiom/Renderer/RenderScene.h @@ -21,6 +21,12 @@ struct GizmoOverlayData { GizmoMode Mode{GizmoMode::Translate}; }; +struct DirectionalLight { + glm::vec3 Color{1.0f}; + float Intensity{1.0f}; + glm::vec3 Direction{0.35f, 0.7f, 0.2f}; // world-space, need not be normalized +}; + class RenderScene { public: void Reset(); @@ -29,5 +35,6 @@ class RenderScene { glm::vec4 BackgroundColor{1.0f, 0.0f, 0.0f, 1.0f}; std::vector Submissions; std::optional GizmoOverlay; + std::optional Sun; }; } // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp index a323040..c43e5ef 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp +++ b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp @@ -605,7 +605,7 @@ void VulkanRendererBackend::InitMeshPipelines() { VkPushConstantRange PushConstant{}; PushConstant.offset = 0; PushConstant.size = sizeof(MeshGraphicsPushConstants); - PushConstant.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + PushConstant.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; VkPipelineLayoutCreateInfo GraphicsLayout = VkInit::PipelineLayoutCreateInfo(); GraphicsLayout.pSetLayouts = &m_MeshGraphicsFrameDescriptorLayout; diff --git a/Axiom/Renderer/Vulkan/VulkanRendererTypes.h b/Axiom/Renderer/Vulkan/VulkanRendererTypes.h index f0e2450..f9882db 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererTypes.h +++ b/Axiom/Renderer/Vulkan/VulkanRendererTypes.h @@ -27,6 +27,10 @@ struct CameraFrameUniform { glm::vec4 CameraPosition{0.0f}; glm::vec4 ViewportSize{0.0f}; glm::uvec4 RenderOptions{0u}; + // xyz = normalized light direction, w = intensity + glm::vec4 LightDirectionAndIntensity{0.35f, 0.7f, 0.2f, 1.0f}; + // xyz = light color, w = 1.0 if a dynamic light is active (0.0 = use defaults) + glm::vec4 LightColorAndEnabled{1.0f, 1.0f, 1.0f, 0.0f}; }; struct MeshProjectPushConstants { @@ -40,6 +44,9 @@ struct MeshRasterPushConstants { struct MeshGraphicsPushConstants { glm::mat4 Model{1.0f}; + glm::vec4 BaseColorFactor{1.0f}; + float Metallic{0.0f}; + float Roughness{0.5f}; }; struct HzbReducePushConstants { diff --git a/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp b/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp index 9eaf115..c562fb4 100644 --- a/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp +++ b/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp @@ -9,6 +9,7 @@ #include #include +#include namespace Axiom { namespace { @@ -30,6 +31,14 @@ VulkanSceneRenderer::BuildCameraData(const RenderContext &Context) { glm::vec4(static_cast(Context.DrawExtent.width), static_cast(Context.DrawExtent.height), 0.0f, 0.0f); CameraData.RenderOptions.x = static_cast(Context.ViewMode); + + if (Context.Scene.Sun.has_value()) { + const auto &Sun = *Context.Scene.Sun; + const glm::vec3 Dir = glm::normalize(Sun.Direction); + CameraData.LightDirectionAndIntensity = glm::vec4(Dir, Sun.Intensity); + CameraData.LightColorAndEnabled = glm::vec4(Sun.Color, 1.0f); + } + return CameraData; } @@ -308,8 +317,13 @@ void VulkanSceneRenderer::RecordGraphicsPass( &GraphicsDescriptorSet, 0, VK_NULL_HANDLE); MeshGraphicsPushConstants PushConstants{}; PushConstants.Model = VisibleSubmission.Submission->Transform; + if (VisibleSubmission.Submission->Material) { + PushConstants.BaseColorFactor = VisibleSubmission.Submission->Material->BaseColorFactor; + PushConstants.Metallic = VisibleSubmission.Submission->Material->Metallic; + PushConstants.Roughness = VisibleSubmission.Submission->Material->Roughness; + } vkCmdPushConstants(Context.CommandBuffer, Context.MeshGraphicsPipelineLayout, - VK_SHADER_STAGE_VERTEX_BIT, 0, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(MeshGraphicsPushConstants), &PushConstants); BindMeshBuffers(Context.CommandBuffer, VisibleSubmission.Mesh); vkCmdDrawIndexed(Context.CommandBuffer, VisibleSubmission.Mesh->IndexCount, 1, diff --git a/Axiom/Session/EditorCommand.h b/Axiom/Session/EditorCommand.h index 16fa59c..0b8c26c 100644 --- a/Axiom/Session/EditorCommand.h +++ b/Axiom/Session/EditorCommand.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -81,6 +82,31 @@ struct DetachScriptCommand { std::string ObjectId; }; +struct SetMeshAssetCommand { + std::string ObjectId; + std::string AssetPath; // relative path from the content directory, e.g. "Meshes/cube.glb" +}; + +struct SetLightPropertiesCommand { + std::string ObjectId; + glm::vec3 Color{1.0f}; + float Intensity{1.0f}; +}; + +struct SetMaterialPropertiesCommand { + std::string ObjectId; + glm::vec4 BaseColorFactor{1.0f}; + float Metallic{0.0f}; + float Roughness{0.5f}; +}; + +struct SetMaterialTextureCommand { + std::string ObjectId; + // Content-relative path of the texture to assign, e.g. "Textures/rock.png". + // Empty string clears the override and falls back to the mesh asset's embedded texture. + std::string TextureAssetPath; +}; + using EditorCommandPayload = std::variant; + DetachScriptCommand, SetMeshAssetCommand, + SetLightPropertiesCommand, + SetMaterialPropertiesCommand, + SetMaterialTextureCommand>; struct EditorCommand { EditorCommandPayload Payload; diff --git a/Axiom/Session/EditorEvent.h b/Axiom/Session/EditorEvent.h index 06a91eb..888fd39 100644 --- a/Axiom/Session/EditorEvent.h +++ b/Axiom/Session/EditorEvent.h @@ -3,6 +3,7 @@ #include "Session/SessionTypes.h" #include +#include #include #include @@ -99,6 +100,29 @@ struct ScriptErrorEvent { std::string Message; }; +struct MeshAssetChangedEvent { + std::string ObjectId; + std::string AssetPath; +}; + +struct LightPropertiesChangedEvent { + std::string ObjectId; + glm::vec3 Color{1.0f}; + float Intensity{1.0f}; +}; + +struct MaterialPropertiesChangedEvent { + std::string ObjectId; + glm::vec4 BaseColorFactor{1.0f}; + float Metallic{0.0f}; + float Roughness{0.5f}; +}; + +struct MaterialTextureChangedEvent { + std::string ObjectId; + std::string TextureAssetPath; // empty = cleared back to mesh asset's embedded texture +}; + using EditorEventPayload = std::variant; + ScriptErrorEvent, + MeshAssetChangedEvent, + LightPropertiesChangedEvent, + MaterialPropertiesChangedEvent, + MaterialTextureChangedEvent>; struct EditorEvent { EditorEventPayload Payload; diff --git a/Axiom/Session/EditorSceneRendererAdapter.cpp b/Axiom/Session/EditorSceneRendererAdapter.cpp index cccd94e..2f78c35 100644 --- a/Axiom/Session/EditorSceneRendererAdapter.cpp +++ b/Axiom/Session/EditorSceneRendererAdapter.cpp @@ -23,10 +23,10 @@ EditorSceneRendererAdapter::BuildRenderSubmissions(const EditorSession &Session) } auto &Cached = m_MeshesByObjectId[Instance.ObjectId]; - if (Cached.Mesh == nullptr) { + if (Cached.Mesh == nullptr || Cached.AssetRelativePath != Instance.AssetRelativePath) { Cached.Mesh = Renderer::Get().CreateMesh(Instance.Mesh); - Cached.Material = Instance.Material; Cached.RenderPath = Instance.RenderPath; + Cached.AssetRelativePath = Instance.AssetRelativePath; } if (Cached.Mesh == nullptr) { @@ -35,7 +35,7 @@ EditorSceneRendererAdapter::BuildRenderSubmissions(const EditorSession &Session) Submissions.push_back({ .Mesh = Cached.Mesh, - .Material = Cached.Material, + .Material = Instance.Material, // always live — picks up material edits .Name = Instance.ObjectId, .RenderPath = Cached.RenderPath, .Transform = Instance.Transform, diff --git a/Axiom/Session/EditorSceneRendererAdapter.h b/Axiom/Session/EditorSceneRendererAdapter.h index 7ff08d5..1035c60 100644 --- a/Axiom/Session/EditorSceneRendererAdapter.h +++ b/Axiom/Session/EditorSceneRendererAdapter.h @@ -14,8 +14,8 @@ class EditorSceneRendererAdapter { private: struct CachedMeshInstance { MeshRef Mesh; - MaterialInstanceRef Material; MeshRenderPath RenderPath{MeshRenderPath::Graphics}; + std::string AssetRelativePath; }; std::unordered_map m_MeshesByObjectId; diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index fcf7477..0399f6b 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -1,5 +1,7 @@ #include "Session/EditorSession.h" +#include "Assets/MeshAsset.h" + #include #include @@ -78,6 +80,18 @@ std::string CommandTypeName(const EditorCommandPayload &Payload) { if (std::holds_alternative(Payload)) { return "detach_script"; } + if (std::holds_alternative(Payload)) { + return "set_mesh_asset"; + } + if (std::holds_alternative(Payload)) { + return "set_light_properties"; + } + if (std::holds_alternative(Payload)) { + return "set_material_properties"; + } + if (std::holds_alternative(Payload)) { + return "set_material_texture"; + } return "set_transform"; } @@ -196,6 +210,19 @@ void EditorSession::SetPresenceState(SessionUserId User, void EditorSession::SetSceneState(EditorSceneState SceneState) { m_State.Scene = std::move(SceneState); + // Populate Material on object details from mesh instances so the inspector + // can display and edit material properties for mesh objects. + for (const auto &MeshInst : m_State.Scene.MeshInstances) { + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(MeshInst.ObjectId); + if (DetailsIt != m_State.Scene.ObjectDetailsById.end() && + MeshInst.Material && !DetailsIt->second.Material.has_value()) { + DetailsIt->second.Material = EditorMaterialProperties{ + .BaseColorFactor = MeshInst.Material->BaseColorFactor, + .Metallic = MeshInst.Material->Metallic, + .Roughness = MeshInst.Material->Roughness, + }; + } + } RebuildInstanceTree(m_State.Scene.Items, m_SceneRoot.get()); PruneInvalidSelections(); RecomputeAllWorldTransforms(); @@ -1004,6 +1031,66 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, } } + if (const auto *MeshAssetCmd = + std::get_if(&QueuedCommand.Command.Payload)) { + if (MeshAssetCmd->ObjectId.empty()) { + FailureReason = "SetMeshAsset requires a non-empty object id."; + return false; + } + if (MeshAssetCmd->AssetPath.empty()) { + FailureReason = "SetMeshAsset requires a non-empty asset path."; + return false; + } + const EditorObjectDetails *Details = FindObjectDetails(MeshAssetCmd->ObjectId); + if (Details == nullptr) { + FailureReason = "SetMeshAsset targeted an unknown object."; + return false; + } + if (Details->Kind != EditorSceneItemKind::Mesh) { + FailureReason = "SetMeshAsset target must be a Mesh object."; + return false; + } + } + + if (const auto *LightCmd = + std::get_if(&QueuedCommand.Command.Payload)) { + const EditorObjectDetails *Details = FindObjectDetails(LightCmd->ObjectId); + if (Details == nullptr) { + FailureReason = "SetLightProperties targeted an unknown object."; + return false; + } + if (Details->Kind != EditorSceneItemKind::Light) { + FailureReason = "SetLightProperties target must be a Light object."; + return false; + } + } + + if (const auto *MatCmd = + std::get_if(&QueuedCommand.Command.Payload)) { + const EditorObjectDetails *Details = FindObjectDetails(MatCmd->ObjectId); + if (Details == nullptr) { + FailureReason = "SetMaterialProperties targeted an unknown object."; + return false; + } + if (Details->Kind != EditorSceneItemKind::Mesh) { + FailureReason = "SetMaterialProperties target must be a Mesh object."; + return false; + } + } + + if (const auto *TexCmd = + std::get_if(&QueuedCommand.Command.Payload)) { + const EditorObjectDetails *Details = FindObjectDetails(TexCmd->ObjectId); + if (Details == nullptr) { + FailureReason = "SetMaterialTexture targeted an unknown object."; + return false; + } + if (Details->Kind != EditorSceneItemKind::Mesh) { + FailureReason = "SetMaterialTexture target must be a Mesh object."; + return false; + } + } + return true; } @@ -1304,6 +1391,170 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, .ScriptClass = std::nullopt}}); } +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetMeshAssetCommand &Command) { + EnsurePresence(QueuedCommand.Context.User); + + if (m_ContentDir.empty()) { + A_CORE_WARN("SetMeshAsset: content directory not configured"); + return; + } + + const std::filesystem::path FullPath = m_ContentDir / Command.AssetPath; + const auto SceneData = Assets::LoadBasicMeshAsset(FullPath); + if (!SceneData.has_value() || SceneData->Instances.empty()) { + A_CORE_WARN("SetMeshAsset: failed to load '{}' for object '{}'", + Command.AssetPath, Command.ObjectId); + return; + } + + const auto &First = SceneData->Instances[0]; + + auto MeshIt = std::find_if( + m_State.Scene.MeshInstances.begin(), m_State.Scene.MeshInstances.end(), + [&](const EditorSceneMeshInstance &I) { return I.ObjectId == Command.ObjectId; }); + if (MeshIt == m_State.Scene.MeshInstances.end()) { + // Object was created at runtime (CreateObject) and has no instance yet — add one now. + m_State.Scene.MeshInstances.push_back(EditorSceneMeshInstance{.ObjectId = Command.ObjectId}); + MeshIt = m_State.Scene.MeshInstances.end() - 1; + } + + MeshIt->Mesh = First.Mesh; + MeshIt->Material = First.Material; + MeshIt->AssetRelativePath = Command.AssetPath; + + // Sync material properties on object details so the inspector reflects the new asset's material. + if (First.Material) { + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(Command.ObjectId); + if (DetailsIt != m_State.Scene.ObjectDetailsById.end()) { + DetailsIt->second.Material = EditorMaterialProperties{ + .BaseColorFactor = First.Material->BaseColorFactor, + .Metallic = First.Material->Metallic, + .Roughness = First.Material->Roughness, + }; + } + } + + A_CORE_INFO("SetMeshAsset: assigned '{}' to object '{}'", + Command.AssetPath, Command.ObjectId); + PublishEvent({.Payload = MeshAssetChangedEvent{ + .ObjectId = Command.ObjectId, + .AssetPath = Command.AssetPath, + }}); +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetLightPropertiesCommand &Command) { + EnsurePresence(QueuedCommand.Context.User); + + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(Command.ObjectId); + if (DetailsIt == m_State.Scene.ObjectDetailsById.end()) { + return; + } + + if (!DetailsIt->second.Light.has_value()) { + DetailsIt->second.Light = EditorLightProperties{}; + } + DetailsIt->second.Light->Color = Command.Color; + DetailsIt->second.Light->Intensity = Command.Intensity; + + PublishEvent({.Payload = LightPropertiesChangedEvent{ + .ObjectId = Command.ObjectId, + .Color = Command.Color, + .Intensity = Command.Intensity, + }}); +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetMaterialPropertiesCommand &Command) { + EnsurePresence(QueuedCommand.Context.User); + + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(Command.ObjectId); + if (DetailsIt == m_State.Scene.ObjectDetailsById.end()) { + return; + } + + if (!DetailsIt->second.Material.has_value()) { + DetailsIt->second.Material = EditorMaterialProperties{}; + } + DetailsIt->second.Material->BaseColorFactor = Command.BaseColorFactor; + DetailsIt->second.Material->Metallic = Command.Metallic; + DetailsIt->second.Material->Roughness = Command.Roughness; + + auto MeshIt = std::find_if(m_State.Scene.MeshInstances.begin(), + m_State.Scene.MeshInstances.end(), + [&](const EditorSceneMeshInstance &M) { + return M.ObjectId == Command.ObjectId; + }); + if (MeshIt != m_State.Scene.MeshInstances.end() && MeshIt->Material) { + MeshIt->Material->BaseColorFactor = Command.BaseColorFactor; + MeshIt->Material->Metallic = Command.Metallic; + MeshIt->Material->Roughness = Command.Roughness; + } + + PublishEvent({.Payload = MaterialPropertiesChangedEvent{ + .ObjectId = Command.ObjectId, + .BaseColorFactor = Command.BaseColorFactor, + .Metallic = Command.Metallic, + .Roughness = Command.Roughness, + }}); +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetMaterialTextureCommand &Command) { + EnsurePresence(QueuedCommand.Context.User); + + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(Command.ObjectId); + if (DetailsIt == m_State.Scene.ObjectDetailsById.end()) + return; + + auto MeshIt = std::find_if(m_State.Scene.MeshInstances.begin(), + m_State.Scene.MeshInstances.end(), + [&](const EditorSceneMeshInstance &M) { + return M.ObjectId == Command.ObjectId; + }); + if (MeshIt == m_State.Scene.MeshInstances.end() || !MeshIt->Material) + return; + + if (Command.TextureAssetPath.empty()) { + // Clear the override — the mesh asset's embedded texture (if any) remains + MeshIt->Material->BaseColorTexture = nullptr; + MeshIt->Material->TextureAssetPath.clear(); + } else { + if (m_ContentDir.empty()) { + A_CORE_WARN("SetMaterialTexture: content directory not configured"); + return; + } + const auto FullPath = m_ContentDir / Command.TextureAssetPath; + auto Loaded = Assets::LoadTextureFromFile(FullPath); + if (!Loaded) { + A_CORE_WARN("SetMaterialTexture: failed to load '{}' for object '{}'", + Command.TextureAssetPath, Command.ObjectId); + return; + } + MeshIt->Material->BaseColorTexture = std::move(Loaded); + MeshIt->Material->TextureAssetPath = Command.TextureAssetPath; + } + + if (!DetailsIt->second.Material.has_value()) + DetailsIt->second.Material = EditorMaterialProperties{}; + DetailsIt->second.Material->TextureAssetPath = + Command.TextureAssetPath.empty() + ? std::nullopt + : std::optional(Command.TextureAssetPath); + + A_CORE_INFO("SetMaterialTexture: assigned '{}' to object '{}'", + Command.TextureAssetPath, Command.ObjectId); + PublishEvent({.Payload = MaterialTextureChangedEvent{ + .ObjectId = Command.ObjectId, + .TextureAssetPath = Command.TextureAssetPath, + }}); +} + +void EditorSession::SetContentDir(std::filesystem::path ContentDir) { + m_ContentDir = std::move(ContentDir); +} + void EditorSession::PublishScriptError(const std::string &ObjectId, const std::string &Message) { PublishEvent({ScriptErrorEvent{.ObjectId = ObjectId, .Message = Message}}); diff --git a/Axiom/Session/EditorSession.h b/Axiom/Session/EditorSession.h index c505c1b..65f88af 100644 --- a/Axiom/Session/EditorSession.h +++ b/Axiom/Session/EditorSession.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -52,6 +53,19 @@ struct EditorTransformDetails { glm::vec3 Scale{1.0f}; }; +struct EditorLightProperties { + glm::vec3 Color{1.0f}; + float Intensity{1.0f}; + glm::vec3 Direction{0.35f, 0.7f, 0.2f}; // world-space (need not be normalized) +}; + +struct EditorMaterialProperties { + glm::vec4 BaseColorFactor{1.0f}; + float Metallic{0.0f}; + float Roughness{0.5f}; + std::optional TextureAssetPath; // content-relative path, nullopt = embedded +}; + struct EditorObjectDetails { std::string ObjectId; std::string DisplayName; @@ -62,6 +76,8 @@ struct EditorObjectDetails { std::optional Transform; // local-space std::optional WorldTransform; // world-space (computed) std::optional ScriptClass; // C# script class name (Actors only) + std::optional Light; // Light objects only + std::optional Material; // Mesh objects only }; enum class EditorUserPresenceState { Connected, Away, Disconnected }; @@ -102,6 +118,7 @@ struct EditorSceneMeshInstance { MaterialInstanceRef Material; MeshRenderPath RenderPath{MeshRenderPath::Graphics}; glm::mat4 Transform{1.0f}; + std::string AssetRelativePath; // content-relative path, empty if using startup default }; struct EditorSceneState { @@ -135,6 +152,9 @@ class EditorSession final : public IEditorCommandSink { void Subscribe(IEditorEventSubscriber *Subscriber); void Unsubscribe(IEditorEventSubscriber *Subscriber); + // Must be called before SetMeshAssetCommand can be processed. + void SetContentDir(std::filesystem::path ContentDir); + void EnsureViewportState(SessionUserId User); void SetPresenceState(SessionUserId User, EditorUserPresenceState State); void SetSceneState(EditorSceneState SceneState); @@ -237,6 +257,14 @@ class EditorSession final : public IEditorCommandSink { const AttachScriptCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const DetachScriptCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetMeshAssetCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetLightPropertiesCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetMaterialPropertiesCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetMaterialTextureCommand &Command); void PublishEvent(const EditorEvent &Event); private: @@ -244,5 +272,6 @@ class EditorSession final : public IEditorCommandSink { EditorSessionState m_State; EditorMessageBus m_MessageBus; std::unique_ptr m_SceneRoot; + std::filesystem::path m_ContentDir; }; } // namespace Axiom diff --git a/Axiom/Session/StartupScene.cpp b/Axiom/Session/StartupScene.cpp index a41d0ba..0ef5e59 100644 --- a/Axiom/Session/StartupScene.cpp +++ b/Axiom/Session/StartupScene.cpp @@ -145,6 +145,11 @@ std::vector BuildStartupObjectDetails() { .RotationDegrees = glm::vec3(-45.0f, 30.0f, 0.0f), .Scale = glm::vec3(1.0f), }, + .Light = EditorLightProperties{ + .Color = glm::vec3(1.0f, 0.98f, 0.92f), + .Intensity = 1.0f, + .Direction = glm::vec3(0.35f, 0.7f, 0.2f), + }, }, { .ObjectId = "sky-light", diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b07c72..1f20305 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,24 @@ add_subdirectory(ThirdParty/glfw) add_subdirectory(ThirdParty/fastgltf) add_subdirectory(ThirdParty/glm/glm) +include(FetchContent) +FetchContent_Declare( + assimp + URL https://github.com/assimp/assimp/archive/refs/tags/v5.4.3.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ASSIMP_BUILD_ASSIMP_TOOLS OFF CACHE BOOL "" FORCE) +set(ASSIMP_NO_EXPORT ON CACHE BOOL "" FORCE) +set(ASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT OFF CACHE BOOL "" FORCE) +set(ASSIMP_BUILD_FBX_IMPORTER ON CACHE BOOL "" FORCE) +set(ASSIMP_BUILD_OBJ_IMPORTER ON CACHE BOOL "" FORCE) +set(ASSIMP_BUILD_ZLIB OFF CACHE BOOL "" FORCE) +set(ASSIMP_WARNINGS_AS_ERRORS OFF CACHE BOOL "" FORCE) +set(ASSIMP_INSTALL OFF CACHE BOOL "" FORCE) +set(ASSIMP_INJECT_DEBUG_POSTFIX OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(assimp) + if(AXIOM_ENABLE_SCRIPTING) include(cmake/CoralNative.cmake) endif() diff --git a/Content/Engine/lightbulb.svg b/Content/Engine/lightbulb.svg new file mode 100644 index 0000000..0d7b575 --- /dev/null +++ b/Content/Engine/lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Content/Engine/sun.svg b/Content/Engine/sun.svg new file mode 100644 index 0000000..c0deb6c --- /dev/null +++ b/Content/Engine/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Content/Engine/tf2 coconut.jpg b/Content/Engine/tf2 coconut.jpg new file mode 100644 index 0000000..c24b741 Binary files /dev/null and b/Content/Engine/tf2 coconut.jpg differ diff --git a/Content/Shaders/mesh.frag b/Content/Shaders/mesh.frag index 6e4a86f..888b072 100644 --- a/Content/Shaders/mesh.frag +++ b/Content/Shaders/mesh.frag @@ -2,10 +2,18 @@ layout(location = 0) in vec3 inNormal; layout(location = 1) in vec2 inTexCoord; +layout(location = 2) in vec3 inWorldPos; layout(location = 0) out vec4 outColor; layout(set = 0, binding = 1) uniform texture2D baseColorImage; layout(set = 0, binding = 2) uniform sampler baseColorSampler; + +layout(push_constant) uniform MeshGraphicsPushConstants { + mat4 model; + vec4 baseColorFactor; + float metallic; + float roughness; +} pushConstants; layout(std140, set = 0, binding = 0) uniform CameraFrame { mat4 view; mat4 projection; @@ -13,14 +21,55 @@ layout(std140, set = 0, binding = 0) uniform CameraFrame { vec4 cameraPosition; vec4 viewportSize; uvec4 renderOptions; + vec4 lightDirectionAndIntensity; // xyz = direction, w = intensity + vec4 lightColorAndEnabled; // xyz = color, w = 1.0 if dynamic light active } cameraFrame; void main() { - vec3 lightDir = normalize(vec3(0.35, 0.7, 0.2)); - vec4 baseColor = texture(sampler2D(baseColorImage, baseColorSampler), inTexCoord); - float lighting = 1.0; - if (cameraFrame.renderOptions.x == 0u) { - lighting = max(dot(normalize(inNormal), lightDir), 0.2); + vec4 texColor = texture(sampler2D(baseColorImage, baseColorSampler), inTexCoord); + vec4 baseColor = texColor * pushConstants.baseColorFactor; + + bool litMode = cameraFrame.renderOptions.x == 0u; + bool hasLight = cameraFrame.lightColorAndEnabled.w > 0.5; + + if (!litMode) { + outColor = vec4(baseColor.rgb, baseColor.a); + return; } - outColor = vec4(baseColor.rgb * lighting, baseColor.a); + + // Dielectric ambient is low; metallic gets a boosted ambient to stand in for + // environment reflections that would normally light a mirror-like surface. + float dielectricAmbient = mix(0.05, 0.20, pushConstants.roughness); + float metallicAmbient = mix(0.35, 0.15, pushConstants.roughness); + float ambientFloor = mix(dielectricAmbient, metallicAmbient, pushConstants.metallic); + + if (!hasLight) { + outColor = vec4(baseColor.rgb * ambientFloor, baseColor.a); + return; + } + + vec3 L = normalize(cameraFrame.lightDirectionAndIntensity.xyz); + float lightIntensity = cameraFrame.lightDirectionAndIntensity.w; + vec3 lightColor = cameraFrame.lightColorAndEnabled.xyz; + + vec3 N = normalize(inNormal); + vec3 V = normalize(cameraFrame.cameraPosition.xyz - inWorldPos); + vec3 H = normalize(L + V); + + float NdotL = max(dot(N, L), 0.0); + float NdotH = max(dot(N, H), 0.0); + + // Roughness -> shininess: roughness=0 gives tight highlight, roughness=1 gives almost none. + float shininess = mix(256.0, 2.0, pushConstants.roughness); + float specularStr = pow(NdotH, shininess) * (1.0 - pushConstants.roughness); + + // Metallic reduces diffuse (energy redirected to specular). + float diffuseScale = mix(1.0, 0.15, pushConstants.metallic); + float diffuse = max(NdotL * lightIntensity * diffuseScale, ambientFloor); + + // Dielectric specular is near-white (F0 ~0.04); metallic tints specular with base color. + vec3 specularColor = mix(vec3(0.04), baseColor.rgb, pushConstants.metallic); + vec3 specular = specularColor * specularStr * lightIntensity * lightColor; + + outColor = vec4(baseColor.rgb * lightColor * diffuse + specular, baseColor.a); } diff --git a/Content/Shaders/mesh.frag.spv b/Content/Shaders/mesh.frag.spv index b92fe4e..ff98207 100644 Binary files a/Content/Shaders/mesh.frag.spv and b/Content/Shaders/mesh.frag.spv differ diff --git a/Content/Shaders/mesh.vert b/Content/Shaders/mesh.vert index 6daded4..5f575ff 100644 --- a/Content/Shaders/mesh.vert +++ b/Content/Shaders/mesh.vert @@ -6,6 +6,7 @@ layout(location = 2) in vec2 inTexCoord; layout(location = 0) out vec3 outNormal; layout(location = 1) out vec2 outTexCoord; +layout(location = 2) out vec3 outWorldPos; layout(std140, set = 0, binding = 0) uniform CameraFrame { mat4 view; @@ -17,7 +18,10 @@ layout(std140, set = 0, binding = 0) uniform CameraFrame { } cameraFrame; layout(push_constant) uniform MeshGraphicsPushConstants { - mat4 model; + mat4 model; + vec4 baseColorFactor; + float metallic; + float roughness; } pushConstants; void main() { @@ -27,4 +31,5 @@ void main() { gl_Position = clipPosition; outNormal = normalize(mat3(pushConstants.model) * inNormal.xyz); outTexCoord = inTexCoord; + outWorldPos = worldPosition.xyz; } diff --git a/Content/Shaders/mesh.vert.spv b/Content/Shaders/mesh.vert.spv index 4656c46..a4bf2f5 100644 Binary files a/Content/Shaders/mesh.vert.spv and b/Content/Shaders/mesh.vert.spv differ diff --git a/Content/trout.glb b/Content/trout.glb new file mode 100644 index 0000000..956000f Binary files /dev/null and b/Content/trout.glb differ diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index 4d82b97..792be05 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -2,7 +2,7 @@ ## Document Status - Status: Draft -- Date: 2026-05-08 +- Date: 2026-05-11 - Audience: Engine, tools, networking, web, and infrastructure contributors - Intended outcome: Establish the target architecture for evolving WraithEngine into a distributed game engine and browser-based collaborative editor @@ -63,6 +63,16 @@ - `SceneFile` (`Axiom/Assets/SceneFile.h/.cpp`) provides `SaveSceneToFile` / `LoadSceneFromFile`; serialization uses a manual `ostringstream` JSON emitter in flat-node format with `parentId` links; deserialization uses a purpose-built recursive descent parser (no external JSON library) - `LoadStartupScene` now checks for `Content/scene.json` first and falls back to the hardcoded default scene; scene state persists across server restarts automatically - `SaveScene` command writes `Content/scene.json` and replies with `{"type":"scene_saved"}` or `{"type":"scene_save_failed"}`; the toolbar Save button animates to a green checkmark for 2.5 s on success or a red X on failure +- Phase 7 (Asset Pipeline) is now implemented: mesh asset assignment, dynamic directional lighting, material property editing, texture thumbnail previews, texture assignment, FBX/OBJ import via assimp, and OS-level file import into the content browser +- `SetMeshAssetCommand` lets any `SceneMeshObject` reference a discovered `.glb`/`.gltf`/`.fbx`/`.obj` asset by path; the handler creates a `MeshInstance` entry for runtime-created objects (i.e. those created via `CreateObject`) so `SetMeshAsset` works immediately after object creation without requiring a prior scene-file load +- `SetLightPropertiesCommand` drives the first `SceneLight` in the scene; color, intensity, and direction are uploaded to the `CameraFrameUniform` UBO every frame; the fragment shader uses Blinn-Phong specular with a metallic ambient boost to approximate environment reflections without IBL +- `SetMaterialPropertiesCommand` exposes `BaseColorFactor` (vec4), `Metallic`, and `Roughness` on mesh instances; values are passed as Vulkan push constants to `mesh.frag`; the inspector shows a color picker, alpha slider, and metallic/roughness number inputs with an Apply button +- `SetMaterialTextureCommand` assigns a standalone PNG/JPG texture to a mesh's base-color slot by content-relative path; the texture is loaded via stb_image, uploaded as a `TextureSourceData` ref to the live `MaterialInstance`, and persisted in `scene.json` via `textureAssetPath`; the inspector shows the current texture name with a clear button; dragging a texture asset onto a mesh object in the outliner or content browser sends the command; empty path clears the texture back to the mesh asset's embedded material +- FBX and OBJ import is now implemented via assimp v5.4.3 (added as a FetchContent dependency with only the FBX and OBJ importers enabled); `IAssetImporter` interface and `AssimpImporter` convert assimp's scene graph to `MeshSceneData`; row-major `aiMatrix4x4` is transposed to column-major `glm::mat4`; embedded compressed textures (`*N` pointer) and external file references are both handled; `.fbx` and `.obj` are registered in `LocalAssetSource` and routed through `AssimpImporter` in `LoadBasicMeshAsset` +- `POST /assets/upload?dir=` endpoint on `AxiomRemoteViewportServer` parses `multipart/form-data`, validates file extensions, guards against path traversal, writes files to the content directory, and broadcasts a refreshed asset list to all WebSocket clients; the content browser file area is a drop zone that accepts OS-dragged files with a "Drop to import" overlay; the Import button opens a native file picker; both paths call the upload endpoint and refresh the asset list +- `GET /assets/thumbnail?path=` endpoint on the remote viewport server decodes the URL-encoded path, loads the image via stb_image, scales to 128×128 via nearest-neighbor, and returns a JPEG; the content browser fetches and displays thumbnails for texture assets in grid view +- Content browser is now non-recursive: only immediate children of the current path are shown; the sidebar renders a dynamic tree derived from actual asset paths; breadcrumb navigation and folder double-click update `currentPath`; search bypasses the folder filter and matches recursively +- 17 new Google Test cases added across `HeadlessProtocolTests` (protocol parse/serialize coverage for all Phase 7 commands and events) and `SceneLifecycleTests` (session behavior for `SetLightPropertiesCommand`, `SetMaterialPropertiesCommand`, `SetSceneState` material backfill, `SetMeshAsset` validation, and the `CreateObject`→`SetMeshAsset` runtime-creation regression) ## 1. Executive Summary WraithEngine will evolve from a single-process native editor into a distributed platform with one shared C++ engine runtime that supports two execution styles: @@ -1041,45 +1051,35 @@ Progress update: - Five Google Test cases in `Tests/ScriptingTests.cpp`: `ScriptHostLifecycle`, `InternalCallRoundTrip`, `ScriptLifecycle`, `HotReload`, `RestrictedProfileBlocks` - Coral patched: cross-ALC assembly sharing in `AssemblyLoader.ResolveAssembly` (prevents duplicate `WraithEngine.Managed` load with null function pointers) and `ManagedAssembly::RefreshTypeCache` (repopulates `s_CachedTypes` after `UnloadAssemblyLoadContext` clears it globally) -### Phase 7: Asset Pipeline — Textures, FBX/OBJ Import, Basic Materials - -Scope: Expand the asset system beyond raw `.glb` mesh loading to support standalone -texture import, FBX and OBJ source formats, and a first-pass material model that wires -surface parameters to the Vulkan renderer without requiring a full PBR implementation. - -#### 7.1 Texture Import -- Add `TextureAsset` to the `IAssetSource` / `AssetId` identity model -- Import `.png`, `.jpg`, `.jpeg`, `.hdr`, `.exr` via stb_image or a similar single-header library -- Upload to Vulkan via a staging buffer / `vkCmdCopyBufferToImage` path; cache as `VkImage` + `VkImageView` + `VkSampler` -- Surface texture assets in the content browser under the existing Texture filter tab (already present in the UI) -- Add `TextureRef` property kind to the reflection system so materials can reference textures by `AssetId` - -#### 7.2 FBX / OBJ Import -- Add [assimp](https://github.com/assimp/assimp) as a ThirdParty dependency (CMake `FetchContent` or submodule) -- Introduce `IAssetImporter` interface with implementations for FBX (via assimp) and OBJ (via assimp or tinyobjloader) -- Emit glTF-compatible in-memory mesh and material data so the existing `fastgltf` render path can be reused downstream; alternatively introduce a shared `MeshData` struct consumed by both loaders -- Register `.fbx` and `.obj` extensions in `LocalAssetSource` and the content browser filter tabs -- Import settings (coordinate-system handedness flip, scale factor, smoothing groups) stored as metadata alongside the stable `AssetId` - -#### 7.3 Basic Materials (no PBR required) -- Introduce `MaterialAsset` with at minimum: albedo color (`vec4`), albedo texture ref, roughness (`float`), metallic (`float`) -- Serialize material assets to `Content/` as `.mat.json` files alongside the stable `AssetId` -- Add a simple forward-shading GLSL material shader variant; the existing mesh pipeline switches to a per-material descriptor set -- Expose material properties in the details panel via `GetSchema` / `SetProperty` (reuses the existing reflection + property dispatch path) -- `MeshObject` gains a `MaterialId` reference; `SceneMeshObject` stores it; `SetProperty("materialId", ...)` dispatches a new `SetMaterialCommand` -- Full PBR (IBL, clearcoat, transmission) is deferred to a later rendering phase - -#### 7.4 Lighting -- Add `LightComponent` as a first-class scene instance type alongside the existing `SceneLight` stub; light properties stored in `EditorObjectDetails` -- **Point light** — position, color, intensity, range (attenuation radius); represented as a uniform buffer entry in a per-frame light list -- **Directional light** — direction (derived from transform rotation), color, intensity; one active directional light per scene for v1 -- **Spot light** — position, direction, color, intensity, inner/outer cone angles -- **Ambient** — a single ambient color/intensity applied to every fragment as a base term; simple sky color for v1 (IBL/HDRI deferred to a later PBR phase) -- Vulkan: extend the per-frame UBO (or add a separate SSBO) to carry a packed light list; update the forward-shading fragment shader to iterate lights using Blinn-Phong or a simple physically-motivated approximation -- **Shadow mapping** — directional shadow map first (single cascade, 2048×2048 depth attachment, PCF filter); point and spot shadow maps deferred to a later phase -- Light objects appear in the scene outliner, support transform editing, and expose their properties in the details panel via the existing `GetSchema` / `SetProperty` path -- `GetSchema` for a Light object returns `color`, `intensity`, `range`/`angle` (type-specific), and `castsShadows` as editable properties -- Emissive property on `MaterialAsset` (color + intensity multiplier) so meshes can self-illuminate without requiring a separate light source +### Phase 7: Asset Pipeline + +Scope: Get real asset data flowing — mesh assignment, directional lighting, material +editing, texture previews, texture assignment, FBX/OBJ import, and OS-level asset import +into the content browser — without requiring a full PBR renderer or secondary import +pipeline. + +Progress update: + +- `SetMeshAssetCommand { ObjectId, AssetPath }` lets any `SceneMeshObject` reference any discovered `.glb`/`.gltf`/`.fbx`/`.obj` asset; the handler resolves `ContentDir / AssetPath`, calls `LoadBasicMeshAsset`, and updates the live `MeshInstance`; if the object was created at runtime via `CreateObject` (which does not pre-populate `MeshInstances`), the handler now creates the entry rather than silently dropping the command +- `SetMeshAsset` is fully serialized to `scene.json` via the `assetRelativePath` field on each mesh entry so assignments survive server restarts; the content browser double-click on a `.glb`/`.fbx`/`.obj` asset while a mesh object is selected sends the command end-to-end +- `SetLightPropertiesCommand { ObjectId, Color, Intensity, Direction }` drives the first visible `SceneLight` in the scene; direction is derived from the light object's world-space position each frame; the `CameraFrameUniform` UBO was extended with `lightDirectionAndIntensity` and `lightColorAndEnabled` fields consumed by `mesh.frag` +- `mesh.frag` was rewritten with a Blinn-Phong specular model: half-vector `H = normalize(L + V)`, `shininess = mix(256, 2, roughness)`, specular color blended between dielectric F0 `vec3(0.04)` and base color via metallic factor; a separate metallic ambient floor (`0.35` at metallic=1/roughness=0) approximates environment reflections absent IBL so metallic surfaces are not black without a high-intensity light +- `SetMaterialPropertiesCommand { ObjectId, BaseColorFactor, Metallic, Roughness }` updates both `ObjectDetailsById.Material` and the live `MeshInstance.Material`; values reach the shader as Vulkan push constants in `MeshGraphicsPushConstants`; both stage flags (`VERTEX_BIT | FRAGMENT_BIT`) are set so the layout is valid +- `SetMaterialTextureCommand { ObjectId, TextureAssetPath }` assigns a standalone PNG/JPG texture to a mesh's base-color slot; the texture is loaded via stb_image and set on the live `MaterialInstance.BaseColorTexture`; the path is persisted in `scene.json` under `textureAssetPath` per mesh object; on load `SceneFile` calls `LoadTextureFromFile` and propagates both the GPU resource and the path into `ObjectDetailsById` so the inspector is immediately correct; sending an empty path clears the texture; the inspector's `MaterialSection` shows the current texture filename with a one-click clear button; texture assets are draggable in both the outliner and the content browser +- FBX and OBJ import: `IAssetImporter` interface added at `Axiom/Assets/IAssetImporter.h`; `AssimpImporter` (`AssimpImporter.h/.cpp`) converts assimp's scene graph to `MeshSceneData` — `ConvertMesh` extracts positions/normals/UVs/indices/bounds, `ConvertMaterial` reads diffuse color, metallic/roughness factors, and diffuse textures (embedded `*N` compressed textures via `LoadTextureFromMemory`; external paths resolved relative to the asset directory via `LoadTextureFromFile`); `ToGlm` transposes assimp's row-major `aiMatrix4x4` to glm column-major; assimp v5.4.3 added as FetchContent with only the FBX and OBJ importers enabled and `ASSIMP_BUILD_ZLIB=OFF` to avoid a macOS SDK header conflict with the bundled zlib; `.fbx` and `.obj` registered in `LocalAssetSource::KindFromExtension` and `kContentExtensions`; `LoadBasicMeshAsset` routes `.fbx`/`.obj` through `AssimpImporter` before attempting fastgltf; public `LoadTextureFromFile` / `LoadTextureFromMemory` wrappers added to `MeshAsset.h/.cpp` so `AssimpImporter` can call stb_image without re-defining `STB_IMAGE_IMPLEMENTATION` +- `POST /assets/upload?dir=` on `AxiomRemoteViewportServer` parses `multipart/form-data`, extracts `filename` from each part's `Content-Disposition`, validates extensions (`.glb`, `.gltf`, `.fbx`, `.obj`, `.png`, `.jpg`, `.jpeg`), guards against `..` path traversal, writes bytes to `Content//` creating directories as needed, and broadcasts a refreshed `asset_list` to all WebSocket clients; the content browser file panel has `onDragOver`/`onDrop` handlers that accept OS-dragged files (detected by `dataTransfer.types.includes("Files")`) with a blue "Drop to import" ring overlay; the Import button is now a styled `