├── .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 | 
4 |
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 |
--------------------------------------------------------------------------------