前端多媒体小知识之:三维模型介绍

1,089 阅读12分钟

作者:王思萍

前言

以前的电商产品在线上展示的方式通常是是图文结合,我们关注到,如今许多电商导购场景开始通过三维模型,提供线上更多的交互展示。对于消费者来说,三维模型提供了更好的产品互动,可以更加充分的了解产品,从而有更好的消费体验。本文以一个小例子为引,介绍了三维模型的基本知识与概念。

三维模型基本构成

简单的三维模型由两个最基本的部分构成:几何数据和材质

几何数据

包含了顶点位置、顶点的法向量、顶点的uv贴图坐标等信息

材质

  • 基本材质:粗糙度、金属高光度、透明度等材质信息
  • 贴图
    • uv贴图:3D模型表面的平面表示。可以想象一下把正方体剪开成一个平面的过程

    • 凹凸贴图: 包含一些凸起的矩形和文本的凹凸信息。

    • 法线贴图

例子 🌰:展开uv并绘制贴图

我们通过尝试一步一步制作简单一个三维人脸模型来展示基本的步骤

  1. 软件帮我们展的 uv 是从顶点剪开的,从图中并不能识别正反面,眼睛与嘴巴位置

  1. 手动标记剪裁边,再展开uv,我们标记正面的边缘一圈为剪裁边,再剪裁开来,得到以下

  1. 导出uv
  1. 根据uv图中的位置,在ps中绘制贴图

在uv图中,可以看到眼睛与嘴巴的大致位置,因此我们可以粗糙地画上红色的嘴巴、浅色的腮红、浅灰色的眉毛等,并且给一个皮肤的颜色作为图片的底色。

  1. 眼睛的贴图也是同理

导出的uv图:

如图所示,我们切成了前半眼球,后半眼球两个部分(上面为眼球前半部分,下面为眼球后半部分),我们在前半部分的中心位置给上一个瞳仁的图,并没有全部覆盖的原因是眼球前半部分还有眼白,瞳仁只是一小部分。后半眼球也给涂抹上白色。

  1. 赋予模型贴图

(模型做的很粗糙,能懂就行,求轻喷)

二、如何存储三维模型

以下以常见的.obj格式与web界友好的.glTF格式为例,介绍是模型数据是如何在这两种文件中组织的。

1、软件导出obj格式

以简单格式obj为例:

导出的时候一般会有两个东西:

  • box.obj
  • box.mtl

.mtl文件

一个简单的box.mtl文件示例:

newmtl lambert5SG
illum 4
Kd 0.00 0.00 0.00
Ka 0.00 0.00 0.00
Tf 1.00 1.00 1.00
map_Kd box_uvmap.png
Ni 1.00

这个文件的重点在于:map_kd 指向了贴图路径

.obj文件

预览文件如下,我们分别来看其中不同标记的含义

# 链接到材质
mtllib box.mtl

# 默认group
g default

# 8个顶点
v -0.500000 -0.500000 0.500000
v 0.500000 -0.500000 0.500000
v -0.500000 0.500000 0.500000
v 0.500000 0.500000 0.500000
v -0.500000 0.500000 -0.500000
v 0.500000 0.500000 -0.500000
v -0.500000 -0.500000 -0.500000
v 0.500000 -0.500000 -0.500000

# 14个纹理上的点
vt 0.375000 0.000000
vt 0.625000 0.000000
vt 0.375000 0.250000
vt 0.625000 0.250000
vt 0.375000 0.500000
vt 0.625000 0.500000
vt 0.375000 0.750000
vt 0.625000 0.750000
vt 0.375000 1.000000
vt 0.625000 1.000000
vt 0.875000 0.000000
vt 0.875000 0.250000
vt 0.125000 0.000000
vt 0.125000 0.250000

# 24个顶点法向量
# 正方体6个面,其实6个法向量即可,但是blender导出时每个法向量都重复了4次
vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
vn 0.000000 1.000000 0.000000
vn 0.000000 1.000000 0.000000
vn 0.000000 1.000000 0.000000
vn 0.000000 1.000000 0.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 0.000000 -1.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 1.000000 0.000000 0.000000
vn 1.000000 0.000000 0.000000
vn 1.000000 0.000000 0.000000
vn 1.000000 0.000000 0.000000
vn -1.000000 0.000000 0.000000
vn -1.000000 0.000000 0.000000
vn -1.000000 0.000000 0.000000
vn -1.000000 0.000000 0.000000

# 光滑 off表示关闭
s off

# 代表group,顶点或者三角面片的集合名称
g pCube1

# 材质
usemtl lambert5SG

# 面
f 1/1/1 2/2/2 4/4/3 3/3/4
f 3/3/5 4/4/6 6/6/7 5/5/8
f 5/5/9 6/6/10 8/8/11 7/7/12
f 7/7/13 8/8/14 2/10/15 1/9/16
f 2/2/17 8/11/18 6/12/19 4/4/20
f 7/13/21 1/1/22 3/3/23 5/14/24

mtllib

代表材质库,通常指向到某个mtl文件

mtllib box.mtl

usemtl

usemtl phong1SG

usemtl为模型指定一个基础材质

v(vertices)

几何形状的顶点位置。因为物体是由面构成的,而面是由线构成的,线由点构成的,所以无论是何形状,几何顶点是最基础的数据,下面表示了世界坐标为(0.5, 0.5, -0.5)的一个点

v -0.500000 -0.500000 0.500000 

vt(vertex texture)

顶点纹理,代表当前顶点对应纹理图的哪个(x, y);

通常是x, y 介于0-1之间,如果大于1,就相当于将纹理重新扩充然后取值,比如镜像填充、翻转填充之类的,然后根据纹理图的宽高去计算具体像素位置。

vt 0.625000 0.500000

vn(vertex normal)

顶点法向量的坐标(x,y,z)。

vn -1.000000 0.000000 0.000000

f(face)

大部分几何体都是由面构成的,而面是由点构成的。在结构关系、视觉效果正确的模型中,每个顶点都有空间中的一个位置(决定了面的位置),有对应的纹理坐标(决定了面的渲染纹理),有法线(决定了面的朝向:内或者外)。其格式有几种形式:

# 仅包含顶点
f v1 v2 v3

# 包含顶点和纹理
f v1/vt1 v2/vt2 v3/vt3

# 包含顶点、纹理和法向量
f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 

# 包括顶点和法向量
f v1//vn1 v2//vn2 v3//vn3

不同形式的记录方法都是使用斜杠区分不同的字段索引,f 后面跟的是正整数,代表对应字段的索引。

第三方三维建模软件中,常见的片元为由3个顶点构成的三角面,以及四个顶点构成的四角面。下面就表示了六个面,每个面由4个点构成,用斜杠区分了同一个顶点的位置索引、纹理索引、法向量索引。

f 1/1/1 2/2/2 4/4/3 3/3/4
f 3/3/5 4/4/6 6/6/7 5/5/8
f 5/5/9 6/6/10 8/8/11 7/7/12
f 7/7/13 8/8/14 2/10/15 1/9/16
f 2/2/17 8/11/18 6/12/19 4/4/20
f 7/13/21 1/1/22 3/3/23 5/14/24

可以看到第一个面的四个点的第三个索引分别是1 2 3 4,对应vn都是同一个vn 0.000000 0.000000 1.000000

第二个面的四个点的第三个索引是分别是5 6 7 8,对应的vn也都是同一个vn 0.000000 1.000000 0.000000

⚠️这里说明一下,为什么需要顶点法线呢?

因为一个面有正反的分别,点的法向量决定了面的渲染方式。比如我们将预览文件中第一个面的第四个顶点3/3/4改成3/3/9,也就是把第一个面的对应顶点的的法向量从vn 0.000000 0.000000 1.000000改成了相反的vn 0.000000 0.000000 -1.000000,会出现以下情形(后面为原来的模型,贴图正确显示,前面为修改后的模型,可以看到显示异常):

image.png

但进入模型内部可以看到该贴图

image.png

格式缺点

文本格式,在数据量大的情况下,会导致存储空间大、快速读写数据的效率极低

2、软件导出glTF格式

glTF格式的目标是为3D内容的数据格式提供统一的标准,它的目标是成为3D界的jpeg,意思就是希望像使用jpeg图片格式一样简单地使用它,比如方便地在网页中进行加载,或者打开系统默认程序就能看到可交互的三维模型。

目前而言,现存的3D数据格式不能够方便地在互联网上进行传输,以及直接高效地进行渲染。glTF的目标是作为一个中转格式,而不是另一个新的3D数据格式:

  • 使用JSON来描述场景结构,可以方便地被应用程序分析处理。
  • 3D数据以一种可以被大多数图形API直接使用的方式进行存储,不需要应用程序进行解码或预处理操作。

以blender建模软件导出glTF为例,导出时,有下面三种选择:

image.png

为了便于学习,我们以.glTF一个描述模型的综合信息的JSON文件为例,讲解它是如何读取.bin二进制文件的。

下面以最简单的gltf文件(一个三角面)为例:

其文件结构:

{
  "scene": 0,
  "scenes" : [
    {
      "nodes" : [ 0 ]
    }
  ],
  
  "nodes" : [
    {
      "mesh" : 0
    }
  ],
  
  "meshes" : [
    {
      "primitives" : [ {
        "attributes" : {
          "POSITION" : 1
        },
        "indices" : 0
      } ]
    }
  ],

  "buffers" : [
    {
      "uri" : "data:application/octet-stream;base64,AAABAAIAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAA=",
      "byteLength" : 44
    }
  ],
  "bufferViews" : [
    {
      "buffer" : 0,
      "byteOffset" : 0,
      "byteLength" : 6
    },
    {
      "buffer" : 0,
      "byteOffset" : 8,
      "byteLength" : 36
    }
  ],
  "accessors" : [
    {
      "bufferView" : 0,
      "byteOffset" : 0,
      "componentType" : 5123,
      "count" : 3,
      "type" : "SCALAR",
      "max" : [ 2 ],
      "min" : [ 0 ]
    },
    {
      "bufferView" : 1,
      "byteOffset" : 0,
      "componentType" : 5126,
      "count" : 3,
      "type" : "VEC3",
      "max" : [ 1.0, 1.0, 0.0 ],
      "min" : [ 0.0, 0.0, 0.0 ]
    }
  ],
  
  "asset" : {
    "version" : "2.0"
  }
}

有了大体的认识,我们分别从每个字段来理解他是如何组织三维模型各个部分的。

1) 依次读取scenes、nodes、meshes

glTF比obj的覆盖内容更多,它不仅仅知识一个模型,还是一个场景,这个场景里可以存储动画、多个场景信息、骨骼、灯光信息、摄像机以及摄像机动画等。通过scene: 0读取到scenes数组中下标为0的元素,然后读取到了nodes中下表为0的元素,再接着我们读取到了meshes中下标为0的网格

 "scenes" : [
    {
      "nodes" : [ 0 ]
    }
  ],
  "scene": 0, 
  "nodes" : [
    {
      "mesh" : 0
    }
  ],

mesh中存储了一些访问器,也就是accessors数组的索引,不同的accessor访问二进制数据的方式不同,在读取mesh之前,我们来了解一下accessors

2) 认识accessors

bufferView的访问器,怎么读、从哪读

  • 数据位置

    • bufferView:一个accessor对应一个bufferView
    • bufferoffset:用来表明bufferview的数据从什么地方开始读取。例子中bufferoffset均为0,因为没有出现一个bufferview需要多个accessor来读取的情况。
  • 数据类型

    • type:如scalar标量,vet3向量,vet4向量,matrix4四阶矩阵
    • componentType:描述数据类型,如5123为unsigned short(2个字节),5126为float(4个字节)
  • 数据内容

    • count:数据元素的个数
    • max:最大值
    • min:最小值
  "accessors" : [
    {
      "bufferView" : 0,
      "byteOffset" : 0,
      "componentType" : 5123,
      "count" : 3,
      "type" : "SCALAR",
      "max" : [ 2 ],
      "min" : [ 0 ]
    },
    {
      "bufferView" : 1,
      "byteOffset" : 0,
      "componentType" : 5126,
      "count" : 3,
      "type" : "VEC3",
      "max" : [ 1.0, 1.0, 0.0 ],
      "min" : [ 0.0, 0.0, 0.0 ]
    }
  ],

第一个accessor访问器得到的数据为 几何图形顶点的索引信息

第二个accessor访问器得到的数据为 几何图形顶点的位置信息

3) accessor读取buffers、bufferViews

一个buffer代表一个原始的二进制数据块,没有层级结构,buffer通过uri来定位数据。这个uri可以指向一个外部的文件(.bin文件),或者是直接写入的二进制数据。

数据块中的数据可以是顶点属性数据、索引数据、蒙皮信息、关键帧动画等。如果要读取buffer中的数据,需要额外的信息——bufferView和accessor,根据得到的数据结构、数据类型来解析buffer数据。

  "buffers" : [
    {
      "uri" : "data:application/octet-stream;base64,AAABAAIAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAA=",
      "byteLength" : 44
    }
  ],

一个bufferview代表一个buffer的切片(或块)

  "bufferViews" : [
    {
      "buffer" : 0,
      "byteOffset" : 0,
      "byteLength" : 6
    },
    {
      "buffer" : 0,
      "byteOffset" : 8,
      "byteLength" : 36
    }
  ],

4) 解析meshes

网格由很多片元primitive构成,片元的属性有顶点位置信息POSITION、顶点法线坐标NORMAL,对于有蒙皮的三角形会更加复杂,其属性名为JOINTS_0,表示骨骼与模型的绑定信息,以及WEIGHTS_0属性,表明关节带动不同部位的皮肤的权重设置。其属性值都是对应accessor的索引,从而可以读取到对应的数据。

 "meshes" : [
    {
      "primitives" : [ {
        "attributes" : {
          "POSITION" : 1,
          "NORMAL" : 2
          // JOINTS_0 和 WEIGHTS_0出现在有骨骼的模型中
          // "JOINTS_0": 3, // 骨骼,也叫关节
          // "WEIGHTS_0": 4 // 蒙皮权重,即关节对模型的影响
        },
        "indices" : 0,
        "mode": 4
      } ]
    }
  ],

默认情况下,mesh primitive的渲染模式为三角形。但是通过mode属性,可以指定其他渲染模式。glTF支持的渲染模式有。另:不同渲染模式的区别

mode属性值渲染模式
0ponits
1lines
2line_loop
3line_strip
4triangles
5triangle_strip
6triangle_fan

还有一些模型相关的属性,如materials, textures, animation, skins等等,下面依次来介绍。

5) 解析materials

材质决定了物体的高光、反光、透明度、粗糙度等物理属性,从而渲渲染出不同质感、更为真实的物体。

  "materials" : [
    {
      "pbrMetallicRoughness": {
        "baseColorFactor": [ 1.000, 0.766, 0.336, 1.0 ],
        "metallicFactor": 0.5,
        "roughnessFactor": 0.1
      }
    }
  ],

把材质指派给对应的网格

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

6) 解析textures

一个面可以有多种纹理贴图,每种贴图对应到不同的通道上

  • 发光纹理emissive texture :描述了物体表面发出某种颜色的光的部分。
  • 遮挡纹理occlusion texture:用来模拟物体相互交错产生阴影的效果。
  • 法线贴图normal map:一种用于调制表面法线的纹理,可以模拟更精细的几何细节,而无需更高的网格分辨率
  • 颜色贴图:最常见的贴图,让每个片元有对应的色块
  • ......
"textures": [
  {
    "source": 0,
    "sampler": 0
  }
],
"images": [
  {
    "uri": "testTexture.png"
  }
],
"samplers": [
  {
     "magFilter": 9729,
     "minFilter": 9987,
     "wrapS": 33648,
     "wrapT": 33648
   }
],

7) 解析animations

每一个动画可以多个channel,每一个channel对应着一个属性值的关键帧变化,如下所示表示nodes[0]的translate属性变化,也就是发生了位移。而保存位移信息的动画数据就在samplers[0]中,其input为2,代表accessors[2], 读取对应的bufferView,可以得到打下的关键帧的时间信息;其output为3,代表accessors[3], 读取对应的bufferView,可以得到打下的关节帧的具体属性调整。其中interpolation表示线性插值,表明了两个关键帧中间的补帧方式,从而显示流畅的动画。

  "animations": [
    {
      "channels" : [ {
        "sampler" : 0,
        "target" : {
          "node" : 0,
          "path" : "translate"
        }
      } ],
       "samplers" : [{
          "input" : 2, // 打下关键帧对应的时间
          "interpolation" : "LINEAR",// 插值:线性
          "output" : 3 // 帧数据
        }],
    }
  ],

8) 解析skin

  "skins": [
    {
      "inverseBindMatrices": 4,
      "joints": [1, 2] // 骨骼数据,为node数组索引值,指向了一个node
    }
  ],

现有渲染库的用户几乎不需要手动处理 glTF 资产中包含的顶点蒙皮数据:实际的蒙皮计算通常发生在顶点着色器中,这是各个库的低级实现细节。具体可以查看tutorial1 tutorial2 顶点信息:

绑定了两个骨骼并且设置骨骼的关键帧动画后: