はじめに
こんにちは。ゲーム会社新卒2年目のクライアントエンジニアです。
技術記事を投稿してみるのは今回が初になります。
この記事はKLab Engineer Advent Calendar 2025の22日目の記事です。
MatCapとは?
MatCapは光源計算を行わず、疑似的に陰影や質感を表現する手法です。
昔からある技術ですが処理負荷の軽さから今でも使われることがあります。
まずは最低限の実装をしてみる
テクスチャの用意
MatCapにはライティングの元となる丸形のテクスチャを用意する必要があります。
せっかくなので最近リリースされたUnityAIで生成してみようかとも思いましたが、自分のプロンプトがアレなのか丸形の画像を出せずに断念
今回はこちらからお借りしました。
https://www.pixelfondue.com/blog/30matcaps
シェーダーを書く
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);を入れ、ビュー空間の法線を使うことでカメラの動きに合わせてライティングが動きます。
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自体の実装は完了です。
翡翠とか曇りガラスっぽい見た目のオブジェになっていいですね。
既に良さげですがここから改良していきます。
テクスチャ境界のノイズを回避する
オブジェクトを画面端に置くと、見る角度によって濃い筋のようなアーティファクトが出てしまいます。

テクスチャの円形を大きくしてもいいのですが、UVを縮小し円の外側からサンプリングを行わないようにします。
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);
}
フレネル
ややのっぺりとしているので立体感を出すためにフレネルを実装していきます。
法線と視線の角度によって縁を検出し、そこに白を加算しています。
//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
// 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ノイズでもいいかもですが…)
画面端の歪みに対応する

モデルとしてのクオリティは徐々に上がってきましたが、まだ問題があります。
画面中央にオブジェクトがある場合は問題なさそうですが、端にあるモデルは色の変化が少ないように感じられます。
これは、全てのピクセルでビュー空間の法線を計算するときにカメラの前方ベクトル(青い矢印)を使っていることが影響しています。(厳密には実質的にそうなっているだけですが)
画面中央では視線と前方ベクトルが一致するので問題ないのですが、画面端ではカメラからピクセルへ向かう視線と前方ベクトルに角度差が生じます。そのため視野角が大きいほどこの症状は顕著になります(画像はわかりやすいように視野角120にしています)
これを解決するにはピクセル毎にカメラ中央からのベクトル(赤い矢印)を作り、そちらを法線の計算に使う必要があります。
// 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;
初期状態と比較
おわりに
細かい工夫を積み重ねて徐々に良くしていく過程は面白いですね。特にグラフィックス系はその差分がわかりやすくて良し。少しでもその感覚が伝わったらうれしいです。
それではまた次の記事で!ご覧いただきありがとうございました!









