【Unity】シェーダーで世界を曲げる

# はじめに

この記事では、「世界を曲げる表現」をシェーダーで実現する方法を紹介します。
「世界を曲げる表現」とは、以下のようなものです。

[ASTRONEER: Glitchwalkers](https://store.steampowered.com/app/3042220/ASTRONEER_Glitchwalkers)
<img src="https://www.4gamer.net/games/317/G031730/20190207039/SS/003.jpg">
[Before We Leave](https://store.steampowered.com/app/1073910/Before_We_Leave/)
<img src="https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/1073910/ss_48cf3bc5d9bc9788d061948857b740334a773653.1920x1080.jpg?t=1729002350">

奥にあるオブジェクトほど沈み込み、側面や底面が見えるようになっています。
また、地平線が弧を描くように曲がっています。
この記事では、このような表現をシェーダーで実現する方法を詳しく解説します。

# 作ったもの

曲がった世界 その1
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3493963/3247b0ba-3006-08d3-2334-17c2e5c936d0.gif" width=720>

曲がった世界 その2
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3493963/bb73d5e8-e4ea-33e9-9f88-5511379d10f8.gif" width=720>

曲げる前の平たい世界
![スクリーンショット 2024-12-08 4.25.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3493963/446f7119-f1d0-cb1b-cbb5-af3d268e000c.png)

## シェーダーコード全文

```hlsl
Shader "Sample/WorldBending"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Amount ("Amount", Range(-1, 1)) = 0
    }
    SubShader
    {
        Tags
        {
            "RenderType"="Opaque"
            "RenderPipeline"="UniversalPipeline"
        }
        LOD 100

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct appdata
            {
                float4 posOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float4 posHCS : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float _Amount;

            v2f vert(appdata v)
            {
                v2f o;

                // カメラ座標からのオフセットを求める
                float3 posWS = TransformObjectToWorld(v.posOS);
                float3 cameraPosWS = _WorldSpaceCameraPos;
                float3 offsetFromCamera = posWS - cameraPosWS;

                // 曲げた後のワールド座標を求める
                float bendByOffsetX = pow(offsetFromCamera.x, 2) * -_Amount;
                float bendByOffsetZ = pow(offsetFromCamera.z, 2) * -_Amount;
                float bend = bendByOffsetX + bendByOffsetZ;
                posWS.y += bend;

                // ワールド空間からクリップ空間に変換
                float3 bendedPosOS = TransformWorldToObject(posWS);
                o.posHCS = TransformObjectToHClip(bendedPosOS);
                
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            half4 frag(v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.uv);
            }
            ENDHLSL
        }
    }
}
```

### ポイント解説

```
float bendByOffsetX = pow(offsetFromCamera.x, 2) * -_Amount;
float bendByOffsetZ = pow(offsetFromCamera.z, 2) * -_Amount;
float bend = bendByOffsetX + bendByOffsetZ;
```

ここでは、カメラからの距離が同じであれば同じ曲げ量（`bend`）となるように計算を行っています。
これは中学校で習う三平方の定理を利用しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3493963/74c20493-a4e1-d278-705f-3366f30d4120.jpeg" width=400>

また、指数関数の結果に対して`_Amount`で調整を加えることで、擬似的な球体を表現しています。

## オブジェクトが消える問題とその解決策

上述のシェーダーを適用すると、画面奥のオブジェクトがパッと消える現象が起こるようになります。
下のGIFでは、左奥のオブジェクトが消えています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3493963/3d83c4d6-b428-7037-ad14-ea905fa9b9e7.gif" width=720>

これはオクルージョンカリングによるものです。
オクルージョンカリングは、GPU側で頂点シェーダが実行されるより前にCPU側で行われます。
頂点移動する前のオブジェクトがカメラの視錐台より外に存在するため、レンダリングされなくなってしまっています。

この問題は、オクルージョンカリングに利用される行列（`Camera.cullingMatrix`）を調整することで解決できます。
`cullingMatrix`には`projectionMatrix * worldToCameraMatrix`が格納されているため、任意のプロジェクション行列を使って調整することが可能です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3493963/93833244-b478-ddbd-407a-7313f787091f.jpeg" width=400>

下記のコードは、Orthographicなプロジェクション行列を利用して調整したものです。

```cs
	private void OnEnable() {
		if (!Application.isPlaying) {
			return;
		}

		RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
		RenderPipelineManager.endCameraRendering += OnEndCameraRendering;
	}

	private void OnDisable() {
		RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
		RenderPipelineManager.endCameraRendering -= OnEndCameraRendering;
	}

	private static void OnBeginCameraRendering(ScriptableRenderContext ctx, Camera cam) {
		const float orthoSize = 30f; // 要調整
		const float nearClip = 0.001f; // 要調整
		const float farClip = 1000f; // 要調整
		var aspectRatio = (float)Screen.width / Screen.height;
		var orthoWidth = orthoSize * aspectRatio;
		cam.cullingMatrix =
			Matrix4x4.Ortho(-orthoWidth, orthoWidth, -orthoSize, orthoSize, nearClip, farClip) *
			cam.worldToCameraMatrix;
	}

	private static void OnEndCameraRendering(ScriptableRenderContext ctx, Camera cam) {
		cam.ResetCullingMatrix();
	}
```

### 調整手順

自分の場合は以下の手順で調整を行いました。

1. ワールドを曲げた状態で動き回り、表示されないオブジェクトを確認
2. ワールドを曲げていない状態（MaterialのAmount=0）にし、カメラをOrthographicに切り替え
3. 表示されなかったオブジェクトがカメラ内に映るようにSizeを調整
4. ワールドとカメラを元の状態に戻し、手順1から繰り返し

# おわりに

シェーダーで「世界を曲げる表現」を実現しました。
ぜひ、あなたのゲームにもこの技術を取り入れてみてください。

# 参考にしたリンクなど

- [notslot/tutorial-world-bending](https://github.com/notslot/tutorial-world-bending) : 大いに参考にしたリポジトリ  
  ShaderGraphを利用し、同様の表現を実現しています
- [How camera.cullingMatrix work](https://discussions.unity.com/t/how-camera-cullingmatrix-work/943574) : `cullingMatrix = projectMatrix * worldToCameraMatrix` となっていることを知った記事
- [【Unity】PerspectiveなカメラのProjection行列をスクリプトで上書きしてOrthographicなカメラにする](https://light11.hatenadiary.com/entry/2019/04/17/012544) : Orthographicなプロジェクション行列の作成について
- [RPG Poly Pack - Lite](https://assetstore.unity.com/packages/3d/environments/landscapes/rpg-poly-pack-lite-148410) : 利用した3Dモデルのアセット