LoginSignup
6
4

More than 3 years have passed since last update.

Amebient / 水汲み

Last updated at Posted at 2020-12-08

phi16です。Amebient Advent Calendar 9日目。の話をします。

ある程度の方は気づいてらっしゃると思いますが、Amebientでは水が汲めます。まぁ何度か書いてますね。
image.png
これの話をします。

願い

実はこの「容器に入った水」概念、私がVRChatに来て最初に作ったものの1つです。

シェーダの面白い応用無いかな、ってことで考えたのがこれでした。そしてUdonが来て最初に作ったのも、この類似物です。

まず、純粋なシェーダだと状態保持が出来ません。元の角度に戻したら水量が戻ってしまいます。それをシェーダ計算機を使ってどうにかしていたのが大昔なわけですが、それでは同期もしません。ずっと各々の物体の角度をいい感じに保持できたらなぁとは思っていたのです。

だからUdonが来て元々やりたかったことを完璧な形で再現できた1。さらに物体の形状もだいぶ複雑に出来て大分成長を感じたものでした。まずシェーディングがまともだしな。

というわけで「容器に入った水」概念がもともと無意識的に好きらしい私ですが、このAmebientの世界が生まれ始めた頃から「溜まった水に水滴が落ちる音」は要るとは思っていたので、頑張って作ろうと思っていました。…さらに5月末にHalf-Life: Alyxに水が追加された話を見たりして…。まぁ戦いに行くことにしました。

HL:Aの水はディティール側面が強いと思いますが、Amebientでは機能の一つです。つまりそれは制御可能かつ観測可能でなければいけません。強く波立つよりも穏やかに水位を提示するほうが重要だったのです。ということにしました (波作りたくなかったので)。

 
そして大事なのは「どう汲むか」です。初期構想にあったように、「海から汲む」のがまず根底にありました。それは次のように言うことができます。

「容器の縁が海面以下のとき、水位は海面と一致する。」

これはなんとかなりそうです。しかし、加えて「シンクから汲みたい」「バケツに汲んでおきたい」「容器から容器に移したい」みたいな話も出てきます。

しかしこれらの実装方法は…明らかとは言えません。初期の初期には「容器の縁の最も低い場所から斜め下に向かってRaycastして容器にぶつかったらそこに水を追加する」とか考えていました。でもそれは自然に見えるでしょうか。制御できるでしょうか。その実装で本当に良いんでしょうか。「容積を持った水流」という概念は、表示するのも実装するのも大変そうで… 私はやりたくなかった。

そこで別解釈を考えました。「水の中に完全に含まれた領域は、水が溜まったことになる」。これだけならなんとかなる可能性があったのです。そして逆に「宙を介した伝播」が出来ないことから、溢れた水は作らないことにしました。…作るのが大変そうだったからという側面の方が強いですけど。

そしてこれは機能的にも十分で、大きなバケツに水を汲んでおけばわざわざ海まで汲みに行かなくて良いわけで、バケツの抽象としての存在を全うできます。水を汲んだときに元の容器の水量が減らないことにすればこれをいくらでも続けられます。水量が減らないのは「減るように実装するのが困難だから」というのもあったわけですが2、便利でもあるのでそれを止める人はあんまり居ないと…思いました。

 
というわけで仕様は次です。

  • 海面より下に縁が来ているとき、水位は海面まで上昇する
  • 容器に溜まった水は水源として振る舞う
  • 「縁の高さより下の領域」が完全に水源に内包されるとき、水位は水源の高さまで上昇する
  • 縁の最も低い場所まで水位は下がる
    • (これは上のルールよりも強いです)
  • 容器が下を向いてたら水は無くなる

そしてもう一つ。「水が溜まり得る場所は、円錐台形のみ」という仕様があります。これはいろんな計算を簡略化するためにお願いしたもので…。バケツや缶・皿にも対応できるので大丈夫だろう、と思って仕様に入れました。これによって四角形のシンクが作れなくなったんですが3、まぁシンクの役割の代わりはあるのでいいかな…。あと一斗缶は開いてると困るので蓋を閉めてもらいました。

溜まり得るというのは文字通りの意味で、食器の形状に「水が溜まりそうな場所」があると溜められるべきだと言えます。なので円錐台形以外でそういう形状が出来てしまうことを避けてもらうようCapにお願いしていたのです。

というわけでこれをやっていきます。

気づいてもらう

この機能、何も言わないとさすがに気づかれない可能性があります。まぁそれでも世界進行には困らないんですけど… でも世界的に大事な成分の一つです。そして、勿論説明しすぎるのは良くないです。もしかして…と思った人が自主的に気づいたらそれに越したことはありません。

というわけで、示唆が2つあります。1つはメインフロアのバルブとバケツ。
image.png
そういえばこれはバルブから水が出ることの示唆にもなっていましたね。書くの忘れてました。どちらかというとそっちの側面の方が強いかな…。テーマに合わせて時折ぽちゃんと鳴るの、とても良いです。

そしてもう1つがこれ。
image.png
汲めるかも?と思っても、そこに水源がなければ・容器が無ければ試すこと自体ができません。なので「水源の近くに容器のある状況」を成立させる必要がありました。ということで、なんとなくここに来た人がなんとなく手に取ってくれるようにここにバケツが置いてあります。ちょっと水も入っていて、汲めるかも?と思うに足る発想としては十分ではないかなと思っています。

空のバケツにすると単純に持って帰っちゃう可能性もあって… (まぁ実際そういうことはあるんでしょうけど)。でももっと貯めちゃうとあからさますぎるかな…?という感じ。もうちょっと建物近くに置くのもまぁありかなとか考えてたことはありましたけど、わりと絵として綺麗なのでこれで決定することにしました。

ちなみに溜まった水の示唆はこの段階で既にやってます。全体的に「バケツ」は一貫して水の示唆として使っていますね。元々食器と違って水を貯めるもの、ですからね。

実装

はい。やります。

形状定義

先の通り円錐台形のみに対して考えれば良いということになっています。しました。
image.png
これは回転体として考えることができて、形状は4つの実数で表すことができます。Blenderでモデル開いて測定するなどしてました。その値は水面を司るUdonであるSurfaceに記録されています (prefab化されてる)。

また、楽器の違いを表す為にスケールを変えることがあります。特にuniform(3つのスケール値が全て同じ)なものだけでなく、縦方向だけ伸びてるものもあるんです。「意味が違う」ものはそれぞれ違うメッシュになっていますが、「ちょっとだけ違う音」くらいの差はスケール変化で対応しています4

というわけでSurfaceは自身のスケールを見て、この形状データを伸縮したりしてます。また描画時には逆にスケールが邪魔なのでそれを補正する為にスケール比をマテリアルパラメータに渡しています。

水面の高さ

まず「容器に入っている水は、水面の高さというパラメータのみで表せる」ということを前提として組んでいきます。

水量を表すのに「水面の高さ」を保存するのは悪手で、傾くと当然高さが変わってしまいます。同じ状態 (水量) であるなら同じデータを持つことが望ましい5と思います。なので素直に水量[m3]を保存することにしたんですが、ここから水面の高さを計算しなくてはいけません。

無理です。水平時でさえ積分の逆を解かなきゃいけないんです。ということでそれっぽければOKということにします。

明らかな挙動は「水が無い時の高さは容器の一番低い場所」「水が満杯のときの高さは容器の一番高い場所」です。というわけで、水の占める割合で以て最低点と最高点をlerpした点を取ることにしました。線形は一番単純な近似なので。
image.png

逆に、「ある水位まで持ち上げる」ようなケースでは水位から水量を計算する必要がありますが、線形なので素直に逆計算が出来ますね。

この近似は改善の余地があって、容器の傾きに応じて水の占める割合にちょっと関数を噛ますともうちょっとマシな水位になります。
実際砂時計の方はsinhとか使ってごちゃごちゃしてたんですけど、まぁこっちの場合はそこまで丁寧にやらなくてもいいかな…という気持ちで線形にしちゃいました。一応内部ではQuantFuncっていう関数を呼んでいます、恒等関数ですけど。
そして実はこれを前提にしていたので、水位から水量の計算が自明ではありませんでした。実際Surfaceのソースコードは二分法で逆関数を近似計算してます。単調なので。
まぁ、大したコストではないんですけどね…。折角なのでちょっと使ってあげたかったね。

容器の形状が回転体のみになっているので、容器の最低点と最高点は傾きの角度で確定します

Vector2[] vertices = new Vector2[3] {
    new Vector2(0, shape.y),
    new Vector2(shape.x, shape.y),
    new Vector2(shape.z, shape.w)
};
float tilt = (transform.rotation * Vector3.up).y;
float angle = Mathf.Acos(tilt);
float boundMin = 0, boundMax = 0, pourBound = 0;
for(int i=0;i<3;i++) {
    float p1 = vertices[i].x*Mathf.Sin(angle) + vertices[i].y*Mathf.Cos(angle);
    float p2 = - vertices[i].x*Mathf.Sin(angle) + vertices[i].y*Mathf.Cos(angle);
    if(i == 0) boundMin = boundMax = p1;
    else {
        boundMin = Mathf.Min(boundMin, p1);
        boundMax = Mathf.Max(boundMax, p1);
    }
    boundMin = Mathf.Min(boundMin, p2);
    boundMax = Mathf.Max(boundMax, p2);
    if(i == 2) {
        pourBound = Mathf.Min(p1, p2);
    }
}
float q = quantity / capacity;
float level = Mathf.Lerp(boundMin, boundMax, q);

ちなみにpourBoundっていうのはそのうち使われることになる「水が溢れる高さ」のことです。

包含判定

2つの容器の包含を判定する必要があります。とりあえず自明ではないです。

解析的に解くのもしんどそうなので、近似を考えます。内包される方は点群として捉えることにしました。凸だし。
image.png

各点たちに対して、まず「容器に含まれているか」を計算します。点を一度ローカル空間に移してから…こう。
image.png
コードで書くとこう。

float rad = 0, y = lp.y;
float e = (y - sShape.y) / (sShape.w - sShape.y);
rad = Mathf.Lerp(sShape.x, sShape.z, e);

if(y < sShape.y-0.001f) break;
if(sShape.w+0.001f < y) break;
if(rad+0.001f < new Vector2(lp.x,lp.z).magnitude) break;

1点でも容器からはみ出る点があったら判定をやめます。で、全て通過したら「水の中に入っているか」判定をするんですが、これは「現在の水位が、容器の溢れる高さよりも高い」ことで判定をしています。これでOK。

 
問題は点の生成の方で。
image.png

「容器の溢れる高さ (以降level) より下の部分を表す凸形状の点群」を作るんですが、ひたすら計算をする羽目になりました。

まず外周を作ります。とりあえず円は12角形で近似することにしました。levelより下にある頂点を追加します…が、足りません。
この水面の楕円部分が欲しかったので、「母線がlevelと交わる場合はその交点も追加する」ことにします。線形変換なので簡単。
image.png

…もうちょっと足りません。
image.png
この点も欲しいので計算をします。考え方は「水平面と底面の交差線のうち回転軸から一定半径離れた点」です。

Vector3 normal3 = rot * Vector3.up;
if(Mathf.Abs(normal3.y) < 0.9999) {
    Vector2 normal = new Vector2(normal3.x, normal3.z).normalized;
    Vector2 tangent = Vector2.Perpendicular(normal);
    Vector3 center = normal3 * shape.y * vScale + pos;
    float tan = Mathf.Sqrt(1 - normal3.y*normal3.y) / normal3.y; // tan(acos(normal.y))
    float radius = shape.x;
    float diff = center.y - level;
    Vector2 mid = normal * diff / tan;
    // |(center.xz + mid + t*tangent, level) - center.xzy| = radius
    // (t*tangent.x+mid.x)^2 + (t*tangent.y+mid.y)^2 + (level-center.y)^2 = radius^2
    // t^2 + t*2*(tangent.x*mid.x + tangent.y*mid.y) - (radius^2 - (level-center.y)^2 - mid.x^2 - mid.y^2) = 0
    // b = 2 * (tangent.x*normal.x + tangent.y*normal.y) * diff / tan = 0
    float c = Mathf.Pow(level-center.y, 2) + mid.sqrMagnitude - radius*radius;
    if(c < 0) {
        float t = Mathf.Sqrt(-c); // x^2 + c = 0
        Vector3 mid3 = new Vector3(center.x + mid.x, level, center.z + mid.y);
        Vector3 tan3 = new Vector3(tangent.x, 0, tangent.y) * t;
        points[pointCount+0] = mid3 + tan3;
        points[pointCount+1] = mid3 - tan3;
        pointCount += 2;
    }
}

image.png

なんかいい感じに計算するといい感じになります…。二次方程式を雑に立てたらめちゃくちゃ単純な形になったのでするっと解けた (これはつまりもっと便利な定式化の仕方があるということで…)。

そんなこんなで点が取れて、内包判定が出来るようになります。たいへんね…。まず図を書くのが大変だった。
image.png

 
こういう方法を取ろうという結論に至るまでがそこそこ時間掛かってるんですけどね、あんまりそういうのは覚えてないんですよね…。これもソースコード残ってるから思い出せるだけですし。

偶に算数できるようになってるとこういうことがあって便利です。たまにね…。

水位更新

もう後は単純です。

まず溜まっている水、水源のリストをWaterServerが管理しています。これは各々Surfaceが「水が無くなったら削除」「水が増えたら追加」を依頼しています。

で、pickupされているSurfaceは全水源に対して先程の包含判定を行い、条件が成立するものがあるかどうかを調べます。

もしも包含関係が成立するなら水位は必ず溢れる限界の高さまで来ます。外側の容器に関わらず。なので高さを更新、即ち水量を更新。また、自身の傾きによって「水位が溢れる高さよりも高い」場合には限界まで水位を下げます。

加えて海面の場合も水源と似たような処理をするんですが、この場合に限っては段々水が増えていくようになってます。理由はSurfaceの描画はZWrite Onなので「水面と水面」だと高い方しか見えないんですが、海面はZWrite Offになっているので海中にある容器の水面が見えてしまうからです。急に変化するのは良くない気持ちがあるので段々増えるようにしました。

ちょこっと不思議な見え方にはなってますけど海の特別性として解釈可能かな、と思っています。私は。

 
あと同期は水量を直接UdonSyncedにすることで行っています。水量が更新されるのはpickupされた時だけなので、それはownerが行うはずです6。owner以外は水量変化を直接受け取るだけなので、これで完璧です。多分。

描画

まずあの水面は…こうなってます。
image.png
元々は唯のQuadで、頂点シェーダで「回転軸と水面の交点」を中心とした十分大きな矩形として範囲を置いています7。回転周りの計算とかそこそこ面倒なんですけどまぁ頑張ります…。

水面の高さはUdonと同様に水量から計算しています。この時に「溢れる高さ」を越えないようにminを取ったりもしてますね。

で、容器に収まるようにclipします。これは容器の形状をそのままパラメータとして貰っておいて各ピクセルで計算することで行っています。

void clipWater(float3 lp) {
    float2 vertices[3] = {
        float2(0, _Shape.y),
        _Shape.xy,
        _Shape.zw
    };
    float rad = 0, y = lp.y;
    for(int i=0;i<2;i++) {
        if(vertices[i].y < y && y <= vertices[i+1].y) {
            float e = (y - vertices[i].y) / (vertices[i+1].y - vertices[i].y);
            rad = lerp(vertices[i].x, vertices[i+1].x, e);
        }
    }
    if(y <= vertices[0].y) rad = -1.0;
    if(vertices[2].y < y) rad = vertices[2].x;
    clip(rad - length(lp.xz));
    if(vertices[2].y+0.001 < y) clip(-1.0);
}

後はいい感じに色をつけます。DropRain説明したしたのと9割は共通で、あとは「縁に近い部分は白に近づけてnormalも平面にする」くらいでしょうか。

あとZWrite Onになっているのはこういう理由ですね。しないと雨が貫通しちゃいます。
image.png
FallRainSurfaceの存在を知らないので本当に貫通しちゃうんです (実際楽器は平気で貫通しています)。海はZWrite Onですけど、代わりに高さが明らかなのでそこで波紋を出せていて綺麗になってます。

さすがに容器の中に水滴が落ちてる図は微妙だと思ったので切ることにした、というわけです。寧ろ切る方法が存在していてよかった…。あ、でも服とかは一部消えちゃうかも。まぁあんまり「容器の水面を通してアバターを見る」ことは無いと思うんでクリティカルでは無いでしょう…。きっと。

水面を揺らす

揺れてほしいので揺らしました。特に書かなくてもいいかなと思ったんだけど多少書くことにします。

これは 飯盒が揺れる仕組みバケツを揺らす記事 と勿論似てはいるんですけど、色々と簡略化されています。というのも、水面は私達の制御対象なので予測できない振る舞いをあまりしてほしくなかったからです。容器自体を移動したときには本来力が掛かるはずなんですが完全に無視していて、これによって持ち運びしやすくなっています。というか最初はちゃんと実装したんだけどあまりにも溢れるのでやめたんです。

保持してる情報は回転量 (Quaternion)waterRot角速度 (Vector3)waterVelです。静止状態ではwaterRottransform.rotationに、waterVelVector3.zeroに近づいていきます。水面の向きではなく回転量をそのまま保存しているのは「容器を持ってプレイヤーが回転した時」とかに水がくるっと回るのが割とかわいかったからです。というかまぁそうなるよな、とは思った。

さて、単位Quaternionの成す空間は御存知の通り3次元のリー群になっており、接空間となるリー代数は3次元実交代行列の成す空間となります。これは回転ベクトルの成す空間と同型なので、角速度を回転ベクトル表現しておくことで加算とスカラー倍が出来るようになります8

private Vector3 ToRotVector(Quaternion q) {
    if(Mathf.Abs(q.w) > 1 - 0.00001f) return Vector3.zero;
    float angle = 2 * Mathf.Acos(Mathf.Abs(q.w));
    return new Vector3(q.x, q.y, q.z).normalized * angle * (q.w < 0 ? -1 : 1);
}
private Quaternion FromRotVector(Vector3 v) {
    if(v.magnitude < 0.00001f) return Quaternion.identity;
    return Quaternion.AngleAxis(v.magnitude * Mathf.Rad2Deg, v.normalized);
}

微小変化として扱うときは回転ベクトル上で計算して、回転に反映するときはQuaternionに戻す感じで。で、いい感じに処理を書いてあげます。

Vector3 waterAcc = ToRotVector(transform.rotation * Quaternion.Inverse(waterRot));
waterVel += waterAcc * 0.4f;
waterVel *= 0.85f;
waterRot *= FromRotVector(waterVel * 0.1f);
waterRot = Quaternion.Slerp(waterRot, transform.rotation, 0.2f);

こういうのはほぼ気分で書いているので正確なことは言えないんですが… 水平に戻るための量を速度に足し込むことでバネっぽい振る舞いをさせて、ついでに減衰 (0.85f) によって安定させています。それを回転量に適用しつつ、それでも安定しなかったので回転量を無理やり水平に戻す処理が入っています。…くるって回すとすごい速度で水が回りだして消え去る、みたいな現象を起こさないようにするのにそこそこ時間が掛かりました。初期に起きてました。

ちなみにこの辺の処理にはTime.deltaTimeを本来使うべきなんですが、ラグった時に何が起きるかわかんないという点があって敢えて使っていません。それこそそういう発散っぽい動きをされると困るので。水面は全体的に「安定しつつ見た目の豊かさを与える」くらいの揺れに押さえています。

で、ここで計算した回転量を使って実際には描画とか水位の計算をやっています。ただ内包判定とか衝突判定とかに使うとちょっと怖いかなと思ったのでそちらではtransform.rotationを直接参照していますね。

 
そんなとこかな。あ、あとwaterVelの大きさに応じて水面のbump量を変えたりしてた、そういえば。

まとめ

このツイートの裏ではこんなことが行われていました。という記事でした。

元々は「水が汲めると思うから汲む」くらいの気持ちで導入したわけですが、水音が鳴ることでちょっとした意味を持って。さらにバルブと組み合わせるといろんな音が出て楽しくて。最終的には電子楽器3の根幹として機能するという、めちゃくちゃお得…というかなんていうか。まぁ作っておいてよかったな感がすごい機能です。

ここでは色々と面倒なことをやっているので大変でしたけど、「容器に入った液体が段々減っていく」みたいなのはUdonと組み合わせれば結構気楽に作れるので一部の需要には適うんではないでしょうか…。(私の最初に作ったアレだとちょっと勿体ないというか、もっとrichな表現であるべきというか) 水位の計算も円筒程度ならすぐできますよね…多分。

まぁわかんないですけど。はい。

では… 次回は世界遷移のお話をします。ついでにいろんな話をする予定です。


  1. 同期は入ってないんですけど入れようと思えば入れられるはずです 

  2. 容器が水面から離れるほどに段々水位が減っていく、みたいな感じになる… (何故なら掬った瞬間というのは存在しないので) 

  3. 今考えてみれば計算自体はなんとかなる気がしてきた、容積一定なら 

  4. 開口部の面積と容積の比が音程に影響するっぽいんですけど丁寧には合わせてません 

  5. 微妙に揺れる度に水量が減ったりもしそうですし… 

  6. 正確にはPercussionをpickupしたときにSurfaceSetOwnerしています 

  7. ちなみにbounds調節の為にSkinnedMeshRendererを使っています。手作業で全楽器の水面のboundsを設定しました。 

  8. あってるよねこれ? 

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4