diff --git a/Axiom/Assets/SvgTexture.cpp b/Axiom/Assets/SvgTexture.cpp new file mode 100644 index 00000000..ec842310 --- /dev/null +++ b/Axiom/Assets/SvgTexture.cpp @@ -0,0 +1,332 @@ +#include "Assets/SvgTexture.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#endif + +namespace Axiom::Assets { +namespace { + +struct SvgViewBox { + float MinX{0.0f}; + float MinY{0.0f}; + float Width{0.0f}; + float Height{0.0f}; +}; + +std::string ReadTextFile(const std::filesystem::path &Path) { + std::ifstream Input(Path, std::ios::binary); + if (!Input.is_open()) { + return {}; + } + return std::string(std::istreambuf_iterator(Input), + std::istreambuf_iterator()); +} + +std::optional FindAttributeValue(std::string_view Text, + std::string_view Name) { + const std::string Key = std::string(Name) + "=\""; + const size_t Start = Text.find(Key); + if (Start == std::string_view::npos) { + return std::nullopt; + } + const size_t ValueStart = Start + Key.size(); + const size_t ValueEnd = Text.find('"', ValueStart); + if (ValueEnd == std::string_view::npos) { + return std::nullopt; + } + return Text.substr(ValueStart, ValueEnd - ValueStart); +} + +bool ParseFloatToken(const char *&Cursor, const char *End, float &OutValue) { + while (Cursor < End && + (std::isspace(static_cast(*Cursor)) || *Cursor == ',')) { + ++Cursor; + } + if (Cursor >= End) { + return false; + } + + char *ParseEnd = nullptr; + OutValue = std::strtof(Cursor, &ParseEnd); + if (ParseEnd == Cursor) { + return false; + } + Cursor = ParseEnd; + return true; +} + +std::optional ParseViewBox(std::string_view Value) { + SvgViewBox Result{}; + const char *Cursor = Value.data(); + const char *End = Value.data() + Value.size(); + if (!ParseFloatToken(Cursor, End, Result.MinX) || + !ParseFloatToken(Cursor, End, Result.MinY) || + !ParseFloatToken(Cursor, End, Result.Width) || + !ParseFloatToken(Cursor, End, Result.Height)) { + return std::nullopt; + } + if (Result.Width <= 0.0f || Result.Height <= 0.0f) { + return std::nullopt; + } + return Result; +} + +#if defined(__APPLE__) +class SvgPathParser { +public: + explicit SvgPathParser(std::string_view PathData) + : m_Cursor(PathData.data()), m_End(PathData.data() + PathData.size()) {} + + bool Parse(CGMutablePathRef Path) { + char Command = 0; + while (SkipSeparators()) { + if (std::isalpha(static_cast(*m_Cursor)) != 0) { + Command = *m_Cursor++; + } else if (Command == 0) { + return false; + } + + switch (Command) { + case 'M': + case 'm': + if (!ParseMoveTo(Path, Command == 'm')) { + return false; + } + break; + case 'L': + case 'l': + if (!ParseLineTo(Path, Command == 'l')) { + return false; + } + break; + case 'H': + case 'h': + if (!ParseHorizontalTo(Path, Command == 'h')) { + return false; + } + break; + case 'V': + case 'v': + if (!ParseVerticalTo(Path, Command == 'v')) { + return false; + } + break; + case 'C': + case 'c': + if (!ParseCurveTo(Path, Command == 'c')) { + return false; + } + break; + case 'Z': + case 'z': + CGPathCloseSubpath(Path); + m_Current = m_SubpathStart; + break; + default: + return false; + } + } + return true; + } + +private: + bool SkipSeparators() { + while (m_Cursor < m_End && + (std::isspace(static_cast(*m_Cursor)) || + *m_Cursor == ',')) { + ++m_Cursor; + } + return m_Cursor < m_End; + } + + bool HasNumberAhead() const { + const char *Probe = m_Cursor; + while (Probe < m_End && + (std::isspace(static_cast(*Probe)) || *Probe == ',')) { + ++Probe; + } + return Probe < m_End && + (std::isdigit(static_cast(*Probe)) != 0 || + *Probe == '-' || *Probe == '+' || *Probe == '.'); + } + + bool ReadNumber(float &OutValue) { + return ParseFloatToken(m_Cursor, m_End, OutValue); + } + + bool ReadPoint(CGPoint &OutPoint, bool Relative) { + float X = 0.0f; + float Y = 0.0f; + if (!ReadNumber(X) || !ReadNumber(Y)) { + return false; + } + OutPoint = CGPointMake(Relative ? m_Current.x + X : X, + Relative ? m_Current.y + Y : Y); + return true; + } + + bool ParseMoveTo(CGMutablePathRef Path, bool Relative) { + CGPoint Point{}; + if (!ReadPoint(Point, Relative)) { + return false; + } + CGPathMoveToPoint(Path, nullptr, Point.x, Point.y); + m_Current = Point; + m_SubpathStart = Point; + + while (HasNumberAhead()) { + if (!ReadPoint(Point, Relative)) { + return false; + } + CGPathAddLineToPoint(Path, nullptr, Point.x, Point.y); + m_Current = Point; + } + return true; + } + + bool ParseLineTo(CGMutablePathRef Path, bool Relative) { + while (HasNumberAhead()) { + CGPoint Point{}; + if (!ReadPoint(Point, Relative)) { + return false; + } + CGPathAddLineToPoint(Path, nullptr, Point.x, Point.y); + m_Current = Point; + } + return true; + } + + bool ParseHorizontalTo(CGMutablePathRef Path, bool Relative) { + while (HasNumberAhead()) { + float X = 0.0f; + if (!ReadNumber(X)) { + return false; + } + m_Current.x = Relative ? (m_Current.x + X) : X; + CGPathAddLineToPoint(Path, nullptr, m_Current.x, m_Current.y); + } + return true; + } + + bool ParseVerticalTo(CGMutablePathRef Path, bool Relative) { + while (HasNumberAhead()) { + float Y = 0.0f; + if (!ReadNumber(Y)) { + return false; + } + m_Current.y = Relative ? (m_Current.y + Y) : Y; + CGPathAddLineToPoint(Path, nullptr, m_Current.x, m_Current.y); + } + return true; + } + + bool ParseCurveTo(CGMutablePathRef Path, bool Relative) { + while (HasNumberAhead()) { + CGPoint C1{}; + CGPoint C2{}; + CGPoint End{}; + if (!ReadPoint(C1, Relative) || !ReadPoint(C2, Relative) || + !ReadPoint(End, Relative)) { + return false; + } + CGPathAddCurveToPoint(Path, nullptr, C1.x, C1.y, C2.x, C2.y, End.x, End.y); + m_Current = End; + } + return true; + } + + const char *m_Cursor{nullptr}; + const char *m_End{nullptr}; + CGPoint m_Current{0.0, 0.0}; + CGPoint m_SubpathStart{0.0, 0.0}; +}; + +TextureSourceDataRef RasterizeSvg(std::string_view SvgText, + uint32_t TargetSize) { + const auto ViewBoxText = FindAttributeValue(SvgText, "viewBox"); + const auto PathText = FindAttributeValue(SvgText, "d"); + if (!ViewBoxText.has_value() || !PathText.has_value()) { + return nullptr; + } + + const auto ViewBox = ParseViewBox(*ViewBoxText); + if (!ViewBox.has_value()) { + return nullptr; + } + + CGMutablePathRef Path = CGPathCreateMutable(); + SvgPathParser Parser(*PathText); + const bool Parsed = Parser.Parse(Path); + if (!Parsed) { + CGPathRelease(Path); + return nullptr; + } + + const float LongestSide = std::max(ViewBox->Width, ViewBox->Height); + const float Scale = static_cast(TargetSize) / LongestSide; + const uint32_t Width = + std::max(1u, static_cast(std::ceil(ViewBox->Width * Scale))); + const uint32_t Height = + std::max(1u, static_cast(std::ceil(ViewBox->Height * Scale))); + + auto Texture = std::make_shared(); + Texture->Width = Width; + Texture->Height = Height; + Texture->Pixels.resize(static_cast(Width) * static_cast(Height) * 4u, 0u); + + CGColorSpaceRef ColorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef Context = CGBitmapContextCreate( + Texture->Pixels.data(), Width, Height, 8, Width * 4, ColorSpace, + static_cast(kCGImageAlphaPremultipliedLast | + kCGBitmapByteOrder32Big)); + CGColorSpaceRelease(ColorSpace); + + if (Context == nullptr) { + CGPathRelease(Path); + return nullptr; + } + + CGContextSetAllowsAntialiasing(Context, true); + CGContextSetShouldAntialias(Context, true); + CGContextTranslateCTM(Context, 0.0f, static_cast(Height)); + CGContextScaleCTM(Context, Scale, -Scale); + CGContextTranslateCTM(Context, -ViewBox->MinX, -ViewBox->MinY); + CGContextAddPath(Context, Path); + CGContextSetRGBFillColor(Context, 1.0, 1.0, 1.0, 1.0); + CGContextFillPath(Context); + + CGContextRelease(Context); + CGPathRelease(Path); + return Texture->IsValid() ? Texture : nullptr; +} +#endif + +} // namespace + +TextureSourceDataRef LoadSvgTextureFromFile(const std::filesystem::path &Path, + uint32_t TargetSize) { + const std::string SvgText = ReadTextFile(Path); + if (SvgText.empty()) { + return nullptr; + } + +#if defined(__APPLE__) + return RasterizeSvg(SvgText, TargetSize); +#else + (void)TargetSize; + return nullptr; +#endif +} +} // namespace Axiom::Assets diff --git a/Axiom/Assets/SvgTexture.h b/Axiom/Assets/SvgTexture.h new file mode 100644 index 00000000..4c42eef0 --- /dev/null +++ b/Axiom/Assets/SvgTexture.h @@ -0,0 +1,10 @@ +#pragma once + +#include "Renderer/Material.h" + +#include + +namespace Axiom::Assets { +TextureSourceDataRef LoadSvgTextureFromFile(const std::filesystem::path &Path, + uint32_t TargetSize = 128); +} // namespace Axiom::Assets diff --git a/Axiom/CMakeLists.txt b/Axiom/CMakeLists.txt index 6d1ae37a..c906ba58 100644 --- a/Axiom/CMakeLists.txt +++ b/Axiom/CMakeLists.txt @@ -5,6 +5,7 @@ set(ENGINE_SOURCES Assets/IAssetSource.cpp Assets/MeshAsset.cpp Assets/SceneFile.cpp + Assets/SvgTexture.cpp Core/Application.cpp Core/GlfwEditorInputSource.cpp Core/GlfwWindow.cpp @@ -36,6 +37,7 @@ set(ENGINE_SOURCES Renderer/Vulkan/VulkanGizmoRenderer.cpp Renderer/Vulkan/VulkanImGuiRenderer.cpp Renderer/Vulkan/VulkanInitializers.cpp + Renderer/Vulkan/VulkanLightBillboardRenderer.cpp Renderer/Vulkan/VulkanMaterialResources.cpp Renderer/Vulkan/VulkanMesh.cpp Renderer/Vulkan/VulkanOcclusionCulling.cpp diff --git a/Axiom/Renderer/RenderCommand.cpp b/Axiom/Renderer/RenderCommand.cpp index 1404cd3d..421da02b 100644 --- a/Axiom/Renderer/RenderCommand.cpp +++ b/Axiom/Renderer/RenderCommand.cpp @@ -20,6 +20,13 @@ void RenderCommand::Submit(const RenderMeshSubmission &Submission) { } } +void RenderCommand::SubmitLightBillboard( + const LightBillboardOverlay &Billboard) { + if (s_ActiveScene) { + s_ActiveScene->LightBillboards.push_back(Billboard); + } +} + void RenderCommand::SetGizmoOverlay(const GizmoOverlayData &Gizmo) { if (s_ActiveScene) { s_ActiveScene->GizmoOverlay = Gizmo; diff --git a/Axiom/Renderer/RenderCommand.h b/Axiom/Renderer/RenderCommand.h index c463a29a..e790c7ce 100644 --- a/Axiom/Renderer/RenderCommand.h +++ b/Axiom/Renderer/RenderCommand.h @@ -10,6 +10,7 @@ class RenderCommand { static void BeginScene(RenderScene &Scene); static void SetCamera(const Camera &Camera); static void Submit(const RenderMeshSubmission &Submission); + static void SubmitLightBillboard(const LightBillboardOverlay &Billboard); static void SetGizmoOverlay(const GizmoOverlayData &Gizmo); static void SetSun(const DirectionalLight &Light); static void EndScene(); diff --git a/Axiom/Renderer/RenderScene.cpp b/Axiom/Renderer/RenderScene.cpp index 0f65cf3e..886fa50b 100644 --- a/Axiom/Renderer/RenderScene.cpp +++ b/Axiom/Renderer/RenderScene.cpp @@ -7,5 +7,6 @@ void RenderScene::Reset() { Submissions.clear(); GizmoOverlay.reset(); Sun.reset(); + LightBillboards.clear(); } } // namespace Axiom diff --git a/Axiom/Renderer/RenderScene.h b/Axiom/Renderer/RenderScene.h index 2b5ea03a..dce28791 100644 --- a/Axiom/Renderer/RenderScene.h +++ b/Axiom/Renderer/RenderScene.h @@ -7,6 +7,7 @@ #include #include +#include #include namespace Axiom { @@ -27,6 +28,13 @@ struct DirectionalLight { glm::vec3 Direction{0.35f, 0.7f, 0.2f}; // world-space, need not be normalized }; +struct LightBillboardOverlay { + std::string ObjectId; + glm::vec3 WorldPosition{0.0f}; + glm::vec4 Color{1.0f}; + float PixelSize{48.0f}; +}; + class RenderScene { public: void Reset(); @@ -36,5 +44,6 @@ class RenderScene { std::vector Submissions; std::optional GizmoOverlay; std::optional Sun; + std::vector LightBillboards; }; } // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanLightBillboardRenderer.cpp b/Axiom/Renderer/Vulkan/VulkanLightBillboardRenderer.cpp new file mode 100644 index 00000000..3d475172 --- /dev/null +++ b/Axiom/Renderer/Vulkan/VulkanLightBillboardRenderer.cpp @@ -0,0 +1,244 @@ +#include "Renderer/Vulkan/VulkanLightBillboardRenderer.h" + +#include "Core/Log.h" +#include "Renderer/Camera.h" +#include "Renderer/Vulkan/VulkanInitializers.h" +#include "Renderer/Vulkan/VulkanPipeline.h" +#include "Session/MeshPicking.h" + +#include +#include + +#ifndef AXIOM_CONTENT_DIR +#define AXIOM_CONTENT_DIR "Content" +#endif + +namespace Axiom { +namespace { + +struct BillboardPushConstants { + glm::mat4 ViewProjection{1.0f}; + glm::vec4 WorldPositionAndHalfSize{0.0f}; + glm::vec4 Color{1.0f}; + glm::vec4 CameraRight{1.0f, 0.0f, 0.0f, 0.0f}; + glm::vec4 CameraUp{0.0f, 1.0f, 0.0f, 0.0f}; +}; +static_assert(sizeof(BillboardPushConstants) <= 128, + "BillboardPushConstants exceeds guaranteed push constant minimum"); + +} // namespace + +void VulkanLightBillboardRenderer::Init(const InitInfo &Info, + DeletionQueue &DeletionQueue) { + m_Device = Info.Device; + + { + DescriptorLayoutBuilder Builder; + Builder.AddBinding(0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER); + m_DescriptorLayout = Builder.Build( + m_Device, VK_SHADER_STAGE_FRAGMENT_BIT); + } + + if (Info.DescriptorAllocator != nullptr && Info.TextureView != VK_NULL_HANDLE && + Info.TextureSampler != VK_NULL_HANDLE) { + m_DescriptorSet = + Info.DescriptorAllocator->Allocate(m_Device, m_DescriptorLayout); + VkDescriptorImageInfo TextureInfo{}; + TextureInfo.sampler = Info.TextureSampler; + TextureInfo.imageView = Info.TextureView; + TextureInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet TextureWrite = + VkInit::WriteDescriptorSet(VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + m_DescriptorSet, &TextureInfo, 0); + vkUpdateDescriptorSets(m_Device, 1, &TextureWrite, 0, VK_NULL_HANDLE); + } + + VkPushConstantRange PushConstantRange{}; + PushConstantRange.stageFlags = + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + PushConstantRange.offset = 0; + PushConstantRange.size = sizeof(BillboardPushConstants); + + VkPipelineLayoutCreateInfo LayoutInfo = VkInit::PipelineLayoutCreateInfo(); + LayoutInfo.setLayoutCount = 1; + LayoutInfo.pSetLayouts = &m_DescriptorLayout; + LayoutInfo.pushConstantRangeCount = 1; + LayoutInfo.pPushConstantRanges = &PushConstantRange; + + if (vkCreatePipelineLayout(m_Device, &LayoutInfo, VK_NULL_HANDLE, + &m_PipelineLayout) != VK_SUCCESS) { + A_ERROR("VulkanLightBillboardRenderer: failed to create pipeline layout"); + return; + } + + VkShaderModule VertexShader; + const std::string VertexPath = + std::string(AXIOM_CONTENT_DIR) + "/Shaders/light_billboard.vert.spv"; + if (!VkUtil::LoadShaderModule(VertexPath.c_str(), m_Device, &VertexShader)) { + A_ERROR("VulkanLightBillboardRenderer: failed to load vertex shader: {0}", + VertexPath); + return; + } + + VkShaderModule FragmentShader; + const std::string FragmentPath = + std::string(AXIOM_CONTENT_DIR) + "/Shaders/light_billboard.frag.spv"; + if (!VkUtil::LoadShaderModule(FragmentPath.c_str(), m_Device, + &FragmentShader)) { + A_ERROR("VulkanLightBillboardRenderer: failed to load fragment shader: {0}", + FragmentPath); + vkDestroyShaderModule(m_Device, VertexShader, VK_NULL_HANDLE); + return; + } + + std::array ShaderStages = { + VkInit::PipelineShaderStageCreateInfo(VK_SHADER_STAGE_VERTEX_BIT, + VertexShader), + VkInit::PipelineShaderStageCreateInfo(VK_SHADER_STAGE_FRAGMENT_BIT, + FragmentShader)}; + + VkPipelineVertexInputStateCreateInfo VertexInputInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO}; + + VkPipelineInputAssemblyStateCreateInfo InputAssembly{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO, + .topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST}; + + VkPipelineViewportStateCreateInfo ViewportState{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO, + .viewportCount = 1, + .scissorCount = 1}; + + VkPipelineRasterizationStateCreateInfo Rasterizer{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO, + .polygonMode = VK_POLYGON_MODE_FILL, + .cullMode = VK_CULL_MODE_NONE, + .frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, + .lineWidth = 1.0f}; + + VkPipelineMultisampleStateCreateInfo Multisampling{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO, + .rasterizationSamples = VK_SAMPLE_COUNT_1_BIT}; + + VkPipelineDepthStencilStateCreateInfo DepthStencil{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO, + .depthTestEnable = VK_FALSE, + .depthWriteEnable = VK_FALSE, + .depthBoundsTestEnable = VK_FALSE, + .stencilTestEnable = VK_FALSE}; + + VkPipelineColorBlendAttachmentState ColorBlendAttachment{}; + ColorBlendAttachment.colorWriteMask = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + ColorBlendAttachment.blendEnable = VK_TRUE; + ColorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + ColorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + ColorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; + ColorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + ColorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + ColorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; + + VkPipelineColorBlendStateCreateInfo ColorBlending{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO, + .attachmentCount = 1, + .pAttachments = &ColorBlendAttachment}; + + std::array DynamicStates = {VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo DynamicState{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO, + .dynamicStateCount = static_cast(DynamicStates.size()), + .pDynamicStates = DynamicStates.data()}; + + VkPipelineRenderingCreateInfo RenderingInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO, + .colorAttachmentCount = 1, + .pColorAttachmentFormats = &Info.DrawImageFormat}; + + VkGraphicsPipelineCreateInfo PipelineInfo{ + .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, + .pNext = &RenderingInfo, + .stageCount = static_cast(ShaderStages.size()), + .pStages = ShaderStages.data(), + .pVertexInputState = &VertexInputInfo, + .pInputAssemblyState = &InputAssembly, + .pViewportState = &ViewportState, + .pRasterizationState = &Rasterizer, + .pMultisampleState = &Multisampling, + .pDepthStencilState = &DepthStencil, + .pColorBlendState = &ColorBlending, + .pDynamicState = &DynamicState, + .layout = m_PipelineLayout, + .renderPass = VK_NULL_HANDLE, + .subpass = 0}; + + if (vkCreateGraphicsPipelines(m_Device, VK_NULL_HANDLE, 1, &PipelineInfo, + VK_NULL_HANDLE, &m_Pipeline) != VK_SUCCESS) { + A_ERROR("VulkanLightBillboardRenderer: failed to create pipeline"); + } + + vkDestroyShaderModule(m_Device, VertexShader, VK_NULL_HANDLE); + vkDestroyShaderModule(m_Device, FragmentShader, VK_NULL_HANDLE); + + DeletionQueue.PushFunction([this]() { + vkDestroyPipeline(m_Device, m_Pipeline, VK_NULL_HANDLE); + vkDestroyPipelineLayout(m_Device, m_PipelineLayout, VK_NULL_HANDLE); + vkDestroyDescriptorSetLayout(m_Device, m_DescriptorLayout, VK_NULL_HANDLE); + }); +} + +void VulkanLightBillboardRenderer::DrawLightBillboards( + VkCommandBuffer CommandBuffer, VkExtent2D DrawExtent, VkImageView DrawImageView, + const RenderScene &Scene) { + if (Scene.ActiveCamera == nullptr || Scene.LightBillboards.empty() || + m_Pipeline == VK_NULL_HANDLE || m_DescriptorSet == VK_NULL_HANDLE) { + return; + } + + VkRenderingAttachmentInfo ColorAttachment = VkInit::AttachmentInfo( + DrawImageView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + VkRenderingInfo RenderingInfo = + VkInit::RenderingInfo(DrawExtent, &ColorAttachment, VK_NULL_HANDLE); + vkCmdBeginRendering(CommandBuffer, &RenderingInfo); + + vkCmdBindPipeline(CommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, m_Pipeline); + vkCmdBindDescriptorSets(CommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + m_PipelineLayout, 0, 1, &m_DescriptorSet, 0, nullptr); + + VkViewport Viewport{.x = 0.0f, + .y = 0.0f, + .width = static_cast(DrawExtent.width), + .height = static_cast(DrawExtent.height), + .minDepth = 0.0f, + .maxDepth = 1.0f}; + VkRect2D Scissor{.offset = {0, 0}, .extent = DrawExtent}; + vkCmdSetViewport(CommandBuffer, 0, 1, &Viewport); + vkCmdSetScissor(CommandBuffer, 0, 1, &Scissor); + + const glm::mat4 ViewProjection = + Scene.ActiveCamera->GetViewProjectionMatrix(); + const glm::vec3 CameraRight = Scene.ActiveCamera->GetRight(); + const glm::vec3 CameraUp = Scene.ActiveCamera->GetUp(); + + for (const LightBillboardOverlay &Billboard : Scene.LightBillboards) { + BillboardPushConstants Push{}; + Push.ViewProjection = ViewProjection; + Push.WorldPositionAndHalfSize = glm::vec4( + Billboard.WorldPosition, ComputeBillboardHalfSizeWorld( + *Scene.ActiveCamera, Billboard.WorldPosition, + Billboard.PixelSize, DrawExtent.height)); + Push.Color = Billboard.Color; + Push.CameraRight = glm::vec4(CameraRight, 0.0f); + Push.CameraUp = glm::vec4(CameraUp, 0.0f); + vkCmdPushConstants(CommandBuffer, m_PipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(BillboardPushConstants), &Push); + vkCmdDraw(CommandBuffer, 6, 1, 0, 0); + } + + vkCmdEndRendering(CommandBuffer); +} + +} // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanLightBillboardRenderer.h b/Axiom/Renderer/Vulkan/VulkanLightBillboardRenderer.h new file mode 100644 index 00000000..1569e738 --- /dev/null +++ b/Axiom/Renderer/Vulkan/VulkanLightBillboardRenderer.h @@ -0,0 +1,35 @@ +#pragma once + +#include "Renderer/RenderScene.h" +#include "Renderer/Vulkan/VulkanDeletionQueue.h" +#include "Renderer/Vulkan/VulkanDescriptors.h" +#include "Renderer/Vulkan/VulkanTypes.h" + +namespace Axiom { + +class VulkanLightBillboardRenderer { +public: + struct InitInfo { + VkDevice Device{VK_NULL_HANDLE}; + VkFormat DrawImageFormat{VK_FORMAT_UNDEFINED}; + DescriptorAllocator *DescriptorAllocator{nullptr}; + VkImageView TextureView{VK_NULL_HANDLE}; + VkSampler TextureSampler{VK_NULL_HANDLE}; + }; + + void Init(const InitInfo &Info, DeletionQueue &DeletionQueue); + + void DrawLightBillboards(VkCommandBuffer CommandBuffer, VkExtent2D DrawExtent, + VkImageView DrawImageView, const RenderScene &Scene); + + bool IsInitialized() const { return m_Pipeline != VK_NULL_HANDLE; } + +private: + VkDevice m_Device{VK_NULL_HANDLE}; + VkDescriptorSetLayout m_DescriptorLayout{VK_NULL_HANDLE}; + VkDescriptorSet m_DescriptorSet{VK_NULL_HANDLE}; + VkPipeline m_Pipeline{VK_NULL_HANDLE}; + VkPipelineLayout m_PipelineLayout{VK_NULL_HANDLE}; +}; + +} // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp index c43e5ef0..2849cfc4 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp +++ b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp @@ -1,5 +1,6 @@ #include "Renderer/Vulkan/VulkanRendererBackend.h" +#include "Assets/SvgTexture.h" #include "Renderer/RenderScene.h" #include "Renderer/Vulkan/VulkanBuffer.h" #include "Renderer/Vulkan/VulkanDescriptors.h" @@ -479,12 +480,23 @@ void VulkanRendererBackend::InitDescriptors() { void VulkanRendererBackend::InitTextureResources() { m_MaterialResources.InitFallbackTexture(); + const std::filesystem::path LightIconPath = + std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine" / "lightbulb.svg"; + if (const auto IconTexture = Assets::LoadSvgTextureFromFile(LightIconPath)) { + m_LightBillboardMaterial = std::make_shared(); + m_LightBillboardMaterial->BaseColorTexture = IconTexture; + } else { + A_CORE_WARN("Failed to load light billboard icon from {0}; using fallback texture", + LightIconPath.string()); + m_LightBillboardMaterial = std::make_shared(); + } } void VulkanRendererBackend::InitPipelines() { InitBackgroundPipelines(); InitMeshPipelines(); InitGizmoPipeline(); + InitLightBillboardPipeline(); } void VulkanRendererBackend::InitGizmoPipeline() { @@ -493,6 +505,18 @@ void VulkanRendererBackend::InitGizmoPipeline() { m_MainDeletionQueue); } +void VulkanRendererBackend::InitLightBillboardPipeline() { + const VkImageView TextureView = + m_MaterialResources.ResolveMaterialTextureView(m_LightBillboardMaterial); + m_LightBillboardRenderer.Init( + {.Device = m_Device.Device, + .DrawImageFormat = m_DrawImage.ImageFormat, + .DescriptorAllocator = &m_GlobalDescriptorAllocator, + .TextureView = TextureView, + .TextureSampler = m_TextureSampler}, + m_MainDeletionQueue); +} + void VulkanRendererBackend::InitBackgroundPipelines() { VkPipelineLayoutCreateInfo ComputeLayout = VkInit::PipelineLayoutCreateInfo(); @@ -1309,6 +1333,11 @@ void VulkanRendererBackend::Draw() { VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT, MeshFrame.TimestampQueryPool, 3); + if (m_ActiveScene != nullptr && m_LightBillboardRenderer.IsInitialized()) { + m_LightBillboardRenderer.DrawLightBillboards( + CommandBuffer, m_DrawExtent, m_DrawImage.ImageView, *m_ActiveScene); + } + if (m_ActiveScene != nullptr && m_GizmoRenderer.IsInitialized()) { m_GizmoRenderer.DrawGizmoOverlay(CommandBuffer, m_DrawExtent, m_DrawImage.ImageView, *m_ActiveScene); diff --git a/Axiom/Renderer/Vulkan/VulkanRendererBackend.h b/Axiom/Renderer/Vulkan/VulkanRendererBackend.h index 26461abc..44d8de23 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererBackend.h +++ b/Axiom/Renderer/Vulkan/VulkanRendererBackend.h @@ -9,6 +9,7 @@ #include "Renderer/Vulkan/VulkanDevice.h" #include "Renderer/Vulkan/VulkanGizmoRenderer.h" #include "Renderer/Vulkan/VulkanImGuiRenderer.h" +#include "Renderer/Vulkan/VulkanLightBillboardRenderer.h" #include "Renderer/Vulkan/VulkanMaterialResources.h" #include "Renderer/Vulkan/VulkanOcclusionCulling.h" #include "Renderer/Vulkan/VulkanRendererTypes.h" @@ -57,6 +58,7 @@ class VulkanRendererBackend final : public RendererBackend { void InitBackgroundPipelines(); void InitMeshPipelines(); void InitGizmoPipeline(); + void InitLightBillboardPipeline(); void InitMeshFrameResources(); void InitHzbResources(); @@ -150,9 +152,11 @@ class VulkanRendererBackend final : public RendererBackend { std::array m_MeshFrames{}; VulkanGizmoRenderer m_GizmoRenderer; VulkanImGuiRenderer m_ImGuiRenderer; + VulkanLightBillboardRenderer m_LightBillboardRenderer; VulkanMaterialResources m_MaterialResources; VulkanOcclusionCulling m_OcclusionCulling; VulkanSceneRenderer m_SceneRenderer; + MaterialInstanceRef m_LightBillboardMaterial; RenderScene *m_ActiveScene{nullptr}; RendererFrameStats m_FrameStats{}; float m_TimestampPeriod{0.0f}; diff --git a/Axiom/Session/EditorCommand.h b/Axiom/Session/EditorCommand.h index 0b8c26ca..c9db32c3 100644 --- a/Axiom/Session/EditorCommand.h +++ b/Axiom/Session/EditorCommand.h @@ -53,6 +53,13 @@ struct CreateObjectCommand { std::string TemplateId; }; +struct CreateMeshObjectCommand { + std::string AssetPath; + glm::vec3 Location{0.0f}; + glm::vec3 RotationDegrees{0.0f}; + glm::vec3 Scale{1.0f}; +}; + struct DuplicateObjectCommand { std::string ObjectId; }; @@ -111,7 +118,8 @@ using EditorCommandPayload = std::variant(Payload)) { return "create_object"; } + if (std::holds_alternative(Payload)) { + return "create_mesh_object"; + } if (std::holds_alternative(Payload)) { return "duplicate_object"; } @@ -931,6 +934,34 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, } } + if (const auto *CreateMeshCmd = + std::get_if(&QueuedCommand.Command.Payload)) { + if (CreateMeshCmd->AssetPath.empty()) { + FailureReason = "CreateMeshObject requires a non-empty asset path."; + return false; + } + if (FindWorldFolder() == nullptr) { + FailureReason = "No world folder found in scene root."; + return false; + } + if (CreateMeshCmd->Scale.x <= 0.0f || CreateMeshCmd->Scale.y <= 0.0f || + CreateMeshCmd->Scale.z <= 0.0f) { + FailureReason = "Scale values must be greater than zero."; + return false; + } + if (m_ContentDir.empty()) { + FailureReason = "CreateMeshObject requires a configured content directory."; + return false; + } + const std::filesystem::path FullPath = m_ContentDir / CreateMeshCmd->AssetPath; + const auto SceneData = Assets::LoadBasicMeshAsset(FullPath); + if (!SceneData.has_value() || SceneData->Instances.empty()) { + FailureReason = "CreateMeshObject failed to load mesh asset: " + + CreateMeshCmd->AssetPath + "."; + return false; + } + } + if (const auto *DupCmd = std::get_if(&QueuedCommand.Command.Payload)) { if (DupCmd->ObjectId.empty()) { @@ -1262,6 +1293,54 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, }}); } +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const CreateMeshObjectCommand &Command) { + EnsurePresence(QueuedCommand.Context.User); + + const std::string ObjectId = BuildUniqueObjectId("Mesh"); + const std::string DisplayName = BuildUniqueDisplayName("Mesh"); + const EditorTransformDetails Transform{ + .Location = Command.Location, + .RotationDegrees = Command.RotationDegrees, + .Scale = Command.Scale, + }; + + m_State.Scene.ObjectDetailsById.emplace( + ObjectId, + EditorObjectDetails{ + .ObjectId = ObjectId, + .DisplayName = DisplayName, + .Kind = EditorSceneItemKind::Mesh, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = Transform, + .WorldTransform = Transform, + }); + + if (Instance *Node = CreateInstanceForTemplate("Mesh", ObjectId)) { + Node->SetParent(FindWorldFolder()); + } + + SyncItemsFromTree(); + PublishEvent({.Payload = ObjectCreatedEvent{ + .User = QueuedCommand.Context.User, + .ObjectId = ObjectId, + .DisplayName = DisplayName, + }}); + + HandleCommand(QueuedCommand, SetMeshAssetCommand{ + .ObjectId = ObjectId, + .AssetPath = Command.AssetPath, + }); + HandleCommand(QueuedCommand, SetTransformCommand{ + .ObjectId = ObjectId, + .Location = Command.Location, + .RotationDegrees = Command.RotationDegrees, + .Scale = Command.Scale, + }); +} + void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, const DuplicateObjectCommand &Command) { EnsurePresence(QueuedCommand.Context.User); diff --git a/Axiom/Session/EditorSession.h b/Axiom/Session/EditorSession.h index 65f88af9..ebbfb675 100644 --- a/Axiom/Session/EditorSession.h +++ b/Axiom/Session/EditorSession.h @@ -245,6 +245,8 @@ class EditorSession final : public IEditorCommandSink { const SetObjectVisibilityCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const CreateObjectCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const CreateMeshObjectCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const DuplicateObjectCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, diff --git a/Axiom/Session/MeshPicking.h b/Axiom/Session/MeshPicking.h new file mode 100644 index 00000000..81e38f4b --- /dev/null +++ b/Axiom/Session/MeshPicking.h @@ -0,0 +1,284 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Axiom { + +struct ViewportRay { + glm::vec3 Origin{0.0f}; + glm::vec3 Direction{0.0f, 0.0f, -1.0f}; +}; + +struct ViewportObjectHitResult { + std::string ObjectId; + glm::vec3 WorldPosition{0.0f}; + float Distance{0.0f}; +}; + +using MeshHitResult = ViewportObjectHitResult; + +inline std::optional +BuildViewportRay(const Camera &Cam, uint32_t VpWidth, uint32_t VpHeight, + glm::vec2 MousePixel) { + if (VpWidth == 0 || VpHeight == 0) { + return std::nullopt; + } + + const glm::vec3 RayOrigin = Cam.GetPosition(); + const float NdcX = (MousePixel.x / static_cast(VpWidth)) * 2.0f - 1.0f; + const float NdcY = (MousePixel.y / static_cast(VpHeight)) * 2.0f - 1.0f; + const glm::vec4 WorldH = + glm::inverse(Cam.GetViewProjectionMatrix()) * glm::vec4(NdcX, NdcY, 0.0f, 1.0f); + if (glm::abs(WorldH.w) < 1e-6f) { + return std::nullopt; + } + + const glm::vec3 WorldPt = glm::vec3(WorldH) / WorldH.w; + const glm::vec3 RayDir = glm::normalize(WorldPt - RayOrigin); + if (!std::isfinite(RayDir.x) || !std::isfinite(RayDir.y) || + !std::isfinite(RayDir.z)) { + return std::nullopt; + } + + return ViewportRay{.Origin = RayOrigin, .Direction = RayDir}; +} + +inline std::optional +HitTestMeshesDetailed(const Camera &Cam, uint32_t VpWidth, uint32_t VpHeight, + glm::vec2 MousePixel, + const std::vector &Instances) { + const auto Ray = BuildViewportRay(Cam, VpWidth, VpHeight, MousePixel); + if (!Ray.has_value() || Instances.empty()) { + return std::nullopt; + } + + const glm::vec3 &RayOrigin = Ray->Origin; + const glm::vec3 &RayDir = Ray->Direction; + + float BestT = std::numeric_limits::infinity(); + std::optional BestHit; + + for (const EditorSceneMeshInstance &Instance : Instances) { + if (Instance.Mesh.BoundsMin == Instance.Mesh.BoundsMax) { + continue; + } + + const glm::mat4 InvT = glm::inverse(Instance.Transform); + const glm::vec3 LocalOrigin = glm::vec3(InvT * glm::vec4(RayOrigin, 1.0f)); + const glm::vec3 LocalDirRaw = glm::vec3(InvT * glm::vec4(RayDir, 0.0f)); + const float LocalDirLen = glm::length(LocalDirRaw); + if (LocalDirLen < 1e-6f) { + continue; + } + const glm::vec3 LocalDir = LocalDirRaw / LocalDirLen; + + const glm::vec3 &BMin = Instance.Mesh.BoundsMin; + const glm::vec3 &BMax = Instance.Mesh.BoundsMax; + float TMin = 0.0f; + float TMax = std::numeric_limits::infinity(); + bool Miss = false; + + for (int Axis = 0; Axis < 3; ++Axis) { + const float D = LocalDir[Axis]; + const float O = LocalOrigin[Axis]; + if (glm::abs(D) < 1e-8f) { + if (O < BMin[Axis] || O > BMax[Axis]) { + Miss = true; + break; + } + } else { + const float InvD = 1.0f / D; + float T1 = (BMin[Axis] - O) * InvD; + float T2 = (BMax[Axis] - O) * InvD; + if (T1 > T2) { + std::swap(T1, T2); + } + TMin = std::max(TMin, T1); + TMax = std::min(TMax, T2); + if (TMin > TMax) { + Miss = true; + break; + } + } + } + if (Miss || TMax < 0.0f) { + continue; + } + + const float LocalT = (TMin >= 0.0f) ? TMin : TMax; + const glm::vec3 WorldHit = glm::vec3( + Instance.Transform * glm::vec4(LocalOrigin + LocalT * LocalDir, 1.0f)); + const float WorldT = glm::dot(WorldHit - RayOrigin, RayDir); + if (WorldT > 0.0f && WorldT < BestT) { + BestT = WorldT; + BestHit = MeshHitResult{ + .ObjectId = Instance.ObjectId, + .WorldPosition = WorldHit, + .Distance = WorldT, + }; + } + } + + return BestHit; +} + +inline float ComputeBillboardHalfSizeWorld(const Camera &Cam, + const glm::vec3 &WorldPosition, + float PixelSize, + uint32_t VpHeight) { + const glm::vec3 ToBillboard = WorldPosition - Cam.GetPosition(); + const float ForwardDistance = + glm::dot(ToBillboard, glm::normalize(Cam.GetForward())); + const float Distance = std::max(ForwardDistance, 0.1f); + const float ProjectionY = glm::abs(Cam.GetProjectionMatrix()[1][1]); + if (ProjectionY < 0.0001f || VpHeight == 0) { + return 0.1f; + } + + const float TanHalfFov = 1.0f / ProjectionY; + const float WorldUnitsPerPixel = + (2.0f * Distance * TanHalfFov) / static_cast(VpHeight); + return std::max(0.01f, PixelSize * 0.5f * WorldUnitsPerPixel); +} + +inline std::optional +HitTestLightBillboardsDetailed(const Camera &Cam, uint32_t VpWidth, + uint32_t VpHeight, glm::vec2 MousePixel, + const std::vector &Billboards) { + const auto Ray = BuildViewportRay(Cam, VpWidth, VpHeight, MousePixel); + if (!Ray.has_value() || Billboards.empty()) { + return std::nullopt; + } + + const glm::vec3 PlaneNormal = glm::normalize(Cam.GetForward()); + const glm::vec3 PlaneRight = Cam.GetRight(); + const glm::vec3 PlaneUp = Cam.GetUp(); + + float BestDistance = std::numeric_limits::infinity(); + std::optional BestHit; + for (const LightBillboardOverlay &Billboard : Billboards) { + const float Denominator = glm::dot(Ray->Direction, PlaneNormal); + if (glm::abs(Denominator) < 1e-6f) { + continue; + } + + const float Distance = + glm::dot(Billboard.WorldPosition - Ray->Origin, PlaneNormal) / + Denominator; + if (Distance < 0.0f) { + continue; + } + + const glm::vec3 WorldHit = Ray->Origin + Ray->Direction * Distance; + const glm::vec3 Delta = WorldHit - Billboard.WorldPosition; + const float HalfSize = ComputeBillboardHalfSizeWorld( + Cam, Billboard.WorldPosition, Billboard.PixelSize, VpHeight); + const float AlongRight = glm::dot(Delta, PlaneRight); + const float AlongUp = glm::dot(Delta, PlaneUp); + if (glm::abs(AlongRight) > HalfSize || glm::abs(AlongUp) > HalfSize) { + continue; + } + + if (Distance < BestDistance) { + BestDistance = Distance; + BestHit = ViewportObjectHitResult{ + .ObjectId = Billboard.ObjectId, + .WorldPosition = WorldHit, + .Distance = Distance, + }; + } + } + + return BestHit; +} + +inline std::optional +ResolveViewportSelectionHit(const Camera &Cam, uint32_t VpWidth, + uint32_t VpHeight, glm::vec2 MousePixel, + const std::vector &Instances, + const std::vector &Billboards) { + const auto MeshHit = + HitTestMeshesDetailed(Cam, VpWidth, VpHeight, MousePixel, Instances); + const auto BillboardHit = HitTestLightBillboardsDetailed( + Cam, VpWidth, VpHeight, MousePixel, Billboards); + + if (MeshHit.has_value() && BillboardHit.has_value()) { + return MeshHit->Distance <= BillboardHit->Distance + ? std::optional(*MeshHit) + : BillboardHit; + } + if (MeshHit.has_value()) { + return *MeshHit; + } + if (BillboardHit.has_value()) { + return BillboardHit; + } + return std::nullopt; +} + +// Returns the ObjectId of the closest mesh instance hit by a ray cast from the +// camera through MousePixel, or an empty string if no mesh is hit. Uses AABB +// slab intersection in object space to handle arbitrary transforms correctly. +inline std::string HitTestMeshes(const Camera &Cam, uint32_t VpWidth, + uint32_t VpHeight, glm::vec2 MousePixel, + const std::vector &Instances) { + const auto Hit = HitTestMeshesDetailed(Cam, VpWidth, VpHeight, MousePixel, Instances); + return Hit.has_value() ? Hit->ObjectId : std::string{}; +} + +inline std::optional +IntersectViewportRayWithGroundPlane(const Camera &Cam, uint32_t VpWidth, + uint32_t VpHeight, glm::vec2 MousePixel, + float GroundY = 0.0f) { + const auto Ray = BuildViewportRay(Cam, VpWidth, VpHeight, MousePixel); + if (!Ray.has_value()) { + return std::nullopt; + } + + const float Denominator = Ray->Direction.y; + if (glm::abs(Denominator) < 1e-6f) { + return std::nullopt; + } + + const float T = (GroundY - Ray->Origin.y) / Denominator; + if (T < 0.0f) { + return std::nullopt; + } + + return Ray->Origin + Ray->Direction * T; +} + +inline glm::vec3 +ResolveViewportDropPosition(const Camera &Cam, uint32_t VpWidth, + uint32_t VpHeight, glm::vec2 MousePixel, + const std::vector &Instances, + float GroundY = 0.0f, float FallbackDistance = 3.0f) { + if (const auto Hit = + HitTestMeshesDetailed(Cam, VpWidth, VpHeight, MousePixel, Instances); + Hit.has_value()) { + return Hit->WorldPosition; + } + + if (const auto GroundHit = + IntersectViewportRayWithGroundPlane(Cam, VpWidth, VpHeight, MousePixel, GroundY); + GroundHit.has_value()) { + return *GroundHit; + } + + return Cam.GetPosition() + Cam.GetForward() * FallbackDistance; +} + +} // namespace Axiom diff --git a/Content/Shaders/light_billboard.frag b/Content/Shaders/light_billboard.frag new file mode 100644 index 00000000..20cfb810 --- /dev/null +++ b/Content/Shaders/light_billboard.frag @@ -0,0 +1,12 @@ +#version 460 + +layout(set = 0, binding = 0) uniform sampler2D iconTexture; + +layout(location = 0) in vec2 inUv; +layout(location = 1) in vec4 inColor; +layout(location = 0) out vec4 outColor; + +void main() { + vec4 texel = texture(iconTexture, inUv); + outColor = vec4(inColor.rgb * texel.rgb, inColor.a * texel.a); +} diff --git a/Content/Shaders/light_billboard.frag.spv b/Content/Shaders/light_billboard.frag.spv new file mode 100644 index 00000000..155d0296 Binary files /dev/null and b/Content/Shaders/light_billboard.frag.spv differ diff --git a/Content/Shaders/light_billboard.vert b/Content/Shaders/light_billboard.vert new file mode 100644 index 00000000..c7fc57b2 --- /dev/null +++ b/Content/Shaders/light_billboard.vert @@ -0,0 +1,32 @@ +#version 460 + +layout(push_constant) uniform BillboardPushConstants { + mat4 viewProjection; + vec4 worldPositionAndHalfSize; + vec4 color; + vec4 cameraRight; + vec4 cameraUp; +} push; + +layout(location = 0) out vec2 outUv; +layout(location = 1) out vec4 outColor; + +void main() { + const vec2 corners[6] = vec2[]( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, 1.0) + ); + + vec2 corner = corners[gl_VertexIndex]; + vec3 worldPosition = push.worldPositionAndHalfSize.xyz + + push.cameraRight.xyz * corner.x * push.worldPositionAndHalfSize.w + + push.cameraUp.xyz * corner.y * push.worldPositionAndHalfSize.w; + + gl_Position = push.viewProjection * vec4(worldPosition, 1.0); + outUv = vec2(corner.x * 0.5 + 0.5, 1.0 - (corner.y * 0.5 + 0.5)); + outColor = push.color; +} diff --git a/Content/Shaders/light_billboard.vert.spv b/Content/Shaders/light_billboard.vert.spv new file mode 100644 index 00000000..87e4af51 Binary files /dev/null and b/Content/Shaders/light_billboard.vert.spv differ diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index 792be051..48cb619f 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -47,6 +47,8 @@ - `VulkanGizmoRenderer` draws mode-appropriate handles: arrows for translate, arrows with perpendicular cross-caps for scale, and 24-segment screen-space rings for rotate; the hovered handle brightens in all modes - Gizmo mouse coordinates are forwarded using the correct `object-contain` content rect mapping so hit-testing is accurate regardless of the viewport aspect ratio or window size - `gizmoMode` state lives in `RemoteViewportContext` so the toolbar and viewport share a single source of truth without prop drilling +- Mesh assets can now be dragged from the content browser into the remote viewport to create mesh objects directly at the cursor-resolved spawn point +- Visible lights now render as color-tinted billboard icons in the remote viewport, and those billboards participate in remote click selection using the same gizmo-first input path - Collaboration v1 is now implemented: object locking, selection/lock visibility, presence roster, heartbeat-driven idle detection, and two-threshold disconnect (Away at 10 s, Disconnected at 30 s with lock release) - `EditorObjectLockState` (`Unlocked` / `Locked`) and `EditorObjectCollaborationState` live in `EditorSessionState`; `AcquireLock`, `ReleaseLock`, and `ReleaseAllLocksForUser` are public `EditorSession` methods - `ValidateCommand` rejects any mutating command (`SetTransform`, `Rename`, `SetObjectVisibility`, `Delete`, `Reparent`) on an object locked by a different user @@ -1177,6 +1179,7 @@ Likely targets based on current trajectory: | `Axiom/Session/EditorSession.cpp` | ~2,000 lines | Command dispatch, event publication, lock management, presence logic, schema generation | | `Headless/RemoteViewportServer.cpp` | ~1,500 lines | HTTP routing, WebSocket framing, WebRTC signaling, command parsing, client lifecycle | | `Headless/HeadlessCommandProtocol.cpp` | ~800 lines | Growing with every new command; serialization/deserialization should be generated or table-driven | +| viewport interaction / gizmo hit-testing path | multi-file | mode-specific hit testing, drag math, and interaction branching are starting to duplicate patterns and should move toward reusable primitives or strategies | #### 10.2 Proposed splits @@ -1199,6 +1202,12 @@ Likely targets based on current trajectory: - `CommandDeserializer` — inbound command JSON - Register new commands by adding a row to a dispatch table rather than growing `if/else` or `switch` chains +**Viewport interaction and hit-testing** → simplify into modular interaction primitives: +- extract per-tool interaction handlers so translate / rotate / scale / selection no longer expand one shared branch ladder +- separate hit-test resolution from drag execution so the same selection / gizmo queries can be reused by hover, drag-start, and future tools +- prefer data-driven handle descriptors or small strategy objects over repeated mode checks spread through the remote viewport path +- keep the authoritative command path unchanged while reducing the amount of bespoke branching needed to add a new viewport tool + #### 10.3 Acceptance criteria - No existing test regressions - No single `.cpp` file exceeds 600 lines after the refactor @@ -1285,6 +1294,8 @@ Progress update: - the details panel supports rename and transform editing; drafts are scoped to the selected object's ID so periodic server snapshot polls do not clobber edits in progress - viewport keyboard input (WASD, Space, Shift) is now gated on pointer lock state; keys are only consumed while the viewport has pointer lock and are cleared immediately when it releases, so other UI elements (inputs, the outliner) receive input normally - a server-side transform gizmo is now implemented across all three modes (Translate, Scale, Rotate); the toolbar Move/Rotate/Scale buttons and Q/E/R shortcuts switch modes with active-state feedback; dragging any handle drives `SetTransformCommand` through the same authoritative command path +- grid snapping is now configured from a toolbar dropdown instead of a fixed toggle preset; the selected move/rotate/scale increments are stored in shared viewport state and pushed to the server with `set_grid_snap`, so gizmo drags snap authoritatively +- the remote viewport header maximize button now toggles browser fullscreen on the viewport shell and reflects enter/exit state in its iconography - reparent is implemented: any object can be dragged onto any other in the outliner; transforms are stored in local space and world transforms are recomputed for the entire moved subtree - Collaboration v1 is complete: object locking prevents simultaneous gizmo conflicts, presence roster shows connected users, and the heartbeat/timeout loop handles hard tab closes - Phase 5 (Reflection and Asset Evolution) is complete: `AssetId` stable identity, `IAssetSource` / `LocalAssetSource` VFS, `ListAssets` / `GetSchema` / `SetProperty` / `SaveScene` commands, `SceneFile` JSON persistence, content browser wired to live asset catalogue, details panel schema-driven, toolbar Save button with success/failure animation diff --git a/Docs/HeadlessAxiomSessionPrototype.md b/Docs/HeadlessAxiomSessionPrototype.md index 0816cafd..2abd8b21 100644 --- a/Docs/HeadlessAxiomSessionPrototype.md +++ b/Docs/HeadlessAxiomSessionPrototype.md @@ -252,18 +252,32 @@ A server-side transform gizmo is now fully implemented on the `scene-editing` br - `gizmo_drag_start` / `gizmo_drag_end` (reliable): bracket the drag; drag start resolves the hit handle and captures object state; drag end commits the final transform - `gizmo_drag_update` (unreliable): sent every `mousemove` during drag; server applies the current drag math and dispatches `SetTransformCommand` - `set_gizmo_mode`: switches the per-client gizmo mode (`translate` / `scale` / `rotate`); server updates hit-test and rendering for subsequent frames +- `set_grid_snap`: updates per-client snap settings for gizmo drags; move snap can be disabled or set from the toolbar dropdown, while rotation and scale use the selected increments when snapping is enabled - snapshot refresh is suppressed while a gizmo drag is active to prevent server state polls from fighting the in-progress drag position ### Mode Switching - `GizmoMode` enum (Translate / Scale / Rotate) lives in `RenderScene.h` and is passed through `GizmoOverlayData` to the renderer - per-client mode is stored in `RemoteClientSession::CurrentGizmoMode` and in `HeadlessSessionLayer` for the render path - the toolbar Move, Rotate, and Scale buttons are now wired to `setGizmoMode` from `RemoteViewportContext`; active-state styling reflects the current mode +- the toolbar Grid Snap control is now a dropdown instead of a simple toggle; it stores shared snap settings in `RemoteViewportContext` and sends them over `set_grid_snap` so the authoritative drag path uses the chosen move/rotate/scale increments - Q = Translate, E = Scale, R = Rotate keyboard shortcuts fire when the viewport is focused and not in camera look mode - `gizmoMode` state lives in `RemoteViewportContext` so toolbar and viewport share a single source of truth +### Viewport Chrome +- the maximize button in the remote viewport header now toggles browser fullscreen on the viewport shell via the Fullscreen API +- the button swaps between enter/exit fullscreen icons based on `fullscreenchange`, so the viewport chrome stays in sync even if fullscreen exits outside the button + ### Coordinate Mapping Fix - mouse pixel coordinates sent to the server account for the `object-contain` CSS letterboxing on the video element: the actual content rectangle is computed from the uniform scale factor and centering offsets before mapping to server pixels, so hit-testing is accurate regardless of window aspect ratio +## Viewport Object Interaction + +- dragging a mesh asset from the content browser into the remote viewport now creates a new mesh object directly at the resolved drop point; the server prefers mesh-surface hit, then ground-plane intersection, then a fixed point in front of the camera +- visible light objects are rendered as camera-facing `lightbulb.svg` billboards tinted from the light color +- remote viewport click selection now considers those light billboards in addition to mesh hits +- selection remains gizmo-first; if no gizmo handle is hit, the server compares the nearest mesh hit and nearest light-billboard hit and selects whichever is closer in world space +- billboard selection targets the underlying light object id; hidden lights do not expose selectable billboards + ## Collaboration v1 Object locking, selection/lock visibility, presence indicators, and heartbeat-driven idle detection are now fully implemented. diff --git a/Editor/GlfwEditorLayer.cpp b/Editor/GlfwEditorLayer.cpp index 0b25b9ba..9fcf7199 100644 --- a/Editor/GlfwEditorLayer.cpp +++ b/Editor/GlfwEditorLayer.cpp @@ -5,7 +5,11 @@ #include #include +#define GLFW_INCLUDE_NONE +#include + #include +#include #include namespace Axiom { @@ -43,6 +47,34 @@ void GlfwEditorLayer::OnUpdate() { }); } m_Session.Tick(); + + // Left-click mesh picking — detect rising edge to select on click, not hold. + { + const bool IsLeftDown = m_WindowInputPlatform != nullptr && + m_WindowInputPlatform->IsMouseButtonPressed(GLFW_MOUSE_BUTTON_LEFT); + const bool ClickedNow = IsLeftDown && !m_LastLeftMouseDown; + m_LastLeftMouseDown = IsLeftDown; + + if (ClickedNow && Viewport != nullptr && !Viewport->IsLooking) { + const Window *Win = Application::Get().GetWindow(); + if (Win != nullptr) { + const glm::dvec2 CursorPos = m_WindowInputPlatform->GetCursorPosition(); + const std::string HitId = HitTestMeshes( + Viewport->Camera, Win->GetWidth(), Win->GetHeight(), + glm::vec2(CursorPos), m_Session.GetState().Scene.MeshInstances); + if (!HitId.empty()) { + const CommandContext Ctx{ + .Session = m_SessionId, + .User = m_LocalUserId, + .FrameIndex = Application::Get().GetFrameIndex(), + .DeltaTimeSeconds = Application::Get().GetDeltaTime(), + }; + m_Session.Submit(Ctx, EditorCommand{SelectObjectCommand{.ObjectId = HitId}}); + } + } + } + } + if (m_InputSource != nullptr) { m_InputSource->SyncViewport(m_Session.FindViewport(m_LocalUserId)); } diff --git a/Editor/GlfwEditorLayer.h b/Editor/GlfwEditorLayer.h index 730d1209..09e5984d 100644 --- a/Editor/GlfwEditorLayer.h +++ b/Editor/GlfwEditorLayer.h @@ -27,5 +27,6 @@ class GlfwEditorLayer final : public Layer { std::unique_ptr m_WindowInputPlatform; std::unique_ptr m_InputSource; float m_MoveSpeed{3.5f}; + bool m_LastLeftMouseDown{false}; }; } // namespace Axiom diff --git a/EditorFrontend/components/engine/content-browser.tsx b/EditorFrontend/components/engine/content-browser.tsx index 1d768ede..c5da57df 100644 --- a/EditorFrontend/components/engine/content-browser.tsx +++ b/EditorFrontend/components/engine/content-browser.tsx @@ -444,10 +444,16 @@ export function ContentBrowser() { e.dataTransfer.setData("axiom/asset-path", asset.path) e.dataTransfer.setData("axiom/asset-kind", asset.kind) e.dataTransfer.effectAllowed = "copy" + ;(window as any).__axiomDragAsset = { kind: asset.kind, path: asset.path } + }} + onDragEnd={(e) => { + ;(window as any).__axiomDragAsset = null + const h = (window as any).__axiomViewportDropHandler + if (h) h(e.clientX, e.clientY, asset.kind, asset.path) }} title={ canAssignToSelection && asset.kind === "mesh" - ? "Double-click or drag to assign to a mesh object" + ? "Double-click to assign, or drag into the viewport to add" : canAssignToSelection && asset.kind === "texture" ? "Double-click or drag to assign texture to a mesh object" : asset.path @@ -496,10 +502,16 @@ export function ContentBrowser() { e.dataTransfer.setData("axiom/asset-path", asset.path) e.dataTransfer.setData("axiom/asset-kind", asset.kind) e.dataTransfer.effectAllowed = "copy" + ;(window as any).__axiomDragAsset = { kind: asset.kind, path: asset.path } + }} + onDragEnd={(e) => { + ;(window as any).__axiomDragAsset = null + const h = (window as any).__axiomViewportDropHandler + if (h) h(e.clientX, e.clientY, asset.kind, asset.path) }} title={ canAssignToSelection && asset.kind === "mesh" - ? "Double-click or drag to assign to a mesh object" + ? "Double-click to assign, or drag into the viewport to add" : canAssignToSelection && asset.kind === "texture" ? "Double-click or drag to assign texture to a mesh object" : asset.path diff --git a/EditorFrontend/components/engine/details.tsx b/EditorFrontend/components/engine/details.tsx index b4b8be98..3eb174db 100644 --- a/EditorFrontend/components/engine/details.tsx +++ b/EditorFrontend/components/engine/details.tsx @@ -84,9 +84,23 @@ function DetailsContent({ useEffect(() => { setDraftName(details.displayName) - setDraft(toDraft(details)) }, [details.objectId]) + useEffect(() => { + setDraft(toDraft(details)) + }, [ + details.objectId, + details.transform?.location[0], + details.transform?.location[1], + details.transform?.location[2], + details.transform?.rotationDegrees[0], + details.transform?.rotationDegrees[1], + details.transform?.rotationDegrees[2], + details.transform?.scale[0], + details.transform?.scale[1], + details.transform?.scale[2], + ]) + const schemaTransformReadOnly = schema?.properties.find((p) => p.name === "location")?.readOnly ?? null const canEdit = diff --git a/EditorFrontend/components/engine/remote-viewport-context.tsx b/EditorFrontend/components/engine/remote-viewport-context.tsx index f697ecce..a34ceb02 100644 --- a/EditorFrontend/components/engine/remote-viewport-context.tsx +++ b/EditorFrontend/components/engine/remote-viewport-context.tsx @@ -34,6 +34,13 @@ export type RemoteViewportViewMode = "lit" | "unlit" | "wireframe" export type RemoteViewportGizmoMode = "translate" | "scale" | "rotate" export type SessionSceneItemKind = "folder" | "mesh" | "light" | "camera" | "actor" +export interface RemoteViewportGridSnapSettings { + enabled: boolean + translationStep: number + rotationStepDegrees: number + scaleStep: number +} + export interface SessionAssetDescriptor { id: number name: string @@ -130,6 +137,7 @@ interface RemoteViewportActions { toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise + setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise refreshSessionSnapshot: () => Promise selectObject: (objectId: string) => Promise renameObject: (objectId: string, displayName: string) => Promise @@ -181,6 +189,7 @@ interface RemoteViewportContextValue { sessionDetailText: string viewMode: RemoteViewportViewMode gizmoMode: RemoteViewportGizmoMode + gridSnapSettings: RemoteViewportGridSnapSettings isLooking: boolean eventLog: string[] serverOrigin: string @@ -254,6 +263,7 @@ interface RemoteViewportContextValue { toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise + setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise refreshSessionSnapshot: () => Promise selectObject: (objectId: string) => Promise renameObject: (objectId: string, displayName: string) => Promise @@ -298,11 +308,19 @@ function findSceneItem(items: SessionSceneItem[], objectId: string | null): Sess } export function RemoteViewportProvider({ children }: { children: ReactNode }) { + const defaultGridSnapSettings: RemoteViewportGridSnapSettings = { + enabled: true, + translationStep: 1, + rotationStepDegrees: 15, + scaleStep: 0.1, + } + const actionsRef = useRef({ reconnect: async () => {}, toggleLook: async () => {}, setMode: async () => {}, setGizmoMode: async () => {}, + setGridSnapSettings: async () => {}, refreshSessionSnapshot: async () => {}, selectObject: async () => false, renameObject: async () => false, @@ -335,6 +353,8 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { ) const [viewMode, setViewMode] = useState("lit") const [gizmoMode, setGizmoModeState] = useState("translate") + const [gridSnapSettings, setGridSnapSettingsState] = + useState(defaultGridSnapSettings) const [isLooking, setIsLooking] = useState(false) const [eventLog, setEventLog] = useState([]) const [serverOrigin, setServerOrigin] = useState("") @@ -447,6 +467,11 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { await actionsRef.current.setGizmoMode(mode) }, []) + const setGridSnapSettings = useCallback(async (settings: RemoteViewportGridSnapSettings) => { + setGridSnapSettingsState(settings) + await actionsRef.current.setGridSnapSettings(settings) + }, []) + const refreshSessionSnapshot = useCallback(async () => { await actionsRef.current.refreshSessionSnapshot() }, []) @@ -568,6 +593,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { sessionDetailText, viewMode, gizmoMode, + gridSnapSettings, isLooking, eventLog, serverOrigin, @@ -621,6 +647,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { toggleLook, setMode, setGizmoMode: setGizmoModeAction, + setGridSnapSettings, refreshSessionSnapshot, selectObject, renameObject, @@ -646,6 +673,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { frameText, isLooking, participants, + gridSnapSettings, sessionDetailText, sessionState, sessionStatusText, @@ -682,6 +710,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { gizmoMode, setMode, setGizmoModeAction, + setGridSnapSettings, setSessionDetailText, setSessionState, setSessionSnapshot, diff --git a/EditorFrontend/components/engine/toolbar.tsx b/EditorFrontend/components/engine/toolbar.tsx index dfe4a754..d850f699 100644 --- a/EditorFrontend/components/engine/toolbar.tsx +++ b/EditorFrontend/components/engine/toolbar.tsx @@ -22,14 +22,33 @@ import { Sun, Camera, RefreshCw, + ChevronDown, } from "lucide-react" -import { useRemoteViewport } from "./remote-viewport-context" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + useRemoteViewport, + type RemoteViewportGridSnapSettings, +} from "./remote-viewport-context" import { PresenceRoster } from "./presence-roster" +const TRANSLATION_SNAP_OPTIONS = [0.25, 0.5, 1, 5] as const +const ROTATION_SNAP_OPTIONS = [5, 10, 15, 45] as const +const SCALE_SNAP_OPTIONS = [0.01, 0.05, 0.1, 0.25] as const + export function Toolbar() { const { gizmoMode, + gridSnapSettings, setGizmoMode, + setGridSnapSettings, saveScene, saveStatus, setSaveStatus, @@ -117,7 +136,10 @@ export function Toolbar() { - + void setGridSnapSettings(settings)} + /> @@ -146,6 +168,100 @@ export function Toolbar() { ) } +function GridSnapDropdown({ + settings, + onChange, +}: { + settings: RemoteViewportGridSnapSettings + onChange: (settings: RemoteViewportGridSnapSettings) => void +}) { + return ( + + + + + + + Move Snap + + + onChange({ + ...settings, + enabled: value !== "0", + translationStep: value === "0" ? settings.translationStep : Number(value), + }) + } + > + Off + {TRANSLATION_SNAP_OPTIONS.map((value) => ( + + {formatSnapValue(value)} units + + ))} + + + + + Rotate Snap + + + onChange({ + ...settings, + rotationStepDegrees: Number(value), + }) + } + > + {ROTATION_SNAP_OPTIONS.map((value) => ( + + {value} degrees + + ))} + + + + + Scale Snap + + + onChange({ + ...settings, + scaleStep: Number(value), + }) + } + > + {SCALE_SNAP_OPTIONS.map((value) => ( + + {formatSnapValue(value)} scale + + ))} + + + + ) +} + +function formatSnapValue(value: number) { + return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0$/, "") +} + function ToolbarGroup({ children }: { children: React.ReactNode }) { return
{children}
} diff --git a/EditorFrontend/components/engine/viewport.tsx b/EditorFrontend/components/engine/viewport.tsx index d737d285..b68e71f9 100644 --- a/EditorFrontend/components/engine/viewport.tsx +++ b/EditorFrontend/components/engine/viewport.tsx @@ -3,8 +3,7 @@ import { useEffect, useRef, useState, type ElementType } from "react" import { Maximize2, - Grid3X3, - Eye, + Minimize2, Camera, ChevronDown, } from "lucide-react" @@ -17,6 +16,7 @@ import { } from "@/components/ui/dropdown-menu" import { useRemoteViewport, + type RemoteViewportGridSnapSettings, type SessionObjectTransformUpdate, type SessionObjectDetails, type RemoteViewportConnectionState, @@ -90,117 +90,136 @@ interface SessionConnectResponse { type RemoteViewportCommand = | { - type: "set_view_mode" - viewMode: ViewMode - } + type: "set_view_mode" + viewMode: ViewMode + } | { - type: "set_look_active" - isLooking: boolean - cursorPosition: [number, number] - } + type: "set_look_active" + isLooking: boolean + cursorPosition: [number, number] + } | { - type: "update_viewport_camera" - worldMovement: [number, number, number] - cursorPosition: [number, number] - } + type: "update_viewport_camera" + worldMovement: [number, number, number] + cursorPosition: [number, number] + } | { - type: "set_viewport_camera_pose" - position: [number, number, number] - yawDegrees: number - pitchDegrees: number - } + type: "set_viewport_camera_pose" + position: [number, number, number] + yawDegrees: number + pitchDegrees: number + } | { - type: "select_object" - objectId: string - } + type: "select_object" + objectId: string + } | { - type: "rename_object" - objectId: string - displayName: string - } + type: "rename_object" + objectId: string + displayName: string + } | { - type: "set_object_visibility" - objectId: string - visible: boolean - } + type: "set_object_visibility" + objectId: string + visible: boolean + } | ({ - type: "set_transform" - } & SessionObjectTransformUpdate) + type: "set_transform" + } & SessionObjectTransformUpdate) | { - type: "create_object" - templateId: string - } + type: "create_object" + templateId: string + } | { - type: "duplicate_object" - objectId: string - } + type: "duplicate_object" + objectId: string + } | { - type: "delete_object" - objectId: string - } + type: "delete_object" + objectId: string + } | { - type: "reparent_object" - objectId: string - newParentId: string - } + type: "reparent_object" + objectId: string + newParentId: string + } | { - type: "gizmo_hover" - mouseX: number - mouseY: number - } + type: "gizmo_hover" + mouseX: number + mouseY: number + } | { - type: "gizmo_drag_start" - mouseX: number - mouseY: number - } + type: "gizmo_drag_start" + mouseX: number + mouseY: number + } | { - type: "gizmo_drag_update" - mouseX: number - mouseY: number - } + type: "gizmo_drag_update" + mouseX: number + mouseY: number + } | { - type: "gizmo_drag_end" - mouseX: number - mouseY: number - } + type: "gizmo_drag_end" + mouseX: number + mouseY: number + } | { - type: "set_gizmo_mode" - mode: GizmoMode - } + type: "set_gizmo_mode" + mode: GizmoMode + } + | { + type: "set_grid_snap" + enabled: boolean + translationStep: number + rotationStepDegrees: number + scaleStep: number + } | { type: "heartbeat" } | { type: "list_assets" } | { type: "get_schema"; objectId: string } | { type: "save_scene" } | { - type: "set_property" - objectId: string - property: string - value: string | boolean | [number, number, number] - } + type: "set_property" + objectId: string + property: string + value: string | boolean | [number, number, number] + } | { type: "reload_scripts" } | { - type: "set_mesh_asset" - objectId: string - assetPath: string - } + type: "set_mesh_asset" + objectId: string + assetPath: string + } | { - type: "set_light_properties" - objectId: string - color: [number, number, number] - intensity: number - } + type: "set_light_properties" + objectId: string + color: [number, number, number] + intensity: number + } | { - type: "set_material_properties" - objectId: string - baseColorFactor: [number, number, number, number] - metallic: number - roughness: number - } + type: "set_material_properties" + objectId: string + baseColorFactor: [number, number, number, number] + metallic: number + roughness: number + } | { - type: "set_material_texture" - objectId: string - textureAssetPath: string - } + type: "set_material_texture" + objectId: string + textureAssetPath: string + } + | { + type: "drop_mesh" + mouseX: number + mouseY: number + assetPath: string + } + | { + type: "drop_texture" + mouseX: number + mouseY: number + textureAssetPath: string + } function getServerOrigin() { const configuredOrigin = process.env.NEXT_PUBLIC_AXIOM_SERVER_ORIGIN?.trim() @@ -240,15 +259,16 @@ export function Viewport() { const rightMouseDownRef = useRef(false) const viewportFocusedRef = useRef(false) const isDraggingGizmoRef = useRef(false) + const [isFullscreen, setIsFullscreen] = useState(false) const keysRef = useRef(new Set()) const cursorRef = useRef({ x: 0, y: 0 }) const pendingLookDeltaRef = useRef({ x: 0, y: 0 }) const isLookingRef = useRef(false) const viewModeRef = useRef("lit") const gizmoModeRef = useRef("translate") - const setGizmoModeCtxRef = useRef<(mode: GizmoMode) => Promise>(async () => {}) + const setGizmoModeCtxRef = useRef<(mode: GizmoMode) => Promise>(async () => { }) const notifyServerOnDestroyRef = useRef(true) - const connectRef = useRef<() => Promise>(async () => {}) + const connectRef = useRef<() => Promise>(async () => { }) const sendCommandRef = useRef< (command: RemoteViewportCommand, preferredChannel?: ChannelPreference) => Promise >(async () => false) @@ -306,6 +326,18 @@ export function Viewport() { participantsRef.current = participants }, [participants]) + useEffect(() => { + function syncFullscreenState() { + setIsFullscreen(document.fullscreenElement === viewportShellRef.current) + } + + syncFullscreenState() + document.addEventListener("fullscreenchange", syncFullscreenState) + return () => { + document.removeEventListener("fullscreenchange", syncFullscreenState) + } + }, []) + useEffect(() => { let disposed = false @@ -501,8 +533,8 @@ export function Viewport() { const errorPayload = payload as { detail?: string; message?: string } | null throw new Error( errorPayload?.detail ?? - errorPayload?.message ?? - `${response.status} ${response.statusText}` + errorPayload?.message ?? + `${response.status} ${response.statusText}` ) } @@ -1608,6 +1640,18 @@ export function Viewport() { gizmoModeRef.current = nextMode await sendCommand({ type: "set_gizmo_mode", mode: nextMode }, "reliable") }, + setGridSnapSettings: async (settings: RemoteViewportGridSnapSettings) => { + await sendCommand( + { + type: "set_grid_snap", + enabled: settings.enabled, + translationStep: settings.translationStep, + rotationStepDegrees: settings.rotationStepDegrees, + scaleStep: settings.scaleStep, + }, + "reliable" + ) + }, }) setServerOrigin(serverOrigin) setupClientIdClaimChannel() @@ -1787,8 +1831,41 @@ export function Viewport() { void destroyPeerConnection("page_unload") } + let lastDragX = 0 + let lastDragY = 0 + + const handleDocDragOver = (event: DragEvent) => { + lastDragX = event.clientX + lastDragY = event.clientY + } + + ; (window as any).__axiomViewportDropHandler = (clientX: number, clientY: number, kind: string, path: string) => { + if ((kind !== "texture" && kind !== "mesh") || !path) return + const x = lastDragX || clientX + const y = lastDragY || clientY + const s = viewportShellRef.current + const v = videoRef.current + if (!s || !v || !v.videoWidth || !v.videoHeight) return + const rect = s.getBoundingClientRect() + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return + const scale = Math.min(rect.width / v.videoWidth, rect.height / v.videoHeight) + const contentW = v.videoWidth * scale + const contentH = v.videoHeight * scale + const cssX = x - rect.left - (rect.width - contentW) / 2 + const cssY = y - rect.top - (rect.height - contentH) / 2 + if (cssX < 0 || cssY < 0 || cssX > contentW || cssY > contentH) return + const mouseX = (cssX / contentW) * v.videoWidth + const mouseY = (cssY / contentH) * v.videoHeight + if (kind === "mesh") { + void sendCommand({ type: "drop_mesh", mouseX, mouseY, assetPath: path }, "reliable") + return + } + void sendCommand({ type: "drop_texture", mouseX, mouseY, textureAssetPath: path }, "reliable") + } + video?.addEventListener("loadedmetadata", handleLoadedMetadata) video?.addEventListener("resize", handleResize) + document.addEventListener("dragover", handleDocDragOver) document.addEventListener("mousedown", handleMouseDown) document.addEventListener("mouseup", handleMouseUp) document.addEventListener("contextmenu", handleContextMenu) @@ -1796,7 +1873,6 @@ export function Viewport() { document.addEventListener("keydown", handleKeyDown) document.addEventListener("keyup", handleKeyUp) window.addEventListener("beforeunload", handleBeforeUnload) - void connect() return () => { @@ -1805,6 +1881,8 @@ export function Viewport() { stopViewportInputPump() video?.removeEventListener("loadedmetadata", handleLoadedMetadata) video?.removeEventListener("resize", handleResize) + document.removeEventListener("dragover", handleDocDragOver) + ; (window as any).__axiomViewportDropHandler = null document.removeEventListener("mousedown", handleMouseDown) document.removeEventListener("mouseup", handleMouseUp) document.removeEventListener("contextmenu", handleContextMenu) @@ -1848,6 +1926,20 @@ export function Viewport() { ) } + async function toggleFullscreen() { + const shell = viewportShellRef.current + if (!shell) { + return + } + + if (document.fullscreenElement === shell) { + await document.exitFullscreen() + return + } + + await shell.requestFullscreen() + } + return (
@@ -1886,13 +1978,17 @@ export function Viewport() {
- - - void toggleLook()} /> - void connectRef.current()} /> + void toggleFullscreen()} + />
-
+