渐变二维码Metal实现

获取示例代码

前言

上一篇文章中我们介绍了如何使用CALayer遮罩实现渐变二维码,没看过的读者如果有兴趣可以去看一下。本文将介绍如何使用Metal(苹果的亲儿子)实现渐变二维码的效果。下面是效果图。

Metal概述

作为一名iOS开发,就算你没用过Metal,也应该听说过。Metal是苹果捣腾出来用来代替OpenGL的一套3D渲染API,他和OpenGL ES一样,可以高效率的利用GPU,编写Vertex Shader和Fragment Shader充分的控制渲染流程。想要顺利的看完本文,你至少要知道Shader是什么。Metal和OpenGL ES在Shader的概念上是互通的,可以前往OpenGL ES相关知识对Shader有一个初步的了解,这对你阅读下文会有很大的帮助。

涉及代码

本文涉及的代码在项目的ColorfulQRCodeMetalView.swiftqrcode.metal中。ViewController.swift里只是添加了ColorfulQRCodeMetalView进行示例显示。

Metal基础代码

首先来介绍使用Metal需要的基础代码,CAMetalLayer是利用Metal进行渲染的容器,我们需要创建并初始化一个CAMetalLayerdevice是Metal中用来申请资源的重要对象,比如创建纹理,缓冲区等等。

func initMetal() {
    device = MTLCreateSystemDefaultDevice()
    guard device != nil else {
        print("Metal is not supported on this device")
        return
    }
    
    metalLayer = CAMetalLayer()
    metalLayer.device = device
    metalLayer.pixelFormat = .bgra8Unorm
    metalLayer.framebufferOnly = true
    metalLayer.frame = self.bounds
    self.layer.addSublayer(metalLayer)
}

接下来初始化Shader,在Metal中,Shader编译处理完后形成的对象称为Pipline,流水线。还是很符合Shader的实际工作流程的。

func initPipline() {
    commandQueue = device.makeCommandQueue()
    commandQueue.label = "main metal command queue"
    
    let defaultLibrary = device.makeDefaultLibrary()!
    let fragmentProgram = defaultLibrary.makeFunction(name: "passThroughFragment")!
    let vertexProgram = defaultLibrary.makeFunction(name: "passThroughVertex")!
    
    self.pipelineStateDescriptor = MTLRenderPipelineDescriptor()
    pipelineStateDescriptor.vertexFunction = vertexProgram
    pipelineStateDescriptor.fragmentFunction = fragmentProgram
    pipelineStateDescriptor.colorAttachments[0].pixelFormat = metalLayer.pixelFormat
    
    do {
        try pipelineState = device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
    } catch let error {
        print("Failed to create pipeline state, error \(error)")
    }
}

创建完成后,得到一个pipelineState,我们会在渲染时使用到它。

Metal渲染基础代码

我们有了显示渲染内容的metalLayer,关联好Shader的pipelineState,接下来,就可以编写基础的渲染代码了。

func render(qrcodeTexture: MTLTexture) {
    guard let drawable = metalLayer?.nextDrawable() else { return }
    let renderPassDescriptor = MTLRenderPassDescriptor.init()
    renderPassDescriptor.colorAttachments[0].texture = drawable.texture
    renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor.init(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0);
    renderPassDescriptor.colorAttachments[0].loadAction = .clear
    
    let commandBuffer = commandQueue.makeCommandBuffer()!
    commandBuffer.label = "Frame command buffer"
    let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
    renderEncoder.label = "render encoder"
    renderEncoder.pushDebugGroup("begin draw")
    renderEncoder.setRenderPipelineState(pipelineState)
    
    self.draw(renderEncoder: renderEncoder, qrcodeTexture: qrcodeTexture)
    
    renderEncoder.popDebugGroup()
    renderEncoder.endEncoding()
    
    commandBuffer.present(drawable)
    commandBuffer.commit()
}

这段代码看似很长,实际上并没有做具体的渲染工作。只是做了一些准备工作。使用renderPassDescriptor来描述本次渲染的渲染目标和如何清空缓冲区。然后配合pipelineState获得一个用作绘图的对象renderEncoder。我在draw方法中使用该对象进行具体内容的绘制。最后使用commandBufferpresentcommit把绘制指令递交给GPU。

创建二维码纹理

ColorfulQRCodeMetalView接受UIImage类型的变量作为二维码图片,但是Metal只接受纹理。所以我们需要使用UIImage类型的二维码图片生成Metal纹理。

func createQRCodeTexture(qrcodeImage: UIImage) -> MTLTexture? {
    let bitsPerComponent = 8
    let bytesPerPixel = 4
    let width:Int = Int(qrcodeImage.size.width)
    let height:Int = Int(qrcodeImage.size.height)
    let imageData = UnsafeMutableRawPointer.allocate(bytes: Int(width * height * bytesPerPixel), alignedTo: 8)
    
    let colorSpace = CGColorSpaceCreateDeviceRGB()
    let imageContext = CGContext.init(data: imageData, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: width * bytesPerPixel, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGImageByteOrderInfo.order32Big.rawValue )
    UIGraphicsPushContext(imageContext!)
    imageContext?.translateBy(x: 0, y: CGFloat(height))
    imageContext?.scaleBy(x: 1, y: -1)
    qrcodeImage.draw(in: CGRect.init(x: 0, y: 0, width: width, height: height))
    UIGraphicsPopContext()
    
    let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, width: width, height: height, mipmapped: false)
    descriptor.usage = .shaderRead
    let texture = device.makeTexture(descriptor: descriptor)
    texture?.replace(region: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0, withBytes: imageData, bytesPerRow: width * bytesPerPixel)
    return texture
}

将图片数据解压为RGBA格式的纯字节流,然后通过Metal的API创建纹理。

绘制

万事具备,接下来就可以进行绘制了。我们要绘制的是一个2x2的矩形,这样可以刚好填满MetalLayer,然后把二维码纹理传递给Shader。渐变色数组和渐变色数目也要传递给Shader,我们会在Shader中计算每个像素应该是什么颜色。

func draw(renderEncoder: MTLRenderCommandEncoder, qrcodeTexture: MTLTexture) {
    let squareData: [Float] = [
        -1,   1,    0.0, 0, 0,
        -1,   -1,   0.0, 0, 1,
        1,    -1,   0.0, 1, 1,
        1,    -1,   0.0, 1, 1,
        1,    1,    0.0, 1, 0,
        -1,   1,    0.0, 0, 0
    ]
    let vertexBufferSize = MemoryLayout<Float>.size * squareData.count
    let vertexBuffer = device.makeBuffer(bytes: squareData, length: vertexBufferSize, options: MTLResourceOptions.cpuCacheModeWriteCombined)
    renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
    renderEncoder.setFragmentTexture(qrcodeTexture, index: 0)
    
    let colors: [Float] = [
        0x2a / 255.0, 0x9c / 255.0, 0x1f / 255.0,
        0xe6 / 255.0, 0xcd / 255.0, 0x27 / 255.0,
        0xe6 / 255.0, 0x27 / 255.0, 0x57 / 255.0
    ]
    let colorsBufferSize = MemoryLayout<Float>.size * colors.count
    let colorsBuffer = device.makeBuffer(bytes: colors, length: colorsBufferSize, options: MTLResourceOptions.cpuCacheModeWriteCombined)
    renderEncoder.setFragmentBuffer(colorsBuffer, offset: 0, index: 0)
    
    let uniform: [Int] = [colors.count / 3]
    let uniformBufferSize = MemoryLayout<Int>.size * uniform.count
    let uniformBuffer = device.makeBuffer(bytes: uniform, length: uniformBufferSize, options: MTLResourceOptions.cpuCacheModeWriteCombined)
    renderEncoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 1)
    
    renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}

代码从上往下,先是创建顶点缓冲对象,绑定到Vertex Shader Index为0的缓冲区,然后创建渐变色数组缓冲对象,绑定到Fragment Shader Index为0的缓冲区。最后创建一个Uniform缓冲对象,里面只有渐变色的个数,绑定到Fragment Shader Index为1的缓冲区。都绑定完后,使用drawPrimitives绘制三角形。

Shader

swift的代码差不多就结束了,接下来我们来看看Shader是怎么写的。Metal的Shader以metal为后缀。本文Shader文件名为qrcode.metal。Vertex Shader和Fragment Shader可以在一个文件中,通过对方法的修饰区分。我们先来看一下Shader的全貌。

#include <metal_stdlib>
using namespace metal;

struct VertexIn
{
    packed_float3  position;
    packed_float2  uv;
};

struct VertexOut
{
    float4  position [[position]];
    float2  uv;
};

struct Uniforms
{
    int colorCount;
};

vertex VertexOut passThroughVertex(uint vid [[ vertex_id ]],
                                   const device VertexIn* vertexIn [[ buffer(0) ]])
{
    VertexOut outVertex;
    VertexIn inVertex = vertexIn[vid];
    outVertex.position = float4(inVertex.position, 1.0);
    outVertex.uv = inVertex.uv;
    return outVertex;
};

constexpr sampler s(coord::normalized, address::repeat, filter::linear);

fragment float4 passThroughFragment(VertexOut inFrag [[stage_in]],
                                   texture2d<float> diffuse [[ texture(0) ]],
                                    const device packed_float3* colors [[ buffer(0) ]],
                                    const device Uniforms& uniform [[ buffer(1) ]])
{
    int colorCount = uniform.colorCount;
    float colorEffectRange = 1.0 / (colorCount - 1.0);
    float3 gradientColor = float3(0.0);
    int colorZoneIndex = inFrag.uv.y / colorEffectRange;
    colorZoneIndex = colorZoneIndex >= colorCount - 1 ? colorCount - 2 : colorZoneIndex;
    float effectFactor = (inFrag.uv.y - colorZoneIndex * colorEffectRange) / colorEffectRange;
    gradientColor = colors[colorZoneIndex] * (1.0 - effectFactor) + colors[colorZoneIndex + 1] * effectFactor;
    float4 qrcodeColor = diffuse.sample(s, inFrag.uv);
    if (qrcodeColor.r > 0.5) {
        discard_fragment();
    } else {
        return float4(gradientColor, 1.0);
    }
};

Vertex Shader

Metal中Vertex Shader的入口方法需要以 vertex开头,本文的入口方法如下。它做的事情很简单,接受传递进来的顶点数据,包括位置和uv,然后原封不动的传递给Fragment Shader。我们在主程序中将顶点数据绑定到了缓冲区0,所以顶点数据参数后面使用[[ buffer(0) ]]标记。

vertex VertexOut passThroughVertex(uint vid [[ vertex_id ]],
                                   const device VertexIn* vertexIn [[ buffer(0) ]])
{
    VertexOut outVertex;
    VertexIn inVertex = vertexIn[vid];
    outVertex.position = float4(inVertex.position, 1.0);
    outVertex.uv = inVertex.uv;
    return outVertex;
};

Fragment Shader

Fragment Shader以fragment开头,接受了3个参数,二维码纹理diffuse,渐变色数组colors,渐变色个数uniform.colorCount。我们还申明了一个sampler s,用来对纹理进行采样。

constexpr sampler s(coord::normalized, address::repeat, filter::linear);

fragment float4 passThroughFragment(VertexOut inFrag [[stage_in]],
                                   texture2d<float> diffuse [[ texture(0) ]],
                                    const device packed_float3* colors [[ buffer(0) ]],
                                    const device Uniforms& uniform [[ buffer(1) ]])
{
    int colorCount = uniform.colorCount;
    float colorEffectRange = 1.0 / (colorCount - 1.0);
    float3 gradientColor = float3(0.0);
    int colorZoneIndex = inFrag.uv.y / colorEffectRange;
    colorZoneIndex = colorZoneIndex >= colorCount - 1 ? colorCount - 2 : colorZoneIndex;
    float effectFactor = (inFrag.uv.y - colorZoneIndex * colorEffectRange) / colorEffectRange;
    gradientColor = colors[colorZoneIndex] * (1.0 - effectFactor) + colors[colorZoneIndex + 1] * effectFactor;
    float4 qrcodeColor = diffuse.sample(s, inFrag.uv);
    if (qrcodeColor.r > 0.5) {
        discard_fragment();
    } else {
        return float4(gradientColor, 1.0);
    }
};

代码主要是在计算当前像素的取色,首先计算出当前像素点在渐变色的哪一段,得到colorZoneIndex,然后根据像素在这一段的位置,使用两端的颜色计算最终的像素颜色。最后取出二维码的颜色,如果二维码是偏白色的就直接忽略这个像素,这样就会显示主程序里配置的清除色clearColor。反之,直接返回计算出来的渐变色。我使用r > 0.5来判断是不是白色像素。这样出来的效果就是,白色的部分被替换为clearColor,黑色部分变成对应的渐变色。

总结

本文我们使用Metal实现了渐变二维码的效果, 相对遮罩技术来说,没有任何技巧性,粗暴的将每个像素处理成我们需要的颜色。当然你也可以使用Fragment Shader中的算法,直接在CPU中处理每个像素。不过很明显这种任务GPU更合适。

Updated: