这篇笔记主要关于如何在 SwiftUI 中使用 Metal Shading Language 来编写提供给视图的材质。
SwiftUI 中的 Shader
在 iOS 17 之后,SwiftUI 支持对 View 使用三种不同的视觉效果,分别被称为 colorEffect, layerEffect 和 distortEffect。这三种 Effect 可以将一个基于 2D 平面的 Metal Shader 施用于当前的 view。
然而,即使在官方文档 [1] 中,对于 SwiftUI 中可以使用的 Shader 的很多说明都少得可怜,并且有一些限制其实也并没有很清晰地提出。本篇笔记旨在陈列一些重点的细节,以帮助更多人进行 SwiftUI 的 Metal Shader 开发。
另外,本文中提到的所有 Shader 以及代码库已经开源,感兴趣的读者可以自行查看 GitHub 代码库:https://github.com/Lockbrains/SwiftUI-2D-Shader-Assets.
使用方式
SwiftUI 端调用
在 SwiftUI 中,对一个 view 可以直接使用一个(或是同时多个) .colorEffect 或是 .layerEffect 或是 .distortEffect 的修饰符,例如,
这里的三种 Shader Effect 分别对应以下的情况:
.colorEffect:如果对应的 Shader 只需要当前像素的颜色信息,则应该使用 Color Effect。可以把 .colorEffect 视作一个片元着色器。
.layerEffect:如果对应的 Shader 不仅需要当前像素的颜色信息,layerEffect 给我们提供了被修饰视图的整个 layer,这样我们就可以实现一些上下文相关的效果,比如高斯模糊。
.distortEffect:如果对应的 Shader 会对顶点位置进行修改,则我们需要使用 Distort Effect。可以把 .distortEffect 视作一个顶点着色器。
在上面的代码中,colorEffect() 中的内容是我在另一个文件中定义好的一个示例 Shader(为了便于阅读),它具有如下的形式:
其中,函数 dissolveEffect 具有如下形式:
这里提供了两层封装,依然是为了代码的易读性以及为了让第三方在使用 Shader 时更直观,我会在之后的小节中详述其中的思路。但如果你需要单独地写一个临时使用的 Shader,直接在.colorEffect中使用 ShaderLibrary.shaderName() 即可,然后用下文数据传递一节中提到的方法将参数传递给 Shader 即可。
MSL 的语法
每一个中 Effect 需要使用符合要求的 MSL 函数来提供 Shader 的内容。不同的 Effect 的函数必须要服从不同的函数签名。
Color Effect
对于 Color Effect 而言,其需要提供的签名如下:
其中,对于第 0 个参数的 float2 position,官方的说辞是 position 是用户空间的像素坐标。在开发 SwiftUI 的 shader 之前,务必需要理解用户空间坐标的意义,如果不清楚,可以看下文用户空间一节。
另外,也必须提供一个位于第 1 号参数位的 half4 color,这是我们 view 该逻辑位置目前的颜色。
不过,这里的提供二字其实不太准确。position 和 color 都只需要在 MSL 的 Shader 签名中提供,在 SwiftUI 的 ShaderLibrary.shaderName 中我们并不需要提供这两个属性,这两个属性是自动传入的。但是对于其它的签名,则是一一对应且按照顺序的。例如,在我所写的 dissolveEffect 中,其 MSL 签名如下:
对应地,在 SwiftUI 中调用该 Shader 时使用的顺序则是:
注意它们顺序上的一一对应。在 SwiftUI 的 API 中,我们无法看到每个参数的名字,这对于开发多输入的 shader 的确提高了 debug 的难度,所以我强烈建议在调用时记得加上足够的 comment。
Layer Effect
对于 Layer Effect 而言,其需要提供的签名如下:
在 MSL 中,我们可以通过 layer.sample(position) 的方式来获得当前 view 在 position 位置的颜色。那么,因为我们有了整个 layer 的全局信息,显然也可以通过 layer.sample(f(position)) 的方式来获取一个与当前位置有关的其它位置的信息。这就给了我们来写诸如模糊一类的操作的可能。
比如在这个简易的高斯模糊中,我们就可以通过采样 position 附近总共9个逻辑点来进行高斯模糊。
在 SwiftUI 中,传递数据的方式与 Color Effect 相同,不再赘述,需要注意的是我们需要额外提供一个 maxSampleOffset:
根据官方文档的信息,
比如在上面的高斯模糊中,我们可能会对周围的9个像素进行模糊,这边将 width 和 height 设置成 3 就是合理的。这个 maxSampleOffset 相当于告诉 shader,虽然我们可能会用到当前位置以外的像素的信息,但绝对不会超过这个范围,这也是苹果官方优化 Layer Effect 的方式。
用户空间
用户坐标空间(User Space Coordinates, USC)是应用程序逻辑中使用的坐标系统,而不是设备或屏幕的物理像素坐标。这种坐标空间通常被归一化,并根据应用需求进行缩放、平移或旋转,以便更方便地布局或设计内容。
在 SwiftUI 中,我们通常不需要直接使用物理像素坐标,而是基于逻辑坐标。用户坐标空间让我们以一种与设备无关的方式布局图形内容。
示例
假设我们在一个 300 × 300 的 SwiftUI 的 Rectangle 组件上使用了 Shader,那么 position 参数会以用户空间的坐标提供,例如在中心时的位置就可能是(150,150)。如果我们将 Rectangle 放大或者缩小(使用.scaleEffect修饰符),position的坐标值依然是基于 300 x 300 的范围,而不会反映为具体的像素数。
注意,这里的300 x 300 或是 (150, 150), 它们的单位都是逻辑点,而不是像素。SwiftUI 中的点是与屏幕分辨率无关的逻辑单位,例如在高分辨率的 Retina 屏幕上,一个逻辑点就可能对应多个物理像素。
增长方向
在 SwiftUI 中,通常我们认为左上角是(0,0),右下角是最大值(例如在上例中就是 (300, 300))。我们可以通过简单的 Shader 来验证这件事。
使用一个简单的 Shader 返回一个最简单的 Gradient,然后对某个 view 使用 .colorEffect。
这里的 size 应该提供当前 view 的逻辑大小。这个 gradient 的确反映出左上角是(0,0),右下角是最大值的特征。
UV
了解以上这些基础,如果我们依然想要使用一个归一化的手段(也就是常说的 uv 坐标系)来书写 Shader,那么我建议可以在 MSL 的输入中加入一个 float2 size。这个 size 就是当前 view 的逻辑大小。通过这个逻辑大小,我们就能很轻松地计算出归一化的 uv,就如同在上面的 trivialGradient 中所做的一样:
然后在 SwiftUI 中,将逻辑尺寸用 .float2(x, y) 传入 MSL,就可以制作基于归一化坐标的效果了。
数据传递
这个部分只能说苹果官方确实给出了说明,但是真的需要找很久。总之,Metal 和 SwiftUI 的数据类型是有区别的,举个简单的例子,在 Metal 中 float2,half2 这些都是很常见的数据类型,但是 SwiftUI 中只有 Float,传递数据需要分别知道两边有什么数据类型,通过什么 API 来进行数据的传递。
MSL 端
MSL 端支持的数据建议查阅 Metal Shading Language Specification。不过大部分时候我们只会用到简单的 float, floatn, half, halfn, texture2d<half> 类型。特别地,当描述、记录颜色时,我们总是应该使用 half4。
需要注意的是,MSL 中 halfn 和 floatn 之间并不能隐式转换。虽然你可以使用 halfn * float,或是 floatn * half,但你不能使用 halfn 与 floatn 相乘。
SwiftUI 端
在 SwiftUI 中,我们需要通过以下的接口来把数据传递给 Metal:
.color(Color):括号内输入一个 SwiftUI 的 Color类型,这个数据会被转译成 half4 类型。注意,在 Metal 中,half4 类型默认就是用来描述颜色的,你可以将 half4 就视作 color4。
.float(T):括号内输入一个符合 BinaryFloatingPoint 协议的 SwiftUI 类型。这个数据会被转译成 float 类型。
.float2(T, T):括号内输入两个符合 BinaryFloatingPoint 协议的 SwiftUI 类型。这个数据会被转译成 float2 类型。类似地还有 .float3, .float4,不予赘述。
.image(Image): 括号内输入一个 SwiftUI 的 Image 类型,这个数据会被转译成 texture2d<half> 类型。需要特别注意的是,目前 SwiftUI 中的 shader 最多只能有一个 Texture 类型的输入。如果你写了一个需要采样两张贴图的 Shader,那么非常遗憾它将没有效果。
除以上之外,也有一些传递 Array 的方式,
.colorArray([Color]):括号内输入一个 SwiftUI 的 Color 数组。这个数组会被转译成一个数对,即(device const half4 *, count)。
.floatArray([T]):括号内输入一个符合 BinaryFloatingPoint 协议的 SwiftUI 类型数组。这个数组会被转译成 一个数对,即(device const float *, count)。
.data(Data):括号内输入一个 SwiftUI 的 Data 数据。这个数据会被转译成一个数对,即 (device const void *, size_in_bytes)。
如何转译其它平台的 Shader
来自 ShaderToy 的 Shader
来自 Unity 的 Shader
参考资料:
苹果开发者文档(Shader),https://developer.apple.com/documentation/swiftui/shader
苹果开发者文档(Shader.Argument), https://developer.apple.com/documentation/swiftui/shader/argument
How to add Metal shaders to SwiftUI views using layer effects, https://www.hackingwithswift.com/quick-start/swiftui/how-to-add-metal-shaders-to-swiftui-views-using-layer-effects
在 SwiftUI 中使用 Metal Shader,https://www.cnblogs.com/jerrywossion/p/18090457
Comments