From b0e5cf082c3ea1d33915ae3cf19b8c5033bb21fe Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 17 Sep 2026 16:16:06 -0700 Subject: [PATCH] refactor: test the production shader rendering core --- ScreenSlanger/renderer.swift | 206 ++++++++++++--------------- ScreenSlangerTests/ShaderTests.swift | 107 ++++++-------- 2 files changed, 135 insertions(+), 178 deletions(-) diff --git a/ScreenSlanger/renderer.swift b/ScreenSlanger/renderer.swift index 76889cb..6b538b9 100644 --- a/ScreenSlanger/renderer.swift +++ b/ScreenSlanger/renderer.swift @@ -472,17 +472,7 @@ class MetalRenderer { return } - let renderPassDescriptor = MTLRenderPassDescriptor() - renderPassDescriptor.colorAttachments[0].texture = drawable.texture - renderPassDescriptor.colorAttachments[0].loadAction = .clear - renderPassDescriptor.colorAttachments[0].storeAction = .store - renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) - - guard let commandBuffer = shared.commandQueue.makeCommandBuffer(), - let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) - else { - return - } + guard let commandBuffer = shared.commandQueue.makeCommandBuffer() else { return } // Set scissor rect to exclude the menu bar. // The scissor rect is relative to the drawable, not global screen coordinates. @@ -517,71 +507,20 @@ class MetalRenderer { width: clampedWidth, height: clampedHeight ) - encoder.setScissorRect(scissorRect) - - if let renderPipeline = shared.renderPipeline { - encoder.setRenderPipelineState(renderPipeline) - - switch shared.activeShaderType { - case .retroArch: - // Set up RetroArch-style uniforms - encodeRetroArchUniforms( - encoder: encoder, - textureWidth: width, - textureHeight: height - ) - - // Bind all textures based on their Metal bindings (parsed from generated code) - let samplers = shared.getTextureSamplers() - - // If no samplers were parsed, fallback to binding Source at index 0 - if samplers.isEmpty { - encoder.setFragmentTexture(texture, index: 0) - encoder.setFragmentSamplerState(shared.samplerState, index: 0) - } - - for sampler in samplers { - if sampler.name == "Source" { - // Source is the screen capture texture - encoder.setFragmentTexture(texture, index: sampler.binding) - encoder.setFragmentSamplerState(shared.samplerState, index: sampler.binding) - } else if let loadedTexture = shared.getTexture(named: sampler.name) { - encoder.setFragmentTexture(loadedTexture, index: sampler.binding) - encoder.setFragmentSamplerState(shared.repeatSamplerState, index: sampler.binding) - } else { - // Use source texture as fallback to avoid validation errors - encoder.setFragmentTexture(texture, index: sampler.binding) - encoder.setFragmentSamplerState(shared.repeatSamplerState, index: sampler.binding) - } - } - - case .slang: - // Slang shaders use fixed texture binding at index 0 - encoder.setFragmentTexture(texture, index: 0) - encoder.setFragmentSamplerState(shared.samplerState, index: 0) - - // Slang shaders expect a Uniforms struct at buffer(0) - let screenSize = vector_float2(Float(self.screen.frame.width), Float(self.screen.frame.height)) - let mousePosition = vector_float2( - Float(NSEvent.mouseLocation.x), Float(NSEvent.mouseLocation.y)) - let time = Float(ProcessInfo.processInfo.systemUptime - shared.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) + let context = ShaderFrameContext( + outputSize: SIMD2(Float(drawableWidth), Float(drawableHeight)), + mousePosition: SIMD2( + Float((NSEvent.mouseLocation.x - screenFrame.minX) * scaleFactor), + Float((NSEvent.mouseLocation.y - screenFrame.minY) * scaleFactor)), + time: Float(ProcessInfo.processInfo.systemUptime - shared.baseTime)) + do { + try ShaderRenderCore(resources: shared).encode( + commandBuffer: commandBuffer, source: texture, destination: drawable.texture, + context: context, scissor: scissorRect) + } catch { + return } - encoder.endEncoding() - // Core Video may recycle the capture surface before the GPU has sampled it. // Retain its texture wrapper and pixel buffer until this command finishes. let retainedSurface = RetainedCaptureSurface(texture: textureRef, buffer: contentBuffer) @@ -592,50 +531,93 @@ class MetalRenderer { commandBuffer.commit() } - /// Encode RetroArch-style uniforms for the shader +} + +/// Inputs shared by onscreen drawing and offscreen regression tests, in pixels. +struct ShaderFrameContext { + var outputSize: SIMD2 + var mousePosition: SIMD2 = .zero + var time: Float = 0 + var frameCount: UInt32 = 0 +} + +enum ShaderRenderError: Error { + case noPipeline + case encoderUnavailable +} + +/// The production texture binding and draw path. It does not depend on windows or capture. +@MainActor +final class ShaderRenderCore { + let resources: SharedMetalResources + + init(resources: SharedMetalResources = .shared) { + self.resources = resources + } + + func encode( + commandBuffer: MTLCommandBuffer, source: MTLTexture, destination: MTLTexture, + context: ShaderFrameContext, scissor: MTLScissorRect? = nil + ) throws { + guard let pipeline = resources.renderPipeline else { throw ShaderRenderError.noPipeline } + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = destination + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + pass.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { + throw ShaderRenderError.encoderUnavailable + } + defer { encoder.endEncoding() } + encoder.setRenderPipelineState(pipeline) + if let scissor { encoder.setScissorRect(scissor) } + + switch resources.activeShaderType { + case .retroArch: + encodeRetroArchUniforms(encoder: encoder, source: source, context: context) + let samplers = resources.getTextureSamplers() + if samplers.isEmpty { + encoder.setFragmentTexture(source, index: 0) + encoder.setFragmentSamplerState(resources.samplerState, index: 0) + } + for sampler in samplers { + let texture = sampler.name == "Source" ? source + : resources.getTexture(named: sampler.name) ?? source + encoder.setFragmentTexture(texture, index: sampler.binding) + encoder.setFragmentSamplerState( + sampler.name == "Source" ? resources.samplerState : resources.repeatSamplerState, + index: sampler.binding) + } + case .slang: + encoder.setFragmentTexture(source, index: 0) + encoder.setFragmentSamplerState(resources.samplerState, index: 0) + var uniforms = SlangUniforms( + screenSize: context.outputSize, mousePosition: context.mousePosition, time: context.time) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) + case .none: + throw ShaderRenderError.noPipeline + } + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + } + private func encodeRetroArchUniforms( - encoder: MTLRenderCommandEncoder, - textureWidth: Int, - textureHeight: Int + encoder: MTLRenderCommandEncoder, source: MTLTexture, context: ShaderFrameContext ) { - let scaleFactor = self.screen.backingScaleFactor - let outputWidth = Float(self.screen.frame.width * scaleFactor) - let outputHeight = Float(self.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)) - } - - // IMPORTANT: float4 members require 16-byte alignment in Metal - // After N floats, we need to pad to the next 16-byte boundary (4 floats) - // 7 floats = 28 bytes, next 16-byte boundary is 32 bytes (8 floats) - while pushBuffer.count % 4 != 0 { - pushBuffer.append(0) // Padding for alignment + var pushBuffer = resources.parameterState.parameters.map { + resources.parameterState.getValue(for: $0.name) } - - // Add standard RetroArch parameters (OutputSize, OriginalSize, SourceSize) - // These are vec4s: xy = size, zw = 1.0/size + while pushBuffer.count % 4 != 0 { pushBuffer.append(0) } + let width = Float(source.width) + let height = Float(source.height) + let output = context.outputSize 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 + output.x, output.y, 1 / output.x, 1 / output.y, + width, height, 1 / width, 1 / height, + width, height, 1 / width, 1 / height ]) - - // Pad to 16-byte alignment - while pushBuffer.count % 4 != 0 { - pushBuffer.append(0) + pushBuffer.withUnsafeBytes { bytes in + encoder.setFragmentBytes(bytes.baseAddress!, length: bytes.count, index: 0) } - - encoder.setFragmentBytes(pushBuffer, length: pushBuffer.count * MemoryLayout.stride, index: 0) - - // Note: We don't need vertex uniforms since our custom vertex shader generates the quad procedurally } } diff --git a/ScreenSlangerTests/ShaderTests.swift b/ScreenSlangerTests/ShaderTests.swift index ed0f41f..8deec69 100644 --- a/ScreenSlangerTests/ShaderTests.swift +++ b/ScreenSlangerTests/ShaderTests.swift @@ -24,38 +24,24 @@ struct ShaderTests { @Test("Slang texture sampling preserves all four texels") func slangTextureSampling() throws { - let device = try #require(MTLCreateSystemDefaultDevice(), "A Metal device is required") - let pipeline = try MetalRenderer.buildRenderPipeline( - device: device, effectSource: fixtureSource("passthrough")) - let output = try render(device: device, pipeline: pipeline, pixels: inputPixels) { encoder in - var uniforms = SlangUniforms(screenSize: SIMD2(2, 2), mousePosition: .zero, time: 0) - encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) - } + let shared = SharedMetalResources.shared + defer { try? shared.setEffectSource(nil) } + try shared.setEffectSource(fixtureSource("passthrough")) + let output = try render() try expectPixels(output, expected: inputPixels) } @Test("RetroArch helper, texture binding, and push parameter render correctly") func retroArchParametersAndSampling() throws { - let device = try #require(MTLCreateSystemDefaultDevice(), "A Metal device is required") + let shared = SharedMetalResources.shared + defer { try? shared.setEffectSource(nil) } let fixture = try fixtureURL("retroarch-helper") - let source = try String(contentsOf: fixture, encoding: .utf8) - let (pipeline, parameters, samplers) = try MetalRenderer.buildRetroArchPipeline( - device: device, effectSource: source, shaderDirectory: fixture.deletingLastPathComponent()) - try #require(parameters.count == 1, "Expected one GAIN parameter") + try shared.setEffectSource(String(contentsOf: fixture, encoding: .utf8), shaderPath: fixture.path) + let parameters = shared.parameterState.parameters + try #require(parameters.count == 1) #expect(parameters[0].name == "GAIN") #expect(parameters[0].defaultValue == 0.5) - let sourceSampler = try #require( - samplers.first(where: { $0.name == "Source" }), "Generated Metal lost the Source sampler binding") - let output = try render( - device: device, pipeline: pipeline, pixels: inputPixels, textureIndex: sourceSampler.binding - ) { encoder in - // Match the fixture's std430 layout: a float, padding, then three vec4s. - let push: [Float] = [parameters[0].defaultValue, 0, 0, 0, - 2, 2, 0.5, 0.5, 2, 2, 0.5, 0.5, 2, 2, 0.5, 0.5] - push.withUnsafeBytes { bytes in - encoder.setFragmentBytes(bytes.baseAddress!, length: bytes.count, index: 0) - } - } + let output = try render() let expected: [UInt8] = [ 0, 0, 128, 255, 0, 128, 0, 255, 128, 0, 0, 255, 128, 128, 128, 255, @@ -111,11 +97,29 @@ struct ShaderTests { shaderPath: fixture.path) let pipeline = try #require(shared.renderPipeline, "Edited shader did not create a pipeline") #expect(pipeline !== previous, "Edited shader reused the previous pipeline") - let output = try render(device: shared.device, pipeline: pipeline, pixels: inputPixels) { _ in } + let output = try render() let green: [UInt8] = [0, 255, 0, 255] try expectPixels(output, expected: Array(repeating: green, count: 4).flatMap { $0 }) } + @Test("Frame context drives production Slang uniforms without a new input image") + func nativeFrameContext() throws { + let shared = SharedMetalResources.shared + defer { try? shared.setEffectSource(nil) } + try shared.setEffectSource(""" + float4 shaderFunction(ShaderInput input) { + return float4(input.time, input.mousePosition.x / input.screenSize.x, + input.mousePosition.y / input.screenSize.y, 1.0); + } + """) + let first = try render(context: ShaderFrameContext( + outputSize: SIMD2(2, 2), mousePosition: SIMD2(1, 2), time: 0)) + try expectPixels(first, expected: Array(repeating: [255, 128, 0, 255], count: 4).flatMap { $0 }) + let second = try render(context: ShaderFrameContext( + outputSize: SIMD2(2, 2), mousePosition: SIMD2(1, 2), time: 1)) + try expectPixels(second, expected: Array(repeating: [255, 128, 255, 255], count: 4).flatMap { $0 }) + } + @Test("Invalid Slang returns a compiler diagnostic") func invalidSlangDiagnostic() throws { do { @@ -188,14 +192,9 @@ struct ShaderTests { let shared = SharedMetalResources.shared defer { try? shared.setEffectSource(nil) } try shared.setEffectSource(preset, shaderPath: presetURL.path) - let texture = try #require(shared.getTexture(named: "BACKGROUND"), "Preset did not load its texture") + _ = try #require(shared.getTexture(named: "BACKGROUND"), "Preset did not load its texture") let pipeline = try #require(shared.renderPipeline, "Preset did not create its pipeline") - let binding = try #require( - shared.getTextureSamplers().first(where: { $0.name == "BACKGROUND" })?.binding, - "Preset did not create its texture sampler") - let output = try render( - device: shared.device, pipeline: pipeline, pixels: [], textureIndex: binding, sourceTexture: texture - ) { _ in } + let output = try render() let pixel: [UInt8] = grayscale ? [128, 128, 128, 255] : [0, 0, 255, 255] try expectPixels(output, expected: Array(repeating: pixel, count: 4).flatMap { $0 }) @@ -209,9 +208,7 @@ struct ShaderTests { try shared.setEffectSource(preset, shaderPath: presetURL.path) let reloadedPipeline = try #require(shared.renderPipeline, "Preset reload did not create a pipeline") #expect(reloadedPipeline !== pipeline, "Preset reload reused the previous shader pipeline") - let reloadedOutput = try render( - device: shared.device, pipeline: reloadedPipeline, pixels: [], sourceTexture: texture - ) { _ in } + let reloadedOutput = try render() let green: [UInt8] = [0, 255, 0, 255] try expectPixels(reloadedOutput, expected: Array(repeating: green, count: 4).flatMap { $0 }) } @@ -226,45 +223,23 @@ struct ShaderTests { } private func render( - device: MTLDevice, - pipeline: MTLRenderPipelineState, - pixels: [UInt8], - textureIndex: Int = 0, - sourceTexture: MTLTexture? = nil, - uniforms: (MTLRenderCommandEncoder) -> Void + context: ShaderFrameContext = ShaderFrameContext(outputSize: SIMD2(2, 2)) ) throws -> [UInt8] { + let shared = SharedMetalResources.shared + let device = shared.device let descriptor = MTLTextureDescriptor.texture2DDescriptor( pixelFormat: .bgra8Unorm, width: 2, height: 2, mipmapped: false) descriptor.storageMode = .shared descriptor.usage = [.shaderRead, .renderTarget] - let source = try #require(sourceTexture ?? device.makeTexture(descriptor: descriptor)) + let source = try #require(device.makeTexture(descriptor: descriptor)) let destination = try #require(device.makeTexture(descriptor: descriptor)) - let queue = try #require(device.makeCommandQueue()) - let commands = try #require(queue.makeCommandBuffer()) + let commands = try #require(shared.commandQueue.makeCommandBuffer()) let region = MTLRegionMake2D(0, 0, 2, 2) - if sourceTexture == nil { - try #require(pixels.count == 16, "Rendering a 2×2 BGRA texture requires 16 bytes") - pixels.withUnsafeBytes { bytes in - source.replace(region: region, mipmapLevel: 0, withBytes: bytes.baseAddress!, bytesPerRow: 8) - } + inputPixels.withUnsafeBytes { bytes in + source.replace(region: region, mipmapLevel: 0, withBytes: bytes.baseAddress!, bytesPerRow: 8) } - let samplerDescriptor = MTLSamplerDescriptor() - samplerDescriptor.minFilter = .nearest - samplerDescriptor.magFilter = .nearest - samplerDescriptor.sAddressMode = .clampToEdge - samplerDescriptor.tAddressMode = .clampToEdge - let sampler = try #require(device.makeSamplerState(descriptor: samplerDescriptor)) - let pass = MTLRenderPassDescriptor() - pass.colorAttachments[0].texture = destination - pass.colorAttachments[0].loadAction = .clear - pass.colorAttachments[0].storeAction = .store - let encoder = try #require(commands.makeRenderCommandEncoder(descriptor: pass)) - encoder.setRenderPipelineState(pipeline) - encoder.setFragmentTexture(source, index: textureIndex) - encoder.setFragmentSamplerState(sampler, index: textureIndex) - uniforms(encoder) - encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) - encoder.endEncoding() + try ShaderRenderCore(resources: shared).encode( + commandBuffer: commands, source: source, destination: destination, context: context) commands.commit() commands.waitUntilCompleted() if let error = commands.error { throw error } -- 2.51.2