init
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2021, Holochip
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 the "License";
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
get_filename_component(FOLDER_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
get_filename_component(PARENT_DIR ${CMAKE_CURRENT_LIST_DIR} PATH)
|
||||
get_filename_component(CATEGORY_NAME ${PARENT_DIR} NAME)
|
||||
|
||||
add_sample_with_tags(
|
||||
ID ${FOLDER_NAME}
|
||||
CATEGORY ${CATEGORY_NAME}
|
||||
AUTHOR "Holochip"
|
||||
NAME "Texture compression comparison"
|
||||
DESCRIPTION "Compare the performance of different texture formats (ASTC, BC[x], ETC2, and PVRTC)")
|
||||
@@ -0,0 +1,27 @@
|
||||
////
|
||||
- Copyright (c) 2019-2023, The Khronos Group
|
||||
-
|
||||
- SPDX-License-Identifier: Apache-2.0
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 the "License";
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-
|
||||
////
|
||||
|
||||
= Texture compression comparison
|
||||
|
||||
ifdef::site-gen-antora[]
|
||||
TIP: The source for this sample can be found in the https://github.com/KhronosGroup/Vulkan-Samples/tree/main/samples/performance/texture_compression_comparison[Khronos Vulkan samples github repository].
|
||||
endif::[]
|
||||
|
||||
|
||||
This sample demonstrates how to use different types of compressed GPU textures in a Vulkan application, and shows the timing benefits of each.
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
/* Copyright (c) 2021-2025, Holochip
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 the "License";
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#include "texture_compression_comparison.h"
|
||||
#include "rendering/subpasses/forward_subpass.h"
|
||||
#include "scene_graph/components/camera.h"
|
||||
#include "scene_graph/components/image.h"
|
||||
#include "scene_graph/components/material.h"
|
||||
#include "scene_graph/components/mesh.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr std::array<const char *, 19> error_codes = {
|
||||
"KTX_SUCCESS",
|
||||
"KTX_FILE_DATA_ERROR",
|
||||
"KTX_FILE_ISPIPE",
|
||||
"KTX_FILE_OPEN_FAILED",
|
||||
"KTX_FILE_OVERFLOW",
|
||||
"KTX_FILE_READ_ERROR",
|
||||
"KTX_FILE_SEEK_ERROR",
|
||||
"KTX_FILE_UNEXPECTED_EOF",
|
||||
"KTX_FILE_WRITE_ERROR",
|
||||
"KTX_GL_ERROR",
|
||||
"KTX_INVALID_OPERATION",
|
||||
"KTX_INVALID_VALUE",
|
||||
"KTX_NOT_FOUND",
|
||||
"KTX_OUT_OF_MEMORY",
|
||||
"KTX_TRANSCODE_FAILED",
|
||||
"KTX_UNKNOWN_FILE_FORMAT",
|
||||
"KTX_UNSUPPORTED_TEXTURE_TYPE",
|
||||
"KTX_UNSUPPORTED_FEATURE",
|
||||
"KTX_LIBRARY_NOT_LINKED",
|
||||
};
|
||||
|
||||
std::string get_sponza_texture_filename(const std::string &short_name)
|
||||
{
|
||||
return vkb::fs::path::get(vkb::fs::path::Type::Assets) + "scenes/sponza/ktx2/" + short_name + "2";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
#define KTX_CHECK(x) \
|
||||
do \
|
||||
{ \
|
||||
KTX_error_code err = x; \
|
||||
if (err != KTX_SUCCESS) \
|
||||
{ \
|
||||
auto index = static_cast<uint32_t>(err); \
|
||||
LOGE("Detected KTX error: {}", index < error_codes.size() ? error_codes[index] : ""); \
|
||||
abort(); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
bool TextureCompressionComparison::prepare(const vkb::ApplicationOptions &options)
|
||||
{
|
||||
if (!VulkanSample::prepare(options))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
load_assets();
|
||||
|
||||
auto &camera_node = vkb::add_free_camera(get_scene(), "main_camera", get_render_context().get_surface_extent());
|
||||
camera = &camera_node.get_component<vkb::sg::Camera>();
|
||||
|
||||
create_subpass();
|
||||
|
||||
get_stats().request_stats({vkb::StatIndex::frame_times, vkb::StatIndex::gpu_ext_read_bytes});
|
||||
create_gui(*window, &get_stats());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void TextureCompressionComparison::update(float delta_time)
|
||||
{
|
||||
if (require_redraw)
|
||||
{
|
||||
const auto &formats = get_texture_formats();
|
||||
require_redraw = false;
|
||||
assert(current_format >= 0 && static_cast<size_t>(current_format) < formats.size());
|
||||
current_benchmark = update_textures(formats[current_format]);
|
||||
}
|
||||
VulkanSample::update(delta_time);
|
||||
}
|
||||
|
||||
void TextureCompressionComparison::draw_gui()
|
||||
{
|
||||
if (gui_texture_names.empty())
|
||||
{
|
||||
const auto &formats = get_texture_formats();
|
||||
gui_texture_names.resize(formats.size());
|
||||
std::transform(formats.cbegin(), formats.cend(), gui_texture_names.begin(), [this](const CompressedTexture_t &format) -> std::string {
|
||||
return fmt::format(FMT_STRING("{:s} {:s}"), format.short_name, is_texture_format_supported(format) ? "" : "(not supported)");
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<const char *> name_pointers(gui_texture_names.size());
|
||||
std::transform(gui_texture_names.cbegin(), gui_texture_names.cend(), name_pointers.begin(), [](const std::string &in) {
|
||||
return in.c_str();
|
||||
});
|
||||
|
||||
get_gui().show_options_window([this, &name_pointers]() {
|
||||
if (ImGui::Combo("Compressed Format", ¤t_gui_format, name_pointers.data(), static_cast<int>(name_pointers.size())))
|
||||
{
|
||||
require_redraw = true;
|
||||
const auto &format = get_texture_formats()[current_gui_format];
|
||||
if (is_texture_format_supported(format))
|
||||
{
|
||||
current_format = current_gui_format;
|
||||
}
|
||||
}
|
||||
const auto &format = get_texture_formats()[current_gui_format];
|
||||
if (is_texture_format_supported(format))
|
||||
{
|
||||
ImGui::Text("Format name: %s", format.format_name);
|
||||
ImGui::Text("Bytes: %f MB", static_cast<float>(current_benchmark.total_bytes) / 1024.f / 1024.f);
|
||||
ImGui::Text("Compression Time: %f (ms)", current_benchmark.compress_time_ms);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui::Text("%s not supported on this GPU.", format.short_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const std::vector<TextureCompressionComparison::CompressedTexture_t> &TextureCompressionComparison::get_texture_formats()
|
||||
{
|
||||
static std::vector<TextureCompressionComparison::CompressedTexture_t> formats = {
|
||||
CompressedTexture_t{nullptr,
|
||||
"",
|
||||
VK_FORMAT_R8G8B8A8_SRGB,
|
||||
KTX_TTF_RGBA32,
|
||||
"KTX_TTF_RGBA32",
|
||||
"RGBA 32",
|
||||
true},
|
||||
CompressedTexture_t{&VkPhysicalDeviceFeatures::textureCompressionBC,
|
||||
"",
|
||||
VK_FORMAT_BC7_SRGB_BLOCK,
|
||||
KTX_TTF_BC7_RGBA,
|
||||
"KTX_TTF_BC7_RGBA",
|
||||
"BC7"},
|
||||
CompressedTexture_t{&VkPhysicalDeviceFeatures::textureCompressionBC,
|
||||
"",
|
||||
VK_FORMAT_BC3_SRGB_BLOCK,
|
||||
KTX_TTF_BC3_RGBA,
|
||||
"KTX_TTF_BC3_RGBA",
|
||||
"BC3"},
|
||||
CompressedTexture_t{&VkPhysicalDeviceFeatures::textureCompressionASTC_LDR,
|
||||
"",
|
||||
VK_FORMAT_ASTC_4x4_SRGB_BLOCK,
|
||||
KTX_TTF_ASTC_4x4_RGBA,
|
||||
"KTX_TTF_ASTC_4x4_RGBA",
|
||||
"ASTC 4x4"},
|
||||
CompressedTexture_t{&VkPhysicalDeviceFeatures::textureCompressionETC2,
|
||||
"",
|
||||
VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK,
|
||||
KTX_TTF_ETC2_RGBA,
|
||||
"KTX_TTF_ETC2_RGBA",
|
||||
"ETC2"}};
|
||||
return formats;
|
||||
}
|
||||
|
||||
bool TextureCompressionComparison::is_texture_format_supported(const TextureCompressionComparison::CompressedTexture_t &format)
|
||||
{
|
||||
const auto device_features = get_device().get_gpu().get_features();
|
||||
|
||||
const bool supported_by_feature = format.feature_ptr && device_features.*format.feature_ptr;
|
||||
const bool supported_by_extension = strlen(format.extension_name) && get_device().get_gpu().is_extension_supported(format.extension_name);
|
||||
const bool supported_by_default = format.always_supported;
|
||||
|
||||
return supported_by_default || supported_by_feature || supported_by_extension;
|
||||
}
|
||||
|
||||
void TextureCompressionComparison::load_assets()
|
||||
{
|
||||
load_scene("scenes/sponza/Sponza01.gltf");
|
||||
if (!has_scene())
|
||||
{
|
||||
throw std::runtime_error("Unable to load Sponza scene");
|
||||
}
|
||||
|
||||
for (auto &&mesh : get_scene().get_components<vkb::sg::Mesh>())
|
||||
{
|
||||
for (auto &&sub_mesh : mesh->get_submeshes())
|
||||
{
|
||||
auto material = sub_mesh->get_material();
|
||||
for (auto &name_texture : material->textures)
|
||||
{
|
||||
vkb::sg::Texture *texture = name_texture.second;
|
||||
auto image = texture->get_image();
|
||||
textures.emplace_back(texture, image->get_name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TextureCompressionComparison::create_subpass()
|
||||
{
|
||||
vkb::ShaderSource vert_shader("base.vert.spv");
|
||||
vkb::ShaderSource frag_shader("base.frag.spv");
|
||||
auto scene_sub_pass = std::make_unique<vkb::ForwardSubpass>(get_render_context(), std::move(vert_shader), std::move(frag_shader), get_scene(), *camera);
|
||||
|
||||
auto render_pipeline = std::make_unique<vkb::RenderPipeline>();
|
||||
render_pipeline->add_subpass(std::move(scene_sub_pass));
|
||||
|
||||
set_render_pipeline(std::move(render_pipeline));
|
||||
}
|
||||
|
||||
TextureCompressionComparison::TextureBenchmark TextureCompressionComparison::update_textures(const TextureCompressionComparison::CompressedTexture_t &new_format)
|
||||
{
|
||||
TextureBenchmark benchmark;
|
||||
std::unordered_set<std::string> visited;
|
||||
for (auto &&texture_filename : textures)
|
||||
{
|
||||
vkb::sg::Texture *texture = texture_filename.first;
|
||||
assert(!!texture);
|
||||
auto &internal_name = texture_filename.second;
|
||||
if (!visited.count(internal_name))
|
||||
{
|
||||
auto filename = get_sponza_texture_filename(internal_name);
|
||||
auto new_image = compress(filename, new_format, "");
|
||||
texture_raw_data[internal_name].image = std::move(new_image.first);
|
||||
texture_raw_data[internal_name].benchmark = new_image.second;
|
||||
benchmark += new_image.second;
|
||||
}
|
||||
|
||||
vkb::sg::Image *image = texture_raw_data[internal_name].image.get();
|
||||
assert(image);
|
||||
texture->set_image(*image);
|
||||
visited.insert(internal_name);
|
||||
}
|
||||
|
||||
// update the forward subpass to use the new textures
|
||||
create_subpass();
|
||||
|
||||
return benchmark;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
class CompressedImage : public vkb::sg::Image
|
||||
{
|
||||
public:
|
||||
CompressedImage(vkb::core::DeviceC &device, const std::string &name, std::vector<vkb::sg::Mipmap> &&mipmaps, VkFormat format) :
|
||||
vkb::sg::Image(name, std::vector<uint8_t>{}, std::move(mipmaps))
|
||||
{
|
||||
vkb::sg::Image::set_format(format);
|
||||
vkb::sg::Image::create_vk_image(device);
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<vkb::sg::Image> TextureCompressionComparison::create_image(ktxTexture2 *ktx_texture, const std::string &name)
|
||||
{
|
||||
std::unique_ptr<vkb::core::BufferC> staging_buffer = std::make_unique<vkb::core::BufferC>(get_device(), ktx_texture->dataSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
|
||||
memcpy(staging_buffer->map(), ktx_texture->pData, ktx_texture->dataSize);
|
||||
|
||||
const auto vk_format = static_cast<VkFormat>(ktx_texture->vkFormat);
|
||||
|
||||
VkExtent3D extent{ktx_texture->baseWidth, ktx_texture->baseHeight, 1};
|
||||
|
||||
std::vector<VkBufferImageCopy> buffer_copies;
|
||||
std::unique_ptr<vkb::sg::Image> image_out;
|
||||
{
|
||||
std::vector<vkb::sg::Mipmap> mip_maps;
|
||||
for (uint32_t mip_level = 0; mip_level < ktx_texture->numLevels; ++mip_level)
|
||||
{
|
||||
VkExtent3D mip_extent = VkExtent3D{extent.width >> mip_level, extent.height >> mip_level, 1};
|
||||
if (!mip_extent.width || !mip_extent.height)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ktx_size_t offset{0};
|
||||
KTX_CHECK(ktxTexture_GetImageOffset((ktxTexture *) ktx_texture, mip_level, 0, 0, &offset));
|
||||
VkBufferImageCopy buffer_image_copy = {};
|
||||
buffer_image_copy.imageSubresource = VkImageSubresourceLayers{VK_IMAGE_ASPECT_COLOR_BIT, mip_level, 0, 1};
|
||||
buffer_image_copy.imageExtent = mip_extent;
|
||||
buffer_image_copy.bufferOffset = static_cast<uint32_t>(offset);
|
||||
buffer_copies.push_back(buffer_image_copy);
|
||||
|
||||
vkb::sg::Mipmap mip_map;
|
||||
mip_map.extent = buffer_image_copy.imageExtent;
|
||||
mip_map.level = mip_level;
|
||||
mip_map.offset = static_cast<uint32_t>(offset);
|
||||
mip_maps.push_back(mip_map);
|
||||
}
|
||||
|
||||
image_out = std::make_unique<CompressedImage>(get_device(), name, std::move(mip_maps), vk_format);
|
||||
}
|
||||
auto &vkb_image = image_out->get_vk_image();
|
||||
auto image = vkb_image.get_handle();
|
||||
|
||||
VkImageSubresourceRange subresource_range{VK_IMAGE_ASPECT_COLOR_BIT, 0, static_cast<uint32_t>(buffer_copies.size()), 0, 1};
|
||||
|
||||
VkCommandBuffer command_buffer = get_device().create_command_buffer(VK_COMMAND_BUFFER_LEVEL_PRIMARY, true);
|
||||
|
||||
vkb::image_layout_transition(command_buffer, image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, subresource_range);
|
||||
|
||||
vkCmdCopyBufferToImage(command_buffer, staging_buffer->get_handle(), image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, static_cast<uint32_t>(buffer_copies.size()), buffer_copies.data());
|
||||
|
||||
vkb::image_layout_transition(command_buffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, subresource_range);
|
||||
|
||||
get_device().flush_command_buffer(command_buffer, get_device().get_queue_by_flags(VK_QUEUE_GRAPHICS_BIT, 0).get_handle(), true);
|
||||
|
||||
return image_out;
|
||||
}
|
||||
|
||||
std::pair<std::unique_ptr<vkb::sg::Image>, TextureCompressionComparison::TextureBenchmark> TextureCompressionComparison::compress(const std::string &filename, TextureCompressionComparison::CompressedTexture_t texture_format, const std::string &name)
|
||||
{
|
||||
ktxTexture2 *ktx_texture{nullptr};
|
||||
KTX_CHECK(ktxTexture2_CreateFromNamedFile(filename.c_str(), KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT, &ktx_texture));
|
||||
|
||||
TextureBenchmark benchmark;
|
||||
{
|
||||
const auto start = std::chrono::high_resolution_clock::now();
|
||||
KTX_CHECK(ktxTexture2_TranscodeBasis(ktx_texture, texture_format.ktx_format, 0));
|
||||
const auto end = std::chrono::high_resolution_clock::now();
|
||||
benchmark.compress_time_ms = static_cast<float>(std::chrono::duration_cast<std::chrono::microseconds>(end - start).count()) / 1000.f;
|
||||
}
|
||||
benchmark.total_bytes = ktx_texture->dataSize;
|
||||
auto image = create_image(ktx_texture, name);
|
||||
ktxTexture_Destroy((ktxTexture *) ktx_texture);
|
||||
|
||||
return {std::move(image), benchmark};
|
||||
}
|
||||
|
||||
std::unique_ptr<TextureCompressionComparison> create_texture_compression_comparison()
|
||||
{
|
||||
return std::make_unique<TextureCompressionComparison>();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* Copyright (c) 2021-2024, Holochip
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 the "License";
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "ktx.h"
|
||||
|
||||
#include "api_vulkan_sample.h"
|
||||
#include "scene_graph/components/camera.h"
|
||||
|
||||
class TextureCompressionComparison : public vkb::VulkanSampleC
|
||||
{
|
||||
public:
|
||||
TextureCompressionComparison() = default;
|
||||
~TextureCompressionComparison() override = default;
|
||||
bool prepare(const vkb::ApplicationOptions &options) override;
|
||||
void update(float delta_time) override;
|
||||
void draw_gui() override;
|
||||
|
||||
private:
|
||||
struct CompressedTexture_t
|
||||
{
|
||||
VkBool32 VkPhysicalDeviceFeatures::*feature_ptr{nullptr};
|
||||
const char *extension_name = "";
|
||||
VkFormat format = VK_FORMAT_MAX_ENUM;
|
||||
ktx_transcode_fmt_e ktx_format = KTX_TTF_NOSELECTION;
|
||||
const char *format_name = "";
|
||||
const char *short_name = "";
|
||||
bool always_supported = false;
|
||||
};
|
||||
|
||||
struct TextureBenchmark
|
||||
{
|
||||
TextureBenchmark &operator+=(const TextureBenchmark &other)
|
||||
{
|
||||
total_bytes += other.total_bytes;
|
||||
compress_time_ms += other.compress_time_ms;
|
||||
frame_time_ms += other.frame_time_ms;
|
||||
return *this;
|
||||
}
|
||||
VkDeviceSize total_bytes = 0;
|
||||
float compress_time_ms = 0.f;
|
||||
float frame_time_ms = 0.f;
|
||||
};
|
||||
|
||||
struct SampleTexture
|
||||
{
|
||||
std::vector<uint8_t> raw_bytes;
|
||||
std::unique_ptr<vkb::sg::Image> image;
|
||||
TextureBenchmark benchmark;
|
||||
};
|
||||
|
||||
private:
|
||||
static const std::vector<CompressedTexture_t> &get_texture_formats();
|
||||
bool is_texture_format_supported(const CompressedTexture_t &format);
|
||||
void load_assets();
|
||||
void create_subpass();
|
||||
TextureBenchmark update_textures(const CompressedTexture_t &new_format);
|
||||
std::unique_ptr<vkb::sg::Image> create_image(ktxTexture2 *ktx_texture, const std::string &name);
|
||||
std::pair<std::unique_ptr<vkb::sg::Image>, TextureBenchmark> compress(const std::string &filename, CompressedTexture_t texture_format, const std::string &name);
|
||||
std::vector<std::string> gui_texture_names;
|
||||
std::unordered_map<std::string, SampleTexture> texture_raw_data;
|
||||
std::vector<std::pair<vkb::sg::Texture *, std::string>> textures;
|
||||
vkb::sg::Camera *camera{VK_NULL_HANDLE};
|
||||
TextureBenchmark current_benchmark{};
|
||||
int current_format = 0, current_gui_format = 0;
|
||||
bool require_redraw = true;
|
||||
};
|
||||
|
||||
std::unique_ptr<TextureCompressionComparison> create_texture_compression_comparison();
|
||||
Reference in New Issue
Block a user