VRChatで全天球画像を表示する方法

# １、概要
完成図はこんな感じになります。
https://youtu.be/DYld7as4qHQ

ワールドココです。
https://www.vrchat.com/home/launch?worldId=wrld_2a3ce568-6345-491d-849b-ec526afbb499&instanceId=61571
申し訳ないですが、まだNewUserなのでフレンドのフレンドまでしか入れません。

記事内に書いてあるライトルームは全て「Lightroom Classic」になります。
ただの「Lightroom」にはパノラマ合成機能は有りません。
adbeのフォトプランに必要な「Lightroom Classic」と「Photoshop」が付いてくるのでお勧めです。
https://www.adobe.com/jp/creativecloud/photography.html

# ２、写真を撮影する。
##２－１、機材
![名称付き.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/c99af351-86e4-145b-9237-f60c56e5a5df.jpeg)
持ち運び最優先の機材になります。
この組み合わせなら三脚が必要なく一脚もコンパクトな為、出張道具の隙間に紛れ込ませることも可能です。
一応パノラマとして繋げられるので十分な撮影は出来ていると思います。
ただし、天頂と真下の撮影が出来ない事、レンズの位置を保持する精度が落ちる事が本物のパノラマヘッドより劣ります。
amazonでは最安5000円くらいからパノラマヘッドが有るようですので
色々と探して自分に合った機材を集めるのが良いと思います。
カメラから購入する必要がある場合はまず「どのレンズを使用するか」を決めてからカメラを選んだ方が色々楽です。
魚眼は特殊なレンズであるためどのカメラでも十分な種類があるとは言えません。
カメラの機種によって選べるレンズが狭まりますので先にレンズを選んだ方が良いです。
センサーがAPS-Cなら8mmの魚眼を選ぶと、良い感じかなと思っています。
魚眼と超広角はまったく別の物ですので注意が必要です。

## ２－２、カメラのセッティング

・保存形式はRAW
・絞り(F値)、シャッタースピード、ISO感度は全てマニュアル
・半押しでフォーカス、露出を自動調整「しない」ように設定
　特に露出は絶対に固定で行う必要があります。
　撮影途中で露出が大幅に再補正された場合、後処理が出来なくなる事があります。
・フォーカス調整を行うボタンを「半押し以外」のボタンで設定
・露出ブラケット撮影を出来る場合はなるべく広いレンジで露出ブラケットを設定

###マニュアル露出調整の指針
一脚の場合、シャッタースピードは1/30くらいが下限になります。
僕のセッティングだと下記くらいを狙うようにしています。
・シャッタースピード最低でも1/30 sec 
・Ｆ値 8.0
・ISO感度 200

露出調整は下記の順番で行います。
シャッタースピード(最低1/30) > F値 > ISO(最高でも 400未満に抑える)


## ２ー３、撮影手順
![後ろからみた図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/b56372dc-f895-0d51-6c77-609a64a9f0b0.jpeg)
撮影の際に近い構図のカメラ



１、フォーカス、露出関係を合わせる
ピントが合って欲しい辺りに合わせます。
魚眼レンズの場合、ピントが合う範囲が非常に広いためほぼ全域で合うと思います。

露出関係の参考値は下記になります。
・シャッタースピード最低でも1/30 sec 
・Ｆ値 8.0
・ISO感度 200(最高でも400未満)
必要に応じて左から順に合わせます。
シャッタースピード > F値 > ISO
シャッタースピードでどうにもならなければF値、ISOと触ります。

２、始点の目印になりそうな物を中央へ映す
液晶を見ながら始点の目印になりそうな物を中央へ持ってきます。
この場合は道を中央へ映しています。
![中央位置調整.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/8628f295-e2fb-0c3a-00d8-9873bdf09416.jpeg)

３、方角を合わせる。
どちらの方角でも構いませんが、コンパスの周囲のダイヤルを回してNの位置を合わせます。
0度、60度、120度、180度(S)、240度、300度、360度(0度)
で撮影をする為、計算がしやすいように合わせておく必要があります。
![方角を合わせます。.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/5e0df0f4-2dbc-28c4-fa7e-299f14510c4e.jpeg)

４、水平を合わせ、撮影する
水準器で水平を合わせ、合い次第撮影します。
ファインダーや液晶は一切見ません。
撮影時は水準器のみを見て撮影します。

尚、下記は基本的に撮影が終わるまで固定となります。
・シャッタースピード
・Ｆ値
・ISO感度
・フォーカス
露出関係は特に動くとヤバいので注意が必要です。

５、方角を合わせる。
0度、60度、120度、180度(S)、240度、300度、360度(0度)
撮影ごとに順番で合わせます。

６、水平を合わせ、撮影する
水準器で水平を合わせ、合い次第撮影します。
ファインダーや液晶は一切見ません。
撮影時は水準器のみを見て撮影します。
2回目以降の撮影では露出、フォーカスの調整は行いません。

７、全周の撮影
５，６を繰り返して全部の角度で撮影します。

注意点
とにかく水平が大事です。
撮影時に水平を保つことに全神経を注いでください。
HAKUBAの水準器は他と比べて値段高めですが、とても重要な部分なので良い物を使った方が良いです。

全周を撮影する場合、どんな状況でも広めのブラケット撮影を推奨します。
連射だったとしても大して画質は下がりませんし、
動体が多くても意外とうまく補正してくれます。
加えて全周の撮影という事は逆光での撮影を強いられることが非常に多く、
大きな明暗差は避けられません。
このためとりあえずブラケット撮影を行うくらいの対応が必要になります。

パノラママウントの制約で天頂と真下は諦めています。

# ３、画像処理
幾つかの方法がありますが、今回はライトルームを使用しました。
（オカネを掛けれるなら多分PTGuiの方が綺麗に出来るかと思います。
特にPTGui Proに付いてる32bit tiffを作って後からトーンカーブ修正は惹かれるものがあります。）

３－１、LightRoomClassicへ写真を読み込む
LightRoomClassicを起動して「ファイル→写真とビデオを読み込み」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/bbe327f3-9627-5ce1-cc67-0683116d86ad.png)

３－２、Shiftを押しながら始点と終点を選択
読み込みが終了し、写真が表示されたら写真を選択します。
撮影の際道を中央へ配置したので、道を目印にします。
最初と最後にほぼ同じ道の絵が有るので、画像で分かると思います。
Shiftを押しながら始点と終点を選択します。
この画面ではIMG_1911～IMG_1920になります。
本来であれば写真7枚、ブラケット撮影を行っていれば21枚の写真を選択する事になります。
（レンズやパノラマヘッドの内容が違うため、写真枚数は画面と一致しません）
![最初と最後をほぼ同じ構図にすると分かりやすい.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/c605d48e-4e97-07b5-5e44-3acb3f92b40d.jpeg)

３－３、パノラマツールで合成します。
「２」で選択した画像を右クリックすると「写真結合→パノラマ」とかがあると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/db9ca000-88ee-84c6-d70a-126ebd90b2bb.png)

球面法じゃないと後処理が通らないので注意
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/a3bcc5ca-1a86-9ad6-104b-ce018d1468c1.png)

３－４、横長画像をフォトショップで正方形にする。(PhotoshopのScript使用)
このサイトのスクリプトを参考に改造しました。
http://sabaten.com/blog/182/

内部的には
１、元画像の解像度を16384*4096へリサイズ
２、解像度8192*8192の画像を新規作成
３、元画像の左半分8192*4096を新規画像の上半分へ貼り付け
４、元画像の右半分8192*4096を新規画像の下半分へ貼り付け
５、保存
６、全画像を閉じる
を行っています。

この画像を下の2行の画像へ変換します。
この画像を
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/752db18d-5561-8514-edeb-97c76e60992b.png)

この画像のように2行化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/d1317ec2-b1a0-638a-4ccd-d379e2f2187c.png)


下のコードをフォトショの"C:\Program Files\Adobe\Adobe Photoshop 2020\Presets\Scripts"
へ入れると下記みたいな感じで使いやすくなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/89c3b2ad-b153-0720-a617-67d6bd3984f1.png)

``` 全天球ファイル変換.jsx
/*
    <javascriptresource>
    <name>天球用ファイルサイズ変換</name>
    <about>天球用ファイルサイズ変換</about>
    </javascriptresource>
*/

//====初期化====
var convertWidth=8192; //横画面の変更後の幅解像度

var jpegQuality=10; //JPEG出力時のクオリティ(0~12)
var outPutPath="D:\\photgra\\pict\\panorama\\熊野座神社\\output\\"; //任意の出力パス

var saveRulerUnits=preferences.rulerUnits; //単位の保存
preferences.rulerUnits=Units.PIXELS; //単位をピクセルに

//====開いている画像の取得====
var baseFile=activeDocument; //現在アクティブなドキュメントを取得
var baseName=baseFile.name.split(".")[0]; //ファイル名の取得

//====保存の設定====
var saveName=baseName+"_ConvedPano.jpg"; //保存ファイル名の設定 
var saveFile=new File(outPutPath+saveName); //保存パス＋保存ファイル名 

//====画像のコピー====
var x=baseFile.width; //ベースファイルの横幅を取得
var y=baseFile.height; //ベースファイルの高さを取得
baseFile.selection.selectAll(); //全選択
baseFile.selection.copy(); //選択範囲のコピー


//====新規ドキュメントに貼り付け====
var newFile=documents.add(x , y,baseFile.resolution,"New File"); //新規ファイルの作成
newFile.paste(); //貼り付け
newFile.flatten(); //レイヤー結合


//====サイズの変更====
newFile.resizeImage(convertWidth * 2 ,convertWidth / 2, newFile.resolution , ResampleMethod.AUTOMATIC );

//====変換先ファイルの作成====
var convertedFile = documents.add(convertWidth , convertWidth ,baseFile.resolution,"New File"); //新規ファイルの作成

//====変換元ファイルからコピー====
app.activeDocument = newFile;
var regionUp= [ [0, 0], [0, convertWidth / 2 ], [convertWidth, convertWidth / 2 ] , [convertWidth, 0] ];
newFile.selection.select(regionUp, SelectionType.REPLACE, 0, false);
newFile.selection.copy(); //選択範囲のコピー

//====変換先へペースト====
app.activeDocument = convertedFile;
var pasteRegion1 = [ [0, 0], [0, convertWidth / 2 ], [convertWidth, convertWidth / 2 ] , [convertWidth, 0] ];
convertedFile.selection.select(pasteRegion1, SelectionType.REPLACE, 0, false);
convertedFile.paste( true );

//====変換元ファイルからコピー====
app.activeDocument = newFile;
var regionBottom= [ [convertWidth, 0], [convertWidth, convertWidth / 2 ], 
	[convertWidth * 2, convertWidth / 2] , [convertWidth * 2 , 0] ];
	
newFile.selection.select(regionBottom, SelectionType.REPLACE, 0, false);
newFile.selection.copy(); //選択範囲のコピー

//====変換先へペースト====
app.activeDocument = convertedFile;
var pasteRegion2 = [ [0, convertWidth / 2], [0, convertWidth ], [convertWidth , convertWidth  ] , [convertWidth , convertWidth / 2] ];
convertedFile.selection.select(pasteRegion2, SelectionType.REPLACE, 0, false);
convertedFile.paste( true );
convertedFile.flatten(); //レイヤー結合

//====JPEGで保存====
var jpegSaveOpt=new JPEGSaveOptions(); //JPEG保存設定
jpegSaveOpt.quality=jpegQuality; //JPEGクオリティ
jpegSaveOpt.embedColorProfile=false;
jpegSaveOpt.formatOptions=FormatOptions.OPTIMIZEDBASELINE;

convertedFile.saveAs(saveFile,jpegSaveOpt,true,Extension.LOWERCASE);

//====新規ドキュメントを閉じる====
baseFile.close(SaveOptions.DONOTSAVECHANGES);
baseFile=null;

newFile.close(SaveOptions.DONOTSAVECHANGES);
newFile=null;

convertedFile.close(SaveOptions.DONOTSAVECHANGES);
convertedFile=null;

//====元ファイルを閉じる====
//baseFile.close(SaveOptions.DONOTSAVECHANGES);
//baseFile=null;

preferences.rulerUnits=saveRulerUnits; //単位を元に戻す

```

# ４、全天球シェーダーを作る。

ここのコードを参考に作成しました。
https://stackoverflow.com/questions/37088286/360-viewer-in-unity-texture-appears-warped-in-the-top-and-bottom

``` SphealImage.shader

Shader "Unlit/SphealImage"
{
	Properties{
		_Color("Main Color", Color) = (1,1,1,1)					//何もない所の色
		_MainTex("Diffuse (RGB) Alpha (A)", 2D) = "gray" {}		//全天球画像
		_PitchMin("PitchMin" , Range(0.0, 1.0)) = 0.1			//上の表示範囲
		_PitchMax("PitchMax" , Range(0.0, 1.0)) = 0.9			//下の表示範囲
		_YawMin("YawMin" , Range(0.0, 1.0)) = 0.0				//写真の横幅
		_YawMax("YawMax" , Range(0.0, 1.0)) = 1.0				//写真の横幅
		_Alpha("Opacity" , Range(0.0, 1.0)) = 1.0				//何もない場所の不透明度
		_NoiseTex("Noize", 2D) = "Noise" {}						//消えるときに使用するノイズ画像
		_NoiseLoopU("NoiseLoopU" ,float ) = 8					//ノイズ画像の繰り返し数
		_NoiseLoopV("NoiseLoopV" , float) = 2					//ノイズ画像の繰り返し数
	}

		SubShader{
			Tags {  "Queue" = "AlphaTest+10 " }
			Pass {

				//Cull Front
				//ZWrite Off
				ZTest Always
				//Blend SrcAlpha OneMinusSrcAlpha

				CGPROGRAM
					#pragma vertex vert
					#pragma fragment frag
					//#pragma fragmentoption ARB_precision_hint_fastest
					//#pragma glsl
					//#pragma target 3.0

					#include "UnityCG.cginc"

					struct appdata {
					   float4 vertex : POSITION;
					   float3 normal : NORMAL;
					};

					struct v2f
					{
						float4    pos : SV_POSITION;
						float3    normal : TEXCOORD0;
					};

					sampler2D _NoiseTex;
					float _NoiseLoopU;
					float _NoiseLoopV;

					v2f vert(appdata v)
					{
						v2f o;
						o.pos = UnityObjectToClipPos(v.vertex);
						o.normal = v.normal;

						return o;
					}

					sampler2D _MainTex;
					float _PitchMin;
					float _PitchMax;
					float _YawMin;
					float _YawMax;
					float4 _Color;
					float _Alpha;

					#define PI 3.141592653589793

					//法線の向きをテクスチャ座標へ変換
					inline float2 RadialCoords(float3 a_coords)
					{
						float3 a_coords_n = normalize(a_coords);
						float lon = 0.0;

						lon = atan2(a_coords_n.z, a_coords_n.x);
						float lat = acos(a_coords_n.y);
						float2 sphereCoords = float2(lon, lat) * (1.0 / PI);
						return float2(1.0 - (sphereCoords.x * 0.5 + 0.5),  sphereCoords.y);
					}

					//テクスチャ座標を2行テクスチャ
					inline float2 RdAdjust(float2 inCord )
					{
						float2 res;
						res.x = (inCord.x - _YawMin) / (_YawMax - _YawMin);
						res.y = (inCord.y - _PitchMin) / (_PitchMax - _PitchMin);
						res.x *= 2.0;
						res.y *= 0.5;

						if (res.x > 1.0)
						{
							res.x -= 1.0;
							res.y += 0.5;
						}

						return res;
					}

					//ピクセルシェーダ
					float4 frag(v2f IN) : COLOR
					{
						//法線をテクスチャ座標化
						float2 equiUV = RadialCoords(IN.normal);

						//指定範囲内かのチェック
						if (equiUV.x >= _YawMin && equiUV.x <= _YawMax &&
							equiUV.y >= _PitchMin && equiUV.y <= _PitchMax )
						{

							float4 res = tex2D(_MainTex, RdAdjust(equiUV ));
							res.a = 1.0;

							//ノイズ用UV作成、
							float2 noiseTexUv = float2(equiUV.x * _NoiseLoopU, equiUV.y * _NoiseLoopV);
							float texAlpha = tex2D(_NoiseTex, RdAdjust(noiseTexUv));
							clip(_Alpha - texAlpha);
							
							return res;
						}

						float4 res = _Color;
						res.a = _Alpha;
						return _Color;


					}
				ENDCG
			}
		}
	FallBack "VertexLit"
}

``` 

後はメタセコかなんかで面を反転させた球を作るか、
UNITYの標準の球に「//Cull Front」の行を有効化するかします。

下図はメタセコで面を反転した球を作ってる図です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/c78b7ad7-9be5-35b9-1d82-d084a0437613.png)

後はUNITYへ組み込めば表示されます。
RenderQueueはシェーダーへ組み込んだものの方が優先されます。
マテリアルの設定ではなくシェーダーで必要に応じて調整してください。
今回作成したワールドでは表示用のエフェクトの都合でレンダリング順をかなり後の方にしています。

エディター画面上ではマテリアル設定が優先されるので、編集の都合に合わせて設定してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32364/f15d985b-29fb-65da-10d2-776722ce0365.png)