🩸

[Unity] 鮮血を、浴びせよ...!!

2023/11/21に公開

1. はじめに

タイトルで釣ってみました。血は浴びません。

任意の3Dモデルに対して、テクスチャを好きな場所に貼り付けていくという実装をしてみたいと思います。血は浴びないと言いましたが、返り血テクスチャを貼り付けて血を浴びたり、ペイント弾を付けたりできるようにします。

調べてみるとこのような実装はデカールという技術として昔からあるようです。厳密なデカールの定義が分からないので本実装がデカールかは分かりませんが、ペイントのようなイメージです。Unityの機能でもあるようですし、最近CyberAgentさんのOSSでも公開されていました。

それぞれ実装方法は異なりそうですね。

今回は「目的の表現のための自前のシェーダーで使うためにテクスチャとして欲しい」という要件が私の中であったのでその用途に合う実装の話になります。試してみたい表現実装の前段階として実装してみたので技術ブログにした次第です。

2. 方針の思案

まずはどのように実現をするのか方針を定めるところから書いていきたいと思います。

実装までスキップしても大丈夫です。

「3Dモデルのテクスチャ自体に、デカールテクスチャを重ねて書き込む」というのがまずパッと思い付きます。今回は好きな回数だけペタペタ貼り付けたいので、単に3Dモデルのシェーダーでデカールテクスチャを重ねて表示するだけでなく、最終結果を3Dモデルのテクスチャに描き込み、累積描画をする必要があります。Graphics.Blitを使うことで累積描画はできそうです。

Graphics.Blit ではテクスチャ座標(UV)でのOffsetを指定できるので、3Dモデルのデカールを貼り付けたい場所のUVが分かればそこへ描画ができそうです。調べてみるとRayを飛ばして当たった場所のUVを取得できるようです。
https://forum.unity.com/threads/get-uv-coordinate-from-collision.12720/

コライダからのUVの取得

キャラクターだとCapsuleColliderをよく使いますが、そこへRayを飛ばしてもUVが取得できる気がしないのでドキュメントを確認してみるとMeshColliderを持っている場合のみのようです。 なのでキャラクタなどのUVはRayでは取得できないかもしれません。
https://docs.unity3d.com/ja/2021.2/ScriptReference/RaycastHit-textureCoord.html

これでいけそうな気がしますが、実は3Dモデルに対して描画したい場所のUVが手に入ったとして問題があります。それは3Dモデルのテクスチャ画像は、3DモデルのメッシュがUV展開されたものであり、そのテクスチャ座標系でデカールテクスチャの貼り付けしても、実際の3Dモデル空間でのテクスチャ貼り付けはできません...!!

テクスチャ座標系でデカールテクスチャ貼り付けをすると以下のようになり、思ったように貼り付けはできません。

UVの解決がネックですね。Graphics.BlitだけではUV空間での描画しかできなさそうです。なので、テクスチャがモデルにマッピングされた後のそのモデル空間でのアプローチが良さそうな気がします。これを解決するには、「3Dモデル空間でのデカールテクスチャの投影」を考え、3Dモデルのシェーダー側でその投影がされる場所にて、デカールテクスチャの色をフェッチするというのが案として考えられます。イメージとしてはキューブマップの色をフェッチするような感じです。

ただ、前述した通り、3Dモデルのシェーダーで「デカールテクスチャを表示するだけ」だと実現できません。

今回は好きな回数だけペタペタ貼り付けたいので、単にシェーダーでデカールテクスチャを重ねて表示するだけでなく、最終結果を3Dモデルのテクスチャに描き込み、累積描画をする必要があります。

そこで、やりたいことは「シェーダーの最終結果を3Dモデルのテクスチャとして描き込みして保存」です。普段使っているMeshRendererコンポーネントを使った3Dモデルの描画はカメラのRenderTargetに描画されてしまい、今回の目的は達成できません。

テクスチャに焼くために先ほどは Graphics.Blit を使おうとしていましたが、Meshを描画する Graphics.DrawMeshNow というものもあるようです。その際、シェーダーを噛ませることが可能なので、目的を実現できそうです。

ということで、3Dモデルのレンダリング用シェーダーとは別で、Graphics.DrawMeshNowで3DモデルのMeshに対してシェーダーを走らせ、最終結果をテクスチャとして保存する方向で実装していきたいと思います。

3. 仕組み

ざっくりとした方針について考えました。実装に入る前に、もう少し具体な手法について詰めていきます。

3.1 最終結果をテクスチャに保存

まずは肝となる「Graphics.DrawMeshNowで3DモデルのMeshに対してシェーダーを走らせ、最終結果をテクスチャとして保存する」について考えていきます。

これは3Dモデルに対してUV展開そのものをすれば実現ができそうです。

通常、3Dモデルを表示する際には頂点シェーダーでは、「モデルのローカル空間 → ワールド空間 → カメラ空間」 と頂点座標を変換します。その後、カメラに映る部分だけがフラグメント(ピクセル)シェーダーに渡され、ピクセル単位での描画が行われます。

そこで、頂点シェーダーで 「モデルのローカル空間 → UV空間(UV展開) → カメラ空間」 と変換することで、最終出力をUV展開した状態で出力します。

UV空間に座標は変換しましたが、その後のフラグメントシェーダーへは本来のモデル空間での座標も頂点シェーダーから渡します。それをもとにモデル空間での座標計算を行い、デカールテクスチャを投影します。

もちろん、メッシュはUV展開された状態でカメラにマッピングされているので最終出力はUV画像となります。これらの処理は、「3Dモデルのシェーダー」とは別の「専用デカール用シェーダー」で行います。デカール用シェーダーで出力したものをテクスチャ画像として保存し、そのテクスチャを「3Dモデルのシェーダー」のMainTextureとして渡します。そのため、「デカール用シェーダー」では陰影などのシェーディングなどは行わず、UV展開とデカールテクスチャの投影のみを行います。

MainTexture 3Dモデルのシェーダーのマテリアル

(メモリをかなり圧迫していそうな問題はひとまず保留とします...)

3.2 デカールテクスチャを投影

続いてデカールテクスチャを投影する方法について考えてい行きます。角度が付いたところに弱いなどの欠点はありますが、極力シンプルな方法で行ってみたいと思います。

以下のようなイメージです。フラグメントシェーダーは3Dモデル(青球)の表面の各ピクセルで実行されます。その際に、この赤い投影エリアの中であれば、対応するデカールテクスチャ座標から色を取得します。赤い投影エリアの外であれば、そのまま3Dモデルのテクスチャの色を取得します。

図のように、モデル空間で「デカールテクスチャの板の位置・回転」を考える必要があります。

  • 座標
  • 法線方向 (ローカルy軸方向)
  • 接線方向 (ローカルx軸方向)
  • 大きさ

これらの情報があれば計算できそうです。これらはC#プログラムから、デカールテクスチャを貼り付ける際にマテリアルへ情報を渡します。

投影の計算は、デカールテクスチャ平面からピクセルへのベクトルを求め、デカールテクスチャ平面への正射影ベクトルを計算すればOKです。が、射影方向の平面上のベクトルが分からないので、接線方向(U軸)と従接線方向(V軸)で内積を取り、それぞれをそのまま平面上の2次元座標とすることとします。

4. 実装

それでは実装をしていきたいと思います。

4.1. デカール用シェーダー

4.1.1. 頂点シェーダー

ここでは、通常の変換ではなく、頂点をUV展開した座標に変換します。

頂点シェーダーの出力は以下になります。頂点をClipSpace座標に変換をして positionCS : SV_POSITION に格納して返します。

struct Varyings
{
    float4 positionCS : SV_POSITION; // ClipSpace座標
    (省略)
}

基本的にはUV展開するにはUV座標をそのまま使うだけです。一度そのまま使ってみます。

Varyings ProcessVertex(Attributes input)
{
	(省略)

	// UV座標をそのままClip空間として表示
	output.positionCS = float4(input.texcoord.xy, 0, 1);
	
	(省略)
	return output;
}

画面いっぱいに表示されず少し思っていたのとは違いますね。
UV座標は 0 \sim 1 の範囲ですが、Clip空間は -1 \sim 1 の範囲なので、Clip空間に変換するために加工を少しだけします。UV座標を2倍をして1を引くことでClip空間の座標に写像します。

注意点として、DirectX系とOpenGL系でy軸の向きが異なるようです。

ビルトイン変数 ProjectionParams.x には +1 か -1 の値が含まれています。-1 は投影が OpenGL のような投影座標に一致するように上下を逆さまに反転したことを示し、+1 は反転していないことを示します。
Unity - Manual: Platform-specific rendering differences

OpenGL系に合わせるようにDirectX系であれば反転させる必要があり、 _ProjectionParams.x には反転させていたら -1 、そのままであれば 1 が入っているようです。 -1 \sim 1 になっているので、単にこの値をかけてやれば反転できます。

書き直したものが以下になります。

Varyings ProcessVertex(Attributes input)
{
	(省略)

	// UV座標をそのままClip空間として表示
	// プラットフォームごとによる上下の違いについて:https://docs.unity3d.com/2019.1/Documentation/Manual/SL-PlatformDifferences.html
	const float x = input.texcoord.x * 2 - 1;
	const float y = input.texcoord.y * 2 - 1;
	const float flippedY = _ProjectionParams.x;
	output.positionCS = float4(x, flippedY * y, 0, 1);
	
	(省略)
	return output;
}

表示されました。これで頂点をUV展開できました。

他にも計算で使う用にオブジェクト空間の座標と法線も後続のフラグメントシェーダーに渡しています。以下は頂点シェーダーの全体になります。

struct Varyings
{
	float4 positionCS : SV_POSITION; // ClipSpace座標
	float2 texcoord : TEXCOORD0; // UV座標
	float3 positionOS : TEXCOORD1; // ObjectSpace座標
	half3 normalOS : TEXCOORD2;
};

Varyings ProcessVertex(Attributes input)
{
	Varyings output = (Varyings)0;

	// UV座標をそのままClip空間として表示
	// プラットフォームごとによる上下の違いについて:https://docs.unity3d.com/2019.1/Documentation/Manual/SL-PlatformDifferences.html
	const float x = input.texcoord.x * 2 - 1;
	const float y = input.texcoord.y * 2 - 1;
	const float flippedY = _ProjectionParams.x;
	output.positionCS = float4(x, flippedY * y, 0, 1);

	// 計算をObject空間で行うため、Object空間の法線と座標を渡す。
	// World空間でもいいが、精度が足りなくなりやすいので理由がなければObject空間で計算しておく。
	output.texcoord = TRANSFORM_TEX(input.texcoord, _AccumulateTexture);
	output.normalOS = input.normal;
	output.positionOS = input.positionOS.xyz;
	return output;
}

4.1.2. フラグメントシェーダー

頂点シェーダーでUV展開をしましたが一旦忘れて大丈夫です。 positionCS:SV_POSITIONはシステム側が描画に使うためのもので、もうこちらでは扱いません。扱うのは、Object空間における座標です。頂点シェーダーからObject空間での座標と法線をフラグメントシェーダーに渡しましたのでそれを使います。

先に全体を載せます。

half4 ProcessFragment(Varyings input) : SV_Target
{
	// 計算のために法線・接線・従接線が欲しい。
	// 念のため外積を用いて軸の方向ベクトルが直交していることを強制しておく。
	const half3 normal = normalize(input.normalOS);
	half3 decalNormal = normalize(_DecalNormal);
	half3 decalTangent = normalize(_DecalTangent);
	half3 decalBitangent = normalize(cross(decalTangent, decalNormal));
	decalTangent = normalize(cross(decalNormal, decalBitangent));

	// 1. 平面上に描画座標をマップする。
	// 平面から描画座標のベクトルを求め、平面への正射影ベクトルを求める(=平面に投影した座標)
	const half3 decalCenterToPositionVector = (input.positionOS - _DecalPositionOS) * _ObjectScale;
	const float2 positionOnPlane = float2(dot(decalTangent, decalCenterToPositionVector), dot(decalBitangent, decalCenterToPositionVector));

	// 2. 平面に投影した座標を、平面のUV座標に変換する
	const half2 unclampedUv = (positionOnPlane / _DecalSize) + 0.5; // 0~1空間に正規化するが、範囲外の場合もあるのでClampしない。0~1なら平面内。
	const half sameDirectionMask = step(0.2, dot(normal, decalNormal)); // Decalと同じ向きなら1, 逆なら0
	const half decalAreaMask = int(0 <= unclampedUv.x && unclampedUv.x <= 1 && 0 <= unclampedUv.y && unclampedUv.y <= 1); // 平面内なら1, 平面外なら0 
	const half2 uv = unclampedUv * sameDirectionMask * decalAreaMask;

	// 4. UV座標のDecalTextureの色をフェッチするだけ
	half4 decalColor = SAMPLE_TEXTURE2D(_DecalTexture, sampler_DecalTexture, TRANSFORM_TEX(uv, _DecalTexture));
	decalColor.xyz *= _Color.xyz * decalColor.xyz * decalColor.w;

	// 5. 累積テクスチャに重ねて描画する
	const half4 acc = SAMPLE_TEXTURE2D(_AccumulateTexture, sampler_AccumulateTexture, input.texcoord);
	return half4(lerp(acc.xyz, decalColor.xyz, decalColor.w), acc.w);
}

順に見ていきます。

やりたいことは描画座標をデカールテクスチャ平面上の座標に写像させ、その地点のデカールテクスチャの色を取得することです。なのでまずはデカールテクスチャ平面上の座標を求めます。

// 1. 平面上に描画座標をマップする。
// 平面から描画座標のベクトルを求め、平面への正射影ベクトルを求める(=平面に投影した座標)
const half3 decalCenterToPositionVector = (input.positionOS - _DecalPositionOS) * _ObjectScale;
const float2 positionOnPlane = float2(dot(decalTangent, decalCenterToPositionVector), dot(decalBitangent, decalCenterToPositionVector));

次にデカールテクスチャのUV座標に変換します。デカールテクスチャ平面上の座標をUV座標(0 \sim 1)にマッピングすればOKです。ただし、デカールテクスチャ平面の法線と描画地点の法線が同じ向きである場合だけ描画します。そうしないと3Dモデルの反対側にも描画されてしまいます。

// 2. 平面に投影した座標を、平面のUV座標に変換する
const half2 unclampedUv = (positionOnPlane / _DecalSize) + 0.5; // 0~1空間に正規化するが、範囲外の場合もあるのでClampしない。0~1なら平面内。
const half sameDirectionMask = step(0.2, dot(normal, decalNormal)); // Decalと同じ向きなら1, 逆なら0
const half decalAreaMask = int(0 <= unclampedUv.x && unclampedUv.x <= 1 && 0 <= unclampedUv.y && unclampedUv.y <= 1); // 平面内なら1, 平面外なら0 
const half2 uv = unclampedUv * sameDirectionMask * decalAreaMask;

あとは計算したUV座標でデカールテクスチャの色を取得するだけです。
以上でシェーダー側の実装は終わりです。

4.2. デカール用ペインタークラス(C#)

基礎処理のシェーダー(マテリアル)とのやり取り部分だけをメソッドでまとめて、提供するような単純なクラス(DecalPainter)を用意してみました。処理部分だけを使い回しがしやすいようにMonoBehaviourを継承せずただのC#クラスとしておきます。

SetPointer() でデカールを張り付ける場所などをセットします。そして Paint() でその場所にデカールを張り付けて、テクスチャを更新します。更新されたテクスチャはtextureとして参照できるのでそれを3D Modelのマテリアルの _MainTex などに使います。このtextureDecalPainternewしたときに生成され、Disposeすると破棄します。

public class DecalPainter : IDisposable
{
	(省略)
	public Texture2D texture { get; private set; }
	(省略)
	
	/// <summary>
	/// マッピング用マテリアルにデカール情報をセットする。
	/// 累積テクスチャにデカールテクスチャを重畳してるだけ。
	/// 累積テクスチャに上書きするまで累積はされていかない。
	/// </summary>
	public void SetPointer(
		Vector3 paintPositionOnObjectSpace,
		Vector3 normal,
		Vector3 tangent,
		float decalSize,
		Color color,
		Vector3 transformScale
	)
	{
		mappingMaterial.SetVector(_decalPositionOSNameID, paintPositionOnObjectSpace);
		mappingMaterial.SetFloat(_decalSizeNameID, decalSize);
		mappingMaterial.SetVector(_decalNormalNameID, normal.normalized);
		mappingMaterial.SetVector(_decalTangentNameID, tangent.normalized);
		mappingMaterial.SetColor(_colorNameID, color);
		mappingMaterial.SetVector(_objectScaleNameID, transformScale);
	}

	/// <summary>
	/// texture(累積テクスチャ)に描画
	/// </summary>
	public void Paint()
	{
		var dst = texture;

		// RenderTargetの設定
		var temporaryRenderTexture = RenderTexture.GetTemporary(dst.width, dst.height, 0);
		var activeRenderTexture = RenderTexture.active;
		RenderTexture.active = temporaryRenderTexture;

		// 対象Meshを用いて、デカール画像を累積テクスチャに重ねてRenderTargetに描画する
		GL.Clear(clearDepth: true, clearColor: true, Color.clear);
		mappingMaterial.SetPass(0);
		Graphics.DrawMeshNow(_targetMesh, Vector3.zero, Quaternion.identity);

		// RenderTargetを累積テクスチャに書き込む
		dst.ReadPixels(new Rect(0f, 0f, dst.width, dst.height), 0, 0);
		dst.Apply();

		// RenderTargetを元に戻す
		RenderTexture.active = activeRenderTexture;
		RenderTexture.ReleaseTemporary(temporaryRenderTexture);
	}
}

重要なところだけ抜き出しました。

// RenderTargetの設定
var temporaryRenderTexture = RenderTexture.GetTemporary(dst.width, dst.height, 0);
var activeRenderTexture = RenderTexture.active;
RenderTexture.active = temporaryRenderTexture;

// 対象Meshを用いて、デカール画像を累積テクスチャに重ねてRenderTargetに描画する
GL.Clear(clearDepth: true, clearColor: true, Color.clear);
mappingMaterial.SetPass(0);
Graphics.DrawMeshNow(_targetMesh, Vector3.zero, Quaternion.identity);

ここでは Graphics.DrawMeshNow を用いて実装したデカール用シェーダーの実行をしています。ドキュメントを確認します。
https://docs.unity3d.com/ja/2018.4/ScriptReference/Graphics.DrawMeshNow.html

RenderTargetに即時メッシュを描画し、material.SetPass(0)で設定すればそのマテリアルを使ってくれます。

上述のコードでは、まず現在のRenderTarget(おそらくカメラ描画に使っているもの)を退避させます。一時的なRenderTextureを取得し、それをRenderTargetとしています。取得したRenderTextureが思ったように初期化されているかは不明なのでGL.Clear()で綺麗にしておきましょう。そのあとはGraphics.DrawMeshNow()でRenderTargetに即時描画をします。

GL.Clear()のドキュメントも見ておきましょう。
https://docs.unity3d.com/ScriptReference/GL.Clear.html

4.3. 使う

MeshFilterMeshRendererのコンポーネントを持っているようなMonoBehaviourクラスで、先ほど実装したDecalPainterを使います。

テスト用なのでだいぶ手動ですが下図のようなデカールペイントができるサンプルコードです。

public class MeshPaintTester : MonoBehaviour
{
	[Header("Decal")]
	[SerializeField] MeshRenderer _decalPlane;

	[Header("Target")]
	[SerializeField] MeshFilter _targetMeshFilter;
	[SerializeField] MeshRenderer _targetMeshRenderer;

	DecalPainter _decalPainter;
	Material _targetMeshMaterial;


	void Awake()
	{
		// TargetMeshのMaterialを複製して使う (参照先マテリアルを変更したくないのでInstantiateしたMaterialをSharedに入れて使う)
		// (getterで別MaterialInstanceにして返すようなのでこういう書き方をしています)
		_targetMeshMaterial = _targetMeshRenderer.material;
		_targetMeshRenderer.sharedMaterial = _targetMeshMaterial;

		// TargetMesh専用のデカール累積テクスチャを生成し、セットする
		_decalPainter = new DecalPainter(_targetMeshFilter);
		_decalPainter.BakeBaseTexture(_targetMeshMaterial.mainTexture);
		_targetMeshMaterial.mainTexture = _decalPainter.texture;

		// デカール画像を設定する
		_decalPainter.SetDecalTexture(_decalPlane.sharedMaterial.mainTexture);
	}

	void OnDestroy()
	{
		// Textureを動的確保しているので、Disposeで破棄させています。ゴミが溜まるので忘れず呼びます。
		_decalPainter?.Dispose();
		_decalPainter = null;

		// マテリアルも別Instance化しているので忘れずに破棄します
		if (_targetMeshMaterial != null)
		{
			Destroy(_targetMeshMaterial);
		}
	}

	void Update()
	{
		if (Input.GetKeyDown(KeyCode.Space))
		{
			var decalPlaneTransform = _decalPlane.transform;

			// ペイント情報をセットアップ
			var targetMeshTransform = _targetMeshFilter.transform;
			var size = targetMeshTransform.lossyScale.x * 0.5f;
			_decalPainter.SetPointer(
				paintPositionOnObjectSpace: targetMeshTransform.InverseTransformPoint(decalPlaneTransform.position),
				normal: targetMeshTransform.InverseTransformDirection(decalPlaneTransform.up),
				tangent: targetMeshTransform.InverseTransformDirection(decalPlaneTransform.right),
				decalSize: size,
				color: Color.red,
				transformScale: targetMeshTransform.lossyScale
			);

			// 累積描画
			_decalPainter.Paint();
		}
	}
}

5. サンプルプロジェクト

GithubにUnityサンプルプロジェクトをアップしています。ブログでは一部の解説プログラムのみなので、全体はこちらで見ていただければと思います。
https://github.com/ner-develop/DecalPainter

技術ブログの基本サンプル以外に、技術ブログ冒頭でお見せした、タンクを操作してペイント弾を撃つ簡単なサンプルも入っています。

6. 欠点・課題

  • 同じデカールテクスチャでも、綺麗さが貼り付け先の3Dモデルのテクスチャ解像度の依存する
  • 3DモデルのメッシュはUV展開されており、かつUVに重複がないことが条件
    • Unity標準オブジェクトはダメ
  • 非圧縮画像を置くのでメモリ圧迫しそう
  • テクスチャが別なのでマテリアルはすべて別
    • SRP Batcherが有効になっていればFrameDebuggerではDrawCallは増加せず
  • 近づくとUV境界が汚いのが分かる

などなど欠点・課題も多いです。

また、デカールテクスチャの投影処理がシンプルなので、角度によっては伸びてしまい、面積を保持するような貼り付けはできません。

7. おわりに

一長一短なところはありますが、 _MainTex に渡して使ったり、インクだけのテクスチャとして用意することができるので、独自シェーダー側で使うという私の用途は満たせたかなと思います。

発展として、ペイント時に同時に速度ベクトルを別テクスチャに焼いて、逐次更新してインクが重力で垂れていくような表現をやってみたいと思っています。より表題の表現っぽくなりそうです。

Discussion