このノートでは Metal のテクスチャを扱います。どう読み込み、どうサンプリングするか。そして細部に見えて画を台無しにしかねない設定について。
テクスチャの読み込み
MetalKit の MTKTextureLoader を使えば、アセットカタログやファイルから数行で 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はローダーにミップチェーンを作らせます。SRGBは独立した節を割く価値があります。
sRGB はハードウェアに任せる
色テクスチャ(albedo、UI アイコン)の大半は sRGB エンコードで保存されています。これはガンマ 2.2 に近い非線形カーブで、限られた 8 ビットの符号域を、人間の目が敏感な暗部へより多く割り当てるためのものです。
一方、ライティング計算は線形空間で行わなければなりません。sRGB の値をそのまま光の強度に掛けるのは端的に誤りで、全体が暗く、中間調が濁った絵になります。
対策はシェーダーでの pow(color, 2.2) ではなく、sRGB ピクセルフォーマット(.bgra8Unorm_srgb)を使うことです。
Swiftoptions: [.SRGB: true]
こうするとハードウェアのテクスチャユニットがサンプリング時に変換を行い、しかもそれはバイリニア補間の前に行われます。この順序が決定的です。非線形空間で二色を補間すると答えは誤りになりますが、ハードウェアは正しい順序を保証します。手書きの pow ではこれができず、しかもサンプリングのたびにべき乗の代価を払います。
出力側も揃える必要があります。drawable のピクセルフォーマットが .bgra8Unorm_srgb なら、フラグメント関数が書いた線形色はハードウェアが sRGB へ符号化します。全体の流れは sRGB テクスチャ → ハードウェアでデコード → 線形空間で計算 → ハードウェアでエンコード → sRGB で表示となります。
重要な例外がひとつ。法線マップ、ラフネス、メタリック、AO に sRGB フォーマットを使ってはいけません。 これらが保持しているのは知覚的な明るさではなく数値であり、ガンマカーブを通すと全値が壊れます。もっともよくあるレンダリングバグのひとつで、法線方向のずれや、材質が滑らかすぎる/粗すぎるという症状で現れます。
サンプラー
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 はミップレベル間を混ぜるかを決めます。.nearest はレベルの切り替わりに目に見える帯を残し、.linear がトライリニアフィルタリングです。
アドレスモードは [0,1] の外側の挙動を決めます。.repeat はタイル、.clampToEdge は端のテクセルを引き伸ばし、.mirrorRepeat は鏡像タイル、.clampToZero は透明を返します。タイルしないテクスチャ——キャラクターマップ、アトラス——には必ず .clampToEdge を使ってください。.repeat だとバイリニア補間が反対側の端からテクセルを拾い、幅 1 ピクセルの誤った縁が生じます。アトラスでは隣のタイルが滲む形で現れます。
異方性フィルタリングは浅い角度でのぼやけを解決します。面がほぼ視線と平行なとき、UV はスクリーンの x 方向と y 方向でまったく異なる速さで変化します。通常のミップマップは大きいほうしか選べないため、一方向が過度にぼけます。異方性フィルタリングは長いほうの軸に沿って複数回サンプリングします。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);
ミップマップが性能機能である理由
ミップマップはふつうアンチエイリアスとして紹介されますが、より重要な役割は性能です。
遠くの三角形がフル解像度のテクスチャをサンプリングすると、隣接ピクセルが読むテクセルはメモリ上で遠く離れており、ほぼ毎回キャッシュミスになります。テクスチャ帯域はただちにボトルネックになります。ミップマップは遠方のオブジェクトに小さな画像をサンプリングさせ、隣接ピクセルのテクセルを同じキャッシュラインに載せます。
代価はメモリが 3 分の 1 増えるだけ(1 + 1/4 + 1/16 + … = 4/3)で、ほぼ常に見合います。特別な理由がないかぎり、すべてのテクスチャにミップマップを持たせるべきです。
上のローダーオプション以外に、実行時に blit エンコーダで生成することもできます。
Swiftlet blit = commandBuffer.makeBlitCommandEncoder()!
blit.generateMipmaps(for: texture)
blit.endEncoding()
テクスチャ配列とバインドレス
テクスチャの切り替えはすべてエンコーダのステート変更です。オブジェクトが多いと、この切り替えが CPU のボトルネックになります。
テクスチャ配列(type2DArray)は同サイズ・同フォーマットの複数テクスチャを一つのオブジェクトにまとめ、整数のスライス番号で選びます。
MSLfloat4 c = texArray.sample(s, in.uv, in.materialIndex);
引数バッファ(argument buffer)はさらに進んで、テクスチャ・サンプラー・バッファといった一式を、GPU からアドレス可能な一つの構造体に詰めます。シェーダーはそこから任意の資源をインデックスで取り出せます。これが Metal のバインドレス手法です。
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 自身が駆動する処理にもできます。
次回はカメラと操作を扱います。