Improve synchronization based on vulkan-tutorial
https://vulkan-tutorial.com/Drawing_a_triangle/Drawing/Rendering_and_presentation
This commit is contained in:
parent
771355ec87
commit
9515f1c807
|
|
@ -276,10 +276,6 @@ int main()
|
||||||
Nz::UInt32 imageCount = vulkanWindow.GetFramebufferCount();
|
Nz::UInt32 imageCount = vulkanWindow.GetFramebufferCount();
|
||||||
std::vector<Nz::Vk::CommandBuffer> renderCmds = cmdPool.AllocateCommandBuffers(imageCount, VK_COMMAND_BUFFER_LEVEL_PRIMARY);
|
std::vector<Nz::Vk::CommandBuffer> renderCmds = cmdPool.AllocateCommandBuffers(imageCount, VK_COMMAND_BUFFER_LEVEL_PRIMARY);
|
||||||
|
|
||||||
std::vector<Nz::Vk::Fence> fences(imageCount);
|
|
||||||
for (auto& fence : fences)
|
|
||||||
fence.Create(vulkanDevice.shared_from_this());
|
|
||||||
|
|
||||||
for (Nz::UInt32 i = 0; i < imageCount; ++i)
|
for (Nz::UInt32 i = 0; i < imageCount; ++i)
|
||||||
{
|
{
|
||||||
Nz::Vk::CommandBuffer& renderCmd = renderCmds[i];
|
Nz::Vk::CommandBuffer& renderCmd = renderCmds[i];
|
||||||
|
|
@ -325,8 +321,6 @@ int main()
|
||||||
|
|
||||||
renderCmd.Begin();
|
renderCmd.Begin();
|
||||||
|
|
||||||
vulkanWindow.BuildPreRenderCommands(i, renderCmd);
|
|
||||||
|
|
||||||
renderCmd.BeginRenderPass(render_pass_begin_info);
|
renderCmd.BeginRenderPass(render_pass_begin_info);
|
||||||
//renderCmd.ClearAttachment(clearAttachment, clearRect);
|
//renderCmd.ClearAttachment(clearAttachment, clearRect);
|
||||||
//renderCmd.ClearAttachment(clearAttachmentDepth, clearRect);
|
//renderCmd.ClearAttachment(clearAttachmentDepth, clearRect);
|
||||||
|
|
@ -339,8 +333,6 @@ int main()
|
||||||
renderCmd.DrawIndexed(drfreakIB->GetIndexCount());
|
renderCmd.DrawIndexed(drfreakIB->GetIndexCount());
|
||||||
renderCmd.EndRenderPass();
|
renderCmd.EndRenderPass();
|
||||||
|
|
||||||
vulkanWindow.BuildPostRenderCommands(i, renderCmd);
|
|
||||||
|
|
||||||
if (!renderCmd.End())
|
if (!renderCmd.End())
|
||||||
{
|
{
|
||||||
NazaraError("Failed to specify render cmd");
|
NazaraError("Failed to specify render cmd");
|
||||||
|
|
@ -355,6 +347,31 @@ int main()
|
||||||
|
|
||||||
window.EnableEventPolling(true);
|
window.EnableEventPolling(true);
|
||||||
|
|
||||||
|
struct ImageSync
|
||||||
|
{
|
||||||
|
Nz::Vk::Fence inflightFence;
|
||||||
|
Nz::Vk::Semaphore imageAvailableSemaphore;
|
||||||
|
Nz::Vk::Semaphore renderFinishedSemaphore;
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::size_t MaxConcurrentImage = imageCount;
|
||||||
|
|
||||||
|
std::vector<ImageSync> frameSync(MaxConcurrentImage);
|
||||||
|
for (ImageSync& syncData : frameSync)
|
||||||
|
{
|
||||||
|
syncData.imageAvailableSemaphore.Create(vulkanDevice.shared_from_this());
|
||||||
|
syncData.renderFinishedSemaphore.Create(vulkanDevice.shared_from_this());
|
||||||
|
|
||||||
|
syncData.inflightFence.Create(vulkanDevice.shared_from_this(), VK_FENCE_CREATE_SIGNALED_BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*std::vector<std::reference_wrapper<Nz::Vk::Fence>> imageFences;
|
||||||
|
for (std::size_t i = 0; i < imageCount; ++i)
|
||||||
|
imageFences.emplace_back(sync[i % sync.size()].inflightFence);*/
|
||||||
|
std::vector<Nz::Vk::Fence*> inflightFences(imageCount, nullptr);
|
||||||
|
|
||||||
|
std::size_t currentFrame = 0;
|
||||||
|
|
||||||
Nz::Clock updateClock;
|
Nz::Clock updateClock;
|
||||||
Nz::Clock secondClock;
|
Nz::Clock secondClock;
|
||||||
unsigned int fps = 0;
|
unsigned int fps = 0;
|
||||||
|
|
@ -425,36 +442,26 @@ int main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImageSync& syncPrimitives = frameSync[currentFrame];
|
||||||
|
syncPrimitives.inflightFence.Wait();
|
||||||
|
|
||||||
Nz::UInt32 imageIndex;
|
Nz::UInt32 imageIndex;
|
||||||
if (!vulkanWindow.Acquire(&imageIndex))
|
if (!vulkanWindow.Acquire(&imageIndex, syncPrimitives.imageAvailableSemaphore))
|
||||||
{
|
{
|
||||||
std::cout << "Failed to acquire next image" << std::endl;
|
std::cout << "Failed to acquire next image" << std::endl;
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
fences[imageIndex].Wait();
|
if (inflightFences[imageIndex])
|
||||||
fences[imageIndex].Reset();
|
inflightFences[imageIndex]->Wait();
|
||||||
|
|
||||||
VkCommandBuffer renderCmdBuffer = renderCmds[imageIndex];
|
inflightFences[imageIndex] = &syncPrimitives.inflightFence;
|
||||||
VkSemaphore waitSemaphore = vulkanWindow.GetRenderSemaphore();
|
inflightFences[imageIndex]->Reset();
|
||||||
|
|
||||||
VkPipelineStageFlags wait_dst_stage_mask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
if (!graphicsQueue.Submit(renderCmds[imageIndex], syncPrimitives.imageAvailableSemaphore, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, syncPrimitives.renderFinishedSemaphore, syncPrimitives.inflightFence))
|
||||||
VkSubmitInfo submit_info = {
|
|
||||||
VK_STRUCTURE_TYPE_SUBMIT_INFO, // VkStructureType sType
|
|
||||||
nullptr, // const void *pNext
|
|
||||||
1U, // uint32_t waitSemaphoreCount
|
|
||||||
&waitSemaphore, // const VkSemaphore *pWaitSemaphores
|
|
||||||
&wait_dst_stage_mask, // const VkPipelineStageFlags *pWaitDstStageMask;
|
|
||||||
1, // uint32_t commandBufferCount
|
|
||||||
&renderCmdBuffer, // const VkCommandBuffer *pCommandBuffers
|
|
||||||
0, // uint32_t signalSemaphoreCount
|
|
||||||
nullptr // const VkSemaphore *pSignalSemaphores
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!graphicsQueue.Submit(submit_info, fences[imageIndex]))
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
vulkanWindow.Present(imageIndex);
|
vulkanWindow.Present(imageIndex, syncPrimitives.renderFinishedSemaphore);
|
||||||
|
|
||||||
// On incrémente le compteur de FPS improvisé
|
// On incrémente le compteur de FPS improvisé
|
||||||
fps++;
|
fps++;
|
||||||
|
|
@ -477,6 +484,8 @@ int main()
|
||||||
// Et on relance l'horloge pour refaire ça dans une seconde
|
// Et on relance l'horloge pour refaire ça dans une seconde
|
||||||
secondClock.Restart();
|
secondClock.Restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentFrame = (currentFrame + 1) % imageCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
instance.vkDestroyDebugReportCallbackEXT(instance, callback, nullptr);
|
instance.vkDestroyDebugReportCallbackEXT(instance, callback, nullptr);
|
||||||
|
|
|
||||||
|
|
@ -26,19 +26,14 @@ namespace Nz
|
||||||
VkRenderTarget(VkRenderTarget&&) = delete; ///TOOD?
|
VkRenderTarget(VkRenderTarget&&) = delete; ///TOOD?
|
||||||
virtual ~VkRenderTarget();
|
virtual ~VkRenderTarget();
|
||||||
|
|
||||||
virtual bool Acquire(UInt32* imageIndex) const = 0;
|
virtual bool Acquire(UInt32* imageIndex, VkSemaphore signalSemaphore = VK_NULL_HANDLE, VkFence signalFence = VK_NULL_HANDLE) const = 0;
|
||||||
|
|
||||||
virtual void BuildPreRenderCommands(UInt32 imageIndex, Vk::CommandBuffer& commandBuffer) = 0;
|
|
||||||
virtual void BuildPostRenderCommands(UInt32 imageIndex, Vk::CommandBuffer& commandBuffer) = 0;
|
|
||||||
|
|
||||||
virtual const Vk::Framebuffer& GetFrameBuffer(UInt32 imageIndex) const = 0;
|
virtual const Vk::Framebuffer& GetFrameBuffer(UInt32 imageIndex) const = 0;
|
||||||
virtual UInt32 GetFramebufferCount() const = 0;
|
virtual UInt32 GetFramebufferCount() const = 0;
|
||||||
|
|
||||||
const Vk::RenderPass& GetRenderPass() const { return m_renderPass; }
|
inline const Vk::RenderPass& GetRenderPass() const;
|
||||||
|
|
||||||
const Vk::Semaphore& GetRenderSemaphore() const { return m_imageReadySemaphore; }
|
virtual void Present(UInt32 imageIndex, VkSemaphore waitSemaphore = VK_NULL_HANDLE) = 0;
|
||||||
|
|
||||||
virtual void Present(UInt32 imageIndex) = 0;
|
|
||||||
|
|
||||||
VkRenderTarget& operator=(const VkRenderTarget&) = delete;
|
VkRenderTarget& operator=(const VkRenderTarget&) = delete;
|
||||||
VkRenderTarget& operator=(VkRenderTarget&&) = delete; ///TOOD?
|
VkRenderTarget& operator=(VkRenderTarget&&) = delete; ///TOOD?
|
||||||
|
|
@ -51,8 +46,9 @@ namespace Nz
|
||||||
void Destroy();
|
void Destroy();
|
||||||
|
|
||||||
Vk::RenderPass m_renderPass;
|
Vk::RenderPass m_renderPass;
|
||||||
Vk::Semaphore m_imageReadySemaphore;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#include <Nazara/VulkanRenderer/VkRenderTarget.inl>
|
||||||
|
|
||||||
#endif // NAZARA_VULKANRENDERER_RENDERTARGET_HPP
|
#endif // NAZARA_VULKANRENDERER_RENDERTARGET_HPP
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright (C) 2016 Jérôme Leclercq
|
||||||
|
// This file is part of the "Nazara Engine - Vulkan Renderer"
|
||||||
|
// For conditions of distribution and use, see copyright notice in Config.hpp
|
||||||
|
|
||||||
|
#include <Nazara/VulkanRenderer/VkRenderTarget.hpp>
|
||||||
|
#include <Nazara/VulkanRenderer/Debug.hpp>
|
||||||
|
|
||||||
|
namespace Nz
|
||||||
|
{
|
||||||
|
inline const Vk::RenderPass& VkRenderTarget::GetRenderPass() const
|
||||||
|
{
|
||||||
|
return m_renderPass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#include <Nazara/VulkanRenderer/DebugOff.hpp>
|
||||||
|
|
@ -37,10 +37,7 @@ namespace Nz
|
||||||
VkRenderWindow(VkRenderWindow&&) = delete; ///TODO
|
VkRenderWindow(VkRenderWindow&&) = delete; ///TODO
|
||||||
virtual ~VkRenderWindow();
|
virtual ~VkRenderWindow();
|
||||||
|
|
||||||
bool Acquire(UInt32* index) const override;
|
bool Acquire(UInt32* index, VkSemaphore signalSemaphore = VK_NULL_HANDLE, VkFence signalFence = VK_NULL_HANDLE) const override;
|
||||||
|
|
||||||
void BuildPreRenderCommands(UInt32 imageIndex, Vk::CommandBuffer& commandBuffer) override;
|
|
||||||
void BuildPostRenderCommands(UInt32 imageIndex, Vk::CommandBuffer& commandBuffer) override;
|
|
||||||
|
|
||||||
bool Create(RendererImpl* renderer, RenderSurface* surface, const Vector2ui& size, const RenderWindowParameters& parameters) override;
|
bool Create(RendererImpl* renderer, RenderSurface* surface, const Vector2ui& size, const RenderWindowParameters& parameters) override;
|
||||||
|
|
||||||
|
|
@ -53,7 +50,7 @@ namespace Nz
|
||||||
|
|
||||||
std::shared_ptr<RenderDevice> GetRenderDevice() override;
|
std::shared_ptr<RenderDevice> GetRenderDevice() override;
|
||||||
|
|
||||||
void Present(UInt32 imageIndex) override;
|
void Present(UInt32 imageIndex, VkSemaphore waitSemaphore = VK_NULL_HANDLE) override;
|
||||||
|
|
||||||
VkRenderWindow& operator=(const VkRenderWindow&) = delete;
|
VkRenderWindow& operator=(const VkRenderWindow&) = delete;
|
||||||
VkRenderWindow& operator=(VkRenderWindow&&) = delete; ///TODO
|
VkRenderWindow& operator=(VkRenderWindow&&) = delete; ///TODO
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ namespace Nz
|
||||||
return m_device;
|
return m_device;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void VkRenderWindow::Present(UInt32 imageIndex)
|
inline void VkRenderWindow::Present(UInt32 imageIndex, VkSemaphore waitSemaphore)
|
||||||
{
|
{
|
||||||
NazaraAssert(imageIndex < m_frameBuffers.size(), "Invalid image index");
|
NazaraAssert(imageIndex < m_frameBuffers.size(), "Invalid image index");
|
||||||
|
|
||||||
m_presentQueue.Present(m_swapchain, imageIndex);
|
m_presentQueue.Present(m_swapchain, imageIndex, waitSemaphore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,10 @@ namespace Nz
|
||||||
inline bool Present(const VkPresentInfoKHR& presentInfo) const;
|
inline bool Present(const VkPresentInfoKHR& presentInfo) const;
|
||||||
inline bool Present(VkSwapchainKHR swapchain, UInt32 imageIndex, VkSemaphore waitSemaphore = VK_NULL_HANDLE) const;
|
inline bool Present(VkSwapchainKHR swapchain, UInt32 imageIndex, VkSemaphore waitSemaphore = VK_NULL_HANDLE) const;
|
||||||
|
|
||||||
inline bool Submit(const VkSubmitInfo& submit, VkFence fence = VK_NULL_HANDLE) const;
|
inline bool Submit(VkCommandBuffer commandBuffer, VkSemaphore waitSemaphore, VkPipelineStageFlags waitStage, VkSemaphore signalSemaphore, VkFence signalFence = VK_NULL_HANDLE) const;
|
||||||
inline bool Submit(UInt32 submitCount, const VkSubmitInfo* submits, VkFence fence = VK_NULL_HANDLE) const;
|
inline bool Submit(UInt32 commandBufferCount, const VkCommandBuffer* commandBuffers, VkSemaphore waitSemaphore, VkPipelineStageFlags waitStage, VkSemaphore signalSemaphore, VkFence signalFence = VK_NULL_HANDLE) const;
|
||||||
|
inline bool Submit(const VkSubmitInfo& submit, VkFence signalFence = VK_NULL_HANDLE) const;
|
||||||
|
inline bool Submit(UInt32 submitCount, const VkSubmitInfo* submits, VkFence signalFence = VK_NULL_HANDLE) const;
|
||||||
|
|
||||||
inline bool WaitIdle() const;
|
inline bool WaitIdle() const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,36 @@ namespace Nz
|
||||||
return Present(presentInfo);
|
return Present(presentInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool Queue::Submit(const VkSubmitInfo& submit, VkFence fence) const
|
inline bool Queue::Submit(VkCommandBuffer commandBuffer, VkSemaphore waitSemaphore, VkPipelineStageFlags waitStage, VkSemaphore signalSemaphore, VkFence signalFence) const
|
||||||
{
|
{
|
||||||
return Submit(1, &submit, fence);
|
return Submit(1U, &commandBuffer, waitSemaphore, waitStage, signalSemaphore, signalFence);
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool Queue::Submit(UInt32 submitCount, const VkSubmitInfo* submits, VkFence fence) const
|
inline bool Queue::Submit(UInt32 commandBufferCount, const VkCommandBuffer* commandBuffers, VkSemaphore waitSemaphore, VkPipelineStageFlags waitStage, VkSemaphore signalSemaphore, VkFence signalFence) const
|
||||||
{
|
{
|
||||||
m_lastErrorCode = m_device->vkQueueSubmit(m_handle, submitCount, submits, fence);
|
VkSubmitInfo submitInfo = {
|
||||||
|
VK_STRUCTURE_TYPE_SUBMIT_INFO,
|
||||||
|
nullptr,
|
||||||
|
(waitSemaphore) ? 1U : 0U,
|
||||||
|
&waitSemaphore,
|
||||||
|
&waitStage,
|
||||||
|
commandBufferCount,
|
||||||
|
commandBuffers,
|
||||||
|
(signalSemaphore) ? 1U : 0U,
|
||||||
|
&signalSemaphore
|
||||||
|
};
|
||||||
|
|
||||||
|
return Submit(submitInfo, signalFence);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool Queue::Submit(const VkSubmitInfo& submit, VkFence signalFence) const
|
||||||
|
{
|
||||||
|
return Submit(1, &submit, signalFence);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool Queue::Submit(UInt32 submitCount, const VkSubmitInfo* submits, VkFence signalFence) const
|
||||||
|
{
|
||||||
|
m_lastErrorCode = m_device->vkQueueSubmit(m_handle, submitCount, submits, signalFence);
|
||||||
if (m_lastErrorCode != VkResult::VK_SUCCESS)
|
if (m_lastErrorCode != VkResult::VK_SUCCESS)
|
||||||
{
|
{
|
||||||
NazaraError("Failed to submit queue: " + TranslateVulkanError(m_lastErrorCode));
|
NazaraError("Failed to submit queue: " + TranslateVulkanError(m_lastErrorCode));
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,5 @@ namespace Nz
|
||||||
void VkRenderTarget::Destroy()
|
void VkRenderTarget::Destroy()
|
||||||
{
|
{
|
||||||
m_renderPass.Destroy();
|
m_renderPass.Destroy();
|
||||||
m_imageReadySemaphore.Destroy();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ namespace Nz
|
||||||
VkRenderTarget::Destroy();
|
VkRenderTarget::Destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool VkRenderWindow::Acquire(UInt32* imageIndex) const
|
bool VkRenderWindow::Acquire(UInt32* imageIndex, VkSemaphore signalSemaphore, VkFence signalFence) const
|
||||||
{
|
{
|
||||||
if (!m_swapchain.AcquireNextImage(std::numeric_limits<UInt64>::max(), m_imageReadySemaphore, VK_NULL_HANDLE, imageIndex))
|
if (!m_swapchain.AcquireNextImage(std::numeric_limits<UInt64>::max(), signalSemaphore, signalFence, imageIndex))
|
||||||
{
|
{
|
||||||
NazaraError("Failed to acquire next image");
|
NazaraError("Failed to acquire next image");
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -43,30 +43,6 @@ namespace Nz
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void VkRenderWindow::BuildPreRenderCommands(UInt32 imageIndex, Vk::CommandBuffer& commandBuffer)
|
|
||||||
{
|
|
||||||
//commandBuffer.SetImageLayout(m_swapchain.GetBuffer(imageIndex).image, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);
|
|
||||||
|
|
||||||
// Temporary
|
|
||||||
/*if (m_depthStencilFormat != VK_FORMAT_MAX_ENUM)
|
|
||||||
{
|
|
||||||
VkImageSubresourceRange imageRange = {
|
|
||||||
VK_IMAGE_ASPECT_DEPTH_BIT, // VkImageAspectFlags aspectMask
|
|
||||||
0, // uint32_t baseMipLevel
|
|
||||||
1, // uint32_t levelCount
|
|
||||||
0, // uint32_t baseArrayLayer
|
|
||||||
1 // uint32_t layerCount
|
|
||||||
};
|
|
||||||
|
|
||||||
commandBuffer.SetImageLayout(m_depthBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, imageRange);
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
|
|
||||||
void VkRenderWindow::BuildPostRenderCommands(UInt32 imageIndex, Vk::CommandBuffer& commandBuffer)
|
|
||||||
{
|
|
||||||
//commandBuffer.SetImageLayout(m_swapchain.GetBuffer(imageIndex).image, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool VkRenderWindow::Create(RendererImpl* renderer, RenderSurface* surface, const Vector2ui& size, const RenderWindowParameters& parameters)
|
bool VkRenderWindow::Create(RendererImpl* renderer, RenderSurface* surface, const Vector2ui& size, const RenderWindowParameters& parameters)
|
||||||
{
|
{
|
||||||
m_physicalDevice = Vulkan::GetPhysicalDevices()[0].device;
|
m_physicalDevice = Vulkan::GetPhysicalDevices()[0].device;
|
||||||
|
|
@ -194,8 +170,6 @@ namespace Nz
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m_imageReadySemaphore.Create(m_device);
|
|
||||||
|
|
||||||
m_clock.Restart();
|
m_clock.Restart();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue