Connecting Metal

MetalView, a struct in a file of the same name, relies on MetalKit and the UIViewRepresentable protocol to make it accessible to our SwiftUi code. Some of the resources I relied upon in creating this implementation are identified at the top of the file. Since its inception, if you didn’t learn about using Metal from Warren Moore, you probably learned about it from someone who did. His Writing a Modern Metal Application from Scratch is especially useful. And Caroline Begbie and Marius Horga’s Metal by Tutorials is a useful reference.

Before turning to the MetalView file though, there are a couple of things that need to happen in Imager_PerformerApp.swift.

Adding the Globals class as an ObservableObject will have multiple uses in Image Performer. We use it first to inject a metalContext into the MetalView struct. In addition to being declared in the @main struct, it is attached to the ContentView through an environmentObject.

import SwiftUI
 
@main
struct Image_PerformerApp: App {
     var globals = Globals()
 
     var body: some Scene {
         WindowGroup {
             ContentView()
                 .statusBar(hidden:true)
                 .environmentObject(globals)
         }
     }
 }
 
 class Globals: ObservableObject {
     @Published var metalContext = MetalContext()
 }
 
 class MetalContext {
     var metalDevice: MTLDevice!
     var metalCommandQueue: MTLCommandQueue!
 

     init() {
         if let metalDevice = MTLCreateSystemDefaultDevice() {
             self.metalDevice = metalDevice
          }
         
         if metalDevice.makeCommandQueue() != nil {
             metalCommandQueue = metalDevice.makeCommandQueue()!
         }
         else {
             print("Unable to establish a metalCommandQueue on a Metal device")
             exit(0)
         }
     }
 }

The MetalContext class defines the metalDevice and its associated commandQueue, which will be used by all of the Metal code in the app.

In the MetalView.swift file, the struct MetalView is defined as a UIViewRepresentable. This is the strategy that Apple has provided for using Swift code within SwiftUI. The makeUIView function contains quite standard Metal initialization and you will find explanations of its properties in most basic Metal texts or tutorials. The draw fucntion is also pretty standard and explained well elsewhere.

Within the Coordinator class is where the action happens. The parent variable is used to grab onto the metalContext. The variables, pulsedFunctionIncreasing and locationWithinPulsedFunction are used by the clearColor() and pulse() functions.

The pulse() function will be called approximately sixty times per second. You can check this by uncommenting the code block near the bottom of the function. We will later use pulse() to drive the app’s metronome and other time-related code. For now, it updates a linear oscillator that will continuously change the clearColor (or background) shown on the canvas.

The clearColor() function is called by pulse() to update the purity of the canvas’ color linearly. When it reaches the function’s limit it reverses direction. In the next module, we will provide an interface for changing these limiting values as well as the hue.

import SwiftUI
import MetalKit

 struct MetalView: UIViewRepresentable {
     @EnvironmentObject var globals: Globals
 
     func makeCoordinator() -> Coordinator {
         return Coordinator(self)
     }
     
     func makeUIView(context: UIViewRepresentableContext<MetalView>) -> MTKView {
         let mtkView = MTKView()
         mtkView.delegate = context.coordinator
         mtkView.preferredFramesPerSecond = 60
         mtkView.enableSetNeedsDisplay = true
         
         mtkView.device = globals.metalContext.metalDevice
 
         mtkView.framebufferOnly = false
         mtkView.drawableSize = mtkView.frame.size
         mtkView.enableSetNeedsDisplay = true
         mtkView.isPaused = false
         return mtkView
     }
     
     func updateUIView(_ uiView: MTKView, context: UIViewRepresentableContext<MetalView>) {
 
     }
     
     class Coordinator : NSObject, MTKViewDelegate {
         var parent: MetalView
         var metalCommandQueue: MTLCommandQueue!
         
         var pulsedFunctionIncreasing: Bool = true
         var locationWithinPulsedFunction: Double = 0.5
 
         init(_ parent: MetalView) {
 
             self.parent = parent
             self.metalCommandQueue = parent.globals.metalContext.metalCommandQueue
             
             super.init()
         }
         
         func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
  
         }
         
         private func clearColor() -> MTLClearColor {
             let hue:Double = 0.0
             let purityCenter: Double = 0.5
             let purityRange: Double = 0.7
 
             var brightness:Double = 0.5
             var saturation:Double = 0.5
  
             if purityCenter <= 0.5 { saturation = purityCenter }
             else { brightness = Double(purityCenter) }
             
             if purityRange > 0.0 {
                 let purityIncrement:Double = Double(purityRange)
                 let purityBegin:Double = Double(purityCenter - (purityRange / 2))
                 if (purityBegin + (purityIncrement * locationWithinPulsedFunction)) <= 0.5
                 { saturation = purityBegin + (purityIncrement * locationWithinPulsedFunction) }
                 else    { brightness = purityBegin + (purityIncrement * locationWithinPulsedFunction) }
             }
             
             var temp:Double = brightness * 2.0
             if brightness > 0.5 { temp = 1 - ((brightness - 0.5) * 2) }
             brightness = temp
 
             temp = saturation * 2.0
             if saturation > 0.5 { temp = 1 - ((saturation - 0.5) * 2) }
             saturation = temp
             
             let theClearColor:RGBAColor = fromHue(hue, Saturation:saturation, Value:brightness, Alpha: 1.0)
             let clearColor = MTLClearColorMake(theClearColor.red, theClearColor.green, theClearColor.blue, theClearColor.alpha)
             
             return clearColor
         }
         
         var lastPulse = Date()
         
         private func pulse() {
             if (pulsedFunctionIncreasing) {
                 locationWithinPulsedFunction += 0.01
                 if (locationWithinPulsedFunction >= 1.0) { pulsedFunctionIncreasing = false }
             }
             else {
                 locationWithinPulsedFunction -= 0.01
                 if (locationWithinPulsedFunction <= 0.0) { pulsedFunctionIncreasing = true }
             }
             
             /*
             // check that we get around 60 pulses/second
             let curPulse = Date()
             let diff = curPulse.timeIntervalSince(lastPulse)
             print(diff)
             lastPulse = curPulse
             */
         }
         
         func draw(in view: MTKView) {
            guard let drawable = view.currentDrawable else {
                 return
             }
             
             let commandBuffer = metalCommandQueue.makeCommandBuffer()
 
             pulse()
             
             let rpd = view.currentRenderPassDescriptor
             rpd?.colorAttachments[0].clearColor = clearColor()
             rpd?.colorAttachments[0].loadAction = .clear
             rpd?.colorAttachments[0].storeAction = .store
             
             let rce = commandBuffer?.makeRenderCommandEncoder(descriptor: rpd!)
             rce?.endEncoding()
             
             commandBuffer?.present(drawable)
             commandBuffer?.commit()
         }
     }
 }

MTLClearColorMake() uses the RGBA color model, whereas Imager Performer uses the hue, saturation, value (hsv) model. The func fromHue(_, Saturation, Value, Alpha) provides code from a classic computer graphics text by Foley, et al. to make the transformation. There may be easier ways to do this in Metal now, but I am not aware of them and this code has been running effectively for many years.

struct RGBAColor {
     var red: Double = 0.0
     var green: Double = 0.0
     var blue: Double = 0.0
     var alpha: Double = 1.0
 }
 

func fromHue(_ hue:Double, Saturation saturation:Double, Value value:Double, Alpha alpha:Double) -> RGBAColor {
     var theColor = RGBAColor()
     theColor.alpha = alpha
 

     if saturation == 0.0 // The color is on the black-and-white center line. Achromatic color: There is no hue.
     {
         theColor.red = value
         theColor.green = value
         theColor.blue = value
     }
     else    // chromatic color; s != 0 so, so there is a hue
     {
         var theHue = hue * 360.0            // move from [0,1] to [0,360] to align with Foley,et al.
         if theHue == 360.0 {theHue = 0}     //360 is equivalent to 0 degrees
         theHue /= 60.0                      // h is now in [0,6]
         let i:Int = Int(theHue)             // floor of h; largest integer <=h
         let f:Double = theHue - Double(i)   // f is the fractional part of h
         let p:Double = value * (1.0 - saturation)
         let q:Double = value * (1.0 - (saturation * f));
         let t:Double = value * (1.0 - (saturation * (1.0 - f)))
         
         switch(i)
         {
         case 0:
             theColor.red = value; theColor.green = t; theColor.blue = p
         case 1:
             theColor.red = q; theColor.green = value; theColor.blue = p
         case 2:
             theColor.red = p; theColor.green = value; theColor.blue = t
         case 3:
             theColor.red = p; theColor.green = q; theColor.blue = value
         case 4:
             theColor.red = t; theColor.green = p; theColor.blue = value
         case 5: theColor.red = value; theColor.green = p; theColor.blue = q
         default:
             break
         }
     }
     
     return theColor
}

Finally, in CanvasPanel.swift, we need to update the body to use MetalView().

struct CanvasPanel: View {
     var body: some View {
         MetalView()
     }
 }

As of February 17, 2021