How to render a field strength in SwiftUI

148 views Asked by At

My app requires to render a field strength, i.e. the value of a function f(x,y) in a rectangle view.
I know that I can render linear or radial gradients, see here, but I did not find a way to set the brightness or opacity of a pixel of a View depending on a function f(x,y).
I could probably assemble the View of very many subviews where the brightness or opacity of each subview is set according to such a function, but this is ugly and there should be a better way.
Any suggestions?

3

There are 3 answers

1
jrturton On BEST ANSWER

Here is an alternative answer using a Metal shader. You'd need to create a shader for each implementation of the field strength algorithm, here's my attempt at your border field:

#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 borderField(float2 position, half4 currentColor, float2 size, half4 newColor) {
    assert(size.x >= 0 && position.x <= size.x);
    assert(size.y >= 0 && position.y <= size.y);

    // 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);
    return half4(newColor.rgb, strength);

}

Add this to your project in a new .metal file.

The first two parameters are passed by default, which is the position of the current pixel and its current colour. The subsequent ones are up to you.

To use in SwiftUI, use the .colorEffect modifier:

Rectangle()
    .frame(width: 300, height: 300)
    .colorEffect(ShaderLibrary.borderField(.float2(300, 300), .color(.blue)))

Note that we're passing in the size and the base color here.

This gives:

enter image description here

I'd be fascinated to know the performance differences you encounter between these implementations.

——

Update from Reinhard Männer where it seems Metal really is quite a lot faster!

Your 1st solution was already great, but this one is absolutely great!
I implemented it in my app.
I am not sure how to time it right, but I did the following:

var body: some View {
    let start = Date.now
    Rectangle()
        .frame(width: boardSize.width, height: boardSize.height)
        .colorEffect(ShaderLibrary.borderField(.float2(boardSize.width, boardSize.height), .color(.green)))
    let _ = print(-start.timeIntervalSinceNow)
}

where boardSize has been set the same as in my previous timing (900,357).
Earlier, the rendering took about 0.15 sec. With your metal solution and my timing above, the result is
3.0040740966796875e-05
If my timing is right, this is an unbelievable speedup of 5.000. And this motivates me, to look also into metal. Thanks a lot!

2
Reinhard Männer On

Here is my solution, based on the suggestion of lorem ipsum above.
This suggestion requires to compute an Image and to overlay it over a View.

I thus wrote the following extension to Image:

extension Image {
    
    /// Creates an Image of a given size with a given color. 
    /// The color, without the alpha value, is here hard coded, but could of course be given by a parameter.
    /// The alpha value represents the field function, depending on the (x,y) pixel value.
    /// - Parameters:
    ///   - size: The size of the resulting image
    ///   - alpha: The field function
    init?(size: CGSize, alpha: (_ x: Int, _ y: Int) -> Double) {
        // Set color components as 8 bits in hex. Alpha will be taken from the function parameter.
        // Pixel values are 32 bits: ARGB
        let r: UInt32 = 0x00 << 16
        let g: UInt32 = 0x00 << 8
        let b: UInt32 = 0xFF 
        
        let pixelWidth  = Int(size.width)
        let pixelHeight = Int(size.height)
        var srgbArray: [UInt32] = [] // The pixel array

        // Compute field values
        for y in 0 ..< pixelHeight {
            for x in 0 ..< pixelWidth {
                let fieldStrength = alpha(x, y)
                if fieldStrength < 0 || fieldStrength > 1.0 {
                    return nil // The alpha function did return an illegal value (< 0 or > 1).
                }
                let a = UInt32(fieldStrength * 255.0) << 24 // alpha
                srgbArray.append(a | r | g | b)
            }
        }
        
        // https://forums.swift.org/t/creating-a-cgimage-from-color-array/18634
        let cgImg = srgbArray.withUnsafeMutableBytes { ptr -> CGImage in
            let ctx = CGContext(
                data: ptr.baseAddress,
                width: pixelWidth,
                height: pixelHeight,
                bitsPerComponent: 8,
                bytesPerRow: 4 * pixelWidth,
                space: CGColorSpace(name: CGColorSpace.sRGB)!,
                bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue + CGImageAlphaInfo.premultipliedFirst.rawValue
            )!
            return ctx.makeImage()!
        }
        
        self.init(
            cgImg,
            scale: 1.0,
            orientation: .up,
            label: Text("Field")
        )
    }
}

The Image is initialized as

let image = Image(size: fieldSize, alpha: alpha(x:y:))  

where function alpha can be any function. Here, for demo purposes, I am using the following function:

func alpha(x: Int, y: Int) -> Double {
    Field.borderfield(size: fieldSize, x: x, y: y)
}

with

struct Field {
    
    static func borderfield(size: CGSize, x: Int, y: Int) -> Double {
        let a = Int(size.width)
        let b = Int(size.height)
        assert(x >= 0 && x <= a)
        assert(y >= 0 && y <= b)
        
        // 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 signalStrength = 1.0/sqrt(r)
        print(signalStrength)
        return signalStrength
    }
    
}  

Function borderfield is a very rough approximation to the field that a charged border of the displayed rectangle would produce.
This is the image:
enter image description here

4
jrturton On

Here is a similar (but much shorter!) implementation using Canvas. I haven't looked at the performance but my experience with canvas is that it's extremely good, especially if you cache the resolved shading values as I have.

struct FieldStrengthView: View {

    let color: Color
    let alpha: (_ x: Int, _ y: Int) -> Double

    var body: some View {
        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 strength = alpha(x, y)
                    let path = Path(CGRect(x: x, y: y, width: 1, height: 1))
                    if let shading = shadings[strength] {
                        context.fill(path, with: shading)
                    } else {
                        let shading = GraphicsContext.Shading.color(color.opacity(strength))
                        shadings[strength] = shading
                        context.fill(path, with: shading)
                    }
                }
            }
        }
    }
}

It fills in one pixel at a time using the field strength from your existing function:

#Preview {
    VStack {
        FieldStrengthView(color: .blue, alpha: { x, y in
            Field.borderfield(size: CGSize(width: 300, height: 300), x: x, y: y)
        })
        .frame(width: 300, height: 300)
        FieldStrengthView(color: .green, alpha: { x, y in
            Field.borderfield(size: CGSize(width: 200, height: 200), x: x, y: y)
        })
        .frame(width: 200, height: 200)
    }
}

Gives:

enter image description here