T.TAO
ブログへ戻る
/10 min read/Graphics Engine

Metal #8 マテリアル

#ComputerGraphics#GraphicsEngine#Metal

このノートではマテリアルを扱います。前回のライティングパラメータをアーティストが扱える体系へどう整理するか、そしてなぜ業界が PBR へ収束したのか。

マテリアルとはパラメータ群とテクスチャ群

Phong のマテリアルはおおよそこうなります。

Ctypedef struct {
    vector_float3 diffuse;
    vector_float3 specular;
    float         shininess;
} PhongMaterial;

このパラメータ群の問題は、どれも測定可能な物理量に対応していないことです。specular にはいくつを入れるべきか。shininess は 32 か、64 か。アーティストは試行錯誤するしかありません。さらに悪いことに、同じマテリアルでも照明環境が変われば調整し直しになります。パラメータが光の強度に関する暗黙の前提を抱えているからです。

PBR(物理ベースレンダリング)はまさにこれを解決します。測定可能な物理特性でマテリアルを記述し、一つのマテリアルがどんな照明下でも正しく振る舞うようにします。

metallic-roughness ワークフロー

現在の事実上の標準は四つのチャンネルです。

Ctypedef struct {
    vector_float3 baseColor;   // アルベド、線形空間
    float         metallic;    // 0 = 誘電体, 1 = 金属
    float         roughness;   // 0 = 鏡面, 1 = 完全拡散
    float         ao;          // アンビエントオクルージョン
} PBRMaterial;

baseColor は誘電体では拡散色、金属では反射率の色です。金属に拡散成分はありません。直感に反しますが重要な点です。金は「黄色い拡散反射」ではなく、青より赤と緑を多く返す鏡面反射です。

metallic は物理的には二値です。ある材質は金属であるか、ないかのどちらかです。中間値は遷移領域——錆びた鉄、剥げた塗装——のためだけに存在し、「半金属」という存在しないもののためではありません。狙いの見た目を得るために metallic を 0.5 にしているなら、たいていほかのどこかが間違っています。

roughness は微小表面の統計的分布を表します。アーティストがもっとも頻繁に触るチャンネルです。「新品か使い込まれたか、乾いているか濡れているか」に直接対応するからです。

テクスチャは通常、albedo(sRGB)+ normal(線形)+ ORM(AO・roughness・metallic を一枚の RGB に詰めたもの、線形)として梱包されます。ORM のパッキングはテクスチャフェッチを一回減らすためです。

法線マップと TBN

法線マップが保持しているのは接空間での法線の摂動です。ワールド空間でなく接空間を使うのは、一枚のマップをどんな向きのモデルにも適用でき、モデルが変形しアニメーションできるようにするためです。

接空間の法線をワールド空間へ変換するには基底が必要です。接線 T、従接線 B、法線 N

MSLstruct VertexOut {
    float4 position [[position]];
    float3 normalWS;
    float3 tangentWS;
    float3 bitangentWS;
    float2 uv;
};

// 頂点関数内
out.normalWS    = normalize(uniforms.normalMatrix * in.normal);
out.tangentWS   = normalize(uniforms.normalMatrix * in.tangent.xyz);
out.bitangentWS = cross(out.normalWS, out.tangentWS) * in.tangent.w;

この in.tangent.w手性で、+1 か −1 のいずれかです。UV が鏡像になっているかを記録しています。キャラクターモデルはほぼ必ず左右で UV の半分を共有しており、鏡像側では従接線が逆を向きます。この符号を掛けなければ、鏡像側の法線マップは反転します。片側が凹み、もう片側が膨らんで見える形で現れます。

フラグメント関数では次のようにします。

MSLfloat3 tangentNormal = normalTex.sample(s, in.uv).xyz * 2.0 - 1.0;
float3x3 TBN = float3x3(normalize(in.tangentWS),
                        normalize(in.bitangentWS),
                        normalize(in.normalWS));
float3 n = normalize(TBN * tangentNormal);

* 2.0 - 1.0 はテクスチャが [0,1] を保持しているのに対し、法線成分の範囲が [-1,1] だからです。法線マップに sRGB フォーマットを使ってはいけない理由もここにあります。ガンマカーブがこの対応づけを完全に壊します。

Model I/O は読み込み時に接線を生成できます。

SwiftmdlMesh.addTangentBasis(forTextureCoordinateAttributeNamed: MDLVertexAttributeTextureCoordinate,
                        tangentAttributeNamed: MDLVertexAttributeTangent,
                        bitangentAttributeNamed: nil)

ただし注意点があります。エンジンの接線は、DCC ツールで法線マップをベイクしたときの接線と一致していなければ、微妙なシェーディング誤差が生じます。業界がこの理由で MikkTSpace に標準化しているのは、そのためです。

Cook-Torrance BRDF

PBR のスペキュラ項はふつう Cook-Torrance のマイクロファセットモデルです。

Plain Textf_spec = D * F * G / (4 * NdotL * NdotV)

各項には役割があります。

D、法線分布関数は、微小表面のうち何割が光をちょうど目の方向へ反射する向きを持つかを表します。ハイライトの形を決めます。現在の既定は GGX / Trowbridge-Reitz です。その長い裾が実測データにもっともよく合うからです。ハイライトの芯の周りにある柔らかなハローが、その裾にあたります。

MSLfloat D_GGX(float NdotH, float roughness) {
    float a  = roughness * roughness;
    float a2 = a * a;
    float d  = NdotH * NdotH * (a2 - 1.0) + 1.0;
    return a2 / (M_PI_F * d * d);
}

roughness * roughness に注目してください。アーティストが作るのは知覚的粗さであり、GGX が要求するのは alpha です。この二乗があることで、スライダーの中間域の挙動が直感どおりになります。

F、フレネル項は反射率が視角によってどう変わるかを表します。あらゆる材質は浅い角度で全反射に近づきます。濡れた地面に遠くの反射が見えるのに、足元には見えないのはこのためです。Schlick 近似は次のとおり。

MSLfloat3 F_Schlick(float VdotH, float3 F0) {
    return F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);
}

F0 は垂直入射時の反射率です。誘電体はほぼ例外なく 0.04 前後(きわめて有用な定数)で、金属の F0 はその baseColor そのものです。

MSLfloat3 F0 = mix(float3(0.04), baseColor, metallic);

G、幾何項は微小表面どうしがどれだけ互いを遮るかを表します。粗い表面を浅い角度から見ると、突起が谷を遮り、D だけが予測するよりも反射エネルギーは少なくなります。

最後に、エネルギー保存を念頭に拡散とスペキュラを合成します。金属に拡散はないので (1 - metallic) が掛かります。

MSLfloat3 kD = (1.0 - F) * (1.0 - metallic);
float3 color = (kD * baseColor / M_PI_F + specular) * lightColor * NdotL;

/ M_PI_F はしばしば省略されますが、省くと拡散がスペキュラより π 倍明るくなり、両者のバランスが崩れます。

関数定数によるマテリアルバリアント

すべてのマテリアルが法線マップを持つわけではありません。組み合わせごとにシェーダーを書けば爆発しますし、実行時に分岐すれば性能を捨てます。Metal の関数定数はまさにこれを解決します。

MSLconstant bool hasNormalMap  [[function_constant(0)]];
constant bool hasMetallicMap [[function_constant(1)]];

fragment float4 pbr_fragment(...) {
    float3 n = in.normalWS;
    if (hasNormalMap) {          // 実行時分岐ではなくコンパイル時に除去される
        n = sampleNormalMap(...);
    }
    ...
}

パイプライン生成時に定数値を渡せば、Metal は分岐を取り除いた版に特殊化します。#define の組み合わせより管理しやすく、実行時分岐より高速です。

次回はこのレンダリング処理を複数のパスへ整理する方法を扱います。

このシリーズの記事

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 [付録] Compute Shader
  22. 22Metal #22 [付録] SwiftUI における Metal