コポうぇぶろぐ

コポコポによる備忘録&Tipsブログ

視差オクルージョンマッピング(parallax occlution mapping)

はじめに

本記事では、Parallax Occlution Mapping(視差オクルージョンマッピング)の実装について簡単にまとめてみました。
インテリアマッピング実装時に視差マップを試したところ、その上位互換?があったので気になって試しに実装してみました。本記事はその備忘録のようなものになります。

視差マッピング

視差マッピングとは、視線ベクトルと法線ベクトルの角度、ハイトマップの高さ情報元に、UVを視線ベクトル方向にずらす手法です。こちらは「視差マッピング」で調べていただくと沢山情報がありますので、是非ご覧ください。余り大きい高低差の表現には向かず、微妙な凸凹や数センチ程度の凹みでしか表現できなさそうでした。

視差オクルージョンマッピング(Parallax Occlution Mapping)

視差オクルージョンマッピングとは、上記の視差マッピングの手法の上位互換版で、視線の向きにあった遮蔽前後関係を計算し色を決める手法になります。(オクルージョン=手前にある物体が背後にある物体を隠して見えないようにする状態のこと)。


メッシュより奥に高低差が出来るような見た目になります。
(メッシュより手前に出ることはないです)
Tweetのような山を表現するよりは、亀裂等のような”凹み”との相性がよさそうです。

github.com

実装

面を低くする

視差オクルージョンマッピングでは「視点/視線」によって変化します。

f:id:coposuke:20190116031300j:plain:w400

視線の先はハイトマップによって左右されますが、ハイトマップが真っ黒(高さ=0)であれば、こちらで指定した高低差情報の最底面に衝突することになります。まずは簡単にこちらの実装から進めていきます。

f:id:coposuke:20190116031458j:plain:w400

こちらの画像を見てわかる通り、視線ベクトル方向にUVがずれていることが想像できるかと思います。ひとまず簡単に視線方向にUVをずらしてみます。

Shader "Custom/ParallaxOcclusionMapping"
{
	Properties
	{
		_MainTex ("Main Texture", 2D) = "white" {}
		_HeightMap("Height Map", 2D) = "white" {}
		_HeightScale("Height", Float) = 0.5
	}

	SubShader
	{
		Tags
		{
			"RenderType"="Opaque"
		}

		LOD 200

		Pass
		{
			CGPROGRAM
			#include "UnityCG.cginc"
			#pragma vertex vert
			#pragma fragment frag

			sampler2D _MainTex;
			sampler2D _HeightMap;
			float _HeightScale;

			struct Vertex
			{
				float4 position : POSITION;
				float3 normal : NORMAL;
				float2 uv : TEXCOORD0;
			};

			struct Vertex2Fragment
			{
				float4 position : SV_POSITION;
				float3 normal : NORMAL;
				float2 uv : TEXCOORD0;
				float3 objectViewDir : TEXCOORD1;
			};

			Vertex2Fragment vert(Vertex i)
			{
				Vertex2Fragment o;
				
				o.position = mul(unity_ObjectToWorld, i.position);
				o.normal = i.normal;
				o.uv = i.uv;
				o.objectViewDir = o.position - _WorldSpaceCameraPos.xyz;
				o.position = mul(UNITY_MATRIX_VP, o.position);

				return o;
			}

			float4 frag(Vertex2Fragment i) : SV_TARGET
			{
				float3 rayDir = i.objectViewDir;
				float2 uv = i.uv;

				// 元々のUVに視線分だけずらしたもの
				uv += rayDir.xz * _HeightScale;
				return tex2D(_MainTex, uv);
			}
			ENDCG
		}
	}
}

視線に合わせてUVをずらしているだけですが、既にカメラをくるくる動かすとそれっぽく見えます。
f:id:coposuke:20190119032932g:plain:w400
しかし、rayDirはカメラから点までのベクトル(視線+距離)が入っているので、
カメラを遠ざけたり近づけたりすると面の深さが変わってしまいます。
なので、_HeightScaleの高さに固定しましょう。

struct Vertex2Fragment
{
	…
	float3 objectViewDir : TEXCOORD1;
	float3 objectPos : TEXCOORD2;		// 追記
};

Vertex2Fragment vert(Vertex i)
{
	…
	o.objectViewDir = o.position - _WorldSpaceCameraPos.xyz;
	o.objectPos = o.position;		// 追記
	…
}

float4 frag(Vertex2Fragment i) : SV_TARGET
{
	float3 rayDir = normalize(i.objectViewDir);
	float3 rayPos = i.objectPos;
	float2 uv = {0, 0};
				
	float rayScale = (-_HeightScale / rayDir.y);
	float3 rayStep = rayDir * rayScale;

	rayPos = rayPos + rayStep;

	uv = rayPos.xz;
	return tex2D(_MainTex, uv);
}

平面の位置から視線方向にある最低面のワールド座標を求めます。
これは正規化された視線ベクトルが何個目で最低面に衝突するかという計算で、
Y軸だけを考えればよいので「最低面 / 視線ベクトルのY」となります。
下記画像をご覧いただけるとイメージがつかみやすいかと思います。
f:id:coposuke:20190119064116j:plain:w400

これで高低差=0.5(unit)とした、最底面(Y=-0.5)の位置にテクスチャが描画されます。
1Unit立方体の最低面と一致した動きになっているかと思います。
f:id:coposuke:20190119071811g:plain:w400

1点問題として、この変更では頂点情報のUVを利用していないので、
ワールド座標のXZ軸に沿ってテクスチャがスクロールしてしまいます。

本来は頂点情報のUVから用いるべきですが、
余計な計算が増えてしまうので説明用にこの状態のまま進めていきます。
(UV計算が気になる方はGithubのソースをご覧ください)

シンプルにレイを進めてみる

POMを実装する前に、rayDirを適当な尺度で進めてみましょう。
実はこのようなテキトウな実装でも、ある程度立体的な見た目が作れます。

float4 frag(Vertex2Fragment i) : SV_TARGET
{
	float3 rayDir = normalize(i.objectViewDir);
	float3 rayPos = i.objectPos;
	float rayHeight = 0.0;			// 追記
	float objHeight = -_HeightScale;	// 追記
	float2 uv = {0,0};
				
	//float rayScale = (-_HeightScale / rayDir.y);	// 今回は使わない
	//float3 rayStep = rayDir * rayScale;		// 今回は使わない
				
	for (int i = 0; i < 32 && objHeight < rayHeight; ++i)
	{
		rayPos += rayDir *0.01;	// テキトウに0.01ずつ進める
		uv = rayPos.xz;

		objHeight = tex2D(_HeightMap, uv).r;
		objHeight = objHeight * _HeightScale - _HeightScale;
		rayHeight = rayPos.y;
	}

	return tex2D(_MainTex, uv);
}

ジャギジャギしていますが、こんなのでも立体的に見えるようになったかと思います。
f:id:coposuke:20190119155520g:plain:w400

rayPosをrayDirの向きに0.01ずつ進めています。
ハイトマップから得た高さ情報(0.0~1.0)を加工して「-_HeightScale~0.0」の範囲とし、
このobjHeightとrayPos.y(rayHeight)の高さが逆転する(交差する)場所を求めています。
f:id:coposuke:20190119170839g:plain:w400

ただし、このアルゴリズムではレイの進行距離に限界があるので、
rayDirが平面と平行に近くなるにつれて表示がおかしなことになります。

現状では0.01 * 32しか進んでおりませんので、
rayPosが0.32進んだあたりで進行を止めて、その場のUVを拾ってきている状態です。
f:id:coposuke:20190119171859g:plain:w400

高さを決めてレイを進めてみる

ここからPOMのアルゴリズムに合わせていきます。
シンプルにレイを進めても進行距離に限界があったので、その欠点を解消していきます。

最初に実装した「面を低くする」で底面を描画したと思いますが、
POMには必ず底面が存在しますので、底面がゴールになるようにレイを進行させていきます。

f:id:coposuke:20190119200903j:plain:w400
底面までの距離を分割していく。ミルフィーユみたいな感じ。

float4 frag(Vertex2Fragment i) : SV_TARGET
{
	float3 rayDir = normalize(i.objectViewDir);
	float3 rayPos = i.objectPos;
	float rayHeight = 0.0;			// 追記
	float objHeight = -_HeightScale;	// 追記
	float2 uv = {0,0};
				
	const int HeightSamples = 32;				// 追記
	const float HeightPerSample = 1.0 / HeightSamples;	// 追記
	float rayScale = (-_HeightScale / rayDir.y);
	float3 rayStep = rayDir * rayScale * HeightPerSample;	// 変更
				
	for (int i = 0; i < HeightSamples && objHeight < rayHeight; ++i)
	{
		rayPos += rayStep;
		uv = rayPos.xz;

		objHeight = tex2D(_HeightMap, uv).r;
		objHeight = objHeight * _HeightScale - _HeightScale;
		rayHeight = rayPos.y;
	}

	return tex2D(_MainTex, uv);
}

ジャギジャギしていますが、これで先程に比べて角度に強くなったかと思います。
段になっている箇所も分割数で固定されたので、角度を変えても縞々の位置は変わりません。
f:id:coposuke:20190119201459g:plain:w400

線形補間で滑らかにする

ここまで高さに関する調整を行いましたが、いまだジャギジャギしています。
これは、進み過ぎたrayPosが存在しており、適切なUVが取ってこれていない為です。

ここでPOMのアルゴリズムである高低差を考慮した2点間の線形補間を行います。

float4 frag(Vertex2Fragment i) : SV_TARGET
{	
	…
	for (int i = 0; i < HeightSamples && objHeight < rayHeight; ++i)
	{
		…
	}

	// Parallax Occlusion Mapping(以下全て追加)
	float2 nextObjPoint = uv;
	float2 prevObjPoint = uv - rayStep.xz;
	float nextHeight = objHeight;
	float prevHeight = tex2D(_HeightMap, prevObjPoint).r * _HeightScale - _HeightScale;
	nextHeight -= rayHeight;
	prevHeight -= rayHeight - rayStep.y;

	float weight = nextHeight / (nextHeight - prevHeight);
	uv = lerp(nextObjPoint, prevObjPoint, weight);
				
	return tex2D(_MainTex, uv);
}

衝突検知した段と検知前の段のUVをうまい事補間することで綺麗に描画されるようになりました。
f:id:coposuke:20190120030112g:plain:w400

下記画像の緑色の丸のUVになるように補間しています。
この補間のポイントは、緑色の丸はレイの進行線上になるというところです。
f:id:coposuke:20190120023759j:plain:w400

weightの計算が複雑なので解説いたしますと、
ハイト情報と交差したということは、nextHeightは必ず正数になり、prevHeightは必ず負数になります。

weight = nextHeight / (nextHeight - prevHeight);

この様にウェイトを求めることで、レイ上になるように計算されます。
ただし、画像にもあるように万能ではなく、実際のハイト情報とのズレが生じますが、
厳しい角度や近くで見ない限りは気にならないレベルです。
f:id:coposuke:20190120025830j:plain:w400

POMの実装は以上になります。
法線や頂点情報のUVを利用した実装に関してはモチベがあがったら書きます。

二分木探索で滑らかにする(おまけ)

Relief Parallax Mappingという二分木探索で交点を求める手法もあります。(POMとは関係ありません)

float4 frag(Vertex2Fragment i) : SV_TARGET
{	
	…
	for (int i = 0; i < HeightSamples && objHeight < rayHeight; ++i)
	{
		…
	}

	// Relief Parallax Mapping(2分木探索するだけ)
	// rayの向きを逆にして検索します(通り過ぎた分を戻していく)
	const int ReliefIteration = 5;
	for (int i = 0; i < ReliefIteration; ++i)
	{
		rayStep /= 2;
					
		objHeight = tex2D(_HeightMap, uv).r * _HeightScale - _HeightScale;
		rayHeight = rayPos.y;

		if(rayHeight < objHeight)
			rayPos -= rayStep;
		else
			rayPos += rayStep;
	}

	return tex2D(_MainTex, uv);
}

rayStepを半分にし「rayPosに減算で戻し、加算で進める」を繰り返します。
rayPosがハイト情報より上か下かを判定し、徐々に交点に近づけていきます。
f:id:coposuke:20190120033619g:plain:w400

探索回数が多いほどPOMより精度があがりますが、処理が重いのが難点です。
ゲームで利用する場合は多くの場合POMのほうが良さげな気がします。

まとめ

ちょっと重いかなーとも思いましたが、ハイエンドゲームに使われているそうです。
レイが面と平行に近いと表示が乱れるので、段を等間隔にするのではなく、
面に近い高さを細かく、底面に近い高さを粗く出来たら良くなるかも?
といったことを考えました。