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