Metal学习笔记七:片元着色器

知道如何通过将顶点数据发送到 vertex 函数来渲染三角形、线条和点是一项非常巧妙的技能 --- 尤其是因为您能够使用简单的单行片段函数为形状着色。但是,片段着色器能够执行更多操作。

➤ 打开网站 https://shadertoy.com,在那里您会发现大量令人眼花缭乱的社区创建的出色着色器。

这些示例可能看起来像复杂 3D 模型的渲染图,但外观具有欺骗性!您在此处看到的每个 "模型" 都是完全使用数学生成的,用 GLSL 片段着色器编写。GLSL 是 OpenGL 的图形库着色语言 --- 在本章中,您将开始了解所有着色高手使用的原理。

注意:每个图形API都使用自己的着色器语言。原理是相同的,因此,如果您找到喜欢的GLSL着色器,则可以使用Metal MSL重新创建它。

起始项目

Starter 项目展示了一个示例,该示例将多个管线状态与不同的顶点函数结合使用,具体取决于您渲染的是旋转的火车还是全屏四边形。

➤ 打开本章的入门项目。

➤ 构建并运行项目。(您可以选择渲染火车或四边形。您将先从四边形开始。)

让我们仔细看看代码。

➤ 打开 Shaders 组中的 Vertex.metal,您将看到两个顶点函数:

• vertex_main:此函数将呈现火车,就像在上一章中所做的那样。

• vertex_quad:此函数使用着色器中定义的数组渲染全屏四边形。

这两个函数都输出一个 VertexOut结构体,其中仅包含顶点的位置。

➤ 打开 Renderer.swift。

在 init(metalView:options:) 中,您将看到两个管线状态对象 (PSO)。两个 PSO 之间的唯一区别是 GPU 在绘制时将调用的顶点函数。

根据 options.renderChoice 的值,draw(in:) 渲染火车模型或四边形,并换入正确的管线状态。SwiftUI 视图处理 Options 的更新,而 MetalViewRepresentable 将当前选项传递给 Renderer。

➤ 在继续之前,请确保您了解此项目的运作方式。

屏幕空间

片段函数可以执行的许多操作之一是创建复杂的模式,这些模式用来填充呈现的四边形上的屏幕像素。目前,片段函数只有 vertex 函数的插值position输出可供其使用。因此,首先,您将了解您可以利用此position做什么以及它的局限性是什么。

➤ 打开 Fragment.metal,将 fragment 函数内容改为:

Swift 复制代码
float color;
in.position.x < 200 ? color = 0 : color = 1;
return float4(color, color, color, 1);

当光栅器处理顶点位置时,它会将它们从 NDC(标准化设备坐标)转换为屏幕空间。您在 ContentView.swift 中将 Metal 视图的宽度定义为 400点。使用新添加的代码,您说如果 x 位置小于 200,则将颜色设为黑色。否则,将颜色设为白色。

注意:虽然您可以使用 if 语句,但编译器可以更好地优化三元语句,因此使用它更有意义。

➤ 在您的 Mac 和 iPhone 15 Pro Max 模拟器上构建并运行该应用程序。

您是否预料到一半的屏幕是黑色的?视图的宽是 400 点,所以这是合理的。但是您可能没有考虑到一些事情:Apple Retina 显示屏具有不同的像素分辨率或像素密度。例如,MacBook Pro 配备 2 倍 Retina 显示屏,而 iPhone 15 Pro Max 配备 3 倍 Retina 显示屏。这些不同的显示屏意味着 MacBook Pro 上的 400 点, Metal 视图可创建 800x800 像素的可绘制纹理,而 iPhone 视图可创建 1200x1200 像素的可绘制纹理。

您的四边形填满了屏幕,您正在写入视图的可绘制渲染目标纹理(其大小与设备的显示屏相匹配),但没有简单的方法可以在 fragment 函数中找出当前渲染目标纹理的大小。

➤ 打开 Common.h,并添加新的结构体:

Swift 复制代码
typedef struct {
  uint width;
  uint height;
} Params;

此代码包含可发送到 fragment 函数的参数。您可以根据需要向此结构体添加参数。

➤ 打开 Renderer.swift,并向 Renderer 添加一个新属性:

Swift 复制代码
var params = Params()

您将把当前渲染目标大小存储在新属性中。

➤ 将以下代码添加到 mtkView(_:drawableSizeWillChange:) 的末尾:

Swift 复制代码
 params.width = UInt32(size.width)
params.height = UInt32(size.height)

size 包含视图的可绘制纹理大小。换句话说,也就是视图的bounds按设备的比例因子进行缩放后的尺寸。

➤ 在 draw(in:)中调用渲染模型或四边形的方法之前,将参数发送到 fragment 函数:

Swift 复制代码
renderEncoder.setFragmentBytes(
  &params,
  length: MemoryLayout<Params>.stride,
  index: 12)

请注意,您使用 setFragmentBytes(:length:index:)将数据发送到片段函数的方式与之前使用 setVertexBytes(:length:index:)的方式相同。

➤ 打开 Fragment.metal,将 fragment_main 的签名更改为:

Swift 复制代码
 fragment float4 fragment_main(
  constant Params &params [[buffer(12)]],
  VertexOut in [[stage_in]])

具有目标绘图纹理大小的参数现在可用于 fragment 函数。

➤ 将设置 color 值的代码(基于 in.position.x 的值)更改为:

Swift 复制代码
   in.position.x < params.width * 0.5 ? color = 0 : color = 1;

在这里,您将使用目标渲染大小进行计算。

➤ 在 macOS 和 iPhone 15 Pro Max 模拟器中运行该应用程序。

太棒了,现在两种设备的渲染看起来都一样。

Metal标准库函数

除了标准的数学函数(如 sin、abs 和 length)之外,还有一些其他有用的函数。让我们来看看:

step

如果 x 小于 edge,则 step(edge, x) 返回 0。否则,它将返回 1。此评估正是您对当前 fragment 函数执行的操作。

➤ 将 fragment 函数的内容替换为:

Swift 复制代码
 float color = step(params.width * 0.5, in.position.x);
return float4(color, color, color, 1);

此代码生成的结果与以前相同,但代码略少。

➤ 构建并运行。

结果是,左侧为黑色,因为左侧 step 的结果为 0。而右侧为白色,因为右侧step 的结果为 1 。

让我们用棋盘格模式更进一步。

➤ 将 fragment 函数的内容替换为:

Swift 复制代码
uint checks = 8;
// 1
float2 uv = in.position.xy / params.width;
// 2
uv = fract(uv * checks * 0.5) - 0.5;
// 3
float3 color = step(uv.x * uv.y, 0.0);
return float4(color, 1.0);

以下是正在发生的事情:

  1. UV 坐标形成一个值介于 0 和 1 之间的网格。因此,中点位于 [0.5, 0.5],左上角位于 [0.0, 0.0]。UV 坐标通常与将顶点映射到纹理相关联,如第 8 章 "纹理"所示。

  2. fract(x)返回 x 的小数部分。将 UV 的小数值乘以checks值的一半,得到一个介于 0 和 1 之间的值。然后减去 0.5,使一半的值小于零。

  3. 如果 xy 乘法的结果小于零,则结果为 1 或白色。否则,它是 0 或黑色。

例如:

Swift 复制代码
float2 uv = (550, 50) / 800;     // uv = (0.6875, 0.0625)
uv = fract(uv * checks * 0.5);   // uv = (0.75, 0.25)
uv -= 0.5; // uv = (0.25, -0.25)
float3 color = step(uv.x * uv.y, 0.0); // x > -0.0625, so color
is 1

➤ 构建并运行应用程序。

length

创建正方形很有趣,但让我们使用 length 函数创建一些圆。

➤ 将 fragment 函数替换为:

Swift 复制代码
float center = 0.5;
float radius = 0.2;
float2 uv = in.position.xy / params.width - center;
float3 color = step(length(uv), radius);
return float4(color, 1.0);

➤ 构建并运行应用程序。

要调整形状大小并在屏幕上移动形状,请更改圆的中心和半径。

smoothstep

smoothstep(edge0, edge1, x)返回介于 0 和 1 之间的平滑艾米插值。

注意:edge1 必须大于 edge0,x 应该是 edge0 <= x <= edge1。

➤ 将片段函数改为:

Swift 复制代码
 float color = smoothstep(0, params.width, in.position.x);
return float4(color, color, color, 1);

color 包含介于 0 和 1 之间的值。当位置与屏幕宽度相同时,颜色为 0 或白色。当位置位于屏幕的最左侧时,颜色为 0 或黑色。

➤ 构建并运行应用程序。

在两种边缘情况之间,颜色是在黑色和白色之间插值的渐变。在这里,您使用 smoothstep 来计算颜色,但您也可以使用它在任意两个值之间进行插值。例如,您可以使用 smoothstep 为 vertex 函数中的位置设置动画。

mix

mix(x, y, a)产生与 x + (y - x) * a 相同的结果。

➤ 将片段函数更改为:

Swift 复制代码
float3 red = float3(1, 0, 0);
float3 blue = float3(0, 0, 1);
float3 color = mix(red, blue, 0.6);
return float4(color, 1);

混合 0 将产生全红色。混合 1 产生全蓝色。这些颜色共同产生 60% 的红色和蓝色混合。

➤ 构建并运行应用程序。

您可以将混合与 smoothstep 结合使用以产生颜色渐变。

➤ 将 fragment 函数替换为:

Swift 复制代码
float3 red = float3(1, 0, 0);
float3 blue = float3(0, 0, 1);
float result = smoothstep(0, params.width, in.position.x);
float3 color = mix(red, blue, result);
return float4(color, 1);

此代码使用result的插值,将其用作红色和蓝色的混合比例。

➤ 构建并运行应用程序。

normalize

规范化过程是指重新调整数据比例以使用标准范围。例如,向量同时具有 direction 和 magnitude。在下图中,向量 A 的长度为 2.12132,方向为 45 度。向量 B 的长度相同,但方向不同。向量 C 的长度不同,但方向相同。

如果两个向量的大小相同,则更容易比较它们的方向,因此可以将向量标准化为单位长度。normalize(x)返回方向相同但长度为 1 的向量 x。

让我们看看另一个规范化的例子。假设您希望使用颜色可视化顶点位置,以便更好地调试某些代码。

➤ 将片段函数改为:

Swift 复制代码
return in.position;

➤ 构建并运行应用程序。

片段函数应返回每个元素介于 0 和 1 之间的 RGBA 颜色。但是,由于位置位于屏幕空间中,因此每个位置在 [0, 0, 0] 和 [800, 800, 0] 之间变化,这就是四边形呈现黄色的原因(它仅在左上角位于 0 和 1 之间)。

➤ 现在,将代码更改为:

Swift 复制代码
 float3 color = normalize(in.position.xyz);
return float4(color, 1);

在这里,您将向量 in.position.xyz 标准化为长度为 1。现在,所有颜色都保证介于 0 和 1 之间。归一化后,最右上角的位置 (800, 0, 0) 包含红色的 1, 0, 0。

➤ 构建并运行应用程序以查看结果。

法线

尽管可视化位置有助于调试,但通常对创建 3D 渲染没有帮助。但是,找到三角形的朝向对于着色很有用,而着色器正是法线发挥作用的地方。法线是表示顶点或表面朝向的向量。在下一章中,您将学习如何为模型增加光照。但首先,您需要了解法线。

从 Blender 捕获的以下图像显示了指向的顶点法线。球体的每个顶点都指向不同的方向。

球体的着色取决于这些法线。如果法线指向光源,则 Blender 将更亮。

四边形对于着色目的不是很有趣,因此请将默认渲染切换到火车。

➤ 打开 Options.swift,并将 renderChoice 的初始化更改为:

Swift 复制代码
var renderChoice = RenderChoice.train

➤ 运行应用程序以检查您的火车渲染。

与全屏四边形不同,只有火车覆盖的片段才会显示。但是,每个片段的颜色仍然取决于片元的屏幕位置,而不是火车顶点的位置。

加载带法线的火车模型

3D模型文件通常包含表面法线值,您可以和模型一起加载这些值。如果您的文件不包含Surface Formals,则Model I/O可以使用MDLMesh的addNormals(withAttributeNamed:creaseThreshold:),在导入时生成它们。

为顶点描述器增加法线

➤ 打开 VertexDescriptor.swift。

目前,您只加载 position 属性。是时候将 normal 添加到顶点描述符。

➤ 在设置 offset 的代码之后,在设置 layouts[0] 的代码之前,将以下代码添加到 MDLVertexDescriptor 的 defaultLayout:

Swift 复制代码
vertexDescriptor.attributes[1] = MDLVertexAttribute(
  name: MDLVertexAttributeNormal,
  format: .float3,
  offset: offset,
  bufferIndex: 0)
offset += MemoryLayout<float3>.stride

这里,法线类型是 float3,并在缓冲区 0 中和position交错放置。float3 是在 MathLibrary.swift 中定义的 SIMD3<Float> 类型的别名。每个顶点在索引0缓冲区中占用两个 float3,即 32 字节。layouts[0] 描述带有 stride 的索引0缓冲区。

更新 Shader 函数

➤ 打开 Vertex.metal。

火车模型的管线状态使用此顶点描述符,以便顶点函数可以处理属性,并将这些属性与 VertexIn中的属性匹配。

➤ 构建并运行应用程序,您会发现一切仍然按预期工作。即使您向顶点缓冲区添加了新属性,管线也会忽略它。

因为您尚未将其作为attribute(n)包含在 VertexIn 中。是时候解决这个问题了。

➤ 在 VertexIn 中添加以下代码:

Swift 复制代码
float3 normal [[attribute(1)]];

在这里,您将 attribute(1) 与顶点描述符的属性 1 匹配。现在你将能够访问 vertex 函数中的 normal 属性。

➤ 接下来,将以下代码添加到 VertexOut 中:

Swift 复制代码
float3 normal;

通过在此处包含 normal,您现在可以将数据传递给 fragment 函数。

➤ 在 vertex_main 中,将赋值更改为 out:

Swift 复制代码
VertexOut out {
  .position = position,
  .normal = in.normal
};

完美!通过该更改,您现在可以从 vertex 函数返回位置和法线。

➤ 打开 Fragment.metal,将 fragment_main 的内容替换为:

Swift 复制代码
return float4(in.normal, 1);

别担心,编译错误是意料之中的。即使您在 Vertex.metal 中更新了 VertexOut,该结构体的作用域也仅在该文件中。

添加头文件

在多个着色器文件中需要结构体和函数是很常见的。因此,就像您对 Swift 和 Metal 之间的桥接头文件 Common.h 所做的那样,您可以添加其他头文件并将它们导入到着色器文件中。

➤ 使用 macOS 头文件模板在 Shaders 组中创建一个新文件,并将其命名为 ShaderDefs.h。

➤ 将代码替换为:

Swift 复制代码
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
  float4 position [[position]];
  float3 normal;
};

在这里,您可以在 metal 命名空间中定义 VertexOut。

➤ 打开 Vertex.metal,并删除 VertexOut 结构。

➤ 导入 Common.h 后,添加:

Swift 复制代码
   #import "ShaderDefs.h"

➤ 打开 Fragment.metal,并删除 VertexOut 结构。

➤ 同样,在导入 Common.h 后,添加:

Swift 复制代码
#import "ShaderDefs.h"

➤ 构建并运行应用程序。

哦,现在看起来有点奇怪!

您的法线看起来好像显示正确 --- 红色法线位于火车的右侧,绿色法线向上,蓝色位于后面 --- 但随着火车旋转,它的某些部分看起来几乎是透明的。

这里的问题是光栅器会混淆顶点的深度顺序。当你从前面看火车时,你不应该能看到火车的后面;它应该被遮挡。

深度

光栅器默认情况下不会处理深度顺序,因此您需要以深度模板状态为光栅器提供所需的信息。

您可能还记得第3章"渲染管道",模板测试单元检查渲染管道期间片段是否可见。如果确定片段在另一个片段后面,则将其丢弃。

让我们给渲染编码器一个MTLDepthStencilState属性,以描述如何进行此测试。

➤打开Renderer.swift。

➤在init(metalView:options:)结束之前,设置metalView.clearColor之后,添加:

Swift 复制代码
metalView.depthStencilPixelFormat = .depth32Float

该代码告诉Metal View,您需要保留深度信息。默认的像素格式为.invalid,它告知视图不需要创建深度和模板纹理。

渲染命令编码器使用的管线状态必须具有相同的深度像素格式。

➤在init(metalView:options:)设置PipelinedEscriptor.colorattachments [0] .pixelformat之后,在do {之前添加:

Swift 复制代码
   pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float

如果您现在要构建并运行该应用程序,那么您将获得与以前相同的结果。但是,在幕后,视图创建了纹理,光栅器可以在该纹理上写入深度值。

接下来,您需要设置希望光栅器计算深度值的方式。

➤向渲染器添加新属性:

Swift 复制代码
let depthStencilState: MTLDepthStencilState?

该属性具有正确的渲染设置,使其具有深度模板状态。

➤ 在 Renderer 中创建此方法以实例化深度模板状态:

Swift 复制代码
static func buildDepthStencilState() -> MTLDepthStencilState? {
// 1
  let descriptor = MTLDepthStencilDescriptor()
// 2
  descriptor.depthCompareFunction = .less
// 3
  descriptor.isDepthWriteEnabled = true
  return Renderer.device.makeDepthStencilState(
    descriptor: descriptor)
}

浏览这段代码:

  1. 创建一个描述符,用于初始化深度模板状态,就像您对管道状态对象所做的那样。

  2. 指定如何比较当前和已处理的片段。使用 compare 函数 less 时,如果当前片段深度小于帧缓冲区中前一个片段的深度,则当前片段将替换前一个片段。

  3. 说明是否写入深度值。如果您有多个通道,如第 12 章 "渲染通道"中所述,有时您需要读取已绘制的片段。在这种情况下,请将 isDepthWriteEnabled 设置为 false。请注意,当您绘制需要深度的对象时,isDepthWriteEnabled 始终为 true。

➤ 在 super.init() 之前从 init(metalView:options:) 调用方法:

Swift 复制代码
depthStencilState = Renderer.buildDepthStencilState()

➤ 在 draw(in:) 中,将以下内容添加到方法顶部的 guard { } 之后:

Swift 复制代码
renderEncoder.setDepthStencilState(depthStencilState)

➤ 构建并运行应用程序,以光彩夺目的 3D 形式查看您的火车。

当火车旋转时,它会以红色、绿色、蓝色和黑色的阴影出现。

考虑一下你在这个渲染中看到的内容。法线当前位于对象空间中。因此,即使火车在世界空间中旋转,颜色/法线也不会随着模型旋转的改变而改变。

当法线沿模型的 x 轴指向右侧时,值为 [1, 0, 0]。这与 RGB 值中的红色相同,因此对于指向右侧的法线,片段为红色。

指向上方的法线在 y 轴上为 1,因此颜色为绿色。

指向摄像机的法线为负数。当颜色为 [0, 0, 0] 或更小时,它们为黑色。当你看到火车旋转的后部时,你可以看出指向 z 方向的车轮后部是蓝色的 [0, 0, 1]。

现在,您在 fragment 函数中拥有了法线,您可以根据颜色的朝向开始操作颜色。当您开始使用光照时,操纵颜色非常重要。

半球光照

半球照明使用环境光。使用这种类型的照明,场景的一半使用一种颜色照明,另一半使用另一种颜色照明。例如,下图中的球体使用半球照明。

请注意球体如何呈现从天空反射的颜色(顶部)和从地面反射的颜色(底部)。要查看这种类型的光照效果,您需要更改 fragment 函数,以便:

• 朝上的法线为蓝色。

• 朝下的法线为绿色。

• 过渡值为蓝色和绿色混合。

➤ 打开 Fragment.metal,并将 fragment_main 的内容替换为:

Swift 复制代码
float4 sky = float4(0.34, 0.9, 1.0, 1.0);
float4 earth = float4(0.29, 0.58, 0.2, 1.0);
float intensity = in.normal.y * 0.5 + 0.5;
return mix(earth, sky, intensity);

mix(x, y, z) 根据第三个值在前两个值之间进行插值,第三个值必须介于 0 和 1 之间。您的正常值介于 -1 和 1 之间,因此您可以在 0 和 1 之间转换强度。

➤ 构建并运行应用程序以查看您闪亮的火车。请注意,火车的顶部是蓝色的,而它的底部是绿色的。

片段着色器非常强大,允许您精确地为对象着色。在第 10 章 "光照基础知识"中,您将使用法线的力量为场景提供更逼真的光照着色。在第19章"镶嵌与地形"中,你将创建一个与此类似的效果,学习如何根据坡度在地形上放置雪。

挑战

目前,您正在对所有缓冲区索引和属性使用硬编码的魔数。随着应用程序的增长,跟踪这些数字将变得越来越困难。所以,你在本章中的挑战是寻找所有这些神奇的数字,并为它们起一个令人难忘的名字。对于此挑战,您将在 Common.h 中创建一个枚举。

以下是一些可帮助您入门的代码:

Swift 复制代码
typedef enum {
  VertexBuffer = 0,
  UniformsBuffer = 11,
  ParamsBuffer = 12
} BufferIndices;

现在,您可以在 Swift 和 C++ 着色器函数中使用这些常量:

Swift 复制代码
//Swift
encoder.setVertexBytes(
  &uniforms,
  length: MemoryLayout<Uniforms>.stride,
  index: Int(UniformsBuffer.rawValue))
Swift 复制代码
// Shader Function
vertex VertexOut vertex_main(
  const VertexIn in [[stage_in]],
  constant Uniforms &uniforms [[buffer(UniformsBuffer)]])

您甚至可以在 VertexDescriptor.swift 中添加扩展来美化代码:

Swift 复制代码
extension BufferIndices {
  var index: Int {
    return Int(self.rawValue)
  }
}

使用此代码,您可以使用 UniformsBuffer.index 而不是 Int(UniformsBuffer.rawValue)。

您可以在本章的 challenge 文件夹中找到完整的解决方案。

相关推荐
西西弗Sisyphus2 小时前
将用于 Swift 微调模型的 JSON Lines(JSONL)格式数据集,转换为适用于 Qwen VL 模型微调的 JSON 格式
swift·qwen3
Digitally3 小时前
将联系人添加到iPhone的8种有效方法
ios·iphone
Digitally3 小时前
如何在没有 iCloud 的情况下备份 iPhone
ios·iphone·icloud
Dashing5 小时前
KN:Kotlin 与 OC 交互
ios·kotlin
黄毛火烧雪下6 小时前
创建一个ios小组件项目
ios
songgeb6 小时前
🧩 iOS DiffableDataSource 死锁问题记录
ios·swift
2501_929157689 小时前
「IOS苹果游戏」600个
游戏·ios
00后程序员张9 小时前
iOS 26 App 运行状况全面解析 多工具协同监控与调试实战指南
android·ios·小程序·https·uni-app·iphone·webview
大熊猫侯佩10 小时前
【大话码游之 Observation 传说】上集:月光宝盒里的计数玄机
swiftui·swift·weak·observable·self·引用循环·observations
白玉cfc10 小时前
【iOS】KVC 与 KVO 的基本了解与使用
macos·ios·objective-c·cocoa