Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/crutchcorn/ScreenSlanger. Easy to use utility to apply slang shaders to your macOS monitors.
Something went wrong. Try again.
50 kB · 952 lines
Swift
at updates
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953import CoreGraphicsimport Foundationimport ImageIOimport Metalimport Testing
private final class ShaderFixtureBundle: NSObject {}
@Suite("Shader rendering and reloads", .serialized)@MainActorstruct ShaderTests { // BGRA bytes for red, green, blue, and white. Distinct texels catch both // incorrect texture coordinates and missing texture bindings. private let inputPixels: [UInt8] = [ 0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255, ]
@Test("waves.slang compiles to a Metal render pipeline") func wavesCompiles() throws { let device = try #require(MTLCreateSystemDefaultDevice(), "A Metal device is required") _ = try MetalRenderer.buildRenderPipeline(device: device, effectSource: fixtureSource("waves")) }
@Test("Slang texture sampling preserves all four texels") func slangTextureSampling() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(source: fixtureSource("passthrough"))) let output = try render() try expectPixels(output, expected: inputPixels) }
@Test("RetroArch helper, texture binding, and push parameter render correctly") func retroArchParametersAndSampling() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } let fixture = try fixtureURL("retroarch-helper") try await shared.loadEffect(ShaderLoadRequest(url: fixture)) let parameters = shared.parameterState.parameters try #require(parameters.count == 1) #expect(parameters[0].name == "GAIN") #expect(parameters[0].defaultValue == 0.5) let output = try render() let expected: [UInt8] = [ 0, 0, 128, 255, 0, 128, 0, 255, 128, 0, 0, 255, 128, 128, 128, 255, ] try expectPixels(output, expected: expected) }
@Test("Preset grayscale textures sample as opaque grayscale") func grayscalePresetTexture() async throws { try await expectPresetTexture(grayscale: true) }
@Test("Preset RGB textures preserve their color channels") func colorPresetTexture() async throws { try await expectPresetTexture(grayscale: false) }
@Test("Preset reload recompiles an edited shader at the same path") func presetReload() async throws { try await expectPresetTexture(grayscale: true, reload: true) }
@Test("Failed shader replacement clears the pipeline and retries still fail") func failedShaderReplacementAndRecovery() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } let fixture = try fixtureURL("passthrough") let source = try String(contentsOf: fixture, encoding: .utf8) try await shared.loadEffect(ShaderLoadRequest(source: source, url: fixture)) try #require(shared.renderPipeline != nil, "Valid shader did not create a pipeline") for attempt in 1...2 { do { try await shared.loadEffect(ShaderLoadRequest( source: "float4 shaderFunction(ShaderInput input) { return missingSmokeSymbol; }", url: fixture)) Issue.record("Invalid shader attempt \(attempt) reused a cached pipeline") } catch { #expect(error.localizedDescription.contains("missingSmokeSymbol")) } #expect(shared.renderPipeline == nil, "Failed shader left a stale render pipeline active") } try await shared.loadEffect(ShaderLoadRequest(source: source, url: fixture)) #expect(shared.renderPipeline != nil, "Valid shader could not recover after compilation failed") }
@Test("Changed shader content at the same path produces a new pipeline") func changedShaderContent() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } let fixture = try fixtureURL("passthrough") let source = try String(contentsOf: fixture, encoding: .utf8) try await shared.loadEffect(ShaderLoadRequest(source: source, url: fixture)) let previous = try #require(shared.renderPipeline) try await shared.loadEffect(ShaderLoadRequest( source: "float4 shaderFunction(ShaderInput input) { return float4(0.0, 1.0, 0.0, 1.0); }", url: fixture)) 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() 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() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(source: """ 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 { _ = try SlangCompiler.compileToMetal(slangSource: SlangCompiler.wrapEffectSource( "float4 shaderFunction(ShaderInput input) { return missingSmokeSymbol; }")) Issue.record("Invalid Slang unexpectedly compiled") } catch SlangCompilerError.compilationFailed(let diagnostic) { #expect(diagnostic.contains("missingSmokeSymbol"), "Diagnostic did not identify the invalid symbol: \(diagnostic)") } }
@Test("Invalid RetroArch GLSL returns a compiler diagnostic") func invalidRetroArchDiagnostic() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } let source = try fixtureSource("retroarch-helper") let shader = temporary.appendingPathComponent("invalid.slang") try source.replacingOccurrences(of: "color * gain", with: "missingSmokeSymbol") .write(to: shader, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } do { try await shared.loadEffect(ShaderLoadRequest(url: shader)) Issue.record("Invalid GLSL unexpectedly compiled") } catch { #expect(error.localizedDescription.contains("missingSmokeSymbol"), "Diagnostic did not identify the invalid symbol: \(error)") } #expect(shared.effect == nil) }
@Test("RetroArch binds reordered parameters at their reflected uniform offsets") func reorderedUniformParameters() async throws { let source = retroArchSource( fragment: "vec4(global.FIRST, global.SECOND, global.OutputSize.x * 0.25, 1.0)", uniforms: """ #pragma parameter FIRST "First" 0.25 0.0 1.0 0.05 #pragma parameter SECOND "Second" 0.75 0.0 1.0 0.05 layout(set = 0, binding = 0) uniform Global { mat4 MVP; vec4 OutputSize; float SECOND; float FIRST; } global; """) try await withRetroArchShader(source) { shared in try expectPixels(render(), expected: solidPixel([128, 191, 64, 255])) shared.parameterState.setValue(1, for: "FIRST") shared.parameterState.setValue(0, for: "SECOND") try expectPixels(render(), expected: solidPixel([128, 0, 255, 255])) } }
@Test("RetroArch binds separate uniform and push buffers with FrameCount") func separateUniformAndPushBuffers() async throws { let source = retroArchSource( fragment: "vec4(params.GAIN, global.FIRST, float(params.FrameCount) * 0.25, 1.0)", uniforms: """ #pragma parameter GAIN "Gain" 0.75 0.0 1.0 0.05 #pragma parameter FIRST "First" 0.25 0.0 1.0 0.05 layout(set = 0, binding = 0) uniform Global { mat4 MVP; float FIRST; } global; layout(push_constant) uniform Push { vec4 SourceSize; uint FrameCount; float GAIN; } params; """) try await withRetroArchShader(source) { _ in let output = try render(context: ShaderFrameContext(outputSize: SIMD2(2, 2), frameCount: 2)) try expectPixels(output, expected: solidPixel([128, 64, 191, 255])) } }
@Test("RetroArch executes the shader's custom vertex varying") func customVertexStage() async throws { let source = retroArchSource( fragment: "texture(Source, vTexCoord)", vertex: "gl_Position = global.MVP * Position; vTexCoord = vec2(1.0) - TexCoord;") try await withRetroArchShader(source) { _ in try expectPixels(render(), expected: [ 255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, ]) try expectPixels(render(scissor: MTLScissorRect(x: 1, y: 0, width: 1, height: 2)), expected: [ 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 255, ]) } }
@Test("Every preset pass runs, including differently sized intermediate targets") func multiPassPreset() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } try retroArchSource(fragment: "vec4(texture(Source, vTexCoord).rgb * 0.5, 1.0)") .write(to: temporary.appendingPathComponent("first.slang"), atomically: true, encoding: .utf8) try retroArchSource(fragment: "vec4(texture(Source, vTexCoord).bgr, 1.0)") .write(to: temporary.appendingPathComponent("second.slang"), atomically: true, encoding: .utf8) let preset = temporary.appendingPathComponent("two-pass.slangp") try """ shaders = 2 shader0 = "first.slang" filter_linear0 = false scale_type0 = "source" scale0 = 2.0 shader1 = "second.slang" filter_linear1 = false scale_type1 = "viewport" scale1 = 1.0 """.write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: preset)) try expectPixels(render(), expected: [ 128, 0, 0, 255, 0, 128, 0, 255, 0, 0, 128, 255, 128, 128, 128, 255, ]) try expectPixels(render(scissor: MTLScissorRect(x: 1, y: 0, width: 1, height: 2)), expected: [ 0, 0, 0, 0, 0, 128, 0, 255, 0, 0, 0, 0, 128, 128, 128, 255, ]) }
@Test("Referenced presets resolve relative includes and reload their edits") func referencedPresetAndIncludes() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } let base = temporary.appendingPathComponent("base") try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) let include = base.appendingPathComponent("color.inc") try "vec4 fixtureColor() { return vec4(1.0, 0.0, 0.0, 1.0); }" .write(to: include, atomically: true, encoding: .utf8) try retroArchSource( fragment: "vec4(fixtureColor().rgb * global.GAIN, 1.0)", uniforms: """ #pragma parameter GAIN "Gain" 0.5 0.0 1.0 0.05 layout(set = 0, binding = 0) uniform Global { mat4 MVP; float GAIN; } global; """, fragmentDeclarations: "#include \"color.inc\"") .write(to: base.appendingPathComponent("color.slang"), atomically: true, encoding: .utf8) try "shaders = 1\nshader0 = \"color.slang\"\n" .write(to: base.appendingPathComponent("base.slangp"), atomically: true, encoding: .utf8) let preset = temporary.appendingPathComponent("reference.slangp") try "#reference \"base/base.slangp\"\nGAIN = \"0.25\"\n" .write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: preset)) #expect(shared.parameterState.getValue(for: "GAIN") == 0.25) try expectPixels(render(), expected: solidPixel([0, 0, 64, 255]))
try "vec4 fixtureColor() { return vec4(0.0, 1.0, 0.0, 1.0); }" .write(to: include, atomically: true, encoding: .utf8) try await shared.loadEffect(ShaderLoadRequest(url: preset)) try expectPixels(render(), expected: solidPixel([0, 64, 0, 255])) }
@Test("Preset lookup textures honor nearest filtering and repeat wrapping") func lookupTextureSamplerSettings() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } try retroArchSource( fragment: "texture(BACKGROUND, vec2(1.1, 0.1))", fragmentDeclarations: "layout(set = 0, binding = 3) uniform sampler2D BACKGROUND;") .write(to: temporary.appendingPathComponent("sample.slang"), atomically: true, encoding: .utf8) try writeTexture( rgba: [255, 0, 0, 255, 0, 255, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255], to: temporary.appendingPathComponent("lookup.png")) let preset = temporary.appendingPathComponent("sampler.slangp") try """ shaders = 1 shader0 = "sample.slang" textures = "BACKGROUND" BACKGROUND = "lookup.png" BACKGROUND_linear = false BACKGROUND_wrap_mode = "repeat" """.write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: preset)) try expectPixels(render(), expected: solidPixel([0, 0, 255, 255])) }
@Test("Compact grayscale lookup textures preserve alpha, borders, and mipmap sampling") func grayscaleLookupSampling() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } let shared = SharedMetalResources.shared defer { shared.clear() } let shader = temporary.appendingPathComponent("sample.slang") let texture = temporary.appendingPathComponent("lookup.png") let preset = temporary.appendingPathComponent("sampler.slangp") let cases: [(pixels: [UInt8], expression: String, expected: [UInt8])] = [ (Array(repeating: [128, 128, 128, 255], count: 4).flatMap { $0 }, "texture(BACKGROUND, vec2(-0.25, 0.25))", [0, 0, 0, 0]), (Array(repeating: [128, 128, 128, 64], count: 4).flatMap { $0 }, "texture(BACKGROUND, vec2(0.25, 0.25))", [128, 128, 128, 64]), ([0, 0, 0, 255, 64, 64, 64, 255, 128, 128, 128, 255, 192, 192, 192, 255], "textureLod(BACKGROUND, vec2(0.5), 1.0)", [96, 96, 96, 255]), ] for item in cases { try writeTexture(rgba: item.pixels, to: texture, premultiplied: false) try retroArchSource(fragment: item.expression, fragmentDeclarations: "layout(set = 0, binding = 3) uniform sampler2D BACKGROUND;") .write(to: shader, atomically: true, encoding: .utf8) try """ shaders = 1 shader0 = "sample.slang" textures = "BACKGROUND" BACKGROUND = "lookup.png" BACKGROUND_linear = false BACKGROUND_mipmap = true BACKGROUND_wrap_mode = "clamp_to_border" """.write(to: preset, atomically: true, encoding: .utf8) try await shared.loadEffect(ShaderLoadRequest(url: preset)) try expectPixels(render(), expected: solidPixel(item.expected)) } }
@Test("Grayscale lookup uploads preserve odd widths and rows across upload strips") func grayscaleLookupUploadStrips() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } let width = 3, height = 67 let values = (0..<(width * height)).map { UInt8(($0 * 37) % 256) } let provider = try #require(CGDataProvider(data: Data(values) as CFData)) let image = try #require(CGImage( width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 8, bytesPerRow: width, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)) let png = try #require(CGImageDestinationCreateWithURL( temporary.appendingPathComponent("lookup.png") as CFURL, "public.png" as CFString, 1, nil)) CGImageDestinationAddImage(png, image, nil) try #require(CGImageDestinationFinalize(png)) try retroArchSource(fragment: "texture(BACKGROUND, vTexCoord)", fragmentDeclarations: "layout(set = 0, binding = 3) uniform sampler2D BACKGROUND;") .write(to: temporary.appendingPathComponent("sample.slang"), atomically: true, encoding: .utf8) let preset = temporary.appendingPathComponent("sample.slangp") try """ shaders = 1 shader0 = "sample.slang" textures = "BACKGROUND" BACKGROUND = "lookup.png" BACKGROUND_linear = false """.write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: preset)) let source = try makeTexture(width: width, height: height) let destination = try makeTexture(width: width, height: height) let commands = try #require(shared.commandQueue.makeCommandBuffer()) try ShaderRenderCore(resources: shared).encode( commandBuffer: commands, source: source, destination: destination, context: ShaderFrameContext(outputSize: SIMD2(Float(width), Float(height)))) try expectPixels(finishRendering(commands, to: destination), expected: values.flatMap { [$0, $0, $0, 255] }) }
@Test("Missing preset textures fail without retaining the previous effect") func missingPresetTexture() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } try fixtureSource("retroarch-background") .write(to: temporary.appendingPathComponent("background.slang"), atomically: true, encoding: .utf8) let preset = temporary.appendingPathComponent("missing.slangp") try """ shaders = 1 shader0 = "background.slang" textures = "BACKGROUND" BACKGROUND = "missing.png" """.write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(source: fixtureSource("passthrough"))) do { try await shared.loadEffect(ShaderLoadRequest(url: preset)) Issue.record("Preset with a missing lookup texture unexpectedly loaded") } catch { #expect(shared.effect == nil) } }
@Test("A corrupt lookup texture fails cleanly and can be replaced at the same path") func corruptPresetTextureRecovery() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } try fixtureSource("retroarch-background") .write(to: temporary.appendingPathComponent("background.slang"), atomically: true, encoding: .utf8) let texture = temporary.appendingPathComponent("lookup.png") try Data("Invalid PNG content".utf8).write(to: texture) let preset = temporary.appendingPathComponent("corrupt.slangp") try """ shaders = 1 shader0 = "background.slang" textures = "BACKGROUND" BACKGROUND = "lookup.png" """.write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(source: fixtureSource("passthrough"))) do { try await shared.loadEffect(ShaderLoadRequest(url: preset)) Issue.record("Preset with a corrupt lookup texture unexpectedly loaded") } catch { #expect(shared.effect == nil) } try writeTexture(rgba: solidPixel([255, 0, 0, 255]), to: texture) try await shared.loadEffect(ShaderLoadRequest(url: preset)) try expectPixels(render(), expected: solidPixel([0, 0, 255, 255])) }
@Test("Native and RetroArch rendering leave excluded desktop areas transparent") func desktopScissorComposition() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } let scissor = MTLScissorRect(x: 1, y: 0, width: 1, height: 2) try await shared.loadEffect(ShaderLoadRequest(source: fixtureSource("passthrough"))) let expected: [UInt8] = [ 0, 0, 0, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 255, 255, 255, ] try expectPixels(render(scissor: scissor), expected: expected) try await withRetroArchShader(retroArchSource(fragment: "texture(Source, vTexCoord)")) { _ in try expectPixels(render(scissor: scissor), expected: expected) } }
@Test("RetroArch masks every desktop edge without rescaling pixels or changing their alpha") func desktopMaskEdges() async throws { // Different colors and alpha across sixteen texels expose UV rescaling, // blending, stale edges, and accidentally clearing visible pixels. var pixels: [UInt8] = [] for index in 0..<16 { pixels.append(UInt8(index * 11)) pixels.append(UInt8(255 - index * 9)) pixels.append(UInt8(index * 7)) pixels.append(UInt8(40 + index * 12)) } let scissors = [ MTLScissorRect(x: 0, y: 0, width: 4, height: 4), MTLScissorRect(x: 1, y: 1, width: 2, height: 2), MTLScissorRect(x: 0, y: 1, width: 4, height: 2), MTLScissorRect(x: 1, y: 0, width: 2, height: 4), MTLScissorRect(x: 0, y: 0, width: 1, height: 1), MTLScissorRect(x: 3, y: 3, width: 1, height: 1), MTLScissorRect(x: 2, y: 1, width: 0, height: 2), MTLScissorRect(x: 1, y: 2, width: 2, height: 0), ] try await withRetroArchShader(retroArchSource(fragment: "texture(Source, vTexCoord)")) { shared in let source = try makeTexture(pixels: pixels, width: 4, height: 4) let destination = try makeTexture(width: 4, height: 4) let core = ShaderRenderCore(resources: shared) for scissor in scissors { let commands = try #require(shared.commandQueue.makeCommandBuffer()) try core.encode(commandBuffer: commands, source: source, destination: destination, context: ShaderFrameContext(outputSize: SIMD2(4, 4)), scissor: scissor) let expected = (0..<16).flatMap { index -> [UInt8] in let x = index % 4, y = index / 4 let visible = (scissor.x..<(scissor.x + scissor.width)).contains(x) && (scissor.y..<(scissor.y + scissor.height)).contains(y) return visible ? Array(pixels[(index * 4)..<(index * 4 + 4)]) : [0, 0, 0, 0] } try expectPixels(finishRendering(commands, to: destination), expected: expected) } } }
@Test("Desktop masking leaves complete final-pass feedback available on the next frame") func desktopMaskPreservesFeedback() async throws { let source = retroArchSource( fragment: "global.FrameCount == 0u ? texture(Source, vTexCoord) : texture(PassFeedback0, vTexCoord)", uniforms: "layout(set = 0, binding = 0) uniform Global { mat4 MVP; uint FrameCount; } global;", fragmentDeclarations: "layout(set = 0, binding = 3) uniform sampler2D PassFeedback0;") for scissor in [MTLScissorRect(x: 1, y: 0, width: 1, height: 2), MTLScissorRect(x: 0, y: 0, width: 0, height: 0)] { try await withRetroArchShader(source) { _ in _ = try render(scissor: scissor) try expectPixels(render( context: ShaderFrameContext(outputSize: SIMD2(2, 2), frameCount: 1), scissor: MTLScissorRect(x: 0, y: 0, width: 2, height: 2), pixels: solidPixel([0, 0, 0, 0])), expected: inputPixels) } } }
@Test("Intermediate-pass feedback survives when the final pass renders directly") func intermediateFeedbackWithDirectOutput() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } try retroArchSource( fragment: "global.FrameCount == 0u ? texture(Source, vTexCoord) : texture(PassFeedback0, vTexCoord)", uniforms: "layout(set = 0, binding = 0) uniform Global { mat4 MVP; uint FrameCount; } global;", fragmentDeclarations: "layout(set = 0, binding = 3) uniform sampler2D PassFeedback0;") .write(to: temporary.appendingPathComponent("feedback.slang"), atomically: true, encoding: .utf8) try retroArchSource(fragment: "texture(Source, vTexCoord)") .write(to: temporary.appendingPathComponent("output.slang"), atomically: true, encoding: .utf8) let preset = temporary.appendingPathComponent("feedback.slangp") try """ shaders = 2 shader0 = "feedback.slang" filter_linear0 = false scale_type0 = "source" scale0 = 2.0 shader1 = "output.slang" filter_linear1 = false """.write(to: preset, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: preset)) _ = try render(scissor: MTLScissorRect(x: 1, y: 0, width: 1, height: 2)) try expectPixels(render( context: ShaderFrameContext(outputSize: SIMD2(2, 2), frameCount: 1), pixels: solidPixel([0, 0, 0, 0])), expected: inputPixels) }
@Test("Each display retains its own previous input across repeated frames") func displayHistoryIsIsolated() async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } let shader = temporary.appendingPathComponent("history.slang") try retroArchSource( fragment: "texture(OriginalHistory1, vTexCoord)", fragmentDeclarations: "layout(set = 0, binding = 3) uniform sampler2D OriginalHistory1;") .write(to: shader, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: shader, displayIDs: [101, 202])) let red = solidPixel([0, 0, 255, 255]) let green = solidPixel([0, 255, 0, 255]) let blue = solidPixel([255, 0, 0, 255]) let white = solidPixel([255, 255, 255, 255])
// The first frame seeds each chain's history. Interleave subsequent // frames so a shared history would immediately expose the other display. _ = try render(displayID: 101, pixels: red) _ = try render(displayID: 202, pixels: green) try expectPixels(render( context: ShaderFrameContext(outputSize: SIMD2(2, 2), frameCount: 1), displayID: 101, pixels: blue), expected: red) try expectPixels(render( context: ShaderFrameContext(outputSize: SIMD2(2, 2), frameCount: 1), displayID: 202, pixels: white), expected: green) try expectPixels(render( context: ShaderFrameContext(outputSize: SIMD2(2, 2), frameCount: 2), displayID: 101, pixels: white), expected: blue) }
@Test("Busy chains reject another frame before changing in-flight uniforms") func unsubmittedFrameKeepsItsUniforms() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: fixtureURL("retroarch-helper"))) let source = try makeTexture(pixels: inputPixels) let firstDestination = try makeTexture() let nextDestination = try makeTexture() let first = try #require(shared.commandQueue.makeCommandBuffer()) let next = try #require(shared.commandQueue.makeCommandBuffer()) let core = ShaderRenderCore(resources: shared) let context = ShaderFrameContext(outputSize: SIMD2(2, 2)) defer { // Ensure even a failed assertion cannot abandon a successfully // encoded buffer that owns the chain's busy gate. if first.status == .notEnqueued { _ = try? finishRendering(first, to: firstDestination) } } try core.encode(commandBuffer: first, source: source, destination: firstDestination, context: context) shared.parameterState.setValue(1, for: "GAIN") do { try core.encode(commandBuffer: next, source: source, destination: nextDestination, context: context) Issue.record("An unsubmitted frame did not hold the filter chain's busy gate") } catch RetroArchRuntimeError.frameInFlight { // Rejection must precede parameter writes to the first frame's buffers. } try expectPixels(finishRendering(first, to: firstDestination), expected: [ 0, 0, 128, 255, 0, 128, 0, 255, 128, 0, 0, 255, 128, 128, 128, 255, ]) try core.encode(commandBuffer: next, source: source, destination: nextDestination, context: context) try expectPixels(finishRendering(next, to: nextDestination), expected: inputPixels) }
@Test("An idle RetroArch chain releases its completed command buffer") func completedFrameStorageIsReleased() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: fixtureURL("retroarch-helper"))) let core = ShaderRenderCore(resources: shared) weak var completed: (any MTLCommandBuffer)? try autoreleasepool { let commands = try #require(shared.commandQueue.makeCommandBuffer()) let destination = try makeTexture() completed = commands try core.encode(commandBuffer: commands, source: makeTexture(pixels: inputPixels), destination: destination, context: ShaderFrameContext(outputSize: SIMD2(2, 2))) _ = try finishRendering(commands, to: destination) } // Completion schedules cleanup on the chain's own thread. No additional // render or clear may be needed to release the last command's storage. for _ in 0..<200 { if completed == nil { break } try await Task.sleep(for: .milliseconds(10)) } #expect(completed == nil, "The idle chain retained its completed command buffer") #expect(shared.effect != nil) }
@Test("Late and premature completion notifications cannot release a newer frame") func completionCannotReleaseAnotherFrame() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: fixtureURL("retroarch-helper"))) let effect = try #require(shared.effect) guard case .retroArch(let chains) = effect.backend else { Issue.record("Expected a RetroArch filter chain") return } let chain = try #require(chains[0]) let core = ShaderRenderCore(resources: shared) let source = try makeTexture(pixels: inputPixels) let destination = try makeTexture() let context = ShaderFrameContext(outputSize: SIMD2(2, 2)) let first = try #require(shared.commandQueue.makeCommandBuffer()) try core.encode(commandBuffer: first, source: source, destination: destination, context: context) _ = try finishRendering(first, to: destination)
let second = try #require(shared.commandQueue.makeCommandBuffer()) let third = try #require(shared.commandQueue.makeCommandBuffer()) defer { if second.status == .notEnqueued { _ = try? finishRendering(second, to: destination) } chain.discardUnsubmittedFrame(commandBuffer: third) } try core.encode(commandBuffer: second, source: source, destination: destination, context: context) // A completion may arrive after the next frame has claimed the slot. // Queue these before the next encode so the worker processes them first. chain.releaseCompletedFrame(id: ObjectIdentifier(first)) chain.releaseCompletedFrame(id: ObjectIdentifier(second)) shared.parameterState.setValue(1, for: "GAIN") do { try core.encode(commandBuffer: third, source: source, destination: destination, context: context) Issue.record("A completion notification released an unsubmitted frame") return } catch RetroArchRuntimeError.frameInFlight { // The rejected attempt must not alter the second frame's uniforms. } try expectPixels(finishRendering(second, to: destination), expected: [ 0, 0, 128, 255, 0, 128, 0, 255, 128, 0, 0, 255, 128, 128, 128, 255, ]) try core.encode(commandBuffer: third, source: source, destination: destination, context: context) try expectPixels(finishRendering(third, to: destination), expected: inputPixels) }
@Test("Discarding an unsubmitted frame releases its chain for the next render") func discardedFrameReleasesBusyGate() async throws { let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: fixtureURL("retroarch-helper"))) let effect = try #require(shared.effect) guard case .retroArch(let chains) = effect.backend else { Issue.record("Expected a RetroArch filter chain") return } let chain = try #require(chains[0]) let abandoned = try #require(shared.commandQueue.makeCommandBuffer()) defer { chain.discardUnsubmittedFrame(commandBuffer: abandoned) } try ShaderRenderCore(resources: shared).encode( commandBuffer: abandoned, source: makeTexture(pixels: inputPixels), destination: makeTexture(), context: ShaderFrameContext(outputSize: SIMD2(2, 2))) chain.discardUnsubmittedFrame(commandBuffer: abandoned)
// Simulate abandoning a partially encoded frame after a later encoder // fails. The next real frame must not remain permanently "busy". shared.parameterState.setValue(1, for: "GAIN") try expectPixels(render(), expected: inputPixels) #expect(abandoned.status == .notEnqueued) }
@Test("An obsolete background compilation cannot replace a newer effect") func supersededLoadCannotPublish() async throws { let resources = SharedMetalResources() let request = ShaderLoadRequest(source: try fixtureSource("passthrough")) try await resources.loadEffect(request) let pipeline = try #require(resources.renderPipeline) let stale = CompiledEffect(backend: .slang(pipeline), parameters: []) let current = CompiledEffect(backend: .slang(pipeline), parameters: []) let entered = AsyncStream<Void>.makeStream() let release = DispatchSemaphore(value: 0) defer { release.signal(); resources.clear() } let previous = Task { try await resources.loadEffect(request) { _, _, _, _ in entered.continuation.yield(()) entered.continuation.finish() release.wait() return stale } } for await _ in entered.stream { break } try await resources.loadEffect(request) { _, _, _, _ in current } release.signal() do { try await previous.value Issue.record("Superseded load unexpectedly succeeded") } catch is CancellationError { // Some native library compilation cannot stop mid-call. Even if it // returns a result afterward, the generation check must discard it. } #expect(resources.effect?.id == current.id) }
@Test("Concurrent RetroArch loads and later compiler exits preserve independent live pipelines") func independentCompilerLifetimes() async throws { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } let firstURL = directory.appendingPathComponent("first.slang") let secondURL = directory.appendingPathComponent("second.slang") try retroArchSource(fragment: "vec4(0.125, 0.25, 0.5, 1.0)") .write(to: firstURL, atomically: true, encoding: .utf8) try retroArchSource(fragment: "vec4(0.75, 0.5, 0.25, 1.0)") .write(to: secondURL, atomically: true, encoding: .utf8) let device = SharedMetalResources.shared.device let first = SharedMetalResources(device: device) let second = SharedMetalResources(device: device) defer { first.clear(); second.clear() } async let firstLoad: Void = first.loadEffect(ShaderLoadRequest(url: firstURL)) async let secondLoad: Void = second.loadEffect(ShaderLoadRequest(url: secondURL)) try await firstLoad try await secondLoad
func pixels(_ resources: SharedMetalResources) throws -> [UInt8] { let destination = try makeTexture() let command = try #require(resources.commandQueue.makeCommandBuffer()) try ShaderRenderCore(resources: resources).encode(commandBuffer: command, source: makeTexture(pixels: inputPixels), destination: destination, context: ShaderFrameContext(outputSize: SIMD2(2, 2))) return try finishRendering(command, to: destination) } try expectPixels(pixels(first), expected: solidPixel([128, 64, 32, 255])) try expectPixels(pixels(second), expected: solidPixel([64, 128, 191, 255])) try retroArchSource(fragment: "vec4(0.0, 1.0, 0.0, 1.0)") .write(to: firstURL, atomically: true, encoding: .utf8) try await first.loadEffect(ShaderLoadRequest(url: firstURL)) try expectPixels(pixels(first), expected: solidPixel([0, 255, 0, 255])) try expectPixels(pixels(second), expected: solidPixel([64, 128, 191, 255])) }
private func solidPixel(_ pixel: [UInt8]) -> [UInt8] { Array(repeating: pixel, count: 4).flatMap { $0 } }
private func retroArchSource( fragment: String, uniforms: String = "layout(set = 0, binding = 0) uniform Global { mat4 MVP; vec4 OutputSize; } global;", vertex: String = "gl_Position = global.MVP * Position; vTexCoord = TexCoord;", fragmentDeclarations: String = "" ) -> String { """ #version 450 \(uniforms) #pragma stage vertex layout(location = 0) in vec4 Position; layout(location = 1) in vec2 TexCoord; layout(location = 0) out vec2 vTexCoord; void main() { \(vertex) } #pragma stage fragment layout(location = 0) in vec2 vTexCoord; layout(location = 0) out vec4 FragColor; layout(set = 0, binding = 2) uniform sampler2D Source; \(fragmentDeclarations) void main() { FragColor = \(fragment); } """ }
private func withRetroArchShader( _ source: String, check: (SharedMetalResources) throws -> Void ) async throws { let temporary = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: temporary) } let shader = temporary.appendingPathComponent("fixture.slang") try source.write(to: shader, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: shader)) try check(shared) }
private func writeTexture(rgba: [UInt8], to url: URL, premultiplied: Bool = true) throws { let provider = try #require(CGDataProvider(data: Data(rgba) as CFData)) let image = try #require(CGImage( width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: (premultiplied ? CGImageAlphaInfo.premultipliedLast : .last).rawValue) .union(.byteOrder32Big), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)) let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil)) CGImageDestinationAddImage(destination, image, nil) try #require(CGImageDestinationFinalize(destination)) }
private func temporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("Shader fixtures \(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory }
private func fixtureURL(_ name: String) throws -> URL { let bundle = Bundle(for: ShaderFixtureBundle.self) return try #require( bundle.url(forResource: name, withExtension: "slang", subdirectory: "Fixtures") ?? bundle.url(forResource: name, withExtension: "slang"), "Missing bundled shader fixture: \(name).slang") }
private func fixtureSource(_ name: String) throws -> String { try String(contentsOf: fixtureURL(name), encoding: .utf8) }
private func expectPresetTexture(grayscale: Bool, reload: Bool = false) async throws { let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: temporary) } try FileManager.default.copyItem( at: fixtureURL("retroarch-background"), to: temporary.appendingPathComponent("background.slang"))
// Exercise the runtime's image decoder and GPU lookup-texture bindings // with both single-channel PNGs and full-color PNGs. let data = grayscale ? Data(repeating: 128, count: 4) : Data([255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255]) let colorSpace = grayscale ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB() let bitmapInfo = grayscale ? CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) : CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big) let provider = try #require(CGDataProvider(data: data as CFData)) let image = try #require(CGImage( width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: grayscale ? 8 : 32, bytesPerRow: grayscale ? 2 : 8, space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)) let destination = try #require(CGImageDestinationCreateWithURL( temporary.appendingPathComponent("background.png") as CFURL, "public.png" as CFString, 1, nil)) CGImageDestinationAddImage(destination, image, nil) try #require(CGImageDestinationFinalize(destination), "Could not write PNG texture fixture")
let preset = """ shaders = 1 shader0 = "background.slang" textures = "BACKGROUND" BACKGROUND = "background.png" """ let presetURL = temporary.appendingPathComponent("background.slangp") try preset.write(to: presetURL, atomically: true, encoding: .utf8) let shared = SharedMetalResources.shared defer { shared.clear() } try await shared.loadEffect(ShaderLoadRequest(url: presetURL)) let previousID = try #require(shared.effect).id 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 })
if reload { let shaderURL = temporary.appendingPathComponent("background.slang") let originalSource = try String(contentsOf: shaderURL, encoding: .utf8) try originalSource.replacingOccurrences( of: "texture(BACKGROUND, vTexCoord)", with: "vec4(0.0, 1.0, 0.0, 1.0)" ).write(to: shaderURL, atomically: true, encoding: .utf8) try await shared.loadEffect(ShaderLoadRequest(url: presetURL)) #expect(try #require(shared.effect).id != previousID) let reloadedOutput = try render() let green: [UInt8] = [0, 255, 0, 255] try expectPixels(reloadedOutput, expected: Array(repeating: green, count: 4).flatMap { $0 }) } }
private func expectPixels(_ actual: [UInt8], expected: [UInt8]) throws { try #require(actual.count == expected.count, "Pixel output length mismatch") for index in actual.indices { #expect(abs(Int(actual[index]) - Int(expected[index])) <= 1, "Pixel bytes differ at channel \(index): got \(actual), expected \(expected)") } }
private func render( context: ShaderFrameContext = ShaderFrameContext(outputSize: SIMD2(2, 2)), scissor: MTLScissorRect? = nil, displayID: UInt32 = 0, pixels: [UInt8]? = nil ) throws -> [UInt8] { let shared = SharedMetalResources.shared let source = try makeTexture(pixels: pixels ?? inputPixels) let destination = try makeTexture() let commands = try #require(shared.commandQueue.makeCommandBuffer()) try ShaderRenderCore(resources: shared, displayID: displayID).encode( commandBuffer: commands, source: source, destination: destination, context: context, scissor: scissor) return try finishRendering(commands, to: destination) }
private func makeTexture(pixels: [UInt8]? = nil, width: Int = 2, height: Int = 2) throws -> MTLTexture { let descriptor = MTLTextureDescriptor.texture2DDescriptor( pixelFormat: .bgra8Unorm, width: width, height: height, mipmapped: false) descriptor.storageMode = .shared descriptor.usage = [.shaderRead, .renderTarget] let texture = try #require(SharedMetalResources.shared.device.makeTexture(descriptor: descriptor)) if let pixels { try #require(pixels.count == width * height * 4) pixels.withUnsafeBytes { bytes in texture.replace(region: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0, withBytes: bytes.baseAddress!, bytesPerRow: width * 4) } } return texture }
private func finishRendering(_ commands: MTLCommandBuffer, to destination: MTLTexture) throws -> [UInt8] { commands.commit() commands.waitUntilCompleted() if let error = commands.error { throw error } var output = [UInt8](repeating: 0, count: destination.width * destination.height * 4) output.withUnsafeMutableBytes { bytes in destination.getBytes(bytes.baseAddress!, bytesPerRow: destination.width * 4, from: MTLRegionMake2D(0, 0, destination.width, destination.height), mipmapLevel: 0) } return output }}