2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ステップアップさせていくMatCap実装

Last updated at Posted at 2025-12-21

はじめに

こんにちは。ゲーム会社新卒2年目のクライアントエンジニアです。
技術記事を投稿してみるのは今回が初になります。

この記事はKLab Engineer Advent Calendar 2025の22日目の記事です。

MatCapとは?

MatCapは光源計算を行わず、疑似的に陰影や質感を表現する手法です。
昔からある技術ですが処理負荷の軽さから今でも使われることがあります。

まずは最低限の実装をしてみる

テクスチャの用意

MatCapにはライティングの元となる丸形のテクスチャを用意する必要があります。

image.png

せっかくなので最近リリースされたUnityAIで生成してみようかとも思いましたが、自分のプロンプトがアレなのか丸形の画像を出せずに断念

今回はこちらからお借りしました。
https://www.pixelfondue.com/blog/30matcaps

シェーダーを書く

MatCap.shader
    Varyings vert(Attributes input)
    {
        Varyings output;
        
        output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
        float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
        output.normalVS = TransformWorldToViewNormal(normalWS);
        
        return output;
    }

float3 normalWS = TransformObjectToWorldNormal(input.normalOS);を入れ、ビュー空間の法線を使うことでカメラの動きに合わせてライティングが動きます。

MatCap.shader
    half4 frag(Varyings input) : SV_Target
    {
        float3 normalVS = normalize(input.normalVS);
        
        // 法線をUV座標に変換
        float2 matcapUV = normalVS.xy * 0.5 + 0.5;
        return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, matcapUV);
    }

fragment側では-1.0~1.0の範囲の法線を0.0~1.0の範囲にしてUVとして使える範囲に変換します。
UVに法線を使うことで、カメラに対して垂直な面はテクスチャの中央から、上向きの面はx=0.5,y=0からサンプリングされます。
これだけでMatCap自体の実装は完了です。

この時点での見た目はこんな感じ。
simple.gif

翡翠とか曇りガラスっぽい見た目のオブジェになっていいですね。
既に良さげですがここから改良していきます。

テクスチャ境界のノイズを回避する

オブジェクトを画面端に置くと、見る角度によって濃い筋のようなアーティファクトが出てしまいます。
image.png

試しに外側を赤くしたテクスチャを使ってみると…
image.png

赤い部分が混ざってしまっていることがわかります。
image.png

テクスチャの円形を大きくしてもいいのですが、UVを縮小し円の外側からサンプリングを行わないようにします。

MatCap.shader
    half4 frag(Varyings input) : SV_Target
    {
        float3 normalVS = normalize(input.normalVS);
        
        // 法線をUV座標に変換
         // 縮小して境界のアーティファクトを防止
        float2 matcapUV = normalVS.xy * (0.5 - 0.01) + 0.5;

        return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, matcapUV);
    }

フレネル

ややのっぺりとしているので立体感を出すためにフレネルを実装していきます。
法線と視線の角度によって縁を検出し、そこに白を加算しています。

MatCap.shader
//frag
    half ndotv = saturate(dot(normalWS, viewDirWS));
    half fresnel = pow(saturate(1.0h - ndotv), _FresnelPower);
    col.rgb += _FresnelColor.rgb * fresnel * _FresnelStrength;

比較画像 (→がフレネルあり)

ノーマルマップに対応

フレネルは主にモデルの縁の調整でしたが、
ノーマルマップを使うことで、面の情報量も増やしていきます。
特にMatCapでは法線の変化が色の変化なのでかなり相性がいいと思ってます。

こちらからお借りしたTextureをNormalMap化しています。
https://opengameart.org/content/700-noise-textures

MatCap.shader
// vert
    output.uv = TRANSFORM_TEX(input.uv, _NormalMap);

    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    output.normalWS = normalInput.normalWS;
    output.tangentWS = normalInput.tangentWS;
    output.bitangentWS = normalInput.bitangentWS;
    
// frag
    float3 nTS = UnpackNormalScale(
        SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv),
        _NormalScale);
    float3x3 tbn = float3x3(
        normalize(input.tangentWS),
        normalize(input.bitangentWS),
        normalize(input.normalWS)
    );
    normalWS = normalize(mul(nTS, tbn));

比較画像 (→がノーマルマップあり)

あまり強度が高すぎるとデコボコしてかえって素材感を損なってしまうので、
ライティングにうねりが付く程度の強さにしておくのが良さそうに感じました。

足元が滑らかにつながっていなかったり、部位によって模様の細かさが違うのはモデル側のUV調整もしくはTriPlanerなどでどうにかなりそうですが、これは今後の記事のストックにしておきます。
むしろこれくらいの法線変化だったら3Dノイズでもいいかもですが…

画面端の歪みに対応する

image.png
モデルとしてのクオリティは徐々に上がってきましたが、まだ問題があります。
画面中央にオブジェクトがある場合は問題なさそうですが、端にあるモデルは色の変化が少ないように感じられます。

image.png

これは、全てのピクセルでビュー空間の法線を計算するときにカメラの前方ベクトル(青い矢印)を使っていることが影響しています。(厳密には実質的にそうなっているだけですが)
画面中央では視線と前方ベクトルが一致するので問題ないのですが、画面端ではカメラからピクセルへ向かう視線と前方ベクトルに角度差が生じます。そのため視野角が大きいほどこの症状は顕著になります(画像はわかりやすいように視野角120にしています)
これを解決するにはピクセル毎にカメラ中央からのベクトル(赤い矢印)を作り、そちらを法線の計算に使う必要があります。

MatCap.shader
// frag
    float3 viewDirWS = normalize(GetWorldSpaceViewDir(input.positionWS));

    // カメラの上方向を取得
    float3x3 viewToWorld = (float3x3)GetViewToWorldMatrix();
    float3 cameraUpWS    = normalize(viewToWorld[1]);

    // 視線方向に直交する右と上を再構築
    float3 viewRightWS = normalize(cross(viewDirWS, cameraUpWS));
    float3 viewUpWS = normalize(cross(viewRightWS, viewDirWS));

    // 再構築した座標系と法線の内積をUVに変換
    matcapUV.x = dot(viewRightWS, normalWS);
    matcapUV.y = dot(viewUpWS, normalWS);

    // 少し縮小して境界のアーティファクトを防止
    matcapUV = matcapUV * (0.5 - 0.01) + 0.5;

image.png
中央でも画面端であっても均質にすることができました。

初期状態と比較

compare.gif
やったー

おわりに

細かい工夫を積み重ねて徐々に良くしていく過程は面白いですね。特にグラフィックス系はその差分がわかりやすくて良し。少しでもその感覚が伝わったらうれしいです。
それではまた次の記事で!ご覧いただきありがとうございました!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?