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