知道如何通过将顶点数据发送到 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 函数内容改为:
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,并添加新的结构体:
typedef struct {uint width;uint height;
} Params;
此代码包含可发送到 fragment 函数的参数。您可以根据需要向此结构体添加参数。
➤ 打开 Renderer.swift,并向 Renderer 添加一个新属性:
var params = Params()
您将把当前渲染目标大小存储在新属性中。
➤ 将以下代码添加到 mtkView(_:drawableSizeWillChange:) 的末尾:
params.width = UInt32(size.width)
params.height = UInt32(size.height)
size 包含视图的可绘制纹理大小。换句话说,也就是视图的bounds按设备的比例因子进行缩放后的尺寸。
➤ 在 draw(in:)中调用渲染模型或四边形的方法之前,将参数发送到 fragment 函数:
renderEncoder.setFragmentBytes(¶ms,length: MemoryLayout<Params>.stride,index: 12)
请注意,您使用 setFragmentBytes(_:length:index:)将数据发送到片段函数的方式与之前使用 setVertexBytes(_:length:index:)的方式相同。
➤ 打开 Fragment.metal,将 fragment_main 的签名更改为:
fragment float4 fragment_main(constant Params ¶ms [[buffer(12)]],VertexOut in [[stage_in]])
具有目标绘图纹理大小的参数现在可用于 fragment 函数。
➤ 将设置 color 值的代码(基于 in.position.x 的值)更改为:
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 函数的内容替换为:
float color = step(params.width * 0.5, in.position.x);
return float4(color, color, color, 1);
此代码生成的结果与以前相同,但代码略少。
➤ 构建并运行。
结果是,左侧为黑色,因为左侧 step 的结果为 0。而右侧为白色,因为右侧step 的结果为 1 。
让我们用棋盘格模式更进一步。
➤ 将 fragment 函数的内容替换为:
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 或黑色。
例如:
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 函数替换为:
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。
➤ 将片段函数改为:
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 相同的结果。
➤ 将片段函数更改为:
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 函数替换为:
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。
让我们看看另一个规范化的例子。假设您希望使用颜色可视化顶点位置,以便更好地调试某些代码。
➤ 将片段函数改为:
return in.position;
➤ 构建并运行应用程序。
片段函数应返回每个元素介于 0 和 1 之间的 RGBA 颜色。但是,由于位置位于屏幕空间中,因此每个位置在 [0, 0, 0] 和 [800, 800, 0] 之间变化,这就是四边形呈现黄色的原因(它仅在左上角位于 0 和 1 之间)。
➤ 现在,将代码更改为:
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 的初始化更改为:
var renderChoice = RenderChoice.train
➤ 运行应用程序以检查您的火车渲染。
与全屏四边形不同,只有火车覆盖的片段才会显示。但是,每个片段的颜色仍然取决于片元的屏幕位置,而不是火车顶点的位置。
加载带法线的火车模型
3D模型文件通常包含表面法线值,您可以和模型一起加载这些值。如果您的文件不包含Surface Formals,则Model I/O可以使用MDLMesh的addNormals(withAttributeNamed:creaseThreshold:),在导入时生成它们。
为顶点描述器增加法线
➤ 打开 VertexDescriptor.swift。
目前,您只加载 position 属性。是时候将 normal 添加到顶点描述符。
➤ 在设置 offset 的代码之后,在设置 layouts[0] 的代码之前,将以下代码添加到 MDLVertexDescriptor 的 defaultLayout:
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 中添加以下代码:
float3 normal [[attribute(1)]];
在这里,您将 attribute(1) 与顶点描述符的属性 1 匹配。现在你将能够访问 vertex 函数中的 normal 属性。
➤ 接下来,将以下代码添加到 VertexOut 中:
float3 normal;
通过在此处包含 normal,您现在可以将数据传递给 fragment 函数。
➤ 在 vertex_main 中,将赋值更改为 out:
VertexOut out {.position = position,.normal = in.normal
};
完美!通过该更改,您现在可以从 vertex 函数返回位置和法线。
➤ 打开 Fragment.metal,将 fragment_main 的内容替换为:
return float4(in.normal, 1);
别担心,编译错误是意料之中的。即使您在 Vertex.metal 中更新了 VertexOut,该结构体的作用域也仅在该文件中。
添加头文件
在多个着色器文件中需要结构体和函数是很常见的。因此,就像您对 Swift 和 Metal 之间的桥接头文件 Common.h 所做的那样,您可以添加其他头文件并将它们导入到着色器文件中。
➤ 使用 macOS 头文件模板在 Shaders 组中创建一个新文件,并将其命名为 ShaderDefs.h。
➤ 将代码替换为:
#include <metal_stdlib>
using namespace metal;
struct VertexOut {float4 position [[position]];float3 normal;
};
在这里,您可以在 metal 命名空间中定义 VertexOut。
➤ 打开 Vertex.metal,并删除 VertexOut 结构。
➤ 导入 Common.h 后,添加:
#import "ShaderDefs.h"
➤ 打开 Fragment.metal,并删除 VertexOut 结构。
➤ 同样,在导入 Common.h 后,添加:
#import "ShaderDefs.h"
➤ 构建并运行应用程序。
哦,现在看起来有点奇怪!
您的法线看起来好像显示正确 — 红色法线位于火车的右侧,绿色法线向上,蓝色位于后面 — 但随着火车旋转,它的某些部分看起来几乎是透明的。
这里的问题是光栅器会混淆顶点的深度顺序。当你从前面看火车时,你不应该能看到火车的后面;它应该被遮挡。
深度
光栅器默认情况下不会处理深度顺序,因此您需要以深度模板状态为光栅器提供所需的信息。
您可能还记得第3章“渲染管道”,模板测试单元检查渲染管道期间片段是否可见。如果确定片段在另一个片段后面,则将其丢弃。
让我们给渲染编码器一个MTLDepthStencilState属性,以描述如何进行此测试。
➤打开Renderer.swift。
➤在init(metalView:options:)结束之前,设置metalView.clearColor之后,添加:
metalView.depthStencilPixelFormat = .depth32Float
该代码告诉Metal View,您需要保留深度信息。默认的像素格式为.invalid,它告知视图不需要创建深度和模板纹理。
渲染命令编码器使用的管线状态必须具有相同的深度像素格式。
➤在init(metalView:options:)设置PipelinedEscriptor.colorattachments [0] .pixelformat之后,在do {之前添加:
pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float
如果您现在要构建并运行该应用程序,那么您将获得与以前相同的结果。但是,在幕后,视图创建了纹理,光栅器可以在该纹理上写入深度值。
接下来,您需要设置希望光栅器计算深度值的方式。
➤向渲染器添加新属性:
let depthStencilState: MTLDepthStencilState?
该属性具有正确的渲染设置,使其具有深度模板状态。
➤ 在 Renderer 中创建此方法以实例化深度模板状态:
static func buildDepthStencilState() -> MTLDepthStencilState? {
// 1let descriptor = MTLDepthStencilDescriptor()
// 2descriptor.depthCompareFunction = .less
// 3descriptor.isDepthWriteEnabled = truereturn Renderer.device.makeDepthStencilState(descriptor: descriptor)
}
浏览这段代码:
1. 创建一个描述符,用于初始化深度模板状态,就像您对管道状态对象所做的那样。
2. 指定如何比较当前和已处理的片段。使用 compare 函数 less 时,如果当前片段深度小于帧缓冲区中前一个片段的深度,则当前片段将替换前一个片段。
3. 说明是否写入深度值。如果您有多个通道,如第 12 章 “渲染通道”中所述,有时您需要读取已绘制的片段。在这种情况下,请将 isDepthWriteEnabled 设置为 false。请注意,当您绘制需要深度的对象时,isDepthWriteEnabled 始终为 true。
➤ 在 super.init() 之前从 init(metalView:options:) 调用方法:
depthStencilState = Renderer.buildDepthStencilState()
➤ 在 draw(in:) 中,将以下内容添加到方法顶部的 guard { } 之后:
renderEncoder.setDepthStencilState(depthStencilState)
➤ 构建并运行应用程序,以光彩夺目的 3D 形式查看您的火车。
当火车旋转时,它会以红色、绿色、蓝色和黑色的阴影出现。
考虑一下你在这个渲染中看到的内容。法线当前位于对象空间中。因此,即使火车在世界空间中旋转,颜色/法线也不会随着模型旋转的改变而改变。
当法线沿模型的 x 轴指向右侧时,值为 [1, 0, 0]。这与 RGB 值中的红色相同,因此对于指向右侧的法线,片段为红色。
指向上方的法线在 y 轴上为 1,因此颜色为绿色。
指向摄像机的法线为负数。当颜色为 [0, 0, 0] 或更小时,它们为黑色。当你看到火车旋转的后部时,你可以看出指向 z 方向的车轮后部是蓝色的 [0, 0, 1]。
现在,您在 fragment 函数中拥有了法线,您可以根据颜色的朝向开始操作颜色。当您开始使用光照时,操纵颜色非常重要。
半球光照
半球照明使用环境光。使用这种类型的照明,场景的一半使用一种颜色照明,另一半使用另一种颜色照明。例如,下图中的球体使用半球照明。
请注意球体如何呈现从天空反射的颜色(顶部)和从地面反射的颜色(底部)。要查看这种类型的光照效果,您需要更改 fragment 函数,以便:
• 朝上的法线为蓝色。
• 朝下的法线为绿色。
• 过渡值为蓝色和绿色混合。
➤ 打开 Fragment.metal,并将 fragment_main 的内容替换为:
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 中创建一个枚举。
以下是一些可帮助您入门的代码:
typedef enum {VertexBuffer = 0,UniformsBuffer = 11,ParamsBuffer = 12
} BufferIndices;
现在,您可以在 Swift 和 C++ 着色器函数中使用这些常量:
//Swift
encoder.setVertexBytes(&uniforms,length: MemoryLayout<Uniforms>.stride,index: Int(UniformsBuffer.rawValue))
// Shader Function
vertex VertexOut vertex_main(const VertexIn in [[stage_in]],constant Uniforms &uniforms [[buffer(UniformsBuffer)]])
您甚至可以在 VertexDescriptor.swift 中添加扩展来美化代码:
extension BufferIndices {var index: Int {return Int(self.rawValue)}
}
使用此代码,您可以使用 UniformsBuffer.index 而不是 Int(UniformsBuffer.rawValue)。
您可以在本章的 challenge 文件夹中找到完整的解决方案。