渐变二维码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.swift
和qrcode.metal
中。ViewController.swift
里只是添加了ColorfulQRCodeMetalView
进行示例显示。
Metal基础代码
首先来介绍使用Metal需要的基础代码,CAMetalLayer
是利用Metal进行渲染的容器,我们需要创建并初始化一个CAMetalLayer
。device
是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
方法中使用该对象进行具体内容的绘制。最后使用commandBuffer
的present
和commit
把绘制指令递交给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更合适。