1. 程式人生 > >iOS GPUImage原始碼解讀

iOS GPUImage原始碼解讀

前言

GPUImage是iOS上一個基於OpenGL進行影象處理的開源框架,內建大量濾鏡,架構靈活,可以在其基礎上很輕鬆地實現各種影象處理功能。本文主要向大家分享一下專案的核心架構、原始碼解讀及使用心得。

GPUImage有哪些特性

  1. 豐富的輸入元件 攝像頭、圖片、視訊、OpenGL紋理、二進位制資料、UIElement(UIView, CALayer)
  2. 大量現成的內建濾鏡(4大類) 1). 顏色類(亮度、色度、飽和度、對比度、曲線、白平衡...) 2). 影象類(仿射變換、裁剪、高斯模糊、毛玻璃效果...) 3). 顏色混合類(差異混合、alpha混合、遮罩混合...) 4). 效果類(畫素化、素描效果、壓花效果、球形玻璃效果...)
  3. 豐富的輸出元件 UIView、視訊檔案、GPU紋理、二進位制資料
  4. 靈活的濾鏡鏈 濾鏡效果之間可以相互串聯、並聯,呼叫管理相當靈活。
  5. 介面易用 濾鏡和OpenGL資源的建立及使用都做了統一的封裝,簡單易用,並且內建了一個cache模組實現了framebuffer的複用。
  6. 執行緒管理 OpenGLContext不是多執行緒安全的,GPUImage建立了專門的contextQueue,所有的濾鏡都會扔到統一的執行緒中處理。
  7. 輕鬆實現自定義濾鏡效果 繼承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的對映