如何优雅地实现一个分屏滤镜

本文通过编写一个通用的片段着色器,实现了抖音中的各种分屏滤镜。另外,还讲解了延时动态分屏滤镜的实现。

一、静态分屏

静态分屏指的是,每一个屏的图像都完全一样。

分屏滤镜实现起来比较容易,无非是在片段着色器中,修改纹理坐标和纹理的对应关系。分屏之后,每个屏内纹理的对应关系都不太一样。因此在实现的时候,容易写的很复杂,会有大量的区域判断逻辑。

这样实现出来的着色器拓展性比较差。假如有多种分屏滤镜,就要实现多个着色器,而且屏数越多,区域判断逻辑就越复杂。

所以,我们会采取一种更优雅的方式,为所有的分屏滤镜实现一个通用的着色器,然后将屏数当作参数,由着色器外部控制。

预备知识

首先,我们来了解等一下会使用到的 GLSL 运算和函数。vec2 是二维向量类型,它支持下面的各种运算。

1、向量与向量的加减乘除(两个向量需要保证维数相同)

下面以乘法为例,其他类似。

1
2
vec2 a, b, c;
c = a * b;

等价于

1
2
c.x = a.x * b.x;
c.y = a.y * b.y;

2、向量与标量的加减乘除

下面以加法为例,其他类似。

1
2
3
vec2 a, b;
float c;
b = a + c;

等价于

1
2
b.x = a.x + c;
b.y = a.y + c;

3、向量与向量的 mod 运算(两个向量需要保证维数相同)

1
2
vec2 a, b, c;
c = mod(a, b);

等价于

1
2
c.x = mod(a.x, b.x);
c.y = mod(a.y, b.y);

4、向量与标量的 mod 运算

1
2
3
vec2 a, b;
float c;
b = mod(a, c);

等价于

1
2
b.x = mod(a.x, c);
b.y = mod(a.y, c);

着色器实现

有了上面的 GLSL 运算知识,来看下我们最终实现的片段着色器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
precision highp float;

uniform sampler2D inputImageTexture;
varying vec2 textureCoordinate;

uniform float horizontal; // (1)
uniform float vertical;

void main (void) {
float horizontalCount = max(horizontal, 1.0); // (2)
float verticalCount = max(vertical, 1.0);

float ratio = verticalCount / horizontalCount; // (3)

vec2 originSize = vec2(1.0, 1.0);
vec2 newSize = originSize;

if (ratio > 1.0) {
newSize.y = 1.0 / ratio;
} else {
newSize.x = ratio;
}

vec2 offset = (originSize - newSize) / 2.0; // (4)
vec2 position = offset + mod(textureCoordinate * min(horizontalCount, verticalCount), newSize); // (5)

gl_FragColor = texture2D(inputImageTexture, position); // (6)
}

(1) 我们最终暴露的接口,通过 uniform 变量的形式,从着色器外部传入横向分屏数 horizontal纵向分屏数 vertical

(2) 开始运算前,做了最小分屏数的限制,避免小于 1.0 的分屏数出现。

(3) 从这一行开始,是为了计算分屏之后,每一屏的新尺寸。比如分成 2 : 2,则 newSize 仍然是 (1.0, 1.0),因为每一屏都能显示完整的图像;而分成 3 : 2(横向 3 屏,纵向 2 屏),则 newSize 将会是 (2.0 / 3.0, 1.0),因为每一屏的纵向能显示完整的图像,而横向只能显示 2 / 3 的图像。

(4) 计算新的图像在原始图像中的偏移量。因为我们的图像要居中裁剪,所以要计算出裁剪后的偏移。比如 (2.0 / 3.0, 1.0) 的图像,对应的 offset(1.0 / 6.0, 0.0)

(5) 这一行是这个着色器的精华所在,可能不太好理解。我们将原始的纹理坐标,乘上 horizontalCountverticalCount 的较小者,然后对新的尺寸进行求模运算。这样,当原始纹理坐标在 0 ~ 1 的范围内增长时,可以让新的纹理坐标newSize 的范围内循环多次。另外,计算的结果加上 offset,可以让新的纹理坐标偏移到居中的位置。

下面简单演示一下每一步计算的效果,帮助理解:

(6) 通过新的计算出来的纹理坐标,从纹理中读出相应的颜色值输出。

效果展示

现在,我们得到了一个通用的分屏着色器,像三屏、六屏、九屏这些效果,只需要修改两个参数就可以实现。另外,上面的实现逻辑,甚至可以支持 1.5 : 2.5 这种非整数的分屏操作。

二、动态分屏

动态分屏指的是,每个屏的图像都不一样,每间隔一段时间,会主动捕获一个新的图像。

由于每个屏的图像都不一样,因此在渲染过程中,需要捕获多个不同的纹理。比如我们想要实现一个四屏的滤镜,就需要捕获 4 个不同的纹理。

预备知识

我们知道,在 GPUImage 框架中,滤镜效果的渲染发生在 GPUImageFilter 中。

从渲染层面来说,GPUImageFilter 接收一个纹理的输入,然后经过自身效果的渲染,输出一个新的纹理 。

但实际上,由于渲染过程需要先绑定帧缓存,所以纹理被包装在 GPUImageFramebuffer 中。

因此,在不同的 GPUImageFilter 之间传递的对象其实是 GPUImageFramebuffer。一般的流程是,从 firstInputFramebuffer 中读取纹理,将结果渲染到 outputFramebuffer 的纹理中,然后将 outputFramebuffer 传递给下一个节点。

outputFramebuffer 是需要重新创建的,如果不做额外的缓存处理,在整个滤镜链的渲染中,将需要创建大量的 GPUImageFramebuffer 对象。

因此, GPUImage 框架提供了 GPUImageFramebufferCache 来管理 GPUImageFramebuffer 的重用。当需要创建 outputFramebuffer 的时候,会先从 GPUImageFramebufferCache 中去获取缓存的对象,获取不到才会重新创建。

由于纹理被包装在 GPUImageFramebuffer 中,所以当 GPUImageFramebuffer 被重用时,原先保存的纹理就会被覆盖。

GPUImageFramebuffer 提供了 lockunlock 的操作。 lock 会使引用计数加 1,unlock 会使引用计数减 1,当引用计数为 0 的时候,GPUImageFramebuffer 会被加入到 cache 中,等待被重用。

所以,我们要捕获纹理,做法就是:在拍摄过程中,不让 GPUImageFramebuffer 进入 cache

注: 这里的引用计数不是 OC 层面的引用计数,而是 GPUImageFramebuffer 内部的一个属性,属于业务逻辑层的东西。

代码实现

1、捕获和释放

GPUImageFramebuffer 的捕获和释放都很简单,通过 lockunlock 来实现,

1
2
[firstInputFramebuffer lock];
self.firstFramebuffer = firstInputFramebuffer;
1
2
[self.firstFramebuffer unlock];
self.firstFramebuffer = nil;

2、多纹理的渲染

在捕获了额外的纹理后,需要重写 -renderToTextureWithVertices:textureCoordinates: 方法,在里面传递多个纹理到着色器中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 第一个纹理
if (self.firstFramebuffer) {
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_2D, [self.firstFramebuffer texture]);
glUniform1i(firstTextureUniform, 3);
}

// 第二个纹理
if (self.secondFramebuffer) {
glActiveTexture(GL_TEXTURE4);
glBindTexture(GL_TEXTURE_2D, [self.secondFramebuffer texture]);
glUniform1i(secondTextureUniform, 4);
}

// 第三个纹理
if (self.thirdFramebuffer) {
glActiveTexture(GL_TEXTURE5);
glBindTexture(GL_TEXTURE_2D, [self.thirdFramebuffer texture]);
glUniform1i(thirdTextureUniform, 5);
}

// 第四个纹理
if (self.fourthFramebuffer) {
glActiveTexture(GL_TEXTURE6);
glBindTexture(GL_TEXTURE_2D, [self.fourthFramebuffer texture]);
glUniform1i(fourthTextureUniform, 6);
}

// 传递纹理的数量
glUniform1i(textureCountUniform, (int)self.capturedCount);

同时在着色器中接收并处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
precision highp float;

uniform sampler2D inputImageTexture;

uniform sampler2D inputImageTexture1;
uniform sampler2D inputImageTexture2;
uniform sampler2D inputImageTexture3;
uniform sampler2D inputImageTexture4;

uniform int textureCount;

varying vec2 textureCoordinate;

void main (void) {
vec2 position = mod(textureCoordinate * 2.0, 1.0);

if (textureCoordinate.x <= 0.5 && textureCoordinate.y <= 0.5) { // 左上
gl_FragColor = texture2D(textureCount >= 1 ? inputImageTexture1 : inputImageTexture,
position);
} else if (textureCoordinate.x > 0.5 && textureCoordinate.y <= 0.5) { // 右上
gl_FragColor = texture2D(textureCount >= 2 ? inputImageTexture2 : inputImageTexture,
position);
} else if (textureCoordinate.x <= 0.5 && textureCoordinate.y > 0.5) { // 左下
gl_FragColor = texture2D(textureCount >= 3 ? inputImageTexture3 : inputImageTexture,
position);
} else { // 右下
gl_FragColor = texture2D(textureCount >= 4 ? inputImageTexture4 : inputImageTexture,
position);
}
}

由于这里每个屏接收的纹理都不一样,就不可避免地要添加区域判断逻辑了。

效果展示

最后,看一下延时动态分屏的效果:

源码

请到 GitHub 上查看完整代码。