iOS共享Unity纹理调研与实践

一、背景

按照目前的unity方案,同一时间下unity只能够渲染一个空间场景(放在iOS下,也就是我们只能显示一个unity的view),但是业务会存在同时渲染多个unityView的场景,因此我们需要调研Unity支持多view场景,那么为什么会产生“iOS共享Unity纹理”这个概念呢?请听我慢慢道来~~

unity版本2021.3.2f1(很重要,因为unity里面渲染的很多设置和版本相关)

二、iOS共享Unity纹理

既然unity只能同时渲染一个unity场景,那么我们可不可以在一个场景下渲染多个"renderTexture(渲染纹理)",然后返回对应的纹理地址给原生,让原生去自己渲染。理论上是可以实现的(因为unity实时显示原生相机的内容就是通过这种方案)。下面是该方案的交互图:


三、最终效果

上半部分是unity渲染显示的,下半部分是iOS原生使用Metal自定义渲染流程实现的纹理渲染,可以看到画面以及旋转方向都是正确的、拥有透明背景和支持unity多线程渲染。一路走来,属实有点坎坷,但是还是fix了(那些天熬过的夜、失过的眠还是值得的)。

四、实施"曲折"路线

主要按照以下三个流程进行:

为什么采用这三个流程进行推进,主要是采用"分治"法解决这一大难题,也就是把大问题拆解出来,分成各个小问题分而治之。

  1. 纹理指针传递

  1. 确定iOS渲染引擎

由于这个是Android先行,因此一开始参考安卓端在网上找到的一个方案Unity安卓共享纹理 - zhxmdefj - 博客园,但是一看就懵逼了:

wtf。。。这不是在骗我,在iOS中拿到unity的线程,确定可以拿到?而且还要在拿到的线程里面做执行一些操作,这在我看来是没办法实现的(尽管能拿到最底层的thread_t,但是拿到这个好像也没啥用,做不了太多的事情)。因为感觉事有蹊跷啊,但是OpenGL的渲染上下文是和线程相关的,拿不到对应的线程是没办法完成这个需求的(WTF完犊子了这。。难道还没开始就要结束了?)。

赶紧去百度和谷歌啊,于是在网上疯狂找了一番(unity iOS共享纹理),结果啥都没找到。。。。(不行我要冷静,冷静了蛮久蛮久后),"iOS不是支持Metal吗?为什么要用OpenGL去搞呢?而且Metal天然支持多线程的啊(也就是Metal内部做了线程同步),那么也就没有说要拿到unity线程的这个说法啊~~~~"灵感出现(只能说这个灵感有点厉害啊。现在只希望unity会聪明的用Metal渲染引擎)。

为了验证自己的想法,赶紧去看看unity导出的工程验证一下自己的猜测:

发现UnityFramework里面确实是依赖了Metal这个系统库,而且没看到OpenGL相关的依赖,如果你还是不信,别急,我证明给你看(哈哈,确实这样说服你会有一点牵强)。

看到目前的unity项目用的确实是metal,网上查询资料得知在Unity 2021.3及更高版本中,默认情况下会使用Metal作为iOS的渲染引擎。因此我们可以不用管这个线程带来的上下文问题。

  1. unity的纹理地址怎么传给iOS

安卓那边是直接texture.GetNativeTexturePtr().ToInt32()拿到的是纹理id,很奇怪,在unity和iOS都是打印的地址(很长的一串数字),但是在安卓端打印的却是54这种数字(应该就是纹理id)。虽然安卓端拿到的是纹理id,但是看起来iOS端拿到的是纹理地址。

unity那边拿的纹理地址是texture.GetNativeTexturePtr(),类型是intptr对应iOS里面的(void *),因为目前三端的交互定义了一套协议(都是转成json字符串去交互的),如果没有定义这一套协议的话,那么unity的intptr原生是可以直接拿到void *去接收的,但是目前都需要转成json字符串去交互,那么有一个问题:unity将intptr转成string,然后原生拿到string转成void *会不会改变原本的含义呢?试一试就知道咯(大致是会改变的)。

不出意外,果然是崩了,看起来应该是不能这样的(也就是unity将intptr转成string,然后原生拿到string转成void *会改变原本的含义)。

问题是c#里面的intptr怎么通过string进行传递,然后接收方要怎么去对这个string进去处理,才能使这个含义不发生改变呢?

/// 将字符串转换为无符号长长整型(unsigned long long)
/// - Parameters:
///   - __str: 要转换的字符串
///   - __endptr: 指向一个指针的指针,用于存储转换结束后的字符串的下一个字符的地址。如果不需要此信息,可以将其设置为NULL。
///   - __base: 转换时使用的进制数。可以是2到36之间的任意整数,或者0。如果为0,则根据字符串的前缀来确定进制数(0x或0X表示十六进制,0表示八进制,否则为十进制)。
/// - Returns: 转换后的无符号长长整型数
strtoull(const char *__str, char **__endptr, int __base);

就是使用strtoull将unity那边传过来的string进行转换。这里__base一开始传的是16(以为是16进制),运行还是会发生崩溃,后面改成10发现是OK的(不知道为啥unity传来的指针地址是10进制)。

// 将字符串转换回指针类型
id<MTLTexture> tex = (__bridge id<MTLTexture>)(void*)strtoull([param UTF8String], NULL, 10);

运行后发现正常执行下去,不会出现异常。因此暂时可以确定转换是成功的。

  1. 纹理转图片

  1. 读取纹理地址转成图片进行显示

为了验证纹理地址的正确性,决定先转成uiimage进行显示,以便达到验证:

@objc static func mtlTextureToUIImage(texture:MTLTexture,wScale:CGFloat,hScale:CGFloat) -> UIImage? {
        let ciimage = CIImage(mtlTexture: texture, options: nil)
        //剪切图片
        let croppedCiImage = ciimage?.cropped(to: CGRect(x: CGFloat(texture.width)/2 * (1 - wScale), y: CGFloat(texture.height)/2 * (1 - hScale), width: CGFloat(texture.width) * wScale, height: CGFloat(texture.height) * hScale))
        //orientation:这里需要设置镜像,不然内容会是倒置镜像的
        let uiimage12 = UIImage(ciImage: croppedCiImage!, scale: 0, orientation: UIImage.Orientation.downMirrored)
        return uiimage12
}

这里特别要注意,获取到的图片要设置orientation为UIImage.Orientation.downMirrored,不然显示的图片会是镜像的。

渲染代码如下

[HostRouterApi sharedInstance].sendEventToHostBlock = ^(NSString *param) {
        
        NSLog(@"receive unity call host %@", param);
        
        // 将字符串转换回指针类型
        id<MTLTexture> tex = (__bridge id<MTLTexture>)(void*)strtoull([param UTF8String], NULL, 10);
        self.texture = tex;
        NSLog(@"%zd",self.texture.pixelFormat);
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            UIImage *image = [MTLTextureConverter mtlTextureToUIImageWithTexture:tex wScale:100 hScale:100];
            dispatch_async(dispatch_get_main_queue(), ^{
                self.imageView.image = image;

            });
        });
    };

上半部分小的是unity渲染出来的,下半部分大的是原生拿到的纹理进行渲染显示的。

  1. 镜像问题优化

发现目前的纹理取出来是镜像的,安卓那边也是。基于此提出的一个建议就是:unity那么直接在写入纹理的时候对其进行优化处理,从源头去解决镜像问题。

询问了unity开发人员后:也表示问题不大,可以解决。

  1. Metal绑定纹理地址,自定义渲染流程持续渲染

为什么上述将纹理转成image然后显示的方案不行(监听屏幕刷新率,定时去在子线程内将纹理转成image然后更新到图层上去)。

因为当你将纹理转换为图片后,需要将纹理数据复制到图像对象中,并进行像素格式的转换。这涉及到额外的内存分配、数据复制和格式转换操作,会消耗一定的时间和计算资源。而且,每次更新纹理时都需要进行这些操作,会增加额外的开销。 相比之下,直接渲染纹理可以避免这些额外的操作。你可以将纹理直接绑定到渲染管线中的纹理绑定点上,并在绘制命令中使用纹理进行渲染。这样可以减少内存复制和数据转换的开销,提高渲染的效率。 此外,直接渲染纹理还可以利用GPU的并行处理能力,以及Metal提供的高效的渲染管线,进一步提高渲染的性能。

总而言之,直接渲染纹理通常比将纹理转换为图片再显示具有更好的性能,因为它避免了不必要的内存复制和数据转换操作,并能充分利用GPU的并行处理能力和Metal的高效渲染管线。

  1. 使用MTKView去进行纹理的持续读取渲染

在这里遇到了两个极其坑爹的地方:

  1. MTKView如果什么格式都不设置,渲染的内容会有问题。

  2. 只能说iOS原生和unity的这个colorPixelFormat要对应起来。但是因为目前unity那边设置的colorPixelFormat是MTLPixelFormatRGBA8Unorm,一设置就会崩溃。



    1. 经过查询资料得知CAMetalLayer支持的也就只有五个:
   MTLPixelFormatBGRA8Unorm(默认值)
   MTLPixelFormatBGRA8Unorm_sRGB, 
   MTLPixelFormatRGBA16Float
   MTLPixelFormatBGRA10_XR
   MTLPixelFormatBGRA10_XR_sRGB

关键是坑爹的这个unity版本(2021.3.2f1)可设置的不多,找到了一个对应的MTLPixelFormatRGBA16Float,unity设置对应如下

然后原生需要这么设置:

self.mtkView.colorPixelFormat = MTLPixelFormatRGBA16Float;

这样才能正确的读取纹理内容进行显示。

最终大致代码如下:

- (void)setupMetal {
    self.mtkView = [[MTKView alloc] initWithFrame:CGRectMake(100, 500, 150, 150)];
    self.mtkView.device = MTLCreateSystemDefaultDevice();
    [self.view insertSubview:self.mtkView atIndex:0];
    self.mtkView.delegate = self;
    self.commandQueue = [self.mtkView.device newCommandQueue];
    self.mtkView.framebufferOnly = NO;
    self.mtkView.colorPixelFormat = MTLPixelFormatRGBA16Float;
//    self.mtkView.depthStencilPixelFormat = MTLPixelFormatR32Float;
    
}

uintptr_t textureAddress = (uintptr_t)strtoull([param UTF8String], NULL, 10);   
// 将字符串转换回指针类型
id<MTLTexture> tex = (__bridge id<MTLTexture>)(void*)strtoull([param UTF8String], NULL, 10);
self.texture = tex;

- (void)drawInMTKView:(MTKView *)view {
    if (self.texture) {
        id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
        id<MTLTexture> drawingTexture = view.currentDrawable.texture;
        
        MPSImageGaussianBlur *filter = [[MPSImageGaussianBlur alloc] initWithDevice:self.mtkView.device sigma:0];
        [filter encodeToCommandBuffer:commandBuffer sourceTexture:self.texture destinationTexture:drawingTexture];
        
        [commandBuffer presentDrawable:view.currentDrawable];
        [commandBuffer commit];
        
    }
}

上半部分是unity显示的,下半部分是原生渲染的。可以发现除了显示效果是反的之外,其他都是正常的。可以利用MTKView进行对纹理的持续读取渲染。

五、优化Metal(MTKView)渲染

第五步使用的MTKView对纹理的渲染有两个问题:

  1. 利用MPSImageGaussianBlur取巧,省去了复杂的纹理渲染流程,虽然sigma设置为0不会有高斯模糊,但是对于MPSImageGaussianBlur函数,即使将sigma参数设置为0,它仍然会执行一些计算和内存操作,以及对图像进行处理的步骤。虽然这些操作可能会被优化,但仍然可能对性能产生一定的影响。
  2. 画面是镜像的(对于有洁癖的我是不能容忍的),而且他妈的不能调整纹理大小。

因此尝试对这两个问题进行修复。首先需要了解一下Metal的简要流程:

  • 命令缓存区(Command Buffer)是从命令队列(Command Queue)创建的
  • 命令编码器(Command Encoder)将命令编码到命令缓存区中
  • 提交命令缓存区并将其发送到GPU
  • GPU执行命令并将结果呈现为可绘制
  1. Metal API

  1. MTKView与MTLDevice

在MetalKit中提供了一个视图类MTKView,类似于GLKit中GLKView,它是NSView(macOS中的视图类)或者UIView(iOS、tvOS中的视图类)的子类。用于处理metal绘制并显示到屏幕过程中的细节。

MTLDevice代表GPU设备,提供创建缓存、纹理等的接口,在初始化时候需要赋给MTKView

    // 初始化MTKView
    self.mtkView = [[MTKView alloc] init];
    self.mtkView.delegate = self;
    self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
    self.mtkView.frame = CGRectMake(100, 400, 300, 300);
    self.mtkView.framebufferOnly = NO;
    // 和unity的纹理颜色格式对应上
    self.mtkView.colorPixelFormat = MTLPixelFormatRGBA16Float;
    [self.view addSubview:self.mtkView];

MTKView的Delegate是MTKViewDelegate,我们需要实现这个协议的方法:

- (void)mtkView:(nonnull MTKView *)view drawableSizeWillChange:(CGSize)size {
    // MTKView的大小改变
    self.viewportSize = (vector_uint2){size.width, size.height};
}

- (void)drawInMTKView:(MTKView *)view {
    // 用于向着色器传递数据
    /*...具体实现*/
}
  1. MTLCommandQueue

在获取了GPU后,还需要一个渲染队列,即命令队列Command Queue类型是MTLCommandQueue,该队列是与GPU交互的第一个对象,队列中存储的是将要渲染的命令MTLCommandBuffer

队列的获取需要通过MTLDevice对象获取,且每个命令队列的生命周期很长,因此commandQueue可以重复使用,而不是频繁创建和销毁。

_commandQueue = [_device newCommandQueue];
  1. MTLRenderPipelineState

渲染管道状态 Render Pipeline State是一个协议,定义了图形渲染管道的状态,包括放在.metal文件的顶点和片段函数。

  1. MTLTexture

纹理 MTLTexture表示一个图片数据的纹理。我们可以根据纹理描述器 MTLTextureDescriptor来生成MTLTexture

  1. MTLBuffer

代表一个我们自定义的数据存储资源对象,在这里,用于存储顶点与纹理坐标数据,通过MTLDevice获取。

  1. MTLCommandBuffer

命令缓存区 Command Buffer主要是用于存储编码的命令,其生命周期是知道缓存区被提交到GPU执行为止,单个的命令缓存区可以包含不同的编码命令,主要取决于用于构建它的编码器的类型和数量。

命令缓存区的创建可以通过调用MTLCommandQueuecommandBuffer方法。且command buffer对象的提交只能提交至创建它的MTLCommandQueue对象中

commandBuffer在未提交命令缓存区之前,是不会开始执行的,提交后,命令缓存区将按其入队的顺序执行,使用[commandBuffer commit]提交命令。

- (void)drawInMTKView:(MTKView *)view {
    // 用于向着色器传递数据
    id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
    
    /*... 设置MTLRenderCommandEncoder进行Encode*/

    // 提交
    [commandBuffer presentDrawable:view.currentDrawable];
    [commandBuffer commit];
}
  1. MTLRenderCommandEncoder

渲染命令编码器 Render Command Encoder表示单个渲染过程中相关联的渲染状态和渲染命令,有以下功能:

  • 指定图形资源,例如缓存区和纹理对象,其中包含顶点、片元、纹理图片数据
  • 指定一个MTLRenderPipelineState对象,表示编译的渲染状态,包含顶点着色器和片元着色器的编译&链接情况
  • 指定固定功能,包括视口、三角形填充模式、剪刀矩形、深度、模板测试以及其他值
  • 绘制3D图元

由当前队列的缓冲MTLCommandBuffer根据描述器MTLRenderPassDescriptor的接口获取(这个可以通过MTKView的currentRenderPassDescriptor拿到,代表每一帧当前渲染视图的一些纹理、缓冲、大小等数据的描述器)。

id <MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
MTLRenderPassDescriptor *renderDesc = view.currentRenderPassDescriptor;
if (!renderDesc) {
    [commandBuffer commit];
    return;
}
// 获取MTLRenderCommandEncoder
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];

然后需要对之前提到的MTLRenderPipelineState(映射.metal文件用)、MTLTexture(读取图片获得的纹理数据)、MTLBuffer(顶点坐标和纹理坐标构成的缓冲)进行设置,最后调用drawPrimitives进行绘制,再endEncoding

id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderDesc];
[renderEncoder setViewport:(MTLViewport){0, 0, self.viewportSize.x, self.viewportSize.y, -1, 1}];
// 映射.metal文件的方法
[renderEncoder setRenderPipelineState:self.pipelineState];
// 设置顶点数据
[renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
// 设置纹理数据
[renderEncoder setFragmentTexture:self.texture atIndex:0];
// 开始绘制
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:self.numVertices];
// 结束渲染
[renderEncoder endEncoding];
//一旦框架缓冲区完成,使用当前可绘制的进度表
[commandBuffer presentDrawable:view.currentDrawable];
// 提交 会将命令缓冲区(command buffer)提交给与之关联的 MTLCommandQueue,然后 GPU 将会按照提交的顺序执行这些命令
[commandBuffer commit];

和OpenGL一样,我们可以使用4个顶点来绘制一个矩形,修改drawPrimitives:的参数为MTLPrimitiveTypeTriangleStrip,然后顶点顺序为z字形即可。

  1. 渲染纹理流程

暂时无法在飞书文档外展示此内容

以上为iOS原生渲染unity纹理的主要流程。以下为官方渲染流程图:

  1. 自定义纹理渲染实现

  1. 初始化阶段

主要分为以下三步:

  • 初始化Device 、 Queue、MTKView
  • setupVertex函数:设置顶点相关操作
  • setupPipeLine函数:设置渲染管道相关操作
  1. 初始化Device 、 Queue、MTKView
- (void)setupMTKView {
    // 初始化MTKView
    self.mtkView = [[MTKView alloc] init];
    self.mtkView.delegate = self;
    self.device = self.mtkView.device = MTLCreateSystemDefaultDevice();
    self.mtkView.frame = CGRectMake(0, 500, 300, 300);
    self.mtkView.framebufferOnly = NO;
//    这里的格式要和unity对齐
    self.mtkView.colorPixelFormat = MTLPixelFormatRGBA16Float;
//    这里切记要设置为NO,不然无法实现透明背景色
    self.mtkView.opaque = NO;
    [self.view addSubview:self.mtkView];
}

注意点主要有两点:

  • MTKView的colorPixelFormat要和unity对齐(self.mtkView.colorPixelFormat = MTLPixelFormatRGBA16Float;)
  • MTKView的opaque要设置为NO,不然无法实现unity的透明背景色(self.mtkView.opaque = NO;)
  1. setupVertex函数

主要是初始化顶点数据,包括顶点坐标和纹理坐标,当顶点坐标的范围不是-1~1时,是位于物体坐标系,需要在metal文件的顶点着色器中作归一化处理,并且将顶点数据存储到MTLBuffer对象中,GPU函数流程如下

上面所说的镜像问题就可以在这里处理,在这里我们建立像素坐标和纹理坐标的映射关系。这里需要说明一下Metal的坐标系:

顶点坐标系是四维的(x, y, z, w),原点在图片的正中心。

顶点坐标系

纹理坐标系是二维的(x, y),原点在图片的左上角。

纹理坐标系

得结构体:

typedef struct {
    vector_float4 position;
    vector_float2 textureCoordinate;
} HobenVertex;
  1. 解决渲染纹理是镜像问题

当我们需要绘制一个矩形图片时,需要将顶点坐标和纹理坐标一一对应。正常情况下,设置为下面这种即可。

float heightScaling = 1.0;
float widthScaling = 1.0;
HobenVertex vertices[] = {
    // 顶点坐标 x, y, z, w  --- 纹理坐标 x, y
    { {-widthScaling,  heightScaling, 0.0, 1.0}, {0.0, 0.0} },
    { { widthScaling,  heightScaling, 0.0, 1.0}, {1.0, 0.0} },
    { {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 1.0} },
    { { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 1.0} },
};

但是由于unity那边的纹理传的是镜像的(反的),因此我们需要对顶点数据做相应的处理:

float heightScaling = 1.0;
float widthScaling = 1.0;
HobenVertex vertices[] = {
    // 顶点坐标 x, y, z, w  --- 纹理坐标 x, y
    { {-widthScaling,  heightScaling, 0.0, 1.0}, {0.0, 1.0} },
    { { widthScaling,  heightScaling, 0.0, 1.0}, {1.0, 1.0} },
    { {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 0.0} },
    { { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 0.0} },
};
  1. setupPipeLine函数

主要是渲染管道相关的初始化工作,对应的流程图如下

渲染管道的初始化分为以下几部分

  • 加载metal文件
  • 配置渲染管道
  • 创建渲染管线对象
  • 设置commandQueue命令对象

这里说的加载metal文件其实是需要我们创建的一个OC与C的桥接文件。里面的内容主要是1个结构体+两个函数,结构体中包含顶点坐标 和纹理坐标,作为定点着色器的输出以及片元着色器的输入,对于渲染来说,一般至少需要这两种数据:顶点坐标(x、y、z、w四维)、纹理坐标(x、y两维),这里我们定义一个包含上述两个变量的数据结构:

//返回值结构体:顶点着色器输出和片元着色器输入(相当于OpenGL ES中的varying修饰的变量,即桥接)
typedef struct
{
//    顶点坐标
    float4 clipSpacePosition [[position]];
    
//    纹理坐标
    float2 textureCoordinate;
    
}RasterizerData;

着色函数的执行流程如下:

  • 顶点着色函数

主要是将顶点坐标归一化处理,并将处理后的顶点坐标和纹理坐标输出,经过metal的图元装配和光栅化处理,将顶点数据传入片元着色器,其流程图如下

vertex RasterizerData vertexShader(uint vertexId [[ vertex_id ]],
                                   constant HobenVertex *vertexArray [[ buffer(0) ]]);

顶点着色器以vertex为修饰符,返回RasterizerData数据结构并作为片段着色器的输入,需要输入索引和顶点缓存数组。

[[ vertex_id ]] 是顶点id标识符,即索引,他并不由开发者传递;

[[buffer(index)]] 是index的缓存类型,对应OC语言的

[renderEncoder setVertexBuffer:buffer offset:0 atIndex:index];

这里的buffer就是我们事先设置好的坐标映射:

HobenVertex vertices[] = {
        // 顶点坐标 x, y, z, w  --- 纹理坐标 x, y
        { {-widthScaling,  heightScaling, 0.0, 1.0}, {0.0, 1.0} },
        { { widthScaling,  heightScaling, 0.0, 1.0}, {1.0, 1.0} },
        { {-widthScaling, -heightScaling, 0.0, 1.0}, {0.0, 0.0} },
        { { widthScaling, -heightScaling, 0.0, 1.0}, {1.0, 0.0} },
    };

我们根据OC传入的一堆HobenVertex类型的顶点和对应的索引,将其转化为MSL对应的结构体RasterizerData,顶点着色器渲染完毕。

  • 片元着色函数

主要是通过采样器获取纹素,相当于GLSL中内建函数texture2D,在metal中是通过纹理的sample函数获取,主要流程图如下

fragment float4 fragmentShader(RasterizerData input [[ stage_in ]],
                               texture2d <float> colorTexture [[ texture(0) ]]);

片元着色器以fragment为修饰符,返回float4数据结构(即该像素的rgba),需要输入光栅化处理好的数据和纹理数据。

[[ stage_in ]]是由顶点着色函数输出然后经过光栅化生成的数据,这是系统生成的,无需我们进行设置和输入。

[[ texture(index) ]]代表纹理数据,index对应OC语言设置里面的

// 设置纹理数据
[renderEncoder setFragmentTexture:texture atIndex:index];

texture2d<T, access a = access::sample>代表这是一个纹理数据,其中T可以是half、float、short、int等,access表示纹理访问权限,当access没写时,默认是sample,还可以设置为sample(可读写可采样)、read(只读)、write(可读写)。

  1. 渲染阶段

其实这里主要是在MTKViewDelegate代理方法里面的drawInMTKView所做的逻辑。

这里说明下传递数据这部分,向着色器中传递的数据有下面两种:

  1. 顶点数据:包含顶点坐标和纹理坐标,由于顶点数据是存储在缓存区中的,所以需要通过setVertexBuffer函数传递到顶点着色函数中。
  2. 纹理数据:将纹理对象加载到GPU中,需要通过setFragmentTexture函数传递到片元着色函数,通过采样器读物纹素,加载纹理。
/*
 需要传递的数据有以下三种:
 1)顶点数据、纹理坐标,
 2)纹理数据
 */
//将数据加载到MTLBuffer (即metal文件中的顶点着色函数)--> 顶点函数
[renderEncoder setVertexBuffer:self.vertices offset:0 atIndex:0];
//将纹理对象传递到片元着色器(即metal中的片元着色函数) -- 纹理数据
[renderEncoder setFragmentTexture:self.texture atIndex:0];
  1. 插曲

自定义纹理渲染出的人物色调和unity渲染出来的不一致?

当我在多视图里面添加了一个人物,unity主视图那边也会添加一个人物。只有摄像机不一样,摄像机的角度也都是一样的。但是呈现出来的效果确实不太一样的,有细微的差距。

图1

图2

上图1是自定义渲染流程渲染出来的,图2是unity主视图渲染出来的。可以看出有明显的差异,自定义渲染流程渲染出来的要比unity主视图自己渲染的要黄一点(wtf)。

这就相当的操蛋了,难道是自定义渲染流程有问题?细想如果有问题那为什么之前渲染的人物没有问题呢?基于此疑问。去问了一下unity同学,给出的答复是sharder不同所以导致的表象不同(没太懂,但是细想应该不会)。那去确认下是否是自定义渲染流程导致的问题吧。怎么确认呢?可以直接拿到GPU里面纹理的图片看看是否是正常。

前面有说过从纹理获取图片的代码:

- (void)getCurrentTextureImage:(void (^)(UIImage *))completion {
    LZAvatarLogzI(@"getCurrentTextureImage");
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        CIImage *ciimage = [[CIImage alloc] initWithMTLTexture:self.texture options:nil];
        // 剪切图片
        CGRect rect = CGRectMake(0, 0, self.texture.width, self.texture.height);
        CIImage *croppedCiImage = [ciimage imageByCroppingToRect:rect];
        //CIImage转UIImage
        UIImage *image = [[UIImage alloc] initWithCIImage:croppedCiImage scale:0 orientation:UIImageOrientationUp];
        dispatch_async(dispatch_get_main_queue(), ^{
            completion(image);
        });
    });
}

使用此方法获取到的图片效果和自定义渲染流程渲染出来的纹理效果是一样的,也就是说自定义渲染流程没有问题。因为unity那边写入的纹理就是如此。基于此又去找unity同事探讨看是什么问题导致的。然后说是使用linear颜色格式导致的,要使用gamma颜色格式就会正常(因为安卓那边进行gamma转换正常了。真的正常了吗?)好吧那就gamma一下吧,在sharder函数里面加上一层gamma转换后,测试一下输出效果:

// Apply gamma correction
    constexpr float gamma = 2.2;
    colorSample.rgb = pow(colorSample.rgb, float3(1.0 / gamma));
    colorSample.rgb = clamp(colorSample.rgb, 0.0, 1.0); // Clamp color values

gamma后的效果如下:

只能说更不正常了。看来gamma转换行不通了。难道是颜色格式导致的?尝试了下将纹理颜色格式换成其他的比如MTLPixelFormatBGRA8Unorm_sRGB。测试后也还是不行,这时候最初的疑惑再次出现在我的脑海里:为什么之前的渲染是一样的?而且为什么写入的纹理会有问题?带着这些疑惑,想叫unity的同事将主场景的那个也返回纹理指针给我,然后我这边比较一下我这边渲染的 和 unity那边渲染的效果是不是一样。这样控制变量法也许能看出问题出现在哪里。但是。。。unity那边说相机不会导致这种问题,因此这个无限接近真相的策略未被实施(事实证明后面还是这样控制变量法去对比找到了真相,当然这是后话了)。那咋办,总不能之前的问题都解决了,被这个问题打倒了吧。不行,绝对不行。于是开始尝试用xcode去调试metal渲染。https://developer.apple.com/documentation/xcode/capturing-a-metal-workload-in-xcode找到了苹果的这篇文章,按照文章调试,最后发现我们自定义渲染的流程很短

如图所示:就两步输出了。

然后看unity主视图渲染的:

流程貌似很长,而且在之前图片的颜色格式都是偏黄的那种(也就是和我们自定义渲染流程拿到的纹理图片是一样的)

中间经过了一连串处理后,纹理图像变成了unity主视图输出的那种偏白的

带着这个疑问,继续去找unity同事去对,叫其在unity运行下效果和我这边自定义渲染流程出来的图片对比下,看到底是哪里出了问题。最终发现unity那边运行的效果和我这边自定义渲染流程渲染出来的结果是一样的,也就是非主相机渲染出的图片比主相机场景渲染的要偏黄一点(也可以说主相机那边曝光的更白一点)。然后unity那边经过一些排查和测试。最终发现是主相机加了一个滤镜(没看错就是主相机加了一个滤镜,非主相机没加滤镜)。这也是为什么不管我这边怎么处理,拿到的非主相机渲染的纹理图片和主相机的纹理图片都是不一致的。所以安卓真的linear颜色格式转gamma颜色格式就能解决问题???对此我深表怀疑!(后续证明,安卓那边linear颜色格式转gamma颜色格式也不能解决问题。因为源数据输入都不一样,你处理方输出一样的话肯定是处理方输出有问题了)

没错,导致这一现象的根本原因是主相机加了一层滤镜,而其他相机没有加滤镜!最终unity主相机去除滤镜后原生端两者显示的效果就是一样的。。。。。。

六、优化

  1. iOS 禁止后台应用程序使用 GPU

现象

目前程序demo退到后台会打印以下日志

2023-09-04 11:55:45.086396+0800 UnityInter[95163:3600190] Execution of the command buffer was aborted due to an error during execution. Insufficient Permission (to submit GPU work from background) (00000006:kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted)

https://developer.apple.com/library/archive/documentation/Miscellaneous/Conceptual/MetalProgrammingGuide/Device/Device.html

Metal无法在后台执行Metal命令,因此Unity需要处理相关逻辑(性能优化着想,在后台也不应该进行纹理的刷新)

解决方案

  • unity端处理该逻辑

七、感触

只能说渲染这一块,涉及的东西太多了。拿到了纹理,仅仅是渲染这个纹理都要自定义渲染流程,如果说要针对渲染流程做一些自定义处理的话,怕是会(更坎坷、更难)。。。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容