Metal学习笔记八:纹理

到目前为止,您已经学习了如何使用片段函数和着色器为模型添加颜色和细节。另一种选择是使用图像纹理,您将在本章中学习如何操作。更具体地说,您将了解:

• 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 函数中读取纹理,您需要执行以下步骤:

  1. 集中加载和存储图像纹理。

  2. 在绘制模型之前,将加载的纹理传递给 fragment 函数。

  3. 更改 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 纹理。

浏览代码:

  1. 如果纹理已加载到纹理中,则返回该纹理。请注意,您是按名称加载纹理的,因此您必须确保模型没有冲突的名称。

  2. 使用 MetalKit 的 MTKTextureLoader 创建纹理加载器。

  3. 更改纹理的原点选项,以确保纹理加载时其原点位于左下角。如果没有此选项,纹理将无法正确覆盖房屋。

  4. 使用提供的纹理和加载器选项创建新的 MTLTexture。出于调试目的,请打印一条消息。

  5. 将纹理添加到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
} }

了解这些扩展的作用:

  1. 使用提供的子网格材质加载基础颜色(漫反射)纹理。稍后,您将以相同的方式加载子网格的其他纹理。

  2. MDLMaterialProperty.textureName 返回文件中的纹理名称,如果未提供名称,则返回唯一标识符。

  3. 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
}

浏览代码:

  1. 如果您已经加载了此名称的纹理,请返回加载的纹理。

  2. 像设置 USD 纹理加载一样设置纹理加载器。从资产目录中加载纹理,并指定名称。在实际应用程序中,对于不同的分辨率比例,您将拥有不同大小的纹理。在资源目录中,您可以根据比例以及设备和色域分配纹理。此处只有一个纹理,因此请使用 1.0 的比例因子。

  3. 如果纹理加载正确,则打印出调试语句,并将其保存在纹理控制器中。

现在,您需要将此纹理分配给地平面。

➤ 打开 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 的当前纹理替换为这些纹理。除了添加纹理之外,您只需按照本章所述更改模型的初始化即可。如果你有任何困难,请查看本章的挑战文件夹。

参考

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

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

相关推荐
二流小码农10 小时前
鸿蒙开发:实现一个标题栏吸顶
android·ios·harmonyos
season_zhu10 小时前
iOS开发:关于日志框架
ios·架构·swift
Digitally14 小时前
如何在电脑上轻松访问 iPhone 文件
ios·电脑·iphone
安和昂14 小时前
【iOS】YYModel源码解析
ios
pop_xiaoli14 小时前
UI学习—cell的复用和自定义cell
学习·ui·ios
大熊猫侯佩15 小时前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩16 小时前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩16 小时前
使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用
数据库·swift·apple
大熊猫侯佩16 小时前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple
Daniel_Coder17 小时前
Xcode 16.4 + iOS 18 系统运行时崩溃:___cxa_current_primary_exception 符号丢失的原因与解决方案
ios·xcode·ios 18·dyld·libc++abi