如何設計一個 WebGL 基礎庫

要想使用 WebGL 直通 GPU 的渲染能力,不少同窗的第一反應就是使用現成的開源項目,如著名的 Three.js 等。可是,直接套用它們就是惟一的選擇了嗎?若是想深刻 WebGL 基礎甚至本身造輪子,又該從何下手呢?本文但願以筆者本身的實踐經驗爲例,科普一些圖形基礎庫設計層面的知識。前端

背景

不久前,咱們爲 稿定設計 Web 端 添加了 3D 文字的編輯能力。你能夠將本來侷限在二維平面上的文字立體化,爲其添加豐富的質感,就像這樣:git

3D 文字的 WebGL 渲染部分,使用了咱們自研的 Beam 基礎庫。它並非某個開源項目的 fork 魔改版,而是從頭開始正向實現的。做爲 Beam 的做者,推進一個新輪子在生產環境中落地的經歷,無疑給了我不少值得分享的經驗。下面就先從最基本的定位出發,聊聊 WebGL 基礎庫吧。github

渲染引擎與 WebGL 基礎庫

提到 WebGL 時,你們廣泛會想到 Three.js 這樣的開源項目,這多少有點「用 React 全家桶表明 Web 前端」 的感受。其實,Three 已是個頗爲高層的 3D 渲染引擎了。它和 Beam 這樣的 WebGL 基礎庫之間,粗略來講有這些異同:算法

  • 渲染引擎須要屏蔽 WebGL 等渲染端的內部概念,基礎庫則要開放這些概念。
  • 渲染引擎還能夠有 SVG / Canvas 等多個渲染端,基礎庫則專一一個渲染端。
  • 渲染引擎的設計是針對 3D 或 2D 等特定場景的,基礎庫則沒有這種假設。
  • 渲染引擎在體積上一般更重,基礎庫則顯然較輕。

其實,我更願意把 Three 和 Beam 的對比,當作 React 和 jQuery 的對比:一個想將渲染端的複雜度屏蔽到極致,另外一個則想將直接操做渲染端的 API 簡化到極致。有鑑於圖形渲染管線的高度靈活性,再加上重量級上的顯著區別(Three 源碼體積已超過 1M,且 Tree Shaking 效果不佳),筆者相信凡是但願追求控制的場景,均可以是 WebGL 基礎庫的用武之地。編程

社區有 Regl 這樣流行的 WebGL 基礎庫,這證實相似的輪子並非個僞需求。canvas

WebGL 概念抽象

在設計基礎庫的實際 API 前,咱們至少須要清楚 WebGL 是如何工做的。WebGL 代碼有不少瑣碎之處,一頭扎進代碼裏,容易使咱們只見樹木不見森林。根據筆者的理解,整個 WebGL 應用中咱們操做的概念,其實不外乎這幾個:數組

  • Shader 着色器,是存放圖形算法的對象。 相比於在 CPU 上單線程執行的 JS 代碼,着色器在 GPU 上並行執行,計算出每幀數百萬個像素各自的顏色。
  • Resource 資源,是存放圖形數據的對象。就像 JSON 成爲 Web App 的數據那樣,資源是傳遞給着色器的數據,包括大段的頂點數組、紋理圖像,以及全局的配置項等。
  • Draw 繪製,是選好資源後運行着色器的請求。要想渲染真實際的場景,通常須要多組着色器與多個資源,來回繪製屢次才能完成一幀。每次繪製前,咱們都須要選好着色器,併爲其關聯好不一樣的資源,也都會啓動一次圖形渲染管線。
  • Command 命令,是執行繪製前的配置。WebGL 是很是有狀態的。每次繪製前,咱們都必須當心地處理好狀態機。這些狀態變動就是經過命令來實現的。Beam 基於一些約定大幅簡化了人工的命令管理,固然你也能夠定製本身的命令。

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

圖中的 Buffers / Textures / Uniforms 都屬於典型的資源。一幀當中可能存在屢次繪製,每次繪製都須要着色器和相應的資源。在繪製之間,咱們經過命令來管理好 WebGL 的狀態。這就是筆者在設計 Beam 時,爲 WebGL 創建的思惟模型了。編輯器

理解這個思惟模型很重要。由於 Beam 的 API 設計就是徹底依據這個模型而實現的。讓咱們進一步看看一個實際的場景吧:ide

圖中咱們繪製了不少質感不一樣的球體。這一幀的渲染,則能夠這樣解構到上面的這些概念下:

  • 着色器無疑就是球體質感的渲染算法。對經典的 3D 遊戲來講,要渲染不一樣質感的物體,常常須要切換不一樣的着色器。但如今基於物理的渲染算法流行後,這些球體也不難作到使用同一個着色器來渲染。
  • 資源包括了大段的球體頂點數據、材質紋理的圖像數據,以及光照參數、變換矩陣等配置項。
  • 繪製是分屢次進行的。咱們選擇每次繪製一個球體,而每次繪製也都會啓動一次圖形渲染管線。
  • 命令則是相鄰的球體繪製之間,所執行的那些狀態變動。

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

還有很重要的一點,那就是雖然咱們已經知道,一幀畫面能夠經過屢次繪製而生成,而每次繪製又對應執行一次圖形渲染管線的執行。可是,所謂的圖形渲染管線又是什麼呢?這對應於這張圖:

渲染管線,通常指的就是這樣一個 GPU 上由頂點數據到像素的過程。現代的可編程 GPU 來講,管線中的某些階段是可編程的。WebGL 標準裏,這對應於圖中藍色的頂點着色器和片元着色器階段。你能夠把它們想象成兩個須要你來寫的函數。它們大致上分別作這樣的工做:

  • 頂點着色器輸入原始的頂點座標,輸出根據你的需求變換後的座標。
  • 片元着色器輸入一個像素位置,輸出根據你的需求計算出的像素顏色。

以上這些就是筆者從基礎庫設計者的視角出發,所看到的 WebGL 基礎概念啦。

API 基礎設計

雖然上面的章節徹底沒有涉及代碼,但充分理清楚概念後,編碼就是水到渠成的了。因爲命令能夠被自動化,在設計 Beam 時,筆者只定義了三個核心 API,分別是

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

它們各自對應於管理着色器、資源和繪製。讓咱們看看怎樣基於這個設計,來繪製 WebGL 中的 Hello World 三角形吧:

Beam 的代碼示例以下:

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, bottom left
    0, 1, 0, // vertex 1, top middle
    1, -1, 0 // vertex 2, bottom right
  ],
  color: [
    1, 0, 0, // vertex 0, red
    0, 1, 0, // vertex 1, green
    0, 0, 1 // vertex 2, blue
  ]
})
const indexBuffer = beam.resource(IndexBuffer, {
  array: [0, 1, 2]
})

beam
  .clear()
  .draw(shader, vertexBuffers, indexBuffer)
複製代碼

下面逐個介紹一些重要的 API 片斷。首先天然是用 Canvas 初始化出 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, bottom left
    0, 1, 0, // vertex 1, top middle
    1, -1, 0 // vertex 2, bottom right
  ],
  color: [
    1, 0, 0, // vertex 0, red
    0, 1, 0, // vertex 1, green
    0, 0, 1 // vertex 2, blue
  ]
})
複製代碼

裝頂點的 Buffer 一般會使用很緊湊的數據集。咱們能夠定義這份數據的一個子集或者超集來用於實際渲染,以便於減小數據冗餘並複用更多頂點。爲此咱們須要引入 WebGL 中的 IndexBuffer 概念,它指定了渲染時用到的頂點下標:

這個例子裏,每一個下標都對應頂點數組裏的 3 個位置。

const indexBuffer = beam.resource(IndexBuffer, {
  array: [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,由頂點着色器字符串、片元着色器字符串,和其它 Schema 字段組成。很是粗略地說,着色器對每一個頂點執行一次,而片元着色器則對每一個像素執行一次。這些着色器是用 WebGL 標準中的 GLSL 語言編寫的。在 WebGL 中,頂點着色器將 gl_Position 做爲座標位置輸出,而片元着色器則將 gl_FragColor 做爲像素顏色輸出。還有名爲 vColor 的 varying 變量,它會由頂點着色器傳遞到片元着色器,並自動插值。最後,這裏的 positioncolor 這兩個 attribute 變量,和前面 vertexBuffers 中的 key 相對應。這就是 Beam 用於自動化命令的約定了。

API 進階使用

相信必定有許多同窗對這一設計的可用性還會有疑問,畢竟即使能按這套規則來渲染三角形,未必能證實它適合更復雜的應用呀。其實 Beam 已經實際應用在了咱們內部的不一樣場景中,下面簡單介紹一些更進一步的示例。對這些示例的詳細介紹,均可以在筆者編寫的 Beam 文檔中查到。

渲染 3D 物體

咱們剛渲染出的三角形,還只是 2D 圖形而已。如何渲染出立方體、球體,和更復雜的 3D 模型呢?其實並不難,只要多一些頂點和着色器的配置就行。以用 Beam 渲染這個 3D 球體爲例:

3D 圖形一樣由三角形組成,而三角形也仍然由頂點組成。以前,咱們的頂點包含 positioncolor 屬性。而對於 3D 球體,咱們則須要使用 positionnormal 屬性。這個 normal 即爲法向量,包含了球體在該頂點位置的表面朝向,這對光照計算十分重要。

不只如此,爲了將頂點從 3D 空間轉換到 2D 空間,咱們須要一個由矩陣組成的「照相機」。對每一個傳遞到頂點着色器的頂點,咱們都須要爲其應用這些變換矩陣。這些矩陣對於並行運行的着色器來講,是全局惟一的。這就是 WebGL 中的 uniforms 概念了。Uniforms 也是 Beam 中的一種資源類型,包含着色器中的不一樣全局配置,例如相機位置、線條顏色、特效強度等。

所以要想渲染一個最簡單的球,咱們能夠複用上例中的片元着色器,只需更新頂點着色器爲以下所示便可:

attribute vec4 position;
attribute vec4 normal;

// 變換矩陣
uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projectionMat;

varying highp vec4 vColor;

void main() {
  gl_Position = projectionMat * viewMat * modelMat * position;
  vColor = normal; // 將法向量可視化
}
複製代碼

由於咱們已經在着色器中添加了 uniform 變量,Schema 也須要相應地添加一個 uniforms 字段:

const identityMat = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
const { vec4, mat4 } = SchemaTypes

export const MyShader = {
  vs: vertexShader,
  fs: fragmentShader,
  buffers: {
    position: { type: vec4, n: 3 },
    normal: { type: vec4, n: 3 }
  },
  uniforms: {
    // The default field is handy for reducing boilerplate
    modelMat: { type: mat4, default: identityMat },
    viewMat: { type: mat4 },
    projectionMat: { type: mat4 }
  }
}
複製代碼

而後咱們就能夠繼續使用 Beam 中簡潔的 API 了:

const beam = new Beam(canvas)

const shader = beam.shader(NormalColor)
const cameraMats = createCamera({ eye: [0, 10, 10] })
const ball = createBall()

beam.clear().draw(
  shader,
  beam.resource(VertexBuffers, ball.data),
  beam.resource(IndexBuffer, ball.index),
  beam.resource(Uniforms, cameraMats)
)
複製代碼

這個示例的代碼能夠在 Basic Ball 中找到。

Beam 是一個不以 3D 爲目標設計的 WebGL 庫,所以幾何對象、變換矩陣、相機等概念並不屬於它的一部分。爲了方便使用,Beam 的示例中包含了一些相關的 Utils 代碼,但別對它們要求過高啦。

添加動畫

如何移動 WebGL 中的物體呢?你固然能夠計算出運動後的新位置並更新 Buffer,但這可能很慢。另外一種方式是直接更新上面提到的變換矩陣。這些矩陣都屬於短小精悍,易於更新的 uniforms 資源。

經過 requestAnimationFrame API,咱們很容易就能讓上面的球體運動起來:

const beam = new Beam(canvas)

const shader = beam.shader(NormalColor)
const ball = createBall()
const buffers = [
  beam.resource(VertexBuffers, ball.data),
  beam.resource(IndexBuffer, ball.index)
]
let i = 0; let d = 10
const cameraMats = createCamera({ eye: [0, d, d] })
const camera = beam.resource(Uniforms, cameraMats)

const tick = () => {
  i += 0.02
  d = 10 + Math.sin(i) * 5
  const { viewMat } = createCamera({ eye: [0, d, d] })

  // 更新 uniform 資源
  camera.set('viewMat', viewMat)

  beam.clear().draw(shader, ...buffers, camera)
  requestAnimationFrame(tick)
}
tick() // 開始 Render Loop
複製代碼

這裏的 camera 變量是個 Beam 的 Uniforms 資源實例,它的數據以 key-value 的形式存儲。你能夠自由添加或更高不一樣的 uniform key。當 beam.draw 觸發時,只有與着色器相匹配的 uniform 數據纔會上傳到 GPU。

這個示例的代碼能夠在 Zooming Ball 中找到。

Buffer 資源一樣能夠經過相似的 set() 方法來更新,不過對於 WebGL 中較重的負載來講,這可能比較慢。

渲染圖像

咱們已經看到了 VertexBuffers / IndexBuffer / Uniforms 三種資源類型了。若是想渲染圖像,那麼咱們還須要最後一種關鍵的資源類型,即 Textures。這方面最簡單的示例,是帶有這樣貼圖的 3D 盒子:

對於須要紋理的圖形,在 positionnormal 以外,咱們還須要一個額外的 texCoord 屬性,以便於將圖像對齊到圖形的相應位置,這個值也會插值後傳入片元着色器中。看看這時的頂點着色器吧:

attribute vec4 position;
attribute vec4 normal;
attribute vec2 texCoord;

uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projectionMat;

varying highp vec2 vTexCoord;

void main() {
  vTexCoord = texCoord;
  gl_Position = projectionMat * viewMat * modelMat * position;
}
複製代碼

以及新的片元着色器:

uniform sampler2D img;
uniform highp float strength;

varying highp vec2 vTexCoord;

void main() {
  gl_FragColor = texture2D(img, vTexCoord);
}
複製代碼

如今咱們須要爲 Schema 添加 textures 字段:

const { vec4, vec2, mat4, tex2D } = SchemaTypes
export const MyShader = {
  vs: vertexShader,
  fs: fragmentShader,
  buffers: {
    position: { type: vec4, n: 3 },
    texCoord: { type: vec2 }
  },
  uniforms: {
    modelMat: { type: mat4, default: identityMat },
    viewMat: { type: mat4 },
    projectionMat: { type: mat4 }
  },
  textures: {
    img: { type: tex2D }
  }
}
複製代碼

最後就是渲染邏輯了:

const beam = new Beam(canvas)

const shader = beam.shader(MyShader)
const cameraMats = createCamera({ eye: [10, 10, 10] })
const box = createBox()

loadImage('prague.jpg').then(image => {
  const imageState = { image, flip: true }
  beam.clear().draw(
    shader,
    beam.resource(VertexBuffers, box.data),
    beam.resource(IndexBuffer, box.index),
    beam.resource(Uniforms, cameraMats),
    // 這個 'img' 鍵用來與着色器相匹配
    beam.resource(Textures, { img: imageState })
  )
})
複製代碼

這就是 Beam 中基礎的紋理使用方式了。由於咱們能直接控制圖像着色器,在此基礎上添加圖像處理特效是很容易的。

這個示例的代碼能夠在 Image Box 中找到。

這裏不妨將 createBox 換成 createBall 試試?

渲染多個物體

如何渲染多個物體呢?讓咱們看看 beam.draw API 的靈活性吧:

要渲染多個球體和多個立方體,咱們只須要兩組 VertexBuffersIndexBuffer,一組是球,另外一組則是立方體:

const shader = beam.shader(MyShader)
const ball = createBall()
const box = createBox()
const ballBuffers = [
  beam.resource(VertexBuffers, ball.data),
  beam.resource(IndexBuffer, ball.index)
]
const boxBuffers = [
  beam.resource(VertexBuffers, box.data),
  beam.resource(IndexBuffer, box.index)
]
複製代碼

而後在 for 循環裏,咱們就能夠輕鬆地用不一樣的 uniform 配置來繪製它們了。只要在 beam.draw 前更新 modelMat,咱們就能夠更新該物體在世界座標系中的位置,進而使其出如今屏幕上的不一樣位置了:

const cameraMats = createCamera(
  { eye: [0, 50, 50], center: [10, 10, 0] }
)
const camera = beam.resource(Uniforms, cameraMats)
const baseMat = mat4.create()

const render = () => {
  beam.clear()
  for (let i = 1; i < 10; i++) {
    for (let j = 1; j < 10; j++) {
      const modelMat = mat4.translate(
        [], baseMat, [i * 2, j * 2, 0]
      )
      camera.set('modelMat', modelMat)
      const resources = (i + j) % 2
        ? ballBuffers
        : boxBuffers

      beam.draw(shader, ...resources, camera)
    }
  }
}

render()
複製代碼

這裏的 render 函數以 beam.clear 開始,緊接着就能夠跟隨複雜的 beam.draw 渲染邏輯了。

這個示例的代碼能夠在 Multi Graphics 中找到。

離屏渲染

在 WebGL 中可使用 framebuffer object 來實現離屏渲染,從而將輸出渲染到紋理上。Beam 目前有一個相應的 OffscreenTarget 資源類型,不過注意這一類型是不能扔進 beam.draw 的。

好比默認的渲染邏輯看起來像這樣:

beam
  .clear()
  .draw(shaderX, ...resourcesA)
  .draw(shaderY, ...resourcesB)
  .draw(shaderZ, ...resourcesC)
複製代碼

經過可選的 offscreen2D 方法,這一渲染邏輯能夠輕鬆地這樣嵌套在函數做用域裏:

beam.clear()
beam.offscreen2D(offscreenTarget, () => {
  beam
    .draw(shaderX, ...resourcesA)
    .draw(shaderY, ...resourcesB)
    .draw(shaderZ, ...resourcesC)
})
複製代碼

這樣就能夠將輸出重定向到離屏的紋理上了。

這個示例的代碼能夠在 Basic Mesh 中找到。

其餘渲染技術

對實時渲染來講,用於標準化渲染質感的基於物理渲染 (PBR) 和用於渲染陰影的 Shadow Mapping,是兩種主要的進階渲染技術。筆者在 Beam 中也實現了這二者的示例,例如上面出現過的 PBR 材質球:

這些示例省略了一些瑣碎之處,相對更注重代碼的可讀性。能夠看看這裏:

如前文所言,目前 稿定設計 Web 版 的 3D 文字特性,也是從 Beam 的 PBR 能力出發來實現的。像這樣的 3D 文字:

或者這樣:

它們都是用 Beam 來渲染的。固然,Beam 只負責直接與 WebGL 相關的渲染部分,在其之上還有咱們定製以後,嵌入平面編輯器中使用的 3D 文字渲染器,以及文字幾何變換相關的算法。這些代碼涉及咱們的一些專利,並不會隨 Beam 一塊兒開源。其實,基於 Beam 很容易實現本身定製的專用渲染器,從而實現面向特定場景的優化。這也是筆者對這樣一個 WebGL 基礎庫的預期。

在 Beam 自帶的示例中,還展現了基於 Beam 實現的這些例子:

  • 物體 Mesh 加載
  • 紋理配置
  • 經典光照算法
  • 可串聯的圖像濾鏡
  • 深度紋理可視化
  • 基礎的粒子效果
  • WebGL 擴展配置
  • 上層 Renderer 封裝

Beam 已經開源,歡迎 PR 提供新的示例哦 :)

致謝與總結

Beam 的實現歷程裏,和 at 工業聚在 API 設計層面的交流給了筆者不少啓發。公司內外很多前輩的指點,也在筆者面臨關鍵決策時頗有幫助。最終這套方案可以落地,更離不開組內前端同窗們支持下最爲重要的,你們大量的細節工做

其實在接下 3D 文字的需求前,筆者並無比畫一堆立方體更復雜的 WebGL 經驗。但只要以基礎出發來學習,短短几個月裏,就足夠在知足產品需求的基礎上熟悉 WebGL,順便沉澱出這樣的輪子了。因此其實不必以「這個在我能力範圍外」爲理由來爲本身設限,把本身束縛在某個溫馨區內。做爲工程師,咱們能作的事還有不少!

而對於 Beam 自身的存在必要性而言,至少在國內,筆者確實還沒發如今 WebGL 基礎庫的這個細分定位上,有比它更符合理想化設計的開源產品。這裏真的不是說國內技術實力不行,像沈毅大神的 ClayGL 和謝光磊大神的 G3D 就都很是棒。區別之處在於,它們解決的實際上是比 Beam 更高層次、更貼近普通開發者的問題。拿它們和 Beam 相比較,就像拿 Vue 和簡化的 React Reconciler 相比較同樣。

越作愈加現,這是個至關小衆的領域。這意味着這樣的技術產品,可能很難得到社區主流羣體的嘗試與承認。

但是,有些最後繞不開的事,總有人要去作呀。

我主要是個前端開發者。若是你對 Web 結構化數據編輯、WebGL 渲染、Hybrid 應用開發,或者計算機愛好者的碎碎念感興趣,歡迎關注我或個人公衆號 color-album 噢 :)

相關文章
相關標籤/搜索