今天我想更深入地了解一下Metal中的渲染管线。我们将探讨如何制作出引人入胜的溶解特效,同时了解Metal渲染管线的基础知识。
通过这个实际的例子,我们将了解片段着色器(fragment shader)、噪声函数(noise function)和 alpha 阈值(alpha threshold)如何协同工作来创造吸引人的视觉效果。
注意,有剧透哦
如果你等不及要看最终结果,我在文章最后放了一个 gist,源代码在 gist 里。你可以拿去实验。
理论部分首先,让我们稍微熟悉一下溶解效果是如何工作的以及我们需要做什么。每个片段的 alpha 值会通过一个简单的阶梯函数来确定。我们将根据片段的 UV 坐标生成一些噪声函数的随机值,并检查这些值是否超过了某个门槛。如果超过了这个门槛,片段就是可见的,否则就是不可见的。
可见度阈值是一个在动画过程中会变化的变量。表示溶解过程的变量不随时间变化,而是依赖于到下一个顶点的距离(我们稍后再来讨论这一点)。
特效分解
乍一看,这可能会有点让人困惑,让我们通过一个小例子来拆解一下这个逻辑。
首先,让我们来强调单独列出计算 alpha 组件的公式:
_alpha = 如果噪音 > (阈值 - 进度),那么 1.0,否则 0.0_
纹理中随机选取两个点,一个不可见,另一个可见。对于这两个点以及其他点而言,可见性阈值相同(因为它依赖于时间,而不是点的位置)。但是,溶解进度取决于点的位置,而不是时间。噪声参数可以是任何值,对于这些特定的点,如图所示。
嗯,还是这样比较好,最好将溶解进度称为溶解延迟时间,但我们保持现状。
计算例子
接下来,我们将计算每个点的alpha值。对于p1,计算如下:(此处alpha指特定的参数)
alpha = 0.2 > 0.5 - 0.2 ? 1.0 : 0.0; alpha = 0.0
或者更自然地表达为:
如果0.2大于0.5减去0.2,那么alpha等于1.0,否则等于0.0;alpha再被赋值为0.0。
p2是这样的:
alpha 等于 0.9 大于 0.5 减去 0.7 吗?如果是,那么 alpha 就等于 1.0,否则 alpha 就等于 0.0,这里是说 alpha 最终等于 1.0。
这些计算针对每个片段进行,以确定其可见性。当动画接近尾声时,越来越多的像素点将进入噪声参数为零透明度的区域。
这里再举一个这些概念的例子。正如我之前所说,所谓的进步其实更像是延误。无论如何,你可以认为进步决定了噪音函数生成的值能映射到多少可见部分上,这要根据当前的阈值来判断。
范围内的计算
理论也讲够了,现在咱们可以动手开始写代码吧。
准备让我们从定义负责所有渲染工作的渲染器类型开始(哈哈,lmao)。
import MetalKit
final class DissolveRenderer: NSObject {
init?(mtkView: MTKView) {}
}
注:此代码片段无需翻译,因为它已经是通用格式。
在我们继续前进之前,让我们先添加一个视图包装器的模板,这样我们就能看看中间的结果了。
import SwiftUI
/// 溶解视图控制器的实现
final class DissolveViewController: UIViewController {
private lazy var metalView = MTKView()
private var renderer: DissolveRenderer! // 溶解渲染器
override func loadView() {
view = metalView
}
override func viewDidLoad() {
super.viewDidLoad()
renderer = DissolveRenderer(mtkView: metalView) // 使用metalView创建DissolveRenderer
}
}
/// 溶解视图的表示
struct DissolveView: UIViewControllerRepresentable { // UIViewController可表示
/// 创建UIViewController
func makeUIViewController(
context: Context
) -> some UIViewController {
DissolveViewController() // 溶解视图控制器
}
/// 更新UIViewController
func updateUIViewController(
_ uiViewController: UIViewControllerType,
context: Context
) {}
}
/// 预览溶解视图
#Preview {
DissolveView()
.scaledToFit()
}
同样地,我们需要为Metal创建一个所需的环境:设备、队列、着色器程序。
最终 class DissolveRenderer: NSObject {
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
init?(mtkView: MTKView) {
guard
let device = MTLCreateSystemDefaultDevice(),
let queue = device.makeCommandQueue(),
let library = device.makeDefaultLibrary(),
let vertexFunc = library.makeFunction(name: "顶点着色器"),
let fragmentFunc = library.makeFunction(name: "片段着色器")
else { return nil }
self.device = device
mtkView.device = device
commandQueue = queue
}
}
在我们编写动画的过程中,我们会参考我之前的文章,该文章主要讨论了如何使用计算着色器以及如何设置MetalKit的代码(特别是数据传输和状态设置的部分)。
用 SwiftUI 和 Metal 做一个带涟漪和发光效果的交互元素https://medium.com/sparkling-shiny-things-with-metal-and-swiftui-cba69c730a24?source=post_page-----2b4de9b3d467--------------------------------
为了建立渲染管线,我们首先需要创建一个渲染状态。接着,我们需要定义一个渲染描述符来创建渲染状态。我们将启用混合,这样我们就能看到颜色 alpha 分量变化的效果。
final class DissolveRenderer: NSObject {
...
private let pipelineState: MTLRenderPipelineState
init?(mtkView: MTKView) {
...
// 初始化管道描述符
let pipelineDescriptor = MTLRenderPipelineDescriptor()
// 设置顶点函数和片段函数
pipelineDescriptor.vertexFunction = vertexFunc
pipelineDescriptor.fragmentFunction = fragmentFunc
// 设置颜色附件的像素格式
pipelineDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
// 启用混合并设置混合因子
pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
do {
// 创建渲染管道状态
pipelineState = try device.makeRenderPipelineState(
descriptor: pipelineDescriptor
)
} catch {
// 错误处理
return nil
}
}
}
别忘了给 MTKView
指定一个代理,这样我们就能处理绘图逻辑了。
最终类 DissolveRenderer: NSObject {
...
private let 渲染管线状态: MTLRenderPipelineState
init?(mtkView: MTKView) {
...
super.init()
mtkView.delegate = self
}
}
扩展 DissolveRenderer: MTKView代理 {
// 绘制方法,根据传入的视图进行绘制
func draw(in view: MTKView) {}
// 当可绘制大小将要改变时调用的方法
func mtkView(
_ view: MTKView,
drawableSizeWillChange size: CGSize
) {}
}
注:顶点
在渲染管线中,顶点表示一个包含绘制或渲染所需信息的实例。我们关心的是颜色、位置和进度。我们定义颜色为RGBA,其取值范围是从0.0到1.0。
说实话,我们会经常遇到或处理这个 0.0到1.0 的范围。
顶点的排布
每个顶点的位置都是用齐次坐标定义的,这一概念被用于投影几何学中。因为我们是在一个平面上工作,所以顶点的z
和w
参数在整个平面上是恒定的。
你可以在这里了解更多关于射影几何的知识:
解释齐次坐标及射影几何在这篇文章里,我会尽量简单地解释齐次坐标,也就是4D坐标。在之前的文章中……www.tomdalling.com为了不让事情变得复杂,我们将顶点数据表示为浮点数数组。为了让Metal能够理解这些数据,我们将这些数据包装成一个 MTLBuffer
实例。
最终类 DissolveRenderer: NSObject {
...
private var vertexBuffer: MTLBuffer!
private var 顶点数量 = 0
init?(mtkView: MTKView) {
...
// xyzw, 进度, rgba
let vertexData: [Float] = [
// 第一个三角形
-1.0, 1.0, 0.0, 1.0, 0.0, 0.9176470588, 0.2235294118, 0.3921568627, 1,
1.0, 1.0, 0.0, 1.0, 0.0, 0.1058823529, 0.5019607843, 0.9490196078, 1,
-1.0, -1.0, 0.0, 1.0, 1.0, 0.4117647059, 0.8352941176, 0.8745098039, 1,
// 第二个三角形
-1.0, -1.0, 0.0, 1.0, 1.0, 0.4117647059, 0.8352941176, 0.8745098039, 1,
1.0, 1.0, 0.0, 1.0, 0.0, 0.1058823529, 0.5019607843, 0.9490196078, 1,
1.0, -1.0, 0.0, 1.0, 1.0, 0.3882352941, 0.3882352941, 0.8431372549, 1,
]
顶点数量 = vertexData.count / 9
vertexBuffer = device.makeBuffer(
bytes: vertexData,
length: MemoryLayout<Float>.stride * vertexData.count
)
}
}
你可能已经注意到,我们列出了六组坐标、进度和颜色的组合,而不是通常预期的四组。这里有一张图来解释为什么是六组而不是四组。
最终,Metal 使用基本图形来渲染任何内容。其中一种基本图形是一个三角形,由三个顶点组成。在这种情况下,我们想绘制一个矩形,它可以通过两个三角形来定义。为了描述这两个三角形,我们创建了 6 个顶点 — 每个三角形三个顶点。
绘制基本形状
你或许知道,渲染包括几个部分:顶点处理、光栅化过程和片段处理。我们刚刚处理了顶点数据。
栅格化是自动完成的,其核心是计算要显示的像素及其周围的值插值。因此,你在图片中看到的每一个小矩形都是一个像素或片段。但请不要混淆,这些片段并不是顶点。到了这一步,顶点基本上已经不再重要了,因为这些值已经被计算并插值得到了每个像素。
如果你对插值不太熟悉,简单来说,插值就是一种通过一组数据来估算近似值的数学方法。比如,在我们的例子中,我们在处理的是动画进度在顶点之间从0.0到1.0的插值。除了进度外,我们还会得到颜色值的插值(混合)。
位图化材质
在这份 Apple 的指南中详细介绍了渲染管线的概念,包括顶点和光栅化过程,我强烈推荐大家阅读。
[使用渲染管线绘制基本图形 | Apple 开发者文档绘制一个简单的二维三角形
](https://developer.apple.com/documentation/metal/using_a_render_pipeline_to_render_primitives?source=post_page-----2b4de9b3d467--------------------------------)
让我们继续准备片段着色器程序,为了发送渲染指令,我们需要获取一个渲染命令编码器的实例。
func draw(in view: MTKView) {
guard
let drawable = view.currentDrawable,
let commandBuffer = commandQueue.makeCommandBuffer(),
let descriptor = view.currentRenderPassDescriptor,
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
else { return }
}
接下来我们来定义计算可见性限制。它的变化将与帧率同步。
final class DissolveRenderer: NSObject {
...
private var visibilityThreshold: Float = 0.0
private var timer: Float = 0.0
var duration: Float = 2.0
}
extension DissolveRenderer: MTKViewDelegate {
func draw(in view: MTKView) {
...
timer += 1.0 / (Float(view.preferredFramesPerSecond) * duration)
visibilityThreshold = Float(timer).truncatingRemainder(dividingBy: 2.0)
}
...
}
接下来就是把渲染状态编码了。设置索引值的方式和可编程着色器一样:我们用索引设置一个参数,然后在着色器中用同样的索引和类型去读取它。
注意这里的 drawPrimitives
调用,我们指定了所需的图元类型以及绘制它们所需的顶点数量。
extension DissolveRenderer: MTKViewDelegate {
func draw(in view: MTKView) {
...
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(
vertexBuffer,
offset: 0,
index: 0
)
renderEncoder.setFragmentBytes(
&visibilityThreshold,
length: MemoryLayout<Float>.stride,
index: 1
)
renderEncoder.drawPrimitives(
type: .triangle,
vertexStart: 0,
vertexCount: vertexCount
)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
...
}
看起来一切都挺好的。这里唯一缺的就是着色器,那我们来创建一下吧。
着色器(Shader)首先创建一个新的 .metal
文件并定义顶点数据结构。[[position]]
参数帮助我们告诉 Metal,这个参数应该包含每个像素坐标的值,片段函数会调用的每个像素。
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float 进度值;
float4 color;
};
在我们的例子中,顶点函数仅用于创建这种结构的一个实例。参数 vertexID
用来索引每个处理顶点,我们可以利用它来获取通过缓冲区传递的数据。
正如我之前所说,我们不会通过添加额外的Metal模板代码来使数据集的表示复杂化。因此,在处理顶点数据时,我们需要自己去获取数据。
索引计算的数学原理非常简单:数据被分成每9个元素一组的子组。为了获得下一个子组,我们将顶点索引乘以9(即子组的大小)。我们通过添加偏移量来从子组中获取特定数据。因此,坐标位于偏移量0到3,进度位于偏移量4,而颜色分量位于偏移量5到8。
vertex VertexOut vertexShader(
uint vertexID [[vertex_id]],
constant float* vertices [[buffer(0)]]
) {
VertexOut out;
// 定义顶点位置
out.position = float4(
vertices[vertexID * 9 + 0],
vertices[vertexID * 9 + 1],
vertices[vertexID * 9 + 2],
vertices[vertexID * 9 + 3]
);
// 定义进度
out.progress = vertices[vertexID * 9 + 4];
// 定义颜色
out.color = float4(
vertices[vertexID * 9 + 5],
vertices[vertexID * 9 + 6],
vertices[vertexID * 9 + 7],
vertices[vertexID * 9 + 8]
);
return out;
}
让我们加一个片段功能吧。正如你看到的,它确实按照我们之前讨论的那样,根据噪声、可见度阈值和片段进度值计算出alpha值。
片段着色器 float4 fragmentShader(
VertexOut in [[stage_in]],
常量 float &visibilityThreshold [[buffer(1)]]
) {
// uv 表示纹理坐标
float2 uv = in.position.xy;
// _delayedAge 表示延迟年龄,是 visibilityThreshold 减去 in.progress 的结果
float _delayedAge = visibilityThreshold - in.progress;
// _noise 计算纹理坐标的噪声值
float _noise = noise(uv * 0.1);
// _alpha 计算 alpha 值,使用 step 函数比较 _delayedAge 和 _noise
float _alpha = step(_delayedAge, _noise);
// 返回颜色和 alpha 值
return float4(in.color.rgb, _alpha);
}
我们将补全缺失的噪声函数。通常来说,它是效果工作的基础模式。尝试用不同的噪声函数替换,你会发现不同的结果。之后可以尝试用不同的方法生成噪声,真的非常有趣。
float rand(float2 n) {
return fract(sin(dot(n, n)) * length(n));
}
float noise(float2 n) {
const float2 d = float2(0.0, 1.0);
float2 b = floor(n);
float2 f = smoothstep(float2(0.0), float2(1.0), fract(n));
return mix(
mix(rand(b), rand(b + d.yx), f.x),
mix(rand(b + d.xy), rand(b + d.yy), f.x),
f.y
);
}
在我们的示例中,噪声函数选择一个小矩形区域(不一定是单一的片段),并用从0.0到1.0的多种噪声值多次标记该区域。当可视范围扩大时,靠近边缘的部分会逐渐消失。
你可以通过将噪声函数的参数乘以一个因子来控制这些切片的规模。因子越小,矩形越大,反之亦然。例如,我们使用 uv * 0.1
,这会生成明显且易于区分的矩形。
噪音函数分析
运行一下代码,确保动画能正常播放。
最后结果
结论部分在文章标题中提到SwiftUI是为了吸引点击量,但实际上99%的操作都在MetalKit完成的。不过结果确实令人满意。希望我解释渲染的方法能帮助你不再害怕使用Metal,并激励你创作出伟大作品。
这里是在文章中提到的源代码的Gist链接。如果你喜欢这篇文章,不妨给它几个掌声,让更多的人看到它。我还会不断解析Metal相关的工作并进行实验,。
谢谢大家的阅读,很快见!
共同学习,写下你的评论
评论加载中...
作者其他优质文章