用 MetalFX Upscaling 提升渲染性能
Boost performance with MetalFX Upscaling
2022年6月6日
一句话判断
Apple 终于给出了自己的超分辨率方案——MetalFX Spatial Upscaling 和 Temporal Anti-Aliasing/Upscaling,名字很像 DLSS/FSR,思路也差不多:渲染低分辨率,AI 放大到高分辨率。
这场 Session 讲了什么
MetalFX 是 Metal 3 引入的图像放大和抗锯齿框架。它提供两种模式:Spatial Upscaling(空间放大)和 Temporal Anti-Aliasing and Upscaling(时序抗锯齿+放大)。
Spatial Upscaling 是较简单的方案。你以较低分辨率渲染一帧画面,然后把这张颜色图交给 MetalFX,它会用 Apple Silicon 的 GPU 神经网络引擎把图像放大到目标分辨率。整个过程中只用到当前帧的数据,不需要历史帧信息。优点是集成简单——基本上就是渲染完之后多调一次 MetalFX 的 encode;缺点是质量有上限,因为没有时序信息来弥补细节。
Temporal Anti-Aliasing and Upscaling 是更高级的方案。它同时解决两个问题:抗锯齿和分辨率放大。你同样以低分辨率渲染,但需要额外提供运动向量(motion vector)和深度信息。MetalFX 利用这些信息结合历史帧来重建细节,输出最终画面。质量明显好于 Spatial 方案,但集成复杂度更高——你需要精确生成 motion vector,还要处理画面重置(比如镜头切换)的情况。
Jitter 序列。 时序方案要求你在渲染时对投影矩阵施加 sub-pixel 级别的抖动(jitter),每帧偏移半个像素左右。推荐的 jitter 序列是 Halton(2,3),这是一个低差异序列,能让采样点在像素空间内均匀分布。Apple 提供了现成的 jitter 偏移值表。
Mip bias。 使用 MetalFX 时,建议对纹理的 mip level 施加 bias(通常是 -0.5 到 -1.0 之间),让纹理采样使用更高分辨率的 mip level。这能补偿低分辨率渲染造成的细节损失,在最终放大后获得更清晰的结果。
False dependency。 MetalFX 的 temporal 方案需要读取上一帧的输出结果作为历史输入。如果渲染管线里有 barrier 或者 useResource 导致 MetalFX 的输出纹理被当作”正在被写入”的状态,GPU 可能会插入不必要的同步点。Apple 称之为 false dependency——理论上不需要等,但因为资源状态标记导致 GPU 等了。解决方案是把 MetalFX 的输出纹理标记为”无 hazard”。
演示游戏。 Session 里展示了三款即将登陆 Mac 的游戏:Resident Evil Village、No Man’s Sky 和 Grid Legends。这些游戏都用 MetalFX 把内部渲染分辨率提升到了接近原生的效果。
值得深挖的点
Temporal 方案的 motion vector 精度。 motion vector 是整个时序方案的质量基石。它描述了屏幕上每个像素在当前帧和上一帧之间的位移。如果你的 motion vector 不够精确(比如角色动画的细微运动没有被捕获),放大后的画面会出现”鬼影”或者模糊。精确的 motion vector 需要在 vertex shader 里同时计算当前帧和上一帧的 clip space 位置,然后取差值。对于粒子系统和透明物体,motion vector 的生成更复杂。
Mip bias 的调优。 mip bias 不是”越大越好”。-0.5 是比较保守的起点,如果你追求极致质量可以尝试 -1.0 甚至更低,但过低的 bias 会导致纹理闪烁(因为采样到了过高分辨率的 mip level,相邻像素的采样差异被放大了)。Apple 的建议是根据实际渲染分辨率和目标分辨率的比值来决定:比值越大(渲染分辨率越低),bias 可以越激进。
代码片段
Spatial Upscaling 基本用法:
// 创建 spatial upscaler
let descriptor = MTLFXSpatialScalerDescriptor()
descriptor.colorTextureFormat = .rgba16Float
descriptor.outputTextureFormat = .bgra10_xr
descriptor.inputWidth = renderWidth // 低分辨率
descriptor.inputHeight = renderHeight
descriptor.outputWidth = displayWidth // 目标分辨率
descriptor.outputHeight = displayHeight
let spatialScaler = descriptor.makeSpatialScaler(device: device)!
// 渲染完成后执行放大
let commandBuffer = commandQueue.makeCommandBuffer()!
spatialScaler.colorTexture = lowResColorTexture // 你的渲染结果
spatialScaler.outputTexture = fullResOutputTexture
spatialScaler.encode(commandBuffer: commandBuffer)
commandBuffer.commit()
Temporal 方案的 Jitter 偏移:
// Metal Shading Language - Halton(2,3) jitter 序列
constant const float2 jitterOffsets[] = {
float2(0.0, 0.0),
float2(0.5, 0.333333),
float2(0.25, 0.666667),
float2(0.75, 0.111111),
float2(0.125, 0.444444),
float2(0.625, 0.777778),
float2(0.375, 0.222222),
float2(0.875, 0.555556),
// ... 继续更多帧
};
// 在 vertex shader 里应用 jitter
uint jitterIndex = frameIndex % 8; // 用帧号循环
float2 jitter = jitterOffsets[jitterIndex] * 2.0 - 1.0; // 映射到 [-1, 1]
projectionMatrix.columns[2][0] += jitter.x / float(renderWidth);
projectionMatrix.columns[2][1] += jitter.y / float(renderHeight);
最佳实践
先集成 Spatial 方案,验证管线正确,再切换到 Temporal。Spatial 方案不需要 motion vector 和历史帧管理,调试成本更低。等你确认低分辨率渲染+放大的基本流程没问题了,再加 temporal 的复杂度。
渲染分辨率的选择没有固定公式,但 50%-70% 的目标分辨率是常见起点。比如目标 4K(3840x2160),可以先用 1920x1080(50%)测试,质量不够就提到 2560x1440(67%)。Apple Silicon 的 Neural Engine 处理 MetalFX 的开销很小,瓶颈通常在渲染阶段。
启用 MetalFX 后把 mip bias 设为 -0.5 到 -1.0 之间。这个改动独立于 MetalFX 本身,你只需要在采样纹理时修改 sampler 的 mip bias 值。这能显著提升最终画面的细节表现。
还有什么值得关注
- MetalFX 目前只在 Apple Silicon 上可用,Intel Mac 不支持。如果你的游戏需要支持 Intel Mac,需要准备 fallback 方案(比如直接以目标分辨率渲染,或者使用其他放大算法)。
- temporal 方案在画面场景切换(cutscene transition)时需要调用
reset()告诉 MetalFX 丢弃历史帧信息,否则会出现严重的鬼影。 - MetalFX 的输入纹理格式支持比较广泛,但输出纹理建议使用
bgra10_xr或rgba16Float,避免精度损失。 - Resident Evil Village 和 No Man’s Sky 的 Mac 版本使用的是 temporal 方案,Apple 声称在 M1 MacBook 上能跑到 1080p 流畅帧率。