Metal 学习笔记二:3D模型

server/2025/2/26 4:21:38/

是什么让一个好游戏更好玩?漂亮的图像!就像《神界:原罪2》,《暗黑破坏神3》以及《巫师3》等大作一样,需要一个强大的程序团队以及3D美术团队强强合作。你在屏幕中看到正是3D模型使用自定义渲染绘制的结果。就像上一章你绘制的红色球体那样,只是效果更为丰富和先进而已。

在本节,我们会熟悉3D模型,我们会学习如何创建它们,它们是由什么组成,以及如何用不同的颜色和风格来渲染它们。

什么是3D模型?

3D模型是由顶点组成的。每个顶点都是3D空间的一个点,由 x, y, z 值组成。

就像你在前一章看到的,你会发送这些顶点数据到GPU来渲染它们。

打开本章的starter的playground。这个playground包含两页,Render and Export 3D Model 以及 Import Train。它也包含了USDZ格式的train模型,如果你看不到这些东西的话,你可能需要使用右上角图标来显示项目导航。

要显示文件扩展名,请打开 Xcode 设置,然后在 General 选项卡上,选择 File Extensions: Show All。

从项目导航那里,选择Render and Export 3D Model。它包含了第一章的代码,"Hello Metal!" 检查它在playground实时视图中显示的球体。当前球体是显示为一个平坦的实心红色实体。

要查看每个三角形的边,您可以使用线框渲染模型。

为了使用线框渲染,在renderEncoder.setVertexBuffer(...)后添加如下代码:

renderEncoder.setTriangleFillMode(.lines)

此代码指示 GPU 渲染线条而不是实心三角形。

运行playground:

这里有点视觉错觉。它可能看起来不像,但 GPU 正在渲染直线。球体边缘看起来弯曲的原因是 GPU 渲染的三角形数量。如果渲染的三角形较少,则曲线模型往往看起来有些“块状”。
你现在可以真正看到球体的 3D 性质。模型的三角形在水平方向上均匀分布,但由于您在二维屏幕上查看,因此它们在球体边缘的位置比中间的三角形小。

在 Blender 或 Maya 等 3D 应用程序中,您通常会作点、线条和面。点是顶点;线(也称为边)是顶点之间的线;而面是三角形平面区域。

顶点通常按三角形排列,因为 GPU 硬件专门用于处理它们。GPU 的核心指令希望看到一个三角形。在所有可能的形状中,为什么是三角形?
• 三角形是二维中可绘制的任何多边形中顶点最少的。
• 无论以何种方式移动三角形的点,这三个点将始终位于同一平面上。
• 从任何顶点开始分割三角形时,它总是变成两个三角形。
在 3D 应用程序中建模时,通常使用四边形(四点多边形)。四边形与细分或平滑算法配合得很好。当您使用 Model I/O 框架导入模型时,Model I/O 会将这些四边形转换为三角形。

用Blender创建模型

为了创建3D模型,你需要一个好用的3D建模程序。市面上有很多建模软件,从免费的到昂贵的。对于免费中最好用的3D建模软件是Blender(作者使用的版本是 v.3.6)。也有很多建模专家使用Blender,如果你了解Cheetah3D、Maya或Houdini等其他软件的话,你会发现Blender用起来也差不多。

下载并安装Blender,通过 https://www.blender.org 网址。运行Blender,点击空白区域关闭启动tips界面,然后你会看到一个操作界面,如下图:

如果你的界面看起来不一样的话,可以点击Edit Menu -> Preferences. 点击左下角的汉堡菜单,选择Load Factory Settings,并且点击Load Factory Preferences。点击Save Preferences保存设置。

如果你希望创建你自己的模型,最好的开始是参照Blender说的教程,在地址。

这个教程会教你怎么做一个蘑菇,你可以后续在你的playerground中把这个蘑菇给渲染出来,本章后面的挑战中,有这个题目。

3D文件格式

.obj: 由Wavefront科技公司开发,流行过一段时间;基本上所有3D建模程序都支持导入和导出.obj文件,你可以使用和obj同文件的.mtl文件来指定材质(包括纹理以及表面属性等),不过这个文件不支持动画。

.glTF:由Khronos开发出来--就是那个管理Vulkan和OpenGL的机构--这个格式相对比较新,并且仍然在开发中。因为它的灵活性,所以它有强大的社区支持,它也支持动画模型。

.blend:是Blender建模软件用的格式。

.fbx:Autodesk私有格式,由于Autodesk公司有多个强大建模软件的原因,这个格式使用很广泛,并且支持动画,但是缺点是, 它是私有的,并且没有单一标准。

.usd:Universal Scene Description是一个由Pixar开发的开源格式,完整文档在https://openusd.org。USD 文件可以引用许多模型和文件,因此团队中的每个人都可以处理场景的单独部分。USD 文件可以具有多个不同的扩展名。.usd 可以是 ASCII 或二进制。.usda 是人类可读的 ASCII。.usdz 是一个 USD 存档文件,其中包含模型或场景所需的一切。Apple 将 USDZ 格式用于其 AR 模型。

OBJ 文件仅包含单个模型,而 glTF 和 USD 文件是整个场景的容器,包括模型、动画、相机和灯光。
在本书中,您将主要使用 USD 格式。

您可以使用 Apple 的 Reality Converter 将 3D 文件转换为 USDZ。Apple 还提供了用于验证和检查 USDZ 文件 (https:// apple.co/3gykNcI) 的工具,以及示例 USDZ 文件库 (https://apple.co/ 3iJzMBW)。

导出到Blender

现在你已经安装好Blender了,是时候导出你在playground中创建的模型到Blender中了。

仍然在Render and Export 3D Model页,到playground的靠前的代码,你创建模型的地方,修改如下代码:

let mdlMesh = MDLMesh(shereWithExtent: [0.75, 0.75, 0.75],segments:[100, 100],inwardNormals: false,geomeryType: .triangles,allocator: allocator)// 修改为如下代码,即改为创建圆锥体,而不是球体
let mdlMesh = MDLMesh(coneWithExtent: [1, 1, 1],segments:[10, 10],inwardNormals: false,cap: true,geomeryType: .triangles,allocator: allocator)

这将会创建圆锥体,而不是球体。运行playground,你将会看到线框的圆锥体。

这个将会是你通过Model I/O 来导出的模型。

在Finder中,在Documents目录,创建一个新的文件夹,命名为Shared Playground Data。这个是你保存playgrounds的文件的地方,确保你命名准确。

注意:全局常量playgroundSharedDataDirectory会持有这个文件夹的名字。

为了导出这个圆锥体,增加如下代码,在创建mesh的后面:

// begin export code// 在Model I/O 的场景的最上层是一个MDLAsset,
// 你可以增加子对象,比如Mesh,相机,灯光等
// 到asset并且构建一个完整场景层次
let asset = MDLAsset()
asset.add(mdlMesh)// 检测Model I/O 是否能导出一个 .usda文件格式
// 你可以选择.usd或.usdz,但是选择ASCII文本可以方便检查文件内容
let fileExtension = "usda"
guard MDLAsset.canExportFileExtension(fileExtension) else {fatalError("Can't export a .\(fileExtension) format")
}// 导出圆锥体到Shared Playground Data的文件夹中
do {let url = playgroundSharedDataDirectory.appendingPathComponent("primitive.\(fileExtension)")try asset.export(to: url)
} catch {fatalError("Error \(error.localizedDescription)")
}
// end export code

运行playground即可导出圆锥体对象了。

usd文件格式

在Finder,导航到Documents -> Shared Playground Data. 我们看到playground已经导出这个文件:primitive.usda。

使用一个文本编辑器,打开primitive.usda。

以下是描述具有四个角顶点的平面基元的示例 USD 文件。圆锥体 USD 文件看起来相似,只是它具有更多的顶点数据。

#usda 1.0
(defaultPrim = "plane"endTimeCode = 0startTimeCode = 0timeCodesPerSecond = 60upAxis = "Y" )
def Mesh "plane"
{uniform bool doubleSided = 0float3[] extent = [(0, -0.5, -0.5), (0, 0.5, 0.5)]int[] faceVertexCounts = [3, 3]int[] faceVertexIndices = [3, 2, 1, 3, 1, 0]normal3f[] normals = [(-1, 0, 0), (-1, 0, 0), (-1, 0, 0),
(-1, 0, 0)]point3f[] points = [(0, 0.5, 0.5), (0, -0.5, 0.5), (-0,
-0.5, -0.5), (0, 0.5, -0.5)]float2[] primvars:Texture_uv = [(1, 1), (0, 1), (0, 0), (1,
0)] (interpolation = "vertex")
}

该文件首先描述大体功能,例如动画计时和向上的方向。然后,该文件将描述网格。
以下是平面 USD 的详细解释:
• extent:网格的大小。创建圆锥体时,在 coneWithExtent 中,您在所有轴上都指定了 1。最小顶点位置值为 -0.5,最大值为 0.5。
• faceVertexCounts:此平面由两个三角形组成,每个三角形有三个顶点。您的圆锥体将有许多三角形。
• faceVertexIndices:此平面有四个顶点,每个角一个。索引顺序是这些顶点的渲染顺序。要组成两个三角形,您需要渲染六个顶点。
• normals:表面法线。法线是与平面正交、指向外面的向量。稍后将阅读有关法线的更多信息。
• points:每个顶点的位置。该平面有四个顶点。您的圆锥体将具有许多顶点。
• Texture_uv:UV 坐标确定顶点在 2D 纹理上的位置。纹理上的坐标称为 uv 坐标,而不是 xy 坐标,但它们的工作原理相同。您的圆锥体不使用这些 UV 坐标,因为您尚未对其应用任何自定义纹理。

导入圆锥

现在准备导入圆锥体到Blender中,按如下步骤:

1,打开Blender

2,选择File -> New -> General

3,选中初始的立方体

4,按X键删除那个立方体,并确认

现在你的Blender已经清空好了。

选择 File -> Import -> Universal Scene Description (.usd*),并在Documents/Shared Playground Data的文件夹中选中primitive.usda文件。

这样圆锥体就导入到Blender中了:

左键点击这个圆锥体以选中它,并按Tab键,让Blender切换到编辑模式,让你能看到组成圆锥体的顶点和三角形。

在编辑模式中,你可以移动顶点位置,以及添加新顶点来创造更复杂的3D模型。

使用playground,我们已经可以创建,渲染和导出3D模型了。在本章的后面,我们将渲染一个更为复杂的模型,它有多个材质组。

材质

材质描述 3D 渲染器应如何为顶点着色。例如,顶点应该光滑有光泽吗?粉红色?反射?

材料特性可以包括:
• diffuse:表面的基本颜色。
• metallic:描述表面是否为金属。
• roughness:描述表面的粗糙程度。如果表面的粗糙度为 0,则它是完全平坦且有光泽的。

材质组

在Blender中,打开train.blend。它就在本章的资源目录下。它是tran.usdz的源编辑文件。左键点击这个模型来选中它,然后按Tab键进入编辑模式。

不同于普通的灰色圆锥,火车模型有几种颜色。这些颜色被定义在材料组中 - 每种颜色一组。在 Blender 屏幕的右侧,您将看到属性面板,其中材料上下文已选定(这是垂直图标列表底部的图标),该模型中的材料列表在顶部。

选中Body,然后点击下面的“Select”,分配到Body纹理组的顶点都会高亮起来:

注意如何将顶点分为不同的组或材料。这种分离使选择Blender中的各个部分变得更加容易,并使您能够分配不同的颜色。

回到Xcode中,在项目导航那里,打开Import Train的playground页面。

在playground的资源文件夹,我们可以看到train.usdz文件。在窗口上拖动,以便将视图摄像头绕着模型移动。

在Import Train中,移除你创建圆锥体的代码:

let mdlMesh = MDLMesh(coneWithExtent: [1, 1, 1],segments:[10, 10],inwardNormals: false,cap: true,geomeryType: .triangles,allocator: allocator)

不用担心,你的代码暂时编译不过,直到你完本节。我们现在要加载train模型,使用如下代码:

guard let assetURL = Bundle.main.url(forResource: "train",withExtension: "usdz") else {fatalError()
}

这会设置好USD文件的URL地址。

顶点描述

Metal创建对象时,都会使用一个描述来填充信息。你在上一章看到创建管线状态对象时也使用过描述。在加载模型之前,我们需要通过创建一个顶点描述,以便告诉Metal如何安排顶点和其他数据。

接下来的图表描述模型顶点数据的输入缓冲,它共有两个顶点,每个顶点包含了位置,法线以及纹理坐标属性。顶点描述让Metal知道怎么解析顶点数据。

在刚才设置模型URL代码后,添加如下代码:

// 创建一个顶点描述,用来配置所有顶点属性。
// 一般顶点属性会包含位置,法线,和纹理坐标等数据。
// 不过这里只需要位置。每个顶点最多可以包含31种属性。
let vertexDescriptor = MTLVertexDescriptor()// 属性0:位置,它是一个由3个float组成的数据
vertexDescriptor.attributes[0].format = .float3// 属性0在缓存中的偏移为0,是第一个属性
vertexDescriptor.attributes[0].offset = 0// 当你发送顶点数据到GPU时,你通过一个MTLBuffer发送它,
// 并且会用一个索引来标识这个缓冲。有31个有效缓冲,
// Metal会用一个缓冲参数表来记录它们。使用0号缓冲,
// 以便顶点着色器可以匹配输入到缓冲0的顶点数据
vertexDescriptor.attributes[0].bufferIndex = 0

紧接着是如下代码:

// 指定buffer0的顶点数据步幅值。步幅值是每个顶点占用字节个数。
// 这里我们每个顶点只有位置信息,所以占用了3个float3的尺寸,float3等同于swift中的SIMD3<Float>。
// 使用缓冲区布局索引和步幅格式,您可以设置引用具有不同布局的多个 MTLBuffer 的复杂顶点描述符。您可以选择交错位置、法线和纹理坐标;或者,您可以先布置包含所有位置数据的缓冲区,然后再包含其他数据。
vertexDescriptor.layouts[0].stride = MemoryLayout<SIMD3<Float>>.stride// 通过metal的顶点描述,创建一个Model I/O专用的顶点描述
// Model I/O 需要的格式略有不同的顶点描述符,因此您可以从 Metal 顶点描述符创建新的Model I/O 描述符。如果您有Model I/O 描述符并且需要一个 Metal 描述符,MTKMetalVertexDescriptorFromModelIO() 提供了一个解决方案
let meshDescriptor =MTKModelIOVertexDescriptorFromMetal(vertexDescriptor)// 把字符串名“position”分配给属性0,这让Model I/O 知道这个属性代表位置信息。
// 法线和纹理坐标数据也可用,但使用此顶点描述符,您告诉 Model I/O 您对加载这些属性不感兴趣。
(meshDescriptor.attributes[0] as! MDLVertexAttribute).name =
MDLVertexAttributePosition

接下来是如下代码:

// 通过URL,顶点描述,以及内存分配器读取到资源。
let asset = MDLAsset(url: assetURL,vertexDescriptor: meshDescriptor,bufferAllocator: allocator)
// 先只取得第一个子mesh
let mdlMesh = asset.childObjects(of: MDLMesh.self).first as! MDLMesh

此代码使用 URL、顶点描述符和内存分配器读取asset。然后,您读入asset中的第一个 Model I/O 网格缓冲区。一些更复杂的对象将具有多个网格,但您稍后会处理这个问题。
现在您已经加载了模型顶点信息,代码的其余部分将相同,您的 Playground 将从新的 mdlMesh 变量加载网格。

运行Playground查看线框渲染的train:

耶!真酷,不过它们好像有点太高了,我们想办法把它拉低一些,接近地面。

Metal坐标系统

所有模型都有一个原点。原点是mesh放在3D场景的定位点,火车的原点是[0, 0, 0],在模型的中下方。我们在Blender编辑这个模型时,这个点在场景的正中间。

Metal的NDC(归一化设备坐标)系是一个2个单元宽,2个单元高,1个单元深的盒子,其中X是右/左,Y是上/下,Z是 进/出 屏幕。

归一化,意味着调整到标准数值范围。在屏幕中,比如你的窗口像素坐标是0到375,但是Metal的NDC坐标系不关心屏幕的实际尺寸,它的X的坐标总是从-1.0到1.0。在第四章,“3D变换”,我们会学习在不同坐标系之间的变换。因为火车的原点[0, 0, 0]在它的中下部,所以,它的中下部位于在NDC屏幕的中间。导致轮胎看起来有点高。

GPU 根据顶点函数的输出呈现顶点位置。您的playground目前包含一个非常简单的顶点函数,该函数返回传递给它的顶点位置。

在playground中定位到let shader = """...""",

shader 是一个文本字符串,其中包含 Metal 库加载和编译的着色器函数代码。通过更改此字符串中的 vertex_in.position,您可以更改每个顶点的渲染位置。
在着色器文本字符串中,将返回 vertex_in.position;更改为:

 float4 position = vertex_in.position;
position.y -= 1.0;
return position;

请小心地完全按照所示方式添加此代码,在每行的末尾添加分号。由于代码包含在字符串中,因此编译器无法识别错误。
在这里,您从渲染的每个顶点的 y 位置减去 1.0。y 轴上的 NDC -1.0 位于屏幕底部。如果您还不太了解发生了什么,请不要担心,因为您将在第 4 章 “顶点函数”中重新讨论这个主题。

运行playground,轮胎现在已经显示到了屏幕的底部了。

 

现在车轮已经固定,你准备好解决失踪火车的情况了!

子网格submesh

到现在,你的模型只显示了一个纹理组,也就是一个子mesh。下图中是一个平面,有4个顶点,以及两个材质组。

当 Model I/O 加载这个平面时,它会把4个顶点放到一个MTLBuffer中,接下来的图显示两个子网格的缓冲是如何索引它的顶点数据的。

第一个子网格缓冲区保存浅色三角形 ACD 的顶点索引。这些索引指向顶点 0、2 和 3。第二个子网格缓冲区保存暗三角形 ADB 的索引。子网格在子网格缓冲区开始的位置也有一个偏移。索引可以保存在 uint16 或 uint32 中。第二个子网格缓冲区的偏移量将是 uint 类型大小的三倍。

环绕顺序

顶点顺序(也称为环绕顺序)在这里很重要。该平面的顶点顺序是逆时针。通过逆时针环绕顺序,以逆时针顺序定义的三角形正面面向您,而以顺时针顺序环绕的三角形背对您。在后面的章节,我们将会理解渲染管线,并且会看到GPU是怎么剔除背面的三角形的,以便节省处理时间。

渲染子mesh

目前,我们只渲染了第一个子mesh。我们的火车模型有多个材质组,因此它有多个子mesh,我们需要用一个循环来渲染它们。

在playground代码靠后位置,修改:

guard let submesh = mesh.submeshes.first else {fatalError()
}
renderEncoder.drawIndexedPrimitives(type: .triangle,indexCount: submesh.indexCount,indexType: submesh.indexType,indexBuffer: submesh.indexBuffer.buffer,indexBufferOffset: 0)

为:

for submesh in mesh.submeshes {renderEncoder.drawIndexedPrimitives(type: .triangle,indexCount: submesh.indexCount,indexType: submesh.indexType,indexBuffer: submesh.indexBuffer.buffer,indexBufferOffset: submesh.indexBuffer.offset)
}

这个循环,会遍历所有子mesh,并调用draw call。mesh和子mesh都在MTLBuffers中,子mesh持有它使用的网格中顶点的索引清单。

运行playground,然后你的火车就能完全渲染出来了,除了材质颜色,你将会在第7章“纹理映射与材质”中学习如何着色。

祝贺!你已经渲染了3D模型了。暂时不用管你只能渲染它到一个类2D的效果,并且没有显示材质颜色。到下一章,你会知道渲染更多的知识,并且再后续章节,你会知道如何把它渲染得更为3D一些。

挑战

如果你要参加一个有趣的挑战,请完成 Blender 教程来制作蘑菇 (https://bit.ly/3gwKiel),然后将你在 Blender 中制作的内容导出到 .usdz 文件。如果要跳过建模,可以在本章的 resources 目录中找到 mushroom.usdz 文件。
➤ 将 mushroom.usdz 导入 playground 并渲染它。
如果您使用自己建模的蘑菇,您可能会发现蘑菇是侧躺的。Blender 使用 Z 轴向上,而你的 Playground 期望 Y 轴向上。在导出为 USD 之前,您应该将模型在 Z 轴上旋转 180°,在 X 轴上旋转 270°。然后,您必须在 Blender 中应用所有变换,然后才能使用菜单选项 Object ▸ Apply ▸ All Transforms 导出。resources 目录中的 mushroom.usdz 已经旋转。

如果你遇到困难,完成的 Playground 位于本章的挑战目录中。

参考

https://zhuanlan.zhihu.com/p/384962760


http://www.ppmy.cn/server/170684.html

相关文章

百度首页上线 DeepSeek 入口,免费使用

大家好&#xff0c;我是小悟。 百度首页正式上线了 DeepSeek 入口&#xff0c;这一重磅消息瞬间在技术圈掀起了惊涛骇浪&#xff0c;各大平台都被刷爆了屏。 百度这次可太给力了&#xff0c;PC 端开放仅 1 小时&#xff0c;就有超千万人涌入体验。这速度&#xff0c;简直比火…

Python网络爬虫技术详解文档

Python网络爬虫技术详解文档 目录 网络爬虫概述爬虫核心技术解析常用Python爬虫库实战案例演示反爬虫机制与应对策略爬虫法律与道德规范高级爬虫技术资源推荐与学习路径1. 网络爬虫概述 1.1 什么是网络爬虫 网络爬虫(Web Crawler)是一种按特定规则自动抓取互联网信息的程序…

Linux-Ansible命令

文章目录 常用命令基础命令 &#x1f3e1;作者主页&#xff1a;点击&#xff01; &#x1f916;Linux专栏&#xff1a;点击&#xff01; ⏰️创作时间&#xff1a;2025年02月21日18点49分 常用命令 ansible #主命令&#xff0c;管理员临时命令的执行工具 ansible-doc #…

双非一本电子信息专业自学嵌入式,学完 Linux 后咋走?单片机 FreeRTOS 要补吗?

今天给大家分享的是一位粉丝的提问&#xff0c;双非一本电子信息专业自学嵌入式&#xff0c;学完 Linux 后咋走&#xff1f;单片机 & FreeRTOS 要补吗&#xff1f; 接下来把粉丝的具体提问和我的回复分享给大家&#xff0c;希望也能给一些类似情况的小伙伴一些启发和帮助。…

R语言Stan贝叶斯空间条件自回归CAR模型分析死亡率多维度数据可视化

全文链接&#xff1a;https://tecdat.cn/?p40424 在空间数据分析领域&#xff0c;准确的模型和有效的工具对于研究人员至关重要。本文为区域数据的贝叶斯模型分析提供了一套完整的工作流程&#xff0c;基于Stan这一先进的贝叶斯建模平台构建&#xff0c;帮助客户为空间分析带来…

Linux性能监控工具汇总

文章目录 前言一、性能监控工具介绍1.概念介绍2.常用组合方式3.对比 二、sar工具1.sar安装2.sar工具参数3.sar工具使用示例3.1.每两秒采集一次cpu使用情况&#xff0c;总计采集2次,然后输出CPU使用情况的统计信息3.2.磁盘IO使用情况统计3.3.内存使用情况统计3.4.网卡流量使用情…

TD时间差分算法

TD算法用来估计value-state 给定data/experiece of algorithm&#xff0c; TD算法&#xff1a; 其中TD error&#xff1a; δ t v ( s t ) − [ r t 1 γ v ( s t 1 ) ] v ( s t ) − v t ‾ \delta_t v(s_t) -[r_{t1} \gamma v(s_{t1})]v(s_t) - \overline{v_{t}} δ…

GAMES104:18 网络游戏的架构基础-学习笔记

文章目录 课前QA一&#xff0c;网络协议Network Protocols1.0 Socket1.1 传输控制协议TCP&#xff08;Transmission Control Protocol&#xff09;1.2 用户数据报协议UDP(User Datagram Protocol)1.3 Reliable UDP1.3.1 自动重传请求ARQ(Automatic Repeat Request)1.3.1.1 滑窗…