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閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容