diff --git a/.gitignore b/.gitignore index dd92440f..ec7eb483 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ Tests/TestScripts/**/bin/ Tests/TestScripts/**/obj/ # Coral is a git submodule — its own .gitignore handles bin/ and obj/ internally. # Listing Coral sub-paths here is redundant and interferes with submodule commits. +EditorFrontend/next-env.d.ts +*.wtex +*.wmat +*.wmesh diff --git a/Axiom/Assets/SceneFile.cpp b/Axiom/Assets/SceneFile.cpp index acdf83a2..e8e5e765 100644 --- a/Axiom/Assets/SceneFile.cpp +++ b/Axiom/Assets/SceneFile.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -174,6 +175,64 @@ EditorTransformDetails DecomposeMatrix(const glm::mat4 &Matrix) { }; } +glm::vec3 AbsVec3(const glm::vec3 &Value) { + return glm::vec3(std::abs(Value.x), std::abs(Value.y), std::abs(Value.z)); +} + +void ExpandBounds(const glm::vec3 &BoundsMin, const glm::vec3 &BoundsMax, + const glm::mat4 &Transform, glm::vec3 &OutMin, + glm::vec3 &OutMax, bool &HasBounds) { + const std::array Corners = { + glm::vec3(BoundsMin.x, BoundsMin.y, BoundsMin.z), + glm::vec3(BoundsMax.x, BoundsMin.y, BoundsMin.z), + glm::vec3(BoundsMin.x, BoundsMax.y, BoundsMin.z), + glm::vec3(BoundsMax.x, BoundsMax.y, BoundsMin.z), + glm::vec3(BoundsMin.x, BoundsMin.y, BoundsMax.z), + glm::vec3(BoundsMax.x, BoundsMin.y, BoundsMax.z), + glm::vec3(BoundsMin.x, BoundsMax.y, BoundsMax.z), + glm::vec3(BoundsMax.x, BoundsMax.y, BoundsMax.z), + }; + + for (const glm::vec3 &Corner : Corners) { + const glm::vec3 WorldCorner = glm::vec3(Transform * glm::vec4(Corner, 1.0f)); + if (!HasBounds) { + OutMin = WorldCorner; + OutMax = WorldCorner; + HasBounds = true; + continue; + } + OutMin = glm::min(OutMin, WorldCorner); + OutMax = glm::max(OutMax, WorldCorner); + } +} + +std::optional +BuildDefaultStaticMeshPhysics(const MeshSceneData &SceneData, + const EditorTransformDetails &RootTransform) { + glm::vec3 CombinedMin(0.0f); + glm::vec3 CombinedMax(0.0f); + bool HasBounds = false; + + for (const auto &Instance : SceneData.Instances) { + ExpandBounds(Instance.Mesh.BoundsMin, Instance.Mesh.BoundsMax, + Instance.Transform, CombinedMin, CombinedMax, HasBounds); + } + + if (!HasBounds) { + return std::nullopt; + } + + glm::vec3 HalfExtents = glm::max(glm::abs(CombinedMin), glm::abs(CombinedMax)); + HalfExtents *= AbsVec3(RootTransform.Scale); + HalfExtents = glm::max(HalfExtents, glm::vec3(0.01f)); + + return EditorPhysicsProperties{ + .BodyType = EditorPhysicsBodyType::Static, + .ColliderType = EditorPhysicsColliderType::Box, + .BoxHalfExtents = HalfExtents, + }; +} + void ExpandMeshAssetIntoScene(EditorSceneState &State, std::string_view RootObjectId, const MeshSceneData &SceneData, std::string_view AssetPath) { @@ -186,6 +245,11 @@ void ExpandMeshAssetIntoScene(EditorSceneState &State, std::string_view RootObje RootDetails.IsGeneratedAssetChild = false; RootDetails.GeneratedFromAssetRootId = std::nullopt; RootDetails.AssetRelativePath = std::string(AssetPath); + if (!RootDetails.Physics.has_value()) { + const EditorTransformDetails RootTransform = + RootDetails.Transform.value_or(EditorTransformDetails{}); + RootDetails.Physics = BuildDefaultStaticMeshPhysics(SceneData, RootTransform); + } auto *RootItem = FindSceneItemMutable(State.Items, RootObjectId); if (RootItem == nullptr) { @@ -415,6 +479,26 @@ bool SaveSceneToFile(const std::filesystem::path &Path, << ",\"lightIntensity\":" << Details.Light->Intensity << ",\"lightDirection\":" << SerializeVec3(Details.Light->Direction); } + if (Details.Physics.has_value()) { + Out << ",\"physicsBodyType\":" + << EscStr(Details.Physics->BodyType == EditorPhysicsBodyType::Dynamic + ? "dynamic" + : (Details.Physics->BodyType == EditorPhysicsBodyType::Static + ? "static" + : "none")) + << ",\"physicsColliderType\":" + << EscStr(Details.Physics->ColliderType == EditorPhysicsColliderType::Sphere + ? "sphere" + : (Details.Physics->ColliderType == EditorPhysicsColliderType::Box + ? "box" + : "none")) + << ",\"physicsBoxHalfExtents\":" + << SerializeVec3(Details.Physics->BoxHalfExtents) + << ",\"physicsSphereRadius\":" << Details.Physics->SphereRadius + << ",\"physicsMass\":" << Details.Physics->Mass + << ",\"physicsFriction\":" << Details.Physics->Friction + << ",\"physicsRestitution\":" << Details.Physics->Restitution; + } Out << "}"; } Out << "\n ],\n"; @@ -646,6 +730,7 @@ LoadSceneFromFile(const std::filesystem::path &Path) { std::string MaterialAssetPath; std::string TextureAssetPath; std::optional Light; + std::optional Physics; }; std::string MeshAsset; @@ -745,6 +830,74 @@ LoadSceneFromFile(const std::filesystem::path &Path) { if (V) { if (!Data.Light) Data.Light = EditorLightProperties{}; Data.Light->Direction = *V; } return true; } + if (K == "physicsBodyType") { + auto V = P.ParseString(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + if (*V == "static") { + Data.Physics->BodyType = EditorPhysicsBodyType::Static; + } else if (*V == "dynamic") { + Data.Physics->BodyType = EditorPhysicsBodyType::Dynamic; + } else { + Data.Physics->BodyType = EditorPhysicsBodyType::None; + } + } + return true; + } + if (K == "physicsColliderType") { + auto V = P.ParseString(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + if (*V == "box") { + Data.Physics->ColliderType = EditorPhysicsColliderType::Box; + } else if (*V == "sphere") { + Data.Physics->ColliderType = EditorPhysicsColliderType::Sphere; + } else { + Data.Physics->ColliderType = EditorPhysicsColliderType::None; + } + } + return true; + } + if (K == "physicsBoxHalfExtents") { + auto V = P.ParseVec3(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + Data.Physics->BoxHalfExtents = *V; + } + return true; + } + if (K == "physicsSphereRadius") { + auto V = P.ParseNumber(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + Data.Physics->SphereRadius = static_cast(*V); + } + return true; + } + if (K == "physicsMass") { + auto V = P.ParseNumber(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + Data.Physics->Mass = static_cast(*V); + } + return true; + } + if (K == "physicsFriction") { + auto V = P.ParseNumber(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + Data.Physics->Friction = static_cast(*V); + } + return true; + } + if (K == "physicsRestitution") { + auto V = P.ParseNumber(); + if (V) { + if (!Data.Physics) Data.Physics = EditorPhysicsProperties{}; + Data.Physics->Restitution = static_cast(*V); + } + return true; + } return false; }); if (!ObjId.empty()) Objects[ObjId] = std::move(Data); @@ -817,6 +970,7 @@ LoadSceneFromFile(const std::filesystem::path &Path) { Details.Transform = Data.Transform; Details.ScriptClass = Data.ScriptClass; Details.Light = Data.Light; + Details.Physics = Data.Physics; Details.GeneratedFromAssetRootId = Data.GeneratedFromAssetRootId; Details.AssetRelativePath = Data.AssetRelativePath; State.ObjectDetailsById[Id] = std::move(Details); @@ -927,6 +1081,16 @@ LoadSceneFromFile(const std::filesystem::path &Path) { Transform = BuildTransformMatrix(T); } + if (DetailsIt != State.ObjectDetailsById.end() && + !DetailsIt->second.Physics.has_value()) { + const auto RootTransform = + DetailsIt->second.Transform.value_or(EditorTransformDetails{}); + MeshSceneData SingleMeshScene; + SingleMeshScene.Instances.push_back(Instance); + DetailsIt->second.Physics = + BuildDefaultStaticMeshPhysics(SingleMeshScene, RootTransform); + } + State.MeshInstances.push_back({ .ObjectId = ObjId, .Mesh = Instance.Mesh, diff --git a/Axiom/CMakeLists.txt b/Axiom/CMakeLists.txt index 577b1d8f..05b97cfa 100644 --- a/Axiom/CMakeLists.txt +++ b/Axiom/CMakeLists.txt @@ -24,6 +24,7 @@ set(ENGINE_SOURCES Core/Log.cpp Core/VulkanLoader.cpp Remote/AxiomSessionEndpoint.cpp + Physics/PhysicsWorld.cpp Session/BufferedEditorInputSource.cpp Session/EditorMessageBus.cpp Session/EditorSceneRendererAdapter.cpp @@ -293,6 +294,7 @@ target_include_directories(AxiomCore PUBLIC "${CMAKE_SOURCE_DIR}/ThirdParty/vma" "${CMAKE_SOURCE_DIR}/ThirdParty/volk" "${CMAKE_SOURCE_DIR}/ThirdParty/spdlog/include" + "$<$:${AXIOM_JOLT_SOURCE_DIR}>" "${Vulkan_INCLUDE_DIRS}" ) @@ -314,6 +316,13 @@ else() target_compile_definitions(AxiomCore PUBLIC AXIOM_ENABLE_WEBRTC=0) endif() +if(AXIOM_ENABLE_PHYSICS) + target_link_libraries(AxiomCore PUBLIC Jolt) + target_compile_definitions(AxiomCore PUBLIC AXIOM_ENABLE_PHYSICS=1) +else() + target_compile_definitions(AxiomCore PUBLIC AXIOM_ENABLE_PHYSICS=0) +endif() + if(AXIOM_ENABLE_SCRIPTING) target_link_libraries(AxiomCore PUBLIC CoralNative) target_compile_definitions(AxiomCore PUBLIC AXIOM_SCRIPTING_ENABLED=1) diff --git a/Axiom/Physics/PhysicsWorld.cpp b/Axiom/Physics/PhysicsWorld.cpp new file mode 100644 index 00000000..ab0ba28c --- /dev/null +++ b/Axiom/Physics/PhysicsWorld.cpp @@ -0,0 +1,351 @@ +#include "Physics/PhysicsWorld.h" + +#include + +#include +#include + +#if AXIOM_ENABLE_PHYSICS +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#endif + +namespace Axiom { + +#if AXIOM_ENABLE_PHYSICS +namespace { + +constexpr JPH::ObjectLayer kStaticObjectLayer = 0; +constexpr JPH::ObjectLayer kDynamicObjectLayer = 1; +constexpr JPH::BroadPhaseLayer kStaticBroadPhaseLayer(0); +constexpr JPH::BroadPhaseLayer kDynamicBroadPhaseLayer(1); + +class BroadPhaseLayerInterfaceImpl final + : public JPH::BroadPhaseLayerInterface { +public: + JPH::uint GetNumBroadPhaseLayers() const override { return 2; } + + JPH::BroadPhaseLayer GetBroadPhaseLayer( + JPH::ObjectLayer inLayer) const override { + return inLayer == kDynamicObjectLayer ? kDynamicBroadPhaseLayer + : kStaticBroadPhaseLayer; + } + +#if defined(JPH_EXTERNAL_PROFILE) || defined(JPH_PROFILE_ENABLED) + const char *GetBroadPhaseLayerName( + JPH::BroadPhaseLayer inLayer) const override { + return inLayer == kDynamicBroadPhaseLayer ? "Dynamic" : "Static"; + } +#endif +}; + +class ObjectVsBroadPhaseLayerFilterImpl final + : public JPH::ObjectVsBroadPhaseLayerFilter { +public: + bool ShouldCollide(JPH::ObjectLayer inLayer1, + JPH::BroadPhaseLayer inLayer2) const override { + if (inLayer1 == kStaticObjectLayer) { + return inLayer2 == kDynamicBroadPhaseLayer; + } + return true; + } +}; + +class ObjectLayerPairFilterImpl final : public JPH::ObjectLayerPairFilter { +public: + bool ShouldCollide(JPH::ObjectLayer inLayer1, + JPH::ObjectLayer inLayer2) const override { + if (inLayer1 == kStaticObjectLayer && inLayer2 == kStaticObjectLayer) { + return false; + } + return true; + } +}; + +JPH::Vec3 ToJoltVec3(const glm::vec3 &Value) { + return JPH::Vec3(Value.x, Value.y, Value.z); +} + +JPH::RVec3 ToJoltRVec3(const glm::vec3 &Value) { + return JPH::RVec3(Value.x, Value.y, Value.z); +} + +template +glm::vec3 ToGlmVec3(const TVector &Value) { + return {static_cast(Value.GetX()), static_cast(Value.GetY()), + static_cast(Value.GetZ())}; +} + +JPH::Quat ToJoltQuatDegrees(const glm::vec3 &RotationDegrees) { + return JPH::Quat::sEulerAngles( + JPH::Vec3(glm::radians(RotationDegrees.x), glm::radians(RotationDegrees.y), + glm::radians(RotationDegrees.z))); +} + +glm::vec3 ToGlmEulerDegrees(const JPH::Quat &Rotation) { + const glm::quat GlmRotation(Rotation.GetW(), Rotation.GetX(), Rotation.GetY(), + Rotation.GetZ()); + return glm::degrees(glm::eulerAngles(GlmRotation)); +} + +void TraceImpl(const char *Format, ...) { + char Buffer[1024]; + va_list Args; + va_start(Args, Format); + std::vsnprintf(Buffer, sizeof(Buffer), Format, Args); + va_end(Args); + A_CORE_TRACE("Jolt: {}", Buffer); +} + +#ifdef JPH_ENABLE_ASSERTS +bool AssertFailedImpl(const char *Expression, const char *Message, + const char *File, JPH::uint Line) { + A_CORE_ERROR("Jolt assertion failed: {} ({}) at {}:{}", + Expression != nullptr ? Expression : "", + Message != nullptr ? Message : "", File != nullptr ? File : "", + Line); + std::fprintf(stderr, "Jolt assertion failed: %s (%s) at %s:%u\n", + Expression != nullptr ? Expression : "", + Message != nullptr ? Message : "", + File != nullptr ? File : "", Line); + return false; +} +#endif + +struct JoltRuntime final { + JoltRuntime() { + JPH::Trace = TraceImpl; +#ifdef JPH_ENABLE_ASSERTS + JPH::AssertFailed = AssertFailedImpl; +#endif + JPH::RegisterDefaultAllocator(); + JPH::Factory::sInstance = new JPH::Factory(); + JPH::RegisterTypes(); + } + + ~JoltRuntime() { + JPH::UnregisterTypes(); + delete JPH::Factory::sInstance; + JPH::Factory::sInstance = nullptr; + } +}; + +JoltRuntime &GetJoltRuntime() { + static JoltRuntime Runtime; + return Runtime; +} + +} // namespace +#endif + +struct PhysicsWorld::Impl { +#if AXIOM_ENABLE_PHYSICS + struct BodyRecord { + std::string ObjectId; + EditorPhysicsBodyType BodyType{EditorPhysicsBodyType::None}; + JPH::BodyID BodyId; + }; + + BroadPhaseLayerInterfaceImpl BroadPhaseLayers; + ObjectVsBroadPhaseLayerFilterImpl ObjectVsBroadPhaseLayerFilter; + ObjectLayerPairFilterImpl ObjectLayerPairFilter; + std::unique_ptr TempAllocator; + std::unique_ptr JobSystem; + std::unique_ptr System; + std::vector Bodies; + std::unordered_map BodyIndexByObjectId; + bool Running{false}; + + Impl() { + (void)GetJoltRuntime(); + TempAllocator = std::make_unique(8 * 1024 * 1024); + JobSystem = std::make_unique(1024); + System = std::make_unique(); + System->Init(1024, 0, 1024, 1024, BroadPhaseLayers, + ObjectVsBroadPhaseLayerFilter, ObjectLayerPairFilter); + System->SetGravity(JPH::Vec3(0.0f, -9.81f, 0.0f)); + } + + JPH::ShapeRefC BuildShape(const EditorPhysicsProperties &Physics) { + switch (Physics.ColliderType) { + case EditorPhysicsColliderType::Box: + return new JPH::BoxShape(ToJoltVec3(Physics.BoxHalfExtents)); + case EditorPhysicsColliderType::Sphere: + return new JPH::SphereShape(Physics.SphereRadius); + default: + return nullptr; + } + } + + void Reset() { + if (System == nullptr) { + Bodies.clear(); + BodyIndexByObjectId.clear(); + Running = false; + return; + } + + auto &BodyInterface = System->GetBodyInterface(); + for (const BodyRecord &Body : Bodies) { + if (!Body.BodyId.IsInvalid()) { + BodyInterface.RemoveBody(Body.BodyId); + BodyInterface.DestroyBody(Body.BodyId); + } + } + Bodies.clear(); + BodyIndexByObjectId.clear(); + Running = false; + } +#endif +}; + +PhysicsWorld::PhysicsWorld() { +#if AXIOM_ENABLE_PHYSICS + m_Impl = std::make_unique(); +#endif +} + +PhysicsWorld::~PhysicsWorld() = default; + +bool PhysicsWorld::IsAvailable() const { +#if AXIOM_ENABLE_PHYSICS + return m_Impl != nullptr; +#else + return false; +#endif +} + +bool PhysicsWorld::IsRunning() const { +#if AXIOM_ENABLE_PHYSICS + return m_Impl != nullptr && m_Impl->Running; +#else + return false; +#endif +} + +void PhysicsWorld::Start(const EditorSceneState &Scene) { +#if AXIOM_ENABLE_PHYSICS + if (m_Impl == nullptr) { + return; + } + + m_Impl->Reset(); + auto &BodyInterface = m_Impl->System->GetBodyInterface(); + + for (const auto &[ObjectId, Details] : Scene.ObjectDetailsById) { + if (!Details.Transform.has_value() || !Details.Physics.has_value()) { + continue; + } + + const EditorPhysicsProperties &Physics = *Details.Physics; + if (Physics.BodyType == EditorPhysicsBodyType::None || + Physics.ColliderType == EditorPhysicsColliderType::None) { + continue; + } + + JPH::ShapeRefC Shape = m_Impl->BuildShape(Physics); + if (Shape == nullptr) { + continue; + } + + const JPH::EMotionType MotionType = + Physics.BodyType == EditorPhysicsBodyType::Dynamic + ? JPH::EMotionType::Dynamic + : JPH::EMotionType::Static; + const JPH::ObjectLayer Layer = + Physics.BodyType == EditorPhysicsBodyType::Dynamic ? kDynamicObjectLayer + : kStaticObjectLayer; + const EditorTransformDetails &Transform = Details.WorldTransform.has_value() + ? *Details.WorldTransform + : *Details.Transform; + JPH::BodyCreationSettings Settings( + Shape.GetPtr(), ToJoltRVec3(Transform.Location), + ToJoltQuatDegrees(Transform.RotationDegrees), MotionType, Layer); + Settings.mFriction = Physics.Friction; + Settings.mRestitution = Physics.Restitution; + if (Physics.BodyType == EditorPhysicsBodyType::Dynamic && + Physics.Mass > 0.0f) { + Settings.mMassPropertiesOverride.mMass = Physics.Mass; + Settings.mOverrideMassProperties = + JPH::EOverrideMassProperties::CalculateInertia; + } + + const JPH::BodyID BodyId = BodyInterface.CreateAndAddBody( + Settings, Physics.BodyType == EditorPhysicsBodyType::Dynamic + ? JPH::EActivation::Activate + : JPH::EActivation::DontActivate); + if (BodyId.IsInvalid()) { + A_CORE_WARN("PhysicsWorld: failed to create body for '{}'", ObjectId); + continue; + } + + const size_t Index = m_Impl->Bodies.size(); + m_Impl->Bodies.push_back( + {.ObjectId = ObjectId, .BodyType = Physics.BodyType, .BodyId = BodyId}); + m_Impl->BodyIndexByObjectId.emplace(ObjectId, Index); + } + + m_Impl->Running = true; +#else + (void)Scene; +#endif +} + +void PhysicsWorld::Stop() { +#if AXIOM_ENABLE_PHYSICS + if (m_Impl != nullptr) { + m_Impl->Reset(); + } +#endif +} + +std::vector PhysicsWorld::Step(float DeltaTimeSeconds) { + std::vector Updates; +#if AXIOM_ENABLE_PHYSICS + if (m_Impl == nullptr || !m_Impl->Running || DeltaTimeSeconds <= 0.0f) { + return Updates; + } + + m_Impl->System->Update(DeltaTimeSeconds, 1, m_Impl->TempAllocator.get(), + m_Impl->JobSystem.get()); + const JPH::BodyInterface &BodyInterface = m_Impl->System->GetBodyInterface(); + + for (const Impl::BodyRecord &Body : m_Impl->Bodies) { + if (Body.BodyType != EditorPhysicsBodyType::Dynamic || Body.BodyId.IsInvalid()) { + continue; + } + + Updates.push_back({ + .ObjectId = Body.ObjectId, + .WorldTransform = + EditorTransformDetails{ + .Location = ToGlmVec3( + BodyInterface.GetCenterOfMassPosition(Body.BodyId)), + .RotationDegrees = ToGlmEulerDegrees( + BodyInterface.GetRotation(Body.BodyId)), + .Scale = glm::vec3(1.0f), + }, + }); + } +#else + (void)DeltaTimeSeconds; +#endif + return Updates; +} + +} // namespace Axiom diff --git a/Axiom/Physics/PhysicsWorld.h b/Axiom/Physics/PhysicsWorld.h new file mode 100644 index 00000000..7e75e222 --- /dev/null +++ b/Axiom/Physics/PhysicsWorld.h @@ -0,0 +1,33 @@ +#pragma once + +#include "Session/EditorSession.h" + +#include +#include +#include + +namespace Axiom { + +struct PhysicsTransformUpdate { + std::string ObjectId; + EditorTransformDetails WorldTransform; +}; + +class PhysicsWorld final { +public: + PhysicsWorld(); + ~PhysicsWorld(); + + bool IsAvailable() const; + bool IsRunning() const; + + void Start(const EditorSceneState &Scene); + void Stop(); + std::vector Step(float DeltaTimeSeconds); + +private: + struct Impl; + std::unique_ptr m_Impl; +}; + +} // namespace Axiom diff --git a/Axiom/Renderer/Mesh.h b/Axiom/Renderer/Mesh.h index d0f0df34..4bc23a27 100644 --- a/Axiom/Renderer/Mesh.h +++ b/Axiom/Renderer/Mesh.h @@ -61,5 +61,6 @@ struct RenderMeshSubmission { std::string Name; MeshRenderPath RenderPath{MeshRenderPath::Graphics}; glm::mat4 Transform{1.0f}; + bool Translucent{false}; }; } // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp index 2849cfc4..3e1999d2 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp +++ b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp @@ -863,6 +863,27 @@ void VulkanRendererBackend::InitMeshPipelines() { &PipelineInfo, VK_NULL_HANDLE, &m_MeshGraphicsPipeline)); + VkPipelineColorBlendAttachmentState AlphaBlendAttachment = ColorBlendAttachment; + AlphaBlendAttachment.blendEnable = VK_TRUE; + AlphaBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + AlphaBlendAttachment.dstColorBlendFactor = + VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + AlphaBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; + AlphaBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + AlphaBlendAttachment.dstAlphaBlendFactor = + VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + AlphaBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; + VkPipelineColorBlendStateCreateInfo AlphaBlending = ColorBlending; + AlphaBlending.pAttachments = &AlphaBlendAttachment; + VkPipelineDepthStencilStateCreateInfo AlphaDepthStencil = DepthStencil; + AlphaDepthStencil.depthWriteEnable = VK_FALSE; + VkGraphicsPipelineCreateInfo AlphaPipelineInfo = PipelineInfo; + AlphaPipelineInfo.pColorBlendState = &AlphaBlending; + AlphaPipelineInfo.pDepthStencilState = &AlphaDepthStencil; + VK_CHECK(vkCreateGraphicsPipelines(m_Device.Device, VK_NULL_HANDLE, 1, + &AlphaPipelineInfo, VK_NULL_HANDLE, + &m_MeshGraphicsAlphaBlendPipeline)); + VkPipelineColorBlendAttachmentState DepthOnlyColorAttachment{}; DepthOnlyColorAttachment.colorWriteMask = 0; VkPipelineColorBlendStateCreateInfo DepthOnlyBlending = ColorBlending; @@ -915,6 +936,8 @@ void VulkanRendererBackend::InitMeshPipelines() { vkDestroyPipeline(m_Device.Device, m_MeshDepthPipeline, VK_NULL_HANDLE); vkDestroyPipeline(m_Device.Device, m_MeshWireframePipeline, VK_NULL_HANDLE); + vkDestroyPipeline(m_Device.Device, m_MeshGraphicsAlphaBlendPipeline, + VK_NULL_HANDLE); vkDestroyPipelineLayout(m_Device.Device, m_MeshGraphicsPipelineLayout, VK_NULL_HANDLE); vkDestroyPipeline(m_Device.Device, m_MeshGraphicsPipeline, VK_NULL_HANDLE); @@ -1145,6 +1168,7 @@ void VulkanRendererBackend::DrawMeshes(VkCommandBuffer CommandBuffer, .MeshPipeline = m_MeshPipeline, .MeshPipelineLayout = m_MeshPipelineLayout, .MeshGraphicsPipeline = m_MeshGraphicsPipeline, + .MeshGraphicsAlphaBlendPipeline = m_MeshGraphicsAlphaBlendPipeline, .MeshGraphicsPipelineLayout = m_MeshGraphicsPipelineLayout, .MeshWireframePipeline = m_MeshWireframePipeline, .MeshDepthPipeline = m_MeshDepthPipeline, diff --git a/Axiom/Renderer/Vulkan/VulkanRendererBackend.h b/Axiom/Renderer/Vulkan/VulkanRendererBackend.h index 44d8de23..318de042 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererBackend.h +++ b/Axiom/Renderer/Vulkan/VulkanRendererBackend.h @@ -129,6 +129,7 @@ class VulkanRendererBackend final : public RendererBackend { VkPipeline m_MeshPipeline{VK_NULL_HANDLE}; VkPipelineLayout m_MeshPipelineLayout{VK_NULL_HANDLE}; VkPipeline m_MeshGraphicsPipeline{VK_NULL_HANDLE}; + VkPipeline m_MeshGraphicsAlphaBlendPipeline{VK_NULL_HANDLE}; VkPipelineLayout m_MeshGraphicsPipelineLayout{VK_NULL_HANDLE}; VkPipeline m_MeshWireframePipeline{VK_NULL_HANDLE}; VkPipeline m_MeshDepthPipeline{VK_NULL_HANDLE}; diff --git a/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp b/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp index c562fb42..53e85c1a 100644 --- a/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp +++ b/Axiom/Renderer/Vulkan/VulkanSceneRenderer.cpp @@ -333,6 +333,68 @@ void VulkanSceneRenderer::RecordGraphicsPass( vkCmdEndRendering(Context.CommandBuffer); } +void VulkanSceneRenderer::RecordTranslucentGraphicsPass( + const RenderContext &Context, const VkViewport &Viewport, + const VkRect2D &Scissor, + const std::vector &GraphicsSubmissions, + const VkDescriptorBufferInfo &CameraBufferInfo, bool ForceWireframe) { + if (GraphicsSubmissions.empty()) { + return; + } + + VkRenderingAttachmentInfo ColorAttachment = + VkInit::AttachmentInfo(Context.DrawImage.ImageView, VK_NULL_HANDLE, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + VkRenderingAttachmentInfo DepthAttachment{ + .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, + .pNext = VK_NULL_HANDLE, + .imageView = Context.RasterDepthImage.ImageView, + .imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, + .loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, + .storeOp = VK_ATTACHMENT_STORE_OP_STORE}; + VkRenderingInfo RenderingInfo = + VkInit::RenderingInfo(Context.DrawExtent, &ColorAttachment, &DepthAttachment); + + vkCmdBeginRendering(Context.CommandBuffer, &RenderingInfo); + vkCmdBindPipeline(Context.CommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + ForceWireframe ? Context.MeshWireframePipeline + : Context.MeshGraphicsAlphaBlendPipeline); + vkCmdSetViewport(Context.CommandBuffer, 0, 1, &Viewport); + vkCmdSetScissor(Context.CommandBuffer, 0, 1, &Scissor); + + for (size_t SubmissionIndex = 0; SubmissionIndex < GraphicsSubmissions.size(); + ++SubmissionIndex) { + const auto &VisibleSubmission = GraphicsSubmissions[SubmissionIndex]; + VkDescriptorSet GraphicsDescriptorSet = + Context.Frame.GraphicsFrameDescriptorSets[SubmissionIndex]; + UpdateGraphicsFrameDescriptors( + Context, GraphicsDescriptorSet, + Context.MaterialResources.ResolveMaterialTextureView( + VisibleSubmission.Submission->Material), + CameraBufferInfo); + vkCmdBindDescriptorSets(Context.CommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + Context.MeshGraphicsPipelineLayout, 0, 1, + &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 | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(MeshGraphicsPushConstants), &PushConstants); + BindMeshBuffers(Context.CommandBuffer, VisibleSubmission.Mesh); + vkCmdDrawIndexed(Context.CommandBuffer, VisibleSubmission.Mesh->IndexCount, 1, + 0, 0, 0); + } + + vkCmdEndRendering(Context.CommandBuffer); +} + void VulkanSceneRenderer::RenderScenePasses(const RenderContext &Context) const { auto &Camera = *Context.Scene.ActiveCamera; CameraFrameUniform CameraData = BuildCameraData(Context); @@ -359,10 +421,12 @@ void VulkanSceneRenderer::RenderScenePasses(const RenderContext &Context) const Context.FrameStats.TriangleCount = 0; std::vector Candidates; - std::vector GraphicsSubmissions; + std::vector OpaqueGraphicsSubmissions; + std::vector TranslucentGraphicsSubmissions; std::vector ComputeSubmissions; Candidates.reserve(SubmissionCount); - GraphicsSubmissions.reserve(SubmissionCount); + OpaqueGraphicsSubmissions.reserve(SubmissionCount); + TranslucentGraphicsSubmissions.reserve(SubmissionCount); ComputeSubmissions.reserve(SubmissionCount); for (size_t Index = 0; Index < SubmissionCount; ++Index) { @@ -419,12 +483,18 @@ void VulkanSceneRenderer::RenderScenePasses(const RenderContext &Context) const continue; } - VisibleMeshSubmission VisibleSubmission{Candidate.Submission, Candidate.Mesh}; + VisibleMeshSubmission VisibleSubmission{ + .Submission = Candidate.Submission, + .Mesh = Candidate.Mesh, + .SortDepth = Candidate.SortDepth, + }; if (!ForceWireframe && Candidate.Submission->RenderPath == MeshRenderPath::Compute) { ComputeSubmissions.push_back(VisibleSubmission); + } else if (Candidate.Submission->Translucent) { + TranslucentGraphicsSubmissions.push_back(VisibleSubmission); } else { - GraphicsSubmissions.push_back(VisibleSubmission); + OpaqueGraphicsSubmissions.push_back(VisibleSubmission); } ++Context.FrameStats.MeshSubmissionCount; @@ -435,7 +505,7 @@ void VulkanSceneRenderer::RenderScenePasses(const RenderContext &Context) const static_cast(Context.DrawExtent.height), 0.0f, 1.0f}; VkRect2D Scissor{{0, 0}, Context.DrawExtent}; - RecordDepthPrepass(Context, Viewport, Scissor, GraphicsSubmissions, + RecordDepthPrepass(Context, Viewport, Scissor, OpaqueGraphicsSubmissions, ComputeSubmissions); Context.BuildHzb(Context.CommandBuffer, Context.Frame); @@ -443,8 +513,19 @@ void VulkanSceneRenderer::RenderScenePasses(const RenderContext &Context) const Context.Frame.HzbViewportSize = glm::vec2(CameraData.ViewportSize); if (ComputeSubmissions.empty()) { - RecordGraphicsPass(Context, Viewport, Scissor, GraphicsSubmissions, + RecordGraphicsPass(Context, Viewport, Scissor, OpaqueGraphicsSubmissions, CameraBufferInfo, ForceWireframe); + if (!TranslucentGraphicsSubmissions.empty()) { + std::sort(TranslucentGraphicsSubmissions.begin(), + TranslucentGraphicsSubmissions.end(), + [](const VisibleMeshSubmission &Left, + const VisibleMeshSubmission &Right) { + return Left.SortDepth > Right.SortDepth; + }); + RecordTranslucentGraphicsPass(Context, Viewport, Scissor, + TranslucentGraphicsSubmissions, + CameraBufferInfo, ForceWireframe); + } return; } @@ -532,7 +613,18 @@ void VulkanSceneRenderer::RenderScenePasses(const RenderContext &Context) const .pImageMemoryBarriers = ToGraphicsBarriers.data()}; vkCmdPipelineBarrier2(Context.CommandBuffer, &ToGraphicsDependencyInfo); - RecordGraphicsPass(Context, Viewport, Scissor, GraphicsSubmissions, + RecordGraphicsPass(Context, Viewport, Scissor, OpaqueGraphicsSubmissions, CameraBufferInfo, ForceWireframe); + if (!TranslucentGraphicsSubmissions.empty()) { + std::sort(TranslucentGraphicsSubmissions.begin(), + TranslucentGraphicsSubmissions.end(), + [](const VisibleMeshSubmission &Left, + const VisibleMeshSubmission &Right) { + return Left.SortDepth > Right.SortDepth; + }); + RecordTranslucentGraphicsPass(Context, Viewport, Scissor, + TranslucentGraphicsSubmissions, + CameraBufferInfo, ForceWireframe); + } } } // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanSceneRenderer.h b/Axiom/Renderer/Vulkan/VulkanSceneRenderer.h index 83e4b979..cd961b76 100644 --- a/Axiom/Renderer/Vulkan/VulkanSceneRenderer.h +++ b/Axiom/Renderer/Vulkan/VulkanSceneRenderer.h @@ -37,6 +37,7 @@ class VulkanSceneRenderer { VkPipeline MeshPipeline{VK_NULL_HANDLE}; VkPipelineLayout MeshPipelineLayout{VK_NULL_HANDLE}; VkPipeline MeshGraphicsPipeline{VK_NULL_HANDLE}; + VkPipeline MeshGraphicsAlphaBlendPipeline{VK_NULL_HANDLE}; VkPipelineLayout MeshGraphicsPipelineLayout{VK_NULL_HANDLE}; VkPipeline MeshWireframePipeline{VK_NULL_HANDLE}; VkPipeline MeshDepthPipeline{VK_NULL_HANDLE}; @@ -53,6 +54,7 @@ class VulkanSceneRenderer { struct VisibleMeshSubmission { const RenderMeshSubmission *Submission{nullptr}; std::shared_ptr Mesh; + float SortDepth{0.0f}; }; struct CandidateSubmission { @@ -89,5 +91,10 @@ class VulkanSceneRenderer { const VkRect2D &Scissor, const std::vector &GraphicsSubmissions, const VkDescriptorBufferInfo &CameraBufferInfo, bool ForceWireframe); + static void RecordTranslucentGraphicsPass( + const RenderContext &Context, const VkViewport &Viewport, + const VkRect2D &Scissor, + const std::vector &GraphicsSubmissions, + const VkDescriptorBufferInfo &CameraBufferInfo, bool ForceWireframe); }; } // namespace Axiom diff --git a/Axiom/Scripting/InternalCalls.cpp b/Axiom/Scripting/InternalCalls.cpp index 6dbb4838..fc97f7b0 100644 --- a/Axiom/Scripting/InternalCalls.cpp +++ b/Axiom/Scripting/InternalCalls.cpp @@ -64,7 +64,7 @@ void GameObject_SetTransform(Coral::String ObjectId, if (!s_Session || !InTransform) return; std::string id = ObjectId; - CommandContext Ctx{s_SessionId, s_UserId}; + CommandContext Ctx{s_SessionId, s_UserId, 0, 0.0f, true}; SetTransformCommand Cmd{.ObjectId = std::move(id), .Location = InTransform->Location, .RotationDegrees = InTransform->RotationDegrees, diff --git a/Axiom/Scripting/ScriptHost.cpp b/Axiom/Scripting/ScriptHost.cpp index 429a9659..78df2082 100644 --- a/Axiom/Scripting/ScriptHost.cpp +++ b/Axiom/Scripting/ScriptHost.cpp @@ -13,6 +13,26 @@ namespace Axiom { +bool ScriptHost::IsSimulationRunning() const { + return m_Session != nullptr && + m_Session->GetRuntimeState() == EditorRuntimeState::Playing; +} + +void ScriptHost::InstantiateAllEligibleScripts() { +#if AXIOM_SCRIPTING_ENABLED + if (!m_UserAssemblyLoaded || m_Session == nullptr || !IsSimulationRunning()) { + return; + } + + for (const auto &[Id, Details] : m_Session->GetState().Scene.ObjectDetailsById) { + if (Details.Kind == EditorSceneItemKind::Actor && + Details.ScriptClass.has_value()) { + InstantiateScript(Id, *Details.ScriptClass); + } + } +#endif +} + ScriptHost::~ScriptHost() { if (m_Initialized) { Shutdown(); @@ -232,17 +252,7 @@ void ScriptHost::LoadUserAssembly(const std::filesystem::path &AssemblyPath) { m_UserAssemblyLoaded = true; A_CORE_INFO("ScriptHost: user assembly loaded ({})", Assembly.GetName()); - // Re-instantiate scripts for all existing Actors that already have a - // ScriptClass set (e.g. loaded from scene.json). - if (m_Session != nullptr) { - for (const auto &[Id, Details] : - m_Session->GetState().Scene.ObjectDetailsById) { - if (Details.Kind == EditorSceneItemKind::Actor && - Details.ScriptClass.has_value()) { - InstantiateScript(Id, *Details.ScriptClass); - } - } - } + InstantiateAllEligibleScripts(); #else (void)AssemblyPath; #endif @@ -321,15 +331,22 @@ void ScriptHost::ReloadUserAssembly() { m_UserAssemblyLoaded = true; A_CORE_INFO("ScriptHost: user assembly reloaded ({})", Assembly.GetName()); - // 6. Re-instantiate every script that existed before the reload - for (const auto &[ObjectId, ClassName] : ToReinstate) { - InstantiateScript(ObjectId, ClassName); + // 6. Re-instantiate every script that existed before the reload, but only + // while the session is actively simulating. + if (IsSimulationRunning()) { + for (const auto &[ObjectId, ClassName] : ToReinstate) { + InstantiateScript(ObjectId, ClassName); + } } #endif } void ScriptHost::Tick(float DeltaTimeSeconds) { #if AXIOM_SCRIPTING_ENABLED + if (!IsSimulationRunning()) { + return; + } + for (auto &[ObjectId, Instance] : m_ScriptInstances) { try { Instance.InvokeMethod("OnTick", DeltaTimeSeconds); @@ -352,7 +369,8 @@ void ScriptHost::OnEditorEvent(const PublishedEditorEvent &Event) { // A new object just appeared in the scene — if it's an Actor with a // ScriptClass already set (e.g. loaded from scene.json and immediately // re-created), instantiate the script. - if (m_UserAssemblyLoaded && m_Session != nullptr) { + if (m_UserAssemblyLoaded && m_Session != nullptr && + IsSimulationRunning()) { const auto *Details = m_Session->FindObjectDetails(Payload.ObjectId); if (Details != nullptr && @@ -365,13 +383,19 @@ void ScriptHost::OnEditorEvent(const PublishedEditorEvent &Event) { DestroyScript(Payload.ObjectId); } else if constexpr (std::is_same_v) { // AttachScript / DetachScript acknowledged - if (m_UserAssemblyLoaded) { + if (m_UserAssemblyLoaded && IsSimulationRunning()) { if (Payload.ScriptClass.has_value()) { InstantiateScript(Payload.ObjectId, *Payload.ScriptClass); } else { DestroyScript(Payload.ObjectId); } } + } else if constexpr (std::is_same_v) { + if (Payload.State == EditorRuntimeState::Playing) { + InstantiateAllEligibleScripts(); + } else if (Payload.State == EditorRuntimeState::Edit) { + DestroyAllScripts(); + } } }, Event.Event.Payload); diff --git a/Axiom/Scripting/ScriptHost.h b/Axiom/Scripting/ScriptHost.h index c0530d87..feb527ce 100644 --- a/Axiom/Scripting/ScriptHost.h +++ b/Axiom/Scripting/ScriptHost.h @@ -99,6 +99,9 @@ class ScriptHost final : public IEditorEventSubscriber { #endif private: + bool IsSimulationRunning() const; + void InstantiateAllEligibleScripts(); + // Instantiate a C# script class for the given objectId and call OnCreate(). // Any existing instance for that objectId is destroyed first. void InstantiateScript(const std::string &ObjectId, diff --git a/Axiom/Session/EditorCommand.h b/Axiom/Session/EditorCommand.h index c9db32c3..97da06e5 100644 --- a/Axiom/Session/EditorCommand.h +++ b/Axiom/Session/EditorCommand.h @@ -17,6 +17,7 @@ struct CommandContext { SessionUserId User; uint64_t FrameIndex{0}; float DeltaTimeSeconds{0.0f}; + bool IsScriptContext{false}; }; struct UpdateViewportCameraCommand { @@ -114,6 +115,19 @@ struct SetMaterialTextureCommand { std::string TextureAssetPath; }; +struct SetPhysicsPropertiesCommand { + std::string ObjectId; + EditorPhysicsProperties Physics; +}; + +struct PlaySessionCommand {}; + +struct PauseSessionCommand {}; + +struct ResumeSessionCommand {}; + +struct StopSessionCommand {}; + using EditorCommandPayload = std::variant; + SetMaterialTextureCommand, SetPhysicsPropertiesCommand, + PlaySessionCommand, + PauseSessionCommand, ResumeSessionCommand, + StopSessionCommand>; struct EditorCommand { EditorCommandPayload Payload; diff --git a/Axiom/Session/EditorEvent.h b/Axiom/Session/EditorEvent.h index 888fd39c..e06f029c 100644 --- a/Axiom/Session/EditorEvent.h +++ b/Axiom/Session/EditorEvent.h @@ -123,6 +123,16 @@ struct MaterialTextureChangedEvent { std::string TextureAssetPath; // empty = cleared back to mesh asset's embedded texture }; +struct PhysicsPropertiesChangedEvent { + std::string ObjectId; + EditorPhysicsProperties Physics; +}; + +struct RuntimeStateChangedEvent { + SessionUserId User; + EditorRuntimeState State{EditorRuntimeState::Edit}; +}; + using EditorEventPayload = std::variant; + MaterialTextureChangedEvent, + PhysicsPropertiesChangedEvent, + RuntimeStateChangedEvent>; struct EditorEvent { EditorEventPayload Payload; diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index 304c9541..584533ee 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -2,6 +2,7 @@ #include "Assets/AssetCooker.h" #include "Assets/MeshAsset.h" +#include "Physics/PhysicsWorld.h" #include @@ -12,6 +13,7 @@ #include #include +#include #include namespace Axiom { @@ -62,6 +64,66 @@ std::string PresenceStateName(EditorUserPresenceState State) { return "connected"; } +bool IsHostUser(SessionUserId User) { return User.Value == 1; } + +glm::vec3 AbsVec3(const glm::vec3 &Value) { + return glm::vec3(std::abs(Value.x), std::abs(Value.y), std::abs(Value.z)); +} + +void ExpandBounds(const glm::vec3 &BoundsMin, const glm::vec3 &BoundsMax, + const glm::mat4 &Transform, glm::vec3 &OutMin, + glm::vec3 &OutMax, bool &HasBounds) { + const std::array Corners = { + glm::vec3(BoundsMin.x, BoundsMin.y, BoundsMin.z), + glm::vec3(BoundsMax.x, BoundsMin.y, BoundsMin.z), + glm::vec3(BoundsMin.x, BoundsMax.y, BoundsMin.z), + glm::vec3(BoundsMax.x, BoundsMax.y, BoundsMin.z), + glm::vec3(BoundsMin.x, BoundsMin.y, BoundsMax.z), + glm::vec3(BoundsMax.x, BoundsMin.y, BoundsMax.z), + glm::vec3(BoundsMin.x, BoundsMax.y, BoundsMax.z), + glm::vec3(BoundsMax.x, BoundsMax.y, BoundsMax.z), + }; + + for (const glm::vec3 &Corner : Corners) { + const glm::vec3 WorldCorner = glm::vec3(Transform * glm::vec4(Corner, 1.0f)); + if (!HasBounds) { + OutMin = WorldCorner; + OutMax = WorldCorner; + HasBounds = true; + continue; + } + OutMin = glm::min(OutMin, WorldCorner); + OutMax = glm::max(OutMax, WorldCorner); + } +} + +std::optional +BuildDefaultStaticMeshPhysics(const MeshSceneData &SceneData, + const EditorTransformDetails &RootTransform) { + glm::vec3 CombinedMin(0.0f); + glm::vec3 CombinedMax(0.0f); + bool HasBounds = false; + + for (const auto &Instance : SceneData.Instances) { + ExpandBounds(Instance.Mesh.BoundsMin, Instance.Mesh.BoundsMax, + Instance.Transform, CombinedMin, CombinedMax, HasBounds); + } + + if (!HasBounds) { + return std::nullopt; + } + + glm::vec3 HalfExtents = glm::max(glm::abs(CombinedMin), glm::abs(CombinedMax)); + HalfExtents *= AbsVec3(RootTransform.Scale); + HalfExtents = glm::max(HalfExtents, glm::vec3(0.01f)); + + return EditorPhysicsProperties{ + .BodyType = EditorPhysicsBodyType::Static, + .ColliderType = EditorPhysicsColliderType::Box, + .BoxHalfExtents = HalfExtents, + }; +} + std::string DefaultPresentationColor(SessionUserId User) { static constexpr const char *Palette[] = { "#F97316", "#22C55E", "#0EA5E9", "#F59E0B", @@ -122,9 +184,42 @@ std::string CommandTypeName(const EditorCommandPayload &Payload) { if (std::holds_alternative(Payload)) { return "set_material_texture"; } + if (std::holds_alternative(Payload)) { + return "set_physics_properties"; + } + if (std::holds_alternative(Payload)) { + return "play_session"; + } + if (std::holds_alternative(Payload)) { + return "pause_session"; + } + if (std::holds_alternative(Payload)) { + return "resume_session"; + } + if (std::holds_alternative(Payload)) { + return "stop_session"; + } return "set_transform"; } +bool IsAuthoringMutationCommand(const EditorCommandPayload &Payload) { + return std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload) || + std::holds_alternative(Payload); +} + EditorSceneItemKind KindForClassName(std::string_view ClassName) { if (ClassName == "SceneMeshObject") return EditorSceneItemKind::Mesh; if (ClassName == "SceneLight") return EditorSceneItemKind::Light; @@ -173,6 +268,10 @@ bool IsNearlyZero(const glm::vec3 &Value) { return glm::dot(Value, Value) <= 0.0f; } +bool IsPositive(const glm::vec3 &Value) { + return Value.x > 0.0f && Value.y > 0.0f && Value.z > 0.0f; +} + bool IsWhitespace(char Value) { return Value == ' ' || Value == '\t' || Value == '\n' || Value == '\r' || Value == '\f' || Value == '\v'; @@ -228,6 +327,41 @@ glm::mat4 BuildTransformMatrix(const EditorTransformDetails &Transform) { Matrix = glm::scale(Matrix, Transform.Scale); return Matrix; } + +TextureSourceDataRef CloneTextureSourceData( + const TextureSourceDataRef &Texture) { + if (!Texture) { + return nullptr; + } + + auto Copy = std::make_shared(); + Copy->Width = Texture->Width; + Copy->Height = Texture->Height; + Copy->Pixels = Texture->Pixels; + return Copy; +} + +MaterialInstanceRef CloneMaterialInstance(const MaterialInstanceRef &Material) { + if (!Material) { + return nullptr; + } + + auto Copy = std::make_shared(); + Copy->BaseColorTexture = CloneTextureSourceData(Material->BaseColorTexture); + Copy->BaseColorFactor = Material->BaseColorFactor; + Copy->Metallic = Material->Metallic; + Copy->Roughness = Material->Roughness; + Copy->TextureAssetPath = Material->TextureAssetPath; + return Copy; +} + +EditorSceneState CloneEditorSceneState(const EditorSceneState &Scene) { + EditorSceneState Copy = Scene; + for (auto &MeshInstance : Copy.MeshInstances) { + MeshInstance.Material = CloneMaterialInstance(MeshInstance.Material); + } + return Copy; +} } // namespace EditorSession::EditorSession(SessionId Session, EditorSessionConfig Config) @@ -235,16 +369,21 @@ EditorSession::EditorSession(SessionId Session, EditorSessionConfig Config) InitSceneRoot(); } +EditorSession::~EditorSession() = default; +EditorSession::EditorSession(EditorSession &&) noexcept = default; +EditorSession &EditorSession::operator=(EditorSession &&) noexcept = default; + void EditorSession::Submit(const CommandContext &Context, const EditorCommand &Command) { m_MessageBus.EnqueueCommand(Context, Command); } -void EditorSession::Tick() { +void EditorSession::Tick(float DeltaTimeSeconds) { m_MessageBus.DispatchQueuedCommands( [this](const QueuedEditorCommand &QueuedCommand) { ProcessCommand(QueuedCommand); }); + StepRuntimePhysics(DeltaTimeSeconds); } void EditorSession::Subscribe(IEditorEventSubscriber *Subscriber) { @@ -405,6 +544,30 @@ std::vector EditorSession::BuildParticipants( return Participants; } +SessionUserId EditorSession::ResolveRuntimeControllerUser() const { + if (m_State.RuntimeControllerUser.has_value()) { + const SessionUserId User = *m_State.RuntimeControllerUser; + if (const EditorUserPresence *Presence = FindPresence(User); + Presence != nullptr && + Presence->State != EditorUserPresenceState::Disconnected) { + return User; + } + } + + std::optional Candidate; + for (const auto &[User, Presence] : m_State.PresenceByUser) { + if (Presence.State == EditorUserPresenceState::Disconnected || + IsHostUser(User)) { + continue; + } + if (!Candidate.has_value() || User.Value < Candidate->Value) { + Candidate = User; + } + } + + return Candidate.value_or(SessionUserId{1}); +} + const EditorObjectCollaborationState *EditorSession::FindCollaborationState( std::string_view ObjectId) const { const auto It = @@ -748,6 +911,11 @@ void EditorSession::ExpandMeshAssetIntoScene(std::string_view RootObjectId, RootDetails.IsGeneratedAssetChild = false; RootDetails.GeneratedFromAssetRootId = std::nullopt; RootDetails.AssetRelativePath = std::string(AssetPath); + if (!RootDetails.Physics.has_value()) { + const EditorTransformDetails RootTransform = + RootDetails.Transform.value_or(EditorTransformDetails{}); + RootDetails.Physics = BuildDefaultStaticMeshPhysics(SceneData, RootTransform); + } if (SceneData.Instances.size() == 1) { const auto &First = SceneData.Instances.front(); @@ -1055,6 +1223,24 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, return false; } + if ((std::holds_alternative(QueuedCommand.Command.Payload) || + std::holds_alternative(QueuedCommand.Command.Payload) || + std::holds_alternative(QueuedCommand.Command.Payload) || + std::holds_alternative(QueuedCommand.Command.Payload)) && + QueuedCommand.Context.User.Value != ResolveRuntimeControllerUser().Value) { + FailureReason = + "Only the current simulation host can control simulation state."; + return false; + } + + if (!QueuedCommand.Context.IsScriptContext && + m_State.RuntimeState != EditorRuntimeState::Edit && + IsAuthoringMutationCommand(QueuedCommand.Command.Payload)) { + FailureReason = + "Authoring edits are disabled while shared simulation is active."; + return false; + } + const EditorViewportState &Viewport = const_cast(this)->EnsureViewport(QueuedCommand.Context.User); @@ -1093,6 +1279,8 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, SingleId = C->ObjectId; else if (const auto *C = std::get_if(&QueuedCommand.Command.Payload)) SingleId = C->ObjectId; + else if (const auto *C = std::get_if(&QueuedCommand.Command.Payload)) + SingleId = C->ObjectId; if (!SingleId.empty()) { const auto CollabIt = m_State.Scene.CollaborationByObjectId.find(SingleId); if (CollabIt != m_State.Scene.CollaborationByObjectId.end() && @@ -1132,6 +1320,47 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, } } + if (const auto *PhysicsCommand = + std::get_if(&QueuedCommand.Command.Payload)) { + if (PhysicsCommand->ObjectId.empty()) { + FailureReason = "Physics commands require a non-empty object id."; + return false; + } + + const EditorObjectDetails *Details = FindObjectDetails(PhysicsCommand->ObjectId); + if (Details == nullptr) { + FailureReason = "Physics targeted an unknown object."; + return false; + } + if (!Details->SupportsTransform) { + FailureReason = "Physics can only be assigned to transformable objects."; + return false; + } + if (PhysicsCommand->Physics.BodyType == EditorPhysicsBodyType::Dynamic && + PhysicsCommand->Physics.Mass <= 0.0f) { + FailureReason = "Dynamic physics bodies require a positive mass."; + return false; + } + if (PhysicsCommand->Physics.ColliderType == EditorPhysicsColliderType::Box && + !IsPositive(PhysicsCommand->Physics.BoxHalfExtents)) { + FailureReason = "Box colliders require positive half extents."; + return false; + } + if (PhysicsCommand->Physics.ColliderType == EditorPhysicsColliderType::Sphere && + PhysicsCommand->Physics.SphereRadius <= 0.0f) { + FailureReason = "Sphere colliders require a positive radius."; + return false; + } + if (PhysicsCommand->Physics.Friction < 0.0f) { + FailureReason = "Physics friction must be zero or greater."; + return false; + } + if (PhysicsCommand->Physics.Restitution < 0.0f) { + FailureReason = "Physics restitution must be zero or greater."; + return false; + } + } + if (const auto *RenameCommand = std::get_if(&QueuedCommand.Command.Payload)) { if (RenameCommand->ObjectId.empty()) { @@ -1360,6 +1589,34 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, } } + if (std::holds_alternative(QueuedCommand.Command.Payload)) { + if (m_State.RuntimeState != EditorRuntimeState::Edit) { + FailureReason = "PlaySession is only valid while in edit mode."; + return false; + } + } + + if (std::holds_alternative(QueuedCommand.Command.Payload)) { + if (m_State.RuntimeState != EditorRuntimeState::Playing) { + FailureReason = "PauseSession is only valid while playing."; + return false; + } + } + + if (std::holds_alternative(QueuedCommand.Command.Payload)) { + if (m_State.RuntimeState != EditorRuntimeState::Paused) { + FailureReason = "ResumeSession is only valid while paused."; + return false; + } + } + + if (std::holds_alternative(QueuedCommand.Command.Payload)) { + if (m_State.RuntimeState == EditorRuntimeState::Edit) { + FailureReason = "StopSession is only valid while simulation is active."; + return false; + } + } + return true; } @@ -1648,21 +1905,29 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, const SetTransformCommand &Command) { EnsurePresence(QueuedCommand.Context.User); - auto DetailsIt = m_State.Scene.ObjectDetailsById.find(Command.ObjectId); + ApplyWorldTransform( + Command.ObjectId, + EditorTransformDetails{ + .Location = Command.Location, + .RotationDegrees = Command.RotationDegrees, + .Scale = Command.Scale, + }, + QueuedCommand.Context.User, true); +} + +void EditorSession::ApplyWorldTransform(std::string_view ObjectId, + const EditorTransformDetails &WorldTD, + SessionUserId User, + bool ShouldPublish) { + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(std::string(ObjectId)); if (DetailsIt == m_State.Scene.ObjectDetailsById.end()) { return; } - const EditorTransformDetails WorldTD{ - .Location = Command.Location, - .RotationDegrees = Command.RotationDegrees, - .Scale = Command.Scale, - }; const glm::mat4 WorldMatrix = BuildTransformMatrix(WorldTD); - // Convert world-space command to local-space for storage EditorTransformDetails LocalTD = WorldTD; - const Instance *Node = FindInstanceById(m_SceneRoot.get(), Command.ObjectId); + const Instance *Node = FindInstanceById(m_SceneRoot.get(), ObjectId); if (Node && Node->GetParent() && Node->GetParent() != m_SceneRoot.get()) { const glm::mat4 ParentWorld = ComputeWorldTransformMatrix(Node->GetParent()); LocalTD = DecomposeMatrix(glm::inverse(ParentWorld) * WorldMatrix); @@ -1672,25 +1937,27 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, DetailsIt->second.WorldTransform = WorldTD; for (EditorSceneMeshInstance &Inst : m_State.Scene.MeshInstances) { - if (Inst.ObjectId == Command.ObjectId) { + if (Inst.ObjectId == ObjectId) { Inst.Transform = WorldMatrix; break; } } - // Propagate to children whose world positions depend on this object if (Node) { - for (const Instance *Child : Node->GetChildren()) + for (const Instance *Child : Node->GetChildren()) { RecomputeSubtreeWorldTransforms(Child); + } } - PublishEvent({.Payload = ObjectTransformUpdatedEvent{ - .User = QueuedCommand.Context.User, - .ObjectId = Command.ObjectId, - .Location = Command.Location, - .RotationDegrees = Command.RotationDegrees, - .Scale = Command.Scale, - }}); + if (ShouldPublish) { + PublishEvent({.Payload = ObjectTransformUpdatedEvent{ + .User = User, + .ObjectId = std::string(ObjectId), + .Location = WorldTD.Location, + .RotationDegrees = WorldTD.RotationDegrees, + .Scale = WorldTD.Scale, + }}); + } } void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, @@ -1857,6 +2124,122 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, }}); } +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetPhysicsPropertiesCommand &Command) { + (void)QueuedCommand; + + auto DetailsIt = m_State.Scene.ObjectDetailsById.find(Command.ObjectId); + if (DetailsIt == m_State.Scene.ObjectDetailsById.end()) { + return; + } + + DetailsIt->second.Physics = Command.Physics; + if (Command.Physics.BodyType == EditorPhysicsBodyType::None && + Command.Physics.ColliderType == EditorPhysicsColliderType::None) { + DetailsIt->second.Physics.reset(); + } + + PublishEvent({.Payload = PhysicsPropertiesChangedEvent{ + .ObjectId = Command.ObjectId, + .Physics = DetailsIt->second.Physics.value_or(EditorPhysicsProperties{}), + }}); +} + +void EditorSession::EnsurePhysicsWorldStarted() { + if (m_PhysicsWorld == nullptr) { + m_PhysicsWorld = std::make_unique(); + } + if (!m_PhysicsWorld->IsAvailable()) { + A_CORE_WARN("EditorSession: physics requested but backend is unavailable"); + return; + } + m_PhysicsWorld->Start(m_State.Scene); +} + +void EditorSession::StopPhysicsWorld() { + if (m_PhysicsWorld != nullptr) { + m_PhysicsWorld->Stop(); + } +} + +void EditorSession::StepRuntimePhysics(float DeltaTimeSeconds) { + if (m_State.RuntimeState != EditorRuntimeState::Playing || + m_PhysicsWorld == nullptr || !m_PhysicsWorld->IsRunning()) { + return; + } + + for (const PhysicsTransformUpdate &Update : m_PhysicsWorld->Step(DeltaTimeSeconds)) { + const EditorObjectDetails *Existing = FindObjectDetails(Update.ObjectId); + if (Existing == nullptr) { + continue; + } + + EditorTransformDetails Applied = Update.WorldTransform; + if (Existing->WorldTransform.has_value()) { + Applied.Scale = Existing->WorldTransform->Scale; + } else if (Existing->Transform.has_value()) { + Applied.Scale = Existing->Transform->Scale; + } + ApplyWorldTransform(Update.ObjectId, Applied, SessionUserId{1}, true); + } +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const PlaySessionCommand &Command) { + (void)Command; + EnsurePresence(QueuedCommand.Context.User); + m_RuntimeSceneSnapshot = RuntimeSceneSnapshot{ + .Scene = CloneEditorSceneState(m_State.Scene), + .SelectedObjectIds = m_State.SelectedObjectIds, + }; + m_State.RuntimeState = EditorRuntimeState::Playing; + EnsurePhysicsWorldStarted(); + PublishEvent({.Payload = RuntimeStateChangedEvent{ + .User = QueuedCommand.Context.User, + .State = m_State.RuntimeState, + }}); +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const PauseSessionCommand &Command) { + (void)Command; + EnsurePresence(QueuedCommand.Context.User); + m_State.RuntimeState = EditorRuntimeState::Paused; + PublishEvent({.Payload = RuntimeStateChangedEvent{ + .User = QueuedCommand.Context.User, + .State = m_State.RuntimeState, + }}); +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const ResumeSessionCommand &Command) { + (void)Command; + EnsurePresence(QueuedCommand.Context.User); + m_State.RuntimeState = EditorRuntimeState::Playing; + PublishEvent({.Payload = RuntimeStateChangedEvent{ + .User = QueuedCommand.Context.User, + .State = m_State.RuntimeState, + }}); +} + +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const StopSessionCommand &Command) { + (void)Command; + EnsurePresence(QueuedCommand.Context.User); + StopPhysicsWorld(); + if (m_RuntimeSceneSnapshot.has_value()) { + SetSceneState(std::move(m_RuntimeSceneSnapshot->Scene)); + m_State.SelectedObjectIds = std::move(m_RuntimeSceneSnapshot->SelectedObjectIds); + PruneInvalidSelections(); + m_RuntimeSceneSnapshot.reset(); + } + m_State.RuntimeState = EditorRuntimeState::Edit; + PublishEvent({.Payload = RuntimeStateChangedEvent{ + .User = QueuedCommand.Context.User, + .State = m_State.RuntimeState, + }}); +} + void EditorSession::SetContentDir(std::filesystem::path ContentDir) { m_ContentDir = std::move(ContentDir); } diff --git a/Axiom/Session/EditorSession.h b/Axiom/Session/EditorSession.h index 1c44eb86..49919cce 100644 --- a/Axiom/Session/EditorSession.h +++ b/Axiom/Session/EditorSession.h @@ -19,6 +19,8 @@ #include namespace Axiom { +class PhysicsWorld; + struct EditorSessionConfig { glm::vec3 InitialCameraPosition{0.0f, 0.8f, 3.5f}; glm::vec3 InitialCameraTarget{0.0f, 0.3f, 0.0f}; @@ -79,6 +81,7 @@ struct EditorObjectDetails { std::optional ScriptClass; // C# script class name (Actor objects only) std::optional Light; // Light objects only std::optional Material; // Mesh objects only + std::optional Physics; std::optional GeneratedFromAssetRootId; std::string AssetRelativePath; // content-relative path when assigned directly to this object }; @@ -134,6 +137,8 @@ struct EditorSceneState { struct EditorSessionState { SessionId Session; + EditorRuntimeState RuntimeState{EditorRuntimeState::Edit}; + std::optional RuntimeControllerUser; std::unordered_map Viewports; std::unordered_map @@ -147,10 +152,15 @@ class EditorSession final : public IEditorCommandSink { public: EditorSession(SessionId Session, EditorSessionConfig Config = EditorSessionConfig{}); + ~EditorSession(); + EditorSession(const EditorSession &) = delete; + EditorSession &operator=(const EditorSession &) = delete; + EditorSession(EditorSession &&) noexcept; + EditorSession &operator=(EditorSession &&) noexcept; void Submit(const CommandContext &Context, const EditorCommand &Command) override; - void Tick(); + void Tick(float DeltaTimeSeconds = 1.0f / 60.0f); void Subscribe(IEditorEventSubscriber *Subscriber); void Unsubscribe(IEditorEventSubscriber *Subscriber); @@ -181,8 +191,10 @@ class EditorSession final : public IEditorCommandSink { const EditorUserPresence *FindPresence(SessionUserId User) const; EditorParticipant BuildParticipant(SessionUserId User) const; std::vector BuildParticipants(SessionUserId CurrentUser) const; + SessionUserId ResolveRuntimeControllerUser() const; const EditorObjectCollaborationState *FindCollaborationState( std::string_view ObjectId) const; + EditorRuntimeState GetRuntimeState() const { return m_State.RuntimeState; } void AcquireLock(const std::string &ObjectId, SessionUserId User); void ReleaseLock(const std::string &ObjectId, SessionUserId User); @@ -276,13 +288,37 @@ class EditorSession final : public IEditorCommandSink { const SetMaterialPropertiesCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const SetMaterialTextureCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetPhysicsPropertiesCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const PlaySessionCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const PauseSessionCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const ResumeSessionCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const StopSessionCommand &Command); + void ApplyWorldTransform(std::string_view ObjectId, + const EditorTransformDetails &WorldTransform, + SessionUserId User, bool PublishEvent); + void EnsurePhysicsWorldStarted(); + void StopPhysicsWorld(); + void StepRuntimePhysics(float DeltaTimeSeconds); void PublishEvent(const EditorEvent &Event); private: + struct RuntimeSceneSnapshot { + EditorSceneState Scene; + std::unordered_map + SelectedObjectIds; + }; + EditorSessionConfig m_Config; EditorSessionState m_State; EditorMessageBus m_MessageBus; std::unique_ptr m_SceneRoot; std::filesystem::path m_ContentDir; + std::optional m_RuntimeSceneSnapshot; + std::unique_ptr m_PhysicsWorld; }; } // namespace Axiom diff --git a/Axiom/Session/SessionTypes.h b/Axiom/Session/SessionTypes.h index bdbf3b9f..17dc567b 100644 --- a/Axiom/Session/SessionTypes.h +++ b/Axiom/Session/SessionTypes.h @@ -4,6 +4,8 @@ #include #include +#include + namespace Axiom { struct CommandId { uint64_t Value{0}; @@ -43,4 +45,20 @@ struct AssetIdHash { }; enum class EditorObjectLockState { Unlocked, Locked }; + +enum class EditorRuntimeState { Edit, Playing, Paused }; + +enum class EditorPhysicsBodyType { None, Static, Dynamic }; + +enum class EditorPhysicsColliderType { None, Box, Sphere }; + +struct EditorPhysicsProperties { + EditorPhysicsBodyType BodyType{EditorPhysicsBodyType::None}; + EditorPhysicsColliderType ColliderType{EditorPhysicsColliderType::None}; + glm::vec3 BoxHalfExtents{0.5f, 0.5f, 0.5f}; + float SphereRadius{0.5f}; + float Mass{1.0f}; + float Friction{0.2f}; + float Restitution{0.0f}; +}; } // namespace Axiom diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f203052..285c5957 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,8 @@ set(AXIOM_SCRIPTING_TRUST_DEFAULT "Restricted" CACHE STRING set_property(CACHE AXIOM_SCRIPTING_TRUST_DEFAULT PROPERTY STRINGS "Restricted" "Trusted") option(AXIOM_ENABLE_WEBRTC "Enable the macOS WebRTC transport integration seam" OFF) +option(AXIOM_ENABLE_PHYSICS + "Enable the JoltPhysics runtime simulation seam" ON) set(AXIOM_WEBRTC_FRAMEWORK_PATH "" CACHE PATH "Optional path to a locally built WebRTC.framework for macOS WebRTC integration") set(AXIOM_WEBRTC_LIBRARY_PATH "" CACHE FILEPATH @@ -45,6 +47,28 @@ set(ASSIMP_INSTALL OFF CACHE BOOL "" FORCE) set(ASSIMP_INJECT_DEBUG_POSTFIX OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(assimp) +if(AXIOM_ENABLE_PHYSICS) + set(TARGET_UNIT_TESTS OFF CACHE BOOL "" FORCE) + set(TARGET_HELLO_WORLD OFF CACHE BOOL "" FORCE) + set(TARGET_SAMPLES OFF CACHE BOOL "" FORCE) + set(TARGET_PERFORMANCE_TEST OFF CACHE BOOL "" FORCE) + set(TARGET_VIEWER OFF CACHE BOOL "" FORCE) + set(TARGET_TEST_FRAMEWORK OFF CACHE BOOL "" FORCE) + FetchContent_Declare( + JoltPhysics + GIT_REPOSITORY https://github.com/jrouwe/JoltPhysics.git + GIT_TAG v5.5.0 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(JoltPhysics) + FetchContent_GetProperties(JoltPhysics + SOURCE_DIR AXIOM_JOLT_SOURCE_DIR + BINARY_DIR AXIOM_JOLT_BINARY_DIR) + if(NOT TARGET Jolt) + add_subdirectory("${AXIOM_JOLT_SOURCE_DIR}/Build" "${AXIOM_JOLT_BINARY_DIR}") + endif() +endif() + if(AXIOM_ENABLE_SCRIPTING) include(cmake/CoralNative.cmake) endif() diff --git a/Content/Cooked/AssetCookManifest.json b/Content/Cooked/AssetCookManifest.json index 4ef95a0c..1142f3c4 100644 --- a/Content/Cooked/AssetCookManifest.json +++ b/Content/Cooked/AssetCookManifest.json @@ -14,7 +14,7 @@ {"assetId":8379936892125882364,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__85","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__85.wtex","formatVersion":1,"sourceHash":1136906439044749114}, {"assetId":17615425434727149779,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__85","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__85.wmat","formatVersion":1,"sourceHash":15021837972009510944}, {"assetId":5370529907106783966,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":3307378166964972138,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat","formatVersion":1,"sourceHash":713712224417849749}, + {"assetId":3307378166964972138,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat","formatVersion":1,"sourceHash":16414512736408610493}, {"assetId":11435685726260663774,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__87","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__87.wtex","formatVersion":1,"sourceHash":1136906439044749114}, {"assetId":1601912636468888724,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__87","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__87.wmat","formatVersion":1,"sourceHash":2836131640602896348}, {"assetId":1914623039437072610,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__88","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__88.wtex","formatVersion":1,"sourceHash":1136906439044749114}, diff --git a/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat b/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat index 85e1cc22..7a0a03a6 100644 Binary files a/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat and b/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat differ diff --git a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex index ea084279..46b064ee 100644 Binary files a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex and b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex differ diff --git a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex index e0708d00..f2edcc3c 100644 Binary files a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex and b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex differ diff --git a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex index 3252ed36..bd2e1d0c 100644 Binary files a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex and b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex differ diff --git a/Content/Cooked/sponza_atrium_3.wmesh b/Content/Cooked/sponza_atrium_3.wmesh index ddb2b67f..37339303 100644 Binary files a/Content/Cooked/sponza_atrium_3.wmesh and b/Content/Cooked/sponza_atrium_3.wmesh differ diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index 5a45224b..1a60c79c 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -75,6 +75,14 @@ - `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) +- Shared simulation runtime controls are now implemented: `PlaySession`, `PauseSession`, `ResumeSession`, and `StopSession` flow through the authoritative command/event path, the browser toolbar is live, and `Stop` restores the exact pre-play edit snapshot +- Hosted simulation is session-wide, not per-user: all collaborators observe the same `Edit` / `Playing` / `Paused` state, and authoring mutations are rejected while simulation is active +- Simulation control authority is now explicit and separate from the headless renderer's reserved local render user: the first connected browser collaborator becomes the `runtimeControllerUserId`, while the headless app keeps `SessionUserId{1}` for local rendering +- Scripts now sit behind the runtime boundary: they instantiate and tick only while playing, freeze while paused, and tear down on stop +- Jolt physics is now integrated as a runtime-only service that is created on play, stepped only while playing, frozen while paused, and destroyed on stop +- Physics authoring is now available through authoritative scene details: body type, collider type, box half extents, sphere radius, mass, friction, and restitution persist through save/load +- Imported mesh assets now default to static box collision that covers the authored mesh bounds; older scenes are migrated to that default on load only when the mesh had no authored physics yet +- Generated mesh children from multi-mesh imports remain read-only and inherit physics authoring from their imported root mesh object; the browser inspector now surfaces that inheritance instead of hiding physics entirely ## 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: diff --git a/EditorFrontend/components/engine/details.tsx b/EditorFrontend/components/engine/details.tsx index 4dadf3e7..ae91865f 100644 --- a/EditorFrontend/components/engine/details.tsx +++ b/EditorFrontend/components/engine/details.tsx @@ -79,7 +79,7 @@ function DetailsContent({ details: SessionObjectDetails schema: SessionObjectSchema | null }) { - const { participants, setProperty, updateTransform } = useRemoteViewport() + const { participants, runtimeState, setProperty, updateTransform } = useRemoteViewport() const [draftName, setDraftName] = useState(details.displayName) const [draft, setDraft] = useState(() => toDraft(details)) const [isSaving, setIsSaving] = useState(false) @@ -110,6 +110,8 @@ function DetailsContent({ (schemaTransformReadOnly !== null ? !schemaTransformReadOnly : !details.capabilities.transformReadOnly) + const simulationActive = runtimeState !== "edit" + const canAuthor = !simulationActive const selectedByNames = details.collaboration.selectedByUserIds.map((userId) => { const collaborator = participants.find((entry) => entry.userId === userId) return collaborator?.displayName ?? fallbackUserLabel(userId) @@ -166,7 +168,7 @@ function DetailsContent({
setDraftName(event.target.value)} type="text" value={draftName} @@ -182,10 +184,14 @@ function DetailsContent({ /> +
+
+ ) : null} + {colliderType === "sphere" ? ( + + ) : null} + {colliderType === "sphere" ? ( +
+ +
+ ) : null} + {bodyType === "dynamic" ? ( + + ) : null} + {bodyType === "dynamic" ? ( +
+ +
+ ) : null} + +
+ +
+ +
+ +
+ {isReadOnly ? ( +

+ Physics values are read-only for this object. +

+ ) : null} +
+ + ) +} + function DetailRow({ label, value }: { label: string; value: string }) { return (
@@ -739,6 +1043,64 @@ function toDraft(details: SessionObjectDetails): DraftTransform { } } +function LabeledSelect({ + label, + value, + options, + onChange, + disabled, +}: { + label: string + value: string + options: Array<{ label: string; value: string }> + onChange: (value: string) => void + disabled?: boolean +}) { + return ( +
+ {label} + +
+ ) +} + +function LabeledNumberInput({ + label, + value, + onChange, + disabled, +}: { + label: string + value: string + onChange: (value: string) => void + disabled?: boolean +}) { + return ( +
+ {label} + onChange(event.target.value)} + step={0.1} + type="number" + value={value} + /> +
+ ) +} + function toStringVec3(value: [number, number, number]): [string, string, string] { return [String(value[0]), String(value[1]), String(value[2])] } diff --git a/EditorFrontend/components/engine/project-browser.tsx b/EditorFrontend/components/engine/project-browser.tsx index 1a2334c4..3b77f504 100644 --- a/EditorFrontend/components/engine/project-browser.tsx +++ b/EditorFrontend/components/engine/project-browser.tsx @@ -1,6 +1,6 @@ "use client" -import { FolderOpen, Loader2, Plus, RefreshCw, Sparkles } from "lucide-react" +import { FolderOpen, Loader2, Plus, RefreshCw, Search, SlidersHorizontal, X } from "lucide-react" import { useMemo, useState } from "react" export interface ProjectDescriptor { @@ -49,210 +49,243 @@ export function ProjectBrowser({ loading, busy, error, - serverOrigin, canClose, onClose, onRefresh, onOpenProject, onCreateProject, }: ProjectBrowserProps) { - const [projectName, setProjectName] = useState("") + const [filter, setFilter] = useState("") + const [selected, setSelected] = useState(activeProject ?? null) + const [showCreate, setShowCreate] = useState(false) + const [newProjectName, setNewProjectName] = useState("") + const sortedProjects = useMemo( - () => [...projects].sort((left, right) => left.name.localeCompare(right.name)), - [projects] + () => + [...projects] + .sort((a, b) => a.name.localeCompare(b.name)) + .filter((p) => p.name.toLowerCase().includes(filter.toLowerCase())), + [projects, filter] ) async function handleCreateProject() { - const trimmed = projectName.trim() + const trimmed = newProjectName.trim() if (!trimmed) return await onCreateProject(trimmed) - setProjectName("") + setNewProjectName("") + setShowCreate(false) + } + + async function handleOpen() { + if (!selected) return + await onOpenProject(selected.slug) } + const selectedIsActive = selected?.slug === activeProject?.slug + return ( -
-
-
-
-
-
- - Project Workspace -
- {canClose ? ( - - ) : null} -
+ // Backdrop +
+ {/* Dialog */} +
-

- Choose the project that owns this editor session. -

-

- New projects get their own content root and scene file. Shared engine assets stay - available from the global engine content directory. -

+ {/* Title bar */} +
+ Open Project + {canClose && ( + + )} +
-
-
-
-

- Create Project -

-

- Start a clean workspace under the managed host projects root. -

-
- -
+ {/* Search + actions bar */} +
+
+ + setFilter(e.target.value)} + type="text" + /> +
+ + +
-
- setProjectName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - void handleCreateProject() - } - }} - placeholder="Project name" - type="text" - value={projectName} - /> - -
+ {/* Project thumbnail grid */} +
+ {sortedProjects.length === 0 && !showCreate ? ( +
+ +

No projects found

+

+ {filter ? "Try a different filter." : "Create a new project to get started."} +

+ ) : ( +
+ {/* New project card */} + {showCreate ? ( +
+
+ +
+ setNewProjectName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateProject() + if (e.key === "Escape") { setShowCreate(false); setNewProjectName("") } + }} + type="text" + /> +
+ + +
+
+ ) : null} - {error ? ( -
- {error} -
- ) : null} -
+ {sortedProjects.map((project) => { + const isActive = activeProject?.slug === project.slug + const isSelected = selected?.slug === project.slug + return ( + + ) + })}
-
- Shared Engine - - {activeProject?.engineContentDir ?? "Content/Engine"} - + )} + + {error ? ( +
+ {error}
+ ) : null} +
+ + {/* Footer bar */} +
+ {/* Project name row */} +
+ Project Name +
-
-
-
-
-

- Open Project -

-

- Managed Projects -

-
+ {/* Action buttons row */} +
-
-
- {sortedProjects.length === 0 ? ( -
- -

No projects yet

-

- Create your first project to start a dedicated content workspace and editor - session. -

-
- ) : null} - - {sortedProjects.map((project) => { - const isActive = activeProject?.slug === project.slug - return ( -
+ {canClose && ( + -
- - ) - })} + Cancel + + )} + +
- +
) diff --git a/EditorFrontend/components/engine/remote-viewport-context.tsx b/EditorFrontend/components/engine/remote-viewport-context.tsx index a34ceb02..1db8f9b5 100644 --- a/EditorFrontend/components/engine/remote-viewport-context.tsx +++ b/EditorFrontend/components/engine/remote-viewport-context.tsx @@ -30,6 +30,8 @@ export type RemoteSessionState = | "command-rejected" | "error" +export type RemoteRuntimeState = "edit" | "playing" | "paused" + export type RemoteViewportViewMode = "lit" | "unlit" | "wireframe" export type RemoteViewportGizmoMode = "translate" | "scale" | "rotate" export type SessionSceneItemKind = "folder" | "mesh" | "light" | "camera" | "actor" @@ -50,7 +52,7 @@ export interface SessionAssetDescriptor { export interface SessionPropertyDescriptor { name: string - type: "string" | "bool" | "vec3" + type: "string" | "bool" | "number" | "vec3" | "enum" readOnly: boolean value?: string } @@ -113,11 +115,23 @@ export interface SessionMaterialDetails { textureAssetPath: string | null } +export interface SessionPhysicsDetails { + bodyType: "none" | "static" | "dynamic" + colliderType: "none" | "box" | "sphere" + boxHalfExtents: [number, number, number] + sphereRadius: number + mass: number + friction: number + restitution: number +} + export interface SessionObjectDetails { objectId: string displayName: string kind: SessionSceneItemKind visible: boolean + isGeneratedAssetChild: boolean + generatedFromAssetRootId: string | null capabilities: { supportsTransform: boolean transformReadOnly: boolean @@ -125,6 +139,7 @@ export interface SessionObjectDetails { transform: SessionTransformDetails | null light: SessionLightDetails | null material: SessionMaterialDetails | null + physics: SessionPhysicsDetails | null collaboration: { selectedByUserIds: number[] lockState: "unlocked" | "locked" @@ -136,6 +151,7 @@ interface RemoteViewportActions { reconnect: () => Promise toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise + setShowColliders: (showColliders: boolean) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise refreshSessionSnapshot: () => Promise @@ -151,10 +167,14 @@ interface RemoteViewportActions { listAssets: () => Promise getSchema: (objectId: string) => Promise saveScene: () => Promise + playSession: () => Promise + pauseSession: () => Promise + resumeSession: () => Promise + stopSession: () => Promise setProperty: ( objectId: string, property: string, - value: string | boolean | [number, number, number] + value: string | number | boolean | [number, number, number] ) => Promise reloadScripts: () => Promise setMeshAsset: (objectId: string, assetPath: string) => Promise @@ -187,7 +207,10 @@ interface RemoteViewportContextValue { frameText: string sessionStatusText: string sessionDetailText: string + runtimeState: RemoteRuntimeState + canControlRuntime: boolean viewMode: RemoteViewportViewMode + showColliders: boolean gizmoMode: RemoteViewportGizmoMode gridSnapSettings: RemoteViewportGridSnapSettings isLooking: boolean @@ -208,6 +231,7 @@ interface RemoteViewportContextValue { setSessionStatusText: (value: string) => void setSessionDetailText: (value: string) => void setViewMode: (value: RemoteViewportViewMode) => void + setShowCollidersState: (value: boolean) => void setIsLooking: (value: boolean) => void setServerOrigin: (value: string) => void appendEventLog: (value: string) => void @@ -233,12 +257,16 @@ interface RemoteViewportContextValue { setObjectSchema: (schema: SessionObjectSchema) => void getSchema: (objectId: string) => Promise saveScene: () => Promise + playSession: () => Promise + pauseSession: () => Promise + resumeSession: () => Promise + stopSession: () => Promise saveStatus: "idle" | "saving" | "saved" | "failed" setSaveStatus: (status: "idle" | "saving" | "saved" | "failed") => void setProperty: ( objectId: string, property: string, - value: string | boolean | [number, number, number] + value: string | number | boolean | [number, number, number] ) => Promise reloadScripts: () => Promise setMeshAsset: (objectId: string, assetPath: string) => Promise @@ -262,6 +290,7 @@ interface RemoteViewportContextValue { reconnect: () => Promise toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise + setShowColliders: (showColliders: boolean) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise refreshSessionSnapshot: () => Promise @@ -278,6 +307,9 @@ interface RemoteViewportContextValue { interface SessionSnapshot { currentUserId: number + runtimeControllerUserId: number + runtimeState: RemoteRuntimeState + showColliders: boolean participants: SessionParticipant[] sceneTree: SessionSceneItem[] selections: SessionSelection[] @@ -319,6 +351,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { reconnect: async () => {}, toggleLook: async () => {}, setMode: async () => {}, + setShowColliders: async () => {}, setGizmoMode: async () => {}, setGridSnapSettings: async () => {}, refreshSessionSnapshot: async () => {}, @@ -334,6 +367,10 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { listAssets: async () => {}, getSchema: async () => {}, saveScene: async () => {}, + playSession: async () => false, + pauseSession: async () => false, + resumeSession: async () => false, + stopSession: async () => false, setProperty: async () => false, reloadScripts: async () => {}, setMeshAsset: async () => false, @@ -351,7 +388,9 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { const [sessionDetailText, setSessionDetailText] = useState( "Waiting for authoritative session state" ) + const [runtimeState, setRuntimeState] = useState("edit") const [viewMode, setViewMode] = useState("lit") + const [showColliders, setShowCollidersState] = useState(true) const [gizmoMode, setGizmoModeState] = useState("translate") const [gridSnapSettings, setGridSnapSettingsState] = useState(defaultGridSnapSettings) @@ -359,6 +398,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { const [eventLog, setEventLog] = useState([]) const [serverOrigin, setServerOrigin] = useState("") const [currentUserId, setCurrentUserId] = useState(null) + const [runtimeControllerUserId, setRuntimeControllerUserId] = useState(null) const [participants, setParticipants] = useState([]) const [sceneTree, setSceneTree] = useState([]) const [selections, setSelections] = useState([]) @@ -385,6 +425,9 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { const setSessionSnapshot = useCallback((snapshot: SessionSnapshot) => { setCurrentUserId(snapshot.currentUserId) + setRuntimeControllerUserId(snapshot.runtimeControllerUserId) + setRuntimeState(snapshot.runtimeState) + setShowCollidersState(snapshot.showColliders) setParticipants(snapshot.participants) setSceneTree(snapshot.sceneTree) setSelections( @@ -400,6 +443,9 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { const clearSessionSnapshot = useCallback(() => { setCurrentUserId(null) + setRuntimeControllerUserId(null) + setRuntimeState("edit") + setShowCollidersState(true) setParticipants([]) setSceneTree([]) setSelections([]) @@ -462,6 +508,11 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { await actionsRef.current.setMode(mode) }, []) + const setShowColliders = useCallback(async (nextValue: boolean) => { + setShowCollidersState(nextValue) + await actionsRef.current.setShowColliders(nextValue) + }, []) + const setGizmoModeAction = useCallback(async (mode: RemoteViewportGizmoMode) => { setGizmoModeState(mode) await actionsRef.current.setGizmoMode(mode) @@ -525,11 +576,19 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { await actionsRef.current.saveScene() }, []) + const playSession = useCallback(async () => actionsRef.current.playSession(), []) + + const pauseSession = useCallback(async () => actionsRef.current.pauseSession(), []) + + const resumeSession = useCallback(async () => actionsRef.current.resumeSession(), []) + + const stopSession = useCallback(async () => actionsRef.current.stopSession(), []) + const setProperty = useCallback( async ( objectId: string, property: string, - value: string | boolean | [number, number, number] + value: string | number | boolean | [number, number, number] ) => actionsRef.current.setProperty(objectId, property, value), [] ) @@ -581,6 +640,10 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { ? selections.find((selection) => selection.userId === currentUserId)?.objectId ?? null : null const selectedObject = findSceneItem(sceneTree, selectedObjectId) + const canControlRuntime = + currentUserId !== null && + runtimeControllerUserId !== null && + currentUserId === runtimeControllerUserId const value = useMemo( () => ({ @@ -591,7 +654,10 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { frameText, sessionStatusText, sessionDetailText, + runtimeState, + canControlRuntime, viewMode, + showColliders, gizmoMode, gridSnapSettings, isLooking, @@ -612,6 +678,10 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { setObjectSchema, getSchema, saveScene, + playSession, + pauseSession, + resumeSession, + stopSession, saveStatus, setSaveStatus, setProperty, @@ -633,6 +703,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { setSessionStatusText, setSessionDetailText, setViewMode, + setShowCollidersState, setIsLooking, setServerOrigin, appendEventLog, @@ -646,6 +717,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { reconnect, toggleLook, setMode, + setShowColliders, setGizmoMode: setGizmoModeAction, setGridSnapSettings, refreshSessionSnapshot, @@ -668,6 +740,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { connectionState, clearSessionSnapshot, currentUserId, + runtimeControllerUserId, detailText, eventLog, frameText, @@ -677,6 +750,8 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { sessionDetailText, sessionState, sessionStatusText, + runtimeState, + canControlRuntime, reconnect, refreshSessionSnapshot, registerActions, @@ -695,6 +770,10 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { objectSchema, getSchema, saveScene, + playSession, + pauseSession, + resumeSession, + stopSession, saveStatus, setProperty, reloadScripts, @@ -708,7 +787,9 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { dismissScriptErrorToast, serverOrigin, gizmoMode, + showColliders, setMode, + setShowColliders, setGizmoModeAction, setGridSnapSettings, setSessionDetailText, diff --git a/EditorFrontend/components/engine/toolbar.tsx b/EditorFrontend/components/engine/toolbar.tsx index d850f699..1a5781ea 100644 --- a/EditorFrontend/components/engine/toolbar.tsx +++ b/EditorFrontend/components/engine/toolbar.tsx @@ -50,11 +50,17 @@ export function Toolbar() { setGizmoMode, setGridSnapSettings, saveScene, + playSession, + pauseSession, + resumeSession, + stopSession, saveStatus, setSaveStatus, reloadScripts, reloadStatus, setReloadStatus, + runtimeState, + canControlRuntime, } = useRemoteViewport() useEffect(() => { @@ -150,10 +156,28 @@ export function Toolbar() { - - - - + void (runtimeState === "paused" ? resumeSession() : playSession())} + /> + void pauseSession()} + /> + + void stopSession()} + /> @@ -272,19 +296,23 @@ function ToolbarButton({ active, className, onClick, + disabled, }: { icon: React.ElementType tooltip: string active?: boolean className?: string onClick?: () => void + disabled?: boolean }) { return ( + + + + + + void setShowColliders(checked === true)} + > + Colliders + + +
/// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/Headless/HeadlessCommandProtocol.cpp b/Headless/HeadlessCommandProtocol.cpp index 2a5759d6..27b75683 100644 --- a/Headless/HeadlessCommandProtocol.cpp +++ b/Headless/HeadlessCommandProtocol.cpp @@ -176,6 +176,12 @@ std::string EventPayloadType(const EditorEventPayload &Payload) { if (std::holds_alternative(Payload)) { return "material_texture_changed"; } + if (std::holds_alternative(Payload)) { + return "physics_properties_changed"; + } + if (std::holds_alternative(Payload)) { + return "runtime_state_changed"; + } return "object_transform_updated"; } @@ -209,6 +215,45 @@ std::string PresenceStateToString(EditorUserPresenceState State) { return "connected"; } +std::string RuntimeStateToString(EditorRuntimeState State) { + switch (State) { + case EditorRuntimeState::Edit: + return "edit"; + case EditorRuntimeState::Playing: + return "playing"; + case EditorRuntimeState::Paused: + return "paused"; + } + + return "edit"; +} + +std::string PhysicsBodyTypeToString(EditorPhysicsBodyType Type) { + switch (Type) { + case EditorPhysicsBodyType::None: + return "none"; + case EditorPhysicsBodyType::Static: + return "static"; + case EditorPhysicsBodyType::Dynamic: + return "dynamic"; + } + + return "none"; +} + +std::string PhysicsColliderTypeToString(EditorPhysicsColliderType Type) { + switch (Type) { + case EditorPhysicsColliderType::None: + return "none"; + case EditorPhysicsColliderType::Box: + return "box"; + case EditorPhysicsColliderType::Sphere: + return "sphere"; + } + + return "none"; +} + std::string LockStateToString(EditorObjectLockState State) { switch (State) { case EditorObjectLockState::Unlocked: @@ -300,6 +345,15 @@ void SerializeObjectDetails(std::ostringstream &Stream, << "\",\"displayName\":\"" << EscapeJson(Details.DisplayName) << "\",\"kind\":\"" << SceneItemKindToString(Details.Kind) << "\",\"visible\":" << (Details.Visible ? "true" : "false") + << ",\"isGeneratedAssetChild\":" + << (Details.IsGeneratedAssetChild ? "true" : "false"); + if (Details.GeneratedFromAssetRootId.has_value()) { + Stream << ",\"generatedFromAssetRootId\":\"" + << EscapeJson(*Details.GeneratedFromAssetRootId) << "\""; + } else { + Stream << ",\"generatedFromAssetRootId\":null"; + } + Stream << ",\"capabilities\":{\"supportsTransform\":" << (Details.SupportsTransform ? "true" : "false") << ",\"transformReadOnly\":" @@ -344,6 +398,22 @@ void SerializeObjectDetails(std::ostringstream &Stream, } else { Stream << ",\"material\":null"; } + if (Details.Physics.has_value()) { + Stream << ",\"physics\":{\"bodyType\":\"" + << PhysicsBodyTypeToString(Details.Physics->BodyType) + << "\",\"colliderType\":\"" + << PhysicsColliderTypeToString(Details.Physics->ColliderType) + << "\",\"boxHalfExtents\":[" + << Details.Physics->BoxHalfExtents.x << "," + << Details.Physics->BoxHalfExtents.y << "," + << Details.Physics->BoxHalfExtents.z + << "],\"sphereRadius\":" << Details.Physics->SphereRadius + << ",\"mass\":" << Details.Physics->Mass + << ",\"friction\":" << Details.Physics->Friction + << ",\"restitution\":" << Details.Physics->Restitution << "}"; + } else { + Stream << ",\"physics\":null"; + } Stream << ",\"collaboration\":{\"selectedByUserIds\":["; bool FirstSelectionOwner = true; for (const auto &Participant : BuildParticipants(State, SessionUserId{0})) { @@ -441,6 +511,8 @@ std::optional ParseHeadlessCommand(std::string_view JsonLine, std::string &Error) { static const std::regex TypePattern(R"json("type"\s*:\s*"([^"]+)")json"); static const std::regex ViewModePattern(R"json("viewMode"\s*:\s*"([^"]+)")json"); + static const std::regex ShowCollidersPattern( + R"json("showColliders"\s*:\s*(true|false))json"); static const std::regex BoolPattern( R"json("isLooking"\s*:\s*(true|false))json"); static const std::regex CursorPattern( @@ -497,9 +569,44 @@ std::optional ParseHeadlessCommand(std::string_view JsonLine, .EditorPayload = {}, .ViewMode = ParsedMode}; } + if (*Type == "set_show_colliders") { + const auto ShowColliders = MatchString(JsonLine, ShowCollidersPattern); + if (!ShowColliders.has_value()) { + Error = "`set_show_colliders` requires `showColliders`."; + return std::nullopt; + } + + return HeadlessCommand{.Type = HeadlessCommandType::SetShowColliders, + .EditorPayload = {}, + .ShowColliders = *ShowColliders == "true"}; + } if (*Type == "quit") { return HeadlessCommand{.Type = HeadlessCommandType::Quit, .EditorPayload = {}}; } + if (*Type == "play_session") { + return HeadlessCommand{ + .Type = HeadlessCommandType::PlaySession, + .EditorPayload = {.Payload = PlaySessionCommand{}}, + }; + } + if (*Type == "pause_session") { + return HeadlessCommand{ + .Type = HeadlessCommandType::PauseSession, + .EditorPayload = {.Payload = PauseSessionCommand{}}, + }; + } + if (*Type == "resume_session") { + return HeadlessCommand{ + .Type = HeadlessCommandType::ResumeSession, + .EditorPayload = {.Payload = ResumeSessionCommand{}}, + }; + } + if (*Type == "stop_session") { + return HeadlessCommand{ + .Type = HeadlessCommandType::StopSession, + .EditorPayload = {.Payload = StopSessionCommand{}}, + }; + } if (*Type == "set_look_active") { const auto BoolValue = MatchString(JsonLine, BoolPattern); if (!BoolValue.has_value()) { @@ -917,6 +1024,8 @@ std::optional ParseHeadlessCommand(std::string_view JsonLine, static const std::regex PropPattern(R"json("property"\s*:\s*"([^"]+)")json"); static const std::regex StringValPattern(R"json("value"\s*:\s*"([^"]*)")json"); static const std::regex BoolValPattern(R"json("value"\s*:\s*(true|false))json"); + static const std::regex NumberValPattern( + R"json("value"\s*:\s*(-?[0-9Ee.+-]+))json"); static const std::regex Vec3ValPattern( R"json("value"\s*:\s*\[\s*(-?[0-9Ee.+-]+)\s*,\s*(-?[0-9Ee.+-]+)\s*,\s*(-?[0-9Ee.+-]+)\s*\])json"); @@ -928,6 +1037,10 @@ std::optional ParseHeadlessCommand(std::string_view JsonLine, Val = PropertyValue{*StrVal}; } else if (const auto BoolStr = MatchString(JsonLine, BoolValPattern)) { Val = PropertyValue{*BoolStr == "true"}; + } else if (const auto NumberStr = MatchString(JsonLine, NumberValPattern)) { + if (const auto Number = ParseDouble(*NumberStr)) { + Val = PropertyValue{static_cast(*Number)}; + } } else { std::match_results M; if (std::regex_search(JsonLine.begin(), JsonLine.end(), M, Vec3ValPattern) && @@ -1004,6 +1117,7 @@ ParseRemoteViewportCommand(std::string_view JsonLine, std::string &Error) { switch (Command->Type) { case HeadlessCommandType::SetViewMode: + case HeadlessCommandType::SetShowColliders: case HeadlessCommandType::SetLookActive: case HeadlessCommandType::SetViewportCameraPose: case HeadlessCommandType::SelectObject: @@ -1031,6 +1145,10 @@ ParseRemoteViewportCommand(std::string_view JsonLine, std::string &Error) { case HeadlessCommandType::SetLightProperties: case HeadlessCommandType::SetMaterialProperties: case HeadlessCommandType::SetMaterialTexture: + case HeadlessCommandType::PlaySession: + case HeadlessCommandType::PauseSession: + case HeadlessCommandType::ResumeSession: + case HeadlessCommandType::StopSession: case HeadlessCommandType::DropMesh: case HeadlessCommandType::DropTexture: case HeadlessCommandType::ReloadScripts: @@ -1200,6 +1318,26 @@ std::string SerializeEvent(const PublishedEditorEvent &Event) { std::get_if(&Event.Event.Payload)) { Stream << ",\"objectId\":\"" << EscapeJson(TexEv->ObjectId) << "\",\"textureAssetPath\":\"" << EscapeJson(TexEv->TextureAssetPath) << "\""; + } else if (const auto *PhysicsProps = + std::get_if(&Event.Event.Payload)) { + Stream << ",\"objectId\":\"" << EscapeJson(PhysicsProps->ObjectId) + << "\",\"bodyType\":\"" + << PhysicsBodyTypeToString(PhysicsProps->Physics.BodyType) + << "\",\"colliderType\":\"" + << PhysicsColliderTypeToString(PhysicsProps->Physics.ColliderType) + << "\",\"boxHalfExtents\":[" + << PhysicsProps->Physics.BoxHalfExtents.x << "," + << PhysicsProps->Physics.BoxHalfExtents.y << "," + << PhysicsProps->Physics.BoxHalfExtents.z + << "],\"sphereRadius\":" << PhysicsProps->Physics.SphereRadius + << ",\"mass\":" << PhysicsProps->Physics.Mass + << ",\"friction\":" << PhysicsProps->Physics.Friction + << ",\"restitution\":" << PhysicsProps->Physics.Restitution; + } else if (const auto *RuntimeState = + std::get_if(&Event.Event.Payload)) { + Stream << ",\"user\":" << RuntimeState->User.Value + << ",\"runtimeState\":\"" + << RuntimeStateToString(RuntimeState->State) << "\""; } Stream << "}"; return Stream.str(); @@ -1352,14 +1490,38 @@ std::string SerializeWebRtcIceCandidateList( std::string SerializeSessionSnapshot(const EditorSessionState &State, SessionUserId CurrentUser, + bool ShowColliders, bool TransportConnected, std::string_view TransportState, std::string_view WebRtcConnectionState) { const std::vector Participants = BuildParticipants(State, CurrentUser); + const SessionUserId RuntimeControllerUser = + [&]() -> SessionUserId { + if (State.RuntimeControllerUser.has_value()) { + return *State.RuntimeControllerUser; + } + + std::optional Candidate; + for (const auto &[User, Presence] : State.PresenceByUser) { + if (Presence.State == EditorUserPresenceState::Disconnected || + User.Value == 1) { + continue; + } + if (!Candidate.has_value() || User.Value < Candidate->Value) { + Candidate = User; + } + } + + return Candidate.value_or(SessionUserId{1}); + }(); std::ostringstream Stream; Stream << "{\"type\":\"session_snapshot\",\"sessionId\":" << State.Session.Value << ",\"currentUserId\":" << CurrentUser.Value + << ",\"runtimeControllerUserId\":" << RuntimeControllerUser.Value + << ",\"showColliders\":" << (ShowColliders ? "true" : "false") + << ",\"runtimeState\":\"" << RuntimeStateToString(State.RuntimeState) + << "\"" << ",\"transport\":{\"connected\":" << (TransportConnected ? "true" : "false") << ",\"state\":\"" << EscapeJson(TransportState) << "\",\"webrtcConnectionState\":\"" @@ -1415,13 +1577,13 @@ std::string SerializeSessionSnapshot(const EditorSessionState &State, std::string SerializeSessionConnectResponse( std::string_view ClientId, const EditorSessionState &State, - SessionUserId CurrentUser, bool TransportConnected, + SessionUserId CurrentUser, bool ShowColliders, bool TransportConnected, std::string_view TransportState, std::string_view WebRtcConnectionState) { std::ostringstream Stream; Stream << "{\"type\":\"session_connect\",\"clientId\":\"" << EscapeJson(ClientId) << "\",\"snapshot\":" - << SerializeSessionSnapshot(State, CurrentUser, TransportConnected, + << SerializeSessionSnapshot(State, CurrentUser, ShowColliders, TransportConnected, TransportState, WebRtcConnectionState) << "}"; return Stream.str(); @@ -1556,6 +1718,24 @@ std::string SerializeObjectSchema(const EditorObjectDetails &Details) { AppendProp("baseColorTexture", "texture_ref", false, TexPath); } + if (Details.SupportsTransform) { + const EditorPhysicsProperties Physics = + Details.Physics.value_or(EditorPhysicsProperties{}); + AppendProp("physicsBodyType", "enum", Details.TransformReadOnly, + PhysicsBodyTypeToString(Physics.BodyType)); + AppendProp("physicsColliderType", "enum", Details.TransformReadOnly, + PhysicsColliderTypeToString(Physics.ColliderType)); + AppendProp("physicsBoxHalfExtents", "vec3", Details.TransformReadOnly); + AppendProp("physicsSphereRadius", "number", Details.TransformReadOnly, + std::to_string(Physics.SphereRadius)); + AppendProp("physicsMass", "number", Details.TransformReadOnly, + std::to_string(Physics.Mass)); + AppendProp("physicsFriction", "number", Details.TransformReadOnly, + std::to_string(Physics.Friction)); + AppendProp("physicsRestitution", "number", Details.TransformReadOnly, + std::to_string(Physics.Restitution)); + } + Stream << "]}"; return Stream.str(); } diff --git a/Headless/HeadlessCommandProtocol.h b/Headless/HeadlessCommandProtocol.h index 921cabc5..bb7b15da 100644 --- a/Headless/HeadlessCommandProtocol.h +++ b/Headless/HeadlessCommandProtocol.h @@ -25,6 +25,7 @@ namespace Axiom { enum class HeadlessCommandType { LoadStartupScene, SetViewMode, + SetShowColliders, SetLookActive, SetViewportCameraPose, SelectObject, @@ -53,6 +54,10 @@ enum class HeadlessCommandType { SetLightProperties, SetMaterialProperties, SetMaterialTexture, + PlaySession, + PauseSession, + ResumeSession, + StopSession, DropMesh, DropTexture, Heartbeat, @@ -60,12 +65,13 @@ enum class HeadlessCommandType { Quit, }; -using PropertyValue = std::variant; +using PropertyValue = std::variant; struct HeadlessCommand { HeadlessCommandType Type; EditorCommand EditorPayload; RendererViewMode ViewMode{RendererViewMode::Lit}; + bool ShowColliders{true}; glm::vec2 MousePosition{0.0f}; GizmoMode Mode{GizmoMode::Translate}; bool Enabled{false}; @@ -146,12 +152,14 @@ std::string SerializeWebRtcIceCandidateList( std::span Candidates); std::string SerializeSessionSnapshot(const EditorSessionState &State, SessionUserId CurrentUser, + bool ShowColliders, bool TransportConnected, std::string_view TransportState, std::string_view WebRtcConnectionState); std::string SerializeSessionConnectResponse(std::string_view ClientId, const EditorSessionState &State, SessionUserId CurrentUser, + bool ShowColliders, bool TransportConnected, std::string_view TransportState, std::string_view WebRtcConnectionState); diff --git a/Headless/HeadlessRenderView.h b/Headless/HeadlessRenderView.h index 29cc5e62..79857f3e 100644 --- a/Headless/HeadlessRenderView.h +++ b/Headless/HeadlessRenderView.h @@ -14,6 +14,7 @@ struct HeadlessRenderViewState { std::string ClientId; SessionUserId User; RendererViewMode ViewMode{RendererViewMode::Lit}; + bool ShowColliders{true}; bool IsLocal{false}; }; @@ -40,6 +41,7 @@ class HeadlessRenderViewRegistry { if (Inserted) { View.ClientId = It->first; View.ViewMode = RendererViewMode::Lit; + View.ShowColliders = true; } View.User = User; View.IsLocal = false; @@ -96,6 +98,32 @@ class HeadlessRenderViewRegistry { return true; } + bool SetShowColliders(SessionUserId User, bool ShowColliders) { + if (m_LocalView.User.Value == User.Value) { + m_LocalView.ShowColliders = ShowColliders; + return true; + } + + for (auto &[ClientId, View] : m_RemoteViewsByClientId) { + (void)ClientId; + if (View.User.Value == User.Value) { + View.ShowColliders = ShowColliders; + return true; + } + } + return false; + } + + bool SetRemoteShowColliders(std::string_view ClientId, bool ShowColliders) { + auto It = m_RemoteViewsByClientId.find(std::string(ClientId)); + if (It == m_RemoteViewsByClientId.end()) { + return false; + } + + It->second.ShowColliders = ShowColliders; + return true; + } + const HeadlessRenderViewState *FindRemoteView( std::string_view ClientId) const { const auto It = m_RemoteViewsByClientId.find(std::string(ClientId)); @@ -143,6 +171,7 @@ class HeadlessRenderViewRegistry { .ClientId = "", .User = SessionUserId{1}, .ViewMode = RendererViewMode::Lit, + .ShowColliders = true, .IsLocal = true, }; std::unordered_map m_RemoteViewsByClientId; diff --git a/Headless/HeadlessSessionHost.cpp b/Headless/HeadlessSessionHost.cpp index f56cc21a..3101215a 100644 --- a/Headless/HeadlessSessionHost.cpp +++ b/Headless/HeadlessSessionHost.cpp @@ -95,6 +95,15 @@ void HeadlessSessionHost::SetRemoteViewMode(SessionUserId User, m_RenderViews.SetViewMode(User, ViewMode); } +void HeadlessSessionHost::SetRemoteShowColliders(bool ShowColliders) { + m_RenderViews.SetShowColliders(m_Layer->GetLocalUserId(), ShowColliders); +} + +void HeadlessSessionHost::SetRemoteShowColliders(SessionUserId User, + bool ShowColliders) { + m_RenderViews.SetShowColliders(User, ShowColliders); +} + void HeadlessSessionHost::EnsureRemoteRenderView(const std::string &ClientId, SessionUserId User) { m_RenderViews.UpsertRemoteView(ClientId, User); diff --git a/Headless/HeadlessSessionHost.h b/Headless/HeadlessSessionHost.h index b3883986..b4ec50ec 100644 --- a/Headless/HeadlessSessionHost.h +++ b/Headless/HeadlessSessionHost.h @@ -30,6 +30,8 @@ class HeadlessSessionHost final : public Application { void SetTransportVideoEncoder(std::unique_ptr Encoder); void SetRemoteViewMode(RendererViewMode ViewMode); void SetRemoteViewMode(SessionUserId User, RendererViewMode ViewMode); + void SetRemoteShowColliders(bool ShowColliders); + void SetRemoteShowColliders(SessionUserId User, bool ShowColliders); void EnsureRemoteRenderView(const std::string &ClientId, SessionUserId User); void RemoveRemoteRenderView(std::string_view ClientId); void FocusRemoteRenderView(std::string_view ClientId); diff --git a/Headless/HeadlessSessionLayer.cpp b/Headless/HeadlessSessionLayer.cpp index 6ef8418c..bab32655 100644 --- a/Headless/HeadlessSessionLayer.cpp +++ b/Headless/HeadlessSessionLayer.cpp @@ -10,13 +10,19 @@ #include #include +#include #include #include +#include #include +#include namespace Axiom { namespace { +constexpr float ColliderOverlayScale = 1.01f; +constexpr float ColliderCornerScale = 0.085f; + MeshData BuildPresenceMarkerMeshData() { MeshData Mesh{}; Mesh.Vertices = { @@ -38,6 +44,104 @@ MeshData BuildPresenceMarkerMeshData() { return Mesh; } +MeshData BuildUnitBoxMeshData() { + MeshData Mesh{}; + Mesh.Vertices = { + {.Position = {-1.0f, -1.0f, 1.0f, 1.0f}, .Normal = {0.0f, 0.0f, 1.0f, 0.0f}}, + {.Position = {1.0f, -1.0f, 1.0f, 1.0f}, .Normal = {0.0f, 0.0f, 1.0f, 0.0f}}, + {.Position = {1.0f, 1.0f, 1.0f, 1.0f}, .Normal = {0.0f, 0.0f, 1.0f, 0.0f}}, + {.Position = {-1.0f, 1.0f, 1.0f, 1.0f}, .Normal = {0.0f, 0.0f, 1.0f, 0.0f}}, + {.Position = {-1.0f, -1.0f, -1.0f, 1.0f}, .Normal = {0.0f, 0.0f, -1.0f, 0.0f}}, + {.Position = {1.0f, -1.0f, -1.0f, 1.0f}, .Normal = {0.0f, 0.0f, -1.0f, 0.0f}}, + {.Position = {1.0f, 1.0f, -1.0f, 1.0f}, .Normal = {0.0f, 0.0f, -1.0f, 0.0f}}, + {.Position = {-1.0f, 1.0f, -1.0f, 1.0f}, .Normal = {0.0f, 0.0f, -1.0f, 0.0f}}, + {.Position = {-1.0f, -1.0f, -1.0f, 1.0f}, .Normal = {-1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, -1.0f, 1.0f, 1.0f}, .Normal = {-1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, 1.0f, 1.0f, 1.0f}, .Normal = {-1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, 1.0f, -1.0f, 1.0f}, .Normal = {-1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, -1.0f, -1.0f, 1.0f}, .Normal = {1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, -1.0f, 1.0f, 1.0f}, .Normal = {1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, 1.0f, 1.0f, 1.0f}, .Normal = {1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, 1.0f, -1.0f, 1.0f}, .Normal = {1.0f, 0.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, 1.0f, -1.0f, 1.0f}, .Normal = {0.0f, 1.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, 1.0f, 1.0f, 1.0f}, .Normal = {0.0f, 1.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, 1.0f, 1.0f, 1.0f}, .Normal = {0.0f, 1.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, 1.0f, -1.0f, 1.0f}, .Normal = {0.0f, 1.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, -1.0f, -1.0f, 1.0f}, .Normal = {0.0f, -1.0f, 0.0f, 0.0f}}, + {.Position = {-1.0f, -1.0f, 1.0f, 1.0f}, .Normal = {0.0f, -1.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, -1.0f, 1.0f, 1.0f}, .Normal = {0.0f, -1.0f, 0.0f, 0.0f}}, + {.Position = {1.0f, -1.0f, -1.0f, 1.0f}, .Normal = {0.0f, -1.0f, 0.0f, 0.0f}}, + }; + Mesh.Indices = { + 0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, 8, 9, 10, 8, 10, 11, + 12, 14, 13, 12, 15, 14, 16, 17, 18, 16, 18, 19, 20, 22, 21, 20, 23, 22, + }; + Mesh.BoundsMin = {-1.0f, -1.0f, -1.0f}; + Mesh.BoundsMax = {1.0f, 1.0f, 1.0f}; + return Mesh; +} + +MeshData BuildUnitSphereMeshData(uint32_t LongitudeSegments = 16, + uint32_t LatitudeSegments = 10) { + MeshData Mesh{}; + Mesh.Vertices.reserve(static_cast(LongitudeSegments + 1) * + static_cast(LatitudeSegments + 1)); + for (uint32_t Lat = 0; Lat <= LatitudeSegments; ++Lat) { + const float V = static_cast(Lat) / + static_cast(LatitudeSegments); + const float Theta = V * std::numbers::pi_v; + const float SinTheta = std::sin(Theta); + const float CosTheta = std::cos(Theta); + for (uint32_t Lon = 0; Lon <= LongitudeSegments; ++Lon) { + const float U = static_cast(Lon) / + static_cast(LongitudeSegments); + const float Phi = U * std::numbers::pi_v * 2.0f; + const float SinPhi = std::sin(Phi); + const float CosPhi = std::cos(Phi); + const glm::vec3 Normal{SinTheta * CosPhi, CosTheta, SinTheta * SinPhi}; + Mesh.Vertices.push_back({ + .Position = glm::vec4(Normal, 1.0f), + .Normal = glm::vec4(glm::normalize(Normal), 0.0f), + .TexCoord = {U, V}, + }); + } + } + + Mesh.Indices.reserve(static_cast(LongitudeSegments) * + static_cast(LatitudeSegments) * 6u); + const uint32_t Stride = LongitudeSegments + 1; + for (uint32_t Lat = 0; Lat < LatitudeSegments; ++Lat) { + for (uint32_t Lon = 0; Lon < LongitudeSegments; ++Lon) { + const uint32_t A = Lat * Stride + Lon; + const uint32_t B = A + Stride; + const uint32_t C = A + 1; + const uint32_t D = B + 1; + Mesh.Indices.insert(Mesh.Indices.end(), {A, B, C, C, B, D}); + } + } + Mesh.BoundsMin = {-1.0f, -1.0f, -1.0f}; + Mesh.BoundsMax = {1.0f, 1.0f, 1.0f}; + return Mesh; +} + +MeshData BuildUnitCornerMarkerMeshData() { + MeshData Mesh{}; + Mesh.Vertices = { + {.Position = {-1.0f, -1.0f, 1.0f, 1.0f}}, {.Position = {1.0f, -1.0f, 1.0f, 1.0f}}, + {.Position = {1.0f, 1.0f, 1.0f, 1.0f}}, {.Position = {-1.0f, 1.0f, 1.0f, 1.0f}}, + {.Position = {-1.0f, -1.0f, -1.0f, 1.0f}}, {.Position = {1.0f, -1.0f, -1.0f, 1.0f}}, + {.Position = {1.0f, 1.0f, -1.0f, 1.0f}}, {.Position = {-1.0f, 1.0f, -1.0f, 1.0f}}, + }; + Mesh.Indices = { + 0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, + 4, 0, 3, 4, 3, 7, 1, 5, 6, 1, 6, 2, + 3, 2, 6, 3, 6, 7, 4, 5, 1, 4, 1, 0, + }; + Mesh.BoundsMin = {-1.0f, -1.0f, -1.0f}; + Mesh.BoundsMax = {1.0f, 1.0f, 1.0f}; + return Mesh; +} + glm::vec4 ParseHexColor(std::string_view Hex) { if (Hex.size() != 7 || Hex.front() != '#') { return {1.0f, 1.0f, 1.0f, 1.0f}; @@ -79,6 +183,34 @@ glm::mat4 BuildPresenceTransform(const EditorParticipant::CameraState &Camera) { return Transform * glm::scale(glm::mat4(1.0f), glm::vec3(0.35f)); } + +glm::mat4 BuildTransformMatrix(const EditorTransformDetails &Transform) { + glm::mat4 Matrix = glm::translate(glm::mat4(1.0f), Transform.Location); + Matrix = glm::rotate(Matrix, glm::radians(Transform.RotationDegrees.y), + glm::vec3(0.0f, 1.0f, 0.0f)); + Matrix = glm::rotate(Matrix, glm::radians(Transform.RotationDegrees.x), + glm::vec3(1.0f, 0.0f, 0.0f)); + Matrix = glm::rotate(Matrix, glm::radians(Transform.RotationDegrees.z), + glm::vec3(0.0f, 0.0f, 1.0f)); + return glm::scale(Matrix, Transform.Scale); +} + +const EditorTransformDetails *GetEffectiveTransform(const EditorObjectDetails &Details) { + if (Details.WorldTransform.has_value()) { + return &*Details.WorldTransform; + } + if (Details.Transform.has_value()) { + return &*Details.Transform; + } + return nullptr; +} + +bool HasRenderableCollider(const EditorObjectDetails &Details) { + return Details.Visible && Details.Physics.has_value() && + Details.Physics->BodyType != EditorPhysicsBodyType::None && + Details.Physics->ColliderType != EditorPhysicsColliderType::None && + GetEffectiveTransform(Details) != nullptr; +} } // namespace HeadlessSessionLayer::HeadlessSessionLayer() @@ -87,10 +219,12 @@ HeadlessSessionLayer::HeadlessSessionLayer() void HeadlessSessionLayer::OnAttach() { m_Session.EnsureViewportState(m_LocalUserId); m_PresenceMarkerMesh = Renderer::Get().CreateMesh(BuildPresenceMarkerMeshData()); + m_ColliderBoxMesh = Renderer::Get().CreateMesh(BuildUnitBoxMeshData()); + m_ColliderSphereMesh = Renderer::Get().CreateMesh(BuildUnitSphereMeshData()); } void HeadlessSessionLayer::OnUpdate() { - m_Session.Tick(); + m_Session.Tick(Application::Get().GetDeltaTime()); if (m_ScriptHost != nullptr) { m_ScriptHost->Tick(Application::Get().GetDeltaTime()); } @@ -158,6 +292,11 @@ void HeadlessSessionLayer::OnRender() { for (const auto &Billboard : BuildLightBillboards()) { RenderCommand::SubmitLightBillboard(Billboard); } + if (RenderView.ShowColliders) { + for (const auto &Submission : BuildColliderOverlaySubmissions()) { + RenderCommand::Submit(Submission); + } + } for (const auto &Submission : BuildPresenceOverlaySubmissions(RenderUser)) { RenderCommand::Submit(Submission); } @@ -228,6 +367,86 @@ std::vector HeadlessSessionLayer::BuildLightBillboards() return Result; } +std::vector +HeadlessSessionLayer::BuildColliderOverlaySubmissions() const { + std::vector Result; + for (const auto &[Id, Details] : m_Session.GetState().Scene.ObjectDetailsById) { + (void)Id; + if (!HasRenderableCollider(Details)) { + continue; + } + + const EditorTransformDetails &Transform = *GetEffectiveTransform(Details); + const EditorPhysicsProperties &Physics = *Details.Physics; + MeshRef ColliderMesh; + glm::mat4 ColliderTransform = BuildTransformMatrix(Transform); + if (Physics.ColliderType == EditorPhysicsColliderType::Box) { + if (m_ColliderBoxMesh == nullptr) { + continue; + } + ColliderMesh = m_ColliderBoxMesh; + ColliderTransform *= glm::scale(glm::mat4(1.0f), + Physics.BoxHalfExtents * ColliderOverlayScale); + } else if (Physics.ColliderType == EditorPhysicsColliderType::Sphere) { + if (m_ColliderSphereMesh == nullptr) { + continue; + } + ColliderMesh = m_ColliderSphereMesh; + ColliderTransform *= glm::scale( + glm::mat4(1.0f), + glm::vec3(Physics.SphereRadius * ColliderOverlayScale)); + } else { + continue; + } + + Result.push_back({ + .Mesh = ColliderMesh, + .Material = GetOrCreateColliderMaterial(Physics.BodyType), + .Name = Details.ObjectId + "-collider", + .RenderPath = MeshRenderPath::Graphics, + .Transform = ColliderTransform, + .Translucent = true, + }); + + if (m_ColliderBoxMesh == nullptr) { + continue; + } + + const glm::vec3 HalfExtents = + Physics.ColliderType == EditorPhysicsColliderType::Box + ? Physics.BoxHalfExtents * ColliderOverlayScale + : glm::vec3(Physics.SphereRadius * ColliderOverlayScale); + for (int X = -1; X <= 1; X += 2) { + for (int Y = -1; Y <= 1; Y += 2) { + for (int Z = -1; Z <= 1; Z += 2) { + const glm::vec3 LocalOffset = + glm::vec3(static_cast(X), static_cast(Y), + static_cast(Z)) * + HalfExtents; + glm::mat4 CornerTransform = + BuildTransformMatrix(Transform) * + glm::translate(glm::mat4(1.0f), LocalOffset) * + glm::scale(glm::mat4(1.0f), glm::vec3(std::max( + 0.03f, + std::max(HalfExtents.x, + std::max(HalfExtents.y, + HalfExtents.z)) * + ColliderCornerScale))); + Result.push_back({ + .Mesh = m_ColliderBoxMesh, + .Material = GetOrCreateColliderMaterial(Physics.BodyType), + .Name = Details.ObjectId + "-collider-corner", + .RenderPath = MeshRenderPath::Graphics, + .Transform = CornerTransform, + .Translucent = false, + }); + } + } + } + } + return Result; +} + bool HeadlessSessionLayer::LoadStartupSceneIntoSession() { return LoadStartupSceneIntoSession(std::filesystem::path(AXIOM_CONTENT_DIR)); } @@ -310,6 +529,28 @@ HeadlessSessionLayer::GetOrCreatePresenceMaterial(SessionUserId User) const { return Material; } +MaterialInstanceRef HeadlessSessionLayer::GetOrCreateColliderMaterial( + EditorPhysicsBodyType BodyType) const { + const int Key = static_cast(BodyType); + const auto Existing = m_ColliderMaterials.find(Key); + if (Existing != m_ColliderMaterials.end()) { + return Existing->second; + } + + auto Material = std::make_shared(); + if (BodyType == EditorPhysicsBodyType::Dynamic) { + Material->BaseColorFactor = {1.0f, 0.55f, 0.2f, 0.22f}; + } else if (BodyType == EditorPhysicsBodyType::Static) { + Material->BaseColorFactor = {0.2f, 0.9f, 1.0f, 0.18f}; + } else { + Material->BaseColorFactor = {0.8f, 0.8f, 0.8f, 0.18f}; + } + Material->Metallic = 0.0f; + Material->Roughness = 0.15f; + m_ColliderMaterials.emplace(Key, Material); + return Material; +} + CommandContext HeadlessSessionLayer::MakeContext() const { return MakeContext(m_LocalUserId); } diff --git a/Headless/HeadlessSessionLayer.h b/Headless/HeadlessSessionLayer.h index ab2a680d..3772edc7 100644 --- a/Headless/HeadlessSessionLayer.h +++ b/Headless/HeadlessSessionLayer.h @@ -43,6 +43,10 @@ class HeadlessSessionLayer final : public Layer { m_RenderViewResolver = std::move(Resolver); } void SetPresenceMarkerMeshForTesting(MeshRef Mesh) { m_PresenceMarkerMesh = std::move(Mesh); } + void SetColliderMeshesForTesting(MeshRef BoxMesh, MeshRef SphereMesh) { + m_ColliderBoxMesh = std::move(BoxMesh); + m_ColliderSphereMesh = std::move(SphereMesh); + } void SetScriptHost(ScriptHost *Host) { m_ScriptHost = Host; } EditorSession &GetSession() { return m_Session; } SessionUserId GetLocalUserId() const { return m_LocalUserId; } @@ -52,11 +56,14 @@ class HeadlessSessionLayer final : public Layer { void SetGizmoMode(SessionUserId User, GizmoMode Mode); GizmoMode GetGizmoMode(SessionUserId User) const; std::vector BuildLightBillboards() const; + std::vector BuildColliderOverlaySubmissions() const; std::vector BuildPresenceOverlaySubmissions(SessionUserId RenderUser) const; private: MaterialInstanceRef GetOrCreatePresenceMaterial(SessionUserId User) const; + MaterialInstanceRef + GetOrCreateColliderMaterial(EditorPhysicsBodyType BodyType) const; CommandContext MakeContext() const; CommandContext MakeContext(SessionUserId User) const; @@ -66,8 +73,11 @@ class HeadlessSessionLayer final : public Layer { ScriptHost *m_ScriptHost{nullptr}; EditorSceneRendererAdapter *m_RendererAdapter{nullptr}; MeshRef m_PresenceMarkerMesh; + MeshRef m_ColliderBoxMesh; + MeshRef m_ColliderSphereMesh; RenderViewResolver m_RenderViewResolver; mutable std::unordered_map m_PresenceMaterials; + mutable std::unordered_map m_ColliderMaterials; mutable std::mutex m_GizmoHoverMutex; std::unordered_map m_GizmoHoveredAxisByUser; mutable std::mutex m_GizmoModeMutex; diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 4c2c86ee..d21c2ea4 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -1286,6 +1286,9 @@ bool RemoteViewportServer::HandlePostRequest(uintptr_t ClientSocketValue, case HeadlessCommandType::SetViewMode: m_Host.SetRemoteViewMode(*User, Command->ViewMode); break; + case HeadlessCommandType::SetShowColliders: + m_Host.SetRemoteShowColliders(*User, Command->ShowColliders); + break; case HeadlessCommandType::SetLookActive: case HeadlessCommandType::SetViewportCameraPose: case HeadlessCommandType::UpdateViewportCamera: @@ -1299,6 +1302,10 @@ bool RemoteViewportServer::HandlePostRequest(uintptr_t ClientSocketValue, case HeadlessCommandType::SetTransform: case HeadlessCommandType::AttachScript: case HeadlessCommandType::DetachScript: + case HeadlessCommandType::PlaySession: + case HeadlessCommandType::PauseSession: + case HeadlessCommandType::ResumeSession: + case HeadlessCommandType::StopSession: case HeadlessCommandType::SetMeshAsset: case HeadlessCommandType::SetLightProperties: case HeadlessCommandType::SetMaterialProperties: @@ -1755,9 +1762,18 @@ bool RemoteViewportServer::HandleSessionConnectRequest( const WebRtcSessionStatus Status = Client.WebRtcSession != nullptr ? Client.WebRtcSession->GetStatus() : WebRtcSessionStatus{}; + const bool ShowColliders = + [&]() -> bool { + if (const HeadlessRenderViewState *View = + m_Host.FindRemoteRenderView(Client.ClientId); + View != nullptr) { + return View->ShowColliders; + } + return true; + }(); const std::string Payload = SerializeSessionConnectResponse( Client.ClientId, m_Host.GetHeadlessLayer().GetSession().GetState(), - Client.User, m_TransportConnected.load(), + Client.User, ShowColliders, m_TransportConnected.load(), m_TransportConnected.load() ? "connected" : "disconnected", Status.ConnectionState); const std::string Response = JsonResponse("200 OK", Payload); @@ -1845,8 +1861,24 @@ bool RemoteViewportServer::HandleGetRequest(uintptr_t ClientSocketValue, const WebRtcSessionStatus Status = ClientId.has_value() ? GetClientWebRtcStatus(*ClientId) : WebRtcSessionStatus{}; + const bool ShowColliders = + [&]() -> bool { + if (ClientId.has_value()) { + if (const HeadlessRenderViewState *View = + m_Host.FindRemoteRenderView(*ClientId); + View != nullptr) { + return View->ShowColliders; + } + } + if (const HeadlessRenderViewState *View = m_Host.FindRenderView(*User); + View != nullptr) { + return View->ShowColliders; + } + return true; + }(); const std::string Body = SerializeSessionSnapshot( m_Host.GetHeadlessLayer().GetSession().GetState(), *User, + ShowColliders, m_TransportConnected.load(), m_TransportConnected.load() ? "connected" : "disconnected", Status.ConnectionState); @@ -2769,6 +2801,9 @@ bool RemoteViewportServer::HandleWebSocketMessage(uintptr_t ClientSocketValue, case HeadlessCommandType::SetViewMode: m_Host.SetRemoteViewMode(Command->ViewMode); return true; + case HeadlessCommandType::SetShowColliders: + m_Host.SetRemoteShowColliders(Command->ShowColliders); + return true; case HeadlessCommandType::DropMesh: HandleMeshDropCommand(m_Host.GetHeadlessLayer().GetLocalUserId(), *Command); return true; @@ -2787,6 +2822,10 @@ bool RemoteViewportServer::HandleWebSocketMessage(uintptr_t ClientSocketValue, case HeadlessCommandType::SetTransform: case HeadlessCommandType::AttachScript: case HeadlessCommandType::DetachScript: + case HeadlessCommandType::PlaySession: + case HeadlessCommandType::PauseSession: + case HeadlessCommandType::ResumeSession: + case HeadlessCommandType::StopSession: case HeadlessCommandType::SetMeshAsset: case HeadlessCommandType::SetLightProperties: case HeadlessCommandType::SetMaterialProperties: @@ -2856,6 +2895,9 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, case HeadlessCommandType::SetViewMode: m_Host.SetRemoteViewMode(Client->User, Command->ViewMode); return true; + case HeadlessCommandType::SetShowColliders: + m_Host.SetRemoteShowColliders(Client->User, Command->ShowColliders); + return true; case HeadlessCommandType::SetLookActive: case HeadlessCommandType::SetViewportCameraPose: case HeadlessCommandType::UpdateViewportCamera: @@ -2869,6 +2911,10 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, case HeadlessCommandType::SetTransform: case HeadlessCommandType::AttachScript: case HeadlessCommandType::DetachScript: + case HeadlessCommandType::PlaySession: + case HeadlessCommandType::PauseSession: + case HeadlessCommandType::ResumeSession: + case HeadlessCommandType::StopSession: case HeadlessCommandType::SetMeshAsset: case HeadlessCommandType::SetLightProperties: case HeadlessCommandType::SetMaterialProperties: @@ -2878,6 +2924,7 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, case HeadlessCommandType::DropMesh: HandleMeshDropCommand(Client->User, *Command); return true; + case HeadlessCommandType::ReloadScripts: { m_Host.ReloadUserScripts(); if (Client->WebRtcSession != nullptr) { @@ -2959,6 +3006,86 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, } return true; } + } else if (Name == "physicsBodyType" || Name == "physicsColliderType" || + Name == "physicsBoxHalfExtents" || Name == "physicsSphereRadius" || + Name == "physicsMass" || Name == "physicsFriction" || + Name == "physicsRestitution") { + const auto &DetailsById = + m_Host.GetHeadlessLayer().GetSession().GetState().Scene.ObjectDetailsById; + const auto It = DetailsById.find(ObjId); + if (It == DetailsById.end() || !It->second.SupportsTransform) { + return false; + } + + Axiom::EditorPhysicsProperties Physics = + It->second.Physics.value_or(Axiom::EditorPhysicsProperties{}); + if (Name == "physicsBodyType") { + const auto *S = std::get_if(&Val); + if (S == nullptr) { + return false; + } + if (*S == "none") { + Physics.BodyType = Axiom::EditorPhysicsBodyType::None; + } else if (*S == "static") { + Physics.BodyType = Axiom::EditorPhysicsBodyType::Static; + } else if (*S == "dynamic") { + Physics.BodyType = Axiom::EditorPhysicsBodyType::Dynamic; + } else { + return false; + } + } else if (Name == "physicsColliderType") { + const auto *S = std::get_if(&Val); + if (S == nullptr) { + return false; + } + if (*S == "none") { + Physics.ColliderType = Axiom::EditorPhysicsColliderType::None; + } else if (*S == "box") { + Physics.ColliderType = Axiom::EditorPhysicsColliderType::Box; + } else if (*S == "sphere") { + Physics.ColliderType = Axiom::EditorPhysicsColliderType::Sphere; + } else { + return false; + } + } else if (Name == "physicsBoxHalfExtents") { + const auto *V = std::get_if(&Val); + if (V == nullptr) { + return false; + } + Physics.BoxHalfExtents = *V; + } else if (Name == "physicsSphereRadius") { + const auto *Number = std::get_if(&Val); + if (Number == nullptr) { + return false; + } + Physics.SphereRadius = *Number; + } else if (Name == "physicsMass") { + const auto *Number = std::get_if(&Val); + if (Number == nullptr) { + return false; + } + Physics.Mass = *Number; + } else if (Name == "physicsFriction") { + const auto *Number = std::get_if(&Val); + if (Number == nullptr) { + return false; + } + Physics.Friction = *Number; + } else if (Name == "physicsRestitution") { + const auto *Number = std::get_if(&Val); + if (Number == nullptr) { + return false; + } + Physics.Restitution = *Number; + } + + m_Host.SubmitRemoteCommand( + Client->User, + EditorCommand{SetPhysicsPropertiesCommand{ + .ObjectId = ObjId, + .Physics = Physics, + }}); + return true; } else if (Name == "location" || Name == "rotationDegrees" || Name == "scale") { if (const auto *V = std::get_if(&Val)) { const auto &DetailsById = @@ -2997,6 +3124,11 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, return true; } case HeadlessCommandType::GizmoHover: { + if (m_Host.GetHeadlessLayer().GetSession().GetRuntimeState() != + EditorRuntimeState::Edit) { + m_Host.GetHeadlessLayer().SetGizmoHoveredAxis(Client->User, -1); + return true; + } if (Client->GizmoDrag.has_value()) { return true; } @@ -3029,6 +3161,10 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, return true; } case HeadlessCommandType::GizmoDragStart: { + if (m_Host.GetHeadlessLayer().GetSession().GetRuntimeState() != + EditorRuntimeState::Edit) { + return true; + } if (Client->GizmoDrag.has_value()) { return true; } @@ -3103,6 +3239,17 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, return true; } case HeadlessCommandType::GizmoDragUpdate: { + if (m_Host.GetHeadlessLayer().GetSession().GetRuntimeState() != + EditorRuntimeState::Edit) { + if (Client->GizmoDrag.has_value()) { + EditorSession &Session = m_Host.GetHeadlessLayer().GetSession(); + const std::string DragObjectId = Client->GizmoDrag->ObjectId; + Client->GizmoDrag.reset(); + Session.ReleaseLock(DragObjectId, Client->User); + m_Host.GetHeadlessLayer().SetGizmoHoveredAxis(Client->User, -1); + } + return true; + } if (!Client->GizmoDrag.has_value()) { return true; } diff --git a/Projects/batch3-blank-project/Content/Cooked/AssetCookManifest.json b/Projects/batch3-blank-project/Content/Cooked/AssetCookManifest.json index 2cb83e17..71555fe0 100644 --- a/Projects/batch3-blank-project/Content/Cooked/AssetCookManifest.json +++ b/Projects/batch3-blank-project/Content/Cooked/AssetCookManifest.json @@ -206,6 +206,11 @@ {"assetId":12140336635836207652,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__101","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__101.wmat","formatVersion":1,"sourceHash":3586948860202070916}, {"assetId":16670649961235633850,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__102","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__102.wtex","formatVersion":1,"sourceHash":1136906439044749114}, {"assetId":5457201860838636531,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__102","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__102.wmat","formatVersion":1,"sourceHash":6145656221699350966}, - {"assetId":13913950202207721753,"kind":"mesh","relativePath":"sponza_atrium_3.glb","cookedPath":"Cooked/sponza_atrium_3.wmesh","formatVersion":2,"sourceHash":13152113367551948137} + {"assetId":13913950202207721753,"kind":"mesh","relativePath":"sponza_atrium_3.glb","cookedPath":"Cooked/sponza_atrium_3.wmesh","formatVersion":2,"sourceHash":13152113367551948137}, + {"assetId":6419001625350043339,"kind":"texture","relativePath":"Generated/MeshTextures/trout__0","cookedPath":"Cooked/Generated/MeshTextures/trout__0.wtex","formatVersion":1,"sourceHash":5886763761298696808}, + {"assetId":7556121013933574936,"kind":"material","relativePath":"Generated/MeshMaterials/trout__0","cookedPath":"Cooked/Generated/MeshMaterials/trout__0.wmat","formatVersion":1,"sourceHash":17660163787741318390}, + {"assetId":7899683460070073077,"kind":"texture","relativePath":"Generated/MeshTextures/trout__1","cookedPath":"Cooked/Generated/MeshTextures/trout__1.wtex","formatVersion":1,"sourceHash":12699784328467437287}, + {"assetId":3547444530683706770,"kind":"material","relativePath":"Generated/MeshMaterials/trout__1","cookedPath":"Cooked/Generated/MeshMaterials/trout__1.wmat","formatVersion":1,"sourceHash":15174070660722794267}, + {"assetId":4629282750075033978,"kind":"mesh","relativePath":"trout.glb","cookedPath":"Cooked/trout.wmesh","formatVersion":2,"sourceHash":4135491888657007981} ] } diff --git a/Projects/batch3-blank-project/Content/scene.json b/Projects/batch3-blank-project/Content/scene.json index fb87ae2e..247483e0 100644 --- a/Projects/batch3-blank-project/Content/scene.json +++ b/Projects/batch3-blank-project/Content/scene.json @@ -4,12 +4,14 @@ "nodes": [ {"id":"world","parentId":null,"displayName":"World","kind":"Folder","visible":true}, {"id":"Mesh","parentId":"world","displayName":"Mesh","kind":"Mesh","visible":true}, - {"id":"Light","parentId":"world","displayName":"Light","kind":"Light","visible":true} + {"id":"Light","parentId":"world","displayName":"Light","kind":"Light","visible":true}, + {"id":"Mesh_2","parentId":"world","displayName":"Mesh 2","kind":"Mesh","visible":true} ], "objects": [ - {"id":"Light","displayName":"Light","kind":"Light","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[5.60545,7.46207,2.64436],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"lightColor":[1,1,1],"lightIntensity":7,"lightDirection":[0.35,0.7,0.2]}, + {"id":"Mesh_2","displayName":"Mesh 2","kind":"Mesh","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[3.83602,14,2.72024],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"assetRelativePath":"trout.glb","physicsBodyType":"dynamic","physicsColliderType":"box","physicsBoxHalfExtents":[0.0852263,0.345423,0.29578],"physicsSphereRadius":0.5,"physicsMass":1,"physicsFriction":0.2,"physicsRestitution":0}, {"id":"world","displayName":"World","kind":"Folder","visible":true,"isGeneratedAssetChild":false,"supportsTransform":false,"transformReadOnly":true}, - {"id":"Mesh","displayName":"Mesh","kind":"Mesh","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[-0.0819834,0.688007,2.82843],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"assetRelativePath":"sponza_atrium_3.glb"} + {"id":"Mesh","displayName":"Mesh","kind":"Mesh","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[-0.0819834,0.688007,2.82843],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"assetRelativePath":"sponza_atrium_3.glb","physicsBodyType":"static","physicsColliderType":"box","physicsBoxHalfExtents":[15.3676,11.4355,9.46246],"physicsSphereRadius":0.5,"physicsMass":1,"physicsFriction":0.2,"physicsRestitution":1}, + {"id":"Light","displayName":"Light","kind":"Light","visible":true,"isGeneratedAssetChild":false,"supportsTransform":true,"transformReadOnly":false,"location":[5.60545,7.46207,2.64436],"rotationDegrees":[-0,0,0],"scale":[1,1,1],"lightColor":[1,1,1],"lightIntensity":7,"lightDirection":[0.35,0.7,0.2]} ], "meshNameToObjectId": { diff --git a/Projects/batch3-blank-project/Content/trout.glb b/Projects/batch3-blank-project/Content/trout.glb new file mode 100644 index 00000000..956000fd Binary files /dev/null and b/Projects/batch3-blank-project/Content/trout.glb differ diff --git a/README.md b/README.md index e67c61fb..c416b414 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Wraith Engine is a C++/Vulkan game engine runtime paired with a browser-based editor shell. The engine runs headless on a server, streams rendered viewports to browser clients via WebRTC with H.264, and processes editing commands through an authoritative command/event model. One shared runtime supports both local native editing and remotely hosted collaborative sessions. +Collaborative simulation is session-wide: when the simulation host presses `Play`, `Pause`, or `Stop`, every connected collaborator sees the same runtime state. In the current model, the first connected browser collaborator becomes the simulation host for that session, while the headless renderer keeps its reserved local render user. + ## Architecture ``` @@ -35,6 +37,9 @@ AxiomRemoteViewportServer (C++) - WebRTC streaming to browser - C# scripting via [Coral](https://github.com/StudioCherno/Coral) (.NET 9, hot reload, two trust tiers) - Scene persistence — `scene.json` save/load across restarts +- Session-wide Play / Pause / Stop with authoritative edit-snapshot restore +- Runtime-only Jolt physics stepping with pause / resume support +- Default static box collision for imported mesh assets, with load-time migration for older meshes that had no authored physics yet **Browser editor** - Dockable panels: outliner, details/property inspector, content browser, toolbar @@ -45,6 +50,8 @@ AxiomRemoteViewportServer (C++) - Light icons render as color-tinted billboards and are selectable from the remote viewport - User presence and camera visualization - Script class attachment and hot-reload button +- Inspector-driven physics authoring: body type, collider type, extents/radius, mass, friction, bounce +- Read-only physics visibility for generated mesh children, with inheritance hints pointing back to the authored root mesh object ## Prerequisites @@ -73,6 +80,23 @@ cmake --preset debug cmake --build build/debug ``` +### With physics enabled + +Physics uses Jolt and is currently enabled by default, but this is the explicit build if you want to guarantee it is on: + +```bash +cmake --preset debug -DAXIOM_ENABLE_PHYSICS=ON +cmake --build build/debug +``` + +To build tests against the physics-enabled runtime: + +```bash +cmake --preset debug -DBUILD_TESTING=ON -DAXIOM_ENABLE_PHYSICS=ON +cmake --build build/debug +ctest --test-dir build/debug +``` + ### With C# scripting enabled Build the managed assemblies first, then configure with the scripting flag: @@ -158,6 +182,7 @@ cmake --build build/release | `AXIOM_SCRIPTING_WATCH` | `BOOL` | `OFF` | Auto-reload user scripts on disk change (macOS kqueue). Requires `AXIOM_ENABLE_SCRIPTING=ON` | | `AXIOM_SCRIPTING_TRUST_DEFAULT` | `STRING` | `Restricted` | Default sandbox tier for user scripts. `Restricted` (hosted — blocks `System.Net.*`, `System.Reflection.Emit`, etc.) or `Trusted` (local dev — full BCL access) | | `AXIOM_ENABLE_WEBRTC` | `BOOL` | `OFF` | Enable the macOS WebRTC transport | +| `AXIOM_ENABLE_PHYSICS` | `BOOL` | `ON` | Enable the JoltPhysics runtime simulation seam | | `AXIOM_WEBRTC_FRAMEWORK_PATH` | `PATH` | _(empty)_ | Path to a `WebRTC.framework` bundle (macOS framework variant) | | `AXIOM_WEBRTC_LIBRARY_PATH` | `FILEPATH` | _(empty)_ | Path to a `libwebrtc` static/shared binary (non-framework variant) | | `AXIOM_WEBRTC_INCLUDE_DIR` | `PATH` | _(empty)_ | Include directory for the non-framework libwebrtc variant | diff --git a/Tests/HeadlessProtocolTests.cpp b/Tests/HeadlessProtocolTests.cpp index b50d362d..d9aca6f6 100644 --- a/Tests/HeadlessProtocolTests.cpp +++ b/Tests/HeadlessProtocolTests.cpp @@ -169,6 +169,31 @@ TEST(HeadlessProtocolTests, RemoteViewportAcceptsSetGridSnapCommand) { EXPECT_FLOAT_EQ(Command->ScaleStep, 0.05f); } +TEST(HeadlessProtocolTests, RemoteViewportAcceptsPlaySessionCommand) { + std::string Error; + const auto Command = Axiom::ParseRemoteViewportCommand( + R"json({"type":"play_session"})json", Error); + + ASSERT_TRUE(Command.has_value()) << Error; + EXPECT_EQ(Command->Type, Axiom::HeadlessCommandType::PlaySession); + EXPECT_TRUE( + std::holds_alternative(Command->EditorPayload.Payload)); +} + +TEST(HeadlessProtocolTests, RemoteViewportAcceptsNumericSetPropertyCommand) { + std::string Error; + const auto Command = Axiom::ParseRemoteViewportCommand( + R"json({"type":"set_property","objectId":"ball","property":"physicsMass","value":2.5})json", + Error); + + ASSERT_TRUE(Command.has_value()) << Error; + EXPECT_EQ(Command->Type, Axiom::HeadlessCommandType::SetProperty); + ASSERT_TRUE(Command->PropertyVal.has_value()); + const auto *Value = std::get_if(&*Command->PropertyVal); + ASSERT_NE(Value, nullptr); + EXPECT_FLOAT_EQ(*Value, 2.5f); +} + TEST(HeadlessProtocolTests, SerializesCommandRejectedEvent) { const Axiom::PublishedEditorEvent Event{ .Id = Axiom::EventId{4}, @@ -284,6 +309,20 @@ TEST(HeadlessProtocolTests, SerializesObjectTransformUpdatedEvent) { EXPECT_NE(Json.find("\"scale\":[1,1.5,2]"), std::string::npos); } +TEST(HeadlessProtocolTests, SerializesRuntimeStateChangedEvent) { + const Axiom::PublishedEditorEvent Event{ + .Id = Axiom::EventId{17}, + .Event = {.Payload = Axiom::RuntimeStateChangedEvent{ + .User = Axiom::SessionUserId{1}, + .State = Axiom::EditorRuntimeState::Playing, + }}}; + + const std::string Json = Axiom::SerializeEvent(Event); + EXPECT_NE(Json.find("\"payloadType\":\"runtime_state_changed\""), + std::string::npos); + EXPECT_NE(Json.find("\"runtimeState\":\"playing\""), std::string::npos); +} + TEST(HeadlessProtocolTests, SerializesRemoteViewportLifecycleMessages) { EXPECT_EQ(Axiom::SerializeConnected(), "{\"type\":\"connected\"}"); EXPECT_EQ(Axiom::SerializeDisconnected(), "{\"type\":\"disconnected\"}"); @@ -348,6 +387,15 @@ TEST(HeadlessProtocolTests, SerializesSessionSnapshot) { .RotationDegrees = glm::vec3(4.0f, 5.0f, 6.0f), .Scale = glm::vec3(1.0f, 1.0f, 1.0f), }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .BoxHalfExtents = glm::vec3(0.5f, 0.75f, 1.0f), + .SphereRadius = 1.25f, + .Mass = 3.5f, + .Friction = 0.6f, + .Restitution = 0.4f, + }, }, }}, .CollaborationByObjectId = {{ @@ -363,9 +411,12 @@ TEST(HeadlessProtocolTests, SerializesSessionSnapshot) { }; const std::string Json = Axiom::SerializeSessionSnapshot( - State, Axiom::SessionUserId{1}, true, "connected", "connected"); + State, Axiom::SessionUserId{1}, true, true, "connected", "connected"); EXPECT_NE(Json.find("\"type\":\"session_snapshot\""), std::string::npos); EXPECT_NE(Json.find("\"currentUserId\":1"), std::string::npos); + EXPECT_NE(Json.find("\"runtimeControllerUserId\":1"), std::string::npos); + EXPECT_NE(Json.find("\"showColliders\":true"), std::string::npos); + EXPECT_NE(Json.find("\"runtimeState\":\"edit\""), std::string::npos); EXPECT_NE(Json.find("\"participants\""), std::string::npos); EXPECT_NE(Json.find("\"displayName\":\"Local User\""), std::string::npos); EXPECT_NE(Json.find("\"presenceState\":\"connected\""), std::string::npos); @@ -381,6 +432,13 @@ TEST(HeadlessProtocolTests, SerializesSessionSnapshot) { EXPECT_NE(Json.find("\"supportsTransform\":true"), std::string::npos); EXPECT_NE(Json.find("\"transformReadOnly\":true"), std::string::npos); EXPECT_NE(Json.find("\"location\":[1,2,3]"), std::string::npos); + EXPECT_NE(Json.find("\"physics\":{\"bodyType\":\"dynamic\""), + std::string::npos); + EXPECT_NE(Json.find("\"colliderType\":\"sphere\""), std::string::npos); + EXPECT_NE(Json.find("\"sphereRadius\":1.25"), std::string::npos); + EXPECT_NE(Json.find("\"mass\":3.5"), std::string::npos); + EXPECT_NE(Json.find("\"friction\":0.6"), std::string::npos); + EXPECT_NE(Json.find("\"restitution\":0.4"), std::string::npos); EXPECT_NE(Json.find("\"selectedByUserIds\":[1]"), std::string::npos); EXPECT_NE(Json.find("\"lockState\":\"locked\""), std::string::npos); EXPECT_NE(Json.find("\"lockOwnerUserId\":1"), std::string::npos); @@ -402,7 +460,7 @@ TEST(HeadlessProtocolTests, SerializesSessionConnectResponse) { }; const std::string Json = Axiom::SerializeSessionConnectResponse( - "client-7", State, Axiom::SessionUserId{7}, true, "connected", + "client-7", State, Axiom::SessionUserId{7}, true, true, "connected", "new"); EXPECT_NE(Json.find("\"type\":\"session_connect\""), std::string::npos); EXPECT_NE(Json.find("\"clientId\":\"client-7\""), std::string::npos); @@ -686,7 +744,7 @@ TEST(HeadlessProtocolTests, SerializesObjectDetailsWithMaterial) { State.SelectedObjectIds[Axiom::SessionUserId{1}] = "crate-1"; const std::string Json = Axiom::SerializeSessionSnapshot( - State, Axiom::SessionUserId{1}, true, "connected", "connected"); + State, Axiom::SessionUserId{1}, true, true, "connected", "connected"); EXPECT_NE(Json.find("\"material\":{"), std::string::npos); EXPECT_NE(Json.find("\"baseColorFactor\":[0.5,0.5,0.5,1]"), std::string::npos); EXPECT_NE(Json.find("\"metallic\":0"), std::string::npos); @@ -706,6 +764,6 @@ TEST(HeadlessProtocolTests, SerializesObjectDetailsWithNullMaterialForLights) { State.SelectedObjectIds[Axiom::SessionUserId{1}] = "sun"; const std::string Json = Axiom::SerializeSessionSnapshot( - State, Axiom::SessionUserId{1}, true, "connected", "connected"); + State, Axiom::SessionUserId{1}, true, true, "connected", "connected"); EXPECT_NE(Json.find("\"material\":null"), std::string::npos); } diff --git a/Tests/LayerTests.cpp b/Tests/LayerTests.cpp index d278de95..5da59762 100644 --- a/Tests/LayerTests.cpp +++ b/Tests/LayerTests.cpp @@ -78,6 +78,8 @@ class FakeInputPlatform final : public Axiom::IInputPlatform { Axiom::CursorMode ModeSet{Axiom::CursorMode::Normal}; }; +class DummyMesh final : public Axiom::Mesh {}; + class RecordingEndpointSubscriber final : public Axiom::ISessionTransportSubscriber { public: @@ -1289,6 +1291,107 @@ TEST(HeadlessSessionLayerTests, BuildLightBillboardsUsesVisibleLightsOnly) { Billboards.end()); } +TEST(HeadlessSessionLayerTests, BuildColliderOverlaySubmissionsUsesPhysicsData) { + Axiom::HeadlessSessionLayer Layer; + Layer.SetColliderMeshesForTesting(std::make_shared(), + std::make_shared()); + Layer.GetSession().SetObjectDetails({ + { + .ObjectId = "static-box", + .DisplayName = "Static Box", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = {1.0f, 2.0f, 3.0f}, + .RotationDegrees = {0.0f, 45.0f, 0.0f}, + .Scale = {2.0f, 3.0f, 4.0f}, + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Static, + .ColliderType = Axiom::EditorPhysicsColliderType::Box, + .BoxHalfExtents = {1.5f, 0.5f, 0.25f}, + }, + }, + { + .ObjectId = "dynamic-sphere", + .DisplayName = "Dynamic Sphere", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = {-1.0f, 4.0f, 2.0f}, + .Scale = {1.0f, 1.0f, 1.0f}, + }, + .WorldTransform = Axiom::EditorTransformDetails{ + .Location = {-3.0f, 5.0f, 8.0f}, + .RotationDegrees = {10.0f, 0.0f, 20.0f}, + .Scale = {1.0f, 2.0f, 1.0f}, + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .SphereRadius = 0.75f, + }, + }, + { + .ObjectId = "hidden-physics", + .DisplayName = "Hidden Physics", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = false, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = {9.0f, 9.0f, 9.0f}, + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Static, + .ColliderType = Axiom::EditorPhysicsColliderType::Box, + }, + }, + }); + + const std::vector Submissions = + Layer.BuildColliderOverlaySubmissions(); + + ASSERT_EQ(Submissions.size(), 18u); + const size_t TranslucentCount = static_cast(std::count_if( + Submissions.begin(), Submissions.end(), + [](const Axiom::RenderMeshSubmission &Submission) { + return Submission.Translucent; + })); + EXPECT_EQ(TranslucentCount, 2u); + const auto StaticIt = std::find_if( + Submissions.begin(), Submissions.end(), + [](const Axiom::RenderMeshSubmission &Submission) { + return Submission.Name == "static-box-collider"; + }); + ASSERT_NE(StaticIt, Submissions.end()); + EXPECT_TRUE(StaticIt->Translucent); + EXPECT_FLOAT_EQ(StaticIt->Transform[3].x, 1.0f); + EXPECT_FLOAT_EQ(StaticIt->Transform[3].y, 2.0f); + EXPECT_FLOAT_EQ(StaticIt->Transform[3].z, 3.0f); + EXPECT_NE(StaticIt->Material, nullptr); + EXPECT_GT(StaticIt->Material->BaseColorFactor.g, 0.8f); + EXPECT_GT(glm::length(glm::vec3(StaticIt->Transform[0])), 3.0f); + + const auto DynamicIt = std::find_if( + Submissions.begin(), Submissions.end(), + [](const Axiom::RenderMeshSubmission &Submission) { + return Submission.Name == "dynamic-sphere-collider"; + }); + ASSERT_NE(DynamicIt, Submissions.end()); + EXPECT_TRUE(DynamicIt->Translucent); + EXPECT_FLOAT_EQ(DynamicIt->Transform[3].x, -3.0f); + EXPECT_FLOAT_EQ(DynamicIt->Transform[3].y, 5.0f); + EXPECT_FLOAT_EQ(DynamicIt->Transform[3].z, 8.0f); + EXPECT_NE(DynamicIt->Material, nullptr); + EXPECT_GT(DynamicIt->Material->BaseColorFactor.r, 0.9f); + EXPECT_LT(DynamicIt->Material->BaseColorFactor.a, 0.5f); +} + TEST(SvgTextureTests, LightbulbSvgRasterizesToValidTexture) { const std::filesystem::path IconPath = std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / "lightbulb.svg"; diff --git a/Tests/SceneLifecycleTests.cpp b/Tests/SceneLifecycleTests.cpp index 6d69dc29..e55a78a9 100644 --- a/Tests/SceneLifecycleTests.cpp +++ b/Tests/SceneLifecycleTests.cpp @@ -20,10 +20,11 @@ class RecordingSubscriber final : public Axiom::IEditorEventSubscriber { std::vector Events; }; -Axiom::CommandContext MakeContext(uint64_t FrameIndex = 1) { +Axiom::CommandContext MakeContext(uint64_t FrameIndex = 1, + uint64_t UserId = 7) { return { .Session = Axiom::SessionId{1}, - .User = Axiom::SessionUserId{7}, + .User = Axiom::SessionUserId{UserId}, .FrameIndex = FrameIndex, .DeltaTimeSeconds = 1.0f / 60.0f, }; @@ -97,6 +98,594 @@ std::filesystem::path WriteSingleMeshObj(const std::filesystem::path &ContentRoo } // namespace +TEST(SceneLifecycleTests, HostCanTransitionRuntimeStateThroughSimulationModes) { + Axiom::EditorSession Session = MakeWorldSession(); + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); + + Session.Submit(MakeContext(2, 1), {.Payload = Axiom::PauseSessionCommand{}}); + Session.Tick(); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Paused); + + Session.Submit(MakeContext(3, 1), {.Payload = Axiom::ResumeSessionCommand{}}); + Session.Tick(); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); + + Session.Submit(MakeContext(4, 1), {.Payload = Axiom::StopSessionCommand{}}); + Session.Tick(); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Edit); + + const auto *RuntimeChanged = + FindEvent(Subscriber.Events); + ASSERT_NE(RuntimeChanged, nullptr); +} + +TEST(SceneLifecycleTests, NonHostCannotControlRuntimeState) { + Axiom::EditorSession Session = MakeWorldSession(); + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.Submit(MakeContext(), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Edit); + const auto *Rejected = + FindEvent(Subscriber.Events); + ASSERT_NE(Rejected, nullptr); + EXPECT_NE(Rejected->Reason.find("host"), std::string::npos); +} + +TEST(SceneLifecycleTests, FirstConnectedCollaboratorControlsRuntimeState) { + Axiom::EditorSession Session = MakeWorldSession(); + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.SetPresence({ + { + .User = Axiom::SessionUserId{1}, + .DisplayName = "Headless Host", + .State = Axiom::EditorUserPresenceState::Connected, + .IsLocal = true, + }, + { + .User = Axiom::SessionUserId{2}, + .DisplayName = "User 1", + .State = Axiom::EditorUserPresenceState::Connected, + .IsLocal = false, + }, + { + .User = Axiom::SessionUserId{3}, + .DisplayName = "User 2", + .State = Axiom::EditorUserPresenceState::Connected, + .IsLocal = false, + }, + }); + + EXPECT_EQ(Session.ResolveRuntimeControllerUser().Value, 2u); + + Session.Submit(MakeContext(1, 2), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); + + Subscriber.Events.clear(); + Session.Submit(MakeContext(2, 3), {.Payload = Axiom::PauseSessionCommand{}}); + Session.Tick(); + + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); + const auto *Rejected = + FindEvent(Subscriber.Events); + ASSERT_NE(Rejected, nullptr); + EXPECT_NE(Rejected->Reason.find("simulation host"), std::string::npos); +} + +TEST(SceneLifecycleTests, InvalidRuntimeTransitionsAreRejected) { + Axiom::EditorSession Session = MakeWorldSession(); + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PauseSessionCommand{}}); + Session.Tick(); + + const auto *PauseRejected = + FindEvent(Subscriber.Events); + ASSERT_NE(PauseRejected, nullptr); + EXPECT_NE(PauseRejected->Reason.find("only valid while playing"), + std::string::npos); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Edit); + + Subscriber.Events.clear(); + + Session.Submit(MakeContext(2, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); + + Subscriber.Events.clear(); + + Session.Submit(MakeContext(3, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + + const auto *PlayRejected = + FindEvent(Subscriber.Events); + ASSERT_NE(PlayRejected, nullptr); + EXPECT_NE(PlayRejected->Reason.find("only valid while in edit mode"), + std::string::npos); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); +} + +TEST(SceneLifecycleTests, AuthoringMutationsAreRejectedWhileSimulationIsActive) { + Axiom::EditorSession Session = MakeWorldSession(); + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.SetObjectDetails({ + { + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }, + { + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(1.0f, 2.0f, 3.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(1.0f), + }, + }, + }); + Session.SetSceneItems({{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + }}, + }}); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + ASSERT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Playing); + + Subscriber.Events.clear(); + Session.Submit(MakeContext(2, 1), + {.Payload = Axiom::SetTransformCommand{ + .ObjectId = "crate-1", + .Location = glm::vec3(9.0f, 8.0f, 7.0f), + .RotationDegrees = glm::vec3(15.0f), + .Scale = glm::vec3(2.0f), + }}); + Session.Tick(); + + const auto *TransformRejected = + FindEvent(Subscriber.Events); + ASSERT_NE(TransformRejected, nullptr); + EXPECT_NE(TransformRejected->Reason.find("simulation is active"), + std::string::npos); + ASSERT_EQ(FindEvent(Subscriber.Events), nullptr); + + const auto *DetailsAfterPlay = Session.FindObjectDetails("crate-1"); + ASSERT_NE(DetailsAfterPlay, nullptr); + ASSERT_TRUE(DetailsAfterPlay->Transform.has_value()); + EXPECT_EQ(DetailsAfterPlay->Transform->Location, glm::vec3(1.0f, 2.0f, 3.0f)); + + Subscriber.Events.clear(); + Session.Submit(MakeContext(3, 1), {.Payload = Axiom::PauseSessionCommand{}}); + Session.Tick(); + ASSERT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Paused); + + Subscriber.Events.clear(); + Session.Submit(MakeContext(4, 1), + {.Payload = Axiom::SetPhysicsPropertiesCommand{ + .ObjectId = "crate-1", + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .SphereRadius = 0.75f, + .Mass = 3.0f, + .Friction = 0.4f, + .Restitution = 0.2f, + }, + }}); + Session.Tick(); + + const auto *PhysicsRejected = + FindEvent(Subscriber.Events); + ASSERT_NE(PhysicsRejected, nullptr); + EXPECT_NE(PhysicsRejected->Reason.find("simulation is active"), + std::string::npos); + ASSERT_EQ(FindEvent(Subscriber.Events), nullptr); + + const auto *DetailsAfterPause = Session.FindObjectDetails("crate-1"); + ASSERT_NE(DetailsAfterPause, nullptr); + EXPECT_FALSE(DetailsAfterPause->Physics.has_value()); +} + +TEST(SceneLifecycleTests, PhysicsStepsDynamicBodiesOnlyWhilePlaying) { +#if !AXIOM_ENABLE_PHYSICS + GTEST_SKIP() << "Physics backend disabled for this build."; +#else + Axiom::EditorSession Session = MakeWorldSession(); + Session.SetObjectDetails({ + { + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }, + { + .ObjectId = "floor", + .DisplayName = "Floor", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(0.0f, -1.0f, 0.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(1.0f), + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Static, + .ColliderType = Axiom::EditorPhysicsColliderType::Box, + .BoxHalfExtents = glm::vec3(8.0f, 0.5f, 8.0f), + }, + }, + { + .ObjectId = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(0.0f, 5.0f, 0.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(1.0f), + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .SphereRadius = 0.5f, + .Mass = 1.0f, + }, + }, + }); + Session.SetSceneItems({{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "floor", + .DisplayName = "Floor", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + }, + { + .Id = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + }}, + }}); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(1.0f / 60.0f); + for (int Step = 0; Step < 60; ++Step) { + Session.Tick(1.0f / 60.0f); + } + + const auto *Ball = Session.FindObjectDetails("ball"); + ASSERT_NE(Ball, nullptr); + ASSERT_TRUE(Ball->WorldTransform.has_value() || Ball->Transform.has_value()); + const Axiom::EditorTransformDetails &Transform = + Ball->WorldTransform.has_value() ? *Ball->WorldTransform : *Ball->Transform; + EXPECT_LT(Transform.Location.y, 5.0f); +#endif +} + +TEST(SceneLifecycleTests, PhysicsPauseFreezesDynamicBodies) { +#if !AXIOM_ENABLE_PHYSICS + GTEST_SKIP() << "Physics backend disabled for this build."; +#else + Axiom::EditorSession Session = MakeWorldSession(); + Session.SetObjectDetails({ + { + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }, + { + .ObjectId = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(0.0f, 3.0f, 0.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(1.0f), + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .SphereRadius = 0.5f, + .Mass = 1.0f, + }, + }, + }); + Session.SetSceneItems({{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + }}, + }}); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + for (int Step = 0; Step < 20; ++Step) { + Session.Tick(1.0f / 60.0f); + } + Session.Submit(MakeContext(2, 1), {.Payload = Axiom::PauseSessionCommand{}}); + Session.Tick(1.0f / 60.0f); + + const auto *BeforePause = Session.FindObjectDetails("ball"); + ASSERT_NE(BeforePause, nullptr); + const float BeforeY = + (BeforePause->WorldTransform.has_value() ? BeforePause->WorldTransform->Location.y + : BeforePause->Transform->Location.y); + + for (int Step = 0; Step < 30; ++Step) { + Session.Tick(1.0f / 60.0f); + } + + const auto *AfterPause = Session.FindObjectDetails("ball"); + ASSERT_NE(AfterPause, nullptr); + const float AfterY = + (AfterPause->WorldTransform.has_value() ? AfterPause->WorldTransform->Location.y + : AfterPause->Transform->Location.y); + EXPECT_NEAR(AfterY, BeforeY, 0.0001f); +#endif +} + +TEST(SceneLifecycleTests, PhysicsStopRestoresPrePlayTransformState) { +#if !AXIOM_ENABLE_PHYSICS + GTEST_SKIP() << "Physics backend disabled for this build."; +#else + Axiom::EditorSession Session = MakeWorldSession(); + Session.SetObjectDetails({ + { + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }, + { + .ObjectId = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(1.0f, 4.0f, 0.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(1.0f), + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .SphereRadius = 0.5f, + .Mass = 1.0f, + }, + }, + }); + Session.SetSceneItems({{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + }}, + }}); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + for (int Step = 0; Step < 20; ++Step) { + Session.Tick(1.0f / 60.0f); + } + Session.Submit(MakeContext(2, 1), {.Payload = Axiom::StopSessionCommand{}}); + Session.Tick(1.0f / 60.0f); + + const auto *Ball = Session.FindObjectDetails("ball"); + ASSERT_NE(Ball, nullptr); + ASSERT_TRUE(Ball->Transform.has_value()); + EXPECT_EQ(Ball->Transform->Location, glm::vec3(1.0f, 4.0f, 0.0f)); +#endif +} + +TEST(SceneLifecycleTests, StopSessionRestoresPrePlayTransformState) { + Axiom::EditorSession Session = MakeWorldSession(); + Session.SetObjectDetails({ + { + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }, + { + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(1.0f, 2.0f, 3.0f), + .RotationDegrees = glm::vec3(0.0f, 0.0f, 0.0f), + .Scale = glm::vec3(1.0f), + }, + }, + }); + Session.SetSceneItems({{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .Children = {}, + }}, + }}); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + Session.Submit(MakeContext(2, 1), + {.Payload = Axiom::SetTransformCommand{ + .ObjectId = "crate-1", + .Location = glm::vec3(9.0f, 8.0f, 7.0f), + .RotationDegrees = glm::vec3(10.0f, 20.0f, 30.0f), + .Scale = glm::vec3(2.0f), + }}); + Session.Tick(); + Session.Submit(MakeContext(3, 1), {.Payload = Axiom::StopSessionCommand{}}); + Session.Tick(); + + const auto *Details = Session.FindObjectDetails("crate-1"); + ASSERT_NE(Details, nullptr); + ASSERT_TRUE(Details->Transform.has_value()); + EXPECT_EQ(Session.GetRuntimeState(), Axiom::EditorRuntimeState::Edit); + EXPECT_EQ(Details->Transform->Location, glm::vec3(1.0f, 2.0f, 3.0f)); + EXPECT_EQ(Details->Transform->RotationDegrees, glm::vec3(0.0f, 0.0f, 0.0f)); + EXPECT_EQ(Details->Transform->Scale, glm::vec3(1.0f)); +} + +TEST(SceneLifecycleTests, StopSessionRemovesObjectsCreatedDuringPlay) { + Axiom::EditorSession Session = MakeWorldSession(); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + Axiom::CommandContext ScriptCtx = MakeContext(2, 1); + ScriptCtx.IsScriptContext = true; + Session.Submit(ScriptCtx, + {.Payload = Axiom::CreateObjectCommand{.TemplateId = "Mesh"}}); + Session.Tick(); + + const auto *WorldBeforeStop = Session.FindSceneItem("world"); + ASSERT_NE(WorldBeforeStop, nullptr); + ASSERT_EQ(WorldBeforeStop->Children.size(), 1u); + const std::string CreatedId = WorldBeforeStop->Children.front().Id; + + Session.Submit(MakeContext(3, 1), {.Payload = Axiom::StopSessionCommand{}}); + Session.Tick(); + + const auto *WorldAfterStop = Session.FindSceneItem("world"); + ASSERT_NE(WorldAfterStop, nullptr); + EXPECT_TRUE(WorldAfterStop->Children.empty()); + EXPECT_EQ(Session.FindSceneItem(CreatedId), nullptr); + EXPECT_EQ(Session.FindObjectDetails(CreatedId), nullptr); +} + +TEST(SceneLifecycleTests, StopSessionRestoresPrePlayMaterialState) { + auto Mat = std::make_shared(); + Mat->BaseColorFactor = glm::vec4(0.2f, 0.3f, 0.4f, 1.0f); + Mat->Metallic = 0.1f; + Mat->Roughness = 0.8f; + + Axiom::EditorSession Session(Axiom::SessionId{1}); + Session.SetSceneItems({{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .Children = {}, + }}); + Session.SetObjectDetails({{ + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{}, + .Material = Axiom::EditorMaterialProperties{ + .BaseColorFactor = glm::vec4(0.2f, 0.3f, 0.4f, 1.0f), + .Metallic = 0.1f, + .Roughness = 0.8f, + }, + }}); + Session.SetSceneMeshInstances({{ + .ObjectId = "crate-1", + .Mesh = {}, + .Material = Mat, + .RenderPath = Axiom::MeshRenderPath::Graphics, + .Transform = glm::mat4(1.0f), + }}); + + Session.Submit(MakeContext(1, 1), {.Payload = Axiom::PlaySessionCommand{}}); + Session.Tick(); + Session.Submit(MakeContext(2, 1), + {.Payload = Axiom::SetMaterialPropertiesCommand{ + .ObjectId = "crate-1", + .BaseColorFactor = glm::vec4(0.9f, 0.1f, 0.2f, 1.0f), + .Metallic = 0.95f, + .Roughness = 0.05f, + }}); + Session.Tick(); + Session.Submit(MakeContext(3, 1), {.Payload = Axiom::StopSessionCommand{}}); + Session.Tick(); + + const auto *Details = Session.FindObjectDetails("crate-1"); + ASSERT_NE(Details, nullptr); + ASSERT_TRUE(Details->Material.has_value()); + EXPECT_FLOAT_EQ(Details->Material->BaseColorFactor.r, 0.2f); + EXPECT_FLOAT_EQ(Details->Material->Metallic, 0.1f); + EXPECT_FLOAT_EQ(Details->Material->Roughness, 0.8f); + + const auto &Instances = Session.GetState().Scene.MeshInstances; + ASSERT_EQ(Instances.size(), 1u); + ASSERT_TRUE(Instances[0].Material != nullptr); + EXPECT_FLOAT_EQ(Instances[0].Material->BaseColorFactor.r, 0.2f); + EXPECT_FLOAT_EQ(Instances[0].Material->Metallic, 0.1f); + EXPECT_FLOAT_EQ(Instances[0].Material->Roughness, 0.8f); +} + // --------------------------------------------------------------------------- // Create // --------------------------------------------------------------------------- @@ -256,6 +845,12 @@ TEST(SceneLifecycleTests, CreateMeshObjectAddsMeshWithAssetAndTransform) { EXPECT_FLOAT_EQ(Details->Transform->Location.y, 2.0f); EXPECT_FLOAT_EQ(Details->Transform->Location.z, 3.0f); EXPECT_FLOAT_EQ(Details->WorldTransform->RotationDegrees.y, 45.0f); + ASSERT_TRUE(Details->Physics.has_value()); + EXPECT_EQ(Details->Physics->BodyType, Axiom::EditorPhysicsBodyType::Static); + EXPECT_EQ(Details->Physics->ColliderType, Axiom::EditorPhysicsColliderType::Box); + EXPECT_FLOAT_EQ(Details->Physics->BoxHalfExtents.x, 1.5f); + EXPECT_FLOAT_EQ(Details->Physics->BoxHalfExtents.y, 1.5f); + EXPECT_FLOAT_EQ(Details->Physics->BoxHalfExtents.z, 0.01f); const auto &Instances = Session.GetState().Scene.MeshInstances; const auto It = std::find_if(Instances.begin(), Instances.end(), @@ -327,6 +922,12 @@ TEST(SceneLifecycleTests, CreateMeshObjectExpandsMultiMeshAssetIntoGeneratedChil ASSERT_NE(ChildDetails, nullptr); EXPECT_TRUE(ChildDetails->IsGeneratedAssetChild); EXPECT_TRUE(ChildDetails->TransformReadOnly); + ASSERT_TRUE(RootDetails->Physics.has_value()); + EXPECT_EQ(RootDetails->Physics->BodyType, Axiom::EditorPhysicsBodyType::Static); + EXPECT_EQ(RootDetails->Physics->ColliderType, Axiom::EditorPhysicsColliderType::Box); + EXPECT_GT(RootDetails->Physics->BoxHalfExtents.x, 0.0f); + EXPECT_GT(RootDetails->Physics->BoxHalfExtents.y, 0.0f); + EXPECT_GT(RootDetails->Physics->BoxHalfExtents.z, 0.0f); } TEST(SceneLifecycleTests, CreateWithUnknownTemplateIdIsRejected) { @@ -1577,6 +2178,190 @@ TEST(SceneLifecycleTests, SceneFile_SaveLoadRoundTripsCookedMaterialState) { "Engine/tf2 coconut.jpg"); } +TEST(SceneLifecycleTests, SetPhysicsPropertiesUpdatesAuthoritativeDetails) { + Axiom::EditorSession Session = MakeWorldSession(); + RecordingSubscriber Subscriber; + Session.Subscribe(&Subscriber); + + Session.SetObjectDetails({ + { + .ObjectId = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }, + { + .ObjectId = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{}, + }, + }); + Session.SetSceneItems({{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + }}, + }}); + + Session.Submit(MakeContext(), + {.Payload = Axiom::SetPhysicsPropertiesCommand{ + .ObjectId = "ball", + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .SphereRadius = 0.75f, + .Mass = 2.5f, + .Friction = 0.35f, + .Restitution = 0.8f, + }, + }}); + Session.Tick(); + + ASSERT_EQ(FindEvent(Subscriber.Events), nullptr); + const auto *Details = Session.FindObjectDetails("ball"); + ASSERT_NE(Details, nullptr); + ASSERT_TRUE(Details->Physics.has_value()); + EXPECT_EQ(Details->Physics->BodyType, Axiom::EditorPhysicsBodyType::Dynamic); + EXPECT_EQ(Details->Physics->ColliderType, Axiom::EditorPhysicsColliderType::Sphere); + EXPECT_FLOAT_EQ(Details->Physics->SphereRadius, 0.75f); + EXPECT_FLOAT_EQ(Details->Physics->Mass, 2.5f); + EXPECT_FLOAT_EQ(Details->Physics->Friction, 0.35f); + EXPECT_FLOAT_EQ(Details->Physics->Restitution, 0.8f); + + const auto *Changed = + FindEvent(Subscriber.Events); + ASSERT_NE(Changed, nullptr); + EXPECT_EQ(Changed->ObjectId, "ball"); + EXPECT_EQ(Changed->Physics.BodyType, Axiom::EditorPhysicsBodyType::Dynamic); +} + +TEST(SceneLifecycleTests, SceneFile_SaveLoadRoundTripsPhysicsState) { + EnsureLogInitialized(); + + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-physics-scene-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content"); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + .Children = {}, + }}; + Scene.ObjectDetailsById["ball"] = Axiom::EditorObjectDetails{ + .ObjectId = "ball", + .DisplayName = "Ball", + .Kind = Axiom::EditorSceneItemKind::Actor, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(1.0f, 2.0f, 3.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(1.0f), + }, + .Physics = Axiom::EditorPhysicsProperties{ + .BodyType = Axiom::EditorPhysicsBodyType::Dynamic, + .ColliderType = Axiom::EditorPhysicsColliderType::Sphere, + .BoxHalfExtents = glm::vec3(0.25f, 0.5f, 0.75f), + .SphereRadius = 0.6f, + .Mass = 4.0f, + .Friction = 0.45f, + .Restitution = 0.25f, + }, + }; + + const auto ScenePath = TempRoot / "Content" / "scene.json"; + ASSERT_TRUE(Axiom::Assets::SaveSceneToFile(ScenePath, Scene)); + + const auto Loaded = Axiom::Assets::LoadSceneFromFile(ScenePath); + ASSERT_TRUE(Loaded.has_value()); + const auto DetailsIt = Loaded->ObjectDetailsById.find("ball"); + ASSERT_NE(DetailsIt, Loaded->ObjectDetailsById.end()); + ASSERT_TRUE(DetailsIt->second.Physics.has_value()); + EXPECT_EQ(DetailsIt->second.Physics->BodyType, + Axiom::EditorPhysicsBodyType::Dynamic); + EXPECT_EQ(DetailsIt->second.Physics->ColliderType, + Axiom::EditorPhysicsColliderType::Sphere); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->BoxHalfExtents.y, 0.5f); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->SphereRadius, 0.6f); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->Mass, 4.0f); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->Friction, 0.45f); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->Restitution, 0.25f); +} + +TEST(SceneLifecycleTests, SceneFile_LoadMigratesMissingMeshPhysicsToStaticBox) { + EnsureLogInitialized(); + + const auto TempRoot = + std::filesystem::temp_directory_path() / "wraithengine-mesh-physics-migration-test"; + std::error_code RemoveError; + std::filesystem::remove_all(TempRoot, RemoveError); + std::filesystem::create_directories(TempRoot / "Content"); + WriteSingleMeshObj(TempRoot / "Content"); + + Axiom::EditorSceneState Scene; + Scene.Items = {{ + .Id = "world", + .DisplayName = "World", + .Kind = Axiom::EditorSceneItemKind::Folder, + .Visible = true, + .Children = {{ + .Id = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .Children = {}, + }}, + }}; + Scene.ObjectDetailsById["crate-1"] = Axiom::EditorObjectDetails{ + .ObjectId = "crate-1", + .DisplayName = "Crate", + .Kind = Axiom::EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Axiom::EditorTransformDetails{ + .Location = glm::vec3(0.0f), + .RotationDegrees = glm::vec3(0.0f), + .Scale = glm::vec3(2.0f, 3.0f, 4.0f), + }, + .AssetRelativePath = "singlemesh.obj", + }; + + const auto ScenePath = TempRoot / "Content" / "scene.json"; + ASSERT_TRUE(Axiom::Assets::SaveSceneToFile(ScenePath, Scene)); + + const auto Loaded = Axiom::Assets::LoadSceneFromFile(ScenePath); + ASSERT_TRUE(Loaded.has_value()); + const auto DetailsIt = Loaded->ObjectDetailsById.find("crate-1"); + ASSERT_NE(DetailsIt, Loaded->ObjectDetailsById.end()); + ASSERT_TRUE(DetailsIt->second.Physics.has_value()); + EXPECT_EQ(DetailsIt->second.Physics->BodyType, + Axiom::EditorPhysicsBodyType::Static); + EXPECT_EQ(DetailsIt->second.Physics->ColliderType, + Axiom::EditorPhysicsColliderType::Box); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->BoxHalfExtents.x, 2.0f); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->BoxHalfExtents.y, 3.0f); + EXPECT_FLOAT_EQ(DetailsIt->second.Physics->BoxHalfExtents.z, 0.01f); +} + TEST(SceneLifecycleTests, SceneFile_SaveLoadRegeneratesMultiMeshChildrenWithoutDuplicatingThem) { EnsureLogInitialized(); diff --git a/Tests/ScriptingTests.cpp b/Tests/ScriptingTests.cpp index f6a2d710..c77a5bfc 100644 --- a/Tests/ScriptingTests.cpp +++ b/Tests/ScriptingTests.cpp @@ -27,6 +27,15 @@ static constexpr const char *kEngineManagedDir = AXIOM_MANAGED_DIR; static constexpr const char *kTestScriptsDir = AXIOM_TEST_SCRIPTS_DIR; static constexpr const char *kRestrictedScriptsDir = AXIOM_RESTRICTED_SCRIPTS_DIR; +Axiom::CommandContext HostContext(uint64_t FrameIndex = 1) { + return { + .Session = Axiom::SessionId{1}, + .User = Axiom::SessionUserId{1}, + .FrameIndex = FrameIndex, + .DeltaTimeSeconds = 1.0f / 60.0f, + }; +} + // ----------------------------------------------------------------------- // Fixture — one Coral runtime for the entire test binary // ----------------------------------------------------------------------- @@ -113,6 +122,10 @@ Axiom::EditorSession *ScriptingTest::s_Session = nullptr; // ----------------------------------------------------------------------- TEST_F(ScriptingTest, ScriptHostLifecycle) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(100), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } EXPECT_TRUE(s_Host->IsInitialized()); EXPECT_TRUE(s_Host->IsEngineAssemblyLoaded()); } @@ -125,10 +138,17 @@ TEST_F(ScriptingTest, ScriptHostLifecycle) { // ----------------------------------------------------------------------- TEST_F(ScriptingTest, InternalCallRoundTrip) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(101), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } s_Host->LoadUserAssembly( std::filesystem::path(kTestScriptsDir) / "WraithTestScripts.dll"); ASSERT_TRUE(s_Host->IsUserAssemblyLoaded()); + s_Session->Submit(HostContext(1), {.Payload = Axiom::PlaySessionCommand{}}); + s_Session->Tick(); + // Drive OnTick — queues a SetTransformCommand on the session. s_Host->Tick(1.0f / 60.0f); @@ -142,6 +162,8 @@ TEST_F(ScriptingTest, InternalCallRoundTrip) { EXPECT_FLOAT_EQ(Details->Transform->Location.x, 1.0f); EXPECT_FLOAT_EQ(Details->Transform->Location.y, 2.0f); EXPECT_FLOAT_EQ(Details->Transform->Location.z, 3.0f); + s_Session->Submit(HostContext(102), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); } // ----------------------------------------------------------------------- @@ -151,12 +173,19 @@ TEST_F(ScriptingTest, InternalCallRoundTrip) { // ----------------------------------------------------------------------- TEST_F(ScriptingTest, ScriptLifecycle) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(103), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } // LoadUserAssembly destroys any live scripts first, then re-instantiates // TickScript for actor1 (which has ScriptClass set). s_Host->LoadUserAssembly( std::filesystem::path(kTestScriptsDir) / "WraithTestScripts.dll"); ASSERT_TRUE(s_Host->IsUserAssemblyLoaded()); + s_Session->Submit(HostContext(2), {.Payload = Axiom::PlaySessionCommand{}}); + s_Session->Tick(); + s_Host->Tick(1.0f / 60.0f); s_Session->Tick(); @@ -165,6 +194,8 @@ TEST_F(ScriptingTest, ScriptLifecycle) { ASSERT_TRUE(Details->Transform.has_value()); // OnTick sets Location.x to 1 — confirm the script ran. EXPECT_FLOAT_EQ(Details->Transform->Location.x, 1.0f); + s_Session->Submit(HostContext(104), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); } // ----------------------------------------------------------------------- @@ -174,12 +205,19 @@ TEST_F(ScriptingTest, ScriptLifecycle) { // ----------------------------------------------------------------------- TEST_F(ScriptingTest, HotReload) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(105), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } const auto AssemblyPath = std::filesystem::path(kTestScriptsDir) / "WraithTestScripts.dll"; s_Host->LoadUserAssembly(AssemblyPath); ASSERT_TRUE(s_Host->IsUserAssemblyLoaded()); + s_Session->Submit(HostContext(3), {.Payload = Axiom::PlaySessionCommand{}}); + s_Session->Tick(); + // Simulate a hot reload: unload ALC, reload assembly, re-instantiate. s_Host->ReloadUserAssembly(); EXPECT_TRUE(s_Host->IsUserAssemblyLoaded()); @@ -192,6 +230,8 @@ TEST_F(ScriptingTest, HotReload) { ASSERT_NE(Details, nullptr); ASSERT_TRUE(Details->Transform.has_value()); EXPECT_FLOAT_EQ(Details->Transform->Location.x, 1.0f); + s_Session->Submit(HostContext(106), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); } // ----------------------------------------------------------------------- @@ -201,6 +241,10 @@ TEST_F(ScriptingTest, HotReload) { // ----------------------------------------------------------------------- TEST_F(ScriptingTest, RestrictedProfileBlocks) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(107), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } ASSERT_TRUE(s_Host->IsRestricted()) << "Host must be in Restricted mode for this test to be meaningful"; @@ -213,4 +257,68 @@ TEST_F(ScriptingTest, RestrictedProfileBlocks) { EXPECT_FALSE(s_Host->IsUserAssemblyLoaded()); } +TEST_F(ScriptingTest, EditModeDoesNotTickScripts) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(108), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } + s_Host->LoadUserAssembly( + std::filesystem::path(kTestScriptsDir) / "WraithTestScripts.dll"); + ASSERT_TRUE(s_Host->IsUserAssemblyLoaded()); + + const auto *Before = s_Session->FindObjectDetails("actor1"); + ASSERT_NE(Before, nullptr); + ASSERT_TRUE(Before->Transform.has_value()); + EXPECT_FLOAT_EQ(Before->Transform->Location.x, 0.0f); + + s_Host->Tick(1.0f / 60.0f); + s_Session->Tick(); + + const auto *After = s_Session->FindObjectDetails("actor1"); + ASSERT_NE(After, nullptr); + ASSERT_TRUE(After->Transform.has_value()); + EXPECT_FLOAT_EQ(After->Transform->Location.x, 0.0f); +} + +TEST_F(ScriptingTest, PauseFreezesScriptTicks) { + if (s_Session->GetRuntimeState() != Axiom::EditorRuntimeState::Edit) { + s_Session->Submit(HostContext(109), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); + } + s_Host->LoadUserAssembly( + std::filesystem::path(kTestScriptsDir) / "WraithTestScripts.dll"); + ASSERT_TRUE(s_Host->IsUserAssemblyLoaded()); + + s_Session->Submit(HostContext(4), {.Payload = Axiom::PlaySessionCommand{}}); + s_Session->Tick(); + s_Host->Tick(1.0f / 60.0f); + s_Session->Tick(); + + s_Session->Submit(HostContext(5), {.Payload = Axiom::PauseSessionCommand{}}); + s_Session->Tick(); + + auto ManualCtx = HostContext(6); + ManualCtx.IsScriptContext = true; + s_Session->Submit(ManualCtx, + {.Payload = Axiom::SetTransformCommand{ + .ObjectId = "actor1", + .Location = {0.0f, 0.0f, 0.0f}, + .RotationDegrees = {0.0f, 0.0f, 0.0f}, + .Scale = {1.0f, 1.0f, 1.0f}, + }}); + s_Session->Tick(); + + s_Host->Tick(1.0f / 60.0f); + s_Session->Tick(); + + const auto *Details = s_Session->FindObjectDetails("actor1"); + ASSERT_NE(Details, nullptr); + ASSERT_TRUE(Details->Transform.has_value()); + EXPECT_FLOAT_EQ(Details->Transform->Location.x, 0.0f); + EXPECT_FLOAT_EQ(Details->Transform->Location.y, 0.0f); + EXPECT_FLOAT_EQ(Details->Transform->Location.z, 0.0f); + s_Session->Submit(HostContext(110), {.Payload = Axiom::StopSessionCommand{}}); + s_Session->Tick(); +} + #endif // AXIOM_SCRIPTING_ENABLED