在大型项目里使用 Metal 的一点经验

发布于:2019-03-21 11:49,阅读数:888,点赞数:10


# 引言

本文中列举了笔者最近几个月开发一款基于 Metal 的渲染引擎过程中遇到的坑。

引擎本身已在公司商用并申请专利,因此太多细节不便公开。但在开发过程中遇到的一些问题,我觉得还是可以简单写一写的。

![](//cdn.yuusann.com/img/posts/17019_5.jpg)

# 正文

## 纹理问题

纹理是渲染引擎的核心资源,但由于图片格式五花八门,造成生成纹理的过程变的很复杂。

一种偷懒的办法是使用`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`直接失败。

![](//cdn.yuusann.com/img/posts/17019_5.jpg)

这个问题的核心原因在于`libWebP`吐出来的`CGImage`的像素格式是`RGB888`的,`MTKTextureLoader`它不吃这种像素格式的图片(不得不吐槽一下苹果真的懒,这都懒得处理)。

![](//cdn.yuusann.com/img/posts/17019_6.jpg)

所以这种情况我们需要自己处理一下,把`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`,也是没有办法生成纹理的。

![](//cdn.yuusann.com/img/posts/17019_5.jpg)

还真是个挑食的家伙,在这里需要转成`RGB`颜色空间喂给它。

![](//cdn.yuusann.com/img/posts/17019_6.jpg)

笔者尝试过同样使用`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`值,可怕的是这个功能默认是开启的,妈的智障。

![](//cdn.yuusann.com/img/posts/17003_6.jpg)

怎么关呢?以下代码即可解决。

```objectivec
NSDictionary *options = @{ MTKTextureLoaderOptionSRGB: @(NO) };
id<MTLTexture> texture = [self.textureLoader newTextureWithCGImage:cgImage options:options error:&error];
```

## 着色器在部分机型上出现问题

核心问题是不同机型上出现了变量自动转换结果不一致的情况。

![](//cdn.yuusann.com/img/posts/17019_5.jpg)

顶点着色器被标为`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`,即使值本身没有超过该类型的值的最高限制,仍然会出现不可预料的结果,最可怕的这只在某些机型上发生。

![](//cdn.yuusann.com/img/posts/17019_6.jpg)

这一点上开发者还需自己多加注意,出现问题的时候非常难查。

## 同屏多实例帧率过低

笔者最初采用`MTKView`作为容器,系统会在特定时候回调以下方法触发渲染。

```objectivec
- (void)drawInMTKView:(nonnull MTKView *)view
```

但触发机制是个迷,在同屏多个`MTKView`的时候,每个`MTKView`明显低于了 60 帧。

![](//cdn.yuusann.com/img/posts/17019_5.jpg)

例如屏幕上有三个实例,Xcode 上的帧率显示为 120 帧(多个实例时这个数字会成倍往上走,但最高就到 120 为止),每个实例的帧率下降到了 40 帧,如果实例继续增加,帧率还会进一步下降,显然是不符合直觉的。

![](//cdn.yuusann.com/img/posts/17019_6.jpg)

因此在多实例场景下,不要使用`MTKView`,即使性能上没有任何压力,也会莫名其妙掉帧。

自己触发渲染是最好的选择,用`CAMetalLayer`作为容器,用`CADisplayLink`作为触发器能够解决这个问题。

## 三倍屏手机上View边缘出现闪动的黑线

笔者用于绘制的容器是`CAMetalLayer`,在宿主`UIView`的子类里重写`layerClass`方法。

```objectivec
+ (Class)layerClass {
return [CAMetalLayer class];
}
```

这样创建的 layer 和宿主大小应该是严格一致的,为什么边缘会出现黑线呢?

![](//cdn.yuusann.com/img/posts/17019_5.jpg)

原因是宿主的背景颜色默认是`clearColor`,在系统某个神秘的 bug 之下,透明色被渲染成了黑色展示了出来。

![](//cdn.yuusann.com/img/posts/17019_6.jpg)

修复的方法也很简单,设为任意颜色就可以了,反正是背景被`CAMetalLayer`严格覆盖,是露不出来的。

# 结语

笔者开发的引擎已经商用了,表现良好。虽然开发过程中遇到了一些坑,但是不能否认 Metal 比 OpenGL 好太多太多了。

这些坑和 OpenGL 的那些坑比起来,少太多太多了。Metal 本身的稳定性、跨端性(macOS)近乎完美,同一套代码稍作修改,即可在 macOS 上跑起来,在 macOS 上的 demo 里开发完,直接复制到 iOS 的工程里就可以了,美滋滋。

期待 Metal 后续的发展。


评论:0条


返回列表

返回归档

返回主页