在大型项目里使用 Metal 的一点经验
发布于:2019-03-21 11:49,阅读数:1035,点赞数:10
# 引言 本文中列举了笔者最近几个月开发一款基于 Metal 的渲染引擎过程中遇到的坑。 引擎本身已在公司商用并申请专利,因此太多细节不便公开。但在开发过程中遇到的一些问题,我觉得还是可以简单写一写的。  # 正文 ## 纹理问题 纹理是渲染引擎的核心资源,但由于图片格式五花八门,造成生成纹理的过程变的很复杂。 一种偷懒的办法是使用`MetalKit`提供的`MTKTextureLoader`,它的以下方法可以让我们从非常熟悉的`CGImage`生成纹理。 ```objectivec - (nullable id <MTLTexture>)newTextureWithCGImage:(nonnull CGImageRef)cgImage options:(nullable NSDictionary <MTKTextureLoaderOption, id> *)options error:(NSError *__nullable *__nullable)error; ``` 但是你以为这样就万事大吉了吗?错。 生成纹理的过程是偷懒了,但`CGImage`本身也很复杂,非常多的情况会造成纹理创建失败。 这里笔者列举三个开发过程中遇到的问题。 ### libWebP解码出来的文件无法生成贴图 `libWebP`吐出来的图片喂给`MTKTextureLoader`直接失败。  这个问题的核心原因在于`libWebP`吐出来的`CGImage`的像素格式是`RGB888`的,`MTKTextureLoader`它不吃这种像素格式的图片(不得不吐槽一下苹果真的懒,这都懒得处理)。  所以这种情况我们需要自己处理一下,把`RGB888`转成`RGBA8888`,即需要在每个像素的数据后面插一个`alpha`进去。 方法有很多,最简单的甚至可以用个 for 循环直接搞,但是最快的方法是用苹果内置的`Accelerate`库的`vImage`接口。 代码笔者先贴在这里,API 使用流程很清晰,但是有几个`vImage`的结构体需要单独了解一下,有兴趣的小伙伴可以自己研究。 ```objectivec + (CGImageRef)convertRGB888ToRGBA8888WithCGImageRef:(CGImageRef)cgImage { @autoreleasepool{ size_t width = CGImageGetWidth(cgImage); size_t height = CGImageGetHeight(cgImage); vImage_Buffer srcBuffer, dstBuffer; vImage_CGImageFormat srcFormat { (uint32_t)CGImageGetBitsPerComponent(cgImage), (uint32_t)CGImageGetBytesPerRow(cgImage), CGImageGetColorSpace(cgImage), kCGBitmapByteOrderDefault, 0, NULL, kCGRenderingIntentDefault, }; vImage_CGImageFormat dstFormat { 8, 32, CGColorSpaceCreateDeviceRGB(), kCGBitmapByteOrder32Big | kCGImageAlphaPremultipliedLast, 0, NULL, kCGRenderingIntentDefault, }; vImage_Error err = kvImageNoError; CGImageRef result = nil; do { err = vImageBuffer_InitWithCGImage(&srcBuffer, &srcFormat, NULL, cgImage, kvImageNoFlags); if (err != kvImageNoError) { break; } err = vImageBuffer_Init(&dstBuffer, height, width, 32, kvImageNoFlags); if (err != kvImageNoError) { break; } err = vImageConvert_RGB888toRGBA8888(&srcBuffer, NULL, 0xff, &dstBuffer, NO, kvImageNoFlags); if (err != kvImageNoError) { break; } if (err != kvImageNoError) { break; } result = vImageCreateCGImageFromBuffer(&dstBuffer, &dstFormat, NULL, NULL, kvImageNoFlags, &err); } while(0); return result ?: nil; } } ``` ### 颜色空间为kCGColorSpaceModelIndexed的图片无法生成贴图 有一类`CGImage`的`bytePerRow`为 8,颜色空间为`kCGColorSpaceModelIndexed`,也是没有办法生成纹理的。  还真是个挑食的家伙,在这里需要转成`RGB`颜色空间喂给它。  笔者尝试过同样使用`vImage`去处理,但是没有成功,转出来的图片花屏或有其他问题,退而求其次使用了`CGImage`的 API 进行了处理。 ```objectivec + (CGImageRef)renderCGImageToRGBA8888WithCGImageRef:(CGImageRef)cgImage { @autoreleasepool{ CGColorSpaceRef colorspaceRef = CGColorSpaceCreateDeviceRGB(); size_t width = CGImageGetWidth(cgImage); size_t height = CGImageGetHeight(cgImage); size_t bytesPerRow = 4 * width; CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, bytesPerRow, colorspaceRef, kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast); VG_GUARD_ELSE_RETURN_NIL(context); CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage); CGImageRef result = CGBitmapContextCreateImage(context); CGContextRelease(context); return result; } } ``` ### 生成的纹理颜色灰暗 生成纹理时`MetalKit`自作聪明地调整了图片的`gamma`值,可怕的是这个功能默认是开启的,妈的智障。  怎么关呢?以下代码即可解决。 ```objectivec NSDictionary *options = @{ MTKTextureLoaderOptionSRGB: @(NO) }; id<MTLTexture> texture = [self.textureLoader newTextureWithCGImage:cgImage options:options error:&error]; ``` ## 着色器在部分机型上出现问题 核心问题是不同机型上出现了变量自动转换结果不一致的情况。  顶点着色器被标为`vertex_id`槽位的参数输入的是该顶点的索引值。 ```c++ vertex VertexOut vertex_xxx(device VertexIn *vertices [[buffer(0)]], uint vertexId [[vertex_id]]) { VertexOut out; out.vertexId = vertexId; //... return out; } ``` 这个参数的类型是`uint`,如果这个参数需要被输出给图元着色器使用,一般会使用一个结构体存起来一起返回。 如果这个结构体中接收这个变量的类型不是`uint`,而是`ushort`或者`int`,即使值本身没有超过该类型的值的最高限制,仍然会出现不可预料的结果,最可怕的这只在某些机型上发生。  这一点上开发者还需自己多加注意,出现问题的时候非常难查。 ## 同屏多实例帧率过低 笔者最初采用`MTKView`作为容器,系统会在特定时候回调以下方法触发渲染。 ```objectivec - (void)drawInMTKView:(nonnull MTKView *)view ``` 但触发机制是个迷,在同屏多个`MTKView`的时候,每个`MTKView`明显低于了 60 帧。  例如屏幕上有三个实例,Xcode 上的帧率显示为 120 帧(多个实例时这个数字会成倍往上走,但最高就到 120 为止),每个实例的帧率下降到了 40 帧,如果实例继续增加,帧率还会进一步下降,显然是不符合直觉的。  因此在多实例场景下,不要使用`MTKView`,即使性能上没有任何压力,也会莫名其妙掉帧。 自己触发渲染是最好的选择,用`CAMetalLayer`作为容器,用`CADisplayLink`作为触发器能够解决这个问题。 ## 三倍屏手机上View边缘出现闪动的黑线 笔者用于绘制的容器是`CAMetalLayer`,在宿主`UIView`的子类里重写`layerClass`方法。 ```objectivec + (Class)layerClass { return [CAMetalLayer class]; } ``` 这样创建的 layer 和宿主大小应该是严格一致的,为什么边缘会出现黑线呢?  原因是宿主的背景颜色默认是`clearColor`,在系统某个神秘的 bug 之下,透明色被渲染成了黑色展示了出来。  修复的方法也很简单,设为任意颜色就可以了,反正是背景被`CAMetalLayer`严格覆盖,是露不出来的。 # 结语 笔者开发的引擎已经商用了,表现良好。虽然开发过程中遇到了一些坑,但是不能否认 Metal 比 OpenGL 好太多太多了。 这些坑和 OpenGL 的那些坑比起来,少太多太多了。Metal 本身的稳定性、跨端性(macOS)近乎完美,同一套代码稍作修改,即可在 macOS 上跑起来,在 macOS 上的 demo 里开发完,直接复制到 iOS 的工程里就可以了,美滋滋。 期待 Metal 后续的发展。
评论:0条