視界ジャックシェーダーでDepthからワールド座標を復元する
タイムライン演出でも出番が多く、暗転明転、グリッチ、色収差などの効果はよく見ると思います。
別の話として、Unityには深度テクスチャからワールド座標を復元するというテクニックがあります。
何年も前ですが、私もこちらの解説から知ることができました。ありがとうございます。
URPに至っては公式のマニュアルでコード付きの解説記事があるくらい一般的なテクニックなようです(本当か?)
VRChat環境以外であれば、Keijiro Takahashiさんがパブリックドメインで公開しているものがあるので、こちらを使うのも良いと思います(ポスプロ用のシェーダーとして作成しBlitFullscreenTriangleしているため、そのままVRChatでは使えない)
DepthTextureを生成しておく必要があり、Queue2501以上の座標は復元できないという制約はあるものの、
Projectorを用いた手法に比べてかなり低負荷(フラグメントシェーダーの内容次第ではある)
対象のマテリアルを問わず上から色を重ねることができ、個々のシェーダーに機能追加するより実装コストが低い
作成できるエフェクトの幅が広く、様々な表現を行える
スクリーンスペースでありながらスクリーンスペースではない表現を行えるので、割とVRにも適している
と非常に優秀なテクニックだと考えています。
深度テクスチャからワールド座標を復元することの解説記事はありますが、使用するための準備に触れ、視界ジャック用のシェーダーとしてそれなりに丁寧に実装し、作成できる効果まで解説しているものはまだ見当たらなかったため、ここで解説してみようと思います。
用語やなぜワールド座標を復元できるのかといった説明は省略し、既存の記事の紹介に留めます。
https://scrapbox.io/files/695a355f7830fae65a4ab0c7.png
1.視界ジャックを行う
別に巨大なCubeの内側に描画しても全く問題ないんですが、Quadをカメラに貼り付けたほうがかっこいいのでそうします。
code:viewjack
v2f vert (appdata v)
{
v2f o;
UNITY_INITIALIZE_OUTPUT(v2f, o);
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
// uvから直接clip空間座標の四隅に頂点を配置し、画面全体を覆う
o.vertex = float4(v.uv * 2.0 - 1.0, 0, 1);
return o;
}
2.MeshRendererのBoundsを修正する
Boundsが何かという説明は割愛します。
アバターの服や髪が見る向きによって消える現象に遭遇したり、それで白い四角い箱の位置を直したことがある人は結構いると思います、アレです。
別に視界ジャック用のMeshRendererをSkinnedMeshRendererに変更してBoundsを修正しても全く問題ないんですが、MeshRendererのままBoundsを修正したほうがかっこいいのでそうします。
MeshRendererはインスペクタ上からBoundsを変更することができません。シェーダーで動かさなければ各頂点は動かず、Boundsの大きさも変わらないからでしょう。逆に、シェーダーで頂点を動かす場合はBoundsを拡大しなければならないということです。
事前にMeshアセットのBoundsを修正しておく方法と、UdonでランタイムにBoundsを修正する方法があります。
視界ジャック用のメッシュを1回作って使いまわす方が楽なので、私は前者の方法でやっています。
以下のようなスクリプトで、Boundsを修正したメッシュアセットを作っています。
code:MeshBoundsModifier
using UnityEditor;
using UnityEngine;
public class MeshBoundsModifier : EditorWindow
{
Mesh orgMesh;
Vector3 newCenter = Vector3.zero;
Vector3 newSize = new(100f, 100f, 100f);
DefaultAsset saveFolder;
static void ShowWindow() => GetWindow<MeshBoundsModifier>("MeshBoundsModifier");
void OnGUI()
{
orgMesh = (Mesh)EditorGUILayout.ObjectField("SourceMesh", orgMesh, typeof(Mesh), false);
saveFolder = (DefaultAsset)EditorGUILayout.ObjectField("Save Folder", saveFolder, typeof(DefaultAsset), false);
newCenter = EditorGUILayout.Vector3Field("New Center", newCenter);
newSize = EditorGUILayout.Vector3Field("New Size", newSize);
using (new EditorGUI.DisabledScope(orgMesh == null))
if (GUILayout.Button("Execute")) BoundsMod();
}
void BoundsMod()
{
var srcPath = AssetDatabase.GetAssetPath(orgMesh);
if (string.IsNullOrEmpty(srcPath))
{
Debug.LogError("Please select an asset Mesh (from Project).");
return;
}
var folderPath = (saveFolder && AssetDatabase.IsValidFolder(AssetDatabase.GetAssetPath(saveFolder)))
? AssetDatabase.GetAssetPath(saveFolder)
: System.IO.Path.GetDirectoryName(srcPath).Replace("\\", "/");
var baked = Instantiate(orgMesh);
baked.name = orgMesh.name + "BoundsModify";
var b = baked.bounds;
b.center = newCenter;
b.size = new Vector3(Mathf.Abs(newSize.x), Mathf.Abs(newSize.y), Mathf.Abs(newSize.z));
baked.bounds = b;
var savePath = $"{folderPath}/{baked.name}.asset";
if (AssetDatabase.LoadAssetAtPath<Mesh>(savePath)) AssetDatabase.DeleteAsset(savePath);
AssetDatabase.CreateAsset(baked, savePath);
AssetDatabase.SaveAssets();
Debug.Log($"Bounds modify completed: {savePath}");
}
}
作成されたメッシュのインスペクターを確認すると、Boundsが拡大されていることがわかります。
1000とか10000とか、カメラが存在し得る範囲をカバーするようにします。
https://scrapbox.io/files/695a1cdd64c0ea37fb31aca3.png
これでどこを向いても視界ジャック用のQuadが描画されるようにになります。
3.DepthTextureを有効にする
DepthTextureが何かという説明は割愛します。
こちらが分かりやすいと思います。
今のVRChatでDepthTextureを有効にするには2通りの方法があります。
UdonでCamera.depthTextureMode=DepthTextureMode.Depthにする
しかし、ScreenCameraとPhotoCamera、更に追加で設置したカメラがあればそれに対しても設定が必要なので、実際のところ面倒です。後述の方法と負荷について比較してはいないので、その点でメリットがある場合や、カメラ毎に指定したい場合は良いと思います。
影付きディレクショナルライトを置く
これはある程度詳しい人ほど間違えやすく、また信じてもらえなかったりするのですが、このとき配置する影付きディレクショナルライトのCulling Maskには深度を書き込みたいオブジェクトのレイヤーを含める必要はありません。
なにも配置していないレイヤー(Water等)だけをCulling Maskに設定しても、DepthTextureは生成されます。
カメラの描画対象に含まれているレイヤーである必要はあります。
何か配置されているレイヤーを含めるとそのShadowCasterの分Batch数が増えてしまうので、何もないレイヤーだけを対象にする方が良いです。
何もないレイヤーだけを対象にしてもBatch数はある程度増加します。
アバターでも使える方法で、またVRCCameraSettingsが解放される前はこの方法しかなかったため、私は今のところこちらの方法でやっています。
以下のような設定で置いています。
https://scrapbox.io/files/695a239adf1ed901ddfee193.png
4.DepthTextureからワールド座標を復元する
本題です。
流石にコードとコメントが一番わかりやすそうなので全文提示します。
なぜこれでワールド座標が復元できるかについては冒頭にも紹介したこちらの記事が非常にわかりやすいです。
code:PositionFromDepth
Shader "AY_Shader/PositionFromDepth"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="Overlay" "Queue"="Overlay" "DisableBatching" = "True" "IgnoreProjector" = "True"}
LOD 100
Cull Off
ZTest Always
ZWrite Off
Blend One Zero
Pass
{
CGPROGRAM
struct appdata
{
// float4 vertex : POSITION; // モデル本来の頂点座標は使ってないので要らない
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 vertex : SV_POSITION;
float4 screenPos: TEXCOORD0;
float3 viewDirWS : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
v2f vert (appdata v)
{
v2f o;
UNITY_INITIALIZE_OUTPUT(v2f, o);
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
// uvから直接clip空間座標の四隅に頂点を配置し、画面全体を覆う
o.vertex = float4(v.uv * 2.0 - 1.0, 0, 1);
float2 ndc = o.vertex.xy;
#if UNITY_UV_STARTS_AT_TOP ndc.y = -ndc.y;
// viewDirWSがワールド座標復元に必要なので、
// NDCからワールド空間まで逆変換(ベクトル・正規化前)を行って戻す
float3 viewDirVS = mul(unity_CameraInvProjection, float4(ndc, 0, 1)).xyz;
o.viewDirWS = mul((float3x3)UNITY_MATRIX_I_V, viewDirVS);
// DepthTexture取得用
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}
float4 frag (v2f i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
// DepthTexture取得
float2 uv = i.screenPos.xy / i.screenPos.w;
uv = UnityStereoTransformScreenSpaceTex(uv);
float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv);
// Skybox領域を除外したい場合(用途による)
#if defined(UNITY_REVERSED_Z) clip(rawDepth - 1e-6);
clip((1.0 - 1e-6) - rawDepth);
// ワールド座標復元
float3 camPos = _WorldSpaceCameraPos;
float3 camDir = unity_CameraToWorld._m02_m12_m22; // カメラ正面方向
float3 viewDir = normalize(i.viewDirWS); // カメラから各ピクセルへの方向
float forwardDot = max(1e-6, dot(camDir, viewDir)); // 念のためゼロ除算回避
float depth = LinearEyeDepth(rawDepth) / forwardDot;
float3 worldPos = camPos + viewDir * depth; // カメラ座標、向き、長さが揃いワールド座標が計算できる
// デバッグ用のライン描画
float3 fPos = frac(worldPos);
float3 fw = fwidth(worldPos);
float3 debugLine = smoothstep(fw, 0.0, min(fPos, 1.0 - fPos));
// 簡易な法線復元(ddx/ddyベースなので軽量だが2x2ピクセル単位)
float3 dpdx = ddx(worldPos);
float3 dpdy = ddy(worldPos);
float3 normal = normalize(cross(dpdy, dpdx));
debugLine *= 1.0 - abs(normal); // 法線と近い軸のDebugLineを減衰(y=0の床面等に描画したくない)
float4 col = 1.0;
col.rgb = debugLine;
// 法線を見たい場合
// col.rgb = normal * 0.5 + 0.5;
return col;
}
ENDCG
}
}
}
これを初めて作ったとき、なぜunity_CameraInvProjectionとUNITY_MATRIX_I_Vなのかというところでかなり詰まりました。
transpose(UNITY_MATRIX_P)やunity_CameraToWorldだと正しくなりません。
なおUNITY_MATRIX_I_Pはそもそも組み込み関数として存在しません。
私もちゃんと理解しているというわけではないのですが、こういったシェーダーを書くのであればこれらの変換行列の内容が異なっているということは押さえておいた方が良いです。
少し凝ったGPUパーティクル的なものや、レイマーチング的なものを書く場合も引っかかる可能性がありそうです。
5.エフェクトを作る
取得したworldPosを使って、ワールドのオブジェクトに様々なエフェクトを上乗せすることができます。
もちろん実際は動くエフェクトを作りたいわけですが、その部分はここでは省略します。
フェイクライト
以下の記事で紹介した選択肢の1つです。
光源の座標と色をプロパティやグローバル配列から渡したり、シェーダー内で決定することで光が当たっているような表現ができます。
単なる加算合成にするか、GrabPassと組み合わせて元の色に乗算して加算するか…など選ぶ余地があります。
https://scrapbox.io/files/695a33cecc7366cb9a49dc8b.png
code:fakelightsample
Properties
{
HDR_Color ("Color", Color) = (1,1,1,1) _FakeLightPos ("FakeLightPos", Vector) = (0,0,0)
}
SubShader
{
Tags { "RenderType"="Overlay" "Queue"="Overlay" "DisableBatching" = "True" "IgnoreProjector" = "True"}
LOD 100
Cull Off
ZTest Always
ZWrite Off
Blend One One // 加算合成にする
...
float4 frag (v2f i) : SV_Target
{
...
float3 worldPos = camPos + viewDir * depth;
float4 col = 1.0;
float3 diff = worldPos - _FakeLightPos;
float dist2 = dot(diff, diff);
col.rgb = _Color / max(1e-3, dist2);
return col;
}
ENDCG
}
}
}
パターンを描画する
これを応用して任意の場所から広がる波紋のようなエフェクトを作ったりします。
https://scrapbox.io/files/695a2bcfdf7906da2e56252d.png
code:pattern1
float dist = length(worldPos);
col.rgb = _Color * smoothstep(0.9, 1.0, sin(dist * UNITY_PI));
3Dノイズ等を描画する
Voronoiを例に挙げましたが、それなりに負荷があるのでその辺は内容次第で工夫する必要があるかもしれません。
いずれにしても3Dのパターンを描けるというのが重要です。
TriPlanarと組み合わせてテクスチャを貼ったりするのも良いかもしれません。
https://scrapbox.io/files/695a2e93eb8c100e420f90ba.png
code:voronoi
float3 Hash33(float3 p)
{
return frac(sin(float3(
dot(p, float3(127.1, 311.7, 74.7)),
dot(p, float3(269.5, 183.3, 246.1)),
dot(p, float3(113.5, 271.9, 124.6))
)) * 43758.5453);
}
float Voronoi3D(float3 p)
{
float3 i = floor(p), f = frac(p);
float md = 1e9;
for (int z=-1; z<=1; z++)
for (int y=-1; y<=1; y++)
for (int x=-1; x<=1; x++)
{
float3 g = float3(x,y,z);
float3 o = Hash33(i + g);
float3 r = g + o - f;
md = min(md, dot(r,r));
}
return sqrt(md);
}
float4 frag (v2f i) : SV_Target
{
...
float value = Voronoi3D(worldPos);
col.rgb = _Color * value * value;
return col;
}
ENDCG
Voronoi同様のループ内でFakeLight的な計算を行うことで、散らばった点光源から光を受けているような表現もできます。
視界ジャックではなく、パーティクル用のシェーダーでCVSに光源としての情報を入れて使う、といったアプローチもあります。
https://scrapbox.io/files/695a343e1006662fb7737ddb.png