WebGL学习(十八)加载三维模型

614 阅读11分钟

1. 概述

之前学习了这么多计算机图形的知识,但是我们使用的图形都只是简单的立方体,如果我们想要画一个复杂的图形那将需要定义非常多的顶点数据,还要微调很多细节曲面。全部手工绘制确实很麻烦,好在现在有现成的3d建模工具,可以可视化绘制模型,然后导出顶点、纹理等等信息。我们只需要解析模型文件,就能复原一个模型,不需要再手动定义顶点了。

参考

一文看懂3D模型obj文件

OBJ模型文件的结构、导入与渲染Ⅰ

threejs的OBJLoader源码

2. 文件格式

3d模型文件有很多格式,glbgltffbx等等,本文基于.obj格式介绍怎么解析加载模型,目的主要还是通过一种规格了解模型文件的存储方式。

.objWavefront Technologies开发的一种开放文件,所以基本上所有软件都支持obj

你可以下载一个blender一种三维建模工具,绘制一个简单的图像并导出。blender怎么使用这里就不展开了,现在我用blender绘制了一个简单的图像:

image.png

如果按照以前的方法,绘制这么简单的一个物体可能都要扣破头皮。

3. 深入.obj

如果你使用软件导出.obj文件,同时还会导出一个.mtl文件。.obj保存的是各种顶点信息,.mtl保存的是材质信息。

参考1

参考2

3.1. .obj

# Blender 3.5.1
# www.blender.org
mtllib demo.mtl
o Cube.001
v 0.558189 1.968730 -1.318339
# ... 省略
v 0.679955 3.499619 0.299044
vn -0.9288 -0.0000 0.3705
# ... 省略
vn -0.4379 0.2930 -0.8500
vt 0.625000 0.500000
# ... 省略
vt 0.250000 0.250000
s 0
usemtl 默认
f 6/7/2 2/2/2 4/4/2 8/13/2
# ... 省略
f 9/15/2 10/17/2 11/19/2 12/21/2 13/23/2 14/25/2 15/27/2 16/29/2 17/31/2 18/33/2 19/35/2 20/37/2 21/39/2 22/41/2 23/43/2 24/45/2 25/47/2 26/49/2 27/51/2 28/53/2 29/55/2 30/57/2 31/59/2 32/61/2 33/63/2 34/65/2 35/67/2 36/69/2 37/71/2 38/73/2 39/75/2 40/77/2
usemtl 上面
f 1/1/5 5/6/5 7/10/5 3/3/5
usemtl 前面
f 4/4/6 3/3/6 7/11/6 8/14/6
usemtl 圆锥
f 8/12/1 7/9/1 5/5/1 6/8/1
# ... 省略
f 40/78/38 41/79/38 9/16/38

上面就是一个.obj文件的内容,就是普通的文本数据。

  • # 开头的是注释
  • 3行mtllib指明了关联哪个mtl文件
  • 4行o指明了模型名称
  • 5~14行都是定义的物体顶点信息,v代表顶点,vn代表法向量,vt代表纹理坐标,s代表一组相同平滑度的面。坐标中的w是可选的,默认是1
  • 15行,usemtl表示使用mtl文件中的哪一个材质,
  • 16行,定义了使用这个材质的表面,它是顶点法向量纹理的索引序列,格式是顶点索引/纹理索引/法向量索引,注意是从1开始的。比如19行使用了一个叫做上面的材质,20行定义了这个材质应用在顶点索引为1、5、7、3,纹理索引1、6、10、3,法向量索引5、5、5、5

3.2. .mtl

# Blender 3.5.1 MTL File: 'None'
# www.blender.org

newmtl 默认
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 2

newmtl 上面
# ...省略

newmtl 前面
# ...省略

newmtl 圆锥
# ...省略
  • # 开头的是注释
  • newmtl 表示材质名
  • 接下来就是一系列颜色光照相关的设置。
  • Ns:高光(镜面反射)强度,越大越亮越集中,就是物体特别反光的那一部分,范围0~1000
  • Ka:环境光颜色,所有分量取值范围[0, 1]
  • Kd:漫反射光颜色,物体的大部分颜色就是来自这里,所有分量取值范围[0, 1]
  • Ks:指定高光的颜色
  • Ke:材质的自发光颜色
  • Ni:光的折射率(光密度)
  • d:材质的透明度(溶解度),取值范围为0-1,值越小,透明度越高,反之亦然
  • illum:表示光照模型,取值范围[0, 10],每一个值对应一种光照。比如1表示颜色打开,环境光关闭,其他配置就不展开了。
  • map_[key]:给上面的各种颜色设置纹理贴图,map_Ka环境光纹理,map_Kd漫反射纹理等等

4. 解析文件

4.1. 解析.obj

我们的目的是了解解析模型文件,所以我会忽略一些属性,尽量简单化。现在我们先完成解析顶点信息,能够画出图形来。

为了能看起来清晰,不会做太多的封装,也没有什么设计模式。主要还了解为主。

下面问我们解析一个最简单的立方体。

4.1.1. 解析文本

文本还是很简单,objmtl语法都是一行为一个语句,每一行第一个单词是命令,后面就是值。

const fileInput = document.getElementById('file') as HTMLInputElement
fileInput.addEventListener('input', function(e) {
  const fileReader = new FileReader()
  fileReader.readAsText(this.files.item(0), 'utf-8')
  // 读取字符串
  fileReader.onload = function (e) {
    if(typeof this.result === 'string') {
      // 分隔每一行
      const rows = this.result.split('\n')
      rows.forEach(row => {
        // 每一行第一个单词为key,剩余的都是值
        const [key, ...values] = row.split(' ')
      })
    }
  }
})

4.1.2. 解析顶点数据

这里我们先不解析颜色材质,先保证图像画出来

// 顶点数组
const vertexList: number[] = []
// 法向量
const normalList: number[] = []
// 材质、面数据
interface FaceItem {
  // 面的顶点索引
  vIndices?: number[],
  // 法向量索引
  nIndices?: number[]
}
const materials: Record<string, FaceItem[]> = {}
// ...
fileReader.onload = function (e) {
if(typeof this.result === 'string') {
  // 分隔每一行
  const rows = this.result.split('\n')
  // 当前处理的材质信息
  let currentMaterial = ''
  rows.forEach(row => {
    // 每一行第一个单词为key,剩余的都是值
    const [key, ...values] = row.split(' ')
    // 目前只处理顶点,其他的先不管
    switch (key) {
      case 'v': {
      // 保存顶点坐标
      // 其实v后面除了携带顶点信息,还能携带颜色
      // 这里就不考虑了,简单理解下就行了
        vertexList.push(...values.map(Number))
        break
      }
      case 'vn': {
      // 保存法向量坐标
        normalList.push(...values.map(Number))
        break
      }
      case 'usemtl': {
      // 遇到usemtl说明要解析面的信息了
        materials[values[0]] = []
        currentMaterial = values[0]
        break
      }
      case 'f': {
      // 在下一个usemtl出现之前
      // f就是这个材质应用的面
      // 示例的模型,材质正好包裹了所有的面
      // 所以那这个当做顶点索引
      // 实际情况应该综合所有的材质组成顶点
        const faceList = materials[currentMaterial]
        const face: FaceItem = {
          vIndices: [],
          nIndices: []
        }
        // 顶点、纹理、法向量索引
        values.forEach(indice => {
          const [v, t, n] = indice.split('/')
          if(v) {
            face.vIndices.push(Number(v))
          }
          if(t) {

          }
          if(n) {
            face.nIndices.push(Number(n))
          }
        })

        faceList.push(face)
      }
      default: break
    }
  })
 }
}
// 有了索引信息还有顶点信息就可以绘制了
// 把每一个的索引收集到一起
Object.entries(materials).forEach(([_, faceList]) => {
    faceList.forEach(face => vertexIndices.push(...face.vIndices))
})

// 这个Lib是封装的一些基础操作
// 不影响阅读,就是一些取值设值操作
const {
    modelMat,
    mvpMat,
} = Lib.getMVP({model: {rotate: objectRotate}})

Lib.setUniformMatrixValue('mvpMat', mvpMat)
Lib.setUniformMatrixValue('modelMat', modelMat)
Lib.bindVertexBuffer(Float32Array.from(vertexList), 'pos', 3, gl.FLOAT, 0, 0)
Lib.bindIndexBuffer(new Uint8Array(vertexIndices))
Lib.draw(true)

实际效果:

msedge_6uCXE7DdZk.gif

根本不是立方体,但是我们也可以看出有三角面,但是连接顺序好像有问题。

这其实是因为obj导出的索引并不是按照三角面来的,而是一个整个面边角顶点。比如一个正方形平面,四个角四个顶点,一个锥体表面,就是三个角三个顶点。

mspaint_DRvVvCLxyl.png 所以直接使用是我无法绘制出正确图像的。我们需要根据给出的顶点切割出三角形这样才能正确绘制。

mspaint_THQWngw7md.png 假设obj中其中一个面的顶点索引顺序是v0-v1-v2-v3(逆时针旋转),那么我们可以保持第一个顶点不变,依次我往后数两个顶点组成一个三角面。比如这里的v0-v1-v2v0-v2-v3,以此类推无论多么复杂的面都能分解成多个三角形

// 上面代码72行部分改造下
// v1------v0
//  |  ╲       |
//  |     ╲    |
//  |       ╲  |
// v2-------v3
// 右手定则,大拇指朝观察者为正,其实就是逆时针顺序
// 假设默认顺序是1、2、3、0
// 但是webgl只能通过三角形绘制,所以拆成两个三角形,但是要保证正方向
// 为了计算简单,我们让起点不变,向后移动组合。多个顶点也是这样
// 1、2、3 + 1、3、0

// 保存所有索引
const vertexIndices: number[] = []
const normalIndices: number[] = []
Object.entries(materials).forEach(([_, faceList]) => {
faceList.forEach(face => {
  // 倒数第二个就不用计算了,越界了
  let n = face.vIndices.length - 2
  face.vIndices = face.vIndices.reduce<number[]>((prev, cur, index, arr) => {
    if(index >= n) return prev
    // 所有三角形都从第一个点开始
    prev.push(arr[0] - 1)
    // 后两个
    prev.push(...[arr[index + 1] - 1, arr[index + 2] - 1])
    return prev
  }, [])
  // 法线向量索引一样的操作
  face.nIndices = face.nIndices.reduce<number[]>((prev, cur, index, arr) => {
    // 法向量个数肯定和顶点一样的
    if(index >= n) return prev
    prev.push(arr[0] - 1)
    prev.push(...[arr[index + 1] - 1, arr[index + 2] - 1])
    return prev
  }, [])
  normalIndices.push(...face.nIndices)
  vertexIndices.push(...face.vIndices)
})
})

虽然没有颜色但是可以看出成功了

msedge_9XsFinfGK5.gif

4.1.3. 加点颜色

//...
// 按照之前的学习,给每个面的顶点设置颜色就行了
Lib.bindVertexBuffer(Float32Array.from([
1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
]), 'color', 3, gl.FLOAT, 0, 0)
//....

因为我加了光线颜色和环境光,所以有点暗,不过仍然可以看出成功设定成了红色。

msedge_1iPtI7gsOX.gif

如果你想给某一个面设置颜色那就有点困难了,obj文件中是按照整个面来定义的顶点和索引,而webgl中一个面只能由多个三角形组成,所以一个面比obj中定义的顶点数要多很多,直接对obj提供的顶点设置颜色是不得行的。

所以我们下一步是需要还原顶点数组。

// obj给了8个顶点
// 但是每个面需要两个三角形组成,2 * 3个顶点
// 总共2 * 3 * 6 = 36个顶点
// 再乘以3个坐标轴,数组里面应该有108个元素
const vertexData: number[] = []
vertexIndices.forEach(indice => {
    // 每个点3个坐标
    vertexData.push(...vertexList.slice(indice * 3, indice * 3 + 3))
})
Lib.bindVertexBuffer(Float32Array.from(vertexData), 'pos', 3, gl.FLOAT, 0, 0)

obj只给了8个顶点,实际上我们需要6 * 3 * 2 = 36个顶点,当然这里面有重复的,不过这是最简单的方法。所以根据前面的顶点索引vertexIndices,可以取出我们需要的顶点。

这里还需要注意的是,绘制的时候不能再用obj文件给的顶点索引了,因为那个索引对应的是整个面的顶点,而现在我们是要分割了三角形的面。

实际上,我们在前面分割三角形之后,就是正确的顶点排列了。

// 此时的vertexData已经是划分了三角形的顶点数据
// 不能直接用obj提供的顶点索引,因为那个索引是按照面分划分的
// vertexData有重复点,比如第一个面有6个顶点数据
// vertexData里面的顶点已经是正确顺序的了,一个面由两个三角形2*3个顶点组成
// 所以顶点索引实际上只需要挨着排下去就行了
const vertexIndicesData: number[] = vertexData.map((_, index) => index)
Lib.bindIndexBuffer(new Uint8Array(vertexIndicesData))

接下来我们就可以设置某一个面的颜色了

  // 现在一个面是6个顶点
  // 根据obj文件的定义,第一组应该是上面
  // 将上面设置为绿色
  Lib.bindVertexBuffer(Float32Array.from([
    0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0,
    1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
    1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
    1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
    1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
    1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
  ]), 'color', 3, gl.FLOAT, 0, 0)

效果

msedge_o9iXMYYz7W.png

有了基础的模型加个光照运动什么的也不是什么难事了

// 加个光照
Lib.setUniformValue('lightDirection', [0, 0, 2])
Lib.setUniformValue('lightColor', [1, 1, 1])
Lib.setUniformValue('ambientColor', [0.2, 0.2, 0.2])
// 法向量和顶点一样处理
const normalData: number[] = []
normalIndices.forEach(indice => {
// 每个点3个坐标
normalData.push(...normalList.slice(indice * 3, indice * 3 + 3))
})
Lib.bindVertexBuffer(Float32Array.from(normalData), 'originalNormal', 3, gl.FLOAT, 0, 0)
// 法向量的逆转置,为了能运动计算光照
Lib.setUniformMatrixValue('normalMat', mat4.transpose(mat4.create(),  mat4.invert(mat4.create(), modelMat)))
Lib.draw(true)

msedge_OCrE2MdwDQ.gif

4.2. 解析.mtl

模型已经能加载了,现在我们可以来研究一下,怎么加载材质。

当我们读取objusemtl时,就知道该解析材质了,材质本身就是一些颜色图像的定义,直接拿过来就行了。这里就简单的设置一下颜色就行了。

// .... 
const readMTL = (file: File) => new Promise((resolve) => {
  const mtl: Record<string, string[]> = {}
  const fileReader = new FileReader()
  let currentMtl = ''
  fileReader.readAsText(file, 'utf-8')
  fileReader.onload = function (e) {
    if(typeof this.result === 'string') {
      // 和处理obj文件一样的
      const rows = this.result.split('\n')
      rows.forEach(row => {
        const [key, ...values] = row.split(' ')
        switch (key) {
          case '#': {
            break;
          }
          case 'newmtl': {
            currentMtl = values[0]
            break
          }
          // 这里只用了kd
          case 'Kd': {
            mtl[currentMtl] = values.map(v => v.replace('\r', ''))
            break
          }
          default: break
        }
      })

      resolve(mtl)
    }
  }
})
// ....

解析mtlobj是一样的。

5.总结

上面的代码只是一个思路,还不能算通用的解析器,还有很多问题和边界没考虑。只是了解一下,主要还是基本知识重要。