Vket6ブース「うさみみ雑貨店」解説

# 概要
初投稿です。

https://twitter.com/BigVinegar/status/1428668455729856512

バーチャルマーケット6に出展したブース「うさみみ雑貨店」の機構解説です。
サンプルコードは[GitHub](https://github.com/myxyy/RotateCullingExample)にあります。

# 回転数の記録
機構で作られる空間は0回転目、1回転目…の複数の世界を持ち、n回転で世界が一巡するようになっています。（ブースでは3回転分、サンプルコードでは2回転分の世界がある）
機構では軸の周りにプレイヤーやオブジェクトが何回転したかという情報をfloat値で保持してnの剰余をとるという方法で世界の判別を行っています。

```ObjectAngleManager.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

public class ObjectAngleManager: UdonSharpBehaviour
{
    [SerializeField] private Transform axis;
    [SerializeField] private Transform trackingObject;
    [SerializeField] private int objectInitialPhase = 0;
    [SerializeField] private float netNormalizedAngle;
    private float normalizedAngle;
    private Matrix4x4 WorldToAxisMatrix;
    private const float tau = Mathf.PI * 2.0f;
    public float getModuloAngle(int m)
    {
        return Mathf.Floor(Mathf.Repeat(netNormalizedAngle, m)) + normalizedAngle;
    }

    private float getFloatAngle(Vector3 p)
    {
        return Mathf.Repeat(Mathf.Atan2(p.z, p.x) / tau, 1);
    }

    private float getNormalizedAngle(float previousNetNormalizedAngle, float currentNormalizedAngle)
    {
        var currentNetNormalizedAngleCandidate_0 = Mathf.Floor(previousNetNormalizedAngle) + currentNormalizedAngle;
        var currentNetNormalizedAngleCandidate_m = currentNetNormalizedAngleCandidate_0 - 1;
        var currentNetNormalizedAngleCandidate_p = currentNetNormalizedAngleCandidate_0 + 1;
        var diff0 = Mathf.Abs(currentNetNormalizedAngleCandidate_0 - previousNetNormalizedAngle);
        var diffm = Mathf.Abs(currentNetNormalizedAngleCandidate_m - previousNetNormalizedAngle);
        var diffp = Mathf.Abs(currentNetNormalizedAngleCandidate_p - previousNetNormalizedAngle);
        if (diffm < diff0)
            return currentNetNormalizedAngleCandidate_m;
        else if (diff0 < diffp)
            return currentNetNormalizedAngleCandidate_0;
        else
            return currentNetNormalizedAngleCandidate_p;
    }

    void Start()
    {
        if (trackingObject == null) trackingObject = this.transform;
        WorldToAxisMatrix = axis.localToWorldMatrix.inverse;
        normalizedAngle = getFloatAngle(WorldToAxisMatrix.MultiplyPoint3x4(trackingObject.position));
        netNormalizedAngle = normalizedAngle + objectInitialPhase;
    }

    public void Update()
    {
        if (!axis.gameObject.isStatic) WorldToAxisMatrix = axis.localToWorldMatrix.inverse;
        if (!trackingObject.gameObject.isStatic)
        {
            var objectWorldPos = trackingObject.position;
            var objectAxisPos = WorldToAxisMatrix.MultiplyPoint3x4(objectWorldPos);
            normalizedAngle = getFloatAngle(objectAxisPos);
            netNormalizedAngle = getNormalizedAngle(netNormalizedAngle, normalizedAngle);
        }
    }
}
```

`netNormalizedAngle`が軸の周りの回転数で、`trackingObject`が軸の周りを反時計周りに5周半回ったら`netNormalizedAngle`は`5.5`となります。

getNormalizedAngleは前フレームの`netNormalizedAngle`と現在フレームの軸周りの角度（getFloatAngleで取得します）から現在フレームの`netNormalizedAngle`を計算するメソッドです。
前フレームの世界がn週目であるとき、現在フレームの世界は

* n-1週目
* n週目
* n+1週目

の3パターンがあり、getNormalizedAngleはそのうち前フレームとの差分が一番小さい場合を現在の世界として採用するようになっています。

# オブジェクトの描画

オブジェクトを消す処理はシェーダを用いてピクセル単位でオブジェクトの描画をclipすることで実装しています。
世界がn周（n=`_Period`）で一巡する場合、シェーダには

* プレイヤーのnetNormalizedAngleのnの剰余（`_PlayerAngle`）
* 描画するオブジェクトのnetNormalizedAngleのnの剰余（`_ObjectAngle`）
* 軸のモデル行列（`_WorldToAxis`）

が毎フレーム流し込まれます：

```CullObjectGimickManager.cs

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

public class CullObjectGimickManager: UdonSharpBehaviour
{
    [SerializeField] private ObjectAngleManager playerAngle;
    [SerializeField] private ObjectAngleManager objectAngle;
    [SerializeField] private Transform axis;
    [SerializeField] private int period = 1;
    private Material material;
    void Start()
    {
        if (objectAngle == null) objectAngle = this.GetComponent<ObjectAngleManager>();
        if (period < 1) period = 1;
        material = this.GetComponent<MeshRenderer>().material;
        material.SetFloat("_Period", period);
    }
    void Update()
    {
        material.SetFloat("_PlayerAngle", playerAngle.getModuloAngle(period));
        material.SetFloat("_ObjectAngle", objectAngle.getModuloAngle(period));
        Matrix4x4 worldToAxis = axis.transform.localToWorldMatrix.inverse;
        material.SetMatrix("_WorldToAxis", worldToAxis);
    }
}
```

シェーダの処理としては単に描画ピクセル位置と視点位置が軸を中心に180°以上の角度があれば描画しない、という処理をやっているだけです。
ただしn周で一巡する世界の場合、シェーダーにはプレイヤー、オブジェクトの軸回りの回転数として0～360n°の角度が渡されるため（実際は0～n）、これら角度をnで割り、180/n°以上離れているときに描画しない、という処理になっています。

```CullObjectUnlit.shader
Shader "Room720/CullObjectUnlit"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_Color ("Color", color) = (1,1,1,1)
		_PlayerAngle ("PlayerNormalizedAngle", Float) = 0
		_ObjectAngle ("ObjectNormalizedAngle", Float) = 0
		_Period ("Period", int) = 1
		[MaterialToggle]_debug ("debug", float) = 0
	}
	SubShader
	{
	Tags { "RenderType"="Opaque" "Queue"="Geometry" }
		LOD 100

		Pass
		{
			Cull Back
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#define tau (UNITY_PI*2.)
			
			#include "UnityCG.cginc"

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

			struct v2f
			{
				float2 uv : TEXCOORD0;
				float4 vertex : SV_POSITION;
				float3 axisSpacePixelPos : TEXCOORD1;
				nointerpolation float normalizedCameraAngle: TEXCOORD2;
				nointerpolation float3 axisSpaceObjectPos : TEXCOORD3;
				nointerpolation float normalizedObjectAngle: TEXCOORD4;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;
			float _PlayerAngle;
			float _ObjectAngle;
			float4x4 _WorldToAxis;
			int _Period;
			fixed4 _Color;
			float _debug;

			float dotFloatAngles(float a, float b)
			{
				return dot(float2(cos(a * tau), sin(a * tau)), float2(cos(b * tau), sin(b * tau)));
			}

			float getFloatAngle(float3 p)
			{
				return frac(atan2(p.z, p.x) / tau);
			}

			float getNormalizedAngle(float previousNormalizedAngle, float absoluteAngle)
			{
				float normalizeAngleCandidate_m = (floor(frac((previousNormalizedAngle * _Period - 1) / _Period) * _Period) + absoluteAngle) / _Period;
        		float normalizeAngleCandidate_0 = (floor(previousNormalizedAngle * _Period) + absoluteAngle) / _Period;
        		float normalizeAngleCandidate_p = (floor(frac((previousNormalizedAngle * _Period + 1) / _Period) * _Period) + absoluteAngle) / _Period;
        		float dotm = dotFloatAngles(previousNormalizedAngle, normalizeAngleCandidate_m);
        		float dot0 = dotFloatAngles(previousNormalizedAngle, normalizeAngleCandidate_0);
        		float dotp = dotFloatAngles(previousNormalizedAngle, normalizeAngleCandidate_p);
       			 if (dotm > dot0)
					return normalizeAngleCandidate_m;
    		    else if (dot0 > dotp)
					return normalizeAngleCandidate_0;
    		    else
					return normalizeAngleCandidate_p;
			}


			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);

				float3 worldSpaceCameraPos = _WorldSpaceCameraPos;
				#if defined(USING_STEREO_MATRICES)
					worldSpaceCameraPos = (unity_StereoWorldSpaceCameraPos[0] + unity_StereoWorldSpaceCameraPos[1]) * .5;
				#endif

				float playerAngle = getFloatAngle(mul(_WorldToAxis, float4(worldSpaceCameraPos,1)).xyz);
				o.normalizedCameraAngle = getNormalizedAngle(_PlayerAngle / _Period, playerAngle);

				float3 axisSpaceObjectPos = mul(_WorldToAxis, mul(unity_ObjectToWorld, float4(0, 0, 0, 1))).xyz;

				float objectAngle = getFloatAngle(axisSpaceObjectPos);
				o.normalizedObjectAngle = getNormalizedAngle(_ObjectAngle / _Period, objectAngle);

				o.axisSpaceObjectPos = axisSpaceObjectPos;
				o.axisSpacePixelPos = mul(_WorldToAxis, mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.))).xyz;

				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				float ncangle = i.normalizedCameraAngle;
				float noangle = i.normalizedObjectAngle;
				float3 aopos = i.axisSpaceObjectPos;
				float3 appos = i.axisSpacePixelPos;
				fixed4 col = tex2D(_MainTex, i.uv)*_Color;

				float sign = cross(normalize(float3(aopos.x, 0., aopos.z)), normalize(float3(appos.x, 0., appos.z))).y < 0 ? (1) : (-1);
				float absRelAngle = acos(clamp(dot(normalize(float2(aopos.x, aopos.z)), normalize(float2(appos.x, appos.z))), -1, 1)) / tau;
				float npangle = frac((noangle*_Period + sign * absRelAngle) / _Period);

				if (!_debug) clip(tau / 2 - acos(clamp(dotFloatAngles(ncangle, npangle), -1, 1))*_Period);

				return col;
			}
			ENDCG
		}
	}
}

```
Udonを用いた機構ではプレイヤーの頭の位置をトラッキングしており、正確な視点位置はシェーダで再計算しています。
0~2πに各角度を正規化した上で内積のacosを取るという処理をしていますがこのあたりもっと良い方法がある気もします。

# コライダーの切り替え
ブースで登れるようになる階段のコライダーはプレイヤーと階段が一定角度以内のときのみ出現するようになっています。

```SetActiveColliderByAngle.cs

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

public class SetActiveColliderByAngle : UdonSharpBehaviour
{
    [SerializeField] private ObjectAngleManager playerAngle;
    [SerializeField] private Collider collider;
    [SerializeField] private int period = 1;
    [SerializeField] private float appearMin = 0.0f;
    [SerializeField] private float appearMax = 1.0f;
    [SerializeField] private Transform axis;
    [SerializeField] private Transform trackingObject;
    [SerializeField] private int objectInitialPhase = 0;
    [SerializeField] private float netNormalizedAngle;
    private float normalizedAngle;
    private Matrix4x4 WorldToAxisMatrix;
    private const float tau = Mathf.PI * 2.0f;
    public float getModuloAngle(int m)
    {
        return Mathf.Floor(Mathf.Repeat(netNormalizedAngle, m)) + normalizedAngle;
    }
    private float dotFloatAngles(float a, float b)
    {
        var va = new Vector2(Mathf.Cos(tau * a), Mathf.Sin(tau * a));
        var vb = new Vector2(Mathf.Cos(tau * b), Mathf.Sin(tau * b));
        return Vector2.Dot(va, vb);
    }
    private float getFloatAngle(Vector3 p)
    {
        return Mathf.Repeat(Mathf.Atan2(p.z, p.x) / tau, 1);
    }
    private float getNormalizedAngle(float previousNetNormalizedAngle, float currentNormalizedAngle)
    {
        var currentNetNormalizedAngleCandidate_0 = Mathf.Floor(previousNetNormalizedAngle) + currentNormalizedAngle;
        var currentNetNormalizedAngleCandidate_m = currentNetNormalizedAngleCandidate_0 - 1;
        var currentNetNormalizedAngleCandidate_p = currentNetNormalizedAngleCandidate_0 + 1;
        var diff0 = Mathf.Abs(currentNetNormalizedAngleCandidate_0 - previousNetNormalizedAngle);
        var diffm = Mathf.Abs(currentNetNormalizedAngleCandidate_m - previousNetNormalizedAngle);
        var diffp = Mathf.Abs(currentNetNormalizedAngleCandidate_p - previousNetNormalizedAngle);
        if (diffm < diff0)
            return currentNetNormalizedAngleCandidate_m;
        else if (diff0 < diffp)
            return currentNetNormalizedAngleCandidate_0;
        else
            return currentNetNormalizedAngleCandidate_p;
    }
    void Start()
    {
        if (trackingObject == null) trackingObject = this.transform;
        WorldToAxisMatrix = axis.localToWorldMatrix.inverse;
        normalizedAngle = getFloatAngle(WorldToAxisMatrix.MultiplyPoint3x4(trackingObject.position));
        netNormalizedAngle = normalizedAngle + objectInitialPhase;
    }
    public void Update()
    {
        if (!axis.gameObject.isStatic) WorldToAxisMatrix = axis.localToWorldMatrix.inverse;
        if (!trackingObject.gameObject.isStatic)
        {
            var objectWorldPos = trackingObject.position;
            var objectAxisPos = WorldToAxisMatrix.MultiplyPoint3x4(objectWorldPos);
            normalizedAngle = getFloatAngle(objectAxisPos);
            netNormalizedAngle = getNormalizedAngle(netNormalizedAngle, normalizedAngle);
        }
 
        float relativeAngle = Mathf.Repeat(playerAngle.getModuloAngle(period) - getModuloAngle(period), period);
        bool isAppeared =
            (appearMin < appearMax) ?
            (appearMin < relativeAngle && relativeAngle < appearMax) :
            (appearMin < relativeAngle || relativeAngle < appearMax);
        collider.enabled = isAppeared;
    }
}
```

コライダーのオンオフは

```
        float relativeAngle = Mathf.Repeat(playerAngle.getModuloAngle(period) - getModuloAngle(period), period);
        bool isAppeared =
            (appearMin < appearMax) ?
            (appearMin < relativeAngle && relativeAngle < appearMax) :
            (appearMin < relativeAngle || relativeAngle < appearMax);
        collider.enabled = isAppeared;
```

の部分で行っています。  
プレイヤーの角度ではなくプレイヤーとオブジェクトの相対角度を基に判定を行っているのはオブジェクトが動く場合のためで、サンプルコードではキューブを持って軸の周りを歩くことができます（オブジェクトから手を離して一周するとピックアップ用コライダーが消え触れられなくなる）。

# 感想
Twitterで見る限りでは思っていたより反響があり良かったです。
何かの参考やインスピレーションの元になれば幸いです。

課題点としては

* 基本的に全てのオブジェクトが描画されているので重い
  - コライダー消す容量でオブジェクトごと消せば良い？
* 管理が大変
* できたらプレイヤーの描画も消したい

等があります。特にプレイヤー消すのはかなり欲しいので誰か作って（他力本願）