與自定義濾鏡實戰(zhàn))
GPUImage3出來也挺久了但網(wǎng)上大部分教程還是圍繞它前代版本在寫很多朋友照著老文章遷移過來看了Metal幾個文件直接懵掉。我前后從舊版框架一路用到 GPUImage3中間還踩過不少坑這篇就把我梳理出來的框架骨干思路講明白不摳每一行源碼而是站在設(shè)計者角度看看整條渲染鏈?zhǔn)窃趺创饋淼摹槭裁催@代版本改動這么大、自己加濾鏡時應(yīng)該改哪里。如果你正準(zhǔn)備在視頻流上實時磨皮、做濾鏡鏈、接相機(jī)數(shù)據(jù)做 AR 特效或者只是想把一堆圖像濾鏡串成一條固定管線快速出 Demo這篇會比較對路?;A(chǔ)差一點也沒關(guān)系碰到概念我會用大白話拆開。1. 整體視角其實 GPUImage3 就是一條紋理流水線1.1 為什么這代版本要從頭重寫前代框架建立在 OpenGL ES 2.0 上每個濾鏡編譯出一段 GLSL 字符串運行期再拼 shader參數(shù)通過glUniform傳。這套方案在當(dāng)時的 iOS 上很成熟可跨平臺思路也一直受制于 OpenGL 的生態(tài)。蘋果把 Metal 推到全平臺之后舊框架的很多基礎(chǔ)組件已經(jīng)等不到合適時機(jī)升級OpenGL ES 被標(biāo)記廢棄、模擬器支持逐步轉(zhuǎn)向 Metal 渲染、現(xiàn)代 GPU 的特性也只有 Metal 能充分利用。與其打補丁不如推倒重來。GPUImage3 整個框架換到了 Metal 上這一點是理解一切的前提。Metal 是蘋果自家的圖形 API資源生命周期、渲染命令編碼、GPU 內(nèi)存管理全部直接可控。代價是框架不再像舊版那樣用字符串 shader 拼裝而是默認(rèn)所有濾鏡都從一組成熟的內(nèi)置 shader 函數(shù)里選自定義時也可以直接寫 Metal Shading Language。剛開始寫會覺得別扭但跑起來之后就明白這套機(jī)制的好處編譯期能查錯、運行期沒有字符串解析開銷、參數(shù)綁定有了統(tǒng)一模型。1.2 框架的三大抽象角色GPUImage3 把整條處理鏈抽象成三個角色Source圖像從哪里來、Operation/Filter圖像怎么變、Consumer圖像到哪里去。任何一塊圖像數(shù)據(jù)都是從 Source 出發(fā)途經(jīng)若干個 Filter最后落到 Consumer。你可以把這條鏈路想成一條流水線傳送帶移動的是紋理每個工位加工一次最終出品。在框架代碼里三者對應(yīng)的是ImageSource協(xié)議、ImageProcessingOperation操作協(xié)議以及ImageConsumer協(xié)議。大多數(shù)濾鏡類同時實現(xiàn)了處理操作和消費端協(xié)議也就是它自己既要跟前一個節(jié)點要數(shù)據(jù)又要給后一個節(jié)點供數(shù)據(jù)。正因如此鏈的中間節(jié)點才能自由拼接。這種抽象不是 GPUImage3 獨創(chuàng)但卻是它最值得先理解的骨架。你把一張圖片送進(jìn)一個濾鏡和一個 Metal 視圖鏈路看似簡單其實背后框架幫你管好了紋理創(chuàng)建、像素格式轉(zhuǎn)換、渲染命令提交和消費者回調(diào)。只要理清這三個角色看到任何帶addTarget想法的代碼都不會發(fā)怵因為邏輯始終只有一條數(shù)據(jù)從左到右流節(jié)點頭尾相連。2. 核心鏈路拆解紋理在框架里是怎么一路流的2.1 數(shù)據(jù)源頭相機(jī)、圖片文件、視頻文件先說數(shù)據(jù)的入口。過去我們處理視頻幀最麻煩的是拿到CVPixelBuffer之后要手工拷貝紋理。GPUImage3 里攝像頭、圖片文件、視頻文件都有對應(yīng)的 Source 類它們統(tǒng)一在內(nèi)部把數(shù)據(jù)轉(zhuǎn)成MTLTexture而且是延遲創(chuàng)建——只有下一個節(jié)點真正需要時紋理才會被包好傳過去。以相機(jī)為例Camera類負(fù)責(zé)封裝AVFoundation采集邏輯。拿到一幀之后它會做三件事把像素數(shù)據(jù)換成 GPU 可讀的紋理、記錄時間戳、按當(dāng)前連接關(guān)系找到所有下游消費者并觸發(fā)處理。整段流程是跑在 Metal 命令緩沖區(qū)里的不會在主線程同步做重活。攝像頭開始采集之前還要想好采集分辨率因為這會直接影響紋理大小紋理大小又會傳給下游每一級濾鏡。圖片文件作為輸入更簡單直接從本地讀取通過解碼得到像素數(shù)據(jù)再生成紋理。視頻文件的邏輯類似只是背后多了逐幀接管。用這類 Source 入門最穩(wěn)能先把鏈路調(diào)試通再換攝像頭。2.2 鏈中節(jié)點Filter 是怎么被串起來的Filter 是這條流水線上最核心的節(jié)點。每個濾鏡類至少有四個共同要素輸入紋理、輸出紋理、一個 Metal 渲染管線、一組參數(shù)綁定。一次處理動作本質(zhì)上就是把輸入紋理畫到輸出紋理上中間執(zhí)行自己那段片段著色器。串鏈的方式很直接源碼里最常見的就是let blurFilter BasicOperation(fragmentFunctionName: gaussianBlur) let lookUpFilter BasicOperation(fragmentFunctionName: colorLookup) camera.addTarget(blurFilter) blurFilter.addTarget(lookUpFilter) lookUpFilter.addTarget(renderView)每調(diào)用一次addTarget上游的紋理目標(biāo)列表里就多一個消費者。同一幀數(shù)據(jù)可以分流給多個下游節(jié)點這也就是自定義濾鏡和組合特效的基礎(chǔ)。注意一個細(xì)節(jié)處理鏈一旦建立紋理格式變化是逐級傳遞的某個濾鏡改成rgba8Unorm下游如果處理不了就得靠框架自動轉(zhuǎn)換??蚣苤С指袷睫D(zhuǎn)換節(jié)點但會帶來額外開銷能用一致的格式設(shè)計整條鏈路就盡量保持一致。2.3 出口渲染到屏幕、寫文件、取回 CPU 數(shù)據(jù)一條鏈最終要落在某個出口上。GPUImage3 提供的常見出口有三個渲染到MetalView、把渲染結(jié)果編碼成圖片或視頻、直接抓取紋理數(shù)據(jù)下來做后續(xù)計算。MetalView是最簡單的只要把鏈尾的addTarget指到它它內(nèi)部會在每幀拿到上游紋理后利用MTKView繪制到屏幕。需要注意視圖的生命周期由外部持有不要在想當(dāng)然的局部變量里創(chuàng)建視圖然后丟引用否則首幀之后直接黑屏。把鏈尾接到文件寫入時框架會把每一幀編碼進(jìn)AVAssetWriter這一步不是零成本的。寫文件前最好先確認(rèn)目標(biāo)文件格式、編碼參數(shù)以及是否需要實時預(yù)覽否則可能出現(xiàn)能預(yù)覽但寫不進(jìn)去的詭異問題。至于抓取 CPU 數(shù)據(jù)適合開發(fā)者做離線分析比如把某一幀處理結(jié)果下載成數(shù)組再去跑自己的圖像算法。2.4 一次典型流程從采集到預(yù)覽整條鏈全走一遍我把一個實際跑通的流程簡化放在這里新項目直接照著套就行l(wèi)et camera try Camera(sessionPreset: .hd1920x1080) let filter BasicOperation(fragmentFunctionName: saturation) let renderView MetalView(frame: CGRect(x: 0, y: 0, width: 720, height: 1280)) camera -- filter -- renderView camera.startCapture()這段代碼里--是框架提供的運算符重載執(zhí)行的就是addTarget。首次跑通之后你會看到視頻實時經(jīng)過飽和度濾鏡渲染在屏幕上。如果這時候在對每個環(huán)節(jié)做斷點你會發(fā)現(xiàn)紋理傳遞是異步的很多參數(shù)改完后不是立刻生效而是等下一幀入隊才生效。要驗證當(dāng)前幀的處理結(jié)果得通過出口抓取紋理光靠斷點看內(nèi)部狀態(tài)容易誤判。3. 著色器與參數(shù)框架怎么幫你搞定 GPU 驅(qū)動3.1 從字符串 shader 換到內(nèi)置函數(shù)庫舊版框架會把用戶寫的 GLSL 片段字符串反復(fù)拼接運行期編譯。這種做法的問題很明顯拼出來的代碼一旦有語法錯誤要等運行期才暴露變量名沖突、類型不匹配這類問題排查起來極其痛苦。GPUImage3 把大量基礎(chǔ)濾鏡都封裝成一套內(nèi)置腳本編譯發(fā)生在創(chuàng)建管線時而不是每一幀。所以你會發(fā)現(xiàn)框架里很多操作類初始化時只要傳一個函數(shù)名就行。這樣設(shè)計還有一個附帶好處同一個濾鏡函數(shù)可以被多個操作實例復(fù)用不同的參數(shù)通過 uniform 緩沖區(qū)分開GPU 不需要重復(fù)編譯。比如模糊濾鏡高斯模糊和普通模糊可能共用同一個函數(shù)只是切換了采樣半徑和權(quán)重參數(shù)。這正是 Metal 推薦的高效用法——管線復(fù)用參數(shù)保持獨立。3.2 uniform 的綁定邏輯與實時調(diào)參使用舊版框架時調(diào)參靠glUniform*系列函數(shù)每加一個參數(shù)就得手動寫綁定代碼。GPUImage3 的 uniform 機(jī)制把這件事收斂了參數(shù)以數(shù)組形式保存在每個操作實例中框架內(nèi)部根據(jù) shader 中聲明的 uniform 名稱自動匹配再通過一個統(tǒng)一的 buffer 傳到 GPU。我自己最常用的實時調(diào)參場景是做一個滑動條控制濾鏡強(qiáng)度。只要保證操作實例存在滑動過程中不斷修改參數(shù)值比如filter.uniformSettings[0] Float(slider.value)實際上框架并不是每一幀都重新上傳整個 uniform buffer而是會做臟標(biāo)記檢查參數(shù)變了才更新。理解這個機(jī)制調(diào)參時就不用糾結(jié)性能問題頻繁滑動也沒關(guān)系。需要注意的一點是不同濾鏡類對 uniform 的索引約定不同源碼注釋里通常會寫明 0 號參數(shù)代表什么。自己寫自定義濾鏡時最好在初始化方法里就把默認(rèn)值設(shè)好給后續(xù)接入的人省心。3.3 自定義濾鏡的正確打開方式自定義濾鏡要分兩種情況看一種是把多個內(nèi)置濾鏡組合另一種是自己寫完整 Metal 函數(shù)。組合是最省事的定義一個類內(nèi)部創(chuàng)建兩個基礎(chǔ)操作對象在初始化時把內(nèi)部節(jié)點串好再對外暴露自己的addTarget接口。這種方式適合快速原型缺點是多了一次不必要的紋理中轉(zhuǎn)實時性能要求高時得留意。自己寫 shader 的推薦姿勢是先找到最接近目標(biāo)功能的內(nèi)置函數(shù)復(fù)制出來修改不要從零開始寫全套渲染設(shè)置??蚣芾镆粋€濾鏡類的最小實現(xiàn)大致長這樣final class MyFilter: BasicOperation { init() { super.init(fragmentFunctionName: myFilterFragment) } }就這些。你只需要在 Metal 文件里寫好myFilterFragment再往這個類里加參數(shù)對象即可。真需要復(fù)雜功能時再去看那幾個能處理多輸入的 blend 濾鏡它們展示了如何接收多張紋理、如何做多重采樣這是進(jìn)階最值得參考的一批例子。4. 設(shè)計取舍為什么 GPUImage3 選擇這種構(gòu)建方式4.1 把渲染管線狀態(tài)集中管理Metal 里的MTLRenderPipelineState是 GPU 干活前必須準(zhǔn)備好的渲染配置。GPUImage3 用一個統(tǒng)一機(jī)制管理這些狀態(tài)對象它按濾鏡對應(yīng)的函數(shù)名、紋理格式和像素格式生成一個描述符然后緩存到內(nèi)部字典里。同一函數(shù)名的濾鏡實例多了也不會重復(fù)創(chuàng)建管線。這帶來一個工程上的好處即使同時存在十幾個濾鏡每個濾鏡渲染前的狀態(tài)切換也是可預(yù)期的。相比之下如果每個濾鏡自己各自管理管線對象內(nèi)存碎片和緩存命中問題會非常突出。你現(xiàn)在有點編輯器的感覺把濾鏡鏈當(dāng)成了一個隨時可重組的嵌套結(jié)構(gòu)而底層管線盡量保持穩(wěn)定。4.2 分塊渲染省內(nèi)存不只是靠 GC手機(jī) GPU 顯存是稀缺資源尤其是 4K 分辨率的視頻鏈路里每一步都可能產(chǎn)生一整張全屏紋理十幾級濾鏡下去峰值內(nèi)存能輕松爆掉。GPUImage3 引入了分塊渲染tiling作為核心策略不把整張輸出紋理一次性畫完而是把目標(biāo)區(qū)域切分成小塊每個塊畫完后立刻重用內(nèi)存。這相當(dāng)于把流水線變成了“小批作業(yè)”。對 CPU 側(cè)代碼來說邏輯更復(fù)雜但換來的收益是終于能在中端手機(jī)上跑非常高分辨率的實時鏈。開發(fā)者可以在創(chuàng)建視圖或輸出時設(shè)置分塊行為默認(rèn)值通常適合多數(shù)場景。自定義濾鏡時如果發(fā)現(xiàn)內(nèi)存暴漲優(yōu)先檢查是不是某些操作不支持分塊那種操作會強(qiáng)迫框架暫時關(guān)閉分塊優(yōu)化。4.3 調(diào)度與異步為什么很多回調(diào)不是直接給數(shù)據(jù)GPUImage3 把 Metal 命令提交和 GPU 執(zhí)行做成異步化所以有些同學(xué)會疑惑“我明明在下一行代碼才調(diào)用 filter怎么數(shù)據(jù)還沒出來”一切都好解釋命令進(jìn)了隊列真正執(zhí)行發(fā)生在合適的時間點??蚣軆?nèi)部有一個串行隊列保證紋理訪問有序同時多個濾鏡可以并行各自處理不相干的任務(wù)。不過并行不是隨意來的每條鏈上的依賴關(guān)系不能亂所以框架對外暴露的大部分操作還是保持在鏈?zhǔn)秸{(diào)用下工作。你只需要記住一個原則不要在渲染線程里做耗時阻塞的事不要在回調(diào)里同步讀寫主線程 UI遇到黑屏先懷疑是不是某個對象被提前釋放了。5. 實操場景從零配置到第一幀渲染的成功路徑5.1 集成方式的選擇與依賴配置GPUImage3 支持通過標(biāo)準(zhǔn)包管理工具集成老項目也能遷。第一次接入時最常踩的坑是模擬器上跑Metal 功能大部分沒問題但有部分高級特性在模擬器上缺失如果真機(jī)正常模擬器黑屏先別改代碼。集成過程本身不難把倉庫鏈接加進(jìn)依賴文件import GPUImage就好。關(guān)鍵是注意最低系統(tǒng)版本Metal 框架的完整能力需要對應(yīng)系統(tǒng)版本配合版本不足時編譯能過但運行時 API 會崩。5.2 第一個實時濾鏡 Demo靜態(tài)圖和相機(jī)分別跑通建議第一步先用本地靜態(tài)圖驗證鏈路代碼大概是這樣let input PictureInput(url: imageURL) let filter BasicOperation(fragmentFunctionName: contrast) let output try! PictureOutput() input -- filter -- output output.encodedImageAvailable() { image, _ in // 拿到處理后圖片再做后續(xù)展示 } input.processImage()靜態(tài)圖沒有相機(jī)權(quán)限和采集時序的問題適合排查濾鏡本身是否正確。跑通之后把手頭處理邏輯原樣替換成Camera只改入口和出口鏈路代碼不用動。這個替換本身就是一個很好的框架理解測試Source 換成相機(jī)Consumer 換成渲染視圖中間所有 Filter 節(jié)點完全復(fù)用。5.3 首幀驗證必查清單第一次看到畫面后別急著順手就提交代碼按這個清單自查攝像頭方向是不是反的紋理方向約定在不同源之間有差異。濾鏡強(qiáng)度參數(shù)初值是否合理滑動條從 0 開始容易看到無變化。內(nèi)存變化是否陡增實時鏈長時間跑下來有沒有持續(xù)上漲。后臺切回前臺時畫面是否凍結(jié)App 進(jìn)入后臺前是否正確暫停采集和渲染。這幾個方向是實時渲染項目最常見的排查起點提前自查能省好幾輪聯(lián)調(diào)。6. 恩幾個讓我調(diào)得最久的坑6.1 黑屏但代碼沒報錯管線未就緒有一次我把模擬器上跑通的項目拿到真機(jī)直接黑屏。真機(jī)日志沒輸出斷點也不走。最后發(fā)現(xiàn)是某個紋理格式與渲染目標(biāo)不匹配Metal 在提交時靜默失敗畫面不會渲染。遇到這種“無日志黑屏”先別懷疑業(yè)務(wù)邏輯把紋理格式徹底排查一遍。在管線上打印中間紋理的pixelFormat通常能立刻定位。6.2 加了濾鏡后 CPU 占用突然飆升有一版我處理視頻是直接把每幀經(jīng)過濾鏡后的紋理用readPixels方法拿回 CPU結(jié)果內(nèi)存和 CPU 同時飆升。原因在于這個操作強(qiáng)制同步等待 GPU等于把一個像輸液管一樣順暢的異步管線變成了每幀都要等結(jié)果的同步系統(tǒng)。解決方案是只在需要取結(jié)果的幀上觸發(fā)抓取或者改用異步回調(diào)接口。6.3 鏈路上某個濾鏡節(jié)點只能帶慢動作排查性能問題時靠二分法最有效把鏈路從中間拆掉一半看速度是否恢復(fù)。有一次發(fā)現(xiàn)慢在模糊濾鏡因為它的采樣半徑設(shè)得過大。由于大半徑模糊需要大量紋理采樣GPU 開銷是平方級增長不是自己想當(dāng)然的線性增長。減少半徑或者改用小圖加縮放組合性能立刻回來。6.4 實時鏈路常見問題速查表癥狀可能原因排查建議黑屏管線狀態(tài)不匹配、紋理格式錯誤打印 pipeline 描述符、檢查紋理格式畫面凍結(jié)后臺恢復(fù)未重啟渲染監(jiān)聽前后臺通知并重新開始采集CPU 高同步讀取像素改用異步回調(diào)取消主線程readPixels濾鏡無效果uniform 索引綁定錯誤核對源碼注釋確認(rèn)參數(shù)序號內(nèi)存猛漲分塊渲染被禁用檢查濾鏡是否支持分塊拆分大鏈掉幀嚴(yán)重某節(jié)點采樣負(fù)載過大二分鏈路定位節(jié)點降低半徑/分級處理排查邏輯固定下來之后做任何 GPUImage3 項目都會順手很多。我個人在實際項目里的體會是這類框架的源碼讀一遍記不住但只要把“紋理流通、鏈?zhǔn)狡唇?、異步?zhí)行、格式一致”這四件事刻在腦子里遇到問題就不會毫無頭緒。想深入的同學(xué)下一步可以選一條自帶濾鏡去跟它的初始化方法看看管線描述符是怎么從函數(shù)名生成出來的那會比看任何二次加工的視頻教程都有用。