D37628d4ffa51918874a4dbc6db1e8e5
Metal【6】—— GPUImage 3 浅析、基础框架搭建、后续规划

话说一段时间没更新了,最近确实有点忙。赶着中秋放假,更新一篇 Metal 相关的文章。也祝大家中秋快乐~

这次的内容会比较简单、轻松。主要是浅析一下 GPUImage 3 的设计,然后基于此,剥离出一些代码,形成基础框架,实现视图封装,也为之后的效果处理提供支持。最后,我们再聊聊接下去的规划

本文的 Demo 效果如下:

PS:

订阅后的朋友,可以加我微信:wxidlongze,拉你进群。交流,扯淡,学习资源分享~

最后,源码在文末~

那么,开始吧。


GPUImage 3 浅析

GPUImage

GPUImage

之前接触过 OpenGL 的同学,肯定对 GPUImage 都不陌生,它是 @bradlarson 开源的,基于 OpenGL ES 封装的图像、视频处理框架,内置多种滤镜。支持自定义滤镜、相机实时滤镜处理等。

它极大程度降低了使用 OpenGL ES 的成本,发挥 GPU 的优势,方便处理图像,已经成为业内的标准,广受好评。

GPUImage 发展至今,演化出了不同的版本:

  • GPUImage,基于 OpenGL ES 封装,使用 Objective-C 编写
  • GPUImage 2,基于 OpenGL ES 封装,使用 Swift 编写
  • GPUImage 3,基于 Metal 封装,使用 Swift 编写

没错,GPUImage 3 是基于 Metal 封装的。在 WWDC 2018 宣布,iOS 12 将弃用 OpenGL / CL 后,原作者第一时间就提供了 Metal 相关的支持,赞。

PS:

值得一提的是,GPUImage 和 GPUImage 2 的主要贡献者,都是 Brad Larson 一个人。而 GPUImage 3 额外多了 @RedQueenCoder,也就是我们之前提到的,《Metal Programming Guide: Tutorial and Reference via Swift》 一书的作者。

相信,GPUImage 3 也会很快,成为主流的选择。

所以,GPUImage 3 自然是本文要分析的对象了。

PS:

截止 9 月 21 日,写这篇文章的时候,GPUImage 3 的最后一次提交是 afe11cc(Merge branch 'master' of github.com:BradLarson/GPUImage3),完成了基础的框架搭建,但还存在一些功能未实现

所以我们只能初步的分析下已实现的这些内容。

当然,不影响我们基础框架的实现,我们的基础框架,会随着效果的实现,逐步完善。

分析源码,要到哪一程度?

以我个人的观点,我会着重看它的设计思想,核心点的实现方式。然后思考,这么做的好处在哪里。

阅读源码过程中,如果能发现那么几行有意思、有内容的代码,也是极好。

所以针对 GPUImage 3,我们主要看它是如何对 Metal 进行封装,即它的设计思想,不会逐行代码解析。

首先,我们看下它的文件结构:

如果之前是之前有使用过 GPUImage 2 的同学,一定对这个文件结构非常熟悉,它和 GPUImage 2 基本一致。按照作者的意思,他会保证 GPUImage 2 / 3 API 的统一,将内部实现细节替换成 Metal。使得接入方,从 OpenGL 迁移到 Metal 的成本降低到最低。

The API is a clone of that used in GPUImage 2, and is intended to be a drop-in replacement for that version of the framework. Swapping between Metal and OpenGL versions of the framework should be as simple as changing which framework your application is linked against. A few low-level interfaces, such as those around texture input and output, will necessarily be Metal- or OpenGL-specific, but everything else is designed to be compatible between the two.

可以看到,GPUImage 3 整体分为 4 个部分, Base、Inputs、Outputs、Operations

回顾我们之前提到的图像处理过程,可以描述成图片,经过一系列滤镜处理后,得到结果图,然后渲染到视图上。

而 GPUImage 3,正是按照这样的一个流程,进行拆分。

具体对应层级如下所示:

第一层,是我们进行图像过程时,具体的流程描述

第二层,对应文件结构。按照这个划分的话,具体描述如下:

  • Inputs,存放输入源。具体表现为 PictureInput。
  • Outputs,存放输出源。具体表现为 RenderView。
  • Operation,存放各种滤镜。具体表现为 BasicOperation 等。
  • Base,至于 Base,则是为了完成上述的处理过程,添加的一些必要辅助、工具类。

具体的,我们逐个分析。


Base

Base 里面,涉及到较多 Metal 相关的,可以细分为这么两类:

  • 自定义基础数据类型:ImageOrientation、Color、Position、Size、Matrix、Timestamp、Texture
  • 辅助、工具类:MetalRenderingDevice、MetalRendering、ShaderUniformSettings、Pipeline

自定义的基础数据类型,这里比较简单,主要是为了兼容不同的平台,版本等问题。比如从 OpenGL 到 Metal 的切换,虽然它们二者的基础数据类型不一致,但是 GPUImage 通过自己定义的一套,就能保证对外使用的一致。

这里的类型包括方向,颜色,位置,大小,矩阵,时间戳,纹理等 。

PS:

相比 GPUImage 2,目前少了 FillMode,即填充模式。也就是上节我们遗留的图片变形问题。

在下文的基础框架搭建时候,我们会把这个补上。

这里比较简单,不再多说。


MetalRenderingDevice,以单例的形式,维护了 Metal 里面频繁要用到的对象,包括 MTLDevice,MTLCommandQueue,MTLLibrary。

public let sharedMetalRenderingDevice = MetalRenderingDevice()

public class MetalRenderingDevice {
    // MTLDevice
    // MTLCommandQueue

    public let device: MTLDevice
    public let commandQueue: MTLCommandQueue
    public let shaderLibrary: MTLLibrary
    public let metalPerformanceShadersAreSupported: Bool
      ...
}

之前有提到过,Metal 里面某些对象的创建代价是比较大的,而且可以重复使用。所以这类的,就需要放在单例里面统一维护,避免不必要的性能开销。这类对象具体包括:

  • Command queues
  • Data buffers
  • Textures
  • Sampler states
  • Libraries
  • Compute states
  • Render pipeline states
  • Depth/stencil states

PS:

MetalRenderingDevice,有点像 OpenGL 里面的 Context。统一维护了 Metal 相关的环境。


MetalRendering,顾名思义,是专门用来做渲染的。

提供了两个便捷方法:

func generateRenderPipelineState(device:MetalRenderingDevice, vertexFunctionName:String, fragmentFunctionName:String, operationName:String) -> MTLRenderPipelineState {
    // ....
}

extension MTLCommandBuffer {
    func renderQuad(pipelineState:MTLRenderPipelineState, uniformSettings:ShaderUniformSettings? = nil, inputTextures:[UInt:Texture], useNormalizedTextureCoordinates:Bool = true, imageVertices:[Float] = standardImageVertices, outputTexture:Texture, outputOrientation:ImageOrientation = .portrait) {
        // ....
    }
}

generateRenderPipelineState,通过传入的 device,vertexFunctionName 以及 fragmentFunctionName,来生成所需的 MTLRenderPipelineState。operationName 是辅助参数,调试查看的,和实际的渲染没有什么关系。

renderQuad 是针对 MTLCommandBuffer 扩展的一个方法,专门用来处理渲染,即对应的渲染管线配置,指令提交等。它接收对应的配置项,比如 MTLRenderPipelineState 和 inputTextures,然后执行渲染操作,将结果绘制到 outputTexture 上。可以说,这是整个渲染操作的核心所在。


ShaderUniformSettings,这里要先介绍下 uniform,到目前为止,我们还没有接触过。

A uniform is a value that is passed as a parameter to a shader that does not change over the course of a draw call. From the point of view of a shader, it is a constant.

简单来说,uniform 就是我们需要控制的一系列参数。和之前传入的 color 数据不同的是,它的值,是我们可控的,在 shader 执行过程中,它是一个常量。不需要经过插值在 fragment 中动态生成。

比如我们处理效果时,需要传入的半径,力度,透明度等,都属于 uniform。

uniform 数据,同样是存放在 MTLBuffer 中,然后传递给 shader。所以,不同于 OpenGL 中 uniform 通过 key 来索引,Metal 中则是按照 index 来标识。

以 RGBAdjustment 为例,它是一个用来调整 RGB 的滤镜。

open class BasicOperation {
    public var uniformSettings = ShaderUniformSettings()
}

public class RGBAdjustment: BasicOperation {
    public var red:Float = 1.0 { didSet { uniformSettings[0] = red } }
    public var blue:Float = 1.0 { didSet { uniformSettings[1] = blue } }
    public var green:Float = 1.0 { didSet { uniformSettings[2] = green } }

    public init() {
        super.init(fragmentFunctionName:"rgbAdjustmentFragment", numberOfInputs:1)

        uniformSettings.appendUniform(1.0)
        uniformSettings.appendUniform(1.0)
        uniformSettings.appendUniform(1.0)
    }
}

PS:

BasicOperation 是 GPUImage 3 里面对滤镜的抽象,这里先不考虑它具体是什么,下文会提到。

我们只关心,每个滤镜,都有 ShaderUniformSettings 这么一个类型的变量,来维护自己的参数列表。

当我们提供这样一个可以调整 RGB 的滤镜的时候,我们肯定会开放参数,供外部调整 RGB 各个分量的程度值。

比如这里开放的 red,blue,green 三个属性。

外部使用的时候,只需要通过 filter.red = 0.5 这样来调整即可。而对应内部的具体实现,都是和 uniformSettings 相关。

GPUImage 3 通过 ShaderUniformSettings,提供了 uniform 的便捷操作。

uniformValues 是 ShaderUniformSettings 中维护的一个参数列表。

通过 subscript,可以很方便的访问和修改对应下标处的参数值。

public var red:Float = 1.0 { didSet { uniformSettings[0] = red } }

///
public subscript(index:Int) -> Float {
    get { return uniformValues[internalIndex(for:index)]}
    set(newValue) {
        shaderUniformSettingsQueue.async {
            self.uniformValues[self.internalIndex(for:index)] = newValue
        }
    }
}

另外有这么两个核心的方法。appendUniform 和 restoreShaderSettings。

public func appendUniform(_ value:UniformConvertible) {
    let lastOffset = alignPackingForOffset(uniformSize:value.uniformSize(), lastOffset:uniformValueOffsets.last ?? 0)

    uniformValues.append(contentsOf:value.toFloatArray())
    uniformValueOffsets.append(lastOffset + value.uniformSize())
}

public func restoreShaderSettings(renderEncoder:MTLRenderCommandEncoder) {
    shaderUniformSettingsQueue.sync {
        guard (uniformValues.count > 0) else { return }
        let uniformBuffer = sharedMetalRenderingDevice.device.makeBuffer(bytes: uniformValues,
                                                                         length: uniformValues.count * MemoryLayout<Float>.size,
                                                                         options: [])!
        renderEncoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 1)
    }
}

appendUniform 会按序添加新的参数值,并通过 alignPackingForOffset 字节对齐,将当前参数值,存在到 uniformValues 数组对应下标处。

这里有两个关键点,按序和对应下标。

所以每个滤镜初始化的时候,都需要填充具体的默认值。

uniformSettings.appendUniform(1.0)
uniformSettings.appendUniform(1.0)
uniformSettings.appendUniform(1.0)

最后,通过调用 restoreShaderSettings,将所有的参数数据,放置在一个 MTLBuffer 中,再通过 setFragmentBuffer 传递到指定下标【1】中。

至于为什么,要放在同一个 buffer 里,主要是考虑到了扩展性和维护成本。不同的滤镜,对应的参数个数是不一样的。如果个数不统一,那么下标的维护,就会比较困难。

而放在同一个 buffer 里面,就要求内外的数据结构是一致的,即 GPU 知道按哪种格式去读区数据。

所以它对应的 shader,应该是这样的:

typedef struct
{
    float redAdjustment;
    float greenAdjustment;
    float blueAdjustment;
} RGBAdjustmentUniform;

fragment half4 rgbAdjustmentFragment(SingleInputVertexIO fragmentInput [[stage_in]],
                             texture2d<half> inputTexture [[texture(0)]],
                             constant RGBAdjustmentUniform& uniform [[ buffer(1) ]])
{
  // ....
}

这里的 RGBAdjustmentUniform 结构体,各个基础数据的顺序,和外部的 red,blue,green 对应下标,是一致的。

fragment 通过 constant RGBAdjustmentUniform& uniform [[ buffer(1) ]],指明下标【1】位置的 buffer 是我们外部传入的 uniform 数据。

所以,之后我们扩展自定义滤镜的时候,相关的 uniform 也应该按照这个方式去管理。


Base 部分,我们最后看一下 Pipeline。它是整个 GPUImage 3 设计的核心。里面有三个重要的 protocol:

  • ImageSource
  • ImageConsumer
  • ImageProcessingOperation

即上图中对应的第三个层,它表示各个部分所具备的能力。

public protocol ImageSource {
    var targets:TargetContainer { get }
    func transmitPreviousImage(to target:ImageConsumer, atIndex:UInt)
}
public extension ImageSource {
    public func updateTargetsWithTexture(_ texture: Texture) {
      for (target, index) in targets {
          target.newTextureAvailable(texture, fromSourceIndex: index)
      }
    }
}


public protocol ImageConsumer:AnyObject {
    var maximumInputs:UInt { get }
    var sources:SourceContainer { get }

    func newTextureAvailable(_ texture:Texture, fromSourceIndex:UInt)
}

public protocol ImageProcessingOperation: ImageConsumer, ImageSource {
}

其中,ImageSource 表示该对象具有输出能力。它维护了一组 targets,即能接收对应输出的对象。通过 transmitPreviousImage 方法,可以将当前的纹理,输出到对应的 target。正如我们之前提到的“待处理的原图”,它本身是作为输入源,但是它具备输出能力,能将它输出给接下去的滤镜,做效果处理。

ImageConsumer,表示该对象具有输入能力。它维护了一组 sources,即对应的输入源。当 newTextureAvailable 被触发的时候,表明新的纹理来了。这时候就可以做相关的刷新操作。

对比之前我们提到的“视图”,它本身是输出源,但是它具备输入能力,即能接收对应的纹理数据进行展示。

ImageProcessingOperation,对应我们之前提到的“滤镜”,它同时具备输入和输出能力

top Created with Sketch.