1. 程式人生 > >WebGL簡易教程(十五):載入gltf模型

WebGL簡易教程(十五):載入gltf模型

目錄

  • 1. 概述
  • 2. 例項
    • 2.1. 資料
    • 2.2. 程式
      • 2.2.1. 檔案讀取
      • 2.2.2. glTF格式解析
      • 2.2.3. 初始化頂點緩衝區
      • 2.2.4. 其他
  • 3. 結果
  • 4. 參考
  • 5. 相關

1. 概述

一般來說,圖形渲染總是需要從磁碟資料開始,最終儲存到磁碟資料中,儲存這種資料的就是3D模型檔案。3D模型檔案一般會把頂點、索引、紋理、材質等等資訊都儲存起來,方便下次直接讀取。3D模型檔案格式一般是與圖形渲染工作強關聯的,瞭解3D模型檔案格式的組成,有助於進一步瞭解圖形渲染的流程。

glTF可以說是專門為WebGL量身定製的資料格式,具有以下特點:

  1. 場景資料結構是使用JSON來描述的,讀取後即可解析,無需再自定義組織物件。
  2. buffer資料被儲存為二進位制檔案,佔用空間小,讀取後即可使用,無需轉換過程。
  3. 紋理資料可以使用jpg檔案,方便壓縮和傳輸。

從以上特性可以看出,glTF特別方便與網際網路的使用場景,便於傳輸且預處理程度小。在這篇教程中,就通過一個帶紋理的地形檔案,具體解析以下glTF格式,順便加深一下WebGL中初始化資料的理解。

2. 例項

2.1. 資料

使用的地形glTF檔案已經處理好並上傳到文章末尾的地址中(具體的轉換過程可以參看《DEM轉換為gltf》)。glTF是這樣一個JSON檔案:

{
    "asset": {
        "generator": "CL",
        "version": "2.0"
    },
    "scene": 0,
    "scenes": [
        {
            "nodes": [
                0
            ]
        }
    ],
    "nodes": [
        {
            "mesh": 0
        }
    ],
    "meshes": [
        {
            "primitives": [
                {
                    "attributes": {
                        "POSITION": 1,
                        "TEXCOORD_0": 2
                    },
                    "indices": 0,
                    "material": 0
                }
            ]
        }
    ],
    "materials": [
        {
            "pbrMetallicRoughness": {
                "baseColorTexture": {
                    "index": 0
                }
            }
        }
    ],
    "textures": [
        {
            "sampler": 0,
            "source": 0
        }
    ],
    "images": [
        {
            "uri": "tex.jpg"
        }
    ],
    "samplers": [
        {
            "magFilter": 9729,
            "minFilter": 9987,
            "wrapS": 33648,
            "wrapT": 33648
        }
    ],
    "buffers": [
        {
            "uri": "new.bin",
            "byteLength": 595236
        }
    ],
    "bufferViews": [
        {
            "buffer": 0,
            "byteOffset": 374400,
            "byteLength": 220836,
            "target": 34963
        },
        {
            "buffer": 0,
            "byteStride": 20,
            "byteOffset": 0,
            "byteLength": 374400,
            "target": 34962
        }
    ],
    "accessors": [
        {
            "bufferView": 0,
            "byteOffset": 0,
            "componentType": 5123,
            "count": 110418,
            "type": "SCALAR",
            "max": [
                18719
            ],
            "min": [
                0
            ]
        },
        {
            "bufferView": 1,
            "byteOffset": 0,
            "componentType": 5126,
            "count": 18720,
            "type": "VEC3",
            "max": [
                770,
                0.0,
                1261.151611328125
            ],
            "min": [
                0.0,
                -2390,
                733.5555419921875
            ]
        },
        {
            "bufferView": 1,
            "byteOffset": 12,
            "componentType": 5126,
            "count": 18720,
            "type": "VEC2",
            "max": [
                1,
                1
            ],
            "min": [
                0,
                0
            ]
        }
    ]
}

可以看到這個檔案連結了兩個外部檔案new.bin和tex.jpg。new.bin也就是儲存的頂點資料資訊,是個二進位制檔案,tex.jpg也就是紋理圖片。將這個資料匯入到glTF Viewer網站上檢視,顯示結果如下:

注意,由於安全策略的原因,瀏覽器匯入資料時應該將new.gltf、new.bin、tex.jpg這三個檔案一同匯入,否則無法正確讀取顯示。

2.2. 程式

2.2.1. 檔案讀取

由於需要一次性載入多個檔案,所以需要將input控制元件改成支援多檔案的:

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <title> 顯示地形 </title> 
</head>

<body onload="main()">
  <div><input type='file' id='demFile' multiple="multiple"></div>  
  <div>
    <canvas id="webgl" width="600" height="600">
      請使用支援WebGL的瀏覽器
    </canvas>
  </div>

  <script src="../lib/webgl-utils.js"></script>
  <script src="../lib/webgl-debug.js"></script>
  <script src="../lib/cuon-utils.js"></script>
  <script src="../lib/cuon-matrix.js"></script>
  <script src="TerrainViewer.js"></script>
</body>

</html>

在glTF Viewer網站中檢視glTF的原理並不是將資料提交到後臺,而是直接交給前段頁面的JS進行讀取。可以通過FileReader物件來進行讀取。FileReader讀取的好處是不會觸發瀏覽器的安全策略,不用設定跨域(至少chrome不用):

  var demFile = document.getElementById('demFile');
  if (!demFile) {
    console.log("Failed to get demFile element!");
    return;
  }

  //載入檔案後的事件
  demFile.addEventListener("change", function (event) {
    //判斷瀏覽器是否支援FileReader介面
    if (typeof FileReader == 'undefined') {
      console.log("你的瀏覽器不支援FileReader介面!");
      return;
    }

    //讀取檔案後的事件
    var reader = new FileReader();
    reader.onload = function () {
      if (reader.result) {
        var gltfObj = JSON.parse(reader.result);

        for (var fi = 0; fi < input.files.length; fi++) {
          //讀取bin檔案
          if (gltfObj.buffers[0].uri === input.files[fi].name) {
            var binReader = new FileReader();
            binReader.onload = function () {
              if (binReader.result) {
                for (var fi = 0; fi < input.files.length; fi++) {
                  if (gltfObj.images[0].uri === input.files[fi].name) {
                    //讀取紋理影象   
                    var imgReader = new FileReader();

                    imgReader.onload = function () {
                      //建立一個image物件
                      var image = new Image();
                      if (!image) {
                        console.log('Failed to create the image object');
                        return false;
                      }

                      //影象載入的響應函式 
                      image.onload = function () {
                        //繪製函式
                        onDraw(gl, canvas, gltfObj, binReader.result, image);
                      };

                      //瀏覽器開始載入影象
                      image.src = imgReader.result;
                    }

                    imgReader.readAsDataURL(input.files[fi]); //按照base64格式讀取
                    break;
                  }
                }
              }
            }
            binReader.readAsArrayBuffer(input.files[fi]);    //按照ArrayBuffer格式讀取
            break;
          }
        }
      }
    }

    var input = event.target;

    var flag = false;
    for (var fi = 0; fi < input.files.length; fi++) {
      if (getFileSuffix(input.files[fi].name) === "gltf") {
        flag = true;
        reader.readAsText(input.files[fi]);      //按照字串格式讀取
        break;
      }
    }

    if (!flag) {
      alert("沒有找到gltf");
    }
  });

這段程式碼看起來很繁複,其實原理很簡單:遍歷載入的檔案,對於gltf檔案採用FileReader.readAsText()也就是字串格式的方法讀取,這個字串隨後被解析成JSON;對於bin檔案採用FileReader.readAsArrayBuffer()讀取,將其讀取成ArrayBuffer物件;對於jpg檔案採用FileReader.readAsDataURL讀取,將其讀取成data:url開頭的base64字串,這個字串可以直接生成JS的Image物件。

注意FileReader的讀取方式都是非同步讀取,必須等到三個檔案都讀取完成,才呼叫onDraw()函式進行繪製。讀取得到的物件也不用再多做處理,可以直接在後面的初始化步驟中使用。

2.2.2. glTF格式解析

初始化頂點緩衝區函式initVertexBuffers()中就用到了之前獲取的物件。gltfObj是獲取的JSON物件,裡面記錄了對三維物體的描述資訊。具體解析如下:

2.2.2.1. 場景節點

    "asset": {
        "generator": "CL",
        "version": "2.0"
    },
    "scene": 0,
    "scenes": [
        {
            "nodes": [
                0
            ]
        }
    ],
    "nodes": [
        {
            "mesh": 0
        }
    ],

asset表示的是元資料資訊,version一般為2.0。
scene是整個場景的入口,0表示scenes陣列的第一個;scenes節點又包含了一個nodes陣列,其中每個nodes物件包含一個children陣列,這一陣列引用了nodes物件的所有子結點。通過孩子結點,構成了整個場景結構:

這一段描述的其實是場景的結構層次模型。基本上來講,一般的三維渲染引擎都會將三維場景中的物體分解成節點,採用樹的結構來描述場景,這樣做能夠很方便的進行狀態控制以及姿態傳遞。這裡沒有那麼複雜的結構,就簡化為0。

mesh則表示場景節點中的幾何物件。

2.2.2.2. 網格

"meshes": [
        {
            "primitives": [
                {
                    "attributes": {
                        "POSITION": 1,
                        "TEXCOORD_0": 2
                    },
                    "indices": 0,
                    "material": 0
                }
            ]
        }
    ],

mesh物件包含了一個primitive陣列物件。primitive表達的是一個圖元,描述每個網格是怎樣的幾何圖形。其attributes物件表達了圖元頂點的屬性。這裡的POSITION屬性表示頂點的位置資訊,屬性值1表示訪問器物件accessors陣列的索引;TEXCOORD_0表示頂點的紋理位置資訊,屬性值2表示訪問器物件accessors陣列的索引。

indices屬性表示圖元頂點資料是通過索引來描述的,其值3表示訪問器物件accessors陣列的索引。

而material則表示圖元用到了材質,在materials節點中可以找到其具體的描述。

2.2.2.3. 緩衝,緩衝檢視和訪問器

    "buffers": [
        {
            "uri": "new.bin",
            "byteLength": 595236
        }
    ],
    "bufferViews": [
        {
            "buffer": 0,
            "byteOffset": 374400,
            "byteLength": 220836,
            "target": 34963
        },
        {
            "buffer": 0,
            "byteStride": 20,
            "byteOffset": 0,
            "byteLength": 374400,
            "target": 34962
        }
    ],
    "accessors": [
        {
            "bufferView": 0,
            "byteOffset": 0,
            "componentType": 5123,
            "count": 110418,
            "type": "SCALAR",
            "max": [
                18719
            ],
            "min": [
                0
            ]
        },
        {
            "bufferView": 1,
            "byteOffset": 0,
            "componentType": 5126,
            "count": 18720,
            "type": "VEC3",
            "max": [
                770,
                0.0,
                1261.151611328125
            ],
            "min": [
                0.0,
                -2390,
                733.5555419921875
            ]
        },
        {
            "bufferView": 1,
            "byteOffset": 12,
            "componentType": 5126,
            "count": 18720,
            "type": "VEC2",
            "max": [
                1,
                1
            ],
            "min": [
                0,
                0
            ]
        }
    ]

這裡詳細描述了上面提到的訪問器物件accessors。之所以定義這個屬性物件,是因為頂點資料資訊被直接儲存為二進位制buffer了,需要去區分描述buffer哪些是位置資訊,哪些是紋理座標資訊,哪些是索引資訊。

buffers物件就是頂點資料的二進位制buffer,url表示被儲存為外部的二進位制檔案new.bin,byteLength表示其長度為595236,這個檔案在匯入的時候會被讀取成JS的ArrayBuffer物件。

bufferViews物件將buffers分成兩個檢視:前374400個位元組表達的是頂點資料,步長byteStride為20個表示每20個位元組的資料表達一個頂點,target為34962表示的就是ARRAY_BUFFER;而從374400開始的220836個位元組表示的是頂點索引的資料,target為34963表示的就是ELEMENT_ARRAY_BUFFER。

accessors物件則進一步描述了頂點資料的組織。

  1. 屬性bufferView表示的就是前面bufferViews物件的索引值。
  2. byteOffset表示資料從那個位元組開始;componentType表示儲存的資料型別,5123表示為UNSIGNED_SHORT型,佔用2個位元組;而5126表示FLOAT訊號,佔用4個位元組。
  3. count表示資料的個數。
  4. type表示資料的型別,可以為標量SCALAR,也可以為向量"VEC2"、"VEC3"等,甚至可以為矩陣"MAT3"等。
  5. min,max則表示每個值得最大最小值,填寫正確的範圍,有助於瀏覽操作。

通過以上屬性值,就能夠正確區分描述頂點資料資訊了。注意頂點資料的bufferViews物件在accessors物件被進一步劃分檢視,分別描述了位置資訊和紋理座標資訊:bufferViews物件的步長byteStride被設定為20,accessors物件的偏移量byteOffset分別設定為0和12,說明二進位制bin中的組織的結構為:

位置X座標 位置Y座標 位置Z座標 紋理S座標 紋理T座標
位置X座標 位置Y座標 位置Z座標 紋理S座標 紋理T座標
位置X座標 位置Y座標 位置Z座標 紋理S座標 紋理T座標
...

當然,二進位制bin中是沒有空格和回車的,這裡只是為了方便好看。

2.2.2.4. 紋理材質

    "materials": [
        {
            "pbrMetallicRoughness": {
                "baseColorTexture": {
                    "index": 0
                }
            }
        }
    ],
    "textures": [
        {
            "sampler": 0,
            "source": 0
        }
    ],
    "images": [
        {
            "uri": "tex.jpg"
        }
    ],
    "samplers": [
        {
            "magFilter": 9729,
            "minFilter": 9987,
            "wrapS": 33648,
            "wrapT": 33648
        }
    ],

在primitives物件的material的屬性中,指向的就是這個materials節點的索引值。materials物件又指向了紋理物件textures,textures物件通過索引引用了一個sampler物件和一個image物件。image物件包含了一個uri,引用了一個外部影象檔案。samplers是一個取樣器,用於設定紋理具體的取樣方式,其設定引數與WebGL中設定紋理的方式向對應。

2.2.3. 初始化頂點緩衝區

讀取後的資料可以直接交給initVertexBuffers()初始化頂點緩衝區,具體的實現程式碼如下:

//
function initVertexBuffers(gl, gltfObj, binBuf) {
  //獲取頂點資料位置資訊  
  var positionAccessorId = gltfObj.meshes[0].primitives[0].attributes.POSITION;
  if (gltfObj.accessors[positionAccessorId].componentType != 5126) {
    return 0;
  }

  var positionBufferViewId = gltfObj.accessors[positionAccessorId].bufferView;
  var verticesColors = new Float32Array(binBuf, gltfObj.bufferViews[positionBufferViewId].byteOffset, gltfObj.bufferViews[positionBufferViewId].byteLength / Float32Array.BYTES_PER_ELEMENT);

  gltfObj.cuboid = new Cuboid(gltfObj.accessors[positionAccessorId].min[0], gltfObj.accessors[positionAccessorId].max[0], gltfObj.accessors[positionAccessorId].min[1], gltfObj.accessors[positionAccessorId].max[1], gltfObj.accessors[positionAccessorId].min[2], gltfObj.accessors[positionAccessorId].max[2]);

  // 建立緩衝區物件
  var vertexColorBuffer = gl.createBuffer();
  var indexBuffer = gl.createBuffer();
  if (!vertexColorBuffer || !indexBuffer) {
    console.log('Failed to create the buffer object');
    return -1;
  }

  // 將緩衝區物件繫結到目標
  gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer);
  // 向緩衝區物件寫入資料
  gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW);

  //獲取著色器中attribute變數a_Position的地址 
  var a_Position = gl.getAttribLocation(gl.program, 'a_Position');
  if (a_Position < 0) {
    console.log('Failed to get the storage location of a_Position');
    return -1;
  }

  // 將緩衝區物件分配給a_Position變數  
  gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, gltfObj.bufferViews[positionBufferViewId].byteStride, gltfObj.accessors[positionAccessorId].byteOffset);

  // 連線a_Position變數與分配給它的緩衝區物件
  gl.enableVertexAttribArray(a_Position);

  //獲取頂點資料紋理資訊  
  var txtCoordAccessorId = gltfObj.meshes[0].primitives[0].attributes.TEXCOORD_0;
  if (gltfObj.accessors[txtCoordAccessorId].componentType != 5126) {
    return 0;
  }
  var txtCoordBufferViewId = gltfObj.accessors[txtCoordAccessorId].bufferView;

  //獲取著色器中attribute變數a_TxtCoord的地址 
  var a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
  if (a_TexCoord < 0) {
    console.log('Failed to get the storage location of a_TexCoord');
    return -1;
  }
  // 將緩衝區物件分配給a_Color變數
  gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, gltfObj.bufferViews[txtCoordBufferViewId].byteStride, gltfObj.accessors[txtCoordAccessorId].byteOffset);
  // 連線a_Color變數與分配給它的緩衝區物件
  gl.enableVertexAttribArray(a_TexCoord);

  //獲取頂點資料索引資訊
  var indicesAccessorId = gltfObj.meshes[0].primitives[0].indices;
  var indicesBufferViewId = gltfObj.accessors[indicesAccessorId].bufferView;
  var indices = new Uint16Array(binBuf, gltfObj.bufferViews[indicesBufferViewId].byteOffset, gltfObj.bufferViews[indicesBufferViewId].byteLength / Uint16Array.BYTES_PER_ELEMENT);

  // 將頂點索引寫入到緩衝區物件
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

  return indices.length;
}

這段程式碼的原理非常簡單,讀取的glTF被直接解析為JSON後,通過primitives屬性找到頂點位置座標和頂點紋理座標的訪問器物件accessors,繼而找到緩衝區buffer和緩衝區檢視bufferView。由於緩衝區資料檔案new.bin已經被讀取成ArrayBuffer,可以將這個ArrayBuffer分成兩個檢視[6],一組檢視為Float32Array型別的頂點陣列,一組檢視為Uint16Array型別的頂點陣列索引。其中,頂點陣列可以通過 gl.vertexAttribPointer()函式做進一步分配,分別給著色器分配位置變數和紋理座標變數(可以複習一下《WebGL簡易教程(三):繪製一個三角形(緩衝區物件)》建立緩衝區物件的五個步驟)。

2.2.4. 其他

程式其他的步驟基本上沒有變化,由於資料讀取後JS的Image物件已經生成,仍然按照以前的方式根據Image物件生成紋理物件。著色器部分也非常簡單:

// 頂點著色器程式
var VSHADER_SOURCE =
  'attribute vec4 a_Position;\n' + //位置
  'attribute vec2 a_TexCoord;\n' + //顏色
  'varying vec2 v_TexCoord;\n' + //紋理座標
  'uniform mat4 u_MvpMatrix;\n' +
  'void main() {\n' +
  '  gl_Position = u_MvpMatrix * a_Position;\n' + // 設定頂點座標
  '  v_TexCoord = a_TexCoord;\n' +  //紋理座標
  '}\n';

// 片元著色器程式
var FSHADER_SOURCE =
  'precision mediump float;\n' +
  'uniform sampler2D u_Sampler;\n' +
  'varying vec2 v_TexCoord;\n' + //紋理座標
  'void main() {\n' +
  '  gl_FragColor = texture2D(u_Sampler, v_TexCoord);\n' +
  '}\n';

紋理座標傳入頂點著色器再傳入片元著色器,通過紋理物件插值得到片元最終值。

3. 結果

從以上解析過程可以看到,glTF的格式設計確實非常精妙,讀取的資料能夠直接為WebGL所用,既節省了空間又省略了一些預處理的過程,值得進一步深入研究。

開啟HTML頁面,匯入new.gltf、new.bin、tex.jpg,顯示的效果如下:

這個例子是通過JS的FileReader來處理資料,所以不需要設定瀏覽器跨域。

4. 參考

1.《WebGL程式設計指南》
2.glTF格式詳解(目錄)
3.glTF Tutorial
4.前端H5中JS用FileReader物件讀取blob物件二進位制資料,檔案傳輸
5.gltf2.0規範
6.JavaScript 之 ArrayBuffer

5. 相關

程式碼和資料地址

上一篇
目錄