iOS GPUImage原始碼解讀
前言
GPUImage是iOS上一個基於OpenGL進行影象處理的開源框架,內建大量濾鏡,架構靈活,可以在其基礎上很輕鬆地實現各種影象處理功能。本文主要向大家分享一下專案的核心架構、原始碼解讀及使用心得。
GPUImage有哪些特性
- 豐富的輸入元件 攝像頭、圖片、視訊、OpenGL紋理、二進位制資料、UIElement(UIView, CALayer)
- 大量現成的內建濾鏡(4大類) 1). 顏色類(亮度、色度、飽和度、對比度、曲線、白平衡...) 2). 影象類(仿射變換、裁剪、高斯模糊、毛玻璃效果...) 3). 顏色混合類(差異混合、alpha混合、遮罩混合...) 4). 效果類(畫素化、素描效果、壓花效果、球形玻璃效果...)
- 豐富的輸出元件 UIView、視訊檔案、GPU紋理、二進位制資料
- 靈活的濾鏡鏈 濾鏡效果之間可以相互串聯、並聯,呼叫管理相當靈活。
- 介面易用 濾鏡和OpenGL資源的建立及使用都做了統一的封裝,簡單易用,並且內建了一個cache模組實現了framebuffer的複用。
- 執行緒管理 OpenGLContext不是多執行緒安全的,GPUImage建立了專門的contextQueue,所有的濾鏡都會扔到統一的執行緒中處理。
- 輕鬆實現自定義濾鏡效果 繼承GPUImageFilter自動獲得上面全部特性,無需關注上下文的環境搭建,專注於效果的核心演算法實現即可。
基本用法
// 獲取一張圖片 UIImage *inputImage = [UIImage imageNamed:@"sample.jpg"]; // 建立圖片輸入元件GPUImagePicture *sourcePicture = [[GPUImagePicture alloc] initWithImage:inputImage smoothlyScaleOutput:YES]; // 建立素描濾鏡 GPUImageSketchFilter *customFilter = [[GPUImageSketchFilter alloc] init]; // 把素描濾鏡串聯在圖片輸入元件之後 [sourcePicture addTarget:customFilter]; // 建立ImageView輸出元件GPUImageView *imageView = [[GPUImageView alloc]initWithFrame:mainScreenFrame]; [self.view addSubView:imageView]; // 把ImageView輸出元件串在濾鏡鏈末尾[customFilter addTarget:imageView]; // 呼叫圖片輸入元件的process方法,渲染結果就會繪製到imageView上[sourcePicture processImage];
效果如圖:
整個框架的目錄結構
核心架構
基本上每個濾鏡都繼承自GPUImageFilter; 而GPUImageFilter作為整套框架的核心; 接收一個GPUImageFrameBuffer輸入; 呼叫GLProgram渲染處理; 輸出一個GPUImageFrameBuffer; 把輸出的GPUImageFrameBuffer傳給通過targets屬性關聯的下級濾鏡; 直到傳遞至最終的輸出元件;
核心架構可以整體劃分為三塊:輸入、濾鏡處理、輸出 接下來我們就深入原始碼,看看GPUImage是如何獲取資料、傳遞資料、處理資料和輸出資料的
獲取資料
GPUImage提供了多種不同的輸入元件,但是無論是哪種輸入源,獲取資料的本質都是把影象資料轉換成OpenGL紋理。這裡就以視訊拍攝元件(GPUImageVideoCamera)為例,來講講GPUImage是如何把每幀取樣資料傳入到GPU的。
GPUImageVideoCamera裡大部分程式碼都是對攝像頭的呼叫管理,不瞭解的同學可以去學習一下AVFoundation(傳送門)。攝像頭拍攝過程中每一幀都會有一個數據回撥,在GPUImageVideoCamera中對應的處理回撥的方法為:
- (void)processVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer;
iOS的每一幀攝像頭取樣資料都會封裝成CMSampleBufferRef; CMSampleBufferRef除了包含影象資料、還包含一些格式資訊、影象寬高、時間戳等額外屬性; 攝像頭預設的取樣格式為YUV420,關於YUV格式大家可以自行搜尋學習一下(傳送門):
YUV420按照資料的儲存方式又可以細分成若干種格式,這裡主要是kCVPixelFormatType_420YpCbCr8BiPlanarFullRange和kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange兩種;
兩種格式都是planar型別的儲存方式,y資料和uv資料分開放在兩個plane中; 這樣的資料沒法直接傳給GPU去用,GPUImageVideoCamera把兩個plane的資料分別取出:
- (void)processVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer { // 一大坨的程式碼用於獲取取樣資料的基本屬性(寬、高、格式等等) ...... if ([GPUImageContext supportsFastTextureUpload] && captureAsYUV) { CVOpenGLESTextureRef luminanceTextureRef = NULL; CVOpenGLESTextureRef chrominanceTextureRef = NULL; if (CVPixelBufferGetPlaneCount(cameraFrame) > 0) // Check for YUV planar inputs to do RGB conversion { ...... // 從cameraFrame的plane-0提取y通道的資料,填充到luminanceTextureRef glActiveTexture(GL_TEXTURE4); err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, [[GPUImageContext sharedImageProcessingContext] coreVideoTextureCache], cameraFrame, NULL, GL_TEXTURE_2D, GL_LUMINANCE, bufferWidth, bufferHeight, GL_LUMINANCE, GL_UNSIGNED_BYTE, 0, &luminanceTextureRef); ...... // 從cameraFrame的plane-1提取uv通道的資料,填充到chrominanceTextureRef glActiveTexture(GL_TEXTURE5); err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, [[GPUImageContext sharedImageProcessingContext] coreVideoTextureCache], cameraFrame, NULL, GL_TEXTURE_2D, GL_LUMINANCE_ALPHA, bufferWidth/2, bufferHeight/2, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, 1, &chrominanceTextureRef); ...... // 把luminance和chrominance作為2個獨立的紋理傳入GPU [self convertYUVToRGBOutput]; ...... } } else { ...... } }
注意CVOpenGLESTextureCacheCreateTextureFromImage中對於internalFormat的設定; 通常我們建立一般紋理的時候都會設成GL_RGBA,傳入的影象資料也會是rgba格式的; 而這裡y資料因為只包含一個通道,所以設成了GL_LUMINANCE(灰度圖); uv資料則包含2個通道,所以設成了GL_LUMINANCE_ALPHA(帶alpha的灰度圖); 另外uv紋理的寬高只設成了影象寬高的一半,這是因為yuv420中,每個相鄰的2x2格子共用一份uv資料; 資料傳到GPU紋理後,再通過一個顏色轉換(yuv->rgb)的shader(shader是OpenGL可程式設計著色器,可以理解為GPU側的程式碼,關於shader需要一些OpenGL程式設計基礎(傳送門)),繪製到目標紋理:
// fullrange varying highp vec2 textureCoordinate; uniform sampler2D luminanceTexture; uniform sampler2D chrominanceTexture; uniform mediump mat3 colorConversionMatrix; void main() { mediump vec3 yuv; lowp vec3 rgb; yuv.x = texture2D(luminanceTexture, textureCoordinate).r; yuv.yz = texture2D(chrominanceTexture, textureCoordinate).ra - vec2(0.5, 0.5); rgb = colorConversionMatrix * yuv; gl_FragColor = vec4(rgb, 1); }
// videorange varying highp vec2 textureCoordinate; uniform sampler2D luminanceTexture; uniform sampler2D chrominanceTexture; uniform mediump mat3 colorConversionMatrix; void main() { mediump vec3 yuv; lowp vec3 rgb; yuv.x = texture2D(luminanceTexture, textureCoordinate).r - (16.0/255.0); yuv.yz = texture2D(chrominanceTexture, textureCoordinate).ra - vec2(0.5, 0.5); rgb = colorConversionMatrix * yuv; gl_FragColor = vec4(rgb, 1); }
注意yuv420fullrange和yuv420videorange的數值範圍是不同的,因此轉換公式也不同,這裡會有2個顏色轉換shader,根據實際的取樣格式選擇正確的shader; 渲染輸出到目標紋理後就得到一個轉換成rgb格式的GPU紋理,完成了獲取輸入資料的工作;
傳遞資料
GPUImage的影象處理過程,被設計成了濾鏡鏈的形式;輸入元件、效果濾鏡、輸出元件串聯在一起,每次推動渲染的時候,輸入資料就會按順序傳遞,經過處理,最終輸出。
GPUImage設計了一個GPUImageInput協議,定義了GPUImageFilter之間傳入資料的方法:
- (void)setInputFramebuffer:(GPUImageFramebuffer *)newInputFramebuffer atIndex:(NSInteger)textureIndex { firstInputFramebuffer = newInputFramebuffer; [firstInputFramebuffer lock]; }
firstInputFramebuffer屬性用來儲存輸入紋理; GPUImageFilter作為單輸入濾鏡基類遵守了GPUImageInput協議,GPUImage還提供了GPUImageTwoInputFilter, GPUImageThreeInputFilter等多輸入filter的基類。
這裡還有一個很重要的入口方法用於推動資料流轉:
- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex { ...... [self renderToTextureWithVertices:imageVertices textureCoordinates:[[self class] textureCoordinatesForRotation:inputRotation]]; [self informTargetsAboutNewFrameAtTime:frameTime]; }
每個濾鏡都是由這個入口方法開始啟動,這個方法包含2個呼叫 1). 首先呼叫render方法進行效果渲染 2). 呼叫informTargets方法將渲染結果推到下級濾鏡
GPUImageFilter繼承自GPUImageOutput,定義了輸出資料,向後傳遞的方法:
- (void)notifyTargetsAboutNewOutputTexture;
但是這裡比較奇怪的是濾鏡鏈的傳遞實際並沒有用notifyTargets方法,而是用了前面提到的informTargets方法:
- (void)informTargetsAboutNewFrameAtTime:(CMTime)frameTime { ...... // Get all targets the framebuffer so they can grab a lock on it for (id<GPUImageInput> currentTarget in targets) { if (currentTarget != self.targetToIgnoreForUpdates) { NSInteger indexOfObject = [targets indexOfObject:currentTarget]; NSInteger textureIndex = [[targetTextureIndices objectAtIndex:indexOfObject] integerValue]; [self setInputFramebufferForTarget:currentTarget atIndex:textureIndex]; [currentTarget setInputSize:[self outputFrameSize] atIndex:textureIndex]; } } ...... // Trigger processing last, so that our unlock comes first in serial execution, avoiding the need for a callback for (id<GPUImageInput> currentTarget in targets) { if (currentTarget != self.targetToIgnoreForUpdates) { NSInteger indexOfObject = [targets indexOfObject:currentTarget]; NSInteger textureIndex = [[targetTextureIndices objectAtIndex:indexOfObject] integerValue]; [currentTarget newFrameReadyAtTime:frameTime atIndex:textureIndex]; } } }
GPUImageOutput定義了一個targets屬性來儲存下一級濾鏡,這裡可以注意到targets是個陣列,因此濾鏡鏈也支援並聯結構。可以看到這個方法主要做了2件事情: 1). 對每個target呼叫setInputFramebuffer方法把自己的渲染結果傳給下級濾鏡作為輸入 2). 對每個target呼叫newFrameReadyAtTime方法推動下級濾鏡啟動渲染 濾鏡之間通過targets屬性相互銜接串在一起,完成了資料傳遞工作。
處理資料
前面提到的renderToTextureWithVertices:方法便是每個濾鏡必經的渲染入口。 每個濾鏡都可以設定自己的shader,重寫該渲染方法,實現自己的效果:
- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates { ...... [GPUImageContext setActiveShaderProgram:filterProgram]; outputFramebuffer = [[GPUImageContext sharedFramebufferCache] fetchFramebufferForSize:[self sizeOfFBO] textureOptions:self.outputTextureOptions onlyTexture:NO]; [outputFramebuffer activateFramebuffer]; ...... [self setUniformsForProgramAtIndex:0]; glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, backgroundColorAlpha); glClear(GL_COLOR_BUFFER_BIT); glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_2D, [firstInputFramebuffer texture]); glUniform1i(filterInputTextureUniform, 2); glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, vertices); glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, textureCoordinates); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); ...... }
上面這個是GPUImageFilter的預設方法,大致做了這麼幾件事情: 1). 向frameBufferCache申請一個outputFrameBuffer 2). 將申請得到的outputFrameBuffer啟用並設為渲染物件 3). glClear清除畫布 4). 設定輸入紋理 5). 傳入頂點 6). 傳入紋理座標 7). 呼叫繪製方法
再來看看GPUImageFilter使用的預設shader:
// vertex shader attribute vec4 position; attribute vec4 inputTextureCoordinate; varying vec2 textureCoordinate; void main() { gl_Position = position; textureCoordinate = inputTextureCoordinate.xy; }
// fragment shader varying highp vec2 textureCoordinate; uniform sampler2D inputImageTexture; void main() { gl_FragColor = texture2D(inputImageTexture, textureCoordinate); }
這個shader實際上啥也沒做,VertexShader(頂點著色器)就是把傳入的頂點座標和紋理座標原樣傳給FragmentShader,FragmentShader(片段著色器)就是從紋理取出原始色值直接輸出,最終效果就是把圖片原樣渲染到畫面。
輸出資料
比較常用的主要是GPUImageView和GPUImageMovieWriter。
GPUImageView繼承自UIView,用於實時預覽,用法非常簡單 1). 建立GPUImageView 2). 串入濾鏡鏈 3). 插到視圖裡去 UIView的contentMode、hidden、backgroundColor等屬性都可以正常使用 裡面比較關鍵的方法主要有這麼2個:
// 申明自己的CALayer為CAEAGLLayer+ (Class)layerClass { return [CAEAGLLayer class]; }
- (void)createDisplayFramebuffer { [GPUImageContext useImageProcessingContext]; glGenFramebuffers(1, &displayFramebuffer); glBindFramebuffer(GL_FRAMEBUFFER, displayFramebuffer); glGenRenderbuffers(1, &displayRenderbuffer); glBindRenderbuffer(GL_RENDERBUFFER, displayRenderbuffer); [[[GPUImageContext sharedImageProcessingContext] context] renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer*)self.layer]; GLint backingWidth, backingHeight; glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backingWidth); glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backingHeight); ...... glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, displayRenderbuffer); ...... }
建立frameBuffer和renderBuffer時把renderBuffer和CALayer關聯在一起; 這是iOS內建的一種GPU渲染輸出的聯動方法; 這樣newFrameReadyAtTime渲染過後畫面就會輸出到CALayer。
GPUImageMovieWriter主要用於將視訊輸出到磁碟; 裡面大量的程式碼都是在設定和使用AVAssetWriter,不瞭解的同學還是得去看AVFoundation; 這裡主要是重寫了newFrameReadyAtTime:方法:
- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex { ...... GPUImageFramebuffer *inputFramebufferForBlock = firstInputFramebuffer; glFinish(); runAsynchronouslyOnContextQueue(_movieWriterContext, ^{ ...... // Render the frame with swizzled colors, so that they can be uploaded quickly as BGRA frames [_movieWriterContext useAsCurrentContext]; [self renderAtInternalSizeUsingFramebuffer:inputFramebufferForBlock]; CVPixelBufferRef pixel_buffer = NULL; if ([GPUImageContext supportsFastTextureUpload]) { pixel_buffer = renderTarget; CVPixelBufferLockBaseAddress(pixel_buffer, 0); } else { CVReturn status = CVPixelBufferPoolCreatePixelBuffer (NULL, [assetWriterPixelBufferInput pixelBufferPool], &pixel_buffer); if ((pixel_buffer == NULL) || (status != kCVReturnSuccess)) { CVPixelBufferRelease(pixel_buffer); return; } else { CVPixelBufferLockBaseAddress(pixel_buffer, 0); GLubyte *pixelBufferData = (GLubyte *)CVPixelBufferGetBaseAddress(pixel_buffer); glReadPixels(0, 0, videoSize.width, videoSize.height, GL_RGBA, GL_UNSIGNED_BYTE, pixelBufferData); } } ...... [assetWriterPixelBufferInput appendPixelBuffer:pixel_buffer]; ...... }); }
這裡有幾個地方值得注意: 1). 在取資料之前先調了一下glFinish,CPU和GPU之間是類似於client-server的關係,CPU側呼叫OpenGL命令後並不是同步等待OpenGL完成渲染再繼續執行的,而glFinish命令可以確保OpenGL把佇列中的命令都渲染完再繼續執行,這樣可以保證後面取到的資料是正確的當次渲染結果。 2). 取資料時用了supportsFastTextureUpload判斷,這是個從iOS5開始支援的一種CVOpenGLESTextureCacheRef和CVImageBufferRef的對映(對映的建立可以參看獲取資料中的CVOpenGLESTextureCacheCreateTextureFromImage),通過這個對映可以直接拿到CVPixelBufferRef而不需要再用glReadPixel來讀取資料,這樣效能更好。
最後歸納一下本文涉及到的知識點
1. AVFoundation 攝像頭呼叫、輸出視訊都會用到AVFoundation 2. YUV420 視訊採集的資料格式 3. OpenGL shader GPU的可程式設計著色器 4. CAEAGLLayer iOS內建的GPU到螢幕的聯動方法 5. fastTextureUpload iOS5開始支援的一種CVOpenGLESTextureCacheRef和CVImageBufferRef的對映