WebGL學習(3) - 3D模型

Jeff.Zhong發表於2017-12-27

  原文地址:WebGL學習(3) - 3D模型
  相信很多人是以建立逼真酷炫的三維效果為目標而學習webGL的吧,首先我就是?。我掌握了足夠的webGL技巧後,正準備大展身手時,遇到了一種尷尬的情況:還是做不出想要的東西?。為啥呢,因為沒有3D模型可供操作啊,純粹用程式碼構建複雜的3D模型完全不可想象。那必須使用3dMax,maya,以及開源的blender等建模軟體進行構建。既然已經入了webGL的坑了,那也只能硬著頭皮繼續學習3D建模,斷斷續續學了一個多月的blender教程,總算入門了。
  這節主要學習如何匯入模型檔案,然後用程式碼應用效果,操作模型。首先展示下我的大作,噴火戰鬥機的3D模型:webGL 噴火戰鬥機
WebGL學習(3) - 3D模型

內容大綱

  1. 模型檔案
  2. 著色器
  3. 光照
  4. 模型變換
  5. 事件處理

模型檔案

  blender匯出的模型檔案plane.obj, 同時還包括材質檔案plane.mtl。模型包括2800多個頂點,2200多個面,共200多k的體積,內容比較大,所以只能將檔案載入入html檔案比較方便。
  那怎麼載入呢?一般會使用ajax獲取,但我這裡有更方便的辦法。那就是將模型檔案內容預編譯直出到html中,這樣不但提高了載入效能,開發也更方便。具體可參考我之前的文章:前端快速開發模版
  這裡使用我之前的開發模版, 將模型(obj、mtl)檔案以字串的形式寫入text/template模版中,同時將GLSL語言寫的著色器也預編譯到html中。到時用gulp的命令構建頁面,所有內容就會自動生成到頁面中,html部分的程式碼如下所示:

    {% extends '../layout/layout.html' %}
    {% block title %}spitfire fighter{% endblock %}
    {% block js %}
    <script src="./lib/webgl.js"></script>
    <script src="./lib/objParse.js"></script>
    <script src="./lib/matrix.js"></script>
    <script src="./js/index.js"></script>
    {% endblock %}
    {% block content %}
    <div class="content">
    <p>上下左右方向鍵 調整視角,W/S/A/D鍵 旋轉模型, +/-鍵 放大縮小</p>
    <canvas id="canvas" width="800" height="600"></canvas>
    </div>
    <!-- obj檔案 -->
    <script type="text/template" id="tplObj">
    {% include '../model/plane.obj' %}
    </script>
    <!-- mtl檔案 -->
    <script type="text/template" id="tplMtl">
    {% include '../model/plane.mtl' %}
    </script>
    <!-- 頂點著色器 -->
    <script type="x-shader/x-vertex" id="vs">
    {% include '../glsl/vs.glsl' %}
    </script>
    <!-- 片元著色器 -->
    <script type="x-shader/x-fragment" id="fs">
    {% include '../glsl/fs.glsl' %} 
    </script>
    {% endblock %}

obj檔案

  obj檔案包含的是模型的頂點法線索引等資訊。這裡以最簡單的立方體為例。

  • v 幾何體頂點
  • vt 貼圖座標點
  • vn 頂點法線
  • f 面:頂點索引 / 紋理座標索引 / 法線索引
  • usemtl 使用的材質名稱
    # Blender v2.79 (sub 0) OBJ File: ''
    # www.blender.org
    mtllib cube.mtl
    o Cube
    v -0.442946 -1.000000 -1.000000
    v -0.442946 -1.000000 1.000000
    v -2.442946 -1.000000 1.000000
    v -2.442945 -1.000000 -1.000000
    v -0.442945 1.000000 -0.999999
    v -0.442946 1.000000 1.000001
    v -2.442946 1.000000 1.000000
    v -2.442945 1.000000 -1.000000
    vn 0.0000 -1.0000 0.0000
    vn 0.0000 1.0000 0.0000
    vn 1.0000 0.0000 0.0000
    vn -0.0000 -0.0000 1.0000
    vn -1.0000 -0.0000 -0.0000
    vn 0.0000 0.0000 -1.0000
    usemtl Material
    s off
    f 1//1 2//1 3//1 4//1
    f 5//2 8//2 7//2 6//2
    f 1//3 5//3 6//3 2//3
    f 2//4 6//4 7//4 3//4
    f 3//5 7//5 8//5 4//5
    f 5//6 1//6 4//6 8//6

mtl檔案

  mtl檔案包含的是模型的材質資訊

  • Ka 環境色 rgb
  • Kd 漫反射色,材質顏色 rgb
  • Ks 高光色,材質高光顏色 rgb
  • Ns 反射高光度 指定材質的反射指數
  • Ni 折射值 指定材質表面的光密度
  • d 透明度
    # Blender MTL File: 'None'
    # Material Count: 1

    newmtl Material
    Ns 96.078431
    Ka 1.000000 1.000000 1.000000
    Kd 0.640000 0.640000 0.640000
    Ks 0.500000 0.500000 0.500000
    Ke 0.000000 0.000000 0.000000
    Ni 1.000000
    d 1.000000
    illum 2

  知道了obj和mtl檔案的格式,我們需要做的就是讀取它們,逐行分析,這裡使用的objParse讀取解析,想知道內部原理,可以檢視原始碼,這裡不詳述。
  提取出需要的資訊後,就可將模型資訊寫入緩衝區,然後渲染出來。

    var canvas = document.getElementById('canvas'),
        gl = get3DContext(canvas, true),
        objElem = document.getElementById('tplObj'),
        mtlElem = document.getElementById('tplMtl');
    function main() {
        //...

        //獲取變數地址
        var program = gl.program;
        program.a_Position = gl.getAttribLocation(gl.program, 'a_Position');
        //...

        // 建立空資料緩衝
        var vertexBuffer = createEmptyArrayBuffer(gl, program.a_Position, 3, gl.FLOAT);
        //...

        // 分析模型字串
        var objDoc = new OBJDoc('plane',objElem.text,mtlElem.text);
        if(!objDoc.parse(1, false)){return;}
        var drawingInfo = objDoc.getDrawingInfo();

        // 將資料寫入緩衝區
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, drawingInfo.vertices, gl.STATIC_DRAW);
        //...
    }

著色器

頂點著色器

  頂點著色器比較簡單,和之前的區別比較大的是,把計算顏色光照部分移到了片元著色器,這樣可以實現逐片元光照,效果會更加逼真和自然。

    attribute vec4 a_Position;//頂點位置
    attribute vec4 a_Color;//頂點顏色
    attribute vec4 a_Scolor;//頂點高光顏色
    attribute vec4 a_Normal;//法向量
    uniform mat4 u_MvpMatrix;//mvp矩陣
    uniform mat4 u_ModelMatrix;//模型矩陣
    uniform mat4 u_NormalMatrix;
    varying vec4 v_Color;
    varying vec4 v_Scolor;
    varying vec3 v_Normal;
    varying vec3 v_Position;

    void main() {
        gl_Position = u_MvpMatrix * a_Position;
        // 計算頂點在世界座標系的位置
        v_Position = vec3(u_ModelMatrix * a_Position);
        // 計算變換後的法向量並歸一化
        v_Normal = normalize(vec3(u_NormalMatrix * a_Normal));
        v_Color = a_Color;
        v_Scolor = a_Scolor;
    }

光照

  光照相關的計算主要在片元著色器中,首先科普一下光照的相關資訊。

    物體呈現出顏色亮度就是表面的反射光導致,計算反射光公式如下:
    <表面的反射光顏色> = <漫反射光顏色> + <環境反射光顏色> + <鏡面反射光顏色>

    1. 其中漫反射公式如下:
    <漫反射光顏色> = <入射光顏色> * <表面基底色> * <光線入射角度>

    光線入射角度可以由光線方向和表面的法線進行點積求得:
    <光線入射角度> = <光線方向> * <法線方向>

    最後的漫反射公式如下:
    <漫反射光顏色> = <入射光顏色> * <表面基底色> * (<光線方向> * <法線方向>)

    2. 環境反射光顏色根據如下公式得到:
    <環境反射光顏色> = <入射光顏色> * <表面基底色>

    3. 鏡面(高光)反射光顏色公式,這裡使用的是馮氏反射原理
    <鏡面反射光顏色> = <高光顏色> * <鏡面反射亮度權重> 

    其中鏡面反射亮度權重又如下
    <鏡面反射亮度權重> = (<觀察方向的單位向量> * <入射光反射方向>) ^ 光澤度

片元著色器

  著色器程式碼就是對上面公式內容的演繹

    #ifdef GL_ES
    precision mediump float;
    #endif
    uniform vec3 u_LightPosition;//光源位置
    uniform vec3 u_diffuseColor;//漫反射光顏色
    uniform vec3 u_AmbientColor;//環境光顏色
    uniform vec3 u_specularColor;//鏡面反射光顏色
    uniform float u_MaterialShininess;// 鏡面反射光澤度
    varying vec3 v_Normal;//法向量
    varying vec3 v_Position;//頂點位置
    varying vec4 v_Color;//頂點顏色
    varying vec4 v_Scolor;//頂點高光顏色

    void main() {
        // 對法線歸一化
        vec3 normal = normalize(v_Normal);
        // 計算光線方向(光源位置-頂點位置)並歸一化
        vec3 lightDirection = normalize(u_LightPosition - v_Position);
        // 計算光線方向和法向量點積
        float nDotL = max(dot(lightDirection, normal), 0.0);
        // 漫反射光亮度
        vec3 diffuse = u_diffuseColor  * nDotL * v_Color.rgb;
        // 環境光亮度
        vec3 ambient = u_AmbientColor * v_Color.rgb;
        // 觀察方向的單位向量V
        vec3 eyeDirection = normalize(-v_Position);
        // 反射方向
        vec3 reflectionDirection = reflect(-lightDirection, normal);
        // 鏡面反射亮度權重
        float specularLightWeighting = pow(max(dot(reflectionDirection, eyeDirection), 0.0), u_MaterialShininess);
        // 鏡面高光亮度
        vec3 specular =  v_Scolor.rgb * specularLightWeighting ;
        gl_FragColor = vec4(ambient + diffuse + specular, v_Color.a);
    }

模型變換

  這裡先設定光照相關的初始條件,然後是mvp矩陣變換和法向量矩陣相關的計算,具體知識點可參考之前的文章WebGL學習(2) - 3D場景

  要注意的是逆轉置矩陣,主要用於計算模型變換之後的法向量,有了變換後的法向量才能正確計算光照。

     求逆轉置矩陣步驟
        1.求原模型矩陣的逆矩陣
        2.將逆矩陣轉置

    <變換後法向量> = <逆轉置矩陣> * <變換前法向量>

  給著色器變數賦值然後繪製出模型,最後呼叫requestAnimationFrame不斷執行動畫。矩陣的旋轉部分可結合下面的keydown事件進行檢視。

    function main() {
        //...

        // 光線方向
        gl.uniform3f(u_LightPosition, 0.0, 2.0, 12.0);
        // 漫反射光照顏色
        gl.uniform3f(u_diffuseColor, 1.0, 1.0, 1.0);
        // 設定環境光顏色
        gl.uniform3f(u_AmbientColor, 0.5, 0.5, 0.5);
        // 鏡面反射光澤度
        gl.uniform1f(u_MaterialShininess, 30.0);

        var modelMatrix = new Matrix4();
        var mvpMatrix = new Matrix4();
        var normalMatrix = new Matrix4();
        var n = drawingInfo.indices.length;

        (function animate() {
            // 模型矩陣
            if(notMan){ angleY+=0.5; }
            modelMatrix.setRotate(angleY % 360, 0, 1, 0); // 繞y軸旋轉
            modelMatrix.rotate(angleX % 360, 1, 0, 0); // 繞x軸旋轉

            var eyeY=viewLEN*Math.sin(viewAngleY*Math.PI/180),
                len=viewLEN*Math.cos(viewAngleY*Math.PI/180),
                eyeX=len*Math.sin(viewAngleX*Math.PI/180),
                eyeZ=len*Math.cos(viewAngleX*Math.PI/180);

            // 視點投影
            mvpMatrix.setPerspective(30, canvas.width / canvas.height, 1, 300);
            mvpMatrix.lookAt(eyeX, eyeY, eyeZ, 0, 0, 0, 0, (viewAngleY>90||viewAngleY<-90)?-1:1, 0);
            mvpMatrix.multiply(modelMatrix);
            // 根據模型矩陣計算用來變換法向量的矩陣
            normalMatrix.setInverseOf(modelMatrix);
            normalMatrix.transpose();

            // 模型矩陣
            gl.uniformMatrix4fv(u_ModelMatrix, false, modelMatrix.elements);
            // mvp矩陣
            gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements);
            // 法向量矩陣
            gl.uniformMatrix4fv(u_NormalMatrix, false, normalMatrix.elements);

            // 清屏|清深度緩衝
            gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

            // 根據頂點索引繪製圖形(圖形型別,繪製頂點個數,頂點索引資料型別,頂點索引中開始繪製的位置)
            gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_SHORT, 0);
            requestAnimationFrame(animate);
        }());
    }

事件處理

  +/- 鍵實現放大/縮小場景的功能;WSAD鍵實現模型的旋轉,也就是實現繞x軸和y軸旋轉;上下左右方向鍵實現的是視點的旋轉。矩陣變換的相關實現參考上面程式碼的動畫部分。

  模型旋轉和視點旋轉看著很相似,其實又有不同的。視點的旋轉是整個場景比如光照模型等都是跟著變化的,如果以場景做參照物,它就相當於人改變觀察位置觀看物體。而模型旋轉呢,它只旋轉模型自身,外部的光照和場景都是不變的,以場景做參照物,相當於人在同一位置觀看模型在運動。從demo的光照可以看出兩種方式的區別。

    document.addEventListener('keydown',function(e){
        if([37,38,39,65,58,83,87,40].indexOf(e.keyCode)>-1){
            notMan=false;
        }
        switch(e.keyCode){
            case 38:        //up
                viewAngleY-=2;
                if(viewAngleY<-270){
                    viewAngleY+=360
                }
                break;
            case 40:        //down
                viewAngleY+=2;
                if(viewAngleY>270){
                    viewAngleY-=360
                }
                break;
            case 37:        //left
                viewAngleX+=2;
                break;
            case 39:        //right
                viewAngleX-=2;
                break;
            case 87:        //w
                angleX-=2;
                break;
            case 83:        //s
                angleX+=2;
                break;
            case 65:        //a
                angleY+=2;
                break;
            case 68:        //d
                angleY-=2;
                break;
            case 187:       //zoom in
                if(viewLEN>6) viewLEN--;
                break;
            case 189:       //zoom out
                if(viewLEN<30) viewLEN++;
                break;
            default:break;
        }
    },false);

總結

  最後,個人感覺建立3D模型還是挺費時間,需要花心機慢慢調整,才能做出比較完美的模型。

相關文章