
4 changed files with 901 additions and 70 deletions
@ -0,0 +1,9 @@ |
|||
#version 450 |
|||
|
|||
layout(location = 0) in vec3 fragColor; |
|||
|
|||
layout(location = 0) out vec4 outColor; |
|||
|
|||
void main() { |
|||
outColor = vec4(fragColor, 1.0); |
|||
} |
@ -0,0 +1,20 @@ |
|||
#version 450 |
|||
|
|||
layout(location = 0) out vec3 fragColor; |
|||
|
|||
vec2 positions[3] = vec2[]( |
|||
vec2(0.0, -0.5), |
|||
vec2(0.5, 0.5), |
|||
vec2(-0.5, 0.5) |
|||
); |
|||
|
|||
vec3 colors[3] = vec3[]( |
|||
vec3(1.0, 0.0, 0.0), |
|||
vec3(0.0, 1.0, 0.0), |
|||
vec3(0.0, 0.0, 1.0) |
|||
); |
|||
|
|||
void main() { |
|||
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0); |
|||
fragColor = colors[gl_VertexIndex]; |
|||
} |
@ -1,101 +1,901 @@ |
|||
#define GLFW_INCLUDE_VULKAN |
|||
#include <GLFW/glfw3.h> |
|||
|
|||
#include <heck/log.h> |
|||
#include <iostream> |
|||
#include <stdexcept> |
|||
#include <cstdlib> |
|||
#include <vector> |
|||
#include <optional> |
|||
#include <set> |
|||
#include <fstream> |
|||
|
|||
|
|||
const uint32_t WIDTH = 800; |
|||
const uint32_t HEIGHT = 600; |
|||
|
|||
class HelloTriangleApplication { |
|||
public: |
|||
void run() |
|||
{ |
|||
initWindow(); |
|||
initVulkan(); |
|||
mainLoop(); |
|||
cleanup(); |
|||
} |
|||
namespace Heck { |
|||
struct VulkanBase { |
|||
VulkanBase(GLFWwindow& window) : window(window) |
|||
{ |
|||
deviceExtensions.emplace_back(VK_KHR_SWAPCHAIN_EXTENSION_NAME); |
|||
} |
|||
VulkanBase() = delete; |
|||
VulkanBase(VulkanBase& other) = delete; |
|||
VulkanBase(VulkanBase&& other) = delete; |
|||
VulkanBase& operator=(VulkanBase& rhs) = delete; |
|||
VulkanBase& operator=(VulkanBase&& rhs) = delete; |
|||
~VulkanBase() = default; |
|||
|
|||
private: |
|||
GLFWwindow* window; |
|||
VkInstance instance; |
|||
struct QueueFamilyIndices { |
|||
std::optional<uint32_t> graphicsFamily; |
|||
std::optional<uint32_t> presentFamily; |
|||
|
|||
void initWindow() |
|||
{ |
|||
glfwInit(); |
|||
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); |
|||
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); |
|||
window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr); |
|||
} |
|||
bool isComplete() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
return graphicsFamily.has_value() && presentFamily.has_value(); |
|||
} |
|||
}; |
|||
|
|||
void initVulkan() |
|||
{ |
|||
createInstance(); |
|||
} |
|||
struct SwapChainSupportDetails { |
|||
VkSurfaceCapabilitiesKHR capabilities; |
|||
std::vector<VkSurfaceFormatKHR> formats; |
|||
std::vector<VkPresentModeKHR> presentModes; |
|||
}; |
|||
|
|||
std::vector<const char*> deviceExtensions{}; |
|||
|
|||
GLFWwindow& window; |
|||
VkInstance instance{}; |
|||
VkSurfaceKHR surface{}; |
|||
|
|||
VkPhysicalDevice physicalDevice = VK_NULL_HANDLE; |
|||
VkDevice device{}; |
|||
|
|||
VkQueue graphicsQueue{}; |
|||
VkQueue presentQueue{}; |
|||
|
|||
VkSwapchainKHR swapChain{}; |
|||
std::vector<VkImage> swapChainImages{}; |
|||
VkFormat swapChainImageFormat{}; |
|||
VkExtent2D swapChainExtent{}; |
|||
std::vector<VkImageView> swapChainImageViews{}; |
|||
|
|||
VkPipelineLayout pipelineLayout{}; |
|||
VkRenderPass renderPass{}; |
|||
VkPipeline graphicsPipeline{}; |
|||
std::vector<VkFramebuffer> swapChainFramebuffers{}; |
|||
|
|||
void mainLoop() |
|||
{ |
|||
while (!glfwWindowShouldClose(window)) { |
|||
glfwPollEvents(); |
|||
VkCommandPool commandPool{}; |
|||
VkCommandBuffer commandBuffer{}; |
|||
|
|||
VkSemaphore imageAvailableSemaphore{}; |
|||
VkSemaphore renderFinishedSemaphore{}; |
|||
VkFence inFlightFence{}; |
|||
|
|||
|
|||
void init() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
createInstance(); |
|||
createSurface(); |
|||
pickPhysicalDevice(); |
|||
createLogicalDevice(); |
|||
createSwapChain(); |
|||
createImageViews(); |
|||
createRenderPass(); |
|||
createGraphicsPipeline(); |
|||
createFramebuffers(); |
|||
createCommandPool(); |
|||
createCommandBuffer(); |
|||
createSyncObjects(); |
|||
} |
|||
} |
|||
|
|||
void cleanup() |
|||
{ |
|||
vkDestroyInstance(instance, nullptr); |
|||
glfwDestroyWindow(window); |
|||
glfwTerminate(); |
|||
} |
|||
void createInstance() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
VkInstanceCreateInfo createInfo{}; |
|||
|
|||
VkApplicationInfo appInfo{}; |
|||
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; |
|||
appInfo.pApplicationName = "Hello Triangle"; |
|||
appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0); |
|||
appInfo.pEngineName = "No Engine"; |
|||
appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0); |
|||
appInfo.apiVersion = VK_API_VERSION_1_0; |
|||
createInfo.pApplicationInfo = &appInfo; |
|||
|
|||
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; |
|||
|
|||
uint32_t glfwExtensionCount = 0; |
|||
const char** glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount); |
|||
createInfo.enabledExtensionCount = glfwExtensionCount; |
|||
createInfo.ppEnabledExtensionNames = glfwExtensions; |
|||
createInfo.enabledLayerCount = 0; |
|||
|
|||
std::vector<const char*> requiredExtensions{}; |
|||
for (uint32_t i = 0; i < glfwExtensionCount; i++) { |
|||
requiredExtensions.emplace_back(glfwExtensions[i]); |
|||
} |
|||
requiredExtensions.emplace_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME); |
|||
|
|||
createInfo.flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; |
|||
createInfo.enabledExtensionCount = (uint32_t)requiredExtensions.size(); |
|||
createInfo.ppEnabledExtensionNames = requiredExtensions.data(); |
|||
|
|||
if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create instance!"); |
|||
} |
|||
} |
|||
|
|||
void createSurface() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
if (glfwCreateWindowSurface(instance, &window, nullptr, &surface) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create window surface!"); |
|||
} |
|||
} |
|||
|
|||
QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
QueueFamilyIndices indices{}; |
|||
|
|||
uint32_t queueFamilyCount = 0; |
|||
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr); |
|||
|
|||
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount); |
|||
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data()); |
|||
|
|||
int i = 0; |
|||
for (const auto& queueFamily : queueFamilies) { |
|||
HECK_LOG_INFO("Checking queue family nr:" << 0); |
|||
if (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) { |
|||
indices.graphicsFamily = i; |
|||
} |
|||
|
|||
VkBool32 presentSupport = false; |
|||
vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport); |
|||
if (presentSupport) { |
|||
indices.presentFamily = i; |
|||
} |
|||
|
|||
if (indices.isComplete()) { |
|||
break; |
|||
} |
|||
|
|||
i++; |
|||
} |
|||
|
|||
return indices; |
|||
} |
|||
|
|||
void pickPhysicalDevice() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
uint32_t deviceCount = 0; |
|||
vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); |
|||
|
|||
HECK_LOG_INFO("devices found:" << deviceCount); |
|||
if (deviceCount == 0) { |
|||
throw std::runtime_error("failed to find GPUs with Vulkan support!"); |
|||
} |
|||
|
|||
std::vector<VkPhysicalDevice> devices{ deviceCount }; |
|||
vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data()); |
|||
|
|||
for (const VkPhysicalDevice& device : devices) { |
|||
if (isDeviceSuitable(device)) { |
|||
physicalDevice = device; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (physicalDevice == VK_NULL_HANDLE) { |
|||
throw std::runtime_error("failed to find a suitable GPU!"); |
|||
} |
|||
} |
|||
|
|||
void createLogicalDevice() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
QueueFamilyIndices indices = findQueueFamilies(physicalDevice); |
|||
|
|||
std::vector<VkDeviceQueueCreateInfo> queueCreateInfos; |
|||
std::set<uint32_t> uniqueQueueFamilies = { indices.graphicsFamily.value(), |
|||
indices.presentFamily.value() }; |
|||
|
|||
float queuePriority = 1.0f; |
|||
for (uint32_t queueFamily : uniqueQueueFamilies) { |
|||
VkDeviceQueueCreateInfo queueCreateInfo{}; |
|||
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; |
|||
queueCreateInfo.queueFamilyIndex = queueFamily; |
|||
queueCreateInfo.queueCount = 1; |
|||
queueCreateInfo.pQueuePriorities = &queuePriority; |
|||
queueCreateInfos.push_back(queueCreateInfo); |
|||
} |
|||
|
|||
VkPhysicalDeviceFeatures deviceFeatures{}; |
|||
|
|||
VkDeviceCreateInfo createInfo{}; |
|||
createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; |
|||
|
|||
createInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size()); |
|||
createInfo.pQueueCreateInfos = queueCreateInfos.data(); |
|||
|
|||
createInfo.pEnabledFeatures = &deviceFeatures; |
|||
|
|||
void createInstance() |
|||
{ |
|||
VkApplicationInfo appInfo{}; |
|||
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; |
|||
appInfo.pApplicationName = "Hello Triangle"; |
|||
appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0); |
|||
appInfo.pEngineName = "No Engine"; |
|||
appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0); |
|||
appInfo.apiVersion = VK_API_VERSION_1_0; |
|||
createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size()); |
|||
createInfo.ppEnabledExtensionNames = deviceExtensions.data(); |
|||
|
|||
VkInstanceCreateInfo createInfo{}; |
|||
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; |
|||
createInfo.pApplicationInfo = &appInfo; |
|||
createInfo.enabledLayerCount = 0; |
|||
|
|||
uint32_t glfwExtensionCount = 0; |
|||
const char** glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount); |
|||
createInfo.enabledExtensionCount = glfwExtensionCount; |
|||
createInfo.ppEnabledExtensionNames = glfwExtensions; |
|||
createInfo.enabledLayerCount = 0; |
|||
if (vkCreateDevice(physicalDevice, &createInfo, nullptr, &device) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create logical device!"); |
|||
} |
|||
|
|||
std::vector<const char*> requiredExtensions{}; |
|||
for (uint32_t i = 0; i < glfwExtensionCount; i++) { |
|||
requiredExtensions.emplace_back(glfwExtensions[i]); |
|||
vkGetDeviceQueue(device, indices.graphicsFamily.value(), 0, &graphicsQueue); |
|||
vkGetDeviceQueue(device, indices.presentFamily.value(), 0, &presentQueue); |
|||
} |
|||
requiredExtensions.emplace_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME); |
|||
|
|||
createInfo.flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; |
|||
createInfo.enabledExtensionCount = (uint32_t)requiredExtensions.size(); |
|||
createInfo.ppEnabledExtensionNames = requiredExtensions.data(); |
|||
bool isDeviceSuitable(VkPhysicalDevice device) |
|||
{ |
|||
HECK_LOG_INFO(device); |
|||
bool ret = false; |
|||
QueueFamilyIndices indices = findQueueFamilies(device); |
|||
|
|||
if (vkCreateInstance(&createInfo, nullptr, &instance) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create instance!"); |
|||
bool extensionsSupported = checkDeviceExtensionSupport(device); |
|||
bool swapChainAdequate = false; |
|||
if (extensionsSupported) { |
|||
SwapChainSupportDetails swapChainSupport = querySwapChainSupport(device); |
|||
swapChainAdequate = !swapChainSupport.formats.empty() && |
|||
!swapChainSupport.presentModes.empty(); |
|||
} |
|||
|
|||
if (indices.isComplete() && extensionsSupported && swapChainAdequate) { |
|||
HECK_LOG_INFO("device is suitable"); |
|||
ret = true; |
|||
} else { |
|||
HECK_LOG_INFO("device not suitable"); |
|||
ret = false; |
|||
} |
|||
return ret; |
|||
} |
|||
|
|||
|
|||
void createSwapChain() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
SwapChainSupportDetails swapChainSupport = querySwapChainSupport(physicalDevice); |
|||
|
|||
VkSurfaceFormatKHR surfaceFormat = chooseSwapSurfaceFormat(swapChainSupport.formats); |
|||
VkPresentModeKHR presentMode = chooseSwapPresentMode(swapChainSupport.presentModes); |
|||
VkExtent2D extent = chooseSwapExtent(swapChainSupport.capabilities); |
|||
|
|||
uint32_t imageCount = swapChainSupport.capabilities.minImageCount + 1; |
|||
if (swapChainSupport.capabilities.maxImageCount > 0 && |
|||
imageCount > swapChainSupport.capabilities.maxImageCount) { |
|||
imageCount = swapChainSupport.capabilities.maxImageCount; |
|||
} |
|||
|
|||
VkSwapchainCreateInfoKHR createInfo{}; |
|||
createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; |
|||
createInfo.surface = surface; |
|||
|
|||
createInfo.minImageCount = imageCount; |
|||
createInfo.imageFormat = surfaceFormat.format; |
|||
createInfo.imageColorSpace = surfaceFormat.colorSpace; |
|||
createInfo.imageExtent = extent; |
|||
createInfo.imageArrayLayers = 1; |
|||
createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; |
|||
|
|||
QueueFamilyIndices indices = findQueueFamilies(physicalDevice); |
|||
uint32_t queueFamilyIndices[] = { indices.graphicsFamily.value(), |
|||
indices.presentFamily.value() }; |
|||
|
|||
if (indices.graphicsFamily != indices.presentFamily) { |
|||
createInfo.imageSharingMode = VK_SHARING_MODE_CONCURRENT; |
|||
createInfo.queueFamilyIndexCount = 2; |
|||
createInfo.pQueueFamilyIndices = queueFamilyIndices; |
|||
} else { |
|||
createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; |
|||
} |
|||
|
|||
createInfo.preTransform = swapChainSupport.capabilities.currentTransform; |
|||
createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; |
|||
createInfo.presentMode = presentMode; |
|||
createInfo.clipped = VK_TRUE; |
|||
|
|||
createInfo.oldSwapchain = VK_NULL_HANDLE; |
|||
|
|||
if (vkCreateSwapchainKHR(device, &createInfo, nullptr, &swapChain) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create swap chain!"); |
|||
} |
|||
|
|||
vkGetSwapchainImagesKHR(device, swapChain, &imageCount, nullptr); |
|||
swapChainImages.resize(imageCount); |
|||
vkGetSwapchainImagesKHR(device, swapChain, &imageCount, swapChainImages.data()); |
|||
|
|||
swapChainImageFormat = surfaceFormat.format; |
|||
swapChainExtent = extent; |
|||
} |
|||
|
|||
VkSurfaceFormatKHR chooseSwapSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& availableFormats) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
for (const auto& availableFormat : availableFormats) { |
|||
if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB && |
|||
availableFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) { |
|||
return availableFormat; |
|||
} |
|||
} |
|||
|
|||
return availableFormats[0]; |
|||
} |
|||
|
|||
VkPresentModeKHR chooseSwapPresentMode(const std::vector<VkPresentModeKHR>& availablePresentModes) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
for (const auto& availablePresentMode : availablePresentModes) { |
|||
if (availablePresentMode == VK_PRESENT_MODE_MAILBOX_KHR) { |
|||
return availablePresentMode; |
|||
} |
|||
} |
|||
|
|||
return VK_PRESENT_MODE_FIFO_KHR; |
|||
} |
|||
|
|||
VkExtent2D chooseSwapExtent(const VkSurfaceCapabilitiesKHR& capabilities) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
if (capabilities.currentExtent.width != std::numeric_limits<uint32_t>::max()) { |
|||
return capabilities.currentExtent; |
|||
} else { |
|||
int width; |
|||
int height; |
|||
glfwGetFramebufferSize(&window, &width, &height); |
|||
|
|||
VkExtent2D actualExtent = { static_cast<uint32_t>(width), |
|||
static_cast<uint32_t>(height) }; |
|||
|
|||
actualExtent.width = std::clamp( |
|||
actualExtent.width, |
|||
capabilities.minImageExtent.width, |
|||
capabilities.maxImageExtent.width); |
|||
actualExtent.height = std::clamp( |
|||
actualExtent.height, |
|||
capabilities.minImageExtent.height, |
|||
capabilities.maxImageExtent.height); |
|||
|
|||
return actualExtent; |
|||
} |
|||
} |
|||
|
|||
bool checkDeviceExtensionSupport(VkPhysicalDevice device) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
uint32_t extensionCount; |
|||
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr); |
|||
|
|||
std::vector<VkExtensionProperties> availableExtensions(extensionCount); |
|||
vkEnumerateDeviceExtensionProperties( |
|||
device, |
|||
nullptr, |
|||
&extensionCount, |
|||
availableExtensions.data()); |
|||
|
|||
std::set<std::string> requiredExtensions(deviceExtensions.begin(), deviceExtensions.end()); |
|||
|
|||
for (const auto& extension : availableExtensions) { |
|||
requiredExtensions.erase(extension.extensionName); |
|||
} |
|||
|
|||
return requiredExtensions.empty(); |
|||
} |
|||
|
|||
SwapChainSupportDetails querySwapChainSupport(VkPhysicalDevice device) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
SwapChainSupportDetails details; |
|||
|
|||
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(device, surface, &details.capabilities); |
|||
|
|||
uint32_t formatCount; |
|||
vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr); |
|||
|
|||
if (formatCount != 0) { |
|||
details.formats.resize(formatCount); |
|||
vkGetPhysicalDeviceSurfaceFormatsKHR( |
|||
device, |
|||
surface, |
|||
&formatCount, |
|||
details.formats.data()); |
|||
} |
|||
|
|||
uint32_t presentModeCount; |
|||
vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr); |
|||
|
|||
if (presentModeCount != 0) { |
|||
details.presentModes.resize(presentModeCount); |
|||
vkGetPhysicalDeviceSurfacePresentModesKHR( |
|||
device, |
|||
surface, |
|||
&presentModeCount, |
|||
details.presentModes.data()); |
|||
} |
|||
|
|||
return details; |
|||
} |
|||
|
|||
|
|||
void createImageViews() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
swapChainImageViews.resize(swapChainImages.size()); |
|||
|
|||
for (size_t i = 0; i < swapChainImages.size(); i++) { |
|||
HECK_LOG_INFO("Creating swap chain image nr: " << i); |
|||
VkImageViewCreateInfo createInfo{}; |
|||
createInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; |
|||
createInfo.image = swapChainImages[i]; |
|||
createInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; |
|||
createInfo.format = swapChainImageFormat; |
|||
createInfo.components.r = VK_COMPONENT_SWIZZLE_IDENTITY; |
|||
createInfo.components.g = VK_COMPONENT_SWIZZLE_IDENTITY; |
|||
createInfo.components.b = VK_COMPONENT_SWIZZLE_IDENTITY; |
|||
createInfo.components.a = VK_COMPONENT_SWIZZLE_IDENTITY; |
|||
createInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; |
|||
createInfo.subresourceRange.baseMipLevel = 0; |
|||
createInfo.subresourceRange.levelCount = 1; |
|||
createInfo.subresourceRange.baseArrayLayer = 0; |
|||
createInfo.subresourceRange.layerCount = 1; |
|||
|
|||
if (vkCreateImageView(device, &createInfo, nullptr, &swapChainImageViews[i]) != |
|||
VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create image views!"); |
|||
} |
|||
} |
|||
} |
|||
|
|||
void createGraphicsPipeline() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
auto vertShaderCode = readFile("../shaders/base.vert.spv"); |
|||
auto fragShaderCode = readFile("../shaders/base.frag.spv"); |
|||
|
|||
VkShaderModule vertShaderModule = createShaderModule(vertShaderCode); |
|||
VkShaderModule fragShaderModule = createShaderModule(fragShaderCode); |
|||
|
|||
VkPipelineShaderStageCreateInfo vertShaderStageInfo{}; |
|||
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; |
|||
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT; |
|||
vertShaderStageInfo.module = vertShaderModule; |
|||
vertShaderStageInfo.pName = "main"; |
|||
|
|||
VkPipelineShaderStageCreateInfo fragShaderStageInfo{}; |
|||
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; |
|||
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT; |
|||
fragShaderStageInfo.module = fragShaderModule; |
|||
fragShaderStageInfo.pName = "main"; |
|||
|
|||
VkPipelineShaderStageCreateInfo shaderStages[] = { vertShaderStageInfo, |
|||
fragShaderStageInfo }; |
|||
|
|||
VkPipelineVertexInputStateCreateInfo vertexInputInfo{}; |
|||
vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; |
|||
vertexInputInfo.vertexBindingDescriptionCount = 0; |
|||
vertexInputInfo.vertexAttributeDescriptionCount = 0; |
|||
|
|||
VkPipelineInputAssemblyStateCreateInfo inputAssembly{}; |
|||
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; |
|||
inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; |
|||
inputAssembly.primitiveRestartEnable = VK_FALSE; |
|||
|
|||
VkPipelineViewportStateCreateInfo viewportState{}; |
|||
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; |
|||
viewportState.viewportCount = 1; |
|||
viewportState.scissorCount = 1; |
|||
|
|||
VkPipelineRasterizationStateCreateInfo rasterizer{}; |
|||
rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; |
|||
rasterizer.depthClampEnable = VK_FALSE; |
|||
rasterizer.rasterizerDiscardEnable = VK_FALSE; |
|||
rasterizer.polygonMode = VK_POLYGON_MODE_FILL; |
|||
rasterizer.lineWidth = 1.0f; |
|||
rasterizer.cullMode = VK_CULL_MODE_BACK_BIT; |
|||
rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE; |
|||
rasterizer.depthBiasEnable = VK_FALSE; |
|||
|
|||
VkPipelineMultisampleStateCreateInfo multisampling{}; |
|||
multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; |
|||
multisampling.sampleShadingEnable = VK_FALSE; |
|||
multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; |
|||
|
|||
VkPipelineColorBlendAttachmentState colorBlendAttachment{}; |
|||
colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | |
|||
VK_COLOR_COMPONENT_G_BIT | |
|||
VK_COLOR_COMPONENT_B_BIT | |
|||
VK_COLOR_COMPONENT_A_BIT; |
|||
colorBlendAttachment.blendEnable = VK_FALSE; |
|||
|
|||
VkPipelineColorBlendStateCreateInfo colorBlending{}; |
|||
colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; |
|||
colorBlending.logicOpEnable = VK_FALSE; |
|||
colorBlending.logicOp = VK_LOGIC_OP_COPY; |
|||
colorBlending.attachmentCount = 1; |
|||
colorBlending.pAttachments = &colorBlendAttachment; |
|||
colorBlending.blendConstants[0] = 0.0f; |
|||
colorBlending.blendConstants[1] = 0.0f; |
|||
colorBlending.blendConstants[2] = 0.0f; |
|||
colorBlending.blendConstants[3] = 0.0f; |
|||
|
|||
std::vector<VkDynamicState> dynamicStates = { VK_DYNAMIC_STATE_VIEWPORT, |
|||
VK_DYNAMIC_STATE_SCISSOR }; |
|||
VkPipelineDynamicStateCreateInfo dynamicState{}; |
|||
dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; |
|||
dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size()); |
|||
dynamicState.pDynamicStates = dynamicStates.data(); |
|||
|
|||
VkPipelineLayoutCreateInfo pipelineLayoutInfo{}; |
|||
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; |
|||
pipelineLayoutInfo.setLayoutCount = 0; |
|||
pipelineLayoutInfo.pushConstantRangeCount = 0; |
|||
|
|||
if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != |
|||
VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create pipeline layout!"); |
|||
} |
|||
|
|||
VkGraphicsPipelineCreateInfo pipelineInfo{}; |
|||
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; |
|||
pipelineInfo.stageCount = 2; |
|||
pipelineInfo.pStages = shaderStages; |
|||
pipelineInfo.pVertexInputState = &vertexInputInfo; |
|||
pipelineInfo.pInputAssemblyState = &inputAssembly; |
|||
pipelineInfo.pViewportState = &viewportState; |
|||
pipelineInfo.pRasterizationState = &rasterizer; |
|||
pipelineInfo.pMultisampleState = &multisampling; |
|||
pipelineInfo.pColorBlendState = &colorBlending; |
|||
pipelineInfo.pDynamicState = &dynamicState; |
|||
pipelineInfo.layout = pipelineLayout; |
|||
pipelineInfo.renderPass = renderPass; |
|||
pipelineInfo.subpass = 0; |
|||
pipelineInfo.basePipelineHandle = VK_NULL_HANDLE; |
|||
|
|||
if (vkCreateGraphicsPipelines( |
|||
device, |
|||
VK_NULL_HANDLE, |
|||
1, |
|||
&pipelineInfo, |
|||
nullptr, |
|||
&graphicsPipeline) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create graphics pipeline!"); |
|||
} |
|||
|
|||
vkDestroyShaderModule(device, fragShaderModule, nullptr); |
|||
vkDestroyShaderModule(device, vertShaderModule, nullptr); |
|||
} |
|||
|
|||
VkShaderModule createShaderModule(const std::vector<char>& code) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
VkShaderModuleCreateInfo createInfo{}; |
|||
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; |
|||
createInfo.codeSize = code.size(); |
|||
createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data()); |
|||
|
|||
VkShaderModule shaderModule; |
|||
if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create shader module!"); |
|||
} |
|||
|
|||
return shaderModule; |
|||
} |
|||
|
|||
void createRenderPass() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
VkAttachmentDescription colorAttachment{}; |
|||
colorAttachment.format = swapChainImageFormat; |
|||
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT; |
|||
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; |
|||
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; |
|||
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; |
|||
colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; |
|||
colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; |
|||
colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; |
|||
|
|||
VkAttachmentReference colorAttachmentRef{}; |
|||
colorAttachmentRef.attachment = 0; |
|||
colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; |
|||
|
|||
VkSubpassDescription subpass{}; |
|||
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; |
|||
subpass.colorAttachmentCount = 1; |
|||
subpass.pColorAttachments = &colorAttachmentRef; |
|||
|
|||
VkSubpassDependency dependency{}; |
|||
dependency.srcSubpass = VK_SUBPASS_EXTERNAL; |
|||
dependency.dstSubpass = 0; |
|||
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; |
|||
dependency.srcAccessMask = 0; |
|||
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; |
|||
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; |
|||
|
|||
VkRenderPassCreateInfo renderPassInfo{}; |
|||
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; |
|||
renderPassInfo.attachmentCount = 1; |
|||
renderPassInfo.pAttachments = &colorAttachment; |
|||
renderPassInfo.subpassCount = 1; |
|||
renderPassInfo.pSubpasses = &subpass; |
|||
renderPassInfo.dependencyCount = 1; |
|||
renderPassInfo.pDependencies = &dependency; |
|||
|
|||
if (vkCreateRenderPass(device, &renderPassInfo, nullptr, &renderPass) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create render pass!"); |
|||
} |
|||
} |
|||
|
|||
void createFramebuffers() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
swapChainFramebuffers.resize(swapChainImageViews.size()); |
|||
|
|||
for (size_t i = 0; i < swapChainImageViews.size(); i++) { |
|||
VkImageView attachments[] = { swapChainImageViews[i] }; |
|||
|
|||
VkFramebufferCreateInfo framebufferInfo{}; |
|||
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; |
|||
framebufferInfo.renderPass = renderPass; |
|||
framebufferInfo.attachmentCount = 1; |
|||
framebufferInfo.pAttachments = attachments; |
|||
framebufferInfo.width = swapChainExtent.width; |
|||
framebufferInfo.height = swapChainExtent.height; |
|||
framebufferInfo.layers = 1; |
|||
|
|||
if (vkCreateFramebuffer(device, &framebufferInfo, nullptr, &swapChainFramebuffers[i]) != |
|||
VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create framebuffer!"); |
|||
} |
|||
} |
|||
} |
|||
|
|||
void createCommandPool() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
QueueFamilyIndices queueFamilyIndices = findQueueFamilies(physicalDevice); |
|||
|
|||
VkCommandPoolCreateInfo poolInfo{}; |
|||
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; |
|||
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; |
|||
poolInfo.queueFamilyIndex = queueFamilyIndices.graphicsFamily.value(); |
|||
|
|||
if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create command pool!"); |
|||
} |
|||
} |
|||
|
|||
void createCommandBuffer() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
VkCommandBufferAllocateInfo allocInfo{}; |
|||
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; |
|||
allocInfo.commandPool = commandPool; |
|||
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; |
|||
allocInfo.commandBufferCount = 1; |
|||
|
|||
if (vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to allocate command buffers!"); |
|||
} |
|||
} |
|||
|
|||
void recordCommandBuffer(VkCommandBuffer commandBuffer, uint32_t imageIndex) |
|||
{ |
|||
VkCommandBufferBeginInfo beginInfo{}; |
|||
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; |
|||
|
|||
if (vkBeginCommandBuffer(commandBuffer, &beginInfo) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to begin recording command buffer!"); |
|||
} |
|||
|
|||
VkRenderPassBeginInfo renderPassInfo{}; |
|||
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; |
|||
renderPassInfo.renderPass = renderPass; |
|||
renderPassInfo.framebuffer = swapChainFramebuffers[imageIndex]; |
|||
renderPassInfo.renderArea.offset = { 0, 0 }; |
|||
renderPassInfo.renderArea.extent = swapChainExtent; |
|||
|
|||
VkClearValue clearColor = { { { 0.0f, 0.0f, 0.0f, 1.0f } } }; |
|||
renderPassInfo.clearValueCount = 1; |
|||
renderPassInfo.pClearValues = &clearColor; |
|||
|
|||
vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE); |
|||
|
|||
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline); |
|||
|
|||
VkViewport viewport{}; |
|||
viewport.x = 0.0f; |
|||
viewport.y = 0.0f; |
|||
viewport.width = static_cast<float>(swapChainExtent.width); |
|||
viewport.height = static_cast<float>(swapChainExtent.height); |
|||
viewport.minDepth = 0.0f; |
|||
viewport.maxDepth = 1.0f; |
|||
vkCmdSetViewport(commandBuffer, 0, 1, &viewport); |
|||
|
|||
VkRect2D scissor{}; |
|||
scissor.offset = { 0, 0 }; |
|||
scissor.extent = swapChainExtent; |
|||
vkCmdSetScissor(commandBuffer, 0, 1, &scissor); |
|||
|
|||
vkCmdDraw(commandBuffer, 3, 1, 0, 0); |
|||
|
|||
vkCmdEndRenderPass(commandBuffer); |
|||
|
|||
if (vkEndCommandBuffer(commandBuffer) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to record command buffer!"); |
|||
} |
|||
} |
|||
|
|||
void createSyncObjects() |
|||
{ |
|||
VkSemaphoreCreateInfo semaphoreInfo{}; |
|||
semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; |
|||
|
|||
VkFenceCreateInfo fenceInfo{}; |
|||
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; |
|||
fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; |
|||
|
|||
if (vkCreateSemaphore(device, &semaphoreInfo, nullptr, &imageAvailableSemaphore) != |
|||
VK_SUCCESS || |
|||
vkCreateSemaphore(device, &semaphoreInfo, nullptr, &renderFinishedSemaphore) != |
|||
VK_SUCCESS || |
|||
vkCreateFence(device, &fenceInfo, nullptr, &inFlightFence) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to create synchronization objects for a frame!"); |
|||
} |
|||
} |
|||
|
|||
void drawFrame() |
|||
{ |
|||
vkWaitForFences(device, 1, &inFlightFence, VK_TRUE, UINT64_MAX); |
|||
vkResetFences(device, 1, &inFlightFence); |
|||
|
|||
uint32_t imageIndex; |
|||
vkAcquireNextImageKHR( |
|||
device, |
|||
swapChain, |
|||
UINT64_MAX, |
|||
imageAvailableSemaphore, |
|||
VK_NULL_HANDLE, |
|||
&imageIndex); |
|||
|
|||
vkResetCommandBuffer(commandBuffer, /*VkCommandBufferResetFlagBits*/ 0); |
|||
recordCommandBuffer(commandBuffer, imageIndex); |
|||
|
|||
VkSubmitInfo submitInfo{}; |
|||
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; |
|||
|
|||
VkSemaphore waitSemaphores[] = { imageAvailableSemaphore }; |
|||
VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT }; |
|||
submitInfo.waitSemaphoreCount = 1; |
|||
submitInfo.pWaitSemaphores = waitSemaphores; |
|||
submitInfo.pWaitDstStageMask = waitStages; |
|||
|
|||
submitInfo.commandBufferCount = 1; |
|||
submitInfo.pCommandBuffers = &commandBuffer; |
|||
|
|||
VkSemaphore signalSemaphores[] = { renderFinishedSemaphore }; |
|||
submitInfo.signalSemaphoreCount = 1; |
|||
submitInfo.pSignalSemaphores = signalSemaphores; |
|||
|
|||
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFence) != VK_SUCCESS) { |
|||
throw std::runtime_error("failed to submit draw command buffer!"); |
|||
} |
|||
|
|||
VkPresentInfoKHR presentInfo{}; |
|||
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; |
|||
|
|||
presentInfo.waitSemaphoreCount = 1; |
|||
presentInfo.pWaitSemaphores = signalSemaphores; |
|||
|
|||
VkSwapchainKHR swapChains[] = { swapChain }; |
|||
presentInfo.swapchainCount = 1; |
|||
presentInfo.pSwapchains = swapChains; |
|||
|
|||
presentInfo.pImageIndices = &imageIndex; |
|||
|
|||
vkQueuePresentKHR(presentQueue, &presentInfo); |
|||
} |
|||
|
|||
void cleanup() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
vkDestroySemaphore(device, renderFinishedSemaphore, nullptr); |
|||
vkDestroySemaphore(device, imageAvailableSemaphore, nullptr); |
|||
vkDestroyFence(device, inFlightFence, nullptr); |
|||
|
|||
vkDestroyCommandPool(device, commandPool, nullptr); |
|||
|
|||
for (auto framebuffer : swapChainFramebuffers) { |
|||
vkDestroyFramebuffer(device, framebuffer, nullptr); |
|||
} |
|||
|
|||
vkDestroyPipeline(device, graphicsPipeline, nullptr); |
|||
vkDestroyRenderPass(device, renderPass, nullptr); |
|||
vkDestroyPipelineLayout(device, pipelineLayout, nullptr); |
|||
|
|||
for (auto imageView : swapChainImageViews) { |
|||
vkDestroyImageView(device, imageView, nullptr); |
|||
} |
|||
|
|||
vkDestroySwapchainKHR(device, swapChain, nullptr); |
|||
vkDestroyDevice(device, nullptr); |
|||
vkDestroySurfaceKHR(instance, surface, nullptr); |
|||
vkDestroyInstance(instance, nullptr); |
|||
} |
|||
|
|||
static std::vector<char> readFile(const std::string& filename) |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
std::ifstream file(filename, std::ios::ate | std::ios::binary); |
|||
if (!file.is_open()) { |
|||
throw std::runtime_error("failed to open file!"); |
|||
} |
|||
size_t fileSize = (size_t)file.tellg(); |
|||
std::vector<char> buffer(fileSize); |
|||
file.seekg(0); |
|||
file.read(buffer.data(), fileSize); |
|||
file.close(); |
|||
|
|||
return buffer; |
|||
} |
|||
}; |
|||
} // namespace Heck
|
|||
|
|||
|
|||
GLFWwindow* window = nullptr; |
|||
Heck::VulkanBase *vb = nullptr; |
|||
|
|||
void init() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
glfwInit(); |
|||
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); |
|||
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); |
|||
window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr); |
|||
} |
|||
|
|||
void mainLoop() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
while (!glfwWindowShouldClose(window)) { |
|||
glfwPollEvents(); |
|||
vb->drawFrame(); |
|||
|
|||
} |
|||
}; |
|||
|
|||
vkDeviceWaitIdle(vb->device); |
|||
} |
|||
|
|||
void cleanup() |
|||
{ |
|||
HECK_LOG_INFO(""); |
|||
glfwDestroyWindow(window); |
|||
glfwTerminate(); |
|||
} |
|||
|
|||
int main() |
|||
{ |
|||
HelloTriangleApplication app{}; |
|||
Heck::Log::set_level(HECK_LOG_LEVEL_ALL); |
|||
init(); |
|||
|
|||
try { |
|||
app.run(); |
|||
} catch (const std::exception& e) { |
|||
std::cerr << e.what() << std::endl; |
|||
return EXIT_FAILURE; |
|||
} |
|||
Heck::VulkanBase _vb{ *window }; |
|||
vb = &_vb; |
|||
vb->init(); |
|||
|
|||
mainLoop(); |
|||
|
|||
vb->cleanup(); |
|||
cleanup(); |
|||
|
|||
return EXIT_SUCCESS; |
|||
} |
|||
|
Loading…
Reference in new issue