How to continuously render a software bitmap with SwiftUI?

Viewed 436

I'm trying to make a simple software renderer for MacOS, but am having trouble displaying my bitmap continuously. The examples I find about setting this up is either using IOS or OpenGL/Metal views.

Here's my attempt in a mcve which compiles but stops updating the screen after a couple of iterations. I've made sure that the displayCallback is called correctly.

import SwiftUI

@main
struct PlatformApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

public struct Color {
    public var r, g, b, a: UInt8
    
    public init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) {
        self.r = r
        self.g = g
        self.b = b
        self.a = a
    }
}

public extension Color {
    static let clear = Color(r: 0,   g: 0,   b: 0, a: 0)
    static let red   = Color(r: 255, g: 0,   b: 0)
    static let green = Color(r: 0,   g: 255, b: 0)
    static let blue  = Color(r: 0,   g: 0,   b: 255)
}

extension Image {
    init?(bitmap: Bitmap) {
        let alphaInfo = CGImageAlphaInfo.premultipliedLast
        let bytesPerPixel = MemoryLayout<Color>.size
        let bytesPerRow = bitmap.width * bytesPerPixel

        guard let providerRef = CGDataProvider(data: Data(
            bytes: bitmap.pixels, count: bitmap.height * bytesPerRow
        ) as CFData) else {
            return nil
        }

        guard let cgImage = CGImage(
            width: bitmap.width,
            height: bitmap.height,
            bitsPerComponent: 8,
            bitsPerPixel: bytesPerPixel * 8,
            bytesPerRow: bytesPerRow,
            space: CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: CGBitmapInfo(rawValue: alphaInfo.rawValue),
            provider: providerRef,
            decode: nil,
            shouldInterpolate: true,
            intent: .defaultIntent
        ) else {
            return nil
        }

        self.init(decorative: cgImage, scale: 1.0, orientation: .up)
    }
}

public struct Bitmap {
    public private(set) var pixels: [Color]
    public let width: Int
    
    public init(width: Int, pixels: [Color]) {
        self.width  = width
        self.pixels = pixels
    }
}

public extension Bitmap {
    var height: Int {
        return pixels.count / width
    }
    
    subscript(x: Int, y: Int) -> Color {
        get { return pixels[y * width + x] }
        set { pixels[y * width + x] = newValue }
    }

    init(width: Int, height: Int, color: Color) {
        self.pixels = Array(repeating: color, count: width * height)
        self.width  = width
    }
}

public struct Renderer {
    public private(set) var bitmap: Bitmap
    private var i: Int = 0
    private var j: Int = 0
    public init(width: Int, height: Int, backgroundColor: Color = .clear) {
        self.bitmap = Bitmap(width: width, height: height, color: backgroundColor)
    }
}

public extension Renderer {
    mutating func draw() {  // Temporary for testing
        bitmap[i, j] = [Color.red, Color.green, Color.blue][i % 3]
    
        i = i + 1
        if (i >= bitmap.width) {
            i = 0
            j += 1
            if (j >= bitmap.height) {
                j = 0
            }
        }
    }
}


struct ContentView: View {
    @State var game = Game(width: 10, height: 10)
    
    var body: some View {
        game.image?
            .resizable()
            .interpolation(.none)
            .frame(width: 200, height: 200, alignment: .center)
            .aspectRatio(contentMode: .fit)
    }
}


class Game {
    public  var image: Image?
    private var renderer: Renderer
    private var displayLink: CVDisplayLink?
    
    let displayCallback: CVDisplayLinkOutputCallback = { displayLink, inNow, inOutputTime, flagsIn, flagsOut, displayLinkContext in
        let game = unsafeBitCast(displayLinkContext, to: Game.self)
        game.renderer.draw()
        game.image = Image(bitmap: game.renderer.bitmap)
        return kCVReturnSuccess
    }
    
    init(width: Int, height: Int) {
        self.renderer = Renderer(width: width, height: height)

        let error = CVDisplayLinkCreateWithActiveCGDisplays(&self.displayLink)
        guard let link = self.displayLink, kCVReturnSuccess == error else {
            NSLog("Display Link created with error: %d", error)
            return
        }

        CVDisplayLinkSetOutputCallback(link, displayCallback, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
        CVDisplayLinkStart(link)
    }
    
    deinit {
        CVDisplayLinkStop(self.displayLink!)
    }
}

The result:

Output

So the problem seem to be that the view isn't updated properly. I thought that @State var game would make it so the view got updated whenever it was modified, but I guess I need to modify the field directly for it to register.

I've tried using Timer in ContentView to call a function that updates it, but I can't capture self in its init (Escaping closure captures mutating 'self' parameter) or have a @objc function in the view (@objc can only be used with members of classes, @objc protocols, and concrete extensions of classes).

I'm not sure how to approach this problem. I've tried to pass ContentView to the CVDisplayLinkOutputCallback, but I got an error stating that Generic struct 'Unmanaged' requires that 'ContentView' be a class type.

So, how do I continuously render a software bitmap with SwiftUI?

1 Answers

You’re using @State but your game is a reference type class not a value type struct. Use something like observedobject

For example: a quick and dirty hack:

struct ContentView: View {
    @ObservedObject
    var game = Game(width: 10, height: 10)
    
    var body: some View {
        game.image?
            .resizable()
            .interpolation(.none)
            .frame(width: 200, height: 200, alignment: .center)
            .aspectRatio(contentMode: .fit)
    }
}


class Game: ObservableObject {
    @Published
    var toggle: Bool = false
    
    public  var image: Image? {
        didSet {
            DispatchQueue.main.async { [weak self] in
                self?.toggle.toggle()
            }
        }
    }
Related