隨著現代瀏覽器端技術不短進步,web也逐漸支援了3D繪圖技術。時至今日webGL這項技術已經在去年迎來了2.0版本。現如今資料視覺化技術也日益繁雜起來,越來越多的資料視覺化也逐漸從平面圖表增加了許多新的3D型別圖表,亦或者藉助webGL的力量讓資料渲染能力得到大幅度提升。
本人也是因為上司的工作安排,開始了webGL的學習之路。在學習的過程中發現目前網上學習原生webGL的資料少之又少,手頭僅僅握著《webGL程式設計指南》這本書。
沒錯,就是這本。這本書也的確足夠像我這樣的初心者看了,本篇文章一共涉及三個部分:著色器程式和編譯流程、四階貝塞爾曲線的頂點資料生成函式、緩衝區的使用。後面我會附上原始碼,感興趣的同學可以去看一下。
本篇內部分內容如果出現錯誤等,歡迎大家進行指點。
進入正片:
首先,webGL這一技術是由JavaScript和glsl著色器語言著兩種語言組成,那麼什麼是glsl著色器語言呢?
著色器語言(簡稱shader)是一段執行在顯示卡上的程式,在webGL當中有兩種著色器,一個叫做頂點著色器,另一個叫片元著色器。(來源:《webGL程式設計指南》)
通俗的講,頂點著色器的作用即時描述一個三維物體的物理空間點座標以及與這些座標相關的資訊。我們可以看到下面的立方體中有八個點(v0-v7)(圖片來源:WebGL入門教程第1篇--六色立方 - CSDN部落格)
我們可以看到這個立方體是有著八個點圍城的面來組成的立方體,所以這些點被稱作“頂點”。
片元著色器即根據著色器程式控制畫素的顏色渲染,片元也可以理解為螢幕上顯示的一個畫素點。大致可理解為下圖:(出處忘記了)
簡單的介紹過了兩個需要用到的著色器之後,我們進入下一階段:
著色器程式編譯
webGL的著色器程通常會以字串的形式提前準備好,我們一起來看一下此次我們需要用到的著色器程式程式碼:
const VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'void main(){\n' +
' gl_Position = a_Position;\n' +
'}\n';
const FSHADER_SOURCE =
'precision mediump float;\n' +
'uniform vec4 u_FragColor;\n' +
'void main() {\n' +
' gl_FragColor = u_FragColor;\n' +
'}\n';
複製程式碼
靜態變數VSHADER_SOURCE就是頂點著色器程式碼,下面的則是片元著色器程式碼。
看程式碼外觀上學過C語言的同學會覺得很有親切感,但是要注意的是著色器程式碼並非C語言的程式碼。
我們注意看頂點著色器的第一行程式碼與片元著色器的第二行程式碼,可以看到他們的語句結構是一致的,由以下三部分組成:
儲存限定符表示後面的變數為attribute變數,且該變數的資料將從著色器外部傳入。注意:attribute並非代表著變數儲存型別,而是類似於一種傳輸資料的限定標記,與int、float這樣的宣告儲存空間的型別不同。變數a_Position的資料儲存型別是指前方的vec4。
我們除去剛才看到的attribute著個儲存限定符外,我們還看到了另外一個儲存限定符——uniform。
那麼我們應該如何使用這兩個儲存限定符呢,根據《webGL程式設計指南》一書中的描述總結成一句話即:與頂點資料相關採用 attribute,與頂點資料無關採用uniform。
頂點資料實際上就是用來描述每個頂點的座標值,假設我們需要畫一條線段,一條線段就需要兩個點來連線,那麼我們的頂點資料即可以向下面那樣表示:
let position = [-1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
// x1, y1, z1, x2, y2, z2
複製程式碼
我們可以發現所有的資料均按照一定規律儲存到了陣列中,像這樣描述頂點座標值集合的資料就可以採用attribute這一儲存限定符。
那麼就僅僅只有頂點資料才可以採用attribute嗎,答案是否定的。
在實際的填坑過程中,我發現像比如用來描述顏色的資料也可以用attribute來修飾,目前我發現attribute儲存型別只會出現在頂點著色器中(此處仍待考證,如有錯誤請指正)。
uniform儲存限定符一般用作儲存顏色、各種矩陣(檢視、模型、投影三大矩陣,我個人簡稱MVP。。→_→)或其他型別資料。
好了,廢了這麼多口舌,接下來終於進入到編譯階段,在這個階段一共分為以下幾個步驟:
- 建立著色器物件
- 向著色器物件傳入著色器原始碼
- 編譯原始碼
- 建立著色器程式
- 為著色器程式分配著色器物件
- 連線程式
接下來我們一起來看一下著色器程式原始碼編譯流程這一部分的程式碼:
// 分別建立頂點著色器物件與片元著色器物件
let shader_V = gl.createShader(gl.VERTEX_SHADER);
let shader_F = gl.createShader(gl.FRAGMENT_SHADER);
// 向相應的著色器物件傳入字串原始碼
gl.shaderSource(shader_V, VSHADER_SOURCE);
gl.shaderSource(shader_F, FSHADER_SOURCE);
// 編譯頂點著色器原始碼
gl.compileShader(shader_V);
let isCompiled_V = gl.getShaderParameter(shader_V, gl.COMPILE_STATUS);
if (!isCompiled_V) {
throw new Error('compile Shader is failed');
}
// 編譯片元著色器原始碼
gl.compileShader(shader_F);
let isCompiled_F = gl.getShaderParameter(shader_F, gl.COMPILE_STATUS);
if (!isCompiled_F) {
throw new Error('compile Shader is failed');
}
// 建立著色器程式
let program = gl.createProgram();
// 將著色器物件分配給著色器程式
gl.attachShader(program, shader_V);
gl.attachShader(program, shader_F);
// 連線著色器程式
gl.linkProgram(program);
let isLinked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!isLinked) {
throw new Error('link Shader is failed');
}
// 啟用指定的著色器程式
gl.useProgram(program);
複製程式碼
細心的同學會發現,我最後一行程式碼並沒有在剛才的流程上出現。這是因為在多物體渲染中很可能存在使用不同的著色器程式程式碼這種情況,也因此會根據情況產生多個著色器程式,並且在呼叫渲染函式時,webGL會以當前啟用的著色器程式為基準去渲染,因此著色器程式需要在各種情況下去切換,所以他不能被算作編譯流程當中。
獲取儲存限定符地址
編譯過著色器程式之後,我們將兩個儲存限定符相關的變數地址獲取到,為我們下一步工作做準備。
// 獲取儲存限定符型別變數地址
let a_Position = gl.getAttribLocation(program, 'a_Position');
let u_FragColor = gl.getUniformLocation(program, 'u_FragColor');
複製程式碼
我們可以看到attribute、uniform這兩種儲存限定符變數獲取地址的API稍有不同,因此在使用的時候需要注意區分。
貝塞爾曲線公式
接下來我們需要開始準備各種繪製圖形所需的頂點資料了。切合此次主題,我們需要用到貝塞爾曲線公式來計算出頂點,從而生成貝塞爾曲線。
/**
* 生成四階貝塞爾曲線定點資料
* @param p0 起始點 { x : number, y : number, z : number }
* @param p1 控制點1 { x : number, y : number, z : number }
* @param p2 控制點2 { x : number, y : number, z : number }
* @param p3 終止點 { x : number, y : number, z : number }
* @param num 線條精度
* @param tick 繪製係數
* @returns {{points: Array, num: number}}
*/
function create3DBezier(p0, p1, p2, p3, num, tick) {
let pointMum = num || 100;
let _tick = tick || 1.0;
let t = _tick / (pointMum - 1);
let points = [];
for (let i = 0; i < pointMum; i++) {
let point = getBezierNowPoint(p0, p1, p2, p3, i, t);
points.push(point.x);
points.push(point.y);
points.push(point.z);
}
return points;
}
/**
* 四階貝塞爾曲線公式
* @param p0
* @param p1
* @param p2
* @param p3
* @param t
* @returns {*}
* @constructor
*/
function Bezier(p0, p1, p2, p3, t) {
let P0, P1, P2, P3;
P0 = p0 * (Math.pow((1 - t), 3));
P1 = 3 * p1 * t * (Math.pow((1 - t), 2));
P2 = 3 * p2 * Math.pow(t, 2) * (1 - t);
P3 = p3 * Math.pow(t, 3);
return P0 + P1 + P2 + P3;
}
/**
* 獲取四階貝塞爾曲線中指定位置的點座標
* @param p0
* @param p1
* @param p2
* @param p3
* @param num
* @param tick
* @returns {{x, y, z}}
*/
function getBezierNowPoint(p0, p1, p2, p3, num, tick) {
return {
x : Bezier(p0.x, p1.x, p2.x, p3.x, num * tick),
y : Bezier(p0.y, p1.y, p2.y, p3.y, num * tick),
z : Bezier(p0.z, p1.z, p2.z, p3.z, num * tick),
}
}
複製程式碼
如果我們只需要獲取整條貝塞爾曲線上所有的頂點資料集,那麼我們就需要呼叫create3DBezier()函式並填入指定引數即可。關於四階貝塞爾曲線公式等數學知識請自行百度,本人也僅是照著公式將函式敲出來了→_→。
// 傳入頂點資料
let bezierPoint = create3DBezier(
{ x : -0.7, y : 0, z : 0 }, // p0
{ x : -0.25, y : 0.5, z : 0 }, // p1
{ x : 0.25, y : 0.5, z : 0 }, // p2
{ x : 0.7, y : 0, z : 0 }, // p3
20,
1.0
);
複製程式碼
通過該函式,我們得到了頂點資料的集合,格式與上面我們舉例子繪製線條的格式是一樣的,只是資料量上有差異。
頂點資料型別
得到資料之後我們並沒有完成對資料的處理,因為接下來我們需要將資料型別進行轉換。那麼轉換的方式非常簡單:
let points = new Float32Array(bezierPoint);
複製程式碼
我們可以看到直接將Array型別的陣列變成了Float32Array型別了。那麼有的同學就會問,為什麼需要這麼做?這麼做的好處是什麼?
首先我們把第一個問題先放一下,因為會在後面講到緩衝區操作相關時我會為大家介紹,那麼我們來看看用型別化陣列的好處是什麼呢?
關於Array型別陣列對於JavaScript功底比較好的同學可能會知道,JavaScript的陣列(Array)嚴格上來講還不能被稱為是“陣列”,因為他僅僅是一個類似於物件的存在,並且在記憶體上的儲存方式上與型別化陣列有截然不同的方式。
從上圖中我們可以看出來,一般的Array型別陣列在記憶體當中並不是一串連續的記憶體空間地址,但Float32Array則是連續的,因此兩者在陣列存取上的速度肯定有著明顯的區別。同時在資料進行大量生成時Array.push()這種方式雖然十分便利,但重複的分配空間操作會帶來很大的效能影響,而使用型別化陣列雖然便利度比Array稍微遜色一點,但帶來的效能優化還是很可觀的。曾經公司內部將6W條資料進行頂點資料的生成,裡面用到了concat、push等操作,就會發現效能問題相當明顯。
好了,解釋完了其中一個問題過後,我們帶著一個未解決的問題進入下一個階段:
緩衝區操作
同學們首先會好奇,這個緩衝區的作用到底是什麼?不用他是否可以繪製圖形?習慣性先回答第二個問題。。硬要較真的話不用緩衝區當然是可以的。但不用緩衝區你也沒辦法繪製出絢麗的webGL圖形→_→
那麼回過頭來看,緩衝區的作用究竟是什麼呢?藉助緩衝區我們可以一次性將大量頂點資料傳入到glsl著色器當中。這部分內容我建議大家去閱讀一下《webGL程式設計指南》一書,根據書的章節一點點了解,會更加貼切的理解為什麼需要緩衝區。
那麼接下來讓我們來看看緩衝區部分的程式碼吧:
// 建立緩衝區
let vertexBuffer = gl.createBuffer();
// 繫結緩衝區
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 向緩衝區寫入資料
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
// 分配緩衝區至指定著色器變數地址
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0);
// 連線地址
gl.enableVertexAttribArray(a_Position);
複製程式碼
從上至下我們大體可分為五個步驟:
- 建立緩衝區
- 繫結緩衝區
- 向緩衝區寫入資料
- 分配緩衝區至指定著色器變數地址
- 連線至該著色器地址
關於這幾個步驟的緩衝區操作其實是可以視情況呼叫的,也就是說不一定非要按著順序來,在這裡只是給對webGL感興趣的同學進行簡單的說明。
並且關於緩衝區的操作也可以按照情況可以進行優化。就在這裡不進行細講了,如果已經有了一定基礎的同學,可以嘗試閱讀一下three.js的原始碼,在three.js內對緩衝區操作進行了許多的優化。
到了這裡我該對上面殘留的那個型別化陣列問題進行解答了。其實型別化陣列不止Float32Array一種,型別化陣列共分為以下幾種:
Int8Array
Uint8Array
Int16Array
Uint16Array
Int32Array
Uint32Array
Float32Array
Float64Array
複製程式碼
具體選用哪種型別化陣列是要和你的緩衝區資料型別相匹配的,關鍵就在於gl.vertexAttribPointer()這個API身上。那麼我們來具體看一下這個函式的引數:
根據這個表中給出的資訊我們可以看到第三個引數就是用來指定緩衝區資料型別的,那麼第三個引數都可以是哪些型別呢?我們繼續看下面一個表:
這樣大家恐怕就清楚什麼情況下采用何種型別化陣列了吧。
之後我們把剩下的顏色值也傳入到glsl中吧:
// 傳入顏色
gl.uniform4fv(u_FragColor, [0.0, 1.0, 1.0, 1.0]);
複製程式碼
繪製圖形
到了這裡就即將進入結尾階段了,首先我們先將畫布作清空處理:
// 設定顏色緩衝區清空顏色
gl.clearColor(0.0, 0.0, 0.0, 1.0);
// 清空顏色緩衝區
gl.clear(gl.COLOR_BUFFER_BIT);
複製程式碼
清空畫布之後,我們就開始呼叫繪製函式吧
// 繪製
gl.drawArrays(gl.LINE_STRIP, 0, bezierPoint.length / 3);
複製程式碼
關於gl.drawArrays()函式,首先第一個引數即指定繪圖型別,第二個引數是指從第幾個頂點開始繪製,第三個引數即繪製圖形需要使用多少個頂點。
那麼他的第一個引數都有哪些呢?我們繼續看錶吧
以上就是webGL的基本繪圖型別,我們此次繪製的是一個單條多點的線段,因此我們採用的是gl.LINE_STRIP。
完成以上程式碼後我們來看看效果吧~
到此我們的小案例就算完成了~
那麼我也為大家展示一下目前公司內部運用原生webgl做出來的兩個相似的demo:
demo1
demo2
謝謝大家支援,如有任何問題請在留言處進行批評指正。
貝塞爾曲線demo地址:Axiny/webgl-bezier-demo