實用 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),即 positioncolor,每個屬性都對應於一個獨立的 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; 來省略為每個變量手動指定精度。在現在這個時代,基本可以一律用高精度了。
  • 這裡 positioncolor 這兩個 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)  })

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

  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 位置,將獲得的顏色作為該像素的輸出。對整個矩形內的每個像素點都執行一遍這個採樣過程後,自然就把圖像搬上屏幕了

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

這一步的例子,可以在 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)

這裡只需要渲染一次,故而沒有單獨為 VertexBuffersIndexBuffer 等資源變量命名,直接在 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