From f92cbc55018b5a3d98dd2093354f20c62ace5fda Mon Sep 17 00:00:00 2001
From: ReinUsesLisp <reinuseslisp@airmail.cc>
Date: Tue, 21 Jan 2020 16:40:53 -0300
Subject: [PATCH] yuzu: Implement Vulkan frontend

Adds a Qt and SDL2 frontend for Vulkan. It also finishes the missing
bits on Vulkan initialization.
---
 src/core/frontend/emu_window.h                |   7 +
 src/video_core/CMakeLists.txt                 |   1 +
 .../renderer_vulkan/renderer_vulkan.cpp       | 265 ++++++++++++++++
 src/video_core/video_core.cpp                 |  15 +-
 src/yuzu/CMakeLists.txt                       |   5 +
 src/yuzu/bootmanager.cpp                      | 282 ++++++++++++++----
 src/yuzu/bootmanager.h                        |  30 +-
 src/yuzu/configuration/configure_debug.cpp    |   3 +
 src/yuzu/configuration/configure_debug.ui     | 142 ++++++---
 src/yuzu/configuration/configure_graphics.cpp |  94 ++++++
 src/yuzu/configuration/configure_graphics.h   |  12 +
 src/yuzu/configuration/configure_graphics.ui  |  78 ++++-
 src/yuzu/main.cpp                             |  88 +-----
 src/yuzu/main.h                               |   1 -
 src/yuzu_cmd/CMakeLists.txt                   |  11 +
 src/yuzu_cmd/emu_window/emu_window_sdl2.cpp   |   4 +
 src/yuzu_cmd/emu_window/emu_window_sdl2.h     |   3 +
 .../emu_window/emu_window_sdl2_gl.cpp         |   7 +
 src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h  |   4 +
 .../emu_window/emu_window_sdl2_vk.cpp         | 161 ++++++++++
 src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h  |  39 +++
 src/yuzu_cmd/yuzu.cpp                         |  18 +-
 .../emu_window/emu_window_sdl2_hide.cpp       |  15 +-
 .../emu_window/emu_window_sdl2_hide.h         |   7 +
 24 files changed, 1105 insertions(+), 187 deletions(-)
 create mode 100644 src/video_core/renderer_vulkan/renderer_vulkan.cpp
 create mode 100644 src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp
 create mode 100644 src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h

diff --git a/src/core/frontend/emu_window.h b/src/core/frontend/emu_window.h
index 4a9912641b..3376eedc58 100644
--- a/src/core/frontend/emu_window.h
+++ b/src/core/frontend/emu_window.h
@@ -75,6 +75,13 @@ public:
         return nullptr;
     }
 
+    /// Returns if window is shown (not minimized)
+    virtual bool IsShown() const = 0;
+
+    /// Retrieves Vulkan specific handlers from the window
+    virtual void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                        void* surface) const = 0;
+
     /**
      * Signal that a touch pressed event has occurred (e.g. mouse click pressed)
      * @param framebuffer_x Framebuffer x-coordinate that was pressed
diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt
index ccfed4f2e2..8218b7cd2e 100644
--- a/src/video_core/CMakeLists.txt
+++ b/src/video_core/CMakeLists.txt
@@ -154,6 +154,7 @@ if (ENABLE_VULKAN)
         renderer_vulkan/maxwell_to_vk.cpp
         renderer_vulkan/maxwell_to_vk.h
         renderer_vulkan/renderer_vulkan.h
+        renderer_vulkan/renderer_vulkan.cpp
         renderer_vulkan/vk_blit_screen.cpp
         renderer_vulkan/vk_blit_screen.h
         renderer_vulkan/vk_buffer_cache.cpp
diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp
new file mode 100644
index 0000000000..d5032b4328
--- /dev/null
+++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp
@@ -0,0 +1,265 @@
+// Copyright 2018 yuzu Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include <memory>
+#include <optional>
+#include <vector>
+
+#include <fmt/format.h>
+
+#include "common/assert.h"
+#include "common/logging/log.h"
+#include "common/telemetry.h"
+#include "core/core.h"
+#include "core/core_timing.h"
+#include "core/frontend/emu_window.h"
+#include "core/memory.h"
+#include "core/perf_stats.h"
+#include "core/settings.h"
+#include "core/telemetry_session.h"
+#include "video_core/gpu.h"
+#include "video_core/renderer_vulkan/declarations.h"
+#include "video_core/renderer_vulkan/renderer_vulkan.h"
+#include "video_core/renderer_vulkan/vk_blit_screen.h"
+#include "video_core/renderer_vulkan/vk_device.h"
+#include "video_core/renderer_vulkan/vk_memory_manager.h"
+#include "video_core/renderer_vulkan/vk_rasterizer.h"
+#include "video_core/renderer_vulkan/vk_resource_manager.h"
+#include "video_core/renderer_vulkan/vk_scheduler.h"
+#include "video_core/renderer_vulkan/vk_swapchain.h"
+
+namespace Vulkan {
+
+namespace {
+
+VkBool32 DebugCallback(VkDebugUtilsMessageSeverityFlagBitsEXT severity_,
+                       VkDebugUtilsMessageTypeFlagsEXT type,
+                       const VkDebugUtilsMessengerCallbackDataEXT* data,
+                       [[maybe_unused]] void* user_data) {
+    const vk::DebugUtilsMessageSeverityFlagBitsEXT severity{severity_};
+    const char* message{data->pMessage};
+
+    if (severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eError) {
+        LOG_CRITICAL(Render_Vulkan, "{}", message);
+    } else if (severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eWarning) {
+        LOG_WARNING(Render_Vulkan, "{}", message);
+    } else if (severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eInfo) {
+        LOG_INFO(Render_Vulkan, "{}", message);
+    } else if (severity & vk::DebugUtilsMessageSeverityFlagBitsEXT::eVerbose) {
+        LOG_DEBUG(Render_Vulkan, "{}", message);
+    }
+    return VK_FALSE;
+}
+
+std::string GetReadableVersion(u32 version) {
+    return fmt::format("{}.{}.{}", VK_VERSION_MAJOR(version), VK_VERSION_MINOR(version),
+                       VK_VERSION_PATCH(version));
+}
+
+std::string GetDriverVersion(const VKDevice& device) {
+    // Extracted from
+    // https://github.com/SaschaWillems/vulkan.gpuinfo.org/blob/5dddea46ea1120b0df14eef8f15ff8e318e35462/functions.php#L308-L314
+    const u32 version = device.GetDriverVersion();
+
+    if (device.GetDriverID() == vk::DriverIdKHR::eNvidiaProprietary) {
+        const u32 major = (version >> 22) & 0x3ff;
+        const u32 minor = (version >> 14) & 0x0ff;
+        const u32 secondary = (version >> 6) & 0x0ff;
+        const u32 tertiary = version & 0x003f;
+        return fmt::format("{}.{}.{}.{}", major, minor, secondary, tertiary);
+    }
+    if (device.GetDriverID() == vk::DriverIdKHR::eIntelProprietaryWindows) {
+        const u32 major = version >> 14;
+        const u32 minor = version & 0x3fff;
+        return fmt::format("{}.{}", major, minor);
+    }
+
+    return GetReadableVersion(version);
+}
+
+std::string BuildCommaSeparatedExtensions(std::vector<std::string> available_extensions) {
+    std::sort(std::begin(available_extensions), std::end(available_extensions));
+
+    static constexpr std::size_t AverageExtensionSize = 64;
+    std::string separated_extensions;
+    separated_extensions.reserve(available_extensions.size() * AverageExtensionSize);
+
+    const auto end = std::end(available_extensions);
+    for (auto extension = std::begin(available_extensions); extension != end; ++extension) {
+        if (const bool is_last = extension + 1 == end; is_last) {
+            separated_extensions += *extension;
+        } else {
+            separated_extensions += fmt::format("{},", *extension);
+        }
+    }
+    return separated_extensions;
+}
+
+} // Anonymous namespace
+
+RendererVulkan::RendererVulkan(Core::Frontend::EmuWindow& window, Core::System& system)
+    : RendererBase(window), system{system} {}
+
+RendererVulkan::~RendererVulkan() {
+    ShutDown();
+}
+
+void RendererVulkan::SwapBuffers(const Tegra::FramebufferConfig* framebuffer) {
+    const auto& layout = render_window.GetFramebufferLayout();
+    if (framebuffer && layout.width > 0 && layout.height > 0 && render_window.IsShown()) {
+        const VAddr framebuffer_addr = framebuffer->address + framebuffer->offset;
+        const bool use_accelerated =
+            rasterizer->AccelerateDisplay(*framebuffer, framebuffer_addr, framebuffer->stride);
+        const bool is_srgb = use_accelerated && screen_info.is_srgb;
+        if (swapchain->HasFramebufferChanged(layout) || swapchain->GetSrgbState() != is_srgb) {
+            swapchain->Create(layout.width, layout.height, is_srgb);
+            blit_screen->Recreate();
+        }
+
+        scheduler->WaitWorker();
+
+        swapchain->AcquireNextImage();
+        const auto [fence, render_semaphore] = blit_screen->Draw(*framebuffer, use_accelerated);
+
+        scheduler->Flush(false, render_semaphore);
+
+        if (swapchain->Present(render_semaphore, fence)) {
+            blit_screen->Recreate();
+        }
+
+        render_window.SwapBuffers();
+        rasterizer->TickFrame();
+    }
+
+    render_window.PollEvents();
+}
+
+bool RendererVulkan::Init() {
+    PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr{};
+    render_window.RetrieveVulkanHandlers(&vkGetInstanceProcAddr, &instance, &surface);
+    const vk::DispatchLoaderDynamic dldi(instance, vkGetInstanceProcAddr);
+
+    std::optional<vk::DebugUtilsMessengerEXT> callback;
+    if (Settings::values.renderer_debug && dldi.vkCreateDebugUtilsMessengerEXT) {
+        callback = CreateDebugCallback(dldi);
+        if (!callback) {
+            return false;
+        }
+    }
+
+    if (!PickDevices(dldi)) {
+        if (callback) {
+            instance.destroy(*callback, nullptr, dldi);
+        }
+        return false;
+    }
+    debug_callback = UniqueDebugUtilsMessengerEXT(
+        *callback, vk::ObjectDestroy<vk::Instance, vk::DispatchLoaderDynamic>(
+                       instance, nullptr, device->GetDispatchLoader()));
+
+    Report();
+
+    memory_manager = std::make_unique<VKMemoryManager>(*device);
+
+    resource_manager = std::make_unique<VKResourceManager>(*device);
+
+    const auto& framebuffer = render_window.GetFramebufferLayout();
+    swapchain = std::make_unique<VKSwapchain>(surface, *device);
+    swapchain->Create(framebuffer.width, framebuffer.height, false);
+
+    scheduler = std::make_unique<VKScheduler>(*device, *resource_manager);
+
+    rasterizer = std::make_unique<RasterizerVulkan>(system, render_window, screen_info, *device,
+                                                    *resource_manager, *memory_manager, *scheduler);
+
+    blit_screen = std::make_unique<VKBlitScreen>(system, render_window, *rasterizer, *device,
+                                                 *resource_manager, *memory_manager, *swapchain,
+                                                 *scheduler, screen_info);
+
+    return true;
+}
+
+void RendererVulkan::ShutDown() {
+    if (!device) {
+        return;
+    }
+    const auto dev = device->GetLogical();
+    const auto& dld = device->GetDispatchLoader();
+    if (dev && dld.vkDeviceWaitIdle) {
+        dev.waitIdle(dld);
+    }
+
+    rasterizer.reset();
+    blit_screen.reset();
+    scheduler.reset();
+    swapchain.reset();
+    memory_manager.reset();
+    resource_manager.reset();
+    device.reset();
+}
+
+std::optional<vk::DebugUtilsMessengerEXT> RendererVulkan::CreateDebugCallback(
+    const vk::DispatchLoaderDynamic& dldi) {
+    const vk::DebugUtilsMessengerCreateInfoEXT callback_ci(
+        {},
+        vk::DebugUtilsMessageSeverityFlagBitsEXT::eError |
+            vk::DebugUtilsMessageSeverityFlagBitsEXT::eWarning |
+            vk::DebugUtilsMessageSeverityFlagBitsEXT::eInfo |
+            vk::DebugUtilsMessageSeverityFlagBitsEXT::eVerbose,
+        vk::DebugUtilsMessageTypeFlagBitsEXT::eGeneral |
+            vk::DebugUtilsMessageTypeFlagBitsEXT::eValidation |
+            vk::DebugUtilsMessageTypeFlagBitsEXT::ePerformance,
+        &DebugCallback, nullptr);
+    vk::DebugUtilsMessengerEXT callback;
+    if (instance.createDebugUtilsMessengerEXT(&callback_ci, nullptr, &callback, dldi) !=
+        vk::Result::eSuccess) {
+        LOG_ERROR(Render_Vulkan, "Failed to create debug callback");
+        return {};
+    }
+    return callback;
+}
+
+bool RendererVulkan::PickDevices(const vk::DispatchLoaderDynamic& dldi) {
+    const auto devices = instance.enumeratePhysicalDevices(dldi);
+
+    // TODO(Rodrigo): Choose device from config file
+    const s32 device_index = Settings::values.vulkan_device;
+    if (device_index < 0 || device_index >= static_cast<s32>(devices.size())) {
+        LOG_ERROR(Render_Vulkan, "Invalid device index {}!", device_index);
+        return false;
+    }
+    const vk::PhysicalDevice physical_device = devices[device_index];
+
+    if (!VKDevice::IsSuitable(dldi, physical_device, surface)) {
+        return false;
+    }
+
+    device = std::make_unique<VKDevice>(dldi, physical_device, surface);
+    return device->Create(dldi, instance);
+}
+
+void RendererVulkan::Report() const {
+    const std::string vendor_name{device->GetVendorName()};
+    const std::string model_name{device->GetModelName()};
+    const std::string driver_version = GetDriverVersion(*device);
+    const std::string driver_name = fmt::format("{} {}", vendor_name, driver_version);
+
+    const std::string api_version = GetReadableVersion(device->GetApiVersion());
+
+    const std::string extensions = BuildCommaSeparatedExtensions(device->GetAvailableExtensions());
+
+    LOG_INFO(Render_Vulkan, "Driver: {}", driver_name);
+    LOG_INFO(Render_Vulkan, "Device: {}", model_name);
+    LOG_INFO(Render_Vulkan, "Vulkan: {}", api_version);
+
+    auto& telemetry_session = system.TelemetrySession();
+    constexpr auto field = Telemetry::FieldType::UserSystem;
+    telemetry_session.AddField(field, "GPU_Vendor", vendor_name);
+    telemetry_session.AddField(field, "GPU_Model", model_name);
+    telemetry_session.AddField(field, "GPU_Vulkan_Driver", driver_name);
+    telemetry_session.AddField(field, "GPU_Vulkan_Version", api_version);
+    telemetry_session.AddField(field, "GPU_Vulkan_Extensions", extensions);
+}
+
+} // namespace Vulkan
\ No newline at end of file
diff --git a/src/video_core/video_core.cpp b/src/video_core/video_core.cpp
index 8e947394c9..a5f81a8a0c 100644
--- a/src/video_core/video_core.cpp
+++ b/src/video_core/video_core.cpp
@@ -3,19 +3,32 @@
 // Refer to the license.txt file included.
 
 #include <memory>
+#include "common/logging/log.h"
 #include "core/core.h"
 #include "core/settings.h"
 #include "video_core/gpu_asynch.h"
 #include "video_core/gpu_synch.h"
 #include "video_core/renderer_base.h"
 #include "video_core/renderer_opengl/renderer_opengl.h"
+#ifdef HAS_VULKAN
+#include "video_core/renderer_vulkan/renderer_vulkan.h"
+#endif
 #include "video_core/video_core.h"
 
 namespace VideoCore {
 
 std::unique_ptr<RendererBase> CreateRenderer(Core::Frontend::EmuWindow& emu_window,
                                              Core::System& system) {
-    return std::make_unique<OpenGL::RendererOpenGL>(emu_window, system);
+    switch (Settings::values.renderer_backend) {
+    case Settings::RendererBackend::OpenGL:
+        return std::make_unique<OpenGL::RendererOpenGL>(emu_window, system);
+#ifdef HAS_VULKAN
+    case Settings::RendererBackend::Vulkan:
+        return std::make_unique<Vulkan::RendererVulkan>(emu_window, system);
+#endif
+    default:
+        return nullptr;
+    }
 }
 
 std::unique_ptr<Tegra::GPU> CreateGPU(Core::System& system) {
diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt
index a3fb91d295..b841e63fa4 100644
--- a/src/yuzu/CMakeLists.txt
+++ b/src/yuzu/CMakeLists.txt
@@ -200,3 +200,8 @@ if (MSVC)
     copy_yuzu_SDL_deps(yuzu)
     copy_yuzu_unicorn_deps(yuzu)
 endif()
+
+if (ENABLE_VULKAN)
+    target_include_directories(yuzu PRIVATE ../../externals/Vulkan-Headers/include)
+    target_compile_definitions(yuzu PRIVATE HAS_VULKAN)
+endif()
diff --git a/src/yuzu/bootmanager.cpp b/src/yuzu/bootmanager.cpp
index 7490fb718b..67ca590355 100644
--- a/src/yuzu/bootmanager.cpp
+++ b/src/yuzu/bootmanager.cpp
@@ -2,19 +2,30 @@
 // Licensed under GPLv2 or any later version
 // Refer to the license.txt file included.
 
+#include <glad/glad.h>
+
 #include <QApplication>
 #include <QHBoxLayout>
 #include <QKeyEvent>
+#include <QMessageBox>
 #include <QOffscreenSurface>
 #include <QOpenGLWindow>
 #include <QPainter>
 #include <QScreen>
+#include <QStringList>
 #include <QWindow>
+#ifdef HAS_VULKAN
+#include <QVulkanWindow>
+#endif
+
 #include <fmt/format.h>
+
+#include "common/assert.h"
 #include "common/microprofile.h"
 #include "common/scm_rev.h"
 #include "core/core.h"
 #include "core/frontend/framebuffer_layout.h"
+#include "core/frontend/scope_acquire_window_context.h"
 #include "core/settings.h"
 #include "input_common/keyboard.h"
 #include "input_common/main.h"
@@ -114,19 +125,10 @@ private:
     QOpenGLContext context;
 };
 
-// This class overrides paintEvent and resizeEvent to prevent the GUI thread from stealing GL
-// context.
-// The corresponding functionality is handled in EmuThread instead
-class GGLWidgetInternal : public QOpenGLWindow {
+class GWidgetInternal : public QWindow {
 public:
-    GGLWidgetInternal(GRenderWindow* parent, QOpenGLContext* shared_context)
-        : QOpenGLWindow(shared_context), parent(parent) {}
-
-    void paintEvent(QPaintEvent* ev) override {
-        if (do_painting) {
-            QPainter painter(this);
-        }
-    }
+    GWidgetInternal(GRenderWindow* parent) : parent(parent) {}
+    virtual ~GWidgetInternal() = default;
 
     void resizeEvent(QResizeEvent* ev) override {
         parent->OnClientAreaResized(ev->size().width(), ev->size().height());
@@ -182,9 +184,43 @@ public:
         do_painting = true;
     }
 
+    std::pair<unsigned, unsigned> GetSize() const {
+        return std::make_pair(width(), height());
+    }
+
+protected:
+    bool IsPaintingEnabled() const {
+        return do_painting;
+    }
+
 private:
     GRenderWindow* parent;
-    bool do_painting;
+    bool do_painting = false;
+};
+
+// This class overrides paintEvent and resizeEvent to prevent the GUI thread from stealing GL
+// context.
+// The corresponding functionality is handled in EmuThread instead
+class GGLWidgetInternal final : public GWidgetInternal, public QOpenGLWindow {
+public:
+    GGLWidgetInternal(GRenderWindow* parent, QOpenGLContext* shared_context)
+        : GWidgetInternal(parent), QOpenGLWindow(shared_context) {}
+    ~GGLWidgetInternal() override = default;
+
+    void paintEvent(QPaintEvent* ev) override {
+        if (IsPaintingEnabled()) {
+            QPainter painter(this);
+        }
+    }
+};
+
+class GVKWidgetInternal final : public GWidgetInternal {
+public:
+    GVKWidgetInternal(GRenderWindow* parent, QVulkanInstance* instance) : GWidgetInternal(parent) {
+        setSurfaceType(QSurface::SurfaceType::VulkanSurface);
+        setVulkanInstance(instance);
+    }
+    ~GVKWidgetInternal() override = default;
 };
 
 GRenderWindow::GRenderWindow(GMainWindow* parent, EmuThread* emu_thread)
@@ -201,9 +237,15 @@ GRenderWindow::GRenderWindow(GMainWindow* parent, EmuThread* emu_thread)
 
 GRenderWindow::~GRenderWindow() {
     InputCommon::Shutdown();
+
+    // Avoid an unordered destruction that generates a segfault
+    delete child;
 }
 
 void GRenderWindow::moveContext() {
+    if (!context) {
+        return;
+    }
     DoneCurrent();
 
     // If the thread started running, move the GL Context to the new thread. Otherwise, move it
@@ -215,8 +257,9 @@ void GRenderWindow::moveContext() {
 }
 
 void GRenderWindow::SwapBuffers() {
-    context->swapBuffers(child);
-
+    if (context) {
+        context->swapBuffers(child);
+    }
     if (!first_frame) {
         first_frame = true;
         emit FirstFrameDisplayed();
@@ -224,15 +267,38 @@ void GRenderWindow::SwapBuffers() {
 }
 
 void GRenderWindow::MakeCurrent() {
-    context->makeCurrent(child);
+    if (context) {
+        context->makeCurrent(child);
+    }
 }
 
 void GRenderWindow::DoneCurrent() {
-    context->doneCurrent();
+    if (context) {
+        context->doneCurrent();
+    }
 }
 
 void GRenderWindow::PollEvents() {}
 
+bool GRenderWindow::IsShown() const {
+    return !isMinimized();
+}
+
+void GRenderWindow::RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                           void* surface) const {
+#ifdef HAS_VULKAN
+    const auto instance_proc_addr = vk_instance->getInstanceProcAddr("vkGetInstanceProcAddr");
+    const VkInstance instance_copy = vk_instance->vkInstance();
+    const VkSurfaceKHR surface_copy = vk_instance->surfaceForWindow(child);
+
+    std::memcpy(get_instance_proc_addr, &instance_proc_addr, sizeof(instance_proc_addr));
+    std::memcpy(instance, &instance_copy, sizeof(instance_copy));
+    std::memcpy(surface, &surface_copy, sizeof(surface_copy));
+#else
+    UNREACHABLE_MSG("Executing Vulkan code without compiling Vulkan");
+#endif
+}
+
 // On Qt 5.0+, this correctly gets the size of the framebuffer (pixels).
 //
 // Older versions get the window size (density independent pixels),
@@ -241,10 +307,9 @@ void GRenderWindow::PollEvents() {}
 void GRenderWindow::OnFramebufferSizeChanged() {
     // Screen changes potentially incur a change in screen DPI, hence we should update the
     // framebuffer size
-    const qreal pixel_ratio = GetWindowPixelRatio();
-    const u32 width = child->QPaintDevice::width() * pixel_ratio;
-    const u32 height = child->QPaintDevice::height() * pixel_ratio;
-    UpdateCurrentFramebufferLayout(width, height);
+    const qreal pixelRatio{GetWindowPixelRatio()};
+    const auto size{child->GetSize()};
+    UpdateCurrentFramebufferLayout(size.first * pixelRatio, size.second * pixelRatio);
 }
 
 void GRenderWindow::ForwardKeyPressEvent(QKeyEvent* event) {
@@ -290,7 +355,7 @@ qreal GRenderWindow::GetWindowPixelRatio() const {
 }
 
 std::pair<u32, u32> GRenderWindow::ScaleTouch(const QPointF pos) const {
-    const qreal pixel_ratio = GetWindowPixelRatio();
+    const qreal pixel_ratio{GetWindowPixelRatio()};
     return {static_cast<u32>(std::max(std::round(pos.x() * pixel_ratio), qreal{0.0})),
             static_cast<u32>(std::max(std::round(pos.y() * pixel_ratio), qreal{0.0}))};
 }
@@ -356,50 +421,46 @@ std::unique_ptr<Core::Frontend::GraphicsContext> GRenderWindow::CreateSharedCont
     return std::make_unique<GGLContext>(context.get());
 }
 
-void GRenderWindow::InitRenderTarget() {
+bool GRenderWindow::InitRenderTarget() {
     shared_context.reset();
     context.reset();
-
-    delete child;
-    child = nullptr;
-
-    delete container;
-    container = nullptr;
-
-    delete layout();
+    if (child) {
+        delete child;
+    }
+    if (container) {
+        delete container;
+    }
+    if (layout()) {
+        delete layout();
+    }
 
     first_frame = false;
 
-    // TODO: One of these flags might be interesting: WA_OpaquePaintEvent, WA_NoBackground,
-    // WA_DontShowOnScreen, WA_DeleteOnClose
-    QSurfaceFormat fmt;
-    fmt.setVersion(4, 3);
-    fmt.setProfile(QSurfaceFormat::CompatibilityProfile);
-    fmt.setOption(QSurfaceFormat::FormatOption::DeprecatedFunctions);
-    // TODO: expose a setting for buffer value (ie default/single/double/triple)
-    fmt.setSwapBehavior(QSurfaceFormat::DefaultSwapBehavior);
-    shared_context = std::make_unique<QOpenGLContext>();
-    shared_context->setFormat(fmt);
-    shared_context->create();
-    context = std::make_unique<QOpenGLContext>();
-    context->setShareContext(shared_context.get());
-    context->setFormat(fmt);
-    context->create();
-    fmt.setSwapInterval(0);
+    switch (Settings::values.renderer_backend) {
+    case Settings::RendererBackend::OpenGL:
+        if (!InitializeOpenGL()) {
+            return false;
+        }
+        break;
+    case Settings::RendererBackend::Vulkan:
+        if (!InitializeVulkan()) {
+            return false;
+        }
+        break;
+    }
 
-    child = new GGLWidgetInternal(this, shared_context.get());
     container = QWidget::createWindowContainer(child, this);
-
     QBoxLayout* layout = new QHBoxLayout(this);
+
     layout->addWidget(container);
     layout->setMargin(0);
     setLayout(layout);
 
-    // Reset minimum size to avoid unwanted resizes when this function is called for a second time.
+    // Reset minimum required size to avoid resizing issues on the main window after restarting.
     setMinimumSize(1, 1);
 
-    // Show causes the window to actually be created and the OpenGL context as well, but we don't
-    // want the widget to be shown yet, so immediately hide it.
+    // Show causes the window to actually be created and the gl context as well, but we don't want
+    // the widget to be shown yet, so immediately hide it.
     show();
     hide();
 
@@ -410,9 +471,17 @@ void GRenderWindow::InitRenderTarget() {
     OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size);
 
     OnFramebufferSizeChanged();
-    NotifyClientAreaSizeChanged(std::pair<unsigned, unsigned>(child->width(), child->height()));
+    NotifyClientAreaSizeChanged(child->GetSize());
 
     BackupGeometry();
+
+    if (Settings::values.renderer_backend == Settings::RendererBackend::OpenGL) {
+        if (!LoadOpenGL()) {
+            return false;
+        }
+    }
+
+    return true;
 }
 
 void GRenderWindow::CaptureScreenshot(u32 res_scale, const QString& screenshot_path) {
@@ -441,6 +510,113 @@ void GRenderWindow::OnMinimalClientAreaChangeRequest(std::pair<u32, u32> minimal
     setMinimumSize(minimal_size.first, minimal_size.second);
 }
 
+bool GRenderWindow::InitializeOpenGL() {
+    // TODO: One of these flags might be interesting: WA_OpaquePaintEvent, WA_NoBackground,
+    // WA_DontShowOnScreen, WA_DeleteOnClose
+    QSurfaceFormat fmt;
+    fmt.setVersion(4, 3);
+    fmt.setProfile(QSurfaceFormat::CompatibilityProfile);
+    fmt.setOption(QSurfaceFormat::FormatOption::DeprecatedFunctions);
+    // TODO: expose a setting for buffer value (ie default/single/double/triple)
+    fmt.setSwapBehavior(QSurfaceFormat::DefaultSwapBehavior);
+    shared_context = std::make_unique<QOpenGLContext>();
+    shared_context->setFormat(fmt);
+    shared_context->create();
+    context = std::make_unique<QOpenGLContext>();
+    context->setShareContext(shared_context.get());
+    context->setFormat(fmt);
+    context->create();
+    fmt.setSwapInterval(false);
+
+    child = new GGLWidgetInternal(this, shared_context.get());
+    return true;
+}
+
+bool GRenderWindow::InitializeVulkan() {
+#ifdef HAS_VULKAN
+    vk_instance = std::make_unique<QVulkanInstance>();
+    vk_instance->setApiVersion(QVersionNumber(1, 1, 0));
+    vk_instance->setFlags(QVulkanInstance::Flag::NoDebugOutputRedirect);
+    if (Settings::values.renderer_debug) {
+        const auto supported_layers{vk_instance->supportedLayers()};
+        const bool found =
+            std::find_if(supported_layers.begin(), supported_layers.end(), [](const auto& layer) {
+                constexpr const char searched_layer[] = "VK_LAYER_LUNARG_standard_validation";
+                return layer.name == searched_layer;
+            });
+        if (found) {
+            vk_instance->setLayers(QByteArrayList() << "VK_LAYER_LUNARG_standard_validation");
+            vk_instance->setExtensions(QByteArrayList() << VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
+        }
+    }
+    if (!vk_instance->create()) {
+        QMessageBox::critical(
+            this, tr("Error while initializing Vulkan 1.1!"),
+            tr("Your OS doesn't seem to support Vulkan 1.1 instances, or you do not have the "
+               "latest graphics drivers."));
+        return false;
+    }
+
+    child = new GVKWidgetInternal(this, vk_instance.get());
+    return true;
+#else
+    QMessageBox::critical(this, tr("Vulkan not available!"),
+                          tr("yuzu has not been compiled with Vulkan support."));
+    return false;
+#endif
+}
+
+bool GRenderWindow::LoadOpenGL() {
+    Core::Frontend::ScopeAcquireWindowContext acquire_context{*this};
+    if (!gladLoadGL()) {
+        QMessageBox::critical(this, tr("Error while initializing OpenGL 4.3!"),
+                              tr("Your GPU may not support OpenGL 4.3, or you do not have the "
+                                 "latest graphics driver."));
+        return false;
+    }
+
+    QStringList unsupported_gl_extensions = GetUnsupportedGLExtensions();
+    if (!unsupported_gl_extensions.empty()) {
+        QMessageBox::critical(
+            this, tr("Error while initializing OpenGL!"),
+            tr("Your GPU may not support one or more required OpenGL extensions. Please ensure you "
+               "have the latest graphics driver.<br><br>Unsupported extensions:<br>") +
+                unsupported_gl_extensions.join(QStringLiteral("<br>")));
+        return false;
+    }
+    return true;
+}
+
+QStringList GRenderWindow::GetUnsupportedGLExtensions() const {
+    QStringList unsupported_ext;
+
+    if (!GLAD_GL_ARB_buffer_storage)
+        unsupported_ext.append(QStringLiteral("ARB_buffer_storage"));
+    if (!GLAD_GL_ARB_direct_state_access)
+        unsupported_ext.append(QStringLiteral("ARB_direct_state_access"));
+    if (!GLAD_GL_ARB_vertex_type_10f_11f_11f_rev)
+        unsupported_ext.append(QStringLiteral("ARB_vertex_type_10f_11f_11f_rev"));
+    if (!GLAD_GL_ARB_texture_mirror_clamp_to_edge)
+        unsupported_ext.append(QStringLiteral("ARB_texture_mirror_clamp_to_edge"));
+    if (!GLAD_GL_ARB_multi_bind)
+        unsupported_ext.append(QStringLiteral("ARB_multi_bind"));
+    if (!GLAD_GL_ARB_clip_control)
+        unsupported_ext.append(QStringLiteral("ARB_clip_control"));
+
+    // Extensions required to support some texture formats.
+    if (!GLAD_GL_EXT_texture_compression_s3tc)
+        unsupported_ext.append(QStringLiteral("EXT_texture_compression_s3tc"));
+    if (!GLAD_GL_ARB_texture_compression_rgtc)
+        unsupported_ext.append(QStringLiteral("ARB_texture_compression_rgtc"));
+    if (!GLAD_GL_ARB_depth_buffer_float)
+        unsupported_ext.append(QStringLiteral("ARB_depth_buffer_float"));
+
+    for (const QString& ext : unsupported_ext)
+        LOG_CRITICAL(Frontend, "Unsupported GL extension: {}", ext.toStdString());
+
+    return unsupported_ext;
+}
+
 void GRenderWindow::OnEmulationStarting(EmuThread* emu_thread) {
     this->emu_thread = emu_thread;
     child->DisablePainting();
diff --git a/src/yuzu/bootmanager.h b/src/yuzu/bootmanager.h
index 2fc64895f6..71a2fa3213 100644
--- a/src/yuzu/bootmanager.h
+++ b/src/yuzu/bootmanager.h
@@ -7,17 +7,28 @@
 #include <atomic>
 #include <condition_variable>
 #include <mutex>
+
 #include <QImage>
 #include <QThread>
 #include <QWidget>
+
+#include "common/thread.h"
 #include "core/core.h"
 #include "core/frontend/emu_window.h"
 
 class QKeyEvent;
 class QScreen;
 class QTouchEvent;
+class QStringList;
+class QSurface;
+class QOpenGLContext;
+#ifdef HAS_VULKAN
+class QVulkanInstance;
+#endif
 
+class GWidgetInternal;
 class GGLWidgetInternal;
+class GVKWidgetInternal;
 class GMainWindow;
 class GRenderWindow;
 class QSurface;
@@ -123,6 +134,9 @@ public:
     void MakeCurrent() override;
     void DoneCurrent() override;
     void PollEvents() override;
+    bool IsShown() const override;
+    void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                void* surface) const override;
     std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override;
 
     void ForwardKeyPressEvent(QKeyEvent* event);
@@ -142,7 +156,7 @@ public:
 
     void OnClientAreaResized(u32 width, u32 height);
 
-    void InitRenderTarget();
+    bool InitRenderTarget();
 
     void CaptureScreenshot(u32 res_scale, const QString& screenshot_path);
 
@@ -165,10 +179,13 @@ private:
 
     void OnMinimalClientAreaChangeRequest(std::pair<u32, u32> minimal_size) override;
 
-    QWidget* container = nullptr;
-    GGLWidgetInternal* child = nullptr;
+    bool InitializeOpenGL();
+    bool InitializeVulkan();
+    bool LoadOpenGL();
+    QStringList GetUnsupportedGLExtensions() const;
 
-    QByteArray geometry;
+    QWidget* container = nullptr;
+    GWidgetInternal* child = nullptr;
 
     EmuThread* emu_thread;
     // Context that backs the GGLWidgetInternal (and will be used by core to render)
@@ -177,9 +194,14 @@ private:
     // current
     std::unique_ptr<QOpenGLContext> shared_context;
 
+#ifdef HAS_VULKAN
+    std::unique_ptr<QVulkanInstance> vk_instance;
+#endif
+
     /// Temporary storage of the screenshot taken
     QImage screenshot_image;
 
+    QByteArray geometry;
     bool first_frame = false;
 
 protected:
diff --git a/src/yuzu/configuration/configure_debug.cpp b/src/yuzu/configuration/configure_debug.cpp
index 90c1f9459d..9631059c75 100644
--- a/src/yuzu/configuration/configure_debug.cpp
+++ b/src/yuzu/configuration/configure_debug.cpp
@@ -36,6 +36,8 @@ void ConfigureDebug::SetConfiguration() {
     ui->homebrew_args_edit->setText(QString::fromStdString(Settings::values.program_args));
     ui->reporting_services->setChecked(Settings::values.reporting_services);
     ui->quest_flag->setChecked(Settings::values.quest_flag);
+    ui->enable_graphics_debugging->setEnabled(!Core::System::GetInstance().IsPoweredOn());
+    ui->enable_graphics_debugging->setChecked(Settings::values.renderer_debug);
 }
 
 void ConfigureDebug::ApplyConfiguration() {
@@ -46,6 +48,7 @@ void ConfigureDebug::ApplyConfiguration() {
     Settings::values.program_args = ui->homebrew_args_edit->text().toStdString();
     Settings::values.reporting_services = ui->reporting_services->isChecked();
     Settings::values.quest_flag = ui->quest_flag->isChecked();
+    Settings::values.renderer_debug = ui->enable_graphics_debugging->isChecked();
     Debugger::ToggleConsole();
     Log::Filter filter;
     filter.ParseFilterString(Settings::values.log_filter);
diff --git a/src/yuzu/configuration/configure_debug.ui b/src/yuzu/configuration/configure_debug.ui
index ce49569bb1..e028c4c807 100644
--- a/src/yuzu/configuration/configure_debug.ui
+++ b/src/yuzu/configuration/configure_debug.ui
@@ -7,7 +7,7 @@
     <x>0</x>
     <y>0</y>
     <width>400</width>
-    <height>474</height>
+    <height>467</height>
    </rect>
   </property>
   <property name="windowTitle">
@@ -103,44 +103,6 @@
         </item>
        </layout>
       </item>
-      <item>
-       <widget class="QCheckBox" name="reporting_services">
-        <property name="text">
-         <string>Enable Verbose Reporting Services</string>
-        </property>
-       </widget>
-      </item>
-      <item>
-       <widget class="QLabel" name="label">
-        <property name="font">
-         <font>
-          <italic>true</italic>
-         </font>
-        </property>
-        <property name="text">
-         <string>This will be reset automatically when yuzu closes.</string>
-        </property>
-        <property name="indent">
-         <number>20</number>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </widget>
-   </item>
-   <item>
-    <widget class="QGroupBox" name="groupBox_5">
-     <property name="title">
-      <string>Advanced</string>
-     </property>
-     <layout class="QVBoxLayout" name="verticalLayout">
-      <item>
-       <widget class="QCheckBox" name="quest_flag">
-        <property name="text">
-         <string>Kiosk (Quest) Mode</string>
-        </property>
-       </widget>
-      </item>
      </layout>
     </widget>
    </item>
@@ -167,6 +129,95 @@
      </layout>
     </widget>
    </item>
+   <item>
+    <widget class="QGroupBox" name="groupBox_4">
+     <property name="title">
+      <string>Graphics</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_6">
+      <item>
+       <widget class="QCheckBox" name="enable_graphics_debugging">
+        <property name="enabled">
+         <bool>true</bool>
+        </property>
+        <property name="whatsThis">
+         <string>When checked, the graphics API enters in a slower debugging mode</string>
+        </property>
+        <property name="text">
+         <string>Enable Graphics Debugging</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QGroupBox" name="groupBox_5">
+     <property name="title">
+      <string>Dump</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_6">
+      <item>
+       <widget class="QCheckBox" name="dump_decompressed_nso">
+        <property name="whatsThis">
+         <string>When checked, any NSO yuzu tries to load or patch will be copied decompressed to the yuzu/dump directory.</string>
+        </property>
+        <property name="text">
+         <string>Dump Decompressed NSOs</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QCheckBox" name="dump_exefs">
+        <property name="whatsThis">
+         <string>When checked, any game that yuzu loads will have its ExeFS dumped to the yuzu/dump directory.</string>
+        </property>
+        <property name="text">
+         <string>Dump ExeFS</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QCheckBox" name="reporting_services">
+        <property name="text">
+         <string>Enable Verbose Reporting Services</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QLabel" name="label">
+        <property name="font">
+         <font>
+          <italic>true</italic>
+         </font>
+        </property>
+        <property name="text">
+         <string>This will be reset automatically when yuzu closes.</string>
+        </property>
+        <property name="indent">
+         <number>20</number>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QGroupBox" name="groupBox_6">
+     <property name="title">
+      <string>Advanced</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_7">
+      <item>
+       <widget class="QCheckBox" name="quest_flag">
+        <property name="text">
+         <string>Kiosk (Quest) Mode</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
    <item>
     <spacer name="verticalSpacer">
      <property name="orientation">
@@ -185,6 +236,19 @@
    </item>
   </layout>
  </widget>
+ <tabstops>
+  <tabstop>toggle_gdbstub</tabstop>
+  <tabstop>gdbport_spinbox</tabstop>
+  <tabstop>log_filter_edit</tabstop>
+  <tabstop>toggle_console</tabstop>
+  <tabstop>open_log_button</tabstop>
+  <tabstop>homebrew_args_edit</tabstop>
+  <tabstop>enable_graphics_debugging</tabstop>
+  <tabstop>dump_decompressed_nso</tabstop>
+  <tabstop>dump_exefs</tabstop>
+  <tabstop>reporting_services</tabstop>
+  <tabstop>quest_flag</tabstop>
+ </tabstops>
  <resources/>
  <connections>
   <connection>
diff --git a/src/yuzu/configuration/configure_graphics.cpp b/src/yuzu/configuration/configure_graphics.cpp
index 2c9e322c94..f57a24e36d 100644
--- a/src/yuzu/configuration/configure_graphics.cpp
+++ b/src/yuzu/configuration/configure_graphics.cpp
@@ -3,6 +3,13 @@
 // Refer to the license.txt file included.
 
 #include <QColorDialog>
+#include <QComboBox>
+#ifdef HAS_VULKAN
+#include <QVulkanInstance>
+#endif
+
+#include "common/common_types.h"
+#include "common/logging/log.h"
 #include "core/core.h"
 #include "core/settings.h"
 #include "ui_configure_graphics.h"
@@ -51,10 +58,18 @@ Resolution FromResolutionFactor(float factor) {
 
 ConfigureGraphics::ConfigureGraphics(QWidget* parent)
     : QWidget(parent), ui(new Ui::ConfigureGraphics) {
+    vulkan_device = Settings::values.vulkan_device;
+    RetrieveVulkanDevices();
+
     ui->setupUi(this);
 
     SetConfiguration();
 
+    connect(ui->api, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this,
+            [this] { UpdateDeviceComboBox(); });
+    connect(ui->device, static_cast<void (QComboBox::*)(int)>(&QComboBox::activated), this,
+            [this](int device) { UpdateDeviceSelection(device); });
+
     connect(ui->bg_button, &QPushButton::clicked, this, [this] {
         const QColor new_bg_color = QColorDialog::getColor(bg_color);
         if (!new_bg_color.isValid()) {
@@ -64,11 +79,22 @@ ConfigureGraphics::ConfigureGraphics(QWidget* parent)
     });
 }
 
+void ConfigureGraphics::UpdateDeviceSelection(int device) {
+    if (device == -1) {
+        return;
+    }
+    if (GetCurrentGraphicsBackend() == Settings::RendererBackend::Vulkan) {
+        vulkan_device = device;
+    }
+}
+
 ConfigureGraphics::~ConfigureGraphics() = default;
 
 void ConfigureGraphics::SetConfiguration() {
     const bool runtime_lock = !Core::System::GetInstance().IsPoweredOn();
 
+    ui->api->setEnabled(runtime_lock);
+    ui->api->setCurrentIndex(static_cast<int>(Settings::values.renderer_backend));
     ui->resolution_factor_combobox->setCurrentIndex(
         static_cast<int>(FromResolutionFactor(Settings::values.resolution_factor)));
     ui->use_disk_shader_cache->setEnabled(runtime_lock);
@@ -80,9 +106,12 @@ void ConfigureGraphics::SetConfiguration() {
     ui->force_30fps_mode->setChecked(Settings::values.force_30fps_mode);
     UpdateBackgroundColorButton(QColor::fromRgbF(Settings::values.bg_red, Settings::values.bg_green,
                                                  Settings::values.bg_blue));
+    UpdateDeviceComboBox();
 }
 
 void ConfigureGraphics::ApplyConfiguration() {
+    Settings::values.renderer_backend = GetCurrentGraphicsBackend();
+    Settings::values.vulkan_device = vulkan_device;
     Settings::values.resolution_factor =
         ToResolutionFactor(static_cast<Resolution>(ui->resolution_factor_combobox->currentIndex()));
     Settings::values.use_disk_shader_cache = ui->use_disk_shader_cache->isChecked();
@@ -116,3 +145,68 @@ void ConfigureGraphics::UpdateBackgroundColorButton(QColor color) {
     const QIcon color_icon(pixmap);
     ui->bg_button->setIcon(color_icon);
 }
+
+void ConfigureGraphics::UpdateDeviceComboBox() {
+    ui->device->clear();
+
+    bool enabled = false;
+    switch (GetCurrentGraphicsBackend()) {
+    case Settings::RendererBackend::OpenGL:
+        ui->device->addItem(tr("OpenGL Graphics Device"));
+        enabled = false;
+        break;
+    case Settings::RendererBackend::Vulkan:
+        for (const auto device : vulkan_devices) {
+            ui->device->addItem(device);
+        }
+        ui->device->setCurrentIndex(vulkan_device);
+        enabled = !vulkan_devices.empty();
+        break;
+    }
+    ui->device->setEnabled(enabled && !Core::System::GetInstance().IsPoweredOn());
+}
+
+void ConfigureGraphics::RetrieveVulkanDevices() {
+#ifdef HAS_VULKAN
+    QVulkanInstance instance;
+    instance.setApiVersion(QVersionNumber(1, 1, 0));
+    if (!instance.create()) {
+        LOG_INFO(Frontend, "Vulkan 1.1 not available");
+        return;
+    }
+    const auto vkEnumeratePhysicalDevices{reinterpret_cast<PFN_vkEnumeratePhysicalDevices>(
+        instance.getInstanceProcAddr("vkEnumeratePhysicalDevices"))};
+    if (vkEnumeratePhysicalDevices == nullptr) {
+        LOG_INFO(Frontend, "Failed to get pointer to vkEnumeratePhysicalDevices");
+        return;
+    }
+    u32 physical_device_count;
+    if (vkEnumeratePhysicalDevices(instance.vkInstance(), &physical_device_count, nullptr) !=
+        VK_SUCCESS) {
+        LOG_INFO(Frontend, "Failed to get physical devices count");
+        return;
+    }
+    std::vector<VkPhysicalDevice> physical_devices(physical_device_count);
+    if (vkEnumeratePhysicalDevices(instance.vkInstance(), &physical_device_count,
+                                   physical_devices.data()) != VK_SUCCESS) {
+        LOG_INFO(Frontend, "Failed to get physical devices");
+        return;
+    }
+
+    const auto vkGetPhysicalDeviceProperties{reinterpret_cast<PFN_vkGetPhysicalDeviceProperties>(
+        instance.getInstanceProcAddr("vkGetPhysicalDeviceProperties"))};
+    if (vkGetPhysicalDeviceProperties == nullptr) {
+        LOG_INFO(Frontend, "Failed to get pointer to vkGetPhysicalDeviceProperties");
+        return;
+    }
+    for (const auto physical_device : physical_devices) {
+        VkPhysicalDeviceProperties properties;
+        vkGetPhysicalDeviceProperties(physical_device, &properties);
+        vulkan_devices.push_back(QString::fromUtf8(properties.deviceName));
+    }
+#endif
+}
+
+Settings::RendererBackend ConfigureGraphics::GetCurrentGraphicsBackend() const {
+    return static_cast<Settings::RendererBackend>(ui->api->currentIndex());
+}
diff --git a/src/yuzu/configuration/configure_graphics.h b/src/yuzu/configuration/configure_graphics.h
index fae28d98e5..7e0596d9ce 100644
--- a/src/yuzu/configuration/configure_graphics.h
+++ b/src/yuzu/configuration/configure_graphics.h
@@ -5,7 +5,10 @@
 #pragma once
 
 #include <memory>
+#include <vector>
+#include <QString>
 #include <QWidget>
+#include "core/settings.h"
 
 namespace Ui {
 class ConfigureGraphics;
@@ -27,7 +30,16 @@ private:
     void SetConfiguration();
 
     void UpdateBackgroundColorButton(QColor color);
+    void UpdateDeviceComboBox();
+    void UpdateDeviceSelection(int device);
+
+    void RetrieveVulkanDevices();
+
+    Settings::RendererBackend GetCurrentGraphicsBackend() const;
 
     std::unique_ptr<Ui::ConfigureGraphics> ui;
     QColor bg_color;
+
+    std::vector<QString> vulkan_devices;
+    u32 vulkan_device{};
 };
diff --git a/src/yuzu/configuration/configure_graphics.ui b/src/yuzu/configuration/configure_graphics.ui
index 0309ee3002..e24372204b 100644
--- a/src/yuzu/configuration/configure_graphics.ui
+++ b/src/yuzu/configuration/configure_graphics.ui
@@ -7,21 +7,69 @@
     <x>0</x>
     <y>0</y>
     <width>400</width>
-    <height>300</height>
+    <height>321</height>
    </rect>
   </property>
   <property name="windowTitle">
    <string>Form</string>
   </property>
-  <layout class="QVBoxLayout" name="verticalLayout">
+  <layout class="QVBoxLayout" name="verticalLayout_1">
    <item>
-    <layout class="QVBoxLayout" name="verticalLayout_3">
+    <layout class="QVBoxLayout" name="verticalLayout_2">
+     <item>
+      <widget class="QGroupBox" name="groupBox_2">
+       <property name="title">
+        <string>API Settings</string>
+       </property>
+       <layout class="QVBoxLayout" name="verticalLayout_3">
+        <item>
+         <layout class="QHBoxLayout" name="horizontalLayout_4">
+          <item>
+           <widget class="QLabel" name="label_2">
+            <property name="text">
+             <string>API:</string>
+            </property>
+           </widget>
+          </item>
+          <item>
+           <widget class="QComboBox" name="api">
+            <item>
+             <property name="text">
+              <string notr="true">OpenGL</string>
+             </property>
+            </item>
+            <item>
+             <property name="text">
+              <string notr="true">Vulkan</string>
+             </property>
+            </item>
+           </widget>
+          </item>
+         </layout>
+        </item>
+        <item>
+         <layout class="QHBoxLayout" name="horizontalLayout_5">
+          <item>
+           <widget class="QLabel" name="label_3">
+            <property name="text">
+             <string>Device:</string>
+            </property>
+           </widget>
+          </item>
+          <item>
+           <widget class="QComboBox" name="device"/>
+          </item>
+         </layout>
+        </item>
+       </layout>
+      </widget>
+     </item>
      <item>
       <widget class="QGroupBox" name="groupBox">
        <property name="title">
-        <string>Graphics</string>
+        <string>Graphics Settings</string>
        </property>
-       <layout class="QVBoxLayout" name="verticalLayout_2">
+       <layout class="QVBoxLayout" name="verticalLayout_4">
         <item>
          <widget class="QCheckBox" name="use_disk_shader_cache">
           <property name="text">
@@ -29,13 +77,6 @@
           </property>
          </widget>
         </item>
-        <item>
-         <widget class="QCheckBox" name="use_accurate_gpu_emulation">
-          <property name="text">
-           <string>Use accurate GPU emulation (slow)</string>
-          </property>
-         </widget>
-        </item>
         <item>
          <widget class="QCheckBox" name="use_asynchronous_gpu_emulation">
           <property name="text">
@@ -43,6 +84,13 @@
           </property>
          </widget>
         </item>
+        <item>
+         <widget class="QCheckBox" name="use_accurate_gpu_emulation">
+          <property name="text">
+           <string>Use accurate GPU emulation (slow)</string>
+          </property>
+         </widget>
+        </item>
         <item>
          <widget class="QCheckBox" name="force_30fps_mode">
           <property name="text">
@@ -51,11 +99,11 @@
          </widget>
         </item>
         <item>
-         <layout class="QHBoxLayout" name="horizontalLayout">
+         <layout class="QHBoxLayout" name="horizontalLayout_2">
           <item>
            <widget class="QLabel" name="label">
             <property name="text">
-             <string>Internal Resolution</string>
+             <string>Internal Resolution:</string>
             </property>
            </widget>
           </item>
@@ -91,7 +139,7 @@
          </layout>
         </item>
         <item>
-         <layout class="QHBoxLayout" name="horizontalLayout_6">
+         <layout class="QHBoxLayout" name="horizontalLayout_3">
           <item>
            <widget class="QLabel" name="bg_label">
             <property name="text">
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index b5dd3e0d60..4000bf44ad 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -806,70 +806,12 @@ void GMainWindow::AllowOSSleep() {
 #endif
 }
 
-QStringList GMainWindow::GetUnsupportedGLExtensions() {
-    QStringList unsupported_ext;
-
-    if (!GLAD_GL_ARB_buffer_storage) {
-        unsupported_ext.append(QStringLiteral("ARB_buffer_storage"));
-    }
-    if (!GLAD_GL_ARB_direct_state_access) {
-        unsupported_ext.append(QStringLiteral("ARB_direct_state_access"));
-    }
-    if (!GLAD_GL_ARB_vertex_type_10f_11f_11f_rev) {
-        unsupported_ext.append(QStringLiteral("ARB_vertex_type_10f_11f_11f_rev"));
-    }
-    if (!GLAD_GL_ARB_texture_mirror_clamp_to_edge) {
-        unsupported_ext.append(QStringLiteral("ARB_texture_mirror_clamp_to_edge"));
-    }
-    if (!GLAD_GL_ARB_multi_bind) {
-        unsupported_ext.append(QStringLiteral("ARB_multi_bind"));
-    }
-    if (!GLAD_GL_ARB_clip_control) {
-        unsupported_ext.append(QStringLiteral("ARB_clip_control"));
-    }
-
-    // Extensions required to support some texture formats.
-    if (!GLAD_GL_EXT_texture_compression_s3tc) {
-        unsupported_ext.append(QStringLiteral("EXT_texture_compression_s3tc"));
-    }
-    if (!GLAD_GL_ARB_texture_compression_rgtc) {
-        unsupported_ext.append(QStringLiteral("ARB_texture_compression_rgtc"));
-    }
-    if (!GLAD_GL_ARB_depth_buffer_float) {
-        unsupported_ext.append(QStringLiteral("ARB_depth_buffer_float"));
-    }
-
-    for (const QString& ext : unsupported_ext) {
-        LOG_CRITICAL(Frontend, "Unsupported GL extension: {}", ext.toStdString());
-    }
-
-    return unsupported_ext;
-}
-
 bool GMainWindow::LoadROM(const QString& filename) {
     // Shutdown previous session if the emu thread is still active...
     if (emu_thread != nullptr)
         ShutdownGame();
 
-    render_window->InitRenderTarget();
-
-    {
-        Core::Frontend::ScopeAcquireWindowContext acquire_context{*render_window};
-        if (!gladLoadGL()) {
-            QMessageBox::critical(this, tr("Error while initializing OpenGL 4.3 Core!"),
-                                  tr("Your GPU may not support OpenGL 4.3, or you do not "
-                                     "have the latest graphics driver."));
-            return false;
-        }
-    }
-
-    const QStringList unsupported_gl_extensions = GetUnsupportedGLExtensions();
-    if (!unsupported_gl_extensions.empty()) {
-        QMessageBox::critical(this, tr("Error while initializing OpenGL Core!"),
-                              tr("Your GPU may not support one or more required OpenGL"
-                                 "extensions. Please ensure you have the latest graphics "
-                                 "driver.<br><br>Unsupported extensions:<br>") +
-                                  unsupported_gl_extensions.join(QStringLiteral("<br>")));
+    if (!render_window->InitRenderTarget()) {
         return false;
     }
 
@@ -980,7 +922,9 @@ void GMainWindow::BootGame(const QString& filename) {
     // Create and start the emulation thread
     emu_thread = std::make_unique<EmuThread>(render_window);
     emit EmulationStarting(emu_thread.get());
-    render_window->moveContext();
+    if (Settings::values.renderer_backend == Settings::RendererBackend::OpenGL) {
+        render_window->moveContext();
+    }
     emu_thread->start();
 
     connect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame);
@@ -2195,6 +2139,18 @@ void GMainWindow::closeEvent(QCloseEvent* event) {
     QWidget::closeEvent(event);
 }
 
+void GMainWindow::keyPressEvent(QKeyEvent* event) {
+    if (render_window) {
+        render_window->ForwardKeyPressEvent(event);
+    }
+}
+
+void GMainWindow::keyReleaseEvent(QKeyEvent* event) {
+    if (render_window) {
+        render_window->ForwardKeyReleaseEvent(event);
+    }
+}
+
 static bool IsSingleFileDropEvent(QDropEvent* event) {
     const QMimeData* mimeData = event->mimeData();
     return mimeData->hasUrls() && mimeData->urls().length() == 1;
@@ -2227,18 +2183,6 @@ void GMainWindow::dragMoveEvent(QDragMoveEvent* event) {
     event->acceptProposedAction();
 }
 
-void GMainWindow::keyPressEvent(QKeyEvent* event) {
-    if (render_window) {
-        render_window->ForwardKeyPressEvent(event);
-    }
-}
-
-void GMainWindow::keyReleaseEvent(QKeyEvent* event) {
-    if (render_window) {
-        render_window->ForwardKeyReleaseEvent(event);
-    }
-}
-
 bool GMainWindow::ConfirmChangeGame() {
     if (emu_thread == nullptr)
         return true;
diff --git a/src/yuzu/main.h b/src/yuzu/main.h
index a56f9a981f..65d4f50bb2 100644
--- a/src/yuzu/main.h
+++ b/src/yuzu/main.h
@@ -130,7 +130,6 @@ private:
     void PreventOSSleep();
     void AllowOSSleep();
 
-    QStringList GetUnsupportedGLExtensions();
     bool LoadROM(const QString& filename);
     void BootGame(const QString& filename);
     void ShutdownGame();
diff --git a/src/yuzu_cmd/CMakeLists.txt b/src/yuzu_cmd/CMakeLists.txt
index b5f06ab9e8..a15719a0f1 100644
--- a/src/yuzu_cmd/CMakeLists.txt
+++ b/src/yuzu_cmd/CMakeLists.txt
@@ -8,11 +8,22 @@ add_executable(yuzu-cmd
     emu_window/emu_window_sdl2_gl.h
     emu_window/emu_window_sdl2.cpp
     emu_window/emu_window_sdl2.h
+    emu_window/emu_window_sdl2_gl.cpp
+    emu_window/emu_window_sdl2_gl.h
     resource.h
     yuzu.cpp
     yuzu.rc
 )
 
+if (ENABLE_VULKAN)
+    target_sources(yuzu-cmd PRIVATE
+                   emu_window/emu_window_sdl2_vk.cpp
+                   emu_window/emu_window_sdl2_vk.h)
+
+    target_include_directories(yuzu-cmd PRIVATE ../../externals/Vulkan-Headers/include)
+    target_compile_definitions(yuzu-cmd PRIVATE HAS_VULKAN)
+endif()
+
 create_target_directory_groups(yuzu-cmd)
 
 target_link_libraries(yuzu-cmd PRIVATE common core input_common)
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp b/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp
index b1c512db1b..e961398855 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp
@@ -89,6 +89,10 @@ bool EmuWindow_SDL2::IsOpen() const {
     return is_open;
 }
 
+bool EmuWindow_SDL2::IsShown() const {
+    return is_shown;
+}
+
 void EmuWindow_SDL2::OnResize() {
     int width, height;
     SDL_GetWindowSize(render_window, &width, &height);
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2.h b/src/yuzu_cmd/emu_window/emu_window_sdl2.h
index eaa971f77c..b38f566611 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2.h
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2.h
@@ -21,6 +21,9 @@ public:
     /// Whether the window is still open, and a close request hasn't yet been sent
     bool IsOpen() const;
 
+    /// Returns if window is shown (not minimized)
+    bool IsShown() const override;
+
 protected:
     /// Called by PollEvents when a key is pressed or released.
     void OnKeyEvent(int key, u8 state);
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.cpp b/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.cpp
index 6fde694a2a..7ffa0ac09f 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.cpp
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.cpp
@@ -9,6 +9,7 @@
 #include <SDL.h>
 #include <fmt/format.h>
 #include <glad/glad.h>
+#include "common/assert.h"
 #include "common/logging/log.h"
 #include "common/scm_rev.h"
 #include "common/string_util.h"
@@ -151,6 +152,12 @@ void EmuWindow_SDL2_GL::DoneCurrent() {
     SDL_GL_MakeCurrent(render_window, nullptr);
 }
 
+void EmuWindow_SDL2_GL::RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                               void* surface) const {
+    // Should not have been called from OpenGL
+    UNREACHABLE();
+}
+
 std::unique_ptr<Core::Frontend::GraphicsContext> EmuWindow_SDL2_GL::CreateSharedContext() const {
     return std::make_unique<SDLGLContext>();
 }
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h b/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h
index 630deba937..c753085a83 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h
@@ -22,6 +22,10 @@ public:
     /// Releases the GL context from the caller thread
     void DoneCurrent() override;
 
+    /// Ignored in OpenGL
+    void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                void* surface) const override;
+
     std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override;
 
 private:
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp
new file mode 100644
index 0000000000..89e736ef65
--- /dev/null
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp
@@ -0,0 +1,161 @@
+// Copyright 2018 yuzu Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include <algorithm>
+#include <string>
+#include <vector>
+#include <SDL.h>
+#include <SDL_vulkan.h>
+#include <fmt/format.h>
+#include <vulkan/vulkan.h>
+#include "common/assert.h"
+#include "common/logging/log.h"
+#include "common/scm_rev.h"
+#include "core/settings.h"
+#include "yuzu_cmd/emu_window/emu_window_sdl2_vk.h"
+
+EmuWindow_SDL2_VK::EmuWindow_SDL2_VK(bool fullscreen) : EmuWindow_SDL2(fullscreen) {
+    if (SDL_Vulkan_LoadLibrary(nullptr) != 0) {
+        LOG_CRITICAL(Frontend, "SDL failed to load the Vulkan library: {}", SDL_GetError());
+        exit(EXIT_FAILURE);
+    }
+
+    vkGetInstanceProcAddr =
+        reinterpret_cast<PFN_vkGetInstanceProcAddr>(SDL_Vulkan_GetVkGetInstanceProcAddr());
+    if (vkGetInstanceProcAddr == nullptr) {
+        LOG_CRITICAL(Frontend, "Failed to retrieve Vulkan function pointer!");
+        exit(EXIT_FAILURE);
+    }
+
+    const std::string window_title = fmt::format("yuzu {} | {}-{} (Vulkan)", Common::g_build_name,
+                                                 Common::g_scm_branch, Common::g_scm_desc);
+    render_window =
+        SDL_CreateWindow(window_title.c_str(),
+                         SDL_WINDOWPOS_UNDEFINED, // x position
+                         SDL_WINDOWPOS_UNDEFINED, // y position
+                         Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height,
+                         SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_VULKAN);
+
+    const bool use_standard_layers = UseStandardLayers(vkGetInstanceProcAddr);
+
+    u32 extra_ext_count{};
+    if (!SDL_Vulkan_GetInstanceExtensions(render_window, &extra_ext_count, NULL)) {
+        LOG_CRITICAL(Frontend, "Failed to query Vulkan extensions count from SDL! {}",
+                     SDL_GetError());
+        exit(1);
+    }
+
+    auto extra_ext_names = std::make_unique<const char* []>(extra_ext_count);
+    if (!SDL_Vulkan_GetInstanceExtensions(render_window, &extra_ext_count, extra_ext_names.get())) {
+        LOG_CRITICAL(Frontend, "Failed to query Vulkan extensions from SDL! {}", SDL_GetError());
+        exit(1);
+    }
+    std::vector<const char*> enabled_extensions;
+    enabled_extensions.insert(enabled_extensions.begin(), extra_ext_names.get(),
+                              extra_ext_names.get() + extra_ext_count);
+
+    std::vector<const char*> enabled_layers;
+    if (use_standard_layers) {
+        enabled_extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
+        enabled_layers.push_back("VK_LAYER_LUNARG_standard_validation");
+    }
+
+    VkApplicationInfo app_info{};
+    app_info.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
+    app_info.apiVersion = VK_API_VERSION_1_1;
+    app_info.applicationVersion = VK_MAKE_VERSION(0, 1, 0);
+    app_info.pApplicationName = "yuzu-emu";
+    app_info.engineVersion = VK_MAKE_VERSION(0, 1, 0);
+    app_info.pEngineName = "yuzu-emu";
+
+    VkInstanceCreateInfo instance_ci{};
+    instance_ci.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
+    instance_ci.pApplicationInfo = &app_info;
+    instance_ci.enabledExtensionCount = static_cast<u32>(enabled_extensions.size());
+    instance_ci.ppEnabledExtensionNames = enabled_extensions.data();
+    if (Settings::values.renderer_debug) {
+        instance_ci.enabledLayerCount = static_cast<u32>(enabled_layers.size());
+        instance_ci.ppEnabledLayerNames = enabled_layers.data();
+    }
+
+    const auto vkCreateInstance =
+        reinterpret_cast<PFN_vkCreateInstance>(vkGetInstanceProcAddr(nullptr, "vkCreateInstance"));
+    if (vkCreateInstance == nullptr ||
+        vkCreateInstance(&instance_ci, nullptr, &instance) != VK_SUCCESS) {
+        LOG_CRITICAL(Frontend, "Failed to create Vulkan instance!");
+        exit(EXIT_FAILURE);
+    }
+
+    vkDestroyInstance = reinterpret_cast<PFN_vkDestroyInstance>(
+        vkGetInstanceProcAddr(instance, "vkDestroyInstance"));
+    if (vkDestroyInstance == nullptr) {
+        LOG_CRITICAL(Frontend, "Failed to retrieve Vulkan function pointer!");
+        exit(EXIT_FAILURE);
+    }
+
+    if (!SDL_Vulkan_CreateSurface(render_window, instance, &surface)) {
+        LOG_CRITICAL(Frontend, "Failed to create Vulkan surface! {}", SDL_GetError());
+        exit(EXIT_FAILURE);
+    }
+
+    OnResize();
+    OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size);
+    SDL_PumpEvents();
+    LOG_INFO(Frontend, "yuzu Version: {} | {}-{} (Vulkan)", Common::g_build_name,
+             Common::g_scm_branch, Common::g_scm_desc);
+}
+
+EmuWindow_SDL2_VK::~EmuWindow_SDL2_VK() {
+    vkDestroyInstance(instance, nullptr);
+}
+
+void EmuWindow_SDL2_VK::SwapBuffers() {}
+
+void EmuWindow_SDL2_VK::MakeCurrent() {
+    // Unused on Vulkan
+}
+
+void EmuWindow_SDL2_VK::DoneCurrent() {
+    // Unused on Vulkan
+}
+
+void EmuWindow_SDL2_VK::RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                               void* surface) const {
+    std::memcpy(get_instance_proc_addr, vkGetInstanceProcAddr, sizeof(vkGetInstanceProcAddr));
+    std::memcpy(instance, &this->instance, sizeof(this->instance));
+    std::memcpy(surface, &this->surface, sizeof(this->surface));
+}
+
+std::unique_ptr<Core::Frontend::GraphicsContext> EmuWindow_SDL2_VK::CreateSharedContext() const {
+    return nullptr;
+}
+
+bool EmuWindow_SDL2_VK::UseStandardLayers(PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr) const {
+    if (!Settings::values.renderer_debug) {
+        return false;
+    }
+
+    const auto vkEnumerateInstanceLayerProperties =
+        reinterpret_cast<PFN_vkEnumerateInstanceLayerProperties>(
+            vkGetInstanceProcAddr(nullptr, "vkEnumerateInstanceLayerProperties"));
+    if (vkEnumerateInstanceLayerProperties == nullptr) {
+        LOG_CRITICAL(Frontend, "Failed to retrieve Vulkan function pointer!");
+        return false;
+    }
+
+    u32 available_layers_count{};
+    if (vkEnumerateInstanceLayerProperties(&available_layers_count, nullptr) != VK_SUCCESS) {
+        LOG_CRITICAL(Frontend, "Failed to enumerate Vulkan validation layers!");
+        return false;
+    }
+    std::vector<VkLayerProperties> layers(available_layers_count);
+    if (vkEnumerateInstanceLayerProperties(&available_layers_count, layers.data()) != VK_SUCCESS) {
+        LOG_CRITICAL(Frontend, "Failed to enumerate Vulkan validation layers!");
+        return false;
+    }
+
+    return std::find_if(layers.begin(), layers.end(), [&](const auto& layer) {
+               return layer.layerName == std::string("VK_LAYER_LUNARG_standard_validation");
+           }) != layers.end();
+}
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h
new file mode 100644
index 0000000000..f7234841bc
--- /dev/null
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h
@@ -0,0 +1,39 @@
+// Copyright 2018 yuzu Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include <vulkan/vulkan.h>
+#include "core/frontend/emu_window.h"
+#include "yuzu_cmd/emu_window/emu_window_sdl2.h"
+
+class EmuWindow_SDL2_VK final : public EmuWindow_SDL2 {
+public:
+    explicit EmuWindow_SDL2_VK(bool fullscreen);
+    ~EmuWindow_SDL2_VK();
+
+    /// Swap buffers to display the next frame
+    void SwapBuffers() override;
+
+    /// Makes the graphics context current for the caller thread
+    void MakeCurrent() override;
+
+    /// Releases the GL context from the caller thread
+    void DoneCurrent() override;
+
+    /// Retrieves Vulkan specific handlers from the window
+    void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                void* surface) const override;
+
+    std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override;
+
+private:
+    bool UseStandardLayers(PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr) const;
+
+    VkInstance instance{};
+    VkSurfaceKHR surface{};
+
+    PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr{};
+    PFN_vkDestroyInstance vkDestroyInstance{};
+};
diff --git a/src/yuzu_cmd/yuzu.cpp b/src/yuzu_cmd/yuzu.cpp
index 3ee088a912..325795321e 100644
--- a/src/yuzu_cmd/yuzu.cpp
+++ b/src/yuzu_cmd/yuzu.cpp
@@ -32,6 +32,9 @@
 #include "yuzu_cmd/config.h"
 #include "yuzu_cmd/emu_window/emu_window_sdl2.h"
 #include "yuzu_cmd/emu_window/emu_window_sdl2_gl.h"
+#ifdef HAS_VULKAN
+#include "yuzu_cmd/emu_window/emu_window_sdl2_vk.h"
+#endif
 
 #include "core/file_sys/registered_cache.h"
 
@@ -174,7 +177,20 @@ int main(int argc, char** argv) {
     Settings::values.use_gdbstub = use_gdbstub;
     Settings::Apply();
 
-    std::unique_ptr<EmuWindow_SDL2> emu_window{std::make_unique<EmuWindow_SDL2_GL>(fullscreen)};
+    std::unique_ptr<EmuWindow_SDL2> emu_window;
+    switch (Settings::values.renderer_backend) {
+    case Settings::RendererBackend::OpenGL:
+        emu_window = std::make_unique<EmuWindow_SDL2_GL>(fullscreen);
+        break;
+    case Settings::RendererBackend::Vulkan:
+#ifdef HAS_VULKAN
+        emu_window = std::make_unique<EmuWindow_SDL2_VK>(fullscreen);
+        break;
+#else
+        LOG_CRITICAL(Frontend, "Vulkan backend has not been compiled!");
+        return 1;
+#endif
+    }
 
     if (!Settings::values.use_multi_core) {
         // Single core mode must acquire OpenGL context for entire emulation session
diff --git a/src/yuzu_tester/emu_window/emu_window_sdl2_hide.cpp b/src/yuzu_tester/emu_window/emu_window_sdl2_hide.cpp
index e7fe8decf9..f2cc4a797f 100644
--- a/src/yuzu_tester/emu_window/emu_window_sdl2_hide.cpp
+++ b/src/yuzu_tester/emu_window/emu_window_sdl2_hide.cpp
@@ -5,10 +5,15 @@
 #include <algorithm>
 #include <cstdlib>
 #include <string>
+
+#include <fmt/format.h>
+
 #define SDL_MAIN_HANDLED
 #include <SDL.h>
-#include <fmt/format.h>
+
 #include <glad/glad.h>
+
+#include "common/assert.h"
 #include "common/logging/log.h"
 #include "common/scm_rev.h"
 #include "core/settings.h"
@@ -120,3 +125,11 @@ void EmuWindow_SDL2_Hide::MakeCurrent() {
 void EmuWindow_SDL2_Hide::DoneCurrent() {
     SDL_GL_MakeCurrent(render_window, nullptr);
 }
+
+bool EmuWindow_SDL2_Hide::IsShown() const {
+    return false;
+}
+
+void EmuWindow_SDL2_Hide::RetrieveVulkanHandlers(void*, void*, void*) const {
+    UNREACHABLE();
+}
diff --git a/src/yuzu_tester/emu_window/emu_window_sdl2_hide.h b/src/yuzu_tester/emu_window/emu_window_sdl2_hide.h
index 1a8953c75e..c7fccc0028 100644
--- a/src/yuzu_tester/emu_window/emu_window_sdl2_hide.h
+++ b/src/yuzu_tester/emu_window/emu_window_sdl2_hide.h
@@ -25,6 +25,13 @@ public:
     /// Releases the GL context from the caller thread
     void DoneCurrent() override;
 
+    /// Whether the screen is being shown or not.
+    bool IsShown() const override;
+
+    /// Retrieves Vulkan specific handlers from the window
+    void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
+                                void* surface) const override;
+
     /// Whether the window is still open, and a close request hasn't yet been sent
     bool IsOpen() const;