1. 概述
之前学习了这么多计算机图形的知识,但是我们使用的图形都只是简单的立方体,如果我们想要画一个复杂的图形那将需要定义非常多的顶点数据,还要微调很多细节曲面。全部手工绘制确实很麻烦,好在现在有现成的3d
建模工具,可以可视化绘制模型,然后导出顶点、纹理等等信息。我们只需要解析模型文件,就能复原一个模型,不需要再手动定义顶点了。
2. 文件格式
3d
模型文件有很多格式,glb
、gltf
、fbx
等等,本文基于.obj
格式介绍怎么解析加载模型,目的主要还是通过一种规格了解模型文件的存储方式。
.obj
是Wavefront Technologies
开发的一种开放文件,所以基本上所有软件都支持obj
。
你可以下载一个blender
一种三维建模工具,绘制一个简单的图像并导出。blender
怎么使用这里就不展开了,现在我用blender
绘制了一个简单的图像:
如果按照以前的方法,绘制这么简单的一个物体可能都要扣破头皮。
3. 深入.obj
如果你使用软件导出.obj
文件,同时还会导出一个.mtl
文件。.obj
保存的是各种顶点信息,.mtl
保存的是材质信息。
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. 解析文本
文本还是很简单,obj
和mtl
语法都是一行为一个语句,每一行第一个单词是命令,后面就是值。
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)
实际效果:
根本不是立方体,但是我们也可以看出有三角面,但是连接顺序好像有问题。
这其实是因为obj
导出的索引并不是按照三角面来的,而是一个整个面边角顶点。比如一个正方形平面,四个角四个顶点,一个锥体表面,就是三个角三个顶点。
所以直接使用是我无法绘制出正确图像的。我们需要根据给出的顶点切割出三角形这样才能正确绘制。
假设
obj
中其中一个面的顶点索引顺序是v0-v1-v2-v3
(逆时针旋转),那么我们可以保持第一个顶点不变,依次我往后数两个顶点组成一个三角面。比如这里的v0-v1-v2
和v0-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)
})
})
虽然没有颜色但是可以看出成功了
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)
//....
因为我加了光线颜色和环境光,所以有点暗,不过仍然可以看出成功设定成了红色。
如果你想给某一个面设置颜色那就有点困难了,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)
效果
有了基础的模型加个光照运动什么的也不是什么难事了
// 加个光照
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)
4.2. 解析.mtl
模型已经能加载了,现在我们可以来研究一下,怎么加载材质。
当我们读取obj
的usemtl
时,就知道该解析材质了,材质本身就是一些颜色图像的定义,直接拿过来就行了。这里就简单的设置一下颜色就行了。
// ....
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)
}
}
})
// ....
解析mtl
和obj
是一样的。
5.总结
上面的代码只是一个思路,还不能算通用的解析器,还有很多问题和边界没考虑。只是了解一下,主要还是基本知识重要。