【PointCloud】大量の点群データをUnityで読み込んでVR化する!【メタバース】
この記事を読んで最終的にできるVRアプリ
こんにちは。MESONのプロデューサーの伊藤です。
コロナウイルスによって外出が制限され、ストレスの溜まる日々が続く一方が続きますね。
一方で、外に出られないなら家にいながら外出すればいいじゃない、ということで、あつ森やFortniteなどのゲームやRobloxなどのバーチャルプラットフォームで現実を模したワールドが人気を博すなど、メタバース・デジタルツインと呼ばれる現実世界のデジタルコピーを作る動きも急速に進んでいます。
さて、現実世界のデジタルコピーを作るにあたって、ゼロから3Dモデリングしていくのはなかなか高コスト。そこで、現実空間を点群として3Dスキャンして再現するのがひとつの有力な方法として検討されています。
ここのところ、MESONのプロジェクトでとして大量の点群データをUnityから読み込み効率的に描画し、VR内で体験できるようにする方法を研究しており、一段落ついたので、その際に得た知見を書いていこうと思います。
この記事で学べること
・点群データの基本的な情報
・点群データをUnityで読み込むまでの処理(CloudCompare)
・点群データをUnityで軽量に表示する方法(Unity[Compute Buffer])
・点群データを使った演出表現の可能性
この記事で解説するUnityプロジェクト
実際にQuestで動作するapkファイルもアップロードしてあるので、インストールして実際に試すことも可能です。
そもそも点群データとは
点群データとは、LiDARなどのセンサーによって撮影された、空間上の点の情報をまとめた3Dデータです。ptsなどが代表的な拡張子です。
LiDARの例
データ形式としては、頂点ごとの空間座標(X, Y, Z)と色(R, G, B)の6パラメータが基本の構成要素で、それに加え、スカラー情報と呼ばれる反射率や法線ベクトルなどの情報を含むこともあります。
平面(ポリゴン)とテクスチャで構成されるメッシュデータと比べ、それぞれが独立した点の情報だけで構成されており、センサーから取得できる生データに近いので、多くの場合精度に優れているという特徴があります。
点群データの入手
今回表示する点群データですが、静岡県が運営しているShizuoka Point Cloud DB で公開されているデータを描画していきます。
詳しくは↓のプレゼンがめちゃくちゃ熱いので読んでほしいのですが、簡単に言うと、静岡県が、どうしても×100 実現したい!ということで、VIRTUAL SHIZUOKAを構築すべく県内各所の3D点群データを公開してくれています!https://www.zenken.com/kensyuu/kousyuukai/H31/659/659_sugimoto.pdf
上記スライドより抜粋
このように、とんでもない数の地域の点群データが登録されており、
出典: 静岡県PCDB
それぞれの地点で、こんな感じでlasという拡張子のデータをダウンロードすることができます。
今回は、家にこもってばかりでそろそろ自然に触れたい!ということで、日本三大名瀑に選ばれることもあるという、白糸の滝のデータをダウンロードします。
点群データの下処理
さて、VRで点群表示するために、まずは点群データをUnityで読みやすい形式に変換する必要があります。そのためには、CloudCompareというソフトを使います。
CloudCompareをCUIから使う
CloudCompareは点群データの処理に特化したオープンソースのフリーソフトです。無料ってすばらしい。
おもむろにアプリをインストールし、先ほどダウンロードしたlasデータを読み込むと、こんな感じに可視化してくれます。川の流れに沿って両岸が立体化されている様子がわかりますね。
さて、CloudCompare、上記のようにGUIでも操作できるのですが、今回は
・パラメータなどを変えて試行錯誤しやすい
・重いファイルを扱うときに、GUIだとアプリが落ちてしまいやすい
という理由から、ターミナルでCUIとして使う方法で使っていきます。
# CloudCompareのパスを指定
# (下記はMacOSの場合。環境によって違うと思うので各自で探してください。)
cmd=/Applications/CloudCompare.app/Contents/MacOS/CloudCompare
$cmd \
-SILENT \
-AUTO_SAVE OFF \
-O -GLOBAL_SHIFT AUTO "./original/28XXX00030004-1.las" \
-O -GLOBAL_SHIFT AUTO "./original/28XXX00030004-2.las" \
-O -GLOBAL_SHIFT AUTO "./original/28XXX00030004-3.las" \
-DROP_GLOBAL_SHIFT \
-MERGE_CLOUDS \
-SS SPATIAL 0.2 \
-REMOVE_ALL_SFS \
-C_EXPORT_FMT ASC \
-PREC 3 \
-SAVE_CLOUDS FILE "exported/shiraito.txt"
# 各オプションの解説
# $cmd \
# -SILENT \ GUIを起動せずコマンドラインだけで完結させる
# -AUTO_SAVE OFF \ 途中経過の中間ファイルを作成しない
# -O -GLOBAL_SHIFT AUTO "./original/28XXX00030004-1.las" \ ファイル読み込み
# -O -GLOBAL_SHIFT AUTO "./original/28XXX00030004-2.las" \ GLOBAL_SHIFT = 原点をゼロ付近に自動的に近づけてくれるオプション
# -O -GLOBAL_SHIFT AUTO "./original/28XXX00030004-3.las" \
# -DROP_GLOBAL_SHIFT \ GLOBAL_SHIFTの結果を適用し、元の座標を保存しない
# -MERGE_CLOUDS \ 読み込んだ複数の点群を1かたまりにマージ
# -SS SPATIAL 0.2 \ 点群を距離に応じて間引き(SubSample)。この設定だと、0.2mより近い距離の点が自動マージされる
# -REMOVE_ALL_SFS \ 点ごとに付随するメタ情報(ScalarField)をすべて削除
# -C_EXPORT_FMT ASC \ ファイルのデータ形式を指定
# -PREC 3 \ 保存する際の小数点の精度(m)。今回は0.001m(1mm)まで残すようにしている。
# -SAVE_CLOUDS FILE "exported/shiraito.txt" ファイル名を指定して保存
subsample.sh
今回は、公式ドキュメントを見ながらこんな感じのシェルスクリプトを書きました。ざっくりいうと、
・3つのLASファイルを読み込み
・1つの点群データにマージ
・点群を距離ベースで間引き(SubSample)
・Scalarデータをすべて削除
・ASCデータで保存
という一連の処理をまとめて記述したものです。
特に大事なのはSubSampleで、かんたんにいうと「閾値以上に距離が近い点たちをマージして全体の点の数を減らす」という処理なのですが、この閾値を大きくすると、ファイル容量や読み込みスピードがかなり軽くなる一方で出来上がりの見た目がスカスカでショボくなってしまうので、試行錯誤する際はまずSubSampleのパラメータを変えてみるのがいいと思います。
今回、Unityエディタ上で見たときは、0.1くらいが見栄えのバランスなどちょうどよかったのですが、VRで見たときにややフレーム落ちが気になったので、0.2を最終的な値として使っています。
点群データを変換してみる
ということで、このスクリプトを実施すると、上記のようなログが出力されます。各ファイルに、600万点の点群が記載されており、SubSampleしたことで85万点くらいに削減されていることが分かります。
ファイルサイズ的にも、約600MBから27.2MBまで減っていますね。
あたらしく生成されたファイルの中身は以下のようになっています。Unityで読み込む関係で拡張子はtxtにしていますが、形式としては.ptsという拡張子に準拠したもので、1行が一つの点を表し、スペース区切りで[X Y Z R G B]の順で85万行ずらっと並んでいるという、非常にシンプルなファイル形式です。
この1行1行が、1つの点の[X Y Z R G B]を表している
Unityで表示する
全体の流れ
最大85万頂点を毎フレーム描画する必要があり、CPUベースだと処理的に難しいため、以下のような流れで、Compute Bufferを利用し初期化以外は極力GPUで実行できるように実装しています。
①点群データの読み込み
public static class PtsReader {
async public static Task<List<(Vector3, Vector3)>> Load(TextAsset ptsfile) {
var content = ptsfile.text;
// テキストファイルの読み込みとパースがボトルネックなのでいずれ最適化したい
// 現状はとりあえず非同期読み込みにしてメインスレッドがブロックされることを回避
return await Task.Run(() =>
content.Split('\n').Where(s => s != "").Select(parseRow).ToList()
);
}
private static (Vector3, Vector3) parseRow(string row) {
var splitted = row.Split(' ').Select(float.Parse).ToList();
return (new Vector3(
splitted[0],
splitted[2], // PTSファイルは通常Z-upなので、ここでZとYを交換しY-upに変換
splitted[1]
), new Vector3(
splitted[3],
splitted[4],
splitted[5]
));
}
}
PtsReader.cs
点群読み込み用のクラスです。
ファイルを1行ごとにパースし、Tuple<Vector3, Vector3>のListとして返します。27.2MBの読み込みで15秒くらい時間がかかってしまうので、非同期で返すようにしています。
(ちなみにFetchよりParseのほうにボトルネックがあるようです。良い解決策を思いつく方はコメントいただけますと嬉しいです。)
②③ComputeBufferへデータをセット・描画命令
public class PointCloudLoader : MonoBehaviour {
[SerializeField] Shader PointCloudShader;
[SerializeField] TextAsset PtsFile;
[Range(0, 500)] public float PointRadius = 50;
[Range(0, 500)] public float PointSize = 100;
private ComputeBuffer posbuffer;
private ComputeBuffer colbuffer;
private Material material;
private List<(Vector3, Vector3)> pts;
private bool bufferReady = false;
async void OnEnable() {
if (PointCloudShader == null) {
Debug.LogError("Point Cloud Shader Not Set!");
return;
}
if (pts == null) {
pts = await PtsReader.Load(PtsFile);
}
List<Vector3> positions = pts.Select(item => item.Item1).ToList();
List<Vector3> colors = pts.Select(item => item.Item2).ToList();
// バッファ領域を確保・セット
// 確保する領域サイズは、データ数 × データ一つあたりのサイズ
int size = Marshal.SizeOf(new Vector3());
posbuffer = new ComputeBuffer(positions.Count, size);
colbuffer = new ComputeBuffer(colors.Count, size);
posbuffer.SetData(positions);
colbuffer.SetData(colors);
// マテリアルを作成しバッファとパラメータをセット
material = new Material(PointCloudShader);
material.SetBuffer("colBuffer", colbuffer);
material.SetBuffer("posBuffer", posbuffer);
material.SetFloat("_Radius", PointRadius);
material.SetFloat("_Size", PointSize);
bufferReady = true;
}
void OnRenderObject() {
if (bufferReady) {
// レンダリングのたびに頂点の個数分シェーダーを実行
// MeshTopology.Pointsを指定することで、面ではなく頂点が描画される
material.SetPass(0);
Graphics.DrawProceduralNow(MeshTopology.Points, pts.Count);
}
}
// 省略...詳細はGitHubにて
// https://github.com/jujunjun110/PointCloudVR
}
PointCloudLoader
こちらのクラスでは、読み込んだ点群データを扱います。
まずOnEnableメソッドで、posbuffer, colbufferという名前でメモリ領域にVector3の配列の形でデータを書き込んでいます。
CPUが点群データに触るのは、ここの1回だけで、それ以降は基本的にShader(GPU)の側でよしなに描画されることになります。
バッファへの書き込みが完了したあとは、OnRenderObjectメソッドで、GameObjectやMeshRendererを使わない形でGPUに直接レンダリング命令を出しています。
ここでMeshTopology.Pointsを渡すことで、Unityデフォルトのメッシュ描画モードではなく、頂点描画モードでのレンダリングが実行されます。(詳細)
またここで頂点の個数を渡すことで、Shader側で頂点に一つ一つ順番にアクセスする処理が可能になっています。
④⑤Shaderによる描画
Shader "Custom/PointCloud" {
SubShader {
Tags { "RenderType"="Opaque" }
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// C#から受け渡されるバッファとパラメータの受け取り
StructuredBuffer<float3> colBuffer;
StructuredBuffer<float3> posBuffer;
float _Size;
float _Radius;
struct v2f {
float4 pos: POSITION;
fixed4 col: COLOR;
float size: PSIZE; // MeshTopology.Points の際の頂点の描画サイズを表す定義済セマンティクス
float4 center: TEXCOORD0; // 描画する四角形の中心の座標
float dist: TEXCOORD1; // 描画する頂点とカメラの間の距離
};
v2f vert (uint id : SV_VertexID) {
v2f o;
// 連番で渡ってくる頂点IDを利用して、描画する頂点の座標を取り出し
float4 pos = float4(posBuffer[id], 1);
// 同様に色の取り出し
// Ptsファイルで255段階で保存されている色ので0-1の階調に変換
o.col = fixed4(colBuffer[id] / 255, 1);
// 点群のサイズの補正のためカメラと点群の距離を計算
float dist = length(_WorldSpaceCameraPos - pos);
pos.y += sin(length(pos.xz - _WorldSpaceCameraPos.xz)) * 0.5;
o.pos = UnityObjectToClipPos(pos);
// 四角形の中央のスクリーン座標も渡すようにする
// 自分でプロジェクション座標変換する必要がある
float4 center = ComputeScreenPos(o.pos);
center.xy /= center.w;
center.x *= _ScreenParams.x;
center.y *= _ScreenParams.y;
o.center = center;
o.dist = dist;
o.size = _Size / dist;
return o;
}
fixed4 frag (v2f i) : SV_Target {
// i.pos はPOSITIONセマンティックスを使っているため、
// 1点が複数のピクセルに自動的にラスタライズされる際に、
// ピクセルごとに異なるスクリーン座標が渡されてくる。
// いっぽうi.centerは自前で座標変換しているため、
// vert -> fragで自動の変換などが発生せず
// 同じ四角形の中では必ず同じ座標(四角形の中心)が渡されてくる。
// 円を描画するため、描画するピクセルのスクリーン座標と、
// ピクセルが属する四角形の中心のスクリーン座標の距離を計算
if (length(i.pos.xy - i.center.xy) > _Radius / i.dist) {
discard;
}
return i.col;
}
ENDCG
}
}
}
PointCloud.shader
最後に、メモリ領域に読み込んだ頂点・色のバッファを円の形で表示するShaderです。
StructuredBuffer<float3> colBuffer;
という名前でセットされたBufferの値にアクセスできるので、頂点IDを用い、
float4 pos = float4(posBuffer[id], 1);
のような形でバッファに格納したデータを取得しています。
また、vertex shaderからfragment shaderに値を渡す際に、POSITIONセマンティクスとTEXCOORDセマンティクス(何でも良い)の2つの変数でスクリーンポジションを渡すことにより、各点を円の形にクリッピングしています。
(詳細はコード内のコメント参照)
Unityで実行!
上記のコードを実行した結果が以下です!
いい感じに点群が表示されていますね。めちゃくちゃいい景色!
インスペクタから、点群のサイズを変更することもできます。
点群そのもののサイズ(正方形1辺の長さ)と、それを円でクロップする半径を別々に変数にしているので、点群サイズと半径を近づけると、点の形状が四角形に近づいていくのも確認できます。
VRで実行する
最後に、VRで実行してみましょう。Oculus Integrationをインストールし、サンプルシーンからOVR Player Controller をシーンにコピーすることで、すぐに試すことができます。
どうでしょうか。
これは本当の話なんですが、僕はあまりにいい景色すぎて、初めてうまく動いた際に思わずデバッグを止めて魅入ってしまいました...!
これまでの経験上、木や石などランダムでボコボコした形のものは、メッシュ化してもあまり綺麗にならず、それほど没入感が出ないなという印象だったのですが、点群データを使うとレーザーによる精密性の高い座標・色情報と、点群表現によるちょうどいい抽象化によって、息を呑むくらい美しい体験になっていると感じました。
Oculus Questをお持ちの方には、ぜひ体験してほしいです!
さらに点群ならではの演出を!
最後に、さらに発展的な可能性について。
点群データの一つの特徴として、一つひとつの点が独立して存在しているデータ形式のため、メッシュなどに比べて自由な演出がしやすいという要素があります。
例えば上記の映像は、点群の座標を計算するところの下記のたった1行を追加したものです。
pos.y += sin(length(pos.xz - _WorldSpaceCameraPos.xz)) * 0.5;
つまり、それぞれの点群との水平距離に応じて、各頂点を上下に動かしているだけなのですが、それだけで、同じシーンがとても不思議でユニークなものになっているのが感じられると思います。
点群は、シンプルで軽量な表現だからこそ、演出の可能性は無限大だと感じました。
まとめ
この記事では、
・点群データの基本的な情報
・点群データをUnityで読み込むまでの処理
・点群データをUnityで軽量に表示する方法
・点群データを使った演出表現の可能性
について書いてきました。
この記事を書きながらあらためて、独立した点の集まりというシンプルなフォーマットゆえの、取り扱いの簡単さや演出の可能性の広さを感じました。
今後VR・AR機器の演算能力が上がり、さらに大量の点群の描画も可能になれば、よりリアル寄りの表現も可能になるでしょうし、実際の場所に人が集まることのコストが非常に大きくなるアフターコロナ時代に、重要性が上がってくるフォーマットなのではないかと思いました。
MESONでは、これからも点群を活用した表現や価値提供を研究していきたいと思います。興味をお持ちの方はお気軽にご連絡ください!
mail: jun.ito@meson.tokyo
Twitter: @jujunjun110