如何在 visionOS 上使用 MPS 和 CIFilter 实现特殊视觉效果
说明
在 visionOS 开发中,视觉效果一直都是开发的一个难点。尽管苹果推出了 ShaderGraph 来简化 Shader 的开发,在此基础上我开源了 RealityShaderExtension 框架来帮助降低 Shader 开发的门槛,但在实际开发中,我们仍然面临两个问题:
- 数学与几何知识要求太高,难以开发出满意的效果
- 某些效果如
高斯模糊GaussianBlur
或直方图Histogram
单纯依靠 ShaderGraph 难以编写的,且运行效率不佳
苹果针对 ShaderGraph 功能不够强大的弱点,给出的解决方案是:使用 LowLevelTexture + Compute Shader 更加灵活的实现各种算法功能,然而手写 Metal Compute Shader 代码依然是非常困难的。
不过,苹果有一个已经高度优化的 Compute Shader 框架:Metal Performance Shaders ,我们可以直接与 LowLevelTexture 一起使用。
同时,经过研究,在 UIKit 中常用的 CIFilter 图片处理框架,也是可以与 LowLevelTexture 一起使用的,这样就无需再手动编写各种算法代码了。
同时,不仅是图片可以处理,视频也可以继续使用 AVPlayer 播放的同时,添加 MPS/CIFilter 进行处理。
图片处理
对图片处理时,MPS 和 CIFilter 的基本步骤是一样的:
- 处理流程: MPS/CIFilter -> LowLevelTexture -> TextureResource -> UnlitMaterial
Image(MPS)
使用 MPS 进行处理时:
- 只需要通过
commandBuffer
从 LowLevelTesxture 中获取目标纹理outTexture
- 将源纹理和目标纹理传递给 MPS filter 即可。
关键代码如下:
func populateMPS(inTexture: MTLTexture, lowLevelTexture: LowLevelTexture, device: MTLDevice) {
// Set up the Metal command queue and compute command encoder,
.....
// Create a MPS filter.
let blur = MPSImageGaussianBlur(device: device, sigma: model.blurRadius)
// set input output
let outTexture = lowLevelTexture.replace(using: commandBuffer)
blur.encode(commandBuffer: commandBuffer, sourceTexture: inTexture, destinationTexture: outTexture)
// The usual Metal enqueue process.
.....
}
Image(CIFilter)
使用 CIFilter 进行处理时:
- 需要根据
outTexture
和commandBuffer
创建一个 CIRenderDestination 。 - [可选] 为了更好与 Metal 协作,最好创建一个 GPU-Based CIContext 。
- [可选] 如果遇到颜色空间显示不正确,可以设置 options 中
.workingColorSpace
为 sRGB 等。 - 最后调用
ciContext.startTask
将处理后的图片写入 CIRenderDestination 中。
关键代码如下:
let blur = CIFilter(name: "CIGaussianBlur")
func populateCIFilter(inTexture: MTLTexture, lowLevelTexture: LowLevelTexture, device: MTLDevice) {
// Set up the Metal command queue and compute command encoder,
.......
// Set the CIFilter inputs
blur?.setValue(CIImage(mtlTexture: inTexture), forKey: kCIInputImageKey)
blur?.setValue(model.blurRadius, forKey: kCIInputRadiusKey)
// set input output
let outTexture = lowLevelTexture.replace(using: commandBuffer)
let render = CIRenderDestination(mtlTexture: outTexture, commandBuffer: commandBuffer)
// Create a Context for GPU-Based Rendering
let ciContext = CIContext(mtlCommandQueue: commandQueue,options: [.cacheIntermediates: false, .workingColorSpace: CGColorSpace(name: CGColorSpace.sRGB)!])
if let outImage = blur?.outputImage {
do {
try ciContext.startTask(toRender: outImage, to: render)
} catch {
print(error)
}
}
// The usual Metal enqueue process.
......
}
视频处理
视频处理要稍微复杂一些,需要创建 AVMutableVideoComposition 来从 AVPlayer 中获取视频帧信息再进行处理,处理后的视频继续在 AVPlayer 中直接播放,也可以另外导出到 LowLevelTexture 中进行显示。
注意:视频处理在老版本的(即 Xcode 16.4 中原始的) Vision Pro 模拟器中不能正常工作,在新的模拟器”Apple Vision Pro 4K” 中 使用 CIFilter 处理后的颜色显示不正确。不过在真机测试中,都是正常的。
Video(CIFilter)
- 处理流程:[ CIFilter + AVMutableVideoComposition + AVPlayerItem ] -> VideoMaterial
好消息是,苹果针对 CIFilter 有一个简单方案:
- 在创建 AVMutableVideoComposition 时创建一个闭包
- 在闭包中通过 AVAsynchronousCIImageFilteringRequest 获取适合 CIFilter 处理的视频帧数据
- 源视频数据直接传给 CIFilter 处理后,重新写入 AVAsynchronousCIImageFilteringRequest 即可播放出模糊后的视频。
let asset: AVURLAsset....
let playerItem = AVPlayerItem(asset: asset)
let composition = try await AVMutableVideoComposition.videoComposition(with: asset) { request in
populateCIFilter(request: request)
}
playerItem.videoComposition = composition
// Create a material that uses the VideoMaterial
let player = AVPlayer(playerItem: playerItem)
let videoMaterial = VideoMaterial(avPlayer: player)
真正的处理代码也非常简单,将 CIFilter 的输出重新写入到 request
中即可:
let ciFilter = CIFilter(name: "CIGaussianBlur")
func populateCIFilter(request: AVAsynchronousCIImageFilteringRequest) {
let source = request.sourceImage
ciFilter?.setValue(source, forKey: kCIInputImageKey)
ciFilter?.setValue(model.blurRadius, forKey: kCIInputRadiusKey)
if let output = ciFilter?.outputImage {
request.finish(with: output, context: ciContext)
} else {
request.finish(with: FilterError.failedToProduceOutputImage)
}
}
Video(MPS)
- 处理流程: [ MPS + AVMutableVideoComposition + AVPlayerItem ] -> LowLevelTexture -> TextureResource -> UnlitMaterial
通过 MPS 来处理视频要更加复杂一些:
- 我们需要自定义一个
customVideoCompositorClass
,赋值给 AVMutableVideoComposition - 实现它的协议方法,指定输入和输出的像素格式
- 在 startRequest() 中获取视频帧并转换为 MTLTexture ,由 MPS 进行处理
- [可选] 将源视频写入回去,这样就能在 AVPlayer 中继续播放源视频
自定义一个 SampleCustomCompositor
,并赋值给 composition.customVideoCompositorClass
let composition = try await AVMutableVideoComposition.videoComposition(withPropertiesOf: asset)
composition.customVideoCompositorClass = SampleCustomCompositor.self
let playerItem = AVPlayerItem(asset: asset)
playerItem.videoComposition = composition
在 SampleCustomCompositor 需要指定我们需要的视频帧像素格式,然后就可以在 startRequest() 中获取到对应格式的视频帧,进行模糊处理。
class SampleCustomCompositor: NSObject, AVVideoCompositing {
.....
// 指定我们需要的视频帧格式。一定要设置 kCVPixelBufferMetalCompatibilityKey,否则与 Metal 会出现兼容性问题,导致黑屏等
var sourcePixelBufferAttributes: [String: any Sendable]? = [
String(kCVPixelBufferPixelFormatTypeKey): [kCVPixelFormatType_32BGRA],
String(kCVPixelBufferMetalCompatibilityKey): true // Critical! 非常重要
]
// 我们处理后返回的视频帧格式
var requiredPixelBufferAttributesForRenderContext: [String: any Sendable] = [
String(kCVPixelBufferPixelFormatTypeKey):[kCVPixelFormatType_32BGRA],
String(kCVPixelBufferMetalCompatibilityKey): true
]
....
func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {
.....
let requiredTrackIDs = request.videoCompositionInstruction.requiredSourceTrackIDs
let sourceID = requiredTrackIDs[0]
let sourceBuffer = request.sourceFrame(byTrackID: sourceID.value(of: Int32.self)!)!
Task {@MainActor in
// 将模糊后的视频输出到 LowLevelTexture 中
populateMPS(sourceBuffer: sourceBuffer, lowLevelTexture: SampleCustomCompositor.llt!, device: SampleCustomCompositor.mtlDevice!)
}
// 保持原视频继续输出
request.finish(withComposedVideoFrame: sourceBuffer)
}
@MainActor func populateMPS(sourceBuffer: CVPixelBuffer, lowLevelTexture: LowLevelTexture, device: MTLDevice) {
.....
// Now sourceBuffer should already be in BGRA format, create Metal texture directly
var mtlTextureCache: CVMetalTextureCache? = nil
CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &mtlTextureCache)
let width = CVPixelBufferGetWidth(sourceBuffer)
let height = CVPixelBufferGetHeight(sourceBuffer)
var cvTexture: CVMetalTexture?
let result = CVMetalTextureCacheCreateTextureFromImage(
kCFAllocatorDefault,
mtlTextureCache!,
sourceBuffer,
nil,
.bgra8Unorm,
width,
height,
0,
&cvTexture
)
let bgraTexture = CVMetalTextureGetTexture(cvTexture)
// Create a MPS filter with dynamic blur radius
let blur = MPSImageGaussianBlur(device: device, sigma: Self.blurRadius)
// set input output
let outTexture = lowLevelTexture.replace(using: commandBuffer)
blur.encode(commandBuffer: commandBuffer, sourceTexture: bgraTexture, destinationTexture: outTexture)
// The usual Metal enqueue process.
....
}
}
使用 customVideoCompositorClass
+ MPS
,可以在 AVPlayer 输出源视频(下图左)的同时,在 LowLevelTexture 中输出模糊后的视频(下图右):
参考
项目完整示例:https://github.com/XanderXu/MPSAndCIFilterOnVisionOS
参考资料:
- ShaderGraph
- RealityShaderExtension
- Metal Performance Shaders
- Core Image Programming Guide
- Creating a dynamic height and normal map with low-level texture
- Editing and Playing HDR Video
- Debugging AVFoundation audio mixes, compositions, and video compositions
本文作者
推荐阅读
- 开源框架 RealityShaderExtension,帮你将 Unity 和 Unreal 的 Shader 转到 visionOS - 在 visionOS 上写 Shader 不再头疼
- 当 PICO 4 Ultra 碰上空间视频,会擦出什么火花? - 玩转空间视频,PICO 也很行!
- 一个三维描边效果,带你入门 visionOS 上的 Shader Graph 效果 - 掌握 Shader Graph 基础知识和实战技巧
- 带你在 visionOS 上玩转粒子
- 远不止是游戏:盘点 PICO 4 Ultra 中那些让人心潮澎湃的 MR 能力
- iPhone 15 Pro / Apple Vision Pro 上的空间视频,到底是什么?
- visionOS 2 PortalComponent - 更符合期待的传送神器