这篇笔记主要写一下 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 驱动。
下一篇看摄像机和交互。