renderer_vulkan: General cleanups
* Add a staging buffer to each texture. This is suboptimal since it causes many small allocations, will only be here until I implement a global staging buffer with memory tracking * Make the task scheduler accept generic functions which allows for more powerful resource control
This commit is contained in:
@@ -11,49 +11,58 @@
|
|||||||
|
|
||||||
namespace Vulkan {
|
namespace Vulkan {
|
||||||
|
|
||||||
VKBuffer::~VKBuffer()
|
VKBuffer::~VKBuffer() {
|
||||||
{
|
|
||||||
if (memory != nullptr) {
|
if (memory != nullptr) {
|
||||||
g_vk_instace->GetDevice().unmapMemory(buffer_memory.get());
|
g_vk_instace->GetDevice().unmapMemory(buffer_memory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto deleter = [this]() {
|
||||||
|
if (buffer) {
|
||||||
|
auto& device = g_vk_instace->GetDevice();
|
||||||
|
device.destroyBuffer(buffer);
|
||||||
|
device.freeMemory(buffer_memory);
|
||||||
|
device.destroyBufferView(buffer_view);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
g_vk_task_scheduler->Schedule(deleter);
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKBuffer::Create(uint32_t byte_count, vk::MemoryPropertyFlags properties, vk::BufferUsageFlags usage, vk::Format view_format)
|
void VKBuffer::Create(u32 byte_count, vk::MemoryPropertyFlags properties, vk::BufferUsageFlags usage,
|
||||||
{
|
vk::Format view_format) {
|
||||||
auto& device = g_vk_instace->GetDevice();
|
auto& device = g_vk_instace->GetDevice();
|
||||||
size = byte_count;
|
size = byte_count;
|
||||||
|
|
||||||
vk::BufferCreateInfo bufferInfo({}, byte_count, usage);
|
vk::BufferCreateInfo bufferInfo({}, byte_count, usage);
|
||||||
buffer = device.createBufferUnique(bufferInfo);
|
buffer = device.createBuffer(bufferInfo);
|
||||||
|
|
||||||
auto mem_requirements = device.getBufferMemoryRequirements(buffer.get());
|
auto mem_requirements = device.getBufferMemoryRequirements(buffer);
|
||||||
|
|
||||||
auto memory_type_index = FindMemoryType(mem_requirements.memoryTypeBits, properties);
|
auto memory_type_index = FindMemoryType(mem_requirements.memoryTypeBits, properties);
|
||||||
vk::MemoryAllocateInfo alloc_info(mem_requirements.size, memory_type_index);
|
vk::MemoryAllocateInfo alloc_info(mem_requirements.size, memory_type_index);
|
||||||
|
|
||||||
buffer_memory = device.allocateMemoryUnique(alloc_info);
|
buffer_memory = device.allocateMemory(alloc_info);
|
||||||
device.bindBufferMemory(buffer.get(), buffer_memory.get(), 0);
|
device.bindBufferMemory(buffer, buffer_memory, 0);
|
||||||
|
|
||||||
// Optionally map the buffer to CPU memory
|
// Optionally map the buffer to CPU memory
|
||||||
if (properties & vk::MemoryPropertyFlagBits::eHostVisible)
|
if (properties & vk::MemoryPropertyFlagBits::eHostVisible) {
|
||||||
memory = device.mapMemory(buffer_memory.get(), 0, byte_count);
|
memory = device.mapMemory(buffer_memory, 0, byte_count);
|
||||||
|
}
|
||||||
|
|
||||||
// Create buffer view for texel buffers
|
// Create buffer view for texel buffers
|
||||||
if (usage & vk::BufferUsageFlagBits::eStorageTexelBuffer || usage & vk::BufferUsageFlagBits::eUniformTexelBuffer)
|
if (usage & vk::BufferUsageFlagBits::eStorageTexelBuffer ||
|
||||||
{
|
usage & vk::BufferUsageFlagBits::eUniformTexelBuffer) {
|
||||||
vk::BufferViewCreateInfo view_info({}, buffer.get(), view_format, 0, byte_count);
|
vk::BufferViewCreateInfo view_info({}, buffer, view_format, 0, byte_count);
|
||||||
buffer_view = device.createBufferViewUnique(view_info);
|
buffer_view = device.createBufferView(view_info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKBuffer::CopyBuffer(VKBuffer& src_buffer, VKBuffer& dst_buffer, const vk::BufferCopy& region)
|
void VKBuffer::CopyBuffer(VKBuffer& src_buffer, VKBuffer& dst_buffer, const vk::BufferCopy& region) {
|
||||||
{
|
|
||||||
auto command_buffer = g_vk_task_scheduler->GetCommandBuffer();
|
auto command_buffer = g_vk_task_scheduler->GetCommandBuffer();
|
||||||
command_buffer.copyBuffer(src_buffer.buffer.get(), dst_buffer.buffer.get(), region);
|
command_buffer.copyBuffer(src_buffer.buffer, dst_buffer.buffer, region);
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t VKBuffer::FindMemoryType(uint32_t type_filter, vk::MemoryPropertyFlags properties)
|
u32 VKBuffer::FindMemoryType(u32 type_filter, vk::MemoryPropertyFlags properties) {
|
||||||
{
|
|
||||||
vk::PhysicalDeviceMemoryProperties mem_properties = g_vk_instace->GetPhysicalDevice().getMemoryProperties();
|
vk::PhysicalDeviceMemoryProperties mem_properties = g_vk_instace->GetPhysicalDevice().getMemoryProperties();
|
||||||
|
|
||||||
for (uint32_t i = 0; i < mem_properties.memoryTypeCount; i++)
|
for (uint32_t i = 0; i < mem_properties.memoryTypeCount; i++)
|
||||||
@@ -67,4 +76,19 @@ uint32_t VKBuffer::FindMemoryType(uint32_t type_filter, vk::MemoryPropertyFlags
|
|||||||
UNREACHABLE();
|
UNREACHABLE();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void StagingBuffer::Create(u32 size) {
|
||||||
|
buffer.Create(size, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
|
||||||
|
vk::BufferUsageFlagBits::eTransferSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
u8* StagingBuffer::Request(u32 bytes) {
|
||||||
|
// Check if there is enough space left
|
||||||
|
if (buffer.GetSize() - end_offset >= bytes) {
|
||||||
|
u8* ptr = buffer.GetHostPointer() + end_offset;
|
||||||
|
end_offset += bytes;
|
||||||
|
|
||||||
|
// Schedule the memory to be freed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <deque>
|
||||||
#include <vulkan/vulkan.hpp>
|
#include <vulkan/vulkan.hpp>
|
||||||
#include "common/common_types.h"
|
#include "common/common_types.h"
|
||||||
|
|
||||||
@@ -19,21 +20,23 @@ public:
|
|||||||
~VKBuffer();
|
~VKBuffer();
|
||||||
|
|
||||||
/// Create a generic Vulkan buffer object
|
/// Create a generic Vulkan buffer object
|
||||||
void Create(uint32_t size, vk::MemoryPropertyFlags properties, vk::BufferUsageFlags usage, vk::Format view_format = vk::Format::eUndefined);
|
void Create(u32 size, vk::MemoryPropertyFlags properties, vk::BufferUsageFlags usage,
|
||||||
|
vk::Format view_format = vk::Format::eUndefined);
|
||||||
|
|
||||||
/// Global utility functions used by other objects
|
/// Global utility functions used by other objects
|
||||||
static uint32_t FindMemoryType(uint32_t type_filter, vk::MemoryPropertyFlags properties);
|
static u32 FindMemoryType(u32 type_filter, vk::MemoryPropertyFlags properties);
|
||||||
static void CopyBuffer(VKBuffer& src_buffer, VKBuffer& dst_buffer, const vk::BufferCopy& region);
|
static void CopyBuffer(VKBuffer& src_buffer, VKBuffer& dst_buffer, const vk::BufferCopy& region);
|
||||||
|
|
||||||
/// Return a pointer to the mapped memory if the buffer is host mapped
|
/// Return a pointer to the mapped memory if the buffer is host mapped
|
||||||
u8* GetHostPointer() { return reinterpret_cast<u8*>(memory); }
|
u8* GetHostPointer() { return reinterpret_cast<u8*>(memory); }
|
||||||
vk::Buffer& GetBuffer() { return buffer.get(); }
|
vk::Buffer& GetBuffer() { return buffer; }
|
||||||
|
u32 GetSize() const { return size; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void* memory = nullptr;
|
void* memory = nullptr;
|
||||||
vk::UniqueBuffer buffer;
|
vk::Buffer buffer;
|
||||||
vk::UniqueDeviceMemory buffer_memory;
|
vk::DeviceMemory buffer_memory;
|
||||||
vk::UniqueBufferView buffer_view;
|
vk::BufferView buffer_view;
|
||||||
uint32_t size = 0;
|
uint32_t size = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -31,16 +31,11 @@ public:
|
|||||||
void Shutdown();
|
void Shutdown();
|
||||||
|
|
||||||
// Public interface.
|
// Public interface.
|
||||||
VKBuffer& GetTextureUploadBuffer() { return texture_upload_buffer; }
|
|
||||||
vk::Sampler GetSampler(const SamplerInfo& info);
|
vk::Sampler GetSampler(const SamplerInfo& info);
|
||||||
vk::RenderPass GetRenderPass(vk::Format color_format, vk::Format depth_format, u32 multisamples, vk::AttachmentLoadOp load_op);
|
vk::RenderPass GetRenderPass(vk::Format color_format, vk::Format depth_format, u32 multisamples, vk::AttachmentLoadOp load_op);
|
||||||
vk::PipelineCache GetPipelineCache() const { return pipeline_cache.get(); }
|
vk::PipelineCache GetPipelineCache() const { return pipeline_cache.get(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Dummy image for samplers that are unbound
|
|
||||||
VKTexture dummy_texture;
|
|
||||||
VKBuffer texture_upload_buffer;
|
|
||||||
|
|
||||||
// Descriptor sets
|
// Descriptor sets
|
||||||
std::array<vk::DescriptorSetLayout, DESCRIPTOR_SET_LAYOUT_COUNT> descriptor_layouts;
|
std::array<vk::DescriptorSetLayout, DESCRIPTOR_SET_LAYOUT_COUNT> descriptor_layouts;
|
||||||
vk::UniquePipelineLayout pipeline_layout;
|
vk::UniquePipelineLayout pipeline_layout;
|
||||||
|
@@ -98,8 +98,9 @@ void VKTaskScheduler::Submit(bool present, bool wait_completion) {
|
|||||||
// When the task completes the timeline will increment to the task id
|
// When the task completes the timeline will increment to the task id
|
||||||
vk::TimelineSemaphoreSubmitInfo timeline_info({}, task.task_id);
|
vk::TimelineSemaphoreSubmitInfo timeline_info({}, task.task_id);
|
||||||
|
|
||||||
|
std::array<vk::Semaphore, 2> signal_semaphores = { timeline.get(), present_semaphore.get() };
|
||||||
vk::PipelineStageFlags wait_stage = vk::PipelineStageFlagBits::eColorAttachmentOutput;
|
vk::PipelineStageFlags wait_stage = vk::PipelineStageFlagBits::eColorAttachmentOutput;
|
||||||
vk::SubmitInfo submit_info({}, wait_stage, task.command_buffer, present_semaphore.get(), &timeline_info);
|
vk::SubmitInfo submit_info({}, wait_stage, task.command_buffer, signal_semaphores, &timeline_info);
|
||||||
|
|
||||||
// Wait for new swapchain image
|
// Wait for new swapchain image
|
||||||
if (present) {
|
if (present) {
|
||||||
@@ -108,7 +109,6 @@ void VKTaskScheduler::Submit(bool present, bool wait_completion) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Submit the command buffer
|
// Submit the command buffer
|
||||||
submit_info.setSignalSemaphores(timeline.get());
|
|
||||||
g_vk_instace->GetGraphicsQueue().submit(submit_info);
|
g_vk_instace->GetGraphicsQueue().submit(submit_info);
|
||||||
|
|
||||||
// Present the image when rendering has finished
|
// Present the image when rendering has finished
|
||||||
@@ -125,14 +125,12 @@ void VKTaskScheduler::Submit(bool present, bool wait_completion) {
|
|||||||
BeginTask();
|
BeginTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKTaskScheduler::ScheduleDestroy(auto object) {
|
void VKTaskScheduler::Schedule(std::function<void()> func) {
|
||||||
auto& resources = tasks[current_task];
|
auto& task = tasks[current_task];
|
||||||
auto deleter = [object]() { g_vk_instace->GetDevice().destroy(object); };
|
task.cleanups.push_back(func);
|
||||||
resources.cleanups.push_back(deleter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKTaskScheduler::BeginTask()
|
void VKTaskScheduler::BeginTask() {
|
||||||
{
|
|
||||||
// Move to the next command buffer.
|
// Move to the next command buffer.
|
||||||
u32 next_task_index = (current_task + 1) % CONCURRENT_TASK_COUNT;
|
u32 next_task_index = (current_task + 1) % CONCURRENT_TASK_COUNT;
|
||||||
auto& task = tasks[next_task_index];
|
auto& task = tasks[next_task_index];
|
||||||
@@ -151,6 +149,7 @@ void VKTaskScheduler::BeginTask()
|
|||||||
|
|
||||||
// Reset upload command buffer state
|
// Reset upload command buffer state
|
||||||
current_task = next_task_index;
|
current_task = next_task_index;
|
||||||
|
task.task_id = current_task_id++;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<VKTaskScheduler> g_vk_task_scheduler;
|
std::unique_ptr<VKTaskScheduler> g_vk_task_scheduler;
|
||||||
|
@@ -42,7 +42,7 @@ public:
|
|||||||
vk::CommandBuffer GetCommandBuffer();
|
vk::CommandBuffer GetCommandBuffer();
|
||||||
|
|
||||||
/// Returns the task id that the CPU is recording
|
/// Returns the task id that the CPU is recording
|
||||||
u64 GetCPUTick() const { return tasks[current_task].task_id; }
|
u64 GetCPUTick() const { return current_task_id; }
|
||||||
|
|
||||||
/// Returns the last known task id to have completed execution in the GPU
|
/// Returns the last known task id to have completed execution in the GPU
|
||||||
u64 GetGPUTick() const { return g_vk_instace->GetDevice().getSemaphoreCounterValue(timeline.get()); }
|
u64 GetGPUTick() const { return g_vk_instace->GetDevice().getSemaphoreCounterValue(timeline.get()); }
|
||||||
@@ -52,7 +52,7 @@ public:
|
|||||||
void SyncToGPU(u64 task_index);
|
void SyncToGPU(u64 task_index);
|
||||||
|
|
||||||
/// Schedule a vulkan object for destruction when the GPU no longer uses it
|
/// Schedule a vulkan object for destruction when the GPU no longer uses it
|
||||||
void ScheduleDestroy(auto object);
|
void Schedule(std::function<void()> func);
|
||||||
|
|
||||||
/// Submit the current work batch and move to the next frame
|
/// Submit the current work batch and move to the next frame
|
||||||
void Submit(bool present = true, bool wait_completion = false);
|
void Submit(bool present = true, bool wait_completion = false);
|
||||||
@@ -69,7 +69,7 @@ private:
|
|||||||
|
|
||||||
vk::UniqueSemaphore timeline;
|
vk::UniqueSemaphore timeline;
|
||||||
vk::UniqueCommandPool command_pool;
|
vk::UniqueCommandPool command_pool;
|
||||||
u64 last_completed_task_id = 0;
|
u64 current_task_id = 1;
|
||||||
|
|
||||||
// Each task contains unique resources
|
// Each task contains unique resources
|
||||||
std::array<Task, CONCURRENT_TASK_COUNT> tasks;
|
std::array<Task, CONCURRENT_TASK_COUNT> tasks;
|
||||||
|
@@ -15,26 +15,25 @@ VKTexture::~VKTexture() {
|
|||||||
// Make sure to unbind the texture before destroying it
|
// Make sure to unbind the texture before destroying it
|
||||||
g_vk_state->UnbindTexture(this);
|
g_vk_state->UnbindTexture(this);
|
||||||
|
|
||||||
if (cleanup_image && texture) {
|
auto deleter = [this]() {
|
||||||
g_vk_task_scheduler->ScheduleDestroy(texture);
|
auto& device = g_vk_instace->GetDevice();
|
||||||
}
|
|
||||||
|
if (texture) {
|
||||||
|
if (cleanup_image) {
|
||||||
|
device.destroyImage(texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
device.destroyImageView(texture_view);
|
||||||
|
device.freeMemory(texture_memory);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Schedule deletion of the texture after it's no longer used
|
// Schedule deletion of the texture after it's no longer used
|
||||||
// by the GPU
|
// by the GPU
|
||||||
if (texture_view) {
|
g_vk_task_scheduler->Schedule(deleter);
|
||||||
g_vk_task_scheduler->ScheduleDestroy(texture_view);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (texture_memory) {
|
|
||||||
g_vk_task_scheduler->ScheduleDestroy(texture_memory);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (texture_view) {
|
|
||||||
g_vk_task_scheduler->ScheduleDestroy(texture_view);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKTexture::Create(const Info& info) {
|
void VKTexture::Create(const Info& info, bool make_staging) {
|
||||||
auto& device = g_vk_instace->GetDevice();
|
auto& device = g_vk_instace->GetDevice();
|
||||||
texture_info = info;
|
texture_info = info;
|
||||||
|
|
||||||
@@ -87,6 +86,12 @@ void VKTexture::Create(const Info& info) {
|
|||||||
vk::ImageViewCreateInfo view_info({}, texture, info.view_type, texture_info.format, {},
|
vk::ImageViewCreateInfo view_info({}, texture, info.view_type, texture_info.format, {},
|
||||||
vk::ImageSubresourceRange(vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1));
|
vk::ImageSubresourceRange(vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1));
|
||||||
texture_view = device.createImageView(view_info);
|
texture_view = device.createImageView(view_info);
|
||||||
|
|
||||||
|
// Create staging buffer
|
||||||
|
if (make_staging) {
|
||||||
|
staging.Create(image_size, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
|
||||||
|
vk::BufferUsageFlagBits::eTransferSrc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKTexture::Adopt(vk::Image image, vk::ImageViewCreateInfo view_info) {
|
void VKTexture::Adopt(vk::Image image, vk::ImageViewCreateInfo view_info) {
|
||||||
@@ -181,10 +186,14 @@ void VKTexture::TransitionLayout(vk::ImageLayout new_layout, vk::CommandBuffer&
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VKTexture::CopyPixels(std::span<u32> new_pixels) {
|
void VKTexture::CopyPixels(std::span<u32> new_pixels) {
|
||||||
|
if (!staging.GetHostPointer()) {
|
||||||
|
LOG_ERROR(Render_Vulkan, "Cannot copy pixels without staging buffer!");
|
||||||
|
}
|
||||||
|
|
||||||
auto command_buffer = g_vk_task_scheduler->GetCommandBuffer();
|
auto command_buffer = g_vk_task_scheduler->GetCommandBuffer();
|
||||||
|
|
||||||
// Copy pixels to staging buffer
|
// Copy pixels to staging buffer
|
||||||
std::memcpy(g_vk_res_cache->GetTextureUploadBuffer().GetHostPointer(),
|
std::memcpy(staging.GetHostPointer(),
|
||||||
new_pixels.data(), new_pixels.size() * channels);
|
new_pixels.data(), new_pixels.size() * channels);
|
||||||
|
|
||||||
vk::BufferImageCopy region(0, 0, 0, vk::ImageSubresourceLayers(vk::ImageAspectFlagBits::eColor, 0, 0, 1), 0,
|
vk::BufferImageCopy region(0, 0, 0, vk::ImageSubresourceLayers(vk::ImageAspectFlagBits::eColor, 0, 0, 1), 0,
|
||||||
@@ -194,7 +203,6 @@ void VKTexture::CopyPixels(std::span<u32> new_pixels) {
|
|||||||
// Transition image to transfer format
|
// Transition image to transfer format
|
||||||
TransitionLayout(vk::ImageLayout::eTransferDstOptimal, command_buffer);
|
TransitionLayout(vk::ImageLayout::eTransferDstOptimal, command_buffer);
|
||||||
|
|
||||||
auto& staging = g_vk_res_cache->GetTextureUploadBuffer();
|
|
||||||
command_buffer.copyBufferToImage(staging.GetBuffer(), texture, vk::ImageLayout::eTransferDstOptimal, regions);
|
command_buffer.copyBufferToImage(staging.GetBuffer(), texture, vk::ImageLayout::eTransferDstOptimal, regions);
|
||||||
|
|
||||||
// Prepare for shader reads
|
// Prepare for shader reads
|
||||||
@@ -250,9 +258,14 @@ void VKTexture::Fill(Common::Rectangle<u32> region, glm::vec2 depth_stencil) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VKFramebuffer::~VKFramebuffer() {
|
VKFramebuffer::~VKFramebuffer() {
|
||||||
if (framebuffer) {
|
auto deleter = [this]() {
|
||||||
g_vk_task_scheduler->ScheduleDestroy(framebuffer);
|
if (framebuffer) {
|
||||||
}
|
auto& device = g_vk_instace->GetDevice();
|
||||||
|
device.destroyFramebuffer(framebuffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
g_vk_task_scheduler->Schedule(deleter);
|
||||||
}
|
}
|
||||||
|
|
||||||
void VKFramebuffer::Create(const Info& info) {
|
void VKFramebuffer::Create(const Info& info) {
|
||||||
|
@@ -42,7 +42,7 @@ public:
|
|||||||
~VKTexture();
|
~VKTexture();
|
||||||
|
|
||||||
/// Create a new Vulkan texture object
|
/// Create a new Vulkan texture object
|
||||||
void Create(const Info& info);
|
void Create(const Info& info, bool staging = false);
|
||||||
|
|
||||||
/// Create a non-owning texture object, usefull for image object
|
/// Create a non-owning texture object, usefull for image object
|
||||||
/// from the swapchain that are managed by another object
|
/// from the swapchain that are managed by another object
|
||||||
@@ -79,6 +79,9 @@ private:
|
|||||||
vk::ImageView texture_view;
|
vk::ImageView texture_view;
|
||||||
vk::DeviceMemory texture_memory;
|
vk::DeviceMemory texture_memory;
|
||||||
u32 channels;
|
u32 channels;
|
||||||
|
|
||||||
|
// TODO: Make a global staging buffer
|
||||||
|
VKBuffer staging;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum Attachments {
|
enum Attachments {
|
||||||
|
Reference in New Issue
Block a user