phi16です。Amebient Advent Calendar 16日目。水没の話をします。
その条件についてはちょっと前に話しました。今回は条件が達成されたときに起きる現象、です。
止まるまで
遷移が確定し、風の音が鳴った段階から変化が始まっていきます。まず雨と雷は初回と似たような動きをしています。
加えて、鳴る音が段々減っています。これは、最後の雷が落ちた後は「豪雨のみの静寂」であってほしいから。今まで散々騒がしかった音達がある衝撃以降完全に消え、喪失感をもたらすことに寄与していると思います。
あと鉄パイプの衝突による音発生も出来なくなってますね。そういう「無力さ」とかも。
瞬間的に消えるのではなく段々減るのはらくとさんの案だったかしら。私の「雷の瞬間がわかる体験をしたい」みたいな話からの発展だったかも。電子楽器3を唐突に消すのもちょっと微妙だと思ったので、段々減らしていく必要性はあったとも言えますね。
そして、水滴の落下が止まる。
エフェクトとかはこの瞬間単純に消しちゃっているんですけど、全体的にFallRainのせいでわちゃわちゃしているのでバレないと思います。また、DropRainの白い縦線とかも全部消えてますね。
水滴が落ちてこなくなることの解釈も謎ですけど、雷が以降鳴らなくなることを含めると… 「世界」全体が終わりを迎えたので終わった、というだけで十分かもしれません。それが納得出来うる体験であれば、それで良いような気がします。
また、この瞬間と共に旋律を伴って鐘が鳴り始めます。元々機械起動後から既にランダムなタイミングで鐘は鳴っていたんですけど、あまり主張していませんでした。それは単純に楽器たちの音が大きいからです。
全てが終わると遠くから「終わりを告げる鐘」が鳴るの、こう、完璧ですよね。
貯水槽
雷が落ちてからちょっと待つと、段々貯水槽が軋みだして… 落ちていきます。
ちょっと待つのは同期を取る為です。
雷が鳴る瞬間はリズムに合ってるので各自でズレるんですけど、タンクという「動く物体」「注視できる物体」があると同期されていないのは悲しいわけです。その後水位上昇のタイミングがズレると空中を浮く人にもなっちゃうし。
なのでmasterで雷が落ちてから32拍後に同期信号を発信、そこからアニメーションを開始しています。
この「余韻」はこういう空間にはとても大切なものだったと思います。
動きの実装
**この世界で唯二Animatorで作られたアニメーションです。**もう一つは裏で鳴ってる鐘の音。
どうやっていい感じにタンクが落ちるアニメーションを組むかというのはちょっと悩んだことで、これは物理的挙動なので手付けよりも計算に任せたほうが「正しい」はずなんですよね。
一度全部Capに作ってもらったんですけど、こう、手付けの限界を感じました。というか人間がやることじゃなくない?
というわけで、櫓部分のボーンアニメーションはCapに手付けしてもらって、タンク本体の動きはシェーダでやることにしました。同時に飛び込み板の動きもシェーダで出来てます。
だからAnimatorはmainfloor_divingboard
とrooffloor_tank_tank
のマテリアルパラメータとして単に時間を渡してるだけです。
例えば飛び込み板はこれです。
float d = v.vertex.z;
float t = _PlayTime - 7 - 2.24;
float a = t < 0 ? 1 - exp(-max(0,0.1+t)*20) : cos(t*20) * exp(-t*4);
a *= max(d-0.7, 0) * 0.05;
v.vertex.yz = mul(float2x2(cos(a),-sin(a),sin(a),cos(a)), v.vertex.yz);
不連続なのちょっとウケますね。確かに作り方的にはそうか。衝突の瞬間なのでわかりにくいですけどね。
揺れるタイミングとか細かいパラメータは動かしてみてめちゃくちゃ細かく調節しました。なのでこのコードはあくまで最終結果でしかありません。どれくらい「一回沈む感じ」を出したほうが良いのかとか、色々試していたと思います。
最終的にこの板は結構曲がることにしたので大分かわいい動きになった気がします。
物理計算
まず元々タンクが落ちて来たら面白いというところから始まって、どこに落とすかで「天井で一回」「メインフロアで一回」っていうのを考えました。これはそれぞれのフロアに居る人から直接観測可能なわけで、まぁびっくりしますよね (良い意味で)。前兆となる音もあるので「嫌な体験」にはならないと思います。加えて偶然そこに飛び込み台があったので綺麗に活用したい、ということになりました。伏線回収になるわけですね。
実際は機械フロアに居て「何が起きたかよくわからない」みたいなケースが多かった気もしますけど。でも機械フロアでは200%の表示が見えていて「なんかやばい」ことは気づいているだろうし、沈んでいく貯水槽を近くで眺められる場所にもなっています。メインフロアで遊んでいた人は「責任が無い気持ち」だと思うので、その辺も対照的で良いなと思う。
で、これを実装するとして… 本当にUnity物理で動かすと何が起きるかわからないのでやりたくありません。一度動かした軌跡を記録する、みたいなのもアリですけど結局好きなところに落とすのは大変そうです。そこで、「実現したい動きを実現でき、かつ制御が簡単であるモデル」を考える必要がありました。
ということで、「指定した時刻に地面に衝突したと仮定して撃力が働く剛体」を考えました。
何もしなければ物体は放物運動を行います。地面に衝突する際にはその衝突法線に従って跳ねることになりますけど、その方向を今回は手で制御することにしたのです。当然正しくは無いんですけど、正しさのない世界ですから。
そして衝突位置は場所で指定するのではなく時刻で指定しました。つまりあのビルのモデルは一切衝突計算に関係ありません。適当なパラメータにすると普通に空中で跳ねます。これは良い側面を持っています、後述。
4つの衝撃 (開始、天井、飛び込み板、海) はそれぞれ6つのパラメータを持っています。衝突後に跳ねる力 (3次元)、時刻、衝突期間、そして回転増幅量。
position = float3(0,0,0);
rotation = float4(0,0,0,1);
float t = _PlayTime - 7.05;
float4 impulse[5] = { _Impulse0, _Impulse1, _Impulse2, _Impulse3, float4(0,0,0,100) };
for(int i=0;i<4;i++) {
float u = min(t, impulse[i+1].w);
if(impulse[i].w < u) {
float elapsed = u - impulse[i].w;
float delta = _Delta[i]+0.001;
float lt = elapsed/delta;
float3 fullImp = float3(impulse[i].x, -impulse[i].z, impulse[i].y);
float3 fullRimp = cross(fullImp, float3(0,0,1));
fullRimp *= _RotationBoost[i];
float lx = saturate(lt);
float3 imp = 3*pow(lx,2) - 2*pow(lx,3);
float3 impIntegrate = (pow(lx,3) - pow(lx,4)/2.0 + max(0,lt-1) * 1) * delta;
position += impIntegrate * fullImp;
position += positionVel * elapsed;
position += pow(elapsed, 2) / 2 * gravity;
positionVel += imp * fullImp;
positionVel += elapsed * gravity;
float3 rimp = imp * fullRimp;
float3 rimpIntegrate = impIntegrate * fullRimp;
rotationVel *= 1 - lx * 0.2;
rotation = mulQ(axisQ(rimpIntegrate + rotationVel * elapsed), rotation);
rotationVel += rimp;
}
if(i == 3) gravity.z *= 0.3;
}
コードみてもぐちゃぐちゃだと思うんですけど、要はある時刻に対して決定的に姿勢を確定する計算をしています。未来に起きる全ての外力がわかっているので1フレーム前とか使わなくていいんです。
で、まぁ、なので基本的に延々と積分をする気持ちになります。…加えて、この計算には衝突期間という情報が使われています。
一般に、物体は一瞬で跳ね返るのではなく、まず衝突によって段々変形していって、それを元に戻そうとする力が掛かることで速度が段々変化していきます。多分。
ぐにょんって感じの跳ね返りは「剛体」だと表現できないのです。たぶん…。(シミュレーション手法的には拘束条件の定義によって色々できるとは思うんですけど)
このタンクは重そうなので、跳ね返るには相当な力積が必要です。その為には相応の衝突期間が必要な感じがしたのです。というか最初作ってみてめちゃくちゃ軽そうに見えてしまった。
というわけで、衝突の瞬間からある期間分だけずっと撃力が働いている、ということを考慮に入れてみました。
まず期間内のある瞬間 $t\in[0,1]$ に与えられる撃力の大きさを $-6(t^2-t)$ で定義します。
するとある時間 $t$ までに与えられた撃力の積分は $3t^2-2t^3$ で表せます (これsmoothstep
です)。
これが速度の変化量で、そしてさらに積分すれば位置の変化量になりますね。
この辺の計算に、パラメータで指定された「跳ねる方向」を乗算してあげれば好きな方向に好きな重みで跳ねさせることができるようになります。
ちなみに回転方向もちょっと書いた?ようにほぼ同じように計算できます。衝突位置が重心の真下であることを仮定しているくらい。距離はあまり丁寧にやってなくて、RotationBoost
とかいう謎パラメータで調節をしています。
この「重み」は本当に見た目の良さに貢献しています。作ってびっくりした。
かいせつよう pic.twitter.com/hS3kiBwxSn
— phi16 (@phi16_) December 15, 2020
このアニメーションの仕組みをCapに伝えてパラメータ調節してもらって、さらにそれを私側で細かい重みの調節、みたいな感じで作っていきました。このフローも大分面白かったですね…。ツール作ってる気分。
音
その辺のアニメーションの調節が終わった後、らくとさんに音をお願いすることになりました。複数音源で衝突を鳴らすのではなく単一の音源にあの連続する衝撃音が記録されています (軋む音とかどうしようもないですし)。アニメーション完成後にお願いして音を合わせて貰ったのでぴったりのタイミングで鳴っています。
特にタンクが水に沈むときはちゃんと見た目 (泡とか) を作らなきゃいけないかなぁ、とか思ってたんですけど、音によってある程度説得力が出たのでまぁいいかな、みたいな気持ちにもなれました。これを作っていたのは12日で、急がないと…ってなってたので。
そして音はタンクから出るはず…なんですけど、移動しているのは物体ではなく見た目だけなので自前でAudioSourceを動かす必要がありました。そんなに完璧である必要はなかったので、ほぼ直線移動でどうにかなっています。
水位上昇
そして水没が始まります。関連するマテリアルはAir, Bubble, DropRain, FallRain, Ocean, Overlay, Underwaterで、変化量を丁寧にSetFloat
してあげています。あと環境音制御のUdonにも渡してますね。水位は唯のsmoothstepで変化しています。
加えてRespawner
というUdonにも渡していて、これはリスポーン時のOverlayの制御をしているんですが加えて元々水中に入ったらゆっくり動くようになる処理が入っています。そして、水位上昇が始まってからは重力の9割をキャンセルする処理が入ってます。…これが「メニューを水中で出すと吹っ飛ぶ」原因です…。
if(!player.IsPlayerGrounded()) {
Vector3 velocity = player.GetVelocity();
float dt = Time.deltaTime;
float str = Mathf.Clamp01(underThreshold - position.y);
if(velocity.y < -0.8f) velocity.y = Mathf.Lerp(-0.8f, velocity.y, Mathf.Exp(-dt*str*13.4f));
if(done) {
float cancelRate = 0.9f * str;
velocity -= player.GetGravityStrength() * dt * Physics.gravity * cancelRate;
}
velocity.x *= Mathf.Exp(-dt*str*1.2f);
velocity.z *= Mathf.Exp(-dt*str*1.2f);
player.SetVelocity(velocity);
}
done
っていうのが世界水没フラグね。えー、この処理はvelocity
に毎フレーム適切に重力が掛かることを前提としているんですが、VRChatではその前提が満たされません。メニュー開いてるときは重力が掛かりません。多分ちゃんと「Y方向の速度が正になりそうなら0にする」処理を入れるのが正解です。
一度やってしまったのは事実なので敢えて残しているんですけど、これによって鐘に辿り着けるようになったのもまぁ怪我の功名っていうやつですね…。
こう、裏技的なのを紹介する人って居ると思うんですよ。それはそれでいいと思っていて、で、こういうのをサイレント修正しちゃうとそういう人の願いを破棄することになっちゃって悲しいかなって…。今回はやばいほどクリティカルではない (そこそこやばいんだけど) のでまぁ許してもらおうと思いました。
そういえば遠景のビルにColliderがちゃんと入ってるのはなんとなくで、その頃はバグのことは知らなかった覚えがあります。一応飛行アバターとかあるから行く人いるかもな、とは思ってた。
水位上昇でやっている処理は本当にただ上げてるだけで、他はそのままです。あのライトシャフトは偶然出たものだし、屋上のテントに乗れるのはそういう風に重力キャンセル量を調節したから、貯水槽フロア(?)での水位がいい感じになっているのも水位を調節しただけです。空がやばいのもそのまんま、雨もそのまま。
タンクの無くなった貯水槽に登って (登れるんですよ) 鉄塔を眺めてるときの「あの終末感」、めちゃくちゃ良いんですよね…。「あったものがなくなった世界」であることが文字通り実感できる気がします。
あ、機械系を切る処理があった。でかい機械が沈むと同時に電子楽器系も暗くなってます、そういえば。
おわり
これでAmebientの本筋は全て話し終えたと思います…。多分。
長い記事群を読んで頂いてありがとうございました。何かに役に立つことがあれば良いなと思います。
後はちょっとだけ。Udonのしんどい話が残っています。