原生webGL練習:利用點精靈實現字串動畫!

CharTen發表於2018-07-30

原文首發於簡書,今天試了一下掘金的文章編輯器,簡直好用!以後文章就都發表在掘金了。。。

自從2017年2月份,寫了一個基於canvas2d的字串動畫的玩具之後,就一直想著怎麼樣把那個玩具效能優化一下。而且那玩意侷限性很大,只能渲染純色單色的字,而且通過每一幀瘋狂呼叫CanvasRenderingContext2d.fillText方法,導致繪製效率十分低下,非常吃cpu資源,cpu不好的話,非常容易卡頓。
當時還寫了一篇文章,《直播敲程式碼?你可能需要它》來介紹它,有興趣的朋友可以去翻一下,不管你用什麼方式去實現,基本原理都是那樣。 正好從去年下半年開始跳進了webGL這個天坑,今天我就用webGL重新實現一下它,把它當成一個練習。這個練習主要針對以下幾個內容:

  • 編寫頂點著色器和片元著色器的程式碼
  • 引入著色器程式碼並且讓瀏覽器去編譯執行它們
  • 給著色器程式傳值
  • 使用webgl繪製圖片
  • 使用點精靈

注意哦,webGL!== 3d。webGL只是個底層的繪製API,我僅僅是使用webGL去繪製2d的內容,所有操作均不依賴其他框架,跟threejs無關,跟babylonjs無關,僅僅是個原生wegGL練習。

github page demo,ios12以下不支援getUserMedia,andorid x5核心存在canvas繪製video畫面卡頓的bug(據說是尚不支援webGL視訊紋理)。

效果圖

預研

  • 原理 實際上,這個玩意也就是萬惡的馬賽克的升級版而已,馬賽克大家都很熟悉,最簡單的做法,在原影象上先畫一片小正方形,取小正方形的中點,讀取該點在影象上的顏色值,然後再給這個小正方形塗上這個顏色,然後幾片正方形組合起來,就是一個馬賽克區域啦。而我們要做的只是在最後的階段,塗色的時候做一點改變,把純顏色填充變為文字紋理填充,然後就變成了效果圖的樣子。
    萌萌噠的康納醬.png
  • 點精靈 實際上就是利用片元著色器繪製點。如果你有看過一些webGL的入門教程,第一章的例子通常是畫那個三個點組成的三角形,但實際上,你是可以讓webGL只填充三個頂點的顏色而不去給三角形填充顏色的,只需要改一下引數,改成如下的程式碼:
    gl.drawArray(gl.POINTS,0,3)
    複製程式碼
    只需要在gl.drawArray方法的第一個引數傳入gl.POINTS常量,就能開啟點精靈繪製,片元著色器也只為頂點上色。利用這個特性我們可以簡單繪製馬賽克。 相比使用canvas2d的fillRect方法繪製正方形,點精靈可以一次性繪製上千個正方形,而且你可以在片元著色器內,在正方形內部填充不同的顏色或者圖案。
    1000個點的簡單粒子效果.gif
    大家可以訪問這個demo感受一下 或者拿出手機掃一掃:
    點精靈demo地址.png
    如果大家感興趣,這部分以後也可以單獨拎出來講一講23333

開始編寫頂點著色器

現在開始動手寫程式碼了,首先是編寫頂點著色器的程式碼。頂點著色器的作用很簡單,就是確定點精靈的位置和大小用的,不過,因為webGL裡面的座標系跟我們平常在網頁開發裡面的座標系不一樣,我們平常用的什麼offsetLeft或者offsetTop,都是相對左上角原點去算的。而wegGL的原點是在影象的中間且y軸是反過來的,因此在頂點著色器裡面我們還要翻轉一下座標,方便後續js的計算。

precision mediump float;// 設定浮點精度:中
attribute vec2 a_position;// 點精靈位置
uniform vec2 u_resolution;// canvas的寬高
uniform float u_size; // 點精靈大小
varying vec2 v_position; //將點精靈的位置傳遞給片元著色器

void main(){
    // 從畫素座標轉換到 [0.0,1.0]這個區間內
    vec2 st = a_position / u_resolution;

    // 然後再把[0.0,1.0]對映到[-1.0,1.0]這個區間內,然後y軸翻轉
    vec2 position = (2.0 * st - 1.0) * vec2(1,-1);

    // 把st丟給片元著色器,影象取樣要用到
    v_position = st;

    // 確定點的大小
    gl_PointSize=u_size;

    // 確定點的位置
    gl_Position=vec4(position,0.0,1.0);
}
複製程式碼

著色器實際上就是一個函式,邏輯也不復雜,語法也簡單。對於前端來說,需要注意的是型別問題,還有就是一行程式碼結尾一定要帶分號,不然webGL分分鐘給你罷工。

開始編寫片元著色器

比較複雜的就是片元著色器了,雖說它的工作就是確定畫素點的顏色值,但是涉及到兩個紋理:視訊紋理與文字紋理的處理。

視訊紋理的處理很簡單,我們只需要拿到頂點著色器丟過來的那個st座標點,獲得視訊紋理在這個座標點的顏色就可以了。

而文字紋理就不一樣了,因為我是通過將一長串文字用繪製在一個canvas上,然後直接把這個canvas當成紋理丟進片元著色器。因此,在片元著色器裡面,我們需要確定當前繪製的點精靈要使用這一長串文字中的哪一個,然後把這個字裁剪出來。

那麼,片元著色器裡面究竟要用一長串文字中的哪一個?這個我們可以根據顏色灰度來決定,第一個字代表白色,最後一個字代表黑色,然後中間那些字對應各個階段的灰度值,這個規則是沿用之前的做法,只不過,之前是使用js來判斷使用哪個字,在這裡,我們將判斷權交給webGL,交個片元著色器,讓webGL的glsl語言來判斷。

precision mediump float; // 設定浮點精度:中
uniform sampler2D u_tex1; // 視訊紋理(一個video)
uniform sampler2D u_tex2; // 文字紋理(一個canvas)
uniform vec2 u_resolution; // canvas的寬高
uniform float u_len; // 文字的數量
varying vec2 v_position; // 點精靈的座標

void main(){
    // 點精靈對應在視訊紋理裡面的顏色
    vec4 color = texture2D(u_tex1 , v_position);
    
    // 算一下color的灰度,用來決定用哪一個字
    float gray = (color.r + color.g + color.b)/3.0;

    // 算一下,一個字在文字紋理裡面有多寬
    float s = 1.0/u_len;

    // 根據灰度,和字型寬度,算一下我們要的那個字從文字紋理裡面的第幾個畫素開始
    // 因為字數肯定是整數,這裡需要使用floor函式來丟掉小數部分
    // 然後算出是第幾個字然後再乘以字型寬度,得到我們要的字在文字紋理的位置 
    float p = floor((1.0-gray)/s)*s;
    
    // 從文字紋理拿字
    vec4 text_color = texture2D(u_tex2,vec2(
        gl_PointCoord.x/u_len + p,
        gl_PointCoord.y
    ));

    // 記錄一下我們拿到的文字紋理的alpha通道
    float alpha = text_color.a;

    // 輸出顏色,讓有筆畫的部分著色,沒有筆畫的透明
    gl_FragColor = vec4(color.rgb,alpha);
}
複製程式碼

著色器跟C差不多,語法上真的不難。
難的是,你要如何確定每一個畫素點的顏色。 包括以後要學習的3D部分,也是同樣的道理。

引入著色器程式碼並編譯

下面基本是教科書式的程式碼 首先是用webGL建立一個著色器程式物件,為頂點著色器和片元著色器的連線做準備。

var cvs = document.getElementById("cvs");
var gl = cvs.getContext("webgl");
var progarm = gl.createProgram();
複製程式碼

接著是建立頂點著色器和片元作色器,把上面的著色器原始碼拿給瀏覽器去編譯。

//建立一個頂點著色器物件
var vShader = gl.createShader(gl.VERTEX_SHADER);

//將頂點著色器的原始碼懟進去
gl.shaderSource(vShader,`假裝是上面的頂點作色器原始碼`);

//然後開始編譯原始碼
gl.compileShader(vShader);

//編譯完之後,叫上面的著色器程式物件過來收貨
gl.attachShader(program,vShader);

//然後建立片元著色器物件
var fShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fShader,`假裝是上面的片元作色器原始碼`);
gl.complieShader(fShader);
gl.attachShader(program,fShader);
複製程式碼

program物件收到頂點片元兩個著色器之後,就可以幫這兩個著色器連線起來。之前的頂點著色器裡面說到把st 變數丟給片元著色器,這個就是program物件幫忙丟的。

// 連線起兩個程式
gl.linkProgram(program);

//然後跟webgl說,我要使用這個程式
gl.useProgram(program);

複製程式碼

這個過程非常繁瑣,我們可以封裝一下,方便使用與記憶

/**
* @name createProgram
* @desc 建立著色器程式
* @param {WebGLRenderingContext} gl - webGl的context
* @param {String} vsource - 頂點著色器原始碼字串
* @param {String} fsource - 片元著色器原始碼字串
* @return {WebGLProgram}  - 著色器程式物件
*/
function createProgram(gl,vsource,fsource){
  const program = gl.createProgram();

  const createShader = (source,type)=>{
    const shader = gl.createShader(type);
    gl.shaderSource(shader,source);
    gl.compileShader(shader);
    gl.attachShader(program,shader);
    return shader;
  }

  createShader(vsource,gl.VERTEX_SHADER);
  createShader(fsource,gl.FRAGMENT_SHADER);
  gl.linkProgram(program );
  return program ;
}

//使用
var cvs =document.createElement("canvas");
var gl = cvs.getContext("webgl");
var program = createProgram(
  gl,
  `假裝是頂點著色器原始碼`,
  `假裝是片元著色器原始碼`
);
gl.useProgram(program )

複製程式碼

這樣使用就簡單多了。

建立文字紋理與視訊紋理

關於紋理的建立以及一些小問題,之前的文章《webGL入門小貼士》裡面多多少少有涉及,大家可以參考看一下,這裡我就直接貼程式碼了。 建立紋理的方法封裝:

    /**建立紋理貼圖
      * @param {WebGLRenderingContext} webgl - 使用webgl的上下文
      * @param {Canvas||Image} image - 要作為紋理的圖片物件
      * @return {WebglTexture} texture物件
      */
    function createTexByImage(webgl, image) {
        var texture = webgl.createTexture();
        webgl.bindTexture(webgl.TEXTURE_2D, texture);
        webgl.texImage2D(
            webgl.TEXTURE_2D, 
            0, 
            webgl.RGBA, 
            webgl.RGBA, 
            webgl.UNSIGNED_BYTE, 
            image
        );

        if (isPowerOf2(image.width) && isPowerOf2(image.height)) {
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
            return texture
        }

        webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_MIN_FILTER, webgl.NEAREST);
        webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_MAG_FILTER, webgl.NEAREST);
        webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_WRAP_S, webgl.CLAMP_TO_EDGE);
        webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_WRAP_T, webgl.CLAMP_TO_EDGE);

        return texture
    }
    
    /**檢查數字是否為2的指數
      * @param {Number} value - 要檢查的值
      * @return {Boolean}
      */
    function isPowerOf2(value) {
        return !(value & (value - 1));
    }
複製程式碼

然後使用的話,就直接createTexByImage(gl,image);傳入canvas/image/video建立紋理。

  • 文字紋理canvas的繪製 首先用canvas2D畫出32*32的格子,然後把文字fillText進去,只要就能保證字型寬度相等,不然中文與英文字母混編的話,字型不統一,在glsl裡面就非常難計算

      /**
       * 建立文字紋理
       * @param {String} text - 要成為紋理的文字
       * @param {String} fontFamily - 文字的字型
       * @return {HTMLCanvasElement}
       */
      function createTextTextrue(text, fontFamily) {
          var cvs = document.createElement("canvas");
          var ctx = cvs.getContext("2d");
    
          cvs.width = 32 * text.length;
          cvs.height = 32;
    
          ctx.font = "32px " + fontFamily;
          ctx.textAlign = "center";
          ctx.textBaseline = "middle";
    
          text.split("").forEach(function(word, i) {
              ctx.fillText(word, i * 32 + 16, 16);
          });
    
          return cvs;
      }
    複製程式碼

    結合上面的建立紋理的函式,我們就可以這樣使用:

    createTexture(gl,createTextTextrue('文字','微軟雅黑'))
    複製程式碼

    一個文字紋理就被建立出來準備給webGL用了。

  • 視訊紋理,直接用上面的函式,createTexure(gl,video)把video傳進去就可以了。只不過有一點要注意,傳入的時候video要處於有畫面的狀態,如果video尚未播放,傳進去會報錯。

取樣點

取樣點也就是那些頂點的座標,知道canvas的尺寸,以及字型的大小,然後就可以生成座標了。
因為資料也簡單,就只有x,y值,所以,給webGL傳值可以說相當容易了。 我們直接用一個buffer傳過去

    /**建立取樣點 */
    function createSampPoints(width, height, step) {
        var a = [];

        for (var i = 0; i <= height; i += step) {
            for (var j = 0; j <= width; j += step) {
                a.push(j, i);
            }
        }
        return a;
    }
    
    // 建立頂點
    var points = new Float32Array(createSampPoints(
        cvs.width,
        cvs.height,
        32
    ));

    //建立buffer
    var buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    
    //將頂點寫入記憶體
    gl.bufferData(gl.ARRAY_BUFFER, points , gl.STATIC_DRAW);
    
    // 獲取a_position的記憶體地址
    var index = gl.getAttribLocation(program,'a_position'),
    // 啟用a_position
   gl.enableVertexAttribArray(index);

   // 往a_position寫值(規定a_position讀取buffer的規則)
   // 讀兩個點,float型別,不需要歸一化,兩次點集相隔0,從0位開始讀取
   gl.vertexAttribPointer(index,2, gl.FLOAT, false, 0, 0)

複製程式碼

這樣著色器裡面就能夠讀到a_position的值了,也就是我們丟過去的取樣點。

繪製

還是老樣子,先使用getUserMedia讀到視訊流,然後讓video播放它。

而webGL這邊,可以開一個requestAnimationFrame動畫,不斷查詢video的播放狀態和上面那些操作是否就緒,如果符合條件的話就開始繪製,不符合的話就跳過。還有就是,因為我這邊是通過ajax來請求兩個著色器的原始碼的,所以視訊開始播放的時候,可能我ajax請求還在路上,所以根本沒法監聽video的play事件,只能瘋狂輪詢了。如果你能確定上面那些操作在視訊開始播放的時候就已經就緒了,可以大膽地監聽play事件。

繪製的話,因為視訊畫面會更新的緣故,所以每一幀你都需要更新一下視訊紋理,但是這裡千萬要注意的是,更新紋理不是建立紋理!!!,千萬別在requestAnimationFrame呼叫gl.createTexture方法,每一幀都建立紋理對記憶體的消耗遠遠大於GC的收集速度,進而導致記憶體洩漏。正確的做法是,找到之前那個視訊紋理,重新啟用它,然後使用gl.texImage2D方法去更新紋理。

function draw(){
  if(/**判斷一下是否可以繪製*/){
       requestAnimationFrame(draw);
       //直接下一幀
       return 
  }
  // u_tex0代表的是視訊紋理,所以我們啟用一下TEXTURE0
  gl.activeTexture(gl.TEXTURE0);

  // 假設videoTexture是之前通過createTexture建立出來的紋理
  // 這裡的繫結是綁上面的TEXTURE0紋理,將videoTexture重新賦值給它
  gl.bindTexture(gl.TEXTURE_2D, videoTexture);

  // 將視訊當前幀傳入進去
  gl.texImage2D(
    gl.TEXTURE_2D, 
    0, 
    gl.RGBA, 
    gl.RGBA, 
    gl.UNSIGNED_BYTE, 
    video
  );

  // 清畫面
  gl.clear(gl.COLOR_BUFFER_BIT);

  // 繪製
  gl.drawArrays(
    gl.POINTS,
    0,
    pointes.length / 2
  );
  requestAnimationFrame(draw);
}


複製程式碼

同樣的對文字紋理的更新也遵循此辦法。 繪製的事情幾乎與js無關,js在這裡面的作用就是,配置好一切、更新紋理,然後呼叫繪製而已,對cpu的開銷也小,繪製過程中連一次迴圈什麼的都不需要,最主要的,是在移動端的表現相當流暢,webGL這種技術簡直跟親媽一樣強大。

結語

請各位同學千萬別問這玩意在現實中有什麼用,能實現什麼需求。看標題,這個只是個練習而已,僅僅是為了好玩。 不然你開啟《webgl程式設計指南 》這本書,每個例子都是畫三角形,畫三角形,我畫到現在對三角形有陰影了。。。 嘛,本來學習就是一件枯燥的事情(對我這種學渣來說),如果不在這個過程中找到樂趣所在,很容易就放棄的。多多利用學到的知識,再結合以前學到的,去寫一些有趣的練習吧,舉一反三,這樣對知識的理解或更深刻。 況且在這個練習裡面,遇上了記憶體洩漏的問題並且解決掉了它,可謂是意外之喜呢。畢竟書裡沒寫這部分的內容對不對,遇到就是賺到2333


參考

相關文章