
到目前为止,您已经学习了如何使用片段函数和着色器为模型添加颜色和细节。另一种选择是使用图像纹理,您将在本章中学习如何操作。更具体地说,您将了解:
• UV 坐标:如何展开网格,以便可以对其应用纹理。
• 纹理化模型:如何读取片段着色器中的纹理。
• 资产目录:如何组织纹理。
• 采样器:读取 (采样) 纹理的不同方式。
• Mipmaps:多级纹理,以便纹理分辨率与显示大小匹配并占用更少的内存。
纹理和UV映射
下图显示了一个有12个顶点的房子模型,左边是线框(显示了顶点),右边是纹理映射好的模型。
注意:如果您想更仔细地查看该模型,您可以在本章的 resources/LowPolyHouse 文件夹中找到 Blender 和纹理文件。
要为模型添加纹理,您首先必须使用称为 UV 展开的过程来展平该模型。UV 展开通过展开模型来创建 UV 贴图。要展开模型,您需要使用建模应用程序标记和切割接缝。下图显示了在 Blender 中 UV 展开房屋模型并导出其 UV 贴图的结果。
请注意,屋顶和墙壁有明显的接缝。接缝使该模型可以展平。如果您打印并剪下此 UV 贴图,则可以轻松地将其折叠回房屋。在 Blender 中,您可以完全控制接缝以及如何切割网格。Blender 通过在这些接缝处切割网格来自动展开模型。如有必要,您还可以在 UV 展开窗口中移动顶点以适应您的纹理。
现在你已经有一个扁平的贴图,你可以使用从 Blender 导出的 UV 贴图作为指南来 "绘制" 到它上面。下图显示了通过剪切真实房屋照片创建的房屋纹理(在 Photoshop 中制作)。
请注意,纹理的边缘并不完美,并且可以看到版权信息。在映射上没有顶点的地方,您可以添加任何内容,因为它不会显示在模型上。
注意: 最好不要完全匹配 UV 边缘,而是让颜色渗出,因为有时计算机无法准确计算浮点数。
然后,您将该图像导入 Blender 并将其分配给模型,以获得您在上面看到的纹理房屋。
当您从 Blender 导出 UV 映射模型时,Blender 会将 UV 坐标添加到文件中。每个顶点都有一个二维坐标,用于将其放置在 2D 纹理平面上。左上角是 (0, 1),右下角是 (1, 0)。
下图指示了一些房屋顶点,并列出了一些相对应的坐标。
使用坐标范围0到1的好处是,可以换入较低或较高分辨率的纹理。如果您只是从远处查看模型,则不需要高分辨的纹理。
这个房子很容易展开,但想象一下展开曲面可能有多复杂。下图显示了火车的 UV 贴图(它仍然是一个简单的模型):
当然,Photoshop 并不是为模型添加纹理的唯一解决方案。您可以使用任何图像编辑器在平面纹理上绘画。在过去的几年里,其他几个允许直接在模型上绘画的应用程序已成为主流:
• Blender(免费)
• iPad 上的 Procreate ($)
• Adobe 的 Substance Designer 和 Substance Painter ($$):在 Designer 中,您可以按程序创建复杂的材质。使用 Substance Painter,您可以在模型上绘制这些材质。
• 3Dcoat.com 的 3DCoat ($$)
• Foundry的Mari($$$)
除了纹理之外,在 iPad 上使用 Blender、3DCoat 或 Nomad Sculpt,您还可以以类似于 ZBrush 的方式雕刻模型,然后重新划分高多边形雕刻网格以创建低多边形模型。您稍后会发现,使用这些应用程序绘制时,颜色并不是你唯一可以使用的纹理,因此拥有专门的纹理应用程序非常有价值。
开始程序
➤ 打开本章的入门项目,然后构建并运行应用程序。
该场景包含低多边形房屋。片段着色器代码与上一章中的挑战代码相同,添加了半球照明和不同的背景透明颜色。
其他主要变化是:
• Mesh.swift 和 Submesh.swift 将Model I/O 和 MetalKit 网格缓冲区提取到自定义顶点缓冲区和子网格组中。模型现在包含一个网格数组,而不是单个 MTKMesh。从 Metal API 中抽象出来,可以在生成不使用 Model I/O 和 MetalKit 的模型时提供更大的灵活性。请记住,这是您的引擎,因此您可以选择如何保存网格数据。
• Primitive.swift 扩展了 Model,以便您可以轻松渲染基本形状。该文件支持平面和球体,但您可以添加其他基本形状。
• VertexDescriptor.swift 除了 Position 和 Normal 属性外,还包含一个 UV 属性。模型加载 UV 的方式与上一章中加载法线的方式相同。请注意 UV 将如何使用与位置和法线不同的缓冲区。这不是必需的,但它使布局更灵活,可用于自定义生成的模型。
• Renderer.swift 将 uniform 和 params 传递给 Model 以执行渲染代码。
• ShaderDefs.h 包含 VertexIn 和 VertexOut。这些结构体具有额外的 uv 属性。vertex 函数将插值的 UV 传递给 fragment 函数。
在本章中,您将用纹理中的颜色替换 fragment 函数中的天空和地面颜色。最初,您将使用位于 Models 组中的 lowpoly-house.usdz 中包含的纹理。要在 fragment 函数中读取纹理,您需要执行以下步骤:
-
集中加载和存储图像纹理。
-
在绘制模型之前,将加载的纹理传递给 fragment 函数。
-
更改 fragment 函数以从纹理中读取适当的像素。
1. 加载纹理
一个模型通常具有多个引用相同纹理的子网格。由于您不想重复加载此纹理,因此您将创建一个TextureController 来保存您的纹理。
➤ 创建一个名为 TextureController.swift 的新 Swift 文件。请务必将新文件包含在Target中。将代码替换为:
Swift
import MetalKit
enum TextureController {
static var textures: [String: MTLTexture] = [:]
}
TextureController 将获取模型使用的纹理,并将它们保存在此字典中。
➤ 为 TextureController 添加新方法:
Swift
static func loadTexture(texture: MDLTexture, name: String) ->
MTLTexture? {
// 1
if let texture = textures[name] {
return texture
}
// 2
let textureLoader = MTKTextureLoader(device: Renderer.device)
// 3
let textureLoaderOptions: [MTKTextureLoader.Option: Any] =
[.origin: MTKTextureLoader.Origin.bottomLeft]
// 4
let texture = try? textureLoader.newTexture(
texture: texture,
options: textureLoaderOptions)
print("loaded texture from USD file")
// 5
textures[name] = texture
return texture
}
此方法将接收Model I/O 纹理,并返回准备渲染的 MetalKit 纹理。
浏览代码:
-
如果纹理已加载到纹理中,则返回该纹理。请注意,您是按名称加载纹理的,因此您必须确保模型没有冲突的名称。
-
使用 MetalKit 的 MTKTextureLoader 创建纹理加载器。
-
更改纹理的原点选项,以确保纹理加载时其原点位于左下角。如果没有此选项,纹理将无法正确覆盖房屋。
-
使用提供的纹理和加载器选项创建新的 MTLTexture。出于调试目的,请打印一条消息。
-
将纹理添加到textures并返回它。
注意:加载纹理可能会变得复杂。当metal首次发布时,您必须使用MTLTextureDescriptor指定有关图像的所有内容,例如像素格式,尺寸和用法。但是,使用Metalkit的MTKTextureLoader,您可以使用所提供的默认值并根据需要进行选择。
加载 Submesh 纹理
一个模型网格的每个子网格具有不同的材料特性,例如粗糙度、基色和金属光泽。现在,您将只关注基础颜色纹理。在第11章"地图和材料"中,你将看到一些其他特性。Model I/O 可以方便地加载包含所有材质和纹理的模型。您的工作是以适合您引擎的形式从加载的资产中提取它们。
➤ 打开 Model.swift,找到 let asset = MDLAsset....在这行之后,加上这个:
Swift
asset.loadTextures()
Model I/O 会将 MDLTextureSampler 的值添加到子网格中,以便您能够很快加载纹理。
➤ 打开 Submesh.swift,然后在 Submesh 中,创建一个结构体和一个属性来保存纹理:
Swift
struct Textures {
var baseColor: MTLTexture?
}
var textures: Textures
不用担心编译错误;在初始化纹理之前,您的项目不会编译。
MDLSubmesh 使用一个MDLMaterial 属性保存每个子网格的材质信息。您可以为 Material 提供语义以检索相关材质的值。例如,基色的语义是 MDLMaterialSemantic.baseColor。
➤ 在 Submesh.swift 的末尾,添加三个新的扩展:
Swift
// 1
private extension Submesh.Textures {
init(material: MDLMaterial?) {
baseColor = material?.texture(type: .baseColor)
}
}
// 2
private extension MDLMaterialProperty {
var textureName: String {
stringValue ?? UUID().uuidString
}
}
// 3
private extension MDLMaterial {
func texture(type semantic: MDLMaterialSemantic) ->
MTLTexture? {
if let property = property(with: semantic),
property.type == .texture,
let mdlTexture = property.textureSamplerValue?.texture {
return TextureController.loadTexture(
texture: mdlTexture,
name: property.textureName)
}
return nil
} }
了解这些扩展的作用:
-
使用提供的子网格材质加载基础颜色(漫反射)纹理。稍后,您将以相同的方式加载子网格的其他纹理。
-
MDLMaterialProperty.textureName 返回文件中的纹理名称,如果未提供名称,则返回唯一标识符。
-
MDLMaterial.property(with:) 在子网格的材质中查找提供的属性。然后,检查属性类型是否为纹理,并将纹理加载到 TextureController.textures 中。Material 属性也可以是 float 值,但是其中没有可用于子网格的纹理。
➤ 在init(mdlSubmesh:mtkSubmesh)的底部添加:
Swift
textures = Textures(material: mdlSubmesh.material)
你初始化子网格纹理,最终消除了编译器警告。
➤ 构建并运行您的应用程序以检查一切是否正常。您的模型看起来与初始屏幕截图中的模型相同。但是,您将在控制台中收到一条消息:loaded texture from USD file,表明纹理加载器已成功加载房屋的纹理。
2. 将加载的纹理传递给 Fragment函数
在后面的章节中,您将了解其他几种纹理类型,以及如何使用不同的索引将它们发送到 fragment 函数。
➤ 打开 Shaders 组中的 Common.h,并添加新的枚举来跟踪这些纹理缓冲区索引号:
Swift
typedef enum {
BaseColor = 0
} TextureIndices;
➤ 打开 VertexDescriptor.swift,并将以下代码添加到文件末尾:
Swift
extension TextureIndices {
var index: Int {
return Int(self.rawValue)
}
}
此代码允许您使用BaseColor.index而不是Int(BaseColor.rawValue)。这是一个小的格调,但它使您的代码更易于阅读。
➤打开Rendering.swift。这是您渲染模型的地方。
在处理子网格的代码render(encoder:uniforms:params:)里,在注释// set the fragment texture here:后面添加代码:
Swift
encoder.setFragmentTexture(
submesh.textures.baseColor,
index: BaseColor.index)
现在,您将纹理缓冲区0中的纹理传递给片段函数。
注意:缓冲区,纹理和采样器状态保存在参数表中。如您所见,您可以通过索引号访问这些内容。在iOS上,参数表中至少可以持有31个缓冲区和纹理,和16个采样器声明; macOS上的纹理数量增加到128。您可以在Apple的Metal功能套装表(https://papple.co/2UpCT8r)中找到你的设备支持的功能。
3.更新片段功能
➤打开fragment.metal,紧跟VertexOut in [[stage_in]]之后,将以下新参数添加到fragment_main函数:
Swift
texture2d<float> baseColorTexture [[texture(BaseColor)]]
您现在可以访问GPU上的纹理。
➤用以下代码替换fragment_main中的所有代码:
Swift
constexpr sampler textureSampler;
您读取或采样纹理时,可能不会精确地落在特定的像素上。在纹理空间中,您采样的单元被称为纹素,您可以决定如何使用采样器处理每个纹素。您很快就会了解有关采样器的更多信息。
➤ 接下来,添加这个:
Swift
float3 baseColor = baseColorTexture.sample(
textureSampler,
in.uv).rgb;
return float4(baseColor, 1);
在这里,使用从顶点函数发送的插值 UV 坐标对纹理进行采样,并检索 RGB 值。在 Metal Shading Language 中,您可以使用 rgb 作为 xyz 的等效项来获取浮点元素。然后,从 fragment 函数返回纹理颜色。
➤ 构建并运行应用程序以查看您使用了纹理的房屋。
地平面
是时候为您的场景添加一些地面了。您将使用 Model I/O 中的一种图元来创建地平面,而不是加载 USD 模型,就像您在本书的前几章中所做的那样。
➤ 打开 Primitive.swift 并确保您理解代码。
Model I/O 为平面或球体创建 MDLMesh,并初始化网格和子网格。请注意,您可以在加载 MDLMesh 后分配自己的顶点描述符,Model I/O 将自动重新排列网格缓冲区中的顶点属性顺序。
➤ 打开 Renderer.swift,并向 Renderer 添加新属性以创建地面模型:
Swift
lazy var ground: Model = {
Model(name: "ground", primitiveType: .plane)
}()
➤ 在 draw(in:)中,渲染房屋之后,renderEncoder.endEncoding()之前,添加:
Swift
ground.scale = 40
ground.rotation.z = Float(90).degreesToRadians
ground.rotation.y = sin(timer)
ground.render(
encoder: renderEncoder,
uniforms: uniforms,
params: params)
此代码放大了地平面。原始位置的平面是垂直的,因此您可以在 z 轴上将其旋转 90 度,然后在 y 轴上旋转它以匹配房屋的旋转角度。然后渲染地平面。
➤ 构建并运行应用程序以查看您的地平面。
目前,地面没有纹理或颜色,但您很快就会通过从资产目录中加载纹理来解决此问题。
资源目录asset catalog
当您编写完整的游戏时,您可能会为不同的模型配备许多纹理。如果使用USD格式模型,通常将包括纹理。但是,您可能会使用不具有纹理的不同文件格式,并且组织这些纹理可能会变成劳动力密集型的工作。另外,您还需要压缩图像,并向不同的设备发送不同尺寸和色域的纹理。资产目录将是您转而使用的方式。
顾名思义,资产目录可以持有您的所有资产,无论它们是数据,图像,纹理甚至颜色。您可能已将目录用于应用程序图标和图像。纹理与图像不同,因为GPU使用它们,因此它们在目录中具有不同的属性。要创建纹理,请在资产目录中添加一个新的纹理设置。
➤使用Asset Catalog模板(在Resource部分找到)创建一个新文件,并将其命名为Textures。请记住将其添加到目标中。
➤打开Textures.xcassets,选择Editor ▸ Add New Asset ▸ AR and Textures ▸ Texture Set(或单击面板底部的+,然后选择AR and Textures ▸ Texture Set)。
➤重命名新的纹理为grass。
➤打开本章的资源文件夹,然后将ground.png拖到目录中的Universal插槽。
注意:请小心将图像放在纹理的Universal插槽上。如果将图像拖到资产目录中,则默认情况下它们是图像而不是纹理。稍后您将无法更改任何纹理属性。
您需要向纹理控制器添加另一个方法,以便从资源目录中加载命名纹理。
➤ 打开 TextureController.swift,并向 TextureController 添加一个新方法:
Swift
static func loadTexture(name: String) -> MTLTexture? {
// 1
if let texture = textures[name] {
return texture
}
// 2
let textureLoader = MTKTextureLoader(device: Renderer.device)
let texture: MTLTexture?
texture = try? textureLoader.newTexture(
name: name,
scaleFactor: 1.0,
bundle: Bundle.main,
options: nil)
// 3
if texture != nil {
print("loaded texture: \(name)")
textures[name] = texture
}
return texture
}
浏览代码:
-
如果您已经加载了此名称的纹理,请返回加载的纹理。
-
像设置 USD 纹理加载一样设置纹理加载器。从资产目录中加载纹理,并指定名称。在实际应用程序中,对于不同的分辨率比例,您将拥有不同大小的纹理。在资源目录中,您可以根据比例以及设备和色域分配纹理。此处只有一个纹理,因此请使用 1.0 的比例因子。
-
如果纹理加载正确,则打印出调试语句,并将其保存在纹理控制器中。
现在,您需要将此纹理分配给地平面。
➤ 打开 Model.swift,并将以下内容添加到文件末尾:
Swift
extension Model {
func setTexture(name: String, type: TextureIndices) {
if let texture = TextureController.loadTexture(name: name) {
switch type {
case BaseColor:
meshes[0].submeshes[0].textures.baseColor = texture
default: break
}
}
}
}
此方法加载纹理并将其分配给模型的第一个子网格。
注意:这是分配纹理的快速简便方法。它仅适用于仅使用一种材料的简单模型。如果您经常从资源目录加载子网格纹理,则应设置指向正确纹理的子网格初始化器。
最后要做的是将纹理设置到地面平面上。打开 Renderer.swift,并将地面的声明替换为:
Swift
lazy var ground: Model = {
let ground = Model(name: "ground", primitiveType: .plane)
ground.setTexture(name: "grass", type: BaseColor)
return ground
}()
在加载模型后,从资产目录中加载草地纹理并将其分配给地面平面。
➤构建并运行应用程序以查看茂盛的绿色草地:
这看起来是个问题。草地比原始纹理要暗得多,而且被拉伸和像素化。
sRGB颜色空间
渲染的纹理看起来比原始图像要深得多,因为ground.png是SRGB纹理。 SRGB是一种标准的颜色格式,在阴极射线管显示器的工作方式和人眼看到的颜色之间折中。如下面的灰度值从0到1的示例,SRGB颜色不是线性的。相比于深色,人类更能辨别浅色。
不幸的是,在非线性空间中的颜色上进行数学并不容易。如果将颜色乘以0.5使其变暗,则SRGB的差异会随比率而变化。
目前,您正在将草地纹理加载为SRGB像素数据,并将其渲染到线性色彩空间中。因此,当您采样一个0.2的值时,在SRGB空间中是中度灰色时,线性空间将读取为深灰色。
要大致转换颜色,您可以使用gamma 2.2的倒数:
Swift
sRGBcolor = pow(linearColor, 1.0/2.2);
如果您在从片段函数返回之前,在baseColor上使用此公式,则您的草纹理将看起来像原始的sRGB纹理,但是房子纹理会褪色,因为它正加载在非sRGB颜色空间中。
解决此问题的另一种方法是更改视图的颜色像素格式。
➤打开Renderer.swift,然后在init(metalView:)中找到MetalView.device =
device。在此代码之后,添加:
Swift
metalView.colorPixelFormat = .bgra8Unorm_srgb
在这里,您可以将视图的像素格式,从默认的bgra8unorm更改为在sRGB和线性空间之间转换的格式。
➤ 构建并运行应用程序。

草地颜色现在好多了,但您的非 sRGB 房屋纹理褪色了。
➤ 撤消您刚刚输入的代码:
Swift
metalView.colorPixelFormat = .bgra8Unorm_srgb
GPU抓帧
有一种简单的方法可以找出纹理在 GPU 上的格式,还可以查看当前驻留在其中的所有其他 Metal 缓冲区:Capture GPU workload工具(也称为 GPU 调试器)。
➤ 运行您的应用程序,然后在 Xcode 窗口底部(或调试控制台上方,如果您已打开),单击 M Metal 图标,将要计数的帧数更改为 1,然后单击弹出窗口中的捕获:
此按钮可捕获当前 GPU 帧。在 Debug navigator (调试导航器) 的左侧,您将看到 GPU 跟踪:
注: 若要打开或关闭层次结构中的所有项,可以按住 Option 键点击箭头。
您可以看到您提供给渲染命令编码器的所有命令,例如 setFragmentBytes 和 setRenderPipelineState。稍后,当您有多个命令编码器时,您将看到每个命令编码器都列出来,您可以选择它们以查看它们通过编码生成的操作或纹理。
➤ 在步骤 11 中选择第一个 drawIndexedPrimitives。此时将显示 Vertex 和 Fragment 资源。
➤ 双击每个顶点资源以查看缓冲区中的内容:
• indices:子网格索引。
• Buffer 0:顶点位置和法线数据,与 VertexIn 结构体和顶点描述符的属性匹配。
• 缓冲区 1:UV 纹理坐标数据。
• Vertex Bytes:统一矩阵。
• Vertex Attributes:来自 VertexIn 的传入数据,以及 VertexOut 返回来自顶点函数的数据。此资源对于查看顶点函数的计算结果尤其有用。
• vertex_main:顶点函数。当您有多个顶点函数时,这对于确保设置正确的管道状态非常有用。
浏览 Fragment 资源:
• Texture 0:纹理槽 0 中的房屋纹理。
• Fragment Bytes:参数中的宽度和高度屏幕参数。
• fragment_main:片段函数。
附件:
•CAMetalLayer Drawable:颜色附件0中编码的结果。在这种情况下,这是视图的当前绘制。稍后,您将使用多种颜色附件。
•MTKView Depth:深度缓冲区。黑色更近。白色更远。 光栅器使用深度图。
➤按住Control键,单击Texture 0,然后从弹出菜单中选择获取信息。
像素格式为rgba8unorm,而不是SRGB。
➤在调试导航器中,在第17步中单击第二个drawIndexedPrimitimives命令。再次,按住Control键,单击草纹理,然后从弹出菜单中选择Get Info。
这次的像素格式是rgba8unorm_srgb。
如果您对应用程序中发生的情况不确定,则捕获GPU帧可能会引起您的注意,因为您可以检查每个渲染编码器命令和每个缓冲区。在本书中使用此策略来检查GPU上发生的事情是一个好主意。
现在,回到您纹理不匹配的问题。解决此问题的另一种方法是完全不将资产目录纹理加载为sRGB。
打开Textures.xcassets,单击草纹理,在Attributes inspector中,将Interpretation更改为Data:
当您的应用程序将 sRGB 纹理加载到非 sRGB 缓冲区时,它会自动从 sRGB 空间转换为线性空间。(有关转换规则,请参阅 Apple 的 Metal Shading Language 文档。)通过作为数据而不是颜色进行访问,着色器可以将颜色数据视为线性数据。
您还会注意到,在上图中,原点(与加载 USD 纹理不同)是 Top Left(左上)。资产目录以不同的方式加载纹理。
➤ 构建并运行应用程序,纹理现在以线性颜色像素格式 bgra8Unorm 加载。您可以通过再次捕获 GPU 工作负载来确认这一点。
现在,您可以处理渲染中的其他问题,从像素化的草地开始。
采样器Samplers
在 fragment 函数中对纹理进行采样时,使用了默认采样器。通过更改采样器参数,您可以决定应用程序如何读取纹素。
地面纹理会拉伸以适应地平面,并且纹理中的每个像素都可能被多个渲染的片段使用,从而使其具有像素化的外观。通过更改其中一个采样器参数,您可以告诉 Metal 如何处理纹素小于分配的片段的情况。
➤ 打开 Fragment.metal。在 fragment_main 中,将 textureSampler 定义更改为:
Swift
constexpr sampler textureSampler(filter::linear);
此代码指示采样器平滑纹理。
➤ 构建并运行应用程序。
地面纹理(尽管仍然拉伸)现在是平滑的。有时,例如当您制作 Frogger 的复古游戏时,您会希望保持像素化。在这种情况下,请使用 nearest 筛选。
但是,在这种特殊情况下,您需要平铺纹理。这对于采样来说很容易。
➤ 更改采样器定义, 将baseColor分配为:
Swift
constexpr sampler textureSampler(
filter::linear,
address::repeat);
float3 baseColor = baseColorTexture.sample(
textureSampler,
in.uv * 16).rgb;
此代码将 UV 坐标乘以 16,并访问超出允许范围(0 到 1)的纹理。address::repeat 会更改采样器的寻址模式,因此它将在整个平面上重复纹理 16 次。
下图说明了平铺值为 3 时显示的其他地址采样选项。您可以使用 s_address 或 t_address 分别仅更改宽度或高度坐标。
➤ 构建并运行您的应用程序。
地面看起来很棒!房子...没那么棒。着色器还平铺了房屋纹理。为了解决这个问题,您将在模型上创建一个 tiling 属性,并使用 params 将其发送到 fragment 函数。
➤ 在 Common.h 中,将此添加到 Params:
Swift
uint tiling;
➤ 在 Model.swift 中,在 Model 中创建一个新属性:
Swift
var tiling: UInt32 = 1
➤ 打开 Rendering.swift,然后在 render(encoder:uniforms:params:)中,在 var params = fragment 之后添加以下内容:
Swift
params.tiling = tiling
➤ 在 Renderer.swift 中,将 ground 的声明替换为:
Swift
lazy var ground: Model = {
let ground = Model(name: "ground", primitiveType: .plane)
ground.setTexture(name: "grass", type: BaseColor)
ground.tiling = 16
return ground
}()
现在,您正在将模型的平铺因子发送到 fragment 函数。
➤ 打开 Fragment.metal。在 fragment_main 中,将 baseColor 的声明替换为:
Swift
float3 baseColor = baseColorTexture.sample(
textureSampler,
in.uv * params.tiling).rgb;
➤构建并运行该应用程序,您会发现地面和房屋现在都正确地分块了。
随着场景的旋转,您会发现一些分散注意力的噪音。您已经看到过度样品质地时在草地上发生了什么。但是,当您调解纹理时,您可以得到一个被称为Moiré的渲染文物,该文物发生在房屋的屋顶上。
注意:在着色器中创建采样器并不是唯一的选择。您可以创建一个mtlsamplerstate,用模型握住它,然后使用[[Sampler(n)]]属性将采样器状态发送到片段函数。
此外,地平线上的噪音几乎看起来好像草在闪闪发光。您可以通过使用称为MIPMAP的调整质地正确采样来解决这些工件问题。
多级纹理Mipmaps
检测屋顶纹理大小以及它在屏幕中显示的大小:
出现这种模式是因为您采样的纹素多于像素。理想的情况是,具有相同数量的纹素对应于像素,这意味着对象离得越远,您需要的纹理就越小。解决方案是使用 mipmap。Mipmap 允许 GPU 比较其深度纹理上的片段,并以合适的大小对纹理进行采样。
MIP 代表 multum in parvo --- 一个拉丁短语,意思是"小而多"。
Mipmap 是按 2 的幂次逐级缩小的纹理贴图,一直减小到 1 像素大小。如果您的纹理为 64 x 64 像素,则完整的 mipmap 集将包括:
级别 0:64 x 64,1:32 x 32,2:16 x 16,3:8 x 8,4:4 x 4,5:2 x 2,6:1 x 1。
在下图中,顶部的棋盘格子纹理没有使用mipmap。但在底部图像中,每个片段都是从适当的 MIP 级别采样的。
随着棋盘格后退,有更少的噪点,图像也会更清晰。在地平线上,您可以看到纯色较小的灰色 mipmap。
首次加载纹理时,可以轻松自动生成这些 mipmap。
➤ 打开 TextureController.swift。在loadTexture(texture:name:)中,将纹理加载选项更改为:
Swift
let textureLoaderOptions: [MTKTextureLoader.Option: Any] =
[.origin: MTKTextureLoader.Origin.bottomLeft,
.generateMipmaps: true]
此代码将创建 mipmap,一直到最小的像素。
还有一件事需要更改:片段着色器中的纹理采样器。
➤ 打开 Fragment.metal,将以下代码添加到 textureSampler 的构造中:
Swift
mip_filter::linear
mip_filter 的默认值为 none。但是,如果您提供 .linear 或 .nearest,则 GPU 将对正确的 mipmap 进行采样。
➤ 构建并运行应用程序。

建筑物和地面的噪点都消失了。
使用 Capture GPU workload工具,您可以检查 mipmap。选择 draw 调用,然后双击纹理。
您可以看到所有不同大小的 mipmap 纹理。GPU 将自动加载相应的 mipmap。
资源目录属性
也许您感到惊讶,因为您只更改了 USD 纹理加载方法,就看到地面渲染得到了改善。地面是一个图元平面,您可以从资产目录中加载其纹理。
➤ 打开 Textures.xcassets,然后在 Attributes inspector(属性检查器)打开的情况下,单击草地纹理以查看所有纹理选项。
在这里,你可以看到,默认情况下,所有 mipmap 都是自动创建的。如果将 Mipmap Levels (Mipmap 级别) 更改为 Fixed (固定),则可以选择要创建的级别数。如果您不喜欢自动 mipmap,可以通过将它们拖动到正确的槽位,来将它们替换为您自定义的mipmap。

为正确的工作提供正确的质地
使用资源目录可以完全控制如何交付纹理。目前,草地只有一种颜色纹理。但是,如果您支持具有不同功能的各种设备,则可能需要为每种情况提供特定的纹理。在 RAM 较少的设备上,您需要更小的图形。
例如,以下是您可以通过检查 Apple Watch 以及 sRGB 和 P3 显示器的 Attributes Inspector 中的不同选项来分配各个纹理的列表。
各向异性
渲染的地面在背景中看起来有点泥泞和模糊。这是由于各向异性造成的。各向异性表面会根据您查看它们的角度而变化,当 GPU 对以倾斜角度投影的纹理进行采样时,会导致锯齿。
➤ 在 Fragment.metal 中,将以下内容添加到 textureSampler 的构造中:
Swift
max_anisotropy(8)
Metal 现在将从纹素中获取 8 个样本来构建片段。最多可以指定 16 个样本以提高质量。使用尽可能少的采样以获得所需的显示质量,因为采样会减慢渲染速度。
注意:如前所述,您可以在 Model 上保留 MTLSamplerState。如果增加各向异性采样,则可能不希望在所有模型上都这样做,这可能是在片段着色器之外创建采样器状态的一个很好的理由。
➤ 构建并运行,您的渲染应该是无伪影的。

挑战
将这两个纹理添加到资产目录中,并将 house 和 ground 的当前纹理替换为这些纹理。除了添加纹理之外,您只需按照本章所述更改模型的初始化即可。如果你有任何困难,请查看本章的挑战文件夹。
