之前探究过 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 的对象关系如图所示
其中连接 CPU 和 GPU 的就是命令队列 Command Queue,其上装载多个命令缓冲 Command Buffer,Command Buffer 里能承载 Metal 定义的多种图形、计算命令编码器,在编码器中就是开发者创建的实际的命令和资源,它们最终被传送到 GPU 中进行计算和渲染。
接下来就用 Metal 实现一个图片 LUT 滤镜。
1. 初始化 Metal 初始化工作主要将一些初始化开销大、能够复用的对象进行预先生成和持有.
首先初始化 GPU 接口,可以理解为持有 GPU,在 Metal 中它被定义为 MTLDevice 类型的对象
1 self .mtlDevice = MTLCreateSystemDefaultDevice ();
其次初始化一个 MTKView,它相当于画布,用于 GPU 渲染内容到屏幕上
1 2 3 4 5 [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 很类似,大致如下图所示
因此同样的,需要传入顶点数据、顶点着色器和片段着色器
顶点数据定义如下,每一行的前四个分量为顶点坐标,后两个分量为纹理坐标(归一化)。
1 2 3 4 5 6 7 8 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 中
1 self .vertexBuffer = [self .mtlDevice newBufferWithBytes:vertexArrayData length:sizeof (vertexArrayData) options:0 ];
Metal 搜索顶点着色器和片段着色器的范围是以 Bundle 为维度的,在一个 Bundle 内放进任意名称的 Metal 文件,其中的着色器函数都可以被 Metal 搜索并加载到内存中。
1 2 3 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" ];
接下来要将着色器函数装配到渲染管线上,需要用到 MTLRenderPipelineDescriptor 对象
1 2 3 4 5 MTLRenderPipelineDescriptor *pipelineDescriptor = [MTLRenderPipelineDescriptor new];pipelineDescriptor.vertexFunction = vertextFunc; pipelineDescriptor.fragmentFunction = fragFunc; pipelineDescriptor.colorAttachments[0 ].pixelFormat = MTLPixelFormatBGRA8Unorm ; self .pipelineState = [self .mtlDevice newRenderPipelineStateWithDescriptor:pipelineDescriptor error:nil ];
最后是初始化渲染队列,以及创建纹理缓存
1 2 self .commandQueue = [self .mtlDevice newCommandQueue]; CVMetalTextureCacheCreate(NULL , NULL , self .mtlDevice, NULL , &_textureCache);
2. 图片纹理加载 Metal 为加载图片纹理提供了便捷类 MTKTextureLoader,能够根据多种参数生成 MTLTexture 纹理对象,但是实际使用中发现了两个问题:
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 对象传入
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 - (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); 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; }
这个问题的表现现象是最终渲染出来的图片偏暗,即使是原图也会偏暗,StackOverflow 上有很多对这个 strange color 问题的回答,均提到将 MTKTextureLoader 的 MTKTextureLoaderOptionSRGB 选项设置为 NO,它默认为 YES。
我的理解是,sRGB 实际上是一种对颜色的编码,其效果是增加暗色域的编码精度,降低亮色域的编码精度。那么针对 sRGB 编码的图片就需要进行一次 gamma 校正,以确保进行诸如 LUT 对照计算时能够严格按照线性 RGB 计算。但是实际上我传入的图片都是以 RGB 格式排列,因此不需要进行 gamma 校正,如果不设置关闭 sRGB 的校正,就会对线性 RGB 格式的数据进行校正,导致最终图片偏暗。这个问题在我做 CoreImage 的滤镜调研时也出现过,下面是效果图
生成 LUT 纹理代码
1 2 3 4 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];
生成原图纹理代码
1 2 3 4 5 6 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 的着色器类似,因为它们的原理都是一样的
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 #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 )]]) { 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. 渲染到屏幕 渲染过程首先要获取到下一个内容区缓存,即“画布”
1 2 3 4 id <CAMetalDrawable > drawable = [(CAMetalLayer *)[self .mtlView layer] nextDrawable]; if (!drawable) { return ; }
然后生成 MTLRenderPassDescriptor,相当于对此次渲染流程的描述符
1 2 3 4 5 6 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,装载进去一个命令编码器,命令编码器里包含着色器所需的顶点、纹理等
1 2 3 4 5 6 7 8 9 10 id <MTLCommandBuffer > commandBuffer = [self .commandQueue commandBuffer]; id <MTLRenderCommandEncoder > commandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor]; [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 ]; [commandEncoder setFragmentTexture:self .originalTexture atIndex:0 ]; [commandEncoder setFragmentTexture:self .lutTexture atIndex:1 ]; [commandEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6 ];
最终提交到队列中
1 2 3 [commandEncoder endEncoding]; [commandBuffer presentDrawable:drawable]; [commandBuffer commit];
仍然选取如下原图做测试
选择如下 LUT 图
最终滤镜效果