視差オクルージョンマッピング(parallax occlution mapping)
はじめに
本記事では、Parallax Occlution Mapping(視差オクルージョンマッピング)の実装について簡単にまとめてみました。
インテリアマッピング実装時に視差マップを試したところ、その上位互換?があったので気になって試しに実装してみました。本記事はその備忘録のようなものになります。
目次
視差マッピング
視差マッピングとは、視線ベクトルと法線ベクトルの角度、ハイトマップの高さ情報元に、UVを視線ベクトル方向にずらす手法です。こちらは「視差マッピング」で調べていただくと沢山情報がありますので、是非ご覧ください。余り大きい高低差の表現には向かず、微妙な凸凹や数センチ程度の凹みでしか表現できなさそうでした。
視差オクルージョンマッピング(Parallax Occlution Mapping)
視差オクルージョンマッピングとは、上記の視差マッピングの手法の上位互換版で、視線の向きにあった遮蔽前後関係を計算し色を決める手法になります。(オクルージョン=手前にある物体が背後にある物体を隠して見えないようにする状態のこと)。
やっとParallaxOcclutionMappingになった…
— コポ🌻本販 (@coposuke) 2018年12月27日
前回はただレイを等間隔に進めてたので届いていないところがジャギジャギしてたけど、ミルフィーユを線形補間する方法がPOMみたい。
今日帰ったらブログにまとめるZOY。 pic.twitter.com/36ynM70Ixw
メッシュより奥に高低差が出来るような見た目になります。
(メッシュより手前に出ることはないです)
Tweetのような山を表現するよりは、亀裂等のような”凹み”との相性がよさそうです。
実装
面を低くする
視差オクルージョンマッピングでは「視点/視線」によって変化します。
視線の先はハイトマップによって左右されますが、ハイトマップが真っ黒(高さ=0)であれば、こちらで指定した高低差情報の最底面に衝突することになります。まずは簡単にこちらの実装から進めていきます。
こちらの画像を見てわかる通り、視線ベクトル方向に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をずらしているだけですが、既にカメラをくるくる動かすとそれっぽく見えます。
しかし、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」となります。
下記画像をご覧いただけるとイメージがつかみやすいかと思います。
これで高低差=0.5(unit)とした、最底面(Y=-0.5)の位置にテクスチャが描画されます。
1Unit立方体の最低面と一致した動きになっているかと思います。
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); }
ジャギジャギしていますが、こんなのでも立体的に見えるようになったかと思います。
rayPosをrayDirの向きに0.01ずつ進めています。
ハイトマップから得た高さ情報(0.0~1.0)を加工して「-_HeightScale~0.0」の範囲とし、
このobjHeightとrayPos.y(rayHeight)の高さが逆転する(交差する)場所を求めています。
ただし、このアルゴリズムではレイの進行距離に限界があるので、
rayDirが平面と平行に近くなるにつれて表示がおかしなことになります。
現状では0.01 * 32しか進んでおりませんので、
rayPosが0.32進んだあたりで進行を止めて、その場のUVを拾ってきている状態です。
高さを決めてレイを進めてみる
ここからPOMのアルゴリズムに合わせていきます。
シンプルにレイを進めても進行距離に限界があったので、その欠点を解消していきます。
最初に実装した「面を低くする」で底面を描画したと思いますが、
POMには必ず底面が存在しますので、底面がゴールになるようにレイを進行させていきます。
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); }
ジャギジャギしていますが、これで先程に比べて角度に強くなったかと思います。
段になっている箇所も分割数で固定されたので、角度を変えても縞々の位置は変わりません。
線形補間で滑らかにする
ここまで高さに関する調整を行いましたが、いまだジャギジャギしています。
これは、進み過ぎた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をうまい事補間することで綺麗に描画されるようになりました。
下記画像の緑色の丸のUVになるように補間しています。
この補間のポイントは、緑色の丸はレイの進行線上になるというところです。
weightの計算が複雑なので解説いたしますと、
ハイト情報と交差したということは、nextHeightは必ず正数になり、prevHeightは必ず負数になります。
weight = nextHeight / (nextHeight - prevHeight);
この様にウェイトを求めることで、レイ上になるように計算されます。
ただし、画像にもあるように万能ではなく、実際のハイト情報とのズレが生じますが、
厳しい角度や近くで見ない限りは気にならないレベルです。
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がハイト情報より上か下かを判定し、徐々に交点に近づけていきます。
探索回数が多いほどPOMより精度があがりますが、処理が重いのが難点です。
ゲームで利用する場合は多くの場合POMのほうが良さげな気がします。
まとめ
ちょっと重いかなーとも思いましたが、ハイエンドゲームに使われているそうです。
レイが面と平行に近いと表示が乱れるので、段を等間隔にするのではなく、
面に近い高さを細かく、底面に近い高さを粗く出来たら良くなるかも?
といったことを考えました。