一、碰撞检测
在DirectX SDK中,碰撞检测的相关函数位于xnacollision.h中。但是现在,前面所实现的相关函数都已经转移到Windows SDK的DirectXCollision.h中,并且处于名称空间DirectX内。这里面主要包含了四种包围盒(Bounding Volumes),并且是以类的形式实现的:
- BoundingSphere类–包围球(Bounding Box)
- BoundingBox类–轴对齐包围盒(Axis-aligned bounding box),又称AABB盒
- BoundingOrientedBox类–有向包围盒(Oriented bounding box),又称OBB盒
- BoundingFrustum类–包围视锥体(Bounding Frustum)
除此之外里面还包含有三角形(射线)与其余物体的碰撞检测。
1.1 常见包围盒
1.1.1 包围球(Bounding Box)
一个球体只需要使用圆心坐标和半径就可以表示。结构体的一部分如下:
struct BoundingSphere
{
XMFLOAT3 Center; // 球体中心坐标
float Radius; // 球体半径
// 构造函数
BoundingSphere() : Center(0,0,0), Radius( 1.f ) {}
XM_CONSTEXPR BoundingSphere(const XMFLOAT3& center, float radius )
: Center(center), Radius(radius) {}
BoundingSphere(const BoundingSphere& sp )
: Center(sp.Center), Radius(sp.Radius) {}
// ...
// 静态创建方法
static void CreateMerged(BoundingSphere& Out, const BoundingSphere& S1, const BoundingSphere& S2 );
static void CreateFromBoundingBox(BoundingSphere& Out, const BoundingBox& box );
static void CreateFromBoundingBox(BoundingSphere& Out, const BoundingOrientedBox& box );
static void CreateFromPoints(BoundingSphere& Out, size_t Count, const XMFLOAT3* pPoints, size_t Stride);
static void CreateFromFrustum(BoundingSphere& Out, const BoundingFrustum& fr );
};
其中BoundingSphere::CreateMergerd静态方法将场景中的两个包围球体用一个更大的包围球紧紧包住。
BoundingSphere::CreateFromBoundingBox静态方法则是从AABB盒或OBB盒创建出外接包围球。
然后还需要着重说明一下BoundingSphere::CreateFromPoints静态方法的使用,因为通常情况下我们是用自定义的结构体来描述一个顶点,然后再使用的顶点数组,所以该方法也适用于从顶点数组创建出一个包围球。
- 参数Count说明了顶点的数目。
- 参数pPoints需要填上的是顶点数组第一个元素中位置向量的地址。
- 参数Stride即可以说是顶点结构体的字节大小,也可以说是跳到下一个元素中的位置向量需要偏移的字节数。
下面是一个使用示例:
struct VertexPosNormalTex
{
XMFLOAT3 pos;
XMFLOAT3 normal;
XMFLOAT3 tex;
};
VertexPosNormalTex vertices[20];
// 省略初始化操作...
BoundingSphere sphere;
BoundingSphere::CreateFromPoints(sphere, 20, &vertices[0].pos, sizeof(VertexPosNormalTex));
1.1.2 轴对齐包围盒(Axis-aligned bounding box)
一个物体若拥有AABB盒,那AABB盒的六个面都会和物体紧紧贴靠在一起。它可以用两个点描述:Vmax和Vmin。其中Vmax的含义是:分别取物体所有顶点中x, y, z分量下的最大值以构成该顶点。Vmin的含义则是:分别取物体所有顶点中x, y, z分量下的最小值以构成该顶点。
获取了这两个点后,我们就可以用另一种表述方式:中心位置C和一个3D向量E,E的每个分量的含义为中心位置到该分量对应轴的两个面的距离(距离是相等的)。
该碰撞库使用的则是第二种表述方式,但也支持用第一种方式来构建。结构体的一部分如下:
struct BoundingBox
{
static const size_t CORNER_COUNT = 8; // 边界点数目
XMFLOAT3 Center; // 盒中心点
XMFLOAT3 Extents; // 中心点到每个面的距离
// 构造函数
BoundingBox() : Center(0,0,0), Extents( 1.f, 1.f, 1.f ) {}
XM_CONSTEXPR BoundingBox(const XMFLOAT3& center, const XMFLOAT3& extents)
: Center(center), Extents(extents) {}
BoundingBox(const BoundingBox& box) : Center(box.Center), Extents(box.Extents) {}
// ...
// 静态创建方法
static void CreateMerged(BoundingBox& Out, const BoundingBox& b1, const BoundingBox& b2 );
static void CreateFromSphere(BoundingBox& Out, const BoundingSphere& sh );
static void XM_CALLCONV CreateFromPoints(BoundingBox& Out, FXMVECTOR pt1, FXMVECTOR pt2 );
static void CreateFromPoints(BoundingBox& Out, size_t Count, const XMFLOAT3* pPoints,size_t Stride );
};
BoundingBox::CreateMerged静态方法创建一个最小的AABB盒,能够同时包含这两个AABB盒。
BoundingBox::CreateFromSphere静态方法给球体创建外接立方体包围盒。
BoundingBox::CreateFromPoints静态方法中的参数pt1和pt2即可以为包围盒某一斜对角线上的两个顶点,也可以是一个包含所有点中xyz分量最大值和最小值的两个构造点。
1.1.3 有向包围盒(Oriented bounding box)
对某些物体来说,在经过一系列变换后它的包围盒也需要随之改变。但例如一些默认情况下宽度、深度值比高度大得多的物体,比如飞机、书本等,经过变换后它的AABB盒可能会变得特别大,暴露了许多空余的位置,从而不能很好地将物体包住。
因为AABB盒的边界是轴对齐的,没有办法记录旋转属性。这时候我们可以考虑使用OBB盒,除了包含AABB盒应当记录的信息外,它还记录了旋转相关的信息。结构体部分如下:
struct BoundingOrientedBox
{
static const size_t CORNER_COUNT = 8; // 边界点数目
XMFLOAT3 Center; // 盒中心点
XMFLOAT3 Extents; // 中心点到每个面的距离
XMFLOAT4 Orientation; // 单位旋转四元数(物体->世界)
// 构造函数
BoundingOrientedBox() : Center(0,0,0), Extents( 1.f, 1.f, 1.f ), Orientation(0,0,0, 1.f ) {}
XM_CONSTEXPR BoundingOrientedBox(const XMFLOAT3& _Center, const XMFLOAT3& _Extents, const XMFLOAT4& _Orientation)
: Center(_Center), Extents(_Extents), Orientation(_Orientation) {}
BoundingOrientedBox(const BoundingOrientedBox& box)
: Center(box.Center), Extents(box.Extents), Orientation(box.Orientation) {}
// ...
// 静态创建方法
static void CreateFromBoundingBox(BoundingOrientedBox& Out, const BoundingBox& box );
static void CreateFromPoints(BoundingOrientedBox& Out, size_t Count,
const XMFLOAT3* pPoints, size_t Stride );
};
其中BoundingOrientedBox::CreateFromBoundingBox静态方法创建出跟AABB盒和一样的OBB盒,并且单位旋转四元数是默认的(即没有产生旋转)。
1.1.4 包围视锥体(Bounding Frustum)
为了描述一个视锥体,一种方式是以数学的形式指定视锥体的六个边界平面:左/右平面,顶/底平面,近/远平面。这里假定六个视锥体平面是朝向内部的。
虽然我们可以用六个4D平面向量来存储一个视锥体,但这还是有更节省空间的表示方法。
首先在物体坐标系中,取摄像头的位置为原点,正前方观察方向为Z轴,右方向为X轴,上方向为Y轴,这样就可以得到右(左)平面投影到zOx平面下的直线斜率为X/Z(-X/Z),以及上(下)平面投影到zOy平面下的直线斜率为Y/Z(-Y/Z)。同时也可以得到近(远)平面到原点的距离,也即是对应的Z值。
然后使用一个3D位置向量和单位旋转四元数来表示视锥体在世界中的位置和朝向。这样我们也可以描述一个视锥体了。
下面展示了视锥体包围盒结构体的部分内容:
struct BoundingFrustum
{
static const size_t CORNER_COUNT = 8;
XMFLOAT3 Origin; // 摄像机在世界中的位置,物体坐标系下默认会设为(0.0f, 0.0f, 0.0f)
XMFLOAT4 Orientation; // 单位旋转四元数
float RightSlope; // 右平面投影到zOx平面的直线斜率+X/Z
float LeftSlope; // 左平面投影到zOx平面的直线斜率-X/Z
float TopSlope; // 上平面投影到zOy平面的直线斜率+Y/Z
float BottomSlope; // 下平面投影到zOy平面的直线斜率-Y/Z
float Near, Far; // Z值对应近(远)平面到摄像机物体坐标系原点的距离
// 构造函数
BoundingFrustum() : Origin(0,0,0), Orientation(0,0,0, 1.f), RightSlope( 1.f ), LeftSlope( -1.f ),
TopSlope( 1.f ), BottomSlope( -1.f ), Near(0), Far( 1.f ) {}
XM_CONSTEXPR BoundingFrustum(const XMFLOAT3& _Origin, const XMFLOAT4& _Orientation,
float _RightSlope, float _LeftSlope, float _TopSlope, float _BottomSlope,
float _Near, float _Far)
: Origin(_Origin), Orientation(_Orientation),
RightSlope(_RightSlope), LeftSlope(_LeftSlope), TopSlope(_TopSlope), BottomSlope(_BottomSlope),
Near(_Near), Far(_Far) {}
BoundingFrustum(const BoundingFrustum& fr)
: Origin(fr.Origin), Orientation(fr.Orientation), RightSlope(fr.RightSlope), LeftSlope(fr.LeftSlope),
TopSlope(fr.TopSlope), BottomSlope(fr.BottomSlope), Near(fr.Near), Far(fr.Far) {}
BoundingFrustum(CXMMATRIX Projection) { CreateFromMatrix( *this, Projection ); }
// ...
// 静态创建方法
static void XM_CALLCONV CreateFromMatrix(BoundingFrustum& Out, FXMMATRIX Projection);
}
通常情况下,我们会通过传递投影矩阵来创建一个包围视锥体,而不是直接指定上面的这些信息。
1.2 包围盒的相交、包含、碰撞检测及变换
1.2.1 包围盒与平面的相交检测
对于包围盒与平面的相交检测,返回结果使用了枚举类型PlaneIntersectionType来描述相交情况:
enum PlaneIntersectionType
{
FRONT = 0, // 包围盒在平面的正面区域
INTERSECTING = 1, // 包围盒与平面有相交
BACK = 2, // 包围盒在平面的背面区域
};
上面提到的四种包围盒都具有重载方法Intersects用于检测该包围盒与平面的相交情况:
PlaneIntersectionType XM_CALLCONV Intersects(FXMVECTOR Plane) const;
正/背面的判定取决于一开始平面法向量的设定。比如一个中心在原点,棱长为2的正方体,与平面-z+2=0(对应4D平面向量(0.0f,0.0f,-1.0f,2.0f), 平面法向量(0.0f,0.0f,-1.0f) )的相交结果为:物体在平面的正面区域。
1.2.2 包围盒与包围盒的包含检测
对于两个包围盒的包含检测,返回结果使用了枚举类型ContainmentType来描述包含情况:
enum ContainmentType
{
DISJOINT = 0, // 两个包围盒相互分离
INTERSECTS = 1, // 两个包围盒有相交
CONTAINS = 2, // 两个包围盒存在包含关系
};
这四种包围盒相互之间都有对应的方法来测试:
ContainmentType Contains(const BoundingSphere& sp) const;
ContainmentType Contains(const BoundingBox& box) const;
ContainmentType Contains(const BoundingOrientedBox& box) const;
ContainmentType Contains(const BoundingFrustum& fr) const;
1.2.3 包围盒与包围盒的碰撞检测
如果我们只需要检查两个包围盒之间是否发生碰撞(相交和包含都算),则可以使用下面的这些方法。四种包围盒相互之间都能进行碰撞测试:
bool Intersects(const BoundingSphere& sh) const;
bool Intersects(const BoundingBox& box) const;
bool Intersects(const BoundingOrientedBox& box) const;
bool Intersects(const BoundingFrustum& fr) const;
1.2.4 包围盒的变换
四种包围盒都包含下面两个方法,一个是任意矩阵的变换,另一个是构造世界矩阵的变换(这里用BoundingVolume来指代这四种包围盒):
void XM_CALLCONV Transform(BoundingVolume& Out, FXMMATRIX M ) const;
void XM_CALLCONV Transform(BoundingVolume& Out, float Scale, FXMVECTOR Rotation, FXMVECTOR Translation) const;
要注意的是,第一个参数都是用于输出变换后的包围盒,Rotation则是单位旋转四元数。
1.2.5 包围盒的其它方法
1.2.5.1 获取包围盒的八个顶点
除了包围球外的其它包围盒都拥有方法GetCorners:
void GetCorners(XMFLOAT3* Corners) const;
这里要求传递的参数Corners是一个可以容纳元素个数至少为8的数组。
1.2.5.1 获取包围视锥体的六个平面
BoundingFrustum::GetPlanes方法可以获取视锥体六个平面的平面向量:
void GetPlanes(XMVECTOR* NearPlane, XMVECTOR* FarPlane, XMVECTOR* RightPlane,
XMVECTOR* LeftPlane, XMVECTOR* TopPlane, XMVECTOR* BottomPlane) const;
包围视锥体在检测是否包含某一包围盒的时候内部会调用待测包围盒的ContainedBy静态重载方法,参数为视锥体提供的六个平面。故下面的方法通常我们不会直接用到:
ContainmentType XM_CALLCONV ContainedBy(FXMVECTOR Plane0, FXMVECTOR Plane1, FXMVECTOR Plane2,
GXMVECTOR Plane3, HXMVECTOR Plane4, HXMVECTOR Plane5 ) const;
// Test frustum against six planes (see BoundingFrustum::GetPlanes)
1.3 三角形、射线
三角形的表示需要用到三个坐标点向量,而射线的表示则需要一个Origin向量(射线起点)和一个Direction向量(射线方向),其中Direction是单位向量。
1.3.1 射线(三角形)与非包围盒的相交检测
下面这三个常用的方法都在名称空间DirectX::TriangleTests中(ContainedBy函数不会直接使用故不列出来):
namespace TriangleTests
{
bool XM_CALLCONV Intersects(FXMVECTOR Origin, FXMVECTOR Direction, FXMVECTOR V0, GXMVECTOR V1,
HXMVECTOR V2, float& Dist );
// 射线与三角形的相交检测
bool XM_CALLCONV Intersects(FXMVECTOR A0, FXMVECTOR A1, FXMVECTOR A2, GXMVECTOR B0, HXMVECTOR B1,
HXMVECTOR B2 );
// 三角形与三角形的相交检测
PlaneIntersectionType XM_CALLCONV Intersects(FXMVECTOR V0, FXMVECTOR V1, FXMVECTOR V2,
GXMVECTOR Plane );
// 平面与三角形的相交检测
// 忽略...
};
其中Dist返回的是射线起点到交点的距离,若没有检测到相交,Dist的值为0.0f。
1.3.2 射线(三角形)与包围盒的相交检测
四种包围盒都包含了下面的两个方法:
bool XM_CALLCONV Intersects(FXMVECTOR Origin, FXMVECTOR Direction, float& Dist) const;
// 射线与包围盒的相交检测
bool XM_CALLCONV Intersects(FXMVECTOR V0, FXMVECTOR V1, FXMVECTOR V2) const;
// 三角形与包围盒的相交检测
二、obj读取与二进制缓存
一个模型通常是由三个部分组成:网格、纹理、材质。在一开始的时候,我们是通过Geometry类来生成简单几何体的网格。但现在我们需要寻找合适的方式去表述一个复杂的网格,而且包含网格的文件类型多种多样,对应的描述方式也存在着差异。这一章我们主要研究obj格式文件的读取。
2.1 .obj文件结构简述
.obj文件内部的每一行具体含义取决于开头以空格、制表符分隔的关键字是什么。这里只根据当前项目需要的部分来描述关键字:
顶点数据:
元素:
组合:
材质:
.mtl文件结构简述
.mtl文件内部描述方式和.obj文件一样,但里面使用的关键字有所不同
材质描述:
eg. 简单示例
现在要通过.obj文件来描述一个平面正方形草丛。ground.obj文件如下:
mtllib ground.mtl
v -10.0 -1.0 -10.0
v -10.0 -1.0 10.0
v 10.0 -1.0 10.0
v 10.0 -1.0 -10.0
vn 0.0 0.0 -1.0
vt 0.0 0.0
vt 0.0 5.0
vt 5.0 5.0
vt 5.0 0.0
g Square
usemtl TestMat
f 1/1/1 2/2/1 3/3/1
f 3/3/1 4/4/1 1/1/1
# 2 faces
其中根据v的先后出现顺序,对应的索引为1到4。若索引值为3,则对应第3行v对应的顶点
注意: 索引的初始值在.obj中为1,而不是0!
而诸如1/1/1这样的三索引对应的含义为:顶点坐标索引/纹理坐标索引/法向量索引
若写成1//1,则表明不使用纹理坐标,但现在在我们的项目中不允许缺少上面任何一种索引
这样在一个f里面出现顶点坐标索引/纹理坐标索引/法向量索引的次数说明了该面的顶点数目,目前我们也仅考虑支持三角形面
一个模型最少需要包含一个组或一个对象
注意:
(1).obj纹理坐标是基于笛卡尔坐标系的,即(0.3, 0.7)对应的是实际的纹理坐标(0.3, 0.3),即需要做(x, 1.0 - y)的变换
(2).obj的顶点坐标和法向量坐标是基于右手坐标系的,并且在右手坐标系下顶点实际上是按逆时针排布的,需要进行从右手坐标系到左手坐标系的变换,就得取z的负值,并将顶点顺序反过来读(理论上z值不变化且顶点顺序正常来读也是可以正常显示的,只不过读出来的模型变成了xOy屏幕的镜面反射效果)
而.mtl文件的描述如下
newmtl TestMat
d 1.0000
Ns 10.0000
Ka 0.8000 0.8000 0.8000
Kd 0.3000 0.3000 0.3000
Ks 0.0000 0.0000 0.0000
map_Ka grass.dds
map_Kd grass.dds
漫反射和环境光反射都将使用同一种纹理。
2.2 使用自定义二进制数据格式提升读取效率
使用文本类型的.obj格式文件进行读取的话必然要面临一个比较严重的问题:模型网格的面数较多会导致文本量极大,直接读取.obj的效率会非常低下。 通常推荐在第一次读取.obj文件导入到程序后,再将读取好的顶点等信息以二进制文件的形式合理安排内容布局并保存,然后下次运行的时候读取该二进制文件来获取模型信息,可以大幅度加快读取速度,并且节省了一定的内存空间。
现在来说明下当前项目下自定义二进制格式.mbo的字节布局:
// [Part数目] 4字节
// [AABB盒顶点vMax] 12字节
// [AABB盒顶点vMin] 12字节
// [Part
// [漫射光材质文件名]520字节
// [材质]64字节
// [顶点数]4字节
// [索引数]4字节
// [顶点]32*顶点数 字节
// [索引]2(或4)*索引数 字节,取决于顶点数是否不超过65535
// ]
// ...
这里将.obj中的一个组或一个对象定义为.mbo格式中的一个模型部分,然后顶点使用的是VertexPosNormalTex类型,大小为32字节。索引使用WORD或DWORD类型,若当前Part不同的顶点数超过65535,则必须使用DWORD类型来存储索引。
环境光/漫射光材质文件名使用的是wchar_t[MAX_PATH]的数组,大小为2*260字节。
但要注意一开始从.obj导出的顶点数组是没有经过处理(包含重复顶点),需要通过一定的操作分离出顶点数组和索引数组才能传递给.mbo格式。
2.3 ObjReader–读取.obj/.mbo格式模型
ObjReader.h中包含了ObjReader类和MtlReader类:
#ifndef OBJREADER_H
#define OBJREADER_H
#include <iostream>
#include <vector>
#include <string>
#include <fstream>
#include <unordered_map>
#include <map>
#include <algorithm>
#include <locale>
#include <filesystem>
#include "Vertex.h"
#include "LightHelper.h"
class MtlReader;
class ObjReader
{
public:
struct ObjPart
{
Material material; // 材质
std::vector<VertexPosNormalTex> vertices; // 顶点集合
std::vector<WORD> indices16; // 顶点数不超过65535时使用
std::vector<DWORD> indices32; // 顶点数超过65535时使用
std::wstring texStrDiffuse; // 漫射光纹理文件名,需为相对路径,在mbo必须占260字节
};
// 指定.mbo文件的情况下,若.mbo文件存在,优先读取该文件
// 否则会读取.obj文件
// 若.obj文件被读取,且提供了.mbo文件的路径,则会根据已经读取的数据创建.mbo文件
bool Read(const wchar_t* mboFileName, const wchar_t* objFileName);
bool ReadObj(const wchar_t* objFileName);
bool ReadMbo(const wchar_t* mboFileName);
bool WriteMbo(const wchar_t* mboFileName);
public:
std::vector<ObjPart> objParts;
DirectX::XMFLOAT3 vMin, vMax; // AABB盒双顶点
private:
void AddVertex(const VertexPosNormalTex& vertex, DWORD vpi, DWORD vti, DWORD vni);
// 缓存有v/vt/vn字符串信息
std::unordered_map<std::wstring, DWORD> vertexCache;
};
class MtlReader
{
public:
bool ReadMtl(const wchar_t* mtlFileName);
public:
std::map<std::wstring, Material> materials;
std::map<std::wstring, std::wstring> mapKaStrs;
std::map<std::wstring, std::wstring> mapKdStrs;
};
#endif
ObjReader.cpp定义如下:
#include "ObjReader.h"
using namespace DirectX;
bool ObjReader::Read(const wchar_t * mboFileName, const wchar_t * objFileName)
{
if (mboFileName && ReadMbo(mboFileName))
{
return true;
}
else if (objFileName)
{
bool status = ReadObj(objFileName);
if (status && mboFileName)
return WriteMbo(mboFileName);
return status;
}
return false;
}
bool ObjReader::ReadObj(const wchar_t * objFileName)
{
objParts.clear();
vertexCache.clear();
MtlReader mtlReader;
std::vector<XMFLOAT3> positions;
std::vector<XMFLOAT3> normals;
std::vector<XMFLOAT2> texCoords;
XMVECTOR vecMin = g_XMInfinity, vecMax = g_XMNegInfinity;
std::wifstream wfin(objFileName);
if (!wfin.is_open())
return false;
// 切换中文
std::locale china("chs");
china = wfin.imbue(china);
for (;;)
{
std::wstring wstr;
if (!(wfin >> wstr))
break;
if (wstr[0] == '#')
{
//
// 忽略注释所在行
//
while (!wfin.eof() && wfin.get() != '\n')
continue;
}
else if (wstr == L"o" || wstr == L"g")
{
//
// 对象名(组名)
//
objParts.emplace_back(ObjPart());
// 提供默认材质
objParts.back().material.ambient = XMFLOAT4(0.2f, 0.2f, 0.2f, 1.0f);
objParts.back().material.diffuse = XMFLOAT4(0.8f, 0.8f, 0.8f, 1.0f);
objParts.back().material.specular = XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f);
vertexCache.clear();
}
else if (wstr == L"v")
{
//
// 顶点位置
//
// 注意obj使用的是右手坐标系,而不是左手坐标系
// 需要将z值反转
XMFLOAT3 pos;
wfin >> pos.x >> pos.y >> pos.z;
pos.z = -pos.z;
positions.push_back(pos);
XMVECTOR vecPos = XMLoadFloat3(&pos);
vecMax = XMVectorMax(vecMax, vecPos);
vecMin = XMVectorMin(vecMin, vecPos);
}
else if (wstr == L"vt")
{
//
// 顶点纹理坐标
//
// 注意obj使用的是笛卡尔坐标系,而不是纹理坐标系
float u, v;
wfin >> u >> v;
v = 1.0f - v;
texCoords.emplace_back(XMFLOAT2(u, v));
}
else if (wstr == L"vn")
{
//
// 顶点法向量
//
// 注意obj使用的是右手坐标系,而不是左手坐标系
// 需要将z值反转
float x, y, z;
wfin >> x >> y >> z;
z = -z;
normals.emplace_back(XMFLOAT3(x, y, z));
}
else if (wstr == L"mtllib")
{
//
// 指定某一文件的材质
//
std::wstring mtlFile;
wfin >> mtlFile;
// 去掉前后空格
size_t beg = 0, ed = mtlFile.size();
while (iswspace(mtlFile[beg]))
beg++;
while (ed > beg && iswspace(mtlFile[ed - 1]))
ed--;
mtlFile = mtlFile.substr(beg, ed - beg);
// 获取路径
std::wstring dir = objFileName;
size_t pos;
if ((pos = dir.find_last_of('/')) == std::wstring::npos &&
(pos = dir.find_last_of('\\')) == std::wstring::npos)
{
pos = 0;
}
else
{
pos += 1;
}
mtlReader.ReadMtl((dir.erase(pos) + mtlFile).c_str());
}
else if (wstr == L"usemtl")
{
//
// 使用之前指定文件内部的某一材质
//
std::wstring mtlName;
std::getline(wfin, mtlName);
// 去掉前后空格
size_t beg = 0, ed = mtlName.size();
while (iswspace(mtlName[beg]))
beg++;
while (ed > beg && iswspace(mtlName[ed - 1]))
ed--;
mtlName = mtlName.substr(beg, ed - beg);
objParts.back().material = mtlReader.materials[mtlName];
objParts.back().texStrDiffuse = mtlReader.mapKdStrs[mtlName];
}
else if (wstr == L"f")
{
//
// 几何面
//
VertexPosNormalTex vertex;
DWORD vpi[3], vni[3], vti[3];
wchar_t ignore;
// 顶点位置索引/纹理坐标索引/法向量索引
// 原来右手坐标系下顶点顺序是逆时针排布
// 现在需要转变为左手坐标系就需要将三角形顶点反过来输入
for (int i = 2; i >= 0; --i)
{
wfin >> vpi[i] >> ignore >> vti[i] >> ignore >> vni[i];
}
for (int i = 0; i < 3; ++i)
{
vertex.pos = positions[vpi[i] - 1];
vertex.normal = normals[vni[i] - 1];
vertex.tex = texCoords[vti[i] - 1];
AddVertex(vertex, vpi[i], vti[i], vni[i]);
}
while (iswblank(wfin.peek()))
wfin.get();
// 几何面顶点数可能超过了3,不支持该格式
if (wfin.peek() != '\n')
return false;
}
}
// 顶点数不超过WORD的最大值的话就使用16位WORD存储
for (auto& part : objParts)
{
if (part.vertices.size() < 65535)
{
for (auto& i : part.indices32)
{
part.indices16.push_back((WORD)i);
}
part.indices32.clear();
}
}
XMStoreFloat3(&vMax, vecMax);
XMStoreFloat3(&vMin, vecMin);
return true;
}
bool ObjReader::ReadMbo(const wchar_t * mboFileName)
{
// [Part数目] 4字节
// [AABB盒顶点vMax] 12字节
// [AABB盒顶点vMin] 12字节
// [Part
// [漫射光材质文件名]520字节
// [材质]64字节
// [顶点数]4字节
// [索引数]4字节
// [顶点]32*顶点数 字节
// [索引]2(或4)*索引数 字节,取决于顶点数是否不超过65535
// ]
// ...
std::ifstream fin(mboFileName, std::ios::in | std::ios::binary);
if (!fin.is_open())
return false;
UINT parts = (UINT)objParts.size();
// [Part数目] 4字节
fin.read(reinterpret_cast<char*>(&parts), sizeof(UINT));
objParts.resize(parts);
// [AABB盒顶点vMax] 12字节
fin.read(reinterpret_cast<char*>(&vMax), sizeof(XMFLOAT3));
// [AABB盒顶点vMin] 12字节
fin.read(reinterpret_cast<char*>(&vMin), sizeof(XMFLOAT3));
for (UINT i = 0; i < parts; ++i)
{
wchar_t filePath[MAX_PATH];
// [漫射光材质文件名]520字节
fin.read(reinterpret_cast<char*>(filePath), MAX_PATH * sizeof(wchar_t));
objParts[i].texStrDiffuse = filePath;
// [材质]64字节
fin.read(reinterpret_cast<char*>(&objParts[i].material), sizeof(Material));
UINT vertexCount, indexCount;
// [顶点数]4字节
fin.read(reinterpret_cast<char*>(&vertexCount), sizeof(UINT));
// [索引数]4字节
fin.read(reinterpret_cast<char*>(&indexCount), sizeof(UINT));
// [顶点]32*顶点数 字节
objParts[i].vertices.resize(vertexCount);
fin.read(reinterpret_cast<char*>(objParts[i].vertices.data()), vertexCount * sizeof(VertexPosNormalTex));
if (vertexCount > 65535)
{
// [索引]4*索引数 字节
objParts[i].indices32.resize(indexCount);
fin.read(reinterpret_cast<char*>(objParts[i].indices32.data()), indexCount * sizeof(DWORD));
}
else
{
// [索引]2*索引数 字节
objParts[i].indices16.resize(indexCount);
fin.read(reinterpret_cast<char*>(objParts[i].indices16.data()), indexCount * sizeof(WORD));
}
}
fin.close();
return true;
}
bool ObjReader::WriteMbo(const wchar_t * mboFileName)
{
// [Part数目] 4字节
// [AABB盒顶点vMax] 12字节
// [AABB盒顶点vMin] 12字节
// [Part
// [环境光材质文件名]520字节
// [漫射光材质文件名]520字节
// [材质]64字节
// [顶点数]4字节
// [索引数]4字节
// [顶点]32*顶点数 字节
// [索引]2(或4)*索引数 字节,取决于顶点数是否不超过65535
// ]
// ...
std::ofstream fout(mboFileName, std::ios::out | std::ios::binary);
UINT parts = (UINT)objParts.size();
// [Part数目] 4字节
fout.write(reinterpret_cast<const char*>(&parts), sizeof(UINT));
// [AABB盒顶点vMax] 12字节
fout.write(reinterpret_cast<const char*>(&vMax), sizeof(XMFLOAT3));
// [AABB盒顶点vMin] 12字节
fout.write(reinterpret_cast<const char*>(&vMin), sizeof(XMFLOAT3));
// [Part
for (UINT i = 0; i < parts; ++i)
{
wchar_t filePath[MAX_PATH];
wcscpy_s(filePath, objParts[i].texStrDiffuse.c_str());
// [漫射光材质文件名]520字节
fout.write(reinterpret_cast<const char*>(filePath), MAX_PATH * sizeof(wchar_t));
// [材质]64字节
fout.write(reinterpret_cast<const char*>(&objParts[i].material), sizeof(Material));
UINT vertexCount = (UINT)objParts[i].vertices.size();
// [顶点数]4字节
fout.write(reinterpret_cast<const char*>(&vertexCount), sizeof(UINT));
UINT indexCount;
if (vertexCount > 65535)
{
indexCount = (UINT)objParts[i].indices32.size();
// [索引数]4字节
fout.write(reinterpret_cast<const char*>(&indexCount), sizeof(UINT));
// [顶点]32*顶点数 字节
fout.write(reinterpret_cast<const char*>(objParts[i].vertices.data()), vertexCount * sizeof(VertexPosNormalTex));
// [索引]4*索引数 字节
fout.write(reinterpret_cast<const char*>(objParts[i].indices32.data()), indexCount * sizeof(DWORD));
}
else
{
indexCount = (UINT)objParts[i].indices16.size();
// [索引数]4字节
fout.write(reinterpret_cast<const char*>(&indexCount), sizeof(UINT));
// [顶点]32*顶点数 字节
fout.write(reinterpret_cast<const char*>(objParts[i].vertices.data()), vertexCount * sizeof(VertexPosNormalTex));
// [索引]2*索引数 字节
fout.write(reinterpret_cast<const char*>(objParts[i].indices16.data()), indexCount * sizeof(WORD));
}
}
// ]
fout.close();
return true;
}
void ObjReader::AddVertex(const VertexPosNormalTex& vertex, DWORD vpi, DWORD vti, DWORD vni)
{
std::wstring idxStr = std::to_wstring(vpi) + L"/" + std::to_wstring(vti) + L"/" + std::to_wstring(vni);
// 寻找是否有重复顶点
auto it = vertexCache.find(idxStr);
if (it != vertexCache.end())
{
objParts.back().indices32.push_back(it->second);
}
else
{
objParts.back().vertices.push_back(vertex);
DWORD pos = (DWORD)objParts.back().vertices.size() - 1;
vertexCache[idxStr] = pos;
objParts.back().indices32.push_back(pos);
}
}
bool MtlReader::ReadMtl(const wchar_t * mtlFileName)
{
materials.clear();
mapKdStrs.clear();
std::wifstream wfin(mtlFileName);
std::locale china("chs");
china = wfin.imbue(china);
if (!wfin.is_open())
return false;
std::wstring wstr;
std::wstring currMtl;
for (;;)
{
if (!(wfin >> wstr))
break;
if (wstr[0] == '#')
{
//
// 忽略注释所在行
//
while (wfin.get() != '\n')
continue;
}
else if (wstr == L"newmtl")
{
//
// 新材质
//
std::getline(wfin, currMtl);
// 去掉前后空格
size_t beg = 0, ed = currMtl.size();
while (iswspace(currMtl[beg]))
beg++;
while (ed > beg && iswspace(currMtl[ed - 1]))
ed--;
currMtl = currMtl.substr(beg, ed - beg);
}
else if (wstr == L"Ka")
{
//
// 环境光反射颜色
//
XMFLOAT4& ambient = materials[currMtl].ambient;
wfin >> ambient.x >> ambient.y >> ambient.z;
if (ambient.w == 0.0f)
ambient.w = 1.0f;
}
else if (wstr == L"Kd")
{
//
// 漫射光反射颜色
//
XMFLOAT4& diffuse = materials[currMtl].diffuse;
wfin >> diffuse.x >> diffuse.y >> diffuse.z;
if (diffuse.w == 0.0f)
diffuse.w = 1.0f;
}
else if (wstr == L"Ks")
{
//
// 镜面光反射颜色
//
XMFLOAT4& specular = materials[currMtl].specular;
wfin >> specular.x >> specular.y >> specular.z;
}
else if (wstr == L"Ns")
{
//
// 镜面系数
//
wfin >> materials[currMtl].specular.w;
}
else if (wstr == L"d" || wstr == L"Tr")
{
//
// d为不透明度 Tr为透明度
//
float alpha;
wfin >> alpha;
if (wstr == L"Tr")
alpha = 1.0f - alpha;
materials[currMtl].ambient.w = alpha;
materials[currMtl].diffuse.w = alpha;
}
else if (wstr == L"map_Kd")
{
//
// map_Kd为漫反射使用的纹理
//
std::wstring fileName;
std::getline(wfin, fileName);
// 去掉前后空格
size_t beg = 0, ed = fileName.size();
while (iswspace(fileName[beg]))
beg++;
while (ed > beg && iswspace(fileName[ed - 1]))
ed--;
fileName = fileName.substr(beg, ed - beg);
// 追加路径
std::wstring dir = mtlFileName;
size_t pos;
if ((pos = dir.find_last_of('/')) == std::wstring::npos &&
(pos = dir.find_last_of('\\')) == std::wstring::npos)
pos = 0;
else
pos += 1;
mapKdStrs[currMtl] = dir.erase(pos) + fileName;
}
}
return true;
}
其中AddVertex方法用于去除重复的顶点,并构建索引数组。
在改为读取.mbo文件后,原本读取.obj需要耗时3s,现在可以降到2ms以内,大幅提升了读取效率。其关键点就在于要构造连续性的二进制数据以减少读取次数,并剔除掉原本读取.obj时的各种词法分析部分(在该部分也浪费了大量的时间)。
由于ObjReader类对.obj格式的文件要求比较严格,如果出现不能正确加载的现象,请检查是否出现下面这些情况,否则需要自行修改.obj/.mtl文件,或者给ObjReader实现更多的功能:
- 使用了/将下一行的内容连接在一起表示一行
- 存在索引为负数
- 使用了类似1//2这样的顶点(即不包含纹理坐标的顶点)
- 使用了绝对路径的文件引用
- 相对路径使用了.和…两种路径格式
- 若.mtl材质文件不存在,则内部会使用默认材质值
- 若.mtl内部没有指定纹理文件引用,需要另外自行加载纹理
- f的顶点数不为3(网格只能以三角形构造,即一个f的顶点数只能为3)