こんにちはphi16です。この記事は VRChat Advent Calendar 2020 の15日目の記事です。
丁度1年前にfriends公開を初めて行い、それからちょこちょこ変化しつつクリスマスにわいわいしたワールドがアーカイブとしてのこっている、この森というワールドについて。世界全体というよりは私がやった技術面の話を、いろいろ書いていこうと思います。
世界の話はちょっと複雑なのでね。
目次
- 森生成器
- インタフェース
- 足場生成
- 木生成
- 木配置
- ロープ移動
- 物体生成
- 移動機構
- おまけ
- MoriStandard
- 建物表示板
- イルミネーション
- 私の家
- 写真群
- 動く浴衣の写真
- 昇降盤
森生成器
この言い方から推測できると思いますが、森の基礎メッシュは全て自動生成です。全てっていうのはこれくらい。
この生成スクリプトはUnity上のEditor拡張として実装されていて、C#でごりごりメッシュ操作をやる感じになってます。街灯の配置をしたりもします。
インタフェース
元々複数人 (10人規模) で作ることを想定していたので、気軽に誰でも再配置・再生成できるようにしたいという気持ちがありました (なのでHoudini案はなかった)。まぁ最終的にほぼ私が勝手に橋掛けたり配置弄ったりしていたので目的通りの運用というわけではありませんでしたが。
要件はこんな感じです:
- 好きなところに床を定義する
- 床と床の間に橋や梯子を掛ける
- 木の配置を行える
Unity上に特定のスクリプト (Mori Floor
) のついた物体を配置して、それを足場として認識します。床形状は円・矩形・扇型の3種類があって、大きさなども自由です。また足場と足場の間に橋・梯子を掛けるMori Bridge
コンポーネントがあって、2つのMori Floor
付きオブジェクトと接続位置を指定することができます。
円と円の間をつなぐ橋の場合は接続位置が最近点で確定するので接続位置は省略できます。そうでない場合でも計算すれば…とは思いましたが、手動のほうが早いと思いました。調節したいし。
また、木の形状を示すGameObjectを指定することができ、そのhierarchyをそのまま木の形状として認識します。
各節の大きさはTransform
の (uniformな) Scaleを参照しています。また、節は最大2つまで分岐できます。
足場生成
先程のMori Floor
とMori Bridge
の情報を元にしてメッシュを生やすわけですが、やりたいこととして「複合図形」と「木の貫通対処」がありました。今回は床は常に上を向いているという仮定を置いたので、2次元計算幾何の分野になります。さすがに自前で全実装はしんどいのでライブラリを探して…geometry3Sharpと、clipper_libraryを採用しました。こうなるまでがそこそこ長かったような気がします。
処理内容はこんな感じ:
- 全
Mori Floor
の情報を集めて、それぞれを2D形状化 - 近い高さの床を全て統合 (Unionを取る)
- 木が貫通する部分はいい感じにDifferenceを取る
- できた図形を厚み付けしたり縁取りしたりしてポリゴン化
木の貫通処理は…全ての木の節を円柱で近似して、床の高さでの切断で出来る楕円形を使ってDifferenceを取るみたいなことをしたっぽいです。境界条件が雑なせいでたまに貫通したままのやつがいます。
厚み付けは簡単、縁取りもClipperのおかげで簡単です。ありがた。
foreach(KeyValuePair<Plane, Region> pair in regions) {
Plane front = pair.Key;
Plane edge = pair.Key;
Plane back = front;
Plane backInner = front;
Plane backOuter = front;
front.distance += floorThickness * 0.01f; // To prevent Z-fighting
edge.distance -= floorThickness * 0.3f;
back.distance += floorThickness;
backInner.distance += floorThickness * 0.3f;
backOuter.distance += floorThickness * 1.5f;
Region outer = pair.Value;
Region inner = gs.ClipperUtil.ComputeOffsetPolygon(outer, -0.35f, true);
Region inner2 = gs.ClipperUtil.ComputeOffsetPolygon(outer, -1.5f, true);
Region diff = gs.ClipperUtil.PolygonBoolean(outer, inner, gs.ClipperUtil.BooleanOp.Difference);
Region diff2 = gs.ClipperUtil.PolygonBoolean(inner, inner2, gs.ClipperUtil.BooleanOp.Difference);
SetMaterial(b, MoriMat.FloorFront);
b.AddRegion(inner, front, true);
SetMaterial(b, MoriMat.FloorCover);
b.AddRegionBridge(inner, front, edge);
b.AddRegion(diff, edge, true);
b.AddRegionBridge(outer, edge, back);
b.AddRegion(diff2, backOuter, false);
b.AddRegionBridge(inner, back, backOuter);
b.AddRegionBridge(inner2, backOuter, backInner);
SetMaterial(b, MoriMat.FloorCover2);
b.AddRegion(diff, back, false);
b.AddRegion(inner2, backInner, false);
}
こういうことです。
これの解説してもしょうがないな。
橋と梯子もいい感じに生やします。よくある「長さに応じて個数が勝手に変わるタイプのprocedural」です。
float dist = (p1 - p2).magnitude;
float len = 0.5f, sep = 0.2f;
// L*M + (T+t)*(M+1) = D
// M*(L+T) < D-T
int M = Mathf.FloorToInt((float)(dist-sep) / (len+sep));
sep = (dist - len*M) / (M + 1);
多分全人類同じようなコード書いてるんだろうなと思った。これで分割数がわかるのでキューブをぺちぺち生やします。ついでにロープはいい感じに垂れるようにします。ノリで。
垂れるようにするとロープはもちろん板の方も傾けなければいけません。垂れ方は二次関数で定義したんですが板の傾きはその微分です。ロープを構成する円もそれぞれが傾いてます。
梯子は比較的簡単です。ただ橋だと端点に特別なキューブを生やしてるので雑な処理をしても大丈夫なんですが、梯子はそれが出来ないので位置をちゃんとあわせる必要があります…特に上方での接する点。まぁ最終的にはMori Bridge
定義時の手動位置指定に依るので完璧ではないですね。いっぱいあるし。
ちなみにこれらのメッシュを生やすときに同時にUV座標も書き込んでいます。橋の端点のちょっと大きい柱にsharpが掛かってないのは故意です (やわらかくなる気持ち)。
木生成
実はなんだかんだ単純です。全て円柱として解釈していて、データとして与えられた各節を素直に生成してあげる感じ。
元々は分岐点を綺麗につくってあげたくてめちゃくちゃ試行錯誤してたんですが… 最終的には無視してポリゴンを重ねちゃうほうが最早綺麗だった。
とは言え「2つの円をつなぐ円柱」 (つまりBridge Edge Loops) を作ること自体は非自明で、ポリゴン化された円のどの頂点対を対応させるべきかは明らかではないはずです。**今考えたらConvex Hullで済む気がするけど。**まぁ、ついでにUV座標もちゃんと合わせなきゃいけないのでいい感じの処理方法を考える必要がありました。
私は根から葉 (木構造的な意味で) に向かって円の接方向を伝播させるようにしました。それが先程の木のGizmoにある横線です。法線が違うので同じ接方向が取れるとは限らないんですが、単純に射影取って合わせてます。多分「最適」にはなっています。
で、あと頂点数が常に同一であるという条件も付けちゃったのでここまで来たら素直ですね。接方向から始めてくるっと側面を生やしていく感じで。
あと節を作るときにn分割するようにもしてました。3次Bezierで、制御点の位置は自動なんですが手動にもできます (でも手動調節結局してないと思う)。
木配置
問題はこっちです。
手動でぽちぽちやってて明らかに木っぽくならないことがわかっていたので自動生成をする必要がありました。ランダムに生えたり分岐したりする処理を書いたりはしたんですが微妙で、「木の複雑さ」には至らなかったのです。
そんな中、fotflaさんに Space Colonization Algorithm の話を教えてもらったのでぐっと実装しました。
だいぶマシです。葉は無いしなんだか円を描いてるし微妙な点は色々あるんですが、ぎりぎりしょうがないかな…くらいの気持ちでありました。まぁ威厳はあります。
これらのパラメータ制御はこんな感じでぽちぽちできます。
使いやすくはない。
ちなみにこのときのコードがVket4の私のブースに流用されています。どうにも有機的な調節をするには難しいなと判断して、ワールドテーマに合わせて直角と45度だけにするっていうのは合理的だったなと思います。葉も作りやすいし…。
さて、これで床の上の木は生えたことにするんですが、床の下をどうにかする必要がありました。Space Colonizationは「特定の位置に節が来る」ようなのは気軽に扱えないと思ったし、まぁこれくらいならと思って手動にすることにしました。といっても半自動です。
- まず全ての床に節を生やす
- 要らない床を手で消す
- 手動で2つ選択して、統合ボタンを押すとそれらの中間の下方に新たに分岐節が生成される
- 分岐節の位置を調節しつつ、これを繰り返す
- 最後に各節の角度を自動調節させる
統合されるとちょっと節が大きくなったりします。あと統合済みかどうかがGizmoの色に反映されたり。あと選択しやすいように節点に球を生やす (自動で消せる) ようにもなってました。なんやかんややって出来た木構造 (hierarchy) をぐっと処理させるとメッシュが生えます。
ちなみに「床の上」と「床の下」を hierarchy 上で統合させるのはちょっとヤバいと思ったので
Mori Tree Anchor
コンポーネントを作って「ここにはコレがあったことにする」ことができるようにしてました。
そんな感じで基礎メッシュが完成します。そこそこ重いんですがbakeみたいなものと考えれば。
ロープ移動
ある意味では森の森らしい部分というか…最大のアトラクションというか。VRChat的でない移動としてロープを伝ってすいーっと渡ることができる機構があります。
当然これも自動生成で、駅の位置をGameObjectとしてぽちぽち置いておくと:
- その位置に街灯が立ち
- ロープが生え
- 移動を司るAnimationが生成され
- 経路を示すAnimation Controllerが生成され
- 矢印ボタンに付随する
VRC_Trigger
が設定される
物体生成
駅を司るMori Rail Station
の着いた物体群を子に持つMori Railway
で経路を定義できて、そこでループするかどうかと移動スピードを設定できます。街灯の配置はまぁprefabを置くだけ。
駅と駅をつなぐ曲線は Chordal Catmull-Rom spline と呼ばれるもので、駅と駅の間の距離が違う場合でもほぼ等速に動くようなパラメータを取っています。
uniformにして速度概ね一定になるように調節した場合だと節点で速度が不連続になるので望ましくないというやつ。
これに沿ってロープのメッシュを生やしてあげて。ついでにこれを直接微分すると傾きが出るので合わせてAnimation Curveに突っ込みます。
public AnimationClip GenerateAnimation() {
AnimationClip c = new AnimationClip();
float curTime = 0;
float speed = raw.speed;
AnimationCurve curveX = new AnimationCurve();
AnimationCurve curveY = new AnimationCurve();
AnimationCurve curveZ = new AnimationCurve();
for(int i=0;i<curve.Length;i++) {
Position curPos = curve[i];
Direction curTan = tangents[i] * speed;
curveX.AddKey(new Keyframe(curTime, curPos.x, curTan.x, curTan.x));
curveY.AddKey(new Keyframe(curTime, curPos.y, curTan.y, curTan.y));
curveZ.AddKey(new Keyframe(curTime, curPos.z, curTan.z, curTan.z));
if(i != curve.Length-1) curTime += lengths[i] / speed;
}
c.SetCurve("", typeof(Transform), "localPosition.x", curveX);
c.SetCurve("", typeof(Transform), "localPosition.y", curveY);
c.SetCurve("", typeof(Transform), "localPosition.z", curveZ);
return c;
}
これは全ての計算が終わった後の出力部分。で、生えたAnimation群を使ってAnimation Controllerを生やします。自動化便利。
で、この時にStartPoint
という名前のパラメータ (int) を用意しているので、それを設定するVRC_Trigger
を生やします。
ただVRC_Trigger
のイベント設定を自前で生やすのなんか怖いので、予め9割設定されたprefabを用意しておいてパラメータを変化させることにしました。だいぶ雑な条件のとり方をしていますがまぁ大丈夫。
これで球がボタンを押すと出現、掴むと移動するようになるところまで出来ます。
移動機構
…その後に関してはアレです。Climbing Prefab 及び 私が1年前に書いたやつ を参照ください。まぁやっていることは Climbing Prefab そのままといえばそのままです。
ちなみにトリガー引くと減速できたりします (SpeedのMultiplierに割り当ててある)。
この時は完全に時代だったので思い通りに実装出来て楽しかったし移動自体も楽しくてよかったんですが、今では古代技術みたいなもんです。ロープを掴んで移動するのはこれからもここだけな気がしています (Udonだとvelocity弄るほうが早いので、残念ながら)。
まぁ楽しい移動のあるワールドとして一応今でも存在していることは良いことです。良いこと。この時期はロープ生成機構だけ配布できたらみたいなことを思っていたことがありましたが、まず森の生成機構に深くくっついていたのとやっぱりSDK2依存の強さが怖かったのでやらなかったんですよね。これでよかったんだと思います。
そういえばAnimation Clipの生成部分だけは分離して配布してたりします。なんとなく。
結構前に作ったコレをなんとなく適当に公開しました 使いたいことがあればどうぞ (Sampleが動かないとかあれば確認しますが細かい修正はする気はあんまりない) https://t.co/EwPufok3XH pic.twitter.com/MOYuV2VKll
— phi16 (@phi16_) August 23, 2020
おまけ
MoriStandard
雪が薄く積もっているクリスマス期の森ですが、これは各建物のMaterialが普通のStandardではなくMoriStandardという名前のシェーダになっていて、その中身を統一して書き換えることで行っています。
これはCulling ModeがMaterialから指定できる以外は見かけはStandardと同じで、中身もbuiltin-shadersを拾ってきたものです。その中のForwardBaseの計算にちょっと仕込んであって。
void MoriPostLighting(inout FragmentCommonData s, inout float4 c, inout VertexOutputForwardBase i) {
c.rgb = lerp(c.rgb, float3(0.8,0.8,0.8), smoothstep(0.0,1.0,s.normalWorld.y) * 0.5);
}
まぁそれだけです。これによってこのシェーダを使う全ての物体に概ね雪が薄く積もります。MoriStandardにはInteriorモードもあって、その場合は影響を受けなかったりもします。単純。
元々MoriStandardが作られた経緯は これ だったりします。実際途中までそうだったんです。
ファンタジー感あっていいなって思って。でも微妙に落ち着かなかったり、壁貫通がどうしようもなかったり…最終的には使わないことにしました。
あとフォグの計算もMoriStandardで行っていて、霧密度を $\max(0, 1-\exp(0.1y))$ で定義して解析的に計算しています。最初視界ジャック式ポスプロでやろうとしたんだけどMSAAが効かなくなっちゃうのでやめました。
建物表示板
森の役場に謎の板があって。「今いる建物を誰がつくったか」を表示していました。今はPanoramaの仕様変更かなんかで動かなくなってます。
建物自体にコライダー入れるとカメラが掴めなくなる問題があったのでローカルでプレイヤーの頭上方100mに球を置いて、建物の検出用コライダーも100m上方に移動させることで判定を行っています。
で、この絵はアニメーションします。
かいせつよう pic.twitter.com/0rSy4iSkgM
— phi16 (@phi16_) December 14, 2020
コレ自体はシェーダによるものですが、その為のデータがこれ。
「輪郭抽出したときの線の向き」みたいなものが記録されています。この画像は…
Unity製です。カメラで得たDepthとNormalの情報を使って輪郭抽出して云々しています。中身はもう忘れました…。
これで生やした画像をPanorama経由で読むことで画像の切り替えを行っています。ワールド容量を減らしたかったんだと思う。
ちなみに役場の地図の元データは立体的だったり。
地図に人の存在が現れたりしてほしかったんですがそこまで出来なかったですね。まぁ役割は全うしていたと思います、かっこいい複雑性が出ていい感じでした。
イルミネーション
クリスマスに合わせて生えたイルミネーションですが、もちろん自動生成です。ロープに等間隔にメッシュを生やしていくだけです。
ただ1つ問題があって、PPSのBloomだと遠方の光が点滅するという。1pxあるかないかくらいになると強い光の有無が急激に切り替わる羽目になります。なのでBloomでは「光」感は出せないことがわかりました。
なので、各ライトの中に小さなポリゴンが仕込んであります。こいつがGPUパーティクルの要領でほわっと広がっているのです。
イルミネーションの式はノリで出来ています。
float clampAmpPeriodic(float x, float a, float b) {
return saturate((sin(x)*0.5+0.5)*a-b);
}
float3 illumiColor(float3 worldPos) {
float t = _Time.y*0.3 + worldPos.y*0.1;
float hue = worldPos.x*5 + worldPos.z*7 + sin(worldPos.z*6)*6 + t*(sin(worldPos.y*0.01)*0.5+0.7);
float flash = 1; // sin(worldPos.x*17 + worldPos.z*19 + t)*0.5+0.5;
flash = min(flash, clampAmpPeriodic(dot(worldPos, float3(1,4,3)*9) + t, 7, 2));
flash = min(flash, clampAmpPeriodic(dot(worldPos, float3(2,-1,5)*8) + t, 7, 1));
flash = max(flash, clampAmpPeriodic(dot(worldPos, float3(7,1,-3)*2) + t*6, 7, 6));
float3 base = cos(float3(0,1,-1)*3.1415926535*2/3 + hue) * 0.5 + 0.5;
float strong = clampAmpPeriodic(-length(worldPos) - t*5, 7, 6) * 1.3;
flash = max(strong, flash);
base *= flash * 1.5;
float3 theme = cos(float3(0,1,-1)*3.1415926535*2/3 + t/(2*3.1415926535)/1.2837821) * 0.5 + 0.5;
float themeMix = clampAmpPeriodic(t, 7, 6);
theme *= clampAmpPeriodic(t, 9, 8) * 1.8 * (flash * 0.5 + 0.5);
base = lerp(base, theme, themeMix);
return base;
}
基本的にランダム色に光ってて、偶に周囲全部が同じ色で光ったりする。あと中心との距離で光の強さ (strong
) がだんだん変化したりしてますね。worldPos
と_Time.y
にしか依存してないのでなんか面白い使い方があるかもしれない。予定はありません。
私の家
森全体としてはこんなものかなと思います、あとは私のおうちもそこそこ謎機構入ってるので紹介します。
写真群
私の家には128枚の写真が飾ってあるんですが、当然画質を落として入れざるを得ません。でも綺麗な写真も見たい。そこでPanoramaを使った遅延ロードを行うことにしました。
とりあえずぐっと画像を荒く固めて (これはimagemagick製)、対応するUVを持ったメッシュ自体を生成。
同時に写真オブジェクトそれぞれに対して「近くに来たらPanoramaをActiveにする」ように設定。統合された方の写真は近づくと段々消えるようになっていて、(初回はロードが入るんだけど) 近づくと高画質になる、みたいな感じの挙動をします。していました。
ちょっと前のアップデートから、どうやら128個もPanoramaがあると通信がおかしくなるらしく… 二人以上で森に入れない不具合が起きていたんですが原因がコレだったのです。なのでこのPanoramaロード部分は今は完全に無効化されています…
そういえば写真配置も自動化してました。ただPanoramaの設定が完全には自動化できなくて128個手動で設定したような覚えも…
動く浴衣の写真
— phi16 (@phi16_) December 15, 2019
私の 動く浴衣 の写真が飾ってあります。**動きます。**模様が完全に一致していることから推測できたりもします。
さて、これの仕組みは自明ではありません。動かすにはUV座標が必要ですが、単に撮った写真には情報は乗っていません。またVRChatで撮影したのでメッシュ埋め込みとかもしていません。
ええと。
これは3枚の写真の合成です。白地での撮影、荒いUV、細かいUV。UVの精度は8bitでは足りなかったので2枚使うことで16bit、それを自前でbilinear補間して表示しています。Bチャンネルが0っぽい領域に対してベース色に乗算する感じです。
この写真たちはカメラを3つ重ねて同時に撮ることで撮影しました。そのうち2つは背景が青色でFarClipが12と1250になってます。残りの1つは通常のカメラでFarClipが1600。浴衣のシェーダ (UTS) を直接弄ってカメラのFarClipを元に描画する値を変えるようにしたのです。カメラが識別できればまぁなんでもよかった。
ちなみに通常のカメラと言いつつ実は背景色無しで撮ってて、この背景は合成です。
昇降盤
ではなくて。これは真ん中の球を掴むことでローカルで昇降できる便利なやつです。というかそれ無かったらこの建物上に行けない。
これもまた アレ なわけですが、ロープ移動とは違ってだいぶ処理は複雑です。
- Pickupした瞬間、床をプレイヤー位置に移動
- Pickupした初期座標を床からの相対値で記録
- 以降
- プレイヤー位置の取得
- PickupのY座標変化を取り出して床板に0.2倍して加算
-
上下に範囲を出てないかチェック (
Bounds
のButton.Press()
) - プレイヤー位置に反映
境界チェックするのはこっち。
あの記事には**「これを使えば max(x,y) = (x+y)/2 + |x-y|/2
や min(x,y)
も作れますね。」**って書いてあるんですが、それをやっています。はい。
なんやかんやあって完成。
あとおまけのおまけ。
実はこの魔法陣っぽいのの外周、インスタンス人数を表しています。2進数で。うちにある便利なやつ pic.twitter.com/3pxhAbjA39
— phi16 (@phi16_) December 18, 2019
これは1+2+4+16=23人。今日もありがとうございました pic.twitter.com/fZjLmWZEFZ
— phi16 (@phi16_) December 17, 2019
このときは16+32=48人ですね。ワールド人数が30人になってるのはそういう理由だったり (63以下しか数えられないので32人に出来ない)。たくさんの方々がいらっしゃったので新しくinstanceを立てました pic.twitter.com/WVFlU4WR8a
— phi16 (@phi16_) December 25, 2019
おわりに
すごい雑多な文章になってしまいました。いろんなものが独立して存在しているので各々独立して紹介するしかないというところではあります。
とは言え一貫して自動化に努めたという話でもありますね。折角スクリプト書ける環境なので、自動化出来る部分は自動化していきたいところです。
まぁスクリプト書くの自体面倒だし、私が本文で「単純」みたいなことを書いたところも「やることはわかるけどやるのがだるい」みたいな部分も多くてそう気楽ではないんですが… 気楽になれたらいいですね。
まぁここに書いてある情報を直接誰かが使うことになることは無いと思っているので、古代技術というか、SDK2時代の歴史というか、まぁのんびり読む文書になったらいいなという気持ちです。
読んでくださりありがとうございました。次にメッシュ生成を自動化するときはきっとHoudiniを使っていると思います。
「森」そのものは私がやったことだけではなくていろんな人のいろんな物で出来ているわけで、この記事は純粋に「私がやった技術面」に留めてあります。「願い」とか「物語」みたいな話は…語り継いでいくか、ちゃんと作品として出すかにしたい。