Search code examples
iosswifttexturesgpumetal

How to convert bgra8Unorm iOS-Metal texture to rgba8Unorm texture?


I am working with iOS 11, XCode 9 and Metal 2. I have a MTLTexture with pixel format bgra8Unorm. I cannot change this pixel format, because according to pixelFormat documentation:

The pixel format for a Metal layer must be bgra8Unorm, bgra8Unorm_srgb, rgba16Float, BGRA10_XR, or bgra10_XR_sRGB.

The other pixel formats are not suitable for my application.

Now I want to create an UIImage from the texture. I am able to do so by extracting the pixel bytes from the texture (doc):

getBytes(_:bytesPerRow:bytesPerImage:from:mipmapLevel:slice:)

I am processing these bytes to get an UIImage:

func getUIImageForRGBAData(data: Data) -> UIImage? {
    let d = (data as NSData)

    let width = GlobalConfiguration.textureWidth
    let height = GlobalConfiguration.textureHeight
    let rowBytes = width * 4
    let size = rowBytes * height

    let pointer = malloc(size)
    memcpy(pointer, d.bytes, d.length)

    let colorSpace = CGColorSpaceCreateDeviceRGB()
    let context = CGContext(data: pointer, width: width, height: height, bitsPerComponent: 8, bytesPerRow: rowBytes, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!

    let imgRef = context.makeImage()
    let image = UIImage(cgImage: imgRef!)
    return image
}

However, CGContext assumes that the pixels are in the rgba8 format. For example, red texture pixels are blue in the final UIImage. Is there a way to change the pixelFormat in this process to get the proper colors?


Solution

  • This function will swizzle the bytes of a .bgra8Unorm texture into RGBA order and create a UIImage from the data:

    func makeImage(from texture: MTLTexture) -> UIImage? {
        let width = texture.width
        let height = texture.height
        let bytesPerRow = width * 4
    
        let data = UnsafeMutableRawPointer.allocate(bytes: bytesPerRow * height, alignedTo: 4)
        defer {
            data.deallocate(bytes: bytesPerRow * height, alignedTo: 4)
        }
    
        let region = MTLRegionMake2D(0, 0, width, height)
        texture.getBytes(data, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)
    
        var buffer = vImage_Buffer(data: data, height: UInt(height), width: UInt(width), rowBytes: bytesPerRow)
    
        let map: [UInt8] = [2, 1, 0, 3]
        vImagePermuteChannels_ARGB8888(&buffer, &buffer, map, 0)
    
        guard let colorSpace = CGColorSpace(name: CGColorSpace.genericRGBLinear) else { return nil }
        guard let context = CGContext(data: data, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow,
                                      space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return nil }
        guard let cgImage = context.makeImage() else { return nil }
    
        return UIImage(cgImage: cgImage)
    }
    

    Caveat: This function is very expensive. Creating an image from a Metal texture every frame is almost never what you want to do.