From 4306f0fea62494ac1cfaa25f655165f8525c0762 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 16 Dec 2025 14:31:16 -0800 Subject: [PATCH] feat: initial work to support SLANG format --- ScreenShader.xcodeproj/project.pbxproj | 4 + ScreenShader/config.swift | 181 +++++++++++++++++++++- ScreenShader/config_view.swift | 4 +- ScreenShader/effect_view.swift | 58 +++++++ ScreenShader/overlay.swift | 3 +- ScreenShader/renderer.swift | 100 ++++++++++++- ScreenShader/slang_compiler.swift | 199 +++++++++++++++++++++++++ 7 files changed, 541 insertions(+), 8 deletions(-) create mode 100644 ScreenShader/slang_compiler.swift diff --git a/ScreenShader.xcodeproj/project.pbxproj b/ScreenShader.xcodeproj/project.pbxproj index cb778a4..d3564ec 100644 --- a/ScreenShader.xcodeproj/project.pbxproj +++ b/ScreenShader.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 416975FE2D497E21004C8E93 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416975F42D497E21004C8E93 /* main.swift */; }; 416975FF2D497E21004C8E93 /* effect_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416975F52D497E21004C8E93 /* effect_view.swift */; }; 41C290E42D4B04A100675317 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 41C290E32D4B04A100675317 /* Sparkle */; }; + 9736AA942EF210C1002DDC37 /* slang_compiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9736AA932EF210C1002DDC37 /* slang_compiler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -40,6 +41,7 @@ 41C290DF2D4AC87F00675317 /* red_blue_swap.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = red_blue_swap.png; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -86,6 +88,7 @@ 416975F32D497E21004C8E93 /* error_message.swift */, 416975F42D497E21004C8E93 /* main.swift */, 416975EC2D497E21004C8E93 /* metrics.swift */, + 9736AA932EF210C1002DDC37 /* slang_compiler.swift */, 416975F22D497E21004C8E93 /* overlay.swift */, 416975EF2D497E21004C8E93 /* renderer.swift */, 416975EE2D497E21004C8E93 /* screen_capture.swift */, @@ -186,6 +189,7 @@ 416975FC2D497E21004C8E93 /* overlay.swift in Sources */, 416975FD2D497E21004C8E93 /* error_message.swift in Sources */, 416975F92D497E21004C8E93 /* renderer.swift in Sources */, + 9736AA942EF210C1002DDC37 /* slang_compiler.swift in Sources */, 416975FA2D497E21004C8E93 /* config_view.swift in Sources */, 416975F62D497E21004C8E93 /* metrics.swift in Sources */, 416975FF2D497E21004C8E93 /* effect_view.swift in Sources */, diff --git a/ScreenShader/config.swift b/ScreenShader/config.swift index e98af65..dd167ae 100644 --- a/ScreenShader/config.swift +++ b/ScreenShader/config.swift @@ -1,5 +1,25 @@ import AppKit +/// Shader language types supported by ScreenShader +enum ShaderLanguage: String, Codable, CaseIterable { + case metal = "metal" + case slang = "slang" + + var displayName: String { + switch self { + case .metal: return "Metal" + case .slang: return "Slang" + } + } + + var fileExtension: String { + switch self { + case .metal: return "metal" + case .slang: return "slang" + } + } +} + let defaultShaderSource: String = """ /************************************************************** @@ -46,6 +66,49 @@ let defaultShaderSource: String = """ } """ +let defaultSlangShaderSource: String = """ + /************************************************************** + + These are the inputs provided to the shader (Slang version): + + struct ShaderInput { + // The texture coordinates for indexing into the input at the current + // position. The origin is at the top left of the screen. + float2 texCoord; + // The current position in pixels, with (0,0) at the bottom left of the + // screen. + float2 screenPosition; + // The screen size in pixels. + float2 screenSize; + // The current position of the mouse cursor in pixels, with (0,0) at + // the bottom left of the screen. + float2 mousePosition; + // The elapsed time since the system started in seconds. + float time; + }; + + Available functions: + float4 sampleInput(float2 texCoord) - Sample the input screen texture + float2 texToScreen(float2 texCoord, float2 screenSize) + float2 screenToTex(float2 screenPosition, float2 screenSize) + + **************************************************************/ + + // Don't change the name or signature of this function: + float4 shaderFunction(ShaderInput input) { + float4 inputColor = sampleInput(input.texCoord); + + float4 resultColor = float4( + inputColor.r, + inputColor.g, + inputColor.b, + inputColor.a + ); + + return resultColor; + } + """ + let predefinedShaders: [(String, String)] = [ ( "Swap red-blue channels", @@ -153,6 +216,70 @@ let predefinedShaders: [(String, String)] = [ ), ] +/// Predefined Slang shaders (shader language, name, source) +let predefinedSlangShaders: [(String, String)] = [ + ( + "Swap red-blue channels (Slang)", + """ + float4 shaderFunction(ShaderInput input) { + float4 inputColor = sampleInput(input.texCoord); + return float4(inputColor.b, inputColor.g, inputColor.r, inputColor.a); + } + """ + ), + ( + "Grey scale (Slang)", + """ + float4 shaderFunction(ShaderInput input) { + float4 inputColor = sampleInput(input.texCoord); + float grey = dot(inputColor.rgb, float3(0.299, 0.587, 0.114)); + return float4(grey, grey, grey, inputColor.a); + } + """ + ), + ( + "Color Invert (Slang)", + """ + float4 shaderFunction(ShaderInput input) { + float4 inputColor = sampleInput(input.texCoord); + return float4(1.0 - inputColor.rgb, inputColor.a); + } + """ + ), + ( + "Sepia Tone (Slang)", + """ + float4 shaderFunction(ShaderInput input) { + float4 inputColor = sampleInput(input.texCoord); + + float3 sepia; + sepia.r = dot(inputColor.rgb, float3(0.393, 0.769, 0.189)); + sepia.g = dot(inputColor.rgb, float3(0.349, 0.686, 0.168)); + sepia.b = dot(inputColor.rgb, float3(0.272, 0.534, 0.131)); + + return float4(sepia, inputColor.a); + } + """ + ), + ( + "Vignette (Slang)", + """ + float4 shaderFunction(ShaderInput input) { + float4 inputColor = sampleInput(input.texCoord); + + // Calculate distance from center + float2 center = float2(0.5, 0.5); + float dist = distance(input.texCoord, center); + + // Create vignette effect + float vignette = 1.0 - smoothstep(0.3, 0.8, dist); + + return float4(inputColor.rgb * vignette, inputColor.a); + } + """ + ), +] + class Config: Codable { var configVersion: Int = 1 var effects: Effects = Effects() @@ -203,11 +330,20 @@ class Config: Codable { } if config.effects.effectList().isEmpty { + // Add predefined Metal shaders for (name, shader) in predefinedShaders { - let effect = config.effects.new() + let effect = config.effects.new(language: .metal) config.effects.setName(effect: effect, newName: name) config.effects.setShader(effect: effect, shader: shader) } + // Add predefined Slang shaders (only if slangc is available) + if SlangCompiler.isAvailable { + for (name, shader) in predefinedSlangShaders { + let effect = config.effects.new(language: .slang) + config.effects.setName(effect: effect, newName: name) + config.effects.setShader(effect: effect, shader: shader) + } + } } return config @@ -221,21 +357,52 @@ class Effects: Codable { private var effectToName: [UUID: String] = [:] private var effectToShader: [UUID: String] = [:] private var effectToActive: [UUID: Bool] = [:] + private var effectToLanguage: [UUID: ShaderLanguage] = [:] private var mostRecentActiveEffect: UUID? = nil + + // Custom CodingKeys to handle optional effectToLanguage + private enum CodingKeys: String, CodingKey { + case nextEffectNumber + case effects + case deletedEffects + case effectToName + case effectToShader + case effectToActive + case effectToLanguage + case mostRecentActiveEffect + } + + // Default initializer + init() {} + + // Custom decoder to handle missing effectToLanguage in old config files + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + nextEffectNumber = try container.decode(Int.self, forKey: .nextEffectNumber) + effects = try container.decode([UUID].self, forKey: .effects) + deletedEffects = try container.decode([UUID].self, forKey: .deletedEffects) + effectToName = try container.decode([UUID: String].self, forKey: .effectToName) + effectToShader = try container.decode([UUID: String].self, forKey: .effectToShader) + effectToActive = try container.decode([UUID: Bool].self, forKey: .effectToActive) + // Handle missing effectToLanguage for backwards compatibility + effectToLanguage = try container.decodeIfPresent([UUID: ShaderLanguage].self, forKey: .effectToLanguage) ?? [:] + mostRecentActiveEffect = try container.decodeIfPresent(UUID.self, forKey: .mostRecentActiveEffect) + } func effectList() -> [UUID] { return self.effects } - func new() -> UUID { + func new(language: ShaderLanguage = .metal) -> UUID { let newEffectID = UUID() let newEffectName = "Effect \(self.nextEffectNumber)" self.nextEffectNumber += 1 self.effects.append(newEffectID) self.effectToName[newEffectID] = newEffectName - self.effectToShader[newEffectID] = defaultShaderSource + self.effectToShader[newEffectID] = language == .slang ? defaultSlangShaderSource : defaultShaderSource self.effectToActive[newEffectID] = false + self.effectToLanguage[newEffectID] = language return newEffectID } @@ -285,6 +452,14 @@ class Effects: Codable { self.effectToShader[effect] = shader } + func getLanguage(effect: UUID) -> ShaderLanguage { + return self.effectToLanguage[effect] ?? .metal + } + + func setLanguage(effect: UUID, language: ShaderLanguage) { + self.effectToLanguage[effect] = language + } + func getActiveEffect() -> UUID? { for effect in self.effects { if self.isActive(effect: effect) { diff --git a/ScreenShader/config_view.swift b/ScreenShader/config_view.swift index 3e803b4..8db5133 100644 --- a/ScreenShader/config_view.swift +++ b/ScreenShader/config_view.swift @@ -124,7 +124,9 @@ class ConfigViewController: NSViewController, NSTableViewDelegate, NSTableViewDa let effect = self.effects.effectList()[row] let name = self.effects.getName(effect: effect) let isActive = self.effects.isActive(effect: effect) - return isActive ? "\(name) (active)" : name + let language = self.effects.getLanguage(effect: effect) + let languageTag = language == .slang ? " [Slang]" : "" + return isActive ? "\(name)\(languageTag) (active)" : "\(name)\(languageTag)" } func tableViewSelectionDidChange(_ notification: Notification) { diff --git a/ScreenShader/effect_view.swift b/ScreenShader/effect_view.swift index 8d4060f..a1b179d 100644 --- a/ScreenShader/effect_view.swift +++ b/ScreenShader/effect_view.swift @@ -15,6 +15,8 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel private var stackView: NSStackView! = nil private var nameField: NSTextField! = nil private var activeButton: NSButton! = nil + private var languageLabel: NSTextField! = nil + private var languagePopup: NSPopUpButton! = nil private var deleteButton: NSButton! = nil private var shaderField: NSTextView! = nil private var saveButton: NSButton! = nil @@ -45,6 +47,36 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel self.activeButton.translatesAutoresizingMaskIntoConstraints = false self.activeButton.state = self.effects.isActive(effect: self.effect) ? .on : .off self.stackView.addArrangedSubview(self.activeButton) + + // Shader language selector + let languageStack = NSStackView() + languageStack.orientation = .horizontal + languageStack.spacing = 8 + languageStack.alignment = .centerY + languageStack.translatesAutoresizingMaskIntoConstraints = false + + self.languageLabel = NSTextField(labelWithString: "Shader Language:") + self.languageLabel.translatesAutoresizingMaskIntoConstraints = false + languageStack.addArrangedSubview(self.languageLabel) + + self.languagePopup = NSPopUpButton(frame: .zero, pullsDown: false) + self.languagePopup.translatesAutoresizingMaskIntoConstraints = false + self.languagePopup.addItems(withTitles: ShaderLanguage.allCases.map { $0.displayName }) + let currentLanguage = self.effects.getLanguage(effect: self.effect) + self.languagePopup.selectItem(withTitle: currentLanguage.displayName) + self.languagePopup.target = self + self.languagePopup.action = #selector(self.languageChanged) + languageStack.addArrangedSubview(self.languagePopup) + + // Add Slang availability indicator + if !SlangCompiler.isAvailable { + let warningLabel = NSTextField(labelWithString: "⚠️ slangc not found") + warningLabel.textColor = .systemOrange + warningLabel.translatesAutoresizingMaskIntoConstraints = false + languageStack.addArrangedSubview(warningLabel) + } + + self.stackView.addArrangedSubview(languageStack) self.deleteButton = NSButton( title: "Delete effect", @@ -97,6 +129,32 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel self.effects.delete(effect: self.effect) self.onDelete() } + + @objc func languageChanged() { + guard let selectedTitle = self.languagePopup.selectedItem?.title, + let newLanguage = ShaderLanguage.allCases.first(where: { $0.displayName == selectedTitle }) else { + return + } + + let currentLanguage = self.effects.getLanguage(effect: self.effect) + if newLanguage != currentLanguage { + // Update the language + self.effects.setLanguage(effect: self.effect, language: newLanguage) + + // Optionally update the shader template to the default for the new language + // if the current shader is the default template for the old language + let currentShader = self.effects.getShader(effect: self.effect) + let oldDefaultShader = currentLanguage == .slang ? defaultSlangShaderSource : defaultShaderSource + + if currentShader == oldDefaultShader { + let newDefaultShader = newLanguage == .slang ? defaultSlangShaderSource : defaultShaderSource + self.effects.setShader(effect: self.effect, shader: newDefaultShader) + self.shaderField.string = newDefaultShader + } + + self.onUpdate() + } + } @objc func onSaveButton() { self.effects.setShader(effect: self.effect, shader: self.shaderField.string) diff --git a/ScreenShader/overlay.swift b/ScreenShader/overlay.swift index 57b200c..a893447 100644 --- a/ScreenShader/overlay.swift +++ b/ScreenShader/overlay.swift @@ -89,9 +89,10 @@ class OverlayController: NSObject, MTKViewDelegate { let active = activeEffect != nil let activeEffectShader = active ? self.config.effects.getShader(effect: activeEffect!) : nil + let activeEffectLanguage = active ? self.config.effects.getLanguage(effect: activeEffect!) : .metal do { - try self.renderer.setEffectSource(activeEffectShader) + try self.renderer.setEffectSource(activeEffectShader, language: activeEffectLanguage) self.errorMessage.clear() } catch { print("Effect shader error: \(error.localizedDescription)") diff --git a/ScreenShader/renderer.swift b/ScreenShader/renderer.swift index 0432141..d577e67 100644 --- a/ScreenShader/renderer.swift +++ b/ScreenShader/renderer.swift @@ -46,7 +46,20 @@ class MetalRenderer { } } - static func buildRenderPipeline(device: MTLDevice, effectSource: String) throws + /// Build a render pipeline from Metal shader effect source + static func buildRenderPipeline(device: MTLDevice, effectSource: String, language: ShaderLanguage = .metal) throws + -> MTLRenderPipelineState + { + switch language { + case .metal: + return try buildMetalRenderPipeline(device: device, effectSource: effectSource) + case .slang: + return try buildSlangRenderPipeline(device: device, effectSource: effectSource) + } + } + + /// Build a render pipeline from Metal shader effect source + private static func buildMetalRenderPipeline(device: MTLDevice, effectSource: String) throws -> MTLRenderPipelineState { let librarySource = """ @@ -143,8 +156,89 @@ class MetalRenderer { return try device.makeRenderPipelineState(descriptor: pipelineDescriptor) } + + /// Build a render pipeline from Slang shader effect source + /// This compiles the Slang code to Metal, then builds the pipeline + private static func buildSlangRenderPipeline(device: MTLDevice, effectSource: String) throws + -> MTLRenderPipelineState + { + // Wrap the user's effect code in the Slang framework + let wrappedSource = SlangCompiler.wrapEffectSource(effectSource) + + // Compile Slang to Metal shader source + let metalFragmentSource: String + do { + metalFragmentSource = try SlangCompiler.compileToMetal(slangSource: wrappedSource) + } catch { + throw NSError( + domain: "MetalRenderer", code: 3, + userInfo: [ + NSLocalizedDescriptionKey: "Slang compilation failed: \(error.localizedDescription)" + ]) + } + + // Build the complete Metal library source with the Slang-generated fragment shader + // and our standard vertex shader + let librarySource = """ + #include + using namespace metal; + + // ========== Slang-generated fragment shader code ========== + \(metalFragmentSource) + // ========== End Slang-generated code ========== + + // Vertex shader (always Metal, as Slang only generates the fragment shader) + struct VertexOut { + float4 position [[position]]; + float2 texCoord; + }; + + vertex VertexOut vertex_main(uint vertexId [[vertex_id]]) { + float2 quadVertices[6] = { + float2(-1.0, -1.0), + float2( 1.0, -1.0), + float2(-1.0, 1.0), + float2(-1.0, 1.0), + float2( 1.0, -1.0), + float2( 1.0, 1.0) + }; + + VertexOut out; + out.position = float4(quadVertices[vertexId], 0.0, 1.0); + out.texCoord = float2( + (quadVertices[vertexId].x + 1.0) * 0.5, + (-quadVertices[vertexId].y + 1.0) * 0.5); + return out; + } + """ + + let library = try device.makeLibrary(source: librarySource, options: nil) + + let vertexFunction = library.makeFunction(name: "vertex_main") + // The Slang-generated fragment function - look for fragmentMain (Slang naming) + var fragmentFunction = library.makeFunction(name: "fragmentMain") + // Fallback to main if fragmentMain not found (some Slang versions) + if fragmentFunction == nil { + fragmentFunction = library.makeFunction(name: "main") + } + + guard vertexFunction != nil && fragmentFunction != nil else { + throw NSError( + domain: "MetalRenderer", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Could not find vertex_main or fragmentMain in compiled Slang shader." + ]) + } + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + + return try device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } - func setEffectSource(_ effectSource: String?) throws { + func setEffectSource(_ effectSource: String?, language: ShaderLanguage = .metal) throws { guard let effectSource = effectSource else { self.activeEffectSource = nil self.renderPipeline = nil @@ -153,7 +247,7 @@ class MetalRenderer { self.activeEffectSource = effectSource do { self.renderPipeline = try Self.buildRenderPipeline( - device: self.device, effectSource: effectSource) + device: self.device, effectSource: effectSource, language: language) } catch { self.renderPipeline = nil throw error diff --git a/ScreenShader/slang_compiler.swift b/ScreenShader/slang_compiler.swift new file mode 100644 index 0000000..a35abca --- /dev/null +++ b/ScreenShader/slang_compiler.swift @@ -0,0 +1,199 @@ +import Foundation + +/// Errors that can occur during Slang shader compilation +enum SlangCompilerError: Error, LocalizedError { + case slangcNotFound + case compilationFailed(String) + case invalidOutput + case processError(String) + + var errorDescription: String? { + switch self { + case .slangcNotFound: + return "slangc compiler not found. Please install Slang or set SLANG_PATH environment variable." + case .compilationFailed(let message): + return "Slang compilation failed: \(message)" + case .invalidOutput: + return "Slang compiler produced invalid output" + case .processError(let message): + return "Process error: \(message)" + } + } +} + +/// Wrapper for the Slang shader compiler +class SlangCompiler { + + /// Possible locations to search for slangc + private static let slangcSearchPaths = [ + // Environment variable override + ProcessInfo.processInfo.environment["SLANG_PATH"], + // Homebrew installation + "/opt/homebrew/bin/slangc", + "/usr/local/bin/slangc", + // App bundle resources + Bundle.main.path(forResource: "slangc", ofType: nil), + // Development paths + "/usr/local/slang/bin/slangc", + // User home directory + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".slang/bin/slangc").path + ].compactMap { $0 } + + /// Find the slangc executable + static func findSlangc() -> String? { + for path in slangcSearchPaths { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + /// Check if Slang compiler is available + static var isAvailable: Bool { + return findSlangc() != nil + } + + /// Compile Slang shader source code to Metal Shading Language (MSL) + /// - Parameters: + /// - slangSource: The complete Slang shader source code + /// - entryPoint: The name of the entry point function (default: "fragmentMain") + /// - stage: The shader stage (default: "fragment") + /// - Returns: The generated Metal shader source code + static func compileToMetal( + slangSource: String, + entryPoint: String = "fragmentMain", + stage: String = "fragment" + ) throws -> String { + guard let slangcPath = findSlangc() else { + throw SlangCompilerError.slangcNotFound + } + + // Create a temporary directory for compilation + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + // Write the Slang source to a temporary file + let inputFile = tempDir.appendingPathComponent("shader.slang") + let outputFile = tempDir.appendingPathComponent("shader.metal") + + try slangSource.write(to: inputFile, atomically: true, encoding: .utf8) + + // Build the slangc command + let process = Process() + process.executableURL = URL(fileURLWithPath: slangcPath) + process.arguments = [ + inputFile.path, + "-target", "metal", + "-entry", entryPoint, + "-stage", stage, + "-o", outputFile.path + ] + + let errorPipe = Pipe() + process.standardError = errorPipe + process.standardOutput = Pipe() // Capture stdout too + + do { + try process.run() + process.waitUntilExit() + } catch { + throw SlangCompilerError.processError(error.localizedDescription) + } + + // Check for compilation errors + if process.terminationStatus != 0 { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw SlangCompilerError.compilationFailed(errorMessage) + } + + // Read the generated Metal source + guard let metalSource = try? String(contentsOf: outputFile, encoding: .utf8) else { + throw SlangCompilerError.invalidOutput + } + + return metalSource + } + + /// Wrap user-provided Slang effect code with the ScreenShader framework code + /// This creates a complete Slang shader that matches ScreenShader's expectations + static func wrapEffectSource(_ effectSource: String) -> String { + return """ + // ScreenShader Slang wrapper + // This shader is compiled from Slang to Metal + + // Input texture and sampler + Texture2D inputTexture : register(t0); + SamplerState textureSampler : register(s0); + + // Uniform parameters passed from the application + struct Uniforms { + float2 screenSize; + float2 mousePosition; + float time; + }; + + ConstantBuffer uniforms : register(b0); + + // Shader input structure matching ScreenShader's ShaderInput + struct ShaderInput { + float2 texCoord; + float2 screenPosition; + float2 screenSize; + float2 mousePosition; + float time; + }; + + // Utility functions + float2 texToScreen(float2 texCoord, float2 screenSize) { + return float2(texCoord.x * screenSize.x, (1.0 - texCoord.y) * screenSize.y); + } + + float2 screenToTex(float2 screenPosition, float2 screenSize) { + return float2(screenPosition.x / screenSize.x, 1.0 - screenPosition.y / screenSize.y); + } + + // Sample the input texture at the given texture coordinates + float4 sampleInput(float2 texCoord) { + return inputTexture.Sample(textureSampler, texCoord); + } + + // ========== USER EFFECT CODE BEGIN ========== + \(effectSource) + // ========== USER EFFECT CODE END ========== + + // Fragment shader output + struct FragmentOutput { + float4 color : SV_Target0; + }; + + // Fragment shader input from vertex shader + struct FragmentInput { + float4 position : SV_Position; + float2 texCoord : TEXCOORD0; + }; + + // Main fragment shader entry point + [shader("fragment")] + FragmentOutput fragmentMain(FragmentInput input) { + ShaderInput shaderInput; + shaderInput.texCoord = input.texCoord; + shaderInput.screenSize = uniforms.screenSize; + shaderInput.screenPosition = texToScreen(input.texCoord, uniforms.screenSize); + shaderInput.mousePosition = uniforms.mousePosition; + shaderInput.time = uniforms.time; + + FragmentOutput output; + output.color = shaderFunction(shaderInput); + return output; + } + """ + } +} -- 2.51.2