mirror of
https://github.com/doitsujin/dxvk.git
synced 2025-03-14 22:29:15 +01:00
Deferred surface creation is required for Frostpunk due to conflicts with the D3D9 swap chain created by the game before it presents the first frame to the DXGI swap chain, but breaks NieR:Automata due to threading issues.
465 lines
17 KiB
C++
465 lines
17 KiB
C++
#include "dxgi_presenter.h"
|
|
|
|
#include "../spirv/spirv_module.h"
|
|
|
|
#include <dxgi_presenter_frag.h>
|
|
#include <dxgi_presenter_vert.h>
|
|
|
|
namespace dxvk {
|
|
|
|
DxgiVkPresenter::DxgiVkPresenter(
|
|
const Rc<DxvkDevice>& device,
|
|
HWND window)
|
|
: m_window (window),
|
|
m_device (device),
|
|
m_context (device->createContext()) {
|
|
|
|
// Some games don't work with deferred surface creation,
|
|
// so we should default to initializing it immediately.
|
|
DxgiOptions dxgiOptions = getDxgiAppOptions(env::getExeName());
|
|
|
|
if (!dxgiOptions.test(DxgiOption::DeferSurfaceCreation))
|
|
m_surface = CreateSurface();
|
|
|
|
// Reset options for the swap chain itself. We will
|
|
// create a swap chain object before presentation.
|
|
m_options.preferredSurfaceFormat = { VK_FORMAT_UNDEFINED, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR };
|
|
m_options.preferredPresentMode = VK_PRESENT_MODE_FIFO_KHR;
|
|
m_options.preferredBufferSize = { 0u, 0u };
|
|
|
|
// Samplers for presentation. We'll create one with point sampling that will
|
|
// be used when the back buffer resolution matches the output resolution, and
|
|
// one with linar sampling that will be used when the image will be scaled.
|
|
m_samplerFitting = CreateSampler(VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER);
|
|
m_samplerScaling = CreateSampler(VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER);
|
|
|
|
// Create objects required for the gamma ramp. This is implemented partially
|
|
// with an UBO, which stores global parameters, and a lookup texture, which
|
|
// stores the actual gamma ramp and can be sampled with a linear filter.
|
|
m_gammaSampler = CreateSampler(VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE);
|
|
m_gammaTexture = CreateGammaTexture();
|
|
m_gammaTextureView = CreateGammaTextureView();
|
|
|
|
// Set up context state. The shader bindings and the
|
|
// constant state objects will never be modified.
|
|
DxvkInputAssemblyState iaState;
|
|
iaState.primitiveTopology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP;
|
|
iaState.primitiveRestart = VK_FALSE;
|
|
iaState.patchVertexCount = 0;
|
|
m_context->setInputAssemblyState(iaState);
|
|
|
|
m_context->setInputLayout(
|
|
0, nullptr, 0, nullptr);
|
|
|
|
DxvkRasterizerState rsState;
|
|
rsState.enableDepthClamp = VK_FALSE;
|
|
rsState.enableDiscard = VK_FALSE;
|
|
rsState.polygonMode = VK_POLYGON_MODE_FILL;
|
|
rsState.cullMode = VK_CULL_MODE_BACK_BIT;
|
|
rsState.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
|
|
rsState.depthBiasEnable = VK_FALSE;
|
|
rsState.depthBiasConstant = 0.0f;
|
|
rsState.depthBiasClamp = 0.0f;
|
|
rsState.depthBiasSlope = 0.0f;
|
|
m_context->setRasterizerState(rsState);
|
|
|
|
DxvkMultisampleState msState;
|
|
msState.sampleMask = 0xffffffff;
|
|
msState.enableAlphaToCoverage = VK_FALSE;
|
|
msState.enableAlphaToOne = VK_FALSE;
|
|
m_context->setMultisampleState(msState);
|
|
|
|
VkStencilOpState stencilOp;
|
|
stencilOp.failOp = VK_STENCIL_OP_KEEP;
|
|
stencilOp.passOp = VK_STENCIL_OP_KEEP;
|
|
stencilOp.depthFailOp = VK_STENCIL_OP_KEEP;
|
|
stencilOp.compareOp = VK_COMPARE_OP_ALWAYS;
|
|
stencilOp.compareMask = 0xFFFFFFFF;
|
|
stencilOp.writeMask = 0xFFFFFFFF;
|
|
stencilOp.reference = 0;
|
|
|
|
DxvkDepthStencilState dsState;
|
|
dsState.enableDepthTest = VK_FALSE;
|
|
dsState.enableDepthWrite = VK_FALSE;
|
|
dsState.enableDepthBounds = VK_FALSE;
|
|
dsState.enableStencilTest = VK_FALSE;
|
|
dsState.depthCompareOp = VK_COMPARE_OP_ALWAYS;
|
|
dsState.stencilOpFront = stencilOp;
|
|
dsState.stencilOpBack = stencilOp;
|
|
dsState.depthBoundsMin = 0.0f;
|
|
dsState.depthBoundsMax = 1.0f;
|
|
m_context->setDepthStencilState(dsState);
|
|
|
|
DxvkLogicOpState loState;
|
|
loState.enableLogicOp = VK_FALSE;
|
|
loState.logicOp = VK_LOGIC_OP_NO_OP;
|
|
m_context->setLogicOpState(loState);
|
|
|
|
m_blendMode.enableBlending = VK_FALSE;
|
|
m_blendMode.colorSrcFactor = VK_BLEND_FACTOR_ONE;
|
|
m_blendMode.colorDstFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
|
|
m_blendMode.colorBlendOp = VK_BLEND_OP_ADD;
|
|
m_blendMode.alphaSrcFactor = VK_BLEND_FACTOR_ONE;
|
|
m_blendMode.alphaDstFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
|
|
m_blendMode.alphaBlendOp = VK_BLEND_OP_ADD;
|
|
m_blendMode.writeMask = VK_COLOR_COMPONENT_R_BIT
|
|
| VK_COLOR_COMPONENT_G_BIT
|
|
| VK_COLOR_COMPONENT_B_BIT
|
|
| VK_COLOR_COMPONENT_A_BIT;
|
|
|
|
m_context->bindShader(
|
|
VK_SHADER_STAGE_VERTEX_BIT,
|
|
CreateVertexShader());
|
|
|
|
m_context->bindShader(
|
|
VK_SHADER_STAGE_FRAGMENT_BIT,
|
|
CreateFragmentShader());
|
|
|
|
m_hud = hud::Hud::createHud(m_device);
|
|
}
|
|
|
|
|
|
DxgiVkPresenter::~DxgiVkPresenter() {
|
|
m_device->waitForIdle();
|
|
}
|
|
|
|
|
|
void DxgiVkPresenter::InitBackBuffer(const Rc<DxvkImage>& Image) {
|
|
m_context->beginRecording(
|
|
m_device->createCommandList());
|
|
|
|
VkImageSubresourceRange sr;
|
|
sr.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
|
sr.baseMipLevel = 0;
|
|
sr.levelCount = Image->info().mipLevels;
|
|
sr.baseArrayLayer = 0;
|
|
sr.layerCount = Image->info().numLayers;
|
|
|
|
m_context->initImage(Image, sr);
|
|
|
|
m_device->submitCommandList(
|
|
m_context->endRecording(),
|
|
nullptr, nullptr);
|
|
}
|
|
|
|
|
|
void DxgiVkPresenter::PresentImage() {
|
|
if (m_hud != nullptr) {
|
|
m_hud->render({
|
|
m_options.preferredBufferSize.width,
|
|
m_options.preferredBufferSize.height,
|
|
});
|
|
}
|
|
|
|
const bool fitSize =
|
|
m_backBuffer->info().extent.width == m_options.preferredBufferSize.width
|
|
&& m_backBuffer->info().extent.height == m_options.preferredBufferSize.height;
|
|
|
|
m_context->beginRecording(
|
|
m_device->createCommandList());
|
|
|
|
VkImageSubresourceLayers resolveSubresources;
|
|
resolveSubresources.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
|
resolveSubresources.mipLevel = 0;
|
|
resolveSubresources.baseArrayLayer = 0;
|
|
resolveSubresources.layerCount = 1;
|
|
|
|
if (m_backBufferResolve != nullptr) {
|
|
m_context->resolveImage(
|
|
m_backBufferResolve, resolveSubresources,
|
|
m_backBuffer, resolveSubresources,
|
|
VK_FORMAT_UNDEFINED);
|
|
}
|
|
|
|
auto swapSemas = m_swapchain->getSemaphorePair();
|
|
auto swapImage = m_swapchain->getImageView(swapSemas.acquireSync);
|
|
|
|
DxvkRenderTargets renderTargets;
|
|
renderTargets.color[0].view = swapImage;
|
|
renderTargets.color[0].layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
|
m_context->bindRenderTargets(renderTargets);
|
|
|
|
VkViewport viewport;
|
|
viewport.x = 0.0f;
|
|
viewport.y = 0.0f;
|
|
viewport.width = float(swapImage->imageInfo().extent.width);
|
|
viewport.height = float(swapImage->imageInfo().extent.height);
|
|
viewport.minDepth = 0.0f;
|
|
viewport.maxDepth = 1.0f;
|
|
|
|
VkRect2D scissor;
|
|
scissor.offset.x = 0;
|
|
scissor.offset.y = 0;
|
|
scissor.extent.width = swapImage->imageInfo().extent.width;
|
|
scissor.extent.height = swapImage->imageInfo().extent.height;
|
|
|
|
m_context->setViewports(1, &viewport, &scissor);
|
|
|
|
m_context->bindResourceSampler(BindingIds::Sampler,
|
|
fitSize ? m_samplerFitting : m_samplerScaling);
|
|
|
|
m_blendMode.enableBlending = VK_FALSE;
|
|
m_context->setBlendMode(0, m_blendMode);
|
|
|
|
m_context->bindResourceView(BindingIds::Texture, m_backBufferView, nullptr);
|
|
m_context->draw(4, 1, 0, 0);
|
|
|
|
m_context->bindResourceSampler(BindingIds::GammaSmp, m_gammaSampler);
|
|
m_context->bindResourceView (BindingIds::GammaTex, m_gammaTextureView, nullptr);
|
|
|
|
if (m_hud != nullptr) {
|
|
m_blendMode.enableBlending = VK_TRUE;
|
|
m_context->setBlendMode(0, m_blendMode);
|
|
|
|
m_context->bindResourceView(BindingIds::Texture, m_hud->texture(), nullptr);
|
|
m_context->draw(4, 1, 0, 0);
|
|
}
|
|
|
|
m_device->submitCommandList(
|
|
m_context->endRecording(),
|
|
swapSemas.acquireSync,
|
|
swapSemas.presentSync);
|
|
|
|
m_swapchain->present(
|
|
swapSemas.presentSync);
|
|
}
|
|
|
|
|
|
void DxgiVkPresenter::UpdateBackBuffer(const Rc<DxvkImage>& Image) {
|
|
// Explicitly destroy the old stuff
|
|
m_backBuffer = Image;
|
|
m_backBufferResolve = nullptr;
|
|
m_backBufferView = nullptr;
|
|
|
|
// If a multisampled back buffer was requested, we also need to
|
|
// create a resolve image with otherwise identical properties.
|
|
// Multisample images cannot be sampled from.
|
|
if (Image->info().sampleCount != VK_SAMPLE_COUNT_1_BIT) {
|
|
DxvkImageCreateInfo resolveInfo;
|
|
resolveInfo.type = VK_IMAGE_TYPE_2D;
|
|
resolveInfo.format = Image->info().format;
|
|
resolveInfo.flags = 0;
|
|
resolveInfo.sampleCount = VK_SAMPLE_COUNT_1_BIT;
|
|
resolveInfo.extent = Image->info().extent;
|
|
resolveInfo.numLayers = 1;
|
|
resolveInfo.mipLevels = 1;
|
|
resolveInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT
|
|
| VK_IMAGE_USAGE_TRANSFER_DST_BIT;
|
|
resolveInfo.stages = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
|
|
| VK_PIPELINE_STAGE_TRANSFER_BIT;
|
|
resolveInfo.access = VK_ACCESS_SHADER_READ_BIT
|
|
| VK_ACCESS_TRANSFER_WRITE_BIT;
|
|
resolveInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
|
|
resolveInfo.layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
|
|
|
m_backBufferResolve = m_device->createImage(
|
|
resolveInfo, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
}
|
|
|
|
// Create an image view that allows the
|
|
// image to be bound as a shader resource.
|
|
DxvkImageViewCreateInfo viewInfo;
|
|
viewInfo.type = VK_IMAGE_VIEW_TYPE_2D;
|
|
viewInfo.format = Image->info().format;
|
|
viewInfo.aspect = VK_IMAGE_ASPECT_COLOR_BIT;
|
|
viewInfo.minLevel = 0;
|
|
viewInfo.numLevels = 1;
|
|
viewInfo.minLayer = 0;
|
|
viewInfo.numLayers = 1;
|
|
|
|
m_backBufferView = m_device->createImageView(
|
|
m_backBufferResolve != nullptr
|
|
? m_backBufferResolve
|
|
: m_backBuffer,
|
|
viewInfo);
|
|
|
|
InitBackBuffer(m_backBuffer);
|
|
}
|
|
|
|
|
|
void DxgiVkPresenter::SetGammaControl(
|
|
const DXGI_VK_GAMMA_CURVE* pGammaCurve) {
|
|
m_context->beginRecording(
|
|
m_device->createCommandList());
|
|
|
|
m_context->updateImage(m_gammaTexture,
|
|
VkImageSubresourceLayers { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 },
|
|
VkOffset3D { 0, 0, 0 },
|
|
VkExtent3D { DXGI_VK_GAMMA_CP_COUNT, 1, 1 },
|
|
pGammaCurve, 0, 0);
|
|
|
|
m_device->submitCommandList(
|
|
m_context->endRecording(),
|
|
nullptr, nullptr);
|
|
}
|
|
|
|
|
|
void DxgiVkPresenter::RecreateSwapchain(DXGI_FORMAT Format, VkPresentModeKHR PresentMode, VkExtent2D WindowSize) {
|
|
if (m_surface == nullptr)
|
|
m_surface = CreateSurface();
|
|
|
|
DxvkSwapchainProperties options;
|
|
options.preferredSurfaceFormat = PickSurfaceFormat(Format);
|
|
options.preferredPresentMode = PickPresentMode(PresentMode);
|
|
options.preferredBufferSize = WindowSize;
|
|
|
|
const bool doRecreate =
|
|
options.preferredSurfaceFormat.format != m_options.preferredSurfaceFormat.format
|
|
|| options.preferredSurfaceFormat.colorSpace != m_options.preferredSurfaceFormat.colorSpace
|
|
|| options.preferredPresentMode != m_options.preferredPresentMode
|
|
|| options.preferredBufferSize.width != m_options.preferredBufferSize.width
|
|
|| options.preferredBufferSize.height != m_options.preferredBufferSize.height;
|
|
|
|
if (doRecreate) {
|
|
Logger::info(str::format(
|
|
"DxgiVkPresenter: Recreating swap chain: ",
|
|
"\n Format: ", options.preferredSurfaceFormat.format,
|
|
"\n Present mode: ", options.preferredPresentMode,
|
|
"\n Buffer size: ", options.preferredBufferSize.width, "x", options.preferredBufferSize.height));
|
|
|
|
if (m_swapchain == nullptr)
|
|
m_swapchain = m_device->createSwapchain(m_surface, options);
|
|
else
|
|
m_swapchain->changeProperties(options);
|
|
|
|
m_options = options;
|
|
}
|
|
}
|
|
|
|
|
|
VkSurfaceFormatKHR DxgiVkPresenter::PickSurfaceFormat(DXGI_FORMAT Fmt) const {
|
|
std::vector<VkSurfaceFormatKHR> formats;
|
|
|
|
switch (Fmt) {
|
|
case DXGI_FORMAT_R8G8B8A8_UNORM:
|
|
case DXGI_FORMAT_B8G8R8A8_UNORM: {
|
|
formats.push_back({ VK_FORMAT_R8G8B8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
formats.push_back({ VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
} break;
|
|
|
|
case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB:
|
|
case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: {
|
|
formats.push_back({ VK_FORMAT_R8G8B8A8_SRGB, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
formats.push_back({ VK_FORMAT_B8G8R8A8_SRGB, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
} break;
|
|
|
|
case DXGI_FORMAT_R10G10B10A2_UNORM: {
|
|
formats.push_back({ VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
formats.push_back({ VK_FORMAT_A2R10G10B10_UNORM_PACK32, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
} break;
|
|
|
|
case DXGI_FORMAT_R16G16B16A16_FLOAT: {
|
|
formats.push_back({ VK_FORMAT_R16G16B16A16_SFLOAT, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR });
|
|
} break;
|
|
|
|
default:
|
|
Logger::warn(str::format("DxgiVkPresenter: Unknown format: ", Fmt));
|
|
}
|
|
|
|
return m_surface->pickSurfaceFormat(
|
|
formats.size(), formats.data());
|
|
}
|
|
|
|
|
|
VkPresentModeKHR DxgiVkPresenter::PickPresentMode(VkPresentModeKHR Preferred) const {
|
|
return m_surface->pickPresentMode(1, &Preferred);
|
|
}
|
|
|
|
|
|
Rc<DxvkSurface> DxgiVkPresenter::CreateSurface() {
|
|
HINSTANCE instance = reinterpret_cast<HINSTANCE>(
|
|
GetWindowLongPtr(m_window, GWLP_HINSTANCE));
|
|
|
|
return m_device->adapter()->createSurface(instance, m_window);
|
|
}
|
|
|
|
|
|
Rc<DxvkSampler> DxgiVkPresenter::CreateSampler(
|
|
VkFilter Filter,
|
|
VkSamplerAddressMode AddressMode) {
|
|
DxvkSamplerCreateInfo samplerInfo;
|
|
samplerInfo.magFilter = Filter;
|
|
samplerInfo.minFilter = Filter;
|
|
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST;
|
|
samplerInfo.mipmapLodBias = 0.0f;
|
|
samplerInfo.mipmapLodMin = 0.0f;
|
|
samplerInfo.mipmapLodMax = 0.0f;
|
|
samplerInfo.useAnisotropy = VK_FALSE;
|
|
samplerInfo.maxAnisotropy = 1.0f;
|
|
samplerInfo.addressModeU = AddressMode;
|
|
samplerInfo.addressModeV = AddressMode;
|
|
samplerInfo.addressModeW = AddressMode;
|
|
samplerInfo.compareToDepth = VK_FALSE;
|
|
samplerInfo.compareOp = VK_COMPARE_OP_ALWAYS;
|
|
samplerInfo.borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK;
|
|
samplerInfo.usePixelCoord = VK_FALSE;
|
|
return m_device->createSampler(samplerInfo);
|
|
}
|
|
|
|
|
|
Rc<DxvkImage> DxgiVkPresenter::CreateGammaTexture() {
|
|
DxvkImageCreateInfo info;
|
|
info.type = VK_IMAGE_TYPE_1D;
|
|
info.format = VK_FORMAT_R16G16B16A16_UNORM;
|
|
info.flags = 0;
|
|
info.sampleCount = VK_SAMPLE_COUNT_1_BIT;
|
|
info.extent = { DXGI_VK_GAMMA_CP_COUNT, 1, 1 };
|
|
info.numLayers = 1;
|
|
info.mipLevels = 1;
|
|
info.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT
|
|
| VK_IMAGE_USAGE_SAMPLED_BIT;
|
|
info.stages = VK_PIPELINE_STAGE_TRANSFER_BIT
|
|
| VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
|
|
info.access = VK_ACCESS_TRANSFER_WRITE_BIT
|
|
| VK_ACCESS_SHADER_READ_BIT;
|
|
info.tiling = VK_IMAGE_TILING_OPTIMAL;
|
|
info.layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
|
return m_device->createImage(info, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
}
|
|
|
|
|
|
Rc<DxvkImageView> DxgiVkPresenter::CreateGammaTextureView() {
|
|
DxvkImageViewCreateInfo info;
|
|
info.type = VK_IMAGE_VIEW_TYPE_1D;
|
|
info.format = VK_FORMAT_R16G16B16A16_UNORM;
|
|
info.aspect = VK_IMAGE_ASPECT_COLOR_BIT;
|
|
info.minLevel = 0;
|
|
info.numLevels = 1;
|
|
info.minLayer = 0;
|
|
info.numLayers = 1;
|
|
return m_device->createImageView(m_gammaTexture, info);
|
|
}
|
|
|
|
|
|
Rc<DxvkShader> DxgiVkPresenter::CreateVertexShader() {
|
|
const SpirvCodeBuffer codeBuffer(dxgi_presenter_vert);
|
|
|
|
return m_device->createShader(
|
|
VK_SHADER_STAGE_VERTEX_BIT,
|
|
0, nullptr, { 0u, 1u },
|
|
codeBuffer);
|
|
}
|
|
|
|
|
|
Rc<DxvkShader> DxgiVkPresenter::CreateFragmentShader() {
|
|
const SpirvCodeBuffer codeBuffer(dxgi_presenter_frag);
|
|
|
|
// Shader resource slots
|
|
const std::array<DxvkResourceSlot, 4> resourceSlots = {{
|
|
{ BindingIds::Sampler, VK_DESCRIPTOR_TYPE_SAMPLER, VK_IMAGE_VIEW_TYPE_MAX_ENUM },
|
|
{ BindingIds::Texture, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, VK_IMAGE_VIEW_TYPE_2D },
|
|
{ BindingIds::GammaSmp, VK_DESCRIPTOR_TYPE_SAMPLER, VK_IMAGE_VIEW_TYPE_MAX_ENUM },
|
|
{ BindingIds::GammaTex, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, VK_IMAGE_VIEW_TYPE_1D },
|
|
}};
|
|
|
|
// Create the actual shader module
|
|
return m_device->createShader(
|
|
VK_SHADER_STAGE_FRAGMENT_BIT,
|
|
resourceSlots.size(),
|
|
resourceSlots.data(),
|
|
{ 1u, 1u },
|
|
codeBuffer);
|
|
}
|
|
|
|
}
|