From d8b19c3c25effb2416815fbc7fe70c3b5cc1c8f6 Mon Sep 17 00:00:00 2001 From: PatTheMav Date: Fri, 20 Jun 2025 21:43:11 +0200 Subject: [PATCH] libobs-metal: Add Metal renderer --- CMakeLists.txt | 3 + libobs-metal/CMakeLists.txt | 70 + libobs-metal/CVPixelFormat+Extensions.swift | 51 + libobs-metal/MTLCullMode+Extensions.swift | 33 + libobs-metal/MTLOrigin+Extensions.swift | 25 + libobs-metal/MTLPixelFormat+Extensions.swift | 406 +++++ libobs-metal/MTLRegion+Extensions.swift | 25 + libobs-metal/MTLSize+Extensions.swift | 25 + libobs-metal/MTLTexture+Extensions.swift | 76 + .../MTLTextureDescriptor+Extensions.swift | 93 + libobs-metal/MTLTextureType+Extensions.swift | 36 + libobs-metal/MTLViewport+Extensions.swift | 31 + libobs-metal/MetalBuffer.swift | 308 ++++ libobs-metal/MetalDevice.swift | 786 ++++++++ libobs-metal/MetalError.swift | 126 ++ libobs-metal/MetalRenderState.swift | 79 + libobs-metal/MetalShader+Extensions.swift | 27 + libobs-metal/MetalShader.swift | 287 +++ libobs-metal/MetalStageBuffer.swift | 65 + libobs-metal/MetalTexture.swift | 433 +++++ libobs-metal/OBSShader.swift | 1603 +++++++++++++++++ libobs-metal/OBSSwapChain.swift | 125 ++ libobs-metal/Sequence+Hashable.swift | 25 + libobs-metal/libobs+Extensions.swift | 486 +++++ libobs-metal/libobs+SignalHandlers.swift | 34 + libobs-metal/libobs-metal-Bridging-Header.h | 32 + libobs-metal/metal-indexbuffer.swift | 158 ++ libobs-metal/metal-samplerstate.swift | 100 + libobs-metal/metal-shader.swift | 593 ++++++ libobs-metal/metal-stagesurf.swift | 130 ++ libobs-metal/metal-subsystem.swift | 985 ++++++++++ libobs-metal/metal-swapchain.swift | 269 +++ libobs-metal/metal-texture2d.swift | 528 ++++++ libobs-metal/metal-texture3d.swift | 113 ++ libobs-metal/metal-unimplemented.swift | 97 + libobs-metal/metal-vertexbuffer.swift | 115 ++ libobs-metal/metal-zstencilbuffer.swift | 69 + 37 files changed, 8447 insertions(+) create mode 100644 libobs-metal/CMakeLists.txt create mode 100644 libobs-metal/CVPixelFormat+Extensions.swift create mode 100644 libobs-metal/MTLCullMode+Extensions.swift create mode 100644 libobs-metal/MTLOrigin+Extensions.swift create mode 100644 libobs-metal/MTLPixelFormat+Extensions.swift create mode 100644 libobs-metal/MTLRegion+Extensions.swift create mode 100644 libobs-metal/MTLSize+Extensions.swift create mode 100644 libobs-metal/MTLTexture+Extensions.swift create mode 100644 libobs-metal/MTLTextureDescriptor+Extensions.swift create mode 100644 libobs-metal/MTLTextureType+Extensions.swift create mode 100644 libobs-metal/MTLViewport+Extensions.swift create mode 100644 libobs-metal/MetalBuffer.swift create mode 100644 libobs-metal/MetalDevice.swift create mode 100644 libobs-metal/MetalError.swift create mode 100644 libobs-metal/MetalRenderState.swift create mode 100644 libobs-metal/MetalShader+Extensions.swift create mode 100644 libobs-metal/MetalShader.swift create mode 100644 libobs-metal/MetalStageBuffer.swift create mode 100644 libobs-metal/MetalTexture.swift create mode 100644 libobs-metal/OBSShader.swift create mode 100644 libobs-metal/OBSSwapChain.swift create mode 100644 libobs-metal/Sequence+Hashable.swift create mode 100644 libobs-metal/libobs+Extensions.swift create mode 100644 libobs-metal/libobs+SignalHandlers.swift create mode 100644 libobs-metal/libobs-metal-Bridging-Header.h create mode 100644 libobs-metal/metal-indexbuffer.swift create mode 100644 libobs-metal/metal-samplerstate.swift create mode 100644 libobs-metal/metal-shader.swift create mode 100644 libobs-metal/metal-stagesurf.swift create mode 100644 libobs-metal/metal-subsystem.swift create mode 100644 libobs-metal/metal-swapchain.swift create mode 100644 libobs-metal/metal-texture2d.swift create mode 100644 libobs-metal/metal-texture3d.swift create mode 100644 libobs-metal/metal-unimplemented.swift create mode 100644 libobs-metal/metal-vertexbuffer.swift create mode 100644 libobs-metal/metal-zstencilbuffer.swift diff --git a/CMakeLists.txt b/CMakeLists.txt index d3fcec13b..4af6f90b7 100644 --- a/CMakeLists.txt +++ b/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) diff --git a/libobs-metal/CMakeLists.txt b/libobs-metal/CMakeLists.txt new file mode 100644 index 000000000..e9afd5f10 --- /dev/null +++ b/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" +) diff --git a/libobs-metal/CVPixelFormat+Extensions.swift b/libobs-metal/CVPixelFormat+Extensions.swift new file mode 100644 index 000000000..bfb8e8068 --- /dev/null +++ b/libobs-metal/CVPixelFormat+Extensions.swift @@ -0,0 +1,51 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } + } +} diff --git a/libobs-metal/MTLCullMode+Extensions.swift b/libobs-metal/MTLCullMode+Extensions.swift new file mode 100644 index 000000000..ba2b25c01 --- /dev/null +++ b/libobs-metal/MTLCullMode+Extensions.swift @@ -0,0 +1,33 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } + } +} diff --git a/libobs-metal/MTLOrigin+Extensions.swift b/libobs-metal/MTLOrigin+Extensions.swift new file mode 100644 index 000000000..2d160efa5 --- /dev/null +++ b/libobs-metal/MTLOrigin+Extensions.swift @@ -0,0 +1,25 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } +} diff --git a/libobs-metal/MTLPixelFormat+Extensions.swift b/libobs-metal/MTLPixelFormat+Extensions.swift new file mode 100644 index 000000000..d98d8a279 --- /dev/null +++ b/libobs-metal/MTLPixelFormat+Extensions.swift @@ -0,0 +1,406 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } + } +} diff --git a/libobs-metal/MTLRegion+Extensions.swift b/libobs-metal/MTLRegion+Extensions.swift new file mode 100644 index 000000000..afa36322d --- /dev/null +++ b/libobs-metal/MTLRegion+Extensions.swift @@ -0,0 +1,25 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +import Foundation +import Metal + +extension MTLRegion: @retroactive Equatable { + public static func == (lhs: MTLRegion, rhs: MTLRegion) -> Bool { + lhs.origin == rhs.origin && lhs.size == rhs.size + } +} diff --git a/libobs-metal/MTLSize+Extensions.swift b/libobs-metal/MTLSize+Extensions.swift new file mode 100644 index 000000000..001097979 --- /dev/null +++ b/libobs-metal/MTLSize+Extensions.swift @@ -0,0 +1,25 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } +} diff --git a/libobs-metal/MTLTexture+Extensions.swift b/libobs-metal/MTLTexture+Extensions.swift new file mode 100644 index 000000000..59df01eec --- /dev/null +++ b/libobs-metal/MTLTexture+Extensions.swift @@ -0,0 +1,76 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } +} diff --git a/libobs-metal/MTLTextureDescriptor+Extensions.swift b/libobs-metal/MTLTextureDescriptor+Extensions.swift new file mode 100644 index 000000000..78fd45869 --- /dev/null +++ b/libobs-metal/MTLTextureDescriptor+Extensions.swift @@ -0,0 +1,93 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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] + } +} diff --git a/libobs-metal/MTLTextureType+Extensions.swift b/libobs-metal/MTLTextureType+Extensions.swift new file mode 100644 index 000000000..038494338 --- /dev/null +++ b/libobs-metal/MTLTextureType+Extensions.swift @@ -0,0 +1,36 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } + } +} diff --git a/libobs-metal/MTLViewport+Extensions.swift b/libobs-metal/MTLViewport+Extensions.swift new file mode 100644 index 000000000..97c9a9cc3 --- /dev/null +++ b/libobs-metal/MTLViewport+Extensions.swift @@ -0,0 +1,31 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } +} diff --git a/libobs-metal/MetalBuffer.swift b/libobs-metal/MetalBuffer.swift new file mode 100644 index 000000000..e2c90757c --- /dev/null +++ b/libobs-metal/MetalBuffer.swift @@ -0,0 +1,308 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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( + buffer: inout MTLBuffer?, data: UnsafeMutablePointer, count: Int, dynamic: Bool + ) { + let size = MemoryLayout.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? + 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, 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? = 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]() + unpackedColors.reserveCapacity(4) + + for i in 0..(start: $0, count: 4) + + let color = SIMD4( + 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( + 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.. + + 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 . + ******************************************************************************/ + +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, + _ outputTime: UnsafePointer, + _ flagsIn: CVOptionFlags, + _ flagsOut: UnsafeMutablePointer, + _ 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.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.. 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() + } +} diff --git a/libobs-metal/MetalError.swift b/libobs-metal/MetalError.swift new file mode 100644 index 000000000..a29321318 --- /dev/null +++ b/libobs-metal/MetalError.swift @@ -0,0 +1,126 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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" + } + } + } +} diff --git a/libobs-metal/MetalRenderState.swift b/libobs-metal/MetalRenderState.swift new file mode 100644 index 000000000..dff8f5c08 --- /dev/null +++ b/libobs-metal/MetalRenderState.swift @@ -0,0 +1,79 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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() +} diff --git a/libobs-metal/MetalShader+Extensions.swift b/libobs-metal/MetalShader+Extensions.swift new file mode 100644 index 000000000..70e6489d2 --- /dev/null +++ b/libobs-metal/MetalShader+Extensions.swift @@ -0,0 +1,27 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } +} diff --git a/libobs-metal/MetalShader.swift b/libobs-metal/MetalShader.swift new file mode 100644 index 000000000..fd97be72c --- /dev/null +++ b/libobs-metal/MetalShader.swift @@ -0,0 +1,287 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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(data: UnsafePointer?, 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(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...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) + } +} diff --git a/libobs-metal/MetalStageBuffer.swift b/libobs-metal/MetalStageBuffer.swift new file mode 100644 index 000000000..4561f8214 --- /dev/null +++ b/libobs-metal/MetalStageBuffer.swift @@ -0,0 +1,65 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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) + } +} diff --git a/libobs-metal/MetalTexture.swift b/libobs-metal/MetalTexture.swift new file mode 100644 index 000000000..e18e78bdf --- /dev/null +++ b/libobs-metal/MetalTexture.swift @@ -0,0 +1,433 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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?>, 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..> 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.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) + } +} diff --git a/libobs-metal/OBSShader.swift b/libobs-metal/OBSShader.swift new file mode 100644 index 000000000..49bba7d5c --- /dev/null +++ b/libobs-metal/OBSShader.swift @@ -0,0 +1,1603 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 +} + +private struct OBSShaderVariable { + let name: String + + var type: String + var mapping: String? + var storageType: VariableType + + var requiredBy: Set + var returnedBy: Set + + var isStage: Bool + var attributeId: Int? + var isConstant: Bool + var isReference: Bool + + let gsVariable: UnsafeMutablePointer +} + +private struct OBSShaderStruct { + let name: String + + var storageType: VariableType + var members: [OBSShaderVariable] + + let gsVariable: UnsafeMutablePointer +} + +private struct MSLTemplates { + static let header = """ + #include + + 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.size) + case "POSITION", "NORMAL", "TANGENT": + return (.float4, MemoryLayout.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.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.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.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.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.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.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.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.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, functionData: inout OBSShaderFunction, + token: inout UnsafeMutablePointer, 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.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.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, 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" + case "texture3d": + return "texture3d" + case "texture_cube": + return "texturecube" + 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) 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) 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) 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) 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, 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).. 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.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) + } + } +} diff --git a/libobs-metal/OBSSwapChain.swift b/libobs-metal/OBSSwapChain.swift new file mode 100644 index 000000000..4986a7aa2 --- /dev/null +++ b/libobs-metal/OBSSwapChain.swift @@ -0,0 +1,125 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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) + } +} diff --git a/libobs-metal/Sequence+Hashable.swift b/libobs-metal/Sequence+Hashable.swift new file mode 100644 index 000000000..52f6fa121 --- /dev/null +++ b/libobs-metal/Sequence+Hashable.swift @@ -0,0 +1,25 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 = [] + return filter { seen.insert($0).inserted } + } +} diff --git a/libobs-metal/libobs+Extensions.swift b/libobs-metal/libobs+Extensions.swift new file mode 100644 index 000000000..0fa4c8a76 --- /dev/null +++ b/libobs-metal/libobs+Extensions.swift @@ -0,0 +1,486 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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?) -> 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? + + 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.size + case GS_SHADER_PARAM_INT2, GS_SHADER_PARAM_VEC2: + return MemoryLayout.size * 2 + case GS_SHADER_PARAM_INT3, GS_SHADER_PARAM_VEC3: + return MemoryLayout.size * 3 + case GS_SHADER_PARAM_INT4, GS_SHADER_PARAM_VEC4: + return MemoryLayout.size * 4 + case GS_SHADER_PARAM_MATRIX4X4: + return MemoryLayout.size * 4 * 4 + case GS_SHADER_PARAM_TEXTURE: + return MemoryLayout.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.size + case GS_SHADER_PARAM_INT2, GS_SHADER_PARAM_VEC2: + return MemoryLayout.size + case GS_SHADER_PARAM_INT3, GS_SHADER_PARAM_VEC3: + return MemoryLayout.size + case GS_SHADER_PARAM_INT4, GS_SHADER_PARAM_VEC4: + return MemoryLayout.size + case GS_SHADER_PARAM_MATRIX4X4: + return MemoryLayout.size + case GS_SHADER_PARAM_TEXTURE: + return MemoryLayout.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.alignment + case GS_SHADER_PARAM_INT2, GS_SHADER_PARAM_VEC2: + return MemoryLayout.alignment + case GS_SHADER_PARAM_INT3, GS_SHADER_PARAM_VEC3: + return MemoryLayout.alignment + case GS_SHADER_PARAM_INT4, GS_SHADER_PARAM_VEC4: + return MemoryLayout.alignment + case GS_SHADER_PARAM_MATRIX4X4: + return MemoryLayout.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 + } + } +} diff --git a/libobs-metal/libobs+SignalHandlers.swift b/libobs-metal/libobs+SignalHandlers.swift new file mode 100644 index 000000000..fa31d131d --- /dev/null +++ b/libobs-metal/libobs+SignalHandlers.swift @@ -0,0 +1,34 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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?) { + guard let param else { return } + + let metalDevice = unsafeBitCast(param, to: MetalDevice.self) + + metalDevice.dispatchSignal(type: .videoReset) +} diff --git a/libobs-metal/libobs-metal-Bridging-Header.h b/libobs-metal/libobs-metal-Bridging-Header.h new file mode 100644 index 000000000..b357fdc27 --- /dev/null +++ b/libobs-metal/libobs-metal-Bridging-Header.h @@ -0,0 +1,32 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +#import +#import +#import + +#import + +#import +#import +#import +#import +#import +#import + +static const char *const device_name = "Metal"; +static const char *const preprocessor_name = "_Metal"; diff --git a/libobs-metal/metal-indexbuffer.swift b/libobs-metal/metal-indexbuffer.swift new file mode 100644 index 000000000..b986f9008 --- /dev/null +++ b/libobs-metal/metal-indexbuffer.swift @@ -0,0 +1,158 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 + } +} diff --git a/libobs-metal/metal-samplerstate.swift b/libobs-metal/metal-samplerstate.swift new file mode 100644 index 000000000..7f14c9ed8 --- /dev/null +++ b/libobs-metal/metal-samplerstate.swift @@ -0,0 +1,100 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 +} diff --git a/libobs-metal/metal-shader.swift b/libobs-metal/metal-shader.swift new file mode 100644 index 000000000..acf11b89e --- /dev/null +++ b/libobs-metal/metal-shader.swift @@ -0,0 +1,593 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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, file: UnsafePointer, + error_string: UnsafeMutablePointer> +) -> 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, file: UnsafePointer, + error_string: UnsafeMutablePointer> +) -> 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.. 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) -> 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) { + 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.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.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.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) { + let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam) + + var newMatrix = matrix4() + matrix4_from_matrix3(&newMatrix, val) + + shaderUniform.setParameter(data: &newMatrix, size: MemoryLayout.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) { + let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam) + + shaderUniform.setParameter(data: val, size: MemoryLayout.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) { + let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam) + + shaderUniform.setParameter(data: val, size: MemoryLayout.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) { + let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam) + + shaderUniform.setParameter(data: val, size: MemoryLayout.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) { + let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam) + + shaderUniform.setParameter(data: val, size: MemoryLayout.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?) { + let shaderUniform: MetalShader.ShaderUniform = unretained(shaderParam) + + if let val { + shaderUniform.setParameter(data: val, size: MemoryLayout.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.fromOpaque(sampler).takeUnretainedValue() + + shaderUniform.samplerState = samplerState +} diff --git a/libobs-metal/metal-stagesurf.swift b/libobs-metal/metal-stagesurf.swift new file mode 100644 index 000000000..372b07936 --- /dev/null +++ b/libobs-metal/metal-stagesurf.swift @@ -0,0 +1,130 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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, + linesize: UnsafeMutablePointer +) -> 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 +} diff --git a/libobs-metal/metal-subsystem.swift b/libobs-metal/metal-subsystem.swift new file mode 100644 index 000000000..9426277c2 --- /dev/null +++ b/libobs-metal/metal-subsystem.swift @@ -0,0 +1,985 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +import Foundation +import Metal +import simd + +@inlinable +public func unretained(_ pointer: UnsafeRawPointer) -> Instance where Instance: AnyObject { + Unmanaged.fromOpaque(pointer).takeUnretainedValue() +} + +@inlinable +public func retained(_ pointer: UnsafeRawPointer) -> Instance where Instance: AnyObject { + Unmanaged.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 { + 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 { + 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.., 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) { + 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?) { + 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 +} diff --git a/libobs-metal/metal-swapchain.swift b/libobs-metal/metal-swapchain.swift new file mode 100644 index 000000000..94cb5e5ef --- /dev/null +++ b/libobs-metal/metal-swapchain.swift @@ -0,0 +1,269 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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) + -> 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, cy: UnsafeMutablePointer +) { + 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 +} diff --git a/libobs-metal/metal-texture2d.swift b/libobs-metal/metal-texture2d.swift new file mode 100644 index 000000000..084676b6d --- /dev/null +++ b/libobs-metal/metal-texture2d.swift @@ -0,0 +1,528 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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?>?, 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?>?, 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, linesize: UnsafeMutablePointer +) -> 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 + } +} diff --git a/libobs-metal/metal-texture3d.swift b/libobs-metal/metal-texture3d.swift new file mode 100644 index 000000000..0e2bebac8 --- /dev/null +++ b/libobs-metal/metal-texture3d.swift @@ -0,0 +1,113 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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?>?, flags: UInt32 +) -> OpaquePointer? { + let device = Unmanaged.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 +} diff --git a/libobs-metal/metal-unimplemented.swift b/libobs-metal/metal-unimplemented.swift new file mode 100644 index 000000000..35154fc85 --- /dev/null +++ b/libobs-metal/metal-unimplemented.swift @@ -0,0 +1,97 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +@_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 +} diff --git a/libobs-metal/metal-vertexbuffer.swift b/libobs-metal/metal-vertexbuffer.swift new file mode 100644 index 000000000..7357f96e8 --- /dev/null +++ b/libobs-metal/metal-vertexbuffer.swift @@ -0,0 +1,115 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +/// 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, 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?) { + 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? { + let vertexBuffer: MetalVertexBuffer = unretained(vertBuffer) + + return vertexBuffer.vertexData +} diff --git a/libobs-metal/metal-zstencilbuffer.swift b/libobs-metal/metal-zstencilbuffer.swift new file mode 100644 index 000000000..cb5c97db6 --- /dev/null +++ b/libobs-metal/metal-zstencilbuffer.swift @@ -0,0 +1,69 @@ +/****************************************************************************** + Copyright (C) 2024 by Patrick Heyer + + 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 . + ******************************************************************************/ + +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 +}