├── .gitignore ├── AppIcon.icns ├── Bundle.json ├── Images ├── AppIcon.png ├── Concept.png └── Preview.png ├── Package.swift ├── README.md └── Sources ├── App ├── Models │ └── Coordinator.swift ├── ModularApp.swift ├── ModularAppDelegate.swift └── Views │ ├── MainView.swift │ ├── MetalView │ ├── CustomMTKView.swift │ ├── Keyboard │ │ ├── KeyCodes.swift │ │ └── Keyboard.swift │ └── MetalView.swift │ └── UserInterfaceView │ ├── UserInterfaceView.swift │ └── Utilities │ └── MenuTextView.swift └── Core ├── Data ├── RendererError.swift └── RendererObservableData.swift ├── Renderer ├── ModularRenderer+Extensions.swift └── ModularRenderer.swift ├── Shaders ├── Compute │ └── computeLinesFunction.metal ├── Fragment │ └── Fragment.metal ├── Header.metal └── Vertex │ └── Vertex.metal └── Utilities ├── Helper.swift ├── Library.swift ├── ManagedBuffer.swift └── TextureManager.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.app 3 | /.build 4 | /build 5 | /Packages 6 | /*.xcodeproj 7 | xcuserdata/ 8 | /.swiftpm -------------------------------------------------------------------------------- /AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gracien-app/ModularMTL/4df207620ae678b40cadd7723a2de0e67eb5f22d/AppIcon.icns -------------------------------------------------------------------------------- /Bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildNumber" : 1, 3 | "bundleIdentifier" : "com.gracien.ModularMTL", 4 | "category" : "public.app-category.graphics-design", 5 | "minOSVersion" : "12.0", 6 | "target" : "ModularMTL", 7 | "versionString" : "1.0.0" 8 | } 9 | -------------------------------------------------------------------------------- /Images/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gracien-app/ModularMTL/4df207620ae678b40cadd7723a2de0e67eb5f22d/Images/AppIcon.png -------------------------------------------------------------------------------- /Images/Concept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gracien-app/ModularMTL/4df207620ae678b40cadd7723a2de0e67eb5f22d/Images/Concept.png -------------------------------------------------------------------------------- /Images/Preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gracien-app/ModularMTL/4df207620ae678b40cadd7723a2de0e67eb5f22d/Images/Preview.png -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 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: "ModularMTL", 8 | 9 | platforms: [ 10 | .macOS(.v12) 11 | ], 12 | 13 | products: [ 14 | .executable( name: "ModularMTL", targets: ["ModularMTL"]) 15 | ], 16 | 17 | targets: [ 18 | .target(name: "ModularMTLCore", path: "Sources/Core"), 19 | .executableTarget(name: "ModularMTL", dependencies: ["ModularMTLCore"], path: "Sources/App") 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # ModularMTL 4 | 5 | ## About 6 | Modular multiplication on a circle, is a beautiful way to visualise interesting patterns, emerging from basic operation of multiplication. The inspiration for this visualization came from [Times Tables, Mandelbrot and the Heart of Mathematics](https://youtu.be/qhbuKbxJsk8) YouTube video by _Mathologer_. 7 | 8 | Written in **Swift** using **Metal API** and **SwiftUI**. 9 | 10 | ## Images 11 | ![Prototype](Images/Preview.png) 12 | 13 | ## Algorithm 14 | The operating scheme is very simple: 15 | - Distribute _**N**_ points on the circle equally, 16 | - Select a multiplier _**M**_ 17 | - For each point **_n_**: 18 | - Perform modulo _**N**_ operation on a product of index of point _**n**_ and multiplier _**M**_ 19 | - The result of the above operation becomes the index of the endpoint _**e**_ 20 | - Create a connection between points _**n**_ and _**e**_ 21 | 22 | Computation of connections is offloaded to Compute Shaders, written using Metal Shading Language. 23 | 24 | 25 | ## Features 26 | - Animation mode, 27 | - Parameter controls using arrow keys, 28 | - Glow effect on supported hardware (Metal Performance Shaders), 29 | - Managed using Swift Package Manager with separate _Core_ module. 30 | 31 | ## Building 32 | Binary must be bundled together with Core bundle containing default Metal library (`.metallib` file). 33 | The easiest way to do that, is using [swift-bundler](https://github.com/stackotter/swift-bundler) tool. 34 | 35 | Once set up, it can create application bundle with proper structure and Metal library included in Core bundle. 36 | 37 | ```sh 38 | git clone https://github.com/gracien-app/ModularMTL.git 39 | cd ModularMTL 40 | 41 | # Build Universal binary in Release configuration. 42 | # Application bundle will be created in your current directory. 43 | swift-bundler build -c release -o . -u 44 | ``` 45 | 46 | *Universal application bundle available in releases.* 47 | -------------------------------------------------------------------------------- /Sources/App/Models/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | import MetalKit 10 | import ModularMTLCore 11 | 12 | class Coordinator: NSObject, MTKViewDelegate { 13 | 14 | // MARK: - Parameters 15 | var parent: MetalView 16 | var renderer: ModularRenderer! 17 | 18 | 19 | // MARK: - Metal Parameters 20 | var device: MTLDevice? 21 | var queue: MTLCommandQueue? 22 | 23 | 24 | // MARK: - Methods 25 | init(_ parent: MetalView, data: RendererObservableData) { 26 | self.parent = parent 27 | super.init() 28 | 29 | if let device = MTLCreateSystemDefaultDevice() { 30 | self.device = device 31 | do { 32 | let renderer = try ModularRenderer(with: data, device: device) 33 | self.renderer = renderer 34 | 35 | guard let queue = device.makeCommandQueue() else { 36 | data.error = RendererError.FatalError(Details: "Error creating MTLCommandQueue.") 37 | return 38 | } 39 | self.queue = queue 40 | } 41 | catch { 42 | data.error = error 43 | } 44 | } 45 | else { 46 | data.error = RendererError.UnsupportedDevice(Details: "No supported Metal devices found.") 47 | } 48 | } 49 | 50 | 51 | func draw(in view: MTKView) { 52 | 53 | if parent.data.status != .FatalError { 54 | 55 | handleKeyboardInput() 56 | 57 | guard let commandBuffer = queue?.makeCommandBuffer() else { 58 | parent.data.error = RendererError.FatalError(Details: "Error creating MTLCommandBuffer.") 59 | return 60 | } 61 | 62 | commandBuffer.addCompletedHandler { cmndBuffer in 63 | let startTime = cmndBuffer.gpuStartTime 64 | let endTime = cmndBuffer.gpuEndTime 65 | let secTime = endTime - startTime 66 | let msTime = secTime * 1000 67 | 68 | DispatchQueue.main.async { 69 | self.parent.data.averageFrametime(new: msTime) 70 | } 71 | } 72 | 73 | renderer.encodeDraw(to: commandBuffer) 74 | 75 | guard let drawable = view.currentDrawable, 76 | let viewRPD = view.currentRenderPassDescriptor 77 | else { return } 78 | 79 | renderer.encodeDrawToView(to: commandBuffer, with: viewRPD) 80 | 81 | commandBuffer.present(drawable) 82 | commandBuffer.commit() 83 | } 84 | } 85 | 86 | 87 | private func handleKeyboardInput() { 88 | if Keyboard.IsKeyPressed(.upArrow) { 89 | renderer.adjustPointCount(by: 1) 90 | } 91 | else if Keyboard.IsKeyPressed(.downArrow) { 92 | renderer.adjustPointCount(by: -1) 93 | } 94 | if Keyboard.IsKeyPressed(.leftArrow) { 95 | renderer.adjustMultiplier(by: -0.1) 96 | } 97 | else if Keyboard.IsKeyPressed(.rightArrow) { 98 | renderer.adjustMultiplier(by: 0.1) 99 | } 100 | } 101 | 102 | 103 | func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 104 | } 105 | -------------------------------------------------------------------------------- /Sources/App/ModularApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModularApp.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ModularMTLApp: App { 12 | 13 | #if os(macOS) 14 | @NSApplicationDelegateAdaptor(ModularAppDelegate.self) var appDelegate 15 | #endif 16 | 17 | var body: some Scene { 18 | WindowGroup { 19 | MainView() 20 | } 21 | #if os(macOS) 22 | .windowStyle(HiddenTitleBarWindowStyle()) 23 | #endif 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/App/ModularAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModularAppDelegate.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | class ModularAppDelegate: NSObject, NSApplicationDelegate { 12 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 13 | return true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Views/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | import ModularMTLCore 10 | 11 | struct MainView: View { 12 | 13 | @StateObject var data = RendererObservableData() 14 | 15 | var body: some View { 16 | 17 | ZStack { 18 | Color.black.ignoresSafeArea() 19 | 20 | HStack(alignment: .center, spacing: 5) { 21 | MetalView() 22 | .frame(width: data.renderAreaWidth, height: data.renderAreaHeight) 23 | UserInterfaceView() 24 | .frame(maxWidth: .infinity) 25 | } 26 | .environmentObject(data) 27 | .ignoresSafeArea(.all, edges: .top) 28 | .alert("ModularMTL", 29 | isPresented: $data.showAlert, 30 | actions: { Button("Confirm") { if data.status == .FatalError { exit(1) }} }, 31 | message: { Text(data.getAlertMessage()) } 32 | ) 33 | } 34 | .frame(width: data.width, height: data.height, alignment: .center) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Views/MetalView/CustomMTKView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomMTKView.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import MetalKit 9 | import ModularMTLCore 10 | 11 | class CustomMTKView: MTKView { 12 | override var acceptsFirstResponder: Bool { 13 | return true 14 | } 15 | 16 | override func keyDown(with event: NSEvent) { 17 | Keyboard.SetKeyPressed(event.keyCode, isOn: true) 18 | } 19 | 20 | override func keyUp(with event: NSEvent) { 21 | Keyboard.SetKeyPressed(event.keyCode, isOn: false) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Views/MetalView/Keyboard/KeyCodes.swift: -------------------------------------------------------------------------------- 1 | // Source: 2 | // Slightly modified keyboard input handling from 3 | // https://github.com/twohyjr/Metal-Game-Engine-Tutorial 4 | // 5 | 6 | import Foundation 7 | 8 | public enum KeyCodes: UInt16 { 9 | //Special Chars 10 | case space = 0x31 11 | case returnKey = 0x24 12 | case enterKey = 0x4C 13 | case escape = 0x35 14 | case shift = 0x39 15 | case command = 0x37 16 | 17 | //DPad Keys 18 | case leftArrow = 0x7B 19 | case rightArrow = 0x7C 20 | case downArrow = 0x7D 21 | case upArrow = 0x7E 22 | 23 | //Alphabet 24 | case a = 0x00 25 | case b = 0x0B 26 | case c = 0x08 27 | case d = 0x02 28 | case e = 0x0E 29 | case f = 0x03 30 | case g = 0x05 31 | case h = 0x04 32 | case i = 0x22 33 | case j = 0x26 34 | case k = 0x28 35 | case l = 0x25 36 | case m = 0x2E 37 | case n = 0x2D 38 | case o = 0x1F 39 | case p = 0x23 40 | case q = 0x0C 41 | case r = 0x0F 42 | case s = 0x01 43 | case t = 0x11 44 | case u = 0x20 45 | case v = 0x09 46 | case w = 0x0D 47 | case x = 0x07 48 | case y = 0x10 49 | case z = 0x06 50 | } 51 | -------------------------------------------------------------------------------- /Sources/App/Views/MetalView/Keyboard/Keyboard.swift: -------------------------------------------------------------------------------- 1 | // Source: 2 | // Slightly modified keyboard input handling from 3 | // https://github.com/twohyjr/Metal-Game-Engine-Tutorial 4 | // 5 | 6 | import Foundation 7 | 8 | public class Keyboard { 9 | 10 | private static var KEY_COUNT: Int = 256 11 | private static var keys = [Bool].init(repeating: false, count: KEY_COUNT) 12 | 13 | public static func SetKeyPressed(_ keyCode: UInt16, isOn: Bool) { 14 | keys[Int(keyCode)] = isOn 15 | } 16 | 17 | public static func IsKeyPressed(_ keyCode: KeyCodes)->Bool{ 18 | return keys[Int(keyCode.rawValue)] 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/App/Views/MetalView/MetalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetalView.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | import MetalKit 10 | import ModularMTLCore 11 | 12 | struct MetalView: NSViewRepresentable { 13 | 14 | @EnvironmentObject var data: RendererObservableData 15 | 16 | func makeCoordinator() -> Coordinator { 17 | Coordinator(self, data: data) 18 | } 19 | 20 | func updateNSView(_ nsView: MTKView, context: NSViewRepresentableContext) {} 21 | 22 | func makeNSView(context: NSViewRepresentableContext) -> MTKView { 23 | let metalView = CustomMTKView() 24 | 25 | metalView.delegate = context.coordinator 26 | metalView.device = context.coordinator.device 27 | 28 | metalView.framebufferOnly = true 29 | metalView.enableSetNeedsDisplay = false 30 | metalView.preferredFramesPerSecond = data.targetFPS 31 | metalView.colorPixelFormat = .bgra8Unorm_srgb 32 | 33 | metalView.drawableSize = CGSize(width: Int(data.renderAreaWidth), height: Int(data.renderAreaHeight)) 34 | 35 | metalView.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0) 36 | 37 | return metalView 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/App/Views/UserInterfaceView/UserInterfaceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserInterfaceView.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | import ModularMTLCore 10 | 11 | struct UserInterfaceView: View { 12 | 13 | @EnvironmentObject var data: RendererObservableData 14 | 15 | var body: some View { 16 | ZStack { 17 | VStack(alignment: .leading, spacing: 25) { 18 | 19 | MenuTextView(label: "MULTIPLE", data.getDataString(type: .M)) 20 | 21 | MenuTextView(label: "OFFSET", data.getDataString(type: .OFFSET)) 22 | 23 | MenuTextView(label: "DISPERSAL", data.getDataString(type: .N)) 24 | 25 | MenuTextView(label: "U") 26 | 27 | MenuTextView(label: "LATENCY", data.getDataString(type: .FRAMETIME)) 28 | 29 | MenuTextView(label: "ANIMATE", nil, dataBinding: $data.animation) 30 | 31 | MenuTextView(label: "RADIATE", nil, dataBinding: $data.blurEnabled, isDisabled: data.status == .Limited ? true : false) 32 | 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Views/UserInterfaceView/Utilities/MenuTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuTextView.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MenuTextView: View { 11 | let firstChar: String 12 | let remainder: String 13 | 14 | let dataString: String? 15 | let dataBinding: Binding? 16 | 17 | let isDisabled: Bool 18 | 19 | private let size: CGFloat = 32 20 | private let tracking: CGFloat = 25 21 | 22 | 23 | init(label: String, _ dataString: String? = nil, dataBinding: Binding? = nil, isDisabled: Bool = false) { 24 | var tempLabel = label 25 | firstChar = String(tempLabel.removeFirst()) 26 | remainder = tempLabel 27 | self.dataString = dataString 28 | self.dataBinding = dataBinding 29 | self.isDisabled = isDisabled 30 | } 31 | 32 | 33 | var body: some View { 34 | HStack(alignment: .center , spacing: 0) { 35 | 36 | firstCharBody 37 | remainderBody 38 | dataBody 39 | 40 | if let dataBinding = dataBinding { 41 | Toggle(isOn: dataBinding, label: {}) 42 | .disabled(self.isDisabled) 43 | .toggleStyle( 44 | SwitchToggleStyle(tint: Color(.displayP3, red: 0.313,green: 0.392, blue: 0.51, opacity: 0.1)) 45 | ) 46 | } 47 | 48 | Spacer() 49 | } 50 | } 51 | 52 | 53 | var dataBody: some View { 54 | Text(" " + (dataString ?? "")) 55 | .font(.system(size: self.size)) 56 | .fontWeight(.ultraLight) 57 | .tracking(self.tracking/5.0) 58 | } 59 | 60 | 61 | var remainderBody: some View { 62 | Text(remainder) 63 | .font(.system(size: self.size)) 64 | .fontWeight(.ultraLight) 65 | .tracking(self.tracking) 66 | } 67 | 68 | var firstCharBody: some View { 69 | ZStack { 70 | Text(firstChar) 71 | .font(.system(size: self.size)) 72 | .fontWeight(.thin) 73 | .tracking(self.tracking) 74 | .foregroundColor(.white) 75 | Text(firstChar) 76 | .font(.system(size: self.size)) 77 | .fontWeight(.heavy) 78 | .tracking(self.tracking) 79 | .foregroundStyle(.blue) 80 | .blur(radius: 30.0) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Core/Data/RendererError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RendererError.swift 3 | // 4 | // 5 | // Created by Gracjan Jeżewski on 12/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum RendererError: LocalizedError { 11 | 12 | case PipelineCreationError(Details: String) 13 | case BufferCreationError(Details: String) 14 | case TextureCreationError(Details: String) 15 | case LibraryCreationError(Details: String) 16 | case UnsupportedDevice(Details: String) 17 | case FatalError(Details: String) 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Core/Data/RendererObservableData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RendererObservableData.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public class RendererObservableData: ObservableObject { 11 | 12 | public init() {} 13 | 14 | @Published public var pointsCount: UInt = 200 15 | @Published public var multiplier: Float = 1.5 16 | @Published public var frametime: Double = 0 17 | 18 | public var circleRadius: Float = 0.85 19 | public let animationStep: Float = 0.005 20 | public let targetFPS: Int = 60 21 | private let resolution: (CGFloat, CGFloat) = (1300, 650) 22 | 23 | public var animation: Bool = false 24 | public var showAlert: Bool = false 25 | public var blurEnabled: Bool = true 26 | 27 | 28 | @Published public var status: MetalFeatureStatus = .Full { 29 | didSet { 30 | switch status { 31 | case .Full: 32 | showAlert = false 33 | break 34 | default: 35 | showAlert = true 36 | blurEnabled = false 37 | break 38 | } 39 | } 40 | } 41 | 42 | 43 | public var error: Error? { 44 | didSet { 45 | status = .FatalError 46 | } 47 | } 48 | } 49 | 50 | 51 | public extension RendererObservableData { 52 | func averageFrametime(new value: Double) { 53 | let average = (value + frametime) / 2.0 54 | frametime = average 55 | } 56 | 57 | 58 | func getAlertMessage() -> String { 59 | if let error = error { 60 | return "\(error)" 61 | } 62 | else { 63 | return status.rawValue 64 | } 65 | } 66 | 67 | 68 | var width: CGFloat { 69 | return resolution.0 70 | } 71 | 72 | 73 | var height: CGFloat { 74 | return resolution.1 75 | } 76 | 77 | 78 | var renderAreaWidth: CGFloat { 79 | return (width / 2.0) + 28 80 | } 81 | 82 | 83 | var renderAreaHeight: CGFloat { 84 | return height + 28 85 | } 86 | 87 | 88 | func getDataString(type: DataStringType) -> String { 89 | switch type { 90 | case .N: 91 | return String(format: "%u", self.pointsCount) 92 | case .M: 93 | return String(format: "%.2f", self.multiplier) 94 | case .OFFSET: 95 | return String(format: "%.3f", self.animationStep) 96 | case .FRAMETIME: 97 | return String(format: "%.1f", frametime) + "ms" 98 | } 99 | } 100 | 101 | 102 | enum MetalFeatureStatus: String { 103 | case Full = "Full application functionality." 104 | case Limited = "Your device does not support required Metal API feature set.\n\n Application functionality is reduced." 105 | case FatalError = "Your device does not support Metal API." 106 | } 107 | 108 | 109 | enum DataStringType { 110 | case M 111 | case N 112 | case OFFSET 113 | case FRAMETIME 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/Core/Renderer/ModularRenderer+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModularRenderer+Extensions.swift 3 | // 4 | // 5 | // Created by Gracjan J on 09/03/2022. 6 | // 7 | 8 | import Foundation 9 | import MetalKit 10 | 11 | extension ModularRenderer { 12 | 13 | // MARK: - Public extensions 14 | public func adjustPointCount(by offset: Int) { 15 | let newCount = Int(data.pointsCount) + offset 16 | if newCount > 2 { 17 | data.pointsCount = UInt(newCount) 18 | } 19 | else { 20 | data.pointsCount = 2 21 | } 22 | } 23 | 24 | 25 | public func adjustMultiplier(by offset: Float) { 26 | data.multiplier += offset 27 | } 28 | 29 | 30 | public func encodeDrawToView(to commandBuffer: MTLCommandBuffer, 31 | with descriptor: MTLRenderPassDescriptor) { 32 | 33 | let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)! 34 | 35 | renderEncoder.setRenderPipelineState(self.drawInViewPSO) 36 | renderEncoder.setFragmentTexture(self.renderTargetTexture, index: 0) 37 | renderEncoder.setFragmentTexture(self.blurTexture, index: 1) 38 | renderEncoder.setFragmentBytes(&data.blurEnabled, length: MemoryLayout.stride, index: 0) 39 | renderEncoder.setFragmentBytes(&data.multiplier, length: MemoryLayout.stride, index: 1) 40 | renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) 41 | renderEncoder.endEncoding() 42 | } 43 | 44 | 45 | public func encodeDraw(to commandBuffer: MTLCommandBuffer) { 46 | let tempN = simd_uint1(data.pointsCount) 47 | let tempM = data.multiplier 48 | 49 | linesBuffer.updateStatus(points: UInt(tempN), multiplier: tempM) 50 | 51 | if linesBuffer.isValid == false { 52 | encodeLinesPass(storage: linesBuffer.contents(), 53 | commandBuffer: commandBuffer, 54 | elementCount: tempN, 55 | multiplier: tempM) 56 | 57 | let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: self.offscreenRenderPD)! 58 | renderEncoder.setRenderPipelineState(offscreenRenderPSO) 59 | renderEncoder.setVertexBuffer(linesBuffer.contents(), offset: 0, index: 0) 60 | renderEncoder.drawPrimitives(type: .line, vertexStart: 0, vertexCount: Int(tempN*2)) 61 | renderEncoder.endEncoding() 62 | 63 | if data.blurEnabled { 64 | blurKernel.encode(commandBuffer: commandBuffer, 65 | sourceTexture: renderTargetTexture, 66 | destinationTexture: blurTexture) 67 | } 68 | } 69 | 70 | if data.animation == true { 71 | data.multiplier += data.animationStep 72 | } 73 | } 74 | 75 | 76 | // MARK: - Private extensions 77 | private func encodeLinesPass(storage linesBuffer: MTLBuffer, 78 | commandBuffer: MTLCommandBuffer, 79 | elementCount: simd_uint1, 80 | multiplier: simd_float1) { 81 | 82 | var pointsCount = elementCount 83 | var multiplier = multiplier 84 | 85 | let computeLinesEncoder = commandBuffer.makeComputeCommandEncoder() 86 | computeLinesEncoder?.setComputePipelineState(self.computeLinesPSO) 87 | computeLinesEncoder?.label = "Compute Lines - Pass" 88 | 89 | computeLinesEncoder?.setBuffer(linesBuffer, offset: 0, index: 0) 90 | computeLinesEncoder?.setBytes(&pointsCount, length: MemoryLayout.stride, index: 1) 91 | computeLinesEncoder?.setBytes(&multiplier, length: MemoryLayout.stride, index: 2) 92 | computeLinesEncoder?.setBytes(&data.circleRadius, length: MemoryLayout.stride, index: 3) 93 | 94 | let dispatchSize = getDispatchSize(for: self.computeLinesPSO, bufferSize: Int(pointsCount)) 95 | computeLinesEncoder?.dispatchThreads(dispatchSize.0, threadsPerThreadgroup: dispatchSize.1) 96 | 97 | computeLinesEncoder?.endEncoding() 98 | } 99 | 100 | 101 | private func getDispatchSize(for pso: MTLComputePipelineState, bufferSize: Int) -> (MTLSize, MTLSize) { 102 | var threadGroupSize = pso.maxTotalThreadsPerThreadgroup 103 | if threadGroupSize > bufferSize { 104 | threadGroupSize = bufferSize 105 | } 106 | 107 | return ( MTLSize(width: bufferSize, height: 1, depth: 1), 108 | MTLSize(width: threadGroupSize, height: 1, depth: 1)) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/Core/Renderer/ModularRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModularRenderer.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import Foundation 9 | import MetalKit 10 | import MetalPerformanceShaders 11 | 12 | public class ModularRenderer { 13 | 14 | // MARK: - Internal parameters 15 | var device: MTLDevice! 16 | 17 | var drawInViewPSO: MTLRenderPipelineState! 18 | var computeLinesPSO: MTLComputePipelineState! 19 | var offscreenRenderPSO: MTLRenderPipelineState! 20 | 21 | var offscreenRenderPD: MTLRenderPassDescriptor! 22 | 23 | var blurTexture: MTLTexture! 24 | var renderTargetTexture: MTLTexture! 25 | 26 | var library: Library! 27 | var data: RendererObservableData 28 | 29 | var linesBuffer: ManagedBuffer! 30 | 31 | var blurKernel: MPSUnaryImageKernel! 32 | 33 | 34 | // MARK: - Init methods 35 | public init?(with data: RendererObservableData, device: MTLDevice) throws { 36 | self.data = data 37 | self.device = device 38 | 39 | let result = Helper.merge({ self.prepareDevice() }, 40 | { self.prepareBuffers() }, 41 | { self.prepareLibrary() }, 42 | { self.preparePipelines() }, 43 | { self.prepareTextures() }) 44 | 45 | switch result { 46 | case .success(): 47 | return 48 | case .failure(let error): 49 | throw error 50 | } 51 | } 52 | 53 | 54 | private func prepareDevice() -> Result { 55 | if device.supportsFamily(.apple7) { 56 | self.blurKernel = MPSImageGaussianBlur(device: device, sigma: 55) 57 | } 58 | else { 59 | data.status = .Limited 60 | } 61 | return .success(Void()) 62 | } 63 | 64 | 65 | private func prepareLibrary() -> Result { 66 | let functionsList = [ 67 | "computeLinesFunction", 68 | "fragmentFunction", "linesVertexFunction", 69 | "quadFragmentFunction", "quadVertexFunction", 70 | ] 71 | 72 | do { 73 | self.library = try Library(with: device, functions: functionsList) 74 | } 75 | catch let error as RendererError { 76 | return .failure(error) 77 | } 78 | catch { 79 | return .failure(.LibraryCreationError(Details: "Unknown error.")) 80 | } 81 | 82 | return .success(Void()) 83 | } 84 | 85 | 86 | private func prepareBuffers() -> Result { 87 | let minimumSize: UInt = 100 88 | 89 | do { 90 | self.linesBuffer = try ManagedBuffer(with: device, 91 | count: data.pointsCount, 92 | minimum: minimumSize, 93 | label: "LinesBuffer") 94 | } 95 | catch let error as RendererError { 96 | return .failure(error) 97 | } 98 | catch { 99 | return .failure(.BufferCreationError(Details: "Unknown error.")) 100 | } 101 | 102 | return .success(Void()) 103 | } 104 | 105 | 106 | private func preparePipelines() -> Result { 107 | do { 108 | let offscreenRPD = MTLRenderPipelineDescriptor() 109 | offscreenRPD.label = "Offscreen Render Pass" 110 | offscreenRPD.vertexFunction = try library.createFunction(name: "linesVertexFunction").get() 111 | offscreenRPD.fragmentFunction = try library.createFunction(name: "fragmentFunction").get() 112 | offscreenRPD.colorAttachments[0].pixelFormat = MTLPixelFormat.bgra8Unorm_srgb 113 | 114 | let quadRPD = MTLRenderPipelineDescriptor() 115 | quadRPD.label = "Draw-in-View Render Pass" 116 | quadRPD.vertexFunction = try library.createFunction(name: "quadVertexFunction").get() 117 | quadRPD.fragmentFunction = try library.createFunction(name: "quadFragmentFunction").get() 118 | quadRPD.colorAttachments[0].pixelFormat = MTLPixelFormat.bgra8Unorm_srgb 119 | 120 | self.offscreenRenderPSO = try device.makeRenderPipelineState(descriptor: offscreenRPD) 121 | self.drawInViewPSO = try device.makeRenderPipelineState(descriptor: quadRPD) 122 | 123 | let computeLinesFunction = try library.createFunction(name: "computeLinesFunction").get() 124 | self.computeLinesPSO = try device.makeComputePipelineState(function: computeLinesFunction) 125 | } 126 | catch let error as RendererError { 127 | return .failure(error) 128 | } 129 | catch { 130 | return .failure(.PipelineCreationError(Details: "Unknown error.")) 131 | } 132 | 133 | return .success(Void()) 134 | } 135 | 136 | 137 | private func prepareTextures() -> Result { 138 | let renderTargetTextureResult = TextureManager.getTexture(with: device, 139 | format: .bgra8Unorm_srgb, 140 | sizeWH: (Int(data.renderAreaWidth * 2), Int(data.renderAreaHeight * 2)), 141 | type: .renderTarget, 142 | label: "RenderTargetTexture") 143 | 144 | let blurTextureResult = TextureManager.getTexture(with: device, 145 | format: .bgra8Unorm_srgb, 146 | sizeWH: (Int(data.renderAreaWidth * 2), Int(data.renderAreaHeight * 2)), 147 | type: .readWrite, 148 | label: "BlurTexture") 149 | 150 | do { 151 | self.blurTexture = try blurTextureResult.get() 152 | self.renderTargetTexture = try renderTargetTextureResult.get() 153 | } 154 | catch let error as RendererError { 155 | return .failure(error) 156 | } 157 | catch { 158 | return .failure(.TextureCreationError(Details: "Unknown error.")) 159 | } 160 | 161 | self.offscreenRenderPD = MTLRenderPassDescriptor() 162 | self.offscreenRenderPD.colorAttachments[0].texture = self.renderTargetTexture 163 | self.offscreenRenderPD.colorAttachments[0].loadAction = .clear 164 | self.offscreenRenderPD.colorAttachments[0].storeAction = .store 165 | self.offscreenRenderPD.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) 166 | 167 | return .success(Void()) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /Sources/Core/Shaders/Compute/computeLinesFunction.metal: -------------------------------------------------------------------------------- 1 | // 2 | // computeLinesFunction.metal 3 | // 4 | // 5 | // Created by Gracjan J on 14/02/2022. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | /// Compute kernel used to fill target buffer with proper connections based on multiplier parameter provided. 12 | /// 13 | /// Provided buffer contains information about each connection between two points, where: 14 | /// Connection is a 4-element vector with 15 | /// .xy - 2D coordinates of the first point, 16 | /// .zw - 2D coordinates of the second point 17 | /// 18 | /// Check is performed at the beginning to prevent writing to memory outside the bounds of both buffers (identical element count). 19 | /// 20 | kernel void computeLinesFunction(device float4 *linesBuffer [[buffer(0)]], 21 | constant uint &pointsCount [[buffer(1)]], 22 | constant float &multiplier [[buffer(2)]], 23 | constant float &radius [[buffer(3)]], 24 | const uint index [[thread_position_in_grid]]) 25 | { 26 | if (index >= pointsCount) { return; } 27 | 28 | float rotationOffset = M_PI_F; 29 | float angle = (2 * M_PI_F) / float(pointsCount); 30 | 31 | float2 fromPoint, toPoint = float2(0.0); 32 | 33 | fromPoint.x = radius * cos(rotationOffset - angle * index); 34 | fromPoint.y = radius * sin(rotationOffset - angle * index); 35 | 36 | float toIndex = index * multiplier; 37 | toPoint.x = radius * cos(rotationOffset - angle * toIndex); 38 | toPoint.y = radius * sin(rotationOffset - angle * toIndex); 39 | 40 | linesBuffer[index] = float4(fromPoint, toPoint); 41 | return; 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Core/Shaders/Fragment/Fragment.metal: -------------------------------------------------------------------------------- 1 | // 2 | // Fragment.metal 3 | // 4 | // 5 | // Created by Gracjan J on 14/02/2022. 6 | // 7 | 8 | #include 9 | #include "../Header.metal" 10 | using namespace metal; 11 | 12 | /// Fragment function returning colour stored as vertex attribute. 13 | /// 14 | fragment float4 fragmentFunction(Vertex v [[stage_in]]) { 15 | return v.colour; 16 | }; 17 | 18 | fragment float4 quadFragmentFunction(QuadVertex v [[stage_in]], 19 | constant bool &blurEnabled [[buffer(0)]], 20 | constant float &multiplier [[buffer(1)]], 21 | texture2d inputTexture [[texture(0)]], 22 | texture2d blurTexture [[texture(1)]]) { 23 | 24 | constexpr sampler textureSampler; 25 | float4 color = inputTexture.sample(textureSampler, v.uv); 26 | 27 | if (blurEnabled == true && color.x == 0.0 && color.y == 0.0 && color.z == 0.0) { 28 | color = blurTexture.sample(textureSampler, v.uv) * (0.45 + 0.05 * sin(multiplier * 10)); 29 | 30 | color.xyz / 2.2; 31 | } 32 | 33 | return color; 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Core/Shaders/Header.metal: -------------------------------------------------------------------------------- 1 | // 2 | // Header.metal 3 | // 4 | // 5 | // Created by Gracjan J on 14/02/2022. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | /// Vertex structure, uses [[sample_perspective]] to interpolate colour of lines. 12 | struct Vertex { 13 | float4 position [[position]]; 14 | float4 colour [[sample_perspective]]; 15 | }; 16 | 17 | /// Quad vertex data on which texture is rendered in window view. 18 | constant float2 quadVertices[] = { 19 | float2(-1, -1), 20 | float2(-1, 1), 21 | float2( 1, 1), 22 | float2(-1, -1), 23 | float2( 1, 1), 24 | float2( 1, -1) 25 | }; 26 | 27 | /// Vertex structure used in drawing quad. Contains both position and UV coordinates to sample texture on geometry. 28 | struct QuadVertex { 29 | float4 position [[position]]; 30 | float2 uv; 31 | }; 32 | -------------------------------------------------------------------------------- /Sources/Core/Shaders/Vertex/Vertex.metal: -------------------------------------------------------------------------------- 1 | // 2 | // Vertex.metal 3 | // 4 | // 5 | // Created by Gracjan J on 14/02/2022. 6 | // 7 | 8 | #include 9 | #include "../Header.metal" 10 | using namespace metal; 11 | 12 | 13 | /// Vertex function which returns vertices in order appropriate for line rendering mode. 14 | /// 15 | /// Lines buffer contains information about each connection, each element contains 16 | /// 2D coordinates of both ends of the line. 17 | /// To properly read the data, index is divided by two, to compensate how data is structured. 18 | /// Remainder indicates whether we are looking for starting or ending point in the connection. 19 | /// 20 | /// Colour of each vertex is defined here, end points are treated as almost black points to create a linear interpolation of color 21 | /// which adds a degree of depth to the lines. 22 | /// 23 | vertex Vertex linesVertexFunction(constant float4 *linesBuffer [[buffer(0)]], 24 | const uint index [[vertex_id]]) { 25 | 26 | int lineIndex = index / 2; 27 | int indexRemainder = index % 2; 28 | 29 | float4 vertexPosition = float4(0.0, 0.0, 0.99, 1.0); 30 | float4 vertexColour = float4(0.235, 0.435, 0.79, 1.0); 31 | vertexColour.xyz /= 2.2; 32 | 33 | if (indexRemainder == 0) { 34 | vertexPosition.xy = linesBuffer[lineIndex].xy; 35 | } 36 | else { 37 | vertexPosition.xy = linesBuffer[lineIndex].zw; 38 | vertexPosition.z = 1.0; 39 | vertexColour = float4(float3(0.001), 1.0); 40 | } 41 | 42 | return { 43 | .position = vertexPosition, 44 | .colour = vertexColour 45 | }; 46 | } 47 | 48 | vertex QuadVertex quadVertexFunction(const uint index [[vertex_id]]) { 49 | 50 | float2 vertexPosition = quadVertices[index]; 51 | 52 | return { 53 | .position = float4(vertexPosition, 0.0, 1.0), 54 | .uv = vertexPosition * float2(0.5, -0.5) + 0.5 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Core/Utilities/Helper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helper.swift 3 | // 4 | // 5 | // Created by Gracjan J on 11/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Helper { 11 | public static func merge(_ functions: (()->Result)...) -> Result { 12 | for function in functions { 13 | let result = function() 14 | 15 | switch result { 16 | case .failure(_): 17 | return result 18 | default: 19 | break 20 | } 21 | } 22 | return .success(Void()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Core/Utilities/Library.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Library.swift 3 | // 4 | // 5 | // Created by Gracjan J on 13/02/2022. 6 | // 7 | 8 | import Foundation 9 | import MetalKit 10 | 11 | public class Library { 12 | 13 | // MARK: - Parameters 14 | private var metalLibrary: MTLLibrary! 15 | private var mtlFunctions: [String: MTLFunction] = [:] 16 | 17 | // MARK: - Methods 18 | public init?(with device: MTLDevice, functions: [String]) throws { 19 | 20 | switch loadDefaultLibrary(device) { 21 | case .success(let library): 22 | self.metalLibrary = library 23 | case .failure(let error): 24 | throw error 25 | } 26 | 27 | for function in functions { 28 | switch createFunction(name: function) { 29 | case .failure(let error): 30 | throw error as RendererError 31 | default: 32 | break 33 | } 34 | } 35 | } 36 | 37 | 38 | public func getFunction(name: String) -> MTLFunction? { 39 | if let function = mtlFunctions[name] { 40 | return function 41 | } 42 | return nil 43 | } 44 | 45 | 46 | public func createFunction(name: String) -> Result { 47 | if let function = mtlFunctions[name] { 48 | return .success(function) 49 | } 50 | else { 51 | if let function = metalLibrary.makeFunction(name: name) { 52 | function.label = name 53 | mtlFunctions[name] = function 54 | return .success(function) 55 | } 56 | return .failure(.LibraryCreationError(Details: "Unable to create function: \(name)")) 57 | } 58 | } 59 | 60 | 61 | func loadDefaultLibrary(_ device: MTLDevice) -> Result { 62 | let path = "Contents/Resources/ModularMTL_ModularMTLCore.bundle" 63 | 64 | guard let bundle = Bundle(url:Bundle.main.bundleURL.appendingPathComponent(path)) else { 65 | return .failure(.LibraryCreationError(Details: "Unable to find bundle at \(path)")) 66 | } 67 | 68 | do { 69 | return .success(try device.makeDefaultLibrary(bundle: bundle)) 70 | } catch { 71 | return .failure(.LibraryCreationError(Details: "Unable to create default Metal library from bundle.")) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/Core/Utilities/ManagedBuffer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Metal 3 | 4 | extension ManagedBuffer { 5 | enum BufferStatus { 6 | case valid 7 | case tooSmall 8 | case tooBig 9 | case invalid 10 | } 11 | } 12 | 13 | class ManagedBuffer { 14 | 15 | private var device: MTLDevice 16 | private var buffer: MTLBuffer 17 | private var status: BufferStatus 18 | private var lastNM: (UInt, Float)! 19 | private var minimalCapacity: UInt 20 | 21 | private var bufferElementCount: UInt { 22 | let logicalLength = buffer.length 23 | let elementLogicalLength = MemoryLayout.stride 24 | 25 | return UInt(logicalLength / elementLogicalLength) 26 | } 27 | 28 | public var isValid: Bool { 29 | return status == .valid ? true : false 30 | } 31 | 32 | public func contents() -> MTLBuffer { 33 | return buffer 34 | } 35 | 36 | public init?(with device: MTLDevice, count: UInt, minimum minCap: UInt, label: String) throws { 37 | self.device = device 38 | 39 | let minimum = minCap 40 | 41 | let countAdjusted = count < minimum ? minimum : count 42 | let logicalSize = Int(countAdjusted) * MemoryLayout.stride 43 | 44 | guard let buffer = device.makeBuffer(length: logicalSize, options: .storageModePrivate) else { 45 | throw RendererError.BufferCreationError(Details: "Error creating \(label) with logical size of \(logicalSize) bytes") 46 | } 47 | 48 | self.buffer = buffer 49 | self.buffer.label = label 50 | self.status = .invalid 51 | self.minimalCapacity = minimum 52 | } 53 | 54 | public func updateStatus(points count: UInt, multiplier M: Float) { 55 | if lastNM != nil && count == lastNM.0 && M == lastNM.1 { 56 | status = .valid 57 | } 58 | else { 59 | if count > bufferElementCount { 60 | status = .tooSmall 61 | } 62 | else { 63 | let tempReducedSize = bufferElementCount / 4 64 | if tempReducedSize >= self.minimalCapacity && count < tempReducedSize { 65 | status = .tooBig 66 | } 67 | else { 68 | status = .invalid 69 | } 70 | } 71 | } 72 | 73 | updateBuffer(points: count) 74 | self.lastNM = (count, M) 75 | } 76 | 77 | private func updateBuffer(points count: UInt) { 78 | var newLogicalSize = 0 79 | switch self.status { 80 | case .tooSmall: 81 | newLogicalSize = self.buffer.length * 2 82 | break 83 | case .tooBig: 84 | newLogicalSize = self.buffer.length / 2 85 | break 86 | default: 87 | return 88 | } 89 | 90 | guard let buffer = device.makeBuffer(length: newLogicalSize, options: .storageModePrivate) else { 91 | fatalError("[\(buffer.label ?? "Unnamed Buffer")] Error while enlarging the buffer.") 92 | } 93 | 94 | self.buffer = buffer 95 | self.status = .invalid 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/Core/Utilities/TextureManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Metal 3 | 4 | public enum TextureType { 5 | case renderTarget 6 | case readWrite 7 | case readOnly 8 | case writeOnly 9 | } 10 | 11 | public enum TextureManager { 12 | 13 | public static func getTexture(with device: MTLDevice, 14 | format: MTLPixelFormat, 15 | sizeWH: (Int, Int), 16 | type: TextureType, 17 | label: String? = nil) -> Result { 18 | 19 | let texDescriptor = MTLTextureDescriptor() 20 | texDescriptor.width = sizeWH.0 21 | texDescriptor.height = sizeWH.1 22 | texDescriptor.pixelFormat = format 23 | texDescriptor.textureType = .type2D 24 | 25 | switch type { 26 | case .renderTarget: 27 | texDescriptor.usage = [.renderTarget, .shaderRead, .shaderWrite] 28 | texDescriptor.storageMode = .private 29 | case .readWrite: 30 | texDescriptor.usage = [.shaderRead, .shaderWrite] 31 | case .writeOnly: 32 | texDescriptor.usage = [.shaderWrite] 33 | case .readOnly: 34 | texDescriptor.usage = [.shaderRead] 35 | } 36 | 37 | guard let texture = device.makeTexture(descriptor: texDescriptor) else { 38 | let detailLabel = label ?? "Unlabeled texture" 39 | return .failure(.TextureCreationError(Details: "\(detailLabel)")) 40 | } 41 | 42 | if let label = label { 43 | texture.label = label 44 | } 45 | 46 | return .success(texture) 47 | } 48 | 49 | } 50 | --------------------------------------------------------------------------------