天天看點

【WebGL】523- 實用 WebGL 圖像處理入門

技術社群裡有種很有意思的現象,那就是不少人們口耳相傳中的強大技術,往往因為上手難度高而顯得曲高和寡。從這個角度看來,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 基于一些約定大幅簡化了人工的指令管理,當然你也可以定制自己的指令。

這些概念是如何協同工作的呢?請看下圖:

【WebGL】523- 實用 WebGL 圖像處理入門

圖中的 Buffers / Textures / Uniforms 都屬于典型的資源(後面會詳述它們各自的用途)。一幀當中可能存在多次繪制,每次繪制都需要着色器和相應的資源。在繪制之間,我們通過指令來管理好 WebGL 的狀态。僅此而已。

了解這個思維模型很重要。因為 Beam 的 API 設計就是完全依據這個模型而實作的。讓我們進一步看看一個實際的場景吧:

【WebGL】523- 實用 WebGL 圖像處理入門

圖中我們繪制了很多質感不同的球體。這一幀的渲染,則可以這樣解構到上面的這些概念下:

  • 着色器無疑就是球體質感的渲染算法。對經典的 3D 遊戲來說,要渲染不同質感的物體,經常需要切換不同的着色器。但現在基于實體的渲染算法流行後,這些球體也不難做到使用同一個着色器來渲染。
  • 資源包括了大段的球體頂點資料、材質紋理的圖像資料,以及光照參數、變換矩陣等配置項。
  • 繪制是分多次進行的。我們選擇每次繪制一個球體,而每次繪制也都會啟動一次圖形渲染管線。
  • 指令則是相鄰的球體繪制之間,所執行的那些狀态變更。

如何了解狀态變更呢?不妨将 WebGL 想象成一個具備大量開關與接口的儀器。每次按下啟動鍵(執行繪制)前。你都要配置好一堆開關,再連接配接好一條接着色器的線,和一堆接資源的線,就像這樣:

【WebGL】523- 實用 WebGL 圖像處理入門

還有很重要的一點,那就是雖然我們已經知道,一幀畫面可以通過多次繪制而生成,而每次繪制又對應執行一次圖形渲染管線的執行。但是,所謂的圖形渲染管線又是什麼呢?這對應于這張圖:

【WebGL】523- 實用 WebGL 圖像處理入門

渲染管線,一般指的就是這樣一個 GPU 上由頂點資料到像素的過程。對現代 GPU 來說,管線中的某些階段是可程式設計的。WebGL 标準裡,這對應于圖中藍色的頂點着色器和片元着色器階段。你可以把它們想象成兩個需要你寫 C-style 代碼,跑在 GPU 上的函數。它們大體上分别做這樣的工作:

  • 頂點着色器輸入原始的頂點坐标,輸出經過你計算出的坐标。
  • 片元着色器輸入一個像素位置,輸出根據你計算出的像素顔色。

下面,我們将進一步講解如何應用這些概念,搭建出一個完整的 WebGL 入門示例。

WebGL 示例入門

本節同樣來自 如何設計一個 WebGL 基礎庫 一文,但為承接後續的圖像處理内容,叙述有所調整。

在苦口婆心的概念介紹後,就要來到真刀真槍的編碼階段了。由于四大概念中的指令可以被自動化,我們隻為 Beam 定義了三個核心 API,分别是:

  • beam.shader
  • beam.resource
  • beam.draw

顯然地,它們各自管理着色器、資源和繪制。讓我們看看怎樣基于 Beam,來繪制 WebGL 中的 Hello World 彩色三角形吧:

【WebGL】523- 實用 WebGL 圖像處理入門

三角形是最簡單的多邊形,而多邊形則是由頂點組成的。WebGL 中的這些頂點是有序排列,可通過下标索引的。以三角形和矩形為例,這裡使用的頂點順序如下所示:

【WebGL】523- 實用 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 渲染了。接下來讓我們看看,該如何把渲染出的三角形換成矩形。有了上面的鋪墊,這個改動就顯得非常簡單了,稍微改幾行代碼就行。

我們的目标如下圖所示:

【WebGL】523- 實用 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)。如下圖所示:

【WebGL】523- 實用 WebGL 圖像處理入門

注意,不論畫布長寬比例如何,這個坐标系的範圍都是 -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) 坐标,同樣與圖像的寬高比例無關。這一坐标系的具體形式如下所示,配圖來自筆者在盧浮宮拍攝的維納斯像(嘿嘿)

【WebGL】523- 實用 WebGL 圖像處理入門

還記得我們先前給每個頂點附帶了什麼 attribute 屬性嗎?坐标和顔色。現在,我們需要将顔色換成紋理坐标,進而告訴 WebGL,正方形的每一個頂點應該對齊圖像的哪一個位置,就像把被單的四個角對齊被套一樣。這也就意味着我們需要依序提供上圖中,紋理圖像四個角落的坐标。若将這四個坐标當作顔色繪制出來,就能得到下圖:

【WebGL】523- 實用 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】523- 實用 WebGL 圖像處理入門

但平鋪不是唯一的行為。我們也可以修改 WebGL 狀态,讓紋理呈現出不同的展示效果(即所謂的 Wrap 纏繞模式),如下所示:

【WebGL】523- 實用 WebGL 圖像處理入門

除此之外,紋理還有采樣方式等其他配置可供修改。我們暫且不考慮這麼多,看看應該怎麼将最基本的圖像作為紋理渲染出來吧:

// 建立着色器
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)
})      

類似地,我們還是先看整體的渲染邏輯,再看着色器。整個過程其實很簡單,可以概括為三步:

  1. 初始化着色器、矩形資源和紋理資源
  2. 異步加載圖像,完成後把圖像設定為紋理
  3. 執行繪制

相信大家在熟悉 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​

​ 位置,将獲得的顔色作為該像素的輸出。對整個矩形内的每個像素點都執行一遍這個采樣過程後,自然就把圖像搬上螢幕了。

讓我們先歇一口氣,欣賞下渲染出來的高雅藝術吧:

【WebGL】523- 實用 WebGL 圖像處理入門

這一步的例子,可以在 Texture Config 這裡通路到。

如何為圖像增加濾鏡

現在,圖像的采樣過程已經處于我們的着色器代碼控制之下了。這意味着我們可以輕易地控制每個像素的渲染算法,實作圖像濾鏡。這具體要怎麼做呢?下面拿這張筆者在布拉格拍的伏爾塔瓦河做例子(嘿嘿嘿)

【WebGL】523- 實用 WebGL 圖像處理入門

我們看到了一張預設彩色的圖像。最常見的濾鏡操作之一,就是将它轉為灰階圖。這有很多種實作方式,而其中最簡單的一種,不外乎把 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 通道簡單取平均,而需要一個比例系數。這裡為入門做了簡化,效果如圖:

【WebGL】523- 實用 WebGL 圖像處理入門

目前為止我們的着色器裡,真正有效的代碼都隻有一兩行而已。讓我們來嘗試下面這個更複雜一些的飽和度濾鏡吧:

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 的……

增加飽和度後,效果如圖所示:

【WebGL】523- 實用 WebGL 圖像處理入門

但這裡還有一個不大不小的問題,那就是現在的飽和度比例還是這樣的一個常量:

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 這裡通路到。

如何疊加多個圖像

現在,我們已經知道如何為單個圖像編寫着色器了。但另一個常見的需求是,如何處理需要混疊的多張圖像呢?下面讓我們看看該如何處理這樣的圖像疊加效果:

【WebGL】523- 實用 WebGL 圖像處理入門

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 中耗費最多時間設計的特性之一,目前的方案也是經曆過若幹次失敗的嘗試,推翻了用數組、樹和有向無環圖來結構化表達渲染邏輯的方向後才确定的。當然它目前也還有不夠理想的地方,希望大家可以多回報意見和建議。

現在,我們就能嘗到濾鏡鍊在可組合性上的甜頭了。在依次應用了對比度、色相和暈影三個着色器後,渲染效果如下所示:

【WebGL】523- 實用 WebGL 圖像處理入門

這一步的例子,可以在 Multi Filters 這裡通路到。

如何引入 3D 效果

現在,我們已經基本覆寫了 2D 領域的 WebGL 圖像處理技巧了。那麼,是否有可能利用 WebGL 在 3D 領域的能力,實作一些更為強大的特效呢?當然可以。下面我們就給出一個基于 Beam 實作「高性能圖檔爆破輪播」的例子。

本節内容源自筆者在 現在作為前端入門,還有必要去學習高難度的 CSS 和 JS 特效嗎?問題下的問答。閱讀過這個回答的同學也可以跳過。

相信大家應該見過一些圖檔爆炸散開成為粒子的效果,這實際上就是将圖檔拆解為了一堆形狀。這時不妨假設圖像位于機關坐标系上,将圖像拆分為許多爆破粒子,每個粒子都是由兩個三角形組成的小矩形。錄影機從 Z 軸俯視下去,就像這樣:

【WebGL】523- 實用 WebGL 圖像處理入門

相應的資料結構呢?以上圖的粒子為例,其中一個剛好在 X 軸中間的頂點,大緻需要這些參數:

【WebGL】523- 實用 WebGL 圖像處理入門
  • 空間位置,是粒子的三維坐标,這很好了解
  • 紋理位置,告訴 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 如下所示。這裡我們特意降低了粒子數量,友善大家看清它是怎麼一回事:

【WebGL】523- 實用 WebGL 圖像處理入門

如果基于 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