はじめに
これまでいくつかボリュームレンダリングの記事を書いてきました。以下は前回の記事です。
これまではキューブポリゴンを描画範囲として 3D テクスチャのボリュームレンダリングを行っていましたが、これだとポリゴンの内部にカメラが入り込んだときにカリングされてしまいます。
今回は内部にカメラが入っても継続して描画できるようにするには、どのように修正を加えればよいかの解説を行います。
リポジトリ
10. Inside Volume
が今回の内容になります。
原因と対策
先程も触れましたが、カメラがポリゴンの内部に入り込むと、ポリゴンは描画されません。正確には、カメラのニアクリップがオブジェクトの内部に入り込むと…、なので下図のようになります。
図の赤い部分のポリゴンが表示されなくなる感じです。解決策としては、Cull Off
にしてポリゴンの裏側も描画するようにし、レイの開始点をニアクリップ位置になるようにします(これまでのコードはポリゴン表面からレイをカメラ方向に打っていました)。
実装
ではこのコードを実装してみましょう。まずは前回までのおさらい…、というかコードの前提だけ確認しておきます。これまでのボリュームレンダリングでやっていることは、レイを飛ばしてステップごとに 3D テクスチャや密度関数をサンプリングし、ライティングを考慮しながら該当ピクセルの色を決定している感じです。このレイの開始点はこれまではポリゴンの表面からでした。
Shader "CloudLitInsideVolume" { ... CGINCLUDE ... v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.projPos = ComputeScreenPos(o.vertex); COMPUTE_EYEDEPTH(o.projPos.z); return o; } float4 frag(v2f i) : SV_Target { float3 worldPos = i.worldPos; float3 worldDir = normalize(worldPos - _WorldSpaceCameraPos); ... for (int i = 0; i < loop; ++i) { ... (レイを飛ばして 3D テクスチャや密度関数をサンプルして色を決定) } ... return color; } ENDCG SubShader { Tags { "Queue" = "Transparent" "RenderType" = "Transparent" } Pass { Cull Back ZWrite Off Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag ENDCG } } }
この i.worldPos
に頂点シェーダから渡ってきたそのピクセルでのポリゴンの座標が入ってきています。さて、先程図で見たように、Cull Back
から Cull Off
にした場合は、この座標がポリゴン裏側、つまり向かいの面の裏側の座標となってしまいます。これをどうにか判定して、最終的に内部にあるときにはニアクリップ面からレイを飛ばすようにする必要があります。
そこで裏側かどうかではなく、現在のニアクリップ面がキューブの内部に入っているかどうかを判定することにします。まず、とあるワールド座標が現在レンダリング中のキューブ(辺の長さが 1 m の立方体)に含まれているかの判定は以下の isInnerCube
で行えます。
inline float3 toLocal(float3 pos) { return mul(unity_WorldToObject, float4(pos, 1.0)).xyz; } inline float3 getScale() { return float3( length(float3(unity_ObjectToWorld[0].x, unity_ObjectToWorld[1].x, unity_ObjectToWorld[2].x)), length(float3(unity_ObjectToWorld[0].y, unity_ObjectToWorld[1].y, unity_ObjectToWorld[2].y)), length(float3(unity_ObjectToWorld[0].z, unity_ObjectToWorld[1].z, unity_ObjectToWorld[2].z))); } inline bool isInnerCube(float3 pos) { pos = toLocal(pos); float3 scale = getScale(); return all(max(scale * 0.5 - abs(pos), 0.0)); }
そしてニアクリップ面までの距離を以下の getDistanceFromCameraToNearClipPlane
で行います。
inline float3 getCameraPosition() { return UNITY_MATRIX_I_V._m03_m13_m23; } inline float getCameraFocalLength() { return abs(UNITY_MATRIX_P[1][1]); } inline float getCameraNearClip() { return _ProjectionParams.y; } inline float getDistanceFromCameraToNearClipPlane(float4 projPos) { projPos.xy /= projPos.w; projPos.xy = (projPos.xy - 0.5) * 2.0; projPos.x *= _ScreenParams.x / _ScreenParams.y; float3 norm = normalize(float3(projPos.xy, getCameraFocalLength())); return getCameraNearClip() / norm.z; }
これらを組み合わせて、ニアクリップ面の座標を求め、それがキューブ内に入っていたら例の開始点を上書きするように修正します。
float4 frag(v2f i) : SV_Target { float3 worldPos = i.worldPos; ... float3 cameraPos = getCameraPosition(); float distToNearClipPlane = getDistanceFromCameraToNearClipPlane(i.projPos); float3 nearClipPos = cameraPos + distToNearClipPlane * worldDir; if (isInnerCube(nearClipPos)) { worldPos = nearClipPos; } ... } }
これで内部に入れるようになります。
なお、2 パスにして、最初は表、次は裏(このときの開始点はニアクリップ面)とやっても良いかもしれません。
デプスのパスの修正
前回、デプス比較することによる通常のポリゴンとの干渉について書きましたが、上記コードだけでは次のように内部に入ったタイミングでパタパタしてしまいます。
これは ZTest On
をしていることで、そもそもこのキューブの裏側ポリゴンのピクセルシェーダが駆動されなくなっています。通常、レンダリングでは手前のものから順番に描画し、後ろ側の見えないオブジェクトの不要なピクセルシェーダが駆動されないようにしています(これは頂点シェーダで出力した SV_POSITION
セマンティクスのついた座標の情報を使って自動的に行われています)。少しパフォーマンス的にお行儀が悪いですが ZTest Off
にしてしまうことで、このデプス比較を切ってしまい、常に描画されるようにしてみます。すると次のようにカメラが近づいても正しく表示されるようになりました。
デモ
DICOM の記事で見たテクスチャの中に入るのもやってみました。
3D テクスチャの中に入るのもやってみたhttps://t.co/AtFyjokfu8 pic.twitter.com/PLTcDay6C3
— 凹 (@hecomi) 2023年8月27日
おわりに
ちなみに、これらの計算は uRaymarching でも行っています。興味がある方はこちらもご参考いただけると幸いです。
追記
書き終わったあとに、@karasusan さんが同じ内容書かれているのに気づきました…。