/****************************************************************************** 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 }