diff --git a/ScreenShader.xcodeproj/project.pbxproj b/ScreenShader.xcodeproj/project.pbxproj index b78a8fa..f79390c 100644 --- a/ScreenShader.xcodeproj/project.pbxproj +++ b/ScreenShader.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 4159FE622D497D4600B79670 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4159FE612D497D4600B79670 /* Assets.xcassets */; }; + 9736AA952EF210C2002DDC37 /* retroarch_shader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9736AA962EF210C2002DDC37 /* retroarch_shader.swift */; }; 416975F62D497E21004C8E93 /* metrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416975EC2D497E21004C8E93 /* metrics.swift */; }; 416975F72D497E21004C8E93 /* config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416975ED2D497E21004C8E93 /* config.swift */; }; 416975F82D497E21004C8E93 /* screen_capture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416975EE2D497E21004C8E93 /* screen_capture.swift */; }; @@ -40,6 +41,7 @@ 41C290E02D4AC88E00675317 /* toggle.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = toggle.png; sourceTree = ""; }; 41C290E12D4AC88E00675317 /* select_activate.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = select_activate.png; sourceTree = ""; }; 9736AA932EF210C1002DDC37 /* slang_compiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = slang_compiler.swift; sourceTree = ""; }; + 9736AA962EF210C2002DDC37 /* retroarch_shader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = retroarch_shader.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -86,6 +88,7 @@ 416975F42D497E21004C8E93 /* main.swift */, 416975EC2D497E21004C8E93 /* metrics.swift */, 9736AA932EF210C1002DDC37 /* slang_compiler.swift */, + 9736AA962EF210C2002DDC37 /* retroarch_shader.swift */, 416975F22D497E21004C8E93 /* overlay.swift */, 416975EF2D497E21004C8E93 /* renderer.swift */, 416975EE2D497E21004C8E93 /* screen_capture.swift */, @@ -187,6 +190,7 @@ 416975FD2D497E21004C8E93 /* error_message.swift in Sources */, 416975F92D497E21004C8E93 /* renderer.swift in Sources */, 9736AA942EF210C1002DDC37 /* slang_compiler.swift in Sources */, + 9736AA952EF210C2002DDC37 /* retroarch_shader.swift in Sources */, 416975FA2D497E21004C8E93 /* config_view.swift in Sources */, 416975F62D497E21004C8E93 /* metrics.swift in Sources */, ); diff --git a/ScreenShader/config.swift b/ScreenShader/config.swift index 245061a..406cecd 100644 --- a/ScreenShader/config.swift +++ b/ScreenShader/config.swift @@ -44,10 +44,13 @@ let defaultShaderSource: String = """ """ class Config: Codable { - var configVersion: Int = 2 + var configVersion: Int = 3 // Bumped for parameter support var shaderPath: String? = nil var active: Bool = false var targetFPS: Int = 60 + + /// Stored parameter values for RetroArch shaders, keyed by shader path then parameter name + var shaderParameters: [String: [String: Float]] = [:] static func getFileURL() -> URL { let fileManager = FileManager.default @@ -123,4 +126,42 @@ class Config: Codable { active = false } } + + // MARK: - Shader Parameter Management + + /// Get stored parameter value for current shader + func getParameterValue(name: String) -> Float? { + guard let path = shaderPath else { return nil } + return shaderParameters[path]?[name] + } + + /// Set parameter value for current shader + func setParameterValue(name: String, value: Float) { + guard let path = shaderPath else { return } + if shaderParameters[path] == nil { + shaderParameters[path] = [:] + } + shaderParameters[path]?[name] = value + } + + /// Get all stored parameter values for current shader + func getParameterValues() -> [String: Float] { + guard let path = shaderPath else { return [:] } + return shaderParameters[path] ?? [:] + } + + /// Apply stored values to a parameter state + func applyStoredParameters(to state: ShaderParameterState) { + let stored = getParameterValues() + for (name, value) in stored { + state.setValue(value, for: name) + } + } + + /// Save current parameter state to config + func saveParameters(from state: ShaderParameterState) { + for param in state.parameters { + setParameterValue(name: param.name, value: state.getValue(for: param.name)) + } + } } diff --git a/ScreenShader/config_view.swift b/ScreenShader/config_view.swift index 2737a52..1fc5a92 100644 --- a/ScreenShader/config_view.swift +++ b/ScreenShader/config_view.swift @@ -4,6 +4,8 @@ class ConfigViewController: NSViewController { var config: Config! = nil var onConfigUpdate: () -> Void = {} var errorMessage: ErrorMessage! = nil + var parameterState: ShaderParameterState? = nil + var onParameterChanged: ((String, Float) -> Void)? = nil private var stackView: NSStackView! = nil private var shaderPathField: NSTextField! = nil @@ -11,6 +13,11 @@ class ConfigViewController: NSViewController { private var activateButton: NSButton! = nil private var reloadButton: NSButton! = nil private var errorMessageField: NSTextField! = nil + + // Parameter controls + private var parametersSection: NSStackView? = nil + private var parameterSliders: [String: NSSlider] = [:] + private var parameterLabels: [String: NSTextField] = [:] override func loadView() { self.view = NSView() @@ -82,6 +89,23 @@ class ConfigViewController: NSViewController { self.stackView.addArrangedSubview(warningLabel) } + // Add RetroArch tools availability indicator + if !RetroArchShaderCompiler.isAvailable { + let missing = RetroArchShaderCompiler.missingTools.joined(separator: "\n") + let warningLabel = NSTextField(labelWithString: "⚠️ RetroArch shader tools missing:\n\(missing)") + warningLabel.textColor = .systemOrange + warningLabel.translatesAutoresizingMaskIntoConstraints = false + self.stackView.addArrangedSubview(warningLabel) + } + + // Shader parameters section (populated dynamically) + self.parametersSection = NSStackView() + self.parametersSection?.orientation = .vertical + self.parametersSection?.spacing = 8 + self.parametersSection?.alignment = .leading + self.parametersSection?.translatesAutoresizingMaskIntoConstraints = false + self.stackView.addArrangedSubview(self.parametersSection!) + // Error message field self.errorMessageField = NSTextField() self.errorMessageField.isEditable = false @@ -149,6 +173,137 @@ class ConfigViewController: NSViewController { func refreshUI() { self.activateButton.title = self.getActivateButtonTitle() self.activateButton.isEnabled = self.config.hasShaderPath() + self.updateParameterUI() + } + + /// Update parameter sliders when shader changes + func updateParameterUI() { + // Remove existing parameter controls + self.parametersSection?.arrangedSubviews.forEach { $0.removeFromSuperview() } + self.parameterSliders.removeAll() + self.parameterLabels.removeAll() + + guard let state = self.parameterState, !state.parameters.isEmpty else { + return + } + + // Add header + let headerLabel = NSTextField(labelWithString: "Shader Parameters") + headerLabel.font = NSFont.boldSystemFont(ofSize: 14) + headerLabel.translatesAutoresizingMaskIntoConstraints = false + self.parametersSection?.addArrangedSubview(headerLabel) + + // Add a slider for each parameter + for param in state.parameters { + let paramStack = NSStackView() + paramStack.orientation = .horizontal + paramStack.spacing = 8 + paramStack.alignment = .centerY + paramStack.translatesAutoresizingMaskIntoConstraints = false + + // Parameter name label + let nameLabel = NSTextField(labelWithString: param.description) + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.toolTip = param.name + paramStack.addArrangedSubview(nameLabel) + + // Slider + let slider = NSSlider(value: Double(state.getValue(for: param.name)), + minValue: Double(param.minValue), + maxValue: Double(param.maxValue), + target: self, + action: #selector(parameterSliderChanged(_:))) + slider.translatesAutoresizingMaskIntoConstraints = false + slider.identifier = NSUserInterfaceItemIdentifier(param.name) + slider.isContinuous = true + paramStack.addArrangedSubview(slider) + + // Value label + let valueLabel = NSTextField(labelWithString: String(format: "%.2f", state.getValue(for: param.name))) + valueLabel.translatesAutoresizingMaskIntoConstraints = false + valueLabel.isEditable = false + paramStack.addArrangedSubview(valueLabel) + + // Reset button + let resetButton = NSButton(title: "↺", target: self, action: #selector(resetParameter(_:))) + resetButton.identifier = NSUserInterfaceItemIdentifier(param.name) + resetButton.toolTip = "Reset to default (\(param.defaultValue))" + resetButton.bezelStyle = .inline + paramStack.addArrangedSubview(resetButton) + + self.parameterSliders[param.name] = slider + self.parameterLabels[param.name] = valueLabel + + self.parametersSection?.addArrangedSubview(paramStack) + + // Add width constraints + NSLayoutConstraint.activate([ + nameLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 150), + slider.widthAnchor.constraint(equalToConstant: 200), + valueLabel.widthAnchor.constraint(equalToConstant: 50) + ]) + } + + // Add "Reset All" button + let resetAllButton = NSButton(title: "Reset All Parameters", target: self, action: #selector(resetAllParameters)) + resetAllButton.translatesAutoresizingMaskIntoConstraints = false + self.parametersSection?.addArrangedSubview(resetAllButton) + } + + @objc func parameterSliderChanged(_ sender: NSSlider) { + guard let paramName = sender.identifier?.rawValue else { return } + let value = Float(sender.doubleValue) + + // Update the value label + if let label = parameterLabels[paramName] { + label.stringValue = String(format: "%.2f", value) + } + + // Update the parameter state + parameterState?.setValue(value, for: paramName) + + // Save to config + config.setParameterValue(name: paramName, value: value) + + // Notify the renderer + onParameterChanged?(paramName, value) + } + + @objc func resetParameter(_ sender: NSButton) { + guard let paramName = sender.identifier?.rawValue, + let param = parameterState?.parameters.first(where: { $0.name == paramName }) else { return } + + let defaultValue = param.defaultValue + parameterState?.setValue(defaultValue, for: paramName) + + if let slider = parameterSliders[paramName] { + slider.doubleValue = Double(defaultValue) + } + if let label = parameterLabels[paramName] { + label.stringValue = String(format: "%.2f", defaultValue) + } + + config.setParameterValue(name: paramName, value: defaultValue) + onParameterChanged?(paramName, defaultValue) + } + + @objc func resetAllParameters() { + guard let state = parameterState else { return } + + for param in state.parameters { + let defaultValue = param.defaultValue + state.setValue(defaultValue, for: param.name) + + if let slider = parameterSliders[param.name] { + slider.doubleValue = Double(defaultValue) + } + if let label = parameterLabels[param.name] { + label.stringValue = String(format: "%.2f", defaultValue) + } + + config.setParameterValue(name: param.name, value: defaultValue) + onParameterChanged?(param.name, defaultValue) + } } } @@ -156,6 +311,8 @@ class ConfigWindowController: NSWindowController { var config: Config! = nil var errorMessage: ErrorMessage! = nil var onConfigUpdate: () -> Void = {} + var parameterState: ShaderParameterState? = nil + var onParameterChanged: ((String, Float) -> Void)? = nil private var configViewController: ConfigViewController! = ConfigViewController() @@ -167,6 +324,8 @@ class ConfigWindowController: NSWindowController { self.configViewController.config = self.config self.configViewController.onConfigUpdate = self.onConfigUpdate self.configViewController.errorMessage = self.errorMessage + self.configViewController.parameterState = self.parameterState + self.configViewController.onParameterChanged = self.onParameterChanged window.contentView?.addSubview(self.configViewController.view) NSLayoutConstraint.activate([ @@ -183,4 +342,10 @@ class ConfigWindowController: NSWindowController { func refreshActiveEffects() { self.configViewController.refreshUI() } + + func updateParameterState(_ state: ShaderParameterState?) { + self.parameterState = state + self.configViewController.parameterState = state + self.configViewController.updateParameterUI() + } } diff --git a/ScreenShader/main.swift b/ScreenShader/main.swift index f130a17..320a0f4 100644 --- a/ScreenShader/main.swift +++ b/ScreenShader/main.swift @@ -58,6 +58,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.overlayController.refreshConfig() self.configWindowController?.refreshActiveEffects() + + // Update parameter UI with new shader's parameters + self.updateParameterUI() // Indicate that the config should be saved to disk. self.configChanged = true @@ -152,10 +155,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.refreshConfig() } self.configWindowController!.errorMessage = self.errorMessage + self.configWindowController!.parameterState = self.overlayController.getParameterState() + self.configWindowController!.onParameterChanged = { [weak self] name, value in + self?.overlayController.setParameterValue(name: name, value: value) + self?.configChanged = true + } self.configWindowController!.createUI() window.makeKeyAndOrderFront(nil) } + + private func updateParameterUI() { + self.configWindowController?.updateParameterState(self.overlayController.getParameterState()) + } } let app = NSApplication.shared diff --git a/ScreenShader/overlay.swift b/ScreenShader/overlay.swift index e01c347..8514a1f 100644 --- a/ScreenShader/overlay.swift +++ b/ScreenShader/overlay.swift @@ -88,8 +88,11 @@ class OverlayController: NSObject, MTKViewDelegate { let activeShader = self.config.active ? self.config.getShader() : nil do { - try self.renderer.setEffectSource(activeShader) + try self.renderer.setEffectSource(activeShader, shaderPath: self.config.shaderPath) self.errorMessage.clear() + + // Apply stored parameter values from config + self.config.applyStoredParameters(to: self.renderer.parameterState) } catch { print("Effect shader error: \(error.localizedDescription)") self.errorMessage.set(error.localizedDescription) @@ -102,4 +105,14 @@ class OverlayController: NSObject, MTKViewDelegate { self.window.setIsVisible(true) self.screenCapture.setCapturing(true) } + + /// Get the current parameter state from the renderer + func getParameterState() -> ShaderParameterState? { + return self.renderer.parameterState + } + + /// Set a parameter value on the renderer + func setParameterValue(name: String, value: Float) { + self.renderer.parameterState.setValue(value, for: name) + } } diff --git a/ScreenShader/renderer.swift b/ScreenShader/renderer.swift index d8093c1..9b8f388 100644 --- a/ScreenShader/renderer.swift +++ b/ScreenShader/renderer.swift @@ -19,6 +19,57 @@ struct SlangUniforms { var _padding: Float = 0 // Alignment padding } +// Uniforms struct for RetroArch shaders (matches push_constant layout) +struct RetroArchPushConstants { + // Standard RetroArch push constants - matches typical Push struct + var sourceSize: vector_float4 // xy = size, zw = 1/size + var originalSize: vector_float4 // xy = size, zw = 1/size + var outputSize: vector_float4 // xy = size, zw = 1/size + var frameCount: UInt32 + var _padding1: UInt32 = 0 + var _padding2: UInt32 = 0 + var _padding3: UInt32 = 0 +} + +// Standard UBO for RetroArch shaders +struct RetroArchUBO { + var mvp: matrix_float4x4 +} + +/// Enum to track which type of shader is currently active +enum ActiveShaderType { + case none + case slang + case retroArch +} + +/// Holds the current state of shader parameters +class ShaderParameterState { + var parameters: [ShaderParameter] = [] + var values: [String: Float] = [:] + + func getValue(for name: String) -> Float { + if let value = values[name] { + return value + } + if let param = parameters.first(where: { $0.name == name }) { + return param.defaultValue + } + return 0.0 + } + + func setValue(_ value: Float, for name: String) { + values[name] = value + } + + func reset() { + values.removeAll() + for param in parameters { + values[param.name] = param.defaultValue + } + } +} + class MetalRenderer { private let device: MTLDevice private let commandQueue: MTLCommandQueue @@ -27,6 +78,14 @@ class MetalRenderer { private var renderPipeline: MTLRenderPipelineState? = nil private var samplerState: MTLSamplerState! private let baseTime = ProcessInfo.processInfo.systemUptime + + // RetroArch shader support + private var activeShaderType: ActiveShaderType = .none + private(set) var parameterState: ShaderParameterState = ShaderParameterState() + private var shaderDirectory: URL? = nil + + // Background texture for RetroArch shaders that need it + private var backgroundTexture: MTLTexture? = nil init(metalLayer: CAMetalLayer) { guard let device = MTLCreateSystemDefaultDevice() else { @@ -54,7 +113,7 @@ class MetalRenderer { fatalError("Could not create CVMetalTextureCache.") } - // Create a sampler state for Slang shaders + // Create a sampler state for shaders let samplerDescriptor = MTLSamplerDescriptor() samplerDescriptor.minFilter = .linear samplerDescriptor.magFilter = .linear @@ -62,8 +121,64 @@ class MetalRenderer { samplerDescriptor.tAddressMode = .clampToEdge self.samplerState = self.device.makeSamplerState(descriptor: samplerDescriptor) } + + // MARK: - RetroArch Shader Pipeline Builder + + /// Build a render pipeline from a RetroArch shader + static func buildRetroArchPipeline( + device: MTLDevice, + effectSource: String, + shaderDirectory: URL? + ) throws -> (MTLRenderPipelineState, [ShaderParameter]) { + let compiled = try RetroArchShaderCompiler.compileToMetal( + source: effectSource, + shaderDirectory: shaderDirectory + ) + + print("=== RetroArch-generated Metal code ===") + print(compiled.metalSource) + print("=== End RetroArch-generated Metal code ===") + + let library: MTLLibrary + do { + library = try device.makeLibrary(source: compiled.metalSource, options: nil) + } catch { + throw NSError( + domain: "MetalRenderer", code: 4, + userInfo: [ + NSLocalizedDescriptionKey: "Metal compilation failed: \(error.localizedDescription)" + ]) + } + + // RetroArch shaders use main0 for both vertex and fragment by default + // but spirv-cross may generate different names + let vertexFunction = library.makeFunction(name: compiled.vertexFunctionName) + ?? library.makeFunction(name: "vertexMain") + ?? library.makeFunction(name: "vertex_main") + let fragmentFunction = library.makeFunction(name: compiled.fragmentFunctionName) + ?? library.makeFunction(name: "fragmentMain") + ?? library.makeFunction(name: "fragment_main") + + guard vertexFunction != nil && fragmentFunction != nil else { + // List available functions for debugging + let functionNames = library.functionNames.joined(separator: ", ") + throw NSError( + domain: "MetalRenderer", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Could not find shader entry points. Available functions: \(functionNames)" + ]) + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + + let pipeline = try device.makeRenderPipelineState(descriptor: pipelineDescriptor) + return (pipeline, compiled.parameters) + } - /// Build a render pipeline from Slang shader effect source + // MARK: - Standard Slang Pipeline Builder static func buildRenderPipeline(device: MTLDevice, effectSource: String) throws -> MTLRenderPipelineState { @@ -150,19 +265,112 @@ class MetalRenderer { return try device.makeRenderPipelineState(descriptor: pipelineDescriptor) } - func setEffectSource(_ effectSource: String?) throws { + func setEffectSource(_ effectSource: String?, shaderPath: String? = nil) throws { guard let effectSource = effectSource else { self.activeEffectSource = nil self.renderPipeline = nil + self.activeShaderType = .none + self.parameterState = ShaderParameterState() return } + self.activeEffectSource = effectSource + + // Determine shader directory for includes + if let path = shaderPath { + self.shaderDirectory = URL(fileURLWithPath: path).deletingLastPathComponent() + } else { + self.shaderDirectory = nil + } + + // Detect shader type and compile accordingly + if RetroArchShaderCompiler.isRetroArchShader(effectSource) { + // RetroArch-style shader + do { + let (pipeline, parameters) = try Self.buildRetroArchPipeline( + device: self.device, + effectSource: effectSource, + shaderDirectory: self.shaderDirectory + ) + self.renderPipeline = pipeline + self.activeShaderType = .retroArch + self.parameterState = ShaderParameterState() + self.parameterState.parameters = parameters + self.parameterState.reset() + + // Load background texture if the shader uses one + loadBackgroundTextureIfNeeded(effectSource: effectSource) + + print("Loaded RetroArch shader with \(parameters.count) parameters") + for param in parameters { + print(" - \(param.name): \(param.description) [\(param.minValue) - \(param.maxValue), default: \(param.defaultValue)]") + } + } catch { + self.renderPipeline = nil + self.activeShaderType = .none + throw error + } + } else { + // Standard Slang shader + do { + self.renderPipeline = try Self.buildRenderPipeline( + device: self.device, effectSource: effectSource) + self.activeShaderType = .slang + self.parameterState = ShaderParameterState() + } catch { + self.renderPipeline = nil + self.activeShaderType = .none + throw error + } + } + } + + /// Load background texture if the shader references BACKGROUND sampler + private func loadBackgroundTextureIfNeeded(effectSource: String) { + // Check if shader uses BACKGROUND texture + guard effectSource.contains("BACKGROUND") else { + self.backgroundTexture = nil + return + } + + // Look for background texture in shader directory + guard let shaderDir = self.shaderDirectory else { + print("Warning: Shader uses BACKGROUND texture but no shader directory specified") + return + } + + // Common background texture paths for RetroArch shaders + let possiblePaths = [ + shaderDir.appendingPathComponent("png/4k/background.png"), + shaderDir.appendingPathComponent("png/2k/background.png"), + shaderDir.appendingPathComponent("background.png"), + shaderDir.appendingPathComponent("../background.png") + ] + + for path in possiblePaths { + if FileManager.default.fileExists(atPath: path.path) { + loadTexture(from: path) + return + } + } + + print("Warning: Could not find BACKGROUND texture for shader") + } + + /// Load a texture from a file + private func loadTexture(from url: URL) { + let textureLoader = MTKTextureLoader(device: device) do { - self.renderPipeline = try Self.buildRenderPipeline( - device: self.device, effectSource: effectSource) + self.backgroundTexture = try textureLoader.newTexture( + URL: url, + options: [ + .textureUsage: MTLTextureUsage.shaderRead.rawValue, + .textureStorageMode: MTLStorageMode.private.rawValue + ] + ) + print("Loaded background texture: \(url.lastPathComponent)") } catch { - self.renderPipeline = nil - throw error + print("Failed to load background texture: \(error)") } } @@ -218,22 +426,42 @@ class MetalRenderer { encoder.setScissorRect(scissorRect) if let renderPipeline = self.renderPipeline { - var screenSize = vector_float2(Float(screen.frame.width), Float(screen.frame.height)) - var mousePosition = vector_float2( - Float(NSEvent.mouseLocation.x), Float(NSEvent.mouseLocation.y)) - var time = Float(ProcessInfo.processInfo.systemUptime - self.baseTime) - encoder.setRenderPipelineState(renderPipeline) encoder.setFragmentTexture(texture, index: 0) - - // Slang shaders expect a Uniforms struct at buffer(0) and a sampler - var uniforms = SlangUniforms( - screenSize: screenSize, - mousePosition: mousePosition, - time: time - ) - encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) encoder.setFragmentSamplerState(self.samplerState, index: 0) + + switch self.activeShaderType { + case .retroArch: + // Set up RetroArch-style uniforms + encodeRetroArchUniforms( + encoder: encoder, + screen: screen, + textureWidth: width, + textureHeight: height + ) + + // Set background texture if available (typically at binding 3) + if let bgTexture = self.backgroundTexture { + encoder.setFragmentTexture(bgTexture, index: 1) + } + + case .slang: + // Slang shaders expect a Uniforms struct at buffer(0) + var screenSize = vector_float2(Float(screen.frame.width), Float(screen.frame.height)) + var mousePosition = vector_float2( + Float(NSEvent.mouseLocation.x), Float(NSEvent.mouseLocation.y)) + var time = Float(ProcessInfo.processInfo.systemUptime - self.baseTime) + + var uniforms = SlangUniforms( + screenSize: screenSize, + mousePosition: mousePosition, + time: time + ) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) + + case .none: + break + } encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) } @@ -244,4 +472,46 @@ class MetalRenderer { commandBuffer.present(drawable) commandBuffer.commit() } + + /// Encode RetroArch-style uniforms for the shader + private func encodeRetroArchUniforms( + encoder: MTLRenderCommandEncoder, + screen: NSScreen, + textureWidth: Int, + textureHeight: Int + ) { + let scaleFactor = screen.backingScaleFactor + let outputWidth = Float(screen.frame.width * scaleFactor) + let outputHeight = Float(screen.frame.height * scaleFactor) + let sourceWidth = Float(textureWidth) + let sourceHeight = Float(textureHeight) + + // Build the push constants buffer matching the shader's Push struct + // The order must match the shader's push_constant layout + var pushBuffer: [Float] = [] + + // Add user-defined parameters from #pragma parameter + for param in parameterState.parameters { + pushBuffer.append(parameterState.getValue(for: param.name)) + } + + // Add standard RetroArch parameters (OutputSize, OriginalSize, SourceSize) + // These are vec4s: xy = size, zw = 1.0/size + pushBuffer.append(contentsOf: [ + outputWidth, outputHeight, 1.0 / outputWidth, 1.0 / outputHeight, // OutputSize + sourceWidth, sourceHeight, 1.0 / sourceWidth, 1.0 / sourceHeight, // OriginalSize + sourceWidth, sourceHeight, 1.0 / sourceWidth, 1.0 / sourceHeight // SourceSize + ]) + + // Pad to 16-byte alignment + while pushBuffer.count % 4 != 0 { + pushBuffer.append(0) + } + + encoder.setFragmentBytes(pushBuffer, length: pushBuffer.count * MemoryLayout.stride, index: 0) + + // Set vertex uniforms (UBO with MVP matrix) + var mvp = matrix_identity_float4x4 + encoder.setVertexBytes(&mvp, length: MemoryLayout.stride, index: 0) + } } diff --git a/ScreenShader/retroarch_shader.swift b/ScreenShader/retroarch_shader.swift new file mode 100644 index 0000000..53de274 --- /dev/null +++ b/ScreenShader/retroarch_shader.swift @@ -0,0 +1,509 @@ +import Foundation + +/// Errors specific to RetroArch shader compilation +enum RetroArchShaderError: Error, LocalizedError { + case glslangNotFound + case spirvCrossNotFound + case preprocessingFailed(String) + case glslCompilationFailed(String) + case spirvConversionFailed(String) + case invalidShaderFormat(String) + case missingTexture(String) + case processError(String) + + var errorDescription: String? { + switch self { + case .glslangNotFound: + return "glslangValidator not found. Install via: brew install glslang" + case .spirvCrossNotFound: + return "spirv-cross not found. Install via: brew install spirv-cross" + case .preprocessingFailed(let message): + return "Shader preprocessing failed: \(message)" + case .glslCompilationFailed(let message): + return "GLSL compilation failed: \(message)" + case .spirvConversionFailed(let message): + return "SPIRV to Metal conversion failed: \(message)" + case .invalidShaderFormat(let message): + return "Invalid shader format: \(message)" + case .missingTexture(let name): + return "Missing texture: \(name)" + case .processError(let message): + return "Process error: \(message)" + } + } +} + +/// Represents a sampler/texture declaration in a RetroArch shader +struct ShaderSampler { + let name: String + let binding: Int + let set: Int +} + +/// Preprocessed RetroArch shader with separated vertex/fragment stages +struct RetroArchShaderStages { + let vertexSource: String + let fragmentSource: String + let parameters: [ShaderParameter] + let samplers: [ShaderSampler] + let format: String? +} + +/// Compiled RetroArch shader ready for Metal +struct CompiledRetroArchShader { + let metalSource: String + let vertexFunctionName: String + let fragmentFunctionName: String + let parameters: [ShaderParameter] + let samplers: [ShaderSampler] +} + +/// Compiler for RetroArch-style .slang shaders (GLSL with extensions) +class RetroArchShaderCompiler { + + // MARK: - Tool Discovery + + private static let glslangSearchPaths = [ + ProcessInfo.processInfo.environment["GLSLANG_PATH"], + "/opt/homebrew/bin/glslangValidator", + "/usr/local/bin/glslangValidator", + "/usr/bin/glslangValidator", + Bundle.main.path(forResource: "glslangValidator", ofType: nil) + ].compactMap { $0 } + + private static let spirvCrossSearchPaths = [ + ProcessInfo.processInfo.environment["SPIRV_CROSS_PATH"], + "/opt/homebrew/bin/spirv-cross", + "/usr/local/bin/spirv-cross", + "/usr/bin/spirv-cross", + Bundle.main.path(forResource: "spirv-cross", ofType: nil) + ].compactMap { $0 } + + static func findGlslang() -> String? { + for path in glslangSearchPaths { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + static func findSpirvCross() -> String? { + for path in spirvCrossSearchPaths { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + static var isAvailable: Bool { + return findGlslang() != nil && findSpirvCross() != nil + } + + static var missingTools: [String] { + var missing: [String] = [] + if findGlslang() == nil { + missing.append("glslangValidator (install via: brew install glslang)") + } + if findSpirvCross() == nil { + missing.append("spirv-cross (install via: brew install spirv-cross)") + } + return missing + } + + // MARK: - Shader Detection + + /// Detect if a shader source is RetroArch-style (GLSL with pragmas) + static func isRetroArchShader(_ source: String) -> Bool { + let indicators = [ + "#version 450", + "#pragma stage", + "layout(push_constant)", + "layout(std140, set = 0, binding = 0) uniform UBO" + ] + return indicators.contains { source.contains($0) } + } + + // MARK: - Preprocessing + + /// Preprocess a RetroArch shader, extracting parameters and separating stages + static func preprocess(_ source: String) throws -> RetroArchShaderStages { + var parameters: [ShaderParameter] = [] + var samplers: [ShaderSampler] = [] + var format: String? = nil + + var vertexLines: [String] = [] + var fragmentLines: [String] = [] + var commonLines: [String] = [] + + var currentStage: String? = nil // nil = common, "vertex", "fragment" + var inPushConstant = false + var pushConstantFields: [String] = [] + + let lines = source.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Parse #pragma parameter + if trimmed.hasPrefix("#pragma parameter") { + if let param = ShaderParameter.parse(from: line) { + // Deduplicate parameters + if !parameters.contains(where: { $0.name == param.name }) { + parameters.append(param) + } + } + continue // Don't include pragma in output + } + + // Parse #pragma stage + if trimmed.hasPrefix("#pragma stage vertex") { + currentStage = "vertex" + continue + } + if trimmed.hasPrefix("#pragma stage fragment") { + currentStage = "fragment" + continue + } + + // Parse #pragma format + if trimmed.hasPrefix("#pragma format") { + format = String(trimmed.dropFirst("#pragma format".count)) + .trimmingCharacters(in: .whitespaces) + continue + } + + // Skip #pragma name (we handle naming differently) + if trimmed.hasPrefix("#pragma name") { + continue + } + + // Parse sampler declarations + if trimmed.contains("uniform sampler2D") { + if let sampler = parseSamplerDeclaration(line) { + samplers.append(sampler) + } + } + + // Add line to appropriate stage + switch currentStage { + case "vertex": + vertexLines.append(line) + case "fragment": + fragmentLines.append(line) + default: + commonLines.append(line) + } + } + + // Build complete vertex and fragment sources with common prefix + let commonSource = commonLines.joined(separator: "\n") + let vertexSource = commonSource + "\n" + vertexLines.joined(separator: "\n") + let fragmentSource = commonSource + "\n" + fragmentLines.joined(separator: "\n") + + return RetroArchShaderStages( + vertexSource: vertexSource, + fragmentSource: fragmentSource, + parameters: parameters, + samplers: samplers, + format: format + ) + } + + /// Parse a sampler declaration like: layout(set = 0, binding = 2) uniform sampler2D Source; + private static func parseSamplerDeclaration(_ line: String) -> ShaderSampler? { + // Extract set and binding from layout + var set = 0 + var binding = 0 + + if let setMatch = line.range(of: "set\\s*=\\s*(\\d+)", options: .regularExpression) { + let setStr = line[setMatch] + if let numMatch = setStr.range(of: "\\d+", options: .regularExpression) { + set = Int(setStr[numMatch]) ?? 0 + } + } + + if let bindingMatch = line.range(of: "binding\\s*=\\s*(\\d+)", options: .regularExpression) { + let bindingStr = line[bindingMatch] + if let numMatch = bindingStr.range(of: "\\d+", options: .regularExpression) { + binding = Int(bindingStr[numMatch]) ?? 0 + } + } + + // Extract sampler name (last word before semicolon) + if let nameMatch = line.range(of: "sampler2D\\s+(\\w+)", options: .regularExpression) { + let matchStr = String(line[nameMatch]) + let parts = matchStr.components(separatedBy: .whitespaces) + if parts.count >= 2 { + let name = parts[1].replacingOccurrences(of: ";", with: "") + return ShaderSampler(name: name, binding: binding, set: set) + } + } + + return nil + } + + // MARK: - Compilation Pipeline + + /// Compile a RetroArch shader source to Metal + static func compileToMetal(source: String, shaderDirectory: URL? = nil) throws -> CompiledRetroArchShader { + guard let glslangPath = findGlslang() else { + throw RetroArchShaderError.glslangNotFound + } + guard let spirvCrossPath = findSpirvCross() else { + throw RetroArchShaderError.spirvCrossNotFound + } + + // Preprocess the shader + let stages = try preprocess(source) + + // Create temp directory + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + // Compile vertex shader: GLSL -> SPIRV -> Metal + let vertexMetal = try compileStage( + source: stages.vertexSource, + stage: "vert", + glslangPath: glslangPath, + spirvCrossPath: spirvCrossPath, + tempDir: tempDir, + shaderDirectory: shaderDirectory + ) + + // Compile fragment shader: GLSL -> SPIRV -> Metal + let fragmentMetal = try compileStage( + source: stages.fragmentSource, + stage: "frag", + glslangPath: glslangPath, + spirvCrossPath: spirvCrossPath, + tempDir: tempDir, + shaderDirectory: shaderDirectory + ) + + // Combine Metal sources + let combinedMetal = combineMetalShaders( + vertexMetal: vertexMetal, + fragmentMetal: fragmentMetal, + parameters: stages.parameters + ) + + return CompiledRetroArchShader( + metalSource: combinedMetal, + vertexFunctionName: "main0", + fragmentFunctionName: "main0", + parameters: stages.parameters, + samplers: stages.samplers + ) + } + + /// Compile a single shader stage + private static func compileStage( + source: String, + stage: String, // "vert" or "frag" + glslangPath: String, + spirvCrossPath: String, + tempDir: URL, + shaderDirectory: URL? + ) throws -> String { + let inputFile = tempDir.appendingPathComponent("shader.\(stage).glsl") + let spirvFile = tempDir.appendingPathComponent("shader.\(stage).spv") + let metalFile = tempDir.appendingPathComponent("shader.\(stage).metal") + + // Write GLSL source + try source.write(to: inputFile, atomically: true, encoding: .utf8) + + // Step 1: GLSL -> SPIRV using glslangValidator + let glslangProcess = Process() + glslangProcess.executableURL = URL(fileURLWithPath: glslangPath) + glslangProcess.arguments = [ + "-V", // Vulkan GLSL + "-S", stage, // Shader stage + "-o", spirvFile.path, + inputFile.path + ] + + // Add include path if shader directory is specified + if let dir = shaderDirectory { + glslangProcess.arguments?.insert(contentsOf: ["-I", dir.path], at: 1) + } + + let glslangError = Pipe() + glslangProcess.standardError = glslangError + glslangProcess.standardOutput = Pipe() + + do { + try glslangProcess.run() + glslangProcess.waitUntilExit() + } catch { + throw RetroArchShaderError.processError("Failed to run glslangValidator: \(error)") + } + + if glslangProcess.terminationStatus != 0 { + let errorData = glslangError.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw RetroArchShaderError.glslCompilationFailed(errorMessage) + } + + // Step 2: SPIRV -> Metal using spirv-cross + let spirvCrossProcess = Process() + spirvCrossProcess.executableURL = URL(fileURLWithPath: spirvCrossPath) + spirvCrossProcess.arguments = [ + spirvFile.path, + "--msl", // Metal Shading Language output + "--msl-version", "20100", // Metal 2.1 + "--output", metalFile.path + ] + + let spirvCrossError = Pipe() + spirvCrossProcess.standardError = spirvCrossError + spirvCrossProcess.standardOutput = Pipe() + + do { + try spirvCrossProcess.run() + spirvCrossProcess.waitUntilExit() + } catch { + throw RetroArchShaderError.processError("Failed to run spirv-cross: \(error)") + } + + if spirvCrossProcess.terminationStatus != 0 { + let errorData = spirvCrossError.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw RetroArchShaderError.spirvConversionFailed(errorMessage) + } + + // Read Metal source + guard let metalSource = try? String(contentsOf: metalFile, encoding: .utf8) else { + throw RetroArchShaderError.spirvConversionFailed("Failed to read generated Metal source") + } + + return metalSource + } + + /// Combine vertex and fragment Metal shaders into a single source file + private static func combineMetalShaders( + vertexMetal: String, + fragmentMetal: String, + parameters: [ShaderParameter] + ) -> String { + // Extract unique includes and type definitions from both shaders + var includes = Set() + var typeDefinitions: [String] = [] + var vertexMain = "" + var fragmentMain = "" + + // Process vertex shader + let vertexComponents = parseMetalShader(vertexMetal) + includes.formUnion(vertexComponents.includes) + typeDefinitions.append(contentsOf: vertexComponents.types) + vertexMain = vertexComponents.mainFunction + + // Process fragment shader - rename main0 to fragment_main0 + let fragmentComponents = parseMetalShader(fragmentMetal) + includes.formUnion(fragmentComponents.includes) + // Add fragment types but avoid duplicates + for ftype in fragmentComponents.types { + if !typeDefinitions.contains(where: { $0.contains(ftype.components(separatedBy: " ")[1]) }) { + typeDefinitions.append(ftype) + } + } + fragmentMain = fragmentComponents.mainFunction + + // Build combined shader + var combined = """ + // Combined RetroArch shader compiled for Metal + // Auto-generated by ScreenShader + + #include + #include + + using namespace metal; + + """ + + // Add type definitions from vertex shader + combined += "\n// === Vertex Shader Types and Code ===\n" + combined += vertexMetal + + // Add fragment shader with renamed function + combined += "\n// === Fragment Shader Types and Code ===\n" + // Rename the fragment main function to avoid conflict + let renamedFragmentMetal = fragmentMetal + .replacingOccurrences(of: "vertex main0", with: "fragment fragment_main0") + .replacingOccurrences(of: "fragment main0", with: "fragment fragment_main0") + combined += renamedFragmentMetal + + return combined + } + + /// Parse a Metal shader to extract includes, types, and main function + private static func parseMetalShader(_ source: String) -> (includes: [String], types: [String], mainFunction: String) { + var includes: [String] = [] + var types: [String] = [] + var mainFunction = "" + + let lines = source.components(separatedBy: .newlines) + var inMain = false + var braceCount = 0 + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("#include") { + includes.append(line) + } else if trimmed.hasPrefix("struct ") || trimmed.hasPrefix("constant ") { + types.append(line) + } + + if trimmed.contains("main0(") { + inMain = true + } + + if inMain { + mainFunction += line + "\n" + braceCount += line.filter { $0 == "{" }.count + braceCount -= line.filter { $0 == "}" }.count + if braceCount == 0 && mainFunction.contains("{") { + inMain = false + } + } + } + + return (includes, types, mainFunction) + } +} + +// MARK: - Integration with SlangCompiler + +extension SlangCompiler { + + /// Detect shader type and compile appropriately + static func compileShader(source: String, shaderDirectory: URL? = nil) throws -> (metalSource: String, parameters: [ShaderParameter], isRetroArch: Bool) { + if RetroArchShaderCompiler.isRetroArchShader(source) { + // Use RetroArch compiler + if !RetroArchShaderCompiler.isAvailable { + let missing = RetroArchShaderCompiler.missingTools.joined(separator: ", ") + throw SlangCompilerError.compilationFailed( + "RetroArch shader detected but required tools are missing: \(missing)" + ) + } + + let compiled = try RetroArchShaderCompiler.compileToMetal( + source: source, + shaderDirectory: shaderDirectory + ) + return (compiled.metalSource, compiled.parameters, true) + } else { + // Use standard Slang compiler + let wrappedSource = wrapEffectSource(source) + let metalSource = try compileToMetal(slangSource: wrappedSource) + return (metalSource, [], false) + } + } +} diff --git a/ScreenShader/slang_compiler.swift b/ScreenShader/slang_compiler.swift index f8c5c5b..d9969da 100644 --- a/ScreenShader/slang_compiler.swift +++ b/ScreenShader/slang_compiler.swift @@ -21,6 +21,64 @@ enum SlangCompilerError: Error, LocalizedError { } } +/// Represents a shader parameter parsed from #pragma parameter directives +/// Format: #pragma parameter NAME "Description" default min max step +struct ShaderParameter { + let name: String + let description: String + let defaultValue: Float + let minValue: Float + let maxValue: Float + let stepValue: Float + + /// Parse a #pragma parameter line + /// Example: #pragma parameter DARKEN_COLOUR "Darken Colours" 0.0 0.0 2.0 0.05 + static func parse(from line: String) -> ShaderParameter? { + // Remove the #pragma parameter prefix + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("#pragma parameter") else { return nil } + + let content = String(trimmed.dropFirst("#pragma parameter".count)) + .trimmingCharacters(in: .whitespaces) + + // Parse: NAME "Description" default min max [step] + var scanner = Scanner(string: content) + scanner.charactersToBeSkipped = CharacterSet.whitespaces + + // Parse name + guard let name = scanner.scanUpToCharacters(from: .whitespaces) else { return nil } + + // Parse quoted description + guard scanner.scanString("\"") != nil else { return nil } + guard let description = scanner.scanUpToString("\"") else { return nil } + guard scanner.scanString("\"") != nil else { return nil } + + // Parse numeric values + guard let defaultValue = scanner.scanFloat() else { return nil } + guard let minValue = scanner.scanFloat() else { return nil } + guard let maxValue = scanner.scanFloat() else { return nil } + + // Step is optional + let stepValue = scanner.scanFloat() ?? 0.01 + + return ShaderParameter( + name: name, + description: description, + defaultValue: defaultValue, + minValue: minValue, + maxValue: maxValue, + stepValue: stepValue + ) + } +} + +/// Result of preprocessing a RetroArch-style shader +struct PreprocessedShader { + let source: String + let parameters: [ShaderParameter] + let isRetroArchStyle: Bool +} + /// Wrapper for the Slang shader compiler class SlangCompiler {