技術社群裡有種很有意思的現象,那就是不少人們口耳相傳中的強大技術,往往因為上手難度高而顯得曲高和寡。從這個角度看來,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 語言的變體,
則是其内置的 4 維向量資料類型。vec4
- 在 WebGL 中,頂點着色器将
變量作為坐标位置輸出,而片元着色器則将 gl_Position
變量作為像素顔色輸出。本例中的頂點和片元着色器,執行的都隻是最簡單的指派操作。gl_FragColor
- 名為
的 varying 變量,會由頂點着色器傳遞到片元着色器,并自動插值。最終三角形在頂點位置呈現我們定義的紅綠藍純色,而其他位置則被漸變填充,這就是插值計算的結果。vColor
- 變量前的
修飾符用于指定精度,也可以在着色器最前面加一行 highp
來省略為每個變量手動指定精度。在現在這個時代,基本可以一律用高精度了。precision highp float;
- 這裡
和 position
這兩個 attribute 變量,和前面 color
中的 key 相對應。這也是 Beam 中的隐式約定。vertexBuffers
雖然到此為止的資訊量可能比較大,但現在隻要區區幾十行代碼,我們就可以清晰地用 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