继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

用Metal和SwiftUI实现溶解特效讲解

守候你守候我
关注TA
已关注
手记 247
粉丝 14
获赞 36

今天我想更深入地了解一下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 的范围。

顶点的排布

每个顶点的位置都是用齐次坐标定义的,这一概念被用于投影几何学中。因为我们是在一个平面上工作,所以顶点的zw参数在整个平面上是恒定的。

你可以在这里了解更多关于射影几何的知识:

解释齐次坐标及射影几何在这篇文章里,我会尽量简单地解释齐次坐标,也就是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相关的工作并进行实验,。

谢谢大家的阅读,很快见!

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP