C#解析DirectX .x文件:从二进制格式到GPU渲染的完整实践
1. 项目概述:为什么要在C#里折腾.x文件?
如果你是一个用C#做图形应用或者游戏开发的,尤其是早些年接触过DirectX 9那套东西的,那么“.x文件”这个名词对你来说肯定不陌生。它就像是那个时代3D模型的一种“通用货币”,从3ds Max、Maya等建模软件里导出来,然后被游戏引擎或者自研的图形程序读取、渲染。现在虽然主流已经是FBX、glTF了,但理解.x文件的处理流程,依然是深入理解3D图形数据流、掌握底层图形API交互的绝佳途径。这不仅仅是“读取一个文件”那么简单,它涉及到二进制文件格式解析、COM组件交互、顶点与索引缓冲区的构建、以及如何将数据最终喂给GPU这一整套流水线。
我之所以花时间重新梳理这个主题,是因为最近在维护一个遗留的工业仿真项目,其核心资产库仍然是大量的.x文件。网上能找到的资料要么过于陈旧,语焉不详,要么就是直接丢一段C++代码让人摸不着头脑。对于C#开发者来说,如何清晰、安全、高效地在托管环境中操作这些非托管时代的产物,是一个实实在在的痛点。本文将基于DirectX 9.0c的托管库(Microsoft.DirectX)以及更现代的SharpDX库两种路径,彻底拆解.x文件的读取、解析、数据处理到初步渲染的全过程。你会发现,即便面对一个“过时”的格式,其背后蕴含的数据组织思想、性能优化技巧,在今天依然有很高的参考价值。
2. .x文件格式深度解析:不只是模型数据
在动手写代码之前,我们必须像外科医生一样,彻底了解.x文件的“解剖结构”。.x文件本质上是一种基于模板的、可扩展的二进制(也可以是文本)文件格式,它采用了一种类似RIFF(资源交换文件格式)的分块(Chunk)结构。每个数据块都有一个头(Header),包含类型标识符(GUID)和数据大小,然后是其具体的数据内容。这种结构使得它不仅能存储静态网格,还能存储动画、蒙皮信息、材质、纹理路径甚至用户自定义数据。
2.1 核心模板与数据块解析
一个最简单的、只包含网格的.x文件,其逻辑结构可以看作一棵树。根节点下通常包含一个Frame(帧)层级结构,而Mesh(网格)数据就挂载在某个Frame下面。每个Mesh又包含了MeshMaterialList(材质列表)、MeshNormals(法线)、MeshTextureCoords(纹理坐标)等数据引用。
让我们聚焦最核心的Mesh数据块。它内部主要包含两部分:
- 顶点数据(Vertices):一个顶点数组。每个顶点最初通常只包含位置坐标(x, y, z)。注意,这里的顶点数据是“纯净”的几何信息,不包含法线、颜色、纹理坐标等。这些属性通过其他独立的数据块关联。
- 索引数据(Face Indices):一个面(三角形)数组。每个面由三个整数构成,这三个整数是顶点数组的索引,共同定义了一个三角形。这就是经典的“索引化三角网格”存储方式。
这里有一个至关重要的细节:.x文件中的索引数据,其缠绕顺序(Winding Order)默认是顺时针的。在Direct3D中,默认的正面剔除(Cull)模式是逆时针(CCW)为正面。这意味着,如果你直接使用.x文件中的索引数据而不做处理,渲染出来的三角形可能是背面朝外的,导致模型看起来是“透明”或“破碎”的。这是一个经典的坑点。
注意:许多建模软件在导出时可以设置缠绕顺序。但作为读取器,我们不能依赖导出设置。最稳健的做法是在加载索引数据后,主动检查或统一转换缠绕顺序,例如将每个三角形的索引顺序从
(0, 1, 2)交换为(0, 2, 1),从而将其从顺时针改为逆时针。
2.2 材质与纹理信息的关联
材质信息存储在MeshMaterialList中,它首先指明该网格使用了多少种材质(nMaterials)以及每个面对应哪种材质(一个面索引到材质索引的映射数组)。然后,它会引用nMaterials个Material数据块。
每个Material数据块包含:
faceColor:环境光颜色(RGBA)。power:镜面高光指数。specularColor:镜面反射颜色(RGB)。emissiveColor:自发光颜色(RGB)。- 一个可选的纹理文件名引用(通常是
TextureFilename模板)。
这里的关键在于理解“材质-面”的映射关系。一个网格可以使用多种材质(比如一个角色模型,脸、衣服、武器是不同的材质)。MeshMaterialList里的面材质索引数组长度等于网格的面数,每个元素的值告诉渲染器,在绘制对应面时应该使用材质列表中的第几个材质。
3. 工具选型:Managed DirectX 还是 SharpDX?
在C#中处理DirectX,历史上主要有两条路:官方的Managed DirectX 1.1(随DirectX 9.0c SDK发布)和社区驱动的SharpDX。选择哪一个,直接决定了你代码的写法、项目的依赖和未来的可维护性。
3.1 Managed DirectX 1.1:经典但已停滞
这是微软官方提供的.NET封装库,命名空间是Microsoft.DirectX及其子集(如Microsoft.DirectX.Direct3D)。它对.x文件读取有原生支持,通过Mesh.FromFile方法可以一行代码加载模型,非常方便。
优点:
- 官方出品,与DirectX 9.0c SDK集成度最高。
- 封装程度高,对于简单的加载和渲染,代码非常简洁。
- 直接包含
Microsoft.DirectX.Direct3D.X命名空间,用于.x文件的扩展操作。
致命缺点:
- 已停止开发。最后一个版本对应DirectX 9.0c(2004年)。在64位系统、高DPI显示器、以及现代.NET版本(.NET Core/.NET 5+)上,可能存在兼容性问题。
- 强依赖COM。尽管是托管封装,底层大量使用COM互操作,资源管理(如
Dispose)不当极易导致内存泄漏。 - 难以进行底层控制。它把很多细节(如数据缓冲区、着色器)都隐藏了,当你想对加载的模型数据进行自定义处理(比如提取顶点数据做碰撞检测)时,会感到束手束脚。
实操心得:除非你维护的是一个非常古老、且不允许修改技术栈的.NET Framework项目,否则我不推荐在新项目中使用Managed DirectX。它的便利性是以牺牲灵活性和未来为代价的。
3.2 SharpDX:现代且强大的选择
SharpDX是一个完整的、基于C#的DirectX封装库,它通过精细的P/Invoke生成了几乎一对一的DirectX API映射。这意味着它支持从DirectX 9到DirectX 12的所有版本,并且持续更新,完美支持.NET Standard/.NET Core/.NET 5+。
优点:
- 活跃开源,持续维护,兼容现代操作系统和.NET平台。
- API与原生DirectX高度一致,有C++ DirectX经验的开发者几乎可以无缝转换。同时,它提供了更符合C#习惯的托管资源管理。
- 控制力极强。你可以访问到每一个顶点缓冲区、索引缓冲区、着色器常量,进行任何你需要的操作。
- 有强大的工具链,如SharpDX Toolkit,提供了内容管道、游戏框架等高级功能。
缺点:
- 没有内置的.x文件加载器。SharpDX本身不提供像
Mesh.FromFile这样的高级辅助函数。你需要自己解析.x文件,或者使用第三方库(如AssimpNet),或者使用SharpDX Toolkit中的内容处理器(在构建时转换.x文件为自定义格式)。
我的选择与建议:对于学习和深度掌控,我强烈推荐SharpDX路径。它迫使你去理解.x文件的二进制结构,亲手构建顶点/索引缓冲区,这个过程虽然繁琐,但收益巨大。它能让你真正明白3D数据从文件到屏幕的完整旅程。本文后续的深度实操部分,也将以SharpDX路径为主,辅以Managed DirectX的快速示例作为对比。
4. 基于SharpDX的.x文件读取与处理全流程
假设我们选择SharpDX,并且决定自己动手解析.x文件。我们的目标是将.x文件中的几何数据(顶点、索引)和材质信息提取出来,并创建成SharpDX中可渲染的VertexBuffer和IndexBuffer。
4.1 第一步:获取并解析.x文件数据
SharpDX没有内置解析器,我们需要一个第三方库来读取.x文件。一个非常优秀的选择是AssimpNet(Open Asset Import Library的.NET封装)。Assimp支持海量的3D格式,.x文件自然不在话下。
首先,通过NuGet安装AssimpNet:
Install-Package AssimpNet然后,使用Assimp加载.x文件:
using Assimp; // 创建Assimp上下文 using (var importer = new AssimpContext()) { // 设置后处理标志,例如三角化、生成法线等 PostProcessSteps steps = PostProcessSteps.Triangulate | PostProcessSteps.GenerateNormals | PostProcessSteps.FlipWindingOrder; // 注意FlipWindingOrder! // 导入文件 Scene scene = importer.ImportFile("your_model.x", steps); // 此时,scene对象包含了完整的场景图、网格、材质等信息 ProcessScene(scene); }关键点在于PostProcessSteps.FlipWindingOrder。正如前文所述,.x文件默认是顺时针缠绕。Assimp的这个后处理步骤可以帮我们自动翻转索引顺序,确保与Direct3D的默认设置匹配。这是一个非常重要的“避坑”设置。
4.2 第二步:从Assimp场景中提取网格数据
Scene对象包含一个Meshes数组。每个Mesh对象包含了我们需要的所有数据。
void ProcessScene(Scene scene) { foreach (var mesh in scene.Meshes) { // 1. 提取顶点数据 List<YourVertexStruct> vertices = new List<YourVertexStruct>(); for (int i = 0; i < mesh.VertexCount; i++) { var position = mesh.Vertices[i]; // Vector3D var normal = mesh.Normals[i]; // Vector3D (因为我们在导入时要求了GenerateNormals) var texCoord = mesh.HasTextureCoords(0) ? mesh.TextureCoordinateChannels[0][i] : new Vector3D(0,0,0); vertices.Add(new YourVertexStruct( new Vector3(position.X, position.Y, position.Z), new Vector3(normal.X, normal.Y, normal.Z), new Vector2(texCoord.X, texCoord.Y) // 通常取UV的XY分量 )); } // 2. 提取索引数据 List<int> indices = new List<int>(); foreach (var face in mesh.Faces) { // Assimp已经为我们三角化了,所以每个Face的索引数一定是3 indices.AddRange(face.Indices); // face.Indices 是 List<int> } // 3. 提取材质信息 Material assimpMaterial = scene.Materials[mesh.MaterialIndex]; // assimpMaterial包含颜色、纹理路径等信息 // 现在,vertices和indices列表就包含了可以直接用于创建缓冲区的数据 UploadToGPU(vertices, indices); } }这里定义了一个YourVertexStruct,你需要根据你的着色器输入来定义它的结构。例如,一个包含位置、法线和纹理坐标的顶点结构:
[StructLayout(LayoutKind.Sequential)] public struct VertexPositionNormalTexture { public Vector3 Position; public Vector3 Normal; public Vector2 TextureCoordinate; public VertexPositionNormalTexture(Vector3 pos, Vector3 norm, Vector2 tex) { Position = pos; Normal = norm; TextureCoordinate = tex; } // 声明输入元素的布局,用于管线装配阶段 public static readonly InputElement[] InputElements = new[] { new InputElement("POSITION", 0, Format.R32G32B32_Float, 0, 0), new InputElement("NORMAL", 0, Format.R32G32B32_Float, 12, 0), // 偏移12字节(前一个Vector3的大小) new InputElement("TEXCOORD", 0, Format.R32G32_Float, 24, 0) // 偏移24字节 }; public static readonly int SizeInBytes = Utilities.SizeOf<VertexPositionNormalTexture>(); }4.3 第三步:创建GPU缓冲区并上传数据
有了顶点和索引列表,我们就可以创建Direct3D的缓冲区了。
using SharpDX.Direct3D11; using SharpDX.DXGI; Device device; // 你的Direct3D 11设备 void UploadToGPU(List<VertexPositionNormalTexture> vertices, List<int> indices) { // 1. 创建顶点缓冲区 var vertexBufferDesc = new BufferDescription { Usage = ResourceUsage.Default, // 常用用法,GPU读写 SizeInBytes = vertices.Count * VertexPositionNormalTexture.SizeInBytes, BindFlags = BindFlags.VertexBuffer, CpuAccessFlags = CpuAccessFlags.None, // CPU不需要访问 OptionFlags = ResourceOptionFlags.None, StructureByteStride = 0 }; using (var dataStream = new DataStream(vertexBufferDesc.SizeInBytes, true, true)) { dataStream.WriteRange(vertices.ToArray()); dataStream.Position = 0; var vertexBuffer = new Buffer(device, dataStream, vertexBufferDesc); } // 2. 创建索引缓冲区 var indexBufferDesc = new BufferDescription { Usage = ResourceUsage.Default, SizeInBytes = indices.Count * sizeof(int), // 假设使用32位索引 BindFlags = BindFlags.IndexBuffer, CpuAccessFlags = CpuAccessFlags.None, OptionFlags = ResourceOptionFlags.None, StructureByteStride = 0 }; using (var dataStream = new DataStream(indexBufferDesc.SizeInBytes, true, true)) { dataStream.WriteRange(indices.ToArray()); dataStream.Position = 0; var indexBuffer = new Buffer(device, dataStream, indexBufferDesc); } // 3. 在渲染循环中设置缓冲区 deviceContext.InputAssembler.SetVertexBuffers(0, new VertexBufferBinding(vertexBuffer, VertexPositionNormalTexture.SizeInBytes, 0)); deviceContext.InputAssembler.SetIndexBuffer(indexBuffer, Format.R32_UInt, 0); // 格式与sizeof(int)对应 deviceContext.InputAssembler.PrimitiveTopology = PrimitiveTopology.TriangleList; // 4. 绘制 deviceContext.DrawIndexed(indices.Count, 0, 0); }4.4 第四步:处理材质与纹理
材质信息我们从assimpMaterial中获取。对于简单的漫反射纹理:
string texturePath = null; if (assimpMaterial.HasTextureDiffuse) { TextureSlot texSlot = assimpMaterial.TextureDiffuse; texturePath = texSlot.FilePath; // 注意:Assimp返回的路径可能是相对的,也可能是绝对的。需要根据你的资源目录进行处理。 // 通常需要将路径与你的内容根目录拼接。 string fullPath = Path.Combine(ContentRootDirectory, texturePath); // 使用SharpDX.Toolkit.Graphics或Direct3D11创建纹理着色器资源视图(ShaderResourceView) // Texture2D.FromFile(device, fullPath); }对于更复杂的材质(镜面反射、法线贴图等),可以检查assimpMaterial.TextureSpecular,assimpMaterial.TextureNormal等属性。
5. 基于Managed DirectX的快速实现(对比参考)
作为对比,我们看看在Managed DirectX 1.1中如何实现。首先,你需要添加对Microsoft.DirectX和Microsoft.DirectX.Direct3D(以及可能的Microsoft.DirectX.Direct3DX)程序集的引用。这些通常需要手动从旧的DirectX SDK安装目录中获取。
using Microsoft.DirectX; using Microsoft.DirectX.Direct3D; Device device; // 你的Direct3D设备 public Mesh LoadXFileWithManagedDX(string filePath) { Mesh mesh = null; ExtendedMaterial[] materials = null; // 一行代码加载网格和材质 mesh = Mesh.FromFile(filePath, MeshFlags.Managed, device, out GraphicsStream adj, out materials); // 如果存在材质,加载纹理 if (materials != null && materials.Length > 0) { // 为Mesh设置材质和纹理(这里简化处理,只取第一个材质) device.Material = materials[0].Material3D; if (materials[0].TextureFilename != null && materials[0].TextureFilename.Length > 0) { // 加载纹理 Texture texture = TextureLoader.FromFile(device, materials[0].TextureFilename); device.SetTexture(0, texture); } } // 注意:需要处理邻接信息(adj)用于阴影体或某些优化,此处省略 return mesh; } // 在渲染时 mesh.DrawSubset(0); // 绘制使用第一个材质的子集代码确实极其简洁。Mesh.FromFile方法帮你完成了所有繁重的工作:解析文件、创建缓冲区、甚至优化网格(如顶点缓存重排序)。ExtendedMaterial数组包含了所有的材质和纹理文件名。
然而,这里隐藏着巨大的陷阱:
- 资源泄漏:
Mesh、Texture都是COM对象的托管封装,必须显式调用.Dispose()。在复杂的场景中,管理这些对象的生命周期非常容易出错。 - 黑盒操作:你无法轻易地访问或修改网格的原始顶点和索引数据。如果你想实现基于GPU的蒙皮动画、或者将几何数据用于物理碰撞,你需要通过
Mesh.VertexBuffer和Mesh.IndexBuffer属性获取底层数据,并进行复杂的锁定(Lock)和复制操作,代码会变得非常冗长且容易出错。 - 灵活度差:渲染管线被固定了。你想使用自定义的顶点格式或着色器?Managed DirectX 1.1对此的支持非常笨拙。
6. 性能优化与高级处理技巧
无论是使用Assimp+SharpDX还是Managed DX,当处理复杂的.x文件(如包含多个网格、骨骼动画)时,性能和数据组织就变得至关重要。
6.1 顶点缓冲区与索引缓冲区优化
- 顶点缓存优化:图形硬件(GPU)在处理三角形时,会对最近处理过的顶点进行缓存(Post-Transform Vertex Cache)。如果三角形的索引顺序能够最大化地复用缓存中的顶点,就能显著减少顶点着色器的调用次数,提升性能。Assimp在导入时可以通过
PostProcessSteps.ImproveCacheLocality标志来尝试优化索引顺序。在Managed DX中,Mesh构造函数或Mesh.OptimizeInPlace方法也可以实现类似功能。 - 顶点数据压缩:对于
VertexPositionNormalTexture这样的结构,每个顶点占32字节(34 + 34 + 2*4)。如果模型精度要求不高,可以考虑使用Half类型(16位浮点数)存储纹理坐标,甚至将法线压缩到两个SNorm(有符号规范化)的16位整数中(球面映射),可以将顶点大小减少近一半,提升内存带宽利用率。
6.2 处理骨骼动画数据
.x文件可以存储骨骼(Frame的层级变换)和顶点蒙皮信息(每个顶点受哪些骨骼影响,权重如何)。这是实现角色动画的基础。
使用Assimp加载时,确保包含PostProcessSteps.PopulateArmatureData标志。加载后的Mesh对象会包含HasBones属性和Bones集合。每个Bone对象包含其名称、影响顶点的列表和权重,以及一个偏移矩阵(Offset Matrix,用于将顶点从模型空间变换到骨骼空间)。
处理流程概要:
- 遍历
scene.Meshes,找到有骨骼的网格。 - 为每个网格创建一个“骨骼影响”列表,长度等于顶点数。每个顶点关联一个骨骼索引和权重的数组(通常限制为最多4个,即骨骼蒙皮)。
- 将骨骼的偏移矩阵、以及后续每帧计算出的骨骼最终变换矩阵,通过常量缓冲区传递给顶点着色器。
- 在顶点着色器中,根据骨骼索引和权重,对顶点位置和法线进行线性混合(Linear Blending Skinning)。
这是一个非常复杂的主题,需要配套的动画系统(读取关键帧、插值、组合骨骼变换矩阵)来驱动。SharpDX路径给了你完全的控制权来实现这套流程,而Managed DX虽然也通过Mesh的某些方法和SkinInfo类提供了支持,但使用起来更为晦涩和受限。
6.3 资源管理与多线程加载
对于大型场景,同步加载和创建GPU资源会导致主线程卡顿。一个常见的优化模式是:
- 工作线程解析:在后台线程使用Assimp加载.x文件,进行数据解析和提取,生成包含顶点列表、索引列表、材质信息等的纯数据对象。这个过程是CPU密集型的,适合放在线程池中。
- 主线程上传:将解析好的纯数据对象传递给渲染线程(通常是主线程),在渲染线程中创建
VertexBuffer、IndexBuffer和Texture等GPU资源。必须注意,Direct3D设备(Device)及其上下文(Context)不是线程安全的,所有创建资源和渲染的命令都必须在同一个线程(通常是主线程)中发起。
这种“解析与上传分离”的架构,能有效避免因加载复杂模型导致的帧率下降。
7. 常见问题与排查技巧实录
在实际操作中,你几乎一定会遇到下面这些问题。我把它们和解决方法整理成了速查表。
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 模型渲染为全黑或颜色异常 | 1. 材质颜色设置错误或未设置。 2. 光照未开启或光源位置/方向错误。 3. 法线数据有问题(全为零或方向错误)。 | 1. 检查并确保在渲染前正确设置了device.Material(MDX)或像素着色器中的材质常量(SharpDX)。2. 确认光照已启用( device.RenderState.Lighting = truein MDX),并检查光源参数。3. 在着色器中输出法线作为颜色进行可视化调试,或检查Assimp导入时是否生成了法线( GenerateNormals)。 |
| 模型显示为“破碎”或三角形缺失 | 1. 索引缓冲区数据错误或缠绕顺序不对。 2. 背面剔除(Cull)模式设置与缠绕顺序不匹配。 3. 深度测试(Z-Test)未开启或深度缓冲区格式不正确。 | 1.首要检查缠绕顺序。在Assimp导入时添加FlipWindingOrder标志。在MDX中,检查Mesh的创建标志或尝试Mesh.OptimizeInPlace。2. 临时将剔除模式设为 Cull.None,如果模型完整显示,则证实是缠绕顺序问题。3. 确保创建了正确格式的深度/模板缓冲区并开启了深度测试。 |
| 纹理无法加载或显示为纯白/黑 | 1. 纹理文件路径错误。 2. 纹理坐标(UV)超出[0,1]范围且寻址模式未设置。 3. 纹理格式不被硬件支持。 4. 未正确设置纹理采样器状态(Filter, AddressU/V)。 | 1. 打印Assimp或MDX返回的纹理路径,检查文件是否存在。注意相对路径的基准目录。 2. 检查UV值。在着色器中先输出UV作为颜色,看其分布是否在0~1之间。如果不是,可能需要设置纹理寻址模式为 Wrap或Clamp。3. 尝试加载一个简单的 .bmp或.dds文件排除格式问题。4. 确保在渲染前为纹理设置了正确的采样器状态。 |
| 使用SharpDX时,调用DrawIndexed抛出访问冲突异常 | 1. 顶点缓冲区或索引缓冲区未正确绑定或已释放。 2. 顶点结构( InputElement)的定义与顶点缓冲区中的数据布局不匹配。3. 索引计数或起始索引参数超出缓冲区范围。 | 1. 使用调试器检查vertexBuffer和indexBuffer变量是否为null或已Disposed。2.仔细核对 VertexPositionNormalTexture.SizeInBytes与SetVertexBuffers中指定的步幅(stride)是否一致。核对InputElement的格式、偏移量是否与结构体定义完全匹配。这是最常见错误。3. 确保 DrawIndexed的indexCount参数等于或小于索引列表的元素总数。 |
| 加载含动画的.x文件后,模型位置/姿态错乱 | 1. 未正确处理骨骼层级变换(Scene Graph)。 2. 骨骼偏移矩阵(Offset Matrix)使用错误。 3. 动画关键帧数据未读取或未应用。 | 1. 确保遍历了Assimp的scene.RootNode及其子节点,构建了正确的骨骼父子关系树。2. 验证传递给着色器的骨骼变换矩阵是“最终变换矩阵”,即: 最终矩阵 = 父骨骼全局变换矩阵 * 当前骨骼局部变换矩阵 * 偏移矩阵。这个计算顺序至关重要。3. 检查是否从 scene.Animations中读取了动画数据,并在每帧根据时间插值计算出骨骼的局部变换矩阵。 |
| 在64位系统或高DPI屏幕上,Managed DX程序崩溃或渲染异常 | Managed DirectX 1.1对64位和高DPI支持不佳,存在已知兼容性问题。 | 1. 尝试将应用程序目标平台强制设置为x86(32位)。2. 在应用程序清单文件中添加高DPI感知设置。 3.根本解决方案:迁移到SharpDX或使用其他现代图形API封装。 |
我个人在实际操作中体会最深的一点是:数据一致性检查。在将任何数据提交给GPU之前,在CPU端做一次快速的完整性检查能节省大量调试时间。例如,在创建缓冲区后,可以写一小段代码将前几个顶点、索引的值打印出来,与原始文件或Assimp加载的数据进行比对。对于动画,在加载后打印出骨骼的名称、父子关系和第一帧的变换矩阵,确保层级关系正确。图形编程的很多bug现象诡异,但根源往往就是某个矩阵乘反了、某个偏移量算错了、或者某个缓冲区没绑定。养成“数据验证”的习惯,是提升开发效率的关键。
最后,虽然.x文件已不再是行业前沿,但通过亲手实现其加载器,你对3D图形数据管道、内存管理、GPU API的理解会深刻得多。这套知识体系完全适用于FBX、glTF等现代格式。当你下次使用Unity或Unreal Engine一键导入模型时,你会清楚地知道,引擎在背后为你默默完成了所有这些复杂而精细的工作。
