diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9406dd64a1..01b752d97d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -7,7 +7,7 @@ cmake_minimum_required(VERSION 3.22)
 set(CMAKE_XCODE_GENERATE_TOP_LEVEL_PROJECT OFF)
 set(CMAKE_XCODE_EMIT_RELATIVE_PATH YES)
 
-project(suyu)
+project(suyu LANGUAGES C CXX OBJC OBJCXX)
 
 list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/CMakeModules")
 list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modules")
diff --git a/src/common/settings_enums.h b/src/common/settings_enums.h
index fabae6d687..09006f6223 100644
--- a/src/common/settings_enums.h
+++ b/src/common/settings_enums.h
@@ -124,7 +124,7 @@ ENUM(VSyncMode, Immediate, Mailbox, Fifo, FifoRelaxed);
 
 ENUM(VramUsageMode, Conservative, Aggressive);
 
-ENUM(RendererBackend, OpenGL, Vulkan, Null);
+ENUM(RendererBackend, OpenGL, Vulkan, Metal, Null);
 
 ENUM(ShaderBackend, Glsl, Glasm, SpirV);
 
diff --git a/src/suyu/bootmanager.cpp b/src/suyu/bootmanager.cpp
index 606325bf5e..023ece4868 100644
--- a/src/suyu/bootmanager.cpp
+++ b/src/suyu/bootmanager.cpp
@@ -277,6 +277,14 @@ struct VulkanRenderWidget : public RenderWidget {
     }
 };
 
+struct MetalRenderWidget : public RenderWidget {
+    explicit MetalRenderWidget(GRenderWindow* parent) : RenderWidget(parent) {
+        // HACK: manually resize the renderable area
+        resize(600, 400);
+        windowHandle()->setSurfaceType(QWindow::MetalSurface);
+    }
+};
+
 struct NullRenderWidget : public RenderWidget {
     explicit NullRenderWidget(GRenderWindow* parent) : RenderWidget(parent) {}
 };
@@ -284,8 +292,8 @@ struct NullRenderWidget : public RenderWidget {
 GRenderWindow::GRenderWindow(GMainWindow* parent, EmuThread* emu_thread_,
                              std::shared_ptr<InputCommon::InputSubsystem> input_subsystem_,
                              Core::System& system_)
-    : QWidget(parent),
-      emu_thread(emu_thread_), input_subsystem{std::move(input_subsystem_)}, system{system_} {
+    : QWidget(parent), emu_thread(emu_thread_), input_subsystem{std::move(input_subsystem_)},
+      system{system_} {
     setWindowTitle(QStringLiteral("suyu %1 | %2-%3")
                        .arg(QString::fromUtf8(Common::g_build_name),
                             QString::fromUtf8(Common::g_scm_branch),
@@ -933,6 +941,13 @@ bool GRenderWindow::InitRenderTarget() {
             return false;
         }
         break;
+#ifdef __APPLE__
+    case Settings::RendererBackend::Metal:
+        if (!InitializeMetal()) {
+            return false;
+        }
+        break;
+#endif
     case Settings::RendererBackend::Null:
         InitializeNull();
         break;
@@ -1048,6 +1063,15 @@ bool GRenderWindow::InitializeVulkan() {
     return true;
 }
 
+bool GRenderWindow::InitializeMetal() {
+    auto child = new MetalRenderWidget(this);
+    child_widget = child;
+    child_widget->windowHandle()->create();
+    main_context = std::make_unique<DummyContext>();
+
+    return true;
+}
+
 void GRenderWindow::InitializeNull() {
     child_widget = new NullRenderWidget(this);
     main_context = std::make_unique<DummyContext>();
diff --git a/src/suyu/bootmanager.h b/src/suyu/bootmanager.h
index 36eb956ec7..f301b812c7 100644
--- a/src/suyu/bootmanager.h
+++ b/src/suyu/bootmanager.h
@@ -239,6 +239,7 @@ private:
 
     bool InitializeOpenGL();
     bool InitializeVulkan();
+    bool InitializeMetal();
     void InitializeNull();
     bool LoadOpenGL();
     QStringList GetUnsupportedGLExtensions() const;
diff --git a/src/suyu/configuration/configure_graphics.cpp b/src/suyu/configuration/configure_graphics.cpp
index d11110a74a..0810009125 100644
--- a/src/suyu/configuration/configure_graphics.cpp
+++ b/src/suyu/configuration/configure_graphics.cpp
@@ -466,6 +466,9 @@ void ConfigureGraphics::ApplyConfiguration() {
             Settings::values.vulkan_device.SetGlobal(Settings::IsConfiguringGlobal());
             Settings::values.vulkan_device.SetValue(vulkan_device_combobox->currentIndex());
             break;
+        case Settings::RendererBackend::Metal:
+            // TODO
+            break;
         case Settings::RendererBackend::Null:
             break;
         }
diff --git a/src/suyu/configuration/shared_translation.cpp b/src/suyu/configuration/shared_translation.cpp
index 0e4d13bc64..90548d4fe5 100644
--- a/src/suyu/configuration/shared_translation.cpp
+++ b/src/suyu/configuration/shared_translation.cpp
@@ -331,6 +331,9 @@ std::unique_ptr<ComboboxTranslationMap> ComboboxEnumeration(QWidget* parent) {
                               PAIR(RendererBackend, OpenGL, tr("OpenGL")),
 #endif
                               PAIR(RendererBackend, Vulkan, tr("Vulkan")),
+#ifdef __APPLE__
+                              PAIR(RendererBackend, Metal, tr("Metal")),
+#endif
                               PAIR(RendererBackend, Null, tr("Null")),
                           }});
     translations->insert(
diff --git a/src/suyu/configuration/shared_translation.h b/src/suyu/configuration/shared_translation.h
index d5fc3b8def..9cc8ba6a31 100644
--- a/src/suyu/configuration/shared_translation.h
+++ b/src/suyu/configuration/shared_translation.h
@@ -56,6 +56,9 @@ static const std::map<Settings::GpuAccuracy, QString> gpu_accuracy_texts_map = {
 static const std::map<Settings::RendererBackend, QString> renderer_backend_texts_map = {
     {Settings::RendererBackend::Vulkan, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Vulkan"))},
     {Settings::RendererBackend::OpenGL, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "OpenGL"))},
+#ifdef __APPLE__
+    {Settings::RendererBackend::Metal, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Metal"))},
+#endif
     {Settings::RendererBackend::Null, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Null"))},
 };
 
diff --git a/src/suyu/main.cpp b/src/suyu/main.cpp
index 9a3ee7f662..a23ba9e17d 100644
--- a/src/suyu/main.cpp
+++ b/src/suyu/main.cpp
@@ -3800,6 +3800,8 @@ void GMainWindow::OnToggleGraphicsAPI() {
     } else {
 #ifdef HAS_OPENGL
         api = Settings::RendererBackend::OpenGL;
+#elif __APPLE__
+        api = Settings::RendererBackend::Metal;
 #else
         api = Settings::RendererBackend::Null;
 #endif
diff --git a/src/suyu_cmd/CMakeLists.txt b/src/suyu_cmd/CMakeLists.txt
index 45cc281218..3aba7800ae 100644
--- a/src/suyu_cmd/CMakeLists.txt
+++ b/src/suyu_cmd/CMakeLists.txt
@@ -21,6 +21,8 @@ add_executable(suyu-cmd
     emu_window/emu_window_sdl2_null.h
     emu_window/emu_window_sdl2_vk.cpp
     emu_window/emu_window_sdl2_vk.h
+    emu_window/emu_window_sdl2_mtl.cpp
+    emu_window/emu_window_sdl2_mtl.h
     precompiled_headers.h
     sdl_config.cpp
     sdl_config.h
diff --git a/src/suyu_cmd/emu_window/emu_window_sdl2_mtl.cpp b/src/suyu_cmd/emu_window/emu_window_sdl2_mtl.cpp
new file mode 100644
index 0000000000..d84b939586
--- /dev/null
+++ b/src/suyu_cmd/emu_window/emu_window_sdl2_mtl.cpp
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <cstdlib>
+#include <memory>
+#include <string>
+
+#include <fmt/format.h>
+
+#include "common/logging/log.h"
+#include "common/scm_rev.h"
+#include "suyu_cmd/emu_window/emu_window_sdl2_mtl.h"
+#include "video_core/renderer_metal/renderer_metal.h"
+
+#include <SDL.h>
+#include <SDL_syswm.h>
+
+EmuWindow_SDL2_MTL::EmuWindow_SDL2_MTL(InputCommon::InputSubsystem* input_subsystem_,
+                                       Core::System& system_, bool fullscreen)
+    : EmuWindow_SDL2{input_subsystem_, system_} {
+    const std::string window_title = fmt::format("suyu {} | {}-{} (Vulkan)", Common::g_build_name,
+                                                 Common::g_scm_branch, Common::g_scm_desc);
+    render_window =
+        SDL_CreateWindow(window_title.c_str(), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
+                         Layout::ScreenUndocked::Width, Layout::ScreenUndocked::Height,
+                         SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
+
+    SDL_SysWMinfo wm;
+    SDL_VERSION(&wm.version);
+    if (SDL_GetWindowWMInfo(render_window, &wm) == SDL_FALSE) {
+        LOG_CRITICAL(Frontend, "Failed to get information from the window manager: {}",
+                     SDL_GetError());
+        std::exit(EXIT_FAILURE);
+    }
+
+    SetWindowIcon();
+
+    if (fullscreen) {
+        Fullscreen();
+        ShowCursor(false);
+    }
+
+    switch (wm.subsystem) {
+#ifdef SDL_VIDEO_DRIVER_COCOA
+    case SDL_SYSWM_TYPE::SDL_SYSWM_COCOA:
+        window_info.type = Core::Frontend::WindowSystemType::Cocoa;
+        window_info.render_surface = SDL_Metal_CreateView(render_window);
+        break;
+#endif
+    default:
+        LOG_CRITICAL(Frontend, "Window manager subsystem {} not implemented", wm.subsystem);
+        std::exit(EXIT_FAILURE);
+        break;
+    }
+
+    OnResize();
+    OnMinimalClientAreaChangeRequest(GetActiveConfig().min_client_area_size);
+    SDL_PumpEvents();
+    LOG_INFO(Frontend, "suyu Version: {} | {}-{} (Vulkan)", Common::g_build_name,
+             Common::g_scm_branch, Common::g_scm_desc);
+}
+
+EmuWindow_SDL2_MTL::~EmuWindow_SDL2_MTL() = default;
+
+std::unique_ptr<Core::Frontend::GraphicsContext> EmuWindow_SDL2_MTL::CreateSharedContext() const {
+    return std::make_unique<DummyContext>();
+}
diff --git a/src/suyu_cmd/emu_window/emu_window_sdl2_mtl.h b/src/suyu_cmd/emu_window/emu_window_sdl2_mtl.h
new file mode 100644
index 0000000000..c4fcbe5d68
--- /dev/null
+++ b/src/suyu_cmd/emu_window/emu_window_sdl2_mtl.h
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <memory>
+
+#include "core/frontend/emu_window.h"
+#include "suyu_cmd/emu_window/emu_window_sdl2.h"
+
+namespace Core {
+class System;
+}
+
+namespace InputCommon {
+class InputSubsystem;
+}
+
+class EmuWindow_SDL2_MTL final : public EmuWindow_SDL2 {
+public:
+    explicit EmuWindow_SDL2_MTL(InputCommon::InputSubsystem* input_subsystem_, Core::System& system,
+                                bool fullscreen);
+    ~EmuWindow_SDL2_MTL() override;
+
+    std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override;
+};
diff --git a/src/suyu_cmd/suyu.cpp b/src/suyu_cmd/suyu.cpp
index c9c35464e6..12114b565d 100644
--- a/src/suyu_cmd/suyu.cpp
+++ b/src/suyu_cmd/suyu.cpp
@@ -38,6 +38,7 @@
 #include "sdl_config.h"
 #include "suyu_cmd/emu_window/emu_window_sdl2.h"
 #include "suyu_cmd/emu_window/emu_window_sdl2_gl.h"
+#include "suyu_cmd/emu_window/emu_window_sdl2_mtl.h"
 #include "suyu_cmd/emu_window/emu_window_sdl2_null.h"
 #include "suyu_cmd/emu_window/emu_window_sdl2_vk.h"
 #include "video_core/renderer_base.h"
@@ -385,6 +386,9 @@ int main(int argc, char** argv) {
     case Settings::RendererBackend::Vulkan:
         emu_window = std::make_unique<EmuWindow_SDL2_VK>(&input_subsystem, system, fullscreen);
         break;
+    case Settings::RendererBackend::Metal:
+        emu_window = std::make_unique<EmuWindow_SDL2_MTL>(&input_subsystem, system, fullscreen);
+        break;
     case Settings::RendererBackend::Null:
         emu_window = std::make_unique<EmuWindow_SDL2_Null>(&input_subsystem, system, fullscreen);
         break;
diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt
index 0afa3d7036..3306fe69ee 100644
--- a/src/video_core/CMakeLists.txt
+++ b/src/video_core/CMakeLists.txt
@@ -372,6 +372,17 @@ if (APPLE)
         renderer_opengl/util_shaders.cpp
         renderer_opengl/util_shaders.h
     )
+
+    list(APPEND sources
+        renderer_metal/mtl_command_recorder.mm
+        renderer_metal/mtl_device.mm
+        renderer_metal/mtl_rasterizer.mm
+        renderer_metal/mtl_staging_buffer_pool.mm
+        renderer_metal/mtl_swap_chain.mm
+        renderer_metal/mtl_texture_cache.mm
+        renderer_metal/mtl_texture_cache_base.cpp
+        renderer_metal/renderer_metal.mm
+    )
 endif()
 
 add_library(video_core STATIC ${sources})
diff --git a/src/video_core/renderer_metal/mtl_command_recorder.h b/src/video_core/renderer_metal/mtl_command_recorder.h
new file mode 100644
index 0000000000..81895572f9
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_command_recorder.h
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "objc_bridge.h"
+#include "video_core/renderer_metal/objc_bridge.h"
+
+namespace Metal {
+
+class Device;
+
+enum class EncoderType { Render, Compute, Blit };
+
+class CommandRecorder {
+public:
+    CommandRecorder(const Device& device_);
+    ~CommandRecorder();
+
+    void BeginRenderPass(MTLRenderPassDescriptor* render_pass_descriptor);
+
+    void CheckIfRenderPassIsActive() {
+        if (!encoder || encoder_type != EncoderType::Render) {
+            throw std::runtime_error(
+                "Trying to perform render command, but render pass is not active");
+        }
+    }
+
+    void RequireComputeEncoder();
+
+    void RequireBlitEncoder();
+
+    void EndEncoding();
+
+    void Present(CAMetalDrawable_t drawable);
+
+    void Submit();
+
+    MTLCommandBuffer_t GetCommandBuffer() {
+        return command_buffer;
+    }
+
+    MTLCommandEncoder_t GetCommandEncoder() {
+        return encoder;
+    }
+
+private:
+    const Device& device;
+
+    MTLCommandBuffer_t command_buffer = nil;
+    MTLCommandEncoder_t encoder = nil;
+
+    EncoderType encoder_type;
+
+    void RequireCommandBuffer();
+};
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_command_recorder.mm b/src/video_core/renderer_metal/mtl_command_recorder.mm
new file mode 100644
index 0000000000..b005d55b66
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_command_recorder.mm
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "video_core/renderer_metal/mtl_command_recorder.h"
+#include "video_core/renderer_metal/mtl_device.h"
+
+#include <iostream>
+
+namespace Metal {
+
+CommandRecorder::CommandRecorder(const Device& device_) : device(device_) {}
+
+CommandRecorder::~CommandRecorder() = default;
+
+void CommandRecorder::BeginRenderPass(MTLRenderPassDescriptor* render_pass_descriptor) {
+    RequireCommandBuffer();
+    EndEncoding();
+    encoder = [command_buffer renderCommandEncoderWithDescriptor:render_pass_descriptor];
+    encoder_type = EncoderType::Render;
+}
+
+void CommandRecorder::RequireComputeEncoder() {
+    RequireCommandBuffer();
+    if (!encoder || encoder_type != EncoderType::Compute) {
+        EndEncoding();
+        encoder = [command_buffer computeCommandEncoder];
+        encoder_type = EncoderType::Compute;
+    }
+}
+
+void CommandRecorder::RequireBlitEncoder() {
+    RequireCommandBuffer();
+    if (!encoder || encoder_type != EncoderType::Blit) {
+        EndEncoding();
+        encoder = [command_buffer blitCommandEncoder];
+        encoder_type = EncoderType::Blit;
+    }
+}
+
+void CommandRecorder::EndEncoding() {
+    if (encoder) {
+        [encoder endEncoding];
+        [encoder release];
+        encoder = nil;
+    }
+}
+
+void CommandRecorder::Present(CAMetalDrawable_t drawable) {
+    [command_buffer presentDrawable:drawable];
+}
+
+void CommandRecorder::Submit() {
+    if (command_buffer) {
+        EndEncoding();
+        [command_buffer commit];
+        [command_buffer release];
+        command_buffer = nil;
+    }
+}
+
+void CommandRecorder::RequireCommandBuffer() {
+    if (!command_buffer) {
+        command_buffer = [device.GetCommandQueue() commandBuffer];
+    }
+}
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_device.h b/src/video_core/renderer_metal/mtl_device.h
new file mode 100644
index 0000000000..d6414cd222
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_device.h
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "video_core/renderer_metal/objc_bridge.h"
+
+namespace Metal {
+
+class Device {
+public:
+    explicit Device();
+    ~Device();
+
+    MTLDevice_t GetDevice() const {
+        return device;
+    }
+
+    MTLCommandQueue_t GetCommandQueue() const {
+        return command_queue;
+    }
+
+private:
+    MTLDevice_t device;
+    MTLCommandQueue_t command_queue;
+};
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_device.mm b/src/video_core/renderer_metal/mtl_device.mm
new file mode 100644
index 0000000000..2c79381faa
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_device.mm
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "video_core/renderer_metal/mtl_device.h"
+
+namespace Metal {
+
+Device::Device() {
+    device = MTLCreateSystemDefaultDevice();
+    if (!device) {
+        throw std::runtime_error("Failed to create Metal device");
+    }
+    command_queue = [device newCommandQueue];
+    if (!command_queue) {
+        throw std::runtime_error("Failed to create Metal command queue");
+    }
+}
+
+Device::~Device() {
+    [command_queue release];
+    [device release];
+}
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_rasterizer.h b/src/video_core/renderer_metal/mtl_rasterizer.h
new file mode 100644
index 0000000000..9ea17af354
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_rasterizer.h
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "common/common_types.h"
+#include "mtl_texture_cache.h"
+#include "video_core/control/channel_state_cache.h"
+#include "video_core/engines/maxwell_dma.h"
+#include "video_core/rasterizer_interface.h"
+#include "video_core/renderer_metal/mtl_texture_cache.h"
+
+namespace Core {
+class System;
+}
+
+namespace Metal {
+
+class Device;
+class CommandRecorder;
+class SwapChain;
+class TextureCacheRuntime;
+
+class RasterizerMetal;
+
+class AccelerateDMA : public Tegra::Engines::AccelerateDMAInterface {
+public:
+    explicit AccelerateDMA();
+    bool BufferCopy(GPUVAddr start_address, GPUVAddr end_address, u64 amount) override;
+    bool BufferClear(GPUVAddr src_address, u64 amount, u32 value) override;
+    bool ImageToBuffer(const Tegra::DMA::ImageCopy& copy_info, const Tegra::DMA::ImageOperand& src,
+                       const Tegra::DMA::BufferOperand& dst) override {
+        return false;
+    }
+    bool BufferToImage(const Tegra::DMA::ImageCopy& copy_info, const Tegra::DMA::BufferOperand& src,
+                       const Tegra::DMA::ImageOperand& dst) override {
+        return false;
+    }
+};
+
+class RasterizerMetal final : public VideoCore::RasterizerInterface,
+                              protected VideoCommon::ChannelSetupCaches<VideoCommon::ChannelInfo> {
+public:
+    explicit RasterizerMetal(Tegra::GPU& gpu_, Tegra::MaxwellDeviceMemoryManager& device_memory_,
+                             const Device& device_, CommandRecorder& command_recorder_,
+                             const SwapChain& swap_chain_);
+    ~RasterizerMetal() override;
+
+    void Draw(bool is_indexed, u32 instance_count) override;
+    void DrawTexture() override;
+    void Clear(u32 layer_count) override;
+    void DispatchCompute() override;
+    void ResetCounter(VideoCommon::QueryType type) override;
+    void Query(GPUVAddr gpu_addr, VideoCommon::QueryType type,
+               VideoCommon::QueryPropertiesFlags flags, u32 payload, u32 subreport) override;
+    void BindGraphicsUniformBuffer(size_t stage, u32 index, GPUVAddr gpu_addr, u32 size) override;
+    void DisableGraphicsUniformBuffer(size_t stage, u32 index) override;
+    void FlushAll() override;
+    void FlushRegion(DAddr addr, u64 size,
+                     VideoCommon::CacheType which = VideoCommon::CacheType::All) override;
+    bool MustFlushRegion(DAddr addr, u64 size,
+                         VideoCommon::CacheType which = VideoCommon::CacheType::All) override;
+    void InvalidateRegion(DAddr addr, u64 size,
+                          VideoCommon::CacheType which = VideoCommon::CacheType::All) override;
+    void OnCacheInvalidation(DAddr addr, u64 size) override;
+    bool OnCPUWrite(DAddr addr, u64 size) override;
+    VideoCore::RasterizerDownloadArea GetFlushArea(DAddr addr, u64 size) override;
+    void InvalidateGPUCache() override;
+    void UnmapMemory(DAddr addr, u64 size) override;
+    void ModifyGPUMemory(size_t as_id, GPUVAddr addr, u64 size) override;
+    void SignalFence(std::function<void()>&& func) override;
+    void SyncOperation(std::function<void()>&& func) override;
+    void SignalSyncPoint(u32 value) override;
+    void SignalReference() override;
+    void ReleaseFences(bool force) override;
+    void FlushAndInvalidateRegion(
+        DAddr addr, u64 size, VideoCommon::CacheType which = VideoCommon::CacheType::All) override;
+    void WaitForIdle() override;
+    void FragmentBarrier() override;
+    void TiledCacheBarrier() override;
+    void FlushCommands() override;
+    void TickFrame() override;
+    bool AccelerateSurfaceCopy(const Tegra::Engines::Fermi2D::Surface& src,
+                               const Tegra::Engines::Fermi2D::Surface& dst,
+                               const Tegra::Engines::Fermi2D::Config& copy_config) override;
+    Tegra::Engines::AccelerateDMAInterface& AccessAccelerateDMA() override;
+    void AccelerateInlineToMemory(GPUVAddr address, size_t copy_size,
+                                  std::span<const u8> memory) override;
+    void LoadDiskResources(u64 title_id, std::stop_token stop_loading,
+                           const VideoCore::DiskResourceLoadCallback& callback) override;
+    void InitializeChannel(Tegra::Control::ChannelState& channel) override;
+    void BindChannel(Tegra::Control::ChannelState& channel) override;
+    void ReleaseChannel(s32 channel_id) override;
+
+private:
+    Tegra::GPU& gpu;
+    AccelerateDMA accelerate_dma;
+    Tegra::MaxwellDeviceMemoryManager& device_memory;
+
+    const Device& device;
+    CommandRecorder& command_recorder;
+    const SwapChain& swap_chain;
+
+    StagingBufferPool staging_buffer_pool;
+    TextureCacheRuntime texture_cache_runtime;
+    TextureCache texture_cache;
+};
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_rasterizer.mm b/src/video_core/renderer_metal/mtl_rasterizer.mm
new file mode 100644
index 0000000000..ce93692547
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_rasterizer.mm
@@ -0,0 +1,145 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "common/alignment.h"
+#include "video_core/control/channel_state.h"
+#include "video_core/host1x/host1x.h"
+#include "video_core/memory_manager.h"
+#include "video_core/buffer_cache/buffer_cache.h"
+#include "video_core/engines/draw_manager.h"
+#include "video_core/engines/kepler_compute.h"
+#include "video_core/engines/maxwell_3d.h"
+#include "video_core/renderer_metal/mtl_command_recorder.h"
+#include "video_core/renderer_metal/mtl_device.h"
+#include "video_core/renderer_metal/mtl_rasterizer.h"
+#include "video_core/texture_cache/texture_cache_base.h"
+
+#include <iostream>
+
+namespace Metal {
+
+AccelerateDMA::AccelerateDMA() = default;
+
+bool AccelerateDMA::BufferCopy(GPUVAddr start_address, GPUVAddr end_address, u64 amount) {
+    return true;
+}
+bool AccelerateDMA::BufferClear(GPUVAddr src_address, u64 amount, u32 value) {
+    return true;
+}
+
+RasterizerMetal::RasterizerMetal(Tegra::GPU& gpu_,
+                                 Tegra::MaxwellDeviceMemoryManager& device_memory_,
+                                 const Device& device_, CommandRecorder& command_recorder_,
+                                 const SwapChain& swap_chain_)
+    : gpu{gpu_}, device_memory{device_memory_}, device{device_},
+      command_recorder{command_recorder_}, swap_chain{swap_chain_},
+      staging_buffer_pool(device, command_recorder),
+      texture_cache_runtime(device, command_recorder, staging_buffer_pool),
+      texture_cache(texture_cache_runtime, device_memory) {}
+RasterizerMetal::~RasterizerMetal() = default;
+
+void RasterizerMetal::Draw(bool is_indexed, u32 instance_count) {
+    // TODO: uncomment
+    //command_recorder.CheckIfRenderPassIsActive();
+    //const auto& draw_state = maxwell3d->draw_manager->GetDrawState();
+    if (is_indexed) {
+        std::cout << "DrawIndexed" << std::endl;
+        /*[command_buffer drawIndexedPrimitives:MTLPrimitiveTypeTriangle
+                                   indexCount:draw_params.num_indices
+                                    indexType:MTLIndexTypeUInt32
+                                  indexBuffer:draw_state.index_buffer
+                            indexBufferOffset:draw_params.first_index * sizeof(u32)
+                                instanceCount:draw_params.num_instances
+                                   baseVertex:draw_params.base_vertex
+                                 baseInstance:draw_params.base_instance];*/
+        //cmdbuf.DrawIndexed(draw_params.num_vertices, draw_params.num_instances,
+        //                    draw_params.first_index, draw_params.base_vertex,
+        //                    draw_params.base_instance);
+    } else {
+        std::cout << "Draw" << std::endl;
+        //cmdbuf.Draw(draw_params.num_vertices, draw_params.num_instances,
+        //            draw_params.base_vertex, draw_params.base_instance);
+    }
+}
+void RasterizerMetal::DrawTexture() {}
+void RasterizerMetal::Clear(u32 layer_count) {}
+void RasterizerMetal::DispatchCompute() {}
+void RasterizerMetal::ResetCounter(VideoCommon::QueryType type) {}
+void RasterizerMetal::Query(GPUVAddr gpu_addr, VideoCommon::QueryType type,
+                            VideoCommon::QueryPropertiesFlags flags, u32 payload, u32 subreport) {
+    if (!gpu_memory) {
+        return;
+    }
+    if (True(flags & VideoCommon::QueryPropertiesFlags::HasTimeout)) {
+        u64 ticks = gpu.GetTicks();
+        gpu_memory->Write<u64>(gpu_addr + 8, ticks);
+        gpu_memory->Write<u64>(gpu_addr, static_cast<u64>(payload));
+    } else {
+        gpu_memory->Write<u32>(gpu_addr, payload);
+    }
+}
+void RasterizerMetal::BindGraphicsUniformBuffer(size_t stage, u32 index, GPUVAddr gpu_addr,
+                                                u32 size) {}
+void RasterizerMetal::DisableGraphicsUniformBuffer(size_t stage, u32 index) {}
+void RasterizerMetal::FlushAll() {}
+void RasterizerMetal::FlushRegion(DAddr addr, u64 size, VideoCommon::CacheType) {}
+bool RasterizerMetal::MustFlushRegion(DAddr addr, u64 size, VideoCommon::CacheType) {
+    return false;
+}
+void RasterizerMetal::InvalidateRegion(DAddr addr, u64 size, VideoCommon::CacheType) {}
+bool RasterizerMetal::OnCPUWrite(PAddr addr, u64 size) {
+    return false;
+}
+void RasterizerMetal::OnCacheInvalidation(PAddr addr, u64 size) {}
+VideoCore::RasterizerDownloadArea RasterizerMetal::GetFlushArea(PAddr addr, u64 size) {
+    VideoCore::RasterizerDownloadArea new_area{
+        .start_address = Common::AlignDown(addr, Core::DEVICE_PAGESIZE),
+        .end_address = Common::AlignUp(addr + size, Core::DEVICE_PAGESIZE),
+        .preemtive = true,
+    };
+    return new_area;
+}
+void RasterizerMetal::InvalidateGPUCache() {}
+void RasterizerMetal::UnmapMemory(DAddr addr, u64 size) {}
+void RasterizerMetal::ModifyGPUMemory(size_t as_id, GPUVAddr addr, u64 size) {}
+void RasterizerMetal::SignalFence(std::function<void()>&& func) {
+    func();
+}
+void RasterizerMetal::SyncOperation(std::function<void()>&& func) {
+    func();
+}
+void RasterizerMetal::SignalSyncPoint(u32 value) {
+    auto& syncpoint_manager = gpu.Host1x().GetSyncpointManager();
+    syncpoint_manager.IncrementGuest(value);
+    syncpoint_manager.IncrementHost(value);
+}
+void RasterizerMetal::SignalReference() {}
+void RasterizerMetal::ReleaseFences(bool) {}
+void RasterizerMetal::FlushAndInvalidateRegion(DAddr addr, u64 size, VideoCommon::CacheType) {}
+void RasterizerMetal::WaitForIdle() {}
+void RasterizerMetal::FragmentBarrier() {}
+void RasterizerMetal::TiledCacheBarrier() {}
+void RasterizerMetal::FlushCommands() {}
+void RasterizerMetal::TickFrame() {}
+Tegra::Engines::AccelerateDMAInterface& RasterizerMetal::AccessAccelerateDMA() {
+    return accelerate_dma;
+}
+bool RasterizerMetal::AccelerateSurfaceCopy(const Tegra::Engines::Fermi2D::Surface& src,
+                                            const Tegra::Engines::Fermi2D::Surface& dst,
+                                            const Tegra::Engines::Fermi2D::Config& copy_config) {
+    return true;
+}
+void RasterizerMetal::AccelerateInlineToMemory(GPUVAddr address, size_t copy_size,
+                                               std::span<const u8> memory) {}
+void RasterizerMetal::LoadDiskResources(u64 title_id, std::stop_token stop_loading,
+                                        const VideoCore::DiskResourceLoadCallback& callback) {}
+void RasterizerMetal::InitializeChannel(Tegra::Control::ChannelState& channel) {
+    CreateChannel(channel);
+}
+void RasterizerMetal::BindChannel(Tegra::Control::ChannelState& channel) {
+    BindToChannel(channel.bind_id);
+}
+void RasterizerMetal::ReleaseChannel(s32 channel_id) {
+    EraseChannel(channel_id);
+}
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_staging_buffer_pool.h b/src/video_core/renderer_metal/mtl_staging_buffer_pool.h
new file mode 100644
index 0000000000..24584c9ac9
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_staging_buffer_pool.h
@@ -0,0 +1,101 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <climits>
+#include <span>
+#include <vector>
+
+#include "common/common_types.h"
+
+#include "objc_bridge.h"
+#include "video_core/renderer_metal/objc_bridge.h"
+
+namespace Metal {
+
+class Device;
+class CommandRecorder;
+
+enum class MemoryUsage {
+    DeviceLocal,
+    Upload,
+    Download,
+};
+
+struct StagingBufferRef {
+    StagingBufferRef(MTLBuffer_t buffer_, size_t offset_, std::span<u8> mapped_span_);
+    ~StagingBufferRef();
+
+    MTLBuffer_t buffer;
+    size_t offset;
+    std::span<u8> mapped_span;
+};
+
+struct StagingBuffer {
+    StagingBuffer(MTLBuffer_t buffer_, std::span<u8> mapped_span_);
+    ~StagingBuffer();
+
+    MTLBuffer_t buffer;
+    std::span<u8> mapped_span;
+
+    StagingBufferRef Ref() const noexcept;
+};
+
+class StagingBufferPool {
+public:
+    static constexpr size_t NUM_SYNCS = 16;
+
+    explicit StagingBufferPool(const Device& device, CommandRecorder& command_recorder_);
+    ~StagingBufferPool();
+
+    StagingBufferRef Request(size_t size, MemoryUsage usage, bool deferred = false);
+    void FreeDeferred(StagingBufferRef& ref);
+
+    [[nodiscard]] MTLBuffer_t GetSTreamBufferHandle() const noexcept {
+        return stream_buffer;
+    }
+
+    void TickFrame();
+
+private:
+    struct StagingBuffers {
+        std::vector<StagingBuffer> entries;
+        size_t delete_index = 0;
+        size_t iterate_index = 0;
+    };
+
+    static constexpr size_t NUM_LEVELS = sizeof(size_t) * CHAR_BIT;
+    using StagingBuffersCache = std::array<StagingBuffers, NUM_LEVELS>;
+
+    StagingBufferRef GetStreamBuffer(size_t size);
+
+    StagingBufferRef GetStagingBuffer(size_t size, MemoryUsage usage, bool deferred = false);
+
+    StagingBufferRef CreateStagingBuffer(size_t size, MemoryUsage usage, bool deferred);
+
+    StagingBuffersCache& GetCache(MemoryUsage usage);
+
+    void ReleaseCache(MemoryUsage usage);
+
+    void ReleaseLevel(StagingBuffersCache& cache, size_t log2);
+
+    const Device& device;
+    CommandRecorder& command_recorder;
+
+    MTLBuffer_t stream_buffer{};
+
+    size_t iterator = 0;
+    size_t used_iterator = 0;
+    size_t free_iterator = 0;
+    std::array<u64, NUM_SYNCS> sync_ticks{};
+
+    StagingBuffersCache device_local_cache;
+    StagingBuffersCache upload_cache;
+    StagingBuffersCache download_cache;
+
+    size_t current_delete_level = 0;
+    u64 buffer_index = 0;
+    u64 unique_ids{};
+};
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_staging_buffer_pool.mm b/src/video_core/renderer_metal/mtl_staging_buffer_pool.mm
new file mode 100644
index 0000000000..7f420c64a5
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_staging_buffer_pool.mm
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <utility>
+#include <vector>
+
+#include <fmt/format.h>
+
+#include "common/alignment.h"
+#include "common/assert.h"
+#include "common/bit_util.h"
+#include "common/common_types.h"
+#include "common/literals.h"
+#include "video_core/renderer_metal/mtl_command_recorder.h"
+#include "video_core/renderer_metal/mtl_device.h"
+#include "video_core/renderer_metal/mtl_staging_buffer_pool.h"
+
+namespace Metal {
+
+StagingBufferRef::StagingBufferRef(MTLBuffer_t buffer_, size_t offset_, std::span<u8> mapped_span_)
+    : buffer{[buffer_ retain]}, offset{offset_}, mapped_span{mapped_span_} {}
+
+StagingBufferRef::~StagingBufferRef() {
+    [buffer release];
+}
+
+StagingBuffer::StagingBuffer(MTLBuffer_t buffer_, std::span<u8> mapped_span_)
+    : buffer{[buffer_ retain]}, mapped_span{mapped_span_} {}
+
+StagingBuffer::~StagingBuffer() {
+    [buffer release];
+}
+
+StagingBufferRef StagingBuffer::Ref() const noexcept {
+    return StagingBufferRef(buffer, 0, mapped_span);
+}
+
+// TODO: use the _MiB suffix
+constexpr size_t STREAM_BUFFER_SIZE = 128 * 1024 * 1024;//128_MiB;
+constexpr size_t REGION_SIZE = STREAM_BUFFER_SIZE / StagingBufferPool::NUM_SYNCS;
+
+StagingBufferPool::StagingBufferPool(const Device& device_, CommandRecorder& command_recorder_)
+    : device{device_}, command_recorder{command_recorder_} {
+    stream_buffer = [device.GetDevice() newBufferWithLength:STREAM_BUFFER_SIZE
+                                                    options:MTLResourceStorageModeShared];
+}
+
+StagingBufferPool::~StagingBufferPool() = default;
+
+StagingBufferRef StagingBufferPool::Request(size_t size, MemoryUsage usage, bool deferred) {
+    if (!deferred && usage == MemoryUsage::Upload && size <= REGION_SIZE) {
+        return GetStreamBuffer(size);
+    }
+
+    return GetStagingBuffer(size, usage, deferred);
+}
+
+void StagingBufferPool::FreeDeferred(StagingBufferRef& ref) {
+   // TODO: implement this
+}
+
+void StagingBufferPool::TickFrame() {
+    current_delete_level = (current_delete_level + 1) % NUM_LEVELS;
+
+    ReleaseCache(MemoryUsage::DeviceLocal);
+    ReleaseCache(MemoryUsage::Upload);
+    ReleaseCache(MemoryUsage::Download);
+}
+
+StagingBufferRef StagingBufferPool::GetStreamBuffer(size_t size) {
+    // TODO: implement this
+
+    // HACK
+    return GetStagingBuffer(size, MemoryUsage::Upload);
+}
+
+StagingBufferRef StagingBufferPool::GetStagingBuffer(size_t size, MemoryUsage usage,
+                                                     bool deferred) {
+    return CreateStagingBuffer(size, usage, deferred);
+}
+
+StagingBufferRef StagingBufferPool::CreateStagingBuffer(size_t size, MemoryUsage usage,
+                                                        bool deferred) {
+    const u32 log2 = Common::Log2Ceil64(size);
+    MTLBuffer_t buffer = [device.GetDevice() newBufferWithLength:size
+                                                         options:MTLResourceStorageModeShared];
+    // TODO: check if the mapped span is correct
+    std::span<u8> mapped_span(static_cast<u8*>([buffer contents]), size);
+    auto& entry = GetCache(usage)[log2].entries.emplace_back(buffer, mapped_span);
+
+    return entry.Ref();
+}
+
+StagingBufferPool::StagingBuffersCache& StagingBufferPool::GetCache(MemoryUsage usage) {
+    switch (usage) {
+    case MemoryUsage::DeviceLocal:
+        return device_local_cache;
+    case MemoryUsage::Upload:
+        return upload_cache;
+    case MemoryUsage::Download:
+        return download_cache;
+    default:
+        ASSERT_MSG(false, "Invalid memory usage={}", usage);
+        return upload_cache;
+    }
+}
+
+void StagingBufferPool::ReleaseCache(MemoryUsage usage) {
+    ReleaseLevel(GetCache(usage), current_delete_level);
+}
+
+void StagingBufferPool::ReleaseLevel(StagingBuffersCache& cache, size_t log2) {
+    // TODO: implement this
+}
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_swap_chain.h b/src/video_core/renderer_metal/mtl_swap_chain.h
new file mode 100644
index 0000000000..7c492f5dc0
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_swap_chain.h
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "video_core/renderer_metal/objc_bridge.h"
+
+namespace Metal {
+
+class Device;
+class CommandRecorder;
+
+class SwapChain {
+public:
+    SwapChain(const Device& device_, CommandRecorder& command_recorder_,
+              const CAMetalLayer* layer_);
+    ~SwapChain();
+
+    void AcquireNextDrawable();
+
+    void Present();
+
+    MTLTexture_t GetDrawableTexture();
+
+private:
+    const Device& device;
+    CommandRecorder& command_recorder;
+    const CAMetalLayer* layer;
+
+    CAMetalDrawable_t drawable = nil;
+};
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_swap_chain.mm b/src/video_core/renderer_metal/mtl_swap_chain.mm
new file mode 100644
index 0000000000..22bbd43f09
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_swap_chain.mm
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "video_core/renderer_metal/mtl_command_recorder.h"
+#include "video_core/renderer_metal/mtl_device.h"
+#include "video_core/renderer_metal/mtl_swap_chain.h"
+
+namespace Metal {
+
+SwapChain::SwapChain(const Device& device_, CommandRecorder& command_recorder_,
+    const CAMetalLayer* layer_)
+    : device(device_), command_recorder(command_recorder_), layer([layer_ retain]) {
+    // Give the layer our device
+    layer.device = device.GetDevice();
+}
+
+SwapChain::~SwapChain() {
+    if (drawable) {
+        // TODO: should drawable be released?
+        [drawable release];
+    }
+    [layer release];
+}
+
+void SwapChain::AcquireNextDrawable() {
+    // Get the next drawable
+    drawable = [layer nextDrawable];
+}
+
+void SwapChain::Present() {
+    command_recorder.EndEncoding();
+    command_recorder.Present(drawable);
+}
+
+MTLTexture_t SwapChain::GetDrawableTexture() {
+    return drawable.texture;
+}
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_texture_cache.h b/src/video_core/renderer_metal/mtl_texture_cache.h
new file mode 100644
index 0000000000..328a074263
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_texture_cache.h
@@ -0,0 +1,256 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <span>
+
+#include "mtl_staging_buffer_pool.h"
+#include "video_core/texture_cache/texture_cache_base.h"
+
+#include "shader_recompiler/shader_info.h"
+#include "video_core/renderer_metal/mtl_staging_buffer_pool.h"
+#include "video_core/renderer_metal/objc_bridge.h"
+#include "video_core/texture_cache/image_view_base.h"
+
+namespace Settings {
+struct ResolutionScalingInfo;
+}
+
+namespace Metal {
+
+using Common::SlotVector;
+using VideoCommon::ImageId;
+using VideoCommon::NUM_RT;
+using VideoCommon::Region2D;
+using VideoCommon::RenderTargets;
+using VideoCore::Surface::PixelFormat;
+
+class CommandRecorder;
+class Device;
+class Image;
+class ImageView;
+class Framebuffer;
+
+class TextureCacheRuntime {
+public:
+    explicit TextureCacheRuntime(const Device& device_, CommandRecorder& command_recorder_,
+                                 StagingBufferPool& staging_buffer_pool_);
+
+    // TODO: implement
+    void Finish() {}
+
+    void TickFrame();
+
+    StagingBufferRef UploadStagingBuffer(size_t size);
+
+    StagingBufferRef DownloadStagingBuffer(size_t size, bool deferred = false);
+
+    void FreeDeferredStagingBuffer(StagingBufferRef& ref);
+
+    bool CanUploadMSAA() const noexcept {
+        return true;
+    }
+
+    u64 GetDeviceLocalMemory() const {
+        return 0;
+    }
+
+    u64 GetDeviceMemoryUsage() const {
+        return 0;
+    }
+
+    bool CanReportMemoryUsage() const {
+        return false;
+    }
+
+    // TODO: implement
+    void BlitImage(Framebuffer* dst_framebuffer, ImageView& dst, ImageView& src,
+                   const Region2D& dst_region, const Region2D& src_region,
+                   Tegra::Engines::Fermi2D::Filter filter,
+                   Tegra::Engines::Fermi2D::Operation operation) {}
+
+    // TODO: implement
+    void CopyImage(Image& dst, Image& src, std::span<const VideoCommon::ImageCopy> copies) {}
+
+    // TODO: implement
+    void CopyImageMSAA(Image& dst, Image& src, std::span<const VideoCommon::ImageCopy> copies) {}
+
+    bool ShouldReinterpret(Image& dst, Image& src) {
+        // HACK
+        return false;
+    }
+
+    // TODO: implement
+    void ReinterpretImage(Image& dst, Image& src, std::span<const VideoCommon::ImageCopy> copies) {}
+
+    // TODO: implement
+    void ConvertImage(Framebuffer* dst, ImageView& dst_view, ImageView& src_view) {}
+
+    // TODO: implement
+    void InsertUploadMemoryBarrier() {}
+
+    void TransitionImageLayout(Image& image) {}
+
+    // TODO: implement
+    void AccelerateImageUpload(Image&, const StagingBufferRef&,
+                               std::span<const VideoCommon::SwizzleParameters>) {}
+
+    bool HasNativeBgr() const noexcept {
+        return true;
+    }
+
+    bool HasBrokenTextureViewFormats() const noexcept {
+        return false;
+    }
+
+    // TODO: implement
+    void BarrierFeedbackLoop() {}
+
+    const Device& device;
+    CommandRecorder& command_recorder;
+    StagingBufferPool& staging_buffer_pool;
+    const Settings::ResolutionScalingInfo& resolution;
+};
+
+class Image : public VideoCommon::ImageBase {
+public:
+    explicit Image(TextureCacheRuntime& runtime, const VideoCommon::ImageInfo& info,
+                   GPUVAddr gpu_addr, VAddr cpu_addr);
+    explicit Image(const VideoCommon::NullImageParams&);
+
+    ~Image();
+
+    Image(const Image&) = delete;
+    Image& operator=(const Image&) = delete;
+
+    Image(Image&&) = default;
+    Image& operator=(Image&&) = default;
+
+    void UploadMemory(MTLBuffer_t buffer, size_t offset,
+                      std::span<const VideoCommon::BufferImageCopy> copies);
+
+    void UploadMemory(const StagingBufferRef& map,
+                      std::span<const VideoCommon::BufferImageCopy> copies);
+
+    void DownloadMemory(MTLBuffer_t buffer, size_t offset,
+                        std::span<const VideoCommon::BufferImageCopy> copies);
+
+    // For some reason, this function cannot be defined in the .mm file since it would report
+    // undefined symbols
+    void DownloadMemory(std::span<MTLBuffer_t> buffers, std::span<size_t> offsets,
+                        std::span<const VideoCommon::BufferImageCopy> copies) {
+        // TODO: implement
+    }
+
+    void DownloadMemory(const StagingBufferRef& map,
+                        std::span<const VideoCommon::BufferImageCopy> copies);
+
+    bool IsRescaled() const {
+        return rescaled;
+    }
+
+    bool ScaleUp(bool ignore = false) {
+        // HACK
+        return true;
+    }
+
+    bool ScaleDown(bool ignore = false) {
+        // HACK
+        return true;
+    }
+
+    MTLTexture_t GetHandle() const noexcept {
+        return texture;
+    }
+
+private:
+    MTLTexture_t texture = nil;
+    bool initialized = false;
+
+    bool rescaled = false;
+};
+
+class ImageView : public VideoCommon::ImageViewBase {
+public:
+    explicit ImageView(TextureCacheRuntime&, const VideoCommon::ImageViewInfo&, ImageId, Image&);
+    explicit ImageView(TextureCacheRuntime&, const VideoCommon::ImageViewInfo&, ImageId, Image&,
+                       const SlotVector<Image>&);
+    explicit ImageView(TextureCacheRuntime&, const VideoCommon::ImageInfo&,
+                       const VideoCommon::ImageViewInfo&, GPUVAddr);
+    explicit ImageView(TextureCacheRuntime&, const VideoCommon::NullImageViewParams&);
+
+    ~ImageView();
+
+    ImageView(const ImageView&) = delete;
+    ImageView& operator=(const ImageView&) = delete;
+
+    ImageView(ImageView&&) = default;
+    ImageView& operator=(ImageView&&) = default;
+
+    MTLTexture_t GetHandle() const noexcept {
+        return texture;
+    }
+
+private:
+    MTLTexture_t texture;
+};
+
+class ImageAlloc : public VideoCommon::ImageAllocBase {};
+
+class Sampler {
+public:
+    explicit Sampler(TextureCacheRuntime&, const Tegra::Texture::TSCEntry&);
+
+    MTLSamplerState_t GetHandle() const noexcept {
+        return sampler_state;
+    }
+
+private:
+    MTLSamplerState_t sampler_state;
+};
+
+class Framebuffer {
+public:
+    explicit Framebuffer(TextureCacheRuntime& runtime, std::span<ImageView*, NUM_RT> color_buffers,
+                         ImageView* depth_buffer, const VideoCommon::RenderTargets& key);
+    ~Framebuffer();
+
+    Framebuffer(const Framebuffer&) = delete;
+    Framebuffer& operator=(const Framebuffer&) = delete;
+
+    Framebuffer(Framebuffer&&) = default;
+    Framebuffer& operator=(Framebuffer&&) = default;
+
+    void CreateRenderPassDescriptor(TextureCacheRuntime& runtime,
+                                    std::span<ImageView*, NUM_RT> color_buffers,
+                                    ImageView* depth_buffer, bool is_rescaled, size_t width,
+                                    size_t height);
+
+    MTLRenderPassDescriptor* GetHandle() const noexcept {
+        return render_pass;
+    }
+
+private:
+    MTLRenderPassDescriptor* render_pass{};
+};
+
+struct TextureCacheParams {
+    static constexpr bool ENABLE_VALIDATION = true;
+    static constexpr bool FRAMEBUFFER_BLITS = false;
+    static constexpr bool HAS_EMULATED_COPIES = false;
+    static constexpr bool HAS_DEVICE_MEMORY_INFO = true;
+    static constexpr bool IMPLEMENTS_ASYNC_DOWNLOADS = true;
+
+    using Runtime = Metal::TextureCacheRuntime;
+    using Image = Metal::Image;
+    using ImageAlloc = Metal::ImageAlloc;
+    using ImageView = Metal::ImageView;
+    using Sampler = Metal::Sampler;
+    using Framebuffer = Metal::Framebuffer;
+    using AsyncBuffer = Metal::StagingBufferRef;
+    using BufferType = MTLBuffer_t;
+};
+
+using TextureCache = VideoCommon::TextureCache<TextureCacheParams>;
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/mtl_texture_cache.mm b/src/video_core/renderer_metal/mtl_texture_cache.mm
new file mode 100644
index 0000000000..6bc65929f3
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_texture_cache.mm
@@ -0,0 +1,184 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <array>
+#include <boost/container/small_vector.hpp>
+#include <span>
+#include <vector>
+
+#include "common/bit_cast.h"
+#include "common/bit_util.h"
+#include "common/settings.h"
+
+#include "video_core/renderer_metal/mtl_device.h"
+#include "video_core/renderer_metal/mtl_texture_cache.h"
+
+#include "video_core/engines/fermi_2d.h"
+#include "video_core/texture_cache/formatter.h"
+#include "video_core/texture_cache/samples_helper.h"
+#include "video_core/texture_cache/util.h"
+
+namespace Metal {
+
+using Tegra::Engines::Fermi2D;
+using Tegra::Texture::SwizzleSource;
+using Tegra::Texture::TextureMipmapFilter;
+using VideoCommon::BufferImageCopy;
+using VideoCommon::ImageFlagBits;
+using VideoCommon::ImageInfo;
+using VideoCommon::ImageType;
+using VideoCommon::SubresourceRange;
+using VideoCore::Surface::BytesPerBlock;
+using VideoCore::Surface::IsPixelFormatASTC;
+using VideoCore::Surface::IsPixelFormatInteger;
+using VideoCore::Surface::SurfaceType;
+
+TextureCacheRuntime::TextureCacheRuntime(const Device& device_, CommandRecorder& command_recorder_,
+                                         StagingBufferPool& staging_buffer_pool_)
+    : device{device_}, command_recorder{command_recorder_},
+      staging_buffer_pool{staging_buffer_pool_},
+      resolution{Settings::values.resolution_info} {}
+
+void TextureCacheRuntime::TickFrame() {}
+
+StagingBufferRef TextureCacheRuntime::UploadStagingBuffer(size_t size) {
+    return staging_buffer_pool.Request(size, MemoryUsage::Upload);
+}
+
+StagingBufferRef TextureCacheRuntime::DownloadStagingBuffer(size_t size, bool deferred) {
+    return staging_buffer_pool.Request(size, MemoryUsage::Download, deferred);
+}
+
+void TextureCacheRuntime::FreeDeferredStagingBuffer(StagingBufferRef& ref) {
+    staging_buffer_pool.FreeDeferred(ref);
+}
+
+Image::Image(TextureCacheRuntime& runtime, const ImageInfo& info,
+             GPUVAddr gpu_addr_, VAddr cpu_addr_)
+    : VideoCommon::ImageBase(info, gpu_addr_, cpu_addr_) {
+    MTLTextureDescriptor *texture_descriptor =
+        [[MTLTextureDescriptor alloc] init];
+    // TODO: don't hardcode the format
+    texture_descriptor.pixelFormat = MTLPixelFormatRGBA8Unorm;
+    texture_descriptor.width = info.size.width;
+    texture_descriptor.height = info.size.height;
+
+    texture =
+        [runtime.device.GetDevice() newTextureWithDescriptor:texture_descriptor];
+}
+
+Image::Image(const VideoCommon::NullImageParams& params) : VideoCommon::ImageBase{params} {}
+
+Image::~Image() {
+    if (texture) {
+        [texture release];
+    }
+}
+
+// TODO: implement these
+void Image::UploadMemory(MTLBuffer_t buffer, size_t offset,
+                         std::span<const VideoCommon::BufferImageCopy> copies) {
+    ;
+}
+
+void Image::UploadMemory(const StagingBufferRef& map,
+                         std::span<const VideoCommon::BufferImageCopy> copies) {
+    ;
+}
+
+void Image::DownloadMemory(MTLBuffer_t buffer, size_t offset,
+                           std::span<const VideoCommon::BufferImageCopy> copies) {
+    ;
+}
+
+// TODO: uncomment
+/*
+void Image::DownloadMemory(std::span<MTLBuffer_t> buffers, std::span<size_t> offsets,
+                           std::span<const VideoCommon::BufferImageCopy> copies) {
+    ;
+}
+*/
+
+void Image::DownloadMemory(const StagingBufferRef& map,
+                           std::span<const VideoCommon::BufferImageCopy> copies) {
+    ;
+}
+
+ImageView::ImageView(TextureCacheRuntime& runtime,
+                     const VideoCommon::ImageViewInfo& info, ImageId image_id_,
+                     Image& image)
+    : VideoCommon::ImageViewBase{info, image.info, image_id_, image.gpu_addr} {
+    using Shader::TextureType;
+
+    texture = [image.GetHandle() retain];
+
+    // TODO: create texture view
+}
+
+ImageView::ImageView(TextureCacheRuntime& runtime,
+                     const VideoCommon::ImageViewInfo& info, ImageId image_id_,
+                     Image& image, const SlotVector<Image>& slot_imgs)
+    : ImageView(runtime, info, image_id_, image) {
+    // TODO: save slot images
+}
+
+ImageView::ImageView(TextureCacheRuntime&, const VideoCommon::ImageInfo& info,
+                     const VideoCommon::ImageViewInfo& view_info, GPUVAddr gpu_addr_)
+    : VideoCommon::ImageViewBase{info, view_info, gpu_addr_} {
+    // TODO: implement
+}
+
+ImageView::ImageView(TextureCacheRuntime& runtime, const VideoCommon::NullImageViewParams& params)
+    : VideoCommon::ImageViewBase{params} {
+    // TODO: implement
+}
+
+ImageView::~ImageView() { [texture release]; }
+
+Sampler::Sampler(TextureCacheRuntime& runtime,
+                 const Tegra::Texture::TSCEntry& tsc) {
+    MTLSamplerDescriptor* sampler_descriptor =
+        [[MTLSamplerDescriptor alloc] init];
+
+    // TODO: configure the descriptor
+
+    sampler_state = [runtime.device.GetDevice()
+        newSamplerStateWithDescriptor:sampler_descriptor];
+}
+
+Framebuffer::Framebuffer(TextureCacheRuntime& runtime,
+                         std::span<ImageView*, NUM_RT> color_buffers,
+                         ImageView* depth_buffer,
+                         const VideoCommon::RenderTargets& key) {
+    CreateRenderPassDescriptor(runtime, color_buffers, depth_buffer,
+                               key.is_rescaled, key.size.width, key.size.height);
+}
+
+Framebuffer::~Framebuffer() = default;
+
+void Framebuffer::CreateRenderPassDescriptor(
+    TextureCacheRuntime& runtime, std::span<ImageView*, NUM_RT> color_buffers,
+    ImageView* depth_buffer, bool is_rescaled, size_t width, size_t height) {
+    render_pass = [MTLRenderPassDescriptor renderPassDescriptor];
+
+    for (size_t index = 0; index < NUM_RT; ++index) {
+        const ImageView* const color_buffer = color_buffers[index];
+        if (!color_buffer) {
+        continue;
+        }
+        // TODO: don't use index as attachment index
+        render_pass.colorAttachments[index].clearColor =
+            MTLClearColorMake(0.5, 1.0, 0.0, 1.0);
+        render_pass.colorAttachments[index].loadAction = MTLLoadActionClear;
+        render_pass.colorAttachments[index].storeAction = MTLStoreActionStore;
+        render_pass.colorAttachments[index].texture = color_buffer->GetHandle();
+    }
+    if (depth_buffer) {
+        render_pass.depthAttachment.clearDepth = 1.0;
+        render_pass.depthAttachment.loadAction = MTLLoadActionClear;
+        render_pass.depthAttachment.storeAction = MTLStoreActionStore;
+        render_pass.depthAttachment.texture = depth_buffer->GetHandle();
+    }
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_metal/mtl_texture_cache_base.cpp b/src/video_core/renderer_metal/mtl_texture_cache_base.cpp
new file mode 100644
index 0000000000..714484b747
--- /dev/null
+++ b/src/video_core/renderer_metal/mtl_texture_cache_base.cpp
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "video_core/renderer_metal/mtl_texture_cache.h"
+#include "video_core/texture_cache/texture_cache.h"
+
+namespace VideoCommon {
+template class VideoCommon::TextureCache<Metal::TextureCacheParams>;
+}
diff --git a/src/video_core/renderer_metal/objc_bridge.h b/src/video_core/renderer_metal/objc_bridge.h
new file mode 100644
index 0000000000..37c120626d
--- /dev/null
+++ b/src/video_core/renderer_metal/objc_bridge.h
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#ifdef __OBJC__
+#import <Metal/Metal.h>
+#import <QuartzCore/QuartzCore.h>
+typedef id<MTLDevice> MTLDevice_t;
+typedef id<MTLCommandQueue> MTLCommandQueue_t;
+typedef id<MTLCommandBuffer> MTLCommandBuffer_t;
+typedef id<MTLCommandEncoder> MTLCommandEncoder_t;
+typedef id<MTLBuffer> MTLBuffer_t;
+typedef id<MTLTexture> MTLTexture_t;
+typedef id<MTLSamplerState> MTLSamplerState_t;
+typedef id<CAMetalDrawable> CAMetalDrawable_t;
+#else
+typedef void* MTLDevice_t;
+typedef void* MTLCommandQueue_t;
+typedef void* MTLCommandBuffer_t;
+typedef void* MTLCommandEncoder_t;
+typedef void* MTLBuffer_t;
+typedef void* MTLTexture_t;
+typedef void* MTLSamplerState_t;
+typedef void MTLRenderPassDescriptor;
+typedef void CAMetalLayer;
+typedef void* CAMetalDrawable_t;
+#define nil NULL
+#endif
diff --git a/src/video_core/renderer_metal/renderer_metal.h b/src/video_core/renderer_metal/renderer_metal.h
new file mode 100644
index 0000000000..877c50fe77
--- /dev/null
+++ b/src/video_core/renderer_metal/renderer_metal.h
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include "objc_bridge.h"
+#include "video_core/host1x/gpu_device_memory_manager.h"
+#include "video_core/renderer_base.h"
+#include "video_core/renderer_metal/mtl_command_recorder.h"
+#include "video_core/renderer_metal/mtl_device.h"
+#include "video_core/renderer_metal/mtl_rasterizer.h"
+#include "video_core/renderer_metal/mtl_swap_chain.h"
+
+namespace Core {
+class TelemetrySession;
+}
+
+namespace Core::Memory {
+class Memory;
+}
+
+namespace Tegra {
+class GPU;
+}
+
+namespace Metal {
+
+class RendererMetal final : public VideoCore::RendererBase {
+public:
+    explicit RendererMetal(Core::Frontend::EmuWindow& emu_window,
+                           Tegra::MaxwellDeviceMemoryManager& device_memory_, Tegra::GPU& gpu_,
+                           std::unique_ptr<Core::Frontend::GraphicsContext> context);
+    ~RendererMetal() override;
+
+    void Composite(std::span<const Tegra::FramebufferConfig> framebuffer) override;
+
+    std::vector<u8> GetAppletCaptureBuffer() override;
+
+    VideoCore::RasterizerInterface* ReadRasterizer() override {
+        return &rasterizer;
+    }
+
+    [[nodiscard]] std::string GetDeviceVendor() const override {
+        return "Apple";
+    }
+
+private:
+    Tegra::MaxwellDeviceMemoryManager& device_memory;
+    Tegra::GPU& gpu;
+
+    Device device;
+    CommandRecorder command_recorder;
+    SwapChain swap_chain;
+
+    RasterizerMetal rasterizer;
+};
+
+} // namespace Metal
diff --git a/src/video_core/renderer_metal/renderer_metal.mm b/src/video_core/renderer_metal/renderer_metal.mm
new file mode 100644
index 0000000000..8911f0e1b2
--- /dev/null
+++ b/src/video_core/renderer_metal/renderer_metal.mm
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "core/frontend/emu_window.h"
+#include "core/frontend/graphics_context.h"
+#include "video_core/capture.h"
+#include "video_core/renderer_metal/renderer_metal.h"
+#include "video_core/renderer_metal/mtl_device.h"
+
+namespace Metal {
+
+RendererMetal::RendererMetal(Core::Frontend::EmuWindow& emu_window,
+                             Tegra::MaxwellDeviceMemoryManager& device_memory_, Tegra::GPU& gpu_,
+                             std::unique_ptr<Core::Frontend::GraphicsContext> context_)
+    : RendererBase(emu_window, std::move(context_)), device_memory{device_memory_},
+      gpu{gpu_}, device{},
+      command_recorder(device),
+      swap_chain(device, command_recorder,
+                 static_cast<const CAMetalLayer*>(render_window.GetWindowInfo().render_surface)),
+      rasterizer(gpu_, device_memory, device, command_recorder, swap_chain) {}
+
+RendererMetal::~RendererMetal() = default;
+
+void RendererMetal::Composite(std::span<const Tegra::FramebufferConfig> framebuffers) {
+    if (framebuffers.empty()) {
+        return;
+    }
+
+    // Ask the swap chain to get next drawable
+    swap_chain.AcquireNextDrawable();
+
+    // TODO: copy the framebuffer to the drawable texture instead of this dummy render pass
+    MTLRenderPassDescriptor* render_pass_descriptor = [MTLRenderPassDescriptor renderPassDescriptor];
+    render_pass_descriptor.colorAttachments[0].clearColor = MTLClearColorMake(1.0, 0.5, 0.0, 1.0);
+    render_pass_descriptor.colorAttachments[0].loadAction  = MTLLoadActionClear;
+    render_pass_descriptor.colorAttachments[0].storeAction = MTLStoreActionStore;
+    render_pass_descriptor.colorAttachments[0].texture = swap_chain.GetDrawableTexture();
+
+    command_recorder.BeginRenderPass(render_pass_descriptor);
+
+    swap_chain.Present();
+    command_recorder.Submit();
+
+    gpu.RendererFrameEndNotify();
+    rasterizer.TickFrame();
+
+    render_window.OnFrameDisplayed();
+}
+
+std::vector<u8> RendererMetal::GetAppletCaptureBuffer() {
+    return std::vector<u8>(VideoCore::Capture::TiledSize);
+}
+
+} // namespace Metal
diff --git a/src/video_core/video_core.cpp b/src/video_core/video_core.cpp
index 509ba8a5b4..efcf315e02 100644
--- a/src/video_core/video_core.cpp
+++ b/src/video_core/video_core.cpp
@@ -11,6 +11,9 @@
 #include "video_core/renderer_null/renderer_null.h"
 #include "video_core/renderer_opengl/renderer_opengl.h"
 #include "video_core/renderer_vulkan/renderer_vulkan.h"
+#ifdef __APPLE__
+#include "video_core/renderer_metal/renderer_metal.h"
+#endif
 #include "video_core/video_core.h"
 
 namespace {
@@ -22,7 +25,9 @@ std::unique_ptr<VideoCore::RendererBase> CreateRenderer(
 
     switch (Settings::values.renderer_backend.GetValue()) {
 #ifdef __APPLE__
-        // do nothing for now, include metal in here at later date.
+    case Settings::RendererBackend::Metal:
+        return std::make_unique<Metal::RendererMetal>(emu_window, device_memory, gpu,
+                                                      std::move(context));
 #else
         // openGL, not supported on Apple so not bothering to include if macos
     case Settings::RendererBackend::OpenGL: