要想使用 WebGL 直通 GPU 的渲染能力,不少同窗的第一反應就是使用現成的開源項目,如著名的 Three.js 等。可是,直接套用它們就是惟一的選擇了嗎?若是想深刻 WebGL 基礎甚至本身造輪子,又該從何下手呢?本文但願以筆者本身的實踐經驗爲例,科普一些圖形基礎庫設計層面的知識。前端
不久前,咱們爲 稿定設計 Web 端 添加了 3D 文字的編輯能力。你能夠將本來侷限在二維平面上的文字立體化,爲其添加豐富的質感,就像這樣:git
3D 文字的 WebGL 渲染部分,使用了咱們自研的 Beam 基礎庫。它並非某個開源項目的 fork 魔改版,而是從頭開始正向實現的。做爲 Beam 的做者,推進一個新輪子在生產環境中落地的經歷,無疑給了我不少值得分享的經驗。下面就先從最基本的定位出發,聊聊 WebGL 基礎庫吧。github
提到 WebGL 時,你們廣泛會想到 Three.js 這樣的開源項目,這多少有點「用 React 全家桶表明 Web 前端」 的感受。其實,Three 已是個頗爲高層的 3D 渲染引擎了。它和 Beam 這樣的 WebGL 基礎庫之間,粗略來講有這些異同:算法
其實,我更願意把 Three 和 Beam 的對比,當作 React 和 jQuery 的對比:一個想將渲染端的複雜度屏蔽到極致,另外一個則想將直接操做渲染端的 API 簡化到極致。有鑑於圖形渲染管線的高度靈活性,再加上重量級上的顯著區別(Three 源碼體積已超過 1M,且 Tree Shaking 效果不佳),筆者相信凡是但願追求控制的場景,均可以是 WebGL 基礎庫的用武之地。編程
社區有 Regl 這樣流行的 WebGL 基礎庫,這證實相似的輪子並非個僞需求。canvas
在設計基礎庫的實際 API 前,咱們至少須要清楚 WebGL 是如何工做的。WebGL 代碼有不少瑣碎之處,一頭扎進代碼裏,容易使咱們只見樹木不見森林。根據筆者的理解,整個 WebGL 應用中咱們操做的概念,其實不外乎這幾個:數組
這些概念是如何協同工做的呢?請看下圖:app
圖中的 Buffers / Textures / Uniforms 都屬於典型的資源。一幀當中可能存在屢次繪製,每次繪製都須要着色器和相應的資源。在繪製之間,咱們經過命令來管理好 WebGL 的狀態。這就是筆者在設計 Beam 時,爲 WebGL 創建的思惟模型了。編輯器
理解這個思惟模型很重要。由於 Beam 的 API 設計就是徹底依據這個模型而實現的。讓咱們進一步看看一個實際的場景吧:ide
圖中咱們繪製了不少質感不一樣的球體。這一幀的渲染,則能夠這樣解構到上面的這些概念下:
如何理解狀態變動呢?不妨將 WebGL 想象成一個具有大量開關與接口的儀器。每次按下啓動鍵(執行繪製)前。你都要配置好一堆開關,再鏈接好一條接着色器的線,和一堆接資源的線,就像這樣:
還有很重要的一點,那就是雖然咱們已經知道,一幀畫面能夠經過屢次繪製而生成,而每次繪製又對應執行一次圖形渲染管線的執行。可是,所謂的圖形渲染管線又是什麼呢?這對應於這張圖:
渲染管線,通常指的就是這樣一個 GPU 上由頂點數據到像素的過程。現代的可編程 GPU 來講,管線中的某些階段是可編程的。WebGL 標準裏,這對應於圖中藍色的頂點着色器和片元着色器階段。你能夠把它們想象成兩個須要你來寫的函數。它們大致上分別作這樣的工做:
以上這些就是筆者從基礎庫設計者的視角出發,所看到的 WebGL 基礎概念啦。
雖然上面的章節徹底沒有涉及代碼,但充分理清楚概念後,編碼就是水到渠成的了。因爲命令能夠被自動化,在設計 Beam 時,筆者只定義了三個核心 API,分別是
它們各自對應於管理着色器、資源和繪製。讓咱們看看怎樣基於這個設計,來繪製 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),即 position 和 color,每一個屬性都對應於一個獨立的 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 變量,它會由頂點着色器傳遞到片元着色器,並自動插值。最後,這裏的 position
和 color
這兩個 attribute 變量,和前面 vertexBuffers
中的 key 相對應。這就是 Beam 用於自動化命令的約定了。
相信必定有許多同窗對這一設計的可用性還會有疑問,畢竟即使能按這套規則來渲染三角形,未必能證實它適合更復雜的應用呀。其實 Beam 已經實際應用在了咱們內部的不一樣場景中,下面簡單介紹一些更進一步的示例。對這些示例的詳細介紹,均可以在筆者編寫的 Beam 文檔中查到。
咱們剛渲染出的三角形,還只是 2D 圖形而已。如何渲染出立方體、球體,和更復雜的 3D 模型呢?其實並不難,只要多一些頂點和着色器的配置就行。以用 Beam 渲染這個 3D 球體爲例:
3D 圖形一樣由三角形組成,而三角形也仍然由頂點組成。以前,咱們的頂點包含 position 和 color 屬性。而對於 3D 球體,咱們則須要使用 position 和 normal 屬性。這個 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 盒子:
對於須要紋理的圖形,在 position 和 normal 以外,咱們還須要一個額外的 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 的靈活性吧:
要渲染多個球體和多個立方體,咱們只須要兩組 VertexBuffers
和 IndexBuffer
,一組是球,另外一組則是立方體:
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 實現的這些例子:
Beam 已經開源,歡迎 PR 提供新的示例哦 :)
Beam 的實現歷程裏,和 at 工業聚在 API 設計層面的交流給了筆者不少啓發。公司內外很多前輩的指點,也在筆者面臨關鍵決策時頗有幫助。最終這套方案可以落地,更離不開組內前端同窗們支持下最爲重要的,你們大量的細節工做。
其實在接下 3D 文字的需求前,筆者並無比畫一堆立方體更復雜的 WebGL 經驗。但只要以基礎出發來學習,短短几個月裏,就足夠在知足產品需求的基礎上熟悉 WebGL,順便沉澱出這樣的輪子了。因此其實不必以「這個在我能力範圍外」爲理由來爲本身設限,把本身束縛在某個溫馨區內。做爲工程師,咱們能作的事還有不少!
而對於 Beam 自身的存在必要性而言,至少在國內,筆者確實還沒發如今 WebGL 基礎庫的這個細分定位上,有比它更符合理想化設計的開源產品。這裏真的不是說國內技術實力不行,像沈毅大神的 ClayGL 和謝光磊大神的 G3D 就都很是棒。區別之處在於,它們解決的實際上是比 Beam 更高層次、更貼近普通開發者的問題。拿它們和 Beam 相比較,就像拿 Vue 和簡化的 React Reconciler 相比較同樣。
越作愈加現,這是個至關小衆的領域。這意味着這樣的技術產品,可能很難得到社區主流羣體的嘗試與承認。
但是,有些最後繞不開的事,總有人要去作呀。
我主要是個前端開發者。若是你對 Web 結構化數據編輯、WebGL 渲染、Hybrid 應用開發,或者計算機愛好者的碎碎念感興趣,歡迎關注我或個人公衆號
color-album
噢 :)