├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── Playgrounds └── Hello World.playground │ ├── Contents.swift │ ├── contents.xcplayground │ ├── playground.xcworkspace │ └── contents.xcworkspacedata │ └── timeline.xctimeline ├── README.md ├── Sources └── MSLView │ ├── MSLView.swift │ └── Renderer.swift └── Tests └── MSLViewTests └── MSLViewTests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | env: 5 | XCODE_VER: 12.4 6 | 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | xcode_version: ['12.4'] 12 | runs-on: macos-latest 13 | env: 14 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode_version }}.app 15 | steps: 16 | - name: Check out MSLView 17 | uses: actions/checkout@v2 18 | - name: Build MSLView 19 | run: | 20 | set -euo pipefail 21 | xcodebuild -scheme MSLView clean build | xcpretty 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Audulus LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MSLView", 8 | platforms: [ 9 | .macOS(.v11), .iOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "MSLView", 15 | targets: ["MSLView"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "MSLView", 26 | dependencies: []), 27 | .testTarget( 28 | name: "MSLViewTests", 29 | dependencies: ["MSLView"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Playgrounds/Hello World.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MSLView 3 | 4 | struct Constants { 5 | var r: Float 6 | } 7 | 8 | struct ContentView: View { 9 | 10 | @State var constants = Constants(r: 0) 11 | let shader = """ 12 | struct Constants { 13 | float r; 14 | }; 15 | fragment float4 mainImage(FragmentIn input [[stage_in]], 16 | constant Constants& c, 17 | constant uint2& viewSize) { 18 | return float4(c.r, 19 | input.position.x/viewSize.x, 20 | input.position.y/viewSize.y,1); 21 | } 22 | """ 23 | 24 | var body: some View { 25 | VStack { 26 | MSLView(shader: shader, constants: constants) 27 | .frame(width: 500, height: 500) 28 | HStack { 29 | Slider(value: $constants.r) 30 | Text("\(constants.r)") 31 | }.padding() 32 | } 33 | } 34 | 35 | } 36 | 37 | import PlaygroundSupport 38 | PlaygroundPage.current.needsIndefiniteExecution = true 39 | PlaygroundPage.current.setLiveView(ContentView()) 40 | -------------------------------------------------------------------------------- /Playgrounds/Hello World.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Playgrounds/Hello World.playground/playground.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Playgrounds/Hello World.playground/timeline.xctimeline: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MSLView 2 | 3 | ![build status](https://github.com/audulus/MSLView/actions/workflows/build.yml/badge.svg) 4 | Swift Package Manager (SPM) compatible 6 | 7 | SwiftUI view for Shadertoy-style MSL shaders 8 | 9 | ```Swift 10 | import MSLView 11 | 12 | struct Constants { 13 | var r: Float 14 | } 15 | 16 | struct ContentView: View { 17 | 18 | @State var constants = Constants(r: 0) 19 | let shader = """ 20 | struct Constants { 21 | float r; 22 | }; 23 | fragment float4 mainImage(FragmentIn input [[stage_in]], 24 | constant Constants& c, 25 | constant uint2& viewSize) { 26 | return float4(c.r, 27 | input.position.x/viewSize.x, 28 | input.position.y/viewSize.y,1); 29 | } 30 | """ 31 | 32 | var body: some View { 33 | VStack { 34 | MSLView(shader: shader, constants: constants) 35 | HStack { 36 | Slider(value: $constants.r) 37 | Text("\(constants.r)") 38 | }.padding() 39 | } 40 | } 41 | 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /Sources/MSLView/MSLView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MetalKit 3 | 4 | #if os(macOS) 5 | /// Shadertoy-style view. Specify a fragment shader (must be named "mainImage") and a struct of constants 6 | /// to pass to the shader. In order to ensure the constants struct is consistent with the MSL version, it's 7 | /// best to include it in a Swift briding header. Constants are bound at position 0, and a uint2 for the view size 8 | /// is bound at position 1. 9 | public struct MSLView : NSViewRepresentable { 10 | 11 | var shader: String? 12 | var constants: T 13 | 14 | public init(shader: String, constants: T) { 15 | self.shader = shader 16 | self.constants = constants 17 | } 18 | 19 | public init(constants: T) { 20 | self.constants = constants 21 | } 22 | 23 | public class Coordinator { 24 | var renderer: Renderer 25 | 26 | init(constants: T) { 27 | renderer = Renderer(device: MTLCreateSystemDefaultDevice()!, constants: constants) 28 | } 29 | } 30 | 31 | public func makeCoordinator() -> Coordinator { 32 | return Coordinator(constants: constants) 33 | } 34 | 35 | public func makeNSView(context: Context) -> some NSView { 36 | let metalView = MTKView(frame: CGRect(x: 0, y: 0, width: 1024, height: 768), 37 | device: MTLCreateSystemDefaultDevice()!) 38 | metalView.enableSetNeedsDisplay = true 39 | metalView.isPaused = true 40 | metalView.delegate = context.coordinator.renderer 41 | if let shader = shader { 42 | context.coordinator.renderer.setShader(source: shader) 43 | } else { 44 | context.coordinator.renderer.setDefaultShader() 45 | } 46 | return metalView 47 | } 48 | 49 | public func updateNSView(_ nsView: NSViewType, context: Context) { 50 | if let shader = shader { 51 | context.coordinator.renderer.setShader(source: shader) 52 | } 53 | context.coordinator.renderer.constants = constants 54 | nsView.setNeedsDisplay(nsView.bounds) 55 | } 56 | } 57 | #else 58 | /// Shadertoy-style view. Specify a fragment shader (must be named "mainImage") and a struct of constants 59 | /// to pass to the shader. In order to ensure the constants struct is consistent with the MSL version, it's 60 | /// best to include it in a Swift briding header. Constants are bound at position 0, and a uint2 for the view size 61 | /// is bound at position 1. 62 | public struct MSLView : UIViewRepresentable { 63 | 64 | var shader: String 65 | var constants: T 66 | 67 | public init(shader: String, constants: T) { 68 | self.shader = shader 69 | self.constants = constants 70 | } 71 | 72 | public class Coordinator { 73 | var renderer: Renderer 74 | 75 | init(constants: T) { 76 | renderer = Renderer(device: MTLCreateSystemDefaultDevice()!, constants: constants) 77 | } 78 | } 79 | 80 | public func makeCoordinator() -> Coordinator { 81 | return Coordinator(constants: constants) 82 | } 83 | 84 | public func makeUIView(context: Context) -> some UIView { 85 | let metalView = MTKView(frame: CGRect(x: 0, y: 0, width: 1024, height: 768), 86 | device: MTLCreateSystemDefaultDevice()!) 87 | metalView.enableSetNeedsDisplay = true 88 | metalView.isPaused = true 89 | metalView.delegate = context.coordinator.renderer 90 | context.coordinator.renderer.setShader(source: shader) 91 | return metalView 92 | } 93 | 94 | public func updateUIView(_ uiView: UIViewType, context: Context) { 95 | context.coordinator.renderer.setShader(source: shader) 96 | context.coordinator.renderer.constants = constants 97 | uiView.setNeedsDisplay() 98 | } 99 | 100 | } 101 | #endif 102 | 103 | struct TestConstants { 104 | var r: Float 105 | } 106 | 107 | struct TestView: View { 108 | 109 | static let shader = """ 110 | struct Constants { 111 | float r; 112 | }; 113 | fragment float4 mainImage(FragmentIn input [[stage_in]], 114 | constant Constants& c, 115 | constant uint2& viewSize) { 116 | return float4(c.r, 117 | input.position.x/viewSize.x, 118 | input.position.y/viewSize.y,1); 119 | } 120 | """ 121 | 122 | @State var constants = TestConstants(r: 0.0) 123 | 124 | var body: some View { 125 | VStack { 126 | MSLView(shader: TestView.shader, constants: constants) 127 | Slider(value: $constants.r) 128 | } 129 | } 130 | } 131 | 132 | struct MSLView_Previews: PreviewProvider { 133 | 134 | static var previews: some View { 135 | TestView() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/MSLView/Renderer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MetalKit 3 | 4 | let MaxBuffers = 3 5 | 6 | class Renderer: NSObject, MTKViewDelegate { 7 | 8 | var device: MTLDevice! 9 | var queue: MTLCommandQueue! 10 | var pipeline: MTLRenderPipelineState! 11 | var source = "" 12 | public var constants: T 13 | 14 | private let inflightSemaphore = DispatchSemaphore(value: MaxBuffers) 15 | 16 | init(device: MTLDevice, constants: T) { 17 | self.device = device 18 | queue = device.makeCommandQueue() 19 | self.constants = constants 20 | } 21 | 22 | 23 | let vertex = """ 24 | #include 25 | 26 | struct FragmentIn { 27 | float4 position [[ position ]]; 28 | }; 29 | 30 | constant float2 pos[4] = { {-1,-1}, {1,-1}, {-1,1}, {1,1 } }; 31 | 32 | vertex FragmentIn __vertex__(uint id [[ vertex_id ]]) { 33 | FragmentIn out; 34 | out.position = float4(pos[id], 0, 1); 35 | return out; 36 | } 37 | 38 | """ 39 | 40 | func setShader(source: String) { 41 | 42 | if source == self.source { 43 | return 44 | } 45 | 46 | self.source = source 47 | 48 | do { 49 | let library = try device.makeLibrary(source: vertex + source, options: nil) 50 | 51 | let rpd = MTLRenderPipelineDescriptor() 52 | rpd.vertexFunction = library.makeFunction(name: "__vertex__") 53 | rpd.fragmentFunction = library.makeFunction(name: "mainImage") 54 | rpd.colorAttachments[0].pixelFormat = .bgra8Unorm 55 | 56 | pipeline = try device.makeRenderPipelineState(descriptor: rpd) 57 | 58 | } catch let error { 59 | print("Error: \(error)") 60 | } 61 | } 62 | 63 | func setDefaultShader() { 64 | 65 | do { 66 | let library = try device.makeLibrary(source: vertex, options: nil) 67 | let defaultLibrary = device.makeDefaultLibrary()! 68 | let rpd = MTLRenderPipelineDescriptor() 69 | rpd.vertexFunction = library.makeFunction(name: "__vertex__") 70 | rpd.fragmentFunction = defaultLibrary.makeFunction(name: "mainImage") 71 | rpd.colorAttachments[0].pixelFormat = .bgra8Unorm 72 | 73 | pipeline = try device.makeRenderPipelineState(descriptor: rpd) 74 | } catch let error { 75 | print("Error: \(error)") 76 | } 77 | } 78 | 79 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { 80 | 81 | } 82 | 83 | func draw(in view: MTKView) { 84 | 85 | let size = view.frame.size 86 | let w = Float(size.width) 87 | let h = Float(size.height) 88 | // let scale = Float(view.contentScaleFactor) 89 | 90 | if w == 0 || h == 0 { 91 | return 92 | } 93 | 94 | // use semaphore to encode 3 frames ahead 95 | _ = inflightSemaphore.wait(timeout: DispatchTime.distantFuture) 96 | 97 | let commandBuffer = queue.makeCommandBuffer()! 98 | 99 | let semaphore = inflightSemaphore 100 | commandBuffer.addCompletedHandler { _ in 101 | semaphore.signal() 102 | } 103 | 104 | 105 | if let renderPassDescriptor = view.currentRenderPassDescriptor, let currentDrawable = view.currentDrawable { 106 | 107 | renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1) 108 | 109 | let enc = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)! 110 | enc.setRenderPipelineState(pipeline) 111 | let c = [constants] 112 | enc.setFragmentBytes(c, length: MemoryLayout.size, index: 0) 113 | var size = SIMD2(Int32(view.drawableSize.width), Int32(view.drawableSize.height)) 114 | enc.setFragmentBytes(&size, length: MemoryLayout>.size, index: 1) 115 | enc.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) 116 | enc.endEncoding() 117 | 118 | commandBuffer.present(currentDrawable) 119 | } 120 | commandBuffer.commit() 121 | 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Tests/MSLViewTests/MSLViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import MSLView 3 | 4 | final class MSLViewTests: XCTestCase { 5 | func testExample() throws { 6 | } 7 | } 8 | --------------------------------------------------------------------------------