Jelajahi Sumber

libobs-metal: Add Metal renderer

PatTheMav 4 bulan lalu
induk
melakukan
d8b19c3c25
37 mengubah file dengan 8447 tambahan dan 0 penghapusan
  1. 3 0
      CMakeLists.txt
  2. 70 0
      libobs-metal/CMakeLists.txt
  3. 51 0
      libobs-metal/CVPixelFormat+Extensions.swift
  4. 33 0
      libobs-metal/MTLCullMode+Extensions.swift
  5. 25 0
      libobs-metal/MTLOrigin+Extensions.swift
  6. 406 0
      libobs-metal/MTLPixelFormat+Extensions.swift
  7. 25 0
      libobs-metal/MTLRegion+Extensions.swift
  8. 25 0
      libobs-metal/MTLSize+Extensions.swift
  9. 76 0
      libobs-metal/MTLTexture+Extensions.swift
  10. 93 0
      libobs-metal/MTLTextureDescriptor+Extensions.swift
  11. 36 0
      libobs-metal/MTLTextureType+Extensions.swift
  12. 31 0
      libobs-metal/MTLViewport+Extensions.swift
  13. 308 0
      libobs-metal/MetalBuffer.swift
  14. 786 0
      libobs-metal/MetalDevice.swift
  15. 126 0
      libobs-metal/MetalError.swift
  16. 79 0
      libobs-metal/MetalRenderState.swift
  17. 27 0
      libobs-metal/MetalShader+Extensions.swift
  18. 287 0
      libobs-metal/MetalShader.swift
  19. 65 0
      libobs-metal/MetalStageBuffer.swift
  20. 433 0
      libobs-metal/MetalTexture.swift
  21. 1603 0
      libobs-metal/OBSShader.swift
  22. 125 0
      libobs-metal/OBSSwapChain.swift
  23. 25 0
      libobs-metal/Sequence+Hashable.swift
  24. 486 0
      libobs-metal/libobs+Extensions.swift
  25. 34 0
      libobs-metal/libobs+SignalHandlers.swift
  26. 32 0
      libobs-metal/libobs-metal-Bridging-Header.h
  27. 158 0
      libobs-metal/metal-indexbuffer.swift
  28. 100 0
      libobs-metal/metal-samplerstate.swift
  29. 593 0
      libobs-metal/metal-shader.swift
  30. 130 0
      libobs-metal/metal-stagesurf.swift
  31. 985 0
      libobs-metal/metal-subsystem.swift
  32. 269 0
      libobs-metal/metal-swapchain.swift
  33. 528 0
      libobs-metal/metal-texture2d.swift
  34. 113 0
      libobs-metal/metal-texture3d.swift
  35. 97 0
      libobs-metal/metal-unimplemented.swift
  36. 115 0
      libobs-metal/metal-vertexbuffer.swift
  37. 69 0
      libobs-metal/metal-zstencilbuffer.swift

+ 3 - 0
CMakeLists.txt

@@ -25,6 +25,9 @@ if(OS_WINDOWS)
   add_subdirectory(libobs-winrt)
 endif()
 add_subdirectory(libobs-opengl)
+if(OS_MACOS)
+  add_subdirectory(libobs-metal)
+endif()
 add_subdirectory(plugins)
 
 add_subdirectory(test/test-input)

+ 70 - 0
libobs-metal/CMakeLists.txt

@@ -0,0 +1,70 @@
+cmake_minimum_required(VERSION 3.28...3.30)
+
+add_library(libobs-metal SHARED)
+add_library(OBS::libobs-metal ALIAS libobs-metal)
+
+target_sources(
+  libobs-metal
+  PRIVATE
+    CVPixelFormat+Extensions.swift
+    MTLCullMode+Extensions.swift
+    MTLOrigin+Extensions.swift
+    MTLPixelFormat+Extensions.swift
+    MTLRegion+Extensions.swift
+    MTLSize+Extensions.swift
+    MTLTexture+Extensions.swift
+    MTLTextureDescriptor+Extensions.swift
+    MTLTextureType+Extensions.swift
+    MTLViewport+Extensions.swift
+    MetalBuffer.swift
+    MetalDevice.swift
+    MetalError.swift
+    MetalRenderState.swift
+    MetalShader+Extensions.swift
+    MetalShader.swift
+    MetalStageBuffer.swift
+    MetalTexture.swift
+    OBSShader.swift
+    OBSSwapChain.swift
+    Sequence+Hashable.swift
+    libobs+Extensions.swift
+    libobs+SignalHandlers.swift
+    libobs-metal-Bridging-Header.h
+    metal-indexbuffer.swift
+    metal-samplerstate.swift
+    metal-shader.swift
+    metal-stagesurf.swift
+    metal-subsystem.swift
+    metal-swapchain.swift
+    metal-texture2d.swift
+    metal-texture3d.swift
+    metal-unimplemented.swift
+    metal-vertexbuffer.swift
+    metal-zstencilbuffer.swift
+)
+
+target_link_libraries(libobs-metal PRIVATE OBS::libobs)
+
+target_enable_feature(libobs "Metal renderer")
+
+set_property(SOURCE OBSMetalRenderer.swift APPEND PROPERTY COMPILE_FLAGS -emit-objc-header)
+
+set_target_properties_obs(
+    libobs-metal
+    PROPERTIES FOLDER core
+               VERSION 0
+               PREFIX ""
+)
+
+set_target_xcode_properties(
+    libobs-metal
+    PROPERTIES SWIFT_VERSION 6.0
+               CLANG_ENABLE_OBJC_ARC YES
+               CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION YES
+               GCC_WARN_SHADOW YES
+               CLANG_ENABLE_MODULES YES
+               CLANG_MODULES_AUTOLINK YES
+               GCC_STRICT_ALIASING YES
+               DEFINES_MODULE YES
+               SWIFT_OBJC_BRIDGING_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/libobs-metal-Bridging-Header.h"
+)

+ 51 - 0
libobs-metal/CVPixelFormat+Extensions.swift

@@ -0,0 +1,51 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import CoreVideo
+import Metal
+
+extension OSType {
+    /// Conversion of CoreVideo pixel formats into corresponding Metal pixel formats
+    var mtlFormat: MTLPixelFormat? {
+        switch self {
+        case kCVPixelFormatType_OneComponent8:
+            return .r8Unorm
+        case kCVPixelFormatType_OneComponent16Half:
+            return .r16Float
+        case kCVPixelFormatType_OneComponent32Float:
+            return .r32Float
+        case kCVPixelFormatType_TwoComponent8:
+            return .rg8Unorm
+        case kCVPixelFormatType_TwoComponent16Half:
+            return .rg16Float
+        case kCVPixelFormatType_TwoComponent32Float:
+            return .rg32Float
+        case kCVPixelFormatType_32BGRA:
+            return .bgra8Unorm
+        case kCVPixelFormatType_32RGBA:
+            return .rgba8Unorm
+        case kCVPixelFormatType_64RGBAHalf:
+            return .rgba16Float
+        case kCVPixelFormatType_128RGBAFloat:
+            return .rgba32Float
+        case kCVPixelFormatType_ARGB2101010LEPacked:
+            return .bgr10a2Unorm
+        default:
+            return nil
+        }
+    }
+}

+ 33 - 0
libobs-metal/MTLCullMode+Extensions.swift

@@ -0,0 +1,33 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLCullMode {
+    /// Conversion of the cull mode into its corresponding `libobs` type
+    var obsMode: gs_cull_mode {
+        switch self {
+        case .back:
+            return GS_BACK
+        case .front:
+            return GS_FRONT
+        default:
+            return GS_NEITHER
+        }
+    }
+}

+ 25 - 0
libobs-metal/MTLOrigin+Extensions.swift

@@ -0,0 +1,25 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLOrigin: @retroactive Equatable {
+    public static func == (lhs: MTLOrigin, rhs: MTLOrigin) -> Bool {
+        lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z
+    }
+}

+ 406 - 0
libobs-metal/MTLPixelFormat+Extensions.swift

@@ -0,0 +1,406 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import CoreGraphics
+import CoreVideo
+import Foundation
+import Metal
+
+extension MTLPixelFormat {
+    /// Property to check whether the pixel format is an 8-bit format
+    var is8Bit: Bool {
+        switch self {
+        case .a8Unorm, .r8Unorm, .r8Snorm, .r8Uint, .r8Sint:
+            return true
+        case .r8Unorm_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a 16-bit format
+    var is16Bit: Bool {
+        switch self {
+        case .r16Unorm, .r16Snorm, .r16Uint, .r16Sint:
+            return true
+        case .rg8Unorm, .rg8Snorm, .rg8Uint, .rg8Sint:
+            return true
+        case .rg16Float:
+            return true
+        case .rg8Unorm_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a packed 16-bit format
+    var isPacked16Bit: Bool {
+        switch self {
+        case .b5g6r5Unorm, .a1bgr5Unorm, .abgr4Unorm, .bgr5A1Unorm:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a 32-bit format
+    var is32Bit: Bool {
+        switch self {
+        case .r32Uint, .r32Sint:
+            return true
+        case .r32Float:
+            return true
+        case .rg16Unorm, .rg16Snorm, .rg16Uint, .rg16Sint:
+            return true
+        case .rg16Float:
+            return true
+        case .rgba8Unorm, .rgba8Snorm, .rgba8Uint, .rgba8Sint, .bgra8Unorm:
+            return true
+        case .rgba8Unorm_srgb, .bgra8Unorm_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a packed 32-bit format
+    var isPacked32Bit: Bool {
+        switch self {
+        case .rgb10a2Unorm, .rgb10a2Uint, .bgr10a2Unorm:
+            return true
+        case .rg11b10Float:
+            return true
+        case .rgb9e5Float:
+            return true
+        case .bgr10_xr, .bgr10_xr_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a 64-bit format
+    var is64Bit: Bool {
+        switch self {
+        case .rg32Uint, .rg32Sint:
+            return true
+        case .rg32Float:
+            return true
+        case .rgba16Unorm, .rgba16Snorm, .rgba16Uint, .rgba16Sint:
+            return true
+        case .rgba16Float:
+            return true
+        case .bgra10_xr, .bgra10_xr_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a 128-bit format
+    var is128Bit: Bool {
+        switch self {
+        case .rgba32Uint, .rgba32Sint:
+            return true
+        case .rgba32Float:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format will trigger automatic sRGB gamma encoding and decoding
+    var isSRGB: Bool {
+        switch self {
+        case .r8Unorm_srgb, .rg8Unorm_srgb, .bgra8Unorm_srgb, .rgba8Unorm_srgb:
+            return true
+        case .bgr10_xr_srgb, .bgra10_xr_srgb:
+            return true
+        case .astc_4x4_srgb, .astc_5x4_srgb, .astc_5x5_srgb, .astc_6x5_srgb, .astc_6x6_srgb, .astc_8x5_srgb,
+            .astc_8x6_srgb, .astc_8x8_srgb, .astc_10x5_srgb, .astc_10x6_srgb, .astc_10x8_srgb, .astc_10x10_srgb,
+            .astc_12x10_srgb, .astc_12x12_srgb:
+            return true
+        case .bc1_rgba_srgb, .bc2_rgba_srgb, .bc3_rgba_srgb, .bc7_rgbaUnorm_srgb:
+            return true
+        case .eac_rgba8_srgb, .etc2_rgb8, .etc2_rgb8a1_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is an extended dynamic range (EDR) format
+    var isEDR: Bool {
+        switch self {
+        case .bgr10_xr, .bgra10_xr, .bgr10_xr_srgb, .bgra10_xr_srgb:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format uses a form of texture compression
+    var isCompressed: Bool {
+        switch self {
+        // S3TC
+        case .bc1_rgba, .bc1_rgba_srgb, .bc2_rgba, .bc2_rgba_srgb, .bc3_rgba, .bc3_rgba_srgb:
+            return true
+        // RGTC
+        case .bc4_rUnorm, .bc4_rSnorm, .bc5_rgUnorm, .bc5_rgSnorm:
+            return true
+        // BPTC
+        case .bc6H_rgbFloat, .bc6H_rgbuFloat, .bc7_rgbaUnorm, .bc7_rgbaUnorm_srgb:
+            return true
+        // EAC
+        case .eac_r11Unorm, .eac_r11Snorm, .eac_rg11Unorm, .eac_rg11Snorm, .eac_rgba8, .eac_rgba8_srgb:
+            return true
+        // ETC
+        case .etc2_rgb8, .etc2_rgb8_srgb, .etc2_rgb8a1, .etc2_rgb8a1_srgb:
+            return true
+        // ASTC
+        case .astc_4x4_srgb, .astc_5x4_srgb, .astc_5x5_srgb, .astc_6x5_srgb, .astc_6x6_srgb, .astc_8x5_srgb,
+            .astc_8x6_srgb, .astc_8x8_srgb, .astc_10x5_srgb, .astc_10x6_srgb, .astc_10x8_srgb, .astc_10x10_srgb,
+            .astc_12x10_srgb, .astc_12x12_srgb, .astc_4x4_ldr, .astc_5x4_ldr, .astc_5x5_ldr, .astc_6x5_ldr,
+            .astc_6x6_ldr, .astc_8x5_ldr, .astc_8x6_ldr, .astc_8x8_ldr, .astc_10x5_ldr, .astc_10x6_ldr, .astc_10x8_ldr,
+            .astc_10x10_ldr, .astc_12x10_ldr, .astc_12x12_ldr:
+            return true
+        // ASTC HDR
+        case .astc_4x4_hdr, .astc_5x4_hdr, .astc_5x5_hdr, .astc_6x5_hdr, .astc_6x6_hdr, .astc_8x5_hdr, .astc_8x6_hdr,
+            .astc_8x8_hdr, .astc_10x5_hdr, .astc_10x6_hdr, .astc_10x8_hdr, .astc_10x10_hdr, .astc_12x10_hdr,
+            .astc_12x12_hdr:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is a depth buffer format
+    var isDepth: Bool {
+        switch self {
+        case .depth16Unorm, .depth32Float:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Property to check whether the pixel format is depth stencil format
+    var isStencil: Bool {
+        switch self {
+        case .stencil8, .x24_stencil8, .x32_stencil8, .depth24Unorm_stencil8, .depth32Float_stencil8:
+            return true
+        default:
+            return false
+        }
+    }
+
+    /// Returns number of color components used by the pixel format
+    var componentCount: Int? {
+        switch self {
+        case .a8Unorm, .r8Unorm, .r8Snorm, .r8Uint, .r8Sint, .r8Unorm_srgb:
+            return 1
+        case .r16Unorm, .r16Snorm, .r16Uint, .r16Sint, .r16Float:
+            return 1
+        case .r32Uint, .r32Sint, .r32Float:
+            return 1
+        case .rg8Unorm, .rg8Snorm, .rg8Uint, .rg8Sint, .rg8Unorm_srgb:
+            return 2
+        case .rg16Unorm, .rg16Snorm, .rg16Uint, .rg16Sint:
+            return 2
+        case .rg32Uint, .rg32Sint, .rg32Float:
+            return 2
+        case .b5g6r5Unorm, .rg11b10Float, .rgb9e5Float, .gbgr422, .bgrg422:
+            return 3
+        case .a1bgr5Unorm, .abgr4Unorm, .bgr5A1Unorm:
+            return 4
+        case .rgba8Unorm, .rgba8Snorm, .rgba8Uint, .rgba8Sint, .rgba8Unorm_srgb, .bgra8Unorm, .bgra8Unorm_srgb:
+            return 4
+        case .rgb10a2Unorm, .rgb10a2Uint, .bgr10a2Unorm, .bgr10_xr, .bgr10_xr_srgb:
+            return 4
+        case .rgba16Unorm, .rgba16Snorm, .rgba16Uint, .rgba16Sint, .rgba16Float:
+            return 4
+        case .rgba32Uint, .rgba32Sint, .rgba32Float:
+            return 4
+        case .bc4_rUnorm, .bc4_rSnorm, .eac_r11Unorm, .eac_r11Snorm:
+            return 1
+        case .bc5_rgUnorm, .bc5_rgSnorm:
+            return 2
+        case .bc6H_rgbFloat, .bc6H_rgbuFloat, .eac_rg11Unorm, .eac_rg11Snorm, .etc2_rgb8, .etc2_rgb8_srgb:
+            return 3
+        case .bc1_rgba, .bc1_rgba_srgb, .bc2_rgba, .bc2_rgba_srgb, .bc3_rgba, .bc3_rgba_srgb, .etc2_rgb8a1,
+            .etc2_rgb8a1_srgb, .eac_rgba8, .eac_rgba8_srgb, .bc7_rgbaUnorm, .bc7_rgbaUnorm_srgb:
+            return 4
+        default:
+            return nil
+        }
+    }
+
+    /// Conversion of pixel format to `libobs` color format
+    var gsColorFormat: gs_color_format {
+        switch self {
+        case .a8Unorm:
+            return GS_A8
+        case .r8Unorm:
+            return GS_R8
+        case .rgba8Unorm:
+            return GS_RGBA
+        case .bgra8Unorm:
+            return GS_BGRA
+        case .rgb10a2Unorm:
+            return GS_R10G10B10A2
+        case .rgba16Unorm:
+            return GS_RGBA16
+        case .r16Unorm:
+            return GS_R16
+        case .rgba16Float:
+            return GS_RGBA16F
+        case .rgba32Float:
+            return GS_RGBA32F
+        case .rg16Float:
+            return GS_RG16F
+        case .rg32Float:
+            return GS_RG32F
+        case .r16Float:
+            return GS_R16F
+        case .r32Float:
+            return GS_R32F
+        case .bc1_rgba:
+            return GS_DXT1
+        case .bc2_rgba:
+            return GS_DXT3
+        case .bc3_rgba:
+            return GS_DXT5
+        default:
+            return GS_UNKNOWN
+        }
+    }
+
+    /// Returns the bits per pixel based on the pixel format
+    var bitsPerPixel: Int? {
+        if self.is8Bit {
+            return 8
+        } else if self.is16Bit || self.isPacked16Bit {
+            return 16
+        } else if self.is32Bit || self.isPacked32Bit {
+            return 32
+        } else if self.is64Bit {
+            return 64
+        } else if self.is128Bit {
+            return 128
+        } else {
+            return nil
+        }
+    }
+
+    /// Returns the bytes per pixel based on the pixel format
+    var bytesPerPixel: Int? {
+        if self.is8Bit {
+            return 1
+        } else if self.is16Bit || self.isPacked16Bit {
+            return 2
+        } else if self.is32Bit {
+            return 4
+        } else if self.isPacked32Bit {
+            switch self {
+            case .rgb10a2Unorm, .rgb10a2Uint, .bgr10a2Unorm, .rg11b10Float, .rgb9e5Float:
+                return 4
+            case .bgr10_xr, .bgr10_xr_srgb:
+                return 8
+            default:
+                return nil
+            }
+        } else if self.is64Bit {
+            return 8
+        } else {
+            return nil
+        }
+    }
+
+    /// Returns the bytes used per color component of the pixel format
+    var bitsPerComponent: Int? {
+        if !self.isCompressed {
+            if let bitsPerPixel = self.bitsPerPixel, let componentCount = self.componentCount {
+                return bitsPerPixel / componentCount
+            }
+        }
+
+        return nil
+    }
+}
+
+extension MTLPixelFormat {
+    /// Converts the pixel format into a compatible CoreGraphics color space
+    var colorSpace: CGColorSpace? {
+        switch self {
+        case .a8Unorm, .r8Unorm, .r8Snorm, .r8Uint, .r8Sint, .r16Unorm, .r16Snorm, .r16Uint, .r16Sint,
+            .r16Float, .r32Uint, .r32Sint, .r32Float:
+            return CGColorSpace(name: CGColorSpace.linearGray)
+        case .rg8Unorm, .rg8Snorm, .rg8Uint, .rg8Sint, .rgba8Unorm, .rgba8Snorm, .rgba8Uint, .rgba8Sint, .bgra8Unorm,
+            .rgba16Unorm, .rgba16Snorm, .rgba16Uint, .rgba16Sint:
+            return CGColorSpace(name: CGColorSpace.linearSRGB)
+        case .rg8Unorm_srgb, .rgba8Unorm_srgb, .bgra8Unorm_srgb:
+            return CGColorSpace(name: CGColorSpace.sRGB)
+        case .rg16Float, .rg32Float, .rgba16Float, .rgba32Float, .bgr10_xr, .bgr10a2Unorm:
+            return CGColorSpace(name: CGColorSpace.extendedLinearSRGB)
+        case .bgr10_xr_srgb:
+            return CGColorSpace(name: CGColorSpace.extendedSRGB)
+        default:
+            return nil
+        }
+    }
+}
+
+extension MTLPixelFormat {
+    /// Initializes a ``MTLPixelFormat`` with a compatible CoreVideo video pixel format
+    init?(osType: OSType) {
+        guard let pixelFormat = osType.mtlFormat else {
+            return nil
+        }
+
+        self = pixelFormat
+    }
+
+    /// Conversion of the pixel format into a compatible CoreVideo video pixel format
+    var videoPixelFormat: OSType? {
+        switch self {
+        case .r8Unorm, .r8Unorm_srgb:
+            return kCVPixelFormatType_OneComponent8
+        case .r16Float:
+            return kCVPixelFormatType_OneComponent16Half
+        case .r32Float:
+            return kCVPixelFormatType_OneComponent32Float
+        case .rg8Unorm, .rg8Unorm_srgb:
+            return kCVPixelFormatType_TwoComponent8
+        case .rg16Float:
+            return kCVPixelFormatType_TwoComponent16Half
+        case .rg32Float:
+            return kCVPixelFormatType_TwoComponent32Float
+        case .bgra8Unorm, .bgra8Unorm_srgb:
+            return kCVPixelFormatType_32BGRA
+        case .rgba8Unorm, .rgba8Unorm_srgb:
+            return kCVPixelFormatType_32RGBA
+        case .rgba16Float:
+            return kCVPixelFormatType_64RGBAHalf
+        case .rgba32Float:
+            return kCVPixelFormatType_128RGBAFloat
+        default:
+            return nil
+        }
+    }
+}

+ 25 - 0
libobs-metal/MTLRegion+Extensions.swift

@@ -0,0 +1,25 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLRegion: @retroactive Equatable {
+    public static func == (lhs: MTLRegion, rhs: MTLRegion) -> Bool {
+        lhs.origin == rhs.origin && lhs.size == rhs.size
+    }
+}

+ 25 - 0
libobs-metal/MTLSize+Extensions.swift

@@ -0,0 +1,25 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLSize: @retroactive Equatable {
+    public static func == (lhs: MTLSize, rhs: MTLSize) -> Bool {
+        lhs.width == rhs.width && lhs.height == rhs.height && lhs.depth == rhs.depth
+    }
+}

+ 76 - 0
libobs-metal/MTLTexture+Extensions.swift

@@ -0,0 +1,76 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLTexture {
+    /// Creates an opaque pointer of a ``MTLTexture`` instance and increases the reference count.
+    /// - Returns: Opaque pointer for the ``MTLTexture``
+    func getRetained() -> OpaquePointer {
+        let retained = Unmanaged.passRetained(self).toOpaque()
+
+        return OpaquePointer(retained)
+    }
+
+    /// Creates an opaque pointer of a ``MTLTexture`` instance without increasing the reference count.
+    /// - Returns: Opaque pointer for the ``MTLTexture``
+    func getUnretained() -> OpaquePointer {
+        let unretained = Unmanaged.passUnretained(self).toOpaque()
+
+        return OpaquePointer(unretained)
+    }
+}
+
+extension MTLTexture {
+    /// Convenience property to get the texture's size as a ``MTLSize`` object
+    var size: MTLSize {
+        .init(
+            width: self.width,
+            height: self.height,
+            depth: self.depth
+        )
+    }
+
+    /// Convenience property to get the texture's region as a ``MTLRegion`` object
+    var region: MTLRegion {
+        .init(
+            origin: .init(x: 0, y: 0, z: 0),
+            size: self.size
+        )
+    }
+
+    /// Gets a new ``MTLTextureDescriptor`` instance with the properties of the texture
+    var descriptor: MTLTextureDescriptor {
+        let descriptor = MTLTextureDescriptor()
+
+        descriptor.textureType = self.textureType
+        descriptor.pixelFormat = self.pixelFormat
+        descriptor.width = self.width
+        descriptor.height = self.height
+        descriptor.depth = self.depth
+        descriptor.mipmapLevelCount = self.mipmapLevelCount
+        descriptor.sampleCount = self.sampleCount
+        descriptor.arrayLength = self.arrayLength
+        descriptor.storageMode = self.storageMode
+        descriptor.cpuCacheMode = self.cpuCacheMode
+        descriptor.usage = self.usage
+        descriptor.allowGPUOptimizedContents = self.allowGPUOptimizedContents
+
+        return descriptor
+    }
+}

+ 93 - 0
libobs-metal/MTLTextureDescriptor+Extensions.swift

@@ -0,0 +1,93 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Metal
+
+extension MTLTextureDescriptor {
+
+    /// Convenience initializer for a texture descriptor with `libobs` data
+    /// - Parameters:
+    ///   - type: Metal texture type
+    ///   - width: Width of texture
+    ///   - height: Height of texture
+    ///   - depth: Depth of texture
+    ///   - colorFormat: `libobs` color format for the texture
+    ///   - levels: Mip map levels
+    ///   - flags: Additional usage flags as `libobs` bitfield
+    convenience init?(
+        type: MTLTextureType, width: UInt32, height: UInt32, depth: UInt32, colorFormat: gs_color_format,
+        levels: UInt32, flags: UInt32
+    ) {
+        let arrayLength: Int
+        switch type {
+        case .type2D:
+            arrayLength = 1
+        case .type3D:
+            arrayLength = 1
+        case .typeCube:
+            arrayLength = 6
+        default:
+            assertionFailure("MTLTextureDescriptor: Unsupported texture type for libobs initializer")
+            return nil
+        }
+
+        self.init()
+
+        self.textureType = type
+        self.pixelFormat = colorFormat.mtlFormat
+        self.width = Int(width)
+        self.height = Int(height)
+        self.depth = Int(depth)
+        self.sampleCount = 1
+        self.arrayLength = arrayLength
+        self.cpuCacheMode = .defaultCache
+        self.allowGPUOptimizedContents = true
+        self.hazardTrackingMode = .default
+
+        if (Int32(flags) & GS_BUILD_MIPMAPS) != 0 {
+            self.mipmapLevelCount = Int(levels)
+        } else {
+            self.mipmapLevelCount = 1
+        }
+
+        if (Int32(flags) & GS_RENDER_TARGET) != 0 {
+            self.storageMode = .private
+            self.usage = [.shaderRead, .renderTarget]
+        } else {
+            self.storageMode = .shared
+            self.usage = [.shaderRead]
+        }
+    }
+
+    convenience init?(width: UInt32, height: UInt32, colorFormat: gs_zstencil_format) {
+        self.init()
+
+        self.textureType = .type2D
+        self.pixelFormat = colorFormat.mtlFormat
+        self.width = Int(width)
+        self.height = Int(height)
+        self.depth = 1
+        self.sampleCount = 1
+        self.arrayLength = 1
+        self.cpuCacheMode = .defaultCache
+        self.allowGPUOptimizedContents = true
+        self.hazardTrackingMode = .default
+        self.mipmapLevelCount = 1
+        self.storageMode = .private
+        self.usage = [.shaderRead]
+    }
+}

+ 36 - 0
libobs-metal/MTLTextureType+Extensions.swift

@@ -0,0 +1,36 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLTextureType {
+    /// Converts the Metal texture type into a compatible `libobs` texture type or `nil` if no compatible mapping is
+    /// possible.
+    var gsTextureType: gs_texture_type? {
+        switch self {
+        case .type2D:
+            return GS_TEXTURE_2D
+        case .type3D:
+            return GS_TEXTURE_3D
+        case .typeCube:
+            return GS_TEXTURE_CUBE
+        default:
+            return nil
+        }
+    }
+}

+ 31 - 0
libobs-metal/MTLViewport+Extensions.swift

@@ -0,0 +1,31 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+extension MTLViewport: @retroactive Equatable {
+    /// Checks two ``MTLViewPort`` objects for equality
+    /// - Parameters:
+    ///   - lhs: First ``MTLViewPort``object
+    ///   - rhs: Second ``MTLViewPort`` object
+    /// - Returns: `true` if the dimensions and origins of both view ports match, `false` otherwise.
+    public static func == (lhs: MTLViewport, rhs: MTLViewport) -> Bool {
+        lhs.width == rhs.width && lhs.height == rhs.height && lhs.originX == rhs.originX
+            && lhs.originY == rhs.originY
+    }
+}

+ 308 - 0
libobs-metal/MetalBuffer.swift

@@ -0,0 +1,308 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+enum MetalBufferType {
+    case vertex
+    case index
+}
+
+/// The MetalBuffer class serves as the super class for both vertex and index buffer objects.
+///
+/// It provides convenience functions to pass buffer instances as retained and unretained opaque pointers and provides
+/// a generic buffer factory method.
+class MetalBuffer {
+    enum BufferDataType {
+        case vertex
+        case normal
+        case tangent
+        case color
+        case texcoord
+    }
+
+    private let device: MTLDevice
+    fileprivate let isDynamic: Bool
+
+    init(device: MetalDevice, isDynamic: Bool) {
+        self.device = device.device
+        self.isDynamic = isDynamic
+    }
+
+    /// Creates a new buffer with the provided data or updates an existing buffer with the provided data
+    /// - Parameters:
+    ///   - buffer: Reference to a buffer variable to either receive the new buffer or provide an existing buffer
+    ///   - data: Pointer to raw data of provided type `T`
+    ///   - count: Byte size of data to be written into the buffer
+    ///   - dynamic: `true` if underlying buffer is dynamically updated for each frame, `false` otherwise.
+    ///
+    /// > Note: Some sources (like the `text-freetype2` source) generate "dynamic" buffers but don't update them at
+    /// every frame and instead treat them as "static" buffers. For this reason `MTLBuffer` objects have to be cached
+    /// and re-used per `MetalBuffer` instance and cannot be dynamically provided from a pool of buffers of a `MTLHeap`.
+    fileprivate func createOrUpdateBuffer<T>(
+        buffer: inout MTLBuffer?, data: UnsafeMutablePointer<T>, count: Int, dynamic: Bool
+    ) {
+        let size = MemoryLayout<T>.size * count
+        let alignedSize = (size + 15) & ~15
+
+        if buffer != nil {
+            if dynamic && buffer!.length == alignedSize {
+                buffer!.contents().copyMemory(from: data, byteCount: size)
+                return
+            }
+        }
+
+        buffer = device.makeBuffer(
+            bytes: data, length: alignedSize, options: [.cpuCacheModeWriteCombined, .storageModeShared])
+    }
+
+    /// Gets an opaque pointer for the ``MetalBuffer`` instance and increases its reference count by one
+    /// - Returns: `OpaquePointer` to class instance
+    ///
+    /// > Note: Use this method when the instance is to be shared via an `OpaquePointer` and needs to be retained. Any
+    /// opaque pointer shared this way needs to be converted into a retained reference again to ensure automatic
+    /// deinitialization by the Swift runtime.
+    func getRetained() -> OpaquePointer {
+        let retained = Unmanaged.passRetained(self).toOpaque()
+
+        return OpaquePointer(retained)
+    }
+
+    /// Gets an opaque pointer for the ``MetalBuffer`` instance without increasing its reference count
+    /// - Returns: `OpaquePointer` to class instance
+    func getUnretained() -> OpaquePointer {
+        let unretained = Unmanaged.passUnretained(self).toOpaque()
+
+        return OpaquePointer(unretained)
+    }
+}
+
+final class MetalVertexBuffer: MetalBuffer {
+    public var vertexData: UnsafeMutablePointer<gs_vb_data>?
+    private var points: MTLBuffer?
+    private var normals: MTLBuffer?
+    private var tangents: MTLBuffer?
+    private var vertexColors: MTLBuffer?
+    private var uvCoordinates: [MTLBuffer?]
+
+    init(device: MetalDevice, data: UnsafeMutablePointer<gs_vb_data>, dynamic: Bool) {
+        self.vertexData = data
+        self.uvCoordinates = Array(repeating: nil, count: data.pointee.num_tex)
+
+        super.init(device: device, isDynamic: dynamic)
+
+        if !dynamic {
+            setupBuffers()
+        }
+    }
+
+    /// Sets up buffer objects for the data provided in the provided `gs_vb_data` structure
+    /// - Parameter data: Pointer to a `gs_vb_data` instance
+    ///
+    /// The provided `gs_vb_data` instance is expected to:
+    /// * Always contain vertex data
+    /// * Optionally contain normals data
+    /// * Optionally contain tangents data
+    /// * Optionally contain color data
+    /// * Optionally contain either 2 or 4 texture coordinates per vertex
+    ///
+    /// > Note: The color data needs to be converted from the packed UInt32 format used by `libobs` into a normalized
+    /// vector of Float32 values as Metal does not support implicit conversion of these types when vertex data is
+    /// provided in a single buffer to a vertex shader.
+    public func setupBuffers(data: UnsafeMutablePointer<gs_vb_data>? = nil) {
+        guard let data = data ?? self.vertexData else {
+            assertionFailure("MetalBuffer: Unable to create MTLBuffers without vertex data")
+            return
+        }
+
+        let numVertices = data.pointee.num
+
+        createOrUpdateBuffer(buffer: &points, data: data.pointee.points, count: numVertices, dynamic: isDynamic)
+
+        #if DEBUG
+            points?.label = "Vertex buffer points data"
+        #endif
+
+        if let normalsData = data.pointee.normals {
+            createOrUpdateBuffer(buffer: &normals, data: normalsData, count: numVertices, dynamic: isDynamic)
+
+            #if DEBUG
+                normals?.label = "Vertex buffer normals data"
+            #endif
+        }
+
+        if let tangentsData = data.pointee.tangents {
+            createOrUpdateBuffer(buffer: &tangents, data: tangentsData, count: numVertices, dynamic: isDynamic)
+
+            #if DEBUG
+                tangents?.label = "Vertex buffer tangents data"
+            #endif
+        }
+
+        if let colorsData = data.pointee.colors {
+            var unpackedColors = [SIMD4<Float>]()
+            unpackedColors.reserveCapacity(4)
+
+            for i in 0..<numVertices {
+                let vertexColor = colorsData.advanced(by: i)
+
+                vertexColor.withMemoryRebound(to: UInt8.self, capacity: 4) {
+                    let colorValues = UnsafeBufferPointer<UInt8>(start: $0, count: 4)
+
+                    let color = SIMD4<Float>(
+                        x: Float(colorValues[0]) / 255.0,
+                        y: Float(colorValues[1]) / 255.0,
+                        z: Float(colorValues[2]) / 255.0,
+                        w: Float(colorValues[3]) / 255.0
+                    )
+
+                    unpackedColors.append(color)
+                }
+            }
+
+            unpackedColors.withUnsafeMutableBufferPointer {
+                createOrUpdateBuffer(
+                    buffer: &vertexColors, data: $0.baseAddress!, count: numVertices, dynamic: isDynamic)
+            }
+
+            #if DEBUG
+                vertexColors?.label = "Vertex buffer colors data"
+            #endif
+        }
+
+        guard data.pointee.num_tex > 0 else {
+            return
+        }
+
+        let textureVertices = UnsafeMutableBufferPointer<gs_tvertarray>(
+            start: data.pointee.tvarray, count: data.pointee.num_tex)
+
+        for (textureSlot, textureVertex) in textureVertices.enumerated() {
+            textureVertex.array.withMemoryRebound(to: Float32.self, capacity: textureVertex.width * numVertices) {
+                createOrUpdateBuffer(
+                    buffer: &uvCoordinates[textureSlot], data: $0, count: textureVertex.width * numVertices,
+                    dynamic: isDynamic)
+            }
+
+            #if DEBUG
+                uvCoordinates[textureSlot]?.label = "Vertex buffer texture uv data (texture slot \(textureSlot))"
+            #endif
+        }
+    }
+
+    /// Gets a collection of all ` MTLBuffer` objects created for the vertex data contained in the ``MetalBuffer``.
+    /// - Parameter shader: ``MetalShader`` instance for which the buffers will be used
+    /// - Returns: Array for `MTLBuffer`s in the order required by the shader
+    ///
+    /// > Important: To ensure that the data in the buffers is aligned with the structures declared in the shaders,
+    /// each ``MetalShader`` provides a "buffer order". The corresponding collection will contain the associated
+    /// ``MTLBuffer`` objects in this order.
+    public func getShaderBuffers(for shader: MetalShader) -> [MTLBuffer] {
+        var bufferList = [MTLBuffer]()
+
+        for bufferType in shader.bufferOrder {
+            switch bufferType {
+            case .vertex:
+                if let points {
+                    bufferList.append(points)
+                }
+            case .normal:
+                if let normals { bufferList.append(normals) }
+            case .tangent:
+                if let tangents { bufferList.append(tangents) }
+            case .color:
+                if let vertexColors { bufferList.append(vertexColors) }
+            case .texcoord:
+                guard shader.textureCount == uvCoordinates.count else {
+                    assertionFailure(
+                        "MetalBuffer: Amount of available texture uv coordinates not sufficient for vertex shader")
+                    break
+                }
+
+                for i in 0..<shader.textureCount {
+                    if let uvCoordinate = uvCoordinates[i] {
+                        bufferList.append(uvCoordinate)
+                    }
+                }
+            }
+        }
+
+        return bufferList
+    }
+
+    deinit {
+        gs_vbdata_destroy(vertexData)
+    }
+}
+
+final class MetalIndexBuffer: MetalBuffer {
+    public var indexData: UnsafeMutableRawPointer?
+    public var count: Int
+    public var type: MTLIndexType
+
+    var indices: MTLBuffer?
+
+    init(device: MetalDevice, type: MTLIndexType, data: UnsafeMutableRawPointer?, count: Int, dynamic: Bool) {
+        self.indexData = data
+        self.count = count
+        self.type = type
+
+        super.init(device: device, isDynamic: dynamic)
+
+        if !dynamic {
+            setupBuffers()
+        }
+    }
+
+    /// Sets up buffer objects for the data provided in the provided memory location
+    /// - Parameter data: Pointer to bytes representing index buffer data
+    ///
+    /// The provided memory location is expected to provide bytes represnting index buffer data as either unsigned
+    /// 16-bit integers or unsigned 32-bit integers. The size depends on the type used to create the
+    /// ``MetalIndexBuffer`` instance.
+    public func setupBuffers(_ data: UnsafeMutableRawPointer? = nil) {
+        guard let indexData = data ?? indexData else {
+            assertionFailure("MetalIndexBuffer: Unable to generate MTLBuffer without buffer data")
+            return
+        }
+
+        let byteSize =
+            switch type {
+            case .uint16: 2 * count
+            case .uint32: 4 * count
+            @unknown default:
+                fatalError("MTLIndexType \(type) is not supported")
+            }
+
+        indexData.withMemoryRebound(to: UInt8.self, capacity: byteSize) {
+            createOrUpdateBuffer(buffer: &indices, data: $0, count: byteSize, dynamic: isDynamic)
+        }
+
+        #if DEBUG
+            if !isDynamic {
+                indices?.label = "Index buffer static data"
+            } else {
+                indices?.label = "Index buffer dynamic data"
+            }
+        #endif
+    }
+
+    deinit {
+        bfree(indexData)
+    }
+}

+ 786 - 0
libobs-metal/MetalDevice.swift

@@ -0,0 +1,786 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import AppKit
+import Foundation
+import Metal
+import simd
+
+/// Describes which clear actions to take when an explicit clear is requested
+struct ClearState {
+    var colorAction: MTLLoadAction = .dontCare
+    var depthAction: MTLLoadAction = .dontCare
+    var stencilAction: MTLLoadAction = .dontCare
+    var clearColor: MTLClearColor = MTLClearColor()
+    var clearDepth: Double = 0.0
+    var clearStencil: UInt32 = 0
+    var clearTarget: MetalTexture? = nil
+}
+
+/// Object wrapping an `MTLDevice` object and providing convenience functions for interaction with `libobs`
+class MetalDevice {
+    private let identityMatrix = matrix_float4x4.init(diagonal: SIMD4(1.0, 1.0, 1.0, 1.0))
+    private let fallbackVertexBuffer: MTLBuffer
+    private var nopVertexFunction: MTLFunction
+    private var pipelines = [Int: MTLRenderPipelineState]()
+    private var depthStencilStates = [Int: MTLDepthStencilState]()
+    private var obsSignalCallbacks = [MetalSignalType: () -> Void]()
+    private var displayLink: CVDisplayLink?
+
+    let device: MTLDevice
+    let commandQueue: MTLCommandQueue
+    var renderState: MetalRenderState
+    var swapChains = [OBSSwapChain]()
+    let swapChainQueue = DispatchQueue(label: "swapchainUpdateQueue", qos: .userInteractive)
+
+    init(device: MTLDevice) throws {
+        self.device = device
+
+        guard let commandQueue = device.makeCommandQueue() else {
+            throw MetalError.MTLDeviceError.commandQueueCreationFailure
+        }
+
+        guard let buffer = device.makeBuffer(length: 1, options: .storageModePrivate) else {
+            throw MetalError.MTLDeviceError.bufferCreationFailure("Fallback vertex buffer")
+        }
+
+        let nopVertexSource = "[[vertex]] float4 vsNop() { return (float4)0; }"
+
+        let compileOptions = MTLCompileOptions()
+        if #available(macOS 15, *) {
+            compileOptions.mathMode = .fast
+        } else {
+            compileOptions.fastMathEnabled = true
+        }
+
+        guard let library = try? device.makeLibrary(source: nopVertexSource, options: compileOptions),
+            let function = library.makeFunction(name: "vsNop")
+        else {
+            throw MetalError.MTLDeviceError.shaderCompilationFailure("Vertex NOP shader")
+        }
+
+        CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
+        if displayLink == nil {
+            throw MetalError.MTLDeviceError.displayLinkCreationFailure
+        }
+
+        self.commandQueue = commandQueue
+        self.nopVertexFunction = function
+        self.fallbackVertexBuffer = buffer
+
+        self.renderState = MetalRenderState(
+            viewMatrix: identityMatrix,
+            projectionMatrix: identityMatrix,
+            viewProjectionMatrix: identityMatrix,
+            scissorRectEnabled: false,
+            gsColorSpace: GS_CS_SRGB
+        )
+
+        let clearPipelineDescriptor = renderState.clearPipelineDescriptor
+        clearPipelineDescriptor.colorAttachments[0].isBlendingEnabled = false
+        clearPipelineDescriptor.vertexFunction = nopVertexFunction
+        clearPipelineDescriptor.fragmentFunction = nil
+        clearPipelineDescriptor.inputPrimitiveTopology = .point
+
+        setupSignalHandlers()
+        setupDisplayLink()
+    }
+
+    func dispatchSignal(type: MetalSignalType) {
+        if let callback = obsSignalCallbacks[type] {
+            callback()
+        }
+    }
+
+    /// Creates signal handlers for specific OBS signals and adds them to a collection of signal handlers using the signal name as their key
+    private func setupSignalHandlers() {
+        let videoResetCallback = { [self] in
+            guard let displayLink else { return }
+
+            CVDisplayLinkStop(displayLink)
+            CVDisplayLinkStart(displayLink)
+        }
+
+        obsSignalCallbacks.updateValue(videoResetCallback, forKey: MetalSignalType.videoReset)
+    }
+
+    /// Sets up the `CVDisplayLink` used by the ``MetalDevice`` to synchronize projector output with the operating
+    /// system's screen refresh rate.
+    private func setupDisplayLink() {
+        func displayLinkCallback(
+            displayLink: CVDisplayLink,
+            _ now: UnsafePointer<CVTimeStamp>,
+            _ outputTime: UnsafePointer<CVTimeStamp>,
+            _ flagsIn: CVOptionFlags,
+            _ flagsOut: UnsafeMutablePointer<CVOptionFlags>,
+            _ displayLinkContext: UnsafeMutableRawPointer?
+        ) -> CVReturn {
+            guard let displayLinkContext else { return kCVReturnSuccess }
+
+            let metalDevice = unsafeBitCast(displayLinkContext, to: MetalDevice.self)
+
+            metalDevice.blitSwapChains()
+
+            return kCVReturnSuccess
+        }
+
+        let opaqueSelf = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
+
+        CVDisplayLinkSetOutputCallback(displayLink!, displayLinkCallback, opaqueSelf)
+    }
+
+    /// Iterates over all ``OBSSwapChain`` instances present on the ``MetalDevice`` instance and encodes a block
+    /// transfer command on the GPU to copy the contents of the projector rendered by `libobs`'s render loop into the
+    /// drawable provided by a `CAMetalLayer`.
+    func blitSwapChains() {
+        guard swapChains.count > 0 else { return }
+
+        guard let commandBuffer = commandQueue.makeCommandBuffer(),
+            let encoder = commandBuffer.makeBlitCommandEncoder()
+        else {
+            return
+        }
+
+        self.swapChainQueue.sync {
+            swapChains = swapChains.filter { $0.discard == false }
+        }
+
+        for swapChain in swapChains {
+            guard let renderTarget = swapChain.renderTarget, let drawable = swapChain.layer.nextDrawable() else {
+                continue
+            }
+
+            guard renderTarget.texture.width == drawable.texture.width,
+                renderTarget.texture.height == drawable.texture.height,
+                renderTarget.texture.pixelFormat == drawable.texture.pixelFormat
+            else {
+                continue
+            }
+
+            autoreleasepool {
+                encoder.waitForFence(swapChain.fence)
+                encoder.copy(from: renderTarget.texture, to: drawable.texture)
+
+                commandBuffer.addScheduledHandler { _ in
+                    drawable.present()
+                }
+            }
+        }
+
+        encoder.endEncoding()
+        commandBuffer.commit()
+    }
+
+    /// Simulates an explicit "clear" command commonly used in OpenGL or Direct3D11 implementations.
+    /// - Parameter state: A ``ClearState`` object holding the requested clear actions
+    ///
+    /// Metal (like Direct3D12 and Vulkan) does not have an explicit clear command anymore. Devices with M- and
+    /// A-series SOCs have deferred tile-based GPUs which do not load render targets as single large textures, but
+    /// instead interact with textures via tiles. A load and store command is executed every time this occurs and a
+    /// clear is achieved via a load command.
+    ///
+    /// If no actual rendering occurs however, no load or store commands are executed, and a render target will be
+    /// "untouched". This would lead to issues in situations like switching to an empty scene, as the lack of any
+    /// sources would trigger no draw calls.
+    ///
+    /// Thus an explicit draw call needs to be scheduled to achieve the same outcome as the explicit "clear" call in
+    /// legacy APIs. This is achieved using the most lightweight pipeline possible:
+    /// * A single vertex shader that returns 0 for all points
+    /// * No fragment shader
+    /// * Just load and store commands
+    ///
+    /// While this is indeed more inefficient than the "native" approach, it is the best way to ensure expected
+    /// output with `libobs` rendering system.
+    ///
+    func clear(state: ClearState) throws {
+        try ensureCommandBuffer()
+
+        let commandBuffer = renderState.commandBuffer!
+
+        guard let renderTarget = renderState.renderTarget else {
+            return
+        }
+
+        let pipelineDescriptor = renderState.clearPipelineDescriptor
+
+        if renderState.useSRGBGamma && renderTarget.sRGBtexture != nil {
+            pipelineDescriptor.colorAttachments[0].pixelFormat = renderTarget.sRGBtexture!.pixelFormat
+        } else {
+            pipelineDescriptor.colorAttachments[0].pixelFormat = renderTarget.texture.pixelFormat
+        }
+
+        pipelineDescriptor.colorAttachments[0].isBlendingEnabled = false
+
+        if let depthStencilAttachment = renderState.depthStencilAttachment {
+            pipelineDescriptor.depthAttachmentPixelFormat = depthStencilAttachment.texture.pixelFormat
+            pipelineDescriptor.stencilAttachmentPixelFormat = depthStencilAttachment.texture.pixelFormat
+        } else {
+            pipelineDescriptor.depthAttachmentPixelFormat = .invalid
+            pipelineDescriptor.stencilAttachmentPixelFormat = .invalid
+        }
+
+        let stateHash = pipelineDescriptor.hashValue
+
+        let renderPipelineState: MTLRenderPipelineState
+
+        if let pipelineState = pipelines[stateHash] {
+            renderPipelineState = pipelineState
+        } else {
+            do {
+                let pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
+                pipelines.updateValue(pipelineState, forKey: stateHash)
+
+                renderPipelineState = pipelineState
+            } catch {
+                throw MetalError.MTLDeviceError.pipelineStateCreationFailure
+            }
+        }
+
+        let depthStencilDescriptor = MTLDepthStencilDescriptor()
+        depthStencilDescriptor.isDepthWriteEnabled = false
+        let depthStateHash = depthStencilDescriptor.hashValue
+
+        let depthStencilState: MTLDepthStencilState
+
+        if let state = depthStencilStates[depthStateHash] {
+            depthStencilState = state
+        } else {
+            guard let state = device.makeDepthStencilState(descriptor: depthStencilDescriptor) else {
+                throw MetalError.MTLDeviceError.depthStencilStateCreationFailure
+            }
+
+            depthStencilStates.updateValue(state, forKey: depthStateHash)
+
+            depthStencilState = state
+        }
+
+        let renderPassDescriptor = MTLRenderPassDescriptor()
+
+        if state.colorAction == .clear {
+            renderPassDescriptor.colorAttachments[0].loadAction = .clear
+            renderPassDescriptor.colorAttachments[0].storeAction = .store
+            renderPassDescriptor.colorAttachments[0].clearColor = state.clearColor
+        } else {
+            renderPassDescriptor.colorAttachments[0].loadAction = state.colorAction
+        }
+
+        if state.depthAction == .clear {
+            renderPassDescriptor.depthAttachment.loadAction = .clear
+            renderPassDescriptor.depthAttachment.storeAction = .store
+            renderPassDescriptor.depthAttachment.clearDepth = state.clearDepth
+        } else {
+            renderPassDescriptor.depthAttachment.loadAction = state.depthAction
+        }
+
+        if state.stencilAction == .clear {
+            renderPassDescriptor.stencilAttachment.loadAction = .clear
+            renderPassDescriptor.stencilAttachment.storeAction = .store
+            renderPassDescriptor.stencilAttachment.clearStencil = state.clearStencil
+        } else {
+            renderPassDescriptor.stencilAttachment.loadAction = state.stencilAction
+        }
+
+        if renderState.useSRGBGamma && renderTarget.sRGBtexture != nil {
+            renderPassDescriptor.colorAttachments[0].texture = renderTarget.sRGBtexture!
+        } else {
+            renderPassDescriptor.colorAttachments[0].texture = renderTarget.texture
+        }
+
+        renderTarget.hasPendingWrites = true
+        renderState.inFlightRenderTargets.insert(renderTarget)
+
+        renderPassDescriptor.colorAttachments[0].level = 0
+        renderPassDescriptor.colorAttachments[0].slice = 0
+        renderPassDescriptor.colorAttachments[0].depthPlane = 0
+
+        if let zstencilAttachment = renderState.depthStencilAttachment {
+            renderPassDescriptor.depthAttachment.texture = zstencilAttachment.texture
+            renderPassDescriptor.stencilAttachment.texture = zstencilAttachment.texture
+        } else {
+            renderPassDescriptor.depthAttachment.texture = nil
+            renderPassDescriptor.stencilAttachment.texture = nil
+        }
+
+        guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
+            throw MetalError.MTLCommandBufferError.encoderCreationFailure
+        }
+
+        encoder.setRenderPipelineState(renderPipelineState)
+
+        if renderState.depthStencilAttachment != nil {
+            encoder.setDepthStencilState(depthStencilState)
+        }
+
+        encoder.setCullMode(.none)
+        encoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: 1, instanceCount: 1, baseInstance: 0)
+        encoder.endEncoding()
+    }
+
+    /// Schedules a draw call on the GPU with the information currently set up in the ``MetalRenderState`.`
+    /// - Parameters:
+    ///   - primitiveType: Type of primitives to render
+    ///   - vertexStart: Start index for the vertices to be drawn
+    ///   - vertexCount: Amount of vertices to be drawn
+    ///
+    /// Modern APIs like Metal have moved away from the "magic  state" mental model used by legacy APIs like OpenGL or
+    /// Direct3D11 which required the APIs to validate the "global state" at every draw call. Instead Metal requires
+    /// the creation of a pipeline object which is immutable after creation and thus has to run validation once and can
+    /// then run draw calls directly.
+    ///
+    /// Due to the nature of OBS Studio, the pipeline state can change constantly, as blending, filtering, and
+    /// conversion of data can constantly be changed by users of the program, which means that the combination of blend
+    /// modes, shaders, and attachments can change constantly.
+    ///
+    /// To avoid a costly re-creation of pipelines for every draw call, pipelines are cached after creation and if a
+    /// draw call uses an established pipeline, it will be reused from cache instead. While this cannot avoid the cost
+    /// of creating new pipelines during runtime, it mitigates the cost for consecutive draw calls.
+    func draw(primitiveType: MTLPrimitiveType, vertexStart: Int, vertexCount: Int) throws {
+        try ensureCommandBuffer()
+
+        let commandBuffer = renderState.commandBuffer!
+
+        guard let renderTarget = renderState.renderTarget else {
+            return
+        }
+
+        guard renderState.vertexBuffer != nil || vertexCount > 0 else {
+            assertionFailure("MetalDevice: Attempted to render without a vertex buffer set")
+            return
+        }
+
+        guard let vertexShader = renderState.vertexShader else {
+            assertionFailure("MetalDevice: Attempted to render without vertex shader set")
+            return
+        }
+
+        guard let fragmentShader = renderState.fragmentShader else {
+            assertionFailure("MetalDevice: Attempted to render without fragment shader set")
+            return
+        }
+
+        let renderPipelineDescriptor = renderState.pipelineDescriptor
+        let renderPassDescriptor = renderState.renderPassDescriptor
+
+        if renderState.isRendertargetChanged {
+            if renderState.useSRGBGamma && renderTarget.sRGBtexture != nil {
+                renderPipelineDescriptor.colorAttachments[0].pixelFormat = renderTarget.sRGBtexture!.pixelFormat
+                renderPassDescriptor.colorAttachments[0].texture = renderTarget.sRGBtexture!
+            } else {
+                renderPipelineDescriptor.colorAttachments[0].pixelFormat = renderTarget.texture.pixelFormat
+                renderPassDescriptor.colorAttachments[0].texture = renderTarget.texture
+            }
+
+            renderTarget.hasPendingWrites = true
+            renderState.inFlightRenderTargets.insert(renderTarget)
+
+            if let zstencilAttachment = renderState.depthStencilAttachment {
+                renderPipelineDescriptor.depthAttachmentPixelFormat = zstencilAttachment.texture.pixelFormat
+                renderPipelineDescriptor.stencilAttachmentPixelFormat = zstencilAttachment.texture.pixelFormat
+                renderPassDescriptor.depthAttachment.texture = zstencilAttachment.texture
+                renderPassDescriptor.stencilAttachment.texture = zstencilAttachment.texture
+            } else {
+                renderPipelineDescriptor.depthAttachmentPixelFormat = .invalid
+                renderPipelineDescriptor.stencilAttachmentPixelFormat = .invalid
+                renderPassDescriptor.depthAttachment.texture = nil
+                renderPassDescriptor.stencilAttachment.texture = nil
+
+            }
+        }
+
+        renderPassDescriptor.colorAttachments[0].loadAction = .load
+        renderPassDescriptor.depthAttachment.loadAction = .load
+        renderPassDescriptor.stencilAttachment.loadAction = .load
+
+        let stateHash = renderState.pipelineDescriptor.hashValue
+
+        let pipelineState: MTLRenderPipelineState
+
+        if let state = pipelines[stateHash] {
+            pipelineState = state
+        } else {
+            do {
+                let state = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
+
+                pipelines.updateValue(state, forKey: stateHash)
+                pipelineState = state
+            } catch {
+                throw MetalError.MTLDeviceError.pipelineStateCreationFailure
+            }
+        }
+
+        guard let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
+        else {
+            throw MetalError.MTLCommandBufferError.encoderCreationFailure
+        }
+
+        commandEncoder.setRenderPipelineState(pipelineState)
+
+        if let effect: OpaquePointer = gs_get_effect() {
+            gs_effect_update_params(effect)
+        }
+
+        commandEncoder.setViewport(renderState.viewPort)
+        commandEncoder.setFrontFacing(.counterClockwise)
+        commandEncoder.setCullMode(renderState.cullMode)
+
+        if let scissorRect = renderState.scissorRect, renderState.scissorRectEnabled {
+            commandEncoder.setScissorRect(scissorRect)
+        }
+
+        let depthStateHash = renderState.depthStencilDescriptor.hashValue
+        let depthStencilState: MTLDepthStencilState
+
+        if let state = depthStencilStates[depthStateHash] {
+            depthStencilState = state
+        } else {
+            guard let state = device.makeDepthStencilState(descriptor: renderState.depthStencilDescriptor) else {
+                throw MetalError.MTLDeviceError.depthStencilStateCreationFailure
+            }
+
+            depthStencilStates.updateValue(state, forKey: depthStateHash)
+            depthStencilState = state
+        }
+
+        commandEncoder.setDepthStencilState(depthStencilState)
+
+        var gsViewMatrix: matrix4 = matrix4()
+        gs_matrix_get(&gsViewMatrix)
+
+        let viewMatrix = matrix_float4x4(
+            rows: [
+                SIMD4(gsViewMatrix.x.x, gsViewMatrix.x.y, gsViewMatrix.x.z, gsViewMatrix.x.w),
+                SIMD4(gsViewMatrix.y.x, gsViewMatrix.y.y, gsViewMatrix.y.z, gsViewMatrix.y.w),
+                SIMD4(gsViewMatrix.z.x, gsViewMatrix.z.y, gsViewMatrix.z.z, gsViewMatrix.z.w),
+                SIMD4(gsViewMatrix.t.x, gsViewMatrix.t.y, gsViewMatrix.t.z, gsViewMatrix.t.w),
+            ]
+        )
+
+        renderState.viewProjectionMatrix = (viewMatrix * renderState.projectionMatrix)
+
+        if let viewProjectionUniform = vertexShader.viewProjection {
+            viewProjectionUniform.setParameter(
+                data: &renderState.viewProjectionMatrix, size: MemoryLayout<matrix_float4x4>.size)
+        }
+
+        vertexShader.uploadShaderParameters(encoder: commandEncoder)
+        fragmentShader.uploadShaderParameters(encoder: commandEncoder)
+
+        if let vertexBuffer = renderState.vertexBuffer {
+            let buffers = vertexBuffer.getShaderBuffers(for: vertexShader)
+
+            commandEncoder.setVertexBuffers(
+                buffers,
+                offsets: .init(repeating: 0, count: buffers.count),
+                range: 0..<buffers.count)
+        } else {
+            commandEncoder.setVertexBuffer(fallbackVertexBuffer, offset: 0, index: 0)
+        }
+
+        for (index, texture) in renderState.textures.enumerated() {
+            if let texture {
+                commandEncoder.setFragmentTexture(texture, index: index)
+            }
+        }
+
+        for (index, samplerState) in renderState.samplers.enumerated() {
+            if let samplerState {
+                commandEncoder.setFragmentSamplerState(samplerState, index: index)
+            }
+        }
+
+        if let indexBuffer = renderState.indexBuffer,
+            let bufferData = indexBuffer.indices
+        {
+            commandEncoder.drawIndexedPrimitives(
+                type: primitiveType,
+                indexCount: (vertexCount > 0) ? vertexCount : indexBuffer.count,
+                indexType: indexBuffer.type,
+                indexBuffer: bufferData,
+                indexBufferOffset: 0
+            )
+        } else {
+            if let vertexBuffer = renderState.vertexBuffer,
+                let vertexData = vertexBuffer.vertexData
+            {
+                commandEncoder.drawPrimitives(
+                    type: primitiveType,
+                    vertexStart: vertexStart,
+                    vertexCount: vertexData.pointee.num
+                )
+            } else {
+                commandEncoder.drawPrimitives(
+                    type: primitiveType,
+                    vertexStart: vertexStart,
+                    vertexCount: vertexCount
+                )
+            }
+        }
+
+        commandEncoder.endEncoding()
+    }
+
+    /// Creates a command buffer on the render state if none exists
+    func ensureCommandBuffer() throws {
+        if renderState.commandBuffer == nil {
+            guard let buffer = commandQueue.makeCommandBuffer() else {
+                throw MetalError.MTLCommandQueueError.commandBufferCreationFailure
+            }
+
+            renderState.commandBuffer = buffer
+        }
+    }
+
+    /// Updates a memory fence used on the GPU to signal that the current render target (which is associated with a
+    /// ``OBSSwapChain`` is available for  other GPU commands.
+    ///
+    /// This is necessary as the final output of projectors needs to be blitted into the drawables provided by the
+    /// `CAMetalLayer` of each ``OBSSwapChain`` at the screen refresh interval, but projectors are usually rendered
+    /// using tens of seperate little draw calls.
+    ///
+    /// Thus a virtual "display render stage" state is maintained by the Metal renderer, which is started when a
+    /// ``OBSSwapChain`` instance is loaded by `libobs`  and ended when `device_end_scene` is called.
+    func finishDisplayRenderStage() {
+        let buffer = commandQueue.makeCommandBufferWithUnretainedReferences()
+        let encoder = buffer?.makeBlitCommandEncoder()
+
+        guard let buffer, let encoder, let swapChain = renderState.swapChain else {
+            return
+        }
+
+        encoder.updateFence(swapChain.fence)
+        encoder.endEncoding()
+        buffer.commit()
+    }
+
+    /// Ensures that all encoded render commands in the current command buffer are committed to the command queue for
+    /// execution on the GPU.
+    ///
+    /// This is particularly important when textures (or texture data) is to be blitted into other textures or buffers,
+    /// as pending GPU commands in the existing buffer need to run before any commands that rely on the result of these
+    /// draw commands to have taken place.
+    ///
+    /// Within the same queue this is ensured by Metal itself, but requires the commands to be encoded and committed
+    /// in the desired order.
+    func finishPendingCommands() {
+        guard let commandBuffer = renderState.commandBuffer, commandBuffer.status != .committed else {
+            return
+        }
+
+        commandBuffer.commit()
+
+        renderState.inFlightRenderTargets.forEach {
+            $0.hasPendingWrites = false
+        }
+
+        renderState.inFlightRenderTargets.removeAll(keepingCapacity: true)
+        renderState.commandBuffer = nil
+    }
+
+    /// Copies the contents of a texture into another texture of identical dimensions
+    /// - Parameters:
+    ///   - source: Source texture to copy from
+    ///   - destination: Destination texture to copy to
+    ///
+    /// This function requires both textures to have been created with the same dimensions, otherwise the copy
+    /// operation will fail.
+    ///
+    /// If the source texture has pending writes (e.g., it was used as the render target for a clear or draw command),
+    /// then the current command buffer  will be committed to ensure that the blit command encoded by this function
+    /// happens after the pending commands.
+    func copyTexture(source: MetalTexture, destination: MetalTexture) throws {
+        if source.hasPendingWrites {
+            finishPendingCommands()
+        }
+
+        try ensureCommandBuffer()
+
+        let buffer = renderState.commandBuffer!
+        let encoder = buffer.makeBlitCommandEncoder()
+
+        guard let encoder else {
+            throw MetalError.MTLCommandQueueError.commandBufferCreationFailure
+        }
+
+        encoder.copy(from: source.texture, to: destination.texture)
+        encoder.endEncoding()
+    }
+
+    /// Copies the contents of a texture into a texture for CPU access
+    /// - Parameters:
+    ///   - source: Source texture to copy from
+    ///   - destination: Destination texture to copy to
+    ///
+    /// This function requires both texture to have been created with the same dimensions, otherwise the copy operation
+    /// will fail.
+    ///
+    /// If the source texture has pending writes (e.g., it was used as the render target for a clear or draw command),
+    /// then the current command buffer will be comitted to ensure that the blit command encoded by this function
+    /// happens after the pending commands.
+    ///
+    /// > Important: This function differs from ``copyTexture`` insofar as it will wait for the completion of all
+    /// commands in the command queue to ensure that the GPU has actually completed the blit into the destination
+    /// texture.
+    func stageTexture(source: MetalTexture, destination: MetalTexture) throws {
+        if source.hasPendingWrites {
+            finishPendingCommands()
+        }
+
+        let buffer = commandQueue.makeCommandBufferWithUnretainedReferences()
+        let encoder = buffer?.makeBlitCommandEncoder()
+
+        guard let buffer, let encoder else {
+            throw MetalError.MTLCommandQueueError.commandBufferCreationFailure
+        }
+
+        encoder.copy(from: source.texture, to: destination.texture)
+        encoder.endEncoding()
+        buffer.commit()
+        buffer.waitUntilCompleted()
+    }
+
+    /// Copies the contents of a texture into a buffer for CPU access
+    /// - Parameters:
+    ///   - source: Source texture to copy from
+    ///   - destination: Destination buffer to copy to
+    ///
+    /// This function requires that the destination buffer has been created with enough capacity to hold the source
+    /// textures pixel data.
+    ///
+    /// If the source texture has pending writes (e.g., it was used as the render target for a clear or draw command),
+    /// then the current command buffer will be comitted to ensure that the blit command encoded by this function
+    /// happens after the pending commands.
+    ///
+    /// > Important: This function will wait for the completion of all commands in the command queue to ensure that the
+    /// GPU has actually completed the blit into the destination buffer.
+    ///
+    func stageTextureToBuffer(source: MetalTexture, destination: MetalStageBuffer) throws {
+        if source.hasPendingWrites {
+            finishPendingCommands()
+        }
+
+        let buffer = commandQueue.makeCommandBufferWithUnretainedReferences()
+        let encoder = buffer?.makeBlitCommandEncoder()
+
+        guard let buffer, let encoder else {
+            throw MetalError.MTLCommandQueueError.commandBufferCreationFailure
+        }
+
+        encoder.copy(
+            from: source.texture,
+            sourceSlice: 0,
+            sourceLevel: 0,
+            sourceOrigin: .init(x: 0, y: 0, z: 0),
+            sourceSize: .init(width: source.texture.width, height: source.texture.height, depth: 1),
+            to: destination.buffer,
+            destinationOffset: 0,
+            destinationBytesPerRow: destination.width * destination.format.bytesPerPixel!,
+            destinationBytesPerImage: 0)
+
+        encoder.endEncoding()
+        buffer.commit()
+        buffer.waitUntilCompleted()
+    }
+
+    /// Copies the contents of a buffer into a texture for GPU access
+    /// - Parameters:
+    ///   - source: Source buffer to copy from
+    ///   - destination: Destination texture to copy to
+    ///
+    /// This function requires that the destination texture has been created with enough capacity to hold the source
+    /// buffer pixel data.
+    ///
+    func stageBufferToTexture(source: MetalStageBuffer, destination: MetalTexture) throws {
+        let buffer = commandQueue.makeCommandBufferWithUnretainedReferences()
+        let encoder = buffer?.makeBlitCommandEncoder()
+
+        guard let buffer, let encoder else {
+            throw MetalError.MTLCommandQueueError.commandBufferCreationFailure
+        }
+
+        encoder.copy(
+            from: source.buffer,
+            sourceOffset: 0,
+            sourceBytesPerRow: source.width * source.format.bytesPerPixel!,
+            sourceBytesPerImage: 0,
+            sourceSize: .init(width: source.width, height: source.height, depth: 1),
+            to: destination.texture,
+            destinationSlice: 0,
+            destinationLevel: 0,
+            destinationOrigin: .init(x: 0, y: 0, z: 0)
+        )
+
+        encoder.endEncoding()
+        buffer.commit()
+        buffer.waitUntilScheduled()
+    }
+
+    /// Copies a region from a source texture into a region of a destination texture
+    /// - Parameters:
+    ///   - source: Source texture to copy from
+    ///   - sourceRegion: Region of the source texture to copy from
+    ///   - destination: Destination texture to copy to
+    ///   - destinationRegion: Destination region to copy into
+    ///
+    /// This function requires that the destination region fits within the dimensions of the destination texture,
+    /// otherwise the copy operation will fail.
+    ///
+    /// If the source texture has pending writes (e.g., it was used as the render target for a clear or draw command),
+    /// then the current command buffer will be comitted to ensure that the blit command encoded by this function
+    /// happens after the pending commands.
+    ///
+    func copyTextureRegion(
+        source: MetalTexture, sourceRegion: MTLRegion, destination: MetalTexture, destinationRegion: MTLRegion
+    ) throws {
+        if source.hasPendingWrites {
+            finishPendingCommands()
+        }
+
+        let buffer = commandQueue.makeCommandBufferWithUnretainedReferences()
+        let encoder = buffer?.makeBlitCommandEncoder()
+
+        guard let buffer, let encoder else {
+            throw MetalError.MTLCommandQueueError.commandBufferCreationFailure
+        }
+
+        encoder.copy(
+            from: source.texture,
+            sourceSlice: 0,
+            sourceLevel: 0,
+            sourceOrigin: sourceRegion.origin,
+            sourceSize: sourceRegion.size,
+            to: destination.texture,
+            destinationSlice: 0,
+            destinationLevel: 0,
+            destinationOrigin: destinationRegion.origin
+        )
+
+        encoder.endEncoding()
+        buffer.commit()
+    }
+
+    /// Stops the `CVDisplayLink` used by the ``MetalDevice`` instance
+    func shutdown() {
+        guard let displayLink else { return }
+
+        CVDisplayLinkStop(displayLink)
+        self.displayLink = nil
+    }
+
+    deinit {
+        shutdown()
+    }
+}

+ 126 - 0
libobs-metal/MetalError.swift

@@ -0,0 +1,126 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+enum MetalError {
+    enum MTLCommandQueueError: Error, CustomStringConvertible {
+        case commandBufferCreationFailure
+
+        var description: String {
+            switch self {
+            case .commandBufferCreationFailure:
+                "MTLCommandQueue failed to create command buffer"
+            }
+        }
+    }
+
+    enum MTLDeviceError: Error, CustomStringConvertible {
+        case commandQueueCreationFailure
+        case displayLinkCreationFailure
+        case bufferCreationFailure(String)
+        case shaderCompilationFailure(String)
+        case pipelineStateCreationFailure
+        case depthStencilStateCreationFailure
+        case samplerStateCreationFailure
+
+        var description: String {
+            switch self {
+            case .commandQueueCreationFailure:
+                "MTLDevice failed to create command queue"
+            case .displayLinkCreationFailure:
+                "MTLDevice failed to create CVDisplayLink for projector output"
+            case .bufferCreationFailure(_):
+                "MTLDevice failed to create buffer"
+            case .shaderCompilationFailure(_):
+                "MTLDevice failed to create shader library and function"
+            case .pipelineStateCreationFailure:
+                "MTLDevice failed to create render pipeline state"
+            case .depthStencilStateCreationFailure:
+                "MTLDevice failed to create depth stencil state"
+            case .samplerStateCreationFailure:
+                "MTLDevice failed to create sampler state with provided descriptor"
+            }
+        }
+    }
+
+    enum MTLCommandBufferError: Error, CustomStringConvertible {
+        case encoderCreationFailure
+
+        var description: String {
+            switch self {
+            case .encoderCreationFailure:
+                "MTLCommandBuffer failed to create command encoder"
+            }
+        }
+    }
+
+    enum MetalShaderError: Error, CustomStringConvertible {
+        case missingVertexDescriptor
+        case missingSamplerDescriptors
+
+        var description: String {
+            switch self {
+            case .missingVertexDescriptor:
+                "MetalShader of type vertex requires a vertex descriptor"
+            case .missingSamplerDescriptors:
+                "MetalShader of type fragment requires at least a single sampler descriptor"
+            }
+        }
+    }
+
+    enum OBSShaderParserError: Error, CustomStringConvertible {
+        case parseFail(String)
+        case unsupportedType
+        case missingNextToken
+        case unexpectedToken
+        case missingMainFunction
+
+        var description: String {
+            switch self {
+            case .parseFail:
+                "Failed to parse provided shader string"
+            case .unsupportedType:
+                "Provided GS type is not convertible to a Metal type"
+            case .missingNextToken:
+                "Required next token not found in parser token collection"
+            case .unexpectedToken:
+                "Required next token had unexpected type in parser token collection"
+            case .missingMainFunction:
+                "Shader has no main function"
+            }
+        }
+    }
+
+    enum OBSShaderError: Error, CustomStringConvertible {
+        case unsupportedType
+        case parseFail(String)
+        case parseError(String)
+        case transpileError(String)
+
+        var description: String {
+            switch self {
+            case .unsupportedType:
+                "Unsupported Metal shader type"
+            case .parseFail(_):
+                "OBS shader parser failed to parse effect"
+            case .parseError(_):
+                "OBS shader parser encountered warnings and/or errors while parsing effect"
+            case .transpileError(_):
+                "Transpiling OBS effects file into MSL shader failed"
+            }
+        }
+    }
+}

+ 79 - 0
libobs-metal/MetalRenderState.swift

@@ -0,0 +1,79 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+import simd
+
+/// The MetalRenderState struct emulates a state object like Direct3D's `ID3D11DeviceContext`, holding references to
+/// elements of a render pipeline that would be considered the "current" variant of each.
+///
+/// Typical "current" state elements include (but are not limited to):
+///
+/// * Variant of the render target for linear color writes
+/// * Variant of the render target for color writes with automatic sRGB gamma encoding
+/// * View matrix and view projection matrix
+/// * Vertex buffer and optional index buffer
+/// * Depth stencil attachment
+/// * Vertex shader
+/// * Fragment shader
+/// * View port size
+/// * Cull mode
+///
+/// These references are swapped out by OBS for each "scene" and "scene items" within it before issuing draw calls,
+/// thus actual pipelines need to be created "on demand" based on the pipeline descriptor and stored in a cache to
+/// avoid the cost of pipeline validation on consecutive render passes.
+struct MetalRenderState {
+    var viewMatrix: matrix_float4x4
+    var projectionMatrix: matrix_float4x4
+    var viewProjectionMatrix: matrix_float4x4
+
+    var renderTarget: MetalTexture?
+    var sRGBrenderTarget: MetalTexture?
+    var depthStencilAttachment: MetalTexture?
+    var isRendertargetChanged = false
+
+    var vertexBuffer: MetalVertexBuffer?
+    var indexBuffer: MetalIndexBuffer?
+
+    var vertexShader: MetalShader?
+    var fragmentShader: MetalShader?
+
+    var viewPort = MTLViewport()
+    var cullMode = MTLCullMode.none
+
+    var scissorRectEnabled: Bool
+    var scissorRect: MTLScissorRect?
+
+    var gsColorSpace: gs_color_space
+    var useSRGBGamma = false
+
+    var swapChain: OBSSwapChain?
+    var isInDisplaysRenderStage = false
+
+    var pipelineDescriptor = MTLRenderPipelineDescriptor()
+    var clearPipelineDescriptor = MTLRenderPipelineDescriptor()
+    var renderPassDescriptor = MTLRenderPassDescriptor()
+    var depthStencilDescriptor = MTLDepthStencilDescriptor()
+    var commandBuffer: MTLCommandBuffer?
+
+    var textures = [MTLTexture?](repeating: nil, count: Int(GS_MAX_TEXTURES))
+    var samplers = [MTLSamplerState?](repeating: nil, count: Int(GS_MAX_TEXTURES))
+
+    var projections = [matrix_float4x4]()
+    var inFlightRenderTargets = Set<MetalTexture>()
+}

+ 27 - 0
libobs-metal/MetalShader+Extensions.swift

@@ -0,0 +1,27 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Adds the comparison operator to make ``MetalShader`` instances comparable. Comparison is based on the source string
+/// and function type.
+extension MetalShader: Equatable {
+    static func == (lhs: MetalShader, rhs: MetalShader) -> Bool {
+        return lhs.source == rhs.source && lhs.function.functionType == rhs.function.functionType
+    }
+}

+ 287 - 0
libobs-metal/MetalShader.swift

@@ -0,0 +1,287 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+class MetalShader {
+    /// This class wraps a single uniform shader variable, which will hold the data associated with the uniform updated
+    /// by `libobs` at each render loop, which is then converted and set as vertex or fragment bytes for a render pass
+    /// by the ``MetalDevice/draw`` function.
+    class ShaderUniform {
+        let name: String
+        let gsType: gs_shader_param_type
+        fileprivate let textureSlot: Int
+        var samplerState: MTLSamplerState?
+        fileprivate let byteOffset: Int
+
+        var currentValues: [UInt8]?
+        var defaultValues: [UInt8]?
+        fileprivate var hasUpdates: Bool
+
+        init(
+            name: String, gsType: gs_shader_param_type, textureSlot: Int, samplerState: MTLSamplerState?,
+            byteOffset: Int
+        ) {
+            self.name = name
+            self.gsType = gsType
+
+            self.textureSlot = textureSlot
+            self.samplerState = samplerState
+            self.byteOffset = byteOffset
+            self.currentValues = nil
+            self.defaultValues = nil
+            self.hasUpdates = false
+        }
+
+        /// Sets the data for the shader uniform
+        /// - Parameters:
+        ///   - data: Pointer to data of type `T`
+        ///   - size: Size of data available at the pointer provided by `data`
+        ///
+        /// This function will reinterpet the data provided by the pointer as raw bytes and store it as raw bytes on
+        /// the Uniform.
+        public func setParameter<T>(data: UnsafePointer<T>?, size: Int) {
+            guard let data else {
+                assertionFailure(
+                    "MetalShader.ShaderUniform: Attempted to set a shader parameter with an empty data pointer")
+                return
+            }
+
+            data.withMemoryRebound(to: UInt8.self, capacity: size) {
+                self.currentValues = Array(UnsafeBufferPointer<UInt8>(start: $0, count: size))
+            }
+
+            hasUpdates = true
+        }
+    }
+
+    /// This struct serves as a data container to communicate shader meta data between the ``OBSShader`` shader
+    /// transpiler and the actual ``MetalShader`` instances created with them.
+    struct ShaderData {
+        let uniforms: [ShaderUniform]
+        let bufferOrder: [MetalBuffer.BufferDataType]
+
+        let vertexDescriptor: MTLVertexDescriptor?
+        let samplerDescriptors: [MTLSamplerDescriptor]?
+
+        let bufferSize: Int
+        let textureCount: Int
+    }
+
+    private weak var device: MetalDevice?
+    let source: String
+    private var uniformData: [UInt8]
+    private var uniformSize: Int
+    private var uniformBuffer: MTLBuffer?
+
+    private let library: MTLLibrary
+    let function: MTLFunction
+    var uniforms: [ShaderUniform]
+    var vertexDescriptor: MTLVertexDescriptor?
+    var textureCount = 0
+    var samplers: [MTLSamplerState]?
+
+    let type: MTLFunctionType
+    let bufferOrder: [MetalBuffer.BufferDataType]
+
+    var viewProjection: ShaderUniform?
+
+    init(device: MetalDevice, source: String, type: MTLFunctionType, data: ShaderData) throws {
+        self.device = device
+        self.source = source
+        self.type = type
+        self.uniforms = data.uniforms
+        self.bufferOrder = data.bufferOrder
+        self.uniformSize = (data.bufferSize + 0x0F) & ~0x0F
+        self.uniformData = [UInt8](repeating: 0, count: self.uniformSize)
+        self.textureCount = data.textureCount
+
+        switch type {
+        case .vertex:
+            guard let descriptor = data.vertexDescriptor else {
+                throw MetalError.MetalShaderError.missingVertexDescriptor
+            }
+
+            self.vertexDescriptor = descriptor
+
+            self.viewProjection = self.uniforms.first(where: { $0.name == "ViewProj" })
+        case .fragment:
+            guard let samplerDescriptors = data.samplerDescriptors else {
+                throw MetalError.MetalShaderError.missingSamplerDescriptors
+            }
+
+            var samplers = [MTLSamplerState]()
+            samplers.reserveCapacity(samplerDescriptors.count)
+
+            for descriptor in samplerDescriptors {
+                guard let samplerState = device.device.makeSamplerState(descriptor: descriptor) else {
+                    throw MetalError.MTLDeviceError.samplerStateCreationFailure
+                }
+
+                samplers.append(samplerState)
+            }
+
+            self.samplers = samplers
+        default:
+            fatalError("MetalShader: Unsupported shader type \(type)")
+        }
+
+        do {
+            library = try device.device.makeLibrary(source: source, options: nil)
+        } catch {
+            throw MetalError.MTLDeviceError.shaderCompilationFailure("Failed to create shader library")
+        }
+
+        guard let function = library.makeFunction(name: "_main") else {
+            throw MetalError.MTLDeviceError.shaderCompilationFailure("Failed to create '_main' function")
+        }
+
+        self.function = function
+    }
+
+    /// Updates the Metal-specific data associated with a ``ShaderUniform`` with the raw bytes provided by `libobs`
+    /// - Parameter uniform: Inout reference to the ``ShaderUniform`` instance
+    ///
+    /// Uniform data is provided by `libobs` precisely in the format required by the shader (and interpreted by
+    /// `libobs`), which means that the raw bytes stored on the ``ShaderUniform`` are usually already in the correct
+    /// order and can be used without reinterpretation.
+    ///
+    /// The exception to this rule is data for textures, which represents a copy of a `gs_shader_texture` struct that
+    /// itself contains the pointer address of an `OpaquePointer` for a ``MetalTexture`` instance.
+    private func updateUniform(uniform: inout ShaderUniform) {
+        guard let device = self.device else { return }
+        guard let currentValues = uniform.currentValues else { return }
+
+        if uniform.gsType == GS_SHADER_PARAM_TEXTURE {
+            var textureObject: OpaquePointer?
+            var isSrgb = false
+
+            currentValues.withUnsafeBufferPointer {
+                $0.baseAddress?.withMemoryRebound(to: gs_shader_texture.self, capacity: 1) {
+                    textureObject = $0.pointee.tex
+                    isSrgb = $0.pointee.srgb
+                }
+            }
+
+            if let textureObject {
+                let texture: MetalTexture = unretained(UnsafeRawPointer(textureObject))
+
+                if texture.sRGBtexture != nil, isSrgb {
+                    device.renderState.textures[uniform.textureSlot] = texture.sRGBtexture!
+                } else {
+                    device.renderState.textures[uniform.textureSlot] = texture.texture
+                }
+            }
+
+            if let samplerState = uniform.samplerState {
+                device.renderState.samplers[uniform.textureSlot] = samplerState
+                uniform.samplerState = nil
+            }
+        } else {
+            if uniform.hasUpdates {
+                let startIndex = uniform.byteOffset
+                let endIndex = uniform.byteOffset + currentValues.count
+
+                uniformData.replaceSubrange(startIndex..<endIndex, with: currentValues)
+            }
+        }
+
+        uniform.hasUpdates = false
+    }
+
+    /// Creates a new buffer with the provided data or updates an existing buffer with the provided data
+    /// - Parameters:
+    ///   - buffer: Reference to a buffer variable to either receive the new buffer or provide an existing buffer
+    ///   - data: Raw byte data array
+    private func createOrUpdateBuffer(buffer: inout MTLBuffer?, data: inout [UInt8]) {
+        guard let device = self.device else { return }
+
+        let size = MemoryLayout<UInt8>.size * data.count
+        let alignedSize = (size + 0x0F) & ~0x0F
+
+        if buffer != nil {
+            if buffer!.length == alignedSize {
+                buffer!.contents().copyMemory(from: data, byteCount: size)
+                return
+            }
+        }
+
+        buffer = device.device.makeBuffer(bytes: data, length: alignedSize)
+    }
+
+    /// Sets uniform data for a current render encoder either directly as a buffer
+    /// - Parameter encoder: `MTLRenderCommandEncoder` for a render pass that requires the uniform data
+    ///
+    /// Uniform data will be uploaded at index 30 (the very last available index) and is available as a single
+    /// contiguous block of data. Uniforms are declared as structs in the Metal Shaders and explicitly passed into
+    /// each function that requires access to them.
+    func uploadShaderParameters(encoder: MTLRenderCommandEncoder) {
+        for var uniform in uniforms {
+            updateUniform(uniform: &uniform)
+        }
+
+        guard uniformSize > 0 else {
+            return
+        }
+
+        switch function.functionType {
+        case .vertex:
+            switch uniformData.count {
+            case 0..<4096: encoder.setVertexBytes(&uniformData, length: uniformData.count, index: 30)
+            default:
+                createOrUpdateBuffer(buffer: &uniformBuffer, data: &uniformData)
+                #if DEBUG
+                    uniformBuffer?.label = "Vertex shader uniform buffer"
+                #endif
+                encoder.setVertexBuffer(uniformBuffer, offset: 0, index: 30)
+            }
+        case .fragment:
+            switch uniformData.count {
+            case 0..<4096: encoder.setFragmentBytes(&uniformData, length: uniformData.count, index: 30)
+            default:
+                createOrUpdateBuffer(buffer: &uniformBuffer, data: &uniformData)
+                #if DEBUG
+                    uniformBuffer?.label = "Fragment shader uniform buffer"
+                #endif
+                encoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 30)
+            }
+        default:
+            fatalError("MetalShader: Unsupported shader type \(function.functionType)")
+        }
+    }
+
+    /// Gets an opaque pointer for the ``MetalShader`` instance and increases its reference count by one
+    /// - Returns: `OpaquePointer` to class instance
+    ///
+    /// > Note: Use this method when the instance is to be shared via an `OpaquePointer` and needs to be retained. Any
+    /// opaque pointer shared this way  needs to be converted into a retained reference again to ensure automatic
+    /// deinitialization by the Swift runtime.
+    func getRetained() -> OpaquePointer {
+        let retained = Unmanaged.passRetained(self).toOpaque()
+
+        return OpaquePointer(retained)
+    }
+
+    /// Gets an opaque pointer for the ``MetalShader`` instance without increasing its reference count
+    /// - Returns: `OpaquePointer` to class instance
+    func getUnretained() -> OpaquePointer {
+        let unretained = Unmanaged.passUnretained(self).toOpaque()
+
+        return OpaquePointer(unretained)
+    }
+}

+ 65 - 0
libobs-metal/MetalStageBuffer.swift

@@ -0,0 +1,65 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+class MetalStageBuffer {
+    let device: MetalDevice
+    let buffer: MTLBuffer
+    let format: MTLPixelFormat
+    let width: Int
+    let height: Int
+
+    init?(device: MetalDevice, width: Int, height: Int, format: MTLPixelFormat) {
+        self.device = device
+        self.width = width
+        self.height = height
+        self.format = format
+
+        guard let bytesPerPixel = format.bytesPerPixel,
+            let buffer = device.device.makeBuffer(
+                length: width * height * bytesPerPixel,
+                options: .storageModeShared
+            )
+        else {
+            return nil
+        }
+
+        self.buffer = buffer
+    }
+
+    /// Gets an opaque pointer for the ``MetalStageBuffer`` instance and increases its reference count by one
+    /// - Returns: `OpaquePointer` to class instance
+    ///
+    /// > Note: Use this method when the instance is to be shared via an `OpaquePointer` and needs to be retained. Any
+    ///  opaque pointer shared this way needs to be converted into a retained reference again to ensure automatic
+    /// deinitialization by the Swift runtime.
+    func getRetained() -> OpaquePointer {
+        let retained = Unmanaged.passRetained(self).toOpaque()
+
+        return OpaquePointer(retained)
+    }
+
+    /// Gets an opaque pointer for the ``MetalStageBuffer`` instance without increasing its reference count
+    /// - Returns: `OpaquePointer` to class instance
+    func getUnretained() -> OpaquePointer {
+        let unretained = Unmanaged.passUnretained(self).toOpaque()
+
+        return OpaquePointer(unretained)
+    }
+}

+ 433 - 0
libobs-metal/MetalTexture.swift

@@ -0,0 +1,433 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import CoreVideo
+import Foundation
+import Metal
+
+private let bgraSurfaceFormat = kCVPixelFormatType_32BGRA  // 0x42_47_52_41
+private let l10rSurfaceFormat = kCVPixelFormatType_ARGB2101010LEPacked  // 0x6C_31_30_72
+
+enum MetalTextureMapMode {
+    case unmapped
+    case read
+    case write
+}
+
+/// Struct used for data exchange between ``MetalTexture`` and `libobs` API functions during mapping and unmapping of
+/// textures.
+struct MetalTextureMapping {
+    let mode: MetalTextureMapMode
+    let rowSize: Int
+    let data: UnsafeMutableRawPointer
+}
+
+/// Convenience class for managing ``MTLTexture`` objects
+class MetalTexture {
+    private let descriptor: MTLTextureDescriptor
+    private var mappingMode: MetalTextureMapMode
+    private let resourceID: UUID
+
+    weak var device: MetalDevice?
+    var data: UnsafeMutableRawPointer?
+    var hasPendingWrites: Bool = false
+    var sRGBtexture: MTLTexture?
+    var texture: MTLTexture
+    var stageBuffer: MetalStageBuffer?
+
+    /// Binds the provided `IOSurfaceRef` to a new `MTLTexture` instance
+    /// - Parameters:
+    ///   - device: `MTLDevice` instance to use for texture object creation
+    ///   - surface: `IOSurfaceRef` reference to an existing `IOSurface`
+    /// - Returns: `MTLTexture` instance if texture was created successfully, `nil` otherwise
+    private static func bindSurface(device: MetalDevice, surface: IOSurfaceRef) -> MTLTexture? {
+        guard let pixelFormat = MTLPixelFormat.init(osType: IOSurfaceGetPixelFormat(surface)) else {
+            assertionFailure("MetalDevice: IOSurface pixel format is not supported")
+            return nil
+        }
+
+        let descriptor = MTLTextureDescriptor.texture2DDescriptor(
+            pixelFormat: pixelFormat,
+            width: IOSurfaceGetWidth(surface),
+            height: IOSurfaceGetHeight(surface),
+            mipmapped: false
+        )
+
+        descriptor.usage = [.shaderRead]
+
+        let texture = device.device.makeTexture(descriptor: descriptor, iosurface: surface, plane: 0)
+        return texture
+    }
+
+    /// Creates a new ``MetalDevice`` instance with the provided `MTLTextureDescriptor`
+    /// - Parameters:
+    ///   - device: `MTLDevice` instance to use for texture object creation
+    ///   - descriptor: `MTLTextureDescriptor` to use for texture object creation
+    init?(device: MetalDevice, descriptor: MTLTextureDescriptor) {
+        self.device = device
+
+        let texture = device.device.makeTexture(descriptor: descriptor)
+
+        guard let texture else {
+            assertionFailure(
+                "MetalTexture: Failed to create texture with size \(descriptor.width)x\(descriptor.height)")
+            return nil
+        }
+
+        self.texture = texture
+
+        self.resourceID = UUID()
+        self.mappingMode = .unmapped
+        self.descriptor = texture.descriptor
+
+        updateSRGBView()
+    }
+
+    /// Creates a new ``MetalDevice`` instance with the provided `IOSurfaceRef`
+    /// - Parameters:
+    ///   - device: `MTLDevice` instance to use for texture object creation
+    ///   - surface: `IOSurfaceRef` to use for texture object creation
+    init?(device: MetalDevice, surface: IOSurfaceRef) {
+        self.device = device
+
+        let texture = MetalTexture.bindSurface(device: device, surface: surface)
+
+        guard let texture else {
+            assertionFailure("MetalTexture: Failed to create texture with IOSurface")
+            return nil
+        }
+
+        self.texture = texture
+
+        self.resourceID = UUID()
+        self.mappingMode = .unmapped
+        self.descriptor = texture.descriptor
+
+        updateSRGBView()
+    }
+
+    /// Creates a new ``MetalDevice`` instance with the provided `MTLTexture`
+    /// - Parameters:
+    ///   - device: `MTLDevice` instance to use for future texture operations
+    ///   - surface: `MTLTexture` to wrap in the ``MetalDevice`` instance
+    init?(device: MetalDevice, texture: MTLTexture) {
+        self.device = device
+        self.texture = texture
+
+        self.resourceID = UUID()
+        self.mappingMode = .unmapped
+        self.descriptor = texture.descriptor
+
+        updateSRGBView()
+    }
+
+    /// Creates a new ``MetalDevice`` instance with a placeholder texture
+    /// - Parameters:
+    ///   - device: `MTLDevice` instance to use for future texture operations
+    ///
+    /// This constructor creates a "placeholder" object that can be shared with `libobs` or updated with an actual
+    /// `MTLTexture` later.
+    init?(device: MetalDevice) {
+        self.device = device
+
+        let descriptor = MTLTextureDescriptor.texture2DDescriptor(
+            pixelFormat: .bgra8Unorm, width: 2, height: 2, mipmapped: false)
+
+        guard let texture = device.device.makeTexture(descriptor: descriptor) else {
+            assertionFailure("MetalTexture: Failed to create placeholder texture object")
+            return nil
+        }
+
+        self.texture = texture
+        self.sRGBtexture = nil
+        self.resourceID = UUID()
+        self.mappingMode = .unmapped
+        self.descriptor = texture.descriptor
+    }
+
+    /// Updates the ``MetalTexture`` with a new `IOSurfaceRef`
+    /// - Parameter surface: Updated `IOSurfaceRef` to a new `IOSurface`
+    /// - Returns: `true` if update was successful, `false` otherwise
+    ///
+    /// "Rebinding" was used with the OpenGL backend, but is not available in Metal. Instead a new `MTLTexture` is
+    /// created with the provided `IOSurfaceRef` and the ``MetalTexture`` is updated accordingly.
+    ///
+    func rebind(surface: IOSurfaceRef) -> Bool {
+        guard let device = self.device, let texture = MetalTexture.bindSurface(device: device, surface: surface) else {
+            assertionFailure("MetalTexture: Failed to rebind IOSurface to texture")
+            return false
+        }
+
+        self.texture = texture
+
+        updateSRGBView()
+
+        return true
+    }
+
+    /// Creates a `MTLTextureView` for the texture wrapped by the ``MetalTexture`` instance with a corresponding sRGB
+    /// pixel format, if the texture's pixel format has an appropriate sRGB variant.
+    func updateSRGBView() {
+        guard !texture.isFramebufferOnly else {
+            self.sRGBtexture = nil
+            return
+        }
+
+        let sRGBFormat: MTLPixelFormat? =
+            switch texture.pixelFormat {
+            case .bgra8Unorm: .bgra8Unorm_srgb
+            case .rgba8Unorm: .rgba8Unorm_srgb
+            case .r8Unorm: .r8Unorm_srgb
+            case .rg8Unorm: .rg8Unorm_srgb
+            case .bgra10_xr: .bgra10_xr_srgb
+            default: nil
+            }
+
+        if let sRGBFormat {
+            self.sRGBtexture = texture.makeTextureView(pixelFormat: sRGBFormat)
+        } else {
+            self.sRGBtexture = nil
+        }
+    }
+
+    /// Downloads pixel data from the wrapped `MTLTexture` to the memory location provided by a pointer.
+    /// - Parameters:
+    ///   - data: Pointer to memory that should receive the texture data
+    ///   - mipmapLevel: Mipmap level of the texture to copy data from
+    ///
+    /// > Important: The access of texture data is neither protected nor synchronized. If any draw calls to the texture
+    /// take place while this function is executed, the downloaded data will reflect this. Use explicit synchronization
+    /// before initiating a download to prevent this.
+    func download(data: UnsafeMutableRawPointer, mipmapLevel: Int = 0) {
+        let mipmapWidth = texture.width >> mipmapLevel
+        let mipmapHeight = texture.height >> mipmapLevel
+
+        let rowSize = mipmapWidth * texture.pixelFormat.bytesPerPixel!
+        let region = MTLRegionMake2D(0, 0, mipmapWidth, mipmapHeight)
+
+        texture.getBytes(data, bytesPerRow: rowSize, from: region, mipmapLevel: mipmapLevel)
+    }
+
+    /// Uploads pixel data into the  wrappred `MTLTexture` from the memory location provided by a pointer.
+    /// - Parameters:
+    ///   - data: Pointer to memory that contains the texture data
+    ///   - mipmapLevels: Mipmap level of the texture to copy data into
+    ///
+    /// > Important: The write access of texture data is neither protected nor synchronized. If any draw calls use this
+    /// texture for reading or writing while this function is executed, the upload might have been incomplete or the
+    /// data might have been overwritten by the GPU. Use explicit synchronization before initiaitng an upload to
+    /// prevent this.
+    func upload(data: UnsafePointer<UnsafePointer<UInt8>?>, mipmapLevels: Int) {
+        let bytesPerPixel = texture.pixelFormat.bytesPerPixel!
+
+        switch texture.textureType {
+        case .type2D, .typeCube:
+            let textureCount = if texture.textureType == .typeCube { 6 } else { 1 }
+
+            let data = UnsafeBufferPointer(start: data, count: (textureCount * mipmapLevels))
+
+            for i in 0..<textureCount {
+                for mipmapLevel in 0..<mipmapLevels {
+                    let index = mipmapLevels * i + mipmapLevel
+
+                    guard let data = data[index] else { break }
+
+                    let mipmapWidth = texture.width >> mipmapLevel
+                    let mipmapHeight = texture.height >> mipmapLevel
+                    let rowSize = mipmapWidth * bytesPerPixel
+
+                    let region = MTLRegionMake2D(0, 0, mipmapWidth, mipmapHeight)
+
+                    texture.replace(
+                        region: region, mipmapLevel: mipmapLevel, slice: i, withBytes: data, bytesPerRow: rowSize,
+                        bytesPerImage: 0)
+                }
+            }
+        case .type3D:
+            let data = UnsafeBufferPointer(start: data, count: mipmapLevels)
+
+            for (mipmapLevel, mipmapData) in data.enumerated() {
+                guard let mipmapData else { break }
+
+                let mipmapWidth = texture.width >> mipmapLevel
+                let mipmapHeight = texture.height >> mipmapLevel
+                let mipmapDepth = texture.depth >> mipmapLevel
+                let rowSize = mipmapWidth * bytesPerPixel
+                let imageSize = rowSize * mipmapHeight
+
+                let region = MTLRegionMake3D(0, 0, 0, mipmapWidth, mipmapHeight, mipmapDepth)
+
+                texture.replace(
+                    region: region,
+                    mipmapLevel: mipmapLevel,
+                    slice: 0,
+                    withBytes: mipmapData,
+                    bytesPerRow: rowSize,
+                    bytesPerImage: imageSize
+                )
+            }
+        default:
+            fatalError("MetalTexture: Unsupported texture type \(texture.textureType)")
+        }
+
+        if texture.mipmapLevelCount > 1 {
+            let device = self.device!
+
+            try? device.ensureCommandBuffer()
+
+            guard let buffer = device.renderState.commandBuffer,
+                let encoder = buffer.makeBlitCommandEncoder()
+            else {
+                assertionFailure("MetalTexture: Failed to create command buffer for mipmap generation")
+                return
+            }
+
+            encoder.generateMipmaps(for: texture)
+            encoder.endEncoding()
+        }
+    }
+
+    /// Emulates the "map" operation available in Direct3D, providing a pointer for texture uploads or downloads
+    /// - Parameters:
+    ///   - mode: Map mode to use (writing or reading)
+    ///   - mipmapLevel: Mip map level to map
+    /// - Returns: A ``MetalTextureMapping`` struct that provides the result of the mapping
+    ///
+    /// In Direct3D a "map" operation will do many things at once depending on the current state of its pipelines and
+    /// the mapping mode used:
+    /// * When mapped for writing, Direct3D will provide a pointer to CPU memory into which an application can write
+    ///   new texture data.
+    /// * When mapped for reading, Direct3D will provide a pointer to CPU memory into which it has copied the contents
+    ///   of the texture
+    ///
+    /// In either case, the texture will be blocked from access by the GPU until it is unmapped again. In some cases a
+    /// "map" operation will also implicitly initiate a "flush" operation to ensure that pending GPU commands involving
+    /// this texture are submitted before it becomes unavailable.
+    ///
+    /// Metal does not provide such a convenience method and because `libobs` operates under the assumption that it has
+    /// to copy its own data into a memory location provided by Direct3D, this has to be emulated explicitly here,
+    /// albeit without the blocking of access to the texture.
+    ///
+    /// This function always needs to be balanced by an appropriate ``unmap`` call.
+    func map(mode: MetalTextureMapMode, mipmapLevel: Int = 0) -> MetalTextureMapping? {
+        guard mappingMode == .unmapped else {
+            assertionFailure("MetalTexture: Attempted to map already-mapped texture.")
+            return nil
+        }
+
+        let mipmapWidth = texture.width >> mipmapLevel
+        let mipmapHeight = texture.height >> mipmapLevel
+
+        let rowSize = mipmapWidth * texture.pixelFormat.bytesPerPixel!
+        let dataSize = rowSize * mipmapHeight
+
+        // TODO: Evaluate whether a blit to/from a `MTLBuffer` with its `contents` pointer shared is more efficient
+        let data = UnsafeMutableRawBufferPointer.allocate(byteCount: dataSize, alignment: MemoryLayout<UInt8>.alignment)
+
+        guard let baseAddress = data.baseAddress else {
+            return nil
+        }
+
+        if mode == .read {
+            download(data: baseAddress, mipmapLevel: mipmapLevel)
+        }
+
+        self.data = baseAddress
+        self.mappingMode = mode
+
+        let mapping = MetalTextureMapping(
+            mode: mode,
+            rowSize: rowSize,
+            data: baseAddress
+        )
+
+        return mapping
+    }
+
+    /// Emulates the "unmap" operation available in Direct3D
+    /// - Parameter mipmapLevel: The mipmap level that is to be unmapped
+    ///
+    /// This function will replace the contents of the "mapped" texture with the data written into the memory provided
+    /// by the "mapping".
+    ///
+    /// As such this function has to always balance the corresponding ``map`` call to ensure that the data written into
+    /// the provided memory location is written into the texture and the memory itself is deallocated.
+    func unmap(mipmapLevel: Int = 0) {
+        guard mappingMode != .unmapped else {
+            assertionFailure("MetalTexture: Attempted to unmap an unmapped texture")
+            return
+        }
+
+        let mipmapWidth = texture.width >> mipmapLevel
+        let mipmapHeight = texture.height >> mipmapLevel
+
+        let rowSize = mipmapWidth * texture.pixelFormat.bytesPerPixel!
+        let region = MTLRegionMake2D(0, 0, mipmapWidth, mipmapHeight)
+
+        if let textureData = self.data {
+            if self.mappingMode == .write {
+                texture.replace(
+                    region: region,
+                    mipmapLevel: mipmapLevel,
+                    withBytes: textureData,
+                    bytesPerRow: rowSize
+                )
+            }
+
+            textureData.deallocate()
+            self.data = nil
+        }
+
+        self.mappingMode = .unmapped
+    }
+
+    /// Gets an opaque pointer for the ``MetalTexture`` instance and increases its reference count by one
+    /// - Returns: `OpaquePointer` to class instance
+    ///
+    /// > Note: Use this method when the instance is to be shared via an `OpaquePointer` and needs to be retained. Any
+    /// opaque pointer shared this way  needs to be converted into a retained reference again to ensure automatic
+    /// deinitialization by the Swift runtime.
+    func getRetained() -> OpaquePointer {
+        let retained = Unmanaged.passRetained(self).toOpaque()
+
+        return OpaquePointer(retained)
+    }
+
+    /// Gets an opaque pointer for the ``MetalTexture`` instance without increasing its reference count
+    /// - Returns: `OpaquePointer` to class instance
+    func getUnretained() -> OpaquePointer {
+        let unretained = Unmanaged.passUnretained(self).toOpaque()
+
+        return OpaquePointer(unretained)
+    }
+}
+
+/// Extends the ``MetalTexture`` class with comparison operators and a hash function to enable the use inside a `Set`
+/// collection
+extension MetalTexture: Hashable {
+    static func == (lhs: MetalTexture, rhs: MetalTexture) -> Bool {
+        lhs.resourceID == rhs.resourceID
+    }
+
+    static func != (lhs: MetalTexture, rhs: MetalTexture) -> Bool {
+        lhs.resourceID != rhs.resourceID
+    }
+
+    func hash(into hasher: inout Hasher) {
+        hasher.combine(resourceID)
+    }
+}

+ 1603 - 0
libobs-metal/OBSShader.swift

@@ -0,0 +1,1603 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+private enum SampleVariant {
+    case load
+    case sample
+    case sampleBias
+    case sampleGrad
+    case sampleLevel
+}
+
+private struct VariableType: OptionSet {
+    var rawValue: UInt
+
+    static let typeUniform = VariableType(rawValue: 1 << 0)
+    static let typeStruct = VariableType(rawValue: 1 << 1)
+    static let typeStructMember = VariableType(rawValue: 1 << 2)
+    static let typeInput = VariableType(rawValue: 1 << 3)
+    static let typeOutput = VariableType(rawValue: 1 << 4)
+    static let typeTexture = VariableType(rawValue: 1 << 5)
+    static let typeConstant = VariableType(rawValue: 1 << 6)
+
+}
+
+private struct OBSShaderFunction {
+    let name: String
+
+    var returnType: String
+    var typeMap: [String: String]
+
+    var requiresUniformBuffers: Bool
+    var textures: [String]
+    var samplers: [String]
+
+    var arguments: [OBSShaderVariable]
+
+    let gsFunction: UnsafeMutablePointer<shader_func>
+}
+
+private struct OBSShaderVariable {
+    let name: String
+
+    var type: String
+    var mapping: String?
+    var storageType: VariableType
+
+    var requiredBy: Set<String>
+    var returnedBy: Set<String>
+
+    var isStage: Bool
+    var attributeId: Int?
+    var isConstant: Bool
+    var isReference: Bool
+
+    let gsVariable: UnsafeMutablePointer<shader_var>
+}
+
+private struct OBSShaderStruct {
+    let name: String
+
+    var storageType: VariableType
+    var members: [OBSShaderVariable]
+
+    let gsVariable: UnsafeMutablePointer<shader_struct>
+}
+
+private struct MSLTemplates {
+    static let header = """
+        #include <metal_stdlib>
+
+        using namespace metal;
+        """
+
+    static let variable = "[qualifier] [type] [name] [mapping]"
+
+    static let shaderStruct = """
+        typedef struct {
+        [variable]
+        } [typename];
+        """
+
+    static let function = "[decorator] [type] [name]([parameters]) {[content]}"
+}
+
+private typealias ParserError = MetalError.OBSShaderParserError
+private typealias ShaderError = MetalError.OBSShaderError
+
+class OBSShader {
+    private let type: MTLFunctionType
+    private let content: String
+    private let fileLocation: String
+
+    private var parser: shader_parser
+    private var parsed: Bool
+
+    private var uniformsOrder = [String]()
+    private var uniforms = [String: OBSShaderVariable]()
+    private var structs = [String: OBSShaderStruct]()
+    private var functionsOrder = [String]()
+    private var functions = [String: OBSShaderFunction]()
+    private var referenceVariables = [String]()
+
+    var metaData: MetalShader.ShaderData?
+
+    init(type: MTLFunctionType, content: String, fileLocation: String) throws {
+        guard type == .vertex || type == .fragment else {
+            throw ShaderError.unsupportedType
+        }
+
+        self.type = type
+        self.content = content
+        self.fileLocation = fileLocation
+
+        self.parsed = false
+
+        self.parser = shader_parser()
+
+        try withUnsafeMutablePointer(to: &parser) {
+            shader_parser_init($0)
+
+            let result = shader_parse($0, content.cString(using: .utf8), content.cString(using: .utf8))
+            let warnings = shader_parser_geterrors($0)
+
+            if let warnings {
+                throw ShaderError.parseError(String(cString: warnings))
+            }
+
+            if !result {
+                throw ShaderError.parseFail("Shader failed to parse: \(fileLocation)")
+            } else {
+                self.parsed = true
+            }
+        }
+    }
+
+    /// Transpiles a `libobs` effect string into a Metal Shader Language (MSL) string
+    /// - Returns: MSL string representing the transpiled shader
+    func transpiled() throws -> String {
+        try analyzeUniforms()
+        try analyzeParameters()
+        try analyzeFunctions()
+
+        let uniforms = try transpileUniforms()
+        let structs = try transpileStructs()
+        let functions = try transpileFunctions()
+
+        self.metaData = try buildMetadata()
+
+        return [MSLTemplates.header, uniforms, structs, functions].joined(separator: "\n\n")
+    }
+
+    /// Builds a metadata object for the current shader
+    /// - Returns: ``ShaderData`` object with the shader metadata
+    ///
+    /// The effects used by `libobs` are written in HLSL with some customizations to allow multiple shaders within the
+    /// same effects file (which is supported natively by MSL). As MSL does not support "global" variables, uniforms
+    /// have to be provided explicitly via buffers and the data inside those buffers needs to be laid out in the correct
+    /// way.
+    ///
+    /// Uniforms are converted into `struct` objects in the shader files and as MSL is based on C++14, these structs
+    /// will have a size, stride, and alignment, set by the compiler. Thus the uniform data used by the shader needs to
+    /// be laid out in the buffer according to this alignment.
+    ///
+    /// The layout of vertex buffer data also needs to be communicated using `MTLVertexDescriptor` instances for vertex
+    /// shaders and `MTLSamplerState` instances for fragment shaders. Both will be created and set up in a
+    /// ``ShaderData`` which is used to create the actual ``MetalShader`` object.
+    private func buildMetadata() throws -> MetalShader.ShaderData {
+        var uniformInfo = [MetalShader.ShaderUniform]()
+
+        var textureSlot = 0
+        var uniformBufferSize = 0
+
+        /// The order of buffers and uniforms is "load-bearing" as the order (and thus alignment and offsets) of
+        /// uniforms in the corresponding uniforms struct are
+        /// influenced by it.
+        for uniformName in uniformsOrder {
+            guard let uniform = uniforms[uniformName] else {
+                throw ParserError.parseFail("No uniform data found for '\(uniformName)'")
+            }
+
+            let gsType = get_shader_param_type(uniform.gsVariable.pointee.type)
+            let isTexture = uniform.storageType.contains(.typeTexture)
+
+            let byteSize: Int
+            let alignment: Int
+            let bufferOffset: Int
+
+            if isTexture {
+                byteSize = 0
+                alignment = 0
+                bufferOffset = uniformBufferSize
+            } else {
+                byteSize = gsType.mtlSize
+                alignment = gsType.mtlAlignment
+                bufferOffset = (uniformBufferSize + (alignment - 1)) & ~(alignment - 1)
+            }
+
+            let shaderUniform = MetalShader.ShaderUniform(
+                name: uniform.name,
+                gsType: gsType,
+                textureSlot: (isTexture ? textureSlot : 0),
+                samplerState: nil,
+                byteOffset: bufferOffset
+            )
+
+            shaderUniform.defaultValues = Array(
+                UnsafeMutableBufferPointer(
+                    start: uniform.gsVariable.pointee.default_val.array,
+                    count: uniform.gsVariable.pointee.default_val.num)
+            )
+
+            shaderUniform.currentValues = shaderUniform.defaultValues
+
+            uniformBufferSize = bufferOffset + byteSize
+
+            if isTexture {
+                textureSlot += 1
+            }
+
+            uniformInfo.append(shaderUniform)
+        }
+
+        guard let mainFunction = functions["main"] else {
+            throw ParserError.missingMainFunction
+        }
+
+        let parameterMapper = { (mapping: String) -> MetalBuffer.BufferDataType? in
+            switch mapping {
+            case "POSITION":
+                .vertex
+            case "NORMAL":
+                .normal
+            case "TANGENT":
+                .tangent
+            case "COLOR":
+                .color
+            case _ where mapping.hasPrefix("TEXCOORD"):
+                .texcoord
+            default:
+                .none
+            }
+        }
+
+        let descriptorMapper = { (parameter: OBSShaderVariable) -> (MTLVertexFormat, Int)? in
+            guard let mapping = parameter.mapping else {
+                return nil
+            }
+
+            let type = parameter.type
+
+            switch mapping {
+            case "COLOR":
+                return (.float4, MemoryLayout<vec4>.size)
+            case "POSITION", "NORMAL", "TANGENT":
+                return (.float4, MemoryLayout<vec4>.size)
+            case _ where mapping.hasPrefix("TEXCOORD"):
+                guard let numCoordinates = type[type.index(type.startIndex, offsetBy: 5)].wholeNumberValue else {
+                    assertionFailure("Unsupported type \(type) for texture parameter")
+                    return nil
+                }
+
+                let format: MTLVertexFormat =
+                    switch numCoordinates {
+                    case 0: .float
+                    case 2: .float2
+                    case 3: .float3
+                    case 4: .float4
+                    default: .invalid
+                    }
+
+                guard format != .invalid else {
+                    assertionFailure("OBSShader: Unsupported amount of texture coordinates '\(numCoordinates)'")
+                    return nil
+                }
+
+                return (format, MemoryLayout<Float32>.size * numCoordinates)
+            case "VERTEXID":
+                return nil
+            default:
+                assertionFailure("OBSShader: Unsupported mapping \(mapping)")
+                return nil
+            }
+        }
+
+        switch type {
+        case .vertex:
+            var bufferOrder = [MetalBuffer.BufferDataType]()
+            var descriptorData = [(MTLVertexFormat, Int)?]()
+            let descriptor = MTLVertexDescriptor()
+
+            for argument in mainFunction.arguments {
+                if argument.storageType.contains(.typeStruct) {
+                    let actualStructType = argument.type.replacingOccurrences(of: "_In", with: "")
+
+                    guard let shaderStruct = structs[actualStructType] else {
+                        throw ParserError.parseFail("Shader function without struct metadata encountered ")
+                    }
+
+                    for shaderParameter in shaderStruct.members {
+                        if let mapping = shaderParameter.mapping, let mapping = parameterMapper(mapping) {
+                            bufferOrder.append(mapping)
+                        }
+
+                        if let description = descriptorMapper(shaderParameter) {
+                            descriptorData.append(description)
+                        }
+                    }
+                } else {
+                    if let mapping = argument.mapping, let mapping = parameterMapper(mapping) {
+                        bufferOrder.append(mapping)
+                    }
+
+                    if let description = descriptorMapper(argument) {
+                        descriptorData.append(description)
+                    }
+                }
+            }
+
+            let textureUnitCount = bufferOrder.filter({ $0 == .texcoord }).count
+
+            for (attributeId, description) in descriptorData.filter({ $0 != nil }).enumerated() {
+                descriptor.attributes[attributeId].bufferIndex = attributeId
+                descriptor.attributes[attributeId].format = description!.0
+                descriptor.layouts[attributeId].stride = description!.1
+            }
+
+            return MetalShader.ShaderData(
+                uniforms: uniformInfo,
+                bufferOrder: bufferOrder,
+                vertexDescriptor: descriptor,
+                samplerDescriptors: nil,
+                bufferSize: uniformBufferSize,
+                textureCount: textureUnitCount
+            )
+        case .fragment:
+            var samplers = [MTLSamplerDescriptor]()
+
+            for i in 0..<parser.samplers.num {
+                let sampler: UnsafeMutablePointer<shader_sampler>? = parser.samplers.array.advanced(by: i)
+
+                if let sampler {
+                    var sampler_info = gs_sampler_info()
+                    shader_sampler_convert(sampler, &sampler_info)
+
+                    let borderColor: MTLSamplerBorderColor =
+                        switch sampler_info.border_color {
+                        case 0x00_00_00_FF:
+                            .opaqueBlack
+                        case 0xFF_FF_FF_FF:
+                            .opaqueWhite
+                        default:
+                            .transparentBlack
+                        }
+
+                    let descriptor = MTLSamplerDescriptor()
+
+                    descriptor.borderColor = borderColor
+                    descriptor.maxAnisotropy = Int(sampler_info.max_anisotropy)
+
+                    guard
+                        let sAddressMode = sampler_info.address_u.mtlMode,
+                        let tAddressMode = sampler_info.address_v.mtlMode,
+                        let rAddressMode = sampler_info.address_w.mtlMode,
+                        let minMagFilter = sampler_info.filter.minMagFilter,
+                        let mipFilter = sampler_info.filter.mipFilter
+                    else {
+                        samplers.append(descriptor)
+                        continue
+                    }
+
+                    descriptor.sAddressMode = sAddressMode
+                    descriptor.tAddressMode = tAddressMode
+                    descriptor.rAddressMode = rAddressMode
+
+                    descriptor.minFilter = minMagFilter
+                    descriptor.magFilter = minMagFilter
+                    descriptor.mipFilter = mipFilter
+
+                    samplers.append(descriptor)
+                }
+            }
+
+            return MetalShader.ShaderData(
+                uniforms: uniformInfo,
+                bufferOrder: [],
+                vertexDescriptor: nil,
+                samplerDescriptors: samplers,
+                bufferSize: uniformBufferSize,
+                textureCount: 0
+            )
+        default:
+            throw ShaderError.unsupportedType
+        }
+    }
+
+    /// Analyzes shader uniform parameters parsed by the ``libobs`` shader parser.
+    ///
+    /// Each global variable declared as a "uniform" is stored as an ``OBSShaderVariable`` struct, which will be
+    /// extended with additional metadata by later analystics steps.
+    ///
+    /// This is necessary as MSL does not support global variables and all data needs to be explicitly provided
+    /// via buffer objects, which requires these "unforms" to be wrapped into a single struct and passed as an explicit
+    /// buffer object.
+    private func analyzeUniforms() throws {
+        for i in 0..<parser.params.num {
+            let uniform: UnsafeMutablePointer<shader_var>? = parser.params.array.advanced(by: i)
+
+            guard let uniform, let name = uniform.pointee.name, let type = uniform.pointee.type else {
+                throw ParserError.parseFail("Uniform is missing name or type information")
+            }
+
+            let mapping: String? =
+                if let mapping = uniform.pointee.mapping {
+                    String(cString: mapping)
+                } else {
+                    nil
+                }
+
+            var data = OBSShaderVariable(
+                name: String(cString: name),
+                type: String(cString: type),
+                mapping: mapping,
+                storageType: .typeUniform,
+                requiredBy: [],
+                returnedBy: [],
+                isStage: false,
+                attributeId: 0,
+                isConstant: (uniform.pointee.var_type == SHADER_VAR_CONST),
+                isReference: false,
+                gsVariable: uniform
+            )
+
+            if self.type == .fragment {
+                /// A texture uniform does not contribute to the uniform buffer
+                if data.type.hasPrefix("texture") {
+                    data.storageType.remove(.typeUniform)
+                    data.storageType.insert(.typeTexture)
+                }
+            }
+
+            uniformsOrder.append(data.name)
+            uniforms.updateValue(data, forKey: data.name)
+
+        }
+    }
+
+    /// Analyzes struct parameter declarations parsed by the ``libobs`` shader parser.
+    ///
+    /// Structured data declarations are used to pass data into and out of shaders.
+    ///
+    /// Whereas HLSL allows one to use "InOut" structures with attribute mappings (e.g., using the same type defintion
+    /// for vertex data going in and out of a vertex shader), MSL does not allow the mixing of input mappings and output
+    /// mappings in the same type definition.
+    ///
+    /// Thus when the same struct type is used as an input argument for a function but also used as its output type, it
+    /// needs to be split up into two separate types for the MSL shader.
+    ///
+    /// This function will first detect all struct type definitions in the shader file and then check if it is used as
+    /// an input argument or function output and update the associated ``OBSShaderVariable`` structs accordingly.
+    private func analyzeParameters() throws {
+        for i in 0..<parser.structs.num {
+            let shaderStruct: UnsafeMutablePointer<shader_struct>? = parser.structs.array.advanced(by: i)
+
+            guard let shaderStruct, let name = shaderStruct.pointee.name else {
+                throw ParserError.parseFail("Constant data struct has no name")
+            }
+
+            var parameters = [OBSShaderVariable]()
+            parameters.reserveCapacity(shaderStruct.pointee.vars.num)
+
+            for j in 0..<shaderStruct.pointee.vars.num {
+                let variablePointer: UnsafeMutablePointer<shader_var>? = shaderStruct.pointee.vars.array.advanced(by: j)
+
+                guard let variablePointer, let variableName = variablePointer.pointee.name,
+                    let variableType = variablePointer.pointee.type
+                else {
+                    throw ParserError.parseFail("Constant data variable has no name")
+                }
+
+                let mapping: String? =
+                    if let variableMapping = variablePointer.pointee.mapping { String(cString: variableMapping) } else {
+                        nil
+                    }
+
+                let variable = OBSShaderVariable(
+                    name: String(cString: variableName),
+                    type: String(cString: variableType),
+                    mapping: mapping,
+                    storageType: .typeStructMember,
+                    requiredBy: [],
+                    returnedBy: [],
+                    isStage: false,
+                    attributeId: nil,
+                    isConstant: false,
+                    isReference: false,
+                    gsVariable: variablePointer
+                )
+
+                parameters.append(variable)
+            }
+
+            let data = OBSShaderStruct(
+                name: String(cString: name),
+                storageType: [],
+                members: parameters,
+                gsVariable: shaderStruct
+            )
+
+            structs.updateValue(data, forKey: data.name)
+        }
+
+        for i in 0..<parser.funcs.num {
+            let function: UnsafeMutablePointer<shader_func>? = parser.funcs.array.advanced(by: i)
+
+            guard let function, let functionName = function.pointee.name, let returnType = function.pointee.return_type
+            else {
+                throw ParserError.parseFail("Shader function has no name or type information")
+            }
+
+            var functionData = OBSShaderFunction(
+                name: String(cString: functionName),
+                returnType: String(cString: returnType),
+                typeMap: [:],
+                requiresUniformBuffers: false,
+                textures: [],
+                samplers: [],
+                arguments: [],
+                gsFunction: function,
+            )
+
+            for j in 0..<function.pointee.params.num {
+                let parameter: UnsafeMutablePointer<shader_var>? = function.pointee.params.array.advanced(by: j)
+
+                guard let parameter, let parameterName = parameter.pointee.name,
+                    let parameterType = parameter.pointee.type
+                else {
+                    throw ParserError.parseFail("Function parameter has no name or type information")
+                }
+
+                let mapping: String? =
+                    if let parameterMapping = parameter.pointee.mapping {
+                        String(cString: parameterMapping)
+                    } else {
+                        nil
+                    }
+
+                /// Most effects do not seem to use `out` or `inout` function arguments, but the lanczos scale filter
+                /// does. The most straight-forward way
+                /// to support this pattern is to use C++-style references with the `thread` storage specifier.
+                let isReferenceVariable =
+                    (parameter.pointee.var_type == SHADER_VAR_OUT || parameter.pointee.var_type == SHADER_VAR_INOUT)
+
+                var parameterData = OBSShaderVariable(
+                    name: String(cString: parameterName),
+                    type: String(cString: parameterType),
+                    mapping: mapping,
+                    storageType: .typeInput,
+                    requiredBy: [functionData.name],
+                    returnedBy: [],
+                    isStage: false,
+                    attributeId: nil,
+                    isConstant: (parameter.pointee.var_type == SHADER_VAR_CONST),
+                    isReference: isReferenceVariable,
+                    gsVariable: parameter
+                )
+
+                if isReferenceVariable {
+                    referenceVariables.append(parameterData.name)
+                }
+
+                if parameterData.type == functionData.returnType {
+                    parameterData.returnedBy.insert(functionData.name)
+                }
+
+                if !functionData.typeMap.keys.contains(parameterData.name) {
+                    functionData.typeMap.updateValue(parameterData.type, forKey: parameterData.name)
+                }
+
+                /// Metal does not support using the same attribute mappings for structs as input to shader functions
+                /// and output. They need to use different
+                /// mappings and thus every "InOut" struct by `libobs` needs to be split up into a separate input and
+                /// output struct type.
+                for var shaderStruct in structs.values {
+                    if shaderStruct.name == parameterData.type {
+                        shaderStruct.storageType.insert(.typeInput)
+                        parameterData.storageType.insert(.typeStruct)
+
+                        if shaderStruct.name == functionData.returnType {
+                            shaderStruct.storageType.insert(.typeOutput)
+                            parameterData.storageType.insert(.typeOutput)
+                            parameterData.type.append("_In")
+                            functionData.returnType.append("_Out")
+                        }
+
+                        structs.updateValue(shaderStruct, forKey: shaderStruct.name)
+                    }
+                }
+
+                functionData.arguments.append(parameterData)
+            }
+
+            if var shaderStruct = structs[functionData.returnType] {
+                shaderStruct.storageType.insert(.typeOutput)
+                structs.updateValue(shaderStruct, forKey: shaderStruct.name)
+            }
+
+            functions.updateValue(functionData, forKey: functionData.name)
+        }
+    }
+
+    /// Analyzes function data parsed by the ``libobs`` shader parser
+    ///
+    /// As MSL does not support uniforms or using the same struct type for input and output, function bodies themselves
+    /// need to be parsed again and checked for their usage of these types or variables.
+    ///
+    /// Due to the way that the ``libobs`` parser works, each body of a block (either within curly braces or
+    /// parentheses) is analyzed recursively and updating the same ``OBSShaderFunction`` struct.
+    ///
+    /// After a full analysis pass, this struct should contain  information about all uniforms, textures, and samplers
+    /// used (or passed on) by the function.
+    private func analyzeFunctions() throws {
+        for i in 0..<parser.funcs.num {
+            let function: UnsafeMutablePointer<shader_func>? = parser.funcs.array.advanced(by: i)
+
+            guard var function, var token = function.pointee.start, let functionName = function.pointee.name else {
+                throw ParserError.parseFail("Shader function has no name")
+            }
+
+            let functionData = functions[String(cString: functionName)]
+
+            guard var functionData else {
+                throw ParserError.parseFail("Shader function without function meta data encountered")
+            }
+
+            try analyzeFunction(function: &function, functionData: &functionData, token: &token, end: "}")
+
+            functionData.textures = functionData.textures.unique()
+            functionData.samplers = functionData.samplers.unique()
+
+            functions.updateValue(functionData, forKey: functionData.name)
+            functionsOrder.append(functionData.name)
+        }
+    }
+
+    /// Analyzes a function body or source scope to check for use of global variables, textures, or samplers.
+    ///
+    /// Because MSL does not support global variables, unforms, textures, or samplers need to be passed explicitly to a
+    /// function. This requires scanning the entire function body (recursively in the case of separate function scopes
+    /// denoted by curvy brackets or parantheses) for any occurrence of a known uniform, texture, or sampler variable
+    /// name.
+    ///
+    /// - Parameters:
+    ///   - function: Pointer to a ``shader_func`` element representing a parsed shader function
+    ///   - functionData: Reference to a ``OBSShaderFunction`` struct, which will be updated by this function
+    ///   - token: Pointer to a ``cf_token`` element used to interact with the shader parser provided by ``libobs``
+    ///   - end: The sentinel character at which analysis (and parsing) should stop
+    private func analyzeFunction(
+        function: inout UnsafeMutablePointer<shader_func>, functionData: inout OBSShaderFunction,
+        token: inout UnsafeMutablePointer<cf_token>, end: String
+    ) throws {
+        let uniformNames =
+            (uniforms.filter {
+                !$0.value.storageType.contains(.typeTexture)
+            }).keys
+
+        while token.pointee.type != CFTOKEN_NONE {
+            token = token.successor()
+
+            if token.pointee.str.isEqualTo(end) {
+                break
+            }
+
+            let stringToken = token.pointee.str.getString()
+
+            if token.pointee.type == CFTOKEN_NAME {
+                if uniformNames.contains(stringToken) && functionData.requiresUniformBuffers == false {
+                    functionData.requiresUniformBuffers = true
+                }
+
+                if let function = functions[stringToken] {
+                    if function.requiresUniformBuffers && functionData.requiresUniformBuffers == false {
+                        functionData.requiresUniformBuffers = true
+                    }
+
+                    functionData.textures.append(contentsOf: function.textures)
+                    functionData.samplers.append(contentsOf: function.samplers)
+                }
+
+                if type == .fragment {
+                    for uniform in uniforms.values {
+                        if stringToken == uniform.name && uniform.storageType.contains(.typeTexture) {
+                            functionData.textures.append(stringToken)
+                        }
+                    }
+
+                    for i in 0..<parser.samplers.num {
+                        let sampler: UnsafeMutablePointer<shader_sampler>? = parser.samplers.array.advanced(by: i)
+
+                        guard let sampler, let samplerName = sampler.pointee.name else {
+                            break
+                        }
+
+                        if stringToken == String(cString: samplerName) {
+                            functionData.samplers.append(stringToken)
+                        }
+                    }
+                }
+            } else if token.pointee.type == CFTOKEN_OTHER {
+                if token.pointee.str.isEqualTo("{") {
+                    try analyzeFunction(function: &function, functionData: &functionData, token: &token, end: "}")
+                } else if token.pointee.str.isEqualTo("(") {
+                    try analyzeFunction(function: &function, functionData: &functionData, token: &token, end: ")")
+                }
+            }
+        }
+    }
+
+    /// Transpiles the uniform global variables used by the shader into a `UniformData` struct that contains the
+    /// uniforms.
+    /// - Returns: String representing the uniform data struct
+    private func transpileUniforms() throws -> String {
+        var output = [String]()
+
+        for uniformName in uniformsOrder {
+            if var uniform = uniforms[uniformName] {
+                uniform.isStage = false
+                uniform.attributeId = 0
+
+                if !uniform.storageType.contains(.typeTexture) {
+                    let variableString = try transpileVariable(variable: uniform)
+                    output.append("\(variableString);")
+                }
+            }
+        }
+
+        if output.count > 0 {
+            let replacements = [
+                ("[variable]", output.joined(separator: "\n")),
+                ("[typename]", "UniformData"),
+            ]
+
+            let uniformString = replacements.reduce(into: MSLTemplates.shaderStruct) { string, replacement in
+                string = string.replacingOccurrences(of: replacement.0, with: replacement.1)
+            }
+
+            return uniformString
+        } else {
+            return ""
+        }
+    }
+
+    /// Transpiles the vertex data structs used by the shader
+    /// - Returns: String representing the vertex data structs
+    private func transpileStructs() throws -> String {
+        var output = [String]()
+
+        for var shaderStruct in structs.values {
+            if shaderStruct.storageType.isSuperset(of: [.typeInput, .typeOutput]) {
+                /// Metal does not support using the same attribute mappings for structs as input to shader functions
+                /// and output. They need to use different mappings and thus every "InOut" struct by `libobs` needs to
+                /// be split up into a separate input and output struct type.
+                for suffix in ["_In", "_Out"] {
+                    var variables = [String]()
+
+                    for (structVariableId, var structVariable) in shaderStruct.members.enumerated() {
+                        let variableString: String
+
+                        switch suffix {
+                        case "_In":
+                            structVariable.storageType.formUnion([.typeInput])
+                            structVariable.attributeId = structVariableId
+                            variableString = try transpileVariable(variable: structVariable)
+                            structVariable.storageType.remove([.typeInput])
+                        case "_Out":
+                            structVariable.storageType.formUnion([.typeOutput])
+                            variableString = try transpileVariable(variable: structVariable)
+                            structVariable.storageType.remove([.typeOutput])
+                        default:
+                            throw ParserError.parseFail("Shader struct with unknown prefix encountered")
+                        }
+
+                        variables.append("\(variableString);")
+                        shaderStruct.members[structVariableId] = structVariable
+                    }
+
+                    let replacements = [
+                        ("[variable]", variables.joined(separator: "\n")),
+                        ("[typename]", "\(shaderStruct.name)\(suffix)"),
+                    ]
+
+                    let result = replacements.reduce(into: MSLTemplates.shaderStruct) {
+                        string, replacement in
+                        string = string.replacingOccurrences(of: replacement.0, with: replacement.1)
+                    }
+
+                    output.append(result)
+                }
+            } else {
+                var variables = [String]()
+
+                for (structVariableId, var structVariable) in shaderStruct.members.enumerated() {
+                    if shaderStruct.storageType.contains(.typeInput) {
+                        structVariable.storageType.insert(.typeInput)
+                        structVariable.attributeId = structVariableId
+                    } else if shaderStruct.storageType.contains(.typeOutput) {
+                        structVariable.storageType.insert(.typeOutput)
+                    }
+
+                    let variableString = try transpileVariable(variable: structVariable)
+
+                    structVariable.storageType.subtract([.typeInput, .typeOutput])
+
+                    variables.append("\(variableString);")
+                    shaderStruct.members[structVariableId] = structVariable
+                }
+
+                let replacements = [
+                    ("[variable]", variables.joined(separator: "\n")),
+                    ("[typename]", shaderStruct.name),
+                ]
+
+                let result = replacements.reduce(into: MSLTemplates.shaderStruct) {
+                    string, replacement in
+                    string = string.replacingOccurrences(of: replacement.0, with: replacement.1)
+                }
+
+                output.append(result)
+            }
+        }
+
+        if output.count > 0 {
+            return output.joined(separator: "\n\n")
+        } else {
+            return ""
+        }
+    }
+
+    /// Transpiles a shader function into its MSL variant
+    /// - Returns: String representing the transpiled MSL shader function
+    private func transpileFunctions() throws -> String {
+        var output = [String]()
+
+        for functionName in functionsOrder {
+            guard let function = functions[functionName], var token = function.gsFunction.pointee.start else {
+                throw ParserError.parseFail("Shader function has no name")
+            }
+
+            var stageConsumed = false
+            let isMain = functionName == "main"
+
+            var variables = [String]()
+            for var variable in function.arguments {
+                if isMain && !stageConsumed {
+                    variable.isStage = true
+                    stageConsumed = true
+                }
+
+                try variables.append(transpileVariable(variable: variable))
+            }
+
+            /// As Metal has no support for global constants, the constant data needs to be wrapped into a `struct`
+            /// and the associated data is uploaded into a vertex buffer at a specific index (30 in this case).
+            ///
+            /// Buffers are not automatically available to shader functions but are passed into the function explicitly
+            ///as arguments.
+            ///
+            /// As `libobs` effects are based around a "main" entry function (something strongly discouraged by Metal),
+            /// each "main" function needs to receive the actual buffer as an argument and each function called _by_
+            /// the main function and which internally accesses the uniform needs to have that uniform passed
+            /// explicitly as an argument as well.
+            if (uniforms.values.filter { !$0.storageType.contains(.typeTexture) }).count > 0 {
+                if isMain {
+                    variables.append("constant UniformData &uniforms [[buffer(30)]]")
+                } else if function.requiresUniformBuffers {
+                    variables.append("constant UniformData &uniforms")
+                }
+            }
+
+            if type == .fragment {
+                var textureId = 0
+
+                for uniformName in uniformsOrder {
+                    guard let uniform = uniforms[uniformName] else {
+                        break
+                    }
+
+                    if uniform.storageType.contains(.typeTexture) {
+                        if isMain {
+                            let variableString = try transpileVariable(variable: uniform)
+
+                            variables.append("\(variableString) [[texture(\(textureId))]]")
+                            textureId += 1
+                        } else if function.textures.contains(uniform.name) {
+                            let variableString = try transpileVariable(variable: uniform)
+                            variables.append(variableString)
+                        }
+                    }
+                }
+
+                var samplerId = 0
+                for i in 0..<parser.samplers.num {
+                    let sampler: UnsafeMutablePointer<shader_sampler>? = parser.samplers.array.advanced(by: i)
+
+                    if let sampler, let samplerName = sampler.pointee.name {
+                        let name = String(cString: samplerName)
+
+                        if isMain {
+                            let variableString = "sampler \(name) [[sampler(\(samplerId))]]"
+                            variables.append(variableString)
+                            samplerId += 1
+                        } else if function.samplers.contains(name) {
+                            let variabelString = "sampler \(name)"
+                            variables.append(variabelString)
+                        }
+                    }
+                }
+            }
+
+            let mappedType = try convertToMTLType(gsType: function.returnType)
+
+            let functionContent: String
+            var replacements = [(String, String)]()
+
+            /// Metal shaders do not have "main" functions - a single shader file usually contains all shader functions
+            /// used by an application, each identified by their name and type decorator. This is not supported by OBS,
+            /// so each shader needs to have a "main" function that calls the actual shader function, which thus
+            /// requires a new shader library to be created for each effect file.
+            if isMain {
+                replacements = [
+                    ("[name]", "_main"),
+                    ("[parameters]", variables.joined(separator: ", ")),
+                ]
+
+                switch type {
+                case .vertex:
+                    replacements.append(("[decorator]", "[[vertex]]"))
+                case .fragment:
+                    replacements.append(("[decorator]", "[[fragment]]"))
+                default:
+                    fatalError("OBSShader: Unsupported shader type \(type)")
+                }
+
+                let temporaryContent = try transpileFunctionContent(token: &token, end: "}")
+
+                if type == .fragment && isMain && mappedType == "float3" {
+                    replacements.append(("[type]", "float4"))
+
+                    // TODO: Replace with Swift-native Regex once macOS 13+ is minimum target
+                    let regex = try NSRegularExpression(pattern: "return (.+);")
+                    functionContent = regex.stringByReplacingMatches(
+                        in: temporaryContent,
+                        range: NSRange(location: 0, length: temporaryContent.count),
+                        withTemplate: "return float4($1, 1);"
+                    )
+                } else {
+                    functionContent = temporaryContent
+                    replacements.append(("[type]", mappedType))
+                }
+
+                replacements.append(("[content]", functionContent))
+            } else {
+                functionContent = try transpileFunctionContent(token: &token, end: "}")
+
+                replacements = [
+                    ("[decorator]", ""),
+                    ("[type]", mappedType),
+                    ("[name]", function.name),
+                    ("[parameters]", variables.joined(separator: ", ")),
+                    ("[content]", functionContent),
+                ]
+            }
+
+            let result = replacements.reduce(into: MSLTemplates.function) {
+                string, replacement in
+                string = string.replacingOccurrences(of: replacement.0, with: replacement.1)
+            }
+
+            output.append(result)
+        }
+
+        if output.count > 0 {
+            return output.joined(separator: "\n\n")
+        } else {
+            return ""
+        }
+    }
+
+    /// Transpiles a variable into its MSL variant
+    /// - Parameter variable: Variable to transpile
+    /// - Returns: String representing a transpiled variable
+    ///
+    /// Variables can either be members of a `struct` or an argument to a function. The ``OBSShaderVariable`` instance
+    /// has a `storageType` property which encodes the use of the variable and helps in creation of the appropriate MSL
+    /// string representation.
+    private func transpileVariable(variable: OBSShaderVariable) throws -> String {
+        var mappings = [String]()
+
+        var metalMapping: String
+        var indent = 0
+
+        let metalType = try convertToMTLType(gsType: variable.type)
+
+        if variable.storageType.contains(.typeUniform) {
+            indent = 4
+        } else if variable.storageType.isSuperset(of: [.typeInput, .typeStructMember]) {
+            switch type {
+            case .vertex:
+                indent = 4
+
+                /// Attributes are used to associate a member of a uniform `struct` with its data in the vertex buffer
+                /// stage.
+                if let attributeId = variable.attributeId {
+                    mappings.append("attribute(\(attributeId))")
+                }
+            case .fragment:
+                indent = 4
+
+                if let mappingPointer = variable.gsVariable.pointee.mapping,
+                    let mappedString = convertToMTLMapping(gsMapping: String(cString: mappingPointer))
+                {
+                    mappings.append(mappedString)
+                }
+            default:
+                fatalError("OBSShader: Unsupported shader function type \(type)")
+            }
+        } else if variable.storageType.isSuperset(of: [.typeOutput, .typeStructMember]) {
+            indent = 4
+
+            if let mappingPointer = variable.gsVariable.pointee.mapping,
+                let mappedString = convertToMTLMapping(gsMapping: String(cString: mappingPointer))
+            {
+                mappings.append(mappedString)
+            }
+        } else {
+            indent = 0
+
+            if variable.isStage {
+                if let mappingPointer = variable.gsVariable.pointee.mapping,
+                    let mappedString = convertToMTLMapping(gsMapping: String(cString: mappingPointer))
+                {
+                    mappings.append(mappedString)
+                } else {
+                    mappings.append("stage_in")
+                }
+            }
+        }
+
+        if mappings.count > 0 {
+            metalMapping = " [[\(mappings.joined(separator: ", "))]]"
+        } else {
+            metalMapping = ""
+        }
+
+        let qualifier =
+            if variable.storageType.contains(.typeConstant) {
+                " constant "
+            } else if variable.isReference {
+                " thread "
+            } else {
+                ""
+            }
+
+        let name =
+            if variable.isReference {
+                "&\(variable.name)"
+            } else { variable.name }
+
+        let result = "\(String(repeating: " ", count: indent))\(qualifier)\(metalType) \(name)\(metalMapping)"
+
+        return result
+    }
+
+    /// Transpiles the body of a function into its MSL representation
+    /// - Parameters:
+    ///   - token: Stateful `libobs` parser token pointer
+    ///   - end: String representing which ends function body parsing if matched
+    /// - Returns: String representing the body of a MSL shader function
+    ///
+    /// OBS effect function content needs to be transpiled into MSL function content token by token, as each token
+    /// needs to be matched not only against direct translations (e.g., a HLSL function name into its appropriate MSL
+    /// variant) but also to detect if a token represents a uniform variable which will not be available as a global
+    /// variable in MSL, but instead will only exist as part of the `uniform` struct that was  explicitly passed into
+    /// the function.
+    ///
+    /// Similarly, if a function call is encountered, the function's metadata needs to be checked for use of such a
+    /// uniform and the call signature extended to explicitly pass the data into the called function.
+    ///
+    /// Because Metal does not implicitly or automagically coerce types (but the effects files sometimes rely on this),
+    /// some arguments and parameters need to be explicitly wrapped in casts to wider types (e.g., a `float3` is
+    /// returned from a fragment shader, but fragment shaders _have to_ provide a `float4`).
+    ///
+    /// There are many such conversions necessary, as MSL is more strict than HLSL or GLSL when it comes to type safety.
+    private func transpileFunctionContent(token: inout UnsafeMutablePointer<cf_token>, end: String) throws -> String {
+        var content = [String]()
+
+        while token.pointee.type != CFTOKEN_NONE {
+            token = token.successor()
+
+            if token.pointee.str.isEqualTo(end) {
+                break
+            }
+
+            let stringToken = token.pointee.str.getString()
+
+            if token.pointee.type == CFTOKEN_NAME {
+                let type = try convertToMTLType(gsType: stringToken)
+
+                if stringToken == "obs_glsl_compile" {
+                    content.append("false")
+                    continue
+                }
+
+                if type != stringToken {
+                    content.append(type)
+                    continue
+                }
+
+                if let intrinsic = try convertToMTLIntrinsic(intrinsic: stringToken) {
+                    content.append(intrinsic)
+                    continue
+                }
+
+                if stringToken == "mul" {
+                    try content.append(convertToMTLMultiplication(token: &token))
+                    continue
+                } else if stringToken == "mad" {
+                    try content.append(convertToMTLMultiplyAdd(token: &token))
+                    continue
+                } else {
+                    var skip = false
+                    for uniform in uniforms.values {
+                        if uniform.name == stringToken && uniform.storageType.contains(.typeTexture) {
+                            try content.append(createSampler(token: &token))
+                            skip = true
+                            break
+                        }
+                    }
+
+                    if skip {
+                        continue
+                    }
+                }
+
+                if uniforms.keys.contains(stringToken) {
+                    let priorToken = token.predecessor()
+                    let priorString = priorToken.pointee.str.getString()
+
+                    if priorString != "." {
+                        content.append("uniforms.\(stringToken)")
+                        continue
+                    }
+                }
+
+                var skip = false
+                for shaderStruct in structs.values {
+                    if shaderStruct.name == stringToken {
+                        if shaderStruct.storageType.isSuperset(of: [.typeInput, .typeOutput]) {
+                            content.append("\(stringToken)_Out")
+                            skip = true
+                            break
+                        }
+                    }
+                }
+
+                if skip {
+                    continue
+                }
+
+                if let comparison = try convertToMTLComparison(token: &token) {
+                    content.append(comparison)
+                    continue
+                }
+
+                content.append(stringToken)
+            } else if token.pointee.type == CFTOKEN_OTHER {
+                if token.pointee.str.isEqualTo("{") {
+                    let blockContent = try transpileFunctionContent(token: &token, end: "}")
+                    content.append("{\(blockContent)}")
+                    continue
+                } else if token.pointee.str.isEqualTo("(") {
+                    let priorToken = token.predecessor()
+                    let functionName = priorToken.pointee.str.getString()
+
+                    var functionParameters = [String]()
+
+                    let parameters = try transpileFunctionContent(token: &token, end: ")")
+
+                    if functionName == "int3" {
+                        let intParameters = parameters.split(
+                            separator: ",", maxSplits: 3, omittingEmptySubsequences: true)
+
+                        switch intParameters.count {
+                        case 3:
+                            functionParameters.append(
+                                "int(\(intParameters[0])), int(\(intParameters[1])), int(\(intParameters[2]))")
+                        case 2:
+                            functionParameters.append("int2(\(intParameters[0])), int(\(intParameters[1]))")
+                        case 1:
+                            functionParameters.append("\(intParameters)")
+                        default:
+                            throw ParserError.parseFail("int3 constructor with invalid amount of arguments encountered")
+                        }
+                    } else {
+                        functionParameters.append(parameters)
+                    }
+
+                    if let additionalArguments = generateAdditionalArguments(for: functionName) {
+                        functionParameters.append(additionalArguments)
+                    }
+
+                    content.append("(\(functionParameters.joined(separator: ", ")))")
+                    continue
+                }
+
+                content.append(stringToken)
+            } else {
+                content.append(stringToken)
+            }
+        }
+
+        return content.joined()
+    }
+
+    /// Converts a HLSL-like type into a MSL type if possible
+    /// - Parameter gsType: HLSL-like type string
+    /// - Returns: MSL type string
+    private func convertToMTLType(gsType: String) throws -> String {
+        switch gsType {
+        case "texture2d":
+            return "texture2d<float>"
+        case "texture3d":
+            return "texture3d<float>"
+        case "texture_cube":
+            return "texturecube<float>"
+        case "texture_rect":
+            throw ParserError.unsupportedType
+        case "half2":
+            return "float2"
+        case "half3":
+            return "float3"
+        case "half4":
+            return "float4"
+        case "half":
+            return "float"
+        case "min16float2":
+            return "half2"
+        case "min16float3":
+            return "half3"
+        case "min16float4":
+            return "half4"
+        case "min16float":
+            return "half"
+        case "min10float":
+            throw ParserError.unsupportedType
+        case "double":
+            throw ParserError.unsupportedType
+        case "min16int2":
+            return "short2"
+        case "min16int3":
+            return "short3"
+        case "min16int4":
+            return "short4"
+        case "min16int":
+            return "short"
+        case "min16uint2":
+            return "ushort2"
+        case "min16uint3":
+            return "ushort3"
+        case "min16uint4":
+            return "ushort4"
+        case "min16uint":
+            return "ushort"
+        case "min13int":
+            throw ParserError.unsupportedType
+        default:
+            return gsType
+        }
+    }
+
+    /// Converts an HLSL-like uniform mapping into a MSL attribute decoration if possible
+    /// - Parameter gsMapping: HLSL-like mapping
+    /// - Returns: MSL attribute string
+    private func convertToMTLMapping(gsMapping: String) -> String? {
+        switch gsMapping {
+        case "POSITION":
+            return "position"
+        case "VERTEXID":
+            return "vertex_id"
+        default:
+            return nil
+        }
+    }
+
+    /// Converts a HLSL-like comparison to a vector-safe MSL comparison operation
+    /// - Parameter token: Start token of the comparison in the function body
+    /// - Returns: MSL comparison operation
+    ///
+    /// A comparison operation that involves a vector will always result in a boolean vector in MSL (and not a scalar
+    /// vector). Thus any functions that compares two vectors will also result in a vector
+    /// (e.g., float2 == float2 -> bool2). This will break when a ternary expression is used, as the first element of
+    /// it needs to be as scalar boolean in MSL.
+    ///
+    /// Wrapping the comparison in `all` ensures that a single scalar `true` is returned if all elements of the
+    /// resulting boolean vectors are `true` as well.
+    private func convertToMTLComparison(token: inout UnsafeMutablePointer<cf_token>) throws -> String? {
+        var isComparator = false
+
+        let nextToken = token.successor()
+
+        if nextToken.pointee.type == CFTOKEN_OTHER {
+            let comparators = ["==", "!=", "<", "<=", ">=", ">"]
+
+            for comparator in comparators {
+                if nextToken.pointee.str.isEqualTo(comparator) {
+                    isComparator = true
+                    break
+                }
+            }
+        }
+
+        if isComparator {
+            var cfp = parser.cfp
+            cfp.cur_token = token
+
+            let lhs = cfp.cur_token.pointee.str.getString()
+
+            guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+
+            let comparator = cfp.cur_token.pointee.str.getString()
+
+            guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+
+            let rhs = cfp.cur_token.pointee.str.getString()
+
+            return "all(\(lhs) \(comparator) \(rhs))"
+        } else {
+            return nil
+        }
+    }
+
+    /// Converts HLSL-like intrinsic into its MSL representation
+    /// - Parameter intrinsic: HLSL-like intrinsic string
+    /// - Returns: MSL intrinsic string
+    private func convertToMTLIntrinsic(intrinsic: String) throws -> String? {
+        switch intrinsic {
+        case "clip":
+            throw ParserError.unsupportedType
+        case "ddx":
+            return "dfdx"
+        case "ddy":
+            return "dfdy"
+        case "frac":
+            return "fract"
+        case "lerp":
+            return "mix"
+        default:
+            return nil
+        }
+    }
+
+    /// Converts a HLSL-like multiplication function call into a direct multiplication
+    /// - Parameter token: Start token of the multiplication in the function body
+    /// - Returns: MSL multiplication string
+    private func convertToMTLMultiplication(token: inout UnsafeMutablePointer<cf_token>) throws -> String {
+        var cfp = parser.cfp
+        cfp.cur_token = token
+
+        guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+        guard cfp.tokenIsEqualTo("(") else { throw ParserError.unexpectedToken }
+        guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+        let lhs = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+
+        guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+
+        cfp.cur_token = cfp.cur_token.predecessor()
+
+        let rhs = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+        token = cfp.cur_token
+
+        return "(\(lhs)) * (\(rhs))"
+    }
+
+    /// Converts a HLSL-like multiply+add function call into a direct multiplication followed by addition
+    /// - Parameter token: Start token of the multiply+add in the function body
+    /// - Returns: MSL multiplication and addition string
+    private func convertToMTLMultiplyAdd(token: inout UnsafeMutablePointer<cf_token>) throws -> String {
+        var cfp = parser.cfp
+        cfp.cur_token = token
+
+        guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+        guard cfp.tokenIsEqualTo("(") else { throw ParserError.unexpectedToken }
+        guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+        let first = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+
+        guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+        let second = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+
+        guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+        let third = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+        token = cfp.cur_token
+
+        return "((\(first)) * (\(second))) + (\(third))"
+    }
+
+    /// Creates an MSL sampler call from a HLSL-like sampler call
+    /// - Parameter token: Start token of the sampler call in the function
+    /// - Returns: String of an MSL sampler call
+    private func createSampler(token: inout UnsafeMutablePointer<cf_token>) throws -> String {
+        var cfp = parser.cfp
+        cfp.cur_token = token
+
+        let stringToken = token.pointee.str.getString()
+
+        guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+        guard cfp.tokenIsEqualTo(".") else { throw ParserError.unexpectedToken }
+        guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+        guard cfp.cur_token.pointee.type == CFTOKEN_NAME else { throw ParserError.unexpectedToken }
+
+        let textureCall: String
+
+        if cfp.tokenIsEqualTo("Sample") {
+            textureCall = try createTextureCall(token: &cfp.cur_token, callType: .sample)
+        } else if cfp.tokenIsEqualTo("SampleBias") {
+            textureCall = try createTextureCall(token: &cfp.cur_token, callType: .sampleBias)
+        } else if cfp.tokenIsEqualTo("SampleGrad") {
+            textureCall = try createTextureCall(token: &cfp.cur_token, callType: .sampleGrad)
+        } else if cfp.tokenIsEqualTo("SampleLevel") {
+            textureCall = try createTextureCall(token: &cfp.cur_token, callType: .sampleLevel)
+        } else if cfp.tokenIsEqualTo("Load") {
+            textureCall = try createTextureCall(token: &cfp.cur_token, callType: .load)
+        } else {
+            throw ParserError.missingNextToken
+        }
+
+        token = cfp.cur_token
+        return "\(stringToken).\(textureCall)"
+    }
+
+    /// Creates a MSL sampler call based on the sampling type
+    /// - Parameters:
+    ///   - token: Start token of the sampler call arguments in the function body
+    ///   - callType: Type of sampling used
+    /// - Returns: String of an MSL sampler call
+    private func createTextureCall(token: inout UnsafeMutablePointer<cf_token>, callType: SampleVariant) throws
+        -> String
+    {
+        var cfp = parser.cfp
+        cfp.cur_token = token
+
+        guard cfp.advanceToken() else { throw ParserError.missingNextToken }
+        guard cfp.tokenIsEqualTo("(") else { throw ParserError.unexpectedToken }
+        guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+        switch callType {
+        case .sample:
+            let first = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let second = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+            token = cfp.cur_token
+            return "sample(\(first), \(second))"
+        case .sampleBias:
+            let first = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let second = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let third = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+            token = cfp.cur_token
+            return "sample(\(first), \(second), bias(\(third)))"
+        case .sampleGrad:
+            let first = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let second = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let third = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let fourth = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+            token = cfp.cur_token
+            return "sample(\(first), \(second), gradient2d(\(third), \(fourth)))"
+        case .sampleLevel:
+            let first = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let second = try transpileFunctionContent(token: &cfp.cur_token, end: ",")
+            guard cfp.hasNextToken() else { throw ParserError.missingNextToken }
+
+            let third = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+            token = cfp.cur_token
+            return "sample(\(first), \(second), level(\(third)))"
+        case .load:
+            let first = try transpileFunctionContent(token: &cfp.cur_token, end: ")")
+
+            let loadCall: String
+
+            /// Many load calls in OBS effects files rely on implicit type conversion, which is not allowed in MSL in
+            /// addition to `read` calls only accepting a `uint2` followed by a `uint`. Any instance of a `int3` thus
+            /// needs to be converted into the appropriate variant compatible with the `read` call.
+            if first.hasPrefix("int3(") {
+                let loadParameters = first[
+                    first.index(first.startIndex, offsetBy: 5)..<first.index(first.endIndex, offsetBy: -1)
+                ].split(separator: ",", maxSplits: 3, omittingEmptySubsequences: true)
+
+                switch loadParameters.count {
+                case 3:
+                    loadCall = "read(uint2(\(loadParameters[0]), \(loadParameters[1])), uint(\(loadParameters[2])))"
+                case 2:
+                    loadCall = "read(uint2(\(loadParameters[0])), uint(\(loadParameters[1])))"
+                case 1:
+                    loadCall = "read(uint2(\(loadParameters[0]).xy), 0)"
+                default:
+                    throw ParserError.parseFail("int3 constructor with invalid number of arguments encountered")
+                }
+            } else {
+                loadCall = "read(uint2(\(first).xy), 0)"
+            }
+
+            token = cfp.cur_token
+            return loadCall
+        }
+    }
+
+    /// Generates the explicit arguments that need to be passed into MSL shader functions in place of direct access to
+    /// uniform globals which are not supported by Metal.
+    /// - Parameter functionName: Name of the function to generate the additional arguments for
+    /// - Returns: String of additional arguments to be put into the function signature
+    private func generateAdditionalArguments(for functionName: String) -> String? {
+        var output = [String]()
+
+        for function in functions.values {
+            if function.name != functionName {
+                continue
+            }
+
+            if function.requiresUniformBuffers {
+                output.append("uniforms")
+            }
+
+            for texture in function.textures {
+                for uniform in uniforms.values {
+                    if uniform.name == texture && uniform.storageType.contains(.typeTexture) {
+                        output.append(texture)
+                    }
+                }
+            }
+
+            for sampler in function.samplers {
+                for i in 0..<parser.samplers.num {
+                    let samplerPointer: UnsafeMutablePointer<shader_sampler>? = parser.samplers.array.advanced(by: i)
+
+                    if let samplerPointer {
+                        if sampler == String(cString: samplerPointer.pointee.name) {
+                            output.append(sampler)
+                        }
+                    }
+                }
+            }
+        }
+
+        if output.count > 0 {
+            return output.joined(separator: ", ")
+        }
+
+        return nil
+    }
+
+    deinit {
+        withUnsafeMutablePointer(to: &parser) {
+            shader_parser_free($0)
+        }
+    }
+}

+ 125 - 0
libobs-metal/OBSSwapChain.swift

@@ -0,0 +1,125 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import AppKit
+import CoreVideo
+import Foundation
+import Metal
+
+class OBSSwapChain {
+    enum ColorRange {
+        case sdr
+        case hdrPQ
+        case hdrHLG
+    }
+
+    private weak var device: MetalDevice?
+    private var view: NSView?
+
+    var colorRange: ColorRange
+    var edrHeadroom: CGFloat = 0.0
+    let layer: CAMetalLayer
+    var renderTarget: MetalTexture?
+    var viewSize: MTLSize
+    var fence: MTLFence
+    var discard: Bool = false
+
+    init?(device: MetalDevice, size: MTLSize, colorSpace: gs_color_format) {
+        self.device = device
+        self.viewSize = size
+        self.layer = CAMetalLayer()
+        self.layer.framebufferOnly = false
+        self.layer.device = device.device
+        self.layer.drawableSize = CGSize(width: viewSize.width, height: viewSize.height)
+        self.layer.pixelFormat = .bgra8Unorm_srgb
+        self.layer.colorspace = CGColorSpace(name: CGColorSpace.sRGB)
+        self.layer.wantsExtendedDynamicRangeContent = false
+        self.layer.edrMetadata = nil
+        self.layer.displaySyncEnabled = false
+        self.colorRange = .sdr
+
+        guard let fence = device.device.makeFence() else { return nil }
+
+        self.fence = fence
+    }
+
+    /// Updates the provided view to use the `CAMetalLayer` managed by the ``OBSSwapChain``
+    /// - Parameter view: `NSView` instance to update
+    ///
+    /// > Important: This function has to be called from the main thread
+    @MainActor
+    func updateView(_ view: NSView) {
+        self.view = view
+        view.layer = self.layer
+        view.wantsLayer = true
+
+        updateEdrHeadroom()
+    }
+
+    /// Updates the EDR headroom value on the ``OBSSwapChain`` with the value from the screen the managed `NSView` is
+    /// associated with.
+    ///
+    /// This is necessary to ensure that the projector uses the appropriate SDR or EDR output depending on the screen
+    /// the view is on.
+    @MainActor
+    func updateEdrHeadroom() {
+        guard let view = self.view else {
+            return
+        }
+
+        if let screen = view.window?.screen {
+            self.edrHeadroom = screen.maximumPotentialExtendedDynamicRangeColorComponentValue
+        } else {
+            self.edrHeadroom = CGFloat(1.0)
+        }
+    }
+
+    /// Resizes the drawable of the managed `CAMetalLayer` to the provided size
+    /// - Parameter size: Desired new size of the drawable
+    ///
+    /// This is usually achieved via a delegate method directly on the associated `NSView` instance, but because the
+    /// view is managed by Qt, the resize event is routed manually into the ``OBSSwapChain`` instance by `libobs`.
+    func resize(_ size: MTLSize) {
+        guard viewSize.width != size.width || viewSize.height != size.height else { return }
+
+        viewSize = size
+        layer.drawableSize = CGSize(
+            width: viewSize.width,
+            height: viewSize.height)
+        renderTarget = nil
+    }
+
+    /// Gets an opaque pointer for the ``OBSSwapChain`` instance and increases its reference count by one
+    /// - Returns: `OpaquePointer` to class instance
+    ///
+    /// > Note: Use this method when the instance is to be shared via an `OpaquePointer` and needs to be retained. Any
+    /// opaque pointer shared this way needs to be converted into a retained reference again to ensure automatic
+    /// deinitialization by the Swift runtime.
+    func getRetained() -> OpaquePointer {
+        let retained = Unmanaged.passRetained(self).toOpaque()
+
+        return OpaquePointer(retained)
+    }
+
+    /// Gets an opaque pointer for the ``OBSSwapChain`` instance without increasing its reference count
+    /// - Returns: `OpaquePointer` to class instance
+    func getUnretained() -> OpaquePointer {
+        let unretained = Unmanaged.passUnretained(self).toOpaque()
+
+        return OpaquePointer(unretained)
+    }
+}

+ 25 - 0
libobs-metal/Sequence+Hashable.swift

@@ -0,0 +1,25 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+extension Sequence where Iterator.Element: Hashable {
+    /// Filters a `Sequence` to only contain its unique elements, retaining order for unique elements.
+    /// - Returns: Filtered `Sequence` with unique elements of original `Sequence`
+    func unique() -> [Iterator.Element] {
+        var seen: Set<Iterator.Element> = []
+        return filter { seen.insert($0).inserted }
+    }
+}

+ 486 - 0
libobs-metal/libobs+Extensions.swift

@@ -0,0 +1,486 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+import simd
+
+public enum OBSLogLevel: Int32 {
+    case error = 100
+    case warning = 200
+    case info = 300
+    case debug = 400
+}
+
+extension strref {
+    mutating func getString() -> String {
+        let buffer = UnsafeRawBufferPointer(start: self.array, count: self.len)
+
+        let string = String(decoding: buffer, as: UTF8.self)
+
+        return string
+    }
+
+    mutating func isEqualTo(_ comparison: String) -> Bool {
+        return strref_cmp(&self, comparison.cString(using: .utf8)) == 0
+    }
+
+    mutating func isEqualToCString(_ comparison: UnsafeMutablePointer<CChar>?) -> Bool {
+        if let comparison {
+            let result = withUnsafeMutablePointer(to: &self) {
+                strref_cmp($0, comparison) == 0
+            }
+
+            return result
+        }
+
+        return false
+    }
+}
+
+extension cf_parser {
+    mutating func advanceToken() -> Bool {
+        let result = withUnsafeMutablePointer(to: &self) {
+            cf_next_token($0)
+        }
+
+        return result
+    }
+
+    mutating func hasNextToken() -> Bool {
+        let result = withUnsafeMutablePointer(to: &self) {
+            var nextToken: UnsafeMutablePointer<cf_token>?
+
+            switch $0.pointee.cur_token.pointee.type {
+            case CFTOKEN_SPACETAB, CFTOKEN_NEWLINE, CFTOKEN_NONE:
+                nextToken = $0.pointee.cur_token
+            default:
+                nextToken = $0.pointee.cur_token.advanced(by: 1)
+            }
+
+            if var nextToken {
+                while nextToken.pointee.type == CFTOKEN_SPACETAB || nextToken.pointee.type == CFTOKEN_NEWLINE {
+                    nextToken = nextToken.successor()
+                }
+
+                return nextToken.pointee.type != CFTOKEN_NONE
+            } else {
+                return false
+            }
+        }
+
+        return result
+    }
+
+    mutating func tokenIsEqualTo(_ comparison: String) -> Bool {
+        let result = withUnsafeMutablePointer(to: &self) {
+            cf_token_is($0, comparison.cString(using: .utf8))
+        }
+
+        return result
+    }
+}
+
+extension gs_shader_param_type {
+    var size: Int {
+        switch self {
+        case GS_SHADER_PARAM_BOOL, GS_SHADER_PARAM_INT, GS_SHADER_PARAM_FLOAT:
+            return MemoryLayout<Float32>.size
+        case GS_SHADER_PARAM_INT2, GS_SHADER_PARAM_VEC2:
+            return MemoryLayout<Float32>.size * 2
+        case GS_SHADER_PARAM_INT3, GS_SHADER_PARAM_VEC3:
+            return MemoryLayout<Float32>.size * 3
+        case GS_SHADER_PARAM_INT4, GS_SHADER_PARAM_VEC4:
+            return MemoryLayout<Float32>.size * 4
+        case GS_SHADER_PARAM_MATRIX4X4:
+            return MemoryLayout<Float32>.size * 4 * 4
+        case GS_SHADER_PARAM_TEXTURE:
+            return MemoryLayout<gs_shader_texture>.size
+        case GS_SHADER_PARAM_STRING, GS_SHADER_PARAM_UNKNOWN:
+            return 0
+        default:
+            return 0
+        }
+    }
+
+    var mtlSize: Int {
+        switch self {
+        case GS_SHADER_PARAM_BOOL, GS_SHADER_PARAM_INT, GS_SHADER_PARAM_FLOAT:
+            return MemoryLayout<simd_float1>.size
+        case GS_SHADER_PARAM_INT2, GS_SHADER_PARAM_VEC2:
+            return MemoryLayout<simd_float2>.size
+        case GS_SHADER_PARAM_INT3, GS_SHADER_PARAM_VEC3:
+            return MemoryLayout<simd_float3>.size
+        case GS_SHADER_PARAM_INT4, GS_SHADER_PARAM_VEC4:
+            return MemoryLayout<simd_float4>.size
+        case GS_SHADER_PARAM_MATRIX4X4:
+            return MemoryLayout<simd_float4x4>.size
+        case GS_SHADER_PARAM_TEXTURE:
+            return MemoryLayout<gs_shader_texture>.size
+        case GS_SHADER_PARAM_STRING, GS_SHADER_PARAM_UNKNOWN:
+            return 0
+        default:
+            return 0
+        }
+    }
+
+    var mtlAlignment: Int {
+        switch self {
+        case GS_SHADER_PARAM_BOOL, GS_SHADER_PARAM_INT, GS_SHADER_PARAM_FLOAT:
+            return MemoryLayout<simd_float1>.alignment
+        case GS_SHADER_PARAM_INT2, GS_SHADER_PARAM_VEC2:
+            return MemoryLayout<simd_float2>.alignment
+        case GS_SHADER_PARAM_INT3, GS_SHADER_PARAM_VEC3:
+            return MemoryLayout<simd_float3>.alignment
+        case GS_SHADER_PARAM_INT4, GS_SHADER_PARAM_VEC4:
+            return MemoryLayout<simd_float4>.alignment
+        case GS_SHADER_PARAM_MATRIX4X4:
+            return MemoryLayout<simd_float4x4>.alignment
+        case GS_SHADER_PARAM_TEXTURE:
+            return 0
+        case GS_SHADER_PARAM_STRING, GS_SHADER_PARAM_UNKNOWN:
+            return 0
+        default:
+            return 0
+
+        }
+    }
+}
+
+extension gs_color_format {
+    var sRGBVariant: MTLPixelFormat? {
+        switch self {
+        case GS_RGBA:
+            return .rgba8Unorm_srgb
+        case GS_BGRX, GS_BGRA:
+            return .bgra8Unorm_srgb
+        default:
+            return nil
+        }
+    }
+
+    var mtlFormat: MTLPixelFormat {
+        switch self {
+        case GS_A8:
+            return .a8Unorm
+        case GS_R8:
+            return .r8Unorm
+        case GS_R8G8:
+            return .rg8Unorm
+        case GS_R16:
+            return .r16Unorm
+        case GS_R16F:
+            return .r16Float
+        case GS_RG16:
+            return .rg16Unorm
+        case GS_RG16F:
+            return .rg16Float
+        case GS_R32F:
+            return .r32Float
+        case GS_RG32F:
+            return .rg32Float
+        case GS_RGBA:
+            return .rgba8Unorm
+        case GS_BGRX, GS_BGRA:
+            return .bgra8Unorm
+        case GS_R10G10B10A2:
+            return .rgb10a2Unorm
+        case GS_RGBA16:
+            return .rgba16Unorm
+        case GS_RGBA16F:
+            return .rgba16Float
+        case GS_RGBA32F:
+            return .rgba32Float
+        case GS_DXT1:
+            return .bc1_rgba
+        case GS_DXT3:
+            return .bc2_rgba
+        case GS_DXT5:
+            return .bc3_rgba
+        default:
+            return .invalid
+        }
+    }
+}
+
+extension gs_color_space {
+    var colorFormat: gs_color_format {
+        switch self {
+        case GS_CS_SRGB_16F, GS_CS_709_SCRGB:
+            return GS_RGBA16F
+        default:
+            return GS_RGBA
+        }
+    }
+
+    var pixelFormat: MTLPixelFormat? {
+        switch self {
+        case GS_CS_SRGB:
+            .bgra8Unorm_srgb
+        case GS_CS_709_SCRGB:
+            nil
+        case GS_CS_709_EXTENDED:
+            .bgra10_xr_srgb
+        case GS_CS_SRGB_16F:
+            nil
+        default:
+            nil
+        }
+    }
+}
+
+extension gs_depth_test {
+    var mtlFunction: MTLCompareFunction {
+        switch self {
+        case GS_NEVER:
+            return .never
+        case GS_LESS:
+            return .less
+        case GS_LEQUAL:
+            return .lessEqual
+        case GS_EQUAL:
+            return .equal
+        case GS_GEQUAL:
+            return .greaterEqual
+        case GS_GREATER:
+            return .greater
+        case GS_NOTEQUAL:
+            return .notEqual
+        case GS_ALWAYS:
+            return .always
+        default:
+            return .never
+        }
+    }
+}
+
+extension gs_stencil_op_type {
+    var mtlOperation: MTLStencilOperation {
+        switch self {
+        case GS_KEEP:
+            return .keep
+        case GS_ZERO:
+            return .zero
+        case GS_REPLACE:
+            return .replace
+        case GS_INCR:
+            return .incrementWrap
+        case GS_DECR:
+            return .decrementWrap
+        case GS_INVERT:
+            return .invert
+        default:
+            return .keep
+        }
+    }
+}
+
+extension gs_blend_type {
+    var blendFactor: MTLBlendFactor? {
+        switch self {
+        case GS_BLEND_ZERO:
+            return .zero
+        case GS_BLEND_ONE:
+            return .one
+        case GS_BLEND_SRCCOLOR:
+            return .sourceColor
+        case GS_BLEND_INVSRCCOLOR:
+            return .oneMinusSourceColor
+        case GS_BLEND_SRCALPHA:
+            return .sourceAlpha
+        case GS_BLEND_INVSRCALPHA:
+            return .oneMinusSourceAlpha
+        case GS_BLEND_DSTCOLOR:
+            return .destinationColor
+        case GS_BLEND_INVDSTCOLOR:
+            return .oneMinusDestinationColor
+        case GS_BLEND_DSTALPHA:
+            return .destinationAlpha
+        case GS_BLEND_INVDSTALPHA:
+            return .oneMinusDestinationAlpha
+        case GS_BLEND_SRCALPHASAT:
+            return .sourceAlphaSaturated
+        default:
+            return nil
+        }
+    }
+}
+
+extension gs_blend_op_type {
+    var mtlOperation: MTLBlendOperation? {
+        switch self {
+        case GS_BLEND_OP_ADD:
+            return .add
+        case GS_BLEND_OP_MAX:
+            return .max
+        case GS_BLEND_OP_MIN:
+            return .min
+        case GS_BLEND_OP_SUBTRACT:
+            return .subtract
+        case GS_BLEND_OP_REVERSE_SUBTRACT:
+            return .reverseSubtract
+        default:
+            return nil
+        }
+    }
+}
+
+extension gs_cull_mode {
+    var mtlMode: MTLCullMode {
+        switch self {
+        case GS_BACK:
+            return .back
+        case GS_FRONT:
+            return .front
+        default:
+            return .none
+        }
+    }
+}
+
+extension gs_draw_mode {
+    var mtlPrimitive: MTLPrimitiveType? {
+        switch self {
+        case GS_POINTS:
+            return .point
+        case GS_LINES:
+            return .line
+        case GS_LINESTRIP:
+            return .lineStrip
+        case GS_TRIS:
+            return .triangle
+        case GS_TRISTRIP:
+            return .triangleStrip
+        default:
+            return nil
+        }
+    }
+}
+
+extension gs_rect {
+    var mtlViewPort: MTLViewport {
+        MTLViewport(
+            originX: Double(self.x),
+            originY: Double(self.y),
+            width: Double(self.cx),
+            height: Double(self.cy),
+            znear: 0.0,
+            zfar: 1.0)
+    }
+
+    var mtlScissorRect: MTLScissorRect {
+        MTLScissorRect(
+            x: Int(self.x),
+            y: Int(self.y),
+            width: Int(self.cx),
+            height: Int(self.cy))
+    }
+}
+
+extension gs_zstencil_format {
+    var mtlFormat: MTLPixelFormat {
+        switch self {
+        case GS_ZS_NONE:
+            return .invalid
+        case GS_Z16:
+            return .depth16Unorm
+        case GS_Z24_S8:
+            return .depth24Unorm_stencil8
+        case GS_Z32F:
+            return .depth32Float
+        case GS_Z32F_S8X24:
+            return .depth32Float_stencil8
+        default:
+            return .invalid
+        }
+    }
+}
+
+extension gs_index_type {
+    var mtlType: MTLIndexType? {
+        switch self {
+        case GS_UNSIGNED_LONG:
+            return .uint16
+        case GS_UNSIGNED_SHORT:
+            return .uint32
+        default:
+            return nil
+        }
+    }
+
+    var byteSize: Int {
+        guard let indexType = self.mtlType else {
+            return 0
+        }
+
+        let byteSize =
+            if indexType == .uint16 {
+                2
+            } else {
+                4
+            }
+
+        return byteSize
+    }
+}
+
+extension gs_address_mode {
+    var mtlMode: MTLSamplerAddressMode? {
+        switch self {
+        case GS_ADDRESS_WRAP:
+            return .repeat
+        case GS_ADDRESS_CLAMP:
+            return .clampToEdge
+        case GS_ADDRESS_MIRROR:
+            return .mirrorRepeat
+        case GS_ADDRESS_BORDER:
+            return .clampToBorderColor
+        case GS_ADDRESS_MIRRORONCE:
+            return .mirrorClampToEdge
+        default:
+            return nil
+        }
+    }
+}
+
+extension gs_sample_filter {
+    var minMagFilter: MTLSamplerMinMagFilter? {
+        switch self {
+        case GS_FILTER_POINT, GS_FILTER_MIN_MAG_POINT_MIP_LINEAR, GS_FILTER_MIN_POINT_MAG_LINEAR_MIP_POINT,
+            GS_FILTER_MIN_POINT_MAG_MIP_LINEAR:
+            return .nearest
+        case GS_FILTER_LINEAR, GS_FILTER_MIN_LINEAR_MAG_MIP_POINT, GS_FILTER_MIN_LINEAR_MAG_POINT_MIP_LINEAR,
+            GS_FILTER_MIN_MAG_LINEAR_MIP_POINT, GS_FILTER_ANISOTROPIC:
+            return .linear
+        default:
+            return nil
+        }
+    }
+
+    var mipFilter: MTLSamplerMipFilter? {
+        switch self {
+        case GS_FILTER_POINT, GS_FILTER_MIN_MAG_POINT_MIP_LINEAR, GS_FILTER_MIN_POINT_MAG_LINEAR_MIP_POINT,
+            GS_FILTER_MIN_POINT_MAG_MIP_LINEAR:
+            return .nearest
+        case GS_FILTER_LINEAR, GS_FILTER_MIN_LINEAR_MAG_MIP_POINT, GS_FILTER_MIN_LINEAR_MAG_POINT_MIP_LINEAR,
+            GS_FILTER_MIN_MAG_LINEAR_MIP_POINT, GS_FILTER_ANISOTROPIC:
+            return .linear
+        default:
+            return nil
+        }
+    }
+}

+ 34 - 0
libobs-metal/libobs+SignalHandlers.swift

@@ -0,0 +1,34 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+
+enum MetalSignalType: String {
+    case videoReset = "video_reset"
+}
+
+/// Dispatches the video reset event to the ``MetalDevice`` instance
+/// - Parameters:
+///   - param: Opaque pointer to a ``MetalDevice`` instance
+///   - _: Unused pointer to signal callback data
+public func metal_video_reset_handler(_ param: UnsafeMutableRawPointer?, _: UnsafeMutablePointer<calldata>?) {
+    guard let param else { return }
+
+    let metalDevice = unsafeBitCast(param, to: MetalDevice.self)
+
+    metalDevice.dispatchSignal(type: .videoReset)
+}

+ 32 - 0
libobs-metal/libobs-metal-Bridging-Header.h

@@ -0,0 +1,32 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+#import <util/base.h>
+#import <util/cf-parser.h>
+#import <util/cf-lexer.h>
+
+#import <obs.h>
+
+#import <graphics/graphics.h>
+#import <graphics/device-exports.h>
+#import <graphics/vec2.h>
+#import <graphics/matrix3.h>
+#import <graphics/matrix4.h>
+#import <graphics/shader-parser.h>
+
+static const char *const device_name = "Metal";
+static const char *const preprocessor_name = "_Metal";

+ 158 - 0
libobs-metal/metal-indexbuffer.swift

@@ -0,0 +1,158 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Creates a ``MetalIndexBuffer`` object to share with `libobs` and hold the provided indices
+///
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - type: Size of each index value (16 bit or 32 bit)
+///   - indices: Opaque pointer to index buffer data set up by `libobs`
+///   - num: Count of vertices present at the memory address provided by the `indices` argument
+///   - flags: Bit field of `libobs` buffer flags
+/// - Returns: Opaque pointer to a retained ``MetalIndexBuffer`` instance if valid index type was provided, `nil`
+/// otherwise
+///
+/// > Note: The ownership of the memory pointed to by `indices` is implicitly transferred to the ``MetalIndexBuffer``
+/// instance, but is not managed by Swift.
+@_cdecl("device_indexbuffer_create")
+public func device_indexbuffer_create(
+    device: UnsafeRawPointer, type: gs_index_type, indices: UnsafeMutableRawPointer, num: UInt32, flags: UInt32
+) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    guard let indexType = type.mtlType else {
+        return nil
+    }
+
+    let indexBuffer = MetalIndexBuffer(
+        device: device,
+        type: indexType,
+        data: indices,
+        count: Int(num),
+        dynamic: (Int32(flags) & GS_DYNAMIC) != 0
+    )
+
+    return indexBuffer.getRetained()
+}
+
+/// Sets up a ``MetalIndexBuffer`` as the index buffer for the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - indexbuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+///
+/// > Note: The reference count of the ``MetalIndexBuffer`` instance will not be increased by this call.
+///
+/// > Important: If a `nil` pointer is provided as the index buffer, the index buffer will be _unset_.
+@_cdecl("device_load_indexbuffer")
+public func device_load_indexbuffer(device: UnsafeRawPointer, indexbuffer: UnsafeRawPointer?) {
+    let device: MetalDevice = unretained(device)
+
+    if let indexbuffer {
+        device.renderState.indexBuffer = unretained(indexbuffer)
+    } else {
+        device.renderState.indexBuffer = nil
+    }
+}
+
+/// Requests the deinitialization of a shared ``MetalIndexBuffer`` instance
+/// - Parameter indexBuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+///
+/// The deinitialization is handled automatically by Swift after the ownership of the instance has been transferred
+/// into the function and becomes the last strong reference to it. After the function leaves its scope, the object will
+/// be deinitialized and deallocated automatically.
+///
+/// > Note: The index buffer data memory is implicitly owned by the ``MetalIndexBuffer`` instance and will be manually
+/// cleaned up and deallocated by the instance's `deinit` method.
+@_cdecl("gs_indexbuffer_destroy")
+public func gs_indexbuffer_destroy(indexBuffer: UnsafeRawPointer) {
+    let _ = retained(indexBuffer) as MetalIndexBuffer
+}
+
+/// Requests the index buffer's current data to be transferred into GPU memory
+/// - Parameter indexBuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+///
+/// This function will call `gs_indexbuffer_flush_direct` with `nil` data pointer.
+@_cdecl("gs_indexbuffer_flush")
+public func gs_indexbuffer_flush(indexBuffer: UnsafeRawPointer) {
+    gs_indexbuffer_flush_direct(indexBuffer: indexBuffer, data: nil)
+}
+
+/// Requests the index buffer to be updated with the provided data and then transferred into GPU memory
+/// - Parameters:
+///   - indexBuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+///   - data: Opaque pointer to index buffer data set up by `libobs`
+///
+/// This function is called to ensure that the index buffer data that is contained in the memory pointed at by the
+/// `data` argument is uploaded into GPU memory. If a `nil` pointer is provided instead, the data provided to the
+/// instance during creation will be used instead.
+@_cdecl("gs_indexbuffer_flush_direct")
+public func gs_indexbuffer_flush_direct(indexBuffer: UnsafeRawPointer, data: UnsafeMutableRawPointer?) {
+    let indexBuffer: MetalIndexBuffer = unretained(indexBuffer)
+
+    indexBuffer.setupBuffers(data)
+}
+
+/// Returns an opaque pointer to the index buffer data associated with the ``MetalIndexBuffer`` instance
+/// - Parameter indexBuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+/// - Returns: Opaque pointer to index buffer data in memory
+///
+/// The returned opaque pointer represents the unchanged memory address that was provided for the creation of the index
+/// buffer object.
+///
+/// > Warning: There is only limited memory safety associated with this pointer. It is implicitly owned and its
+/// lifetime is managed by the ``MetalIndexBuffer`` instance, but it was originally created by `libobs`.
+@_cdecl("gs_indexbuffer_get_data")
+public func gs_indexbuffer_get_data(indexBuffer: UnsafeRawPointer) -> UnsafeMutableRawPointer? {
+    let indexBuffer: MetalIndexBuffer = unretained(indexBuffer)
+
+    return indexBuffer.indexData
+}
+
+/// Returns the number of indices associated with the ``MetalIndexBuffer`` instance
+/// - Parameter indexBuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+/// - Returns: Number of index buffers
+///
+/// > Note: This returns the same number that was provided for the creation of the index buffer object.
+@_cdecl("gs_indexbuffer_get_num_indices")
+public func gs_indexbuffer_get_num_indices(indexBuffer: UnsafeRawPointer) -> UInt32 {
+    let indexBuffer: MetalIndexBuffer = unretained(indexBuffer)
+
+    return UInt32(indexBuffer.count)
+}
+
+/// Gets the index buffer type as a `libobs` enum value
+/// - Parameter indexBuffer: Opaque pointer to ``MetalIndexBuffer`` instance shared with `libobs`
+/// - Returns: Index buffer type as identified by the `gs_index_type` enum
+///
+/// > Warning: As the `gs_index_type` enumeration does not provide an "invalid" value (and thus `0` becomes a valied
+/// value), this function has no way to communicate an incompatible index buffer type that might be introduced at a
+/// later point.
+@_cdecl("gs_indexbuffer_get_type")
+public func gs_indexbuffer_get_type(indexBuffer: UnsafeRawPointer) -> gs_index_type {
+    let indexBuffer: MetalIndexBuffer = unretained(indexBuffer)
+
+    switch indexBuffer.type {
+    case .uint16: return GS_UNSIGNED_SHORT
+    case .uint32: return GS_UNSIGNED_LONG
+    @unknown default:
+        assertionFailure("gs_indexbuffer_get_type: Unsupported index buffer type \(indexBuffer.type)")
+        return GS_UNSIGNED_SHORT
+    }
+}

+ 100 - 0
libobs-metal/metal-samplerstate.swift

@@ -0,0 +1,100 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Creates a new ``MTLSamplerDescriptor`` to share as an opaque pointer with `libobs`
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - info: Sampler information encoded as a `gs_sampler_info` struct
+/// - Returns: Opaque pointer to a new ``MTLSamplerDescriptor`` instance on success, `nil` otherwise
+@_cdecl("device_samplerstate_create")
+public func device_samplerstate_create(device: UnsafeRawPointer, info: gs_sampler_info) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    guard let sAddressMode = info.address_u.mtlMode,
+        let tAddressMode = info.address_v.mtlMode,
+        let rAddressMode = info.address_w.mtlMode
+    else {
+        assertionFailure("device_samplerstate_create: Invalid address modes provided")
+        return nil
+    }
+
+    guard let minFilter = info.filter.minMagFilter, let magFilter = info.filter.minMagFilter,
+        let mipFilter = info.filter.mipFilter
+    else {
+        assertionFailure("device_samplerstate_create: Invalid filter modes provided")
+        return nil
+    }
+
+    let descriptor = MTLSamplerDescriptor()
+    descriptor.sAddressMode = sAddressMode
+    descriptor.tAddressMode = tAddressMode
+    descriptor.rAddressMode = rAddressMode
+
+    descriptor.minFilter = minFilter
+    descriptor.magFilter = magFilter
+    descriptor.mipFilter = mipFilter
+
+    descriptor.maxAnisotropy = max(16, min(1, Int(info.max_anisotropy)))
+
+    descriptor.compareFunction = .always
+    descriptor.borderColor =
+        if (info.border_color & 0x00_00_00_FF) == 0 {
+            .transparentBlack
+        } else if info.border_color == 0xFF_FF_FF_FF {
+            .opaqueWhite
+        } else {
+            .opaqueBlack
+        }
+
+    guard let samplerState = device.device.makeSamplerState(descriptor: descriptor) else {
+        assertionFailure("device_samplerstate_create: Unable to create sampler state")
+        return nil
+    }
+
+    let retained = Unmanaged.passRetained(samplerState).toOpaque()
+
+    return OpaquePointer(retained)
+}
+
+/// Requests the deinitialization of the ``MTLSamplerState`` instance shared with `libobs`
+/// - Parameter samplerstate: Opaque pointer to ``MTLSamplerState`` instance shared with `libobs`
+///
+/// Ownership of the ``MTLSamplerState`` instance will be transferred into the function and if this was the last
+/// strong reference to it, the object will be automatically deinitialized and deallocated by Swift.
+@_cdecl("gs_samplerstate_destroy")
+public func gs_samplerstate_destroy(samplerstate: UnsafeRawPointer) {
+    let _ = retained(samplerstate) as MTLSamplerState
+}
+
+/// Loads the provided ``MTLSamplerState`` into the current pipeline's sampler array at the requested texture unit
+/// number
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - samplerstate: Opaque pointer to ``MTLSamplerState`` instance shared with `libobs`
+///   - unit: Number identifying the "texture slot" used by OBS Studio's renderer.
+///
+///   Texture slot numbers are equivalent to array index and represent a direct mapping between samplers and textures.
+@_cdecl("device_load_samplerstate")
+public func device_load_samplerstate(device: UnsafeRawPointer, samplerstate: UnsafeRawPointer, unit: UInt32) {
+    let device: MetalDevice = unretained(device)
+    let samplerState: MTLSamplerState = unretained(samplerstate)
+
+    device.renderState.samplers[Int(unit)] = samplerState
+}

+ 593 - 0
libobs-metal/metal-shader.swift

@@ -0,0 +1,593 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+private typealias ParserError = MetalError.OBSShaderParserError
+private typealias ShaderError = MetalError.OBSShaderError
+private typealias MetalShaderError = MetalError.MetalShaderError
+
+/// Creates a ``MetalShader`` instance from the given shader string for use as a vertex shader.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - shader: C character pointer with the contents of the `libobs` effect file
+///   - file: C character pointer with the contents of the `libobs` effect file location
+///   - error_string: Pointer for another C character pointer with the contents of an error description
+/// - Returns: Opaque pointer to a new ``MetalShader`` instance on success or `nil` on error
+///
+/// The string pointed to by the `data` argument is a re-compiled shader string created from the associated "effect"
+/// file (which will contain multiple effects). Each effect is made up of several passes (though usually only a single
+/// pass is defined), each of which contains a vertex and fragment shader. This function is then called with just the
+/// vertex shader string.
+///
+/// This vertex shader string needs to be parsed again and transpiled into a Metal shader string, which is handled by
+/// the ``OBSShader`` class. The transpiled string is then used to create the actual ``MetalShader`` instance.
+@_cdecl("device_vertexshader_create")
+public func device_vertexshader_create(
+    device: UnsafeRawPointer, shader: UnsafePointer<CChar>, file: UnsafePointer<CChar>,
+    error_string: UnsafeMutablePointer<UnsafeMutablePointer<CChar>>
+) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    let content = String(cString: shader)
+    let fileLocation = String(cString: file)
+
+    do {
+        let obsShader = try OBSShader(type: .vertex, content: content, fileLocation: fileLocation)
+        let transpiled = try obsShader.transpiled()
+
+        guard let metaData = obsShader.metaData else {
+            OBSLog(.error, "device_vertexshader_create: No required metadata found for transpiled shader")
+            return nil
+        }
+
+        let metalShader = try MetalShader(device: device, source: transpiled, type: .vertex, data: metaData)
+
+        return metalShader.getRetained()
+    } catch let error as ParserError {
+        switch error {
+        case .parseFail(let description):
+            OBSLog(.error, "device_vertexshader_create: Error parsing shader.\n\(description)")
+        default:
+            OBSLog(.error, "device_vertexshader_create: Error parsing shader.\n\(error.description)")
+        }
+    } catch let error as ShaderError {
+        switch error {
+        case .transpileError(let description):
+            OBSLog(.error, "device_vertexshader_create: Error transpiling shader.\n\(description)")
+        case .parseError(let description):
+            OBSLog(.error, "device_vertexshader_create: OBS parser error.\n\(description)")
+        case .parseFail(let description):
+            OBSLog(.error, "device_vertexshader_create: OBS parser failure.\n\(description)")
+        default:
+            OBSLog(.error, "device_vertexshader_create: OBS shader error.\n\(error.description)")
+        }
+    } catch {
+        switch error {
+        case let error as MetalShaderError:
+            OBSLog(.error, "device_vertexshader_create: Error compiling shader.\n\(error.description)")
+        case let error as MetalError.MTLDeviceError:
+            OBSLog(.error, "device_vertexshader_create: Device error compiling shader.\n\(error.description)")
+        default:
+            OBSLog(.error, "device_vertexshader_create: Unknown error occurred")
+        }
+    }
+
+    return nil
+}
+
+/// Creates a ``MetalShader`` instance from the given shader string for use as a fragment shader.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - shader: C character pointer with the contents of the `libobs` effect file
+///   - file: C character pointer with the contents of the `libobs` effect file location
+///   - error_string: Pointer for another C character pointer with the contents of an error description
+/// - Returns: Opaque pointer to a new ``MetalShader`` instance on success or `nil` on error
+///
+/// The string pointed to by the `data` argument is a re-compiled shader string created from the associated "effect"
+/// file (which will contain multiple effects). Each effect is made up of several passes (though usually only a single
+/// pass is defined), each of which contains a vertex and fragment shader. This function is then called with just the
+/// vertex shader string.
+///
+/// This fragment shader string needs to be parsed again and transpiled into a Metal shader string, which is handled by
+/// the ``OBSShader`` class. The transpiled string is then used to create the actual ``MetalShader`` instance.
+@_cdecl("device_pixelshader_create")
+public func device_pixelshader_create(
+    device: UnsafeRawPointer, shader: UnsafePointer<CChar>, file: UnsafePointer<CChar>,
+    error_string: UnsafeMutablePointer<UnsafeMutablePointer<CChar>>
+) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    let content = String(cString: shader)
+    let fileLocation = String(cString: file)
+
+    do {
+        let obsShader = try OBSShader(type: .fragment, content: content, fileLocation: fileLocation)
+        let transpiled = try obsShader.transpiled()
+
+        guard let metaData = obsShader.metaData else {
+            OBSLog(.error, "device_pixelshader_create: No required metadata found for transpiled shader")
+            return nil
+        }
+
+        let metalShader = try MetalShader(device: device, source: transpiled, type: .fragment, data: metaData)
+
+        return metalShader.getRetained()
+    } catch let error as ParserError {
+        switch error {
+        case .parseFail(let description):
+            OBSLog(.error, "device_vertexshader_create: Error parsing shader.\n\(description)")
+        default:
+            OBSLog(.error, "device_vertexshader_create: Error parsing shader.\n\(error.description)")
+        }
+    } catch let error as ShaderError {
+        switch error {
+        case .transpileError(let description):
+            OBSLog(.error, "device_vertexshader_create: Error transpiling shader.\n\(description)")
+        case .parseError(let description):
+            OBSLog(.error, "device_vertexshader_create: OBS parser error.\n\(description)")
+        case .parseFail(let description):
+            OBSLog(.error, "device_vertexshader_create: OBS parser failure.\n\(description)")
+        default:
+            OBSLog(.error, "device_vertexshader_create: OBS shader error.\n\(error.description)")
+        }
+    } catch {
+        switch error {
+        case let error as MetalShaderError:
+            OBSLog(.error, "device_vertexshader_create: Error compiling shader.\n\(error.description)")
+        case let error as MetalError.MTLDeviceError:
+            OBSLog(.error, "device_vertexshader_create: Device error compiling shader.\n\(error.description)")
+        default:
+            OBSLog(.error, "device_vertexshader_create: Unknown error occurred")
+        }
+    }
+
+    return nil
+}
+
+/// Loads the ``MetalShader`` instance for use as the vertex shader for the current render pipeline descriptor.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - vertShader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+///
+/// This function will simply set up the ``MTLFunction`` wrapped by the ``MetalShader`` instance as the current
+/// pipeline descriptor's `vertexFunction`. The Metal renderer will lazily create new render pipeline states for each
+/// permutation of pipeline descriptors, which is a comparatively costly operation but will only occur once for any
+/// such permutation.
+///
+/// > Note: If a `NULL` pointer is passed for the `vertShader` argument, the vertex function on the current render
+/// pipeline descriptor will be _unset_.
+///
+@_cdecl("device_load_vertexshader")
+public func device_load_vertexshader(device: UnsafeRawPointer, vertShader: UnsafeRawPointer?) {
+    let device: MetalDevice = unretained(device)
+
+    if let vertShader {
+        let shader: MetalShader = unretained(vertShader)
+
+        guard shader.type == .vertex else {
+            assertionFailure("device_load_vertexshader: Invalid shader type \(shader.type)")
+            return
+        }
+
+        device.renderState.vertexShader = shader
+        device.renderState.pipelineDescriptor.vertexFunction = shader.function
+        device.renderState.pipelineDescriptor.vertexDescriptor = shader.vertexDescriptor
+    } else {
+        device.renderState.vertexShader = nil
+        device.renderState.pipelineDescriptor.vertexFunction = nil
+        device.renderState.pipelineDescriptor.vertexDescriptor = nil
+    }
+}
+
+/// Loads the ``MetalShader`` instance for use as the fragment shader for the current render pipeline descriptor.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - vertShader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+///
+/// This function will simply set up the ``MTLFunction`` wrapped by the ``MetalShader`` instance as the current
+/// pipeline descriptor's `fragmentFunction`. The Metal renderer will lazily create new render pipeline states for
+/// each permutation of pipeline descriptors, which is a comparatively costly operation but will only occur once for
+/// any such permutation.
+///
+/// As any fragment function is potentially associated with a number of textures and associated sampler states, the
+/// associated arrays are reset whenever a new fragment function is set up.
+///
+/// > Note: If a `NULL` pointer is passed for the `pixelShader` argument, the fragment function on the current render
+/// pipeline descriptor will be _unset_.
+///
+@_cdecl("device_load_pixelshader")
+public func device_load_pixelshader(device: UnsafeRawPointer, pixelShader: UnsafeRawPointer?) {
+    let device: MetalDevice = unretained(device)
+
+    for index in 0..<Int(GS_MAX_TEXTURES) {
+        device.renderState.textures[index] = nil
+        device.renderState.samplers[index] = nil
+    }
+
+    if let pixelShader {
+        let shader: MetalShader = unretained(pixelShader)
+
+        guard shader.type == .fragment else {
+            assertionFailure("device_load_pixelshader: Invalid shader type \(shader.type)")
+            return
+        }
+
+        device.renderState.fragmentShader = shader
+        device.renderState.pipelineDescriptor.fragmentFunction = shader.function
+
+        if let samplers = shader.samplers {
+            device.renderState.samplers.replaceSubrange(0..<samplers.count, with: samplers)
+        }
+    } else {
+        device.renderState.pipelineDescriptor.fragmentFunction = nil
+    }
+}
+
+/// Gets the ``MetalShader`` set up as the current vertex shader for the pipeline
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Opaque pointer to ``MetalShader`` instance if a vertex shader is currently set up or `nil` otherwise
+@_cdecl("device_get_vertex_shader")
+public func device_get_vertex_shader(device: UnsafeRawPointer) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    if let shader = device.renderState.vertexShader {
+        return shader.getUnretained()
+    } else {
+        return nil
+    }
+}
+
+/// Gets the ``MetalShader`` set up as the current fragment shader for the pipeline
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Opaque pointer to ``MetalShader`` instance if a fragment shader is currently set up or `nil` otherwise
+@_cdecl("device_get_pixel_shader")
+public func device_get_pixel_shader(device: UnsafeRawPointer) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    if let shader = device.renderState.fragmentShader {
+        return shader.getUnretained()
+    } else {
+        return nil
+    }
+}
+
+/// Requests the deinitialization of the ``MetalShader`` instance shared with `libobs`
+/// - Parameter shader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+///
+/// Ownership of the ``MetalShader`` instance will be transferred into the function and if this was the last strong
+/// reference to it, the object will be automatically deinitialized and deallocated by Swift.
+@_cdecl("gs_shader_destroy")
+public func gs_shader_destroy(shader: UnsafeRawPointer) {
+    let _ = retained(shader) as MetalShader
+}
+
+/// Gets the number of uniform parameters used on the ``MetalShader``
+/// - Parameter shader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+/// - Returns: Number of uniforms
+@_cdecl("gs_shader_get_num_params")
+public func gs_shader_get_num_params(shader: UnsafeRawPointer) -> UInt32 {
+    let shader: MetalShader = unretained(shader)
+
+    return UInt32(shader.uniforms.count)
+}
+
+/// Gets a uniform parameter from the ``MetalShader`` by its array index
+/// - Parameters:
+///   - shader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+///   - param: Array index of uniform parameter to get
+/// - Returns: Opaque pointer to a ``ShaderUniform`` instance if index within uniform array bounds or `nil` otherwise
+///
+/// This function requires that the array indices of the uniforms array do not change for a ``MetalShader`` and also
+/// that the exact order of uniforms is identical between `libobs`'s interpretation of the effects file and the
+/// transpiled shader's analysis of the uniforms.
+///
+/// > Important: The opaque pointer for the ``ShaderUniform`` instance is passe unretained and as such can become
+/// invalid when its owning ``MetalShader`` instance either is deinitialized itself or is replaced in the uniforms
+/// array.
+@_cdecl("gs_shader_get_param_by_idx")
+public func gs_shader_get_param_by_idx(shader: UnsafeRawPointer, param: UInt32) -> OpaquePointer? {
+    let shader: MetalShader = unretained(shader)
+
+    guard param < shader.uniforms.count else {
+        return nil
+    }
+
+    let uniform = shader.uniforms[Int(param)]
+    let unretained = Unmanaged.passUnretained(uniform).toOpaque()
+
+    return OpaquePointer(unretained)
+}
+
+/// Gets a uniform parameter from the ``MetalShader`` by its name
+/// - Parameters:
+///   - shader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+///   - param: C character array pointer with the name of the requested uniform parameter
+/// - Returns: Opaque pointer to a ``ShaderUniform`` instance if any uniform with the provided name was found or `nil`
+/// otherwise
+///
+/// > Important: The opaque pointer for the ``ShaderUniform`` instance is passe unretained and as such can become
+/// invalid when its owning ``MetalShader`` instance either is deinitialized itself or is replaced in the uniforms
+/// array.
+///
+@_cdecl("gs_shader_get_param_by_name")
+public func gs_shader_get_param_by_name(shader: UnsafeRawPointer, param: UnsafeMutablePointer<CChar>) -> OpaquePointer?
+{
+    let shader: MetalShader = unretained(shader)
+
+    let paramName = String(cString: param)
+
+    for uniform in shader.uniforms {
+        if uniform.name == paramName {
+            let unretained = Unmanaged.passUnretained(uniform).toOpaque()
+            return OpaquePointer(unretained)
+        }
+    }
+
+    return nil
+}
+
+/// Gets the uniform parameter associated with the view projection matrix used by the ``MetalShader``
+/// - Parameter shader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+/// - Returns: Opaque pointer to a ``ShaderUniform`` instance if a uniform for the view projection matrix was found
+/// or `nil` otherwise
+///
+/// The uniform for the view projection matrix has the associated name `viewProj` in the Metal renderer, thus a
+/// name-based lookup is used to find the associated ``ShaderUniform`` instance.
+///
+/// > Important: The opaque pointer for the ``ShaderUniform`` instance is passe unretained and as such can become
+/// invalid when its owning ``MetalShader`` instance either is deinitialized itself or is replaced in the uniforms
+/// array.
+///
+@_cdecl("gs_shader_get_viewproj_matrix")
+public func gs_shader_get_viewproj_matrix(shader: UnsafeRawPointer) -> OpaquePointer? {
+    let shader: MetalShader = unretained(shader)
+    let paramName = "viewProj"
+
+    for uniform in shader.uniforms {
+        if uniform.name == paramName {
+            let unretained = Unmanaged.passUnretained(uniform).toOpaque()
+            return OpaquePointer(unretained)
+        }
+    }
+
+    return nil
+}
+
+/// Gets the uniform parameter associated with the world projection matrix used by the ``MetalShader``
+/// - Parameter shader: Opaque pointer to ``MetalShader`` instance shared with `libobs`
+/// - Returns: Opaque pointer to a ``ShaderUniform`` instance if a uniform for the world projection matrix was found
+/// or `nil` otherwise
+///
+/// The uniform for the view projection matrix has the associated name `worldProj` in the Metal renderer, thus a
+/// name-based lookup is used to find the associated ``ShaderUniform`` instance.
+///
+/// > Important: The opaque pointer for the ``ShaderUniform`` instance is passe unretained and as such can become
+/// invalid when its owning ``MetalShader`` instance either is deinitialized itself or is replaced in the uniforms
+/// array.
+@_cdecl("gs_shader_get_world_matrix")
+public func gs_shader_get_world_matrix(shader: UnsafeRawPointer) -> OpaquePointer? {
+    let shader: MetalShader = unretained(shader)
+    let paramName = "worldProj"
+
+    for uniform in shader.uniforms {
+        if uniform.name == paramName {
+            let unretained = Unmanaged.passUnretained(uniform).toOpaque()
+            return OpaquePointer(unretained)
+        }
+    }
+
+    return nil
+}
+
+/// Gets the name and uniform type from the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - info: Pointer to a `gs_shader_param_info` struct pre-allocated by `libobs`
+///
+/// > Warning: The C character array pointer holding the name of the uniform is managed by Swift and might become
+/// invalid at any point in time.
+@_cdecl("gs_shader_get_param_info")
+public func gs_shader_get_param_info(shaderParam: UnsafeRawPointer, info: UnsafeMutablePointer<gs_shader_param_info>) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    shaderUniform.name.withCString {
+        info.pointee.name = $0
+    }
+    info.pointee.type = shaderUniform.gsType
+}
+
+/// Sets a boolean value on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: Boolean value to set for the uniform
+@_cdecl("gs_shader_set_bool")
+public func gs_shader_set_bool(shaderParam: UnsafeRawPointer, val: Bool) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    withUnsafePointer(to: val) {
+        shaderUniform.setParameter(data: $0, size: MemoryLayout<Int32>.size)
+    }
+}
+
+/// Sets a 32-bit floating point value on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: 32-bit floating point value to set for the uniform
+@_cdecl("gs_shader_set_float")
+public func gs_shader_set_float(shaderParam: UnsafeRawPointer, val: Float32) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    withUnsafePointer(to: val) {
+        shaderUniform.setParameter(data: $0, size: MemoryLayout<Float32>.size)
+    }
+}
+
+/// Sets a 32-bit signed integer value on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: 32-bit signed integer value to set for the uniform
+@_cdecl("gs_shader_set_int")
+public func gs_shader_set_int(shaderParam: UnsafeRawPointer, val: Int32) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    withUnsafePointer(to: val) {
+        shaderUniform.setParameter(data: $0, size: MemoryLayout<Int32>.size)
+    }
+}
+
+/// Sets a 3x3 matrix of 32-bit floating point values on the ``ShaderUniform``instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: A 3x3 matrix of 32-bit floating point values
+///
+/// The 3x3 matrix is converted into a 4x4 matrix (padded with zeros) before actually being set as the uniform data
+@_cdecl("gs_shader_set_matrix3")
+public func gs_shader_set_matrix3(shaderParam: UnsafeRawPointer, val: UnsafePointer<matrix3>) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    var newMatrix = matrix4()
+    matrix4_from_matrix3(&newMatrix, val)
+
+    shaderUniform.setParameter(data: &newMatrix, size: MemoryLayout<matrix4>.size)
+}
+
+/// Sets a 4x4 matrix of 32-bit floating point values on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: A 4x4 matrix of 32-bit floating point values
+@_cdecl("gs_shader_set_matrix4")
+public func gs_shader_set_matrix4(shaderParam: UnsafeRawPointer, val: UnsafePointer<matrix4>) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    shaderUniform.setParameter(data: val, size: MemoryLayout<matrix4>.size)
+}
+
+/// Sets a vector of 2 32-bit floating point values on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: A vector of 2 32-bit floating point values
+@_cdecl("gs_shader_set_vec2")
+public func gs_shader_set_vec2(shaderParam: UnsafeRawPointer, val: UnsafePointer<vec2>) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    shaderUniform.setParameter(data: val, size: MemoryLayout<vec2>.size)
+}
+
+/// Sets a vector of 3 32-bit floating point values on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: A vector of 3 32-bit floating point values
+@_cdecl("gs_shader_set_vec3")
+public func gs_shader_set_vec3(shaderParam: UnsafeRawPointer, val: UnsafePointer<vec3>) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    shaderUniform.setParameter(data: val, size: MemoryLayout<vec3>.size)
+}
+
+/// Sets a vector of 4 32-bit floating point values on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: A vector of 4 32-bit floating point values
+@_cdecl("gs_shader_set_vec4")
+public func gs_shader_set_vec4(shaderParam: UnsafeRawPointer, val: UnsafePointer<vec4>) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    shaderUniform.setParameter(data: val, size: MemoryLayout<vec4>.size)
+}
+
+/// Sets up the data of a `gs_shader_texture` struct as a uniform on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: A pointer to a `gs_shader_struct` containing an opaque pointer to the actual ``MetalTexture`` instance
+///     and an sRGB gamma state flag
+///
+/// The struct's data is copied verbatim into the uniform, which allows reconstruction of the pointer at a later point
+/// as long as the actual ``MetalTexture`` instance still exists.
+@_cdecl("gs_shader_set_texture")
+public func gs_shader_set_texture(shaderParam: UnsafeRawPointer, val: UnsafePointer<gs_shader_texture>?) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    if let val {
+        shaderUniform.setParameter(data: val, size: MemoryLayout<gs_shader_texture>.size)
+    }
+}
+
+/// Sets an arbitrary value on the ``ShaderUniform`` instance
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - val: Opaque pointer to some unknown data for use as the uniform
+///   - size: The size of the data available at the memory pointed to by the `val` argument
+///
+/// The ``ShaderUniform`` itself is set up to hold a specific uniform type, each of which is associated with a size of
+/// bytes required for it. If the size of the data pointed to by `val` does not fit into this size, the uniform will
+/// not be updated.
+///
+/// If the ``ShaderUniform`` expects a texture parameter, the pointer will be bound as memory of a `gs_shader_texture`
+/// instance before setting it up.
+@_cdecl("gs_shader_set_val")
+public func gs_shader_set_val(shaderParam: UnsafeRawPointer, val: UnsafeRawPointer, size: UInt32) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    let size = Int(size)
+    let valueSize = shaderUniform.gsType.size
+
+    guard valueSize == size else {
+        assertionFailure("gs_shader_set_val: Required size of uniform does not match size of input")
+        return
+    }
+
+    if shaderUniform.gsType == GS_SHADER_PARAM_TEXTURE {
+        let shaderTexture = val.bindMemory(to: gs_shader_texture.self, capacity: 1)
+
+        shaderUniform.setParameter(data: shaderTexture, size: valueSize)
+    } else {
+        let bytes = val.bindMemory(to: UInt8.self, capacity: valueSize)
+        shaderUniform.setParameter(data: bytes, size: valueSize)
+    }
+}
+
+/// Resets the ``ShaderUniform``'s current data with its default data
+/// - Parameter shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///
+/// Each ``ShaderUniform`` is optionally set up with a set of default data (stored as an array of bytes) which is
+/// simply copied into the current values.
+@_cdecl("gs_shader_set_default")
+public func gs_shader_set_default(shaderParam: UnsafeRawPointer) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    if let defaultValues = shaderUniform.defaultValues {
+        shaderUniform.currentValues = Array(defaultValues)
+    }
+}
+
+/// Sets up the ``MTLSamplerState`` as the sampler state for the ``ShaderUniform``
+/// - Parameters:
+///   - shaderParam: Opaque pointer to ``ShaderUniform`` instance shared with `libobs`
+///   - sampler: Opaque pointer to ``MTLSamplerState`` instance shared with `libobs`
+///
+/// If the uniform represents a texture for use in the associated shader, this function will also set up the provided
+/// ``MTLSamplerState`` for the associated texture's texture slot.
+@_cdecl("gs_shader_set_next_sampler")
+public func gs_shader_set_next_sampler(shaderParam: UnsafeRawPointer, sampler: UnsafeRawPointer) {
+    let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam)
+
+    let samplerState = Unmanaged<MTLSamplerState>.fromOpaque(sampler).takeUnretainedValue()
+
+    shaderUniform.samplerState = samplerState
+}

+ 130 - 0
libobs-metal/metal-stagesurf.swift

@@ -0,0 +1,130 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Creates a ``MetalStageBuffer`` instance for use as a stage surface by `libobs`
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+///   - width: Number of data rows
+///   - height: Number of data columns
+///   - format: Color format of the stage surface texture as defined by `libobs`'s `gs_color_format` struct
+/// - Returns: A ``MetalStageBuffer`` instance that wraps a `MTLBuffer` or a `nil` pointer otherwise
+///
+/// Stage surfaces are used by `libobs` for transfer of image data from the GPU to the CPU. The most common use case is
+/// to block transfer (blit) the video output texture into a staging texture and then downloading the texture data from
+/// the staging texture into CPU memory.
+@_cdecl("device_stagesurface_create")
+public func device_stagesurface_create(device: UnsafeRawPointer, width: UInt32, height: UInt32, format: gs_color_format)
+    -> OpaquePointer?
+{
+    let device: MetalDevice = unretained(device)
+
+    guard
+        let buffer = MetalStageBuffer(
+            device: device,
+            width: Int(width),
+            height: Int(height),
+            format: format.mtlFormat
+        )
+    else {
+        OBSLog(.error, "device_stagesurface_create: Unable to create MetalStageBuffer with provided format \(format)")
+        return nil
+    }
+
+    return buffer.getRetained()
+}
+
+/// Requests the deinitialization of the ``MetalStageBuffer`` instance that was shared with `libobs`
+/// - Parameter stagesurf: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+///
+/// The ownership of the shared pointer is transferred into this function and the instance is placed under Swift's
+/// memory management again.
+@_cdecl("gs_stagesurface_destroy")
+public func gs_stagesurface_destroy(stagesurf: UnsafeRawPointer) {
+    let _ = retained(stagesurf) as MetalStageBuffer
+}
+
+/// Gets the "width" of the staging texture
+/// - Parameter stagesurf: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+/// - Returns: Amount of data rows in the buffer representing the width of an image
+@_cdecl("gs_stagesurface_get_width")
+public func gs_stagesurface_get_width(stagesurf: UnsafeRawPointer) -> UInt32 {
+    let stageSurface: MetalStageBuffer = unretained(stagesurf)
+
+    return UInt32(stageSurface.width)
+}
+
+/// Gets the "height" of the staging texture
+/// - Parameter stagesurf: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+/// - Returns: Amount of data columns in the buffer representing the height of an image
+@_cdecl("gs_stagesurface_get_height")
+public func gs_stagesurface_get_height(stagesurf: UnsafeRawPointer) -> UInt32 {
+    let stageSurface: MetalStageBuffer = unretained(stagesurf)
+
+    return UInt32(stageSurface.height)
+}
+
+/// Gets the color format of the staged image data
+/// - Parameter stagesurf: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+/// - Returns: Color format in `libobs`'s own color format struct
+///
+/// The Metal color format is automatically converted into its corresponding `gs_color_format` variant.
+@_cdecl("gs_stagesurface_get_color_format")
+public func gs_stagesurface_get_height(stagesurf: UnsafeRawPointer) -> gs_color_format {
+    let stageSurface: MetalStageBuffer = unretained(stagesurf)
+
+    return stageSurface.format.gsColorFormat
+}
+
+/// Provides a pointer to memory that contains the buffer's raw data.
+/// - Parameters:
+///   - stagesurf: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+///   - ptr: Opaque pointer to memory which itself can hold a pointer to the actual image data
+///   - linesize: Opaque pointer to memory which itself can hold the row size of the image data
+/// - Returns: `true` if the data can be provided, `false` otherwise
+///
+/// Metal does not provide "map" and "unmap" operations as they exist in Direct3D11, as resource management and
+/// synchronization needs to be handled explicitly by the application. To reduce unnecessary copy operations, the
+/// original texture's data was copied into a `MTLBuffer` (instead of another texture) using a block transfer on the
+/// GPU.
+///
+/// As the Metal renderer is only available on Apple Silicon machines, this means that the buffer itself is available
+/// for direct access by the CPU and thus a pointer to the raw bytes of the buffer can be shared with `libobs`.
+@_cdecl("gs_stagesurface_map")
+public func gs_stagesurface_map(
+    stagesurf: UnsafeRawPointer, ptr: UnsafeMutablePointer<UnsafeMutableRawPointer>,
+    linesize: UnsafeMutablePointer<UInt32>
+) -> Bool {
+    let stageSurface: MetalStageBuffer = unretained(stagesurf)
+
+    ptr.pointee = stageSurface.buffer.contents()
+    linesize.pointee = UInt32(stageSurface.width * stageSurface.format.bytesPerPixel!)
+
+    return true
+}
+
+/// Signals that the downloaded image data of the stage texture is not needed anymore.
+///
+/// - Parameter stagesurf: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`
+///
+/// This function has no effect as the `MTLBuffer` used by the ``MetalStageBuffer`` does not need to be "unmapped".
+@_cdecl("gs_stagesurface_unmap")
+public func gs_stagesurface_unmap(stagesurf: UnsafeRawPointer) {
+    return
+}

+ 985 - 0
libobs-metal/metal-subsystem.swift

@@ -0,0 +1,985 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+import simd
+
+@inlinable
+public func unretained<Instance>(_ pointer: UnsafeRawPointer) -> Instance where Instance: AnyObject {
+    Unmanaged<Instance>.fromOpaque(pointer).takeUnretainedValue()
+}
+
+@inlinable
+public func retained<Instance>(_ pointer: UnsafeRawPointer) -> Instance where Instance: AnyObject {
+    Unmanaged<Instance>.fromOpaque(pointer).takeRetainedValue()
+}
+
+@inlinable
+public func OBSLog(_ level: OBSLogLevel, _ format: String, _ args: CVarArg...) {
+    let logMessage = String.localizedStringWithFormat(format, args)
+
+    logMessage.withCString { cMessage in
+        withVaList([cMessage]) { arguments in
+            blogva(level.rawValue, "%s", arguments)
+        }
+    }
+}
+
+/// Returns the graphics API name implemented by the "device".
+/// - Returns: Constant pointer to a C string with the API name
+///
+@_cdecl("device_get_name")
+public func device_get_name() -> UnsafePointer<CChar> {
+    return device_name
+}
+
+/// Gets the graphics API identifier number for the "device".
+/// - Returns: Numerical identifier
+///
+@_cdecl("device_get_type")
+public func device_get_type() -> Int32 {
+    return GS_DEVICE_METAL
+}
+
+/// Returns a string to be used as a suffix for libobs' shader preprocessor, which will be used as part of a shaders
+/// identifying information.
+/// - Returns: Constant pointer to a C string with the suffix text
+@_cdecl("device_preprocessor_name")
+public func device_preprocessor_name() -> UnsafePointer<CChar> {
+    return preprocessor_name
+}
+
+/// Creates a new Metal device instance and stores an opaque pointer to a ``MetalDevice`` instance in the provided
+/// pointer.
+///
+/// - Parameters:
+///   - devicePointer: Pointer to memory allocated by the caller to receive the pointer of the create device instance
+///   - adapter: Numerical identifier of a graphics display adaptor to create the device on.
+/// - Returns: Device creation result value defined as preprocessor macro in libobs' graphics API header
+///
+/// This method will increment the reference count on the created ``MetalDevice`` instance to ensure it will not be
+/// deallocated until `libobs` actively relinquishes ownership of it via a call of  `device_destroy`.
+///
+/// > Important: As the Metal API is only supported on Apple Silicon devices, the adapter argument is effectively
+/// ignored (there is only ever one "adapter" in an Apple Silicon machine and thus only the "default" device is used.
+@_cdecl("device_create")
+public func device_create(devicePointer: UnsafeMutableRawPointer, adapter: UInt32) -> Int32 {
+    guard NSProtocolFromString("MTLDevice") != nil else {
+        OBSLog(.error, "This Mac does not support Metal.")
+        return GS_ERROR_NOT_SUPPORTED
+    }
+
+    OBSLog(.info, "---------------------------------")
+
+    guard let metalDevice = MTLCreateSystemDefaultDevice() else {
+        OBSLog(.error, "Unable to initialize Metal device.")
+        return GS_ERROR_FAIL
+    }
+
+    var descriptions: [String] = []
+
+    descriptions.append("Initializing Metal...")
+    descriptions.append("\t- Name               : \(metalDevice.name)")
+    descriptions.append("\t- Unified Memory     : \(metalDevice.hasUnifiedMemory ? "Yes" : "No")")
+    descriptions.append("\t- Raytracing Support : \(metalDevice.supportsRaytracing ? "Yes" : "No")")
+
+    if #available(macOS 14.0, *) {
+        descriptions.append("\t- Architecture       : \(metalDevice.architecture.name)")
+    }
+
+    OBSLog(.info, descriptions.joined(separator: "\n"))
+
+    do {
+        let device = try MetalDevice(device: metalDevice)
+
+        let retained = Unmanaged.passRetained(device).toOpaque()
+
+        let signalName = MetalSignalType.videoReset.rawValue
+        let signalHandler = obs_get_signal_handler()
+        signalName.withCString {
+            signal_handler_connect(signalHandler, $0, metal_video_reset_handler, retained)
+        }
+
+        devicePointer.storeBytes(of: OpaquePointer(retained), as: OpaquePointer.self)
+    } catch {
+        OBSLog(.error, "Unable to create MetalDevice wrapper instance")
+        return GS_ERROR_FAIL
+    }
+
+    return GS_SUCCESS
+}
+
+/// Uninitializes the Metal device instance created for libobs.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// This method will take ownership of the reference shared with `libobs` and thus return all strong references to the
+/// shared ``MetalDevice`` instance to pure Swift code (and thus its own memory managed). The active call to
+/// ``MetalDevice/shutdown()`` is necessary to ensure that internal clean up code runs _before_ `libobs` runs any of
+/// its own clean up code (which is not memory safe).
+@_cdecl("device_destroy")
+public func device_destroy(device: UnsafeMutableRawPointer) {
+    let signalName = MetalSignalType.videoReset.rawValue
+    let signalHandler = obs_get_signal_handler()
+
+    signalName.withCString {
+        signal_handler_disconnect(signalHandler, $0, metal_video_reset_handler, device)
+    }
+
+    let device: MetalDevice = retained(device)
+
+    device.shutdown()
+}
+
+/// Returns opaque pointer to actual (wrapped) API-specific device object
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Opaque pointer to ``MTLDevice`` object wrapped by ``MetalDevice`` instance
+///
+/// The pointer shared by this function is unretained and is thus unsafe. It doesn't seem that anything in OBS Studio's
+/// codebase actually uses this function, but it is part of the graphics API and thus has to be implemented.
+@_cdecl("device_get_device_obj")
+public func device_get_device_obj(device: UnsafeMutableRawPointer) -> OpaquePointer? {
+    let metalDevice: MetalDevice = unretained(device)
+    let mtlDevice = metalDevice.device
+
+    return OpaquePointer(Unmanaged.passUnretained(mtlDevice).toOpaque())
+}
+
+/// Sets up the blend factor to be used by the current pipeline.
+///
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - src: `libobs` blend type for the source
+///   - dest: `libobs` blend type for the destination
+///
+/// This function uses the same blend factor for color and alpha channel. The enum values provided by `libobs` are
+/// converted into their appropriate ``MTLBlendFactor``variants automatically (if possible).
+///
+/// > Important: Calling this function can trigger the creation of an entirely new render pipeline state, which is a
+/// costly operation.
+@_cdecl("device_blend_function")
+public func device_blend_function(device: UnsafeRawPointer, src: gs_blend_type, dest: gs_blend_type) {
+    device_blend_function_separate(
+        device: device,
+        src_c: src,
+        dest_c: dest,
+        src_a: src,
+        dest_a: dest
+    )
+}
+
+/// Sets up the color and alpha blend factors to be used by the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - src_c: `libobs` blend factor for the source color
+///   - dest_c: `libobs` blend factor for the destination color
+///   - src_a: `libobs` blend factor for the source alpha channel
+///   - dest_a: `libobs` blend factor for the destination alpha channel
+///
+/// This function uses different blend factors for color and alpha channel. The enum values provided by `libobs` are
+/// converted into their appropriate ``MTLBlendFactor`` variants automatically  (if possible).
+///
+/// > Important: Calling this function can trigger the creation of an entirely new render pipeline state, which is a
+/// costly operation.
+@_cdecl("device_blend_function_separate")
+public func device_blend_function_separate(
+    device: UnsafeRawPointer, src_c: gs_blend_type, dest_c: gs_blend_type, src_a: gs_blend_type, dest_a: gs_blend_type
+) {
+    let device: MetalDevice = unretained(device)
+
+    let pipelineDescriptor = device.renderState.pipelineDescriptor
+    guard let sourceRGBFactor = src_c.blendFactor,
+        let sourceAlphaFactor = src_a.blendFactor,
+        let destinationRGBFactor = dest_c.blendFactor,
+        let destinationAlphaFactor = dest_a.blendFactor
+    else {
+        assertionFailure(
+            """
+            device_blend_function_separate: Incompatible blend factors used. Values:
+                - Source RGB        : \(src_c)
+                - Source Alpha      : \(src_a)
+                - Destination RGB   : \(dest_c)
+                - Destination Alpha : \(dest_a)
+            """)
+        return
+    }
+
+    pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = sourceRGBFactor
+    pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = sourceAlphaFactor
+    pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = destinationRGBFactor
+    pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = destinationAlphaFactor
+}
+
+/// Sets the blend operation to be used by the current pipeline.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - op: `libobs` blend operation name
+///
+/// This function converts the provided `libobs` value into its appropriate ``MTLBlendOperation`` variant automatically
+/// (if possible).
+///
+/// > Important: Calling this function can trigger the creation of an entirely new render pipeline state, which is a
+/// costly operation.
+@_cdecl("device_blend_op")
+public func device_blend_op(device: UnsafeRawPointer, op: gs_blend_op_type) {
+    let device: MetalDevice = unretained(device)
+
+    let pipelineDescriptor = device.renderState.pipelineDescriptor
+
+    guard let blendOperation = op.mtlOperation else {
+        assertionFailure("device_blend_op: Incompatible blend operation provided. Value: \(op)")
+        return
+    }
+
+    pipelineDescriptor.colorAttachments[0].rgbBlendOperation = blendOperation
+}
+
+/// Returns the _current_ color space as set up by any preceding calls of the `libobs` renderer.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Color space  enum value as defined by `libobs`
+///
+/// This color space value is commonly set by `libobs`' renderer to check the "current state", and make necessary
+/// switches to ensure color-correct rendering
+/// (e.g., to check if the renderer uses an SDR color space but the current source might provide HDR image data). This
+/// value is effectively just retained as a state variable for `libobs`.
+@_cdecl("device_get_color_space")
+public func device_get_color_space(device: UnsafeRawPointer) -> gs_color_space {
+    let device: MetalDevice = unretained(device)
+
+    return device.renderState.gsColorSpace
+}
+
+/// Signals the beginning of a new render loop iteration by `libobs` renderer.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// This function is the first graphics API-specific function called by `libobs` render loop and can be used as a
+/// signal to reset any lingering state of the prior loop iteration.
+///
+/// For the Metal renderer this ensures that the current render target, current swap chain, as well as the list of
+/// active swap chains is reset. As the Metal renderer also needs to keep track of whether `libobs` is rendering any
+/// "displays", the associated state variable is also reset here.
+@_cdecl("device_begin_frame")
+public func device_begin_frame(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.useSRGBGamma = false
+    device.renderState.renderTarget = nil
+
+    device.renderState.swapChain = nil
+    device.renderState.isInDisplaysRenderStage = false
+
+    return
+}
+
+/// Gets a pointer to the current render target
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Opaque pointer to ``MetalTexture`` object representing the render target
+///
+/// OBS Studio's renderer only ever uses a single render target at the same time and switches them out if it needs to
+/// render a different output. Due to this single  state approach, it needs to retain any "current" values before
+/// replacing them with (temporary) new values. It does so by retrieving pointers to the current objects set up within
+/// the graphics API's opaque implementation and storing them for later use.
+@_cdecl("device_get_render_target")
+public func device_get_render_target(device: UnsafeRawPointer) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    guard let renderTarget = device.renderState.renderTarget else {
+        return nil
+    }
+
+    return renderTarget.getUnretained()
+}
+
+/// Replaces the "current" render target and zstencil attachment with the objects associated by any provided non-`nil`
+/// pointers.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - tex: Opaque (optional) pointer to ``MetalTexture`` instance shared with `libobs`
+///   - zstencil: Opaque (optional) pointer to ``MetalTexture`` instance shared with `libobs`
+///
+/// This setter function is often used in conjunction with its associated getter function to temporarily "switch state"
+/// of the renderer by retaining a pointer to the "current" render target, setting up a new one, issuing a draw call,
+/// before restoring the original render target.
+///
+/// This is regularly used for "texrender" instances, such as combining the chroma and luma components of a video frame
+/// (and uploaded as single- and dual-channel textures respectively) back into an RGB texture. This texture is then
+/// used as the "output" of its corresponding source in the "actual" render pass, which will use the original render
+/// target again.
+@_cdecl("device_set_render_target")
+public func device_set_render_target(device: UnsafeRawPointer, tex: UnsafeRawPointer?, zstencil: UnsafeRawPointer?) {
+    device_set_render_target_with_color_space(
+        device: device,
+        tex: tex,
+        zstencil: zstencil,
+        space: GS_CS_SRGB
+    )
+}
+
+/// Replaces the "current" render target and zstencil attachment with the objects associated by any provided non-`nil`
+/// pointers and also updated the "current" color space used by the renderer.
+
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - tex: Opaque (optional) pointer to ``MetalTexture`` instance shared with `libobs`
+///   - zstencil: Opaque (optional) pointer to ``MetalTexture`` instance shared with `libobs`
+///   - space: `libobs`-based color space value
+///
+/// This setter function is often used in conjunction with its associated getter function to temporarily "switch state"
+/// of the renderer by retaining a pointer to the "current" render target, setting up a new one, issuing a draw call,
+/// before restoring the original render target.
+///
+/// This is regularly used for "texrender" instances, such as combining the chroma and luma components of a video frame
+/// (and uploaded as single- and dual-channel textures respectively) back into an RGB texture. This texture is then
+/// used as the "output" of its corresponding source in the "actual" render pass, which will use the original render
+/// target again.
+///
+/// A `nil` pointer provided for either the render target or zstencil attachment means that the "current" value for
+/// either should be removed, leaving the renderer in an "invalid" state at least for the render target (using no
+/// zstencil attachment is a valid state however).
+///
+/// > Important: Use this variant if you need to also update the "current" color space which might be checked by
+/// sources' render function to check whether linear gamma or sRGB's gamma will be used to encode color values.
+@_cdecl("device_set_render_target_with_color_space")
+public func device_set_render_target_with_color_space(
+    device: UnsafeRawPointer, tex: UnsafeRawPointer?, zstencil: UnsafeRawPointer?, space: gs_color_space
+) {
+    let device: MetalDevice = unretained(device)
+
+    if let tex {
+        let metalTexture: MetalTexture = unretained(tex)
+
+        device.renderState.renderTarget = metalTexture
+        device.renderState.isRendertargetChanged = true
+    } else {
+        device.renderState.renderTarget = nil
+    }
+
+    if let zstencil {
+        let zstencilAttachment: MetalTexture = unretained(zstencil)
+
+        device.renderState.depthStencilAttachment = zstencilAttachment
+        device.renderState.isRendertargetChanged = true
+    } else {
+        device.renderState.depthStencilAttachment = nil
+    }
+
+    device.renderState.gsColorSpace = space
+}
+
+/// Switches the current render state to use sRGB gamma encoding and decoding when reading from textures and writing
+/// into render targets
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - enable: Boolean to enable or disable the automatic sRGB gamma encoding and decoding
+///
+/// OBS Studio's renderer has been retroactively updated to use sRGB color primaries _and_ gamma encoding by
+/// preference, but not by default. Any source has to opt-in to the use of automatic sRGB gamma encoding and decoding,
+/// while the default is still to use linear gamma.
+///
+/// This method is thus used by sources to enable or disable the associated behavior and control the way color values
+/// generated by fragment shaders are written into the render target.
+@_cdecl("device_enable_framebuffer_srgb")
+public func device_enable_framebuffer_srgb(device: UnsafeRawPointer, enable: Bool) {
+    let device: MetalDevice = unretained(device)
+
+    if device.renderState.useSRGBGamma != enable {
+        device.renderState.useSRGBGamma = enable
+        device.renderState.isRendertargetChanged = true
+    }
+}
+
+/// Retrieves the current render state's setting for using automatic encoding and decoding of color values using sRGB
+/// gamma.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Boolean value of the sRGB gamma setting
+///
+/// This function is used to check the current state which might have possibly been explicitly changed by calls of
+/// ``device_enable_framebuffer_srgb``.
+///
+/// A source which might only be able to work with color values that have sRGB gamma already applied to them and thus
+/// might want to ensure that the color values provided by the fragment shader will not have the sRGB gamma curve
+/// encoded on them again.
+///
+/// By calling this function, a source can check if automatic gamma encoding is enabled and then turn it off
+/// explicitly, which will ensure that color data is written as-is and no additional encoding will take place.
+@_cdecl("device_framebuffer_srgb_enabled")
+public func device_framebuffer_srgb_enabled(device: UnsafeRawPointer) -> Bool {
+    let device: MetalDevice = unretained(device)
+
+    return device.renderState.useSRGBGamma
+}
+
+/// Signals the beginning of a new scene.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// OBS Studio's renderer signals a new scene for each "display" and for every "video mix", which implicitly signals a
+/// change of output format. This usually also implies that all current textures that might have been set up for
+/// fragment shaders should be reset. For Metal this also requires creating a new "current" command buffer which should
+/// contain all GPU commands necessary to render the "scene".
+@_cdecl("device_begin_scene")
+public func device_begin_scene(device: UnsafeMutableRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    for index in 0..<GS_MAX_TEXTURES {
+        device.renderState.textures[Int(index)] = nil
+        device.renderState.samplers[Int(index)] = nil
+    }
+}
+
+/// Signals the end of a scene.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// OBS Studio's renderer signals the end of a scene for each "display" and for every "video mix", which implicitly
+/// marks the end of the output at a different format. As the Metal renderer needs a way to detect if all draw commands
+/// for a given "display" have ended (and there is no bespoke signal for that in the API), it uses an internal state
+/// variable to track if a display had been loaded for the "current" pipeline state and resets it at the "end of scene"
+/// signal.
+@_cdecl("device_end_scene")
+public func device_end_scene(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    if device.renderState.isInDisplaysRenderStage {
+        device.finishDisplayRenderStage()
+        device.renderState.isInDisplaysRenderStage = false
+    }
+}
+
+/// Schedules a draw command on the GPU using all "state" variables set up by OBS Studio's renderer up to this point.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - drawMode: Primitive type to draw as specified by `libobs`
+///   - startVertex: Start index of vertex to begin drawing with
+///   - numVertices: Count of vertices to draw
+///
+///  Due to OBS Studio's design this function will usually render only a very low amount of vertices (commonly only 4
+/// of them) and very often those vertices  are already loaded up as vertex buffers for use by the vertex shader. In
+/// those cases `libobs` does not seem to provide a vertex count and implicitly expects the graphics API implementation
+/// to deduct the vertex count from the amount of vertices available in its vertex data struct.
+///
+/// In other cases a vertex shader will not use any buffers but calculate the vertex positions based on vertex ID and
+/// a non-null vertex count has to be provided.
+@_cdecl("device_draw")
+public func device_draw(device: UnsafeRawPointer, drawMode: gs_draw_mode, startVertex: UInt32, numVertices: UInt32) {
+    let device: MetalDevice = unretained(device)
+
+    guard let primitiveType = drawMode.mtlPrimitive else {
+        OBSLog(.error, "device_draw: Unsupported draw mode provided: \(drawMode)")
+        return
+    }
+
+    do {
+        try device.draw(primitiveType: primitiveType, vertexStart: Int(startVertex), vertexCount: Int(numVertices))
+    } catch let error as MetalError.MTLDeviceError {
+        OBSLog(.error, "device_draw: \(error.description)")
+    } catch {
+        OBSLog(.error, "device_draw: Unknown error occurred")
+    }
+}
+
+/// Sets up a load action for the "current" frame buffer and depth stencil attachment to simulate the "clear" behavior
+/// of other graphics APIs.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - clearFlags: Bit field provided by `libobs` to mark the clear operations to handle
+///   - color: The RGBA color to use for clearing the frame buffer
+///   - depth: The depth to clear from the depth stencil attachment
+///   - stencil: The stencil to clear from the depth stencil attachment
+///
+/// In APIs like OpenGL or Direct3D11 render targets have to be explicitly cleared. In OpenGL this is achieved by
+/// calling `glClear()` which will schedule a clear operation. Similarly Direct3D11 requires a call to
+/// `ClearRenderTargetView` with a specific `ID3D11RenderTargetView` to do the same.
+///
+/// Metal does not provide an explicit command to "clear the screen" (as one does not render directly to screens
+/// anymore with these APIs). Instead Metal provides "load commands" and "store commands" which describe what should
+/// happen to a render target when it is loaded for rendering and unloaded after rendering.
+///
+/// Thus a "clear" is a "load command" for a render target or depth stencil attachment that is automatically executed
+/// by Metal when it loads or stores them and thus requires Metal to do an explicit (empty) draw call to ensure that
+/// the load and store commands are executed even when no other draw calls will follow.
+@_cdecl("device_clear")
+public func device_clear(
+    device: UnsafeRawPointer, clearFlags: UInt32, color: UnsafePointer<vec4>, depth: Float, stencil: UInt8
+) {
+    let device: MetalDevice = unretained(device)
+
+    var clearState = ClearState()
+
+    if (Int32(clearFlags) & GS_CLEAR_COLOR) == 1 {
+        clearState.colorAction = .clear
+        clearState.clearColor = MTLClearColor(
+            red: Double(color.pointee.x),
+            green: Double(color.pointee.y),
+            blue: Double(color.pointee.z),
+            alpha: Double(color.pointee.w)
+        )
+    } else {
+        clearState.colorAction = .load
+    }
+
+    if (Int32(clearFlags) & GS_CLEAR_DEPTH) == 1 {
+        clearState.clearDepth = Double(depth)
+        clearState.depthAction = .clear
+    } else {
+        clearState.depthAction = .load
+    }
+
+    if (Int32(clearFlags) & GS_CLEAR_STENCIL) == 1 {
+        clearState.clearStencil = UInt32(stencil)
+        clearState.stencilAction = .clear
+    } else {
+        clearState.stencilAction = .load
+    }
+
+    do {
+        try device.clear(state: clearState)
+
+    } catch let error as MetalError.MTLDeviceError {
+        OBSLog(.error, "device_clear: \(error.description)")
+    } catch {
+        OBSLog(.error, "device_clear: Unknown error occurred")
+    }
+}
+
+/// Returns whether the current display is ready to preset a frame generated the renderer
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Boolean value to state whether a frame generated by the renderer could actually be displayed
+///
+/// As OBS Studio's renderer is not synced with the operating system's compositor, situations could arise where the
+/// renderer needs to be able to "hand off" a generated display output to the compositor but might not be able to
+/// because it's not "ready" to receive such a frame. If that is the case, the graphics API can check for such a state
+/// and return `false` here, allowing `libobs` to skip rendering the output for the "current" display entirely.
+///
+/// In Direct3D11 the `DXGI_SWAP_EFFECT_FLIP_DISCARD` flip effect is used, which allows OBS Studio to render a preview
+/// into a buffer without having to care about the compositor. This is not possible in Metal as it's not the
+/// application that provides the output buffer, it's the compositor which provides a "drawable" surface. For each
+/// display there can only be a maximum of 3 drawables "in flight", a request for any consecutive drawable will stall
+/// the renderer.
+///
+/// There is currently no way to check for the amount of available drawables, which could be used to return `false`
+/// here and would allow `libobs` to skip output rendering on its current frame and try again on the next.
+///
+/// > Note: This check applies to the display associated with whichever "swap chain" might be "current" and is thus
+/// depends on swap chain state.
+@_cdecl("device_is_present_ready")
+public func device_is_present_ready(device: UnsafeRawPointer) -> Bool {
+    return true
+}
+
+/// Commits the current command buffer to schedule and execute the GPU commands encoded within it and waits until they
+/// have been scheduled.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// OBS Studio's renderer will call this function when it has set up all draw commands for a given "display". It is
+/// usually accompanied by a call to end the current scene just before and thus marks the end of commands for the
+/// current command buffer.
+@_cdecl("device_present")
+public func device_present(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    device.finishPendingCommands()
+}
+
+/// Commits the current command buffer to schedule and execute the GPU commands encoded within it and waits until they
+/// have been scheduled.
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// OBS Studio's renderer will call this function when it is finished setting up all draw commands for the video output
+/// texture, and also after it has used the GPU to encode a video output frame.
+@_cdecl("device_flush")
+public func device_flush(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    device.finishPendingCommands()
+}
+
+/// Sets the "current" cull mode to be used by the next draw call
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - mode: `libobs` cull mode identifier
+///
+/// Converts the cull mode provided by `libobs` into its appropriate ``MTLCullMode`` variant.
+@_cdecl("device_set_cull_mode")
+public func device_set_cull_mode(device: UnsafeRawPointer, mode: gs_cull_mode) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.cullMode = mode.mtlMode
+}
+
+/// Gets the "current" cull mode that was set up for the next draw call
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: `libobs` cull mode
+///
+/// Converts the ``MTLCullMode`` set up currently into its `libobs` variation
+@_cdecl("device_get_cull_mode")
+public func device_get_cull_mode(device: UnsafeRawPointer) -> gs_cull_mode {
+    let device: MetalDevice = unretained(device)
+
+    return device.renderState.cullMode.obsMode
+}
+
+/// Switches blending of the next draw operation with the contents of the "current" framebuffer.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - enable: `true` if contents should be blended, `false` otherwise
+///
+/// This function directly enables or disables blending for the first render target set up in the current pipeline.
+@_cdecl("device_enable_blending")
+public func device_enable_blending(device: UnsafeRawPointer, enable: Bool) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.pipelineDescriptor.colorAttachments[0].isBlendingEnabled = enable
+}
+
+/// Switches depth testing on the next draw operation with the contents of the current depth stencil buffer.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - enable: `true` if depth testing should be enabled, `false` otherwise
+///
+/// This function directly enables or disables depth texting for the depth stencil attachment set up in the current pipeline
+@_cdecl("device_enable_depth_test")
+public func device_enable_depth_test(device: UnsafeRawPointer, enable: Bool) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.depthStencilDescriptor.isDepthWriteEnabled = enable
+}
+
+/// Sets the read mask in the depth stencil descriptor set up in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - enable: `true` if the read mask should be `1`, `false` for a read mask of `0`
+///
+/// The `MTLDepthStencilDescriptor` can differentiate between a front facing stencil and a back facing stencil. As
+/// `libobs` does not make this distinction, both values will be set to the same value.
+@_cdecl("device_enable_stencil_test")
+public func device_enable_stencil_test(device: UnsafeRawPointer, enable: Bool) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.depthStencilDescriptor.frontFaceStencil.readMask = enable ? 1 : 0
+    device.renderState.depthStencilDescriptor.backFaceStencil.readMask = enable ? 1 : 0
+}
+
+/// Sets the write mask in the depth stencil descriptor set up in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - enable: `true` if the write mask should be `1`, `false` for a write mask of `0`
+///
+/// The `MTLDepthStencilDescriptor` can differentiate between a front facing stencil and a back facing stencil. As
+/// `libobs` does not make this distinction, both values will be set to the same value.
+@_cdecl("device_enable_stencil_write")
+public func device_enable_stencil_write(device: UnsafeRawPointer, enable: Bool) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.depthStencilDescriptor.frontFaceStencil.writeMask = enable ? 1 : 0
+    device.renderState.depthStencilDescriptor.backFaceStencil.writeMask = enable ? 1 : 0
+}
+
+/// Sets the color write mask for the render target set up in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - red: `true` if the red color channel should be written, `false` otherwise
+///   - green: `true` if the green color channel should be written, `false` otherwise
+///   - blue: `true` if the blue color channel should be written, `false` otherwise
+///   - alpha: `true` if the alpha channel should be written, `false` otherwise
+///
+/// The separate `bool` values are converted into an ``MTLColorWriteMask`` which is then set up on the first render
+/// target of the current pipeline.
+@_cdecl("device_enable_color")
+public func device_enable_color(device: UnsafeRawPointer, red: Bool, green: Bool, blue: Bool, alpha: Bool) {
+    let device: MetalDevice = unretained(device)
+
+    var colorMask = MTLColorWriteMask()
+
+    if red {
+        colorMask.insert(.red)
+    }
+
+    if green {
+        colorMask.insert(.green)
+    }
+
+    if blue {
+        colorMask.insert(.blue)
+    }
+
+    if alpha {
+        colorMask.insert(.alpha)
+    }
+
+    device.renderState.pipelineDescriptor.colorAttachments[0].writeMask = colorMask
+}
+
+/// Sets the depth compare function for the depth stencil descriptor to be used in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - test: `libobs` enum describing the depth compare function to use
+///
+/// The enum value provided by `libobs` is converted into a ``MTLCompareFunction``, which is then set directly as the
+/// compare function on the depth stencil descriptor.
+@_cdecl("device_depth_function")
+public func device_depth_function(device: UnsafeRawPointer, test: gs_depth_test) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.depthStencilDescriptor.depthCompareFunction = test.mtlFunction
+}
+
+/// Sets the stencil compare functions for the specified stencil side(s) on the depth stencil descriptor in the current
+/// pipeline.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - side: The stencil side(s) for which the compare function should be set up
+///   - test: `libobs` enum describing the stencil test function to use
+///
+/// The enum values provided by `libobs` are first checked for the stencil side, after which the compare function value
+/// itself is converted into a ``MTLCompareFunction``, which is then set directly as the compare function on the depth
+/// stencil descriptor.
+@_cdecl("device_stencil_function")
+public func device_stencil_function(device: UnsafeRawPointer, side: gs_stencil_side, test: gs_depth_test) {
+    let device: MetalDevice = unretained(device)
+
+    let stencilCompareFunction: (MTLCompareFunction, MTLCompareFunction)
+
+    if side == GS_STENCIL_FRONT {
+        stencilCompareFunction = (test.mtlFunction, .never)
+    } else if side == GS_STENCIL_BACK {
+        stencilCompareFunction = (.never, test.mtlFunction)
+    } else {
+        stencilCompareFunction = (test.mtlFunction, test.mtlFunction)
+    }
+
+    device.renderState.depthStencilDescriptor.frontFaceStencil.stencilCompareFunction = stencilCompareFunction.0
+    device.renderState.depthStencilDescriptor.backFaceStencil.stencilCompareFunction = stencilCompareFunction.1
+}
+
+/// Sets the stencil fail, depth fail, and depth pass operations for the specified stencil side(s) on the depth stencil
+/// descriptor for the current pipeline.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - side: The stencil side(s) for which the fail and pass functions should be set up
+///   - fail: `libobs` enum value describing the stencil fail operation
+///   - zfail: `libobs` enum value describing the depth fail operation
+///   - zpass: `libobs` enum value describing the depth pass operation
+///
+/// The enum values provided by `libobs` are first checked for the stencil side, after which the fail function values
+/// themselves are converted into their ``MTLCompareFunction`` variants, which are then set directly on the depth
+/// stencil descriptor.
+@_cdecl("device_stencil_op")
+public func device_stencil_op(
+    device: UnsafeRawPointer, side: gs_stencil_side, fail: gs_stencil_op_type, zfail: gs_stencil_op_type,
+    zpass: gs_stencil_op_type
+) {
+    let device: MetalDevice = unretained(device)
+
+    let stencilFailOperation: (MTLStencilOperation, MTLStencilOperation)
+    let depthFailOperation: (MTLStencilOperation, MTLStencilOperation)
+    let depthPassOperation: (MTLStencilOperation, MTLStencilOperation)
+
+    if side == GS_STENCIL_FRONT {
+        stencilFailOperation = (fail.mtlOperation, .keep)
+        depthFailOperation = (zfail.mtlOperation, .keep)
+        depthPassOperation = (zpass.mtlOperation, .keep)
+    } else if side == GS_STENCIL_BACK {
+        stencilFailOperation = (.keep, fail.mtlOperation)
+        depthFailOperation = (.keep, zfail.mtlOperation)
+        depthPassOperation = (.keep, zpass.mtlOperation)
+    } else {
+        stencilFailOperation = (fail.mtlOperation, fail.mtlOperation)
+        depthFailOperation = (zfail.mtlOperation, zfail.mtlOperation)
+        depthPassOperation = (zpass.mtlOperation, zpass.mtlOperation)
+    }
+
+    device.renderState.depthStencilDescriptor.frontFaceStencil.stencilFailureOperation = stencilFailOperation.0
+    device.renderState.depthStencilDescriptor.frontFaceStencil.depthFailureOperation = depthFailOperation.0
+    device.renderState.depthStencilDescriptor.frontFaceStencil.depthStencilPassOperation = depthPassOperation.0
+
+    device.renderState.depthStencilDescriptor.backFaceStencil.stencilFailureOperation = stencilFailOperation.1
+    device.renderState.depthStencilDescriptor.backFaceStencil.depthFailureOperation = depthFailOperation.1
+    device.renderState.depthStencilDescriptor.backFaceStencil.depthStencilPassOperation = depthPassOperation.1
+}
+
+/// Sets up the viewport for use in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - x: Origin X coordinate for the viewport
+///   - y: Origin Y coordinate for the viewport
+///   - width: Width of the viewport
+///   - height: Height of the viewport
+///
+/// The separate values for origin and dimension are converted into an ``MTLViewport`` which is then retained as the
+/// "current" viewport for later use when the pipeline is actually set up.
+@_cdecl("device_set_viewport")
+public func device_set_viewport(device: UnsafeRawPointer, x: Int32, y: Int32, width: Int32, height: Int32) {
+    let device: MetalDevice = unretained(device)
+
+    let viewPort = MTLViewport(
+        originX: Double(x),
+        originY: Double(y),
+        width: Double(width),
+        height: Double(height),
+        znear: 0.0,
+        zfar: 1.0
+    )
+
+    device.renderState.viewPort = viewPort
+}
+
+/// Gets the origin and dimensions of the viewport currently set up for use by the pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - rect: A pointer to a ``gs_rect`` struct in memory
+///
+/// The function is provided a pointer to a ``gs_struct`` instance in memory which can hold the x and y values for the
+/// origin and dimension of the viewport.
+///
+/// This function is usually called when some source needs to retain the current "state" of the pipeline (of which
+/// there can ever only be one) and overwrite the state with its own (in this case its own viewport). To be able to
+/// restore the prior state, the "current" state needs to be retrieved from the pipeline.
+@_cdecl("device_get_viewport")
+public func device_get_viewport(device: UnsafeRawPointer, rect: UnsafeMutablePointer<gs_rect>) {
+    let device: MetalDevice = unretained(device)
+
+    rect.pointee.x = Int32(device.renderState.viewPort.originX)
+    rect.pointee.y = Int32(device.renderState.viewPort.originY)
+    rect.pointee.cx = Int32(device.renderState.viewPort.width)
+    rect.pointee.cy = Int32(device.renderState.viewPort.height)
+}
+
+/// Sets up a scissor rect to be used by the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - rect: Pointer to a ``gs_rect`` struct in memory that contains origin and dimension of the scissor rect
+///
+/// The ``gs_rect`` is converted into a ``MTLScissorRect`` object before saving it in the "current" render state
+/// for use in the next draw call.
+@_cdecl("device_set_scissor_rect")
+public func device_set_scissor_rect(device: UnsafeRawPointer, rect: UnsafePointer<gs_rect>?) {
+    let device: MetalDevice = unretained(device)
+
+    if let rect {
+        device.renderState.scissorRect = rect.pointee.mtlScissorRect
+        device.renderState.scissorRectEnabled = true
+    } else {
+        device.renderState.scissorRect = nil
+        device.renderState.scissorRectEnabled = false
+    }
+}
+
+/// Sets up an orthographic projection matrix with the provided view frustum
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - left: Left edge of view frustum on the near plane
+///   - right: Right edge of view frustum on the near plane
+///   - top: Top edge of view frustum on the near plane
+///   - bottom: Bottom edge of view frustum on the near plane
+///   - near: Distance of near plane on the Z axis
+///   - far: Distance of far plane on the Z axis
+@_cdecl("device_ortho")
+public func device_ortho(
+    device: UnsafeRawPointer, left: Float, right: Float, top: Float, bottom: Float, near: Float, far: Float
+) {
+    let device: MetalDevice = unretained(device)
+
+    let rml = right - left
+    let bmt = bottom - top
+    let fmn = far - near
+
+    device.renderState.projectionMatrix = matrix_float4x4(
+        rows: [
+            SIMD4((2.0 / rml), 0.0, 0.0, 0.0),
+            SIMD4(0.0, (2.0 / -bmt), 0.0, 0.0),
+            SIMD4(0.0, 0.0, (1 / fmn), 0.0),
+            SIMD4((left + right) / -rml, (bottom + top) / bmt, near / -fmn, 1.0),
+        ]
+    )
+}
+
+/// Sets up a perspective projection matrix with the provided view frustum
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - left: Left edge of view frustum on the near plane
+///   - right: Right edge of view frustum on the near plane
+///   - top: Top edge of view frustum on the near plane
+///   - bottom: Bottom edge of view frustum on the near plane
+///   - near: Distance of near plane on the Z axis
+///   - far: Distance of far plane on the Z axis
+@_cdecl("device_frustum")
+public func device_frustum(
+    device: UnsafeRawPointer, left: Float, right: Float, top: Float, bottom: Float, near: Float, far: Float
+) {
+    let device: MetalDevice = unretained(device)
+
+    let rml = right - left
+    let tmb = top - bottom
+    let fmn = far - near
+
+    device.renderState.projectionMatrix = matrix_float4x4(
+        columns: (
+            SIMD4(((2 * near) / rml), 0.0, 0.0, 0.0),
+            SIMD4(0.0, ((2 * near) / tmb), 0.0, 0.0),
+            SIMD4(((left + right) / rml), ((top + bottom) / tmb), (-far / fmn), -1.0),
+            SIMD4(0.0, 0.0, (-(far * near) / fmn), 0.0)
+        )
+    )
+}
+
+/// Requests the current projection matrix to be pushed into a projection stack
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// OBS Studio's renderer works with the assumption of one big "current" state stack, which requires the entire state
+/// to be changed to meet different rendering requirements. Part of this state is the current projection matrix, which
+/// might need to be replaced temporarily. This function will be called when another projection matrix will be set up
+/// to allow for its restoration later.
+@_cdecl("device_projection_push")
+public func device_projection_push(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.projections.append(device.renderState.projectionMatrix)
+}
+
+/// Requests the most recently pushed projection matrix to be removed from the stack and set up as the new current
+/// matrix
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// OBS Studio's renderer works with the assumption of one big "current" state stack. This requires some elements of
+/// this state to be temporarily retained before reinstating them after. This function will reinstate the most recently
+/// added matrix as the new "current" matrix.
+@_cdecl("device_projection_pop")
+public func device_projection_pop(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    device.renderState.projectionMatrix = device.renderState.projections.removeLast()
+}
+
+/// Checks whether the current display is capable of displaying high dynamic range content.
+///
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - monitor: Opaque pointer of a platform-dependent monitor identifier
+/// - Returns: `true` if the display is capable of displaying high dynamic range content, `false` otherwise
+///
+/// On macOS this capability is described by the ``NSScreen/maximumPotentialExtendedDynamicRangeColorComponentValue``
+/// property, which can be checked using the  ``NSWindow/screen`` property after retrieving the ``NSView/window``
+/// property.
+@_cdecl("device_is_monitor_hdr")
+public func device_is_monitor_hdr(device: UnsafeRawPointer, monitor: UnsafeRawPointer) -> Bool {
+    let device: MetalDevice = unretained(device)
+
+    guard let swapChain = device.renderState.swapChain else {
+        return false
+    }
+
+    return swapChain.edrHeadroom > 1.0
+}

+ 269 - 0
libobs-metal/metal-swapchain.swift

@@ -0,0 +1,269 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import AppKit
+import Foundation
+
+/// Creates a ``OBSSwapChain`` instance for use as a pseudo swap chain implementation to be shared with `libobs`
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - data: Pointer to platform-specific `gs_init_data` struct
+/// - Returns: Opaque pointer to a new ``OBSSwapChain`` on success or `nil` on error
+///
+/// As interaction with UI elements needs to happen on the main thread of macOS, this function is marked with
+/// `@MainActor`. This is also necessary because ``OBSSwapChain/updateView`` itself interacts with the ``NSView``
+/// instance passed via the `data` argument and also has to occur on the main thread.
+///
+/// As applications cannot manage their own swap chain on macOS, the ``OBSSwapChain`` class merely wraps the
+/// management of the ``CAMetalLayer`` that will be associated with the ``NSView`` and handles the drawables used to
+/// render their contents.
+///
+/// > Important: This function can only be called from the main thread.
+@MainActor
+@_cdecl("device_swapchain_create")
+public func device_swapchain_create(device: UnsafeMutableRawPointer, data: UnsafePointer<gs_init_data>)
+    -> OpaquePointer?
+{
+    let device: MetalDevice = unretained(device)
+
+    let view = data.pointee.window.view.takeUnretainedValue() as! NSView
+    let size = MTLSize(
+        width: Int(data.pointee.cx),
+        height: Int(data.pointee.cy),
+        depth: 0
+    )
+
+    guard let swapChain = OBSSwapChain(device: device, size: size, colorSpace: data.pointee.format) else { return nil }
+
+    swapChain.updateView(view)
+
+    device.swapChainQueue.sync {
+        device.swapChains.append(swapChain)
+    }
+
+    return swapChain.getRetained()
+}
+
+/// Updates the internal size parameter and dimension of the ``CAMetalLayer`` managed by the ``OBSSwapChain`` instance
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - width: Width to update the layer's dimensions to
+///   - height: Height to update the layer's dimensions to
+///
+/// As the relationship between the ``CAMetalLayer`` and the ``NSView`` it is associated with is managed indirectly,
+/// the metal layer cannot directly react to size changes (even though it would be possible to do so). Instead
+/// ``AppKit`` will report a size change to the application, which will be picked up by Qt, who will emit a size
+/// change event on the main loop, which will update internal state of the ``OBSQTDisplay`` class. These changes are
+/// asynchronously picked up by `libobs` render loop, which will then call this function.
+@_cdecl("device_resize")
+public func device_resize(device: UnsafeMutableRawPointer, width: UInt32, height: UInt32) {
+    let device: MetalDevice = unretained(device)
+
+    guard let swapChain = device.renderState.swapChain else {
+        return
+    }
+
+    swapChain.resize(.init(width: Int(width), height: Int(height), depth: 0))
+}
+
+/// This function does nothing on Metal
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///
+/// The intended purpose of this function is to update the render target in the "current" swap chain with the color
+/// space of its "display" and thus pick up changes in color spaces between different screens.
+///
+/// On macOS this just requires updating the EDR headroom for the screen the view might be associated with, as the
+/// actual color space and EDR capabilities are evaluated on every render loop.
+///
+/// > Important: This function can only be called from the main thread.
+@_cdecl("device_update_color_space")
+public func device_update_color_space(device: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+
+    guard device.renderState.swapChain != nil else {
+        return
+    }
+
+    nonisolated(unsafe) let swapChain = device.renderState.swapChain!
+
+    Task { @MainActor in
+        swapChain.updateEdrHeadroom()
+    }
+}
+
+/// Gets the dimensions of the ``CAMetalLayer`` managed by the ``OBSSwapChain`` instance set up in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - cx: Pointer to memory for the width of the layer
+///   - cy: Pointer to memory for the height of the layer
+@_cdecl("device_get_size")
+public func device_get_size(
+    device: UnsafeMutableRawPointer, cx: UnsafeMutablePointer<UInt32>, cy: UnsafeMutablePointer<UInt32>
+) {
+    let device: MetalDevice = unretained(device)
+
+    guard let swapChain = device.renderState.swapChain else {
+        cx.pointee = 0
+        cy.pointee = 0
+        return
+    }
+
+    cx.pointee = UInt32(swapChain.viewSize.width)
+    cy.pointee = UInt32(swapChain.viewSize.height)
+}
+
+/// Gets the width of the ``CAMetalLayer`` managed by the ``OBSSwapChain`` instance set up in the current pipeline
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Width of the layer
+@_cdecl("device_get_width")
+public func device_get_width(device: UnsafeRawPointer) -> UInt32 {
+    let device: MetalDevice = unretained(device)
+
+    guard let swapChain = device.renderState.swapChain else {
+        return 0
+    }
+
+    return UInt32(swapChain.viewSize.width)
+}
+
+/// Gets the height of the ``CAMetalLayer`` managed by the ``OBSSwapChain`` instance set up in the current pipeline
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Height of the layer
+@_cdecl("device_get_height")
+public func device_get_height(device: UnsafeRawPointer) -> UInt32 {
+    let device: MetalDevice = unretained(device)
+
+    guard let swapChain = device.renderState.swapChain else {
+        return 0
+    }
+
+    return UInt32(swapChain.viewSize.height)
+}
+
+/// Sets up the ``OBSSwapChain`` for use in the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - swap: Opaque pointer to ``OBSSwapChain`` instance shared with `libobs`
+///
+/// The first call of this function in any render loop marks the "begin" of OBS Studio's display render stage. There
+/// will only ever be one "current" swap chain in use by `libobs` and there is no dedicated call to "reset" or unload
+/// the current swap chain, instead a new swap chain is loaded or the "scene end" function is called.
+@_cdecl("device_load_swapchain")
+public func device_load_swapchain(device: UnsafeRawPointer, swap: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+    let swapChain: OBSSwapChain = unretained(swap)
+
+    if swapChain.edrHeadroom > 1.0 {
+        var videoInfo: obs_video_info = obs_video_info()
+        obs_get_video_info(&videoInfo)
+
+        let videoColorSpace = videoInfo.colorspace
+
+        switch videoColorSpace {
+        case VIDEO_CS_2100_PQ:
+            if swapChain.colorRange != .hdrPQ {
+                // TODO: Investigate whether it's viable to use PQ or HLG tone mapping for the preview
+                // Use the following code to enable it for either:
+                // 2100 PQ:
+                //  let maxLuminance = obs_get_video_hdr_nominal_peak_level()
+                //  swapChain.layer.edrMetadata = .hdr10(
+                //    minLuminance: 0.0001, maxLuminance: maxLuminance, opticalOutputScale: 10000)
+                // HLG:
+                //  swapChain.layer.edrMetadata = .hlg
+                swapChain.layer.pixelFormat = .rgba16Float
+                swapChain.layer.colorspace = CGColorSpace(name: CGColorSpace.extendedLinearSRGB)
+                swapChain.layer.wantsExtendedDynamicRangeContent = true
+                swapChain.layer.edrMetadata = nil
+                swapChain.colorRange = .hdrPQ
+                swapChain.renderTarget = nil
+            }
+        case VIDEO_CS_2100_HLG:
+            if swapChain.colorRange != .hdrHLG {
+                swapChain.layer.pixelFormat = .rgba16Float
+                swapChain.layer.colorspace = CGColorSpace(name: CGColorSpace.extendedLinearSRGB)
+                swapChain.layer.wantsExtendedDynamicRangeContent = true
+                swapChain.layer.edrMetadata = nil
+                swapChain.colorRange = .hdrHLG
+                swapChain.renderTarget = nil
+            }
+        default:
+            if swapChain.colorRange != .sdr {
+                swapChain.layer.pixelFormat = .bgra8Unorm_srgb
+                swapChain.layer.colorspace = CGColorSpace(name: CGColorSpace.sRGB)
+                swapChain.layer.wantsExtendedDynamicRangeContent = false
+                swapChain.layer.edrMetadata = nil
+                swapChain.colorRange = .sdr
+                swapChain.renderTarget = nil
+            }
+        }
+    } else {
+        if swapChain.colorRange != .sdr {
+            swapChain.layer.pixelFormat = .bgra8Unorm_srgb
+            swapChain.layer.colorspace = CGColorSpace(name: CGColorSpace.sRGB)
+            swapChain.layer.wantsExtendedDynamicRangeContent = false
+            swapChain.layer.edrMetadata = nil
+            swapChain.colorRange = .sdr
+            swapChain.renderTarget = nil
+        }
+    }
+
+    switch swapChain.colorRange {
+    case .hdrHLG, .hdrPQ:
+        device.renderState.gsColorSpace = GS_CS_709_EXTENDED
+        device.renderState.useSRGBGamma = false
+    case .sdr:
+        device.renderState.gsColorSpace = GS_CS_SRGB
+        device.renderState.useSRGBGamma = true
+    }
+
+    if let renderTarget = swapChain.renderTarget {
+        device.renderState.renderTarget = renderTarget
+    } else {
+        let descriptor = MTLTextureDescriptor.texture2DDescriptor(
+            pixelFormat: swapChain.layer.pixelFormat,
+            width: Int(swapChain.layer.drawableSize.width),
+            height: Int(swapChain.layer.drawableSize.height),
+            mipmapped: false)
+
+        descriptor.usage = [.renderTarget]
+
+        guard let renderTarget = MetalTexture(device: device, descriptor: descriptor) else {
+            return
+        }
+
+        swapChain.renderTarget = renderTarget
+        device.renderState.renderTarget = renderTarget
+    }
+
+    device.renderState.depthStencilAttachment = nil
+    device.renderState.isRendertargetChanged = true
+    device.renderState.isInDisplaysRenderStage = true
+
+    device.renderState.swapChain = swapChain
+}
+
+/// Requests deinitialization of the ``OBSSwapChain`` instance shared with `libobs`
+/// - Parameter texture: Opaque pointer to ``OBSSwapChain`` instance shared with `libobs`
+///
+/// The ownership of the shared pointer is transferred into this function and the instance is placed under Swift's
+/// memory management again.
+@_cdecl("gs_swapchain_destroy")
+public func gs_swapchain_destroy(swapChain: UnsafeMutableRawPointer) {
+    let swapChain = retained(swapChain) as OBSSwapChain
+
+    swapChain.discard = true
+}

+ 528 - 0
libobs-metal/metal-texture2d.swift

@@ -0,0 +1,528 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Creates a two-dimensional ``MetalTexture`` instance with the specified usage options and the raw image data (if
+/// provided)
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - width: Desired width of the texture
+///   - height: Desired height of the texture
+///   - color_format: Desired color format of the texture as described by `gs_color_format`
+///   - levels: Amount of mip map levels to generate for the texture
+///   - data: Optional pointer to raw pixel data per mip map level
+///   - flags: Texture resource use information encoded as `libobs` bitfield
+/// - Returns: Opaque pointer to a created ``MetalTexture`` instance or a `nil` pointer on error
+///
+/// This function will create a new ``MTLTexture`` wrapped within a ``MetalTexture`` class and also upload any pixel
+/// data if non-`nil` pointers have been provided via the `data` argument.
+///
+/// > Important: If mipmap generation is requested, execution will be blocked by waiting for the blit command encoder
+/// to generate the mipmaps.
+@_cdecl("device_texture_create")
+public func device_texture_create(
+    device: UnsafeRawPointer, width: UInt32, height: UInt32, color_format: gs_color_format, levels: UInt32,
+    data: UnsafePointer<UnsafePointer<UInt8>?>?, flags: UInt32
+) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    let descriptor = MTLTextureDescriptor.init(
+        type: .type2D,
+        width: width,
+        height: height,
+        depth: 1,
+        colorFormat: color_format,
+        levels: levels,
+        flags: flags
+    )
+
+    guard let descriptor, let texture = MetalTexture(device: device, descriptor: descriptor) else {
+        return nil
+    }
+
+    if let data {
+        texture.upload(data: data, mipmapLevels: descriptor.mipmapLevelCount)
+    }
+
+    return texture.getRetained()
+}
+
+/// Creates a ``MetalTexture`` instance for a cube texture with the specified usage options and the raw image data (if provided)
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - size: Desized edge length for the cube
+///   - color_format: Desired color format of the texture as described by `gs_color_format`
+///   - levels: Amount of mip map levels to generate for the texture
+///   - data: Optional pointer to raw pixel data per mip map level
+///   - flags: Texture resource use information encoded as `libobs` bitfield
+/// - Returns: Opaque pointer to created ``MetalTexture`` instance or a `nil` pointer on error
+///
+/// This function will create a new ``MTLTexture`` wrapped within a ``MetalTexture`` class and also upload any pixel
+/// data if non-`nil` pointers have
+/// been provided via the `data` argument.
+///
+/// > Important: If mipmap generation is requested, execution will be blocked by waiting for the blit command encoder
+/// to generate the mipmaps.
+@_cdecl("device_cubetexture_create")
+public func device_cubetexture_create(
+    device: UnsafeRawPointer, size: UInt32, color_format: gs_color_format, levels: UInt32,
+    data: UnsafePointer<UnsafePointer<UInt8>?>?, flags: UInt32
+) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    let descriptor = MTLTextureDescriptor.init(
+        type: .typeCube,
+        width: size,
+        height: size,
+        depth: 1,
+        colorFormat: color_format,
+        levels: levels,
+        flags: flags
+    )
+
+    guard let descriptor, let texture = MetalTexture(device: device, descriptor: descriptor) else {
+        return nil
+    }
+
+    if let data {
+        texture.upload(data: data, mipmapLevels: descriptor.mipmapLevelCount)
+    }
+
+    return texture.getRetained()
+}
+
+/// Requests deinitialization of the ``MetalTexture`` instance shared with `libobs`
+/// - Parameter texture: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///
+/// The ownership of the shared pointer is transferred into this function and the instance is placed under Swift's
+/// memory management again.
+@_cdecl("gs_texture_destroy")
+public func gs_texture_destroy(texture: UnsafeRawPointer) {
+    let _ = retained(texture) as MetalTexture
+}
+
+/// Gets the type of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter texture: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Texture type identified by `gs_texture_type` enum value
+///
+/// > Warning: As `libobs` has no enum value for "invalid texture type", there is no way for this function to signal
+/// that the wrapped texture has an incompatible ``MTLTextureType``. Instead of crashing the program (which would
+/// avoid undefined behavior), this function will return the 2D texture type value instead, which is incorrect, but is
+/// more in line with how OBS Studio handles undefined behavior.
+@_cdecl("device_get_texture_type")
+public func device_get_texture_type(texture: UnsafeRawPointer) -> gs_texture_type {
+    let texture: MetalTexture = unretained(texture)
+
+    return texture.texture.textureType.gsTextureType ?? GS_TEXTURE_2D
+}
+
+/// Requests the ``MetalTexture`` instance to be loaded as one of the current pipeline's fragment attachments in the
+/// specified texture slot
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///   - unit: Texture slot for fragment attachment
+///
+/// OBS Studio expects pipelines to support fragment attachments for textures and samplers up to the amount defined in
+/// the `GS_MAX_TEXTURES` preprocessor directive. The order of this calls can be arbitrary, so at any point in time a
+/// request to load a texture into slot "5" can take place, even if slots 0 to 4 are empty.
+@_cdecl("device_load_texture")
+public func device_load_texture(device: UnsafeRawPointer, tex: UnsafeRawPointer, unit: UInt32) {
+    let device: MetalDevice = unretained(device)
+    let texture: MetalTexture = unretained(tex)
+
+    device.renderState.textures[Int(unit)] = texture.texture
+}
+
+/// Requests an sRGB variant of a ``MetalTexture`` instance to be set as one of the current pipeline's fragment
+/// attachments in the specified texture slot.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///   - unit: Texture slot for fragment attachment
+/// OBS Studio expects pipelines to support fragment attachments for textures and samplers up to the amount defined in
+/// the `GS_MAX_TEXTURES` preprocessor directive. The order of this calls can be arbitrary, so at any point in time a
+/// request to load a texture into slot "5" can take place, even if slots 0 to 4 are empty.
+///
+/// > Important: This variant of the texture load functions expects a texture whose color values are already sRGB gamma
+/// encoded and thus also expects that the color values used in the fragment shader will have been automatically
+/// decoded into linear gamma. If the ``MetalTexture`` instance has no dedicated ``MetalTexture/sRGBtexture`` instance,
+/// it will use the normal ``MetalTexture/texture`` instance instead.
+@_cdecl("device_load_texture_srgb")
+public func device_load_texture_srgb(device: UnsafeRawPointer, tex: UnsafeRawPointer, unit: UInt32) {
+    let device: MetalDevice = unretained(device)
+    let texture: MetalTexture = unretained(tex)
+
+    if texture.sRGBtexture != nil {
+        device.renderState.textures[Int(unit)] = texture.sRGBtexture!
+    } else {
+        device.renderState.textures[Int(unit)] = texture.texture
+    }
+}
+
+/// Copies image data from a region in the source ``MetalTexture`` into a destination ``MetalTexture`` at the provided
+/// origin
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - dst: Opaque pointer to ``MetalTexture`` instance shared with `libobs`, used as destination for the copy operation
+///   - dst_x: X coordinate of the origin in the destination texture
+///   - dst_y: Y coordinate of the origin in the destination texture
+///   - src: Opaque pointer to ``MetalTexture`` instance shared with `libobs`, used as source for the copy operation
+///   - src_x: X coordinate of the origin in the source texture
+///   - src_y: Y coordinate of the origin in the source texture
+///   - src_w: Width of the region in the source texture
+///   - src_h: Height of the region in the source texture
+///
+/// This function will fail if the destination texture's dimensions aren't large enough to hold the region copied from
+/// the source texture. This check will use the desired origin within the destination texture and the region's size
+/// into account and checks whether the total dimensions of the destination are large enough (starting at the
+/// destination origin) to hold the source's region.
+///
+/// > Important: Execution will **not** be blocked, the copy operation will be committed to the command queue and
+/// executed at some point after this function returns.
+@_cdecl("device_copy_texture_region")
+public func device_copy_texture_region(
+    device: UnsafeRawPointer, dst: UnsafeRawPointer, dst_x: UInt32, dst_y: UInt32, src: UnsafeRawPointer, src_x: UInt32,
+    src_y: UInt32, src_w: UInt32, src_h: UInt32
+) {
+    let device: MetalDevice = unretained(device)
+    let source: MetalTexture = unretained(src)
+    let destination: MetalTexture = unretained(dst)
+
+    var sourceRegion = MTLRegion(
+        origin: .init(x: Int(src_x), y: Int(src_y), z: 0),
+        size: .init(width: Int(src_w), height: Int(src_h), depth: 1)
+    )
+
+    let destinationRegion = MTLRegion(
+        origin: .init(x: Int(dst_x), y: Int(dst_y), z: 0),
+        size: .init(width: destination.texture.width, height: destination.texture.height, depth: 1)
+    )
+
+    if sourceRegion.size.width == 0 {
+        sourceRegion.size.width = source.texture.width - sourceRegion.origin.x
+    }
+
+    if sourceRegion.size.height == 0 {
+        sourceRegion.size.height = source.texture.height - sourceRegion.origin.y
+    }
+
+    guard
+        destinationRegion.size.width - destinationRegion.origin.x > sourceRegion.size.width
+            && destinationRegion.size.height - destinationRegion.origin.y > sourceRegion.size.height
+    else {
+        OBSLog(
+            .error,
+            "device_copy_texture_region: Destination texture \(destinationRegion.size) is not large enough to hold source region (\(sourceRegion.size) at origin \(destinationRegion.origin)"
+        )
+        return
+    }
+
+    do {
+        try device.copyTextureRegion(
+            source: source,
+            sourceRegion: sourceRegion,
+            destination: destination,
+            destinationRegion: destinationRegion)
+    } catch let error as MetalError.MTLDeviceError {
+        OBSLog(.error, "device_clear: \(error.description)")
+    } catch {
+        OBSLog(.error, "device_clear: Unknown error occurred")
+    }
+}
+
+/// Copies the image data from the source ``MetalTexture`` into the destination ``MetalTexture``
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - dst: Opaque pointer to ``MetalTexture`` instance shared with `libobs`, used as destination for the copy
+///     operation
+///   - src: Opaque pointer to ``MetalTexture`` instance shared with `libobs`, used as source for the copy operation
+///
+/// > Warning: This function requires that the source and destination texture dimensions are identical, otherwise the
+/// copy operation will fail.
+///
+/// > Important: Execution will **not** be blocked, the copy operation will be committed to the command queue and
+/// executed at some point after this function returns.
+@_cdecl("device_copy_texture")
+public func device_copy_texture(device: UnsafeRawPointer, dst: UnsafeRawPointer, src: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+    let source: MetalTexture = unretained(src)
+    let destination: MetalTexture = unretained(dst)
+
+    do {
+        try device.copyTexture(source: source, destination: destination)
+    } catch let error as MetalError.MTLDeviceError {
+        OBSLog(.error, "device_clear: \(error.description)")
+    } catch {
+        OBSLog(.error, "device_clear: Unknown error occurred")
+    }
+}
+
+/// Copies the image data from the source ``MetalTexture`` into the destination ``MetalTexture`` and blocks execution
+/// until the copy operation has finished.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - dst: Opaque pointer to ``MetalStageBuffer`` instance shared with `libobs`, used as destination for the copy
+///     operation
+///   - src: Opaque pointer to ``MetalTexture`` instance shared with `libobs`, used as source for the copy operation
+///
+/// > Important: Execution will be blocked by waiting for the blit command encoder to finish the copy operation.
+@_cdecl("device_stage_texture")
+public func device_stage_texture(device: UnsafeRawPointer, dst: UnsafeRawPointer, src: UnsafeRawPointer) {
+    let device: MetalDevice = unretained(device)
+    let source: MetalTexture = unretained(src)
+    let destination: MetalStageBuffer = unretained(dst)
+
+    do {
+        try device.stageTextureToBuffer(source: source, destination: destination)
+    } catch let error as MetalError.MTLDeviceError {
+        OBSLog(.error, "device_clear: \(error.description)")
+    } catch {
+        OBSLog(.error, "device_clear: Unknown error occurred")
+    }
+}
+
+/// Gets the width of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Width of the texture
+@_cdecl("gs_texture_get_width")
+public func device_texture_get_width(tex: UnsafeRawPointer) -> UInt32 {
+    let texture: MetalTexture = unretained(tex)
+
+    return UInt32(texture.texture.width)
+}
+
+/// Gets the height of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Height of the texture
+@_cdecl("gs_texture_get_height")
+public func device_texture_get_height(tex: UnsafeRawPointer) -> UInt32 {
+    let texture: MetalTexture = unretained(tex)
+
+    return UInt32(texture.texture.height)
+}
+
+/// Gets the color format of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Color format as defined by the `gs_color_format` enumeration
+@_cdecl("gs_texture_get_color_format")
+public func gs_texture_get_color_format(tex: UnsafeRawPointer) -> gs_color_format {
+    let texture: MetalTexture = unretained(tex)
+
+    return texture.texture.pixelFormat.gsColorFormat
+}
+
+/// Allocates memory for an update of the texture's image data wrapped by the ``MetalTexture`` instance.
+/// - Parameters:
+///   - tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///   - ptr: Pointer to memory for the raw image data
+///   - linesize: Pointer to integer for the row size of the texture
+/// - Returns: `true` if the mapping memory was allocated successfully, `false` otherwise
+///
+/// Metal does not provide "map" and "unmap" operations as they exist in Direct3D11, as resource management and
+/// synchronization needs to be handled explicitly by the application. Thus "mapping" just means that enough memory for
+/// raw image data is allocated and an unmanaged pointer to that memory is shared with `libobs` for writing the image data.
+///
+/// To ensure that the data written into the memory provided by this function is actually used to update the texture,
+/// the corresponding function `gs_texture_unmap` needs to be used.
+///
+/// > Important: This function can only be used to **push** new image data into the texture. To _pull_ image data from
+/// the texture, use a stage surface instead.
+@_cdecl("gs_texture_map")
+public func gs_texture_map(
+    tex: UnsafeRawPointer, ptr: UnsafeMutablePointer<UnsafeMutableRawPointer>, linesize: UnsafeMutablePointer<UInt32>
+) -> Bool {
+    let texture: MetalTexture = unretained(tex)
+
+    guard texture.texture.textureType == .type2D, let device = texture.device else {
+        return false
+    }
+
+    let stageBuffer: MetalStageBuffer
+
+    if texture.stageBuffer == nil
+        || (texture.stageBuffer!.width != texture.texture.width
+            && texture.stageBuffer!.height != texture.texture.height)
+    {
+        guard
+            let buffer = MetalStageBuffer(
+                device: device,
+                width: texture.texture.width,
+                height: texture.texture.height,
+                format: texture.texture.pixelFormat
+            )
+        else {
+            OBSLog(.error, "gs_texture_map: Unable to create MetalStageBuffer for mapping texture")
+            return false
+        }
+
+        texture.stageBuffer = buffer
+        stageBuffer = buffer
+    } else {
+        stageBuffer = texture.stageBuffer!
+    }
+
+    ptr.pointee = stageBuffer.buffer.contents()
+    linesize.pointee = UInt32(stageBuffer.width * stageBuffer.format.bytesPerPixel!)
+
+    return true
+}
+
+/// Writes back raw image data into the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///
+/// This function needs to be used in tandem with `gs_texture_map`, which allocates memory for raw image data that
+/// should be used in an update of the wrapped `MTLTexture`. This function will then actually replace the image data
+/// in the texture with that raw image data and deallocate the memory that was allocated during `gs_texture_map`.
+@_cdecl("gs_texture_unmap")
+public func gs_texture_unmap(tex: UnsafeRawPointer) {
+    let texture: MetalTexture = unretained(tex)
+
+    guard texture.texture.textureType == .type2D, let stageBuffer = texture.stageBuffer, let device = texture.device
+    else {
+        return
+    }
+
+    do {
+        try device.stageBufferToTexture(source: stageBuffer, destination: texture)
+    } catch let error as MetalError.MTLDeviceError {
+        OBSLog(.error, "gs_texture_unmap: \(error.description)")
+    } catch {
+        OBSLog(.error, "gs_texture_unmap: Unknown error occurred")
+    }
+}
+
+/// Gets an opaque pointer to the ``MTLTexture`` instance wrapped by the provided ``MetalTexture`` instance
+/// - Parameter tex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Opaque pointer to ``MTLTexture`` instance
+///
+/// > Important: The opaque pointer returned by this function is **unretained**, which means that the ``MTLTexture``
+/// instance it refers to might be deinitialized at any point when no other Swift code holds a strong reference to it.
+@_cdecl("gs_texture_get_obj")
+public func gs_texture_get_obj(tex: UnsafeRawPointer) -> OpaquePointer {
+    let texture: MetalTexture = unretained(tex)
+
+    let unretained = Unmanaged.passUnretained(texture.texture).toOpaque()
+
+    return OpaquePointer(unretained)
+}
+
+/// Requests deinitialization of the ``MetalTexture`` instance shared with `libobs`
+/// - Parameter cubetex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///
+/// The ownership of the shared pointer is transferred into this function and the instance is placed under
+/// Swift's memory management again.
+@_cdecl("gs_cubetexture_destroy")
+public func gs_cubetexture_destroy(cubetex: UnsafeRawPointer) {
+    let _ = retained(cubetex) as MetalTexture
+}
+
+/// Gets the edge size of the cube texture wrapped by the ``MetalTexture`` instance
+/// - Parameter cubetex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Edge size of the cube
+@_cdecl("gs_cubetexture_get_size")
+public func gs_cubetexture_get_size(cubetex: UnsafeRawPointer) -> UInt32 {
+    let texture: MetalTexture = unretained(cubetex)
+
+    return UInt32(texture.texture.width)
+}
+
+/// Gets the color format of the cube texture wrapped by the ``MetalTexture`` instance
+/// - Parameter cubetex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Color format value
+@_cdecl("gs_cubetexture_get_color_format")
+public func gs_cubetexture_get_color_format(cubetex: UnsafeRawPointer) -> gs_color_format {
+    let texture: MetalTexture = unretained(cubetex)
+
+    return texture.texture.pixelFormat.gsColorFormat
+}
+
+/// Gets the device capability state for shared textures
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Always `true`
+///
+/// While Metal provides a specific "shared texture" type, OBS Studio understands this to mean "textures shared between
+/// processes", which is usually achieved using ``IOSurface`` references on macOS. Metal textures can be created from
+/// these references, so this is always `true`.
+@_cdecl("device_shared_texture_available")
+public func device_shared_texture_available(device: UnsafeRawPointer) -> Bool {
+    return true
+}
+
+/// Creates a ``MetalTexture`` wrapping an ``MTLTexture`` that was created using the provided ``IOSurface`` reference.
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - iosurf: ``IOSurface`` reference to use as the image data source for the texture
+/// - Returns: An opaque pointer to a ``MetalTexture`` instance on success, `nil` otherwise
+///
+/// If the provided ``IOSurface`` uses a video image format that has no compatible ``Metal`` pixel format, creation of
+/// the texture will fail.
+@_cdecl("device_texture_create_from_iosurface")
+public func device_texture_create_from_iosurface(device: UnsafeRawPointer, iosurf: IOSurfaceRef) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    let texture = MetalTexture(device: device, surface: iosurf)
+
+    guard let texture else {
+        return nil
+    }
+
+    return texture.getRetained()
+}
+
+/// Replaces the current ``IOSurface``-based ``MTLTexture`` wrapped by the provided ``MetalTexture`` instance with a
+/// new instance.
+/// - Parameters:
+///   - texture: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///   - iosurf: ``IOSurface`` reference to use as the image data source for the texture
+/// - Returns: An opaque pointer to a ``MetalTexture`` instance on success, `nil` otherwise
+///
+/// The "rebind" mentioned in the function name is limited to the ``MTLTexture`` instance wrapped inside the
+/// ``MetalTexture`` instance, as textures are immutable objects (but their underlying data is mutable). This allows
+/// `libobs` to hold onto the same opaque ``MetalTexture`` pointer even though the backing surface might have changed.
+@_cdecl("gs_texture_rebind_iosurface")
+public func gs_texture_rebind_iosurface(texture: UnsafeRawPointer, iosurf: IOSurfaceRef) -> Bool {
+    let texture: MetalTexture = unretained(texture)
+
+    return texture.rebind(surface: iosurf)
+}
+
+/// Creates a new ``MetalTexture`` instance with an opaque shared texture "handle"
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///   - handle: Arbitrary handle value that needs to be reinterpreted into the correct platform specific shared
+///     reference type
+/// - Returns: An opaque pointer to a ``MetalTexture`` instance on success, `nil` otherwise
+///
+/// The "handle" is a generalised argument used on all platforms and needs to be converted into a platform-specific
+/// type before the "shared" texture can be created. In case of macOS this means converting the unsigned integer into
+/// a ``IOSurface`` address.
+///
+/// > Warning: As the handle is a 32-bit integer, this can break on 64-bit systems if the ``IOSurface`` pointer
+/// address does not fit into a 32-bit number.
+@_cdecl("device_texture_open_shared")
+public func device_texture_open_shared(device: UnsafeRawPointer, handle: UInt32) -> OpaquePointer? {
+    if let reference = IOSurfaceLookupFromMachPort(handle) {
+        let texture = device_texture_create_from_iosurface(device: device, iosurf: reference)
+
+        return texture
+    } else {
+        return nil
+    }
+}

+ 113 - 0
libobs-metal/metal-texture3d.swift

@@ -0,0 +1,113 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Creates a three-dimensional ``MetalTexture`` instance with the specified usage options and the raw image data
+/// (if provided)
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - size: Desired size of the texture
+///   - color_format: Desired color format of the texture as described by `gs_color_format`
+///   - levels: Amount of mip map levels to generate for the texture
+///   - data: Optional pointer to raw pixel data per mip map level
+///   - flags: Texture resource use information encoded as `libobs` bitfield
+/// - Returns: Opaque pointer to a created ``MetalTexture`` instance or a `NULL` pointer on error
+///
+/// This function will create a new ``MTLTexture`` wrapped within a ``MetalTexture`` class and also upload any pixel
+/// data if non-`NULL` pointers have been provided via the `data` argument.
+///
+/// > Important: If mipmap generation is requested, execution will be blocked by waiting for the blit command encoder
+/// to generate the mipmaps.
+@_cdecl("device_voltexture_create")
+public func device_voltexture_create(
+    device: UnsafeRawPointer, width: UInt32, height: UInt32, depth: UInt32, color_format: gs_color_format,
+    levels: UInt32, data: UnsafePointer<UnsafePointer<UInt8>?>?, flags: UInt32
+) -> OpaquePointer? {
+    let device = Unmanaged<MetalDevice>.fromOpaque(device).takeUnretainedValue()
+
+    let descriptor = MTLTextureDescriptor.init(
+        type: .type3D,
+        width: width,
+        height: height,
+        depth: depth,
+        colorFormat: color_format,
+        levels: levels,
+        flags: flags
+    )
+
+    guard let descriptor, let texture = MetalTexture(device: device, descriptor: descriptor) else {
+        return nil
+    }
+
+    if let data {
+        texture.upload(data: data, mipmapLevels: descriptor.mipmapLevelCount)
+    }
+
+    return texture.getRetained()
+}
+
+/// Requests deinitialization of the ``MetalTexture`` instance shared with `libobs`
+/// - Parameter texture: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///
+/// The ownership of the shared pointer is transferred into this function and the instance is placed under
+/// Swift's memory management again.
+@_cdecl("gs_voltexture_destroy")
+public func gs_voltexture_destroy(voltex: UnsafeRawPointer) {
+    let _ = retained(voltex) as MetalTexture
+}
+
+/// Gets the width of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter voltex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Width of the texture
+@_cdecl("gs_voltexture_get_width")
+public func gs_voltexture_get_width(voltex: UnsafeRawPointer) -> UInt32 {
+    let texture: MetalTexture = unretained(voltex)
+
+    return UInt32(texture.texture.width)
+}
+
+/// Gets the height of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter voltex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Height of the texture
+@_cdecl("gs_voltexture_get_height")
+public func gs_voltexture_get_height(voltex: UnsafeRawPointer) -> UInt32 {
+    let texture: MetalTexture = unretained(voltex)
+
+    return UInt32(texture.texture.height)
+}
+
+/// Gets the depth of the texture wrapped by the ``Metaltexture`` instance
+/// - Parameter voltex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Depth of the texture
+@_cdecl("gs_voltexture_get_depth")
+public func gs_voltexture_get_depth(voltex: UnsafeRawPointer) -> UInt32 {
+    let texture: MetalTexture = unretained(voltex)
+
+    return UInt32(texture.texture.depth)
+}
+
+/// Gets the color format of the texture wrapped by the ``MetalTexture`` instance
+/// - Parameter voltex: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+/// - Returns: Color format as defined by the `gs_color_format` enumeration
+@_cdecl("gs_voltexture_get_color_format")
+public func gs_voltexture_get_color_format(voltex: UnsafeRawPointer) -> gs_color_format {
+    let texture: MetalTexture = unretained(voltex)
+
+    return texture.texture.pixelFormat.gsColorFormat
+}

+ 97 - 0
libobs-metal/metal-unimplemented.swift

@@ -0,0 +1,97 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+@_cdecl("device_load_default_samplerstate")
+public func device_load_default_samplerstate(device: UnsafeRawPointer, b_3d: Bool, unit: Int) {
+    return
+}
+
+@_cdecl("device_enter_context")
+public func device_enter_context(device: UnsafeMutableRawPointer) {
+    return
+}
+
+@_cdecl("device_leave_context")
+public func device_leave_context(device: UnsafeMutableRawPointer) {
+    return
+}
+
+@_cdecl("device_timer_create")
+public func device_timer_create(device: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("device_timer_range_create")
+public func device_timer_range_create(device: UnsafeRawPointer) {
+}
+
+@_cdecl("gs_timer_destroy")
+public func gs_timer_destroy(timer: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("gs_timer_begin")
+public func gs_timer_begin(timer: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("gs_timer_end")
+public func gs_timer_end(timer: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("gs_timer_get_data")
+public func gs_timer_get_data(timer: UnsafeRawPointer) -> Bool {
+    return false
+}
+
+@_cdecl("gs_timer_range_destroy")
+public func gs_timer_range_destroy(range: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("gs_timer_range_begin")
+public func gs_timer_range_begin(range: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("gs_timer_range_end")
+public func gs_timer_range_end(range: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("gs_timer_range_get_data")
+public func gs_timer_range_get_data(range: UnsafeRawPointer, disjoint: Bool, frequency: UInt64) -> Bool {
+    return false
+}
+
+@_cdecl("device_debug_marker_begin")
+public func device_debug_marker_begin(device: UnsafeRawPointer, monitor: UnsafeMutableRawPointer) {
+    return
+}
+
+@_cdecl("device_debug_marker_end")
+public func device_debug_marker_end(device: UnsafeRawPointer) {
+    return
+}
+
+@_cdecl("device_set_cube_render_target")
+public func device_set_cube_render_target(
+    device: UnsafeRawPointer, cubetex: UnsafeRawPointer, side: Int, zstencil: UnsafeRawPointer
+) {
+    return
+}

+ 115 - 0
libobs-metal/metal-vertexbuffer.swift

@@ -0,0 +1,115 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+/// Creates a new ``MetalVertexBuffer`` instance with the given vertex buffer data and usage flags
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - data: Pointer to `gs_vb_data` vertex buffer data created by `libobs`
+///   - flags: Usage flags encoded as `libobs` bitmask
+/// - Returns: Opaque pointer to a new ``MetalVertexBuffer`` instance if successful, `nil` otherwise
+///
+/// > Note: The ownership of the memory pointed to by `data` is implicitly transferred to the ``MetalVertexBuffer``
+/// instance, but is not managed by Swift.
+@_cdecl("device_vertexbuffer_create")
+public func device_vertexbuffer_create(device: UnsafeRawPointer, data: UnsafeMutablePointer<gs_vb_data>, flags: UInt32)
+    -> OpaquePointer
+{
+    let device: MetalDevice = unretained(device)
+
+    let vertexBuffer = MetalVertexBuffer(
+        device: device,
+        data: data,
+        dynamic: (Int32(flags) & GS_DYNAMIC) != 0
+    )
+
+    return vertexBuffer.getRetained()
+}
+
+/// Requests the deinitialization of a shared ``MetalVertexBuffer`` instance
+/// - Parameter indexBuffer: Opaque pointer to ``MetalVertexBuffer`` instance shared with `libobs`
+///
+/// The deinitialization is handled automatically by Swift after the ownership of the instance has been transferred
+/// into the function and becomes the last strong reference to it. After the function leaves its scope, the object will
+/// be deinitialized and deallocated automatically.
+///
+/// > Note: The vertex buffer data memory is implicitly owned by the ``MetalVertexBuffer`` instance and will be
+/// manually cleaned up and deallocated by the instance's ``deinit`` method.
+@_cdecl("gs_vertexbuffer_destroy")
+public func gs_vertexbuffer_destroy(vertBuffer: UnsafeRawPointer) {
+    let _ = retained(vertBuffer) as MetalVertexBuffer
+}
+
+/// Sets up a ``MetalVertexBuffer`` as the vertex buffer for the current pipeline
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - vertbuffer: Opaque pointer to ``MetalVertexBuffer`` instance shared with `libobs`
+///
+/// > Note: The reference count of the ``MetalVertexBuffer`` instance will not be increased by this call.
+///
+/// > Important: If a `nil` pointer is provided as the vertex buffer, the index buffer will be _unset_.
+@_cdecl("device_load_vertexbuffer")
+public func device_load_vertexbuffer(device: UnsafeRawPointer, vertBuffer: UnsafeMutableRawPointer?) {
+    let device: MetalDevice = unretained(device)
+
+    if let vertBuffer {
+        device.renderState.vertexBuffer = unretained(vertBuffer)
+    } else {
+        device.renderState.vertexBuffer = nil
+    }
+}
+
+/// Requests the vertex buffer's current data to be transferred into GPU memory
+/// - Parameter vertBuffer: Opaque pointer to ``MetalVertexBuffer`` instance shared with `libobs`
+///
+/// This function will call `gs_vertexbuffer_flush_direct` with a `nil` pointer as the data pointer.
+@_cdecl("gs_vertexbuffer_flush")
+public func gs_vertexbuffer_flush(vertbuffer: UnsafeRawPointer) {
+    gs_vertexbuffer_flush_direct(vertbuffer: vertbuffer, data: nil)
+}
+
+/// Requests the vertex buffer to be updated with the provided data and then transferred into GPU memory
+/// - Parameters:
+///   - vertBuffer: Opaque pointer to ``MetalVertexBuffer`` instance shared with `libobs`
+///   - data: Opaque pointer to vertex buffer data set up by `libobs`
+///
+/// This function is called to ensure that the vertex buffer data that is contained in the memory pointed at by the
+/// `data` argument is uploaded into GPU memory.
+///
+/// If a `nil` pointer is provided instead, the data provided to the instance during creation will be used instead.
+@_cdecl("gs_vertexbuffer_flush_direct")
+public func gs_vertexbuffer_flush_direct(vertbuffer: UnsafeRawPointer, data: UnsafeMutablePointer<gs_vb_data>?) {
+    let vertexBuffer: MetalVertexBuffer = unretained(vertbuffer)
+
+    vertexBuffer.setupBuffers(data: data)
+}
+
+/// Returns an opaque pointer to the vertex buffer data associated with the ``MetalVertexBuffer`` instance
+/// - Parameter vertBuffer: Opaque pointer to ``MetalVertexBuffer`` instance shared with `libobs`
+/// - Returns: Opaque pointer to index buffer data in memory
+///
+/// The returned opaque pointer represents the unchanged memory address that was provided for the creation of the index
+/// buffer object.
+///
+/// > Warning: There is only limited memory safety associated with this pointer. It is implicitly owned and its
+/// lifetime is managed by the ``MetalVertexBuffer``
+/// instance, but it was originally created by `libobs`.
+@_cdecl("gs_vertexbuffer_get_data")
+public func gs_vertexbuffer_get_data(vertBuffer: UnsafeRawPointer) -> UnsafeMutablePointer<gs_vb_data>? {
+    let vertexBuffer: MetalVertexBuffer = unretained(vertBuffer)
+
+    return vertexBuffer.vertexData
+}

+ 69 - 0
libobs-metal/metal-zstencilbuffer.swift

@@ -0,0 +1,69 @@
+/******************************************************************************
+ Copyright (C) 2024 by Patrick Heyer <[email protected]>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ ******************************************************************************/
+
+import Foundation
+import Metal
+
+/// Creates ``MetalTexture`` for use as a depth stencil attachment
+/// - Parameters:
+///   - device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+///   - width: Desired width of the texture
+///   - height: Desired height of the texture
+///   - color_format: Desired color format of the depth stencil attachment as described by `gs_zstencil_format`
+/// - Returns: Opaque pointer to a created ``MetalTexture`` instance or a `NULL` pointer on error
+@_cdecl("device_zstencil_create")
+public func device_zstencil_create(device: UnsafeRawPointer, width: UInt32, height: UInt32, format: gs_zstencil_format)
+    -> OpaquePointer?
+{
+    let device: MetalDevice = unretained(device)
+
+    let descriptor = MTLTextureDescriptor.init(
+        width: width,
+        height: height,
+        colorFormat: format
+    )
+
+    guard let descriptor, let texture = MetalTexture(device: device, descriptor: descriptor) else {
+        return nil
+    }
+
+    return texture.getRetained()
+}
+
+/// Gets the ``MetalTexture`` instance used as the depth stencil attachment for the current pipeline
+/// - Parameter device: Opaque pointer to ``MetalDevice`` instance shared with `libobs`
+/// - Returns: Opaque pointer to ``MetalTexture`` instance if any is set, `nil` otherwise
+@_cdecl("device_get_zstencil_target")
+public func device_get_zstencil_target(device: UnsafeRawPointer) -> OpaquePointer? {
+    let device: MetalDevice = unretained(device)
+
+    guard let stencilAttachment = device.renderState.depthStencilAttachment else {
+        return nil
+    }
+
+    return stencilAttachment.getUnretained()
+}
+
+/// Requests deinitialization of the ``MetalTexture`` instance shared with `libobs`
+/// - Parameter zstencil: Opaque pointer to ``MetalTexture`` instance shared with `libobs`
+///
+/// The ownership of the shared pointer is transferred into this function and the instance is placed under Swift's
+/// memory management again.
+@_cdecl("gs_zstencil_destroy")
+public func gs_zstencil_destroy(zstencil: UnsafeRawPointer) {
+    let _ = retained(zstencil) as MetalTexture
+}