美文网首页精选集iOSiOS开发技术分享
iOS 利用 Metal 实现 LUT 滤镜

iOS 利用 Metal 实现 LUT 滤镜

作者: Yasic | 来源:发表于2018-09-26 11:15 被阅读151次

    源码地址:MetalCode

    之前探究过 iOS 上通过 CoreImage、OpenGLES 等技术实现 LUT 滤镜的对比 -- iOS 针对 LUT 滤镜的实现对比,但是其实在图形处理这块,Apple 更推崇自家公司的 Metal,这是一个和 OpenGLES 类似的面向底层的图形编程接口,最早在 2014 年的 WWDC 的时候发布,可用于从 CPU 发送指令到 GPU 驱动 GPU 进行大量并行矩阵运算。

    Metal 提供以下特性:

    • 低开销接口。Metal 被设计用于消灭像状态检查一类的隐性性能瓶颈,你可以控制 GPU 的异步行为,以实现用于并行创建和提交命令缓冲区的高效多线程操作
    • 内存和资源管理。Metal 框架提供了表示 GPU 内存分配的缓冲区和纹理对象,纹理对象具有确切的像素格式,能被用于纹理图像或附件
    • 集成对图形和计算操作的支持。Metal 对图形操作和计算操作使用了相同的数据结构和资源(如 buffer、texture、command queue),Metal 的着色器语言同时支持图形函数和计算函数,Metal 框架支持在运行时接口(CPU)、图形着色器和计算方法间共享资源
    • 预编译着色器。Metal 的着色器函数能与代码一同在编译器编译,并在运行时加载,这样的流程能提供更方便的着色器调试功能。

    Metal 的对象关系如图所示

    image

    其中连接 CPU 和 GPU 的就是命令队列 Command Queue,其上装载多个命令缓冲 Command Buffer,Command Buffer 里能承载 Metal 定义的多种图形、计算命令编码器,在编码器中就是开发者创建的实际的命令和资源,它们最终被传送到 GPU 中进行计算和渲染。

    接下来就用 Metal 实现一个图片 LUT 滤镜。

    1. 初始化

    Metal 初始化工作主要将一些初始化开销大、能够复用的对象进行预先生成和持有.

    首先初始化 GPU 接口,可以理解为持有 GPU,在 Metal 中它被定义为 MTLDevice 类型的对象

        self.mtlDevice = MTLCreateSystemDefaultDevice(); // 获取 GPU 接口
    

    其次初始化一个 MTKView,它相当于画布,用于 GPU 渲染内容到屏幕上

        [self.view addSubview:self.mtlView];
        [self.mtlView mas_makeConstraints:^(MASConstraintMaker *make) {
            make.left.top.equalTo(self.view);
            make.width.height.equalTo(self.view);
        }];
    

    Metal 的渲染流程与 OpenGLES 很类似,大致如下图所示

    因此同样的,需要传入顶点数据、顶点着色器和片段着色器

    顶点数据定义如下,每一行的前四个分量为顶点坐标,后两个分量为纹理坐标(归一化)。

    static const float vertexArrayData[] = {
        -1.0, -1.0, 0.0, 1.0, 0, 1,
        -1.0, 1.0, 0.0, 1.0, 0, 0,
        1.0, -1.0, 0.0, 1.0, 1, 1,
        -1.0, 1.0, 0.0, 1.0, 0, 0,
        1.0, 1.0, 0.0, 1.0, 1, 0,
        1.0, -1.0, 0.0, 1.0, 1, 1
    };
    

    然后加载到顶点 buffer 中

        self.vertexBuffer = [self.mtlDevice newBufferWithBytes:vertexArrayData length:sizeof(vertexArrayData) options:0]; // 利用数组初始化一个顶点缓存,MTLResourceStorageModeShared 资源存储在CPU和GPU都可访问的系统存储器中
    

    Metal 搜索顶点着色器和片段着色器的范围是以 Bundle 为维度的,在一个 Bundle 内放进任意名称的 Metal 文件,其中的着色器函数都可以被 Metal 搜索并加载到内存中。

        id<MTLLibrary> library = [self.mtlDevice newDefaultLibraryWithBundle:[NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"XXXXXX" ofType:@"bundle"]] error:nil];
        id<MTLFunction> vertextFunc = [library newFunctionWithName:@"vertex_func"];
        id<MTLFunction> fragFunc = [library newFunctionWithName:@"fragment_func"]; //从 bundle 中获取顶点着色器和片段着色器
    

    接下来要将着色器函数装配到渲染管线上,需要用到 MTLRenderPipelineDescriptor 对象

        MTLRenderPipelineDescriptor *pipelineDescriptor = [MTLRenderPipelineDescriptor new];
        pipelineDescriptor.vertexFunction = vertextFunc;
        pipelineDescriptor.fragmentFunction = fragFunc;
        pipelineDescriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm; //此设置配置像素格式,以便通过渲染管线的所有内容都符合相同的颜色分量顺序(在本例中为Blue(蓝色),Green(绿色),Red(红色),Alpha(阿尔法))以及尺寸(在这种情况下,8-bit(8位)颜色值变为 从0到255)
        self.pipelineState = [self.mtlDevice newRenderPipelineStateWithDescriptor:pipelineDescriptor error:nil]; // 初始化一个渲染管线状态描述位,相当于 CPU 和 GPU 之间建立的管道
    

    最后是初始化渲染队列,以及创建纹理缓存

        self.commandQueue = [self.mtlDevice newCommandQueue]; // 获取一个渲染队列,其中装载需要渲染的指令 MTLCommandBuffer    
        CVMetalTextureCacheCreate(NULL, NULL, self.mtlDevice, NULL, &_textureCache); // 创建纹理缓存
    

    2. 图片纹理加载

    Metal 为加载图片纹理提供了便捷类 MTKTextureLoader,能够根据多种参数生成 MTLTexture 纹理对象,但是实际使用中发现了两个问题:

    • 问题1:BGRA 问题

    Metal 的渲染视图 MTKView 默认支持的 pixelFormat 是 MTLPixelFormatBGRA8Unorm,而说明文档上说 MTKView 还支持以下格式

    The color pixel format for the current drawable's texture.
    The pixel format for a MetalKit view must be MTLPixelFormatBGRA8Unorm, MTLPixelFormatBGRA8Unorm_sRGB, MTLPixelFormatRGBA16Float, MTLPixelFormatBGRA10_XR, or MTLPixelFormatBGRA10_XR_sRGB.

    但是我尝试设置为其他值时都发生了 crash,所以整个渲染流程、命令编码过程中都需要设置 pixelFormat 为 BGRA 格式,这样遇到的问题就是针对一些内部像素排列顺序是 RGBA 格式的图片,生成的纹理和最终渲染出来的图片会发蓝,为了确保传入的图片都是 BGRA 格式的图片,我预先将传入的图片按 BGRA 渲染到 CGContext 上,再提取出 UIImage 对象传入

    - (unsigned char *)bitmapFromImage:(UIImage *)targetImage
    {
        CGImageRef imageRef = targetImage.CGImage;
        
        NSUInteger iWidth = CGImageGetWidth(imageRef);
        NSUInteger iHeight = CGImageGetHeight(imageRef);
        NSUInteger iBytesPerPixel = 4;
        NSUInteger iBytesPerRow = iBytesPerPixel * iWidth;
        NSUInteger iBitsPerComponent = 8;
        unsigned char *imageBytes = malloc(iWidth * iHeight * iBytesPerPixel);
        
        CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB();
        
        CGContextRef context = CGBitmapContextCreate(imageBytes,
                                                     iWidth,
                                                     iHeight,
                                                     iBitsPerComponent,
                                                     iBytesPerRow,
                                                     colorspace,
                                                     kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst); // 转 BGRA 格式
        
        CGRect rect = CGRectMake(0, 0, iWidth, iHeight);
        CGContextDrawImage(context, rect, imageRef);
        CGColorSpaceRelease(colorspace);
        CGContextRelease(context);
        return imageBytes;
    }
    
    - (NSData *)imageDataFromBitmap:(unsigned char *)imageBytes imageSize:(CGSize)imageSize
    {
        CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
        CGContextRef context = CGBitmapContextCreate(imageBytes,
                                                     imageSize.width,
                                                     imageSize.height,
                                                     8,
                                                     imageSize.width * 4,
                                                     colorSpace,
                                                     kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);
        CGImageRef imageRef = CGBitmapContextCreateImage(context);
        CGContextRelease(context);
        CGColorSpaceRelease(colorSpace);
        
        UIImage *result = [UIImage imageWithCGImage:imageRef];
        NSData *imageData = UIImagePNGRepresentation(result);
        CGImageRelease(imageRef);
        return imageData;
    }
    
    • 问题2:sRGB 问题

    这个问题的表现现象是最终渲染出来的图片偏暗,即使是原图也会偏暗,StackOverflow 上有很多对这个 strange color 问题的回答,均提到将 MTKTextureLoader 的 MTKTextureLoaderOptionSRGB 选项设置为 NO,它默认为 YES。

    我的理解是,sRGB 实际上是一种对颜色的编码,其效果是增加暗色域的编码精度,降低亮色域的编码精度。那么针对 sRGB 编码的图片就需要进行一次 gamma 校正,以确保进行诸如 LUT 对照计算时能够严格按照线性 RGB 计算。但是实际上我传入的图片都是以 RGB 格式排列,因此不需要进行 gamma 校正,如果不设置关闭 sRGB 的校正,就会对线性 RGB 格式的数据进行校正,导致最终图片偏暗。这个问题在我做 CoreImage 的滤镜调研时也出现过,下面是效果图

    • 最终代码

    生成 LUT 纹理代码

        unsigned char *imageBytes = [self bitmapFromImage:lutImage];
        NSData *imageData = [self imageDataFromBitmap:imageBytes imageSize:CGSizeMake(CGImageGetWidth(lutImage.CGImage), CGImageGetHeight(lutImage.CGImage))];
        free(imageBytes);
        self.lutTexture = [loader newTextureWithData:imageData options:@{MTKTextureLoaderOptionSRGB:@(NO)} error:&err]; // 生成 LUT 滤镜纹理
    

    生成原图纹理代码

        unsigned char *imageBytes = [self bitmapFromImage:image];
        NSData *imageData = [self imageDataFromBitmap:imageBytes imageSize:CGSizeMake(CGImageGetWidth(image.CGImage), CGImageGetHeight(image.CGImage))];
        free(imageBytes);    
        MTKTextureLoader *loader = [[MTKTextureLoader alloc] initWithDevice:self.mtlDevice];
        NSError* err;
        self.originalTexture = [loader newTextureWithData:imageData options:@{MTKTextureLoaderOptionSRGB:@(NO)} error:&err];
    

    3. 着色器代码

    Metal 着色器代码与 OpenGLES 的着色器类似,因为它们的原理都是一样的

    #include <metal_stdlib>
    using namespace metal;
    
    struct Vertex {
        packed_float4 position;
        packed_float2 texCoords;
    };
    
    struct ColoredVertex
    {
        float4 position [[position]];
        float2 texCoords;
    };
    
    vertex ColoredVertex vertex_func(constant Vertex *vertices [[buffer(0)]], uint vid [[vertex_id]]) {
        Vertex inVertex = vertices[vid];
        ColoredVertex outVertex;
        outVertex.position = inVertex.position;
        outVertex.texCoords = inVertex.texCoords;
        
        return outVertex;
    }
    
    fragment half4 fragment_func(ColoredVertex vert [[stage_in]], texture2d<half> originalTexture [[texture(0)]], texture2d<half> lutTexture [[texture(1)]]) {
        // stage_in 修饰光栅化顶点
        float width = originalTexture.get_width();
        float height = originalTexture.get_height();
        uint2 gridPos = uint2(vert.texCoords.x * width ,vert.texCoords.y * height);
        
        half4 color = originalTexture.read(gridPos);
        
        float blueColor = color.b * 63.0;
        
        int2 quad1;
        quad1.y = floor(floor(blueColor) / 8.0);
        quad1.x = floor(blueColor) - (quad1.y * 8.0);
        
        int2 quad2;
        quad2.y = floor(ceil(blueColor) / 8.0);
        quad2.x = ceil(blueColor) - (quad2.y * 8.0);
        
        half2 texPos1;
        texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.r);
        texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.g);
        
        half2 texPos2;
        texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.r);
        texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.g);
        
        half4 newColor1 = lutTexture.read(uint2(texPos1.x * 512,texPos1.y * 512));
        half4 newColor2 = lutTexture.read(uint2(texPos2.x * 512,texPos2.y * 512));
        
        half4 newColor = mix(newColor1, newColor2, half(fract(blueColor)));
        half4 finalColor = mix(color, half4(newColor.rgb, 1.0), half(1.0));
        
        half4 realColor = half4(finalColor);
        return realColor;
    }
    

    此处不再赘述。

    4. 渲染到屏幕

    渲染过程首先要获取到下一个内容区缓存,即“画布”

        id<CAMetalDrawable> drawable = [(CAMetalLayer*)[self.mtlView layer] nextDrawable]; // 获取下一个可用的内容区缓存,用于绘制内容
        if (!drawable) {
            return;
        }
    

    然后生成 MTLRenderPassDescriptor,相当于对此次渲染流程的描述符

        MTLRenderPassDescriptor *renderPassDescriptor = [self.mtlView currentRenderPassDescriptor]; // 获取当前的渲染描述符
        if (!renderPassDescriptor) {
            return;
        }
        renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.5, 0.5, 0.5, 1.0); // 设置颜色附件的清除颜色
        renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear; // 用于避免渲染新的帧时附带上旧的内容
    

    接下来从命令队列中取出一个可用的命令 buffer,装载进去一个命令编码器,命令编码器里包含着色器所需的顶点、纹理等

        id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer]; // 获取一个可用的命令 buffer
        id<MTLRenderCommandEncoder> commandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor]; // 通过渲染描述符构建 encoder
        [commandEncoder setCullMode:MTLCullModeBack]; // 设置剔除背面
        [commandEncoder setFrontFacingWinding:MTLWindingClockwise]; // 设定按顺时针顺序绘制顶点的图元是朝前的
        [commandEncoder setViewport:(MTLViewport){0.0, 0.0, self.mtlView.drawableSize.width, self.mtlView.drawableSize.height, -1.0, 1.0 }]; // 设置可视区域
        [commandEncoder setRenderPipelineState:self.pipelineState];// 设置渲染管线状态位
        [commandEncoder setVertexBuffer:self.vertexBuffer offset:0 atIndex:0]; // 设置顶点buffer
        [commandEncoder setFragmentTexture:self.originalTexture atIndex:0]; // 设置纹理 0,即原图
        [commandEncoder setFragmentTexture:self.lutTexture atIndex:1]; // 设置纹理 1,即 LUT 图
        [commandEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6]; // 绘制三角形图元
    

    最终提交到队列中

        [commandEncoder endEncoding];
        [commandBuffer presentDrawable:drawable];
        [commandBuffer commit];
    

    仍然选取如下原图做测试

    选择如下 LUT 图

    lookup.png

    最终滤镜效果

    相关文章

      网友评论

      • 一点东风:请问怎么把half4类型的参数传入着色器啊,现在想把颜色传给着色器,但是好像在.matel文件之外没办法使用half4类型啊

      本文标题:iOS 利用 Metal 实现 LUT 滤镜

      本文链接:https://www.haomeiwen.com/subject/mbjdoftx.html