mirror of
https://github.com/KytyPS5/KytyPS5.git
synced 2026-10-24 12:56:53 +02:00
graphics: support compute stores to 2D MSAA images
This commit is contained in:
@@ -680,6 +680,8 @@ if(BUILD_TESTING)
|
||||
COMMAND $<TARGET_FILE:shader_recompiler_compute_tests> --wait-until-safe-for-rendering-only)
|
||||
add_test(NAME depth_bits
|
||||
COMMAND $<TARGET_FILE:shader_recompiler_compute_tests> --depth-bits-only)
|
||||
add_test(NAME msaa_storage
|
||||
COMMAND $<TARGET_FILE:shader_recompiler_compute_tests> --msaa-storage-only)
|
||||
add_test(NAME pm4_context_state
|
||||
COMMAND $<TARGET_FILE:shader_recompiler_compute_tests> --context-state-only)
|
||||
add_test(NAME gpu_tiler
|
||||
|
||||
@@ -90,7 +90,8 @@ ImageCreateFlags(const GraphicContext& graphics, const ImageInfo& info, vk::Imag
|
||||
if (HasFormatFeature(properties, vk::FormatFeatureFlagBits::eColorAttachment)) {
|
||||
usage |= vk::ImageUsageFlagBits::eColorAttachment;
|
||||
}
|
||||
if (info.samples == 1) {
|
||||
if (graphics.GetPhysicalDeviceProperties().limits.storageImageSampleCounts &
|
||||
static_cast<vk::SampleCountFlagBits>(info.samples)) {
|
||||
usage |= vk::ImageUsageFlagBits::eStorage;
|
||||
}
|
||||
return usage;
|
||||
|
||||
@@ -101,6 +101,7 @@ IsSupportedStorageImageResource(const ShaderRecompiler::IR::ImageResource& resou
|
||||
(resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim1D ||
|
||||
resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim1DArray ||
|
||||
resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim2D ||
|
||||
resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim2DMsaa ||
|
||||
resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim3D ||
|
||||
resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim2DArray) &&
|
||||
((resource.mip_mode == ShaderRecompiler::IR::ImageMipMode::None &&
|
||||
|
||||
@@ -254,6 +254,10 @@ static bool IsSupportedStorageTextureDescriptor(const ShaderRecompiler::IR::Imag
|
||||
const bool is_3d = resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim3D &&
|
||||
descriptor.Type() == Prospero::ImageType::kColor3D &&
|
||||
descriptor.BaseArray5() == 0;
|
||||
const bool is_2d_msaa =
|
||||
resource.dimension == ShaderRecompiler::Decoder::ImageDimension::Dim2DMsaa &&
|
||||
descriptor.Type() == Prospero::ImageType::kColor2DMsaa && descriptor.Depth() == 0 &&
|
||||
descriptor.BaseArray5() == 0;
|
||||
TileTextureBlockLayout tile_layout {};
|
||||
bool supported_tile = false;
|
||||
switch (tile) {
|
||||
@@ -284,8 +288,8 @@ static bool IsSupportedStorageTextureDescriptor(const ShaderRecompiler::IR::Imag
|
||||
const bool supported_swizzle =
|
||||
IsValidImageSwizzle(swizzle) &&
|
||||
(swizzle == DstSel(4, 5, 6, 7) || !resource.read || resource.atomic);
|
||||
return (is_1d || is_1d_array || is_2d || is_2d_array || is_3d) && supported_tile &&
|
||||
descriptor.BaseLevel() <= descriptor.LastLevel() &&
|
||||
return (is_1d || is_1d_array || is_2d || is_2d_array || is_3d || is_2d_msaa) &&
|
||||
supported_tile && descriptor.BaseLevel() <= descriptor.LastLevel() &&
|
||||
descriptor.MinLod() == 0 && supported_swizzle && descriptor.BCSwizzle() == 0 &&
|
||||
!descriptor.MsaaDepth();
|
||||
}
|
||||
|
||||
@@ -609,6 +609,8 @@ static vk::Device VulkanCreateDevice(GraphicContext& graphics,
|
||||
device_features.depthClamp = VK_TRUE;
|
||||
#endif
|
||||
device_features.shaderStorageImageWriteWithoutFormat = VK_TRUE;
|
||||
device_features.shaderStorageImageMultisample =
|
||||
supported_features2.features.shaderStorageImageMultisample;
|
||||
device_features.shaderImageGatherExtended = VK_TRUE;
|
||||
device_features.independentBlend = VK_TRUE;
|
||||
device_features.dualSrcBlend = VK_TRUE;
|
||||
@@ -623,6 +625,11 @@ static vk::Device VulkanCreateDevice(GraphicContext& graphics,
|
||||
device_features.multiViewport = VK_TRUE;
|
||||
device_features.fillModeNonSolid = VK_TRUE;
|
||||
device_features.vertexPipelineStoresAndAtomics = VK_TRUE;
|
||||
// Storage image sample counts beyond 1x require the feature.
|
||||
if (!device_features.shaderStorageImageMultisample) {
|
||||
graphics.physical_device_properties.limits.storageImageSampleCounts =
|
||||
vk::SampleCountFlagBits::e1;
|
||||
}
|
||||
graphics.sample_rate_shading_enabled = true;
|
||||
device_features.shaderInt64 = VK_TRUE;
|
||||
device_features.shaderFloat64 =
|
||||
|
||||
@@ -121,6 +121,9 @@ uint32_t ImageType(EmitterState& state, const IR::ImageResource& image) {
|
||||
EXIT("invalid image resource class");
|
||||
}
|
||||
const auto& info = ImageDimensionInfoFor(image.dimension);
|
||||
if (sampled == 2 && info.multisampled != 0u) {
|
||||
state.builder.RequireCapability(spv::CapabilityStorageImageMultisample);
|
||||
}
|
||||
const auto scalar_type =
|
||||
image.atomic64 ? TypeU64(state) : ImageScalarType(state, image.numeric_class);
|
||||
return state.builder.Type(spv::OpTypeImage, scalar_type,
|
||||
@@ -213,15 +216,20 @@ uint32_t ImageDescriptorPointer(EmitterState& state, uint32_t resource, uint32_t
|
||||
}
|
||||
|
||||
void EmitStorageImageWrite(EmitterState& state, uint32_t resource, uint32_t mip_lod, uint32_t coord,
|
||||
uint32_t texel, uint32_t array_index) {
|
||||
uint32_t texel, uint32_t array_index, uint32_t sample) {
|
||||
const auto& image = state.program.info.images.at(resource);
|
||||
EXIT_IF(image.resource_class != IR::ImageResourceClass::Storage);
|
||||
if (!image.atomic) {
|
||||
state.builder.RequireCapability(spv::CapabilityStorageImageWriteWithoutFormat);
|
||||
}
|
||||
const auto EmitWrite = [&](uint32_t mip) {
|
||||
state.builder.AddFunction(spv::OpImageWrite, LoadImageDescriptor(state, resource, mip, array_index),
|
||||
coord, texel);
|
||||
const auto descriptor = LoadImageDescriptor(state, resource, mip, array_index);
|
||||
if (sample == 0u) {
|
||||
state.builder.AddFunction(spv::OpImageWrite, descriptor, coord, texel);
|
||||
} else {
|
||||
state.builder.AddFunction(spv::OpImageWrite, descriptor, coord, texel,
|
||||
spv::ImageOperandsSampleMask, sample);
|
||||
}
|
||||
};
|
||||
if (image.mip_mode != IR::ImageMipMode::Dynamic) {
|
||||
EmitWrite(0);
|
||||
|
||||
@@ -941,9 +941,12 @@ void EmitImage(ValueEmitContext& ctx, const IR::Inst& inst) {
|
||||
const auto mip_lod = candidate.mip_mode == IR::ImageMipMode::Dynamic
|
||||
? LodU32(ctx, mem, *address, mem.image_dimension) : 0u;
|
||||
const auto coord = CoordU32(ctx, mem, *address, candidate.dimension);
|
||||
const auto& dimension_info = ImageDimensionInfoFor(candidate.dimension);
|
||||
const auto sample = dimension_info.multisampled != 0u
|
||||
? AddressU32(ctx, mem, *address, dimension_info.coordinate_components) : 0u;
|
||||
const auto texel = StoreTexel(ctx, selected, ctx.Arg(inst, 2),
|
||||
candidate.numeric_class == Prospero::TextureNumericClass::Uint);
|
||||
EmitStorageImageWrite(state, resource, mip_lod, coord, texel, array_index);
|
||||
EmitStorageImageWrite(state, resource, mip_lod, coord, texel, array_index, sample);
|
||||
});
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -339,8 +339,9 @@ uint32_t MakeSampledImage(EmitterState& state, uint32_t resource, uint32_t sampl
|
||||
uint32_t mip = 0, uint32_t array_index = 0,
|
||||
bool sampler_dynamic = false);
|
||||
|
||||
// sample is 0 (no id) for single-sampled images.
|
||||
void EmitStorageImageWrite(EmitterState& state, uint32_t resource, uint32_t mip_lod, uint32_t coord,
|
||||
uint32_t texel, uint32_t array_index);
|
||||
uint32_t texel, uint32_t array_index, uint32_t sample);
|
||||
|
||||
spv::ExecutionModel ExecutionModelForStage(ShaderType stage);
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ struct StageOutput {
|
||||
inline constexpr uint32_t FirstImageBinding = 1u;
|
||||
inline constexpr uint32_t FirstComparisonImageBinding = 22u;
|
||||
inline constexpr uint32_t FirstStorageImageBinding = 29u;
|
||||
inline constexpr uint32_t ImageBindingCount = 48u;
|
||||
inline constexpr uint32_t ImageBindingCount = 50u;
|
||||
|
||||
enum class DescriptorBindingKind : uint32_t {
|
||||
Buffers = 0u,
|
||||
@@ -357,8 +357,8 @@ enum class DescriptorBindingKind : uint32_t {
|
||||
Count,
|
||||
};
|
||||
|
||||
static_assert(static_cast<uint32_t>(DescriptorBindingKind::Samplers) == 49u);
|
||||
static_assert(static_cast<uint32_t>(DescriptorBindingKind::Count) == 56u);
|
||||
static_assert(static_cast<uint32_t>(DescriptorBindingKind::Samplers) == 51u);
|
||||
static_assert(static_cast<uint32_t>(DescriptorBindingKind::Count) == 58u);
|
||||
|
||||
struct PushData {
|
||||
static constexpr uint32_t DwordCount = 32;
|
||||
@@ -419,8 +419,8 @@ DescriptorBindingForImage(const ImageResource& image) {
|
||||
constexpr uint32_t SampledUintBinding = 8u;
|
||||
constexpr uint32_t SampledSintBinding = 15u;
|
||||
constexpr uint32_t StorageFloatBinding = FirstStorageImageBinding;
|
||||
constexpr uint32_t StorageUintBinding = StorageFloatBinding + 5u;
|
||||
constexpr uint32_t AtomicUintBinding = StorageUintBinding + 5u;
|
||||
constexpr uint32_t StorageUintBinding = StorageFloatBinding + 6u;
|
||||
constexpr uint32_t AtomicUintBinding = StorageUintBinding + 6u;
|
||||
|
||||
uint32_t base = 0;
|
||||
bool sampled = false;
|
||||
@@ -467,10 +467,10 @@ DescriptorBindingForImage(const ImageResource& image) {
|
||||
case Decoder::ImageDimension::Dim2D: dimension = 2u; break;
|
||||
case Decoder::ImageDimension::Dim2DArray: dimension = 3u; break;
|
||||
case Decoder::ImageDimension::Dim2DMsaa:
|
||||
if (!sampled) {
|
||||
if (image.atomic) {
|
||||
return std::nullopt;
|
||||
}
|
||||
dimension = 4u;
|
||||
dimension = sampled ? 4u : 5u;
|
||||
break;
|
||||
case Decoder::ImageDimension::Dim2DMsaaArray:
|
||||
if (!sampled) {
|
||||
|
||||
@@ -8547,16 +8547,16 @@ void TestShaderInfoCollection() {
|
||||
void TestImageBindingAbi() {
|
||||
using NumericClass = Libs::Graphics::Prospero::TextureNumericClass;
|
||||
|
||||
Check(ImageBindingCount == 48u &&
|
||||
Check(ImageBindingCount == 50u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Buffers) == 0u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Samplers) == 49u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Gds) == 50u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::BdaPagetable) == 51u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::FaultBuffer) == 52u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::FlattenedSrt) == 53u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::ShaderData) == 54u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::SharedMemory) == 55u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Count) == 56u,
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Samplers) == 51u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Gds) == 52u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::BdaPagetable) == 53u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::FaultBuffer) == 54u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::FlattenedSrt) == 55u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::ShaderData) == 56u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::SharedMemory) == 57u &&
|
||||
static_cast<uint32_t>(DescriptorBindingKind::Count) == 58u,
|
||||
"native descriptor binding anchors changed");
|
||||
|
||||
const std::array sampled_dimensions{
|
||||
@@ -8615,6 +8615,8 @@ void TestImageBindingAbi() {
|
||||
CheckBinding(ImageResourceClass::Storage, numeric_class, dimension,
|
||||
false);
|
||||
}
|
||||
CheckBinding(ImageResourceClass::Storage, numeric_class,
|
||||
Decoder::ImageDimension::Dim2DMsaa, false);
|
||||
}
|
||||
for (const auto dimension : storage_dimensions) {
|
||||
CheckBinding(ImageResourceClass::Storage, NumericClass::Uint, dimension,
|
||||
@@ -8658,11 +8660,15 @@ void TestImageBindingAbi() {
|
||||
image.numeric_class = NumericClass::Sint;
|
||||
Check(Invalid(image), "signed storage image received a descriptor binding");
|
||||
image.numeric_class = NumericClass::Float;
|
||||
image.dimension = Decoder::ImageDimension::Dim2DMsaa;
|
||||
image.dimension = Decoder::ImageDimension::Dim2DMsaaArray;
|
||||
Check(Invalid(image),
|
||||
"multisampled storage image received a descriptor binding");
|
||||
image.dimension = Decoder::ImageDimension::Dim2D;
|
||||
"multisampled storage array received a descriptor binding");
|
||||
image.dimension = Decoder::ImageDimension::Dim2DMsaa;
|
||||
image.numeric_class = NumericClass::Uint;
|
||||
image.atomic = true;
|
||||
Check(Invalid(image), "multisampled atomic image received a descriptor binding");
|
||||
image.numeric_class = NumericClass::Float;
|
||||
image.dimension = Decoder::ImageDimension::Dim2D;
|
||||
Check(Invalid(image), "float atomic image received a descriptor binding");
|
||||
}
|
||||
|
||||
|
||||
@@ -12595,6 +12595,95 @@ public:
|
||||
std::printf("[gpu] %-32s ok\n", name);
|
||||
}
|
||||
|
||||
// Cyberpunk 2077 clears a rect of an MSAA color target from compute, one sample per store.
|
||||
void CheckMsaaStorageClear() {
|
||||
constexpr const char *name = "MsaaStorageClear";
|
||||
constexpr uintptr_t base = 0x0000000205000000ull;
|
||||
constexpr uint64_t allocation_size = 0x10000;
|
||||
// Captured CS 0xad6c86aca9c0916e: s[8:11] color, s[12:13] origin, s14 width,
|
||||
// s15 height << 16 | samples.
|
||||
std::vector<u32> clear{0xbfa00001u, 0xd7460001u, 0x04050611u, 0xd7460000u, 0x04010610u,
|
||||
0x7da21ef9u, 0x85060001u, 0x7da8000eu, 0xbf88000fu, 0x4a08000cu,
|
||||
0x4a0a020du, 0x996b800fu, 0xbeea0380u, 0xbf0a6b6au, 0xbf840009u,
|
||||
0x7e0c026au, 0x7e000208u, 0x7e020209u, 0x7e04020au, 0x7e06020bu,
|
||||
0x816a816au, 0xf0200f30u, 0x00000004u, 0xbf82fff5u, 0xbf810000u};
|
||||
ShaderMapUserData(reinterpret_cast<uint64_t>(clear.data()),
|
||||
{.type = Prospero::ShaderBinaryType::kCs,
|
||||
.code_size_bytes = static_cast<u32>(clear.size() * sizeof(u32))});
|
||||
EnsureRuntimeContext();
|
||||
int64_t direct_offset = -1;
|
||||
Require(name, "direct allocation",
|
||||
Libs::LibKernel::Memory::KernelAllocateDirectMemory(
|
||||
0, Libs::LibKernel::Memory::KernelGetDirectMemorySize(),
|
||||
allocation_size, 0x10000, 0, &direct_offset) == 0,
|
||||
"MSAA allocation failed");
|
||||
void *mapped = reinterpret_cast<void *>(base);
|
||||
Require(name, "direct mapping",
|
||||
Libs::LibKernel::Memory::KernelMapDirectMemory(
|
||||
&mapped, allocation_size, 0x3, 0x10, direct_offset, 0x10000) == 0 &&
|
||||
mapped == reinterpret_cast<void *>(base),
|
||||
"MSAA mapping failed");
|
||||
RenderContext context(m_runtime_context);
|
||||
context.InitializeGpu(nullptr);
|
||||
LibKernel::Memory::InstallGpuResources(&context);
|
||||
context.GetGpu().SendCommandSync([&] {
|
||||
CommandProcessor processor(context, 0);
|
||||
processor.BufferInit();
|
||||
auto &scheduler = context.GetCommandScheduler();
|
||||
auto &textures = context.GetTextureCache();
|
||||
auto &shaders = processor.GetShCtx();
|
||||
context.MapMemory(base, allocation_size);
|
||||
// 8x8 4x MSAA RGBA8: LAST_LEVEL and MAX_MIP hold log2(samples).
|
||||
const ShaderTextureResource target{{static_cast<u32>(base >> 8u),
|
||||
(static_cast<u32>(Prospero::BufferFormat::k8_8_8_8UNorm) << 20u) | (3u << 30u),
|
||||
1u | (7u << 14u),
|
||||
DstSel(4, 5, 6, 7) | (2u << 16u) |
|
||||
(static_cast<u32>(Prospero::TileMode::kRenderTarget) << 20u) |
|
||||
(static_cast<u32>(Prospero::ImageType::kColor2DMsaa) << 28u),
|
||||
0, 0x00700020u, 0, 0}};
|
||||
ShaderRecompiler::IR::DescriptorValue value{};
|
||||
value.dword_count = 8;
|
||||
std::copy_n(target.fields, 8, value.dwords.begin());
|
||||
ShaderRecompiler::IR::ImageResource resource{};
|
||||
resource.resource_class = ShaderRecompiler::IR::ImageResourceClass::Storage;
|
||||
resource.numeric_class = Prospero::TextureNumericClass::Float;
|
||||
resource.dimension = ShaderRecompiler::Decoder::ImageDimension::Dim2DMsaa;
|
||||
resource.written = true;
|
||||
const auto binding =
|
||||
RenderExecutorTestAccess::ResolveTexture(context.GetRenderExecutor(), resource, value);
|
||||
TextureCacheTestAccess::ClearImage(textures, scheduler.Current(), binding.image_id,
|
||||
{vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}, {});
|
||||
shaders.SetCsShader({.data_addr = reinterpret_cast<uint64_t>(clear.data()),
|
||||
.num_thread_x = 8, .num_thread_y = 8, .num_thread_z = 1,
|
||||
.wave_size = 64, .user_sgpr = 16, .tgid_x_en = true,
|
||||
.tgid_y_en = true, .tidig_comp_cnt = 1});
|
||||
const std::array<u32, 8> args{std::bit_cast<u32>(1.0f), 0, 0, std::bit_cast<u32>(1.0f),
|
||||
1, 1, 2, (2u << 16u) | 4u};
|
||||
for (u32 i = 0; i < 8; i++) {
|
||||
shaders.SetCsUserSgpr(i, target.fields[i], HW::UserSgprType::Unknown);
|
||||
shaders.SetCsUserSgpr(8u + i, args[i], HW::UserSgprType::Unknown);
|
||||
}
|
||||
processor.DispatchDirect(1, 1, 1, 0x41u);
|
||||
for (const int row : {1, 2, 3}) {
|
||||
const u32 inside = row == 3 ? 0u : 0xff0000ffu;
|
||||
Require(name, "every sample in the rect",
|
||||
ReadCachedTexel(name, context, binding.image_id, {0, row, 0}, {4, 1, 1}) ==
|
||||
std::vector<u32>{0, inside, inside, 0},
|
||||
"the compute clear missed samples or wrote outside its rect");
|
||||
}
|
||||
context.UnmapMemory(base, allocation_size);
|
||||
scheduler.Finish();
|
||||
});
|
||||
context.ShutdownGpu();
|
||||
LibKernel::Memory::InstallGpuResources(nullptr);
|
||||
Require(name, "unmap", Libs::LibKernel::Memory::KernelMunmap(base, allocation_size) == 0,
|
||||
"MSAA mapping release failed");
|
||||
Require(name, "release",
|
||||
Libs::LibKernel::Memory::KernelReleaseDirectMemory(direct_offset, allocation_size) == 0,
|
||||
"MSAA allocation release failed");
|
||||
std::printf("[gpu] %-32s ok\n", name);
|
||||
}
|
||||
|
||||
void CheckSampledDccClear() {
|
||||
constexpr const char *name = "SampledDccClear";
|
||||
constexpr uintptr_t base = 0x0000000205000000ull;
|
||||
@@ -21003,6 +21092,7 @@ private:
|
||||
device_info.pNext = &conditional_rendering;
|
||||
vk::PhysicalDeviceFeatures device_features{};
|
||||
device_features.shaderStorageImageWriteWithoutFormat = true;
|
||||
device_features.shaderStorageImageMultisample = available_features.shaderStorageImageMultisample;
|
||||
device_features.shaderImageGatherExtended = true;
|
||||
device_features.sampleRateShading = true;
|
||||
device_features.shaderInt64 = true;
|
||||
@@ -49870,6 +49960,11 @@ int main(int argc, char **argv) {
|
||||
RunCase(&vulkan, ImageSamplePacked8<false, true>());
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && std::strcmp(argv[1], "--msaa-storage-only") == 0) {
|
||||
VulkanHarness vulkan;
|
||||
vulkan.CheckMsaaStorageClear();
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && std::strcmp(argv[1], "--depth-bits-only") == 0) {
|
||||
VulkanHarness vulkan;
|
||||
vulkan.CheckDepthBitsCopy();
|
||||
@@ -50768,6 +50863,7 @@ int main(int argc, char **argv) {
|
||||
vulkan.CheckRenderExecutorColorMetadataClear();
|
||||
vulkan.CheckSampledDccClear();
|
||||
vulkan.CheckDepthBitsCopy();
|
||||
vulkan.CheckMsaaStorageClear();
|
||||
vulkan.CheckRenderExecutorColorStandardTileDiscovery();
|
||||
vulkan.CheckRenderExecutorColorDepthTileDiscovery();
|
||||
vulkan.CheckRenderExecutorStencilBindingDiscovery();
|
||||
|
||||
Reference in New Issue
Block a user