91cdb2d2bfac91f9b0a26f8ae0f47be5
Metal【7】—— 颜色滤镜

回到 Metal 部分,继续分析上次 GPUImage 3 余下的 Operations 部分。然后介绍颜色滤镜常见的两种实现方式:shaderlookup table,并实现饱和度、亮度滤镜,以及常见的阿宝色滤镜。同时会分析对应算法的原理。

效果如下:

PS:

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

最后,源码在文末~

那么,开始吧。


1. GPUImage 3 Operations 分析

在上篇中,我们对 GPUImage 3 整体的文件架构进行了分析,但是遗留 Operations 部分只是简单带过。

这篇中。我们着重看下,GPUImage 3 是如何设计和实现滤镜的。

同样,我们先看它的文件结构:

Operations 中主要包含了这两部分内容:

  • 为实现滤镜而封装的基础类:根目录下的类。
  • 已实现的内置滤镜:子文件夹中的类。

PS:

GPUImage 中包含众多内置滤镜,但都是一些基础的效果。我们平时直接使用的情况比较少。而且这部分,涉及的是效果算法层面的东西,和 Metal 本身关系不大。所以我们会略过这部分的内容,感兴趣的话,大家可以自行查阅。

所以,我们着重看下,GPUImage 3 是如何设计滤镜,让我们可以很方便的使用,以及扩展各种效果的滤镜。

回到滤镜的本质,它其实就是一个效果处理过程的抽象。将输入图像,经过一定的滤镜算法处理后,得到输出图像。所以,它同时具备输入和输出的能力。

public protocol ImageProcessingOperation: ImageConsumer, ImageSource {
}

open class BasicOperation: ImageProcessingOperation {
    public let maximumInputs: UInt
    public let targets = TargetContainer()
    public let sources = SourceContainer()

    public var uniformSettings = ShaderUniformSettings()

    let renderPipelineState: MTLRenderPipelineState
    var inputTextures = [UInt:Texture]()
    let textureInputSemaphore = DispatchSemaphore(value:1)
    // ....
}

如上, BasicOperation 是 GPUImage 3 对滤镜的抽象。从命名,我们也可以看出,滤镜本身就是一种操作

它主要包括这么几个属性:

  • targets、sources 不再累述,管理着对应的输入输出。
  • renderPipelineState,本次渲染操作的具体描述,主要差异体现在 Vertex Function 和 Fragment Function 上。
  • uniformSettings,滤镜需要的一些参数配置。
  • inputTextures,对应的输入纹理。

这几个都是中规中矩的。下面我们着重说一下 maximumInputstextureInputSemaphore 存在的意义。

maximumInputs 指,具体的输入纹理个数。

为什么需要额外标记这么一个东西呢?主要有这么两个目的:

  1. 便捷初始化
  2. 容错

而 textureInputSemaphore,主要是为了控制一次处理的完整执行。

这两点,我们会在下文中具体展开讲。

BasicOperation 只提供了这么一个初始化方式,接收 Vertex Function 和 Fragment Function,以及之前提到的 maximumInputs。

public init(vertexFunctionName: String? = nil,
            fragmentFunctionName: String,
            numberOfInputs: UInt = 1) {
    self.maximumInputs = numberOfInputs

    let concreteVertexFunctionName = vertexFunctionName ?? FunctionName.defaultVertexFunctionNameForInputs(numberOfInputs)
    renderPipelineState = generateRenderPipelineState(vertexFunctionName: concreteVertexFunctionName, fragmentFunctionName: fragmentFunctionName)
}

我们知道,正常情况下,一个滤镜,它只需要一张输入纹理,即待处理的图片。这时候,默认的 maximumInputs 为 1。

但也存在一些情况,这个滤镜的处理,需要额外的纹理来配合。比如 mask,比如 lut(下文会提到),所以它需要额外的输入纹理来辅助,这时候 maximumInputs 为 2。

1 和 2,承包了绝大多数的情况,当然,也有可能存在3,存在 4 等等情况。

GPUImage,为我们便捷创建了大多数情况下,1 和 2 所需要的 Vertex Function。

let concreteVertexFunctionName = vertexFunctionName ?? FunctionName.defaultVertexFunctionNameForInputs(numberOfInputs)

////

enum FunctionName {
    static let OneInputVertex = "oneInputVertex"
    static let TwoInputVertex = "twoInputVertex"
    static let PassthroughFragment = "passthroughFragment"

    static func defaultVertexFunctionNameForInputs(_ inputCount:UInt) -> String {
        switch inputCount {
        case 1:
            return OneInputVertex
        case 2:
            return TwoInputVertex
        default:
            return OneInputVertex
        }
    }
}

即 oneInputVertex 和 twoInputVertex,它们适用于绝大多数情况。二者差异主要体现在多了纹理坐标的传入。

vertex SingleInputVertexIO oneInputVertex(device packed_float2 *position [[buffer(0)]],
                                          device packed_float2 *texturecoord [[buffer(1)]],
                                          uint vid [[vertex_id]])
{
    SingleInputVertexIO outputVertices;

    outputVertices.position = float4(position[vid], 0, 1.0);
    outputVertices.textureCoordinate = texturecoord[vid];

    return outputVertices;
}

vertex TwoInputVertexIO twoInputVertex(device packed_float2 *position [[buffer(0)]],
                                       device packed_float2 *texturecoord [[buffer(1)]],
                                       device packed_float2 *texturecoord2 [[buffer(2)]],
                                       uint vid [[vertex_id]])
{
    TwoInputVertexIO outputVertices;

    outputVertices.position = float4(position[vid], 0, 1.0);
    outputVertices.textureCoordinate = texturecoord[vid];
    outputVertices.textureCoordinate2 = texturecoord2[vid];

    return outputVertices;
}

当然,上面提到的,都是便捷、通用的方式。你完全可以使用自定义的 Vertex Function。

最后,和之前一样,通过 Vertex Function 和 Fragment Function 创建对应的 renderPipelineState。

PS:

这里的 Fragment Function,没有提供默认实现,因为它是整个滤镜的具体体现,是没办法抽象、归纳出来的,因为各个滤镜对应的算法都不一样。

BasicOperation 具备输入和输出能力,它的核心,体现在 newTextureAvailable 的处理上:

public func newTextureAvailable(_ texture: Texture, fromSourceIndex: UInt) {
    let _ = textureInputSemaphore.wait(timeout:DispatchTime.distantFuture)
    defer {
        textureInputSemaphore.signal()
    }

    inputTextures[fromSourceIndex] = texture

    if (UInt(inputTextures.count) >= maximumInputs) {
        let outputWidth: Int
        let outputHeight: Int

        let firstInputTexture = inputTextures[0]!
        outputWidth = firstInputTexture.texture.width
        outputHeight = firstInputTexture.texture.height

        guard let commandBuffer = sharedContext.commandQueue.makeCommandBuffer() else {
            return
        }

        let outputTexture = Texture(width: outputWidth, height: outputHeight)

        commandBuffer.renderQuad(pipelineState: renderPipelineState, uniformSettings: uniformSettings, inputTextures: inputTextures, outputTexture: outputTexture)
        commandBuffer.commit()

        updateTargetsWithTexture(outputTexture)
    }
}

我们逐步来看。

let _ = textureInputSemaphore.wait(timeout:DispatchTime.distantFuture)
defer {
    textureInputSemaphore.signal()
}

首先就是 textureInputSemaphore 这个信号量,它保证 newTextureAvailable 中的一次渲染操作,能完整执行。因为这部分操作是异步执行的,如果不加控制,会存在当前一条滤镜链正在处理的时候,另外一条又插入进来,但是我们的屏幕只有一个,同时操作的 drawable 也只有一个(self.metalView.currentDrawable),所以要严格保证顺序,否则在顺序出错的情况下,会报错:

[CAMetalLayerDrawable present] should not be called after already presenting this drawable. Get a nextDrawable instead.

PS:

可能有部分同学对 Swift 里头的 defer 不太熟悉,这里简单解释一下:

defer:译为延缓、推迟。之意类似栈,在当前作用域执行完后,再执行 defer 中的代码。

所以上面的 textureInputSemaphore.signal() 会在 newTextureAvailable 执行完毕后,再调用,保证一次渲染的完整性。

再往下,

if (UInt(inputTextures.count) >= maximumInputs)

这里就提到了 maximumInputs 的第二个作用,容错。确保所有的输入纹理都传递到位,才往下执行对应的渲染操作。

最后的渲染操作,核心就是:

commandBuffer.renderQuad(pipelineState: renderPipelineState, uniformSettings: uniformSettings, inputTextures: inputTextures, outputTexture: outputTexture)
commandBuffer.commit()

updateTargetsWithTexture(outputTexture)

即,通过 renderQuad 完成当前渲染,再通过 updateTargetsWithTexture 把结果输出。

至此,GPUImage 3 中对基础滤镜的封装,我们已经简单分析过了。

但还有比如 TextureSamplingOperation、BlendShaderTypes、OperationGroup 等,我们没有说明,因为这部分其实是功能的扩展,暂时我们不会用到,平时使用的也比较少,就不抽离出来。

比如 OperationGroup,可以方便将若干个 BasicOperation 的实例包装成一个 OperationGroup 操作组,通过给闭包赋值来定义组内滤镜的处理流程,外部可以将 OperationGroup 的实例作为一个独立单位参与其他滤镜处理。如下:

// 给闭包赋值,绑定处理链
operationGroup.configureGroup{input, output in
    input --> brightnessAdjustment --> exposureAdjustment --> output
}

稍微了解一下,就好。


2. 颜色滤镜常见实现方式

在了解完 GPUImage 3 提供的基础封装之后,我们可以在此基础上,实现自定义的滤镜效果。

滤镜效果大致可以分为这么几类:

  • 独立像素点变换,包括亮度、对比、饱和度、色调等
  • 像素卷积变换,包括边缘检测、浮雕化、模糊、锐化等
  • 仿射矩阵变换。包括缩放、旋转、倾斜、扭曲、液化等

简单的颜色滤镜,即独立像素点变换,按照一定规律,修改当前像素点的色值。每个像素点都是独立的,不相互依赖。

PS:
什么叫相互依赖?比如模糊滤镜,当前像素点的最终色值,会根据图片周围几个像素点的色值,结合计算得出。没办法根据固定的算式,直接得到结果值。

而实现这样的颜色滤镜,我们常用的有这么两种方式:shader 和 lookup table。下面逐个进行说明。

2.1 Shader

2.1.1 原理

所谓 Shader,即滤镜的具体实现,都在 Fragment Function 中,用 MSL 来编写。即用具体的算法,来实现滤镜。

2.1.2 如何学习常见效果的 shader 编写?

其实这就是一个把具体算法,用脚本语言描述的过程。归根结底,还是在算法上。
复杂的,还是要推动实验室同学去完成..

至于简单,入门的。我这里推荐一本书,可以帮助大家了解图像处理的一些基础知识。
比如亮度,色调,对比度,饱和度,或者复杂点的浮雕,卡通效果等等。

《Graphics Shaders: Theory and Practice》,有对应的中文译本:《图像着色器:理论和实践》

Shadertoy,构建和分享世界上你最喜欢的着色器并受启发。

这个网站也很赞,是一个 基于 pixel shaders 的 playgrounds,是一个比较活跃的 shader 社区,可以了解下。

2.1.3 饱和度滤镜

We think of color saturation as a description of the “purity” of the color, or how far the color is from gray. This is consistent with the notion of saturation in the HLS color system, where saturation is the distance from the pure grays that are at the center of the HLS double cone. If saturation is reduced, the color is more gray; if it is increased, the color is purer and more vivid.

-----------------《Graphics Shaders: Theory and Practice》 P275

饱和度是指图像颜色的浓度。

  • 饱和度越高,颜色越饱满,即所谓的青翠欲滴的感觉。
  • 饱和度越低,颜色就会显得越陈旧、惨淡。
  • 饱和度为0时,图像就为灰度图像。

在算法实现上,饱和度表示色相中灰色分量所占的比例,它使用从0%(灰色)至100%(完全饱和)的百分比来度量。所以,为了控制图像的饱和度,我们可以创建一个灰度基准图像。

而灰度图,存储的其实是颜色的亮度值(Luminance)信息。

Luminance 亮度,指的是投射在固定方向和面积上的发光强度,发光强度是一个可测量的属性。

所谓可测量,是指在 sRGB 规范中,Luminance 被定义为 RGB 的线形组合,sRGB 中基于亮度值的权值向量为:

// Luminance Constants
constant half3 luminanceWeighting = half3(0.2125, 0.7154, 0.0721);

所以,在 Fragment Function 中,某个片段的像素值 color,对应的亮度为:

half luminance = dot(color.rgb, luminanceWeighting);

PS:

T s dot(T x, T y) :Return the dot product of x and y。

i.e., x[0] * y[0] + x[1] * y[1] + …

内置函数,向量点乘。

然后将色值 color,使用饱和度系数 saturation 和亮度 luminance 进行差值混合,即可。

PS:

T mix(T x, T y, T a):Returns the linear blend of x and y implemented as: x + (y – x ) * a

top Created with Sketch.