iOS 针对 LUT 滤镜的实现对比

4,667 阅读17分钟
原文链接: www.jianshu.com

在 app 内利用各种图形算法可以对图片进行一些变换,这样的效果也称为“滤镜”,滤镜效果大致可以分为以下几类:

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

其中最简单的就是进行独立像素点变换,利用 LUT 技术还可以提供给设计师灵活的方式来自定义各种滤镜效果。

1.LUT 技术

1.1 LUT 技术简介

LUT 是 LookUpTable 的简称,也称作颜色查找表技术,它可以分为一维 LUT(1DLUT) 和 三维 LUT(3DLUT)。简单来说,LUT 就是一个 RGB 组合到 RGB 组合的映射,对于一维 LUT,假设映射关系为 LUT1,则

LUT(R1) = R2
LUT(G1) = G2
LUT(B1) = B2

其中 R1、G1、B1 为原像素值,R2、G2、B2 为映射像素值,可以看出 1DLUT 的映射颜色值的每一个分量仅与其原始像素值的分量有关,用图像表示如下

对于 3DLUT,假设其映射关系为 LUT3,则

LUT(R1, G1, B1) = (R2, G2, B2)

3DLUT 相比于 1DLUT 能够实现全立体的色彩空间控制,非常适合用于精确的颜色控制工作,它的示意图如下

可以简单做一个计算,如果 RGB 三个分量分别可以取 256 种值的话,那么 3DLUT 技术就可以包含 256X256X256 种情况,大约占 48MB 空间,这样一个 3DLUT 映射关系的数据量有些庞大,通常会采取采样方式来降低数据量,例如可以对每一个分量按照每 4 个变化值为间距,进行 64 次采样,获得一个 64X64X64 大小的映射关系表,对于不在表内的颜色值进行内插法获得其相似结果。

那么获得了 LUT 映射表以后,如何对任意一张图片进行滤镜变换呢。我们可以遍历图片的像素点,对于每一个像素点,获得其 RGB 组合,在 LUT 表格中查找此 RGB 组合及其对应的 RGB 映射值,然后用 RGB 映射值替换原图的像素点,就可以完成滤镜变换了。

1.2 3DLUT 数据存储方式

3DLUT 是一个三维颜色空间体,通过下面的方式可以将其数据压入一张二维图片中。这里以一张 64X64X64 数据量的 LUT 图为例,它的大小是 512X512

它在横竖方向上分成了 8X8 一共 64 个小方格,每一个小方格内的 B 分量为一个定值,总共就表示了 B 分量的 64 种可能值。同时对于每一个小方格,横竖方向又各自分为 64 个小格,横向小格的 R 分量依次增加,纵向小格的 G 分量依次增加,通过放大图片可以看到如下细节

这样就将所有数据都存储到一张 LUT 图中了,从图中也可以看出色值随着 RGB 分量变化而变化的情况。

上面所展示的 LUT 图是一张特殊的 LUT 图,因为它的映射关系最简单,原始 RGB 颜色是什么,映射 RGB 颜色就是什么,这样的 LUT 图我们可以将其作为 LUT 参照图,设计师将想实现的滤镜效果分别作用于 LUT 参照图上,可以生成 LUT 滤镜图,其可能情况如下图所示

通过对比 LUT 参照图和 LUT 滤镜图,就能获知任何原始 RGB 色值的映射颜色值是多少了。

2. LUT 滤镜变换过程实现

iOS 中与图像处理有关的框架大致有以下几个:CoreImage,Metal,OpenGL-ES,第三方框架 GPUImage 等,它们都可以实现 LUT 映射。下面分点阐述。

2.1 CoreImage

CoreImage 是 iOS5 新加入到 iOS 平台的一个图像处理框架,提供了强大高效的图像处理功能, 用来对基于像素的图像进行操作与分析, 内置了很多强大的滤镜(Filter) (目前数量超过了 180 种)。CoreImage 实现 LUT 有两种方式:

  • CIColorCube 过滤器
  • CIKernel

2.1.1 CIColorCube 过滤器

CIColorCube 接受一个 LUT 映射颜色矩阵作为输入参数,对于输入图片进行色值映射,具体实现如下

  • 获取 LUT 图的 bitmap
+ (unsigned char *)createRGBABitmapFromImage:(CGImageRef)image
{
    CGContextRef context = NULL;
    CGColorSpaceRef colorSpace;
    unsigned char *bitmap;
    NSInteger bitmapSize;
    NSInteger bytesPerRow;
    
    size_t width = CGImageGetWidth(image);
    size_t height = CGImageGetHeight(image);
    
    bytesPerRow = width * 4;
    bitmapSize = bytesPerRow * height;
    
    bitmap = malloc(bitmapSize);
    if (bitmap == NULL) {
        return NULL;
    }
    
    colorSpace = CGColorSpaceCreateDeviceRGB();
    if (colorSpace == NULL) {
        free(bitmap);
        return NULL;
    }
    
    context = CGBitmapContextCreate (bitmap,
                                     width,
                                     height,
                                     8,
                                     bytesPerRow,
                                     colorSpace,
                                     kCGImageAlphaPremultipliedLast);
    CGColorSpaceRelease(colorSpace);
    if (context == NULL) {
        free(bitmap);
    }
    
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), image);
    CGContextRelease(context);
    return bitmap;
}
  • 生成 CIColorCube 所需的 inputCubeData
+ (CIFilter *)filterWithLUTImage:(UIImage *)image dimension:(NSInteger)n
{
    NSInteger width = CGImageGetWidth(image.CGImage);
    NSInteger height = CGImageGetHeight(image.CGImage);
    NSInteger row = height/n;
    NSInteger column = width/n;
    
    if ((width % n != 0) || (height % n != 0) || (row * column != n)) {return nil;}
    
    unsigned char *bitmap = [self createRGBABitmapFromImage:image.CGImage];
    
    if (!bitmap) {return nil;}
    
    NSInteger z = 0;
    NSUInteger size = n * n * n * sizeof(float) * 4; // 所有像素点的 rgba 值个数 64 * 64 * 64 * 4
    float *data = malloc(size); // 存储空间
    NSInteger bitmapOffest = 0;
    
    for (NSInteger rowIndex = 0; rowIndex < row; rowIndex++) {
        for (NSInteger y = 0; y < n; y++) {
            NSInteger originalZ = z;
            for (NSInteger columnIndex = 0; columnIndex < column; columnIndex++) {
                for (NSInteger x = 0; x < n; x++) {
                    double r = (unsigned int)bitmap[bitmapOffest];
                    double g = (unsigned int)bitmap[bitmapOffest + 1];
                    double b = (unsigned int)bitmap[bitmapOffest + 2];
                    double a = (unsigned int)bitmap[bitmapOffest + 3];
                    NSInteger dataOffset = (z * n * n + y * n + x) * 4; // 在大存储空间中的偏移,z 从 0 开始,z 偏移一个,总共偏移 64 * 64 个点,y 偏移一个,总共偏移 64 个点,加上 x 个点,乘以 rgba 的 4 个点
                    // 存储值
                    data[dataOffset] = r / 255.0;
                    data[dataOffset + 1] = g / 255.0;
                    data[dataOffset + 2] = b / 255.0;
                    data[dataOffset + 3] = a / 255.0;
                    
                    bitmapOffest += 4; // 偏移 4 个点
                }
                z++;
            }
            z = originalZ; // 每一行遍历完,z 恢复到行头所属块的 index
        }
        z += column;
    }
    
    free(bitmap); // 释放位图
    
    CIFilter *filter = [CIFilter filterWithName:@"CIColorCube"];
    [filter setValue:[NSData dataWithBytesNoCopy:data length:size freeWhenDone:YES] forKey:@"inputCubeData"];
    [filter setValue:[NSNumber numberWithInteger:n] forKey:@"inputCubeDimension"];
    
    return filter;
}

这里读取的时候就是按照 3DLUT 存储方式来读取到一个存储空间里的。

  • 封装并使用

将上述过程封装为一个 Category,传入原图,得到处理后的图片

    CIImage *image = [[CIImage alloc] initWithImage:self.mediaAsset.assetImage];
    CIFilter *filter = [CIFilter filterWithLUTImage:[UIImage imageNamed:@"lookup_yth002"] dimension:64];
    [filter setValue:image forKey:@"inputImage"];
    CIImage *outputImage = [filter outputImage];
    CIContext *context = [CIContext contextWithOptions:nil];
    CGImageRef cgImage = [context createCGImage:outputImage fromRect:[outputImage extent]];
    UIImage *result = [UIImage imageWithCGImage:cgImage];
    CGImageRelease(cgImage);

下面以一个具体图片进行效果对比

我们用下面这张图作为原始待处理的图片

通过 PS 进行一系列处理后的目标效果图如下

对 LUT 参照图进行相同处理后得到 LUT 滤镜图

拿着 LUT 通过 CIColorCube 过滤器得到效果图如下

可以看到效果不是太理想,因此我换了一种方式进行转换。

2.2 CIKernel

CoreImage 除了自带的很多 Filter,还支持用户自定义 Filter,这需要用到 CIKernel 脚本,它使用 Core Image Kernel Language (CIKL) 语言来编写,CIKL 语言是 OpenGL Shading Language (GLSL) 的子集,因此很多语法、变量都和 GLSL 类似。

这里我们编写一个 CIKernel 脚本,传入原图和 LUT 图,并进行颜色映射

kernel vec4 YasicLUT(sampler inputImage, sampler inputLUT, float intensity) {
    // sample 函数从图片的指定坐标提取颜色值 rgba
    vec4 textureColor = sample(inputImage,samplerCoord(inputImage));
    // clamp 函数对 rgba 进行归一化,转化为 0 到 1.0 之间的值
    textureColor = clamp(textureColor, vec4(0.0), vec4(1.0));
    // 获取 b 值,从而确定在 LUT 图中的大方格下标
    float blueColor = textureColor.b * 63.0;

    // 取下边界方格和上边界方格,下边界指与此 b 分量最接近的下边界方格,如 b 分量为 2,则下边界方格 为 0 方格,上边界方格为 1 方格 
    highp vec2 quad1;
    quad1.y = floor(floor(blueColor) / 8.0);
    quad1.x = floor(blueColor) - (quad1.y * 8.0);
    highp vec2 quad2;
    quad2.y = floor(ceil(blueColor) / 7.999);
    quad2.x = ceil(blueColor) - (quad2.y * 8.0);

    // 从下边界大方格中,获取对应的小方格坐标,通过 r 值确定横坐标,通过 g 值确定纵坐标
    // 这里进行的乘法操作是为了将坐标进行归一化,也就是都除了 LUT 图宽 512,同时由于所求坐标值必须是每个像素格的中心位置,所以进行了 0.5 像素偏移和 1 像素偏移,它的效果是,如果 r 或者 g 分量为 0,则刚好向右偏移 0.5 像素,不为 0 则向左偏移 0.5 像素,从而保证取到正确的像素格。
    highp vec2 texPos1;
    texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);

    highp vec2 texPos2;
    texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);

    // 取上下边界对应像素值
    vec4 newColor1 = sample(inputLUT, texPos1);
    vec4 newColor2 = sample(inputLUT, texPos2);
    // mix 方法根据 b 分量进行两个像素值的混合
    vec4 newColor = mix(newColor1, newColor2, fract(blueColor));
    return mix(textureColor, vec4(newColor.rgb, textureColor.a), intensity);
}

将此 kernel 文件读取到内存中,生成对应的 CIKernel 对象

    NSString *kernelString = [[NSString alloc] initWithContentsOfURL:[[NSBundle bundleForClass:self.class] URLForResource:@"YasicLUT" withExtension:@"cikernel"] encoding:NSUTF8StringEncoding error:nil];
    CIKernel *kernel = [CIKernel kernelWithString:kernelString];

然后对图片进行变换

    self.resultImageView.image = [UIImage imageWithCIImage:[kernel applyWithExtent:ciTargetImage.extent roiCallback:^CGRect(int index, CGRect destRect) {
        if (index == 0) {
            // 原图
            return destRect;
        } else {
            // LUT 图
            return ciLUTImage.extent;
        }
    } arguments:@[ciTargetImage, ciLUTImage], @(1.0)]]];

这里用到了一个重要函数

- (nullable CIImage *)applyWithExtent:(CGRect)extent
                          roiCallback:(CIKernelROICallback)callback
                            arguments:(nullable NSArray<id> *)args;

它接受四个参数

  • extent,表示当前 Filter 处理的图片区域,此处就是待处理图片的区域
  • callBack,需要返回 ROI,即在一定的时间内特别感兴趣的区域,这里根据 index 值,如果 index 为 0,表示是原图,就将传入的 destRect 直接返回即可,如果 index 为 1,表示 LUT 图,则需要将整个 LUT 图的区域都返回,因为我们并不能根据原图待处理区域确定 LUT 图对应的感兴趣区域
  • arguments,CIKernel 脚本所需参数,根据我们前面写的 CIKernel 脚本,这里依次传入原图和 LUT 图即可。

下面是 CIKernel 处理的效果图

效果也不是很理想,猜测可能是 CIImage 内部对图片进行了一些处理,导致 LUT 映射出现偏差,我们用 GPUImage 库验证 LUT 技术的可行性。

2.3 GPUImage

GPUImage 是一个非常强大的开源图像处理库,基于 GPU 进行图像处理,性能相比 CoreImage 要好很多。GPUImage 本身提供一个 GPUImageLookupFilter 滤镜,只需要传入 LUT 图即可实现 LUT 映射效果,使用如下

- (UIImage *)generateImage:(UIImage *)inputImage
{
    UIImage *outputImage;
    GPUImagePicture *stillImageSource = [[GPUImagePicture alloc] initWithImage:inputImage];    
    //添加滤镜
    GPUImageLookupFilter *lookUpFilter = [[GPUImageLookupFilter alloc] init];
    GPUImagePicture *lookupImg = [[GPUImagePicture alloc] initWithImage:[UIImage imageNamed:@"lookup_freeze"]];    
    [lookupImg addTarget:lookUpFilter atTextureLocation:1];    
    [stillImageSource addTarget:lookUpFilter atTextureLocation:0];    
    [lookUpFilter useNextFrameForImageCapture];    
    if([lookupImg processImageWithCompletionHandler:nil] && [stillImageSource processImageWithCompletionHandler:nil]) {
        outputImage = [lookUpFilter imageFromCurrentFramebuffer];
    }
    return outputImage;
}

最终 GPUImage 的处理效果图如下

可以看到跟 PS 处理的目标效果图非常接近了,证明用 LUT 技术实现滤镜效果是可行的,接下来可以用 OpenGL-ES 实现这一效果。

2.4 OpenGL-ES

OpenGL-ES 的基础知识网上有很多,这里列举一些我觉得写得不错的教程和博客,下面就不再赘述一些技术性的概念和函数

iOS 原生支持 OpenGL-ES,OpenGL-ES 利用图形渲染管线(Graphic Pipeline)将原始图像数据经过变换处理后展示到屏幕上,其具体的流程如下

其中顶点着色器(VertexShader)和片段着色器(FragmentShader)是可编程部分,也是主要开发部分,它们就是用前面提到的 GLSL 语法实现的。简单来说,我们需要向 OpenGL-ES 输入一系列的顶点,它们标识了某一帧的边界,以及每一个顶点上的色值或者纹理坐标值(用于寻找对应的纹理贴图),OpenGL-ES 执行顶点着色器处理顶点数据,然后将顶点间划分成一个个片段,并行地进行片段着色,也就是执行片段着色器,最终形成完整的图形数据。

OpenGL-ES 将图形数据定义为帧缓存(frameBuffer),它类似于一个指针,而真正保存像素值等具体色值的对象是渲染缓存(renderBuffer),帧缓存则保存并维护了渲染缓存的索引。通过 OpenGL-ES 绘制一帧到屏幕的流程如下

  • 设置 EAGLContext
  • 设置 RenderBuffer
  • 设置 FrameBuffer
  • 设置 ViewPort
  • 编译 Shader
  • 传入顶点数据
  • 绘制到屏幕

但是这里我们最终需要获取 OpenGL-ES 生成的图片,而不是将其渲染到屏幕上,所以流程略有不同

  • 设置 EAGLContext
  • 创建离屏帧缓存
  • 设置 ViewPort
  • 编译 Shader
  • 传入顶点数据和纹理数据
  • 绘制到帧缓存
  • 获取渲染图片

具体代码如下

2.4.1 设置 EAGLContext

需要确定使用的 OpenGL-ES 版本,这里选择 OpenGL-ES2.0 API,同时设置 CAEAGLLayer 对象作为绘制对象,它的作用有两个,其一是为渲染缓存分配共享存储,其二是将渲染缓存区呈现给 CoreAnimation,用 renderBuffer 的数据替换之前的内容,相当于是承接 RenderBuffer 和上层 UI 的抽象层。这里还设置了 kEAGLDrawablePropertyRetainedBacking 为 YES,表示每次显示时必须完全重绘,不保存上次显示的内容。

        self.eagLayer = (CAEAGLLayer *)self.layer;
        self.eagLayer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking: @YES,
                                          kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8};
        self.eaglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];

2.4.2 创建离屏帧缓存

- (void)createOffScreenFrameBuffer:(UIImage *)image
{
    glGenFramebuffers(1, &_offscreenFrameBuffer);
    glBindFramebuffer(GL_FRAMEBUFFER, _offscreenFrameBuffer);
    
    GLuint texture;
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.size.width, image.size.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
    
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
    
    GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
    if(status != GL_FRAMEBUFFER_COMPLETE) {
        printf("failed to make complete framebuffer object %x", status);
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    glBindTexture(GL_TEXTURE_2D, 0);
}

这里并未将待渲染的图片纹理直接索引到帧缓存中,因为图片尚未处理过,所以只是开辟足够大的空间就可以了。

2.4.3 设置 ViewPort

ViewPort 能告诉 OpenGL-ES 应该把渲染之后的图形绘制在渲染窗体(如 CAEAGLLayer)的哪一部分

glViewport(0, 0, self.targetImage.size.width, self.targetImage.size.height);

2.4.4 编译 Shader

GLSL 编写的顶点着色器和片段着色器都需要在运行时读取到内存中进行编译,这里将它们存储在 Bundle 中

attribute vec4 position;
attribute vec2 a_texCoordIn;
varying vec2 v_TexCoordOut;

void main(void) {
    v_TexCoordOut = a_texCoordIn;
    gl_Position = position;
}

顶点着色器传入顶点坐标后直接赋值给内部参数 gl_Position,纹理坐标传输给片段着色器用于采样纹理。

precision mediump float;

varying vec2 v_TexCoordOut;
uniform sampler2D inputImageTexture;
uniform sampler2D inputImageTexture2; // lookup texture

void main()
{
    vec4 textureColor = texture2D(inputImageTexture, v_TexCoordOut);
    
    float blueColor = textureColor.b * 63.0;
    
    vec2 quad1;
    quad1.y = floor(floor(blueColor) / 8.0);
    quad1.x = floor(blueColor) - (quad1.y * 8.0);
    
    vec2 quad2;
    quad2.y = floor(ceil(blueColor) / 8.0);
    quad2.x = ceil(blueColor) - (quad2.y * 8.0);
    
    vec2 texPos1;
    texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
    
    vec2 texPos2;
    texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
    
    vec4 newColor1 = texture2D(inputImageTexture2, texPos1);
    vec4 newColor2 = texture2D(inputImageTexture2, texPos2);
    
    vec4 newColor = mix(newColor1, newColor2, fract(blueColor));
    gl_FragColor = mix(textureColor, vec4(newColor.rgb, textureColor.w), 1.0);
}

片段着色器与之前的 CIKernel 脚本类似,根据原图的 rgb 值,从 LUT 纹理中提取对应位置的色值,混合后赋值给内部参数 gl_FragColor。

然后进行着色器编译

{
    GLuint vertexShader = [self compileShader:@"MyTestVertexShader.vsh" withType:GL_VERTEX_SHADER];
    GLuint fragmentShader = [self compileShader:@"MyTestFragmentShader.fsh" withType:GL_FRAGMENT_SHADER];
    self.programHandle = glCreateProgram();
    glAttachShader(self.programHandle, vertexShader);
    glAttachShader(self.programHandle, fragmentShader);
    glLinkProgram(self.programHandle);
    GLint linkSuccess;
    glGetProgramiv(self.programHandle, GL_LINK_STATUS, &linkSuccess);
    if (linkSuccess == GL_FALSE) {
        GLchar messages[256];
        glGetProgramInfoLog(self.programHandle, sizeof(messages), 0, &messages[0]);
        NSLog(@"GLGetProgramInfo error:%@", [NSString stringWithUTF8String:messages]);
        exit(1);
    }
    glUseProgram(self.programHandle);
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
}

- (GLuint)compileShader:(NSString *)shaderName withType:(GLenum)shaderType {
    NSString *shaderPath = [[NSBundle mainBundle] pathForResource:shaderName ofType:nil];
    NSError *error = nil;
    NSString *shaderString = [NSString stringWithContentsOfFile:shaderPath encoding:NSUTF8StringEncoding error:&error];
    if (!shaderString) {
        exit(1);
    }
    
    const char* shaderStringUTF8 = [shaderString UTF8String];
    int shaderStringLength = (int)[shaderString length];
    
    GLuint shaderHandle = glCreateShader(shaderType);
    
    glShaderSource(shaderHandle, 1, &shaderStringUTF8, &shaderStringLength);
    glCompileShader(shaderHandle);
    
    GLint compileSuccess;
    glGetShaderiv(shaderHandle, GL_COMPILE_STATUS, &compileSuccess);
    if (compileSuccess == GL_FALSE) {
        GLchar messages[256];
        glGetShaderInfoLog(shaderHandle, sizeof(messages), 0, &messages[0]);
        NSString *messageString = [NSString stringWithUTF8String:messages];
        NSLog(@"GLGetShaderiv error:%@", messageString);
        exit(1);
    }
    
    return shaderHandle;
}

2.4.5 传入顶点和纹理数据

OpenGL-ES 坐标系是三维空间坐标系,按照右手法则,以屏幕中心为原点,横向为 X 轴,竖向为 Y 轴,纵向为 Z 轴,进行了归一化,所以订单数据如下

    const GLfloat vertices[] = {
        -1, -1, 0,   //左下
        1,  -1, 0,   //右下
        -1, 1,  0,   //左上
        1,  1,  0 }; //右上
    glEnableVertexAttribArray(glGetAttribLocation(self.programHandle, "position"));
    glVertexAttribPointer(glGetAttribLocation(self.programHandle, "position"), 3, GL_FLOAT, GL_FALSE, 0, vertices);

纹理数据与之对应,但是纹理数据是二维的,并且变化范围是 0 到 1。

    static const GLfloat coords[] = {
        0, 0,
        1, 0,
        0, 1,
        1, 1
    };

    glVertexAttribPointer(glGetAttribLocation(self.programHandle, "a_texCoordIn"), 2, GL_FLOAT, GL_FALSE, 0, coords);
    glEnableVertexAttribArray(glGetAttribLocation(self.programHandle, "a_texCoordIn"));

接下来还需要将待处理的图片和 LUT 转换为 2D 纹理

- (GLuint)getTextureFromImage:(UIImage *)image needTranslate:(BOOL)need {
    CGImageRef imageRef = [image CGImage];
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    CGRect rect = CGRectMake(0, 0, width, height);
    GLubyte *textureData = (GLubyte *)malloc(width * height * 4);
    
    CGColorSpaceRef colorSpaceRef = CGColorSpaceCreateDeviceRGB();
    NSUInteger bytesPerPixel = 4;
    NSUInteger bytesPerRow = bytesPerPixel * width;
    NSUInteger bitsPerComponent = 8;
    CGContextRef context = CGBitmapContextCreate(textureData, width, height, bitsPerComponent, bytesPerRow, colorSpaceRef, kCGImageAlphaPremultipliedLast|kCGBitmapByteOrder32Big);
    
    if (need) {
        CGContextTranslateCTM(context, 0, height);
        CGContextScaleCTM(context, 1.0f, -1.0f);
    }

    CGContextClearRect(context, rect);
    CGContextDrawImage(context, rect, imageRef);
    
    glEnable(GL_TEXTURE_2D);
    
    GLuint texureName;
    glGenTextures(1, &texureName);
    glBindTexture(GL_TEXTURE_2D, texureName);
    
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, (GLsizei)width, (GLsizei)height, 0, GL_RGBA, GL_UNSIGNED_BYTE, textureData);
    glBindTexture(GL_TEXTURE_2D, 0); //解绑
    CGContextRelease(context);
    CGColorSpaceRelease(colorSpaceRef);
    free(textureData);
    return texureName;
}

根据坐标系来看纹理图片从 UIKit 读取到 OpenGL 是需要进行上下翻转的,但是经过我实际使用发现,LUT 图不能进行翻转,具体原因不明,所以这里进行了翻转操作的 BOOL 控制

    glActiveTexture(GL_TEXTURE0);
    GLuint texName = [self getTextureFromImage:self.targetImage needTranslate:YES];
    glBindTexture(GL_TEXTURE_2D, texName);
    glUniform1i(glGetUniformLocation(self.programHandle, "inputImageTexture"), 0);
    
    glActiveTexture(GL_TEXTURE1);
    GLuint LUTName = [self getTextureFromImage:[UIImage imageNamed:@"lookup_yth002"] needTranslate:NO];
    glBindTexture(GL_TEXTURE_2D, LUTName);
    glUniform1i(glGetUniformLocation(self.programHandle, "inputImageTexture2"), 1);

将两个纹理数据分别绑定到索引 0 和 1所属的纹理单元上,并传输给 shader 作为参数。

2.4.6 绘制到帧缓存

    glBindFramebuffer(GL_FRAMEBUFFER, _offscreenFrameBuffer);    
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

2.4.7 获取渲染图片

- (void)getImageFromBuffe:(int)width withHeight:(int)height {
    GLint x = 0, y = 0;
    NSInteger dataLength = width * height * 4;
    GLubyte *data = (GLubyte*)malloc(dataLength * sizeof(GLubyte));
    
    glPixelStorei(GL_PACK_ALIGNMENT, 4);
    glReadPixels(x, y, width, height, GL_RGBA, GL_UNSIGNED_BYTE, data);
    
    CGDataProviderRef ref = CGDataProviderCreateWithData(NULL, data, dataLength, NULL);
    CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB();
    CGImageRef iref = CGImageCreate(width, height, 8, 32, width * 4, colorspace, kCGBitmapByteOrder32Big | kCGImageAlphaPremultipliedLast,
                                    ref, NULL, true, kCGRenderingIntentDefault);
    
    
    UIGraphicsBeginImageContext(CGSizeMake(width, height));
    CGContextRef cgcontext = UIGraphicsGetCurrentContext();
    CGContextSetBlendMode(cgcontext, kCGBlendModeCopy);
    CGContextDrawImage(cgcontext, CGRectMake(0.0, 0.0, width, height), iref);
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    self.targetImageView.image = image;
    UIGraphicsEndImageContext();
    
    free(data);
    CFRelease(ref);
    CFRelease(colorspace);
    CGImageRelease(iref);
}

最终效果图如下所示

可以看到 OpenGL 处理的效果很理想。

2.5 CPU 像素替换与 OpenGL-ES 性能对比

其实考虑到 LUT 技术的原理就可以想到,对于图片的每一个像素进行查找替换的操作完全可以在 CPU 内存中就能完成,我们通过 CGContextDrawImage 方法获取到原始图片和 LUT 滤镜图的 bitmap,之后通过遍历原始图的 bitmap,根据每一个像素点的 RGB 值查找 LUT 图中对应的像素值,生成一个新的 bitmap,转换为图片,就是一次 LUT 转换。

具体代码如下

- (void)bitmapTransition:(UIImage *)originalImage LUTImage:(UIImage *)LUTImage
{
    NSInteger width = CGImageGetWidth(originalImage.CGImage);
    NSInteger height = CGImageGetHeight(originalImage.CGImage);
    
    unsigned char *originalBitmap = [self createRGBABitmapFromImage:originalImage.CGImage];
    unsigned char *LUTBitmap = [self createRGBABitmapFromImage:LUTImage.CGImage];
    unsigned char *finalBitmap = malloc(width * height * 4);
    
    for (int row = 0; row < height; row++) {
        for (int column = 0; column < width; column++) {
            double r = (double)originalBitmap[row * width * 4 + column * 4];
            double g = (double)originalBitmap[row * width * 4 + column * 4 + 1];
            double b = (double)originalBitmap[row * width * 4 + column * 4 + 2];
            double a = (double)originalBitmap[row * width * 4 + column * 4 + 3];
            
            double ro = r/255.0;
            double go = g/255.0;
            double bo = b/255.0;
            double ao = a/255.0;
            
            double bIndex = bo * 63.0;
            
            double y1 = floor(floor(bIndex) / 8.0);
            double x1 = floor(bIndex) - (y1 * 8.0);
            
            double y2 = floor(ceil(bIndex) / 7.99);
            double x2 = ceil(bIndex) - (y2 * 8.0);
            
            int xl1 = (x1 * 64.0 + 0.5 + ro * 63.0);
            int yl1 = (y1 * 64.0 + 0.5 + go * 63.0);
            
            int xl2 = (x2 * 64.0 + 0.5 + ro * 63.0);
            int yl2 = (y2 * 64.0 + 0.5 + go * 63.0);
            
            double rf1 = (double)LUTBitmap[yl1 * 512 * 4 + xl1 * 4];
            double gf1 = (double)LUTBitmap[yl1 * 512 * 4+ xl1 * 4 + 1];
            double bf1 = (double)LUTBitmap[yl1 * 512 * 4 + xl1 * 4 + 2];
            
            double rf2 = (double)LUTBitmap[yl2 * 512 * 4 + xl2 * 4];
            double gf2 = (double)LUTBitmap[yl2 * 512 * 4 + xl2 * 4 + 1];
            double bf2 = (double)LUTBitmap[yl2 * 512 * 4 + xl2 * 4 + 2];
            
            double rf0 = rf1 * (1.0 - bo) + rf2 * bo;
            double gf0 = gf1 * (1.0 - bo) + gf2 * bo;
            double bf0 = bf1 * (1.0 - bo) + bf2 * bo;
            
            finalBitmap[row * width * 4 + column * 4] = rf0;
            finalBitmap[row * width * 4 + column * 4 + 1] = gf0;
            finalBitmap[row * width * 4 + column * 4 + 2] = bf0;
            finalBitmap[row * width * 4 + column * 4 + 3] = a;
        }
    }
    
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGContextRef context = CGBitmapContextCreate(finalBitmap,
                                                 width,
                                                 height,
                                                 8,
                                                 width * 4,
                                                 colorSpace,
                                                 kCGImageAlphaPremultipliedLast);
    CGImageRef imageRef = CGBitmapContextCreateImage(context);
    CGContextRelease(context);
    CGColorSpaceRelease(colorSpace);
    UIImage *image = [UIImage imageWithCGImage:imageRef];
    CGImageRelease(imageRef);
    
}

这里 createRGBABitmapFromImage 方法在开头就提到了,下面的替换操作也是将之前的着色器代码进行了改写,基本思路是相同的,其最终效果图如下

可以看到效果和 OpenGL-ES 非常接近,也是很理想的处理效果。但是 CPU 处理像素相比于 GPU 的并行计算性能是很糟糕的,这里以几张不同尺寸的图片,在 iPhone 8 Plus 上对两种方式进行了测试,获得性能比较结果如下。

这样的耗时差距在处理一张图片时尚可接受,但是在相机捕获时实时渲染每一帧图片的时候,就会有显著的性能差别,尤其是 iPhone 8 Plus 相机捕获的每一帧大小几乎都是最后几种情况那么大(4032x3024),因此很有必要采用 OpenGL-ES 实现 LUT 滤镜效果。