diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index 88c06b2ce..54be7dc0c 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -131,8 +131,8 @@ add_library(core STATIC
     frontend/framebuffer_layout.cpp
     frontend/framebuffer_layout.h
     frontend/input.h
-    frontend/scope_acquire_window_context.cpp
-    frontend/scope_acquire_window_context.h
+    frontend/scope_acquire_context.cpp
+    frontend/scope_acquire_context.h
     gdbstub/gdbstub.cpp
     gdbstub/gdbstub.h
     hardware_interrupt_manager.cpp
diff --git a/src/core/core.cpp b/src/core/core.cpp
index 86e314c94..a82faf127 100644
--- a/src/core/core.cpp
+++ b/src/core/core.cpp
@@ -24,6 +24,7 @@
 #include "core/file_sys/sdmc_factory.h"
 #include "core/file_sys/vfs_concat.h"
 #include "core/file_sys/vfs_real.h"
+#include "core/frontend/scope_acquire_context.h"
 #include "core/gdbstub/gdbstub.h"
 #include "core/hardware_interrupt_manager.h"
 #include "core/hle/kernel/client_port.h"
@@ -184,6 +185,8 @@ struct System::Impl {
 
     ResultStatus Load(System& system, Frontend::EmuWindow& emu_window,
                       const std::string& filepath) {
+        Core::Frontend::ScopeAcquireContext acquire_context{emu_window};
+
         app_loader = Loader::GetLoader(GetGameFileFromPath(virtual_filesystem, filepath));
         if (!app_loader) {
             LOG_CRITICAL(Core, "Failed to obtain loader for {}!", filepath);
diff --git a/src/core/frontend/emu_window.h b/src/core/frontend/emu_window.h
index 3376eedc5..5eb87fb63 100644
--- a/src/core/frontend/emu_window.h
+++ b/src/core/frontend/emu_window.h
@@ -26,9 +26,6 @@ public:
 
     /// Releases (dunno if this is the "right" word) the context from the caller thread
     virtual void DoneCurrent() = 0;
-
-    /// Swap buffers to display the next frame
-    virtual void SwapBuffers() = 0;
 };
 
 /**
diff --git a/src/core/frontend/framebuffer_layout.h b/src/core/frontend/framebuffer_layout.h
index 1d39c1faf..e9d0a40d3 100644
--- a/src/core/frontend/framebuffer_layout.h
+++ b/src/core/frontend/framebuffer_layout.h
@@ -29,6 +29,7 @@ enum class AspectRatio {
 struct FramebufferLayout {
     u32 width{ScreenUndocked::Width};
     u32 height{ScreenUndocked::Height};
+    bool is_srgb{};
 
     Common::Rectangle<u32> screen;
 
diff --git a/src/core/frontend/scope_acquire_context.cpp b/src/core/frontend/scope_acquire_context.cpp
new file mode 100644
index 000000000..878c3157c
--- /dev/null
+++ b/src/core/frontend/scope_acquire_context.cpp
@@ -0,0 +1,18 @@
+// Copyright 2019 yuzu Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include "core/frontend/emu_window.h"
+#include "core/frontend/scope_acquire_context.h"
+
+namespace Core::Frontend {
+
+ScopeAcquireContext::ScopeAcquireContext(Core::Frontend::GraphicsContext& context)
+    : context{context} {
+    context.MakeCurrent();
+}
+ScopeAcquireContext::~ScopeAcquireContext() {
+    context.DoneCurrent();
+}
+
+} // namespace Core::Frontend
diff --git a/src/core/frontend/scope_acquire_window_context.h b/src/core/frontend/scope_acquire_context.h
similarity index 60%
rename from src/core/frontend/scope_acquire_window_context.h
rename to src/core/frontend/scope_acquire_context.h
index 2d9f6e825..7a65c0623 100644
--- a/src/core/frontend/scope_acquire_window_context.h
+++ b/src/core/frontend/scope_acquire_context.h
@@ -8,16 +8,16 @@
 
 namespace Core::Frontend {
 
-class EmuWindow;
+class GraphicsContext;
 
 /// Helper class to acquire/release window context within a given scope
-class ScopeAcquireWindowContext : NonCopyable {
+class ScopeAcquireContext : NonCopyable {
 public:
-    explicit ScopeAcquireWindowContext(Core::Frontend::EmuWindow& window);
-    ~ScopeAcquireWindowContext();
+    explicit ScopeAcquireContext(Core::Frontend::GraphicsContext& context);
+    ~ScopeAcquireContext();
 
 private:
-    Core::Frontend::EmuWindow& emu_window;
+    Core::Frontend::GraphicsContext& context;
 };
 
 } // namespace Core::Frontend
diff --git a/src/core/frontend/scope_acquire_window_context.cpp b/src/core/frontend/scope_acquire_window_context.cpp
deleted file mode 100644
index 3663dad17..000000000
--- a/src/core/frontend/scope_acquire_window_context.cpp
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright 2019 yuzu Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-#include "core/frontend/emu_window.h"
-#include "core/frontend/scope_acquire_window_context.h"
-
-namespace Core::Frontend {
-
-ScopeAcquireWindowContext::ScopeAcquireWindowContext(Core::Frontend::EmuWindow& emu_window_)
-    : emu_window{emu_window_} {
-    emu_window.MakeCurrent();
-}
-ScopeAcquireWindowContext::~ScopeAcquireWindowContext() {
-    emu_window.DoneCurrent();
-}
-
-} // namespace Core::Frontend
diff --git a/src/core/settings.cpp b/src/core/settings.cpp
index d1fc94060..7c0303684 100644
--- a/src/core/settings.cpp
+++ b/src/core/settings.cpp
@@ -94,6 +94,7 @@ void LogSettings() {
     LogSetting("Renderer_UseAccurateGpuEmulation", Settings::values.use_accurate_gpu_emulation);
     LogSetting("Renderer_UseAsynchronousGpuEmulation",
                Settings::values.use_asynchronous_gpu_emulation);
+    LogSetting("Renderer_UseVsync", Settings::values.use_vsync);
     LogSetting("Audio_OutputEngine", Settings::values.sink_id);
     LogSetting("Audio_EnableAudioStretching", Settings::values.enable_audio_stretching);
     LogSetting("Audio_OutputDevice", Settings::values.audio_device_id);
diff --git a/src/core/settings.h b/src/core/settings.h
index f837d3fbc..15b691342 100644
--- a/src/core/settings.h
+++ b/src/core/settings.h
@@ -435,6 +435,7 @@ struct Values {
     bool use_disk_shader_cache;
     bool use_accurate_gpu_emulation;
     bool use_asynchronous_gpu_emulation;
+    bool use_vsync;
     bool force_30fps_mode;
 
     float bg_red;
diff --git a/src/core/telemetry_session.cpp b/src/core/telemetry_session.cpp
index 0e72d31cd..0f3685d1c 100644
--- a/src/core/telemetry_session.cpp
+++ b/src/core/telemetry_session.cpp
@@ -188,6 +188,7 @@ void TelemetrySession::AddInitialInfo(Loader::AppLoader& app_loader) {
              Settings::values.use_accurate_gpu_emulation);
     AddField(field_type, "Renderer_UseAsynchronousGpuEmulation",
              Settings::values.use_asynchronous_gpu_emulation);
+    AddField(field_type, "Renderer_UseVsync", Settings::values.use_vsync);
     AddField(field_type, "System_UseDockedMode", Settings::values.use_docked_mode);
 }
 
diff --git a/src/video_core/gpu_thread.cpp b/src/video_core/gpu_thread.cpp
index 2cdf1aa7f..b1088af3d 100644
--- a/src/video_core/gpu_thread.cpp
+++ b/src/video_core/gpu_thread.cpp
@@ -5,7 +5,7 @@
 #include "common/assert.h"
 #include "common/microprofile.h"
 #include "core/core.h"
-#include "core/frontend/scope_acquire_window_context.h"
+#include "core/frontend/scope_acquire_context.h"
 #include "video_core/dma_pusher.h"
 #include "video_core/gpu.h"
 #include "video_core/gpu_thread.h"
@@ -27,7 +27,7 @@ static void RunThread(VideoCore::RendererBase& renderer, Tegra::DmaPusher& dma_p
         return;
     }
 
-    Core::Frontend::ScopeAcquireWindowContext acquire_context{renderer.GetRenderWindow()};
+    Core::Frontend::ScopeAcquireContext acquire_context{renderer.GetRenderWindow()};
 
     CommandDataContainer next;
     while (state.is_running) {
diff --git a/src/video_core/renderer_base.h b/src/video_core/renderer_base.h
index af1bebc4f..5ec99a126 100644
--- a/src/video_core/renderer_base.h
+++ b/src/video_core/renderer_base.h
@@ -35,15 +35,19 @@ public:
     explicit RendererBase(Core::Frontend::EmuWindow& window);
     virtual ~RendererBase();
 
-    /// Swap buffers (render frame)
-    virtual void SwapBuffers(const Tegra::FramebufferConfig* framebuffer) = 0;
-
     /// Initialize the renderer
     virtual bool Init() = 0;
 
     /// Shutdown the renderer
     virtual void ShutDown() = 0;
 
+    /// Finalize rendering the guest frame and draw into the presentation texture
+    virtual void SwapBuffers(const Tegra::FramebufferConfig* framebuffer) = 0;
+
+    /// Draws the latest frame to the window waiting timeout_ms for a frame to arrive (Renderer
+    /// specific implementation)
+    virtual void TryPresent(int timeout_ms) = 0;
+
     // Getter/setter functions:
     // ------------------------
 
diff --git a/src/video_core/renderer_opengl/gl_resource_manager.cpp b/src/video_core/renderer_opengl/gl_resource_manager.cpp
index f0ddfb276..c0aee770f 100644
--- a/src/video_core/renderer_opengl/gl_resource_manager.cpp
+++ b/src/video_core/renderer_opengl/gl_resource_manager.cpp
@@ -15,6 +15,24 @@ MICROPROFILE_DEFINE(OpenGL_ResourceDeletion, "OpenGL", "Resource Deletion", MP_R
 
 namespace OpenGL {
 
+void OGLRenderbuffer::Create() {
+    if (handle != 0)
+        return;
+
+    MICROPROFILE_SCOPE(OpenGL_ResourceCreation);
+    glGenRenderbuffers(1, &handle);
+}
+
+void OGLRenderbuffer::Release() {
+    if (handle == 0)
+        return;
+
+    MICROPROFILE_SCOPE(OpenGL_ResourceDeletion);
+    glDeleteRenderbuffers(1, &handle);
+    OpenGLState::GetCurState().ResetRenderbuffer(handle).Apply();
+    handle = 0;
+}
+
 void OGLTexture::Create(GLenum target) {
     if (handle != 0)
         return;
diff --git a/src/video_core/renderer_opengl/gl_resource_manager.h b/src/video_core/renderer_opengl/gl_resource_manager.h
index 514d1d165..995a4e45e 100644
--- a/src/video_core/renderer_opengl/gl_resource_manager.h
+++ b/src/video_core/renderer_opengl/gl_resource_manager.h
@@ -11,6 +11,31 @@
 
 namespace OpenGL {
 
+class OGLRenderbuffer : private NonCopyable {
+public:
+    OGLRenderbuffer() = default;
+
+    OGLRenderbuffer(OGLRenderbuffer&& o) noexcept : handle(std::exchange(o.handle, 0)) {}
+
+    ~OGLRenderbuffer() {
+        Release();
+    }
+
+    OGLRenderbuffer& operator=(OGLRenderbuffer&& o) noexcept {
+        Release();
+        handle = std::exchange(o.handle, 0);
+        return *this;
+    }
+
+    /// Creates a new internal OpenGL resource and stores the handle
+    void Create();
+
+    /// Deletes the internal OpenGL resource
+    void Release();
+
+    GLuint handle = 0;
+};
+
 class OGLTexture : private NonCopyable {
 public:
     OGLTexture() = default;
diff --git a/src/video_core/renderer_opengl/gl_state.cpp b/src/video_core/renderer_opengl/gl_state.cpp
index ab1f7983c..7d3bc1a1f 100644
--- a/src/video_core/renderer_opengl/gl_state.cpp
+++ b/src/video_core/renderer_opengl/gl_state.cpp
@@ -423,6 +423,13 @@ void OpenGLState::ApplyClipControl() {
     }
 }
 
+void OpenGLState::ApplyRenderBuffer() {
+    if (cur_state.renderbuffer != renderbuffer) {
+        cur_state.renderbuffer = renderbuffer;
+        glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
+    }
+}
+
 void OpenGLState::ApplyTextures() {
     const std::size_t size = std::size(textures);
     for (std::size_t i = 0; i < size; ++i) {
@@ -478,6 +485,7 @@ void OpenGLState::Apply() {
     ApplyPolygonOffset();
     ApplyAlphaTest();
     ApplyClipControl();
+    ApplyRenderBuffer();
 }
 
 void OpenGLState::EmulateViewportWithScissor() {
@@ -551,4 +559,11 @@ OpenGLState& OpenGLState::ResetFramebuffer(GLuint handle) {
     return *this;
 }
 
+OpenGLState& OpenGLState::ResetRenderbuffer(GLuint handle) {
+    if (renderbuffer == handle) {
+        renderbuffer = 0;
+    }
+    return *this;
+}
+
 } // namespace OpenGL
diff --git a/src/video_core/renderer_opengl/gl_state.h b/src/video_core/renderer_opengl/gl_state.h
index 4953eeda2..bce662f2c 100644
--- a/src/video_core/renderer_opengl/gl_state.h
+++ b/src/video_core/renderer_opengl/gl_state.h
@@ -158,6 +158,8 @@ public:
         GLenum depth_mode = GL_NEGATIVE_ONE_TO_ONE;
     } clip_control;
 
+    GLuint renderbuffer{}; // GL_RENDERBUFFER_BINDING
+
     OpenGLState();
 
     /// Get the currently active OpenGL state
@@ -196,6 +198,7 @@ public:
     void ApplyPolygonOffset();
     void ApplyAlphaTest();
     void ApplyClipControl();
+    void ApplyRenderBuffer();
 
     /// Resets any references to the given resource
     OpenGLState& UnbindTexture(GLuint handle);
@@ -204,6 +207,7 @@ public:
     OpenGLState& ResetPipeline(GLuint handle);
     OpenGLState& ResetVertexArray(GLuint handle);
     OpenGLState& ResetFramebuffer(GLuint handle);
+    OpenGLState& ResetRenderbuffer(GLuint handle);
 
     /// Viewport does not affects glClearBuffer so emulate viewport using scissor test
     void EmulateViewportWithScissor();
diff --git a/src/video_core/renderer_opengl/renderer_opengl.cpp b/src/video_core/renderer_opengl/renderer_opengl.cpp
index bba16afaf..447f69d4d 100644
--- a/src/video_core/renderer_opengl/renderer_opengl.cpp
+++ b/src/video_core/renderer_opengl/renderer_opengl.cpp
@@ -9,11 +9,11 @@
 #include <glad/glad.h>
 #include "common/assert.h"
 #include "common/logging/log.h"
+#include "common/microprofile.h"
 #include "common/telemetry.h"
 #include "core/core.h"
 #include "core/core_timing.h"
 #include "core/frontend/emu_window.h"
-#include "core/frontend/scope_acquire_window_context.h"
 #include "core/memory.h"
 #include "core/perf_stats.h"
 #include "core/settings.h"
@@ -24,6 +24,144 @@
 
 namespace OpenGL {
 
+// If the size of this is too small, it ends up creating a soft cap on FPS as the renderer will have
+// to wait on available presentation frames.
+constexpr std::size_t SWAP_CHAIN_SIZE = 3;
+
+struct Frame {
+    u32 width{};                      /// Width of the frame (to detect resize)
+    u32 height{};                     /// Height of the frame
+    bool color_reloaded{};            /// Texture attachment was recreated (ie: resized)
+    OpenGL::OGLRenderbuffer color{};  /// Buffer shared between the render/present FBO
+    OpenGL::OGLFramebuffer render{};  /// FBO created on the render thread
+    OpenGL::OGLFramebuffer present{}; /// FBO created on the present thread
+    GLsync render_fence{};            /// Fence created on the render thread
+    GLsync present_fence{};           /// Fence created on the presentation thread
+    bool is_srgb{};                   /// Framebuffer is sRGB or RGB
+};
+
+/**
+ * For smooth Vsync rendering, we want to always present the latest frame that the core generates,
+ * but also make sure that rendering happens at the pace that the frontend dictates. This is a
+ * helper class that the renderer uses to sync frames between the render thread and the presentation
+ * thread
+ */
+class FrameMailbox {
+public:
+    std::mutex swap_chain_lock;
+    std::condition_variable present_cv;
+    std::array<Frame, SWAP_CHAIN_SIZE> swap_chain{};
+    std::queue<Frame*> free_queue;
+    std::deque<Frame*> present_queue;
+    Frame* previous_frame{};
+
+    FrameMailbox() {
+        for (auto& frame : swap_chain) {
+            free_queue.push(&frame);
+        }
+    }
+
+    ~FrameMailbox() {
+        // lock the mutex and clear out the present and free_queues and notify any people who are
+        // blocked to prevent deadlock on shutdown
+        std::scoped_lock lock{swap_chain_lock};
+        std::queue<Frame*>().swap(free_queue);
+        present_queue.clear();
+        present_cv.notify_all();
+    }
+
+    void ReloadPresentFrame(Frame* frame, u32 height, u32 width) {
+        frame->present.Release();
+        frame->present.Create();
+        GLint previous_draw_fbo{};
+        glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &previous_draw_fbo);
+        glBindFramebuffer(GL_FRAMEBUFFER, frame->present.handle);
+        glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
+                                  frame->color.handle);
+        if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+            LOG_CRITICAL(Render_OpenGL, "Failed to recreate present FBO!");
+        }
+        glBindFramebuffer(GL_DRAW_FRAMEBUFFER, previous_draw_fbo);
+        frame->color_reloaded = false;
+    }
+
+    void ReloadRenderFrame(Frame* frame, u32 width, u32 height) {
+        OpenGLState prev_state = OpenGLState::GetCurState();
+        OpenGLState state = OpenGLState::GetCurState();
+
+        // Recreate the color texture attachment
+        frame->color.Release();
+        frame->color.Create();
+        state.renderbuffer = frame->color.handle;
+        state.Apply();
+        glRenderbufferStorage(GL_RENDERBUFFER, frame->is_srgb ? GL_SRGB8 : GL_RGB8, width, height);
+
+        // Recreate the FBO for the render target
+        frame->render.Release();
+        frame->render.Create();
+        state.draw.read_framebuffer = frame->render.handle;
+        state.draw.draw_framebuffer = frame->render.handle;
+        state.Apply();
+        glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER,
+                                  frame->color.handle);
+        if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+            LOG_CRITICAL(Render_OpenGL, "Failed to recreate render FBO!");
+        }
+        prev_state.Apply();
+        frame->width = width;
+        frame->height = height;
+        frame->color_reloaded = true;
+    }
+
+    Frame* GetRenderFrame() {
+        std::unique_lock lock{swap_chain_lock};
+
+        // If theres no free frames, we will reuse the oldest render frame
+        if (free_queue.empty()) {
+            auto frame = present_queue.back();
+            present_queue.pop_back();
+            return frame;
+        }
+
+        Frame* frame = free_queue.front();
+        free_queue.pop();
+        return frame;
+    }
+
+    void ReleaseRenderFrame(Frame* frame) {
+        std::unique_lock lock{swap_chain_lock};
+        present_queue.push_front(frame);
+        present_cv.notify_one();
+    }
+
+    Frame* TryGetPresentFrame(int timeout_ms) {
+        std::unique_lock lock{swap_chain_lock};
+        // wait for new entries in the present_queue
+        present_cv.wait_for(lock, std::chrono::milliseconds(timeout_ms),
+                            [&] { return !present_queue.empty(); });
+        if (present_queue.empty()) {
+            // timed out waiting for a frame to draw so return the previous frame
+            return previous_frame;
+        }
+
+        // free the previous frame and add it back to the free queue
+        if (previous_frame) {
+            free_queue.push(previous_frame);
+        }
+
+        // the newest entries are pushed to the front of the queue
+        Frame* frame = present_queue.front();
+        present_queue.pop_front();
+        // remove all old entries from the present queue and move them back to the free_queue
+        for (auto f : present_queue) {
+            free_queue.push(f);
+        }
+        present_queue.clear();
+        previous_frame = frame;
+        return frame;
+    }
+};
+
 namespace {
 
 constexpr char vertex_shader[] = R"(
@@ -158,21 +296,91 @@ void APIENTRY DebugHandler(GLenum source, GLenum type, GLuint id, GLenum severit
 } // Anonymous namespace
 
 RendererOpenGL::RendererOpenGL(Core::Frontend::EmuWindow& emu_window, Core::System& system)
-    : VideoCore::RendererBase{emu_window}, emu_window{emu_window}, system{system} {}
+    : VideoCore::RendererBase{emu_window}, emu_window{emu_window}, system{system},
+      frame_mailbox{std::make_unique<FrameMailbox>()} {}
 
 RendererOpenGL::~RendererOpenGL() = default;
 
+MICROPROFILE_DEFINE(OpenGL_RenderFrame, "OpenGL", "Render Frame", MP_RGB(128, 128, 64));
+MICROPROFILE_DEFINE(OpenGL_WaitPresent, "OpenGL", "Wait For Present", MP_RGB(128, 128, 128));
+
 void RendererOpenGL::SwapBuffers(const Tegra::FramebufferConfig* framebuffer) {
+    render_window.PollEvents();
+
+    if (!framebuffer) {
+        return;
+    }
+
     // Maintain the rasterizer's state as a priority
     OpenGLState prev_state = OpenGLState::GetCurState();
     state.AllDirty();
     state.Apply();
 
+    PrepareRendertarget(framebuffer);
+    RenderScreenshot();
+
+    Frame* frame;
+    {
+        MICROPROFILE_SCOPE(OpenGL_WaitPresent);
+
+        frame = frame_mailbox->GetRenderFrame();
+
+        // Clean up sync objects before drawing
+
+        // INTEL driver workaround. We can't delete the previous render sync object until we are
+        // sure that the presentation is done
+        if (frame->present_fence) {
+            glClientWaitSync(frame->present_fence, 0, GL_TIMEOUT_IGNORED);
+        }
+
+        // delete the draw fence if the frame wasn't presented
+        if (frame->render_fence) {
+            glDeleteSync(frame->render_fence);
+            frame->render_fence = 0;
+        }
+
+        // wait for the presentation to be done
+        if (frame->present_fence) {
+            glWaitSync(frame->present_fence, 0, GL_TIMEOUT_IGNORED);
+            glDeleteSync(frame->present_fence);
+            frame->present_fence = 0;
+        }
+    }
+
+    {
+        MICROPROFILE_SCOPE(OpenGL_RenderFrame);
+        const auto& layout = render_window.GetFramebufferLayout();
+
+        // Recreate the frame if the size of the window has changed
+        if (layout.width != frame->width || layout.height != frame->height ||
+            is_srgb != frame->is_srgb) {
+            LOG_DEBUG(Render_OpenGL, "Reloading render frame");
+            is_srgb = frame->is_srgb = screen_info.display_srgb;
+            frame_mailbox->ReloadRenderFrame(frame, layout.width, layout.height);
+        }
+        state.draw.draw_framebuffer = frame->render.handle;
+        state.Apply();
+        DrawScreen(layout);
+        // Create a fence for the frontend to wait on and swap this frame to OffTex
+        frame->render_fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
+        glFlush();
+        frame_mailbox->ReleaseRenderFrame(frame);
+        m_current_frame++;
+        rasterizer->TickFrame();
+    }
+
+    // Restore the rasterizer state
+    prev_state.AllDirty();
+    prev_state.Apply();
+}
+
+void RendererOpenGL::PrepareRendertarget(const Tegra::FramebufferConfig* framebuffer) {
     if (framebuffer) {
         // If framebuffer is provided, reload it from memory to a texture
         if (screen_info.texture.width != static_cast<GLsizei>(framebuffer->width) ||
             screen_info.texture.height != static_cast<GLsizei>(framebuffer->height) ||
-            screen_info.texture.pixel_format != framebuffer->pixel_format) {
+            screen_info.texture.pixel_format != framebuffer->pixel_format ||
+            gl_framebuffer_data.empty()) {
             // Reallocate texture if the framebuffer size has changed.
             // This is expected to not happen very often and hence should not be a
             // performance problem.
@@ -181,22 +389,7 @@ void RendererOpenGL::SwapBuffers(const Tegra::FramebufferConfig* framebuffer) {
 
         // Load the framebuffer from memory, draw it to the screen, and swap buffers
         LoadFBToScreenInfo(*framebuffer);
-
-        if (renderer_settings.screenshot_requested)
-            CaptureScreenshot();
-
-        DrawScreen(render_window.GetFramebufferLayout());
-
-        rasterizer->TickFrame();
-
-        render_window.SwapBuffers();
     }
-
-    render_window.PollEvents();
-
-    // Restore the rasterizer state
-    prev_state.AllDirty();
-    prev_state.Apply();
 }
 
 void RendererOpenGL::LoadFBToScreenInfo(const Tegra::FramebufferConfig& framebuffer) {
@@ -418,13 +611,48 @@ void RendererOpenGL::DrawScreen(const Layout::FramebufferLayout& layout) {
     DrawScreenTriangles(screen_info, static_cast<float>(screen.left),
                         static_cast<float>(screen.top), static_cast<float>(screen.GetWidth()),
                         static_cast<float>(screen.GetHeight()));
-
-    m_current_frame++;
 }
 
-void RendererOpenGL::UpdateFramerate() {}
+void RendererOpenGL::TryPresent(int timeout_ms) {
+    const auto& layout = render_window.GetFramebufferLayout();
+    auto frame = frame_mailbox->TryGetPresentFrame(timeout_ms);
+    if (!frame) {
+        LOG_DEBUG(Render_OpenGL, "TryGetPresentFrame returned no frame to present");
+        return;
+    }
+
+    // Clearing before a full overwrite of a fbo can signal to drivers that they can avoid a
+    // readback since we won't be doing any blending
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    // Recreate the presentation FBO if the color attachment was changed
+    if (frame->color_reloaded) {
+        LOG_DEBUG(Render_OpenGL, "Reloading present frame");
+        frame_mailbox->ReloadPresentFrame(frame, layout.width, layout.height);
+    }
+    glWaitSync(frame->render_fence, 0, GL_TIMEOUT_IGNORED);
+    // INTEL workaround.
+    // Normally we could just delete the draw fence here, but due to driver bugs, we can just delete
+    // it on the emulation thread without too much penalty
+    // glDeleteSync(frame.render_sync);
+    // frame.render_sync = 0;
+
+    glBindFramebuffer(GL_READ_FRAMEBUFFER, frame->present.handle);
+    glBlitFramebuffer(0, 0, frame->width, frame->height, 0, 0, layout.width, layout.height,
+                      GL_COLOR_BUFFER_BIT, GL_LINEAR);
+
+    // Insert fence for the main thread to block on
+    frame->present_fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
+    glFlush();
+
+    glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
+}
+
+void RendererOpenGL::RenderScreenshot() {
+    if (!renderer_settings.screenshot_requested) {
+        return;
+    }
 
-void RendererOpenGL::CaptureScreenshot() {
     // Draw the current frame to the screenshot framebuffer
     screenshot_framebuffer.Create();
     GLuint old_read_fb = state.draw.read_framebuffer;
@@ -459,8 +687,6 @@ void RendererOpenGL::CaptureScreenshot() {
 }
 
 bool RendererOpenGL::Init() {
-    Core::Frontend::ScopeAcquireWindowContext acquire_context{render_window};
-
     if (GLAD_GL_KHR_debug) {
         glEnable(GL_DEBUG_OUTPUT);
         glDebugMessageCallback(DebugHandler, nullptr);
diff --git a/src/video_core/renderer_opengl/renderer_opengl.h b/src/video_core/renderer_opengl/renderer_opengl.h
index b56328a7f..4107e10a9 100644
--- a/src/video_core/renderer_opengl/renderer_opengl.h
+++ b/src/video_core/renderer_opengl/renderer_opengl.h
@@ -44,19 +44,23 @@ struct ScreenInfo {
     TextureInfo texture;
 };
 
+struct PresentationTexture {
+    u32 width = 0;
+    u32 height = 0;
+    OGLTexture texture;
+};
+
+class FrameMailbox;
+
 class RendererOpenGL final : public VideoCore::RendererBase {
 public:
     explicit RendererOpenGL(Core::Frontend::EmuWindow& emu_window, Core::System& system);
     ~RendererOpenGL() override;
 
-    /// Swap buffers (render frame)
-    void SwapBuffers(const Tegra::FramebufferConfig* framebuffer) override;
-
-    /// Initialize the renderer
     bool Init() override;
-
-    /// Shutdown the renderer
     void ShutDown() override;
+    void SwapBuffers(const Tegra::FramebufferConfig* framebuffer) override;
+    void TryPresent(int timeout_ms) override;
 
 private:
     /// Initializes the OpenGL state and creates persistent objects.
@@ -74,10 +78,7 @@ private:
 
     void DrawScreenTriangles(const ScreenInfo& screen_info, float x, float y, float w, float h);
 
-    /// Updates the framerate.
-    void UpdateFramerate();
-
-    void CaptureScreenshot();
+    void RenderScreenshot();
 
     /// Loads framebuffer from emulated memory into the active OpenGL texture.
     void LoadFBToScreenInfo(const Tegra::FramebufferConfig& framebuffer);
@@ -87,6 +88,8 @@ private:
     void LoadColorToActiveGLTexture(u8 color_r, u8 color_g, u8 color_b, u8 color_a,
                                     const TextureInfo& texture);
 
+    void PrepareRendertarget(const Tegra::FramebufferConfig* framebuffer);
+
     Core::Frontend::EmuWindow& emu_window;
     Core::System& system;
 
@@ -107,6 +110,12 @@ private:
     /// Used for transforming the framebuffer orientation
     Tegra::FramebufferConfig::TransformFlags framebuffer_transform_flags;
     Common::Rectangle<int> framebuffer_crop_rect;
+
+    /// Represents if the final render frame is sRGB
+    bool is_srgb{};
+
+    /// Frame presentation mailbox
+    std::unique_ptr<FrameMailbox> frame_mailbox;
 };
 
 } // namespace OpenGL
diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp
index d5032b432..ddc62bc97 100644
--- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp
+++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp
@@ -106,8 +106,14 @@ RendererVulkan::~RendererVulkan() {
 }
 
 void RendererVulkan::SwapBuffers(const Tegra::FramebufferConfig* framebuffer) {
+    render_window.PollEvents();
+
+    if (!framebuffer) {
+        return;
+    }
+
     const auto& layout = render_window.GetFramebufferLayout();
-    if (framebuffer && layout.width > 0 && layout.height > 0 && render_window.IsShown()) {
+    if (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);
@@ -128,13 +134,16 @@ void RendererVulkan::SwapBuffers(const Tegra::FramebufferConfig* framebuffer) {
             blit_screen->Recreate();
         }
 
-        render_window.SwapBuffers();
         rasterizer->TickFrame();
     }
 
     render_window.PollEvents();
 }
 
+void RendererVulkan::TryPresent(int /*timeout_ms*/) {
+    // TODO (bunnei): ImplementMe
+}
+
 bool RendererVulkan::Init() {
     PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr{};
     render_window.RetrieveVulkanHandlers(&vkGetInstanceProcAddr, &instance, &surface);
@@ -262,4 +271,4 @@ void RendererVulkan::Report() const {
     telemetry_session.AddField(field, "GPU_Vulkan_Extensions", extensions);
 }
 
-} // namespace Vulkan
\ No newline at end of file
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.h b/src/video_core/renderer_vulkan/renderer_vulkan.h
index a472c5dc9..f513397f0 100644
--- a/src/video_core/renderer_vulkan/renderer_vulkan.h
+++ b/src/video_core/renderer_vulkan/renderer_vulkan.h
@@ -36,14 +36,10 @@ public:
     explicit RendererVulkan(Core::Frontend::EmuWindow& window, Core::System& system);
     ~RendererVulkan() override;
 
-    /// Swap buffers (render frame)
-    void SwapBuffers(const Tegra::FramebufferConfig* framebuffer) override;
-
-    /// Initialize the renderer
     bool Init() override;
-
-    /// Shutdown the renderer
     void ShutDown() override;
+    void SwapBuffers(const Tegra::FramebufferConfig* framebuffer) override;
+    void TryPresent(int timeout_ms) override;
 
 private:
     std::optional<vk::DebugUtilsMessengerEXT> CreateDebugCallback(
diff --git a/src/yuzu/bootmanager.cpp b/src/yuzu/bootmanager.cpp
index 55a37fffa..c3dbb1a88 100644
--- a/src/yuzu/bootmanager.cpp
+++ b/src/yuzu/bootmanager.cpp
@@ -9,6 +9,9 @@
 #include <QKeyEvent>
 #include <QMessageBox>
 #include <QOffscreenSurface>
+#include <QOpenGLContext>
+#include <QOpenGLFunctions>
+#include <QOpenGLFunctions_4_3_Core>
 #include <QOpenGLWindow>
 #include <QPainter>
 #include <QScreen>
@@ -23,9 +26,10 @@
 #include "common/assert.h"
 #include "common/microprofile.h"
 #include "common/scm_rev.h"
+#include "common/scope_exit.h"
 #include "core/core.h"
 #include "core/frontend/framebuffer_layout.h"
-#include "core/frontend/scope_acquire_window_context.h"
+#include "core/frontend/scope_acquire_context.h"
 #include "core/settings.h"
 #include "input_common/keyboard.h"
 #include "input_common/main.h"
@@ -35,15 +39,27 @@
 #include "yuzu/bootmanager.h"
 #include "yuzu/main.h"
 
-EmuThread::EmuThread(GRenderWindow* render_window) : render_window(render_window) {}
+EmuThread::EmuThread(GRenderWindow& window)
+    : shared_context{window.CreateSharedContext()},
+      context{(Settings::values.use_asynchronous_gpu_emulation && shared_context) ? *shared_context
+                                                                                  : window} {}
 
 EmuThread::~EmuThread() = default;
 
-void EmuThread::run() {
-    render_window->MakeCurrent();
+static GMainWindow* GetMainWindow() {
+    for (QWidget* w : qApp->topLevelWidgets()) {
+        if (GMainWindow* main = qobject_cast<GMainWindow*>(w)) {
+            return main;
+        }
+    }
+    return nullptr;
+}
 
+void EmuThread::run() {
     MicroProfileOnThreadCreate("EmuThread");
 
+    Core::Frontend::ScopeAcquireContext acquire_context{context};
+
     emit LoadProgress(VideoCore::LoadCallbackStage::Prepare, 0, 0);
 
     Core::System::GetInstance().Renderer().Rasterizer().LoadDiskResources(
@@ -53,11 +69,6 @@ void EmuThread::run() {
 
     emit LoadProgress(VideoCore::LoadCallbackStage::Complete, 0, 0);
 
-    if (Settings::values.use_asynchronous_gpu_emulation) {
-        // Release OpenGL context for the GPU thread
-        render_window->DoneCurrent();
-    }
-
     // Holds whether the cpu was running during the last iteration,
     // so that the DebugModeLeft signal can be emitted before the
     // next execution step
@@ -98,190 +109,202 @@ void EmuThread::run() {
 #if MICROPROFILE_ENABLED
     MicroProfileOnThreadExit();
 #endif
-
-    render_window->moveContext();
 }
 
 class GGLContext : public Core::Frontend::GraphicsContext {
 public:
-    explicit GGLContext(QOpenGLContext* shared_context) : shared_context{shared_context} {
-        context.setFormat(shared_context->format());
-        context.setShareContext(shared_context);
-        context.create();
+    explicit GGLContext(QOpenGLContext* shared_context)
+        : context(new QOpenGLContext(shared_context->parent())),
+          surface(new QOffscreenSurface(nullptr)) {
+
+        // disable vsync for any shared contexts
+        auto format = shared_context->format();
+        format.setSwapInterval(0);
+
+        context->setShareContext(shared_context);
+        context->setFormat(format);
+        context->create();
+        surface->setParent(shared_context->parent());
+        surface->setFormat(format);
+        surface->create();
     }
 
     void MakeCurrent() override {
-        context.makeCurrent(shared_context->surface());
+        context->makeCurrent(surface);
     }
 
     void DoneCurrent() override {
-        context.doneCurrent();
+        context->doneCurrent();
     }
 
-    void SwapBuffers() override {}
-
 private:
-    QOpenGLContext* shared_context;
-    QOpenGLContext context;
+    QOpenGLContext* context;
+    QOffscreenSurface* surface;
 };
 
-class GWidgetInternal : public QWindow {
+class ChildRenderWindow : public QWindow {
 public:
-    GWidgetInternal(GRenderWindow* parent) : parent(parent) {}
-    virtual ~GWidgetInternal() = default;
+    ChildRenderWindow(QWindow* parent, QWidget* event_handler)
+        : QWindow{parent}, event_handler{event_handler} {}
 
-    void resizeEvent(QResizeEvent* ev) override {
-        parent->OnClientAreaResized(ev->size().width(), ev->size().height());
-        parent->OnFramebufferSizeChanged();
-    }
+    virtual ~ChildRenderWindow() = default;
 
-    void keyPressEvent(QKeyEvent* event) override {
-        InputCommon::GetKeyboard()->PressKey(event->key());
-    }
-
-    void keyReleaseEvent(QKeyEvent* event) override {
-        InputCommon::GetKeyboard()->ReleaseKey(event->key());
-    }
-
-    void mousePressEvent(QMouseEvent* event) override {
-        if (event->source() == Qt::MouseEventSynthesizedBySystem)
-            return; // touch input is handled in TouchBeginEvent
-
-        const auto pos{event->pos()};
-        if (event->button() == Qt::LeftButton) {
-            const auto [x, y] = parent->ScaleTouch(pos);
-            parent->TouchPressed(x, y);
-        } else if (event->button() == Qt::RightButton) {
-            InputCommon::GetMotionEmu()->BeginTilt(pos.x(), pos.y());
-        }
-    }
-
-    void mouseMoveEvent(QMouseEvent* event) override {
-        if (event->source() == Qt::MouseEventSynthesizedBySystem)
-            return; // touch input is handled in TouchUpdateEvent
-
-        const auto pos{event->pos()};
-        const auto [x, y] = parent->ScaleTouch(pos);
-        parent->TouchMoved(x, y);
-        InputCommon::GetMotionEmu()->Tilt(pos.x(), pos.y());
-    }
-
-    void mouseReleaseEvent(QMouseEvent* event) override {
-        if (event->source() == Qt::MouseEventSynthesizedBySystem)
-            return; // touch input is handled in TouchEndEvent
-
-        if (event->button() == Qt::LeftButton)
-            parent->TouchReleased();
-        else if (event->button() == Qt::RightButton)
-            InputCommon::GetMotionEmu()->EndTilt();
-    }
-
-    void DisablePainting() {
-        do_painting = false;
-    }
-
-    void EnablePainting() {
-        do_painting = true;
-    }
-
-    std::pair<unsigned, unsigned> GetSize() const {
-        return std::make_pair(width(), height());
-    }
+    virtual void Present() = 0;
 
 protected:
-    bool IsPaintingEnabled() const {
-        return do_painting;
+    bool event(QEvent* event) override {
+        switch (event->type()) {
+        case QEvent::UpdateRequest:
+            Present();
+            return true;
+        case QEvent::MouseButtonPress:
+        case QEvent::MouseButtonRelease:
+        case QEvent::MouseButtonDblClick:
+        case QEvent::MouseMove:
+        case QEvent::KeyPress:
+        case QEvent::KeyRelease:
+        case QEvent::FocusIn:
+        case QEvent::FocusOut:
+        case QEvent::FocusAboutToChange:
+        case QEvent::Enter:
+        case QEvent::Leave:
+        case QEvent::Wheel:
+        case QEvent::TabletMove:
+        case QEvent::TabletPress:
+        case QEvent::TabletRelease:
+        case QEvent::TabletEnterProximity:
+        case QEvent::TabletLeaveProximity:
+        case QEvent::TouchBegin:
+        case QEvent::TouchUpdate:
+        case QEvent::TouchEnd:
+        case QEvent::InputMethodQuery:
+        case QEvent::TouchCancel:
+            return QCoreApplication::sendEvent(event_handler, event);
+        case QEvent::Drop:
+            GetMainWindow()->DropAction(static_cast<QDropEvent*>(event));
+            return true;
+        case QEvent::DragResponse:
+        case QEvent::DragEnter:
+        case QEvent::DragLeave:
+        case QEvent::DragMove:
+            GetMainWindow()->AcceptDropEvent(static_cast<QDropEvent*>(event));
+            return true;
+        default:
+            return QWindow::event(event);
+        }
+    }
+
+    void exposeEvent(QExposeEvent* event) override {
+        QWindow::requestUpdate();
+        QWindow::exposeEvent(event);
     }
 
 private:
-    GRenderWindow* parent;
-    bool do_painting = false;
+    QWidget* event_handler{};
 };
 
-// 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 {
+class OpenGLWindow final : public ChildRenderWindow {
 public:
-    GGLWidgetInternal(GRenderWindow* parent, QOpenGLContext* shared_context)
-        : GWidgetInternal(parent), QOpenGLWindow(shared_context) {}
-    ~GGLWidgetInternal() override = default;
+    OpenGLWindow(QWindow* parent, QWidget* event_handler, QOpenGLContext* shared_context)
+        : ChildRenderWindow{parent, event_handler},
+          context(new QOpenGLContext(shared_context->parent())) {
 
-    void paintEvent(QPaintEvent* ev) override {
-        if (IsPaintingEnabled()) {
-            QPainter painter(this);
-        }
+        // disable vsync for any shared contexts
+        auto format = shared_context->format();
+        format.setSwapInterval(Settings::values.use_vsync ? 1 : 0);
+        this->setFormat(format);
+
+        context->setShareContext(shared_context);
+        context->setScreen(this->screen());
+        context->setFormat(format);
+        context->create();
+
+        setSurfaceType(QWindow::OpenGLSurface);
+
+        // TODO: One of these flags might be interesting: WA_OpaquePaintEvent, WA_NoBackground,
+        // WA_DontShowOnScreen, WA_DeleteOnClose
     }
+
+    ~OpenGLWindow() override {
+        context->doneCurrent();
+    }
+
+    void Present() override {
+        if (!isExposed()) {
+            return;
+        }
+
+        context->makeCurrent(this);
+        Core::System::GetInstance().Renderer().TryPresent(100);
+        context->swapBuffers(this);
+        auto f = context->versionFunctions<QOpenGLFunctions_4_3_Core>();
+        f->glFinish();
+        QWindow::requestUpdate();
+    }
+
+private:
+    QOpenGLContext* context{};
 };
 
 #ifdef HAS_VULKAN
-class GVKWidgetInternal final : public GWidgetInternal {
+class VulkanWindow final : public ChildRenderWindow {
 public:
-    GVKWidgetInternal(GRenderWindow* parent, QVulkanInstance* instance) : GWidgetInternal(parent) {
+    VulkanWindow(QWindow* parent, QWidget* event_handler, QVulkanInstance* instance)
+        : ChildRenderWindow{parent, event_handler} {
         setSurfaceType(QSurface::SurfaceType::VulkanSurface);
         setVulkanInstance(instance);
     }
-    ~GVKWidgetInternal() override = default;
+
+    ~VulkanWindow() override = default;
+
+    void Present() override {
+        // TODO(bunnei): ImplementMe
+    }
+
+private:
+    QWidget* event_handler{};
 };
 #endif
 
-GRenderWindow::GRenderWindow(GMainWindow* parent, EmuThread* emu_thread)
-    : QWidget(parent), emu_thread(emu_thread) {
+GRenderWindow::GRenderWindow(QWidget* parent_, EmuThread* emu_thread)
+    : QWidget(parent_), emu_thread(emu_thread) {
     setWindowTitle(QStringLiteral("yuzu %1 | %2-%3")
                        .arg(QString::fromUtf8(Common::g_build_name),
                             QString::fromUtf8(Common::g_scm_branch),
                             QString::fromUtf8(Common::g_scm_desc)));
     setAttribute(Qt::WA_AcceptTouchEvents);
-
+    auto layout = new QHBoxLayout(this);
+    layout->setMargin(0);
+    setLayout(layout);
     InputCommon::Init();
+
+    GMainWindow* parent = GetMainWindow();
     connect(this, &GRenderWindow::FirstFrameDisplayed, parent, &GMainWindow::OnLoadComplete);
 }
 
 GRenderWindow::~GRenderWindow() {
     InputCommon::Shutdown();
-
-    // Avoid an unordered destruction that generates a segfault
-    delete child;
 }
 
-void GRenderWindow::moveContext() {
-    if (!context) {
-        return;
+void GRenderWindow::MakeCurrent() {
+    if (core_context) {
+        core_context->MakeCurrent();
     }
-    DoneCurrent();
-
-    // If the thread started running, move the GL Context to the new thread. Otherwise, move it
-    // back.
-    auto thread = (QThread::currentThread() == qApp->thread() && emu_thread != nullptr)
-                      ? emu_thread
-                      : qApp->thread();
-    context->moveToThread(thread);
 }
 
-void GRenderWindow::SwapBuffers() {
-    if (context) {
-        context->swapBuffers(child);
+void GRenderWindow::DoneCurrent() {
+    if (core_context) {
+        core_context->DoneCurrent();
     }
+}
+
+void GRenderWindow::PollEvents() {
     if (!first_frame) {
         first_frame = true;
         emit FirstFrameDisplayed();
     }
 }
 
-void GRenderWindow::MakeCurrent() {
-    if (context) {
-        context->makeCurrent(child);
-    }
-}
-
-void GRenderWindow::DoneCurrent() {
-    if (context) {
-        context->doneCurrent();
-    }
-}
-
-void GRenderWindow::PollEvents() {}
-
 bool GRenderWindow::IsShown() const {
     return !isMinimized();
 }
@@ -291,7 +314,7 @@ void GRenderWindow::RetrieveVulkanHandlers(void* get_instance_proc_addr, void* i
 #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);
+    const VkSurfaceKHR surface_copy = vk_instance->surfaceForWindow(child_window);
 
     std::memcpy(get_instance_proc_addr, &instance_proc_addr, sizeof(instance_proc_addr));
     std::memcpy(instance, &instance_copy, sizeof(instance_copy));
@@ -309,21 +332,10 @@ void GRenderWindow::RetrieveVulkanHandlers(void* get_instance_proc_addr, void* i
 void GRenderWindow::OnFramebufferSizeChanged() {
     // Screen changes potentially incur a change in screen DPI, hence we should update the
     // framebuffer size
-    const qreal pixelRatio{GetWindowPixelRatio()};
-    const auto size{child->GetSize()};
-    UpdateCurrentFramebufferLayout(size.first * pixelRatio, size.second * pixelRatio);
-}
-
-void GRenderWindow::ForwardKeyPressEvent(QKeyEvent* event) {
-    if (child) {
-        child->keyPressEvent(event);
-    }
-}
-
-void GRenderWindow::ForwardKeyReleaseEvent(QKeyEvent* event) {
-    if (child) {
-        child->keyReleaseEvent(event);
-    }
+    const qreal pixel_ratio = windowPixelRatio();
+    const u32 width = this->width() * pixel_ratio;
+    const u32 height = this->height() * pixel_ratio;
+    UpdateCurrentFramebufferLayout(width, height);
 }
 
 void GRenderWindow::BackupGeometry() {
@@ -351,13 +363,12 @@ QByteArray GRenderWindow::saveGeometry() {
     return geometry;
 }
 
-qreal GRenderWindow::GetWindowPixelRatio() const {
-    // windowHandle() might not be accessible until the window is displayed to screen.
-    return windowHandle() ? windowHandle()->screen()->devicePixelRatio() : 1.0f;
+qreal GRenderWindow::windowPixelRatio() const {
+    return devicePixelRatio();
 }
 
 std::pair<u32, u32> GRenderWindow::ScaleTouch(const QPointF pos) const {
-    const qreal pixel_ratio{GetWindowPixelRatio()};
+    const qreal pixel_ratio = windowPixelRatio();
     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}))};
 }
@@ -367,6 +378,47 @@ void GRenderWindow::closeEvent(QCloseEvent* event) {
     QWidget::closeEvent(event);
 }
 
+void GRenderWindow::keyPressEvent(QKeyEvent* event) {
+    InputCommon::GetKeyboard()->PressKey(event->key());
+}
+
+void GRenderWindow::keyReleaseEvent(QKeyEvent* event) {
+    InputCommon::GetKeyboard()->ReleaseKey(event->key());
+}
+
+void GRenderWindow::mousePressEvent(QMouseEvent* event) {
+    if (event->source() == Qt::MouseEventSynthesizedBySystem)
+        return; // touch input is handled in TouchBeginEvent
+
+    auto pos = event->pos();
+    if (event->button() == Qt::LeftButton) {
+        const auto [x, y] = ScaleTouch(pos);
+        this->TouchPressed(x, y);
+    } else if (event->button() == Qt::RightButton) {
+        InputCommon::GetMotionEmu()->BeginTilt(pos.x(), pos.y());
+    }
+}
+
+void GRenderWindow::mouseMoveEvent(QMouseEvent* event) {
+    if (event->source() == Qt::MouseEventSynthesizedBySystem)
+        return; // touch input is handled in TouchUpdateEvent
+
+    auto pos = event->pos();
+    const auto [x, y] = ScaleTouch(pos);
+    this->TouchMoved(x, y);
+    InputCommon::GetMotionEmu()->Tilt(pos.x(), pos.y());
+}
+
+void GRenderWindow::mouseReleaseEvent(QMouseEvent* event) {
+    if (event->source() == Qt::MouseEventSynthesizedBySystem)
+        return; // touch input is handled in TouchEndEvent
+
+    if (event->button() == Qt::LeftButton)
+        this->TouchReleased();
+    else if (event->button() == Qt::RightButton)
+        InputCommon::GetMotionEmu()->EndTilt();
+}
+
 void GRenderWindow::TouchBeginEvent(const QTouchEvent* event) {
     // TouchBegin always has exactly one touch point, so take the .first()
     const auto [x, y] = ScaleTouch(event->touchPoints().first().pos());
@@ -415,26 +467,20 @@ void GRenderWindow::focusOutEvent(QFocusEvent* event) {
     InputCommon::GetKeyboard()->ReleaseAllKeys();
 }
 
-void GRenderWindow::OnClientAreaResized(u32 width, u32 height) {
-    NotifyClientAreaSizeChanged(std::make_pair(width, height));
+void GRenderWindow::resizeEvent(QResizeEvent* event) {
+    QWidget::resizeEvent(event);
+    OnFramebufferSizeChanged();
 }
 
 std::unique_ptr<Core::Frontend::GraphicsContext> GRenderWindow::CreateSharedContext() const {
-    return std::make_unique<GGLContext>(context.get());
+    if (Settings::values.renderer_backend == Settings::RendererBackend::OpenGL) {
+        return std::make_unique<GGLContext>(QOpenGLContext::globalShareContext());
+    }
+    return {};
 }
 
 bool GRenderWindow::InitRenderTarget() {
-    shared_context.reset();
-    context.reset();
-    if (child) {
-        delete child;
-    }
-    if (container) {
-        delete container;
-    }
-    if (layout()) {
-        delete layout();
-    }
+    ReleaseRenderTarget();
 
     first_frame = false;
 
@@ -451,13 +497,6 @@ bool GRenderWindow::InitRenderTarget() {
         break;
     }
 
-    container = QWidget::createWindowContainer(child, this);
-    QBoxLayout* layout = new QHBoxLayout(this);
-
-    layout->addWidget(container);
-    layout->setMargin(0);
-    setLayout(layout);
-
     // Reset minimum required size to avoid resizing issues on the main window after restarting.
     setMinimumSize(1, 1);
 
@@ -467,14 +506,9 @@ bool GRenderWindow::InitRenderTarget() {
     hide();
 
     resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height);
-    child->resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height);
-    container->resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height);
 
     OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size);
-
     OnFramebufferSizeChanged();
-    NotifyClientAreaSizeChanged(child->GetSize());
-
     BackupGeometry();
 
     if (Settings::values.renderer_backend == Settings::RendererBackend::OpenGL) {
@@ -486,6 +520,14 @@ bool GRenderWindow::InitRenderTarget() {
     return true;
 }
 
+void GRenderWindow::ReleaseRenderTarget() {
+    if (child_widget) {
+        layout()->removeWidget(child_widget);
+        delete child_widget;
+        child_widget = nullptr;
+    }
+}
+
 void GRenderWindow::CaptureScreenshot(u32 res_scale, const QString& screenshot_path) {
     auto& renderer = Core::System::GetInstance().Renderer();
 
@@ -521,16 +563,19 @@ bool GRenderWindow::InitializeOpenGL() {
     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);
+    fmt.setSwapInterval(0);
+    QSurfaceFormat::setDefaultFormat(fmt);
+
+    GMainWindow* parent = GetMainWindow();
+    QWindow* parent_win_handle = parent ? parent->windowHandle() : nullptr;
+    child_window = new OpenGLWindow(parent_win_handle, this, QOpenGLContext::globalShareContext());
+    child_window->create();
+    child_widget = createWindowContainer(child_window, this);
+    child_widget->resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height);
+    layout()->addWidget(child_widget);
+
+    core_context = CreateSharedContext();
 
-    child = new GGLWidgetInternal(this, shared_context.get());
     return true;
 }
 
@@ -559,7 +604,14 @@ bool GRenderWindow::InitializeVulkan() {
         return false;
     }
 
-    child = new GVKWidgetInternal(this, vk_instance.get());
+    GMainWindow* parent = GetMainWindow();
+    QWindow* parent_win_handle = parent ? parent->windowHandle() : nullptr;
+    child_window = new VulkanWindow(parent_win_handle, this, vk_instance.get());
+    child_window->create();
+    child_widget = createWindowContainer(child_window, this);
+    child_widget->resize(Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height);
+    layout()->addWidget(child_widget);
+
     return true;
 #else
     QMessageBox::critical(this, tr("Vulkan not available!"),
@@ -569,7 +621,7 @@ bool GRenderWindow::InitializeVulkan() {
 }
 
 bool GRenderWindow::LoadOpenGL() {
-    Core::Frontend::ScopeAcquireWindowContext acquire_context{*this};
+    Core::Frontend::ScopeAcquireContext 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 "
@@ -621,12 +673,10 @@ QStringList GRenderWindow::GetUnsupportedGLExtensions() const {
 
 void GRenderWindow::OnEmulationStarting(EmuThread* emu_thread) {
     this->emu_thread = emu_thread;
-    child->DisablePainting();
 }
 
 void GRenderWindow::OnEmulationStopping() {
     emu_thread = nullptr;
-    child->EnablePainting();
 }
 
 void GRenderWindow::showEvent(QShowEvent* event) {
diff --git a/src/yuzu/bootmanager.h b/src/yuzu/bootmanager.h
index 71a2fa321..79b030304 100644
--- a/src/yuzu/bootmanager.h
+++ b/src/yuzu/bootmanager.h
@@ -11,11 +11,13 @@
 #include <QImage>
 #include <QThread>
 #include <QWidget>
+#include <QWindow>
 
 #include "common/thread.h"
 #include "core/core.h"
 #include "core/frontend/emu_window.h"
 
+class GRenderWindow;
 class QKeyEvent;
 class QScreen;
 class QTouchEvent;
@@ -26,14 +28,6 @@ class QOpenGLContext;
 class QVulkanInstance;
 #endif
 
-class GWidgetInternal;
-class GGLWidgetInternal;
-class GVKWidgetInternal;
-class GMainWindow;
-class GRenderWindow;
-class QSurface;
-class QOpenGLContext;
-
 namespace VideoCore {
 enum class LoadCallbackStage;
 }
@@ -42,7 +36,7 @@ class EmuThread final : public QThread {
     Q_OBJECT
 
 public:
-    explicit EmuThread(GRenderWindow* render_window);
+    explicit EmuThread(GRenderWindow& window);
     ~EmuThread() override;
 
     /**
@@ -96,7 +90,11 @@ private:
     std::mutex running_mutex;
     std::condition_variable running_cv;
 
-    GRenderWindow* render_window;
+    /// Only used in asynchronous GPU mode
+    std::unique_ptr<Core::Frontend::GraphicsContext> shared_context;
+
+    /// This is shared_context in asynchronous GPU mode, core_context in synchronous GPU mode
+    Core::Frontend::GraphicsContext& context;
 
 signals:
     /**
@@ -126,11 +124,10 @@ class GRenderWindow : public QWidget, public Core::Frontend::EmuWindow {
     Q_OBJECT
 
 public:
-    GRenderWindow(GMainWindow* parent, EmuThread* emu_thread);
+    GRenderWindow(QWidget* parent, EmuThread* emu_thread);
     ~GRenderWindow() override;
 
-    // EmuWindow implementation
-    void SwapBuffers() override;
+    // EmuWindow implementation.
     void MakeCurrent() override;
     void DoneCurrent() override;
     void PollEvents() override;
@@ -139,30 +136,36 @@ public:
                                 void* surface) const override;
     std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override;
 
-    void ForwardKeyPressEvent(QKeyEvent* event);
-    void ForwardKeyReleaseEvent(QKeyEvent* event);
-
     void BackupGeometry();
     void RestoreGeometry();
     void restoreGeometry(const QByteArray& geometry); // overridden
     QByteArray saveGeometry();                        // overridden
 
-    qreal GetWindowPixelRatio() const;
-    std::pair<u32, u32> ScaleTouch(QPointF pos) const;
+    qreal windowPixelRatio() const;
 
     void closeEvent(QCloseEvent* event) override;
+
+    void resizeEvent(QResizeEvent* event) override;
+
+    void keyPressEvent(QKeyEvent* event) override;
+    void keyReleaseEvent(QKeyEvent* event) override;
+
+    void mousePressEvent(QMouseEvent* event) override;
+    void mouseMoveEvent(QMouseEvent* event) override;
+    void mouseReleaseEvent(QMouseEvent* event) override;
+
     bool event(QEvent* event) override;
+
     void focusOutEvent(QFocusEvent* event) override;
 
-    void OnClientAreaResized(u32 width, u32 height);
-
     bool InitRenderTarget();
 
+    /// Destroy the previous run's child_widget which should also destroy the child_window
+    void ReleaseRenderTarget();
+
     void CaptureScreenshot(u32 res_scale, const QString& screenshot_path);
 
 public slots:
-    void moveContext(); // overridden
-
     void OnEmulationStarting(EmuThread* emu_thread);
     void OnEmulationStopping();
     void OnFramebufferSizeChanged();
@@ -173,6 +176,7 @@ signals:
     void FirstFrameDisplayed();
 
 private:
+    std::pair<u32, u32> ScaleTouch(QPointF pos) const;
     void TouchBeginEvent(const QTouchEvent* event);
     void TouchUpdateEvent(const QTouchEvent* event);
     void TouchEndEvent();
@@ -184,15 +188,9 @@ private:
     bool LoadOpenGL();
     QStringList GetUnsupportedGLExtensions() const;
 
-    QWidget* container = nullptr;
-    GWidgetInternal* child = nullptr;
-
     EmuThread* emu_thread;
-    // Context that backs the GGLWidgetInternal (and will be used by core to render)
-    std::unique_ptr<QOpenGLContext> context;
-    // Context that will be shared between all newly created contexts. This should never be made
-    // current
-    std::unique_ptr<QOpenGLContext> shared_context;
+
+    std::unique_ptr<GraphicsContext> core_context;
 
 #ifdef HAS_VULKAN
     std::unique_ptr<QVulkanInstance> vk_instance;
@@ -202,6 +200,15 @@ private:
     QImage screenshot_image;
 
     QByteArray geometry;
+
+    /// Native window handle that backs this presentation widget
+    QWindow* child_window = nullptr;
+
+    /// In order to embed the window into GRenderWindow, you need to use createWindowContainer to
+    /// put the child_window into a widget then add it to the layout. This child_widget can be
+    /// parented to GRenderWindow and use Qt's lifetime system
+    QWidget* child_widget = nullptr;
+
     bool first_frame = false;
 
 protected:
diff --git a/src/yuzu/configuration/config.cpp b/src/yuzu/configuration/config.cpp
index 6209fff75..d0f574147 100644
--- a/src/yuzu/configuration/config.cpp
+++ b/src/yuzu/configuration/config.cpp
@@ -640,6 +640,7 @@ void Config::ReadRendererValues() {
         ReadSetting(QStringLiteral("use_accurate_gpu_emulation"), false).toBool();
     Settings::values.use_asynchronous_gpu_emulation =
         ReadSetting(QStringLiteral("use_asynchronous_gpu_emulation"), false).toBool();
+    Settings::values.use_vsync = ReadSetting(QStringLiteral("use_vsync"), true).toBool();
     Settings::values.force_30fps_mode =
         ReadSetting(QStringLiteral("force_30fps_mode"), false).toBool();
 
@@ -1074,6 +1075,7 @@ void Config::SaveRendererValues() {
                  Settings::values.use_accurate_gpu_emulation, false);
     WriteSetting(QStringLiteral("use_asynchronous_gpu_emulation"),
                  Settings::values.use_asynchronous_gpu_emulation, false);
+    WriteSetting(QStringLiteral("use_vsync"), Settings::values.use_vsync, true);
     WriteSetting(QStringLiteral("force_30fps_mode"), Settings::values.force_30fps_mode, false);
 
     // Cast to double because Qt's written float values are not human-readable
diff --git a/src/yuzu/configuration/configure_graphics.cpp b/src/yuzu/configuration/configure_graphics.cpp
index ea899c080..fe64c7d81 100644
--- a/src/yuzu/configuration/configure_graphics.cpp
+++ b/src/yuzu/configuration/configure_graphics.cpp
@@ -103,6 +103,8 @@ void ConfigureGraphics::SetConfiguration() {
     ui->use_accurate_gpu_emulation->setChecked(Settings::values.use_accurate_gpu_emulation);
     ui->use_asynchronous_gpu_emulation->setEnabled(runtime_lock);
     ui->use_asynchronous_gpu_emulation->setChecked(Settings::values.use_asynchronous_gpu_emulation);
+    ui->use_vsync->setEnabled(runtime_lock);
+    ui->use_vsync->setChecked(Settings::values.use_vsync);
     ui->force_30fps_mode->setEnabled(runtime_lock);
     ui->force_30fps_mode->setChecked(Settings::values.force_30fps_mode);
     UpdateBackgroundColorButton(QColor::fromRgbF(Settings::values.bg_red, Settings::values.bg_green,
@@ -120,6 +122,7 @@ void ConfigureGraphics::ApplyConfiguration() {
     Settings::values.use_accurate_gpu_emulation = ui->use_accurate_gpu_emulation->isChecked();
     Settings::values.use_asynchronous_gpu_emulation =
         ui->use_asynchronous_gpu_emulation->isChecked();
+    Settings::values.use_vsync = ui->use_vsync->isChecked();
     Settings::values.force_30fps_mode = ui->force_30fps_mode->isChecked();
     Settings::values.bg_red = static_cast<float>(bg_color.redF());
     Settings::values.bg_green = static_cast<float>(bg_color.greenF());
diff --git a/src/yuzu/configuration/configure_graphics.ui b/src/yuzu/configuration/configure_graphics.ui
index db60426ab..9acc7dd93 100644
--- a/src/yuzu/configuration/configure_graphics.ui
+++ b/src/yuzu/configuration/configure_graphics.ui
@@ -84,6 +84,16 @@
           </property>
          </widget>
         </item>
+        <item>
+         <widget class="QCheckBox" name="use_vsync">
+          <property name="toolTip">
+           <string>VSync prevents the screen from tearing, but some graphics cards have lower performance with VSync enabled. Keep it enabled if you don't notice a performance difference.</string>
+          </property>
+          <property name="text">
+           <string>Use VSync (OpenGL only)</string>
+          </property>
+         </widget>
+        </item>
         <item>
          <widget class="QCheckBox" name="use_accurate_gpu_emulation">
           <property name="text">
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index 54ca2dc1d..47615adfe 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -20,7 +20,6 @@
 #include "core/file_sys/vfs.h"
 #include "core/file_sys/vfs_real.h"
 #include "core/frontend/applets/general_frontend.h"
-#include "core/frontend/scope_acquire_window_context.h"
 #include "core/hle/service/acc/profile_manager.h"
 #include "core/hle/service/am/applet_ae.h"
 #include "core/hle/service/am/applet_oe.h"
@@ -985,11 +984,8 @@ void GMainWindow::BootGame(const QString& filename) {
         return;
 
     // Create and start the emulation thread
-    emu_thread = std::make_unique<EmuThread>(render_window);
+    emu_thread = std::make_unique<EmuThread>(*render_window);
     emit EmulationStarting(emu_thread.get());
-    if (Settings::values.renderer_backend == Settings::RendererBackend::OpenGL) {
-        render_window->moveContext();
-    }
     emu_thread->start();
 
     connect(render_window, &GRenderWindow::Closed, this, &GMainWindow::OnStopGame);
@@ -1087,6 +1083,9 @@ void GMainWindow::ShutdownGame() {
     emulation_running = false;
 
     game_path.clear();
+
+    // When closing the game, destroy the GLWindow to clear the context after the game is closed
+    render_window->ReleaseRenderTarget();
 }
 
 void GMainWindow::StoreRecentFile(const QString& filename) {
@@ -2215,48 +2214,47 @@ void GMainWindow::closeEvent(QCloseEvent* event) {
     QWidget::closeEvent(event);
 }
 
-void GMainWindow::keyPressEvent(QKeyEvent* event) {
-    if (render_window) {
-        render_window->ForwardKeyPressEvent(event);
+static bool IsSingleFileDropEvent(const QMimeData* mime) {
+    return mime->hasUrls() && mime->urls().length() == 1;
+}
+
+void GMainWindow::AcceptDropEvent(QDropEvent* event) {
+    if (IsSingleFileDropEvent(event->mimeData())) {
+        event->setDropAction(Qt::DropAction::LinkAction);
+        event->accept();
     }
 }
 
-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;
-}
-
-void GMainWindow::dropEvent(QDropEvent* event) {
-    if (!IsSingleFileDropEvent(event)) {
-        return;
+bool GMainWindow::DropAction(QDropEvent* event) {
+    if (!IsSingleFileDropEvent(event->mimeData())) {
+        return false;
     }
 
     const QMimeData* mime_data = event->mimeData();
-    const QString filename = mime_data->urls().at(0).toLocalFile();
+    const QString& filename = mime_data->urls().at(0).toLocalFile();
 
     if (emulation_running && QFileInfo(filename).suffix() == QStringLiteral("bin")) {
+        // Amiibo
         LoadAmiibo(filename);
     } else {
+        // Game
         if (ConfirmChangeGame()) {
             BootGame(filename);
         }
     }
+    return true;
+}
+
+void GMainWindow::dropEvent(QDropEvent* event) {
+    DropAction(event);
 }
 
 void GMainWindow::dragEnterEvent(QDragEnterEvent* event) {
-    if (IsSingleFileDropEvent(event)) {
-        event->acceptProposedAction();
-    }
+    AcceptDropEvent(event);
 }
 
 void GMainWindow::dragMoveEvent(QDragMoveEvent* event) {
-    event->acceptProposedAction();
+    AcceptDropEvent(event);
 }
 
 bool GMainWindow::ConfirmChangeGame() {
@@ -2377,6 +2375,7 @@ int main(int argc, char* argv[]) {
 
     // Enables the core to make the qt created contexts current on std::threads
     QCoreApplication::setAttribute(Qt::AA_DontCheckOpenGLContextThreadAffinity);
+    QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
     QApplication app(argc, argv);
 
     // Qt changes the locale and causes issues in float conversion using std::to_string() when
diff --git a/src/yuzu/main.h b/src/yuzu/main.h
index 8eba2172c..a67125567 100644
--- a/src/yuzu/main.h
+++ b/src/yuzu/main.h
@@ -78,6 +78,9 @@ public:
 
     std::unique_ptr<DiscordRPC::DiscordInterface> discord_rpc;
 
+    bool DropAction(QDropEvent* event);
+    void AcceptDropEvent(QDropEvent* event);
+
 signals:
 
     /**
@@ -264,8 +267,4 @@ protected:
     void dropEvent(QDropEvent* event) override;
     void dragEnterEvent(QDragEnterEvent* event) override;
     void dragMoveEvent(QDragMoveEvent* event) override;
-
-    // Overrides used to forward signals to the render window when the focus moves out.
-    void keyPressEvent(QKeyEvent* event) override;
-    void keyReleaseEvent(QKeyEvent* event) override;
 };
diff --git a/src/yuzu_cmd/config.cpp b/src/yuzu_cmd/config.cpp
index 96f1ce3af..b77c12baf 100644
--- a/src/yuzu_cmd/config.cpp
+++ b/src/yuzu_cmd/config.cpp
@@ -390,6 +390,8 @@ void Config::ReadValues() {
         sdl2_config->GetBoolean("Renderer", "use_accurate_gpu_emulation", false);
     Settings::values.use_asynchronous_gpu_emulation =
         sdl2_config->GetBoolean("Renderer", "use_asynchronous_gpu_emulation", false);
+    Settings::values.use_vsync =
+        static_cast<u16>(sdl2_config->GetInteger("Renderer", "use_vsync", 1));
 
     Settings::values.bg_red = static_cast<float>(sdl2_config->GetReal("Renderer", "bg_red", 0.0));
     Settings::values.bg_green =
diff --git a/src/yuzu_cmd/default_ini.h b/src/yuzu_cmd/default_ini.h
index 8a2b658cd..df7473858 100644
--- a/src/yuzu_cmd/default_ini.h
+++ b/src/yuzu_cmd/default_ini.h
@@ -150,6 +150,11 @@ use_accurate_gpu_emulation =
 # 0 : Off (slow), 1 (default): On (fast)
 use_asynchronous_gpu_emulation =
 
+# Forces VSync on the display thread. Usually doesn't impact performance, but on some drivers it can
+# so only turn this off if you notice a speed difference.
+# 0: Off, 1 (default): On
+use_vsync =
+
 # The clear color for the renderer. What shows up on the sides of the bottom screen.
 # Must be in range of 0.0-1.0. Defaults to 1.0 for all.
 bg_red =
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp b/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp
index e96139885..19584360c 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2.cpp
@@ -13,7 +13,7 @@
 #include "input_common/sdl/sdl.h"
 #include "yuzu_cmd/emu_window/emu_window_sdl2.h"
 
-EmuWindow_SDL2::EmuWindow_SDL2(bool fullscreen) {
+EmuWindow_SDL2::EmuWindow_SDL2(Core::System& system, bool fullscreen) : system{system} {
     if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK) < 0) {
         LOG_CRITICAL(Frontend, "Failed to initialize SDL2! Exiting...");
         exit(1);
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2.h b/src/yuzu_cmd/emu_window/emu_window_sdl2.h
index b38f56661..fffac4252 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2.h
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2.h
@@ -10,9 +10,13 @@
 
 struct SDL_Window;
 
+namespace Core {
+class System;
+}
+
 class EmuWindow_SDL2 : public Core::Frontend::EmuWindow {
 public:
-    explicit EmuWindow_SDL2(bool fullscreen);
+    explicit EmuWindow_SDL2(Core::System& system, bool fullscreen);
     ~EmuWindow_SDL2();
 
     /// Polls window events
@@ -24,6 +28,9 @@ public:
     /// Returns if window is shown (not minimized)
     bool IsShown() const override;
 
+    /// Presents the next frame
+    virtual void Present() = 0;
+
 protected:
     /// Called by PollEvents when a key is pressed or released.
     void OnKeyEvent(int key, u8 state);
@@ -55,6 +62,9 @@ protected:
     /// Called when a configuration change affects the minimal size of the window
     void OnMinimalClientAreaChangeRequest(std::pair<unsigned, unsigned> minimal_size) override;
 
+    /// Instance of the system, used to access renderer for the presentation thread
+    Core::System& system;
+
     /// Is the window still open?
     bool is_open = true;
 
@@ -62,7 +72,7 @@ protected:
     bool is_shown = true;
 
     /// Internal SDL2 render window
-    SDL_Window* render_window;
+    SDL_Window* render_window{};
 
     /// Keeps track of how often to update the title bar during gameplay
     u32 last_time = 0;
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 7ffa0ac09..c0d373477 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.cpp
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.cpp
@@ -13,24 +13,25 @@
 #include "common/logging/log.h"
 #include "common/scm_rev.h"
 #include "common/string_util.h"
+#include "core/core.h"
 #include "core/settings.h"
 #include "input_common/keyboard.h"
 #include "input_common/main.h"
 #include "input_common/motion_emu.h"
+#include "video_core/renderer_base.h"
 #include "yuzu_cmd/emu_window/emu_window_sdl2_gl.h"
 
 class SDLGLContext : public Core::Frontend::GraphicsContext {
 public:
     explicit SDLGLContext() {
         // create a hidden window to make the shared context against
-        window = SDL_CreateWindow("", SDL_WINDOWPOS_UNDEFINED, // x position
-                                  SDL_WINDOWPOS_UNDEFINED,     // y position
-                                  Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height,
-                                  SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN);
+        window = SDL_CreateWindow(NULL, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 0, 0,
+                                  SDL_WINDOW_HIDDEN | SDL_WINDOW_OPENGL);
         context = SDL_GL_CreateContext(window);
     }
 
     ~SDLGLContext() {
+        DoneCurrent();
         SDL_GL_DeleteContext(context);
         SDL_DestroyWindow(window);
     }
@@ -43,8 +44,6 @@ public:
         SDL_GL_MakeCurrent(window, nullptr);
     }
 
-    void SwapBuffers() override {}
-
 private:
     SDL_Window* window;
     SDL_GLContext context;
@@ -80,7 +79,8 @@ bool EmuWindow_SDL2_GL::SupportsRequiredGLExtensions() {
     return unsupported_ext.empty();
 }
 
-EmuWindow_SDL2_GL::EmuWindow_SDL2_GL(bool fullscreen) : EmuWindow_SDL2(fullscreen) {
+EmuWindow_SDL2_GL::EmuWindow_SDL2_GL(Core::System& system, bool fullscreen)
+    : EmuWindow_SDL2{system, fullscreen} {
     SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4);
     SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
     SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_COMPATIBILITY);
@@ -90,6 +90,7 @@ EmuWindow_SDL2_GL::EmuWindow_SDL2_GL(bool fullscreen) : EmuWindow_SDL2(fullscree
     SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
     SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 0);
     SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1);
+    SDL_GL_SetSwapInterval(0);
 
     std::string window_title = fmt::format("yuzu {} | {}-{}", Common::g_build_fullname,
                                            Common::g_scm_branch, Common::g_scm_desc);
@@ -105,13 +106,22 @@ EmuWindow_SDL2_GL::EmuWindow_SDL2_GL(bool fullscreen) : EmuWindow_SDL2(fullscree
         exit(1);
     }
 
+    dummy_window = SDL_CreateWindow(NULL, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 0, 0,
+                                    SDL_WINDOW_HIDDEN | SDL_WINDOW_OPENGL);
+
     if (fullscreen) {
         Fullscreen();
     }
-    gl_context = SDL_GL_CreateContext(render_window);
 
-    if (gl_context == nullptr) {
-        LOG_CRITICAL(Frontend, "Failed to create SDL2 GL context! {}", SDL_GetError());
+    window_context = SDL_GL_CreateContext(render_window);
+    core_context = CreateSharedContext();
+
+    if (window_context == nullptr) {
+        LOG_CRITICAL(Frontend, "Failed to create SDL2 GL context: {}", SDL_GetError());
+        exit(1);
+    }
+    if (core_context == nullptr) {
+        LOG_CRITICAL(Frontend, "Failed to create shared SDL2 GL context: {}", SDL_GetError());
         exit(1);
     }
 
@@ -128,28 +138,22 @@ EmuWindow_SDL2_GL::EmuWindow_SDL2_GL(bool fullscreen) : EmuWindow_SDL2(fullscree
     OnResize();
     OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size);
     SDL_PumpEvents();
-    SDL_GL_SetSwapInterval(false);
     LOG_INFO(Frontend, "yuzu Version: {} | {}-{}", Common::g_build_fullname, Common::g_scm_branch,
              Common::g_scm_desc);
     Settings::LogSettings();
-
-    DoneCurrent();
 }
 
 EmuWindow_SDL2_GL::~EmuWindow_SDL2_GL() {
-    SDL_GL_DeleteContext(gl_context);
-}
-
-void EmuWindow_SDL2_GL::SwapBuffers() {
-    SDL_GL_SwapWindow(render_window);
+    core_context.reset();
+    SDL_GL_DeleteContext(window_context);
 }
 
 void EmuWindow_SDL2_GL::MakeCurrent() {
-    SDL_GL_MakeCurrent(render_window, gl_context);
+    core_context->MakeCurrent();
 }
 
 void EmuWindow_SDL2_GL::DoneCurrent() {
-    SDL_GL_MakeCurrent(render_window, nullptr);
+    core_context->DoneCurrent();
 }
 
 void EmuWindow_SDL2_GL::RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
@@ -161,3 +165,13 @@ void EmuWindow_SDL2_GL::RetrieveVulkanHandlers(void* get_instance_proc_addr, voi
 std::unique_ptr<Core::Frontend::GraphicsContext> EmuWindow_SDL2_GL::CreateSharedContext() const {
     return std::make_unique<SDLGLContext>();
 }
+
+void EmuWindow_SDL2_GL::Present() {
+    SDL_GL_MakeCurrent(render_window, window_context);
+    SDL_GL_SetSwapInterval(Settings::values.use_vsync ? 1 : 0);
+    while (IsOpen()) {
+        system.Renderer().TryPresent(100);
+        SDL_GL_SwapWindow(render_window);
+    }
+    SDL_GL_MakeCurrent(render_window, nullptr);
+}
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 c753085a8..b80669ff0 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_gl.h
@@ -10,17 +10,12 @@
 
 class EmuWindow_SDL2_GL final : public EmuWindow_SDL2 {
 public:
-    explicit EmuWindow_SDL2_GL(bool fullscreen);
+    explicit EmuWindow_SDL2_GL(Core::System& system, bool fullscreen);
     ~EmuWindow_SDL2_GL();
 
-    /// 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;
+    void Present() override;
 
     /// Ignored in OpenGL
     void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
@@ -29,10 +24,17 @@ public:
     std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override;
 
 private:
+    /// Fake hidden window for the core context
+    SDL_Window* dummy_window{};
+
     /// Whether the GPU and driver supports the OpenGL extension required
     bool SupportsRequiredGLExtensions();
 
     using SDL_GLContext = void*;
+
     /// The OpenGL context associated with the window
-    SDL_GLContext gl_context;
+    SDL_GLContext window_context;
+
+    /// The OpenGL context associated with the core
+    std::unique_ptr<Core::Frontend::GraphicsContext> core_context;
 };
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp
index a203f0da9..abcc58165 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.cpp
@@ -15,7 +15,8 @@
 #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) {
+EmuWindow_SDL2_VK::EmuWindow_SDL2_VK(Core::System& system, bool fullscreen)
+    : EmuWindow_SDL2{system, fullscreen} {
     if (SDL_Vulkan_LoadLibrary(nullptr) != 0) {
         LOG_CRITICAL(Frontend, "SDL failed to load the Vulkan library: {}", SDL_GetError());
         exit(EXIT_FAILURE);
@@ -110,8 +111,6 @@ EmuWindow_SDL2_VK::~EmuWindow_SDL2_VK() {
     vkDestroyInstance(vk_instance, nullptr);
 }
 
-void EmuWindow_SDL2_VK::SwapBuffers() {}
-
 void EmuWindow_SDL2_VK::MakeCurrent() {
     // Unused on Vulkan
 }
@@ -160,3 +159,7 @@ bool EmuWindow_SDL2_VK::UseStandardLayers(PFN_vkGetInstanceProcAddr vkGetInstanc
                return layer.layerName == std::string("VK_LAYER_LUNARG_standard_validation");
            }) != layers.end();
 }
+
+void EmuWindow_SDL2_VK::Present() {
+    // TODO (bunnei): ImplementMe
+}
diff --git a/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h
index 2a7c06a24..1eb8c0868 100644
--- a/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h
+++ b/src/yuzu_cmd/emu_window/emu_window_sdl2_vk.h
@@ -10,19 +10,12 @@
 
 class EmuWindow_SDL2_VK final : public EmuWindow_SDL2 {
 public:
-    explicit EmuWindow_SDL2_VK(bool fullscreen);
+    explicit EmuWindow_SDL2_VK(Core::System& system, 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 Present() override;
     void RetrieveVulkanHandlers(void* get_instance_proc_addr, void* instance,
                                 void* surface) const override;
 
diff --git a/src/yuzu_cmd/yuzu.cpp b/src/yuzu_cmd/yuzu.cpp
index 325795321..babf4c3a4 100644
--- a/src/yuzu_cmd/yuzu.cpp
+++ b/src/yuzu_cmd/yuzu.cpp
@@ -177,14 +177,16 @@ int main(int argc, char** argv) {
     Settings::values.use_gdbstub = use_gdbstub;
     Settings::Apply();
 
+    Core::System& system{Core::System::GetInstance()};
+
     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);
+        emu_window = std::make_unique<EmuWindow_SDL2_GL>(system, fullscreen);
         break;
     case Settings::RendererBackend::Vulkan:
 #ifdef HAS_VULKAN
-        emu_window = std::make_unique<EmuWindow_SDL2_VK>(fullscreen);
+        emu_window = std::make_unique<EmuWindow_SDL2_VK>(system, fullscreen);
         break;
 #else
         LOG_CRITICAL(Frontend, "Vulkan backend has not been compiled!");
@@ -192,12 +194,6 @@ int main(int argc, char** argv) {
 #endif
     }
 
-    if (!Settings::values.use_multi_core) {
-        // Single core mode must acquire OpenGL context for entire emulation session
-        emu_window->MakeCurrent();
-    }
-
-    Core::System& system{Core::System::GetInstance()};
     system.SetContentProvider(std::make_unique<FileSys::ContentProviderUnion>());
     system.SetFilesystem(std::make_shared<FileSys::RealVfsFilesystem>());
     system.GetFileSystemController().CreateFactories(*system.GetFilesystem());
@@ -234,12 +230,23 @@ int main(int argc, char** argv) {
 
     system.TelemetrySession().AddField(Telemetry::FieldType::App, "Frontend", "SDL");
 
-    emu_window->MakeCurrent();
     system.Renderer().Rasterizer().LoadDiskResources();
 
+    // Acquire render context for duration of the thread if this is the rendering thread
+    if (!Settings::values.use_asynchronous_gpu_emulation) {
+        emu_window->MakeCurrent();
+    }
+    SCOPE_EXIT({
+        if (!Settings::values.use_asynchronous_gpu_emulation) {
+            emu_window->DoneCurrent();
+        }
+    });
+
+    std::thread render_thread([&emu_window] { emu_window->Present(); });
     while (emu_window->IsOpen()) {
         system.RunLoop();
     }
+    render_thread.join();
 
     system.Shutdown();
 
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 f2cc4a797..a1bdb1a12 100644
--- a/src/yuzu_tester/emu_window/emu_window_sdl2_hide.cpp
+++ b/src/yuzu_tester/emu_window/emu_window_sdl2_hide.cpp
@@ -112,10 +112,6 @@ EmuWindow_SDL2_Hide::~EmuWindow_SDL2_Hide() {
     SDL_Quit();
 }
 
-void EmuWindow_SDL2_Hide::SwapBuffers() {
-    SDL_GL_SwapWindow(render_window);
-}
-
 void EmuWindow_SDL2_Hide::PollEvents() {}
 
 void EmuWindow_SDL2_Hide::MakeCurrent() {
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 c7fccc002..b13e15309 100644
--- a/src/yuzu_tester/emu_window/emu_window_sdl2_hide.h
+++ b/src/yuzu_tester/emu_window/emu_window_sdl2_hide.h
@@ -13,9 +13,6 @@ public:
     explicit EmuWindow_SDL2_Hide();
     ~EmuWindow_SDL2_Hide();
 
-    /// Swap buffers to display the next frame
-    void SwapBuffers() override;
-
     /// Polls window events
     void PollEvents() override;