前言
最近做多路視頻的渲染,本文是其渲染方案的預研。
效果大概如下:
正文
一、多GPUImageView方案
用GPUImage進行多路視頻的渲染,有一個非常簡單的方案:多個GPUImageView方式,每路視頻畫面單獨渲染。
一路視頻對應一個濾鏡鏈,拿到視頻數據后進行裁剪,直接顯示到對應的GPUImageView上;多個GPUImageView組成多路視頻畫面,通過改變GPUImageView的坐標可以實現畫面拼接的效果。
方案很簡單,寫了一個demo,地址在這里。
demo用兩個mp4視頻作為源數據,用GPUImageMovie作為濾鏡鏈的起始,用GPUImageCropFilter對視頻進行裁剪,再經過GPUImageTransformFilter轉置,最終顯示在GPUImageView上。
這個方案的優缺點也很明顯:
優點:實現簡單,畫面拼接由UIKit層的API實現;
缺點:渲染到屏幕的次數增多,渲染頻率遠大于屏幕顯示幀率;
二、單GPUImageView方案
上面的方案最明顯的問題就是渲染到屏幕的次數比屏幕刷新的次數還多,那么自然延伸出來一種方案:只用一個GPUImageView,視頻統一渲染到GPUImageView。
GPUImageView顯示區域劃分成多個區域,每個區域對應一路視頻;多路視頻畫面都采用離屏渲染的方式,繪制到紋理的對應區域中,再由multiTexFilter進行處理;multiTexFilter集合多路視頻的渲染,如果沒有更新則使用上次的數據,再把紋理傳遞給GPUImageView,最終GPUImageView渲染到屏幕上;
方案較為復雜,同樣實現了demo,地址在這里。
demo用兩個mp4視頻作為源數據,仍然用GPUImageMovie作為濾鏡鏈的起始,再經過GPUImageCropFilter、GPUImageTransformFilter處理,交給LYMultiTextureFilter,最終顯示在GPUImageView上。
此方案的核心在于LYMultiTextureFilter類,我們來仔細分析下其構成:
/**
把多個Texture渲染到同一個FrameBuffer
使用時需要先綁定frameBuffer和顯示區域rect
在newFrame回調的時候,會根據index把圖像繪制在綁定的rect
特別的,會制定一個mainIndex,當此index就緒時調用newFrame通知響應鏈的下一個
*/
@interface LYMultiTextureFilter : GPUImageFilter
- (instancetype)initWithMaxFilter:(NSInteger)maxFilter;
/**
設置繪制區域rect,并且綁定紋理id
@param rect 繪制區域 origin是起始點,size是矩形大小;(取值范圍是0~1,點(0,0)表示左下角,點(1,1)表示右上角, Size(1,1)表示最大區域)
@param filterIndex 紋理id
*/
- (void)setDrawRect:(CGRect)rect atIndex:(NSInteger)filterIndex;
- (void)setMainIndex:(NSInteger)filterIndex;
LYMultiTextureFilter繼承自GPUImageFilter,通過-setDrawRect
綁定紋理的繪制區域;特別的,可以設置一個mainIndex,當此index就緒時調用newFrame通知濾鏡鏈的下一個目標。
具體實現有幾個注意事項:
1、通過頂點指定特別的渲染區域;
2、因為GPUImageFramebuffer默認會被復用,導致無法保存上一次的紋理,這里需要修改邏輯-renderToTextureWithVertices:
和-informTargetsAboutNewFrameAtTime
,使得LYMultiTextureFilter一直使用同一個GPUImageFramebuffer;
3、dealloc的時候需要釋放outputFramebuffer,避免內存泄露;
這個方案的特點:
優點:統一渲染,避免的渲染次數大于屏幕幀率;
缺點:multiTexFilter實現復雜,畫面拼接需要用shader來實現;
三、兩個方案的對比
通過對比兩個方案的CPU、GPU占比,分析性能。
CPU對比:如下。選中前30s的區間,singleImageView的方式有0.5s(大概2%)的微弱優勢。整個過程的cpu消耗波形類似,占比也大致相同。
GPU對比:方案1的GPU消耗比方案2的消耗更小!!!
難以置信,因為方案2是對方案1的優化,但是為何會占用更多的GPU?
通過instruments工具查看,方案1的presentRenderBuffer調用次數超過每秒100次,方案2在30次左右。
回顧方案1的設計:
GPUImageMovie => GPUImageCropFilter => GPUImageTransformFilter => GPUImageView
方案2的設計:
GPUImageMovie => GPUImageCropFilter => GPUImageTransformFilter => LYMultiTextureFilter => GPUImageView
方案2濾鏡鏈比方案1多了LYMultiTextureFilter,雖然調用presentRenderBuffer的次數少,但是明顯離屏渲染變多。相反,因為多LYMultiTextureFilter這個路徑,導致總的gl渲染指令變多,GPU消耗更大!!
究其原因,是因為作為數據源GPUImageMovie是不同步的。即使是方案2,多個GPUImageMovie的數據輸出也是不同步,渲染給LYMultiTextureFilter的次數同樣可能出現大于使用次數的情況。方案2相對方案1,同樣存在多余的渲染問題。
于是有了進一步優化的方案。
四、屏幕幀率驅動的單GPUImageView方案
先看一張大圖:
相對于之前的方案,這里引入CADisplayLink作為渲染的驅動,同時視頻數據只保持最新的一幀。如果每個cropFilter都能驅動multiTexFilter渲染,則multiTexFilter的渲染頻率最高會達到120FPS,浪費性能。
為了保證multiTexFilter繪制的幀率不超過30FPS,cropFilter渲染順序是1~N,最后以cropFilterN 作為multiTexFilter渲染的驅動。
當cropFilterN渲染完,就進行multiTexFilter的渲染,如果其他cropFilter沒有數據則使用上一幀數據。
cropFilter在渲染的時候從DataManager取數據,如果沒有數據則使用默認數據進行渲染。
為了方便對比,同樣實現了demo,地址在這里。
與方案2相比,有幾個不同點:
1、CADisplayLink驅動渲染,并且每次只讀取當前最新幀;
2、引入LYAssetReader,LYAssetReader是對AVAssetReader和AVAssetReaderTrackOutput的簡單封裝,使得能循環播放和讀取當前最新的視頻幀;
3、使用GPUImageMovie的-processMovieFrame
接口作為濾鏡鏈的輸入;
4、可以控制每次渲染中,cropFilter的渲染順序;
綜合考慮,暫定方案3--屏幕幀率驅動的單GPUImageView方案作為生產環境的渲染方案。
五、Demo實現過程的坑
1、幀緩存復用
有段時間沒有接觸GPUImage,導致demo開發過程遇到幾個坑,首先第一個是如何保證畫面渲染的連續。
在實現方案2的demo時,考慮到多路視頻渲染中可能某一路的視頻畫面沒有更新,比如說GPUImageMovie讀取視頻源數據較慢,此時GPUImageMovie對應的顯示區域就無法redraw,導致該區域的內容顯示異常,出現閃爍的效果。(GPUImage的幀緩存是復用的)
這里有兩種方案:
1、保留GPUImageMovie讀取的最后一幀信息,每次再傳給cropFilters進行渲染;
2、保留cropFilters上一次的渲染結果;
從性能優化的角度出發,保留上一次的渲染結果更為合理。
這里的實現需要對GPUImage以及OpenGL有所了解,保留渲染結果其實就是復用上一次的幀緩存,不調用glClear
進行清理;而GPUImage的outputFramebuffer在渲染完后會回收,所以需要一些修改,如下:
if (!outputFramebuffer) {
outputFramebuffer = [[GPUImageContext sharedFramebufferCache] fetchFramebufferForSize:[self sizeOfFBO] textureOptions:self.outputTextureOptions onlyTexture:NO];
glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, backgroundColorAlpha);
glClear(GL_COLOR_BUFFER_BIT);
}
else {
[outputFramebuffer lock];
}
僅在第一次的時候,為outputFramebuffer申請緩存;以后再次使用的時候,只需要進行lock即可。
這里有一個坑:GPUImageContext的fetchFramebufferForSize
會默認進行一次lock,在使用完之后會再unlock;如果在第二次使用outputFramebuffer的之后不進行lock,會導致retainCount<0的情況。
2、GPUImageMovie處理CMSampleBuffer
另外一個較大的坑是GPUImageMovie,方案3的Demo在實現過程中遇到一個必現Crash:在調用GPUImageMovie的-processMovieFrame
處理從視頻幀讀取的CMSampleBuffer時,會在convertYUVToRGBOutput
的glDrawArrays
報錯,錯誤類型是EXC_BAD_ACCESS,具體關鍵詞是"gleRunVertexSubmitARM"。
懷疑過紋理有異常、頂點數據有異常、處理線程不統一等導致,均不是原因。
最后通過二分大法,插入以下代碼定位到問題:
{
GLenum err = glGetError();
if (err != GL_NO_ERROR) {
printf("glError: %04x caught at %s:%u\n", err, __FILE__, __LINE__);
}
}
}
問題的核心在于GPUImageMovie *imageMovie = [[GPUImageMovie alloc] init];
GPUImageMovie的convertYUVToRGBOutput
需要用到數據在initWithUrl:
這個接口才有初始化!