T.TAO
返回博客
/7 min read/Graphics Engine

Metal #5 纹理

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下 Metal 中的纹理:怎么加载、怎么采样,以及那些看起来像细节但会直接毁掉画面的设置。

加载纹理

MetalKit 提供了 MTKTextureLoader,几行就能从 asset catalog 或文件读出 MTLTexture

Swiftlet loader = MTKTextureLoader(device: device)

let texture = try loader.newTexture(
    name: "brick_albedo",
    scaleFactor: 1.0,
    bundle: nil,
    options: [
        .textureUsage      : MTLTextureUsage.shaderRead.rawValue,
        .textureStorageMode: MTLStorageMode.private.rawValue,
        .generateMipmaps   : true,
        .SRGB              : true
    ]
)

四个选项都值得解释:

  • textureUsage 声明这张纹理会被怎么用。只读的话就只写 .shaderRead,驱动可以据此做更激进的优化。要当渲染目标就加 .renderTarget
  • textureStorageMode: .private 表示纹理只存在于 GPU 显存,CPU 访问不到。对于加载后不再修改的资源,这是最快的模式。
  • generateMipmaps 让 loader 顺便生成 mip 链。
  • SRGB 这一项是下面要展开讲的重点。

sRGB:必须交给硬件

绝大多数颜色纹理(albedo、UI 图标)是以 sRGB 编码保存的——这是一条近似 gamma 2.2 的非线性曲线,目的是在 8 位精度下把更多码值分配给人眼敏感的暗部。

而光照计算必须在线性空间进行。把 sRGB 数值直接拿去乘光照强度是错的,会让画面整体偏暗、中间调发脏。

解决办法不是在着色器里写 pow(color, 2.2),而是使用 sRGB 像素格式(.bgra8Unorm_srgb):

Swiftoptions: [.SRGB: true]

这样硬件的纹理采样单元会在采样时自动做转换,而且是在双线性插值之前。这点很关键:在非线性空间里插值两个颜色,结果是错的;硬件保证了正确的顺序。手写 pow 做不到这一点,而且每次采样都要付出一次幂运算的代价。

对应地,输出端也要一致:如果 drawable 的像素格式是 .bgra8Unorm_srgb,那么片元函数写出的线性颜色会被硬件自动编码回 sRGB。整条链路就是:sRGB 纹理 → 硬件解码 → 线性空间计算 → 硬件编码 → sRGB 显示

有一条重要例外:法线贴图、粗糙度、金属度、AO 这些不是颜色的贴图绝对不能用 sRGB 格式。 它们存的是数值,不是感知亮度,走一遍 gamma 曲线就全错了。这是新手最常见的渲染 bug 之一,症状是法线方向偏移、材质看起来太光滑或太粗糙。

采样器

MTLSamplerState 描述"怎么从纹理里取值":

Swiftlet descriptor = MTLSamplerDescriptor()
descriptor.minFilter = .linear
descriptor.magFilter = .linear
descriptor.mipFilter = .linear          // 三线性过滤
descriptor.sAddressMode = .repeat
descriptor.tAddressMode = .repeat
descriptor.maxAnisotropy = 8
samplerState = device.makeSamplerState(descriptor: descriptor)

过滤模式.nearest 保持硬边(像素画、色板贴图、ID 图),.linear 做双线性插值。mipFilter 决定 mip 层级之间是否插值——.nearest 会在层级切换处留下可见的条带,.linear 就是三线性过滤。

寻址模式决定 UV 超出 [0,1] 的行为:.repeat 平铺、.clampToEdge 拉伸边缘像素、.mirrorRepeat 镜像平铺、.clampToZero 返回透明。对于非平铺的贴图(角色贴图、图集),一定要用 .clampToEdge——用 .repeat 会在双线性插值时从对边采到像素,产生一条一像素宽的错误边缘,在图集里表现为相邻图块渗色。

各向异性过滤解决斜视角下的模糊。当表面几乎与视线平行时,UV 在屏幕 x 和 y 方向上的变化率差别很大,常规 mipmap 只能取两者中更大的那个,于是一个方向被过度模糊了。各向异性过滤沿更长的方向多次采样。maxAnisotropy = 8 对地面和墙面几乎是必需的,代价也不高。

在着色器里绑定使用:

MSLfragment float4 fragment_main(VertexOut in [[stage_in]],
                              texture2d<float> albedoTex [[texture(0)]],
                              sampler          s         [[sampler(0)]])
{
    float4 albedo = albedoTex.sample(s, in.uv);
    return albedo;
}

采样器也可以直接在着色器里声明为常量,省掉一次绑定:

MSLconstexpr sampler s(filter::linear, mip_filter::linear, address::repeat);

mipmap 为什么是性能问题

mipmap 通常被当成抗锯齿手段,但它更重要的作用是性能

一个远处的三角形,如果采样全分辨率纹理,相邻像素采到的纹素在内存中相距很远,几乎每次采样都是一次 cache miss。纹理带宽会立刻成为瓶颈。mipmap 让远处的物体采样小图,相邻像素的纹素落在同一条 cache line 上。

代价只有 1/3 的额外内存(1 + 1/4 + 1/16 + ... = 4/3),几乎总是划算的。除非有充分理由,所有纹理都应该有 mipmap。

生成方式除了上面的 loader 选项,也可以在运行时用 blit encoder:

Swiftlet blit = commandBuffer.makeBlitCommandEncoder()!
blit.generateMipmaps(for: texture)
blit.endEncoding()

纹理数组与无绑定

每次切换纹理都意味着一次编码器状态变更。物体很多时,这些切换会成为 CPU 瓶颈。

纹理数组type2DArray)把多张同尺寸同格式的纹理放进一个对象,用一个整数索引选择切片:

MSLfloat4 c = texArray.sample(s, in.uv, in.materialIndex);

参数缓冲区(argument buffer)更进一步,把一整组资源(纹理、采样器、buffer)打包成一个 GPU 可寻址的结构。着色器可以从里面按索引取任意资源,这就是 Metal 的无绑定(bindless)方案:

MSLstruct Material {
    texture2d<float> albedo;
    texture2d<float> normal;
    float            roughness;
};

fragment float4 f(constant Material *materials [[buffer(0)]],
                  uint id [[flat]] /* ... */)
{
    float4 c = materials[id].albedo.sample(s, uv);
}

有了它,成百上千个材质不同的物体可以合并进很少的 draw call,甚至配合 indirect command buffer 完全由 GPU 驱动。

下一篇看摄像机和交互。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal