iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Modern Web

如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起系列 第 26

3D 物件檔案 — .obj

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 26 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

到了本系列文章的尾聲,本章節將製作一個完整的場景作為完結作品:主角是一艘帆船,在一片看不到邊的海面上,天氣晴朗。

06-boat-ocean.html / 06-boat-ocean.js

基於先前製作的光影效果,筆者製作了新的起始點:

live-screen-recording

此起始點相較於 Day 25,主要有以下修改:

  • 地板(ground)改為海洋(ocean),並且有獨立自己的 fragment shader,目前使用 normal map 使之有一個固定的波紋,在本章將會讓改成隨時間變化的波紋
    • 同時也讓 normal map 取得之法向量對水面倒影陰影造成影響(distortion),才不會顯得反射光與倒影、陰影有衝突感
  • 使用 twgl.createTextures() 使 texture 之讀取、建立程式可以大幅縮短
  • 使用 twgl.resizeCanvasToDisplaySize() 取代 canvas 之大小調整,同時在右上角讓使用者調整解析度倍率,一般來說是 普通 也就是一倍,如果 window.devicePixelRatio 大於 1(例如 Retina 螢幕),使用者也可以提高使用完整的螢幕解析度

可以看到畫面上有一顆從上上個章節就一直存在的球體,第一個目標就是將之換成新主角 -- 帆船

.obj / .mtl 檔案

.obj 存放的是 3D 物件資料,精確的說,經過讀取後可以成為 vertex attribute 的資料來源,包含了各個頂點的位置、texcoord、法向量,成為 3D 場景中的一個物件,而 .mtl 則是存放材質資料,像是散射光、反射光的顏色等

筆者先前在練習 .obj 的讀取時,順便小小學習了 Blender 這套開源的 3D 建模軟體,並且製作了一艘船:

https://sketchfab.com/3d-models/my-first-boat-f505dd73384245e08765ea6824b12644

boat-screenshot

這個模型就成了筆者接下來練習時需要模型時使用的素材,同時也是我們要放入場景中的帆船,匯出成 .obj & .mtl 之後,用文字編輯器打開其 .obj 可以看到:

# Blender v2.93.0 OBJ File: 'my-first-boat.blend'
# www.blender.org
mtllib my-first-boat.mtl
o Cube_Cube.001
v -0.245498 -0.021790 2.757482
v -0.551836 0.552017 2.746644
v -0.371110 -0.118091 0.326329
...
vt 0.559949 0.000000
vt 0.625000 0.000000
vt 0.625000 0.250000
...
vn -0.7759 -0.6250 0.0861
vn 0.0072 -0.0494 -0.9988
vn 0.7941 -0.6020 0.0836
...
usemtl body
...
f 17/1/1 2/2/1 4/3/1 18/4/1
f 18/4/2 4/3/2 8/5/2 19/6/2
f 19/6/3 8/5/3 6/7/3 20/8/3
...
o Cylinder.004_Cylinder.009
v 0.000000 0.308823 0.895517
v 0.000000 0.640209 0.895517
...

.obj 要紀錄 3D 物件的每個頂點資料,想當然爾檔案通常不小,這個模型有 20.6k 個三角形,檔案大小約 1.3MB,這邊不會看全部的細節,只擷取了一些小片段來觀察其內容

  • mtllib my-first-boat.mtlmtllib 開頭表示使用了 my-first-boat.mtl 這個檔案來描述材質
  • o Cube_Cube.001o 開頭表示一個子物件的開始,Cube_Cube.001 這個名字來自於 blender 中的物件名稱
  • v -0.245498 -0.021790 2.757482 / vt 0.559949 0.000000 / vn -0.7759 -0.6250 0.0861v / vt / vn 開頭分別為位置、texcoord、法向量資料,實際去打開檔案可以看到 .obj 絕大部分的內容都像這樣
  • usemtl body 表示這個子物件要使用的材質的名字,理論上可以在 .mtl 中找到對應的名字
  • f 17/1/1 2/2/1 4/3/1 18/4/1 表示一個『面』,這邊是一個四邊形,有四個頂點,每個頂點分別用一個 index 數字表示使用的哪一筆位置、texcoord、法向量,類似於 Day 18 之 indexed element
  • 接下來看到另一個 o 開頭 o Cylinder.004_Cylinder.009 表示另一個子物件的開始,Cube_Cube.001 這個名字來自於 blender 中的物件名稱

同樣地,看一下 .mtl 的片段:

# Blender MTL File: 'my-first-boat.blend'
# Material Count: 10

newmtl Material.001
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.352941 0.196078 0.047058
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2

...

newmtl flag-my-logo
Ns 225.000000
Ka 1.000000 1.000000 1.000000
Kd 0.000000 0.000000 0.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
map_Kd me.png

這個檔案就相對小很多,newmtl Material.001 對應 .obj 中的材質名稱,接下來則是對於不同光線的顏色或參數,根據這邊的定義,Ka 表示環境光顏色、Kd 散射光顏色、Ks 反射光顏色、Ns 反射光『範圍參數』(u_specularExponent),Kestackoverflow 上說是自發光的顏色;最後帆船模型中間船桅上有一面旗子,旗子中的圖案使用了 texture,因此 flag-my-logo 這個材質有一個參數 map_Kd me.png 表示要使用 me.png 這個圖檔作為 texture;剩下的設定在我們的 shader 中也沒有相關的實做,就先忽略不管

開始寫程式讀取 .obj 之前,可以從這邊下載 my-first-boat.obj, my-first-boat.mtl 以及 me.png 放置在專案 assets/ 資料夾下

這邊的 .mtl 檔案與上傳到 sketchfab.com 的有點不同,因為本系列文實做的 shader 會導致一些材質顏色不明顯,因此筆者有手動調整 .mtl 部份材質的顏色

讀取 .obj & .mtl

好的,綜觀來看,自己寫讀取程式的話,除了 .obj / .mtl parser 之外,得從 f 開頭的『面』資料展開成一個個三角形,接著取得要使用的位置、texcoord、法向量,轉換成 buffer 作為 vertex attribute 使用,除此之外還要處理 .mtl 的對應、建立子物件等,顯然是個不小的工程;既然 .obj 是一種公用格式,那麼應該可以找到現成的讀取工具,筆者找到的是這款:

https://github.com/frenchtoast747/webgl-obj-loader

可惜作者沒有提供 ES module 的方式引入,因此筆者 fork 此專案並且修改使之可以產出 ES module 的版本:github.com/pastleo/webgl-obj-loader,下載 build 好的 webgl-obj-loader.esm.js 並放至於專案的 vendor/webgl-obj-loader.esm.js,接著在 06-boat-ocean.js 就可以直接引入:

import * as WebGLObjLoader from './vendor/webgl-obj-loader.esm.js';

接著建立一個 function 來串接 WebGLObjLoader 讀取 .obj, .mtl 並傳入 WebGL,經過一些 survey 之後筆者使用它的 WebGLObjLoader.downloadModels(),可以同時下載所有需要的檔案並解析好,包含 .mtl 甚至 texture 圖檔,先看一下經過 WebGLObjLoader 讀取好的資料看起來如何:

async function loadBoatModel(gl) {
  const { boatModel } = await WebGLObjLoader.downloadModels([{
    name: 'boatModel',
    obj: './assets/my-first-boat.obj',
    mtl: true,
  }]);
  
  console.log(boatModel);
}

setup() 中呼叫:

async function setup() {
  // ...
  await loadBoatModel(gl);
  // ...
}

loaded-model-data

配合其文件的說明.vertices 對應 a_position.vertexNormals 對應 a_texcoord.textures 對應 a_normal,但是這些 vertex attribute 不能直接使用,而是要透過 .indices 指向每個頂點對應的資料,同時 .indices 已經是 Day 18 的 indexed element 所需要之 ELEMENT_ARRAY_BUFFER,不像是 .obj 中一個個 f 開頭的頂點指向不同組 position/texcoord/normal

那材質的部分呢?在我們的實作中同一個物件一次渲染只能指定一組 u_diffuse, u_specular 等 uniform 讓物件為一個單色,要不然就是用 u_diffuseMap 指定 texture,直接使用 .indices 作為 ELEMENT_ARRAY_BUFFER 的話便無法使不同子物件使用不同的材質,幸好 WebGLObjLoader 所回傳的物件中有 .indicesPerMaterial,裡面包含了一個個的 indices 陣列,分別對應一組材質設定,有趣的事情是,這些 indices 所對應的實際 vertex attribute 是共用的,也就是說 position/texcoord/normal 的 buffer 只要建立一組,接下來每個子物件建立各自的 indices buffer 並與共用 position/texcoord/normal 的 buffer 組成『物件』 VAO,最後渲染時各個物件設定好各自的 uniform 後進行繪製即可

因此在 WebGLObjLoader.downloadModels() 之後建立共用的 bufferInfo:

async function loadBoatModel(gl) {
  const { boatModel } = await WebGLObjLoader.downloadModels([{ /* ... */ }]);

  const sharedBufferInfo = twgl.createBufferInfoFromArrays(gl, {
    position: { numComponents: 3, data: boatModel.vertices },
    texcoord: { numComponents: 2, data: boatModel.textures },
    normal: { numComponents: 3, data: boatModel.vertexNormals },
  });
}

接下來讓 app.objects.boat 表示整艘帆船,但是要一個一個繪製子物件,因此使 app.objects.boat 為一個陣列,每一個元素包含子物件的 bufferInfo, VAO 以及 uniforms,從 boatModel.indicesPerMaterial.map() 出發:

async function loadBoatModel(gl, programInfo) {
  // ...
  return boatModel.indicesPerMaterial.map((indices, mtlIdx) => {
    const material = boatModel.materialsByIndex[mtlIdx];

    const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
      indices,
    }, sharedBufferInfo);

    return {
      bufferInfo,
      vao: twgl.createVAOFromBufferInfo(gl, programInfo, bufferInfo),
    }
  });
}
  1. .indicesPerMaterial 陣列中,第幾個 indices 陣列就使用第幾個 material,因此 boatModel.materialsByIndex[mtlIdx]; 取得對應的材質設定
  2. 使用 twgl.createBufferInfoFromArrays() 的第三個參數 srcBufferInfo 來『共用』剛才建立的 sharedBufferInfo,這感覺其實有點像是 Map 的 merge 或是 Object.assign()
  3. loadBoatModel() 傳入 programInfo,以便建立 VAO

這樣一來 vertex attribute buffer, indices buffer 以及 VAO 就準備好了,剩下的就是把材質資料轉成 uniforms key-value 物件,把這邊取得的 material 印出來看:

material-content

雖然這個物件有不少東西,不過要找到 u_diffuse, u_specular 對應的資料不會很困難,名字幾乎能夠直接對起來;如果是有 texture 的,可以在 material.mapDiffuse.texture 找到,而且已經是 Image 物件,直接餵給 twgl.createTexture() 即可:

async function loadBoatModel(gl, textures, programInfo) {
  // ...

  return boatModel.indicesPerMaterial.map((indices, mtlIdx) => {
    const material = boatModel.materialsByIndex[mtlIdx];

    let u_diffuseMap = textures.nil;
    if (material.mapDiffuse.texture) {
      u_diffuseMap = twgl.createTexture(gl, {
        wrapS: gl.CLAMP_TO_EDGE, wrapT: gl.CLAMP_TO_EDGE,
        min: gl.LINEAR_MIPMAP_LINEAR,
        src: material.mapDiffuse.texture,
      });
    }

    return {
      /* bufferInfo, vao */,
      uniforms: {
        u_diffuse: material.diffuse,
        u_diffuseMap,
        u_specular: material.specular,
        u_specularExponent: material.specularExponent,
        u_emissive: material.emissive,
        u_ambient: [0.6, 0.6, 0.6],
      },
    }
  });
}

對於沒有使用 texture 的子物件,就跟之前一樣要設定成 texture.nil 避免影響到單色渲染,令一個比較特別的是 u_ambient,因為筆者為此系列文撰寫的 shader 運作方式與 blender、sketchfab.com 上看到的不同,或許是有些材質的設定沒實做的關係,會顯得特別暗,同時 u_ambient 這邊實做的功能是基於 diffuse 的最低亮度,因此筆者一律設定成 [0.6, 0.6, 0.6]

因為原本 u_ambient 為全域的 uniform,而之後會變成各個物件個別設定,最後在 setup() 中傳入所需的參數並接收子物件陣列到 app.objects.boat 準備好:

 async function setup() {
   // ...
+  objects.boat = await loadBoatModel(gl, textures, programInfo);
   // ...
 }

 function render(app) {
   // ...
   const globalUniforms = {
     u_worldViewerPosition: cameraMatrix.slice(12, 15),
     u_lightDirection: lightDirection,
-    u_ambient: [0.4, 0.4, 0.4],
     // ...
   }
 }
 
 function renderBall(app, viewMatrix, programInfo) {
   // ...
   twgl.setUniforms(programInfo, {
     // ...
     u_emissive: [0.15, 0.15, 0.15],
+    u_ambient: [0.4, 0.4, 0.4],
     // ...
   });
 }

 function renderOcean(app, viewMatrix, reflectionMatrix, programInfo) {
   // ...
   twgl.setUniforms(programInfo, {
     // ...
     u_emissive: [0, 0, 0],
+    u_ambient: [0.4, 0.4, 0.4],
     // ...
   });
   // ...
 }

這樣一來 app.objects.boat 就準備好帆船的資料了,雖然畫面上沒有變化,但是可以在 Console 上輸入 app.objects.boat 來確認:

app.objects.boat-content

確認資料準備好了,待下篇來把球體換成帆船,繪製 .obj 模型到畫面上!本篇的完整程式碼可以在這邊找到:


上一篇
陰影(下)
下一篇
.obj 之繪製 & Skybox
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言