實用 WebGL 圖像處理入門
- 2020 年 2 月 23 日
- 筆記
技術社區里有種很有意思的現象,那就是不少人們口耳相傳中的強大技術,往往因為上手難度高而顯得曲高和寡。從這個角度看來,WebGL 和函數式編程有些類似,都屬於優勢已被論證了多年,卻一直較為不溫不火的技術。但是,一旦這些技術的易用性跨越了某個臨界點,它們其實並沒有那麼遙不可及。這次我們就將以 WebGL 為例,嘗試降低它的入門門檻,講解它在前端圖像處理領域的應用入門。
臨近 2020 年的今天,社區里已經有了許多 WebGL 教程。為什麼還要另起爐灶再寫一篇呢?這來自於筆者供職的稿定科技前端團隊,在 WebGL 基礎庫層面進行技術創新的努力。前一段時間,我們開源了自主研發的 WebGL 基礎庫 Beam。它以 10KB 不到的體積,將傳統上入門時動輒幾百行的 WebGL 渲染邏輯降低到了幾十行的量級,並在性能上也毫不妥協。開源兩周內,Beam 的 Star 數量就達到了 GitHub 全站 WebGL Library 搜索條目下的前 10%,在國內也還沒有定位相當的競品。這次我們將藉助 Beam 來編寫 WebGL 渲染邏輯,用精鍊的代碼和概念告訴大家,該如何硬核而不失優雅地手動操控 GPU 渲染管線,實現多樣的前端圖像處理能力。
本文將覆蓋的內容如下所示。我們希望能帶着感興趣的同學從零基礎入門,直通具備實用價值的圖像濾鏡能力開發:
- WebGL 概念入門
- WebGL 示例入門
- 如何用 WebGL 渲染圖像
- 如何為圖像增加濾鏡
- 如何疊加多個圖像
- 如何組合多個濾鏡
- 如何引入 3D 效果
- 如何封裝自定渲染器
為了照顧沒有基礎的同學,在進入實際的圖像處理部分前,我們會重新用 Beam 入門一遍 WebGL。熟悉相關概念的同學可以直接跳過這些部分。
WebGL 概念入門
Beam 的一個設計目標,是讓使用者即便沒有相關經驗,也能靠它快速搞懂 WebGL。但這並不意味着它像 Three.js 那樣可以幾乎完全不用懂圖形學,拿來就是一把梭。相比之下,Beam 選擇對 WebGL 概念做高度的抽象。在學習理解這些概念後,你就不僅能理解 GPU 渲染管線,還能用簡單的代碼來操作它了。畢竟這篇文章本身,也是本着授人以漁的理念來寫作的。
本節來自 如何設計一個 WebGL 基礎庫 一文,熟悉的同學可跳過。
WebGL 體系有很多瑣碎之處,一頭扎進代碼里,容易使我們只見樹木不見森林。然而我們真正需要關心的概念,其實可以被高度濃縮為這幾個:
- Shader 着色器,是存放圖形算法的對象。相比於在 CPU 上單線程執行的 JS 代碼,着色器在 GPU 上並行執行,計算出每幀數百萬個像素各自的顏色。
- Resource 資源,是存放圖形數據的對象。就像 JSON 成為 Web App 的數據那樣,資源是傳遞給着色器的數據,包括大段的頂點數組、紋理圖像,以及全局的配置項等。
- Draw 繪製,是選好資源後運行着色器的請求。要想渲染真實際的場景,一般需要多組着色器與多個資源,來回繪製多次才能完成一幀。每次繪製前,我們都需要選好着色器,並為其關聯好不同的資源,也都會啟動一次圖形渲染管線。
- Command 命令,是執行繪製前的配置。WebGL 是非常有狀態的。每次繪製前,我們都必須小心地處理好狀態機。這些狀態變更就是通過命令來實現的。Beam 基於一些約定大幅簡化了人工的命令管理,當然你也可以定製自己的命令。
這些概念是如何協同工作的呢?請看下圖:
圖中的 Buffers / Textures / Uniforms 都屬於典型的資源(後面會詳述它們各自的用途)。一幀當中可能存在多次繪製,每次繪製都需要着色器和相應的資源。在繪製之間,我們通過命令來管理好 WebGL 的狀態。僅此而已。
理解這個思維模型很重要。因為 Beam 的 API 設計就是完全依據這個模型而實現的。讓我們進一步看看一個實際的場景吧:
圖中我們繪製了很多質感不同的球體。這一幀的渲染,則可以這樣解構到上面的這些概念下:
- 着色器無疑就是球體質感的渲染算法。對經典的 3D 遊戲來說,要渲染不同質感的物體,經常需要切換不同的着色器。但現在基於物理的渲染算法流行後,這些球體也不難做到使用同一個着色器來渲染。
- 資源包括了大段的球體頂點數據、材質紋理的圖像數據,以及光照參數、變換矩陣等配置項。
- 繪製是分多次進行的。我們選擇每次繪製一個球體,而每次繪製也都會啟動一次圖形渲染管線。
- 命令則是相鄰的球體繪製之間,所執行的那些狀態變更。
如何理解狀態變更呢?不妨將 WebGL 想像成一個具備大量開關與接口的儀器。每次按下啟動鍵(執行繪製)前。你都要配置好一堆開關,再連接好一條接着色器的線,和一堆接資源的線,就像這樣:
還有很重要的一點,那就是雖然我們已經知道,一幀畫面可以通過多次繪製而生成,而每次繪製又對應執行一次圖形渲染管線的執行。但是,所謂的圖形渲染管線又是什麼呢?這對應於這張圖:
渲染管線,一般指的就是這樣一個 GPU 上由頂點數據到像素的過程。對現代 GPU 來說,管線中的某些階段是可編程的。WebGL 標準里,這對應於圖中藍色的頂點着色器和片元着色器階段。你可以把它們想像成兩個需要你寫 C-style 代碼,跑在 GPU 上的函數。它們大體上分別做這樣的工作:
- 頂點着色器輸入原始的頂點坐標,輸出經過你計算出的坐標。
- 片元着色器輸入一個像素位置,輸出根據你計算出的像素顏色。
下面,我們將進一步講解如何應用這些概念,搭建出一個完整的 WebGL 入門示例。
WebGL 示例入門
本節同樣來自 如何設計一個 WebGL 基礎庫 一文,但為承接後續的圖像處理內容,敘述有所調整。
在苦口婆心的概念介紹後,就要來到真刀真槍的編碼階段了。由於四大概念中的命令可以被自動化,我們只為 Beam 定義了三個核心 API,分別是:
- beam.shader
- beam.resource
- beam.draw
顯然地,它們各自管理着色器、資源和繪製。讓我們看看怎樣基於 Beam,來繪製 WebGL 中的 Hello World 彩色三角形吧:
三角形是最簡單的多邊形,而多邊形則是由頂點組成的。WebGL 中的這些頂點是有序排列,可通過下標索引的。以三角形和矩形為例,這裡使用的頂點順序如下所示:
Beam 的代碼示例如下,壓縮後全部代碼體積僅有 6KB:
import { Beam, ResourceTypes } from 'beam-gl' import { MyShader } from './my-shader.js' const { VertexBuffers, IndexBuffer } = ResourceTypes const canvas = document.querySelector('canvas') const beam = new Beam(canvas) const shader = beam.shader(MyShader) const vertexBuffers = beam.resource(VertexBuffers, { position: [ -1, -1, 0, // vertex 0 左下角 0, 1, 0, // vertex 1 頂部 1, -1, 0 // vertex 2 右下角 ], color: [ 1, 0, 0, // vertex 0 紅色 0, 1, 0, // vertex 1 綠色 0, 0, 1 // vertex 2 藍色 ] }) const indexBuffer = beam.resource(IndexBuffer, { array: [0, 1, 2] // 由 0 1 2 號頂點組成的三角形 }) beam .clear() .draw(shader, vertexBuffers, indexBuffer)
下面逐個介紹一些重要的 API 片段。首先自然是初始化 Beam 了:
const canvas = document.querySelector('canvas') const beam = new Beam(canvas)
然後我們用 beam.shader
來實例化着色器,這裡的 MyShader
稍後再說:
const shader = beam.shader(MyShader)
着色器準備好之後,就是準備資源了。為此我們需要使用 beam.resource
API 來創建三角形的數據。這些數據裝在不同的 Buffer 里,而 Beam 使用 VertexBuffers
類型來表達它們。三角形有 3 個頂點,每個頂點有兩個屬性 (attribute),即 position 和 color,每個屬性都對應於一個獨立的 Buffer。這樣我們就不難用普通的 JS 數組(或 TypedArray)來聲明這些頂點數據了。Beam 會替你將它們上傳到 GPU:
注意區分 WebGL 中的頂點和坐標概念。頂點 (vertex) 不僅可以包含一個點的坐標屬性,還可以包含法向量、顏色等其它屬性。這些屬性都可以輸入頂點着色器中來做計算。
const vertexBuffers = beam.resource(VertexBuffers, { position: [ -1, -1, 0, // vertex 0 左下角 0, 1, 0, // vertex 1 頂部 1, -1, 0 // vertex 2 右下角 ], color: [ 1, 0, 0, // vertex 0 紅色 0, 1, 0, // vertex 1 綠色 0, 0, 1 // vertex 2 藍色 ] })
裝頂點的 Buffer 通常會使用很緊湊的數據集。我們可以定義這份數據的一個子集或者超集來用於實際渲染,以便於減少數據冗餘並復用更多頂點。為此我們需要引入 WebGL 中的 IndexBuffer
概念,它指定了渲染時用到的頂點下標。這個例子里,0 1 2 這樣的每個下標,都對應頂點數組裡的 3 個位置:
const indexBuffer = beam.resource(IndexBuffer, { array: [0, 1, 2] // 由 0 1 2 號頂點組成的三角形 })
最後我們就可以進入渲染環節啦。首先用 beam.clear
來清空當前幀,然後為 beam.draw
傳入一個着色器對象和任意多個資源對象即可:
beam .clear() .draw(shader, vertexBuffers, indexBuffer)
我們的 beam.draw
API 是非常靈活的。如果你有多個着色器和多個資源,可以隨意組合它們來鏈式地完成繪製,渲染出複雜的場景。就像這樣:
beam .draw(shaderX, ...resourcesA) .draw(shaderY, ...resourcesB) .draw(shaderZ, ...resourcesC)
別忘了還有個遺漏的地方:如何決定三角形的渲染算法呢?這是在 MyShader
變量里指定的。它其實是個着色器的 Schema,像這樣:
import { SchemaTypes } from 'beam-gl' const vertexShader = ` attribute vec4 position; attribute vec4 color; varying highp vec4 vColor; void main() { vColor = color; gl_Position = position; } ` const fragmentShader = ` varying highp vec4 vColor; void main() { gl_FragColor = vColor; } ` const { vec4 } = SchemaTypes export const MyShader = { vs: vertexShader, fs: fragmentShader, buffers: { position: { type: vec4, n: 3 }, color: { type: vec4, n: 3 } } }
Beam 中的着色器 Schema,需要提供 fs / vs / buffers
等字段。這裡的一些要點包括如下:
- 可以粗略認為,頂點着色器對三角形每個頂點執行一次,而片元着色器則對三角形內的每個像素執行一次。
- 頂點着色器和片元着色器,都是用 WebGL 標準中的 GLSL 語言編寫的。這門語言其實就是 C 語言的變體,
vec4
則是其內置的 4 維向量數據類型。 - 在 WebGL 中,頂點着色器將
gl_Position
變量作為坐標位置輸出,而片元着色器則將gl_FragColor
變量作為像素顏色輸出。本例中的頂點和片元着色器,執行的都只是最簡單的賦值操作。 - 名為
vColor
的 varying 變量,會由頂點着色器傳遞到片元着色器,並自動插值。最終三角形在頂點位置呈現我們定義的紅綠藍純色,而其他位置則被漸變填充,這就是插值計算的結果。 - 變量前的
highp
修飾符用於指定精度,也可以在着色器最前面加一行precision highp float;
來省略為每個變量手動指定精度。在現在這個時代,基本可以一律用高精度了。 - 這裡
position
和color
這兩個 attribute 變量,和前面vertexBuffers
中的 key 相對應。這也是 Beam 中的隱式約定。
雖然到此為止的信息量可能比較大,但現在只要區區幾十行代碼,我們就可以清晰地用 Beam 來手動控制 WebGL 渲染了。接下來讓我們看看,該如何把渲染出的三角形換成矩形。有了上面的鋪墊,這個改動就顯得非常簡單了,稍微改幾行代碼就行。
我們的目標如下圖所示:
這對應於這樣的代碼:
const vertexBuffers = beam.resource(VertexBuffers, { position: [ -1, -1, 0, // vertex 0 左下角 -1, 1, 0, // vertex 1 左上角 1, -1, 0, // vertex 2 右下角 1, 1, 0 // vertex 3 右上角 ], color: [ 1, 0, 0, // vertex 0 紅色 0, 1, 0, // vertex 1 綠色 0, 0, 1, // vertex 2 藍色 1, 1, 0 // vertex 3 黃色 ] }) const indexBuffer = beam.resource(IndexBuffer, { array: [ 0, 1, 2, // 左下三角形 1, 2, 3 // 右上三角形 ] })
其他代碼完全不用改動,我們就能看到 Canvas 被填滿了。這正好告訴了我們另一個重要信息:WebGL 的屏幕坐標系以畫布中央為原點,畫布左下角為 (-1, -1),右上角則為 (1, 1)。如下圖所示:
注意,不論畫布長寬比例如何,這個坐標系的範圍都是 -1 到 1 的。只要嘗試更改一下 Canvas 的尺寸,你就能知道這是什麼意思了。
到目前為止,我們的渲染算法,其實只有片元着色器里的這一行:
void main() { gl_FragColor = vColor; }
對每個像素,這個 main 函數都會執行,將插值後的 varying 變量 vColor
顏色直接賦給 gl_FragColor
作為輸出。能不能玩出些花樣呢?很簡單:
gl_FragColor = vec4(0.8, 0.9, 0.6, 0.4); // 固定顏色 gl_FragColor = vColor.xyzw; // 四個分量的語法糖 gl_FragColor = vColor.rgba; // 四個分量的等效語法糖 gl_FragColor = vColor.stpq; // 四個分量的等效語法糖 gl_FragColor = vColor + vec4(0.5); // 變淡 gl_FragColor = vColor * 0.5; // 變暗 gl_FragColor = vColor.yxzw; // 交換 X 與 Y 分量 gl_FragColor = vColor.rbga; // 交換 G 與 B 分量 gl_FragColor = vColor.rrrr; // 灰度展示 R 分量 gl_FragColor = vec4(vec2(0), vColor.ba); // 清空 R 與 G 分量
這一步的例子,可以在 Hello World 這裡訪問到。
雖然這些例子只示範了 GLSL 的基本語法,但別忘了這可是編譯到 GPU 上並行計算的代碼,和單線程的 JS 有着雲泥之別。只不過,目前我們的輸入都是由各頂點之間的顏色插值而來,因此效果難以超出普通漸變的範疇。該怎樣渲染出常見的點陣圖像呢?到此我們終於可以進入正題,介紹與圖像處理關係最為重大的紋理資源了。
如何用 WebGL 渲染圖像
為了進行圖像處理,瀏覽器中的 Image 對象顯然是必須的輸入。在 WebGL 中,Image 對象可以作為紋理,貼到多邊形表面。這意味着,在片元着色器里,我們可以根據某種規則來採樣圖像的某個位置,將該位置的圖像顏色作為輸入,計算出最終屏幕上的像素顏色。顯然,這個過程需要在着色器里表達圖像的不同位置,這用到的就是所謂的紋理坐標系了。
紋理坐標系又叫 ST 坐標系。它以圖像左下角為原點,右上角為 (1, 1) 坐標,同樣與圖像的寬高比例無關。這一坐標系的具體形式如下所示,配圖來自筆者在盧浮宮拍攝的維納斯像(嘿嘿)
還記得我們先前給每個頂點附帶了什麼 attribute 屬性嗎?坐標和顏色。現在,我們需要將顏色換成紋理坐標,從而告訴 WebGL,正方形的每一個頂點應該對齊圖像的哪一個位置,就像把被單的四個角對齊被套一樣。這也就意味着我們需要依序提供上圖中,紋理圖像四個角落的坐標。若將這四個坐標當作顏色繪製出來,就能得到下圖:
不難看出,圖中左下角對應 RGB 下的 (0, 0, 0) 黑色;左上角對應 RGB 下的 (0, 1, 0) 綠色;右下角對應 RGB 下的 (1, 0, 0) 紅色;右上角則對應 RGB 下的 (1, 1, 0) 黃色。由此可見,這幾個顏色 R 通道和 G 通道分量的取值,就和紋理坐標系中對應的 X Y 位置一致。這樣一來,我們就用 RGB 顏色驗證了數據的正確性。這種技巧也常對着色算法調試有所幫助。
和屏幕坐標系超出 (-1, 1) 區間就會被裁掉不同,紋理坐標系的取值可以是任意的正負浮點數。那麼超過區間該怎麼辦呢?默認行為是平鋪,像這樣:
但平鋪不是唯一的行為。我們也可以修改 WebGL 狀態,讓紋理呈現出不同的展示效果(即所謂的 Wrap 纏繞模式),如下所示:
除此之外,紋理還有採樣方式等其他配置可供修改。我們暫且不考慮這麼多,看看應該怎麼將最基本的圖像作為紋理渲染出來吧:
// 創建着色器 const shader = beam.shader(TextureDemo) // 創建用於貼圖的矩形 const rect = { vertex: { position: [ -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0 ], texCoord: [ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 ] }, index: { array: [0, 1, 2, 0, 2, 3] } } const vertexBuffers = beam.resource(VertexBuffers, rect.vertex) const indexBuffer = beam.resource(IndexBuffer, rect.index) // 創建紋理資源 const textures = beam.resource(Textures) // 異步加載圖像 fetchImage('venus.jpg').then(image => { // 設入紋理圖像後,執行繪製 textures.set('img', { image, flip: true }) beam .clear() .draw(shader, vertexBuffers, indexBuffer, textures) })
類似地,我們還是先看整體的渲染邏輯,再看着色器。整個過程其實很簡單,可以概括為三步:
- 初始化着色器、矩形資源和紋理資源
- 異步加載圖像,完成後把圖像設置為紋理
- 執行繪製
相信大家在熟悉 Beam 的 API 後,應該不會覺得這部分代碼有什麼特別之處了吧。下面我們來關注重要的 TextureDemo
着色器部分,如下所示:
const vs = ` attribute vec4 position; attribute vec2 texCoord; varying highp vec2 vTexCoord; void main() { vTexCoord = texCoord; gl_Position = position; } ` const fs = ` varying highp vec2 vTexCoord; uniform sampler2D img; void main() { gl_FragColor = texture2D(img, vTexCoord); } ` const { vec2, vec4, tex2D } = SchemaTypes export const TextureDemo = { vs, fs, buffers: { position: { type: vec4, n: 3 }, texCoord: { type: vec2 } }, textures: { img: { type: tex2D } } }
就像 vColor
那樣地,我們將 vTexCoord
變量從頂點着色器傳入了片元着色器,這時同樣隱含了插值處理。
這組着色器中,真正值得一提的是這麼兩行:
uniform sampler2D img; // ... gl_FragColor = texture2D(img, vTexCoord);
你可以認為,片元着色器中 uniform sampler2D
類型的 img
變量,會被綁定到一張圖像紋理上。然後,我們就可以用 WebGL 內置的 texture2D
函數來做紋理採樣了。因此,這個着色器的渲染算法,其實就是採樣 img
圖像的 vTexCoord
位置,將獲得的顏色作為該像素的輸出。對整個矩形內的每個像素點都執行一遍這個採樣過程後,自然就把圖像搬上屏幕了。
讓我們先歇一口氣,欣賞下渲染出來的高雅藝術吧:
這一步的例子,可以在 Texture Config 這裡訪問到。
如何為圖像增加濾鏡
現在,圖像的採樣過程已經處於我們的着色器代碼控制之下了。這意味着我們可以輕易地控制每個像素的渲染算法,實現圖像濾鏡。這具體要怎麼做呢?下面拿這張筆者在布拉格拍的伏爾塔瓦河做例子(嘿嘿嘿)
我們看到了一張默認彩色的圖像。最常見的濾鏡操作之一,就是將它轉為灰度圖。這有很多種實現方式,而其中最簡單的一種,不外乎把 RGB 通道的值全設置成一樣的:
// 先採樣出紋理的 vec4 顏色 vec4 texColor = texture2D(img, vTexCoord); // 然後可以這樣 gl_FragColor = texColor.rrra; // 或者這樣 float average = (texColor.r + texColor.g + texColor.b) / 3.0; gl_FragColor = vec4(vec3(average), texColor.a);
注意,在嚴格意義上,灰度化既不是用 R 通道覆蓋 RGB,也不是對 RGB 通道簡單取平均,而需要一個比例係數。這裡為入門做了簡化,效果如圖:
目前為止我們的着色器里,真正有效的代碼都只有一兩行而已。讓我們來嘗試下面這個更複雜一些的飽和度濾鏡吧:
precision highp float; uniform sampler2D img; varying vec2 vTexCoord; const float saturation = 0.5; // 飽和度比例常量 void main() { vec4 color = texture2D(img, vTexCoord); float average = (color.r + color.g + color.b) / 3.0; if (saturation > 0.0) { color.rgb += (average - color.rgb) * (1.0 - 1.0 / (1.001 - saturation)); } else { color.rgb += (average - color.rgb) * (-saturation); } gl_FragColor = color; }
這個算法本身不是我們關注的重點,你很容易在社區找到各種各樣的着色器。這裡主要只是想告訴大家,着色器里是可以寫 if else 的……
增加飽和度後,效果如圖所示:
但這裡還有一個不大不小的問題,那就是現在的飽和度比例還是這樣的一個常量:
const float saturation = 0.5;
如果要實現「拖動滑塊調節濾鏡效果強度」這樣常見的需求,難道要不停地更改着色器源碼嗎?顯然不是這樣的。為此,我們需要引入最後一種關鍵的資源類型:Uniform 資源。
在 WebGL 中,Uniform 概念類似於全局變量。一般的全局變量,是在當前代碼中可見,而 Uniform 則對於這個着色器並行中的每次執行,都是全局可見並唯一的。這樣,着色器在計算每個像素的顏色時,都能拿到同一份「強度」參數的信息了。像上面 uniform sampler2D
類型的紋理採樣器,就是這樣的一個 Uniform 變量。只不過 Beam 處理了瑣碎的下層細節,你只管把 JS 中的 Image 對象按約定傳進來,就能把圖像綁定到這個着色器變量里來使用了。
每個 Uniform 都是一份短小的數據,如 vec4
向量或 mat4
矩陣等。要想使用它,可以從簡單的着色器代碼修改開始:
precision highp float; varying vec2 vTexCoord; uniform sampler2D img; uniform float saturation; // 由 const 改為 uniform
該怎麼給這個變量賦值呢?在 Schema 里適配一下就行:
const { vec2, vec4, float, tex2D } = SchemaTypes export const TextureDemo = { vs, fs, buffers: { position: { type: vec4, n: 3 }, texCoord: { type: vec2 } }, textures: { img: { type: tex2D } }, // 新增這個 uniforms 字段 uniforms: { saturation: { type: float, default: 0.5 } } }
這裡的 default
屬於方便調試的語法糖,理論上這時代碼的運行結果是完全一致的,只是 saturation
變量從 Shader 中的常量變成了從 JS 里傳入。怎麼進一步控制它呢?其實也很簡單,只需要 beam.draw
的時候多傳入個資源就行了:
// ... // 創建 Uniform 資源 const uniforms = beam.resource(Uniforms, { saturation: 0.5 }) // 異步加載圖像 fetchImage('venus.jpg').then(image => { textures.set('img', { image, flip: true }) // Uniform 可以隨時更新 // uniforms.set('saturation', 0.4) beam .clear() .draw(shader, vertexBuffers, indexBuffer, uniforms, textures) })
這樣,我們就可以在 JS 中輕鬆地控制濾鏡的強度了。像典型 3D 場景中,也是這樣通過 Uniform 來控制相機位置等參數的。
我們還可以將 Uniform 數組與卷積核函數配合,實現圖像的邊緣檢測、模糊等效果,並支持無縫的效果強度調整。不要怕所謂的卷積和核函數,它們的意思只是「計算一個像素時,可以採樣它附近的像素」而已。由於這種手法並不需要太多額外的 WebGL 能力,這裡就不再展開了。
這一步的例子,可以在 Single Filter 這裡訪問到。
如何疊加多個圖像
現在,我們已經知道如何為單個圖像編寫着色器了。但另一個常見的需求是,如何處理需要混疊的多張圖像呢?下面讓我們看看該如何處理這樣的圖像疊加效果:
JS 側的渲染邏輯如下所示:
// ... const render = ([imageA, imageB]) => { const imageStates = { img0: { image: imageA, flip: true }, img1: { image: imageB, flip: true } } beam.clear().draw( shader, beam.resource(VertexBuffers, rect.vertex), beam.resource(IndexBuffer, rect.index), beam.resource(Textures, imageStates) ) } loadImages('html5-logo.jpg', 'black-hole.jpg').then(render)
這裡只需要渲染一次,故而沒有單獨為 VertexBuffers
和 IndexBuffer
等資源變量命名,直接在 draw
的時候初始化就行。那麼關鍵的 Shader 該如何實現呢?此時的着色器 Schema 結構是這樣的:
const fs = ` precision highp float; uniform sampler2D img0; uniform sampler2D img1; varying vec2 vTexCoord; void main() { vec4 color0 = texture2D(img0, vTexCoord); vec4 color1 = texture2D(img1, vTexCoord); gl_FragColor = color0 * color1.r; } ` const { vec2, vec4, mat4, tex2D } = SchemaTypes export const MixImage = { vs, // 頂點着色器和前例相同 fs, buffers: { position: { type: vec4, n: 3 }, texCoord: { type: vec2 } }, textures: { img0: { type: tex2D }, img1: { type: tex2D } } }
這裡的核心代碼在於 gl_FragColor = color0 * color1.r;
這一句,而這兩個顏色則分別來自於對兩張圖像的 texture2D
採樣。有了更豐富的輸入,我們自然可以有更多的變化可以玩了。比如這樣:
gl_FragColor = color0 * (1.0 - color1.r);
就可以得到相反的疊加結果。
在現在的 WebGL 里,我們一般可以至少同時使用 16 個紋理。這個上限說實話也不小了,對於常見的圖像混疊需求也都能很好地滿足。但是瀏覽器自身也是通過類似的 GPU 渲染管線來渲染的,它是怎麼渲染頁面里動輒成百上千張圖像的呢?這說起來知易行難,靠的是分塊多次繪製。
這一步的例子,可以在 Mix Images 這裡訪問到。
如何組合多個濾鏡
到現在為止我們已經單獨實現過多種濾鏡了,但如何將它們的效果串聯起來呢?WebGL 的着色器畢竟是字符串,我們可以做魔改拼接,生成不同的着色器。這確實是許多 3D 庫中的普遍實踐,也利於追求極致的性能。但這裡選擇的是一種工程上實現更為簡潔優雅的方式,即離屏的鏈式渲染。
假設我們有 A B C 等多種濾鏡(即用於圖像處理的着色器),那麼該如何將它們的效果依次應用到圖像上呢?我們需要先為原圖應用濾鏡 A,然後將 A 的渲染結果傳給 B,再將 A + B 的渲染結果傳給 C…依此類推,即可組成一條完整的濾鏡鏈。
為了實現這一目標,我們顯然需要暫存某次渲染的結果。熟悉 Canvas 的同學一定對離屏渲染不陌生,在 WebGL 中也有類似的概念。但 WebGL 的離屏渲染,並不像 Canvas 那樣能直接新建多個離屏的 <canvas>
標籤,而是以渲染到紋理的方式來實現的。
在給出代碼前,我們需要先做些必要的科普。在 WebGL 和 OpenGL 體系中有個最為經典的命名槽點,那就是 Buffer 和 Framebuffer 其實完全是兩種東西(不要誤給 Framebuffer 加了駝峰命名噢)。Buffer 可以理解為存儲大段有序數據的對象,而 Framebuffer 指代的則是屏幕!一般來說,我們渲染到屏幕時,使用的就是默認的物理 Framebuffer。但離屏渲染時,我們渲染的 Framebuffer 是個虛擬的對象,即所謂的 Framebuffer Object (FBO)。紋理對象可以 attach 到 Framebuffer Object 上,這樣繪製時就會將像素數據寫到內存,而不是物理顯示設備了。
上面的介紹有些繞口,其實只要記住這兩件事就對了:
- 離屏渲染時,要將渲染目標從物理 Framebuffer 換成 FBO。
- FBO 只是個殼,要將紋理對象掛載上去,這才是像素真正寫入的地方。
對離屏渲染,Beam 也提供了完善的支持。FBO 有些繞口,因此 Beam 提供了名為 OffscreenTarget
的特殊資源對象。這種對象該如何使用呢?假設現在我們有 3 個着色器,分別是用於調整對比度、色相和暈影的濾鏡,那麼將它們串聯使用的代碼示例如下:
import { Beam, ResourceTypes, Offscreen2DCommand } from 'beam-gl' // ... const beam = new Beam(canvas) // 默認導入的最小包不帶離屏支持,需手動擴展 beam.define(Offscreen2DCommand) // ... // 原圖的紋理資源 const inputTextures = beam.resource(Textures) // 中間環節所用的紋理資源 const outputTextures = [ beam.resource(Textures), beam.resource(Textures) ] // 中間環節所用的離屏對象 const targets = [ beam.resource(OffscreenTarget), beam.resource(OffscreenTarget) ] // 將紋理掛載到離屏對象上,這步的語義暫時還不太直觀 outputTextures[0].set('img', targets[0]) outputTextures[1].set('img', targets[1]) // 固定的矩形 Buffer 資源 const rect= [rectVertex, rectIndex] const render = image => { // 更新輸入紋理 inputTextures.set('img', { image, flip: true }) beam.clear() beam // 用輸入紋理,渲染對比度濾鏡到第一個離屏對象 .offscreen2D(targets[0], () => { beam.draw(contrastShader, ...rect, inputTextures) }) // 用第一個輸出紋理,渲染色相濾鏡到第二個離屏對象 .offscreen2D(targets[1], () => { beam.draw(hueShader, ...rect, outputTextures[0]) }) // 用第二個輸出紋理,渲染暈影濾鏡直接上屏 beam.draw(vignetteShader, ...rect, outputTextures[1]) } fetchImage('prague.jpg').then(render)
這裡的渲染邏輯,其實只是將原本這樣的代碼結構:
beam .clear() .draw(shaderX, ...resourcesA) .draw(shaderY, ...resourcesB) .draw(shaderZ, ...resourcesC)
換成了擴展 offscreen2D
API 後的這樣:
beam .clear() .offscreen2D(targetP, () => { beam.draw(shaderX, ...resourcesA) }) .offscreen2D(targetQ, () => { beam.draw(shaderY, ...resourcesB) }) .offscreen2D(targetR, () => { beam.draw(shaderZ, ...resourcesC) }) // 還需要在外面再 beam.draw 一次,才能把結果上屏
只要被嵌套在 offscreen2D
函數里,那麼 beam.draw
在功能完全不變的前提下,渲染結果會全部走到相應的離屏對象里,從而寫入離屏對象所掛載的紋理上。這樣,我們就用函數的作用域表達出了離屏渲染的作用域!這是 Beam 的一大創新點,能用來支持非常靈活的渲染邏輯。比如這樣的嵌套渲染結構,也是完全合法的:
beam .clear() .offscreen2D(target, () => { beam .draw(shaderX, ...resourcesA) .draw(shaderY, ...resourcesB) .draw(shaderZ, ...resourcesC) }) .draw(shaderW, ...resourcesD)
離屏渲染的 API 看似簡單,其實是 Beam 中耗費最多時間設計的特性之一,目前的方案也是經歷過若干次失敗的嘗試,推翻了用數組、樹和有向無環圖來結構化表達渲染邏輯的方向後才確定的。當然它目前也還有不夠理想的地方,希望大家可以多反饋意見和建議。
現在,我們就能嘗到濾鏡鏈在可組合性上的甜頭了。在依次應用了對比度、色相和暈影三個着色器後,渲染效果如下所示:
這一步的例子,可以在 Multi Filters 這裡訪問到。
如何引入 3D 效果
現在,我們已經基本覆蓋了 2D 領域的 WebGL 圖像處理技巧了。那麼,是否有可能利用 WebGL 在 3D 領域的能力,實現一些更為強大的特效呢?當然可以。下面我們就給出一個基於 Beam 實現「高性能圖片爆破輪播」的例子。
本節內容源自筆者在 現在作為前端入門,還有必要去學習高難度的 CSS 和 JS 特效嗎?問題下的問答。閱讀過這個回答的同學也可以跳過。
相信大家應該見過一些圖片爆炸散開成為粒子的效果,這實際上就是將圖片拆解為了一堆形狀。這時不妨假設圖像位於單位坐標繫上,將圖像拆分為許多爆破粒子,每個粒子都是由兩個三角形組成的小矩形。攝像機從 Z 軸俯視下去,就像這樣:
相應的數據結構呢?以上圖的粒子為例,其中一個剛好在 X 軸中間的頂點,大致需要這些參數:
- 空間位置,是粒子的三維坐標,這很好理解
- 紋理位置,告訴 GPU 需要採樣圖像的哪個部分
- 粒子中心位置,相當於讓四個頂點團結在一起的 ID,免得各自跑偏了
只要 50 行左右的 JS,我們就可以完成初始數據的計算:
// 這種數據處理場景下,這個簡陋的 push 性能好很多 const push = (arr, x) => { arr[arr.length] = x } // 生成將圖像等分為 n x n 矩形的數據 const initParticlesData = n => { const [positions, centers, texCoords, indices] = [[], [], [], []] // 這種時候求別用 forEach 了 for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { const [x0, x1] = [i / n, (i + 1) / n] // 每個粒子的 x 軸左右坐標 const [y0, y1] = [j / n, (j + 1) / n] // 每個粒子的 y 軸上下坐標 const [xC, yC] = [x0 + x1 / 2, y0 + y1 / 2] // 每個粒子的中心二維坐標 const h = 0.5 // 將中心點從 (0.5, 0.5) 平移到原點的偏移量 // positions in (x, y), z = 0 push(positions, x0 - h); push(positions, y0 - h) push(positions, x1 - h); push(positions, y0 - h) push(positions, x1 - h); push(positions, y1 - h) push(positions, x0 - h); push(positions, y1 - h) // texCoords in (x, y) push(texCoords, x0); push(texCoords, y0) push(texCoords, x1); push(texCoords, y0) push(texCoords, x1); push(texCoords, y1) push(texCoords, x0); push(texCoords, y1) // center in (x, y), z = 0 push(centers, xC - h); push(centers, yC - h) push(centers, xC - h); push(centers, yC - h) push(centers, xC - h); push(centers, yC - h) push(centers, xC - h); push(centers, yC - h) // indices const k = (i * n + j) * 4 push(indices, k); push(indices, k + 1); push(indices, k + 2) push(indices, k); push(indices, k + 2); push(indices, k + 3) } } // 着色器內的變量名是單數形式,將複數形式的數組名與其對應起來 return { pos: positions, center: centers, texCoord: texCoords, index: indices } }
現在我們已經能把原圖拆分為一堆小矩形來渲染了。但這樣還不夠,因為默認情況下這些小矩形都是連接在一起的。借鑒一般遊戲中粒子系統的實現,我們可以把動畫算法寫到着色器里,只逐幀更新一個隨時間遞增的數字,讓 GPU 推算出每個粒子不同時間應該在哪。配套的着色器實現如下:
/* 這是頂點着色器,片元着色器無須改動 */ attribute vec4 pos; attribute vec4 center; attribute vec2 texCoord; uniform mat4 viewMat; uniform mat4 projectionMat; uniform mat4 rotateMat; uniform float iTime; varying vec2 vTexCoord; const vec3 camera = vec3(0, 0, 1); // 偽隨機數生成器 float rand(vec2 co) { return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453); } void main() { // 求出粒子相對於相機位置的單位方向向量,並附帶上偽隨機數的擾動 vec3 dir = normalize(center.xyz * rand(center.xy) - camera); // 沿擾動後的方向,隨時間遞增偏移量 vec3 translatedPos = pos.xyz + dir * iTime; // 給紋理坐標插值 vTexCoord = texCoord; // 求出矩陣變換後最終的頂點位置 gl_Position = projectionMat * viewMat * vec4(translatedPos, 1); }
由於進入了 3D 世界,因此這個着色器引入了經典的 MVP 矩陣變換。這其實也已經遠離了本文的主題,相信感興趣的同學一定不難找到入門資料學習掌握。這個粒子效果的 Demo 如下所示。這裡我們特意降低了粒子數量,方便大家看清它是怎麼一回事:
如果基於 CSS,只要有了幾百個 DOM 元素要高頻更新,渲染時就會顯得力不從心。而相比之下基於 WebGL,穩定 60 幀更新幾萬個粒子是完全不成問題的。由此可見,在與圖像處理相關的特效層面,WebGL 始終是有它的用武之地的。
這一步的例子,可以在 Image Explode 這裡訪問到。
如何封裝自定渲染器
最後,我們將視野回到前端工程,簡單聊聊如何封裝自己的渲染器。
Beam 自身不是一個 WebGL 渲染器或渲染引擎,而是方便大家編寫渲染邏輯的通用基礎庫。當我們想要進一步復用渲染邏輯的時候,封裝出自己的 WebGL 渲染器就顯得必要了。
這裡用 JS 中最為標準化的 class,演示如何封裝出一個簡單的濾鏡渲染器:
class FilterRenderer { constructor (canvas) { this.beam = new Beam(canvas) this.shader = this.beam(MyShader) this.rect = createRect() this.textures = this.beam.resource(Textures) this.uniforms = this.beam.resource(Uniforms, { strength: 0 }) } setStrength (strength) { this.uniforms.set('strength', strength) } setImage (image) { this.textures.set('img', { image, flip: true }) } render () { const { beam, shader, rect, textures, uniforms } = this beam .clear() .draw(shader, rect, textures, uniforms) } }
只要這樣,在使用時就可以完全把 Beam 的 WebGL 概念屏蔽掉了:
const renderer = new FilterRenderer(canvas) renderer.setImage(myImage) renderer.setStrength(1) renderer.render()
這時值得注意的地方有這麼幾個:
- 盡量在構造器對應的初始化階段分配好資源
- 盡量不要高頻更新大段的 Buffer 數據
- 不用的紋理和 Buffer 等資源要手動用
destroy
方法銷毀掉
當然,JS 中的 class 也不完美,而新興的 Hooks 等範式也有潛力應用到這一領域,實現更好的 WebGL 工程化設計。該如何根據實際需求,定製出自己的渲染器呢?這就要看大家的口味和追求啦。
後記
為了盡量將各種重要的 WebGL 技巧濃縮在一起,快速達到足夠實用的程度,本文篇幅顯得有些長。雖然 Beam 的入門相對於 Vue 和 React 這樣的常見框架來說還是有些門檻,但相比於一般需要分許多篇連載才能覆蓋圖像處理和離屏渲染的 WebGL 教程來說,我們已經屏蔽掉許多初學時不必關心的瑣碎細節了。也歡迎大家對這種行文方式的反饋。
值得一提的是,Beam 不是一個為圖像處理而生的庫,API 中也沒有為這個場景做任何特殊定製。它的設計初衷,其實是作為我司 3D 文字功能的渲染器。但由於它的 WebGL 基礎庫定位,它在 10KB 不到的體積下,不僅能平滑地從 3D 應用到 2D,甚至在 2D 場景下的擴展性,還能輕鬆超過 glfx.js 這樣尚不支持濾鏡鏈的社區標杆。這也反映出了設計框架時常有的兩種思路:一種是為每個新需求來者不拒地設計新的 API,將框架實現得包羅萬象;另一種是謹慎地找到需求間的共性,實現最小的 API 子集供使用者組合定製。顯然筆者更傾向於後者。
Beam 的後續發展,也需要大家的支持——其實只要你不吝於給它個 Star 就夠了。這會給我們更大的動力繼續爭取資源來維護它,或者進一步分享更多的 WebGL 知識與經驗。歡迎大家移步這裡:
[Beam – Expressive WebGL] https://github.com/doodlewind/beam
到此為止,相信我們已經對 WebGL 在圖像處理領域的基本應用有了代碼層面的認識了。希望大家對日常遇到的技術能少些「這麼底層我管不來,用別人封裝的東西就好」的心態,保持對舒適區外技術的學習熱情,為自主創新貢獻自己哪怕是微小的一份力量。
作者:doodlewind花名雪碧 | github.com/doodlewind
https://zhuanlan.zhihu.com/p/100388037