Files

1604 lines
64 KiB
Swift

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