Search code examples
swiftuicanvasmetal

Why do SwiftUI Canvas and Metal render differently?


My app requires to render a SwiftUI View with an opacity defined by a field function.
jrturton developed a Canvas version, and a much faster Metal version (thanks again!).
I expected that both versions yield the same result, but this is not the case and I do not understand why, because both versions use float 32, as far as I can see.
Here is my result for both versions: enter image description here And here is my code:

struct ContentView: View {
let boardSize = CGSize(width: 100, height: 100)

var body: some View {
    HStack {
        VStack {
            Canvas { context, size in
                let pixelWidth  = Int(size.width)
                let pixelHeight = Int(size.height)
                var shadings: [Double: GraphicsContext.Shading] = [:]
                for y in 0 ..< pixelHeight {
                    for x in 0 ..< pixelWidth {
                        let path = Path(CGRect(x: x, y: y, width: 1, height: 1))
                        let strength = BorderField.borderField(size: boardSize, at: CGPoint(x: x, y: y)) 
                        if let shading = shadings[strength] {
                            context.fill(path, with: shading)
                        } else {
                            let shading = GraphicsContext.Shading.color(.red.opacity(strength))
                            shadings[strength] = shading
                            context.fill(path, with: shading)
                        }
                    }
                }
            }
            .frame(width: boardSize.width, height: boardSize.height)
            Text("Canvas")
        }
        
        VStack {
            Rectangle()
                .frame(width: boardSize.width, height: boardSize.height)
                .colorEffect(ShaderLibrary.borderField(.float2(boardSize.width, boardSize.height), .color(.red)))
            Text("Metal")
        }
    }
}

class BorderField {
    
    static func borderField(size: CGSize, at point: CGPoint) -> Double {
        let a = size.width
        let b = size.height
        let x = point.x
        let y = point.y
        
        // Compute distance to border
        let dt = b - y  // Distance to top
        let db = y      // Distance to bottom
        let dl = x      // Distance to left
        let dr = a - x  // Distance to right
        let minDistance = min(dt, db, dl, dr)
        
        let d = Double(minDistance)
        let r = d + 1.0 // min r is now 1
        let strength = 1.0/sqrt(r)
        return strength
    }

}

#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 borderField(float2 position, half4 currentColor, float2 size, half4 newColor) {
    
    // Compute distance to border
    float dt = size.y - position.y; // Distance to top
    float db = position.y;          // Distance to bottom
    float dl = position.x;          // Distance to left
    float dr = size.x - position.x; // Distance to right
    float minDistance = min(min(dt, db), min(dl, dr));
    float r = minDistance + 1.0;
    float strength = 1.0 / sqrt(r);
    float str = strength < 0.2 ? 0.0 : strength; // If not set to exactly 0, zstacked views are covered
    return half4(newColor.rgb, str);
}

I don't have any experience in Metal rendering, and would be happy for an explanation.

EDIT:

As Hamid Yusifli pointed out in his answer, my code is a slightly modified version of the originally suggested code. I used the additional line

float str = strength < 0.2 ? 0.0 : strength;  

because my app requires to overlay several fields using a ZStack, and these overlayed Views behave differently for Canvas and Metal rendering.
One can see this even without a ZStack:
enter image description here Here, the Metal view was created by the original code.

So the question remains: Why does Metal render differently?


Solution

  • I think the difference comes from the processing of the Color that is passed to the function. Things like Color.blue are

    A representation of a color that adapts to a given context

    And it seems like the precise RGB values being used in the canvas and the metal shader are different. We don't know what "context" the color is adapting to in the shader. If you force a uniform alpha of 0.5 for the canvas and the metal shader, and set Color.blue, you get this:

    enter image description here

    (Canvas on left, Metal on the right).

    However, if you instead use a non-contextual input colour with defined RGB values, such as Color(red: 0, green: 0, blue: 1), you get this with a fixed 0.5 alpha:

    enter image description here

    And returning to the border field function:

    enter image description here

    To get consistent results, you have to be explicit about the RGB values of the input colours.