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 模型到画面上!本篇的完整程序码可以在这边找到:


<<:  全端入门Day26_後端程序撰写之Django

>>:  入门魔法 - 常用阵列方法(一) forEach、filter、 map

Day17 开发套件 - 实作BasicMessageChannel

用於双向的单次讯息传递,包括发送讯息、接收讯息两个功能 经过以上的实作,其实两端的通讯步骤都差不多,...

[day6]API串接-Message内文加密

在串接API时,遇到最大的坎就是Message内文加密了, 就让我们来试看看罗~ Message内文...

Unity与Photon的新手相遇旅途 | Day12-血量制作

今天的内容为该如何制作血量,并且在攻击时或受到伤害时改变血量。 ...

认识 React Hooks 之一

今天要学习的是 React 的新功能 Hook,而 Hook 是 React 在 16.8 版本中新...

DAY25 深度学习-卷积神经网路-Yolo v3

今天介绍一下Yolo v3, 首先在v3中使用了darknet-53的架构,架构如下图: 相比v2的...